Java 中的 AI 与机器学习:TensorFlow、DJL 与企业级 AI

1. 引言:Java 意外的机器学习复兴

尽管 Python 主导了机器学习的研究与实验,但生产部署讲述着不同的故事。截至 2025 年,68% 的应用程序运行在 Java 或 JVM 上,那些已在 Java 生态系统投入巨资的企业面临一个关键问题:是重新培训团队并重写系统,还是将机器学习能力引入 Java?答案正日益倾向于后者。

Netflix 使用 Deep Java Library 进行分布式深度学习实时推理,通过字符级 CNN 和通用句子编码器模型处理日志数据,每个事件的延迟为 7 毫秒。这代表了一个更广泛的趋势——尽管 Python 在训练方面占主导地位,但 Java 在生产系统、多线程、稳定性和企业集成方面的优势,使其在机器学习部署上极具吸引力。

本文探讨 Java 在机器学习生命周期中的角色,比较各种框架,探索与 Python 生态系统的集成模式,并识别 Java 提供明显优势的场景。

2. Java ML 框架对比

2.1 Deep Java Library:引擎无关的方案

Deep Java Library 是一个开源的、高层次的、引擎无关的 Java 深度学习框架,它提供原生 Java 开发体验,功能如同任何其他常规 Java 库。由 AWS 创建的 DJL,其架构理念以抽象为核心——开发者编写一次代码,即可在 PyTorch、TensorFlow、MXNet 或 ONNX Runtime 之间切换而无需修改。

该框架由五层架构组成。高层 API 层提供符合 Java 习惯的接口,供开发者直接交互。引擎抽象层与底层框架通信,隐藏实现差异。NDManager 管理表示张量的 NDArray 的生命周期,在处理后自动释放张量内存以防止泄漏或崩溃。数据处理层提供为模型准备数据的实用工具。最后,原生引擎层通过对 C++ 实现的 JNA 调用执行实际计算。

DJL 与 TensorFlow、PyTorch、MXNet 等各种深度学习框架无缝集成,提供一个高层次 API 以便于在 Java 环境中轻松构建、训练和部署模型,并且与 AWS 服务紧密集成。其 Model Zoo 提供了来自 GluonCV、HuggingFace、TorchHub 和 Keras 的 70 多个预训练模型,支持单行命令加载模型。

优势:

  • 引擎灵活性:允许根据部署需求切换后端(研究模型用 PyTorch,生产用 MXNet,跨平台用 ONNX)。
  • 原生多线程支持:与 Akka、Akka Streams 及并发 Java 应用程序自然集成。
  • 自动 CPU/GPU 检测:无需配置即可确保最佳硬件利用率。
  • 通过 DJL Spring starters 集成 Spring Boot:简化企业采用。

局限:

  • 训练功能存在,但不如推理功能成熟
  • 文档侧重于推理而非训练工作流
  • 社区规模小于 Python 优先的框架

2.2 Deeplearning4j:JVM 原生解决方案

Eclipse Deeplearning4j 是为 Java 虚拟机编写的编程库,是一个广泛支持深度学习算法的框架,包括受限玻尔兹曼机、深度信念网络、深度自动编码器、堆叠降噪自动编码器、递归神经张量网络、word2vec、doc2vec 和 GloVe 的实现。

DL4J 于 2014 年问世,目标客户是已投入 Java 基础设施的企业。Eclipse Deeplearning4j 项目包括 Samediff(一个类似 TensorFlow/PyTorch 的框架,用于执行复杂计算图)、Python4j(一个 Python 脚本执行框架,用于将 Python 脚本部署到生产环境)、Apache Spark 集成以及 Datavec(一个将原始输入数据转换为张量的数据转换库)。

该框架的分布式计算能力使其区别于其他方案。Deeplearning4j 包含与 Apache Hadoop 和 Spark 集成的分布式并行版本。对于处理大规模数据的组织,DL4J 提供了无需 Python 依赖的原生 JVM 解决方案。

优势:

  • 完整的 ML 生命周期支持——训练、推理和部署完全在 Java 中完成。
  • 分布式训练:使用 Spark 或 Hadoop 在集群中扩展。
  • ND4J 提供支持 GPU 加速的、类似 NumPy 的 n 维数组。
  • SameDiff 提供类似 TensorFlow 的“先定义后运行”图执行方式。
  • Keras 模型导入:支持 h5 文件,包括 tf.keras 模型。

局限:

  • 文档和社区资源落后于 TensorFlow 和 PyTorch。
  • 与高层次框架相比学习曲线更陡峭。
  • 采用范围较窄,主要集中在重度使用 Java 的企业。

2.3 TensorFlow Java:官方但功能有限

TensorFlow Java 可在任何 JVM 上运行以构建、训练和部署机器学习模型,支持 CPU 和 GPU 在图模式或即时执行模式下的运行,并提供了在 JVM 环境中使用 TensorFlow 的丰富 API。作为 TensorFlow 的官方 Java 绑定,它提供了对 TensorFlow 计算图执行的直接访问。

TensorFlow 的 Java 语言绑定已移至其独立的代码库,以便独立于官方 TensorFlow 版本进行演进和发布,大多数构建任务已从 Bazel 迁移到 Maven。这种分离允许在不等待 TensorFlow 核心发布的情况下进行 Java 特定的改进。

优势:

  • 与 TensorFlow 生态系统和工具直接集成。
  • SavedModel 格式兼容性支持从 Python 到 Java 的无缝模型移交。
  • TensorFlow Lite 支持面向移动和边缘部署。
  • 通过原生 TensorFlow 运行时支持 GPU 和 TPU 加速。

局限:

  • TensorFlow Java API 不在 TensorFlow API 稳定性保证范围内。
  • 对 Keras on Java 几乎无官方支持,迫使开发者必须在 Python 中定义和训练复杂模型以供后续导入 Java。
  • 与 DJL 甚至 DL4J 相比,较低级别的 API 需要编写更多代码。




3. 框架对比表

标准 Deep Java Library Deeplearning4j TensorFlow Java
主要用例 推理与模型服务 完整 ML 生命周期 模型服务
引擎支持 PyTorch, TensorFlow, MXNet, ONNX 原生 JVM 仅 TensorFlow
训练能力 有限 完全支持 有限
分布式计算 通过引擎(如 MXNet 上的 Spark) 原生 Spark/Hadoop 通过 TensorFlow
模型导入 PyTorch, TensorFlow, Keras, ONNX Keras, TensorFlow, ONNX 仅 TensorFlow
预训练模型 Model Zoo 中 70+ 社区模型 TensorFlow Hub
Spring Boot 集成 原生 starters 手动 手动
学习曲线 中-高
内存管理 NDManager(自动) ND4J(堆外) 手动会话
企业就绪度 非常高
社区规模 增长中 小众 大(Python)
最适合 云原生推理 大数据 ML 流水线 TensorFlow 生态系统

决策矩阵:

  • 选择 DJL 用于:微服务、无服务器函数、Spring Boot 应用、引擎灵活性、AWS 生态系统。
  • 选择 DL4J 用于:分布式训练、Spark/Hadoop 集成、完整的纯 Java 技术栈、企业数据流水线。
  • 选择 TensorFlow Java 用于:现有的 TensorFlow 投资、TPU 部署、直接的 Python 模型兼容性。

4. 与 Python ML 生态系统的集成

4.1 多语言生产模式

最优的企业 ML 工作流通常结合 Python 的研究能力和 Java 的生产优势。数据科学家在熟悉的 Python 环境中使用 TensorFlow、PyTorch 或 scikit-learn 训练模型。工程师随后将这些模型部署在每天处理数百万请求的 Java 应用程序中。

