自动模块:连接传统Java与模块化Java的桥梁

当Java 9在2017年发布时,它带来了Java历史上最具雄心的变革之一:Java平台模块系统(JPMS),亲切地称为"Project Jigsaw"。经历了二十年的类路径混沌之后,Java终于拥有了一个真正的模块系统。但存在一个问题——一个巨大的问题。那数十亿行现有的Java代码怎么办?Maven中央仓库中成千上万个从未听说过模块的库又该如何处理?

自动模块应运而生:这一巧妙的折衷方案使得JPMS迁移变得可行,而非灾难性的。它们是连接昨日类路径世界与明日模块化未来的桥梁。可以将它们视为两国边境上的外交翻译——不属于任何一方的公民,但对双方之间的沟通至关重要。

让我们深入探讨自动模块的工作原理、它们为何重要,以及如何在您的迁移之旅中有效地使用它们。

迁移挑战:Java为何需要桥梁

想象一下,您正在管理一个大型企业应用程序。您使用了Spring、Hibernate、Apache Commons、Google Guava以及数十个其他依赖项。现在是2017年,您希望迁移到Java 9的模块系统。但有一个问题:您的所有依赖项都还没有被模块化。

如果没有某种兼容性层,对话将是这样的:

您:"我想为我的应用程序使用模块。"
JPMS:"太好了!您的所有依赖项都有模块描述符吗?"
您:"没有,它们只是普通的JAR文件。"
JPMS:"那么您不能在模块化应用程序中使用它们。"
您:"所以我不能迁移?"
JPMS:"正确。"
您:"……"

这将迫使进行自底向上的迁移:依赖树底部的每个库都需要先进行模块化,然后其上的任何东西才能迁移。整个生态系统完成迁移将需要数年——甚至数十年——的时间。Java社区实际上将被行动最缓慢的依赖项所挟持。

JDK提供了工具来帮助开发人员将现有代码迁移到JPMS。应用程序代码仍然可以依赖Java 9之前的库,这些jar文件被视为特殊的"自动"模块,从而更容易逐步迁移到Java 9。

什么是自动模块?

自动模块是位于模块路径上的普通JAR(没有模块描述符)。模块路径上的所有东西,无论它是普通jar还是带有module-info的jar,都会成为一个命名模块。

其神奇之处在于:自动模块是没有module-info.java的JAR文件,它们仅仅通过被放置在模块路径(而非类路径)上,就能表现得像模块一样。

让我们分解一下当您将常规JAR文件放在模块路径上时会发生什么:

1. 它获得一个模块名称

自动模块的模块名称来源于用于包含该构件的JAR文件,如果其主清单条目中包含Automatic-Module-Name属性。否则,模块名称来源于JAR文件的名称。

示例转换:

  • commons-io-2.11.0.jar → 模块名称:commons.io
  • jackson-databind-2.14.2.jar → 模块名称:jackson.databind
  • my-awesome-library.jar → 模块名称:my.awesome.library

命名算法会剥离版本号,将连字符替换为点,并移除无效字符。但这种基于文件名的命名是危险的——稍后会详细说明。

2. 它导出所有内容

由于自动模块中没有为驻留的包定义显式的导出/开放声明,自动模块中的每个包都被视为已导出,即使它实际上可能仅用于内部用途。

您精心设计的内部专用包?现在全部公开了。这破坏了封装性,但对于兼容性是必要的。传统代码通常依赖于访问"内部"包,而自动模块保留了这种行为。

3. 它依赖所有模块

无法预先确切知道一个自动模块可能依赖哪些其他模块。自动模块在解析过程中会受到特殊处理,以便它们能够读取配置中的所有其他模块。

一个自动模块隐式地依赖模块路径上的所有其他模块,加上类路径上的所有内容。这与模块化设计背道而驰,但同样,这是为了兼容性。传统的JAR没有明确声明依赖关系,因此JPMS假定它们需要一切。

4. 它桥接类路径

这是真正巧妙的部分:自动模块允许模块路径依赖于类路径,而这通常是不允许的。

常规模块无法看到类路径上的任何东西("未命名模块")。但自动模块可以。这使得模块化代码可以使用自动模块,而自动模块又可以反过来使用旧的类路径JAR。这是一个连接所有三个世界的三向握手。

实践示例:迁移Spring应用程序

让我们演练一个真实的迁移场景。您有一个Spring Boot应用程序,包含以下依赖项:

my-app (您的代码)
├── spring-boot-starter-web
├── spring-data-jpa
├── hibernate-core
├── postgresql-driver
└── apache-commons-lang

这些库都还没有被模块化。以下是您的迁移路径:

步骤1:创建您的模块描述符

// module-info.java
module com.mycompany.myapp {
    requires spring.boot;  // 将成为自动模块
    requires spring.data.jpa;  // 将成为自动模块
    requires hibernate.core;  // 将成为自动模块
    requires postgresql;  // 将成为自动模块
    requires org.apache.commons.lang3;  // 将成为自动模块

    // 您的导出
    exports com.mycompany.myapp.api;
}

步骤2:将依赖项放置在模块路径上

配置您的构建工具,将JAR放在模块路径上而不是类路径上:

Maven:

<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 {
    modularity.inferModulePath = true
}

步骤3:运行您的应用程序

java --module-path mods --module com.mycompany.myapp/com.mycompany.myapp.Main

您的显式模块(com.mycompany.myapp)现在依赖于自动模块(Spring、Hibernate等),这些自动模块可以访问它们需要的任何东西。迁移完成了——至少对于您的应用程序代码而言。

Automatic-Module-Name:您的稳定锚点

这里事情变得棘手了。当一个模块依赖于一个基于文件名的自动模块,并且该模块又被其他模块依赖时,整个堆栈就被链接起来了。堆栈中的所有东西都必须从v1一起升级到v2。

这就是Stephen Colebourne(Joda-Time的创建者)所说的"模块地狱"。

基于文件名的命名问题
想象以下场景:

  • 您依赖 guava-30.0-jre.jar → 自动模块名称:guava.30.0.jre
  • 您发布了您的库 v1.0,其中包含 requires guava.30.0.jre;
  • Guava 发布 v31.0 → JAR 名称变为 guava-31.0-jre.jar
  • 模块名称变为 guava.31.0.jre
  • 您的 v1.0 发布版本现在损坏了,因为它需要一个不再存在的模块名

针对此问题提出的主要缓解措施是,jar文件可以在MANIFEST.MF中有一个名为"Automatic-Module-Name"的新条目。当JPMS检查一个自动模块时,如果MANIFEST.MF条目存在,则使用该值作为模块名,而不是文件名。

解决方案:显式模块名称
库维护者应该将以下内容添加到他们的JAR清单中:

Maven:

<plugin>
    <artifactId>maven-jar-plugin</artifactId>
    <configuration>
        <archive>
            <manifestEntries>
                <Automatic-Module-Name>com.google.common</Automatic-Module-Name>
            </manifestEntries>
        </archive>
    </configuration>
</plugin>

Gradle:

tasks.jar {
    manifest {
        attributes["Automatic-Module-Name"] = "com.google.common"
    }
}

现在,无论JAR文件名是什么,模块名称始终是com.google.common。模块名应全局唯一,并由点分隔的Java标识符组成。通常它应该是一个反向域名,类似于Java包名中常见的格式。

这使您可以:

  • 将 Guava 从 30.0 升级到 31.0 而不会破坏依赖模块
  • 选择一个与您的主包名匹配的名称
  • 为您未来的显式模块化保留您的模块名

真实世界示例:RxJava

RxJava通过指定一个稳定的自动模块名Automatic-Module-Name: io.reactivex解决了这个问题,同时仍然以JDK8为目标。

这非常高明:RxJava保持了Java 8兼容性(没有module-info.java),但通过声明其未来的模块名为JPMS做好了准备。RxJava的用户可以自信地编写requires io.reactivex;,知道这个名称在RxJava最终成为显式模块时不会改变。

迁移策略:自上而下 vs. 自下而上

模块系统设计为同时支持"自下而上"和"自上而下"的迁移。自下而上迁移意味着您先迁移您的小型工具库,最后迁移您的主要应用程序。自上而下迁移意味着您先迁移您的应用程序,之后再迁移工具库。

自上而下迁移(推荐用于应用程序)

这是应用程序开发人员的实用方法:

  1. 最初将所有东西保留在类路径上(未命名模块)
  2. 将应用程序代码移动到模块路径,添加module-info.java
  3. 应用程序将所有依赖项作为自动模块读取
  4. 随着时间推移逐步迁移内部库
  5. 等待第三方库自然地成为显式模块

优点:

  • 您控制应用程序的模块化时间表
  • 无需等待依赖项
  • 增量进展可见且有价值

缺点:

  • 暂时依赖自动模块
  • 必须小心处理重度使用反射的框架

自下而上迁移(适用于库的理想方式)

在此策略中,最初属于应用程序的所有JAR文件都将位于其模块路径上,因此所有未迁移的项目都被视为自动模块。选择依赖层次结构中尚未迁移到模块的较高级别项目,将module-info.java文件添加到该项目中,将其从自动模块转换为命名模块。

优点:

  • 自下而上构建适当的模块化架构
  • 每一层在依赖层迁移之前都已完全模块化

缺点:

  • 需要缓慢的社区范围协调
  • 受限于行动最慢的依赖项

黑暗面:陷阱与限制

自动模块并不完美。过度依赖它们可能导致脆弱的系统、意外的依赖关系和维护问题。

1. 被破坏的封装

所有东西都被导出。您小心隐藏的内部API?全部暴露了。这可能会在您本不打算支持的实现细节上创建依赖关系。

// 在自动模块 "my.library" 中
package my.library.internal;  // 您本意是将其作为 INTERNAL

public class SecretImplementation {
    // 但因为它是自动模块,所以现在成了 PUBLIC API
    public void doSomethingSecret() { }
}

// 在您的应用程序模块中
import my.library.internal.SecretImplementation;  // 这可以工作!

SecretImplementation.doSomethingSecret();  // 糟糕

当该库最终成为显式模块并停止导出my.library.internal时,您的代码就会中断。

2. 隐式依赖所有模块

每个自动模块都依赖每个其他模块。这会创建隐藏的传递依赖。

// 您的模块
requires my.library;  // 它是自动模块

// 您现在隐式地可以访问:
// - my.library 直接依赖的所有东西
// - 那些依赖项所依赖的所有东西
// - 模块路径上的所有其他东西

// 这是伪装下的"依赖地狱"

3. 模块名称不稳定性

如果清单中没有Automatic-Module-Name,升级依赖项可能会破坏您的构建:

# 构建成功
requires commons.io.2.11.0;

# 升级依赖项
# 构建失败,因为模块名称改变了!
requires commons.io.2.12.0;  # 现在需要这个

4. 拆分包噩梦

拆分包可能是一个挑战:重构或合并以避免同一包出现在多个模块中。

