开发者博客 – IT技术 尽在开发者博客

开发者博客 – 科技是第一生产力


  • 首页

  • 归档

  • 搜索

知其然而知其所以然,为什么Kafka在28版本中会“抛弃”

发表于 2021-04-25

做积极的人,越努力越幸运!图片

本文已收录到GitHub:github.com/dingwpmz/Ja…

相信大家最近一定关注到一款重量级消息中间件Kafka发布了2.8版本,并且正式移除了对Zookeeper的依赖,**背后的设计哲学是什么呢?**仅仅只是减少了一个外部依赖吗?

答案显然不会这么简单,容我慢慢道来。

在解答为什么之前,我觉得非常有必要先来阐述一下Zookeeper的经典使用场景。

1、Zookeeper的经典使用场景


zookeeper是伴随着大数据、分布式领域的兴起。大数据中的一个非常重要的议题是如何使用众多廉价的机器来实现可靠存储。

所谓廉价的机器就是发生故障的概率非常大,但单台的成本也非常低,分布式领域希望使用多台机器组成一个集群,将数据存储在多台机器上(副本),为了方便实现数据一致性,通常需要从一个复制组中挑选一台主节点用户处理数据的读写,其他节点从主节点拷贝数据,当主节点宕机,需要自动进行重新选举,实现高可用。

上述场景中有一个非常重要的功能Leader选举,如何选举出一个主节点、并支持主节点宕机后自动触发重新选举,实现主从自动切换,实现高可用。

使用Zookeeper提供的临时顺序节点与事件监听机制,能非常轻松的实现Leader选举。

图片

上面的t1,t2可以理解为一个组织中的多个成员,能提供相同的服务,但为了实现冷备效果(即同一时间只有一个成员对外提供服务,我们称之为Leader,当Leader宕机或停止服务后,该组织中的其他成名重新竞争Leader,然后继续对外提供服务)。

正如上图所示,Zookeeper是以集群部署的,能有效避免单点故障,并且集群内部提供了对数据的强一致性。

当成员需要竞争Leader时,借助Zookeeper的实现套路是向zookeeper中的一个数据节点(示例中为/app/order-service/leader)节点创建两个子节点,并且是顺序的临时节点。

客户端判断创建的节点的序号是否为/app/order-service/leader中序号最小的节点,如果是则成为Leader,对外提供服务;

如果序号不是最小的,则向自己前置的注册节点删除事件,一旦Leader代表的进程宕机,它与Zookeeper的会话失效后,与之关联的临时节点会被删除,一旦Leader创建的节点被删除,其后继节点会得到通知,从而再次触发选主,选举出新的Leader,继续对外提供服务,保质服务的高可用性。

回顾上述场景,借助Zookeeper能非常轻松的实现选主,为应用提高可用带来简便性,主要是利用了Zookeeper的几个特性:

  • 临时节点

临时节点是与会话关联的,一点创建该临时节点的会话结束,与之会被自动删除,无需应用方人工删除。

  • 顺序节点
  • 事件机制

借助与事件机制,Zookeeper能及时通知存活的其他应用节点,重新触发选举,使得实现自动主从切换变的非常简单。

2、Kafka对Zookeeper的迫切需求


Kafka中存在众多的Leader选举,熟悉Kafka的朋友应该知道,一个主题可以拥有多个分区(数据分片),每一个数据分片可以配置多个副本,如何保证一个分区的数据在多个副本之间的一致性成为一个迫切的需求。

Kafka的实现套路就是一个分区的多个副本,从中选举出一个Leader用来承担客户端的读写请求,从节点从主节点处拷贝内容,Leader节点根据数据在副本中成功写入情况,进行抉择来确定是否写入成功。

Kafka中topic的分区分布示意图:

图片

故此处需要进行Leader选举,而基于Zookeeper能轻松实现,从此一拍即合,开启了一段“蜜月之旅”。

3、Zookeeper的致命弱点


Zookeeper是集群部署,只要集群中超过半数节点存活,即可提供服务,例如一个由3个节点的Zookeeper,允许1个Zookeeper节点宕机,集群仍然能提供服务;一个由5个节点的Zookeeper,允许2个节点宕机。

但Zookeeper的设计是CP模型,即要保证数据的强一致性,必然在可用性方面做出牺牲。

Zookeeper集群中也存在所谓的Leader节点和从节点,Leader节点负责写,Leader与从节点可用接受读请求,但在Zookeeper内部节点在选举时整个Zookeeper无法对外提供服务。当然正常情况下选举会非常快,但在异常情况下就不好说了,例如Zookeeper节点发生full Gc,此时造成的影响将是毁灭性的。

Zookeeper节点如果频繁发生Full Gc,此时与客户端的会话将超时,由于此时无法响应客户端的心跳请求(Stop World),从而与会话相关联的临时节点将被删除,注意,此时是所有的临时节点会被删除,Zookeeper依赖的事件通知机制将失效,整个集群的选举服务将失效。

站在高可用性的角度,Kafka集群的可用性不仅取决于自身,还受到了外部组件的制约,从长久来看,显然都不是一个优雅的方案。

随着分布式领域相关技术的不断完善,去中心化的思想逐步兴起,去Zookeeper的呼声也越来越高,在这个进程中涌现了一个非常优秀的算法:Raft协议。

Raft协议的两个重要组成部分:Leader选举、日志复制,而日志复制为多个副本提供数据强一致性提供了强一致性,并且一个显著的特点是Raft节点是去中心化的架构,不依赖外部的组件,而是作为一个协议簇嵌入到应用中的,即与应用本身是融合为一体的。

再以Kafka Topic的分布图举例,引用Raft协议的示例图如下:

图片

关于Raft协议,本文并不打算深入进行探讨,但为选主提供了另外一种可行方案,而且还无需依赖第三方组件,何乐而不为呢?故最终Kafka在2.8版本中正式废弃了Zookeeper,拥抱Raft。

如果大家对Raft协议感兴趣,推荐阅读笔者关于Raft协议的系列文章:

  1. 初探raft协议
  2. Raft协议之Leader协议选主实现原理

好了,本文就介绍到这里了,三连(关注、点赞、留言)是对我最大的鼓励,当然可以加笔者微信:dingwpmz,拉您入技术交流群,共同进步。

最后分享笔者一个硬核的RocketMQ电子书,您将获得千亿级消息流转的运维经验。

图片

获取方式:微信搜索【中间件兴趣圈】,回复RMQPDF即可获取。

中间件兴趣圈

中间件兴趣圈

《RocketMQ技术内幕》作者维护,主打成体系剖析JAVA主流中间件架构与设计原理,为构建完备的互联网分布式架构体系而努力,助力突破职场瓶颈。

本文转载自: 掘金

开发者博客 – 和开发相关的 这里全都有

不要再盲目使用parallelStream啦! 创作者训练

发表于 2021-04-25

前言

JAVA8 的特性相信许多开发者都已经非常了解了,其中很重要的一个特性– Stream ,这个特性让我们能够以声明性的方式在集合上构建复杂的查询。并且,Stream API 为并行执行也提供了一种简单的方法。只需添加 parallel() 语句或使用 parallelStream() 函数。但是如果开发者盲目的使用并行流,不仅不会提高性能,反而会引发致命的错误。

示例

现在给你一组指定的数组,需要你计算出每个数字的乘积。这种情况我们采用 stream 可以用一个链式代码直接一步到位,省去了写 for 循环的这样臃肿的代码。

1
2
3
4
5
6
java复制代码 public static void main(String[] args) {
long[] array = new long[]{3, 123, 1, 31, 56, 61, 22};
long total = Arrays.stream(array)
.reduce(1, (acc, next) -> acc * next);
System.out.println(total);
}

如果我们拿到的结果还需要乘以一个固定的数字 m ,那么我们只需要修改代码为:

1
2
java复制代码 int total = Arrays.stream(array)
.reduce(m, (acc, next) -> acc * next);

如果数字过多串行流的顺序执行会不会导致效率很低呢?于是我又尝试采用 parallel() 来执行程序。

1
2
3
4
5
6
7
java复制代码 public static void main(String[] args) {
long[] array = new long[]{3, 123, 1, 31, 56, 61, 22};
long total = Arrays.stream(array)
.parallel()
.reduce(1, (acc, next) -> acc * next);
System.out.println(total);
}

我意外的发现,当 m=1 的时候,串行流和并行流取得的结果是一致的,而当 m 不为 1 时,两者的结果并不匹配。比如当 m=3 的时候,串行流的运算结果为 2578991184 而并行流的运算结果为 1880084573136 。是什么导致了这样的误差呢?

ForkJoinPool

Java Streams 默认使用同一个 ForkJoinPool 执行并行流。 ForkJoinPool 主要就是将任务递归拆分为多个块,然后可以独立地计算每个块。

Stream.reduce 顺序执行的时候是这样的:

未命名文件 (6).png

并行流的算法其实也非常简单,我们假设任务仅被分成 2 部分:

未命名文件 (8).png

每个块都多乘了一次 m ,并行流给每个任务块都应用了给定的标识 m 。知道了这个刚刚的 bug 我们也就可以解决了。我们可以将每个标识 m 都采用 1 ,乘 1 并不会影响程序结果,然后得到最后的结果只会再乘以 m :

1
2
3
4
5
6
7
java复制代码 public static void main(String[] args) {
long[] array = new long[]{3, 123, 1, 31, 56, 61, 22};
long total = Arrays.stream(array)
.parallel()
.reduce(1, (acc, next) -> acc * next) * m;
System.out.println(total);
}

通过这个示例,我们再使用流的时候,有哪些小细节应该注意呢?

Reduce 应当可拆分

如果不确定流是串行流(比如它作为函数参数来提供),则 reduce 函数的 identity 不应影响单个任务块的结果。即求和函数的 identity 必须为 0 ,而求乘积的 identity 必须为 1。

合理采用并行流

并不是所有流操作都应该并行化。例如 map ,flatMap 和 filter 是无状态的,因此我们可以采用并行流的做法。而 sort ,distinct 和 limit 不但不会带来性能提升,反而可能会引发错误。

并且,并行化的有效性在很大程度上取决于流的来源。 ArrayList ,array 或IntStream.range 支持随机访问,这意味着它们可以轻松拆分。但是 LinkedList 分解需要 O(n) 时间。还有 Stream.iterate 和 BufferedReader 也要尽量避免采用并行流,因为它们的开头都有未知的长度,因此很难估算拆分来源。

编写单元测试

并行流虽然具备潜在的性能优势,但是同时也可能带来一些致命的错误,因此,每次将串行流替换为并行流时,为了确保功能未被破坏。需要编写一定的单元侧试。

总结

今天主要介绍了并行流一些使用上的小细节,但是对 ForkJoinPool 并没有做具体解析。觉得写的不错的小伙伴点个赞支持一下吧。

本文转载自: 掘金

开发者博客 – 和开发相关的 这里全都有

盘点 SpringIOC Resource 及 Docu

发表于 2021-04-24

总文档 :文章目录

Github : github.com/black-ant

一 . 前言

第二篇 IOC 的文章还是挑一个软柿子捏 , 这一篇说一说 Resource 和 Document 的加载方式 , 也是比较简单的东西.

扫描部分的体系包括 Resource , Document ,Annotation 几部分 , 这里只说前面2种

二 . Resource 体系篇

Resource 是一切的基础 , 所有的外部对象都可以看成一个 Resource , 为此 Spring 提供了很多 Resource 的实现类 :

Resource.png

常见的 Resource 主要是用于以下功能:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
java复制代码// 当用不同的 ResourceLoader 加载资源的时候 , 会根据资源类型的不同 , 选择生成不同的 Resource

C- ByteArrayResource : 给定字节数组的资源实现 , 用于从任何给定的字节数组加载内容,而不必求助于单一使用的InputStreamResource
C- ClassPathResource : 类路径资源的资源实现 , 使用给定的ClassLoader或给定的Class来加载资源
C- ContextResource : 用于从封闭的“上下文”加载资源的扩展接口
C- DescriptiveResource : 保存资源描述但不指向实际可读资源的简单资源实现
C- EncodedResource : 将资源描述符与用于从资源中读取的特定编码或字符集组合在一起
C- FileSystemResource : 资源实现处理一个文件系统目标
C- FileUrlResource : 它假定文件解析,达到实现WritableResource接口的程度
C- HttpResource : 将资源写入HTTP响应的扩展接口
C- ImportResource : 指示一个或多个包含要导入的bean定义的资源
C- InputStreamResource : 给定InputStream的资源实现 , 只在没有其他特定资源实现适用的情况下使用
C- ServletContextResource : ServletContext资源的资源实现,解释web应用程序根目录中的相对路径
C- VfsResource : 基于JBoss VFS的资源实现
C- WritableResource : 支持向资源写入的资源的扩展接口。提供一个输出流访问器

Resource 体系功能 :

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
java复制代码// 统一资源的核心类是 : Resource , 为 Spring 框架所有资源的抽象和访问接口 
I- Resource
E- InputStreamSource

C- AbstractResource
I- Resource

> 以上是统一资源管理中核心的三个类 ,他们的继承关系如上
: Spring 中所有的资源都可以用 Resource 表示
: AbstractResource 继承自 Resource ,并且对其做了实现

> Resource 中有很多常见的功能
exists / isReadable / isOpen / isFile / getURL / getFile / readableChannel
contentLength / lastModified / createRelative / getFileName / getDescription

> AbstractResource 有以下通用的实现
- FileSystemResource : 对 java.io.File 类型资源的封装,只要是跟 File 打交道的,基本上与 FileSystemResource 也可以打交道
- 支持文件和 URL 的形式,实现 WritableResource 接口,从 Spring Framework 5.0 开始,FileSystemResource 使用 NIO2 API进行读/写交互
- ByteArrayResource : 对字节数组提供的数据的封装。
- URIResource : 对 java.net.URL类型资源的封装。内部委派 URL 进行具体的资源操作。
- ClassPathResource : class path 类型资源的实现。使用给定的 ClassLoader 或者给定的 Class 来加载资源。
- InputStreamResource : 将给定的 InputStream 作为一种资源的 Resource 的实现类。

C- AbstractResource
- exists() : 判断文件是否存在

Resource 接口的实现

1
2
3
java复制代码
C- AbstractResource
TODO

Resource 会通过 ResourceLoader 进行加载 , 重要的 ResourceLoader 包含如下结构 :

ResourceLoader.png

需要注意的是 : ApplicationContext 基本上都是 ResourceLoader 的实现类 , 所以他们通常带有ResourceLoader 的功能

ResourceLoader加载体系

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
java复制代码
> Spring 通过 ResourceLoader 来进行 资源的加载

C11- ResourceLoader : 资源的加载
M- getResource() : 根据所提供资源的路径 location 返回 Resource 实例
- 支持 URL位置资源 / ClassPath位置资源 / 相对路径资源
M- getClassLoader() : 返回 ClassLoader 实例
MC- ResourceLoader(ClassLoader)
- Thread.currentThread().getContextClassLoader()
- ClassUtils.getDefaultClassLoader()
- setClassLoader()


: interface ResourcePatternResolver extends ResourceLoader

C18- DefaultResourceLoader
MC- DefaultResourceLoader
- ClassUtils.getDefaultClassLoader();
MC- DefaultResourceLoader(@Nullable ClassLoader classLoader)
M- addProtocolResolver(ProtocolResolver) : 自定义的 Resolver 加入 Spring 体系
M- getResource(String location)
- 首先,通过 ProtocolResolver 来加载资源 , 成功返回 Resource
- 其次,以 / 开头,调用 #getResourceByPath() 方法, 返回 ClassPathContextResource 类型的资源
- 再次,以 classpath: 开头,返回 ClassPathResource 类型的资源
- 通过#getClassLoader() 获取当前的 ClassLoader
- 然后,根据是否为文件 URL ,是则返回 FileUrlResource 类型的资源,否则返回 UrlResource 类型的资源
- 最后,返回 ClassPathContextResource 类型的资源

// resourceLoader.getResource("D:/Users/chenming673/Documents/spark.txt");

C- ResourcePatternResolver : ResourceLoader 的默认实现
M- setClassLoader / getClassLoader

> FileSystemResourceLoader
内部类 : FileSystemContextResource extends FileSystemResource

C- ProtocolResolver : 用户自定义协议资源解决策略
?- 作为 DefaultResourceLoader 的 SPI:它允许用户自定义资源加载协议,而不需要继承 ResourceLoader 的子类
?- 现 ProtocolResolver 接口也可以实现自定义的 ResourceLoader
M- resolve(String , ResourceLoader )

一个 Resource 加载流程中涉及的主要类

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
java复制代码// XML 流程 Resource 加载 , 我们还是从 BeanDefinitionReader 开始看起
C160- AbstractBeanDefinitionReader
M- loadBeanDefinitions(location, null)
M- loadBeanDefinitions(String location, @Nullable Set<Resource> actualResources)
- getResourceLoader 获取一个 ResourceLoader
- ResourceLoader 通过 location 获取 Resource[] ->
- 讲获取的 Resource 加入 actualResources , 用于后方处理

C16- ResourcePatternResolver


C51- GenericApplicationContext
M51_033- getResources(String locationPattern)


C17- PathMatchingResourcePatternResolver
M17_02- getResources(String locationPattern)
- classpath*: 开头 , 则分别调用 findPathMatchingResources (-> ) / findAllClassPathResources
- getResourceLoader() 调用获取 Resource -> M18_05
M17_03- findPathMatchingResources
- 获取路径 ,递归获取包路径
- 通过包 URLResource 调用 doFindPathMatchingFileResources 获取类

M17_04- findAllClassPathResources
- 调用 doFindAllClassPathResources 获取 classResource

M17_05- doFindAllClassPathResources
- 获取一个 ClassLoader , 通过 ClassLoader 获取 resource url
- 通过 convertClassLoaderURL 对 URL 列表转换为 UrlResource
?- 这里其实还是包路径 -> PS:M17_05_01



// PS:M17_05_01
file:/D:/java/workspace/git/case/case%20Origin%20Source/case%20SpringBootIOC/target/classes/com/gang/study/source/springboot/demo/


C18- DefaultResourceLoader
M18_05- getResource(String location)
- 如果存在 ProtocolResolvers 集合, 则循环集合 , 试图用 ProtocolResolver 处理返回
- 如果是 / 开头 , 则生成一个 ClassPathContextResource
- 如果是 classpath 打头 ,new 创建出一个 ClassPathResource , 并且为其配置一个 ClassLoader
?- 所以 , 这里 bean.xml 是被映射为 ClassPathResource
- 如果是 URL 类型 , 构建为一个 FileUrlResource


// M17_03 伪代码
// locationPattern -- classpath*:com/gang/study/source/springboot/demo/**/*.class
protected Resource[] findPathMatchingResources(String locationPattern) throws IOException {
// classpath*:com/gang/study/source/springboot/demo/
String rootDirPath = determineRootDir(locationPattern);
// **/*.class
String subPattern = locationPattern.substring(rootDirPath.length());

// 扫描路径 , 将路径下资源转换为Resource数组
Resource[] rootDirResources = getResources(rootDirPath);
Set<Resource> result = new LinkedHashSet<>(16);
for (Resource rootDirResource : rootDirResources) {
rootDirResource = resolveRootDirResource(rootDirResource);
URL rootDirUrl = rootDirResource.getURL();
if (equinoxResolveMethod != null && rootDirUrl.getProtocol().startsWith("bundle")) {
URL resolvedUrl = (URL) ReflectionUtils.invokeMethod(equinoxResolveMethod, null, rootDirUrl);
if (resolvedUrl != null) {
rootDirUrl = resolvedUrl;
}
rootDirResource = new UrlResource(rootDirUrl);
}
if (rootDirUrl.getProtocol().startsWith(ResourceUtils.URL_PROTOCOL_VFS)) {
// VFS 的加载方式
result.addAll(VfsResourceMatchingDelegate.findMatchingResources(rootDirUrl, subPattern, getPathMatcher()));
}else if (ResourceUtils.isJarURL(rootDirUrl) || isJarResource(rootDirResource)) {
// JAR 包路径的加载
result.addAll(doFindPathMatchingJarResources(rootDirResource, rootDirUrl, subPattern));
}else {
// 加载类
result.addAll(doFindPathMatchingFileResources(rootDirResource, subPattern));
}
}
return result.toArray(new Resource[0]);
}

