Java平台模块系统:遗留应用的迁移策略

Java平台模块系统(JPMS)随Java 9引入,代表了该平台最重大的变革之一。对于运行遗留单体应用的组织而言,JPMS通过显式的模块边界和强封装,提供了一条通往更好可维护性、安全性和可扩展性的道路。然而,迁移需要周密的规划和策略。本文为成功将遗留Java应用迁移至模块化架构提供了实用指南。

理解JPMS解决的问题

遗留Java单体应用通常存在紧耦合(组件深度交织,导致更改风险高)和弱封装(所有内容全局可访问,增加错误)的问题。其他挑战还包括构建速度慢(微小更改需要完全重建)以及有限的可扩展性(难以独立部署部分功能)。

JPMS通过引入显式的模块边界、强封装和更好的依赖管理来解决这些问题。该模块系统允许您分组包,并严格控制哪些属于公共API,模块会显式声明其依赖项,而不是仅仅依赖类路径。

什么是模块?

模块用于分组包,并严格控制哪些包属于公共API。与Jar文件不同,模块显式声明它们依赖哪些模块,以及它们导出哪些包。模块声明存在于源层次结构根目录下名为 module-info.java 的文件中。

以下是一个基本的模块声明:

module com.company.data {
    requires java.sql;
    requires java.logging;

    exports com.company.data.api;
    // 内部包不会被导出
}

导出包的公共成员将被依赖模块访问,而私有成员则无法访问(即使通过反射,具体取决于Java版本设置)。

迁移准备

在着手模块化之前,您需要了解应用程序的结构和依赖关系。

步骤1:分析依赖关系

JDeps(Java依赖分析工具)是一个命令行工具,用于处理Java字节码并分析类之间静态声明的依赖关系。该工具对于迁移规划至关重要。

在您的应用程序上运行 jdeps 以获取依赖关系概览:

jdeps -s myapp.jar

这将提供一个摘要,显示您的应用程序依赖哪些JDK模块。要进行更详细的分析:

jdeps --class-path 'libs/*' -summary -recursive myapp.jar

步骤2:识别JDK内部API使用情况

最关键的迁移挑战之一是识别对JDK内部API的依赖,这些API在Java 9+中不再可访问。使用 jdeps 查找这些有问题的依赖:

jdeps -jdkinternals myapp.jar

如果您使用了内部API,jdeps 可能会建议替换方案以帮助您更新代码。例如,如果您的代码使用了 sun.misc.BASE64Encoderjdeps 将建议改用 java.util.Base64

步骤3:创建依赖关系图

理解应用程序的一个有效方法是使用分层图,以分层方式表示依赖关系。这有助于识别哪些组件可以迁移以及迁移顺序。

您可以生成DOT文件用于可视化:

jdeps --dot-output . myapp.jar

然后使用像Graphviz这样的工具或像 webgraphviz.com 这样的在线服务来可视化生成的 .dot 文件。

步骤4:识别循环依赖

JPMS禁止模块之间存在循环依赖。解决此问题的常用方法是创建一个包含共享代码的中间模块,从而打破循环。

例如,如果模块A依赖于B,而模块B又依赖于A,您需要将共享功能提取到一个新的模块C中,让A和B都依赖于C。

迁移策略:自顶向下 vs 自底向上

JPMS支持两种主要的迁移策略,每种策略适用于不同的场景。

自底向上迁移

在此策略中,最初所有JAR文件都位于类路径上。然后,通过一个逐步的过程,所有JAR都可以逐个迁移到模块路径上。

过程:

  1. 选择最低层级的模块:从对您的其他JAR没有依赖关系的模块开始。
  2. 添加 module-info.java:创建一个模块描述符,定义其依赖项和导出项。
  3. 移动到模块路径:此JAR成为命名模块。
  4. 向上重复:继续处理下一层级的模块。

一个三层应用程序(Utils → Services → Application)的示例进展:

# 步骤1:模块化Utils(无内部依赖)
jdeps --generate-module-info . utils.jar

# 步骤2:模块化Services(依赖于Utils)
jdeps --generate-module-info . services.jar

# 步骤3:模块化Application(依赖于两者)
jdeps --generate-module-info . application.jar

自底向上迁移允许底层模块强制执行强封装,而上层模块在过渡期间仍可以访问模块化和非模块化的依赖项。

自顶向下迁移

在此策略中,最初所有JAR文件都位于模块路径上,因此所有未迁移的项目都被视为自动模块。然后,您选择依赖层次结构中尚未迁移的最高层级的项目。

过程:

  1. 将所有JAR放在模块路径上:非模块化JAR成为自动模块。
  2. 从顶部开始:首先模块化您的主应用程序模块。
  3. 添加 module-info.java:使用自动模块名称定义依赖项。
  4. 向下工作:逐渐用适当的命名模块替换自动模块。

