[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


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

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

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

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

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

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

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

1 设计 Spring Data JPA 存储库

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

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

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

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

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

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

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

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

2 在存储库中使用查询

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

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

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

使用两种方法的示例:

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

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

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

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

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

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

3 Spring Data JPA 投影

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

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

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

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

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

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

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

List<EmployeeView> findBySalaryGreaterThan(BigDecimal amount);

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

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

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

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

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

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

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

// 用法:

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

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

4 有效使用存储库方法

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

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

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

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

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

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

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

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

5 存储库中的存储过程

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

第一种方法是使用 @NamedStoredProcedureQuery

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

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

在实体上的声明示例:

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

存储库方法:

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

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

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

6 Spring Data JPA 存储库速查表

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

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

要扩展的接口

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

何时选择哪个

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

发现与基础配置

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

事务(默认)

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

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

两种核心方法

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

修改查询

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

使用自定义查询进行分页

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

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

类型

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

注意

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

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

批处理与往返次数

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

让数据库工作

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

分页选项

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

6.5 从 Spring Data JPA 调用存储过程

方法

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

输出

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

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

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

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

使用 Spring AI 创建 MCP 服务器

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

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

引言

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

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

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

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

先决条件

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

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

构建 MCP 服务器

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

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

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

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

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

创建 Artist 的数据模型。

public record Artist(String name) {
}

创建 Song 的数据模型。

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

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

@Service
public class ArtistService {

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

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

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

}

以类似的方式,创建 SongService

@Service
public class SongService {

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

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

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

}

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

@SpringBootApplication
public class MyMcpServerPlanetApplication {

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

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

}

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

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

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

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

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

构建 jar 文件。

mvn clean verify

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

测试 MCP 服务器

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

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

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

Test MCP Server)

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

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

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

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

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

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

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

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

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

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

以下是 Gunter 喜爱的艺术家:

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

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

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

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

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

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

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

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

添加搜索功能

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

ArtistService 添加一个 search_artist 工具。

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

SongService 添加一个 search_song 工具。

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

结论

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


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

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

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

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

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

为何运行时安全被忽视

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

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

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

输入验证:万物皆不可信

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

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

糟糕的验证示例

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

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

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

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

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

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

正确的输入验证

以下是正确的做法:

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

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

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

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

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

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

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

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

验证复杂对象

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

        return true;
    }
}

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

白名单与黑名单的陷阱

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

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

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

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

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

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

沙箱机制:限制损害

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

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

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

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

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

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

现代沙箱方法

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

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

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

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

        Process process = pb.start();

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

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

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

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

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

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

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

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

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

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

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

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

限制资源消耗:

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

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

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

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

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

真实世界的沙箱示例

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

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

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

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

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

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

            Process process = pb.start();

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

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

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

            return result;

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

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

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

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

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

为何反序列化是危险的

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

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

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

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

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

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

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

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

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

当你必须反序列化时

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

使用 ObjectInputFilter (Java 9+):

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

        ois.setObjectInputFilter(filter);

        return ois.readObject();
    }
}

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

验证对象图:

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

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

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

        String className = desc.getName();

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

        return super.resolveClass(desc);
    }

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

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

对序列化数据进行签名:

public class SignedSerializer {
    private final SecretKey signingKey;

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

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

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

        return buffer.array();
    }

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

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

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

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

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

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

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

替代序列化库

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

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

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

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

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

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

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

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

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

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

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

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

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

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

修复需要多次更改:

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

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

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

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

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

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

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

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

实用安全检查清单

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

输入验证:

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

沙箱机制:

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

反序列化:

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

通用实践:

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

有用的工具

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

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

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

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

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

思维模式的转变

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

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

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

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

有用资源


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

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

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

引言

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

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

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

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

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

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

虚拟线程

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

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

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

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

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

ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor();

对比示例

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

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

示例 — 源代码

Example01CachedThreadPool.java

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

var executor = Executors.newCachedThreadPool()
package threads;

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

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

public class Example01CachedThreadPool {

    public void executeTasks(final int NUMBER_OF_TASKS) {

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

        long startTime = System.currentTimeMillis();

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

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

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

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

}
package threads;

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

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

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

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

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

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

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

}

我 PC 上的测试结果:

Example02FixedThreadPool.java

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

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

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

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

public class Example02FixedThreadPool {

    public void executeTasks(final int NUMBER_OF_TASKS) {

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

        long startTime = System.currentTimeMillis();

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

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

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

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

}
package threads;

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

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

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

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

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

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

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

}

我 PC 上的测试结果:

Example03VirtualThread.java

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

var executor = Executors.newVirtualThreadPerTaskExecutor()
package threads;

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

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

public class Example03VirtualThread {

    public void executeTasks(final int NUMBER_OF_TASKS) {

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

        long startTime = System.currentTimeMillis();

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

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

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

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

}
package threads;

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

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

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

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

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

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

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

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

}

我 PC 上的测试结果:

结论

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

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

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

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


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

Java数据库应用原型

一个使用 Spring Boot 和容器进行测试、Keycloak 提供安全、PostgreSQL 提供数据持久化的,带有 REST 和安全功能的 Java 数据库应用原型。