加载 Resource 的几个场景 , 通常可以由 location 看出来

类型一 : classpath*:com/gang/study/source/springboot/demo/**/*.class

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
java复制代码//这种路径 , 其源头为  ComponentScanAnnotationParser 开始 , 更早的源头是 Configuration 的相关扫描逻辑

C153- ComponentScanAnnotationParser
M153_01- parse : 由该方法扫描处理 ComponentScan -> M155_03

C155- ClassPathBeanDefinitionScanner
M155_03- doScan(String... basePackages) -> M201_03

C201- ClassPathScanningCandidateComponentProvider
M201_03- scanCandidateComponents(String basePackage)
- 构建一个地址 -> PS:201_03_01
- 调用 ResourcePatternResolver(AnnotationConfigServletWebServerApplicationContext) 获取 Resouce
- 最终调用 M17_02

C17- PathMatchingResourcePatternResolver
M17_02- getResources(String locationPattern)
- Class 前缀 , 最终调用 M17_03
M17_03- findPathMatchingResources
M17_04- findAllClassPathResources



// M201_03 伪代码
private Set<BeanDefinition> scanCandidateComponents(String basePackage) {
//...................
String packageSearchPath = ResourcePatternResolver.CLASSPATH_ALL_URL_PREFIX +
resolveBasePackage(basePackage) + '/' + this.resourcePattern;
// PS:201_03_01
// com.gang.study.source.springboot.demo 转变为
// classpath*:com/gang/study/source/springboot/demo/**/*.class
Resource[] resources = getResourcePatternResolver().getResources(packageSearchPath);
//...................


}

前置资源的加载 :

M153_01M155_03M201_03M17_02ComponentScan 扫描类路径doScan 扫描路径调用 PathMatchingResourcePatternResolver 获取 resourceM153_01M155_03M201_03M17_02
Resource 资源的加载 :

  • M17_02 : getResources
  • M17_03 : findPathMatchingResources
  • M17_04 : findAllClassPathResources
  • M17_05 : doFindAllClassPathResources

M17_02M17_03M17_04M17_05发现是 待匹配的classpath resource 调用 findPathMatchingResources递归 Resource回调 getResources发现为 classPath 调用findAllClassPathResources返回具体的classPath 即 URLReource (实际)调用 classloader 扫描具体的类对象返回对象 , 放入集合M17_02M17_03M17_04M17_05
核心逻辑就是 :

  • 如果路径是待匹配的 : findPathMatchingResources
  • 其中通过匹配 , 调用 findAllClassPathResources 获取真实路径
  • 通过真实路径 , 调用 doFindAllClassPathResources 获取具体的类 Resource

类型二 : classpath*:messages.properties 资源加载

前置资源的加载 :

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
java复制代码
// 这种情况的加载主要来源于 Configuration , 例如 MessageSourceAutoConfiguration
// 此处对 message 资源进行加载 , 调用 ResourceBundleCondition 进行资源加载


C202- ResourceBundleCondition
M202_01- getMatchOutcomeForBasename

M202_02- getResources
- 通过一个 classLoader 构建了一个 PathMatchingResourcePatternResolver
- 调用 getResources 返回相关的 resource 资源
?- 这里因为需要指定的资源 , 所以方式是定下了的

// M202_02 伪代码
private Resource[] getResources(ClassLoader classLoader, String name) {
String target = name.replace('.', '/');
return new PathMatchingResourcePatternResolver(classLoader).getResources("classpath*:" + target + ".properties");

}

// 同样的 , 调用了 PathMatchingResourcePatternResolver
// 此处无需匹配 , 直接 调用 findAllClassPathResources
C17- PathMatchingResourcePatternResolver
M17_02- getResources(String locationPattern)
- 无需匹配 , 直接 调用 findAllClassPathResources -> M17_04
M17_04- findAllClassPathResources

M202_02M17_02M17_04获取 classpath*:messages.properties无需匹配 , 直接 调用 findAllClassPathResources返回资源对于 URLResourceConfiguration 使用资源M202_02M17_02M17_04

类型三 : xml 类型资源

以 classpath:spring-common.xml为例

1
2
3
4
5
6
7
8
9
10
11
12
13
java复制代码

// 起源 :
loadBeanDefinitionsFromImportedResources(configClass.getImportedResources());
?- 即 ImportedResources 注解导致

// 流程 :
C160- AbstractBeanDefinitionReader
M- loadBeanDefinitions
- ((ResourcePatternResolver) resourceLoader).getResources(location)
?- 注意 , 这里的 Resources 仍然是 pring-common.xml 这个对象

// 后续继续调用 XmlBeanDefinitionReader , 这里就不详细说了

PS: 至于其他的不具有代表性 , 就不详细说了

三 . Document 体系篇

当统一资源加载完成后 , 其中的 xml 类型会被处理为 Document 对象

Document 扫描是指对文档体系的扫描 , 主要用于xml 方式配置 Bean 时

PS : 说一句 , 早期的时候我也觉得说 XML 配置很陈旧 , 是过时的方法 , 但是现在反而有种不一样的感觉 , 相比Configuration 方式 ,它的条理更清晰 , 用起来其实也不复杂

Document 体系功能

我们先看下 Doument 的常见方法功能 :

作用 : 用于处理 Document 文档类型对象 , 包括 .xml , .dto , .schemas 等

起源 : XmlBeanDefinitionReader#doLoadDocument(InputSource inputSource, Resource resource) 方法

  • 该方法做了2件事
    • 调用 #getValidationModeForResource(Resource resource) 方法,获取指定资源(xml)的验证模式
    • 调用 DocumentLoader#loadDocument 获取 XML Document 实例
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
java复制代码C25- DocumentLoader : 获取 Document 的策略,由接口 org.springframework.beans.factory.xml.DocumentLoader 定义
P- inputSource 方法参数,加载 Document 的 Resource 资源。
P- entityResolver 方法参数,解析文件的解析器。
P- errorHandler 方法参数,处理加载 Document 对象的过程的错误。
P- validationMode 方法参数,验证模式。
P- namespaceAware 方法参数,命名空间支持。如果要提供对 XML 名称空间的支持,则需要值为 true 。

C26- DefaultDocumentLoader : DocumentLoader 的默认实现类 
M26_01- loadDocument
- 首先,调用 #createDocumentBuilderFactory(...) 方法,创建 javax.xml.parsers.DocumentBuilderFactory 对象
- DocumentBuilderFactory.newInstance(); -- 创建 DocumentBuilderFactory
- factory.setNamespaceAware(namespaceAware); -- 设置命名空间支持
- 调用 #createDocumentBuilder 方法,创建 javax.xml.parsers.DocumentBuilder 对象
- 创建 DocumentBuilder 对象
- 设置 EntityResolver 属性
- 设置 ErrorHandler 属性
- 调用 DocumentBuilder#parse(InputSource) 方法,解析 InputSource ,返回 Document 对象

// XmlBeanDefinitionReader :
M- getEntityResolver() : 返回指定的解析器,如果没有指定,则构造一个未指定的默认解析器
-1 ResourceLoader resourceLoader = getResourceLoader();
IF-2 resourceLoader != null
- this.entityResolver = new ResourceEntityResolver(resourceLoader);
ELSE-2
- this.entityResolver = new DelegatingEntityResolver(getBeanClassLoader());

Document 扫描跟踪 :

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
java复制代码
// 起点 : Document 的起点是 , 这里会通过一个 Resource 加载对于的 xml , 将 XML 转换为 Document
C- ConfigurationClassBeanDefinitionReader
M- loadBeanDefinitionsFromImportedResources

// 中间的逻辑比较简单 , 我们直接从 XmlBeanDefinitionReader 开始看

C21- XmlBeanDefinitionReader
M21_01- doLoadBeanDefinitions(InputSource inputSource, Resource resource)
- 调用 doLoadDocument , 将 Resource 转换为 Document -> M23_01
- 调用 registerBeanDefinitions(doc, resource) 注册Bean
M21_02- doLoadDocument
- documentLoader.loadDocument : loader 加载


C- DocumentLoader

C23- DefaultDocumentLoader
I- DocumentLoader
M23_01- loadDocument(InputSource inputSource, EntityResolver entityResolver,ErrorHandler errorHandler, int validationMode, boolean namespaceAware)
- createDocumentBuilderFactory 创建一个 DocumentBuilderFactory -> M23_02
- 通过 DocumentBuilderFactory 创建一个 DocumentBuilder -> M23_03
- DocumentBuilder parse 方法解读 Document
M23_02- createDocumentBuilderFactory(int validationMode, boolean namespaceAware)
- 通过 newInstance 生成 DocumentBuilderFactory
- 为 DocumentBuilderFactory 设置 NamespaceAware , Validating , 以及 Attribute
?- 注意 , 这里会判断 validationMode 类型来设置
M23_03- createDocumentBuilder(DocumentBuilderFactory factory, EntityResolver entityResolver, ErrorHandler errorHandler)
- 通过传入的工厂生成 DocumentBuilder
- 为 DocumentBuilder 设置 EntityResolver 和 ErrorHandler


// M23_01 补充
public Document loadDocument(InputSource inputSource, EntityResolver entityResolver,
ErrorHandler errorHandler, int validationMode, boolean namespaceAware) throws Exception {
DocumentBuilderFactory factory = createDocumentBuilderFactory(validationMode, namespaceAware);
DocumentBuilder builder = createDocumentBuilder(factory, entityResolver, errorHandler);
return builder.parse(inputSource);
}


// M23_02 补充
protected DocumentBuilderFactory createDocumentBuilderFactory(int validationMode, boolean namespaceAware){
DocumentBuilderFactory factory = DocumentBuilderFactory.newInstance();
factory.setNamespaceAware(namespaceAware);
if (validationMode != XmlValidationModeDetector.VALIDATION_NONE) {
factory.setValidating(true);
if (validationMode == XmlValidationModeDetector.VALIDATION_XSD) {
factory.setNamespaceAware(true);
factory.setAttribute(SCHEMA_LANGUAGE_ATTRIBUTE, XSD_SCHEMA_LANGUAGE);
//.... 省略catch
}
}
return factory;
}


// N23_03
protected DocumentBuilder createDocumentBuilder(DocumentBuilderFactory factory,
@Nullable EntityResolver entityResolver, @Nullable ErrorHandler errorHandler)
throws ParserConfigurationException {

DocumentBuilder docBuilder = factory.newDocumentBuilder();
if (entityResolver != null) {
docBuilder.setEntityResolver(entityResolver);
}
if (errorHandler != null) {
docBuilder.setErrorHandler(errorHandler);
}
return docBuilder;
}

DocumentBuilder

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
java复制代码DocumentBuilder 是一个抽象类 , 其主要 实现是 DocumentBuilderImpl


C24- DocumentBuilder
F- DOMParser domParser;
F- EntityResolver fInitEntityResolver
F- ErrorHandler fInitErrorHandler
MC- DocumentBuilderImpl
M24_01- parse
- domParser.parse(is)
- domParser getDocument 获得 Document

// 这里涉及到 com.sun.org.apache.xerces.internal.parsers.DOMParser 对象
C- DOMParser
?- 用于对 DOM 类型解析处理

完整 Document 处理流程图

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
java复制代码// Step 1 : ConfigurationClassBeanDefinitionReader # loadBeanDefinitions
?- 用于加载 class类上面的 classpath load
// Step 2 : AbstractBeanDefinitionReader # loadBeanDefinitions
?- 开始加载 xml 文件
// Step 3 : XmlBeanDefinitionReader # loadBeanDefinitions
?- 处理加载的 resource 对象 , xml resource 的加载可以看上文

// Step 4 : 主要处理流程开始
C21- XmlBeanDefinitionReader
M21_01- doLoadBeanDefinitions
- doLoadDocument(inputSource, resource) -> M21_02
M21_02- doLoadDocument


C26- DefaultDocumentLoader : DocumentLoader 的默认实现类 
M26_01- loadDocument

C24- DocumentBuilder
M24_01- parse

M21_01M21_02M26_01M24_01加载 bean.xml , 获得对象的 resource 文件doLoadDocument 处理 resource 对象DocumentBuilderFactory -> DocumentBuilder 加载document解析 Document返回 Document 对象处理 document 对象 -> registerBeanDefinitionsM21_01M21_02M26_01M24_01

扩展 EntityResolver 对象

EntityResolver 对象用于对 Document 进行解析 , 现在常见的有这几种 :

参考文档 @ cmsblogs.com/?p=2695

  • ResourceEntityResolver:继承自 EntityResolver ,通过 ResourceLoader 来解析实体的引用。
  • DelegatingEntityResolver:EntityResolver 的实现,分别代理了 dtd 的 BeansDtdResolver 和 xml schemas 的 PluggableSchemaResolver。
  • BeansDtdResolver : spring bean dtd 解析器。EntityResolver 的实现,用来从 classpath 或者 jar 文件加载 dtd。
  • PluggableSchemaResolver:使用一系列 Map 文件将 schema url 解析到本地 classpath 资源
1
2
3
4
5
6
7
8
9
10
11
java复制代码protected EntityResolver getEntityResolver() {
if (this.entityResolver == null) {
ResourceLoader resourceLoader = getResourceLoader();
if (resourceLoader != null) {
this.entityResolver = new ResourceEntityResolver(resourceLoader);
}else {
this.entityResolver = new DelegatingEntityResolver(getBeanClassLoader());
}
}
return this.entityResolver;
}

补充 Document 功能

Document 主要就是为了将 XML 解释为可读对象

Document.png

补充 DTD 和 XSD

参考文档 @ cmsblogs.com/?p=2688 , 建议看看原版 , 这里摘取些关键环节

DTD(Document Type Definition),即文档类型定义,为 XML 文件的验证机制,属于 XML 文件中组成的一部分。

DTD 是一种保证 XML 文档格式正确的有效验证方式,它定义了相关 XML 文档的元素、属性、排列方式、元素的内容类型以及元素的层次结构。其实 DTD 就相当于 XML 中的 “词汇”和“语法”,我们可以通过比较 XML 文件和 DTD 文件 来看文档是否符合规范,元素和标签使用是否正确。 要在 Spring 中使用 DTD,需要在 Spring XML 文件头部声明:

1
2
java复制代码<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE beans PUBLIC "-//SPRING//DTD BEAN//EN" "http://www.springframework.org/dtd/spring-beans.dtd">

DTD 在一定的阶段推动了 XML 的发展,但是它本身存在着一些缺陷:

  • 它没有使用 XML 格式,而是自己定义了一套格式,相对解析器的重用性较差;
  • DTD 的构建和访问没有标准的编程接口,因而解析器很难简单的解析 DTD 文档。
  • DTD 对元素的类型限制较少;同时其他的约束力也叫弱。
  • DTD 扩展能力较差。
  • 基于正则表达式的 DTD 文档的描述能力有限。

XSD 具有如下优势
针对 DTD 的缺陷,W3C 在 2001 年推出 XSD。XSD(XML Schemas Definition)即 XML Schema 语言。XML Schema 本身就是一个 XML文档,使用的是 XML 语法,因此可以很方便的解析 XSD 文档。

  • XML Schema基于XML,没有专门的语法
  • XML Schema可以象其他XML文件一样解析和处理
  • XML Schema比DTD提供了更丰富的数据类型.
  • XML Schema提供可扩充的数据模型。
  • XML Schema支持综合命名空间
  • XML Schema支持属性组。

不同的验证模式使用不同的解析器解析 :

  • XSD : www.springframework.org/schema/bean…
  • DTD : www.springframework.org/dtd/spring-…

ValidationMode 的验证位置

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
JAVA复制代码
C21- XmlBeanDefinitionReader
M21_02- doLoadDocument -> PS:M21_02_01

// PS:M21_02_01 此处获取了 ValidationMode
return this.documentLoader.loadDocument(inputSource, getEntityResolver(), this.errorHandler,getValidationModeForResource(resource), isNamespaceAware());


protected int getValidationModeForResource(Resource resource) {
int validationModeToUse = getValidationMode();
if (validationModeToUse != VALIDATION_AUTO) {
return validationModeToUse;
}
int detectedMode = detectValidationMode(resource);
if (detectedMode != VALIDATION_AUTO) {
return detectedMode;
}
return VALIDATION_XSD;
}


// 常量
/**
* Indicates that the validation mode should be auto-guessed, since we cannot find
* a clear indication (probably choked on some special characters, or the like).
*/
public static final int VALIDATION_AUTO = 1;

/**
* Indicates that DTD validation should be used (we found a "DOCTYPE" declaration).
*/
public static final int VALIDATION_DTD = 2;

/**
* Indicates that XSD validation should be used (found no "DOCTYPE" declaration).
*/
public static final int VALIDATION_XSD = 3;

总结

Resource 和 Document 的扫描收集是一切的基础 ,当2者采集完全后 , 就可以开始相关类的加载工作了

本文转载自: 掘金

开发者博客 – 和开发相关的 这里全都有

美团Serverless平台Nest的探索与实践

发表于 2021-04-24

Serverless是目前比较热门的技术话题,各大云平台以及互联网大厂内部都在积极建设Serverless产品。本文将介绍美团Serverless产品在落地过程中的一些实践经验,其中包括技术选型的考量、系统的详细设计、系统稳定性优化、产品的周边生态建设以及在美团的落地情况。虽然各个公司的背景不尽相同,但总有一些可以相互借鉴的思路或方法,希望能给大家带来一些启发或者帮助。

1 背景

Serverless一词于2012年被提出,2014年由于亚马逊的AWS Lambda无服务器计算服务的兴起,而被大家广泛认知。Serverless通常被直译成“无服务器”,无服务器计算是可以让用户在不考虑服务器的情况下构建并运行应用程序。使用无服务器计算,应用程序仍在服务器上运行,但所有服务器管理工作均由Serverless平台负责。如机器申请、代码发布、机器宕机、实例扩缩容、机房容灾等都由平台帮助自动完成,业务开发只需考虑业务逻辑的实现即可。

回顾计算行业的发展历程,基础设施从物理机到虚拟机,再从虚拟机到容器;服务架构从传统单体应用架构到SOA架构,再从SOA架构到微服务架构。从基础设施和服务架构两条主线来看整体技术发展趋势,大家可能会发现,不论是基础设施还是服务架构,都是从大往小或者由巨到微的方向上演进,这种演变的本质原则无非是解决资源成本或者研发效率的问题。当然,Serverless也不例外,它也是用来解决这两个方面的问题:

  • 资源利用率:Serverless产品支持快速弹性伸缩能力,能够帮助业务提升资源利用率,在业务流量高峰时,业务的计算能力、容量自动扩容,承载更多的用户请求,而在业务流量下降时,所使用的资源也会同时收缩,避免资源浪费。
  • 研发运维效率:在Serverless上开发人员一般只需要填写代码路径或者上传代码包,平台能够帮助完成构建、部署的工作。开发人员不直接面对机器,对于机器的管理,机器是否正常以及流量高低峰的是否需要扩缩容等问题,这些统统不需要去考虑,由Serverless产品帮助研发人员去完成。这样就能使他们从繁琐的运维工作中解放出来,从DevOps转向NoOps,更加专注于业务逻辑的实现。