自顶向下迁移鼓励在更高层级进行仔细的API设计,并允许您的主应用程序代码立即从模块化中受益。

理解模块类型

在迁移过程中,您将处理三种类型的模块:

命名模块(显式模块)

显式模块遵循其模块声明(module-info.java)中定义的依赖项和API规则。这些是具有完整模块描述符的完全模块化JAR。

自动模块

自动模块是位于模块路径上的普通JAR(无模块描述符)。模块名称源自JAR文件名或 MANIFEST.MF 文件中的 Automatic-Module-Name 条目。

自动模块在迁移期间提供了一个桥梁:

  • 它们可以读取所有其他模块
  • 它们导出其所有包
  • 其他命名模块可以依赖它们

未命名模块

类路径上的所有内容都成为未命名模块的一部分。未命名模块可以读取所有其他模块,但不能被命名模块所需。这提供了向后兼容性。

使用 jdeps 生成模块描述符

jdeps 命令可以为指定的JAR文件生成 module-info.java 文件:

# 生成模块描述符
jdeps --generate-module-info output-dir myapp.jar dependency.jar

# 生成开放模块(用于反射密集型代码)
jdeps --generate-open-module output-dir myapp.jar

生成的 module-info.java 文件提供了一个起点,但您应该审查并完善它们,以正确封装内部包。

处理常见的迁移挑战

拆分包

一个包不能存在于多个模块中。如果您有拆分包,您需要进行重构或合并,以避免同一个包分布在多个模块中。

反射密集型框架

如果框架使用反射(例如 Spring, Hibernate),请声明 opens 以允许访问:

module com.company.data {
    requires java.sql;

    exports com.company.data.api;
    opens com.company.data.entities to org.hibernate.core;
}

opens 指令允许对包进行反射访问,而无需将其导出到公共API。

第三方库

某些第三方库可能尚未模块化;在这些情况下,请使用自动模块或模块修补。随着生态系统的成熟,越来越多的库正在添加模块支持。

对于没有模块的库,请确保它们的 MANIFEST.MF 中有一个 Automatic-Module-Name,或者向上游贡献一个。

更新构建工具

Maven 配置
使用 maven-compiler-plugin 并配置 --module-path--patch-module 选项:

<plugin>
    <groupId>org.apache.maven.plugins</groupId>
    <artifactId>maven-compiler-plugin</artifactId>
    <version>3.11.0</version>
    <configuration>
        <release>17</release>
    </configuration>
</plugin>

Gradle 配置
使用支持模块化的 java-library 插件:

plugins {
    id 'java-library'
}

java {
    modularity.inferModulePath = true
}

实践示例:模块化一个三层应用程序

让我们逐步完成一个典型的分层应用程序(包含数据层、服务层和API层)的模块化过程。

步骤1:数据层模块

// 数据层的 module-info.java
module com.company.app.data {
    requires java.sql;
    requires java.logging;

    exports com.company.app.data.api;
    // 内部包不导出
}

编译和打包:

javac -d mods/com.company.app.data src/com/company/app/data/**/*.java
jar --create --file mods/com.company.app.data.jar -C mods/com.company.app.data .

步骤2:服务层模块

// 服务层的 module-info.java
module com.company.app.service {
    requires com.company.app.data;
    requires java.logging;

    exports com.company.app.service.api;
}

使用模块路径编译:

javac --module-path mods -d mods/com.company.app.service \
    src/com/company/app/service/**/*.java

步骤3:应用程序模块

// 应用程序的 module-info.java
module com.company.app {
    requires com.company.app.service;
    requires com.company.app.data;
    requires java.logging;
}

运行模块化应用程序:

java --module-path mods -m com.company.app/com.company.app.Main

测试您的模块化应用程序

运行完整的单元测试和集成测试,以验证模块边界。测试确保:

  • 所有必需的模块都已正确声明
  • 导出的包对于依赖模块是足够的
  • 没有发生非法访问尝试
  • 基于反射的框架能通过 opens 指令正常工作

用于兼容性的命令行选项

在迁移期间,您可能需要临时的变通方案。Java提供了几个命令行选项:

  • --add-exports:将一个模块的包导出给另一个模块
  • --add-opens:为一个包开放深度反射权限
  • --add-reads:使一个模块读取另一个模块
  • --patch-module:覆盖或扩充模块的内容

这些选项应作为临时解决方案,同时您需要适当地重构代码。

迁移后的好处

成功迁移后,您将获得:强封装(模块显式控制API)、更好的依赖管理(具有清晰的模块边界)、更高的安全性(通过减少攻击面)以及更快的启动时间(通过优化的模块图)。

模块化的JDK本身展示了这些好处——您可以使用 jlink 创建仅包含应用程序所需模块的自定义运行时映像,从而显著减小部署大小。

