Fork me on GitHub

分类 编程语言 下的文章

Java 21 初体验(三)

上一篇笔记上上一篇笔记 中,我们学习了 Java 21 中前 10 个重要特性:

接下来,我们将继续学习最后 5 个特性:

向量 API(第六次孵化)

向量 API 最初由 JEP 338 提出,并作为孵化 API 集成到 Java 16 中,在 Java 17 到 20 中,又经过了 JEP 414JEP 417JEP 426JEP 438 四次的孵化,这次在 Java 21 中,已经是第六次孵化了。

向量 API 又被称为 Vector API,要注意,这里讲的并不是 Java 中的 Vector 集合类,而是一种专门用于向量计算的全新 API。尽管这个 API 还在孵化期,并没有正式发布,但是这项技术很值得我们提前学习和了解,因为这项技术代表了 Java 语言发展的一个重要方向,在未来一定会有着重要的影响。随着生成式人工智能的发展,Embedding 技术也如日中天,它将各种类型的数据(如文本、图像、声音等)转换为高维数值向量,从而实现对数据特征和语义信息的表示。Embedding 技术在个性化推荐、多模态检索和自然语言处理等领域中发挥着重要作用,而这些场景都离不开向量计算。

什么是向量?

向量是数学和物理学中的一个基本概念,具有大小和方向两个属性,比如物理学中的力就是向量。向量可以有多种不同的表示方式:

  • 在代数中,一般印刷用黑体的小写英文字母来表示(比如 abc 等),手写用在 a、b、c 等字母上加一箭头(→)表示;
  • 在几何中,向量可以形象化地表示为带箭头的线段,箭头所指的方向代表向量的方向,线段长度则代表向量的大小;
  • 在坐标系中,向量可以用点的坐标位置来表示,比如平面直角坐标系中的向量可以记为 (x, y),空间直角坐标系中的向量可以记为 (x, y, z),多维空间以此类推;此外,向量也可以使用矩阵来表示;
  • 在计算机科学中,向量可以被理解为一个数字列表或数组,这在编程语言中尤为常见。

和向量这个概念相对应的,还有标量、矩阵、张量等概念,这几个概念可以代表不同的维度,一般用点线面体来类比:

  • 点——标量(scalar)
  • 线——向量(vector)
  • 面——矩阵(matrix)
  • 体——张量(tensor)

vector.png

标量计算 vs. 向量计算

标量就是一个数字,在 Java 中通常可以表示为一个整数或浮点数等,我们所熟知的算术运算基本上都是作用于标量之上的,比如下面的代码对 ab 两个标量求和:

int a = 1;
int b = 1;
int c = a + b;

如果将 ab 换成向量,也就是数组,该如何求和呢?最简单的方法是使用 for 循环依次相加数组中对应的元素:

int[] a = new int[] {1, 2, 3, 4};
int[] b = new int[] {1, 2, 3, 4};
int[] c = new int[4];
for (int i = 0; i < a.length; i++) {
    c[i] = a[i] + b[i];
}

很显然这不是什么高明的做法,仔细观察上面的代码就会发现,对于数组中每个元素的相加是互不影响的,那么我们能不能并行计算呢?一种有效的解决方法是使用 并行流(Parallel Stream)

IntStream.range(0, a.length)
    .parallel()
    .forEach(i -> c[i] = a[i] + b[i]);

另一种解决方法就是我们将要学习的 向量 API(Vector API)

IntVector aVector = IntVector.fromArray(IntVector.SPECIES_128, a, 0);
IntVector bVector = IntVector.fromArray(IntVector.SPECIES_128, b, 0);
IntVector cVector = aVector.add(bVector);

注意,由于向量 API 并没有正式发布,运行时需要手动加上 jdk.incubator.vector 模块:

$ java --add-modules jdk.incubator.vector VectorDemo.java

向量 API 定义了专门的向量类,比如这里的 IntVector,并提供了 fromArray 方法方便我们将数组转换为向量,然后再通过 aVector.add(bVector) 执行两个向量的加法运算。

除了加法运算,向量 API 还提供了一组方法来执行各种其他的向量计算:

  • 算术运算(Arithmetic Operations)

    • 加法:vector1.add(vector2)
    • 减法:vector1.sub(vector2)
    • 乘法:vector1.mul(vector2)
    • 除法:vector1.div(vector2)
  • 逐元素操作(Element-Wise Operations)

    • 绝对值:vector.abs()
    • 负数:vector.neg()
    • 平方根:vector.sqrt()
    • 指数:vector.exp()
    • 对数:vector.log()
  • 规约运算(Reductions)

    • 元素之和:vector.reduce(VectorOperators.ADD)
    • 最小元素:vector.reduce(VectorOperators.MIN)
    • 最大元素:vector.reduce(VectorOperators.MAX)
    • 平均值:vector.reduce(VectorOperators.ADD).mul(1.0 / vector.length())
  • 逻辑运算(Logical Operations)

    • 与:vector1.and(vector2)
    • 或:vector1.or(vector2)
    • 非:vector.not()
  • 比较操作(Comparisons)

    • 等于:vector1.eq(vector2)
    • 小于:vector1.lt(vector2)
    • 大于:vector1.compare(VectorOperators.GT, vector2)

单指令多数据(SIMD)

使用向量 API 来执行向量计算,不仅代码精简,容易理解,而且它还有另一个好处,那就是性能提升。尽管使用并行流也能提升一定的性能,但是并行流和向量 API 是两种完全不同的优化技术,前者使用多线程在不同的 CPU 核上并行计算,而后者通过 SIMD 技术,在单个 CPU 周期内对多个数据同时执行相同操作,从而达到并行计算的目的。

SIMD(Single Instruction, Multiple Data,单指令多数据) 是一种并行处理技术,它的核心思想是将一个控制器与多个处理单元结合在一起,使得这些处理单元可以针对不同的数据同时执行相同的操作,简单来说就是一个指令能够同时处理多个数据。这与传统的 SISD(Single Instruction, Single Data,单指令单数据) 形成对比,在后者中,一个指令只能处理一个数据。

在上面那个向量求和的例子中,我们先是使用 for 循环实现:

for (int i = 0; i < a.length; i++) {
    c[i] = a[i] + b[i];
}

数组中的每个元素将使用(大致)1 个 CPU 指令进行计算,这意味着我们需要 4 个指令或 4 个 CPU 周期才能完成计算,这就是典型的 SISD。而使用向量 API 可以将向量的计算编译为对应 CPU 架构上的 SIMD 指令集,只要 1 个指令即可完成向量计算:

simd.png

在实际应用中,许多现代处理器都支持 SIMD 指令集,如 Intel 的 MMXSSEAVX,ARM 的 NEONSVE 等,这些指令集能够显著提升程序的执行效率,特别是在需要大量数值计算的场景下。不过使用这些指令集的门槛并不低,通常涉及到汇编语言或一些特殊的函数库,比如 Intel 的跨平台函数库 IPP(Integrated Performance Primitives) 或使用 内置函数 (Intrinsic function) 等。

相比于传统的手写 SIMD 代码,Java 的向量 API 提供了更高的可读性和维护性,开发者可以使用熟悉的 Java 语法和类型系统,无需处理底层寄存器和指令,编写出简洁明了的、平台无关的、高性能的向量计算代码。

其实,在向量 API 提出之前,Java 已经在 SIMD 上探索了很长一段时间了,比如 HotSpot 的 自动向量化(Auto-Vectorization) 功能,它将标量操作转换为 超字操作(SuperWord Operations),然后再映射到 SIMD 指令。然而,这个过程完全依赖 JIT,并没有什么可靠的方法来保证编写的代码一定可以使用 SIMD 指令优化,有些代码甚至根本无法进行优化,开发人员必须深入了解 HotSpot 的自动向量化算法及其限制,才能实现可靠的性能提升。向量 API 使得这个过程完全由开发人员自己控制,因此可以写出更加可预测、更加稳健的代码。

我们可以通过 -XX:-UseSuperWord 参数关闭 HotSpot 的自动向量化功能。

使用向量 API

在学习了向量的基础知识之后,接下来我们将继续深入学习向量 API 的使用。

上面介绍向量计算时,我们已经学习了向量 API 的基本用法,使用 IntVector 实现两个向量相加。这个示例为了易于理解,做了简单处理,并没有考虑在实际使用时的边界情况,假设我们将 ab 两个数组改成 10 个数字:

int[] a = new int[] {1, 2, 3, 4, 5, 6, 7, 8, 9, 10};
int[] b = new int[] {1, 2, 3, 4, 5, 6, 7, 8, 9, 10};
IntVector aVector = IntVector.fromArray(IntVector.SPECIES_128, a, 0);
IntVector bVector = IntVector.fromArray(IntVector.SPECIES_128, b, 0);
IntVector cVector = aVector.add(bVector);

运行后得到的结果 c 仍然是 [2, 4, 6, 8],后面新加的数字并没有计算。这是因为每个向量的存储空间有限,并不能一次存下所有的数据。这里涉及向量 API 的一个重要概念:向量种类(Vector Species),它是 数据类型(Data Types)向量形状(Vector Shapes) 的组合;所谓数据类型就是 Java 的基础类型,比如 byte、short、int、long 这些整数类型和 float、double 浮点类型,而所谓向量形状就是向量的位大小或位数;比如这里的向量种类为 IntVector.SPECIES_128,它代表数据类型为 int,向量形状为 128 位;而我们知道,一般情况下 int 值的大小为 32 位,所以这个向量一次只能存储 128/32 = 4 个 int 值,这也被形象地称为 通道(Lanes),表示向量一次可以处理的数据个数。

知道这一点后,我们就可以写出更加通用的向量计算代码了。首先我们需要将数据按通道数分组,然后一组一组的进行处理:

int[] a = new int[] {1, 2, 3, 4, 5, 6, 7, 8, 9, 10};
int[] b = new int[] {1, 2, 3, 4, 5, 6, 7, 8, 9, 10};
int[] c = new int[10];
int lanes = IntVector.SPECIES_128.length();
int loopBound = IntVector.SPECIES_128.loopBound(a.length);
for (int i = 0; i < loopBound; i += lanes) {
    IntVector aVector = IntVector.fromArray(IntVector.SPECIES_128, a, i);
    IntVector bVector = IntVector.fromArray(IntVector.SPECIES_128, b, i);
    IntVector cVector = aVector.add(bVector);
    cVector.intoArray(c, i);
}
for (int i = loopBound; i < a.length; i++) {
    c[i] = a[i] + b[i];
}
IntStream.of(c).forEach(x -> System.out.println(x));

我们可以注意到,在遍历时 i 每次增加 lanes,它的值等于 IntVector.SPECIES_128.length(),也就是通道数,一般来说该值等于 4,所以我们是按 4 个一组进行处理的。但是要注意数据不一定能被通道数完全整除,比如这里 10 个数字,前 8 个可以分为两组处理掉,还剩下 2 个怎么办呢?这时我们只能使用最原始的标量计算来处理了。

此外,在实际编码时向量种类不建议写死,可以使用 IntVector.SPECIES_PREFERRED 替代,它会根据平台自动选择最合适的向量种类:

static final VectorSpecies<Integer> SPECIES = IntVector.SPECIES_PREFERRED;

可以看出尽管向量 API 的使用有不少好处,但是我们也需要谨慎对待:

  • 首先,在使用向量 API 时,数据对齐是一个重要的考虑因素,不对齐的数据访问可能会导致性能下降。开发者需要确保数据在内存中的对齐方式,以充分发挥 SIMD 指令的优势;
  • 另外,向量 API 有硬件依赖性,它依赖于底层硬件支持的 SIMD 指令集,许多功能可能在其他平台和架构上不可用,性能也可能会有所不同。开发者需要了解目标平台的特性,并进行适当的性能优化。

弃用 Windows 32-bit x86 移植,为删除做准备

这个特性比较简单。随着 64 位架构的普及,32 位操作系统逐渐被淘汰,比如微软从 Windows 10 开始就只提供 64 位版本了,Windows 10 将是最后一个支持 32 位的 Windows 操作系统,而且 2025 年 10 月后将不再支持

64 位架构相比于 32 位,在性能和安全方面都有巨大的提升。比如 64 位架构可以提供更大的内存地址空间,从而提高应用程序的性能和扩展性,同时它也引入了更多的保护机制,提高了应用程序的安全性。

但由于架构的差异,同时兼容 32 位和 64 位需要不少的维护成本,很多 Java 的新特性已经不支持 32 位系统了,比如虚拟线程,所以弃用 32 位势在必行。

在 Windows 32-bit x86 系统下构建 Java 21 的源码将报如下错误:

$ bash ./configure
...
checking compilation type... native
configure: error: The Windows 32-bit x86 port is deprecated and may be removed in a future release. \
Use --enable-deprecated-ports=yes to suppress this error.
configure exiting with result code 1
$

暂时可以通过 --enable-deprecated-ports=yes 参数来解决:

$ bash ./configure --enable-deprecated-ports=yes

准备禁用代理的动态加载

Java Agent 通常被直译为 Java 代理,它是一个 jar 包,这个 jar 包很特别,不能独立运行,而是要依附到我们的目标 JVM 进程中。它利用 JVM 提供的 Instrumentation API 来修改已加载到 JVM 中的字节码,从而实现很多高级功能,比如:

Java Agent 简单示例

为了对 Java Agent 的概念有一个更直观的认识,我们从一个简单的示例入手,从零开始实现一个 Java Agent。先创建如下目录结构:

├── pom.xml
└── src
    └── main
        ├── java
        │   └── com
        │       └── example
        │           └── AgentDemo.java
        └── resources
            └── META-INF
                └── MANIFEST.MF

包含三个主要文件:

  • pom.xml - Maven 项目的配置文件
  • AgentDemo.java - Java Agent 的入口类
  • MANIFEST.MF - 元数据文件,用于描述打包的 JAR 文件中的各种属性和信息

Java Agent 的入口类定义如下:

package com.example;

import java.lang.instrument.Instrumentation;

public class AgentDemo {

    public static void premain(String agentArgs, Instrumentation inst) {
        System.out.println("premain");
    }
}

我们知道,常规 Java 程序的入口方法是 main 函数,而 Java Agent 的入口方法是 premain 函数。其中,String agentArgs 是传递给 Agent 的参数,比如当我们运行 java -javaagent:agent-demo.jar=some-args app.jar 命名时,参数 agentArgs 的值就是字符串 some-args;另一个参数 Instrumentation inst 是 JVM 提供的修改字节码的接口,我们可以通过这个接口定位到希望修改的类并做出修改。

Instrumentation API 是 Java Agent 的核心,它可以在加载 class 文件之前做拦截,对字节码做修改(addTransformer),也可以在运行时对已经加载的类的字节码做变更(retransformClassesredefineClasses);Instrumentation 的英文释义是插桩或植入,所以这个操作又被称为 字节码插桩,由于这个操作非常的底层,一般会配合一些字节码修改的库,比如 ASMJavassistByte Buddy 等。关于 Instrumentation API 是一个较为艰深复杂的话题,本文为简单起见,没有深入展开,感兴趣的同学可以自行查找相关资料。

有了 Java Agent 的入口类之后,我们还需要告诉 JVM 这个入口类的位置,可以在 MANIFEST.MF 元数据文件中通过 Premain-Class 参数来描述:

Premain-Class: com.example.AgentDemo

打包的时候,要注意将 MANIFEST.MF 文件一起打到 jar 包里,这可以通过打包插件 maven-assembly-plugin 来实现:

<plugin>
    <groupId>org.apache.maven.plugins</groupId>
    <artifactId>maven-assembly-plugin</artifactId>
    <version>3.6.0</version>
    <configuration>
        <descriptorRefs>
            <descriptorRef>jar-with-dependencies</descriptorRef>
        </descriptorRefs>
        <archive>
            <manifestFile>src/main/resources/META-INF/MANIFEST.MF</manifestFile>
        </archive>
    </configuration>
    <executions>
        <execution>
            <phase>package</phase>
            <goals>
                <goal>single</goal>
            </goals>
        </execution>
    </executions>
</plugin>

最后,执行 mvn clean package 打包命令,生成 target/agent-demo-1.0-SNAPSHOT-jar-with-dependencies.jar 文件,我们就得到了一个最简单的 Java Agent 了。

Java Agent 的两种加载方式

Java Agent 最常见的使用方式是在运行 java 命令时通过 -javaagent 参数指定要加载的 Agent 文件:

$ java -javaagent:agent-demo-1.0-SNAPSHOT-jar-with-dependencies.jar Hello.java

这种方式被称为 静态加载(static loading)。在这种情况下,Java Agent 和应用程序一起启动,并在运行主程序的 main 方法之前先调用 Java Agent 的 premain 方法,下面是程序的运行结果:

premain
Hello

既然有静态加载,自然就有动态加载。动态加载(dynamic loading) 指的是将 Java Agent 动态地加载到已运行的 JVM 进程中,当我们不希望中断生产环境中已经运行的应用程序时,这个特性非常有用。

我们先正常启动一个 Java 应用程序:

$ java Hello.java
Hello

通过 jps 得到该程序的 PID,然后使用 Java 的 Attach API 附加(attach) 到该程序上:

String pidOfOtherJVM = "3378";
VirtualMachine vm = VirtualMachine.attach(pidOfOtherJVM);

附加成功后得到 VirtualMachine 实例,VirtualMachine 提供了一个 loadAgent() 方法用于动态加载 Java Agent:

File agentJar = new File("/com.docker.devenvironments.code/agent-demo-1.0-SNAPSHOT-jar-with-dependencies.jar");
vm.loadAgent(agentJar.getAbsolutePath());

// do other works

vm.detach();

查看应用程序的日志,可以发现如下报错:

Failed to find Agent-Class manifest attribute from /com.docker.devenvironments.code/agent-demo.jar

这是因为目前我们这个 Java Agent 还不支持动态加载,动态加载的入口并不是 premain 函数,而是 agentmain 函数,我们在 AgentDemo 类中新增代码如下:

...
    public static void agentmain(String agentArgs, Instrumentation inst) {
        System.out.println("agentmain");
    }
...

并在 MANIFEST.MF 文件中新增 Agent-Class 参数:

Agent-Class: com.example.AgentDemo

重新打包,并再次动态加载,可以在应用程序中看到日志如下:

WARNING: A Java agent has been loaded dynamically (/com.docker.devenvironments.code/agent-demo-1.0-SNAPSHOT-jar-with-dependencies.jar)
WARNING: If a serviceability tool is in use, please run with -XX:+EnableDynamicAgentLoading to hide this warning
WARNING: If a serviceability tool is not in use, please run with -Djdk.instrument.traceUsage for more information
WARNING: Dynamic loading of agents will be disallowed by default in a future release
agentmain

可以看到 agentmain 函数被成功执行,动态加载生效了。

禁用 Java Agent 的动态加载

在上面的应用程序日志中,我们可以看到几行 WARNING 提示,这其实就是 Java 21 引入的新内容了,当 JVM 检测到有 Java Agent 被动态加载,就会打印这几行警告信息,告知用户动态加载机制将在未来的版本中默认禁用。如果不想看到这样的日志,可以在启动应用程序时加上 -XX:+EnableDynamicAgentLoading 选项:

$ java -XX:+EnableDynamicAgentLoading Hello.java

那么 Java 21 为什么要禁用 Java Agent 的动态加载呢?这就要提到 Java 所追求的 Integrity by Default 原则了。Integrity 一般被翻译为 完整性,片面的理解就是要保证我们程序中的任何内容,包括数据或代码都是完整的、没有被篡改的。而 Instrumentation API 通过修改已加载到 JVM 中的字节码来改变现有应用程序,在不更改源代码的情况下改变应用程序的行为。当我们静态加载 Java Agent 时,这并不是什么大问题,因为这是用户明确且有意的使用;然而,动态加载则是间接的,它超出了用户的控制范围,可能对用户的应用程序造成严重破坏,很显然并不符合完整性原则。

因此,作为应用程序的所有者,必须有意识地、明确地决定允许和加载哪些 Java Agent:要么使用静态加载,要么通过 -XX:+EnableDynamicAgentLoading 选项允许动态加载。

密钥封装机制 API

密钥封装(Key Encapsulation) 是一种现代加密技术,它使用非对称或公钥加密来保护对称密钥。传统的做法是使用公钥加密随机生成的对称密钥,但这需要 填充(Paddings) 并且难以证明安全,密钥封装机制(Key Encapsulation Mechanism,KEM) 另辟蹊径,使用公钥的属性来推导相关的对称密钥,不需要填充。

KEM 的概念是由 Crammer 和 Shoup 在 Design and Analysis of Practical Public-Key Encryption Schemes Secure against Adaptive Chosen Ciphertext Attack 这篇论文中提出的,后来 Shoup 将其提议为 ISO 标准,并于 2006 年 5 月接受并发布为 ISO 18033-2

经过多年的发展,KEM 已经在多个密码学领域有所应用:

Java 平台中现有的加密 API 都无法以自然的方式表示 KEM,第三方安全提供商的实施者已经表达了对标准 KEM API 的需求。于是,Java 21 引入了一种新的 KEM API,使应用程序能够自然且方便地使用 KEM 算法。

对称加密

上面对 KEM 的描述中涉及大量现代密码学的概念,为了对 KEM 有一个更直观的认识,我们不妨快速浏览一遍密码学的发展历史。

我们经常会在各种讲述一二战的谍战片中看到破译电报的片段,当时使用的密码算法在现在看来是非常简单的,几乎所有的密码系统使用的都是 对称加密(Symmetric Cryptography) 算法,也就是说使用相同的密钥进行消息的加密与解密,因为这个特性,我们也称这个密钥为 共享密钥(Shared Secret Key)

symmetric-crypto.png

常见的对称加密算法有:DES3DESAESSalsa20 / ChaCha20BlowfishRC6Camelia 等。

其中绝大多数都是 块密码算法(Block Cipher) 或者叫 分组密码算法,这种算法一次只能加密固定大小的块(例如 128 位);少部分是 流密码算法(Stream Cipher),流密码算法将数据逐字节地加密为密文流。为了实现加密任意长度的数据,我们通常需要将分组密码算法转换为流密码算法,这被称为 分组密码的工作模式,常用的工作模式有:ECB(电子密码本)、CBC(密码块链接)、CTR(计数器)、CFB(密文反馈模式)、OFB(输出反馈模式)、GCM(伽罗瓦/计数器模式)) 等。

分组密码的工作模式其背后的主要思想是把明文分成多个长度固定的组,再在这些分组上重复应用分组密码算法,以实现安全地加密或解密任意长度的数据。某些分组模式(如 CBC)要求将输入拆分为分组,并使用填充算法(例如添加特殊填充字符)将最末尾的分组填充到块大小,也有些分组模式(如 CTR、CFB、OFB、CCM、EAX 和 GCM)根本不需要填充,因为它们在每个步骤中,都直接在明文部分和内部密码状态之间执行异或(XOR)运算。

因此我们在使用对称加密时,往往要指定 工作模式(Modes)填充模式(Paddings) 这两个参数,下面是使用 Java 标准库提供的接口实现 AES 加密和解密的示例:

private static void testAES() throws Exception {

    // 1. 生成对称密钥
    KeyGenerator keyGenerator = KeyGenerator.getInstance("AES");
    keyGenerator.init(new SecureRandom());
    Key secretKey =  keyGenerator.generateKey();

    // 1. 使用固定密钥:128 位密钥 = 16 字节
    // SecretKey secretKey = new SecretKeySpec("1234567890abcdef".getBytes(), "AES");

    // 2. 加密
    Cipher cipher = Cipher.getInstance("AES/ECB/PKCS5Padding");
    cipher.init(Cipher.ENCRYPT_MODE, secretKey);
    byte[] encrypted = cipher.doFinal("hello".getBytes());

    // 3. 解密
    cipher.init(Cipher.DECRYPT_MODE, secretKey);
    byte[] decrypted = cipher.doFinal(encrypted);
    System.out.println(new String(decrypted));
}

我们首先通过 KeyGenerator 生成一个对称密钥(也可以直接使用 SecretKeySpec 来定义一个固定的密钥,但是要注意密钥的长度),然后通过 算法名称/工作模式/填充模式 来获取一个 Cipher 实例,这里使用的是 AES 算法,ECB 分组模式以及 PKCS5Padding 填充模式,关于其他算法和模式可参考 Java Security Standard Algorithm Names。得到 Cipher 实例后,就可以对数据进行加密和解密,可以看到,这里加密和解密使用的是同一个密钥。

对称加密算法的问题有两点:

  • 需要安全的通道进行密钥交换,早期最常见的是面对面交换密钥,一旦密钥泄露,数据将完全暴露;
  • 每个点对点通信都需要使用不同的密钥,密钥的管理会变得很困难,如果你需要跟 100 个朋友安全通信,你就要维护 100 个不同的对称密钥;

综上,对称加密会导致巨大的 密钥交换密钥保存与管理 的成本。

密钥交换协议

为了解决对称加密存在的两大问题,密码学家们前仆后继,想出了各种各样的算法,其中最关键的一个是 Whitfield Diffie 和 Martin Hellman 在 1976 年公开发表的一种算法,也就是现在广为人知的 Diffie–Hellman 密钥交换(Diffie–Hellman Key Exchange,DHKE) 算法。

dhke.png

上图是经典 DHKE 协议的整个过程,其基本原理涉及到数学中的 模幂(Modular Exponentiations)离散对数(Discrete Logarithms) 的知识。

模幂是指求 ga 次幂模 p 的值,其中 g a p 均为整数,公式如下:

A = (g^a) mod p

而离散对数是指在已知 g p 和模幂值 A 的情况下,求幂指数 a 的逆过程。

我们通过将 p 设置为一个非常大的质数,使用计算机计算上述模幂的值是非常快的,但是求离散对数却非常困难,这也就是所谓的 离散对数难题(Discrete Logarithm Problem,DLP)

在 DHKE 协议中,Alice 和 Bob 首先约定好两个常数 gp,这两个数所有人都可见。然后他们分别生成各自的私钥 ab,这两个值各自保存,不对外公开。他们再分别使用各自的私钥计算出模幂 AB,这两个值就是他们的公钥:

A = (g^a) mod p
B = (g^b) mod p

接着,Alice 将 A 发送给 Bob,Bob 将 B 发送给 Alice,接受到彼此的公钥之后,他们使用自己的私钥来计算模幂:

S1 = (B^a) mod p
S2 = (A^b) mod p

根据模幂的数学性质,我们可以得知 S1S2 是相等的!

S1 = (B^a) mod p = (g^b)^a mod p = ( g^(b*a) ) mod p
S2 = (A^b) mod p = (g^a)^b mod p = ( g^(a*b) ) mod p

至此 Alice 和 Bob 就协商出了一个共享密钥,这个密钥可以在后续的通讯中作为对称密钥来加密通讯内容。可以看到,尽管整个密钥交换过程是公开的,但是任何窃听者都无法根据公开信息推算出密钥,这就是密钥交换协议的巧妙之处。

下面的代码演示了如何在 Java 中实现标准的 DHKE 协议:

private static void testKeyAgreement() throws Exception {

    // 1. Alice 和 Bob 分别生成各自的密钥对
    KeyPairGenerator keyPairGen = KeyPairGenerator.getInstance("DH");
    keyPairGen.initialize(512);
    KeyPair keyPairAlice = keyPairGen.generateKeyPair();
    KeyPair keyPairBob = keyPairGen.generateKeyPair();

    // 2. Alice 根据 Bob 的公钥协商出对称密钥
    KeyAgreement keyAgreement = KeyAgreement.getInstance("DH");
    keyAgreement.init(keyPairAlice.getPrivate());
    keyAgreement.doPhase(keyPairBob.getPublic(), true);
    byte[] secretKey1 = keyAgreement.generateSecret();

    // 3. Bob 根据 Alice 的公钥协商出对称密钥
    keyAgreement.init(keyPairBob.getPrivate());
    keyAgreement.doPhase(keyPairAlice.getPublic(), true);
    byte[] secretKey2 = keyAgreement.generateSecret();

    // 4. 比较双方的密钥是否一致
    System.out.println("Alice Secret key: " + HexFormat.of().formatHex(secretKey1));
    System.out.println("Bob Secret key: " + HexFormat.of().formatHex(secretKey2));
}

这里首先通过 KeyPairGenerator 为 Alice 和 Bob 分别生成密钥对(密钥对中包含了一个私钥和一个公钥,也就是上文中的 a/bA/B),然后使用 KeyAgreement.getInstance("DH") 获取一个 KeyAgreement 实例,用于密钥协商,Alice 根据 Bob 的公钥协商出对称密钥 S1,Bob 根据 Alice 的公钥协商出对称密钥 S2,根据输出结果可以看到 S1S2 是相等的。

非对称加密

从第一次世界大战、第二次世界大战到 1976 年这段时期密码的发展阶段,被称为 近代密码阶段。1976 年是密码学的一个分水岭,在 Whitfield Diffie 和 Martin Hellman 这篇论文 中,他们不仅提出了 DHKE 算法,还提出了 公钥密码学(Public- Key Cryptography) 的概念。

公钥密码学中最核心的部分是 非对称加密(Asymmetric Encryption) 算法,和 DHKE 算法类似,它也是基于两个不同的密钥来实现加密和解密,一个称为公钥,另一个称为私钥,其中公钥可以公开,任何人都能访问;但和 DHKE 不同的是,DHKE 中的公钥只是用于协商出一个对称密钥,用于后续通讯的加解密,而在非对称加密中,不需要密钥协商,消息的发送者可以直接使用接受者的公钥对数据进行加密,而加密后的数据只有私钥的持有者才能将其解密。

asymmetric-encryption.png

非对称加密算法的这种神奇特性,使得通讯双发不需要预先协商密钥,因此非常适合在多方通信中使用;也使得公钥密码学的概念很快就深入人心,它极大地推动了现代密码学的发展,为 数字签名数字证书 提供了理论基础,特别是 公钥基础设施(PKI) 体系的建立,实现安全的身份验证和数据保护。

可以说,非对称加密是密码学领域一项划时代的发明,它宣告了近代密码阶段的终结,是现代密码学的起点。


最著名的非对称加密算法非 RSA 莫属,它是 1977 年由三位美国数学家 Ron Rivest、Adi Shamir 和 Leonard Adleman 共同设计的,这种算法以他们名字的首字母命名。RSA 算法涉及不少数论中的基础概念和定理,比如 互质欧拉函数模反元素中国余数定理费马小定理 等,网上有大量的文章介绍 RSA 算法原理,感兴趣的同学可以查阅相关的资料。

不过对于初学者来说,这些原理可能显得晦涩难懂,不妨玩一玩下面这个数学小魔术:

首先,让 A 任意想一个 3 位数,并把这个数乘以 91,然后将积的末三位告诉 B,B 就可以猜出 A 想的是什么数字。比如 A 想的是 123,那么他就计算出 123 * 91 = 11193,并把结果的末三位 193 告诉 B。那么 B 要怎么猜出对方的数字呢?其实很简单,只需要把对方说的数字再乘以 11,乘积的末三位就是 A 刚开始想的数了。可以验证一下,193 * 11 = 2123,末三位正是对方所想的秘密数字!

这个小魔术的道理其实很简单,由于 91 * 11 = 1001,而任何一个三位数乘以 1001 后,末三位显然都不变,例如 123 * 1001 = 123123

这个例子直观地展示了非对称加密算法的工作流程:A 和 B 可以看做消息的发送方和接受方,其中 91 是 B 的公钥,123 是 A 要发送的消息,123 * 91 就好比使用公钥加密,193 就是加密后的密文;而 11B 的私钥,193 * 11 就是使用私钥解密。

RSA 算法的本质就是上面这套思想,只不过它不是简单的乘法计算,而是换成了更加复杂的指数和取模运算。

下面继续使用 Java 代码来实现 RSA 的加密和解密:

private static void testRSA() throws Exception {

    // 1. Bob 生成密钥对
    KeyPairGenerator keyPairGen = KeyPairGenerator.getInstance("RSA");
    keyPairGen.initialize(2048);
    KeyPair keyPairBob = keyPairGen.generateKeyPair();

    // 2. Alice 使用 Bob 的公钥加密数据
    Cipher cipher1 = Cipher.getInstance("RSA");
    cipher1.init(Cipher.ENCRYPT_MODE, keyPairBob.getPublic());
    byte[] encrypted = cipher1.doFinal("hello".getBytes());

    // 3. Bob 使用自己的私钥解密数据
    Cipher cipher2 = Cipher.getInstance("RSA");
    cipher2.init(Cipher.DECRYPT_MODE, keyPairBob.getPrivate());
    byte[] decrypted = cipher2.doFinal(encrypted);

    System.out.println(new String(decrypted));
}

这里的代码和对称加密如出一辙,都是先通过 Cipher.getInstance() 获取一个 Cipher 实例,然后再通过它对数据进行加密和解密;和对称加密不同的是,这里加密用的是 Bob 的公钥,而解密用的是 Bob 的私钥。

其实,根据非对称加密的性质,我们不仅可以 公钥加密,私钥解密,而且也可以 私钥加密,公钥解密,不过用私钥加密的信息所有人都能够用公钥解密,这看起来貌似没啥用,但是密码学家们却发现它大有用处,由于私钥加密的信息只能用公钥解密,也就意味着这个消息只能是私钥持有者发出的,其他人是不能伪造或篡改的,所以我们可以把它用作 数字签名,数字签名在数字证书等应用中。

除了 RSA 算法,还有一些其他重要的非对称加密算法,比如 Rabin 密码ElGamal 密码 以及基于椭圆曲线的 ECC 密码(Elliptic Curve Cryptography) 等。

后量子密码学

非对称加密算法的安全性,基本上都是由不同的数学难题保障的,比如:

这些数学难题暂时都没有好方法解决,所以这些非对称加密算法暂时仍然被认为是安全的;一旦这些数学难题被破解,那么这些加密算法就不再安全了。

近年来,随着 量子计算机 的不断发展,很多运行于量子计算机的量子算法被提出来,其中最著名的是数学家彼得·秀尔于 1994 年提出的 秀尔算法,可以在多项式时间内解决整数分解问题。

这也就意味着,如果攻击者拥有大型量子计算机,那么他可以使用秀尔算法解决整数分解问题,从而破解 RSA 算法。不仅如此,后来人们还发现,使用秀尔算法也可以破解离散对数和椭圆曲线等问题,这导致目前流行的公钥密码系统都是 量子不安全(quantum-unsafe) 的。如果人类进入量子时代,这些密码算法都将被淘汰。

密码学家们估算认为,破解 2048 位的 RSA 需要 4098 个量子比特与 5.2 万亿个托佛利门,目前还不存在建造如此大型量子计算机的科学技术,因此现有的公钥密码系统至少在未来十年(或更久)依然是安全的。尽管如此,密码学家已经积极展开了后量子时代的密码学研究,也就是 后量子密码学(Post-quantum Cryptography,PQC)

目前已经有一些量子安全的公钥密码系统问世,但是由于它们需要更长的密钥、更长的签名等原因,并没有被广泛使用。这些量子安全的公钥密码算法包括:NewHopeNTRUBLISSKyber 等,有兴趣的同学可以自行查阅相关文档。

混合密码系统

非对称加密好处多多,既可以用来加密和解密,也可以用来签名和验证,而且还大大降低了密钥管理的成本。不过非对称加密也有不少缺点:

  • 使用密钥对进行加解密,算法要比对称加密更复杂;而且一些非对称密码系统(如 ECC)不直接提供加密能力,需要结合使用更复杂的方案才能实现加解密;
  • 只能加解密很短的消息;
  • 加解密非常缓慢,比如 RSA 加密比 AES 慢 1000 倍;

为了解决这些问题,现代密码学提出了 混合密码系统(Hybrid Cryptosystem)混合公钥加密(Hybrid Public Key Encryption,HPKE) 的概念,将对称加密和非对称加密的优势相结合,好比同时装备电动机和发动机两种动力系统的混合动力汽车。发送者首先生成一个对称密码,使用这个对称密码来加密消息,然后使用接受者的公钥来加密对称密码;接受者首先使用自己的私钥解密出对称密码,然后再用对称密码解密消息。这里的对称密码也被称为 会话密钥(Session Key)

下面的代码演示了 Alice 是如何利用 Bob 的公钥将一个 AES 对称密钥发送给 Bob 的:

private static void testRSA_AES() throws Exception {

    // 1. Bob 生成密钥对
    KeyPairGenerator keyPairGen = KeyPairGenerator.getInstance("RSA");
    keyPairGen.initialize(2048);
    KeyPair keyPair = keyPairGen.generateKeyPair();

    // 2. Alice 生成一个对称密钥
    KeyGenerator keyGen = KeyGenerator.getInstance("AES");
    keyGen.init(256);
    SecretKey secretKey = keyGen.generateKey();

    // 3. Alice 使用 Bob 的公钥加密对称密钥
    Cipher cipher1 = Cipher.getInstance("RSA");
    cipher1.init(Cipher.ENCRYPT_MODE, keyPair.getPublic());
    byte[] secretKeyEncrypted = cipher1.doFinal(secretKey.getEncoded());

    // 4. Bob 使用自己的私钥解密出对称密钥
    Cipher cipher2 = Cipher.getInstance("RSA");
    cipher2.init(Cipher.DECRYPT_MODE, keyPair.getPrivate());
    byte[] secretKeyDecrypted = cipher2.doFinal(secretKeyEncrypted);

    // 5. 比较双方的密钥是否一致
    System.out.println("Alice Secret key: " + HexFormat.of().formatHex(secretKey.getEncoded()));
    System.out.println("Bob Secret key: " + HexFormat.of().formatHex(secretKeyDecrypted));
}

可以看出,在混合密码系统中,非对称加密算法的作用和上文中的 DHKE 一样,只是用于密钥交换,并不用于加密消息,这和 DHKE 的工作原理几乎是一样的,所以严格来说,DHKE 也算是一种混合密码系统,只是两种密钥交换的实现不一样罢了。如何将会话密钥加密并发送给对方,就是 密钥封装机制(Key Encapsulation Mechanisms,KEM) 要解决的问题。

密钥封装机制

综上所述,密钥封装机制就是一种基于非对称加密的密钥交换技术,其主要目的是在不直接暴露私钥的情况下安全地传输会话密钥。

在 KEM 中,发起方运行一个封装算法产生一个会话密钥以及与之对应的 密钥封装消息(key encapsulation message),这个消息在 ISO 18033-2 中被称为 密文(ciphertext),随后发起方将密钥封装消息发送给接收方,接收方收到后,使用自己的私钥进行解封,从而获得相同的会话密钥。一个 KEM 由三部分组成:

  • 密钥对生成函数:由接收方调用,用于生成密钥对,包含公钥和私钥;
  • 密钥封装函数:由发送方调用,根据接收方的公钥产生一个会话密钥和密钥封装消息,然后发送方将密钥封装消息发送给接收方;
  • 密钥解封函数:由接收方调用,根据自己的私钥和接受到的密钥封装消息,计算出会话密钥。

其中第一步可以由现有的 KeyPairGenerator API 完成,但是后两步 Java 中暂时没有合适的 API 来自然的表示,这就是 JEP 452 被提出的初衷。通过 密钥封装机制 API(KEM API) 可以方便的实现密钥封装和解封:

private static void testKEM() throws Exception {

    // 1. Bob 生成密钥对
    KeyPairGenerator keyPairGen = KeyPairGenerator.getInstance("X25519");
    KeyPair keyPair = keyPairGen.generateKeyPair();

    // 2. Alice 根据 Bob 的公钥生成一个 Encapsulated 对象,这个对象里包含了:
    //    * 共享密钥 shared secret
    //    * 密钥封装消息 key encapsulation message
    //    * 可选参数 optional parameters
    //    然后 Alice 将密钥封装消息发送给 Bob
    KEM kem1 = KEM.getInstance("DHKEM");
    Encapsulator sender = kem1.newEncapsulator(keyPair.getPublic());
    Encapsulated encapsulated = sender.encapsulate();
    SecretKey k1 = encapsulated.key();

    // 3. Bob 根据自己的私钥和 Alice 发过来的密钥封装消息,计算出共享密钥
    KEM kem2 = KEM.getInstance("DHKEM");
    Decapsulator receiver = kem2.newDecapsulator(keyPair.getPrivate());
    SecretKey k2 = receiver.decapsulate(encapsulated.encapsulation());

    // 4. 比较双方的密钥是否一致
    System.out.println(Base64.getEncoder().encodeToString(k1.getEncoded()));
    System.out.println(Base64.getEncoder().encodeToString(k2.getEncoded()));
}

从代码可以看出密钥封装机制和混合密码系统有点像,但是看起来要更简单一点,省去了使用 KeyGenerator.generateKey() 生成对称密钥的步骤,而是使用密钥封装算法直接给出,至于这个密钥封装算法可以抽象成任意的实现,可以是密钥生成算法,也可以是随机数算法。

Java 文档 中可以看到 KEM 算法暂时只支持 DHKEM 这一种。但是 KEM API 提供了 服务提供商接口(Service Provider Interface,SPI),允许安全提供商在 Java 代码或本地代码中实现自己的 KEM 算法,比如 RSA-KEM、ECIES-KEM、PSEC-KEM、PQC-KEM 等。

结构化并发(预览版本)

结构化并发(Structured Concurrency) 最初由 JEP 428 提出,并在 JDK 19 中作为孵化 API 发布,接着又在 JDK 20 中通过 JEP 437 再次孵化,现在该特性进入预览版本了。结构化并发是一种多线程编程方法,它将在不同线程中运行的相关任务组视为单个工作单元,从而简化错误处理和取消操作,提高程序的可靠性和可观察性。

结构化并发和虚拟线程、作用域值一样,都是由 Loom 项目发展而来。

那么到底什么是结构化并发呢?我们不妨从结构化编程的概念开始聊起。

结构化编程(Structured Programming)

计算机发展的早期,程序员必须使用很低级的编程语言去写程序,比如汇编语言,通过一条条的硬件指令去操作计算机,这种编程方式非常痛苦;于是一些计算机界大佬便开始着手重新设计编程语言,使用类似英语的语句来表达操作,这就诞生了一批比汇编语言稍微高级一点的编程语言,如 FORTRAN、FLOW-MATIC、COBOL 等。

这些语言和现在我们所使用的 Java 或者 C 等高级语言还是有一些差距的,没有函数代码块,没有条件或循环控制语句,这些现在看来稀松平常的特性当时还没有被发明出来。设想一下如果程序只能从上往下顺序执行,那么我们就不能复用之前已经编写过的逻辑,想要重新执行一遍之前的逻辑,就得把前面的代码重写一遍,很显然这是非常麻烦的,所以一些设计者在语言中加入了 GOTO 语句,可以让程序在执行时跳转到指定位置,从而实现代码复用。

GOTO 语句的发明使得编程语言变得更加强大,但是这种跳转执行的逻辑使得程序充满了不确定性,一旦程序中大量使用了 GOTO 语句,整个代码就会变得一团糟:

spaghetti.jpg

这种代码如同面条一般,所以被形象地戏称为 面条式代码(Spaghetti Code)

1968 年 3 月,荷兰计算机科学家 Edsger W. Dijkstra 发表了一篇文章 Goto Statement Considered Harmful,提出了著名的 GOTO 有害论;后来,他又编写了一部札记 Notes on Structured Programming,通过大量的篇幅详细阐述了他理想中的编程范式,首次提出了 结构化编程(Structured Programming) 的概念。

structured-programming.jpg

结构化编程的核心思想是 基于块语句,实现代码逻辑的抽象与封装,从而保证控制流拥有单一的入口与出口,现代编程语言中的条件语句、循环语句、方法调用都是结构化编程的体现,我们基于现代编程语言所编写的程序,基本上都是结构化的。

相比 GOTO 语句,结构化编程使代码逻辑变得更加清晰,思维模型变得更加简单;如今,大部分现代编程语言都已经禁用 GOTO 语句,尽管 breakcontinue 语句仍然可以实现跳转逻辑,但是他们还是遵循结构化的基本原则:控制流拥有单一的入口与出口。

少部分编程语言仍然支持 GOTO,但是它们大都遵循高德纳所提出的前进分支和后退分支不得交叉的原则。

结构化并发(Structured Concurrency)

了解了结构化编程的历史后,我们再来看看什么是结构化并发。假设我们有两个独立的任务 task1task2 需要执行,由于它们之间互不影响,我们可以使用 ExecutorService 来并发执行:

private static void testExecutorService() throws Exception {
    System.out.println("main thread start");
    ExecutorService executor = Executors.newCachedThreadPool();
    Future<Integer> f1 = executor.submit(() -> task1(0));
    Future<Integer> f2 = executor.submit(() -> task2(0));
    System.out.println(f1.get());
    System.out.println(f2.get());
    System.out.println("main thread end");
    executor.shutdown();
}

通过 submit 提交任务,并通过 get 等待任务执行结束,代码非常简单,整个流程也非常顺利。然而,真实情况却未必如此,由于子任务并发执行,每个子任务都可能成功或失败,当某个子任务失败时,我们要考虑的事情可能会变得出乎意料地复杂:

  • 如果 task1 运行失败,那么在调用 f1.get() 时会抛出异常,但 task2 将继续在其自己的线程中运行,这是一种线程泄漏,不仅浪费资源,而且可能会干扰其他任务;
  • 如果 task2 运行失败,由于先执行 f1.get(),会阻塞等待 task1 运行结束才会执行 f2.get() 抛出异常,task1 可能会执行很久,这是一种不必要的等待;
  • 如果主线程被中断,该中断不会传播到子任务中,task1task2 线程都会泄漏;
  • 另一种场景中,如果我们只需要 task1task2 中的任意一个结果,这又该如何实现?

其实以上这些场景都可以实现,但需要极其复杂、难以维护的代码,比如 这里 使用 CompletableFuture 演示了三个子任务之间互相取消的场景,其代码的复杂程度应该会吓坏不少人。

此外,这类代码也不好调试,通过线程转储,我们会得到一堆名为 “pool-X-thread-Y” 的线程,我们无法知道哪个子线程属于哪个主线程,每个子线程的运行就像非结构化编程中的 GOTO 一样,不知道会跳转到哪里。这种情况被称为 非结构化并发(Unstructured Concurrency)。我们的任务在一张错综复杂的线程网中运行,其开始与结束在代码中难以察觉,缺乏清晰的错误处理机制,当主线程结束时,常常会出现孤立线程的情况。

结构化并发(Structured Concurrency) 正是为解决这些问题而提出的,它的核心思想和结构化编程一样:在并发模型下,也要保证控制流拥有单一的入口与出口。程序可以产生多个子线程来实现并发,但是所有子线程最终都要在统一的出口处完成合并:

structured-concurrency-vs-unstructured-concurrency.png

使用结构化并发有着诸多好处:

  • 在出口处,所有子线程都应该处于完成或取消状态,所以子线程的开始和结束变得清晰可见,这使得代码更易于阅读和维护;
  • 子线程发生的错误能传播到父线程中,父线程的取消也能传播到子线程中,从而简化了线程之间的错误处理和状态控制;
  • 另外,线程转储还可以保持父线程与子线程之间的调用层次结构,增强了可观察性,有助于程序调试。

使用 StructuredTaskScope 实现结构化并发

在 Java 中,实现结构化并发的基本 API 是 StructuredTaskScope,它的基本用法如下:

private static void testStructuredTaskScope() throws Exception {
    System.out.println("main thread start");
    try (var scope = new StructuredTaskScope<Object>()) {
        Subtask<Integer> t1 = scope.fork(() -> task1(0));
        Subtask<Integer> t2 = scope.fork(() -> task2(0));
        scope.join();
        System.out.println(t1.get());
        System.out.println(t2.get());
    }
    System.out.println("main thread end");
}

这里实现了和之前代码同样的逻辑,只是写法上略有区分,我们将 ExecutorService 替换为 StructuredTaskScope,并将 executor.submit() 替换为 scope.fork(),然后使用 scope.join() 等待所有任务完成。之后,我们可以通过 Subtask.get() 读取子任务的结果,如果某个子任务发生异常,Subtask.get() 会抛出 IllegalStateException 异常。因此,在调用 get() 之前,最好先用 state() 查询子任务的状态:

if (t1.state() == Subtask.State.SUCCESS) {
    System.out.println(t1.get());
} else {
    System.out.println("task1 error: " + t1.exception().getMessage());
}

StructuredTaskScope 的关闭策略

scope.join() 可以保证所有子线程全部处于完成或取消状态,这样可以消除孤儿线程的风险。但是在有些场景下,如果某个子线程异常,等待其他子任务的结果就没有了意义,这时我们可以取消其他子任务,避免无谓的等待;还有些情况是,只要有一个子任务运行成功即可,无需等待所有任务都运行结束。这就引出了 StructuredTaskScope关闭策略(Shutdown policies)StructuredTaskScope 定义了两种关闭策略,分别处理这两种情况:

ShutdownOnFailure 策略

使用 ShutdownOnFailure 策略,当某个子任务中发生异常时,将导致所有其他子任务终止。它的使用方法如下所示:

private static void testStructuredTaskScopeShutdownOnFailure() throws Exception {
    System.out.println("main thread start");
    try (var scope = new StructuredTaskScope.ShutdownOnFailure()) {
        Subtask<Integer> t1 = scope.fork(() -> task1(1));
        Subtask<Integer> t2 = scope.fork(() -> task2(0));
        scope.join().throwIfFailed();
        System.out.println(t1.get());
        System.out.println(t2.get());
    }
    System.out.println("main thread end");
}

首先,我们使用 new StructuredTaskScope.ShutdownOnFailure() 创建一个 ShutdownOnFailure 策略的 StructuredTaskScope,然后在 scope.join() 的时候,通过 throwIfFailed() 让其在子任务失败时抛出异常。假设 task1 异常,运行结果如下:

main thread start
task1 start
task2 start
java.lang.InterruptedException
        at java.base/java.lang.VirtualThread.sleepNanos(VirtualThread.java:805)
        at java.base/java.lang.Thread.sleep(Thread.java:507)
        at StructuredConcurrencyDemo.task2(StructuredConcurrencyDemo.java:91)
        at StructuredConcurrencyDemo.lambda$9(StructuredConcurrencyDemo.java:130)
        at java.base/java.util.concurrent.StructuredTaskScope$SubtaskImpl.run(StructuredTaskScope.java:889)
        at java.base/java.lang.VirtualThread.run(VirtualThread.java:311)
task2 end
Exception in thread "main" java.util.concurrent.ExecutionException: java.lang.RuntimeException: code is illegal
        at java.base/java.util.concurrent.StructuredTaskScope$ShutdownOnFailure.throwIfFailed(StructuredTaskScope.java:1318)
        at java.base/java.util.concurrent.StructuredTaskScope$ShutdownOnFailure.throwIfFailed(StructuredTaskScope.java:1295)
        at StructuredConcurrencyDemo.testStructuredTaskScopeShutdownOnFailure(StructuredConcurrencyDemo.java:131)
        at StructuredConcurrencyDemo.main(StructuredConcurrencyDemo.java:14)
Caused by: java.lang.RuntimeException: code is illegal
        at StructuredConcurrencyDemo.task1(StructuredConcurrencyDemo.java:74)
        at StructuredConcurrencyDemo.lambda$8(StructuredConcurrencyDemo.java:129)
        at java.base/java.util.concurrent.StructuredTaskScope$SubtaskImpl.run(StructuredTaskScope.java:889)
        at java.base/java.lang.VirtualThread.run(VirtualThread.java:311)

可以看到当 task1 异常时,task2 出现了 InterruptedException,说明 task2 被中断了,从而避免了无谓的等待。

ShutdownOnSuccess 策略

使用 ShutdownOnSuccess 策略,只要某个子任务中成功,将导致所有其他子任务终止。它的使用方法如下所示:

private static void testStructuredTaskScopeShutdownOnSuccess() throws Exception {
    System.out.println("main thread start");
    try (var scope = new StructuredTaskScope.ShutdownOnSuccess<Object>()) {
        scope.fork(() -> task1(0));
        scope.fork(() -> task2(0));
        scope.join();
        System.out.println(scope.result());
    }
    System.out.println("main thread end");
}

首先,我们使用 new StructuredTaskScope.ShutdownOnSuccess<Object>() 创建一个 ShutdownOnSuccess 策略的 StructuredTaskScope,然后通过 scope.join() 等待子任务结束,任意一个子任务结束,整个 StructuredTaskScope 都会结束,并保证其他子任务被取消,最后通过 scope.result() 获取第一个运行成功的子任务结果。运行结果如下:

main thread start
task1 start
task2 start
task2 end
2
java.lang.InterruptedException
        at java.base/java.lang.VirtualThread.sleepNanos(VirtualThread.java:805)
        at java.base/java.lang.Thread.sleep(Thread.java:507)
        at StructuredConcurrencyDemo.task1(StructuredConcurrencyDemo.java:78)
        at StructuredConcurrencyDemo.lambda$10(StructuredConcurrencyDemo.java:142)
        at java.base/java.util.concurrent.StructuredTaskScope$SubtaskImpl.run(StructuredTaskScope.java:889)
        at java.base/java.lang.VirtualThread.run(VirtualThread.java:311)
task1 end
main thread end

可以看到当 task2 最先运行结束,所以输出了 task2 的结果,同时 task1 出现了 InterruptedException,说明 task1 被中断了,避免了线程泄露。

自定义关闭策略

如果这两个标准策略都不满足你的需求,我们还可以编写自定义的策略,通过继承 StructuredTaskScope 类,并重写其 handleComplete(...) 方法,从而实现不同于 ShutdownOnSuccessShutdownOnFailure 的策略。这里 有一个自定义关闭策略的示例可供参考。

可观察性

使用结构化并发的另一个好处是,线程是有层次结构的,我们可以从线程转储中看到某个主线程都派生了哪些子线程,也可以看出某个子线程来自于哪个主线程,从而方便问题排查。使用下面的命令以 JSON 格式进行线程转储:

$ jcmd <pid> Thread.dump_to_file -format=json threads.json

从转储结果中可以清晰的看到线程之间的层次结构:

{
    "container": "java.util.concurrent.StructuredTaskScope$ShutdownOnSuccess@58644d46",
    "parent": "<root>",
    "owner": "1",
    "threads": [
        {
            "tid": "19",
            "name": "",
            "stack": [
                "java.base\/java.lang.VirtualThread.parkNanos(VirtualThread.java:631)",
                "java.base\/java.lang.VirtualThread.sleepNanos(VirtualThread.java:803)",
                "java.base\/java.lang.Thread.sleep(Thread.java:507)",
                "StructuredConcurrencyDemo.task1(StructuredConcurrencyDemo.java:78)",
                "StructuredConcurrencyDemo.lambda$10(StructuredConcurrencyDemo.java:142)",
                "java.base\/java.util.concurrent.StructuredTaskScope$SubtaskImpl.run(StructuredTaskScope.java:889)",
                "java.base\/java.lang.VirtualThread.run(VirtualThread.java:311)"
            ]
        },
        {
            "tid": "21",
            "name": "",
            "stack": [
                "java.base\/java.lang.VirtualThread.parkNanos(VirtualThread.java:631)",
                "java.base\/java.lang.VirtualThread.sleepNanos(VirtualThread.java:803)",
                "java.base\/java.lang.Thread.sleep(Thread.java:507)",
                "StructuredConcurrencyDemo.task2(StructuredConcurrencyDemo.java:92)",
                "StructuredConcurrencyDemo.lambda$11(StructuredConcurrencyDemo.java:143)",
                "java.base\/java.util.concurrent.StructuredTaskScope$SubtaskImpl.run(StructuredTaskScope.java:889)",
                "java.base\/java.lang.VirtualThread.run(VirtualThread.java:311)"
            ]
        }
    ],
    "threadCount": "2"
}

总结

这个 Java 21 的学习笔记系列,是从去年 12 月份开始学习并整理的,中间由于学习大模型相关技术停滞了半年时间,今年 7 月份开始继续写,前前后后花了大约 3 个多月的时间,总算将 Java 21 里的所有特性都过了一遍。Java 21 是最新的 LTS 版本,自 2023 年 9 月发布以来,已经在很多企业和项目中使用。Java 技术在飞速发展和演进,Java 22 于今年 3 月发布,Java 23 计划在 9 月推出,这样的发展速度让我觉得它并不是一个年近 30 的编程语言,而是一个朝气蓬勃、活力无限的语言,为了不使自己落伍,学习 Java 新版本已经势在必行。

Java 21 带来了一系列重要的功能和特性,在学习过程中,为了彻底搞清每个特性的来龙去脉,我都尽量从最基础的概念讲起。比如学习密钥封装机制 API 时,我们从对称加密、非对称加密、混合密码系统开始学起,最终才引出 KEM 的概念和作用;比如学习外部函数和内存 API 时,我们从 JNI 的缺点引出 FFI,从 ByteBufferUnsafe 的缺点引出 Memory API,从而对 FFM API 有一个更深入的认识;又比如学习向量 API 时,我们从最基础的向量是什么开始学起,然后引入标量计算、向量计算以及 SIMD 的概念,让我们明白 Java 为何要引入向量 API,以及看到 Java 语言发展的一个重要方向。

关于 Java 21 网上已经有大量的学习资料和教程,但是和网上那些教程不同的是,我在学习时喜欢抽丝剥茧,追根溯源,打破砂锅问到底。我认为只有充分了解一个事物的历史,才能真正掌握这个事物的未来,我对 Java 21 中的每一个特性都做了一定的延伸和展开。但是同时,我在学习时也有很多参考这些教程的地方,笔记中使用的图片大多引用自这些教程,在这里对所有原作者表示感谢。

整个系列篇幅较长,如有遗漏,还望指正。

参考

扫描二维码,在手机上阅读!

Java 21 初体验(二)

上一篇笔记 中,我们学习了 Java 21 中前 5 个重要特性:

接下来,我们将继续学习后面的 5 个特性:

外部函数和内存 API(第三次预览版本)

外部函数和内存 API(Foreign Function & Memory API,简称 FFM API) 是 Java 17 中首次引入的一个重要特性,经过了 JEP 412JEP 419 两个孵化版本,以及 JEP 424JEP 434 两个预览版本,在 Java 21 中,这已经是第三个预览版本了。

Java 22 中,这个特性终于退出了预览版本。

近年来,随着人工智能、数据科学、图像处理等领域的发展,我们在越来越多的场景下接触到原生代码:

  • Off-CPU Computing (CUDA, OpenCL)
  • Deep Learning (Blas, cuBlas, cuDNN, Tensorflow)
  • Graphics Processing (OpenGL, Vulkan, DirectX)
  • Others (CRIU, fuse, io_uring, OpenSSL, V8, ucx, ...)

这些代码不太可能用 Java 重写,也没有必要,Java 急需一种能与本地库进行交互的方案,这就是 FFM API 诞生的背景。FFM API 最初作为 Panama 项目 中的核心组件,旨在改善 Java 与本地代码的互操作性。FFM API 是 Java 现代化进程中的一个重要里程碑,标志着 Java 在与本地代码互操作性方面迈出了重要一步,它的引入也为 Java 在人工智能、数据科学等领域的应用提供了更多的可能性,有望加速 Java 在这些领域的发展和应用。

FFM API 由两大部分组成:外部函数接口(Foreign Function Interface,简称 FFI)内存 API(Memory API),FFI 用于实现 Java 代码和外部代码之间的相互操作,而 Memory API 则用于安全地管理堆外内存。

使用 JNI 调用外部函数

在引入外部函数之前,如果想要实现 Java 调用外部函数库,我们需要借助 JNI (Java Native Interface) 来实现。下面的代码是一个使用 JNI 调用外部函数的例子:

public class JNIDemo {
    static {
        System.loadLibrary("JNIDemo");
    }

    public static void main(String[] args) {
        new JNIDemo().sayHello();
    }

    private native void sayHello();
}

其中 sayHello 函数使用了 native 修饰符,表明这是一个本地方法,该方法的实现不在 Java 代码中。这个本地方法可以使用 C 语言来实现,我们首先需要生成这个本地方法对应的 C 语言头文件:

$ javac -h . JNIDemo.java

javac 命令不仅可以将 .java 文件编译成 .class 字节码文件,而且还可以生成本地方法的头文件,参数 -h . 表示将头文件生成到当前目录。这个命令执行成功后,当前目录应该会生成 JNIDemo.classJNIDemo.h 两个文件,JNIDemo.h 文件内容如下:

/* DO NOT EDIT THIS FILE - it is machine generated */
#include <jni.h>
/* Header for class JNIDemo */

#ifndef _Included_JNIDemo
#define _Included_JNIDemo
#ifdef __cplusplus
extern "C" {
#endif
/*
 * Class:     JNIDemo
 * Method:    sayHello
 * Signature: ()V
 */
JNIEXPORT void JNICALL Java_JNIDemo_sayHello
  (JNIEnv *, jobject);

#ifdef __cplusplus
}
#endif
#endif

正如我们所看到的,在这个头文件中定义了一个名为 Java_JNIDemo_sayHello 的函数,这个名称是根据包名、类名和方法名自动生成的。有了这个自动生成的头文件,我们就可以在 C 语言里实现这个这个方法了,于是接着创建一个 JNIDemo.c 文件,编写代码:

#include "jni.h"
#include "JNIDemo.h"
#include <stdio.h>

JNIEXPORT void JNICALL Java_JNIDemo_sayHello(JNIEnv *env, jobject jobj) {
    printf("Hello World!\n");
}

这段代码很简单,直接调用标准库中的 printf 输出 Hello World!

然后使用 gcc 将这个 C 文件编译成动态链接库:

$ gcc -I${JAVA_HOME}/include -I${JAVA_HOME}/include/darwin -dynamiclib JNIDemo.c -o libJNIDemo.dylib

这个命令会在当前目录下生成一个名为 libJNIDemo.dylib 的动态链接库文件,这个库文件正是我们在 Java 代码中通过 System.loadLibrary("JNIDemo") 加载的库文件。

注意这里我用的是 Mac 操作系统,动态链接库的名称必须以 lib 为前缀,以 .dylib 为扩展名,其他操作系统的命令略有区别。

Linux 系统:

$ gcc -I${JAVA_HOME}/include -I${JAVA_HOME}/include/linux -shared JNIDemo.c -o libJNIDemo.so

Windows 系统:

$ gcc -I${JAVA_HOME}/include -I${JAVA_HOME}/include/win32 -shared JNIDemo.c -o JNIDemo.dll

至此,我们就可以运行这个 Hello World 的本地实现了:

$ java -cp . -Djava.library.path=. JNIDemo

以上步骤演示了如何使用 JNI 调用外部函数,这只是 JNI 的一个简单示例,更多 JNI 的高级功能,比如实现带参数的函数,在 C 代码中访问 Java 对象或方法等,可以参考 Baeldung 的这篇教程

外部函数接口(Foreign Function Interface)

从上面的过程可以看出,JNI 的使用非常繁琐,一个简单的 Hello World 都要费好大劲:首先要在 Java 代码中定义 native 方法,然后从 Java 代码派生 C 头文件,最后还要使用 C 语言对其进行实现。Java 开发人员必须跨多个工具链工作,当本地库快速演变时,这个工作就会变得尤为枯燥乏味。

除此之外,JNI 还有几个更为严重的问题:

  • Java 语言最大的特性是跨平台,所谓 一次编译,到处运行,但是使用本地接口需要涉及 C 语言的编译和链接,这是平台相关的,所以丧失了 Java 语言的跨平台特性;
  • JNI 桩代码非常难以编写和维护,首先,JNI 在类型处理上很糟糕,由于 Java 和 C 的类型系统不一致,比如聚合数据在 Java 中用对象表示,而在 C 中用结构体表示,因此,任何传递给 native 方法的 Java 对象都必须由本地代码费力地解包;另外,假设某个本地库包含 1000 个函数,那么意味着我们要生成 1000 个对应的 JNI 桩代码,这么大量的 JNI 桩代码非常难以维护;
  • 由于本地代码不受 JVM 的安全机制管理,所以 JNI 本质上是不安全的,它在使用上非常危险和脆弱,JNI 错误可能导致 JVM 的崩溃;
  • JNI 的性能也不行,一方面是由于 JNI 方法调用不能从 JIT 优化中受益,另一方面是由于通过 JNI 传递 Java 对象很慢;这就导致开发人员更愿意使用 Unsafe API 来分配堆外内存,并将其地址传递给 native 方法,这使得 Java 代码非常不安全!

多年来,已经出现了许多框架来解决 JNI 遗留下来的问题,包括 JNAJNRJavaCPP。这些框架通常比 JNI 有显著改进,但情况仍然不尽理想,尤其是与提供一流本地互操作性的语言相比。例如,Python 的 ctypes 包可以动态地包装本地库中的函数,而无需任何胶水代码,Rust 则提供了从 C/C++ 头文件自动生成本地包装器的工具。

FFI 综合参考了其他语言的实现,试图更加优雅地解决这些问题,它实现了对外部函数库的原生接口,提供了一种更高效更安全的方式来访问本地内存和函数,从而取代了传统的 JNI。

下面的代码是使用 FFI 实现和上面相同的 Hello World 的例子:

public class FFIDemo {
    public static void main(String[] args) throws Throwable {
        Linker linker = Linker.nativeLinker();
        SymbolLookup symbolLookup = linker.defaultLookup();
        MethodHandle printf = linker.downcallHandle(
            symbolLookup.find("printf").orElseThrow(), 
            FunctionDescriptor.of(ValueLayout.JAVA_LONG, ValueLayout.ADDRESS)
        );
        try (Arena arena = Arena.ofConfined()) {
            MemorySegment hello = arena.allocateUtf8String("Hello World!\n");
            printf.invoke(hello);
        }
    }
}

注意,Java 22 中取消了 Arena::allocateUtf8String() 方法,改成了 Arena::allocateFrom() 方法。

相比于 JNI 的实现,FFI 的代码要简洁优雅得多。这里的代码涉及三个 FFI 中的重要接口:

  • Linker
  • SymbolLookup
  • FunctionDescriptor

其中 SymbolLookup 用于从已加载的本地库中查找外部函数的地址,Linker 用于链接 Java 代码与外部函数,它同时支持下行调用(从 Java 代码调用本地代码)和上行调用(从本地代码返回到 Java 代码),FunctionDescriptor 用于描述外部函数的返回类型和参数类型,这些类型在 FFM API 中可以由 MemoryLayout 对象描述,例如 ValueLayout 表示值类型,GroupLayout 表示结构类型。

通过 FFI 提供的接口,我们可以生成对应外部函数的方法句柄(MethodHandle),方法句柄是 Java 7 引入的一个抽象概念,可以实现对方法的动态调用,它提供了比反射更高的性能和更灵活的使用方式,这里复用了方法句柄的概念,通过方法句柄的 invoke() 方法就可以实现外部函数的调用。

这里我们不再需要编写 C 代码,也不再需要编译链接生成动态库,所以,也就不存在平台相关的问题了。另一方面,FFI 接口的设计大多数情况下是安全的,由于都是 Java 代码,因此也受到 Java 安全机制的约束,虽然也有一部分接口是不安全的,但是比 JNI 来说要好多了。

OpenJDK 还提供了一个 jextract 工具,用于从本地库自动生成 Java 代码,有兴趣的同学可以尝试一下。

使用 ByteBufferUnsafe 访问堆外内存

上面说过,FFM API 的另一个主要部分是 内存 API(Memory API),用于安全地管理堆外内存。其实在 FFIDemo 的示例中我们已经见到内存 API 了,其中 printf 打印的 Hello World!\n 字符串,就是通过 Arena 这个内存 API 分配的。

但是在学习内存 API 之前,我们先来复习下 Java 在之前的版本中是如何处理堆外内存的。

内存的使用往往和程序性能挂钩,很多像 TensorFlow、Ignite、Netty 这样的类库,都对性能有很高的要求,为了避免垃圾收集器不可预测的行为以及额外的性能开销,这些类库一般倾向于使用 JVM 之外的内存来存储和管理数据,这就是我们常说的 堆外内存(off-heap memory)

使用堆外内存有两个明显的好处:

  • 使用堆外内存,也就意味着堆内内存较小,从而可以减少垃圾回收次数,以及垃圾回收停顿对于应用的影响;
  • 在 I/O 通信过程中,通常会存在堆内内存和堆外内存之间的数据拷贝操作,频繁的内存拷贝是性能的主要障碍之一,为了极致的性能,一份数据应该只占一份内存空间,这就是所谓的 零拷贝,直接使用堆外内存可以提升程序 I/O 操作的性能。

ByteBuffer 是访问堆外内存最常用的方法:

private static void testDirect() {
    ByteBuffer bb = ByteBuffer.allocateDirect(10);
    bb.putInt(0);
    bb.putInt(1);
    bb.put((byte)0);
    bb.put((byte)1);

    bb.flip();

    System.out.println(bb.getInt());
    System.out.println(bb.getInt());
    System.out.println(bb.get());
    System.out.println(bb.get());
}

上面的代码使用 ByteBuffer.allocateDirect(10) 分配了 10 个字节的直接内存,然后通过 put 写内存,通过 get 读内存。

可以注意到这里的 int 是 4 个字节,byte 是 1 个字节,当写完 2 个 int 和 2 个 byte 后,如果再继续写,就会报 java.nio.BufferOverflowException 异常。

另外还有一点值得注意,我们并没有手动释放内存。虽然这个内存是直接从操作系统分配的,不受 JVM 的控制,但是创建 DirectByteBuffer 对象的同时也会创建一个 Cleaner 对象,它用于跟踪对象的垃圾回收,当 DirectByteBuffer 被垃圾回收时,分配的堆外内存也会一起被释放,所以我们不用手动释放内存。

ByteBuffer 是异步编程和非阻塞编程的核心类,从 java.nio.ByteBuffer 这个包名就可以看出这个类是为 NIO 而设计,可以说,几乎所有的 Java 异步模式或者非阻塞模式的代码,都要直接或者间接地使用 ByteBuffer 来管理数据。尽管如此,这个类仍然存在着一些无法摆脱的限制:

  • 首先,它不支持手动释放内存,ByteBuffer 对应内存的释放,完全依赖于 JVM 的垃圾回收机制,这对于一些像 Netty 这样追求极致性能的类库来说并不满足,这些类库往往需要对内存进行精确的控制;
  • 其次,ByteBuffer 使用了 Java 的整数来表示存储空间的大小,这就导致,它的存储空间最多只有 2G;在网络编程的环境下,这可能并不是一个问题,但是在处理超过 2G 的文件时就不行了,而且像 Memcahed 这样的分布式缓存系统,内存 2G 的限制明显是不够的。

为了突破这些限制,有些类库选择了访问堆外内存的另一条路,使用 sun.misc.Unsafe 类。这个类提供了一些低级别不安全的方法,可以直接访问系统内存资源,自主管理内存资源:

private static void testUnsafe() throws Exception {
    Field f = Unsafe.class.getDeclaredField("theUnsafe");
    f.setAccessible(true);
    Unsafe unsafe = (Unsafe) f.get(null);
    
    long address = unsafe.allocateMemory(10);
    unsafe.putInt(address, 0);
    unsafe.putInt(address+4, 1);
    unsafe.putByte(address+8, (byte)0);
    unsafe.putByte(address+9, (byte)1);
    System.out.println(unsafe.getInt(address));
    System.out.println(unsafe.getInt(address+4));
    System.out.println(unsafe.getByte(address+8));
    System.out.println(unsafe.getByte(address+9));
    unsafe.freeMemory(address);
}

Unsafe 的使用方法和 ByteBuffer 很像,我们使用 unsafe.allocateMemory(10) 分配了 10 个字节的直接内存,然后通过 put 写内存,通过 get 读内存,区别在于我们要手动调整内存地址。

使用 Unsafe 操作内存就像是使用 C 语言中的指针一样,效率虽然提高了不少,但是很显然,它增加了 Java 语言的不安全性,因为它实际上可以访问到任意位置的内存,不正确使用 Unsafe 类会使得程序出错的概率变大。

注意,默认情况下,我们无法直接使用 Unsafe 类,直接使用的话会报下面这样的 SecurityException 异常:

Exception in thread "main" java.lang.SecurityException: Unsafe
       at jdk.unsupported/sun.misc.Unsafe.getUnsafe(Unsafe.java:99)
       at ByteBufferDemo.testUnsafe(ByteBufferDemo.java:33)
       at ByteBufferDemo.main(ByteBufferDemo.java:10)

所以上面的代码通过反射的手段,使得我们可以使用 Unsafe

说了这么多,总结一句话就是:ByteBuffer 安全但效率低,Unsafe 效率高但是不安全。此时,就轮到 内存 API 出场了。

内存 API(Memory API)

内存 API 基于前人的经验,使用了全新的接口设计,它的基本使用如下:

private static void testAllocate() {
    try (Arena offHeap = Arena.ofConfined()) {
        MemorySegment address = offHeap.allocate(8);
        address.setAtIndex(ValueLayout.JAVA_INT, 0, 1);
        address.setAtIndex(ValueLayout.JAVA_INT, 1, 0);
        System.out.println(address.getAtIndex(ValueLayout.JAVA_INT, 0));
        System.out.println(address.getAtIndex(ValueLayout.JAVA_INT, 1));
    }
}

这段代码使用 Arena::allocate() 分配了 8 个字节的外部内存,然后写入两个整型数字,最后再读取出来。下面是另一个示例,写入再读取字符串:

private static void testAllocateString() {
    try (Arena offHeap = Arena.ofConfined()) {
        MemorySegment str = offHeap.allocateUtf8String("hello");
        System.out.println(str.getUtf8String(0));
    }
}

这段代码使用 Arena::allocateUtf8String() 根据字符串的长度动态地分配外部内存,然后通过 MemorySegment::getUtf8String() 将其复制到 JVM 栈上并输出。

注意,Java 22 中取消了 Arena::allocateUtf8String()MemorySegment::getUtf8String() 方法,改成了 Arena::allocateFrom()MemorySegment::getString() 方法。

这两段代码中的 ArenaMemorySegment 是内存 API 的关键,MemorySegment 用于表示一段内存片段,既可以是堆内内存也可以是堆外内存;Arena 定义了内存资源的生命周期管理机制,它实现了 AutoCloseable 接口,所以可以使用 try-with-resource 语句及时地释放它管理的内存。

Arena.ofConfined() 表示定义一块受限区域,只有一个线程可以访问在受限区域中分配的内存段。除此之外,我们还可以定义其他类型的区域:

  • Arena.global() - 全局区域,分配的区域永远不会释放,随时可以访问;
  • Arena.ofAuto() - 自动区域,由垃圾收集器自动检测并释放;
  • Arena.ofShared() - 共享区域,可以被多个线程同时访问;

Arena 接口的设计经过了多次调整,在最初的版本中被称为 ResourceScope,后来改成 MemorySession,再后来又拆成了 ArenaSegmentScope 两个类,现在基本上稳定使用 Arena 就可以了。

Arena 接口,内存 API 还包括了下面这些接口,主要可以分为两大类:

  • ArenaMemorySegmentSegmentAllocator - 这几个接口用于控制外部内存的分配和释放
  • MemoryLayoutVarHandle - 这几个接口用于操作和访问结构化的外部内存

内存 API 试图简化 Java 代码操作堆外内存的难度,通过它可以实现更高效的内存访问方式,同时可以保障一定的安全性,特别适用于下面这些场景:

  • 大规模数据处理:在处理大规模数据集时,内存 API 的直接内存访问能力将显著提高程序的执行效率;
  • 高性能计算:对于需要频繁进行数值计算的任务,内存 API 可以减少对象访问的开销,从而实现更高的计算性能;
  • 与本地代码交互:内存 API 的使用可以使得 Java 代码更方便地与本地代码进行交互,结合外部函数接口,可以实现更灵活的数据传输和处理。

相信等内存 API 正式发布之后,之前使用 ByteBufferUnsafe 的很多类库估计都会考虑切换成使用内存 API 来获取性能的提升。

未命名模式和变量(预览版本)

未命名模式和变量也是一个预览特性,其主要目的是为了提高代码的可读性和可维护性。

在 Java 代码中,我们偶尔会遇到一些不需要使用的变量,比如下面这个例子中的异常 e

try { 
    int i = Integer.parseInt(s);
    System.out.println("Good number: " + i);
} catch (NumberFormatException e) { 
    System.out.println("Bad number: " + s);
}

这时我们就可以使用这个特性,使用下划线 _ 来表示不需要使用的变量:

try { 
    int i = Integer.parseInt(s);
    System.out.println("Good number: " + i);
} catch (NumberFormatException _) { 
    System.out.println("Bad number: " + s);
}

上面这个这被称为 未命名变量(Unnamed Variables)

顾名思义,未命名模式和变量包含两个方面:未命名模式(Unnamed Patterns)未命名变量(Unnamed Variables)

未命名模式(Unnamed Patterns)

上一篇笔记 中,我们学习了什么是 记录模式(Record Pattern) 以及 instanceofswitch 两种模式匹配。未命名模式允许在模式匹配中省略掉记录组件的类型和名称。下面的代码展示了如何在 instanceof 模式匹配中使用未命名模式这个特性:

if (obj instanceof Person(String name, _)) {
    System.out.println("Name: " + name);
}

其中 Person 记录的第二个参数 Integer age 在后续的代码中没用到,于是用下划线 _ 把类型和名称都代替掉。我们也可以只代替 age 名称,这被称为 未命名模式变量(Unnamed Pattern Variables)

if (obj instanceof Person(String name, Integer _)) {
    System.out.println("Name: " + name);
}

这个特性也可以在 switch 模式匹配中使用:

switch (b) {
    case Box(RedBall _), Box(BlueBall _) -> processBox(b);
    case Box(GreenBall _)                -> stopProcessing();
    case Box(_)                          -> pickAnotherBox();
}

这里前两个 case 是未命名模式变量,最后一个 case 是未命名模式。

未命名变量(Unnamed Variables)

未命名变量的使用场景更加丰富,除了上面在 catch 子句中使用的例子外,下面列举了一些其他的典型场景。

for 循环中使用:

int acc = 0;
for (Order _ : orders) {
    if (acc < LIMIT) { 
        ... acc++ ...
    }
}

在赋值语句中使用:

Queue<Integer> q = ... // x1, y1, z1, x2, y2, z2, ...
while (q.size() >= 3) {
   var x = q.remove();
   var y = q.remove();
   var _ = q.remove();
   ... new Point(x, y) ...
}

try-with-resource 语句中使用:

try (var _ = ScopedContext.acquire()) {
  // No use of acquired resource
}

在 lambda 表达式中使用:

stream.collect(
    Collectors.toMap(String::toUpperCase, _ -> "NODATA")
)

虚拟线程

虚拟线程(Virtual Thread) 是 Java 21 中最突出的特性之一,作为 Loom 项目的一部分,开发人员对这个特性可谓期待已久。它由预览特性变成正式特性经历了两个版本的迭代,第一次预览是 Java 19 的 JEP 425 ,第二次预览是 Java 20 的 JEP 436,在 Java 21 中虚拟线程特性正式发布。

虚拟线程 vs. 平台线程

在引入虚拟线程之前,我们常使用 java.lang.Thread 来创建 Java 线程,这个线程被称为 平台线程(Platform Thread),它和操作系统的内核线程是一对一的关系,由内核线程调度器负责调度。

platform-threads.png

为了提高应用程序的性能和系统的吞吐量,我们将添加越来越多的 Java 线程,下面是一个模拟多线程的例子,我们创建 10 万个线程,每个线程模拟 I/O 操作等待 1 秒钟:

private static void testThread() {
    long l = System.currentTimeMillis();
    try(var executor = Executors.newCachedThreadPool()) {
        IntStream.range(0, 100000).forEach(i -> {
            executor.submit(() -> {
                Thread.sleep(Duration.ofSeconds(1));
                // System.out.println(i);
                return i;
            });
        });
    }
    System.out.printf("elapsed time:%d ms", System.currentTimeMillis() - l);
}

这里的 10 万个线程对应着 10 万个内核线程,这种通过大量的线程来提高系统性能是不现实的,因为内核线程成本高昂,不仅会占用大量资源来处理上下文切换,而且可用数量也很受限,一个线程大约消耗 1M~2M 的内存,当系统资源不足时就会报错:

$ java ThreadDemo.java
Exception in thread "pool-2-thread-427" java.lang.OutOfMemoryError: Java heap space
        at java.base/java.util.concurrent.SynchronousQueue$TransferStack.snode(SynchronousQueue.java:328)
        at java.base/java.util.concurrent.SynchronousQueue$TransferStack.transfer(SynchronousQueue.java:371)
        at java.base/java.util.concurrent.SynchronousQueue.poll(SynchronousQueue.java:903)
        at java.base/java.util.concurrent.ThreadPoolExecutor.getTask(ThreadPoolExecutor.java:1069)
        at java.base/java.util.concurrent.ThreadPoolExecutor.runWorker(ThreadPoolExecutor.java:1130)
        at java.base/java.util.concurrent.ThreadPoolExecutor$Worker.run(ThreadPoolExecutor.java:642)
        at java.base/java.lang.Thread.runWith(Thread.java:1596)
        at java.base/java.lang.Thread.run(Thread.java:1583)

于是人们又发明了各种线程池技术,最大程度地提高线程的复用性。下面我们使用一个固定大小为 200 的线程池来解决线程过多时报错的问题:

private static void testThreadPool() {
    long l = System.currentTimeMillis();
    try(var executor = Executors.newFixedThreadPool(200)) {
        IntStream.range(0, 100000).forEach(i -> {
            executor.submit(() -> {
                Thread.sleep(Duration.ofSeconds(1));
                // System.out.println(i);
                return i;
            });
        });
    }
    System.out.printf("elapsed time:%d ms", System.currentTimeMillis() - l);
}

在使用固定大小的线程池后,不会出现创建大量线程导致报错的问题,任务可以正常完成。但是这里的线程池却成了我们应用程序最大的性能瓶颈,程序运行花费了 50 秒的时间:

$ java ThreadDemo.java
elapsed time:50863 ms

按理说每个线程耗时 1 秒,无论是多少个线程并发,总耗时应该都是 1 秒,很显然这里并没有发挥出硬件应有的性能。

为了充分利用硬件,研究人员转而采用线程共享的方式,它的核心想法是这样的:我们并不需要在一个线程上从头到尾地处理一个请求,当执行到等待 I/O 操作时,可以将这个请求缓存到池中,以便线程可以处理其他请求,当 I/O 操作结束后会收到一个回调通知,再将请求从池中取出继续处理。这种细粒度的线程共享允许在高并发操作时不消耗大量线程,从而消除内核线程稀缺而导致的性能瓶颈。

这种方式使用了一种被称为 异步编程(Asynchronous Programming) 的风格,通过所谓的 响应式框架(Reactive Frameworks) 来实现,比如著名的 Reactor 项目一直致力于通过响应式编程来提高 Java 性能。但是这种风格的代码难以理解、难以调试、难以使用,普通开发人员只能对其敬而远之,只有高阶开发人员才能玩得转,所以并没有得到普及。

所以 Java 一直在寻找一种既能有异步编程的性能,又能编写起来简单的方案,最终虚拟线程诞生。

虚拟线程由 Loom 项目提出,最初被称为 纤程(Fibers),类似于 协程(Coroutine) 的概念,它由 JVM 而不是操作系统进行调度,可以让大量的虚拟线程在较少数量的平台线程上运行。我们将上面的代码改成虚拟线程非常简单,只需要将 Executors.newFixedThreadPool(200) 改成 Executors.newVirtualThreadPerTaskExecutor() 即可:

private static void testVirtualThread() {
    long l = System.currentTimeMillis();
    try(var executor = Executors.newVirtualThreadPerTaskExecutor()) {
        IntStream.range(0, 100000).forEach(i -> {
            executor.submit(() -> {
                Thread.sleep(Duration.ofSeconds(1));
                // System.out.println(i);
                return i;
            });
        });
    }
    System.out.printf("elapsed time:%d ms", System.currentTimeMillis() - l);
}

运行结果显示,虚拟线程使得程序的性能得到了非常显著的提升,10 万个线程全部运行只花费 1 秒多的时间:

$ java ThreadDemo.java
elapsed time:1592 ms

虚拟线程的数量可以远大于平台线程的数量,多个虚拟线程将由 JVM 调度在某个平台线程上执行,一个平台线程可以在不同的时间执行不同的虚拟线程,当虚拟线程被阻塞或等待时,平台线程可以切换到另一个虚拟线程执行。

虚拟线程、平台线程和系统内核线程的关系图如下所示:

virtual-threads.png

值得注意的是,虚拟线程适用于 I/O 密集型任务,不适用于计算密集型任务,因为计算密集型任务始终需要 CPU 资源作为支持。如果测试程序中的任务不是等待 1 秒钟,而是执行一秒钟的计算(比如对一个巨大的数组进行排序),那么程序不会有明显的性能提升。因为虚拟线程不是更快的线程,它们运行代码的速度与平台线程相比并无优势。虚拟线程的存在是为了提供更高的吞吐量,而不是速度(更低的延迟)。

创建虚拟线程

为了降低虚拟线程的使用门槛,官方尽力复用原有的 java.lang.Thread 线程类,让我们的代码可以平滑地过渡到虚拟线程的使用。下面列举几种创建虚拟线程的方式:

  1. 通过 Thread.startVirtualThread() 创建
Thread.startVirtualThread(() -> {
    System.out.println("Hello");
});
  1. 使用 Thread.ofVirtual() 创建
Thread.ofVirtual().start(() -> {
    System.out.println("Hello");
});

上面的代码通过 start() 直接启动虚拟线程,也可以通过 unstarted() 创建一个未启动的虚拟线程,再在合适的时机启动:

Thread thread = Thread.ofVirtual().unstarted(() -> {
    System.out.println("Hello");
});
thread.start();

Thread.ofVirtual() 对应的是 Thread.ofPlatform(),用于创建平台线程。

  1. 通过 ThreadFactory 创建
ThreadFactory factory = Thread.ofVirtual().factory();
Thread thread = factory.newThread(() -> {
    System.out.println("Hello");
});
thread.start();
  1. 通过 Executors.newVirtualThreadPerTaskExecutor() 创建
try(var executor = Executors.newVirtualThreadPerTaskExecutor()) {
    executor.submit(() -> {
        System.out.println("Hello");
    });
}

这种方式和传统的创建线程池非常相似,只需要改一行代码就可以把之前的线程池切换到虚拟线程。

很有意思的一点是,这里我们并没有指定虚拟线程的数量,这是因为虚拟线程非常廉价非常轻量,使用后立即就被销毁了,所以根本不需要被重用或池化。

正是由于虚拟线程非常轻量,我们可以在单个平台线程中创建成百上千个虚拟线程,它们通过暂停和恢复来实现线程之间的切换,避免了上下文切换的额外耗费,兼顾了多线程的优点,简化了高并发程序的复杂,可以有效减少编写、维护和观察高吞吐量并发应用程序的工作量。

调试虚拟线程

JDK 长期以来一直提供调试、分析和监控线程的机制,这些机制对于故障排查、维护和优化是必不可少的,JDK 提供了很多工具来实现这点,这些工具现在对虚拟线程也提供了同样的支持。

比如 jstackjcmd 是流行的线程转储工具,它们可以打印出应用程序的所有线程,这种扁平的列表结构对于几十或几百个平台线程来说还可以,但对于成千上万的虚拟线程来说已经不适合了,于是在 jcmd 中引入了一种新的线程转储方式,以 JSON 格式将虚拟线程与平台线程一起打印:

$ jcmd <pid> Thread.dump_to_file -format=json <file>

以下是这样的线程转储的示例:

virtual-threads-dump.png

未命名类和实例 Main 方法(预览版本)

相信所有学过 Java 的人对下面这几行代码都非常熟悉吧:

public class Hello {
    public static void main(String[] args) {
        System.out.println("Hello");
    }
}

通常我们初学 Java 的时候,都会写出类似这样的 Hello World 程序,不过作为初学者的入门示例,这段代码相比其他语言来说显得过于臃肿了,给初学者的感觉就是 Java 太复杂了,因为这里掺杂了太多只有在开发大型应用的时候才会涉及到的概念:

  • 首先 public class Hello 这行代码涉及了类的声明和访问修饰符,这些概念可以用于数据隐藏、重用、访问控制、模块化等,在大型复杂应用程序中很有用;但是对于一个初学者,往往是从变量、控制流和子程序的基本编程概念开始学习的,在这个小例子中,它们毫无意义;
  • 其次,main() 函数的 String[] args 这个参数主要用于接收从命令行传入的参数,但是对于一个初学者来说,在这里它显得非常神秘,因为它在代码中从未被使用过;
  • 最后,main() 函数前面的 static 修饰符是 Java 类和对象模型的一部分,这个概念这对初学者也很不友好,甚至是有害的,因为如果要在代码中添加一个新的方法或字段时,为了访问它们,我们必须将它们全部声明成 static 的,这是一种既不常见也不是好习惯的用法,要么就要学习如何实例化对象。

为了让初学者可以快速上手,Java 21 引入了未命名类和实例 Main 方法这个特性,这个特性包含两个部分:

  1. 增强了 Java 程序的 启动协议(the launch protocol),使得 main 方法可以没有访问修饰符、没有 static 修饰符和没有 String[] 参数:
class Hello { 
    void main() { 
        System.out.println("Hello");
    }
}

这样的 main 方法被称为 实例 Main 方法(instance main methods)

  1. 实现了 未命名类(unnamed class) 特性,使我们可以不用声明类,进一步简化上面的代码:
void main() {
    System.out.println("Hello");
}

在 Java 语言中,每个类都位于一个包中,每个包都位于一个模块中。而一个未命名的类位于未命名的包中,未命名的包位于未命名的模块中。

作用域值(预览版本)

作用域值(Scoped Values)Loom 项目提出的另一个重要特性,它提供了一种隐式方法参数的形式,允许在大型程序的各个部分之间安全地共享数据,而无需将它们作为显式参数添加到调用链中的每个方法中。作用域值通常是作为一个公共静态字段,因此可以从任何方法中访问到。如果多个线程使用相同的作用域值,则从每个线程的角度来看,它可能包含不同的值。

如果您熟悉 线程本地变量(thread-local variables),这听起来会很熟悉,事实上,作用域值正是为了解决使用线程本地变量时可能遇到的一些问题,在某些情况下可以将其作为线程本地变量的现代替代品。

一个例子

在 Web 应用开发中,一个经典的场景是获取当前已登录的用户信息,下面的代码模拟了大概的流程:

public class UserDemo {
    
    public static void main(String[] args) {

        // 从 request 中获取用户信息
        String userId = getUserFromRequest();
        
        // 查询用户详情
        String userInfo = new UserService().getUserInfo(userId);
        System.out.println(userInfo);
    }

    private static String getUserFromRequest() {
        return "admin";
    }

    static class UserService {
        public String getUserInfo(String userId) {
            return new UserRepository().getUserInfo(userId);
        }
    }

    static class UserRepository {
        public String getUserInfo(String userId) {
            return String.format("%s:%s", userId, userId);
        }
    }
}

在接收到请求时,首先对用户进行身份验证,然后得到用户信息,这个信息可能被很多地方使用。在这里我们使用方法参数将用户信息传递到其他要使用的地方,可以看到,userId 参数从 UserDemo 传到 UserService 又传到 UserRepository

在一个复杂的应用程序中,请求的处理可能会延伸到数百个方法,这时,我们需要为每一个方法添加 userId 参数,将用户传递到最底层需要用户信息的方法中。很显然,额外的 userId 参数会使我们的代码很快变得混乱,因为大多数方法不需要用户信息,甚至可能有一些方法出于安全原因根本不应该能够访问用户。如果在调用堆栈的某个深处我们还需要用户的 IP 地址怎么办?那么我们将不得不再添加一个 ip 参数,然后通过无数的方法传递它。

使用 ThreadLocal 线程本地变量

解决这一问题的传统方法是使用 ThreadLocal,它是线程本地变量,只要线程不销毁,我们随时可以获取 ThreadLocal 中的变量值。

public class UserDemoThreadLocal {
    
    private final static ThreadLocal<String> USER = new ThreadLocal<>();
    
    public static void main(String[] args) {
        
        // 从 request 中获取用户信息
        String userId = getUserFromRequest();
        USER.set(userId);

        // 查询用户详情
        String userInfo = new UserService().getUserInfo();
        System.out.println(userInfo);
    }

    private static String getUserFromRequest() {
        return "admin";
    }

    static class UserService {
        public String getUserInfo() {
            return new UserRepository().getUserInfo();
        }
    }

    static class UserRepository {
        public String getUserInfo() {
            String userId = USER.get();
            return String.format("%s:%s", userId, userId);
        }
    }
}

这里我们定义了一个名为 USERThreadLocal 全局变量,获取完用户信息之后将其存入 USER 中,然后在 UserRepository 中直接从 USER 中获取。尽管看起来像普通变量,但线程本地变量的特点是每个线程都有一个独立实例,它的值取决于哪个线程调用其 getset 方法来读取或写入其值。使用线程本地变量,可以方便地在调用堆栈上的方法之间共享数据,而无需使用方法参数。

注意,ThreadLocal 只能在单个线程中共享数据,如果内部方法中创建了新线程,我们可以使用 InheritableThreadLocal,它是 ThreadLocal 的子类,主要用于子线程创建时自动继承父线程的 ThreadLocal 变量,方便必要信息的进一步传递。

使用 ScopedValue 作用域值

不幸的是,线程本地变量存在许多设计缺陷,无法避免:

  • 不受限制的可变性(Unconstrained mutability) - 线程本地变量都是可变的,它的值可以随意被更改,任何能够调用线程本地变量的 get 方法的代码都可以随时调用该变量的 set 方法;但是往往更常见的需求是从一个方法向其他方法简单的单向数据传输,就像上面的示例一样;对线程本地变量的任意修改可能导致类似意大利面条的数据流以及难以察觉的错误;
  • 无限寿命(Unbounded lifetime) - 一旦线程本地变量通过 set 方法设值,这个值将在线程的整个生命周期中被保留,直到调用 remove 方法,不幸的是,开发人员经常忘记调用 remove 方法;如果使用了线程池,如果没有正确清除线程本地变量,可能会将一个线程的变量意外地泄漏到另一个不相关的线程中,导致潜在地安全漏洞;此外,忘记清理线程局部变量还可能导致内存泄露;
  • 昂贵的继承(Expensive inheritance) - 当使用大量线程时,我们通常会使用 InheritableThreadLocal 让子线程自动继承父线程的线程本地变量,子线程无法共享父线程使用的存储空间,这会显著增加程序的内存占用;特别是在虚拟线程推出之后,这个问题变得更为显著,因为虚拟线程足够廉价,程序中可能会创建成千上万的虚拟线程,如果一百万个虚拟线程中的每一个都有自己的线程局部变量副本,很快就会出现内存不足的问题。

作用域值(Scoped Values) 就是为解决这些问题而诞生的新概念。

  • 首先,作用域值是不可变的,它的值无法更改,单向的数据传输使得代码流程更清晰;
  • 另外,作用域值只在有限范围内使用,用完立即释放,不存在忘记清理的问题,所以也不会导致内存泄露;
  • 最后,作用域值更轻量,由于它是不可变的,所以父线程和子线程可以复用一个实例,再多的虚拟线程也不会有内存不足的问题。

下面用 ScopedValue 对上面的代码进行重写:

public class UserDemoScopedValue {
    
    final static ScopedValue<String> USER = ScopedValue.newInstance();

    public static void main(String[] args) {
        // 从 request 中获取用户信息
        String userId = getUserFromRequest();
        ScopedValue.where(USER, userId)
            .run(() -> {
                // 查询用户详情
                String userInfo = new UserService().getUserInfo();
                System.out.println(userInfo);
            });
    }

    private static String getUserFromRequest() {
        return "admin";
    }

    static class UserService {
        public String getUserInfo() {
            return new UserRepository().getUserInfo();
        }
    }

    static class UserRepository {
        public String getUserInfo() {
            String userId = USER.get();
            return String.format("%s:%s", userId, userId);
        }
    }
}

我们首先调用 ScopedValue.where(USER, userId),它用于将作用域值和某个对象进行绑定,然后调用 run() 方法,它接受一个 lambda 表达式,从该表达式直接或间接调用的任何方法都可以通过 get() 方法读取作用域值。

作用域值仅在 run() 调用的生命周期内有效,在 run() 方法完成后,绑定将被销毁。这种有界的生命周期,使得数据从调用方传输到被调用方(直接和间接)的单向传输一目了然。

作用域值的重绑定

上面说过,作用域值是不可变的,没有任何方法可以更改作用域值,但是我们可以重新绑定作用域值:

private static final ScopedValue<String> X = ScopedValue.newInstance();

void foo() {
    ScopedValue.where(X, "hello").run(() -> bar());
}

void bar() {
    System.out.println(X.get()); // prints hello
    ScopedValue.where(X, "goodbye").run(() -> baz());
    System.out.println(X.get()); // prints hello
}

void baz() {
    System.out.println(X.get()); // prints goodbye
}

在这个例子中,foo() 方法将作用域值 X 绑定为 hello,所以在 bar() 方法中使用 X.get() 获得的是 hello;但是接下来,我们重新将 X 绑定为 goodbye,再去调用 baz() 方法,这时在 baz() 方法中使用 X.get() 得到的就是 goodbye 了;不过值得注意的是,当 baz() 方法结束后,重新回到 bar() 方法,使用 X.get() 获得的仍然是 hello,说明作用域值并没有被修改。

作用域值的线程继承

在使用 ThreadLocal 的时候,我们通常会使用 InheritableThreadLocal 让子线程自动继承父线程的线程本地变量,那么作用域值如何实现线程继承呢?可惜的是,并不存在 InheritableScopedValue 这样的类,Java 21 提供了另一种解决方案:结构化并发 API(JEP 428)

StructuredTaskScope 是结构化并发中的核心类,它的使用方法如下:

try (var scope = new StructuredTaskScope.ShutdownOnFailure()) {
    Supplier<String> user = scope.fork(() -> USER.get());
    scope.join().throwIfFailed();
    System.out.println("task scope: " + user.get());
} catch (Exception ex) {
}

其中 scope.fork() 方法用于创建子线程,父线程中的作用域值会自动被 StructuredTaskScope 创建的子线程继承,子线程中的代码可以使用父线程中为作用域值建立的绑定,而几乎没有额外开销。与线程局部变量不同,父线程的作用域值绑定不会被复制到子线程中,因此它的性能更高,也不会消耗过多的内存。

子线程的作用域值绑定的生命周期由 StructuredTaskScope 提供的 fork/join 模型控制,scope.join() 等待子线程结束,当线程结束后绑定就会自动销毁,避免了使用线程本地变量时出现无限生命周期的问题。

结构化并发也是 Java 21 中的一项重要特性,我们将在下一篇笔记中继续学习它的知识。

参考

更多

JDK Projects

  • Project Panama - Interconnecting JVM and native code
  • Project Amber - Explore and incubate smaller, productivity-oriented Java language features
  • Project Loom - Supporting easy-to-use, high-throughput lightweight concurrency and new programming models on the Java platform.
扫描二维码,在手机上阅读!

Java 21 初体验

2023 年 9 月 19 日,Java 21 发布正式版本,这是 Java 时隔两年发布的又一个 LTS 版本,上一个 LTS 版本是 2021 年 9 月 14 日发布的 Java 17

jdk-versions.png

Java 17 目前是使用最广泛的版本,但随着 Java 21 的发布,这一局面估计会很快被打破,这是因为 Java 21 可能是几年内最为重要的版本,它带来了一系列重要的功能和特性,包括 记录模式switch 模式匹配字符串模板分代式 ZGC不需要定义类的 Main 方法,等等等等,不过其中最为重要的一项,当属由 Loom 项目 发展而来的 虚拟线程。Java 程序一直以文件体积大、启动速度慢、内存占用多被人诟病,但是有了虚拟线程,再结合 GraalVM 的原生镜像,我们就可以写出媲美 C、Rust 或 Go 一样小巧灵活、高性能、可伸缩的应用程序。

转眼间,距离 Java 21 发布已经快 3 个月了,网上相关的文章也已经铺天盖地,为了不使自己落伍,于是便打算花点时间学习一下。尽管在坊间一直流传着 版本任你发,我用 Java 8 这样的说法,但是作为一线 Java 开发人员,最好还是紧跟大势,未雨绸缪,有备无患。而且最重要的是,随着 Spring Boot 2.7.18 的发布,2.x 版本将不再提供开源支持,而 3.x 不支持 Java 8,最低也得 Java 17,所以仍然相信这种说法的人除非不使用 Spring Boot,要么不升级 Spring Boot,否则学习 Java 新版本都是势在必行。

准备开发环境

我这里使用 Docker Desktop 的 Dev Environments 作为我们的实验环境。Dev Environments 是 Docker Desktop 从 3.5.0 版本开始引入的一项新特性,目前还处于 Beta 阶段,它通过配置文件的方式方便开发人员创建容器化的、可复用的开发环境,结合 VSCode 的 Dev Containers 插件 以及它丰富的插件生态可以帮助开发人员迅速展开编码工作,而不用疲于开发环境的折腾。

dev-environments.png

Dev Environments 的界面如上图所示,官方提供了两个示例供参考,一个是单容器服务,一个是多容器服务:

我们可以直接从 Git 仓库地址来创建开发环境,就如官方提供的示例一样,也可以从本地目录创建开发环境,默认情况下,Dev Environments 会自动检测项目的语言和依赖,不过自动检测的功能并不是那么准确,比如我们的目录是一个 Java 项目,Dev Environments 会使用 docker/dev-environments-java 镜像来创建开发环境,而这个镜像使用的是 Java 11,并不是我们想要的。

如果自动检测失败,就会使用 docker/dev-environments-default 这个通用镜像来创建开发环境。

所以我们还得手动指定镜像,总的来说,就是在项目根目录下创建一个 compose-dev.yaml 配置文件,内容如下:

services:
  app:
    entrypoint:
    - sleep
    - infinity
    image: openjdk:21-jdk
    init: true
    volumes:
    - type: bind
      source: /var/run/docker.sock
      target: /var/run/docker.sock

然后再使用 Dev Environments 打开该目录,程序会自动拉取该镜像并创建开发环境:

dev-environments-created.png

开发环境创建成功后,我们就可以使用 VSCode 打开了:

dev-environments-done.png

使用 VSCode 打开开发环境,实际上就是使用 VSCode 的 Dev Containers 插件 连接到容器里面,打开终端,敲入 java -version 命令:

bash-4.4# java -version
openjdk version "21" 2023-09-19
OpenJDK Runtime Environment (build 21+35-2513)
OpenJDK 64-Bit Server VM (build 21+35-2513, mixed mode, sharing)

由于这是一个崭新的环境,我们还要为 VSCode 安装一些开发所需的插件,比如 Extension Pack for Java

vscode-dev-env.png

至此我们就得到了一个非常干净纯粹的 Java 21 开发环境。

接下来,我们就在这个全新的开发环境中一览 Java 21 的全部特性,包括下面 15 个 JEP:

由于内容较多,我将分成三个篇幅来介绍,这是第一篇,主要介绍前 5 个特性。

字符串模板(预览版本)

字符串模板是很多语言都具备的特性,它允许在字符串中使用占位符来动态替换变量的值,这种构建字符串的方式比传统的字符串拼接或格式化更为简洁和直观。相信学过 JavaScript 的同学对下面这个 Template literals 的语法不陌生:

const name = 'zhangsan'
const age = 18
const message = `My name is ${name}, I'm ${age} years old.`
console.log(message)

如上所示,JavaScript 通过反引号 ` 来定义字符串模板,而 Java 21 则引入了一个叫做 模版表达式(Template expressions) 的概念来定义字符串模板。下面是一个简单示例:

String name = "zhangsan";
int age = 18;
String message = STR."My name is \{name}, I'm \{age} years old.";
System.out.println(message);

看上去和 JavaScript 的 Template literals 非常相似,但还是有一些区别的,模版表达式包含三个部分:

  • 首先是一个 模版处理器(template processor):这里使用的是 STR 模板处理器,也可以是 RAWFMT 等,甚至可以自定义;
  • 中间是一个点号(.);
  • 最后跟着一个字符串模板,模板中使用 \{name}\{age} 这样的占位符语法,这被称为 内嵌表达式(embedded expression)

当模版表达式运行的时候,模版处理器会将模版内容与内嵌表达式的值组合起来,生成结果。

不过,当我们执行上述代码时,很可能会报 Invalid escape sequence (valid ones are \b \t \n \f \r \" \' \\ ) 这样的错:

preview-feature-error.png

这是因为字符串模板还只是一个预览特性,根据 JEP 12: Preview Features,我们需要添加 --enable-preview 参数开启预览特性,使用 javac 编译时,还需要添加 --release 参数。使用下面的命令将 .java 文件编译成 .class 文件:

$ javac --enable-preview --release 21 StringTemplates.java 
Note: StringTemplates.java uses preview features of Java SE 21.
Note: Recompile with -Xlint:preview for details.

再使用下面的命令运行 .class 文件:

$ java --enable-preview StringTemplates
My name is zhangsan, I'm 18 years old.

从 Java 11 开始,我们可以直接运行 .java 文件了,参见 JEP 330,所以上面的两个命令也可以省略成一个命令:

$ java --enable-preview --source 21 StringTemplates.java

STR 模版处理器

STR 模板处理器中的内嵌表达式还有很多其他写法,比如执行数学运算:

int x = 1, y = 2;
String s1 = STR."\{x} + \{y} = \{x + y}";

调用方法:

String s2 = STR."Java version is \{getVersion()}";

访问字段:

Person p = new Person(name, age);
String s3 = STR."My name is \{p.name}, I'm \{p.age} years old.";

内嵌表达式中可以直接使用双引号,不用 \" 转义:

String s4 = STR."I'm \{age >= 18 ? "an adult" : "a child"}.";

内嵌表达式中可以编写注释和换行:

String s5 = STR."I'm \{
    // check the age
    age >= 18 ? "an adult" : "a child"
}.";

多行模板表达式

在 Java 13 的 JEP 355 中首次引入了 文本块(Text Blocks) 特性,并经过 Java 14 的 JEP 368 和 Java 15 的 JEP 378 两个版本的迭代,使得该特性正式可用,这个特性可以让我们在 Java 代码中愉快地使用多行字符串。在使用文本块之前,定义一个 JSON 格式的字符串可能会写出像下面这样无法直视的代码来:

String json1 = "{\n" +
               "  \"name\": \"zhangsan\",\n" +
               "  \"age\": 18\n" +
               "}\n";

但是在使用文本块之后,这样的代码就变得非常清爽:

String json2 = """
               {
                 "name": "zhangsan",
                 "age": 18
               }
               """;

文本块以三个双引号 """ 开始,同样以三个双引号结束,看上去和 Python 的多行字符串类似,不过 Java 的文本块会自动处理换行和缩进,使用起来更方便。上面的文本块在 Java 中输出如下:

{
  "name": "zhangsan",
  "age": 18
}

注意开头没有换行,结尾有一个换行。而在 Python 中输出如下:


               {
                 "name": "zhangsan",
                 "age": 18
               }

不仅开头和结尾都有换行,而且每一行有很多缩进,这里可以看出 Python 的处理很简单,它直接把 """ 之间的内容原样输出了,而 Java 是根据最后一个 """ 和内容之间的相对缩进来决定输出。很显然,我们更喜欢 Java 这样的输出结果,如果希望 Python 有同样的输出结果,就得这样写:

json = """{
  "name": "zhangsan",
  "age": 18
}
"""

这在代码的可读性上就比不上 Java 了,这里不得不感叹 Java 的设计,在细节的处理上做的确实不错。

言归正传,说回字符串模板这个特性,我们也可以在文本块中使用,如下:

String json3 = STR."""
               {
                 "name": "\{name}",
                 "age": \{age}
               }
               """;

FMT 模板处理器

FMT 是 Java 21 内置的另一个模版处理器,它不仅有 STR 模版处理器的插值功能,还可以对输出进行格式化操作。格式说明符(format specifiers) 放在嵌入表达式的左侧,如下所示:

%7.2f\{price}

支持的格式说明符参见 java.util.Formatter 文档。

不过在我的环境里编译时,会报错 cannot find symbol: variable FMT,就算是把镜像更换成 openjdk:22-jdk 也是一样的错,不清楚是为什么。

有序集合

Java 集合框架(Java Collections Framework,JCF) 为集合的表示和操作提供了一套统一的体系架构,让开发人员可以使用标准的接口来组织和操作集合,而不必关心底层的数据结构或实现方式。JCF 的接口大致可以分为 CollectionMap 两组,一共 15 个:

jcf-interfaces.png

在过去的 20 个版本里,这些接口已经被证明非常有用,在日常开发中发挥了重要的作用。那么 Java 21 为什么又要增加一个新的 有序集合(Sequenced Collections) 接口呢?

不一致的顺序操作

这是因为这些接口在处理集合顺序问题时很不一致,导致了无谓的复杂性,比如要获取集合的第一个元素:

获取第一个元素
Listlist.get(0)
Dequedeque.getFirst()
SortedSetsortedSet.first()
LinkedHashSetlinkedHashSet.iterator().next()

可以看到,不同的集合有着不同的实现。再比如获取集合的最后一个元素:

获取最后一个元素
Listlist.get(list.size() - 1)
Dequedeque.getLast()
SortedSetsortedSet.last()
LinkedHashSet-

List 的实现显得非常笨重,而 LinkedHashSet 根本没有提供直接的方法,只能将整个集合遍历一遍才能获取最后一个元素。

除了获取集合的第一个元素和最后一个元素,对集合进行逆序遍历也是各不相同,比如 NavigableSet 提供了 descendingSet() 方法来逆序遍历:

for (var e : navSet.descendingSet()) {
    process(e);
}

Deque 通过 descendingIterator() 来逆序遍历:

for (var it = deque.descendingIterator(); it.hasNext();) {
    var e = it.next();
    process(e);
}

List 则是通过 listIterator() 来逆序遍历:

for (var it = list.listIterator(list.size()); it.hasPrevious();) {
    var e = it.previous();
    process(e);
}

由此可见,与顺序相关的处理方法散落在 JCF 的不同地方,使用起来极为不便。于是,Java 21 为我们提供了一个描述和操作有序集合的新接口,这个接口定义了一些与顺序相关的方法,将这些散落在各个地方的逻辑集中起来,让我们更方便地处理有序集合。

统一的有序集合接口

与顺序相关的操作主要包括三个方面:

  • 获取集合的第一个或最后一个元素
  • 向集合的最前面或最后面插入或删除元素
  • 按照逆序遍历集合

为此,Java 21 新增了三个有序接口:SequencedCollectionSequencedSetSequencedMap,他们的定义如下:

interface SequencedCollection<E> extends Collection<E> {
    SequencedCollection<E> reversed();
    void addFirst(E);
    void addLast(E);
    E getFirst();
    E getLast();
    E removeFirst();
    E removeLast();
}

interface SequencedSet<E> extends Set<E>, SequencedCollection<E> {
    SequencedSet<E> reversed();
}

interface SequencedMap<K,V> extends Map<K,V> {
    SequencedMap<K,V> reversed();
    SequencedSet<K> sequencedKeySet();
    SequencedCollection<V> sequencedValues();
    SequencedSet<Entry<K,V>> sequencedEntrySet();
    V putFirst(K, V);
    V putLast(K, V);
    Entry<K, V> firstEntry();
    Entry<K, V> lastEntry();
    Entry<K, V> pollFirstEntry();
    Entry<K, V> pollLastEntry();
}

他们在 JCF 大家庭中的位置如下图所示:

sequenced-collection.png

有了这些接口,对于所有的有序集合,我们都可以通过下面的方法来获取第一个和最后一个元素:

System.out.println("The first element is: " + list.getFirst());
System.out.println("The last element is: " + list.getLast());

逆序遍历也变得格外简单:

list.reversed().forEach(it -> System.out.println(it));

分代式 ZGC

想要搞清楚 Java 21 中的 分代式 ZGC(Generational ZGC) 这个特性,我们需要先搞清楚什么是 ZGC。

ZGC 简介

ZGC(The Z Garbage Collector) 是由 Oracle 开发的一款垃圾回收器,最初在 Java 11 中以实验性功能推出,并经过几个版本的迭代,最终在 Java 15 中被宣布为 Production Ready,相比于其他的垃圾回收器,ZGC 更适用于大内存、低延迟服务的内存管理和回收。下图展示的是不同的垃圾回收器所专注的目标也各不相同:

gc-landscape.png

低延迟服务的最大敌人是 GC 停顿,所谓 GC 停顿指的是垃圾回收期间的 STW(Stop The World),当 STW 时,所有的应用线程全部暂停,等待 GC 结束后才能继续运行。要想实现低延迟,就要想办法减少 GC 的停顿时间,根据 JEP 333 的介绍,最初 ZGC 的目标是:

  • GC 停顿时间不超过 10ms;
  • 支持处理小到几百 MB,大到 TB 量级的堆;
  • 相对于使用 G1,应用吞吐量的降低不超过 15%;

经过几年的发展,目前 ZGC 的最大停顿时间已经优化到了不超过 1 毫秒(Sub-millisecond,亚毫秒级),且停顿时间不会随着堆的增大而增加,甚至不会随着 root-set 或 live-set 的增大而增加(通过 JEP 376 Concurrent Thread-Stack Processing 实现),支持处理最小 8MB,最大 16TB 的堆:

zgc-goals.png

ZGC 之所以能实现这么快的速度,不仅是因为它在算法上做了大量的优化和改进,而且还革命性的使用了大量的创新技术,包括:

  • Concurrent:全链路并发,ZGC 在整个垃圾回收阶段几乎全部实现了并发;
  • Region-based:和 G1 类似,ZGC 是一种基于区域的垃圾回收器;
  • Compacting:垃圾回收的过程中,ZGC 会产生内存碎片,所以要进行内存整理;
  • NUMA-aware:NUMA 全称 Non-Uniform Memory Access(非一致内存访问),是一种多内存访问技术,使用 NUMA,CPU 会访问离它最近的内存,提升读写效率;
  • Using colored pointers:染色指针是一种将数据存放在指针里的技术,ZGC 通过染色指针来标记对象,以及实现对象的多重视图;
  • Using load barriers:当应用程序从堆中读取对象引用时,JIT 会向应用代码中注入一小段代码,这就是读屏障;通过读屏障操作,不仅可以让应用线程帮助完成对象的标记(mark),而且当对象地址发生变化时,还能自动实现对象转移(relocate)和重映射(remap);

关于这些技术点,网上的参考资料有很多,有兴趣的同学可以通过本文的更多部分进一步学习,其中最有意思的莫过于 染色指针读屏障,下面重点介绍这两项。

染色指针

在 64 位的操作系统中,一个指针有 64 位,但是由于内存大小限制,其实有很多高阶位是用不上的,所以我们可以在指针的高阶位中嵌入一些元数据,这种在指针中存储元数据的技术就叫做 染色指针(Colored Pointers)。染色指针是 ZGC 的核心设计之一,以前的垃圾回收器都是使用对象头来标记对象,而 ZGC 则通过染色指针来标记对象。ZGC 将一个 64 位的指针划分成三个部分:

colored-pointers.png

其中,前面的 16 位暂时没用,预留给以后使用;后面的 44 位表示对象的地址,所以 ZGC 最大可以支持 2^44=16T 内存;中间的 4 位即染色位,分别是:

  • Finalizable:标识这个对象只能通过 Finalizer 才能访问;
  • Remapped:标识这个对象是否在转移集(Relocation Set)中;
  • Marked1:用于标记可到达的对象(活跃对象);
  • Marked0:用于标记可到达的对象(活跃对象);

此外,染色指针不仅用来标记对象,还可以实现对象地址的多重视图,上述 Marked0、Marked1、Remapped 三个染色位其实代表了三种地址视图,分别对应三个虚拟地址,这三个虚拟地址指向同一个物理地址,并且在同一时间,三个虚拟地址有且只有一个有效,整个视图映射关系如下:

zgc-mmap.png

这三个地址视图的切换是由垃圾回收的不同阶段触发的:

  • 初始化阶段:程序启动时,ZGC 完成初始化,整个堆内存空间的地址视图被设置为 Remapped;
  • 标记阶段:当进入标记阶段时,视图转变为 Marked0 或者 Marked1;
  • 转移阶段:从标记阶段结束进入转移阶段时,视图再次被设置为 Remapped;

读屏障

读屏障(Load Barriers) 是 ZGC 的另一项核心技术,当应用程序从堆中读取对象引用时,JIT 会向应用代码中注入一小段代码:

load-barriers.png

在上面的代码示例中,只有第一行是从堆中读取对象引用,所以只会在第一行后面注入代码,注入的代码类似于这样:

String n = person.name; // Loading an object reference from heap
if (n & bad_bit_mask) {
    slow_path(register_for(n), address_of(person.name));
}

这行代码虽然简单,但是用途却很大,在垃圾回收的不同阶段,触发的逻辑也有所不同:在标记阶段,通过读屏障操作,可以让应用线程帮助 GC 线程一起完成对象的标记或重映射;在转移阶段,如果对象地址发生变化,还能自动实现对象转移。

ZGC 工作流程

整个 ZGC 可以划分成下面六个阶段:

zgc-phases.png

其中有三个是 STW 阶段,尽管如此,但是 ZGC 对 STW 的停顿时间有着严格的要求,一般不会超过 1 毫秒。这六个阶段的前三个可以统称为 标记(Mark)阶段

  • Pause Mark Start - 标记开始阶段,将地址视图被设置成 Marked0 或 Marked1(交替设置);这个阶段会 STW,它只标记 GC Roots 直接可达的对象,GC Roots 类似于局部变量,通过它可以访问堆上其他对象,这样的对象不会太多,所以 STW 时间很短;
  • Concurrent Mark/Remap - 并发标记阶段,GC 线程和应用线程是并发执行的,在第一步的基础上,继续往下标记存活的对象;另外,这个阶段还会对上一个 GC 周期留下来的失效指针进行重映射修复;
  • Pause Mark End - 标记结束阶段,由于并发标记阶段应用线程还在运行,所以可能会修改对象的引用,导致漏标,这个阶段会标记这些漏标的对象;

ZGC 的后三个阶段统称为 转移(Relocation)阶段(也叫重定位阶段):

  • Concurrent Prepare for Relocate - 为转移阶段做准备,比如筛选所有可以被回收的页面,将垃圾比较多的页面作为接下来转移候选集(EC);
  • Pause Relocate Start - 转移开始阶段,将地址视图从 Marked0 或者 Marked1 调整为 Remapped,从 GC Roots 出发,遍历根对象的直接引用的对象,对这些对象进行转移;
  • Concurrent Relocate - 并发转移阶段,将之前选中的转移集中存活的对象移到新的页面,转移完成的页面即可被回收掉,并发转移完成之后整个 ZGC 周期完成。注意这里只转移了对象,并没有对失效指针进行重映射,ZGC 通过转发表存储旧地址到新地址的映射,如果这个阶段应用线程访问这些失效指针,会触发读屏障机制自动修复,对于没有访问到的失效指针,要到下一个 GC 周期的并发标记阶段才会被修复。

为什么要分代?

在 ZGC 面世之前,Java 内置的所有垃圾回收器都实现了分代回收(G1 是逻辑分代):

垃圾回收器(别名)用法说明
Serial GC、Serial Copying-XX:+UseSerialGC串行,用于年轻代,使用复制算法
Serial Old、MSC-XX:+UseSerialOldGC串行,用于老年代,使用标记-整理算法
ParNew GC-XX:+UseParNewGCSerial GC 的并行版本,用于年轻代,使用复制算法
Parallel GC、Parallel Scavenge-XX:+UseParallelGC并行,用于年轻代,使用复制算法
Parallel Old、Parallel Compacting-XX:+UseParallelOldGC并行,用于老年代,使用标记-整理算法
CMS、Concurrent Mark Sweep-XX:+UseConcMarkSweepGC并发,用于老年代,使用标记-清除算法
G1、Garbage First-XX:+UseG1GC并发,既可以用于年轻代,也可以用于老年代,使用复制 + 标记-整理算法,用来取代 CMS

这些分代回收器之间可以搭配使用,周志明老师在《深入理解 Java 虚拟机》这本书中总结了各种回收器之间的关系:

gc-pairs.png

其中,Serial + CMS 和 ParNew + Serial Old 这两个组件在 Java 9 之后已经被取消,而 CMS 与 Serial Old 之间的连线表示 CMS 在并发失败的时候(Concurrent Mode Failure)会切换成 Serial Old 备用方案。

分代的基本思想源自于 弱分代假说(Weak Generational Hypothesis),这个假说认为绝大部分对象都是朝生夕死的,也就是说年轻对象往往很快死去,而老对象往往会保留下来。根据这个假说,JVM 将内存区域划分为 年轻代(Young Generation)老年代(Old Generation),新生代又进一步划分为 伊甸园区(Eden)第一幸存区(S0)第二幸存区(S1)

伊甸园区用来分配新创建的对象,如果没有足够的空间,就会触发一次 年轻代 GC(Young GC,Minor GC) 来释放内存空间,这里一般使用 标记-复制(Mark-Copy) 算法,将存活的对象标记下来,然后复制到一个幸存区中;年轻代的内存空间一般较小,所以可以更频繁地触发 GC,清理掉那些朝生夕死的对象,从而提高应用程序的性能;如果 GC 后伊甸园区还没有足够的空间存放新创建的对象,或者幸存区中某个对象的存活时间超过一定的阈值,这时就会将对象分配到老年代,如果老年代的空间也满了,就会触发一次 老年代 GC(Old GC,Full GC);老年代的内存空间要大的多,而且其中的对象大部分是存活的,GC 发生的频率要小很多,所以不再使用标记-复制算法,而是采用移动对象的方式来实现内存碎片的整理。

但是在上面的 ZGC 的工作流程中,我们却没有看到分代的影子,这也就意味着每次 ZGC 都是对整个堆空间进行扫描,尽管 ZGC 的 STW 时间已经被优化到不到 1ms,但是其他几个阶段是和应用线程一起执行的,这势必会影响到应用程序的吞吐量。让 ZGC 支持分代是一项巨大的工程,开发团队足足花了三年时间才让我们有幸在 Java 21 中体验到这一令人激动的特性。

除了 ZGC,Java 11 之后还引入了一些新的垃圾回收器:

垃圾回收器用法说明
ZGC-XX:+UseZGC低延迟 GC,from JDK 11
Epsilon GC-XX:+UseEpsilonGCNo-op GC,什么都不做,用于测试,from JDK 11
Shenandoah-XX:+UseShenandoahGCCPU 密集型 GC,from JDK 12

ZGC 实践

使用 -XX:+PrintCommandLineFlags,可以打印出 Java 的默认命令行参数:

$ java -XX:+PrintCommandLineFlags -version
-XX:ConcGCThreads=1 -XX:G1ConcRefinementThreads=4 -XX:GCDrainStackTargetSize=64 -XX:InitialHeapSize=128639872 -XX:MarkStackSize=4194304 -XX:MaxHeapSize=2058237952 -XX:MinHeapSize=6815736 -XX:+PrintCommandLineFlags -XX:ReservedCodeCacheSize=251658240 -XX:+SegmentedCodeCache -XX:+UseCompressedOops -XX:+UseG1GC 
openjdk version "21" 2023-09-19
OpenJDK Runtime Environment (build 21+35-2513)
OpenJDK 64-Bit Server VM (build 21+35-2513, mixed mode, sharing)

从上面的结果可以看出,Java 21 默认使用的仍然是 G1 垃圾回收器,它从 Java 9 就开始做为默认垃圾回收器了。

注意:Java 8 中默认的垃圾回收器是 Parallel GC。

如果想开启 ZGC,我们需要加上 -XX:+UseZGC 参数:

$ java -XX:+UseZGC -Xmx100M -Xlog:gc ZgcTest.java

其中 -Xlog:gc 参数表示打印出 GC 过程中的日志(就是 Java 8 的 -XX:+PrintGC 参数),输出结果如下:

[0.157s][info][gc] Using The Z Garbage Collector
[0.420s][info][gc] GC(0) Garbage Collection (Warmup) 14M(14%)->12M(12%)
[0.472s][info][gc] GC(1) Garbage Collection (System.gc()) 18M(18%)->8M(8%)

也可以使用 -Xlog:gc* 参数打印出 GC 过程中的详细日志(就是 Java 8 的 -XX+PrintGCDetails 参数),输出结果如下:

$ java -XX:+UseZGC -Xmx100M -Xlog:gc* ZgcTest.java
[0.010s][info][gc,init] Initializing The Z Garbage Collector
[0.011s][info][gc,init] Version: 21+35-2513 (release)
[0.011s][info][gc,init] Using legacy single-generation mode
[0.011s][info][gc,init] Probing address space for the highest valid bit: 47
[0.011s][info][gc,init] NUMA Support: Disabled
[0.011s][info][gc,init] CPUs: 4 total, 4 available
[0.011s][info][gc,init] Memory: 7851M
[0.011s][info][gc,init] Large Page Support: Disabled
[0.011s][info][gc,init] GC Workers: 1 (dynamic)
[0.011s][info][gc,init] Address Space Type: Contiguous/Unrestricted/Complete
[0.011s][info][gc,init] Address Space Size: 1600M x 3 = 4800M
[0.011s][info][gc,init] Heap Backing File: /memfd:java_heap
[0.011s][info][gc,init] Heap Backing Filesystem: tmpfs (0x1021994)
[0.012s][info][gc,init] Min Capacity: 8M
[0.012s][info][gc,init] Initial Capacity: 100M
[0.012s][info][gc,init] Max Capacity: 100M
[0.012s][info][gc,init] Medium Page Size: N/A
[0.012s][info][gc,init] Pre-touch: Disabled
[0.012s][info][gc,init] Available space on backing filesystem: N/A
[0.014s][info][gc,init] Uncommit: Enabled
[0.014s][info][gc,init] Uncommit Delay: 300s
[0.134s][info][gc,init] Runtime Workers: 1
[0.134s][info][gc     ] Using The Z Garbage Collector
[0.149s][info][gc,metaspace] CDS archive(s) mapped at: [0x0000006800000000-0x0000006800cb0000-0x0000006800cb0000), size 13303808, SharedBaseAddress: 0x0000006800000000, ArchiveRelocationMode: 1.
[0.149s][info][gc,metaspace] Compressed class space mapped at: 0x0000006801000000-0x0000006841000000, reserved size: 1073741824
[0.149s][info][gc,metaspace] Narrow klass base: 0x0000006800000000, Narrow klass shift: 0, Narrow klass range: 0x100000000
[0.357s][info][gc,start    ] GC(0) Garbage Collection (Warmup)
[0.357s][info][gc,task     ] GC(0) Using 1 workers
[0.357s][info][gc,phases   ] GC(0) Pause Mark Start 0.007ms
[0.366s][info][gc,phases   ] GC(0) Concurrent Mark 8.442ms
[0.366s][info][gc,phases   ] GC(0) Pause Mark End 0.005ms
[0.366s][info][gc,phases   ] GC(0) Concurrent Mark Free 0.000ms
[0.367s][info][gc,phases   ] GC(0) Concurrent Process Non-Strong References 1.092ms
[0.367s][info][gc,phases   ] GC(0) Concurrent Reset Relocation Set 0.000ms
[0.373s][info][gc,phases   ] GC(0) Concurrent Select Relocation Set 5.587ms
[0.373s][info][gc,phases   ] GC(0) Pause Relocate Start 0.003ms
[0.375s][info][gc,phases   ] GC(0) Concurrent Relocate 2.239ms
[0.375s][info][gc,load     ] GC(0) Load: 0.65/0.79/0.63
[0.375s][info][gc,mmu      ] GC(0) MMU: 2ms/99.7%, 5ms/99.9%, 10ms/99.9%, 20ms/99.9%, 50ms/100.0%, 100ms/100.0%
[0.375s][info][gc,marking  ] GC(0) Mark: 1 stripe(s), 2 proactive flush(es), 1 terminate flush(es), 0 completion(s), 0 continuation(s) 
[0.375s][info][gc,marking  ] GC(0) Mark Stack Usage: 32M
[0.375s][info][gc,nmethod  ] GC(0) NMethods: 889 registered, 90 unregistered
[0.375s][info][gc,metaspace] GC(0) Metaspace: 8M used, 8M committed, 1088M reserved
[0.375s][info][gc,ref      ] GC(0) Soft: 142 encountered, 0 discovered, 0 enqueued
[0.375s][info][gc,ref      ] GC(0) Weak: 747 encountered, 602 discovered, 224 enqueued
[0.375s][info][gc,ref      ] GC(0) Final: 0 encountered, 0 discovered, 0 enqueued
[0.375s][info][gc,ref      ] GC(0) Phantom: 146 encountered, 144 discovered, 143 enqueued
[0.375s][info][gc,reloc    ] GC(0) Small Pages: 7 / 14M, Empty: 0M, Relocated: 3M, In-Place: 0
[0.375s][info][gc,reloc    ] GC(0) Large Pages: 1 / 2M, Empty: 0M, Relocated: 0M, In-Place: 0
[0.375s][info][gc,reloc    ] GC(0) Forwarding Usage: 1M
[0.375s][info][gc,heap     ] GC(0) Min Capacity: 8M(8%)
[0.375s][info][gc,heap     ] GC(0) Max Capacity: 100M(100%)
[0.375s][info][gc,heap     ] GC(0) Soft Max Capacity: 100M(100%)
[0.375s][info][gc,heap     ] GC(0)                Mark Start          Mark End        Relocate Start      Relocate End           High               Low         
[0.375s][info][gc,heap     ] GC(0)  Capacity:      100M (100%)        100M (100%)        100M (100%)        100M (100%)        100M (100%)        100M (100%)   
[0.375s][info][gc,heap     ] GC(0)      Free:       84M (84%)          82M (82%)          82M (82%)          88M (88%)          88M (88%)          78M (78%)    
[0.375s][info][gc,heap     ] GC(0)      Used:       16M (16%)          18M (18%)          18M (18%)          12M (12%)          22M (22%)          12M (12%)    
[0.375s][info][gc,heap     ] GC(0)      Live:         -                 6M (6%)            6M (6%)            6M (6%)             -                  -          
[0.375s][info][gc,heap     ] GC(0) Allocated:         -                 2M (2%)            2M (2%)            3M (4%)             -                  -          
[0.375s][info][gc,heap     ] GC(0)   Garbage:         -                 9M (10%)           9M (10%)           1M (2%)             -                  -          
[0.375s][info][gc,heap     ] GC(0) Reclaimed:         -                  -                 0M (0%)            7M (8%)             -                  -          
[0.375s][info][gc          ] GC(0) Garbage Collection (Warmup) 16M(16%)->12M(12%)
[0.403s][info][gc,start    ] GC(1) Garbage Collection (System.gc())
[0.403s][info][gc,task     ] GC(1) Using 1 workers
[0.403s][info][gc,phases   ] GC(1) Pause Mark Start 0.006ms
[0.410s][info][gc,phases   ] GC(1) Concurrent Mark 7.316ms
[0.410s][info][gc,phases   ] GC(1) Pause Mark End 0.006ms
[0.410s][info][gc,phases   ] GC(1) Concurrent Mark Free 0.001ms
[0.412s][info][gc,phases   ] GC(1) Concurrent Process Non-Strong References 1.621ms
[0.412s][info][gc,phases   ] GC(1) Concurrent Reset Relocation Set 0.001ms
[0.414s][info][gc,phases   ] GC(1) Concurrent Select Relocation Set 2.436ms
[0.414s][info][gc,phases   ] GC(1) Pause Relocate Start 0.003ms
[0.415s][info][gc,phases   ] GC(1) Concurrent Relocate 0.865ms
[0.415s][info][gc,load     ] GC(1) Load: 0.65/0.79/0.63
[0.415s][info][gc,mmu      ] GC(1) MMU: 2ms/99.7%, 5ms/99.8%, 10ms/99.9%, 20ms/99.9%, 50ms/100.0%, 100ms/100.0%
[0.415s][info][gc,marking  ] GC(1) Mark: 1 stripe(s), 2 proactive flush(es), 1 terminate flush(es), 0 completion(s), 0 continuation(s) 
[0.415s][info][gc,marking  ] GC(1) Mark Stack Usage: 32M
[0.415s][info][gc,nmethod  ] GC(1) NMethods: 983 registered, 129 unregistered
[0.415s][info][gc,metaspace] GC(1) Metaspace: 9M used, 9M committed, 1088M reserved
[0.415s][info][gc,ref      ] GC(1) Soft: 155 encountered, 0 discovered, 0 enqueued
[0.415s][info][gc,ref      ] GC(1) Weak: 729 encountered, 580 discovered, 58 enqueued
[0.415s][info][gc,ref      ] GC(1) Final: 0 encountered, 0 discovered, 0 enqueued
[0.415s][info][gc,ref      ] GC(1) Phantom: 49 encountered, 47 discovered, 46 enqueued
[0.415s][info][gc,reloc    ] GC(1) Small Pages: 6 / 12M, Empty: 0M, Relocated: 1M, In-Place: 0
[0.415s][info][gc,reloc    ] GC(1) Large Pages: 2 / 4M, Empty: 2M, Relocated: 0M, In-Place: 0
[0.415s][info][gc,reloc    ] GC(1) Forwarding Usage: 0M
[0.415s][info][gc,heap     ] GC(1) Min Capacity: 8M(8%)
[0.415s][info][gc,heap     ] GC(1) Max Capacity: 100M(100%)
[0.415s][info][gc,heap     ] GC(1) Soft Max Capacity: 100M(100%)
[0.415s][info][gc,heap     ] GC(1)                Mark Start          Mark End        Relocate Start      Relocate End           High               Low         
[0.415s][info][gc,heap     ] GC(1)  Capacity:      100M (100%)        100M (100%)        100M (100%)        100M (100%)        100M (100%)        100M (100%)   
[0.415s][info][gc,heap     ] GC(1)      Free:       84M (84%)          84M (84%)          84M (84%)          92M (92%)          92M (92%)          82M (82%)    
[0.415s][info][gc,heap     ] GC(1)      Used:       16M (16%)          16M (16%)          16M (16%)           8M (8%)           18M (18%)           8M (8%)     
[0.415s][info][gc,heap     ] GC(1)      Live:         -                 4M (5%)            4M (5%)            4M (5%)             -                  -          
[0.415s][info][gc,heap     ] GC(1) Allocated:         -                 0M (0%)            2M (2%)            2M (2%)             -                  -          
[0.415s][info][gc,heap     ] GC(1)   Garbage:         -                11M (11%)           9M (9%)            1M (1%)             -                  -          
[0.415s][info][gc,heap     ] GC(1) Reclaimed:         -                  -                 2M (2%)           10M (10%)            -                  -          
[0.415s][info][gc          ] GC(1) Garbage Collection (System.gc()) 16M(16%)->8M(8%)
[0.416s][info][gc,heap,exit] Heap
[0.416s][info][gc,heap,exit]  ZHeap           used 8M, capacity 100M, max capacity 100M
[0.416s][info][gc,heap,exit]  Metaspace       used 9379K, committed 9600K, reserved 1114112K
[0.416s][info][gc,heap,exit]   class space    used 1083K, committed 1216K, reserved 1048576K

从日志中可以看到 ZGC 的整个过程。默认情况下并没有开启分代式 ZGC,如果想开启分代式 ZGC,我们还需要加上 -XX:+ZGenerational 参数:

$ java -XX:+UseZGC -XX:+ZGenerational -Xmx100M -Xlog:gc* ZgcTest.java

这个输出比较多,此处就省略了,从输出中可以看到不同分代的回收情况。关于 ZGC,还有很多微调参数,详细内容可参考 ZGC 的官方文档

记录模式

记录模式(Record Patterns) 是对 记录类(Records) 这个特性的延伸,所以,我们先大致了解下什么是记录类,然后再来看看什么是记录模式。

什么是记录类(Records)?

记录类早在 Java 14 就已经引入了,它类似于 Tuple,提供了一种更简洁、更紧凑的方式来表示不可变数据,记录类经过三个版本的迭代(JEP 359JEP 384JEP 395),最终在 Java 16 中发布了正式版本。

记录类的概念在其他编程语言中其实早已有之,比如 Kotlin 的 Data class 或者 Scala 的 Case class。它本质上依然是一个类,只不过使用关键字 record 来定义:

record Point(int x, int y) { }

记录类的定义非常灵活,我们可以在单独文件中定义,也可以在类内部定义,甚至在函数内部定义。记录类的使用和普通类无异,使用 new 创建即可:

Point p1 = new Point(10, 20);
System.out.println("x = " + p1.x());
System.out.println("y = " + p1.y());
System.out.println("p1 is " + p1.toString());

记录类具备如下特点:

  • 它是一个 final 类;
  • 它不能继承其他类,也不能继承其他记录类;
  • 它的所有字段也是 final 的,所以一旦创建就不能修改;
  • 它内置实现了构造函数,函数参数就是所有的字段;
  • 它内置实现了所有字段的 getter 方法,没有 setter 方法;
  • 它内置实现了 equals()hashCode()toString() 函数;

所以上面的示例和下面的 Point 类是等价的:

public final class Point {
    final int x;
    final int y;

    public Point(int x, int y) {
        this.x = x;
        this.y = y;
    }

    public int x() {
        return x;
    }

    public int y() {
        return y;
    }

    @Override
    public boolean equals(Object o) {
        if (this == o) return true;
        if (o == null || getClass() != o.getClass()) return false;
        Point point = (Point) o;
        return x == point.x && y == point.y;
    }

    @Override
    public int hashCode() {
        return Objects.hash(x, y);
    }

    @Override
    public String toString() {
        return "Point{" +
                "x=" + x +
                ", y=" + y +
                '}';
    }
}

我们也可以在记录类中声明新的方法:

record Point(int x, int y) {
    boolean isOrigin() {
        return x == 0 && y == 0;
    }
}

记录类的很多特性和 Lombok 非常类似,比如下面通过 Lombok 的 @Value 注解创建一个不可变对象:

@Value
public class Point {
    int x;
    int y;
}

不过记录类和 Lombok 还是有一些区别的:

  • 根据 JEP 395 的描述,记录类是作为不可变数据的透明载体,也就是说记录类无法隐藏字段;然而,Lombok 允许我们修改字段名称和访问级别;
  • 记录类适合创建小型对象,当类中存在很多字段时,记录类会变得非常臃肿;使用 Lombok 的 @Builder 构建器模式可以写出更干净的代码;
  • 记录类只能创建不可变对象,而 Lombok 的 @Data 可以创建可变对象;
  • 记录类不支持继承,但是 Lombok 创建的类可以继承其他类或被其他类继承;

什么是记录模式(Record Patterns)?

相信很多人都写过类似下面这样的代码:

if (obj instanceof Integer) {
    int intValue = ((Integer) obj).intValue();
    System.out.println(intValue);
}

这段代码实际上做了三件事:

  • Test:测试 obj 的类型是否为 Integer
  • Conversion:将 obj 的类型转换为 Integer
  • Destructuring:从 Integer 类中提取出 int 值;

这三个步骤构成了一种通用的模式:测试并进行强制类型转换,这种模式被称为 模式匹配(Pattern Matching)。虽然简单,但是却很繁琐。Java 16 在 JEP 394 中正式发布了 instanceof 模式匹配 的特性,帮我们减少这种繁琐的条件状态提取:

if (obj instanceof Integer intValue) {
    System.out.println(intValue);
}

这里的 Integer intValue 被称为 类型模式(Type Patterns),其中 Integer 是匹配的断言,intValue 是匹配成功后的变量,这个变量可以直接使用,不需要再进行类型转换了。

匹配的断言也支持记录类:

if (obj instanceof Point p) {
    int x = p.x();
    int y = p.y();
    System.out.println(x + y);
}

不过,这里虽然测试和转换代码得到了简化,但是从记录类中提取值仍然不是很方便,我们还可以进一步简化这段代码:

if (obj instanceof Point(int x, int y)) {
    System.out.println(x + y);
}

这里的 Point(int x, int y) 就是 Java 21 中的 记录模式(Record Patterns),可以说它是 instanceof 模式匹配的一个特例,专门用于从记录类中提取数据;记录模式也经过了三个版本的迭代:JEP 405JEP 432JEP 440,现在终于在 Java 21 中发布了正式版本。

此外,记录模式还支持嵌套,我们可以在记录模式中嵌套另一个模式,假设有下面两个记录类:

record Address(String province, String city) {}
record Person(String name, Integer age, Address address) {}

我们可以一次性提取出外部记录和内部记录的值:

if (obj instanceof Person(String name, Integer age, Address(String province, String city))) {
    System.out.println("Name: " + name);
    System.out.println("Age: " + age);
    System.out.println("Address: " + province + " " + city);
}

仔细体会上面的代码,是不是非常优雅?

switch 模式匹配

上面学习了 instanceof 模式匹配,其实还有另一种模式匹配叫做 switch 模式匹配,这个特性经历了 JEP 406JEP 420JEP 427JEP 433JEP 441 五个版本的迭代,从 Java 17 开始首个预览版本到 Java 21 正式发布足足开发了 2 年时间。

在介绍这个功能之前,有一个前置知识点需要复习一下:在 Java 14 中发布了一个特性叫做 Switch Expressions,这个特性允许我们在 case 中使用 Lambda 表达式来简化 switch 语句的写法:

int result = switch (type) {
    case "child" -> 0;
    case "adult" -> 1;
    default -> -1;
};
System.out.println(result);

这种写法不仅省去了繁琐的 break 关键词,而且 switch 作为表达式可以直接赋值给一个变量。switch 模式匹配 则更进一步,允许我们在 case 语句中进行类型的测试和转换,下面是 switch 模式匹配的一个示例:

String formatted = switch (obj) {
    case Integer i -> String.format("int %d", i);
    case Long l    -> String.format("long %d", l);
    case Double d  -> String.format("double %f", d);
    case String s  -> String.format("string %s", s);
    default        -> "unknown";
};
System.out.println(formatted);

作为对比,如果不使用 switch 模式匹配,我们只能写出下面这样的面条式代码:

String formatted;
if (obj instanceof Integer i) {
    formatted = String.format("int %d", i);
} else if (obj instanceof Long l) {
    formatted = String.format("long %d", l);
} else if (obj instanceof Double d) {
    formatted = String.format("double %f", d);
} else if (obj instanceof String s) {
    formatted = String.format("string %s", s);
} else {
    formatted = "unknown";
}
System.out.println(formatted);

参考

更多

垃圾回收

ZGC

G1

CMS

扫描二维码,在手机上阅读!

读源码剖析 Spring Security 的实现原理

Spring Security 是一个轻量级的安全框架,可以和 Spring 项目很好地集成,提供了丰富的身份认证和授权相关的功能,而且还能防止一些常见的网络攻击。我在工作中有很多项目都使用了 Spring Security 框架,但基本上都是浅尝辄止,按照说明文档配置好就完事了,一直没有时间深入地研究过。最近在 Reflectoring 上看到了一篇文章 Getting started with Spring Security and Spring Boot,写得非常全面仔细,感觉是一篇不错的 Spring Security 入门文章,于是花了一点时间拜读了一番,结合着 官方文档源码 系统地学习一下 Spring Security 的实现原理。

入门示例

我们先从一个简单的例子开始,这里我直接使用了 使用 Spring 项目脚手架 中的 Hello World 示例。为了让这个示例程序开启 Spring Security 功能,我们在 pom.xml 文件中引入 spring-boot-starter-security 依赖即可:

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

启动程序,会在控制台日志中看到类似下面这样的信息:

2023-05-15 06:52:52.418  INFO 8596 --- [           main] .s.s.UserDetailsServiceAutoConfiguration : 

Using generated security password: eeb386a9-e16a-4b9b-bbc6-c054c8d263b0

这个是由 Spring Security 随机生成的密码。访问 /hello 页面,可以看到出现了一个登录页面:

login.png

输入用户名(默认为 user)和密码(控制台日志)登录成功后我们才能正常访问页面。默认的用户名和密码可以使用下面的配置进行修改:

spring.security.user.name=admin
spring.security.user.password=123456

为了后续更好地对 Spring Security 进行配置,理解 Spring Security 的实现原理,我们需要进一步学习 Spring Security 的三大核心组件:

  • 过滤器(Servlet Filters)
  • 认证(Authentication)
  • 授权(Authorization)

Servlet Filters:Spring Security 的基础

我们知道,在 Spring MVC 框架中,DispatcherServlet 负责对用户的 Web 请求进行分发和处理,在请求到达 DispatcherServlet 之前,会经过一系列的 Servlet Filters,这被称之为过滤器,主要作用是拦截请求并对请求做一些前置或后置处理。这些过滤器串在一起,形成一个过滤器链(FilterChain):

filterchain.png

我们可以在配置文件中加上下面的日志配置:

logging.level.org.springframework.boot.web.servlet.ServletContextInitializerBeans=TRACE

然后重新启动服务,会在控制台输出类似下面这样的日志(为了方便查看,我做了一点格式化):

2023-05-18 07:08:14.805 TRACE 10020 --- [           main] o.s.b.w.s.ServletContextInitializerBeans : 
    Added existing Filter initializer bean 'webMvcMetricsFilter'; order=-2147483647, 
    resource=class path resource [org/springframework/boot/actuate/autoconfigure/metrics/web/servlet/WebMvcMetricsAutoConfiguration.class]
2023-05-18 07:08:14.806 TRACE 10020 --- [           main] o.s.b.w.s.ServletContextInitializerBeans : 
    Added existing Filter initializer bean 'securityFilterChainRegistration'; order=-100, 
    resource=class path resource [org/springframework/boot/autoconfigure/security/servlet/SecurityFilterAutoConfiguration.class]
2023-05-18 07:08:14.808 TRACE 10020 --- [           main] o.s.b.w.s.ServletContextInitializerBeans : 
    Added existing Servlet initializer bean 'dispatcherServletRegistration'; order=2147483647, 
    resource=class path resource [org/springframework/boot/autoconfigure/web/servlet/DispatcherServletAutoConfiguration$DispatcherServletRegistrationConfiguration.class]
2023-05-18 07:08:14.810 TRACE 10020 --- [           main] o.s.b.w.s.ServletContextInitializerBeans : 
    Added existing Filter initializer bean 'errorPageSecurityFilter'; order=2147483647, 
    resource=class path resource [org/springframework/boot/autoconfigure/security/servlet/SpringBootWebSecurityConfiguration$ErrorPageSecurityFilterConfiguration.class]
2023-05-18 07:08:14.813 TRACE 10020 --- [           main] o.s.b.w.s.ServletContextInitializerBeans : 
    Added existing ServletContextInitializer initializer bean 'servletEndpointRegistrar'; order=2147483647, 
    resource=class path resource [org/springframework/boot/actuate/autoconfigure/endpoint/web/ServletEndpointManagementContextConfiguration$WebMvcServletEndpointManagementContextConfiguration.class]
2023-05-18 07:08:14.828 TRACE 10020 --- [           main] o.s.b.w.s.ServletContextInitializerBeans : 
    Created Filter initializer for bean 'characterEncodingFilter'; order=-2147483648, 
    resource=class path resource [org/springframework/boot/autoconfigure/web/servlet/HttpEncodingAutoConfiguration.class]    
2023-05-18 07:08:14.831 TRACE 10020 --- [           main] o.s.b.w.s.ServletContextInitializerBeans : 
    Created Filter initializer for bean 'formContentFilter'; order=-9900, 
    resource=class path resource [org/springframework/boot/autoconfigure/web/servlet/WebMvcAutoConfiguration.class]
2023-05-18 07:08:14.834 TRACE 10020 --- [           main] o.s.b.w.s.ServletContextInitializerBeans : 
    Created Filter initializer for bean 'requestContextFilter'; order=-105, 
    resource=class path resource [org/springframework/boot/autoconfigure/web/servlet/WebMvcAutoConfiguration$WebMvcAutoConfigurationAdapter.class]
2023-05-18 07:08:14.842 DEBUG 10020 --- [           main] o.s.b.w.s.ServletContextInitializerBeans : 
    Mapping filters: 
        filterRegistrationBean urls=[/*] order=-2147483647, 
        springSecurityFilterChain urls=[/*] order=-100, 
        filterRegistrationBean urls=[/*] order=2147483647, 
        characterEncodingFilter urls=[/*] order=-2147483648, 
        formContentFilter urls=[/*] order=-9900, 
        requestContextFilter urls=[/*] order=-105
2023-05-18 07:08:14.844 DEBUG 10020 --- [           main] o.s.b.w.s.ServletContextInitializerBeans : 
    Mapping servlets: dispatcherServlet urls=[/] 

这里显示了应用开启的所有 Filter 以及对应的自动配置类,可以看到 Spring Security 自动注入了两个 FilterRegistrationBean

  • 来自配置类 SecurityFilterAutoConfigurationsecurityFilterChainRegistration
  • 来自配置类 ErrorPageSecurityFilterConfigurationerrorPageSecurityFilter

DelegatingFilterProxy:Servlet Filter 与 Spring Bean 的桥梁

注意上面显示的并非 Filter 的名字,而是 FilterRegistrationBean 的名字,这是一种 RegistrationBean,它实现了 ServletContextInitializer 接口,用于在程序启动时,将 FilterServlet 注入到 ServletContext 中:

public abstract class RegistrationBean implements ServletContextInitializer, Ordered {

    @Override
    public final void onStartup(ServletContext servletContext) throws ServletException {
        ...
        register(description, servletContext);
    }

}

其中 securityFilterChainRegistration 的定义如下:

@Bean
@ConditionalOnBean(name = DEFAULT_FILTER_NAME)
public DelegatingFilterProxyRegistrationBean securityFilterChainRegistration(
        SecurityProperties securityProperties) {
    DelegatingFilterProxyRegistrationBean registration = new DelegatingFilterProxyRegistrationBean(
            DEFAULT_FILTER_NAME);
    registration.setOrder(securityProperties.getFilter().getOrder());
    registration.setDispatcherTypes(getDispatcherTypes(securityProperties));
    return registration;
}

这个 RegistrationBean 的类型为 DelegatingFilterProxyRegistrationBean,由它注入的 FilterDelegatingFilterProxy

public class DelegatingFilterProxyRegistrationBean extends AbstractFilterRegistrationBean<DelegatingFilterProxy> {
    ...
}

这是一个非常重要的 Servlet Filter,它充当着 Servlet 容器和 Spring 上下文之间的桥梁,由于 Servlet 容器有着它自己的标准,在注入 Filter 时并不知道 Spring Bean 的存在,所以我们可以通过 DelegatingFilterProxy 来实现 Bean Filter 的延迟加载:

delegatingfilterproxy.png

看一下 DelegatingFilterProxy 的实现:

public class DelegatingFilterProxy extends GenericFilterBean {

    @Override
    public void doFilter(ServletRequest request, ServletResponse response, FilterChain filterChain)
            throws ServletException, IOException {

        // Lazily initialize the delegate if necessary.
        Filter delegateToUse = this.delegate;
        if (delegateToUse == null) {
            synchronized (this.delegateMonitor) {
                delegateToUse = this.delegate;
                if (delegateToUse == null) {
                    WebApplicationContext wac = findWebApplicationContext();
                    if (wac == null) {
                        throw new IllegalStateException("No WebApplicationContext found: " +
                                "no ContextLoaderListener or DispatcherServlet registered?");
                    }
                    delegateToUse = initDelegate(wac);
                }
                this.delegate = delegateToUse;
            }
        }

        // Let the delegate perform the actual doFilter operation.
        invokeDelegate(delegateToUse, request, response, filterChain);
    }
}

这段代码很容易理解,首先判断代理的 Bean Filter 是否存在,如果不存在则根据 findWebApplicationContext() 找到 Web 应用上下文,然后从上下文中获取 Bean Filter 并初始化,最后再调用该 Bean Filter

FilterChainProxy:Spring Security 的统一入口

那么接下来的问题是,这个 DelegatingFilterProxy 代理的 Bean Filter 是什么呢?我们从上面定义 DelegatingFilterProxyRegistrationBean 的地方可以看出,代理的 Bean Filter 叫做 DEFAULT_FILTER_NAME,查看它的定义就知道,实际上就是 springSecurityFilterChain

public static final String DEFAULT_FILTER_NAME = "springSecurityFilterChain";

那么这个 springSecurityFilterChain 是在哪定义的呢?我们可以在 WebSecurityConfiguration 配置类中找到答案:

public class WebSecurityConfiguration {

    @Bean(name = AbstractSecurityWebApplicationInitializer.DEFAULT_FILTER_NAME)
    public Filter springSecurityFilterChain() throws Exception {
        boolean hasConfigurers = this.webSecurityConfigurers != null && !this.webSecurityConfigurers.isEmpty();
        boolean hasFilterChain = !this.securityFilterChains.isEmpty();
        if (!hasConfigurers && !hasFilterChain) {
            WebSecurityConfigurerAdapter adapter = this.objectObjectPostProcessor
                    .postProcess(new WebSecurityConfigurerAdapter() {
                    });
            this.webSecurity.apply(adapter);
        }
        for (SecurityFilterChain securityFilterChain : this.securityFilterChains) {
            this.webSecurity.addSecurityFilterChainBuilder(() -> securityFilterChain);
            for (Filter filter : securityFilterChain.getFilters()) {
                if (filter instanceof FilterSecurityInterceptor) {
                    this.webSecurity.securityInterceptor((FilterSecurityInterceptor) filter);
                    break;
                }
            }
        }
        for (WebSecurityCustomizer customizer : this.webSecurityCustomizers) {
            customizer.customize(this.webSecurity);
        }
        return this.webSecurity.build();
    }
}

很显然,springSecurityFilterChain 经过一系列的安全配置,最后通过 this.webSecurity.build() 构建出来的,进一步深入到 webSecurity 的源码我们就可以发现它的类型是 FilterChainProxy

public final class WebSecurity extends AbstractConfiguredSecurityBuilder<Filter, WebSecurity>
        implements SecurityBuilder<Filter>, ApplicationContextAware, ServletContextAware {

    @Override
    protected Filter performBuild() throws Exception {

        int chainSize = this.ignoredRequests.size() + this.securityFilterChainBuilders.size();
        List<SecurityFilterChain> securityFilterChains = new ArrayList<>(chainSize);
        List<RequestMatcherEntry<List<WebInvocationPrivilegeEvaluator>>> requestMatcherPrivilegeEvaluatorsEntries = new ArrayList<>();
        for (RequestMatcher ignoredRequest : this.ignoredRequests) {
            SecurityFilterChain securityFilterChain = new DefaultSecurityFilterChain(ignoredRequest);
            securityFilterChains.add(securityFilterChain);
            requestMatcherPrivilegeEvaluatorsEntries
                    .add(getRequestMatcherPrivilegeEvaluatorsEntry(securityFilterChain));
        }
        for (SecurityBuilder<? extends SecurityFilterChain> securityFilterChainBuilder : this.securityFilterChainBuilders) {
            SecurityFilterChain securityFilterChain = securityFilterChainBuilder.build();
            securityFilterChains.add(securityFilterChain);
            requestMatcherPrivilegeEvaluatorsEntries
                    .add(getRequestMatcherPrivilegeEvaluatorsEntry(securityFilterChain));
        }

        FilterChainProxy filterChainProxy = new FilterChainProxy(securityFilterChains);
        if (this.httpFirewall != null) {
            filterChainProxy.setFirewall(this.httpFirewall);
        }
        if (this.requestRejectedHandler != null) {
            filterChainProxy.setRequestRejectedHandler(this.requestRejectedHandler);
        }
        filterChainProxy.afterPropertiesSet();

        Filter result = filterChainProxy;

        this.postBuildAction.run();
        return result;
    }
}

FilterChainProxy 的名字可以看出来,它也是一个代理类,它代理的类叫做 SecurityFilterChain,它包含了多个 Security Filters 形成一个过滤器链,这和 Servlet Filters 有点类似,只不过这些 Security Filters 都是普通的 Spring Bean:

securityfilterchain.png

使用 FilterChainProxy 来代理 Security Filters 相比于直接使用 Servlet Filters 或使用 DelegatingFilterProxy 来代理有几个明显的好处:

  1. FilterChainProxy 作为 Spring Security 对 Servlet 的支持入口,方便理解和调试;
  2. FilterChainProxy 可以对 Spring Security 做一些集中处理,比如统一清除 SecurityContext 防止内存泄漏,以及统一使用 HttpFirewall 对应用进行保护等;
  3. 支持多个 SecurityFilterChain,传统的 Servlet Filters 只能通过 URL 来匹配,使用 FilterChainProxy 可以配合 RequestMatcher 更灵活地控制调用哪个 SecurityFilterChain

securityfilterchains.png

构建 SecurityFilterChain

上面讲到,FilterChainProxy 是通过 webSecurity 构建的,一个 FilterChainProxy 里包含一个或多个 SecurityFilterChain,那么 SecurityFilterChain 是由谁构建的呢?答案是 httpSecurity。我们可以在 SecurityFilterChainConfiguration 配置类中看到 SecurityFilterChain 的构建过程:

@Configuration(proxyBeanMethods = false)
@ConditionalOnDefaultWebSecurity
static class SecurityFilterChainConfiguration {

    @Bean
    @Order(SecurityProperties.BASIC_AUTH_ORDER)
    SecurityFilterChain defaultSecurityFilterChain(HttpSecurity http) throws Exception {
        http.authorizeRequests().anyRequest().authenticated();
        http.formLogin();
        http.httpBasic();
        return http.build();
    }
}

深入到 http.build() 的源码,可以看到过滤器链的默认实现为 DefaultSecurityFilterChain

public final class HttpSecurity extends AbstractConfiguredSecurityBuilder<DefaultSecurityFilterChain, HttpSecurity>
        implements SecurityBuilder<DefaultSecurityFilterChain>, HttpSecurityBuilder<HttpSecurity> {

    @SuppressWarnings("unchecked")
    @Override
    protected DefaultSecurityFilterChain performBuild() {

        this.filters.sort(OrderComparator.INSTANCE);
        List<Filter> sortedFilters = new ArrayList<>(this.filters.size());
        for (Filter filter : this.filters) {
            sortedFilters.add(((OrderedFilter) filter).filter);
        }
        return new DefaultSecurityFilterChain(this.requestMatcher, sortedFilters);
    }
}

构建 Security Filters

通过上面的梳理,我们大概清楚了 SecurityFilterChain 的构建过程,接下来,我们继续看 Security Filters 的构建过程。我们知道,一个SecurityFilterChain 中包含了多个 Security Filters,那么这些 Security Filters 是从哪里来的呢?

HttpSecurity 的代码里可以找到这么几个方法:

  • public HttpSecurity addFilter(Filter filter)
  • public HttpSecurity addFilterBefore(Filter filter, Class<? extends Filter> beforeFilter)
  • public HttpSecurity addFilterAfter(Filter filter, Class<? extends Filter> afterFilter)
  • public HttpSecurity addFilterAt(Filter filter, Class<? extends Filter> atFilter)

我们不妨在 addFilter 方法内下个断点,然后以调试模式启动程序,每次触发断点时,我们将对应的 Filter 记录下来,并通过堆栈找到该 Filter 是从何处添加的:

序号Filter来源
1WebAsyncManagerIntegrationFilterHttpSecurityConfiguration.httpSecurity()
2CsrfFilterCsrfConfigurer.configure()
3ExceptionTranslationFilterExceptionHandlingConfigurer.configure()
4HeaderWriterFilterHeadersConfigurer.configure()
5SessionManagementFilterSessionManagementConfigurer.configure()
6DisableEncodeUrlFilterSessionManagementConfigurer.configure()
7SecurityContextPersistenceFilterSecurityContextConfigurer.configure()
8RequestCacheAwareFilterRequestCacheConfigurer.configure()
9AnonymousAuthenticationFilterAnonymousConfigurer.configure()
10SecurityContextHolderAwareRequestFilterServletApiConfigurer.configure()
11DefaultLoginPageGeneratingFilterDefaultLoginPageConfigurer.configure()
12DefaultLogoutPageGeneratingFilterDefaultLoginPageConfigurer.configure()
13LogoutFilterLogoutConfigurer.configure()
14FilterSecurityInterceptorAbstractInterceptUrlConfigurer.configure()
15UsernamePasswordAuthenticationFilterAbstractAuthenticationFilterConfigurer.configure()
16BasicAuthenticationFilterHttpBasicConfigurer.configure()

除了第一个 WebAsyncManagerIntegrationFilter 是在创建 HttpSecurity 的时候直接添加的,其他的 Filter 都是通过 XXXConfigurer 这样的配置器添加的。我们继续深挖下去可以发现,生成这些配置器的地方有两个,第一个地方是在 HttpSecurityConfiguration 配置类中创建 HttpSecurity 时,如下所示:

class HttpSecurityConfiguration {

    @Bean(HTTPSECURITY_BEAN_NAME)
    @Scope("prototype")
    HttpSecurity httpSecurity() throws Exception {
        WebSecurityConfigurerAdapter.LazyPasswordEncoder passwordEncoder = new WebSecurityConfigurerAdapter.LazyPasswordEncoder(
                this.context);
        AuthenticationManagerBuilder authenticationBuilder = new WebSecurityConfigurerAdapter.DefaultPasswordEncoderAuthenticationManagerBuilder(
                this.objectPostProcessor, passwordEncoder);
        authenticationBuilder.parentAuthenticationManager(authenticationManager());
        authenticationBuilder.authenticationEventPublisher(getAuthenticationEventPublisher());
        HttpSecurity http = new HttpSecurity(this.objectPostProcessor, authenticationBuilder, createSharedObjects());
        // @formatter:off
        http
            .csrf(withDefaults())
            .addFilter(new WebAsyncManagerIntegrationFilter())
            .exceptionHandling(withDefaults())
            .headers(withDefaults())
            .sessionManagement(withDefaults())
            .securityContext(withDefaults())
            .requestCache(withDefaults())
            .anonymous(withDefaults())
            .servletApi(withDefaults())
            .apply(new DefaultLoginPageConfigurer<>());
        http.logout(withDefaults());
        // @formatter:on
        applyDefaultConfigurers(http);
        return http;
    }
}

另外一个地方则是在上面的 SecurityFilterChainConfiguration 配置类中使用 http.build() 构建 SecurityFilterChain 之前(参见上面 defaultSecurityFilterChain 的代码),至此,我们大概理清了所有的 Security Filters 是如何创建的,下面再以表格的形式重新整理下:

序号FilterhttpSecurity 配置
1WebAsyncManagerIntegrationFilterhttp.addFilter(new WebAsyncManagerIntegrationFilter())
2CsrfFilterhttp.csrf(withDefaults())
3ExceptionTranslationFilterhttp.exceptionHandling(withDefaults())
4HeaderWriterFilterhttp.headers(withDefaults())
5SessionManagementFilterhttp.sessionManagement(withDefaults())
6DisableEncodeUrlFilterhttp.sessionManagement(withDefaults())
7SecurityContextPersistenceFilterhttp.securityContext(withDefaults())
8RequestCacheAwareFilterhttp.requestCache(withDefaults())
9AnonymousAuthenticationFilterhttp.anonymous(withDefaults())
10SecurityContextHolderAwareRequestFilterhttp.servletApi(withDefaults())
11DefaultLoginPageGeneratingFilterhttp.apply(new DefaultLoginPageConfigurer<>())
12DefaultLogoutPageGeneratingFilterhttp.apply(new DefaultLoginPageConfigurer<>())
13LogoutFilterhttp.logout(withDefaults())
14FilterSecurityInterceptorhttp.authorizeRequests().anyRequest().authenticated()
15UsernamePasswordAuthenticationFilterhttp.formLogin()
16BasicAuthenticationFilterhttp.httpBasic()

其实,如果仔细观察我们的程序输出的日志,也可以看到 Spring Security 默认的过滤器链为 DefaultSecurityFilterChain,以及它注入的所有 Security Filters

2023-05-17 08:16:18.173  INFO 3936 --- [           main] o.s.s.web.DefaultSecurityFilterChain     : Will secure any request with [
        org.springframework.security.web.session.DisableEncodeUrlFilter@1d6751e3, 
        org.springframework.security.web.context.request.async.WebAsyncManagerIntegrationFilter@2d258eff, 
        org.springframework.security.web.context.SecurityContextPersistenceFilter@202898d7, 
        org.springframework.security.web.header.HeaderWriterFilter@2c26ba07, 
        org.springframework.security.web.csrf.CsrfFilter@52d3fafd, 
        org.springframework.security.web.authentication.logout.LogoutFilter@235c997d, 
        org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter@5d5c41e5, 
        org.springframework.security.web.authentication.ui.DefaultLoginPageGeneratingFilter@50b93353, 
        org.springframework.security.web.authentication.ui.DefaultLogoutPageGeneratingFilter@6dca31eb, 
        org.springframework.security.web.authentication.www.BasicAuthenticationFilter@22825e1e, 
        org.springframework.security.web.savedrequest.RequestCacheAwareFilter@2c719bd4, 
        org.springframework.security.web.servletapi.SecurityContextHolderAwareRequestFilter@53aa38be, 
        org.springframework.security.web.authentication.AnonymousAuthenticationFilter@4a058df8, 
        org.springframework.security.web.session.SessionManagementFilter@42ea7565, 
        org.springframework.security.web.access.ExceptionTranslationFilter@77cb452c, 
        org.springframework.security.web.access.intercept.FilterSecurityInterceptor@8054fe2]

在某些低版本中,可能会显示 DefaultSecurityFilterChain: Will not secure any request 这样的日志,这可能是 Spring Security 的 BUG,升级到最新版本即可。

其中有几个 Security Filters 比较重要,是实现认证和授权的基础:

  • CsrfFilter:默认开启对所有接口的 CSRF 防护,关于 CSRF 的详细信息,可以参考 Configuring CSRF/XSRF with Spring Security
  • DefaultLoginPageGeneratingFilter:用于生成 /login 登录页面;
  • DefaultLogoutPageGeneratingFilter:用于生成 /login?logout 登出页面;
  • LogoutFilter:当用户退出应用时被调用,它通过注册的 LogoutHandler 删除会话并清理 SecurityContext,然后通过 LogoutSuccessHandler 将页面重定向到 /login?logout
  • UsernamePasswordAuthenticationFilter:实现基于用户名和密码的安全认证,当认证失败,抛出 AuthenticationException 异常;
  • BasicAuthenticationFilter:实现 Basic 安全认证,当认证失败,抛出 AuthenticationException 异常;
  • AnonymousAuthenticationFilter:如果 SecurityContext 中没有 Authentication 对象时,它自动创建一个匿名用户 anonymousUser,角色为 ROLE_ANONYMOUS
  • FilterSecurityInterceptor:这是 Spring Security 的最后一个 Security Filters,它从 SecurityContext 中获取 Authentication 对象,然后对请求的资源做权限判断,当授权失败,抛出 AccessDeniedException 异常;
  • ExceptionTranslationFilter:用于处理过滤器链中抛出的 AuthenticationExceptionAccessDeniedException 异常,AuthenticationException 异常由 AuthenticationEntryPoint 来处理,AccessDeniedException 异常由 AccessDeniedHandler 来处理;

认证和授权

有了 Security Filters,我们就可以实现各种 Spring Security 的相关功能了。应用程序的安全性归根结底包括了两个主要问题:认证(Authentication)授权(Authorization)。认证解决的是 你是谁? 的问题,而授权负责解决 你被允许做什么?,授权也被称为 访问控制(Access Control)。这一节将深入学习 Spring Security 是如何实现认证和授权的。

跳转到 /login 页面

让我们回到第一节的例子,当访问 /hello 时,可以看到浏览器自动跳转到了 /login 登录页面,那么 Spring Security 是如何实现的呢?为了一探究竟,我们可以将 Spring Security 的日志级别调到 TRACE

logging.level.org.springframework.security=TRACE

这样我们就能完整地看到这个请求经过 Security Filters 的处理过程:

2023-05-20 09:37:38.558 DEBUG 6632 --- [nio-8080-exec-9] o.s.security.web.FilterChainProxy        : Securing GET /hello
2023-05-20 09:37:38.559 TRACE 6632 --- [nio-8080-exec-9] o.s.security.web.FilterChainProxy        : Invoking DisableEncodeUrlFilter (1/17)
2023-05-20 09:37:38.559 TRACE 6632 --- [nio-8080-exec-9] o.s.security.web.FilterChainProxy        : Invoking WebAsyncManagerIntegrationFilter (2/17)
2023-05-20 09:37:38.560 TRACE 6632 --- [nio-8080-exec-9] o.s.security.web.FilterChainProxy        : Invoking SecurityContextPersistenceFilter (3/17)
2023-05-20 09:37:38.561 TRACE 6632 --- [nio-8080-exec-9] w.c.HttpSessionSecurityContextRepository : No HttpSession currently exists
2023-05-20 09:37:38.561 TRACE 6632 --- [nio-8080-exec-9] w.c.HttpSessionSecurityContextRepository : Created SecurityContextImpl [Null authentication]
2023-05-20 09:37:38.562 DEBUG 6632 --- [nio-8080-exec-9] s.s.w.c.SecurityContextPersistenceFilter : Set SecurityContextHolder to empty SecurityContext
2023-05-20 09:37:38.562 TRACE 6632 --- [nio-8080-exec-9] o.s.security.web.FilterChainProxy        : Invoking HeaderWriterFilter (4/17)
2023-05-20 09:37:38.562 TRACE 6632 --- [nio-8080-exec-9] o.s.security.web.FilterChainProxy        : Invoking CorsFilter (5/17)
2023-05-20 09:37:38.566 TRACE 6632 --- [nio-8080-exec-9] o.s.security.web.FilterChainProxy        : Invoking CsrfFilter (6/17)
2023-05-20 09:37:38.567 TRACE 6632 --- [nio-8080-exec-9] o.s.security.web.csrf.CsrfFilter         : Did not protect against CSRF since request did not match CsrfNotRequired [TRACE, HEAD, GET, OPTIONS]
2023-05-20 09:37:38.568 TRACE 6632 --- [nio-8080-exec-9] o.s.security.web.FilterChainProxy        : Invoking LogoutFilter (7/17)
2023-05-20 09:37:38.571 TRACE 6632 --- [nio-8080-exec-9] o.s.s.w.a.logout.LogoutFilter            : Did not match request to Ant [pattern='/logout', POST]        
2023-05-20 09:37:38.573 TRACE 6632 --- [nio-8080-exec-9] o.s.security.web.FilterChainProxy        : Invoking UsernamePasswordAuthenticationFilter (8/17)
2023-05-20 09:37:38.574 TRACE 6632 --- [nio-8080-exec-9] w.a.UsernamePasswordAuthenticationFilter : Did not match request to Ant [pattern='/login', POST]
2023-05-20 09:37:38.576 TRACE 6632 --- [nio-8080-exec-9] o.s.security.web.FilterChainProxy        : Invoking DefaultLoginPageGeneratingFilter (9/17)
2023-05-20 09:37:38.578 TRACE 6632 --- [nio-8080-exec-9] o.s.security.web.FilterChainProxy        : Invoking DefaultLogoutPageGeneratingFilter (10/17)
2023-05-20 09:37:38.582 TRACE 6632 --- [nio-8080-exec-9] .w.a.u.DefaultLogoutPageGeneratingFilter : Did not render default logout page since request did not match [Ant [pattern='/logout', GET]]
2023-05-20 09:37:38.583 TRACE 6632 --- [nio-8080-exec-9] o.s.security.web.FilterChainProxy        : Invoking BasicAuthenticationFilter (11/17)
2023-05-20 09:37:38.584 TRACE 6632 --- [nio-8080-exec-9] o.s.s.w.a.www.BasicAuthenticationFilter  : Did not process authentication request since failed to find username and password in Basic Authorization header
2023-05-20 09:37:38.587 TRACE 6632 --- [nio-8080-exec-9] o.s.security.web.FilterChainProxy        : Invoking RequestCacheAwareFilter (12/17)
2023-05-20 09:37:38.588 TRACE 6632 --- [nio-8080-exec-9] o.s.s.w.s.HttpSessionRequestCache        : No saved request
2023-05-20 09:37:38.590 TRACE 6632 --- [nio-8080-exec-9] o.s.security.web.FilterChainProxy        : Invoking SecurityContextHolderAwareRequestFilter (13/17)      
2023-05-20 09:37:38.591 TRACE 6632 --- [nio-8080-exec-9] o.s.security.web.FilterChainProxy        : Invoking AnonymousAuthenticationFilter (14/17)
2023-05-20 09:37:38.592 TRACE 6632 --- [nio-8080-exec-9] o.s.s.w.a.AnonymousAuthenticationFilter  : Set SecurityContextHolder to AnonymousAuthenticationToken [Principal=anonymousUser, Credentials=[PROTECTED], Authenticated=true, Details=WebAuthenticationDetails [RemoteIpAddress=127.0.0.1, SessionId=null], Granted Authorities=[ROLE_ANONYMOUS]]
2023-05-20 09:37:38.593 TRACE 6632 --- [nio-8080-exec-9] o.s.security.web.FilterChainProxy        : Invoking SessionManagementFilter (15/17)
2023-05-20 09:37:38.593 TRACE 6632 --- [nio-8080-exec-9] o.s.security.web.FilterChainProxy        : Invoking ExceptionTranslationFilter (16/17)
2023-05-20 09:37:38.594 TRACE 6632 --- [nio-8080-exec-9] o.s.security.web.FilterChainProxy        : Invoking FilterSecurityInterceptor (17/17)
2023-05-20 09:37:38.596 TRACE 6632 --- [nio-8080-exec-9] edFilterInvocationSecurityMetadataSource : Did not match request to EndpointRequestMatcher includes=[health], excludes=[], includeLinks=false - [permitAll] (1/2)
2023-05-20 09:37:38.610 TRACE 6632 --- [nio-8080-exec-9] o.s.s.w.a.i.FilterSecurityInterceptor    : Did not re-authenticate AnonymousAuthenticationToken [Principal=anonymousUser, Credentials=[PROTECTED], Authenticated=true, Details=WebAuthenticationDetails [RemoteIpAddress=127.0.0.1, SessionId=null], Granted Authorities=[ROLE_ANONYMOUS]] before authorizing
2023-05-20 09:37:38.619 TRACE 6632 --- [nio-8080-exec-9] o.s.s.w.a.i.FilterSecurityInterceptor    : Authorizing filter invocation [GET /hello] with attributes [authenticated]
2023-05-20 09:37:38.626 TRACE 6632 --- [nio-8080-exec-9] o.s.s.w.a.expression.WebExpressionVoter  : Voted to deny authorization
2023-05-20 09:37:38.632 TRACE 6632 --- [nio-8080-exec-9] o.s.s.w.a.i.FilterSecurityInterceptor    : Failed to authorize filter invocation [GET /hello] with attributes [authenticated] using AffirmativeBased [DecisionVoters=[org.springframework.security.web.access.expression.WebExpressionVoter@f613067], AllowIfAllAbstainDecisions=false]
2023-05-20 09:37:38.640 TRACE 6632 --- [nio-8080-exec-9] o.s.s.w.a.ExceptionTranslationFilter     : Sending AnonymousAuthenticationToken [Principal=anonymousUser, Credentials=[PROTECTED], Authenticated=true, Details=WebAuthenticationDetails [RemoteIpAddress=127.0.0.1, SessionId=null], Granted Authorities=[ROLE_ANONYMOUS]] 
to authentication entry point since access is denied

org.springframework.security.access.AccessDeniedException: Access is denied
        at org.springframework.security.access.vote.AffirmativeBased.decide(AffirmativeBased.java:73) ~[spring-security-core-5.7.8.jar:5.7.8]

2023-05-20 09:37:38.691 DEBUG 6632 --- [nio-8080-exec-9] o.s.s.w.s.HttpSessionRequestCache        : Saved request http://localhost:8080/hello to session
2023-05-20 09:37:38.693 DEBUG 6632 --- [nio-8080-exec-9] s.w.a.DelegatingAuthenticationEntryPoint : Trying to match using And [Not [RequestHeaderRequestMatcher [expectedHeaderName=X-Requested-With, expectedHeaderValue=XMLHttpRequest]], MediaTypeRequestMatcher [contentNegotiationStrategy=org.springframework.web.accept.HeaderContentNegotiationStrategy@4b95451, matchingMediaTypes=[application/xhtml+xml, image/*, text/html, text/plain], useEquals=false, ignoredMediaTypes=[*/*]]]       
2023-05-20 09:37:38.701 DEBUG 6632 --- [nio-8080-exec-9] s.w.a.DelegatingAuthenticationEntryPoint : Match found! Executing org.springframework.security.web.authentication.LoginUrlAuthenticationEntryPoint@168ad26f
2023-05-20 09:37:38.709 DEBUG 6632 --- [nio-8080-exec-9] o.s.s.web.DefaultRedirectStrategy        : Redirecting to http://localhost:8080/login
2023-05-20 09:37:38.712 TRACE 6632 --- [nio-8080-exec-9] o.s.s.w.header.writers.HstsHeaderWriter  : Not injecting HSTS header since it did not match request to [Is Secure]
2023-05-20 09:37:38.720 DEBUG 6632 --- [nio-8080-exec-9] w.c.HttpSessionSecurityContextRepository : Did not store empty SecurityContext
2023-05-20 09:37:38.730 DEBUG 6632 --- [nio-8080-exec-9] w.c.HttpSessionSecurityContextRepository : Did not store empty SecurityContext
2023-05-20 09:37:38.731 DEBUG 6632 --- [nio-8080-exec-9] s.s.w.c.SecurityContextPersistenceFilter : Cleared SecurityContextHolder to complete request

这个过程中有两点比较重要:第一点是经过 AnonymousAuthenticationFilter 时,将当前用户设置为 anonymousUser,角色为 ROLE_ANONYMOUS;第二点是经过 FilterSecurityInterceptor 时,校验当前用户是否有访问 /hello 页面的权限,在上面的 defaultSecurityFilterChain 中,可以看到 http.authorizeRequests().anyRequest().authenticated() 这样的代码,这说明 Spring Security 默认对所有的页面都开启了鉴权,所以会抛出 AccessDeniedException 异常,而这个异常被 ExceptionTranslationFilter 拦截,并将这个异常交给 LoginUrlAuthenticationEntryPoint 处理,从而重定向到 /login 页面,整个过程的示意图如下:

redirect-login.png

接下来,浏览器开始访问重定向后的 /login 页面,这时请求又会再一次经历一系列的 Security Filters,和上面的 /hello 请求不一样的是,/login 请求经过 DefaultLoginPageGeneratingFilter 时,会生成上面我们看到的登录页面并结束整个调用链:

public class DefaultLoginPageGeneratingFilter extends GenericFilterBean {

    private void doFilter(HttpServletRequest request, HttpServletResponse response, FilterChain chain)
            throws IOException, ServletException {
        boolean loginError = isErrorPage(request);
        boolean logoutSuccess = isLogoutSuccess(request);
        if (isLoginUrlRequest(request) || loginError || logoutSuccess) {
            String loginPageHtml = generateLoginPageHtml(request, loginError, logoutSuccess);
            response.setContentType("text/html;charset=UTF-8");
            response.setContentLength(loginPageHtml.getBytes(StandardCharsets.UTF_8).length);
            response.getWriter().write(loginPageHtml);
            return;
        }
        chain.doFilter(request, response);
    }
}

AuthenticationManager:剖析认证流程

接下来,输入用户名和密码并提交,请求会再一次经历 Security Filters,这一次,请求在 UsernamePasswordAuthenticationFilter 这里被拦截下来,并开始了用户名和密码的认证过程:

public class UsernamePasswordAuthenticationFilter extends AbstractAuthenticationProcessingFilter {

    @Override
    public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response)
            throws AuthenticationException {
        if (this.postOnly && !request.getMethod().equals("POST")) {
            throw new AuthenticationServiceException("Authentication method not supported: " + request.getMethod());
        }
        String username = obtainUsername(request);
        username = (username != null) ? username.trim() : "";
        String password = obtainPassword(request);
        password = (password != null) ? password : "";
        UsernamePasswordAuthenticationToken authRequest = UsernamePasswordAuthenticationToken.unauthenticated(username,
                password);
        // Allow subclasses to set the "details" property
        setDetails(request, authRequest);
        return this.getAuthenticationManager().authenticate(authRequest);
    }
}

这里将遇到 Spring Security 中处理认证的核心接口:AuthenticationManager

public interface AuthenticationManager {

    Authentication authenticate(Authentication authentication) throws AuthenticationException;
}

这个接口只有一个 authenticate() 方法,它的入参是一个未认证的 Authentication,从 UsernamePasswordAuthenticationFilter 的代码中可以看到使用了 UsernamePasswordAuthenticationToken,它的返回有三种情况:

  • 如果认证成功,则返回认证成功后的 Authentication(通常带有 authenticated=true);
  • 如果认证失败,则抛出 AuthenticationException 异常;
  • 如果无法判断,则返回 null

AuthenticationManager 接口最常用的一个实现是 ProviderManager 类,它包含了一个或多个 AuthenticationProvider 实例:

public class ProviderManager implements AuthenticationManager {

    private List<AuthenticationProvider> providers;
}

AuthenticationProvider 有点像 AuthenticationManager,但它有一个额外的方法 boolean supports(Class<?> authentication)

public interface AuthenticationProvider {

    Authentication authenticate(Authentication authentication) throws AuthenticationException;

    boolean supports(Class<?> authentication);
}

Spring Security 会遍历列表中所有的 AuthenticationProvider,并通过 supports() 方法来选取合适的 AuthenticationProvider 实例来实现认证,从上文中我们知道,UsernamePasswordAuthenticationFilter 在认证时使用的 Authentication 类型为 UsernamePasswordAuthenticationToken,对于这个 Authentication,默认使用的 AuthenticationProviderDaoAuthenticationProvider,它继承自抽象类 AbstractUserDetailsAuthenticationProvider

public abstract class AbstractUserDetailsAuthenticationProvider
        implements AuthenticationProvider, InitializingBean, MessageSourceAware {

    @Override
    public Authentication authenticate(Authentication authentication) throws AuthenticationException {

        String username = determineUsername(authentication);
        UserDetails user = retrieveUser(username, (UsernamePasswordAuthenticationToken) authentication);
        
        this.preAuthenticationChecks.check(user);
        additionalAuthenticationChecks(user, (UsernamePasswordAuthenticationToken) authentication);
        this.postAuthenticationChecks.check(user);
        
        Object principalToReturn = user;
        if (this.forcePrincipalAsString) {
            principalToReturn = user.getUsername();
        }
        return createSuccessAuthentication(principalToReturn, authentication, user);
    }
}

其中,最关键的代码有两行,第一行是通过 retrieveUser() 方法获取 UserDetails

public class DaoAuthenticationProvider extends AbstractUserDetailsAuthenticationProvider {

    @Override
    protected final UserDetails retrieveUser(String username, UsernamePasswordAuthenticationToken authentication)
            throws AuthenticationException {
        
        UserDetails loadedUser = this.getUserDetailsService().loadUserByUsername(username);
        return loadedUser;
    }
}

进入 retrieveUser() 方法内部,可以看到它是通过 UserDetailsServiceloadUserByUsername() 方法来获取 UserDetails 的,而这个 UserDetailsService 默认实现是 InMemoryUserDetailsManager

public class InMemoryUserDetailsManager implements UserDetailsManager, UserDetailsPasswordService {

    @Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
        UserDetails user = this.users.get(username.toLowerCase());
        if (user == null) {
            throw new UsernameNotFoundException(username);
        }
        return new User(user.getUsername(), user.getPassword(), user.isEnabled(), user.isAccountNonExpired(),
                user.isCredentialsNonExpired(), user.isAccountNonLocked(), user.getAuthorities());
    }
}

它的实现非常简单,就是从 users 这个 Map 中直接获取 UserDetails,那么 users 这个 Map 又是从哪来的呢? 答案就是我们在配置文件中配置的 spring.security.user,我们可以从自动配置类 UserDetailsServiceAutoConfiguration 中找到 InMemoryUserDetailsManager 的初始化代码:

public class UserDetailsServiceAutoConfiguration {

    @Bean
    @Lazy
    public InMemoryUserDetailsManager inMemoryUserDetailsManager(SecurityProperties properties,
            ObjectProvider<PasswordEncoder> passwordEncoder) {
        SecurityProperties.User user = properties.getUser();
        List<String> roles = user.getRoles();
        return new InMemoryUserDetailsManager(User.withUsername(user.getName())
            .password(getOrDeducePassword(user, passwordEncoder.getIfAvailable()))
            .roles(StringUtils.toStringArray(roles))
            .build());
    }
}

另一行关键代码是通过 additionalAuthenticationChecks() 方法对 UserDetailsUsernamePasswordAuthenticationToken 进行校验,一般来说,就是验证密码是否正确:

public class DaoAuthenticationProvider extends AbstractUserDetailsAuthenticationProvider {

    @Override
    protected void additionalAuthenticationChecks(UserDetails userDetails,
            UsernamePasswordAuthenticationToken authentication) throws AuthenticationException {
        
        String presentedPassword = authentication.getCredentials().toString();
        if (!this.passwordEncoder.matches(presentedPassword, userDetails.getPassword())) {
            this.logger.debug("Failed to authenticate since password does not match stored value");
            throw new BadCredentialsException(this.messages
                    .getMessage("AbstractUserDetailsAuthenticationProvider.badCredentials", "Bad credentials"));
        }
    }
}

一旦用户名和密码都验证通过,就调用 createSuccessAuthentication() 方法创建并返回一个认证成功后的 Authentication,然后经过一系列的后处理,整个认证的流程如下所示:

usernamepasswordauthenticationfilter.png

其中,SecurityContextHolder 将认证成功后的 Authentication 保存到安全上下文中供后续 Filter 使用;AuthenticationSuccessHandler 用于定义一些认证成功后的自定义逻辑,默认实现为 SimpleUrlAuthenticationSuccessHandler,它返回一个重定向,将浏览器转到登录之前用户访问的页面。

在我的测试中,SimpleUrlAuthenticationSuccessHandler 貌似并没有触发,新版本的逻辑有变动?

AccessDecisionManager:剖析授权流程

其实,在上面分析重定向 /login 页面的流程时已经大致了解了实现授权的逻辑,请求经过 FilterSecurityInterceptor 时,校验当前用户是否有访问页面的权限,如果没有,则会抛出 AccessDeniedException 异常。FilterSecurityInterceptor 的核心代码如下:

public class FilterSecurityInterceptor extends AbstractSecurityInterceptor implements Filter {
    
    public void invoke(FilterInvocation filterInvocation) throws IOException, ServletException {

        InterceptorStatusToken token = super.beforeInvocation(filterInvocation);
        try {
            filterInvocation.getChain().doFilter(filterInvocation.getRequest(), filterInvocation.getResponse());
        }
        finally {
            super.finallyInvocation(token);
        }
        super.afterInvocation(token, null);
    }
}

可以看到,主要逻辑就包含在 beforeInvocation()finallyInvocation()afterInvocation() 这三个方法中,而对授权相关的部分则位于 beforeInvocation() 方法中:

public abstract class AbstractSecurityInterceptor
        implements InitializingBean, ApplicationEventPublisherAware, MessageSourceAware {

    protected InterceptorStatusToken beforeInvocation(Object object) {
        
        Collection<ConfigAttribute> attributes = this.obtainSecurityMetadataSource().getAttributes(object);
        
        Authentication authenticated = authenticateIfRequired();
        
        // Attempt authorization
        attemptAuthorization(object, attributes, authenticated);
        
        if (this.publishAuthorizationSuccess) {
            publishEvent(new AuthorizedEvent(object, attributes, authenticated));
        }

        return new InterceptorStatusToken(SecurityContextHolder.getContext(), false, attributes, object);
    }

    private void attemptAuthorization(Object object, Collection<ConfigAttribute> attributes,
            Authentication authenticated) {
        try {
            this.accessDecisionManager.decide(authenticated, object, attributes);
        }
        catch (AccessDeniedException ex) {
            publishEvent(new AuthorizationFailureEvent(object, attributes, authenticated, ex));
            throw ex;
        }
    }
}

在这里,我们遇到了 Spring Security 实现授权的核心接口:AccessDecisionManager,Spring Security 就是通过该接口的 decide() 方法来决定用户是否有访问某个资源的权限。AccessDecisionManager 接口的默认实现为 AffirmativeBased,可以从 AbstractInterceptUrlConfigurer 中找到它的踪影:

public abstract class AbstractInterceptUrlConfigurer<C extends AbstractInterceptUrlConfigurer<C, H>, H extends HttpSecurityBuilder<H>>
        extends AbstractHttpConfigurer<C, H> {
    
    private AccessDecisionManager createDefaultAccessDecisionManager(H http) {
        AffirmativeBased result = new AffirmativeBased(getDecisionVoters(http));
        return postProcess(result);
    }
}

AffirmativeBased 实例中包含一个或多个 AccessDecisionVoter,它通过遍历所有的 AccessDecisionVoter 依次投票决定授权是否允许,只要有一个 AccessDecisionVoter 拒绝,则抛出 AccessDeniedException 异常:

public class AffirmativeBased extends AbstractAccessDecisionManager {

    @Override
    @SuppressWarnings({ "rawtypes", "unchecked" })
    public void decide(Authentication authentication, Object object, Collection<ConfigAttribute> configAttributes)
            throws AccessDeniedException {
        int deny = 0;
        for (AccessDecisionVoter voter : getDecisionVoters()) {
            int result = voter.vote(authentication, object, configAttributes);
            switch (result) {
            case AccessDecisionVoter.ACCESS_GRANTED:
                return;
            case AccessDecisionVoter.ACCESS_DENIED:
                deny++;
                break;
            default:
                break;
            }
        }
        if (deny > 0) {
            throw new AccessDeniedException(
                    this.messages.getMessage("AbstractAccessDecisionManager.accessDenied", "Access is denied"));
        }
        // To get this far, every AccessDecisionVoter abstained
        checkAllowIfAllAbstainDecisions();
    }
}

默认情况下,AffirmativeBased 实例中只有一个 AccessDecisionVoter,那就是 WebExpressionVoter

public class WebExpressionVoter implements AccessDecisionVoter<FilterInvocation> {

    @Override
    public int vote(Authentication authentication, FilterInvocation filterInvocation,
            Collection<ConfigAttribute> attributes) {
        
        WebExpressionConfigAttribute webExpressionConfigAttribute = findConfigAttribute(attributes);
        
        EvaluationContext ctx = webExpressionConfigAttribute.postProcess(
                this.expressionHandler.createEvaluationContext(authentication, filterInvocation), filterInvocation);

        boolean granted = ExpressionUtils.evaluateAsBoolean(webExpressionConfigAttribute.getAuthorizeExpression(), ctx);
        if (granted) {
            return ACCESS_GRANTED;
        }
        return ACCESS_DENIED;
    }
}

WebExpressionVoter 将授权转换为 SpEL 表达式,检查授权是否通过,就是看执行 SpEL 表达式的结果是否为 true,这里的细节还有很多,详细内容还是参考 官方文档 吧。

参考

更多

Spring Security 的安全防护

Spring Security 自定义配置

Spring Security 单元测试

扫描二维码,在手机上阅读!

gRPC 快速入门

RPC 又被称为 远程过程调用,英文全称为 Remote Procedure Call,是一种服务间的通信规范,它可以让你像调用本地方法一样调用远程服务提供的方法,而不需要关心底层的通信细节。RPC 的概念早在上个世纪七八十年代就已经被提出,1984 年,Birrell 和 Nelson 在 ACM Transactions on Computer Systems 期刊上发表了一篇关于 RPC 的经典论文 Implementing remote procedure calls,论文中首次给出了实现 RPC 的基本框架:

rpc.png

从这个框架中可以看到很多现代 RPC 框架的影子,比如客户端和服务端的 Stub、序列化和反序列化等,事实上,所有后来的 RPC 框架几乎都是源自于这个原型。

不过在那个年代,RPC 的争议是非常大的,由于网络环境的不可靠性,RPC 永远都不可能做到像调用本地方法一样。大家提出了一堆问题,比如:故障恢复、请求重试、异步请求、服务寻址等,在那个互联网都还没有出现的年代,一堆大神们就已经在讨论分布式系统间的调用问题了,而他们讨论的问题焦点,基本上都演变成了 RPC 历史中永恒的话题。

为了解决这些问题,软件架构经历了一代又一代的发展和演进。1988 年,Sun 公司推出了第一个商业化的 RPC 库 Sun RPC ,并被定义为标准的 RPC 规范;1991 年,非营利性组织 OMG 发布 CORBA,它通过接口定义语言 IDL 中的抽象类型映射让异构环境之间的互操作成为了可能;不过由于其复杂性,很快就被微软推出的基于 XML 的 SOAP 技术所打败,随后 SOAP 作为 W3C 标准大大推动了 Web Service 概念的发展;像 SOAP 这种基于 XML 的 RPC 技术被称为 XML-RPC,它最大的问题是 XML 报文内容过于冗余,对 XML 的解析效率也很低,于是 JSON 应运而生,进而导致 RESTful 的盛行;不过无论是 XML 还是 JSON,都是基于文本传输,性能都无法让人满意,直到 2008 年,Google 开源 Protocol Buffers,这是一种高效的结构化数据存储格式,可以用于结构化数据的序列化,非常适合做数据存储或 RPC 数据交换格式;可能是由于微服务的流行,之后的 RPC 框架如雨后春笋般蓬勃发展,同年,Facebook 向 Apache 贡献了开源项目 Thrift,2009 年,Hadoop 之父 Doug Cutting 开发出 Avro,成为 Hadoop 的一个子项目,随后又脱离 Hadoop 成为 Apache 顶级项目;2011 年,阿里也开源了它自研的 RPC 框架 Dubbo,和前两个一样,最后也贡献给了 Apache;2015 年,Google 开源 gRPC 框架,开创性地使用 HTTP/2 作为传输协议,基于 HTTP/2 的多路复用和服务端推送技术,gRPC 支持双向流式通信,这使得 RPC 框架终于不再拘泥于万年不变的 C/S 模型了。

2017 年,gRPC 作为孵化项目成为 CNCF 的一员,不论是 Envoy 还是 Istio 等 Service Mesh 方案,都将 gRPC 作为一等公民,可以预见的是,谷歌正在将 gRPC 打造成云原生时代通信层事实上的标准。

Hello World 开始

这一节我们使用 Go 语言实现一个简单的 Hello World 服务,学习 gRPC 的基本概念。首先,我们通过 go mod init 初始化示例项目:

$ mkdir demo && cd demo
$ go mod init example.com/demo
go: creating new go.mod: module example.com/demo
go: to add module requirements and sums:
        go mod tidy

然后获取 grpc 依赖:

$ go get google.golang.org/grpc@latest
go: downloading golang.org/x/net v0.5.0
go: downloading golang.org/x/sys v0.4.0
go: downloading google.golang.org/genproto v0.0.0-20230110181048-76db0878b65f
go: downloading golang.org/x/text v0.6.0
go: added github.com/golang/protobuf v1.5.2
go: added golang.org/x/net v0.5.0
go: added golang.org/x/sys v0.4.0
go: added golang.org/x/text v0.6.0
go: added google.golang.org/genproto v0.0.0-20230110181048-76db0878b65f
go: added google.golang.org/grpc v1.53.0
go: added google.golang.org/protobuf v1.28.1

编写 .proto 文件

正如前文所述,Google 在 2009 年开源了一种高效的结构化数据存储格式 Protocol Buffers,这种格式非常适合用于 RPC 的数据交换,所以顺理成章的,Google 在开发 gRPC 时就采用了 Protocol Buffers 作为默认的数据格式。不过要注意的是 Protocol Buffers 不仅仅是一种数据格式,而且也是一种 IDL(Interface Description Language,接口描述语言),它通过一种中立的方式来描述接口和数据类型,从而实现跨语言和跨平台开发。

一般使用 .proto 后缀的文件来定义接口和数据类型,所以接下来,我们要创建一个 hello.proto 文件,我们将其放在 proto 目录下:

$ mkdir proto && cd proto
$ vim hello.proto

文件内容如下:

syntax = "proto3";

option go_package = "example.com/demo/proto";

service HelloService {
  rpc SayHello (HelloRequest) returns (HelloResponse) {}
}

message HelloRequest {
  string name = 1;
}

message HelloResponse {
  string message = 1;
}

我们在第一行指定使用 proto3 语法,这是目前推荐的版本,如果不指定,默认将使用 proto2,可能会导致一些版本兼容性的问题。随后我们用关键字 service 定义了一个 HelloService 服务,该服务包含一个 SayHello 方法,方法的入参为 HelloRequest,出参为 HelloResponse,这两个消息类型都在后面通过关键字 message 所定义。Protocol Buffers 的语法非常直观,也比较容易理解,这里只是使用了一些简单的语法,其他更复杂的语法可以参考 Protocol Buffers 的官方文档,另外这里有一份 中文语法指南 也可供参考。

编写好 hello.proto 文件之后,我们还需要一些工具将其转换为 Go 语言。这些工具包括:

  • protoc
  • protoc-gen-go
  • protoc-gen-go-grpc

protoc 是 Protocol Buffers 编译器,用于将 .proto 文件转换为其他编程语言,而不同语言的转换工作由不同语言的插件来实现。Go 语言的插件有两个:protoc-gen-goprotoc-gen-go-grpc,插件 protoc-gen-go 会生成一个后缀为 .pb.go 的文件,其中包含 .proto 文件中定义数据类型和其序列化方法;插件 protoc-gen-go-grpc 会生成一个后缀为 _grpc.pb.go 的文件,其中包含供客户端调用的服务方法和服务端要实现的接口类型。

protoc 可以从 Protocol Buffers 的 Release 页面 下载,下载后将 bin 目录添加到 PATH 环境变量即可:

$ curl -LO https://github.com/protocolbuffers/protobuf/releases/download/v22.0/protoc-22.0-linux-x86_64.zip

protoc-gen-goprotoc-gen-go-grpc 两个插件可以通过 go install 命令直接安装:

$ go install google.golang.org/protobuf/cmd/protoc-gen-go@v1.28.1
$ go install google.golang.org/grpc/cmd/protoc-gen-go-grpc@v1.2.0

安装完成后使用 --version 参数检测各个命令是否正常:

$ protoc --version
libprotoc 22.0

$ protoc-gen-go --version
protoc-gen-go v1.28.1

$ protoc-gen-go-grpc --version
protoc-gen-go-grpc 1.2.0

一切就绪后,就可以使用下面这行命令生成相应的 Go 代码了:

$ cd proto
$ protoc --go_out=. --go_opt=paths=source_relative \
    --go-grpc_out=. --go-grpc_opt=paths=source_relative \
    hello.proto

这个命令在当前目录下生成了 hello.pb.gohello_grpc.pb.go 两个文件。

实现服务端

在生成的 hello_grpc.pb.go 文件中,定义了一个 HelloServiceServer 接口:

// HelloServiceServer is the server API for HelloService service.
// All implementations must embed UnimplementedHelloServiceServer
// for forward compatibility
type HelloServiceServer interface {
    SayHello(context.Context, *HelloRequest) (*HelloResponse, error)
    mustEmbedUnimplementedHelloServiceServer()
}

并且在接口的下面提供了一个默认实现:

type UnimplementedHelloServiceServer struct {
}

func (UnimplementedHelloServiceServer) SayHello(context.Context, *HelloRequest) (*HelloResponse, error) {
    return nil, status.Errorf(codes.Unimplemented, "method SayHello not implemented")
}
func (UnimplementedHelloServiceServer) mustEmbedUnimplementedHelloServiceServer() {}

注意看 HelloServiceServer 的上面有一行注释:All implementations must embed UnimplementedHelloServiceServer for forward compatibility,为了保证向前兼容性,我们自己在实现这个接口时必须要嵌入 UnimplementedHelloServiceServer 这个默认实现,这篇文章 对此有一个简单的说明。

接下来我们创建一个 server 目录,并创建一个 main.go 文件:

$ mkdir server && cd server
$ vim main.go

定义 server 结构体,继承 UnimplementedHelloServiceServer 并重写 SayHello 方法:

type server struct {
    proto.UnimplementedHelloServiceServer
}

func (s *server) SayHello(ctx context.Context, request *proto.HelloRequest) (*proto.HelloResponse, error) {
    log.Printf("Request recieved: %v\n", request.GetName())
    return &proto.HelloResponse{
        Message: "Hello " + request.GetName(),
    }, nil
}

然后在入口方法中,通过 proto.RegisterHelloServiceServer(s, &server{}) 将我们的实现注册到 grpc Server 中:

func main() {

    lis, err := net.Listen("tcp", ":8080")
    if err != nil {
        log.Fatalf("Server listen failed!")
    }
    log.Printf("Server listening at: %s", lis.Addr())

    s := grpc.NewServer()
    proto.RegisterHelloServiceServer(s, &server{})
    if err := s.Serve(lis); err != nil {
        log.Fatalf("Server serve failed!")
    }
}

使用 go run 运行该代码:

$ go run ./server/main.go
2023/03/02 07:40:50 Server listening at: [::]:8080

一个 gRPC 的服务端就启动成功了!

实现客户端

接下来,我们来实现客户端。其实,在 hello_grpc.pb.go 文件中,protoc 也为我们定义了一个 HelloServiceClient 接口:

type HelloServiceClient interface {
    SayHello(ctx context.Context, in *HelloRequest, opts ...grpc.CallOption) (*HelloResponse, error)
}

并提供了该接口的默认实现:

type helloServiceClient struct {
    cc grpc.ClientConnInterface
}

func NewHelloServiceClient(cc grpc.ClientConnInterface) HelloServiceClient {
    return &helloServiceClient{cc}
}

func (c *helloServiceClient) SayHello(ctx context.Context, in *HelloRequest, opts ...grpc.CallOption) (*HelloResponse, error) {
    out := new(HelloResponse)
    err := c.cc.Invoke(ctx, "/HelloService/SayHello", in, out, opts...)
    if err != nil {
        return nil, err
    }
    return out, nil
}

HelloServiceServer 不同的是,这个客户端实现我们无需修改,可以直接使用。首先我们创建一个 client 目录,并创建一个 main.go 文件:

$ mkdir client && cd client
$ vim main.go

然后在入口方法中,通过 grpc.Dial 创建一个和服务端的连接:

conn, err := grpc.Dial("localhost:8080", grpc.WithTransportCredentials(insecure.NewCredentials()))
if err != nil {
    log.Fatalf("Connect grpc server failed: %v", err)
}
defer conn.Close()

注意我们的服务端没有开启 TLS,连接是不安全的,所以我们需要加一个不安全证书的连接选项,否则连接的时候会报错:

Connect grpc server failed: grpc: no transport security set (use grpc.WithTransportCredentials(insecure.NewCredentials()) explicitly or set credentials)

然后使用 hello_grpc.pb.go 文件中提供的 NewHelloServiceClient 方法创建一个客户端实例:

c := proto.NewHelloServiceClient(conn)

同时使用 context 创建一个带超时的上下文:

ctx, cancel := context.WithTimeout(context.Background(), time.Second)
defer cancel()

使用创建的客户端调用 SayHello 方法:

r, err := c.SayHello(ctx, &proto.HelloRequest{Name: "zhangsan"})
if err != nil {
    log.Fatalf("Call SayHello failed: %v", err)
}
log.Printf("SayHello response: %s", r.GetMessage())

从调用的代码上看起来,确实和调用本地方法一样,传入 HelloRequest 请求,得到 HelloResponse 响应。至此,一个简单的客户端就编写完成了,使用 go run 运行该代码:

$ go run ./client/main.go
2023/03/03 07:03:34 SayHello response: Hello zhangsan

测试服务端

除了编写客户端,我们也可以使用其他的工具来测试服务端,对于 HTTP 服务,我们一般使用 curlPostman 之类的工具;而对于 gRPC 服务,也有类似的工具,比如 grpcurlgrpcui 等,这里整理了一份 关于 gRPC 工具的清单

这里使用 grpcurl 来对我们的服务端进行简单的测试。首先从它的 Release 页面 下载并安装 grpcurl

$ curl -LO https://github.com/fullstorydev/grpcurl/releases/download/v1.8.7/grpcurl_1.8.7_linux_x86_64.tar.gz

grpcurl 中最常使用的是 list 子命令,它可以列出服务端支持的所有服务:

$ grpcurl -plaintext localhost:8080 list
Failed to list services: server does not support the reflection API

不过这要求我们的服务端必须开启 反射 API,打开 server/main.go 文件,在其中加上下面这行代码:

reflection.Register(s)

这样我们就通过 Go 语言中提供的 reflection 包开启了反射 API,然后使用 grpcurllist 命令重新列出服务端的所有服务:

$ grpcurl -plaintext localhost:8080 list
HelloService
grpc.reflection.v1alpha.ServerReflection

如果服务端没有开启反射 API,grpc 也支持直接使用 Proto 文件Protoset 文件

我们还可以使用 list 命令继续列出 HelloService 服务的所有方法:

$ grpcurl -plaintext localhost:8080 list HelloService
HelloService.SayHello

如果要查看某个方法的详细信息,可以使用 describe 命令:

$ grpcurl -plaintext localhost:8080 describe HelloService.SayHello
HelloService.SayHello is a method:
rpc SayHello ( .HelloRequest ) returns ( .HelloResponse );

可以看出,这和我们在 proto 文件中的定义是一致的。最后,使用 grpcurl 来调用这个方法:

$ grpcurl -plaintext -d '{"name": "zhangsan"}' localhost:8080 HelloService.SayHello
{
  "message": "Hello zhangsan"
}

如果入参比较大,可以将其保存在一个文件中,使用下面的方法来调用:

$ cat input.json | grpcurl -plaintext -d @ localhost:8080 HelloService.SayHello
{
  "message": "Hello zhangsan"
}

gRPC 的四种形式

gRPC 支持四种不同的通信方式:

  • 简单 RPC(Simple RPC
  • 服务端流 RPC(Server-side streaming RPC
  • 客户端流 RPC(Client-side streaming RPC
  • 双向流 RPC(Bidirectional streaming RPC

上一节中的 SayHello 就是一个简单 RPC 的例子:

rpc SayHello (HelloRequest) returns (HelloResponse) {}

这种 RPC 有时候也被称为 Unary RPC,除此之外,gRPC 还支持三种流式通信方法,也即 Streaming RPC

服务端流 RPC(Server-side streaming RPC

第一种叫服务端流 RPC,它接受一个正常的请求,并以流的形式向客户端发送多个响应。在下面的例子中,客户端向服务端发送一个字符串,服务端对字符串进行分词,并将分词结果以流式返回给客户端。首先,我们在 .proto 文件中定义 Split 方法和相应的消息体:

rpc Split (SplitRequest) returns (stream SplitResponse) {}

然后,使用 protoc 生成服务端和客户端的代码,接着在 server/main.go 文件中添加服务端实现:

func (s *server) Split(request *proto.SplitRequest, stream proto.HelloService_SplitServer) error {
    log.Printf("Request recieved: %v\n", request.GetSentence())
    words := strings.Split(request.GetSentence(), " ")
    for _, word := range words {
        if err := stream.Send(&proto.SplitResponse{Word: word}); err != nil {
            return err
        }
    }
    return nil
}

和简单 RPC 的 SayHello 方法相比,服务端流 RPC 的 Split 方法在参数上有一些细微的差别,少了一个 ctx context.Context 参数,而多了一个 stream proto.HelloService_SplitServer 参数,这是 protoc 自动生成的一个接口:

type HelloService_SplitServer interface {
    Send(*SplitResponse) error
    grpc.ServerStream
}

这个接口继承自 grpc.ServerStream 接口,并具有一个 Send 方法,用来向客户端发送响应。

client/main.go 文件中添加客户端实现:

stream, err := c.Split(ctx, &proto.SplitRequest{Sentence: "Hello World"})
if err != nil {
    log.Fatalf("Call Split failed: %v", err)
}
for {
    r, err := stream.Recv()
    if err == io.EOF {
        break
    }
    if err != nil {
        log.Fatalf("%v.Split(_) = _, %v", c, err)
    }
    log.Printf("Split response: %s", r.GetWord())
}

和简单 RPC 的客户端代码相比,Split 方法不是直接返回 SplitResponse,而是返回一个 stream 流,它的类型为 HelloService_SplitClient 接口:

type HelloService_SplitClient interface {
    Recv() (*SplitResponse, error)
    grpc.ClientStream
}

这个接口继承自 grpc.ClientStream 接口,并具有一个 Recv 方法,用来接受服务端发送的响应,当服务端发送结束后,Recv 方法将返回 io.EOF 错误。

客户端流 RPC(Client-side streaming RPC

第二种叫客户端流 RPC,它以流的形式接受客户端发送来的多个请求,服务端处理之后返回一个正常的响应。在下面的例子中,客户端向服务端发送多个数字,服务端收集之后进行求和,并将求和结果返回给客户端。首先,我们在 .proto 文件中定义 Sum 方法和相应的消息体:

rpc Sum (stream SumRequest) returns (SumResponse) {}

然后,使用 protoc 生成服务端和客户端的代码,接着在 server/main.go 文件中添加服务端实现:

func (s *server) Sum(stream proto.HelloService_SumServer) error {
    var sum int32 = 0
    for {
        r, err := stream.Recv()
        if err == io.EOF {
            return stream.SendAndClose(&proto.SumResponse{Sum: sum})
        }
        if err != nil {
            return err
        }
        sum = sum + r.GetNum()
    }
}

从上面的代码可以看到,Sum 方法没有了 request 参数,只有一个 stream 参数,请求参数通过 stream.Recv 以流的形式读取,当读取结束后,stream.Recv 方法将返回 io.EOF 错误,这时我们通过 stream.SendAndClose 将处理之后的结果返回给客户端,并关闭连接。

client/main.go 文件中添加客户端实现:

stream2, err := c.Sum(ctx)
if err != nil {
    log.Fatalf("%v.Sum(_) = _, %v", c, err)
}
nums := []int32{1, 2, 3, 4, 5, 6, 7}
for _, num := range nums {
    if err := stream2.Send(&proto.SumRequest{Num: num}); err != nil {
        log.Fatalf("%v.Send(%v) = %v", stream, num, err)
    }
}
response, err := stream2.CloseAndRecv()
if err != nil {
    log.Fatalf("%v.CloseAndRecv() failed: %v", stream2, err)
}
log.Printf("Sum response: %v", response.GetSum())

在上面的代码中,Sum 方法返回一个 stream 变量,然后通过 stream.Send 不断向服务端发送请求,当数据发送结束后,再通过 stream.CloseAndRecv 关闭连接,并接受服务端响应。

双向流 RPC(Bidirectional streaming RPC

第三种叫双向流 RPC,这有点像网络聊天,服务端和客户端双方以任意的顺序互相通信,服务端可以在每次接受客户端请求时就返回一次响应,也可以接受多个请求后再返回一次响应。首先,我们在 .proto 文件中定义 Chat 方法和相应的消息体:

rpc Chat (stream ChatRequest) returns (stream ChatResponse) {}

然后,使用 protoc 生成服务端和客户端的代码,接着在 server/main.go 文件中添加服务端实现:

func (s *server) Chat(stream proto.HelloService_ChatServer) error {
    for {
        r, err := stream.Recv()
        if err == io.EOF {
            return nil
        }
        if err != nil {
            return err
        }
        if err = stream.Send(&proto.ChatResponse{Message: "Reply to " + r.GetMessage()}); err != nil {
            return err
        }
    }
}

上面的代码和客户端流 RPC 比较类似,只不过服务端的响应变得更及时,每次接受到客户端请求时都会响应,而不是等客户端请求结束后再响应。

client/main.go 文件中添加客户端实现:

stream3, err := c.Chat(ctx)
if err != nil {
    log.Fatalf("%v.Chat(_) = _, %v", c, err)
}
waitc := make(chan struct{})
go func() {
    for {
        in, err := stream3.Recv()
        if err == io.EOF {
            close(waitc)
            return
        }
        if err != nil {
            log.Fatalf("Failed to receive: %v", err)
        }
        log.Printf("Chat response: %s", in.GetMessage())
    }
}()

messages := []string{"Hello", "How're you?", "Bye"}
for _, message := range messages {
    if err := stream3.Send(&proto.ChatRequest{Message: message}); err != nil {
        log.Fatalf("Failed to send: %v", err)
    }
}
stream3.CloseSend()
<-waitc

双向流 RPC 的客户端实现要稍微复杂一点。首先,我们通过 stream.Send 来发送请求,由于发送和接受都是流式的,所以我们没法像客户端流 RPC 那样通过 stream.CloseAndRecv() 来获取响应,我们只能调用 stream.CloseSend() 告诉服务端发送结束,然后我们需要创建一个新的 goroutine 来接受响应,另外,我们创建了一个 channel,用于在响应接受结束后通知主线程,以便程序能正常退出。

参考

更多

gRPC 安全认证

根据官方的 gRPC 认证指南,gRPC 支持多种不同的认证方法,包括:SSL/TLS 认证ALTS 认证 以及一些基于 token 的认证方法,如 OAuth2、GCE 等。

除了这些原生的认证方法,我们也可以通过 Metadata 来传送认证信息,从而实现 gRPC 的认证功能;另外,gRPC 还支持拦截器特性,通过拦截器也可以实现安全认证,Go gRPC Middleware 提供了很多拦截器的例子。

扫描二维码,在手机上阅读!

WebAssembly 学习笔记

WebAssembly(简称 WASM)是一种以安全有效的方式运行可移植程序的新兴 Web 技术,下面是引用 MDN 上对它的定义

WebAssembly 是一种新的编码方式,可以在现代的网络浏览器中运行 - 它是一种低级的类汇编语言,具有紧凑的二进制格式,可以接近原生的性能运行,并为诸如 C/C++ 等语言提供一个编译目标,以便它们可以在 Web 上运行。它也被设计为可以与 JavaScript 共存,允许两者一起工作。

也就是说,无论你使用的是哪一种语言,我们都可以将其转换为 WebAssembly 格式,并在浏览器中以原生的性能运行。WebAssembly 的开发团队来自 Mozilla、Google、Microsoft 和 Apple,分别代表着四大网络浏览器 Firefox、Chrome、Microsoft Edge 和 Safari,从 2017 年 11 月开始,这四大浏览器就开始实验性的支持 WebAssembly。当时 WebAssembly 还没有形成标准,这么多的浏览器开发商对某个尚未标准化的技术 达成如此一致的意见,这在历史上是很罕见的,可以看出这绝对是一项值得关注的技术,被号称为 the future of web development

four-browsers.png

WebAssembly 在 2019 年 12 月 5 日被万维网联盟(W3C)推荐为标准,与 HTML,CSS 和 JavaScript 一起,成为 Web 的第四种语言。

WebAssembly 之前的历史

JavaScript 诞生于 1995 年 5 月,一个让人津津乐道的故事是,当时刚加入网景的 Brendan Eich 仅仅花了十天时间就开发出了 JavaScript 语言。开发 JavaScript 的初衷是为 HTML 提供一种脚本语言使得网页变得更动态,当时根本就没有考虑什么浏览器兼容性、安全性、移植性这些东西,对性能也没有特别的要求。但随着 Web 技术的发展,网页要解决的问题已经远不止简单的文本信息,而是包括了更多的高性能图像处理和 3D 渲染等方面,这时,JavaScript 的性能问题就凸显出来了。于是,如何让 JavaScript 执行的更快,变成了各大浏览器生产商争相竞逐的目标。

浏览器性能之战

这场关于浏览器的性能之战在 2008 年由 Google 带头打响,这一年的 9 月 2 日,Google 发布了一款跨时代的浏览器 Chrome,具备简洁的用户界面和极致的用户体验,内置的 V8 引擎采用了全新的 JIT 编译(Just-in-time compilation,即时编译)技术,使得浏览器的响应速度得到了几倍的提升。次年,Apple 发布了他们的浏览器新版本 Safari 4,其中引入新的 Nitro 引擎(也被称为 SquirrelFish 或 JavaScriptCore),同样使用的是 JIT 技术。紧接着,Mozilla 在 Firefox 3.5 中引入 TraceMonkey 技术,Microsoft 在 2011 年也推出 Chakra) 引擎。

使用 JIT 技术,极大的提高了 JavaScript 的性能。那么 JIT 是如何工作的呢?我们知道,JavaScript 是解释型语言,因此传统的 JavaScript 引擎需要逐行读取 JavaScript 代码,并将其翻译成可执行的机器码。很显然这是极其低效的,如果有一段代码需要执行上千次,那么 JavaScript 引擎也会傻傻的翻译上千次。JIT 技术的基本思路就是缓存,它将执行频次比较高的代码实时编译成机器码,并缓存起来,当下次执行到同样代码时直接使用相应的机器码替换掉,从而获得极大的性能提升。另外,对于执行频次非常高的代码,JIT 引擎还会使用优化编译器(Optimising Compiler)编译出更高效的机器码。关于 JIT 技术的原理可以参考 A crash course in just-in-time (JIT) compilers 这篇文章。

JIT 技术推出之后,JavaScript 的性能得到了飞速提升:

jit-performance.png

随着性能的提升,JavaScript 的应用范围也得到了极大的扩展,Web 内容变得更加丰富,图片、视频、游戏,等等等等,甚至有人将 JavaScript 用于后端开发(Node.js)。不过 JIT 也不完全是 “性能银弹”,因为通过 JIT 优化也是有一定代价的,比如存储优化后的机器码需要更多的内存,另外 JIT 优化对变量类型非常敏感,但是由于 JavaScript 动态类型 的特性,用户代码中对某个变量的类型并不会严格固定,这时 JIT 优化的效果将被大打折扣。比如下面这段简单的代码:

function arraySum(arr) {
  var sum = 0;
  for (var i = 0; i < arr.length; i++) {
    sum += arr[i];
  }
}

假设 JIT 检测到 sum += arr[i]; 这行代码被执行了很多次,开始对其进行编译优化,它首先需要确认 sumarriarr[i] 这些变量的类型,如果 arr[i]int 类型,这就是整数相加的操作,但如果 arr[i]string 类型,这又变成了字符串拼接的操作,这两种情况编译成的机器码是完全不同的。所以 JIT 引擎会先根据代码执行情况假设变量为某种类型,然后再进行优化,当执行时会对类型进行检测,一旦检测到类型不同时,这个 JIT 优化将被作废,这个过程叫做 去优化(deoptimization,或者 bailing out)。假如用户写出这样的代码:

arr = [1, "hello"];

JavaScript 这种动态类型的特点对 JIT 引擎是非常不友好的,反复的优化和去优化不仅无法提高性能,甚至会有副作用。所以在实际的生产环境中,JIT 的效果往往没有那么显著,通过 JIT 的优化很快就遇到了瓶颈。

但是日益丰富的 Web 内容对 JavaScript 的性能提出了更高的要求,尤其是 3D 游戏,这些游戏在 PC 上跑都很吃力,更别说在浏览器里运行了。如何让 JavaScript 执行地更快,是摆在各大浏览器生产商面前的一大难题,很快,Google 和 Mozilla 交出了各自的答卷。

Google 的 NaCl 解决方案

Google 在 2008 年开源了 NaCl 技术,并在 2011 年的 Chrome 14 中正式启用。NaCl 的全称为 Native Client,这是一种可以在浏览器中执行原生代码(native code)的技术,听起来很像是 Microsoft 当时所使用的 ActiveX 技术,不过 ActiveX 由于其安全性一直被人所诟病。而 NaCl 定义了一套原生代码的安全子集,执行于独立的沙盒环境之中,并通过一套被称为 PPAPI(Pepper Plugin API)的接口来和 JavaScript 交互,避免了可能的安全问题。NaCl 采取了和 JIT 截然不同的 AOT 编译(Ahead-of-time compilation,即提前编译)技术,所以在性能上的表现非常突出,几乎达到了和原生应用一样的性能。不过由于 NaCl 应用是 C/C++ 语言编写的,与 CPU 架构强关联,不具有可移植性,因此需要针对不同的平台进行开发以及编译,用户使用起来非常痛苦。

为了解决这个问题,Google 在 2013 年又推出了 PNaCl 技术(Portable Native Client),PNaCl 的创新之处在于使用 LLVM IR(Intermediate Representation)来分发应用,而不是直接分发原生代码,LLVM IR 也被称为 Bitcode,它是一种平台无关的中间语言表示,实现了和 Java 一样的目标:一次编译,到处运行。

如果我们站在今天的视角来看,PNaCl 这项技术是非常超前的,它的核心理念和如今的 WebAssembly 如出一辙,只不过它出现的时机不对,当时很多人都对在浏览器中执行原生代码持怀疑态度,担心可能出现和 ActiveX 一样的安全问题,而且当时 HTML5 技术正发展的如火如荼,人们都在想着如何从浏览器中移除诸如 Flash 或 Java Applet 这些 JavaScript 之外的技术,所以 PNaCl 技术从诞生以来,一直不温不火,尽管后来 Firefox 和 Opera 等浏览器也开始支持 NaCl 和 PPAPI,但是一直无法得到普及(当时的 IE 还占领着浏览器市场的半壁江山)。

随着 WebAssembly 技术的发展,Google Chrome 最终在 2018 年移除了对 PNaCl 的支持,决定全面拥抱 WebAssembly 技术。

Mozilla 的 asm.js 解决方案

2010 年,刚刚加入 Mozilla 的 Alon Zakai 在工作之余突发奇想,能不能将自己编写的 C/C++ 游戏引擎运行在浏览器上?当时 NaCl 技术还没怎么普及,Alon Zakai 一时之间并没有找到什么好的技术方案。好在 C/C++ 是强类型语言,JavaScript 是弱类型语言,所以将 C/C++ 代码转换为 JavaScript 在技术上是完全可行的。Alon Zakai 于是便开始着手编写这样的一个编译器,Emscripten 便由此诞生了!

Emscripten 和传统的编译器很类似,都是将某种语言转换为另一种语言形式,不过他们之间有着本质的区别。传统的编译器是将一种语言编译成某种 low-level 的语言,比如将 C/C++ 代码编译成二进制文件(机器码),这种编译器被称为 Compiler;而 Emscripten 是将 C/C++ 代码编译成和它 same-level 的 JavaScript 代码,这种编译器被称为 Transpiler 或者 Source to source compiler

Emscripten 相比于 NaCl 来说兼容性更好,于是很快就得到了 Mozilla 的认可。之后 Alon Zakai 被邀请加入 Mozilla 的研究团队并全职负责 Emscripten 的开发,以及通过 Emscripten 编译生成的 JavaScript 代码的性能优化上。在 2013 年,Alon Zakai 联合 Luke Wagner,David Herman 一起发布了 asm.js 规范,同年,Mozilla 也发布了 Firefox 22,并内置了新一代的 OdinMonkey 引擎,它是第一个支持 asm.js 规范的 JavaScript 引擎。

asm.js 的思想很简单,就是尽可能的在 JavaScript 中使用类型明确的参数,并通过 TypedArray 取消了垃圾回收机制,这样可以让 JIT 充分利用和优化,进而提高 JavaScript 的执行性能。比如下面这样一段 C 代码:

int f(int i) {
  return i + 1;
}

使用 Emscripten 编译生成的 JavaScript 代码如下:

function f(i) {
  i = i|0;
  return (i + 1)|0;
}

通过在变量和返回值后面加上 |0 这样的操作,我们明确了参数和返回值的数据类型,当 JIT 引擎检测到这样的代码时,便可以跳过语法分析和类型推断这些步骤,将代码直接转成机器语言。据称,使用 asm.js 能达到原生代码 50% 左右的速度,虽然没有 NaCl 亮眼,但是这相比于普通的 JavaScript 代码而言已经是极大的性能提升了。而且我们可以看出 asm.js 采取了和 NaCl 截然不同的思路,asm.js 其实和 JavaScript 没有区别,它只是 JavaScript 的一个子集而已,这样做不仅可以充分发挥出 JIT 的最大功效,而且能兼容所有的浏览器。

但是 asm.js 也存在着不少的问题。首先由于它还是和 JavaScript一样是文本格式,因此加载和解析都会花费比较长的时间,这被称为慢启动问题;其次,asm.js 除了在变量后面加 |0 之外,还有很多类似这样的标注代码:

asmjs.png

很显然,这让代码的可读性和可扩展性都变的很差;最后,仍然是性能问题,通过 asm.js 无论怎么优化最终生成的都还是 JavaScript 代码,性能自然远远比不上原生代码;因此这并不是一个非常理想的技术方案。

其他解决方案

除了 NaCl 和 asm.js,实际上还有一些其他的解决方案,但最终的结果要么夭折,要么被迫转型。其中值得一提的是 Google 发明的 Dart 语言,Dart 语言的野心很大,它最初的目的是要取代 JavaScript 成为 Web 的首选语言,为此 Google 还开发了一款新的浏览器 Dartium,内置 Dart 引擎可以执行 Dart 程序,而且对于不支持 Dart 程序的浏览器,它还提供了相应的工具将 Dart 转换为 JavaScript。这一套组合拳可谓是行云流水,可是结果如何可想而知,不仅很难得到用户的承认,而且也没得到其他浏览器的认可,最终 Google 在 2015 年取消了该计划。目前 Dart 语言转战移动开发领域,比如跨平台开发框架 Flutter 就是采用 Dart 开发的。

WebAssembly = NaCl + asm.js

随着技术的发展,Mozilla 和 Google 的工程师出现了很多次的交流和合作,通过汲取 NaCl 和 asm.js 两者的优点,双方推出了一种全新的技术方案:

  • 和 NaCl/PNaCl 一样,基于二进制格式,从而能够被快速解析,达到原生代码的运行速度;
  • 和 PNaCl 一样,依赖于通用的 LLVM IR,这样既具备可移植性,又便于其他语言快速接入;
  • 和 asm.js 一样,使用 Emscripten 等工具链进行编译;另外,Emscripten 同时支持生成 asm.js 和二进制格式,当浏览器不兼容新的二进制格式时,asm.js 可以作为降级方案;
  • 和 asm.js 一样,必须以非常自然的方式直接操作 Web API,而不用像 PNaCl 一样需要处理与 JavaScript 之间的通信;

这个技术方案在 2015 年正式命名为 WebAssembly,2017 年各大浏览器生产商纷纷宣布支持 WebAssembly,2019 年 WebAssembly 正式成为 W3C 标准,一场关于浏览器的性能革命已经悄然展开。

wasm-performance.png

WebAssembly 入门示例

从上面的学习中我们知道,WebAssembly 是一种通用的编码格式,并且已经有很多编程语言支持将源码编译成这种格式了,官方的 Getting Started 有一个详细的列表。这一节我们就跟着官方的教程实践一下下面这三种语言:

将 C/C++ 程序编译成 WebAssembly

首先我们参考 Emscripten 的官方文档 上的步骤下载并安装 Emscripten SDK,安装完成后通过下面的命令检查环境是否正常:

$ emcc --check
emcc (Emscripten gcc/clang-like replacement + linker emulating GNU ld) 3.1.24 (68a9f990429e0bcfb63b1cde68bad792554350a5)
shared:INFO: (Emscripten: Running sanity checks)

环境准备就绪后,我们就可以将 C/C++ 的代码编译为 WebAssembly 了。写一个简单的 Hello World 程序 hello.c

#include <stdio.h>

int main() {
    printf("Hello World\n");
    return 0;
}

然后使用 emcc 进行编译:

$ emcc hello.c -o hello.html

上面这个命令会生成三个文件:

  • hello.wasm - 这就是生成的 WebAssembly 二进制字节码文件
  • hello.js - 包含一段胶水代码(glue code)通过 JavaScript 来调用 WebAssembly 文件
  • hello.html - 方便开发调试,在页面上显示 WebAssembly 的调用结果

我们不能直接用浏览器打开 hello.html 文件,因为浏览器不支持 file:// 形式的 XHR 请求,所以在 HTML 中无法加载 .wasm 等相关的文件,为了看到效果,我们需要一个 Web Server,比如 Nginx、Tomcat 等,不过这些安装和配置都比较麻烦,我们还有很多其他的方法快速启动一个 Web Server。

比如通过 npm 启动一个本地 Web Server:

$ npx serve .

或者使用 Python3 的 http.server 模块:

$ python3 -m http.server

访问 hello.html 页面如下:

hello-html.png

可以看到我们在 C 语言中打印的 Hello World 成功输出到浏览器了。

另外,我们也可以将 C 语言中的函数暴露出来给 JavaScript 调用。默认情况下,Emscripten 生成的代码只会调用 main() 函数,其他函数忽略。我们可以使用 emscripten.h 中的 EMSCRIPTEN_KEEPALIVE 来暴露函数,新建一个 greet.c 文件如下:

#include <stdio.h>
#include <emscripten/emscripten.h>

int main() {
    printf("Hello World\n");
    return 0;
}

#ifdef __cplusplus
#define EXTERN extern "C"
#else
#define EXTERN
#endif

EXTERN EMSCRIPTEN_KEEPALIVE void greet(char* name) {
    printf("Hello, %s!\n", name);
}

上面的代码定义了一个 void greet(char* name) 函数,为了让这个函数可以在 JavaScript 中调用,编译时还需要指定 NO_EXIT_RUNTIMEEXPORTED_RUNTIME_METHODS 参数,将 ccall 导出来:

$ emcc -o greet.html greet.c -s NO_EXIT_RUNTIME=1 -s EXPORTED_RUNTIME_METHODS=ccall

greet.html 文件和上面的 hello.html 几乎是一样的,我们在该文件中加几行代码来测试我们的 greet() 函数,首先加一个按钮:

<button id="mybutton">Click me!</button>

然后为它添加点击事件,可以看到 JavaScript 就是通过上面导出的 ccall 来调用 greet() 函数的:

document.getElementById("mybutton").addEventListener("click", () => {
  const result = Module.ccall(
    "greet",         // name of C function
    null,            // return type
    ["string"],      // argument types
    ["WebAssembly"]  // arguments
  );
});

除了 ccall,我们还可以使用 -s EXPORTED_RUNTIME_METHODS=ccall,cwrap 同时导出 ccallcwrap 函数。ccall 的作用是直接调用某个 C 函数,而 cwrap 是将 C 函数编译为一个 JavaScript 函数,并可以反复调用,这在正式项目中更实用。

点击这个按钮,可以在页面和控制台上都看到 greet() 函数打印的内容:

greet-html.png

将 Rust 程序编译成 WebAssembly

首先按照官方文档 安装 Rust,安装包含了一系列常用的命令行工具,包括 rustuprustccargo 等,其中 cargo 是 Rust 的包管理器,可以使用它安装 wasm-pack

$ cargo install wasm-pack

wasm-pack 用于将 Rust 代码编译成 WebAssembly 格式,不过要注意它不支持 bin 项目,只支持 lib 项目,所以我们通过 --lib 来创建项目:

$ cargo new --lib rust-demo
     Created library `rust-demo` package

打开 ./src/lib.rs,输入以下代码:

use wasm_bindgen::prelude::*;

#[wasm_bindgen]
extern {
    pub fn alert(s: &str);
}

#[wasm_bindgen]
pub fn greet(name: &str) {
    alert(&format!("Hello, {}!", name));
}

在上面的代码中我们使用了 wasm-bindgen 这个工具,它实现了 JavaScript 和 Rust 之间的相互通信,关于它的详细说明可以参考 《The wasm-bindgen Guide》 这本电子书。我们首先通过 extern 声明了一个 JavaScript 中的 alert() 函数,然后我们就可以像调用正常的 Rust 函数一样调用这个外部函数。下面再通过 pub fngreet() 函数暴露出来,这样我们也可以从 JavaScript 中调用这个 Rust 函数。

接着修改 ./Cargo.toml 文件,添加如下内容:

[lib]
crate-type = ["cdylib"]

[dependencies]
wasm-bindgen = "0.2"

其中 crate-type = ["cdylib"] 表示生成一个 动态系统库。使用 wasm-pack 进行构建:

$ wasm-pack build --target web

这个命令会生成一个 pkg 目录,里面包含了 wasm 文件和对应的 JavaScript 胶水代码,这和上面的 emcc 结果类似,不过并没有生成相应的测试 HTML 文件。我们手工创建一个 index.html 文件,内容如下:

<!DOCTYPE html>
<html lang="en-US">
  <head>
    <meta charset="utf-8" />
    <title>hello-wasm example</title>
  </head>
  <body>
    <script type="module">
      import init, { greet } from "./pkg/rust_demo.js";
      init().then(() => {
        greet("WebAssembly");
      });
    </script>
  </body>
</html>

然后启动一个 Web Server,并在浏览器中打开测试页面:

rust-demo-html.png

我们成功在浏览器中调用了使用 Rust 编写的 greet() 函数!

将 Go 程序编译成 WebAssembly

首先确保你已经 安装了 Go

$ go version
go version go1.19 linux/amd64

使用 go mod init 初始化模块:

$ mkdir go-demo && cd go-demo
$ go mod init com.example

新建一个 main.go 文件:

package main

import "fmt"

func main() {
    fmt.Println("Hello, WebAssembly!")
}

使用 go build 可以将它编译成可执行文件,通过在命令之前指定 GOOS=js GOARCH=wasm 可以将它编译成 WebAssembly 文件:

$ GOOS=js GOARCH=wasm go build -o main.wasm

和上面的 C 语言或 Rust 语言的例子一样,为了测试这个 main.wasm 文件,我们还需要 JavaScript 胶水代码和一个测试 HTML 文件。Go 的安装目录下自带了一个 wasm_exec.js 文件,我们将其拷贝到当前目录:

$ cp "$(go env GOROOT)/misc/wasm/wasm_exec.js" .

然后创建一个 index.html 文件(也可以直接使用 Go 自带的 wasm_exec.html 文件):

<html>
  <head>
    <meta charset="utf-8"/>
      <script src="wasm_exec.js"></script>
      <script>
        const go = new Go();
        WebAssembly.instantiateStreaming(fetch("main.wasm"), go.importObject).then((result) => {
          go.run(result.instance);
        });
      </script>
  </head>
  <body></body>
</html>

启动 Web Server 后在浏览器中打开该页面:

go-demo-html.png

在控制台中我们就可以看到程序运行的结果了。

除了在浏览器中测试 WebAssembly 文件,也可以使用 Go 安装目录自带的 go_js_wasm_exec 工具来运行它:

$ $(go env GOROOT)/misc/wasm/go_js_wasm_exec ./main.wasm
Hello, WebAssembly!

或者 go run 时带上 -exec 参数来运行:

$ GOOS=js GOARCH=wasm go run -exec="$(go env GOROOT)/misc/wasm/go_js_wasm_exec" .
Hello, WebAssembly!

运行这个命令需要安装 Node.js v12 以上的版本,打开 go_js_wasm_exec 文件可以看到它实际上就是执行 node wasm_exec_node.js 这个命令。

上面的例子是直接在 JavaScript 中执行 Go 程序,如果我们需要将 Go 中的函数导出给 JavaScript 调用,可以通过 syscall/js 来实现:

package main

import (
    "syscall/js"
)

func addFunction(this js.Value, p []js.Value) interface{} {
    sum := p[0].Int() + p[1].Int()
    return js.ValueOf(sum)
}

func main() {
    js.Global().Set("add", js.FuncOf(addFunction))
    select {} // block the main thread forever
}

注意在 main() 函数中我们使用 select {} 将程序阻塞住,防止程序退出,否则 JavaScript 在调用 Go 函数时会报下面这样的错误:

wasm_exec.js:536 Uncaught Error: Go program has already exited
    at globalThis.Go._resume (wasm_exec.js:536:11)
    at wasm_exec.js:549:8
    at <anonymous>:1:1

由于 add 函数是直接添加到 js.Global() 中的,我们可以直接通过 window.add 来访问它:

go-add-html.png

js.Global() 为我们提供了一个 Go 和 JavaScript 之间的桥梁,我们不仅可以将 Go 函数暴露给 JavaScript 调用,甚至可以通过 js.Global() 来操作 DOM:

func hello(this js.Value, args []js.Value) interface{} {
    doc := js.Global().Get("document")
    h1 := doc.Call("createElement", "h1")
    h1.Set("innerText", "Hello World")
    doc.Get("body").Call("append", h1)
    return nil
}

除了官方的 go build 可以将 Go 程序编译成 WebAssembly 文件,你也可以尝试使用 TinyGo,这是 Go 语言的一个子集实现,它对 Go 规范做了适当的裁剪,只保留了一些比较重要的库,这让它成为了一种更加强大和高效的语言,你可以在意想不到的地方运行它(比如很多物联网设备)。另外,使用 TinyGo 编译 WebAssembly 还有一个很大的优势,它编译出来的文件比 Go 官方编译出来的文件小得多(上面的例子中 C/C++ 或 Rust 编译出来的 wasm 文件只有 100~200K,而 Go 编译出来的 wasm 文件竟然有 2M 多)。

WebAssembly 文本格式

上面我们使用了三种不同的编程语言来体验 WebAssembly,学习了如何编译,以及如何在浏览器中使用 JavaScript 调用它。不过这里有一个问题,由于 wasm 文件是二进制格式,对我们来说是完全黑盒的,不像 JavaScript 是纯文本的,我们可以方便地通过浏览器自带的开发者工具对其进行调试,而 wasm 如果调用出问题,我们将很难排查。实际上,WebAssembly 在设计之初就已经考虑了这样的问题,所以它不仅具有 二进制格式,而且还有一种类似于汇编语言的 文本格式,方便用户查看、编辑和调试。

下面是 WebAssembly 文本格式的一个简单例子:

(module
  (func $add (param $lhs i32) (param $rhs i32) (result i32)
    local.get $lhs
    local.get $rhs
    i32.add)
  (export "add" (func $add))
)

WebAssembly 代码中的基本单元是一个模块,每个模块通过一个大的 S-表达式 来表示,S-表达式是一种嵌套结构,实际上它是树的一种表示形式。上面的代码首先通过 (module) 定义了一个模块,然后模块中使用 (func $add (param $lhs i32) (param $rhs i32) (result i32)) 定义了一个 add() 函数,这个 S-表达式转换为比较好理解的形式就是 i32 add(i32 lhs, i32 rhs),最后通过 (export "add" (func $add)) 将该函数暴露出来,关于这段代码的详细解释可以参考 Mozilla 官方文档中的 Understanding WebAssembly text format

我们将上面的代码保存到 add.wat 文件中,并通过 WABT 工具包(The WebAssembly Binary Toolkit)中的 wat2wasm 将其转换为 wasm 格式:

$ wat2wasm add.wat -o add.wasm

使用下面的 JavaScript 脚本加载 wasm 并调用 add() 函数:

fetchAndInstantiate('add.wasm').then(function(instance) {
    console.log(instance.exports.add(1, 2));  // "3"
});

// fetchAndInstantiate() found in wasm-utils.js
function fetchAndInstantiate(url, importObject) {
    return fetch(url).then(response =>
    response.arrayBuffer()
    ).then(bytes =>
    WebAssembly.instantiate(bytes, importObject)
    ).then(results =>
    results.instance
    );
}

将这段 JavaScript 脚本放在一个 HTML 文件中,然后启动 Web Server 访问,可以看到控制台输出了 3,也就是 add(1, 2) 的结果,并且我们还可以通过 Chrome 提供的 开发者工具对 wasm 文件进行调试

wasm-debug.png

参考

  1. WebAssembly 官网
  2. WebAssembly | MDN
  3. WebAssembly 中文网
  4. WebAssembly Design Documents
  5. WebAssembly Specification
  6. WebAssembly - 维基百科
  7. asm.js 和 Emscripten 入门教程
  8. 浏览器是如何工作的:Chrome V8 让你更懂JavaScript
  9. WebAssembly完全入门——了解wasm的前世今身
  10. 浅谈WebAssembly历史
  11. A cartoon intro to WebAssembly Articles
  12. 一个白学家眼里的 WebAssembly
  13. 使用 Docker 和 Golang 快速上手 WebAssembly
  14. 如何评论浏览器最新的 WebAssembly 字节码技术?
  15. 如何看待 WebAssembly 这门技术?
  16. 系统学习WebAssembly(1) —— 理论篇
  17. 快 11K Star 的 WebAssembly,你应该这样学
  18. WebAssembly 与 JIT
  19. WebAssembly 初步探索
  20. WebAssembly 實戰 – 讓 Go 與 JS 在瀏覽器上共舞

更多

在非浏览器下运行 WebAssembly

WebAssembly 最早只应用于 Web 浏览器中,但鉴于它所拥有 可移植、安全及高效 等特性,WebAssembly 也被逐渐应用在 Web 领域之外的一些其他场景中,并为此提出了一项新的接口标准 —— WASI(WebAssembly System Interface)

要让 WebAssembly 跑在非 Web 环境下,我们必须有一款支持 WASI 接口的运行时(WASI runtime),目前比较流行的有:wasttimewasmerWasmEdge 等,这些运行时提供了不同编程语言的 SDK,可以使得我们在各种不同的语言中调用 WebAssembly 模块。

使用 WABT 工具包

WABT 工具包中除了上文所使用的 wat2wasm 之外,还提供了很多其他有用的工具:

扫描二维码,在手机上阅读!

Java 8 之 Stream API 用法总结

Java 编程语言发展迅速,从 Java 9 开始,Java 采取了小步迭代的发布方式,以每 6 个月发布一个版本的速度在持续更新,目前最新的版本已经升到 19 了

java-versions.png

尽管如此,据 JRebel 2022 年发布的 Java 开发者生产力报告 显示,Java 8 作为第一个 LTS 版本(另两个是 Java 11 和 17),仍然是使用最多的一个版本。

java-version-usage.png

Java 8 由 Oracle 公司于 2014 年 3 月 18 日发布,在这个版本中新增了大量的特性,首次引入了 Lambda 表达式和方法引用,开启了 Java 语言函数式编程的大门,其中新增的 Stream API(java.util.stream)特性更是将函数式编程发挥到了淋漓尽致的地步。

Stream API 概述

在 Java 8 之前,处理集合数据的常规方法是 for 循环:

List<String> words = List.of("A", "B", "C");
for (String word: words) {
    System.out.println(word.toLowerCase());
}

或者使用 iterator 迭代器:

List<String> words = List.of("A", "B", "C");
Iterator<String> iterator = words.iterator();
while (iterator.hasNext()) {
    System.out.println(iterator.next().toLowerCase());
}

这种集合的遍历方式被称为 外部迭代,也就是说由用户来决定 “做什么”(大写转小写) 和 “怎么做”(通过 foriterator 遍历)。

而在 Java 8 中,新增的 Stream API 通过 内部迭代 来处理集合数据,使用了 访问者设计模式(Visitor Pattern),用户只需要通过函数式的方法提供 “做什么” 即可,“怎么做” 交给 Stream API 内部实现:

List<String> words = List.of("A", "B", "C");
words.stream().forEach(word -> System.out.println(word.toLowerCase()));

使用内部迭代可以让用户更聚焦待解决的问题,编写代码不易出错,而且通常编写的代码更少也更易读。这是 Stream API 的一大特征。其实,上面的两种代码实际上对应着两种截然不同的编程风格,那种用户需要关注怎么做,需要 step-by-step 地告诉计算机执行细节的编程风格,被称为 命令式编程(Imperative programming),而用户只关注做什么,只需要告诉计算机想要什么结果,计算过程由计算机自己决定的编程风格,被称为 声明式编程(Declarative programming)

另外,正如 Stream API 的名字一样,Stream API 中有很多方法都会返回流对象本身,于是我们就可以将多个操作串联起来形成一个管道(pipeline),写出下面这样流式风格(fluent style)的代码:

List<String> names = students.stream()
    .filter(s -> s.getScore() >= 60)
    .sorted((x, y) -> x.getScore() - y.getScore())
    .map(Student::getName)
    .collect(Collectors.toList());

Stream API 使用

流的创建

JDK 中提供了很多途径来创建一个流,这一节总结一些常用的创建流的方法。流有一个很重要的特性:不会对数据源进行修改,所以我们可以对同一个数据源创建多个流。

创建一个空流

我们可以通过 Stream.empty() 创建一个不包含任何数据的空流:

Stream<String> streamEmpty = Stream.empty();

在代码中使用空指针是一种不好的编程风格,空流的作用就是为了避免在程序中返回空指针:

public Stream<String> streamOf(List<String> list) {
    return list == null || list.isEmpty() ? Stream.empty() : list.stream();
}

从集合类创建流

JDK 中自带了大量的集合类,比如 ListSetQueue 以及它们的子类,这些类都继承自 Collection 接口:

jdk-collections.gif

注意 Map 不是集合类,但是 Map 中的 keySet()values()entrySet() 方法返回的是集合类。

我们可以通过任何一个集合类的 stream() 方法创建一个流:

List<String> collection = Arrays.asList("a", "b", "c");
Stream<String> streamOfCollection = collection.stream();

从数组创建流

数组和集合类都是用于存储多个对象,只不过数组的长度固定,而集合的长度可变。我们可以使用 Arrays.stream() 静态方法从一个数组创建流:

String[] array = new String[]{"a", "b", "c"};
Stream<String> streamOfArray = Arrays.stream(array);

也可以使用 Stream.of() 方法来创建:

Stream<String> streamOfArray2 = Stream.of(array);

由于 Stream.of() 函数的入参定义是一个可变参数,本质上是个数组,所以既可以像上面那样传入一个数组,也可以直接传入数组元素创建:

Stream<String> streamOfArray3 = Stream.of("a", "b", "c");

使用 Stream.builder() 手工创建流

有时候流中的数据不是来自某个数据源,而是需要手工添加,我们可以使用 Stream.builder() 方法手工创建流:

Stream<String> streamOfBuilder = Stream.<String>builder()
    .add("a")
    .add("b")
    .add("c")
    .build();

也可以往 builder 中依次添加:

Stream.Builder<String> builder = Stream.<String>builder();
builder.add("a");
builder.add("b");
builder.add("c");
Stream<String> streamOfBuilder2 = builder.build();

使用 Stream.generate() 生成流

Stream.generate() 方法也可以用于手工创建流,这个方法需要提供一个 Supplier<T> 的实现,生成的是一个无限流,一般通过 limit 来限定数量:

Stream<String> streamOfGenerate = Stream.generate(() -> "hello").limit(3);

上面的例子中通过 Lambda 表达式 () -> "hello" 一直生成 hello 字符串。如果要生成不一样的数据,可以将变量传到 Lambda 表达式中,比如下面的例子生成 1 2 3 这样的连续整数:

AtomicInteger num = new AtomicInteger(0);
Stream<Integer> streamOfGenerate2 = Stream.generate(() -> num.incrementAndGet()).limit(3);

使用 Stream.iterate() 生成流

在上面的例子中,我们通过将变量传到 Lambda 表达式来生成一个整数数列,像这种根据迭代来生成数据的场景,还有一种更简单的实现:

Stream<Integer> streamOfIterate = Stream.iterate(1, n -> n + 1).limit(3);

iterate() 函数第一个参数为流的第一个元素,后续的元素通过第二个参数中的 UnaryOperator<T> 来迭代生成。

生成基础类型的流

由于 Stream<T> 接口使用了泛型,它的类型参数只能是对象类型,所以我们无法生成基础类型的流,我们只能使用相应的封装类型来生成流,这样就会导致自动装箱和拆箱(auto-boxing),影响性能。

于是 JDK 提供了几个特殊的接口来方便我们创建基础类型的流。JDK 一共有 8 个基础类型,包括 4 个整数类型(byteshortintlong),2 个浮点类型(floatdouble),1 个字符型(char)和 1 个布尔型(boolean),不过只提供了 3 个基础类型的流:IntStreamLongStreamDoubleStream

基础类型流和普通流接口基本一致,我们可以通过上面介绍的各种方法来创建基础类型流。JDK 还针对不同的基础类型提供了相应的更便捷的生成流的方法,比如 IntStream.range() 函数用于方便的生成某个范围内的整数序列:

IntStream intStream = IntStream.range(1, 4);

要注意的是这个数列是左闭右开的,不包含第二个参数,IntStream.rangeClosed() 函数生成的数列是左右都是闭区间:

IntStream intStream2 = IntStream.rangeClosed(1, 3);

此外,Random 类也提供了一些生成基础类型流的方法,比如下面的代码生成 3 个随机的 int 型整数:

IntStream intStream = new Random().ints(3);

生成随机的 longdouble 类型:

LongStream longStream = new Random().longs(3);
DoubleStream doubleStream = new Random().doubles(3);

使用 String.chars() 生成字符流

String 类提供了一个 chars() 方法,用于从字符串生成字符流,正如上面所说,JDK 只提供了 IntStreamLongStreamDoubleStream 三种基础类型流,并没有 CharStream 一说,所以返回值使用了 IntStream

IntStream charStream = "abc".chars();

使用 Pattern.splitAsStream() 生成字符串流

我们知道,String 类里有一个 split() 方法可以将一个字符串分割成子串,但是返回值是一个数组,如果要生成一个子串流,可以使用正则表达式包中 Pattern 类的 splitAsStream() 方法:

Stream<String> stringStream = Pattern.compile(", ").splitAsStream("a, b, c");

从文件生成字符串流

另外,Java NIO 包中的 Files 类提供了一个 lines() 方法,它依次读取文件的每一行并生成字符串流:

try (Stream<String> stringStream = Files.lines(Paths.get(filePath + "test.txt"));) {
    stringStream.forEach(System.out::println);
}

注意使用 try-with-resources 关闭文件。

中间操作

上一节主要介绍了一些常用的创建流的方法,流一旦创建好了,就可以对流执行各种操作。我们将对流的操作分成两种类型:中间操作(Intermediate operation)结束操作(Terminal operation),所有的中间操作返回的结果都是流本身,所以可以写出链式的代码,而结束操作会关闭流,让流无法再访问。

中间操作又可以分成 无状态操作(Stateless operation)有状态操作(Stateful operation) 两种,无状态是指元素的处理不受前面元素的影响,而有状态是指必须等到所有元素处理之后才知道最终结果。

下面通过一些实例来演示不同操作的具体用法,首先创建一个流,包含一些学生数据:

Stream<Student> students = Stream.of(
    Student.builder().name("张三").gender("男").age(27).number(3L).interests("画画、篮球").build(),
    Student.builder().name("李四").gender("男").age(29).number(2L).interests("篮球、足球").build(),
    Student.builder().name("王二").gender("女").age(27).number(1L).interests("唱歌、跳舞、画画").build(),
    Student.builder().name("麻子").gender("女").age(31).number(4L).interests("篮球、羽毛球").build()
);

无状态操作

filter

filter 用于对数据流进行过滤,它接受一个 Predicate<? super T> predicate 参数,返回符合该 Predicate 条件的元素:

students = students.filter(s -> s.getAge() > 30);
map / mapToInt / mapToLong / mapToDouble

map 接受一个 Function<? super T, ? extends R> mapper 类型的参数,对数据流的类型进行转换,从 T 类型转换为 R 类型,比如下面的代码将数据流 Stream<Student> 转换为 Stream<StudentDTO>

Stream<StudentDTO> studentDTOs = students.map(s -> {
    return StudentDTO.builder().name(s.getName()).age(s.getAge()).build();
});

如果要转换成基本类型流,可以使用 mapToIntmapToLongmapToDouble 方法:

LongStream studentAges = students.mapToLong(s -> s.getAge());

上面的 Lambda 也可以写成方法引用:

LongStream studentAges2 = students.mapToLong(Student::getAge);
flatMap / flatMapToInt / flatMapToLong / flatMapToDouble

flatMap 接受一个 Function<? super T, ? extends Stream<? extends R>> mapper 类型的参数,和 map 不同的是,他将 T 类型转换为 R 类型的流,而不是转换为 R 类型,然后再将流中所有数据平铺得到最后的结果:

Stream<String> studentInterests = students.flatMap(s -> Arrays.stream(s.getInterests().split("、")));

每个学生可能有一个或多个兴趣,使用 分割,上面的代码首先将每个学生的兴趣拆开得到一个字符串流,然后将流中的元素平铺,最后得到汇总后的字符串流,该流中包含了所有学生的所有兴趣(元素可能重复)。可以看出 flatMap 实际上是对多个流的数据进行合并。

peek

peek 一般用来调试,它接受一个 Consumer<? super T> action 参数,可以在流的计算过程中对元素进行处理,无返回结果,比如打印出元素的状态:

Stream<String> studentNames = students.filter(s -> s.getAge() > 20)
    .peek(System.out::println)
    .map(Student::getName)
    .peek(System.out::println);
unordered

相遇顺序(encounter order) 是流中的元素被处理时的顺序,创建流的数据源决定了流是否有序,比如 List 或数组是有序的,而 HashSet 是无序的。一些中间操作也可以修改流的相遇顺序,比如 sorted() 用于将无序流转换为有序,而 unordered() 也可以将一个有序流变成无序。

对于 串行流(sequential streams),相遇顺序并不会影响性能,只会影响确定性。如果一个流是有序的,每次执行都会得到相同的结果,如果一个流是无序的,则可能会得到不同的结果。

不过根据官方文档的说法,我使用 unordered() 将一个流改成无序流,重复执行得到的结果还是一样的 [2, 4, 6],并没有得到不同的结果:

List<Integer> ints = Stream.of(1, 2, 3).unordered()
   .map(x -> x*2)
   .collect(Collectors.toList());

网上有说法 认为,这是因为 unordered() 并不会打乱流原本的顺序,只会 消除流必须保持有序的约束,从而允许后续操作使用不必考虑排序的优化。

对于 并行流(parallel streams),去掉有序约束后可能会提高流的执行效率,有些聚合操作,比如 distinct()Collectors.groupingBy() 在不考虑元素有序时具备更好的性能。

有状态操作

distinct

distinct() 方法用于去除流中的重复元素:

Stream<Integer> intStream = Stream.of(1, 2, 3, 2, 4, 3, 1, 2);
intStream = intStream.distinct();

distinct() 是根据流中每个元素的 equals() 方法来去重的,所以如果流中是对象类型,可能需要重写其 equals() 方法。

sorted

sorted() 方法根据 自然序(natural order) 对流中的元素进行排序,流中的元素必须实现 Comparable 接口:

Stream<Integer> intStream = Stream.of(1, 3, 2, 4);
intStream = intStream.sorted();

如果流中的元素没有实现 Comparable 接口,我们可以提供一个比较器 Comparator<? super T> comparator 对流进行排序:

students = students.sorted(new Comparator<Student>() {

    @Override
    public int compare(Student o1, Student o2) {
        return o1.getAge().compareTo(o2.getAge());
    }
    
});

上面是通过匿名内部类的方式创建了一个比较器,我们可以使用 Lambda 来简化它的写法:

students = students.sorted((o1, o2) -> o1.getAge().compareTo(o2.getAge()));

另外,Comparator 还内置了一些静态方法可以进一步简化代码:

students = students.sorted(Comparator.comparing(Student::getAge));

甚至可以组合多个比较条件写出更复杂的排序逻辑:

students = students.sorted(
    Comparator.comparing(Student::getAge).thenComparing(Student::getNumber)
);
skip / limit

skiplimit 这两个方法有点类似于 SQL 中的 LIMIT offset, rows 语句,用于返回指定的记录条数,最常见的一个用处是用来做分页查询。

Stream<Integer> intStream = Stream.of(1, 2, 3, 4, 5, 6, 7, 8, 9, 10);
intStream = intStream.skip(3).limit(3);
dropWhile / takeWhile

dropWhiletakeWhile 这两个方法的作用也是返回指定的记录条数,只不过条数不是固定的,而是根据某个条件来决定返回哪些元素:

Stream<Integer> intStream = Stream.of(1, 2, 3, 4, 5, 6, 7, 8, 9, 10);
intStream = intStream.dropWhile(x -> x <= 3).takeWhile(x -> x <= 6);

结束操作

流的中间操作其实只是一个标记,它是延迟执行的,要等到结束操作时才会触发实际的计算,而且每个流只能有一个结束操作。结束操作会关闭流,对已经关闭的流再执行操作会抛出 IllegalStateException 异常。

结束操作也可以分成两种类型:短路操作(Short-Circuit operation)非短路操作(Non-Short-Circuit operation),短路操作是指不用处理全部元素就可以返回结果,它必须一个元素处理一次,而非短路操作可以批量处理数据,但是需要等全部元素都处理完才会返回结果。

短路操作

anyMatch / allMatch / nonMatch

这几个 match 方法非常类似,它们都接受一个 Predicate<? super T> predicate 条件,用于判断流中元素是否满足某个条件。

anyMatch 表示只要有一个元素满足条件即返回 true

boolean hasAgeGreaterThan30 = students.anyMatch(s -> s.getAge() > 30);

allMatch 表示所有元素都满足条件才返回 true

boolean allAgeGreaterThan20 = students.allMatch(s -> s.getAge() > 20);

noneMatch 表示所有元素都不满足条件才返回 true

boolean noAgeGreaterThan40 = students.noneMatch(s -> s.getAge() > 40);
findFirst / findAny

这两个 find 方法也是非常类似,都是从流中返回一个元素,如果没有,则返回一个空的 Optional,它们经常和 filter 方法联合使用。

findFirst 用于返回流中第一个元素:

// 返回的是 李四
Optional<Student> student = students.filter(s -> s.getAge() > 28).findFirst();

findAny() 返回的元素是不确定的,如果是串行流,返回的是第一个元素:

// 返回的是 李四
Optional<Student> student = students.filter(s -> s.getAge() > 28).findAny();

如果是并行流,则返回值是随机的:

// 返回不确定
Optional<Student> student = students.parallel().filter(s -> s.getAge() > 28).findAny();

非短路操作

forEach / forEachOrdered

这两个 forEach 方法有点类似于 peek 方法,都是接受一个 Consumer<? super T> action 参数,对流中每一个元素进行处理,只不过 forEach 是结束操作,而 peek 是中间操作。

intStream.forEach(System.out::println);

这两个方法的区别在于 forEach 的处理顺序是不确定的,而 forEachOrdered 会按照流中元素的 相遇顺序(encounter order) 来处理。比如下面的代码:

intStream.parallel().forEach(System.out::println);

由于这里使用了并行流,forEach 输出结果是随机的。如果换成 forEachOrdered,则会保证输出结果是有序的:

intStream.parallel().forEachOrdered(System.out::println);
toArray

toArray 方法用于将流转换为一个数组,默认情况下数组类型是 Object[]

Object[] array = students.toArray();

如果要转换为确切的对象类型,toArray 还接受一个 IntFunction<A[]> generator 参数,也是数组的构造函数:

Student[] array = students.toArray(Student[]::new);
reduce

在英语中 reduce 这个单词的意思是 “减少、缩小”,顾名思义,reduce 方法的作用也是如此,它会根据某种规则依次处理流中的元素,经过计算与合并后返回一个唯一的值。早在 2004 年,Google 就研究并提出了一种面向大规模数据处理的并行计算模型和方法,被称为 MapReduce,这里的 Map 表示 映射,Reduce 表示 规约,它们和 Java Stream API 中的 mapreduce 方法有着异曲同工之妙,都是从函数式编程语言中借鉴的思想。

reduce 方法有三种不同的函数形式,第一种也是最简单的:

Optional<T> reduce(BinaryOperator<T> accumulator);

它接受一个 BinaryOperator<T> accumulator 参数,BinaryOperator 是一个函数式接口,它是 BiFunction 接口的特殊形式,BiFunction 表示的是两个入参和一个出参的函数:

@FunctionalInterface
public interface BiFunction<T, U, R> {
    // ...
}

BinaryOperator 同样也是两个入参和一个出参的函数,但是它的两个入参的类型和出参的类型是一样的:

@FunctionalInterface
public interface BinaryOperator<T> extends BiFunction<T,T,T> {
    // ...
}

accumulator 的意思是累加器,它是一个函数,它有两个参数。它的第一个参数是上次函数执行的返回值(也称为中间结果),第二个参数是流中的元素,函数将两个值按照方法进行处理,得到值赋给下次执行这个函数的参数。第一次执行的时候第一参数的值是流中第一元素,第二个元素是流中第二元素,因为流可能为空,所以这个方法的返回值为 Optional

最容易想到的一个例子是通过 reduce 来求和:

Optional<Integer> result = students.map(Student::getAge).reduce((x, y) -> x + y);

其中的 Lambda 表达式 (x, y) -> x + y 也可以简写成方法引用 Integer::sum

Optional<Integer> result = students.map(Student::getAge).reduce(Integer::sum);

不仅如此,稍微改一下 accumulator 函数,我们还可以实现其他的功能,比如求最大值:

Optional<Integer> result = students.map(Student::getAge).reduce((x, y) -> x > y ? x : y);

求最小值:

Optional<Integer> result = students.map(Student::getAge).reduce((x, y) -> x < y ? x : y);

这些参数同样也都可以使用方法引用 Integer::maxInteger::min 进行简化。

reduce 的第二种形式是:

T reduce(T identity, BinaryOperator<T> accumulator);

它和第一种形式的区别在于多了一个和流中元素同类型的 T identity 参数,这个参数的作用是设置初始值,当流中元素为空时,返回初始值。这个形式的好处是不会返回 Optional 类型,代码看起来更简单,所以一般更推荐使用这种形式:

Integer result = students.map(Student::getAge).reduce(0, Integer::sum);

reduce 的 JDK 源码注释里,有一段伪代码很好地解释了 reduce 内部的处理逻辑:

U result = identity;
for (T element : this stream)
    result = accumulator.apply(result, element)
return result;

reduce 的第三种形式如下:

<U> U reduce(U identity, 
    BiFunction<U, ? super T, U> accumulator, 
    BinaryOperator<U> combiner);

可以看到第三种形式要稍微复杂一点,它接受三个参数,第一个参数 identity 表示初始值,第二个参数 accumulator 表示累加器,这和形式二是一样的,不过注意看会发现这两个参数的类型发生了变化,而且返回值的类型也变了,不再局限于和流中元素同类型。第三个参数 BinaryOperator<U> combiner 被称为组合器,这个参数有什么作用呢?在上面的例子中,我们使用的都是串行流,当我们处理并行流时,流会被拆分成多个子流进行 reduce 操作,很显然我们还需要将多个子流的处理结果进行汇聚,这个汇聚操作就是 combiner

不过如果你的汇聚操作和累加器逻辑是一样的,combiner 参数也可以省略:

Integer result = intStream.parallel().reduce(0, Integer::sum);

这个写法和下面的写法没有任何区别:

Integer result = intStream.parallel().reduce(0, Integer::sum, Integer::sum);

到目前为止我们还没有看到 reduce 方法的特别之处,可能你会觉得它不过就是普通的方法,用于 对流中的所有元素累积处理,最终得到一个处理结果。其实这是一个非常强大的工具,也是一个抽象程度非常高的概念,它的用法可以非常灵活,从下面几个例子可以一窥 reduce 的冰山一角。

统计元素个数:

Stream<Integer> intStream = Stream.of(1, 3, 2, 4, 2, 4, 2);
Map<Integer, Integer> countMap = intStream.reduce(new HashMap<>(), (x, y) -> {
    if (x.containsKey(y)) {
        x.put(y, x.get(y) + 1);
    } else {
        x.put(y, 1);
    }
    return x;
}, (x, y) -> new HashMap<>());

数组去重:

Stream<Integer> intStream = Stream.of(1, 3, 2, 4, 2, 4, 2);
List<Integer> distinctMap = intStream.reduce(new ArrayList<>(), (x, y) -> {
    if (!x.contains(y)) {
        x.add(y);
    }
    return x;
}, (x, y) -> new ArrayList<>());

List 转 Map:

Map<Long, Student> studentMap = students.reduce(new HashMap<Long, Student>(), (x, y) -> {
    x.put(y.getNumber(), y);
    return x;
}, (x, y) -> new HashMap<Long, Student>());

可以看到,一旦这个返回类型不做限制时,我们能做的事情就太多了。只要是类似的汇聚操作,都可以用 reduce 实现,这也是 MapReduce 可以用于大规模数据处理的原因。不过上面处理的都是串行流,所以 combiner 参数并没有什么用,随便写都不影响处理结果,但是当我们处理并行流时,combiner 参数就不能乱写了,也不能省略,这是因为它和累加器的参数是不一样的,而且它们的处理逻辑也略有区别。比如上面的 List 转 Map 的例子,如果使用并行流,则必须写 combiner 参数:

Map<Long, Student> studentMap = students.parallel().reduce(new HashMap<Long, Student>(), (x, y) -> {
    x.put(y.getNumber(), y);
    return x;
}, (x, y) -> {
    for (Map.Entry<Long, Student> entry : y.entrySet()) {
        x.put(entry.getKey(), entry.getValue());
    }
    return x;
});
collect

collect 函数正如它的名字一样,可以将流中的元素经过处理并收集起来,得到收集后的结果,这听起来感觉和 reduce 函数有点像,而且它的函数定义也和 reduce 函数很类似:

<R> R collect(Supplier<R> supplier,
    BiConsumer<R, ? super T> accumulator,
    BiConsumer<R, R> combiner);

不过区别还是有的,collect 函数的第一个参数也是用于设置初始值,不过它是通过一个 Supplier<R> supplier 来设置,这是一个没有参数的函数,函数的返回值就是初始值。第二个和第三个参数也是累加器 accumulator 和组合器 combiner,它们的作用和在 reduce 中是一样的,不过它们的类型是 BiConsumer 而不是 BinaryOperator(也不是 BiFunction),这也就意味着累加器和组合器是没有返回值的,所以需要在累加器中使用引用类型来储存中间结果,下面是使用 collect 对流中元素求和的例子:

Stream<Integer> intStream = Stream.of(1, 3, 2, 4);
AtomicInteger result = intStream.collect(
    () -> new AtomicInteger(),
    (a, b) -> a.addAndGet(b), 
    (a, b) -> {}
);

将上面的代码和 reduce 求和的代码对比一下,可以看出两者几乎是一样的,一般来说 reduce 能实现的功能,collect 基本上也都能实现,区别在于它的初始值是一个引用变量,并且中间的计算结果也一直储存在这个引用变量中,最后的返回值也是这个引用变量。很显然,这个引用变量是一个 可变的容器(mutable container),所以 collect 在官方文档中也被称为 Mutable reduction 操作。

而且 collect 相比于 reduce 来说显得更强大,因为它还提供了一个更简单的形式,它将 supplieraccumulatorcombiner 抽象为收集器 Collector 接口:

<R, A> R collect(Collector<? super T, A, R> collector);

这个函数的定义虽然看上去非常简单,但是不得不说,collect 可能是 Stream API 中最为复杂的函数,其复杂之处就在于收集器的创建,为了方便我们创建收集器,Stream API 提供了一个工具类 Collectors,它内置了大量的静态方法可以创建一些常用的收集器,比如我们最常用的 Collectors.toList() 可以将流中元素收集为一个列表:

List<Integer> result = intStream.collect(Collectors.toList());

从源码中可以看出这个收集器是由 ArrayList::newList::add 组成的:

public static <T>
Collector<T, ?, List<T>> toList() {
    return new CollectorImpl<>(
        (Supplier<List<T>>) ArrayList::new, 
        List::add,
        (left, right) -> { left.addAll(right); return left; },
        CH_ID);
}

上面 reduce 中的几个例子,我们一样可以使用 collect 来实现,比如求和:

Integer result = intStream.collect(Collectors.summingInt(Integer::valueOf));

求最大值:

Optional<Integer> result = intStream.collect(Collectors.maxBy(Integer::compareTo));

统计元素个数:

Map<Integer, Long> result = intStream.collect(Collectors.groupingBy(i -> i, Collectors.counting()));

数组去重:

Map<Integer, Integer> result = intStream.collect(Collectors.toMap(i -> i, i -> i, (i, j) -> i));

List 转 Map:

Map<Long, Student> result = students.collect(Collectors.toMap(Student::getNumber, Function.identity()));

除此之外,Collectors 还内置了很多其他的静态方法,比如字符串拼接:

String result = students.map(Student::getName).collect(Collectors.joining("、"));

按条件将数据分为两组:

Map<Boolean, List<Student>> result = students.collect(Collectors.partitioningBy(x -> x.getAge() > 30));

按字段值将数据分为多组:

Map<Integer, List<Student>> result = students.collect(Collectors.groupingBy(Student::getAge));

partitioningBygroupingBy 函数非常类似,只不过一个将数据分成两组,一个将数据分为多组,它们的第一个参数都是 Function<? super T, ? extends K> classifier,又被称为 分类函数(classification function),分组返回的 Map 的键就是由它产生的,而对应的 Map 的值是该分类的数据列表。很容易想到,既然得到了每个分类的数据列表,我们当然可以继续使用 Stream API 对每个分类的数据进一步处理。所以 groupingBy 函数还提供了另一种形式:

Collector<T, ?, Map<K, D>> groupingBy(
    Function<? super T, ? extends K> classifier, 
    Collector<? super T, A, D> downstream)

第二个参数仍然是一个收集器 Collector,这被称为 下游收集器(downstream collector),比如上面那个统计元素个数的例子:

Map<Integer, Long> result = intStream.collect(Collectors.groupingBy(i -> i, Collectors.counting()));

这里就使用了下游收集器 Collectors.counting() 对每个分组的数据进行计数。我们甚至可以对下游收集器返回的结果继续使用下游收集器处理,比如我希望得修改分组后的数据类型:

Map<String, List<String>> result = students.collect(Collectors.groupingBy(
    Student::getGender, Collectors.mapping(
        Student::getName, Collectors.toList())));

这里我希望按学生性别分组,并得到每个性别的学生姓名列表,而不是学生列表。首先使用收集器 Collectors.mapping() 将 Student 对象转换为姓名,然后再使用 Collectors.toList() 将学生姓名收集到一个列表。这种包含一个或多个下游收集器的操作被称为 Multi-level reduction

count

count 比较简单,用于统计流中元素个数:

long count = students.count();
max / min

maxmin 函数用于计算流中的最大元素和最小元素,元素的大小通过比较器 Comparator<? super T> comparator 来决定。比如获取年龄最大的学生:

Optional<Student> maxAgeStudent = students.max(Comparator.comparingInt(Student::getAge));

不过对于基础类型流,maxmin 函数进行了简化,不需要比较器参数:

OptionalInt maxAge = students.mapToInt(Student::getAge).max();
sum / average / summaryStatistics

另外,对于基础类型流,还特意增加了一些统计类的函数,比如 sum 用于对流中数据进行求和:

int sumAge = students.mapToInt(Student::getAge).sum();

average 用于求平均值:

OptionalDouble averageAge = students.mapToInt(Student::getAge).average();

summaryStatistics 用于一次性获取流中数据的统计信息(包括最大值、最小值、总和、数量、平均值):

IntSummaryStatistics summaryStatistics = students.mapToInt(Student::getAge).summaryStatistics();
System.out.println("Max = " + summaryStatistics.getMax());
System.out.println("Min = " + summaryStatistics.getMin());
System.out.println("Sum = " + summaryStatistics.getSum());
System.out.println("Count = " + summaryStatistics.getCount());
System.out.println("Average = " + summaryStatistics.getAverage());

参考

  1. Java8 Stream的总结
  2. Java 8 新特性 | 菜鸟教程
  3. Java 8 Stream | 菜鸟教程
  4. Package java.util.stream Description
  5. https://www.baeldung.com/java-streams
  6. https://www.baeldung.com/tag/java-streams/
  7. https://www.cnblogs.com/wangzhuxing/p/10204894.html
  8. https://www.cnblogs.com/yulinfeng/p/12561664.html

更多

Collectors 静态方法一览

  • 转换为集合

    • Collector<T, ?, C> toCollection(Supplier<C> collectionFactory)
    • Collector<T, ?, List<T>> toList()
    • Collector<T, ?, Set<T>> toSet()
  • 统计计算

    • Collector<T, ?, IntSummaryStatistics> summarizingInt(ToIntFunction<? super T> mapper)
    • Collector<T, ?, LongSummaryStatistics> summarizingLong(ToLongFunction<? super T> mapper)
    • Collector<T, ?, DoubleSummaryStatistics> summarizingDouble(ToDoubleFunction<? super T> mapper)
    • Collector<T, ?, Optional<T>> minBy(Comparator<? super T> comparator)
    • Collector<T, ?, Optional<T>> maxBy(Comparator<? super T> comparator)
    • Collector<T, ?, Integer> summingInt(ToIntFunction<? super T> mapper)
    • Collector<T, ?, Long> summingLong(ToLongFunction<? super T> mapper)
    • Collector<T, ?, Double> summingDouble(ToDoubleFunction<? super T> mapper)
    • Collector<T, ?, Double> averagingInt(ToIntFunction<? super T> mapper)
    • Collector<T, ?, Double> averagingLong(ToLongFunction<? super T> mapper)
    • Collector<T, ?, Double> averagingDouble(ToDoubleFunction<? super T> mapper)
    • Collector<T, ?, Long> counting()
  • 字符串拼接

    • Collector<CharSequence, ?, String> joining()
    • Collector<CharSequence, ?, String> joining(CharSequence delimiter)
    • Collector<CharSequence, ?, String> joining(CharSequence delimiter, CharSequence prefix, CharSequence suffix)
  • Map & Reduce

    • Collector<T, ?, R> mapping(Function<? super T, ? extends U> mapper, Collector<? super U, A, R> downstream)
    • Collector<T, ?, T> reducing(T identity, BinaryOperator<T> op)
    • Collector<T, ?, Optional<T>> reducing(BinaryOperator<T> op)
    • Collector<T, ?, U> reducing(U identity, Function<? super T, ? extends U> mapper, BinaryOperator<U> op)
  • 分组

    • Collector<T, ?, Map<K, List<T>>> groupingBy(Function<? super T, ? extends K> classifier)
    • Collector<T, ?, Map<K, D>> groupingBy(Function<? super T, ? extends K> classifier, Collector<? super T, A, D> downstream)
    • Collector<T, ?, M> groupingBy(Function<? super T, ? extends K> classifier, Supplier<M> mapFactory, Collector<? super T, A, D> downstream)
    • Collector<T, ?, ConcurrentMap<K, List<T>>> groupingByConcurrent(Function<? super T, ? extends K> classifier)
    • Collector<T, ?, ConcurrentMap<K, D>> groupingByConcurrent(Function<? super T, ? extends K> classifier, Collector<? super T, A, D> downstream)
    • Collector<T, ?, M> groupingByConcurrent(Function<? super T, ? extends K> classifier, Supplier<M> mapFactory, Collector<? super T, A, D> downstream)
    • Collector<T, ?, Map<Boolean, List<T>>> partitioningBy(Predicate<? super T> predicate)
    • Collector<T, ?, Map<Boolean, D>> partitioningBy(Predicate<? super T> predicate, Collector<? super T, A, D> downstream)
  • List 转 Map

    • Collector<T, ?, Map<K,U>> toMap(Function<? super T, ? extends K> keyMapper, Function<? super T, ? extends U> valueMapper)
    • Collector<T, ?, Map<K,U>> toMap(Function<? super T, ? extends K> keyMapper, Function<? super T, ? extends U> valueMapper, BinaryOperator<U> mergeFunction)
    • Collector<T, ?, M> toMap(Function<? super T, ? extends K> keyMapper, Function<? super T, ? extends U> valueMapper, BinaryOperator<U> mergeFunction, Supplier<M> mapSupplier)
    • Collector<T, ?, ConcurrentMap<K,U>> toConcurrentMap(Function<? super T, ? extends K> keyMapper, Function<? super T, ? extends U> valueMapper)
    • Collector<T, ?, ConcurrentMap<K,U>> toConcurrentMap(Function<? super T, ? extends K> keyMapper, Function<? super T, ? extends U> valueMapper, BinaryOperator<U> mergeFunction)
    • Collector<T, ?, M> toConcurrentMap(Function<? super T, ? extends K> keyMapper, Function<? super T, ? extends U> valueMapper, BinaryOperator<U> mergeFunction, Supplier<M> mapSupplier)
  • 其他

    • Collector<T,A,RR> collectingAndThen(Collector<T,A,R> downstream, Function<R,RR> finisher)
扫描二维码,在手机上阅读!

使用 Visual Studio Code 进行 Go 开发

Visual Studio Code(简称 VS Code)是微软于 2015 年 4 月在微软开发者大会(Microsoft Build 2015)上开源的一款非常优秀的跨平台源代码编辑器,它不仅原生支持 JavaScript、TypeScript、CSS 和 HTML,而且可以通过强大的插件系统支持其他任意的编程语言,比如:PythonJavaC/C++Go 等等。你可以在 插件市场 找到更多其他的插件。通过统一的接口模型,VS Code 为不同的编程语言提供了统一的编程体验,你再也不需要在不同语言的 IDE 之间来回切换了。

VS Code 为不同的编程语言提供了如下通用的语言特性:

  • 语法高亮(Syntax highlighting)、括号匹配(Bracket matching)
  • 代码自动补全(IntelliSense)
  • 语法检查(Linting and corrections)
  • 代码导航(Go to Definition, Find All References)
  • 调试
  • 重构

VS Code 使用 Monaco Editor 作为其底层的代码编辑器,不仅可以跨平台使用,而且还可以通过浏览器在线使用,你可以访问 vscode.dev,操作界面和桌面版几乎是一样的。在 2019 年的 Stack Overflow 组织的开发者调查中,VS Code 被认为是最受开发者欢迎的开发环境。

安装 Go 插件

Go 语言 又被称为 Golang,是 Google 开发的一种静态强类型、编译型、并发型,并具有垃圾回收功能的编程语言。它于 2007 年 9 月开始设计,并在 2009 年 11 月正式发布并完全开源,至今已有 13 年的历史了。目前的 Go 语言在国内外的技术社区都非常热门,并诞生了很多著名的开源项目,如 Kubernetes、etcd 和 Prometheus 等,在近年来热门的微服务架构和云原生技术的发展中起到了举足轻重的作用。

在这篇笔记中,我们将学习如何在 VS Code 中进行 Go 语言的开发。

首先打开官网的 Download and install 页面,按照提示的步骤下载并安装 Go 语言开发环境。

然后在 VS Code 中安装 Go 插件

go-extension.png

此时,我们只需要打开以 .go 结尾的文件,就可以激活该插件,左下角会显示出 Go 的状态栏,并在状态栏上可以看到当前使用的 Go 版本:

status-bar-menu.png

另外,这个插件还依赖 一些 Go 工具,比如 goplsdlv 等。gopls 是 Go 官方的 language server,dlv 使用 Delve 进行 Go 语言的调试和测试,都是开发过程中必不可少的组件。如果其中任何一个工具缺失,VS Code 下面的状态栏就会弹出 ⚠️ Analysis Tools Missing 的警告提示,点击提示将自动下载安装这些工具:

install-tools.gif

安装完成后,一切准备就绪,就可以开始我们的 Go 语言之旅了。

You are ready to Go :-)

从这里可以看到 Go 插件支持的 所有特性

Go 入门示例

这一节我们将演示如何在 VS Code 中开发一个 Go 项目。首先创建一个空目录 demo,并在 VS Code 中打开它。然后我们新建一个终端,输入下面的命令创建一个 Go 模块(module):

$ go mod init example.com/demo
go: creating new go.mod: module example.com/demo

运行成功后,可以发现创建了一个 go.mod 文件,这个文件类似于 Maven 项目中 pom.xml 文件,用于管理项目依赖的模块。早期的版本中,Go 语言是没有依赖管理功能的,所有依赖的第三方包都放在 GOPATH 目录下,这就导致了同一个包只能保存一个版本,如果不同的项目依赖同一个包的不同版本,该怎么办呢?

于是 Go 语言从 v1.5 版本开始引入 vendor 模式,如果项目目录下有 vendor 目录,那么 Go 会优先使用 vendor 内的包,可以使用 godepdep 来管理 vender 模式下的依赖包。

不过从 v1.11 版本开始,官方又推出了 Go module 功能,并在 v1.13 版本中作为 Go 语言默认的依赖管理工具。使用 Go module 依赖管理会在项目根目录下生成 go.modgo.sum 两个文件。

我们打开 go.mod 这个文件,目前内容还比较简单,只是定义了当前的模块名以及使用的 Go 版本:

module example.com/demo

go 1.19

接下来我们在项目中创建一个 包(package),也就是一个目录,比如 hello,并在该目录下创建一个文件 hello.go,打开这个文件时会激活 Go 插件。等插件加载完毕,我们就可以编写 Go 代码了,在文件中输入如下内容:

package hello

func SayHello() string {
    return "Hello world"
}

第一行使用 package 声明包,然后下面通过 func 定义了一个 SayHello() string 方法,注意在 Go 语言中类型是写在方法名后面的。

接下来,在项目根目录下创建一个 main.go 文件,内容如下:

package main

import (
    "fmt"

    "example.com/demo/hello"
)

func main() {
    fmt.Println(hello.SayHello())
}

第一行依然是使用 package 来声明包,每个 .go 文件都需要声明包,只不过包名不同;然后使用 import 导入我们要使用的包,这里我们使用了 fmt 这个系统包,它是用于打印输出的,还使用了我们上面创建的 example.com/demo/hello 这个包,这样我们就可以调用其他包里的方法了;最后通过 func 定义了一个 main() 方法,这个方法是整个程序的入口。

就这样一个简单的示例项目就完成了。我们打开终端,输入 go run 命令即可运行程序:

$ go run main.go
Hello world

或者使用 go build 将代码编译为可执行程序:

$ go build main.go

运行生成的可执行程序:

$ ./main
Hello world

引用三方包

上面的例子中我们只使用了系统包和自己代码中的包,如果要使用第三方包该怎么办呢?

我们可以使用 go get 下载第三方包并将依赖更新到 go.mod 文件中,比如我们要添加 rsc.io/quote 这个依赖包,执行如下命令:

$ go get rsc.io/quote
go: downloading rsc.io/quote v1.5.2
go: added golang.org/x/text v0.0.0-20170915032832-14c0d48ead0c
go: added rsc.io/quote v1.5.2

这个命令默认会从 Go 官方的模块代理(https://proxy.golang.org)下载依赖包,如果遇到网络问题,可以使用下面的命令改为国内的代理(https://goproxy.cn):

$ go env -w GOPROXY=https://goproxy.cn,direct

go get 命令执行成功后,重新打开 go.mod 文件,可以看到自动添加了依赖:

require (
    golang.org/x/text v0.0.0-20170915032832-14c0d48ead0c // indirect
    rsc.io/quote v1.5.2 // indirect
    rsc.io/sampler v1.3.0 // indirect
)

这时我们就可以在代码中使用 rsc.io/quote 这个包了:

package main

import (
    "fmt"

    "example.com/demo/hello"
    "rsc.io/quote"
)

func main() {
    fmt.Println(hello.SayHello())
    fmt.Println(quote.Go())
}

重新运行程序:

$ go run main.go
Hello world
Don't communicate by sharing memory, share memory by communicating.

编写单元测试

这一节我们使用 Go 语言的标准库 testing 对我们的代码进行单元测试。Go 语言推荐将测试文件和源代码文件放在一起,测试文件以 _test.go 结尾,比如我们要对上面的 hello.go 编写单元测试,可以在同目录创建一个 hello_test.go 文件,文件内容如下:

package hello_test

import (
    "testing"

    "example.com/demo/hello"
)

func TestSayHello(t *testing.T) {
    if hello.SayHello() != "Hello world" {
        t.Fatal("Not good")
    }
}

测试用例名称一般命名为 Test 加上待测试的方法名,比如这里的 TestSayHello 是对 SayHello 的测试,测试用的参数有且只有一个,在这里是 t *testing.T,表示这是一个单元测试,如果是基准测试,这个参数类型为 *testing.B

VS Code 会自动识别单元测试的包和方法,并在包和方法上显示一个链接:

unit-test.png

我们可以点击方法上的 run testdebug test 来执行测试,或者使用 go test 命令来执行,由于这个测试是写在 hello 这个目录下,我们需要进入该目录执行测试:

$ cd hello
$ go test
PASS
ok      example.com/demo/hello  0.277s

这里有一点需要特别注意,我们在这个文件的最顶部声明包时用的是 package hello_test,而不是 package hello,其实两种方法都可以,这取决于你编写的是黑盒测试还是白盒测试。如果你使用 package hello,那么在单元测试代码中就可以对私有方法进行测试,相当于白盒测试,而这里我们使用的是黑盒测试,也就是只对包里公共方法进行测试。

调试 Go 程序

在上面的单元测试方法上面有一个 debug test 链接,点击该链接就可以调试 Go 程序了。如果要以调试模式启动 main() 函数,可以打开 main.go 文件,使用 F5 快捷键启动调试器。

go-debugging.png

或者打开 VS Code 的 “运行和调试” 侧边栏,然后点击 “运行和调试” 按钮也可以启动调试器。如果调试器启动成功,我们可以在下方的调试控制台看到类似这样的输出:

Starting: C:\Users\aneasystone\go\bin\dlv.exe dap --check-go-version=false --listen=127.0.0.1:60508 from d:\code\weekly-practice\notes\week021-go-in-visual-studio-code\demo
DAP server listening at: 127.0.0.1:60508

Go 语言的官方调试器是 dlv,它的全称为 Delve,VSCode 通过运行 dlv dap 命令来启动 Go 语言的调试器,这个命令会在本地启动一个 TCP 服务器,并通过 DAP 协议(Debug Adaptor Protocol)) 和 VS Code 进行通信实现调试的功能。

使用 F5 快捷键或 “运行和调试” 按钮时,VS Code 会使用默认配置对当前打开的文件进行调试。如果想修改配置参数,我们可以创建一个 launch.json 配置文件:

create-launch-json.png

点击 “创建 launch.json 文件” 按钮会弹出一个下拉框,我们可以:

  • 调试一个包(Launch Package)
  • 附加到本地进程(Attach to local process)
  • 连接到远程服务(Connect to server)

我们选择第一个,创建的 launch.json 配置文件如下:

{
    "version": "0.2.0",
    "configurations": [
        {
            "name": "Launch Package",
            "type": "go",
            "request": "launch",
            "mode": "auto",
            "program": "${fileDirname}"
        }
    ]
}

我们将 ${fileDirname} 变量修改为 .,表示项目的根目录。这样我们就可以在打开任意文件的时候快速调试 main() 方法了,而不用每次都打开 main.go 文件来调试。如果我们需要对调试器进行配置,比如配置命令行参数启动(args),修改当前工作目录(cwd),配置 dlv 调试器(dlvFlags)等等,我们在 launch.json 中输入引号后 VS Code 会自动提示所有支持的配置项:

create-launch-json-args.png

这些配置项的含义可以参考 Launch.json Attributes

参考

  1. Go in Visual Studio Code
  2. VSCode Go Wiki
  3. Go Documentation
  4. Getting started with VS Code Go
  5. Go语言之依赖管理
  6. Go Test 单元测试简明教程
  7. Proper package naming for testing with the Go language
  8. Debug Go programs in VS Code
扫描二维码,在手机上阅读!

写一个简单的 Kubernetes Operator

Kubernetes Operator 这一概念是由 CoreOS 的工程师于 2016 年提出的,它是一种通过 自定义资源custom resourceCR)来包装、运行和管理 Kubernetes 应用的方式。Kubernetes 1.7 版本以来就引入了自定义资源的概念,该功能可以让开发人员扩展新功能或更新现有功能,并且可以自动执行一些管理任务,这些自定义资源就像 Kubernetes 的原生组件一样。

通过自定义资源,我们可以将应用抽象为一个整体,而不用去关心该应用是由哪些 Kubernetes 原生组件构成的,什么 Pods、Deployments、Services 或 ConfigMaps 统统交给 Operator 来管理。创建 Operator 的关键是自定义资源的设计,通过直接调用 Kubernetes API,编写自定义规则自动管理和维护 Kubernetes 集群中的应用,包括自动化安装、配置、更新、故障转移、备份恢复等等。这样的应用也被称为 Kubernetes 原生应用(Kubernetes-native application)。可以把 Operator 当做是一个运维人员,它以软件的形式帮助我们管理 Kubernetes 中运行的应用。Operator 可以帮我们实现下面这些运维工作:

operator-capabilitiy-model.png

这个图也被称为 Operator 的能力模型,将 Operator 的能力由低到高分成了 5 个等级。

控制器循环

Kubernetes Operator 遵循 control loop 原则,这是 Kubernetes 的核心原则之一,也是机器人和自动化领域中一种常见的持续运行动态系统的机制。它依赖于一种快速调整工作负载需求的能力,进而能够尽可能准确地适应现有资源。

reconciliation-loop.png

在 Kubernetes 中,这个循环被称为 reconciliation loop。在这个循环中,有一个非常重要的角色:控制器(Controller),它可以对集群的变化做出响应,并执行相应的动作。控制器首先观察 Kubernetes 对象的当前状态,然后通过 Kubernetes API 进行持续调整,直到将对象的当前状态变成所需状态为止。

第一个 Kubernetes Controller 是 kube-controller-manager,它被认为是所有 Operator 的鼻祖。

使用 Operator Framework 开发 Operator

Operator Framework 是 CoreOS 开源的一个用于快速开发或管理 Operator 的工具包,主要分为三大部分:

  • Operator SDKBuild, test, iterate. 你无需了解复杂的 Kubernetes API 特性,就可以根据你自己的专业知识构建一个 Operator 应用。
  • Operator Lifecycle Managerinstall, manage, update. OLM 是一款帮助你安装、更新和管理 Kubernetes Operator 的工具。
  • OperatorHub.ioPublish & share. OperatorHub 是一个类似 DockerHub 的仓库,你可以在这里搜索你想要的 Operator,或者将你的 Operator 发布并分享给其他人。

通过 Operator SDK 我们可以快速开发一个 Kubernetes Operator,它不仅提供了一套 High level API 来方便我们处理业务逻辑,还提供了一个命令行工具用于快速生成一个 Operator 的脚手架项目。

安装 operator-sdk

在开发 Operator 之前,先确保你已经有一个能访问的 Kubernetes 集群环境,Kubernetes 的安装可以参考 Kubernetes 安装小记。查看 Kubernetes 集群信息:

$ kubectl cluster-info
Kubernetes control plane is running at https://kubernetes.docker.internal:6443
CoreDNS is running at https://kubernetes.docker.internal:6443/api/v1/namespaces/kube-system/services/kube-dns:dns/proxy

To further debug and diagnose cluster problems, use 'kubectl cluster-info dump'.

另外,Go 的开发环境也是必不可少的,可以参考 Go 的 官方文档 下载并安装。

$ curl -LO https://go.dev/dl/go1.19.linux-amd64.tar.gz
$ sudo tar -C /usr/local -xzf go1.19.linux-amd64.tar.gz

将路径 /usr/local/go/bin 添加到 PATH 环境变量,或者将下面这行添加到 ~/.profile 文件中:

$ export PATH=$PATH:/usr/local/go/bin

查看 Go 版本:

$ go version
go version go1.19 linux/amd64

接下来,我们继续安装 Operator SDK。我们在 Operator SDK 的 Releases 页面 找到合适的版本并下载:

$ curl -LO https://github.com/operator-framework/operator-sdk/releases/download/v1.23.0/operator-sdk_linux_amd64

将其移动到 /usr/local/bin/ 目录即可完成安装:

$ chmod +x operator-sdk_linux_amd64 && sudo mv operator-sdk_linux_amd64 /usr/local/bin/operator-sdk

查看已安装的 operator-sdk 版本:

$ operator-sdk version
operator-sdk version: "v1.23.0", commit: "1eaeb5adb56be05fe8cc6dd70517e441696846a4", kubernetes version: "1.24.2", go version: "go1.18.5", GOOS: "linux", GOARCH: "amd64"

另外,operator-sdk 依赖于 makegcc,确保系统上已经安装了 makegcc 工具。

使用 operator-sdk 初始化 Operator 项目

Operator SDK 提供了三种方式开发 Operator:

我们这里将使用 Go 来开发 Operator,这种方式也是最灵活的,你可以使用 client-go 调用 Kubernetes API 来对 Kubernetes 对象进行操作。首先使用 operator-sdk init 初始化项目结构:

$ operator-sdk init --domain example.com --project-name memcached-operator --repo github.com/example/memcached-operator
Writing kustomize manifests for you to edit...
Writing scaffold for you to edit...
Get controller runtime:
$ go get sigs.k8s.io/controller-runtime@v0.12.2
Update dependencies:
$ go mod tidy
Next: define a resource with:
$ operator-sdk create api

其中 --project-name 参数可以省略,默认项目名称就是目录名。--domain--project-name 两个参数用于组成 Operator 的镜像名称 example.com/memcached-operator,而 --repo 参数用于定义 Go 模块名:

module github.com/example/memcached-operator

初始化后的完整项目结构如下:

$ tree .
.
├── Dockerfile
├── Makefile
├── PROJECT
├── README.md
├── config
│   ├── default
│   │   ├── kustomization.yaml
│   │   ├── manager_auth_proxy_patch.yaml
│   │   └── manager_config_patch.yaml
│   ├── manager
│   │   ├── controller_manager_config.yaml
│   │   ├── kustomization.yaml
│   │   └── manager.yaml
│   ├── manifests
│   │   └── kustomization.yaml
│   ├── prometheus
│   │   ├── kustomization.yaml
│   │   └── monitor.yaml
│   ├── rbac
│   │   ├── auth_proxy_client_clusterrole.yaml
│   │   ├── auth_proxy_role.yaml
│   │   ├── auth_proxy_role_binding.yaml
│   │   ├── auth_proxy_service.yaml
│   │   ├── kustomization.yaml
│   │   ├── leader_election_role.yaml
│   │   ├── leader_election_role_binding.yaml
│   │   ├── role_binding.yaml
│   │   └── service_account.yaml
│   └── scorecard
│       ├── bases
│       │   └── config.yaml
│       ├── kustomization.yaml
│       └── patches
│           ├── basic.config.yaml
│           └── olm.config.yaml
├── go.mod
├── go.sum
├── hack
│   └── boilerplate.go.txt
└── main.go

主要包括以下几个文件:

  • go.mod - 用于定义 Go 项目的依赖信息
  • PROJECT - 用于保存项目的配置信息
  • Makefile - 包含一些有用的项目构建目标(make targets
  • config - 该目录下包含一些用于项目部署的 YAML 文件
  • main.go - Operator 的主程序入口

创建 API

初始化项目之后,接着就可以使用 operator-sdk create api 命令创建 API 了:

$ operator-sdk create api --group cache --version v1alpha1 --kind Memcached --resource --controller
Writing kustomize manifests for you to edit...
Writing scaffold for you to edit...
api/v1alpha1/memcached_types.go
controllers/memcached_controller.go
Update dependencies:
$ go mod tidy
Running make:
$ make generate
./memcached-operator/bin/controller-gen object:headerFile="hack/boilerplate.go.txt" paths="./..."
Next: implement your new API and generate the manifests (e.g. CRDs,CRs) with:
$ make manifests

使用 operator-sdk create 命令可以生成 apiwebhook 的脚手架代码,我们这里生成的是 api,包括两部分内容:自定义资源(--resource)和控制器相关的逻辑代码(--controller),其中 --group--version--kind 分别用来设置资源的分组、版本和类型。

接下来可以从这些文件开始入手:

  • api/v1beta1/memcached_types.go
  • controllers/memcached_controller.go
  • controllers/suite_test.go

memcached_types.go 文件用于定义资源的接口规范,我们在 MemcachedSpec 中添加一个新字段 Size 如下(默认已经生成了一个 Foo 字段):

// MemcachedSpec defines the desired state of Memcached
type MemcachedSpec struct {
    // INSERT ADDITIONAL SPEC FIELDS - desired state of cluster
    // Important: Run "make" to regenerate code after modifying this file

    // Foo is an example field of Memcached. Edit memcached_types.go to remove/update
    Foo string `json:"foo,omitempty"`
    Size int32 `json:"size"`
}

接着打开 memcached_controller.go 文件,其中 Reconcile 方法就是上面所介绍的 reconciliation loop 的核心代码,可以在这里实现自己的业务逻辑,比如调用 Kubernetes API 创建、删除或更新各种 Kubernetes 资源。我们这里只是简单地将资源的属性值打印出来(官方对 memcached-operator 有完整的示例代码,可以 参考这里):

func (r *MemcachedReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) {
    _ = log.FromContext(ctx)

    instance := &cachev1alpha1.Memcached{}
    err := r.Get(context.TODO(), req.NamespacedName, instance)
    if err != nil {
        fmt.Println("Get instance err")
        return ctrl.Result{}, err
    }

    fmt.Printf("Foo = %s, Size = %d\n", instance.Spec.Foo, instance.Spec.Size)

    return ctrl.Result{}, nil
}

然后执行下面的命令生成自定义资源文件:

$ make manifests
test -s ./memcached-operator/bin/controller-gen || GOBIN=./memcached-operator/bin go install sigs.k8s.io/controller-tools/cmd/controller-gen@v0.9.2
./memcached-operator/bin/controller-gen rbac:roleName=manager-role crd webhook paths="./..." output:crd:artifacts:config=config/crd/bases

生成的自定义资源文件位于 config/crd/bases/cache.example.com_memcacheds.yaml,文件内容如下:

---
apiVersion: apiextensions.k8s.io/v1
kind: CustomResourceDefinition
metadata:
  annotations:
    controller-gen.kubebuilder.io/version: v0.9.2
  creationTimestamp: null
  name: memcacheds.cache.example.com
spec:
  group: cache.example.com
  names:
    kind: Memcached
    listKind: MemcachedList
    plural: memcacheds
    singular: memcached
  scope: Namespaced
  versions:
  - name: v1alpha1
    schema:
      openAPIV3Schema:
        description: Memcached is the Schema for the memcacheds API
        properties:
          apiVersion:
            description: 'APIVersion defines the versioned schema of this representation
              of an object. Servers should convert recognized schemas to the latest
              internal value, and may reject unrecognized values. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources'
            type: string
          kind:
            description: 'Kind is a string value representing the REST resource this
              object represents. Servers may infer this from the endpoint the client
              submits requests to. Cannot be updated. In CamelCase. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds'
            type: string
          metadata:
            type: object
          spec:
            description: MemcachedSpec defines the desired state of Memcached
            properties:
              foo:
                description: Foo is an example field of Memcached. Edit memcached_types.go
                  to remove/update
                type: string
              size:
                format: int32
                type: integer
            required:
            - size
            type: object
          status:
            description: MemcachedStatus defines the observed state of Memcached
            type: object
        type: object
    served: true
    storage: true
    subresources:
      status: {}

在这个文件中,我们定义了一个名为 Memcached 的自定义资源(Custom Resource Definition,简称 CRD),并定义了 foosize 两个属性,且 size 属性为必填项。

本地调试 Operator

至此,一个简单的 Operator 就开发好了,接下来我们运行 make install 命令,该命令使用 kustomize build 生成 CRD 配置文件并执行 kubectl apply 将 CRD 安装到 Kubernetes 集群中:

$ make install
test -s ./memcached-operator/bin/controller-gen || GOBIN=./memcached-operator/bin go install sigs.k8s.io/controller-tools/cmd/controller-gen@v0.9.2
./memcached-operator/bin/controller-gen rbac:roleName=manager-role crd webhook paths="./..." output:crd:artifacts:config=config/crd/bases
./memcached-operator/bin/kustomize build config/crd | kubectl apply -f -
customresourcedefinition.apiextensions.k8s.io/memcacheds.cache.example.com created

通过 kubectl get crds 可以查看集群中的自定义资源是否创建成功:

$ kubectl get crds
NAME                           CREATED AT
memcacheds.cache.example.com   2022-08-26T09:24:19Z

可以看到集群中多了一个自定义资源 memcacheds.cache.example.com。然后运行 make run 命令在本地启动控制器:

$ make run
test -s ./memcached-operator/bin/controller-gen || GOBIN=./memcached-operator/bin go install sigs.k8s.io/controller-tools/cmd/controller-gen@v0.9.2
./memcached-operator/bin/controller-gen rbac:roleName=manager-role crd webhook paths="./..." output:crd:artifacts:config=config/crd/bases
./memcached-operator/bin/controller-gen object:headerFile="hack/boilerplate.go.txt" paths="./..."
go fmt ./...
api/v1alpha1/groupversion_info.go
go vet ./...
go run ./main.go
1.6615063195978441e+09  INFO    controller-runtime.metrics      Metrics server is starting to listen    {"addr": ":8080"}
1.6615063195986106e+09  INFO    setup   starting manager
1.6615063195992978e+09  INFO    Starting server {"path": "/metrics", "kind": "metrics", "addr": "[::]:8080"}
1.6615063195993063e+09  INFO    Starting server {"kind": "health probe", "addr": "[::]:8081"}
1.661506319599374e+09   INFO    Starting EventSource    {"controller": "memcached", "controllerGroup": "cache.example.com", "controllerKind": "Memcached", "source": "kind source: *v1alpha1.Memcached"}
1.6615063196000834e+09  INFO    Starting Controller     {"controller": "memcached", "controllerGroup": "cache.example.com", "controllerKind": "Memcached"}
1.6615063197010505e+09  INFO    Starting workers        {"controller": "memcached", "controllerGroup": "cache.example.com", "controllerKind": "Memcached", "worker count": 1}

接下来我们就可以创建一个自定义资源实例测试一下。首先修改 config/samples/cache_v1alpha1_memcached.yaml 文件,填入 foosize 两个属性:

apiVersion: cache.example.com/v1alpha1
kind: Memcached
metadata:
  name: memcached-sample
spec:
  foo: Hello World
  size: 10

然后执行 kubectl apply 命令创建自定义资源实例:

$ kubectl apply -f config/samples/cache_v1alpha1_memcached.yaml
memcached.cache.example.com/memcached-sample created

此时查看控制器的输出如下:

Foo = Hello World, Size = 10

说明控制器监听到了自定义资源的创建,并输出了它的属性值。使用 kubectl get 查看刚刚创建的自定义资源:

$ kubectl get memcached.cache.example.com/memcached-sample
NAME               AGE
memcached-sample   13m

然后我们测试下自定义资源更新时的情况,修改 cache_v1alpha1_memcached.yaml 文件,比如将 size 改为 9,重新执行 kubectl apply 命令,控制器会立即监听到该修改,并输出新的属性值:

Foo = Hello World, Size = 9

部署 Operator

Operator 开发完成后,我们需要将它部署到 Kubernetes 集群中。首先我们将其构建成 Docker 镜像,可以使用下面的命令构建,并将镜像推送到镜像仓库:

$ make docker-build docker-push IMG="aneasystone/memcached-operator:v0.0.1"
test -s ./memcached-operator/bin/controller-gen || GOBIN=./memcached-operator/bin go install sigs.k8s.io/controller-tools/cmd/controller-gen@v0.9.2
./memcached-operator/bin/controller-gen rbac:roleName=manager-role crd webhook paths="./..." output:crd:artifacts:config=config/crd/bases
./memcached-operator/bin/controller-gen object:headerFile="hack/boilerplate.go.txt" paths="./..."
go fmt ./...
go vet ./...
KUBEBUILDER_ASSETS="/home/aneasystone/.local/share/kubebuilder-envtest/k8s/1.24.2-linux-amd64" go test ./... -coverprofile cover.out
?       github.com/example/memcached-operator   [no test files]
?       github.com/example/memcached-operator/api/v1alpha1      [no test files]
ok      github.com/example/memcached-operator/controllers       8.935s  coverage: 0.0% of statements
docker build -t aneasystone/memcached-operator:v0.0.1 .
[+] Building 3.3s (18/18) FINISHED                                                                                                                                
 => [internal] load build definition from Dockerfile
 => => transferring dockerfile: 38B                                                         0.0s
 => [internal] load .dockerignore                                                           0.0s
 => => transferring context: 35B                                                            0.0s
 => [internal] load metadata for gcr.io/distroless/static:nonroot                           0.7s
 => [internal] load metadata for docker.io/library/golang:1.18                              3.0s
 => [auth] library/golang:pull token for registry-1.docker.io0.0s
 => [builder 1/9] FROM docker.io/library/golang:1.18@sha256:5540a6a6b3b612c382accc545b3f6702de21e77b15d89ad947116c94b5f42993        0.0s
 => [internal] load build context                                                           0.1s
 => => transferring context: 3.84kB                                                         0.0s
 => [stage-1 1/3] FROM gcr.io/distroless/static:nonroot@sha256:1f580b0a1922c3e54ae15b0758b5747b260bd99d39d40c2edb3e7f6e2452298b     0.0s
 => CACHED [builder 2/9] WORKDIR /workspace                                                 0.0s
 => CACHED [builder 3/9] COPY go.mod go.mod                                                 0.0s
 => CACHED [builder 4/9] COPY go.sum go.sum                                                 0.0s
 => CACHED [builder 5/9] RUN go mod download                                                0.0s
 => CACHED [builder 6/9] COPY main.go main.go                                               0.0s
 => CACHED [builder 7/9] COPY api/ api/                                                     0.0s
 => CACHED [builder 8/9] COPY controllers/ controllers/                                     0.0s
 => CACHED [builder 9/9] RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -a -o manager main.go                                   0.0s
 => CACHED [stage-1 2/3] COPY --from=builder /workspace/manager .                           0.0s
 => exporting to image                                                                      0.0s
 => => exporting layers                                                                     0.0s
 => => writing image sha256:84df51146080fec45fb74d5be29705f41c27de062e1192cb7c43a3a80c22977e                                        0.0s
 => => naming to docker.io/aneasystone/memcached-operator:v0.0.1                            0.0s
docker push aneasystone/memcached-operator:v0.0.1
The push refers to repository [docker.io/aneasystone/memcached-operator]
b399109810db: Pushed 
c456571abc85: Pushed 
v0.0.1: digest: sha256:60822319ac3578e3f62a73530c5ca08472014bf7861b75de6dd88502ee11d088 size: 739

上面我将镜像推送到 Docker 官方镜像仓库 docker.io,你也可以配置成自己的镜像仓库地址。

然后就可以将镜像部署到 Kubernetes 集群中了,官方提供了两种部署方式:直接部署 或 使用 OLM 部署。

直接部署

运行下面的 make deploy 命令:

$ make deploy IMG="aneasystone/memcached-operator:v0.0.1"
test -s ./memcached-operator/bin/controller-gen || GOBIN=./memcached-operator/bin go install sigs.k8s.io/controller-tools/cmd/controller-gen@v0.9.2
./memcached-operator/bin/controller-gen rbac:roleName=manager-role crd webhook paths="./..." output:crd:artifacts:config=config/crd/bases
test -s ./memcached-operator/bin/kustomize || { curl -s "https://raw.githubusercontent.com/kubernetes-sigs/kustomize/master/hack/install_kustomize.sh" | bash -s -- 3.8.7 ./memcached-operator/bin; }
cd config/manager && ./memcached-operator/bin/kustomize edit set image controller=aneasystone/memcached-operator:v0.0.1
./memcached-operator/bin/kustomize build config/default | kubectl apply -f -
namespace/memcached-operator-system created
customresourcedefinition.apiextensions.k8s.io/memcacheds.cache.example.com unchanged
serviceaccount/memcached-operator-controller-manager created
role.rbac.authorization.k8s.io/memcached-operator-leader-election-role created
clusterrole.rbac.authorization.k8s.io/memcached-operator-manager-role created
clusterrole.rbac.authorization.k8s.io/memcached-operator-metrics-reader created
clusterrole.rbac.authorization.k8s.io/memcached-operator-proxy-role created
rolebinding.rbac.authorization.k8s.io/memcached-operator-leader-election-rolebinding created
clusterrolebinding.rbac.authorization.k8s.io/memcached-operator-manager-rolebinding created
clusterrolebinding.rbac.authorization.k8s.io/memcached-operator-proxy-rolebinding created
configmap/memcached-operator-manager-config created
service/memcached-operator-controller-manager-metrics-service created
deployment.apps/memcached-operator-controller-manager created

从日志可以看到部署了一堆的东西,包括一个名字空间:

  • namespace/memcached-operator-system created

一个自定义资源:

  • customresourcedefinition.apiextensions.k8s.io/memcacheds.cache.example.com unchanged

一个 ConfigMap、Service 和 Deployment(这就是我们的 Operator):

  • configmap/memcached-operator-manager-config created
  • service/memcached-operator-controller-manager-metrics-service created
  • deployment.apps/memcached-operator-controller-manager created

还有一堆账户角色这些和安全相关的资源:

  • serviceaccount/memcached-operator-controller-manager created
  • role.rbac.authorization.k8s.io/memcached-operator-leader-election-role created
  • clusterrole.rbac.authorization.k8s.io/memcached-operator-manager-role created
  • clusterrole.rbac.authorization.k8s.io/memcached-operator-metrics-reader created
  • clusterrole.rbac.authorization.k8s.io/memcached-operator-proxy-role created
  • rolebinding.rbac.authorization.k8s.io/memcached-operator-leader-election-rolebinding created
  • clusterrolebinding.rbac.authorization.k8s.io/memcached-operator-manager-rolebinding created
  • clusterrolebinding.rbac.authorization.k8s.io/memcached-operator-proxy-rolebinding created

这些和正常的 Kubernetes 资源是完全一样的,我们可以使用 kubectl get 查询各个资源的详情,注意指定名字空间(-n memcached-operator-system):

$ kubectl get deployment -n memcached-operator-system
NAME                                    READY   UP-TO-DATE   AVAILABLE   AGE
memcached-operator-controller-manager   1/1     1            1           9m6s
$ kubectl get pods -n memcached-operator-system
NAME                                                     READY   STATUS    RESTARTS   AGE
memcached-operator-controller-manager-689d94c9bf-bqv2q   2/2     Running   0          8m54s
$ kubectl get service -n memcached-operator-system
NAME                                                    TYPE        CLUSTER-IP     EXTERNAL-IP   PORT(S)    AGE
memcached-operator-controller-manager-metrics-service   ClusterIP   10.96.197.28   <none>        8443/TCP   11m

同样的,也可以使用 kubectl logs 查看 Operator 的日志:

$ kubectl logs -f memcached-operator-controller-manager-689d94c9bf-bqv2q -n memcached-operator-system

如果要卸载 Operator,执行 make undeploy 命令即可:

$ make undeploy
./memcached-operator/bin/kustomize build config/default | kubectl delete --ignore-not-found=false -f -
namespace "memcached-operator-system" deleted
customresourcedefinition.apiextensions.k8s.io "memcacheds.cache.example.com" deleted
serviceaccount "memcached-operator-controller-manager" deleted
role.rbac.authorization.k8s.io "memcached-operator-leader-election-role" deleted
clusterrole.rbac.authorization.k8s.io "memcached-operator-manager-role" deleted
clusterrole.rbac.authorization.k8s.io "memcached-operator-metrics-reader" deleted
clusterrole.rbac.authorization.k8s.io "memcached-operator-proxy-role" deleted
rolebinding.rbac.authorization.k8s.io "memcached-operator-leader-election-rolebinding" deleted
clusterrolebinding.rbac.authorization.k8s.io "memcached-operator-manager-rolebinding" deleted
clusterrolebinding.rbac.authorization.k8s.io "memcached-operator-proxy-rolebinding" deleted
configmap "memcached-operator-manager-config" deleted
service "memcached-operator-controller-manager-metrics-service" deleted
deployment.apps "memcached-operator-controller-manager" deleted

使用 OLM 部署

OLM 的全称为 Operator Lifecycle Manager,是一款用于 Operator 的管理工具,可以使用 OLM 来帮你安装或更新 Kubernetes Operator。我们首先通过 operator-sdk 安装 OLM:

$ operator-sdk olm install
INFO[0001] Fetching CRDs for version "latest"
INFO[0001] Fetching resources for resolved version "latest"
I0827 15:01:42.199954   12688 request.go:601] Waited for 1.0471208s due to client-side throttling, not priority and fairness, request: GET:https://kubernetes.docker.internal:6443/apis/autoscaling/v1?timeout=32s
INFO[0012] Creating CRDs and resources
INFO[0012]   Creating CustomResourceDefinition "catalogsources.operators.coreos.com"
INFO[0012]   Creating CustomResourceDefinition "clusterserviceversions.operators.coreos.com"
INFO[0012]   Creating CustomResourceDefinition "installplans.operators.coreos.com"
INFO[0012]   Creating CustomResourceDefinition "olmconfigs.operators.coreos.com"
INFO[0012]   Creating CustomResourceDefinition "operatorconditions.operators.coreos.com"
INFO[0012]   Creating CustomResourceDefinition "operatorgroups.operators.coreos.com"
INFO[0012]   Creating CustomResourceDefinition "operators.operators.coreos.com"
INFO[0012]   Creating CustomResourceDefinition "subscriptions.operators.coreos.com"
INFO[0012]   Creating Namespace "olm"
INFO[0012]   Creating Namespace "operators"
INFO[0012]   Creating ServiceAccount "olm/olm-operator-serviceaccount"
INFO[0012]   Creating ClusterRole "system:controller:operator-lifecycle-manager"
INFO[0012]   Creating ClusterRoleBinding "olm-operator-binding-olm"
INFO[0012]   Creating OLMConfig "cluster"
INFO[0015]   Creating Deployment "olm/olm-operator"
INFO[0015]   Creating Deployment "olm/catalog-operator"
INFO[0015]   Creating ClusterRole "aggregate-olm-edit"
INFO[0015]   Creating ClusterRole "aggregate-olm-view"
INFO[0015]   Creating OperatorGroup "operators/global-operators"
INFO[0015]   Creating OperatorGroup "olm/olm-operators"
INFO[0015]   Creating ClusterServiceVersion "olm/packageserver"
INFO[0015]   Creating CatalogSource "olm/operatorhubio-catalog"
INFO[0016] Waiting for deployment/olm-operator rollout to complete
INFO[0016]   Waiting for Deployment "olm/olm-operator" to rollout: 0 of 1 updated replicas are available
INFO[0019]   Deployment "olm/olm-operator" successfully rolled out
INFO[0019] Waiting for deployment/catalog-operator rollout to complete
INFO[0019]   Deployment "olm/catalog-operator" successfully rolled out
INFO[0019] Waiting for deployment/packageserver rollout to complete
INFO[0019]   Waiting for Deployment "olm/packageserver" to rollout: 0 of 2 updated replicas are available
INFO[0033]   Deployment "olm/packageserver" successfully rolled out
INFO[0033] Successfully installed OLM version "latest"

NAME                                            NAMESPACE    KIND                        STATUS
catalogsources.operators.coreos.com                          CustomResourceDefinition    Installed
clusterserviceversions.operators.coreos.com                  CustomResourceDefinition    Installed
installplans.operators.coreos.com                            CustomResourceDefinition    Installed
olmconfigs.operators.coreos.com                              CustomResourceDefinition    Installed
operatorconditions.operators.coreos.com                      CustomResourceDefinition    Installed
operatorgroups.operators.coreos.com                          CustomResourceDefinition    Installed
operators.operators.coreos.com                               CustomResourceDefinition    Installed
subscriptions.operators.coreos.com                           CustomResourceDefinition    Installed
olm                                                          Namespace                   Installed
operators                                                    Namespace                   Installed
olm-operator-serviceaccount                     olm          ServiceAccount              Installed
system:controller:operator-lifecycle-manager                 ClusterRole                 Installed
olm-operator-binding-olm                                     ClusterRoleBinding          Installed
cluster                                                      OLMConfig                   Installed
olm-operator                                    olm          Deployment                  Installed
catalog-operator                                olm          Deployment                  Installed
aggregate-olm-edit                                           ClusterRole                 Installed
aggregate-olm-view                                           ClusterRole                 Installed
global-operators                                operators    OperatorGroup               Installed
olm-operators                                   olm          OperatorGroup               Installed
packageserver                                   olm          ClusterServiceVersion       Installed
operatorhubio-catalog                           olm          CatalogSource               Installed

如上所示,OLM 会在 Kubernetes 集群中安装一堆的资源,可以看到 OLM 本身也包含了两个 Operator:OLM Operator 和 Catalog Operator。关于他们的作用可以参考 《如何管理越来越多的 operator?OLM 给你答案》 这篇文章。

OLM 通过 Bundle 形式来组织和管理 Operator,使用 make bundle 生成 Bundle 相关的配置文件:

$ make bundle IMG="aneasystone/memcached-operator:v0.0.1"
test -s ./memcached-operator/bin/controller-gen || GOBIN=./memcached-operator/bin go install sigs.k8s.io/controller-tools/cmd/controller-gen@v0.9.2
./memcached-operator/bin/controller-gen rbac:roleName=manager-role crd webhook paths="./..." output:crd:artifacts:config=config/crd/bases
test -s ./memcached-operator/bin/kustomize || { curl -s "https://raw.githubusercontent.com/kubernetes-sigs/kustomize/master/hack/install_kustomize.sh" | bash -s -- 3.8.7 ./memcached-operator/bin; }
operator-sdk generate kustomize manifests -q

Display name for the operator (required):
> memcached-operator

Description for the operator (required):
> memcached operator

Provider's name for the operator (required):
> aneasystone

Any relevant URL for the provider name (optional):
> https://www.aneasystone.com

Comma-separated list of keywords for your operator (required):
> memcached

Comma-separated list of maintainers and their emails (e.g. 'name1:email1, name2:email2') (required):
> aneasystone@gmail.com
cd config/manager && ./memcached-operator/bin/kustomize edit set image controller=aneasystone/memcached-operator:v0.0.1
./memcached-operator/bin/kustomize build config/manifests | operator-sdk generate bundle -q --overwrite --version 0.0.1
INFO[0001] Creating bundle.Dockerfile
INFO[0001] Creating bundle/metadata/annotations.yaml
INFO[0001] Bundle metadata generated suceessfully
operator-sdk bundle validate ./bundle
INFO[0001] All validation tests have completed successfully

然后将 Bundle 构建成镜像并推送到镜像仓库:

$ make bundle-build bundle-push BUNDLE_IMG="aneasystone/memcached-operator-bundle:v0.0.1"
docker build -f bundle.Dockerfile -t aneasystone/memcached-operator-bundle:v0.0.1 .
[+] Building 0.6s (7/7) FINISHED
 => [internal] load build definition from bundle.Dockerfile                                                            0.1s
 => => transferring dockerfile: 971B                                                                                   0.0s
 => [internal] load .dockerignore                                                                                      0.1s
 => => transferring context: 35B                                                                                       0.0s
 => [internal] load build context                                                                                      0.0s
 => => transferring context: 12.72kB                                                                                   0.0s
 => [1/3] COPY bundle/manifests /manifests/                                                                            0.0s
 => [2/3] COPY bundle/metadata /metadata/                                                                              0.1s
 => [3/3] COPY bundle/tests/scorecard /tests/scorecard/                                                                0.1s
 => exporting to image                                                                                                 0.1s
 => => exporting layers                                                                                                0.1s
 => => writing image sha256:849fde8bbc55db7a1cd884ccdc7c61bfdca343650f72eb65e616c98c17193bca                           0.0s
 => => naming to docker.io/aneasystone/memcached-operator-bundle:v0.0.1                                                0.0s
make docker-push IMG=aneasystone/memcached-operator-bundle:v0.0.1
make[1]: Entering directory './memcached-operator'
docker push aneasystone/memcached-operator-bundle:v0.0.1
The push refers to repository [docker.io/aneasystone/memcached-operator-bundle]
ee3ff18c6586: Pushed
1cca854eb4c8: Pushed
2fa3c5f0ef35: Pushed
v0.0.1: digest: sha256:c42ec3c4f9d461128c640f5568886b006e0332ea0d4a173008e97addefbfd3f9 size: 939
make[1]: Leaving directory './memcached-operator'

运行 Bundle 将我们的 Operator 部署到 Kubernetes 集群中:

$ operator-sdk run bundle docker.io/aneasystone/memcached-operator-bundle:v0.0.1
INFO[0023] Creating a File-Based Catalog of the bundle "docker.io/aneasystone/memcached-operator-bundle:v0.0.1"
INFO[0028] Generated a valid File-Based Catalog
INFO[0033] Created registry pod: docker-io-aneasystone-memcached-operator-bundle-v0-0-1
INFO[0033] Created CatalogSource: memcached-operator-catalog
INFO[0033] OperatorGroup "operator-sdk-og" created
INFO[0033] Created Subscription: memcached-operator-v0-0-1-sub
INFO[0037] Approved InstallPlan install-z264c for the Subscription: memcached-operator-v0-0-1-sub
INFO[0037] Waiting for ClusterServiceVersion "default/memcached-operator.v0.0.1" to reach 'Succeeded' phase
INFO[0037]   Waiting for ClusterServiceVersion "default/memcached-operator.v0.0.1" to appear
INFO[0056]   Found ClusterServiceVersion "default/memcached-operator.v0.0.1" phase: Pending
INFO[0058]   Found ClusterServiceVersion "default/memcached-operator.v0.0.1" phase: Installing
INFO[0069]   Found ClusterServiceVersion "default/memcached-operator.v0.0.1" phase: Succeeded
INFO[0069] OLM has successfully installed "memcached-operator.v0.0.1"

可以使用 kubectl get 检查 Operator 运行的状态,和上一节直接部署不一样的是,Operator 被安装在默认的 default 名字空间里了,其他的几乎没啥区别。可以更新 config/samples/cache_v1alpha1_memcached.yaml 文件来对 Operator 进行测试。

如果要卸载 Operator,执行下面的命令:

$ operator-sdk cleanup memcached-operator

卸载 OLM:

$ operator-sdk olm uninstall

使用 kubernetes-sigs/kubebuilder 开发 Operator

operator-sdkkubebuilder 都是为了方便用户创建和管理 Operator 而生的脚手架项目,其实 operator-sdk 在底层也使用了 kubebuilder,比如 operator-sdk 的命令行工具就是直接调用 kubebuilder 的命令行工具。无论由 operator-sdk 还是 kubebuilder 创建的 Operator 项目都是调用的 controller-runtime 接口,具有相同的项目目录结构。

参考

  1. Kubernetes 文档 / 概念 / 扩展 Kubernetes / Operator 模式
  2. Kubernetes Operator 基础入门
  3. Kubernetes Operator 快速入门教程
  4. Kubernetes Operators 入门笔记
  5. 亲历者说:Kubernetes API 与 Operator,不为人知的开发者战争
  6. 《Kubernetes Operators eBook》By Jason Dobies & Joshua Wood
  7. Quickstart for Go-based Operators
  8. What is a Kubernetes operator?
  9. Introducing Operators: Putting Operational Knowledge into Software
  10. Kubernetes Operators 101, Part 1: Overview and key features
  11. Kubernetes Operators 101, Part 2: How operators work
  12. 如何管理越来越多的 operator?OLM 给你答案

更多

1. 安装 gcc 报 404 Not Found 错

在 Ubuntu 上使用 sudo apt install gcc 安装 gcc 时,报如下错误:

E: Failed to fetch http://security.ubuntu.com/ubuntu/pool/main/l/linux/linux-libc-dev_4.15.0-189.200_amd64.deb  404  Not Found [IP: 2001:67c:1562::15 80]
E: Unable to fetch some archives, maybe run apt-get update or try with --fix-missing?

解决方法很简单,执行 sudo apt update 更新软件源中的所有软件列表即可。

2. 使用 operator-sdk 创建 API 报错

执行 operator-sdk create api 命令创建 API 时,报如下错误:

/usr/local/go/src/net/cgo_linux.go:12:8: no such package located
Error: not all generators ran successfully
run `controller-gen object:headerFile=hack/boilerplate.go.txt paths=./... -w` to see all available markers, or `controller-gen object:headerFile=hack/boilerplate.go.txt paths=./... -h` for usage
Makefile:94: recipe for target 'generate' failed
make: *** [generate] Error 1
Error: failed to create API: unable to run post-scaffold tasks of "base.go.kubebuilder.io/v3": exit status 2

没有安装 gcc 工具,使用 sudo apt install gcc 安装 gcc 即可。

3. make buildmake test 时报错

在公司电脑开发 Operator 时遇到了这个问题,执行 make buildmake test 时报下面这样的错:

STEP: bootstrapping test environment
1.6621765789962418e+09  DEBUG   controller-runtime.test-env     starting control plane

1.6621765802518039e+09  ERROR   controller-runtime.test-env     unable to start the controlplane        {"tries": 0, "error": "timeout waiting for process etcd to start successfully (it may have failed to start, or stopped unexpectedly before becoming ready)"}

看报错信息猜测可能是和 etcd 有关,使用 ps aux | grep etcd 确实可以看到在执行测试时启动了一个 etcd 的进程:

$ ps aux | grep etcd
aneasystone  2609 23.0  0.1 737148 22560 pts/0    Sl   13:34   0:00 /home/aneasystone/.local/share/kubebuilder-envtest/k8s/1.24.1-linux-amd64/etcd --advertise-client-urls=http://127.0.0.1:52467 --data-dir=/tmp/k8s_test_framework_3831360890 --listen-client-urls=http://127.0.0.1:52467 --listen-peer-urls=http://127.0.0.1:52468 --unsafe-no-fsync=true

于是我试着手工运行这个命令,发现 etcd 服务启动时报错了:

2022-09-03 11:42:28.499748 E | etcdserver: publish error: etcdserver: request timed out
2022-09-03 11:42:35.501458 E | etcdserver: publish error: etcdserver: request timed out

使用 etcdctl 也连不上该 etcd 服务。一开始我以为是 kubebuilder 自带的 etcd 文件有问题,于是就自己安装了一个 etcd,直接运行时也是报错,只不过报错信息有点不一样:

panic: invalid page type: 0: 4

goroutine 1 [running]:

github.com/etcd-io/bbolt.(*Cursor).search(0xc00005be18, {0xc00005be70, 0x8, 0x8}, 0xc00005bdb8?)

看报错是位于 etcd-io/bbolt 这个包,BoltDB 是 etcd 使用的内存 KV 数据库。使用 boltpanic: invalid page type 为关键字,很快就在 microsoft/WSL 里找到了一个相关的 Issue:BoltDB panics on cursor search since April update,根据 Issue 里的描述,写了一个 BoltDB 的简单示例:

package main

import (
    "fmt"
    "log"
    "os"

    "github.com/boltdb/bolt"
)

func main() {
    os.Remove("test.db")
    db, err := bolt.Open("test.db", 0600, nil)
    if err != nil {
        log.Fatal(err)
    }
    defer db.Close()

    db.Update(func(tx *bolt.Tx) error {
        _, err := tx.CreateBucket([]byte("MyBucket"))
        if err != nil {
            return fmt.Errorf("create bucket: %s", err)
        }
        return err
    })

    db.View(func(tx *bolt.Tx) error {
        b := tx.Bucket([]byte("MyBucket"))
        c := b.Cursor()
        c.Seek([]byte("test"))
        return nil
    })
    os.Remove("test.db")
}

运行代码后也是和上面几乎一模一样的报错:

$ go run main.go
panic: invalid page type: 0: 4

goroutine 1 [running]:
github.com/boltdb/bolt.(*Cursor).search(0xc00005be18, {0xc00005be70, 0x8, 0x8}, 0xc00005bdb8?)

至此大概可以推断这应该是 WSL 的问题,WSL 目前最新版本是 WSL 2,不过要注意的是,根据 Microsoft 官方的升级指南,WSL 2 只支持 Windows 10 Build 18362 之后的版本:

Builds lower than 18362 do not support WSL 2. Use the Windows Update Assistant to update your version of Windows.

打开 Windows 更新,更新完成后重启,问题解决。

4. Operator 示例

扫描二维码,在手机上阅读!

使用 qiankun 开发微前端应用

微前端(Micro Frontends) 这个概念是在 2016 年底的时候在 ThoughtWorks Technology Radar 上首次提出来的,它将服务端的微服务概念延伸到前端领域。随着应用规模的不断变大,传说中的 SPA(单页面应用)会变得越来越复杂,也越来越难以维护。这样大规模的前端应用一般都是由很多相对独立的功能模块组合而成,且不同的功能模块由不同的团队负责,根据分而治之的思想,于是就有了将这些功能模块拆分成不同前端项目的想法,微前端技术也就此诞生。

qiankun 是阿里开源的一款微前端框架,它的灵感来自于 single-spa 项目,号称 可能是你见过最完善的微前端解决方案。single-spa 于 2018 年诞生,也是一个用于前端微服务化的解决方案,它实现了路由劫持和应用加载,不过它的缺点是不够灵活,不能动态加载 js 文件,而且没有处理样式隔离,不支持 js 沙箱机制。qiankun 于 2019 年开源,提供了更加开箱即用的 API (single-spa + sandbox + import-html-entry),它基于 single-spa,具备 js 沙箱、样式隔离、HTML Loader、预加载 等微前端系统所需的能力。qiakun 升级 2.0 后,支持多个微应用的同时加载,有了这个特性,我们基本可以像接入 iframe 一样方便的接入微应用。

官方示例

qiankun 的源码里提供了大量完整的示例项目,我们先来体验体验这些示例,感受下微前端的魅力。首先,将 qiankun 的代码 clone 到本地:

$ git clone https://github.com/umijs/qiankun.git

qiankun 使用 Yarn 构建和打包项目,首先安装 Yarn:

$ npm install -g yarn

然后安装 qiankun 框架所依赖的包以及示例项目:

$ yarn install
$ yarn examples:install

示例项目中包含了各种不同框架的实现,比如 VueVue 3React 15React 16Angular 9 以及使用 jQuery 实现的纯 HTML 项目,Yarn 会依次安装各个示例项目的依赖包,整个过程会比较长,安装完成之后,使用下面的命令运行示例项目:

$ yarn examples:start

然后打开浏览器,访问 http://localhost:7099/

example.gif

或者使用下面的命令运行 multiple demo

$ yarn examples:start-multiple

qiankun-multiple-demo.png

开发实战

这一节我们将从零开始,使用 qiankun 搭建一个简单的微前端项目,这个项目包括一个主应用和两个微应用。这里为了简单起见,两个微应用都是使用 Vue 开发,但是实际上,微前端对微应用的技术栈是不限的,微应用完全可以独立开发。

准备主应用

我们直接使用 vue-cli 创建一个 Vue 脚手架项目,首先确保已安装 Node.js 环境:

$ node -v
v16.14.2

$ npm -v
8.5.0

然后安装最新版本的 vue-cli

$ npm install -g @vue/cli

$ vue -V
@vue/cli 5.0.8

使用 vue-cli 创建 demo 项目:

$ vue create demo

?  Your connection to the default npm registry seems to be slow.  
   Use https://registry.npmmirror.com for faster installation? Yes

Vue CLI v5.0.8
? Please pick a preset: Default ([Vue 3] babel, eslint)

Vue CLI v5.0.8
✨  Creating project in D:\code\weekly-practice\notes\week017-qiankun-micro-frontends\demo.
⚙️  Installing CLI plugins. This might take a while...

added 849 packages in 36s
🚀  Invoking generators...
📦  Installing additional dependencies...

added 95 packages in 11s
⚓  Running completion hooks...

📄  Generating README.md...

🎉  Successfully created project demo.      
👉  Get started with the following commands:

 $ cd demo
 $ npm run serve

使用 npm run serve 即可启动项目,启动成功后在浏览器中访问 http://localhost:8080/

vue-demo.png

准备微应用

然后照葫芦画瓢,使用 vue-cli 创建 app1 和 app2 项目:

$ vue create app1

Vue CLI v5.0.8
? Please pick a preset: Default ([Vue 3] babel, eslint)
? Pick the package manager to use when installing dependencies: Yarn

Vue CLI v5.0.8
✨  Creating project in D:\code\weekly-practice\notes\week017-qiankun-micro-frontends\app1.
⚙️  Installing CLI plugins. This might take a while...

yarn install v1.22.19
info No lockfile found.
[1/4] Resolving packages...
[2/4] Fetching packages...
[3/4] Linking dependencies...

success Saved lockfile.
Done in 22.33s.
🚀  Invoking generators...
📦  Installing additional dependencies...

yarn install v1.22.19
[1/4] Resolving packages...
[2/4] Fetching packages...
[3/4] Linking dependencies...
[4/4] Building fresh packages...
success Saved lockfile.
Done in 7.88s.
⚓  Running completion hooks...

📄  Generating README.md...

🎉  Successfully created project app1.
👉  Get started with the following commands:

 $ cd app1
 $ yarn serve

使用 vue-cli 创建的项目默认端口是 8080,为了不和主应用冲突,需要修改 vue.config.js 配置文件,将微应用的端口修改为 8081 和 8082:

const { defineConfig } = require('@vue/cli-service')
module.exports = defineConfig({
  transpileDependencies: true,
  devServer: {
    port: 8081
  }
})

改造主应用

一切准备就绪后,接下来我们就开始将主应用改造成微前端架构。首先在主应用安装 qiankun 依赖:

$ npm i qiankun -S

然后在 main.js 文件中注册微应用:

import { registerMicroApps, start } from 'qiankun';

registerMicroApps([{
  name: 'app1',
  entry: '//localhost:8081',
  container: '#app1',
  activeRule: '/app1'
}, {
  name: 'app2',
  entry: '//localhost:8082',
  container: '#app2',
  activeRule: '/app2'
}]);

start();

只需这几行代码,微应用就注册好了。当 url 发生变化时,qiankun 会根据 activeRule 规则自动匹配相应的微应用,并将其插入到指定的 DOM 容器(container)中。我们在 public/index.html 里为每个微应用准备一个容器:

    <div id="app"></div>
    <div id="app1"></div>
    <div id="app2"></div>

改造微应用

不过此时主应用还无法加载微应用,我们需要对微应用做两处改造。首先,微应用需要在自己的入口 js (通常就是你配置的 webpack 的 entry js) 导出 bootstrapmountunmount 三个生命周期钩子,以供主应用在适当的时机调用。打开文件 main.js,添加如下代码:

let instance = null
function render() {
  instance = createApp(App).mount('#app')
}

if (!window.__POWERED_BY_QIANKUN__) { // 默认独立运行
  render();
}

export async function bootstrap(props) {
  console.log('bootstrap app1', props)
}
export async function mount(props) {
  console.log('mount app1', props)
  render()
}
export async function unmount(props) {
  console.log('unmount app1', props)
  console.log(instance)
}

其中我们可以通过 window.__POWERED_BY_QIANKUN__ 来区分微应用是自启动的还是由 qiankun 加载的,这样可以让微应用在两种模式下都兼容。

注意,网上有很多示例在 unmount 中会调用 instance.$destroy() 来销毁实例,但是在 Vue 3 中 instance.$destroy() 方法已经废弃了。

其次,我们需要将微应用改为以 umd 的方式打包,并注意设置 'Access-Control-Allow-Origin':'*' 允许跨域访问:

const { defineConfig } = require('@vue/cli-service')
module.exports = defineConfig({
  transpileDependencies: true,
  devServer: {
    port: 8081,
    headers:{
      'Access-Control-Allow-Origin':'*'
    }
  },
  configureWebpack:{
    output:{
      library: `app1`,
      libraryTarget: 'umd'
    }
  }
})

运行

主应用和微应用都改造完成后,依次运行,然后在浏览器中依次访问,确保每个应用都可独立访问。另外,由于我们在主应用中加载了微应用,使用 http://localhost:8080/app1http://localhost:8080/app2 应该也可以访问微应用:

demo-micro-app1.png

使用 Vue Router 切换微应用

为了更方便地构建单页面应用(SPA),在现代的 Web 框架中,几乎都有 路由 的概念,一般用在左侧菜单或顶部导航上。Vue Router 就是 Vue.js 的官方路由。首先在主应用中安装 vue-routerelement-plus

$ npm i vue-router -S
$ npm i element-plus -S

main.js 中注册路由:

import { createRouter, createWebHistory } from 'vue-router'
import HelloWorld from './components/HelloWorld.vue'
import About from './components/About.vue'

const router = createRouter({
  history: createWebHistory(),
  routes: [
    { path: '/hello', component: HelloWorld },
    { path: '/about', component: About }
  ]
})

import ElementPlus from 'element-plus'
import 'element-plus/dist/index.css'

createApp(App).use(router).use(ElementPlus).mount('#app')

主应用的代码修改如下:

<template>
  <div>
    <h1>Hello qiankun!</h1>
    <el-menu :router="true" mode="horizontal">
      <el-menu-item index="/hello">Hello</el-menu-item>
      <el-menu-item index="/about">About</el-menu-item>
      <el-menu-item index="/app1">App1</el-menu-item>
      <el-menu-item index="/app2">App2</el-menu-item>
    </el-menu>
    <router-view></router-view>
    <div id="app1"></div>
    <div id="app2"></div>
  </div>
</template>

为什么切换微应用时,导航消失了?而不是加载在导航下面的容器中?

这是因为主应用的容器 id 和 微应用的容器 id 都叫 app,所以冲突了,将主应用的 id 修改为 demo 即可。

运行之后,在浏览器里访问主应用,然后就可以通过导航菜单来访问微应用了:

vue-router.png

参考

  1. qiankun 官方文档
  2. qiankun 技术圆桌 | 分享一些 qiankun 开发及微前端实践过程中的心得
  3. 万字长文-落地微前端 qiankun 理论与实践指北
  4. Micro Frontends | extending the microservice idea to frontend development
  5. single-spa
  6. 微前端框架 之 single-spa 从入门到精通
  7. 微前端框架 之 qiankun 从入门到源码分析
  8. 微前端实战 - 基于 qiankun 的最佳实践
扫描二维码,在手机上阅读!