注册
环信即时通讯云

环信即时通讯云

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

环信开发文档

Demo体验

Demo体验

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

RTE开发者社区

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

技术讨论区

技术交流、答疑
资源下载

资源下载

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

iOS Library

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

Android Library

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

到了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
收起阅读 »

推荐一个小而全的第三方登录开源组件

大家好,我是 Java陈序员。 我们在企业开发中,常常需要实现登录功能,而有时候为了方便,就需要集成第三方平台的授权登录。如常见的微信登录、微博登录等,免去了用户注册步骤,提高了用户体验。 为了业务考虑,我们有时候集成的不仅仅是一两个第三方平台,甚至更多。这就...
继续阅读 »

大家好,我是 Java陈序员


我们在企业开发中,常常需要实现登录功能,而有时候为了方便,就需要集成第三方平台的授权登录。如常见的微信登录、微博登录等,免去了用户注册步骤,提高了用户体验。


为了业务考虑,我们有时候集成的不仅仅是一两个第三方平台,甚至更多。这就会大大的提高了工作量,那么有没有开源框架来统一来集成这些第三方授权登录呢?


答案是有的,今天给大家介绍的项目提供了一个第三方授权登录的工具类库


项目介绍


JustAuth —— 一个第三方授权登录的工具类库,可以让你脱离繁琐的第三方登录 SDK,让登录变得So easy!


JustAuth


JustAuth 集成了诸如:Github、Gitee、微博、钉钉、百度、Coding、腾讯云开发者平台、OSChina、支付宝、QQ、微信、淘宝、Google、Facebook、抖音、领英、小米、微软、今日头条、Teambition、StackOverflow、Pinterest、人人、华为、企业微信、酷家乐、Gitlab、美团、饿了么、推特、飞书、京东、阿里云、喜马拉雅、Amazon、Slack和 Line 等第三方平台的授权登录。


功能特色:



  • 丰富的 OAuth 平台:支持国内外数十家知名的第三方平台的 OAuth 登录。

  • 自定义 state:支持自定义 State 和缓存方式,开发者可根据实际情况选择任意缓存插件。

  • 自定义 OAuth:提供统一接口,支持接入任意 OAuth 网站,快速实现 OAuth 登录功能。

  • 自定义 Http:接口 HTTP 工具,开发者可以根据自己项目的实际情况选择相对应的HTTP工具。

  • 自定义 Scope:支持自定义 scope,以适配更多的业务场景,而不仅仅是为了登录。

  • 代码规范·简单:JustAuth 代码严格遵守阿里巴巴编码规约,结构清晰、逻辑简单。


安装使用


回顾 OAuth 授权流程


参与的角色



  • Resource Owner 资源所有者,即代表授权客户端访问本身资源信息的用户(User),也就是应用场景中的“开发者A”

  • Resource Server 资源服务器,托管受保护的用户账号信息,比如 Github
    Authorization Server 授权服务器,验证用户身份然后为客户端派发资源访问令牌,比如 Github

  • Resource ServerAuthorization Server 可以是同一台服务器,也可以是不同的服务器,视具体的授权平台而有所差异

  • Client 客户端,即代表意图访问受限资源的第三方应用


授权流程


OAuth 授权流程


使用步骤


1、申请注册第三方平台的开发者账号


2、创建第三方平台的应用,获取配置信息(accessKey, secretKey, redirectUri)


3、使用 JustAuth 实现授权登陆


引入依赖


<dependency>
<groupId>me.zhyd.oauthgroupId>
<artifactId>JustAuthartifactId>
<version>{latest-version}version>
dependency>

调用 API


// 创建授权request
AuthRequest authRequest = new AuthGiteeRequest(AuthConfig.builder()
.clientId("clientId")
.clientSecret("clientSecret")
.redirectUri("redirectUri")
.build());
// 生成授权页面
authRequest.authorize("state");
// 授权登录后会返回code(auth_code(仅限支付宝))、state,1.8.0版本后,可以用AuthCallback类作为回调接口的参数
// 注:JustAuth默认保存state的时效为3分钟,3分钟内未使用则会自动清除过期的state
authRequest.login(callback);


说明:
JustAuth 的核心就是一个个的 request,每个平台都对应一个具体的 request 类。
所以在使用之前,需要就具体的授权平台创建响应的 request.如示例代码中对应的是 Gitee 平台。



集成国外平台



国外平台需要额外配置 httpConfig



AuthRequest authRequest = new AuthGoogleRequest(AuthConfig.builder()
.clientId("Client ID")
.clientSecret("Client Secret")
.redirectUri("应用回调地址")
// 针对国外平台配置代理
.httpConfig(HttpConfig.builder()
// Http 请求超时时间
.timeout(15000)
// host 和 port 请修改为开发环境的参数
.proxy(new Proxy(Proxy.Type.HTTP, new InetSocketAddress("127.0.0.1", 10080)))
.build())
.build());

SpringBoot 集成


引入依赖


<dependency>
<groupId>com.xkcoding.justauthgroupId>
<artifactId>justauth-spring-boot-starterartifactId>
<version>1.4.0version>
dependency>

配置文件


justauth:
enabled: true
type:
QQ:
client-id: 10**********6
client-secret: 1f7d08**********5b7**********29e
redirect-uri: http://oauth.xkcoding.com/demo/oauth/qq/callback
union-id: false
WEIBO:
client-id: 10**********6
client-secret: 1f7d08**********5b7**********29e
redirect-uri: http://oauth.xkcoding.com/demo/oauth/weibo/callback
GITEE:
client-id: 10**********6
client-secret: 1f7d08**********5b7**********29e
redirect-uri: http://oauth.xkcoding.com/demo/oauth/gitee/callback
DINGTALK:
client-id: 10**********6
client-secret: 1f7d08**********5b7**********29e
redirect-uri: http://oauth.xkcoding.com/demo/oauth/dingtalk/callback
BAIDU:
client-id: 10**********6
client-secret: 1f7d08**********5b7**********29e
redirect-uri: http://oauth.xkcoding.com/demo/oauth/baidu/callback
CSDN:
client-id: 10**********6
client-secret: 1f7d08**********5b7**********29e
redirect-uri: http://oauth.xkcoding.com/demo/oauth/csdn/callback
CODING:
client-id: 10**********6
client-secret: 1f7d08**********5b7**********29e
redirect-uri: http://oauth.xkcoding.com/demo/oauth/coding/callback
coding-group-name: xx
OSCHINA:
client-id: 10**********6
client-secret: 1f7d08**********5b7**********29e
redirect-uri: http://oauth.xkcoding.com/demo/oauth/oschina/callback
ALIPAY:
client-id: 10**********6
client-secret: 1f7d08**********5b7**********29e
redirect-uri: http://oauth.xkcoding.com/demo/oauth/alipay/callback
alipay-public-key: MIIB**************DAQAB
WECHAT_OPEN:
client-id: 10**********6
client-secret: 1f7d08**********5b7**********29e
redirect-uri: http://oauth.xkcoding.com/demo/oauth/wechat_open/callback
WECHAT_MP:
client-id: 10**********6
client-secret: 1f7d08**********5b7**********29e
redirect-uri: http://oauth.xkcoding.com/demo/oauth/wechat_mp/callback
WECHAT_ENTERPRISE:
client-id: 10**********6
client-secret: 1f7d08**********5b7**********29e
redirect-uri: http://oauth.xkcoding.com/demo/oauth/wechat_enterprise/callback
agent-id: 1000002
TAOBAO:
client-id: 10**********6
client-secret: 1f7d08**********5b7**********29e
redirect-uri: http://oauth.xkcoding.com/demo/oauth/taobao/callback
GOOGLE:
client-id: 10**********6
client-secret: 1f7d08**********5b7**********29e
redirect-uri: http://oauth.xkcoding.com/demo/oauth/google/callback
FACEBOOK:
client-id: 10**********6
client-secret: 1f7d08**********5b7**********29e
redirect-uri: http://oauth.xkcoding.com/demo/oauth/facebook/callback
DOUYIN:
client-id: 10**********6
client-secret: 1f7d08**********5b7**********29e
redirect-uri: http://oauth.xkcoding.com/demo/oauth/douyin/callback
LINKEDIN:
client-id: 10**********6
client-secret: 1f7d08**********5b7**********29e
redirect-uri: http://oauth.xkcoding.com/demo/oauth/linkedin/callback
MICROSOFT:
client-id: 10**********6
client-secret: 1f7d08**********5b7**********29e
redirect-uri: http://oauth.xkcoding.com/demo/oauth/microsoft/callback
MI:
client-id: 10**********6
client-secret: 1f7d08**********5b7**********29e
redirect-uri: http://oauth.xkcoding.com/demo/oauth/mi/callback
TOUTIAO:
client-id: 10**********6
client-secret: 1f7d08**********5b7**********29e
redirect-uri: http://oauth.xkcoding.com/demo/oauth/toutiao/callback
TEAMBITION:
client-id: 10**********6
client-secret: 1f7d08**********5b7**********29e
redirect-uri: http://oauth.xkcoding.com/demo/oauth/teambition/callback
RENREN:
client-id: 10**********6
client-secret: 1f7d08**********5b7**********29e
redirect-uri: http://oauth.xkcoding.com/demo/oauth/renren/callback
PINTEREST:
client-id: 10**********6
client-secret: 1f7d08**********5b7**********29e
redirect-uri: http://oauth.xkcoding.com/demo/oauth/pinterest/callback
STACK_OVERFLOW:
client-id: 10**********6
client-secret: 1f7d08**********5b7**********29e
redirect-uri: http://oauth.xkcoding.com/demo/oauth/stack_overflow/callback
stack-overflow-key: asd*********asd
HUAWEI:
client-id: 10**********6
client-secret: 1f7d08**********5b7**********29e
redirect-uri: http://oauth.xkcoding.com/demo/oauth/huawei/callback
KUJIALE:
client-id: 10**********6
client-secret: 1f7d08**********5b7**********29e
redirect-uri: http://oauth.xkcoding.com/demo/oauth/kujiale/callback
GITLAB:
client-id: 10**********6
client-secret: 1f7d08**********5b7**********29e
redirect-uri: http://oauth.xkcoding.com/demo/oauth/gitlab/callback
MEITUAN:
client-id: 10**********6
client-secret: 1f7d08**********5b7**********29e
redirect-uri: http://oauth.xkcoding.com/demo/oauth/meituan/callback
ELEME:
client-id: 10**********6
client-secret: 1f7d08**********5b7**********29e
redirect-uri: http://oauth.xkcoding.com/demo/oauth/eleme/callback
TWITTER:
client-id: 10**********6
client-secret: 1f7d08**********5b7**********29e
redirect-uri: http://oauth.xkcoding.com/demo/oauth/twitter/callback
XMLY:
client-id: 10**********6
client-secret: 1f7d08**********5b7**********29e
redirect-uri: http://oauth.xkcoding.com/demo/oauth/xmly/callback
# 设备唯一标识ID
device-id: xxxxxxxxxxxxxx
# 客户端操作系统类型,1-iOS系统,2-Android系统,3-Web
client-os-type: 3
# 客户端包名,如果 clientOsType 为12时必填。对Android客户端是包名,对IOS客户端是Bundle ID
#pack-id: xxxx
FEISHU:
client-id: 10**********6
client-secret: 1f7d08**********5b7**********29e
redirect-uri: http://oauth.xkcoding.com/demo/oauth/feishu/callback
JD:
client-id: 10**********6
client-secret: 1f7d08**********5b7**********29e
redirect-uri: http://oauth.xkcoding.com/demo/oauth/jd/callback
cache:
type: default

