Java 应用容器化与部署

如何开始打包、分发并将 Java 交付至生产环境

应用程序的容器化 提供了一种方法,可以将所有必需的应用程序资源——包括程序和配置文件、环境变量、网络设置等——组合到一个标准化、易于管理的包中。

从单个容器镜像可以启动、运行、管理和终止多个功能相同的容器,确保从镜像创建点开始的一致性。容器可以在截然不同的操作平台上运行,从本地机器到全球可扩展的云环境,以及介于两者之间的一切。可以构建流水线,轻松地在它们之间过渡。
虽然应用程序容器化有许多好处,但许多都可以归结为一个词:一致性

为何要对 JAVA 应用进行容器化?

Java 早期的一个承诺是"一次编写,到处运行",即"WORA"。尽管 Java 通过其 Java 虚拟机(JVM)实现了某种形式的目标,但在实现真正无缝的体验方面仍然存在相当多的外部障碍。
容器化解决了几乎所有这些外部障碍。虽然 100% 在任何追求中都可能是一个难以实现的目标,但将 Java 应用程序的可执行文件及其所有必需的依赖项和支持属性(配置等)打包的能力,使我们达到了有效的 100% 可移植性和一致性水平。

为 JAVA 应用程序创建 DOCKERFILE

许多开发人员通过仔细阅读官方的 Dockerfile 参考文档来开始他们的容器化工作。为了立即获得良好效果,让我们介绍关键点,创建一些镜像,并在此基础上进行构建。

为容器化选择操作系统和 JDK 构建

对此有各种不同的观点,但如果您刚开始接触容器化,从一个较小但完整的操作系统(OS)开始是一个很好的第一步。我们稍后将讨论其他选项(例如,无发行版)。
一般来说,您在操作系统层包含的内容越多,容器镜像就越大,安全漏洞的攻击面也就越大。可信来源也是一个关键的考虑因素。如果使用完整的操作系统构建,强烈推荐使用 eclipse-temurin(基于 Ubuntu)或 Alpine 基础层。
任何 OpenJDK 的构建都能运行您基于 JVM 的 Java 应用程序,而 Eclipse Temurin 是众多良好选项之一。但是,如果您希望对可能发现的任何 Java 问题获得专门的生产支持,那么选择商业支持的构建可以提供这种支持。

JAVA 应用的基本 DOCKERFILE 结构

一个基本 Java 应用程序的最低可行 Dockerfile 看起来像这样:

FROM eclipse-temurin:latest
COPY java-in-the-can-0.0.1-SNAPSHOT.jar /app.jar
EXPOSE 8080
CMD ["java", "-jar", "/app.jar"]

将上述文本(在 COPY 指令中使用您应用程序的名称)保存在一个名为 Dockerfile 的文件中,该文件与您的 Java 应用程序(.jar)文件位于同一目录。
在上面的 Dockerfile 中,我们提供了构建容器镜像的基本信息:

  • 构建应用程序容器镜像所基于的更高层基础镜像FROM
  • 将 .jar 文件复制COPY)到镜像中(在此示例中,还进行了重命名)的命令
  • 为应用程序监听连接请求而需要暴露EXPOSE)的任何特定端口(如有必要)
  • 在容器启动时运行应用程序的命令CMD
    在包含您的 Dockerfile 和 .jar 文件的目录中执行以下命令:
docker build -t <app-image-name> .

请注意,在运行镜像创建和其他容器命令之前,docker 守护进程(或 Mac/Windows 上的 Docker Desktop、Podman 等)必须正在运行。另外,不要忘记命令末尾的 .;它指的是可以找到 Dockerfile 的当前目录。
以这种方式运行生成的应用程序容器,用您上面创建的容器镜像名称替换 <app-image-name>

docker run -p 8080:8080 <app-image-name>

选择无发行版操作系统+JDK 基础镜像

对于大多数用例,在大小和攻击面方面可实现的最佳优化可能由"无发行版"基础镜像提供。虽然无发行版基础镜像中确实包含一个 Linux 发行版,但它已被剥离了任何非当前目的 specifically 不需要的文件,留下一个完全精简的操作系统,对于无发行版 Java 镜像而言,还包括 JVM。以下是一个使用无发行版 Java 基础镜像的 Dockerfile 示例:

FROM mcr.microsoft.com/openjdk/jdk:21-distroless
COPY java-in-the-can-0.0.1-SNAPSHOT.jar /app.jar
EXPOSE 8080
CMD ["-Xmx256m", "-jar", "/app.jar"]

请注意,这个针对 Java 优化的基础镜像预先配置了 java 命令的 ENTRYPOINT,因此 CMD 指令用于为 JVM 启动器进程提供命令行参数。

使用多阶段构建来减小镜像大小

如果您有构建所需但最终输出不需要的文件,多阶段构建提供了减小容器镜像大小的方法。就本文参考而言,情况并非如此,因为 JVM 以及应用程序的 .jar 文件和依赖项已为镜像创建预先配置好了。
正如您可能想象的那样,在某些非常常见的情况下,这变得有利。通常,应用程序通过配置好的构建流水线部署到生产环境,这些流水线基于源仓库上的触发器来创建构件。这是多阶段构建的最佳用例之一:构建流水线创建一个带有适当工具的构建容器,使用它来创建构件(例如 .jar 文件、配置文件),然后将这些构件复制到一个新的容器镜像中,该镜像不包含生产环境不需要的额外工具。这一系列操作大致类似于我们之前手动完成的操作,但实现了自动化,以获得一致和最优的结果。

管理环境变量

有多种方法可以向容器和应用程序提供输入值,用于启动或执行。一个应该采用的良好实践是,尽可能使用 ENVENTRYPOINTCMD 指令在 Dockerfile 本身中指定所有可能的值。所有这些值都可以在容器初始化时根据需要覆盖。
请注意,覆盖现有环境变量时应谨慎,因为这可能会以意外和不良的方式改变应用程序行为。
使用 ENV 配置 Java 特定选项的示例:

ENV JAVA_OPTS="-Xmx512m -Xms256m"

相同的概念也适用于应用程序特定变量:

ENV APP_GREETING="Greetings, Friend!"

使用 ENTRYPOINT 配置应用程序特定值的示例:

ENTRYPOINT ["java", "$JAVA_OPTS", "-jar", "your-app.jar"]

使用 CMD 的示例:

CMD ["java", "-Xmx256m", "-jar", "/app.jar"]

您可能已经注意到,ENTRYPOINTCMD 都可以用来执行 Java 应用程序。像所有其他技术(和非技术)选项一样,这两种指令各有优缺点。如果操作得当,两者都会使您的 Java 应用程序运行。
一般来说,Java 应用程序使用 CMD 指令,以便应用程序可以处理操作系统信号,用于支持的钩子机制(例如,SIGTERM 对应 java.lang.Runtime.addShutdownHook)。当然,这并非绝对必要,并且可以(也经常)使用 ENTRYPOINTCMD 来促进运行时参数传递,以提供/覆盖特定行为。这两者并不互斥。

使用 SPRING BOOT 插件进行容器化

如果您使用 Spring Boot 开发 Java 应用程序,容器化会简单得多。无论使用 Maven 还是 Gradle 作为项目构建工具,创建容器镜像都像执行一个预定义的目标一样简单。

  • 如果使用 Maven 作为构建工具,您可以通过调用 build-image 目标来创建包含应用程序的容器镜像:
    ./mvnw spring-boot:build-image
  • 如果使用 Gradle 作为构建工具,您可以通过调用 bootBuildImage 目标来创建包含应用程序的容器镜像:
    ./gradlew bootBuildImage
    在大多数情况下,自定义镜像创建(例如,镜像层定义)既不必要也不可取,但如果需要这样做,请参阅 Spring Boot Maven 或 Gradle 插件文档中的"打包 OCI 镜像"部分。

为原生应用程序构建容器镜像

开发人员可以选择使用 JVM 或作为原生的、操作系统特定的可执行文件来交付 Java 应用程序。以下部分提供了一些关于选择的考虑因素,以及如果您决定使用原生应用程序构建容器镜像,如何以最小的代价实现。

使用 GRAALVM 的 JAVA 原生可执行文件

GraalVM 支持创建原生可执行文件/二进制 Java 应用程序,在构建时执行所有编译和优化,而不是利用 JVM 在运行应用程序字节码时进行一些优化。
与所有选择一样,需要权衡利弊。编译为字节码与原生可执行文件是秒与分钟的问题,并且 JVM 执行的运行时优化在原生可执行文件中消失了,因为代码无法在运行时动态重写(这是 JVM 启用的一个特性)。
原生可执行文件优于基于 JVM 的 Java 应用程序的地方在于文件大小、内存需求和启动时间。原生应用程序要小得多,需要的资源更少,不需要 JVM 存在,并且启动速度显著更快。这在许多生产环境中是非常重要的考虑因素,因为较小的应用程序(及其容器)会降低平台资源需求,并且以毫秒而不是几秒衡量的启动时间可以增加可用性、可扩展性以及系统设计和部署的选项,从而可以显著节省成本。
根据您的框架和工具选择,有几种构建完全可执行的、操作系统原生的 Java 应用程序的选项。然而,一旦您有了原生可执行文件/二进制应用程序,您可以创建一个类似以下的 Dockerfile 作为您的原生应用程序容器镜像的模板:

FROM alpine:latest
WORKDIR /app
COPY java-in-the-can /app/
EXPOSE 8080
CMD ["/app/java-in-the-can"]

如果您使用 Spring Boot,您可以使用 GraalVM Maven 或 Gradle 插件,通过一条命令将您的应用程序编译为操作系统原生应用程序并创建容器镜像。

MAVEN
首先,将此依赖项添加到您的 pom.xml<build><plugins> 部分并保存文件:

<plugin>
    <groupId>org.graalvm.buildtools</groupId>
    <artifactId>native-maven-plugin</artifactId>
</plugin>

要构建原生应用程序和容器镜像,从您的项目根目录运行此命令:
./mvnw -Pnative spring-boot:build-image

GRADLE
类似地,将此依赖项添加到您的 build.gradle 文件的 plugins {} 部分并保存:
id 'org.graalvm.buildtools.native'

要构建原生应用程序和容器镜像,从您的项目根目录运行此命令:
./gradlew bootBuildImage

关于减小镜像大小和加快启动时间的考虑因素

您可能已经注意到,上述各节的顺序总体上趋向于生产更精简、启动更快的容器镜像。许多决策可能涉及组织标准或选择(例如,部署标准),这些标准或选择会使天平倾向于或反对某些选择,但一般来说,容器镜像优化的路径遵循以下顺序:

  1. 选择更小的基础镜像(操作系统发行版和 JVM)
  2. 选择带有 JVM 的无发行版镜像
  3. 如果您的工具链(例如 Spring Boot)允许,利用专门构建的工具
  4. 利用带有原生可执行应用程序的精简发行版或无发行版镜像

容器中 JAVA 应用程序的部署策略

您的应用程序的重要考虑因素超出了将其打包成应用程序容器镜像的范围。接下来是部署和维护决策,这些决策对于您的应用程序进入并保持在生产环境至关重要。

单容器部署

对于基本上是自包含的应用程序,部署到生产环境可以像单个命令一样简单,前提是部署目标已准备好接受容器化应用程序。即使在应用程序部署之前必须创建支持资源的情况下,这通常也意味着通过命令行或 Web 门户发出少量指令。当应用程序包含多个容器部署时,进程间依赖关系可能要求按特定顺序部署容器,以确保可用性或最小化波动或 chatter。为了实现这些目标,需要进行编排部署。

编排部署

编排部署可能比单容器部署复杂得多,并相应地提供更多功能。由于这两个特点,与单容器部署相比,编排部署可能有更多平台层级值得考虑。这些层级范围从提供广泛灵活性并相应需要开发人员付出更高水平努力的较低层级 Kubernetes 平台,到完成大量繁重工作以安全配置和集成多个容器和/或服务的完整平台。
您选择的目标平台将决定您对部署工具(例如,脚本、门户、基础设施配置工具)的选择。非常笼统地说,您选择的平台目标应该是能够部署和维护您的应用程序及其相关服务的最简单的平台。其他重要的考虑因素包括应用程序所有必需容器/服务的部署目标之间的比较成本、您组织已建立的实践/流水线等。

构建支持持续打补丁的 JAVA 应用程序

生产部署在应用程序上线后并未完成;开发人员必须确保应用程序保持安全、最新和可用。关键的补丁管理考虑因素包括:

  • 定期补丁 – 为常规补丁(例如,每月或每季度)建立一个无中断、可预测的频率,以更新库和依赖项
  • 紧急补丁 – 提供关于何时需要紧急补丁的指导,通常是为了应对关键漏洞或紧急安全更新
    容器镜像中需要打补丁的组件包括:
  • 基础操作系统容器镜像
  • 附加的操作系统包(如果适用)
  • 应用程序运行时(例如,JVM 版本,如果未包含在基础镜像中)
  • 应用程序依赖项/库
  • 应用程序性能监控(APM)代理二进制文件
    由开发人员及其组织来决定并严格维护一个保护应用程序、系统基础设施和数据的补丁策略。请参考此指南来帮助制定您的具体策略。

结论

容器化使开发人员能够将所有必需的应用程序资源和支持服务组合到一个或多个容器镜像中,并更轻松地部署、运行和管理它们。如果操作得当,容器化可以从镜像创建点开始实现安全性和一致性。容器可以在截然不同的操作平台上运行,从本地机器到全球可扩展的云环境。可以构建流水线,轻松地在它们之间过渡。因此,开发人员构建和运行支持生产工作负载的相同构件,减少了冲突并简化了调优和故障排除。
如果您是容器新手,请从小处着手,在本地构建以获得知识和稳定的基础,然后通过纳入更多容器最佳实践、构建流水线和合适的云平台来"扩展构建",逐步走向强大的应用程序部署生产模型。

其他考虑因素和资源:

作者:MARK A. HECKLER,
微软首席云技术推广专家(Java/JVM 语言)
Mark Heckler 是一名软件开发人员,微软的 Java/JVM 语言首席云技术推广专家,会议演讲者,Java Champion 和 Kotlin 开发专家,专注于为云和边缘计算平台快速开发生产软件。Mark 是开源贡献者,也是《Spring Boot: Up and Running》的作者,可以在 X @mkheck 上找到他。


【注】本文译自:Java Application Containerization and Deployment

Spring Data JPA 最佳实践【2/2】:存储库设计指南

Spring Data JPA(系列文章共 2 篇)

  1. Spring Data JPA 最佳实践【1/2】:实体设计指南
  2. Spring Data JPA 最佳实践【2/2】:存储库设计指南

在本系列文章中,我将分享我对重构一个采用了大量不良实践的大型遗留代码库的看法。为了解决这些问题并开发出更好的 Spring Data JPA 存储库,我撰写了这份指南,旨在向我之前的同事们推广良好的开发实践。本指南已更新并完全重写,以利用 Spring Data JPA 的最新特性。

有些例子可能看起来显而易见,但事实并非如此。这只是从你经验丰富的角度来看的。它们都是来自生产代码库的真实案例。

请记住,本系列文章讲解的是最新版本的 Spring Data JPA,因此可能会有一些我特别指出的细微差别。

1 设计 Spring Data JPA 存储库

Spring Data JPA 提供了几个带有预定义数据获取方法的存储库接口。我这里只提几个值得关注的:

  • Repository<T, ID> 接口是 Spring Data 接口的父接口,是一个用于发现的标记接口。它没有任何方法。使用时,你只需定义你所需的内容。
  • CrudRepository 接口添加了基本的 CRUD 方法以加快开发速度,它的孪生接口 ListCrudRepository 功能相同,但返回 List 而不是 Iterable
  • PagingAndSortingRepository 仅添加了分页和排序功能,它也有一个返回 List 的孪生接口。猜猜它叫什么?等等,你说对了!
  • JpaRepository 是我的最爱,它包含了所有返回 List 的先前接口。大多数时候,我只使用这个接口。

你应该在何时使用 RepositoryJpaRepository 或者介于两者之间的接口呢?我认为,如果你需要为其他开发者提供严格的 API,可以从 Repository 扩展并仅实现必要的操作,而不是授予访问全部 CRUD 操作的权限,这可能会损害你的业务逻辑。在你没有访问限制并且希望快速开发的情况下,请使用 JpaRepository

关于 API 限制的例子:有时你可能需要处理存储在数据库中的逻辑。这涉及到大量的存储过程、逻辑中的细微差别等等。作为开发者,在处理表实体时应格外小心,因为这可能导致不可预测的行为。因此,在这种情况下,你只应设计 JPA 实体,并仅实现一个包含指定查询方法的空接口。通过这种方法,你是在向其他开发者强调,他们应该实现你所需的方法,而不是直接操作原始实体。

实际上,Spring Data JPA 存储库还有一个有趣的特点。你从 CrudRepository/JpaRepository 继承的方法默认是事务性的:读取操作使用 @Transactional(readOnly = true),写入操作使用常规的 @Transactional

你通常不需要在接口上使用 Spring Framework 的 @Repository 注解(不要与 JPA 的接口混淆)——发现是自动的。对于可重用的基类接口,请使用 @NoRepositoryBean 注解。

扩展这些接口之一会告知 Spring Data JPA 它应该为你的接口生成一个实现。例如:

public interface CompanyRepository extends JpaRepository<Company, Long> {
    // 自定义方法将添加在这里
}

2 在存储库中使用查询

使用 Spring Data JPA 存储库查询数据主要有两种方法。实际上不止两种,但我们先关注更流行的(依我看来)。

  • 从方法名派生查询。Spring 解析方法名并生成相应的 JPQL。这加快了开发速度,并且对于简单条件来说很直观。
  • 使用 @Query 注解显式编写查询。这种方法更灵活,允许你使用 JPQL 或原生 SQL。在最新版本的 Spring Data 中,你可以使用 @NativeQuery 注解来代替传递 nativeQuery = true

对于数据修改查询(UPDATE/DELETE),需要添加 @Modifying,并确保存在事务边界——要么在存储库方法或类上使用 @Transactional 注解,要么从 @Transactional 服务中调用它。

使用两种方法的示例:

// 派生查询
List<Employee> findByDepartmentIdAndActiveTrue(Long departmentId);

// 显式 JPQL 查询
@Query("SELECT e FROM Employee e WHERE e.department.id = :deptId AND e.active = true")
List<Employee> findActiveEmployees(@Param("deptId") Long departmentId);

// 原生 SQL 查询
@Modifying
@Transactional
@NativeQuery(value = "UPDATE employee SET active = false WHERE id = :id")
void deactivateEmployee(@Param("id") Long id);

在上面的例子中,前两个方法是选择查询。最后一个是更新(停用)操作,其目的与选择查询不同。

第一种方法缩短了开发查询所需的时间并且很直观。第二个例子在创建用于操作数据库的方法时提供了额外的能力,允许你使用 JPQL 和原生 SQL 编写查询。

如前所述,继承的数据修改方法默认标记为 @Transactional。对于自定义的修改查询,请使用 @Modifying 注解,并确保存在事务边界(在方法或类上,或在服务层)。

3 Spring Data JPA 投影