在工作中开发时,我多次需要一个简单应用的模板,以便基于此模板开始为手头的项目添加特定代码。
在本文中,我将创建一个简单的 Java 应用程序,它连接到数据库,暴露一些 REST 端点,并使用基于角色的访问来保护这些端点。
目的是拥有一个最小化且功能齐全的应用程序,然后可以针对特定任务进行定制。
对于数据库,我们将使用 PostgreSQL;对于安全,我们将使用 Keycloak,两者都通过容器部署。在开发过程中,我使用 podman 来测试容器是否正确创建(作为 docker 的替代品——它们在大多数情况下可以互换)作为一次学习体验。
应用程序本身是使用 Spring Boot 框架开发的,并使用 Flyway 进行数据库版本管理。
所有这些技术都是 Java EE 领域业界标准,在项目中被使用的可能性很高。

我们构建原型的核心需求是一个图书馆应用程序,它暴露 REST 端点,允许创建作者、书籍以及它们之间的关系。这将使我们能够实现一个多对多关系,然后可以将其扩展用于任何可以想象的目的。
完整可用的应用程序可以在 https://github.com/ghalldev/db_proto 找到。
本文中的代码片段取自该代码库

在创建容器之前,请确保使用您偏好的值定义以下环境变量(教程中故意省略了它们,以避免传播多个用户使用的默认值):

DOCKER_POSTGRES_PASSWORD
DOCKER_KEYCLOAK_ADMIN_PASSWORD
DOCKER_GH_USER1_PASSWORD

配置 PostgreSQL:

docker container create --name gh_postgres --env POSTGRES_PASSWORD=$DOCKER_POSTGRES_PASSWORD --env POSTGRES_USER=gh_pguser --env POSTGRES_INITDB_ARGS=--auth=scram-sha-256 --publish 5432:5432 postgres:17.5-alpine3.22
docker container start gh_postgres

配置 Keycloak:
首先是容器的创建并启动:

docker container create --name gh_keycloak --env DOCKER_GH_USER1_PASSWORD=$DOCKER_GH_USER1_PASSWORD --env KC_BOOTSTRAP_ADMIN_USERNAME=gh_admin --env KC_BOOTSTRAP_ADMIN_PASSWORD=$DOCKER_KEYCLOAK_ADMIN_PASSWORD --publish 8080:8080 --publish 8443:8443 --publish 9000:9000 keycloak/keycloak:26.3 start-dev
docker container start gh_keycloak

在容器启动并运行后,我们可以继续创建领域、用户和角色(这些命令必须在正在运行的容器内部执行):

cd $HOME/bin
./kcadm.sh config credentials --server http://localhost:8080 --realm master --user gh_admin --password $KC_BOOTSTRAP_ADMIN_PASSWORD
./kcadm.sh create realms -s realm=gh_realm -s enabled=true
./kcadm.sh create users -s username=gh_user1 -s email="gh_user1@email.com" -s firstName="gh_user1firstName" -s lastName="gh_user1lastName" -s emailVerified=true -s enabled=true -r gh_realm
./kcadm.sh set-password -r gh_realm --username gh_user1 --new-password $DOCKER_GH_USER1_PASSWORD
./kcadm.sh create roles -r gh_realm -s name=viewer -s 'description=Realm role to be used for read-only features'
./kcadm.sh add-roles --uusername gh_user1 --rolename viewer -r gh_realm
./kcadm.sh create roles -r gh_realm -s name=creator -s 'description=Realm role to be used for create/update features'
./kcadm.sh add-roles --uusername gh_user1 --rolename creator -r gh_realm
ID_ACCOUNT_CONSOLE=$(./kcadm.sh get clients -r gh_realm --fields id,clientId | grep -B 1 '"clientId" : "account-console"' | grep -oP '[0-9a-f]{8}-([0-9a-f]{4}-){3}[0-9a-f]{12}')
./kcadm.sh update clients/$ID_ACCOUNT_CONSOLE -r gh_realm -s 'fullScopeAllowed=true' -s 'directAccessGrantsEnabled=true'

用户 gh_user1 在领域 gh_realm 中被创建,并拥有 viewercreator 角色。

您可能已经注意到,我们没有创建新的客户端,而是使用了 Keycloak 自带的一个默认客户端:account-console。这是为了方便起见,在实际场景中,您会创建一个特定的客户端,然后将其更新为具有 fullScopeAllowed(这会导致领域角色被添加到令牌中——默认情况下不添加)和 directAccessGrantsEnabled(允许通过 Keycloak 的 openid-connect/token 端点生成令牌,在我们的例子中使用 curl)。

创建的角色随后可以在 Java 应用程序内部使用,以根据我们约定的契约来限制对某些功能的访问——viewer 只能访问只读操作,而 creator 可以执行创建、更新和删除操作。当然,同样地,可以根据任何原因创建各种角色,只要约定的契约被明确定义并被所有人理解。
角色还可以进一步添加到组中,但本教程不包含这部分内容。

但是,在实际使用这些角色之前,我们必须告诉 Java 应用程序如何提取角色——这是必须的,因为 Keycloak 将角色添加到 JWT 的方式是其特有的,所以我们必须编写一段自定义代码,将其转换为 Spring Security 可以使用的东西:

@Bean
public JwtAuthenticationConverter jwtAuthenticationConverter() {
    // 遵循与 org.springframework.security.oauth2.server.resource.authentication.JwtGrantedAuthoritiesConverter 相同的模式
    Converter<Jwt, Collection<GrantedAuthority>> keycloakRolesConverter = new Converter<>() {
        private static final String DEFAULT_AUTHORITY_PREFIX = "ROLE_";
        //https://github.com/keycloak/keycloak/blob/main/services/src/main/java/org/keycloak/protocol/oidc/TokenManager.java#L901
        private static final String KEYCLOAK_REALM_ACCESS_CLAIM_NAME = "realm_access";
        private static final String KEYCLOAK_REALM_ACCESS_ROLES = "roles";

        @Override
        public Collection<GrantedAuthority> convert(Jwt source) {
            Collection<GrantedAuthority> grantedAuthorities = new ArrayList<>();
            Map<String, List<String>> realmAccess = source.getClaim(KEYCLOAK_REALM_ACCESS_CLAIM_NAME);
            if (realmAccess == null) {
                logger.warn("No " + KEYCLOAK_REALM_ACCESS_CLAIM_NAME + " present in the JWT");
                return grantedAuthorities;
            }
            List<String> roles = realmAccess.get(KEYCLOAK_REALM_ACCESS_ROLES);
            if (roles == null) {
                logger.warn("No " + KEYCLOAK_REALM_ACCESS_ROLES + " present in the JWT");
                return grantedAuthorities;
            }
            roles.forEach(
                    role -> grantedAuthorities.add(new SimpleGrantedAuthority(DEFAULT_AUTHORITY_PREFIX + role)));

            return grantedAuthorities;
        }

    };
    JwtAuthenticationConverter jwtAuthenticationConverter = new JwtAuthenticationConverter();
    jwtAuthenticationConverter.setJwtGrantedAuthoritiesConverter(keycloakRolesConverter);

    return jwtAuthenticationConverter;
}

AppConfiguration 类中还完成了其他重要配置,例如启用方法安全性和禁用 CSRF。

现在我们可以在 REST 控制器中使用 @org.springframework.security.access.prepost.PreAuthorize 注解来限制访问:

@PostMapping("/author")
@PreAuthorize("hasRole('creator')")
public void addAuthor(@RequestParam String name, @RequestParam String address) {
  authorService.add(new AuthorDto(name, address));
}

@GetMapping("/author")
@PreAuthorize("hasRole('viewer')")
public String getAuthors() {
  return authorService.allInfo();
}

通过这种方式,只有成功通过身份验证且拥有 hasRole 中列出的角色的用户才能调用端点,否则他们将收到 HTTP 403 Forbidden 错误。

在容器启动并配置完成后,Java 应用程序可以启动了,但在启动之前需要添加数据库密码——这可以通过环境变量完成(下面是一个 Linux shell 示例):

export SPRING_DATASOURCE_PASSWORD=$DOCKER_POSTGRES_PASSWORD

现在,如果一切正常启动并运行,我们可以使用 curl 来测试我们的应用程序(以下所有命令均为 Linux shell 命令)。

使用之前创建的用户 gh_user1 登录并提取身份验证令牌:

KEYCLOAK_ACCESS_TOKEN=$(curl -d 'client_id=account-console' -d 'username=gh_user1' -d "password=$DOCKER_GH_USER1_PASSWORD" -d 'grant_type=password' 'http://localhost:8080/realms/gh_realm/protocol/openid-connect/token' | grep -oP '"access_token":"\K[^"]*')

创建一个新作者(这将测试 creator 角色是否有效):

curl -X POST --data-raw 'name="GH_name1"&address="GH_address1"' -H "Authorization: Bearer $KEYCLOAK_ACCESS_TOKEN" 'localhost:8090/library/author'

检索库中的所有作者(这将测试 viewer 角色是否有效):

curl -X GET -H "Authorization: Bearer $KEYCLOAK_ACCESS_TOKEN" 'localhost:8090/library/author'

至此,您应该拥有了创建自己的 Java 应用程序所需的一切,可以根据需要对其进行扩展和配置。


【注】本文译自:Java Spring Boot Template With PostgreSQL, Keycloak Securit

单体架构中的事件驱动架构:Java应用程序的渐进式重构

传统观点认为事件驱动架构属于微服务架构范畴,服务通过消息代理进行异步通信。然而,事件驱动模式一些最具价值的应用恰恰发生在单体应用程序内部——在这些地方,紧密耦合已造成维护噩梦,而渐进式重构则提供了一条通往更好架构的路径,且无需分布式系统的运维复杂性。

为何在单体应用中使用事件有意义

传统的分层单体应用存在一个特定问题:直接的方法调用在组件之间创建了僵化的依赖关系。您的订单处理代码直接调用库存管理,库存管理又调用仓库系统,继而触发电子邮件通知。每个组件都了解其他几个组件,从而形成一个纠缠不清的网,更改其中一部分需要理解并测试它所触及的所有内容。

事件驱动模式引入了间接性。当下单时,订单服务发布一个"OrderPlaced"事件。其他对订单感兴趣的组件——库存、发货、通知——订阅此事件并独立响应。订单服务不知道也不关心谁在监听。即使这些组件存在于同一个代码库并共享同一个数据库,它们也变得松散耦合。

