《UNIX 传奇:历史与回忆》 是 bwk(Brian W. Kernighan)2019 年的新作,回忆了 UNIX 在大半个世纪的风雨历程,是一本引人入胜的书籍。通过对 UNIX 操作系统的历史和发展进行详细的叙述和回顾,让我对这个操作系统有了更深入的了解。读完这本书,我不仅对 UNIX 的技术细节有了更清晰的认识,也对 UNIX 的影响力和价值有了更深刻的体会。
书中首先回顾了 UNIX 的诞生和发展过程,从贝尔实验室的研究项目到成为世界上最重要的操作系统没有之一,UNIX 经历了漫长而曲折的发展历程。作者通过详细的叙述和丰富的历史资料,将 UNIX 的发展与当时的技术环境和社会背景相结合,深入分析了 UNIX 的成功原因和对计算机科学的影响。
在书里,作者还介绍了 UNIX 的设计原则和哲学思想,如小即是美、一切皆文件等,这些原则不仅体现了 UNIX 的简洁和灵活性,也影响了后来的操作系统设计。通过对 UNIX 设计原则的解读,我对 UNIX 的设计理念有了更深入的理解,也对软件设计和开发有了新的思考。
上图(来源)中站着的是 dmr(Dennis MacAlistair Ritchie)、坐着打字的是 Ken(Ken Thompson) 和几台 PDP-11。此外,本书还详细介绍了 UNIX 的核心组件和功能,如文件系统、进程管理、网络通信等等。通过对这些功能的解析,会 UNIX 的内部机制有了更深入的了解,也对操作系统的工作原理有了更全面的认识。同时,书中还介绍了 UNIX 的各种衍生版本和相关技术,如 Linux、BSD(加州大学伯克利分校维护的版本)等,这些衍生版本不仅丰富了 UNIX 的功能和应用领域,也推动了开源软件的发展。
书中除了对 UNIX 技术的介绍,还涉及了 UNIX 社区的发展和文化。UNIX 社区以其开放、自由的精神吸引了众多开发者和用户,形成了独特的文化氛围。通过对 UNIX 社区的描述和分析,我对 UNIX 社区的运作方式和价值观有了更深入的了解。UNIX 社区以其开放的开发模式和共享的文化,促进了知识和经验的交流,推动了技术的不断进步。在 UNIX 社区中,人们通过邮件列表、论坛和会议等形式进行交流和合作,共同解决问题、改进软件,形成了一种合作共赢的氛围。
此外,作者还介绍了 UNIX 在商业领域的应用和发展。UNIX 不仅在学术界和科研领域得到广泛应用,也在商业领域取得了巨大成功。通过对 UNIX 商业化的历史和案例的介绍,对 UNIX 在商业环境中的优势和挑战有了更深入的认识。UNIX 的开放性和灵活性使其成为企业 IT 系统的首选,而 UNIX 商业公司的崛起也推动了 UNIX 的发展和推广。
上图是 1991 年 8 月 林纳斯·托瓦兹 的 Linux宣告(图片来源)。在读《UNIX 传奇:历史与回忆》之后,对 UNIX 的重要性和影响力有了更深刻的认识。UNIX 不仅是一种操作系统,更是一种思想和理念的体现。UNIX 的设计原则和开放的开发模式影响了整个计算机科学领域,推动了软件工程的发展。UNIX 的成功不仅在于其技术实力,更在于其背后的开放和合作精神。
然后,本书还通过对 UNIX 历史的回顾和个人经历的叙述,让我感受到了 UNIX 社区的热情和活力。UNIX 社区的成员们对技术的热爱和追求,以及对自由和开放的坚持,让我深受启发。作为一名从事软件开发的人,我深深地感受到了 UNIX 所传递的价值观和精神,这将对我的工作和职业发展产生积极的影响。
读完《UNIX 传奇:历史与回忆》后,我深受感动和启发。这本书不仅让我了解了 UNIX 的历史和技术,也让我感受到了 UNIX 的精神和价值。UNIX 的开放性、灵活性和合作精神,都是我在工作和生活中需要学习和借鉴的地方。
UNIX 的设计哲学「小即是美」,让我明白了在解决问题时,简洁的解决方案往往是最好的。在软件开发中,我们应该尽量避免复杂性,追求简洁和高效。同时,UNIX 的「一切皆文件」原则,也让我明白了抽象和统一的重要性。通过把所有资源都视为文件,UNIX 简化了操作和管理的复杂性,提高了效率和可用性。
上图是自 1969 年以来 UNIX 和类 UNIX 系统的演变历史(图片来源)。UNIX 开放源代码和开发模式,也让我认识到了开放和共享的价值。在今天的互联网时代,开放和共享是推动技术和知识进步的重要力量。我们应该积极参与开源社区,共享我们的知识和经验,共同推动技术的发展。
此外,UNIX 社区的活力和热情,也让我深受感动。在 UNIX 社区中,人们无私地分享知识,热情地帮助他人,共同解决问题,这种精神是我需要学习和倡导的。
总的来说,《UNIX 传奇:历史与回忆》是一本非常值得一读的书。它不仅让我了解了 UNIX 的历史和技术,也让我感受到了 UNIX 的精神和价值。
这本书对我来说,既是一次知识的旅行,也是一次精神的洗礼。我相信,这本书对任何对计算机科学和软件开发感兴趣的人,都会有所启发和帮助~
Goroutines 是 Go 语言主要的并发原语。它看起来非常像线程,但是相比于线程它的创建和管理成本很低。Go 在运行时将 goroutine 有效地调度到真实的线程上,以避免浪费资源,因此您可以轻松地创建大量的 goroutine(例如每个请求一个 goroutine),并且您可以编写简单的,命令式的阻塞代码。因此,Go 的网络代码往往比其它语言中的等效代码更直接,更容易理解(这点从下文中的示例代码可以看出)。
对我来说,goroutine 是将 Go 这门语言与其它语言区分开来的一个主要特征。这就是为什么大家更喜欢用 Go 来编写需要并发的代码。在下面讨论更多关于 goroutine 之前,我们先了解一些历史,这样你就能理解为什么你想要它们了。
高性能服务器需要同时处理来自多个客户端的请求。有很多方法可以设计一个服务端架构来处理这个问题。最容易想到的就是让一个主进程在循环中调用 accept,然后调用 fork 来创建一个处理请求的子进程。这篇 Beej’s Guide to Network Programming 指南中提到了这种方式。
在网络编程中,fork 是一个很好的模式,因为你可以专注于网络而不是服务器架构。但是它很难按照这种模式编写出一个高效的服务器,现在应该没有人在实践中使用这种方式了。
fork 同时也存在很多问题,首先第一个是成本: Linux 上的 fork 调用看起来很快,但它会将你所有的内存标记为 copy-on-write。每次写入 copy-on-write 页面都会导致一个小的页面错误,这是一个很难测量的小延迟,进程之间的上下文切换也很昂贵。
另一个问题是规模: 很难在大量子进程中协调共享资源(如 CPU、内存、数据库连接等)的使用。如果流量激增,并且创建了太多进程,那么它们将相互争夺 CPU。但是如果限制创建的进程数量,那么在 CPU 空闲时,大量缓慢的客户端可能会阻塞每个人的正常使用,这时使用超时机制会有所帮助(无论服务器架构如何,超时设置都是很必要的)。
通过使用线程而不是进程,上面这些问题在一定程度上能得到缓解。创建线程比创建进程更“便宜”,因为它共享内存和大多数其它资源。在共享地址空间中,线程之间的通信也相对容易,使用信号量和其它结构来管理共享资源,然而,线程仍然有很大的成本,如果你为每个连接创建一个新线程,你会遇到扩展问题。与进程一样,你此时需要限制正在运行的线程的数量,以避免严重的 CPU 争用,并且需要使慢速请求超时。创建一个新线程仍然需要时间,尽管可以通过使用线程池在请求之间回收线程来缓解这一问题。
无论你是使用进程还是线程,你仍然有一个难以回答的问题: 你应该创建多少个线程?如果您允许无限数量的线程,客户端可能会用完所有的内存和 CPU,而流量会出现小幅激增。如果你限制服务器的最大线程数,那么一堆缓慢的客户端就会阻塞你的服务器。虽然超时是有帮助的,但它仍然很难有效地使用你的硬件资源。
那么既然无法轻易预测出需要多少线程,当如果尝试将请求与线程解耦时会发生什么呢?如果我们只有一个线程专门用于应用程序逻辑(或者可能是一个小的、固定数量的线程),然后在后台使用异步系统调用处理所有的网络流量,会怎么样?这就是一种 事件驱动 的服务端架构。
事件驱动架构模式是围绕 select 系统调用设计的。后来像 poll 这样的机制已经取代了 select,但是 select 是广为人知的,它们在这里都服务于相同的概念和目的。select 接受一个文件描述符列表(通常是套接字),并返回哪些是准备好读写的。如果所有文件描述符都没有准备好,则选择阻塞,直到至少有一个准备好。
1 |
|
为了实现一个事件驱动的服务器,你需要跟踪一个 socket 和网络上被阻塞的每个请求的一些状态。在服务器上有一个单一的主事件循环,它调用 select 来处理所有被阻塞的套接字。当 select 返回时,服务器知道哪些请求可以进行了,因此对于每个请求,它调用应用程序逻辑中的存储状态。当应用程序需要再次使用网络时,它会将套接字连同新状态一起添加回“阻塞”池中。这里的状态可以是应用程序恢复它正在做的事情所需的任何东西: 一个要回调的 closure,或者一个 Promise。
从技术上讲,这些其实都可以用一个线程实现。这里不能谈论任何特定实现的细节,但是像 JavaScript
这样缺乏线程的语言也很好的遵循了这个模型。Node.js 更是将自己描述为“an event-driven JavaScript runtime, designed to build scalable network applications.”
事件驱动的服务器通常比纯粹基于 fork 或线程的服务器更好地利用 CPU 和内存。你可以为每个核心生成一个应用程序线程来并行处理请求。线程不会相互争夺 CPU,因为线程的数量等于内核的数量。当有请求可以进行时,线程永远不会空闲,非常高效。效率如此之高,以至于现在大家都使用这种方式来编写服务端代码。
从理论上讲,这听起来不错,但是如果你编写这样的应用程序代码,就会发现这是一场噩梦。。。具体是什么样的噩梦,取决于你所使用的语言和框架。在 JavaScript 中,异步函数通常返回一个 Promise,你给它附加回调。在 Java gRPC 中,你要处理的是 StreamObserver。如果你不小心,你最终会得到很多深度嵌套的“箭头代码”函数。如果你很小心,你就把函数和类分开了,混淆了你的控制流。不管怎样,你都是在 callback hell 里。
下面是一个 Java gRPC 官方教程 中的一个示例:
1 | public void routeChat() throws Exception { |
上面代码官方的初学者教程,它不是一个完整的例子,发送代码是同步的,而接收代码是异步的。在 Java 中,你可能会为你的 HTTP 服务器、gRPC、数据库和其它任何东西处理不同的异步类型,你需要在所有这些服务器之间使用适配器,这很快就会变得一团糟。
同时这里如果使用锁也很危险,你需要小心跨网络调用持有锁。锁和回调也很容易犯错误。例如,如果一个同步方法调用一个返回 ListenableFuture 的函数,然后附加一个内联回调,那么这个回调也需要一个同步块,即使它嵌套在父方法内部。
终于到了我们的主角——goroutines。它是 Go 语言版本的线程。像它语言(比如:Java)中的线程一样,每个 gooutine 都有自己的堆栈。goroutine 可以与其它 goroutine 并行执行。与线程不同,goroutine 的创建成本非常低:它不绑定到 OS 线程上,它的堆栈开始非常小(初始只有 2 K),但可以根据需要增长。当你创建一个 goroutine 时,你实际上是在分配一个 closure,并在运行时将其添加到队列中。
在内部实现中,Go 的运行时有一组执行程序的 OS 线程(通常每个内核一个线程)。当一个线程可用并且一个 goroutine 准备运行时,运行时将这个 goroutine 调度到线程上,执行应用程序逻辑。如果一个运行例程阻塞了像 mutex 或 channel 这样的东西时,运行时将它添加到阻塞的运行 goroutine 集合中,然后将下一个就绪的运行例程调度到同一个 OS 线程上。
这也适用于网络:当一个线程程序在未准备好的套接字上发送或接收数据时,它将其 OS 线程交给调度器。这听起来是不是很熟悉?Go 的调度器很像事件驱动服务器中的主循环。除了仅仅依赖于 select 和专注于文件描述符之外,调度器处理语言中可能阻塞的所有内容。
你不再需要避免阻塞调用,因为调度程序可以有效地利用 CPU。可以自由地生成许多 goroutine(可以每个请求一个!),因为创建它们的成本很低,而且不会争夺 CPU,你不需要担心线程池和执行器服务,因为运行时实际上有一个大的线程池。
简而言之,你可以用干净的命令式风格编写简单的阻塞应用程序代码,就像在编写一个基于线程的服务器一样,但你保留了事件驱动服务器的所有效率优势,两全其美。这类代码可以很好地跨框架组合。你不需要 streamobserver 和 ListenableFutures 之间的这类适配器。
下面让我们看一下来自 Go gRPC 官方教程 的相同示例。可以发现这里的控制流比 Java 示例中的更容易理
解,因为发送和接收代码都是同步的。在这两个 goroutines 中,我们都可以在一个 for 循环中调用 stream.Recv 和stream.Send。不再需要回调、子类或执行器这些东西了。
1 | stream, err := client.RouteChat(context.Background()) |
如何你使用 Java 这门语言,到目前为止,你要么必须生成数量不合理的线程,要么必须处理 Java 特有的回调地狱。令人高兴的是,JEP 444 中增加了 virtual threads,这看起来很像 Go 语言中的 goroutine。
创建虚拟线程的成本很低。JVM 将它们调度到平台线程(platform threads,内核中的真实线程)上。平台线程的数量是固定的,一般每个内核一个平台线程。当一个虚拟线程执行阻塞操作时,它会释放它的平台线程,JVM
可能会将另一个虚拟线程调度到它上面。与 gooutine 不同,虚拟线程调度是协作的: 虚拟线程在执行阻塞操作之前不会服从于调度程序。这意味着紧循环可以无限期地保持线程。目前不清楚这是实现限制还是有更深层次的问题。Go 以前也有这个问题,直到 1.14 才实现了完全抢占式调度(可见 GopherCon 2021)。
Java 的虚拟线程现在可以预览,预计在 JDK 21 中成为 stable(官方消息是预计 2023 年 9 月发布)状态。哈哈,很期待到时候能删除大量的 ListenableFutures。每当引入一种新的语言或运行时特性时,都会有一个漫长的迁移过渡期,个人认为 Java 生态系统在这方面还是过于保守了。
]]>大部分人在日常的业务开发中,其实很少去关注数据库的事务相关问题,基本上都是 CURD 一把梭。正好最近在看 MySQL 的相关基础知识,其中对于幻读问题之前一直没有理解深刻,今天就来聊聊「InnoDB 是如何解决幻读的」,话不多说,下面进入主题。
事务隔离是数据库处理的基础之一,是 ACID 中的 I
。在 MySQL 的 InnoDB 引擎中支持在 SQL:1992 标准中的四种事务隔离级别,如下图所示,其中 P1 表示脏读(Dirty read),P2 表示不可重复读(Dirty read),P3 表示幻读(Phantom)。
为什么需要定义这么多隔离呢?从上图中也能猜出一二了,InnoDB 提供多个隔离级别主要原因是:让使用者可以在多个事务同时进行更改和执行查询时微调性能与结果的可靠性、一致性和可再现性之间的平衡的设置。是一种性能与结果可靠性间的 trade off
。
在聊「InnoDB 解决幻读方式」前我们需要先了解幻读是什么,官方文档的描述如下:
A row that appears in the result set of a query, but not in the result set of an earlier query.
其中我加粗的「result set」是关键的地方,两次查询返回的是结果集,说明必须是一个范围查询操作。总结下,幻读就是:在同一个事务中,在前后两次查询相同范围时,两次查询得到的结果是不一致的。所以幻读会产生数据一致性问题。
为了解决上述的幻读问题,InnoDB 引入了两种锁,分别是「间隙锁」和「next-key 锁」。下面通过一个示例来描述这两种锁的作用分别是什么。假如存在一个这样的 B+ Tree 的索引结构,结构中有 4 个索引元素分别是:9527、9530、9535、9540。
此时当我们使用如下 SQL 通过主键索引查询一条记录,并且加上 X 锁(排它锁)时:
1 | select * from user where id = 9527 for update; |
这时就会产生一个记录锁(也就是行锁),锁定 id = 9527
这个索引。
在被锁定的记录(这里是 id = 9527)的锁释放之前,其它事务无法对这条被锁定记录做任何操作。再回忆一下,前面说的幻读定义「在同一个事务中,在前后两次查询相同范围时,两次查询得到的结果是不一致」。注意,这里强调的是范围查询。
InnoDB 要解决幻读问题,就必须得保证在如果在一个事务中,通过如下这条语句进行锁定时:
1 | select * from user where id > 9530 and id < 9535 for update; |
此时,另外一个语句再执行一如下这条 insert 语句时,需要被阻塞,直到上面这个获得锁的事务释放锁后才能执行。
1 | insert into user(id, name, age) values(9533, 'Jack', 44); |
为此,InnoDB 引入了「间隙锁」,它的主要功能是锁定一段范围内的索引记录。比如上面查询 id > 9530 and id < 9535
的时候,对 B+ Tree 中的(9530,9535)这个开区间范围的索引加间隙锁。
在这种加了间隙锁的情况下,其它事务对这个区间的数据进行插入、更新、删除都会被锁住直到这个获取到锁的事务释放。
这种是在区间之间的情况,你可能想到另外的一种情况:锁定多个区间,如下的一条语句:
1 | select * from user where id > 9530 for update; |
上面这条查询语句是针对 id > 9530
这个条件加锁,那么此时它需要锁定多个索引区间,所以在这种情况下 InnoDB 引入了「next-key 锁」机制。其实 next-key 锁的效果相当于间隙锁和记录锁的合集,记录锁锁定存在的记录行,间隙锁锁住记录行之间的间隙,而 next-key 锁它锁住的是两者之和。
在 InnoDB 中,每个数据行上的非唯一索引列上都会存在一把 next-key 锁,当某个事务持有该数据行的 next-key 锁时,会锁住一段左开右闭区间的数据。因此,当通过 id > 9530
这样一种范围查询加锁时,会加 next-key 锁,锁定区间是范围是:
(9530,9535] (9535,9540] (9540,+∞]
间隙锁(也叫 Gap 锁)和 next-key 锁的区别在于加锁的范围,间隙锁只锁定两个索引之间的引用间隙,而 next-key 锁会锁定多个索引区间,它包含「记录锁」和「间隙锁」。所以,当我们使用了范围查询,不仅仅命中了已存在的 Record 记录,还包含了 Gap 间隙。
虽然在 InnoDB 引擎中通过间隙锁和 next-key 锁的方式解决了幻读问题,但是加锁之后会影响到数据库的并发性能,因此,如果对性能要求较高的业务场景中,建议把隔离级别设置成 RC(READ COMMITTED),这个级别中不存在间隙锁,但是需要考虑到幻读问题会导致的数据一致性。
]]>首先需要明确的是 TCP 是一个可靠传输协议,它的所有特点最终都是为了这个可靠传输服务。在网上看到过很多文章讲 TCP 连接的三次握手
和断开连接的四次挥手
,但是都太过于理论,看完感觉总是似懂非懂。反复思考过后,觉得我自己还是偏工程型的人,要学习这些理论性的知识,最好的方式还是要通过实际案例来理解,这样才会具象深刻。本文通过 Wireshark 抓包来分析 TCP 三次握手
和四次挥手
,如果你也对这些理论感觉似懂非懂,那么强烈建议你也结合抓包实践来强化理解这些理论性的知识。
TCP 建立连接的三次握手是连接的双方协商确认一些信息(Sequence number、Maximum Segment Size、Window Size 等),Sequence number 有两个作用:一个是 SYN 标识位为 1 时作为初始序列号(ISN),则实际第一个数据字节的序列号和相应 ACK 中的确认号就是这个序列号加 1;另一个是 SYN 标识位为 0 时,则是当前会话的 segment(传输层叫 segment,网络层叫 packet,数据链路层叫 frame)的第一个数据字节的累积序列号。Maximum Segment Size 简称 MSS,表示最大一个 segment 中能传输的信息(不含 TCP、IP 头部)。Window Size 表示发送方接收窗口的大小。下面看看我在本地访问博客 mghio 的三次握手过程:
图中三个小红框表示与服务器建立连接的三次握手。
到这一步,client 端的 60469 端口已经是 ESTABLISHED 状态了。
可以看到,其实三次握手的核心目的就是双方互相告知对象自己的 Sequence number,蓝框是 client 端的初始 Sequence number 和 client 端回复的 ACK,绿框是 server 端的初始 Sequence number 和 client 端回复的 ACK。这样协商好初始 Sequence number 后,发送数据包时发送端就可以判断丢包和进行丢包重传了。
三次握手还有一个目的是协商一些信息(上图中黄色方框是 Maximum Segment Size,粉色方框是 Window Size)。
到这里,就可以知道平常所说的建立TCP连接
本质是为了实现 TCP 可靠传输做的前置准备工作,实际上物理层并没有这个连接在那里。TCP 建立连接之后时拥有和维护一些状态信息,这个状态信息就包含了 Sequence number、MSS、Window Size 等,TCP 握手就是协商出来这些初始值。而这些状态才是我们平时所说的 TCP 连接的本质。因为这个太重要了,我还要再次强调一下,TCP 是一个可靠传输协议,它的所有特点最终都是为了这个可靠传输服务。
下面再来看看,当关闭浏览器页面是发生断开连接的四次挥手过程:
相信你已经发现了,上图抓包抓到的不是四次挥手,而是三次挥手,这是为何呢?
这是由于 TCP 的时延机制(因为系统内核并不知道应用能不能立即关闭),当被挥手端(这里是 server 的 443 端口)第一次收到挥手端(这里是 client 的 63612 端口)的 FIN 请求时,并不会立即发送 ACK,而是会经过一段延迟时间后再发送,但是此时被挥手端也没有数据发送,就会向挥手端发送 FIN 请求,这里就可能造成被挥手端发送的 FIN 与 ACK 一起被挥手端收到,导致出现第二、三次挥手合并为一次的现象,也就最终呈现出“三次挥手”的情况。
断开连接四次挥手分为如下四步(假设没有出现挥手合并的情况):
下面是 TCP 连接流转状态图(其中 CLOSED 状态是虚拟的,实际上并不存在),这个图很重要,记住这个图后基本上所有的 TCP 网络问题就可以解决(图片来源)。
其中比较难以理解的是 TIME_WAIT 状态,主动关闭的那一端会经历这个状态。这一端停留在这个状态的最长时间是 Maximum segment lifetime(MSL)的 2 倍,大部分时候被简称之为 2MSL。存在 TIME_WAIT 状态有如下两个原因:
嘿嘿,这是个经典的面试题,其实大部分人都背过挥手是四次的原因:因为 TCP 是全双工(双向)的,所以回收需要四次……。但是再反问下:握手也是双向的,但是为什么是只要三次呢?
网上流传的资料都说 TCP 是双向的,所以回收需要四次,但是握手也是双向(握手双方都在告知对方自己的初始 Sequence number),那么为什么就不用四次握手呢?所以凡事需要多问几个为什么,要有探索和怀疑精神。
你再仔细回看上面三次握手的第二步(SYN + ACK),其实是可以拆分为两步的:第一步回复 ACK,第二步再发 SYN 也是完全可以的,只是效率会比较低,这样的话三次握手不也变成四次握手了。
看起来四次挥手主要是收到第一个 FIN 包后单独回复了一个 ACK 包这里多了一次,如果能像握手那样也回复 FIN + ACK 那么四次挥手也就变成三次了。这里再贴一下上面这个挥手的抓包图:
这个图中第二个红框就是 server 端回复的 FIN + ACK 包,这样四次挥手变成三次了(如果一个包算一次的话)。这里使用四次挥手原因主要是:被动关闭端在收到 FIN 后,知道主动关闭端要关闭了,然后系统内核层会通知应用层要关闭,此时应用层可能还需要做些关闭前的准备工作,可能还有数据没发送完,所以系统内核先回复一个 ACK 包,然后等应用层准备好了主动调 close 关闭时再发 FIN 包。
而握手过程中就没有这个准备过程了,所以可以立即发送 SYN + ACK(在这里的两步合成一步了,提高效率)。挥手过程中系统内核在收到对方的 FIN 后,只能 ACK,不能主动替应用来 FIN,因为系统内核并不知道应用能不能立即关闭。
TCP 是一个很复杂的协议,为了实现可靠传输以及处理各种网络传输中的 N 多问题,有一些很经典的解决方案,比如其中的网络拥塞控制算法、滑动窗口、数据重传等。强烈建议你去读一下 rfc793 和 TCP/IP 详解 卷1:协议 这本书。
如果你是那些纯看理论就能掌握好一门技能,然后还能举三反一的人,那我很佩服你;如果不是,那么学习理论知识注意要结合实践来强化理解理论,要经过反反复复才能比较好地掌握一个知识,讲究技巧,必要时要学会通过工具来达到目的。
最后 TCP 所有特性基本上核心都是为了实现可靠传输这个目标来服务的,然后有一些是出于优化性能的目的。
]]>在 Spring 框架中有很多实用的功能,不需要写大量的配置代码,只需添加几个注解即可开启。 其中一个重要原因是那些 @EnableXXX 注解,它可以让你通过在配置类加上简单的注解来快速地开启诸如事务管理(@EnableTransactionManagement)、Spring MVC(@EnableWebMvc)或定时任务(@EnableScheduling)等功能。这些看起来简单的注解语句提供了很多功能,但它们的内部机制从表面上看却不太明显。 一方面,对于使用者来说用这么少的代码获得这么多实用的功能是很好的,但另一方面,如果你不了解某个东西的内部是如何工作的,就会使调试和解决问题更加困难。
Spring 框架中那些 @EnableXXX 注解的设计目标是允许用户用最少的代码来开启复杂使用的功能。 此外,用户必须能够使用简单的默认值,或者允许手动配置该代码。最后,代码的复杂性要向框架使用者隐藏掉。 简而言之,让使用者设置大量的 Bean,并选择性地配置它们,而不必知道这些 Bean 的细节(或真正被设置的内容)。下面来看看具体的几个例子:
首先要知道的是,@EnableXXX 注解并不神奇。实际上在 BeanFactory 中并不知道这些注解的具体内容,而且在 BeanFactory 类中,核心功能和特定注解(如 @EnableWebMvc)或它们所存放的 jar 包(如 spring-web)之间没有任何依赖关系。 让我们看一下 @EnableScheduling,下面看看它是如何工作的。 定义一个 SchedulingConfig 配置类,如下所示:
1 |
|
上面的内容没有什么特别之处。只是一个用 @EnableScheduling 注释的标准 Java 配置。@EnableScheduling 让你以设定的频率执行某些方法。例如,你可以每 10 分钟运行 BankService.transferMoneyToMghio()。 @EnableScheduling 注解源码如下:
1 | (ElementType.TYPE) |
上面的 EnableScheduling 注解,我们可以看到它只是一个标准的类级注解(@Target/@Retention),应该包含在 JavaDocs 中(@Documented),但是它有一个 Spring 特有的注解(@Import)。 @Import 是将一切联系起来的关键。 在这种情况下,由于我们的 SchedulingConfig 被注解为 @EnableScheduling,当 BeanFactory 解析文件时(内部是ConfigurationClassPostProcessor 在解析它),它也会发现 @Import(SchedulingConfiguration.class) 注解,它将导入该值中定义的类。 在这个注解中,就是 SchedulingConfiguration。
这里导入是什么意思呢?在这种情况下,它只是被当作另一个 Spring Bean。 SchedulingConfiguration 实际上被注解为@Configuration,所以 BeanFactory 会把它看作是另一个配置类,所有在该类中定义的 Bean 都会被拉入你的应用上下文,就像你自己定义了另一个 @Configuration 类一样。 如果我们检查 SchedulingConfiguration,我们可以看到它只定义了一个Bean(一个Post Processor),它负责我们上面描述的调度工作,源码如下:
1 |
|
也许你会问,如果想配置 SchedulingConfiguration 中定义的 bean 呢? 这里也只是在处理普通的Bean。 所以你对其它 Bean 所使用的机制也适用于此。 在这种情况下,ScheduledAnnotationBeanPostProcessor 使用一个标准的 Spring Bean 生命周期(postProcessAfterInitialization)来发现应用程序上下文何时被刷新。 当符合条件时,它会检查是否有任何 Bean 实现了 SchedulingConfigurer,如果有,就使用这些 Bean 来配置自己。 其实这一点并不明细(在 IDE 中也不太容易找到),但它与 BeanFactory 是完全分离的,而且是一个相当常见的模式,一个 Bean 被用来配置另一个 Bean。 而现在我们可以把所有的点连接起来,它(在某种程度上)很容易找到(你可以 Google 一下文档或阅读一下 JavaDocs)。
在上一个示例中,我们讨论了像 @EnableScheduling 这样的注解如何使用 @Import 来导入另一个 @Configuration 类并使其所有的 Bean 对你的应用程序可用(和可配置)。但是如果你想根据某些配置加载不同的 Bean 集,会发生什么呢? @EnableTransactionManagement 就是一个很好的例子。TransactioConfig 定义如下:
1 |
|
再一次,上面没有什么特别之处。只是一个用@EnableTransactionManagement注释的标准Java配置。唯一与之前的例子有些不同的是,用户为注释指定了一个参数(mode=AdviceMode.ASPECTJ)。 @EnableTransactionManagement注解本身看起来像这样。
1 | (ElementType.TYPE) |
和前面一样,一个相当标准的注解,尽管这次它有一些参数。 然而,正如前文提到,@Import 注解是将一切联系在一起的关键,这一点再次得到证实。 但区别在于,这次我们导入的是 TransactionManagementConfigurationSelector 这个类,通过源码可以发现,其实它不是一个被 @Configuration 注解的类。 TransactionManagementConfigurationSelector 是一个实现ImportSelector 的类。 ImportSelector 的目的是让你的代码选择在运行时加载哪些配置类。 它有一个方法,接收关于注解的一些元数据,并返回一个类名数组。 在这种情况下,TransactionManagementConfigurationSelector 会查看模式并根据模式返回一些类。其中的 selectImports 方法源码如下:
1 |
|
这些类中的大多数是 @Configuration(例如 ProxyTransactionManagementConfiguration),通过前文介绍我们知道它们会像前面一样工作。 对于 @Configuration 类,它们被加载和配置的方式与我们之前看到的完全一样。 所以简而言之,我们可以使用 @Import 和 @Configuration 类来加载一套标准的 Bean,或者使用 @Import 和 ImportSelector 来加载一套在运行时决定的 Bean。
@Import 支持的最后一种情况,即当你想直接处理 BeanRegistry(工厂)时。如果你需要操作Bean Factory或者在Bean定义层处理Bean,那么这种情况就适合你,它与上面的情况非常相似。 你的 AspectJProxyConfig 可能看起来像。
1 |
|
再一次,上面定义没有什么特别的东西。只是一个用 @EnableAspectJAutoProxy 注释的标准 Java 配置。 下面是@EnableAspectJAutoProxy 的源代码。
1 | (ElementType.TYPE) |
和前面一样,@Import 是关键,但这次它指向 AspectJAutoProxyRegistrar,它既没有 @Configuration 注解,也没有实现 ImportSelector 接口。 这次使用的是实现了 ImportBeanDefinitionRegistrar。 这个接口提供了对 Bean 注册中心(Bean Registry)和注解元数据的访问,因此我们可以在运行时根据注解中的参数来操作 Bean 注册表。 如果你仔细看过前面的示例,你可以看到我们忽略的类也是 ImportBeanDefinitionRegistrar。 在 @Configuration 类不够用的时候,这些类会直接操作 BeanFactory。
所以现在我们已经涵盖了 @EnableXXX 注解使用 @Import 将各种 Bean 引入你的应用上下文的所有不同方式。 它们要么直接引入一组 @Configuration 类,这些类中的所有 Bean 都被导入到你的应用上下文中。 或者它们引入一个 ImportSelector 接口实现类,在运行时选择一组 @Configuration 类并将这些 Bean 导入到你的应用上下文中。 最后,他们引入一个ImportBeanDefinitionRegistrars,可以直接与 BeanFactory 在 BeanDefinition 级别上合作。
总的来说,个人认为这种将 Bean 导入应用上下文的方法很好,因为它使框架使用者的使用某个功能非常容易。不幸的是,它模糊了如何找到可用的选项以及如何配置它们。 此外,它没有直接利用 IDE 的优势,所以很难知道哪些 Bean 正在被创建(以及为什么)。 然而,现在我们知道了 @Import 注解,我们可以使用 IDE 来挖掘一下每个注解及其相关的配置类,并了解哪些 Bean 正在被创建,它们如何被添加到你的应用上下文中,以及如何配置它们。 希望对你有帮助~
]]>在介绍二维码之前,先来看看它的“大哥”一维码,一维码也叫条形码(好像在日常生活中都是叫这个),它是由不同宽度的黑条和白条按照一定的顺序排列组成的平行线图案,它的宽度记录着数据信息,长度没有记录信息,条形码常用于标出物品的生产国、制造厂家、商品名称、生产日期、图书分类号、邮件起止地点、类别、日期等信息,比如大部分食品包装袋背后都会印有条形码。
全球的条形码标准都是由一个叫GS1
的非营利性组织管理和维护的,通常情况下条形码由 95
条红或黑色的平行竖线组成,前三条是由黑-白-黑
组成,中间的五条由白-黑-白-黑-白
组成,最后的三条和前三条一样也是由黑-白-黑
组成,这样就把一个条形码分为左、右两个部分。剩下的 84 (95-3-5-3=84) 条按每 7 条一组分为 12 组,每组对应着一个数字,不同的数字的具体表示因编码方式而有所不同,不过都遵循着一个规律:右侧部分每一组的白色竖线条数都是奇数个。
这样不管你是正着扫描还是反着扫描都是可以识别的。
中国使用的条形码大部分都是 EAN-13
格式的,条形码数字编码的含义从左至右分别是前三位标识来源 国家编码 ,比如中国为:690–699,后面的 4 ~ 8 位数字代表的是厂商公司代码,但是位数不是固定的,紧接着后面 的 9~12 位是商品编码,第 13 位是校验码,这就意味着公司编码越短,剩余可用于商品编码的位数也越多,可表示的商品也就越多,当然公司代码出售价格也相应更昂贵,另外用在商品上的 EAN-13
条码是要到 国家物品编码中心 去申请的。
二维码 是在一维码的基础之上扩展出来的,二维码有不同的种类,大体上可以分为这两种 ① 堆叠式/行排式二维条码 ② 矩阵式二维码,其中矩阵式二维码最为流行(下文的二维码指矩阵式二维码),它与一维码所不同的是它的宽度和长度均有记录数据信息,存储的数据量更大,除此之外还增加了“定位点”和“容错机制”。通过“定位点”使读码机正确识别进行解读,所以二维码不管是从何种方向读取都是可以被识别的。
“容错机制”可以在没有识别到全部条码时也能正确推断和还原出原始的条码信息,维码的纠错级别,按照不同的纠错率(全部码字与可以纠错的码字的比率)分为 L (约 7%)、M (约 15%)、Q (约 25%)、H (约 30%) 四个不同的级别。比如下面的「mghio」公众号二维码尽管中间有公众号头像,但是依然可以正确识别出来就是这个“容错机制”的功能。不管是条形码(一维码)还是二维码其本质上都是对信息的编码,区别只是对信息的编码方式有所不同。
二维码的版本从 1 ~ 40 共 40 个不同的版本,每个版本的基本结构都是相同的,所不同的是每个版本的码元(构成二维码的方形黑白点)数量不同,从版本 1 (21 × 21 码元) 至版本 40 (177 × 177 码元) 依次递增。
二维码可以分为这几不同的功能区域,分别是版本信息
、格式信息
、数据及容错
、定位标志
、校正标志
等主要区域,其中定位标识用来对二维码进行定位,版本信息表示二维码的版本,有 40 种不同版本的二维码,从版本 1 到版本 40 ,每一版本比前一个版本每边增加 4 个码元,数据及容错用于实际保存的二维码数据信息和用于修正二维码损坏带来的错误的纠错码字,二维码的编码规则比较复杂,感兴趣的朋友可以去看看它的编码规范。
以上介绍的这种普通二维码只是对文字、网址、电话等信息进行编码,不支持图片、音频、视频等内容,且生成二维码后内容无法改变,在信息内容较多时生成的二维码图案复杂,不容易识别和打印,正是由于存在这些特性故称之为静态二维码。静态二维码的好处就是无需联网也能识别,但是有些时候在线下场景经常需要打印二维码出来让用户去扫码,或者在一些运营场景下需要对用户的扫码情况进行数据统计和分析,再使用普通的二维码就无法提供这些功能了,这时候就要使用动态二维码了。
动态二维码也称之为活码,关键就在于“活”,“活”就是内容可变,但是二维码不变。活码的优点其实就是静态二维码的缺点,支持随时修改二维码的内容且二维码图案不变,可跟踪扫描统计数据,支持存储大量文字、图片、文件、音视、视频等内容,同时生成的图案简单易扫。
实际上二维码是按照指定的规则编码后的一串字符串,通常大部分情况下是一个网址,在二维码出现之前,我们访问一个网址是打开浏览器输入网址后按下回车即可访问相应的网站,而有了二维码之后,我们使用软件扫描二维码,软件首先会做一次从二维码到文本的解析、转换,然后根据解析出来的文本结果判断是否是链接,是则跳转到这个链接,尽管对我们而言操作方式改变了,但其原理是相同的。
既然二维码背后是网址,要解决静态二维码生成后内容无法修改的问题,是不是只要把网址做成“活的”就行了,即可操控内容的链接,对外暴露的依然还是同一个网址,服务端只需要对这个网址做个二次跳转就行,实际上“活码”就是这么干的,这个对外暴露固定不变的网址也称为“活址”。此时脑海里浮现着计算机科学界一句著名的话:
计算机科学的任何一个问题,都可以通过增加一个中间层来解决。
上面的这个“活址”就是一个“中间层”的角色,屏蔽和隔离了二维码内容的变化,对外始终都只是暴露一个固定的网址。
比较项 | 普通二维码 | 动态二维码(活码) |
---|---|---|
内容修改 | 不支持 | 可以随时修改 |
内容类型 | 支持文字、网址、电话等 | 支持文字、图片、文件、音视、视频等内容 |
二维码图案 | 内容越多越复杂 | 活码图案简单 |
数据统计 | 不支持 | 支持 |
样式排版 | 不支持 | 支持 |
本文主要对条形码、静态二维码和动态二维码的一些基本概念做了简单的介绍,想要深入了解二维码的实现细节和原理的朋友可以看看耗子叔的这篇文章 二维码的生成细节和原理 或者到 官网 查看相关文档。虽然现在绝大部分人对于二维码都非常熟悉,几乎每天都会进行着扫码操作,不过在人们的大脑中依然有一个“根深蒂固”的认知,认为一个二维码扫描之后只会出现一种固定的结果,在接触 活码 这个概念之前俺也是。你知道的越多,不知道的也越多。
参考资料
]]>在并发编程中,当多个线程同时访问同一个共享的可变变量时,会产生不确定的结果,所以要编写线程安全的代码,其本质上是对这些可变的共享变量的访问操作进行管理。导致这种不确定结果的原因就是可见性
、有序性
和原子性
问题,Java
为解决可见性和有序性问题引入了 Java 内存模型,使用互斥
方案(其核心实现技术是锁
)来解决原子性问题。这篇先来看看解决可见性、有序性问题的 Java 内存模型(JMM)。
Java 内存模型在维基百科上的定义如下:
The Java memory model describes how threads in the Java programming language interact through memory. Together with the description of single-threaded execution of code, the memory model provides the semantics of the Java programming language.
内存模型限制的是共享变量,也就是存储在堆内存中的变量,在 Java 语言中,所有的实例变量、静态变量和数组元素都存储在堆内存之中。而方法参数、异常处理参数这些局部变量存储在方法栈帧之中,因此不会在线程之间共享,不会受到内存模型影响,也不存在内存可见性问题。
通常,在线程之间的通讯方式有共享内存和消息传递两种,很明显,Java 采用的是第一种即共享的内存模型,在共享的内存模型里,多线程之间共享程序的公共状态,通过读-写内存的方式来进行隐式通讯。
从抽象的角度来看,JMM 其实是定义了线程和主内存之间的关系
,首先,多个线程之间的共享变量存储在主内存之中,同时每个线程都有一个自己私有的本地内存,本地内存中存储着该线程读或写共享变量的副本(注意:本地内存是 JMM 定义的抽象概念,实际上并不存在)。抽象模型如下图所示:
在这个抽象的内存模型中,在两个线程之间的通信(共享变量状态变更)时,会进行如下两个步骤:
JMM 本质上是在硬件(处理器)内存模型之上又做了一层抽象,使得应用开发人员只需要了解 JMM 就可以编写出正确的并发代码,而无需过多了解硬件层面的内存模型。
在日常的程序开发中,为一些共享变量赋值的场景会经常碰到,假设一个线程为整型共享变量 count
做赋值操作(count = 9527;
),此时就会有一个问题,其它读取该共享变量的线程在什么情况下获取到的变量值为 9527
呢?如果缺少同步的话,会有很多因素导致其它读取该变量的线程无法立即甚至是永远都无法看到该变量的最新值。
比如缓存就可能会改变写入共享变量副本提交到主内存的次序,保存在本地缓存的值,对于其它线程是不可见的;编译器为了优化性能,有时候会改变程序中语句执行的先后顺序,这些因素都有可能会导致其它线程无法看到共享变量的最新值。
在文章开头,提到了 JMM
主要是为了解决可见性
和有序性
问题,那么首先就要先搞清楚,导致可见性
和有序性
问题发生的本质原因是什么?现在的服务绝大部分都是运行在多核 CPU 的服务器上,每颗 CPU 都有自己的缓存,这时 CPU 缓存与内存的数据就会有一致性问题了,当一个线程对共享变量的修改,另外一个线程无法立刻看到。导致可见性问题的本质原因是缓存。
有序性是指代码实际的执行顺序和代码定义的顺序一致,编译器为了优化性能,虽然会遵守 as-if-serial
语义(不管怎么重排序,在单线程下的执行结果不能改变),不过有时候编译器及解释器的优化也可能引发一些问题。比如:双重检查来创建单实例对象。下面是使用双重检查来实现延迟创建单例对象的代码:
1 | /** |
这里的 instance = new DoubleCheckedInstance();
,看起来 Java
代码只有一行,应该是无法就行重排序的,实际上其编译后的实际指令是如下三步:
上面的第 2 步和第 3 步如果改变执行顺序也不会改变单线程的执行结果,也就是说可能会发生重排序,下图是一种多线程并发执行的场景:
此时线程 B 获取到的 instance
是没有初始化过的,如果此来访问 instance
的成员变量就可能触发空指针异常。导致有序性
问题的本质原因是编译器优化。那你可能会想既然缓存和编译器优化是导致可见性问题和有序性问题的原因,那直接禁用掉不就可以彻底解决这些问题了吗,但是如果这么做了的话,程序的性能可能就会受到比较大的影响了。
其实可以换一种思路,能不能把这些禁用缓存和编译器优化的权利交给编码的工程师来处理,他们肯定最清楚什么时候需要禁用,这样就只需要提供按需禁用缓存和编译优化的方法即可,使用比较灵活。因此Java 内存模型
就诞生了,它规范了 JVM 如何提供按需禁用缓存和编译优化的方法,规定了 JVM 必须遵守一组最小的保证,这个最小保证规定了线程对共享变量的写入操作何时对其它线程可见。
顺序一致性模型是一个理想化后的理论参考模型,处理器和编程语言的内存模型的设计都是参考的顺序一致性模型理论。其有如下两大特性:
在工程师视角下的顺序一致性模型如下:
顺序一致性模型有一个单一的全局内存,这个全局内存可以通过左右摇摆的开关可以连接到任意一个线程,每个线程都必须按照程序的顺序来执行内存的读和写操作。该理想模型下,任务时刻都只能有一个线程可以连接到内存,当多个线程并发执行时,就可以通过开关就可以把多个线程的读和写操作串行化。
顺序一致性模型中,所有操操作完全按照顺序串行执行,但是在 JMM 中就没有这个保证了,未同步的程序
在 JMM 中不仅程序的执行顺序是无序的,而且由于本地内存的存在,所有线程看到的操作顺序也可能会不一致,比如一个线程把写共享变量保存在本地内存中,在还没有刷新到主内存前,其它线程是不可见的,只有更新到主内存后,其它线程才有可能看到。
JMM 对在正确同步的程序
做了顺序一致性的保证,也就是程序的执行结果和该程序在顺序一致性内存模型中的执行结果相同。
Happens-Before
规则是 JMM 中的核心概念,Happens-Before
概念最开始在 这篇论文 提出,其在论文中使用 Happens-Before
来定义分布式系统之间的偏序关系。在 JSR-133 中使用 Happens-Before
来指定两个操作之间的执行顺序。
JMM 正是通过这个规则来保证跨线程的内存可见性,Happens-Before
的含义是前面一个对共享变量的操作结果对该变量的后续操作是可见的
,约束了编译器的优化行为,虽然允许编译器优化,但是优化后的代码必须要满足 Happens-Before
规则,这个规则给工程师做了这个保证:同步的多线程程序是按照 Happens-Before
指定的顺序来执行的。目的就是为了在不改变程序(单线程或者正确同步的多线程程序)执行结果的前提下,尽最大可能的提高程序执行的效率
。
JSR-133
规范中定了如下 6 项 Happens-Before
规则:
Happens-Before
该线程中的任意后续操作Happens-Before
于后面对这个锁的加锁操作volatile
类型的变量的写操作,Happens-Before
与任意后面对这个 volatile
变量的读操作Happens-Before
于操作 B,并且操作 B Happens-Before
于操作 C,则操作 A Happens-Before
于操作 CthreadB.start()
启动线程 B,那么线程 A 的 start()
操作 Happens-Before
于线程 B 的任意操作threadB.join()
并成功返回,那么线程 B 中的任意操作 Happens-Before
于线程 A 从 threadB.join()
操作成功返回JMM 的一个基本原则是:只要不改变单线程和正确同步的多线程的执行结果,编译器和处理器随便怎么优化都可以,实际上对于应用开发人员对于两个操作是否真的被重排序并不关心,真正关心的是执行结果不能被修改。因此 Happens-Before
本质上和 sa-if-serial
的语义是一致的,只是 sa-if-serial
只是保证在单线程下的执行结果不被改变。
本文主要介绍了内存模型的相关基础知识和相关概念,JMM 屏蔽了不同处理器内存模型之间的差异,在不同的处理器平台上给应用开发人员抽象出了统一的 Java 内存模型(JMM)
。常见的处理器内存模型比 JMM 的要弱,因此 JVM 会在生成字节码指令时在适当的位置插入内存屏障(内存屏障的类型会因处理器平台而有所不同)来限制部分重排序。
在我们日常开发的分层结构的应用程序中,为了各层之间互相解耦,一般都会定义不同的对象用来在不同层之间传递数据,因此,就有了各种 XXXDTO
、XXXVO
、XXXBO
等基于数据库对象派生出来的对象,当在不同层之间传输数据时,不可避免地经常需要将这些对象进行相互转换。
此时一般处理两种处理方式:① 直接使用 Setter
和 Getter
方法转换、② 使用一些工具类进行转换(e.g. BeanUtil.copyProperties
)。第一种方式如果对象属性比较多时,需要写很多的 Getter/Setter
代码。第二种方式看起来虽然比第一种方式要简单很多,但是因为其使用了反射,性能不太好,而且在使用中也有很多陷阱。而今天要介绍的主角 MapStruct 在不影响性能的情况下,同时解决了这两种方式存在的缺点。
MapStruct
是一个代码生成器,它基于约定优于配置方法极大地简化了 Java bean
类型之间映射的实现。自动生成的映射转换代码只使用简单的方法调用,因此速度快、类型安全而且易于理解阅读,源码仓库 Github
地址 MapStruct。总的来说,有如下三个特点:
MapStruct
的使用比较简单,只需如下三步即可。
Gradle
方式为例)1 | dependencies { |
1 | /** |
1 | /** |
需要注意的是,转换器不一定都要使用 Mapper
作为结尾,只是官方示例推荐以 XXXMapper
格式命名转换器名称,这里举例的是最简单的映射情况(字段名称和类型都完全匹配),只需要在转换器类上添加 @Mapper
注解即可,转换器代码如下所示:
1 | /** |
通过下面这个简单的测试来校验转换结果是否正确,测试代码如下:
1 | /** |
测试结果正常通过,说明使用 DoctorMapper
转换器达到我们的预期结果。
在以上示例中,使用 MapStruct
通过简单的三步就实现了 Doctor
到 DoctorDTO
的转换,那么,MapStruct
是如何做到的呢?其实通过我们定义的转换器可以发现,转换器是接口类型的,而我们知道在 Java
中,接口是无法提供功能的,只是定义规范,具体干活的还是它的实现类。
因此我们可以大胆猜想,MapStruct
肯定给我们定义的转换器接口(DoctorMapper
)生成了实现类,而通过 Mappers.getMapper(DoctorMapper.class)
获取到的转换器实际上是获取到了转化器接口的实现类。下面通过在测试类中 debug
来验证一下:
通过 debug
可以看出,DoctorMapper.INSTANCE
获取到的是接口的实现类 DoctorMapperImpl
。这个转换器接口实现类是在编译期自动生成的,Gradle
项目是在 build/generated/sources/anotationProcessor/Java
下(Maven
项目在 target/generated-sources/annotations
目录下),生成以上示例转换器接口的实现类源码如下:
可以发现,自动生成的代码和我们平时手写的差不多,简单易懂,代码完全在编译期间生成,没有运行时依赖。和使用反射的实现方式相比还有一个有点就是,出错时很容易去 debug
实现源码来定位,而反射相对来说定位问题就要困难得多了。
从上文的示例可以看出,当属性名称和类型完全一致时,我们只需要定义一个转换器接口并添加 @Mapper
注解即可,然后 MapStruct
会自动生成实现类完成转换。示例代码如下:
1 | /** |
1 | /** |
1 | /** |
当对象属性类型相同但是属性名称不一样时,通过 @Mapping
注解来手动指定转换。示例代码如下:
1 | /** |
1 | /** |
1 | /** |
有时候,对于某些类型(比如:一个类的属性是自定义的类),无法以自动生成代码的形式进行处理。此时我们需要自定义类型转换的方法,在 JDK 7
之前的版本,就需要使用抽象类来定义转换 Mapper
了,在 JDK 8
以上的版本可以使用接口的默认方法来自定义类型转换的方法。示例代码如下:
1 | /** |
1 | /** |
1 | /** |
1 | /** |
1 | /** |
在一些实际业务编码的过程中,不可避免地需要将多个对象转化为一个对象的场景,MapStruct
也能很好的支持,对于这种最终返回信息来源于多个类,我们可以通过配置来实现多对一的转换。示例代码如下:
1 | /** |
1 | /** |
1 | /** |
从这个示例中的转换器(AddressMapper
)可以看出,当属性名称和类型完全匹配时同样可以自动转换,但是当来源对象有多个属性名称及类型完全和目标对象相同时,还是需要手动配置指定的,因为此时 MapStruct
也无法准确判断应该使用哪个属性转换。
获取转换器的方式根据 @Mapper
注解的 componentModel
属性不同而不同,支持以下四种不同的取值:
Mappers.getMapper(Class)
)来获取CDI bean
,使用 @Inject
注解来获取Spring
的方式,可以通过 @Autowired
注解来获取,在 Spring
框架中推荐使用此方式@javax.inject.Named
和 @Singleton
注解,通过 @Inject
来获取上文的示例中都是通过工厂方式获取的,也就是使用 MapStruct
提供的 Mappers.getMapper(Class<T> clazz)
方法来获取指定类型的 Mapper
。然后在调用的时候就不需要反复创建对象了,方法的最终实现是通过我们定义接口的类加载器加载 MapStruct
生成的实现类(类名称规则为:接口名称 + Impl
),然后调用该类的无参构造器创建对象。核心源码如下所示:
对于依赖注入(dependency injection
),使用 Spring
框架开发的朋友们应该很熟悉了,工作中经常使用。MapStruct
也支持依赖注入的使用方式,并且官方也推荐使用依赖注入的方式获取。使用 Spring
依赖注入的方式只需要指定 @Mapper
注解的 componentModel = "spring"
即可,示例代码如下:
1 | /** |
我们可以使用 @Autowired
获取的原因是 SourceMapper
接口的实现类已经被注册为容器中一个 Bean
了,通过如下生成的接口实现类的代码也可以看到,在类上自动加上了 @Component
注解。
最后还有两个注意事项:① 当两个转换对象的属性不一致时(比如 DoctorDTO
中不存在 Doctor
对象中的某个字段),编译时会出现警告提示。可以在@Mapping
注解中配置 ignore = true
,或者当不一致字段比较多时,可以直接设置 @Mapper
注解的 unmappedTargetPolicy
属性或unmappedSourcePolicy
属性设置为 ReportingPolicy.IGNORE
。② 如果你项目中也使用了 Lombok,需要注意一下 Lombok
的版本至少是 1.18.10
或者以上才行,否则会出现编译失败的情况。刚开始用的时候我也踩到这个坑了。。。
本文介绍了对象转换工具 Mapstruct
库,以安全优雅的方式来减少我们的转换代码。从文中的示例中可以看出,Mapstruct
提供了大量的功能和配置,使我们能够以简单快捷的方式创建从简单到复杂的映射器。文中所介绍到的只是 Mapstruct
库的冰山一角,还有很多强大的功能文中没有提到,感兴趣的朋友可以自行查看 官方使用指南。
异步编程是让程序并发运行的一种手段。它允许多个事情同时发生
,当程序调用需要长时间运行的方法时,它不会阻塞当前的执行流程,程序可以继续运行,当方法执行完成时通知给主线程根据需要获取其执行结果或者失败异常的原因。使用异步编程可以大大提高我们程序的吞吐量,可以更好的面对更高的并发场景并更好的利用现有的系统资源,同时也会一定程度上减少用户的等待时间等。本文我们一起来看看在 Java
语言中使用异步编程有哪些方式。
在 Java
语言中最简单使用异步编程的方式就是创建一个 Thread
来实现,如果你使用的 JDK
版本是 8 以上的话,可以使用 Lambda 表达式 会更加简洁。为了能更好的体现出异步的高效性,下面提供同步版本和异步版本的示例作为对照:
1 | /** |
同步执行的运行如下:
注释掉同步调用版本的代码,得到异步执行的结果如下:
从两次的运行结果可以看出,同步版本耗时 4002 ms
,异步版本执行耗时 2064 ms
,异步执行耗时减少将近一半,可以看出使用异步编程后可以大大缩短程序运行时间。
上面的示例的异步线程代码在 main
方法内开启了一个线程 doOneThing-Thread
用来异步执行 doOneThing
任务,在这时该线程与 main
主线程并发运行,也就是任务 doOneThing
与任务 doOtherThing
并发运行,则等主线程运行完 doOtherThing
任务后同步等待线程 doOneThing
运行完毕,整体还是比较简单的。
但是这个示例只能作为示例使用,如果用到了生产环境发生事故后果自负,使用上面这种 Thread
方式异步编程存在两个明显的问题。
FutureTask
的方式了。自 JDK 1.5
开始,引入了 Future
接口和实现 Future
接口的 FutureTask
类来表示异步计算结果。这个 FutureTask
类不仅实现了 Future
接口还实现了 Runnable
接口,表示一种可生成结果的 Runnable
。其可以处于这三种状态:
FutureTask
没有执行 FutureTask.run()
方法之前FutureTask.run()
方法执行的过程中FutureTask.run()
方法正常执行结果或者调用了 FutureTask.cancel(boolean mayInterruptIfRunning)
方法以及在调用 FutureTask.run()
方法的过程中发生异常结束后FutureTask
类实现了 Future
接口的开启和取消任务、查询任务是否完成、获取计算结果方法。要获取 FutureTask
任务的结果,我们只能通过调用 getXXX()
系列方法才能获取,当结果还没出来时候这些方法会被阻塞,同时这了任务可以是 Callable
类型(有返回结果),也可以是 Runnable
类型(无返回结果)。我们修改上面的示例把两个任务方法修改为返回 String
类型,使用 FutureTask
的方法如下:
1 | private static void testFutureTask() throws ExecutionException, InterruptedException { |
使用 FutureTask
异步编程方式的耗时和上面的 Thread
方式是差不多的,其本质都是另起一个线程去做 doOneThing
任务然后等待返回,运行结果如下:
这个示例中,doOneThing
和 doOtherThing
都是有返回值的任务(都返回 String
类型结果),我们在主线程 main
中创建一个异步任务 FutureTask
来执行 doOneThing
,然后使用 ForkJoinPool.commonPool()
创建线程池(有关 ForkJoinPool
的介绍见 这里),然后调用了线程池的 execute
方法把 futureTask
提交到线程池来执行。
通过示例可以看到,虽然 FutureTask
提供了一些方法让我们获取任务的执行结果、任务是否完成等,但是使用还是比较复杂,在一些较为复杂的场景(比如多个 FutureTask
之间的关系表示)的编码还是比较繁琐,还是当我们调用 getXXX()
系列方法时还是会在任务执行完毕前阻塞调用线程,达不到异步编程的效果,基于这些问题,在 JDK 8
中引入了 CompletableFuture
类,下面来看看如何使用 CompletableFuture
来实现异步编程。
JDK 8
中引入了 CompletableFuture
类,实现了 Future
和 CompletionStage
接口,为异步编程提供了一些列方法,如 supplyAsync
、runAsync
和 thenApplyAsync
等,除此之外 CompletableFuture
还有一个重要的功能就是可以让两个或者多个 CompletableFuture
进行运算来产生结果。代码如下:
1 | /** |
执行结果如下:
在主线程 main
中首先调用了方法 doOneThing()
方法开启了一个异步任务,并返回了对应的 CompletableFuture
对象,我们取名为 doOneThingFuture
,然后在 doOneThingFuture
的基础上使用 CompletableFuture
的 thenCompose()
方法,让 doOneThingFuture
方法执行完成后,使用其执行结果作为 doOtherThing(String parameter)
方法的参数创建的异步任务返回。
我们不需要显式使用 ExecutorService
,在 CompletableFuture
内部使用的是 Fork/Join
框架异步处理任务,因此,它使我们编写的异步代码更加简洁。此外,CompletableFuture
类功能很强大其提供了和很多方便的方法,更多关于 CompletableFuture
的使用请见 这篇。
本文介绍了在 Java
中的 JDK
使用异步编程的三种方式,这些是我们最基础的实现异步编程的工具,在其之上的还有 Guava
库提供的 ListenableFuture 和 Futures 类以及 Spring
框架提供的异步执行能力,使用 @Async
等注解实现异步处理,感兴趣的话可以自行学习了解。
Fork/Join
框架是一种在 JDK 7
引入的线程池,用于并行执行把一个大任务拆成多个小任务并行执行,最终汇总每个小任务结果得到大任务结果
的特殊任务。通过其命名也很容易看出框架主要分为 Fork
和 Join
两个阶段,第一阶段 Fork
是把一个大任务拆分为多个子任务并行的执行,第二阶段 Join
是合并这些子任务的所有执行结果,最后得到大任务的结果。
这里不难发现其执行主要流程:首先判断一个任务是否足够小,如果任务足够小,则直接计算,否则,就拆分成几个更小的小任务分别计算,这个过程可以反复的拆分成一系列小任务。Fork/Join
框架是一种基于 分治 的算法,通过拆分大任务成多个独立的小任务,然后并行执行这些小任务,最后合并小任务的结果得到大任务的最终结果,通过并行计算以提高效率。
下面通过一个计算列表中所有元素的总和
的示例来看看 Fork/Join
框架是如何使用的,总的思路是:将这个列表分成许多子列表,然后对每个子列表的元素进行求和,然后,我们再计算所有这些值的总和就得到原始列表的和了。Fork/Join
框架中定义了 ForkJoinTask
来表示一个 Fork/Join
任务,其提供了 fork()
、join()
等操作,通常情况下,我们并不需要直接继承这个 ForkJoinTask
类,而是使用框架提供的两个 ForkJoinTask
的子类:
没有返回结果
的 Fork/Join
任务。有返回结果
的 Fork/Join
任务。很显然,在这个示例中是需要返回结果的,可以定义 SumAction
类继承自 RecursiveTask
,代码如下:
1 | /** |
这里当列表大小小于 SEQUENTIAL_THRESHOLD
变量的值(阈值)时视为小任务,直接计算求和列表元素结果,否则再次拆分为小任务,运行结果如下:
通过这个示例代码可以发现,Fork/Join
框架 中 ForkJoinTask
任务与平常的一般任务的主要不同点在于:ForkJoinTask
需要实现抽象方法 compute()
来定义计算逻辑,在这个方法里一般通用的实现模板是,首先先判断当前任务是否是小任务,如果是,就执行执行任务,如果不是小任务,则再次拆分为两个子任务,然后当每个子任务调用 fork()
方法时,会再次进入到 compute()
方法中,检查当前任务是否需要再拆分为子任务,如果已经是小任务,则执行当前任务并返回结果,否则继续分割,最后调用 join()
方法等待所有子任务执行完成并获得执行结果。伪代码如下:
1 | if (problem is small) { |
Fork/Join
框架核心思想是把一个大任务拆分成若干个小任务,然后汇总每个小任务的结果最终得到大任务的结果,如果让你设计一个这样的框架,你会如何实现呢?(建议思考一下),Fork/Join
框架的整个流程正如其名所示,分为两个步骤:
Fork/Join
框架使用了如下两个类来完成以上两个步骤:
ForkJoin
任务,在使用框架时首先必须先定义任务,通常只需要继承自 ForkJoinTask
类的子类 RecursiveAction
(无返回结果) 或者 RecursiveTask
(有返回结果)即可。ForkJoinTask
的线程池。大任务拆分出的子任务会添加到当前线程的双端队列
的头部。喜欢思考的你,心中想必会想到这么一种场景,当我们需要完成一个大任务时,会先把这个大任务拆分为多个独立的子任务,这些子任务会放到独立的队列中,并为每个队列都创建一个单独的线程去执行队列里的任务,即这里线程和队列时一对一的关系,那么当有的线程可能会先把自己队列的任务执行完成了,而有的线程则没有执行完成,这就导致一些先执行完任务的线程干等了,这是个好问题。
既然是做并发的,肯定要最大程度压榨计算机的性能,对于这种场景并发大师 Doug Lea 使用了工作窃取算法处理,使用工作窃取算法
后,先完成自己队列中任务的线程会去其它线程的队列中”窃取“一个任务来执行,哈哈,一方有难,八方支援。但是此时这个线程和队列的持有线程会同时访问同一个队列,所以为了减少窃取任务的线程和被窃取任务的线程之间的竞争
,ForkJoin
选择了双端队列
这种数据结构,这样就可以按照这种规则执行任务了:被窃取任务的线程始终从队列头部获取任务并执行,窃取任务的线程使用从队列尾部获取任务执行。这个算法在绝大部分情况下都可以充分利用多线程进行并行计算,但是在双端队列里只有一个任务等极端情况下还是会存在一定程度的竞争。
Fork/Join
框架的实现核心是 ForkJoinPool
类,该类的重要组成部分为 ForkJoinTask
数组和 ForkJoinWorkerThread
数组,其中 ForkJoinTask
数组用来存放框架使用者给提交给 ForkJoinPool
的任务,ForkJoinWorkerThread
数组则负责执行这些任务。任务有如下四种状态:
下面来看看这两个类的核心方法实现原理,首先来看 ForkJoinTask
的 fork()
方法,源码如下:
方法对于 ForkJoinWorkerThread
类型的线程,首先会调用 ForkJoinWorkerThread
的 workQueue
的 push()
方法异步的去执行这个任务
,然后马上返回结果。继续跟进 ForkJoinPool
的 push()
方法,源码如下:
方法将当前任务添加到 ForkJoinTask
任务队列数组中,然后再调用 ForkJoinPool
的 signalWork
方法创建或者唤醒一个工作线程来执行该任务。然后再来看看 ForkJoinTask
的 join()
方法,方法源码如下:
方法首先调用了 doJoin()
方法,该方法返回当前任务的状态,根据返回的任务状态做不同的处理:
CancellationException
)继续跟进 doJoin()
方法,方法源码如下:
方法首先判断当前任务状态是否已经执行完成,如果执行完成则直接返回任务状态。如果没有执行完成,则从任务数组中(workQueue
)取出任务并执行,任务执行完成则设置任务状态为 NORMAL
,如果出现异常则记录异常并设置任务状态为 EXCEPTIONAL
(在 doExec()
方法中)。
本文主要介绍了 Java
并发框架中的 Fork/Join
框架的基本原理和其使用的工作窃取算法
(work-stealing
)、设计方式和部分实现源码。Fork/Join
框架在 JDK
的官方标准库中也有应用。比如 JDK 1.8+
标准库提供的 Arrays.parallelSort(array)
可以进行并行排序,它的原理就是内部通过 Fork/Join
框架对大数组分拆进行并行排序,可以提高排序的速度,还有集合中的 Collection.parallelStream()
方法底层也是基于 Fork/Join
框架实现的,最后就是定义小任务的阈值往往是需要通过测试验证才能合理给出,并且保证程序可以达到最好的性能。
什么是循环依赖
呢?可以把它拆分成循环
和依赖
两个部分来看,循环是指计算机领域中的循环,执行流程形成闭合回路;依赖就是完成这个动作的前提准备条件,和我们平常说的依赖大体上含义一致。放到 Spring
中来看就一个或多个 Bean
实例之间存在直接或间接的依赖关系,构成循环调用,循环依赖可以分为直接循环依赖
和间接循环依赖
,直接循环依赖的简单依赖场景:Bean A
依赖于 Bean B
,然后 Bean B
又反过来依赖于 Bean A
(Bean A -> Bean B -> Bean A
),间接循环依赖的一个依赖场景:Bean A
依赖于 Bean B
,Bean B
依赖于 Bean C
,Bean C
依赖于 Bean A
,中间多了一层,但是最终还是形成循环(Bean A -> Bean B -> Bean C -> Bean A
)。
第一种是自依赖,自己依赖自己从而形成循环依赖,一般情况下不会发生这种循环依赖,因为它很容易被我们发现。
第二种是直接依赖,发生在两个对象之间,比如:Bean A
依赖于 Bean B
,然后 Bean B
又反过来依赖于 Bean A
,如果比较细心的话肉眼也不难发现。
第三种是间接依赖,这种依赖类型发生在 3 个或者以上的对象依赖的场景,间接依赖最简单的场景:Bean A
依赖于 Bean B
,Bean B
依赖于 Bean C
,Bean C
依赖于 Bean A
,可以想象当中间依赖的对象很多时,是很难发现这种循环依赖的,一般都是借助一些工具排查。
在介绍 Spring 对几种循环依赖场景的处理方式之前,先来看看在 Spring 中循环依赖会有哪些场景,大部分常见的场景总结如下图所示:
有句话说得好,源码之下无秘密
,下面就通过源码探究这些场景 Spring
是否支持,以及支持的原因或者不支持的原因,话不多说,下面进入正题。
这种使用方式也是最常用的方式之一,假设有两个 Service
分别为 OrderService
(订单相关业务逻辑)和 TradeService
(交易相关业务逻辑),代码如下:
1 | /** |
1 | /** |
这种循环依赖场景,程序是可以正常运行的,从代码上看确实是有循环依赖了,也就是说 Spring
是支持这种循环依赖场景的,这里我们察觉不到循环依赖的原因是 Spring
已经默默地解决了。
假设没有做任何处理,按照正常的创建逻辑来执行的话,流程是这样的:容器先创建 OrderService
,发现依赖于 TradeService
,再创建 OrderService
,又发现依赖于 TradeService
… ,发生无限死循环,最后发生栈溢出错误,程序停止。为了支持这种常见的循环依赖场景,Spring
将创建对象分为如下几个步骤:
BeanPostProcessor
的一些实现类的方法,在这个阶段,Bean
已经创建并赋值属性完成。这时候容器中所有实现 BeanPostProcessor
接口的类都会被调用(e.g. AOP
)InitializingBean
,就会调用这个类的方法来完成类的初始化)为此,Spring
引入了三级缓存来处理这个问题(三级缓存定义在 org.springframework.beans.factory.support.DefaultSingletonBeanRegistry
中),第一级缓存 singletonObjects
用于存放完全初始化好的 Bean
,从该缓存中取出的 Bean
可以直接使用,第二级缓存 earlySingletonObjects
用于存放提前暴露的单例对象的缓存,存放原始的 Bean
对象(属性尚未赋值),用于解决循环依赖,第三级缓存 singletonFactories
用于存放单例对象工厂的缓存,存放 Bean
工厂对象,用于解决循环依赖。上述实例使用三级缓存的处理流程如下所示:
如果你看过三级缓存的定义源码的话,可能也有这样的疑问:为什么第三级的缓存的要定义成 Map<String, ObjectFactory<?>>
,不能直接缓存对象吗?这里不能直接保存对象实例,因为这样就无法对其做增强处理了。详情可见类 org.springframework.beans.factory.support.AbstractAutowireCapableBeanFactory#doCreateBean
方法部分源码如下:
这种方式平常使用得相对较少,还是使用前文的两个 Service
作为示例,唯一不同的地方是现在都声明为多例
了,示例代码如下:
1 | /** |
1 | /** |
如果你在 Spring
中运行以上代码,是可以正常启动成功的,原因是在类 org.springframework.beans.factory.support.DefaultListableBeanFactory
的 preInstantiateSingletons()
方法预实例化处理时,过滤掉了多例类型的 Bean
,方法部分代码如下:
但是如果此时有其它单例类型的 Bean
依赖到这些多例类型的 Bean
的时候,就会报如下所示的循环依赖错误了。
这种场景也会经常碰到,有时候为了实现异步调用会在 XXXXService
类的方法上添加 @Async
注解,让方法对外部变成异步调用(前提要是要在启用类上添加启用注解哦 @EnableAsync
),示例代码如下:
1 | /** |
1 | /** |
1 | /** |
在标有 @Async
注解的场景下,在添加启用异步注解(@EnableAsync
)后,代理对象会通过 AOP
自动生成。以上代码运行会抛出 BeanCurrentlyInCreationException
异常。运行的大致流程如下图所示:
源码在 org.springframework.beans.factory.support.AbstractAutowireCapableBeanFactory
类的方法 doCreateBean
中,会判断第二级缓存 earlySingletonObjects
中的对象是否等于原始对象,方法判断部分的源码如下:
二级缓存存放的对象是 AOP
生成出来的代理对象,和原始对象不相等,所以抛出了循环依赖错误。如果细看源码的话,会发现如果二级缓存是空的话会直接返回(因为比较的对象都没有,根本无法校验了),就不会报循环依赖的错误了,默认情况下,Spring
是按照文件全路径递归搜索,按路径
+ 文件名
排序,排序靠前先加载,所以我们只要调整这两个类名称,让方法标有 @Async
注解的类排序在后面即可。
构造器注入的场景很少,到目前为止我所接触过的公司项目和开源项目中还没遇到使用构造器注入的,虽然用得不多,但是需要知道 Spring
为什么不支持这种场景的循环依赖,构造器注入的示例代码如下:
1 | /** |
1 | /** |
构造器注入无法加入到第三级缓存当中,Spring
框架中的三级缓存在此场景下无用武之地,所以只能抛出异常,整体流程如下(虚线表示无法执行,为了直观也把下一步画出来了):
这种 DependsOn
循环依赖场景很少,一般情况下不怎么使用,了解一下会导致循环依赖的问题即可,@DependsOn
注解主要是用来指定实例化顺序的,示例代码如下:
1 | /** |
1 | /** |
通过上文,我们知道,如果这里的类没有标注 @DependsOn
注解的话是可以正常运行的,因为 Spring
支持单例 setter
注入,但是加了示例代码的 @DependsOn
注解后会报循环依赖错误,原因是在类 org.springframework.beans.factory.support.AbstractBeanFactory
的方法 doGetBean()
中检查了 dependsOn
的实例是否有循环依赖,如果有循环依赖则抛出循环依赖异常,方法判断部分代码如下:
本文主要介绍了什么是循环依赖以及 Spring
对各种循环依赖场景的处理,文中只列出了部分涉及到的源码,都标了所在源码中的位置,感兴趣的朋友可以去看看完整源码,最后 Spring
对各种循环依赖场景的支持情况如下图所示(P.S. Spring
版本:5.1.9.RELEASE):
在 上篇 介绍了 Feign 的核心实现原理,在文末也提到了会再介绍其和 Spring Cloud 的整合原理,Spring 具有很强的扩展性,会把一些常用的解决方案通过 starter 的方式开放给开发者使用,在引入官方提供的 starter 后通常只需要添加一些注解即可使用相关功能(通常是 @EnableXXX)。下面就一起来看看 Spring Cloud 到底是如何整合 Feign 的。
在 Spring 中一切都是围绕 Bean 来展开的工作,而所有的 Bean 都是基于 BeanDefinition 来生成的,可以说 BeanDefinition 是整个 Spring 帝国的基石,这个整合的关键也就是要如何生成 Feign 对应的 BeanDefinition。
要分析其整合原理,我们首先要从哪里入手呢?如果你看过 上篇 的话,在介绍结合 Spring Cloud 使用方式的例子时,第二步就是要在项目的 XXXApplication 上加添加 @EnableFeignClients 注解,我们可以从这里作为切入点,一步步深入分析其实现原理(通常相当一部分的 starter 一般都是在启动类中添加了开启相关功能的注解)。
进入 @EnableFeignClients 注解中,其源码如下:
从注解的源码可以发现,该注解除了定义几个参数(basePackages、defaultConfiguration、clients 等)外,还通过 @Import 引入了 FeignClientsRegistrar 类,一般 @Import 注解有如下功能(具体功能可见 官方 Java Doc):
到这里不难看出,整合实现的主要流程就在 FeignClientsRegistrar 类中了,让我们继续深入到类 FeignClientsRegistrar 的源码,
通过源码可知 FeignClientsRegistrar 实现 ImportBeanDefinitionRegistrar 接口,该接口从名字也不难看出其主要功能就是将所需要初始化的 BeanDefinition 注入到容器中,接口定义两个方法功能都是用来注入给定的 BeanDefinition 的,一个可自定义 beanName(通过实现 BeanNameGenerator 接口自定义生成 beanName 的逻辑),另一个使用默认的规则生成 beanName(类名首字母小写格式)。接口源码如下所示:
对 Spring 有一些了解的朋友们都知道,Spring 会在容器启动的过程中根据 BeanDefinition 的属性信息完成对类的初始化,并注入到容器中。所以这里 FeignClientsRegistrar 的终极目标就是将生成的代理类注入到 Spring 容器中。
虽然 FeignClientsRegistrar 这个类的源码看起来比较多,但是从其终结目标来看,我们主要是看如何生成 BeanDefinition 的,通过源码可以发现其实现了 ImportBeanDefinitionRegistrar 接口,并且重写了 registerBeanDefinitions(AnnotationMetadata, BeanDefinitionRegistry) 方法,在这个方法里完成了一些 BeanDefinition 的生成和注册工作。源码如下:
整个过程主要分为如下两个步骤:
下面分别深入方法源码实现来看其具体实现原理,首先来看看第一步的方法 registerDefaultConfiguration(AnnotationMetadata, BeanDefinitionRegistry),源码如下:
可以看到这里只是获取一下注解 @EnableFeignClients 的默认配置属性 defaultConfiguration 的值,最终的功能实现交给了 registerClientConfiguration(BeanDefinitionRegistry, Object, Object) 方法来完成,继续跟进深入该方法,其源码如下:
可以看到,全局默认配置的 BeanClazz 都是 FeignClientSpecification,然后这里将全局默认配置 configuration 设置为 BeanDefinition 构造器的输入参数,然后当调用构造器实例化时将这个参数传进去。到这里就已经把 @EnableFeignClients 的全局默认配置(注解的 defaultConfiguration 属性)创建出 BeanDefinition 对象并注入到容器中了,第一步到此完成,整体还是比较简单的。
下面再来看看第二步 给标有了 @FeignClient 的类创建 BeanDefinition 对象并注入到容器中 是如何实现的。深入第二步的方法 registerFeignClients(AnnotationMetadata, BeanDefinitionRegistry) 实现中,由于方法实现代码较多,使用截图会比较分散,所以用贴出源代码并在相关位置添加必要注释的方式进行:
1 | public void registerFeignClients(AnnotationMetadata metadata, BeanDefinitionRegistry registry) { |
通过源码可以看到最后是通过方法 registerFeignClient(BeanDefinitionRegistry, AnnotationMetadata, Map<String, Object>) 注入的 @FeignClient 对象,继续深入该方法,源码如下:
方法实现比较长,最终目标是构造出 BeanDefinition 对象,然后通过 BeanDefinitionReaderUtils.registerBeanDefinition(BeanDefinitionHolder, BeanDefinitionRegistry) 注入到容器中。
其中关键的一步是从 @FeignClient 注解中获取信息并设置到 BeanDefinitionBuilder 中,BeanDefinitionBuilder 中注册的类是 FeignClientFactoryBean,这个类的功能正如它的名字一样是用来创建出 FeignClient 的 Bean 的,然后 Spring 会根据 FeignClientFactoryBean 生成对象并注入到容器中。
需要明确的一点是,实际上这里最终注入到容器当中的是 FeignClientFactoryBean 这个类,Spring 会在类初始化的时候会根据这个类来生成实例对象,就是调用 FeignClientFactoryBean.getObject() 方法,这个生成的对象就是我们实际使用的代理对象。下面再进入到类 FeignClientFactoryBean 的 getObject() 这个⽅法,源码如下:
可以看到这个方法是直接调用的类中的另一个方法 getTarget() 的,在继续跟进该方法,由于该方法实现代码较多,使用截图会比较分散,所以用贴出源代码并在相关位置添加必要注释的方式进行:
1 | /** |
通过源码得知 FeignClientFactoryBean 继承了 FactoryBean,其方法 FactoryBean.getObject 返回的就是 Feign 的代理对象,最后这个代理对象被注入到 Spring 容器中,我们就通过 @Autowired 可以直接注入使用了。同时还可以发现上面的代码分支最终都会走到如下代码:
1 | Targeter targeter = get(context, Targeter.class); |
点进去深入 targeter.target 的源码,可以看到实际上这里创建的就是一个代理对象,也就是说在容器启动的时候,会为每个 @FeignClient 创建了一个代理对象。至此,Spring Cloud 和 Feign 整合原理的核心实现介绍完毕。
本文主要介绍了 Spring Cloud 整合 Feign 的原理。通过上文介绍,你已经知道 Srpring 会我们的标注的 @FeignClient 的接口创建了一个代理对象,那么有了这个代理对象我们就可以做增强处理(e.g. 前置增强、后置增强),那么你知道是如何实现的吗?感兴趣的朋友可以再翻翻源码寻找答案(温馨提示:增强逻辑在 InvocationHandler 中)。还有 Feign 与 Ribbon 和 Hystrix 等组件的协作,感兴趣的朋友可以自行下载源码学习了解。
]]>Feign 是⼀个 HTTP 请求的轻量级客户端框架。通过 接口 + 注解的方式发起 HTTP 请求调用,面向接口编程,而不是像 Java 中通过封装 HTTP 请求报文的方式直接调用。服务消费方拿到服务提供方的接⼝,然后像调⽤本地接⼝⽅法⼀样去调⽤,实际发出的是远程的请求。让我们更加便捷和优雅的去调⽤基于 HTTP 的 API,被⼴泛应⽤在 Spring Cloud 的解决⽅案中。开源项目地址:Feign,官方描述如下:
Feign is a Java to HTTP client binder inspired by Retrofit, JAXRS-2.0, and WebSocket. Feign’s first goal was reducing the complexity of binding Denominator uniformly to HTTP APIs regardless of ReSTfulness.
Feign 的首要目标就是减少 HTTP 调用的复杂性。在微服务调用的场景中,我们调用很多时候都是基于 HTTP 协议的服务,如果服务调用只使用提供 HTTP 调用服务的 HTTP Client 框架(e.g. Apache HttpComponnets、HttpURLConnection OkHttp 等),我们需要关注哪些问题呢?
相比这些 HTTP 请求框架,Feign 封装了 HTTP 请求调用的流程,而且会强制使用者去养成面向接口编程的习惯(因为 Feign 本身就是要面向接口)。
以获取 Feign 的 GitHub 开源项目的 Contributors 为例,原生方式使用 Feign 步骤有如下三步(这里以使用 Gradle 进行依赖管理的项目为例):
第一步: 引入相关依赖:implementation ‘io.github.openfeign:feign-core:11.0’
在项目的 build.gradle 文件的依赖声明处 dependencies 添加该依赖声明即可。
第二步: 声明 HTTP 请求接口
使用 Java 的接口和 Feign 的原生注解 @RequestLine 声明 HTTP 请求接口,从这里就可以看到 Feign 给使用者封装了 HTTP 的调用细节,极大的减少了 HTTP 调用的复杂性,只要定义接口即可。
第三步: 配置初始化 Feign 客户端
最后一步配置初始化客户端,这一步主要是设置请求地址、编码(Encoder)、解码(Decoder)等。
通过定义接口,使用注解的方式描述接口的信息,就可以发起接口调用。最后请求结果如下:
同样还是以获取 Feign 的 GitHub 开源项目的 Contributors 为例,结合 Spring Cloud 的使用方式有如下三步:
第一步: 引入相关 starter 依赖:org.springframework.cloud:spring-cloud-starter-openfeign
在项目的 build.gradle 文件的依赖声明处 dependencies 添加该依赖声明即可。
第二步: 在项目的启动类 XXXApplication 上添加 @EnableFeignClients 注解启用 Feign 客户端功能。
第三步: 创建 HTTP 调用接口,并添加声明 @FeignClient 注解。
最后一步配置初始化客户端,这一步主要是设置请求地址(url)、编码(Encoder)、解码(Decoder)等,与原生使用方式不同的是,现在我们是通过 @FeignClient 注解配置的 Feign 客户端属性,同时请求的 URL 也是使用的 Spring MVC 提供的注解。
测试类如下所示:
运行结果如下:
可以看到这里是通过 @Autowired 注入刚刚定义的接口的,然后就可以直接使用其来发起 HTTP 请求了,使用是不是很方便、简洁。
从上面第一个原生使用的例子可以看到,只是定了接口并没有具体的实现类,但是却可以在测试类中直接调用接口的方法来完成接口的调用,我们知道在 Java 里面接口是无法直接进行使用的,因此可以大胆猜测是 Feign 在背后默默生成了接口的代理实现类,也可以验证一下,只需在刚刚的测试类 debug 一下看看接口实际使用的是什么实现类:
从 debug 结果可知,框架生成了接口的代理实现类 HardCodedTarget 的对象 $Proxy14 来完成接口请求调用,和刚刚的猜测一致。Feign 主要是封装了 HTTP 请求调用,其整体架构如下:
测试类代码里面只在 GitHub github = Feign.builder().target(GitHub.class, “https://api.github.com"); 用到了 Feign 框架的功能,所以我们选择从这里来深入源码,点击进入发现是 Feign 抽象类提供的方法,同样我们知道抽象类也是无法进行初始化的,所以肯定是有子类的,如果你刚刚有仔细观察上面的 debug 代码的话,可以发现有一个 ReflectiveFeign 类,这个类就是抽象类 Feign 的子类了。抽象类 feign.Feign 的部分源码如下:
1 | public abstract class Feign { |
可以看到在方法 public
1 | public class ReflectiveFeign extends Feign { |
总体流程就是在方法
下面再深入 MethodHandler,看看是如何完成对方法 HTTP 请求处理的,MethodHandler 是一个接口定义在 feign.InvocationHandlerFactory 接口中(P.S. 基础知识点,接口是可以在内部定义内部接口的哦),有两个实现类分别为 DefaultMethodHandler 和 SynchronousMethodHandler,第一个 DefaultMethodHandler 用来处理接口的默认方法,第二个是用来处理正常的接口方法的,一般情况下都是由该类来处理的。
1 |
|
至此,Feign 的核心实现流程介绍完毕,从代码上看 feign.SynchronousMethodHandler 的操作相对比较简单,主要是通过 client 完成请求,对响应进行解码以及异常处理操作,整体流程如下:
Feign 通过给我们定义的目标接口(比如例子中的 GitHub)生成一个 HardCodedTarget 类型的代理对象,由 JDK 动态代理实现,生成代理的时候会根据注解来生成一个对应的 Map<Method, MethodHandler>,这个 Map 被 InvocationHandler 持有,接口方法调用的时候,进入 InvocationHandler 的 invoke 方法(为什么会进入这里?JDK 动态代理的基础知识)。
然后根据调用的方法从 Map<Method, MethodHandler> 获取对应的 MethodHandler,然后通过 MethodHandler 根据指定的 client 来完成对应处理, MethodHandler 中的实现类 DefaultMethodHandler 处理默认方法(接口的默认方法)的请求处理的,SynchronousMethodHandler 实现类是完成其它方法的 HTTP 请求的实现,这就是 Feign 的主要核心流程,源码已上传 Github。以上是 Feign 框架实现的核心流程介绍,Spring Cloud 是如何整合 Feign 的呢?请看下篇博文,敬请期待。s
]]>我们现在所处的信息爆炸时代,如何强调快速获取信息都不为过,信息多种多样,有些能找到源头,有些则不能,有些能找到规律,有些则不一定能找到,信息的源头和获取渠道很重要。然而事实上,能够真正有效获取到优质信息并加以消化利用的人并不多。
在信息的获取的过程中,应该要具备筛选信息的能力,什么是官方信息,你要核实,什么是虚假信息,你要甄别。看到网上有些陷入杀猪盘的,负载累累。仔细思考一下,其实甄别筛选信息的能力真的是最大的问题。
当然一个人将信息并内化利用是一个很复杂的过程,每个人都有自己独到的方法。今天来聊聊应该如何去获取「优质信息」以及如何去过滤无用信息。下面分享几个获取信息的原则:
这个原则的关键是,这里的“一手信息”是如何定义呢? 对于那些权威机构或者国家机构,或者专家大咖,或者作者本人所发布的信息绝大部分情况下都可以看作为一手信息,第一手信息,不是被别人理解过、消化过的二手信息。
尤其对于知识性的东西来说,更应该是这样。应该是原汁原味的,不应该是被添油加醋的。对于一手信息的价值为什么大于二手信息甚至多手信息呢?很简单,这个效应在股票或者投资市场会被放大的很明显,能够在第一时间获取到第一手信息,是能否准确快速判断出市场行情走向的关键因素之一。
对于这个原则,可能不是绝对,但至少在绝大部分情况下是正确的,对于现在很多“白嫖党”来说,可能确实要改一改自己的陋习了,要知道,其实免费反而最贵,因为它给你带来的负面作用或者时间成本,甚至可能会“毒害”你对于信息和知识的热情,当然也会有少部分人会把好的东西给开源或者免费掉。
和上面的第二点一样,需要你带着审视和批判思维来看待了,国内整体的创作环境个人角色还是相关比较浮躁和恶劣的,尽管这些年有所改观,但当前的自媒体,包括一些所谓的大 V,也会有很多滥竽充数的文章,视频等内容,包括当前都说信息过载,其实准确来说,是垃圾信息过载,那些优质的内容与知识,毕竟少数。
有选择的相信专家,并关注他们的日常分享,但不要迷恋迷信专家,很多人会无脑喷当前所谓的砖家伪公知 ,但在大部分情况,专家是在某些领域沉淀研究了很多年,你可以看看别人的一些思路,观点,与框架性东西,在某些情况下,可能真会对自己有所启发。
举一个简单的例子,如果今天巴菲特发表了一番言论,当然媒体会对此有记录和报道。但是,各种媒体可能记录有误差,而且可能还有意无意加入自己的看法,把不是巴菲特发表的言论加到他头上,这样就主观或客观地引入了错误信息。
此时如果你只从一个信息源了解信息,其实是很难判断所获得的是准确信息还是夹杂着一定的错误信息的。但是如果你能从多个信息源了解信息,虽然它们各自都有部分个人主观因素,但是由于各自角度的不同,很多噪音彼此可以抵消掉,获得的则是相对比较准确的信息。
要时刻警惕回音室响应,避免把自己关进一个封闭的信息圈子,这样慢慢的外部的信息就没法进来了,总之在获取信息的时候一定要尝试从不同维度,不同角度去摄取。
在中国,人们常常都会很纠结一个问题,「就是老婆和妈妈掉到水里后先救谁?」,如果仔细思考一下,这个两难问题的重要原因在于,我们要考虑的因素太多,以至于大家越想越糊涂。其实只要你细想就会发现,这个问题的关键是分清楚什么是我们该考虑的信息,什么是不用考虑的信息。
比如,如果你觉得孝是第一位的,或者觉得以后谁和我生活更长时间是第一位的,作出选择就没有什么难的。这时,你其实是将这个有很多干扰信息的问题,分解到了某些你能够区分的维度,比如孝的维度,或者和一起生活的时间的维度。
这是最后一点,也是最重要的一点,这里的 pull 和 push 可能说得有点偏技术化,解释一下就是 pull 是说要目的地在网络上主动查询一些信息,而不是等各种 APP 给你推送信息,虽然主动查询信息可能会让你感到比较难受,但这是获取优质信息的第一步。
我个人是目前做技术相关的工作,对这点比较有感触,一个人的学习能力强不强,其实就像生存能力一样,一个重要判断点就是看这个人是能自己找食吃,还是要等别人“喂着吃”。
别人投喂给你的信息未必都是错的,都是坏的,比如某些自媒体的信息,但如果要获取到更为准确的信息,我建议你还是要去主动搜索核对一下,而不是单凭别人的一面之词。也就是说,别人是不会为你的后果负责的,而你要为自己的后果负责。
当然,以上是个人在选择信息源的一些原则和思考,希望可以对需要进行信息获取和筛选的朋友有所帮助。
]]>搜索能力是被绝大多数人低估一项基本素质,绝大部分做编程技术相关的朋友应该都知道如何使用 Google
,但是并不知道如何利用它的潜力。其实不管是 Google
还是 百度
,会搜索的人一样都可以查找到需要的东西,不会搜索的人用什么都不好使。下面介绍一些 Google
常用的搜索技巧以及搜索快捷方式,可以帮助你更快,更准确地找到结果。Google
是世界上功能最强大的搜索引擎,它已经改变了我们查找信息的方式。
将您要搜索的关键字用引号引起来,Google
会进行精确的词组搜索。
语法:”[searchkey 1] [searchkey 2]” [searchkey 3]
默认情况下,除非指定,否则 Google
会包含你搜索条件中的所有搜索关键字。通过在您的关键词之字输入OR
,Google
会知道它可以查找一组或另一组。大写 OR
,否则 Google
会认为它只是你的关键字的一部分。
语法:[searchkey 1] OR [searchkey 2]
通过在单词的前面添加减号,将单词从 Google
搜索中排除。
语法:-[searchkey to exclude] [searchkey to include]
使用 Google
的 allintext:
语法仅搜索网站的正文,而忽略链接,URL
和标题。
语法:allintext:[searchkeys]
查找搜索词在不同位置的网页。即-在页面正文中,页面标题,URL
等中。为此,在您的关键字之前使用 intext:
。
语法:intext:[searchkeys]
在网页标题内搜索一个单词,然后在网页上的其他位置搜索另一个单词。为此,您需要将 intitle:
混合到您的搜索查询中。
语法:[searchkeys 1] intitle:[searchkeys 2]
在网页标题中搜索查询中的所有关键字,在我们的搜索词之前使用 allintitle:
。
语法:allintitle:[searchkey1 searchkey2]
使用 allinURL
可以很容易地在 URL
中搜索关键字 。
语法:allinURL:[searchkeys]
在网站内搜索单词-使用网站 URL
前面的 site:
语法,后跟您的搜索词。这会将搜索结果仅限制在该网站上。
语法:site:[website URL] [searchkeys]
通过在单词之前使用 define:
轻松地找到单词的定义,而无需访问词典网站。Google
将提供定义,并提供一个音频播放器来提供该单词的语音发音。
语法:define:[searchkey]
没想到所有的话吗?加上 *
告诉 Google
为您填写空白,这对于歌曲歌词或书名搜索非常有效。
语法:[searchkeys 1] * [searchkeys 2]
搜索文件类型(例如 PowerPoint
,PDF
等)时,请在搜索词中使用 filetype:
命令。
语法:[searchkeyword] filetype:[file type extension]
使用 Google
可以进行任何度量转换。
语法:convert [data value + unit of measure] to [like unit of measure]
在搜索栏中输入您的计算结果,将 Google
用作计算器。数值运算符: *
表示乘,+
表示加,-
表示减,/
表示除。
语法:[number] [operator] [number]
查找图像的名称,描述和类型。
]]>语法:[searchkeyw] image type
在计算机行业工作的人们,最大的感触就是这个行业里总是会出现很多的新东西,各种技术、框架等等,变化无处不在,有很大一部分人都比较焦虑。在一些论坛或者社区里面总是有人在问如何学习一门新技术?怎样才能跟上技术的潮流?我想说是,我们应该打牢基础,应对变化,以不变应万变。
变化都是我们看到表面现象,本质的变化其实并没有多大。计算机发展的这几十年来,理论的层面变得不多,很多理论都是在几十年前就已经发现了的,只是在表现形式上变化比较大,夸张一点的甚至是一年一个样的都有。
所以想要应对这种变化就要抓住其本质不变的地方,也就是其背后的理论基础,打牢理论基础,提升自己的编程内功修养,一些与语言无关比较通用的东西要重点掌握,比如编程里面的一些设计模式、代码重用、解耦以及抽象能力等等。想要代码重用就必须得解耦,想要解耦就进行抽象,抽取出公共不变的东西,这些都是和语言无关的通用的技能。
当你有牢固的基础知识以后,其实也会更加容易的突破自己的技术和成长瓶颈。我认为在技术领域里面其实是不存在量变可以达到质变这么一说的。量变达到质变也是说只要我努力多写代码就能成为架构师,技术有一个质的突破,其实并不是这样的。
尽管你代码写得再多,如果不懂得背后的技术原理,不懂得科学的学习方法,不进行归纳总结输出,是永远达到质变的。所以必须学习和打牢基础理论知识,如果总是只学习一些浮于表面上的东西,当技术形式发生一些变化后,你会发现之前学习的知识已经用不到了,又得重新学习,而在技术世界里变化又是非常快的,所以很多都迷失在不停的学习技术形式之中,这也是造成一部分人感到焦虑的原因之一。
上层的技术实现都是有背后的理论基础作为支撑的,因为这些理论基础都是抽象和归纳,比如不管是 Java
还是其它的一些开发语言,只要只用 TCP/IP 协议,用的都是一样的原理,不同的只是技术实现形式上的差异,你只要打牢基础理论知识,抓住本质原理,不管它技术实现形式上如何变化,都能很快掌握它。
这些知识绝大部分都是一个科班学生本科的专业课讲到的原理知识,但是大部分人在学校可能都没有静下心来认真学习钻研,有句话说得好:“出来混,迟早要还的~”,一个好的学习方法就是一定要看一些经典的书和世界顶级学校的课程,最后自己归纳总结输出。这些知识总的来说可以分为以下几类,
计算机发展的这几十年来,核心的基础知识就是上面列举的这些,虽然我们的直观感受技术是在不断更替的,实际上本质的东西并没有改变,其理论基础还是这些内容,变化的只是技术形式,我想说的一点是对这些基础理论知识的掌握程能直接决定的成长天花板。万丈高楼平地起,勿在浮沙筑高台。
]]>在 上篇 实现了 判断一个类的方式是符合配置的 pointcut 表达式、根据一个 Bean 的名称和方法名,获取 Method 对象、实现了 BeforeAdvice、AfterReturningAdvice 以及 AfterThrowingAdvice并按照指定次序调用 等功能,这篇再来看看剩下的 代理对象如何生成、根据 XML 配置文件生成 BeanDefintion以及如何将生成的代理对象放入到容器中 等功能,话不多说,下面进入主题。
代理对象的生成策略和 Spring 框架一致,当被代理类实现了接口时采用 JDK 动态代理的方式生成代理对象,被代理对象未实现接口时使用 CGLIB 来生成代理对象,为了简单起见这里不支持手动指定生成代理对象的策略,JDK 动态代理的实现这里不在介绍,感兴趣可以自己实现一下,这里主要讨论 CGLIB 的生成方式。
基于面向接口编程的思想,这里的生成代理对象需要定义一个统一的接口,不管是 CGLIB 生成方式还是JDK 动态代理生成方式都要实现该接口。生成代理对象是根据一些配置去生成的,同样,这里生成代理的配置也可以抽取一个统一的接口,在实现类中定义拦截器(也就是 Advice)以及实现的接口等,CGLIB 的基本使用可以到官网自行查找。代理对象生成的整体的类图如下:
其中代理创建的工厂接口 AopProxyFactory 如下,提供了不指定 ClassLoader(使用默认的 ClassLoader)和指定 ClassLoader 两种方式创建代理对象,源码如下:
1 | /** |
使用 CGLIB 创建代理的工厂接口实现类如下所示:
1 | /** |
整体来看还是比较简单的,主要是 CGLIB 第三方字节码生成库的基本用法,当然,前提是你已经了解了 CGLIB 的基本使用。AOP 的相关配置接口 Advised 相对来说就比较简单了,主要是一些相关属性的增、删、改等操作,主要部分代码如下:
1 | /** |
实现类也比较简单,代码如下:
1 | /** |
到这里,代理对象使用 CGLIB 生成的方式就已经实现了,核心代码其实比较简单,主要是需要多考虑考虑代码后期的扩展性。
我们先来看看一般 AOP 在 XML 配置文件中是如何定义的,一个包含 BeforeAdvice、AfterReturningAdvice以及AfterThrowingAdvice 的 XML 配置文件如下:
1 |
|
有了之前解析 XML 的 Bean 定义的经验后,很显然这里我们需要一个数据结构去表示这个 AOP 配置,如果你阅读过 上篇 的话,类 AspectJExpressionPointcut 表示的是 <aop:pointcut id=”placeOrder” expression=”execution(* cn.mghio.service.version5.*.placeOrder(..))”/>,另外几个 Advice 配置分别对应 AspectJBeforeAdvice、AspectJAfterReturningAdvice以及 AspectJAfterThrowingAdvice 等几个类。
这里只要解析 XML 配置文件,然后使用对应的 Advice 的构造器创建对应的对象即可,解析 XML 使用的是 dom4j,主要部分代码如下所示:
1 | /** |
创建 BeanDefinition 已经完成了,现在可根据 XML 配置文件解析出对应的 BeanDefintion 了,下面只需要在合适的时机将这些 BeanDefinition 放到容器中就完成了全部流程了。
该如何把解析出来的 BeanDefintion 放到容器当中去呢?我们知道在 Spring 框架当中提供了很多的“钩子函数”,可以从这里入手,Bean 的生命周期如下:
选择在 Bean 实例化完成之后 BeanPostProcessor 的 postProcessAfterInitialization() 方法创建代理对象,AOP 使用的是 AspectJ,将创建代理对象的类命名为 AspectJAutoProxyCreator,实现 BeanPostProcessor 接口,处理代理对象的创建,AspectJAutoProxyCreator 类的核心源码如下:
1 | /** |
最后别忘了,这里的 BeanPostProcessor 接口是我们新加的,需要到之前定义的 DefaultFactoryBean 中加上对 BeanPostProcessor 的处理逻辑,主要修改如下:
1 | public class DefaultBeanFactory extends AbstractBeanFactory implements BeanDefinitionRegistry { |
最后运行事先测试用例,正常通过符合预期。
本文主要介绍了 AOP 代理对象生成、解析 XML 配置文件并创建对应的 BeanDefinition 以及最后注入到容器中,只是介绍了大体实现思路,具体代码实现已上传 mghio-spring,感兴趣的朋友可以参考,到这里,AOP 实现部分已经全部介绍完毕。
]]>前面两篇 如何实现 AOP(上)、如何实现 AOP(中) 做了一些 AOP
的核心基础知识简要介绍,本文进入到了实战环节了,去实现一个基于 XML
配置的简易版 AOP
,虽然是简易版的但是麻雀虽小五脏俱全
,一些核心的功能都会实现,通过实现这个简易版的 AOP
,相信你会对 AOP
有深入的理解,不止知其然,还能知其所以然。AOP
的顶层接口规范和底层依赖基础组件都是由一个叫 AOP Alliance 的组织制定的,我们经常听到的 AspectJ
、ASM
、CGLIB
就是其中被管理的一些项目,需要明确的一点是,在 Spring
中只是使用了 AspectJ
的核心概念和核心类,并不是像 AspectJ
那样在编译期实现的 AOP
,而是在运行期。话不多说,下面开始进入主题。
假设有一个 OrderService
类(P.S. 这里的 @Component
是我自定义的注解,详见 这篇),其中有一个下单的方法 placeOrder()
,我们想实现的效果是想给这个 placeOrder()
方法加上 数据库事务,即执行方法之前开启事务,执行过程中发生异常回滚事务,正常执行完成提交事务。OrderService
类的代码如下:
1 | /** |
很明显,这里的 pointcut
就是 placeOrder()
方法,在 XML
配置文件中的配置如下:
1 | <aop:pointcut id="placeOrder" expression="execution(* cn.mghio.service.version5.*.placeOrder(..))"/> |
我们需要一个类去表达这个概念,pointcut
要实现的功能是给定一个类的方法,判断是否匹配配置文件中给定的表达式。总的来看 pointcut
由方法匹配器
和匹配表达式
两部分组成,方法匹配器可以有各种不同的实现,所以是一个接口,pointcut
同样也可以基于多种不同技术实现,故也是一个接口,默认是基于 AspectJ
实现的,类图结构如下:
实现类 AspectJExpressionPointcut
是基于 AspectJ
实现的,方法的匹配过程是委托给 AspectJ
中的 PointcutExpression
来判断给定的方法是否匹配表达式,该类的核心实现如下:
1 | /** |
到这里就完成了给定一个类的方法,判断是否匹配配置文件中给定的表达式的功能。再来看如下的一个完整的 AOP
配置:
1 |
|
在实现各种 XXXAdvice
之前需要定位到这个 Method
,比如以上配置文件中的 start
、commit
、rollback
等方法,为了达到这个目标我们还需要实现的功能就是根据一个 Bean
名称(比如这里的 tx
)定位到指定的 Method
,然后通过反射调用这个定位到的方法。实际上也比较简单,这个类命名为 MethodLocatingFactory
,根据其功能可以定义出目标 Bean
的名称 targetBeanName
、需要定位的方法名称 methodName
以及定位完成后得到的方法 method
这三个属性,整体类图结构如下所示:
根据名称和类型定位到方法主要是在 setBeanFactory()
方法中完成的,前提是对应的目标 Bean
名称和方法名称要设置完成,方法定位的类 MethodLocatingFactory
类的代码如下所示:
1 | /** |
各种不同类型的 Advice
(BeforeAdvice
、AfterAdvice
等)目标都是需要在指定对象的指定方法执行前后按指定次序执行一些操作(称之为 拦截器
),比如以上示例中的一种执行次序为:BeforeAdvice
-> placeOrder
-> AfterAdvice
。这里的一个关键问题就是如何去实现按照指定次序的链式调用?,这里先卖个关子,这个问题先放一放等下再介绍具体实现,先来看看要如何定义各种不同类型的 Advice
,我们的 Advice
定义都是扩展自 AOP Alliance
定义的 MethodInterceptor
接口,Advice
部分的核心类图如下:
其实到这里如果有了前面两篇文章(如何实现 AOP(上)、如何实现 AOP(中))的基础了,实现起来就相对比较简单了,就是在方法执行之前、之后以及发生异常时调用一些特定的方法即可,AbstractAspectJAdvice
类定义了一下公共的属性和方法,核心实现源码如下:
1 | /** |
有了这个公共抽象父类之后其它几个 Advice
的实现就很简单了,AspectJBeforeAdvice
就是在执行拦截方法之前调用,核心源码如下:
1 | /** |
同理,AspectJAfterReturningAdvice
就是在方法正常执行结束后调用,核心源码如下:
1 | /** |
剩下的 AspectJAfterThrowingAdvice
想必你已经猜到了,没错,就是在方法执行过程中发生异常时调用,对应 Java
的异常机制也就是在 try{...}catch{...}
的 catch
中调用,核心源码如下:
1 | /** |
我们支持的三种不同的 Advice
已经定义好了,接下来就是如何组装调用的问题了,同时也处理了如何去实现按照指定次序的链式调用?的问题,这里的方法调用我们也是扩展 AOP Alliance
定义的规范,即方法调用 MethodInvocation
接口。
由于这里的方法调用是基于反射完成的,将该类命名为 ReflectiveMethodInvocation
,要使用反射来调用方法,很显然需要知道目标对象 targetObject
、targetMethod
以及方法参数列表 arguments
等参数,当然还有我们的拦截器列表(也就是上文定义的 Advice
)interceptors
,因为这个是一个类似自调用的过程,为了判断是否已经执行完成所有拦截器,还需要记录当前调用拦截器的下标位置 currentInterceptorIndex
,当 currentInterceptorIndex
等于 interceptors.size() - 1
时表示所有拦截器都已调用完成,再调用我们的实际方法即可。核心的类图如下:
其中类 ReflectiveMethodInvocation
的核心源码实现如下,强烈建议大家将 proceed()
方法结合上问定义的几个 Advice
类一起看:
1 | /** |
至此,各种不同类型的 Advice
的核心实现已经介绍完毕,本来打算在这边介绍完 AOP
剩下部分的实现的,但是鉴于文章长度太长,还是放到下一次再开一篇来介绍吧。
本文主要介绍了 AOP
在 XML
配置的 pointcut
解析实现、方法匹配定位以及各种不同类型的 Advice
的实现,特别是 Advice
的实现部分,建议自己动手实现一版,这样印象会更加深刻,另源码已上传至 GitHub,可自行下载参考,有任何问题请留言交流讨论。
在上篇 如何实现 AOP(上) 介绍了 AOP
技术出现的原因和一些重要的概念,在我们自己实现之前有必要先了解一下 AOP
底层到底是如何运作的,所以这篇再来看看 AOP
实现所依赖的一些核心基础技术。AOP
是使用动态代理
和字节码生成技术
来实现的,在运行期(注意:不是编译期!)为目标对象生成代理对象,然后将横切逻辑织入到生成的代理对象中,最后系统使用的是带有横切逻辑的代理对象,而不是被代理对象,由代理对象转发到被代理对象。
动态代理的根源是设计模式中的代理模式,代理模式在 GoF 中的描述如下:
Provide a surrogate or placeholder for another object to control access to it.
从其定义可以看出,代理模式主要是为了控制对象的访问,通常也会拥有被代理者的所有功能。通过代理模式我们可以在不改变被代理类的情况下,通过引入代理类来给被代理类添加一些功能,此时脑海里飘过计算机科学界中一句著名的话(P.S. 基础知识很重要啊,可以参见 这篇):
计算机科学的任何一个问题,都可以通过增加一个中间层来解决。
代理模式其实在现实生活中也经常会接触到,比如在一线城市租房时大部分都是找的租房中介去看的房子、谈价格以及签合同,是因为房子的房东已经把房子全权托管给了中介处理了,这里的租房中介其实就是充当了代理模式中的代理对象的解决,而真正的被代理对象(目标对象)其实是房子的房东,而和我们打交道都是租房中介(代理对象)。代理模式类图结构如下图所示:
图中各个部分的含义如下:
ISubject
接口的实例。ISubject
类型的资源。可以看到 SubjectImpl
和 SubjectProxy
都实现了相同的接口 ISubject
,在代理对象 SubjectProxy
内持有 ISubject
的引用,当 Client
访问 doOperation()
时,代理对象将请求转发给被代理对象,单单从这个过程来看,代理对象如果只是为了转发请求,是不是有点多此一举了?再结合代理模式的定义思考一下,在转发之前(后者之后)不就可以添加一些访问控制了吗。
在代理对象(SubjectProxy
)将请求转发给被代理对象(SubejctImpl
)之前或者之后都是根据需要添加一些处理逻辑,而不需要修改被代理对象的具体实现逻辑,假设 SubjectImpl
是我们系统中 Joinpoint
所在的对象,此时 SubjectImpl
就是我们的目标对象了,只需要为这个目标对象创建一个代理对象,然后将横切逻辑添加到代理对象中,对外暴露出创建出来的代理对象就可以将将横切逻辑和原来的逻辑融合在一起了。
到目前为止,一切都是那么美好,当只为同一个目标对象类型添加横切逻辑时,只需要创建一个代理对象即可,但是在 Joinpoint
相同而目标对象类型不同时,需要为每个不同的目标对象类型都单独创建一个代理对象,而这些代理对象的横切逻辑其实都是一样的,根据 DRY原则,需要寻找另一种技术来解决这个问题。
在 JDK 1.3
引入的 动态代理机制
可以为指定的接口在运行期
动态的去生成代理对象,使用这个动态代理机制
可以解决上述问题,这样我们就可以不事先为每个原始类创建代理类,而是在运行时动态生成
代理类。在 Java
中,使用动态代理是比较简单的,它本身就已经使用反射实现了动态代理的语法,主要是由一个类 Proxy
和一个接口 InvocationHandler
组成,使用动态代理机制实现前文示例如下:
1 | /** |
由以上代码可知,使用 JDK
的动态代理只要 3 步:
Subject
,创建被代理对象 SubjectImpl
JDKInvocationHandler
,持有目标对象 Subject
的引用JDK
的 Proxy
类的静态方法 newProxyInstance
创建代理对象通过设置 sun.misc.ProxyGenerator.saveGeneratedFiles
属性,可以将动态生成的代理类保存在项目根目录下,运行上面的示例代码生成的代理类如下:
1 | public final class $Proxy0 extends Proxy implements Subject { |
从动态生成的代理类的代码可以看出,JDK
动态代理生成的代理类是继承 JDK
中提供的 Proxy
和实现被代理类所实现的接口。进一步可以从这个实现方式得出两点:1. 使用 JDK
动态代理时为什么只能使用接口引用指向代理
,而不能使用被代理的具体类引用指向代理;2. 被代理类必须实现接口,因为 JDK
动态代理生成的代理类必须继承自 Proxy
,而 Java
不支持多重继承,所以只能通过实现接口的方式。
在默认情况下,Spring AOP
发现了目标对象实现了接口,会使用 JDK
动态代理机制为其动态生成代理对象,虽然提倡面向接口编程
,但是也有目标对象没有实现接口的场景,当被代理的目标对象没有实现接口时就无法使用 JDK
动态代理了,那么这种情况下就需要使用第三方工具来帮忙了。
当目标对象没有实现接口时,可以通过动态字节码生成来继承目标对象来动态生成相应的子类,在生成的子类中重写
父类目标对象的行为,然后将横切逻辑放在子类,在系统中使用目标对象的子类,最终的效果是代理模式是一样的,CGLIB 动态字节码生成类库(它本身其实也是一个抽象层,更底层是 ASM)可以动态生成和修改一个类的字节码。当以上示例代码目标对象未实现接口时修改为 CGLIB
动态生成字节码方式实现如下:
1 | /** |
使用 CGLIB
生成代理对象需要 4 步:
Enhancer
对象,动态生成字节码的绝大部分逻辑都是在这个类中完成的。final
类型的。MethodInterceptor
接口),在这里根据需要添加横切逻辑。Enhaner
的 create()
方法创建代理对象。在设置 DebuggingClassWriter.DEBUG_LOCATION_PROPERTY
属性后,反编译已保存的动态生成代理类如下:
从反编译后的代码可以看出 CGLIB
生成的代理类是通过继承被代理类 RealSubject
实现 Factory
接口实现的,要能被继承也就要求被代理类不能是 final
类型的。看到这里你可能会问:既然 JDK
动态代理要求被代理类实现接口,而 CGLIB
动态字节码生成要求不能是 final
类,那对于那些没有实现接口同时还是 final
类,要怎么动态代理呢?好问题,这个就留给你自己去思考了。
本文简要介绍了 Spring AOP
实现所依赖的核心基础技术,从动态代理的根源代理模式到动态代理和动态字节码生成技术,为下篇动手实现简易版的 AOP
打下基础,在了解了所依赖的基础技术后,在具体实现时就会更加丝滑,动态代理
和动态字节码生成
对比如下:
对比项 | JDK 动态代理 | CGLIB |
---|---|---|
生成代理类的方式 | 继承 JDK 中 Proxy ,实现被代理类的所有接口 | 继承被代理类,实现 CGLIB 的 Factory 接口 |
被代理对象的要求 | 必须实现接口,可以是 final 类 | 非 final 类,方法也要是非 final 类型的 |
集成方式 | JDK 内置 | 第三方动态字节码生成类库 |
本文是「如何实现一个简易版的 Spring 系列」的第五篇,在之前介绍了 Spring 中的核心技术之一 IoC,从这篇开始我们再来看看 Spring 的另一个重要的技术——AOP。用过 Spring 框架进行开发的朋友们相信或多或少应该接触过 AOP,用中文描述就是面向切面编程。学习一个新技术了解其产生的背景是至关重要的,在刚开始接触 AOP 时不知道你有没有想过这个问题,既然在面向对象的语言中已经有了 OOP 了,为什么还需要 AOP 呢?换个问法也就是说在 OOP 中有哪些场景其实处理得并不优雅,需要重新寻找一种新的技术去解决处理?(P.S. 这里建议暂停十秒钟,自己先想一想…)
我们做软件开发的最终目的是为了解决公司的各种需求,为业务赋能,注意,这里的需求包含了业务需求和系统需求,对于绝大部分的业务需求的普通关注点,都可以通过面向对象(OOP)的方式对其进行很好的抽象、封装以及模块化,但是对于系统需求使用面向对象的方式虽然很好的对其进行分解并对其模块化,但是却不能很好的避免这些类似的系统需求在系统的各个模块中到处散落的问题。
因此,需要去重新寻找一种更好的办法,可以在基于 OOP 的基础上提供一套全新的方法来处理上面的问题,或者说是对 OOP 面向对象的开发模式做一个补充,使其可以更优雅的处理上面的问题,迄今为止 Spring 提供一个的解决方案就是面向切面编程——AOP。有了 AOP 后,我们可以将这些事务管理、系统日志以及安全检查等系统需求(横切关注点:cross-cutting concern)进行模块化的组织,使得整个系统更加的模块化方便后续的管理和维护。细心的你应该发现在 AOP 里面引入了一个关键的抽象就是切面(Aspect),用于对于系统中的一些横切关注点进行封装,要明确的一点是 AOP 和 OOP 不是非此即彼的对立关系,AOP 是对 OOP 的一种补充和完善,可以相互协作来完成需求,Aspect 对于 AOP 的重要程度就像 Class 对 OOP 一样。
我们最终的目的是要模仿 Spring 框架自己去实现一个简易版的 AOP 出来,虽然是简易版但是会涉及到 Spring AOP 中的核心思想和主要实现步骤,不过在此之前先来看看 AOP 中的重要概念,同时也是为以后的实现打下理论基础,这里需要说明一点是我不会使用中文翻译去描述这些 AOP 定义的术语(另外,业界 AOP 术语本来就不太统一),你需要重点理解的是术语在 AOP 中代表的含义,就像我们不会把 Spring 给翻译成春天一样,在软件开发交流你知道它表示一个 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### Joinpoint
> A point during the execution of a program, such as the execution of a method or the handling of an exception. In Spring AOP, a join point always represents a method execution. -- Spring Docs
通过之前的介绍可知,在我们的系统运行之前,需要将 AOP 定义的一些横切关注点(功能模块)**织入**(可以简单理解为嵌入)到系统的一些业务模块当中去,想要完成织入的前提是我们需要知道可以在哪些**执行点**上进行操作,这些执行点就是 Joinpoint。下面看个简单示例:
```Java
/**
* @author mghio
* @since 2021-05-22
*/
public class Developer {
private String name;
private Integer age;
private String siteUrl;
private String position;
public Developer(String name, String siteUrl) {
this.name = name;
this.siteUrl = siteUrl;
}
public void setSiteUrl(String siteUrl) {
this.siteUrl = siteUrl;
}
public void setAge(Integer age) {
this.age = age;
}
public void setName(String name) {
this.name = name;
}
public void setPosition(String position) {
this.position = position;
}
public void showMainIntro() {
System.out.printf("name:[%s], siteUrl:[%s]\n", this.name, this.siteUrl);
}
public void showAllIntro() {
System.out.printf("name:[%s], age:[%s], siteUrl:[%s], position:[%s]\n",
this.name, this.age, this.siteUrl, this.position);
}
}
1 | /** |
理论上,在上面示例的这个 test() 方法调用中,我们可以选择在 Developer 的构造方法执行时进行织入,也可以在 showMainIntro() 方法的执行点上进行织入(被调用的地方或者在方法内部执行的地方),或者在 setAge() 方法设置 sge 字段时织入,实际上,只要你想可以在 test() 方法的任何一个执行点上执行织入,这些可以织入的执行点就是 Joinpoint。
这么说可能比较抽象,下面通过 test() 方法调用的时序图来直观的看看:
从方法执行的时序来看不难发现,会有如下的一些常见的 Joinpoint 类型:
虽然理论上,在程序执行中的任何执行点都可以作为 Joinpoint,但是在某些类型的执行点上进行织入操作,付出的代价比较大,所以在 Spring 中的 Joinpoint 只支持方法执行(Method execution)这一种类型(这一点从 Spring 的官方文档上也有说明),实际上这种类型就可以满足绝大部分的场景了。
A predicate that matches join points. Advice is associated with a pointcut expression and runs at any join point matched by the pointcut (for example, the execution of a method with a certain name). The concept of join points as matched by pointcut expressions is central to AOP, and Spring uses the AspectJ pointcut expression language by default.– by Spring Docs
Pointcut 表示的是一类 Jointpoint 的表述方式,在进行织入时需要根据 Pointcut 的配置,然后往那些匹配的 Joinpoint 织入横切的逻辑。这里面临的第一个问题:用人类的自然语言可以很快速的表述哪些我们需要织入的 Joinpoint,但是在代码里要如何去表述这些 Joinpoint 呢?
目前有如下的一些表述 Joinpoint 定义的方式:
另外 Pointcut 也支持进行一些简单的逻辑运算,这时我们就可以将多个简单的 Pointcut 通过逻辑运算组合为一个比较复杂的 Pointcut 了,比如在 Spring 配置中的 and 和 or 等逻辑运算标识符以及 AspectJ 中的 && 和 || 等逻辑运算符。
Action taken by an aspect at a particular join point. Different types of advice include “around”, “before” and “after” advice. (Advice types are discussed later.) Many AOP frameworks, including Spring, model an advice as an interceptor and maintain a chain of interceptors around the join point.– by Spring Docs
Advice 表示的是一个注入到 Joinpoint 的横切逻辑,是一个横切关注点逻辑的抽象载体。按照 Advice 的执行点的位置和功能的不同,分为如下几种主要的类型:
A modularization of a concern that cuts across multiple classes. Transaction management is a good example of a crosscutting concern in enterprise Java applications. In Spring AOP, aspects are implemented by using regular classes (the schema-based approach) or regular classes annotated with the @Aspect annotation (the @AspectJ style). – Spring Docs
Aspect 是对我们系统里的横切关注点(crosscutting concern)包装后的一个抽象概念,可以包含多个 Joinpoint 以及多个 Advice 的定义。Spring 集成了 AspectJ 后,也可以使用 @AspectJ 风格的声明式指定一个 Aspect,只要添加 @Aspect 注解即可。
An object being advised by one or more aspects. Also referred to as the “advised object”. Since Spring AOP is implemented by using runtime proxies, this object is always a proxied object. – by Spring Docs
目标对象一般是指那些可以匹配上 Pointcut 声明条件,被织入横切逻辑的对象,正常情况下是由 Pointcut 来确定的,会根据 Pointcut 设置条件的不同而不同。
有了 AOP 这些概念后就可以把上文的例子再次进行整理,各个概念所在的位置如下图所示:
本文首先对 AOP 技术的诞生背景做了简要介绍,后面介绍了 AOP 的几个重要概念为后面我们自己实现简易版 AOP 打下基础,AOP 是对 OOP 的一种补充和完善,文中列出的几个概念只是 AOP 中涉及的概念中的冰山一角,想要深入了解更多的相关概念的朋友们可以看 官方文档 学习,下篇是介绍 AOP 实现依赖的一些基础技术,敬请期待。转发、分享都是对我的支持,我将更有动力坚持原创分享!
]]>