代码使用


@Slf4j
@RestController
@RequestMapping("/oauth")
@RequiredArgsConstructor(onConstructor_ = @Autowired)
public class TestController {
private final AuthRequestFactory factory;

@GetMapping
public List list() {
return factory.oauthList();
}

@GetMapping("/login/{type}")
public void login(@PathVariable String type, HttpServletResponse response) throws IOException {
AuthRequest authRequest = factory.get(type);
response.sendRedirect(authRequest.authorize(AuthStateUtils.createState()));
}

@RequestMapping("/{type}/callback")
public AuthResponse login(@PathVariable String type, AuthCallback callback) {
AuthRequest authRequest = factory.get(type);
AuthResponse response = authRequest.login(callback);
log.info("【response】= {}", JSONUtil.toJsonStr(response));
return response;
}

}

总结


JustAuth 集成的第三方授权登录平台,可以说是囊括了业界中大部分主流的应用系统。如国内的微信、微博、Gitee 等,还有国外的 Github、Google 等。可以满足我们日常的开发需求,开箱即用,可快速集成!


最后,贴上项目地址:


https://github.com/justauth/JustAuth

在线文档:


https://www.justauth.cn/

最后


推荐的开源项目已经收录到 GitHub 项目,欢迎 Star


https://github.com/chenyl8848/great-open-source-project

或者访问网站,进行在线浏览:


https://chencoding.top:8090/#/

作者:Java陈序员
来源:juejin.cn/post/7312060958175559743
收起阅读 »

token过期了怎么办?

token过期了怎么办?一般做法是重复第一次获取token的过程(比如登录,扫描授权等) ,这样做的缺点是用户体验不好,每一小时强制登录一次几乎是无法忍受的。那应该怎么办呢?其实这是一个老生常谈的问题,但是最近发现很多人并不清楚,所以今天就一次讲清这...
继续阅读 »

token过期了怎么办?一般做法是重复第一次获取token的过程(比如登录,扫描授权等) ,这样做的缺点是用户体验不好,每一小时强制登录一次几乎是无法忍受的。那应该怎么办呢?其实这是一个老生常谈的问题,但是最近发现很多人并不清楚,所以今天就一次讲清这个问题!

token 过期处理

没有绝对的安全, 所谓的安全处理, 就是提高攻击者攻击的难度, 对他造成了一定的麻烦, 我们这个网站就是安全的! 网站安全性就是高的! 所以: token 必须要有过期时间!

token 过期问题

目标: 了解token过期问题的存在, 学习token过期的解决思路

现象:

你登陆成功之后,接口会返回一个token值,这个值在后续请求时带上(就像是开门钥匙)。

但是,这个值一般会有有效期(具体是多长,是由后端决定),在我们的项目中,这个有效期是2小时。

如果,上午8点登陆成功,到了10:01分,则token就会失效,再去发请求时,就会报401错误。

思考:

  1. token需要过期时间吗 ?

    token即是获取受保护资源的凭证,当然必须有过期时间。否则一次登录便可永久使用,认证功能就失去了其意义。非但必须有个过期时间,而且过期时间还不能太长,

    参考各个主流网站的token过期时间,一般1小时左右

    token一旦过期, 一定要处理, 不处理, 用户没法进行一些需要授权页面的使用了

  2. token过期该怎么办?

    token过期,就要重新获取。

    那么重新获取有两种方式,一是重复第一次获取token的过程(比如登录,扫描授权等) ,这样做的缺点是用户体验不好,每一小时强制登录一次几乎是无法忍受的。

    那么还剩第二种方法,那就是主动去刷新token. 主动刷新token的凭证是refresh token,也是加密字符串,并且和token是相关联的。相比可以获取各种资源的token,refresh token的作用仅仅是获取新的token,因此其作用和安全性要求都大为降低,所以其过期时间也可以设置得长一些。

目标效果 - 保证每一小时, 都是一个不同的token

第一次请求 9:00 用的是 token1  第二次请求 12:00 用的是 token2

当用户登陆成功之后,返回的token中有两个值,说明如下:

image.png

  • token:

    • 作用:在访问一些接口时,需要传入token,就是它。
    • 有效期:2小时。
  • refresh_token

    • 作用: 当token的有效期过了之后,可以使用它去请求一个特殊接口(这个接口也是后端指定的,明确需要传入refresh_token),并返回一个新的token回来(有效期还是2小时),以替换过期的那个token。
    • 有效期:14天。(最理想的情况下,一次登陆可以持续14天。)

image.png

对于 某次请求A 的响应,如果是401错误

  • 有refresh_token,用refresh_token去请求回新的token

    • 新token请求成功

      • 更新本地token
      • 再发一次请求A
    • 新token请求失败

      • 清空vuex中的token
      • 携带请求地址,跳转到登陆页
  • 没有refresh_token

    • 清空vuex中的token
    • 携带请求地址,跳转到登陆页

对于一个请求的响应 401, 要这么处理, 对于十个请求的响应 401, 也要这么处理,

我们可以统一将这个token过期处理放在响应拦截器中

请求拦截器: 所有的请求, 在真正被发送出去之前, 都会先经过请求拦截器 (可以携带token)

响应拦截器: 所有的响应, 在真正被(.then.catch await)处理之前, 都会先经过响应拦截器, 可以在这个响应拦截器中统一对响应做判断

响应拦截器处理token

目标: 通过 axios 响应拦截器来处理 token 过期的问题

响应拦截器: http://www.kancloud.cn/yunye/axios…

  1. 没有 refresh_token 拦截到登录页, 清除无效的token

测试: {"token":"123.123.123"}

// 添加响应拦截器
http.interceptors.response.use(function (response) {
// 对响应数据做点什么 (成功响应) response 就是成功的响应 res
return response
}, function (error) {
// 对响应错误做点什么 (失败响应) 处理401错误
// console.dir(error)
if (error.response.status === 401) {
console.log('token过期了, 一小时过去了, 需要通过refresh_token去刷新token')
// 获取 refresh_token, 判断是否存在, 存在就去刷新token
const refreshToken = store.state.tokenInfo.refresh_token
if (refreshToken) {
console.log('存在refreshToken, 需要进行刷新token操作')
} else {
// 没有refreshToken, 直接去登录, 将来还能跳回来
// router.currentRoute 指向当前路由信息对象 === 等价于之前页面中用的 this.$route
// 清除本地token, 跳转登录 (无意义的本地token内容, 要清除)
store.commit('removeToken')
router.push({
path: '/login',
query: {
backto: router.currentRoute.fullPath
}
})
}
}
return Promise.reject(error)
})

提供清除token的mutation

// 移出tokenInfo的信息, 恢复成空对象
removeToken (state) {
state.tokenInfo = {}
// 更新到本地, 本地可以清掉token信息
removeToken()
},
  1. 有 refresh_token 发送请求, 刷新token

测试操作: 将 token 修改成 xyz, 模拟 token 过期, 而有 refresh_token 发现401, 会自动帮你刷新token

{"refresh_token":"eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJleHAiOjE2MDYzNTcyODcsInVzZXJfaWQiOjExMDI0OTA1MjI4Mjk3MTc1MDQsInJlZnJlc2giOnRydWV9.2A81gpjxP_wWOjclv0fzSh1wzNm6lNy0iXM5G5l7TQ4","token":"xyz"}

const refreshToken = store.state.tokenInfo.refresh_token
if (refreshToken) {
console.log('存在refreshToken, 需要进行刷新token操作')
// (1) 发送请求, 进行刷新token操作, 获取新的token
// 注意: 这边发请求, 不用http实例, 用它会自动在请求前帮你携带token(会覆盖你的refresh_token)
// 这边, 直接用 axios 发送请求
const res = await axios({
method: 'put',
url: 'http://ttapi.research.itcast.cn/app/v1_0/authorizations',
// 请求头中携带refresh_token信息
headers: {
Authorization: `Bearer ${refreshToken}`
}
})
const newToken = res.data.data.token
// (2) 将新token更新到vuex中
store.commit('setTokenInfo', {
refresh_token: refreshToken,
token: newToken
})
}
  1. 刷新token后, 应该重新发送刚才的请求 (让用户刷新token无感知)
return http(error.config)
  1. 那万一 refresh_token 也过期了, 是真正的用户登录过期了 (一定要让用户重新登录的)

测试: {"refresh_token":"123.123","token":"123.123.123"} 修改后, 修改的是本地, 记得刷新一下

从哪拦走的, 就回到哪去

// 添加响应拦截器
http.interceptors.response.use(function (response) {
// 对响应数据做点什么 (成功响应) response 就是成功的响应 res
return response
}, async function (error) {
// 对响应错误做点什么 (失败响应) 处理401错误
// console.dir(error)
if (error.response.status === 401) {
console.log('token过期了, 一小时过去了, 需要通过refresh_token去刷新token')
// 获取 refresh_token, 判断是否存在, 存在就去刷新token
const refreshToken = store.state.tokenInfo.refresh_token
if (refreshToken) {
try {
console.log('存在refreshToken, 需要进行刷新token操作')
// (1) 发送请求, 进行刷新token操作, 获取新的token
// 注意: 这边发请求, 不用http实例, 用它会自动在请求前帮你携带token(会覆盖你的refresh_token)
// 这边, 直接用 axios 发送请求
const res = await axios({
method: 'put',
url: 'http://ttapi.research.itcast.cn/app/v1_0/authorizations',
// 请求头中携带refresh_token信息
headers: {
Authorization: `Bearer ${refreshToken}`
}
})
const newToken = res.data.data.token
// (2) 将新token更新到vuex中
store.commit('setTokenInfo', {
refresh_token: refreshToken,
token: newToken
})
// (3) 重新发送刚才的请求, http, 自动携带token (携带的是新token)
// error.config就是之前用于请求的配置对象, 可以直接给http使用
return http(error.config)
} catch {
// refresh_token 过期了, 跳转到登录页
// 清除过期的token对象
store.commit('removeToken')
// 跳转到登录页, 跳转完, 将来跳回来
router.push({
path: '/login',
query: {
backto: router.currentRoute.fullPath
}
})
}
} else {
// 没有refreshToken, 直接去登录, 将来还能跳回来
// router.currentRoute 指向当前路由信息对象 === 等价于之前页面中用的 this.$route
// 清除本地token, 跳转登录 (无意义的本地token内容, 要清除)
store.commit('removeToken')
router.push({
path: '/login',
query: {
backto: router.currentRoute.fullPath
}
})
}
}
return Promise.reject(error)
})