最佳实践

成功迁移到JPMS需要遵循成熟的模式并避免常见的陷阱。以下最佳实践源自现实世界的迁移经验,代表了已成功模块化大型Java应用程序的团队的集体智慧。遵循这些指南将帮助您避免代价高昂的错误,并确保顺利过渡到模块化架构。

最佳实践 描述 重要性
从 Java 9+ 兼容性开始 在尝试模块化之前,确保您的应用程序可以在 Java 9+ 上运行 将运行时问题与模块相关问题分开,使调试更容易
增量迁移 不要试图一次性模块化所有内容;选择自底向上或自顶向下策略并系统地进行 降低风险,允许从早期模块中学习,并保持应用程序稳定性
策略性使用自动模块 将自动模块视为迁移期间的临时桥梁,而不是最终目标 自动模块缺乏适当的封装,应替换为显式模块
审查生成的描述符 始终审查和完善由 jdeps 生成的 module-info.java 文件 生成的描述符是起点,可能会过度导出或错过重要的封装机会
积极封装 仅导出真正属于公共API的包;隐藏内部包 强封装防止紧耦合,使重构更安全
文档化模块依赖关系 维护关于模块边界、职责和依赖关系的清晰文档 帮助团队理解模块图并在开发过程中做出明智决策
彻底测试 在每次模块迁移后运行全面的单元测试和集成测试 模块化改变了可见性和访问模式,可能会破坏基于反射的代码
监控非法访问 在测试期间使用 --illegal-access=warn--illegal-access=deny 在内部API导致生产问题之前识别依赖它们的代码
规划循环依赖 通过将共享功能提取到单独的模块中,及早识别并解决循环依赖 JPMS禁止模块间的循环依赖,因此必须对它们进行重构
一致的模块版本管理 在模块生态系统中使用一致的版本控制方案 简化依赖管理并使故障排除更容易

结论

将遗留Java单体应用迁移到使用JPMS的模块化Java是一项重大但有益的工作。通过周密的规划、重构和测试,您可以获得更好的可维护性、性能和安全性等好处。

关键是为您的应用程序选择正确的策略——对于依赖关系较少的库和组件使用自底向上,对于希望在应用程序级别立即受益的应用使用自顶向下。广泛使用 jdeps 来了解您当前的状态并指导您的迁移决策。

虽然模块化可以说是Java有史以来最令人望而生畏的特性之一,并且适当的模块在Java世界中仍然很少见,但对于大规模应用程序而言,强封装、显式依赖关系和更高的安全性所带来的好处使得迁移工作是值得的。


【注】本文译自:Java Platform Module System: Migration Strategies for Legacy Applications

Java记录类入门:简化的以数据为中心的Java编程

记录类声明是一种在Java类中封装数据同时减少样板代码的高效方式。本文将通过基础及高级编程场景介绍其工作原理。

文件柜中的文件记录 图片来源:Stokkete / Shutterstock*

Java记录类是一种用于存储数据的新型类。无需编写构造方法、访问器、equals()hashCode()toString() 的样板代码,只需声明字段,Java编译器便会自动处理其余部分。本文将通过基础与高级用例示例,以及不适用记录类的场景,带您全面了解Java记录类。

注意:Java记录类在JDK 16中正式定型。

Java编译器如何处理记录类

传统Java创建简单数据类需要大量样板代码。以下通过Java吉祥物Duke和Juggy的示例说明:

public class JavaMascot {
    private final String name;
    private final int yearCreated;

    public JavaMascot(String name, int yearCreated) {
        this.name = name;
        this.yearCreated = yearCreated;
    }

    public String getName() { return name; }
    public int getYearCreated() { return yearCreated; }

    // 为简洁起见,省略equals、hashCode和toString方法
}

使用记录类后,上述代码可简化为单行:

public record JavaMascot(String name, int yearCreated) {}

这一简洁声明自动提供了私有final字段、构造方法、访问器方法,以及正确实现的 equals()hashCode()toString() 方法。

定义记录类后,即可投入使用:

public class RecordExample {
    public static void main(String[] args) {
        JavaMascot duke = new JavaMascot("Duke", 1996);
        JavaMascot juggy1 = new JavaMascot("Juggy", 2005);
        JavaMascot juggy2 = new JavaMascot("Juggy", 2005);

        System.out.println(duke); // 输出:JavaMascot[name=Duke, yearCreated=1996]
        System.out.println(juggy1.equals(juggy2)); // 输出:true
        System.out.println(duke.equals(juggy1));   // 输出:false
        System.out.println("吉祥物名称:" + duke.name());
        System.out.println("创建年份:" + duke.yearCreated());
    }
}