虽然AWS在2014年就推出了第一个Serverless产品Lambda,但Serverless技术在国内的应用一直不温不火。不过近两三年,在容器、Kubernetes以及云原生等技术的推动下,Serverless技术迅速发展,国内各大互联网公司都在积极建设Serverless相关产品,探索Serverless技术的落地。在这种背景下,美团也于2019年初开始了Serverless平台的建设,内部项目名称为Nest。

截止到目前,Nest平台已经过两年的建设,回顾整体的建设过程,主要经历了以下三个阶段:

  • 快速验证,落地MVP版本:我们通过技术选型、产品与架构设计、开发迭代,快速落地了Serverless产品的基本的能力,如构建、发布、弹性伸缩、对接触发源、执行函数等。上线后,我们推进了一些业务的试点接入,帮助验证打磨产品。
  • 优化核心技术,保障业务稳定性:有了前期的试点业务验证,我们很快发现产品的存在的一些稳定性相关的问题,主要有弹性伸缩的稳定性、冷启动的速度、系统与业务的可用性、容器的稳定性。针对这些问题我们对各个问题涉及的技术点做了专项的优化改进。
  • 完善技术生态,落实收益:优化了核心技术点后,产品逐渐成熟稳定,但依然面临生态性问题,如研发工具欠缺,上下游产品没有打通、平台开放能力不足等问题,影响或阻碍了产品的推广使用。因此,我们继续完善产品的技术生态,扫清业务接入使用障碍,落实产品的业务收益。

2 快速验证,落地MVP版本

2.1 技术选型

建设Nest平台,首要解决的就是技术选型问题,Nest主要涉及三个关键点的选型:演进路线、基础设施、开发语言。

2.1.1 演进路线

起初Serverless服务主要包含FaaS(Function as a Service)和BaaS(Backend as a Service),近几年Serverless的产品领域有所扩张,它还包含面向应用的Serverless服务。

  • FaaS:是运行在一个无状态的计算容器中的函数服务,函数通常是事件驱动、生命周期很短(甚至只有一次调用)、完全由第三方管理的。业界相关FaaS产品有AWS的Lambda、阿里云的函数计算等。
  • BaaS:是建立在云服务生态之上的后端服务。业界相关BaaS产品包括AWS的S3、DynamoDB等。

面向应用的Serverless服务:如Knative,它提供了从代码包到镜像的构建、部署,以及弹性伸缩等全面的服务托管能力,公有云产品有Google Cloud Run(基于Knative)、阿里云的SAE(Serverless Application Engine)。

在美团内部,BaaS产品其实就是内部的中间件以及底层服务等,它们经过多年的发展,已经非常丰富且成熟了。因此,在美团的Serverless产品演进主要在函数计算服务和面向应用的Serverless服务两个方向上。那究竟该如何演进呢?当时主要考虑到在业界FaaS函数计算服务相对于面向应用的Serverless服务来说,更加成熟且确定。因此,我们决定“先建设FaaS函数计算服务,再建设面向应用的Serverless服务”这样一条演进路线。

2.1.2 基础设施

由于弹性伸缩是Serverless平台必备的能力,因此Serverless必然涉及到底层资源的调度和管理。这也是为什么当前业界有很多开源的Serverless产品(如OpenFaaS、Fission、Nuclio、Knative等)是基于Kubernetes来实现的,因为这种选型能够充分利用Kubernetes的基础设施的管理能力。在美团内部基础设施产品是Hulk,虽然Hulk是基于Kubernetes封装后的产品,但Hulk在落地之初考虑到落地难度以及各种原因,最终未按照原生的方式来使用Kubernetes,并且在容器层采用的也是富容器模式。

在这种历史背景下,我们在做基础设施选型时就面临两种选项:一是使用公司的Hulk来作为Nest的基础设施(非原生Kubernetes),二是采用原生Kubernetes基础设施。我们考虑到当前业界使用原生Kubernetes是主流趋势并且使用原生Kubernetes还能充分利用Kubernetes原生能力,可以减少重复开发。因此,最终考量的结果是我们采用了原生Kubernetes作为我们的基础设施。

2.1.3 开发语言

由于在云原生领域的主流语言是Golang,并且Kubernetes的生态中,Golang是绝对的主导语言。但在美团,Java才是使用最广泛的语言,相比Golang,Java在公司内部生态比较好。因此,在语言的选型上我们选择了Java语言。在Nest产品开发之初,Kubernetes社区的Java客户端还不够完善,但随着项目的推进,社区的Java客户端也逐渐丰富了起来,目前已经完全够用了。另外,我们也在使用过程中,也贡献了一些Pull Request,反哺了社区。

2.2 架构设计

基于以上的演进路线、基础设施、开发语言的选型,我们进行了Nest产品的架构设计。

在整体的架构上,流量由EventTrigger(事件触发源,如Nginx、应用网关、定时任务、消息队列、RPC调用等)触发到Nest平台,Nest平台内会根据流量的特征路由到具体函数实例,触发函数执行,而函数内部代码逻辑可以调用公司内的各个BaaS服务,最终完成函数的执行,返回结果。

图1 FaaS架构图

在技术实现上,Nest平台使用Kubernetes作为基础底座并适当参考了一些Knative的优秀设计,在其架构内部主要由以下几个核心部分组成:

  • 事件网关:核心能力是负责对接外部事件源的流量,然后路由到函数实例上;另外,网关还负责统计各个函数的进出流量信息,为弹性伸缩模块提供伸缩决策的数据支撑。
  • 弹性伸缩:核心能力是负责函数实例的弹性伸缩,伸缩主要根据函数运行的流量数据以及实例阈值配置计算函数目标实例个数,然后借助Kubernetes的资源控制能力,调整函数实例的个数。
  • 控制器:核心能力是负责Kubernetes CRD(Custom Resource Definition)的控制逻辑实现。
  • 函数实例:函数的运行实例。当事件网关流量触发过来,会在函数实例内执行相应的函数代码逻辑。
  • 治理平台:面向用户使用的平台,负责函数的构建、版本、发布以及一些函数元信息的管理等。

图2 Nest架构图

2.3 流程设计

在具体的CI/CD流程上,Nest又与传统的模式有何区别呢?为了说明这个问题,我们先来看一看在Nest平台上函数的整体生命周期怎样的?具体有以下四个阶段:构建、版本、部署、伸缩。

  • 构建:开发的代码和配置通过构建生成镜像或可执行文件。
  • 版本:构建生成的镜像或可执行文件加上发布配置形成一个不可变的版本。
  • 部署:将版本发布,即完成部署。
  • 伸缩:根据函数实例的流量以及负载等信息,来进行实例的弹性扩缩容。

就这四个阶段来看,Nest与传统的CI/CD流程本质区别在于部署和伸缩:传统的部署是感知机器的,一般是将代码包发布到确定的机器上,但Serverless是要向用户屏蔽机器的(在部署时,可能函数的实例数还是0);另外,传统的模式一般是不具备动态扩缩容的,而Serverless则不同,Serverless平台会根据业务的自身流量需要,进行动态扩缩容。后续章节会详细讲解弹性伸缩,因此这里我们只探讨部署的设计。

部署的核心点在于如何向用户屏蔽机器?对于这个问题,我们抽象了机器,提出了分组的概念,分组是由SET(单元化架构的标识,机器上会带有该标识)、泳道(测试环境隔离标识,机器上会带有该标识)、区域(上海、北京等)三个信息组成。用户部署只需在相应的分组上进行操作,而不用涉及到具体机器。能够做到这些的背后,是由Nest平台帮助用户管理了机器资源,每次部署会根据分组信息来实时初始化相应的机器实例。

图3 函数生命周期

2.4 函数触发

函数的执行是由事件触发的。完成函数的触发,需要实现以下四个流程:

  • 流量引入:向事件源注册事件网关的信息,将流量引入到事件网关。如针对MQ事件源,通过注册MQ的消费组,引入MQ的流量到事件网关。
  • 流量适配:事件网关对事件源进入的流量进行适配对接。
  • 函数发现:对函数元数据(函数实例信息、配置信息等)的获取过程,类似微服务的服务发现过程。事件网关接受的事件流量需要发送到具体的函数实例,这就需要对函数进行发现。这里发现实质是获取Kubernetes中的内置资源或者CRD资源中存储的信息。
  • 函数路由:事件流量的路由过程,路由到特定的函数实例上。这里为了支持传统路由逻辑(如SET、泳道、区域路由等)以及版本路由能力,我们采用了多层路由,第一层路由到分组(SET、泳道、区域路由),第二层路由到具体版本。同版本内的实例,通过负载均衡器选择出具体实例。另外,通过该版本路由,我们很轻松的支持了金丝雀、蓝绿发布。

图4 函数触发

2.5 函数执行

函数不同于传统的服务,传统的服务是个可执行的程序,但函数不同,函数是代码片段,自身是不能单独执行的。那流量触发到函数实例后,函数是如何执行的呢?

函数的执行的首要问题是函数的运行环境:由于Nest平台是基于Kubernetes实现的,因此函数一定是运行在Kubernetes的Pod(实例)内,Pod内部是容器,容器的内部是运行时,运行时是函数流量接收的入口,最终也是由运行时来触发函数的执行。一切看起来是那么的顺利成章,但我们在落地时是还是遇到了一些困难,最主要的困难是让开发同学可以在函数内无缝的使用公司内的组件,如OCTO(服务框架)、Celler(缓存系统)、DB等。

在美团的技术体系中,由于多年的技术沉淀,很难在一个纯粹的容器(没有任何其他依赖)中运行公司的业务逻辑。因为公司的容器中沉淀了很多环境或服务治理等能力,如服务治理的Agent服务以及实例环境配置、网络配置等。

因此,为了业务在函数内无缝的使用公司内的组件,我们复用公司的容器体系来降低业务编写函数的成本。但复用公司的容器体系也没那么简单,因为在公司内没有人试过这条路,Nest是公司第一个基于原生Kubernetes建设的平台,“第一个吃螃蟹的人”总会遇到一些坑。对于这些坑,我们只能在推进过程中“逢山开路,遇水搭桥”,遇到一个解决一个。总结下来,其中最核心的是在容器的启动环节打通的CMDB等技术体系,让运行函数的容器与开发同学平时申请的机器用起来没有任何区别。

图5 函数执行

2.6 弹性伸缩

弹性伸缩的核心问题主要有三个:什么时候伸缩,伸缩多少,伸缩的速度快不快?也就是伸缩时机、伸缩算法、伸缩速度的问题。

  • 伸缩时机:根据流量Metrics实时计算函数期望实例数,进⾏扩缩。流量的Metrics数据来自于事件网关,这里主要统计函数的并发度指标,弹性伸缩组件每秒中会主动从事件网关获取一次Metrics数据。
  • 伸缩算法:并发度/单实例阈值=期望实例数。根据收集的Metrics数据以及业务配置的阈值,通过算法计算出期望的实例数,然后通过Kubernetes接口设置具体实例数。整个算法看起来虽然简单,但非常稳定、鲁棒性好。
  • 伸缩速度:主要取决于冷启动时间,在下个章节会详细讲解这块内容。

除了基本的扩缩容能力,我们还支持了伸缩到0,支持配置最大、最小实例数(最小实例即预留实例)。伸缩到0的具体实现是,我们在事件网关内部增加了激活器模块,当函数无实例时,会将函数的请求流量缓存在激活器内部,然后立即通过流量的Metrics去驱动弹性伸缩组件进行扩容,等扩容的实例启动完成后,激活器再将缓存的请求重试到扩容的实例上触发函数执行。

图6 弹性伸缩

3 优化核心技术,保障业务稳定性

3.1 弹性伸缩优化

上面提到的伸缩时机、伸缩算法、伸缩速度这三要素都是理想情况下的模型,尤其是伸缩速度,当前技术根本做不到毫秒级别的扩缩容。因此,在线上实际场景中,弹性伸缩会存在一些不符合预期的情况,比如实例伸缩比较频繁或者扩容来不及,导致服务不太稳定的问题。

  • 针对实例伸缩比较频繁问题,我们在弹性伸缩组件内维护了统计数据的滑动窗⼝,通过计算均值来平滑指标,还通过延时缩容,实时扩容来缓解频繁扩缩问题。另外,我们增加了基于QPS指标的伸缩策略,因为QPS指标相对并发度指标会更加稳定。
  • 针对扩容来不及问题,我们采取提前扩容的手段,当达到实例阈值的70%就扩容,能够比较好的缓解这个问题。除此之外,我们还支持了多指标混合伸缩(并发度、QPS、CPU、Memory),定时伸缩等策略,满足各种业务需求。

下图展示的是线上弹性伸缩的真实案例(其配置的最小实例数为4,单实例阈值100,阈值使用率0.7),其中上半部分是业务每秒的请求数,下半部分是扩缩实例的决策图,可以看到在成功率100%的情况下,业务完美应对流量高峰。

图7 弹性伸缩案例

3.2 冷启动优化

冷启动是指在函数调用链路中包含了资源调度、镜像/代码下载、启动容器、运行时初始化、用户代码初始化等环节。当冷启动完成后,函数实例就绪,后续请求就能直接被函数执行。冷启动在Serverless领域至关重要,它的耗时决定了弹性伸缩的速度。

所谓“天下武功,无坚不破,唯快不破”,这句话在Serverless领域也同样受用。试想如果拉起一个实例足够快,快到毫秒级别,那几乎所有的函数实例都可以缩容到0,等有流量时,再扩容实例处理请求,这对于存在高低峰流量的业务将极大的节省机器资源成本。当然,理想很丰满,现实很骨感。做到毫秒级别几乎不可能。但只要冷启动时间越来越短,成本自然就会越来越低,另外,极短的冷启动时间对伸缩时函数的可用性以及稳定性都有莫大的好处。

图8 冷启动的各个阶段

冷启动优化是个循序渐进的过程,我们对冷启动优化主要经历了三个阶段:镜像启动优化、资源池优化、核心路径优化。

  • 镜像启动优化:我们对镜像启动过程中的耗时环节(启动容器和运行时初始化)进行了针对性优化,主要对容器IO限速、一些特殊Agent启动耗时、启动盘与数据盘数据拷贝等关键点的优化,最终将启动过程中的系统耗时从42s优化到12s左右。

图9 镜像启动优化成果

  • 资源池优化:镜像启动耗时优化到12s,基本已经快达到瓶颈点,再继续优化空间不大。因此,我们想能否绕开镜像启动的耗时环节?最终,我们采用了一个比较简单思路“空间换时间”,用资源池方案:缓存一些已启动的实例,当需要扩容时,直接从资源池获取实例,绕开镜像启动容器的环节,最终效果很明显,将启动的系统耗时从12s优化到3s。这里需要说明的是资源池自身也是通过Kubernetes的Depolyment进行管理,池中实例被取走会立即自动补充。

图10 资源池优化成果

  • 核心路径优化:在资源池优化的基础上,我们再次精益求精,针对启动流程中的下载与解压代码两个耗时环节进行优化,过程中我们采用了高性能的压缩解压算法(LZ4与Zstd)以及并行下载和解压技术,效果非常好。另外,我们还支持了通用逻辑(中间件、依赖包等)下沉,通过预加载的方式,最终将函数端到端的启动耗时优化到2s,这就意味着扩容一个函数实例只需要2s(包含函数启动)。如果排除掉函数自身的初始化启动耗时,平台侧的耗时已在毫秒级别。

3.3 高可用保障

说到高可用,对于一般的平台,指的就是平台自身的高可用,但Nest平台有所不同,Nest的高可用还包含托管在Nest平台上的函数。因此,Nest的高可用保障需要从平台和业务函数两个方面着手。

3.3.1 平台高可用

对平台的高可用,Nest主要从架构层、服务层、监控运营层、业务视角层面都做了全面的保障。

  • 架构层:我们针对有状态服务,如弹性伸缩模块,采用了主从架构,当主节点异常时从节点会立即替换。另外,我们还实现了架构上的多层隔离。 横向地域隔离:Kubernetes两地两集群强隔离、服务(事件网关、弹性伸缩)集群内两地弱隔离(上海的弹性伸缩只负责上海Kubernetes集群内的业务伸缩,事件网关存在两地调用需求,需访问两地Kubernetes)。纵向业务线隔离:服务业务线强隔离,不同业务线使用不同集群服务;在Kubernetes层的资源用namespace实现业务线弱隔离。

图11 部署架构

  • 服务层:主要指的是事件网关服务,由于所有的函数流量都经过事件网关,因此事件网关的可用性尤为重要,这层我们支持了限流和异步化,保障服务的稳定性。
  • 监控运营层:主要通过完善系统监控告警、梳理核心链路并推动相关依赖方进行治理。另外,我们会定期梳理SOP并通过故障演练平台实施故障注入演练,发现系统隐患问题。
  • 业务视角层:我们开发了在线不间断实时巡检服务,通过模拟用户函数的请求流量,实时检测系统的核心链路是否正常。

3.3.2 业务高可用

对于业务高可用,Nest主要从服务层、平台层两个层面做了相关的保障。

  • 服务层:支持了业务降级、限流能力:当后端函数故障时,可通过降级配置,返回降级结果。针对异常的函数流量,平台支持限制其流量,防止后端函数实例的被异常流量打垮。
  • 平台层:支持了实例保活、多层级容灾以及丰富的监控告警能力:当函数实例异常时,平台会自动隔离该实例并立即扩容新实例。平台支持业务多地区部署,在同地区将函数实例尽可能打散不同机房。当宿主机、机房、地区故障时,会立即在可用宿主机、可用机房或可用区重建新实例。另外,平台自动帮业务提供了函数在时延、成功率、实例伸缩、请求数等多种指标的监控,当在这些指标不符合预期时,自动触发告警,通知业务开发和管理员。
  • 图12 业务监控

3.4 容器稳定性优化

前文已提到,Serverless与传统模式在CI/CD流程上是不同的,传统模式都是事先准备好机器然后部署程序,而Serverless则是根据流量的高低峰实时弹性扩缩容实例。当新实例扩容出来后,会立即处理业务流量。这听起来貌似没什么毛病,但在富容器生态下是存在一些问题的:我们发现刚扩容的机器负载非常高,导致一些业务请求执行失败,影响业务可用性。

分析后发现主要是因为容器启动后,运维工具会进行Agent升级、配置修改等操作,这些操作非常耗CPU。同在一个富容器中,自然就抢占了函数进程的资源,导致用户进程不稳定。另外,函数实例的资源配置一般比传统服务的机器要小很多,这也加剧了该问题的严重性。基于此,我们参考业界,联合容器设施团队,落地了轻量级容器,将运维的所有Agent放到Sidecar容器中,而业务的进程单独放到App容器中。采用这种容器的隔离机制,保障业务的稳定性。同时,我们也推动了容器裁剪计划,去掉一些不必要的Agent。

图13 轻量级容器

4 完善生态,落实收益

Serverless是个系统工程,在技术上涉及到Kubernetes、容器、操作系统、JVM、运行时等各种技术,在平台能力上涉及到CI/CD各个流程的方方面面。

为了给用户提供极致的开发体验,我们为用户提供了开发工具的支持,如CLI(Command Line Interface)、WebIDE等。为了解决现有上下游技术产品的交互的问题,我们与公司现有的技术生态做了融合打通,方便开发同学使用。为了方便下游的集成平台对接,我们开放了平台的API,实现Nest赋能各下游平台。针对容器过重,系统开销大,导致低频业务函数自身资源利用率不高的问题,我们支持了函数合并部署,成倍提升资源利用率。

4.1 提供研发工具