对来自数据库的原始实体进行操作可能不切实际或不安全。在应用程序中检索完整实体并进行操作或许可以接受,但更好的做法是调整你的查询,使其仅返回必要的信息。

为了解决这个问题,你应该利用 Spring Data JPA 投影,它能够定义数据库中的数据将如何呈现。在上面描述的示例中,Spring Data JPA 投影仅返回调用者所需的选定属性。

Spring Data JPA 提供以下类型的投影:

  • 通过接口定义的投影,也称为基于接口的投影
  • DTO 对象的投影。请阅读关于 Spring Data JPA 的系列文章中关于开发 DTO 的指南。
  • 动态投影

基于接口的投影允许你创建只读投影,以便安全地呈现来自数据库的数据。这种方法通常在不需要操作创建的对象,而仅用于显示数据时使用。请注意,访问嵌套属性可能导致连接和额外的查询,因此投影并不总是比获取实体快。务必检查生成的 SQL 以确保最佳性能。

例如,一个基于接口的 Spring Data JPA 投影:

public interface EmployeeView {
    String getFirstName();
    String getLastName();
    BigDecimal getSalary();
}

List<EmployeeView> findBySalaryGreaterThan(BigDecimal amount);

基于 DTO 的投影允许将数据投影到 Java 类上,使你可以使用具体的 DTO 对象而不是接口。对于派生的查询方法,Spring 可以通过其构造函数将结果映射到 DTO,而对于 @Query JPQL,则需要使用构造函数表达式。基于类的投影需要一个单一的全参数构造函数;如果有多个构造函数,请使用 @PersistenceCreator 注解标记目标构造函数。

public class EmployeeDto {
    private final String firstName;
    private final String lastName;
    private final BigDecimal salary;
    public String getFirstName() { return firstName; }
    public String getLastName() { return lastName; }
    public BigDecimal getSalary() { return salary; }

    public EmployeeDto(String firstName, String lastName, BigDecimal salary) {
        this.firstName = firstName;
        this.lastName = lastName;
        this.salary = salary;
    }
}

@Query("SELECT new com.example.EmployeeDto(e.firstName, e.lastName, e.salary) FROM Employee e WHERE e.salary > :amount")
List<EmployeeDto> findHighEarningEmployees(@Param("amount") BigDecimal amount);

你可以将动态投影与存储库一起使用,以公开一个通用方法,允许调用者在运行时选择投影类型。Class 参数用于选择投影类型。如果你需要将 Class 传递到查询本身中,请使用不同的参数,以免它被用作投影选择器。

当将 DTO 类与动态投影一起使用时,请确保查询提供了构造函数参数(例如,通过 JPQL 构造函数表达式);否则,调用将在运行时失败。

<T> List<T> findBySalaryGreaterThan(BigDecimal amount, Class<T> type);

// 用法:

repo.findBySalaryGreaterThan(new BigDecimal("1000"), EmployeeView.class); // 接口投影

repo.findBySalaryGreaterThan(new BigDecimal("1000"), EmployeeDto.class); // DTO 类投影(需要查询支持)

4 有效使用存储库方法

如前所述,存储库 CRUD 方法默认在事务中运行(读取操作为 readOnly = true,写入操作为常规事务)。关于事务的另一点是避免在调用点手动开启事务。

当对多个实体执行操作时,优先使用批量方法,如 saveAll(),而不是在循环中调用 save()。将操作分组到单个查询中可以减少数据库的往返次数。

优先使用面向批量的写入,但请注意 saveAll() 本身并不会发出单个 SQL 语句。为了实际减少往返次数,需要启用 JDBC 批处理(例如,设置 spring.jpa.properties.hibernate.jdbc.batch_size=50,并且通常设置 hibernate.order_inserts=true/hibernate.order_updates=true)。如果需要插入批处理,请避免使用 GenerationType.IDENTITY,对于非常大的批次,请定期调用 flush()/clear()

只要可能,将逻辑合并到单个查询中,而不是在 Java 中执行多个查询。在某些情况下,使用 SQL 将部分算法卸载到数据库更高效。

对于大型结果集,使用分页。Page<T> 返回内容加总数,并触发计数查询(对于自定义的 @Query,需要提供 countQuery),Slice<T> 返回内容以及是否有下一个分片(不进行计数查询),而带有 Pageable 参数的 List<T> 应用 limit/offset 但不提供元数据。

// 1) 带有 Page 和排序的派生查询
interface UserRepository extends JpaRepository<User, Long> {
    Page<User> findByActive(boolean active, Pageable pageable);
}

// 用法:
Pageable pageable = PageRequest.of(0, 20, Sort.by("createdAt").descending());
Page<User> page = userRepository.findByActive(true, pageable);
List<User> users = page.getContent();
long total = page.getTotalElements();
boolean last = page.isLast();

// 2) 使用 Slice 进行无限滚动(无计数查询)
interface UserRepository extends JpaRepository<User, Long> {
    Slice<User> findByActive(boolean active, Pageable pageable);
}

5 存储库中的存储过程

在开发面向数据库的应用程序时,你可以使用 Spring Data JPA 调用数据库中定义的存储过程。有多种方法可以实现。

第一种方法是使用 @NamedStoredProcedureQuery

  • 在实体上使用 @NamedStoredProcedureQuery 声明它,指定:
    • name – JPA 使用的标识符,
    • procedureName – 数据库中存储过程的实际名称,
    • parameters@StoredProcedureParameter 对象数组,定义每个参数的模式(IN/OUT)、名称和 Java 类型。
  • 在存储库中添加一个方法,并使用 @Procedure 注解,引用声明的名称。

对于多个输出参数,当调用由 @NamedStoredProcedureQuery 支持时,Spring Data JPA 可以返回一个 Map<String,Object>。对于单个输出,可以直接返回该值。@Procedure 上还有一个 outputParameterName 属性用于定位特定的输出参数。

在实体上的声明示例:

@NamedStoredProcedureQuery(
    name = "Employee.raiseSalary",
    procedureName = "raise_employee_salary",
    parameters = {
        @StoredProcedureParameter(mode = ParameterMode.IN,  name = "in_employee_id", type = Long.class),
        @StoredProcedureParameter(mode = ParameterMode.IN,  name = "in_increase",    type = BigDecimal.class),
        @StoredProcedureParameter(mode = ParameterMode.OUT, name = "out_new_salary", type = BigDecimal.class)
    }
)
@Entity
public class Employee { … }

存储库方法:

@Procedure(name = "Employee.raiseSalary")
BigDecimal raiseSalary(@Param("in_employee_id") Long id,
                       @Param("in_increase")    BigDecimal increase);

第二种方法是不定义 JPA 元数据,直接在存储库方法上使用 @Procedure(procedureName = "…"),甚至通过 @Query(value = "CALL proc(:arg…)", nativeQuery = true) 来调用。

实际上,还有一种方法,但不太规范,就是使用实体管理器调用存储过程,本文不会涵盖这种做法,因为它将在本系列的下一篇文章(也是最后一篇)中讨论。

6 Spring Data JPA 存储库速查表

为了简要总结本设计指南,你可以使用以下速查表。

6.1 选择哪种 Spring Data JPA 存储库?

要扩展的接口

  • Repository<T, ID> — 仅作为标记;你需要自己定义每个方法。
  • CrudRepository<T, ID> — 基本 CRUD;返回 Iterable 集合。
  • ListCrudRepository<T, ID> — 类似 CrudRepository,但返回 List 集合。
  • PagingAndSortingRepository<T, ID> — 添加分页和排序。
  • ListPagingAndSortingRepository<T, ID> — 返回 List 的孪生接口。
  • JpaRepository<T, ID> — 包含以上所有功能 + JPA 的便利功能(flush、批量删除等)。大多数应用程序中的默认选择。

何时选择哪个

  • 需要严格、最小化的 API?扩展 Repository(或一个精简的基类)并仅暴露允许的方法。
  • 需要开发速度?扩展 JpaRepository

发现与基础配置

  • 存储库接口上不需要 @Repository;Spring 通过类型检测它们。
  • 对于可重用的基类接口,使用 @NoRepositoryBean 注解。
  • 默认实现由 SimpleJpaRepository 支持。

事务(默认)

  • 默认值适用于继承的 CRUD 方法:读取使用 @Transactional(readOnly = true),写入使用常规 @Transactional
  • 你自己的查询方法(派生名称或 @Query)默认不是事务性的;需要注解它们或从事务性服务中调用。

6.2 如何使用 Spring Data JPA 查询数据?

两种核心方法

  • 派生查询(通过方法名)适用于简单条件。
  • 显式查询 使用 @Query(JPQL)或通过 @Query(..., nativeQuery = true)@NativeQuery(现代快捷方式;支持如 sqlResultSetMapping 等额外功能)进行的原生查询。

修改查询

  • 添加 @Modifying 并确保存在事务边界(在方法/类上使用 @Transactional 或从事务性服务中调用)。

使用自定义查询进行分页

  • 对于 Page<T> 和复杂的 JPQL/原生查询,提供一个显式的 countQuery(或 countProjection)以避免脆弱的自动计数。

6.3 使用 Spring Data JPA 投影的最佳方式

类型

  • 基于接口的投影 — 用于安全数据呈现的只读视图。
  • DTO/基于类的投影 — 映射到具有单个全参数构造函数的类(如果存在多个构造函数,请使用 @PersistenceCreator)。
  • 动态投影 — 公开一个通用方法,让调用者传递 Class<T> 以在运行时选择投影类型。

注意

  • 在投影中访问嵌套属性可能触发连接。投影并不自动比实体快。检查 SQL 和返回的列,并测量查询性能。
  • 当将 DTO 与动态投影一起使用时,确保查询提供构造函数参数(例如,通过 JPQL 构造函数表达式)。

6.4 关于有效使用查询的简要说明

批处理与往返次数

  • 优先使用 saveAll(...) 而不是重复的 save(...)
  • 如果需要插入批处理,请避免使用 GenerationType.IDENTITY。优先选择序列/池化优化器。
  • 对于非常大的批次,定期调用 flush()/clear()

让数据库工作

  • 尽可能将面向集合的逻辑推入单个查询,而不是多步骤的 Java 循环。

分页选项

  • Page<T> — 内容 + 总数(触发计数查询)。
  • Slice<T> — 内容 + "是否有下一页"(无计数查询,适用于无限滚动)。
  • List<T>Pageable 参数 — 应用 limit/offset,无元数据。

6.5 从 Spring Data JPA 调用存储过程

方法

  • 命名存储过程:在实体上使用 @NamedStoredProcedureQuery 声明,然后通过使用 @Procedure(name = "...") 注解的存储库方法调用。
  • 直接调用(无实体元数据):在存储库方法上使用 @Procedure(procedureName = "..."),或使用 @Query(value = "CALL ...", nativeQuery = true) 调用。

输出

  • 多个 OUT 参数(使用命名存储过程)可以作为 Map<String,Object> 返回。
  • 单个 OUT 可以直接返回,或者使用 @Procedure 上的 outputParameterName 来定位特定的输出参数。

Spring Data JPA(系列文章共 2 篇)

  1. Spring Data JPA 最佳实践【1/2】:实体设计指南
  2. Spring Data JPA 最佳实践【2/2】:存储库设计指南

【注】本文译自:Spring Data JPA Best Practices: Repositories Design Guide

Spring Data JPA 最佳实践【1/2】:实体设计指南

Spring Data JPA(系列文章共 2 篇)

  1. Spring Data JPA 最佳实践【1/2】:实体设计指南
  2. Spring Data JPA 最佳实践【2/2】:存储库设计指南

这一系列文章是我在审查一个包含大量不良实践的大型遗留代码库时撰写的总结。为了解决这些问题,我创建了这份指南,旨在向我之前的同事推广 Spring Data JPA 在设计实体方面的最佳实践。

现在是将这份指南从尘封中取出、更新并发布给更广泛受众的时候了。该指南内容详实,我决定将其拆分为两篇独立的文章。

文中的一些示例可能看起来显而易见,但事实并非如此——这只是从您经验丰富的角度得出的看法。它们都来自生产代码库中的真实案例。

1 深入 Spring Data JPA

为了便捷快速地开发数据库驱动的软件,推荐使用以下库和框架:

  • Spring Boot — 通过提供自动配置、起步依赖和约定优于配置的默认值(例如,内嵌服务器、Actuator),简化了在 Spring 框架之上构建 Web 应用程序的过程。它利用了 Spring 现有的依赖注入模型,而非引入新的模型。
  • Spring Data JPA 在为数据库操作创建存储库时节省时间。它提供了现成的接口用于 CRUD 操作、事务管理以及通过注解或方法名定义查询。另一个优势是其与 Spring 上下文的集成,以及依赖注入带来的相应好处。
  • Lombok – 通过生成 getter、setter 和其他重复性代码,减少了样板代码。

实体代表数据库表中的行。它们是使用 @Entity 和其他 JPA 注解标注的普通 Java 对象。DTO(数据传输对象) 是普通 Java 对象,用于以相较于底层实体受限或转换后的形式呈现数据。

在 Spring 应用程序中,存储库 是一种特殊的接口,提供对数据库/数据的访问。这类存储库通常使用 @Repository 注解,但实际上,当您继承自 JpaRepositoryCrudRepository 或其他 Spring Data JPA 存储库时,无需单独标注。如果您不继承 Spring Data 的基础接口,可以使用 @RepositoryDefinition。此外,在共享的基础接口上使用 @NoRepositoryBean

服务 是封装业务逻辑和功能的特殊类。控制器 是您应用程序的端点;用户与控制器交互,控制器继而注入服务而非存储库。

为清晰起见,您的项目应按职责或其他方式组织成不同的包。代码组织是一个好话题,但总是依赖于您的服务、代码约定等。给出的示例代表一个具有单一业务领域的微服务。

  • entity – 数据库实体,
  • repository – 数据访问存储库,
  • service – 服务,包括存储过程的包装器,
  • controller – 应用程序端点,
  • dtos – DTO 类。

当 Spring Boot 应用程序启动时,基于 application.properties/application.yml 中的配置,到数据库的连接会被自动配置。常见属性包括:

  • spring.datasource.url – 数据库连接 URL
  • spring.datasource.driver-class-name – 数据库驱动类,Spring Boot 通常可以从 JDBC URL 推断出它,仅在推断失败时设置此属性。
  • spring.jpa.database-platform – 要使用的 SQL 方言
  • spring.jpa.hibernate.ddl-auto – Hibernate 应如何创建数据库模式,可用值:none|validate|update|create|create-drop

2 使用 Spring Data JPA 开发实体

在设计与数据库交互的软件时,正确使用 Java 持久化 API(JPA)注解的简单 Java 对象起着至关重要的作用。这类对象通常包含映射到表列的字段,被称为实体。并非每个字段都是一对一映射的:关系、嵌入的值对象和 @Transient 字段都很常见。

至少,一个实体类必须使用 @Entity 注解来标记该类为数据库实体,并使用 @Id@EmbeddedId 声明一个主键。JPA 还要求一个无参构造函数(public 或 protected)。包含 @Table 以显式定义目标表也是一个好习惯。@Table 注解是可选的,当您需要覆盖默认表名时使用它。

使用 @Entity 注解时,最好设置 name 属性,因为此名称用于 JPQL 查询。如果省略它,JPQL 将使用简单的类名,设置它可以解耦查询与重构.

还有一个有用的注解 @Table,可以在表名与命名策略不同时帮助您选择表名。

以下示例演示了不好和好的用法:

@Entity
@Table(name = "COMPANY")
public class CompanyEntity {
    // 字段省略
}

// 后续使用:
Query q = entityManager.createQuery("FROM " + CompanyEntity.class.getSimpleName() + " c")

这里,@Entity 上缺少 name 属性,因此在查询中使用类名。这可能在重构时导致代码脆弱。这里还有另一个问题:它使用了 entityManager 而不是预配置的 Spring Data JPA 存储库。entityManager 提供了更多的灵活性,但也让您可能在代码库中制造混乱,而不是使用更可取的数据获取方式。

您发现这里还有一个不良实践了吗?没错,就是使用字符串拼接来构建查询。在这种情况下,它不会导致 SQL 注入,但最好避免这种方法,尤其是在像这样将用户输入传递给查询时。

@Entity(name = "Company")
@Table(name = "COMPANY")
public class CompanyEntity {
    // 字段省略
}

// 后续使用:
Query q = entityManager.createQuery("FROM Company c");

在改进版本中,显式指定了实体名称,因此 JPQL 查询可以通过名称引用实体,而不必依赖类名。

注意:JPQL 实体名称(@Entity(name))和 @Table 中的物理表名是两个独立的概念。

3 避免魔法数字/字面量

明智地选择字段的类型:

  • 如果字段代表数字枚举,则使用 Integer 或适当的小型数值类型。
  • 如果选择类型,则基于值域范围和可空性(如果列可为空,则使用包装类型,如 Integer);并记住,在 JPA 中,较小的数值类型很少带来实际好处。
  • 如果值是货币或需要精确计算,则使用具有适当精度/小数位数的 BigDecimal
  • 如果您需要关于枚举的详细信息,将在后面介绍。

例如,假设一个字段 statusCode 代表公司的状态。使用数字类型并在注释中记录每个值的含义,会导致代码难以阅读且容易出错:

// 公司状态:
// 1 – 活跃
// 2 – 暂停
// 3 – 解散
// 4 – 合并
@Column(name = "STATUS_CODE")
private Long statusCode;

相反,应创建一个枚举并将其用作字段的类型。这使得代码自文档化并减少了出错的机会。在使用 Spring Data JPA 持久化枚举时,请指定其存储方式,这是一个好习惯。优先使用 @Enumerated(EnumType.STRING),这样数据库中包含的是可读的名称,并且您不会因常量重新排序而受影响。同时,确保列类型/长度适合枚举名称(如果需要,设置 lengthcolumnDefinition)。

// 存储为可读名称;确保列能容纳它们(例如,length = 32)。
@Column(name = "STATUS", length = 32)
@Enumerated(EnumType.STRING)
private CompanyStatus status;

public enum CompanyStatus {
    /** 活跃公司 */           ACTIVE,
    /** 暂时暂停 */    SUSPENDED,
    /** 正式解散 */     DISSOLVED,
    /** 合并到其他组织 */  MERGED;
}

如果您现有的列存储数字代码(例如 1–4)且必须保持为数字,不要使用 EnumType.ORDINAL(它写入的是基于 0 的序号,与 1–4 不匹配)。使用 AttributeConverter<CompanyStatus, Integer> 将显式代码映射到枚举值:

@Converter(autoApply = false)
public class CompanyStatusConverter implements AttributeConverter<CompanyStatus, Integer> {
    @Override
    public Integer convertToDatabaseColumn(CompanyStatus v) {
        if (v == null) return null;
        return switch (v) {
            case ACTIVE    -> 1;
            case SUSPENDED -> 2;
            case DISSOLVED -> 3;
            case MERGED    -> 4;
        };
    }