注意点:

  1. 响应拦截器要加在axios实例 http 上。
  2. 用refresh_token请求新token时,要用axios,不要用实例 http (需要: 手动用 refresh_token 请求)
  3. 得到新token之后,再发请求时,要用 http 实例 (用token请求)
  4. 过期的 token 可以用 refresh_token 再次更新获取新token, 但是过期的 refresh_token 就应该从清除了


作者:JoyZ
来源:juejin.cn/post/7308992811449172005
收起阅读 »

工作6年了日期时间格式化还在写YYYY疯狂给队友埋雷

前言 哈喽小伙伴们好久不见,今天来个有意思的雷,看你有没有埋过。 正文 不多说废话,公司最近来了个外地回来的小伙伴,在广州工作过6年,也是一名挺有经验的开发。 他提交的代码被小组长发现有问题,给打回了,原因是里面日期格式化的用法有问题,用的Simpl...
继续阅读 »

前言



哈喽小伙伴们好久不见,今天来个有意思的雷,看你有没有埋过。



正文



不多说废话,公司最近来了个外地回来的小伙伴,在广州工作过6年,也是一名挺有经验的开发。




他提交的代码被小组长发现有问题,给打回了,原因是里面日期格式化的用法有问题,用的SimpleDateFormat,但不知道是手误还是什么原因,格式用了YYYY-MM-dd。




这种写法埋了一个不大不小的雷。




用一段测试代码就可以展示出来问题



1.jpg



打印结果如下:



2.jpg



很明显,使用YYYY时,2023年变成了2024年,在正常情况下可能没问题,但是在跨年的时候大概率就会有问题了。




原因比较简单,与小写的yyyy不同,大写的YYYY表示一个基于周的年份。它是根据周计算的年份,而不是基于日历的年份。通常情况下,两者的结果是相同的,但在跨年的第一周或最后一周可能会有差异。




比如我如果换成2023-12-30又不会有问题了



3.jpg



另外,Hutool工具类本身是对Java一些工具的封装,DateUtil里面也有用到SimpleDateFormat,因此也会存在类似的问题。



4.jpg



避免这个问题的方法也十分简单,要有公用的格式类,所有使用日期格式的地方都引用这个类,这个类中就定义好yyyy-MM-dd想给的格式即可,这样就不会出现有人手误给大家埋雷了。



总结




  1. 日期时间格式统一使用yyyy小写;

  2. 日期格式要规定大家都引用定义好的工具类,避免有人手误打错。




最后再回头想一想,这种小问题并不会马上暴露出来,倘若没有被发现,到了明年元旦,刚好跨年的时候,是不是就要坑死一堆人了。



作者:程序员济癫
来源:juejin.cn/post/7269013062677823528
收起阅读 »

拼多多算法题,是清华考研真题!

写在前面 在 LeetCode 上有一道"备受争议"的题目。 该题长期作为 拼多多题库中的打榜题 : 据同学们反映,该题还是 清华大学 和 南京大学 考研专业课中的算法题。 其中南京大学的出题人,还真贴心地针对不同解法,划分不同分值: 细翻评论区。 不...
继续阅读 »

写在前面


在 LeetCode 上有一道"备受争议"的题目。


该题长期作为 拼多多题库中的打榜题


出现频率拉满


据同学们反映,该题还是 清华大学南京大学 考研专业课中的算法题。



其中南京大学的出题人,还真贴心地针对不同解法,划分不同分值:




细翻评论区。


不仅是拼多多,该题还在诸如 神州信息滴滴出行 这样的互联网大厂笔试中出现过:





但,这都不是这道题"备受争议"的原因。


这道题最魔幻的地方是:常见解法可做到 O(n)O(n) 时间,O(1)O(1) 空间,而进阶做法最快也只能做到 O(n)O(n) 时间,O(logn)O(\log{n}) 空间


称作"反向进阶"也不为过。


接下来,我将从常规解法的两种理解入手,逐步进阶到考研/笔面中分值更高的进阶做法,帮助大家在这题上做到尽善尽美。


毕竟在一道算法题上做到极致,比背一段大家都会"八股文",在笔面中更显价值。


题目描述


平台:LeetCode


题号:LCR 161 或 53


给你一个整数数组 nums,请你找出一个具有最大和的连续子数组(子数组最少包含一个元素),返回其最大和。


子数组是数组中的一个连续部分。


示例 1:


输入:nums = [-2,1,-3,4,-1,2,1,-5,4]

输出:6

解释:连续子数组 [4,-1,2,1] 的和最大,为 6

示例 2:


输入:nums = [1]

输出:1

示例 3:


输入:nums = [5,4,-1,7,8]

输出:23

提示:



  • 1<=nums.length<=1051 <= nums.length <= 10^5

  • 104<=nums[i]<=104-10^4 <= nums[i] <= 10^4


进阶:如果你已经实现复杂度为 O(n)O(n) 的解法,尝试使用更为精妙的分治法求解。


前缀和 or 线性 DP


当要我们求「连续段」区域和的时候,要很自然的想到「前缀和」。


所谓前缀和,是指对原数组“累计和”的描述,通常是指一个与原数组等长的数组。


设前缀和数组为 sumsum 的每一位记录的是从「起始位置」到「当前位置」的元素和。


例如 sum[x]sum[x] 是指原数组中“起始位置”到“位置 x”这一连续段的元素和。


有了前缀和数组 sum,当我们求连续段 [i,j][i, j] 的区域和时,利用「容斥原理」,便可进行快速求解。


通用公式:ans = sum[j] - sum[i - 1]



由于涉及 -1 操作,为减少边界处理,我们可让前缀和数组下标从 11 开始。在进行快速求和时,再根据原数组下标是否从 11 开始,决定是否进行相应的下标偏移。


学习完一维前缀和后,回到本题。


先用 nums 预处理出前缀和数组 sum,然后在遍历子数组右端点 j 的过程中,通过变量 m 动态记录已访问的左端点 i 的前缀和最小值。最终,在所有 sum[j] - m 的取值中选取最大值作为答案。


代码实现上,我们无需明确计算前缀和数组 sum,而是使用变量 s 表示当前累计的前缀和(充当右端点),并利用变量 m 记录已访问的前缀和的最小值(充当左端点)即可。


本题除了将其看作为「前缀和裸题用有限变量进行空间优化」以外,还能以「线性 DP」角度进行理解。


定义 f[i]f[i] 为考虑前 ii 个元素,且第 nums[i]nums[i] 必选的情况下,形成子数组的最大和。


不难发现,仅考虑前 ii 个元素,且 nums[i]nums[i] 必然参与的子数组中。要么是 nums[i]nums[i] 自己一个成为子数组,要么与前面的元素共同组成子数组。


因此,状态转移方程:


f[i]=max(f[i1]+nums[i],nums[i])f[i] = \max(f[i - 1] + nums[i], nums[i])

由于 f[i]f[i] 仅依赖于 f[i1]f[i - 1] 进行转移,可使用有限变量进行优化,因此写出来的代码也是和上述前缀和角度分析的类似。


Java 代码:


class Solution {
public int maxSubArray(int[] nums) {
int s = 0, m = 0, ans = -10010;
for (int x : nums) {
s += x;
ans = Math.max(ans, s - m);
m = Math.min(m, s);
}
return ans;
}
}

C++ 代码:


class Solution {
public:
int maxSubArray(vector<int>& nums) {
int s = 0, m = 0, ans = -10010;
for (int x : nums) {
s += x;
ans = max(ans, s - m);
m = min(m, s);
}
return ans;
}
};

Python 代码:


class Solution:
def maxSubArray(self, nums: List[int]) -> int:
s, m, ans = 0, 0, -10010
for x in nums:
s += x
ans = max(ans, s - m)
m = min(m, s)
return ans

TypeScript 代码:


function maxSubArray(nums: number[]): number {
let s = 0, m = 0, ans = -10010;
for (let x of nums) {
s += x;
ans = Math.max(ans, s - m);
m = Math.min(m, s);
}
return ans;
};


  • 时间复杂度:O(n)O(n)

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


分治


“分治法”的核心思路是将大问题拆分成更小且相似的子问题,通过递归解决这些子问题,最终合并子问题的解来得到原问题的解。


实现分治,关键在于对“递归函数”的设计(入参 & 返回值)。


在涉及数组的分治题中,左右下标 lr 必然会作为函数入参,因为它能用于表示当前所处理的区间,即小问题的范围。


对于本题,仅将最大子数组和(答案)作为返回值并不足够,因为单纯从小区间的解无法直接推导出大区间的解,我们需要一些额外信息来辅助求解。


具体的,我们可以将返回值设计成四元组,分别代表 区间和前缀最大值后缀最大值最大子数组和,用 [sum, lm, rm, max] 表示。


有了完整的函数签名 int[] dfs(int[] nums, int l, int r),考虑如何实现分治:



  1. 根据当前区间 [l,r][l, r] 的长度进行分情况讨论:

    1. l=rl = r,只有一个元素,区间和为 nums[l]nums[l],而 最大子数组和、前缀最大值 和 后缀最大值 由于允许“空数组”,因此均为 max(nums[l],0)\max(nums[l], 0)

    2. 否则,将当前问题划分为两个子问题,通常会划分为两个相同大小的子问题,划分为 [l,mid][l, mid][mid+1,r][mid + 1, r] 两份,递归求解,其中 mid=l+r2mid = \left \lfloor \frac{l + r}2{} \right \rfloor