开发工具能够降低平台的使用成本,帮助开发同学快速的进行CI/CD流程。目前Nest提供了CLI工具,帮助开发同学快速完成创建应用、本地构建、本地测试、Debug、远程发布等操作。Nest还提供了WebIDE,支持在线一站式完成代码的修改、构建、发布、测试。

4.2 融合技术生态

仅支持这些研发工具还是不够的,项目推广使用后,我们很快就发现开发同学对平台有了新的需求,如无法在Pipeline流水线、线下服务实例编排平台上完成对函数的操作,这对我们项目的推广也形成了一些阻碍。因此,我们融合这些公司的成熟技术生态,打通了Pipeline流水线等平台,融入到现有的上下游技术体系内,解决用户的后顾之忧。

4.3 开放平台能力

有很多Nest的下游解决方案平台,如SSR(Server Side Render)、服务编排平台等,通过对接Nest的OpenAPI,实现了生产力的进一步解放。例如,不用让开发同学自己去申请、管理和运维机器资源,就能够让用户非常快速的实现一个SSR项目或者编排程序从0到1的创建、发布与托管。

Nest除了开放了平台的API,还对用户提供了自定义资源池的能力,拥有了该项能力,开发同学可以定制自己的资源池,定制自己的机器环境,甚至可以下沉一些通用的逻辑,实现冷启动的进一步优化。

4.4 支持合并部署

合并部署指的是将多个函数部署在一个机器实例内。合并部署的背景主要有两个:

  • 当前的容器较重,容器自身的系统开销较大,导致业务进程资源利用率不高(尤其是低频业务)。
  • 在冷启动耗时不能满足业务对时延的要求的情况下,我们通过预留实例来解决业务的需求。

基于这两个背景,我们考虑支持合并部署,将一些低频的函数部署到同一个机器实例内,来提升预留实例中业务进程的资源利用率。

在具体实现上,我们参考Kubernetes的设计方案,设计了一套基于Sandbox的函数合并部署体系(每个Sandbox就是一个函数资源),将Pod类比成Kubernetes的Node资源,Sandbox类比成Kubernetes的Pod资源,Nest Sidecar类比成Kubelet。为了实现Sandbox特有的部署、调度等能力,我们还自定义了一些Kubernetes资源(如SandboxDeployment、SandboxReplicaSet、SandboxEndpoints等)来支持函数动态插拔到具体的Pod实例上。

图14 合并部署架构

除此之外,在合并部署的形态下,函数之间的隔离性也是不可回避的问题。为了尽可能的解决函数(合并在同一个实例中)之间的互相干扰问题,在Runtime的实现上,我们针对Node.js和Java语言的特点采取了不同的策略:Node.js语言的函数使用不同的进程来实现隔离,而Java语言的函数,我们采用类加载隔离。采用这种策略的主要原因是由于Java进程占用内存空间相较于Node.js进程会大很多。

5 落地场景、收益

目前Nest产品在美团前端Node.js领域非常受欢迎,也是落地最广泛的技术栈。当前Nest产品在美团前端已实现了规模化落地,几乎涵盖了所有业务线,接入了大量的B/C端的核心流量。

5.1 落地场景

具体的落地前端场景有:BFF(Backend For Frontend)、CSR(Client Side Render)/SSR(Server Side Render)、后台管理平台场景、定时任务、数据处理等。

  • BFF场景:BFF层主要为前端页面提供数据,采用Serverless模式,前端同学不需要考虑不擅长的运维环节,轻松实现了BFF向SFF(Serverless For Frontend)模式的转变。
  • CSR/SSR场景:CSR/SSR指的是客户端渲染和服务端渲染,有了Serverless平台,不用考虑运维环节,更多的前端业务来尝试使用SSR来实现前端首屏的快速展现。
  • 后台管理平台场景:公司有很多的后台管理平台的Web服务,它们虽然相较于函数是比较重的,但完全可以直接托管Serverless平台,充分享受Serverless平台极致的发布和运维效率。
  • 定时任务场景:公司存在很多周期性任务,如每隔几秒拉取数据,每天0点清理日志,每小时收集全量数据并生成报表等,Serverless平台直接与任务调度系统打通,只需写好任务的处理逻辑并在平台上配置定时触发器,即完成定时任务的接入,完全不用管理机器资源。
  • 数据处理场景:将MQ Topic作为事件源接入Serverless平台,平台会自动订阅Topic的消息,当有消息消费时,触发函数执行,类似定时任务场景,作为用户也只需写好数据处理的逻辑并在平台上配置好MQ触发器,即完成MQ消费端的接入,完全不用管理机器资源。

5.2 落地收益

Serverless的收益是非常明显的,尤其在前端领域,大量的业务接入已是最好的说明。具体收益,从以下两个方面分别来看:

  • 降成本:通过Serverless的弹性伸缩能力,高频业务资源利用率能提升到40%~50%;低频业务函数通过合并部署,也能极大降低函数运行成本。
  • 提效率:整体研发研发效率提升约40%。
  • 从代码开发来看,提供完备的CLI、WebIDE等研发工具,能够帮助开发同学生成代码脚手架,聚焦编写业务逻辑,快速完成本地测试;另外,让业务服务零成本具备在线查看日志与监控的能力。
  • 从发布来看,通过云原生的模式,业务无需申请机器,发布、回滚都是秒级别的体验。另外,还能利用平台天然能力,配合事件网关,实现切流、完成金丝雀测试等。
  • 从日常运维来看,业务无需关注机器故障、资源不足、机房容灾等传统模式该考虑的问题,另外,当业务进程异常时,Nest能够自动完成异常实例的隔离,迅速拉起新实例实现替换,降低业务影响。

6 未来规划

  • 场景化解决方案:接入Serverless的场景众多,如SSR、后台管理端、BFF等,不同的场景有不同的项目模板、场景配置,如伸缩配置、触发器配置等,另外,不同的语言,配置也有所不同。这无形中增加了业务的使用成本,给新业务的接入带来了阻碍。因此,我们考虑场景化的思路来建设平台,将平台的能力与场景强关联起来,平台深度沉淀各场景的基本配置和资源,这样不同的场景,业务只需要简单的配置就可以将Serverless玩转起来。
  • 传统微服务Serverless化:传统微服务的Serverless化即是路线选型中提到的面向应用的Serverless服务。在美团使用最广的开发语言是Java,公司内部存在大量的传统的微服务项目,这些项目如果都迁移到函数模式,显然是不现实的。试想如果这些传统的微服务项目不用改造,也能直接享受Serverless的技术红利,其业务价值不言而喻。因此,传统微服务的Serverless化是我们未来拓展业务的一个重要方向。在实施路径上,我们会考虑将服务治理体系(如ServiceMesh)与Serverless做技术融合,服务治理组件为Serverless提供伸缩指标支持并在伸缩过程中实现精准的流量调配。
  • 冷启动优化:当前虽然函数的冷启动优化已经取得了较好的成绩,尤其是平台侧的系统启动耗时,提升空间已经非常有限,但业务代码自身的启动耗时还是非常突出,尤其是传统Java微服务,基本是分钟级别的启动耗时。因此,后续我们的冷启动优化会重点关注业务自身的启动耗时,争取极大降低业务自身的启动时间。在具体优化方法上,我们会考虑采用AppCDS、GraalVM等技术,降低业务自身启动耗时。
  • 其他规划
  • 丰富完善研发工具,提升研发效率,如IDE插件等。
  • 打通上下游技术生态,深度融入公司现有技术体系,减少因上下游平台带来使用障碍。
  • 容器轻量化,轻量化的容器能够带来更优的启动耗时以及更佳的资源利用率,因此,容器轻量化一直是Serverless的不懈追求。在具体落地上,准备联合容器设施团队一起推进容器中的一些Agent采用DaemonSet方式部署,下沉到宿主机,提升容器的有效载荷。

作者简介

  • 殷琦、华珅、飞飞、志洋、奕锟等,来自基础架构部应用中间件团队。
  • 佳文、凯鑫,亚辉等,来自金融技术平台大前端团队。

招聘信息

美团基础架构团队诚招高级、资深技术专家,Base北京、上海。我们致力于建设美团全公司统一的高并发高性能分布式基础架构平台,涵盖数据库、分布式监控、服务治理、高性能通信、消息中间件、基础存储、容器化、集群调度等基础架构主要的技术领域。欢迎有兴趣的同学投送简历到:tech@meituan.com。

阅读美团技术团队更多技术文章合集

前端 | 算法 | 后端 | 数据 | 安全 | 运维 | iOS | Android | 测试

| 在公众号菜单栏对话框回复【2020年货】、【2019年货】、【2018年货】、【2017年货】等关键词,可查看美团技术团队历年技术文章合集。

| 本文系美团技术团队出品,著作权归属美团。欢迎出于分享和交流等非商业目的转载或使用本文内容,敬请注明“内容转载自美团技术团队”。本文未经许可,不得进行商业性转载或者使用。任何商用行为,请发送邮件至tech@meituan.com申请授权。

本文转载自: 掘金

开发者博客 – 和开发相关的 这里全都有

图解MVCC多版本并发控制 前言 1 理解mvcc前奏,u

发表于 2021-04-24

前言

现在已经知道了sql四种隔离级别分别为 RU、RC、RR和串行化。

而我们熟悉的MySQL的默认隔离级别是第三种 RR(可重复读)。相对于SQL标准的RR,MySQL的RR是基于MVCC机制实现的,在此隔离级别下,是可以防止脏写、脏读、不可重复读和幻读。

那MVCC是什么样的一种机制呢?

其实MVCC是 multi-version concurrent control(多版本并发控制)的缩写,是基于undo log 多版本链条 + ReadView机制来实现。

下面说说MVCC机制的实现和基于MVCC实现的RC和RR。

  1. 理解mvcc前奏,undo log 版本链是什么?

关于undo log我们之前也讲过。每条数据都有两个隐藏字段,分别是trx_id和roll_pointer。trx_id就是最近更新这条数据的事务id,roll_pointer就是指向更新这个事务之前生成的undo log。

假设事务A(id=50)插入了一条数据,插入的值为A,那么,对应的trx_id为50,roll_pointer指向一个空的undo log,因为之前是没有这条数据。

图 1-1

接着事务B过来更新了一下这条数据,把值改成了B值,事务B的id为60,rooll_pointer指向这个实际的undo log回滚日志:

图 1-2

这条undo log就记录了事务B的事务id,修改后的值B以及roll_pointer指向的是它修改前的undo log

接着事务C又来修改一下这个值为值C,所对应的事务id为70,如图所示:

图 1-3

这里可以看到roll_pointer指向了本次修改之前的undo log,并且和事务A的undo log也串联了起来。

所以这里可以清楚的了解到,每次修改数据都会更新trx_id和roll_pointer这两个字段,同时之前的多个数据快照对应的undo log,会通过roll_pointer指针串联起来,形成的一个版本链。

  1. 基于 undo log 多版本链条实现的ReadView是什么样的?

当执行一个事务时,就生成一个ReadView,里面包含几个部分:

  • m_ids: 此时有哪些事务在MySQL中执行还没提交的
  • min_trx_id:m_ids里面最小的值
  • max_trx_id: MySQL下一个要生成的事务id,就是最大事务id
  • creator_trx_id:当前事务的id

下面通过一个例子理解ReadView的用处。

假设数据库里已经存在了一条数据,由之前的事务插入的,其事务id为32,初始值为原始值。

图 2-1

接下来,有两个事务 A 和 B 并发过来执行。事务A的事务id为45,事务B的事务id为59。事务B要更新这行数据,事务A要查询这行数据。

图 2-2

现在事务A开启了一个ReadView,在这个ReadView里面,m_ids包含了事务A和事务B的两个id,45和59,然后min_trx_id是45,max_trx_id是60,creator_trx_id是当前开启事务的id 45,就是事务A。

图 2-3

现在事务A进行第一次查询,首先会走一个判断,判断一下这行数据的trx_id是否小于ReadView的min_trx_id,此时,发现trx_id是32,是小于ReadView里的min_trx_id(45),说明事务A开始之前,修改这行数据的事务早就提交了,所以是可以查到这行数据的。

图 2-4

接着事务B过来修改这行数据,把原始值改成了值B,然后这行数据的trx_id就变成了59,同时roll_pointer指向修改之前生成的undo log 。

图 2-5

这个时候事务A再次查询,会发现这行数据的trx_id是59,是大于ReadView里的min_trx_id(45),同时小于max_trx_id(60)的。说明更新这条数据的事务有可能存在ReadView的m_ids中,然后判断m_ids里面是否存在trx_id=59的事务,刚好m_ids里面是存在59这个事务id,证实是跟自己同一时段并发执行的事务,该数据不能查询。

图 2-6

既然这行数据不能查询,应该返回什么数据?

这时就会顺着这条数据的roll_pointer的undo log日志链条往下找,就会找到最近的一条trx_id=32的undo log。说明这个undo log版本必然是在事务A开启之前就执行提交的。

看到这里,就体会到了undo log版本链条的作用了,通过保存快照链条,让你快速读到之前的快照值。

通过以上 undo log版本链条 + ReadView 就可以保证了事务A不会读到并发执行的事务B更新的值。

接着看,假设事务A自己更新了这行数据,改成值A,trx_id更新为45,同时保存之前事务B修改的值得快照

图 2-7

此时事务A再来查询这行数据,发现trx_id=45,与ReadView里的creator_trx_id(45)是一致的,说明这是自己修改的这行数据,当然可以被查询到。

图 2-8

接着在事务A执行事务期间,突然开启了一个事务C,事务id为78,然后更新了这行数据为值C,并提交了。

图 2-9

这个时候事务A再去查询这行数,发现trx_id=78,大于ReadView中的max_trx_id,说明事务A执行期间,有另外一个事务更新了数据,所以并不能查询到。

图 2-10

然后顺着undo log 版本链条往下找,查询到自己修改过的值。

通过undo log版本链条 + ReadView 这套机制,我们知道了在事务开启之后,只可以读到事务开始之前或者此事务自己修改的数据。这样,就可以实现多个事务并发执行时候的数据隔离。

  1. 基于ReadView机制实现的RC隔离级别

已提交读隔离级别,说明在事务运行期间,其他事务执行并且提交了,你就可以都到别的事务更新的数据,所以会出现不可重复读和幻读的问题。

RC隔离级别的核心就是,每次发起一次查询,都重新生成一个ReadView。

假设现在有两个事务对同意一行数据并发执行,分别是事务A和事务B,事务A是查询数据,事务id是50;事务B是更新数据,事务id是70。

现在事务A发起查询,开启一个ReadView。因为事务B是并发执行的,所以ReadView中的结构为:

图 3-1

所以,此时无论事务B如何修改数据并提交事务,事务A都无法读取到事务B修改的值。原因也很简单,事务B的事务id存在ReadView的m_ids的活跃事务列表中。

那如何才能让事务A可以读到事务B更新并提交事务的值呢?

那就是每次查询,都重新开启一个ReadView。

现在假设事务B已经把该行的数据改成了值B,并且提交了。事务A再次进行查询,重启生成一个ReadView。此次生成的ReadView中,数据库内活跃的事务只有事务A了。

图 3-2

此时事务A再次基于ReadView判断,发现这条数据的trx_id=70,虽然在min_trx_id与max_trx_id范围之间,但是并不在m_ids列表内。说明事务B在生成本次ReadView之前已经提交。

因为是在生成ReadView之前就提交的事务,说明事务A就可以查询到事务B修改过得值。从而实现已提交读。

图 3-3

所以,已提交读隔离级别关键的地方在于,每次查询都生成新的ReadView。

  1. 基于ReadView机制实现的RR隔离级别

接下来将要讲的是MySQL中的默认隔离级别可重复读,是如何同时避免不可重复读和幻读。

可重复读隔离级别,顾名思义,就是一个事务读同一条数据,无论读多少次,都是同一个值。别的事务就算修改数据提交后,也无法读到它的值。同时,如果别的事务插入一些新的数据,也是无法读到,就可以避免不可重复读和幻读了。

假设数据库已经存在一条数据,此时事务A和事务B同时执行,事务A的id是60,事务B的id是70

图 4-1

此时事务A发起了一个查询,且第一次查询就会生成一个ReadView

图 4-2

事务A基于ReadView去查这条数据,发现trx_id=50是小于min_trx_id的,说明是执行事务A之前就已经提交了的事务插入的,所以可以查到这条数据。

图 4-3

此时事务B过来更新这行数据,把该值改成了值B,同时生成一个undo log,且事务B提交了。

图 4-4

但这个时候,就算事务B已经提交了,在ReadView里面的m_ids中也是存在事务B的事务id,m_ids记录的是执行事务A的时候其他也正在执行的事务的id,并不是说提交了就不存在于m_ids中,除非跟RC隔离级别一样,再生成一个新的ReadView。

所以事务A再次去查询这行数据时,因为m_ids列表中有事务B的事务id,说明事务B也是数据库的活跃事务,就算事务B提交了并不会读取到值B,而实顺着undo log版本链条找到相应的值。

图 4-5

看到这里,就可以清晰的了解到,是如何通过ReadView去避免不可重复读的问题。

那如果是插入数据可能导致的幻读呢?

假设事务A先用”select * from table where id > 10“来查询,此时可能查到的数据只有原始值这条数据。

图 4-6

现在有一个事务C插入了一条数据,然后提交了。

图 4-7

接着事务A再次查询,会发现符合条件的数据有两天,一条是原始值,一条是值C。

但根据ReadView进行判断发现,值C的trx_id=80,比max_trx_id(71)要大。说明是自己发起查询之后,这个事务才开启的,所以此时这条数据是不能查询的。

图 4-8

所以本次查询,事务A还是只能查询到一条数据。

这样看来,在依托ReadView这个机制下,事务A也就不会产生幻读的情况。

看到这里相信大家也就明白基于ReadView机制,RR隔离级别是如何避免不可重复读和幻读的了。

总结

通过一系列篇章和底层原则的分析,大家都明白数据库的脏写、脏读、不可重复读和幻读问题怎样产生的。

而MySQL又是如何基于undo log多版本链条 + ReadView机制 这套机制,实现的RR隔离级别来避免脏写、脏读、不可重复读和幻读问题。

本文转载自: 掘金

开发者博客 – 和开发相关的 这里全都有

JavaScript预编译原理分析

发表于 2021-04-23

首先JavaScript这个预编译和传统的编译是不一样的(可以把js预编译理解为特殊的编译过程)

  • 我们应该已经知道:JavaScript是解释型语言。(解释型语言,就是编译一行,执行一行)
  • 传统的编译会经历很多步骤,分词、解析、代码生成什么的
  • 下面就给大家分享一下我所理解的JS预编译
  • JavaScript运行三部曲*
    脚本执行js,引擎都做了什么呢?
  1. 语法分析

先全部扫一遍 看有没有语法错误.

  1. 预编译(执行前一刻)

变量 声明提升
函数声明整体提升

  1. 解释执行

解释一行执行一行

函数中:预编译执行四部曲

  1. 创建AO对象 (Activation Object (执行期上下文))
  2. 找形参和变量声明,将变量和形参名作为AO属性名,值为undefined
  3. 将实参值和形参统一
  4. 在函数体里面找函数声明,值赋予函数体

全局中:预编译三部曲
5. 创建GO对象(Global Object window就是全局)
6. 找变量声明,将变量声明作为GO对象的属性名,值赋予undifined
7. 找全局里的函数声明,将函数名作为GO对象的属性名,值赋予函数体

实例分析

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
js复制代码<script>
var a = 1;
console.log(a);
function test(a) {
console.log(a);
var a = 123;
console.log(a);
function a() {}
console.log(a);
var b = function() {}
console.log(b);
function d() {}
}
var c = function (){
console.log("I at C function");
}
console.log(c);
test(2);
</script>