记录类自动提供有意义的字符串表示、基于值的等值比较,以及与组件名称匹配的简单访问器方法。

自定义记录类

虽然记录类设计简洁,但仍可通过自定义行为增强功能。以下是相关示例。

紧凑型构造方法

记录类提供特殊的“紧凑型构造方法”语法,无需重复参数列表即可验证或转换输入参数:

record JavaMascot(String name, int yearCreated) {
    // 带验证的紧凑型构造方法
    public JavaMascot {
        if (name == null || name.isBlank()) {
            throw new IllegalArgumentException("名称不能为空");
        }
        if (yearCreated < 1995) {
            throw new IllegalArgumentException("Java吉祥物在1995年前不存在");
        }
    }
}

紧凑型构造方法在字段初始化后、对象完全构建前运行,非常适合用于参数验证。此示例中省略了参数声明,但这些参数在构造方法内仍隐式可用。

添加方法

我们还可以为记录类添加方法:

record JavaMascot(String name, int yearCreated) {
    public boolean isOriginalMascot() {
        return name.equals("Duke");
    }

    public int yearsActive() {
        return java.time.Year.now().getValue() - yearCreated;
    }
}

通过添加方法,记录类可在保持语法简洁和不可变性的同时,封装与其数据相关的行为。

接下来,我们探讨记录类更高级的用法。

使用 instanceofswitch 进行模式匹配

Java 21中,记录类成为模式匹配的关键部分,支持switch表达式、组件解构、嵌套模式和守卫条件。

结合增强的 instanceof 运算符,记录类可在类型验证时简洁地提取组件:

record Person(String name, int age) {}

if (obj instanceof Person person) {
    System.out.println("姓名:" + person.name());
}

再看一个经典示例。几何形状是展示密封接口如何与记录类协同工作的典型例子,这种组合使模式匹配尤为清晰。Switch表达式(Java 17引入)的优雅性在此凸显,它让代码简洁且类型安全,类似于函数式语言中的代数数据类型:

sealed interface Shape permits Rectangle, Circle, Triangle {}

record Rectangle(double width, double height) implements Shape {}
record Circle(double radius) implements Shape {}
record Triangle(double base, double height) implements Shape {}

public class RecordPatternMatchingExample {
    public static void main(String[] args) {
        Shape shape = new Circle(5);

        // 表达性强且类型安全的模式匹配
        double area = switch (shape) {
            case Rectangle r -> r.width() * r.height();
            case Circle c    -> Math.PI * c.radius() * c.radius();
            case Triangle t  -> t.base() * t.height() / 2;
        };

        System.out.println("面积 = " + area);
    }
}

此例中,Shape 是密封接口,仅允许 RectangleCircleTriangle 实现。由于类型集合封闭,switch表达式覆盖所有情况,无需 default 分支。

Java中的模式匹配

若想进一步探索记录类与模式匹配,请参阅我的近期教程:《Java基础与高级模式匹配》

将记录类用作数据传输对象

记录类在现代API设计(如REST、GraphQL、gRPC或服务间通信)中作为数据传输对象(DTO)表现卓越。其简洁语法和内置等值比较特性,使其成为服务层间映射的理想选择。例如:

record UserDTO(String username, String email, Set<String> roles) {}
record OrderDTO(UUID id, UserDTO user, List<ProductDTO> items, BigDecimal total) {}

DTO在微服务应用中无处不在。使用记录类可使DTO更健壮(得益于不可变性),更简洁(无需编写构造方法、getter及 equals()hashCode() 等方法)

函数式与并发编程中的记录类

作为不可变数据容器,记录类完美契合函数式与并发编程需求。它们既可作为纯函数的返回类型,也可用于流处理管道,还能安全地在线程间共享数据。

由于字段为final且不可变,记录类避免了一整类线程问题。一旦构建完成,其状态无法更改,因此无需防御性复制或同步即可实现线程安全。参考以下示例:

transactions.parallelStream().mapToDouble(Transaction::amount).sum();

由于记录类不可变,此并行计算天生具备线程安全性。

不适用Java记录类的场景

至此,我们已了解记录类的优势,但它们并非万能替代品。例如,所有记录类隐式继承 java.lang.Record,因此无法继承其他类(但可实现接口)。在需要类继承的场景中,记录类并不适用。

以下是记录类不适用的其他情况。

记录类设计为不可变

记录类组件始终为final,因此不适用于需要可变/有状态对象的场景。以下示例展示了一个依赖状态变化的可变类,而记录类不允许此类操作:

public class GameCharacter {
    private int health;
    private Position position;

    public void takeDamage(int amount) {
        this.health = Math.max(0, this.health - amount);
    }

    public void move(int x, int y) {
        this.position = new Position(this.position.x() + x, this.position.y() + y);
    }
}

记录类不适合复杂行为建模