这种方法提供了立竿见影的好处,而无需将应用程序拆分为微服务。您在保持单体应用运维简单性的同时,获得了可测试性、灵活性和更清晰的边界。当您最终需要提取服务时,事件驱动的结构使得过渡更加平滑,因为组件已经通过定义良好的消息进行通信,而不是直接的方法调用。

起点:一个紧密耦合的订单系统

考虑一个使用 Spring Boot 构建的典型电子商务单体应用。订单创建流程如下所示:

@Service
@Transactional
public class OrderService {
    private final OrderRepository orderRepository;
    private final InventoryService inventoryService;
    private final PaymentService paymentService;
    private final ShippingService shippingService;
    private final LoyaltyService loyaltyService;
    private final EmailService emailService;
    private final AnalyticsService analyticsService;

    public OrderService(
        OrderRepository orderRepository,
        InventoryService inventoryService,
        PaymentService paymentService,
        ShippingService shippingService,
        LoyaltyService loyaltyService,
        EmailService emailService,
        AnalyticsService analyticsService
    ) {
        this.orderRepository = orderRepository;
        this.inventoryService = inventoryService;
        this.paymentService = paymentService;
        this.shippingService = shippingService;
        this.loyaltyService = loyaltyService;
        this.emailService = emailService;
        this.analyticsService = analyticsService;
    }

    public Order createOrder(CreateOrderRequest request) {
        // 验证库存
        for (OrderItem item : request.getItems()) {
            if (!inventoryService.checkAvailability(item.getProductId(), item.getQuantity())) {
                throw new InsufficientInventoryException(item.getProductId());
            }
        }

        // 处理支付
        PaymentResult payment = paymentService.processPayment(
            request.getCustomerId(),
            calculateTotal(request.getItems()),
            request.getPaymentDetails()
        );

        if (!payment.isSuccessful()) {
            throw new PaymentFailedException(payment.getErrorMessage());
        }

        // 创建订单
        Order order = new Order(
            request.getCustomerId(),
            request.getItems(),
            payment.getTransactionId()
        );
        order.setStatus(OrderStatus.CONFIRMED);
        Order savedOrder = orderRepository.save(order);

        // 预留库存
        for (OrderItem item : request.getItems()) {
            inventoryService.reserveInventory(item.getProductId(), item.getQuantity());
        }

        // 创建发货单
        shippingService.createShipment(savedOrder);

        // 更新忠诚度积分
        loyaltyService.addPoints(
            request.getCustomerId(),
            calculateLoyaltyPoints(savedOrder)
        );

        // 发送确认邮件
        emailService.sendOrderConfirmation(savedOrder);

        // 跟踪分析
        analyticsService.trackOrderPlaced(savedOrder);

        return savedOrder;
    }
}

这段代码可以工作,但存在严重问题。OrderService 知道七个不同的服务。测试需要模拟所有这些服务。添加新的订单后操作意味着要修改此方法。如果电子邮件服务缓慢,订单创建就会变慢。如果分析跟踪失败,整个订单就会失败并回滚。

事务边界也是错误的。所有操作都在单个数据库事务中发生,这意味着即使电子邮件服务临时停机也会阻止订单创建。库存预留和发货单创建在事务上耦合,尽管它们在逻辑上是独立的操作。

引入 Spring 应用事件

Spring Framework 提供了一个内置的事件系统,在单个 JVM 内工作。默认情况下它是同步的,这使得它易于推理和调试。首先定义领域事件:

public abstract class DomainEvent {
    private final Instant occurredAt;
    private final String eventId;

    protected DomainEvent() {
        this.occurredAt = Instant.now();
        this.eventId = UUID.randomUUID().toString();
    }

    public Instant getOccurredAt() {
        return occurredAt;
    }

    public String getEventId() {
        return eventId;
    }
}

public class OrderPlacedEvent extends DomainEvent {
    private final Long orderId;
    private final Long customerId;
    private final List<OrderItem> items;
    private final BigDecimal totalAmount;

    public OrderPlacedEvent(Order order) {
        super();
        this.orderId = order.getId();
        this.customerId = order.getCustomerId();
        this.items = new ArrayList<>(order.getItems());
        this.totalAmount = order.getTotalAmount();
    }

    // Getters
}

事件应该是不可变的,并包含订阅者需要的所有信息。避免直接传递实体——而是复制相关数据。这可以防止订阅者意外修改共享状态。

重构 OrderService 以发布事件,而不是直接调用服务:

@Service
@Transactional
public class OrderService {
    private final OrderRepository orderRepository;
    private final InventoryService inventoryService;
    private final PaymentService paymentService;
    private final ApplicationEventPublisher eventPublisher;

    public OrderService(
        OrderRepository orderRepository,
        InventoryService inventoryService,
        PaymentService paymentService,
        ApplicationEventPublisher eventPublisher
    ) {
        this.orderRepository = orderRepository;
        this.inventoryService = inventoryService;
        this.paymentService = paymentService;
        this.eventPublisher = eventPublisher;
    }