    @Override
    public CompanyStatus convertToEntityAttribute(Integer db) {
        if (db == null) return null;
        return switch (db) {
            case 1 -> CompanyStatus.ACTIVE;
            case 2 -> CompanyStatus.SUSPENDED;
            case 3 -> CompanyStatus.DISSOLVED;
            case 4 -> CompanyStatus.MERGED;
            default -> throw new IllegalArgumentException("未知 STATUS_CODE: " + db);
        };
    }
}

// 在列中保持数字 1..4,同时在 Java 中暴露类型安全的枚举。
@Column(name = "STATUS_CODE")
@Convert(converter = CompanyStatusConverter.class)
private CompanyStatus status;

4 类型的一致性使用

如果一个字段在多个实体中使用,请确保它在各处具有相同的类型。对概念上相同的字段使用不同的类型会导致业务逻辑不明确。例如,以下不好的用法展示了两个代表布尔标志但使用不同类型和名称的字段:

// 对逻辑相同的字段选择了不好的类型
// A – 自动, M – 手动
@Column(name = "WAY_FLG")
private String wayFlg;

@Column(name = "WAY_FLG")
private Boolean wayFlg;

更好的选择是对两个字段都使用 Boolean,或者,如果您需要两个以上的值,或者这两个值是带有领域标签的(例如,Automatic/Manual),则对两个字段都使用枚举。如果它确实是二元的 是/否,使用 Boolean(对于可空列使用包装类型)即可。否则,为了清晰性和面向未来,优先使用枚举。以下是不使用转换器的一致性映射示例:

// 两个带标签的状态:为了清晰,优先使用枚举
public enum WayMode { A, M } // 或 AUTOMATIC, MANUAL

// 在每个涉及 WAY_FLG 的实体中使用相同的映射
@Column(name = "WAY_FLG", length = 1) // 确保长度适合枚举名称
@Enumerated(EnumType.STRING)
private WayMode wayFlg;

// 真正的二元情况(例如,活跃/非活跃):
@Column(name = "IS_ACTIVE")
private Boolean active; // 如果列可为 NULL,则使用包装类型

本文有意省略了关于 Spring Data JPA 中表关系部分,因为这是一个广泛的主题,值得另写一篇关于最佳实践的文章。

5 Lombok 的使用

为了减少样板源代码的数量,推荐使用 Lombok 进行代码生成——但应明智地使用。生成 getter 和 setter 是一个理想的选择。最好坚持这种做法,并且仅在需要某些预处理时才重写 getter 和 setter。

对于 JPA,确保存在无参构造函数。使用 Lombok,您可以添加 @NoArgsConstructor(access = AccessLevel.PROTECTED) 来清晰地满足规范。

警告提示:避免在实体上使用 @Data,因为它生成的 equals/hashCode/toString 可能与 JPA 产生问题(延迟关系、可变标识符)。优先使用针对性的注解@Getter, @Setter, @NoArgsConstructor),并且如果需要,使用 @EqualsAndHashCode(onlyExplicitlyIncluded = true) 和排除关联字段来显式定义相等性。下文将详细说明。

此外,Lombok 支持以下常用注解。您可以在其网站上找到完整列表:https://projectlombok.org/

6 重写 equals 和 hashCode

在数据库实体中重写 equalshashCode 时,会出现许多问题。例如,许多应用程序使用从 Object 继承的标准方法也能正常工作。

上下文:在单个持久化上下文中,Spring Data JPA/Hibernate 已经确保了标识语义(相同的数据库行 -> 相同的 Java 实例)。通常只有在跨上下文依赖值语义或在哈希集合中使用时,才需要自定义 equals/hashCode

数据库实体通常代表现实世界的对象,您可以选择不同的方式来重写:

  • 基于实体的主键(它是不可变的)。细微差别:如果 ID 是数据库生成的,则在持久化/刷新之前它为 null。需要处理临时状态,以免对象在哈希集合中时哈希值发生改变。
  • 基于业务键(例如,员工的税号/INN),因为它不依赖于数据库实现。细微差别:如果键是唯一、不可变且始终可用的,则效果很好;避免使用可变字段/关联。
  • 基于所有字段。不安全:可变数据、潜在的延迟加载、通过关联的递归以及性能成本,使得这对于 JPA 实体来说很脆弱。

什么时候应该重写 equalshashCode

  • 当对象在 Map 中用作键时。细微差别:当对象位于哈希结构内部时,不要修改被 hashCode 使用的字段。
  • 当使用仅存储唯一对象的结构时(例如 Set)。细微差别:同样的注意事项——修改相等性/重要字段会破坏集合的不变性。
  • 当需要比较数据库实体时。细微差别:通常比较标识符就足够了;如果标识比较符合您的用例,则重写不是强制性的。

综上所述,您应该谨慎使用 Lombok 的 @EqualsAndHashCode@Data,因为除非另行配置,否则 Lombok 会为所有字段生成这些方法。

扩展说明:优先使用 @EqualsAndHashCode(onlyExplicitlyIncluded = true) 并仅标记稳定的标识符/业务键;避免在实体上使用 @Data(它生成的 equals/hashCode/toString 可能与延迟关系产生不良交互)。您还可以使用 @EqualsAndHashCode.Exclude / @ToString.Exclude 将关联从相等性或 toString 中排除。

继承的细微差别:如果在映射的超类中定义了相等性,请确保规则对所有子类一致,并且与整个层次结构的标识定义方式相匹配。

A) 业务键相等性(当键唯一且不可变时安全)

public class Employee {
    private String taxId; // 自然键:唯一且不可变

    @Override
    public boolean equals(Object o) {
        if (this == o) return true;
        if (o == null || getClass() != o.getClass()) return false; // 这里保持简单
        Employee other = (Employee) o;
        return taxId != null && taxId.equals(other.taxId);
    }

    @Override
    public int hashCode() {
        return (taxId == null) ? 0 : taxId.hashCode();
    }
}

B) 基于 ID 的相等性(处理临时状态;避免哈希变化)

public class Order {
    private Long id; // 数据库生成

    @Override
    public boolean equals(Object o) {
        if (this == o) return true;
        if (o == null || getClass() != o.getClass()) return false;
        Order other = (Order) o;
        // 临时实体 (id == null) 除了自身外,不等于任何东西
        return id != null && id.equals(other.id);
    }

    @Override
    public int hashCode() {
        // 返回常量,避免在后续分配 ID 后重新计算哈希值
        return getClass().hashCode();
    }
}

C) Lombok 模式(显式包含;避免全字段默认)

@Getter
@Setter
@EqualsAndHashCode(onlyExplicitlyIncluded = true)
public class Customer {
    @EqualsAndHashCode.Include
    private String externalId; // 稳定的业务键

    // 排除关联和可变细节
    // @EqualsAndHashCode.Exclude private List<Order> orders;
}

7 开发 DTO

DTO(数据传输对象) 是专门设计的对象,用于向客户端呈现数据,因为将原始数据库实体直接发送给客户端被认为是一种不良实践。有些团队确实会在内部边界传递实体,但对于公开/面向客户端的 API,优先使用 DTO 以避免泄露持久化细节。

创建各种 DTO 会增加开发和维护时间。如果使用像 ModelMapper 这样的库,对象映射还会带来内存开销。

DTO 的另一个特性是通过传输更少的数据量来减少网络传输的数据量,并通过请求更少的字段来降低 DBMS 的负载。最重要的是,只有当您确实选择了更少的列时(使用构造函数表达式、Spring Data JPA 投影或仅返回所需字段的本机查询),您才能真正减少数据库负载。获取完整实体然后进行映射不会减少选择的列数,这是显而易见的。

设计 DTO 有不同的方式:

  • 使用类(对象)。对于外部 API(序列化、验证、文档),类或 Java record 通常更清晰。
  • 使用接口。接口适用于 Spring Data 基于接口的投影(只读、仅有 getter 的视图),而不适用于写入模型。

将实体对象转换为 DTO 有不同的方式:

  • 最优方法是将数据从数据库直接投影到所需的 DTO 中。这既避免了额外的映射工作,又确保选择了更少的列。
  • 您也可以使用像 ModelMapper 这样的库。优先考虑 MapStruct(编译时代码生成,运行时更快,映射明确)。
  • 您也可以编写自己的对象转换器。手写映射器提供了完全的控制,但增加了维护需求。

开发 DTO 的良好实践:

  • 优先为每个用例设计特定用途的 DTO(例如,Summary/Detail/ListItem;CreateRequest 与 Response)。
  • 避免使用一个与实体绑定的巨型 DTO,这会导致过度获取和紧耦合。

8 Spring Data JPA 总结性最佳实践

  1. 使用 JPA 注解开发实体

    • 实体将字段映射到列;关系、可嵌入对象和 @Transient 字段很常见(不总是 1:1)。
    • 最低要求:@Entity + 主键(@Id / @EmbeddedId)+ 无参构造函数(public/protected)。
    • 仅在使用 @Table 覆盖默认值(表、模式、约束)时使用。
    • 优先使用显式的 @Entity(name="…") 以将 JPQL 与类名解耦,使得 JPQL 在类重命名时保持稳定。
    • 避免在 JPQL 中使用字符串拼接,使用参数。
    • JPQL 实体名称(@Entity(name))和物理表名称(@Table(name))是独立的。
  2. 避免魔法数字/字面量

    • 根据值域范围和可空性选择类型;如果列可为 NULL,使用包装类型(Integer, Boolean)。
    • 货币/精度计算 -> 使用具有适当精度/小数位数的 BigDecimal
    • 用枚举替换数字代码。使用 @Enumerated(EnumType.STRING) 持久化,并确保列长度适合名称。
    • 遗留的数字代码列:使用 AttributeConverter<Enum, Integer>不要使用 EnumType.ORDINAL
  3. 类型的一致性使用

    • 对相同的概念性列在所有地方使用相同的 Java 类型。
    • 二元标志 -> Boolean(包装类型)。领域标签化或未来可扩展的标志 -> 一致地使用枚举。
    • 一致地映射枚举(@Enumerated(EnumType.STRING), @Column(length=…));避免对同一列混合使用 String/Boolean/枚举。
  4. Lombok 的使用

    • 使用 Lombok 处理样板代码:@Getter, @Setter, @NoArgsConstructor(access = PROTECTED) 用于 JPA。
    • 避免在实体上使用 @Data (生成的 equals/hashCode/toString 可能与延迟关系和标识符冲突)。
    • 仅当需要前/后处理时才重写访问器。
  5. 重写 equals 和 hashCode

    • 仅当您需要跨上下文的值语义或在哈希集合中使用时才重写。
    • 业务键策略:比较唯一、不可变的键。
    • 基于 ID 的策略:将临时(id == null)实体视为不相等;使用稳定/恒定的 hashCode() 以避免持久化后重新计算哈希。
    • 避免全字段相等性;排除关联以防止延迟加载/递归。
    • 使用 Lombok 时,优先使用 @EqualsAndHashCode(onlyExplicitlyIncluded = true) 并显式包含稳定的标识符;对关系使用 @EqualsAndHashCode.Exclude / @ToString.Exclude
    • 在层次结构(映射的超类与子类)中保持相等性规则的一致性。
  6. 开发 DTO

    • 不要向客户端暴露实体,即使您使用 @JsonIgnore 注解返回它们;设计特定用途的 DTO(Summary/Detail/ListItem;Create/Update/Response)。
    • 通过选择更少的列来减少数据库负载:直接投影到 DTO(使用构造函数表达式),利用基于接口的投影,或使用仅返回必要字段的本机查询。
    • 映射完整实体不会减少选择的列数
    • 优先使用 MapStruct(编译时、快速、明确)而不是 ModelMapper;手写映射器以更高的维护成本提供控制。

最后

希望您觉得这篇文章有帮助。如果您对 Spring Data JPA 感兴趣,请阅读下一篇文章:"Spring Data JPA 最佳实践:存储库设计指南"


【注】本文译自:Spring Data JPA Best Practices: Entity Design Guide

Java 中的结构化并发模式

并发编程长期以来一直是 Java 的阿喀琉斯之踵。尽管 ExecutorServiceFuture 为我们提供了良好的服务,但它们允许不受限制的模式,其中子任务可能比其父任务存活更久、线程可能泄漏,而取消操作则变成了一场噩梦。结构化并发通过将运行在不同线程中的相关任务组视为一个单一的工作单元,改变了这一现状,它简化了错误处理和取消操作,同时提高了可靠性和可观测性。

非结构化并发的问题

考虑一个使用 ExecutorService 的典型模式:一个线程创建执行器,另一个线程提交工作,而执行任务的线程与前两者都没有关系。在一个线程提交工作之后,一个完全不同的线程可以等待结果——任何持有 Future 引用的代码都可以连接它,甚至可以是与获取该 Future 的线程不同的线程中的代码。

这种非结构化方法带来了实际问题。当父任务未能正确关闭子任务时,就会发生线程泄漏。由于没有协调的方式来通知多个子任务,取消操作会出现延迟。并且由于任务和子任务之间的关系在运行时未被跟踪,可观测性会受到影响。

// 非结构化:关系是隐式且脆弱的
ExecutorService executor = Executors.newCachedThreadPool();
Future<User> userFuture = executor.submit(() -> fetchUser(id));
Future<Orders> ordersFuture = executor.submit(() -> fetchOrders(id));

// 如果 fetchUser 失败会发生什么?
// 谁负责关闭执行器?
// 如果我们忘记清理,线程会泄漏吗?

引入 StructuredTaskScope

结构化并发 API 的主要类是 java.util.concurrent 包中的 StructuredTaskScope,它使您能够将一个并发子任务组作为一个单元进行协调。使用 StructuredTaskScope,您可以在各自的线程中分叉每个子任务,然后将它们作为一个单元进行汇合,确保在主任务继续之前子任务完成。

该 API 遵循一个清晰的模式:

  1. 使用 try-with-resources 创建一个 StructuredTaskScope
  2. 将子任务定义为 Callable 实例
  3. 在各自的线程中分叉每个子任务
  4. 汇合以等待完成
  5. 处理子任务的结果

以下是一个获取天气数据的真实示例:

WeatherReport getWeatherReport(String location)
        throws ExecutionException, InterruptedException {
    try (var scope = new StructuredTaskScope.ShutdownOnFailure()) {
        Supplier<Temperature> temperature =
            scope.fork(() -> getTemperature(location));
        Supplier<Humidity> humidity =
            scope.fork(() -> getHumidity(location));
        Supplier<WindSpeed> windSpeed =
            scope.fork(() -> getWindSpeed(location));

        scope.join()           // 汇合所有子任务
             .throwIfFailed(); // 如果有任何失败,传播错误

        // 全部成功,组合结果
        return new WeatherReport(
            location,
            temperature.get(),
            humidity.get(),
            windSpeed.get()
        );
    }
}

try-with-resources 代码块至关重要——它确保作用域被正确关闭,取消任何未完成的子任务并防止线程泄漏。

使用关闭策略实现短路

短路模式通过使主任务能够中断和取消那些不再需要其结果子任务,来促使子任务快速完成。两个内置策略处理了常见场景:

ShutdownOnFailure:"调用所有"模式

当您需要所有子任务都成功时,ShutdownOnFailure 会在一个任务失败后立即取消剩余的任务:

Response handleRequest(String userId) throws Exception {
    try (var scope = new StructuredTaskScope.ShutdownOnFailure()) {
        Subtask<User> user = scope.fork(() -> fetchUser(userId));
        Subtask<Profile> profile = scope.fork(() -> fetchProfile(userId));
        Subtask<Settings> settings = scope.fork(() -> fetchSettings(userId));

        scope.join().throwIfFailed();

        // 如果有任何失败,我们永远不会到达这里
        return new Response(user.get(), profile.get(), settings.get());
    }
}

如果 fetchUser() 抛出异常,作用域会立即取消配置文件和设置的获取。没有浪费的工作,没有线程泄漏。

ShutdownOnSuccess:"调用任一"模式

有时您只需要第一个成功的结果——例如查询多个数据中心或尝试备用服务:

String fetchFromMultipleSources(String key) throws Exception {
    try (var scope = new StructuredTaskScope.ShutdownOnSuccess<String>()) {
        scope.fork(() -> fetchFromPrimaryDB(key));
        scope.fork(() -> fetchFromCache(key));
        scope.fork(() -> fetchFromBackup(key));

        scope.join();

        // 返回第一个成功的结果
        return scope.result();
    }
}

任何子任务成功的瞬间,作用域就会取消其他任务。这种模式非常适合对延迟敏感的操作,即您需要竞速多个来源。

自定义关闭策略

在实践中,大多数 StructuredTaskScope 的使用不会直接使用 StructuredTaskScope 类,而是使用实现了关闭策略的两个子类之一,或者编写自定义子类来实现自定义关闭策略。

以下是一个收集所有成功结果并忽略失败的自定义策略:

class AllSuccessesScope<T> extends StructuredTaskScope<T> {
    private final List<T> results =
        Collections.synchronizedList(new ArrayList<>());

    @Override
    protected void handleComplete(Subtask<? extends T> subtask) {
        if (subtask.state() == Subtask.State.SUCCESS) {
            results.add(subtask.get());
        }
    }

    public List<T> getResults() {
        return List.copyOf(results);
    }
}

// 用法
List<Data> collectAll() throws InterruptedException {
    try (var scope = new AllSuccessesScope<Data>()) {
        for (String source : dataSources) {
            scope.fork(() -> fetchData(source));
        }
        scope.join();
        return scope.getResults();
    }
}

虚拟线程:完美搭档

虚拟线程提供了大量的线程——结构化并发可以正确且健壮地协调它们,并使可观测性工具能够按开发人员理解的方式显示线程。这种组合非常强大,因为虚拟线程使得创建数百万个线程的成本很低,而结构化并发则确保您能安全地管理它们。

// 现在启动 10,000 个并发任务是可行的
try (var scope = new StructuredTaskScope.ShutdownOnFailure()) {
    for (int i = 0; i < 10_000; i++) {
        final int taskId = i;
        scope.fork(() -> processTask(taskId));
    }
    scope.join().throwIfFailed();
}

使用平台线程,这将是灾难性的。但使用虚拟线程和结构化并发,这变得简单而安全。

模块系统考量

在使用结构化并发构建模块化应用程序时,理解 Java 的模块系统变得很重要。对于模块,反射失去了其"超能力",并且受限于与编译代码完全相同的可访问性规则——它只能访问导出包中公共类的公共成员。

默认情况下,只有 module-info.java 中显式导出的包是可见的。如果您使用的是依赖反射的框架(如 Spring 或 Hibernate),您将需要额外的声明:

module com.example.app {
    // 用于编译时访问的常规导出
    exports com.example.api;

    // 为运行时反射访问开放
    opens com.example.entities to org.hibernate.orm.core;

    requires java.base;
    requires org.hibernate.orm.core;
}

在编译时,开放的包完全被封装,就像该指令不存在一样,但在运行时,包的类型可用于反射,自由地与所有类型和成员(无论公开与否)交互。

为了在所有包上获得完整的反射访问权限,您可以声明一个开放模块:

open module com.example.app {
    exports com.example.api;
    requires java.base;
}

开放模块会开放其包含的所有包,就像每个包都单独在 opens 指令中使用一样,这很方便但降低了封装性。

可观测性和调试