模型导出格式:

  • ONNX:这个通用的交换格式支持大多数框架。在 PyTorch 中训练,导出到 ONNX,通过 DJL 或 DL4J 导入。这种方法支持与框架无关的部署流水线。
  • TensorFlow SavedModel:对于长期生产服务,导出到中立格式(如 ONNX)或针对服务优化的框架特定生产格式(SavedModel、TorchScript)。SavedModel 将计算图、变量值和元数据打包到单个目录结构中。
  • TorchScript:PyTorch 模型通过脚本或追踪序列化为 TorchScript。DJL 的 PyTorch 引擎直接加载这些模型,保持完整的计算图。
  • Keras H5:DL4J 导入 Keras 模型(包括 tf.keras 变体),保留层配置和训练好的权重。

4.2 Python4j:在 Java 中嵌入 Python

DL4J 的 Python4j 模块解决了需要 Java 中不可用的 Python 库的场景。Python4j 是一个 Python 脚本执行框架,简化了将 Python 脚本部署到生产环境的过程。该方法将 CPython 解释器嵌入到 JVM 进程中,实现双向调用。

用例包括:

  • 在 Java 推理前使用 scikit-learn 流水线进行预处理。
  • 从 Java 数据流水线调用专门的 Python 库(NumPy, SciPy)。
  • 在 Java 模型服务旁边运行基于 Python 的特征工程。

权衡之处在于需要管理 Python 运行时依赖项和潜在的 GIL 限制。对于高吞吐量场景,模型导出仍然优于运行时 Python 执行。

5. 模型服务与部署模式

5.1 实时推理架构

面向用户的应用,其生产 ML 系统需要低于 100 毫秒的延迟。Java 的线程模型和 JVM 优化在此背景下表现出色。在生产中无需 Python 即可提供 TensorFlow 模型服务,每次预测延迟低于 10 毫秒,并像任何 Spring Boot 服务一样水平扩展。

同步 REST API:

@RestController
public class PredictionController {
    private final Predictor<Image, Classifications> predictor;

    @PostMapping("/predict")
    public Classifications predict(@RequestBody Image image) {
        return predictor.predict(image); // <10ms 典型延迟
    }
}

Spring Boot 的自动配置、健康检查和指标与 DJL 或 DL4J 的预测器实例无缝集成。水平扩展遵循标准的微服务模式——在负载均衡器后部署多个实例。

异步处理:
对于非关键预测,异步处理可提高吞吐量。Java 的 CompletableFutureReactor 或 Kotlin 协程支持并发预测批处理:

// 异步批量预测
List<CompletableFuture<Result>> futures = images.stream()
    .map(img -> CompletableFuture.supplyAsync(
        () -> predictor.predict(img), executor))
    .collect(Collectors.toList());

5.2 批量推理模式

批量作业可以容器化并部署到作业调度器或流水线(如 Airflow/Prefect、Kubeflow Pipelines、云数据管道服务),而在线模型则部署到服务基础设施(Web 服务器、Kubernetes)。

DL4J 的 Spark 集成处理海量数据集:

// Spark 上的分布式批量评分
JavaRDD<DataSet> testData = loadTestData();
JavaRDD<INDArray> predictions = SparkDl4jMultiLayer
    .predict(model, testData);

该模式将推理分布在集群节点上,高效处理数百万条记录。对于拥有 Hadoop 或 Spark 基础设施的组织,这种原生集成消除了 Python 桥接开销。

5.3 边缘与移动端部署

DJL 支持部署到边缘设备和移动平台。对于 Android,DJL 提供了针对 ARM 处理器优化的 TensorFlow Lite 和 ONNX Runtime 引擎。自动 CPU/GPU 检测可适应可用硬件。

用例包括:

  • 移动应用中的设备端图像分类。
  • 无需云连接的 IoT 传感器异常检测。
  • 需要本地推理的边缘计算场景。

该方法降低了延迟,提高了隐私性(数据保留在本地),并消除了网络依赖。

6. 可扩展性考量

6.1 容器化与编排

使用 Docker 进行容器化,允许将模型及其代码连同所有必需的库和依赖项打包到一个自包含的单元中,该单元可以在任何地方运行(您的笔记本电脑、云虚拟机、Kubernetes 集群中)。

Java ML 服务与传统 Spring Boot 应用的容器化方式相同:
Dockerfile 模式:

FROM eclipse-temurin:21-jre-alpine
COPY target/ml-service.jar app.jar
ENTRYPOINT ["java", "-jar", "app.jar"]

Kubernetes 编排处理扩展、健康检查和滚动更新。这种统一性意味着现有的 DevOps 流水线无需特殊处理即可扩展到 ML 服务。

6.2 性能优化策略

  • 模型量化:通过将 float32 权重转换为 int8 来减少模型大小和推理时间。TensorFlow Lite 和 ONNX Runtime 支持量化,且精度损失最小。典型收益:模型缩小 4 倍,推理速度加快 2-3 倍。
  • 批处理:将预测分组以分摊开销。DJL 和 DL4J 支持批处理输入,利用 SIMD 指令,并将每项预测的延迟从 10 毫秒降低到批量 32 条时的每条 2-3 毫秒。
  • 模型编译:ONNX Runtime 和 TensorFlow XLA 将模型编译为优化的执行图。在容器构建期间进行预编译可消除运行时编译开销。
  • 内存管理:DJL 通过其特殊的内存收集器 NDManager 解决了内存泄漏问题,该管理器及时收集 C++ 应用程序内部的陈旧对象,在测试 100 小时连续推理不崩溃后,在生产环境中提供稳定性。
  • 连接池:对于调用外部模型服务器(TensorFlow Serving、Triton)的服务,维护连接池以减少 TCP 握手开销。

6.3 水平扩展模式

Java ML 服务的扩展方式与无状态 Web 服务相同:

  • 在负载均衡器后部署多个实例。
  • 基于 CPU、内存或自定义指标(推理队列深度)使用 Kubernetes HorizontalPodAutoscaler。
  • 实施熔断器以优雅地处理下游故障。
  • 使用 Redis 或 Caffeine 缓存频繁的预测结果。

推理的无状态特性(给定模型版本)使得无需协调开销即可实现弹性扩展。

7. Java 应用的 MLOps

7.1 持续训练与部署

MLOps 团队的目标是自动将 ML 模型部署到核心软件系统中或作为服务组件,自动化整个 ML 工作流步骤,无需任何人工干预。

  • Level 0(手动):许多团队拥有能够构建先进模型的数据科学家和 ML 研究人员,但他们构建和部署 ML 模型的过程完全是手动的,每个步骤都需要手动执行和手动过渡。这代表了 2025 年 35% 的 Java ML 部署。
  • Level 1(ML 流水线自动化):自动化训练流水线根据新数据重新训练模型。Jenkins、GitHub Actions 或 GitLab CI 触发训练作业,将模型导出到工件仓库(Nexus、Artifactory),并通知部署系统。版本化的模型自动部署到预发布环境。
  • Level 2(ML 的 CI/CD):持续集成通过添加测试和验证数据和模型来扩展对代码和组件的测试和验证;持续交付关注自动部署另一个 ML 模型预测服务的 ML 训练流水线的交付;持续训练自动重新训练 ML 模型以重新部署。

在 Java 上下文中,这意味着:

  • 数据流水线和预处理的自动化单元测试。
  • 确保模型预测符合预期输出的集成测试。
  • 金丝雀部署(5% 流量导向新模型版本)。
  • 性能下降时的自动化回滚。