    public Order createOrder(CreateOrderRequest request) {
        // 验证库存
        for (OrderItem item : request.getItems()) {
            if (!inventoryService.checkAvailability(item.getProductId(), item.getQuantity())) {
                throw new InsufficientInventoryException(item.getProductId());
            }
        }

        // 处理支付
        PaymentResult payment = paymentService.processPayment(
            request.getCustomerId(),
            calculateTotal(request.getItems()),
            request.getPaymentDetails()
        );

        if (!payment.isSuccessful()) {
            throw new PaymentFailedException(payment.getErrorMessage());
        }

        // 创建并保存订单
        Order order = new Order(
            request.getCustomerId(),
            request.getItems(),
            payment.getTransactionId()
        );
        order.setStatus(OrderStatus.CONFIRMED);
        Order savedOrder = orderRepository.save(order);

        // 同步预留库存(仍在关键路径上)
        for (OrderItem item : request.getItems()) {
            inventoryService.reserveInventory(item.getProductId(), item.getQuantity());
        }

        // 为非关键操作发布事件
        eventPublisher.publishEvent(new OrderPlacedEvent(savedOrder));

        return savedOrder;
    }
}

现在 OrderService 仅依赖四个组件,而不是八个。更重要的是,它只了解对订单创建至关重要的操作——库存验证、支付处理和库存预留。其他所有操作都通过事件发生。

为解耦的操作创建事件监听器:

@Component
public class OrderEventListeners {
    private static final Logger logger = LoggerFactory.getLogger(OrderEventListeners.class);

    private final ShippingService shippingService;
    private final LoyaltyService loyaltyService;
    private final EmailService emailService;
    private final AnalyticsService analyticsService;

    public OrderEventListeners(
        ShippingService shippingService,
        LoyaltyService loyaltyService,
        EmailService emailService,
        AnalyticsService analyticsService
    ) {
        this.shippingService = shippingService;
        this.loyaltyService = loyaltyService;
        this.emailService = emailService;
        this.analyticsService = analyticsService;
    }

    @EventListener
    @Transactional(propagation = Propagation.REQUIRES_NEW)
    public void handleOrderPlaced(OrderPlacedEvent event) {
        try {
            shippingService.createShipment(event.getOrderId());
        } catch (Exception e) {
            logger.error("Failed to create shipment for order {}", event.getOrderId(), e);
            // 不要重新抛出 - 其他监听器仍应执行
        }
    }

    @EventListener
    @Transactional(propagation = Propagation.REQUIRES_NEW)
    public void updateLoyaltyPoints(OrderPlacedEvent event) {
        try {
            int points = calculatePoints(event.getTotalAmount());
            loyaltyService.addPoints(event.getCustomerId(), points);
        } catch (Exception e) {
            logger.error("Failed to update loyalty points for order {}", event.getOrderId(), e);
        }
    }

    @EventListener
    public void sendConfirmationEmail(OrderPlacedEvent event) {
        try {
            emailService.sendOrderConfirmation(event.getOrderId());
        } catch (Exception e) {
            logger.error("Failed to send confirmation email for order {}", event.getOrderId(), e);
        }
    }

    @EventListener
    public void trackAnalytics(OrderPlacedEvent event) {
        try {
            analyticsService.trackOrderPlaced(event.getOrderId(), event.getTotalAmount());
        } catch (Exception e) {
            logger.error("Failed to track analytics for order {}", event.getOrderId(), e);
        }
    }
}

每个监听器在它自己的事务中运行(在适当的时候)并独立处理故障。如果发送电子邮件失败,发货单创建仍然会发生。即使分析跟踪抛出异常,订单创建事务也会成功提交。

理解事务边界

@Transactional(propagation = Propagation.REQUIRES_NEW) 注解至关重要。没有它,所有监听器都会参与订单创建事务。如果任何监听器失败,整个订单都会回滚——这正是我们试图避免的情况。

使用 REQUIRES_NEW,每个监听器都会启动一个新的事务。当监听器运行时,订单已经提交。这意味着:

  • 监听器无法阻止订单创建
  • 监听器故障不会回滚订单
  • 每个监听器的工作是独立原子性的

但这有一个权衡。如果监听器失败,订单存在但某些后处理没有发生。您需要处理这些部分故障的策略:

@EventListener
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void handleOrderPlaced(OrderPlacedEvent event) {
    try {
        shippingService.createShipment(event.getOrderId());
    } catch (Exception e) {
        logger.error("Failed to create shipment for order {}", event.getOrderId(), e);

        // 记录失败以便重试
        failedEventRepository.save(new FailedEvent(
            event.getClass().getSimpleName(),
            event.getEventId(),
            "handleOrderPlaced",
            e.getMessage()
        ));
    }
}

一个单独的后台作业可以重试失败的事件:

@Component
public class FailedEventRetryJob {
    private final FailedEventRepository failedEventRepository;
    private final ApplicationEventPublisher eventPublisher;

    @Scheduled(fixedDelay = 60000) // 每分钟
    public void retryFailedEvents() {
        List failures = failedEventRepository.findRetryable();

        for (FailedEvent failure : failures) {
            try {
                // 重建并重新发布事件
                DomainEvent event = reconstructEvent(failure);
                eventPublisher.publishEvent(event);

                failure.markRetried();
                failedEventRepository.save(failure);
            } catch (Exception e) {
                logger.warn("Retry failed for event {}", failure.getEventId(), e);
                failure.incrementRetryCount();
                failedEventRepository.save(failure);
            }
        }
    }
}