JPMS禁止两个模块拥有同一个包。如果您有:

  • my.library.utils 在 JAR A 中(自动模块 my.library.a
  • my.library.utils 在 JAR B 中(自动模块 my.library.b

模块系统拒绝加载两者。这在基于类路径的系统中很常见,但在JPMS中是不允许的。

最佳实践:明智地使用自动模块

仅将自动模块用作过渡性辅助工具,逐步用显式模块化JAR替换它们,使用MANIFEST.MF中的Automatic-Module-Name定义稳定的名称,在CI/CD流水线中记录使用情况,并跟踪传递依赖关系。

对于库维护者

1. 立即添加 Automatic-Module-Name

即使您还没有准备好进行完全模块化:

tasks.jar {
    manifest {
        attributes["Automatic-Module-Name"] = "com.mycompany.mylib"
    }
}

这几乎没有成本,但能为用户提供巨大价值。

2. 仔细选择名称

模块名称应与JAR文件的根包同名。例如,如果一个JAR文件包含com.google.utilities.i18ncom.google.utilities.strings,那么com.google.utilities是模块名的好选择。

遵循反向DNS命名:com.google.guava 而不是 guava

3. 规划您最终的显式模块

您现在选择的Automatic-Module-Name将来就是您的module-info.java中的名称。请明智选择。

对于应用程序开发人员

1. 跟踪自动模块使用情况

维护一个哪些依赖项是自动模块的列表:

// module-info.java
module com.myapp {
    // 自动模块(待替换)
    requires spring.boot;  // TODO: 当Spring完全模块化时替换
    requires hibernate.core;  // TODO: 当Hibernate完全模块化时替换

    // 显式模块
    requires java.sql;
    requires java.logging;
}

2. 使用 jdeps 分析依赖关系

jdeps --module-path mods --check myapp.jar

# 显示:
# - 哪些模块是自动模块
# - 拆分包
# - 对JDK内部API的依赖

3. 设定迁移里程碑

  • 2025年Q1:应用程序代码模块化
  • 2025年Q2:内部库模块化
  • 2025年Q3:在可用时用显式模块替换自动模块

4. 小心处理反射

像Spring和Hibernate这样的框架大量使用反射。您可能需要:

java --module-path mods \
     --add-opens com.myapp/com.myapp.entities=hibernate.core \
     --module com.myapp/com.myapp.Main

--add-opens标志允许Hibernate反射性地访问您的实体类,即使它们位于一个模块中。

生态系统现状(2025年)

JPMS发布五年后,人们对JPMS的看法褒贬不一。好处是显而易见的——安全性、优化、强封装。然而,即使五年后,仍有太多的库生态系统落后。JPMS是一个只有当大家都配合时,其好处才能彰显的系统。

截至2025年,主要库已取得进展:

完全模块化:

  • 大多数JDK模块(java.base, java.sql 等)
  • Jackson (jackson-databind, jackson-core)
  • SLF4J 和 Logback
  • JUnit 5

使用 Automatic-Module-Name

  • Spring Framework(各种模块)
  • Hibernate ORM
  • Apache Commons 库
  • Google Guava

仍有问题:

  • 一些传统的JDBC驱动
  • 较旧的Apache项目
  • 停留在Java 8的利基库

好消息是:大多数积极维护的库现在至少定义了Automatic-Module-Name,使得自上而下的迁移成为可能。

何时不应使用模块

并非每个项目都需要JPMS。在以下情况下可以跳过模块:

  • 您正在构建没有分发顾虑的内部应用程序
  • 您的依赖项对其支持不佳
  • 迁移成本超过收益
  • 您因兼容性原因而停留在Java 8

在Java 9中,您仍然可以在运行应用程序时使用Java VM的-classpath参数。在类路径上,您可以像在Java 9之前那样包含所有您较旧的Java类。

Java 9+ 并不强制您使用模块。类路径仍然有效。自动模块的存在是为了让您能够桥接到模块化,而不是因为您必须迁移。

定论:训练轮还是永久装置?

自动模块就像学骑自行车时的训练轮。它们帮助您过渡,但永久保留它们会阻碍您充分享受模块化系统的效率和安全性。

这个比喻非常贴切。自动模块被设计为临时桥梁,而非终点。它们牺牲了适当模块化的许多好处:

  • 无封装(所有内容都导出)
  • 无显式依赖(依赖所有模块)
  • 潜在的名称不稳定性(基于文件名的命名)

但它们对于迁移绝对至关重要。没有它们,JPMS将无法被采用——没有成千上万个开源项目的协调,就不可能实现迁移。

前进之路

对于大多数项目来说,现实的时间线如下:

  • 2017-2020年: 引入自动模块,早期采用者进行实验
  • 2020-2023年: 主要库添加 Automatic-Module-Name
  • 2023-2025年: 逐步转换为显式模块
  • 2025-2030年: 大多数活跃项目完全模块化
  • 2030年+: 自动模块罕见,仅用于遗留依赖项

我们现在正处于中间阶段。如果您今天开始迁移:

  • 您的应用程序: 可以立即成为显式模块
  • 您的库: 至少应具有 Automatic-Module-Name
  • 您的依赖项: 将混合自动模块和显式模块
  • 您的未来: 逐步用显式模块替换自动模块

代码示例:完整的迁移工作流

让我们看一个完整的前后迁移示例:

之前(基于类路径)

// src/com/myapp/Main.java
package com.myapp;

import com.google.common.collect.ImmutableList;
import org.apache.commons.lang3.StringUtils;

public class Main {
    public static void main(String[] args) {
        var list = ImmutableList.of("Hello", "World");
        System.out.println(StringUtils.join(list, " "));
    }
}
# 编译和运行
javac -cp libs/guava-31.0.jar:libs/commons-lang3-3.12.jar \
      src/com/myapp/Main.java

java -cp .:libs/guava-31.0.jar:libs/commons-lang3-3.12.jar \
     com.myapp.Main

之后(使用自动模块的基于模块)

// src/module-info.java
module com.myapp {
    // 自动模块(这些JAR中没有module-info)
    requires com.google.common;  // 具有 Automatic-Module-Name 的 Guava
    requires org.apache.commons.lang3;  // 具有 Automatic-Module-Name 的 Commons Lang
}

// src/com/myapp/Main.java
package com.myapp;

import com.google.common.collect.ImmutableList;
import org.apache.commons.lang3.StringUtils;

public class Main {
    public static void main(String[] args) {
        var list = ImmutableList.of("Hello", "World");
        System.out.println(StringUtils.join(list, " "));
    }
}
# 使用模块编译和运行
javac --module-path libs \
      -d mods/com.myapp \
      src/module-info.java src/com/myapp/Main.java

java --module-path mods:libs \
     --module com.myapp/com.myapp.Main

代码是相同的;只有打包方式改变了。但现在您拥有了:

  • 依赖项的显式声明
  • 模块边界强制执行
  • 通往完全模块化的路径
  • 使用 jlink 创建自定义运行时映像的能力

jlink 的额外好处:显著减小体积
在迁移之前,使用launch4j创建的等效运行时映像重达175.1 MB。使用JPMS和jlink,映像仅为30.3 MB,减少了5.8倍(83%)!

一旦您完全模块化(没有剩余的自动模块),您就可以使用jlink创建仅包含您所需模块的自定义JRE映像:

jlink --module-path $JAVA_HOME/jmods:mods:libs \
      --add-modules com.myapp \
      --output myapp-runtime \
      --launcher myapp=com.myapp/com.myapp.Main

# 结果:myapp-runtime/ 是一个自包含的 JRE + 您的应用程序
# 可以比捆绑完整 JDK 小 70-80%

这只有在您的所有依赖项都是显式模块时才有效。自动模块不能被包含在jlink映像中——这是它们是训练轮而非最终目标的又一个原因。

我们在本文中学到了什么

关键要点:
自动模块在Java 9中作为一种兼容性机制被引入,以解决巨大的迁移问题。当JPMS于2017年出现时,Java生态系统已经包含了数十亿行代码和成千上万个从未考虑模块化设计的库。一次性转换所有内容是不现实的,因此自动模块提供了一座桥梁,允许传统JAR参与模块系统,而无需立即进行更改。通过将一个普通JAR放在模块路径上,Java将其视为自动模块,从其文件名派生其名称,导出其所有包,并隐式依赖所有其他模块。这种设计牺牲了模块化的保证——例如封装和显式依赖——以保持兼容性并允许逐步迁移。

然而,这种基于文件名的命名系统引入了严重的不稳定性。一个简单的版本升级就可能改变JAR的文件名,从而改变其模块名,破坏依赖它的应用程序。这导致了"模块地狱"的风险,即由于不一致或冲突的名称导致模块解析失败。推荐的解决方案是提供一个Automatic-Module-Name清单条目,它为模块建立了一个稳定的、面向未来的名称,而无需完整的module-info.java。因此,库维护者有责任至少声明此清单属性,因为它几乎没有成本,但能为用户提供巨大价值,并为未来的显式模块化保留模块名。

自动模块在所谓的三向桥梁中也扮演着关键角色。它们连接了显式模块、自动模块和类路径上的未命名模块,使得模块化和非模块化代码能够共存。这使得自上而下迁移——应用程序开发人员先模块化自己的代码——成为大多数团队最实用的策略,而自下而上迁移则需要整个生态系统进行协调一致的模块化。因此,应用程序开发人员可以将自动模块视为临时依赖项,跟踪它们在依赖图中的存在位置,并随着上游库完全模块化而逐步替换它们。

尽管自动模块很有用,但它们也有显著的缺点。它们导出所有包,这消除了封装性;它们隐式依赖所有其他模块,这隐藏了真实的依赖关系。它们还可能触发在类路径上合法但在模块系统中被禁止的拆分包冲突。像Spring和Hibernate这样重度使用反射的框架增加了更多的复杂性,通常需要--add-opens或类似的标志来访问内部API,尤其是在混合使用显式模块和自动模块时。

截至2025年,生态系统已经取得了实质性进展,主要库要么完全模块化,要么至少声明了Automatic-Module-Name。尽管如此,许多中层和长尾库仍然是非模块化的,因此迁移尚未完成。这减缓了诸如jlink等好处的采用,jlink可以创建显著更小、更高效的自定义运行时映像——但前提是整个依赖图都由显式模块组成。因为自动模块阻碍了这种能力,它们的功能就像训练轮:有意设计的临时辅助工具,帮助开发人员朝着完全模块化迈进,但并非旨在永久使用。

至关重要的是,JPMS仍然是可选的。类路径继续有效,许多项目有充分理由选择根本不采用模块。对于那些确实旨在模块化的项目,当前阶段(2023-2025年)是一个依赖项混合、过渡性工具以及整个生态系统稳步但渐进式进步的时期。最终,自动模块代表了一个核心权衡:它们放弃了真正模块化的严格性、安全性和优化机会,以换取兼容性、增量迁移和生态系统稳定性。它们对于JPMS的采用至关重要——但它们也体现了最终必须解决的技术债务。

底线: 自动模块将JPMS从一场迁移灾难转变为可行的渐进式过渡。它们并不完美——它们破坏了封装并创建了隐藏的依赖关系——但它们绝对必要。请将它们战略性地用作通往完全模块化的垫脚石,而非永久解决方案。对于库维护者,请立即添加Automatic-Module-Name。对于应用程序开发人员,请立即模块化您的代码,并随着生态系统的成熟替换自动依赖项。


【注】本文译自:Automatic Modules: Bridging Legacy and Modular Java

[ddd_java_0-3]基于领域驱动设计的Java开发

目录

1. 理解领域驱动设计
引言
结构
学习目标
领域驱动设计的重要性
连接业务目标与技术实现
核心概念与方法论
DDD的战略层面
DDD的战术层面
本章小结
要点总结
选择题
    答案
参考文献
2. 战略DDD概念
引言
结构
学习目标
领域与子域
    EcoTrack物流示例场景
理解限界上下文
上下文映射技术
业务战略与软件设计的对齐
本章小结
要点总结
选择题
    答案
参考文献
3. 战术DDD实现
引言
结构
技术要求
学习目标
实体与值对象
    实体的陷阱
    建造者与领域特定语言
    值对象
聚合与聚合根
服务及其角色
实现仓储
本章小结
要点总结
选择题
    答案
参考文献
4. 测试与验证DDD应用
引言
结构
技术要求
学习目标
DDD测试入门
单元测试DDD组件
    定义领域组件
    使用测试验证预期行为
    增强测试
使用ArchUnit进行架构验证
本章小结
要点总结
选择题
答案
参考文献
5. 微服务、单体与分布式系统中的DDD
引言
结构
技术要求
学习目标
单体架构中的DDD
    创建领域层与组织包结构
    创建应用层
    创建控制器层
    创建基础设施层
微服务架构中的DDD
微服务的必要性
在分布式系统中应用DDD
重构遗留代码以遵循DDD原则
本章小结
要点总结
选择题
    答案
参考文献
6. 将DDD与整洁架构集成
引言
结构
技术要求
学习目标
整洁架构概述
DDD与整洁架构的结合
    使用整洁架构与DDD创建清晰边界
    在核心应用与外部系统间搭建桥梁
构建可维护的代码结构
    每种方法的使用与组合
本章小结
要点总结
选择题
答案
参考文献
7. DDD与数据建模
引言
结构
技术要求
学习目标
DDD在数据建模中的原则
SQL数据库中的数据建模
    Jakarta Persistence实战
NoSQL数据库中的数据建模
本章小结
要点总结
选择题
答案
8. 使用Jakarta EE的企业级Java开发
引言
结构
技术要求
学习目标
使用Jakarta EE应用DDD
利用Jakarta Data实现更好的封装
将DDD集成到企业级Java应用中
本章小结
要点总结
选择题
    答案
9. 使用Spring的企业级Java开发
引言
结构
技术要求
学习目标
Spring框架与DDD概述
使用Spring Boot应用DDD
    创建并设置新的Spring Boot项目
    定义核心领域实体
    构建仓库与服务
    通过REST端点暴露服务
    完善错误处理
    通过单元测试确保代码行为
本章小结
要点总结
选择题
    答案
10. Eclipse MicroProfile与领域驱动设计
引言
结构
技术要求
学习目标
理解Eclipse MicroProfile及其目标
    澄清Jakarta EE与MicroProfile的相似之处
    Eclipse MicroProfile架构与规范
将Eclipse MicroProfile与DDD集成
微服务实战示例
本章小结
要点总结
选择题
答案
参考文献
11. Quarkus与领域驱动设计
引言
结构
技术要求
学习目标
Quarkus、Jakarta EE与MicroProfile的集成
Quarkus实战
    创建并设置新项目
    配置数据库集成
    Panache实体与资源代码生成
    验证应用行为
    使用Panache的Active Record模式
    使用Panache的仓库模式
    从DDD视角使用Panache
本章小结
要点总结
选择题
    答案
参考文献
12. DDD的代码设计与最佳实践
引言
结构
学习目标
贫血模型与富血模型
DDD中的流式API与建造者模式
DDD中的异常处理与日志记录
    定义异常层次结构
    创建可追踪的异常信息
    安全地处理异常与日志
长期代码质量与可持续性
本章小结
要点总结
选择题
    答案
参考文献
13. 最终考量
引言
结构
技术要求
学习目标
领域叙事法介绍
    领域叙事法的目的与益处
    领域叙事法与敏捷头脑风暴的区别
    探索领域叙事法
延伸阅读与持续探索
本章小结
要点总结
参考文献
索引

[ddd_java_0-2]基于领域驱动设计的Java开发

前言

很长一段时间以来,我在多次会议上谈论我最喜欢的话题之一:软件设计和DDD。像往常一样,我首先询问有多少人听说过DDD?答案是异口同声的肯定。但当我提出第二个问题时,情况就变了:你们中有多少人正确地使用了DDD?答案通常是没有人或只有少数人。那么,为什么有大量的人了解DDD,却只有极少数例外能正确应用它呢?为了解决这个问题,我写了这本书来帮助你。
许多团队在现实项目中难以有效实施DDD。问题的根源相当简单:尽管这些模式已广为人知,但它们的目的常常被误解或忽视。本书旨在弥合这一差距,不仅解释DDD是什么,还通过从背后的原理入手,指导如何有效应用它。
本书首先探讨DDD存在的基础原因。通过理解其战略原则,开发者可以避免常见且往往代价高昂的实施错误。许多关于DDD的书籍假设读者已经确信其价值;本书则退后一步,建立这种基础理解,融合了其他作者的见解,并辅以实际示例和现实经验。本书避免教条主义,将既定理论与现代用例相结合,帮助你做出更适合特定情境的更好设计决策。
为了指导你完成这段旅程,本书分为三个部分。第一部分奠定了DDD的战略基础,强调了理解领域、与业务专家协作以及使用限界上下文和上下文映射等概念来组织系统的重要性。这部分特意放在开头,因为掌握战略DDD对于防止后续的错位和过度工程至关重要。
一旦基础奠定,第二部分就转向DDD的战术方面,即设计与实现的交汇处。你将学习如何建模聚合、封装业务规则,以及如何在不同架构风格(包括单体架构、微服务和分布式系统)中应用DDD。本部分还设有一章专注于测试和验证,帮助你长期维护模型的完整性和表现力。
本书的最后一部分通过研究如何使用 Jakarta EE、Spring、Eclipse MicroProfile 和 Quarkus 等工具将 DDD 集成到现实世界企业环境中,综合了所有概念。它还涉及高级设计实践,包括代码级决策和要避免的反模式。本书最后反思了领域叙事——一种旨在帮助团队建立共同理解并弥合业务与技术之间差距的技术。
第1章:理解领域驱动设计
旅程从介绍领域驱动设计本身开始——它的历史、动机以及它旨在解决的基本问题:业务需求与软件交付之间的脱节。本章通过展示DDD如何鼓励技术团队和业务团队之间的沟通,以及它如何帮助创建既富有表现力又与核心领域保持一致的模型,来奠定基础。
第2章:战略DDD概念
本章通过探索战略DDD概念来加深理解。它介绍了最容易被遗忘的部分和DDD的陷阱,即战略层面。事实上,这通常是DDD中最大的错误所在。我们将探讨几种战略DDD概念,例如限界上下文、上下文映射等。这些工具共同帮助团队管理复杂性,创建更清晰、更有目的性的系统。
第3章:战术DDD实现
在本章中,我们进入战术实现。这里,DDD的构建块——实体、值对象、聚合、服务和仓库——不仅被解释,还通过实际的Java代码进行说明。本章将前面部分的抽象概念与具体的编程实践联系起来,展示了如何在将领域逻辑变为现实的同时,使其与基础设施关注点解耦。
第4章:测试与验证DDD应用
本章顺理成章地解决了如何通过测试来验证和保护领域逻辑的问题。它超越了单元测试,包括使用 ArchUnit 和 jMolecules 等工具进行集成测试和架构验证。它还展示了在DDD背景下进行测试如何将领域模型强化为活的文档和有关业务行为的真相来源。
第5章:微服务、单体与分布式系统中的DDD
DDD与软件架构无关;实际上,它可以应用于多种架构结构,例如经典且直接的单体架构和分布式系统。本章涵盖了几种架构选择,并解释了如何在DDD中使用它们。
第6章:将DDD与整洁架构集成
本章探讨了DDD与整洁架构之间的协同作用。它没有将它们视为独立的学科,而是展示了它们如何通过强化关注点分离和确保领域逻辑保持在核心位置来相互补充。你将学习如何构建应用程序以提升灵活性、可维护性以及领域与外部系统之间的清晰边界。
第7章:DDD与数据建模
数据库是现代应用的核心,我们需要考虑在SQL和NoSQL数据库上进行建模,并进一步将它们与DDD结合。处理与领域相关的数据建模是本章的核心组成部分和范围,它考察了在两种不同范式(一种来自数据库,一种来自应用)上工作的影响。
第8章:使用Jakarta EE的企业级Java
本章将讨论带入企业级Java,重点关注Jakarta EE。它介绍了包括Jakarta Data在内的最新Jakarta规范如何支持DDD友好的设计。
第9章:使用Spring的企业级Java
本章介绍了一个如何在Spring平台上使用DDD的实践示例,重点关注最流行的组件,如Spring Data和Spring Boot,以及应用于DDD的代码结构。
第10章:Eclipse MicroProfile与领域驱动设计
本章介绍Eclipse MicroProfile,并解释它如何赋能云原生DDD应用。借助配置、容错和可观测性等特性,MicroProfile帮助开发者构建既具有弹性又以业务逻辑为中心的系统。本章逐步讲解如何在动态环境中保持应用程序的模块化和表现力。
第11章:Quarkus与领域驱动设计
在本章中,重点转向Quarkus——一个为高性能和开发效率而设计的现代Java框架。本章解释了如何使用Quarkus扩展、响应式编程和高效的依赖注入,在轻量级、容器友好的应用中实现DDD,同时不牺牲设计质量。
第12章:DDD的代码设计与最佳实践
本章专注于设计质量本身。它讨论了维护富有表现力和可持续代码库的最佳实践,包括如何避免贫血领域模型、何时应用建造者模式或流式API,以及如何为长期可读性和协作构建代码。它还提供了负责任地重构和发展领域模型的实用建议。
第13章:最终考量
本章总结了全书,并解释了如何提取战略领域并开始使用最古老的技术——叙事法来实现它。这种技术通过以叙事形式可视化地建模过程,使开发者和领域专家更紧密地保持一致。通过使用叙事法,团队可以发现隐藏的假设、澄清术语,并确保所构建的软件真正代表其所服务的业务。
贯穿本书,我们的目标是揭开DDD的神秘面纱,并为你提供在现实世界的Java项目中应用它的工具和思维方式。无论你是从头开始设计系统,还是重构遗留代码库,每一章都旨在帮助你创建能够表达领域语言并交付真正价值的软件。
代码包与彩色图片
请点击以下链接下载本书的代码包与彩色图片
https://rebrand.ly/c46852
本书的代码包也托管在GitHub上:https://github.com/bpbpublications/Domain-driven-Design-with-Java。如果代码有更新,将在现有的GitHub仓库中更新
我们在 https://github.com/bpbpublications 提供了来自我们丰富的书籍和视频目录的代码包。请查看!
勘误表
BPB Publications为我们的工作感到无比自豪,并遵循最佳实践以确保内容的准确性,为我们的订阅者提供愉悦的阅读体验。我们的读者是我们的镜子,我们利用他们的反馈来反思并改进出版过程中可能发生的人为错误。为了让我们保持质量并帮助联系到因任何未预见错误而遇到困难的读者,请写信至:[email protected]
BPB Publications家族非常感谢您的支持、建议和反馈。
在 www.bpbonline.com,您还可以阅读免费技术文章集,注册各种免费时事通讯,并享受BPB书籍和电子书的独家折扣和优惠。您可以在下面查看我们的社交媒体账号:
盗版
如果您在互联网上以任何形式遇到我们作品的非法复制品,若能提供其地址或网站名称,我们将不胜感激。请通过 [email protected] 联系我们,并附上材料链接。
如果您有兴趣成为作者
如果您是某个领域的专家,并且有兴趣撰写或贡献一本书,请访问 www.bpbonline.com。我们已经与成千上万的开发人员和技术专业人士合作,就像您一样,帮助他们与全球技术社区分享他们的见解。您可以提交一般申请,申请我们正在招募作者的特定热门主题,或者提交您自己的想法。
评论
请留下评论。一旦您阅读并使用过本书,为什么不在您购买它的网站上留下评论呢?潜在的读者就可以看到并使用您公正的意见来做出购买决定。我们BPB可以了解您对我们产品的看法,我们的作者也可以看到您对他们书籍的反馈。谢谢!
有关BPB的更多信息,请访问 www.bpbonline.com。
加入我们的Discord空间
加入我们的Discord工作区,获取最新更新、优惠、全球科技动态、新版本发布以及与作者的交流机会:
https://discord.bpbonline.com

[ddd_java_1]基于领域驱动设计的Java开发

第1章 理解领域驱动设计

引言

软件已成为业务成功不可或缺的战略要素,渗透到现代组织的各个层面。企业日益依赖技术来提升效率、交付价值并保持竞争力。这种日益增长的依赖性凸显了能够使软件解决方案与业务目标紧密契合的开发实践的重要性。领域驱动设计【Domain-driven design (DDD)】 应运而生,正是为了满足这一需求,它提供了一种直接的方法来弥合业务期望与技术实现之间长期存在的鸿沟。它使团队交付的软件,能精准、清晰且持续地支撑和驱动业务成果。
随着商业环境变得更加动态和多面化,将领域知识转化为有效软件解决方案的挑战日益凸显。一个常见的问题在于相关方的设想与软件最终交付成果之间的错位。行业观察指出许多软件项目的关键失败点在于:过度关注未经验证的计划和设计、客户期望模糊或不断变化、实施过程中出现不可预见的复杂性,以及产品与工程团队之间协作不力。这些问题常常导致技术工作严重偏离业务目标。
在当前工具生态中,选择的悖论进一步加剧了这种脱节。尽管开发团队可以接触到前所未有的丰富框架和技术,但过多的选择可能导致决策瘫痪、效率低下,并使人忽视真正重要的东西。在此背景下,复杂性成为一种负担而非优势,使得在整个开发过程中保持清晰度和业务对齐变得越来越困难。
这正是DDD实践发挥其作用之处。通过将开发工作立足于业务领域,并鼓励有意的协作建模,DDD提供了一个交付能反映相关方真实需求解决方案的框架。DDD并不规定特定的架构或技术栈,而是倡导清晰性、共同理解和长期可维护性,而不受技术约束的限制。
本章介绍了DDD背后的基本原理,解释了它为何重要以及它旨在解决哪些问题。它为后续更深层次的技术和战略主题奠定了基础。掌握DDD不仅仅是学习模式——它始于理解其原则,特别是指导从发现到实施的战略和战术维度。本章是这段旅程的第一步。

结构

在本章中,我们将探讨以下主题:

  •   领域驱动设计的重要性
  •   连接业务目标与技术实现
  •   核心概念与方法论

    学习目标

    本章旨在为理解DDD在软件开发中为何至关重要奠定基础,重点关注那些常导致项目失败的挑战,例如业务与技术团队之间的错位、不明确的客户期望以及不必要的复杂性。通过探讨DDD的原则,本章展示了它如何提供一种结构化方法来弥合业务与技术之间的差距,促进协作,并确保软件解决方案与现实需求保持一致。本章不深入探讨实现细节,而是介绍DDD背后的逻辑,为全书深入讨论其战略和战术应用做好铺垫。

    领域驱动设计的重要性

    DDD能够通过以下方式应对常常导致软件项目脱轨的常见挑战:

  •   对齐业务与技术团队:DDD提出的实践可以澄清业务需求,并确保技术实现满足这些需求和期望。
  •   澄清客户期望:减少误解和模糊的需求,从而产生真正满足相关方目标的软件。
  •   简化解决方案设计:将系统的复杂性分解为可管理的部分,可以减少常常令人难以招致、拖慢进度并使长期维护变得困难的复杂性。通过掌握正确的实践,团队可以从更简单的设计中受益,从而降低软件维护的难度。
  •   改善跨团队协作:当业务和技术团队不能紧密合作时,关键的商业见解可能在沟通过程中丢失。借助DDD,每个人都可以协作并开始朝着相同的目标努力。
    通过将软件开发建立在业务领域的基础上,并促进技术团队与业务团队之间持续紧密的合作,DDD实践可以使您的团队确保每个类、方法和变量都与核心业务需求正确对齐,最终让您能够控制最终产品的价值及其与相关方期望的一致性。
    DDD最显著的优势之一是其管理复杂性的能力。在软件开发工具和框架数量不断增长的世界里,开发人员迟早会感到选择过多而迷失业务目标。DDD通过一种结构化方法来应对,该方法允许将复杂的业务领域分解为可管理且重点突出的子域。这样,软件变得更容易理解和维护,同时确保开发过程与整体业务战略保持一致。
    此外,DDD强调通用语言的重要性,这是业务和技术团队之间一致使用的共享语言。共同的词汇可以最大限度地减少误解,并驱动项目所有参与者朝着相同的目标努力。开发过程转变为一种协作的跨团队努力,业务和IT部门可以共同创建并交付能够准确反映业务需求和目标的解决方案。
    现在,着眼于DDD在现实场景中的实际应用,我们可以参考那些精确性和团队对齐至关重要的行业。例如,在合规性和准确性至上的银行业,DDD确保贷款管理系统和交易平台的设计既满足法规要求,也满足金融专业人士的特定需求。在电子商务领域,DDD使得能够开发出快速适应不断变化的市场需求,同时保持无缝客户体验的平台。
    最终,DDD的重要性在于它能为软件开发过程带来清晰度和专注度。通过确保软件反映并支持业务的战略目标,DDD提高了解决方案的整体质量和商业价值。了解您工作所交付的价值,可以激励您在自己的项目中有效地实施DDD。在下一节中,让我们探讨DDD如何帮助弥合业务与技术团队之间的鸿沟,为您提供能够改善项目中跨团队协作的基础知识。

    连接业务目标与技术实现

    软件开发中最大的挑战之一是业务目标与技术实现之间的差距。这种差距常常导致误解、优先级冲突以及软件未能达到目标。DDD可以通过将领域专家融入开发过程来克服这一挑战。
    DDD的核心思想是让软件开发与其所支持的业务领域紧密对齐。开发人员不是依赖大量描述需求的文档,而是可以直接与业务专家(又称领域专家)合作,从而直接了解业务的核心活动、挑战和目标。这种紧密合作是一项关键实践,它使领域专家能够为更好、更明智的技术决策提供关键见解。
    弥合沟通鸿沟的一个关键方法是使用通用语言,即两个团队共享的词汇表。在协作中,团队定义并商定这些术语,随后这些术语在项目各阶段(从初始讨论到实施、验证及最终交付)持续一致地使用。这种方法可以最大限度地减少误解,通过减少错误和不符合要求的不正确交付来节省时间。
    DDD还鼓励团队围绕业务领域构建软件系统,通过以反映业务本身结构的方式来设计代码。这种方法使软件更直观、更易于维护,简化了行业中可能的变化在软件中的反映。
    通过运用DDD实践来弥合业务与技术之间的分歧,可以使组织能够创建技术上良好且与企业战略目标紧密对齐的软件。因此,可以创建出更有效、更高效、更有价值并为业务带来切实效益的软件解决方案。
    在本章的下一节中,我们将分解DDD的核心概念和方法论,让您更好地理解如何将理论应用到您的软件项目中。

    核心概念和方法论

    在深入探讨DDD之前,我们必须首先分解其主要概念及其含义。
    领域是指我们旨在转化为代码的特定主题或知识领域。领域的大小或复杂性不是问题,因为我们可以应用分治法将复杂领域分解为更小、更易于理解的子集。
    下图说明了软件开发中的这种分治方法。它直观地展示了软件开发中的知识如何分解为不同的领域,如数据库、文档和架构,并进一步细分为像SQL和NoSQL这样的子域。这种可视化分解有助于阐明DDD如何通过专注于特定的业务领域来鼓励理解和管理复杂性。

    图1.1:软件开发作为一个领域
    在DDD的语境中,业务领域是公司主要的活动领域,反映其核心提供的价值。例如,星巴克主要与咖啡相关,而亚马逊则在零售和云计算等多个领域运营。公司可以发展,随时间改变或扩展其业务领域。
    为了管理领域的复杂性,可以将其细分为子域。这些子域可以进一步分为核心子域、支撑子域和通用子域。
    鉴于软件工程师对客户业务知识有限,领域专家扮演着至关重要的角色。领域专家对业务复杂性有深刻理解,这些细节自然地成为其软件中的需求。
    :最后的术语"设计"可能难以定义,常常与软件架构混淆。《软件架构基础》等经典著作将架构描述为那些难以更改的东西或设计,但这仍然是一个抽象概念,因为难以更改的内容会有所不同。Neal Ford的著作《Head First in Software Architecture》提供了更细致的观点,将架构和设计定义在一个光谱上,设计是关于做出决策来塑造软件系统的结构和组织,以管理复杂性并创建连贯、可维护的架构。
    下图展示了从软件架构到设计的决策光谱。它表示了设计与架构之间的紧密联系,以及设计决策如何可以是一个更轻松的定义,或者如何成为软件系统核心结构的内在部分。

    图1.2:架构与设计的光谱
    考虑到这一点,我们可以将DDD定义为对软件系统结构和组织做出的有意决策,旨在提取业务知识并将其转化为代码。
    DDD是语言无关的,可以用于任何编程语言、范式或框架构建的解决方案。虽然通常与面向对象编程和Java相关联,但DDD实践也适合根据项目需求选择的任何其他语言。
    提示:本书并非旨在取代关于DDD的经典文献,而是通过实践指导来补充它。像Eric Evans的《领域驱动设计:软件核心复杂性应对之道》和Vaughn Vernon的《实现领域驱动设计》这样的基础著作是必读的,即使它们看起来具有挑战性。
    DDD有两个主要组成部分:战略和战术。两者对于确保良好的技术质量和正确的业务对齐都至关重要。让我们探讨这两个方面的区别以及它们如何相互作用。

    DDD的战略层面

    DDD是所有开发工作建立的基础,其重点是加深对业务、其核心领域以及共同构成其运营的子域的理解。
    DDD中的战略着眼于大局,识别业务中最关键、应优先考虑并反映在软件中的领域。这不仅需要协作,更需要与领域专家建立伙伴关系,他们能够传达系统中需要捕捉的复杂性。战略方法使得开发过程中的每个决策都能基于对业务背景扎实、透彻的理解。它指导整个软件项目的结构和方向。

    DDD的战术层面

    DDD的战术层面涉及将战略见解实际应用到代码中。
    一旦业务领域和子域被明确定义,并且通用语言(领域的术语和关键概念)建立起来,DDD的战术层面就开始发挥作用。它包括实施特定的设计模式和编码实践,这些能够通过软件将战略愿景变为现实。战术确保软件架构与业务模型保持一致,因为领域的抽象概念被转化为具体的、功能性的系统组件。DDD战略的标准定义通过可操作的任务得到准确体现,例如创建实体、值对象、聚合和仓库。
    战略和战术在DDD中结合起来,形成了一种连贯的软件开发方法。这种方法在DDD中不仅是一种选择,更是一种必然。战略提供了总体愿景,并确保软件与业务需求保持一致,而战术则负责将这一愿景转化为可运行系统的实际工作。两者都至关重要;没有坚实的战略基础,软件可能无法充分应对业务的核心挑战;没有有效的战术,即使是最好的战略计划也可能在执行中失败。通过整合这两个方面,DDD使得能够创建技术上健壮且与业务高度相关的软件。
    提示:正如《软件设计哲学》中所解释的,战略型软件工程师明白,软件开发不仅仅是编写代码。相反,只关注战术可能弊大于利,从而获得"战术龙卷风"的绰号。
    虽然软件工程师很容易对DDD的战术方面感到兴奋,但重要的是要记住,有效的实施必须从战略开始。DDD的目标是提取业务知识并将其编码到软件中,这使得战略成为关键的第一步。在本书中,我们将探讨能够将您的DDD实践提升到新水平的核心战略和战术知识。

    本章小结

    本章解决了确保软件开发满足客户和相关方期望这一根本性挑战。通过探讨DDD的核心原则,我们展示了这种方法如何使软件与业务目标保持一致。我们强调了理解领域、做出有意的设计决策以及成功实施DDD所需的战略基础的关键作用。本章的要点包括:DDD如何确保开发与业务需求之间的对齐、领域和设计在将业务知识转化为软件中的作用,以及业务和技术团队之间协作的重要性。
    下一章将深入探讨战略DDD,探索如何有效地识别和分类领域与子域。这种战略洞察将为您提供工具,以做出明智的、与业务对齐的决策,确保您的DDD努力为客户带来真正的价值。
    要点总结

  •   DDD聚焦于业务对齐:DDD的主要目标是确保软件开发与业务目标保持一致并交付真实价值。
  •   常见的项目失败源于错位:诸如不明确的客户期望、糟糕的协作以及过于复杂的设计等问题常常导致软件无法满足业务需求。
  •   业务领域是DDD的核心:软件应围绕实际的业务领域构建,使用反映现实世界运作的概念和语言。
  •   协作是关键:通过共享的通用语言促进业务和技术团队之间的有效沟通,可以减少误解并改善软件成果。
  •   DDD兼具战略性和战术性:战略方面侧重于理解领域和子域,而战术方面则涉及实施反映业务需求的模式和结构。
  •   复杂性应被管理而非增加:DDD有助于将复杂系统分解为可管理的部分,确保软件保持适应性、可维护性并与不断发展的业务需求保持一致。
  •   本章为DDD奠定基础:本章并非涵盖所有细节,而是介绍DDD背后的逻辑,为您深入学习其战略和战术应用做好准备。

    选择题

    1.  DDD旨在解决的主要挑战是什么?
          a. 降低软件开发成本
          b. 使软件开发与业务目标保持一致
          c. 提高软件交付速度
          d. 增加软件项目的技术复杂性
          e. 增强软件界面的美学设计
    2.  以下哪项不是本章讨论的DDD关键焦点?
          a. 领域
          b. 设计
          c. 战术实施
          d. 战略基础
          e. 美学用户界面设计
    3.  为什么在DDD中拥有战略基础至关重要?
          a. 它有助于降低软件工具的成本。
          b. 它确保软件架构难以更改。
          c. 它使软件解决方案与业务目标紧密结合。
          d. 它只专注于开发的技术方面。
          e. 它消除了对领域专家的需求。
    4.  在DDD中,为什么业务和技术团队之间的协作至关重要?
          a. 为了增加项目的复杂性
          b. 为了确保软件按时交付
          c. 为了促进共同理解和语言,减少不必要的复杂性,并确保软件交付业务真正需要的东西
          d. 为了减少所需的技术资源,并在没有额外复杂性的情况下交付业务真正需要的东西
          e. 为了让技术团队可以独立做出所有决策
    5.  领域专家在DDD中的主要角色之一是什么?
          a. 为软件编写代码
          b. 提供深刻的业务知识以指导开发过程
          c. 管理项目的技术资源
          d. 设计软件的用户界面
          e. 创建详细的软件架构图

      答案

      题号 答案选项
      1 b
      2 e
      3 c
      4 d
      5 b

      参考文献

    6.  Sinek, Simon. Start with Why: How Great Leaders Inspire Everyone to Take Action, 2009.
    7.  McAfee, Andrew. Now Every Company Is A Software Company, Forbes Techonomy, 2011.
    8.  Quidgest. Every Business Is a Software Business, Quidgest Articles, n.d.
    9.  Forbes Technology Council. 16 Obstacles To A Successful Software Project (And How To Avoid Them), Forbes, 2022.
    10.  Schwartz, Barry. The Paradox of Choice: Why More Is Less, 2004.
    11.  Krill, Paul. Complexity Is Killing Software Developers, InfoWorld, 2012.
    12.  Evans, Eric. Domain-Driven Design: Tackling Complexity in the Heart of Software, 2003.
    13.  Vernon, Vaughn. Implementing Domain-Driven Design, 2013.
    14.  Richards, Mark & Ford, Neal. Fundamentals of Software Architecture: An Engineering Approach, 2020.
    15. Ford, Neal. Software Architecture: The Hard Parts, 2021.
    16. Ousterhout, John. A Philosophy of Software Design, 2018.

加入我们的Discord空间
加入我们的Discord工作区,获取最新更新、优惠、全球科技动态、新版本发布以及与作者的交流机会:https://discord.bpbonline.com


[ddd_java_0-1]基于领域驱动设计的Java开发

基于领域驱动设计的Java开发

运用DDD原则构建可扩展与可维护的Java应用系统
奥塔维奥·桑塔纳 (Otavio Santana)
网址:www.bpbonline.com
2026年第一版
版权所有 © BPB Publications,印度
eISBN:978-93-65894-226
保留所有权利。未经出版者事先书面许可,本出版物的任何部分不得以任何形式或任何方式(电子或机械方式,包括复印、录制或通过任何信息存储和检索系统)复制、传播或存储于数据库或检索系统中,程序清单除外——这些清单可输入、存储于计算机系统并执行,但不得通过出版、影印、录制或任何电子及机械手段进行复制。
责任限制与担保免责声明
本书所载信息基于作者和出版者的认知,真实准确。作者已尽全力确保出版物的准确性,但出版者不对因本书任何信息引起的任何损失或损害承担责任。
本书提及的所有商标均视为其各自所有者的财产,但BPB Publications不保证此信息的准确性。
www.bpbonline.com
谨以本书献给
我挚爱的妻子:
波莉安娜
关于作者
奥塔维奥·桑塔纳是一位屡获殊荣的软件工程师和架构师,热衷于通过开源最佳实践赋能其他工程师,以构建高度可扩展和高效的软件。他是Java和开源生态系统的知名贡献者,其工作赢得了众多奖项和赞誉。奥塔维奥的爱好包括历史、经济、旅行和掌握多门语言,并极富幽默感。
关于审校者
卡琳娜·瓦雷拉的职业生涯专注于连接和支持企业软件的技术。她深入掌握广泛的技术栈、模式及最佳实践,尤其在Java企业解决方案领域。这一背景为她奠定了坚实基础,能够塑造从早期设计阶段到架构定义,再到云和容器基础设施平台上执行的关键任务解决方案。
她在应用平台方面的深厚专业知识,加上对开源的积极参与,是她十多年来在红帽和IBM参与关键企业解决方案的基础。
作为已出版作家和众多社区(例如SouJava协调员)的活跃贡献者,她的工作始终围绕构建健壮的技术和通过开放知识赋能开发者。
这一背景自然地为她最近作为Aletyx联合创始人的工作奠定了基础,该公司基于多年的实践经验和开源领导力,正在构建下一代智能自动化。
致谢
我要向我的家人、朋友和Java社区表示最深切的感谢,感谢他们的大力支持,使我能够投入时间创作本书。
我也感谢BPB Publications在本书出版过程中提供的指导和专业知识。本书的修订过程是一段漫长的旅程,期间有审校者、技术专家和编辑们的宝贵参与和合作。
最后,感谢所有对本书感兴趣的读者以及你们为使其成为现实所提供的支持。你们的鼓励无比珍贵。

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