基于可变状态、复杂业务逻辑或策略模式、访问者模式、观察者模式等设计,更适合使用传统类实现。以下是复杂逻辑不适用于记录类的示例:

public class TaxCalculator {
    private final TaxRateProvider rateProvider;
    private final DeductionRegistry deductions;

    public TaxAssessment calculateTax(Income income, Residence residence) {
        // 复杂逻辑不适用于记录类
    }
}

记录类与某些框架不兼容

部分框架(尤其是ORM)可能无法良好支持记录类。序列化或重度依赖反射的工具也可能存在问题。请务必检查Java特性与技术栈的兼容性:

// 可能无法与某些ORM框架良好协作
record Employee(Long id, String name, Department department) {}

// 此时仍需使用传统实体类
@Entity
public class Employee {
    @Id
    @GeneratedValue
    private Long id;
    private String name;
    @ManyToOne
    private Department department;

    // Getter、setter、equals、hashCode等方法
}

这些注意事项并不意味着记录类功能不完整,而是强调记录类专为特定场景设计。在某些情况下,传统类仍是更实用的选择。

Java中的记录类与序列化

记录类已在Java生态中被广泛采用,其不可变性使其在持久化、配置和数据传输中极具吸引力。记录类可像普通类一样实现 Serializable 接口。可序列化的记录类组件天然适用于保存配置、恢复状态、网络传输数据或缓存值等场景。

由于记录类字段为final且不可变,它们有助于避免可变状态在序列化与反序列化之间发生变化引发的问题。例如:

import java.io.Serializable;

record User(String username, int age, Profile profile) implements Serializable {}

class Profile {
    private String bio;
}

此例中,Stringint 可序列化,但 Profile 不可序列化,因此 User 无法序列化。若将 Profile 也改为实现 Serializable,则 User 将完全可序列化:

class Profile implements Serializable {
    private String bio;
}

除序列化基础外,Java生态对记录类的支持已迅速成熟。Spring Boot、Quarkus和Jackson等流行框架均与记录类无缝协作,大多数测试工具也是如此。

得益于这种广泛采纳,记录类在实际API中作为DTO表现卓越:

@RestController
@RequestMapping("/api/orders")
public class OrderController {

    @GetMapping("/{id}")
    public OrderView getOrder(@PathVariable UUID id) {
        // 实际应用中,此数据应来自数据库或服务
        return new OrderView(
            id,
            "Duke",
            List.of(new ItemView(UUID.randomUUID(), 2)),
            new BigDecimal("149.99")
        );
    }

    // 用于API响应的记录类DTO
    record OrderView(UUID id, String customerName, List<ItemView> items, BigDecimal total) {}
    record ItemView(UUID productId, int quantity) {}
}

如今,大多数主流Java库和工具已将记录类视为一等公民。早期的质疑已基本消散,开发者正因其清晰性与安全性而广泛接纳记录类。

结语

记录类是Java演进过程中的重大进步。它们降低了数据类的冗余度,并确保了不可变性和行为一致性。通过消除构造方法、访问器及 equals()hashCode() 等方法的样板代码,记录类使代码更简洁、表达力更强,在保持类型安全的同时契合现代实践。

记录类并非适用于所有场景,但在处理不可变数据时优势显著。结合模式匹配,它们能让代码意图更清晰,同时由Java编译器处理样板代码。

随着记录类、密封类和模式匹配等技术的进步,Java正稳步迈向更以数据为中心的编程风格。掌握这些工具是编写现代、高表达力Java代码的最清晰路径之一。


【注】本文译自:Introduction to Java records: Simplified data-centric programming in Java

在企业级 Java 中应用领域驱动设计:一种行为驱动方法

了解如何结合 DDD 和 BDD 于企业级 Java 中,以创建能够模拟真实业务领域并通过可执行场景验证行为的软件。

在软件开发领域,最大的错误之一就是交付客户"精确"想要的东西。这听起来可能像陈词滥调,但即使在行业摸爬滚打数十年后,这个问题依然存在。一个更有效的方法是从关注业务需求开始测试。

行为驱动开发Behavior-driven development】(BDD)是一种强调行为和领域术语(也称为统一语言)的软件开发方法论。它使用共享的自然语言,从用户的角度定义和测试软件行为。BDD 建立在测试驱动开发test-driven development】(TDD)的基础上,专注于与业务相关的场景。这些场景以纯语言规范的形式编写,可以自动化成测试,同时也充当活文档。

这种方法促进了技术和非技术利益相关者之间的共识,确保软件满足用户需求,并有助于减少返工和开发时间。在本文中,我们将进一步探讨这种方法论,并讨论如何使用 Oracle NoSQL 和 Java 来实现它。

BDD 与 DDD 如何协同工作