这种模式提供了最终一致性——系统可能暂时不一致,但通过重试自行恢复。

转向异步事件

Spring 的 @EventListener 默认是同步的。事件处理发生在发布事件的同一线程中,发布者等待所有监听器完成。这提供了强有力的保证,但限制了可扩展性。

通过启用异步支持并注解监听器来使监听器异步:

@Configuration
@EnableAsync
public class AsyncConfig {
    @Bean(name = "eventExecutor")
    public Executor eventExecutor() {
        ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
        executor.setCorePoolSize(4);
        executor.setMaxPoolSize(10);
        executor.setQueueCapacity(100);
        executor.setThreadNamePrefix("event-");
        executor.initialize();
        return executor;
    }
}

@Component
public class OrderEventListeners {
    // ... 依赖 ...

    @Async("eventExecutor")
    @EventListener
    @Transactional(propagation = Propagation.REQUIRES_NEW)
    public void handleOrderPlaced(OrderPlacedEvent event) {
        shippingService.createShipment(event.getOrderId());
    }

    @Async("eventExecutor")
    @EventListener
    public void sendConfirmationEmail(OrderPlacedEvent event) {
        emailService.sendOrderConfirmation(event.getOrderId());
    }
}

使用 @AsynccreateOrder() 方法在发布事件后立即返回。监听器在线程池中并发执行。这显著提高了响应时间——订单创建不再等待电子邮件发送或分析跟踪。

但异步事件引入了新的复杂性。当监听器执行时,订单创建事务可能尚未提交。监听器可能尝试从数据库加载订单,但由于事务仍在进行中而找不到它。

Spring 提供了 @TransactionalEventListener 来处理这种情况:

@Component
public class OrderEventListeners {
    @Async("eventExecutor")
    @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
    public void handleOrderPlaced(OrderPlacedEvent event) {
        // 这仅在订单创建事务成功提交后运行
        shippingService.createShipment(event.getOrderId());
    }
}

AFTER_COMMIT 阶段确保监听器仅在发布事务成功提交后运行。如果订单创建失败并回滚,监听器永远不会执行。这可以防止处理实际上不存在的订单的事件。

实现事件存储

随着事件驱动架构的成熟,存储事件变得有价值。事件存储提供了审计日志,支持调试,并支持更复杂的模式,如事件溯源。

创建一个简单的事件存储:

@Entity
@Table(name = "domain_events")
public class StoredEvent {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @Column(nullable = false)
    private String eventId;

    @Column(nullable = false)
    private String eventType;

    @Column(nullable = false, columnDefinition = "TEXT")
    private String payload;

    @Column(nullable = false)
    private Instant occurredAt;

    @Column(nullable = false)
    private Instant storedAt;

    @Column
    private String aggregateId;

    @Column
    private String aggregateType;

    // 构造器、getter、setter
}

@Repository
public interface StoredEventRepository extends JpaRepository<StoredEvent, Long> {
    List<StoredEvent> findByAggregateIdOrderByOccurredAt(String aggregateId);
    List<StoredEvent> findByEventType(String eventType);
}

拦截并存储所有领域事件:

@Component
public class EventStoreListener {
    private final StoredEventRepository repository;
    private final ObjectMapper objectMapper;

    public EventStoreListener(StoredEventRepository repository, ObjectMapper objectMapper) {
        this.repository = repository;
        this.objectMapper = objectMapper;
    }

    @EventListener
    @Order(Ordered.HIGHEST_PRECEDENCE) // 在其他监听器之前存储
    @Transactional(propagation = Propagation.REQUIRES_NEW)
    public void storeEvent(DomainEvent event) {
        try {
            StoredEvent stored = new StoredEvent();
            stored.setEventId(event.getEventId());
            stored.setEventType(event.getClass().getSimpleName());
            stored.setPayload(objectMapper.writeValueAsString(event));
            stored.setOccurredAt(event.getOccurredAt());
            stored.setStoredAt(Instant.now());

            // 如果可用,提取聚合信息
            if (event instanceof OrderPlacedEvent) {
                OrderPlacedEvent orderEvent = (OrderPlacedEvent) event;
                stored.setAggregateId(orderEvent.getOrderId().toString());
                stored.setAggregateType("Order");
            }

            repository.save(stored);
        } catch (JsonProcessingException e) {
            throw new EventStoreException("Failed to serialize event", e);
        }
    }
}

现在,每个领域事件在业务逻辑处理之前都会持久化。您可以通过重放事件来重建系统中发生的情况:

@Service
public class OrderHistoryService {
    private final StoredEventRepository eventRepository;

    public List<OrderEvent> getOrderHistory(Long orderId) {
        List<StoredEvent> events = eventRepository.findByAggregateIdOrderByOccurredAt(
            orderId.toString()
        );

        return events.stream()
            .map(this::deserializeEvent)
            .collect(Collectors.toList());
    }

    private OrderEvent deserializeEvent(StoredEvent stored) {
        // 根据事件类型反序列化
        try {
            Class<?> eventClass = Class.forName("com.example.events." + stored.getEventType());
            return (OrderEvent) objectMapper.readValue(stored.getPayload(), eventClass);
        } catch (Exception e) {
            throw new EventStoreException("Failed to deserialize event", e);
        }
    }
}