7.2 模型版本控制与注册

将模型视为一等工件:

models/
  fraud-detection/
    v1.0.0/
      model.onnx
      metadata.json
    v1.1.0/
      model.onnx
      metadata.json

元数据包括训练日期、数据集版本、性能指标(准确率、F1 分数)和依赖版本。可以使用 Maven 坐标引用模型版本:

<dependency>
    <groupId>com.company.ml</groupId>
    <artifactId>fraud-detection-model</artifactId>
    <version>1.1.0</version>
    <classifier>onnx</classifier>
</dependency>

这种方法将标准的依赖管理实践应用于 ML 模型,从而实现可重复的构建和可审计的部署。

7.3 监控与可观察性

ML 模型部署后,需要进行监控以确保其按预期执行。Java 的可观察性生态系统自然地扩展到 ML 服务:

要跟踪的指标:

  • 推理延迟:通过 Micrometer 统计 p50、p95、p99 百分位数。
  • 吞吐量:每秒预测数、每秒请求数。
  • 错误率:失败的预测、模型加载失败。
  • 数据漂移:通过统计测试检测到的输入分布变化。
  • 模型性能:生产数据上的准确率、精确率、召回率(当标签可用时)。

与现有工具的集成:
Spring Boot Actuator 暴露 ML 特定指标:

@Component
public class PredictionMetrics {
    private final MeterRegistry registry;

    public void recordPrediction(long latencyMs, String modelVersion) {
        registry.timer("prediction.latency", 
            "model", modelVersion)
            .record(Duration.ofMillis(latencyMs));
    }
}

Prometheus 抓取这些指标,Grafana 可视化趋势,并在出现异常(延迟峰值、准确率下降)时触发告警。

7.4 测试 ML 系统

  • 单元测试:验证数据预处理、特征工程和后处理逻辑。标准的 JUnit 测试即可满足。
  • 集成测试:测试 ML 模型是否成功加载到生产服务中,并且对真实数据的预测符合预期;测试训练环境中的模型与服务环境中的模型给出相同的分数。
  • 性能测试:使用 JMeter 或 Gatling 模拟负载,在真实流量模式下测量吞吐量和延迟。建立基线并检测回归。
  • 影子部署:将新模型版本与现有版本并行运行,记录预测而不影响用户。在全面部署前比较结果以识别意外行为。

8. Java 在机器学习中表现出色的用例

8.1 企业集成场景

  • 金融服务中的欺诈检测:拥有成熟 Java 生态系统的企业越来越寻求将 ML/AI 模型直接集成到其后端系统的方法,而无需启动单独的基于 Python 的微服务。银行每天通过 Java 系统处理数百万笔交易。将 DJL 预测器直接嵌入交易处理流水线中,无需外部服务调用即可实现低于 10 毫秒的欺诈评分。
  • 实时推荐:基于 Spring Boot 构建的电子商务平台集成 DJL 进行产品推荐。会话数据流经现有的 Java 服务,预测在进程内进行,结果无需网络延迟即可呈现。
  • 日志分析与聚类:Netflix 的可观察性团队使用 DJL 在生产中部署迁移学习模型,以对应用程序日志数据进行实时聚类和分析,通过字符级 CNN 和通用句子编码器模型处理日志行,每条约 7 毫秒。基于 DJL 的流水线分配保留相似性的聚类 ID,从而实现告警量减少和存储效率提高。

8.2 大数据 ML 工作流

使用 Spark 或 Hadoop 每天处理 TB 级数据的组织受益于 DL4J 的原生集成。在历史数据上训练模型、对新记录进行评分以及更新模型——所有这些都在 Spark 流水线内完成,无需 Python 桥接。

示例工作流:

  1. 从 HDFS 或 S3 将数据读入 Spark DataFrames。
  2. 使用 Spark SQL 进行特征工程。
  3. 在集群上分布式训练 DL4J 模型。
  4. 使用训练好的模型对新数据评分。
  5. 将结果写回数据仓库。
    整个端到端流程保持在 JVM 中,避免了序列化开销和 Python 互操作的复杂性。

8.3 微服务与云原生应用

Spring Boot 应用程序主导着企业微服务架构。通过 DJL starters 添加 ML 能力可无缝集成:

  • 熔断器:Resilience4j 模式保护 ML 服务免受级联故障影响。
  • 服务发现:Eureka 或 Consul 注册 ML 预测服务。
  • 配置:Spring Cloud Config 管理模型端点和参数。
  • 追踪:Zipkin 或 Jaeger 追踪通过 ML 流水线的请求。
    这种统一性简化了运维——ML 服务与业务逻辑服务以相同的方式部署、扩展和监控。

8.4 边缘计算与物联网

Java 的“一次编写,随处运行”理念扩展到边缘设备。为 ARM 处理器编译的 DJL 模型可以在 Raspberry Pi、NVIDIA Jetson 和工业 IoT 网关上运行。用例包括:

  • 预测性维护:本地分析传感器数据,异常时触发警报。
  • 视频分析:在边缘处理安防摄像头视频流,减少带宽。
  • 智能家居设备:设备端语音识别和自然语言理解。
    GraalVM 原生镜像编译生成独立的可执行文件,内存占用小(< 50MB),启动速度快(< 100ms),非常适合资源受限的环境。

8.5 法规与合规要求

随着欧盟《人工智能法案》等法规的收紧,集成重点转向模型的左移安全性——在流水线中扫描偏见、可解释性和合规性。Java 的强类型、显式异常处理和成熟的日志记录框架便于审计追踪和满足可解释性要求。

金融和医疗保健行业通常要求所有代码(包括 ML 模型)通过经过验证的、带有审批工作流的流水线进行部署。与引入 Python 运行时依赖相比,Java ML 服务能更自然地与现有的治理流程集成。

9. 结论:我们的收获

Java 在机器学习中的作用代表了务实的生产工程,而非研究创新。我们分析得出的主要见解:

  1. 框架选择取决于上下文:DJL 在推理和模型服务方面表现出色,具有引擎灵活性,是云原生微服务的理想选择。DL4J 提供了与大数据框架集成的完整 ML 生命周期功能,适用于需要分布式培训的组织。TensorFlow Java 服务于深度投入 TensorFlow 生态系统、需要直接模型兼容性的团队。
  2. 多语言模式行之有效:在 Python 中训练并在 Java 中部署,利用了每种语言的优势。ONNX 和 SavedModel 格式支持无缝交接。Python4j 在必要时弥合差距,但出于性能考虑,模型导出仍是首选。
  3. 生产性能至关重要:Netflix 7 毫秒的推理延迟证明 Java ML 服务能够满足实时性能要求。适当的内存管理(NDManager、ND4J)、模型优化(量化、编译)和水平扩展提供了生产级系统。
  4. MLOps 成熟度参差不齐:只有 20% 的 Java ML 部署达到了 Level 2 CI/CD 成熟度,具备自动重新训练和监控。机会在于将已建立的 DevOps 实践——容器、编排、可观察性——应用于 ML 工作流。
  5. Java 在特定场景中表现出色:企业集成(欺诈检测、推荐)、大数据 ML 流水线(Spark/Hadoop)、微服务架构、边缘计算和法规合规代表了 Java 的特性——稳定性、线程处理、生态系统成熟度——相比以 Python 为中心的方法提供优势的领域。
  6. 内存管理区分了框架:DJL 的 NDManager 解决了管理 JVM 应用程序中本机内存的关键挑战,实现了 100 小时以上的生产运行而无内存泄漏。这种生产就绪性将企业可行的框架与实验性绑定区分开来。
  7. 差距正在缩小:虽然 Java 不会取代 Python 在 ML 研究中的地位,但像 DJL 和 DL4J 这样的框架已经足够成熟,可用于生产部署。生态系统现在支持完整的推理生命周期,性能可与 Python 解决方案相媲美。