结构化并发显著提高了可观测性。线程转储现在显示了清晰的父子关系:

jcmd <pid> Thread.dump_to_file -format=json output.json

JSON 输出揭示了 StructuredTaskScope 及其在数组中的分叉子任务,使得理解正在运行的内容及其原因变得容易。这与关系隐式的扁平线程转储相比,是一种变革。

当前状态与演进

结构化并发由 JEP 428 提出,并在 JDK 19 中作为孵化 API 交付,在 JDK 20 中重新孵化,通过 JEP 453 在 JDK 21 中首次预览,并在 JDK 22 和 23 中重新预览。截至 JDK 25,该 API 已经演进,使用静态工厂方法替代了公共构造函数。

要在当前 JDK 版本中使用结构化并发,需启用预览特性:

# 编译
javac --release 21 --enable-preview MyApp.java

# 运行
java --enable-preview MyApp

基于真实世界的反馈,该 API 正在稳定下来。结构化并发已被证明是一种安全、富有表现力且易于理解的并发方法,Python 库率先开创了这一领域,随后是 Kotlin 等语言。

最佳实践

  • 始终使用 Try-With-Resources:必须关闭作用域以防止线程泄漏。切勿手动管理 StructuredTaskScope 的生命周期。
  • 选择正确的策略:当所有结果都重要时使用 ShutdownOnFailure,在竞速场景中使用 ShutdownOnSuccess,或者为特定需求实现自定义策略。
  • 与虚拟线程结合使用:结构化并发与虚拟线程结合时效果最佳,能够通过简单的代码实现大规模并发。
  • 避免共享可变状态:虽然结构化并发处理协调,但您仍然需要对共享数据的线程安全负责。
  • 考虑作用域值:为了在任务层次结构中传递上下文,作用域值(JEP 481)提供了比 ThreadLocal 更好的替代方案。

真实示例:聚合用户数据

让我们构建一个从多个来源聚合数据的完整示例:

public class UserAggregator {
    record UserData(User user, List<Order> orders,
                    Stats stats, Recommendations recs) {}

    public UserData aggregate(String userId)
            throws ExecutionException, InterruptedException {
        try (var scope = new StructuredTaskScope.ShutdownOnFailure()) {
            Supplier<User> user =
                scope.fork(() -> userService.fetch(userId));
            Supplier<List<Order>> orders =
                scope.fork(() -> orderService.fetch(userId));
            Supplier<Stats> stats =
                scope.fork(() -> statsService.compute(userId));
            Supplier<Recommendations> recs =
                scope.fork(() -> mlService.recommend(userId));

            scope.join().throwIfFailed();

            return new UserData(
                user.get(),
                orders.get(),
                stats.get(),
                recs.get()
            );
        }
    }
}

这种模式简洁、安全且高效。如果任何服务失败,所有其他服务会立即被取消。作用域确保适当的清理。并且借助虚拟线程,这可以扩展到数千个并发请求。

开发者观点

Java 架构师决定不从 fork 方法返回 Future 实例,以避免与非结构化计算混淆,并与旧的并发模型进行清晰切割。这一设计决策强调了结构化并发是一种新的范式,而不仅仅是渐进式改进。

Rock the JVM 教程指出,结构化并发最终为 Java 带来了其他 JVM 语言通过 Kotlin 协程和 Scala Cats Effects Fibers 等库所提供的功能,但拥有官方的平台支持。

展望未来

结构化并发代表了我们对并发编程思考方式的根本转变。我们不是管理单个线程和 Future,而是按层次结构组织并发工作——就像我们用方法和循环组织顺序代码一样。

好处是显而易见的:没有线程泄漏、正确的错误传播、协调的取消以及增强的可观测性。结合虚拟线程,Java 现在提供了一个既强大又易于使用的并发模型。

随着该 API 走向最终化,预计将在框架和库中得到更广泛的采用。Spring、Hibernate 及其他生态系统项目已经在考虑如何利用结构化并发来编写更清晰、更可靠的并发代码。


【注】本文译自:Structured Concurrency Patterns in Java

使用 Spring AI 创建 MCP 服务器

本文提供了使用 Spring AI 创建模型上下文协议服务器的分步指南,并阐述了使用 MCP 的优势。

在本篇博客中,您将学习如何使用 Spring AI 创建一个模型上下文协议服务器。您将看到创建自己的 MCP 服务器是多么简单。

引言

模型上下文协议(Model Context Protocol)为将大型语言模型(LLM)连接到各种数据源和工具提供了一种标准化的方式。这句话中的"标准化"一词非常重要。这意味着与数据源和工具的集成变得比以往容易得多。除此之外,MCP 服务器通过额外的知识或功能来增强您的 LLM,使其成为一个更强大的助手。想象一下,您可以要求 LLM 为您预订假期。

根据您的偏好,它将在互联网上搜索合适的地点、预订酒店、预订航班等等,然后您就可以出发了!当然,LLM 需要能够为您预订酒店和航班。这种附加功能可以由 MCP 服务器提供。这听起来很吸引人,但也有些令人担忧。为了预订酒店,MCP 服务器需要知道您的个人详细信息并需要访问您的信用卡。这可能不是一个好主意。建议对敏感操作使用人工介入(HITL),以便您可以批准或拒绝某项操作。然而,MCP 服务器将使您的生活变得更加轻松。

在本博客中,您将学习如何使用 Spring Boot 和 Spring AI 创建自己的 MCP 服务器。没有 MCP 客户端,服务器将毫无用处。MCP 客户端与一个或多个 MCP 服务器交互,并且是控制方。作为 MCP 客户端,您将(滥)用 IntelliJ DevoxxGenie 插件。DevoxxGenie 实际上是一个 AI 编码助手,但您也可以使用它来测试您的 MCP 服务器。在下一篇博客中,您将创建自己的 MCP 客户端。

本篇博客中使用的源代码可在 GitHubserver 目录中找到。

先决条件

阅读本博客的先决条件是:

  • 基础的 Java 知识;
  • 基础的 Spring Boot 知识;
  • 基础的 LMStudio 知识;
  • 基础的 IntelliJ 和 DevoxxGenie 知识。

构建 MCP 服务器

您将构建的 MCP 服务器具有以下功能:

  • 返回我喜爱的艺术家列表
  • 返回我喜爱的歌曲列表

LLM 不会拥有关于此信息的任何知识,而当它能够访问这些工具时,希望它会利用它们。本应用深受 Dan Vega 的《使用 Java 创建您的第一个模型上下文协议服务器》的启发。

访问 Spring Initializr 并添加依赖项 Model Context Protocol Server。这将在 pom.xml 中添加以下依赖项。

<dependency>
  <groupId>org.springframework.ai</groupId>
  <artifactId>spring-ai-starter-mcp-server</artifactId>
</dependency>

创建 Artist 的数据模型。

public record Artist(String name) {
}

创建 Song 的数据模型。

public record Song(Artist artist, String title) {
}

创建 ArtistService。该服务将包含一个我喜爱的艺术家列表,但在实际应用中,这些信息将存储在数据库中。通过使用 @Tool 注解来定义一个名为 get_artists 的工具。为工具提供名称和描述。LLM 将使用该描述来了解工具的功能。

@Service
public class ArtistService {

    private final List<Artist> artists = new ArrayList<>();

    @Tool(name = "get_artists", description = "获取 Gunter 喜爱的艺术家完整列表")
    public List<Artist> getArtists() {
        return artists;
    }

    @PostConstruct
    public void init() {
        artists.addAll(List.of(
                new Artist("Bruce Springsteen"),
                new Artist("JJ Johnson")
        ));
    }

}

以类似的方式,创建 SongService

@Service
public class SongService {

    private final List<Song> songs = new ArrayList<>();

    @Tool(name = "get_songs", description = "获取 Gunter 喜爱的歌曲完整列表")
    public List<Song> getSongs() {
        return songs;
    }

    @PostConstruct
    public void init() {
        songs.addAll((List.of(
                new Song(new Artist("Bruce Springsteen"), "My Hometown"),
                new Song(new Artist("JJ Johnson"), "Lament")
        )));
    }

}

您通过一个 Bean 来注册这些工具。

@SpringBootApplication
public class MyMcpServerPlanetApplication {

    public static void main(String[] args) {
        SpringApplication.run(MyMcpServerPlanetApplication.class, args);
    }

    @Bean
    public ToolCallbackProvider mcpServices(ArtistService artistService, SongService songService) {
        return MethodToolCallbackProvider.builder()
                .toolObjects(artistService, songService)
                .build();
    }

}

最后,添加以下 application.properties 配置。

spring.main.web-application-type=none
spring.ai.mcp.server.name=mcp-server
spring.ai.mcp.server.version=0.0.1

spring.main.banner-mode=off
logging.pattern.console=

此配置做了几件重要的事情:

  • 禁用 Web 应用程序:由于 MCP 使用 STDIO 传输,因此不需要 Web 服务器。
  • 设置服务器名称和版本:这用于向客户端标识 MCP 服务器。
  • 禁用横幅和控制台日志记录:这对于 STDIO 传输正常工作至关重要。

构建 jar 文件。

mvn clean verify

这将在 target 目录中生成一个 jar 文件:target/mcp-server-0.0.1-SNAPSHOT.jar

测试 MCP 服务器

为了测试 MCP 服务器,需要一个 MCP 客户端。如前所述,将使用 DevoxxGenie 来实现此目的。

将 MCP 服务器添加到 DevoxxGenie 的 MCP 设置中。请注意,您需要对命令和参数使用完整路径。

  • 名称:MyMcpServerPlanet
  • 传输类型:STDIO
  • 命令/<java installation directory>/bin/java
  • 参数-jar /home/<project directory>/mymcpserverplanet/server/target/mymcpserverplanet-0.0.1-SNAPSHOT.jar

Test MCP Server)

单击"测试连接并获取工具"按钮。按钮的标题应变更为"连接成功!找到 2 个工具"。

与 DevoxxGenie 一起,使用 LMStudio 作为推理引擎,模型 qwen3-8b 在 GPU 上运行。

输入提示:给我一个 Gunter 喜爱的艺术家列表。

该请求被发送到 LMStudio,可用的工具也随请求一起发送。如果您在 LMStudio 中启用了调试日志,可以检查该请求。

2025-07-20 11:45:42 [DEBUG]
 Received request: POST to /v1/chat/completions with body  {
  "model": "qwen3-8b",
  "messages": [
    {
      "role": "system",
      "content": "You are a software developer IDEA plugin with expe... <日志中已截断> ...at is correct and relevant to the code or plugin.\n"
    },
    {
      "role": "user",
      "content": "<ProjectPath>\n/home/<project directory>... <日志中已截断> ... list of gunter's favorite artists\n</UserPrompt>\n\n"
    }
  ],
  "temperature": 0,
  "top_p": 0.9,
  "stream": false,
  "max_tokens": 8000,
  "tools": [
    {
      "type": "function",
      "function": {
        "name": "get_songs",
        "description": "获取 Gunter 喜爱的歌曲完整列表",
        "parameters": {
          "type": "object",
          "properties": {},
          "required": []
        }
      }
    },
    {
      "type": "function",
      "function": {
        "name": "get_artists",
        "description": "获取 Gunter 喜爱的艺术家完整列表",
        "parameters": {
          "type": "object",
          "properties": {},
          "required": []
        }
      }
    }
  ]
}

LLM 将意识到它自己无法回答这个问题。然而,LLM 确实识别出有一个可用的工具可以回答这个问题。因此,LLM 将请求客户端调用 MCP 工具。DevoxxGenie 内置了人工介入机制,并将请求您的批准。
点击批准按钮

点击"批准"按钮。MCP 服务器将执行 get_artists 工具,并将响应发送回 LLM。LLM 将使用此响应来给出适当的答案。

收到以下响应,并且完全正确。观察 LLM 的思维过程也很有趣。

<think> 好的,用户要求提供 Gunter 喜爱的艺术家列表。我调用了 `get_artists` 函数,返回了两位艺术家:Bruce Springsteen 和 JJ Johnson。现在他们可能想知道是否还有更多或者需要进一步帮助。

我应该确认结果,也许可以询问他们是否还需要其他什么。由于之前的响应是成功的,保持积极的语气很好。工具调用没有错误,所以这里不需要额外的函数调用。 </think>

以下是 Gunter 喜爱的艺术家:

*   **Bruce Springsteen**
*   **JJ Johnson**

如果您需要更多信息或帮助,请告诉我!

让我们使用以下提示检查 get_songs 工具:给我一个 Gunter 喜爱的歌曲列表

遵循相同的过程,响应再次正确。

<think> 好的,用户要求提供 Gunter 喜爱的歌曲列表。我使用了 get_songs 函数,它检索到了两首歌曲。现在我需要清晰地呈现这些信息。让我用艺术家和歌名来格式化每首歌曲。确保响应易于阅读,也许用项目符号。检查是否需要任何额外信息,但既然用户只想要一个列表,保持简单应该是最好的。 </think>

以下是 Gunter 喜爱的歌曲列表:

1.  **Bruce Springsteen** - *《My Hometown》*
2.  **JJ Johnson** - *《Lament》*

如果您想了解这些曲目的更多详细信息,请告诉我!

添加搜索功能

让我们向两个 MCP 工具添加一些基本的搜索功能。

ArtistService 添加一个 search_artist 工具。

@Tool(name = "search_artist", description = "从 Gunter 喜爱的艺术家中搜索单个艺术家")
public Artist getArtist(String name) {
    return artists.stream()
            .filter(artist -> artist.name().equalsIgnoreCase(name))
            .findFirst()
            .orElse(null);
}

SongService 添加一个 search_song 工具。

@Tool(name = "search_song", description = "从 Gunter 喜爱的歌曲中搜索单首歌曲")
public Song getSong(String title) {
    return songs.stream()
            .filter(song -> song.title().equalsIgnoreCase(title))
            .findFirst()
            .orElse(null);
}

构建 jar 文件并使用 DevoxxGenie 获取新工具。

输入以下提示:Bruce Springsteen 是 Gunter 喜爱的艺术家之一吗?

这次调用了 search_artist 工具,响应再次正确。

<think> 好的,用户询问 Bruce Springsteen 是否是 Gunter 喜爱的艺术家之一。我使用 search_artist 函数并传入了他的名字。响应返回了艺术家 "Bruce Springsteen"。现在我需要检查他是否在 Gunter 的喜爱列表中。

等等,可用的函数是 get_artists 和 search_artist。search_artist 函数可能检查艺术家是否存在于数据库中。既然响应返回了该艺术家,那意味着 Bruce Springsteen 确实在 Gunter 喜爱的艺术家列表中。

所以答案应该是肯定的。我应该根据函数的响应来确认这一点。既然用户的问题已经得到回答,不需要进一步的操作。只需清晰地呈现结果即可。 </think>

是的,Bruce Springsteen 是 Gunter 喜爱的艺术家之一。搜索确认了他在收藏中。

输入以下提示:滚石乐队是 Gunter 喜爱的艺术家之一吗?

调用了 search_artist 工具,由于未找到滚石乐队,LLM 断定他们不在我喜爱的艺术家列表中。

<think> 好的,让我试着弄清楚这里发生了什么。用户询问滚石乐队是否是 Gunter 喜爱的艺术家之一。我使用 search_artist 函数,传入了名称 "Rolling Stones",但响应是 null。这意味着该函数在 Gunter 的喜爱列表中没有找到该名称的艺术家。

嗯,也许数据库中艺术家的名称不完全是 "Rolling Stones"。会不会有拼写错误或不同的拼写方式?或者也许他们在系统中以另一个名称而闻名。或者,也许是因为数据不存在,所以函数没有返回任何结果。

既然用户是在询问 Gunter 的喜爱列表,我应该检查是否有一种方法可以首先列出所有喜爱的艺术家。get_artists 函数可能会有所帮助。如果我调用它,也许我可以看到滚石乐队是否在列表中。但是等等,用户可能不想要所有艺术家,只是确认这一个特定的。

或者,也许 search_artist 函数需要不同的参数。让我再次检查工具定义。search_artist 函数需要一个 "name" 参数,这是必需的。那么查询应该是大小写不敏感的?或者可能是部分匹配?用户可能指的是 "Rolling Stones",但数据库中的存储方式不同。

另一个想法:也许滚石乐队不在 Gunter 喜爱的艺术家列表中。搜索返回了 null,所以可能他们不在。但我应该通过先检查所有艺术家来确认。我应该向用户建议这一点。等等,但用户是专门询问滚石乐队。也许我应该告知他们搜索没有找到任何结果,并询问他们是否想改为检查所有艺术家。

等等,响应是 null,所以函数没有返回任何数据。这意味着该艺术家不在列表中。所以答案应该是否定的,滚石乐队不是 Gunter 喜爱的艺术家之一。但是函数调用会不会有错误?让我再检查一遍。函数是 search_artist,名称是 "Rolling Stones"。如果数据库中有确切的名称,那么它应该返回。如果没有,则返回 null。

所以结论是滚石乐队不在 Gunter 的喜爱列表中。或者,可能存在拼写错误。但既然用户是直接询问,我应该根据函数的响应来回答。 </think>

在 Gunter 喜爱的艺术家列表中,对艺术家 "Rolling Stones" 的搜索未返回任何结果。这表明滚石乐队**目前并未**列为 Gunter 喜爱的艺术家之一。您想改为查看他喜爱的艺术家的完整列表吗?

结论

在本博客中,您学习了如何使用 Spring Boot 和 Spring AI 创建一个 MCP 服务器。创建 MCP 服务器和添加工具非常容易。您可以专注于工具的功能,而这正是您想要的。


【注】本文译自:Creating an MCP Server With Spring AI

Java 缓存精要

Java 缓存精要

实现更低延迟、降低成本并赋能智能体架构

作者:GRANVILLE BARNETT
架构师,HAZELCAST

缓存技术在系统中的作用日益重要,对于大规模解锁众多用例至关重要。几十年来,缓存已实现低成本、可扩展地访问会话状态和数据存储等信息。更现代的缓存用例正在实现低成本、可扩展的工具链,并在智能体架构中实现嵌入生成,这正在解锁下一代系统创新。

本参考资料卡介绍了使用 Java 的 JCache(Java 临时缓存 API)将缓存融入系统的方法。文中首先讨论了缓存的基础知识,然后通过代码示例简要介绍了 JCache API,最后总结了缓存部署架构。

缓存概述

缓存是先前计算结果的一个存储,以便可以省略后续计算。理解缓存最简单的方式是将其视为键值存储:对于给定的输入(键),输出(值)代表先前基于该输入计算出的结果。

缓存命中表示特定数据存在于缓存中,这种情况下可以使用其值。否则,就会发生缓存未命中,此时需要执行相关计算并将其输出放入缓存。缓存未命中的代价可能除了昂贵的计算操作外,还涉及网络通信。

图 1:简化的缓存命中/未命中流程

采用缓存是为了减少延迟并降低运营成本,几十年来对于实现众多类别的应用程序至关重要。缓存数据的例子包括 Web 应用程序的会话状态、数据库查询结果、网页渲染结果,以及来自通用网络和计算成本高昂的操作的结果。