这实现了强大的调试能力。当客户报告其订单问题时,您可以准确看到发生了什么事件以及发生的顺序。

Saga 和补偿操作

某些工作流需要跨多个步骤进行协调,其中每个步骤都可能失败。传统方法使用分布式事务,但这些方法扩展性不佳且增加了复杂性。Saga 使用编排事件和补偿操作提供了一种替代方案。

考虑一个更复杂的订单流程,您需要:

  1. 预留库存
  2. 处理支付
  3. 创建发货单

如果在预留库存后支付失败,您需要释放预留。通过补偿事件实现这一点:

public class InventoryReservedEvent extends DomainEvent {
    private final Long orderId;
    private final List<ReservationDetail> reservations;

    // 构造器、getter
}

public class PaymentFailedEvent extends DomainEvent {
    private final Long orderId;
    private final String reason;

    // 构造器、getter
}

@Component
public class InventorySagaHandler {
    private final InventoryService inventoryService;

    @EventListener
    public void handlePaymentFailed(PaymentFailedEvent event) {
        // 补偿操作:释放预留库存
        inventoryService.releaseReservation(event.getOrderId());
    }
}

Saga 通过事件而不是中央协调器进行协调:

@Service
public class OrderSagaService {
    private final ApplicationEventPublisher eventPublisher;
    private final InventoryService inventoryService;
    private final PaymentService paymentService;

    public void processOrder(Order order) {
        // 步骤 1: 预留库存
        List<ReservationDetail> reservations = inventoryService.reserve(order.getItems());
        eventPublisher.publishEvent(new InventoryReservedEvent(order.getId(), reservations));

        try {
            // 步骤 2: 处理支付
            PaymentResult payment = paymentService.processPayment(order);

            if (payment.isSuccessful()) {
                eventPublisher.publishEvent(new PaymentSucceededEvent(order.getId(), payment));
            } else {
                // 触发补偿
                eventPublisher.publishEvent(new PaymentFailedEvent(order.getId(), payment.getReason()));
                throw new PaymentException(payment.getReason());
            }
        } catch (Exception e) {
            // 触发补偿
            eventPublisher.publishEvent(new PaymentFailedEvent(order.getId(), e.getMessage()));
            throw e;
        }
    }
}

这种模式在没有分布式事务的情况下保持了一致性。每个步骤发布记录所发生事件的事件。当发生故障时,补偿事件会触发撤销先前步骤的操作。

桥接到外部消息代理

随着单体应用的增长,您可能希望与外部系统集成或为最终的服务提取做准备。Spring Cloud Stream 提供了对 RabbitMQ 或 Kafka 等消息代理的抽象,同时保持相同的事件驱动模式:

<dependency>
    <groupId>org.springframework.cloud</groupId>
    <artifactId>spring-cloud-stream</artifactId>
</dependency>
<dependency>
    <groupId>org.springframework.cloud</groupId>
    <artifactId>spring-cloud-stream-binder-kafka</artifactId>
</dependency>

application.yml 中配置绑定:

spring:
  cloud:
    stream:
      bindings:
        orderPlaced-out-0:
          destination: order.placed
        orderPlaced-in-0:
          destination: order.placed
          group: order-processors
      kafka:
        binder:
          brokers: localhost:9092

创建内部事件和外部消息之间的桥接:

@Component
public class EventPublisher {
    private final StreamBridge streamBridge;

    public EventPublisher(StreamBridge streamBridge) {
        this.streamBridge = streamBridge;
    }

    @EventListener
    public void publishToExternalBroker(OrderPlacedEvent event) {
        // 将内部事件发布到外部消息代理
        streamBridge.send("orderPlaced-out-0", event);
    }
}

@Component
public class ExternalEventConsumer {
    private final ApplicationEventPublisher eventPublisher;

    public ExternalEventConsumer(ApplicationEventPublisher eventPublisher) {
        this.eventPublisher = eventPublisher;
    }

    @Bean
    public Consumer<OrderPlacedEvent> orderPlaced() {
        return event -> {
            // 将外部事件重新发布为内部事件
            eventPublisher.publishEvent(event);
        };
    }
}

这种模式让您可以选择性地将事件发布到外部,同时将内部事件保留在本地。关键的实时操作使用内部事件以实现低延迟。跨服务通信使用消息代理以实现可靠性和可扩展性。

监控与可观测性

事件驱动系统引入了新的可观测性挑战。理解正在发生的情况需要跨多个异步处理步骤跟踪事件。实施全面的日志记录和指标:

@Aspect
@Component
public class EventMonitoringAspect {
    private static final Logger logger = LoggerFactory.getLogger(EventMonitoringAspect.class);
    private final MeterRegistry meterRegistry;

    public EventMonitoringAspect(MeterRegistry meterRegistry) {
        this.meterRegistry = meterRegistry;
    }