分析过程如下:

  1. 页面产生便创建GO全局对象(Global Object)(也就是window对象);
  2. 第一个脚本文件加载;
  3. 脚本加载完毕后,分析语法是否合法;
  4. 开始预编译
  • 查找变量声明,作为GO属性,值赋予undefined;
  • 查找函数声明,作为GO属性,值赋予函数体;
  • 全局预编译结束后,GO中存储的值*
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
js复制代码    //抽象描述
GO/window = {
a: undefined,
c: undefined,
test: function(a) {
console.log(a);
var a = 123;
console.log(a);
function a() {}
console.log(a);
var b = function() {}
console.log(b);
function d() {}
}
}

解释执行代码(直到执行完test(2)语句)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
js复制代码GO/window = {
a: 1,
c: function (){
console.log("I at C function");
}
test: function(a) {
console.log(a);
var a = 123;
console.log(a);
function a() {}
console.log(a);
var b = function() {}
console.log(b);
function d() {}
}
}

执行函数test()之前,再次发生预编译

根据函数中:预编译执行四部曲可知

预编译第一和第二两小步如下:

1
2
3
4
5
js复制代码//抽象描述
AO = {
a:undefined,
b:undefined,
}

预编译之第3步如下:

1
2
3
4
5
js复制代码 //抽象描述
AO = {
a:123,
b:undefined,
}

预编译之第4步如下:

1
2
3
4
5
6
js复制代码//抽象描述
AO = {
a:function a() {},
b:undefined
d:function d() {}
}

执行test()函数时如下过程变化:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
js复制代码 //抽象描述
AO = {
a:function a() {},
b:undefined
d:function d() {}
}
--->
AO = {
a:123,
b:undefined
d:function d() {}
}
--->
AO = {
a:123,
b:function() {}
d:function d() {}
}

执行结果:

QQ截图20210423225850.png

注意:
预编译阶段发生变量声明和函数声明,没有赋值行为,匿名函数不参与预编译 ;只有在解释执行阶段才会进行变量初始化 。

预编译小节

  1. 预编译两个小规则
1. 函数声明整体提升—(无论函数调用和声明的位置是前是后,系统总会把函数声明移到调用前面)
2. 变量 声明提升—(无论变量调用和声明的位置是前是后,系统总会把声明移到调用前,注意仅仅只是声明,所以值是undefined)
  1. 预编译前奏
1. 即任何变量,如果未经声明就赋值,则此变量就位全局变量所有。(全局域就是Window)
2. 一切声明的全局变量,全是window的属性,如:var a=12;等同于Window.a = 12;
3. 函数预编译发生在函数执行前一刻。

参考链接

参考链接

本文转载自: 掘金

开发者博客 – 和开发相关的 这里全都有

5 分钟的 Java 转 Groovy 教程 1 安装 2

发表于 2021-04-23
  1. 安装

安装 Groovy 本身非常简单。对于 Windows 端的用户,将 SDK Bundle 压缩包解压到磁盘的任意路径下,然后像配置 Java 一样去配置 Groovy 的 GROOVY_PATH 和 PATH 变量,Groovy 就算安装完成了。至于 IDE 的选择,笔者仍然选择使用 IDEA IntelliJ,去 plugins 那里搜一搜 Groovy 的插件,然后安装即可。

重点来了。笔者选择安装的版本是 GDK 3.0.8 ( 官方说这是最新的稳定发行版本),它最高支持到 JDK 1.8 ,在更高的版本运行 Groovy 会报错1。这不是我们因为操作疏忽引发的错误,这主要和 JDK 本身的变动有关系。如果要在高版本的 JDK 下运行 Groovy 脚本,则需要在项目中将缺失的依赖项补充上 ( 假设正在 Maven 项目中使用它 ):

1
2
3
4
5
6
7
8
9
10
11
12
13
xml复制代码<dependencies>
<dependency>
<groupId>javax.xml.bind</groupId>
<artifactId>jaxb-api</artifactId>
<version>2.3.1</version>
</dependency>

<dependency>
<groupId>org.glassfish.jaxb</groupId>
<artifactId>jaxb-runtime</artifactId>
<version>2.3.1</version>
</dependency>
</dependencies>

如果你需要使用 Java 和 Groovy 混合开发一些项目,那么 Maven 应该同时配置一个编译插件以及保证 Groovy 能够正常编译的最小依赖包 ( 下方的 groovy-all )。详情参考这篇知乎链接。

1
2
3
4
5
6
xml复制代码<dependency>
<groupId>org.codehaus.groovy</groupId>
<artifactId>groovy-all</artifactId>
<version>3.0.3</version>
<type>pom</type>
</dependency>
  1. Hello!初来乍到

首先,很难用 “编译型还是解释型” 来区分 Groovy 和 Java,因为两者都需要 javac or groovyc 将源码翻译成二进制码,然后交给 JVM 解释执行。这样看的话,Java 和 Groovy 应该都算 “编译兼解释型” 语言。两者的主要区别是:Java 是典型的静态语言 ( 所有的数据都在编译期间就被确定 ),而 Groovy 可以做到 “动态分发”,同时也支持静态编译。

下面的细节有助于我们快速从 Java 过渡 Groovy。

2.1 Groovy as Script

得益于 Groovy 的简练语法(其实几乎只要是个新颖的编程语言就要比 Java 简洁得多,因此 “简洁” 其实不应该再算是 Groovy 的 Feature)和动态特性,使得 Groovy 可以轻松地和系统进程进行交互:

1
2
3
groovy复制代码// 用 groovy 去执行 "groovy -v".
// Windows 环境下需要带上 cmd /C 前缀。
println("cmd /C groovy -v".execute().text)

execute() 方法可以将这个字符串视作是一个命令交给系统去执行,.text 可以获取该命令在系统下的执行结果。下面演示了在 Linux 和 Windows 系统当中,如何通过 .groovy 脚本实现 “浏览当前目录(即执行者 .groovy 所在的那个目录下)的内容”:

1
2
3
4
5
groovy复制代码// Linux 系统
println("ls -l")

// Windows 系统
println("cmd /C dir")

注意,ls 是 Linux 系统中可以直接运行的程序,但是 dir 在 Widows 系统中仅仅是 cmd 命令行解释器当中定义的一条命令,所以在这里补充了额外的前缀 cmd /C。一段 Groovy 代码还可以即时调用另一个文件存储的 Groovy 代码。比如:

1
2
groovy复制代码evaluate(new File("C:\\Users\\i\\IdeaProjects\\GroovyHello\\src\\HelloWorld.groovy"))
// 在另一个 ./HelloWorld.groovy 脚本中,我们在那个文件里仅使用了一句 print 'hello World! -by groovy' 令其输出一段话。

因此,Groovy 被称之为是 “JVM 上的脚本语言”,这名副其实。

2.2 编写 Groovy 逻辑的两种风格

在 .groovy 文件内,可以不声明任何类而直接在文件顶级层次编写代码逻辑 (笔者刚才就是这样做的)。不过这样的话,就不能在文件的顶级层次再声明一个和文件同名的类,否则编译器会给出 there is a synthetic class generated for script code 的错误。

1
2
3
4
5
6
7
8
groovy复制代码// 假定这段代码出现在 Obj.groovy 源文件中 
class Obj{
//...
}

// 这个方法调用和类声明一样都在文件的 '顶级' 位置。
// We don't need 'System.out.println(...)' anymore.
print('hello groovy')

从编译角度来看这可以理解,因为 .groovy 文件被编译成 .class 文件并执行时,编译器实际上会为其生成一个合成类,而正是这一步导致了冲突发生:我们刚定义的类名和它重复了。

实际上,如果 .groovy 文件内部出现了和文件同名的类,则意味着这个 .groovy 文件会被视作是一段 “用 Groovy 方言编写的 Java 代码”,一般它也就不再作为脚本使用,而是变成一个 “普通的类” ( IDEA 称它是一个 Groovy Class) 。这么做的一个直接后果是,我们不能够在文件的顶级层次直接编写代码逻辑。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
groovy复制代码// 这段代码出现在 Obj.groovy 源文件中。
// 它相当于是 .java 文件中的 public class 定义.
class Obj{
// 就像在写 Java 代码一样!
// 在 Groovy 中,public 关键字其实是多余的,默认情况下所有的声明就是 public。
public static void main(String[] args){
// 在 Groovy 中,字符串还可以使用 "",'',""" """,''' ''',包裹,具体区别见后文。
print('hello groovy')
}
}

// 它相当于是 .java 文件中的非 public class 定义.
class OtherObj{}

// 代码不能再出现在文件的 '顶层' 位置,因为我们在 Java 开发时就不是这样做的。
print ('oops')

2.3 异常处理

Java 总是要我们在第一时间处理受检异常,否则傲娇的 javac 编译器就拒绝执行。比如:

1
2
3
4
5
6
7
8
java复制代码public class Test {
public static void main(String[] args) throws FileNotFoundException {
File file = new File("src/HelloWorld.groovy");
// FileInputStream 抛出受检异常,因此调用它的函数要么继续 throws 到上级 (当然,主程序 throws 异常没什么意义),
// 要么就通过 try-catch 内部解决掉它。
FileInputStream fileInputStream = new FileInputStream(file);
}
}

而 Groovy 对异常处理的写法更为宽松:如果没有在该代码块内通过 try-catch 处理异常,那么该异常就会自动地向上级抛出,且无需在函数声明中使用 throws 主动定义它们。下面是 Groovy 代码:

1
2
3
groovy复制代码// 即使没有声明 throws,也没有定义 try-catch, groovyc 仍然会正常执行。
File file = new File('HelloWorld.groovy')
FileInputStream fileInputStream = new FileInputStream(file)

2.4 简洁的 “非空则调用” 语法

为了避免调用某个空指针的方法,在 Java 代码中,我们通常要包裹一层 if 语句块:

1
2
groovy复制代码String maybeNull = "I'm Java";
if(maybeNull != null){System.out.println(nullString.length());}

这一长串逻辑在 Groovy 当中可以直接使用一个 ?. 操作符解决:

1
2
groovy复制代码String maybeNull = 'I\'m groovy'
print(maybeNull?.length())

2.5 GString

在 Groovy 中,短字符串可以使用 '' 或者 "" 表示,而需要跨行的长字符串则通常使用 ''' ''' 或者 """ """ 。被双引号包括的字符串又被称为 GString,和原生的 String 相比,它支持在字符串内部使用 ${} 做占位符 ( 类似 printf ),避免了手工的 String 字符串拼接。

1
2
3
4
5
groovy复制代码name = 'Wangfang'
age = '18'

// 占位符拼接的用法
print("my name is ${name},my age is ${age}.")

2.6 精简的 JavaBean

在 Groovy 当中,编译器总是自动在底层为属性生成对应的 Set 和 Get 方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
groovy复制代码class Student_ {
String name
Integer age

Student_(String name,Integer age){
this.name = name
this.age = age
}
}

Student_ s = new Student_()
// 这个 getXXX 方法是由编译器生成的。
s.getName()

如果希望某个属性在对象被构造之后就不可变,则需使用 final 关键字,编译器将不会主动地为其生成 Set 方法 ( 意味着该属性是只读的 ) 。另外,属性可以不主动声明类型,此时原本的类型被 def 关键字替代。

1
2
3
4
5
6
7
8
groovy复制代码class Student_{
final name
def age
Student_(name,age){
this.name = name
this.age = age
}
}

对于未主动声明类型的属性,其本质上属于 Object 对象,这不利于对该属性的后续操作。想要解决这个问题,不妨在构造器中留下一些线索,以便于编译器能够 “推导” 出目标类型 ( Groovy 总是通过变量的赋值来推断这个变量的实际类型 )。

1
2
3
4
5
6
7
8
9
10
11
12
groovy复制代码class Student_{    
final name
def age

// 使得 name 和 age 属性的实际类型可以被推导
Student_(String name, Integer age){
this.name = name
this.age = age
}
}

s = new Student_('Wang Fang',23)

如果一个属性被声明为了 private,则编译器不会再自动地为该属性声明 Get 和 Set 方法。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
groovy复制代码class Student_{    
final name
private age

// 使得 name 和 age 属性的实际类型可以被推导
Student_(String name, Integer age){
this.name = name
this.age = age
}
}

s = new Student('Wang Fang',23)

//报错
s.getName()

2.7 或许无需手动创建构造器

对于上述的 Student_ 类而言,它可能需要有 4 个构造器:无参构造器,仅附带 name 属性的构造器,仅附带 age 属性的构造器,完整的构造器。Groovy 可以让我们仅通过一个 Map 实现灵活的对象创建,并且不需要再手动地补充构造器写法:

1
2
3
4
5
6
7
8
9
10
groovy复制代码class Student_{
String name
Integer age
}

// 没有实现 Student_(name,age) 构造器,但是可以直接使用
stu1 = new Student_(name: "Wang Fang",age: 12)

// 同样,我们也没有手动实现 Student_(name) 构造器。
stu2 = new Student_(name:"Wang Fang")

在些参数列表中,我们传入的其实是一整个 Map。里面的每一个 k:v 都表示了一个键值对。k 对应了这个类当中每个属性名,而 v 则为这些属性赋值。但是,我们不能这样做:

1
2
groovy复制代码stu1 = new Student_("Wang Fang",12)
stu2 = new Student_("Wang Fang")

除非手动地补充上对应的构造函数。

2.8 方法中的可选形参

Java 不支持可选形参,在调用方法时,每个参数必须严格赋值。而 Groovy 则有所不同:在方法 ( 或函数 ) 参数列表内,可以提前为最后一个参数设定默认值,那么在调用该方法时,最后一个参数可以被省略。

1
2
3
4
5
6
groovy复制代码def add(Integer arg,Integer implicit = 10){arg + implicit}

// 11
print(add(1))
// 3
print(add(1,2))

这个例子还展现了其它细节:至少,Groovy 的方法 ( 函数 ) 不要求显示地添加 return 关键字,它总是默认返回函数体内最后一个调用的结果值。然后,这个函数的返回值类型是显然可以推断的,因此这里也可使用 def 关键字替换掉函数的返回值声明。

2.9 多重赋值

如果方法 ( 函数 ) 返回的是一个数组,那么 Groovy 支持使用多个变量接收数组内的元素内容。比如:

1
2
3
4
5
6
7
8
9
groovy复制代码def swap(x, y) { return [y, x] }

Integer a, b
a = 10
b = 50

// 通过多重赋值实现了两数交换
(a, b) = swap(a, b)
print("a=$a,b=$b")

利用这个特点,Groovy 的方法 (函数) 可以返回多个值。其它支持这么做的编程语言还有 Go,Scala ( 通过包装成元组来实现 ) 等。当接收变量的个数和实际的返回值个数不匹配时,Groovy 会这样做:

  1. 如果接收的变量更多,那么会将没有赋值的变量赋为 null 。
  2. 如果返回值更多,那么多余的返回值会被丢弃。

当然,Groovy 也的确提供了元组,这个写法对于一些 Scala 程序员绝对不陌生:

1
2
3
4
5
6
7
8
9
groovy复制代码Tuple2<Integer,Integer> swap(Integer a,Integer b){
return new Tuple2<Integer,Integer>(b,a)
}

a = 10
b = 20

(a, b) = swap(a, b)
print("a=${a},b=${b}")

2.10 接口实现

假定有这样一个单方法接口:

1
2
3
groovy复制代码interface Calculator<T>{
T add(T a,T b)
}

Java 可能要这样实现:

1
2
3
4
5
6
java复制代码Calculator<Integer> calculator = new Calculator<Integer>() {
@Override
public Integer add(Integer a, Integer b) {
return a + b;
}
};

在 Java 8 之后,匿名实现的写法终于变得更简练了亿些,但遗憾的是,Lambda 表达式只能用于单方法接口。

1
css复制代码Calculator<Integer> calculator = (a, b) -> a + b;

Groovy 给出了与众不同的解决思路:首先给出 Lambda 表达式的语法块,这个语法块被 {} 包裹,在 Groovy 中它被称之为闭包;然后通过 as 关键字将这个闭包声明为是对某一接口的实现2。

1
2
groovy复制代码// 多亏类型推导的存在,我们不需要把 Calculator<Integer> 重新抄写一遍 .....
def a = {a,b ->return a+b} as Calculator<Integer>

如果要实现多方法接口,那么就将多个闭包装入到一个 Map 当中,使用 k 来标注每个闭包实现的是哪个方法:

1
2
3
4
5
6
7
8
9
10
11
12
groovy复制代码interface Calculator<T> {
T add(T a, T b)
T sub(T a, T b)
}

def cal = [
add: { a, b -> a + b },
sub: { a, b -> a - b }
] as Calculator<Integer>

def c = cal.sub(1,2)
print(c)

Groovy 从未强制实现一个接口的所有方法:如果某些方法确实用不到,那就没有必要将对应的闭包实现放入 Map 中。值得注意的是,如果调用了没有实现的接口方法,那么程序就会抛出亲切的 NullPointerException 异常。

2.11 布尔求值

在 If 语句的条件部分,Java 强制要求传入一个计算好的布尔值,否则就报错。

1
2
3
java复制代码int a = 10
// :) => if(a != 0)...
// :( => if(a)...

Groovy 的处理则更加优雅一些,当传入的值不是纯粹的布尔值时,Groovy 会基于传入的类型进行一些合理的推断,而不是直接报错,参见下方的表格:

类型 何时为真
Boolean 值是 true
Collection 集合本身不是 null,且内部有元素
Character 值不为 0
CharSequence 长度大于 0
Enumeration Has More Enumerations 为 True
Iterator hasNext() 为 True
Number Double 值不为 0
Map 映射本身不是 null,且映射内部不为空
Matcher 至少有一个匹配
Object[] 长度大于 0
其它类型 引用不为 null

在大部分情况下,直接向 if 条件部分传入一个值都是为了判断它是否为空。如果要基于该值是否为空来决定是否执行一系列动作,可以考虑使用前文提到的 ?. 操作符简化代码。

2.12 运算符重载

Groovy 预留了一些方法名称,这些方法意味着对操作符进行重载3:

Operator Method
a + b a.plus(b)
a – b a.minus(b)
a * b a.multiply(b)
a ** b a.power(b)
a / b a.div(b)
a % b a.mod(b)
a b
a & b a.and(b)
a ^ b a.xor(b)
a++ or ++a a.next()
a– or –a a.previous()
a[b] a.getAt(b)
a[b] = c a.putAt(b, c)
a << b a.leftShift(b)
a >> b a.rightShift(b)
switch(a) { case(b) : } b.isCase(a)
~a a.bitwiseNegate()
-a a.negative()
+a a.positive()

而这些操作符在遇到 null 时不会抛出空指针异常:

Operator Method
a == b a.equals(b) or a.compareTo(b) == 0 **
a != b ! a.equals(b)
a <=> b a.compareTo(b)
a > b a.compareTo(b) > 0
a >= b a.compareTo(b) >= 0
a < b a.compareTo(b) < 0
a <= b a.compareTo(b) <= 0

举个例子:在程序中定义复数类,然后定义两个复制之和是实部和虚部的分别加和:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
groovy复制代码class ComplexNumber {
Integer real
Integer imaginary

//plus 方法对应 + 操作符
def plus(ComplexNumber other) {
new ComplexNumber(real: this.real + other.real,imaginary: this.imaginary+other.imaginary)
}

@Override
String toString() { "${real} + ${imaginary}i"}
}

// 注意,这个写法相当于 println(...)
// 内部相当于是调用了 new ComplexNumber(balabala).plus(new ComplexNumber(balabala))
println new ComplexNumber(real:3,imaginary: 2) + new ComplexNumber(real:3,imaginary:1)