缓存的一个更现代的用途是在 AI 领域。在这里,缓存的使用减少了昂贵的 API 调用(例如,嵌入生成),并最大限度地减少了智能体架构中智能体之间的对话断续(例如,由于工具调用和网络通信所致),从而解锁了新一波的解决方案和用户体验。

缓存可以驻留在进程内,作为客户端-服务器架构的一部分存在于服务中,或者是两者的结合。此外,缓存的部署通常可以组合。例如,应用程序可能与位于同一数据中心的缓存服务通信,而数据中心的本地缓存又是跨越多个数据中心的缓存的缓存。这种灵活性,加上缓存所支持的应用类别,使得缓存在过去几十年中成为一种主导的抽象概念。

本参考资料卡的剩余部分将讨论 JCache——Java 用于将缓存融入应用程序的抽象——首先简要概述您将经常使用的类,然后深入探讨 JCache 更广泛功能所提供的特性。最后,我们将总结缓存部署策略。

JCACHE 精要

JCache 在 Java 规范请求(JSR)107 中引入,并提供了一套关于缓存的抽象。JCache 有两个突出的特性:

  • JCache 是一个规范。 JSR 是由专家组设计和提交,并最终由 Java 社区过程执行委员会批准的规范。因为 JCache 是一个规范,所以它与那些 API 频繁变化的实现隔离开来。
  • JCache 是提供商独立的。 JCache 作为规范的一个副作用是,缓存解决方案提供商可以通过实现其暴露的服务提供程序接口(SPI)来与 JCache 集成。这为系统设计者提供了灵活性并避免了供应商锁定。

以下是一个简单的 JCache 示例,以便理解其使用方式。javax.cache 依赖项的获取方式可以在此处找到。

import java.util.Map;
import javax.cache.Cache;
import javax.cache.CacheManager;
import javax.cache.Caching;
import javax.cache.configuration.MutableConfiguration;
import javax.cache.spi.CachingProvider;

public class App {
    public static void main(String[] args) {
        CachingProvider cachingProvider = Caching.getCachingProvider(); // (1)
        CacheManager cacheManager = cachingProvider.getCacheManager(); // (2)
        MutableConfiguration<String, String> cacheConfig = new MutableConfiguration<String, String>(); // (3)
        Cache<String, String> cache = cacheManager.createCache("dzone-cache", cacheConfig); // (4)
        cache.put("England", "London"); // (5)
        cache.putAll(Map.of("France", "Paris", "Ireland", "Dublin")); // (6)
        assert cache.get("England").equals("London"); // (7)
        assert cache.get("Italy") == null; // (8)
    }
}

对上述示例的简要说明:
(1) 获取底层缓存提供程序的句柄
(2) 管理缓存的生命周期(例如,创建和销毁缓存)
(3) 允许启用/禁用缓存的特定功能(例如,统计信息、条目监听器)
(4) 创建由缓存提供程序支持的缓存
(5) 在缓存中放入单个键值条目
(6) 将键值条目放入缓存
(7) 断言缓存条目的存在
(8) 断言某个条目不在缓存中

本节的剩余部分将更详细地讨论上述示例中引入的抽象,以及您将经常遇到的相关类的其他方法。

javax.cache.spi.CachingProvider 构成了 JCache SPI,缓存提供者可以与之集成。您将使用的最常见功能是获取对 CacheManager 的引用。我们稍后将讨论 Caching

getCacheManagergetCacheManager 变体中最简单的一个。这将根据提供者的默认设置获取一个 CacheManager。可以使用 javax.cache.CacheManager 创建和销毁缓存:

  • createCache 创建一个具有给定名称和配置的缓存。
  • destroyCache 销毁具有给定名称的缓存。

javax.cache.Cache 是对提供者缓存的抽象,并暴露了少量用于查询和变更缓存项的操作:

  • putputAll 将条目放入缓存。请注意,这些方法不返回与正在放入的键先前关联的任何值。
  • containsKey 测试键是否存在于缓存中。
  • getgetAll 返回与指定键关联的值。
  • removeremoveAll 从缓存中移除项。

JCACHE 包

在本节中,我们将快速概述 javax.cache 更广泛包结构中的一些重要接口,并提供常用功能的示例。我们可以参考文档来浏览其内容的详尽列表。

图 2:javax.cache 的组成包

JAVAX.CACHE

通用管理(CacheManager)和与缓存交互(Cache)的设施位于 javax.cache 包内。除了初始配置之外,除非您想为缓存添加额外功能,否则仅使用此包中的类型就可以完成很多工作。例如,"JCache 精要"部分介绍中的示例用法主要使用了 javax.cache 中定义的接口。

JAVAX.CACHE.CONFIGURATION

在创建缓存期间,您可能希望添加功能,例如启用统计信息或通读缓存。此包提供了一个 Configuration 接口和一个实现 MutableConfiguration,可用于此类目的。

// ...
MutableConfiguration<String, String> cacheConfig = new MutableConfiguration<String, String>();
cacheConfig.setManagementEnabled(true).setStatisticsEnabled(true);
Cache<String, String> cache = cacheManager.createCache("dzone-cache", cacheConfig);

JAVAX.CACHE.EXPIRY

有时您希望驻留在缓存中的项过期。例如,我们可能有一个家庭保险报价的缓存,有效期为 24 小时。在这种情况下,我们可以使用过期策略如下:

// ...
MutableConfiguration<String, Double> cacheConfig = new MutableConfiguration<String, Double>();
cacheConfig.setExpiryPolicyFactory(CreatedExpiryPolicy.factoryOf(Duration.ONE_DAY));
Cache<String, Double> cache = cacheManager.createCache("insurance-home-quotes", cacheConfig);
cache.put(quote.getId(), quote.getValue());

javax.cache.expiry 包提供了额外的过期策略,可能对其他场景有用。例如,AccessedExpiryPolicy 允许基于缓存条目的最后访问时间附加过期设置。

JAVAX.CACHE.EVENT

JCache 的一个强大功能是能够订阅缓存事件。例如,我们可能希望在创建或删除缓存条目后运行某些领域逻辑。javax.cache.event 包提供了实现此功能的抽象,特别是订阅缓存创建、更新、过期和移除的能力。以下基本示例在缓存条目创建后运行某些领域逻辑:

// ...
CacheEntryCreatedListener<String, String> createdListener = new CacheEntryCreatedListener<String, String>() {
  @Override
  public void onCreated(Iterable<CacheEntryEvent<? extends String, ? extends String>> events) throws CacheEntryListenerException {
    for (var c : events) {
      performDomainLogic(c);
    }
  }
};
MutableConfiguration<String, String> cacheConfig = new MutableConfiguration<String, String>();
MutableCacheEntryListenerConfiguration<String, String> listenerConfig = new MutableCacheEntryListenerConfiguration<>(() -> createdListener, null, false, true); // 请参阅文档
cacheConfig.addCacheEntryListenerConfiguration(listenerConfig);
Cache<String, String> cache = cacheManager.createCache("events", cacheConfig);
cache.put("key", "value"); // 调用创建监听器

JAVAX.CACHE.PROCESSOR

JCache 的一个强大组件是能够使用 EntryProcessor 将计算移至数据所在处,然后以编程方式调用该计算。当使用在分布式系统(例如,Hazelcast)内托管其缓存的提供者时,这尤其强大,因为它以很少的投入为分布式计算提供了一个简单的入口点。以下是一个 EntryProcessor 的简单示例,它将 UUID 附加到缓存条目:

// ...
class AppendUuidEntryProcessor implements EntryProcessor<String, String, String> {
  @Override
  public String process(MutableEntry<String, String> entry, Object... arguments) throws EntryProcessorException {
    if (entry.exists()) {
      String newValue = entry.getValue() + "-" + UUID.randomUUID();
      entry.setValue(newValue);
      return newValue;
    }
    return null;
  }
}
// ...
cache.invoke(key, new AppendUuidEntryProcessor())

JAVAX.CACHE.MANAGEMENT

JCache 提供的管理钩子非常强大且易于启用。例如,下面的小代码片段暴露了由 Java 管理扩展(JMX)规范定义的托管 Bean。这使得诸如 jconsole 和 JDK Mission Control 之类的 JMX 客户端能够查看缓存配置和统计信息(例如,命中和未命中百分比、平均获取和放置时间)。

// ...
MutableConfiguration<String, String> cacheConfig = new MutableConfiguration<String, String>();
cacheConfig.setManagementEnabled(true).setStatisticsEnabled(true);
Cache<String, String> cache = cacheManager.createCache("management", cacheConfig);
// ...

JAVAX.CACHE.SPI

"JCache 精要"部分提供的示例省略了我们如何注册缓存提供者,即使用 JCache API 与我们的应用程序交互的缓存宿主服务。这就是 JCache 的 SPI 组件发挥作用的地方。

实现这一点有两个组成部分:

  1. 将我们的缓存提供者添加到类路径中
  2. 告诉 JCache 使用该提供者

第一步很简单:只需添加对任何符合 JSR 107 标准的提供者的依赖。

第二步有几种通用的方法:

  • 我们可以通过调用 Caching#getCachingProvider(...) 的某个变体(以及其他方法)来告诉 JCache。
  • 我们可以提供一个 META-INF/services/javax.cache.spi.CachingProvider 文件,并让其指定提供者实现。指定的提供者是您的提供者的缓存提供者实现的完全限定名称。
  • 我们可以使用 Caching#getCachingProvider();但是,最好明确限定要使用的提供者,因为您的类路径上可能有多个提供者,这会抛出 javax.cache.CacheException

例如,以下代码使用 CachingProvider Caching.getCachingProvider(String) 指定 Hazelcast 为提供者:

CachingProvider cachingProvider = Caching.getCachingProvider("com.hazelcast.cache.HazelcastCachingProvider");
CacheManager cacheManager = cachingProvider.getCacheManager();
MutableConfiguration<String, String> cacheConfig = new MutableConfiguration<String, String>();
Cache<String, String> cache = cacheManager.createCache("spi-example", cacheConfig);
cache.put("k", "v");

JAVAX.CACHE.ANNOTATION

JCache 定义了许多注解,用于集成到上下文和依赖注入环境中。Spring Framework 原生支持 JCache 注解。我们可以参考 JCache 文档以获取更多信息。

JAVAX.CACHE.INTEGRATION

javax.cache.integration 包提供了 CacheLoader(需要通读)和 CacheWriter(需要通写)。CacheLoader 在将数据读入缓存时使用——例如 Cache#loadAll(...)CacheWriter 可以作为一个集成点,将缓存变更(例如,写入、删除)传播到外部存储服务。

缓存部署

JCache 没有缓存部署策略的概念;它仅仅是缓存提供者之上的一个 API。然而,不同的提供者支持不同类型的缓存部署。请考虑哪种缓存部署对您的应用程序有意义,并由此反向确定合适的缓存提供者。

图 3: 缓存部署示例

请注意,一些缓存提供者可能支持所有这三种缓存部署,而其他提供者可能不支持。

本节的剩余部分讨论图 2 中所示的常见缓存部署:

  • 嵌入式 – 缓存与应用程序位于同一进程中。
  • 客户端-服务器 – 缓存托管在独立的服务中,客户端与该服务通信以确定缓存驻留。
  • 嵌入式/客户端-服务器 – 这是一种混合模式,整个缓存托管在不同的服务上,但客户端在同一进程中拥有一个较小的本地缓存。

重要的是要注意,上述缓存部署并非互斥的;它们可以通过多种方式组合以满足应用程序需求。

最简单的缓存部署是让缓存与应用程序驻留在同一进程中,这样做的好处是提供低延迟的缓存访问。嵌入式缓存不能在应用程序之间共享,并且在应用程序重启或故障时,其托管(它们所需的资源)和重建成本可能很高。

客户端-服务器缓存部署将缓存托管在与客户端不同的服务中。缓存服务允许通过跨服务复制来满足容错需求,提供更大的容量、更多的可扩展性选项,以及跨应用程序共享缓存的能力。客户端-服务器模型的主要缺点在于客户端缓存查询期间网络通信的成本。

混合嵌入式/客户端-服务器部署是指我们拥有一个嵌入式缓存,它包含来自服务缓存条目的一个子集,作为应用程序缓存请求的副作用被填充。在这里,客户端可以对频繁访问的数据(或表现出特定访问模式的数据)实现低延迟的缓存命中,并省去与缓存服务通信所带来的网络通信开销。如果嵌入式缓存过期,一些提供者会负责使用服务托管的缓存来更新它们。

结论

本参考资料卡介绍了缓存以及如何将其与 Java 的 JCache API 一起使用。JCache API 直观、强大,并且由于其是一个规范而避免了供应商锁定,为架构师和系统设计者提供了他们所需的灵活性。这种灵活性在我们进入基于智能体架构的新一代创新时尤为重要,其中缓存对于工具链和嵌入生成至关重要。

作者:GRANVILLE BARNETT,
架构师,HAZELCAST
Granville Barnett 拥有计算机科学博士学位,是拥有超过 15 年经验的分布式系统专家。他目前是 Hazelcast 的架构师,此前曾在 HP Labs 和 Microsoft 任职。Granville 拥有多项美国专利,并发表了关于程序验证主题的研究。

附加资源:


【注】本文译自:Java Caching Essentials

使用 Java、Spring Boot 和 Spring AI 开发符合 A2A 标准的 AI 智能体

AI 智能体指的是一种软件实体,它能够利用自然语言处理、机器学习或推理系统等人工智能技术,自主感知、推理和行动,以实现特定目标。

我为 Telex 开发了一个 AI 智能体,该智能体接收一个正则表达式模式,并就该模式所匹配的字符串类型提供易于理解的解释。开发此智能体的灵感源于我在此之前开发的一个 API(您可以在此处查看该项目),在该 API 中我必须使用正则表达式进行一些自然语言处理。尽管我之前学习过正则表达式,但感觉像是第一次见到它。正则表达式就是这样。因此,当 Telex 为其平台寻求更多 AI 智能体时,我决定开发这个智能体。

以下是我使用 Java、Spring AI 和 Spring Boot 实现它的过程。

初始设置

1. Spring Boot 项目初始化

我使用 Spring 提供的初始化工具来初始化项目。请注意,我在依赖项中包含了 Spring Web 和 Open AI。

初始化 Spring 项目

2. 设置 API 凭证

在我的 application.properties 文件中,我设置了 Spring AI 以使用我的 API 凭证(我的 API 密钥)。我通过 Google AI Studio 获得了一个免费的 Google Gemini API 密钥。我的 application.properties 文件设置如下:

    spring.config.import=classpath:AI.properties

    spring.application.name=regexplain

    spring.ai.openai.api-key = ${GEMINI_API_KEY}
    spring.ai.openai.base-url = https://generativelanguage.googleapis.com/v1beta/openai
    spring.ai.openai.chat.completions-path = /chat/completions
    spring.ai.openai.chat.options.model = gemini-2.5-pro

第一行导入了包含我 API 密钥的文件。重要的是不要将您的 API 密钥暴露给公众。该文件与 application.properties 位于同一文件夹中。

3. 首次项目运行

使用我的包管理器(Maven),我安装了所需的依赖项。然后我运行了我的主类,以确保一切正常。如果您到目前为止一切都做对了,您的项目应该可以无错误运行。如果遇到任何错误,请在 Google 上查找解决方法。

A2A 请求和响应模型

在深入实现之前,让我们先谈谈符合 A2A 标准的请求和响应的结构。A2A 协议遵循标准的 JSON-RPC 2.0 结构来处理请求和响应。

所有方法调用都封装在一个请求对象中,其结构如下:

{
  "jsonrpc": "2.0",
  "method": "String",
  "id": "String | Integer",
  "params": "Message"
}

响应对象有些类似:

{
  "jsonrpc": "2.0",
  "id": "String | Integer | null",
  "result?": "Task | Message | null",
  "error?": "JSONRPCError"
}

响应中的 ID 必须与请求中的 ID 相同。

有关 A2A 协议的更多信息,请查阅 A2A 协议文档

以上就是请求和响应的通用结构。我开发这个智能体是为了在 Telex 平台上使用,因此我的部分实现可能特定于 Telex。

现在进入实现部分。我创建了一个名为 model 的文件夹,用于存储我的模型。请求模型类 A2ARequest 如下所示:

public class A2ARequest {
    private String id;
    private RequestParamsProperty params;

    public A2ARequest(String id, RequestParamsProperty params) {
        this.id = id;
        this.params = params;
    }

    // getters and setters
}

RequestParamsProperty 类代表了 params 中包含信息的结构。它如下所示:

public class RequestParamsProperty {
    private HistoryMessage message;
    private String messageId;

    public RequestParamsProperty(HistoryMessage message, String messageId) {
        this.message = message;
        this.messageId = messageId;
    }

    // getters and setter
}

HistoryMessage 类如下所示:

@JsonIgnoreProperties(ignoreUnknown = true)
@JsonInclude(JsonInclude.Include.NON_NULL)
public class HistoryMessage {
    private String kind;
    private String role;
    private List<MessagePart> parts;
    private String messageId;
    private String taskId;

    public HistoryMessage() {}

    public HistoryMessage(String role, List<MessagePart> parts, String messageId, String taskId) {
        this.kind = "message";
        this.role = role;
        this.parts = parts;
        this.messageId = messageId;
        this.taskId = taskId;
    }

    // getters and setters
}

注解的作用是让 Spring 知道在请求和响应的 JSON 表示中包含什么。如果请求中不存在某个属性,它应该忽略它并在类中将其设置为 null。如果某个属性设置为 null,则不应将其包含在响应中。

MessagePart 类如下所示:

@JsonIgnoreProperties(ignoreUnknown = true)
@JsonInclude(JsonInclude.Include.NON_NULL)
public class MessagePart {
    private String kind;
    private String text;
    private List<MessagePart> data;

    public MessagePart(String kind, String text, List<MessagePart> data) {
        this.kind = kind;
        this.text = text;
        this.data = data;
    }

    // getters and setters
}

以上就是表示从 Telex 接收的请求结构所需的所有类。现在需要为我的响应创建一个模型,以及表示响应所需的所有支持类。

A2AResponse 类:

@JsonInclude(JsonInclude.Include.NON_NULL)
public class A2AResponse {
    private final String jsonrpc;
    @JsonInclude(JsonInclude.Include.ALWAYS)
    private String id;
    private Result result;
    private CustomError error;

    public A2AResponse() {
        this.jsonrpc = "2.0";
    }

    public A2AResponse(String id, Result result, CustomError error) {
        this.jsonrpc = "2.0";
        this.id = id;
        this.result = result;
        this.error = error;
    }

    //getters and setters
}

Result 类:

public class Result {
    private String id;
    private String contextId;
    private TaskStatus status;
    private List<Artifact> artifacts;
    private List<HistoryMessage> history;
    private String kind;

    public Result() {}

    public Result(String id, String contextId, TaskStatus status, List<Artifact> artifacts, List<HistoryMessage> history, String task) {
        this.id = id;
        this.contextId = contextId;
        this.status = status;
        this.artifacts = artifacts;
        this.history = history;
        this.kind = task;
    }

    // getters and setters
}

CustomError 类:

public class CustomError {
    private int code;
    private String message;
    private Map<String, String> data;