乍看之下,行为驱动开发(BDD)和领域驱动设计(DDD)似乎解决的是不同的问题——一个侧重于测试,另一个侧重于建模。然而,它们共享相同的哲学基础:确保软件真实反映其所服务的业务领域

DDD,由 Eric Evans 在其 2003 年具有开创性的著作《领域驱动设计:软件核心复杂性的应对之道》中提出,教导我们围绕业务概念(实体、值对象、聚合和限界上下文)来建模软件。其力量在于使用统一语言,这是一种连接开发人员和领域专家的共享词汇表。

BDD,由 Dan North 在几年后提出,是这一思想自然而然的延伸。它将统一语言引入测试过程,将业务规则转化为可执行的规范。DDD 定义了系统应该表示什么,而 BDD 则根据该模型验证系统的行为方式

当结合使用时,DDD 和 BDD 形成了一个持续的反馈循环:

  • DDD 塑造了捕获业务逻辑的领域模型
  • BDD 确保系统行为随着时间的推移与该模型保持一致。

在实践中,这种协同作用意味着您可以编写与聚合(如 Room 和 Reservation)直接相关的特性场景——例如"当我预订一个 VIP 房间时,系统应将其标记为不可用"。这些测试成为开发人员和利益相关者的活文档,确保您的领域始终与真实的业务需求保持一致。

如果您想深入探索这种结合,我的著作《Domain-Driven Design with Java》详细阐述了这些原则。它展示了如何在现代 Java 应用程序中使用 Jakarta EE、Spring 和云技术应用 DDD 模式,为统一架构和行为提供了实践基础。

总之,DDD 和 BDD 共同弥合了理解业务与证明其可行之间的差距——将软件从技术制品转变为领域本身的忠实表达。

代码实现

在本示例中,我们将使用企业级 Java 和 Oracle NoSQL 数据库生成一个简单的酒店管理应用程序。

第一步是创建项目。由于我们使用的是 Java SE,我们可以使用以下 Maven 命令生成它:

mvn archetype:generate                     \
"-DarchetypeGroupId=io.cucumber"           \
"-DarchetypeArtifactId=cucumber-archetype" \
"-DarchetypeVersion=7.30.0"                \
"-DgroupId=org.soujava.demos.hotel"        \
"-DartifactId=behavior-driven-development" \
"-Dpackage=org.soujava.demos"              \
"-Dversion=1.0.0-SNAPSHOT"                 \
"-DinteractiveMode=false"

下一步是引入 Eclipse JNoSQLOracle NoSQL,以及 Jakarta EE 组件的实现:CDI、JSON 和 Eclipse MicroProfile 实现。

您可以找到完整的 pom.xml 文件。

初始项目准备就绪后,我们将从创建测试开始。

请记住,BDD 是 TDD 的扩展,它包含了统一语言——领域和业务之间的共享词汇。

功能: 管理酒店房间

  场景: 注册一个新房间
    假设 酒店管理系统正在运行
    当 我注册一个号码为 203 的房间
    那么 号码为 203 的房间应该出现在房间列表中

  场景: 注册多个房间
    假设 酒店管理系统正在运行
    当 我注册以下房间:
      | number | type      | status             | cleanStatus |
      | 101    | STANDARD  | AVAILABLE          | CLEAN       |
      | 102    | SUITE     | RESERVED           | DIRTY       |
      | 103    | VIP_SUITE | UNDER_MAINTENANCE  | CLEAN       |
    那么 系统中应该有 3 个可用房间

  场景: 更改房间状态
    假设 酒店管理系统正在运行
    并且 一个号码为 101 的房间已注册为 AVAILABLE
    当 我将房间 101 标记为 OUT_OF_SERVICE
    那么 房间 101 应被标记为 OUT_OF_SERVICE

Maven 项目完成后,让我们进入下一步,即创建建模和存储库。如前所述,我们将专注于房间管理。因此,我们的下一个目标是确保之前定义的 BDD 测试通过。让我们从实现领域模型和存储库开始:

public enum CleanStatus {
    CLEAN,       // 清洁
    DIRTY,       // 脏污
    INSPECTION_NEEDED // 需要检查
}

public enum RoomStatus {
    AVAILABLE,         // 可用
    RESERVED,          // 已预订
    UNDER_MAINTENANCE, // 维护中
    OUT_OF_SERVICE     // 停止服务
}

public enum RoomType {
    STANDARD,  // 标准间
    DELUXE,    // 豪华间
    SUITE,     // 套房
    VIP_SUITE  // VIP套房
}

@Entity
public class Room {

    @Id
    private String id;

    @Column
    private int number; // 房间号

    @Column
    private RoomType type; // 房间类型

    @Column
    private RoomStatus status; // 房间状态

    @Column
    private CleanStatus cleanStatus; // 清洁状态