    @Around("@annotation(org.springframework.context.event.EventListener)")
    public Object monitorEventListener(ProceedingJoinPoint joinPoint) throws Throwable {
        String listenerName = joinPoint.getSignature().getName();
        Object[] args = joinPoint.getArgs();
        DomainEvent event = (DomainEvent) args[0];

        Timer.Sample sample = Timer.start(meterRegistry);

        try {
            logger.info("Processing event {} in listener {}", 
                event.getEventId(), listenerName);

            Object result = joinPoint.proceed();

            sample.stop(Timer.builder("event.listener.duration")
                .tag("listener", listenerName)
                .tag("event_type", event.getClass().getSimpleName())
                .tag("status", "success")
                .register(meterRegistry));

            meterRegistry.counter("event.listener.processed",
                "listener", listenerName,
                "event_type", event.getClass().getSimpleName(),
                "status", "success"
            ).increment();

            return result;
        } catch (Exception e) {
            sample.stop(Timer.builder("event.listener.duration")
                .tag("listener", listenerName)
                .tag("event_type", event.getClass().getSimpleName())
                .tag("status", "failure")
                .register(meterRegistry));

            meterRegistry.counter("event.listener.processed",
                "listener", listenerName,
                "event_type", event.getClass().getSimpleName(),
                "status", "failure"
            ).increment();

            logger.error("Error processing event {} in listener {}", 
                event.getEventId(), listenerName, e);

            throw e;
        }
    }
}

这个切面自动跟踪每个事件监听器的执行时间和成功率。结合 Prometheus 和 Grafana 等工具,您可以可视化事件处理模式并识别瓶颈。

添加关联 ID 以跟踪系统中的事件:

public abstract class DomainEvent {
    private final Instant occurredAt;
    private final String eventId;
    private final String correlationId;

    protected DomainEvent(String correlationId) {
        this.occurredAt = Instant.now();
        this.eventId = UUID.randomUUID().toString();
        this.correlationId = correlationId != null ? correlationId : UUID.randomUUID().toString();
    }

    // Getters
}

通过事件链传播关联 ID:

@EventListener
public void handleOrderPlaced(OrderPlacedEvent event) {
    MDC.put("correlationId", event.getCorrelationId());

    try {
        // 执行工作

        // 发布具有相同关联 ID 的后续事件
        eventPublisher.publishEvent(new ShipmentCreatedEvent(
            event.getOrderId(),
            event.getCorrelationId()
        ));
    } finally {
        MDC.clear();
    }
}

现在,与单个订单流相关的所有日志消息共享一个关联 ID,使得跨多个异步操作跟踪整个工作流变得微不足道。

测试事件驱动代码

事件驱动架构需要不同的测试策略。传统的单元测试适用于单个监听器,但集成测试对于验证事件流变得更加重要:

@SpringBootTest
@TestConfiguration
public class OrderEventIntegrationTest {
    @Autowired
    private ApplicationEventPublisher eventPublisher;

    @Autowired
    private ShippingService shippingService;

    @Autowired
    private EmailService emailService;

    @Test
    public void shouldProcessOrderPlacedEventCompletely() throws Exception {
        // 给定
        Order order = createTestOrder();
        OrderPlacedEvent event = new OrderPlacedEvent(order);

        // 当
        eventPublisher.publishEvent(event);

        // 等待异步处理
        await().atMost(5, TimeUnit.SECONDS).untilAsserted(() -> {
            // 然后
            verify(shippingService).createShipment(order.getId());
            verify(emailService).sendOrderConfirmation(order.getId());
        });
    }
}

对于单元测试,注入一个间谍事件发布器以验证事件是否正确发布:

@ExtendWith(MockitoExtension.class)
public class OrderServiceTest {
    @Mock
    private OrderRepository orderRepository;

    @Mock
    private InventoryService inventoryService;

    @Mock
    private PaymentService paymentService;

    @Spy
    private ApplicationEventPublisher eventPublisher = new SimpleApplicationEventPublisher();

    @InjectMocks
    private OrderService orderService;

    @Test
    public void shouldPublishOrderPlacedEventAfterCreatingOrder() {
        // 给定
        CreateOrderRequest request = createValidRequest();

        when(inventoryService.checkAvailability(any(), anyInt())).thenReturn(true);
        when(paymentService.processPayment(any(), any(), any()))
            .thenReturn(PaymentResult.successful("txn-123"));
        when(orderRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));

        // 当
        orderService.createOrder(request);

        // 然后
        verify(eventPublisher).publishEvent(argThat(event -> 
            event instanceof OrderPlacedEvent
        ));
    }
}

迁移之旅

将单体应用重构为使用事件驱动架构并非全有或全无的命题。从一个工作流开始——通常是造成最多痛苦的那个。识别可以事件驱动的直接服务调用,并逐步引入事件。

从同步事件开始,以最小化行为变更。一旦事件正确流动,为非关键监听器切换到异步处理。当您需要审计跟踪或调试能力时,添加事件存储。仅当您需要跨服务通信或准备提取微服务时,才集成外部消息代理。

目标不是实现完美的事件驱动架构。而是减少耦合、提高可测试性,并在组件之间创建更清晰的边界。即使是部分采用也能提供价值——具有一些事件驱动模式的单体应用比完全没有的模式更易于维护。

这种渐进式方法使您能够持续交付价值,而不是投入一个需要数月时间、直到完全结束时才能交付任何成果的重构项目。您能够了解在特定领域和团队中哪些方法有效,根据实际经验而非理论理想来调整实施策略。

有用的资源


【注】本文译自: Event-Driven Architecture in Monoliths: Incremental Refactoring for Java Apps – Java Code Geeks