注册
环信即时通讯云

环信即时通讯云

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

环信开发文档

Demo体验

Demo体验

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

RTE开发者社区

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

技术讨论区

技术交流、答疑
资源下载

资源下载

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

iOS Library

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

Android Library

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

日志脱敏之后,无法根据信息快速定位怎么办?

日志脱敏之殇 小明同学在一家金融公司上班,为了满足安全监管要求,最近天天忙着做日志脱敏。 无意间看到了一篇文章金融用户敏感数据如何优雅地实现脱敏? 感觉写的不错,用起来也很方便。 不过日志脱敏之后,新的问题就诞生了:日志脱敏之后,很多问题无法定位。 比如身-份...
继续阅读 »

日志脱敏之殇


小明同学在一家金融公司上班,为了满足安全监管要求,最近天天忙着做日志脱敏。


无意间看到了一篇文章金融用户敏感数据如何优雅地实现脱敏? 感觉写的不错,用起来也很方便。


不过日志脱敏之后,新的问题就诞生了:日志脱敏之后,很多问题无法定位。


比如身-份-证号日志中看到的是 3****************8,业务方给一个身-份-证号也没法查日志。这可怎么办?


在这里插入图片描述


安全与数据唯一性


类似于数据库中敏感信息的存储,一般都会有一个哈希值,用来定位数据信息,同时保障安全。


那么日志中是否也可以使用类似的方式呢?


说干就干,小明在开源项目 sensitive 基础上,添加了对应的哈希实现。


使用入门


开源地址



github.com/houbb/sensi…



使用方式


1)maven 引入


<dependency>
<groupId>com.github.houbb</groupId>
<artifactId>sensitive-core</artifactId>
<version>1.1.0</version>
</dependency>

2)引导类指定


SensitiveBs.newInstance()
.hash(Hashes.md5())

将哈希策略指定为 md5


3)功能测试


final SensitiveBs sensitiveBs = SensitiveBs.newInstance()
.hash(Hashes.md5());

User sensitiveUser = sensitiveBs.desCopy(user);
String sensitiveJson = sensitiveBs.desJson(user);

Assert.assertEquals(sensitiveStr, sensitiveUser.toString());
Assert.assertEquals(originalStr, user.toString());
Assert.assertEquals(expectJson, sensitiveJson);

可以把如下的对象


User{username='脱敏君', idCard='123456190001011234', password='1234567', email='12345@qq.com', phone='18888888888'}

直接脱敏为:


User{username='脱**|00871641C1724BB717DD01E7E5F7D98A', idCard='123456**********34|1421E4C0F5BF57D3CC557CFC3D667C4E', password='null', email='12******.com|6EAA6A25C8D832B63429C1BEF149109C', phone='1888****888|5425DE6EC14A0722EC09A6C2E72AAE18'}

这样就可以通过明文,获取对应的哈希值,然后搜索日志了。


新的问题


不过小明还是觉得不是很满意,因为有很多系统是已经存在的。


如果全部用注解的方式实现,就会很麻烦,也很难推动。


应该怎么实现呢?


小伙伴们有什么好的思路?欢迎评论区留言


作者:老马啸西风
来源:juejin.cn/post/7239647672460705829
收起阅读 »

尊嘟假嘟?三行代码提升接口性能600倍

一、背景   业务在群里反馈编辑结算单时有些账单明细查不出来,但是新建结算单可以,我第一反应是去测试环境试试有没有该问题,结果发现没任何问题!!!   然后我登录生产环境编辑业务反馈有问题的结算单,发现查询接口直接504网关超时了,此时心里已经猜到是代码性能问...
继续阅读 »

一、背景


  业务在群里反馈编辑结算单时有些账单明细查不出来,但是新建结算单可以,我第一反应是去测试环境试试有没有该问题,结果发现没任何问题!!!
  然后我登录生产环境编辑业务反馈有问题的结算单,发现查询接口直接504网关超时了,此时心里已经猜到是代码性能问题导致的,接来下就把重点放到排查接口超时的问题上了。


二、问题排查


遇到生产问题先查日志是基本操作,登录阿里云的日志平台,可以查到接口耗时竟然高达469245毫秒


这个结算单关联的账单数量也就800多条,所以可以肯定这个接口存在性能问题。


image


但是日志除了接口耗时,并没有其他报错信息或异常信息,看不出哪里导致了接口慢。


接口慢一般是由如下几个原因导致:



  1. 依赖的外部系统慢,比如同步调用外部系统的接口耗时比较久

  2. 处理的数据过多导致

  3. sql性能有问题,存在慢sql

  4. 有大循环存在循环处理的逻辑,如循环读取exel并处理

  5. 网络问题或者依赖的中间件比较慢

  6. 如果使用了锁,也可能由于长时间获取不到锁导致接口超时


当然也可以使用arthas的trace命令分析哪一块比较耗时。


由于安装arthas有点麻烦,就先猜测可能慢sql导致的,然后就登录阿里云RDS查看了慢sql监控日志。
image
好家伙一看吓一跳,sql耗时竟然高达66秒,而且执行次数还挺多!


我赶紧把sql语句放到数据库用explain命令看下执行计划,分析这条sql为啥这么慢。


EXPLAIN SELECT DISTINCT(bill_code) FROM `t_bill_detail_2023_4` WHERE  
(settlement_order_code IS NULL OR settlement_order_code = 'JS23122600000001');

分析结果如下:


image


如果不知道explain结果每个字段的含义,可以看看这篇文章《长达1.7万字的explain关键字指南!》。


可以看到扫描行数达到了250多万行,ref已经是最高效的const,但是看最后的Extra列
Using temporary 表明这个sql用到了临时表,顿时心里清楚什么原因了。


因为sql有个去重关键字DISTINCT,所以mysql在需要建临时表来完成查询结果集的去重操作,如果结果集数据量比较小没有超过buffer,就可以直接在内存中去重,这种效率也是比较高的。


但是如果结果集数据量很大,buffer存不下,那就需要借助磁盘完成去重了,我们都知道操作磁盘相比内存是非常慢的,时间差几个数量级


虽然这个表里的settlement_order_code字段是有索引的,但是线上也有很多settlement_order_code为null的数据,这就导致查出来的结果集非常大,然后又用到临时表,所以sql耗时才这么久!


同时,这里也解释了为什么测试环境没有发现这个问题,因为测试环境的数据不多,直接在内存就完成去重了。


三、问题解决


知道了问题原因就很好解决了,首先根据SQL和接口地址很快就找到出现问题的代码是下图红框圈出来的地方


image


可以看到代码前面有个判断,只有当isThreeOrderQuery=true时才会执行这个查询,判断方法代码如下


image


然后因为这是个编辑场景,前端会把当前结算单号(usedSettlementOrderCode字段)传给后端,所以这个方法就返回了true。


同理,拼接出来的sql就带了条件(settlement_order_code IS NULL OR settlement_order_code = 'JS23122600000001')。
image


解决起来也很简单,把isThreeOrderQuery()方法圈出来的代码去掉就行了,这样就不会执行那个查询,同时也不会影响原有的代码逻辑,因为后面会根据筛选条件再查一次t_bill_detail表。


改代码发布后,再编辑结算单,优化后的效果如下图:


image


只改了三行代码,接口耗时就立马从469245ms缩短到700ms,性能提升了600多倍


四、总结


感觉压测环境还是有必要的,有些问题数据量小了或者请求并发不够都没法暴露出来,同时以后写代码可以提前把sql在数据库explain下看看性能如何,毕竟能跑就行不是我们的追求😏。


作者:2YSP
来源:juejin.cn/post/7322156759443144713
收起阅读 »

Service 层异常抛到 Controller 层处理还是直接处理?

0 前言 一般初学者学习编码和[错误处理]时,先知道[编程语言]有一种处理错误的形式或约定(如Java就抛异常),然后就开始用这些工具。但却忽视这问题本质:处理错误是为了写正确程序。可是 1 啥叫“正确”? 由解决的问题决定的。问题不同,解决方案不同。 如一个...
继续阅读 »

0 前言


一般初学者学习编码和[错误处理]时,先知道[编程语言]有一种处理错误的形式或约定(如Java就抛异常),然后就开始用这些工具。但却忽视这问题本质:处理错误是为了写正确程序。可是


1 啥叫“正确”?


由解决的问题决定的。问题不同,解决方案不同。


如一个web接口接受用户请求,参数age,也许业务要求字段是0~150之间整数。如输入字符串或负数就肯定不接受。一般在后端某地做输入合法性检查,不过就抛异常。


但归根到底这问题“正确”解决方法总是要以某种形式提示用户。而提示用户是某种前端工作,就要看界面是app,H5+AJAX还是类似于[jsp]的服务器产生界面。不管啥,你要根据需求去”设计一个修复错误“的流程。如一个常见的流程要后端抛异常,然后一路到某个集中处理错误的代码,将其转换为某个HTTP的错误(业务错误码)提供给前端,前端再映射做”提示“。如用户输入非法请求,从逻辑上后端都没法自己修复,这是个“正确”的策略。


2 报500了嘞!


如用户上传一个头像,后端将图片发给[云存储],结果云存储报500,咋办?你可能想重试,因为也许仅是[网络抖动],重试就能正常执行。但若重试多次无效,若设计了某种热备方案,可能改为发到另一个服务器。“重试”和“使用备份的依赖”都是“立刻处理“。


但若重试无效,所有的[备份服务]也无效,也许就能像上面那样把错误抛给前端,提示用户“服务器开小差”。从这方案易看出,你想把错误抛到哪里是因为那个catch的地方是处理问题最方便的地方。一个问题的解决方案可能要几个不同的错误处理组合起来才能办到。


3 NPE了!


你的程序抛个NPE。这一般就是程序员的bug:



  • 要不就是程序员想表达一个东西”没有“,结果在后续处理中忘判断是否为null

  • 要不就是在写代码时觉得100%不可能为null的地方出现了一个null


不管哪种,这错误用户总会看到一个很含糊的报错信息,这远远不够。“正确”办法是程序员自己能尽快发现它,并尽快修复。要做到这点,需要[监控系统]不断爬log,把问题报警出来。而非等用户找客服投诉。


4 OOM了!


比如你的[后端程序]突然OOM挂了。挂的程序没法恢复自己。要做到“正确”,须在服务之外的容器考虑这问题。


如你的服务跑在[k8s],他们会监控你程序状态,然后重启新的服务实例弥补挂掉的服务,还得调整流量,把去往宕机服务的流量切换到新实例。这的恢复因为跨系统所以不能仅用异常实现,但道理一样。


但光靠重启就“正确”了?若服务是完全无状态,问题不大。但若有状态,部分用户数据可能被执行一半的请求搞乱。因此重启要留意先“恢复数据到合法状态”。这又回到你要知道咋样才是“正确”的做法。只依靠简单的语法功能不能无脑解决这事。


5 提升维度



  • 一个工作线程的“外部容器“是管理工作线程的“master”

  • 一个网络请求的“外部容器”是一个Web Server

  • 一个用户进程的“外部容器”是[操作系统]

  • Erlang把这种supervisor-worker的机制融入到语言的设计


Web程序很大程度能把异常抛给顶层,是因为:



  • 请求来自前端,对因为用户请求有误(数据合法性、权限、用户上下文状态)造成的问题,最终基本只能告诉用户。因此抛异常到一个集中处理错误的地方,把异常转换为某个业务错误码的方法,合理

  • 后端服务一般无状态。这也是软件系统设计的一般原则。无状态才意味着可随时随地安心重启。用户数据不会因为因为下一条而会出问题

  • 后端对数据的修改依赖DB的事务。因此一个改一半的、没提交的事务不会造成副作用。


但这3条件并非总成立。总能遇到:



  • 一些处理逻辑并非无状态

  • 也并非所有的数据修改都能用一个事务保护


尤其要注意对[微服务]的调用,对内存状态的修改是没有事务保护的,一不留神就会搞乱用户数据。比如下面代码段


6 难以排查的代码段


 try {
int res1 = doStep1();
this.status1 += res1;
int res2 = doStep2();
this.status2 += res2;
// 抛个异常
int res3 = doStep3();
this.status3 = status1 + status2 + res3;
} catch ( ...) {
// ...
}

先假设status1、status2、status3之间需维护某种不变的约束(invariant)。然后执行这段代码时,如在doStep3抛异常,下面对status3的赋值就不会执行。这时如不能将status1、status2的修改rollback,就会造成数据违反约束的问题。


而程序员很难发现这个数据被改坏了。坏数据还可能导致其他依赖这数据的代码逻辑出错(如原本应该给积分的,却没给)。而这种错误一般很难排查,从大量数据里找到不正确的那一小段何其困难。


7 更难搞定的代码段


// controller
void controllerMethod(/* 参数 */) {
try {
return svc.doWorkAndGetResult(/* 参数 */);
} catch (Exception e) {
return ErrorJsonObject.of(e);
}
}

// svc
void doWorkAndGetResult(/* some params*/) {
int res1 = otherSvc1.doStep1(/* some params */);
this.status1 += res1;
int res2 = otherSvc2.doStep2(/* some params */);
this.status2 += res2;
int res3 = otherSvc3.doStep3(/* some params */);
this.status3 = status1 + status2 + res3;
return SomeResult.of(this.status1, this.status2, this.status3);
}

难搞在于你写的时候可能以为doStep1~3这种东西即使抛异常也能被Controller里的catch。


在svc这层是不用处理任何异常,因此不写[try……catch]天经地义。但实际上doStep1、doStep2、doStep3任何一个抛异常都会造成svc的数据状态不一致。甚至你一开始都可以通过文档或其他沟通确定doStep1、doStep2、doStep3一开始都是必然可成功,不会抛错的,因此你写的代码一开始是对的。


但你可能无法控制他们的实现(如他们是另外一个团队开发的[jar]提供的),而他们的实现可能会改成抛错。你的代码可能在完全不自知情况下从“不会出问题”变成“可能出问题”…… 更可怕的类似代码不能正确工作:


void doWorkAndGetResult(/* some params*/) {
try {
int res1 = otherSvc1.doStep1(/* some params */);
this.status1 += res1;
int res2 = otherSvc2.doStep2(/* some params */);
this.status2 += res2;
int res3 = otherSvc3.doStep3(/* some params */);
this.status3 = status1 + status2 + res3;
return SomeResult.of(this.status1, this.status2, this.status3);
} catch (Exception e) {
// do rollback
}
}

你以为这样就会处理好数据rollback,甚至觉得这种代码优雅。但实际上doStep1~3每一个地方抛错,rollback的代码都不一样。


得这么写


void doWorkAndGetResult(/* some params*/) {
int res1, res2, res3;
try {
res1 = otherSvc1.doStep1(/* some params */);
this.status1 += res1;
} catch (Exception e) {
throw e;
}

try {
res2 = otherSvc2.doStep2(/* some params */);
this.status2 += res2;
} catch (Exception e) {
// rollback status1
this.status1 -= res1;
throw e;
}

try {
res3 = otherSvc3.doStep3(/* some params */);
this.status3 = status1 + status2 + res3;
} catch (Exception e) {
// rollback status1 & status2
this.status1 -= res1;
this.status2 -= res2;
throw e;
}
}

这才是得到正确结果的代码,在任何地方出错都能维护数据一致性。优雅吗?


看起来很丑。比go的if err != nil还丑。但要在正确性和优雅性取舍,肯定毫不犹豫选前者。作为程序员不能直接认为抛异常可解决任何问题,须学会写出有正确逻辑的程序,哪怕很难且看起来丑。


为达成高正确性,你不能总将自己大部分注意力放在“一切都OK的流程“,而把错误看作是可随便应付了事的工作或简单的相信exception可自动搞定一切。


8 总结


对错误处理要有敬畏之心:



  • Java因为Checked Exception设计问题不得不避免使用

  • 而Uncaughted Exception实在弱鸡,不能给程序员提供更好帮助


因此,程序员在每次抛错或者处理错误的时候都要三省吾身:



  • 这个错误的处理是正确吗?

  • 会让用户看到啥?

  • 会不会搞乱数据?


不要以为自己抛个异常就完事了。在[编译器]不能帮上太多忙时,好好写UT来保护代码可怜的正确性。


请多写正确的代码


作者:JavaEdge在掘金
来源:juejin.cn/post/7280050832949968954
收起阅读 »

线程数突增!领导说再这么写就gc掉我

线程数突增!领导说再这么写就gc掉我 前言 大家好,我是魔性的茶叶,今天给大家分享一个线上问题引出的一次思考,过程比较长,但是挺有意思。 今天上班把需求写完,出于学习(摸鱼)的心理上skywalking看看,突然发现我们的一个应用,应用内线程数超过900条,接...
继续阅读 »

线程数突增!领导说再这么写就gc掉我


前言


大家好,我是魔性的茶叶,今天给大家分享一个线上问题引出的一次思考,过程比较长,但是挺有意思。


今天上班把需求写完,出于学习(摸鱼)的心理上skywalking看看,突然发现我们的一个应用,应用内线程数超过900条,接近1000条,但是cpu并没有高涨,内存也不算高峰。但是敏锐的我还是立刻意识到这个应用有不妥,因为线程数太多了,不符合我们一个正常健康的应用数量。熟练的打出cpu dump观察,首先看线程组名的概览。


image-20230112200957387


从线程分组看,pool名开头线程占616条,而且waiting状态也是616条,这个点就非常可疑了,我断定就是这个pool开头线程池导致的问题。我们先排查为何这个线程池中会有600+的线程处于waiting状态并且无法释放,记接下来我们找几条线程的堆栈观察具体堆栈:


image-20230112201456234


这个堆栈看上去很合理,线程在线程池中不断的循环获取任务,因为获取不到任务所以进入了waiting状态,等待着有任务后被唤醒。


看上去不只一个线程池,并且这些线程池的名字居然是一样的,我大胆的猜测一下,是不断的创建同样的线程池,但是线程池无法被回收导致的线程数,所以接下来我们要分析两个问题,首先这个线程池在代码里是哪个线程池,第二这个线程池是怎么被创建的?为啥释放不了?


我在idea搜索new ThreadPoolExecutor()得到的结果是这样的:


image-20230112202915219


于是我陷入懵逼的状态,难道还有其他骚操作?


正在这时,一位不知名的郑网友发来一张截图:


image-20230112203527173