随后考虑如何用“子问题”的解合并成“原问题”的解:



  1. 合并区间和 (sum): 当前问题的区间和等于左右两个子问题的区间和之和,即 sum = left[0] + right[0]

  2. 合并前缀最大值 (lm): 当前问题的前缀最大值可以是左子问题的前缀最大值,或者左子问题的区间和加上右子问题的前缀最大值。即 lm = max(left[1], left[0] + right[1])

  3. 合并后缀最大值 (rm): 当前问题的后缀最大值可以是右子问题的后缀最大值,或者右子问题的区间和加上左子问题的后缀最大值。即 rm = max(right[2], right[0] + left[2])

  4. 合并最大子数组和 (max): 当前问题的最大子数组和可能出现在左子问题、右子问题,或者跨越左右两个子问题的边界。因此,max 可以通过 max(left[3], right[3], left[2] + right[1]) 来得到。


一些细节:由于我们在计算 lmrmmax 的时候允许数组为空,而答案对子数组的要求是至少包含一个元素。因此对于 nums 全为负数的情况,我们会错误得出最大子数组和为 0 的答案。针对该情况,需特殊处理,遍历一遍 nums,若最大值为负数,直接返回最大值。


Java 代码:


class Solution {
// 返回值: [sum, lm, rm, max] = [区间和, 前缀最大值, 后缀最大值, 最大子数组和]
int[] dfs(int[] nums, int l, int r) {
if (l == r) {
int t = Math.max(nums[l], 0);
return new int[]{nums[l], t, t, t};
}
// 划分成两个子区间,分别求解
int mid = l + r >> 1;
int[] left = dfs(nums, l, mid), right = dfs(nums, mid + 1, r);
// 组合左右子区间的信息,得到当前区间的信息
int[] ans = new int[4];
ans[0] = left[0] + right[0]; // 当前区间和
ans[1] = Math.max(left[1], left[0] + right[1]); // 当前区间前缀最大值
ans[2] = Math.max(right[2], right[0] + left[2]); // 当前区间后缀最大值
ans[3] = Math.max(Math.max(left[3], right[3]), left[2] + right[1]); // 最大子数组和
return ans;
}
public int maxSubArray(int[] nums) {
int m = nums[0];
for (int x : nums) m = Math.max(m, x);
if (m <= 0) return m;
return dfs(nums, 0, nums.length - 1)[3];
}
}

C++ 代码:


class Solution {
public:
// 返回值: [sum, lm, rm, max] = [区间和, 前缀最大值, 后缀最大值, 最大子数组和]
vector<int> dfs(vector<int>& nums, int l, int r) {
if (l == r) {
int t = max(nums[l], 0);
return {nums[l], t, t, t};
}
// 划分成两个子区间,分别求解
int mid = l + r >> 1;
auto left = dfs(nums, l, mid), right = dfs(nums, mid + 1, r);
// 组合左右子区间的信息,得到当前区间的信息
vector<int> ans(4);
ans[0] = left[0] + right[0]; // 当前区间和
ans[1] = max(left[1], left[0] + right[1]); // 当前区间前缀最大值
ans[2] = max(right[2], right[0] + left[2]); // 当前区间后缀最大值
ans[3] = max({left[3], right[3], left[2] + right[1]}); // 最大子数组和
return ans;
}
int maxSubArray(vector<int>& nums) {
int m = nums[0];
for (int x : nums) m = max(m, x);
if (m <= 0) return m;
return dfs(nums, 0, nums.size() - 1)[3];
}
};

Python 代码:


class Solution:
def maxSubArray(self, nums: List[int]) -> int:
def dfs(l, r):
if l == r:
t = max(nums[l], 0)
return [nums[l], t, t, t]
# 划分成两个子区间,分别求解
mid = (l + r) // 2
left, right = dfs(l, mid), dfs(mid + 1, r)
# 组合左右子区间的信息,得到当前区间的信息
ans = [0] * 4
ans[0] = left[0] + right[0] # 当前区间和
ans[1] = max(left[1], left[0] + right[1]) # 当前区间前缀最大值
ans[2] = max(right[2], right[0] + left[2]) # 当前区间后缀最大值
ans[3] = max(left[3], right[3], left[2] + right[1]) # 最大子数组和
return ans

m = max(nums)
if m <= 0:
return m
return dfs(0, len(nums) - 1)[3]

TypeScript 代码:


function maxSubArray(nums: number[]): number {
const dfs = function (l: number, r: number): number[] {
if (l == r) {
const t = Math.max(nums[l], 0);
return [nums[l], t, t, t];
}
// 划分成两个子区间,分别求解
const mid = (l + r) >> 1;
const left = dfs(l, mid), right = dfs(mid + 1, r);
// 组合左右子区间的信息,得到当前区间的信息
const ans = Array(4).fill(0);
ans[0] = left[0] + right[0]; // 当前区间和
ans[1] = Math.max(left[1], left[0] + right[1]); // 当前区间前缀最大值
ans[2] = Math.max(right[2], right[0] + left[2]); // 当前区间后缀最大值
ans[3] = Math.max(left[3], right[3], left[2] + right[1]); // 最大子数组和
return ans;
}

const m = Math.max(...nums);
if (m <= 0) return m;
return dfs(0, nums.length - 1)[3];
};


  • 时间复杂度:O(n)O(n)

  • 空间复杂度:递归需要函数栈空间,算法每次将当前数组一分为二,进行递归处理,递归层数为 logn\log{n},即函数栈最多有 logn\log{n} 个函数栈帧,复杂度为 O(logn)O(\log{n})


总结


虽然,这道题的进阶做法相比常规做法,在时空复杂度上没有优势。


但进阶做法的分治法更具有 进一步拓展 的价值,容易展开为支持「区间修改,区间查询」的高级数据结构 - 线段树。


实际上,上述的进阶「分治法」就是线段树的"建树"过程。


这也是为什么「分治法」在名校考研课中分值更大,在大厂笔面中属于必选解法的原因,希望大家重点掌握。


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

前端学一点Docker,不信你学不会

虽然前端很少跟docker打交道,但随着工作流程的自动化现代化,docker正变得越来越重要。无论你是希望扩展技能到全栈领域,还是想要炫技,掌握Docker基本知识都是前端小伙伴重要的一步。 什么是Docker Docker 是一个开源的应用容器引擎,可以让...
继续阅读 »

虽然前端很少跟docker打交道,但随着工作流程的自动化现代化,docker正变得越来越重要。无论你是希望扩展技能到全栈领域,还是想要炫技,掌握Docker基本知识都是前端小伙伴重要的一步。


什么是Docker



Docker 是一个开源的应用容器引擎,可以让开发者打包他们的应用以及依赖包到一个轻量级、可移植的容器中,然后发布到任何流行的 Linux 机器上,也可以实现虚拟化。



我们知道,软件的安装是要区分系统环境的;即使是运行环境相同,依赖的版本一旦有所出入,也容易出现“我这里明明好使,你那里为啥不行“的问题。容器技术解决了环境一致性依赖管理的问题。


因此,容器是我们的项目的运行环境,而docker是容器的运行环境与管理平台。


关键词


镜像 (Image)


镜像是构建容器的模板,可以简单理解为类似Js中的class类或构造函数。


镜像中详细记录了应用所需的运行环境、应用代码、如何操作系统文件等等信息;


容器 (Container)


容器是镜像的运行实例。可以简单理解为”new 镜像()“的实例,通过docker命令可以任意创建容器。


当前操作系统(宿主机)与容器之间的关系,可以参照浏览器页面与iframe之间的关系。容器可以拥有独立的IP地址,网络,文件系统以及指令等;容器与容器、容器与”宿主机“之间以隔离的方式运行,每个容器中通常运行着一个(或多个)应用。


仓库 (Registry)


仓库是集中管理镜像的地方。类似于npm平台与npm包之间的关系。


如果我们将搭建项目环境的详细过程以及具体的依赖记录进镜像中,每当需要部署新服务时,就可以很容易的通过镜像,创建出一个个完整的项目运行环境,完成部署。


示例——安装启动Mysql


1. 安装Docker


具体过程可参考菜鸟教程,下面以macOS系统作为例子进行演示。


启动docker客户端如下:



打开系统终端(下面是在vscode的终端中演示),输入命令:


docker -v

效果如下:



说明docker已经安装并启动。


2. 下载Mysql镜像


下载镜像有点类似于安装npm包:npm install <包名>,这里输入docker镜像的安装命令:docker pull mysql来下载安装mysql的镜像:



安装结束后,输入镜像列表的查看命令:docker images



当然,通过docker的客户端App也可以看到:



3. 创建mysql镜像的容器,启动Mysql


输入启动容器命令:


docker run -d -p 3308:3306 -e MYSQL_ROOT_PASSWORD=123456 mysql

先来看下启动结果,下面的一堆数字是完整的容器id:



输入的这一串命令是什么意思?



  • docker run: 这是启动新容器的命令。

  • -d--detach 是使mysql服务在后台运行,而不是占用当前终端界面。

  • -p 3308:3306: 这是端口映射参数:

    • 创建容器会默认创建一个子网,与宿主机所处的网络互相隔离;mysql服务默认端口3306,如果要通过宿主机所在网络访问容器所处的子网络中的服务,就需要进行端口映射(不熟悉网络的可以看下《如何跟小白解释网络》)。

    • 宿主机的端口在前(左边),容器的端口在后(右边)。



  • -e MYSQL_ROOT_PASSWORD=123456: 设置环境变量 MYSQL_ROOT_PASSWORD=123456;也就是将mysql服务的root用户的密码为123456

  • mysql: 这是上面刚刚pull的镜像的名称。


通过上面的命令,我们启动了一个mysql镜像的容器,并将主机的3308端口映射到了容器所在子网中ip的3306端口,这样我们就可以通过访问主机的localhost:3308来访问容器中的mysql服务了。


4.访问Mysql服务


下面写一段nodeJs代码:


// mysql.js
const mysql = require('mysql');
const connection = mysql.createConnection({
host: 'localhost',
port: '3308',
user: 'root',
password: '123456',
database: '',
});
connection.connect();
// 显示全部数据库
connection.query('show databases;', function (err, rows, fields) {
if (err) {
console.log('[SELECT ERROR] - ', err.message);
return;
}
console.log('--------------------------SELECT----------------------------');
console.log(rows);
console.log('------------------------------------------------------------');
});

这里调用了nodeJs的mysql包,访问localhost:3308,用户名为root,密码为123456,运行结果下:


$ node mysql.js;
[SELECT ERROR] - ER_NOT_SUPPORTED_AUTH_MODE: Client does not support authentication
protocol requested by server; consider upgrading MySQL client

这里报错了,原因是mysql服务的认证协议与我们代码中的不同导致的。这里我们需要对mysql服务进行一些修改。