    @Column
    private boolean smokingAllowed; // 允许吸烟

    @Column
    private boolean underMaintenance; // 处于维护状态

}

有了模型,下一步是创建企业级 Java 与作为非关系型数据库的 Oracle NoSQL 之间的桥梁。我们可以使用 Jakarta Data 非常轻松地完成,它只有一个存储库接口,所以我们不需要担心实现。

@Repository
public interface RoomRepository {

    @Query("FROM Room")
    List<Room> findAll();

    @Save
    Room save(Room room);

    void deleteBy();

    Optional<Room> findByNumber(Integer number);
}

项目完成后,下一步是准备测试环境,首先提供一个数据库实例用于测试。多亏了 Testcontainers,我们可以轻松启动一个隔离的 Oracle NoSQL 实例来运行我们的测试。

public enum DatabaseContainer {

    INSTANCE;

    private final GenericContainer<?> container = new GenericContainer<>
            (DockerImageName.parse("ghcr.io/oracle/nosql:latest-ce"))
            .withExposedPorts(8080);

    {
        container.start();
    }
    public DatabaseManager get(String database) {
        DatabaseManagerFactory factory = managerFactory();
        return factory.apply(database);
    }

    public DatabaseManagerFactory managerFactory() {
        var configuration = DatabaseConfiguration.getConfiguration();
        Settings settings = Settings.builder()
                .put(OracleNoSQLConfigurations.HOST, host())
                .build();
        return configuration.apply(settings);
    }

    public String host() {
        return "http://" + container.getHost() + ":" + container.getFirstMappedPort();
    }
}

之后,我们将创建一个与 @Alternative CDI 注解集成的生产者。此配置指导 CDI 如何提供数据库实例——在本例中是由 Testcontainers 管理的实例:

@ApplicationScoped
@Alternative
@Priority(Interceptor.Priority.APPLICATION)
public class ManagerSupplier implements Supplier<DatabaseManager> {

    @Produces
    @Database(DatabaseType.DOCUMENT)
    @Default
    public DatabaseManager get() {
        return DatabaseContainer.INSTANCE.get("hotel");
    }

}

借助 Cucumber,我们可以定义一个将类注入到 Cucumber 测试上下文中的 ObjectFactory。由于我们使用 CDI 并以 Weld 作为实现,我们将创建一个自定义的 WeldCucumberObjectFactory 来无缝集成这两种技术。

public class WeldCucumberObjectFactory implements ObjectFactory {

    private Weld weld;
    private WeldContainer container;

    @Override
    public void start() {
        weld = new Weld();
        container = weld.initialize();
    }

    @Override
    public void stop() {
        if (weld != null) {
            weld.shutdown();
        }
    }

    @Override
    public boolean addClass(Class<?> stepClass) {
        return true;
    }

    @Override
    public <T> T getInstance(Class<T> type) {
        return (T) container.select(type).get();
    }
}

一个重要提示:此设置作为 SPI(服务提供者接口)工作。因此,您必须创建以下文件:

src/test/resources/META-INF/services/io.cucumber.core.backend.ObjectFactory

内容如下:

org.soujava.demos.hotels.config.WeldCucumberObjectFactory

我们将让 Mapper 将我们的数据表转换为所有模型中的 Room 对象。

@ApplicationScoped
public class RoomDataTableMapper {

    @DataTableType
    public Room roomEntry(Map<String, String> entry) {
        return Room.builder()
                .number(Integer.parseInt(entry.get("number")))
                .type(RoomType.valueOf(entry.get("type")))
                .status(RoomStatus.valueOf(entry.get("status")))
                .cleanStatus(CleanStatus.valueOf(entry.get("cleanStatus")))
                .build();
    }
}

整个测试基础设施完成后,下一步是设计包含我们实际测试的 Step 测试类。

@ApplicationScoped
public class HotelRoomSteps {

    @Inject
    private RoomRepository repository;

    @Before
    public void cleanDatabase() {
        repository.deleteBy();
    }

    @Given("the hotel management system is operational")
    public void theHotelManagementSystemIsOperational() {
        Assertions.assertThat(repository).as("RoomRepository 应该已初始化").isNotNull();
    }

    @When("I register a room with number {int}")
    public void iRegisterARoomWithNumber(Integer number) {
        Room room = Room.builder()
                .number(number)
                .type(RoomType.STANDARD)
                .status(RoomStatus.AVAILABLE)
                .cleanStatus(CleanStatus.CLEAN)
                .build();
        repository.save(room);
    }

    @Then("the room with number {int} should appear in the room list")
    public void theRoomWithNumberShouldAppearInTheRoomList(Integer number) {
        List<Room> rooms = repository.findAll();
        Assertions.assertThat(rooms)
                .extracting(Room::getNumber)
                .contains(number);
    }

