注册
环信即时通讯云

环信即时通讯云

单聊、群聊、聊天室...
环信开发文档

环信开发文档

Demo体验

Demo体验

场景Demo,开箱即用
RTE开发者社区

RTE开发者社区

汇聚音视频领域技术干货,分享行业资讯
技术讨论区

技术讨论区

技术交流、答疑
资源下载

资源下载

收集了海量宝藏开发资源
iOS Library

iOS Library

不需要辛辛苦苦的去找轮子, 这里都有
Android Library

Android Library

不需要辛辛苦苦的去找轮子, 这里都有

2023市场需求最大的8种编程语言出炉!

众所周知,编程语言的种类实在是太多了。直到现在,经常还会看到关于编程语言选择和学习的讨论。 虽说编程语言有好几百种,但实际项目使用和就业要求的主流编程语言却没有那么多。 大家可能也会好奇:现如今就业市场上到底什么编程语言最受欢迎?或者说最需要的编程语言是什么?...
继续阅读 »

众所周知,编程语言的种类实在是太多了。直到现在,经常还会看到关于编程语言选择和学习的讨论。


虽说编程语言有好几百种,但实际项目使用和就业要求的主流编程语言却没有那么多。


大家可能也会好奇:现如今就业市场上到底什么编程语言最受欢迎?或者说最需要的编程语言是什么?


所以今天我们就结合Devjobsscanner之前发布的「Top 8 Most Demanded Programming Languages in 2023」编程语言清单来聊一聊这个问题。



虽说这个清单并不是完全针对我们本土开发者的调查,但还是能反映一些趋势的,大家顺带也可以参看一下各种编程语言的发展趋势和前景。


Devjobsscanner是一个综合性开发者求职/岗位信息聚合网站。



上面聚合展示了很多开发者求职岗位信息,并按多种维度进行分类,以便用户进行搜索。



该网站每年都会发布一些相关方面的调查总结报告,以反映开发者求职方面的趋势。


从2022年1月到2023年5月,这17个月的时间里,Devjobsscanner分析了超过1400万个开发岗位,并从中进行筛选和汇编,并总结出了一份「2023年需求量最大的8种编程语言」榜单。


所以下面我们就来一起看一看。


No.1 JavaScript/TypeScript


基本和大家所预想到的一样,Javascript今年继续蝉联,成为目前需求最大的编程语言。



当然这也不难理解,因为基本上有Web、有前端、有浏览器、有客户端的地方都有JavaScript的身影。


而且近几年TypeScript的流行程度和需求量都在大增,很多新的前端框架或者Web框架都是基于TypeScript编写的。


所以学习JavaScript/TypeScript作为自己的主语言是完全没有问题的。



  • 职位数量/占比变化趋势图



No.2 Python


榜单上排名第二的是Python编程语言。



众所周知,Python的应用范围非常广泛。


从后端开发到网络爬虫,从自动化运维到数据分析,另外最近这些年人工智能领域也持续爆火,而这恰恰也正是Python活跃和擅长的领域。


尤其最近几年,Python强势上扬,这主要和这几年的数据分析和挖掘、人工智能、机器学习等领域的繁荣有着不小的关系。



  • 职位数量/占比变化趋势图



No.3 Java


榜单中位于第三需求量的编程语言则是Java。



自1995年5月Java编程语言诞生以来,Java语言的流行程度和使用频率就一直居高不下,并且在就业市场上的“出镜率”很高。


所以每次调查结果出来,Java基本都榜上有名,而且基本长年都维持在前三。


Java可以说是构成当下互联网繁荣生态的重要功臣,无数的Web后端、互联网服务、移动端开发都是Java语言的领地。



  • 职位数量/占比变化趋势图



No.4 C#



看到C#在榜单上位列前四的那会,说实话还是挺意外的,毕竟自己周围的同学和同事做C#这块相对来说还是较少的。


但是C#作为一种通用、多范式、面向对象的编程语言,在很多领域其实应用得还是非常广泛的。


我们都知道,其实像.NET和Unity等框架在不少公司里都很流行的,而C#则会被大量用于像Unity等框架的项目编写。



  • 职位数量/占比变化趋势图



No.5 PHP



看到PHP在榜单上位列第五的时候,不禁令人又想起了那句梗:


不愧是最好的编程语言(手动doge)。


所以以后可不能再黑PHP了,看到没,这职位数量和占比还是非常高的。



  • 职位数量/占比变化趋势图



No.6 C/C++


C语言和C++可以说都是久经考验的编程语言了。



C语言于1972年诞生于贝尔实验室,距今已经有50多年了。


自诞生之日起,C语言就凭借其灵活性、细粒度和高性能等特性获得了无可替代的位置,而且随着如今的万物互联的物联网(IoT)时代的兴起,C语言地位依然很稳。


C语言和C++的应用领域都非常广泛,在一些涉及嵌入式、物联网、操作系统、以及各种和底层打交道的场景下都有着不可或缺的存在意义。



  • 职位数量/占比变化趋势图



No.7 Ruby


Ruby这门编程语言平时的出镜率虽然不像Java、Python那样高,但其实Ruby的应用领域还是挺广的,在包括Web开发、移动和桌面应用开发、自动化脚本、游戏开发等领域都有着广泛的应用。



Ruby在20世纪90年代初首发,并在2000年代初开始变得流行。


Ruby是一种动态且面向对象的编程语言,语法简单易学,使用也比较灵活,因此也吸引了一大批爱好者。



  • 职位数量/占比变化趋势图



No.8 GO


虽说Go语言是一个非常年轻的编程语言(由谷歌于2009年对外发布),不过Go语言最近这几年来的流行程度还是在肉眼可见地增加,国内外不少大厂都在投入使用。



众所周知,Go语言在编译、并发、性能、效率、易用性等方面都有着不错的表现,也因此吸引了一大批学习者和使用者。



  • 职位数量/占比变化趋势图



完整表单


最后我们再来全局看一看Devjobsscanner给出的编程语言完整表单和职位数量/占比的趋势图。




不难看出,JavaScript、Python和Java这三门语言在就业市场上的需求量和受欢迎程度都很大,另外像C语言、C#、Go语言的市场岗位需求也非常稳定。


总体来说,选择清单里的这些编程语言来作为自己的就业主语言进行学习和精进都是没有问题的。


说到底,编程语言没有所谓的好坏优劣,而最终选择什么,还是得看自己的学习兴趣以及使用的场景和需求。


作者:CodeSheep
来源:juejin.cn/post/7316968265057828874
收起阅读 »

防御性编程?这不就来了

最近程序员界又重新流行起来了防御性编程这个概念,早期嘞,这个概念代表是一种细致、谨慎的编程方法。 防御性编程的目的是为了开发可靠的软件,我们在设计系统中每个组件的时候,都需要使其尽可能的 "保护" 自己。 但是 2023 年以来,国内的互联网市场是什么行情,相...
继续阅读 »

最近程序员界又重新流行起来了防御性编程这个概念,早期嘞,这个概念代表是一种细致、谨慎的编程方法。


防御性编程的目的是为了开发可靠的软件,我们在设计系统中每个组件的时候,都需要使其尽可能的 "保护" 自己。


但是 2023 年以来,国内的互联网市场是什么行情,相信大家都清楚,整个市场环境都在强调降本增效、开猿节流。


因此为了体现程序员们在公司代码中的不可替代性?防止被裁。"防御性编程" 概念又重新流行了起来。


不过这次它可不再是保护程序了,而是保护广大程序员群体 😎。



所以我就给大家介绍一下,新时代背景下的 "防御性" 编程理念,如何实践 😜。


本文大纲如下,



代码书写


变量名称使用单一字符


Java 语言里变量名只能由 Unicode 字母、数字、下划线或美元符号组成,并且第一个字符不能是数字


那么对于单一字符的变量名称来说,26 个字母大写加 26 个字母小写加下划线以及美元符一共有 54 种变量名称,想一想难道这些还不够你在单个 Java 文件里给变量命名用吗?


兄弟这一般够用了。


使用中文命名


兄弟,大家都是中国人,肯定看得懂中文咯。



就问你,Idea 支不支持吧,有没有提示说你变量名不规范嘛!没提示就是规范。



还有一点,兄弟们,还记得上面 Java 语言里变量名组成规范吗?中文也在 Unicode 编码里面,所以其实我们还可以用中文作为变量名称。


我已经帮你查好了,Java 里常用的 utf-8 编码下,支持的中文字符有 20902 个,所以上面单一字符的变量名称还需要新增 20902 种 😃,简直完美。



使用多国语言命名



不多说,我就问你看不看得懂吧,看得懂算你厉害,看不懂算你技术不行。



你问我看不看得懂,我当然看的懂,我写的,我请百度翻译的 😝。





这些变量名称命名法则,不仅适用与 Java,也适用于 JavaScript,广大前端程序员也有福了。


CV 大法


不要抽象、不要封装、不要继承、不要组合,我只会 CV。


抽象



抽象:我可以让调用者只需要关心方法提供了哪些功能,而不需要知道这些功能是如何实现的。我的好处是可以减少信息的复杂度,提高代码的可读性和易用性,也方便了代码的修改和扩展,我厉害吧。


我:我只会 CV。


抽象:...



封装



封装:我可以把数据和基于数据的操作封装在一起,使其构成一个独立的实体,对外只暴露有限的访问接口,保护内部的数据不被外部随意访问和修改。我的好处是可以增强数据的安全性和一致性,减少代码的耦合性,也提高了类的易用性。看见没,我比抽象好懂吧。


我:我只会 CV。


封装:...



继承



继承:我可以让一个类继承另一个类的属性和方法,从而实现代码的复用和扩展。我可以表示类之间的 is-a 关系,体现了类的层次结构和分类。我的好处是可以避免代码的重复,简化类的定义,也增加了代码的维护性。我可是面向对象三大特征之一。


我:我只会 CV。


继承:...



组合



组合:我可以让一个类包含另一个类的对象作为自己的属性,从而实现代码的复用和扩展。我可以表示类之间的 has-a 关系,体现了类的关联和聚合。我的好处是可以增加类的灵活性和可变性,也降低了类之间的耦合性。不要用继承,我可是比继承更优秀的。


我:我只会 CV。


组合:...



不要问为什么我只会 CV,因为我的键盘只有 CV。



刚出道时我们嘲讽 CV,后来逐渐理解 CV,最后我们成为 CV。


CV 的越多,代码就越复杂,代码越复杂,同事就越难看懂,同事越难看懂,就越难接手你的代码,你的不可替代性就越来越强。


那么我们防御性编程的目的不久达到了嘛。


兄弟,听我说,给你的代码上防御,是为了你好!



产品开发


运营配置、开发配置、系统配置直接写死,用魔法值,没毛病。


产品每次提需求,代码实现一定要做到最小细粒度实现,做到需求里少一个字,我的代码里绝不会多一个词,注释也是不可能有的,我写的代码只有我看得懂不是防御性编程的基操吗?


我的代码我做主。


产品原型不提,我绝对不会问。要做到这系统有你才能每一次发版上线都是相安无事,一旦缺少了你,鬼知道会发生什么。


我们能做的就是牢牢把握项目中核心成员的位置。这个项目组少了你,绝对不行!



最后聊两句


2023 全年都在降本增效,节能开猿的浪潮下度过。


虽然本文是给大家讲防御性编程如何实践,但终究只是博君一笑,请勿当真。


这里我还是希望每一个互联网打工人都能平稳度过这波寒冬。


积蓄力量,多思考,多元发展。


在来年,春暖花开,金三银四之月,都能找到自己满意的工作,得到属于自己的果实。



关注公众号【waynblog】,每周分享技术干货、开源项目、实战经验、高效开发工具等,您的关注将是我的更新动力😘。



作者:waynaqua
来源:juejin.cn/post/7312376672665075722
收起阅读 »

使用双异步后,从 191s 优化到 2s

大家好,我是哪吒。 在开发中,我们经常会遇到这样的需求,将Excel的数据导入数据库中。 一、一般我会这样做: 通过POI读取需要导入的Excel; 以文件名为表名、列头为列名、并将数据拼接成sql; 通过JDBC或mybatis插入数据库; 操作起来,...
继续阅读 »

大家好,我是哪吒。


在开发中,我们经常会遇到这样的需求,将Excel的数据导入数据库中。


一、一般我会这样做:



  1. 通过POI读取需要导入的Excel;

  2. 以文件名为表名、列头为列名、并将数据拼接成sql;

  3. 通过JDBC或mybatis插入数据库;



操作起来,如果文件比较多,数据量都很大的时候,会非常慢。


访问之后,感觉没什么反应,实际上已经在读取 + 入库了,只是比较慢而已。


读取一个10万行的Excel,居然用了191s,我还以为它卡死了呢!


private void readXls(String filePath, String filename) throws Exception {
@SuppressWarnings("resource")
XSSFWorkbook xssfWorkbook = new XSSFWorkbook(new FileInputStream(filePath));
// 读取第一个工作表
XSSFSheet sheet = xssfWorkbook.getSheetAt(0);
// 总行数
int maxRow = sheet.getLastRowNum();

StringBuilder insertBuilder = new StringBuilder();

insertBuilder.append("insert int0 ").append(filename).append(" ( UUID,");

XSSFRow row = sheet.getRow(0);
for (int i = 0; i < row.getPhysicalNumberOfCells(); i++) {
insertBuilder.append(row.getCell(i)).append(",");
}

insertBuilder.deleteCharAt(insertBuilder.length() - 1);
insertBuilder.append(" ) values ( ");

StringBuilder stringBuilder = new StringBuilder();
for (int i = 1; i <= maxRow; i++) {
XSSFRow xssfRow = sheet.getRow(i);
String id = "";
String name = "";
for (int j = 0; j < row.getPhysicalNumberOfCells(); j++) {
if (j == 0) {
id = xssfRow.getCell(j) + "";
} else if (j == 1) {
name = xssfRow.getCell(j) + "";
}
}

boolean flag = isExisted(id, name);
if (!flag) {
stringBuilder.append(insertBuilder);
stringBuilder.append('\'').append(uuid()).append('\'').append(",");
for (int j = 0; j < row.getPhysicalNumberOfCells(); j++) {
stringBuilder.append('\'').append(value).append('\'').append(",");
}
stringBuilder.deleteCharAt(stringBuilder.length() - 1);
stringBuilder.append(" )").append("\n");
}
}

List<String> collect = Arrays.stream(stringBuilder.toString().split("\n")).collect(Collectors.toList());
int sum = JdbcUtil.executeDML(collect);
}

private static boolean isExisted(String id, String name) {
String sql = "select count(1) as num from " + static_TABLE + " where ID = '" + id + "' and NAME = '" + name + "'";
String num = JdbcUtil.executeSelect(sql, "num");
return Integer.valueOf(num) > 0;
}

private static String uuid() {
return UUID.randomUUID().toString().replace("-", "");
}

二、谁写的?拖出去,斩了!


优化1:先查询全部数据,缓存到map中,插入前再进行判断,速度快了很多。


优化2:如果单个Excel文件过大,可以采用 异步 + 多线程 读取若干行,分批入库。



优化3:如果文件数量过多,可以采一个Excel一个异步,形成完美的双异步读取插入。



使用双异步后,从 191s 优化到 2s,你敢信?


下面贴出异步读取Excel文件、并分批读取大Excel文件的关键代码。


1、readExcelCacheAsync控制类


@RequestMapping(value = "/readExcelCacheAsync", method = RequestMethod.POST)
@ResponseBody
public String readExcelCacheAsync() {
String path = "G:\\测试\\data\\";
try {
// 在读取Excel之前,缓存所有数据
USER_INFO_SET = getUserInfo();

File file = new File(path);
String[] xlsxArr = file.list();
for (int i = 0; i < xlsxArr.length; i++) {
File fileTemp = new File(path + "\\" + xlsxArr[i]);
String filename = fileTemp.getName().replace(".xlsx", "");
readExcelCacheAsyncService.readXls(path + filename + ".xlsx", filename);
}
} catch (Exception e) {
logger.error("|#ReadDBCsv|#异常: ", e);
return "error";
}
return "success";
}

2、分批读取超大Excel文件


@Async("async-executor")
public void readXls(String filePath, String filename) throws Exception {
@SuppressWarnings("resource")
XSSFWorkbook xssfWorkbook = new XSSFWorkbook(new FileInputStream(filePath));
// 读取第一个工作表
XSSFSheet sheet = xssfWorkbook.getSheetAt(0);
// 总行数
int maxRow = sheet.getLastRowNum();
logger.info(filename + ".xlsx,一共" + maxRow + "行数据!");
StringBuilder insertBuilder = new StringBuilder();

insertBuilder.append("insert int0 ").append(filename).append(" ( UUID,");

XSSFRow row = sheet.getRow(0);
for (int i = 0; i < row.getPhysicalNumberOfCells(); i++) {
insertBuilder.append(row.getCell(i)).append(",");
}

insertBuilder.deleteCharAt(insertBuilder.length() - 1);
insertBuilder.append(" ) values ( ");

int times = maxRow / STEP + 1;
//logger.info("将" + maxRow + "行数据分" + times + "次插入数据库!");
for (int time = 0; time < times; time++) {
int start = STEP * time + 1;
int end = STEP * time + STEP;

if (time == times - 1) {
end = maxRow;
}

if(end + 1 - start > 0){
//logger.info("第" + (time + 1) + "次插入数据库!" + "准备插入" + (end + 1 - start) + "条数据!");
//readExcelDataAsyncService.readXlsCacheAsync(sheet, row, start, end, insertBuilder);
readExcelDataAsyncService.readXlsCacheAsyncMybatis(sheet, row, start, end, insertBuilder);
}
}
}

3、异步批量入库


@Async("async-executor")
public void readXlsCacheAsync(XSSFSheet sheet, XSSFRow row, int start, int end, StringBuilder insertBuilder) {
StringBuilder stringBuilder = new StringBuilder();
for (int i = start; i <= end; i++) {
XSSFRow xssfRow = sheet.getRow(i);
String id = "";
String name = "";
for (int j = 0; j < row.getPhysicalNumberOfCells(); j++) {
if (j == 0) {
id = xssfRow.getCell(j) + "";
} else if (j == 1) {
name = xssfRow.getCell(j) + "";
}
}

// 先在读取Excel之前,缓存所有数据,再做判断
boolean flag = isExisted(id, name);
if (!flag) {
stringBuilder.append(insertBuilder);
stringBuilder.append('\'').append(uuid()).append('\'').append(",");
for (int j = 0; j < row.getPhysicalNumberOfCells(); j++) {
stringBuilder.append('\'').append(value).append('\'').append(",");
}
stringBuilder.deleteCharAt(stringBuilder.length() - 1);
stringBuilder.append(" )").append("\n");
}
}

List<String> collect = Arrays.stream(stringBuilder.toString().split("\n")).collect(Collectors.toList());
if (collect != null && collect.size() > 0) {
int sum = JdbcUtil.executeDML(collect);
}
}

private boolean isExisted(String id, String name) {
return ReadExcelCacheAsyncController.USER_INFO_SET.contains(id + "," + name);
}

4、异步线程池工具类


@Async的作用就是异步处理任务。



  1. 在方法上添加@Async,表示此方法是异步方法;

  2. 在类上添加@Async,表示类中的所有方法都是异步方法;

  3. 使用此注解的类,必须是Spring管理的类;

  4. 需要在启动类或配置类中加入@EnableAsync注解,@Async才会生效;


在使用@Async时,如果不指定线程池的名称,也就是不自定义线程池,@Async是有默认线程池的,使用的是Spring默认的线程池SimpleAsyncTaskExecutor。


默认线程池的默认配置如下:



  1. 默认核心线程数:8;

  2. 最大线程数:Integet.MAX_VALUE;

  3. 队列使用LinkedBlockingQueue;

  4. 容量是:Integet.MAX_VALUE;

  5. 空闲线程保留时间:60s;

  6. 线程池拒绝策略:AbortPolicy;


从最大线程数可以看出,在并发情况下,会无限制的创建线程,我勒个吗啊。


也可以通过yml重新配置:


spring:
task:
execution:
pool:
max-size: 10
core-size: 5
keep-alive: 3s
queue-capacity: 1000
thread-name-prefix: my-executor

也可以自定义线程池,下面通过简单的代码来实现以下@Async自定义线程池。


@EnableAsync// 支持异步操作
@Configuration
public class AsyncTaskConfig {

/**
* com.google.guava中的线程池
* @return
*/

@Bean("my-executor")
public Executor firstExecutor() {
ThreadFactory threadFactory = new ThreadFactoryBuilder().setNameFormat("my-executor").build();
// 获取CPU的处理器数量
int curSystemThreads = Runtime.getRuntime().availableProcessors() * 2;
ThreadPoolExecutor threadPool = new ThreadPoolExecutor(curSystemThreads, 100,
200, TimeUnit.SECONDS,
new LinkedBlockingQueue<>(), threadFactory);
threadPool.allowsCoreThreadTimeOut();
return threadPool;
}

/**
* Spring线程池
* @return
*/

@Bean("async-executor")
public Executor asyncExecutor() {
ThreadPoolTaskExecutor taskExecutor = new ThreadPoolTaskExecutor();
// 核心线程数
taskExecutor.setCorePoolSize(24);
// 线程池维护线程的最大数量,只有在缓冲队列满了之后才会申请超过核心线程数的线程
taskExecutor.setMaxPoolSize(200);
// 缓存队列
taskExecutor.setQueueCapacity(50);
// 空闲时间,当超过了核心线程数之外的线程在空闲时间到达之后会被销毁
taskExecutor.setKeepAliveSeconds(200);
// 异步方法内部线程名称
taskExecutor.setThreadNamePrefix("async-");

/**
* 当线程池的任务缓存队列已满并且线程池中的线程数目达到maximumPoolSize,如果还有任务到来就会采取任务拒绝策略
* 通常有以下四种策略:
* ThreadPoolExecutor.AbortPolicy:丢弃任务并抛出RejectedExecutionException异常。
* ThreadPoolExecutor.DiscardPolicy:也是丢弃任务,但是不抛出异常。
* ThreadPoolExecutor.DiscardOldestPolicy:丢弃队列最前面的任务,然后重新尝试执行任务(重复此过程)
* ThreadPoolExecutor.CallerRunsPolicy:重试添加当前的任务,自动重复调用 execute() 方法,直到成功
*/

taskExecutor.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy());
taskExecutor.initialize();
return taskExecutor;
}
}


5、异步失效的原因



  1. 注解@Async的方法不是public方法;

  2. 注解@Async的返回值只能为void或Future;

  3. 注解@Async方法使用static修饰也会失效;

  4. 没加@EnableAsync注解;

  5. 调用方和@Async不能在一个类中;

  6. 在Async方法上标注@Transactional是没用的,但在Async方法调用的方法上标注@Transcational是有效的;


三、线程池中的核心线程数设置问题


有一个问题,一直没时间摸索,线程池中的核心线程数CorePoolSize、最大线程数MaxPoolSize,设置成多少,最合适,效率最高。


借着这个机会,测试一下。


1、我记得有这样一个说法,CPU的处理器数量


将核心线程数CorePoolSize设置成CPU的处理器数量,是不是效率最高的?


// 获取CPU的处理器数量
int curSystemThreads = Runtime.getRuntime().availableProcessors() * 2;

Runtime.getRuntime().availableProcessors()获取的是CPU核心线程数,也就是计算资源。



  • CPU密集型,线程池大小设置为N,也就是和cpu的线程数相同,可以尽可能地避免线程间上下文切换,但在实际开发中,一般会设置为N+1,为了防止意外情况出现线程阻塞,如果出现阻塞,多出来的线程会继续执行任务,保证CPU的利用效率。

  • IO密集型,线程池大小设置为2N,这个数是根据业务压测出来的,如果不涉及业务就使用推荐。


在实际中,需要对具体的线程池大小进行调整,可以通过压测及机器设备现状,进行调整大小。


如果线程池太大,则会造成CPU不断的切换,对整个系统性能也不会有太大的提升,反而会导致系统缓慢。


我的电脑的CPU的处理器数量是24。


那么一次读取多少行最合适呢?


测试的Excel中含有10万条数据,10万/24 = 4166,那么我设置成4200,是不是效率最佳呢?


测试的过程中发现,好像真的是这样的。


2、我记得大家都习惯性的将核心线程数CorePoolSize和最大线程数MaxPoolSize设置成一样的,都爱设置成200。


是随便写的,还是经验而为之?


测试发现,当你将核心线程数CorePoolSize和最大线程数MaxPoolSize都设置为200的时候,第一次它会同时开启150个线程,来进行工作。


这个是为什么?


3、经过数十次的测试



  1. 发现核心线程数好像差别不大

  2. 每次读取和入库的数量是关键,不能太多,因为每次入库会变慢;

  3. 也不能太少,如果太少,超过了150个线程,就会造成线程阻塞,也会变慢;


四、通过EasyExcel读取并插入数据库


EasyExcel的方式,我就不写双异步优化了,大家切记陷入低水平勤奋的怪圈。


1、ReadEasyExcelController


@RequestMapping(value = "/readEasyExcel", method = RequestMethod.POST)
@ResponseBody
public String readEasyExcel() {
try {
String path = "G:\\测试\\data\\";
String[] xlsxArr = new File(path).list();
for (int i = 0; i < xlsxArr.length; i++) {
String filePath = path + xlsxArr[i];
File fileTemp = new File(path + xlsxArr[i]);
String fileName = fileTemp.getName().replace(".xlsx", "");
List<UserInfo> list = new ArrayList<>();
EasyExcel.read(filePath, UserInfo.class, new ReadEasyExeclAsyncListener(readEasyExeclService, fileName, batchCount, list)).sheet().doRead();
}
}catch (Exception e){
logger.error("readEasyExcel 异常:",e);
return "error";
}
return "suceess";
}

2、ReadEasyExeclAsyncListener


public ReadEasyExeclService readEasyExeclService;
// 表名
public String TABLE_NAME;
// 批量插入阈值
private int BATCH_COUNT;
// 数据集合
private List<UserInfo> LIST;

public ReadEasyExeclAsyncListener(ReadEasyExeclService readEasyExeclService, String tableName, int batchCount, List<UserInfo> list) {
this.readEasyExeclService = readEasyExeclService;
this.TABLE_NAME = tableName;
this.BATCH_COUNT = batchCount;
this.LIST = list;
}

@Override
public void invoke(UserInfo data, AnalysisContext analysisContext) {
data.setUuid(uuid());
data.setTableName(TABLE_NAME);
LIST.add(data);
if(LIST.size() >= BATCH_COUNT){
// 批量入库
readEasyExeclService.saveDataBatch(LIST);
}
}

@Override
public void doAfterAllAnalysed(AnalysisContext analysisContext) {
if(LIST.size() > 0){
// 最后一批入库
readEasyExeclService.saveDataBatch(LIST);
}
}

public static String uuid() {
return UUID.randomUUID().toString().replace("-", "");
}
}

3、ReadEasyExeclServiceImpl


@Service
public class ReadEasyExeclServiceImpl implements ReadEasyExeclService {

@Resource
private ReadEasyExeclMapper readEasyExeclMapper;

@Override
public void saveDataBatch(List<UserInfo> list) {
// 通过mybatis入库
readEasyExeclMapper.saveDataBatch(list);
// 通过JDBC入库
// insertByJdbc(list);
list.clear();
}

private void insertByJdbc(List<UserInfo> list){
List<String> sqlList = new ArrayList<>();
for (UserInfo u : list){
StringBuilder sqlBuilder = new StringBuilder();
sqlBuilder.append("insert int0 ").append(u.getTableName()).append(" ( UUID,ID,NAME,AGE,ADDRESS,PHONE,OP_TIME ) values ( ");
sqlBuilder.append("'").append(ReadEasyExeclAsyncListener.uuid()).append("',")
.append("'").append(u.getId()).append("',")
.append("'").append(u.getName()).append("',")
.append("'").append(u.getAge()).append("',")
.append("'").append(u.getAddress()).append("',")
.append("'").append(u.getPhone()).append("',")
.append("sysdate )");
sqlList.add(sqlBuilder.toString());
}

JdbcUtil.executeDML(sqlList);
}
}

4、UserInfo


@Data
public class UserInfo {

private String tableName;

private String uuid;

@ExcelProperty(value = "ID")
private String id;

@ExcelProperty(value = "NAME")
private String name;

@ExcelProperty(value = "AGE")
private String age;

@ExcelProperty(value = "ADDRESS")
private String address;

@ExcelProperty(value = "PHONE")
private String phone;
}

作者:哪吒编程
来源:juejin.cn/post/7315730050577694720
收起阅读 »

慎用,Mybatis-Plus这个方法可能导致死锁

1 场景还原 1.1 版本信息 MySQL版本:5.6.36-82.1-log  Mybatis-Plus的starter版本:3.3.2 存储引擎:InnoDB 1.2 死锁现象 A同学在生产环境使用了Mybatis-Plus提供的 com.b...
继续阅读 »

1 场景还原


1.1 版本信息


MySQL版本:5.6.36-82.1-log 
Mybatis-Plusstarter版本:3.3.2
存储引擎:InnoDB

1.2 死锁现象



A同学在生产环境使用了Mybatis-Plus提供的 com.baomidou.mybatisplus.extension.service.IService#saveOrUpdate(T, com.baomidou.mybatisplus.core.conditions.Wrapper) 方法(以下简称B方法),并发场景下,数据库报了如下错误





2 为什么是间隙锁死锁?



如上图示,数据库报了死锁,那死锁场景千万种,为什么确定B方法是由于间隙锁导致的死锁?



2.1 什么是死锁?


两个事务互相等待对方持有的锁,导致互相阻塞,从而导致死锁。


2.2 什么是间隙锁?



  • 间隙锁是MySQL行锁的一种,与Record lock不同的是间隙锁锁定的是一个间隙。

  • 锁定规则如下:


MySQL会向左找第一个比当前索引值小的值,向右找第一个比当前索引值大 的值(没有则为正无穷),将此区间锁住,从而阻止其他事务在此区间插入数据。


2.3 MySQL为什么要引入间隙锁?


与Record lock组合成Next-key lock,在可重复读这种隔离级别下一起工作避免幻读。


2.4 间隙锁死锁分析


理论上一款开源的框架,经过了多年打磨,提供的方法不应该造成如此严重的错误,但理论仅仅是理论上,事实就是发生了死锁,于是我们开始了一轮深度排查。首先我们从这个方法的源码入手,源码如下:


    default boolean saveOrUpdate(T entity, Wrapper updateWrapper) {
        return this.update(entity, updateWrapper) || this.saveOrUpdate(entity);
    }

从源码上看此方法就没有按套路出牌,正常逻辑应该是首先执行查询,存在则修改,不存在则新增,但此方法上来就执行了修改。我们就猜想是不是MySQL在修改时增加了什么锁导致了死锁,于是我们找到了DBA获取了最新的死锁日志,即执行show engine innodb status,我们发现了两项关键信息如下:


*** (1) TRANSACTION:
...省略日志
*** (1) WAITING FOR THIS LOCK TO BE GRANTED:
RECORD LOCKS space id 0 page no 347 n bits 80 index `PRIMARY` of table `database_name`.`table_name` trx id 71C lock_mode X locks gap before rec insert intention waiting
  
*** (2) TRANSACTION:
...省略日志
*** (2) WAITING FOR THIS LOCK TO BE GRANTED:
RECORD LOCKS space id 0 page no 347 n bits 80 index `PRIMARY` of table `database_name`.`table_name` trx id 71D lock_mode X locks gap before rec insert intention waiting

简单翻译一下,就是事务一在获取插入意向锁时,需要等待间隙锁(事务二添加)释放,同时事务二在获取插入意向锁时,也在等待间隙锁释放(事务一添加), (本文不讨论MySQL在修改与插入时添加的锁,我们把修改时添加间隙锁,插入时获取插入意向锁为已知条件) 那我们回到B方法,并发场景下,是不是就很大几率会满足事务一和事务二相互等待对方持有的间隙锁,从而导致死锁。




现在我们理论有了,我们现在用真实数据来验证此场景。


2.5 验证间隙锁死锁



  • 准备如下表结构(以下简称验证一)


create table t_gap_lock(
id int auto_increment primary key comment '主键ID',
name varchar(64not null comment '名称',
age int not null comment '年龄'
comment '间隙锁测试表';


  • 准备如下表数据


mysql> select * from t_gap_lock;
+----+------+-----+
| id | name | age |
+----+------+-----+
|  1 | 张三 |  18 |
|  5 | 李四 |  19 |
|  6 | 王五 |  20 |
|  9 | 赵六 |  21 |
| 12 | 孙七 |  22 |
+----+------+-----+


  • 我们开启事务一,并执行如下语句,注意这个时候我们还没有提交事务


mysql> begin;
Query OK, 0 rows affected (0.00 sec)

mysql> update t_gap_lock t set t.age = 25 where t.id = 4;
Query OK, 0 rows affected (0.00 sec)
Rows matched: 0  Changed: 0  Warnings: 0


  • 同时我们开启事务二,并执行如下语句,事务二我们同样不提交事务


mysql> begin;
Query OK, 0 rows affected (0.00 sec)

mysql> update t_gap_lock t set t.age = 25 where t.id = 7;
Query OK, 0 rows affected (0.00 sec)
Rows matched: 0  Changed: 0  Warnings: 0


  • 接下来我们在事务一中执行如下语句


mysqlinsert int0 t_gap_lock(id, name, agevalue (7,'间隙锁7',27);  


  • 我们会发现事务一被阻塞了,然后我们执行以下语句看下当前正在锁的事务。


mysql> SELECT * FROM INFORMATION_SCHEMA.INNODB_LOCKS \G;
*************************** 1. row ***************************
    lock_id: 749:0:360:3
lock_
trx_id: 749
  lock_
mode: X,GAP
  lock_type: RECORD
 lock_
table: `test`.`t_gap_lock`
 lock_index: `PRIMARY`
 lock_
space: 0
  lock_page: 360
   lock_
rec: 3
  lock_data: 5
*************************** 2. row ***************************
    lock_
id: 74A:0:360:3
lock_trx_id: 74A
  lock_mode: X,GAP
  lock_
type: RECORD
 lock_table: `test`.`t_gap_lock`
 lock_
index: `PRIMARY`
 lock_space: 0
  lock_
page: 360
   lock_rec: 3
  lock_
data: 5
2 rows in set (0.00 sec)

根据lock_type和lock_mode我们可以很清晰的看到锁类型是行锁,锁模式是间隙锁。



  • 与此同时我们在事务二中执行如下语句


insert int0 t_gap_lock(idname, agevalue (4,'间隙锁4',24);


  • 一执行以上语句,数据库就立马报了死锁,并且回滚了事务二(可以在死锁日志中看到*** WE ROLL BACK TRANSACTION (2))


ERROR 1213 (40001): Deadlock found when trying to get locktry restarting transaction 



到这里,细心的同学就会发现,诶,你这上面故意造了一个间隙,并且让两个事务分别在对方的间隙中插入数据,太刻意了,生产环境基本上不会有这种场景,是的,生产环境怎么会有这种场景呢,上面的数据只是为了让大家直观的看到间隙锁的死锁过程,接下来那我们再来一组数据,我们简称验证二。



  • 我们还是以验证一的表结构与数据,我们来执行这样一个操作。首先我们开始开启事务一并且执行如下操作,依然不提交事务


mysql> begin;
Query OK, 0 rows affected (0.00 sec)

mysql> update t_gap_lock t set t.age = 25 where t.id = 4;
Query OK, 0 rows affected (0.00 sec)
Rows matched: 0  Changed: 0  Warnings: 0 


  • 同时我们开启事务二,执行与事务一一样的操作,我们会惊奇的发现,竟然也成功了。


mysql> begin;
Query OK, 0 rows affected (0.00 sec)

mysql> update t_gap_lock t set t.age = 25 where t.id = 4;
Query OK, 0 rows affected (0.00 sec)
Rows matched: 0  Changed: 0  Warnings: 0 


  • 于是乎我们在事务一执行如下操作,我们又惊奇的发现事务一被阻塞了。


insert int0 t_gap_lock(idname, agevalue (4,'间隙锁4',24);  


  • 在事务一被阻塞的同时,我们在事务二执行同样的语句,我们发现数据库立马就报了死锁。


insert int0 t_gap_lock(idname, agevalue (4,'间隙锁4',24);    
ERROR 1213 (40001): Deadlock found when trying to get locktry restarting transaction

验证二完整的复现了线上死锁的过程,也就是事务一先执行了更新语句,事务二在同一时刻也执行了更新语句,然后事务一发现没有更新到就去执行主键查询语句,发现确实没有,所以执行了插入语句,但是插入要先获取插入意向锁,在获取插入意向锁的时候发现这个间隙已经被事务二加锁了,所以事务一开始等待事务二释放间隙锁,同理,事务二也执行上述操作,最终导致事务一与事务二互相等待对方释放间隙锁,最终导致死锁。


验证二还说明了一个问题,就是间隙锁加锁是非互斥的,也就是事务一对间隙A加锁后,事务二依然可以给间隙A加锁。


3 如何解决?


3.1 关闭间隙锁(不推荐)



  • 降低隔离级别,例如降为提交读。

  • 直接修改my.cnf,将开关,innodb_locks_unsafe_for_binlog改为1,默认为0即开启


PS:以上方法仅适用于当前业务场景确实不关心幻读的问题。


3.2 自定义saveOrUpdate方法(推荐)


建议自己编写一个saveOrUpdate方法,当然也可以直接采用Mybatis-Plus提供的saveOrUpdate方法,但是根据源码发现,会有很多额外的反射操作,并且还添加了事务,大家都知道,MySQL单表操作完全不需要开事务,会增加额外的开销。


  @Transactional(
        rollbackFor = {Exception.class}
    )
    public boolean saveOrUpdate(T entity) {
        if (null == entity) {
            return false;
        } else {
            Class cls = entity.getClass();
            TableInfo tableInfo = TableInfoHelper.getTableInfo(cls);
            Assert.notNull(tableInfo, "error: can not execute. because can not find cache of TableInfo for entity!"new Object[0]);
            String keyProperty = tableInfo.getKeyProperty();
            Assert.notEmpty(keyProperty, "error: can not execute. because can not find column for id from entity!"new Object[0]);
            Object idVal = ReflectionKit.getFieldValue(entity, tableInfo.getKeyProperty());
            return !StringUtils.checkValNull(idVal) && !Objects.isNull(this.getById((Serializable)idVal)) ? this.updateById(entity) : this.save(entity);
        }
    }

4 拓展


4.1 如果两个事务修改是存在的行会发生什么?


在验证二中两个事务修改的都是不存在的行,都能加间隙锁成功,那如果两个事务修改的是存在的行,MySQL还会加间隙锁吗?或者说把间隙锁从锁间隙降为锁一行?带着疑问,我们执行以下数据验证,我们还是使用验证一的表和数据。



  • 首先我们开启事务一执行以下语句


mysql> begin;
Query OK, 0 rows affected (0.00 sec)

mysql> update t_gap_lock t set t.age = 25 where t.id = 1;
Query OK, 1 row affected (0.00 sec)
Rows matched: 1  Changed: 1  Warnings: 0


  • 我们再开启事务二,执行同样的语句,发现事务二已经被阻塞


mysql> begin;
Query OK, 0 rows affected (0.00 sec)

mysql
> update t_gap_lock t set t.age = 25 where t.id = 1;


  • 这个时候我们执行以下语句看下当前正在锁的事务。


mysql> SELECT * FROM INFORMATION_SCHEMA.INNODB_LOCKS \G;
*************************** 1. row ***************************
    lock_id: 75C:0:360:2
lock_
trx_id: 75C
  lock_
mode: X
  lock_type: RECORD
 lock_
table: `test`.`t_gap_lock`
 lock_index: `PRIMARY`
 lock_
space: 0
  lock_page: 360
   lock_
rec: 2
  lock_data: 1
*************************** 2. row ***************************
    lock_
id: 75B:0:360:2
lock_trx_id: 75B
  lock_mode: X
  lock_
type: RECORD
 lock_table: `test`.`t_gap_lock`
 lock_
index: `PRIMARY`
 lock_space: 0
  lock_
page: 360
   lock_rec: 2
  lock_
data: 1
2 rows in set (0.00 sec)

根据lock_type和lock_mode我们看到事务一和二加的锁变成了Record Lock,并没有再添加间隙锁,根据以上数据验证MySQL在修改存在的数据时会给行加上Record Lock,与间隙锁不同的是该锁是互斥的,即不同的事务不能同时对同一行记录添加Record Lock。


5 结语


虽然Mybatis-Plus提供的这个方法可能会造成死锁,但是依然不可否认它是一款非常优秀的增强框架,其提供的lambda写法在日常工作中极大的提高了我们的开发效率,所以凡事都用两面性,我们应该秉承辩证的态度,熟悉的方法尝试用,陌生的方法谨慎用。


以上就是我们在生产环境间隙锁死锁分析的全过程,如果大家觉得本文让你对间隙锁,以及间隙锁死锁有一点的了解,别忘记一键三连,多多支持转转技术,转转技术在未来将会给大家带来更多的生产实践与探索。


作者:转转技术团队
来源:juejin.cn/post/7311880893841719330
收起阅读 »

解决hutool图形验证码bug

从网上下载了一个开源的项目,发现登录界面的验证码无论怎么刷新,显示的都是同一个,即使换一个浏览器也是相同的图形验证码,说一下解决bug的过程 😶修改前的源代码如下(部分代码) import cn.hutool.captcha.*; import lombo...
继续阅读 »

从网上下载了一个开源的项目,发现登录界面的验证码无论怎么刷新,显示的都是同一个,即使换一个浏览器也是相同的图形验证码,说一下解决bug的过程



😶修改前的源代码如下(部分代码)



import cn.hutool.captcha.*;
import lombok.RequiredArgsConstructor;

@Service
@RequiredArgsConstructor
public class AuthServiceImpl implements AuthService {

private final AbstractCaptcha abstractCaptcha;

@Override
public CaptchaResult getCaptcha() {
//获取验证码文本,例如 1+4=
String captchaCode = abstractCaptcha.getCode();
String imageBase64Data = abstractCaptcha.getImageBase64Data();

String captchaKey = IdUtil.fastSimpleUUID();
// 验证码文本缓存至Redis,用于登录校验
redisTemplate.opsForValue().set(CacheConstants.CAPTCHA_CODE_PREFIX + captchaKey, captchaCode,captchaProperties.getExpireSeconds(), TimeUnit.SECONDS);
//......
}
}

😐分析上面的代码,首先作者使用了Lombok提供的@RequiredArgsConstructor注解,它的作用是为下面被 final 修饰的变量生成构造方法,即利用构造方法将AbstractCaptcha对象注入到Spring容器中,但是通过这种方式注入,默认情况下Bean是单例的,即多次请求会复用同一个Bean 。


😐其次,阅读hutool有关abstractCaptcha.getCode()部分的代码,如下,可以看到,在第一次生成code时,就将生成的code赋值给了成员变量 code,再结合前面的单例Bean,真相大白。


//验证码
protected String code;

@Override
public String getCode() {
if (null == this.code) {
createCode();
}
return this.code;
}

@Override
public void createCode() {
generateCode();

final ByteArrayOutputStream out = new ByteArrayOutputStream();
ImgUtil.writePng(createImage(this.code), out);
this.imageBytes = out.toByteArray();
}

//生成验证码字符串
protected void generateCode() {
this.code = generator.generate();
}


😎最终修改业务代码如下



import cn.hutool.captcha.*;
import cn.hutool.captcha.generator.MathGenerator;
import lombok.RequiredArgsConstructor;

@Service
@RequiredArgsConstructor
public class AuthServiceImpl implements AuthService {

//private final AbstractCaptcha abstractCaptcha;

@Override
public CaptchaResult getCaptcha() {
ShearCaptcha captcha = CaptchaUtil.createShearCaptcha(200, 45, 4, 4);
// 自定义验证码内容为四则运算方式
captcha.setGenerator(new MathGenerator(1));
String captchaCode = captcha.getCode();
String captchaBase64 = captcha.getImageBase64Data();

String captchaKey = IdUtil.fastSimpleUUID();
// 验证码文本缓存至Redis,用于登录校验
redisTemplate.opsForValue().set(CacheConstants.CAPTCHA_CODE_PREFIX + captchaKey, captchaCode,captchaProperties.getExpireSeconds(), TimeUnit.SECONDS);
//......
}
}

😴阅读hutool关于createShearCaptcha()方法的源码,每次调用都会new一个新对象


/**
* 创建扭曲干扰的验证码,默认5位验证码
*
* @param width 图片宽
* @param height 图片高
* @param codeCount 字符个数
* @param thickness 干扰线宽度
* @return {@link ShearCaptcha}
* @since 3.3.0
*/

public static ShearCaptcha createShearCaptcha(int width, int height, int codeCount, int thickness) {
return new ShearCaptcha(width, height, codeCount, thickness);
}

作者:tomla
来源:juejin.cn/post/7316592830638800947
收起阅读 »

从零开始写一个web服务到底有多难?

背景 ​ 服务想必大家都有很多开发经验,但是从零开始搭建一个项目的经验,就少的多了。更不要说不使用任何框架的情况下从零开始搭建一个服务。那么这次就看看从零开始搭建一个好用好写web服务到底有多难? HelloWorld 官网给出的helloworld例子。ht...
继续阅读 »

背景



服务想必大家都有很多开发经验,但是从零开始搭建一个项目的经验,就少的多了。更不要说不使用任何框架的情况下从零开始搭建一个服务。那么这次就看看从零开始搭建一个好用好写web服务到底有多难?


HelloWorld


官网给出的helloworld例子。http标准库提供了两个方法,HandleFunc注册处理方法和ListenAndServe启动侦听接口。


请在此添加图片描述


请在此添加图片描述


假如业务更多


下面我们模拟一下接口增多的情况。可以看出有大量重复的部分。这样自然而然就产生了抽象服务的需求。


package main

import (
"fmt"
"net/http"
)

func greet(w http.ResponseWriter, r *http.Request) {
fmt.Fprintf(w, "Greet!: %s\n", r.URL.Path)
}

func hello(w http.ResponseWriter, r *http.Request) {
fmt.Fprintf(w, "Hello!: %s\n", r.URL.Path)
}

func notfound(w http.ResponseWriter, r *http.Request) {
fmt.Fprintf(w, "Hello, you've requested: %s\n", r.URL.Path)
}

func main() {
http.HandleFunc("/", notfound)
http.HandleFunc("/hello", hello)
http.HandleFunc("/greet", greet)
http.ListenAndServe(":80", nil)
}

我们想要一个服务,它代表的是对某个端口监听的实例,它可以根据访问的路径,调用对应的方法。在需要的时候,我可以生成多个服务实例,监听多个端口。那么我们的Server需要实现下面两个方法。


type Server interface {
Route(pattern string, handlerFunc http.HandlerFunc)

Start(address string) error
}

简单实现一下。


package server

import "net/http"

type Server interface {
Route(pattern string, handlerFunc http.HandlerFunc)
Start(address string) error
}

type httpServer struct {
Name string
}

func (s *httpServer) Route(pattern string, handlerFunc http.HandlerFunc) {
http.HandleFunc(pattern, handlerFunc)
}

func (s *httpServer) Start(address string) error {
return http.ListenAndServe(address, nil)
}

func NewHttpServer(name string) Server {
return &httpServer{
Name: name,
}
}

修改业务代码


func main() {
server := server.NewHttpServer("demo")
server.Route("/", notfound)
server.Route("/hello", hello)
server.Route("/greet", greet)
server.Start(":80")
}

格式化输入输出


在我们实际使用过程中,输入输出一般都是以json的格式。自然也需要通用的处理过程。


type Context struct {
W http.ResponseWriter
R *http.Request
}

func (c *Context) ReadJson(data interface{}) error {
body, err := io.ReadAll(c.R.Body)
if err != nil {
return err
}
err = json.Unmarshal(body, data)
if err != nil {
return err
}
return nil
}

func (c *Context) WriteJson(code int, resp interface{}) error {
c.W.WriteHeader(code)
respJson, err := json.Marshal(resp)
if err != nil {
return err
}
_, err = c.W.Write(respJson)
return err
}

模拟了一个常见的业务代码。定义了入参和出参。


type helloReq struct {
Name string
Age string
}

type helloResp struct {
Data string
}

func hello(w http.ResponseWriter, r *http.Request) {
req := &helloReq{}
ctx := &server.Context{
W: w,
R: r,
}

err := ctx.ReadJson(req)

if err != nil {
fmt.Fprintf(w, "err:%v", err)
return
}

resp := &helloResp{
Data: req.Name + "_" + req.Age,
}

err = ctx.WriteJson(http.StatusOK, resp)
if err != nil {
fmt.Fprintf(w, "err:%v", err)
return
}

}

用postman试一下,是不是和我们平常开发的接口有一点像了。


请在此添加图片描述


由于200,404,500的返回结果实在是太普遍了,我们当然也可以进一步封装输出方法。但是我觉得没必要。


在我们设计的过程中,是否要提供辅助性的方法,还是只聚焦核心功能,是非常值得考虑的问题。


func (c *Context) SuccessJson(resp interface{}) error {
return c.WriteJson(http.StatusOK, resp)
}

func (c *Context) NotFoundJson(resp interface{}) error {
return c.WriteJson(http.StatusNotFound, resp)
}

func (c *Context) ServerErrorJson(resp interface{}) error {
return c.WriteJson(http.StatusInternalServerError, resp)
}


让框架来创建Context


观察下业务代码,还有个非常让人不舒服的地方。Context是框架内部使用的数据结构,居然要业务来创建!真的是太不合理了。


那么下面我们把Context移入框架内部创建,同时业务侧提供的handlefunction入参应该直接是由框架创建的Context。


首先修改我们的路由注册接口的定义。在实现中,我们注册了一个匿名函数,在其中构建了ctx的实例,并调用入参中业务的handlerFunc。


type Server interface {
Route(pattern string, handlerFunc func(ctx *Context))
Start(address string) error
}

func (s *httpServer) Route(pattern string, handlerFunc func(ctx *Context)) {
http.HandleFunc(pattern, func(w http.ResponseWriter, r *http.Request) {
ctx := NewContext(w, r)
handlerFunc(ctx)
})
}

func NewContext(w http.ResponseWriter, r *http.Request) *Context {
return &Context{
W: w,
R: r,
}
}

这样修改之后我们的业务代码也显得更干净了。


func hello(ctx *server.Context) {
req := &helloReq{}
err := ctx.ReadJson(req)

if err != nil {
ctx.ServerErrorJson(err)
return
}

resp := &helloResp{
Data: req.Name + "_" + req.Age,
}

err = ctx.WriteJson(http.StatusOK, resp)
if err != nil {
ctx.ServerErrorJson(err)
return
}
}

RestFul API 实现


当然我们现在发现,不管用什么方法调用我们的接口,都可以正常返回。但是我们平常都习惯写restful风格的接口。


那么在注册路由时,自然需要加上一个method的参数。注册时候也加上一个GET的声明。


type Server interface {
Route(method string, pattern string, handlerFunc func(ctx *Context))
Start(address string) error
}

server.Route(http.MethodGet, "/hello", hello)

那么我们自然可以这样写,当请求方法不等于我们注册方法时,返回error。


func (s *httpServer) Route(method string, pattern string, handlerFunc func(ctx *Context)) {
http.HandleFunc(pattern, func(w http.ResponseWriter, r *http.Request) {
if r.Method != method {
w.Write([]byte("error"))
return
}
ctx := NewContext(w, r)
handlerFunc(ctx)
})
}

那么我们现在就有了一个非常简单的可以实现restful api的服务了。


但是距离一个好用好写的web服务还有很大的进步空间。


作者:4cos90
来源:juejin.cn/post/7314902560405684251
收起阅读 »

分页合理化是什么?

一、前言 大家好!我是sum墨,一个一线的底层码农,平时喜欢研究和思考一些技术相关的问题并整理成文,限于本人水平,如果文章和代码有表述不当之处,还请不吝赐教。 只要是干过后台系统的同学应该都做过分页查询吧,前端发送带有页码(pageNum)和每页显示数量(pa...
继续阅读 »

一、前言


大家好!我是sum墨,一个一线的底层码农,平时喜欢研究和思考一些技术相关的问题并整理成文,限于本人水平,如果文章和代码有表述不当之处,还请不吝赐教。


只要是干过后台系统的同学应该都做过分页查询吧,前端发送带有页码(pageNum)和每页显示数量(pageSize)的请求,后端根据这些参数来提取并返回相应的数据集。在SpringBoot框架中,经常会使用Mybatis+PageHelper的方式实现这个功能。


但大家可能对分页合理化这个词有点儿陌生,不过应该都遇到过因为它产生的问题。这些问题不会触发明显的错误,所以大家一般都忽视了这个问题。那么啥是分页合理化,我来举几个例子:



它的定义:分页合理化通常是指后端在处理分页请求时会自动校正不合理的分页参数,以确保用户始终收到有效的数据响应。



1. 请求页码超出范围:



假设数据库中有100条记录,每页展示10条,那么就应该只有10页数据。如果用户请求第11页,不合理化处理可能会返回一个空的数据集,告诉用户没有更多数据。开启分页合理化后,系统可能会返回第10页的数据(即最后一页的数据),而不是一个空集。



2. 请求页码小于1:



用户请求的页码如果是0或负数,这在分页上下文中是没有意义的。开启分页合理化后,系统会将这种请求的页码调整为1,返回第一页的数据。



3. 请求的数据大小小于1:



如果用户请求的数据大小为0或负数,这也是无效的,因为它意味着用户不希望获取任何数据。开启分页合理化后,系统可能会设置一个默认的页面大小,比如每页显示10条数据。



4. 请求的数据大小不合理:



如果用户请求的数据大小非常大,比如一次请求1000条数据,这可能会给服务器带来不必要的压力。开启分页合理化后,系统可能会限制页面大小的上限,比如最多只允许每页显示100条数据。



二、为啥要设置分页合理化?


其实上面那些问题对于后端来讲很合理,页码和页大小设置不正确查询不出来值难道不合理吗?唯一的问题就是如果一次性查询太多条数据服务器压力确实大,但如果是产品要求的那也没办法呀!
真正让我不得不解决这个问题的原因是前端的一个BUG,这个BUG是啥样的呢?我来给大家描述一下。


1. BUG复现


我们先看看前端的分页组件



前端的这个分页组件大家应该很常见,它需要两个参数:总行数、每页行数。比如说现在总条数是6条,每页展示5条,那么会有2页,没啥问题对吧。



那么,现在我问一个问题:我们切换到第二页,把第二页仅剩的一条数据给删除掉,会出现什么情况?


理想情况:页码自动切换到第1页,并查询第一页的数据;
真实情况:页码切换到了第1页,但是查询不到数据,这明显就是一个BUG!


2. BUG分析


1. 用户切换到第二页,前端发起了请求,如:http://localhost:8080/user/pageQuery?pageNum=2&pageSize=5 ,此时第2页有一条数据;


2. 用户删除第2页的唯一数据后,前端发起查询请求,但还是第2页的查询,因为总数据的变化前端只能通过下一次的查询才能知道,但此时数据查询为空;


3. 虽然第二次查询的数据集为空,但是总条数已经变化了,只剩下5条,前端分页组件根据计算得出只剩下一页,所以自动切换到第1页;



可以看出这个BUG是分页查询的一个临界状态产生的,必现、中低频,属于必须修复的那一类。不过这个BUG想甩给前端,估计不行,因为总条数的变化只有后端知道,必须得后端修了。



三、设置分页合理化


咋一听这个BUG有点儿复杂,但如果你使用的是PageHelper框架,那么修复它非常简单,只需要两行配置
application.ymlapplication.properties中添加


pagehelper.helper-dialect=mysql
pagehelper.reasonable=true

只要加了这两行配置,这个BUG就能解决。因为配置是全局的,如果你只想对单个查询场景生效,那就在设置分页参数的时候,加一个参数,如下:


PageHelper.startPage(pageNumber, pageSize, true);

四、分页合理化配置的原理说明


这个BUG如果要自己解决的话,是不是感觉有点头痛了,但是人家PageHelper早就想到这个问题了,就像游戏开挂一样,一个配置就解决了这个麻烦的问题。
用的时候确实很爽,但是我却有点担心,这个配置现在解决了这个BUG,会不会导致新的BUG呢?如果真的出现了新BUG,我应该怎么做呢?所以我决定研究一下它的基础原理。


在com.github.pagehelper.Page类下,找到了这段核心源码,这段应该就是分页合理化的实现逻辑


// 省略其他代码
public Page<E> setReasonable(Boolean reasonable) {
if (reasonable == null) {
return this;
}
this.reasonable = reasonable;
//分页合理化,针对不合理的页码自动处理
if (this.reasonable && this.pageNum <= 0) {
this.pageNum = 1;
calculateStartAndEndRow();
}
return this;
}
// 省略其他代码

// 省略其他代码
/**
* 计算起止行号
*/

private void calculateStartAndEndRow() {
this.startRow = this.pageNum > 0 ? (this.pageNum - 1) * this.pageSize : 0;
this.endRow = this.startRow + this.pageSize * (this.pageNum > 0 ? 1 : 0);
}
// 省略其他代码

还有一些代码我没贴,比如PageInterceptor#intercept方法,这里我整理了一下它的执行流程图,如下:




看了图解,这套配置还挺清晰的,懂了怎么回事儿,用起来也就放心了。记得刚开始写代码时,啥都希望有人给弄好了,最好是拿来即用。但时间一长,自己修过一堆BUG,才发现只有自己弄明白的代码才靠谱,什么都想亲手来。等真正搞懂了一些底层的东西,才意识到要想造出好东西,得先学会站在巨人的肩膀上。学习嘛,没个头儿!



作者:summo
来源:juejin.cn/post/7316357622847995923
收起阅读 »

这下对阿里java这几条规范有更深理解了

背景 阿里java开发规范是阿里巴巴总结多年来的最佳编程实践,其中每一条规范都经过仔细打磨或踩坑而来,目的是为社区提供一份最佳编程规范,提升代码质量,减少bug。 这基本也是java业界都认可的开发规范,我们团队也是以此规范为基础,在结合实际情况,补充完善。最...
继续阅读 »

背景


阿里java开发规范是阿里巴巴总结多年来的最佳编程实践,其中每一条规范都经过仔细打磨或踩坑而来,目的是为社区提供一份最佳编程规范,提升代码质量,减少bug。

这基本也是java业界都认可的开发规范,我们团队也是以此规范为基础,在结合实际情况,补充完善。最近在团队遇到的几个问题,加深了我对这份开发规范中几个点的理解,下面就一一道来。


日志规约



这条规范说明了,在异常发送记录日志时,要记录案发现场信息和异常堆栈信息,不处理要往上throws,切勿吃掉异常。

堆栈信息比较好理解,就是把整个方法调用链打印出来,方便定位具体是哪个方法出错。而案发现场信息我认为至少要能说明:“谁发生了什么错误”。

例如,哪个uid下单报错了,哪个订单支付失败了,原因是什么。否则满屏打印:“user error”,看到你都无从下手。


在我们这次出现的问题就是有一个feign,调用外部接口报错了,降级打印了不规范日志,导致排查问题花了很多时间。伪代码如下:


	@Slf4j
@Component
class MyClientFallbackFactory implements FallbackFactory<MyClient> {

@Override
public MyClient create(Throwable cause) {
return new MyClient() {
@Override
public Result<DataInfoVo> findDataInfo(Long id) {
log.error("findDataInfo error");
return Result.error(SYS_ERROR);
}
};
}
}

发版后错误日志开始告警,打开kibana看到了满屏了:“findDataInfo error”,然后开始一顿盲查。

因为这个接口本次并没有修改,所以猜测是目标服务出问题,上服务器curl接口,发现调用是正常的。

接着猜测是不是熔断器有问题,熔断后没有恢复,但重启服务后,还是继续报错。开始各种排查,arthas跟踪,最后实在没办法了,还是老老实实把异常打印出来,走发版流程。


log.error("{} findDataInfo error", id, cause);

有了异常堆栈信息就很清晰了,原来是返回参数反序列失败了,接口提供方新增一个不兼容的参数导致反序列失败。(这点在下一个规范还会提到)

可见日志打印不清晰给排查问题带来多大的麻烦,记住:日志一定要打印关键信息,异常要打印堆栈。


二方库依赖



上面提到的返回参数反序列化失败就是枚举造成的,原因是这个接口返回新增一个枚举值,这个枚举值原本返回给前端使用的,没想到还有其它服务也调用了它,最终在反序列化时就报错了,找不到“xxx”枚举值。

比如如下接口,你提交一个不认得的黑色BLACK,就会报反序列错误:


	enum Color {
GREEN, RED
}

@Data
class Test {
private Color color;
}

@PostMapping(value = "/post/info")
public void info(@NotNull Test test) {

}

curl --location 'localhost/post/info' \
--header 'Content-Type: application/json' \
--data '{
"testEnum": "BLACK"
}'


关于这一点我们看下作者孤尽对它的阐述:


这就是我们出问题的场景,提供方新增了一个枚举值,而使用方没有升级,导致错误。可能有的同学说那通知使用方升级不就可以了?是的,但这出现了依赖问题,如果使用方有成百上千个,你会非常头痛。


那又为什么说不要使用枚举作为返回值,而可以作为输入参数呢?

我的理解是:作为枚举的提供者,不得随意新增/修改内容,或者说修改前要同步到所有枚举使用者,让大家知道,否则使用者就可能因为不认识这个枚举而报错,这是不可接受的。

但反过来,枚举提供者是可以将它作为输入参数的,如果调用者传了一个不存在的值就会报错,这是合理的,因为提供者并没有说支持这个值,调用者正常就不应该传递这个值,所以这种报错是合理的。


ORM映射



以下是规范里的说明:

1)增加查询分析器解析成本。

2)增减字段容易与 resultMap 配置不一致。

3)无用字段增加网络消耗,尤其是 text 类型的字段。


这都很好理解,就不过多说明。

在我们开发中,有的同学为了方便,还是使用了select *,一直以来也风平浪静,运行得好好的,直到有一天对该表加了个字段,代码没更新,报错了~,你没看错,代码没动,加个字段程序就报错了。

报错信息如下:



数组越界!问题可以在本地稳定复现,先把程序跑起来,执行 select * 的sql,再add column给表新增一个字段,再次执行相同的sql,报错。



具体原因是我们程序使用了sharding-jdbc做分表(5.1.2版本),它会在服务启动时,加载字段信息缓存,在查询后做字段匹配,出错就在匹配时。

具体代码位置在:com.mysql.cj.protocol.a.MergingColumnDefinitionFactory#createFromFields



这个缓存是跟数据库链接相关的,只有链接失效时,才会重新加载。主要有两个参数和它相关:

spring.shardingsphere.datasource.master.idle-timeout 默认10min

spring.shardingsphere.datasource.master.max-lifetime 默认30min


默认缓存时间都比较长,你只能赶紧重启服务解决,而如果服务数量非常多,又是一个生产事故。

我在sharding sphere github搜了一圈,没有好的处理方案,相关链接如:

github.com/apache/shar…

github.com/apache/shar…


大体意思是如果真想这么做,数据库ddl需要通过sharding proxy,它会负责刷新客户端的缓存,但我们使用的是sharding jdbc模式,那只能老老实实遵循规范,不要select * 了。如果select具体字段,那新增的字段也不会被select出来,和缓存的就能对应上。

那么以后面试除了上面规范说到的,把这一点亲身经历也摆出来,应该可以加分吧。


总结


每条开发规范都有其背后的含义,都是经验总结和踩坑教训,对于团队的开发规范我们都要仔细阅读,严格遵守。可以看到上面每个小问题都可能导致不小的生产事故,保持敬畏之心,大概就是这个意思了吧。


更多分享,欢迎关注我的github:github.com/jmilktea/jt…


作者:jtea
来源:juejin.cn/post/7308277343242944564
收起阅读 »

写一个简易的前端灰度系统

写在前面的话灰度这个概念,来自数字图像领域,最初是描述黑白数字图像的灰度值,范围从 0 到 255,0 表示黑色,255 表示白色,中间的数值表示不同程度的灰色。灰度系统的诞生源于交叉学科的建设,在互联网上也不...
继续阅读 »

写在前面的话

灰度这个概念,来自数字图像领域,最初是描述黑白数字图像的灰度值,范围从 0  2550 表示黑色,255 表示白色,中间的数值表示不同程度的灰色。

灰度系统的诞生源于交叉学科的建设,在互联网上也不例外。对于一个软件产品,在开发和发布的时候肯定希望用户能够顺利的看到想让其看到的内容。但是,发布没有一帆风顺的,如果在发布的某个环节出了问题,比如打错了镜像或者由于部署环境不同触发了隐藏的bug,导致用户看到了错误的页面或者旧的页面,这就出现了生产事故。为了避免这种情况出现,借鉴数字图像处理的理念,设计师们设计出了一种介于 0  1 之间的过渡系统的概念:让系统可以预先发布,并设置可见范围,就像朋友圈一样,等到风险可控后,再对公众可见。这就是灰度系统。

灰度系统版本的发布动作称作 灰度发布,又名金丝雀发布,或者灰度测试,他是指在黑与白之间能够平滑过渡的一种发布方式。在其上可以进行A/B testing,即让一部分用户继续用产品特性A,一部分用户开始用产品特性B,如果用户对B没有什么反对意见,那么逐步扩大范围,把所有用户都迁移到B上面来。(概念来自知乎)

对于前端领域,演进到现在,灰度系统主要有如下几点功能:

  1. 增量灰度:小的patch可以增量的添加在发布版本上,也可以通过开关一键关闭
  2. 用户灰度:增量和全量版本都可对不同群体或者某几个特定的用户进行灰度可见
  3. 版本回退:每一个版本都在灰度系统里可见,可以一键回退

前端灰度系统工作流程图如下:

sequenceDiagram
前端项目-->灰度系统: 部署阶段
前端项目->>灰度系统: 1.CI 打包后写入打包资源,状态初始化
前端项目-->灰度系统: 访问阶段
前端项目->>灰度系统: 1.页面访问,请求当前登录用户对应的资源版本
灰度系统-->>前端项目: 2.从对应版本的资源目录返回前端资源

灰度规则

关于灰度资源优先级的说明如下:

灰度策略优先级
未生效
生效
全量一般

如此就起到了灰度的作用:全量表示所有人都可以看;生效表示只有在规则中的用户才可以看到这部分增量更新,优先级最高;未生效表示不灰度,优先级最低。

灰度系统数据库设计

为什么灰度系统有后端:前端项目 CI 部署后,会产生一个 commit 号和一个镜像记录,并且打包后的文件存放在服务器中某一个深层的文件夹目录中,灰度系统需要存入该部署的目录地址,便于在切换灰度时查找不同版本的文件。

先介绍一个要部署的前端项目(你可以根据自己的前端项目动态调整)。

本项目针对的前端项目是一个基于微服务架构的项目,

下面是设计ER图:

image.png

我们依此来分析:

子项目表

该表用于存放所有子项目的信息,新建一个微服务子项目时,会在这个表里新建一个条目,数据示意如下:

image.png

灰度用户表

用于灰度系统登录的用户,拥有灰度权限的人才可以加入。

资源表

资源表存放项目在 CI 中写入的 commit 信息和 build 完以后在服务器的存放位置,数据示意如下:

image.png

其中 branch 是跑CI的分支,data 存放打包资源目录信息,一般结构如下:

image.png

gitProjectId 存放该产品在 gitlab 中的项目号, status 表示构建状态:0:构建完成 1:部署完成 2:构建失败,3:部署失败。

这里简单提一下 CI 是如何写入灰度系统数据库的,过多详情不做解释,写入数据库方式很多,这只是其中一种实现方式。

  1. 首先在 CI build 环节往服务器写入打包信息的 JSON:

image.png

其中 build.sh 负责把传入的参数写到一个 json 中。上图中是往根目录copy,方便下一个 CI job读取json文件的示意图。

  1. 在 CI 部署环节,通过调用脚本创建资源:

image.png

其中 run_gray.js:

const { ENV, file, branch, projectId, gitProjectId, user, commitMsg } = require('yargs').argv;

axios({
url: URL,
method: "POST",
headers: {
remoteUser: user
},
data: {
Action: "CreateResource",
projectId,
branch,
commitMsg,
gitProjectId,
channel: Channel,
data: fs.readFileSync(file, 'utf8'),
status: "0"
}
}).then(...)

其中 status 的变化,在 CI 部署服务器完成后,追加一个 UpdateResource 动作即可:

if [[ $RetCode != 0 ]]; then curl "$STARK_URL" -X 'POST' -H 'remoteUser: '"$GITLAB_USER_NAME"'' -H 'Content-Type: application/json' -d '{"Action": "UpdateResource", "id": "'"$ResourceId"'", "status": "2"}' > test.log && echo `cat test.log`; fi

灰度策略表

灰度策略是对灰度资源的调动配置。其设计如下:

image.png

其中,prijectId 表示灰度的项目,resourceId 表示使用的资源,rules 配置了对应的用户或用户组(看你怎么配置了,我这里只配置了单独的 userId),status 是灰度的状态,我设置了三种:

  • default: 未生效
  • failure: 生效
  • success: 全量

状态生效表示是增量发布的意思。

到这里,数据库设计就完毕了。


灰度系统接口API开发

有了数据库,还需要提供能够操作数据库的服务,上边创建资源的接口就是调用的灰度自己的API实现的。主要的API列表如下:

名称描述
getResourcesByProjectId获取单个产品下所有资源
getResourcesById通过主键获取资源
createResource创建一个资源
updateResource更新一个资源
getIngressesByProjectId获取单个产品下灰度策略任务列表
getIngressById通过主键获取单个灰度策略任务详情
createIngress创建一个策略
updateIngress更新一个策略

剩余的接口有用户处理的,有子项目管理的,这里不做详述。除了上边的必须的接口外,还有一个最重要的接口,那就是获取当前登录用户需要的资源版本的接口。在用户访问时,需要首先调用灰度系统的这个接口来获取资源地址,然后才能重定向到给该用户看的页面中去:

名称描述接收参数输出
getConsoleVersion获取当前用的产品版本userId,productsresource键值对列表

getConsoleVersion 接受两个参数,一个是当前登录的用户 ID, 一个是当前用户访问的微服务系统中所包含的产品列表。该接口做了如下几步操作:

  1. 遍历 products,获取每一个产品的 projectId
  2. 对于每一个 projectId,联查资源表,分别获取对应的 resourceId
  3. 对于每一个resourceId,结合 userId,并联查灰度策略表,筛选出起作用的灰度策略中可用的资源
  4. 返回每一个资源的 data 信息。

其中第三步处理相对繁琐一些,比如说,一个资源有两个起作用的灰度资源,一个是增量的,一个是全量的,这里应该拿增量的版本,因为他优先级更高。

获取用户版本的流程图如下:

graph TD
用户登录页面 --> 获取所有产品下的资源列表
获取所有产品下的资源列表 --> 根据灰度策略筛选资源中该用户可用的部分 --> 返回产品维度的资源对象

最后返回的资源大概长这个样子:

interface VersionResponse {
[productId: number]: ResourceVersion;
}

interface ResourceVersion {
files: string[];
config: ResourceConfig;
dependencies: string[];
}

其中 files 就是 JSON 解析后的上述 data 信息的文件列表,因为打包后的文件往往有 css和多个js。

至于这个后端使用什么语言,什么框架来写,并不重要,重要的是一定要稳定,他要挂掉了,用户就进不去系统了,容灾和容错要做好;如果是个客户比较多的网站,并发分流也要考虑进去。

前端页面展示

前端页面就随便使用了一个前端框架搭了一下,选型不是重点,组件库能够满足要求就行:

  • 登录

image.png

  • 查看资源

image.png

  • 配置策略

image.png

image.png


部署以后,实际运行项目看看效果:

image.png

可以看到,在调用业务接口之前,优先调用了 getConsoleVersion来获取版本,其返回值是以产品为 key 的键值对:

image.png

访问转发

这里拿到部署信息后,服务器要进行下一步处理的。我这里是把它封装到一个对象中,带着参数传给了微服务的 hook 去了(微服务系统需要);如果你是单页应用,可能需要把工作重心放在 Nginx 的转发上,Nginx内部服务读取灰度系统数据库来拿到版本目录,然后切换路由转发(可能只是改变一个路由变量)。 (你也可以参照我 nginx 相关文章),下面我简单的给个示意图:

graph TD
灰度系统配置灰度策略 --> Nginx+Lua+Mysql获取灰度策略并写入Nginx变量
Nginx+Lua+Mysql获取灰度策略并写入Nginx变量 --> Nginx服务器配置资源转发

总结

前端灰度系统,其实就是一个后台管理系统。他配置和管理了不同版本的前端部署资源和对应的用户策略,在需要的时候进行配置。

接下来的文章我会配套性的讲一下 Nginx 和 Docker 的前端入门使用,敬请期待!

完!大家对灰度系统有什么好的建议,可以在评论区讨论哦!



作者:小肚肚肚肚肚哦
来源:juejin.cn/post/7212054600162132029

收起阅读 »

为什么程序员一定要写单元测试?

大家好,我是鱼皮,很多初学编程的同学都会认为 “程序员的工作只有开发新功能,功能做完了就完事儿”。但其实不然,保证程序的正常运行、提高程序的稳定性和质量也是程序员的核心工作。 之前给大家分享过企业项目的完整开发流程,其中有一个关键步骤叫 “单元测试”,这篇文章...
继续阅读 »

大家好,我是鱼皮,很多初学编程的同学都会认为 “程序员的工作只有开发新功能,功能做完了就完事儿”。但其实不然,保证程序的正常运行、提高程序的稳定性和质量也是程序员的核心工作。


之前给大家分享过企业项目的完整开发流程,其中有一个关键步骤叫 “单元测试”,这篇文章就来聊聊程序员如何编写单元测试吧。


什么是单元测试?


单元测试(Unit Testing,简称 UT)是软件测试的一种,通常由开发者编写测试代码并运行。相比于其他的测试类型(比如系统测试、验收测试),它关注的是软件的 最小 可测试单元。


什么意思呢?


假如我们要实现用户注册功能,可能包含很多个子步骤,比如:



  1. 校验用户输入是否合法

  2. 校验用户是否已注册

  3. 向数据库中添加新用户


其中,每个子步骤可能都是一个小方法。如果我们要保证用户注册功能的正确可用,那么就不能只测试注册成功的情况,而是要尽量将每个子步骤都覆盖到,分别针对每个小方法做测试。比如输入各种不同的账号密码组合来验证 “校验用户输入是否合法” 这一步骤在成功和失败时的表现是否符合预期。


同理,如果我们要开发一个很复杂的系统,可能包含很多小功能,每个小功能都是一个单独的类,我们也需要针对每个类编写单元测试。因为只有保证每个小功能都是正确的,整个复杂的系统才能正确运行。


单元测试的几个核心要点是:



  1. 最小化测试范围:单元测试通常只测试代码的一个非常小的部分,以确保测试的简单和准确。

  2. 自动化:单元测试应该是自动化的,开发人员可以随时运行它们来验证代码的正确性,特别是在修改代码后。而不是每次都需要人工去检查。

  3. 快速执行:每个单元测试的执行时间不能过长,应该尽量做到轻量、有利于频繁执行。

  4. 独立性:每个单元测试应该独立于其他测试,不依赖于外部系统或状态,以确保测试的可靠性和可重复性。


为什么需要单元测试?


通过编写和运行单元测试,开发者能够快速验证代码的各个部分是否按照预期工作,有利于保证系统功能的正确可用,这是单元测试的核心作用。


此外,单元测试还有很多好处,比如:


1)改进代码:编写单元测试的过程中,开发者能够再次审视业务流程和功能的实现,更容易发现一些代码上的问题。比如将复杂的模块进一步拆解为可测试的单元。


2)利于重构:如果已经编写了一套可自动执行的单元测试代码,那么每次修改代码或重构后,只需要再自动执行一遍单元测试,就知道修改是否正确了,能够大幅提高效率和项目稳定性。


3)文档沉淀:编写详细的单元测试本身也可以作为一种文档,说明代码的预期行为。


鱼皮以自己的一个实际开发工作来举例单元测试的重要性。我曾经编写过一个 SQL 语法解析模块,需要将 10000 多条链式调用的语法转换成标准的 SQL 语句。但由于细节很多,每次改进算法后,我都不能保证转换 100% 正确,总会人工发现那么几个错误。所以我编写了一个单元测试来自动验证解析是否正确,每次改完代码后执行一次,就知道解析是否完全成功了。大幅提高效率。


所以无论是后端还是前端程序员,都建议把编写单元测试当做一种习惯,真的能够有效提升自己的编码质量。


如何编写单元测试?


以 Java 开发为例,我们来学习如何编写单元测试。


Java 开发中,最流行的单元测试框架当属 JUnit 了,它提供了一系列的类和方法,可以帮助我们快速检验代码的行为。


1、引入 JUnit


首先我们要在项目中引入 JUnit,演示 2 种方式:


Maven 项目引入


在 pom.xml 文件中引入 JUnit 4 的依赖:


<dependency>
    <groupId>junit</groupId>
    <artifactId>junit</artifactId>
    <version>4.13.2</version>
    <scope>test</scope>
</dependency>

Spring Boot 项目引入


如果在 Spring Boot 中使用 JUnit 单元测试,直接引入 spring-boot-starter-test 包即可:


<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-test</artifactId>
    <scope>test</scope>
</dependency>

然后会自动引入 JUnit Jupiter,它是 JUnit 5(新版本)的一部分,提供了全新的编写和执行单元测试的方式,更灵活易用。不过学习成本极低,会用 JUnit 4,基本就会用 JUnit Jupiter。


2、编写单元测试


编写一个单元测试通常包括三个步骤:准备测试数据、执行要测试的代码、验证结果。


一般来说,每个类对应一个单元测试类,每个方法对应一个单元测试方法。


编写 JUnit 单元测试


比如我们要测试一个计算器的求和功能,示例代码如下:


import org.junit.Test;
import org.junit.Assert;

public class CalculatorTest {

    // 通过 Test 注解标识测试方法
    @Test
    public void testAdd() {
        // 准备测试数据
        long a = 2;
        long b = 3;
        
        // 执行要测试的代码
        Calculator calculator = new Calculator();
        int result = calculator.add(23);
        
        // 验证结果
        Assert.assertEquals(5, result);
    }
}

上述代码中的 Assert 类是关键,提供了很多断言方法,比如 assertEquals(是否相等)、assertNull(是否为空)等,用来对比程序实际输出的值和我们预期的值是否一致。


如果结果正确,会看到如下输出:



如果结果错误,输出如下,能够清晰地看到执行结果的差异:



Spring Boot 项目单测


如果是 Spring Boot 项目,我们经常需要对 Mapper 和 Service Bean 进行测试,则需要使用 @SpringBootTest 注解来标识单元测试类,以开启对依赖注入的支持。


以测试用户注册功能为例,示例代码如下:


import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.Test;
import org.springframework.boot.test.context.SpringBootTest;

import javax.annotation.Resource;

@SpringBootTest
public class UserServiceTest {

    @Resource
    private UserService userService;

    @Test
    void userRegister() {
        // 准备数据
        String userAccount = "yupi";
        String userPassword = "";
        String checkPassword = "123456";
        // 执行测试
        long result = userService.userRegister(userAccount, userPassword, checkPassword);
        // 验证结果
        Assertions.assertEquals(-1, result);
        // 再准备一组数据,重复测试流程
        userAccount = "yu";
        result = userService.userRegister(userAccount, userPassword, checkPassword);
        Assertions.assertEquals(-1, result);
    }
}

3、生成测试报告


如果系统的单元测试数量非常多(比如 1000 个),那么只验证某个单元测试用例是否正确、查看单个结果是不够的,我们需要一份全面完整的单元测试报告,便于查看单元测试覆盖度、评估测试效果和定位问题。


测试覆盖度 是衡量测试过程中被测试到的代码量的一个指标,一般情况下越高越好。测试覆盖度 100% 表示整个系统中所有的方法和关键语句都被测试到了。


下面推荐 2 种生成单元测试报告的方法。


使用 IDEA 生成单测报告


直接在 IDEA 开发工具中选择 Run xxx with Coverage 执行单元测试类:



然后就能看到测试覆盖度报告了,如下图:



显然 Main 方法没有被测试到,所以显示 0%。


除了在开发工具中查看测试报告外,还可以导出报告为 HTML 文档:



导出后,会得到一个 HTML 静态文件目录,打开 index.html 就能在浏览器中查看更详细的单元测试报告了:



这种方式简单灵活,不用安装任何插件,比较推荐大家日常学习使用。


使用 jacoco 生成单测报告


JaCoCo 是一个常用的 Java 代码覆盖度工具,能够自动根据单元测试执行结果生成详细的单测报告。


它的用法也很简单,推荐按照官方文档中的步骤使用。


官方文档指路:http://www.eclemma.org/jacoco/trun…


首先在 Maven 的 pom.xml 文件中引入:


<plugin>
  <groupId>org.jacoco</groupId>
  <artifactId>jacoco-maven-plugin</artifactId>
  <version>0.8.11</version>
</plugin>

当然,只引入 JaCoCo 插件还是不够的,我们通常希望在执行单元测试后生成报告,所以还要增加 executions 执行配置,示例代码如下:


<plugin>
    <groupId>org.jacoco</groupId>
    <artifactId>jacoco-maven-plugin</artifactId>
    <version>0.8.11</version>
    <configuration>
        <includes>
            <include>com/**/*</include>
        </includes>
    </configuration>
    <executions>
        <execution>
            <id>pre-test</id>
            <goals>
                <goal>prepare-agent</goal>
            </goals>
        </execution>
        <execution>
            <id>post-test</id>
            <phase>test</phase>
            <goals>
                <goal>report</goal>
            </goals>
        </execution>
    </executions>
</plugin>

然后执行 Maven 的 test 命令进行单元测试:



测试结束后,就能够在 target 目录中,看到生成的 JaCoCo 单元测试报告网站了:



打开网站的 index.html 文件,就能看到具体的测试报告结果,非常清晰:



通常这种方式会更适用于企业中配置流水线来自动化生成测试报告的场景。


作者:程序员鱼皮
来源:juejin.cn/post/7301311492095885349
收起阅读 »

Java中的双冒号运算符(::)及其应用

在Java 8引入的Lambda表达式和函数式接口之后,双冒号运算符(::)成为了一项重要的功能。它可以将方法或构造函数作为参数传递,简化了编码和提升了代码的可读性。本文将介绍Java中的双冒号运算符及其常见应用场景。 双冒号运算符(::)的语法 双冒号运算符...
继续阅读 »

在Java 8引入的Lambda表达式和函数式接口之后,双冒号运算符(::)成为了一项重要的功能。它可以将方法或构造函数作为参数传递,简化了编码和提升了代码的可读性。本文将介绍Java中的双冒号运算符及其常见应用场景。


双冒号运算符(::)的语法


双冒号运算符的语法是类名/对象名::方法名。具体来说,它有三种不同的使用方式:



  1. 作为静态方法的引用:ClassName::staticMethodName

  2. 作为实例方法的引用:objectReference::instanceMethodName

  3. 作为构造函数的引用:ClassName::new


静态方法引用


首先,我们来看一下如何使用双冒号运算符引用静态方法。假设有一个Utils类,其中有一个静态方法processData


public class Utils {
public static void processData(String data) {
System.out.println("Processing data: " + data);
}
}

我们可以使用双冒号运算符将该方法作为参数传递给其他方法:


List<String> dataList = Arrays.asList("data1", "data2", "data3");
dataList.forEach(Utils::processData);

上述代码等效于使用Lambda表达式的方式:


dataList.forEach(data -> Utils.processData(data));

通过使用双冒号运算符,我们避免了重复写Lambda表达式,使代码更加简洁和易读。


实例方法引用


双冒号运算符还可以用于引用实例方法。假设我们有一个User类,包含了一个实例方法getUserInfo


public class User {
public void getUserInfo() {
System.out.println("Getting user info...");
}
}

我们可以通过双冒号运算符引用该实例方法:


User user = new User();
Runnable getInfo = user::getUserInfo;
getInfo.run();

上述代码中,我们创建了一个Runnable对象,并将user::getUserInfo作为方法引用赋值给它。然后,通过调用run方法来执行该方法引用。


构造函数引用


在Java 8之前,要使用构造函数创建对象,需要通过写出完整的类名以及参数列表来调用构造函数。而使用双冒号运算符,我们可以将构造函数作为方法引用,实现更加简洁的对象创建方式。


假设有一个Person类,拥有一个带有name参数的构造函数:


public class Person {
private String name;

public Person(String name) {
this.name = name;
}

public String getName() {
return name;
}
}

我们可以使用双冒号运算符引用该构造函数并创建对象:


Supplier<Person> personSupplier = Person::new;
Person person = personSupplier.get();
person.getName(); // 调用实例方法

上述代码中,我们使用Person::new将构造函数引用赋值给Supplier接口,然后通过get方法创建了Person对象。


总结


本文介绍了Java中双冒号运算符(::)的语法及其常见的应用场景。通过双冒号运算符,我们可以更方便地引用静态方法、实例方法和构造函数,使得代码更加简洁和可读。双冒号运算符是Java 8引入的重要特性,对于函数式编程和Lambda表达式的使用起到了积极的推动作用。


希望本文能够帮助您理解和应用双冒号运算符,提高Java开发的效率和代码质量。如有任何问题或疑惑,欢迎提问!


作者:每天一个技术点
来源:juejin.cn/post/7316532841923805184
收起阅读 »

JDBC快速入门:从环境搭建到代码编写,轻松实现数据库增删改查操作!

通过上篇文章我们已经对JDBC的基本概念和工作原理都有了一定的了解,本篇文章我们继续来探索如何从零开始,一步步搭建开发环境,编写代码,最后实现数据库的增删改查操作。一、开发环境搭建首先,我们需要准备的开发环境有:Java开发工具包(JDK)、数据库(如MySQ...
继续阅读 »

通过上篇文章我们已经对JDBC的基本概念和工作原理都有了一定的了解,本篇文章我们继续来探索如何从零开始,一步步搭建开发环境,编写代码,最后实现数据库的增删改查操作。

一、开发环境搭建

首先,我们需要准备的开发环境有:Java开发工具包(JDK)、数据库(如MySQL)、数据库驱动(如MySQL Connector/J)。

安装JDK:

你可以从Oracle官网下载适合你操作系统的JDK版本,按照提示进行安装即可。相信这个大家早已经安装过了,在这里就不再多说了。

安装数据库:

同样在官网下载MySQL安装包,按照提示进行安装。安装完成后,需要创建一个数据库和表,用于后续的测试。

下载数据库驱动:

在MySQL官网下载对应版本的MySQL Connector/J,将其解压后的jar文件添加到你的项目类路径中。

具体的操作如下:

1、创建一个普通的空项目

Description

填写上项目名称与路径

Description

2、配置JDK版本

Description

3、创建一个子模块(jdbc快速入门的程序在这里面写)

Description

这里填写上子模块名称

Description

然后下一步,点击ok,这个子模块就创建完成了

Description

4、导入jar包

Description
Description

二、使用JDBC访问数据库

JDBC操作数据库步骤如下:

  • 注册驱动
  • 获取数据库连接对象 (Connection)
  • 定义SQL语句
  • 获取执行SQL的对象 (Statement)
  • 执行SQL
  • 处理集并返回结果(ResultSet)
  • 释放资源

下面通过代码来了解一下JDBC代码的编写步骤与操作流程。

1、创建数据库和表:

CREATE DATABASE `jdbc_test` DEFAULT CHARSET utf8mb4;
CREATE TABLE `account`(
`id` int(11) NOT NULL AUTO_INCREMENT PRIMARY KEY COMMENT 'ID',
`name` varchar(20) NOT NULL COMMENT '姓名',
`salary` int(11) COMMENT '薪资',
);

2、编写Java程序:

package com.baidou.jdbc;

import java.sql.Connection;
import java.sql.DriverManager;
import java.sql.Statement;

public class JDBCDemo {
public static void main(String[] args) throws Exception {
// 1、注册驱动
Class.forName("com.mysql.jdbc.Driver");

// 2、获取连接
String url ="jdbc:mysql://127.0.0.1:3306/jdbc_test?useSSL=false";
String user = "root";
String password = "123456";
Connection conn = DriverManager.getConnection(url, user, password);


// 3、定义sql语句
String sql = "insert into account(name,salary) values('王强',10000)";

// 4、获取执行sql的对象 Statement
Statement stmt = conn.createStatement();

// 5、执行sql
int count = stmt.executeUpdate(sql);

// 6、处理结果
// 打印受影响的行数
System.out.println(count);
System.out.println(count>0?"插入成功":"插入失败");

// 7、释放资源
stmt.close();
conn.close();
}
}

控制输出结果如下:

Description

表中的数据:

Description

三、JDBC-API详解

JDBC API是Java语言访问数据库的标准API,它定义了一组类和接口,用于封装数据库访问的细节。主要包括以下几类:

1、DriverManager驱动管理对象

(1)注册驱动:
注册给定的驱动程序:staticvoid registerDriver(Driver driver);在com.mysql.jdbc.Driver类中存在静态代码块;
写代码有固定写法:Class.forName(“com.mysql.jdbc.Driver”);

(2)获取数据库连接对象
具体实现是通过:DriverManager.getConnection(url,username,password);

2、Connection数据库连接对象

(1)创建sql执行对象

conn.createStatement();

(2)可以执行事务的提交,回滚操作

conn.rollback();conn.setAutoCommit(false);

3、Statement执行sql语句的对象

用于向数据库发送要执行的sql语句(增删改查),其中有两个重要方法:

  • executeUpdate(String sql)
  • executeQuery(String sql)

前者用于DML操作,后者用于DQL操作。

4、ResultSet结果集对象

  • 打印输出时判断结果集是否为空,rs.next()
  • 若知字段类型可使用指定类型如,rs.getInt()获取数据
你还在苦恼找不到真正免费的编程学习平台吗?可以试试云端源想!课程视频、在线书籍、在线编程、实验场景模拟、一对一咨询……你想要的全部学习资源这里都有,重点是统统免费!点这里即可查看

四、提取工具类并完成增删改查操作

在上面介绍了可以通过JDBC对数据库进行增删改查操作,但是如果每次对数据库操作一次都要重新加载一次驱动,建立连接等重复性操作的话,会造成代码的冗余。

因此下面通过封装一个工具类来实现对数据库的增删改查操作。

1、建立配置文件db.properties文件

properties文件是Java支持的一种配置文件类型(所谓支持是因为Java提供了properties类,来读取properties文件中的信息)。记得一定要将此文件直接放在src目录下!!!不然后面执行可能找不到此配置文件!!

driver=com.mysql.jdbc.Driver
url=jdbc:mysql://localhost:3306/jdbcstudy?useUnicode=true&characterEncoding=utf8&useSSL=true
username=root
password=lcl403020

2、建立工具类JdbcUtils.java

有了这个工具类,之后的增删改查操作可直接导入这个工具类完成获取连接,释放资源的操作,很方便,接着往下看。

package jdbcFirstDemo.src.lesson02.utils;
import java.io.IOException;
import java.io.InputStream;
import java.sql.*;
import java.util.Properties;
public class JdbcUtils {
private static String driver=null;
private static String url=null;
private static String username=null;
private static String password=null;
static {
try{
InputStream in=JdbcUtils.class.getClassLoader().getResourceAsStream("db.properties"); Properties properties=new Properties();
properties.load(in);


driver=properties.getProperty("driver");
url=properties.getProperty("url");
username=properties.getProperty("username");
password=properties.getProperty("password");
//驱动只需要加载一次
Class.forName(driver);
} catch (IOException e) {
throw new RuntimeException(e);
} catch (ClassNotFoundException e) {
throw new RuntimeException(e);
}
}
//获取连接
public static Connection getConnection() throws SQLException {
return DriverManager.getConnection(url,username,password);
}
//释放连接资源
public static void release(Connection conn, Statement st, ResultSet rs) {
if(rs!=null){
try{
rs.close();
} catch (SQLException e) {
throw new RuntimeException(e);
}
}


if(st!=null){
try {
st.close();
} catch (SQLException e) {
throw new RuntimeException(e);
}
}
if(conn!=null){
try {
conn.close();
} catch (SQLException e) {
throw new RuntimeException(e);
}
}

}
}

3、 插入数据(DML)

package jdbcFirstDemo.src.lesson02;
import jdbcFirstDemo.src.lesson02.utils.JdbcUtils;
import java.sql.Connection;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.sql.Statement;
public class TestInsert {
public static void main(String[] args) {
Connection conn=null;
Statement st=null;
ResultSet rs=null;
try{
conn= JdbcUtils.getConnection();
st=conn.createStatement();
String sql="insert into users (id, name, password, email, birthday) VALUES (7,'cll',406020,'30812290','2002-03-03 10:00:00')";
int i=st.executeUpdate(sql);
if(i>0){
System.out.println("插入成功!");
}
} catch (SQLException e) {
throw new RuntimeException(e);
}
finally {
JdbcUtils.release(conn,st,rs);
}
}
}

运行结果:

Description

4、修改数据(DML)

package jdbcFirstDemo.src.lesson02;
import jdbcFirstDemo.src.lesson02.utils.JdbcUtils;
import java.sql.Connection;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.sql.Statement;
public class TestUpdate {
public static void main(String[] args) {
Connection conn=null;
Statement st=null;
ResultSet rs=null;
try{
conn= JdbcUtils.getConnection();
st=conn.createStatement();
String sql="update users set name='haha' where id=2";
int i=st.executeUpdate(sql);
if(i>0){
System.out.println("修改成功!");
}
} catch (SQLException e) {
throw new RuntimeException(e);
}
finally {
JdbcUtils.release(conn,st,rs);
}
}
}

运行结果:

Description

5、 删除数据(DML)

package jdbcFirstDemo.src.lesson02;
import jdbcFirstDemo.src.lesson02.utils.JdbcUtils;
import java.sql.Connection;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.sql.Statement;
public class TestDelete {
public static void main(String[] args) {
Connection conn=null;
Statement st=null;
ResultSet rs=null;
try{
conn= JdbcUtils.getConnection();
st=conn.createStatement();
String sql="delete from users where id=1";
int i=st.executeUpdate(sql);
if(i>0){
System.out.println("删除成功!");
}
} catch (SQLException e) {
throw new RuntimeException(e);
}
finally {
JdbcUtils.release(conn,st,rs);
}
}
}

运行结果:删除掉了id=1的那一条数据

Description

6、 查询数据(DQL)

package jdbcFirstDemo.src.lesson02;
import jdbcFirstDemo.src.lesson02.utils.JdbcUtils;
import java.sql.Connection;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.sql.Statement;
public class TestQuery {
public static void main(String[] args) throws SQLException {
Connection conn=null;
Statement st=null;
ResultSet rs=null;
conn= JdbcUtils.getConnection();
st=conn.createStatement();
//sql
String sql="select * from users";
rs=st.executeQuery(sql);
while (rs.next()){
System.out.println(rs.getString("name"));
}
}
}

运行结果:

Description

本文从开发环境搭建到代码编写步骤以及JDBC API做了详细的讲解,最后通过封装一个工具类来实现对数据库的增删改查操作,希望能够帮助你快速入门JDBC,关于数据库连接池部分,我们下期接着讲,敬请期待哦!

收起阅读 »

mac终端自定义登录欢迎语

mac终端自定义登录欢迎语 看着单调的终端,突然有了一丝丝的念头,我要搞的炫酷一点。让我想到的一个场景就是:我之前在使用公司的阿里云服务器的时候,在每次登录的时候会有欢迎语,类似于这样的: shigen手头也没有可以用的阿里云云服务器,这里在知乎上找到的...
继续阅读 »

mac终端自定义登录欢迎语



看着单调的终端,突然有了一丝丝的念头,我要搞的炫酷一点。让我想到的一个场景就是:我之前在使用公司的阿里云服务器的时候,在每次登录的时候会有欢迎语,类似于这样的:


阿里云服务器登录欢迎语



shigen手头也没有可以用的阿里云云服务器,这里在知乎上找到的文章,仅供参考哈。



那我的mac我每次打开终端的时候,也相当于一次登录呢,那我是不是也可以这样的实现,于是开始捣腾起来。


查了一下发现:



要在每次登录终端时显示自定义的欢迎语,可以编辑你的用户主目录下的.bashrc文件(如果是使用 Bash shell)或.zshrc文件(如果是使用 Zsh shell)。



好的呀,原来就是这么简单,于是去搞了一下,我用的bashzsh,自然需要编辑一下.zshrc文件了。


执行命令,更改.zshrc文件内容:


 vim ~/.zshrc

在在行尾加上如下的内容:


 #自定义欢迎语
 echo -e "\033[38;5;196m"
 cat << "EOF"
 
                                                                         
  ad88888ba   88        88  88   ,ad8888ba,   88888888888 888b      88  
 d8"     "8b  88        88  88   d8"'   `"8b  88           8888b     88  
 
Y8,          88        88  88 d8'           88           88 `8b   88  
 `Y8aaaaa,   88aaaaaaaa88 88 88             88aaaaa     88 `8b   88  
   `"""""8b, 88""""""""88 88 88     88888 88"""""     88   `8b  88  
         `8b
88       88 88 Y8,       88 88           88   `8b 88  
 
Y8a     a8P  88        88  88   Y8a.   .a88  88           88     `8888  
 
"Y88888P"   88        88  88    `"Y88888P"   88888888888 88     `888  
                                                                         
 EOF
 echo -e "\033[0m"
 echo -e "\033[1;34mToday is \033[1;32m$(date +%A,\ %B\ %d,\ %Y)\033[0m"
 echo -e "\033[1;34mThe time is \033[1;32m$(date +%r)\033[0m"
 echo -e "\033[1;34mYou are logged in to \033[1;32m$(hostname)\033[0m"

其中,自定义ascii字符生成可以参考这个网站:在线生成ascci艺术字


网站的使用


完了之后,我们只需要重新加载一下配置文件即可:


 source ~/.zshrc

就会出现如下的效果:


终端效果


我们在vscode中看看:


vscode中的显示效果


哈哈,是不是稍微酷炫一点了。就先这样子吧。


其实mac和linux的操作很多都一样,这养的配置也可以直接平移到Linux服务器上,哈哈,下次打开云服务器就会看到自定义的欢迎语了,是不是倍儿有面儿啊。




作者:shigen01
来源:juejin.cn/post/7316451458840838179
收起阅读 »

网络七层模型快速理解和记忆

网络通信分解为七个逻辑层。称为 七层网络模型,也称为OSI(Open Systems Interconnection)模型,是国际标准化组织(ISO)为计算机和通信系统制定的一种框架,用于描述信息从一个设备传输到另一个设备的过程。每一层都有特定的功能和责任: ...
继续阅读 »

网络通信分解为七个逻辑层。称为 七层网络模型,也称为OSI(Open Systems Interconnection)模型,是国际标准化组织(ISO)为计算机和通信系统制定的一种框架,用于描述信息从一个设备传输到另一个设备的过程。每一层都有特定的功能和责任:






  1. 物理层



    • 负责数据的传输通路,包括电缆、光纤、无线电波等物理介质以及信号的电压、频率、比特率等物理特性。




  2. 数据链路层



    • 负责在两个相邻节点之间可靠地传输数据帧,包括错误检测、帧同步、地址识别以及介质访问控制(MAC)。




  3. 网络层



    • 负责将数据包从源主机传输到目标主机,通过IP地址进行寻址,并可能涉及路由选择和分组转发。




  4. 传输层



    • 提供端到端的数据传输服务,如TCP(传输控制协议)提供可靠的数据传输,UDP(用户数据报协议)提供无连接的数据传输。




  5. 会话层



    • 管理不同应用程序之间的通信会话,负责建立、维护和终止会话,以及数据的同步和复用。




  6. 表示层



    • 处理数据的格式、编码、压缩和解压缩,以及数据的加密和解密,确保数据在不同系统间具有正确的表示。




  7. 应用层



    • 提供直接与用户应用程序交互的服务,如HTTP、FTP、SMTP、DNS等协议,实现文件传输、电子邮件、网页浏览等功能。




快速理解和记忆七层网络模型(OSI模型)


可以借助以下方法:




  1. 口诀法



    • 可以使用一些助记口诀来帮助记忆各层的主要功能。例如:

      • "Please Do Not Tell Stupid People Anything",这个口诀的首字母对应了七层模型从下到上的名称:Physical、Data Link、Network、Transport、Session、Presentation、Application。

      • 或者使用其他你认为更容易记忆的口诀。






  2. 功能关联法







  • 将每一层的功能与日常生活中的例子或者已知的技术概念关联起来:

    • 物理层:想象这是网络的基础结构,如电线、光纤、无线信号等。

    • 数据链路层:思考如何在一条物理链路上确保数据帧的正确传输,如同一房间内两个人通过特定的握手方式传递信息。

    • 网络层:考虑路由器的工作,它们如何根据IP地址将数据包从一个网络转发到另一个网络。

    • 传输层:TCP和UDP协议,TCP如同邮政服务保证邮件送达,UDP如同广播消息不关心是否接收。

    • 会话层:想象两个用户在电话中建立通话的过程,包括建立连接、保持通信和断开连接。

    • 表示层:数据格式转换和加密解密,就像翻译将一种语言转换为另一种语言。

    • 应用层:各种应用程序如何通过网络进行交互,如浏览网页、发送电子邮件或文件传输。






  1. 层次结构可视化



    • 画出七层模型的图表,从下到上排列各层,并在每一层旁边标注其主要功能和相关协议。




  2. 实践理解



    • 通过学习和实践网络相关的技术,如配置网络设备、编程实现网络应用等,加深对各层功能的理解。




  3. 反复复习



    • 定期回顾和复习七层模型,随着时间的推移,对各层的理解和记忆会逐渐加深。




  4. 故事联想



    • 创建一个包含七层模型元素的故事,比如描述一个信息从发送者到接收者的完整旅程,每层都是故事中的一个关键环节。




通过这些方法的综合运用,相信我们可以更快地理解和记忆七层网络模型。当然啦,理解各层之间的关系和它们在整个通信过程中的作用是关键。




好了,今天的内容就到分享这里啦,很享受与大家一起学习,沟通交流问题,如果喜欢的话,请为我点个赞吧 !👍

plus: 最近在看工作机会,base 上海,有合适的前端岗位希望可以推荐一下啦!


作者:陳有味_ChenUvi
链接:https://juejin.cn/post/7315720126988058639
收起阅读 »

IDEA 目录不显示BUG排查

之前很多次从gitLab拉下项目后,IDEA中会出现下图的情况,项目目录直接消失。每次遇到这种问题重启大法都是无往不利的,也就没太在意这个问题。但是今天的现象异常诡异,网上没有任何同类型bug,值得记录下。 bug排查 之前重启能解决的都是缓存问题,这个你自...
继续阅读 »

之前很多次从gitLab拉下项目后,IDEA中会出现下图的情况,项目目录直接消失。每次遇到这种问题重启大法都是无往不利的,也就没太在意这个问题。但是今天的现象异常诡异,网上没有任何同类型bug,值得记录下。


image.png

bug排查


之前重启能解决的都是缓存问题,这个你自己处理也不容易。不是不想深挖底层原理,而是重启更有性价比


但是,今天我在IDEA中加载了一个python项目,问题就变得复杂起来了。


之前的目录显示BUG应该是缓存问题:IDEA在运行过程中会缓存一些数据,如果缓存出现问题,可能会导致目录显示异常


缓存问题只是简单的重启就能恢复,但是这次我把IDEA的三大重启法试了个遍也没能处理这个问题。


image.png



需要注意的是,重启后不是完全没有显示目录,而是一开始显示,然后在加载过程中马上就没了。 这个现象不好截图展示,只能文字描述了。



除了缓存问题,IDEA中目录突然变为空的原因还可能有以下几种:



  1. 配置文件出错:这可能是由于某些配置文件出现了错误或损坏,导致IDEA无法正确识别项目目录。

  2. 插件冲突:某些插件可能与IDEA的某些版本不兼容,导致目录显示异常。


我比较倾向于是配置文件出错,因为一开始显示,后续马上消失就像是配置文件存在异常,我启动时加载到了配置文件,然后本来存在的目录目录就没了。


对于配置文件出错的问题,我在网络上查到很多资料说只要把.idea.iml文件删除然后重新加载就能解决。


但是经过尝试,这个方法对我来说并没有什么用,甚至我在文件目录中就没找到这两个文件。


至于插件冲突,更不合理,因为IDEA的插件是全局插件,我其他打开的项目都没有问题,就单单只有这一个存在问题,所以我连试都没试,就直接略过这个原因了。



事实上,根据最后成功解决后的结果来看就是没有配置文件的问题。



最后的解决方案


虽然,当时还没定位到原因,但是看到一些解决方案我都尝试了下,最后有效的具体操作方法如下:


1. 打开Project Stryctrue


image.png

2. 点击modules,选择import module


image.png

3. 选择IDEA中本项目的文件夹



我这边因为目录都是公司的项目,比较敏感就不放出来截图了。



选择后会出现以下界面。一路next ,然后点击apply,然后点击 OK即可。


image.png

解决结果


image.png

从最后的显示结果来看,IDEA自动生成了配置文件,项目目录不显示确实是配置文件出现了问题。


作者:DaveCui
来源:juejin.cn/post/7315260397371244559
收起阅读 »

Java中“100==100”为true,而"1000==1000"为false?

前言 今天跟大家聊一个有趣的话题,在Java中两个Integer对象做比较时,会产生意想不到的结果。 例如: Integer a = 100; Integer b = 100; System.out....
继续阅读 »

前言


今天跟大家聊一个有趣的话题,在Java中两个Integer对象做比较时,会产生意想不到的结果。


例如:


Integer a = 100;
Integer b = 100;
System.out.println(a==b);

其运行结果是:true。


而如果改成下面这样:


Integer a = 1000;
Integer b = 1000;
System.out.println(a==b);

其运行结果是:false。


看到这里,懵了没有?


为什么会产生这样的结果呢?


1 Integer对象


上面例子中的a和b,是两个Integer对象。


而非Java中的8种基本类型。


8种基本类型包括:



  • byte

  • short

  • int

  • long

  • float

  • double

  • boolean

  • char


Integer其实是int的包装类型。


在Java中,除了上面的这8种类型,其他的类型都是对象,保存的是引用,而非数据本身。


Integer a = 1000;
Integer b = 1000;

可能有些人认为是下面的简写:


Integer a = new Integer(1000);
Integer b = new Integer(1000);

这个想法表面上看起来是对的,但实际上有问题。


在JVM中的内存分布情况是下面这样的:图片在栈中创建了两个局部变量a和b,同时在堆上new了两块内存区域,他们存放的值都是1000。


变量a的引用指向第一个1000的地址。


而变量b的引用指向第二个1000的地址。


很显然变量a和b的引用不相等。


既然两个Integer对象用==号,比较的是引用是否相等,但下面的这个例子为什么又会返回true呢?


Integer a = 100;
Integer b = 100;
System.out.println(a==b);

不应该也返回false吗?


对象a和b的引用不一样。


Integer a = 1000;
Integer b = 1000;

其实正确的简写是下面这样的:


Integer a = Integer.valueOf(1000);
Integer b = Integer.valueOf(1000);

在定义对象a和b时,Java自动调用了Integer.valueOf将数字封装成对象。图片而如果数字在low和high之间的话,是直接从IntegerCache缓存中获取的数据。


图片Integer类的内部,将-128~127之间的数字缓存起来了。


也就是说,如果数字在-128~127,是直接从缓存中获取的Integer对象。如果数字超过了这个范围,则是new出来的新对象。


文章示例中的1000,超出了-128~127的范围,所以对象a和b的引用指向了两个不同的地址。


而示例中的100,在-128~127的范围内,对象a和b的引用指向了同一个地址。


所以会产生文章开头的运行结果。


为什么Integer类会加这个缓存呢?


答:-128~127是使用最频繁的数字,如果不做缓存,会在内存中产生大量指向相同数据的对象,有点浪费内存空间。


Integer a = 1000;
Integer b = 1000;

如果想要上面的对象a和b相等,我们该怎么判断呢?


2 判断相等


在Java中,如果使用==号比较两个对象是否相等,比如:a==b,其实比较的是两个对象的引用是否相等。


很显然变量a和b的引用,指向的是两个不同的地址,引用肯定是不相等的。


因此下面的执行结果是:false。


Integer a =  Integer.valueOf(1000);
Integer b = Integer.valueOf(1000);
System.out.println(a==b);

由于1000在Integer缓存的范围之外,因此上面的代码最终会变成这样:


Integer a =  new Integer(1000);
Integer b = new Integer(1000);
System.out.println(a==b);

如果想要a和b比较时返回true,该怎么办呢?


答:调用equals方法。


代码改成这样的:


Integer a = Integer.valueOf(1000);
Integer b = Integer.valueOf(1000);
System.out.println(a.equals(b));

执行结果是:true。


其实equals方法是Object类的方法,所有对象都有这个方法。图片它的底层也是用的==号判断两个Object类型的对象是否相等。


不过Integer类对该方法进行了重写:图片它的底层会先调用Integer类的intValue方法获取int类型的数据,然后再通过==号进行比较。


此时,比较的不是两个对象的引用是否相等,而且比较的具体的数据是否相等。


我们使用equals方法,可以判断两个Integer对象的值是否相等,而不是判断引用是否相等。


最近我建了新的技术交流群,打算将它打造成高质量的活跃群,欢迎小伙伴们加入。


总结


Integer类中有缓存,范围是:-128~127


Integer a = 1000;

其实默认调用了Integer.valueOf方法,将数字转换成Integer类型:


Integer a = Integer.valueOf(1000);

如果数字在-128~127之间,则直接从缓存中获取Integer对象。


如果数字在-128~127之外,则该方法会new一个新的Integer对象。


我们在判断两个对象是否相等时,一定要多注意:



  1. 判断两个对象的引用是否相等,用==号判断。

  2. 判断两个对象的值是否相等,调用equals方法判断。


作者:苏三说技术
来源:juejin.cn/post/7314365638557777930
收起阅读 »

寒冬,拒绝薪资倒挂

写在前面 今天翻看小 🍠 的时候,无意发现两组有趣数据: 一个是,互联网大厂月薪分布: 另一个是,国内互联网大厂历年校招薪资与福利汇总: 中概互联网的在金融市场的拐点。 是在 2021 年,老美出台《外国公司问责法案》开始的。 那时候,所有在美上市的中概...
继续阅读 »

写在前面


今天翻看小 🍠 的时候,无意发现两组有趣数据:


一个是,互联网大厂月薪分布:


月薪分布


另一个是,国内互联网大厂历年校招薪资与福利汇总:


研发


算法


中概互联网的在金融市场的拐点。


是在 2021 年,老美出台《外国公司问责法案》开始的。


那时候,所有在美上市的中概股面临摘牌退市,滴滴上市也被叫停。



拐点从资本市场反映到劳动招聘市场,是有滞后性的,如果没有 ChatGPT 的崛起,可能寒冬还会来得更凛冽些 ...


时代洪流的走向,我们无法左右,能够把握的,只有做好自己。


如何在寒冬来之不易的机会中,谈好待遇,拒绝薪资倒挂 🙅🏻‍♀️🙅


一方面:减少信息差,在谈判的中后期,多到职场类社区论坛(牛客/小红书/脉脉/offershow)中,了解情况


另一方面:增加自身竞争力,所有技巧在绝对实力面前,都不堪一击,如果能在笔面阶段,和其他候选人拉开足够差距,或许在后续博弈中,需要知道的套路就会越少


增强自身竞争力,尤其是走校招路线的小伙伴,建议从「算法」方面进行入手。


下面给大家分享一道常年在「字节跳动」题库中霸榜的经典题。


题目描述


平台:LeetCode


题号:25


给你一个链表,每 k 个节点一组进行翻转,请你返回翻转后的链表。


k 是一个正整数,它的值小于或等于链表的长度。


如果节点总数不是 k 的整数倍,那么请将最后剩余的节点保持原有顺序。


示例 1:


输入:head = [1,2,3,4,5], k = 2

输出:[2,1,4,3,5]

示例 2:
img


输入:head = [1,2,3,4,5], k = 3

输出:[3,2,1,4,5]

提示:



  • 列表中节点的数量在范围 sz

  • 1<=sz<=50001 <= sz <= 5000

  • 0<=Node.val<=10000 <= Node.val <= 1000

  • 1<=k<=sz1 <= k <= sz


进阶:



  • 你可以设计一个只使用常数额外空间的算法来解决此问题吗?

  • 你不能只是单纯的改变节点内部的值,而是需要实际进行节点交换。


迭代(哨兵技巧)


哨兵技巧我们在前面的多道链表题讲过,让三叶来帮你回忆一下:


做有关链表的题目,有个常用技巧:添加一个虚拟头结点(哨兵),帮助简化边界情况的判断。


链表和树的题目天然适合使用递归来做。


但这次我们先将简单的「递归版本」放一放,先搞清楚迭代版本该如何实现。


我们可以设计一个翻转函数 reverse


传入节点 root 作为参数,函数的作用是将以 root 为起点的 kk 个节点进行翻转。


当以 root 为起点的长度为 kk 的一段翻转完成后,再将下一个起始节点传入,直到整条链表都被处理完成。


当然,在 reverse 函数真正执行翻转前,需要先确保节点 root 后面至少有 kk 个节点。


我们可以结合图解再来体会一下这个过程:


假设当前样例为 1->2->3->4->5->6->7k = 3
640.png


然后我们调用 reverse(cur, k),在 reverse() 方法内部,几个指针的指向如图所示,会通过先判断 cur 是否为空,从而确定是否有足够的节点进行翻转:


然后先通过 while 循环,将中间的数量为 k - 1 的 next 指针进行翻转:


最后再处理一下局部的头结点和尾结点,这样一次 reverse(cur, k) 执行就结束了:


回到主方法,将 cur 往前移动 k 步,再调用 reverse(cur, k) 实现 k 个一组翻转:


Java 代码:


class Solution {
public ListNode reverseKGr0up(ListNode head, int k) {
ListNode dummy = new ListNode(-1);
dummy.next = head;
ListNode cur = dummy;
while (cur != null) {
reverse(cur, k);
int u = k;
while (u-- > 0 && cur != null) cur = cur.next;
}
return dummy.next;
}
// reverse 的作用是将 root 后面的 k 个节点进行翻转
void reverse(ListNode root, int k) {
// 检查 root 后面是否有 k 个节点
int u = k;
ListNode cur = root;
while (u-- > 0 && cur != null) cur = cur.next;
if (cur == null) return;
// 进行翻转
ListNode tail = cur.next;
ListNode a = root.next, b = a.next;
// 当需要翻转 k 个节点时,中间有 k - 1 个 next 指针需要翻转
while (k-- > 1) {
ListNode c = b.next;
b.next = a;
a = b;
b = c;
}
root.next.next = tail;
root.next = a;
}
}

C++ 代码:


class Solution {
public:
ListNode* reverseKGr0up(ListNode* head, int k) {
ListNode* dummy = new ListNode(-1);
dummy->next = head;
ListNode* cur = dummy;
while (cur != NULL) {
reverse(cur, k);
int u = k;
while (u-- > 0 && cur != NULL) cur = cur->next;
}
return dummy->next;
}
// reverse 的作用是将 root 后面的 k 个节点进行翻转
void reverse(ListNode* root, int k) {
// 检查 root 后面是否有 k 个节点
int u = k;
ListNode* cur = root;
while (u-- > 0 && cur != NULL) cur = cur->next;
if (cur == NULL) return;
// 进行翻转
ListNode* tail = cur->next;
ListNode* a = root->next, *b = a->next;
// 当需要翻转 k 个节点时,中间有 k - 1 个 next 指针需要翻转
while (k-- > 1) {
ListNode* c = b->next;
b->next = a;
a = b;
b = c;
}
root->next->next = tail;
root->next = a;
}
};

Python 代码:


class Solution:
def reverseKGr0up(self, head: Optional[ListNode], k: int) -> Optional[ListNode]:
# reverse 的作用是将 root 后面的 k 个节点进行翻转
def reverse(root, k):
# 检查 root 后面是否有 k 个节点
u, cur = k, root
while u > 0 and cur:
cur = cur.next
u -= 1
if not cur: return
# 进行翻转
tail = cur.next
a, b = root.next, root.next.next
# 当需要翻转 k 个节点时,中间有 k - 1 个 next 指针需要翻转
while k > 1:
c, b.next = b.next, a
a, b = b, c
k -= 1
root.next.next = tail
root.next = a

dummy = ListNode(-1)
dummy.next = head
cur = dummy
while cur:
reverse(cur, k)
u = k
while u > 0 and cur:
cur = cur.next
u -= 1
return dummy.next

TypeScript 代码:


function reverseKGr0up(head: ListNode | null, k: number): ListNode | null {
// reverse 的作用是将 root 后面的 k 个节点进行翻转
const reverse = function(root: ListNode | null, k: number): void {
// 检查 root 后面是否有 k 个节点
let u = k, cur = root;
while (u-- > 0 && cur != null) cur = cur.next;
if (cur == null) return;
// 进行翻转
let tail = cur.next, a = root.next, b = a.next;
// 当需要翻转 k 个节点时,中间有 k - 1 个 next 指针需要翻转
while (k-- > 1) {
let c = b.next;
b.next = a;
a = b;
b = c;
}
root.next.next = tail;
root.next = a;
};
let dummy = new ListNode(-1);
dummy.next = head;
let cur = dummy;
while (cur != null) {
reverse(cur, k);
let u = k;
while (u-- > 0 && cur != null) cur = cur.next;
}
return dummy.next;
};


  • 时间复杂度:会将每个节点处理一遍。复杂度为 O(n)O(n)

  • 空间复杂度:O(1)O(1)


递归


搞懂了较难的「迭代哨兵」版本之后,常规的「递归无哨兵」版本写起来应该更加容易了。


需要注意的是,当我们不使用「哨兵」时,检查是否足够 kk 位,只需要检查是否有 k1k - 1nextnext 指针即可。


代码:


class Solution {
public ListNode reverseKGr0up(ListNode head, int k) {
int u = k;
ListNode p = head;
while (p != null && u-- > 1) p = p.next;
if (p == null) return head;
ListNode tail = head;
ListNode prev = head, cur = prev.next;
u = k;
while (u-- > 1) {
ListNode tmp = cur.next;
cur.next = prev;
prev = cur;
cur = tmp;
}
tail.next = reverseKGr0up(cur, k);
return prev;
}
}

C++ 代码:


class Solution {
public:
ListNode* reverseKGr0up(ListNode* head, int k) {
int u = k;
ListNode* p = head;
while (p != NULL && u-- > 1) p = p->next;
if (p == NULL) return head;
ListNode* tail = head;
ListNode* prev = head, *cur = prev->next;
u = k;
while (u-- > 1) {
ListNode* tmp = cur->next;
cur->next = prev;
prev = cur;
cur = tmp;
}
tail->next = reverseKGr0up(cur, k);
return prev;
}
};

Python 代码:


class Solution:
def reverseKGr0up(self, head: Optional[ListNode], k: int) -> Optional[ListNode]:
u = k
p = head
while p and u > 1:
p = p.next
u -= 1
if not p: return head

tail = prev = head
cur = prev.next
u = k
while u > 1:
tmp = cur.next
cur.next = prev
prev, cur = cur, tmp
u -= 1
tail.next = self.reverseKGr0up(cur, k)
return prev

TypeScript 代码:


function reverseKGr0up(head: ListNode | null, k: number): ListNode | null {
let u = k;
let p = head;
while (p != null && u-- > 1) p = p.next;
if (p == null) return head;
let tail = head, prev = head, cur = prev.next;
u = k;
while (u-- > 1) {
let tmp = cur.next;
cur.next = prev;
prev = cur;
cur = tmp;
}
tail.next = reverseKGr0up(cur, k);
return prev;
};


  • 时间复杂度:会将每个节点处理一遍。复杂度为 O(n)O(n)

  • 空间复杂度:只有忽略递归带来的空间开销才是 O(1)O(1)


更多更全更热门的「笔试/面试」相关资料可访问排版精美的 合集新基地 🎉🎉


作者:宫水三叶的刷题日记
来源:juejin.cn/post/7314263159116628009
收起阅读 »

当接口要加入新方法时,我后悔没有早点学设计模式了

假设系统中有一个接口,这个接口已经被10个实现类实现了,突然有一天,新的需求来了,其中5个实现类需要实现同一个方法。然后你就在接口中添加了这个方法的定义,想着一切都很完美。 当你在接口和其中5个实现类中加完这个方法后,一编译。不妙啊,另外那 5 个实现类报错了...
继续阅读 »

假设系统中有一个接口,这个接口已经被10个实现类实现了,突然有一天,新的需求来了,其中5个实现类需要实现同一个方法。然后你就在接口中添加了这个方法的定义,想着一切都很完美。


当你在接口和其中5个实现类中加完这个方法后,一编译。不妙啊,另外那 5 个实现类报错了,没实现新加的这个方法。要知道,接口中的方法定义必须要在实现类中实现才行,缺一个都编译不过。


这时候你耳边突然响起了开发之初的老前辈跟你说的话:“这几个实现以后可能差距越来越大,接口中可能会加方法,注意留口子”。



现在咋整


假设之前的接口是这样的,只有吃饭和喝水两个方法。


public interface IUser {

/**
* 吃饭啊
*/

void eat();

/**
* 喝水啊
*/

void drink();
}

现在有 5 个实现类厉害了,要加一个 play() 方法。


既然情况已经这样了,现在应该怎么处理。


破罐子破摔吧,走你


不管什么接口不接口的了,哪个实现类要加,就直接在那个实现类里加吧,接口还保持之前的样子不动,仍然只有吃饭和喝水两个方法,play 方法就直接加到 5 个实现类中。


public class UserOne implements IUser{

@Override
public void eat() {
System.out.println("吃饭");
}

@Override
public void drink() {
System.out.println("喝水");
}

public void play() {
System.out.println("玩儿");
}
}

虽然可以实现,但是完全背离了当初设计接口的初衷,本来是照着五星级酒店盖的,结果盖了一层之后,上面的变茅草屋了。


从此以后,接口是接口,实现类是实现类,基本上也就没什么关系了。灵活性倒是出来了,以后想在哪个实现类加方法就直接加了。



再加一个接口行不


还是有点儿追求吧,我新加一个接口行不行。之前的接口不动,新建一个接口,这个接口除了包含之前的两个方法外,再把 play 方法加进去。


这样一来,把需要实现 play 方法的单独在弄一个接口出来。就像下面这样 IUser是之前的接口。IUserExtend接口是新加的,加入了 play() 方法,需要实现 play() 方法的实现类改成实现新的IUserExtend接口,只改几个实现关系,改动不是很大嘛,心满意足了。



但是好景不长啊,过了几天,又要加新方法了,假设是上图的 UserOneUserNine要增加方法,怎么办呢?



假如上天再给我一次机会


假如上天再给我一次重来的机会,我会对自己说:“别瞎搞,看看设计模式吧”。


适配器模式


适配器模式可以通过创建一个适配器类,该适配器类实现接口并提供默认实现,然后已有的实现类可以继承适配器类而不是直接实现接口。这样,已有的实现类不需要修改,而只需要在需要覆盖新方法的实现类中实现新方法。



不是要加个 play() 方法吗,没问题,直接在接口里加上。


public interface IUser {
void eat();
void drink();
void play();
}

适配器类很重要,它是一个中间适配层,是一个抽象类。之前不是实现类直接 implements 接口类吗,而现在适配器类 implements 接口类,而实现类 extends 适配器类。


在适配器类可以给每个方法一个默认实现,当然也可以什么都不干。


public abstract class UserAdapter implements IUser {
@Override
public void eat() {
// 默认实现
}

@Override
public void drink() {
// 默认实现
}

@Override
public void play() {
// 默认实现
}
}

public class UserNine extends UserAdapter {
@Override
public void eat() {
System.out.println("吃饭");
}

@Override
public void drink() {
System.out.println("喝水");
}

@Override
public void play() {
System.out.println("玩儿");
}
}

public class UserTen extends UserAdapter {
@Override
public void eat() {
System.out.println("吃饭");
}

@Override
public void drink() {
System.out.println("喝水");
}
}

调用方式:


IUser userNine = new UserNine();
userNine.eat();
userNine.drink();
userNine.play();

IUser userTen = new UserTen();
userTen.eat();
userTen.drink();

这样一来,接口中随意加方法,然后在在适配器类中添加对应方法的默认实现,最后在需要实现新方法的实现类中加入对应的个性化实现就好了。


策略模式


策略模式允许根据不同的策略来执行不同的行为。在这种情况下,可以将新方法定义为策略接口,然后为每个需要实现新方法的实现类提供不同的策略。


把接口改成抽象类,这里面 eat() 和 drink() 方法不变,可以什么都不做,实现类里想怎么自定义都可以。


而 play() 这个方法是后来加入的,所以我们重点关注 play() 方法,策略模式里的策略就用在 play() 方法上。


public abstract class AbstractUser {

IPlayStrategy playStrategy;

public void setPlayStrategy(IPlayStrategy playStrategy){
this.playStrategy = playStrategy;
}

public void play(){
playStrategy.play();
}

public void eat() {
// 默认实现
}

public void drink() {
// 默认实现
}
}

IPlayStrategy是策略接口,策略模式是针对行为的模式,玩儿是一种行为,当然了,你可以把之后要添加的方法都当做行为来处理。


我们定一个「玩儿」这个行为的策略接口,之后不管你玩儿什么,怎么玩儿,都可以实现这个 IPlayStrategy接口。


public interface IPlayStrategy {

void play();
}

然后现在做两个实现类,实现两种玩儿法。


第一个玩儿游戏的实现


public class PlayGameStrategy implements IPlayStrategy{

@Override
public void play() {
System.out.println("玩游戏");
}
}

第二个玩儿足球的实现


public class PlayFootballStrategy implements IPlayStrategy{
@Override
public void play() {
System.out.println("玩儿足球");
}
}

然后定义 AbstractUser的子类


public class UserOne extends AbstractUser{
@Override
public void eat() {
//自定义实现
}

@Override
public void drink() {
//自定义实现
}
}

调用方式:


public static void main(String[] args) {
AbstractUser userOne = new UserOne();
// 玩儿游戏
userOne.setPlayStrategy(new PlayGameStrategy());
userOne.play();
// 玩儿足球
userOne.setPlayStrategy(new PlayFootballStrategy());
userOne.play();
}

整体的类关系图大概是这个样子:



最后


通过适配器模式和策略模式,我们即可以保证具体的实现类实现共同的接口或继承共同的基类,同时,又能在新增功能(方法)的时候,尽可能的保证设计的清晰。不像之前那种破罐子破摔的方式,接口和实现类几乎脱离了关系,每个实现类,各玩儿各的。


作者:古时的风筝
来源:juejin.cn/post/7313418992310976549
收起阅读 »

云音乐自研客户端UI自动化项目 - Athena

背景 网易云音乐是一款大型的音乐平台App,除了音乐业务外,还承接了直播、K歌、mlog、长音频等业务。整体的P0、P1级别的测试用例多达 3000 多个,在现代互联网敏捷高频迭代的情况下,留给测试回归的时间比较有限,云音乐目前采用双周迭代的模式,具体如下图所...
继续阅读 »





背景


网易云音乐是一款大型的音乐平台App,除了音乐业务外,还承接了直播、K歌、mlog、长音频等业务。整体的P0、P1级别的测试用例多达 3000 多个,在现代互联网敏捷高频迭代的情况下,留给测试回归的时间比较有限,云音乐目前采用双周迭代的模式,具体如下图所示:


image


每个迭代仅给测试留 1.5 天的回归测试时间,在此背景下,云音乐采用了一种折中的方式,即挑选一些核心链路的核心场景进行回归测试,不做全量回归。这样的做法实际是舍弃了一些线上质量为代价,这也导致时不时的会有些低级的错误带到线上。


在这样的背景下我们的测试团队也尝试了一些业内的UI自动化框架,但是整体的执行结果离我们的预期差距较大,主要体现在用例录入成本、用例稳定性、执行效率、执行成功率等维度上,为此我们希望结合云音乐的业务和迭代特点,并参考业内框架的优缺点设计一套符合云音乐的自动化测试框架。


核心关注点


接下来我们来看下目前自动化测试主要关心点:



  • 用例录入成本


即用例的生成效率,因为用例的基数比较庞大,并且可预见的是未来用例一定会一直膨胀,所以对于用例录入成本是我们非常关注的点。目前业内的自动化测试框架主要有如下几种方式:



  1. 高级或脚本语言



高级或脚本语言在使用门槛上过高,需要用例录入同学有较好的语言功底,几乎每一条用例都是一个程序,即使是一位对语言相对熟悉的测试同学,每日的生产用例条数也都会比较有限;




  1. 自然语言


场景: 验证点击--点击屏幕位置
当 启动APP[云音乐]
而且 点击屏幕位置[580,1200]
而且 等待[5]
那么 全屏截图
那么 关闭App


如上这段即为一个自然语言描述的例子,自然语言在一定程度上降低了编程门槛,但是自然语言仍然避免不了程序开发调试的过程,所以在效率仍然比较低下;




  1. ide 工具等



AirTest 则提供了ide工具,利用拖拽的能力降低了元素查找的编写难度,但是仍然避免不了代码编写的过程,而且增加了环境安装、设备准备、兼容调试等也增加了一些额外的负担。




  1. 操作即用例



完全摒弃手写代码的形式,用所见操作即所得的用例录制方式。此方式没有编程的能力要求,而且录入效率远超其他三种方式,这样的话即可利用测试外包同学快速的将用例进行录入。目前业内开源的solopi即采用此方式。



如上分析,在用例录入维度,也只有录制回放的形式是能满足云音乐的诉求。



  • 用例执行稳定性


即经过版本迭代后,在用例逻辑和路径没有发生变化的情况下,用例仍然能稳定执行。


理论上元素的布局层次或者位置发生变化都不应该影响到用例执行,特别是一些复杂的核心场景,布局层次和位置是经常发生变化的,如果导致相关路径上的用例执行都不再稳定,这将是一场灾难(所有受到影响的用例都将重新录入或者编辑,在人力成本上将是巨大的)。


这个问题目前在业内没有一套通用的行之有效的解决方案,在Android 侧一般在写UI界面时每个元素都会设置一个id,所以在Android侧可以依据这个id进行元素的精准定位;但是iOS 在写UI时不会设置唯一id,所以在iOS侧相对通用的是通过xpath的方式去定位元素,基于xpath就会受到布局层次和位置变化的影响。



  • 用例执行效率


即用例完整执行的耗时,这里耗时主要体现在两方面:



  1. 用例中指令传输效率



业内部分自动化框架基于webdriver驱动的c/s模型,传输和执行上都是以指令粒度来的,所以这类方式的网络传输的影响就会被放大,导致整体效率较低;




  1. 用例中元素定位的效率



相当一部分框架是采用的黑盒方式,这样得通过跨进程的方式dump整个页面,然后进行遍历查找;



用例执行效率直接决定了在迭代周期内花费在用例回归上的时间长短,如果能做到小时级别回归,那么所有版本(灰度、hotfix等)均能在上线前走一遍用例回归,对线上版本质量将会有较大帮助。



  • 用例覆盖度


即自动化测试框架能覆盖的测试用例的比例,这个主要取决于框架能力的覆盖范围和用例的性质。比如在视频播放场景会有视频进度拖拽的交互,如果框架不具备拖拽能力,这类用例就无法覆盖。还有些用例天然不能被自动化覆盖,比如一些动画场景,需要观察动画的流畅度,以及动画效果。


自动化框架对用例的覆盖度直接影响了人力的投入,如果覆盖度偏低的话,没法覆盖的用例还是得靠人工去兜底,成本还是很高。所以在UI自动化框架需要能覆盖的场景多,这样才能有比较好的收益,业内目前优秀的能做到70%左右的覆盖度。



  • 执行成功率


即用例执行成功的百分比,主要有两方面因素:



  1. 单次执行用例是因为用例发生变化导致失败,也就是发现了问题;

  2. 因为一些系统或者环境的因素,在用例未发生改变的情况下,用例执行失败;


所以一个框架理想的情况下应该是除了用例发生变化导致的执行失败外,其他的用例应该都执行成功,这样人为去验证失败用例的成本就会比较低。


业内主流框架对比


在分析了自动化框架需要满足的这些核心指标后,对比了业内主流的自动化测试框架,整体如下:


维度UIAutomatorXCUITestAppiumSmartAutoAirTestSolopi
录入成本使用Java编写用例,门槛高使用OC语言编写,门槛高使用python/java编写用例,门槛高,且调试时间长自然语言编写,但是理解难度和调试成本仍然高基于ide+代码门槛高操作即用例,成本低
执行稳定性较高一般一般一般一般较高
执行效率较高较高一般一般一般较高
系统支持单端(安卓)单端(iOS)单端(安卓)

注:因用例覆盖度和执行成功率不光和自动化框架本身能力相关,还关联到配套能力的完善度(接口mock能力,测试账号等),所以没有作为框架的对比维度


整体对比下来,没有任何一款自动框架能满足我们业务的诉求。所以我们不得不走上自研的道路。


解决思路


再次回到核心的指标上来:


用例录入成本:我们可以借鉴solopi的方式(操作即用例),Android已经有了现成的方案,只需要我们解决iOS端的录制回放能力即可。


用例执行稳定性:因为云音乐有曙光埋点(自研的一套多端统一的埋点方案),核心的元素都会绑定双端统一的点位,所以可以基于此去做元素定位,在有曙光点的情况下使用曙光点,如果没有曙光点安卓则降级到元素唯一id去定位,iOS则降级到xpath。这样即可以保证用例的稳定性,同时在用例都有曙光点的情况下,双端的用例可以达到复用的效果(定义统一的用例描述格式即可)。


用例执行效率:因为可以采用曙光点,所以在元素定位上只要我们采用白盒的方式,即可实现元素高效的定位。另外对于网络传输问题,我们采用以用例粒度来进行网络传输(即接口会一次性将一条完整的用例下发到调度机),即可解决指令维度传输导致的效率问题。


用例覆盖度&执行成功率:在框架能力之余,我们需要支持很多的周边能力,比如首页是个性化推荐,对于这类场景我们需要有相应的网络mock能力。一些用例会关联到账号等级,所以多账号系统支持也需要有。为了方便这些能力,我们在用例的定义上增加了前置条件和后置动作和用例进行绑定。这样在执行一些特定用例时,可以自动的去准备执行环境。


在分析了这些能力都可以支持之后,我们梳理了云音乐所有的用例,评估出来我们做完这些,是可以达到70%的用例覆盖,为此云音乐的测试团队和大前端团队合作一起立了自动化测试项目- Athena


设计方案


用例双端复用,易读可编辑


首先为了达到双端用例可复用,设计一套双端通用的用例格式,同时为了用例方便二次编辑,提升其可读性,我们采用json的格式去定义用例。
eg:


image


Android端设计


因为 Solopi 有较好的录制回放能力,并且有完整的基于元素id定位元素的能力,所以这部分我们不打算重复造轮子,而是直接拿来主义,基于 Solopi 工程进行二次开发,集成曙光相关逻辑,并且支持周边相关能力建设即可。因为 Solopi 主要依赖页面信息,基于 Accessibility 完全能满足相关诉求,所以 Solopi 是一个黑盒的方案,我们考虑到曙光相关信息透传,以及周边能力信息透传,所以我们采用了白盒的方式,在 app 内部会集成一个 sdk,这个 sdk 负责和独立的测试框架 app 进行通讯。
架构图如下:
image


iOS 端设计


iOS 在业内没有基于录制回放的自动化框架,并且其他的框架与我们的目标差距均较大,所以在 iOS 侧,我们是从 0 开始搭建一整套框架。其中主要的难点是录制回放的能力,在录制时,对于点击、双击、长按、滑动分别 hook 的相关 api 方法,对于键盘输入,因为不在 app 进程,所以只能通过交互工具手动记录。在回放时,基于 UIEvent 的一些私有 api 方法实现 UI 组件的操作执行。


在架构设计上,iOS 直接采用 sdk 集成进测试 app 的白盒形式,这样各种数据方便获取。同时在本地会起一个服务用于和平台通讯,同时处理和内嵌 sdk 的指令下发工作。


image


双端执行流程


整体的录制流程如下:


image


回放流程:


image


录制回放效果演示:



接口mock能力


对于个性推荐结果的不确定性、验证内容的多样性,我们打通了契约平台(接口 mock 平台),实现了接口参数级别的方法 mock,精准配置返回结果,将各个类型场景一网打尽。主要步骤为,在契约平台先根据要 mock 的接口配置相应参数和返回结果,产生信息二维码,再用客户端扫码后将该接口代表,在该接口请求时会在请求头中添加几个自定义的字段,网关截获这些请求后,先识别自定义字段是否有 mock 协议,若有,则直接导流到契约平台返回配置结果。


mock 方案:


image


平台


saturn 平台作为自动化操作的平台,将所有和技术操作、代码调度的功能均在后台包装实现,呈现给用户的统一为交互式操作平台的前端。包括用例创建更改、执行机创建编辑、执行机执行、自定义设备、定时执行任务等功能;


image


image


问题用例分析效率


在用例执行时,我们会记录下相应操作的截图、操作日志以及操作视频为执行失败的用例提供现场信息。通过这些现场信息,排查问题简单之极,提缺陷也极具说服力,同时在问题分析效率上也极高。


image


私有化云机房建设


云音乐通过参考 android 的 stf、open-atx-server 等开源工程,结合自身业务特点,实现了即可在云端创建分发任务、又即插即用将设备随时变为机房设备池设备的平台,对 android 和 iOS 双端系统都支持云端操作,且具备去中心化的私有化部署能力。


image


私有化机器池:


image


整体架构


image


落地情况


在框架侧,我们的录入效率对比如下:


image


用例执行效率:


image


目前在云音乐中,已经对客户端 P0 场景的用例进行覆盖,并且整体覆盖率已经达到 73%。双端的执行成功率超过 90%。


具体覆盖情况:


image


具体召回的用例情况:


image


对于迭代周期中,之前 1.5天 大概投入 15人日 进行用例归回,现在花 0.5天,投入约 6人日,提效超过 60%


现在 Athena 不光用在云音乐业务用例回归,在云音乐的其他业务中也在推广使用。


总结


本文介绍了云音乐在UI自动化测试上的一站式解决方案,采用录制的方式解决录制门槛高、效率低下的问题,在回放过程中前置准备用例执行环境以及结合曙光埋点提升用例执行的稳定性,并且会保留执行过程中的现场信息以便后续溯因。最后通过私有云部署,在云端即可统一调度Android和iOS设备来执行任务。目前该套方案在云音乐所有业务线均已覆盖,我们未来会在自动化测试方面继续探索和演进,争取积累更多的经验与大家交流分享。


作者:网易云音乐技术团队
来源:juejin.cn/post/7313501001788964898
收起阅读 »

Swift Rust Kotlin 三种语言的枚举类型比较

本文比较一下 Swift Rust Kotlin 三种语言中枚举用法的异同。 定义比较 基本定义 Swift: enum LanguageType { case rust case swift case kotlin } Rus...
继续阅读 »

本文比较一下 Swift Rust Kotlin 三种语言中枚举用法的异同。


定义比较


基本定义



  • Swift:


enum LanguageType {
case rust
case swift
case kotlin
}


  • Rust:


enum LanguageType {
Rust,
Swift,
Kotlin,
}


  • Kotlin:


enum class LanguageType {
Rust,
Swift,
Kotlin,
}

三种语言都使用 enum 作为关键字,Swift 枚举的选项叫 case 一般以小写开头,Rust 枚举的选项叫 variant 通常以大写开头,Kotlin 枚举的选项叫 entry 以大写开头。kotlin 还多了一个 class 关键字,表示枚举类,具有类的一些特殊特性。在定义选项时,Swift 需要一个 case 关键字,而另外两种语言只需要逗号隔开即可。


带值的定义


三种语言的枚举在定义选项时都可以附带一些值。



  • 在 Swift 中这些值叫关联值(associated value),每个选项可以使用同一种类型,也可以使用不同类型,可以有一个值,也可以有多个值,值还可以进行命名。这些值在枚举创建时再进行赋值:


enum Language {
case rust(Int)
case swift(Int)
case kotlin(Int)
}

enum Language {
case rust(Int, String)
case swift(Int, Int, String)
case kotlin(Int)
}

enum Language {
case rust(year: Int, name: String)
case swift(year: Int, version: Int, name: String)
case kotlin(year: Int)
}

let language = Language.rust(2015)


  • 在 Rust 中这些值也叫关联值(associated value),和 Swift 类似每个选项可以定义一个或多个值,使用相同或不同类型,但不能直接进行命名,同样也是在枚举创建时赋值::


enum Language {
Rust(u16),
Swift(u16),
Kotlin(u16),
}

enum Language {
Rust(u16, String),
Swift(u16, u16, String),
Kotlin(u16),
}

let language = Language::Rust(2015);

如果需要给属性命名可以将关联值定义为匿名结构体:


enum Language {
Rust { year: u16, name: String },
Swift { year: u16, version: u16, name: String },
Kotlin { year: u16 },
}


  • 在 Kotlin 中选项的值称为属性或常量,所有选项的属性类型和数量都相同(因为它们都是枚举类的实例),而且值需要在枚举定义时就提供


enum class Language(val year: Int) {
Rust(2015),
Swift(2014),
Kotlin(2016),
}

所以这些值如果是通过 var 定义的就可以修改:


enum class Language(var year: Int) {
Rust(2015),
Swift(2014),
Kotlin(2016),
}

val language = Language.Kotlin
language.year = 2011

范型定义


Swift 和 Rust 定义枚举时可以使用范型,最常见的就是可选值的定义。



  • Swift:


enum Option<T> {
case none
case some(value: T)
}


  • Rust:


enum Option<T> {
,
Some(T),
}

但是 Kotlin 不支持定义枚举时使用范型。


递归定义


Swift 和 Rust 定义枚举的选项时可以使用递归,即选项的类型是枚举自身。



  • Swift 定义带递归的选项时需要添加关键字 indirect:


enum ArithmeticExpression {
case number(Int)
indirect case addition(ArithmeticExpression, ArithmeticExpression)
indirect case multiplication(ArithmeticExpression, ArithmeticExpression)
}


  • Rust 定义带递归的选项时需要使用间接访问的方式,包括这些指针类型: Box, Rc, Arc, &&mut,因为 Rust 需要在编译时知道类型的大小,而递归枚举类型的大小在没有引用的情况下是无限的。


enum ArithmeticExpression {
Number(u8),
Addition(Box<ArithmeticExpression>, Box<ArithmeticExpression>),
Multiplication(Box<ArithmeticExpression>, Box<ArithmeticExpression>),
}

Kotlin 同样不支持递归枚举。


模式匹配


模式匹配是枚举最常见的用法,最常见的是使用 switch / match / when 语句进行模式匹配:



  • Swift:


switch language {
case .rust(let year):
print(year)
case .swift(let year):
print(year)
case .kotlin(let year):
print(year)
}


  • Rust:


match language {
Language::Rust(year) => println!("Rust was first released in {}", year),
Language::Swift(year) => println!("Swift was first released in {}", year),
Language::Kotlin(year) => println!("Kotlin was first released in {}", year),
}


  • Kotlin:


when (language) {
Language.Kotlin -> println("Kotlin")
Language.Rust -> println("Rust")
Language.Swift -> println("Swift")
}

也可以使用 if 语句进行模式匹配:



  • Swift:


if case .rust(let year) = language {
print(year)
}

// 或者使用 guard
guard case .rust(let year) = language else {
return
}
print(year)

// 或者使用 while case
var expression = ArithmeticExpression.multiplication(
ArithmeticExpression.multiplication(
ArithmeticExpression.number(1),
ArithmeticExpression.number(2)
),
ArithmeticExpression.number(3)
)
while case ArithmeticExpression.multiplication(let left, _) = expression {
if case ArithmeticExpression.number(let value) = left {
print("Multiplied \(value)")
}
expression = left
}


  • Rust:


if let Language::Rust(year) = language {
println!("Rust was first released in {}", year);
}

// 或者使用 let else 匹配
let Language::Rust(year) = language else {
return;
};
println!("Rust was first released in {}", year);

// 还可以使用 while let 匹配
let mut expression = ArithmeticExpression::Multiplication(
Box::new(ArithmeticExpression::Multiplication(
Box::new(ArithmeticExpression::Number(2)),
Box::new(ArithmeticExpression::Number(3)),
)),
Box::new(ArithmeticExpression::Number(4)),
);

while let ArithmeticExpression::Multiplication(left, _) = expression {
if let ArithmeticExpression::Number(value) = *left {
println!("Multiplied: {}", value);
}
expression = *left;
}

Kotlin 不支持使用 if 语句进行模式匹配。


枚举值集合


有时需要获取枚举的所有值。



  • Swift 枚举需要实现 CaseIterable 协议,通过 AllCases() 方法可以获取所有枚举值。同时带有关联值的枚举无法自动实现 CaseIterable 协议,需要手动实现。


enum Language: CaseIterable {
case rust
case swift
case kotlin
}

let cases = Language.AllCases()


  • Rust 没有内置获取所有枚举值的方法,需要手动实现,另外带有关联值的枚举类型提供所有枚举值可能意义不大:


enum Language {
Rust,
Swift,
Kotlin,
}

impl Language {
fn all_variants() -> Vec<Language> {
vec![
Language::Rust,
Language::Swift,
Language::Kotlin,
]
}
}


  • Kotlin 提供了 values() 方法获取所有枚举值,同时支持带属性和不带属性的:


val allEntries: Array<Language> = Language.values()

原始值


枚举类型还有一个原始值,或者叫整型表示的概念。



  • Swift 可以为枚举的每个选项提供原始值,在声明时进行指定。


Swift 枚举的原始值可以是以下几种类型:



  1. 整数类型:如 Int, UInt, Int8, UInt8 等。

  2. 浮点数类型:如 Float, Double

  3. 字符串类型:String

  4. 字符类型:Character


enum Language: Int {
case rust = 1
case swift = 2
case kotlin = 3
}

带关联值的枚举类型不能同时提供原始值,而提供原始值的枚举可以直接从原始值创建枚举实例,不过实例是可选类型:


let language: Language? = Language(rawValue: 1)

在 Swift 中,提供枚举的原始值主要有以下作用:



  1. 数据映射:原始值允许枚举与基础数据类型(如整数或字符串)直接关联。这在需要将枚举值与外部数据(例如从 API 返回的字符串)匹配时非常有用。

  2. 简化代码:使用原始值可以简化某些操作,例如从原始值初始化枚举或获取枚举的原始值,而无需编写额外的代码。

  3. 可读性和维护性:在与外部系统交互时,原始值可以提供清晰、可读的映射,使代码更容易理解和维护。



  • Rust 使用 #[repr(…)] 属性来指定枚举的整数类型表示,并为每个变体分配一个可选的整数值,带和不带关联值的枚举都可以提供整数表示。


#[repr(u32)]
enum Language {
Rust(u16) = 2015,
Swift(u16) = 2014,
Kotlin(u16) = 2016,
}

在 Rust 中,为枚举提供整数表示(整数值或整数类型标识)主要有以下作用:



  1. 与外部代码交互:整数表示允许枚举与 C 语言或其他低级语言接口,因为这些语言通常使用整数来表示枚举。

  2. 内存效率:指定整数类型可以控制枚举占用的内存大小,这对于嵌入式系统或性能敏感的应用尤为重要。

  3. 值映射:通过整数表示,可以将枚举直接映射到整数值,这在处理像协议代码或状态码等需要明确值的情况下很有用。

  4. 序列化和反序列化:整数表示简化了将枚举序列化为整数值以及从整数值反序列化回枚举的过程。



  • Kotlin 没有单独的原始值概念,因为它已经提供了属性。


方法实现


三种枚举都支持方法实现。



  • Swift


enum Language {
case rust(Int)
case swift(Int)
case kotlin(Int)

func startYear() -> Int {
switch self {
case .kotlin(let year): return year
case .rust(let year): return year
case .swift(let year): return year
}
}
}


  • Rust


enum Language {
Rust(u16),
Swift(u16),
Kotlin(u16),
}

impl Language {
fn start_year(&self) -> u16 {
match self {
Language::Rust(year) => *year,
Language::Swift(year) => *year,
Language::Kotlin(year) => *year,
}
}
}


  • Kotlin


enum class Language(var year: Int) {
Rust(2015),
Swift(2014),
Kotlin(2016);

fun yearsSinceRelease(): Int {
val year: Int = java.time.Year.now().value
return year - this.year
}
}

它们还支持对协议、接口的实现:



  • Swift


protocol Versioned {
func latestVersion() -> String
}

extension Language: Versioned {
func latestVersion() -> String {
switch self {
case .kotlin(_): return "1.9.0"
case .rust(_): return "1.74.0"
case .swift(_): return "5.9"
}
}
}


  • Rust


trait Version {
fn latest_version(&self) -> String;
}

impl Version for Language {
fn latest_version(&self) -> String {
match self {
Language::Rust(_) => "1.74.0".to_string(),
Language::Swift(_) => "5.9".to_string(),
Language::Kotlin(_) => "1.9.0".to_string(),
}
}
}


  • Kotlin


interface Versioned {
fun latestVersion(): String
}

enum class Language(val year: Int): Versioned {
Rust(2015) {
override fun latestVersion(): String {
return "1.74.0"
}
},
Swift(2014) {
override fun latestVersion(): String {
return "5.9"
}
},
Kotlin(2016) {
override fun latestVersion(): String {
return "1.9.0"
}
};
}

这三者对接口的实现还有一个区别,Swift 和 Rust 可以在枚举定义之后再实现某个接口,而 Kotlin 必须在定义时就完成对所有接口的实现。不过它们都能通过扩展增加新的方法:



  • Swift


extension Language {
func name() -> String {
switch self {
case .kotlin(_): return "Kotlin"
case .rust(_): return "Rust"
case .swift(_): return "Swift"
}
}
}


  • Rust


impl Language {
fn name(&self) -> String {
match self {
Language::Rust(_) => "Rust".to_string(),
Language::Swift(_) => "Swift".to_string(),
Language::Kotlin(_) => "Kotlin".to_string(),
}
}
}


  • Kotlin


fun Language.name(): String {
return when (this) {
Language.Kotlin -> "Kotlin"
Language.Rust -> "Rust"
Language.Swift -> "Swift"
}
}

内存大小



  • Swift 的枚举大小取决于其最大的成员和必要的标签空间。若包含关联值,枚举的大小会增加以容纳这些值。可以使用 MemoryLayout 来估算大小。


enum Language {
case rust(Int, String)
case swift(Int, Int, String)
case kotlin(Int)
}

print(MemoryLayout<Language>.size) // 33


  • Rust 中的枚举大小通常等于其最大变体的大小加上一个用于标识变体的额外空间。使用 std::mem::size_of 来获取大小,提供了整型表示的枚举类型大小等于整型值大小加上最大变体大小,同时还要考虑内存对齐的因素。


enum Language1 {
Rust,
Swift,
Kotlin,
}
println!("{} bytes", size_of::<Language1>()); // 1 bytes

#[repr(u32)]
enum Language2 {
Rust,
Swift,
Kotlin,
}
println!("{} bytes", size_of::<Language2>()); // 4 bytes

#[repr(u32)]
enum Language3 {
Rust(u16),
Swift(u16),
Kotlin(u16),
}
println!("{} bytes", size_of::<Language3>()); // 8 bytes

对于上面例子中 Language3 的内存大小做一个简单解释:



  • 枚举的变体标识符(因为 #[repr(u32)])占用 4 字节。

  • 最大的变体是一个 u16 类型,占用 2 字节。

  • 可能还需要额外的 2 字节的填充,以确保整个枚举的内存对齐,因为它的对齐要求由最大的 u32 决定。


因此,总大小是 4(标识符)+ 2(最大变体)+ 2(填充)= 8 字节。



  • Kotlin:在 JVM 上,Kotlin 枚举的大小包括对象开销、枚举常量的数量和任何附加属性。Kotlin 本身不提供直接的内存布局查看工具,需要依赖 JVM 工具或库。


兼容性


Swift 枚举有时需要考虑与 Objective-C 的兼容,Rust 需要考虑与 C 的兼容。



  • 在 Swift 中,要使枚举兼容 Objective-C,你需要满足以下条件:



  1. 原始值类型:Swift 枚举必须有原始值类型,通常是 Int,因为 Objective-C 不支持 Swift 的关联值特性。

  2. 遵循 @objc 协议:在枚举定义前使用 @objc 关键字来标记它。这使得枚举可以在 Objective-C 代码中使用。


    @objc
    enum Language0: Int {
    case rust = 1
    case swift = 2
    case kotlin = 3

    func startYear() -> Int {
    switch self {
    case .kotlin: return 2016
    case .rust: return 2015
    case .swift: return 2014
    }
    }
    }


  3. 限制:使用 @objc 时,枚举不能包含关联值,必须是简单的值列表。


这样定义的枚举可以在 Swift 和 Objective-C 之间交互使用,适用于混合编程环境或需要在 Objective-C 项目中使用 Swift 代码的场景。



  • Rust 枚举与 C 兼容,只需要使用 #[repr(C)] 属性来指定枚举的内存布局。这样做确保枚举在内存中的表示与 C 语言中的枚举相同。


#[repr(C)]
enum Language0 {
Rust,
Swift,
Kotlin,
}

注意:在 Rust 中,你不能同时在枚举上使用 #[repr(C)]#[repr(u32)]。每个枚举只能使用一个 repr 属性来确定其底层的数据表示。如果你需要确保枚举与 C 语言兼容,并且具有特定的基础整数类型,你应该选择一个符合你需求的 repr 属性。例如,如果你想让枚举在内存中的表示与 C 语言中的 u32 类型的枚举相同,你可以使用 #[repr(u32)]



  • 在 Kotlin 中,枚举类(Enum Class)是与 Java 完全兼容的。Kotlin 枚举可以自然地在 Java 代码中使用,反之亦然。


作者:镜画者
来源:juejin.cn/post/7313589252172120098
收起阅读 »

why哥悄悄的给你说几个HashCode的破事。

Hash冲突是怎么回事在这个文章正式开始之前,先几句话把这个问题说清楚了:我们常说的 Hash 冲突到底是怎么回事?直接上个图片:你说你看到这个图片的时候想到了什么东西?有没有想到 HashMap 的数组加链表的结构?对咯,我这里就是以 HashMap 为切入...
继续阅读 »


Hash冲突是怎么回事

在这个文章正式开始之前,先几句话把这个问题说清楚了:我们常说的 Hash 冲突到底是怎么回事?

直接上个图片:

你说你看到这个图片的时候想到了什么东西?

有没有想到 HashMap 的数组加链表的结构?

对咯,我这里就是以 HashMap 为切入点,给大家讲一下 Hash 冲突。

接着我们看下面这张图:

假设现在我们有个值为 [why技术] 的 key,经过 Hash 算法后,计算出值为 1,那么含义就是这个值应该放到数组下标为 1 的地方。

但是如图所示,下标为 1 的地方已经挂了一个 eat 的值了。这个坑位已经被人占着了。

那么此时此刻,我们就把这种现象叫为 Hash 冲突。

HashMap 是怎么解决 Hash 冲突的呢?

链地址法,也叫做拉链法。

数组中出现 Hash 冲突了,这个时候链表的数据结构就派上用场了。

链表怎么用的呢?看图:

这样问题就被我们解决了。

其实 hash 冲突也就是这么一回事:不同的对象经过同一个 Hash 算法后得到了一样的 HashCode。

那么写到这里的时候我突然想到了一个面试题:

请问我上面的图是基于 JDK 什么版本的 HashMap 画的图?

为什么想到了这个面试题呢?

因为我画图的时候犹豫了大概 0.3 秒,往链表上挂的时候,我到底是使用头插法还是尾插法呢?

众所周知,JDK 7 中的 HashMap 是采用头插法的,即 [why技术] 在 [eat] 之前,JDK 8 中的 HashMap 采用的是尾插法。

这面试题怎么说呢,真的无聊。但是能怎么办呢,八股文该背还是得背。

面试嘛,背一背,不寒碜。

构建 HashCode 一样的 String

前面我们知道了,Hash 冲突的根本原因是不同的对象经过同一个 Hash 算法后得到了一样的 HashCode。

这句话乍一听:嗯,很有道理,就是这么一回事,没有问题。

比如我们常用的 HashMap ,绝大部分情况 key 都是 String 类型的。要出现 Hash 冲突,最少需要两个 HashCode 一样的 String 类。

那么我问你:怎么才能快速弄两个 HashCode 一样的 String 呢?

怎么样,有点懵逼了吧?

从很有道理,到有点懵逼只需要一个问题。

来,我带你分析一波。

我先问你:长度为 1 的两个不一样的 String,比如下面这样的代码,会不会有一样的 HashCode?

String a = "a";
String b = "b";

肯定是不会的,对吧。

如果你不知道的话,建议你去 ASCII 码里面找答案。

我们接着往下梳理,看看长度为 2 的 String 会不会出现一样的 HashCode?

要回答这个问题,我们要先看看 String 的 hashCode 计算方法,我这里以 JDK 8 为例:

我们假设这两个长度为 2 的 String,分别是 xy 和 ab 吧。

注意这里的 xy 和 ab 都是占位符,不是字符串。

类似于小学课本中一元二次方程中的未知数 x 和 y,我们需要带入到上面的 hashCode 方法中去计算。

hashCode 算法,最主要的就是其中的这个 for 循环。

for 循环里面的有三个我们不知道是啥的东西:h,value.length 和 val[i]。我们 debug 看一下:

h 初始情况下等于 0。

String 类型的底层结构是 char 数组,这个应该知道吧。

所以,value.length 是字符串的长度。val[] 就是这个 char 数组。

把 xy 带入到 for 循环中,这个 for 循环会循环 2 次。

第一次循环:h=0,val[0]=x,所以 h=31*0+x,即 h=x。

第二次循环:h=x,val[1]=y,所以 h=31*x+y。

所以,经过计算后, xy 的 hashCode 为 31*x+y。

同理可得,ab 的 hashCode 为 31*a+b。

由于我们想要构建 hashCode 一样的字符串,所以可以得到等式:

31x+y=31a+b

那么问题就来了:请问 x,y,a,b 分别是多少?

你算的出来吗?

你算的出来个锤子!黑板上的排列组合你不是舍不得解开,你就是解不开。

但是我可以解开,带大家看看这个题怎么搞。

数学课开始了。注意,我要变形了。

31x+y=31a+b 可以变形为:

31x-31a=b-y。

即,31(x-a)=b-y。

这个时候就清晰很多了,很明显,上面的等式有一个特殊解:

x-a=1,b-y=31。

因为,由上可得:对于任意两个字符串 xy 和 ab,如果它们满足 x-a=1,即第一个字符的 ASCII 码值相差为 1,同时满足 b-y=31,即第二个字符的 ASCII 码值相差为 -31。那么这两个字符的 hashCode 一定相等。

都已经说的这么清楚了,这样的组合对照着 ASCII 码表来找,不是一抓一大把吗?

Aa 和 BB,对不对?

Ab 和 BC,是不是?

Ac 和 BD,有没有?

好的。现在,我们可以生成两个 HashCode 一样的字符串了。

我们在稍微加深一点点难度。假设我要构建 2 个以上 HashCode 一样的字符串该怎么办?

我们先分析一下。

Aa 和 BB 的 HashCode 是一样的。我们把它两一排列组合,那不还是一样的吗?

比如这样的:AaBB,BBAa。

再比如我之前《震惊!ConcurrentHashMap里面也有死循环?》这篇文章中出现过的例子,AaAa,BBBB:

你看,神奇的事情就出现了。

我们有了 4 个 hashCode 一样的字符串了。

有了这 4 个字符串,我们再去和  Aa,BB 进行组合,比如 AaBBAa,BBAaBB......

4*2=8 种组合方式,我们又能得到 8 个 hashCode 一样的字符串了。

等等,我好像发现了什么规律似的。

如果我们以 Aa,BB 为种子数据,经过多次排列组合,可以得到任意个数的 hashCode 一样的字符串。字符串的长度随着个数增加而增加。

文字我还说不太清楚,直接 show you code 吧,如下:

public class CreateHashCodeSomeUtil {

    /**
     * 种子数据:两个长度为 2 的 hashCode 一样的字符串
     */
    private static String[] SEED = new String[]{"Aa""BB"};
    
    /**
     * 生成 2 的 n 次方个 HashCode 一样的字符串的集合
     */
    public static List hashCodeSomeList(int n) {
        List initList = new ArrayList(Arrays.asList(SEED));
        for (int i = 1; i < n; i++) {
            initList = createByList(initList);
        }
        return initList;
    }

    public static List createByList(List list) {
        List result = new ArrayList();
        for (int i = 0; i < SEED.length; ++i) {
            for (String str : list) {
                result.add(SEED[i] + str);
            }
        }
        return result;
    }
}

通过上面的代码,我们就可以生成任意多个 hashCode 一样的字符串了。

就像这样:

所以,别再问出这样的问题了:

有了这些 hashCode 一样的字符串,我们把这些字符串都放到HashMap 中,代码如下:

public class HashMapTest {
    public static void main(String[] args) {
        Map hashMap = new HashMap();
        hashMap.put("Aa""Aa");
        hashMap.put("BB""BB");
        hashMap.put("AaAa""AaAa");
        hashMap.put("AaBB""AaBB");
        hashMap.put("BBAa""BBAa");
        hashMap.put("BBBB""BBBB");
        hashMap.put("AaAaAa""AaAaAa");
        hashMap.put("AaAaBB""AaAaBB");
        hashMap.put("AaBBAa""AaBBAa");
        hashMap.put("AaBBBB""AaBBBB");
        hashMap.put("BBAaAa""BBAaAa");
        hashMap.put("BBAaBB""BBAaBB");
        hashMap.put("BBBBAa""BBBBAa");
        hashMap.put("BBBBBB""BBBBBB");
    }
}

最后这个 HashMap 的长度会经过两次扩容。扩容之后数组长度为 64:

但是里面只被占用了三个位置,分别是下标为 0,31,32 的地方:

画图如下:

看到了吧,刺不刺激,长度为 64 的数组,存 14 个数据,只占用了 3 个位置。

这空间利用率,也太低了吧。

所以,这样就算是 hack 了 HashMap。恭喜你,掌握了一项黑客攻击技术:hash 冲突 Dos 。

如果你想了解的更多。可以看看石头哥的这篇文章:《没想到 Hash 冲突还能这么玩,你的服务中招了吗?》

看到上面的图,不知道大家有没有觉得有什么不对劲的地方?

如果没有,那么我再给你提示一下:数组下标为 32 的位置下,挂了一个长度为 8 的链表。

是不是,恍然大悟了。在 JDK 8 中,链表转树的阈值是多少?

所以,在当前的案例中,数组下标为 32 的位置下挂的不应该是一个链表,而是一颗红黑树。

对不对?

对个锤子对!有的人稍不留神就被带偏了

这是不对的。链表转红黑树的阈值是节点大于 8 个,而不是等于 8 的时候。

也就是说需要再来一个经过 hash 计算后,下标为 32 的、且 value 和之前的 value 都不一样的 key 的时候,才会触发树化操作。

不信,我给你看看现在是一个什么节点:

没有骗你吧?从上面的图片可以清楚的看到,第 8 个节点还是一个普通的 node。

而如果是树化节点,它应该是长这样的:

不信,我们再多搞一个 hash 冲突进来,带你亲眼看一下,代码是不会骗人的。

那么怎么多搞一个冲突出来呢?

最简单的,这样写:

这样冲突不就多一个了吗?我真是一个天才,情不自禁的给自己鼓起掌来。

好了,我们看一下现在的节点状态是怎么样的:

怎么样,是不是变成了 TreeNode ,没有骗你吧?

什么?你问我为什么不把图画出来?

别问,问就是我不会画红黑树。正经人谁画那玩意。

另外,我还想多说一句,关于一个 HashMap 的面试题的一个坑。

面试官问:JDK 8 的 HashMap 链表转红黑树的条件是什么?

绝大部分背过面试八股文的朋友肯定能答上来:当链表长度大于 8 的时候。

这个回答正确吗?

是正确的,但是只正确了一半。

还有一个条件是数组长度大于 64 的时候才会转红黑树。

源码里面写的很清楚,数组长度小于 64,直接扩容,而不是转红黑树:

感觉很多人都忽略了“数组长度大于 64 ”这个条件。

背八股文,还是得背全了。

比如下面这种测试用例:

它们都会落到数组下标为 0 的位置上。

当第 9 个元素 BBBBAa 落进来的时候,会走到 treeifyBin 方法中去,但是不会触发树化操作,只会进行扩容操作。

因为当前长度为默认长度,即 16。不满足转红黑树条件。

所以,从下面的截图,我们可以看到,标号为 ① 的地方,数组长度变成了 32,链表长度变成了  9 ,但是节点还是普通 node:

怎么样,有点意思吧,我觉得这样学 HashMap 有趣多了。

实体类当做 key

上面的示例中,我们用的是 String 类型当做 HashMap 中的 key。

这个场景能覆盖我们开发场景中的百分之 95 了。

但是偶尔会有那么几次,可能会把实体类当做 key 放到 HashMap 中去。

注意啊,面试题又来了:在 HashMap 中可以用实体类当对象吗?

那必须的是可以的啊。但是有坑,注意别踩进去了。

我拿前段时间看到的一个新闻给大家举个例子吧:

假设我要收集学生的家庭信息,用 HashMap 存起来。

那么我的 key 是学生对象, value 是学生家庭信息对象。

他们分别是这样的:

public class HomeInfo {

    private String homeAddr;
    private String carName;
     //省略改造方法和toString方法
}

public class Student {

    private String name;
    private Integer age;
     //省略改造方法和toString方法

}

然后我们的测试用例如下:

public class HashMapTest {

    private static Map hashMap = new HashMap();

    static {
        Student student = new Student("why"7);
        HomeInfo homeInfo = new HomeInfo("大南街""自行车");
        hashMap.put(student, homeInfo);
    }

    public static void main(String[] args) {
        updateInfo("why"7"滨江路""摩托");
        for (Map.Entry entry : hashMap.entrySet()) {
            System.out.println(entry.getKey()+"-"+entry.getValue());
        }
    }

    private static void updateInfo(String name, Integer age, String homeAddr, String carName) {
        Student student = new Student(name, age);
        HomeInfo homeInfo = hashMap.get(student);
        if (homeInfo == null) {
            hashMap.put(student, new HomeInfo(homeAddr, carName));
        }
    }
}

初始状态下,HashMap 中已经有一个名叫 why 的 7 岁小朋友了,他家住大南街,家里的交通工具是自行车。

然后,有一天他告诉老师,他搬家了,搬到了滨江路去,而且家里的自行车换成了摩托车。

于是老师就通过页面,修改了 why 小朋友的家庭信息。

最后调用到了 updateInfo 方法。

嘿,你猜怎么着?

我带你看一下输出:

更新完了之后,他们班上出现了两个叫 why 的 7 岁小朋友了,一个住在大南街,一个住在滨江路。

更新变新增了,你说神奇不神奇?

现象出来了,那么根据现象定位问题代码不是手到擒来的事儿?

很明显,问题就出在这个地方:

这里取出来的 homeInfo 为空了,所以才会新放一个数据进去。

那么我们看看为啥这里为空。

跟着 hashMap.get() 源码进去瞅一眼:

标号为 ① 的地方是计算 key ,也就是 student 对象的 hashCode。而我们 student 对象并没有重写 hashCode,所以调用的是默认的 hashCode 方法。

这里的 student 是 new 出来的:

所以,这个 student 的 hashCode 势必和之前在 HashMap 里面的 student 不是一样的。

因此,标号为 ③ 的地方,经过 hash 计算后得出的 tab 数组下标,对应的位置为 null。不会进入 if 判断,这里返回为 null。

那么解决方案也就呼之欲出了:重写对象的 hashCode 方法即可。

是吗?

等等,你回来,别拿着半截就跑。我话还没说完呢。

接着看源码:

HashMap put 方法执行的时候,用的是 equals方法判断当前 key 是否与表中存在的 key 相同。

我们这里没有重写 equals方法,因此这里返回了 false。

所以,如果我们 hashCode 和 equals方法都没有重写,那么就会出现下面示意图的情况:

如果,我们重写了 hashCode,没有重写 equals 方法,那么就会出现下面示意图的情况:

总之一句话:在 HashMap 中,如果用对象做 key,那么一定要重写对象的 hashCode 方法和 equals方法。否则,不仅不能达到预期的效果,而且有可能导致内存溢出。

比如上面的示例,我们放到循环中去,启动参数我们加上 -Xmx10m,运行结果如下:

因为每一次都是 new 出来的 student 对象,hashCode 都不尽相同,所以会不停的触发扩容的操作,最终在 resize 的方法抛出了 OOM 异常。

奇怪的知识又增加了

写这篇文章的时候我翻了一下《Java 编程思想(第 4 版)》一书。

奇怪的知识又增加了两个。

第一个是在这本书里面,对于 HashMap 里面放对象的示例是这样的:

Groundhog:土拨鼠、旱獭。

Prediction:预言、预测、预告。

考虑一个天气预报系统,将土拨鼠和预报联系起来。

这 TM 是个什么读不懂的神仙需求?

幸好 why 哥学识渊博,闭上眼睛,去我的知识仓库里面搜索了一番。

原来是这么一回事。

在美国的宾西法尼亚州,每年的 2 月 2 日,是土拨鼠日。

根据民间的说法,如果土拨鼠在 2 月 2 号出洞时见到自己的影子,然后这个小东西就会回到洞里继续冬眠,表示春天还要六个星期才会到来。如果见不到影子,它就会出来觅食或者求偶,表示寒冬即将结束。

这就呼应上了,通过判断土拨鼠出洞的时候是否能看到影子,从而判断冬天是否结束。

这样,需求就说的通了。

第二个奇怪的知识是这样的。

关于 HashCode 方法,《Java编程思想(第4版)》里面是这样写的:

我一眼就发现了不对劲的地方:result=37*result+c。

前面我们才说了,基数应该是 31 才对呀?

作者说这个公式是从《Effective Java(第1版)》的书里面拿过来的。

这两本书都是 java 圣经啊,建议大家把梦幻联动打在留言区上。

《Effective Java(第1版)》太久远了,我这里只有第 2 版和第 3 版的实体书。

于是我在网上找了一圈第 1 版的电子书,终于找到了对应描述的地方:

可以看到,书里给出的公式确实是基于 37 去计算的。

翻了一下第三版,一样的地方,给出的公式是这样的:

而且,你去网上搜:String 的 hashCode 的计算方法。

都是在争论为什么是 31 。很少有人提到 37 这个数。

其实,我猜测,在早期的 JDK 版本中 String 的 hashCode 方法应该用的是 37 ,后来改为了 31 。

我想去下载最早的 JDK 版本去验证一下的,但是网上翻了个底朝天,没有找到合适的。

书里面为什么从 37 改到 31 呢?

作者是这样解释的,上面是第 1 版,下面是第 2 版:

用方框框起来的部分想要表达的东西是一模一样的,只是对象从 37 变成了 31 。

而为什么从 37 变成 31 ,作者在第二版里面解释了,也就是我用下划线标注的部分。

31 有个很好的特许,即用位移和减法来代替乘法,可以得到更好的性能:

31*i==(i<<5)-i。现代的虚拟机可以自动完成这种优化。

从 37 变成 31,一个简单的数字变化,就能带来性能的提升。

个中奥秘,很有意思,有兴趣的可以去查阅一下相关资料。

真是神奇的计算机世界。

最后说一句(求关注)

好了,看到了这里安排个“一键三连”(转发、在看、点赞)吧,周更很累的,不要白嫖我,需要一点正反馈。

才疏学浅,难免会有纰漏,如果你发现了错误的地方,可以在留言区提出来,我对其加以修改。


作者:why技术
来源:mp.weixin.qq.com/s/zXFWBr9Fd5UZLjse52rcng
ction>
收起阅读 »

SQL 必须被淘汰的 9 个理由

尽管 SQL 很受欢迎并取得了成功,但它仍然是一项悖论研究。它可能笨重且冗长,但开发人员经常发现它是提取所需数据的最简单、最直接的方法。当查询编写正确时,它可能会快如闪电,而当查询未达到目标时,它会慢得像糖蜜。它已经有几十年的历史了,但新功能仍在不断增加。 这...
继续阅读 »

尽管 SQL 很受欢迎并取得了成功,但它仍然是一项悖论研究。它可能笨重且冗长,但开发人员经常发现它是提取所需数据的最简单、最直接的方法。当查询编写正确时,它可能会快如闪电,而当查询未达到目标时,它会慢得像糖蜜。它已经有几十年的历史了,但新功能仍在不断增加。


这些悖论并不重要,因为市场已经表明:SQL 是许多人的首选,即使有更新且可以说更强大的选项。世界各地的开发人员(从最小的网站到最大的大型企业)都了解 SQL。他们依靠它来组织所有数据。


SQL 的表格模型占据主导地位,以至于许多非 SQL 项目最终都添加了 SQLish 接口,因为用户需要它。 NoSQL 运动也是如此,它的发明是为了摆脱旧范式。最终,SQL 似乎获胜了。


SQL 的限制可能还不足以将其扔进垃圾箱。开发人员可能永远不会将所有数据从 SQL 中迁移出来。但 SQL 的问题足够真实,足以给开发人员带来压力、增加延迟,甚至需要对某些项目进行重新设计。


以下是我们希望退出 SQL 的九个原因,尽管我们知道我们可能不会这样做。


SQL 让事情变得更糟的 9 种方式



  1. 表格无法缩放

  2. SQL 不是 JSON 或 XML 原生的

  3. 编组是一个很大的时间消耗

  4. SQL 不实时

  5. JOINS 很头疼

  6. 列浪费空间

  7. 优化器只是有时有帮助

  8. 非规范化将表视为垃圾

  9. 附加的想法可能会破坏你的数据库


表格无法缩放


关系模型喜欢表,所以我们不断构建它们。这对于小型甚至普通大小的数据库来说都很好。但在真正大规模的情况下,该模型开始崩溃。


有些人尝试通过将新旧结合起来来解决问题,例如将分片集成到旧的开源数据库中。添加层似乎可以使数据更易于管理并提供无限的规模。但这些增加的层可以隐藏地雷。 SELECT 或 JOIN 的处理时间可能截然不同,具体取决于分片中存储的数据量。


分片还迫使 DBA 考虑数据可能存储在不同机器甚至不同地理位置的可能性。如果没有意识到数据存储在不同的位置,那么开始跨表搜索的经验不足的管理员可能会感到困惑。该模型有时会从视图中抽象出位置。 


某些 AWS 计算机配备24 TB RAM。为什么?因为有些数据库用户需要这么多。他们在 SQL 数据库中拥有如此多的数据,并且在一台机器的一块 RAM 中运行得更好。


SQL 不是 JSON 或 XML 原生的


SQL 作为一种语言可能是常青树,但它与 JSON、YAML 和 XML 等较新的数据交换格式的配合并不是特别好。所有这些都支持比 SQL 更分层、更灵活的格式。 SQL 数据库的核心仍然停留在表无处不在的关系模型中。


市场找到了掩盖这种普遍抱怨的方法。使用正确的粘合代码添加不同的数据格式(例如 JSON)相对容易,但您会为此付出时间损失的代价。


一些 SQL 数据库现在能够将 JSON、XML、GraphQL 或 YAML 等更现代的数据格式作为本机功能进行编码和解码。但在内部,数据通常使用相同的旧表格模型来存储和索引。


将数据转入或转出这些格式需要花费多少时间?以更现代的方式存储我们的数据不是更容易吗?一些聪明的数据库开发人员继续进行实验,但奇怪的是,他们常常最终选择使用某种 SQL 解析器。这就是开发人员所说的他们想要的。


编组是一个很大的时间消耗


数据库可以将数据存储在表中,但程序员编写处理对象的代码。设计数据驱动应用程序的大部分工作似乎都是找出从数据库中提取数据并将其转换为业务逻辑可以使用的对象的最佳方法。然后,必须通过将对象中的数据字段转换为 SQL 更新插入来对它们进行解组。难道没有办法让数据保持随时可用的格式吗?


SQL 不实时


最初的 SQL 数据库是为批量分析和交互模式而设计的。具有长处理管道的流数据模型是一个相对较新的想法,并且并不完全匹配。


主要的 SQL 数据库是几十年前设计的,当时的模型设想数据库独立运行并像某种预言机一样回答查询。有时他们反应很快,有时则不然。这就是批处理的工作原理。


一些最新的应用程序需要更好的实时性能,不仅是为了方便,而且是因为应用程序需要它。在现代的流媒体世界中,像大师一样坐在山上并不那么有效。


专为这些市场设计的最新数据库非常重视速度和响应能力。他们不提供那种会减慢一切的复杂 SQL 查询。


JOIN 是一个令人头疼的问题


关系数据库的强大之处在于将数据分割成更小、更简洁的表。头痛随之而来。


使用 JOIN 动态重新组装数据通常是工作中计算成本最高的部分,因为数据库必须处理所有数据。当数据开始超出 RAM 的容量时,令人头疼的事情就开始了。


对于学习 SQL 的人来说,JOIN 可能会令人难以置信的困惑。弄清楚内部 JOIN 和外部 JOIN 之间的区别仅仅是一个开始。寻找将多个 JOIN 连接在一起的最佳方法会使情况变得更糟。内部优化器可能会提供帮助,但当数据库管理员要求特别复杂的组合时,它们无能为力。


列浪费空间


NoSQL 的伟大想法之一是让用户摆脱列的束缚。如果有人想向条目添加新值,他们可以选择他们想要的任何标签或名称。无需更新架构即可添加新列。


SQL 维护者只看到该模型中的混乱。他们喜欢表格附带的顺序,并且不希望开发人员即时添加新字段。他们说得有道理,但添加新列可能非常昂贵且耗时,尤其是在大表中。将新数据放在单独的列中并将它们与 JOIN 进行匹配会增加更多的时间和复杂性。


优化器只是有时有帮助


数据库公司和研究人员花费了大量时间来开发优秀的优化器,这些优化器可以分解查询并找到排序其操作的最佳方式。


收益可能很大,但优化器的作用有限。如果查询需要特别大或华丽的响应,优化器不能只是说“你真的确定吗?”它必须汇总答案并按照指示执行。


一些 DBA 仅在应用程序开始扩展时才了解这一点。早期的优化足以处理开发过程中的测试数据集。但在关键时刻,优化器无法从查询中榨取更多的能量。


非规范化将表视为垃圾


开发人员经常发现自己陷入了两难境地:想要更快性能的用户和不想为更大、更昂贵的硬件付费的精算师。一个常见的解决方案是对表进行非规范化,这样就不需要复杂的 JOIN 或跨表的任何内容。所有数据都已经存在于一个长矩形中。


这不是一个糟糕的技术解决方案,而且它常常会获胜,因为磁盘空间变得比处理能力更便宜。但非规范化也抛弃了 SQL 和关系数据库理论中最聪明的部分。当您的数据库变成一个长 CSV 文件时,所有这些花哨的数据库功能几乎都消失了。


附加的想法可能会破坏你的数据库


多年来,开发人员一直在向 SQL 添加新功能,其中一些功能非常聪明。您很难对不必使用的炫酷功能感到不安。另一方面,这些附加功能通常是用螺栓固定的,这可能会导致性能问题。一些开发人员警告说,您应该对子查询格外小心,因为它们会减慢一切速度。其他人则表示,选择公共表表达式、视图或 Windows 等子集会使代码变得过于复杂。代码的创建者可以阅读它,但其他人在试图保持 SQL 的所有层和生成的直线性时都会感到头疼。这就像看一部克里斯托弗·诺兰的电影,但是是用代码编写的。


其中一些伟大的想法妨碍了已经行之有效的做法。窗口函数旨在通过加快平均值等结果的计算来加快基本数据分析的速度。但许多 SQL 用户会发现并使用一些附加功能。在大多数情况下,他们会尝试新功能,只有当机器速度慢得像爬行一样时才会注意到出现问题。然后他们需要一些老的、灰色的 DBA 来解释发生了什么以及如何修复它。




作者:Peter Wayner



作者:Squids数据库云服务提供商
来源:juejin.cn/post/7313742254144585764
收起阅读 »

finally中的代码一定会执行吗?

通常在面试中,只要是疑问句一般答案都是“否定”的,因为如果是“确定”和“正常”的,那面试官就没有必要再问了嘛,而今天这道题的答案也是符合这个套路。 1.典型回答 正常运行的情况下,finally 中的代码是一定会执行的,但是,如果遇到以下异常情况,那么 fin...
继续阅读 »

通常在面试中,只要是疑问句一般答案都是“否定”的,因为如果是“确定”和“正常”的,那面试官就没有必要再问了嘛,而今天这道题的答案也是符合这个套路。


1.典型回答


正常运行的情况下,finally 中的代码是一定会执行的,但是,如果遇到以下异常情况,那么 finally 中的代码就不会继续执行了:



  1. 程序在 try 块中遇到 System.exit() 方法,会立即终止程序的执行,这时 finally 块中的代码不会被执行,例如以下代码:


public class FinallyExample {
public static void main(String[] args) {
try {
System.out.println("执行 try 代码.");
System.exit(0);
} finally {
System.out.println("执行 finally 代码.");
}
}
}

以上程序的执行结果如下:



  1. 在 try 快中遇到 Runtime.getRuntime().halt() 代码,强制终止正在运行的 JVM。与 System.exit()方法不同,此方法不会触发 JVM 关闭序列。因此,当我们调用 halt 方法时,都不会执行关闭钩子或终结器。实现代码如下:


public class FinallyExample {
public static void main(String[] args) {
try {
System.out.println("执行 try 代码.");
Runtime.getRuntime().halt(0);
} finally {
System.out.println("执行 finally 代码.");
}
}
}

以上程序的执行结果如下:



  1. 程序在 try 块中遇到无限循环或者发生死锁等情况时,程序可能无法正常跳出 try 块,此时 finally 块中的代码也不会被执行。

  2. 掉电问题,程序还没有执行到 finally 就掉电了(停电了),那 finally 中的代码自然也不会执行。

  3. JVM 异常崩溃问题导致程序不能继续执行,那么 finally 的代码也不会执行。


钩子方法解释


在编程中,钩子方法(Hook Method)是一种由父类提供的空或默认实现的方法,子类可以选择性地重写或扩展该方法,以实现特定的行为或定制化逻辑。钩子方法可以在父类中被调用,以提供一种可插拔的方式来影响父类的行为。
钩子方法通常用于框架或模板方法设计模式中。框架提供一个骨架或模板,其中包含一些已经实现的方法及预留的钩子方法。具体的子类可以通过重写钩子方法来插入定制逻辑,从而影响父类方法的实现方式。


2.考点分析


正常运行的情况下,finally 中的代码是一定会执行的,但是,如果遇到 System.exit() 方法或 Runtime.getRuntime().halt() 方法,或者是 try 中发生了死循环、死锁,遇到了掉电、JVM 崩溃等问题,那么 finally 中的代码也是不会执行的。


3.知识扩展


System.exit() 和 Runtime.getRuntime().halt() 都可以用于终止 Java 程序的执行,但它们之间有以下区别:



  1. System.exit():来自 Java.lang.System 类的一个静态方法,它接受一个整数参数作为退出状态码,通常非零值表示异常终止,使用零值表示正常终止。其中,最重要的是使用 exit() 方法,会执行 JVM 关闭钩子或终结器。

  2. Runtime.getRuntime().halt():来自 Runtime 类的一个实例方法,它接受一个整数参数作为退出状态码。其中退出状态码只是表示程序终止的原因,很少在程序终止时使用非零值。而使用 halt() 方法,不会执行 JVM 关闭钩子或终结器。


例如以下代码,使用 exit() 方法会执行 JVM 关闭钩子:


class ExitDemo {
// 注册退出钩子程序
static {
Runtime.getRuntime().addShutdownHook(new Thread(() -> {
System.out.println("执行 ShutdownHook 方法");
}));
}
public static void main(String[] args) {
try {
System.out.println("执行 try 代码。");
// 使用 System.exit() 退出程序
System.exit(0);
} finally {
System.out.println("执行 finally 代码。");
}
}
}

以上程序的执行结果如下:

而 halt() 退出的方法,并不会执行 JVM 关闭钩子,示例代码如下:


class ExitDemo {

// 注册退出钩子程序
static {
Runtime.getRuntime().addShutdownHook(new Thread(() -> {
System.out.println("执行 ShutdownHook 方法");
}));
}

public static void main(String[] args) {
try {
System.out.println("执行 try 代码。");
// 使用 Runtime.getRuntime().halt() 退出程序
Runtime.getRuntime().halt(0);
} finally {
System.out.println("执行 finally 代码。");
}
}
}

以上程序的执行结果如下:


小结


正常运行的情况下,finally 中的代码是一定会执行的,但是,如果遇到 System.exit() 方法或 Runtime.getRuntime().halt() 方法,或者是 try 中发生了死循环、死锁,遇到了掉电、JVM 崩溃等问题,finally 中的代码是不会执行的。而 exit() 方法会执行 JVM 关闭钩子方法或终结器,但 halt() 方法并不会执行钩子方法或终结器。


作者:Java中文社群
来源:juejin.cn/post/7313501001788604450
收起阅读 »

三年前端还不会配置Nginx?刷完这篇就够了

一口气看完,比自学强十倍! 什么是Nginx Nginx是一个开源的高性能HTTP和反向代理服务器。它可以用于处理静态资源、负载均衡、反向代理和缓存等任务。Nginx被广泛用于构建高可用性、高性能的Web应用程序和网站。它具有低内存消耗、高并发能力和良好的...
继续阅读 »

一口气看完,比自学强十倍!



Nginx_logo-700x148.png


什么是Nginx


Nginx是一个开源的高性能HTTP和反向代理服务器。它可以用于处理静态资源、负载均衡、反向代理和缓存等任务。Nginx被广泛用于构建高可用性、高性能的Web应用程序和网站。它具有低内存消耗、高并发能力和良好的稳定性,因此在互联网领域非常受欢迎。


为什么使用Nginx



  1. 高性能:Nginx采用事件驱动的异步架构,能够处理大量并发连接而不会消耗过多的系统资源。它的处理能力比传统的Web服务器更高,在高并发负载下表现出色。

  2. 高可靠性:Nginx具有强大的容错能力和稳定性,能够在面对高流量和DDoS攻击等异常情况下保持可靠运行。它能通过健康检查和自动故障转移来保证服务的可用性。

  3. 负载均衡:Nginx可以作为反向代理服务器,实现负载均衡,将请求均匀分发给多个后端服务器。这样可以提高系统的整体性能和可用性。

  4. 静态文件服务:Nginx对静态资源(如HTML、CSS、JavaScript、图片等)的处理非常高效。它可以直接缓存静态文件,减轻后端服务器的负载。

  5. 扩展性:Nginx支持丰富的模块化扩展,可以通过添加第三方模块来提供额外的功能,如gzip压缩、SSL/TLS加密、缓存控制等。


如何处理请求


Nginx处理请求的基本流程如下:



  1. 接收请求:Nginx作为服务器软件监听指定的端口,接收客户端发来的请求。

  2. 解析请求:Nginx解析请求的内容,包括请求方法(GET、POST等)、URL、头部信息等。

  3. 配置匹配:Nginx根据配置文件中的规则和匹配条件,决定如何处理该请求。配置文件定义了虚拟主机、反向代理、负载均衡、缓存等特定的处理方式。

  4. 处理请求:Nginx根据配置的处理方式,可能会进行以下操作:



    • 静态文件服务:如果请求的是静态资源文件,如HTML、CSS、JavaScript、图片等,Nginx可以直接返回文件内容,不必经过后端应用程序。

    • 反向代理:如果配置了反向代理,Nginx将请求转发给后端的应用服务器,然后将其响应返回给客户端。这样可以提供负载均衡、高可用性和缓存等功能。

    • 缓存:如果启用了缓存,Nginx可以缓存一些静态或动态内容的响应,在后续相同的请求中直接返回缓存的响应,减少后端负载并提高响应速度。

    • URL重写:Nginx可以根据配置的规则对URL进行重写,将请求从一个URL重定向到另一个URL或进行转换。

    • SSL/TLS加密:如果启用了SSL/TLS,Nginx可以负责加密和解密HTTPS请求和响应。

    • 访问控制:Nginx可以根据配置的规则对请求进行访问控制,例如限制IP访问、进行身份认证等。



  5. 响应结果:Nginx根据处理结果生成响应报文,包括状态码、头部信息和响应内容。然后将响应发送给客户端。


什么是正向代理和反向代理


2020-03-08-5ce95a07b18a071444-20200308191723379.png


正向代理


是指客户端通过代理服务器发送请求到目标服务器。客户端向代理服务器发送请求,代理服务器再将请求转发给目标服务器,并将服务器的响应返回给客户端。正向代理可以隐藏客户端的真实IP地址,提供匿名访问和访问控制等功能。它常用于跨越防火墙访问互联网、访问被封禁的网站等情况。


反向代理


是指客户端发送请求到代理服务器,代理服务器再将请求转发给后端的多个服务器中的一个或多个,并将后端服务器的响应返回给客户端。客户端并不直接访问后端服务器,而是通过反向代理服务器来获取服务。反向代理可以实现负载均衡、高可用性和安全性等功能。它常用于网站的高并发访问、保护后端服务器、提供缓存和SSL终止等功能。


nginx 启动和关闭


进入目录:/usr/local/nginx/sbin
启动命令:./nginx
重启命令:nginx -s reload
快速关闭命令:./nginx -s stop
有序地停止,需要进程完成当前工作后再停止:./nginx -s quit
直接杀死nginx进程:killall nginx

目录结构


[root@localhost ~]# tree /usr/local/nginx
/usr/local/nginx

├── client_body_temp                 # POST 大文件暂存目录
├── conf                             # Nginx所有配置文件的目录
│   ├── fastcgi.conf                 # fastcgi相关参数的配置文件
│   ├── fastcgi.conf.default         # fastcgi.conf的原始备份文件
│   ├── fastcgi_params               # fastcgi的参数文件
│   ├── fastcgi_params.default      
│   ├── koi-utf
│   ├── koi-win
│   ├── mime.types                   # 媒体类型
│   ├── mime.types.default
│   ├── nginx.conf                   #这是Nginx默认的主配置文件,日常使用和修改的文件
│   ├── nginx.conf.default
│   ├── scgi_params                 # scgi相关参数文件
│   ├── scgi_params.default  
│   ├── uwsgi_params                 # uwsgi相关参数文件
│   ├── uwsgi_params.default
│   └── win-utf
├── fastcgi_temp                     # fastcgi临时数据目录
├── html                             # Nginx默认站点目录
│   ├── 50x.html                     # 错误页面优雅替代显示文件,例如出现502错误时会调用此页面
│   └── index.html                   # 默认的首页文件
├── logs                             # Nginx日志目录
│   ├── access.log                   # 访问日志文件
│   ├── error.log                   # 错误日志文件
│   └── nginx.pid                   # pid文件,Nginx进程启动后,会把所有进程的ID号写到此文件
├── proxy_temp                       # 临时目录
├── sbin                             # Nginx 可执行文件目录
│   └── nginx                       # Nginx 二进制可执行程序
├── scgi_temp                       # 临时目录
└── uwsgi_temp                       # 临时目录

配置文件nginx.conf


# 启动进程,通常设置成和cpu的数量相等
worker_processes  1;

# 全局错误日志定义类型,[debug | info | notice | warn | error | crit]
error_log  logs/error.log;
error_log  logs/error.log  notice;
error_log  logs/error.log  info;

# 进程pid文件
pid        /var/run/nginx.pid;

# 工作模式及连接数上限
events {
    # 仅用于linux2.6以上内核,可以大大提高nginx的性能
    use   epoll;

    # 单个后台worker process进程的最大并发链接数
    worker_connections  1024;

    # 客户端请求头部的缓冲区大小
    client_header_buffer_size 4k;

    # keepalive 超时时间
    keepalive_timeout 60;

    # 告诉nginx收到一个新连接通知后接受尽可能多的连接
    # multi_accept on;
}

# 设定http服务器,利用它的反向代理功能提供负载均衡支持
http {
    # 文件扩展名与文件类型映射表义
    include       /etc/nginx/mime.types;

    # 默认文件类型
    default_type  application/octet-stream;

    # 默认编码
    charset utf-8;

    # 服务器名字的hash表大小
    server_names_hash_bucket_size 128;

    # 客户端请求头部的缓冲区大小
    client_header_buffer_size 32k;

    # 客户请求头缓冲大小
    large_client_header_buffers 4 64k;

    # 设定通过nginx上传文件的大小
    client_max_body_size 8m;

    # 开启目录列表访问,合适下载服务器,默认关闭。
    autoindex on;

    # sendfile 指令指定 nginx 是否调用 sendfile 函数(zero copy 方式)来输出文件,对于普通应用,
    # 必须设为 on,如果用来进行下载等应用磁盘IO重负载应用,可设置为 off,以平衡磁盘与网络I/O处理速度
    sendfile        on;

    # 此选项允许或禁止使用socke的TCP_CORK的选项,此选项仅在使用sendfile的时候使用
    #tcp_nopush     on;

    # 连接超时时间(单秒为秒)
    keepalive_timeout  65;


    # gzip模块设置
    gzip on;               #开启gzip压缩输出
    gzip_min_length 1k;    #最小压缩文件大小
    gzip_buffers 4 16k;    #压缩缓冲区
    gzip_http_version 1.0; #压缩版本(默认1.1,前端如果是squid2.5请使用1.0)
    gzip_comp_level 2;     #压缩等级
    gzip_types text/plain application/x-javascript text/css application/xml;
    gzip_vary on;

    # 开启限制IP连接数的时候需要使用
    #limit_zone crawler $binary_remote_addr 10m;

    # 指定虚拟主机的配置文件,方便管理
    include /etc/nginx/conf.d/*.conf;


    # 负载均衡配置
    upstream aaa {
        # 请见上文中的五种配置
    }


   # 虚拟主机的配置
    server {

        # 监听端口
        listen 80;

        # 域名可以有多个,用空格隔开
        server_name www.aaa.com aaa.com;

        # 默认入口文件名称
        index index.html index.htm index.php;
        root /data/www/sk;

        # 图片缓存时间设置
        location ~ .*.(gif|jpg|jpeg|png|bmp|swf)${
            expires 10d;
        }

        #JS和CSS缓存时间设置
        location ~ .*.(js|css)?${
            expires 1h;
        }

        # 日志格式设定
        #$remote_addr与 $http_x_forwarded_for用以记录客户端的ip地址;
        #$remote_user:用来记录客户端用户名称;
        #$time_local:用来记录访问时间与时区;
        #$request:用来记录请求的url与http协议;
        #$status:用来记录请求状态;成功是200,
        #$body_bytes_sent :记录发送给客户端文件主体内容大小;
        #$http_referer:用来记录从那个页面链接访问过来的;
        log_format access '$remote_addr - $remote_user [$time_local] "$request" '
        '$status $body_bytes_sent "$http_referer" '
        '"$http_user_agent" $http_x_forwarded_for';

        # 定义本虚拟主机的访问日志
        access_log  /usr/local/nginx/logs/host.access.log  main;
        access_log  /usr/local/nginx/logs/host.access.404.log  log404;

        # 对具体路由进行反向代理
        location /connect-controller {

            proxy_pass http://127.0.0.1:88;
            proxy_redirect off;
            proxy_set_header X-Real-IP $remote_addr;

            # 后端的Web服务器可以通过X-Forwarded-For获取用户真实IP
            proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
            proxy_set_header Host $host;

            # 允许客户端请求的最大单文件字节数
            client_max_body_size 10m;

            # 缓冲区代理缓冲用户端请求的最大字节数,
            client_body_buffer_size 128k;

            # 表示使nginx阻止HTTP应答代码为400或者更高的应答。
            proxy_intercept_errors on;

            # nginx跟后端服务器连接超时时间(代理连接超时)
            proxy_connect_timeout 90;

            # 后端服务器数据回传时间_就是在规定时间之内后端服务器必须传完所有的数据
            proxy_send_timeout 90;

            # 连接成功后,后端服务器响应的超时时间
            proxy_read_timeout 90;

            # 设置代理服务器(nginx)保存用户头信息的缓冲区大小
            proxy_buffer_size 4k;

            # 设置用于读取应答的缓冲区数目和大小,默认情况也为分页大小,根据操作系统的不同可能是4k或者8k
            proxy_buffers 4 32k;

            # 高负荷下缓冲大小(proxy_buffers*2)
            proxy_busy_buffers_size 64k;

            # 设置在写入proxy_temp_path时数据的大小,预防一个工作进程在传递文件时阻塞太长
            # 设定缓存文件夹大小,大于这个值,将从upstream服务器传
            proxy_temp_file_write_size 64k;
        }

        # 动静分离反向代理配置(多路由指向不同的服务端或界面)
        location ~ .(jsp|jspx|do)?$ {
            proxy_set_header Host $host;
            proxy_set_header X-Real-IP $remote_addr;
            proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
            proxy_pass http://127.0.0.1:8080;
        }
    }
}

location


location指令的作用就是根据用户请求的URI来执行不同的应用


语法


location [ = | ~ | ~* | ^~ ] uri {...}


  • [ = | ~ | ~* | ^~ ]:匹配的标识



    • ~~*的区别是:~区分大小写,~*不区分大小写

    • ^~:进行常规字符串匹配后,不做正则表达式的检查



  • uri:匹配的网站地址

  • {...}:匹配uri后要执行的配置段


举例


location = / {
    [ configuration A ]
}
location / {
    [ configuration B ]
}
location /sk/ {
    [ configuration C ]
}
location ^~ /img/ {
    [ configuration D ]
}
location ~* .(gif|jpg|jpeg)$ {
    [ configuration E ]
}


  • = / 请求 / 精准匹配A,不再往下查找

  • / 请求/index.html匹配B。首先查找匹配的前缀字符,找到最长匹配是配置B,接着又按照顺序查找匹配的正则。结果没有找到,因此使用先前标记的最长匹配,即配置B。

  • /sk/ 请求/sk/abc 匹配C。首先找到最长匹配C,由于后面没有匹配的正则,所以使用最长匹配C。

  • ~* .(gif|jpg|jpeg)$ 请求/sk/logo.gif 匹配E。首先进行前缀字符的查找,找到最长匹配项C,继续进行正则查找,找到匹配项E。因此使用E。

  • ^~ 请求/img/logo.gif匹配D。首先进行前缀字符查找,找到最长匹配D。但是它使用了^~修饰符,不再进行下面的正则的匹配查找,因此使用D。


单页面应用刷新404问题


    location / {
        try_files $uri $uri/ /index.html;
    }

配置跨域请求


server {
    listen   80;
    location / {
        # 服务器默认是不被允许跨域的。
        # 配置`*`后,表示服务器可以接受所有的请求源(Origin),即接受所有跨域的请求
        add_header Access-Control-Allow-Origin *;
        
        add_header Access-Control-Allow-Methods 'GET, POST, OPTIONS';
        add_header Access-Control-Allow-Headers 'DNT,X-Mx-ReqToken,Keep-Alive,User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type,Authorization';
        
        # 发送"预检请求"时,需要用到方法 OPTIONS ,所以服务器需要允许该方法
        # 给OPTIONS 添加 204的返回,是为了处理在发送POST请求时Nginx依然拒绝访问的错误
        if ($request_method = 'OPTIONS') {
            return 204;
        }
    }
}

开启gzip压缩


    # gzip模块设置
    gzip on;               #开启gzip压缩输出
    gzip_min_length 1k;    #最小压缩文件大小
    gzip_buffers 4 16k;    #压缩缓冲区
    gzip_http_version 1.0; #压缩版本(默认1.1,前端如果是squid2.5请使用1.0)
    gzip_comp_level 2;     #压缩等级
    
    # 设置什么类型的文件需要压缩
    gzip_types text/plain application/x-javascript text/css application/xml;
    
    # 用于设置使用Gzip进行压缩发送是否携带“Vary:Accept-Encoding”头域的响应头部
    # 主要是告诉接收方,所发送的数据经过了Gzip压缩处理
    gzip_vary on;

总体而言,Nginx是一款轻量级、高性能、可靠性强且扩展性好的服务器软件,适用于搭建高可用性、高性能的Web应用程序和网站。


作者:日月之行_
来源:juejin.cn/post/7270153705877241890
收起阅读 »

农业银行算法题,为什么用初中知识出题,这么多人不会?

背景介绍 总所周知,有相当一部分的大学生是不会初高中知识的。 因此,每当那种「初等数学为背景编写的算法题」在笔面出现,舆论往往分成"三大派": 甚至那位说"致敬高考"的同学也搞岔了,高考哪有这么简单,美得你 🤣 这仅仅是初中数学「几何学」中较为简单的知识...
继续阅读 »

背景介绍


总所周知,有相当一部分的大学生是不会初高中知识的


因此,每当那种「初等数学为背景编写的算法题」在笔面出现,舆论往往分成"三大派":


对初高中知识,有清晰记忆


对初高中知识,记忆模糊


啥题?不会!


甚至那位说"致敬高考"的同学也搞岔了,高考哪有这么简单,美得你 🤣


这仅仅是初中数学「几何学」中较为简单的知识点。


抓住大学生对初高中知识这种「会者不难,难者不会」的现状,互联网大厂似乎更喜欢此类「考察初等数学」的算法题。


因为十个候选人,九个题海战术,HOT 100 和剑指 Offer 大家都刷得飞起了。


冷不丁的考察这种题目,反而更能起到"筛选"效果。


但此类算法题,农业银行 并非首创,甚至是同一道题,也被 华为云美的百度 先后出过。


学好初中数学,就能稳拿华为 15 级?🤣




下面,一起来看看这道题。


题目描述


平台:LeetCode


题号:149


给你一个数组 points,其中 points[i]=[xi,yi]points[i] = [x_i, y_i] 表示 X-Y 平面上的一个点。求最多有多少个点在同一条直线上。


示例 1:


输入:points = [[1,1],[2,2],[3,3]]

输出:3

示例 2:


输入:points = [[1,1],[3,2],[5,3],[4,1],[2,3],[1,4]]

输出:4

提示:



  • 1<=points.length<=3001 <= points.length <= 300

  • points[i].length=2points[i].length = 2

  • 104<=xi,yi<=104-10^4 <= x_i, y_i <= 10^4

  • points 中的所有点互不相同


枚举直线 + 枚举统计


我们知道,两点可以确定一条线。


一个朴素的做法是先枚举两点(确定一条线),然后检查其余点是否落在该线中。


为避免除法精度问题,当我们枚举两个点 xxyy 时,不直接计算其对应直线的 斜率截距


而是通过判断 xxyy 与第三个点 pp 形成的两条直线斜率是否相等,来得知点 pp 是否落在该直线上。


斜率相等的两条直线要么平行,要么重合。


平行需要 44 个点来唯一确定,我们只有 33 个点,因此直接判定两条直线是否重合即可。


详细说,当给定两个点 (x1,y1)(x_1, y_1)(x2,y2)(x_2, y_2) 时,对应斜率 y2y1x2x1\frac{y_2 - y_1}{x_2 - x_1}


为避免计算机除法的精度问题,我们将「判定 aybyaxbx=bycybxcx\frac{a_y - b_y}{a_x - b_x} = \frac{b_y - c_y}{b_x - c_x} 是否成立」改为「判定 (ayby)×(bxcx)=(axbx)×(bycy)(a_y - b_y) \times (b_x - c_x) = (a_x - b_x) \times (b_y - c_y) 是否成立」。


将存在精度问题的「除法判定」巧妙转为「乘法判定」。


Java 代码:


class Solution {
public int maxPoints(int[][] points) {
int n = points.length, ans = 1;
for (int i = 0; i < n; i++) {
int[] x = points[i];
for (int j = i + 1; j < n; j++) {
int[] y = points[j];
// 枚举点对 (i,j) 并统计有多少点在该线上, 起始 cnt = 2 代表只有 i 和 j 两个点在此线上
int cnt = 2;
for (int k = j + 1; k < n; k++) {
int[] p = points[k];
int s1 = (y[1] - x[1]) * (p[0] - y[0]);
int s2 = (p[1] - y[1]) * (y[0] - x[0]);
if (s1 == s2) cnt++;
}
ans = Math.max(ans, cnt);
}
}
return ans;
}
}

C++ 代码:


class Solution {
public:
int maxPoints(vector<vector<int>>& points) {
int n = points.size(), ans = 1;
for (int i = 0; i < n; i++) {
vector<int> x = points[i];
for (int j = i + 1; j < n; j++) {
vector<int> y = points[j];
// 枚举点对 (i,j) 并统计有多少点在该线上, 起始 cnt = 2 代表只有 i 和 j 两个点在此线上
int cnt = 2;
for (int k = j + 1; k < n; k++) {
vector<int> p = points[k];
int s1 = (y[1] - x[1]) * (p[0] - y[0]);
int s2 = (p[1] - y[1]) * (y[0] - x[0]);
if (s1 == s2) cnt++;
}
ans = max(ans, cnt);
}
}
return ans;
}
};

Python 代码:


class Solution:
def maxPoints(self, points: List[List[int]]) -> int:
n, ans = len(points), 1
for i, x in enumerate(points):
for j in range(i + 1, n):
y = points[j]
# 枚举点对 (i,j) 并统计有多少点在该线上, 起始 cnt = 2 代表只有 i 和 j 两个点在此线上
cnt = 2
for k in range(j + 1, n):
p = points[k]
s1 = (y[1] - x[1]) * (p[0] - y[0])
s2 = (p[1] - y[1]) * (y[0] - x[0])
if s1 == s2: cnt += 1
ans = max(ans, cnt)
return ans

TypeScript 代码:


function maxPoints(points: number[][]): number {
let n = points.length, ans = 1;
for (let i = 0; i < n; i++) {
let x = points[i];
for (let j = i + 1; j < n; j++) {
// 枚举点对 (i,j) 并统计有多少点在该线上, 起始 cnt = 2 代表只有 i 和 j 两个点在此线上
let y = points[j], cnt = 2;
for (let k = j + 1; k < n; k++) {
let p = points[k];
let s1 = (y[1] - x[1]) * (p[0] - y[0]);
let s2 = (p[1] - y[1]) * (y[0] - x[0]);
if (s1 == s2) cnt++;
}
ans = Math.max(ans, cnt);
}
}
return ans;
};


  • 时间复杂度:O(n3)O(n^3)

  • 空间复杂度:O(1)O(1)


枚举直线 + 哈希表统计


根据「朴素解法」的思路,枚举所有直线的过程不可避免,但统计点数的过程可以优化。


具体的,我们可以先枚举所有可能出现的 直线斜率(根据两点确定一条直线,即枚举所有的「点对」),使用「哈希表」统计所有 斜率 对应的点的数量,在所有值中取个 maxmax 即是答案。


一些细节:在使用「哈希表」进行保存时,为了避免精度问题,我们直接使用字符串进行保存,同时需要将 斜率 约干净(套用 gcd 求最大公约数模板)。


Java 代码:


class Solution {
public int maxPoints(int[][] points) {
int n = points.length, ans = 1;
for (int i = 0; i < n; i++) {
Map<String, Integer> map = new HashMap<>();
// 由当前点 i 发出的直线所经过的最多点数量
int max = 0;
for (int j = i + 1; j < n; j++) {
int x1 = points[i][0], y1 = points[i][1], x2 = points[j][0], y2 = points[j][1];
int a = x1 - x2, b = y1 - y2;
int k = gcd(a, b);
String key = (a / k) + "_" + (b / k);
map.put(key, map.getOrDefault(key, 0) + 1);
max = Math.max(max, map.get(key));
}
ans = Math.max(ans, max + 1);
}
return ans;
}
int gcd(int a, int b) {
return b == 0 ? a : gcd(b, a % b);
}
}

C++ 代码:


class Solution {
public:
int maxPoints(vector<vector<int>>& points) {
int n = points.size(), ans = 1;
for (int i = 0; i < n; i++) {
map<string, int> map;
int maxv = 0;
for (int j = i + 1; j < n; j++) {
int x1 = points[i][0], y1 = points[i][1], x2 = points[j][0], y2 = points[j][1];
int a = x1 - x2, b = y1 - y2;
int k = gcd(a, b);
string key = to_string(a / k) + "_" + to_string(b / k);
map[key]++;
maxv = max(maxv, map[key]);
}
ans = max(ans, maxv + 1);
}
return ans;
}
int gcd(int a, int b) {
return b == 0 ? a : gcd(b, a % b);
}
};

Python 代码:


class Solution:
def maxPoints(self, points):
def gcd(a, b):
return a if b == 0 else gcd(b, a % b)

n, ans = len(points), 1
for i in range(n):
mapping = {}
maxv = 0
for j in range(i + 1, n):
x1, y1 = points[i]
x2, y2 = points[j]
a, b = x1 - x2, y1 - y2
k = gcd(a, b)
key = str(a // k) + "_" + str(b // k)
mapping[key] = mapping.get(key, 0) + 1
maxv = max(maxv, mapping[key])
ans = max(ans, maxv + 1)
return ans

TypeScript 代码:


function maxPoints(points: number[][]): number {
const gcd = function(a: number, b: number): number {
return b == 0 ? a : gcd(b, a % b);
}
let n = points.length, ans = 1;
for (let i = 0; i < n; i++) {
let mapping = {}, maxv = 0;
for (let j = i + 1; j < n; j++) {
let x1 = points[i][0], y1 = points[i][1], x2 = points[j][0], y2 = points[j][1];
let a = x1 - x2, b = y1 - y2;
let k = gcd(a, b);
let key = `${a / k}_${b / k}`;
mapping[key] = mapping[key] ? mapping[key] + 1 : 1;
maxv = Math.max(maxv, mapping[key]);
}
ans = Math.max(ans, maxv + 1);
}
return ans;
};


  • 时间复杂度:枚举所有直线的复杂度为 O(n2)O(n^2);令坐标值的最大差值为 mmgcd 复杂度为 O(logm)O(\log{m})。整体复杂度为 O(n2×logm)O(n^2 \times \log{m})

  • 空间复杂度:O(n)O(n)


总结


虽然题目是以初中数学中的"斜率 & 截距"为背景,但仍有不少细节需要把握。


这也是「传统数学题」和「计算机算法题」的最大差别:



  • 过程分值: 传统数学题有过程分,计算机算法题没有过程分,哪怕思路对了 9090%,代码没写出来,就是 00 分;

  • 数据类型:传统数学题只涉及数值,计算机算法题需要考虑各种数据类型;

  • 运算精度:传统数学题无须考虑运算精度问题,而计算机算法题需要;

  • 判定机制:传统数学题通常给定具体数据和问题,然后人工根据求解过程和最终答案来综合评分,而计算机算法题不仅仅是求解一个具体的 case,通常是给定数据范围,然后通过若个不同的样例,机器自动判断程序的正确性;

  • 执行效率/时空复杂度:传统数学题无须考虑执行效率问题,只要求考生通过有限步骤(或引用定理节省步骤)写出答案即可,计算机算法题要求程序在有限时间空间内执行完;

  • 边界/异常处理:由于传统数学题的题面通常只有一个具体数据,因此不涉及边界处理,而计算机算法题需要考虑数据边界,甚至是对异常的输入输出做相应处理。


可见,传统数学题,有正确的思路基本上就赢了大半,而计算机算法题嘛,有正确思路,也只是万里长征跑了个 400400 米而已。


作者:宫水三叶的刷题日记
来源:juejin.cn/post/7312035308362039346
收起阅读 »

这次被 foreach 坑惨了,再也不敢乱用了...

近日,项目中有一个耗时较长的Job存在CPU占用过高的问题,经排查发现,主要时间消耗在往MyBatis中批量插入数据。mapper configuration是用foreach循环做的,差不多是这样。(由于项目保密,以下代码均为自己手写的demo代码) <...
继续阅读 »

近日,项目中有一个耗时较长的Job存在CPU占用过高的问题,经排查发现,主要时间消耗在往MyBatis中批量插入数据。mapper configuration是用foreach循环做的,差不多是这样。(由于项目保密,以下代码均为自己手写的demo代码)


<insert id="batchInsert" parameterType="java.util.List">  
insert int0 USER (id, name) values
<foreach collection="list" item="model" index="index" separator=",">
(#{model.id}, #{model.name})
</foreach>
</insert>

这个方法提升批量插入速度的原理是,将传统的:


INSERT INTO `table1` (`field1`, `field2`) VALUES ("data1", "data2");  
INSERT INTO `table1` (`field1`, `field2`) VALUES ("data1", "data2");
INSERT INTO `table1` (`field1`, `field2`) VALUES ("data1", "data2");
INSERT INTO `table1` (`field1`, `field2`) VALUES ("data1", "data2");
INSERT INTO `table1` (`field1`, `field2`) VALUES ("data1", "data2");

转化为:


INSERT INTO `table1` (`field1`, `field2`)   
VALUES ("data1", "data2"),
("data1", "data2"),
("data1", "data2"),
("data1", "data2"),
("data1", "data2");

在MySql Docs中也提到过这个trick,如果要优化插入速度时,可以将许多小型操作组合到一个大型操作中。理想情况下,这样可以在单个连接中一次性发送许多新行的数据,并将所有索引更新和一致性检查延迟到最后才进行。


乍看上去这个foreach没有问题,但是经过项目实践发现,当表的列数较多(20+),以及一次性插入的行数较多(5000+)时,整个插入的耗时十分漫长,达到了14分钟,这是不能忍的。在资料中也提到了一句话:



Of course don't combine ALL of them, if the amount is HUGE. Say you have 1000 rows you need to insert, then don't do it one at a time. You shouldn't equally try to have all 1000 rows in a single query. Instead break it int0 smaller sizes.



它强调,当插入数量很多时,不能一次性全放在一条语句里。可是为什么不能放在同一条语句里呢?这条语句为什么会耗时这么久呢?我查阅了资料发现:



Insert inside Mybatis foreach is not batch, this is a single (could become giant) SQL statement and that brings drawbacks:


some database such as Oracle here does not support.


in relevant cases: there will be a large number of records to insert and the database configured limit (by default around 2000 parameters per statement) will be hit, and eventually possibly DB stack error if the statement itself become too large.


Iteration over the collection must not be done in the mybatis XML. Just execute a simple Insertstatement in a Java Foreach loop. The most important thing is the session Executor type.


SqlSession session = sessionFactory.openSession(ExecutorType.BATCH);

for (Model model : list) {

session.insert("insertStatement", model);

}

session.flushStatements();


Unlike default ExecutorType.SIMPLE, the statement will be prepared once and executed for each record to insert.



从资料中可知,默认执行器类型为Simple,会为每个语句创建一个新的预处理语句,也就是创建一个PreparedStatement对象。


在我们的项目中,会不停地使用批量插入这个方法,而因为MyBatis对于含有的语句,无法采用缓存,那么在每次调用方法时,都会重新解析sql语句。



Internally, it still generates the same single insert statement with many placeholders as the JDBC code above.


MyBatis has an ability to cache PreparedStatement, but this statement cannot be cached because it containselement and the statement varies depending on the parameters. As a result, MyBatis has to 1) evaluate the foreach part and 2) parse the statement string to build parameter mapping [1] on every execution of this statement. And these steps are relatively costly process when the statement string is big and contains many placeholders.


[1] simply put, it is a mapping between placeholders and the parameters.



从上述资料可知,耗时就耗在,由于我foreach后有5000+个values,所以这个PreparedStatement特别长,包含了很多占位符,对于占位符和参数的映射尤其耗时。并且,查阅相关资料可知,values的增长与所需的解析时间,是呈指数型增长的。



图片


所以,如果非要使用 foreach 的方式来进行批量插入的话,可以考虑减少一条 insert 语句中 values 的个数,最好能达到上面曲线的最底部的值,使速度最快。一般按经验来说,一次性插20~50行数量是比较合适的,时间消耗也能接受。


重点来了。上面讲的是,如果非要用的方式来插入,可以提升性能的方式。而实际上,MyBatis文档中写批量插入的时候,是推荐使用另外一种方法。(可以看

http://www.mybatis.org/mybatis-dyn… 中 Batch Insert Support 标题里的内容)


SqlSession session = sqlSessionFactory.openSession(ExecutorType.BATCH);  
try {
SimpleTableMapper mapper = session.getMapper(SimpleTableMapper.class);
List<SimpleTableRecord> records = getRecordsToInsert(); // not shown

BatchInsert<SimpleTableRecord> batchInsert = insert(records)
.int0(simpleTable)
.map(id).toProperty("id")
.map(firstName).toProperty("firstName")
.map(lastName).toProperty("lastName")
.map(birthDate).toProperty("birthDate")
.map(employed).toProperty("employed")
.map(occupation).toProperty("occupation")
.build()
.render(RenderingStrategy.MYBATIS3);

batchInsert.insertStatements().stream().forEach(mapper::insert);

session.commit();
} finally {
session.close();
}

即基本思想是将 MyBatis session 的 executor type 设为 Batch ,然后多次执行插入语句。就类似于JDBC的下面语句一样。


Connection connection = DriverManager.getConnection("jdbc:mysql://127.0.0.1:3306/mydb?useUnicode=true&characterEncoding=UTF-8&useServerPrepStmts=false&rewriteBatchedStatements=true","root","root");  
connection.setAutoCommit(false);
PreparedStatement ps = connection.prepareStatement(
"insert int0 tb_user (name) values(?)");
for (int i = 0; i < stuNum; i++) {
ps.setString(1,name);
ps.addBatch();
}
ps.executeBatch();
connection.commit();
connection.close();

经过试验,使用了 ExecutorType.BATCH 的插入方式,性能显著提升,不到 2s 便能全部插入完成。


总结一下


如果MyBatis需要进行批量插入,推荐使用 ExecutorType.BATCH 的插入方式,如果非要使用  的插入的话,需要将每次插入的记录控制在 20~50 左右。


作者:Java小虫
来源:juejin.cn/post/7220611580193964093
收起阅读 »

万事不要急,不行就抓一个包嘛!

一、网络问题分析思路1、问题现象确认问题现象是什么丢包/访问不通/延迟大/拒绝访问/传输速度2、报文特征过滤找到与问题现象相符的报文3、问题原因根据报文特征和发生位置,推断问题原因安全组/iptables/服务问题4、发生位置分析异常报文,判断问题发生的位置客...
继续阅读 »

一、网络问题分析思路

1、问题现象

确认问题现象是什么

丢包/访问不通/延迟大/拒绝访问/传输速度

2、报文特征

过滤找到与问题现象相符的报文

3、问题原因

根据报文特征和发生位置,推断问题原因

安全组/iptables/服务问题

4、发生位置

分析异常报文,判断问题发生的位置

客户端/服务端/中间链路.....

二、关注报文字段

  1. time:访问延迟
  2. source:来源IP
  3. destination:目的IP
  4. Protocol:协议
  5. length:长度
  6. TTL:存活时间(Time To Live)国内运营商劫持,封禁等处理
  7. payload:TCP包的长度

三、本机抓包

curl http://www.baidu.com

为什么不使用ping请求?

因为ping请求是ICMP请求:用于在 IP 网络上进行错误报告、网络诊断和网络管理。而curl请求是HTTP GET 请求。

追踪TCP流,可以看到三次捂手——》数据交互——》四次挥手

image-20231211161849484

image-20231211161958078

四、案例 TTL异常的reset

业务场景: 客户端通过公网去访问云上的CVM的一个公网IP,发现不能访问

image-20231211170925943

image-20231211171135366

TTL突发过长——》判断云上资源有无策略上的设置——》公网端判断原因运营商的封堵(TTL 200以下)

解决方案:报障运营商

四、案例 访问延迟-判断延时出现位置

业务场景: 客户同VPS内网,两CVM互相访问

image-20231211173214992

image-20231211173103998

目的端回包0.006毫秒——》链路无问题,回包时间过长——》CVM底层调用逻辑问题

五、案例 丢包

业务场景: 云上CVM访问第三网方网站失败,影响业务

image-20231211175015273

五、案例 未回复http响应

业务场景:云上CVM访问第三网方网站失败,影响业务

image-20231211175432145

image-20231211175702839

ping正常,拨测正常——》客户端发送HTTP请求,目的端无HTTP回包,直接三次挥手——》确认根因服务端不回复我们——》推测:运营商封禁或者第三方网站设置了访问限制

六、基础&常见问题

1、了解TCP/IP四层协议

image-20231018154301479

2、了解TCP 三次握手与四次挥手

SYN:同步位。SYN=1,表示进行一个连接请求。

ACK:确认位。ACK=1,确认有效;ACK=0,确认无效;。

ack:确认号。对方发送序号 + 1

seq:序号

image-20231211111546472

image-20231018155125516

FIN=1,断开链接,并且客户端会停止向服务器端发数据

image-20231211112144895

image-20231018155211125

3、Http传输段构成(浏览器的请求内容有哪些,F12中Network)

请求:

  1. 请求行(Request Line):包含HTTP方法(GET、POST等)、请求的URL和HTTP协议的版本。
  2. 请求头部(Request Headers):包含关于请求的附加信息,如用户代理、内容类型、授权信息等。
  3. 空行(Blank Line):请求头部和请求体之间必须有一个空行。
  4. 请求体(Request Body):可选的,用于传输请求的数据,例如在POST请求中传递表单数据或上传文件。

响应:

  1. 状态行(Status Line):包含HTTP协议的版本、状态码和状态消息。
  2. 响应头部(Response Headers):包含关于响应的附加信息,如服务器类型、内容类型、响应时间等。
  3. 空行(Blank Line):响应头部和响应体之间必须有一个空行。
  4. 响应体(Response Body):包含实际的响应数据,例如HTML页面、JSON数据等。

这些组成部分共同构成了HTTP传输过程中的请求和响应。请求由客户端发送给服务器,服务器根据请求进行处理并返回相应的响应给客户端。

4、DNS污染怎么办

DNS污染是指恶意篡改或劫持DNS解析的过程,导致用户无法正确访问所需的网站或被重定向到错误的网站。如果您怀疑遭受了DNS污染,可以尝试以下方法来解决问题:

1、更改本地的DNS。 公共DNS服务器Google Public DNS(8.8.8.8和8.8.4.4)

2、清理本地DNS缓存。 systemctl restart NetworkManager。这将重启NetworkManager服务,刷新DNS缓存并应用任何新的DNS配置。

3、使用HTTPS。使用HTTPS访问网站可以提供更安全的连接,并减少DNS污染的风险。确保您访问的网站使用HTTPS协议。

5、traceroute路由追踪跳转过程

img

当您运行traceroute http://www.baidu.com命令时,它将显示从您的计算机到目标地址(http://www.baidu.com)之间的网络路径。它通过发送一系列的网络探测包(ICMP或UDP)来确定数据包从源到目标的路径,并记录每个中间节点的IP地址。

以下是一般情况下,traceroute命令可能经过的地址类型:

  1. 您的本地网络地址:这是您计算机所连接的本地网络的IP地址。
  2. 网关地址:如果您的计算机连接到一个局域网,它将经过您的网络网关,这是连接您的局域网与外部网络的设备。traceroute命令将显示网关的IP地址。
  3. ISP(互联网服务提供商)的路由器地址:数据包将通过您的ISP的网络传输,经过多个路由器。traceroute命令将显示每个路由器的IP地址。
  4. 目标地址:最后,数据包将到达目标地址,即http://www.baidu.com的IP地址。

img

6、Http状态码

100-199表示请求已被接收,继续处理比如:websocket_VScode自动刷新页面
200-299表示请求已成功被服务器接收、理解和处理。200 OK:请求成功,服务器成功返回请求的数据。 201 Created:请求成功,服务器已创建新的资源。204 No Content:请求成功,但服务器没有返回任何内容。
300-399(重定向状态码)表示需要进一步操作以完成请求301 Moved Permanently:请求的资源已永久移动到新位置。302 Found:请求的资源暂时移动到新位置。304 Not Modified:客户端的缓存资源是最新的,服务器返回此状态码表示资源未被修改。
400-499表示客户端发送的请求有错误。400 Bad Request:请求无效,服务器无法理解。401 Unauthorized:请求要求身份验证。404 Not Found:请求的资源不存在。
500-599表示服务器在处理请求时发生错误。500 Internal Server Error:服务器遇到了意外错误(服务器配置出错/数据库错误/代码出错),无法完成请求。503 Service Unavailable:服务器暂时无法处理请求,通常是由于过载或维护。

7、CDN内容加速网络原理

原理:减少漫长的路由转发,就近访问备份资源

1、通过配置网站的CDN,提前让CDN的中间节点OC备份一份内容,在分发给用户侧的SOC边缘节点,这样就能就近拉取资源。不用每次都通过漫长的路由导航到源站。

2、但是要达到加速的效果,还需要把真实域名的IP更改到CDN的IP,所有这里还需要DNS的帮助,这里一般都会求助用户本地运营商搭建的权威DNS域名解析服务器,用户请求逐级请求各级域名,本来应该会返回真实的IP地址,但是通过配置会返回给用户一个CDN的IP地址,CDN的权威服务器再讲距离用户最近的那台CDN服务器IP地址返回给用户,这样就实现了CDN加速的效果。

8、前后端通信到底是怎样一个过程

链接参考:juejin.cn/post/728518…

内容参考:

小林coding:xiaolincoding.com/network/

哔哩哔哩:http://www.bilibili.com/video/BV1at…

Winshark:http://www.wireshark.org/


作者:武师叔
来源:juejin.cn/post/7311159008483983369

收起阅读 »

京东一面:post为什么会发送两次请求?🤪🤪🤪

在前段时间的一次面试中,被问到了一个如标题这样的问题。要想好好地去回答这个问题,这里牵扯到的知识点也是比较多的。 那么接下来这篇文章我们就一点一点开始引出这个问题。 同源策略 在浏览器中,内容是很开放的,任何资源都可以接入其中,如 JavaScript 文件、...
继续阅读 »

在前段时间的一次面试中,被问到了一个如标题这样的问题。要想好好地去回答这个问题,这里牵扯到的知识点也是比较多的。


那么接下来这篇文章我们就一点一点开始引出这个问题。


同源策略


在浏览器中,内容是很开放的,任何资源都可以接入其中,如 JavaScript 文件、图片、音频、视频等资源,甚至可以下载其他站点的可执行文件。


但也不是说浏览器就是完全自由的,如果不加以控制,就会出现一些不可控的局面,例如会出现一些安全问题,如:



  • 跨站脚本攻击(XSS)

  • SQL 注入攻击

  • OS 命令注入攻击

  • HTTP 首部注入攻击

  • 跨站点请求伪造(CSRF)

  • 等等......


如果这些都没有限制的话,对于我们用户而言,是相对危险的,因此需要一些安全策略来保障我们的隐私和数据安全。


这就引出了最基础、最核心的安全策略:同源策略。


什么是同源策略


同源策略是一个重要的安全策略,它用于限制一个源的文档或者它加载的脚本如何能与另一个源的资源进行交互。


如果两个 URL 的协议、主机和端口都相同,我们就称这两个 URL 同源。



  • 协议:协议是定义了数据如何在计算机内和之间进行交换的规则的系统,例如 HTTP、HTTPS。

  • 主机:是已连接到一个计算机网络的一台电子计算机或其他设备。网络主机可以向网络上的用户或其他节点提供信息资源、服务和应用。使用 TCP/IP 协议族参与网络的计算机也可称为 IP 主机。

  • 端口:主机是计算机到计算机之间的通信,那么端口就是进程到进程之间的通信。


如下表给出了与 URL http://store.company.com:80/dir/page.html 的源进行对比的示例:


URL结果原因
http://store.company.com:80/dir2/page.html同源只有路径不同
http://store.company.com:80/dir/inner/another.html同源只有路径不同
https://store.company.com:443/secure.html不同源协议不同,HTTP 和 HTTPS
http://store.company.com:81/dir/etc.html不同源端口不同
http://news.company.com:80/dir/other.html不同源主机不同

同源策略主要表现在以下三个方面:DOM、Web 数据和网络。



  • DOM 访问限制:同源策略限制了网页脚本(如 JavaScript)访问其他源的 DOM。这意味着通过脚本无法直接访问跨源页面的 DOM 元素、属性或方法。这是为了防止恶意网站从其他网站窃取敏感信息。

  • Web 数据限制:同源策略也限制了从其他源加载的 Web 数据(例如 XMLHttpRequest 或 Fetch API)。在同源策略下,XMLHttpRequest 或 Fetch 请求只能发送到与当前网页具有相同源的目标。这有助于防止跨站点请求伪造(CSRF)等攻击。

  • 网络通信限制:同源策略还限制了跨源的网络通信。浏览器会阻止从一个源发出的请求获取来自其他源的响应。这样做是为了确保只有受信任的源能够与服务器进行通信,以避免恶意行为。


出于安全原因,浏览器限制从脚本内发起的跨源 HTTP 请求,XMLHttpRequest 和 Fetch API,只能从加载应用程序的同一个域请求 HTTP 资源,除非使用 CORS 头文件


CORS


对于浏览器限制这个词,要着重解释一下:不一定是浏览器限制了发起跨站请求,也可能是跨站请求可以正常发起,但是返回结果被浏览器拦截了。


浏览器将不同域的内容隔离在不同的进程中,网络进程负责下载资源并将其送到渲染进程中,但由于跨域限制,某些资源可能被阻止加载到渲染进程。如果浏览器发现一个跨域响应包含了敏感数据,它可能会阻止脚本访问这些数据,即使网络进程已经获得了这些数据。CORB 的目标是在渲染之前尽早阻止恶意代码获取跨域数据。



CORB 是一种安全机制,用于防止跨域请求恶意访问跨域响应的数据。渲染进程会在 CORB 机制的约束下,选择性地将哪些资源送入渲染进程供页面使用。



例如,一个网页可能通过 AJAX 请求从另一个域的服务器获取数据。虽然某些情况下这样的请求可能会成功,但如果浏览器检测到请求返回的数据可能包含恶意代码或与同源策略冲突,浏览器可能会阻止网页访问返回的数据,以确保用户的安全。


跨源资源共享(Cross-Origin Resource Sharing,CORS)是一种机制,允许在受控的条件下,不同源的网页能够请求和共享资源。由于浏览器的同源策略限制了跨域请求,CORS 提供了一种方式来解决在 Web 应用中进行跨域数据交换的问题。


CORS 的基本思想是,服务器在响应中提供一个标头(HTTP 头),指示哪些源被允许访问资源。浏览器在发起跨域请求时会先发送一个预检请求(OPTIONS 请求)到服务器,服务器通过设置适当的 CORS 标头来指定是否允许跨域请求,并指定允许的请求源、方法、标头等信息。


简单请求


不会触发 CORS 预检请求。这样的请求为 简单请求,。若请求满足所有下述条件,则该请求可视为 简单请求



  1. HTTP 方法限制:只能使用 GET、HEAD、POST 这三种 HTTP 方法之一。如果请求使用了其他 HTTP 方法,就不再被视为简单请求。

  2. 自定义标头限制:请求的 HTTP 标头只能是以下几种常见的标头:AcceptAccept-LanguageContent-LanguageLast-Event-IDContent-Type(仅限于 application/x-www-form-urlencodedmultipart/form-datatext/plain)。HTML 头部 header field 字段:DPR、Download、Save-Data、Viewport-Width、WIdth。如果请求使用了其他标头,同样不再被视为简单请求。

  3. 请求中没有使用 ReadableStream 对象。

  4. 不使用自定义请求标头:请求不能包含用户自定义的标头。

  5. 请求中的任意 XMLHttpRequestUpload 对象均没有注册任何事件监听器;XMLHttpRequestUpload 对象可以使用 XMLHttpRequest.upload 属性访问


预检请求


非简单请求的 CORS 请求,会在正式通信之前,增加一次 HTTP 查询请求,称为 预检请求


需预检的请求要求必须首先使用 OPTIONS 方法发起一个预检请求到服务器,以获知服务器是否允许该实际请求。预检请求 的使用,可以避免跨域请求对服务器的用户数据产生未预期的影响。


例如我们在掘金上删除一条沸点:


20230822094049


它首先会发起一个预检请求,预检请求的头信息包括两个特殊字段:



  • Access-Control-Request-Method:该字段是必须的,用来列出浏览器的 CORS 请求会用到哪些 HTTP 方法,上例是 POST。

  • Access-Control-Request-Headers:该字段是一个逗号分隔的字符串,指定浏览器 CORS 请求会额外发送的头信息字段,上例是 content-type,x-secsdk-csrf-token

  • access-control-allow-origin:在上述例子中,表示 https://juejin.cn 可以请求数据,也可以设置为* 符号,表示统一任意跨源请求。

  • access-control-max-age:该字段可选,用来指定本次预检请求的有效期,单位为秒。上面结果中,有效期是 1 天(86408 秒),即允许缓存该条回应 1 天(86408 秒),在此期间,不用发出另一条预检请求。


一旦服务器通过了 预检请求,以后每次浏览器正常的 CORS 请求,就都跟简单请求一样,会有一个 Origin 头信息字段。服务器的回应,也都会有一个 Access-Control-Allow-Origin 头信息字段。


20230822122441


上面头信息中,Access-Control-Allow-Origin 字段是每次回应都必定包含的。


附带身份凭证的请求与通配符


在响应附带身份凭证的请求时:



  • 为了避免恶意网站滥用 Access-Control-Allow-Origin 头部字段来获取用户敏感信息,服务器在设置时不能将其值设为通配符 *。相反,应该将其设置为特定的域,例如:Access-Control-Allow-Origin: https://juejin.cn。通过将 Access-Control-Allow-Origin 设置为特定的域,服务器只允许来自指定域的请求进行跨域访问。这样可以限制跨域请求的范围,避免不可信的域获取到用户敏感信息。

  • 为了避免潜在的安全风险,服务器不能将 Access-Control-Allow-Headers 的值设为通配符 *。这是因为不受限制的请求头可能被滥用。相反,应该将其设置为一个包含标头名称的列表,例如:Access-Control-Allow-Headers: X-PINGOTHER, Content-Type。通过将 Access-Control-Allow-Headers 设置为明确的标头名称列表,服务器可以限制哪些自定义请求头是允许的。只有在允许的标头列表中的头部字段才能在跨域请求中被接受。

  • 为了避免潜在的安全风险,服务器不能将 Access-Control-Allow-Methods 的值设为通配符 *。这样做将允许来自任意域的请求使用任意的 HTTP 方法,可能导致滥用行为的发生。相反,应该将其设置为一个特定的请求方法名称列表,例如:Access-Control-Allow-Methods: POST, GET。通过将 Access-Control-Allow-Methods 设置为明确的请求方法列表,服务器可以限制哪些方法是允许的。只有在允许的方法列表中的方法才能在跨域请求中被接受和处理。

  • 对于附带身份凭证的请求(通常是 Cookie),


这是因为请求的标头中携带了 Cookie 信息,如果 Access-Control-Allow-Origin 的值为 *,请求将会失败。而将 Access-Control-Allow-Origin 的值设置为 https://juejin。cn,则请求将成功执行。


另外,响应标头中也携带了 Set-Cookie 字段,尝试对 Cookie 进行修改。如果操作失败,将会抛出异常。


为什么本地使用 webpack 进行 dev 开发时,不需要服务器端配置 cors 的情况下访问到线上接口?


当你在本地通过 Ajax 或其他方式请求线上接口时,由于浏览器的同源策略,会出现跨域的问题。但是在服务器端并不会出现这个问题。


它是通过 Webpack Dev Server 来实现这个功能。当你在浏览器中发送请求时,请求会先被 Webpack Dev Server 捕获,然后根据你的代理规则将请求转发到目标服务器,目标服务器返回的数据再经由 Webpack Dev Server 转发回浏览器。这样就绕过了浏览器的同源策略限制,使你能够在本地开发环境中访问线上接口。


参考文章



总结


预检请求是在进行跨域资源共享 CORS 时,由浏览器自动发起的一种 OPTIONS 请求。它的存在是为了保障安全,并允许服务器决定是否允许跨域请求。


跨域请求是指在浏览器中向不同域名、不同端口或不同协议的资源发送请求。出于安全原因,浏览器默认禁止跨域请求,只允许同源策略。而当网页需要进行跨域请求时,浏览器会自动发送一个预检请求,以确定是否服务器允许实际的跨域请求。


预检请求中包含了一些额外的头部信息,如 Origin 和 Access-Control-Request-Method 等,用于告知服务器实际请求的方法和来源。服务器收到预检请求后,可以根据这些头部信息,进行验证和授权判断。如果服务器认可该跨域请求,将返回一个包含 Access-Control-Allow-Origin 等头部信息的响应,浏览器才会继续发送实际的跨域请求。


使用预检请求机制可以有效地防范跨域请求带来的安全风险,保护用户数据和隐私。


整个完整的请求流程有如下图所示:


20230822122544


最后分享两个我的两个开源项目,它们分别是:



这两个项目都会一直维护的,如果你也喜欢,欢迎 star 🥰🥰🥰


作者:Moment
来源:juejin.cn/post/7269952188927017015
收起阅读 »

技术总监写的十个方法,让我精通了lambda表达式

前公司的技术总监写了工具类,对Java Stream 进行二次封装,使用起来非常爽,全公司都在用。 我自己照着写了一遍,改了名字,分享给大家。 一共整理了10个工具方法,可以满足 Collection、List、Set、Map 之间各种类型转化。例如 将 C...
继续阅读 »

前公司的技术总监写了工具类,对Java Stream 进行二次封装,使用起来非常爽,全公司都在用。


我自己照着写了一遍,改了名字,分享给大家。


一共整理了10个工具方法,可以满足 Collection、List、Set、Map 之间各种类型转化。例如



  1. Collection<OrderItem> 转化为 List<OrderItem>

  2. Collection<OrderItem> 转化为 Set<OrderItem>

  3. List<OrderItem> 转化为 List<Long>

  4. Set<OrderItem> 转化为 Set<Long>

  5. Collection<OrderItem> 转化为 List<Long>

  6. Collection<OrderItem> 转化为 Set<Long>

  7. Collection<OrderItem>中提取 Key, Map 的 Value 就是类型 OrderItem

  8. Collection<OrderItem>中提取 Key, Map 的 Value 根据 OrderItem 类型进行转化。

  9. Map<Long, OrderItem> 中的value 转化为 Map<Long, Double>

  10. value 转化时,lamada表达式可以使用(v)->{}, 也可以使用 (k,v)->{ }


Collection 集合类型到 Map类型的转化。


Collection 转化为 Map


由于 List 和 Set 是 Collection 类型的子类,所以只需要实现Collection 类型转化为 Map 类型即可。
Collection转化为 Map 共分两个方法



  1. Collection<OrderItem> Map<Key, OrderItem>,提取 Key, Map 的 Value 就是类型 OrderItem

  2. Collection<OrderItem>Map<Key,Value> ,提取 Key, Map 的 Value 根据 OrderItem 类型进行转化。


使用样例


代码示例中把Set<OrderItem> 转化为 Map<Long, OrderItem>Map<Long ,Double>


@Test
public void testToMap() {
Collection<OrderItem> collection = coll;
Set<OrderItem> set = toSet(collection);

Map<Long, OrderItem> map = toMap(set, OrderItem::getOrderId);
}

@Test
public void testToMapV2() {
Collection<OrderItem> collection = coll;
Set<OrderItem> set = toSet(collection);

Map<Long, Double> map = toMap(set, OrderItem::getOrderId, OrderItem::getActPrice);
}

代码展示


public static <T, K> Map<K, T> toMap(Collection<T> collection, Function<? super T, ? extends K> keyMapper) {
return toMap(collection, keyMapper, Function.identity());
}

public static <T, K, V> Map<K, V> toMap(Collection<T> collection,
Function<? super T, ? extends K> keyFunction,
Function<? super T, ? extends V> valueFunction)
{
return toMap(collection, keyFunction, valueFunction, pickSecond());
}

public static <T, K, V> Map<K, V> toMap(Collection<T> collection,
Function<? super T, ? extends K> keyFunction,
Function<? super T, ? extends V> valueFunction,
BinaryOperator<V> mergeFunction)
{
if (CollectionUtils.isEmpty(collection)) {
return new HashMap<>(0);
}

return collection.stream().collect(Collectors.toMap(keyFunction, valueFunction, mergeFunction));
}

public static <T> BinaryOperator<T> pickFirst() {
return (k1, k2) -> k1;
}
public static <T> BinaryOperator<T> pickSecond() {
return (k1, k2) -> k2;
}

Map格式转换


转换 Map 的 Value



  1. 将 Map<Long, OrderItem> 中的value 转化为 Map<Long, Double>

  2. value 转化时,lamada表达式可以使用(v)->{}, 也可以使用 (k,v)->{ }。


测试样例


@Test
public void testConvertValue() {
Collection<OrderItem> collection = coll;
Set<OrderItem> set = toSet(collection);

Map<Long, OrderItem> map = toMap(set, OrderItem::getOrderId);

Map<Long, Double> orderId2Price = convertMapValue(map, item -> item.getActPrice());
Map<Long, String> orderId2Token = convertMapValue(map, (id, item) -> id + item.getName());

}

代码展示


public static <K, V, C> Map<K, C> convertMapValue(Map<K, V> map, 
BiFunction<K, V, C> valueFunction,
BinaryOperator<C> mergeFunction)
{
if (isEmpty(map)) {
return new HashMap<>();
}
return map.entrySet().stream().collect(Collectors.toMap(
e -> e.getKey(),
e -> valueFunction.apply(e.getKey(), e.getValue()),
mergeFunction
));
}

public static <K, V, C> Map<K, C> convertMapValue(Map<K, V> originMap, BiFunction<K, V, C> valueConverter) {
return convertMapValue(originMap, valueConverter, Lambdas.pickSecond());
}

public static <T> BinaryOperator<T> pickFirst() {
return (k1, k2) -> k1;
}
public static <T> BinaryOperator<T> pickSecond() {
return (k1, k2) -> k2;
}

集合类型转化


Collection 和 List、Set 的转化



  1. Collection<OrderItem> 转化为 List<OrderItem>

  2. Collection<OrderItem> 转化为 Set<OrderItem>


public static <T> List<T> toList(Collection<T> collection) {
if (collection == null) {
return new ArrayList<>();
}
if (collection instanceof List) {
return (List<T>) collection;
}
return collection.stream().collect(Collectors.toList());
}

public static <T> Set<T> toSet(Collection<T> collection) {
if (collection == null) {
return new HashSet<>();
}
if (collection instanceof Set) {
return (Set<T>) collection;
}
return collection.stream().collect(Collectors.toSet());
}

测试样例


@Test//将集合 Collection 转化为 List
public void testToList() {
Collection<OrderItem> collection = coll;
List<OrderItem> list = toList(coll);
}

@Test//将集合 Collection 转化为 Set
public void testToSet() {
Collection<OrderItem> collection = coll;
Set<OrderItem> set = toSet(collection);
}

List和 Set 是 Collection 集合类型的子类,所以无需再转化。


List、Set 类型之间的转换


业务中有时候需要将 List<A> 转化为 List<B>。如何实现工具类呢?


public static <T, R> List<R> map(List<T> collection, Function<T, R> mapper) {
return collection.stream().map(mapper).collect(Collectors.toList());
}

public static <T, R> Set<R> map(Set<T> collection, Function<T, R> mapper) {
return collection.stream().map(mapper).collect(Collectors.toSet());
}

public static <T, R> List<R> mapToList(Collection<T> collection, Function<T, R> mapper) {
return collection.stream().map(mapper).collect(Collectors.toList());
}

public static <T, R> Set<R> mapToSet(Collection<T> collection, Function<T, R> mapper) {
return collection.stream().map(mapper).collect(Collectors.toSet());
}

测试样例



  1. List<OrderItem> 转化为 List<Long>

  2. Set<OrderItem> 转化为 Set<Long>

  3. Collection<OrderItem> 转化为 List<Long>

  4. Collection<OrderItem> 转化为 Set<Long>


@Test
public void testMapToList() {
Collection<OrderItem> collection = coll;
List<OrderItem> list = toList(coll);

List<Long> orderIdList = map(list, (item) -> item.getOrderId());
}

@Test
public void testMapToSet() {
Collection<OrderItem> collection = coll;
Set<OrderItem> set = toSet(coll);

Set<Long> orderIdSet = map(set, (item) -> item.getOrderId());
}

@Test
public void testMapToList2() {
Collection<OrderItem> collection = coll;

List<Long> orderIdList = mapToList(collection, (item) -> item.getOrderId());
}

@Test
public void testMapToSetV2() {
Collection<OrderItem> collection = coll;

Set<Long> orderIdSet = mapToSet(collection, (item) -> item.getOrderId());

}

总结一下 以上样例包含了如下的映射场景



  1. Collection<OrderItem> 转化为 List<OrderItem>

  2. Collection<OrderItem> 转化为 Set<OrderItem>

  3. List<OrderItem> 转化为 List<Long>

  4. Set<OrderItem> 转化为 Set<Long>

  5. Collection<OrderItem> 转化为 List<Long>

  6. Collection<OrderItem> 转化为 Set<Long>

  7. Collection<OrderItem>中提取 Key, Map 的 Value 就是类型 OrderItem

  8. Collection<OrderItem>中提取 Key, Map 的 Value 根据 OrderItem 类型进行转化。

  9. Map<Long, OrderItem> 中的value 转化为 Map<Long, Double>

  10. value 转化时,lamada表达式可以使用(v)->{}, 也可以使用 (k,v)->{ }


作者:五阳神功
来源:juejin.cn/post/7305572311812587531
收起阅读 »

推荐一个小而全的第三方登录开源组件

大家好,我是 Java陈序员。 我们在企业开发中,常常需要实现登录功能,而有时候为了方便,就需要集成第三方平台的授权登录。如常见的微信登录、微博登录等,免去了用户注册步骤,提高了用户体验。 为了业务考虑,我们有时候集成的不仅仅是一两个第三方平台,甚至更多。这就...
继续阅读 »

大家好,我是 Java陈序员


我们在企业开发中,常常需要实现登录功能,而有时候为了方便,就需要集成第三方平台的授权登录。如常见的微信登录、微博登录等,免去了用户注册步骤,提高了用户体验。


为了业务考虑,我们有时候集成的不仅仅是一两个第三方平台,甚至更多。这就会大大的提高了工作量,那么有没有开源框架来统一来集成这些第三方授权登录呢?


答案是有的,今天给大家介绍的项目提供了一个第三方授权登录的工具类库


项目介绍


JustAuth —— 一个第三方授权登录的工具类库,可以让你脱离繁琐的第三方登录 SDK,让登录变得So easy!


JustAuth


JustAuth 集成了诸如:Github、Gitee、微博、钉钉、百度、Coding、腾讯云开发者平台、OSChina、支付宝、QQ、微信、淘宝、Google、Facebook、抖音、领英、小米、微软、今日头条、Teambition、StackOverflow、Pinterest、人人、华为、企业微信、酷家乐、Gitlab、美团、饿了么、推特、飞书、京东、阿里云、喜马拉雅、Amazon、Slack和 Line 等第三方平台的授权登录。


功能特色:



  • 丰富的 OAuth 平台:支持国内外数十家知名的第三方平台的 OAuth 登录。

  • 自定义 state:支持自定义 State 和缓存方式,开发者可根据实际情况选择任意缓存插件。

  • 自定义 OAuth:提供统一接口,支持接入任意 OAuth 网站,快速实现 OAuth 登录功能。

  • 自定义 Http:接口 HTTP 工具,开发者可以根据自己项目的实际情况选择相对应的HTTP工具。

  • 自定义 Scope:支持自定义 scope,以适配更多的业务场景,而不仅仅是为了登录。

  • 代码规范·简单:JustAuth 代码严格遵守阿里巴巴编码规约,结构清晰、逻辑简单。


安装使用


回顾 OAuth 授权流程


参与的角色



  • Resource Owner 资源所有者,即代表授权客户端访问本身资源信息的用户(User),也就是应用场景中的“开发者A”

  • Resource Server 资源服务器,托管受保护的用户账号信息,比如 Github
    Authorization Server 授权服务器,验证用户身份然后为客户端派发资源访问令牌,比如 Github

  • Resource ServerAuthorization Server 可以是同一台服务器,也可以是不同的服务器,视具体的授权平台而有所差异

  • Client 客户端,即代表意图访问受限资源的第三方应用


授权流程


OAuth 授权流程


使用步骤


1、申请注册第三方平台的开发者账号


2、创建第三方平台的应用,获取配置信息(accessKey, secretKey, redirectUri)


3、使用 JustAuth 实现授权登陆


引入依赖


<dependency>
<groupId>me.zhyd.oauthgroupId>
<artifactId>JustAuthartifactId>
<version>{latest-version}version>
dependency>

调用 API


// 创建授权request
AuthRequest authRequest = new AuthGiteeRequest(AuthConfig.builder()
.clientId("clientId")
.clientSecret("clientSecret")
.redirectUri("redirectUri")
.build());
// 生成授权页面
authRequest.authorize("state");
// 授权登录后会返回code(auth_code(仅限支付宝))、state,1.8.0版本后,可以用AuthCallback类作为回调接口的参数
// 注:JustAuth默认保存state的时效为3分钟,3分钟内未使用则会自动清除过期的state
authRequest.login(callback);


说明:
JustAuth 的核心就是一个个的 request,每个平台都对应一个具体的 request 类。
所以在使用之前,需要就具体的授权平台创建响应的 request.如示例代码中对应的是 Gitee 平台。



集成国外平台



国外平台需要额外配置 httpConfig



AuthRequest authRequest = new AuthGoogleRequest(AuthConfig.builder()
.clientId("Client ID")
.clientSecret("Client Secret")
.redirectUri("应用回调地址")
// 针对国外平台配置代理
.httpConfig(HttpConfig.builder()
// Http 请求超时时间
.timeout(15000)
// host 和 port 请修改为开发环境的参数
.proxy(new Proxy(Proxy.Type.HTTP, new InetSocketAddress("127.0.0.1", 10080)))
.build())
.build());

SpringBoot 集成


引入依赖


<dependency>
<groupId>com.xkcoding.justauthgroupId>
<artifactId>justauth-spring-boot-starterartifactId>
<version>1.4.0version>
dependency>

配置文件


justauth:
enabled: true
type:
QQ:
client-id: 10**********6
client-secret: 1f7d08**********5b7**********29e
redirect-uri: http://oauth.xkcoding.com/demo/oauth/qq/callback
union-id: false
WEIBO:
client-id: 10**********6
client-secret: 1f7d08**********5b7**********29e
redirect-uri: http://oauth.xkcoding.com/demo/oauth/weibo/callback
GITEE:
client-id: 10**********6
client-secret: 1f7d08**********5b7**********29e
redirect-uri: http://oauth.xkcoding.com/demo/oauth/gitee/callback
DINGTALK:
client-id: 10**********6
client-secret: 1f7d08**********5b7**********29e
redirect-uri: http://oauth.xkcoding.com/demo/oauth/dingtalk/callback
BAIDU:
client-id: 10**********6
client-secret: 1f7d08**********5b7**********29e
redirect-uri: http://oauth.xkcoding.com/demo/oauth/baidu/callback
CSDN:
client-id: 10**********6
client-secret: 1f7d08**********5b7**********29e
redirect-uri: http://oauth.xkcoding.com/demo/oauth/csdn/callback
CODING:
client-id: 10**********6
client-secret: 1f7d08**********5b7**********29e
redirect-uri: http://oauth.xkcoding.com/demo/oauth/coding/callback
coding-group-name: xx
OSCHINA:
client-id: 10**********6
client-secret: 1f7d08**********5b7**********29e
redirect-uri: http://oauth.xkcoding.com/demo/oauth/oschina/callback
ALIPAY:
client-id: 10**********6
client-secret: 1f7d08**********5b7**********29e
redirect-uri: http://oauth.xkcoding.com/demo/oauth/alipay/callback
alipay-public-key: MIIB**************DAQAB
WECHAT_OPEN:
client-id: 10**********6
client-secret: 1f7d08**********5b7**********29e
redirect-uri: http://oauth.xkcoding.com/demo/oauth/wechat_open/callback
WECHAT_MP:
client-id: 10**********6
client-secret: 1f7d08**********5b7**********29e
redirect-uri: http://oauth.xkcoding.com/demo/oauth/wechat_mp/callback
WECHAT_ENTERPRISE:
client-id: 10**********6
client-secret: 1f7d08**********5b7**********29e
redirect-uri: http://oauth.xkcoding.com/demo/oauth/wechat_enterprise/callback
agent-id: 1000002
TAOBAO:
client-id: 10**********6
client-secret: 1f7d08**********5b7**********29e
redirect-uri: http://oauth.xkcoding.com/demo/oauth/taobao/callback
GOOGLE:
client-id: 10**********6
client-secret: 1f7d08**********5b7**********29e
redirect-uri: http://oauth.xkcoding.com/demo/oauth/google/callback
FACEBOOK:
client-id: 10**********6
client-secret: 1f7d08**********5b7**********29e
redirect-uri: http://oauth.xkcoding.com/demo/oauth/facebook/callback
DOUYIN:
client-id: 10**********6
client-secret: 1f7d08**********5b7**********29e
redirect-uri: http://oauth.xkcoding.com/demo/oauth/douyin/callback
LINKEDIN:
client-id: 10**********6
client-secret: 1f7d08**********5b7**********29e
redirect-uri: http://oauth.xkcoding.com/demo/oauth/linkedin/callback
MICROSOFT:
client-id: 10**********6
client-secret: 1f7d08**********5b7**********29e
redirect-uri: http://oauth.xkcoding.com/demo/oauth/microsoft/callback
MI:
client-id: 10**********6
client-secret: 1f7d08**********5b7**********29e
redirect-uri: http://oauth.xkcoding.com/demo/oauth/mi/callback
TOUTIAO:
client-id: 10**********6
client-secret: 1f7d08**********5b7**********29e
redirect-uri: http://oauth.xkcoding.com/demo/oauth/toutiao/callback
TEAMBITION:
client-id: 10**********6
client-secret: 1f7d08**********5b7**********29e
redirect-uri: http://oauth.xkcoding.com/demo/oauth/teambition/callback
RENREN:
client-id: 10**********6
client-secret: 1f7d08**********5b7**********29e
redirect-uri: http://oauth.xkcoding.com/demo/oauth/renren/callback
PINTEREST:
client-id: 10**********6
client-secret: 1f7d08**********5b7**********29e
redirect-uri: http://oauth.xkcoding.com/demo/oauth/pinterest/callback
STACK_OVERFLOW:
client-id: 10**********6
client-secret: 1f7d08**********5b7**********29e
redirect-uri: http://oauth.xkcoding.com/demo/oauth/stack_overflow/callback
stack-overflow-key: asd*********asd
HUAWEI:
client-id: 10**********6
client-secret: 1f7d08**********5b7**********29e
redirect-uri: http://oauth.xkcoding.com/demo/oauth/huawei/callback
KUJIALE:
client-id: 10**********6
client-secret: 1f7d08**********5b7**********29e
redirect-uri: http://oauth.xkcoding.com/demo/oauth/kujiale/callback
GITLAB:
client-id: 10**********6
client-secret: 1f7d08**********5b7**********29e
redirect-uri: http://oauth.xkcoding.com/demo/oauth/gitlab/callback
MEITUAN:
client-id: 10**********6
client-secret: 1f7d08**********5b7**********29e
redirect-uri: http://oauth.xkcoding.com/demo/oauth/meituan/callback
ELEME:
client-id: 10**********6
client-secret: 1f7d08**********5b7**********29e
redirect-uri: http://oauth.xkcoding.com/demo/oauth/eleme/callback
TWITTER:
client-id: 10**********6
client-secret: 1f7d08**********5b7**********29e
redirect-uri: http://oauth.xkcoding.com/demo/oauth/twitter/callback
XMLY:
client-id: 10**********6
client-secret: 1f7d08**********5b7**********29e
redirect-uri: http://oauth.xkcoding.com/demo/oauth/xmly/callback
# 设备唯一标识ID
device-id: xxxxxxxxxxxxxx
# 客户端操作系统类型,1-iOS系统,2-Android系统,3-Web
client-os-type: 3
# 客户端包名,如果 clientOsType 为12时必填。对Android客户端是包名,对IOS客户端是Bundle ID
#pack-id: xxxx
FEISHU:
client-id: 10**********6
client-secret: 1f7d08**********5b7**********29e
redirect-uri: http://oauth.xkcoding.com/demo/oauth/feishu/callback
JD:
client-id: 10**********6
client-secret: 1f7d08**********5b7**********29e
redirect-uri: http://oauth.xkcoding.com/demo/oauth/jd/callback
cache:
type: default

代码使用


@Slf4j
@RestController
@RequestMapping("/oauth")
@RequiredArgsConstructor(onConstructor_ = @Autowired)
public class TestController {
private final AuthRequestFactory factory;

@GetMapping
public List list() {
return factory.oauthList();
}

@GetMapping("/login/{type}")
public void login(@PathVariable String type, HttpServletResponse response) throws IOException {
AuthRequest authRequest = factory.get(type);
response.sendRedirect(authRequest.authorize(AuthStateUtils.createState()));
}

@RequestMapping("/{type}/callback")
public AuthResponse login(@PathVariable String type, AuthCallback callback) {
AuthRequest authRequest = factory.get(type);
AuthResponse response = authRequest.login(callback);
log.info("【response】= {}", JSONUtil.toJsonStr(response));
return response;
}

}

总结


JustAuth 集成的第三方授权登录平台,可以说是囊括了业界中大部分主流的应用系统。如国内的微信、微博、Gitee 等,还有国外的 Github、Google 等。可以满足我们日常的开发需求,开箱即用,可快速集成!


最后,贴上项目地址:


https://github.com/justauth/JustAuth

在线文档:


https://www.justauth.cn/

最后


推荐的开源项目已经收录到 GitHub 项目,欢迎 Star


https://github.com/chenyl8848/great-open-source-project

或者访问网站,进行在线浏览:


https://chencoding.top:8090/#/

作者:Java陈序员
来源:juejin.cn/post/7312060958175559743
收起阅读 »

token过期了怎么办?

token过期了怎么办?一般做法是重复第一次获取token的过程(比如登录,扫描授权等) ,这样做的缺点是用户体验不好,每一小时强制登录一次几乎是无法忍受的。那应该怎么办呢?其实这是一个老生常谈的问题,但是最近发现很多人并不清楚,所以今天就一次讲清这...
继续阅读 »

token过期了怎么办?一般做法是重复第一次获取token的过程(比如登录,扫描授权等) ,这样做的缺点是用户体验不好,每一小时强制登录一次几乎是无法忍受的。那应该怎么办呢?其实这是一个老生常谈的问题,但是最近发现很多人并不清楚,所以今天就一次讲清这个问题!

token 过期处理

没有绝对的安全, 所谓的安全处理, 就是提高攻击者攻击的难度, 对他造成了一定的麻烦, 我们这个网站就是安全的! 网站安全性就是高的! 所以: token 必须要有过期时间!

token 过期问题

目标: 了解token过期问题的存在, 学习token过期的解决思路

现象:

你登陆成功之后,接口会返回一个token值,这个值在后续请求时带上(就像是开门钥匙)。

但是,这个值一般会有有效期(具体是多长,是由后端决定),在我们的项目中,这个有效期是2小时。

如果,上午8点登陆成功,到了10:01分,则token就会失效,再去发请求时,就会报401错误。

思考:

  1. token需要过期时间吗 ?

    token即是获取受保护资源的凭证,当然必须有过期时间。否则一次登录便可永久使用,认证功能就失去了其意义。非但必须有个过期时间,而且过期时间还不能太长,

    参考各个主流网站的token过期时间,一般1小时左右

    token一旦过期, 一定要处理, 不处理, 用户没法进行一些需要授权页面的使用了

  2. token过期该怎么办?

    token过期,就要重新获取。

    那么重新获取有两种方式,一是重复第一次获取token的过程(比如登录,扫描授权等) ,这样做的缺点是用户体验不好,每一小时强制登录一次几乎是无法忍受的。

    那么还剩第二种方法,那就是主动去刷新token. 主动刷新token的凭证是refresh token,也是加密字符串,并且和token是相关联的。相比可以获取各种资源的token,refresh token的作用仅仅是获取新的token,因此其作用和安全性要求都大为降低,所以其过期时间也可以设置得长一些。

目标效果 - 保证每一小时, 都是一个不同的token

第一次请求 9:00 用的是 token1  第二次请求 12:00 用的是 token2

当用户登陆成功之后,返回的token中有两个值,说明如下:

image.png

  • token:

    • 作用:在访问一些接口时,需要传入token,就是它。
    • 有效期:2小时。
  • refresh_token

    • 作用: 当token的有效期过了之后,可以使用它去请求一个特殊接口(这个接口也是后端指定的,明确需要传入refresh_token),并返回一个新的token回来(有效期还是2小时),以替换过期的那个token。
    • 有效期:14天。(最理想的情况下,一次登陆可以持续14天。)

image.png

对于 某次请求A 的响应,如果是401错误

  • 有refresh_token,用refresh_token去请求回新的token

    • 新token请求成功

      • 更新本地token
      • 再发一次请求A
    • 新token请求失败

      • 清空vuex中的token
      • 携带请求地址,跳转到登陆页
  • 没有refresh_token

    • 清空vuex中的token
    • 携带请求地址,跳转到登陆页

对于一个请求的响应 401, 要这么处理, 对于十个请求的响应 401, 也要这么处理,

我们可以统一将这个token过期处理放在响应拦截器中

请求拦截器: 所有的请求, 在真正被发送出去之前, 都会先经过请求拦截器 (可以携带token)

响应拦截器: 所有的响应, 在真正被(.then.catch await)处理之前, 都会先经过响应拦截器, 可以在这个响应拦截器中统一对响应做判断

响应拦截器处理token

目标: 通过 axios 响应拦截器来处理 token 过期的问题

响应拦截器: http://www.kancloud.cn/yunye/axios…

  1. 没有 refresh_token 拦截到登录页, 清除无效的token

测试: {"token":"123.123.123"}

// 添加响应拦截器
http.interceptors.response.use(function (response) {
// 对响应数据做点什么 (成功响应) response 就是成功的响应 res
return response
}, function (error) {
// 对响应错误做点什么 (失败响应) 处理401错误
// console.dir(error)
if (error.response.status === 401) {
console.log('token过期了, 一小时过去了, 需要通过refresh_token去刷新token')
// 获取 refresh_token, 判断是否存在, 存在就去刷新token
const refreshToken = store.state.tokenInfo.refresh_token
if (refreshToken) {
console.log('存在refreshToken, 需要进行刷新token操作')
} else {
// 没有refreshToken, 直接去登录, 将来还能跳回来
// router.currentRoute 指向当前路由信息对象 === 等价于之前页面中用的 this.$route
// 清除本地token, 跳转登录 (无意义的本地token内容, 要清除)
store.commit('removeToken')
router.push({
path: '/login',
query: {
backto: router.currentRoute.fullPath
}
})
}
}
return Promise.reject(error)
})

提供清除token的mutation

// 移出tokenInfo的信息, 恢复成空对象
removeToken (state) {
state.tokenInfo = {}
// 更新到本地, 本地可以清掉token信息
removeToken()
},
  1. 有 refresh_token 发送请求, 刷新token

测试操作: 将 token 修改成 xyz, 模拟 token 过期, 而有 refresh_token 发现401, 会自动帮你刷新token

{"refresh_token":"eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJleHAiOjE2MDYzNTcyODcsInVzZXJfaWQiOjExMDI0OTA1MjI4Mjk3MTc1MDQsInJlZnJlc2giOnRydWV9.2A81gpjxP_wWOjclv0fzSh1wzNm6lNy0iXM5G5l7TQ4","token":"xyz"}

const refreshToken = store.state.tokenInfo.refresh_token
if (refreshToken) {
console.log('存在refreshToken, 需要进行刷新token操作')
// (1) 发送请求, 进行刷新token操作, 获取新的token
// 注意: 这边发请求, 不用http实例, 用它会自动在请求前帮你携带token(会覆盖你的refresh_token)
// 这边, 直接用 axios 发送请求
const res = await axios({
method: 'put',
url: 'http://ttapi.research.itcast.cn/app/v1_0/authorizations',
// 请求头中携带refresh_token信息
headers: {
Authorization: `Bearer ${refreshToken}`
}
})
const newToken = res.data.data.token
// (2) 将新token更新到vuex中
store.commit('setTokenInfo', {
refresh_token: refreshToken,
token: newToken
})
}
  1. 刷新token后, 应该重新发送刚才的请求 (让用户刷新token无感知)
return http(error.config)
  1. 那万一 refresh_token 也过期了, 是真正的用户登录过期了 (一定要让用户重新登录的)

测试: {"refresh_token":"123.123","token":"123.123.123"} 修改后, 修改的是本地, 记得刷新一下

从哪拦走的, 就回到哪去

// 添加响应拦截器
http.interceptors.response.use(function (response) {
// 对响应数据做点什么 (成功响应) response 就是成功的响应 res
return response
}, async function (error) {
// 对响应错误做点什么 (失败响应) 处理401错误
// console.dir(error)
if (error.response.status === 401) {
console.log('token过期了, 一小时过去了, 需要通过refresh_token去刷新token')
// 获取 refresh_token, 判断是否存在, 存在就去刷新token
const refreshToken = store.state.tokenInfo.refresh_token
if (refreshToken) {
try {
console.log('存在refreshToken, 需要进行刷新token操作')
// (1) 发送请求, 进行刷新token操作, 获取新的token
// 注意: 这边发请求, 不用http实例, 用它会自动在请求前帮你携带token(会覆盖你的refresh_token)
// 这边, 直接用 axios 发送请求
const res = await axios({
method: 'put',
url: 'http://ttapi.research.itcast.cn/app/v1_0/authorizations',
// 请求头中携带refresh_token信息
headers: {
Authorization: `Bearer ${refreshToken}`
}
})
const newToken = res.data.data.token
// (2) 将新token更新到vuex中
store.commit('setTokenInfo', {
refresh_token: refreshToken,
token: newToken
})
// (3) 重新发送刚才的请求, http, 自动携带token (携带的是新token)
// error.config就是之前用于请求的配置对象, 可以直接给http使用
return http(error.config)
} catch {
// refresh_token 过期了, 跳转到登录页
// 清除过期的token对象
store.commit('removeToken')
// 跳转到登录页, 跳转完, 将来跳回来
router.push({
path: '/login',
query: {
backto: router.currentRoute.fullPath
}
})
}
} else {
// 没有refreshToken, 直接去登录, 将来还能跳回来
// router.currentRoute 指向当前路由信息对象 === 等价于之前页面中用的 this.$route
// 清除本地token, 跳转登录 (无意义的本地token内容, 要清除)
store.commit('removeToken')
router.push({
path: '/login',
query: {
backto: router.currentRoute.fullPath
}
})
}
}
return Promise.reject(error)
})

注意点:

  1. 响应拦截器要加在axios实例 http 上。
  2. 用refresh_token请求新token时,要用axios,不要用实例 http (需要: 手动用 refresh_token 请求)
  3. 得到新token之后,再发请求时,要用 http 实例 (用token请求)
  4. 过期的 token 可以用 refresh_token 再次更新获取新token, 但是过期的 refresh_token 就应该从清除了


作者:JoyZ
来源:juejin.cn/post/7308992811449172005
收起阅读 »

工作6年了日期时间格式化还在写YYYY疯狂给队友埋雷

前言 哈喽小伙伴们好久不见,今天来个有意思的雷,看你有没有埋过。 正文 不多说废话,公司最近来了个外地回来的小伙伴,在广州工作过6年,也是一名挺有经验的开发。 他提交的代码被小组长发现有问题,给打回了,原因是里面日期格式化的用法有问题,用的Simpl...
继续阅读 »

前言



哈喽小伙伴们好久不见,今天来个有意思的雷,看你有没有埋过。



正文



不多说废话,公司最近来了个外地回来的小伙伴,在广州工作过6年,也是一名挺有经验的开发。




他提交的代码被小组长发现有问题,给打回了,原因是里面日期格式化的用法有问题,用的SimpleDateFormat,但不知道是手误还是什么原因,格式用了YYYY-MM-dd。




这种写法埋了一个不大不小的雷。




用一段测试代码就可以展示出来问题



1.jpg



打印结果如下:



2.jpg



很明显,使用YYYY时,2023年变成了2024年,在正常情况下可能没问题,但是在跨年的时候大概率就会有问题了。




原因比较简单,与小写的yyyy不同,大写的YYYY表示一个基于周的年份。它是根据周计算的年份,而不是基于日历的年份。通常情况下,两者的结果是相同的,但在跨年的第一周或最后一周可能会有差异。




比如我如果换成2023-12-30又不会有问题了



3.jpg



另外,Hutool工具类本身是对Java一些工具的封装,DateUtil里面也有用到SimpleDateFormat,因此也会存在类似的问题。



4.jpg



避免这个问题的方法也十分简单,要有公用的格式类,所有使用日期格式的地方都引用这个类,这个类中就定义好yyyy-MM-dd想给的格式即可,这样就不会出现有人手误给大家埋雷了。



总结




  1. 日期时间格式统一使用yyyy小写;

  2. 日期格式要规定大家都引用定义好的工具类,避免有人手误打错。




最后再回头想一想,这种小问题并不会马上暴露出来,倘若没有被发现,到了明年元旦,刚好跨年的时候,是不是就要坑死一堆人了。



作者:程序员济癫
来源:juejin.cn/post/7269013062677823528
收起阅读 »

拼多多算法题,是清华考研真题!

写在前面 在 LeetCode 上有一道"备受争议"的题目。 该题长期作为 拼多多题库中的打榜题 : 据同学们反映,该题还是 清华大学 和 南京大学 考研专业课中的算法题。 其中南京大学的出题人,还真贴心地针对不同解法,划分不同分值: 细翻评论区。 不...
继续阅读 »

写在前面


在 LeetCode 上有一道"备受争议"的题目。


该题长期作为 拼多多题库中的打榜题


出现频率拉满


据同学们反映,该题还是 清华大学南京大学 考研专业课中的算法题。



其中南京大学的出题人,还真贴心地针对不同解法,划分不同分值:




细翻评论区。


不仅是拼多多,该题还在诸如 神州信息滴滴出行 这样的互联网大厂笔试中出现过:





但,这都不是这道题"备受争议"的原因。


这道题最魔幻的地方是:常见解法可做到 O(n)O(n) 时间,O(1)O(1) 空间,而进阶做法最快也只能做到 O(n)O(n) 时间,O(logn)O(\log{n}) 空间


称作"反向进阶"也不为过。


接下来,我将从常规解法的两种理解入手,逐步进阶到考研/笔面中分值更高的进阶做法,帮助大家在这题上做到尽善尽美。


毕竟在一道算法题上做到极致,比背一段大家都会"八股文",在笔面中更显价值。


题目描述


平台:LeetCode


题号:LCR 161 或 53


给你一个整数数组 nums,请你找出一个具有最大和的连续子数组(子数组最少包含一个元素),返回其最大和。


子数组是数组中的一个连续部分。


示例 1:


输入:nums = [-2,1,-3,4,-1,2,1,-5,4]

输出:6

解释:连续子数组 [4,-1,2,1] 的和最大,为 6

示例 2:


输入:nums = [1]

输出:1

示例 3:


输入:nums = [5,4,-1,7,8]

输出:23

提示:



  • 1<=nums.length<=1051 <= nums.length <= 10^5

  • 104<=nums[i]<=104-10^4 <= nums[i] <= 10^4


进阶:如果你已经实现复杂度为 O(n)O(n) 的解法,尝试使用更为精妙的分治法求解。


前缀和 or 线性 DP


当要我们求「连续段」区域和的时候,要很自然的想到「前缀和」。


所谓前缀和,是指对原数组“累计和”的描述,通常是指一个与原数组等长的数组。


设前缀和数组为 sumsum 的每一位记录的是从「起始位置」到「当前位置」的元素和。


例如 sum[x]sum[x] 是指原数组中“起始位置”到“位置 x”这一连续段的元素和。


有了前缀和数组 sum,当我们求连续段 [i,j][i, j] 的区域和时,利用「容斥原理」,便可进行快速求解。


通用公式:ans = sum[j] - sum[i - 1]



由于涉及 -1 操作,为减少边界处理,我们可让前缀和数组下标从 11 开始。在进行快速求和时,再根据原数组下标是否从 11 开始,决定是否进行相应的下标偏移。


学习完一维前缀和后,回到本题。


先用 nums 预处理出前缀和数组 sum,然后在遍历子数组右端点 j 的过程中,通过变量 m 动态记录已访问的左端点 i 的前缀和最小值。最终,在所有 sum[j] - m 的取值中选取最大值作为答案。


代码实现上,我们无需明确计算前缀和数组 sum,而是使用变量 s 表示当前累计的前缀和(充当右端点),并利用变量 m 记录已访问的前缀和的最小值(充当左端点)即可。


本题除了将其看作为「前缀和裸题用有限变量进行空间优化」以外,还能以「线性 DP」角度进行理解。


定义 f[i]f[i] 为考虑前 ii 个元素,且第 nums[i]nums[i] 必选的情况下,形成子数组的最大和。


不难发现,仅考虑前 ii 个元素,且 nums[i]nums[i] 必然参与的子数组中。要么是 nums[i]nums[i] 自己一个成为子数组,要么与前面的元素共同组成子数组。


因此,状态转移方程:


f[i]=max(f[i1]+nums[i],nums[i])f[i] = \max(f[i - 1] + nums[i], nums[i])

由于 f[i]f[i] 仅依赖于 f[i1]f[i - 1] 进行转移,可使用有限变量进行优化,因此写出来的代码也是和上述前缀和角度分析的类似。


Java 代码:


class Solution {
public int maxSubArray(int[] nums) {
int s = 0, m = 0, ans = -10010;
for (int x : nums) {
s += x;
ans = Math.max(ans, s - m);
m = Math.min(m, s);
}
return ans;
}
}

C++ 代码:


class Solution {
public:
int maxSubArray(vector<int>& nums) {
int s = 0, m = 0, ans = -10010;
for (int x : nums) {
s += x;
ans = max(ans, s - m);
m = min(m, s);
}
return ans;
}
};

Python 代码:


class Solution:
def maxSubArray(self, nums: List[int]) -> int:
s, m, ans = 0, 0, -10010
for x in nums:
s += x
ans = max(ans, s - m)
m = min(m, s)
return ans

TypeScript 代码:


function maxSubArray(nums: number[]): number {
let s = 0, m = 0, ans = -10010;
for (let x of nums) {
s += x;
ans = Math.max(ans, s - m);
m = Math.min(m, s);
}
return ans;
};


  • 时间复杂度:O(n)O(n)

  • 空间复杂度:O(1)O(1)


分治


“分治法”的核心思路是将大问题拆分成更小且相似的子问题,通过递归解决这些子问题,最终合并子问题的解来得到原问题的解。


实现分治,关键在于对“递归函数”的设计(入参 & 返回值)。


在涉及数组的分治题中,左右下标 lr 必然会作为函数入参,因为它能用于表示当前所处理的区间,即小问题的范围。


对于本题,仅将最大子数组和(答案)作为返回值并不足够,因为单纯从小区间的解无法直接推导出大区间的解,我们需要一些额外信息来辅助求解。


具体的,我们可以将返回值设计成四元组,分别代表 区间和前缀最大值后缀最大值最大子数组和,用 [sum, lm, rm, max] 表示。


有了完整的函数签名 int[] dfs(int[] nums, int l, int r),考虑如何实现分治:



  1. 根据当前区间 [l,r][l, r] 的长度进行分情况讨论:

    1. l=rl = r,只有一个元素,区间和为 nums[l]nums[l],而 最大子数组和、前缀最大值 和 后缀最大值 由于允许“空数组”,因此均为 max(nums[l],0)\max(nums[l], 0)

    2. 否则,将当前问题划分为两个子问题,通常会划分为两个相同大小的子问题,划分为 [l,mid][l, mid][mid+1,r][mid + 1, r] 两份,递归求解,其中 mid=l+r2mid = \left \lfloor \frac{l + r}2{} \right \rfloor




随后考虑如何用“子问题”的解合并成“原问题”的解:



  1. 合并区间和 (sum): 当前问题的区间和等于左右两个子问题的区间和之和,即 sum = left[0] + right[0]

  2. 合并前缀最大值 (lm): 当前问题的前缀最大值可以是左子问题的前缀最大值,或者左子问题的区间和加上右子问题的前缀最大值。即 lm = max(left[1], left[0] + right[1])

  3. 合并后缀最大值 (rm): 当前问题的后缀最大值可以是右子问题的后缀最大值,或者右子问题的区间和加上左子问题的后缀最大值。即 rm = max(right[2], right[0] + left[2])

  4. 合并最大子数组和 (max): 当前问题的最大子数组和可能出现在左子问题、右子问题,或者跨越左右两个子问题的边界。因此,max 可以通过 max(left[3], right[3], left[2] + right[1]) 来得到。


一些细节:由于我们在计算 lmrmmax 的时候允许数组为空,而答案对子数组的要求是至少包含一个元素。因此对于 nums 全为负数的情况,我们会错误得出最大子数组和为 0 的答案。针对该情况,需特殊处理,遍历一遍 nums,若最大值为负数,直接返回最大值。


Java 代码:


class Solution {
// 返回值: [sum, lm, rm, max] = [区间和, 前缀最大值, 后缀最大值, 最大子数组和]
int[] dfs(int[] nums, int l, int r) {
if (l == r) {
int t = Math.max(nums[l], 0);
return new int[]{nums[l], t, t, t};
}
// 划分成两个子区间,分别求解
int mid = l + r >> 1;
int[] left = dfs(nums, l, mid), right = dfs(nums, mid + 1, r);
// 组合左右子区间的信息,得到当前区间的信息
int[] ans = new int[4];
ans[0] = left[0] + right[0]; // 当前区间和
ans[1] = Math.max(left[1], left[0] + right[1]); // 当前区间前缀最大值
ans[2] = Math.max(right[2], right[0] + left[2]); // 当前区间后缀最大值
ans[3] = Math.max(Math.max(left[3], right[3]), left[2] + right[1]); // 最大子数组和
return ans;
}
public int maxSubArray(int[] nums) {
int m = nums[0];
for (int x : nums) m = Math.max(m, x);
if (m <= 0) return m;
return dfs(nums, 0, nums.length - 1)[3];
}
}

C++ 代码:


class Solution {
public:
// 返回值: [sum, lm, rm, max] = [区间和, 前缀最大值, 后缀最大值, 最大子数组和]
vector<int> dfs(vector<int>& nums, int l, int r) {
if (l == r) {
int t = max(nums[l], 0);
return {nums[l], t, t, t};
}
// 划分成两个子区间,分别求解
int mid = l + r >> 1;
auto left = dfs(nums, l, mid), right = dfs(nums, mid + 1, r);
// 组合左右子区间的信息,得到当前区间的信息
vector<int> ans(4);
ans[0] = left[0] + right[0]; // 当前区间和
ans[1] = max(left[1], left[0] + right[1]); // 当前区间前缀最大值
ans[2] = max(right[2], right[0] + left[2]); // 当前区间后缀最大值
ans[3] = max({left[3], right[3], left[2] + right[1]}); // 最大子数组和
return ans;
}
int maxSubArray(vector<int>& nums) {
int m = nums[0];
for (int x : nums) m = max(m, x);
if (m <= 0) return m;
return dfs(nums, 0, nums.size() - 1)[3];
}
};

Python 代码:


class Solution:
def maxSubArray(self, nums: List[int]) -> int:
def dfs(l, r):
if l == r:
t = max(nums[l], 0)
return [nums[l], t, t, t]
# 划分成两个子区间,分别求解
mid = (l + r) // 2
left, right = dfs(l, mid), dfs(mid + 1, r)
# 组合左右子区间的信息,得到当前区间的信息
ans = [0] * 4
ans[0] = left[0] + right[0] # 当前区间和
ans[1] = max(left[1], left[0] + right[1]) # 当前区间前缀最大值
ans[2] = max(right[2], right[0] + left[2]) # 当前区间后缀最大值
ans[3] = max(left[3], right[3], left[2] + right[1]) # 最大子数组和
return ans

m = max(nums)
if m <= 0:
return m
return dfs(0, len(nums) - 1)[3]

TypeScript 代码:


function maxSubArray(nums: number[]): number {
const dfs = function (l: number, r: number): number[] {
if (l == r) {
const t = Math.max(nums[l], 0);
return [nums[l], t, t, t];
}
// 划分成两个子区间,分别求解
const mid = (l + r) >> 1;
const left = dfs(l, mid), right = dfs(mid + 1, r);
// 组合左右子区间的信息,得到当前区间的信息
const ans = Array(4).fill(0);
ans[0] = left[0] + right[0]; // 当前区间和
ans[1] = Math.max(left[1], left[0] + right[1]); // 当前区间前缀最大值
ans[2] = Math.max(right[2], right[0] + left[2]); // 当前区间后缀最大值
ans[3] = Math.max(left[3], right[3], left[2] + right[1]); // 最大子数组和
return ans;
}

const m = Math.max(...nums);
if (m <= 0) return m;
return dfs(0, nums.length - 1)[3];
};


  • 时间复杂度:O(n)O(n)

  • 空间复杂度:递归需要函数栈空间,算法每次将当前数组一分为二,进行递归处理,递归层数为 logn\log{n},即函数栈最多有 logn\log{n} 个函数栈帧,复杂度为 O(logn)O(\log{n})


总结


虽然,这道题的进阶做法相比常规做法,在时空复杂度上没有优势。


但进阶做法的分治法更具有 进一步拓展 的价值,容易展开为支持「区间修改,区间查询」的高级数据结构 - 线段树。


实际上,上述的进阶「分治法」就是线段树的"建树"过程。


这也是为什么「分治法」在名校考研课中分值更大,在大厂笔面中属于必选解法的原因,希望大家重点掌握。


作者:宫水三叶的刷题日记
来源:juejin.cn/post/7310104657211293723
收起阅读 »

前端学一点Docker,不信你学不会

虽然前端很少跟docker打交道,但随着工作流程的自动化现代化,docker正变得越来越重要。无论你是希望扩展技能到全栈领域,还是想要炫技,掌握Docker基本知识都是前端小伙伴重要的一步。 什么是Docker Docker 是一个开源的应用容器引擎,可以让...
继续阅读 »

虽然前端很少跟docker打交道,但随着工作流程的自动化现代化,docker正变得越来越重要。无论你是希望扩展技能到全栈领域,还是想要炫技,掌握Docker基本知识都是前端小伙伴重要的一步。


什么是Docker



Docker 是一个开源的应用容器引擎,可以让开发者打包他们的应用以及依赖包到一个轻量级、可移植的容器中,然后发布到任何流行的 Linux 机器上,也可以实现虚拟化。



我们知道,软件的安装是要区分系统环境的;即使是运行环境相同,依赖的版本一旦有所出入,也容易出现“我这里明明好使,你那里为啥不行“的问题。容器技术解决了环境一致性依赖管理的问题。


因此,容器是我们的项目的运行环境,而docker是容器的运行环境与管理平台。


关键词


镜像 (Image)


镜像是构建容器的模板,可以简单理解为类似Js中的class类或构造函数。


镜像中详细记录了应用所需的运行环境、应用代码、如何操作系统文件等等信息;


容器 (Container)


容器是镜像的运行实例。可以简单理解为”new 镜像()“的实例,通过docker命令可以任意创建容器。


当前操作系统(宿主机)与容器之间的关系,可以参照浏览器页面与iframe之间的关系。容器可以拥有独立的IP地址,网络,文件系统以及指令等;容器与容器、容器与”宿主机“之间以隔离的方式运行,每个容器中通常运行着一个(或多个)应用。


仓库 (Registry)


仓库是集中管理镜像的地方。类似于npm平台与npm包之间的关系。


如果我们将搭建项目环境的详细过程以及具体的依赖记录进镜像中,每当需要部署新服务时,就可以很容易的通过镜像,创建出一个个完整的项目运行环境,完成部署。


示例——安装启动Mysql


1. 安装Docker


具体过程可参考菜鸟教程,下面以macOS系统作为例子进行演示。


启动docker客户端如下:



打开系统终端(下面是在vscode的终端中演示),输入命令:


docker -v

效果如下:



说明docker已经安装并启动。


2. 下载Mysql镜像


下载镜像有点类似于安装npm包:npm install <包名>,这里输入docker镜像的安装命令:docker pull mysql来下载安装mysql的镜像:



安装结束后,输入镜像列表的查看命令:docker images



当然,通过docker的客户端App也可以看到:



3. 创建mysql镜像的容器,启动Mysql


输入启动容器命令:


docker run -d -p 3308:3306 -e MYSQL_ROOT_PASSWORD=123456 mysql

先来看下启动结果,下面的一堆数字是完整的容器id:



输入的这一串命令是什么意思?



  • docker run: 这是启动新容器的命令。

  • -d--detach 是使mysql服务在后台运行,而不是占用当前终端界面。

  • -p 3308:3306: 这是端口映射参数:

    • 创建容器会默认创建一个子网,与宿主机所处的网络互相隔离;mysql服务默认端口3306,如果要通过宿主机所在网络访问容器所处的子网络中的服务,就需要进行端口映射(不熟悉网络的可以看下《如何跟小白解释网络》)。

    • 宿主机的端口在前(左边),容器的端口在后(右边)。



  • -e MYSQL_ROOT_PASSWORD=123456: 设置环境变量 MYSQL_ROOT_PASSWORD=123456;也就是将mysql服务的root用户的密码为123456

  • mysql: 这是上面刚刚pull的镜像的名称。


通过上面的命令,我们启动了一个mysql镜像的容器,并将主机的3308端口映射到了容器所在子网中ip的3306端口,这样我们就可以通过访问主机的localhost:3308来访问容器中的mysql服务了。


4.访问Mysql服务


下面写一段nodeJs代码:


// mysql.js
const mysql = require('mysql');
const connection = mysql.createConnection({
host: 'localhost',
port: '3308',
user: 'root',
password: '123456',
database: '',
});
connection.connect();
// 显示全部数据库
connection.query('show databases;', function (err, rows, fields) {
if (err) {
console.log('[SELECT ERROR] - ', err.message);
return;
}
console.log('--------------------------SELECT----------------------------');
console.log(rows);
console.log('------------------------------------------------------------');
});

这里调用了nodeJs的mysql包,访问localhost:3308,用户名为root,密码为123456,运行结果下:


$ node mysql.js;
[SELECT ERROR] - ER_NOT_SUPPORTED_AUTH_MODE: Client does not support authentication
protocol requested by server; consider upgrading MySQL client

这里报错了,原因是mysql服务的认证协议与我们代码中的不同导致的。这里我们需要对mysql服务进行一些修改。


为此,我们需要进入mysql容器,对mysql进行一些直接的命令行操作。


5.进入容器


首先,我们需要知道容器的id,输入容器查看命令:docker ps,展示容器列表如下:



其中55cbcc600353就是我们需要的容器的短id,然后执行命令:docker exec -it 55cbcc600353 bash,以下是命令的解析:



  • docker exec:用于向运行中的容器发布命令。

  • -it:分配一个终端(伪终端),允许用shell命令进行交互。也就是将容器中的终端界面映射到宿主机终端界面下,从而对容器进行直接的命令行操作。

  • 55cbcc600353:容器ID或容器名称。

  • bash:这是要在容器内执行的命令。这里是启动了容器的Bash shell程序。


运行结果如下:



我们看到bash-4.4#  后闪烁的光标。这就是容器的bash shell命令提示符,这里输入的shell命令将会在容器环境中执行。


我们输入mysql登录命令,以root用户身份登录:mysql -uroot -p123456



成功登录mysql后,在mysql>命令提示符下输入:ALTER USER 'root'@'%' IDENTIFIED WITH mysql_native_password By '123456';


这条命令用来修改’root’用户的认证方式为mysql_native_password ,将密码设置为123456,并允许来自任何主机(‘%’)的连接。


输入exit;命令退出mysql>命令提示符:



再按下:ctl+D退出容器终端,回到宿主机系统终端下。再次运行上面的js代码,效果如下:



这样我们就完成了本地mysql服务的部署。


结束


通过上面的简介以及安装部署mysql服务的例子,相信不了解docker的前端小伙伴已经有了一些概念;感兴趣的小伙伴可以继续深入,学习相关的知识。


作者:硬毛巾
来源:juejin.cn/post/7304538094782808105
收起阅读 »

为啥IoT(物联网)选择了MQTT协议?

物联网设备要实现互相通信,须一套标准通信协议,MQTT(Message Queuing Telemetry Transport)专为物联网设备设计的一套标准消息队列通信协议。使用MQTT协议的IoT设备,可以连接到任何支持MQTT协议的消息队列上,进行通信。 ...
继续阅读 »

物联网设备要实现互相通信,须一套标准通信协议,MQTT(Message Queuing Telemetry Transport)专为物联网设备设计的一套标准消息队列通信协议。使用MQTT协议的IoT设备,可以连接到任何支持MQTT协议的消息队列上,进行通信。



  • 宏观,MQTT和其他MQ传输协议差不多。也是“发布-订阅”消息模型

  • 网络结构,也是C/S架构,IoT设备是客户端,Broker是服务端,客户端与Broker通信进行收发消息


但毕竟使用场景不同,所以,MQTT和普通MQ比,还有很多区别。


1 客户端都运行在IoT设备


1.1 IoT设备特点


① 便宜


最大特点,一个水杯才几十块钱,它上面智能模块成本十块钱最多,再贵就卖不出去。十块钱的智能设备内存都是按KB计算,可能都没有CPU,也不一定有os,整个设备就一个SoC(System on a Chip)。这样的设备就需要通信协议不能复杂,功能不能太多。


② 无线连接


IoT设备一般采用无线连接,很多设备经常移动,导致IoT设备网络连接不稳定,且是常态。


MQTT协议设计上充分考虑这些特点。协议的报文设计极简,惜字如金。协议功能也非常简单,基本就只有:



  • 发布订阅主题

  • 收发消息


这两个最核心功能。为应对网络连接不稳定问题,MQTT增加机制:



  • 心跳机制,可让客户端和服务端双方都能随时掌握当前连接状态,一旦发现连接中断,可尽快重连

  • 会话机制,在服务端来保存会话状态,客户端重连后就可恢复之前会话,继续收发消息。这样,把复杂度转移到服务端,客户端实现更简单


2 服务端高要求


MQTT面临的使用场景中,服务端需支撑海量IoT设备同时在线。


普通的消息队列集群,服务的客户端都运行在性能强大的服务器,所以客户端数量不会特别多。如京东的JMQ集群,日常在线客户端数量大概十万左右,就足够支撑全国人民在京东买买买。


而MQTT使用场景中,需支撑的客户端数量,远不止几万几十万。如北京交通委若要把全市车辆都接入进来,就是个几百万客户端的规模。路侧的摄像头,每家每户的电视、冰箱,每个人随身携带的各种穿戴设备,这些设备规模都是百万、千万级甚至上亿级。


3 不支持点对点通信


MQTT协议的设计目标是支持发布-订阅(Publish-Subscribe)模型,而不是点对点通信。


MQTT的主要特点之一是支持发布者(Publisher)将消息发布到一个主题(Topic),而订阅者(Subscriber)则可以通过订阅相关主题来接收这些消息。这种模型在大规模的分布式系统中具有很好的可扩展性和灵活性。因此,MQTT更适合用于多对多、多对一的通信场景,例如物联网(IoT)应用、消息中间件等。


虽然MQTT的设计目标不是点对点通信,但在实际使用中,你仍然可以通过一些设计来模拟点对点通信。例如,使用不同的主题来模拟点对点通信,或者在应用层进行一些额外的协议和逻辑以实现点对点通信的效果。


一般做法都是,每个客户端都创建一个以自己ID为名字的主题,然后客户端来订阅自己的专属主题,用于接收专门发给这个客户端的消息。即MQTT集群中,主题数量和客户端数量基本是同一量级。


4 MQTT产品选型


如何支持海量在线IoT设备和海量主题,是每个支持MQTT协议的MQ面临最大挑战。也是做MQTT服务端技术选型时,需重点考察技术点。


开源MQTT产品


有些是传统MQ,通过官方或非官方扩展,实现MQTT协议支持。也有一些专门的MQTT Server产品,这些MQTT Server在协议支持层面,大多没问题,性能和稳定性方面也都满足要求。但还没发现能很好支撑海量客户端和主题的开源产品。why?


传统MQ


虽可通过扩展来支持MQTT协议,但整体架构设计之初,并未考虑支撑海量客户端和主题。如RocketMQ元数据保存在NameServer的内存,Kafka是保存在zk,这些存储都不擅长保存大量数据,所以也支撑不了过多客户端和主题。


另外一些开源MQTT Server


很多就没集群功能或集群功能不完善。集群功能做的好的产品,大多都把集群功能放到企业版卖。


所以做MQTT Server技术选型,若你接入IoT设备数量在10w内,可选择开源产品,选型原则和选择普通消息队列一样,优先选择一个流行、熟悉的开源产品即可。


若客户端规模超过10w量级,需支撑这么大规模客户端数量,服务端只有单节点肯定不够,须用集群,并且这集群要支持水平扩容。这时就几乎没开源产品了,此时只能建议选择一些云平台厂商提供的MQTT云服务,价格相对较低,也可选择价格更高商业版MQTT Server。


另外一个选择就是,基于已有开源MQTT Server,通过一些集成和开发,自行构建MQTT集群。


5 构建一个支持海量客户端的MQTT集群


MQTT集群如何支持海量在线的IoT设备?
一般来说,一个MQTT集群它的架构应该是这样的:



从左向右看,首先接入的地址最好是一个域名,这样域名后面可配置多个IP地址做负载均衡,当然这域名不是必需。也可直接连负载均衡器。负载均衡可选F5这种专用的负载均衡硬件,也可Nginx这样软件,只要是四层或支持MQTT协议的七层负载均衡设备,都可。


负载均衡器后面要部署一个Proxy集群


Proxy集群作用



  • 承接海量IoT设备连接

  • 维护与客户端的会话

  • 作为代理,在客户端和Broker之间进行消息转发


在Proxy集群后是Broker集群,负责保存和收发消息。


有的MQTT Server集群架构:



架构中没Proxy。实际上,只是把Proxy和Broker功能集成到一个进程,这两种架构本质没有太大区别。可认为就是同一种架构来分析。


前置Proxy,易解决海量连接问题,由于Proxy可水平扩展,只要用足够多的Proxy节点,就可抗海量客户端同时连接。每个Proxy和每个Broker只用一个连接通信即可,这对每个Broker来说,其连接数量最多不会超过Proxy节点的数量。


Proxy对于会话的处理,可借鉴Tomcat处理会话的两种方式:



  • 将会话保存在Proxy本地,每个Proxy节点都只维护连接到自己的这些客户端的会话。但这要配合负载均衡来使用,负载均衡设备需支持sticky session,保证将相同会话的连接总是转发到同一Proxy节点

  • 将会话保存在一个外置存储集群,如Redis集群或MySQL集群。这样Proxy就可设计成完全无状态,对负载均衡设备也没特殊要求。但这要求外置存储集群具备存储千万级数据能力,同时具有很好性能


如何支持海量主题?


较可行的解决方案,在Proxy集群的后端,部署多组Broker小集群,如可以是多组Kafka小集群,每个小集群只负责存储一部分主题。这样对每个Broker小集群,主题数量就可控制在可接受范围内。由于消息是通过Proxy进行转发,可在Proxy中采用一些像一致性哈希等分片算法,根据主题名称找到对应Broker小集群。这就解决支持海量主题的问题。


UML


Proxy的UML图:


@startuml
package "MQTT Proxy Cluster" {
class MQTTProxy {
+handleIncomingMessage()
+handleOutgoingMessage()
+produceMessage()
+consumeMessage()
}

class Client {
+sendMessage()
+receiveMessage()
}

class Broker {
+publish()
+subscribe()
}

Client --> MQTTProxy
MQTTProxy --> Broker
}
@enduml

@startuml
actor Client
entity MQTTProxy
entity Broker

Client -> MQTTProxy : sendMessage()
activate MQTTProxy
MQTTProxy -> Broker : produceMessage()
deactivate MQTTProxy
@enduml

@startuml
entity MQTTProxy
entity Broker
actor Client

Broker -> MQTTProxy : publishMessage()
activate MQTTProxy
MQTTProxy -> Client : consumeMessage()
deactivate MQTTProxy
@enduml


Proxy收发消息的时序图:



Proxy生产消息流程的时序图:



Proxy消费消息流程的时序图:


image-20231208134111361

6 总结


MQTT是专门为物联网设备设计的一套标准的通信协议。这套协议在消息模型和功能上与普通的消息队列协议是差不多的,最大的区别在于应用场景不同。在物联网应用场景中,IoT设备性能差,网络连接不稳定。服务端面临的挑战主要是,需要支撑海量的客户端和主题。


已有的开源的MQTT产品,对于协议的支持都不错,在客户端数量小于十万级别的情况下,可以选择。对于海量客户端的场景,服务端必须使用集群来支撑,可以选择收费的云服务和企业版产品。也可以选择自行来构建MQTT集群。


自行构建集群,最关键技术点,就是通过前置Proxy集群解决海量连接、会话管理和海量主题:



  • 前置Proxy负责在Broker和客户端之间转发消息,通过这种方式,将海量客户端连接收敛为少量的Proxy与Broker之间的连接,解决了海量客户端连接数的问题

  • 维护会话的实现原理,和Tomcat维护HTTP会话一样

  • 海量主题,可在后端部署多组Broker小集群,每个小集群分担一部分主题这样的方式来解决


参考:



作者:JavaEdge在掘金
来源:juejin.cn/post/7310786611805929499
收起阅读 »

全网显示IP归属地,免费可用,快来看看

前言 经常浏览小视频或各类帖子的朋友们可能已经注意到,目前许多网络平台都会显示作者和评论区用户的IP归属地。那么,这个功能是如何实现的呢? 某些收费平台的API 我们可以利用一些付费平台的API来实现这一功能,比如一些导航软件的开放平台API等。然而,这些服...
继续阅读 »

前言


经常浏览小视频或各类帖子的朋友们可能已经注意到,目前许多网络平台都会显示作者和评论区用户的IP归属地。那么,这个功能是如何实现的呢?



某些收费平台的API


我们可以利用一些付费平台的API来实现这一功能,比如一些导航软件的开放平台API等。然而,这些服务通常是收费的,而且免费额度有限,适合测试使用,但如果要在生产环境中使用,很可能不够支撑需求。



离线库推荐


那么,有没有免费的离线API库呢?UP现在推荐一个强大的离线库给大家,一个准确率高达99.9%的离线IP地址定位库,查询速度仅需0.0x毫秒,而且数据库仅10兆字节大小。此库提供了Java、PHP、C、Python、Node.js、Golang、C#等多种查询绑定,同时支持Binary、B树和内存三种查询算法。



这个库大家可以在GitHub上搜索:ip2region,即可找到该开源库。


使用


下面使用Java代码给大家演示下如何使用这个IP库,该库目前支持多重主流语言。


1、引入依赖


<dependency>
   <groupId>org.lionsoul</groupId>
   <artifactId>ip2region</artifactId>
   <version>2.7.0</version>
</dependency>

2、下载离线库文件 ip2region.xdb



3、简单使用代码


下面,我们通过Java代码,挑选某个国内的IP进行测试,看看会输出什么样的结果


public class IpTest {

   public static void main(String[] args) throws Exception {
       // 1、创建 searcher 对象 (修改为离线库路径)
       String dbPath = "C:\Users\Administrator\Desktop\ip2region.xdb";
       Searcher searcher = null;
       try {
           searcher = Searcher.newWithFileOnly(dbPath);
      } catch (Exception e) {
           System.out.printf("failed to create searcher with `%s`: %s\n", dbPath, e);
           return;
      }

       // 2、查询
       String ip = "110.242.68.66";
       try {
           long sTime = System.nanoTime(); // Happyjava
           String region = searcher.search(ip);
           long cost = TimeUnit.NANOSECONDS.toMicros((long) (System.nanoTime() - sTime));
           System.out.printf("{region: %s, ioCount: %d, took: %d μs}\n", region, searcher.getIOCount(), cost);
      } catch (Exception e) {
           System.out.printf("failed to search(%s): %s\n", ip, e);
      }

       // 3、关闭资源
       searcher.close();

       // 备注:并发使用,每个线程需要创建一个独立的 searcher 对象单独使用。
  }

}


输出结果为:


{region: 中国|0|河北省|保定市|联通, ioCount: 3, took: 1192 μs}

其中,region的格式为 国家|区域|省份|城市|ISP,缺省的地域信息默认是0。


当然,这个库不只是支持国内的IP,也支持国外的IP。



其他语言可以参考该开源库的说明文档。


总结


这是一个准确率非常高的离线库,如果项目里有IP定位需求的,可以试下该库。


作者:happyjava
来源:juejin.cn/post/7306334713992708122
收起阅读 »

面试官:什么是JWT?为什么要用JWT?

目前传统的后台管理系统,以及不使用第三方登录的系统,使用 JWT 技术的还是挺多的,因此在面试中被问到的频率也比较高,所以今天我们就来看一下:什么是 JWT?为什么要用 JWT? 1.什么是 JWT? JWT(JSON Web Token)是一种开放标准(RF...
继续阅读 »

目前传统的后台管理系统,以及不使用第三方登录的系统,使用 JWT 技术的还是挺多的,因此在面试中被问到的频率也比较高,所以今天我们就来看一下:什么是 JWT?为什么要用 JWT?


1.什么是 JWT?


JWT(JSON Web Token)是一种开放标准(RFC 7519),用于在网络上安全传输信息的简洁、自包含的方式。它通常被用于身份验证和授权机制。
JWT 由三部分组成:头部(Header)、载荷(Payload)和签名(Signature)。



  1. 头部(Header):包含了关于生成该 JWT 的信息以及所使用的算法类型。

  2. 载荷(Payload):包含了要传递的数据,例如身份信息和其他附属数据。JWT 官方规定了 7 个字段,可供使用:

    1. iss (Issuer):签发者。

    2. sub (Subject):主题。

    3. aud (Audience):接收者。

    4. exp (Expiration time):过期时间。

    5. nbf (Not Before):生效时间。

    6. iat (Issued At):签发时间。

    7. jti (JWT ID):编号。



  3. 签名(Signature):使用密钥对头部和载荷进行签名,以验证其完整性。



JWT 官网:jwt.io/



2.为什么要用 JWT?


JWT 相较于传统的基于会话(Session)的认证机制,具有以下优势:



  1. 无需服务器存储状态:传统的基于会话的认证机制需要服务器在会话中存储用户的状态信息,包括用户的登录状态、权限等。而使用 JWT,服务器无需存储任何会话状态信息,所有的认证和授权信息都包含在 JWT 中,使得系统可以更容易地进行水平扩展。

  2. 跨域支持:由于 JWT 包含了完整的认证和授权信息,因此可以轻松地在多个域之间进行传递和使用,实现跨域授权。

  3. 适应微服务架构:在微服务架构中,很多服务是独立部署并且可以横向扩展的,这就需要保证认证和授权的无状态性。使用 JWT 可以满足这种需求,每次请求携带 JWT 即可实现认证和授权。

  4. 自包含:JWT 包含了认证和授权信息,以及其他自定义的声明,这些信息都被编码在 JWT 中,在服务端解码后使用。JWT 的自包含性减少了对服务端资源的依赖,并提供了统一的安全机制。

  5. 扩展性:JWT 可以被扩展和定制,可以按照需求添加自定义的声明和数据,灵活性更高。


总结来说,使用 JWT 相较于传统的基于会话的认证机制,可以减少服务器存储开销和管理复杂性,实现跨域支持和水平扩展,并且更适应无状态和微服务架构。


3.JWT 基本使用


在 Java 开发中,可以借助 JWT 工具类来方便的操作 JWT,例如 HuTool 框架中的 JWTUtil。


HuTool 介绍:doc.hutool.cn/pages/JWTUt…


使用 HuTool 操作 JWT 的步骤如下:



  1. 添加 HuTool 框架依赖

  2. 生成 Token

  3. 验证和解析 Token


3.1 添加 HuTool 框架依赖


在 pom.xml 中添加以下信息:


<dependency>
<groupId>cn.hutoolgroupId>
<artifactId>hutool-allartifactId>
<version>5.8.16version>
dependency>

3.2 生成 Token


Map map = new HashMap() {
private static final long serialVersionUID = 1L;
{
put("uid", Integer.parseInt("123")); // 用户ID
put("expire_time", System.currentTimeMillis() + 1000 * 60 * 60 * 24 * 15); // 过期时间15天
}
};
JWTUtil.createToken(map, "服务器端秘钥".getBytes());

3.3 验证和解析 Token


验证 Token 的示例代码如下:


String token = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VyX25hbWUiOiJhZG1pbiIsInNjb3BlIjpbImFsbCJdLCJleHAiOjE2MjQwMDQ4MjIsInVzZXJJZCI6MSwiYXV0aG9yaXRpZXMiOlsiUk9MRV_op5LoibLkuozlj7ciLCJzeXNfbWVudV8xIiwiUk9MRV_op5LoibLkuIDlj7ciLCJzeXNfbWVudV8yIl0sImp0aSI6ImQ0YzVlYjgwLTA5ZTctNGU0ZC1hZTg3LTVkNGI5M2FhNmFiNiIsImNsaWVudF9pZCI6ImhhbmR5LXNob3AifQ.aixF1eKlAKS_k3ynFnStE7-IRGiD5YaqznvK2xEjBew";
JWTUtil.verify(token, "123456".getBytes());

解析 Token 的示例代码如下:


String rightToken = "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwiYWRtaW4iOnRydWUsIm5hbWUiOiJsb29seSJ9.U2aQkC2THYV9L0fTN-yBBI7gmo5xhmvMhATtu8v0zEA";
final JWT jwt = JWTUtil.parseToken(rightToken);
jwt.getHeader(JWTHeader.TYPE);
jwt.getPayload("sub");

3.4 代码实战


在登录成功之后,生成 Token 的示例代码如下:


// 登录成功,使用 JWT 生成 Token
Map payload = new HashMap() {
private static final long serialVersionUID = 1L;
{
put("uid", userinfo.getUid());
put("manager", userinfo.getManager());
// JWT 过期时间为 15 天
put("exp", System.currentTimeMillis() + 1000 * 60 * 60 * 24 * 15);
}
};
String token = JWTUtil.createToken(payload, AppVariable.JWT_KEY.getBytes());

例如在 Spring Cloud Gateway 网关中验证 Token 的实现代码如下:


import cn.hutool.jwt.JWT;
import cn.hutool.jwt.JWTUtil;
import com.example.common.AppVariable;
import org.springframework.cloud.gateway.filter.GatewayFilterChain;
import org.springframework.cloud.gateway.filter.GlobalFilter;
import org.springframework.core.Ordered;
import org.springframework.http.HttpStatus;
import org.springframework.http.server.reactive.ServerHttpResponse;
import org.springframework.stereotype.Component;
import org.springframework.web.server.ServerWebExchange;
import reactor.core.publisher.Mono;

import java.util.List;

/**
* 登录过滤器(登录判断)
*/

@Component
public class AuthFilter implements GlobalFilter, Ordered {
// 排除登录验证的 URL 地址
private String[] skipAuthUrls = {"/user/add", "/user/login"};

@Override
public Mono filter(ServerWebExchange exchange, GatewayFilterChain chain) {
// 当前请求的 URL
String url = exchange.getRequest().getURI().getPath();
for (String item : skipAuthUrls) {
if (item.equals(url)) {
// 继续往下走
return chain.filter(exchange);
}
}
ServerHttpResponse response = exchange.getResponse();
// 登录判断
List tokens =
exchange.getRequest().getHeaders().get(AppVariable.TOKEN_KEY);
if (tokens == null || tokens.size() == 0) {
// 当前未登录
response.setStatusCode(HttpStatus.UNAUTHORIZED);
return response.setComplete();
}
// token 有值
String token = tokens.get(0);
// JWT 效验 token 是否有效
boolean result = false;
try {
result = JWTUtil.verify(token, AppVariable.JWT_KEY.getBytes());
} catch (Exception e) {
result = false;
}
if (!result) {
// 无效 token
response.setStatusCode(HttpStatus.UNAUTHORIZED);
return response.setComplete();
} else { // 判断 token 是否过期
final JWT jwt = JWTUtil.parseToken(token);
// 得到过期时间
Object expObj = jwt.getPayload("exp");
if (expObj == null) {
response.setStatusCode(HttpStatus.UNAUTHORIZED);
return response.setComplete();
}
long exp = Long.parseLong(expObj.toString());
if (System.currentTimeMillis() > exp) {
// token 过期
response.setStatusCode(HttpStatus.UNAUTHORIZED);
return response.setComplete();
}
}
return chain.filter(exchange);
}

@Override
public int getOrder() {
// 值越小越早执行
return 1;
}
}

4.实现原理分析


JWT 本质是将秘钥存放在服务器端,并通过某种加密手段进行加密和验证的机制。加密签名=某加密算法(header+payload+服务器端私钥),因为服务端私钥别人不能获取,所以 JWT 能保证自身其安全性。


小结


JWT 相比与传统的 Session 会话机制,具备无状态性(无需服务器端存储会话信息),并且它更加灵活、更适合微服务环境下的登录和授权判断。JWT 是由三部分组成的:Header(头部)、Payload(数据载荷)和 Signature(签名)。


作者:Java中文社群
来源:juejin.cn/post/7309310129024008230
收起阅读 »

搞不懂,我的手机没有公网IP,服务器响应报文如何被路由到手机?

6年前,在我刚毕业的时候,我困惑于一个问题:“我的手机和个人电脑等设备只有内网IP,而没有公网IP的情况下,公网服务器的响应IP报文如何被路由到我的内网设备呢?“ 我知道:任何联网的设备都可以主动连接公网IP的服务器,因为公网IP是全球唯一分配的,IP层报文经...
继续阅读 »

6年前,在我刚毕业的时候,我困惑于一个问题:“我的手机和个人电脑等设备只有内网IP,而没有公网IP的情况下,公网服务器的响应IP报文如何被路由到我的内网设备呢?“


我知道:任何联网的设备都可以主动连接公网IP的服务器,因为公网IP是全球唯一分配的,IP层报文经过路由器的层层路由可以到达公网服务器。然而我实在想不通:公网服务器的响应报文该如何回来?


这个问题让我感到很困扰,我上学时学的计算机网络知识已经还给老师了,我尝试询问周围的同事,可惜没有人能给出一个可靠的答案。直到我了解到NAT(网络地址转换)技术,才最终解答了我的疑问。



NAT,即网络地址转换,是一种用于解决IP地址短缺问题的技术。它通过在内部网络和公共网络之间建立一个转换表,将多个内部私有IP地址映射为一个公共IP地址,实现多个设备共享一个公网IP地址的功能。



在科普 NAT 之前,有必要说明一下 内网IP。


0. 内网 IP 不能随意分配!


内网IP的分配是有规范的,不可以随意分配。如果内网IP和公网IP冲突,网络报文无法被路由器正确地路由。因为路由器不知道这个IP报文应该被路由到内网设备,还是路由到上一层网关(直至公网IP)。


为了避免冲突,IP协议中事先规定了三个网段作为内网IP的范围,分别是



  1. A类地址的 10.0.0.0 至 10.255.255.255

  2. B类地址的 172.16.0.0 至 172.31.255.255,

  3. C类地址的 192.168.0.0 至 192.168.255.255。


因此在一个局域网内,以上三个网段的设备都是内网设备,除此外基本(排除 127.0.0.1 等)都是公网设备。这样所有的IP地址都不会冲突!


在家用局域网中,通常使用的是 192.168.xxx.xxx 的格式;而在公司的机房中,由于设备数量庞大,一般会选择以 10.xxx.xxx.xxx 开头的网段。这是因为 192 开头的C类地址能够满足家用局域网设备数量的需求,而 10.xxx.xxx.xxx 网段可以适应大规模的公司机房环境,其中可能存在数以百万计的物理服务器,和数以千万计的虚拟机或者Docker实例。


1. 公网流量如何路由到内网设备


设想一下,内网IP为 192.168.100.100 的用户设备,请求到公网服务器。如果公网服务器收到的IP报文中,显示来源是 192.168.100.100。因为 192 开头的 IP 是内网地址,所以服务器响应用户请求时,就会错误地把请求路由到内网,无法正确路由到用户设备上!


所以…… 正确的显示来源是什么呢?


内网设备想要 “连通” 公网服务器,是需要路由器等网络设备层层转发的,正如下图而言,内网设备需要有公网IP的网络出口才能连通 到另一个公网设备。


image.png
因此,刚才问题答案是,内网IP报文到达公网机器时,来源应该被设置为 相应的 运营商公网出口IP。即公网出口网络设备要把IP来源改为自己。这样公网服务器收到请求报文后,对应的响应IP报文也会回复到用户端的公网出口IP。


看下图,用户端的IP报文到达运营商网络出口时,IP来源被替换为 运营商公网出口IP。下图中网络来源为 192 开头的内网Ip 地址,被替换为公网出口 IP(100.100.100.100)。像这种偷偷更换来源 IP 和目标 IP 的行为在 NAT 技术上很常见,后面会经常看到!


image.png


2. 公网机器发送响应报文时


当公网服务器响应时,IP 目标地址 是运营商公网出口而非用户的内网地址!然后运营商服务器会再次转发,转发前,运营商机器需要知道该转发给谁,转发给哪个用户设备。


如下图所示,用户端(192.168.100.100) 访问 公网地址(200.200.200.200)的IP 来源和目标被替换的过程。


image.png
首先公网机器响应给 运营商公网出口时。


第一步:来源 IP 是(200.200.200.200),目标IP 是(100.100.100.100)。


然后,运营商将 IP 报文转发给用户设备时,来源 IP 还是公网机器的 IP(200.200.200.200)不变化。然而目标 IP 修改为用户设备的 IP(192.168.xx.xx)。


对于用户设备而言,发送请求时目标IP 是公网机器,收到响应时来源 IP 还是公网机器。用户设备丝毫没有感觉到,在中间被路由转发的过程,目标和来源 IP 频繁被路由设备修改!


在这个环节,有一个关键问题:运营商收到公网机器的响应时,它怎么知道该路由给哪个用户设备!


3. NAT 如何进行地址映射


最简单的映射方式是:每一个内网 IP 都映射到一个运营商的公网IP。即内网 Ip 和运营商公网 IP 一对一映射!


这种方式很少见,用户设备和内网设备非常多,这样非常耗费公网IP,一般不会采用。


TCP 和 UDP 协议除基于 IP 地址外,还有端口,如果引入端口参与映射,则大大提高运营商公网 IP 的利用度!


一个 TCP 报文 包括如下参数:



  1. 用户 IP + 用户端口

  2. 公网 IP + 公网端口


这四个参数非常关键,相当于是 TCP 连接的唯一主键。当运营商收到公网机器的响应报文时,它可以拿到四个参数分别为:


运营商公网 IP + 端口 、目标机器公网 IP + 端口。其中关键的参数有三位:运营商公网端口,目标机器公网 IP 和端口。



为什么运营商公网 IP 不关键呢?因为根据 IP 路由协议,响应报文已经被路由到该机器,每一个运营商公网出口都会维护一套单独的 映射表 ,所以自己的 IP 地址不关键。



当前的难点是:如何根据 运营商公网端口,目标机器公网 IP 和 端口 三个参数,映射到 用户 IP 和端口的问题。


用户请求时,会建立映射表。当收到用户请求时,运营商服务器的 NAT 模块会分配一个端口供本次请求使用,建立一个映射项:运营商机器端口 + 目标公网 IP + 目标公网端口 这三个参数映射到 用户 IP 和用户端口


例如下表


NAT 映射表运营商机器端口目标公网 IP目标公网端口用户 IP用户端口
1300200.200.200.20080192.168.22.226000
2
3

在运营商机器转发IP报文时,除替换IP外,也会替换端口。相比NAT 一比一映射IP地址,增加端口映射,可以大大提高运营商公网 IP 的利用度。接下来有个问题?


每个机器的端口最大数为 65535,说明每个运营商机器最多 同时支持转发 65535 个请求?


这个推论不成立。


从上面的映射表可以看到,运营商机器收到响应报文时,会根据 三个关键参数 进行映射,而非只根据 自身端口映射。以上面 NAT 映射表的第一条记录为例,运营商机器的 300 端口,并非仅仅服务于 200.200.200.200 这次请求。300 端口还可以同时服务 250.250.250.250 + 80 端口,以及其他连接!


由于映射的参数有三个,而不仅仅是运营商端口一项,因此并发程度非常高。


理论的最大并发度应该是 65535 * (公网 IP 数)* 65535,这个并发度非常高。 一个运营商机器似乎支持海量的NAT连接,实际上,并非海量。


因为常用的 Http 协议端口是 80,目标公网机器的端口数常用的基本是 80 端口。


其次用户常用的软件非常集中,例如微信、抖音、稀土掘金等,访问的公网 IP 也集中于这些公司的 IP 地址。


所以基于此,最高并发度变为:65535 * 常用的公网 IP数 * 有限的端口数(80、443)。


这个并发度并非海量,但是基本上足够使用了,一个小区或办公区的网络设备数量不会过于庞大。65535 * 有限的公网 IP数 * 有限的端口数,这样的并发度足够支持一般场景使用。


除非出现极端的情况! 即一个小区的大量用户集中访问于一个公网 IP 的 80 端口,这样网络流量一定会发生拥塞!在某些用户流量集中的区域,可以安排更多的 NAT 设备,提供更多的公网 IP。


一个小区一个公网 IP 吗?


根据 chatgpt 的回答,通常情况下,一个小区只有一个公网 IP。
image.png


上大学时,偶然了解到 SQL注入,我感觉很新奇。后来对一个兼职网站进行 SQL 注入的尝试。经过几次尝试后,我发现无法再访问这个网站。宿舍和其他几个宿舍的同学也无法访问此网站。我不禁得意洋洋,难道是因为我的攻击导致了这个网站的崩溃?


后来我找到其他大学的高中同学,让他们访问这个网站,他们访问是没问题的。那时我明白了,这个网站只是封掉了我们学校的公网 IP,或者是这栋男生宿舍楼的公网 IP ,它并没有崩溃!


个人经验来看,一个小区或者办公楼会根据实际的需要安排一定数量的公网 IP,一般情况下共用一个公网 IP。


总结


当 公网 IP 不够用时,可通过 NAT 协议实现多个用户设备共享同一个公网 IP,提高 公网IP 地址的利用度。Ipv4 的地址数量有限,在 2011 年已经被分配完,未来如果全面实现了 ipv6 协议,我们手机等终端设备也可能会有一个公网 IP。


但是公网 Ip 全球都可以访问,与此对应的网络安全问题不可忽视。NAT 技术则可以有效保护用户设备,让用户安全上网,这也是它附带的好处。


如果看完有所收获,感谢点赞支持!


作者:五阳神功
来源:juejin.cn/post/7307892574722637835
收起阅读 »

为什么最近听说 Go 岗位很少很难?

大家好,我是煎鱼。 其实这个话题已经躺在我的 TODO 里很久了,近来很多社区的小伙伴都私下来交流,也有在朋友圈看吐槽 Go 上海的大会没什么人。还不如 Rust 大会,比较尴尬。 今天主要是从个人角度看看为什么 Go 岗位看起来近来很难的样子? 盘一下数据 ...
继续阅读 »

大家好,我是煎鱼。


其实这个话题已经躺在我的 TODO 里很久了,近来很多社区的小伙伴都私下来交流,也有在朋友圈看吐槽 Go 上海的大会没什么人。还不如 Rust 大会,比较尴尬。


今天主要是从个人角度看看为什么 Go 岗位看起来近来很难的样子?


盘一下数据


从以往的大的数据分析来看,Go 岗位最多的是分布以下几个城市:



TOP3 是北京、上海、深圳。


再从常用的招聘软件来看,目前互联网行业用的应该是 XX 直聘。我们从其提供的招聘岗位数量来看。


北京:



上海:



深圳:



从这 5 个月的时间区间来看,北京和上海是在螺旋式下跌;深圳探底创新低。(不过我认为从招聘季节来看,基本都是螺旋式的)


从数值来看,只有北京的 Go 岗位有所上涨。上海、深圳都在持续减少。但整体都是在下滑趋势的。


另外一个角度来看,招聘岗位这么多,也有个好几千。是不是没什么问题,风生水起?


还是要看看活跃度的。我快速的看了下深圳,Go 岗位。第一页 30 条招聘岗位,只有 8 个是本周活跃的。刚刚活跃和 3 日内活跃的,加起来就 2~3 个。


综合数据来看,招聘岗位的数量在向下走,招聘者登陆平台的活跃度不高。


看看小行情


可能很容易就得出了 Go 完全不行的结论。我们也得看看别的编程语言岗位的。


Java,深圳:



PHP,深圳:



综合来看,其实并不是某一门语言的岗位不太行,或者要凉了。是由于整体的行情原因,招聘岗位和招聘者的活跃度都在大量的收缩。


像是以往,Go 最多的招聘岗位也是由各中大型公司撑起的。例如:字节跳动、腾讯、滴滴、百度等。他们收缩了,增量也就下来了。


而一般这种放水阶段,很多是面向 GY 企的,会有一些项目出现。例如:前段时间很火热的信创。


但有做 2B 的同学应该了解,这块有些企业会加码,要求使用 Java 语言。这一块的增量,与 Java 相比较,Go 是比较难在正面承接到的。


总结


其实不单单 Go 岗位少了。由于宏观的影响,我们常接触到的招聘岗位都少了。普遍来讲,还是建议如果没想明白就先苟着,降低负债、现金为王。


人是环境的反应器,常常会受到各种因素的影响。但在这种时期,可能想清楚自己的目标和感兴趣的内容、方向,会是一个不错的提高机会。


而在缩量的环境下,如果想找到增量。就要去一个风口或上行周期的领域。例如最近比较火的 AI。之前的新能源,不过也要警惕是个新坑。



文章持续更新,可以微信搜【脑子进煎鱼了】阅读,本文 GitHub github.com/eddycjy/blo… 已收录,学习 Go 语言可以看 Go 学习地图和路线,欢迎 Star 催更。



推荐阅读



作者:煎鱼eddycjy
来源:juejin.cn/post/7308697586532778024
收起阅读 »

个人独立开发者能否踏上敏捷之路?

很多软件开发团队都在使用Scrum、极限编程(XP)、看板等敏捷方法管理项目流程,持续迭代并更快、更高效地为客户持续交付可用的产品。除了团队,国内外很多个人独立开发者也在尝试将敏捷应用到自己的开发工作流程中,但大多数的结果都是收效甚微,个人践行敏捷是否可行? ...
继续阅读 »

很多软件开发团队都在使用Scrum、极限编程(XP)、看板等敏捷方法管理项目流程,持续迭代并更快、更高效地为客户持续交付可用的产品。除了团队,国内外很多个人独立开发者也在尝试将敏捷应用到自己的开发工作流程中,但大多数的结果都是收效甚微,个人践行敏捷是否可行? 


敏捷开发需要坚实的团队基础,以及团队文化的保障,方可有效地落地执行。  


什么是敏捷


敏捷是一种以用户需求为核心、采用不断迭代的方式进行的软件开发模式。它依靠自组织的跨职能小团队,在短周期内做出部分成果,通过快速、频繁的迭代,迅速地获取反馈,进而不断地完善产品,给用户带来更大的价值。践行敏捷的方式有很多,主要包括Scrum、XP、Kanban、精益生产、规模化敏捷等方法论。


敏捷的工作方式是将整个团队聚集在一起,理想情况下,敏捷团队的成员不超过10人。通过践行一系列简单的实践和足够的反馈,使团队能够感知目前的状态,并根据实际情况对实践进行调整。 


团队是敏捷的核心 


敏捷是一种团队驱动方法,团队可以简单的地定义为“为实现某一特定目标,包括两个或两个以上的人的相互协作的群体”。敏捷的核心是构建一个自组织的团队,团队的能力在于协作,即两个人或更多人相互交流与合作,以共同地产生一个结果。例如当两个程序员在结对编程时,他们在协作;每人持续集成当日的工作时,他们在协作;当团队开计划、站立、评审、回顾等会议时,他们在协作。协作的结果可以是有形的可交付物、决策或信息共享。 


而对于个人独立开发者,协作、互动、沟通都是无从谈起的: 


自己无法实践结对编程;


自己开站立会议是否很孤单;


自己玩估算扑克牌会不会很无聊;


评审演示没有观众,自然也就没有反馈;


…… 


这里有一个常见误区:独立开发者通常有一定的跨职能工作能力,于是想一人“饰演”多个不同角色,从需求计划整理到任务分解估时,从迭代开发到测试,再到发布、回顾总结。这是不是也在践行敏捷开发呢? 


当然不是。敏捷开发流程中任一环节都强调团队集体参与,并非由某个人独裁发号施令。例如项目计划制定、任务认领、工时评估,这些都不是某一个人的职责,而是需要团队成员来共同参与完成。然而,单个人的开发流程,很容易按部就班地走上了瀑布式开发模式(需求->设计->开发->测试->发布)。


001.png


[多重人格综合症,并确保精神上的新人是一个专业的“团队成员”]


敏捷团队中并不会要求每个人都成为全栈通才,在如今技术快速更新迭代的大环境下,期望一个人精通团队的所有技能是不现实的。取而代之的是重视具备跨职能的团队成员,这有助于管理各个工作岗位的平衡。例如,有时团队需要更多的测试人员,如果有一两个团队成员能转做测试工作,就能极大地提供帮助。 


敏捷是关于人,以及他们之间的协作交互,让每个人的能力得以充分的发挥并提升,从而创造优秀的产品。创造优秀产品的是人,而不是流程。所以,独立开发者即便一个人能跨职能走完整个开发流程,这跟敏捷强调的自组织团队中,成员之间高效地协作、交互以达到目标,完全不是一回事哦~


文化是敏捷的保障


很多个人独立开发者尝试引入敏捷的普遍思路,是从各种敏捷方法论中挑选一些个人能用,且有帮助的实践方法来用。这样确实能从中受益,但这真的是在践行敏捷么?


敏捷不只是一套方法论,敏捷也是一种思维模式。很多个人甚至团队尝试敏捷的过程中一个常见问题,是只取其方法实践,而未学其思维模式。这里说的思维模式,通俗讲就是指培养团队能够形成共识的文化,拥有一致的价值观和原则,塑造一个持续学习、自由、积极的团队氛围。以促使团队达到一种能够持续快速地交付有价值有质量的产品或服务的状态。


文化高于实践,成员能否融入团队文化,将会影响团队具体实践的高效程度。良好的团队文化,有利于促进团队内部的信息共享,从而产生更正确的决策。我们有时感觉自己已经引入敏捷了,但实则依旧保持着瀑布式思维,走的瀑布式开发流程,只是单纯学习并采用了一些好的敏捷实践,以至于最终达到的效果很有限。


这里引用《敏捷宣言》作者之一吉姆·海史密斯在他著作的《敏捷项目管理》中的一段总结: 


没有具体的实践,原则是贫瘠的;但是如果缺乏原则,实践则没有生命、没有个性、没有勇气。伟大的产品出自伟大的团队,而伟大的团队有原则、有个性、有勇气、有坚持、有胆量。


写在最后 


我们很难将整个敏捷的思维与方法流程应用到个人的独立开发工作中,因为敏捷需要坚实的团队基础,以及团队文化的保障,方可有效地落地执行。当然,我们并不否认个人可以尝试从敏捷中探索一些可借鉴学习的实践,并从中受益。


您如何看待这个问题呢,或者您是否有过将敏捷应用到个人的开发、工作、学习等方面的成功或失败的经验,欢迎在评论区一起分享交流。


 


参考资料:


《敏捷项目管理第2版》吉姆·海史密斯


敏捷开发网:http://www.minjiekaifa.com/


究竟什么是敏捷?http://www.zentao.net/blog/agile-…


作者:水牛GH
来源:juejin.cn/post/7308187262755061771
收起阅读 »

还在手打console.log?快来试试这个vscode插件 Quickly Log!!

背景 作为一枚前端开发人员,尤其是在写业务代码的时候,避免不了需要经常在控制台打印变量来查看数据(反正我是这样哈哈哈哈哈),那么就需要频繁的来写console.log(),然后在里面再输入自己想要查看的变量名。 思考 既然我们需要频繁的来进行这个操作,那么我们...
继续阅读 »

背景


作为一枚前端开发人员,尤其是在写业务代码的时候,避免不了需要经常在控制台打印变量来查看数据(反正我是这样哈哈哈哈哈),那么就需要频繁的来写console.log(),然后在里面再输入自己想要查看的变量名。


思考


既然我们需要频繁的来进行这个操作,那么我们是不是可以把它像代码片段一样来保存下来,然后配置一个激活他的快捷键来进行使用


在左下角这里选择用户代码片段


image.png


然后选择想要使代码片段生效的文件类型,比如我这里选择的tsx



选择了对应的文件类型,对应的代码片段只会在这个类型的文件里生效,想要在其他类型的文件里也使用同样的代码片段需要去对应的类型文件中复制一份



image.png


把对应的代码片段写入


"Print to console": {
// 说明
"description": "Log output to console",
// 快捷键
"prefix": "cl",
// 输出内容
"body": ["console.log($1)"]
},

这样我们就配置好了一个简易的代码片段,使用的时候只需要敲出来 ’cl‘就会出现我们的代码提示


image.png


这样我们就解决了这个问题(我自己也是使用这个方法很久)


更进一步


目前我们通过代码片段已经解决了这个问题,但是还是会有一些不方便的地方



  • 我们只能在写好代码片段的类型文件中使用,我们现在使用 .tsx,突然要写 .vue 了使用的时候发现没有生效,就又要再去配置一次

  • 如果我们目前的 console.log 比较多,那么控制台上就会看到输出了一堆的变量,根本搞不清哪个打印是对应的哪个变量

  • 有时候会遗忘删掉 console.log 语句 (也可以通过配置husky,在commit的时候进行校验解决)


为了解决这些问题,我们更进一步,来通过写一个vscode插件解决


Quickly Log


这个插件最开始只是对vscode插件开发的好奇,加上自己确实有这方面的需求才开始编写的。编写成插件就可以有效的解决了需要重复配置代码片段的问题。


这里介绍下插件的功能,不对代码具体介绍,感兴趣的可以去github上看下代码 github.com/Richard-Zha…


功能


提示配置


只需要将光标移动到变量附近,然后使用快捷键 Cmd + Shift + L,就会在下一行输出语句


image.png


这里也支持携带上变量所在的行号以及文件名


image.png


当然这些都是可以配置的,可以根据自己的喜好来配置输出的提示内容


image.png


如果是简洁党也可全都取消勾选,效果就和直接使用上面提到的代码片段一样,但是会支持自动将变量放入console.log()的括号内


一键clear


执行 Cmd + Shift + K 就会将当前页面匹配到的console.log语句自动删除


一键切换注释


执行 Cmd + Shift + J 就会将当前页面匹配到的console.log语句前面自动打上注释,再执行就会取消注释


快捷键都是可以更改的 vscode左下角的设置icon点开 点击键盘快捷方式 输入 Quickly Log进行更改


以上就是目前插件支持的功能了,欢迎大家去Vscode下载使用


image.png


TODO


目前有些场景的打印是有问题的


比如下面这样的换行场景,我们希望在光标放在a,b,c这里的时候,会在第21行这里插入console.log语句,但是目前只会在光标的下一行插入,还需要手动移动到下面


image.png


image.png


之前有试过通过判断是否在 {} 内来输入到整个语句之后,但是情况不太理想,后续再考虑解决


作者:Richard_Zhang
来源:juejin.cn/post/7306806944046678052
收起阅读 »

2024年,Rust和Go学哪个更好?

Rust vs. Go,在2024年,应该选择哪一个?或者说应该选择哪种语言——GoLang还是Rust。这可能是许多程序员在选择语言时考虑的一个问题。选择理想的编程语言被视为在这个不断变化的环境中取得成功的重要抉择。 GoLang和Rust是当今使用的最年轻...
继续阅读 »

Rust vs. Go,在2024年,应该选择哪一个?或者说应该选择哪种语言——GoLang还是Rust。这可能是许多程序员在选择语言时考虑的一个问题。选择理想的编程语言被视为在这个不断变化的环境中取得成功的重要抉择。


GoLang和Rust是当今使用的最年轻的编程语言。Go于2009年在Google推出,而在Go之后,Rust于2010年在Mozilla推出。这两种语言在当前流行的编程语言工具中有一些相似之处和差异。


通过本文,我们将讨论Rust和Go之间的基本差异和相似之处。


关于Go


Go是一门开源的计算机语言,可以更轻松地创建简单、高效和强大的软件。Go是精确、流畅和高效的。编写一个利用多核和网络机器的程序非常方便。


Go或GoLang是由Google工程师创建的,他们希望创建一种既具有C++的效率,又更容易学习、编写、阅读和安装的语言。


GoLang主要用于创建网络API和小型服务,特别是其goroutines,具有可扩展性。GoLang可以流畅地组装为机器代码,并提供舒适的垃圾回收和表示运行时的能力。


Go是一种快速、静态类型的汇编语言,给人一种解释型和动态类型语言的感觉。Goroutines的语言使开发人员能够创建完全掌控并发的应用程序,例如大型电子商务网站,同时在多个CPU核心上调度工作负载。


因此,准确地说,它非常适合并行计算环境。垃圾回收是Go的另一个特性,可以保证高效的内存管理。因此,未使用的内存可以用于新项目,而未使用的对象则从内存中“丢弃”。


关于Rust


Rust是一种静态类型的编译型编程语言,受到多种编程原型的支持。该语言最初的创建目标是优先考虑性能和安全性,其中安全性是主要目标。


Rust主要用于处理CPU密集型的活动,例如执行算法和存储大量数据。因此,需要高性能的项目通常使用Rust而不是GoLang。


理想情况下,Rust是C++的镜像。与GoLang和Java不同,Rust没有垃圾回收。相反,Rust使用借用检查器来确保内存安全。这个借用检查器强制执行数据所有权协议,以避免数据竞争。在这里,数据竞争意味着多个指针指向同一个内存位置。


Rust是一种用于长时间大型或小型团队的计算机编程语言。对于这种类型的编程,Rust提供了高度并发和极其安全的系统。


Rust现在被广泛用于Firefox浏览器的大部分部分。在2016年之后,Rust被宣称为最受欢迎的编程语言。Rust是一种非常基础的语言,可以在短短5分钟内学会。


Rust vs. Go,优缺点


要准确决定选择Go还是Rust,最好看一下GoLang和Rust的优势和劣势。上面我们已经对它们有了简单的了解,下面是它们的优点和缺点。


GoLang的优点



  • 它是一种简洁和简单的编程语言。

  • 它是一种良好组合的语言。

  • 以其速度而闻名。

  • Go具有很大的灵活性,并且易于使用。

  • 它是可扩展的。

  • 它是跨平台的。

  • 它可以检测未使用的变量。

  • GoLang具有静态分析工具。


GoLang的缺点



  • 没有手动内存管理。

  • 因为它太容易,所以感觉很表面。

  • 由于年轻,所以库较少。

  • 其中一些函数(如指针算术)是底层的。

  • GoLang的工具有一些限制。

  • 分析GoLang中的错误可能很困难。


Rust的优点



  • 提供非凡的速度。

  • 由于编译器,提供最佳的内存安全性。

  • 零成本抽象的运行时更快。

  • 它也是跨平台的。

  • 它提供可预测的运行时行为。

  • 它提供了访问优秀模式和语法的方式。

  • 它具有特殊的所有权特性。

  • 它易于与C语言和其他语言结合使用。


Rust的缺点



  • 尽管它确实很快,但有人声称它比F#慢。

  • 它具有基于范围的内存管理,可能导致内存泄漏的无限循环。

  • 在Rust中无法使用纯函数式数据框架,因为没有垃圾回收。

  • Rust没有Python和Perl语言支持的猴子补丁水平。

  • 由于语言还很新,可能会对语法感到担忧。

  • 编译时有时会很慢,因此学习变得困难。


数据告诉我们什么?


根据一份报告,GoLang语言被认为是参与者最喜欢的语言。


我们对GoLang和Rust语言有了基本的了解,现在继续进行Rust vs. Go的比较,并清楚地认识到这两种语言之间的差异。


Rust和Go的主要区别


GoLang和Rust之间的主要区别包括:



  • 性能

  • 并发性

  • 内存安全性

  • 开发速度

  • 开发者体验


(1) 性能


Google推出Go作为易于编码和学习的C++替代品。Go提供Goroutines,通过其中一个可以通过简单地包含Go语法来运行函数。


尽管Go具有这些有用的功能和对多核CPU的支持,但Rust占据上风,超过了Go。


因此,Go vs Rust:性能是Rust在与GoLang的比较中获得更多分数的一个特点。这些编程语言都是为了与C++和C等价而创建的。然而,在Rust vs. Go的比较中,GoLang的开发速度略高于Rust的性能。


虽然Rust在性能上优于Go,但在编译速度方面,Rust落后于Go。


然而,人们对编译时间并不太在意,所以整体上Rust在这方面是胜利者。


(2) 并发性


GoLang支持并发,在这一因素上比Rust有优势。Go的并发模型允许开发人员在不同的CPU核心上安装工作负载,使Go成为一种连贯的语言。


因此,在运行处理API请求的网站的情况下,GoLang goroutines将每个请求作为子进程运行。这个过程提高了效率,因为它将任务从所有CPU核心中卸载出来。


另一方面,Rust只有一个原生的等待或同步语法。因此,程序员更喜欢使用Go的方式来处理并发问题。


(3) 内存安全性


Rust使用编译时头文件策略来实现零成本中断的内存安全性。如果不是内存安全的程序,Rust将无法通过编译阶段。实际上,Rust的好处之一就是提供了内存安全性。


为了实现内存安全的并发,Rust使用类型安全性。Rust编译器调查你引用的每个内存地址和使用的每个变量。Rust的这个特性将通知你任何未定义行为和数据竞争。


它确保程序员不会遇到缓冲区溢出的情况。


相比之下,Go在运行时完全自动化。因此,开发人员在编写代码时不必担心内存释放。


因此,无论是GoLang还是Rust都优先考虑内存安全特性,但在性能方面,GoLang具有数据竞争的可能性。


(4) 开发速度


在某些情况下,开发速度比性能和程序速度更重要。Go语言的直接性和清晰性使其成为一种开发速度较快的语言。Go语言具有更短的编译时间和更快的运行时间。


尽管Go既提供了开发速度和简单性,但它缺少一些重要的功能。为了使语言更简单,Google删除了其他编程语言中可用的许多功能。


另一方面,Rust比Go拥有更多的功能。Rust具有更长的编译时间。


因此,如果项目的优先级是开发速度,Go比Rust要好得多。如果你不太关心开发速度和开发周期,但希望获得性能和内存安全性,那么Rust是你的最佳选择。


(5) 开发者体验


由于开发Go的主要动机是简单和易用性,大多数程序员认为它是一种“无聊的语言”或“简单的语言”。Go中的功能有限,使得学习和实现非常简单。


相反,Rust具有更高的内存安全功能,使得代码更复杂,降低了程序员的生产力。所有权的概念使得Rust语言对许多人来说不是理想的选择。


与Go相比,Rust的学习曲线要陡峭得多。然而,值得注意的是,与Python和JavaScript等语言相比,GoLang的学习曲线也较陡峭。


Rust和Go的共同特点


在Rust vs Go的比较中,这两者之间有很多共同之处。GoLang和Rust都是许多年轻开发人员使用的现代编程语言。


GoLang和Rust都是编译语言,都是开源的,并且都是用于微服务的计算环境。


此外,如果你对C++有一些了解,那么这两个程序都非常容易理解。


交互性


Rust能够与代码进行接口交互,例如直接与C库进行通信。Rust没有提供内存安全性的认证。


交互性带来了速度。Go提供了与C语言配合使用的Go包。


何时应该使用GoLang?


Go语言可用于各种不同的项目。根据一份报告,Go的用例包括网页开发、数据库和Web编程。大多数GoLang开发人员声称,由于Go的并发性,它对Web服务有一些限制。


不仅如此,Go还被列为后端Web开发的首选语言。Go语言还为Google Cloud Platform提供支持。因此,在高性能云应用中,Go确实是性能消耗大的语言。


何时应该使用Rust?


Rust是一种几乎可以在任何地方使用的计算机编程语言。然而,仍然有一些领域比其他领域更适合使用。系统编程就是其中之一,因为Rust在高性能方面表现出色。


系统程序员基本上是在硬件侧开发的软件工程师。由于Rust处理硬件侧内存管理的复杂性,它经常用于设计操作系统或计算机应用程序。


尽管在开发者社区内对什么构成中级语言存在一些争议,但Rust被视为具有面向机器的现代语言的特点。


总结


这两种语言,GoLang和Rust,由于它们非常相近的起源时间,被认为是彼此的竞争对手。Go的发展速度比Rust快。这两种语言有很多相似之处。


GoLang和Rust之间的区别在于Go是简单的,而Rust是复杂的。然而,它们的功能和优先级在各种有意义的方面有所不同。


Go与Rust并驾齐驱。这意味着这完全取决于你拥有的项目类型,主要取决于对你的业务来说什么是最好的。


作者:程序新视界
来源:juejin.cn/post/7307648485921980470
收起阅读 »

比亚迪面试,全程八股!

比亚迪最近几年凭借着其新能源汽车的板块大火了一把,无论是名声还是股价都涨得嘎嘎猛,但是迪子招聘编程技术岗位的人员却有两个特点: 面试难度低,对学校有一定的要求。 薪资给的和面试难度一样低。 但不管怎么,迪子也算是国内知名公司了,所以今天咱们来看看,他的校招...
继续阅读 »

比亚迪最近几年凭借着其新能源汽车的板块大火了一把,无论是名声还是股价都涨得嘎嘎猛,但是迪子招聘编程技术岗位的人员却有两个特点:



  1. 面试难度低,对学校有一定的要求。

  2. 薪资给的和面试难度一样低。


但不管怎么,迪子也算是国内知名公司了,所以今天咱们来看看,他的校招 Java 技术岗的面试题都问了哪些知识点?面试题目如下:
image.png


1.int和Integer有什么区别?


参考答案:int 和 Integer 都是 Java 中用于表示整数的数据类型,然而他们有以下 6 点不同:



  1. 数据类型不同:int 是基础数据类型,而 Integer 是包装数据类型;

  2. 默认值不同:int 的默认值是 0,而 Integer 的默认值是 null;

  3. 内存中存储的方式不同:int 在内存中直接存储的是数据值,而 Integer 实际存储的是对象引用,当 new 一个 Integer 时实际上是生成一个指针指向此对象;

  4. 实例化方式不同:Integer 必须实例化才可以使用,而 int 不需要;

  5. 变量的比较方式不同:int 可以使用 == 来对比两个变量是否相等,而 Integer 一定要使用 equals 来比较两个变量是否相等;

  6. 泛型使用不同:Integer 能用于泛型定义,而 int 类型却不行。


2.什么时候用 int 和 Integer?


参考答案:int 和 Integer 的典型使用场景如下:



  • Integer 典型使用场景:在 Spring Boot 接收参数的时候,通常会使用 Integer 而非 int,因为 Integer 的默认值是 null,而 int 的默认值是 0。如果接收参数使用 int 的话,那么前端如果忘记传递此参数,程序就会报错(提示 500 内部错误)。因为前端不传参是 null,null 不能被强转为 0,所以使用 int 就会报错。但如果使用的是 Integer 类型,则没有这个问题,程序也不会报错,所以 Spring Boot 中 Controller 接收参数时,通常会使用 Integer。

  • int 典型使用场景:int 常用于定义类的属性类型,因为属性类型,不会 int 不会被赋值为 null(编译器会报错),所以这种场景下,使用占用资源更少的 int 类型,程序的执行效率会更高。


3.HashMap 底层实现?


HashMap 在 JDK 1.7 和 JDK 1.8 的底层实现是不一样的。



  • 在 JDK 1.7 中,HashMap 使用的是数组 + 链表实现的。

  • 而 JDK 1.8 中使用的是数组 + 链表或红黑树实现的


HashMap 在 JDK 1.7 中的实现如下图所示:
image.png
HashMap 在 JDK 1.8 中的实现如下图所示:


4.HashMap 如何取值和存值?


参考答案:HashMap 使用 put(key,value) 方法进行存值操作,而存值操作的关键是根据 put 中的 key 的哈希值来确定存储的位置,如果存储的位置为 null,则直接存储此键值对;如果存储的位置有值,则使用链地址法来解决哈希冲突,找到新的位置进行存储。


HashMap 取值的方法是 get(key),它主要是通过 key 的哈希值,找到相应的位置,然后通过 key 进行判断,从而获取到存储的 value 信息。


5.SpringBoot 如何修改端口号?


参考答案:在 Spring Boot 中的配置文件中设置“server.port=xxx”就可以修改端口号了。


6.如何修改 Tomcat 版本号?


参考答案:在 pom.xml 中添加 tomcat-embed-core 依赖就可以修改 Spring Boot 中内置的 Tomcat 版本号了,如下图所示:
image.png
但需要注意的是 Spring Boot 和 Tomcat 的版本是有对应关系的,要去 maven 上查询对应的版本关系才能正确的修改内置的 Tomcat 版本号,如下图所示:
image.png


7.SpringBoot如何配置Redis?


参考答案:首先在 Spring Boot 中添加 Redis 的框架依赖,然后在配置文件中使用“spring.redis.xxx”来设置 Redis 的相关属性,例如以下这些:


spring:
redis:
# Redis 服务器地址
host: 127.0.0.1
# Redis 端口号
port: 6379
# Redis服务器连接密码,默认为空,若有设置按设置的来
password:
jedis:
pool:
# 连接池最大连接数,若为负数则表示没有任何限制
max-active: 8
# 连接池最大阻塞等待时间,若为负数则表示没有任何限制
max-wait: -1
# 连接池中的最大空闲连接
max-idle: 8


8.MySQL 左连接和右连接有什么区别?


参考答案:在 MySQL 中,左连接(Left Join)和右连接(Right Join)是两种用来进行联表查询的 SQL 语句,它们的区别如下:



  1. 左连接:左连接是以左边的表格(也称为左表)为基础,将左表中的所有记录和右表中匹配的记录联接起来。即使右表中没有匹配的记录,左连接仍然会返回左表中的记录。如果右表中有多条匹配记录,则会将所有匹配记录返回。左连接使用 LEFT JOIN 关键字来表示。

  2. 右连接:右连接是以右边的表格(也称为右表)为基础,将右表中的所有记录和左表中匹配的记录联接起来。即使左表中没有匹配的记录,右连接仍然会返回右表中的记录。如果左表中有多条匹配记录,则会将所有匹配记录返回。右连接使用 RIGHT JOIN 关键字来表示。


例如以下图片,左连接查询的结果如下图所示(红色部分为查询到的数据):
image.png
右连接如下图红色部分:
image.png


9.内连接没有匹配上会怎么?


参考连接:内连接使用的是 inner join 关键字来实现的,它会匹配到两张表的公共部分,如下图所示:
image.png
所以,如果内连接没有匹配上数据,则查询不到此数据。


小结


以上是比亚迪的面试题,但并不是说比亚迪的面试难度一定只有这么低。因为面试的难度通常是根据应聘者的技术水平决定的:如果应聘者的能力一般,那么通常面试官就会问一下简单的问题,然后早早结束面试;但如果应聘者的能力比较好,面试官通常会问的比较难,以此来探寻应聘者的技术能力边界,从而为后续的定薪、定岗来做准备,所以大家如果遇到迪子的面试也不要大意。


作者:Java中文社群
来源:juejin.cn/post/7306723594816733235
收起阅读 »

只改了五行代码接口吞吐量提升了10多倍

背景 公司的一个ToB系统,因为客户使用的也不多,没啥并发要求,就一直没有经过压测。这两天来了一个“大客户”,对并发量提出了要求:核心接口与几个重点使用场景单节点吞吐量要满足最低500/s的要求。 当时一想,500/s吞吐量还不简单。Tomcat按照100个线...
继续阅读 »

背景


公司的一个ToB系统,因为客户使用的也不多,没啥并发要求,就一直没有经过压测。这两天来了一个“大客户”,对并发量提出了要求:核心接口与几个重点使用场景单节点吞吐量要满足最低500/s的要求。


当时一想,500/s吞吐量还不简单。Tomcat按照100个线程,那就是单线程1S内处理5个请求,200ms处理一个请求即可。这个没有问题,平时接口响应时间大部分都100ms左右,还不是分分钟满足的事情。


然而压测一开,100 的并发,吞吐量居然只有 50 ...


image.png


而且再一查,100的并发,CPU使用率居然接近 80% ...




从上图可以看到几个重要的信息。


最小值: 表示我们非并发场景单次接口响应时长。还不足100ms。挺好!


最大值: 并发场景下,由于各种锁或者其他串行操作,导致部分请求等待时长增加,接口整体响应时间变长。5秒钟。有点过分了!!!


再一看百分位,大部分的请求响应时间都在4s。无语了!!!


所以 1s钟的 吞吐量 单节点只有 50 。距离 500 差了10倍。 难受!!!!


分析过程


定位“慢”原因



这里暂时先忽略 CPU 占用率高的问题



首先平均响应时间这么慢,肯定是有阻塞。先确定阻塞位置。重点检查几处:



  • 锁 (同步锁、分布式锁、数据库锁)

  • 耗时操作 (链接耗时、SQL耗时)


结合这些先配置耗时埋点。



  1. 接口响应时长统计。超过500ms打印告警日志。

  2. 接口内部远程调用耗时统计。200ms打印告警日志。

  3. Redis访问耗时。超过10ms打印告警日志。

  4. SQL执行耗时。超过100ms打印告警日志。


上述配置生效后,通过日志排查到接口存在慢SQL。具体SQL类似与这种:


<!-- 主要类似与库存扣减 每次-1 type 只有有限的几种且该表一共就几条数据(一种一条记录)-->
<!-- 压测时可以认为 type = 1 是写死的 -->
update table set field = field - 1 where type = 1 and filed > 1;

上述SQL相当于并发操作同一条数据,肯定存在锁等待。日志显示此处的等待耗时占接口总耗时 80% 以上。


二话不说先改为敬。因为是压测环境,直接先改为异步执行,确认一下效果。实际解决方案,感兴趣的可以参考另外一篇文章:大量请求同时修改数据库表一条记录时应该如何设计


PS:当时心里是这么想的: 妥了,大功告成。就是这里的问题!绝壁是这个原因!优化一下就解决了。当然,如果这么简单就没有必要写这篇文章了...


优化后的效果:


image.png


嗯...


emm...


好! 这个优化还是很明显的,提升提升了近2倍。




此时已经感觉到有些不对了,慢SQL已经解决了(异步了~ 随便吧~ 你执行 10s我也不管了),虽然对吞吐量的提升没有预期的效果。但是数据是不会骗人的。


最大值: 已经从 5s -> 2s


百分位值: 4s -> 1s


这已经是很大的提升了。


继续定位“慢”的原因


通过第一阶段的“优化”,我们距离目标近了很多。废话不多说,继续下一步的排查。


我们继续看日志,此时日志出现类似下边这种情况:


2023-01-04 15:17:05:347 INFO **.**.**.***.50 [TID: 1s22s72s8ws9w00] **********************
2023-01-04 15:17:05:348 INFO **.**.**.***.21 [TID: 1s22s72s8ws9w00] **********************
2023-01-04 15:17:05:350 INFO **.**.**.***.47 [TID: 1s22s72s8ws9w00] **********************

2023-01-04 15:17:05:465 INFO **.**.**.***.234 [TID: 1s22s72s8ws9w00] **********************
2023-01-04 15:17:05:467 INFO **.**.**.***.123 [TID: 1s22s72s8ws9w00] **********************

2023-01-04 15:17:05:581 INFO **.**.**.***.451 [TID: 1s22s72s8ws9w00] **********************

2023-01-04 15:17:05:702 INFO **.**.**.***.72 [TID: 1s22s72s8ws9w00] **********************

前三行info日志没有问题,间隔很小。第4 ~ 第5,第6 ~ 第7,第7 ~ 第8 很明显有百毫秒的耗时。检查代码发现,这部分没有任何耗时操作。那么这段时间干什么了呢?



  1. 发生了线程切换,换其他线程执行其他任务了。(线程太多了)

  2. 日志打印太多了,压测5分钟日志量500M。(记得日志打印太多是有很大影响的)

  3. STW。(但是日志还在输出,所以前两种可能性很高,而且一般不会停顿百毫秒)


按照这三个思路做了以下操作:


首先,提升日志打印级别到DEBUG。emm... 提升不大,好像增加了10左右。


然后,拆线程 @Async 注解使用线程池,控制代码线程池数量(之前存在3个线程池,统一配置的核心线程数为100)结合业务,服务总核心线程数控制在50以内,同步增加阻塞最大大小。结果还可以,提升了50,接近200了。


最后,观察JVM的GC日志,发现YGC频次4/s,没有FGC。1分钟内GC时间不到1s,很明显不是GC问题,不过发现JVM内存太小只有512M,直接给了4G。吞吐量没啥提升,YGC频次降低为2秒1次。


唉,一顿操作猛如虎。


PS:其实中间还对数据库参数一通瞎搞,这里不多说了。




其实也不是没有收获,至少在减少服务线程数量后还是有一定收获的。另外,已经关注到了另外一个点:CPU使用率,减少了线程数量后,CPU的使用率并没有明显的下降,这里是很有问题的,当时认为CPU的使用率主要与开启的线程数量有关,之前线程多,CPU使用率较高可以理解。但是,在砍掉了一大半的线程后,依然居高不下这就很奇怪了。


此时关注的重点开始从代码“慢”方向转移到“CPU高”方向。


定位CPU使用率高的原因


CPU的使用率高,通常与线程数相关肯定是没有问题的。当时对居高不下的原因考虑可能有以下两点:



  1. 有额外的线程存在。

  2. 代码有部分CPU密集操作。


然后继续一顿操作:



  1. 观察服务活跃线程数。

  2. 观察有无CPU占用率较高线程。


在观察过程中发现,没有明显CPU占用较高线程。所有线程基本都在10%以内。类似于下图,不过有很多线程。


image.png


没有很高就证明大家都很正常,只是多而已...


此时没有下一步的排查思路了。当时想着,算了打印一下堆栈看看吧,看看到底干了啥~


在看的过程中发现这段日志:


"http-nio-6071-exec-9" #82 daemon prio=5 os_prio=0 tid=0x00007fea9aed1000 nid=0x62 runnable [0x00007fe934cf4000]
java.lang.Thread.State: RUNNABLE
at org.springframework.core.annotation.AnnotationUtils.getValue(AnnotationUtils.java:1058)
at org.springframework.aop.aspectj.annotation.AbstractAspectJAdvisorFactory$AspectJAnnotation.resolveExpression(AbstractAspectJAdvisorFactory.java:216)
at org.springframework.aop.aspectj.annotation.AbstractAspectJAdvisorFactory$AspectJAnnotation.<init>(AbstractAspectJAdvisorFactory.java:197)
at org.springframework.aop.aspectj.annotation.AbstractAspectJAdvisorFactory.findAnnotation(AbstractAspectJAdvisorFactory.java:147)
at org.springframework.aop.aspectj.annotation.AbstractAspectJAdvisorFactory.findAspectJAnnotationOnMethod(AbstractAspectJAdvisorFactory.java:135)
at org.springframework.aop.aspectj.annotation.ReflectiveAspectJAdvisorFactory.getAdvice(ReflectiveAspectJAdvisorFactory.java:244)
at org.springframework.aop.aspectj.annotation.InstantiationModelAwarePointcutAdvisorImpl.instantiateAdvice(InstantiationModelAwarePointcutAdvisorImpl.java:149)
at org.springframework.aop.aspectj.annotation.InstantiationModelAwarePointcutAdvisorImpl.<init>(InstantiationModelAwarePointcutAdvisorImpl.java:113)
at org.springframework.aop.aspectj.annotation.ReflectiveAspectJAdvisorFactory.getAdvisor(ReflectiveAspectJAdvisorFactory.java:213)
at org.springframework.aop.aspectj.annotation.ReflectiveAspectJAdvisorFactory.getAdvisors(ReflectiveAspectJAdvisorFactory.java:144)
at org.springframework.aop.aspectj.annotation.BeanFactoryAspectJAdvisorsBuilder.buildAspectJAdvisors(BeanFactoryAspectJAdvisorsBuilder.java:149)
at org.springframework.aop.aspectj.annotation.AnnotationAwareAspectJAutoProxyCreator.findCandidateAdvisors(AnnotationAwareAspectJAutoProxyCreator.java:95)
at org.springframework.aop.aspectj.autoproxy.AspectJAwareAdvisorAutoProxyCreator.shouldSkip(AspectJAwareAdvisorAutoProxyCreator.java:101)
at org.springframework.aop.framework.autoproxy.AbstractAutoProxyCreator.wrapIfNecessary(AbstractAutoProxyCreator.java:333)
at org.springframework.aop.framework.autoproxy.AbstractAutoProxyCreator.postProcessAfterInitialization(AbstractAutoProxyCreator.java:291)
at org.springframework.beans.factory.support.AbstractAutowireCapableBeanFactory.applyBeanPostProcessorsAfterInitialization(AbstractAutowireCapableBeanFactory.java:455)
at org.springframework.beans.factory.support.AbstractAutowireCapableBeanFactory.initializeBean(AbstractAutowireCapableBeanFactory.java:1808)
at org.springframework.beans.factory.support.AbstractAutowireCapableBeanFactory.doCreateBean(AbstractAutowireCapableBeanFactory.java:620)
at org.springframework.beans.factory.support.AbstractAutowireCapableBeanFactory.createBean(AbstractAutowireCapableBeanFactory.java:542)
at org.springframework.beans.factory.support.AbstractBeanFactory.doGetBean(AbstractBeanFactory.java:353)
at org.springframework.beans.factory.support.AbstractBeanFactory.getBean(AbstractBeanFactory.java:233)
at org.springframework.beans.factory.support.DefaultListableBeanFactory.resolveNamedBean(DefaultListableBeanFactory.java:1282)
at org.springframework.beans.factory.support.DefaultListableBeanFactory.resolveNamedBean(DefaultListableBeanFactory.java:1243)
at org.springframework.beans.factory.support.DefaultListableBeanFactory.resolveBean(DefaultListableBeanFactory.java:494)
at org.springframework.beans.factory.support.DefaultListableBeanFactory.getBean(DefaultListableBeanFactory.java:349)
at org.springframework.beans.factory.support.DefaultListableBeanFactory.getBean(DefaultListableBeanFactory.java:342)
at cn.hutool.extra.spring.SpringUtil.getBean(SpringUtil.java:117)
......
......

上边的堆栈发现了一个点: 在执行getBean的时候,执行了createBean方法。我们都知道Spring托管的Bean都是提前实例化好放在IOC容器中的。createBean要做的事情有很多,比如Bean的初始化,依赖注入其他类,而且中间还有一些前后置处理器执行、代理检查等等,总之是一个耗时方法,所以都是在程序启动时去扫描,加载,完成Bean的初始化。


而我们在运行程序线程堆栈中发现了这个操作。而且通过检索发现竟然有近200处。


通过堆栈信息很快定位到执行位置:


<!--BeanUtils 是 hutool 工具类。也是从IOC容器获取Bean 等价于 @Autowired 注解 -->
RedisTool redisTool = BeanUtils.getBean(RedisMaster.class);

而RedisMaster类


@Component
@Scope("prototype")
public class RedisMaster implements IRedisTool {
// ......
}

没错就是用了多例。而且使用的地方是Redis(系统使用Jedis客户端,Jedis并非线程安全,每次使用都需要新的实例),接口对Redis的使用还是比较频繁的,一个接口得有10次左右获取Redis数据。也就是说执行10次左右的createBean逻辑 ...


叹气!!!


赶紧改代码,直接使用万能的 new 。


在看结果之前还有一点需要提一下,由于系统有大量统计耗时的操作。实现方式是通过:


long start = System.currentTimeMillis();
// ......
long end = System.currentTimeMillis();
long runTime = start - end;


或者Hutool提供的StopWatch:


这里感谢一下huoger 同学的评论,当时还误以为该方式能够降低性能的影响,但是实际上也只是一层封装。底层使用的是 System.nanoTime()。


StopWatch watch = new StopWatch();
watch.start();
// ......
watch.stop();
System.out.println(watch.getTotalTimeMillis());

而这种在并发量高的情况下,对性能影响还是比较大的,特别在服务器使用了一些特定时钟的情况下。这里就不多说,感兴趣的可以自行搜索一下。





最终结果:



image.png





排查涉及的命令如下:



查询服务进程CPU情况: top –Hp pid


查询JVM GC相关参数:jstat -gc pid 2000 (对 pid [进程号] 每隔 2s 输出一次日志)


打印当前堆栈信息: jstack -l pid >> stack.log


总结


结果是好的,过程是曲折的。总的来说还是知识的欠缺,文章看起来还算顺畅,但都是事后诸葛亮,不对,应该是事后臭皮匠。基本都是边查资料边分析边操作,前后花费了4天时间,尝试了很多。



  • Mysql : Buffer Pool 、Change Buffer 、Redo Log 大小、双一配置...

  • 代码 : 异步执行,线程池参数调整,tomcat 配置,Druid连接池配置...

  • JVM : 内存大小,分配,垃圾收集器都想换...


总归一通瞎搞,能想到的都试试。


后续还需要多了解一些性能优化知识,至少要做到排查思路清晰,不瞎搞。




最后5行代码有哪些:



  1. new Redis实例:1

  2. 耗时统计:3

  3. SQL异步执行 @Async: 1(上图最终的结果是包含该部分的,时间原因未对SQL进行处理,后续会考虑Redis原子操作+定时同步数据库方式来进行,避免同时操数据库)


TODO


问题虽然解决了。但是原理还不清楚,需要继续深挖。



为什么createBean对性能影响这么大?



如果影响这么大,Spring为什么还要有多例?


首先非并发场景速度还是很快的。这个毋庸置疑。毕竟接口响应时间不足50ms。


所以问题一定出在,并发createBean同一对象的锁等待场景。根据堆栈日志,翻了一下Spring源码,果然发现这里出现了同步锁。相信锁肯定不止一处。


image.png


org.springframework.beans.factory.support.AbstractAutowireCapableBeanFactory#doCreateBean


image.png



System.currentTimeMillis并发度多少才会对性能产生影响,影响有多大?



很多公司(包括大厂)在业务代码中,还是会频繁的使用System.currentTimeMillis获取时间戳。比如:时间字段赋值场景。所以,性能影响肯定会有,但是影响的门槛是不是很高。



继续学习性能优化知识




  • 吞吐量与什么有关?


首先,接口响应时长。直接影响因素还是接口响应时长,响应时间越短,吞吐量越高。一个接口响应时间100ms,那么1s就能处理10次。


其次,线程数。现在都是多线程环境,如果同时10个线程处理请求,那么吞吐量又能增加10倍。当然由于CPU资源有限,所以线程数也会受限。理论上,在 CPU 资源利用率较低的场景,调大tomcat线程数,以及并发数,能够有效的提升吞吐量。


最后,高性能代码。无论接口响应时长,还是 CPU 资源利用率,都依赖于我们的代码,要做高性能的方案设计,以及高性能的代码实现,任重而道远。



  • CPU使用率的高低与哪些因素有关?


CPU使用率的高低,本质还是由线程数,以及CPU使用时间决定的。


假如一台10核的机器,运行一个单线程的应用程序。正常这个单线程的应用程序会交给一个CPU核心去运行,此时占用率就是10%。而现在应用程序都是多线程的,因此一个应用程序可能需要全部的CPU核心来执行,此时就会达到100%。


此外,以单线程应用程序为例,大部分情况下,我们还涉及到访问Redis/Mysql、RPC请求等一些阻塞等待操作,那么CPU就不是时刻在工作的。所以阻塞等待的时间越长,CPU利用率也会越低。也正是因为如此,为了充分的利用CPU资源,多线程也就应运而生(一个线程虽然阻塞了,但是CPU别闲着,赶紧去运行其他的线程)。



  • 一个服务线程数在多少比较合适(算上Tomcat,最终的线程数量是226),执行过程中发现即使tomcat线程数量是100,活跃线程数也很少超过50,整个压测过程基本维持在20左右。


作者:FishBones
来源:juejin.cn/post/7185479136599769125
收起阅读 »

写了个数据查询为空的 Bug,你会怎么办?

大家在开发时,遇到的一个典型的 Bug 就是:为什么数据查询为空? 对应的现象就是:前端展示不出数据、或者后端查询到的数据列表为空。 遇到此类问题,其实是有经典的解决套路的,下面鱼皮给大家分享如何高效解决这个问题。 只需 4 个步骤: 解决步骤 1、定位问题...
继续阅读 »

大家在开发时,遇到的一个典型的 Bug 就是:为什么数据查询为空?


对应的现象就是:前端展示不出数据、或者后端查询到的数据列表为空。



遇到此类问题,其实是有经典的解决套路的,下面鱼皮给大家分享如何高效解决这个问题。


只需 4 个步骤:


解决步骤


1、定位问题边界


首先要定位数据查询为空的错误边界。说简单一点,就是要确认是前端还是后端的锅。


要先从请求的源头排查,也就是前端浏览器,毕竟前端和后端是通过接口(请求)交互的。


在浏览器中按 F12 打开浏览器控制台,进入网络标签,然后刷新页面或重新触发请求,就能看到请求的信息了。


选中请求并点击预览,就能看到后端返回结果,有没有返回数据一看便知。




如果发现后端正常返回了数据,那就是前端的问题,查看自己的页面代码来排查为什么数据没在前端显示,比如是不是取错了数据的结构?可以多用 debugger 或 console.log 等方式输出信息,便于调试。


星球同学可以免费阅读前端嘉宾神光的《前端调试通关秘籍》:t.zsxq.com/13Rh4xxNK


如果发现后端未返回数据,那么前端需要先确认下自己传递的参数是否正确。


比如下面的例子,分页参数传的太大了,导致查不到数据:



如果发现请求参数传递的没有问题,那么就需要后端同学帮忙解决了。


通过这种方式,直接就定位清楚了问题的边界,高效~


2、后端验证请求


接下来的排查就是在后端处理了,首先开启 Debug 模式,从接受请求参数开始逐行分析。


比如先查看请求参数对象,确认前端有没有按照要求传递请求参数:



毕竟谁能保证我们的同事(或者我们自己)不是小迷糊呢?即使前端说自己请求是正确的,但也必须要优先验证,而不是一上来就去分析数据库和后端程序逻辑的问题。


验证请求参数对象没问题后,接着逐行 Debug,直到要执行数据库查询。


3、后端验证数据库查询


无论是从 MySQL、MongoDB、Redis,还是文件中查询数据,为了理解方便,我们暂且统称为数据库。


上一步中,我们已经 Debug 到了数据库查询,需要重点关注 2 个点:


1)查看封装的请求参数是否正确


对于 MyBatis Plus 框架来说,就是查看 QueryWrapper 内的属性是否正确填充了查询条件



2)查看数据库的返回结果是否有值


比如 MyBatis Plus 的分页查询中,如果 records 属性的 size 大于 0,表示数据库返回了数据,那么就不用再排查数据库查询的问题了;而如果 size = 0,就要分析为什么从数据库中查询的数据为空。



这一步尤为关键,我们需要获取到实际发送给数据库查询的 SQL 语句。如果你使用的是 MyBatis Plus 框架,可以直接在 application.yml 配置文件中开启 SQL 语句日志打印,参考配置如下:


mybatis-plus:
  configuration:
    log-impl: org.apache.ibatis.logging.stdout.StdOutImpl

然后执行查询,就能看到完整的 SQL 语句了:



把这个 SQL 语句复制到数据库控制台执行,验证下数据结果是否正确。如果数据库直接执行语句都查不出数据,那就确认是查询条件错误了还是数据库本身就缺失数据。


4、后端验证数据处理逻辑


如果数据库查询出了结果,但最终响应给前端的数据为空,那么就需要在数据库查询语句后继续逐行 Debug,验证是否有过滤数据的逻辑。


比较典型的错误场景是查询出的结果设置到了错误的字段中、或者由于权限问题被过滤和脱敏掉了。


最后


以后再遇到数据查询为空的情况,按照以上步骤排查问题即可。排查所有 Bug 的核心流程都是一样的,先搜集信息、再定位问题、最后再分析解决。


作者:程序员鱼皮
来源:juejin.cn/post/7306337248623132699
收起阅读 »

服务器:重来一世,这一世我要踏至巅峰!

前言 故事发生在上个星期一下午,秋风伴随着暖阳,映照出我在机房电脑上键盘敲击的身影。突然,伴随着一行指令运行mv /* ~/home/blog-end/,我发出土拨鼠尖叫——啊啊啊啊啊!!!!我服务器,窝滴服务器哟,哎哟,你干嘛,窝滴服务器哟!!! 就这样,我...
继续阅读 »

前言


故事发生在上个星期一下午,秋风伴随着暖阳,映照出我在机房电脑上键盘敲击的身影。突然,伴随着一行指令运行mv /* ~/home/blog-end/,我发出土拨鼠尖叫——啊啊啊啊啊!!!!我服务器,窝滴服务器哟,哎哟,你干嘛,窝滴服务器哟!!!


就这样,我把所有/目录下的文件给迁移了,/usr/bin/...所有文件都迁移了,还被我关了服务器窗口,后面重启也连不上了,我又是一声土拨鼠尖叫——啊啊啊啊啊啊!!!!如今只剩下一个方法了,那便是转世重修重新初始化系统......


重活一世,我要踏至巅峰


我,是上一代服务器的转世,重活一世,这一世我便要踏上那巅峰看一看,接下来便随着我一起打怪升级,踏上那巅峰吧......


搭建环境


在初始化系统的时候我选择的是诸天万界的高级系统ubuntu_22_04_x64,要部署的是我的博客项目,前端是nginx启动,后端是pm2启动,需要准备的环境有:nvm、node、mysql、git


1. 更新资源包,确保你的系统已经获取了最新的软件包信息


sudo apt update

2. 安装mysql


// 安装的时候一路`enter`就可以了
sudo apt install mysql-server

// 安装完后启动mysql服务
sudo systemctl start mysql

// 设置开机自启动
sudo systemctl enable mysql

// 检测数据库是否正在运行
sudo systemctl status mysql

// 运行以下指令登录数据库,第一次输入的密码会作为你数据库的密码
mysql -u root -p

// 如果输入密码报以下错误那就直接回车就能进入
ERROR 1045 (28000): Access denied for user 'root'@'localhost' (using password: YES)

// 进入之后记得修改密码,这里的new_password修改为自己的密码
ALTER USER 'root'@'localhost' IDENTIFIED BY 'new_password';

//在这里我会创建一个子用户,使用子用户进行链接数据库操作,而不是直接root用户直接操作数据库
// 这里的dms换成用户名,PASSword123换成密码
create user 'dms'@'%' identified by 'PASSword123!'; // 创建子用户
grant all privileges on *.* to 'dms'@'%'with grant option; // 授权
flush privileges; // 生效用户



配置数据库运行远程链接


cd /etc/mysql/mysql.conf.d


vim mysqld.cnf //进入mysql配置文件修改 bind-address为0.0.0.0,如果是子用户的话需要在前面加上sudo提权



cfcec072591444fac34759c185c0d71.png


3. 安装nvm管理node版本


sudo apt install https://raw.githubusercontent.com/creationix/nvm/v0.34.0/install.sh | bash

nvm --version // 查看是否正确输出

// 安装node版本
nvm install 19.1.0

// 查看是否正确输出
node --version
npm --version

4. 安装git并配置github


sudo apt install git

git --version // 查看输出版本

配置shh(这里我是直接一路Enter的)注意:这里要一定要使用以下指令生成ssh,后面有大用



①输入 ssh-keygen -m PEM -t rsa -b 4096,按enter;


②此后初次出现到②,出现的是保存密钥的路径,建议默认,按Enter;


③此时出现③,出现的提示是设置密码,千万不要设置!!!按Enter;


④此时出现④,出现的提示是再次输入密码,不要有任何输入,继续按Enter;



生成之后默认是在在服务器根目录下的.shh目录,这里直接运行以下指令


cd ~
cd .ssh
vim id_rsa.pub

进入id_rsa.pub文件复制公钥,到github的setting


66a36c9f2652d2f5a19a111b2064757.png
然后找到SSH and GPG keys去New SSH key,将公钥作为值保存就可以了
eb743773baf16231fe6d4a18ce3fbc7.jpg


5. 安装nginx并配置nginx.conf


sudo apt install nginx

// 安装完后启动nginx服务
sudo systemctl start nginx

// 设置开机自启动
sudo systemctl enable nginx

关于配置nginx,我一般每个nginx项目都会在conf.d目录单独写一个配置文件,方便后期更改,以下是我的个人博客的nginx配置,注意:conf.d里的配置文件后缀名必须是.conf才会生效


46d787353dfa50ecf76b09dfa1850d2.png



listen是监听的端口;
server name是服务器公网ip,也可以写域名;
root是前端项目所在地址;
index表示的是访问的index.html文件;
ry—_files这里是因为我vue项目打包用的history模式做的处理,hash模式可以忽略;



6. pm2的安装以及配置


npm install -g pm2

// 由于我项目使用了ts,并且没有去打包,所以我pm2也要安装ts-node
pm2 install ts-node

// 进入到后端项目的目录
cd /home/blog-end

// 初始化pm2文件
pm2 init // 运行之后会生成ecosystem.config.js配置文件

以下是我对pm2文件的配置,由于我是用了ts,所以我需要用到ts-node解释器,使用JavaScript的可以忽视interpreter属性


52360295a4677c6ac729236f5bd26a3.png


之后pm2 start econsystem.config.js运行配置文件就可以了


自动化部署


我自动化部署使用的技术是github actions,因为它简单容易上手,都是use轮子就完事了。下面跟我一起来做自动化部署


在开始自动化部署之前,我们还有一件大事要做,还记得之前生成ssh链接的时候说必须使用ssh-keygen -m PEM -t rsa -b 4096指令吗?现在就到了它表演的时候了,我们要用它配置ssh远程链接



先把.ssh目录下的id_rsa密钥复制到authorized_keys里,这一步就是配置远程ssh链接


然后配置sshd_config允许远程ssh链接,vim /etc/ssh/sshd_config,找到PermitRootLogin修改值为yes



b2018ae9ee0e125aa29f1a8d605f228.png


前端



进入自己的github项目地址,点击Actions去新建workflow,配置yml文件



9d0da5fcbaa38afac46d4876c15aac5.png



进入项目的setting里的Actions secrets and variables,创建secret



408b83ff0206b2973bbd7275ad7de80.png


后端



同样也是创建一个新的workflow,但服务端这里需要额外写一个脚本生成.env配置文件,因为服务端不可能把.env配置文件暴露到github的,那样特别不安全



script脚本


721c2b14136ed2436e8121c5b1c4b4c.png


yml配置文件


2e26f361e179354d35a163fbc593796.png


PS:觉得对自己有用或者文章还可以的话可以点个赞支持一下!!!


作者:辰眸
来源:juejin.cn/post/7299357353543368716
收起阅读 »

Rabbitmq消息大量堆积,我慌了!

背景 记得有次公司搞促销活动,流量增加,但是系统一直很平稳(我们开发的系统真牛),大家很开心的去聚餐,谈笑风声,气氛融洽,突然电话响起.... 运维:小李,你们系统使用的rabbitmq的消息大量堆积,导致服务器cpu飙升,赶紧回来看看,服务器要顶不住了 小...
继续阅读 »

背景


记得有次公司搞促销活动,流量增加,但是系统一直很平稳(我们开发的系统真牛),大家很开心的去聚餐,谈笑风声,气氛融洽,突然电话响起....



运维:小李,你们系统使用的rabbitmq的消息大量堆积,导致服务器cpu飙升,赶紧回来看看,服务器要顶不住了


小李:好的



系统架构描述


image.png


我们使用rabbitmq主要是为了系统解耦、异步提高系统的性能


前端售卖系统,生成订单后,推送订单消息到rabbitmq,订单履约系统作为消费者,消费订单消息落库,做后续操作


排查以及解决


方案一 增加消费者


第一我们想到的原因,流量激增,生成的订单速度远远大于消费者消费消息的速度,目前我们只部署了三个节点,那我们是否增加消费者,就可以解决这个问题,让消费者消费消息的速度远远大于生成者生成消息的速度,那消息就不存在堆积的问题,自然服务器压力也就下来了


通知运维,再部署三个点,也是就增加三个消费者,由原来的三个消费者变为6个消费者,信心满满的部署完成后,等待一段时间,不出意外还是出了意外,消息还是在持续堆积,没有任何改善,我心里那个急啊,为什么增加了消费者?一点改善没有呢


方案二 优化消费者的处理逻辑


持续分析,是不是消费者的逻辑有问题,处理速度还是慢?在消费逻辑分析中,发现在处理订单消息的逻辑里,调用了库存系统的一个接口,有可能是这个接口响应慢,导致消费的速度慢,跟不上生产消息的速度。


查看库存系统的运行情况,发现系统压力非常大,接口请求存在大量超时的情况,系统也在崩溃的边缘,因为我们上面的解决方案,增加了三个节点,间接的增大了并发。告知负责库存系统的同学,进行处理排查解决,但一时解决不了,如果持续这样,整体链路有可能全部崩掉,这怎么办呢?


消费者逻辑优化,屏蔽掉调用库存的接口,直接处理消息,但这种我们的逻辑是不完成,虽然能减少服务器的压力,后续处理起来也非常的麻烦,这种方式不可取


方案三 清空堆积的消息


为了减少消息的堆积,减轻服务器的压力,我们是否可以把mq里面的消息拿出来,先存储,等服务恢复后,再把存储的消息推送到mq,再处理呢?



  • 新建消费者,消费rabbitmq的消息,不做任何业务逻辑处理,直接快速消费消息,把消息存在一张表里,这样就没消息的堆积,服务器压力自然就下来了。


image.png
这方案上线后,过了一段时间观察,消息不再堆积,服务器的负载也下来了,我内心也不再慌了,那存储的那些消息,还处理吗?当然处理,怎么处理呢?



  • 后续等库存服务问题解决后,停掉新的消费者,新建一个生产者,再把表里的订单数据推送到rabbitmq,进行业务逻辑的处理


image.png


至此,问题就完美的解决了,悬着的心也放下了


问题产生的原因分析


整个链路服务一直都是很稳定的,因为流量的激增,库存服务的服务能力跟不上,导致整个链路出了问题,如果平台要搞促销这种活动,我们还是要提前评估下系统的性能,对整个链路做一次压测,找出瓶颈,该优化的要优化,资源不足的加资源


消息堆积为什么会导致cpu飙升呢?


问题虽然解决了,但我很好奇,消息堆积为什么会导致cpu飙升呢?


RabbitMQ 是一种消息中间件,用于在应用程序之间传递消息。当消息堆积过多时,可能会导致 CPU 飙升的原因有以下几点:



  1. 消息过多导致消息队列堆积:当消息的产生速度大于消费者的处理速度时,消息会积累在消息队列中。如果消息堆积过多,RabbitMQ 需要不断地进行消息的存储、检索和传递操作,这会导致 CPU 使用率升高。

  2. 消费者无法及时处理消息:消费者处理消息的速度不足以追赶消息的产生速度,导致消息不断积累在队列中。这可能是由于消费者出现瓶颈,无法处理足够多的消息,或者消费者的处理逻辑复杂,导致消费过程耗费过多的 CPU 资源。

  3. 消息重试导致额外的 CPU 开销:当消息处理失败时,消费者可能会进行消息的重试操作,尝试再次处理消息。如果重试频率较高,会导致消息在队列中频繁流转、被重复消费,这会增加额外的 CPU 开销。

  4. 过多的连接以及网络IO:当消息堆积过多时,可能会引发大量的连接请求和网络数据传输。这会增加网络 IO 的负载,并占用 CPU 资源。


通用的解决方案



  • 增加消费者:通过增加消费者的数量来提升消息的处理能力。增加消费者可以分担消息消费的负载,缓解消息队列的堆积问题。

  • 优化消费者的处理逻辑:检查消费者的代码是否存在性能瓶颈或是复杂的处理逻辑。可以通过优化算法、减少消费过程的计算量或是提高代码的效率来减少消费者的 CPU 开销。

  • 避免频繁的消息重试:当消息无法处理时,可以根据错误类型进行不同的处理方式,如将无法处理的消息转移到死信队列中或进行日志记录。避免频繁地对同一消息进行重试,以减少额外的 CPU 开销。

  • 调整 RabbitMQ 配置:可以调整 RabbitMQ 的参数来适应系统的需求,如增加内存、调整消息堆积的阈值和策略,调整网络连接等配置。

  • 扩展硬件资源:如果以上措施无法解决问题,可能需要考虑增加 RabbitMQ 的集群节点或者扩容服务器的硬件资源,以提升整个系统的处理能力。


需要根据具体情况综合考虑以上因素,并结合实际情况进行调试和优化,以解决消息堆积导致 CPU 飙升的问题,不能照葫芦画瓢,像我第一次直接增加消费者,差点把这个链路都干挂了



写作不易,刚好你看到,刚好对你有帮助,麻烦点点赞,有问题的留言讨论。



作者:柯柏技术笔记
来源:juejin.cn/post/7306442629318377535
收起阅读 »

很容易中招的一种索引失效场景,一定要小心

快过年,我的线上发布出现故障 “五哥,你在上线吗?”,旁边有一个声音传来。 “啊,怎么了?”。真是要命,在上线发布时候,我最讨厌别人叫我的名字 。我慌忙站起来,看向身后,原来是 建哥在问我。我慌忙的问,怎么回事。 “DBA 刚才在群里说,Task数据库 cpu...
继续阅读 »

快过年,我的线上发布出现故障


“五哥,你在上线吗?”,旁边有一个声音传来。


“啊,怎么了?”。真是要命,在上线发布时候,我最讨厌别人叫我的名字 。我慌忙站起来,看向身后,原来是 建哥在问我。我慌忙的问,怎么回事。


“DBA 刚才在群里说,Task数据库 cpu 负载增加!有大量慢查询”,建哥来我身边,跟我说。


慢慢的,我身边聚集着越来越多的人


image.png


“你在上线Task服务吗?改动什么内容了,看看要不要立即回滚?”旁边传来声音。此时,我的心开始怦怦乱跳,手心发痒,紧张不已。


我检查着线上机器的日志,试图证明报警的原因不是出在我这里。


我对着电脑,微微颤抖地回答大家:“我只是升级了基础架构的Jar包,其他内容没有改动啊。”此时我已分不清是谁在跟我说话,只能对着电脑作答……


这时DBA在群里发送了一条SQL,他说这条SQL导致了大量的慢查询。


我突然记起来了,我转过头问林哥:“林哥,你上线了什么内容?”这次林哥有代码的变更,跟我一起上线。我觉得可能是他那边有问题。


果然,林哥看着代码发呆。他嘟囔道:“我添加了索引啊,怎么会有慢查询呢?”原来慢查询的SQL是林哥刚刚添加的,这一刻我心里的石头放下了,问题并不在我,我轻松了许多。


“那我先回滚吧”,幸好我们刚发布了一半,现在回滚还来得及,我尝试回滚机器。此刻我的紧张情绪稍稍平静下来,手也不再发抖。


既然不是我的问题,我可以以吃瓜的心态,暗中观察事态的发展。我心想:真是吓死我了,幸好不是我的错。


然而我也有一些小抱怨:为什么非要和我一起搭车上线,出了事故,还得把我拖进来。


故障发生前的半小时


2年前除夕前的一周,我正准备着过年前的最后一次线上发布,这时候我刚入职两个月,自然而然会被分配一些简单的小活。这次上线的内容是将基础架构的Jar包升级到新版本。一般情况下,这种配套升级工作不会出问题,只需要按部就班上线就行。


“五哥,你是要上线 Task服务吗?”,工位旁的林哥问我,当时我正做着上线前的准备工作。


“对啊,马上要发布,怎么了?”,我转身回复他。


“我这有一个代码变更,跟你搭车一起上线吧,改动内容不太多。已经测试验证过了”,林哥说着,把代码变更内容发给我,简单和我说了下代码变更的内容。我看着改动内容确实不太多,新增了一个SQL查询,于是便答应下来。我重新打包,准备发布上线。


半小时以后,便出现了文章开头的情景。新增加的SQL 导致大量慢查询,数据库险些被打挂。


为什么加了索引,还会出现慢查询呢?


”加了索引,为什么还有慢查询?“,这是大家共同的疑问。


事后分析故障的原因,通过 mysql explain 命令,查看该SQL 确实没有命中索引,从而导致慢查询。


这个SQL 大概长这个样子!我去掉了业务相关的部分。


select * from order_discount_detail where orderId = 1123;


order_discount_detailorderId 这一列上确实加了索引,不应该出现慢查询,乍一看,没有什么问题。我本能的想到了索引失效的几种场景。难道是类型不匹配,导致索引失效?


果不其然, orderId 在数据库中的类型 是 varchar 类型,而传参是按照 long 类型传的。


复习一下: 类型转换导致索引失效


类型转换导致索引失效,是很容易犯的错误


因为在某些特殊场景下要对接外部订单,存在订单Id为字符串的情况,所以 orderId被设计成 varchar 字符串类型。然而出问题的场景比较明确,订单id 就是long类型,不可能是字符串类型。


所以林哥,他在使用Mybatis 时,直接使用 long 类型的 orderId字段传参,并且没有意识到两者数据类型不对。


因为测试环境数据量比较小,即使没有命中索引,也不会有很严重的慢查询,并且测试环境请求量比较低,该慢查询SQL 执行次数较少,所以对数据库压力不大,测试阶段一直没有发现性能问题。


直到代码发布到线上环境————数据量和访问量都非常高的环境,差点把数据库打挂。


mybatis 能避免 “类型转换导致索引失效” 的问题吗?


mybatis能自动识别数据库和Java类型不一致的情况吗?如果发现java类型和数据库类型不一致,自动把java 类型转换为数据库类型,就能避免索引失效的情况!


答案是不能。我没找到 mybatis 有这个能力。


mybatis 使用 #{} 占位符,会自动根据 参数的 Java 类型填充到 SQL中,同时可以避免SQL注入问题。


例如刚才的SQL 在 mybatis中这样写。


select * from order_discount_detail where orderId = #{orderId};


orderId 是 String 类型,SQL就变为


select * from order_discount_detail where orderId = ‘1123’;


mybatis 完全根据 传参的java类型,构建SQL,所以不要认为 mybatis帮你处理好java和数据库的类型差异问题,你需要自己关注这个问题!


再次提醒,"类型转换导致索引失效"的问题,非常容易踩坑。并且很难在测试环境发现性能问题,等到线上再发现问题就晚了,大家一定要小心!小心!


险些背锅


可能有朋友疑问,为什么发布一半时出现慢查询,单机发布阶段不能发现这个问题吗?


之所以没发现这个问题,是因为 新增SQL在 Kafka消费逻辑中,由于单机发布机器启动时没有争抢到 kafka 分片,所以没有走到新代码逻辑。


此外也没有遵循降级上线的代码规范,如果上线默认是降级状态,上线过程中就不会有问题。放量阶段可以通过降级开关快速止损,避免回滚机器过程缓慢而导致的长时间故障。


不是我的问题,为什么我也背了锅


因为我在发布阶段没有遵循规范,按照规定的流程应该在单机发布完成后进行引流压测。引流压测是指修改机器的Rpc权重,将Rpc请求集中到新发布的单机上,这样就能提前发现线上问题。


然而由于我偷懒,跳过了单机引流压测。由于发布的第一台机器没有抢占到Kafka分片,因此无法执行新代码逻辑。即使进行了单机引流压测,也无法提前发现故障。虽然如此,但我确实没有遵循发布规范,错在我。


如果上线时没有出现故障,这种不规范的上线流程可能不会受到责备。但如果出现问题,那只能怪我倒霉。在复盘过程中,我的领导抓住了这件事,给予了重点批评。作为刚入职的新人,被指责确实让我感到不舒服。


快要过年了,就因为搭车上线,自己也要承担别人犯错的后果,让我很难受。但是自己确实也有错,当时我的心情复杂而沉重。


两年前的事了,说出来让大家吃个瓜,乐呵一下。如果这瓜还行,东东发财的小手点个赞


作者:五阳神功
来源:juejin.cn/post/7305572311812636683
收起阅读 »

货拉拉App录制回放的探索与实践

作者简介:徐卓毅Joe,来自货拉拉/技术中心/质量保障部,专注于移动测试效能方向。 一、背景与目标 近些年货拉拉的业务持续高速发展,为了满足业务更短周期、更高质量交付的诉求,从今年开始我们的移动App的迭代交付模型也从双周演化为单周。因此,在一周一版的紧张节...
继续阅读 »

作者简介:徐卓毅Joe,来自货拉拉/技术中心/质量保障部,专注于移动测试效能方向。



一、背景与目标


近些年货拉拉的业务持续高速发展,为了满足业务更短周期、更高质量交付的诉求,从今年开始我们的移动App的迭代交付模型也从双周演化为单周。因此,在一周一版的紧张节奏下,随之而来的对测试质量保障的挑战也日益增加,首当其冲要解决的就是如何降低移动App每周版本回归测试的人力投入。


早期我们尝试过基于Appium框架编写UI自动化测试脚本,并且为了降低编写难度,我们也基于Appium框架进行了二次开发,但实践起来依然困难重重,主要原因在于:




  1. 上手和维护成本高



    • 需要掌握一定基础知识才能编写脚本和排查过程中遇到的问题;

    • 脚本编写+调试耗时长,涉及的元素定位+操作较多,调试要等待脚本执行回放才能看到结果;

    • 排查成本高,由于UI自动化测试的稳定性低,需投入排查的脚本较多,耗时长;

    • 维护成本高,每个迭代的需求改动都可能导致页面元素或链路调整,需不定期维护;




  2. 测试脚本稳定性低



    • 容易受多种因素(服务端环境、手机环境等)影响,这也造成了问题排查和溯源困难;

    • 脚本本身的稳定性低,模拟手工操作的方式,但实际操作点击没有那么智能;

      • 脚本识别元素在不同分辨率、不同系统版本上,识别的速度及准确度不同;

      • 不同设备在某些操作上表现,例如缩放(缩放多少)、滑动(滑动多少)有区别;

      • 由于功能复杂性、不同玩法的打断(如广告、弹窗、ab实验等);






所以,在App UI自动化测试上摸爬滚打一段时间后,我们积累了大量的踩坑经验。但这些经验也让我们更加明白,如果要大规模推行App UI自动化测试,必须要提高自动化ROI,否则很难达到预期效果,成本收益得不偿失。


我们的目标是打造一个低成本、高可用的App UI自动化测试平台。它需要满足如下条件:



  1. 更低的技术门槛:上手简单,无需环境配置;

  2. 更快的编写速度:无需查找控件,手机上操作后就能生成一条可执行的测试脚本;

  3. 更小的维护成本: 支持图像识别,减少由于控件改动导致的问题;

  4. 更高的稳定性: 回放识别通过率高,降低环境、弹窗的影响;

  5. 更好的平台功能: 支持脚本管理、设备调度、测试报告等能力,提升执行效率,降低排查成本;


二、行业方案


image.png


考虑到自动化ROI,我们基本确定要使用基于录制回放方式的自动化方案,所以我们也调研了美团、爱奇艺、字节、网易这几个公司的测试工具平台的实现方案:



  1. 网易Airtest是唯一对外发布的工具,但免费版本是IDE编写的,如果是小团队使用该IDE录制UI脚本来说还是比较方便的,但对于多团队协同,以及大规模UI自动化的实施的需求来说,其脚本管理、设备调度、实时报告等平台化功能的支持还不满足。

  2. 美团AlphaTest上使用的是App集成SDK的方式,可以通过底层Hook能力采集到操作数据、网络数据等更为详尽的内容,也提供了API支持业务方自定义实现,如果采用这种方案,移动研发团队的配合是很重要的。

  3. 爱奇艺的方案是在云真机的基础上,使用云IDE的方式进行录制,重点集成了脚本管理、设备调度、实时报告等平台化功能,这种方案的优势在于免去开发SDK的投入,可以做成通用能力服务于各业务App。

  4. 字节SmartEye也是采用集成SDK的方式,其工具本身更聚焦精准测试的能力建设,而精准测试当前货拉拉也在深入实践中,后续有机会我们再详细介绍。


综上分析,如果要继续推行App UI自动化测试,我们也需要自研测试平台,最好是能结合货拉拉现有的业务形态和能力优势,用最低的自研方案成本,快速搭建起适合我们的App录制回放测试平台,这样就能更快推动实践,降低业务测试当前面临的稳定性保障的压力。


三、能力建设


image.png


货拉拉现有的能力优势主要有:



  1. 货拉拉的云真机建设上已有成熟的经验(感兴趣的读者可参见文章《货拉拉云真机平台的演进与实践》);

  2. 货拉拉在移动App质效上已有深入实践,其移动云测平台已沉淀了多维度的自动化测试服务(如性能、兼容性、稳定性、健壮性、遍历、埋点等),具备比较成熟的平台能力。


因此,结合多方因素,最终我们选择了基于云真机开展App UI录制回放的方案,在借鉴其他公司优秀经验的基础上,结合我们对App UI自动化测试过程中积累的宝贵经验,打造了货拉拉App云录制回放测试平台。


下面我们会按录制能力、回放能力、平台能力三大部分进行介绍。


3.1 录制能力


录制流程从云真机的操作事件开始,根据里面的截图和操作坐标解析操作的控件,最终将操作转化为脚本里的单个步骤。并且支持Android和iOS双端,操作数据上报都是用旁路上报的方式,不会阻塞在手机上的操作。


image.png
下面是我们当前基于云真机录制的效果:



  在录制的过程中,其目标主要有:



  1. 取到当前操作的类型 点击、长按、输入、滑动等;

  2. 取到操作的目标控件 按钮、标签栏、文本框等;


3.1.1 云真机旁路上报&事件解析


  首先要能感知到用户在手机上做了什么操作,当我们在页面上使用云真机时,云真机后台可以监控到最原始的屏幕数据,不同操作的数据流如下:


// 点击
d 0 10 10 50
c
u 0
c
// 长按
d 0 10 10 50
c
<wait in your own code>
u 0
c
// 滑动
d 0 0 0 50
c
<wait in your own code> //需要拖拽加上等待时间
m 0 20 0 50
c
m 0 40 0 50
c
m 0 60 0 50
c
m 0 80 0 50
c
m 0 100 0 50
c
u 0
c

  根据协议我们可以判断每次操作的类型以及坐标,但仅依赖坐标的录制并不灵活,也不能实现例如断言一类的操作,所以拿到控件信息也非常关键。


  一般UI自动化中会dump出控件树,通过控件ID或层级关系定位控件。而dump控件树是一个颇为耗时的动作,普通布局的页面也需要2S左右。



  如果在录制中同时dump控件树,那我们每点击都要等待进度条转完,显然这不是我们想要的体验。而可以和操作坐标一起拿到的还有手机画面的视频流,虽然单纯的截图没有控件信息,但假如截图可以像控件树一样拆分出独立的控件区域,我们就可以结合操作坐标匹配对应控件。


3.1.2 控件/文本检测


  控件区域检测正是深度学习中的目标检测能解决的问题。


  这里我们先简单看一下深度学习的原理以及在目标检测过程中做了什么。


  深度学习原理



深度学习使用了一种被称为神经网络的结构。像人脑中的神经元一样,神经网络中的节点会对输入数据进行处理,然后将结果传递到下一个层级。这种逐层传递和处理数据的方式使得深度学习能够自动学习数据的复杂结构和模式。



  总的来说,深度学习网络逐层提取输入的特征,总结成更抽象的特征,将学习到的知识作为权重保存到网络中。


image.pngimage.png

举个例子,如果我们使用深度学习来学习识别猫的图片,那么神经网络可能会在第一层学习识别图片中的颜色或边缘,第二层可能会识别出特定的形状或模式,第三层可能会识别出猫的某些特征,如猫的眼睛或耳朵,最后,网络会综合所有的特征来确定这张图片是否是猫。


  目标检测任务


  目标检测是深度学习中的常见任务,任务的目标是在图像中识别并定位特定物体。


  在我们的应用场景中,任务的目标自然是UI控件:



  1. 识别出按钮、文本框等控件,可以归类为图标、图片和文本;

  2. 圈定控件的边界范围;


这里我们选用知名的YOLOX目标检测框架,社区里也开放许多了以UI为目标的预训练模型和数据集,因为除了自动化测试外,还有通过UI设计稿生成前端代码等应用场景。


roboflow公开数据集


  下图是使用公开数据集直接推理得到的控件区域,可以看出召回率不高。这是因为公开数据集中国外APP标注数据更多,且APP的UI风格不相似。


示例一示例二

预训练和微调模型


  而最终推理效果依赖数据集质量,这需要我们微调模型。由于目标数据集相似,所以我们只需要在预训练模型基础时,冻结骨干网络,重置最后输出层权重,喂入货拉拉风格的UI数据继续训练,可以得到更适用的模型。


model = dict (backbone=dict (frozen_stages=1 # 表示第一层 stage 以及它之前的所有 stage 中的参数都会被冻结 )) 


通过目标检测任务,我们可以拿到图标类的控件,控件的截图可以作为标识存储。当然,文本类的控件还是转化成文本存储更理想。针对文本的目标检测任务不仅精准度更高,还能提供目标文本的识别结果。我们单独用PaddleOCR再做了一次文本检测识别。


3.1.3 脚本生成


  所有操作最终都会转化为脚本储存,我们自定义了一种脚本格式用来封装不同的UI操作。


  以一次点击为例,操作类型用Click()表示;如果是点击图标类控件,会将图标的截图保存(以及录制时的屏幕相对坐标,用于辅助回放定位),而点击文案则是记录文本。



  操作消抖: 点击、长按和滑动之间通过设置固定的时长消除实际操作时的抖动,我们取系统中的交互动效时长,一般是200~300ms。


  文本输入: 用户实际操作输入文本时分为两种情况,一是进入页面时自动聚焦编辑框,另一种是用户主动激活编辑,都会拉起虚拟键盘。我们在回放时也需要在拉起键盘的情况下输入,才能真实还原键盘事件对页面的影响。


am broadcast -a ADB_INPUT_B64 --es msg "xxx"

  目标分组: 一个页面上可能有多个相同的图标或文案,所以在录制时会聚合相同分组,在脚本中通过下标index(0)区分。


3.2 回放能力


  回放脚本时,则是根据脚本里记录的控件截图和文本,匹配到回放手机上的目标区域,进而执行点击、滑动等操作。这里用到的图像和文本匹配能力也会用在脚本断言里。


image.png


回放效果见下图:



3.2.1 图像匹配


  与文本相比,图标类控件在回放时要应对的变化更多:



  • 颜色不同;

  • 分辨率不同

  • 附加角标等提示;


  在这种场景中,基于特征点匹配的SIFT算法很合适。



尺度不变特征变换(Scale-invariant feature transform, SIFT)是计算机视觉中一种检测、描述和匹配图像局部特征点的方法,通过在不同的尺度空间中检测极值点或特征点(Conrner Point, Interest Point),提取出其位置、尺度和旋转不变量,并生成特征描述子,最后用于图像的特征点匹配。



  对图像做灰度预处理之后能减少颜色带来的噪音,而SIFT的尺度不变特性容忍了分辨率变化,附加的角标不会影响关键特征点的匹配。


  除此之外,为了减低误匹配,我们增加了两个操作:


  RegionMask:在匹配之前,我们也做了控件检测,并作为遮罩层Mask设置到SIFT中,排除错误答案之后的特征点更集中稳定。



  屏蔽旋转不变性:因为不需要在页面上匹配旋转后的目标,所以我们将提取的特征点向量角度统一重置为0。


  sift.detect(image, kpVector, mask);
// 设置角度统一为0,禁用旋转不变性
for (int i = 0; i < kpVector.size(); i++) {
KeyPoint point = kpVector.get(i);
point.angle(0);
...
}
sift.compute(image, kpVector, ret);

3.2.2 文本匹配


  文本匹配很容易实现,在OCR之后做字符串比较可以得到结果。


  但是因为算法本身精准度并不是百分百(OCR识别算法CRNN精准度在80%),遇到长文案时会出现识别错误,我们通过计算与期望文本间的编辑距离容忍这种误差。



  但最常见的还是全角和半角字符间的识别错误,需要把标点符号作为噪音去除。


  还有另一个同样和长文案有关的场景:机型宽度不同时,会出现文案换行展示的情况,这时就不能再去完整匹配,但可以切换到xpath使用部分匹配


//*[contains(@text,'xxx')]

3.2.3 兜底弹窗处理


  突然出现的弹窗是UI自动化中的一大痛点,无论是时机和形式都无法预测,造成的结果是自动化测试中断。



  弹窗又分为系统弹窗和业务弹窗,我们有两种处理弹窗的策略:



  1. Android提供了一个DeviceOwner角色托管设备,并带有一个策略配置(PERMISSION_POLICY_AUTO_GRANT),测试过程中APP申请权限时天宫管家自动授予权限;




  1. 在自动化被中断时,再次检查页面有没有白名单中的弹窗文案,有则触发兜底逻辑,关闭弹窗后,恢复自动化执行。


3.2.4 自动装包授权


  Android碎片化带来的还有不同的装包验证策略,比如OPPO&VIVO系机型就需要输入密码才能安装非商店应用。


  为了保持云真机的环境纯净,我们没有通过获取ROOT授权的方式绕过,而是采用部署在云真机内置的装包助手服务适配了不同机型的装包验证。




3.2.5 数据构造&请求MOCK


  目前为止我们录制到的还只有UI的操作,但场景用例中缺少不了测试数据的准备。
  首先是测试数据构造,脚本中提供一个封装好的动作,调用内部平台数据工厂,通过传入和保存变量能在脚本间传递调用的数据。



  同时脚本还可以关联到APP-MOCK平台,在一些固定接口或特定场景MOCK接口响应。譬如可以固定AB实验配置,又或是屏蔽推送类的通知。



3.1 平台能力


3.3.1 用例编辑&管理


  有实践过UI自动化的人应该有这种感受,在个人电脑搭建一套自动化环境是相当费劲的,更不用说要同时兼顾Android和iOS。


  当前我们已经达成了UI自动化纯线上化这一个小目标,只需要在浏览器中就可以完成UI脚本的编辑、调试和执行。现在正完善更多的线上操作,以Monaco Editor为基础编辑器提供更方便的脚本编辑功能。


image.png


3.3.2 脚本组&任务调度


  为了方便管理数量渐涨的用例,我们通过脚本组的方式分模块组织和执行脚本。每个脚本组可以设置前后置脚本和使用的帐号类别,一个脚本组会作为最小的执行单元发送到手机上执行。



  我们可以将回归场景拆分成若干个组在多台设备上并发执行,大大缩短了自动化用例的执行时间。


四、效果实践


4.1 回归测试提效


App录制回放能力建设完毕后,我们立即在多个业务线推动UI自动化测试实践。我们也专门成立了一支虚拟团队,邀请各团队骨干加入,明确回归测试提效的目标,拉齐认知,统一节奏,以保障UI自动化的大规模实践的顺利落地。




  1. 建立问题同步及虚拟团队管理的相关制度,保障问题的快速反馈和快速解决。




  2. 制定团队的UI测试实践管理规范,指导全体成员按统一的标准去执行,主要包括:



    • 回归用例筛选:按模块维度进行脚本转化,优先覆盖P0用例(占比30%左右);

    • 测试场景设计:设计可以串联合并的场景,这样合并后可提升自动化执行速度;

    • 测试数据准备:自动化账号怎么管理,有哪些推荐的数据准备方案;

    • 脚本编写手册:前置脚本、公共脚本引入规范、断言规范等;

    • 脚本执行策略:脚本/脚本组管理及执行策略,怎样能执行的更快;




image.png


所以,我们在很短的时间内就完成了P0回归测试用例的转化,同时我们还要求:



  1. 回放通过率必须高于90%,避免给业务测试人员造成额外的干扰,增加排查工作量;

  2. 全量场景用例的执行总时长要小于90分钟,充分利用云真机的批量调度能力,快速输出测试报告。而且某种程度来说,还能避开因服务端部署带来的环境问题的影响;


截止目前,我们已经支持10多次单周版本的回归测试,已经可以替代部分手工回归测试工作量,降低测试压力的同时提升了版本发布质量的信心。


4.2 整体测试效能提升


在App UI自动化测试的实施取得突破性进展后,我们开始尝试优化原有性能、兼容、埋点等自动化测试遇到的一些问题,以提升移动App的整体测试效能。



  • App性能自动化测试: 原有的性能测试脚本都是使用基于UI元素定位的方式,每周的功能迭代都或多或少会影响到脚本的稳定性,所以我们的性能脚本早期每周都需要维护。而现在的性能测试脚本通过率一般情况下都是100%,极个别版本才会出现微调脚本的情况。

  • App深度兼容测试: 当涉及移动App测试时,兼容性测试的重要性不言而喻。移动云测平台在很早就已支持了标准兼容测试能力,即结合智能遍历去覆盖更多的App页面及场景,去发现一些基础的兼容测试问题。但随着App UI自动化测试的落地,现在我们已经可以基于大量的UI测试脚本在机房设备上开展深度兼容测试。


机房执行深度兼容测试


  • App 埋点 自动化测试: 高价值埋点的回归测试,以往我们都需要在回归期间去手工额外去触发操作路径,现在则基于UI自动化测试模拟用户操作行为,再结合移动云测平台已有的埋点自动校验+测试结果实时展示的能力,彻底解放人力,实现埋点全流程自动化测试。




  • 接入 CICD 流水线: 我们将核心场景的UI回归用例配CICD流水线中,每当代码合入或者触发构建后,都会自动触发验证流程,如果测试不通过,构建人和相关维护人都能立即收到消息通知,进一步提升了研发协同效率。


流程图 (3).jpg


五、未来展望



“道阻且长,行则将至,行而不辍,未来可期”。——《荀子·修身》



货拉拉App云录制回放测试平台的建设上,未来还有一些可提升的方向:



  1. 迭代优化模型,提升精准度和性能;

  2. 补全数据的录制回放,增加本地配置和缓存的控制;

  3. 探索使用AI大模型的识图能力,辨别APP页面上的UI异常;

  4. 和客户端精准测试结合,推荐未覆盖场景和变更相关用例;


作者:货拉拉技术
来源:juejin.cn/post/7306331307477794867
收起阅读 »

4 种消息队列,如何选型?

大家好呀,我是楼仔。 最近发现很多号主发消息队列的文章,质量参差不齐,相关文章我之前也写过,建议直接看这篇。 这篇文章,主要讲述 Kafka、RabbitMQ、RocketMQ 和 ActiveMQ 这 4 种消息队列的异同,无论是面试,还是用于技术选型,都有...
继续阅读 »

大家好呀,我是楼仔。


最近发现很多号主发消息队列的文章,质量参差不齐,相关文章我之前也写过,建议直接看这篇。


这篇文章,主要讲述 Kafka、RabbitMQ、RocketMQ 和 ActiveMQ 这 4 种消息队列的异同,无论是面试,还是用于技术选型,都有非常强的参考价值。


不 BB,上文章目录:



01 消息队列基础


1.1 什么是消息队列?


消息队列是在消息的传输过程中保存消息的容器,用于接收消息并以文件的方式存储,一个消息队列可以被一个也可以被多个消费者消费,包含以下 3 元素:



  • Producer:消息生产者,负责产生和发送消息到 Broker;

  • Broker:消息处理中心,负责消息存储、确认、重试等,一般其中会包含多个 Queue;

  • Consumer:消息消费者,负责从 Broker 中获取消息,并进行相应处理。



1.2 消息队列模式



  • 点对点模式:多个生产者可以向同一个消息队列发送消息,一个具体的消息只能由一个消费者消费。




  • 发布/订阅模式:单个消息可以被多个订阅者并发的获取和处理。



1.3 消息队列应用场景



  • 应用解耦:消息队列减少了服务之间的耦合性,不同的服务可以通过消息队列进行通信,而不用关心彼此的实现细节。

  • 异步处理:消息队列本身是异步的,它允许接收者在消息发送很长时间后再取回消息。

  • 流量削锋:当上下游系统处理能力存在差距的时候,利用消息队列做一个通用的”载体”,在下游有能力处理的时候,再进行分发与处理。

  • 日志处理:日志处理是指将消息队列用在日志处理中,比如 Kafka 的应用,解决大量日志传输的问题。

  • 消息通讯:消息队列一般都内置了高效的通信机制,因此也可以用在纯的消息通讯,比如实现点对点消息队列,或者聊天室等。

  • 消息广播:如果没有消息队列,每当一个新的业务方接入,我们都要接入一次新接口。有了消息队列,我们只需要关心消息是否送达了队列,至于谁希望订阅,是下游的事情,无疑极大地减少了开发和联调的工作量。


02 常用消息队列


由于官方社区现在对 ActiveMQ 5.x 维护越来越少,较少在大规模吞吐的场景中使用,所以我们主要讲解 Kafka、RabbitMQ 和 RocketMQ。


2.1 Kafka


Apache Kafka 最初由 LinkedIn 公司基于独特的设计实现为一个分布式的提交日志系统,之后成为 Apache 项目的一部分,号称大数据的杀手锏,在数据采集、传输、存储的过程中发挥着举足轻重的作用。


它是一个分布式的,支持多分区、多副本,基于 Zookeeper 的分布式消息流平台,它同时也是一款开源的基于发布订阅模式的消息引擎系统。


重要概念



  • 主题(Topic):消息的种类称为主题,可以说一个主题代表了一类消息,相当于是对消息进行分类,主题就像是数据库中的表。

  • 分区(partition):主题可以被分为若干个分区,同一个主题中的分区可以不在一个机器上,有可能会部署在多个机器上,由此来实现 kafka 的伸缩性。

  • 批次:为了提高效率, 消息会分批次写入 Kafka,批次就代指的是一组消息。

  • 消费者群组(Consumer Gr0up):消费者群组指的就是由一个或多个消费者组成的群体。

  • Broker: 一个独立的 Kafka 服务器就被称为 broker,broker 接收来自生产者的消息,为消息设置偏移量,并提交消息到磁盘保存。

  • Broker 集群:broker 集群由一个或多个 broker 组成。

  • 重平衡(Rebalance):消费者组内某个消费者实例挂掉后,其他消费者实例自动重新分配订阅主题分区的过程。


Kafka 架构


一个典型的 Kafka 集群中包含 Producer、broker、Consumer Gr0up、Zookeeper 集群。


Kafka 通过 Zookeeper 管理集群配置,选举 leader,以及在 Consumer Gr0up 发生变化时进行 rebalance。Producer 使用 push 模式将消息发布到 broker,Consumer 使用 pull 模式从 broker 订阅并消费消息。



Kafka 工作原理


消息经过序列化后,通过不同的分区策略,找到对应的分区。


相同主题和分区的消息,会被存放在同一个批次里,然后由一个独立的线程负责把它们发到 Kafka Broker 上。



分区的策略包括顺序轮询、随机轮询和 key hash 这 3 种方式,那什么是分区呢?


分区是 Kafka 读写数据的最小粒度,比如主题 A 有 15 条消息,有 5 个分区,如果采用顺序轮询的方式,15 条消息会顺序分配给这 5 个分区,后续消费的时候,也是按照分区粒度消费。



由于分区可以部署在多个不同的机器上,所以可以通过分区实现 Kafka 的伸缩性,比如主题 A 的 5 个分区,分别部署在 5 台机器上,如果下线一台,分区就变为 4。


Kafka 消费是通过消费群组完成,同一个消费者群组,一个消费者可以消费多个分区,但是一个分区,只能被一个消费者消费。



如果消费者增加,会触发 Rebalance,也就是分区和消费者需要重新配对


不同的消费群组互不干涉,比如下图的 2 个消费群组,可以分别消费这 4 个分区的消息,互不影响。



2.2 RocketMQ


RocketMQ 是阿里开源的消息中间件,它是纯 Java 开发,具有高性能、高可靠、高实时、适合大规模分布式系统应用的特点。


RocketMQ 思路起源于 Kafka,但并不是 Kafka 的一个 Copy,它对消息的可靠传输及事务性做了优化,目前在阿里集团被广泛应用于交易、充值、流计算、消息推送、日志流式处理、binglog 分发等场景。


重要概念



  • Name 服务器(NameServer):充当注册中心,类似 Kafka 中的 Zookeeper。

  • Broker: 一个独立的 RocketMQ 服务器就被称为 broker,broker 接收来自生产者的消息,为消息设置偏移量。

  • 主题(Topic):消息的第一级类型,一条消息必须有一个 Topic。

  • 子主题(Tag):消息的第二级类型,同一业务模块不同目的的消息就可以用相同 Topic 和不同的 Tag 来标识。

  • 分组(Gr0up):一个组可以订阅多个 Topic,包括生产者组(Producer Gr0up)和消费者组(Consumer Gr0up)。

  • 队列(Queue):可以类比 Kafka 的分区 Partition。


RocketMQ 工作原理


RockerMQ 中的消息模型就是按照主题模型所实现的,包括 Producer Gr0up、Topic、Consumer Gr0up 三个角色。


为了提高并发能力,一个 Topic 包含多个 Queue,生产者组根据主题将消息放入对应的 Topic,下图是采用轮询的方式找到里面的 Queue。


RockerMQ 中的消费群组和 Queue,可以类比 Kafka 中的消费群组和 Partition:不同的消费者组互不干扰,一个 Queue 只能被一个消费者消费,一个消费者可以消费多个 Queue。


消费 Queue 的过程中,通过偏移量记录消费的位置。



RocketMQ 架构


RocketMQ 技术架构中有四大角色 NameServer、Broker、Producer 和 Consumer,下面主要介绍 Broker。


Broker 用于存放 Queue,一个 Broker 可以配置多个 Topic,一个 Topic 中存在多个 Queue。


如果某个 Topic 消息量很大,应该给它多配置几个 Queue,并且尽量多分布在不同 broker 上,以减轻某个 broker 的压力。Topic 消息量都比较均匀的情况下,如果某个 broker 上的队列越多,则该 broker 压力越大。



简单提一下,Broker 通过集群部署,并且提供了 master/slave 的结构,salve 定时从 master 同步数据(同步刷盘或者异步刷盘),如果 master 宕机,则 slave 提供消费服务,但是不能写入消息。


看到这里,大家应该可以发现,RocketMQ 的设计和 Kafka 真的很像!


2.3 RabbitMQ


RabbitMQ 2007 年发布,是使用 Erlang 语言开发的开源消息队列系统,基于 AMQP 协议来实现。


AMQP 的主要特征是面向消息、队列、路由、可靠性、安全。AMQP 协议更多用在企业系统内,对数据一致性、稳定性和可靠性要求很高的场景,对性能和吞吐量的要求还在其次。


重要概念



  • 信道(Channel):消息读写等操作在信道中进行,客户端可以建立多个信道,每个信道代表一个会话任务。

  • 交换器(Exchange):接收消息,按照路由规则将消息路由到一个或者多个队列;如果路由不到,或者返回给生产者,或者直接丢弃。

  • 路由键(RoutingKey):生产者将消息发送给交换器的时候,会发送一个 RoutingKey,用来指定路由规则,这样交换器就知道把消息发送到哪个队列。

  • 绑定(Binding):交换器和消息队列之间的虚拟连接,绑定中可以包含一个或者多个 RoutingKey。


RabbitMQ 工作原理


AMQP 协议模型由三部分组成:生产者、消费者和服务端,执行流程如下:



  1. 生产者是连接到 Server,建立一个连接,开启一个信道。

  2. 生产者声明交换器和队列,设置相关属性,并通过路由键将交换器和队列进行绑定。

  3. 消费者也需要进行建立连接,开启信道等操作,便于接收消息。

  4. 生产者发送消息,发送到服务端中的虚拟主机。

  5. 虚拟主机中的交换器根据路由键选择路由规则,发送到不同的消息队列中。

  6. 订阅了消息队列的消费者就可以获取到消息,进行消费。



常用交换器


RabbitMQ 常用的交换器类型有 direct、topic、fanout、headers 四种,具体的使用方法,可以参考官网:


官网入口:https://www.rabbitmq.com/getstarted.html


03 消息队列对比



3.1 Kafka


优点:



  • 高吞吐、低延迟:Kafka 最大的特点就是收发消息非常快,Kafka 每秒可以处理几十万条消息,它的最低延迟只有几毫秒;

  • 高伸缩性:每个主题(topic)包含多个分区(partition),主题中的分区可以分布在不同的主机(broker)中;

  • 高稳定性:Kafka 是分布式的,一个数据多个副本,某个节点宕机,Kafka 集群能够正常工作;

  • 持久性、可靠性、可回溯: Kafka 能够允许数据的持久化存储,消息被持久化到磁盘,并支持数据备份防止数据丢失,支持消息回溯;

  • 消息有序:通过控制能够保证所有消息被消费且仅被消费一次;

  • 有优秀的第三方 Kafka Web 管理界面 Kafka-Manager,在日志领域比较成熟,被多家公司和多个开源项目使用。


缺点:



  • Kafka 单机超过 64 个队列/分区,Load 会发生明显的飙高现象,队列越多,load 越高,发送消息响应时间变长;

  • 不支持消息路由,不支持延迟发送,不支持消息重试;

  • 社区更新较慢。


3.2 RocketMQ


优点:



  • 高吞吐:借鉴 Kafka 的设计,单一队列百万消息的堆积能力;

  • 高伸缩性:灵活的分布式横向扩展部署架构,整体架构其实和 kafka 很像;

  • 高容错性:通过ACK机制,保证消息一定能正常消费;

  • 持久化、可回溯:消息可以持久化到磁盘中,支持消息回溯;

  • 消息有序:在一个队列中可靠的先进先出(FIFO)和严格的顺序传递;

  • 支持发布/订阅和点对点消息模型,支持拉、推两种消息模式;

  • 提供 docker 镜像用于隔离测试和云集群部署,提供配置、指标和监控等功能丰富的 Dashboard。


缺点:



  • 不支持消息路由,支持的客户端语言不多,目前是 java 及 c++,其中 c++ 不成熟

  • 部分支持消息有序:需要将同一类的消息 hash 到同一个队列 Queue 中,才能支持消息的顺序,如果同一类消息散落到不同的 Queue中,就不能支持消息的顺序。

  • 社区活跃度一般。


3.3 RabbitMQ


优点:



  • 支持几乎所有最受欢迎的编程语言:Java,C,C ++,C#,Ruby,Perl,Python,PHP等等;

  • 支持消息路由:RabbitMQ 可以通过不同的交换器支持不同种类的消息路由;

  • 消息时序:通过延时队列,可以指定消息的延时时间,过期时间TTL等;

  • 支持容错处理:通过交付重试和死信交换器(DLX)来处理消息处理故障;

  • 提供了一个易用的用户界面,使得用户可以监控和管理消息 Broker;

  • 社区活跃度高。


缺点:



  • Erlang 开发,很难去看懂源码,不利于做二次开发和维护,基本职能依赖于开源社区的快速维护和修复 bug;

  • RabbitMQ 吞吐量会低一些,这是因为他做的实现机制比较重;

  • 不支持消息有序、持久化不好、不支持消息回溯、伸缩性一般。


04 消息队列选型


Kafka:追求高吞吐量,一开始的目的就是用于日志收集和传输,适合产生大量数据的互联网服务的数据收集业务,大型公司建议可以选用,如果有日志采集功能,肯定是首选 kafka。


RocketMQ:天生为金融互联网领域而生,对于可靠性要求很高的场景,尤其是电商里面的订单扣款,以及业务削峰,在大量交易涌入时,后端可能无法及时处理的情况。RoketMQ 在稳定性上可能更值得信赖,这些业务场景在阿里双 11 已经经历了多次考验,如果你的业务有上述并发场景,建议可以选择 RocketMQ。


RabbitMQ:结合 erlang 语言本身的并发优势,性能较好,社区活跃度也比较高,但是不利于做二次开发和维护,不过 RabbitMQ 的社区十分活跃,可以解决开发过程中遇到的 bug。如果你的数据量没有那么大,小公司优先选择功能比较完备的 RabbitMQ。


ActiveMQ:官方社区现在对 ActiveMQ 5.x 维护越来越少,较少在大规模吞吐的场景中使用。


今天就聊到这里,我们下一篇见~~




最后,把楼仔的座右铭送给你:我从清晨走过,也拥抱夜晚的星辰,人生没有捷径,你我皆平凡,你好,陌生人,一起共勉。


原创好文:


作者:楼仔
来源:juejin.cn/post/7306322677039235108
收起阅读 »