但相比 Scala 而言,笔者认为这种方式有点奇怪 …… 因为当我们需要这么做时,总是得翻阅一下上面的表格,然后去比对哪个操作符对应哪个方法,除非把这张表格背下来 ( 可以,但没必要 )。不过不管怎么样,有总比没有强。

2.13 for 循环

下面是一段 Java 代码演示的 for 循环,i 从 0 开始,直到 3 ( 不包括 3) 为止:

1
java复制代码for (int i = 0; i<3 ; i++){System.out.println("java loop")}

在 Groovy 中, 0 ~ 3 的左闭右开区间可以使用 0..2 来表示:

1
groovy复制代码for (i in 0..2){println "groovy loop"}

in 通常用于遍历 “模糊类型” 的数组。如果遍历的是确定类型的数组,还可以这样写:

1
2
3
4
5
6
7
8
9
10
11
12
groovy复制代码String[] strings = ['java','scala','groovy','go']

// s 必须指明是 String 类型。
// : 替换了之前的 in 关键字。
for(String s : strings){
print(s.length())
}

// 等价写法
for (s in strings){
print s.length()
}

.. 可以被视作是一个特殊的二元符号,在循环语句之外也可以单独使用它来创建一个步长为 1 的序列。

1
2
3
4
5
6
groovy复制代码// 你可以将 .. 视作是 Integer 的一个双目运算符号,其中 n..m 会返回 [n,n+1,n+2,...m] 的序列。
// seq 是 IntRange 类型。
def seq = 0..10

// 11
print seq.size()

2.14 关于导入

Groovy 通常的导入方式和 Java 如出一辙,并且不强制所有的 import 出现在文件的最上方。

1
2
groovy复制代码import java.lang.Math
print Math.random()

除此之外,Groovy 支持 import static 导入某一个类的静态方法,这样我们可以在当前命名空间当中将该静态方法直接作为一个函数来调用。如果担心命名重复,可以使用 as 关键字将该静态方法重新命名。

1
2
3
4
groovy复制代码// 静态导入 Math 类的静态 random 方法
// as 关键字可以顺便将导入的静态方法起一个别名,通常用于简化或者避免命名冲突的目的。
import static math.random as rdm
print rdm()

2.15 一切即闭包

Groovy 特地将 [] 留给了数组的声明:

1
groovy复制代码String[] str = ['java','groovy']

而一切 {} 代码块在 Groovy 会被视作一个闭包,闭包对于 Groovy 来说,是一个具体的 Closure<T> 类型4。( 有关 Groovy 闭包的内容笔者后续会单独说明 ) 在 Java 中,我们可以使用 {} 表示一段有独立作用域的子代码块:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
java复制代码{
System.out.println("block1");
}

{
System.out.println("block2");
}

// ()->void 函数也可以被理解成是子代码块。
Runnable runnable = () -> {
System.out.println("block3");
};

runnable.run();

但在 Groovy 当中,一段 {} 扩起来的闭包不能单独声明出现,除非是写成赋值的形式:

1
2
3
4
5
groovy复制代码// 不能通过编译
{print "hello"}

// 编译通过,这种赋值明确地表示 {} 是一个闭包。
def c = {print "hello"}

2.15.1 避免闭包和匿名类的冲突

如果一个函数 / 方法接收闭包作为参数,那么从语法上可以将这些闭包附着在函数调用的尾部。形象点说,一个 method({...},{...}) 语句块可以改写成 method() {...}{...} 的形式 ( 这么做有利于设计内部 DSL 语法,想想我们为什么能够在 Groovy 写出诸如 print "hello" 的句式?) :

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
groovy复制代码def aspect(before, after) {
before()
print("method")
after()
}

// 正常的调用方式
aspect({print "before doing..."},{print "after doing..."})


// 将闭包迁移到调用尾部,aspect() 的小括号 () 可写可不写。
aspect() {
print("before doing...")
} {
print("after doing...")
}

有时,一个类的构造函数也会需要接收一个闭包,那么在这种场合可能会引发歧义:

1
2
3
4
5
6
groovy复制代码class Aspect{
// Closure 是 Groovy 中表示闭包的类型。它实际上还有一个泛型,该泛型指代的是该闭包的返回值。
Aspect(Closure before){
before()
}
}

按照开头的调用风格,它可以被写成这样:

1
2
3
4
groovy复制代码// 这个写法的原意是将闭包写到构造函数的后面。
def a = new Aspect(){
print "create a aspect..."
}

但对于一个 Java 程序员而言,这种写法看起来却像是在创建一个匿名对象 —— 甚至 Groovy 也会不知所措。在这种情况下,必须严格使用 () 的语法避免歧义发生。

1
groovy复制代码def a = new Aspect({print "create a aspect..."})

2.15.2 避免闭包和实例初始化器的冲突

在某些类的定义中,我们需要使用一段 {} 扩起来的代码块作为实例初始化器:

1
2
3
4
5
6
7
8
9
groovy复制代码class Apple{

String from = "China"

// 我们不认为它是闭包,而是实例初始化器
{
print("这段代码先于 Apple 的构造函数去执行")
}
}

然而 Groovy 却会把字符串 "China" 和它认为的 “闭包” {...} 视作是一个整体,而导致运行时出错。解决办法有两种:要么将实例初始化器移动到内部声明的最上方,要么就显示地使用 ; 分号将两者分隔开:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
groovy复制代码// 解决方法1,推荐
class Apple{

{
print("")
}

String from = "China"
}

// 解决方法 2,不太推荐,因为这种写法容易让人混淆实例初始化器和普通闭包的声明。
class Apple{

String from = "China";

{
print("")
}
}

2.16 强力注解

这里或许有一些官方提供的注解帮助快速开发,它们绝大部分都是来自于 groovy.lang 包,这意味着不需要通过 import 关键字额外地导入外部依赖:

2.16.1 @Canonical 替代 toString

假如希望打印一个类信息,又不想自己生成 toString() 方法,则可以使用 @Canonical 注解。该注解有额外的 excludes 选项:允许我们忽略一些属性。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
groovy复制代码@Canonical
// 如果不想打印 id 和 score,可以:
// @Canonical(excludes="id,score")
class Student {
Integer id
String name
Integer age
String major
Integer score
}

// 如果没有此注解,打印的则是 Student@Hashcode
// 如果有注解,打印的则是 Student(1,"Wang Fang",20,"CS","score")
print new Student(id: 1,name:"Wang Fang",age: 20,major: "CS",score: 90.0d)

2.16.2 @Delegate 实现委托

使用 @Delegate 注解,在 Groovy 中实现方法委托非常容易。委托是继承以外的另一种代码复用的思路。在下面的代码块中,Manager 通过注解将 work() 方法委托给了内部的 worker 属性:

1
2
3
4
5
6
7
8
9
10
11
12
13
groovy复制代码class Worker{
void work(){
print("worker is working exactly.")
}
}

// Manager 获得了 Worker 的公开方法,尽管 worker 属性本身是 private.
class Manager{
@Delegate private Worker worker = new Worker()
}

// 检查 Manager 实例有没有 work 方法,没有就去委托 worker 执行此方法。
new Manager().work()

2.16.3 @Immutable 不可变对象*

不可变的对象天生就是线程安全的。想要创建一个不可变对象,需要限制它的类属性全部是 final ,一旦属性被初始化之后就不可以再被改变。@Immutable 注解可以提供一个便捷的解决方案:

1
2
3
4
5
6
7
8
9
groovy复制代码@Immutable
class Student_{
String id
String name
}

def s = new Student_(id:"0001",name:"Wang Fang")

print s

和其它注解不同,它来自 groovy.transform 包。笔者在使用该注解的时候曾遇到一些奇怪的问题,IDEA 似乎不能很好的识别该注解,并进一步引发代码无法粘贴,错误地弹出警告,代码提示消失等 Bug。

2.16.4 @Lazy 延迟加载类成员

懒加载是大部分新兴语言都支持的特性。在 Groovy 中,它通过注解来实现,注意,该注解只能用于类成员。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
groovy复制代码class Connection{

// 加载 Connection 需要 1 秒的时间
Connection(){
Thread.sleep(1000)
print "Connection 实例初始化完毕"
}
}

class Pool{

// 由于代码没有调用 conn ,因此实际上 new Connection() 并没有真正执行
@Lazy def conn = new Connection()
Pool(){
print "Pool 实例初始化完毕"
}
}

def pool = new Pool();

对于懒加载的成员只有在第一次被调用时才会被初始化,并且 Groovy 内部通过 voaltitle 关键字保证这个创建的过程是线程安全的。

2.16.5 @Newify 注解

该注解的功能有点类似于 Scala 语言当中的 apply 方法,允许我们在创建新对象的时候忽略掉 new 关键字 ( 这个特性也有助于设计 DSL )。该注解可用在类声明和方法声明,也可以用在单独的变量赋值语句上:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
groovy复制代码class Student{
String id
String name
}

class Teacher{
String id
String name
}

@Newify(Student)
def getStudent(){
// 在函数内部创建 Student 时,可以省略掉 new 关键字。
Student(id:"0001",name: "Wang Fang")
}

// 多个类型使用数组的形式排列。
@Newify([Student,Teacher])
static def getStudentAndTeacher(){
[Student(id:"0001",name:"Wang Fang"),Teacher(id: "0002",name:"Cheng Yu")]
}

2.16.6 @Singleton 单例模式

在 Groovy 中,仅凭 @Singleton 注解就可以实现一个线程安全,并且简洁的单例模式。

1
2
3
4
5
6
7
8
9
10
groovy复制代码// 懒加载的单例模式,lazy 项是可选的。
@Singleton(lazy = true)
class TheUnique{
{
println "created only once"
}
}

// 通过 .instance 调用这个单例对象。
TheUnique.instance

单例模式可以选择懒汉式加载,仅需在注解的 lazy 选项中设置为 true 即可。

2.17 注意 Groovy 的 == 符号

在 Java 中,== 可以比较两个基本数据类型的值,或者比较两个引用类型的 HashCode。而 .equals() 方法如何比较则取决于开发者制定的规则:在什么都不做的情况下,.equals 方法和 == 等价。

对于一些常用类型,Java 已经制定好了 .equals() 方法的比较规则。就 String 而言,它的 .equals() 实现首先就是通过 == 符号判断两个字符串的引用是否相同,然后判断两个字符串的长度是否相同,最后再按位判断每个位置的字符是否相同。

而在 Groovy 当中,这两者的混乱程度有所加剧:Groovy 的 == 相当于是 Java 的 .equals() 方法或者是 compareTo() 方法 (见运算符重载的那个表格),而 Java 原始的 == 语义在 Groovy 中变成了 is() 方法。

1
2
3
4
5
6
7
8
groovy复制代码str1 = "111"
str2 = "222"

// 相当于是 Java 语义中的 str1 == str2
str1.is(str2)

// 相当于是 Java 语义中的 str1.equals(str2)
str1 == str2

如果比较的类实现了 Compareble 接口,那么 == 的语义优先会选择 compareTo() 方法而非 equals() 方法。

  1. 参考链接

Footnotes

  1. Groovy 在更高 JDK 版本使用的方法 ↩
  2. Groovy ‘as’ 用于实现2+接口的关键字 ↩
  3. Groovy:运算符重载 ↩
  4. 想要提前了解闭包,可以参考这篇文章:Groovy 闭包 - 简书 (jianshu.com) ↩

本文转载自: 掘金

开发者博客 – 和开发相关的 这里全都有

史上最详Android版kotlin协程入门进阶实战(三)

发表于 2021-04-23

banners_twitter.png
由于文章涉及到的只是点比较多、内容可能过长,可以根据自己的能力水平和熟悉程度分阶段跳着看。如有讲述的不正确的地方劳烦各位私信给笔者,万分感谢image.png

由于时间原因,笔者白天工作只有晚上空闲时间才能写作,所以更新频率应该在一周一篇,当然我也会尽量的利用时间,争取能够提前发布。为了方便阅读将本文章拆分个多个章节,根据自己需要选择对应的章节,现在也只是目前笔者心里的一个大概目录,最终以更新为准:

Kotlin协程基础及原理系列

  • 史上最详Android版kotlin协程入门进阶实战(一) -> kotlin协程的基础用法
  • 史上最详Android版kotlin协程入门进阶实战(二) -> kotlin协程的关键知识点初步讲解
  • 史上最详Android版kotlin协程入门进阶实战(三) -> kotlin协程的异常处理
  • 史上最详Android版kotlin协程入门进阶实战(四) -> 使用kotlin协程开发Android的应用
  • 史上最详Android版kotlin协程入门进阶实战(五) -> kotlin协程的网络请求封装

Flow系列

  • Kotlin协程之Flow使用(一)

扩展系列

  • 封装DataBinding让你少写万行代码
  • ViewModel的日常使用封装

kotlin协程的异常处理

在上一篇中我们提到这节将会讲解协程的异常处理,但是笔者在写这篇文章的时候遇到了些问题,主要是讲解的深度怎么去把控,因为要处理异常,首先得知道异常是如何产生,那么必然就涉及到协程创建->启动->执行->调度->恢复->完成(取消)流程。这其中每一步都能罗列出一堆需要讲解东西,所以笔者最终决定,我们在这章节中只查看关键点位置,其中涉及到的一些跳出关键点的位置,我们只做一个基本提点,不做延伸。

当然基于前两篇文章的反馈,有读者提到文章文字和代码信息太多,从头到尾看下来很累,想让笔者中间安排一些骚图缓解下紧张的学习气氛。

image.png

所以笔者在这篇文章中尝试加入一些元素,如果有不合适的地方,麻烦批评指正。

协程异常的产生流程

我们在开发Android应用时,出现未捕获的异常就会导致程序退出。同样的协程出现未捕获异常,也会导致应用退出。我们要处理异常,那就得先看看协程中的异常产生的流程是什么样的,协程中出现未捕获的异常时会出现哪些信息,如下:

1
2
3
4
5
6
7
8
9
10
kotlin复制代码private fun testCoroutineExceptionHandler(){
GlobalScope.launch {
val job = launch {
Log.d("${Thread.currentThread().name}", " 抛出未捕获异常")
throw NullPointerException("异常测试")
}
job.join()
Log.d("${Thread.currentThread().name}", "end")
}
}

我们抛出了一个NullPointerException异常但没有去捕获,所以会导致了应用崩溃退出。

1
2
3
4
5
6
7
8
9
10
11
kotlin复制代码D/DefaultDispatcher-worker-2:  抛出未捕获异常
E/AndroidRuntime: FATAL EXCEPTION: DefaultDispatcher-worker-1
Process: com.carman.kotlin.coroutine, PID: 22734
java.lang.NullPointerException: 异常测试
at com.carman.kotlin.coroutine.MainActivity$testException$1$job$1.invokeSuspend(MainActivity.kt:251)
at kotlin.coroutines.jvm.internal.BaseContinuationImpl.resumeWith(ContinuationImpl.kt:33)
at kotlinx.coroutines.DispatchedTask.run(DispatchedTask.kt:106)
at kotlinx.coroutines.scheduling.CoroutineScheduler.runSafely(CoroutineScheduler.kt:571)
at kotlinx.coroutines.scheduling.CoroutineScheduler$Worker.executeTask(CoroutineScheduler.kt:750)
at kotlinx.coroutines.scheduling.CoroutineScheduler$Worker.runWorker(CoroutineScheduler.kt:678)
at kotlinx.coroutines.scheduling.CoroutineScheduler$Worker.run(CoroutineScheduler.kt:665)

我们看到这个异常是在在CoroutineScheduler中产生的,虽然我们不知道CoroutineScheduler是个什么东西。但是我们可以从日志上运行的方法名称先大概的分析一下流程:

它先是创建一个CoroutineScheduler的一个Worker对象,接着运行Worker对象的run方法,然后runWorker方法调用了executeTask,紧接着又在executeTask里面执行了runSafely,再接着通过runSafely运行了DispatchedTask的run方法,最后DispatchedTask.run调用了continuation的resumeWith方法,resumeWith方法中在执行invokeSuspend的时候抛出了异常。

再来个通熟一点的,你们应该就能猜出大概意思来。雇主先是找包工头CoroutineScheduler要了一个工人Worker,然后给这个工人安排了一个搬砖任务DispatchedTask,同时告诉这个工人他要安全runSafely的搬砖,然后雇主就让工人Worker开始工作runWorker,工人Worker就开始执行executeTask雇主吩咐的任务DispatchedTask,最后通过resumeWith来执行invokeSuspend的时候告诉雇主出现了问题(抛出了异常).

image.png

别着急,仔细想一想,有没有发现这个跟ThreadPoolExecutor线程池和Thread线程的运行很像。包工头就像是ThreadPoolExecutor线程池,工人就是Thread线程。

我们通过线程池(CoroutineScheduler)创建了一个Thread线程(Worker),然后开始执行线程(runWorker),线程里面通过executeTask执行一个任务DispatchedTask,在执行任务的时候我们通过try..catch来保证任务安全执行runSafely,然后在DispatchedTask执行任务的时候,因为运行出现异常,所以在catch中通过resumeWith来告知结果线程出问题了。咦,逻辑好像突然变得清晰很多。

image.png

这么看的话,这个协程异常的产生是不是基本原理就出来了。那么我们接下里看看是不是正如我们所想的,我们先找到CoroutineScheduler看看他的实现:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
kotlin复制代码    internal class CoroutineScheduler(...) : Executor, Closeable {
@JvmField
val globalBlockingQueue = GlobalQueue()
fun runSafely(task: Task) {
try {
task.run()
} catch (e: Throwable) {
val thread = Thread.currentThread()
thread.uncaughtExceptionHandler.uncaughtException(thread, e)
} finally {
unTrackTask()
}
}

//省略...
internal inner class Worker private constructor() : Thread() {
override fun run() = runWorker()

private fun runWorker() {
var rescanned = false
while (!isTerminated && state != WorkerState.TERMINATED) {
val task = findTask(mayHaveLocalTasks)
if (task != null) {
rescanned = false
minDelayUntilStealableTaskNs = 0L
executeTask(task)
continue
} else {
mayHaveLocalTasks = false
}
//省略...
continue
}
}

private fun executeTask(task: Task) {
//省略...
runSafely(task)
//省略...
}

fun findTask(scanLocalQueue: Boolean): Task? {
if (tryAcquireCpuPermit()) return findAnyTask(scanLocalQueue)
val task = if (scanLocalQueue) {
localQueue.poll() ?: globalBlockingQueue.removeFirstOrNull()
} else {
globalBlockingQueue.removeFirstOrNull()
}
return task ?: trySteal(blockingOnly = true)
}

//省略...
}
//省略...
}

哎呀呀,不得了,跟我们上面想的一模一样。CoroutineScheduler继承Executor,Worker继承Thread,同时runWorker也是线程的run方法。在runWorker执行了executeTask(task),接着在executeTask调用中runSafely(task),然后我们看到runSafely使用try..catch了这个task任务的执行,最后在catch中抛出了未捕获的异常。那么很明显这个task肯定就是我们的DispatchedTask,那就到这里结束了么

很明显并没有,我们看到catch中抛出的是个线程的uncaughtExceptionHandler,这个我们就很熟了,在Android开发中都是通过这个崩溃信息。但是这个明显不是我们这次的目标。
image.png

继续往下分析,我们看看这个task到底是不是DispatchedTask。回到executeTask(task)的调用出,我们看到这个task是通过findTask获取的,而这个task又是在findTask中通过CoroutineScheduler线程池中的globalBlockingQueue队列中取出的,我们看看这个GlobalQueue:

1
kotlin复制代码internal class GlobalQueue : LockFreeTaskQueue<Task>(singleConsumer = false)
1
kotlin复制代码internal actual typealias SchedulerTask = Task