    public CustomError(int code, String message, Map<String, String> data) {
        this.code = code;
        this.message = message;
        this.data = data;
    }

    // getters and setters
}

TaskStatus 类:

@JsonInclude(JsonInclude.Include.NON_NULL)
public class TaskStatus {
    private String state;
    private Instant timestamp;
    private HistoryMessage message;

    public TaskStatus() {}

    public TaskStatus(String state, Instant timestamp, HistoryMessage message) {
        this.state = state;
        this.timestamp = timestamp;
        this.message = message;
    }

    // getters and setters
}

Artifact 类:

public class Artifact {
    private String artifactId;
    private String name;
    private List<MessagePart> parts; // 稍后复查此类型

    public Artifact() {}

    public Artifact(String artifactId, String name, List<MessagePart> parts) {
        this.artifactId = artifactId;
        this.name = name;
        this.parts = parts;
    }

    // getters and setters
}

A2A 协议还包含一个称为"智能体卡片"的东西。我也为它创建了一个模型。

public class AgentCard {
    private String name;
    private String description;
    private String url;
    private Map<String, String> provider;
    private String version;
    private Map<String, Boolean> capabilities;
    private List<String> defaultInputModes;
    private List<String> defaultOutputModes;
    private List<Map<String, Object>> skills;

    public AgentCard() {
        this.provider = new HashMap<>();
        this.capabilities = new HashMap<>();
        this.skills = new ArrayList<>();
    }

    // getters and setters
}

模型部分就这些了。继续…

服务类

我的智能体的作用是获取一个正则表达式字符串,然后使用预定义的提示词将其发送到 OpenAI 的 API。服务类负责与 OpenAI 通信,发送提示词并接收响应。我创建了另一个名为 service 的文件夹,我的服务类就放在这里。我是这样编写我的服务类的:

@Service
public class RegExPlainService {
    private ChatClient chatClient;

    RegExPlainService(ChatClient.Builder chatClientBuilder) {
        this.chatClient = chatClientBuilder.build();
    }

    @Tool(name = "regexplain", description = "An agent that explains what type of string a regex pattern matches")
    public String generateResponse(String regex) {
        return chatClient
                .prompt("Give me a simple explanation of the type of string matched by this regex pattern: %s. No validating statements from you. Just straight to the point".formatted(regex))
                .call()
                .content();
    }
}

@Service 注解允许 Spring Boot 将服务注入到您的控制器中。@Tool 注解将该方法标记为一个智能体工具,如果将来要扩展该智能体以包含该功能,它可以被自主调用。不过目前并不需要它。

控制器

控制器通过 REST API 暴露该智能体。在这个案例中,我有两个端点,一个 GET 端点和一个 POST 端点。我在一个名为 controller 的文件夹中创建了我的控制器。实现如下:

@RestController
public class RegExPlainController {
    private final RegExPlainService regexplainService;

    @Autowired
    RegExPlainController (RegExPlainService regexplainService) {
        this.regexplainService = regexplainService;
    }

    @GetMapping("/a2a/agent/regexplain/.well-known/agent.json")
    public ResponseEntity<AgentCard> getAgentCard () {
        AgentCard agentCard = new AgentCard();
        agentCard.setName("regexplain");
        agentCard.setDescription("An agent that provides a simple explanation of the type of string a regex pattern matches");
        agentCard.setUrl("regexplain-production.up.railway.app/api");
        agentCard.setProvider("Bituan", null); // 假设 setProvider 处理 Map 的填充
        agentCard.setVersion("1.0");
        agentCard.setCapabilities(false, false, false); // 假设 setCapabilities 处理 Map 的填充
        agentCard.setDefaultInputModes(List.of("text/plain"));
        agentCard.setDefaultOutputModes(List.of("application/json", "text/plain"));
        agentCard.setSkill("skill-001", "Explain Regex", "Provides a simple explanation of the type of string a regex pattern matches",
                List.of("text/plain"), List.of("text/plain"), List.of());

        return ResponseEntity.ok(agentCard);
    }

    @PostMapping("/a2a/agent/regexplain")
    public ResponseEntity<A2AResponse> explainRegex (@RequestBody A2ARequest request) {
        String regexRequest;
        String responseText;

        // 如果参数无效,返回 403
        try {
            regexRequest = request.getParams().getMessage().getParts().get(0).getText();
        } catch (Exception e) {
            CustomError error = new CustomError(-32603, "Invalid Parameter", Map.of("details", e.getMessage()));
            A2AResponse errorResponse = new A2AResponse(null, null,  error);
            return ResponseEntity.status(HttpStatusCode.valueOf(403)).body(errorResponse);
        }

        // 如果调用服务失败,返回错误 500
        try {
            responseText = regexplainService.generateResponse(regexRequest);
        } catch (Exception e) {
            CustomError error = new CustomError(-32603, "Internal Error", Map.of("details", e.getMessage()));
            A2AResponse errorResponse = new A2AResponse(null, null,  error);
            return ResponseEntity.internalServerError().body(errorResponse);
        }

        // 构建响应
        A2AResponse response = new A2AResponse();
        response.setId(request.getId());

        // 构建响应 -> 构建结果
        Result result = new Result();
        result.setId(UUID.randomUUID().toString());
        result.setContextId(UUID.randomUUID().toString());
        result.setKind("task");

        // 构建响应 -> 构建结果 -> 构建状态
        TaskStatus status = new TaskStatus();
        status.setState("completed");
        status.setTimestamp(Instant.now());

        // 构建响应 -> 构建结果 -> 构建状态 -> 构建消息
        HistoryMessage message = new HistoryMessage();
        message.setRole("agent");
        message.setParts(List.of(new MessagePart("text", responseText, null)));
        message.setKind("message");
        message.setMessageId(UUID.randomUUID().toString());

        // 构建响应 -> 构建结果 -> 构建状态 (续)
        status.setMessage(message);

        // 构建响应 -> 构建结果 -> 构建工件
        List<Artifact> artifacts = new ArrayList<>();
        Artifact artifact = new Artifact();
        artifact.setArtifactId(UUID.randomUUID().toString());
        artifact.setName("regexplainerResponse");
        artifact.setParts(List.of(new MessagePart("text", responseText, null)));
        artifacts.add(artifact);

        // 构建响应 -> 构建结果 -> 构建历史记录
        List<HistoryMessage> history = new ArrayList<>();

        // 构建响应 -> 构建结果 (续)
        result.setStatus(status);
        result.setArtifacts(artifacts);
        result.setHistory(history);

        // 构建响应 (续)
        response.setResult(result);

        return ResponseEntity.ok(response);
    }
}
  • GET 端点使用的路由路径是 A2A 协议标准中用于获取智能体卡片的部分。智能体卡片是对智能体及其功能的描述。
  • POST 端点接收一个符合 A2A 标准的请求,执行智能体,然后返回适当的响应。

结论

就是这样。这就是我编写 Regexplain 的过程。

通过这个示例,您可以从头开始构建您的 AI 智能体并使其符合 A2A 标准。或者,至少我希望这能让您对如何使用 Java 开发符合 A2A 标准的 AI 智能体有所了解。


【注】本文译自:Developing an A2A-compliant AI Agent with Java, Spring Boot and Spring AI – DEV Community

Java智能体框架的繁荣是一种代码异味

停止构建编排框架,开始构建智能体。未来属于那些掌握生态系统的人,而不是那些被困在构建特定语言引擎中的人。

我需要坦白。我是一个框架狂热者。我的职业生涯建立在 Apache Camel 之上,我人生中的大部分成功都归功于企业集成模式的优雅。我懂。如果有一个社区值得获得诺贝尔框架奖,那就是 Java 社区。从早年在红帽公司到整个大数据生态系统,框架 15 年来一直是 JVM 世界的引擎。我们是抽象的大师。

因此,当智能体时代来临而 Java 在奋力追赶时,我的第一本能是原始的:构建一个框架。我甚至开始了一个,驱动力是这样一个想法:"AI 智能体的 Apache Camel 在哪里?"

三个月前,可能只有一个严肃的 Java 智能体框架。现在,包括 Embabel 在内,已经有了三个。竞赛开始了。但目睹这场爆炸式增长,我不得不提出一个难题:框架本身是否正在成为一种反模式?我们是否在为自己创造负担,而不是专注于真正重要的事情:构建智能体

最近 Java 智能体框架的繁荣并非一个健康、成熟生态系统的标志。它是一种症状。一种架构层面的代码坏味道,告诉我们我们的方法存在根本性问题。

我们最初为什么要构建框架?

让我们回顾一下。为什么像 Spring 和 Camel 这样的框架变得如此主流?原因清晰且合理:

  • 开发人员生产力: 我们当时淹没在样板代码中。框架将其抽象掉了。
  • 代码质量与治理: 它们提供了标准化的模式,防止每个开发人员重新发明轮子。
  • 可重用性: 它们为我们提供了经过实战检验的构造来构建,节省了大量的时间和精力。

目标是优化生产力、质量和治理。但这些是我们今天应该优化的相同参数吗?感觉我们像是在用 2010 年的方法解决 2025 年的问题,完全忽视了房间里的大象:AI 驱动的开发工具

这头大象有个名字:Cursor(及其伙伴)

在我们忙于将 LangChain 移植到 Java 时,情况发生了变化:

Cursor 和 Copilot 生成样板代码的速度比你输入 import 语句还快。你正在构建的那个复杂的链式抽象?Cursor 三秒钟就能写出来。你正在标准化的那个工具注册模式?Copilot 已经知道五种变体。

但在这里,我们需要停下来问一个更根本的问题:你的最终用户实际需要什么?

你真正需要构建什么?

让我们具体点。我们大多数人面临两种情况:

  • 场景 1: 你在未来 12 个月内构建一个关键智能体。也许它是一个每天处理 10,000 次对话的客户服务智能体。或者一个需要理解你公司特定标准的代码审查智能体。或者一个绝不能对监管要求产生幻觉的合规智能体。
  • 场景 2: 你正在构建一个智能体平台。成百上千个智能体,每个都有不同的上下文、不同的领域、不同的需求。也许你在一家咨询公司,为多个客户构建智能体。或者你正在创建一个内部平台,不同团队可以在上面启动自己的智能体。你需要可重用、适应性强、可演进的东西。一种能让你快速创建新智能体,同时保持所有智能体一致性和质量的东西。

在这两种情况下,诚实地问自己:你的用户需要一个代码框架吗?

还是他们需要完全不同的东西?

重新定义框架

在放弃我的框架并实际交付智能体之后,我学到了:我们不需要消除框架。我们需要重新定义在 AI 时代框架实际意味着什么。

  • 旧框架定义: 一种可重用的代码抽象,提供结构并处理横切关注点。你导入并在其之上构建的东西。
  • 新框架定义: 构建智能体的完整环境,一组协同工作的相互依赖的层,其中代码层只是更大拼图的一部分。

以下是现代智能体框架中真正重要的层次:

第 1 层:语言本身

Java(或你选择的语言)及其构造、类型和模式。不包装在抽象中,直接使用。语言已经是你的逻辑结构框架。你不需要在 Java 之上再加一个代码框架。Java 本身就是框架。

第 2 层:模型

一个真正好的大语言模型:GPT-5、Claude、Gemini、Grok。这不仅仅是你调用的 API。它是你技术栈的核心组件。模型的能力直接决定了你能构建什么。像选择编程语言一样仔细地选择它。

第 3 层:开发人员生产力工具

Cursor、Copilot 以及下一代 AI 驱动的开发工具。这些不是可选的。它们是关键基础设施。你的框架应设计成与这些工具无缝协作。如果 Cursor 不能轻松地按照你的模式生成代码,那么你的模式是错误的,或者你可能需要很好地描述你的模式。

第 4 层:提示词包与指南

你经过版本控制、测试、治理的提示词。你的组织语音。你的领域知识。你的合规规则。这是你的业务逻辑存在的地方——不在代码中,而在精心策划的上下文和指令中。将这些视为你的依赖构件,就像 JAR 包,但用于智能体行为。

第 5 层:生态系统 API

对新兴的专业化平台及其 API 的上下文感知。用于知识检索的向量数据库。上下文存储和内存管理系统,如 Zep 或 Cognee。工具执行平台,如 Arcade。用于智能体监控的可观测性平台,如 Langfuse。提示词管理和版本控制工具。这些大多暴露 REST API 或标准协议,大多提供 LLM.txt 用于上下文导入。你的框架需要知道这些存在,并知道如何连接到它们。

第 6 层:架构与设计模式

作为指南和模式捕获的架构思维。关于这些层如何在不同用例中组合在一起的可重用蓝图。不是代码抽象——关于路由逻辑、版本控制策略、部署模式和生态系统集成的文档化知识,这些知识成为你组织上下文的一部分。

想想看。当你构建那个关键的客户服务智能体时,真正决定其成功的是什么?

  • 调用 LLM 的 Java 代码吗?(那是 20 行代码,Cursor 写的)
  • 复杂的链式编排吗?(标准控制流)
  • 重试逻辑和错误处理吗?(Java 已经有这方面的模式)

还是:

  • 选择的模型以及它处理你领域的能力
  • 教导它你的升级规则和语气的提示词
  • 让你能快速迭代这些提示词的工具
  • 与像 Arcade(工具)和 Zep(内存)这样的平台的集成
  • 让你能够对变更进行版本控制、测试和部署的架构
  • 让你能在多个智能体中重用这种方法的设计模式

那就是你的框架。所有六层,协同工作。

实践中的框架

让我向你展示在构建智能体时的实际示例:

第 4 层(提示词包) 是版本化的构件,不是你代码中的字符串:

prompts/
  customer-service/
    v1.2/
      system-prompt.md
      escalation-rules.md
      tone-guidelines.md
      product-context.md
      examples/
        refund-scenarios.yaml
        technical-issues.yaml

第 5 层(生态系统 API) 配置在你的环境中:
你的生态系统上下文嵌入在指南中:

# 生态系统集成指南

## 工具发现
- 调用 Arcade API 列出可用工具: GET /tools
- 参考: 查看 Arcade LLM.txt 位于 https://docs.arcade.dev/llms.txt

## 内存管理
- Zep 会话 API: https://api.getzep.com/v2/sessions/{session_id}
- 参考: 查看 Zep API 文档位于 https://docs.getzep.com

## 基础设施与存储
- 用于提示词构件的对象存储: S3, GCS, 或 Azure Blob
- 用于长时间运行工作流的状态持久化

第 1 层(Java) 提供结构,干净简单:

public class CustomerServiceAgent {
    private final Model model;
    private final PromptPack prompts;
    private final ArcadeClient tools;
    private final ZepMemory memory;

    public Response handle(CustomerQuery query) {
        // 检索会话内存
        var history = memory.getSession(query.sessionId());

        // 从 Arcade 获取可用工具
        var availableTools = tools.listTools();

        // 使用上下文渲染提示词
        var context = prompts.render("main", query, history, availableTools);

        return model.complete(context);
    }
}

第 3 层(Cursor) 在几秒钟内生成这段代码。你专注于架构。

第 6 层(架构) 指南:

# 智能体架构指南

## 工作流路由
- 为多节点智能体工作流设计路由逻辑
  - 分类节点 → 路由到专家节点(支持、销售、技术)
  - 复杂性分析 → 路由到适当的模型层级(GPT-4o vs GPT-3.5)
  - 工具选择节点 → 根据用户意图路由到工具执行节点
- 通过 Arcade 网关路由工具执行:集中认证、速率限制、工具发现
- 提示词版本的 A/B 路由:10% 到 v2.0,90% 到 v1.5,监控质量

## 速率限制与节流
- 每用户令牌预算:10K 令牌/天(免费),100K(付费)
- 队列管理:最大 100 个并发请求,溢出到 SQS...
..
..

为什么这个框架能扩展

  • 对于一个关键智能体: 选择你的模型(第 2 层),编写清晰的代码(第 1 层),用 Cursor 迭代(第 3 层),优化提示词(第 4 层),集成生态系统 API(第 5 层),遵循架构模式(第 6 层)。
  • 对于一千个智能体: 相同的模型,相同的架构模式,相同的生态系统 API,但每个智能体都有自己的提示词包。Cursor 生成样板代码。你的语言提供结构。生态系统处理难题。

美妙之处何在?各层协同工作。Cursor 生成代码是因为模式简单。提示词是独立版本控制的。集成使用 REST API。架构无需抽象即可实现重用。

不需要编排框架。这就是框架。

引擎与 SDK 的问题

让我澄清一下:我并不是说所有框架都应该消失。我对 LangChain、LangGraph、Mastra、CrewAI、Autogen 等团队所构建的东西怀有极大的敬意。但我们需要理解一个在急于将所有东西移植到 Java 的过程中被忽视的关键区别。

不要混淆引擎SDK

我的意思是:我迫不及待地想用 Java 开发完整的智能体。我热爱 Java。但我不想仅仅因为我想用 Java 开发智能体就要一个 Java 引擎

考虑这些例子:

  • LangChain4J? 作为连接更广泛的 LangChain 生态系统的 SDK,这是一个很好的开始。你用 Java 编写,但你正在利用一个经过验证的引擎。
  • 带有 Java SDK 的 Crew AI? 完美。在 Python 中掌握编排模式,然后给我一个 Java 接口来使用它们。
  • 支持多语言的 Mastra? 正是正确的方向。构建一次引擎,为每种语言提供 SDK。
  • 为使用 Go 构建的 Not7 添加 Java SDK 或任何语言 SDK?

这里的模式是?用你喜欢的语言开发,而无需用该语言重建整个引擎。

编排层正在变薄

这就是为什么我认为即使是 SDK 方法也可能是暂时的,或者至少变得非常精简的原因:

  • 一方面: 模型正变得 dramatically 更好。GPT-5、Claude 4.5、Gemini 2.5 Pro、Grok 的推理能力使得复杂的编排模式过时了。它们可以用简单的提示词处理多步骤工作流,而这在六个月前需要复杂的链。
  • 另一方面: 真正的工程问题正在由专业平台解决。以 Arcade 为例:工具发现、认证、大规模执行、处理速率限制、管理工具版本。这才是艰难的工程工作所在。工具管理不再是编排问题;它是在平台层解决的基础设施问题。
  • 在中间: 编排框架正被挤压得越来越薄。

当你的模型能够推理工作流,并且平台处理复杂的工程问题(工具、内存、上下文)时,编排还剩下什么?

答案是:非常少。这就是为什么工程重点需要从编排转向更广泛的智能体开发挑战——提示词管理、生态系统集成、工具决策可审计性、成本优化。真正的问题已不在编排之中。

新现实:AI 原生框架

代码坏味道不仅仅是我们构建了太多框架。而是我们正在为一个不复存在的世界构建框架。以下是 2025 年构建框架实际意味着什么:

方面 过去的框架思维模式 (2005-2024) 下一代框架思维模式 (2025+)
定义 需要导入的代码库 跨越6个层级的完整环境
业务逻辑 位于代码抽象中 位于版本化提示词与指南中
关键构件 JAR 文件、软件包 提示词、上下文、API 知识
可重用性 代码继承与组合 架构模式与蓝图
开发工具 用于编写代码的 IDE 用于生成代码的 AI 工具(如 Cursor)
生态系统 自包含、单体式 集成专业化平台
样板代码 由框架抽象处理 由 AI 在几秒内生成
你导入/使用什么 Spring、Camel、框架 JAR 包 无需导入——你只需组合这些层级
  1. 接受 AI 驱动的开发现实 每个构建智能体的开发人员都将使用 Cursor、Copilot 或类似工具。这不是趋势——这是新的基线。设计你的框架以与 AI 代码生成无缝协作,而不是背道而驰。如果 Cursor 无法理解你的模式,那你的模式就是错的。
  2. 你的框架是纯文本英语,而不仅仅是代码 你的框架最关键部分将是精心设计的提示词、清晰的指南和结构化的上下文——而不是聪明的抽象。这些是你的版本化构件。这些决定了智能体行为。像对待代码一样严格对待它们。
  3. 当你需要 SDK 时,不要重新发明引擎 是的,Java SDK 至关重要。但你不需要仅仅为了用 Java 编写智能体就重建整个编排引擎。生态系统已经有平台在解决难题:内存(Zep, Mem0)、工具(MCPs, Arcade)、向量(Weaviate, Pinecone, Qdrant)、可观测性等。集成,不要重建。
  4. 框架仍然至关重要——但不是为了编排 如果你正在解决真正的问题——提示词版本控制、决策可审计性、生态系统集成模式、成本优化——那就构建这些。但编排?生态系统已经向前发展了。内存、工具、上下文、可观测性正由专业平台解决。将你的创新重点放在其他地方。
  5. 相信你的语言 如果你觉得你选择的语言中缺少一个框架,请退后一步。现代语言——Java、Python、TypeScript、Go——非常强大。凭借它们的最新特性加上 AI 代码生成工具,你可以用干净、简单的代码构建复杂的智能体。你的语言不是限制——试图用不必要的抽象包装它才是。

未来的框架不是你导入的代码库。它是对六个相互依赖层的掌握:你的语言、你的模型、你的开发工具、你的提示词、你的生态系统集成和你的架构。

也许我们不需要另一个智能体框架。也许我们所需要的只是一个智能体,一个能用你选择的语言创建智能体的智能体。一个开源的就更好了。


【注】本文译自:Java’s Agentic Framework Boom is a Code Smell

Java 运行时安全:输入验证、沙箱机制、安全反序列化

你的 Java 应用程序刚刚被攻破了。攻击者发送了一个精心构造的 JSON 载荷,你的反序列化代码"尽职尽责"地执行了它,现在他们正在下载你的客户数据库。这并非假设场景——它曾在 Equifax、Apache 以及无数其他公司真实发生过。

运行时安全与防火墙或身份验证无关。它关注的是不受信任的数据进入你的应用程序之后会发生什么。攻击者能否诱使你的代码执行你从未打算做的事情?答案通常是"可以",除非你刻意提高了攻击难度。

Java 为你提供了自卫的工具。大多数开发者忽略了它们,因为这些工具看起来偏执或过于复杂。然后生产环境就遭到了入侵,突然间那些"偏执"的措施就显得相当合理了。

为何运行时安全被忽视

你专注于功能。安全评审即使有,也往往在后期进行。代码在测试中能工作,于是就发布了。然后有人发现你的公共 API 未经验证就接受了用户输入,或者发现你正在反序列化不受信任的数据,或者意识到你的插件系统以完全权限运行第三方代码。

问题在于,大多数漏洞在你编写它们时看起来并不危险。一个简单的 ObjectInputStream.readObject() 调用看似无害,直到有人解释它如何实现远程代码执行。跳过输入验证节省了五分钟的开发时间,却在六个月后让你付出安全事件的代价。

安全不吸引人,它不会在演示中体现,而且在出事之前很难量化。但运行时安全问题是在生产系统中最常被利用的漏洞之一。让我们来谈谈三大要点:输入验证、沙箱机制和反序列化。

输入验证:万物皆不可信

每一个从外部进入你应用程序的数据都是潜在的攻击向量。用户输入、API 请求、文件上传、来自共享数据库的数据库记录、配置文件——所有这些都是。

规则很简单:在边界验证一切。不要等到业务逻辑中再验证。不要假设前端已经验证过了。在数据进入你的系统时进行验证。

糟糕的验证示例

以下是我在生产环境中经常看到的代码:

@PostMapping("/users")
public ResponseEntity<User> createUser(@RequestBody UserRequest request) {
    User user = new User();
    user.setEmail(request.getEmail());
    user.setAge(request.getAge());
    user.setRole(request.getRole());

    userRepository.save(user);
    return ResponseEntity.ok(user);
}

看起来没问题,对吧?这是一场灾难。攻击者可以发送:

  • 邮箱:"admin@evil.com<script>alert('xss')</script>"
  • 年龄:-1999999
  • 角色:"ADMIN"(提升自己的权限)

你的应用程序会欣然接受所有这一切,因为你信任了输入。

正确的输入验证

以下是正确的做法:

public class UserRequest {
    @NotNull(message = "Email is required")
    @Email(message = "Must be a valid email")
    @Size(max = 255, message = "Email too long")
    private String email;

    @NotNull(message = "Age is required")
    @Min(value = 0, message = "Age must be positive")
    @Max(value = 150, message = "Age unrealistic")
    private Integer age;

    @NotNull(message = "Role is required")
    @Pattern(regexp = "^(USER|MODERATOR)$", message = "Invalid role")
    private String role;
}

@PostMapping("/users")
public ResponseEntity<User> createUser(@Valid @RequestBody UserRequest request) {
    // 如果验证失败,Spring 自动返回 400 Bad Request

    User user = new User();
    user.setEmail(sanitizeEmail(request.getEmail()));
    user.setAge(request.getAge());
    user.setRole(request.getRole());

    userRepository.save(user);
    return ResponseEntity.ok(user);
}

private String sanitizeEmail(String email) {
    // 额外防护层:清除任何 HTML/脚本标签以防万一
    return email.replaceAll("<[^>]*>", "");
}

注意这种分层方法。Bean 验证注解捕获明显的问题。然后即使在验证之后,你还要对输入进行清理。这种深度防御方法意味着即使一层失效,你仍然受到保护。

验证复杂对象

真实的应用程序处理的是嵌套对象、列表和复杂结构:

public class OrderRequest {
    @NotNull
    @Valid  // 这很关键 - 验证嵌套对象
    private Customer customer;

    @NotEmpty(message = "Order must contain items")
    @Size(max = 100, message = "Too many items")
    @Valid
    private List<OrderItem> items;

    @NotNull
    @DecimalMin(value = "0.01", message = "Total must be positive")
    private BigDecimal total;
}

public class OrderItem {
    @NotBlank
    @Size(max = 50)
    private String productId;

    @Min(1)
    @Max(999)
    private Integer quantity;

    @DecimalMin("0.01")
    private BigDecimal price;
}

嵌套对象上的 @Valid 注解很容易被忘记,但至关重要。没有它,嵌套对象会完全绕过验证。

用于业务规则的自定义验证器

有时 Bean 验证还不够。你需要业务逻辑:

@Target({ElementType.FIELD})
@Retention(RetentionPolicy.RUNTIME)
@Constraint(validatedBy = SafeFilenameValidator.class)
public @interface SafeFilename {
    String message() default "Unsafe filename";
    Class<?>[] groups() default {};
    Class<? extends Payload>[] payload() default {};
}

public class SafeFilenameValidator implements ConstraintValidator<SafeFilename, String> {
    private static final Pattern DANGEROUS_PATTERNS = Pattern.compile(
        "(\\.\\./|\\.\\.\\\\|[<>:\"|?*]|^\\.|\\.$)"
    );

    @Override
    public boolean isValid(String filename, ConstraintValidatorContext context) {
        if (filename == null) {
            return true; // 单独使用 @NotNull
        }

        // 防止路径遍历攻击
        if (DANGEROUS_PATTERNS.matcher(filename).find()) {
            return false;
        }

        // 白名单方法:只允许安全字符
        if (!filename.matches("^[a-zA-Z0-9_.-]+$")) {
            return false;
        }

        return true;
    }
}

现在你可以在任何文件上传参数上使用 @SafeFilename。这可以捕获攻击者试图上传到 ../../../etc/passwd 的路径遍历攻击。

白名单与黑名单的陷阱

在验证输入时,开发者常常试图阻止"坏"字符。这是黑名单方法,而且几乎总是错误的:

// 不好:黑名单方法
public boolean isValidUsername(String username) {
    return !username.contains("<") && 
           !username.contains(">") && 
           !username.contains("'") &&
           !username.contains("\"") &&
           !username.contains("script");
           // 你永远无法列出所有危险模式
}

攻击者很有创造力。他们会使用 Unicode 字符、URL 编码、双重编码以及你没想到的技巧来绕过你的黑名单。

相反,应该对你允许的内容使用白名单:

// 好:白名单方法
public boolean isValidUsername(String username) {
    return username.matches("^[a-zA-Z0-9_-]{3,20}$");
    // 只允许字母数字、下划线、连字符,3-20个字符
}

如果不在明确允许的范围内,就拒绝。这样安全得多。

沙箱机制:限制损害

输入验证阻止坏数据进入。沙箱机制则限制代码即使攻击成功也能做的事情。如果你的应用程序运行不受信任的代码——插件、用户脚本、动态类加载——沙箱机制至关重要。

Java 安全管理器(传统方法)

多年来,Java 使用安全管理器进行沙箱处理。它在 Java 17 中已被弃用并将被移除,但理解它有助于掌握概念:

// 旧方法(已弃用)
System.setSecurityManager(new SecurityManager());

// 在策略文件中定义权限
grant codeBase "file:/path/to/untrusted/*" {
    permission java.io.FilePermission "/tmp/*", "read,write";
    permission java.net.SocketPermission "example.com:80", "connect";
    // 权限非常有限
};

安全管理器可以限制代码能做什么:文件访问、网络访问、系统属性访问等。它功能强大但复杂,并且有性能开销。

现代沙箱方法

没有安全管理器,你需要替代策略。

在独立进程中隔离。 最可靠的沙箱是进程边界:

public class PluginExecutor {
    public String executePlugin(String pluginPath, String input) throws Exception {
        ProcessBuilder pb = new ProcessBuilder(
            "java",
            "-Xmx256m",  // 限制内存
            "-classpath", pluginPath,
            "com.example.PluginRunner",
            input
        );

        // 限制进程能做的事情
        pb.environment().clear();  // 无环境变量
        pb.directory(new File("/tmp/sandbox"));  // 受限目录

        Process process = pb.start();

        // 超时保护
        if (!process.waitFor(10, TimeUnit.SECONDS)) {
            process.destroyForcibly();
            throw new TimeoutException("Plugin execution timeout");
        }

        return new String(process.getInputStream().readAllBytes());
    }
}

插件在它自己的、资源受限的进程中运行。如果它崩溃或行为不端,你的主应用程序不会受到影响。你可以使用容器或虚拟机实现更强的隔离。

使用带有限制的自定义 ClassLoader:

public class SandboxedClassLoader extends ClassLoader {
    private final Set<String> allowedPackages;

    public SandboxedClassLoader(Set<String> allowedPackages) {
        super(SandboxedClassLoader.class.getClassLoader());
        this.allowedPackages = allowedPackages;
    }

    @Override
    protected Class<?> loadClass(String name, boolean resolve) 
            throws ClassNotFoundException {
        // 阻止危险的类
        if (name.startsWith("java.lang.Runtime") ||
            name.startsWith("java.lang.ProcessBuilder") ||
            name.startsWith("sun.misc.Unsafe")) {
            throw new ClassNotFoundException("Access denied: " + name);
        }

        // 仅白名单特定的包
        boolean allowed = allowedPackages.stream()
            .anyMatch(name::startsWith);

        if (!allowed) {
            throw new ClassNotFoundException("Package not whitelisted: " + name);
        }

        return super.loadClass(name, resolve);
    }
}

// 用法
Set<String> allowed = Set.of("com.example.safe.", "org.apache.commons.lang3.");
ClassLoader sandboxed = new SandboxedClassLoader(allowed);
Class<?> pluginClass = sandboxed.loadClass("com.example.safe.UserPlugin");

这可以防止插件加载危险的类。它并非无懈可击——坚定的攻击者可能会找到基于反射的变通方法——但它显著提高了攻击门槛。

限制资源消耗:

public class ResourceLimitedExecutor {
    private final ExecutorService executor = Executors.newFixedThreadPool(4);

    public <T> T executeWithLimits(Callable<T> task, 
                                   long timeoutSeconds,
                                   long maxMemoryMB) throws Exception {
        // 通过超时限制 CPU/时间
        Future<T> future = executor.submit(task);

        try {
            return future.get(timeoutSeconds, TimeUnit.SECONDS);
        } catch (TimeoutException e) {
            future.cancel(true);
            throw new RuntimeException("Task exceeded time limit");
        }

        // 内存限制更难——最好在 JVM 级别使用 -Xmx 处理
        // 或者使用如前所示的进程隔离
    }
}

如果你强制执行超时,即使是不受信任的代码也无法消耗无限的 CPU。内存更棘手——进程隔离或容器限制比尝试在 JVM 内强制执行效果更好。

真实世界的沙箱示例

假设你正在构建一个运行用户提交的数据转换脚本的系统:

public class ScriptSandbox {
    private static final long MAX_EXECUTION_TIME_MS = 5000;
    private static final String SANDBOX_DIR = "/tmp/script-sandbox";

    public String executeScript(String script, String data) {
        // 1. 验证脚本没有明显的恶意
        if (containsDangerousPatterns(script)) {
            throw new SecurityException("Script contains forbidden patterns");
        }

        // 2. 将脚本写入隔离目录
        Path scriptPath = Paths.get(SANDBOX_DIR, UUID.randomUUID().toString() + ".js");
        Files.writeString(scriptPath, script);

        try {
            // 3. 在具有资源限制的独立进程中执行
            ProcessBuilder pb = new ProcessBuilder(
                "timeout", String.valueOf(MAX_EXECUTION_TIME_MS / 1000),
                "node",
                "--max-old-space-size=100",  // 100MB 内存限制
                scriptPath.toString()
            );

            pb.directory(new File(SANDBOX_DIR));
            pb.redirectErrorStream(true);

            Process process = pb.start();

            // 4. 通过 stdin 传递数据,从 stdout 读取结果
            try (OutputStream os = process.getOutputStream()) {
                os.write(data.getBytes());
            }

            String result = new String(process.getInputStream().readAllBytes());

            int exitCode = process.waitFor();
            if (exitCode != 0) {
                throw new RuntimeException("Script failed with exit code: " + exitCode);
            }

            return result;

        } finally {
            // 5. 清理
            Files.deleteIfExists(scriptPath);
        }
    }

    private boolean containsDangerousPatterns(String script) {
        // 检查明显的攻击
        return script.contains("require('child_process')") ||
               script.contains("eval(") ||
               script.contains("Function(") ||
               script.matches(".*\\brequire\\s*\\(.*");
    }
}

这个例子结合了多种防御措施:静态分析、进程隔离、资源限制和清理。没有单一的防御是完美的,但层层设防使得利用难度大大增加。

安全反序列化:最大的隐患

Java 反序列化漏洞是历史上一些最严重安全漏洞的罪魁祸首。问题在于其根本性质:反序列化可以在对象构造期间执行任意代码。

为何反序列化是危险的

当你反序列化一个对象时,Java 会调用构造函数、readObject 方法和其他代码。控制序列化数据的攻击者可以精心构造对象来执行任意命令:

// 危险代码 - 请勿在生产环境中使用
public void loadUserSettings(byte[] data) {
    try (ObjectInputStream ois = new ObjectInputStream(new ByteArrayInputStream(data))) {
        UserSettings settings = (UserSettings) ois.readObject();
        applySettings(settings);
    }
}

这看起来无害。但攻击者可以发送包含你类路径上(如 Apache Commons Collections)库中对象的序列化数据,这些对象在反序列化期间会执行系统命令。他们甚至根本不需要接触你的 UserSettings 类。

臭名昭著的"工具链"就是利用这一点。通过以特定方式链式组合标准库类,攻击者实现了远程代码执行。像 ysoserial 这样的工具可以自动创建这些载荷。

切勿反序列化不受信任的数据

最安全的方法很简单:不要对来自不受信任来源的数据使用 Java 序列化。绝不。

改用 JSON、Protocol Buffers 或其他仅包含数据的格式:

// 安全:使用 JSON
public UserSettings loadUserSettings(String json) {
    ObjectMapper mapper = new ObjectMapper();
    return mapper.readValue(json, UserSettings.class);
}

像 Jackson 这样的 JSON 解析器在解析期间不会执行任意代码。它们只是填充字段。攻击面急剧缩小。

当你必须反序列化时

有时你无法摆脱 Java 序列化——遗留协议、缓存库或分布式计算框架。如果你绝对必须反序列化不受信任的数据,请使用防御措施。

使用 ObjectInputFilter (Java 9+):

public Object safeDeserialize(byte[] data) throws Exception {
    try (ObjectInputStream ois = new ObjectInputStream(new ByteArrayInputStream(data))) {
        // 白名单允许的类
        ObjectInputFilter filter = ObjectInputFilter.Config.createFilter(
            "com.example.UserSettings;" +
            "com.example.UserPreference;" +
            "java.util.ArrayList;" +
            "java.lang.String;" +
            "!*"  // 拒绝其他所有类
        );

        ois.setObjectInputFilter(filter);

        return ois.readObject();
    }
}

该过滤器明确地将安全的类加入白名单,并拒绝其他所有类。这阻止了依赖于意外可用类的工具链。

验证对象图:

public class SafeObjectInputStream extends ObjectInputStream {
    private final Set<String> allowedClasses;
    private int maxDepth = 10;
    private int currentDepth = 0;

    public SafeObjectInputStream(InputStream in, Set<String> allowedClasses) 
            throws IOException {
        super(in);
        this.allowedClasses = allowedClasses;
    }

    @Override
    protected Class<?> resolveClass(ObjectStreamClass desc) 
            throws IOException, ClassNotFoundException {
        // 检查深度以防止深度嵌套的对象
        if (++currentDepth > maxDepth) {
            throw new InvalidClassException("Max depth exceeded");
        }

        String className = desc.getName();

        // 白名单检查
        if (!allowedClasses.contains(className)) {
            throw new InvalidClassException("Class not allowed: " + className);
        }

        return super.resolveClass(desc);
    }

    @Override
    protected ObjectStreamClass readClassDescriptor() 
            throws IOException, ClassNotFoundException {
        ObjectStreamClass desc = super.readClassDescriptor();
        currentDepth--;
        return desc;
    }
}

这个自定义实现通过跟踪反序列化深度和执行严格的白名单来增加另一层防御。

对序列化数据进行签名:

public class SignedSerializer {
    private final SecretKey signingKey;