好家伙!竟然是用new FixedTreadPool()整出来的。难怪我完全搜不到,因为用的new FixedTreadPool(),所以线程池中的线程名是默认的pool(又多了一个不使用Executors来创建线程池的理由)。


然后我迫不及die的打开代码,试图找到罪魁祸首,结果发现作者居然是我自己。这是另一个惊喜,惊吓的惊。


冷静下来后我梳理一遍代码,这个接口是我两年前写的,主要是功能是统计用户的钱包每个月的流水,因为担心统计比较慢,所以使用了线程池,做了批量的处理,没想到居然导致了线程数过高,虽然没有导致事故,但是确实是潜在的隐患,现在没出事不代表以后不会出事。


去掉多余业务逻辑,我简单的还原一个代码给大家看,还原现场:


private static void threadDontGcDemo(){
      ExecutorService executorService = Executors.newFixedThreadPool(10);
      executorService.submit(() -> {
           System.out.println("111");
       });
   }

那么为啥线程池里面的线程和线程池都没释放呢


难道是因为没有调用shutdown?我大概能理解我两年前当时为啥不调用shutdown,是因为当初我觉得接口跑完,方法走到结束,理论上栈帧出栈,局部变量应该都销毁了,按理说executorService这个变量应该直接GG了,那么按理说我是不用调用shutdown方法的。


我简单的跑了个demo,循环的去new线程池,不调用shutdown方法,看看线程池能不能被回收


image-20230113142322106


打开java visual vm查看实时线程:


image-20230113142304644


可以看到线程数和线程池都一直在增加,但是一直没有被回收,确实符合发生的问题状况,那么假如我在方法结束前调用shutdown方法呢,会不会回收线程池和线程呢?


简单写个demo结合jvisualvm验证下:


image-20230113142902514


image-20230113142915722


结果是线程和线程池都被回收了。也就是说,执行了shutdown的线程池最后会回收线程池和线程对象


我们知道,一个对象能不能回收,是看它到gc root之间有没有可达路径,线程池不能回收说明到达线程池的gc root还是有可达路径的。这里讲个冷知识,这里的线程池的gc root是线程,具体的gc路径是thread->workers->线程池。线程对象是线程池的gc root,假如线程对象能被gc,那么线程池对象肯定也能被gc掉(因为线程池对象已经没有到gc root的可达路径了)。


那么现在问题就转为线程对象是在什么时候gc


郑网友给了一个粗浅但是合理的解释,线程对象肯定不是在运行中的时候被回收的,因为jvm肯定不可能去回收一条在运行中的线程,至少runnalbe状态的线程jvm不可能去回收。


在stackoverflow上我找到了更准确的答案:stackoverflow.com/questions/2…


image-20230113152802164


A running thread is considered a so called garbage collection root and is one of those things keeping stuff from being garbage collected。


这句话的意思是,一条正在运行的线程是gc root,注意,是正在运行,这个正在运行我先透露下,即使是waiting状态,也算正在运行。这个回答的整体的意思是,运行的线程是gc root,但是非运行的线程不是gc root(可以被回收)。


现在比较清楚了,线程池和线程被回收的关键就在于线程能不能被回收,那么回到原来的起点,为何调用线程池的shutdown方法能够导致线程和线程池被回收呢?难道是shutdown方法把线程变成了非运行状态吗


talk is cheap,show me the code


我们直接看看线程池的shutdown方法的源码


public void shutdown() {
       final ReentrantLock mainLock = this.mainLock;
       mainLock.lock();
       try {
           checkShutdownAccess();
           advanceRunState(SHUTDOWN);
           interruptIdleWorkers();
           onShutdown(); // hook for ScheduledThreadPoolExecutor
      } finally {
           mainLock.unlock();
      }
       tryTerminate();
}

private void interruptIdleWorkers() {
       interruptIdleWorkers(false);
}

private void interruptIdleWorkers(boolean onlyOne) {
       final ReentrantLock mainLock = this.mainLock;
       mainLock.lock();
       try {
           for (Worker w : workers) {
               Thread t = w.thread;
               if (!t.isInterrupted() && w.tryLock()) {
                   try {
                       t.interrupt();
                  } catch (SecurityException ignore) {
                  } finally {
                       w.unlock();
                  }
              }
               if (onlyOne)
                   break;
          }
      } finally {
           mainLock.unlock();
      }
}

我们从interruptIdleWorkers方法入手,这方法看上去最可疑,看到interruptIdleWorkers方法,这个方法里面主要就做了一件事,遍历当前线程池中的线程,并且调用线程的interrupt()方法,通知线程中断,也就是说shutdown方法只是去遍历所有线程池中的线程,然后通知线程中断。所以我们需要了解线程池里的线程是怎么处理中断的通知的。


我们点开worker对象,这个worker对象是线程池中实际运行的线程,所以我们直接看worker的run方法,中断通知肯定是在里面被处理了


//WOrker的run方法里面直接调用的是这个方法
final void runWorker(Worker w) {
       Thread wt = Thread.currentThread();
       Runnable task = w.firstTask;
       w.firstTask = null;
       w.unlock(); // allow interrupts
       boolean completedAbruptly = true;
       try {
           while (task != null || (task = getTask()) != null) {
               w.lock();
               // If pool is stopping, ensure thread is interrupted;
               // if not, ensure thread is not interrupted. This
               // requires a recheck in second case to deal with
               // shutdownNow race while clearing interrupt
               if ((runStateAtLeast(ctl.get(), STOP) ||
                    (Thread.interrupted() &&
                     runStateAtLeast(ctl.get(), STOP))) &&
                   !wt.isInterrupted())
                   wt.interrupt();
               try {
                   beforeExecute(wt, task);
                   Throwable thrown = null;
                   try {
                       task.run();
                  } catch (RuntimeException x) {
                       thrown = x; throw x;
                  } catch (Error x) {
                       thrown = x; throw x;
                  } catch (Throwable x) {
                       thrown = x; throw new Error(x);
                  } finally {
                       afterExecute(task, thrown);
                  }
              } finally {
                   task = null;
                   w.completedTasks++;
                   w.unlock();
              }
          }
           completedAbruptly = false;
      } finally {
           processWorkerExit(w, completedAbruptly);
      }
}



这个runwoker属于是线程池的核心方法了,相当的有意思,线程池能不断运作的原理就是这里,我们一点点看。


首先最外层用一个while循环套住,然后不断的调用gettask()方法不断从队列中取任务,假如拿不到任务或者任务执行发生异常(抛出异常了)那就属于异常情况,直接将completedAbruptly 设置为true,并且进入异常的processWorkerExit流程。


我们看看gettask()方法,了解下啥时候可能会抛出异常:


private Runnable getTask() {
       boolean timedOut = false; // Did the last poll() time out?

       for (;;) {
           int c = ctl.get();
           int rs = runStateOf(c);

           // Check if queue empty only if necessary.
           if (rs >= SHUTDOWN && (rs >= STOP || workQueue.isEmpty())) {
               decrementWorkerCount();
               return null;
          }

           int wc = workerCountOf(c);

           // Are workers subject to culling?
           boolean timed = allowCoreThreadTimeOut || wc > corePoolSize;

           if ((wc > maximumPoolSize || (timed && timedOut))
               && (wc > 1 || workQueue.isEmpty())) {
               if (compareAndDecrementWorkerCount(c))
                   return null;
               continue;
          }

           try {
               Runnable r = timed ?
                   workQueue.poll(keepAliveTime, TimeUnit.NANOSECONDS) :
                   workQueue.take();
               if (r != null)
                   return r;
               timedOut = true;
          } catch (InterruptedException retry) {
               timedOut = false;
          }
      }
  }

这样很清楚了,抛去前面的大部分代码不看,这句代码解释了gettask的作用:


Runnable r = timed ?
  workQueue.poll(keepAliveTime, TimeUnit.NANOSECONDS) :
  workQueue.take()

gettask就是从工作队列中取任务,但是前面还有个timed,这个timed的语义是这样的:如果allowCoreThreadTimeOut参数为true(一般为false)或者当前工作线程数超过核心线程数,那么使用队列的poll方法取任务,反之使用take方法。这两个方法不是重点,重点是poll方法和take方法都会让当前线程进入time_waiting或者waiting状态。而当线程处于在等待状态的时候,我们调用线程的interrupt方法,毫无疑问会使线程当场抛出异常


也就是说线程池的shutdownnow方法调用interruptIdleWorkers去对线程对象interrupt是为了让处于waiting或者是time_waiting的线程抛出异常


那么线程池是在哪里处理这个异常的呢?我们看runwoker中的调用的processWorkerExit方法,说实话这个方法看着就像处理抛出异常的方法:


private void processWorkerExit(Worker w, boolean completedAbruptly) {
       if (completedAbruptly) // If abrupt, then workerCount wasn't adjusted
           decrementWorkerCount();

       final ReentrantLock mainLock = this.mainLock;
       mainLock.lock();
       try {
           completedTaskCount += w.completedTasks;
           workers.remove(w);
      } finally {
           mainLock.unlock();
      }

       tryTerminate();

       int c = ctl.get();
       if (runStateLessThan(c, STOP)) {
           if (!completedAbruptly) {
               int min = allowCoreThreadTimeOut ? 0 : corePoolSize;
               if (min == 0 && ! workQueue.isEmpty())
                   min = 1;
               if (workerCountOf(c) >= min)
                   return; // replacement not needed
          }
           addWorker(null, false);
      }
}

我们可以看到,在这个方法里有一个很明显的 workers.remove(w)方法,也就是在这里,这个w的变量,被移出了workers这个集合,导致worker对象不能到达gc root,于是workder对象顺理成章的变成了一个垃圾对象,被回收掉了。然后等到worker中所有的worker都被移出works后,并且当前请求线程也完成后,线程池对象也成为了一个孤儿对象,没办法到达gc root,于是线程池对象也被gc掉了。


写了挺长的篇幅,我小结一下:



  1. 线程池调用shutdownnow方法是为了调用worker对象的interrupt方法,来打断那些沉睡中的线程(waiting或者time_waiting状态),使其抛出异常

  2. 线程池会把抛出异常的worker对象从workers集合中移除引用,此时被移除的worker对象因为没有到达gc root的路径已经可以被gc掉了

  3. 等到workers对象空了,并且当前tomcat线程也结束,此时线程池对象也可以被gc掉,整个线程池对象成功释放


最后总结:


如果只是在局部方法中使用线程池,线程池对象不是bean的情况时,记得要合理的使用shutdown或者shutdownnow方法来释放线程和线程池对象,如果不使用,会造成线程池和线程对象的堆积。


作者:魔性的茶叶
来源:juejin.cn/post/7197424371991855159
收起阅读 »

PageHelper引发的“幽灵数据”,怎么回事?

前言 最近测试反馈一个问题,某个查询全量信息的接口,有时候返回全量数据,符合预期,但是偶尔又只返回1条数据,简直就是“见鬼”了,究竟是为什么出现这样的“幽灵数据”呢? 大胆猜测 首先我们看了下这对代码的业务逻辑,非常的简单,总共没有几行代码,也没有分页逻辑,代...
继续阅读 »

前言


最近测试反馈一个问题,某个查询全量信息的接口,有时候返回全量数据,符合预期,但是偶尔又只返回1条数据,简直就是“见鬼”了,究竟是为什么出现这样的“幽灵数据”呢?


大胆猜测


首先我们看了下这对代码的业务逻辑,非常的简单,总共没有几行代码,也没有分页逻辑,代码如下:


public  List<SdSubscription> findAll() {
return sdSubscriptionMapper.selectAll();
}

那么究竟是咋回事呢?讲道理不可能出现这种情况的啊,不要慌,我们加点日志,将日志级别调整为DEBUG,让日志飞一段时间。


public  List<SdSubscription> findAll() {
log.info("find the sub start .....");
List<SdSubscription> subs = sdSubscriptionMapper.selectAll();
log.info("find the sub end .....");
return subs;
}

果不其然,日志中出现了奇奇怪怪的分页参数,如下图所示:



果然是PageHelper这个开源框架搞的鬼,我想大家都用过吧,分页非常方便,那么究竟为什么别人都没问题,单单就我会出现问题呢?


PageHelper工作原理


为了回答上面的疑问,我们先看看PageHelper框架的工作原理吧。


PageHelper 是一个开源的 MyBatis 分页插件,它可以帮助开发者在查询数据时,快速的实现分页功能。


PageHelper 的工作原理可以简单概括为以下几个步骤:



  1. 在需要进行分页的查询方法前,调用 PageHelper 的静态方法 startPage(),设置当前页码和每页显示的记录数。它会将分页信息放到线程的ThreadLocal中,那么在线程的任何地方都可以访问了。

  2. 当查询方法执行时,PageHelper 会自动拦截查询语句,如果发现线程的ThreadLocal中有分页信息,那么就会在其前后添加分页语句,例如 MySQL 中的 LIMIT 语句。

  3. 查询结果将被包装在 Page 对象中返回,该对象包含分页信息和查询结果列表。

  4. 在查询方法执行完毕后,会在finally中清除线程ThreadLocal中的分页信息,避免分页设置对其他查询方法的影响。


PageHelper 的实现原理主要依赖于拦截器技术和反射机制,通过拦截查询语句并动态生成分页语句,实现了简单、高效、通用的分页功能。具体源码在下图的类中,非常容易看懂。



明白了PageHelper的工作原理后,反复检查代码,都没有调用过startPagedebug查看ThreadLocal中也没有分页信息啊,懵逼中。那我看看别人写的添加分页参数的代码吧,不看不知道,一看吓一跳。



原来有位“可爱”的同事竟然在查询后,加了一个分页,就是把分页信息放到线程的ThreadLocal中。


那大家是不是有疑问,丁是丁,矛是矛,你的线程关我何事?这就要说到我们的tomcat了。


Tomcat请求流程


其实这就涉及到我们的tomcat相关知识了,我们一个浏览器发一个接口请求,经过我们的tomcat的,究竟是一个什么样的流程呢?



  1. 客户端发送HTTP请求到Tomcat服务器。

  2. TomcatHTTP连接器(Connector)接收到请求,将连接请求交给线程池Executor处理,解析它,然后将请求转发给对应的Web应用程序。

  3. Tomcat的Web应用程序容器(Container)接收到请求,根据请求的URL找到对应的Servlet


关于tomcat中使用线程池提交浏览器的连接请求的源码如下:



从而得知,你的连接请求是从线程池从拿的,而拿到的这个线程恰好是一个“脏线程”,在ThreadLocal中放了分页信息,导致你这边出现问题。


总结


后来追问了同事具体原因,才发现是粗心导致的。有些bug总是出现的莫名其妙,就像生活一样。所以关键的是我们在使用一些开源框架的时候一定要掌握底层实现的原理、核心的机制,这样才能够在解决一些问题的时候有据可循。



欢迎关注个人公众号【JAVA旭阳】交流学习!



作者:JAVA旭阳
来源:juejin.cn/post/7223590232730370108
收起阅读 »

该死,这次一定要弄懂什么是时间复杂度和空间复杂度!

开始首先,相信大家在看一些技术文章或者刷算法题的时候,总是能看到要求某某某程序(算法)的时间复杂度为O(n)或者O(1)等字样,就像这样: Q:那么到底这个O(n)、O(1)是什么意思呢?A:时间复杂度和空间复杂度其实是对算法执行期间的性能进行衡量的...
继续阅读 »

开始

首先,相信大家在看一些技术文章或者刷算法题的时候,总是能看到要求某某某程序(算法)的时间复杂度为O(n)或者O(1)等字样,就像这样:

image.png Q:那么到底这个O(n)、O(1)是什么意思呢?

A:时间复杂度空间复杂度其实是对算法执行期间的性能进行衡量的依据。

Talk is cheap, show me the code!

下面从代码入手,来直观的理解一下这两个概念:

时间复杂度

先来看看copilot如何解释的

image.png

  • 举个🌰
function fn (arr) {
let length = arr.length
for (let i = 0; i < length; i++) {
console.log(arr[i])
}
}

首先来分析一下这段代码,这是一个函数,接收一个数组,然后对这个数组进行了一个遍历

  1. 第一段代码,在函数执行的时候,这段代码只会被执行1次,这里记为 1 次
let length = arr.length
  1. 循环体中的代码,循环多少次就会执行多少次,这里记为 n 次
console.log(arr[i])
  1. 循环条件部分,首先是 let i = 0,只会执行一次,记为 1 次
  2. 然后是i < length这个判断,想要退出循环,这里最后肯定要比循环次数多判断一次,所以记为 n + 1 次
  3. 最后是 i++,会执行 n 次

我们把总的执行次数记为T(n)

T(n) = 1 + n + 1 (n + 1) + n = 3n + 3
  • 再来一个🌰
// arr 是一个二维数组
function fn2(arr) {
let lenOne = arr.length
for(let i = 0; i < lenOne; i++) {
let lenTwo = arr[i].length
for(let j = 0; j < lenTwo; j++) {
console.log(arr[i][j])
}
}
}

来分析一下这段代码,这是一个针对二维数组进行遍历的操作,我们再来分析一下这段代码的执行次数

  1. 第一行赋值代码,只会执行1次
let lenOne = arr.length
  1. 第一层循环,let i = 0 1次,i < lenOne n + 1 次,i++ n 次,let len_two = arr[i].length n 次
  2. 第二层循环,let j = 0 n 次,j < lenTwo n * (n + 1) 次,j++ n * n 次
  3. console n*n 次
T(n) = 1 + n + 1 + n + n + n + n * (n + 1) + n * n + n * n = 3n^2 + 5n + 3

代码的执行次数,可以反映出代码的执行时间。但是如果每次我们都逐行去计算 T(n),事情会变得非常麻烦。算法的时间复杂度,它反映的不是算法的逻辑代码到底被执行了多少次,而是随着输入规模的增大,算法对应的执行总次数的一个变化趋势。我们可以尝试对 T(n) 做如下处理:

  • 若 T(n) 是常数,那么无脑简化为1
  • 若 T(n) 是多项式,比如 3n^2 + 5n + 3,我们只保留次数最高那一项,并且将其常数系数无脑改为1。

那么上面两个算法的时间复杂度可以简化为:

T(n) = 3n + 3
O(n) = n

T(n) = 3n^2 + 5n + 3
O(n) = n^2