我可以看到这个队列里面存放的就是Task,又通过kotlin语言中的typealias给Task取了一个SchedulerTask的别名。而DispatchedTask继承自SchedulerTask,那么DispatchedTask的来源就解释清楚了。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
kotlin复制代码internal abstract class DispatchedTask<in T>(
@JvmField public var resumeMode: Int
) : SchedulerTask() {
//省略...
internal open fun getExceptionalResult(state: Any?): Throwable? =
(state as? CompletedExceptionally)?.cause

public final override fun run() {
assert { resumeMode != MODE_UNINITIALIZED }
val taskContext = this.taskContext
var fatalException: Throwable? = null
try {
val delegate = delegate as DispatchedContinuation<T>
val continuation = delegate.continuation
withContinuationContext(continuation, delegate.countOrElement) {
val context = continuation.context
val state = takeState()
val exception = getExceptionalResult(state)
val job = if (exception == null && resumeMode.isCancellableMode) context[Job] else null
if (job != null && !job.isActive) {
val cause = job.getCancellationException()
cancelCompletedResult(state, cause)
continuation.resumeWithStackTrace(cause)
} else {
if (exception != null) {
continuation.resumeWithException(exception)
} else {
continuation.resume(getSuccessfulResult(state))
}
}
}
} catch (e: Throwable) {
fatalException = e
} finally {
val result = runCatching { taskContext.afterTask() }
handleFatalException(fatalException, result.exceptionOrNull())
}
}
}

接着我们继续看DispatchedTask的run方法,前面怎么获取exception 的我们先不管,直接看当exception 不为空时,通过continuation的resumeWithException返回了异常。我们在上面提到过continuation,在挂起函数的挂起以后,会通过Continuation调用resumeWith函数恢复协程的执行,同时返回Result<T>类型的成功或者失败。实际上resumeWithException调用的是resumeWith,只是它是个扩展函数,只是它只能返回Result.failure。同时异常就这么被Continuation无情抛出。

1
2
kotlin复制代码public inline fun <T> Continuation<T>.resumeWithException(exception: Throwable): Unit =
resumeWith(Result.failure(exception))

诶,不对啊,我们在这里还没有执行invokeSuspend啊,你是不是说错了。
image.png

是滴,这里只是一种可能,我们现在回到调用continuation的地方,这里的continuation在前面通过DispatchedContinuation得到的,而实际上DispatchedContinuation是个BaseContinuationImpl对象(这里不扩展它是怎么来的,不然又得从头去找它的来源)。

1
2
kotlin复制代码  val delegate = delegate as DispatchedContinuation<T>
val continuation = delegate.continuation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
kotlin复制代码internal abstract class BaseContinuationImpl(
public val completion: Continuation<Any?>?
) : Continuation<Any?>, CoroutineStackFrame, Serializable {
public final override fun resumeWith(result: Result<Any?>) {
var current = this
var param = result
while (true) {
probeCoroutineResumed(current)
with(current) {
val completion = completion!! // fail fast when trying to resume continuation
val outcome: Result<Any?> =
try {
val outcome = invokeSuspend(param)
if (outcome === COROUTINE_SUSPENDED) return
Result.success(outcome)
} catch (exception: Throwable) {
Result.failure(exception)
}
releaseIntercepted() // this state machine instance is terminating
if (completion is BaseContinuationImpl) {
current = completion
param = outcome
} else {
completion.resumeWith(outcome)
return
}
}
}
}
}

可以看到最终这里面invokeSuspend才是真正调用我们协程的地方。最后也是通过Continuation调用resumeWith函数恢复协程的执行,同时返回Result<T>类型的结果。和我们上面说的是一样的,只是他们是在不同阶段。

那、那、那、那下面那个finally它又是有啥用,我们都通过resumeWithException把异常抛出去了,为啥下面又还有个handleFatalException,这货又是干啥用的???

handleFatalException主要是用来处理kotlinx.coroutines库的异常,我们这里大致的了解下就行了。主要分为两种:

  1. kotlinx.coroutines库或编译器有错误,导致的内部错误问题。
  2. ThreadContextElement也就是协程上下文错误,这是因为我们提供了不正确的ThreadContextElement实现,导致协程处于不一致状态。
1
2
3
4
kotlin复制代码public interface ThreadContextElement<S> : CoroutineContext.Element {
public fun updateThreadContext(context: CoroutineContext): S
public fun restoreThreadContext(context: CoroutineContext, oldState: S)
}

我们看到handleFatalException实际是调用了handleCoroutineException方法。handleCoroutineException是kotlinx.coroutines库中的顶级函数

1
2
3
4
kotlin复制代码public fun handleFatalException(exception: Throwable?, finallyException: Throwable?) {
//省略....
handleCoroutineException(this.delegate.context, reason)
}
1
2
3
4
5
6
7
8
9
10
11
12
kotlin复制代码public fun handleCoroutineException(context: CoroutineContext, exception: Throwable) {
try {
context[CoroutineExceptionHandler]?.let {
it.handleException(context, exception)
return
}
} catch (t: Throwable) {
handleCoroutineExceptionImpl(context, handlerException(exception, t))
return
}
handleCoroutineExceptionImpl(context, exception)
}

我们看到handleCoroutineException会先从协程上下文拿CoroutineExceptionHandler,如果我们没有定义的CoroutineExceptionHandler话,它将会调用handleCoroutineExceptionImpl抛出一个uncaughtExceptionHandler导致我们程序崩溃退出。

1
2
3
4
5
6
7
8
9
10
11
12
kotlin复制代码internal actual fun handleCoroutineExceptionImpl(context: CoroutineContext, exception: Throwable) {
for (handler in handlers) {
try {
handler.handleException(context, exception)
} catch (t: Throwable) {
val currentThread = Thread.currentThread()
currentThread.uncaughtExceptionHandler.uncaughtException(currentThread, handlerException(exception, t))
}
}
val currentThread = Thread.currentThread()
currentThread.uncaughtExceptionHandler.uncaughtException(currentThread, exception)
}

不知道各位是否理解了上面的流程,笔者最开始的时候也是被这里来来回回的。绕着晕乎乎的。如果没看懂的话,可以休息一下,揉揉眼睛,倒杯热水,再回过头捋一捋。

image.png

好滴,到此处为止。我们已经大概的了解kotlin协程中异常是如何抛出的,下面我们就不再不过多延伸。下面我们来说说异常的处理。

协程的异常处理

kotlin协程异常处理我们要分成两部分来看,通过上面的分解我们知道一种异常是通过resumeWithException抛出的,还有一种异常是直接通过CoroutineExceptionHandler抛出,那么我们现在就开始讲讲如何处理异常。

第一种:当然就是我们最常用的try..catch大法啦,只要有异常崩溃我就先try..catch下,先不管流程对不对,我先保住我的程序不能崩溃。

image.png