未来可能涉及更深层次的集成——Spring AI 为 Java 带来 LLM 能力,GraalVM 原生镜像为无服务器 ML 实现即时启动,以及 MLOps 和 DevOps 实践之间持续的融合。对于拥有大量 Java 投资的组织,问题从“我们能用 Java 做 ML 吗?”转变为“我们如何优化 Java ML 部署?”。

随着 ML 在企业系统中变得无处不在,Java 的生产优势——稳定性、性能、工具成熟度和操作熟悉度——使其成为推理层的务实选择,即使 Python 在训练和实验中仍占主导地位。多语言方法——在 Python 中训练,在 Java 中部署——代表的不是妥协,而是对每个平台独特优势的优化。


【注】本文译自:AI and Machine Learning in Java: TensorFlow, DJL, and Enterprise AI

让我们从Spring AI开始

Spring AI:使用Java迈入生成式AI的第一步

基于Java的企业系统通常难以与Python库及相关工具链协同工作。为此,Spring AI应运而生——这是一个旨在简化整合人工智能功能(特别是大型语言模型)应用开发的开源框架,它采用了Spring生态系统中大家熟悉的模式。

如果您是一名Java开发者,希望将ChatGPT或Google Gemini等强大功能集成到企业应用程序中,而又不想费力研究各提供商特定的SDK,那么Spring AI是您的理想工具。

什么是Spring AI?

Spring AI的核心是充当AI模型的通用抽象层

可以将其类比于Spring Data JPA之于数据库的关系:正如Spring Data抽象了SQL和数据库的具体细节一样,Spring AI则抽象了不同AI提供商(如OpenAI、Google、Azure、Anthropic等)之间的差异。

这种方法带来了两大显著优势:

  1. 可移植性:您只需极少的代码改动即可在不同AI模型和提供商之间切换,从而为您的用例选择最具成本效益或性能最佳的模型。
  2. 熟悉度:它使用了依赖注入、自动配置和流式API(如WebClientJdbcClient)等标准的Spring概念,使得数以百万计的现有Spring开发者能够轻松上手。

为什么选择Spring AI而不是LangChain?

尽管LangChain是一个强大且与提供商无关的框架,并因LLM调用的“链式”编排而广受欢迎,但它主要为Python生态系统构建。相比之下,Spring AI则是从零开始构建,遵循Java语言习惯,并能与Spring Boot应用无缝集成。

以下是Java企业开发者应该认真考虑使用Spring AI的原因:

符合Java习惯”的优势

对于一个Java团队来说,选择Spring AI意味着:

  • 无需多语言复杂性:您可以避免在生产Java环境中引入Python依赖、虚拟环境以及进程间通信带来的麻烦。
  • 性能:Spring AI原生运行在Java虚拟机(JVM)内,充分利用其卓越的垃圾回收和性能优化能力。
  • 工具链:您可以享受到静态类型检查、强大的调试支持以及Java测试框架(如JUnit、Mockito)完整生态系统的益处。
    简而言之,如果您的应用程序是用Java编写并使用Spring Boot,那么Spring AI就是集成生成式AI最自然、阻力最小的选择。

Spring AI的核心概念

要构建一个基本的AI应用,您需要理解三个核心组件:

构建一个简单的聊天服务

让我们创建一个极简的Spring Boot应用程序,它使用ChatClient根据用户的消息生成回复。在本示例中,我们将使用OpenAI模型。

1. 项目设置(Maven)

将以下内容添加到您的pom.xml文件中:

<dependencies>
  <dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-web</artifactId>
  </dependency>
  <dependency>
    <groupId>org.springframework.ai</groupId>
    <artifactId>spring-ai-openai-spring-boot-starter</artifactId>
  </dependency>
</dependencies>
<dependencyManagement>
  <dependencies>
    <dependency>
      <groupId>org.springframework.ai</groupId>
      <artifactId>spring-ai-bom</artifactId>
      <version>1.0.0</version>
      <type>pom</type>
      <scope>import</scope>
    </dependency>
  </dependencies>
</dependencyManagement>

2. 配置(application.properties)

您需要提供AI提供商的API密钥。将其放在src/main/resources/application.properties文件中。

# 用您实际的OpenAI API密钥替换
spring.ai.openai.api-key=<YOUR_OPENAI_API_KEY>

3. 控制器(AiController.java)

这个类定义了一个REST端点,用于接收消息并使用注入的ChatClient获取响应。

package com.example.aidemo;
import org.springframework.ai.chat.client.ChatClient;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
@RestController
public class AiController {
    private final ChatClient chatClient;
    /**
     * Spring Boot会根据依赖项和属性自动配置并注入ChatClient。
     */
    public AiController(ChatClient.Builder chatClientBuilder) {
        // 使用注入的构建器构建ChatClient实例
        this.chatClient = chatClientBuilder.build();
    }
    @GetMapping("/generate")
    public String generate(@RequestParam(value = "message", defaultValue = "Tell me a short, friendly joke.") String message) {
        // 使用流式API定义提示词并调用模型
        return chatClient.prompt()
            .user(message) // 设置用户的输入消息
            .call()       // 执行对AI模型的调用
            .content();   // 从响应中提取纯文本内容
    }
}

4. 运行与测试

  • 运行您的Spring Boot应用程序。
  • 测试端点:http://localhost:8080/generate?message=Explain%20Spring%20AI%20in%20one%20sentence

【注】本文译自:Lets start with Spring AI

停止编写Excel规格文档:企业级Java开发的Markdown先行方法

将设计规格从Excel转移到Markdown,利用AI生成Java代码,从而防止设计与代码脱节,并将开发时间缩短55%。

在企业级Java开发中,设计文档通常被困在诸如Excel或Word之类的二进制孤岛中,导致它们与实际代码渐行渐远。本文展示了一种模式,即通过使用结构化的Markdown生成式AI,将设计文档视为源代码。

我们都经历过这种情况:架构团队向开发团队交付一份详细设计文档([Detailed Design Document]DDD)。它是一个50页的Word文件,或者更糟,是一个包含多个标签页、用于定义Java类、字段和验证规则的大型Excel电子表格。

当你写下第一行代码时,这份文档就已经过时了。

二进制文件几乎无法进行版本控制,差异对比不切实际,并且将定义复制粘贴到Javadoc中非常繁琐。在企业级规模下,这种"代码漂移"(即实现与设计脱节)成为了技术债务的主要来源。

通过将设计文档转移到结构化的Markdown并利用生成式AI,我们可以将文档完全视为源代码。这在架构师的意图和开发人员的集成开发环境(IDE)之间架起了一座桥梁。

问题:二进制壁垒

在传统的瀑布模型或混合开发环境中,设计存在于Office文档(Word/Excel)中,而代码则以文本格式(Java/YAML)存在。由于格式不兼容,自动化流程中断。你无法轻易地将Excel表格"编译"成Java POJO,当然也无法对Word文档进行单元测试。

为了弥合这一差距,设计信息需要具备以下特点:

  • 基于文本(以便于Git版本控制)。
  • 结构化(以便于机器解析)。
  • 人类可读(以便于审查和协作)。
    解决方案就是使用结构化Markdown

解决方案:将Markdown作为数据源