实际推算时间复杂度时不用这么麻烦,像上面的两个函数,第一个是规模为n的数组的遍历,循环会执行n次,所以对应的时间幅度是O(n),第二个函数是 n*n的二维数组的遍历,对应的时间复杂度就是O(n^2) 依次类推,规模为n*m的二维数组的遍历,时间复杂度就是O(n*m)

常见的时间复杂度按照从小到大的顺序排列,有以下几种:

常数时间对数时间线性时间线性对数时间二次时间三次时间指数时间
O(1)O(logn)O(n)O(nlogn)O(n^2)O(n^3)O(2^n)

空间复杂度

先看看copilot的解释:

image.png

  • 来一个🌰看看吧:
function fn (arr) {
let length = arr.length
for (let i = 0; i < length; i++) {
console.log(arr[i])
}
}

在函数fn中,我们创建了变量 length arr i,函数 fn 对内存的占用量是固定的,无论,arr的length如何,所以这个函数对应的空间复杂度就是 O(1)

  • 再来一个🌰:
function fn2(n) {
let arr = []
for(let i = 0; i < n; i++) {
arr[i] = i
}
}

在这个函数中,我们创建了一个数组 arr,并在循环中向 arr 中添加了 n 个元素。因此,arr 的大小与输入 n 成正比。所以,我们说这个函数的空间复杂度是 O(n)。

  • 再再来一个🌰:
function createMatrix(n) {
let matrix = [];
for (let i = 0; i < n; i++) {
matrix[i] = [];
for (let j = 0; j < n; j++) {
matrix[i][j] = 0;
}
}
return matrix;
}

在这个函数中,我们创建了一个二维数组 matrix,并在两层循环中向 matrix 中添加了 n*n 个元素。因此,matrix 的大小与输入 n 的平方成正比。所以,我们说这个函数的空间复杂度是 O(n^2)。

  • 再再再来一个🌰:
// 二分查找算法
function binarySearch(arr, target, low, high) {
if (low > high) {
return -1;
}
let mid = Math.floor((low + high) / 2);
if (arr[mid] === target) {
return mid;
} else if (arr[mid] > target) {
return binarySearch(arr, target, low, mid - 1);
} else {
return binarySearch(arr, target, mid + 1, high);
}
}

在二分查找中,我们每次都将问题规模减半,因此需要的额外空间与输入数据的对数成正比,我们开始时有一个大小为 n 的数组。然后,我们在每一步都将数组划分为两半,并只在其中一半中继续查找。因此,每一步都将问题的规模减半

所以,最多要划分多少次才能找到目标数据呢?答案是log2n次,但是在计算机科学中,当我们说 log n 时,底数通常默认为 2,因为许多算法(如二分查找)都涉及到将问题规模减半的操作。

2^x = n

x = log2n

常见的时间复杂度按照从小到大的顺序排列,有以下几种:

常数空间线性空间平方空间对数空间
O(1)O(n)O(n^2)O(logn)

你学废了吗?


作者:爱吃零食的猫
来源:juejin.cn/post/7320288222529536038

收起阅读 »

为什么mysql最好不要只用limit做分页查询?

在项目中遇到的真实问题,以及我的解决方案,部分数据做了脱敏处理。 问题 最近在做项目时需要写sql做单表查询,每次查出来的数据有几百万甚至上千万条,公司用的数据库是MySQL5.7,做了分库分表,部分数据库设置了查询超时时间,比如查询超过15s直接报超时错误,...
继续阅读 »

在项目中遇到的真实问题,以及我的解决方案,部分数据做了脱敏处理。


问题


最近在做项目时需要写sql做单表查询,每次查出来的数据有几百万甚至上千万条,公司用的数据库是MySQL5.7,做了分库分表,部分数据库设置了查询超时时间,比如查询超过15s直接报超时错误,如下图:


image.png


可以通过show variables like 'max_statement_time';命令查看数据库超时时间(单位:毫秒):


image.png


方案1


尝试使用索引加速sql,从下图可以看到该sql已经走了主键索引,但还是需要扫描150万行,无法从这方面进行优化。


image.png


方案2


尝试使用limit语句进行分页查询,语句为:


SELECT * FROM table WHERE user_id = 123456789 limit 0, 300000;

像这样每次查30万条肯定就不会超时了,但这会引出另一个问题--查询耗时与起始位置成正比,如下图:


image.png


第二条语句实际上查了60w条记录,不过把前30w条丢弃了,只返回后30w条,所以耗时会递增,最终仍会超时。


方案3


使用指定主键范围的分页查询,主要思想是将条件语句改为如下形式(其中id为自增主键):


WHERE user_id = 123456789 AND id > 0 LIMIT 300000;
WHERE user_id = 123456789 AND id > (上次查询结果中最后一条记录的id值) LIMIT 300000;

也可以将上述语句简化成如下形式(注意:带了子查询会变慢):


WHERE user_id = 123456789 AND id >= (SELECT id FROM table LIMIT 300000, 1) limit 300000;

每次查询只需要修改子查询limit语句的起始位置即可,但我发现表中并没有自增主键id这个字段,表内主键是fs_id,而且是无序的。


这个方案还是不行,组内高工都感觉无解了。


方案4


既然fs_id是无序的,那么就给它排序吧,加了个ORDER BY fs_id,最终解决方案如下:


WHERE user_id = 123456789 AND fs_id > 0 ORDER BY fs_id LIMIT 300000;
WHERE user_id = 123456789 AND fs_id > (上次查询结果中最后一条记录的id值) ORDER BY fs_id LIMIT 300000;

效果如下图:


image.png


查询时间非常稳定,每条查询的fs_id都大于上次查询结果中最后一条记录的fs_id值。正常查30w条需要3.88s,排序后查30w条需要6.48s,确实慢了许多,但总算能把问题解决了。目前代码还在线上跑着哈哈,如果有更好的解决方案可以在评论区讨论哟。


作者:我要出去乱说
来源:juejin.cn/post/7209612932366270519
收起阅读 »

都用HTTPS了,还能被查出浏览记录?

最近,群里一个刚入职的小伙因为用公司电脑访问奇怪的网站,被约谈了。他很困惑 —— 访问的都是HTTPS的网站,公司咋知道他访问了啥? 实际上,由于网络通信有很多层,即使加密通信,仍有很多途径暴露你的访问地址,比如: DNS查询:通常DNS查询是不会加密的,...
继续阅读 »

最近,群里一个刚入职的小伙因为用公司电脑访问奇怪的网站,被约谈了。他很困惑 —— 访问的都是HTTPS的网站,公司咋知道他访问了啥?



实际上,由于网络通信有很多层,即使加密通信,仍有很多途径暴露你的访问地址,比如:



  • DNS查询:通常DNS查询是不会加密的,所以,能看到你DNS查询的观察者(比如运营商)是可以推断出访问的网站

  • IP地址:如果一个网站的IP地址是独一无二的,那么只需看到目标 IP地址,就能推断出用户正在访问哪个网站。当然,这种方式对于多网站共享同一个IP地址(比如CDN)的情况不好使

  • 流量分析:当访问一些网站的特定页面,可能导致特定大小和顺序的数据包,这种模式可能被用来识别访问的网站

  • cookies或其他存储:如果你的浏览器有某个网站的cookies,显然这代表你曾访问过该网站,其他存储信息(比如localStorage)同理


除此之外,还有很多方式可以直接、间接知道你的网站访问情况。


本文将聚焦在HTTPS协议本身,聊聊只考虑HTTPS协议的情况下,你的隐私是如何泄露的。


HTTPS简介


我们每天访问的网站大部分是基于HTTPS协议的,简单来说,HTTPS = HTTP + TLS,其中:



  • HTTP是一种应用层协议,用于在互联网上传输超文本(比如网页内容)。由于HTTP是明文传递,所以并不安全

  • TLS是一种安全协议。TLS在传输层对数据进行加密,确保任何敏感信息在两端(比如客户端和服务器)之间安全传输,不被第三方窃取或篡改


所以理论上,结合了HTTPTLS特性的HTTPS,在数据传输过程是被加密的。但是,TLS建立连接的过程却不一定是加密的。


TLS的握手机制


当我们通过TLS传递加密的HTTP信息之前,需要先建立TLS连接,比如:



  • 当用户首次访问一个HTTPS网站,浏览器开始查询网站服务器时,会发生TLS连接

  • 当页面请求API时,会发生TLS连接


建立连接的过程被称为TLS握手,根据TLS版本不同,握手的步骤会有所区别。



但总体来说,TLS握手是为了达到三个目的:



  1. 协商协议和加密套件:通信的两端确认接下来使用的TLS版本及加密套件

  2. 验证省份:为了防止“中间人”攻击,握手过程中,服务器会向客户端发送其证书,包含服务器公钥和证书授权中心(即CA)签名的身份信息。客户端可以使用这些信息验证服务器的身份

  3. 生成会话密钥:生成用于加密接下来数据传输的密钥


TLS握手机制的缺点


虽然TLS握手机制会建立安全的通信,但在握手初期,数据却是明文发送的,这就造成隐私泄漏的风险。


在握手初期,客户端、服务端会依次发送、接收对方的打招呼信息。首先,客户端会向服务端打招呼(发送client hello信息),该消息包含:



  • 客户端支持的TLS版本

  • 支持的加密套件

  • 一串称为客户端随机数client random)的随机字节

  • SNI等一些服务器信息


服务端接收到上述消息后,会向客户端打招呼(发送server hello消息),再回传一些信息。


其中,SNIServer Name Indication,服务器名称指示)就包含了用户访问的网站域名。


那么,握手过程为什么要包含SNI呢?


这是因为,当多个网站托管在一台服务器上并共享一个IP地址,且每个网站都有自己的SSL证书时,那就没法通过IP地址判断客户端是想和哪个网站建立TLS连接,此时就需要域名信息辅助判断。


打个比方,快递员送货上门时,如果快递单只有收货的小区地址(IP地址),没有具体的门牌号(域名),那就没法将快递送到正确的客户手上(与正确的网站建立TLS连接)。


所以,SNI作为TLS的扩展,会在TLS握手时附带上域名信息。由于打招呼的过程是明文发送的,所以在建立HTTPS连接的过程中,中间人就能知道你访问的域名信息。


企业内部防火墙的访问控制和安全策略,就是通过分析SNI信息完成的。



虽然防火墙可能已经有授信的证书,但可以先分析SNI,根据域名情况再判断要不要进行深度检查,而不是对所有流量都进行深度检查



那么,这种情况下该如何保护个人隐私呢?


Encrypted ClientHello


Encrypted ClientHelloECH)是TLS1.3的一个扩展,用于加密Client Hello消息中的SNI等信息。


当用户访问一个启用ECH的服务器时,网管无法通过观察SNI来窥探域名信息。只有目标服务器才能解密ECH中的SNI,从而保护了用户的隐私。



当然,对于授信的防火墙还是不行,但可以增加检查的成本



开启ECH需要同时满足:



  • 服务器支持TLSECH扩展

  • 客户端支持ECH


比如,cloudflare SNI测试页支持ECH扩展,当你的浏览器不支持ECH时,访问该网站sni会返回plaintext



对于chrome,在chrome://flags/#encrypted-client-hello中,配置ECH支持:



再访问上述网站,sni如果返回encrypted则代表支持ECH


总结


虽然HTTPS连接本身是加密的,但在建立HTTPS的过程中(TLS握手),是有数据明文传输的,其中SNI中包含了服务器的域名信息。


虽然SNI信息的本意是解决同一IP下部署多个网站,每个网站对应不同的SSL证书,但也会泄漏访问的网站地址


ECH通过对TLS握手过程中的敏感信息(主要是SNI)进行加密,为用户提供了更强的隐私保护。


作者:魔术师卡颂
来源:juejin.cn/post/7264753569834958908
收起阅读 »

请给系统加个【消息中心】功能,因为真的很简单

我相信,打开一个带有社交类型的网站,你或多或少都可以看到如下的界面: 1)消息提示 2)消息列表 这样 这样 那,这就是我们今天要聊的【消息中心】。 1、设计 老规矩先来搞清楚消息中心的需求,再来代码实现。 我们知道在社交类项目中,有很多评论、点赞等数据...
继续阅读 »

我相信,打开一个带有社交类型的网站,你或多或少都可以看到如下的界面:


1)消息提示


Snipaste_2023-08-27_13-41-36.jpg


2)消息列表


这样


Snipaste_2023-08-27_13-42-25.jpg


这样


Snipaste_2023-08-27_16-41-30.jpg


那,这就是我们今天要聊的【消息中心】。


1、设计


老规矩先来搞清楚消息中心的需求,再来代码实现。


我们知道在社交类项目中,有很多评论、点赞等数据的产生,而如果这些数据的产生不能让用户感知到,那你们想想这会带来什么影响?



用户A:太鸡肋了,发布的内容被人评论点赞了,我居然看不到,下次不用了...


用户B:还好没用这个系统...



所以,看到这些结果我们是不是能够意识到一个健全的社交功能,是不是少不了这种通知用户的机制啊!而这种机制我就把他定义为【消息中心】功能。


再来拆分一下这四个字:消息中心



  1. 消息

  2. 中心


消息:这个可以是由我们自己定义,如:把帖子被用户评论当作一条消息,把评论被用户点赞也可以当作一条消息,甚至系统发布的通知也是一条消息。


中心:这个就是字面意思,将上面所提到的所有消息,归拢到一个地方进行展示。


上面我们也提到消息基本就是这两种:



  • 用户对用户:用户消息

  • 平台对用户:系统消息


针对用户消息,就类似这样,用户 A 给用户 B 的一条评论进行了点赞,那这个点赞动作就会产生一条消息,并且通知到用户 B 的一个存储消息的地方,这里通常就指用户的收件箱。这个收件箱就是专门用来存储用户发给用户的消息,而这个点对点的模式是不是就是推送模式啊!(A 推送消息给 B)


接着针对系统消息,就类似这样,平台管理人员发布了一条通知,告诉大家平台有啥 XXX 活动。那这个活动通知肯定是要让平台的所有用户都知道把,所以这个通知就要存在一个发件箱中。这个发件箱就是专门存储平台的通知,所有用户都来这个发件箱中读取消息就行,而这个一对多的模式是不是就是拉取模式啊!(所有用户都来拉取平台消息)


这样一来,我们根据不同的消息场景就抽出了一个基本的消息推拉模型,模型图如下:



Snipaste_2023-08-27_14-27-25.jpg



Snipaste_2023-08-27_14-59-50.jpg


针对这两种模式,不知道大家有没有看出区别,好像乍一看没啥区别,都是发消息,读消息,对吧!


没错,确实都是一个发,一个读,但是两者的读写频率确实有着巨大的差异。先来看推模型,一个普通用户发表了一条帖子,然后获得了寥寥无几的评论和赞,这好似也没啥特别之处,对吧!那如果这个普通用户发表的帖子成为了热门帖子呢,也即该贴子获得了上万的评论和赞。那,你们想想是不是发消息的频率非常高,而该普通用户肯定是不可能一下子读取这么多消息的,所以是不是一个写多读少的场景。再来看看拉模型,如果你的平台用户人数寥寥无几,那倒没啥特别之处,但如果用户人数几万甚至几十万。那,每个用户都过来拉取系统消息是不是就是一个读频率非常高,而发消息频率非常低(系统消息肯定不会发的很快),所以这是不是一个读多写少的场景。


1.1 推:写多读少


针对这个模式,我们肯定是要将写这个动作交给性能更高的中间件来处理,而不是 MySQL,所以此时我们的 RocketMQ 就出来了。


当系统中产生了评论、点赞类的高频消息,那就无脑的丢给 MQ 吧,让其在消息中间件中呆会,等待消费者慢慢的将消息进行消费并发到各个用户的收件箱中,就类似下面这张图的流程:


Snipaste_2023-08-27_15-45-46.jpg


2.2 拉:读多写少


那对于这个模式,所实话,我觉得不用引入啥就可以实现,因为对于读多的话无非就是一个查,MySQL 肯定是能搞定的,即使你的用户几万、几十万都是 ok 的。


但咱们是不是可以这样想一下,一个系统的官方通知肯定是不多的,或者说几天或者几个星期一次,且一旦发送就不可更改。那是不是可以考虑缓存,让用户读取官方通知的时候走缓存,如果缓存没有再走 MySQL 这样应该是可以提高查询效率,提高响应速度。


具体流程如下图:


Snipaste_2023-08-27_15-57-21.jpg


2.3 表结构设计


基本的业务流程已经分析的差不多了,现在可以把表字段抽一下了,先根据上面分析的,看看我们需要那些表:



  1. 用户收件箱表

  2. 系统发件箱表


看似好像就这两张表,但是应该还有第三张表:



  1. 用户读取系统消息记录表



我们看到页面是不是每次有一条新的消息都会有一个小标点记录新消息数量,而第三张表就是为了这个作用而设计的。


具体原理如下:



  1. 首先运营人员发布的消息都是存储在第二张表中,这肯定是没错的

  2. 那用户每次过来拉取系统消息时,将最近拉取的一条消息写入到第三种表中

  3. 这样等用户下次再来拉取的时候,就可以根据第三张表的读取记录,来确定他有几条系统消息未查看了


可能有人会发出疑问:那用户的收件箱为啥不出一个用户读取记录表呢!


这个很简单,因为收件箱中的数据已经表示这个用户需要都这些个消息了,只是不知道那些是已读的那些是未读的,我们只需要再收件箱表中加一个字段,这个字段的作用就是记录最新一次读取的消息 ID 就行,等下次要读消息时,找到上传读取读取消息的记录ID,往后读新消息即可。



好,现在来看看具体的表字段:


1)用户收件箱表(sb_user_inbox)



  • id

  • 消息数据唯一 id:MQ唯一消息凭证

  • 消息类型:评论消息或者点赞消息

  • 帖子id:业务id

  • 业务数据id:业务id

  • 内容:消息内容

  • 业务数据类型:业务数据类型(商品评论、帖子、帖子一级评论、帖子二级评论)

  • 发起方的用户ID:用户 A 对用户 B 进行点赞,那这就是用户 A 的ID

  • 接收方的用户ID:用户 B 的 ID

  • 用户最新读取位置ID:用户最近一次读取记录的 ID


SQL