1
2
3
4
5
6
7
8
9
10
11
12
13
kotlin复制代码private fun testException(){
GlobalScope.launch{
launch(start = CoroutineStart.UNDISPATCHED) {
Log.d("${Thread.currentThread().name}", " 我要开始抛异常了")
try {
throw NullPointerException("异常测试")
} catch (e: Exception) {
e.printStackTrace()
}
}
Log.d("${Thread.currentThread().name}", "end")
}
}
1
2
3
4
5
6
kotlin复制代码D/DefaultDispatcher-worker-1:  我要开始抛异常了
W/System.err: java.lang.NullPointerException: 异常测试
W/System.err: at com.carman.kotlin.coroutine.MainActivity$testException$1$1.invokeSuspend(MainActivity.kt:252)
W/System.err: at com.carman.kotlin.coroutine.MainActivity$testException$1$1.invoke(Unknown
//省略...
D/DefaultDispatcher-worker-1: end

诶嘿,这个时候我们程序没有崩溃,只是输出了警告日志而已。那如果遇到try..catch搞不定的怎么办,或者遗漏了需要try..catch的位置怎么办。比如:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
kotlin复制代码private fun testException(){
var a:MutableList<Int> = mutableListOf(1,2,3)
GlobalScope.launch{
launch {
Log.d("${Thread.currentThread().name}","我要开始抛异常了" )
try {
launch{
Log.d("${Thread.currentThread().name}", "${a[1]}")
}
a.clear()
} catch (e: Exception) {
e.printStackTrace()
}
}
Log.d("${Thread.currentThread().name}", "end")
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
log复制代码D/DefaultDispatcher-worker-1: end
D/DefaultDispatcher-worker-2: 我要开始抛异常了
E/AndroidRuntime: FATAL EXCEPTION: DefaultDispatcher-worker-2
Process: com.carman.kotlin.coroutine, PID: 5394
java.lang.IndexOutOfBoundsException: Index: 1, Size: 0
at java.util.ArrayList.get(ArrayList.java:437)
at com.carman.kotlin.coroutine.MainActivity$testException$1$1$1.invokeSuspend(MainActivity.kt:252)
at kotlin.coroutines.jvm.internal.BaseContinuationImpl.resumeWith(ContinuationImpl.kt:33)
at kotlinx.coroutines.DispatchedTask.run(DispatchedTask.kt:106)
at kotlinx.coroutines.scheduling.CoroutineScheduler.runSafely(CoroutineScheduler.kt:571)
at kotlinx.coroutines.scheduling.CoroutineScheduler$Worker.executeTask(CoroutineScheduler.kt:750)
at kotlinx.coroutines.scheduling.CoroutineScheduler$Worker.runWorker(CoroutineScheduler.kt:678)
at kotlinx.coroutines.scheduling.CoroutineScheduler$Worker.run(CoroutineScheduler.kt:665)

image.png

当你以为使用try..catch就能捕获的时候,然而实际并没有。这是因为我们的try..catch使用方式不对,我们必须在使用a[1]时候再用try..catch捕获才行。那就有人会想那我每次都记得使用try..catch就好了。

是,当然没问题。但是你能保证你每次都能记住吗,你的同一战壕里的战友会记住吗。而且当你的逻辑比较复杂的时候,你使用那么多try..catch你代码阅读性是不是降低了很多后,你还能记住哪里有可能会出现异常吗。

image.png

这个时候就需要使用协程上下文中的CoroutineExceptionHandler。我们在上一篇文章讲解协程上下文的时候提到过,它是协程上下文中的一个Element,是用来捕获协程中未处理的异常。

1
2
3
4
5
6
kotlin复制代码public interface CoroutineExceptionHandler : CoroutineContext.Element {

public companion object Key : CoroutineContext.Key<CoroutineExceptionHandler>

public fun handleException(context: CoroutineContext, exception: Throwable)
}

我们稍作修改:

1
2
3
4
5
6
7
8
9
10
11
12
kotlin复制代码private fun testException(){
val exceptionHandler = CoroutineExceptionHandler { coroutineContext, throwable ->
Log.d("exceptionHandler", "${coroutineContext[CoroutineName]} :$throwable")
}
GlobalScope.launch(CoroutineName("异常处理") + exceptionHandler){
val job = launch{
Log.d("${Thread.currentThread().name}","我要开始抛异常了" )
throw NullPointerException("异常测试")
}
Log.d("${Thread.currentThread().name}", "end")
}
}
1
2
3
kotlin复制代码D/DefaultDispatcher-worker-1: 我要开始抛异常了
D/exceptionHandler: CoroutineName(异常处理) :java.lang.NullPointerException: 异常测试
D/DefaultDispatcher-worker-2: end

这个时候即使我们没有使用try..catch去捕获异常,但是异常还是被我们捕获处理了。是不是感觉异常处理也没有那么难。那如果按照上面的写,我们是不是得在每次启动协程的时候,也需要跟try..catch一样都需要加上一个CoroutineExceptionHandler呢?
这个时候我们就看出来,各位是否真的有吸收前面讲解的知识:

  • 第一种:我们上面讲解的协程作用域部分你已经消化吸收,那么恭喜你接下来的你可以大概的过一遍或者选择跳过了。因为接下来的部分和协程作用域中说到的内容大体一致。
  • 第二种:除第一种的,都是第二种。那你接下来你就得认证仔细的看了。

我们之前在讲到协同作用域和主从(监督)作用域的时候提到过,异常传递的问题。我们先来看看协同作用域:

  • 协同作用域如果子协程抛出未捕获的异常时,会将异常传递给父协程处理,如果父协程被取消,则所有子协程同时也会被取消。

容我盗个官方图
默认情况下,当协程因出现异常失败时,它会将异常传播到它的父级,父级会取消其余的子协程,同时取消自身的执行。最后将异常在传播给它的父级。当异常到达当前层次结构的根,在当前协程作用域启动的所有协程都将被取消。

image.png

我们在前一个案例的基础上稍作做一下修改,只在父协程上添加CoroutineExceptionHandler,照例上代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
kotlin复制代码private fun testException(){
val exceptionHandler = CoroutineExceptionHandler { coroutineContext, throwable ->
Log.d("exceptionHandler", "${coroutineContext[CoroutineName]} 处理异常 :$throwable")
}
GlobalScope.launch(CoroutineName("父协程") + exceptionHandler){
val job = launch(CoroutineName("子协程")) {
Log.d("${Thread.currentThread().name}","我要开始抛异常了" )
for (index in 0..10){
launch(CoroutineName("孙子协程$index")) {
Log.d("${Thread.currentThread().name}","${coroutineContext[CoroutineName]}" )
}
}
throw NullPointerException("空指针异常")
}
for (index in 0..10){
launch(CoroutineName("子协程$index")) {
Log.d("${Thread.currentThread().name}","${coroutineContext[CoroutineName]}" )
}
}
try {
job.join()
} catch (e: Exception) {
e.printStackTrace()
}
Log.d("${Thread.currentThread().name}", "end")
}
}
1
2
3
4
5
6
kotlin复制代码D/DefaultDispatcher-worker-3: 我要开始抛异常了
W/System.err: kotlinx.coroutines.JobCancellationException: StandaloneCoroutine is cancelling; job=StandaloneCoroutine{Cancelling}@f6b7807
W/System.err: Caused by: java.lang.NullPointerException: 空指针异常
W/System.err: at com.carman.kotlin.coroutine.MainActivity$testException$1$job$1.invokeSuspend(MainActivity.kt:26//省略...
D/DefaultDispatcher-worker-6: end
D/exceptionHandler: CoroutineName(父协程) 处理异常 :java.lang.NullPointerException: 空指针异常

我们看到子协程job的异常被父协程处理了,无论我下面开启多少个子协程产生异常,最终都是被父协程处理。但是有个问题是:因为异常会导致父协程被取消执行,同时导致后续的所有子协程都没有执行完成(可能偶尔有个别会执行完)。那可能就会是有人问了,这种做法的意义和应用场景是什么呢?

image.png

如果有一个页面,它最终展示的数据,是通过请求多个服务器接口的数据拼接而成的,而其中某一个接口出问题都将不进行数据展示,而是提示加载失败。那么你就可以使用上面的方案去做,都不用管它们是谁报的错,反正都是统一处理,一劳永逸。类似这样的例子我们在开发中应该经常遇到。

image.png

但是另外一个问题就来了。例如我们APP的首页,首页上展示的数据五花八门。如:广告,弹窗,未读状态,列表数据等等都在首页存在,但是他们相互之间互不干扰又不关联,即使其中某一个失败了也不影响其他数据展示。那通过上面的方案,我们就没办法处理。

这个时候我们就可以通过主从(监督)作用域的方式去实现,与协同作用域一致,区别在于该作用域下的协程取消操作的单向传播性,子协程的异常不会导致其它子协程取消。我再盗个官方图:

image.png

我们在讲解主从(监督)作用域的时候提到过,要实现主从(监督)作用域需要使用supervisorScope或者SupervisorJob。这里我们需要补充一下,我们在使用supervisorScope其实用的就是SupervisorJob。
这也是为什么使用supervisorScope与使用SupervisorJob协程处理是一样的效果。

1
2
3
4
5
6
7
8
kotlin复制代码/**
* 省略...
* but overrides context's [Job] with [SupervisorJob].
* 省略...
*/
public suspend fun <R> supervisorScope(block: suspend CoroutineScope.() -> R): R {
//省略...
}

这段是摘自官方文档的,其他的我把它们省略了,只留了一句:”SupervisorJob会覆盖上下文中的Job“。这也就说明我们在使用supervisorScope的就是使用的SupervisorJob。我们先用supervisorScope实现以下我们上面提到的案例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
kotlin复制代码private fun testException(){
val exceptionHandler = CoroutineExceptionHandler { coroutineContext, throwable ->
Log.d("exceptionHandler", "${coroutineContext[CoroutineName].toString()} 处理异常 :$throwable")
}
GlobalScope.launch(exceptionHandler) {
supervisorScope {
launch(CoroutineName("异常子协程")) {
Log.d("${Thread.currentThread().name}", "我要开始抛异常了")
throw NullPointerException("空指针异常")
}
for (index in 0..10) {
launch(CoroutineName("子协程$index")) {
Log.d("${Thread.currentThread().name}正常执行", "$index")
if (index %3 == 0){
throw NullPointerException("子协程${index}空指针异常")
}
}
}
}
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
kotlin复制代码D/DefaultDispatcher-worker-1: 我要开始抛异常了
D/exceptionHandler: CoroutineName(异常子协程) 处理异常 :java.lang.NullPointerException: 空指针异常
D/DefaultDispatcher-worker-1正常执行: 1
D/DefaultDispatcher-worker-1正常执行: 2
D/DefaultDispatcher-worker-3正常执行: 0
D/DefaultDispatcher-worker-1正常执行: 3
D/exceptionHandler: CoroutineName(子协程0) 处理异常 :java.lang.NullPointerException: 子协程0空指针异常
D/exceptionHandler: CoroutineName(子协程3) 处理异常 :java.lang.NullPointerException: 子协程3空指针异常
D/DefaultDispatcher-worker-4正常执行: 4
D/DefaultDispatcher-worker-4正常执行: 5
D/DefaultDispatcher-worker-5正常执行: 7
D/DefaultDispatcher-worker-3正常执行: 6
D/DefaultDispatcher-worker-5正常执行: 8
D/DefaultDispatcher-worker-5正常执行: 9
D/exceptionHandler: CoroutineName(子协程9) 处理异常 :java.lang.NullPointerException: 子协程9空指针异常
D/exceptionHandler: CoroutineName(子协程6) 处理异常 :java.lang.NullPointerException: 子协程6空指针异常
D/DefaultDispatcher-worker-2正常执行: 10

可以看到即使当中有多个协程都出现问题,我们还是能够让所有的子协程执行完成。这个时候我们用这样方案是不是就可以解决,我们首页多种数据互不干扰的刷新问题了,同也能够在出现异常的时候统一处理。

那我们在用SupervisorJob实现一遍,看看是不是和supervisorScope一样的,代码奉上:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
kotlin复制代码private fun testException(){
val exceptionHandler = CoroutineExceptionHandler { coroutineContext, throwable ->
Log.d("exceptionHandler", "${coroutineContext[CoroutineName].toString()} 处理异常 :$throwable")
}
val supervisorScope = CoroutineScope(SupervisorJob() + exceptionHandler)
with(supervisorScope) {
launch(CoroutineName("异常子协程")) {
Log.d("${Thread.currentThread().name}", "我要开始抛异常了")
throw NullPointerException("空指针异常")
}
for (index in 0..10) {
launch(CoroutineName("子协程$index")) {
Log.d("${Thread.currentThread().name}正常执行", "$index")
if (index % 3 == 0) {
throw NullPointerException("子协程${index}空指针异常")
}
}
}
}
}

可以看到我们通过CoroutineScope创建一个SupervisorJob的supervisorScope,然后再通过with(supervisorScope)是不是就变得跟直接使用supervisorScope一样了。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
kotlin复制代码D/DefaultDispatcher-worker-1: 我要开始抛异常了
D/DefaultDispatcher-worker-2正常执行: 0
D/exceptionHandler: CoroutineName(子协程0) 处理异常 :java.lang.NullPointerException: 子协程0空指针异常
D/exceptionHandler: CoroutineName(异常子协程) 处理异常 :java.lang.NullPointerException: 空指针异常
D/DefaultDispatcher-worker-2正常执行: 1
D/DefaultDispatcher-worker-2正常执行: 2
D/DefaultDispatcher-worker-4正常执行: 3
D/exceptionHandler: CoroutineName(子协程3) 处理异常 :java.lang.NullPointerException: 子协程3空指针异常
D/DefaultDispatcher-worker-1正常执行: 4
D/DefaultDispatcher-worker-4正常执行: 5
D/DefaultDispatcher-worker-4正常执行: 6
D/exceptionHandler: CoroutineName(子协程6) 处理异常 :java.lang.NullPointerException: 子协程6空指针异常
D/DefaultDispatcher-worker-4正常执行: 8
D/DefaultDispatcher-worker-3正常执行: 7
D/DefaultDispatcher-worker-2正常执行: 9
D/exceptionHandler: CoroutineName(子协程9) 处理异常 :java.lang.NullPointerException: 子协程9空指针异常
D/DefaultDispatcher-worker-3正常执行: 10

当然,我们在使用协程的时候,可能某个协程需要自己处理自己的异常,这个时候只需要在这个协程的上下文中添加CoroutineExceptionHandler即可。毕竟按需使用,谁也不知道产品又会有什么奇怪的想法。

image.png

好了,到现在我们也基本的知道协程中的异常产生流程,和按需处理协程中的异常问题。如果您还有什么不清楚的地方,可以自己动手实验一下或者在下方留言、私信笔者等方式,我会在看到消息的第一时间处理。

预告以及意见收集
在下一章节中,我们将会进入到实际的Android开发中,我们会先构建一个基础APP的框架,封装一些常用的协程方法和请求方式,至于具体的实战项目类型,我想征求一下大家的意见,然后根据反馈的实际情况再来决定,欢迎大家踊跃的提出意见。

最后:祝愿大家都能写出完美的BUG,让测试都无法找到BUG所在。

需要源码的看这里:demo源码

原创不易。如果您喜欢这篇文章,您可以动动小手点赞收藏image.png。

Android技术交流群,有兴趣的可以私聊加入

关联文章
Kotlin协程基础及原理系列

  • 史上最详Android版kotlin协程入门进阶实战(一) -> kotlin协程的基础用法
  • 史上最详Android版kotlin协程入门进阶实战(二) -> kotlin协程的关键知识点初步讲解
  • 史上最详Android版kotlin协程入门进阶实战(三) -> kotlin协程的异常处理
  • 史上最详Android版kotlin协程入门进阶实战(四) -> 使用kotlin协程开发Android的应用
  • 史上最详Android版kotlin协程入门进阶实战(五) -> kotlin协程的网络请求封装

Flow系列

  • Kotlin协程之Flow使用(一)

扩展系列

  • 封装DataBinding让你少写万行代码
  • ViewModel的日常使用封装

本文转载自: 掘金

开发者博客 – 和开发相关的 这里全都有

这款简洁的开源客户关系管理系统,真是好东西

发表于 2021-04-22

  大家好,我是为广大程序员兄弟操碎了心的小编,每天推荐一个小工具/源码,装满你的收藏夹,每天分享一个小技巧,让你轻松节省开发效率,实现不加班不熬夜不掉头发,是我的目标!

  今天小编推荐一款PHP开发的开源客户关系管理系统(CRM),系统提供客户关系管理员(CRM),进销存(JXC),人力资源(HRM),后勤(办公用品,固定资产,公物维修)、物业管理等功能,软件开源免费。

开源协议

  使用 LGPL-3.0 开源许可协议,用户可免费使用,但禁止任何单位或个人修改软件后再次发行的行为。

git地址

  公众号【Github导航站】回复关键词【fly】获取git地址

功能模块

  • 商品管理(商品颜色,计量单位,商品类型,商品维护);
  • 组织结构(部门、权限、职务、用户管理);
  • 企业类型,仓库管理,发货方式,销售阶段,服务类型;供应商管理
  • 供应商管理:实现对公司提供产品及服务的货源进行基本信息的记录.
  • 客户管理:实现对客户信息的添加及修改查询功能。
  • 采购单(进销存):对采购员与供应商所签订的订单信息的记录,便于后勤查询与跟踪。
  • 库存管理:将商品列表中的商品进行入库,并自动生成入库单。
  • 销售管理:销售流程:完善客户信息(客户管理)—销售机会—跟踪—-记录客户需求,制定相关的解决方案—-分析竞争产品—-报价—项目报备—-订单—-编辑出库商品信息—生成出库单—仓库管理员确认出库—-发货。
  • 财务管理:贴近实际业务的财务模块,无须财务知识,就能上手操作。

演示截图

部门管理

权限管理

沟通记录

客户来源

服务记录

销售机会

产品报价

合同添加

销售订单

添加供应商

财务流水

费用收入类型

汇款计划添加

结尾

  本期就分享到这里,我是小编南风吹,专注分享好玩有趣、新奇、实用的开源项目及开发者工具、学习资源!希望能与大家共同学习交流,欢迎关注我的公众号**【Github导航站】**。

本文转载自: 掘金

开发者博客 – 和开发相关的 这里全都有

SpringBoot整合Captcha验证码(含代码) 1

发表于 2021-04-22
  1. 基本结构

使用Captcha生成验证码, 利用Redis存储验证码

Redis中的结构为, Key是32位的UUID, Value为Captcha的4位随机字母以及数字的集合

设定Redis过期时间为1min, 即可实现过期验证码的自动失效

  1. Kaptcha的依赖

基本的依赖这里不再叙述, 主要说一下要导入Captcha的依赖

1
2
3
4
5
6
xml复制代码<!--Kaptcha-->
<dependency>
<groupId>com.github.penggle</groupId>
<artifactId>kaptcha</artifactId>
<version>2.3.2</version>
</dependency>

所有的依赖如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
xml复制代码<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>2.4.0</version>
<relativePath/> <!-- lookup parent from repository -->
</parent>
<groupId>com.wang</groupId>
<artifactId>spring_security_framework</artifactId>
<version>0.0.1-SNAPSHOT</version>
<name>spring_security_framework</name>
<description>Demo project for Spring Boot</description>
<properties>
<java.version>1.8</java.version>
</properties>
<dependencies>
<!--Redis-->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
<!--JDBC-->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-jdbc</artifactId>
</dependency>
<!--SpringSecurity-->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
<!--Thymeleaf-->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-thymeleaf</artifactId>
</dependency>
<!--Validation-->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>
<!--SpringBoot Web-->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<!--Mybatis-->
<dependency>
<groupId>org.mybatis.spring.boot</groupId>
<artifactId>mybatis-spring-boot-starter</artifactId>
<version>2.1.4</version>
</dependency>
<!--SpringSecurity with thymeleaf-->
<dependency>
<groupId>org.thymeleaf.extras</groupId>
<artifactId>thymeleaf-extras-springsecurity5</artifactId>
</dependency>
<!--MySQL connector-->
<dependency>
<groupId>mysql</groupId>
<artifactId>mysql-connector-java</artifactId>
<scope>runtime</scope>
</dependency>
<!--Lombok-->
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<optional>true</optional>
</dependency>
<!--Test-->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.springframework.security</groupId>
<artifactId>spring-security-test</artifactId>
<scope>test</scope>
</dependency>
<!--Druid-->
<dependency>
<groupId>com.alibaba</groupId>
<artifactId>druid-spring-boot-starter</artifactId>
<version>1.2.2</version>
</dependency>
<!--FastJSON-->
<dependency>
<groupId>com.alibaba</groupId>
<artifactId>fastjson</artifactId>
<version>1.2.74</version>
</dependency>
<!--log4j-->
<dependency>
<groupId>log4j</groupId>
<artifactId>log4j</artifactId>
<version>1.2.17</version>
</dependency>
<!--Swagger2-->
<dependency>
<groupId>io.springfox</groupId>
<artifactId>springfox-boot-starter</artifactId>
<version>3.0.0</version>
</dependency>
<!--HuTool-->
<dependency>
<groupId>cn.hutool</groupId>
<artifactId>hutool-all</artifactId>
<version>5.4.7</version>
</dependency>
<!--Kaptcha-->
<dependency>
<groupId>com.github.penggle</groupId>
<artifactId>kaptcha</artifactId>
<version>2.3.2</version>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
</plugins>
</build>
</project>
  1. 配置SpringBoot

配置SpringBoot的配置文件, 这里主要关注一个session的过期时间

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
yaml复制代码#Port
server:
port: 80
servlet:
session:
timeout: 1
spring:
application:
name: SpringSecurityFramework
#dataBase Setting
datasource:
username: root
password: 123456
url: jdbc:mysql://localhost:3306/security?useUnicode=true&characterEncoding=utf-8&serverTimezone=Asia/Shanghai
driver-class-name: com.mysql.cj.jdbc.Driver
type: com.alibaba.druid.pool.DruidDataSource
#Druid Setting
druid:
initial-size: 5
min-idle: 5
max-active: 20
max-wait: 60000
time-between-eviction-runs-millis: 60000
min-evictable-idle-time-millis: 30000
validation-query: SELECT 1 FROM DUAL
test-while-idle: true
test-on-borrow: false
test-on-return: false
pool-prepared-statements: true
#Setting For Druid StatView and Filter
filters: stat,wall,log4j
max-pool-prepared-statement-per-connection-size: 20
use-global-data-source-stat: true
connection-properties: druid.stat.mergeSql=true;druid.stat.slowSql
#Redis Setting
redis:
host: 127.0.0.1
port: 6379
#Thymeleaf
thymeleaf:
cache: false
#Mybatis
mybatis:
type-aliases-package: com.wang.entity
mapper-locations: classpath:Mybatis/mapper/*.xml
configuration:
map-underscore-to-camel-case: true

其余的配置, 如log4j, druid, SpringSecurity, RedisTemplate,这里就不再赘述

  1. 配置Captcha

我们可以通过JAVA的配置类来配置Captcha生成验证码的一些规则

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
java复制代码package com.wang.spring_security_framework.config;
import com.google.code.kaptcha.impl.DefaultKaptcha;
import com.google.code.kaptcha.util.Config;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import java.util.Properties;
//Kaptcha配置
@Configuration
public class KaptchaConfig {
@Bean
public DefaultKaptcha producer() {
//Properties类
Properties properties = new Properties();
// 图片边框
properties.setProperty("kaptcha.border", "yes");
// 边框颜色
properties.setProperty("kaptcha.border.color", "105,179,90");
// 字体颜色
properties.setProperty("kaptcha.textproducer.font.color", "blue");
// 图片宽
properties.setProperty("kaptcha.image.width", "110");
// 图片高
properties.setProperty("kaptcha.image.height", "40");
// 字体大小
properties.setProperty("kaptcha.textproducer.font.size", "30");
// session key
properties.setProperty("kaptcha.session.key", "code");
// 验证码长度
properties.setProperty("kaptcha.textproducer.char.length", "4");
// 字体
properties.setProperty("kaptcha.textproducer.font.names", "宋体,楷体,微软雅黑");
//图片干扰
properties.setProperty("kaptcha.noise.impl","com.google.code.kaptcha.impl.DefaultNoise");
//Kaptcha 使用上述配置
Config config = new Config(properties);
//DefaultKaptcha对象使用上述配置, 并返回这个Bean
DefaultKaptcha defaultKaptcha = new DefaultKaptcha();
defaultKaptcha.setConfig(config);
return defaultKaptcha;
}
}
  1. 工具类

使用UUID作为key, 同时考虑到对验证码的输出结果可能有不同的要求, 这里写两个工具类来处理它们

  • UUIDUtil

package com.wang.spring_security_framework.util;
import org.springframework.context.annotation.Bean;
import org.springframework.stereotype.Component;
import java.util.UUID;
@Component
public class UUIDUtil {
/**
* 生成32位的随机UUID
* @return 字符形式的小写UUID
*/
@Bean
public String getUUID32() {
return UUID.randomUUID().toString()
.replace(“-“, “”).toLowerCase();
}
}

CaptchaUtil

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
java复制代码package com.wang.spring_security_framework.util;
import com.google.code.kaptcha.impl.DefaultKaptcha;
import com.wang.spring_security_framework.service.CaptchaService;
import io.netty.handler.codec.base64.Base64Encoder;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
import sun.misc.BASE64Encoder;
import javax.imageio.ImageIO;
import java.awt.image.BufferedImage;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.io.OutputStream;
import java.util.Map;
@Component
//Captcha 生成工具
public class CaptchaUtil {
@Autowired
private DefaultKaptcha producer;
@Autowired
private CaptchaService captchaService;
//生成catchCreator的map
public Map<String, Object> catchaImgCreator() throws IOException {
//生成文字验证码
String text = producer.createText();
//生成文字对应的图片验证码
BufferedImage image = producer.createImage(text);
//将图片写出
ByteArrayOutputStream outputStream = new ByteArrayOutputStream();
ImageIO.write(image, "jpg", outputStream);
//对写出的字节数组进行Base64编码 ==> 用于传递8比特字节码
BASE64Encoder encoder = new BASE64Encoder();
//生成token
Map<String, Object> token = captchaService.createToken(text);
token.put("img", encoder.encode(outputStream.toByteArray()));
return token;
}
}
  1. 接口以及实现类

  1. 接口

1
2
3
4
5
6
7
8
9
10
11
12
typescript复制代码package com.wang.spring_security_framework.service;
import org.springframework.stereotype.Service;
import java.io.IOException;
import java.util.Map;
public interface CaptchaService {
//生成token
Map<String, Object> createToken(String captcha);
//生成captcha验证码
Map<String, Object> captchaCreator() throws IOException;
//验证输入的验证码是否正确
String versifyCaptcha (String token, String inputCode);
}

2. 实现类

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
typescript复制代码package com.wang.spring_security_framework.service.serviceImpl;
import com.google.code.kaptcha.impl.DefaultKaptcha;
import com.wang.spring_security_framework.service.CaptchaService;
import com.wang.spring_security_framework.util.CaptchaUtil;
import com.wang.spring_security_framework.util.UUIDUtil;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.core.ValueOperations;
import org.springframework.stereotype.Service;
import java.io.IOException;
import java.util.HashMap;
import java.util.Map;
import java.util.concurrent.TimeUnit;
@Service
public class CaptchaServiceImpl implements CaptchaService {
@Autowired
private RedisTemplate<String, Object> redisTemplate;
@Autowired
private UUIDUtil uuidUtil;
@Autowired
private CaptchaUtil captchaUtil;
//从SpringBoot的配置文件中取出过期时间
@Value("${server.servlet.session.timeout}")
private Integer timeout;
//UUID为key, 验证码为Value放在Redis中
@Override
public Map<String, Object> createToken(String captcha) {
//生成一个token
String key = uuidUtil.getUUID32();
//生成验证码对应的token 以token为key 验证码为value存在redis中
ValueOperations<String, Object> valueOperations = redisTemplate.opsForValue();
valueOperations.set(key, captcha);
//设置验证码过期时间
redisTemplate.expire(key, timeout, TimeUnit.MINUTES);
Map<String, Object> map = new HashMap<>();
map.put("token", key);
map.put("expire", timeout);
return map;
}
//生成captcha验证码
@Override
public Map<String, Object> captchaCreator() throws IOException {
return captchaUtil.catchaImgCreator();
}
//验证输入的验证码是否正确
@Override
public String versifyCaptcha(String token, String inputCode) {
//根据前端传回的token在redis中找对应的value
ValueOperations<String, Object> valueOperations = redisTemplate.opsForValue();
if (redisTemplate.hasKey(token)) {
//验证通过, 删除对应的key
if (valueOperations.get(token).equals(inputCode)) {
redisTemplate.delete(token);
return "true";
} else {
return "false";
}
} else {
return "false";
}
}
}
  • 这里的验证, 只是简单的验证了输入是否能从Redis中匹配, 返回了字符串
  • 真实的验证中, 我们还要在逻辑中添加用户名和密码的考虑
  1. Controller

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
kotlin复制代码package com.wang.spring_security_framework.controller;
import com.wang.spring_security_framework.service.CaptchaService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import java.io.IOException;
import java.util.Map;
@RestController
public class LoginController {
@Autowired
CaptchaService captchaService;
@GetMapping("/captcha")
public Map<String, Object> captcha() throws IOException {
return captchaService.captchaCreator();
}
@GetMapping("/login1")
public String login(@RequestParam("token") String token,
@RequestParam("inputCode") String inputCode) {
return captchaService.versifyCaptcha(token, inputCode);
}
}
  • captcha 用于获取一个验证码
  • login1 用于接收到前端的请求后验证并返回结果
  • login1 这里为了测试简便实用了GET方法, 而实际中最好使用POST方法, 这样安全性更高
  1. 前端页面的实现

前端结构如图, 实现了一个简单的验证码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
xml复制代码<!DOCTYPE html>
<html lang="en" xmlns:th="http://www.thymeleaf.org">
<head>
<meta charset="UTF-8">
<title>登录</title>
<script src="https://cdn.bootcss.com/jquery/3.4.1/jquery.js"></script>
</head>
<body>
<div>
<div>
<form th:action="@{/login1}" method="get">
<input type="text" id="userName" placeholder="请输入用户名" name="userName">
<br>
<input type="password" id="password" placeholder="请输入密码" name="password">
<br>
<input type="text" id="inputCode" placeholder="请输入验证码" maxlength="4" name="inputCode">
<!--通过隐藏域传递值, 在下面的验证码点击事件中, 将值绑定过来, 这样就可以获得最新的验证码对应的值了!-->
<input id="token" value="" type="hidden" name="token">
<input type="submit" value="登录">
</form>
</div>
<div>
<!-- 当用户链接时,void(0)计算为0,用户点击不会发生任何效果 -->
<a href="javascript:void(0);" title="点击更换验证码">
<!--this参数, 返回当前的DOM元素-->
<img src="" alt="更换验证码" id="imgVerify" onclick="getVerify(this)">
</a>
</div>
</div>
<script>
//获得img对象
let imgVerify = $("#imgVerify").get(0);
//$(function())等同于$(document).ready(function()) ==> 页面加载完毕之后, 才执行函数
$(function () {
getVerify(imgVerify);
});
//onclick时间绑定的getVerify函数
function getVerify(obj) {
$.ajax({
type: "GET",
url: "/captcha",
success: function (result) {
obj.src = "data:image/jpeg;base64," + result.img;
$("#token").val(result.token);
}
});
}
</script>
</body>
</html>
  • 用一个 a 标签包围 img 标签, 这样如果图片没有加载出来也有一个超链接, 不过点了以后没有效果
  • (function())等同于(function())等同于(function())等同于(document).ready(function()) ==> 页面加载完毕之后, 才执行函数, 这里必须要写这个函数, 否则第一次加载不会调用 onclick 方法, 也就不会生成验证码!
  • 我们利用隐藏域将验证码的key传递到表单中, 我们在 img 的点击事件对应的函数的ajax回调函数中可以利用jQuery操作DOM, 顺带取出key值放到我们的隐藏域中, 这样提交的时候就会提交 key 和用户输入的 value 了

示例

验证通过

完成!

本文转载自: 掘金

开发者博客 – 和开发相关的 这里全都有

1…681682683…956

开发者博客

9558 日志
1953 标签
RSS
© 2025 开发者博客
本站总访问量次
由 Hexo 强力驱动
|
主题 — NexT.Muse v5.1.4
0%