为此,我们需要进入mysql容器,对mysql进行一些直接的命令行操作。


5.进入容器


首先,我们需要知道容器的id,输入容器查看命令:docker ps,展示容器列表如下:



其中55cbcc600353就是我们需要的容器的短id,然后执行命令:docker exec -it 55cbcc600353 bash,以下是命令的解析:



  • docker exec:用于向运行中的容器发布命令。

  • -it:分配一个终端(伪终端),允许用shell命令进行交互。也就是将容器中的终端界面映射到宿主机终端界面下,从而对容器进行直接的命令行操作。

  • 55cbcc600353:容器ID或容器名称。

  • bash:这是要在容器内执行的命令。这里是启动了容器的Bash shell程序。


运行结果如下:



我们看到bash-4.4#  后闪烁的光标。这就是容器的bash shell命令提示符,这里输入的shell命令将会在容器环境中执行。


我们输入mysql登录命令,以root用户身份登录:mysql -uroot -p123456



成功登录mysql后,在mysql>命令提示符下输入:ALTER USER 'root'@'%' IDENTIFIED WITH mysql_native_password By '123456';


这条命令用来修改’root’用户的认证方式为mysql_native_password ,将密码设置为123456,并允许来自任何主机(‘%’)的连接。


输入exit;命令退出mysql>命令提示符:



再按下:ctl+D退出容器终端,回到宿主机系统终端下。再次运行上面的js代码,效果如下:



这样我们就完成了本地mysql服务的部署。


结束


通过上面的简介以及安装部署mysql服务的例子,相信不了解docker的前端小伙伴已经有了一些概念;感兴趣的小伙伴可以继续深入,学习相关的知识。


作者:硬毛巾
来源:juejin.cn/post/7304538094782808105
收起阅读 »

为啥IoT(物联网)选择了MQTT协议?

物联网设备要实现互相通信,须一套标准通信协议,MQTT(Message Queuing Telemetry Transport)专为物联网设备设计的一套标准消息队列通信协议。使用MQTT协议的IoT设备,可以连接到任何支持MQTT协议的消息队列上,进行通信。 ...
继续阅读 »

物联网设备要实现互相通信,须一套标准通信协议,MQTT(Message Queuing Telemetry Transport)专为物联网设备设计的一套标准消息队列通信协议。使用MQTT协议的IoT设备,可以连接到任何支持MQTT协议的消息队列上,进行通信。



  • 宏观,MQTT和其他MQ传输协议差不多。也是“发布-订阅”消息模型

  • 网络结构,也是C/S架构,IoT设备是客户端,Broker是服务端,客户端与Broker通信进行收发消息


但毕竟使用场景不同,所以,MQTT和普通MQ比,还有很多区别。


1 客户端都运行在IoT设备


1.1 IoT设备特点


① 便宜


最大特点,一个水杯才几十块钱,它上面智能模块成本十块钱最多,再贵就卖不出去。十块钱的智能设备内存都是按KB计算,可能都没有CPU,也不一定有os,整个设备就一个SoC(System on a Chip)。这样的设备就需要通信协议不能复杂,功能不能太多。


② 无线连接


IoT设备一般采用无线连接,很多设备经常移动,导致IoT设备网络连接不稳定,且是常态。


MQTT协议设计上充分考虑这些特点。协议的报文设计极简,惜字如金。协议功能也非常简单,基本就只有:



  • 发布订阅主题

  • 收发消息


这两个最核心功能。为应对网络连接不稳定问题,MQTT增加机制:



  • 心跳机制,可让客户端和服务端双方都能随时掌握当前连接状态,一旦发现连接中断,可尽快重连

  • 会话机制,在服务端来保存会话状态,客户端重连后就可恢复之前会话,继续收发消息。这样,把复杂度转移到服务端,客户端实现更简单


2 服务端高要求


MQTT面临的使用场景中,服务端需支撑海量IoT设备同时在线。


普通的消息队列集群,服务的客户端都运行在性能强大的服务器,所以客户端数量不会特别多。如京东的JMQ集群,日常在线客户端数量大概十万左右,就足够支撑全国人民在京东买买买。


而MQTT使用场景中,需支撑的客户端数量,远不止几万几十万。如北京交通委若要把全市车辆都接入进来,就是个几百万客户端的规模。路侧的摄像头,每家每户的电视、冰箱,每个人随身携带的各种穿戴设备,这些设备规模都是百万、千万级甚至上亿级。


3 不支持点对点通信


MQTT协议的设计目标是支持发布-订阅(Publish-Subscribe)模型,而不是点对点通信。


MQTT的主要特点之一是支持发布者(Publisher)将消息发布到一个主题(Topic),而订阅者(Subscriber)则可以通过订阅相关主题来接收这些消息。这种模型在大规模的分布式系统中具有很好的可扩展性和灵活性。因此,MQTT更适合用于多对多、多对一的通信场景,例如物联网(IoT)应用、消息中间件等。


虽然MQTT的设计目标不是点对点通信,但在实际使用中,你仍然可以通过一些设计来模拟点对点通信。例如,使用不同的主题来模拟点对点通信,或者在应用层进行一些额外的协议和逻辑以实现点对点通信的效果。


一般做法都是,每个客户端都创建一个以自己ID为名字的主题,然后客户端来订阅自己的专属主题,用于接收专门发给这个客户端的消息。即MQTT集群中,主题数量和客户端数量基本是同一量级。


4 MQTT产品选型


如何支持海量在线IoT设备和海量主题,是每个支持MQTT协议的MQ面临最大挑战。也是做MQTT服务端技术选型时,需重点考察技术点。


开源MQTT产品


有些是传统MQ,通过官方或非官方扩展,实现MQTT协议支持。也有一些专门的MQTT Server产品,这些MQTT Server在协议支持层面,大多没问题,性能和稳定性方面也都满足要求。但还没发现能很好支撑海量客户端和主题的开源产品。why?


传统MQ


虽可通过扩展来支持MQTT协议,但整体架构设计之初,并未考虑支撑海量客户端和主题。如RocketMQ元数据保存在NameServer的内存,Kafka是保存在zk,这些存储都不擅长保存大量数据,所以也支撑不了过多客户端和主题。


另外一些开源MQTT Server


很多就没集群功能或集群功能不完善。集群功能做的好的产品,大多都把集群功能放到企业版卖。


所以做MQTT Server技术选型,若你接入IoT设备数量在10w内,可选择开源产品,选型原则和选择普通消息队列一样,优先选择一个流行、熟悉的开源产品即可。


若客户端规模超过10w量级,需支撑这么大规模客户端数量,服务端只有单节点肯定不够,须用集群,并且这集群要支持水平扩容。这时就几乎没开源产品了,此时只能建议选择一些云平台厂商提供的MQTT云服务,价格相对较低,也可选择价格更高商业版MQTT Server。


另外一个选择就是,基于已有开源MQTT Server,通过一些集成和开发,自行构建MQTT集群。


5 构建一个支持海量客户端的MQTT集群


MQTT集群如何支持海量在线的IoT设备?
一般来说,一个MQTT集群它的架构应该是这样的:



从左向右看,首先接入的地址最好是一个域名,这样域名后面可配置多个IP地址做负载均衡,当然这域名不是必需。也可直接连负载均衡器。负载均衡可选F5这种专用的负载均衡硬件,也可Nginx这样软件,只要是四层或支持MQTT协议的七层负载均衡设备,都可。


负载均衡器后面要部署一个Proxy集群


Proxy集群作用



  • 承接海量IoT设备连接

  • 维护与客户端的会话

  • 作为代理,在客户端和Broker之间进行消息转发


在Proxy集群后是Broker集群,负责保存和收发消息。


有的MQTT Server集群架构:



架构中没Proxy。实际上,只是把Proxy和Broker功能集成到一个进程,这两种架构本质没有太大区别。可认为就是同一种架构来分析。


前置Proxy,易解决海量连接问题,由于Proxy可水平扩展,只要用足够多的Proxy节点,就可抗海量客户端同时连接。每个Proxy和每个Broker只用一个连接通信即可,这对每个Broker来说,其连接数量最多不会超过Proxy节点的数量。


Proxy对于会话的处理,可借鉴Tomcat处理会话的两种方式:



  • 将会话保存在Proxy本地,每个Proxy节点都只维护连接到自己的这些客户端的会话。但这要配合负载均衡来使用,负载均衡设备需支持sticky session,保证将相同会话的连接总是转发到同一Proxy节点

  • 将会话保存在一个外置存储集群,如Redis集群或MySQL集群。这样Proxy就可设计成完全无状态,对负载均衡设备也没特殊要求。但这要求外置存储集群具备存储千万级数据能力,同时具有很好性能


如何支持海量主题?


较可行的解决方案,在Proxy集群的后端,部署多组Broker小集群,如可以是多组Kafka小集群,每个小集群只负责存储一部分主题。这样对每个Broker小集群,主题数量就可控制在可接受范围内。由于消息是通过Proxy进行转发,可在Proxy中采用一些像一致性哈希等分片算法,根据主题名称找到对应Broker小集群。这就解决支持海量主题的问题。


UML


Proxy的UML图:


@startuml
package "MQTT Proxy Cluster" {
class MQTTProxy {
+handleIncomingMessage()
+handleOutgoingMessage()
+produceMessage()
+consumeMessage()
}

class Client {
+sendMessage()
+receiveMessage()
}

class Broker {
+publish()
+subscribe()
}

Client --> MQTTProxy
MQTTProxy --> Broker
}
@enduml

@startuml
actor Client
entity MQTTProxy
entity Broker

Client -> MQTTProxy : sendMessage()
activate MQTTProxy
MQTTProxy -> Broker : produceMessage()
deactivate MQTTProxy
@enduml

@startuml
entity MQTTProxy
entity Broker
actor Client

Broker -> MQTTProxy : publishMessage()
activate MQTTProxy
MQTTProxy -> Client : consumeMessage()
deactivate MQTTProxy
@enduml


Proxy收发消息的时序图:



Proxy生产消息流程的时序图:



Proxy消费消息流程的时序图:


image-20231208134111361

6 总结


MQTT是专门为物联网设备设计的一套标准的通信协议。这套协议在消息模型和功能上与普通的消息队列协议是差不多的,最大的区别在于应用场景不同。在物联网应用场景中,IoT设备性能差,网络连接不稳定。服务端面临的挑战主要是,需要支撑海量的客户端和主题。