CREATE TABLE `sb_user_inbox` (
`id` bigint(20) NOT NULL,
`uuid` varchar(128) COLLATE utf8mb4_german2_ci NOT NULL COMMENT '消息数据唯一id',
`message_type` tinyint(1) NOT NULL COMMENT '消息类型',
`post_id` bigint(20) DEFAULT NULL COMMENT '帖子id',
`item_id` bigint(20) NOT NULL COMMENT '业务数据id',
`content` varchar(1000) COLLATE utf8mb4_german2_ci DEFAULT NULL COMMENT '内容',
`service_message_type` tinyint(1) NOT NULL COMMENT '业务数据类型',
`from_user_id` bigint(20) NOT NULL COMMENT '发起方的用户ID',
`to_user_id` bigint(20) NOT NULL COMMENT '接收方的用户ID',
`read_position_id` bigint(20) DEFAULT '0' COMMENT '用户最新读取位置ID',
`create_time` datetime DEFAULT NULL,
PRIMARY KEY (`id`),
UNIQUE KEY `un01` (`uuid`),
UNIQUE KEY `un02` (`item_id`,`service_message_type`,`to_user_id`),
KEY `key` (`to_user_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_german2_ci

可以看到,我加了很多业务相关的字段,这个主要是为了方便查询数据和展示数据。


2)系统发件箱表(sb_sys_outbox)



  • id

  • 内容


SQL


CREATE TABLE `sb_sys_outbox` (
`id` bigint(20) NOT NULL AUTO_INCREMENT,
`content` varchar(2000) COLLATE utf8mb4_german2_ci NOT NULL COMMENT '内容',
`create_time` datetime DEFAULT NULL,
PRIMARY KEY (`id`)
) ENGINE=InnoDB AUTO_INCREMENT=4 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_german2_ci

这个表就非常简单了,没啥业务字段冗余。


3)用户读取系统消息记录表(sb_user_read_sys_outbox)



  • id

  • 系统收件箱数据读取id

  • 读取的用户id


SQL


CREATE TABLE `sb_user_read_sys_outbox` (
`id` bigint(20) NOT NULL AUTO_INCREMENT,
`sys_outbox_id` bigint(20) NOT NULL COMMENT '系统收件箱数据读取id',
`user_id` bigint(20) NOT NULL COMMENT '读取的用户id',
PRIMARY KEY (`id`),
UNIQUE KEY `un` (`user_id`),
KEY `key` (`user_id`)
) ENGINE=InnoDB AUTO_INCREMENT=17 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_german2_ci

ok,这是消息中心所有分析阶段了,下面就开始实操。


2、实现


先来引入引入一下 RocketMQ 的依赖




org.apache.rocketmq
rocketmq-spring-boot-starter
2.2.1


RocketMQ 的双主双从同步刷新集群搭建教程:blog.csdn.net/qq_40399646…


MQ 配置:


Snipaste_2023-08-27_16-26-09.jpg


2.1 生产者


先来实现生产者如何发送消息。


1)消息体对象:LikeAndCommentMessageDTO


位置:cn.j3code.config.dto.mq


@Data
public class LikeAndCommentMessageDTO {

/**
* 该消息的唯一id
* 业务方可以不设置,如果为空,代码会自动填充
*/

private String uuid;

/**
* 消息类型
*/

private UserCenterMessageTypeEnum messageType;

/**
* 冗余一个帖子id进来
*/

private Long postId;

/**
* 业务数据id
*/

private Long itemId;

/**
* 如果是评论消息,这个内容就是评论的内容
*/

private String content;

/**
* 业务数据类型
*/

private UserCenterServiceMessageTypeEnum serviceMessageType;

/**
* 发起方的用户ID
*/

private Long fromUserId;

/**
* 接收方的用户ID
*/

private Long toUserId;


/*
例子:
用户 A 发表了一个帖子,B 对这个帖子进行了点赞,那这个实体如下:
messageType = UserCenterMessageTypeEnum.LIKE
itemId = 帖子ID(对评论进行点赞,就是评论id,对评论进行回复,就是刚刚评论的id)
serviceMessageType = UserCenterServiceMessageTypeEnum.POST(这个就是说明 itemId 的 ID 是归于那个业务的,方便后续查询业务数据)
fromUserId = 用户B的ID
toUserId = 用户 A 的ID
*/

}

2)发送消息代码


位置:cn.j3code.community.mq.producer


@Slf4j
@Component
@AllArgsConstructor
public class LikeAndCommentMessageProducer {

private final RocketMQTemplate rocketMQTemplate;

/**
* 单个消息发送
*
*
@param dto
*/

public void send(LikeAndCommentMessageDTO dto) {
if (Objects.isNull(dto.getUuid())) {
dto.setUuid(IdUtil.simpleUUID());
}
checkMessageDTO(dto);
Message message = MessageBuilder
.withPayload(dto)
.build();
rocketMQTemplate.send(RocketMQConstants.USER_MESSAGE_CENTER_TOPIC, message);
}

/**
* 批量消息发送
*
*
@param dtos
*/

public void send(List dtos) {
/**
* 将 dtos 集合分割成 1MB 大小的集合
* MQ 批量推送的消息大小最大 1MB 左右
*/

ListSizeSplitUtil.split(1 * 1024 * 1024L, dtos).forEach(items -> {
List> messageList = new ArrayList<>(items.size());
items.forEach(dto -> {
if (Objects.isNull(dto.getUuid())) {
dto.setUuid(IdUtil.simpleUUID());
}
checkMessageDTO(dto);
Message message = MessageBuilder
.withPayload(dto)
.build();
messageList.add(message);
});
rocketMQTemplate.syncSend(RocketMQConstants.USER_MESSAGE_CENTER_TOPIC, messageList);
});
}

private void checkMessageDTO(LikeAndCommentMessageDTO dto) {
AssertUtil.isTrue(Objects.isNull(dto.getMessageType()), "消息类型不为空!");
AssertUtil.isTrue(Objects.isNull(dto.getItemId()), "业务数据ID不为空!");
AssertUtil.isTrue(Objects.isNull(dto.getServiceMessageType()), "业务数据类型不为空!");
AssertUtil.isTrue(Objects.isNull(dto.getFromUserId()), "发起方用户ID不为空!");
AssertUtil.isTrue(Objects.isNull(dto.getToUserId()), "接收方用户ID不为空!");
}


/**
* 发送点赞消息
*
*
@param messageType 消息类型
*
@param serviceMessageType 业务类型
*
@param itemToUserIdMap 业务ID对应的用户id
*
@param saveLikeList 点赞数据
*/

public void sendLikeMQMessage(
UserCenterMessageTypeEnum messageType,
UserCenterServiceMessageTypeEnum serviceMessageType,
Map itemToUserIdMap, List saveLikeList)
{
if (CollectionUtils.isEmpty(saveLikeList)) {
return;
}
List dtos = new ArrayList<>();
for (Like like : saveLikeList) {
LikeAndCommentMessageDTO messageDTO = new LikeAndCommentMessageDTO();
messageDTO.setItemId(like.getItemId());
messageDTO.setMessageType(messageType);
messageDTO.setServiceMessageType(serviceMessageType);
messageDTO.setFromUserId(like.getUserId());
messageDTO.setToUserId(itemToUserIdMap.get(like.getItemId()));
dtos.add(messageDTO);
}
try {
send(dtos);
} catch (Exception e) {
//错误处理
log.error("发送MQ消息失败!", e);
}
}
}

注意:这里我用了 MQ 批量发送消息的一个功能,但是他有一个限制就是每次只能发送 1MB 大小的数据。所以我需要做一个功能工具类将业务方丢过来的批量数据进行分割。


工具类:ListSizeSplitUtil


位置:cn.j3code.config.util


public class ListSizeSplitUtil {

private static Long maxByteSize;

/**
* 根据传进来的 byte 大小限制,将 list 分割成对应大小的 list 集合数据
*
*
@param byteSize 每个 list 数据最大大小
*
@param list 待分割集合
*
@param
*
@return
*/

public static List> split(Long byteSize, List list) {
if (Objects.isNull(list) || list.size() == 0) {
return new ArrayList<>();
}

if (byteSize <= 100) {
throw new RuntimeException("参数 byteSize 值不小于 100 bytes!");
}
ListSizeSplitUtil.maxByteSize = byteSize;


if (isSurpass(List.of(list.get(0)))) {
throw new RuntimeException("List 中,单个对象都大于 byteSize 的值,分割失败");
}

List> result = new ArrayList<>();

List itemList = new ArrayList<>();
for (int i = 0; i < list.size(); i++) {
itemList.add(list.get(i));

if (isSurpass(itemList)) {
i = i - 1;
itemList.remove(itemList.size() - 1);
result.add(new ArrayList<>(itemList));
itemList = new ArrayList<>();
}
}
result.add(new ArrayList<>(itemList));
return result;
}


private static Boolean isSurpass(List obj) {
// 字节(byte)
long objSize = RamUsageEstimator.sizeOfAll(obj.toArray());
return objSize >= ListSizeSplitUtil.maxByteSize;
}
}

至此呢,生产者的逻辑就算是完成了,每次有消息的时候就调用这个方法即可。


2.2 消费者


位置:cn.j3code.user.mq.consumer


@Slf4j
@Component
@AllArgsConstructor
@RocketMQMessageListener(topic = RocketMQConstants.USER_MESSAGE_CENTER_TOPIC,
consumerGr0up = RocketMQConstants.GR0UP,
messageModel = MessageModel.CLUSTERING,
consumeMode = ConsumeMode.CONCURRENTLY
)

public class LikeAndCommentMessageConsumer implements RocketMQListener {

private final UserInboxService userInboxService;

@Override
public void onMessage(LikeAndCommentMessageDTO message) {
userInboxService.saveMessage(message);
}
}

saveMessage 方法的逻辑就是将消息保存到 MySQL 中,至此消息的产生和存储就算完成了,下面来看看用户如何查看吧!


2.3 用户消息查看


对于用户查看普通的消息就是访问一下 MySQL,并且更新一下最新读取的字段值即可,我贴一下关键代码就行了,代码如下:


public IPage page(UserMessagePageRequest request) {
// 获取消息
IPage page = getBaseMapper().page(new Page(request.getCurrent(), request.getSize()), request);

if (CollectionUtils.isEmpty(page.getRecords())) {
return page;
}
// 记录一下消息读取位置,默认进来就把全部消息读完了,类似掘金
if (request.getCurrent() == 1) {
if (Objects.isNull(page.getRecords().get(0).getReadPositionId()) ||
page.getRecords().get(0).getReadPositionId() == 0) {
UserInbox userInbox = new UserInbox();
userInbox.setId(page.getRecords().get(0).getId());
userInbox.setReadPositionId(userInbox.getId());
updateById(userInbox);
}
}
return page;
}

2.4 系统消息查看


对于系统消息的查看也是,只贴出关键代码,查询和更新读取记录逻辑,代码如下:


@Override
public IPage lookSysPage(SysOutboxPageRequest request) {
Page page = lambdaQuery()
.orderByDesc(SysOutbox::getId)
.page(new Page<>(request.getCurrent(), request.getSize()));
IPage outboxVOIPage = page.convert(userInboxConverter::converter);
if (CollectionUtils.isEmpty(outboxVOIPage.getRecords())) {
return outboxVOIPage;
}
// 记录一下消息读取位置,默认进来就把全部消息读完了,类似掘金
if (request.getCurrent() == 1) {
userReadSysOutboxService.updateReadLog(page.getRecords().get(0).getId(), SecurityUtil.getUserId());
}
return outboxVOIPage;
}

这里,可能有人会发现,没有按照上面分析的那用从缓存中读,是的。这里的实现我没有用到 Redis,这里我偷了一下懒,如果有拿到我代码的同学可以试着优化一下这个逻辑。


作者:J3code
来源:juejin.cn/post/7274922643453853735
收起阅读 »

18张图,详解SpringBoot解析yml全流程

前几天的时候,项目里有一个需求,需要一个开关控制代码中是否执行一段逻辑,于是理所当然的在yml文件中配置了一个属性作为开关,再配合nacos就可以随时改变这个值达到我们的目的,yml文件中是这样写的: switch: turnOn: on 程序中的代码也...
继续阅读 »



前几天的时候,项目里有一个需求,需要一个开关控制代码中是否执行一段逻辑,于是理所当然的在yml文件中配置了一个属性作为开关,再配合nacos就可以随时改变这个值达到我们的目的,yml文件中是这样写的:


switch:
turnOn: on

程序中的代码也很简单,大致的逻辑就是下面这样,如果取到的开关字段是on的话,那么就执行if判断中的代码,否则就不执行:


@Value("${switch.turnOn}")
private String on;

@GetMapping("testn")
public void test(){
if ("on".equals(on)){
//TODO
}
}

但是当代码实际跑起来,有意思的地方来了,我们发现判断中的代码一直不会被执行,直到debug一下,才发现这里的取到的值居然不是on而是true



看到这,是不是感觉有点意思,首先盲猜是在解析yml的过程中把on作为一个特殊的值进行了处理,于是我干脆再多测试了几个例子,把yml中的属性扩展到下面这些:


switch:
turnOn: on
turnOff: off
turnOn2: 'on'
turnOff2: 'off'

再执行一下代码,看一下映射后的值:



可以看到,yml中没有带引号的onoff被转换成了truefalse,带引号的则保持了原来的值不发生改变。


到这里,让我忍不住有点好奇,为什么会发生这种现象呢?于是强忍着困意翻了翻源码,硬磕了一下SpringBoot加载yml配置文件的过程,终于让我看出了点门道,下面我们一点一点细说!


因为配置文件的加载会涉及到一些SpringBoot启动的相关知识,所以如果对SpringBoot启动不是很熟悉的同学,可以先提前先看一下Hydra在古早时期写过一篇Spring Boot零配置启动原理预热一下。下面的介绍中,只会摘出一些对加载和解析配置文件比较重要的步骤进行分析,对其他无关部分进行了省略。


加载监听器


当我们启动一个SpringBoot程序,在执行SpringApplication.run()的时候,首先在初始化SpringApplication的过程中,加载了11个实现了ApplicationListener接口的拦截器。



这11个自动加载的ApplicationListener,是在spring.factories中定义并通过SPI扩展被加载的:



这里列出的10个是在spring-boot中加载的,还有剩余的1个是在spring-boot-autoconfigure中加载的。其中最关键的就是ConfigFileApplicationListener,它和后面要讲到的配置文件的加载相关。


执行run方法


在实例化完成SpringApplication后,会接着往下执行它的run方法。



可以看到,这里通过getRunListeners方法获取的SpringApplicationRunListeners中,EventPublishingRunListener绑定了我们前面加载的11个监听器。但是在执行starting方法时,根据类型进行了过滤,最终实际只执行了4个监听器的onApplicationEvent方法,并没有我们希望看到的ConfigFileApplicationListener,让我们接着往下看。



run方法执行到prepareEnvironment时,会创建一个ApplicationEnvironmentPreparedEvent类型的事件,并广播出去。这时所有的监听器中,有7个会监听到这个事件,之后会分别调用它们的onApplicationEvent方法,其中就有了我们心心念念的ConfigFileApplicationListener,接下来让我们看看它的onApplicationEvent方法中做了什么。



在方法的调用过程中,会加载系统自己的4个后置处理器以及ConfigFileApplicationListener自身,一共5个后置处理器,并执行他们的postProcessEnvironment方法,其他4个对我们不重要可以略过,最终比较关键的步骤是创建Loader实例并调用它的load方法。


加载配置文件


这里的LoaderConfigFileApplicationListener的一个内部类,看一下Loader对象实例化的过程:



在实例化Loader对象的过程中,再次通过SPI扩展的方式加载了两个属性文件加载器,其中的YamlPropertySourceLoader就和后面的yml文件的加载、解析密切关联,而另一个PropertiesPropertySourceLoader则负责properties文件的加载。创建完Loader实例后,接下来会调用它的load方法。



load方法中,会通过嵌套循环方式遍历默认配置文件存放路径,再加上默认的配置文件名称、以及不同配置文件加载器对应解析的后缀名,最终找到我们的yml配置文件。接下来,开始执行loadForFileExtension方法。



loadForFileExtension方法中,首先将classpath:/application.yml加载为Resource文件,接下来准备正式开始,调用了之前创建好的YamlPropertySourceLoader对象的load方法。


封装Node


load方法中,开始准备进行配置文件的解析与数据封装:



load方法中调用了OriginTrackedYmlLoader对象的load方法,从字面意思上我们也可以理解,它的用途是原始追踪yml的加载器。中间一连串的方法调用可以忽略,直接看最后也是最重要的是一步,调用OriginTrackingConstructor对象的getData接口,来解析yml并封装成对象。



在解析yml的过程中实际使用了Composer构建器来生成节点,在它的getNode方法中,通过解析器事件来创建节点。通常来说,它会将yml中的一组数据封装成一个MappingNode节点,它的内部实际上是一个NodeTuple组成的ListNodeTupleMap的结构类似,由一对对应的keyNodevalueNode构成,结构如下:



好了,让我们再回到上面的那张方法调用流程图,它是根据文章开头的yml文件中实际内容内容绘制的,如果内容不同调用流程会发生改变,大家只需要明白这个原理,下面我们具体分析。


首先,创建一个MappingNode节点,并将switch封装成keyNode,然后再创建一个MappingNode,作为外层MappingNodevalueNode,同时存储它下面的4组属性,这也是为什么上面会出现4次循环的原因。如果有点困惑也没关系,看一下下面的这张图,就能一目了然了解它的结构。



在上图中,又引入了一种新的ScalarNode节点,它的用途也比较简单,简单String类型的字符串用它来封装成节点就可以了。到这里,yml中的数据被解析完成并完成了初步的封装,可能眼尖的小伙伴要问了,上面这张图中为什么在ScalarNode中,除了value还有一个tag属性,这个属性是干什么的呢?


在介绍它的作用前,先说一下它是怎么被确定的。这一块的逻辑比较复杂,大家可以翻一下ScannerImplfetchMoreTokens方法的源码,这个方法会根据yml中每一个keyvalue是以什么开头,来决定以什么方式进行解析,其中就包括了{['%?等特殊符号的情况。以解析不带任何特殊字符的字符串为例,简要的流程如下,省略了一些不重要部分:



在这张图的中间步骤中,创建了两个比较重要的对象ScalarTokenScalarEvent,其中都有一个为trueplain属性,可以理解为这个属性是否需要解释,是后面获取Resolver的关键属性之一。


上图中的yamlImplicitResolvers其实是一个提前缓存好的HashMap,已经提前存储好了一些Char类型字符与ResolverTuple的对应关系:



当解析到属性on时,取出首字母o对应的ResolverTuple,其中的tag就是tag:yaml.org.2002:bool。当然了,这里也不是简单的取出就完事了,后续还会对属性进行正则表达式的匹配,看与regexp中的值是否能对的上,检查无误时才会返回这个tag


到这里,我们就解释清楚了ScalarNodetag属性究竟是怎么获取到的了,之后方法调用层层返回,返回到OriginTrackingConstructor父类BaseConstructorgetData方法中。接下来,继续执行constructDocument方法,完成对yml文档的解析。


调用构造器


constructDocument中,有两步比较重要,第一步是推断当前节点应该使用哪种类型的构造器,第二步是使用获得的构造器来重新对Node节点中的value进行赋值,简易流程如下,省去了循环遍历的部分:



推断构造器种类的过程也很简单,在父类BaseConstructor中,缓存了一个HashMap,存放了节点的tag类型到对应构造器的映射关系。在getConstructor方法中,就使用之前节点中存入的tag属性来获得具体要使用的构造器:



tagbool类型时,会找到SafeConstruct中的内部类 ConstructYamlBool作为构造器,并调用它的construct方法实例化一个对象,来作为ScalarNode节点的value的值:



construct方法中,取到的val就是之前的on,至于下面的这个BOOL_VALUES,也是提前初始化好的一个HashMap,里面提前存放了一些对应的映射关系,key是下面列出的这些关键字,value则是Boolean类型的truefalse



到这里,yml中的属性解析流程就基本完成了,我们也明白了为什么yml中的on会被转化为true的原理了。至于最后,Boolean类型的truefalse是如何被转化为的字符串,就是@Value注解去实现的了。


思考


那么,下一个问题来了,既然yml文件解析中会做这样的特殊处理,那么如果换成properties配置文件怎么样呢?


sw.turnOn=on
sw.turnOff=off

执行一下程序,看一下结果:



可以看到,使用properties配置文件能够正常读取结果,看来是在解析的过程中没有做特殊处理,至于解析的过程,有兴趣的小伙伴可以自己去阅读一下源码。


那么,今天就写到这里,我们下期见。


作者:码农参上
来源:juejin.cn/post/7054818269621911559
收起阅读 »

Java 中for循环和foreach循环哪个更快?

本文旨在探究Java中的for循环和foreach循环的性能差异,并帮助读者更好地选择适合自身需求的循环方式 前言 在Java编程中,循环结构是程序员常用的控制流程,而for循环和foreach循环是其中比较常见的两种形式。关于它们哪一个更快的讨论一直存在。...
继续阅读 »

本文旨在探究Java中的for循环和foreach循环的性能差异,并帮助读者更好地选择适合自身需求的循环方式



前言


在Java编程中,循环结构是程序员常用的控制流程,而for循环和foreach循环是其中比较常见的两种形式。关于它们哪一个更快的讨论一直存在。本文旨在探究Java中的for循环和foreach循环的性能差异,并帮助读者更好地选择适合自身需求的循环方式。通过详细比较它们的遍历效率、数据结构适用性和编译器优化等因素,我们将为大家揭示它们的差异和适用场景,以便您能够做出更明智的编程决策。



for循环与foreach循环的比较


小编认为for和foreach 之间唯一的实际区别是,对于可索引对象,我们无权访问索引。


for(int i = 0; i < mylist.length; i++) {
if(i < 5) {
//do something
} else {
//do other stuff
}
}

但是,我们可以使用 foreach 创建一个单独的索引 int 变量。例如:


int index = -1;
for(int myint : mylist) {
index++;
if(index < 5) {
//do something
} else {
//do other stuff
}
}

现在写一个简单的类,其中有 foreachTest() 方法,该方法使用 forEach 迭代列表。


import java.util.List;

public class ForEachTest {
List intList;

public void foreachTest(){
for(Integer i : intList){

}
}
}

编译这个类时,编译器会在内部将这段代码转换为迭代器实现。小编通过执行 javap -verbose IterateListTest 反编译代码。


public void foreachTest();
descriptor: ()V
flags: ACC_PUBLIC
Code:
stack=1, locals=3, args_size=1
0: aload_0
1: getfield #19 // Field intList:Ljava/util/List;
4: invokeinterface #21, 1 // InterfaceMethod java/util/List.iterator:()Ljava/util/Iterator;
9: astore_2
10: goto 23
13: aload_2
14: invokeinterface #27, 1 // InterfaceMethod java/util/Iterator.next:()Ljava/lang/Object;
19: checkcast #33 // class java/lang/Integer
22: astore_1
23: aload_2
24: invokeinterface #35, 1 // InterfaceMethod java/util/Iterator.hasNext:()Z
29: ifne 13
32: return
LineNumberTable:
line 9: 0
line 12: 32
LocalVariableTable:
Start Length Slot Name Signature
0 33 0 this Lcom/greekykhs/springboot/ForEachTest;
StackMapTable: number_of_entries = 2
frame_type = 255 /* full_frame */
offset_delta = 13
locals = [ class com/greekykhs/springboot/ForEachTest, top, class java/util/Iterator ]
stack = []
frame_type = 9 /* same */

从上面的字节码我们可以看到:


a). getfield命令用于获取变量整数。


b).调用List.iterator获取迭代器实例


c).调用iterator.hasNext,如果返回true,则调用iterator.next方法。


下边来做一下性能测试。在 IterateListTest 的主要方法中,创建了一个列表并使用 for 和 forEach 循环对其进行迭代。


import java.util.ArrayList;
import java.util.List;

public class IterateListTest {
public static void main(String[] args) {
List mylist = new ArrayList<>();
for (int i = 0; i < 1000000; i++) {
mylist.add(i);
}

long forLoopStartTime = System.currentTimeMillis();
for (int i = 0; i < mylist.size(); i++) {mylist.get(i);}

long forLoopTraversalCost =System.currentTimeMillis()-forLoopStartTime;
System.out.println("for loop traversal cost for ArrayList= "+ forLoopTraversalCost);

long forEachStartTime = System.currentTimeMillis();
for (Integer integer : mylist) {}

long forEachTraversalCost =System.currentTimeMillis()-forEachStartTime;
System.out.println("foreach traversal cost for ArrayList= "+ forEachTraversalCost);
}
}

结果如下:


总结


观察结果显示,for循环的性能优于for-each循环。然后再使用LinkedList比较它们的性能差异。对于 LinkedList 来说,for-each循环展现出更好的性能。ArrayList内部使用连续存储的数组,因此数据的检索时间复杂度为 O(1),通过索引可以直接访问数据。而 LinkedList 使用双向链表结构,当我们使用 for 循环进行遍历时,每次都需要从链表头节点开始,导致时间复杂度达到了 O(n*n),因此在这种情况下,for-each 循环更适合操作 LinkedList。


作者:葡萄城技术团队
来源:juejin.cn/post/7280050832950624314
收起阅读 »

到了2038年时间戳溢出了怎么办?

计算机中的时间 看完这篇文章相信你会对计算机中的时间有更系统全面的认识。 我经常自嘲,自己写的程序运行不超过3年,因为大部分项目方就早早跑路了。大多数项目上线后,你跟这个项目就再无瓜葛,关于时间你只需要保证时区正确就不会有太大问题,哈哈。 但是今天我想认真对待...
继续阅读 »

计算机中的时间


看完这篇文章相信你会对计算机中的时间有更系统全面的认识。


我经常自嘲,自己写的程序运行不超过3年,因为大部分项目方就早早跑路了。大多数项目上线后,你跟这个项目就再无瓜葛,关于时间你只需要保证时区正确就不会有太大问题,哈哈。 但是今天我想认真对待时间这个问题,作为一个库作者或基础软件作者,就需要考虑下游项目万一因为你处理时间不当而造成困扰,影响范围就比较广了。


计算机中与时间有关的关键词:


时间类型
时间戳(timestamp
定时器(例如jssetInterval())
时间计算
时间段
超时(setTimeout())
时间片
GMT
UTC
Unix时间戳
ISO8601
CST
EST

看到这些你可能会疑惑,为何一个时间竟然如此复杂!!


如果下面的问题你都能答上来,那这篇文章对你的帮助微乎其微,不如做些更有意义的事情。



  • 常用的时间格式,他们都遵循哪些标准?

  • 什么是GMT?

  • 什么是UTC?

  • GMT UTC 和ISO8601有什么区别?

  • RFC5322是什么?

  • RFC5322 采用的是GMT还是UTC?

  • ISO8601 使用的是UTC还是GMT?

  • 在ISO8601中 Z可以使用+00:00表示吗?

  • UTC什么时候校准?

  • CST是东八区吗?

  • Z是ISO 8601规定的吗,为什么是Z?

  • 时区划分是哪个标准定义的?

  • 为什么是1970年1月1日呢?

  • 到了2038年时间戳溢出了怎么办?

  • 计算机中时间的本质是一个long类型吗?

  • WEB前后端用哪个格式传输好?

  • '2024-01-01T24:00:00' 等于 '2024-01-02T00:00:00' ??



正文开始


1. 两种时间标准


UTC和GMT都是时间标准,定义事件的精度。它们只表示 零时区 的时间,本地时间则需要与 时区 或偏移 结合后表示。这两个标准之间差距通常不会超过一秒。


UTC(协调世界时)


UTC,即协调世界时(Coordinated Universal Time),是一种基于原子钟的时间标准。它的校准是根据地球自转的变化而进行的,插入或删除闰秒的实际需求在短期内是难以预测的,因此这个决定通常是在需要校准的时候发布。 闰秒通常由国际电信联盟(ITU) 和国际度量衡局(BIPM) 等组织进行发布。由国际原子时(International Atomic Time,TAI) 通过闰秒 的调整来保持与地球自转的同步。


GMT(格林尼治标准时间)


以英国伦敦附近的格林尼治天文台(0度经线,本初子午线)的时间为基准。使用地球自转的平均速度来测量时间,是一种相对于太阳的平均时刻。尽管 GMT 仍然被广泛使用,但现代科学和国际标准更倾向于使用UTC。


2. 两种显示标准


上面我们讨论的时间标准主要保证的是时间的精度,时间显示标准指的是时间的字符串表示格式。我们熟知的有 RFC 5322 和 ISO 8601。


RFC 5322 电子邮件消息格式的规范


RFC 5322 的最新版本是在2008年10月在IETF发布的,你阅读时可能有了更新的版本。



RFC 5322 是一份由 Internet Engineering Task Force (IETF) 制定的标准,定义了 Internet 上的电子邮件消息的格式规范。该标准于2008年发布,是对之前的 RFC 2822 的更新和扩展。虽然 RFC 5322 主要关注电子邮件消息的格式,但其中的某些规范,比如日期时间格式,也被其他领域采纳,例如在 HTTP 协议中用作日期头部(Date Header)的表示。



格式通常如下:


Thu, 14 Dec 2023 05:36:56 GMT

时区部分为了可读可以如下表示:


Thu, 14 Dec 2023 05:36:56 CST
Thu, 14 Dec 2023 05:36:56 +0800
Thu, 14 Dec 2023 05:36:56 +0000
Thu, 14 Dec 2023 05:36:56 Z

但并不是所有程序都兼容这种时区格式,通常程序会忽略时区,在写程序时要做好测试。标准没有定义毫秒数如何显示。


需要注意的是,有时候我们会见到这种格式Tue Jan 19 2038 11:14:07 GMT+0800 (中国标准时间),这是js日期对象转字符串的格式,它与标准无关,千万不要混淆了。


ISO 8601


ISO 8601 最新版本是 ISO 8601:2019,发布日期为2019年11月15日,你阅读时可能有了更新的版本。


下面列举一些格式示例:


2004-05-03T17:30:08+08:00
2004-05-03T17:30:08+00:00
2004-05-03T17:30:08Z
2004-05-03T17:30:08.000+08:00

标准并没有定义小数位数,保险起见秒后面一般是3位小数用来表示毫秒数。 字母 "Z" 是 "zero"(零)的缩写,因此它被用来表示零时区,也可以使用+00:00,但Z更直观且简洁。



  1. 本标准提供两种方法来表示时间:一种是只有数字的基础格式;第二种是添加了分隔符的扩展格式,更易读。扩展格式使用连字符“-”来分隔日期,使用冒号“:”来分隔时间。比如2009年1月6日在扩展格式中可以写成"2009-01-06",在基本格式中可以简单地写成"20090106"而不会产生歧义。 若要表示前1年之前或9999年之后的年份,标准也允许有共识的双方扩展表达方式。双方应事先规定增加的位数,并且年份前必须有正号“+”或负号“-”而不使用“。依据标准,若年份带符号,则前1年为"+0000",前2年为"-0001",依此类推。

  2. 午夜,一日的开始:完全表示为000000或00:00:00;仅有小时和分表示为0000或00:00

  3. 午夜,一日的终止:完全表示为240000或24:00:00;仅有小时和分表示为2400或24:00

  4. 如果时间在零时区,并恰好与UTC相同,那么在时间最后加一个大写字母Z。Z是相对协调世界时时间0偏移的代号。 如下午2点30分5秒表示为14:30:05Z或143005Z;只表示小时和分,为1430Z或14:30Z;只表示小时,则为14Z或14Z。

  5. 其它时区用实际时间加时差表示,当时的UTC+8时间表示为22:30:05+08:00或223005+0800,也可以简化成223005+08。


日期与时间合并表示时,要在时间前面加一大写字母T,如要表示东八区时间2004年5月3日下午5点30分8秒,可以写成2004-05-03T17:30:08+08:00或20040503T173008+08。


在编写API时推荐使用ISO 8601标准接收参数或响应结果,并且做好时区测试,因为不同编程语言中实现可能有差异。


时区划分和偏移



全球被分为24个时区,每个时区对应一个小时的时间差。 时区划分由IANA维护和管理,其时区数据库被称为 TZ Database(或 Olson Database)。这个数据库包含了全球各个时区的信息,包括时区的名称、标识符、以及历史性的时区变更数据,例如夏令时的开始和结束时间等。在许多操作系统(如Linux、Unix、macOS等)和编程语言(如Java、Python等)中得到广泛应用。


TZ Database具体见我整理的表格,是从Postgresql中导出的一份Excel,关注公众号"程序饲养员",回复"tz"



时区标识符采用"洲名/城市名"的命名规范,例如:"America/New_York"或"Asia/Shanghai"。这种命名方式旨在更准确地反映时区的地理位置。时区的具体规定和管理可能因国家、地区、或国际组织而异。


有一些时区是按照半小时或15分钟的间隔进行偏移的,以适应地理和政治需求。在某些地区,特别是位于边界上的地区,也可能采用不同的时区规则。


EST,CST、GMT(另外一个含义是格林尼治标准时间)这些都是时区的缩写。


这种简写存在重复,如CST 可能有多种不同的含义,China Standard Time(中国标准时间),它对应于 UTC+8,即东八区。Central Standard Time(中部标准时间) 在美国中部标准时间的缩写中也有用。中部标准时间对应于 UTC-6,即西六区。因此在某些软件配置时不要使用简称,一定要使用全称,如”Asia/Shanghai“。


采用东八区的国家和地区有哪些



  • 中国: 中国标准时间(China Standard Time,CST)是东八区的时区,对应于UTC+8。

  • 中国香港: 中国香港也采用东八区的时区,对应于UTC+8。

  • 中国澳门: 澳门也在东八区,使用UTC+8。

  • 中国台湾: 台湾同样在东八区,使用UTC+8。

  • 新加坡: 新加坡位于东八区,使用UTC+8。

  • 马来西亚: 马来西亚的半岛部分和东马来西亚位于东八区,使用UTC+8。

  • 菲律宾: 菲律宾采用东八区的时区,对应于UTC+8。


计算机系统中的时间 —— Unix时间戳


Unix时间戳(Unix timestamp)定义为从1970年01月01日00时00分00秒(UTC)起至现在经过的总秒数(秒是毫秒、微妙、纳秒的总称)。


这个时间点通常被称为 "Epoch" 或 "Unix Epoch"。时间戳是一个整数,表示从 Epoch 开始经过的秒数。


一些关键概念:



  1. 起始时间点: Unix 时间戳的起始时间是 1970 年 1 月 1 日 00:00:00 UTC。在这一刻,Unix 时间戳为 0。

  2. 增量单位: Unix 时间戳以秒为单位递增。每过一秒,时间戳的值增加 1。

  3. 正负值: 时间戳可以是正值或负值。正值表示从 Epoch 开始经过的秒数,而负值表示 Epoch 之前的秒数。

  4. 精度: 通常情况下,Unix 时间戳以整数形式表示秒数。有时也会使用浮点数表示秒的小数部分,以提供更精细的时间分辨率。精确到秒是10位;有些编程语言精确到毫秒是13位,被称为毫秒时间戳。


为什么是1970年1月1日?


这个选择主要是出于历史和技术的考虑。


Unix 操作系统的设计者之一,肯·汤普森(Ken Thompson)和丹尼斯·里奇(Dennis Ritchie)在开发 Unix 操作系统时,需要选择一个固定的起始点来表示时间。1970-01-01 00:00:00 UTC 被选为起始时间。这个设计的简洁性和通用性使得 Unix 时间戳成为计算机系统中广泛使用的标准方式来表示和处理时间。


时间戳为什么只能表示到2038年01月19日03时14分07秒?


在许多系统中,结构体time_t 被定义为 long,具体实现取决于编译器和操作系统的架构。例如,在32位系统上,time_t 可能是32位的 long,而在64位系统上,它可能是64位的 long。 32位有符号long类型,实际表示整数只有31位,最大能表示十进制2147483647(01111111 11111111 11111111 11111111)。


> new Date(2147483647000)
< Tue Jan 19 2038 11:14:07 GMT+0800 (中国标准时间)

实际上到2038年01月19日03时14分07秒,便会到达最大时间,过了这个时间点,所有32位操作系统时间便会变为10000000 00000000 00000000 00000000。因具体实现不同,有可能会是1901年12月13日20时45分52秒,这样便会出现时间回归的现象,很多软件便会运行异常了。


至于时间回归的现象相信随着64为操作系统的产生逐渐得到解决,因为用64位操作系统可以表示到292,277,026,596年12月4日15时30分08秒。


另外,考虑时区因素,北京时间的时间戳的起始时间是1970-01-01T08:00:00+08:00。


好了,关于计算机中的时间就说完了,有疑问评论区相见 或 关注 程序饲养员 公号。



作者:程序饲养员
来源:juejin.cn/post/7312640704404111387
收起阅读 »

Arrays.asList() 隐藏的陷阱,你避开了吗?

[Arrays.asList()方法介绍] [Arrays.asList()方法的坑] [解决Arrays.asList()方法的坑] [总结] [Arrays.asList()方法介绍] [Arrays.asList()方法的坑] [解决Arrays.asL...
继续阅读 »

  • [Arrays.asList()方法介绍]

  • [Arrays.asList()方法的坑]

  • [解决Arrays.asList()方法的坑]

  • [总结]

  • [Arrays.asList()方法介绍]

  • [Arrays.asList()方法的坑]

  • [解决Arrays.asList()方法的坑]

  • [总结]




在Java中,我们经常需要将数组转换为List来方便地进行操作。Arrays.asList()方法是一种常见的方式,但是它存在一个不太常见但需要注意的坑。


本文将深入探讨Arrays.asList()的使用,揭示其中的陷阱,并提供解决方案。


[Arrays.asList()方法介绍]


Arrays.asList()方法是将数组转换为List的方法,它返回一个List对象,但这个List对象并不是java.util.ArrayList对象,而是Arrays内部的ArrayList对象。


Arrays.ArrayList类继承自AbstractList,实现了List接口。它重写了add()remove()等修改List结构的方法,并将它们直接抛出UnsupportedOperationException异常,从而禁止了对List结构的修改。


具体来说,Arrays.asList()方法返回的是Arrays类中的一个私有静态内部类ArrayList,它继承自AbstractList类,实现了List接口。


Arrays.asList()方法的使用非常简单,只需要将一个数组作为参数传递给该方法即可。例如:


String[] arr = new String[]{"a""b""c"};
List<String> list = Arrays.asList(arr);


基于 Spring Boot + MyBatis Plus + Vue & Element 实现的后台管理系统 + 用户小程序,支持 RBAC 动态权限、多租户、数据权限、工作流、三方登录、支付、短信、商城等功能




[Arrays.asList()方法的坑]


尽管Arrays.asList()方法很方便,但也存在一些坑,其中最常见的一个是:在使用Arrays.asList()方法时,如果对返回的List对象进行修改(例如增加、删除元素),将会抛出"UnsupportedOperationException"异常。


为什么会出现这个异常呢?这是因为Arrays.asList()方法返回的List对象,是一个固定大小的List,不能进行结构上的修改,否则会抛出异常。


下面的代码演示了这个问题:


String[] arr = new String[]{"a""b""c"};
List<String> list = Arrays.asList(arr);
list.add("d"); // 抛出 UnsupportedOperationException 异常

上述代码中,我们尝试向List对象中添加一个新的元素"d",结果会抛出"UnsupportedOperationException"异常。



基于 Spring Cloud Alibaba + Gateway + Nacos + RocketMQ + Vue & Element 实现的后台管理系统 + 用户小程序,支持 RBAC 动态权限、多租户、数据权限、工作流、三方登录、支付、短信、商城等功能




[解决Arrays.asList()方法的坑]


要解决Arrays.asList()方法的坑,我们需要将返回的List对象转换为一个可修改的List对象。有几种方法可以实现这个目标:


[方法一:使用java.util.ArrayList类]


我们可以使用java.util.ArrayList类,将Arrays.asList()方法返回的List对象转换为一个java.util.ArrayList对象,示例如下:


String[] arr = new String[]{"a""b""c"};
List<String> list = new ArrayList<>(Arrays.asList(arr));
list.add("d"); // 正常运行

上述代码中,我们首先使用Arrays.asList()方法将一个数组转换为一个List对象,然后使用ArrayList的构造方法,将这个List对象转换为一个java.util.ArrayList对象,最后可以向这个ArrayList对象中添加元素。


[方法二:使用Collections类]


我们也可以使用Collections类提供的静态方法,将Arrays.asList()方法返回的List对象转换为一个可修改的List对象,示例如下:


String[] arr = new String[]{"a""b""c"};
List<String> list = new ArrayList<>(Arrays.asList(arr));
Collections.addAll(list, "d"); // 正常运行

通过Collections.addAll()方法,我们可以将数组中的元素逐个添加到一个新的ArrayList对象中,从而实现了可修改性。


[总结]


在使用Arrays.asList()方法时,需要注意返回的List对象是一个固定大小的List,不支持结构上的修改操作。为了避免这个陷阱,我们可以使用java.util.ArrayList或Collections类提供的方法将返回的List对象转换为可修改的List。通过了解这个陷阱并采取相应的解决方案,我们可以安全地将数组转换为List,并避免潜在的异常情况。


不要让Arrays.asList()的陷阱坑了你的代码!


在Java中,我们经常需要将数组转换为List来方便地进行操作。Arrays.asList()方法是一种常见的方式,但是它存在一个不太常见但需要注意的坑。本文将深入探讨Arrays.asList()的使用,揭示其中的陷阱,并提供解决方案。


[Arrays.asList()方法介绍]


Arrays.asList()方法是将数组转换为List的方法,它返回一个List对象,但这个List对象并不是java.util.ArrayList对象,而是Arrays内部的ArrayList对象。


Arrays.ArrayList类继承自AbstractList,实现了List接口。它重写了add()remove()等修改List结构的方法,并将它们直接抛出UnsupportedOperationException异常,从而禁止了对List结构的修改。


具体来说,Arrays.asList()方法返回的是Arrays类中的一个私有静态内部类ArrayList,它继承自AbstractList类,实现了List接口。


Arrays.asList() 方法的使用非常简单,只需要将一个数组作为参数传递给该方法即可。例如:


String[] arr = new String[]{"a""b""c"};
List<String> list = Arrays.asList(arr);

[Arrays.asList()方法的坑]


尽管Arrays.asList()方法很方便,但也存在一些坑,其中最常见的一个是:在使用Arrays.asList()方法时,如果对返回的List对象进行修改(例如增加、删除元素),将会抛出"UnsupportedOperationException"异常。


为什么会出现这个异常呢?这是因为Arrays.asList()方法返回的List对象,是一个固定大小的List,不能进行结构上的修改,否则会抛出异常。


下面的代码演示了这个问题:


String[] arr = new String[]{"a""b""c"};
List<String> list = Arrays.asList(arr);
list.add("d"); // 抛出 UnsupportedOperationException 异常

上述代码中,我们尝试向List对象中添加一个新的元素"d",结果会抛出"UnsupportedOperationException"异常。


[解决Arrays.asList()方法的坑]


要解决Arrays.asList()方法的坑,我们需要将返回的List对象转换为一个可修改的List对象。有几种方法可以实现这个目标:


[方法一:使用java.util.ArrayList类]


我们可以使用java.util.ArrayList类,将Arrays.asList()方法返回的List对象转换为一个java.util.ArrayList对象,示例如下:


String[] arr = new String[]{"a""b""c"};
List<String> list = new ArrayList<>(Arrays.asList(arr));
list.add("d"); // 正常运行

上述代码中,我们首先使用Arrays.asList()方法将一个数组转换为一个List对象,然后使用ArrayList的构造方法,将这个List对象转换为一个java.util.ArrayList对象,最后可以向这个ArrayList对象中添加元素。


[方法二:使用Collections类]


我们也可以使用Collections类提供的静态方法,将Arrays.asList()方法返回的List对象转换为一个可修改的List对象,示例如下:


String[] arr = new String[]{"a""b""c"};
List<String> list = new ArrayList<>(Arrays.asList(arr));
Collections.addAll(list, "d"); // 正常运行

通过Collections.addAll()方法,我们可以将数组中的元素逐个添加到一个新的ArrayList对象中,从而实现了可修改性。


[总结]


在使用Arrays.asList()方法时,需要注意返回的List对象是一个固定大小的List,不支持结构上的修改操作。为了避免这个陷阱,我们可以使用java.util.ArrayList或Collections类提供的方法将返回的List对象转换为可修改的List。通过了解这个陷阱并采取相应的解决方案,我们可以安全地将数组转换为List,并避免潜在的异常情况。


不要让Arrays.asList()的陷阱坑了你的代码!


作者:智多星云
来源:juejin.cn/post/7258863572553302071
收起阅读 »

告别StringUtil:使用Java 全新String API优化你的代码

前言   Java 编程语言的每一次重要更新,都引入了许多新功能和改进。 并且在String 类中引入了一些新的方法,能够更好地满足开发的需求,提高编程效率。 repeat(int count):返回一个新的字符串,该字符串是由原字符串重复指定次数形成的。 ...
继续阅读 »


前言


  Java 编程语言的每一次重要更新,都引入了许多新功能和改进。 并且在String 类中引入了一些新的方法,能够更好地满足开发的需求,提高编程效率。



  1. repeat(int count):返回一个新的字符串,该字符串是由原字符串重复指定次数形成的。

  2. isBlank():检查字符串是否为空白字符序列,即长度为 0 或仅包含空格字符的字符串。

  3. lines():返回一个流,该流由字符串按行分隔而成。

  4. strip():返回一个新的字符串,该字符串是原字符串去除前导空格和尾随空格后形成的。

  5. stripLeading():返回一个新的字符串,该字符串是原字符串去除前导空格后形成的。

  6. stripTrailing():返回一个新的字符串,该字符串是原字符串去除尾随空格后形成的。

  7. formatted(Object... args):使用指定的参数格式化字符串,并返回格式化后的字符串。

  8. translateEscapes():将 Java 转义序列转换为相应的字符,并返回转换后的字符串。

  9. transform() 方法:该方法可以将一个函数应用于字符串,并返回函数的结果。


示例


1. repeat(int count)


public class StringRepeatExample {
public static void main(String[] args) {
String str = "abc";
String repeatedStr = str.repeat(3);
System.out.println(repeatedStr);
}
}

输出结果:


abcabcabc

2. isBlank()


public class StringIsBlankExample {
public static void main(String[] args) {
String str1 = "";
String str2 = " ";
String str3 = " \t ";

System.out.println(str1.isBlank());
System.out.println(str2.isBlank());
System.out.println(str3.isBlank());
}
}

输出结果:


true
true
true

3. lines()


import java.util.stream.Stream;

public class StringLinesExample {
public static void main(String[] args) {
String str = "Hello\nWorld\nJava";
Stream<String> lines = str.lines();
lines.forEach(System.out::println);
}
}

输出结果:


Hello
World
Java

4. strip()


public class StringStripExample {
public static void main(String[] args) {
String str1 = " abc ";
String str2 = "\t def \n";
System.out.println(str1.strip());
System.out.println(str2.strip());
}
}

输出结果:


abc
def

5. stripLeading()


public class StringStripLeadingExample {
public static void main(String[] args) {
String str1 = " abc ";
String str2 = "\t def \n";
System.out.println(str1.stripLeading());
System.out.println(str2.stripLeading());
}
}

输出结果:


abc
def

6. stripTrailing()


public class StringStripTrailingExample {
public static void main(String[] args) {
String str1 = " abc ";
String str2 = "\t def \n";
System.out.println(str1.stripTrailing());
System.out.println(str2.stripTrailing());
}
}

输出结果:


abc
def

7. formatted(Object... args)


public class StringFormattedExample {
public static void main(String[] args) {
String str = "My name is %s, I'm %d years old.";
String formattedStr = str.formatted( "John", 25);
System.out.println(formattedStr);
}
}

输出结果:


My name is John, I'm 25 years old.

8. translateEscapes()


public class StringTranslateEscapesExample {
public static void main(String[] args) {
String str = "Hello\\nWorld\\tJava";
String translatedStr = str.translateEscapes();
System.out.println(translatedStr);
}
}

输出结果:


Hello
World Java

9. transform()


public class StringTransformExample {
public static void main(String[] args) {
String str = "hello world";
String result = str.transform(i -> i + "!");
System.out.println(result);
}
}

输出结果:


hello world!

结尾


  如果觉得对你有帮助,可以多多评论,多多点赞哦,也可以到我的主页看看,说不定有你喜欢的文章,也可以随手点个关注哦,谢谢。


  我是不一样的科技宅,每天进步一点点,体验不一样的生活。我们下期见!


作者:不一样的科技宅
来源:juejin.cn/post/7222996459833770021
收起阅读 »

不吹不黑,辩证看待开发者是否需要入坑鸿蒙

前言 自打华为2019年发布鸿蒙操作系统以来,网上各种声音百家争鸣。尤其是2023年发布会公布的鸿蒙4.0宣称不再支持Android,更激烈的讨论随之而来。 本文没有宏大的叙事,只有基于现实的考量。 通过本文,你将了解到: HarmonyOS与OpenHa...
继续阅读 »

前言


自打华为2019年发布鸿蒙操作系统以来,网上各种声音百家争鸣。尤其是2023年发布会公布的鸿蒙4.0宣称不再支持Android,更激烈的讨论随之而来。

本文没有宏大的叙事,只有基于现实的考量。

通过本文,你将了解到:




  1. HarmonyOS与OpenHarmony区别

  2. 华为手机的市场占有率

  3. HarmonyOS的市场占有率

  4. 移动开发现状

  5. 鸿蒙开发优劣势

  6. 到底需不需要入坑?



1. HarmonyOS与OpenHarmony区别


HarmonyOS


移动操作系统历史


当下移动端两大巨无霸操作系统瓜分了绝大部分市场:



image.png


iOS是闭源的,只有唯一的一家厂商:Apple。

Google开放了Android基础的能力,这些能力集构成了:Android Open Source Project(简称AOSP),这块是开源免费的,任何人/公司都可以基于此进行二次开发改动。

国内各大手机厂商基于此开发出自己的系统,大浪淘沙,目前主流市场上主要手机厂商及其操作系统如下:



image.png


以上系统均衍生自AOSP,在国内使用没什么问题,若要在国外使用则需要使用Google提供的一些基础服务:统称GMS,这是需要授权的。


HarmonyOS历史与现状


华为在2019年发布了HarmonyOS 1.0 ,彼时的该系统主要应用于智慧屏、手表等设备,在2021年发布的HarmonyOS 2.0 全面应用于Android手机。

也就是这个时候华为/荣耀(未分家前)手机设备都搭载了HarmonyOS,而我们知道换了手机系统但手机上的App并没有换,照样能够正常运行。

依照华为的说法,HarmonyOS兼容Android,而部分网友认为该兼容其实就是Android套壳。

这个时候开发者无需关心鸿蒙开发,因为即使开发了Android app也能够在搭载鸿蒙系统的设备上运行。

2023年华为宣布HarmonyOS Next不再支持Android,也就是说想要在HarmonyOS Next上安装Android app是不可能的事了。

那问题就来了,作为一名Android开发者,以前只需要一套代码就可以在华为/小米/荣耀/OPPO/VIVO上运行,现在不行了,需要单独针对搭载了HarmonyOS Next的华为手机开发一个App。

若当前的App是跨端开发,如使用RN、Flutter等,那么HarmonyOS的支持力度更不可知。


OpenHarmony


从上面的描述可知,只有华为一家主推HarmonyOS,相比整个市场还是太单薄,它需要更多的厂商共同使用、共同促进新系统的发展。

因此华为将HarmonyOS的基础能力剥离出来形成了:OpenAtom OpenHarmony(简称:OpenHarmony)。

OpenHarmony是开放原子开源基金会孵化及运营的开源项目。OpenHarmony由华为公司贡献主要代码、由多家单位共建,具备面向全场景、分布式等特点,是一款全领域、新一代、开源开放的智能终端操作系统。

OpenHarmony类似于Android领域的AOSP,而HarmonyOS则是华为基于OpenHarmony开发的商业版OS。

同样的,其它厂商也可以基于OpenHarmony做改动,发布属于自己的鸿蒙商业版。
通常说的鸿蒙生态是指OpenHarmony及其衍生的商业版鸿蒙系统。

OpenHarmony源码


2. 华为手机的市场占有率


全球手机出货量



image.png


可以看出Android(80%)和iOS(20%)瓜分了天下。

图上没有华为,它被归入了Others里。

点击查看数据来源


再看另一家的统计:



image.png


华为占用约为5%。

点击查看数据来源


第三家的统计:



image.png


点击查看数据来源


虽然各家统计的数据有差异,但可以看出华为在全球手机市场份额并不高。


国内手机市场占有率



image.png


点击查看数据来源


这么看,华为在国内的占有率达到了1/4。


3. HarmonyOS的市场占有率


全球市场系统占有率


手机市场占有率并不代表都搭载了鸿蒙操作系统。

来看看各大操作系统的占有率。



image.png


点击查看数据来源


可以看出,Android和iOS设备量很多,遥遥领先。


再细分移动端的市场占有:



image.png



image.png


点击查看数据来源
同样的Android遥遥领先,此时HarmonyOS占据了3%的份额。


美国市场占有率



image.png



image.png


可以看出,在美国,Android、iOS势均力敌,唯二的存在。


印度市场占有率


再看神秘的东方大国数据:



image.png



image.png


由此可见,在印度,Android才是和咖喱最配的存在,iOS还是太耗家底了。

怪不得小米等一众国内厂商去卷印度了,市场大大滴有,就看能不能躲过印度的罚款。。。


国内鸿蒙市场占有率



image.png



image.png


国内市场里,HarmonyOS占据高达13%,毕竟国内使用华为(荣耀)手机的存量还是蛮多的。


结论:



国内才是使用鸿蒙系统的大头市场



华为官方宣称的占有率



image.png


点击查看数据来源


这里说的设备不止是智能手机,还有平板、座舱、手表等嵌入式设备。


4. 移动开发现状


iOS开发现状


iOS最先火起来的,遥想十年前,随便一个iOS开发者都能找到工作。而现在存留的iOS开发者自嘲:"Dog都不学iOS"。

以前的开发者要么转行,要么继续用"最好"的编译器(xcode)写"最优秀"的语言(OC),当然也可以用Swift,但限于系统要求,SwiftUI也没有大规模普及。

现在很少见有新鲜的血液学习iOS(也有可能iOS装备比较贵吧)了,再加上各种跨平台的框架的投入使用,原生iOS开发者的生存空间越来越小了。


Android开发现状


无独有偶,移动端的难兄难弟怎么会缺少Android呢?

一开始Android使用Java,后面全面拥抱Kotlin。

一开始画画UI,写写逻辑就能找到一份糊口的工作,现在需要去卷各种框架的底层原理,为了KPI需要去研究各种奇淫技巧的性能优化。

跨平台的框架需要去卷,KMP(已稳定)+Compose你学会了吗?RN、Flutter、Uni-app你又懂了多少?

与iOS相比Android可选择的多一些,可以选择车载等其它嵌入式设备,但多不了多少,原生Android开发者的生存空间亦不容乐观。


跨平台的开发框架移动端原生开发者可以学,前端的同学也会过来学,比如RN,Uni-app优势在前端。



行业萎缩,通常不是技术的错,技术一直在,可惜市场需求变少了



5. 鸿蒙开发优劣势


是机会还是坑?


从国内各种新闻来看:



image.png



image.png


看起来是如火如荼。


从国际的新闻看:



image.png


翻看了前几页的新闻,讨论的热度并不高,大多是搬自国内的新闻。


再说说薪资:



image.png


一看就是有夸大的成分,可能真有人达到了,但人数可能是万里挑一,只讲个例不讲普遍性没有意义。


某Boss搜一下北京的岗位:



img_v3_026m_8d70f837-9ff5-4c81-a250-6b5cf7b3198g.jpg


北京的岗位也不多,而且招的都是比较资深的,北京如此,其它城市更不用说。


鸿蒙的基建



image.png


鸿蒙目前提供提供了方舟编译器,方舟语言、IDE、模拟器等一站式开发工具,开发者可以照着官方文档编写。


根据实操的结论:




  1. 各项更新比较快,导致官方的视频/ppt和实际的有些差异

  2. 模拟器有些卡顿,有点当时Android模拟器刚出来的既视感,真机买不起

  3. 排坑的文档不多,属于摸着官方教程过河



鸿蒙官网


鸿蒙入门的简易程度



  1. 基于TS,前端开发方式,语言并不难入手

  2. IDE和Android Studio同出一源,入手比较快

  3. 声明式UI,画UI快,没接触过的同学需要熟悉一下(现在无论是Swift还是Kotlin都支持声明式UI,前端老早就用得飞起了)

  4. 不用再被graddle各种莫名错误折磨了

  5. 中文文档,对英语不好的同学体验比较好


6. 到底需不需要入坑?


对于任何一个操作系统来说,生态是第一位,鸿蒙也不例外。

横亘于鸿蒙面前的难关:




  1. 主流App是否愿意适配鸿蒙系统?

  2. 其它Android厂商是否愿意接入鸿蒙系统?

  3. 鸿蒙对开发者的支持完善与否?

  4. 鸿蒙是否真如宣传般的优秀?



不论鸿蒙是否成功,它对开发者最大的意义在于:



开辟了新的领域,开发者有机会吃到可能的"红利"



而是否入坑,取决于个人的考量,以下仅供参考:




  1. 如果贵司需要适配鸿蒙,那么只能入坑

  2. 如果对鸿蒙兴趣不足,只是觉得最近的热点有点高,未雨绸缪,想试试水,那么可以照着官方文档试试Demo

  3. 如果押宝鸿蒙,则需要深入鸿蒙的各项开发,而不仅仅只是流于表面,当然此种方式下需要花费更多的时间、精力、头发去探索、排坑

  4. 如果认为鸿蒙没有前途,那么也没必要对此冷嘲热讽,静观其变即可



那么,2024年了,你如何选择呢?


作者:小鱼人爱编程
来源:juejin.cn/post/7318561797451481129
收起阅读 »

Java中的一些编程经验

最近看公司项目,其中能学到很多的编程经验,正好总结学习一下 判空的处理 公司的判空处理,我看每个人都有每个人的喜好,这么多列出来,一看还真不知道这些的区别,正好今天总结学习一下😎: StrUtil.isBlank:这个一看就是处理字符串的,用于检查一个字符...
继续阅读 »

最近看公司项目,其中能学到很多的编程经验,正好总结学习一下



判空的处理


公司的判空处理,我看每个人都有每个人的喜好,这么多列出来,一看还真不知道这些的区别,正好今天总结学习一下😎:



  • StrUtil.isBlank:这个一看就是处理字符串的,用于检查一个字符串是否为 null、空字符串("")或者只包含空白字符(如空格、制表符、换行符等),注意只包含空格也会别认定为false

  • Objects.nonNull:它是 java.util.Objects 类的一部分。这个方法用于检查一个对象是否不为 null

  • ObjectUtil.isNull:这个也是检查对象是否为空的

  • CollUtil.isNotEmptyCollUtil.isNotEmpty 方法用于检查一个集合是否非空,即集合中至少包含一个元素,这个主要来检查集合的


这么总结一看,发现挺好区分的,字符串和集合都有对应的处理类,然后对象判空的话,两个都可以,看个人喜好了😁😁😁


异步的使用



看公司代码中调用异步任务的时候,使用了自己不熟悉的类,正好来学习总结一下



先学概念


CompletableFuture概念:是 Java 8 中引入的一个类,它是 java.util.concurrent 包的一部分,用于简化异步编程模型。CompletableFuture 提供了一种更加直观的方式来处理异步操作的结果,以及在异步操作完成后执行后续操作。


说人话😭:就是java.util.concurrent 包下的一个用来异步编程的一个类


核心知识



  • 链式调用:支持链式调用,这意味着你可以在异步任务完成后执行其他操作,如处理结果、执行新的异步任务等

  • 线程安全CompletableFuture 的操作是线程安全的,这意味着你可以在多线程环境中安全地使用它。

  • 异步回调CompletableFuture 可以通过 thenAcceptthenRun 方法来定义异步任务完成后的回调操作。


写个Demo


CompletableFuture 的使用示例:


创建一个异步任务:


 CompletableFuture<String> future = CompletableFuture.supplyAsync(() -> {
     // 异步执行的代码
     return "Hello, World!";
 });

分析:代码中创建一个异步任务,该任务会执行一个 Supplier 函数式接口的实现,这个实现返回一个字符串 "Hello, World!"。supplyAsync 方法会启动一个新的线程来执行这个任务,并且返回一个 CompletableFuture<String> 对象,这个对象代表了异步任务的执行结果。


OK,结束


别走😭,来都来了,多学点:


试试链式调用:


 future.thenApply(s -> s.toUpperCase())
      .thenAccept(System.out::println)
      .exceptionally(e -> {
           System.err.println("An error occurred: " + e.getMessage());
           return null;
      });

分析thenApply 方法用于指定一个函数,这个函数将异步任务的结果作为输入,并返回一个新的结果。在这个例子中,它将字符串转换为大写。


thenAccept 方法用于指定一个消费者函数,这个函数接受 thenApply 方法的结果,并执行某些操作(在这个例子中是打印字符串)。


exceptionally 方法用于指定一个异常处理器,如果前面的操作(thenApplythenAccept)抛出异常,这个处理器会被调用,打印错误信息。


调用结果


 try {
     String result = future.get();
     System.out.println("Result: " + result);
 } catch (InterruptedException | ExecutionException e) {
     e.printStackTrace();
 }

分析:future.get() 方法用于获取异步任务的结果。这个方法会阻塞当前线程,直到异步任务完成。


如果任务被中断或者执行过程中抛出异常,get 方法会抛出 InterruptedExceptionExecutionException


工程实践


学完了一些基本的,看一下公司代码是怎么写的😶‍🌫️:


         CompletableFuture.runAsync(() -> {
             Thread thread = new Thread(uuid) {
                 @Override
                 public void run() {
 ​
                     try {
                         taskContentInfoData(params, uuid, finalInputStream, insertPercent, flDtoList);
                    } catch (Exception exception) {
                         writeJobStatus(uuid, JobStatusEnum.FAIL.getStatus(), null);
                         log.info("错误信息{}", exception);
                         CommonConstants.threadPoolMap.remove(uuid);
                    }
                }
            };
             thread.start();
        });

也很简单,就是 CompletableFuture.runAsync来异步执行一个 Runnable 对象


分析:公司这里处理的也能达到异步的效果,这个实现的run方法里面,又开启了一个线程,主要是为了设置这个线程的唯一标识,所以有点绕。


顺便复习一下创建线程的几种方式:继承Thread类、实现Runnable接口,线程池创建


其中也可以直接创建Thread类来重写其run方法来创建🙌🙌🙌


作者:CoderLiz
来源:juejin.cn/post/7317325051476525093
收起阅读 »

如何写一个redis蜜罐

写在前面 蜜罐就是一种通过模拟真实环境来诱导入侵者的一种技术。通过它可以拖延黑客入侵时间,递给黑客用于取证假数据,溯源黑客等。通过控制平台去操作部署的仿真环境实现高效的诱捕。 之前写过一个简单的仿真redis蜜罐,简单介绍一下。 RESP 搭建这种组件的仿真环...
继续阅读 »

写在前面


蜜罐就是一种通过模拟真实环境来诱导入侵者的一种技术。通过它可以拖延黑客入侵时间,递给黑客用于取证假数据,溯源黑客等。通过控制平台去操作部署的仿真环境实现高效的诱捕。


之前写过一个简单的仿真redis蜜罐,简单介绍一下。


RESP


搭建这种组件的仿真环境,要么用真实的程序,要么就自己实现一套虚假的程序。假如自己实现的话,最关键的就是协议,对于redis来说,它的通信协议相对简单,就是resp,协议格式如下:



  • 单行字符串(Simple Strings): 响应的首字节是 "+"。例如:"+OK\r\n"。

  • 错误(Errors): 响应的首字节是 "-"。例如:"-ERROR message\r\n"。

  • 整型(Integers): 响应的首字节是 ":"。例如:":0\r\n"。

  • 多行字符串(Bulk Strings): 响应的首字节是"",后面跟字符长度,然后跟字符。例如:"",后面跟字符长度,然后跟字符。例如:"6\r\nfoobar\r\n"

  • 数组(Arrays): 响应的首字节是 "*"。例如:"*2\r\n3\nfoo˚\n˚3\r\nfoo\r\n3\r\nbar\r\n"


这就是它的通信协议,相当简单,比如我们想实现一个简单的get key操作,那么协议对应的字符格式为


*2\r\n$3\r\nget\r\n$3\r\nkey\r\n

然后传送给服务端就行。


Redis蜜罐


对于蜜罐,我们只需要实现服务端即可,客户端就用redis-cli这个工具就行。废话不多说,直接贴一下项目github.com/SSRemex/sil…



很简单,server.py实现了一个socket服务,同时加了日志收集功能。


而resp.py则实现了命令解析、命令执行、结果格式处理等操作


命令解析和结果格式主要是协议的解析和封装,这里主要想说一下两点,就是实现了一部分redis的命令


...
class RespHandler:
def __init__(self):
# 用来临时存储数据的字典
self.k_v_dict = {
"admin": "12345"
}

self.executable_command = {
"ping": (self.ping, True),
"get": (self.get, True),
"set": (self.set, True),
"keys": (self.keys, True),
"auth": (self.auth, True),
"del": (self.delete, True),
"exists": (self.exists, True),
"dbsize": (self.dbsize, True),
"config": (self.config, True)

}
self.unexecutable_command = [
"hget", "hset", "hdel", "hlen", "hexists", "hkeys", "hvals", "hgetall", "hincrby", "hincrbyfloat",
"hstrlen", "shutdown", "expire", "expireat", "pexpire", "pexpireat", "ttl", "type", "rename", "renamenx",
"randomkey", "move", "dump", "restore", "migrate", "scan", "select", "flushdb", "flushall", "mset", "mget",
"incr", "decr", "append", "strlen", "getset", "setrange", "getrange", "rpush", "lpush", "linsert", "lrange",
"lindex", "llen", "rpop", "lpop", "lrem", "lset", "blpop",

]
...

这里我内定了一些命令,并实现了他们的功能,让它像真的redis一样,你甚至可以进行kv操作,同时为了真实性,设定了一堆不可执行的命令,调用时会返回redis的报错,就像在配置文件里面禁用了这些命令一样。


演示


服务端执行,默认运行在3998端口



redis-cli连接



可以发现成功连接



此时服务端这边也接收到了,并且生成了日志



接下来,我们在redis-cli执行一些命令



很完美,甚至可以进行set get操作。


最后再次附上项目地址:github.com/SSRemex/sil…


作者:银空飞羽
来源:juejin.cn/post/7316783747491086371
收起阅读 »

90%的Java开发人员都会犯的5个错误

前言 作为一名java开发程序员,不知道大家有没有遇到过一些匪夷所思的bug。这些错误通常需要您几个小时才能解决。当你找到它们的时候,你可能会默默地骂自己是个傻瓜。是的,这些可笑的bug基本上都是你忽略了一些基础知识造成的。其实都是很低级的错误。今天,我总结一...
继续阅读 »



前言


作为一名java开发程序员,不知道大家有没有遇到过一些匪夷所思的bug。这些错误通常需要您几个小时才能解决。当你找到它们的时候,你可能会默默地骂自己是个傻瓜。是的,这些可笑的bug基本上都是你忽略了一些基础知识造成的。其实都是很低级的错误。今天,我总结一些常见的编码错误,然后给出解决方案。希望大家在日常编码中能够避免这样的问题。


1. 使用Objects.equals比较对象


这种方法相信大家并不陌生,甚至很多人都经常使用。是JDK7提供的一种方法,可以快速实现对象的比较,有效避免烦人的空指针检查。但是这种方法很容易用错,例如:


Long longValue = 123L;
System.out.println(longValue==123); //true
System.out.println(Objects.equals(longValue,123)); //false

为什么替换==Objects.equals()会导致不同的结果?这是因为使用==编译器会得到封装类型对应的基本数据类型longValue,然后与这个基本数据类型进行比较,相当于编译器会自动将常量转换为比较基本数据类型, 而不是包装类型。


使用该Objects.equals()方法后,编译器默认常量的基本数据类型为int。下面是源码Objects.equals(),其中a.equals(b)使用的是Long.equals()会判断对象类型,因为编译器已经认为常量是int类型,所以比较结果一定是false


public static boolean equals(Object a, Object b) {
return (a == b) || (a != null && a.equals(b));
}

public boolean equals(Object obj) {
if (obj instanceof Long) {
return value == ((Long)obj).longValue();
}
return false;
}

知道了原因,解决方法就很简单了。直接声明常量的数据类型,如Objects.equals(longValue,123L)。其实如果逻辑严密,就不会出现上面的问题。我们需要做的是保持良好的编码习惯。


2. 日期格式错误


在我们日常的开发中,经常需要对日期进行格式化,但是很多人使用的格式不对,导致出现意想不到的情况。请看下面的例子。


Instant instant = Instant.parse("2021-12-31T00:00:00.00Z");
DateTimeFormatter formatter = DateTimeFormatter.ofPattern("YYYY-MM-dd HH:mm:ss")
.withZone(ZoneId.systemDefault());
System.out.println(formatter.format(instant));//2022-12-31 08:00:00

以上用于YYYY-MM-dd格式化, 年从2021 变成了 2022。为什么?这是因为 javaDateTimeFormatter 模式YYYYyyyy之间存在细微的差异。它们都代表一年,但是yyyy代表日历年,而YYYY代表星期。这是一个细微的差异,仅会导致一年左右的变更问题,因此您的代码本可以一直正常运行,而仅在新的一年中引发问题。12月31日按周计算的年份是2022年,正确的方式应该是使用yyyy-MM-dd格式化日期。


这个bug特别隐蔽。这在平时不会有问题。它只会在新的一年到来时触发。我公司就因为这个bug造成了生产事故。


3. 在 ThreadPool 中使用 ThreadLocal


如果创建一个ThreadLocal 变量,访问该变量的线程将创建一个线程局部变量。合理使用ThreadLocal可以避免线程安全问题。


但是,如果在线程池中使用ThreadLocal ,就要小心了。您的代码可能会产生意想不到的结果。举个很简单的例子,假设我们有一个电商平台,用户购买商品后需要发邮件确认。


private ThreadLocal currentUser = ThreadLocal.withInitial(() -> null);

private ExecutorService executorService = Executors.newFixedThreadPool(4);

public void executor() {
executorService.submit(()->{
User user = currentUser.get();
Integer userId = user.getId();
sendEmail(userId);
});
}

如果我们使用ThreadLocal来保存用户信息,这里就会有一个隐藏的bug。因为使用了线程池,线程是可以复用的,所以在使用ThreadLocal获取用户信息的时候,很可能会误获取到别人的信息。您可以使用会话来解决这个问题。


4. 使用HashSet去除重复数据


在编码的时候,我们经常会有去重的需求。一想到去重,很多人首先想到的就是用HashSet去重。但是,不小心使用 HashSet 可能会导致去重失败。


User user1 = new User();
user1.setUsername("test");

User user2 = new User();
user2.setUsername("test");

List users = Arrays.asList(user1, user2);
HashSet sets = new HashSet<>(users);
System.out.println(sets.size());// the size is 2

细心的读者应该已经猜到失败的原因了。HashSet使用hashcode对哈希表进行寻址,使用equals方法判断对象是否相等。如果自定义对象没有重写hashcode方法和equals方法,则默认使用父对象的hashcode方法和equals方法。所以HashSet会认为这是两个不同的对象,所以导致去重失败。


5. 线程池中的异常被吃掉


ExecutorService executorService = Executors.newFixedThreadPool(1);
executorService.submit(()->{
//do something
double result = 10/0;
});

上面的代码模拟了一个线程池抛出异常的场景。我们真正的业务代码要处理各种可能出现的情况,所以很有可能因为某些特定的原因而触发RuntimeException


但是如果没有特殊处理,这个异常就会被线程池吃掉。这样就会导出出现问题你都不知道,这是很严重的后果。因此,最好在线程池中try catch捕获异常。


总结


本文总结了在开发过程中很容易犯的5个错误,希望大家养成良好的编码习惯。


作者:JAVA旭阳
来源:juejin.cn/post/7182184496517611576
收起阅读 »

如何优雅的将MultipartFile和File互转

我们在开发过程中经常需要接收前端传来的文件,通常需要处理MultipartFile格式的文件。今天来介绍一下MultipartFile和File怎么进行优雅的互转。 前言 首先来区别一下MultipartFile和File: MultipartFile是 S...
继续阅读 »

我们在开发过程中经常需要接收前端传来的文件,通常需要处理MultipartFile格式的文件。今天来介绍一下MultipartFile和File怎么进行优雅的互转。


前言


首先来区别一下MultipartFile和File:



  • MultipartFile是 Spring 框架的一部分,File是 Java 标准库的一部分。

  • MultipartFile主要用于接收上传的文件,File主要用于操作系统文件。


MultipartFile转换为File


使用 transferTo


这是一种最简单的方法,使用MultipartFile自带的transferTo 方法将MultipartFile转换为File,这里通过上传表单文件,将MultipartFile转换为File格式,然后输出到特定的路径,具体写法如下。


transferto.png


使用 FileOutputStream


这是最常用的一种方法,使用 FileOutputStream 可以将字节写入文件。具体写法如下。


FileOutputStream.png


使用 Java NIO


Java NIO 提供了文件复制的方法。具体写法如下。


copy.png


File装换为MultipartFile


从File转换为MultipartFile 通常在测试或模拟场景中使用,生产环境一般不这么用,这里只介绍一种最常用的方法。


使用 MockMultipartFile


在转换之前先确保引入了spring-test 依赖(以Maven举例)


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

通过获得File文件的名称、mime类型以及内容将其转换为MultipartFile格式。具体写法如下。


multi.png



更多文章干货,推荐公众号【程序员老J】



作者:程序员老J
来源:juejin.cn/post/7295559402475667492
收起阅读 »

让SQL起飞(优化)

最近博主看完了《SQL进阶教程》这本书,看完后给博主打开了SQL世界的新大门,对于 SQL 的理解不在局限于以前的常规用法。借用其他读者的评论, 读完醍醐灌顶,对SQL做到了知其然更能知其所以然。全书从头到尾强调了 SQL的内在逻辑是基于集合论和谓词逻辑,而...
继续阅读 »

最近博主看完了《SQL进阶教程》这本书,看完后给博主打开了SQL世界的新大门,对于 SQL 的理解不在局限于以前的常规用法。借用其他读者的评论,



读完醍醐灌顶,对SQL做到了知其然更能知其所以然。全书从头到尾强调了 SQL的内在逻辑是基于集合论和谓词逻辑,而这两条主线恰恰对使用SQL起到了至关重要的指导作用。



本文给大家总结如何让SQL起飞(优化)


一、SQL写法优化


在SQL中,很多时候不同的SQL代码能够得出相同结果。从理论上来说,我们认为得到相同结果的不同SQL之间应该有相同的性能,但遗憾的是,查询优化器生成的执行计划很大程度上受到SQL代码影响,有快有慢。因此如果想优化查询性能,我们必须知道如何写出更快的SQL,才能使优化器的执行效率更高。


1.1 子查询用EXISTS代替IN


当IN的参数是子查询时,数据库首先会执行子查询,然后将结果存储在一张临时的工作表里(内联视图),然后扫描整个视图。很多情况下这种做法都非常耗费资源。使用EXISTS的话,数据库不会生成临时的工作表。但是从代码的可读性上来看,IN要比EXISTS好。使用IN时的代码看起来更加一目了然,易于理解。因此,如果确信使用IN也能快速获取结果,就没有必要非得改成EXISTS了。


这里用Class_A表和Class_B举例,

我们试着从Class_A表中查出同时存在于Class_B表中的员工。下面两条SQL语句返回的结果是一样的,但是使用EXISTS的SQL语句更快一些。


--慢
SELECT *
FROM Class_A
WHERE id IN (SELECT id
FROM Class_B);

--快
SELECT *
FROM Class_A A
WHERE EXISTS
(SELECT *
FROM Class_B B
WHERE A.id = B.id);

使用EXISTS时更快的原因有以下两个。



  1. 如果连接列(id)上建立了索引,那么查询 tb_b 时不用查实际的表,只需查索引就可以了。(同样的IN也可以使用索引,这不是重要原因)

  2. 如果使用EXISTS,那么只要查到一行数据满足条件就会终止查询,不用像使用IN时一样扫描全表。在这一点上NOT EXISTS也一样。


实际上,大部分情况在子查询数量较小的场景下EXISTS和IN的查询性能不相上下,由EXISTS查询更快第二点可知,子查询数量较大时使用EXISTS才会有明显优势。


1.2 避免排序并添加索引


在SQL语言中,除了ORDER BY子句会进行显示排序外,还有很多操作默认也会在暗中进行排序,如果排序字段没有添加索引,会导致查询性能很慢。SQL中会进行排序的代表性的运算有下面这些。



  • GR0UP BY子句

  • ORDER BY子句

  • 聚合函数(SUM、COUNT、AVG、MAX、MIN)

  • DISTINCT

  • 集合运算符(UNION、INTERSECT、EXCEPT)

  • 窗口函数(RANK、ROW_NUMBER等)


如上列出的六种运算(除了集合运算符),它们后面跟随或者指定的字段都可以添加索引,这样可以加快排序。



实际上在DISTINCT关键字、GR0UP BY子句、ORDER BY子句、聚合函数跟随的字段都添加索引,不仅能加速查询,还能加速排序。



1.3 用EXISTS代替DISTINCT


为了排除重复数据,我们可能会使用DISTINCT关键字。如1.2中所说,默认情况下,它也会进行暗中排序。如果需要对两张表的连接结果进行去重,可以考虑使用EXISTS代替DISTINCT,以避免排序。这里用Items表和SalesHistory表举例:

我们思考一下如何从上面的商品表Items中找出同时存在于销售记录表SalesHistory中的商品。简而言之,就是找出有销售记录的商品。


在一(Items)对多(SalesHistory)的场景下,我们需要对item_no去重,使用DISTINCT去重,因此SQL如下:


SELECT DISTINCT I.item_no
FROM Items I INNER JOIN SalesHistory SH
ON I. item_no = SH. item_no;

item_no
-------
10
20
30

使用EXISTS代替DISTINCT去重,SQL如下:


SELECT item_no
FROM Items I
WHERE EXISTS
(SELECT
FROM SalesHistory SH
WHERE I.item_no = SH.item_no);
item_no
-------
10
20
30

这条语句在执行过程中不会进行排序。而且使用EXISTS和使用连接一样高效。


1.4 集合运算ALL可选项


SQL中有UNION、INTERSECT、EXCEPT三个集合运算符。在默认的使用方式下,这些运算符会为了排除掉重复数据而进行排序。



MySQL还没有实现INTERSECT和EXCEPT运算



如果不在乎结果中是否有重复数据,或者事先知道不会有重复数据,请使用UNION ALL代替UNION。这样就不会进行排序了。


1.5 WHERE条件不要写在HAVING字句


例如,这里继续用SalesHistory表举例,下面两条SQL语句返回的结果是一样的:


--聚合后使用HAVING子句过滤
SELECT sale_date, SUM(quantity)
FROM SalesHistory
GR0UP BY sale_date
HAVING sale_date = '2007-10-01';

--聚合前使用WHERE子句过滤
SELECT sale_date, SUM(quantity)
FROM SalesHistory
WHERE sale_date = '2007-10-01'
GR0UP BY sale_date;

但是从性能上来看,第二条语句写法效率更高。原因有两个:



  1. 使用GR0UP BY子句聚合时会进行排序,如果事先通过WHERE子句筛选出一部分行,就能够减轻排序的负担。

  2. 在WHERE子句的条件里可以使用索引。HAVING子句是针对聚合后生成的视图进行筛选的,但是很多时候聚合后的视图都没有继承原表的索引结构。


二、真的用到索引了吗


2.1 隐式的类型转换


如下,col_1字段是char类型:


-- 没走索引
SELECT * FROM SomeTable WHERE col_1 = 10;
-- 走了索引
SELECT * FROM SomeTable WHERE col_1 ='10';
-- 走了索引
SELECT * FROM SomeTable WHERE col_1 = CAST(10, AS CHAR(2));

当查询条件左边和右边类型不一致时会导致索引失效。


2.2 在索引字段上进行运算


如下:


SELECT *
FROM SomeTable
WHERE col_1 * 1.1 > 100;

在索引字段col_1上进行运算会导致索引不生效,把运算的表达式放到查询条件的右侧,就能用到索引了,像下面这样写就OK了。


WHERE col_1 > 100 / 1.1

如果无法避免在左侧进行运算,那么使用函数索引也是一种办法,但是不太推荐随意这么做。使用索引时,条件表达式的左侧应该是原始字段请牢记,这一点是在优化索引时首要关注的地方。


2.3 使用否定形式


下面这几种否定形式不能用到索引。



  • <>

  • !=

  • NOT


这个是跟具体数据库的优化器有关,如果优化器觉得即使走了索引,还是需要扫描很多很多行的话,他可以选择直接不走索引。平时我们用!=、<>、not in的时候,要注意一下。


2.4 使用OR查询前后没有同时使用索引


例如下表:


CREATE TABLE test_tb ( 
id int(11) NOT NULL AUTO_INCREMENT,
name varchar(55) NOT NULL
PRIMARY KEY (id)
)
ENGINE=InnoDB DEFAULT CHARSET=utf8;

使用OR条件进行查询


SELECT * 
FROM test_tb
WHERE id = 1 OR name = 'tom'

这个SQL的执行条件下,很明显id字段查询会走索引,但是对于OR后面name字段的查询是需要进行全表扫描的。在这个场景下,优化器会选择直接进行一遍全表扫描。


2.5 使用联合索引时,列的顺序错误


使用联合索引需要满足最左匹配原则,即最左优先。如果你建立一个(col_1, col_2, col_3)的联合索引,相当于建立了 (col_1)、(col_1,col_2)、(col_1,col_2,col_3) 三个索引。如下例子:


-- 走了索引
SELECT * FROM SomeTable WHERE col_1 = 10 AND col_2 = 100 AND col_3 = 500;
-- 走了索引
SELECT * FROM SomeTable WHERE col_1 = 10 AND col_2 = 100 ;
-- 没走索引
SELECT * FROM SomeTable WHERE col_1 = 10 AND col_3 = 500 ;
-- 没走索引
SELECT * FROM SomeTable WHERE col_2 = 100 AND col_3 = 500 ;
-- 走了索引
SELECT * FROM SomeTable WHERE col_2 = 100 AND col_1 = 10 ;

联合索引中的第一列(col_1)必须写在查询条件的开头,而且索引中列的顺序不能颠倒。



可能需要说明的是最后一条SQL为什么会走索引,简单转化一下,col_2 = 100 AND col_1 = 10,
这个条件就相当于col_1 = 10 AND col_2 = 100,自然就可以走联合索引。



2.6 使用LIKE查询


并不是用了like通配符,索引一定会失效,而是like查询是以%开头,才会导致索引失效。


-- 没走索引
SELECT * FROM SomeTable WHERE col_1 LIKE'%a';
-- 没走索引
SELECT * FROM SomeTable WHERE col_1 LIKE'%a%';
-- 走了索引
SELECT * FROM SomeTable WHERE col_1 LIKE'a%';

2.7 连接字段字符集编码不一致


如果两张表进行连接,关联字段编码不一致会导致关联字段上的索引失效,这是博主在线上经历一次SQL慢查询后的得到的结果,举例如下,有如下两表,它们的name字段都建有索引,但是编码不一致,user表的name字段编码是utf8mb4,user_job表的name字段编码是utf8,


CREATE TABLE `user` (
`id` int NOT NULL AUTO_INCREMENT,
`name` varchar(255) CHARACTER
SET utf8mb4 COLLATE utf8mb4_0900_ai_ci DEFAULT NULL,
`age` int NOT NULL,
PRIMARY KEY (`id`),
KEY `idx_name` (`name`)
) ENGINE=InnoDB AUTO_INCREMENT=2 DEFAULT CHARSET=utf8;

CREATE TABLE `user_job` (
`id` int NOT NULL,
`userId` int NOT NULL,
`job` varchar(255) DEFAULT NULL,
`name` varchar(255) DEFAULT NULL,
PRIMARY KEY (`id`),
KEY `idx_name` (`name`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8;

进行SQL查询如下:


EXPLAIN
SELECT *
from `user` u
join user_job j on u.name = j.name


由结果可知,user表查询走了索引,user_job表的查询没有走索引。想要user_job表也走索引,可以把user表的name字段编码改成utf8即可。


三、减少中间表


在SQL中,子查询的结果会被看成一张新表,这张新表与原始表一样,可以通过代码进行操作。这种高度的相似性使得SQL编程具有非常强的灵活性,但是如果不加限制地大量使用中间表,会导致查询性能下降。


频繁使用中间表会带来两个问题,一是展开数据需要耗费内存资源,二是原始表中的索引不容易使用到(特别是聚合时)。因此,尽量减少中间表的使用也是提升性能的一个重要方法。


3.1 使用HAVING子句


对聚合结果指定筛选条件时,使用HAVING子句是基本原则。不习惯使用HAVING子句的人可能会倾向于像下面这样先生成一张中间表,然后在WHERE子句中指定筛选条件。例如下面:


SELECT * 
FROM (
SELECT sale_date, MAX(quantity) max_qty
FROM SalesHistory
GR0UP BY sale_date
) tmp
WHERE max_qty >= 10

然而,对聚合结果指定筛选条件时不需要专门生成中间表,像下面这样使用HAVING子句就可以。


SELECT sale_date, MAX(quantity)
FROM SalesHistory
GR0UP BY sale_date
HAVING MAX(quantity) >= 10;

HAVING子句和聚合操作是同时执行的,所以比起生成中间表后再执行的WHERE子句,效率会更高一些,而且代码看起来也更简洁。


3.2 对多个字段使用IN


当我们需要对多个字段使用IN条件查询时,可以通过 || 操作将字段连接在一起变成一个字符串处理。


SELECT *
FROM Addresses1 A1
WHERE id || state || city
IN (SELECT id || state|| city
FROM Addresses2 A2);

这样一来,子查询不用考虑关联性,而且只执行一次就可以。


需要说明的MySql中,|| 操作符是代表或者也就是OR的意思。在Mysql中可以使用下面多种写法,如下:


-- 使用CONCAT(str1,str2,...)函数,将多列合并为一个字符串
SELECT *
FROM Addresses1 A1
WHERE CONCAT(id, state, city)
IN ('1湖北武汉', '2湖北黄冈');

-- 使用多列in查询
SELECT *
FROM Addresses1 A1
WHERE (id, state, city)
IN ((1, '湖北', '武汉'), (2, '湖北', '黄冈'));

使用多列in查询这个语法在实际执行中可以走索引,CONCAT(str1,str2,...) 函数不能。


3.3 先进行连接再进行聚合


连接和聚合同时使用时,先进行连接操作可以避免产生中间表。原因是,从集合运算的角度来看,连接做的是“乘法运算”。连接表双方是一对一、一对多的关系时,连接运算后数据的行数不会增加。而且,因为在很多设计中多对多的关系都可以分解成两个一对多的关系,因此这个技巧在大部分情况下都可以使用。


到此本文讲解完毕,感谢大家阅读,感兴趣的朋友可以点赞加关注,你的支持将是我更新动力😘。



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

你的@Autowired被警告了吗

一个警告 近期组里来了新同学,依赖注入的时候习惯使用@Autowired,发现被idea黄色警告,跑过来问大家。由于我平时习惯使用@Resource,没太注意过这个问题,刚好趁着这个机会学习了一波。 首先看下问题的现象,使用@Autowired被idea警告,...
继续阅读 »

一个警告


近期组里来了新同学,依赖注入的时候习惯使用@Autowired,发现被idea黄色警告,跑过来问大家。由于我平时习惯使用@Resource,没太注意过这个问题,刚好趁着这个机会学习了一波。

首先看下问题的现象,使用@Autowired被idea警告,而使用@Resource则不会:


image.png


image.png


@Autowired和@Resource的差异


来源


  • @Resource是JSR 250中的内容,发布时间是2006年

  • @Autowired是Spring2.5中的内容,发布时间是2007年


@Resource是JSR的标准,Spring框架也提供了实现。既然@Autowired是在@Resource之后发布的,应该就有@Resource不能表达的含义或者不能实现的功能。


用法

注入方式

虽然我们开发中使用最多的方式是属性注入,但其实存在构造函数注意、set方法注入等方式。相比于@Resource支持的属性注入和set方法注入,@Autowired还能支持构造方法注入的形式,@Resource是不行的。


image.png


image.png


可指定属性

@Autowired支持required属性


image.png


@Resource支持7个其它属性


image.png


bean查找策略


  • @Autowired是类型优先,如果这个类型的bean有多个,再根据名称定位

  • @Resource是名称优先,如果不存在这个名称的bean,再去根据类型查找


查找过程


@Autowired

auto.png


@Resource

resource.png


思考


对于大多数开发同学来说,@Autowired 和 @Resource 差异是很小的,虽然 @Autowired 多支持构造器注入的形式,但是直接属性注入真的太灵活太香了。而@Autowired晚生于@Resource,既然已经有JSR标准的@Resource,还要增加1个特有Autowired,必然有Spring的考虑。

个人认为,构造器注入和支持属性不同这个理由是很弱的,这些特性完全可以在@Resource上实现,也不违反JSR的约束。比较可能的原因是,含义的不同,而最大的不同体现在bean查找策略上,@Autowired默认byType,@Resource默认byName,这个不同其实隐含了,@Resource注入的bean,更加有确定性,你都已经确定了这个bean的名称了,而类型在Java中编译过程本身是个强依赖,其实这里相当于指定了类型和名称,注入的是一个非常确定的资源。而@Autowired是类型优先,根据类型去查找,相比于@Resource,确定性更弱,我知道这里要注入bean的类型,但是我不确定这个bean的名称,也隐含体现了java多态的思想。


总结


回到开篇的idea的警告,网上有很多人都赞同的一种说法是,@Resource是JSR规范,@Autowired是Spring提供,不推荐使用绑定了Spring的@Autowired,因为@Resource在更换了框架后,依然可以使用。我不太赞同这种说法,因为idea的错误提示很明确,Field injection is not recommended,不推荐使用属性注入的方式,那换成@Resource,我理解并没有解决这个问题,虽然idea确实不警告了,可能有点掩耳盗铃的意思。

比较推荐的做法是,使用构造方法注入,虽然很多时候会有点麻烦,特别是增加依赖的时候,但是正是这种麻烦,会让你不再那么随意做属性注入,更能保持类的职责单一。然后配合lombok,也可以让你省去这种麻烦,不过希望还是能通过这个警告时刻提醒自己保持类的职责单一。


image.png


作者:podongfeng
来源:juejin.cn/post/7265926762729717819
收起阅读 »

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
收起阅读 »