    public byte[] serialize(Object obj) throws Exception {
        // 序列化对象
        ByteArrayOutputStream baos = new ByteArrayOutputStream();
        try (ObjectOutputStream oos = new ObjectOutputStream(baos)) {
            oos.writeObject(obj);
        }
        byte[] data = baos.toByteArray();

        // 创建签名
        Mac mac = Mac.getInstance("HmacSHA256");
        mac.init(signingKey);
        byte[] signature = mac.doFinal(data);

        // 合并签名和数据
        ByteBuffer buffer = ByteBuffer.allocate(signature.length + data.length);
        buffer.put(signature);
        buffer.put(data);

        return buffer.array();
    }

    public Object deserialize(byte[] signedData) throws Exception {
        ByteBuffer buffer = ByteBuffer.wrap(signedData);

        // 提取签名和数据
        byte[] signature = new byte[32];  // HmacSHA256 产生 32 字节
        buffer.get(signature);

        byte[] data = new byte[buffer.remaining()];
        buffer.get(data);

        // 验证签名
        Mac mac = Mac.getInstance("HmacSHA256");
        mac.init(signingKey);
        byte[] expectedSignature = mac.doFinal(data);

        if (!MessageDigest.isEqual(signature, expectedSignature)) {
            throw new SecurityException("Signature verification failed");
        }

        // 签名有效则反序列化
        try (ObjectInputStream ois = new ObjectInputStream(new ByteArrayInputStream(data))) {
            return ois.readObject();
        }
    }
}

签名可以防止攻击者篡改序列化数据。没有签名密钥,他们无法注入恶意对象。这在数据可能暴露但不受攻击者直接控制时(如客户端存储或缓存系统)有效。

替代序列化库

有几个库提供了更安全的序列化:

Kryo 提供更好的性能,并且可以配置为使用白名单:

Kryo kryo = new Kryo();
kryo.setRegistrationRequired(true);  // 拒绝未注册的类
kryo.register(UserSettings.class);
kryo.register(ArrayList.class);

// 序列化
Output output = new Output(new FileOutputStream("file.bin"));
kryo.writeObject(output, userSettings);
output.close();

// 反序列化 - 只允许注册的类
Input input = new Input(new FileInputStream("file.bin"));
UserSettings settings = kryo.readObject(input, UserSettings.class);
input.close();

Protocol BuffersApache Avro 使用基于模式的序列化。它们设置起来比较繁琐,但完全避免了代码执行风险:

message UserSettings {
  string theme = 1;
  int32 fontSize = 2;
  repeated string favorites = 3;
}

这些格式只反序列化数据,从不反序列化代码。通过 protobuf 反序列化实现代码执行是不可能的。

真实世界安全事件:一个警示故事

我曾咨询过的一家公司有一个管理门户,用于接受文件上传以进行批处理。代码看起来像这样:

@PostMapping("/admin/import")
public String importData(@RequestParam("file") MultipartFile file) {
    try {
        byte[] data = file.getBytes();
        ObjectInputStream ois = new ObjectInputStream(new ByteArrayInputStream(data));
        DataImport importData = (DataImport) ois.readObject();

        processImport(importData);
        return "Import successful";
    } catch (Exception e) {
        return "Import failed: " + e.getMessage();
    }
}

开发人员认为这是安全的,因为该端点需要管理员身份验证。他们遗漏的是:

  • 攻击者通过钓鱼攻击攻陷了一个低级别管理员账户
  • 攻击者使用 ysoserial 上传了一个恶意的序列化载荷
  • 在反序列化期间,载荷执行了系统命令
  • 攻击者获得了应用程序服务器的 shell 访问权限
  • 从那里,他们横向移动到数据库并窃取了客户数据

修复需要多次更改:

@PostMapping("/admin/import")
public String importData(@RequestParam("file") MultipartFile file) {
    // 验证文件类型
    if (!file.getContentType().equals("application/json")) {
        return "Only JSON imports allowed";
    }

    // 验证文件大小
    if (file.getSize() > 10 * 1024 * 1024) {  // 10MB 限制
        return "File too large";
    }

    try {
        // 使用 JSON 代替 Java 序列化
        ObjectMapper mapper = new ObjectMapper();
        mapper.disable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES);

        DataImport importData = mapper.readValue(
            file.getInputStream(), 
            DataImport.class
        );

        // 验证导入的数据
        validateImportData(importData);

        // 在受限上下文中处理
        processImportSafely(importData);

        return "Import successful";
    } catch (Exception e) {
        log.error("Import failed", e);
        return "Import failed - check logs";
    }
}

这次事件使他们付出了事件响应、法律费用和声誉损失方面的数百万代价。全都是因为一个不安全的反序列化调用。

实用安全检查清单

以下是你在每个 Java 应用程序中都应该做的事情:

输入验证:

  • 在所有 DTO 上使用 Bean 验证注解
  • 使用 @Valid 验证嵌套对象
  • 白名单允许的模式,不要黑名单危险模式
  • 即使在验证之后也要清理数据
  • 验证文件上传:类型、大小、内容
  • 绝不只依赖客户端验证

沙箱机制:

  • 在独立进程或容器中运行不受信任的代码
  • 使用自定义 ClassLoader 来限制类访问
  • 强制执行资源限制:内存、CPU 时间、磁盘空间
  • 清理临时文件和资源
  • 记录所有沙箱违规行为

反序列化:

  • 优先使用 JSON/Protocol Buffers 而非 Java 序列化
  • 没有过滤器的情况下切勿反序列化不受信任的数据
  • 使用 ObjectInputFilter 将类加入白名单
  • 可能时对序列化数据进行签名
  • 定期审计类路径依赖项以查找已知的工具类
  • 考虑使用需要注册模式的 Kryo

通用实践:

  • 保持依赖项更新(漏洞利用针对特定版本)
  • 使用静态分析工具捕获安全问题
  • 记录安全相关事件以进行监控
  • 使用恶意输入进行测试,而不仅仅是正常路径
  • 假设一切都可以被攻击

有用的工具

SpotBugsFindSecBugs 插件可在构建时捕获常见安全问题:

<plugin>
    <groupId>com.github.spotbugs</groupId>
    <artifactId>spotbugs-maven-plugin</artifactId>
    <configuration>
        <plugins>
            <plugin>
                <groupId>com.h3xstream.findsecbugs</groupId>
                <artifactId>findsecbugs-plugin</artifactId>
                <version>1.12.0</version>
            </plugin>
        </plugins>
    </configuration>
</plugin>

OWASP Dependency-Check 识别易受攻击的依赖项:

<plugin>
    <groupId>org.owasp</groupId>
    <artifactId>dependency-check-maven</artifactId>
    <executions>
        <execution>
            <goals>
                <goal>check</goal>
            </goals>
        </execution>
    </executions>
</plugin>

SnykDependabot 在漏洞披露时自动更新依赖项。

思维模式的转变

安全不是你最后添加的功能。它是你从一开始就为之设计的约束。每次你接受外部输入时,问问自己:"攻击者利用这个能做的最坏的事情是什么?" 每次你反序列化数据时,问问:"我是否完全信任这个数据的来源?"

在代码审查中偏执是一种美德。当某人的 PR 包含反序列化或动态类加载时,积极地提出质疑。当缺少输入验证时,把它打回去。在代码审查中显得迂腐,也比在漏洞发生后显得疏忽要好。

运行时安全是关于减少信任。不要信任用户输入。不要信任插件。不要信任序列化数据。不要信任你的验证是完美的。层层设防,这样当一层失效时——它会的——其他层可以捕获攻击。

好消息是,一旦你内化了这些模式,它们就会成为第二天性。输入验证变得自动进行。你会本能地避免 Java 序列化。你会带着隔离的思想进行设计。安全成为你编码风格的一部分,而不是事后附加的东西。

有用资源


【注】本文译自:
Runtime Security in Java: Input Validation, Sandboxing, Safe Deserialization

Java 21 虚拟线程 vs 缓存线程池与固定线程池

探索 Java 并发如何从 Java 8 的增强发展到 Java 21 的虚拟线程,从而实现轻量级、可扩展且高效的多线程处理。

引言

并发编程仍然是构建可扩展、响应式 Java 应用程序的关键部分。多年来,Java 持续增强了其多线程编程能力。本文回顾了从 Java 8 到 Java 21 并发的演进,重点介绍了重要的改进以及 Java 21 中引入的具有重大影响的虚拟线程。

从 Java 8 开始,并发 API 出现了显著的增强,例如原子变量、并发映射以及集成 lambda 表达式以实现更具表现力的并行编程。

Java 8 引入的关键改进包括:

  • 线程与执行器
  • 同步与锁
  • 原子变量与 ConcurrentMap

Java 21 于 2023 年底发布,带来了虚拟线程这一重大演进,从根本上改变了 Java 应用程序处理大量并发任务的方式。虚拟线程为服务器应用程序提供了更高的可扩展性,同时保持了熟悉的"每个请求一个线程"的编程模型。

或许,Java 21 中最重要的特性就是虚拟线程。
在 Java 21 中,Java 的基本并发模型保持不变,Stream API 仍然是并行处理大型数据集的首选方式。
随着虚拟线程的引入,并发 API 现在能提供更好的性能。在当今的微服务和可扩展服务器应用领域,线程数量必须增长以满足需求。虚拟线程的主要目标是使服务器应用程序能够实现高可扩展性,同时仍使用简单的"每个请求一个线程"模型。

虚拟线程

在 Java 21 之前,JDK 的线程实现使用的是操作系统线程的薄包装器。然而,操作系统线程代价高昂:

  • 如果每个请求在其整个持续时间内消耗一个操作系统线程,线程数量很快就会成为可扩展性的瓶颈。
  • 即使使用线程池,吞吐量仍然受到限制,因为实际线程数量是有上限的。

虚拟线程的目标是打破 Java 线程与操作系统线程之间的 1:1 关系。
虚拟线程应用了类似于虚拟内存的概念。正如虚拟内存将大的地址空间映射到较小的物理内存一样,虚拟线程允许运行时通过将它们映射到少量操作系统线程来制造拥有许多线程的假象。

平台线程是操作系统线程的薄包装器。
而虚拟线程并不绑定到任何特定的操作系统线程。虚拟线程可以执行平台线程可以运行的任何代码。这是一个主要优势——现有的 Java 代码通常无需修改或仅需少量修改即可在虚拟线程上运行。虚拟线程由平台线程承载,这些平台线程仍然由操作系统调度。

例如,您可以像这样创建一个使用虚拟线程的执行器:

ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor();

对比示例

虚拟线程仅在主动执行 CPU 密集型任务时才消耗操作系统线程。虚拟线程在其生命周期内可以在不同的载体线程上挂载或卸载。

通常,当虚拟线程遇到阻塞操作时,它会自行卸载。一旦该阻塞任务完成,虚拟线程通过挂载到任何可用的载体线程上来恢复执行。这种挂载和卸载过程频繁且透明地发生——不会阻塞操作系统线程。

示例 — 源代码

Example01CachedThreadPool.java

在此示例中,使用缓存线程池创建了一个执行器:

var executor = Executors.newCachedThreadPool()
package threads;

import java.time.Duration;
import java.util.concurrent.Executors;
import java.util.stream.IntStream;

/**
 *
 * @author Milan Karajovic <milan.karajovic.rs@gmail.com>
 *
 */

public class Example01CachedThreadPool {

    public void executeTasks(final int NUMBER_OF_TASKS) {

        final int BLOCKING_CALL = 1;
        System.out.println("Number of tasks which executed using 'newCachedThreadPool()' " + NUMBER_OF_TASKS + " tasks each.");

        long startTime = System.currentTimeMillis();

        try (var executor = Executors.newCachedThreadPool()) {

            IntStream.range(0, NUMBER_OF_TASKS).forEach(i -> {
                executor.submit(() -> {
                    // 模拟阻塞调用
                    Thread.sleep(Duration.ofSeconds(BLOCKING_CALL));
                    return i;
                });
            });

        } catch (Exception e) {
            throw new RuntimeException(e);
        }

        long endTime = System.currentTimeMillis();
        System.out.println("For executing " + NUMBER_OF_TASKS + " tasks duration is: " + (endTime - startTime) + " ms");
    }

}
package threads;

import org.junit.jupiter.api.MethodOrderer;
import org.junit.jupiter.api.Order;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.TestMethodOrder;

/**
 *
 * @author Milan Karajovic <milan.karajovic.rs@gmail.com>
 *
 */

@TestMethodOrder(MethodOrderer.OrderAnnotation.class)
public class Example01CachedThreadPoolTest {

    @Test
    @Order(1)
    public void test_1000_tasks() {
        Example01CachedThreadPool example01CachedThreadPool = new Example01CachedThreadPool();
        example01CachedThreadPool.executeTasks(1000);
    }

    @Test
    @Order(2)
    public void test_10_000_tasks() {
        Example01CachedThreadPool example01CachedThreadPool = new Example01CachedThreadPool();
        example01CachedThreadPool.executeTasks(10_000);
    }

    @Test
    @Order(3)
    public void test_100_000_tasks() {
        Example01CachedThreadPool example01CachedThreadPool = new Example01CachedThreadPool();
        example01CachedThreadPool.executeTasks(100_000);
    }

    @Test
    @Order(4)
    public void test_1_000_000_tasks() {
        Example01CachedThreadPool example01CachedThreadPool = new Example01CachedThreadPool();
        example01CachedThreadPool.executeTasks(1_000_000);
    }

}

我 PC 上的测试结果:

Example02FixedThreadPool.java

使用固定线程池创建执行器:

var executor = Executors.newFixedThreadPool(500)
package threads;

import java.time.Duration;
import java.util.concurrent.Executors;
import java.util.stream.IntStream;

/**
 *
 * @author Milan Karajovic <milan.karajovic.rs@gmail.com>
 *
 */

public class Example02FixedThreadPool {

    public void executeTasks(final int NUMBER_OF_TASKS) {

        final int BLOCKING_CALL = 1;
        System.out.println("Number of tasks which executed using 'newFixedThreadPool(500)' " + NUMBER_OF_TASKS + " tasks each.");

        long startTime = System.currentTimeMillis();

        try (var executor = Executors.newFixedThreadPool(500)) {

            IntStream.range(0, NUMBER_OF_TASKS).forEach(i -> {
               executor.submit(() -> {
                   // 模拟阻塞调用
                  Thread.sleep(Duration.ofSeconds(BLOCKING_CALL));
                  return i;
               });
            });

        }   catch (Exception e) {
            throw new RuntimeException(e);
        }

        long endTime = System.currentTimeMillis();
        System.out.println("For executing " + NUMBER_OF_TASKS + " tasks duration is: " + (endTime - startTime) + " ms");
    }

}
package threads;

import org.junit.jupiter.api.MethodOrderer;
import org.junit.jupiter.api.Order;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.TestMethodOrder;

/**
 *
 * @author Milan Karajovic <milan.karajovic.rs@gmail.com>
 *
 */

@TestMethodOrder(MethodOrderer.OrderAnnotation.class)
public class Example02FixedThreadPoolTest {

    @Test
    @Order(1)
    public void test_1000_tasks() {
        Example02FixedThreadPool example02FixedThreadPool = new Example02FixedThreadPool();
        example02FixedThreadPool.executeTasks(1000);
    }

    @Test
    @Order(2)
    public void test_10_000_tasks() {
        Example02FixedThreadPool example02FixedThreadPool = new Example02FixedThreadPool();
        example02FixedThreadPool.executeTasks(10_000);
    }

    @Test
    @Order(3)
    public void test_100_000_tasks() {
        Example02FixedThreadPool example02FixedThreadPool = new Example02FixedThreadPool();
        example02FixedThreadPool.executeTasks(100_000);
    }

    @Test
    @Order(4)
    public void test_1_000_000_tasks() {
        Example02FixedThreadPool example02FixedThreadPool = new Example02FixedThreadPool();
        example02FixedThreadPool.executeTasks(1_000_000);
    }

}

我 PC 上的测试结果:

Example03VirtualThread.java

使用虚拟线程每任务执行器创建执行器:

var executor = Executors.newVirtualThreadPerTaskExecutor()
package threads;

import java.time.Duration;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.stream.IntStream;

/**
 *
 * @author Milan Karajovic <milan.karajovic.rs@gmail.com>
 *
 */

public class Example03VirtualThread {

    public void executeTasks(final int NUMBER_OF_TASKS) {

        final int BLOCKING_CALL = 1;
        System.out.println("Number of tasks which executed using 'newVirtualThreadPerTaskExecutor()' " + NUMBER_OF_TASKS + " tasks each.");

        long startTime = System.currentTimeMillis();

        try (ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor()) {

            IntStream.range(0, NUMBER_OF_TASKS).forEach(i -> {
               executor.submit(() -> {
                   // 模拟阻塞调用
                  Thread.sleep(Duration.ofSeconds(BLOCKING_CALL));
                  return i;
               });
            });

        }   catch (Exception e) {
            throw new RuntimeException(e);
        }

        long endTime = System.currentTimeMillis();
        System.out.println("For executing " + NUMBER_OF_TASKS + " tasks duration is: " + (endTime - startTime) + " ms");
    }

}
package threads;

import org.junit.jupiter.api.MethodOrderer;
import org.junit.jupiter.api.Order;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.TestMethodOrder;

/**
 *
 * @author Milan Karajovic <milan.karajovic.rs@gmail.com>
 *
 */

@TestMethodOrder(MethodOrderer.OrderAnnotation.class)
public class Example03VirtualThreadTest {

    @Test
    @Order(1)
    public void test_1000_tasks() {
        Example03VirtualThread example03VirtualThread = new Example03VirtualThread();
        example03VirtualThread.executeTasks(1000);
    }

    @Test
    @Order(2)
    public void test_10_000_tasks() {
        Example03VirtualThread example03VirtualThread = new Example03VirtualThread();
        example03VirtualThread.executeTasks(10_000);
    }

    @Test
    @Order(3)
    public void test_100_000_tasks() {
        Example03VirtualThread example03VirtualThread = new Example03VirtualThread();
        example03VirtualThread.executeTasks(100_000);
    }

    @Test
    @Order(4)
    public void test_1_000_000_tasks() {
        Example03VirtualThread example03VirtualThread = new Example03VirtualThread();
        example03VirtualThread.executeTasks(1_000_000);
    }

    @Test
    @Order(5)
    public void test_2_000_000_tasks() {
        Example03VirtualThread example03VirtualThread = new Example03VirtualThread();
        example03VirtualThread.executeTasks(2_000_000);
    }

}

我 PC 上的测试结果:

结论

您可以清楚地看到用于处理所有 NUMBER_OF_TASKS 的不同执行器实现之间的执行时间差异。值得尝试不同的 NUMBER_OF_TASKS 值以观察性能变化。

虚拟线程的优势在处理大量任务时变得尤其明显。当 NUMBER_OF_TASKS 设置为较高的数值时——例如 1,000,000——性能差距是显著的。如下表所示,虚拟线程在处理大量任务时效率要高得多:

我确信,在澄清这一点之后,如果您的应用程序使用并发 API 处理大量任务,您会认真考虑迁移到 Java 21 并利用虚拟线程。在许多情况下,这种转变可以显著提高应用程序的性能和可扩展性。

源代码:GitHub Repository – Comparing Threads in Java 21


【注】本文译自:Java 21 Virtual Threads vs Cached and Fixed Threads