    @When("I register the following rooms:")
    public void iRegisterTheFollowingRooms(List<Room> rooms) {
        rooms.forEach(repository::save);
    }

    @Then("there should be {int} rooms available in the system")
    public void thereShouldBeRoomsAvailableInTheSystem(int expectedCount) {
        List<Room> rooms = repository.findAll();
        Assertions.assertThat(rooms).hasSize(expectedCount);
    }

    @Given("a room with number {int} is registered as {word}")
    public void aRoomWithNumberIsRegisteredAs(Integer number, String statusName) {
        RoomStatus status = RoomStatus.valueOf(statusName);
        Room room = Room.builder()
                .number(number)
                .type(RoomType.STANDARD)
                .status(status)
                .cleanStatus(CleanStatus.CLEAN)
                .build();
        repository.save(room);
    }

    @When("I mark the room {int} as {word}")
    public void iMarkTheRoomAs(Integer number, String newStatusName) {
        RoomStatus newStatus = RoomStatus.valueOf(newStatusName);
        Optional<Room> roomOpt = repository.findByNumber(number);

        Assertions.assertThat(roomOpt)
                .as("房间 %s 应该存在", number)
                .isPresent();

        Room updatedRoom = roomOpt.orElseThrow();
        updatedRoom.update(newStatus); // 假设 Room 类有 update 方法

        repository.save(updatedRoom);
    }

    @Then("the room {int} should be marked as {word}")
    public void theRoomShouldBeMarkedAs(Integer number, String expectedStatusName) {
        RoomStatus expectedStatus = RoomStatus.valueOf(expectedStatusName);
        Optional<Room> roomOpt = repository.findByNumber(number);

        Assertions.assertThat(roomOpt)
                .as("房间 %s 应该存在", number)
                .isPresent()
                .get()
                .extracting(Room::getStatus)
                .isEqualTo(expectedStatus);
    }
}

是时候执行测试了:

mvn clean test

您可以看到结果:

INFO: Connecting to Oracle NoSQL database at http://localhost:61325 using ON_PREMISES deployment type
  ✔ Given the hotel management system is operational      # org.soujava.demos.hotels.HotelRoomSteps.theHotelManagementSystemIsOperational()
  ✔ And a room with number 101 is registered as AVAILABLE # org.soujava.demos.hotels.HotelRoomSteps.aRoomWithNumberIsRegisteredAs(java.lang.Integer,java.lang.String)
  ✔ When I mark the room 101 as OUT_OF_SERVICE            # org.soujava.demos.hotels.HotelRoomSteps.iMarkTheRoomAs(java.lang.Integer,java.lang.String)
  ✔ Then the room 101 should be marked as OUT_OF_SERVICE  # org.soujava.demos.hotels.HotelRoomSteps.theRoomShouldBeMarkedAs(java.lang.Integer,java.lang.String)
Oct 21, 2025 6:18:43 PM org.jboss.weld.environment.se.WeldContainer shutdown
INFO: WELD-ENV-002001: Weld SE container fc4b3b51-fba8-4ea6-9cef-42bcee97d220 shut down
[INFO] Tests run: 4, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 7.231 s -- in org.soujava.demos.hotels.RunCucumberTest
[INFO] Running org.soujava.demos.hotels.MongoDBTest
[INFO] Tests run: 1, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 0.007 s -- in org.soujava.demos.hotels.MongoDBTest
[INFO] 
[INFO] Results:
[INFO] 
[INFO] Tests run: 5, Failures: 0, Errors: 0, Skipped: 0
[INFO] 

结论

通过结合领域驱动设计(DDD)和行为驱动开发(BDD),开发人员可以超越技术正确性,构建真正反映业务意图的软件。DDD 为领域提供了结构,确保模型精确地捕捉现实世界的概念,而 BDD 则通过用业务本身的语言编写的清晰、可测试的场景,确保这些模型按预期运行。

在本文中,您学习了如何使用 Oracle NoSQL、Eclipse JNoSQL 和 Jakarta EE 连接这两个世界——从定义您的领域到运行由 Cucumber 和 CDI 支持的真实行为测试。这种协同作用将测试转化为活文档,弥合了工程师和利益相关者之间的差距,并确保您的系统在演进过程中始终与业务目标保持一致。

您可以深入探索并将 DDD 与 BDD 结合起来。在《Domain-Driven Design with Java》这本书中,您可以找到一个很好的起点来理解为什么 DDD 对我们仍然很重要。它扩展了这里分享的想法,展示了 DDD 和 BDD 如何共同带来更简单、更易维护且以业务为中心的软件。这种软件交付的是超越需求的实际价值。


【注】本文译自:Applying Domain-Driven Design With Enterprise Java: A Behavior-Driven Approach

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