已有的开源的MQTT产品,对于协议的支持都不错,在客户端数量小于十万级别的情况下,可以选择。对于海量客户端的场景,服务端必须使用集群来支撑,可以选择收费的云服务和企业版产品。也可以选择自行来构建MQTT集群。


自行构建集群,最关键技术点,就是通过前置Proxy集群解决海量连接、会话管理和海量主题:



  • 前置Proxy负责在Broker和客户端之间转发消息,通过这种方式,将海量客户端连接收敛为少量的Proxy与Broker之间的连接,解决了海量客户端连接数的问题

  • 维护会话的实现原理,和Tomcat维护HTTP会话一样

  • 海量主题,可在后端部署多组Broker小集群,每个小集群分担一部分主题这样的方式来解决


参考:



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

全网显示IP归属地,免费可用,快来看看

前言 经常浏览小视频或各类帖子的朋友们可能已经注意到,目前许多网络平台都会显示作者和评论区用户的IP归属地。那么,这个功能是如何实现的呢? 某些收费平台的API 我们可以利用一些付费平台的API来实现这一功能,比如一些导航软件的开放平台API等。然而,这些服...
继续阅读 »

前言


经常浏览小视频或各类帖子的朋友们可能已经注意到,目前许多网络平台都会显示作者和评论区用户的IP归属地。那么,这个功能是如何实现的呢?



某些收费平台的API


我们可以利用一些付费平台的API来实现这一功能,比如一些导航软件的开放平台API等。然而,这些服务通常是收费的,而且免费额度有限,适合测试使用,但如果要在生产环境中使用,很可能不够支撑需求。



离线库推荐


那么,有没有免费的离线API库呢?UP现在推荐一个强大的离线库给大家,一个准确率高达99.9%的离线IP地址定位库,查询速度仅需0.0x毫秒,而且数据库仅10兆字节大小。此库提供了Java、PHP、C、Python、Node.js、Golang、C#等多种查询绑定,同时支持Binary、B树和内存三种查询算法。



这个库大家可以在GitHub上搜索:ip2region,即可找到该开源库。


使用


下面使用Java代码给大家演示下如何使用这个IP库,该库目前支持多重主流语言。


1、引入依赖


<dependency>
   <groupId>org.lionsoul</groupId>
   <artifactId>ip2region</artifactId>
   <version>2.7.0</version>
</dependency>

2、下载离线库文件 ip2region.xdb



3、简单使用代码


下面,我们通过Java代码,挑选某个国内的IP进行测试,看看会输出什么样的结果


public class IpTest {

   public static void main(String[] args) throws Exception {
       // 1、创建 searcher 对象 (修改为离线库路径)
       String dbPath = "C:\Users\Administrator\Desktop\ip2region.xdb";
       Searcher searcher = null;
       try {
           searcher = Searcher.newWithFileOnly(dbPath);
      } catch (Exception e) {
           System.out.printf("failed to create searcher with `%s`: %s\n", dbPath, e);
           return;
      }

       // 2、查询
       String ip = "110.242.68.66";
       try {
           long sTime = System.nanoTime(); // Happyjava
           String region = searcher.search(ip);
           long cost = TimeUnit.NANOSECONDS.toMicros((long) (System.nanoTime() - sTime));
           System.out.printf("{region: %s, ioCount: %d, took: %d μs}\n", region, searcher.getIOCount(), cost);
      } catch (Exception e) {
           System.out.printf("failed to search(%s): %s\n", ip, e);
      }

       // 3、关闭资源
       searcher.close();

       // 备注:并发使用,每个线程需要创建一个独立的 searcher 对象单独使用。
  }

}


输出结果为:


{region: 中国|0|河北省|保定市|联通, ioCount: 3, took: 1192 μs}

其中,region的格式为 国家|区域|省份|城市|ISP,缺省的地域信息默认是0。


当然,这个库不只是支持国内的IP,也支持国外的IP。



其他语言可以参考该开源库的说明文档。


总结


这是一个准确率非常高的离线库,如果项目里有IP定位需求的,可以试下该库。


作者:happyjava
来源:juejin.cn/post/7306334713992708122
收起阅读 »

面试官:什么是JWT?为什么要用JWT?