我们不应仅将Markdown视为编写README文件的方式,而应将其作为一种结构化的规格说明格式。通过标准化标题和布局,Markdown文件就成为一种一致且对机器友好的数据源,生成式AI工具(GitHub Copilot、ChatGPT等)可以解析它来生成样板代码、图表,甚至为利益相关者生成遗留的Excel报告。

1. 目录结构

为使此方法有效,设计文档必须与代码存放在一起,并镜像包结构,以便它们同步演进。

模式示例:

/project-root
    /src
        /main/java/com/app/backend/RegisteredUser.java
    /design-docs
        /backend
            RegisteredUser.md
            OrderService.md
    /diagrams
        architecture.mermaid

通过将.md文件与.java文件保存在相同的仓库结构中,我们在规格说明和实现之间建立了直接、可追溯的联系。

2. 结构化规格说明

关键在于将Markdown写成实际的规格说明,而不是博客文章。我们使用特定的标题(例如## Class Summary, ## Members)作为自动化工具的挂钩点。

示例RegisteredUser.md

# RegisteredUser

## Class Summary
Represents a user who has completed the registration process.
Manages user credentials and validation status.

## Members
| Name | Type | Description |
| :--- | :--- | :--- |
| userId | String | Unique identifier for the user. |
| userPassword | String | Encrypted password string. |

## Methods
+ fn isValidUserId(): bool
  - Logic: Returns true if userId length >= 3.
+ fn isValidUserPassword(): bool
  - Logic: Returns true if password length >= 8.

这种格式对产品负责人是可读的,同时对大语言模型(LLM)来说也足够结构化,足以解释类型定义和逻辑约束。

实施:从文本到Java

一旦我们用结构化的Markdown表达了设计,生成式AI就可以弥合与Java代码之间的鸿沟。在富士通的案例研究中,他们利用一个VS Code扩展和OpenAI API来读取这些Markdown规格说明,并生成初始的类框架。

你可以使用任何生成式AI编码助手来复制这个工作流。由于提示上下文包含一个严格、可预测的结构,幻觉率会显著下降。

提示上下文示例:

"请阅读以下Markdown设计文档。根据’Methods’部分中的描述,生成一个包含Javadoc、字段和验证逻辑的Java类。"

生成输出示例:

package com.app.backend;

/**
 * Represents a user who has completed the registration process.
 * Manages user credentials and validation status.
 */
public class RegisteredUser {

    private String userId;
    private String userPassword;

    public RegisteredUser(String userId, String userPassword) {
        this.userId = userId;
        this.userPassword = userPassword;
    }

    /**
     * Logic: Returns true if userId length >= 3.
     */
    public boolean isValidUserId() {
        return this.userId != null && this.userId.length() >= 3;
    }

    /**
     * Logic: Returns true if password length >= 8.
     */
    public boolean isValidUserPassword() {
        return this.userPassword != null && this.userPassword.length() >= 8;
    }
}

AI不会猜测;它会严格按照书面描述实现指定的业务规则(>=3, >=8)。如果设计发生变更,你只需更新Markdown,然后重新生成代码。

可视化架构

当不再使用Excel、Visio或其他图表工具时,一个常见的担忧是失去了"绘制"系统的能力。但既然我们的设计现在存在于结构化的文本中,我们就可以将其编译成图表。

利用标准化的Markdown标题,我们可以通过简单地扫描目录来自动生成Mermaid.js类图。

输入(Markdown标题):
Class: RegisteredUser depends on Class: UserProfile

#Mermaid Diagram
classDiagram
    class RegisteredUser {
        +String userId
        +String userPassword
        +isValidUserId()
    }
    class UserProfile {
        +String email
    }
    RegisteredUser --> UserProfile

这确保了你的架构图始终反映设计文档的当前状态,而不是架构师三个月前绘制的内容。

"Excel"需求

许多企业仍然需要Excel文件用于正式签核或非技术利益相关者。

但现在,既然真实来源是结构化的文本(Markdown),生成Excel就变得微不足道。一个简单的脚本(甚至一个AI提示)就可以解析标题并自动填充CSV或XLSX模板。

  • 旧方式:主文件是Excel -> 开发人员手动编写Java。
  • 新方式:主文件是Markdown -> 自动生成Java并为管理层自动生成Excel。

结果与投资回报率

转向Markdown先行方法不仅仅是整理你的仓库。在分析的案例研究中,团队看到了明确的生产力提升:

  • 开发速度加快55%:样板代码(类、测试)直接从Markdown规格说明生成。
  • 减少沟通开销:AI辅助的Markdown转换比处理Excel单元格更快、更准确。
  • 真正的差异对比能力:Git现在能准确显示谁在何时更改了业务规则(通过Git提交历史)。

结论

文档常常沦为事后的补救措施,因为我们用于设计的工具(Office套件)与我们用于开发的工具(IDE)格格不入。通过采用Markdown作为一种正式的规范语言,我们将设计工作直接拉入了DevOps流程。

所以,下次当你被要求编写详细设计时,请跳过电子表格。打开一个.md文件,定义一个清晰的结构,然后让代码从中流淌而出。


【注】本文译自:Markdown-First Approach to Enterprise Java

透过独立变化原则审视错误处理

最近我一直在思考错误处理——不是语法争论或"哪种语言做得更好"的辩论,而是更深层次的问题。是什么让某种方法在架构上优于其他方法?

有一个原则为这些决策提供了客观基础:独立变化原则(PIV)【Principle of Independent Variation】。该原则指出:独立变化的事物应该分离,共同变化的事物应该组合。看似简单,但它能告诉你哪种设计在技术上更优越——尽管具体场景可能仍需权衡。

让我通过将其应用于四种错误处理策略来阐明我的观点。

四种处理方式

返回码(C风格)

int read_file(const char* path, char** content) {
    if (!path) return -1;
    if (!exists(path)) return -2;
    return 0;
}

受检异常

public String readFile(String path) throws IOException, SecurityException {
    // 编译器强制你处理这些异常
}

非受检异常(Python、C#、Java RuntimeException)

def read_file(path):
    # 可能引发 FileNotFoundError、PermissionError...
    # 函数签名不会告知这些信息

Result单子(Rust、Haskell;可通过库在Java、C#、Python中使用)

fn read_file(path: &str) -> Result<String, FileError> {
    // 不处理Result就无法访问值
}

变化驱动因素是什么?

PIV首先要问:这段代码可能变化的独立原因有哪些?

对于错误处理,我认为有三类:

  1. 错误类型演变(出现新的失败模式,旧的被移除)
  2. 错误处理逻辑变化(不同的恢复策略)
  3. 业务逻辑变化(与错误无关)

这些变化确实是独立的。添加新错误类型不应涉及业务逻辑。改变网络故障的恢复方式不应影响存在的错误类型。

为什么错误处理策略必须独立变化

这并非空谈。考虑相同的业务操作——"获取用户资料"——及其在不同上下文中处理失败的不同方式:

恢复策略:

  • 退避重试 — 瞬时网络故障,再次尝试
  • 使用缓存/陈旧数据 — 可接受的降级
  • 返回默认值 — 必须继续运行
  • 快速失败 — 如果失败则无需继续

可观测性策略:

  • 记录日志并继续 — 非关键问题,仅记录
  • 通知值班人员 — 需要人工立即关注
  • 增加指标 — 为SLO仪表板跟踪
  • 追踪关联 — 附加到分布式追踪

传播策略:

  • 吸收 — 不让调用方知晓
  • 转换 — 转换为领域特定错误
  • 原样传播 — 让其向上冒泡
  • 熔断 — 停止调用故障服务

在微服务架构中,这点更加有趣。相同的下游故障可能需要:

  • 服务A:重试3次,然后返回缓存数据(面向用户,延迟敏感)
  • 服务B:立即失败,将消息加入死信队列(异步作业,正确性至关重要)
  • 服务C:记录警告,返回部分结果(聚合器,尽力而为)

业务逻辑——"获取用户资料"——是相同的。错误处理根据操作上下文、SLA和各服务的角色而变化。这些关注点因不同原因不同时间变化。PIV指出:它们必须是可分离的

四种方法对比

变化 返回码 受检异常 非受检异常 Result
添加错误类型 涉及所有调用方 更新调用栈上的每个throws子句 无影响 编译器对不完整匹配发出警告
移除错误类型 静默—常量闲置 编译错误(好!) 无影响 编译错误(好!)
更改处理策略 分散在各调用方 重构try-catch块 可集中处理 交换一个组合子
更改业务逻辑 与错误检查纠缠 埋在try块中 清晰—直接更改 清晰—直接更改

详细分析变化驱动因素

变化驱动因素1:错误类型演变

当你添加新错误类型时会发生什么?

受检异常创建耦合——throws子句具有传染性:

// 你向此方法添加DatabaseException...
void saveUser(User u) throws IOException, DatabaseException

// ...现在这个方法需要更新...
void processRegistration(Form f) throws IOException, DatabaseException

// ...还有这个...
void handleRequest(Request r) throws IOException, DatabaseException

// ...一直波及整个调用栈

我见过团队通过在每层捕获和包装来应对——这完全违背了初衷。或者更糟,到处声明throws Exception

返回码静默失败。你添加新常量-3,但现有调用方仍只检查-1和-2。新错误作为垃圾数据传播。

非受检异常完全隐藏变化。调用方直到运行时崩溃才知道新异常存在。

Result类型像非受检异常一样传播——?操作符向上传递错误而无需中间层签名更改。但与非受检异常不同,错误在类型中可见。当你添加新变体时,编译器标记每个决策点(你匹配的地方),而不是整个调用链。两全其美:轻量级传播,显式处理

变化驱动因素2:处理策略变化

这里差异最明显。Result类型提供组合子——在不展开Result的情况下转换它们的小函数。这让你可以在组合时插入不同策略。

相同业务逻辑,不同恢复策略:

// 策略1:快速失败
let user = fetch_user(id)?;

// 策略2:失败时返回缓存数据
let user = fetch_user(id)
    .or_else(|_| get_cached_user(id))?;

// 策略3:退避重试
let user = fetch_user(id)
    .or_else(|_| { sleep(100); fetch_user(id) })
    .or_else(|_| { sleep(500); fetch_user(id) })?;

// 策略4:默认值
let user = fetch_user(id)
    .unwrap_or_else(|_| User::anonymous());

相同业务逻辑,不同可观测性:

// 记录日志并继续
let user = fetch_user(id)
    .map_err(|e| { log::warn!("获取失败: {e}"); e })?;

// 增加指标
let user = fetch_user(id)
    .map_err(|e| { metrics::increment("user_fetch_error"); e })?;

// 通知值班人员(针对关键路径)
let user = fetch_user(id)
    .map_err(|e| { pagerduty::alert("用户获取失败"); e })?;

相同业务逻辑,不同传播:

// 吸收错误,返回部分结果
let profile = fetch_user(id).ok()
    .map(|u| Profile::from(u))
    .unwrap_or(Profile::empty());

// 转换为领域错误
let user = fetch_user(id)
    .map_err(|e| DomainError::UserUnavailable(e))?;

// 熔断模式
let user = circuit_breaker.call(|| fetch_user(id))?;

业务逻辑——fetch_user(id)——从不改变。处理策略围绕它组合,而不是与它交织。你可以在不触及核心操作的情况下交换策略。

现在用异常实现相同的四种策略:

// 策略1:快速失败
User user = fetchUser(id);  // 抛出,调用方处理

// 策略2:失败时返回缓存数据
User user;
try {
    user = fetchUser(id);
} catch (IOException e) {
    user = getCachedUser(id);
}

// 策略3:退避重试
User user;
int retries = 0;
while (true) {
    try {
        user = fetchUser(id);
        break;
    } catch (IOException e) {
        if (++retries >= 3) throw e;
        Thread.sleep(100 * retries);
    }
}

// 策略4:默认值
User user;
try {
    user = fetchUser(id);
} catch (IOException e) {
    user = User.anonymous();
}

每种策略都是不同的控制流结构。重试需要while循环。回退需要try-catch。默认值需要带赋值的try-catch。它们不能组合——你需要从头重建。

组合策略更糟(重试然后回退到缓存):

User user;
int retries = 0;
while (true) {
    try {
        user = fetchUser(id);
        break;
    } catch (IOException e) {
        retries++;
        if (retries >= 3) {
            try {
                user = getCachedUser(id);
                break;
            } catch (CacheException ce) {
                throw new UserFetchException("所有策略都失败", e);
            }
        }
        Thread.sleep(100 * retries);
    }
}

与Result比较:fetch_user(id).or_else(retry).or_else(retry).or_else(cache)fetchUser调用在两者中都没有被触及,但使用异常时它被埋在20行嵌套控制流中。

使用返回码(相同策略):

User* user = NULL;
int result, retries = 0;

while (retries < 3) {
    result = fetch_user(id, &user);
    if (result == 0) break;
    retries++;
    sleep_ms(100 * retries);
}

if (result != 0) {
    result = get_cached_user(id, &user);
}

if (result != 0) {
    log_error("所有策略都失败");
    return ERR_USER_FETCH;
}

同样情况:fetch_user未被触及,但埋在条件判断中。每个策略更改都意味着重写围绕它的if检查。如果你忘记某个检查,错误就会作为垃圾数据静默传播。

差异不仅仅是语法Result策略线性组合——你可以链式调用它们。异常策略层次嵌套——try-catch内部嵌套try-catch。返回码策略条件分散——到处都是if检查。

注意在任何方法中都不变的是:各个操作(fetchUsergetCachedUser)保持不变。问题在于组合方式。使用异常和返回码时,你组合操作的方式与处理它们失败的方式纠缠在一起。想添加第三个回退?需要重构整个代码块。想更改重试次数?编辑定义操作序列的相同代码。

使用Result时,组合和策略是独立的关注点。fetch_user(id).or_else(f).or_else(g)读起来像管道,每个组合子都是独立的。将.or_else(cache)换为.unwrap_or(default)而无需触及其他部分。

变化驱动因素3:业务逻辑变化

当你需要更改核心操作本身时会发生什么——比如fetch_user现在需要一个额外参数,或者你要用fetch_user_v2替换它?

返回码迫使你在错误检查条件语句中导航以找到实际调用。业务逻辑与错误处理纠缠:

// 实际操作在哪里?埋在这里:
while (retries < 3) {
    result = fetch_user(id, &user);  // <-- 在杂音中找到这个
    if (result == 0) break;
    retries++;
    sleep_ms(100 * retries);
}

受检异常将你的逻辑埋在try块中。低内聚——业务操作与其错误处理交织:

try {
    user = fetchUser(id);  // <-- 实际工作
    break;
} catch (IOException e) {
    // 10行恢复逻辑
}

非受检异常和Result类型都保持业务逻辑清晰。操作独立存在:

let user = fetch_user(id)?;  // 清晰显示发生了什么
user = fetch_user(id)  # 同样清晰

区别在于:使用Result时,你知道错误处理存在于链中的某个地方。使用非受检异常时,你相信某个地方的某人会处理它。

评分卡

每种方法在多大程度上允许你独立变化这三个关注点?

变化驱动因素 返回码 受检异常 受检异常 结果
错误类型演变 ❌ 静默失败 ❌ 传染性签名 ⚠️ 不可见 ✅ 穷尽匹配
处理策略变化 ❌ 重写条件判断 ❌ 重构try-catch ⚠️ 可集中但隐式 ✅ 交换组合子
业务逻辑变化 ❌ 与检查纠缠 ⚠️ 埋在try块中 ✅ 清晰 ✅ 清晰

应用PIV

Result类型胜出不是因为语法或时尚,而是因为它们尊重变化的实际运作方式。成功和失败是独立的关注点——它们来自不同的利益相关者,在不同的时间线上演变,响应不同的压力。PIV指出:分离它们

更深刻的见解是PIV基于业务现实。变化驱动因素不是技术抽象——它们是产品需求、合规更新、操作事件、扩展需求。业务不关心你的try-catch嵌套。它关心的是你能在下一次中断前添加熔断器,或者无需两周重构就能交换重试策略。

唯一不变的是变化本身。与这一现实抗争的软件会积累摩擦。拥抱它的软件——通过分离独立变化的事物——保持可塑性。

要自行应用PIV:

  1. 识别变化驱动因素。这段代码可能变化的独立原因是什么?不是抽象类别——真实的力量。谁要求这些变化?频率如何?在什么时间线上?

  2. 追踪耦合。对于每个驱动因素,还有什么必须改变?如果触及一个关注点会波及另一个,你就耦合了本应独立变化的事物。

  3. 检查内聚性。相关代码是否分散?如果共同变化的代码存在于五个不同文件中,你就碎片化了本应组合的内容。

  4. 比较设计。最小化跨关注点耦合同时保持每个关注点内聚的设计就是PIV推崇的。这不是观点——这是变更成本更低的设计。


【注】本文译自:Error Handling Through the Lens of the Principle of Independent Variation

作用域值:Java开发者期待已久的ThreadLocal现代替代方案

如果你曾编写过多线程Java应用程序,很可能接触过ThreadLocal变量。自Java 1.2引入以来,它已存在超过25年,帮助开发者在线程内共享数据而无需通过每个方法参数传递。但时代在变,Java也在演进。随着虚拟线程和Project Loom的出现,传统的ThreadLocal方式已显陈旧。作用域值(Scoped Values)应运而生:一种更简洁、高效且安全的替代方案,正在重塑我们对线程局部数据的认知。

ThreadLocal的问题:为何需要变革

想象一下:你正在构建一个处理数千个并发请求的Web应用程序。每个请求需要在处理生命周期(从Web控制器到服务层,再到数据访问组件)中携带用户认证信息。传统做法是使用ThreadLocal。

public class ThreadLocalExample {
    private static final ThreadLocal<String> userContext = new ThreadLocal<>();

    public static void main(String[] args) {
        userContext.set("Admin");
        System.out.println("User: " + userContext.get());
        userContext.remove(); // 千万别忘记这一步!
    }
}

但问题在于:ThreadLocal在现代应用中存在以下日益突出的缺陷:

  1. 内存泄漏隐患
    必须手动调用remove(),否则可能导致内存泄漏。忘记清理?随着线程不断累积废弃值,你的应用会逐渐耗尽内存。这就像离开每个房间后都不关灯——最终会导致电路过载。

  2. 可变性混乱
    ThreadLocal值可在任意位置、任意时间被修改。这种“远距离幽灵作用”(借用爱因斯坦描述量子力学的比喻)使得调试极其困难。哪个方法修改了值?何时修改?为何修改?在复杂的调用链中追踪这些问题如同大海捞针。

  3. 昂贵的继承开销
    当父线程使用InheritableThreadLocal创建子线程时,整个线程局部映射会被复制。对于虚拟线程(可能同时存在10万个线程),这种内存压力将无法承受。

  4. 生命周期模糊
    ThreadLocal值会持续存在于线程的整个生命周期,除非显式移除。数据应在何处可访问、何处不可访问的边界并不清晰。

作用域值:我们需要的解决方案

作为Java 20的孵化器功能引入,并在Java 21和22中持续优化,作用域值解决了ThreadLocal的所有痛点,同时为虚拟线程时代量身定制。

作用域值的不同之处
可将作用域值视为ThreadLocal更智能、更规范的“兄弟”。其核心变革如下:

import java.lang.ScopedValue;

public class ScopedValuesExample {
    private static final ScopedValue<String> USER_CONTEXT = ScopedValue.newInstance();

    public static void main(String[] args) {
        ScopedValue.where(USER_CONTEXT, "Admin").run(() -> {
            System.out.println("User: " + USER_CONTEXT.get());
            processRequest(); // 嵌套调用中值仍可访问
        });
        // USER_CONTEXT 在此自动清理!无需手动清除
    }

    private static void processRequest() {
        System.out.println("仍可访问: " + USER_CONTEXT.get());
    }
}

这里发生了什么?值“Admin”仅在run() lambda作用域内绑定到USER_CONTEXT。一旦作用域结束,值会自动消失。无需手动清理,没有内存泄漏,只有清晰可靠的行为。

核心原则:为何作用域值更优

  1. 设计上的不可变性
    一旦将值绑定到ScopedValue,在该作用域内就无法更改。这不是限制,而是特性——它消除了因意外变更导致的整类错误。
ScopedValue.where(USER_CONTEXT, "Admin").run(() -> {
    // 不存在set()方法!
    // 值在整个作用域内保持为"Admin"
    callServiceLayer();
    callDataAccessLayer();
    // 所有方法看到相同且不变的值
});
  1. 显式的有限生命周期
    代码的语法结构清晰展示了数据的可访问范围。看到那些花括号了吗?那就是作用域,也是值的存活范围。超出之后,值即消失。

这不仅是内存管理问题,更关乎认知负荷。你可以一眼理解数据流,无需在代码中费力追踪ThreadLocal值可能被修改或清理的位置。

  1. 极速性能
    由于不可变性,JVM可以积极优化作用域值的访问。无论方法调用嵌套多深,通过get()读取作用域值的速度通常堪比读取局部变量。该实现采用轻量级缓存机制,使得重复访问几乎零开销。

对虚拟线程而言,这一点至关重要。在可能存在数百万个并发线程的情况下,每个字节和每个CPU周期都至关重要。

  1. 零成本继承
    当将作用域值与结构化并发(Java 21的另一预览功能)结合使用时,子线程会自动继承父线程的值。但神奇之处在于:由于值不可变,没有复制开销,本质上只是传递指针。
private static final ScopedValue<String> REQUEST_ID = ScopedValue.newInstance();

ScopedValue.where(REQUEST_ID, "REQ-12345").run(() -> {
    try (var scope = new StructuredTaskScope.ShutdownOnFailure()) {
        // 子线程自动看到REQUEST_ID
        scope.fork(() -> processPartA());
        scope.fork(() -> processPartB());
        scope.join();
    }
});

processPartA()processPartB()都能看到“REQ-12345”且无需任何复制。用ThreadLocal可无法高效实现这一点!

实际案例:构建Web框架上下文

让我们通过一个实际场景(处理包含用户认证和事务管理的Web请求)来展示作用域值的优势:

public class WebFramework {
    private static final ScopedValue<Principal> LOGGED_IN_USER = ScopedValue.newInstance();
    private static final ScopedValue<Connection> DB_CONNECTION = ScopedValue.newInstance();

    public void handleRequest(Request request) {
        Principal user = authenticate(request);
        Connection conn = getConnection();

        ScopedValue.where(LOGGED_IN_USER, user)
                   .where(DB_CONNECTION, conn)
                   .run(() -> {
                       processRequest(request);
                   });

        // conn和user自动清理
    }

    private void processRequest(Request request) {
        // 调用链中的任何方法均可访问这些值
        Principal currentUser = LOGGED_IN_USER.get();
        Connection db = DB_CONNECTION.get();

        // 业务逻辑在此处理
        serviceLayer.process();
        dataAccessLayer.save();
    }
}

注意我们无需进行以下操作:

  • 无需通过每个方法参数传递user和conn
  • 无需手动清理代码
  • 无需担心其他方法修改这些值
  • 即使抛出异常也不会内存泄漏

框架处理请求、绑定必要上下文、通过各层处理请求,并在完成后自动清理。非常优雅。

多线程示例:随机数生成

以下完整示例展示不同线程如何获取各自的作用域值:

import java.lang.ScopedValue;
import java.util.concurrent.Executors;

public class MultiThreadExample {
    private static final ScopedValue<Integer> RANDOM_NUMBER = ScopedValue.newInstance();

    public static void main(String[] args) {
        try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
            for (int i = 0; i < 10; i++) {
                executor.submit(() -> {
                    int randomValue = (int) (Math.random() * 100);
                    ScopedValue.where(RANDOM_NUMBER, randomValue).run(() -> {
                        System.out.println(Thread.currentThread().getName() + 
                                         ": 随机数: " + RANDOM_NUMBER.get());
                        doSomeWork();
                    });
                });
            }
        }
    }

    private static void doSomeWork() {
        // 此处仍可访问RANDOM_NUMBER
        System.out.println("正在处理: " + RANDOM_NUMBER.get());
    }
}

每个虚拟线程获取自己的随机数,并在其作用域内可访问。简单、清晰、高效。

作用域值 vs. ThreadLocal:何时使用

以下情况,使用作用域值

  • 使用虚拟线程(Project Loom)
  • 需要共享不可变上下文数据(用户信息、请求ID、事务上下文)
  • 需要自动清理和有限生命周期
  • 使用结构化并发并需要高效的子线程继承
  • 希望代码更易理解和维护

以下情况,继续使用ThreadLocal

  • 需要真正的每线程可变存储
  • 缓存创建成本高的对象(如DateFormat实例)
  • 处理无法重构的遗留系统
  • 数据确实需要在线程整个生命周期内持久存在

技术深度解析:实际工作原理

在底层,作用域值采用复杂但轻量的实现,涉及两个关键组件:

  • 载体(Carrier):保存ScopedValue与其实际值之间的绑定
  • 快照(Snapshot):在特定时间点捕获所有绑定的状态

当你调用ScopedValue.where(KEY, value).run(...)时,JVM会创建一个新的载体来关联该键值对。ScopedValue对象本身如同映射键——一个用于在载体栈中查找值的唯一指针。

其精妙之处在于缓存:首次调用get()时,会搜索外围作用域以找到绑定,然后将结果缓存到小型线程局部缓存中。后续访问速度极快——可能堪比读取局部变量。

专业提示:如需绑定多个值,可创建记录类来保存它们,并将单个ScopedValue绑定到该记录实例。这能最大化缓存效率:

record RequestContext(Principal user, Connection db, String requestId) {}

private static final ScopedValue<RequestContext> CONTEXT = ScopedValue.newInstance();

ScopedValue.where(CONTEXT, new RequestContext(user, conn, "REQ-123"))
           .run(() -> processRequest());

现状与未来

截至2025年11月,作用域值已从孵化器(Java 20)经过预览状态(Java 21、22),并有望成为永久功能。该API在多个预览周期中保持稳定,表明其已接近正式发布。

在Java 21-23中使用作用域值,当前需要启用预览功能:

javac --release 23 --enable-preview YourProgram.java
java --enable-preview YourProgram

一旦功能定稿(可能在Java 24或25),将不再需要预览标志。

结论

作用域值代表了对多线程Java应用中数据共享方式的根本性重新思考。通过拥抱不可变性、显式作用域和性能优化,它们解决了ThreadLocal的所有主要弱点,同时完美适应虚拟线程革命。

你无需急于替换代码库中的每个ThreadLocal。但对于新代码——尤其是为虚拟线程和结构化并发设计的代码——作用域值应是默认选择。它们更安全、更快速、更清晰、更易维护。

明确的信息是:对于面向虚拟线程和结构化并发的新Java应用,作用域值应成为共享上下文数据的首选。它们使代码更安全、更快速,且显著更易理解。

Java并发的未来已至,而且拥有精美的作用域。

本文要点总结

在服务超过25年后,ThreadLocal在现代Java应用中显现出明显局限性。手动清理要求带来内存泄漏风险,可变性导致意外状态变化,继承机制开销昂贵,生命周期模糊不清——这些问题在使用虚拟线程时尤为突出。

作用域值通过其核心设计解决了这些问题。它们提供自动生命周期管理以消除内存泄漏顾虑,不可变性防止意外变更,有限作用域使数据生命周期显式化。性能表现强劲,通过优化缓存实现快速访问,高效的继承机制允许与子线程零成本共享值。

编程模型以显式作用域为核心。值通过ScopedValue.where(KEY, value).run(...)绑定,在嵌套方法调用中自动可访问,并在作用域结束时自动清理。这种方法与虚拟线程(JEP 444)和结构化并发(JEP 453)良好集成,为现代并发编程创建了连贯的基础。

性能特性适合实际使用。通过智能缓存,读取作用域值的速度接近局部变量,轻量级实现可处理数百万虚拟线程而无ThreadLocal的内存压力。常见用例包括Web请求上下文、用户认证数据、事务管理、请求追踪,以及任何需要通过调用链传播不可变上下文的场景。

作用域值和ThreadLocal之间的选择取决于需求。作用域值适用于现代代码中的不可变上下文共享,而ThreadLocal仍适用于每线程可变缓存和遗留系统。该功能从Java 20的孵化器状态演进至Java 21-23的预览阶段,API稳定性表明它即将成为永久功能。

当需要绑定多个值时,将它们分组到记录类并绑定单个ScopedValue可最大化缓存效率并简化代码。更广泛地说,作用域值代表了向更安全、更易理解的并发编程的转变,与Java向轻量级、高并发应用发展的方向一致。


【注】本文译自:Scoped Values: The Modern Alternative to ThreadLocal That Java Developers Have Been Waiting For

自动模块:连接传统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为我们的工作感到无比自豪,并遵循最佳实践以确保内容的准确性,为我们的订阅者提供愉悦的阅读体验。我们的读者是我们的镜子,我们利用他们的反馈来反思并改进出版过程中可能发生的人为错误。为了让我们保持质量并帮助联系到因任何未预见错误而遇到困难的读者,请写信至:errata@bpbonline.com
BPB Publications家族非常感谢您的支持、建议和反馈。
在 www.bpbonline.com,您还可以阅读免费技术文章集,注册各种免费时事通讯,并享受BPB书籍和电子书的独家折扣和优惠。您可以在下面查看我们的社交媒体账号:
盗版
如果您在互联网上以任何形式遇到我们作品的非法复制品,若能提供其地址或网站名称,我们将不胜感激。请通过 business@bpbonline.com 联系我们,并附上材料链接。
如果您有兴趣成为作者
如果您是某个领域的专家,并且有兴趣撰写或贡献一本书,请访问 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在本书出版过程中提供的指导和专业知识。本书的修订过程是一段漫长的旅程,期间有审校者、技术专家和编辑们的宝贵参与和合作。
最后,感谢所有对本书感兴趣的读者以及你们为使其成为现实所提供的支持。你们的鼓励无比珍贵。