目前传统的后台管理系统,以及不使用第三方登录的系统,使用 JWT 技术的还是挺多的,因此在面试中被问到的频率也比较高,所以今天我们就来看一下:什么是 JWT?为什么要用 JWT? 1.什么是 JWT? JWT(JSON Web Token)是一种开放标准(RF...
继续阅读 »

目前传统的后台管理系统,以及不使用第三方登录的系统,使用 JWT 技术的还是挺多的,因此在面试中被问到的频率也比较高,所以今天我们就来看一下:什么是 JWT?为什么要用 JWT?


1.什么是 JWT?


JWT(JSON Web Token)是一种开放标准(RFC 7519),用于在网络上安全传输信息的简洁、自包含的方式。它通常被用于身份验证和授权机制。
JWT 由三部分组成:头部(Header)、载荷(Payload)和签名(Signature)。



  1. 头部(Header):包含了关于生成该 JWT 的信息以及所使用的算法类型。

  2. 载荷(Payload):包含了要传递的数据,例如身份信息和其他附属数据。JWT 官方规定了 7 个字段,可供使用:

    1. iss (Issuer):签发者。

    2. sub (Subject):主题。

    3. aud (Audience):接收者。

    4. exp (Expiration time):过期时间。

    5. nbf (Not Before):生效时间。

    6. iat (Issued At):签发时间。

    7. jti (JWT ID):编号。



  3. 签名(Signature):使用密钥对头部和载荷进行签名,以验证其完整性。



JWT 官网:jwt.io/



2.为什么要用 JWT?


JWT 相较于传统的基于会话(Session)的认证机制,具有以下优势:



  1. 无需服务器存储状态:传统的基于会话的认证机制需要服务器在会话中存储用户的状态信息,包括用户的登录状态、权限等。而使用 JWT,服务器无需存储任何会话状态信息,所有的认证和授权信息都包含在 JWT 中,使得系统可以更容易地进行水平扩展。

  2. 跨域支持:由于 JWT 包含了完整的认证和授权信息,因此可以轻松地在多个域之间进行传递和使用,实现跨域授权。

  3. 适应微服务架构:在微服务架构中,很多服务是独立部署并且可以横向扩展的,这就需要保证认证和授权的无状态性。使用 JWT 可以满足这种需求,每次请求携带 JWT 即可实现认证和授权。

  4. 自包含:JWT 包含了认证和授权信息,以及其他自定义的声明,这些信息都被编码在 JWT 中,在服务端解码后使用。JWT 的自包含性减少了对服务端资源的依赖,并提供了统一的安全机制。

  5. 扩展性:JWT 可以被扩展和定制,可以按照需求添加自定义的声明和数据,灵活性更高。


总结来说,使用 JWT 相较于传统的基于会话的认证机制,可以减少服务器存储开销和管理复杂性,实现跨域支持和水平扩展,并且更适应无状态和微服务架构。


3.JWT 基本使用


在 Java 开发中,可以借助 JWT 工具类来方便的操作 JWT,例如 HuTool 框架中的 JWTUtil。


HuTool 介绍:doc.hutool.cn/pages/JWTUt…


使用 HuTool 操作 JWT 的步骤如下:



  1. 添加 HuTool 框架依赖

  2. 生成 Token

  3. 验证和解析 Token


3.1 添加 HuTool 框架依赖


在 pom.xml 中添加以下信息:


<dependency>
<groupId>cn.hutoolgroupId>
<artifactId>hutool-allartifactId>
<version>5.8.16version>
dependency>

3.2 生成 Token


Map map = new HashMap() {
private static final long serialVersionUID = 1L;
{
put("uid", Integer.parseInt("123")); // 用户ID
put("expire_time", System.currentTimeMillis() + 1000 * 60 * 60 * 24 * 15); // 过期时间15天
}
};
JWTUtil.createToken(map, "服务器端秘钥".getBytes());

3.3 验证和解析 Token


验证 Token 的示例代码如下:


String token = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VyX25hbWUiOiJhZG1pbiIsInNjb3BlIjpbImFsbCJdLCJleHAiOjE2MjQwMDQ4MjIsInVzZXJJZCI6MSwiYXV0aG9yaXRpZXMiOlsiUk9MRV_op5LoibLkuozlj7ciLCJzeXNfbWVudV8xIiwiUk9MRV_op5LoibLkuIDlj7ciLCJzeXNfbWVudV8yIl0sImp0aSI6ImQ0YzVlYjgwLTA5ZTctNGU0ZC1hZTg3LTVkNGI5M2FhNmFiNiIsImNsaWVudF9pZCI6ImhhbmR5LXNob3AifQ.aixF1eKlAKS_k3ynFnStE7-IRGiD5YaqznvK2xEjBew";
JWTUtil.verify(token, "123456".getBytes());

解析 Token 的示例代码如下:


String rightToken = "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwiYWRtaW4iOnRydWUsIm5hbWUiOiJsb29seSJ9.U2aQkC2THYV9L0fTN-yBBI7gmo5xhmvMhATtu8v0zEA";
final JWT jwt = JWTUtil.parseToken(rightToken);
jwt.getHeader(JWTHeader.TYPE);
jwt.getPayload("sub");

3.4 代码实战


在登录成功之后,生成 Token 的示例代码如下:


// 登录成功,使用 JWT 生成 Token
Map payload = new HashMap() {
private static final long serialVersionUID = 1L;
{
put("uid", userinfo.getUid());
put("manager", userinfo.getManager());
// JWT 过期时间为 15 天
put("exp", System.currentTimeMillis() + 1000 * 60 * 60 * 24 * 15);
}
};
String token = JWTUtil.createToken(payload, AppVariable.JWT_KEY.getBytes());

例如在 Spring Cloud Gateway 网关中验证 Token 的实现代码如下:


import cn.hutool.jwt.JWT;
import cn.hutool.jwt.JWTUtil;
import com.example.common.AppVariable;
import org.springframework.cloud.gateway.filter.GatewayFilterChain;
import org.springframework.cloud.gateway.filter.GlobalFilter;
import org.springframework.core.Ordered;
import org.springframework.http.HttpStatus;
import org.springframework.http.server.reactive.ServerHttpResponse;
import org.springframework.stereotype.Component;
import org.springframework.web.server.ServerWebExchange;
import reactor.core.publisher.Mono;

import java.util.List;

/**
* 登录过滤器(登录判断)
*/

@Component
public class AuthFilter implements GlobalFilter, Ordered {
// 排除登录验证的 URL 地址
private String[] skipAuthUrls = {"/user/add", "/user/login"};

@Override
public Mono filter(ServerWebExchange exchange, GatewayFilterChain chain) {
// 当前请求的 URL
String url = exchange.getRequest().getURI().getPath();
for (String item : skipAuthUrls) {
if (item.equals(url)) {
// 继续往下走
return chain.filter(exchange);
}
}
ServerHttpResponse response = exchange.getResponse();
// 登录判断
List tokens =
exchange.getRequest().getHeaders().get(AppVariable.TOKEN_KEY);
if (tokens == null || tokens.size() == 0) {
// 当前未登录
response.setStatusCode(HttpStatus.UNAUTHORIZED);
return response.setComplete();
}
// token 有值
String token = tokens.get(0);
// JWT 效验 token 是否有效
boolean result = false;
try {
result = JWTUtil.verify(token, AppVariable.JWT_KEY.getBytes());
} catch (Exception e) {
result = false;
}
if (!result) {
// 无效 token
response.setStatusCode(HttpStatus.UNAUTHORIZED);
return response.setComplete();
} else { // 判断 token 是否过期
final JWT jwt = JWTUtil.parseToken(token);
// 得到过期时间
Object expObj = jwt.getPayload("exp");
if (expObj == null) {
response.setStatusCode(HttpStatus.UNAUTHORIZED);
return response.setComplete();
}
long exp = Long.parseLong(expObj.toString());
if (System.currentTimeMillis() > exp) {
// token 过期
response.setStatusCode(HttpStatus.UNAUTHORIZED);
return response.setComplete();
}
}
return chain.filter(exchange);
}

@Override
public int getOrder() {
// 值越小越早执行
return 1;
}
}

4.实现原理分析


JWT 本质是将秘钥存放在服务器端,并通过某种加密手段进行加密和验证的机制。加密签名=某加密算法(header+payload+服务器端私钥),因为服务端私钥别人不能获取,所以 JWT 能保证自身其安全性。


小结


JWT 相比与传统的 Session 会话机制,具备无状态性(无需服务器端存储会话信息),并且它更加灵活、更适合微服务环境下的登录和授权判断。JWT 是由三部分组成的:Header(头部)、Payload(数据载荷)和 Signature(签名)。


作者:Java中文社群
来源:juejin.cn/post/7309310129024008230
收起阅读 »

搞不懂,我的手机没有公网IP,服务器响应报文如何被路由到手机?

6年前,在我刚毕业的时候,我困惑于一个问题:“我的手机和个人电脑等设备只有内网IP,而没有公网IP的情况下,公网服务器的响应IP报文如何被路由到我的内网设备呢?“ 我知道:任何联网的设备都可以主动连接公网IP的服务器,因为公网IP是全球唯一分配的,IP层报文经...
继续阅读 »

6年前,在我刚毕业的时候,我困惑于一个问题:“我的手机和个人电脑等设备只有内网IP,而没有公网IP的情况下,公网服务器的响应IP报文如何被路由到我的内网设备呢?“


我知道:任何联网的设备都可以主动连接公网IP的服务器,因为公网IP是全球唯一分配的,IP层报文经过路由器的层层路由可以到达公网服务器。然而我实在想不通:公网服务器的响应报文该如何回来?


这个问题让我感到很困扰,我上学时学的计算机网络知识已经还给老师了,我尝试询问周围的同事,可惜没有人能给出一个可靠的答案。直到我了解到NAT(网络地址转换)技术,才最终解答了我的疑问。



NAT,即网络地址转换,是一种用于解决IP地址短缺问题的技术。它通过在内部网络和公共网络之间建立一个转换表,将多个内部私有IP地址映射为一个公共IP地址,实现多个设备共享一个公网IP地址的功能。



在科普 NAT 之前,有必要说明一下 内网IP。


0. 内网 IP 不能随意分配!


内网IP的分配是有规范的,不可以随意分配。如果内网IP和公网IP冲突,网络报文无法被路由器正确地路由。因为路由器不知道这个IP报文应该被路由到内网设备,还是路由到上一层网关(直至公网IP)。


为了避免冲突,IP协议中事先规定了三个网段作为内网IP的范围,分别是



  1. A类地址的 10.0.0.0 至 10.255.255.255

  2. B类地址的 172.16.0.0 至 172.31.255.255,

  3. C类地址的 192.168.0.0 至 192.168.255.255。


因此在一个局域网内,以上三个网段的设备都是内网设备,除此外基本(排除 127.0.0.1 等)都是公网设备。这样所有的IP地址都不会冲突!


在家用局域网中,通常使用的是 192.168.xxx.xxx 的格式;而在公司的机房中,由于设备数量庞大,一般会选择以 10.xxx.xxx.xxx 开头的网段。这是因为 192 开头的C类地址能够满足家用局域网设备数量的需求,而 10.xxx.xxx.xxx 网段可以适应大规模的公司机房环境,其中可能存在数以百万计的物理服务器,和数以千万计的虚拟机或者Docker实例。


1. 公网流量如何路由到内网设备


设想一下,内网IP为 192.168.100.100 的用户设备,请求到公网服务器。如果公网服务器收到的IP报文中,显示来源是 192.168.100.100。因为 192 开头的 IP 是内网地址,所以服务器响应用户请求时,就会错误地把请求路由到内网,无法正确路由到用户设备上!


所以…… 正确的显示来源是什么呢?


内网设备想要 “连通” 公网服务器,是需要路由器等网络设备层层转发的,正如下图而言,内网设备需要有公网IP的网络出口才能连通 到另一个公网设备。


image.png
因此,刚才问题答案是,内网IP报文到达公网机器时,来源应该被设置为 相应的 运营商公网出口IP。即公网出口网络设备要把IP来源改为自己。这样公网服务器收到请求报文后,对应的响应IP报文也会回复到用户端的公网出口IP。


看下图,用户端的IP报文到达运营商网络出口时,IP来源被替换为 运营商公网出口IP。下图中网络来源为 192 开头的内网Ip 地址,被替换为公网出口 IP(100.100.100.100)。像这种偷偷更换来源 IP 和目标 IP 的行为在 NAT 技术上很常见,后面会经常看到!


image.png


2. 公网机器发送响应报文时


当公网服务器响应时,IP 目标地址 是运营商公网出口而非用户的内网地址!然后运营商服务器会再次转发,转发前,运营商机器需要知道该转发给谁,转发给哪个用户设备。


如下图所示,用户端(192.168.100.100) 访问 公网地址(200.200.200.200)的IP 来源和目标被替换的过程。


image.png
首先公网机器响应给 运营商公网出口时。


第一步:来源 IP 是(200.200.200.200),目标IP 是(100.100.100.100)。


然后,运营商将 IP 报文转发给用户设备时,来源 IP 还是公网机器的 IP(200.200.200.200)不变化。然而目标 IP 修改为用户设备的 IP(192.168.xx.xx)。


对于用户设备而言,发送请求时目标IP 是公网机器,收到响应时来源 IP 还是公网机器。用户设备丝毫没有感觉到,在中间被路由转发的过程,目标和来源 IP 频繁被路由设备修改!


在这个环节,有一个关键问题:运营商收到公网机器的响应时,它怎么知道该路由给哪个用户设备!


3. NAT 如何进行地址映射


最简单的映射方式是:每一个内网 IP 都映射到一个运营商的公网IP。即内网 Ip 和运营商公网 IP 一对一映射!


这种方式很少见,用户设备和内网设备非常多,这样非常耗费公网IP,一般不会采用。


TCP 和 UDP 协议除基于 IP 地址外,还有端口,如果引入端口参与映射,则大大提高运营商公网 IP 的利用度!


一个 TCP 报文 包括如下参数:



  1. 用户 IP + 用户端口

  2. 公网 IP + 公网端口


这四个参数非常关键,相当于是 TCP 连接的唯一主键。当运营商收到公网机器的响应报文时,它可以拿到四个参数分别为:


运营商公网 IP + 端口 、目标机器公网 IP + 端口。其中关键的参数有三位:运营商公网端口,目标机器公网 IP 和端口。



为什么运营商公网 IP 不关键呢?因为根据 IP 路由协议,响应报文已经被路由到该机器,每一个运营商公网出口都会维护一套单独的 映射表 ,所以自己的 IP 地址不关键。



当前的难点是:如何根据 运营商公网端口,目标机器公网 IP 和 端口 三个参数,映射到 用户 IP 和端口的问题。


用户请求时,会建立映射表。当收到用户请求时,运营商服务器的 NAT 模块会分配一个端口供本次请求使用,建立一个映射项:运营商机器端口 + 目标公网 IP + 目标公网端口 这三个参数映射到 用户 IP 和用户端口


例如下表


NAT 映射表运营商机器端口目标公网 IP目标公网端口用户 IP用户端口
1300200.200.200.20080192.168.22.226000
2
3

在运营商机器转发IP报文时,除替换IP外,也会替换端口。相比NAT 一比一映射IP地址,增加端口映射,可以大大提高运营商公网 IP 的利用度。接下来有个问题?


每个机器的端口最大数为 65535,说明每个运营商机器最多 同时支持转发 65535 个请求?


这个推论不成立。


从上面的映射表可以看到,运营商机器收到响应报文时,会根据 三个关键参数 进行映射,而非只根据 自身端口映射。以上面 NAT 映射表的第一条记录为例,运营商机器的 300 端口,并非仅仅服务于 200.200.200.200 这次请求。300 端口还可以同时服务 250.250.250.250 + 80 端口,以及其他连接!


由于映射的参数有三个,而不仅仅是运营商端口一项,因此并发程度非常高。


理论的最大并发度应该是 65535 * (公网 IP 数)* 65535,这个并发度非常高。 一个运营商机器似乎支持海量的NAT连接,实际上,并非海量。


因为常用的 Http 协议端口是 80,目标公网机器的端口数常用的基本是 80 端口。


其次用户常用的软件非常集中,例如微信、抖音、稀土掘金等,访问的公网 IP 也集中于这些公司的 IP 地址。


所以基于此,最高并发度变为:65535 * 常用的公网 IP数 * 有限的端口数(80、443)。


这个并发度并非海量,但是基本上足够使用了,一个小区或办公区的网络设备数量不会过于庞大。65535 * 有限的公网 IP数 * 有限的端口数,这样的并发度足够支持一般场景使用。


除非出现极端的情况! 即一个小区的大量用户集中访问于一个公网 IP 的 80 端口,这样网络流量一定会发生拥塞!在某些用户流量集中的区域,可以安排更多的 NAT 设备,提供更多的公网 IP。


一个小区一个公网 IP 吗?


根据 chatgpt 的回答,通常情况下,一个小区只有一个公网 IP。
image.png


上大学时,偶然了解到 SQL注入,我感觉很新奇。后来对一个兼职网站进行 SQL 注入的尝试。经过几次尝试后,我发现无法再访问这个网站。宿舍和其他几个宿舍的同学也无法访问此网站。我不禁得意洋洋,难道是因为我的攻击导致了这个网站的崩溃?


后来我找到其他大学的高中同学,让他们访问这个网站,他们访问是没问题的。那时我明白了,这个网站只是封掉了我们学校的公网 IP,或者是这栋男生宿舍楼的公网 IP ,它并没有崩溃!


个人经验来看,一个小区或者办公楼会根据实际的需要安排一定数量的公网 IP,一般情况下共用一个公网 IP。


总结


当 公网 IP 不够用时,可通过 NAT 协议实现多个用户设备共享同一个公网 IP,提高 公网IP 地址的利用度。Ipv4 的地址数量有限,在 2011 年已经被分配完,未来如果全面实现了 ipv6 协议,我们手机等终端设备也可能会有一个公网 IP。


但是公网 Ip 全球都可以访问,与此对应的网络安全问题不可忽视。NAT 技术则可以有效保护用户设备,让用户安全上网,这也是它附带的好处。


如果看完有所收获,感谢点赞支持!


作者:五阳神功
来源:juejin.cn/post/7307892574722637835
收起阅读 »

为什么最近听说 Go 岗位很少很难?

大家好,我是煎鱼。 其实这个话题已经躺在我的 TODO 里很久了,近来很多社区的小伙伴都私下来交流,也有在朋友圈看吐槽 Go 上海的大会没什么人。还不如 Rust 大会,比较尴尬。 今天主要是从个人角度看看为什么 Go 岗位看起来近来很难的样子? 盘一下数据 ...
继续阅读 »

大家好,我是煎鱼。


其实这个话题已经躺在我的 TODO 里很久了,近来很多社区的小伙伴都私下来交流,也有在朋友圈看吐槽 Go 上海的大会没什么人。还不如 Rust 大会,比较尴尬。


今天主要是从个人角度看看为什么 Go 岗位看起来近来很难的样子?


盘一下数据


从以往的大的数据分析来看,Go 岗位最多的是分布以下几个城市:



TOP3 是北京、上海、深圳。


再从常用的招聘软件来看,目前互联网行业用的应该是 XX 直聘。我们从其提供的招聘岗位数量来看。


北京:



上海:



深圳:



从这 5 个月的时间区间来看,北京和上海是在螺旋式下跌;深圳探底创新低。(不过我认为从招聘季节来看,基本都是螺旋式的)


从数值来看,只有北京的 Go 岗位有所上涨。上海、深圳都在持续减少。但整体都是在下滑趋势的。


另外一个角度来看,招聘岗位这么多,也有个好几千。是不是没什么问题,风生水起?


还是要看看活跃度的。我快速的看了下深圳,Go 岗位。第一页 30 条招聘岗位,只有 8 个是本周活跃的。刚刚活跃和 3 日内活跃的,加起来就 2~3 个。


综合数据来看,招聘岗位的数量在向下走,招聘者登陆平台的活跃度不高。


看看小行情


可能很容易就得出了 Go 完全不行的结论。我们也得看看别的编程语言岗位的。


Java,深圳:



PHP,深圳:



综合来看,其实并不是某一门语言的岗位不太行,或者要凉了。是由于整体的行情原因,招聘岗位和招聘者的活跃度都在大量的收缩。


像是以往,Go 最多的招聘岗位也是由各中大型公司撑起的。例如:字节跳动、腾讯、滴滴、百度等。他们收缩了,增量也就下来了。


而一般这种放水阶段,很多是面向 GY 企的,会有一些项目出现。例如:前段时间很火热的信创。


但有做 2B 的同学应该了解,这块有些企业会加码,要求使用 Java 语言。这一块的增量,与 Java 相比较,Go 是比较难在正面承接到的。


总结


其实不单单 Go 岗位少了。由于宏观的影响,我们常接触到的招聘岗位都少了。普遍来讲,还是建议如果没想明白就先苟着,降低负债、现金为王。


人是环境的反应器,常常会受到各种因素的影响。但在这种时期,可能想清楚自己的目标和感兴趣的内容、方向,会是一个不错的提高机会。


而在缩量的环境下,如果想找到增量。就要去一个风口或上行周期的领域。例如最近比较火的 AI。之前的新能源,不过也要警惕是个新坑。



文章持续更新,可以微信搜【脑子进煎鱼了】阅读,本文 GitHub github.com/eddycjy/blo… 已收录,学习 Go 语言可以看 Go 学习地图和路线,欢迎 Star 催更。



推荐阅读



作者:煎鱼eddycjy
来源:juejin.cn/post/7308697586532778024
收起阅读 »

个人独立开发者能否踏上敏捷之路?

很多软件开发团队都在使用Scrum、极限编程(XP)、看板等敏捷方法管理项目流程,持续迭代并更快、更高效地为客户持续交付可用的产品。除了团队,国内外很多个人独立开发者也在尝试将敏捷应用到自己的开发工作流程中,但大多数的结果都是收效甚微,个人践行敏捷是否可行? ...
继续阅读 »

很多软件开发团队都在使用Scrum、极限编程(XP)、看板等敏捷方法管理项目流程,持续迭代并更快、更高效地为客户持续交付可用的产品。除了团队,国内外很多个人独立开发者也在尝试将敏捷应用到自己的开发工作流程中,但大多数的结果都是收效甚微,个人践行敏捷是否可行? 


敏捷开发需要坚实的团队基础,以及团队文化的保障,方可有效地落地执行。  


什么是敏捷


敏捷是一种以用户需求为核心、采用不断迭代的方式进行的软件开发模式。它依靠自组织的跨职能小团队,在短周期内做出部分成果,通过快速、频繁的迭代,迅速地获取反馈,进而不断地完善产品,给用户带来更大的价值。践行敏捷的方式有很多,主要包括Scrum、XP、Kanban、精益生产、规模化敏捷等方法论。


敏捷的工作方式是将整个团队聚集在一起,理想情况下,敏捷团队的成员不超过10人。通过践行一系列简单的实践和足够的反馈,使团队能够感知目前的状态,并根据实际情况对实践进行调整。 


团队是敏捷的核心 


敏捷是一种团队驱动方法,团队可以简单的地定义为“为实现某一特定目标,包括两个或两个以上的人的相互协作的群体”。敏捷的核心是构建一个自组织的团队,团队的能力在于协作,即两个人或更多人相互交流与合作,以共同地产生一个结果。例如当两个程序员在结对编程时,他们在协作;每人持续集成当日的工作时,他们在协作;当团队开计划、站立、评审、回顾等会议时,他们在协作。协作的结果可以是有形的可交付物、决策或信息共享。 


而对于个人独立开发者,协作、互动、沟通都是无从谈起的: 


自己无法实践结对编程;


自己开站立会议是否很孤单;


自己玩估算扑克牌会不会很无聊;


评审演示没有观众,自然也就没有反馈;


…… 


这里有一个常见误区:独立开发者通常有一定的跨职能工作能力,于是想一人“饰演”多个不同角色,从需求计划整理到任务分解估时,从迭代开发到测试,再到发布、回顾总结。这是不是也在践行敏捷开发呢? 


当然不是。敏捷开发流程中任一环节都强调团队集体参与,并非由某个人独裁发号施令。例如项目计划制定、任务认领、工时评估,这些都不是某一个人的职责,而是需要团队成员来共同参与完成。然而,单个人的开发流程,很容易按部就班地走上了瀑布式开发模式(需求->设计->开发->测试->发布)。


001.png


[多重人格综合症,并确保精神上的新人是一个专业的“团队成员”]


敏捷团队中并不会要求每个人都成为全栈通才,在如今技术快速更新迭代的大环境下,期望一个人精通团队的所有技能是不现实的。取而代之的是重视具备跨职能的团队成员,这有助于管理各个工作岗位的平衡。例如,有时团队需要更多的测试人员,如果有一两个团队成员能转做测试工作,就能极大地提供帮助。 


敏捷是关于人,以及他们之间的协作交互,让每个人的能力得以充分的发挥并提升,从而创造优秀的产品。创造优秀产品的是人,而不是流程。所以,独立开发者即便一个人能跨职能走完整个开发流程,这跟敏捷强调的自组织团队中,成员之间高效地协作、交互以达到目标,完全不是一回事哦~


文化是敏捷的保障


很多个人独立开发者尝试引入敏捷的普遍思路,是从各种敏捷方法论中挑选一些个人能用,且有帮助的实践方法来用。这样确实能从中受益,但这真的是在践行敏捷么?


敏捷不只是一套方法论,敏捷也是一种思维模式。很多个人甚至团队尝试敏捷的过程中一个常见问题,是只取其方法实践,而未学其思维模式。这里说的思维模式,通俗讲就是指培养团队能够形成共识的文化,拥有一致的价值观和原则,塑造一个持续学习、自由、积极的团队氛围。以促使团队达到一种能够持续快速地交付有价值有质量的产品或服务的状态。


文化高于实践,成员能否融入团队文化,将会影响团队具体实践的高效程度。良好的团队文化,有利于促进团队内部的信息共享,从而产生更正确的决策。我们有时感觉自己已经引入敏捷了,但实则依旧保持着瀑布式思维,走的瀑布式开发流程,只是单纯学习并采用了一些好的敏捷实践,以至于最终达到的效果很有限。


这里引用《敏捷宣言》作者之一吉姆·海史密斯在他著作的《敏捷项目管理》中的一段总结: 


没有具体的实践,原则是贫瘠的;但是如果缺乏原则,实践则没有生命、没有个性、没有勇气。伟大的产品出自伟大的团队,而伟大的团队有原则、有个性、有勇气、有坚持、有胆量。


写在最后 


我们很难将整个敏捷的思维与方法流程应用到个人的独立开发工作中,因为敏捷需要坚实的团队基础,以及团队文化的保障,方可有效地落地执行。当然,我们并不否认个人可以尝试从敏捷中探索一些可借鉴学习的实践,并从中受益。


您如何看待这个问题呢,或者您是否有过将敏捷应用到个人的开发、工作、学习等方面的成功或失败的经验,欢迎在评论区一起分享交流。


 


参考资料:


《敏捷项目管理第2版》吉姆·海史密斯


敏捷开发网:http://www.minjiekaifa.com/


究竟什么是敏捷?http://www.zentao.net/blog/agile-…


作者:水牛GH
来源:juejin.cn/post/7308187262755061771
收起阅读 »