JavaEE-现代应用架构指南-全-
JavaEE 现代应用架构指南(全)
原文:
zh.annas-archive.org/md5/6c63b1219ff03677be8e0c6a8e668426译者:飞龙
前言
Java EE 8 带来了大量特性,主要针对如微服务、现代化的安全 API 和云部署等新架构。本书将教你使用 Java EE 8 设计和开发面向业务的应用程序。它展示了如何构建系统和应用程序,以及如何在 Java EE 8 时代实现设计模式和领域驱动设计方面。你将了解 Java EE 应用程序背后的概念和原则以及它们如何影响通信、持久性、技术和跨领域关注以及异步行为。
本书专注于解决企业世界中的业务问题和满足客户需求。它涵盖了如何通过合理的技术选择创建企业应用程序,避免盲目崇拜和过度设计。本书展示的方面不仅展示了如何实现某种解决方案,还解释了其动机和理由。
在本书的帮助下,你将理解现代 Java EE 的原则以及如何实现有效的架构。你将获得在自动化、持续交付和云平台时代设计企业软件的知识。你还将了解最先进的企业 Java 技术背后的推理和动机,这些技术专注于业务。
本书涵盖的内容
第一章,简介,介绍了 Java EE 企业应用程序以及为什么 Java EE 在现代系统中(仍然)相关。
第二章,设计和结构化 Java 企业应用程序,通过示例展示了如何设计企业应用程序的结构,同时考虑到业务用例。
第三章,实现现代 Java 企业应用程序,涵盖了如何实现现代 Java EE 应用程序以及为什么这项技术选择至今仍然相关。
第四章,轻量级 Java EE,教你如何通过小型足迹和最小第三方依赖来实现轻量级 Java EE 应用程序。
第五章,使用 Java EE 的容器和云环境,解释了如何利用容器和现代环境的优势,如何集成企业应用程序,以及这一运动如何鼓励高效的开发工作流程。
第六章,应用程序开发工作流程,涵盖了快速开发管道和高软件质量的关键点,从持续交付到自动化测试和 DevOps。
第七章,测试,正如其名所示,涵盖了测试的主题,这有助于你通过合理的覆盖率确保软件开发中的高质量自动化测试。
第八章,微服务和系统架构,展示了在项目和企业环境之后如何设计系统,如何构建应用程序及其接口,以及何时微服务架构是有意义的。
第九章,安全,涵盖了如何在当今环境中实现和集成安全关注点。
第十章,监控、性能和日志,解释了为什么传统的日志是有害的,如何调查性能问题,以及如何监控应用程序的业务和技术方面。
附录,结论,回顾和总结了本书的内容,包括提供建议和激励。
你需要这本书的什么
要执行和执行本书中给出的代码示例,你需要在系统中配置以下工具:
-
NetBeans、IntelliJ 或 Eclipse IDE
-
GlassFish 服务器
-
Apache Maven
-
Docker
-
Jenkins
-
Gradle
这本书面向谁
本书是为有经验的 Java EE 开发者而写的,他们渴望成为企业级应用程序的架构师,或者为希望利用 Java EE 创建有效应用程序蓝图的应用程序架构师。
惯例
在本书中,你会发现许多文本样式,用于区分不同类型的信息。以下是一些这些样式的示例及其含义的解释。
文本中的代码单词、数据库表名、文件夹名、文件名、文件扩展名、路径名、虚拟 URL、用户输入和 Twitter 昵称如下所示:“EJB 使用@Startup注解。”
代码块应如下设置:
@PreDestroy
public void closeClient() {
client.close();
}
当我们希望将你的注意力引到代码块的一个特定部分时,相关的行或项目将以粗体显示:
private Client client;
private List<WebTarget> targets;
@Resource
ManagedExecutorService mes;
为了增加简洁性和可读性,一些代码示例被缩短到其本质。Java import语句仅包括新类型和与示例无关的代码部分,使用省略号(...)省略。
任何命令行输入或输出都应如下编写:
mvn -v
新术语和重要词汇将以粗体显示。
读者反馈
我们读者的反馈总是受欢迎的。告诉我们你对这本书的看法——你喜欢什么或不喜欢什么。读者反馈对我们来说很重要,因为它帮助我们开发出你真正能从中获得最大收益的标题。
要向我们发送一般反馈,请简单地发送电子邮件至feedback@packtpub.com,并在邮件主题中提及书籍的标题。
如果你在一个你擅长的主题上,并且你对撰写或为书籍做出贡献感兴趣,请参阅我们的作者指南www.packtpub.com/authors。
客户支持
现在你已经是 Packt 图书的骄傲拥有者,我们有许多事情可以帮助你从购买中获得最大收益。
下载示例代码
您可以从您的账户下载此书的示例代码文件。www.packtpub.com。如果您在其他地方购买了此书,您可以访问www.packtpub.com/support,并注册以将文件直接通过电子邮件发送给您。您可以通过以下步骤下载代码文件:
-
使用您的电子邮件地址和密码登录或注册我们的网站。
-
将鼠标指针悬停在顶部的 SUPPORT 标签上。
-
点击代码下载和勘误。
-
在搜索框中输入书籍名称。
-
选择您想要下载代码文件的书籍。
-
从下拉菜单中选择您购买此书的来源。
-
点击代码下载。
文件下载完成后,请确保您使用最新版本解压缩或提取文件夹:
-
WinRAR / 7-Zip for Windows
-
Zipeg / iZip / UnRarX for Mac
-
7-Zip / PeaZip for Linux
该书的代码包也托管在 GitHub 上,网址为github.com/PacktPublishing/Architecting-Modern-Java-EE-Applications。我们还有其他来自我们丰富图书和视频目录的代码包,可在github.com/PacktPublishing/找到。查看它们吧!
勘误表
尽管我们已经尽一切努力确保我们内容的准确性,但错误仍然会发生。如果您在我们的书中发现错误——可能是文本或代码中的错误——如果您能向我们报告,我们将不胜感激。这样做可以节省其他读者的挫败感,并帮助我们改进此书的后续版本。如果您发现任何勘误,请通过访问www.packtpub.com/submit-errata,选择您的书籍,点击勘误提交表单链接,并输入您的勘误详情来报告它们。一旦您的勘误得到验证,您的提交将被接受,勘误将被上传到我们的网站或添加到该标题的勘误表部分。
要查看之前提交的勘误表,请访问www.packtpub.com/books/content/support,并在搜索字段中输入书籍名称。所需信息将出现在勘误表部分。
盗版
互联网上对版权材料的盗版是一个跨所有媒体的持续问题。在 Packt,我们非常重视我们版权和许可证的保护。如果您在互联网上发现我们作品的任何非法副本,请立即提供位置地址或网站名称,以便我们可以寻求补救措施。
请通过copyright@packtpub.com与我们联系,并提供涉嫌盗版材料的链接。
我们感谢您在保护我们作者和我们为您提供有价值内容的能力方面的帮助。
问答
如果您在这本书的任何方面遇到问题,您可以联系我们的questions@packtpub.com,我们将尽力解决问题。
第一章:引言
与过去相比,我们看到企业软件中有许多新的需求。仅仅开发一些软件并将其部署到应用服务器上已经不再足够。或者也许从来都不够。
企业系统的新需求
世界比以往任何时候都发展得更快。快速行动是当今 IT 公司最重要的标准之一。我们看到能够以高速度适应现实世界和客户需求的公司。新功能的上市时间已经从数年和数月缩短到数周甚至更少。为了应对这种情况,公司不仅需要引入新技术或向业务问题投入更多资金,还需要重新思考和重构他们在 IT 核心的运营方式。
在这种情况下,快速行动意味着什么?这包括哪些方面?以及哪些方法和技术支持这一点?
快速行动就是快速适应市场和客户的需求。如果需要或看起来有希望的新功能,从最初的想法到用户手中的功能需要多长时间?如果需要新的基础设施,从决策到运行硬件需要多长时间?而且,不要忘记,如果以如此快的速度开发软件,是否有自动质量控制来确保一切按预期工作,不会破坏现有功能?
在软件开发中,这些问题大多导致持续交付和自动化。正在开发的软件需要以自动化、快速、可靠和可重复的方式进行构建、测试和发布。可靠的自动化流程不仅能够加快周转速度,而且最终能够提高质量。自动化质量控制,如软件测试,是流程的一部分。在现代软件开发中,持续交付、自动化和适当的测试是最重要的原则之一。
传统上,基础设施在大多数公司中都是一个巨大的瓶颈。较小的公司常常在有限的预算下难以提供新的基础设施。较大的公司大多无法实施快速且高效的过程。对于大型企业来说,在大多数情况下,问题不在于预算,而在于过程的实施。由于审批和过于复杂的流程,技术上一分钟就能完成的事情,却可能需要等待数天甚至数周才能完成新的基础设施,这种情况并不少见。
因此,应用程序基础设施及其设计是一个重要的方面。第五章,使用 Java EE 的容器和云环境,将向您展示现代云环境的话题。实际上,我们将看到这更多不是关于是否使用云服务提供商的问题。使用本地硬件,可以实施快速高效的过程。更重要的是,这是一个关于是否正确实施过程,使用合适的技术的问题。
现代基础设施需要以自动化、快速、可重复和可靠的方式在几分钟内设置完成。它应该能够轻松适应变化的需求。为了满足这一标准,基础设施应该被定义为代码,无论是通过程序脚本还是声明性描述符。我们将看到基础设施即代码如何影响软件开发工作流程以及哪些技术支持它。
这些需求将影响团队的工作方式。对于开发团队来说,仅仅进行开发并让运维团队处理软件运行和面对生产中的潜在问题已经不再足够。一旦生产中出现关键错误,这种做法总是会导致紧张和互相指责。相反,共同的目标应该是交付满足特定目的的软件。通过将所需的基础设施和配置定义为代码,开发和运维团队将自然地协同工作。这种DevOps运动,即开发和运维的结合,旨在使整个软件团队承担起责任。所有参与人员都对客户能够使用适当的软件负责。这更多的是一个组织挑战,而不是技术挑战。
在技术方面,持续交付以及12 因素和云原生等热门词汇试图满足这些需求。12 因素和云原生方法描述了现代企业应用应该如何开发。它们不仅定义了开发过程的要求,还定义了应用程序运行的方式。我们将在本书的后续部分探讨这些方法、现代云环境以及 Java EE 如何支持我们。
现代企业系统实现方式
现在我们将探讨企业软件项目是如何开发的。
遵循满足现实世界客户需求的方法,我们将面临我们想要开发的应用程序目的的问题。在立即进入技术细节之前,企业系统需要明确其动机和目的。否则,软件只是为了开发而开发。遗憾的是,这种情况太过常见。通过关注业务逻辑和领域驱动设计的原则,正如埃里克·埃文斯在书中精彩描述的那样,我们将确保我们构建的软件将满足业务需求。
只有当利益相关者对应用程序的目的和责任有明确的认识后,我们才能专注于技术方面。团队应优先考虑那些不仅能够适当地实现业务用例,还能减少工作量和管理开销的技术。开发者应能够专注于业务,而不是框架和技术。好的框架支持以精简的方式解决业务问题,并且不会分散开发者的注意力。
选择的技术应尽可能支持高效的开发工作流程。这不仅包括自动化和快速的开发周期,还包括能够适应现代基础设施,例如 Linux 容器。在第四章轻量级 Java EE 和第五章 Java EE 的容器和云环境中,我们将更深入地探讨现代环境的核心要素以及 Java EE 如何支持这些环境。
Java EE 在现代系统中的相关性
既然这是本书的主题,并且与企业系统相关,让我们来谈谈 Java EE。
Java EE 和 J2EE 被广泛使用,尤其是在大型公司中。其优势之一始终是平台由一系列标准组成,这些标准保证了与旧版本的向后兼容性。即使是旧的 J2EE 应用程序也保证在未来仍能正常工作。这对那些长期规划的公司来说一直是一个巨大的好处。针对 Java EE API 开发的应用程序可以在所有 Java EE 应用服务器上运行。供应商无关的应用程序使公司能够构建未来兼容的软件,而不会将其锁定在特定解决方案中。这最终证明是一个明智的决定,最终导致了企业行业的一种心态,即标准,或大家普遍认同的事实标准,可以改善整体状况。
与 J2EE 世界相比,Java EE 发生了很大变化。编程模型完全不同,更加精简,且更高效。当名称从 J2EE 更改为 Java EE 5 时,这种变化尤为显著,尤其是在 EE 6 之后。在第三章实现现代 Java 企业应用中,我们将探讨现代开发 Java 企业应用的方法。我们将看到正在使用的架构方法和编程模型,以及平台如何比以往更多地提高开发效率。希望这样可以使大家明白为什么 Java EE 现在为企业应用开发提供了一种现代解决方案。
目前,将这一信息传达给行业实际上更多的是一个营销和政治挑战,而不是技术挑战。我们仍然看到许多开发者和架构师仍然认为 Java EE 是 J2EE 时代的笨重、重量级企业解决方案,它需要大量的时间、精力和 XML。企业 JavaBeans(EJB)以及应用程序服务器,由于过去的经历,特别有坏名声。这就是为什么许多工程师对该技术有偏见。与其他企业解决方案相比,Java EE 从来没有看到很多针对开发者的营销。
在第四章《轻量级 Java EE》中,我们将看到为什么现代 Java EE 实际上是最轻量级的企业解决方案之一。我们将定义“轻量级”这一术语,并了解为什么 Java EE 平台比以往任何时候都更加相关,尤其是在现代云和容器环境中。特定技术给 IT 行业留下的印象对其成功至关重要。我希望这一章能对这个话题提供一些启示。
公司通常选择 Java EE 主要是因为其可靠性和向后兼容性。我个人偏爱 Java EE,因为它具有高效性和易用性。在第四章《轻量级 Java EE》和第五章《Java EE 的容器和云环境》中,我们将更详细地介绍这一点。在这本书中,我想向读者展示为什么 Java EE 是一个非常适合当今企业需求的解决方案。我还会展示技术和标准,虽然不是每个细节,但更多的是它们是如何相互整合的。我相信,关注整合部分有助于更好地理解如何有效地构建企业应用程序。
Java EE 8 更新和路线图
让我们非常简要地概述一下 Java EE 8 版本中发生的事情。这个版本的目标是进一步提升开发者的体验,进一步简化 API 的使用,并使 Java EE 能够满足云环境中的新需求。我们看到了两个全新的 JSR,JSON-B(Java API for JSON Binding)和安全,以及现有标准的改进。特别是,引入 JSON-B 简化了以供应商无关的方式集成 JSON HTTP API。
Java EE 的方向是改善现代环境和情况下企业应用程序的开发。结果是,现代环境不仅与 Java EE 兼容,而且鼓励了多年来一直是平台一部分的方法。例如,API 与实现的分离,或应用程序服务器的监控。
在长期路线图中,对现代监控、健康检查和弹性的支持更好。目前,这些方面必须通过几行代码进行集成,正如我们将在后续章节中看到的那样。长期目标是使这种集成更加简单直接。Java EE 旨在让开发者专注于他们应该关注的事情——解决业务问题。
Java 社区进程
使 Java EE 平台独特的是其规范的过程。Java EE 的标准是作为Java 社区进程(JCP)的一部分开发的。JCP 是积极鼓励参与定义标准的行业的典范,不仅限于少数参与工程师,而是任何对该技术感兴趣的人。该平台由Java 规范请求(JSR)形式的标准组成。这些 JSR 不仅与 Java 和 Java EE 相关,还与构建在其之上的技术(如 Spring 框架)相关。最终,这些其他技术的实际经验再次帮助塑造新的 JSR。
在应用开发过程中,尤其是在遇到潜在问题时,从 JSR 中产生的书面规范极为有益。支持企业平台的供应商必须按照这些标准中指定的方式提供实现。换句话说,规范文档既向供应商也向开发者说明了技术将如何工作。如果某些功能未满足,供应商必须在其实现中修复这些问题。这也意味着,理论上,开发者只需学习和了解这些技术,无需了解供应商特定的细节。
每个开发者都可以参与 Java 社区进程,帮助塑造 Java 和 Java EE 的未来。定义具体标准的专家小组欢迎任何对该主题感兴趣的人提供建设性的反馈,即使他们不是 JCP 的活跃成员。除此之外,你甚至可以在标准发布之前一睹其下一版本的真容。这两点对架构师和公司来说都非常有趣。不仅可以看到方向将走向何方,还有机会参与并产生影响。
这些动机也是我个人选择专注于 Java EE 的两个原因。我拥有使用 Spring 框架的企业级开发背景。除了这两种技术在编程模型方面非常相似之外,我还特别重视 CDI 标准的强大功能以及能够在平台上无缝使用所有技术的可能性。我开始研究企业平台中具体 JSR,并开始对当时标准化的功能提供反馈和贡献。在撰写本书时,我是两个专家组的成员,分别是 JAX-RS 2.1 和 JSON-P 1.1。帮助定义这些标准极大地增强了我对企业系统的了解。你自然有义务深入研究你帮助标准化的特定技术的主题、动机和解决方案。当然,知道你在 IT 行业的工作中有助于制定标准,这多少是令人满意的。我只能鼓励开发者参与 JCP,关注当前正在开发的内容,并为专家组提供贡献和反馈。
在本书中可以期待什么
我决定写这本书,关于我在过去从事各种 Java 企业系统工作时学到的东西。我的动机是向你展示现代 Java EE 方法的样子。这当然首先是为了开发企业应用程序本身和现代编程模型。我试图建立一个印象,即 Java EE 在 EE 8 时代的应用以及平台的优势所在。有新的设计模式和范例正在被使用,这些模式是从现代框架方法中出现的。如果你熟悉 J2EE 世界,你可能会希望看到现代 Java EE 的优势。我试图展示哪些旧范式、约束和考虑因素使得 J2EE 有时在开发者中不受欢迎,现在已经不再适用并且可以被摒弃。除此之外,这本书也是尝试传播一些热情,并解释为什么我确信 Java 企业级能够很好地实现企业应用程序。
话虽如此,你,作为读者,不需要对 J2EE 世界及其模式和最佳实践有先前的了解。特别是,编程模型如此不同,以至于我相信从零开始展示当今的方法是有意义的。
如果你已经构建并设计了 J2EE 应用程序,这很好。你将看到过去 J2EE 设计模式所面临的挑战,尤其是在现代世界,我们的领域可以首先关注业务需求,而不是实现它的技术。这在我们遵循领域驱动设计的方法时尤其如此。你会注意到过去 J2EE 系统中许多繁琐和痛苦的问题如何在现代 Java EE 中被消除。Java EE 平台的简洁和强大可能会激发你重新思考我们迄今为止所采取的一些方法。也许你可以尝试退一步,以一个新鲜、无偏见的角度看待这项技术。
这本书是为软件工程师、开发者和架构师而写的,他们设计和构建企业应用程序。在书中,我主要会使用开发者或工程师这个术语。话虽如此,我坚信,架构师也应该时不时地,越多越好,接触源代码,并亲自动手与技术打交道。这不仅是为了支持团队中的其他开发者,而且对于他们自己获得更多实际经验也非常重要。同样,所有开发者都应该至少对系统的架构和架构选择的原因有一个基本了解。再次强调,这种相互理解越好,项目中的沟通和开发功能就会越好。
现代企业应用程序开发涉及的不仅仅是开发本身。正如我们所看到的,企业应用程序的新需求,工程师们关心开发工作流程、云环境、容器和容器编排框架。我们将探讨 Java 企业版是否以及如何融入这个世界,以及有哪些方法和最佳实践支持这一点。这将涉及持续交付和自动化测试的话题,为什么它们如此重要,以及它们如何与 Java EE 集成。我们还将涵盖容器技术,如 Docker,以及编排框架,如 Kubernetes。在今天的商业世界中,展示像 Java EE 这样的技术如何支持这些领域是很重要的。
微服务架构是一个大话题,也是今天的一个热点。我们将探讨微服务是什么,以及它们是否以及如何可以用 Java EE 实现。关于安全、日志记录、性能和监控的话题也将在本书的后面部分进行讨论。我会指出,在今天的企业软件世界中,架构师应该知道和注意什么。所使用的技术选择,尤其是当涉及到支持应用程序的现代解决方案时;例如,在 12 因子或云原生应用程序的领域,作为今天可能被选择的内容的例子。然而,更重要的是理解这些技术背后的概念和动机。使用的技术每天都在变化,而计算机科学的原则和概念则存在得更久。
对于本书中涉及的所有主题,我的方法是首先展示解决方案背后的动机和推理,然后是它们如何在 Java EE 中应用和实现。我相信,仅仅教授某种技术当然可以帮助开发者完成日常工作,但他们只有在完全理解其背后的动机之后,才会完全接受这种解决方案。这就是为什么我还会从企业应用的一般动机开始。
Java EE 包含了大量的功能,如果你回顾过去,会发现更多。本书的目标并不是要成为一部完整的 Java EE 参考手册。相反,它旨在提供实际经验以及建议,可以称之为最佳实践,通过实用解决方案解决典型场景。现在,请放松身体,享受穿越现代企业软件世界的旅程。
第二章:设计和构建 Java 企业应用程序
每个软件都是以某种方式设计的。设计包括系统的架构、项目的结构以及代码的结构和质量。它可以很好地传达意图,也可以使意图变得模糊。工程师在实现之前需要设计企业应用程序或系统。为了做到这一点,软件的目的和动机需要明确。
本章将涵盖:
-
开发软件时应关注哪些方面
-
项目构建结构和 Java EE 构建系统
-
如何构建企业项目模块
-
如何实现模块包结构
企业应用程序的目的
在每一个行动背后,无论是日常生活、大型组织还是软件项目,都应该有一个原因。我们人类需要知道我们为什么做事情。在企业软件开发中,这一点并无不同。
当我们构建软件应用程序时,首先应该问的问题是 为什么? 为什么需要这个软件?为什么有必要花费时间和精力去开发解决方案?以及为什么公司应该关心自己开发这个解决方案?
换句话说,应用程序的目的是什么?这个软件试图解决什么问题?我们希望应用程序实现重要的业务流程吗?它会产生收入吗?它将通过销售产品、通过营销、支持客户或业务流程直接产生收入,还是通过其他方式支持客户、员工或业务流程?
这些以及其他问题针对应用程序的业务目标。一般来说,在投入时间和精力之前,每件软件都需要在整体图中找到其合理性。
最明显的合法化是实施必要的业务用例。这些用例为整体业务带来一定的价值,并将迟早实现功能并产生收入。最终,软件应该尽可能好地实现业务用例的目标。
开发者应该关注什么
因此,软件开发人员和项目经理首先应该关注满足业务需求和实现用例。
这听起来显然,但企业项目的焦点往往过于偏离到其他关注点。开发者将精力投入到实现细节或对解决实际问题帮助甚微的功能上。我们过去见过多少日志实现、自制的企业框架或过度设计的抽象级别?
非功能性需求、软件质量以及所谓的横切关注点实际上是软件开发的重要方面。但所有工程努力的首要和主要焦点应该是满足商业需求并开发具有实际目的的软件。
满足客户需求
我们有以下问题:
-
应用程序的商业目的是什么?
-
用户最关心的最重要的功能是什么?
-
哪些方面会产生收入?
这些问题的答案应该为利益相关者所知晓。如果不是这样,那么正确的做法是退一步,审视整个软件领域的全景,并重新考虑软件存在的合理性。并非所有情况下的动机都是纯粹的商业驱动。实际上,有很多情况下我们会实施那些不直接产生收入但间接支持他人的解决方案。这些情况无疑是必要的,我们将在第八章“微服务与系统架构”中涵盖这些情况以及如何构建合理的系统景观的一般主题。
除了这些支持性软件系统之外,我们关注商业方面。牢记这个主要目标,首先要解决的问题是如何对商业用例进行建模并将它们转化为软件。只有在之后,才会使用某些技术来实现这些用例。
这些优先级也将反映客户需求。应用程序的利益相关者关心的是能够实现其目的的软件。
软件工程师往往有不同的看法。他们关心实现细节和解决方案的优雅性。工程师们通常对某些技术充满热情,花费大量时间和精力选择正确的解决方案以及有效地实施它们。这包括许多技术横切关注点,如日志记录,以及所谓的过度设计,这在商业领域并非强制性的。拥抱软件工艺当然有其重要性,并且对于编写更好的软件是必不可少的,但很多时候它与客户的动机是正交的。在花费时间和精力处理实现细节之前,工程师们首先应该了解客户的需求。
项目时间表要求是另一个需要考虑的方面。软件团队会权衡商业用例与技术解决方案的质量。他们倾向于推迟必要的软件测试或质量措施以满足截止日期。用于实现商业应用的技术应该支持有效和务实的发展。
当从付费客户或时间有限且预算有限的经理的角度看待企业世界时,软件工程师可能会理解他们的优先级。首先关注收入生成用例是强制性的。客户和经理将这些技术必要性视为一种必要的恶。
本书剩余部分将向您展示如何使用 Java EE 满足并平衡这两个动机。
外部企业项目结构
在心中牢记商业用例的目标,让我们将焦点稍微更多地转向现实世界的企业项目。在后面的章节中,我们将看到有哪些方法可以帮助我们在架构中以适当的方式反映业务领域。
商业和团队结构
软件项目通常由工程师团队、软件开发人员或架构师开发。为了简单起见,我们将它们称为开发者。软件开发人员、架构师、测试人员以及所有类型的工程师应该时不时地编写代码。
然而,在大多数情况下,我们有多个人同时在一个软件项目上工作。这已经要求我们考虑一些事情,主要是沟通和组织开销。当我们观察由多个团队在多个项目(甚至暂时是同一项目)上工作的组织结构时,我们会面临更多的挑战。
康威定律声称:
设计系统的组织 [...] 受限于产生与这些组织的沟通结构相复制的设计。
- 梅尔文·康威
话虽如此,团队的组织方式和相互沟通的方式不可避免地会渗透到软件设计中。在构建软件项目时,必须考虑开发者的组织结构及其有效的沟通结构。在第八章微服务和系统架构中,我们将详细探讨如何构建多个分布式系统以及更具体的微服务。
即使在一个由少数开发者组成的团队拥有的单一项目中,也可能会同时开发多个特性和错误修复。这一事实影响了我们规划迭代、组织、整合源代码以及构建和部署可运行软件的方式。特别是第六章,应用开发工作流程和第七章,测试将涵盖这一主题。
软件项目内容
企业软件项目包括构建和交付应用程序所需的几个工件。让我们更仔细地看看它们。
应用源代码
首先,所有企业应用程序,可能像任何应用程序一样,都是用源代码编写的。源代码可以说是我们软件项目最重要的部分。它代表了应用程序及其核心功能,可以看作是软件行为的单一真相来源。
项目的源代码被分为运行在生产环境中的代码和用于验证应用程序行为的测试代码。测试代码和生产代码的技术以及质量要求可能会有所不同。在第七章“测试”中,我们将深入探讨软件测试的技术和结构。除了那一章之外,本书的重点在于生产代码,即已发布并处理业务逻辑的代码。
软件结构
软件项目以特定的结构组织源代码。在 Java 项目中,我们可以将组件和职责分别聚类到 Java 包和项目模块中:

结构化这些组件显然更多的是一种架构必要性而非技术必要性。任意打包的代码在技术上也能同样良好运行。然而,这种结构有助于工程师理解软件及其职责。通过聚类实现连贯特性的软件组件,我们增加了内聚性,并实现了源代码的更好组织。
本章节和下一章将讨论由埃里克·埃文斯所著书籍中描述的领域驱动设计(Domain-Driven Design)的好处,以及如何在业务驱动包中组织代码的原因和方法。目前,我们先记录下来,我们将形成逻辑特性的连贯组件分组到逻辑包或项目模块中。
Java SE 9 提供了将模块作为 Java 9 模块发布的能力。这些模块本质上类似于具有声明其他模块依赖和用法限制能力的 JAR 文件。由于本书针对的是 Java EE 8,并且 Java 9 模块在实际项目中的应用尚未广泛推广,我们将仅涵盖 Java 包和项目模块。
进一步分解软件项目的结构,软件组件的下一个更小的单元是 Java 类。类及其职责封装了领域中的单一功能。它们理想上是松散耦合的,并显示出高度的内聚性。
关于清洁代码实践和如何在源代码中表示功能已经有很多论述。例如,罗伯特·C·马丁所著的《Clean Code》一书,解释了诸如适当命名或重构等方法,这些方法有助于在包、类和方法中实现精心制作的源代码。
版本控制系统
由于大多数软件项目需要协调多个开发者同时进行的代码更改,源代码需要置于版本控制之下。版本控制系统(VCS)已经确立为可靠协调、跟踪和了解软件系统变更的必要手段。
版本控制系统的选择有很多,例如 Git、Subversion、Mercurial 或 CVS。在过去的几年里,分布式修订控制系统,尤其是Git,已被广泛接受为最先进的工具。它们使用所谓的哈希树或默克尔树来存储和解决单个提交,这使得高效的差异和合并成为可能。
分布式 VCS 使开发人员能够以分布式的方式与项目仓库一起工作,而无需始终需要网络连接。每个工作站都有自己的仓库,它包含完整的历史记录,并最终与中央项目仓库同步。
就本书撰写时的情况来看,绝大多数软件项目使用 Git 作为版本控制系统。
二进制
版本控制系统(VCS)的项目仓库应仅包含由开发人员产生和维护的源代码。当然,企业应用程序必须以某种二进制工件的形式部署。只有这些可运输的二进制文件才能作为可执行软件运行。二进制文件是开发和构建过程的最终成果。
在 Java 世界中,这意味着 Java 源代码被编译成可移植的字节码,通常分别打包为Web 应用程序存档(WAR)或Java 存档(JAR)。WAR 或 JAR 文件包含了发送应用程序、框架依赖项或库所需的所有类和文件。Java 虚拟机(JVM)最终执行字节码,以及与之相关的我们的业务功能。
在企业项目中,部署工件,即 WAR 或 JAR 文件,要么部署到应用程序容器中,要么自身携带容器。需要应用程序容器,因为除了它们提炼的业务逻辑外,企业应用程序还必须集成其他关注点,例如应用程序生命周期或各种形式的通信。例如,一个实现了某些逻辑但无法通过 HTTP 通信访问的 Web 应用程序几乎没有价值。在 Java 企业版中,应用程序容器负责提供这种集成。打包的应用程序包含提炼的业务逻辑,并部署到服务器上,服务器负责处理其余部分。
近年来,出现了更多像 Docker 这样的 Linux 容器技术。这进一步推动了可运输二进制的思想。然后,二进制不仅包含打包的 Java 应用程序,还包含运行应用程序所需的所有组件。例如,这包括应用程序服务器、Java 虚拟机和所需的操作系统二进制文件。我们将在第四章,“轻量级 Java EE”中讨论发送和部署企业应用程序的主题,特别是关于容器技术。
二进制文件作为软件构建过程的一部分生成。它使得从存储库的源代码中可靠地重新创建所有二进制文件成为可能。因此,二进制文件不应被纳入版本控制之下。对于生成的源代码也是如此。例如,在以前,用于 SOAP 通信所需的 JAX-WS 类通常是从描述符文件生成的。生成的源代码在构建过程中创建,也不应被纳入版本控制之下。其理念是仅在存储库中保留提炼后的源代码,而不保留可以从中派生的任何工件。
构建系统
构建过程首先负责将 Java 软件项目的源代码编译成字节码。每次对项目进行更改时都会发生这种情况。所有现代构建系统都提供了有用的约定,以最小化所需的配置。
在企业领域,拥有所有不同的框架和库,组织并定义所有对 API 和实现的依赖关系是一个重要的步骤。如Apache Maven或Gradle之类的构建工具通过包括强大的依赖关系解析机制来支持开发者。构建工具添加了编译或运行应用程序所需的相应版本的依赖项。这简化了在多个开发者之间设置项目的过程。它还使得构建可重复。
将编译后的类及其依赖项打包成部署工件也是构建过程的一部分。根据所使用的技术,工件被打包为 WAR 或 JAR 文件。第四章,轻量级 Java EE 将讨论打包 Java 企业应用的不同方式及其优缺点。
主题,Gradle 和 Apache Maven,将更深入地讨论这两个主要构建系统的实现和差异。
单模块与多模块项目
如前所述,我们可以分别组织应用程序的源代码为 Java 包和项目模块。项目模块将相关功能组合成可单独构建的子项目。它们通常由构建系统指定。
起初,将项目模块拆分背后的动机是相当可以理解的。将 Java 代码和包分组到相关模块中,为开发者提供了一个更清晰的视图,使得结构更佳,并增加了凝聚力。
多模块的另一个原因是构建时间性能。我们的软件项目越复杂,编译和打包成工件所需的时间就越长。开发者通常一次只接触项目中的少数几个位置。因此,理念是不总是重新构建整个项目,而只重新构建应用所需更改的必要模块。这是 Gradle 构建系统的一个宣传优势,通过只重新构建已更改的部分来节省时间。
这种做法的另一个论点是可以在多个项目中重用某些子模块。通过将子项目构建成自给自足的工件,我们可能可以将子工件包含到另一个软件项目中。例如,一种常见的做法是设计一个模型模块,它包含业务域的实体,通常作为独立的普通 Java 对象(POJOs)。这个模型会被打包成 JAR 文件,并在其他企业项目中作为依赖项重用。
然而,这种方法也有一些缺点,或者更确切地说,是错觉。
重复使用的错觉
我们必须提醒自己,软件项目是由开发团队构建的,因此项目结构将因此遵循他们的沟通结构。在多个项目中重用某些模块需要相当多的协调。
技术依赖
任何要重用的项目模块都必须满足特定标准。首先,共享模块的技术必须与目标项目相匹配。这听起来很显然,但在实现细节上有很多影响。特别是使用的库和框架不可避免地会导致涉及的模块耦合并依赖于特定的技术。例如,Java EE 中的模型类通常包含来自 JPA 等 API 的注解,这些注解需要在所有依赖模块中可用。
对于共享模块正确运行所必需的特定版本的第三方依赖项甚至具有更多技术影响。这些依赖项必须在运行时可用,并且不能与其他依赖项或其版本冲突。这可能导致服务器上已经存在的冲突依赖项带来很多麻烦。同样,包含隐式依赖的实现细节也是如此。
这的典型例子是像 Jackson 或 Gson 这样的 JSON 映射库。许多第三方依赖项使用这些库的特定版本,这些版本可能与运行时其他依赖项或版本冲突。另一个例子是日志实现,如Logback或Log4j。
通常,共享模型应尽可能自给自足,或者至少只包含不会轻易出现这些问题的稳定依赖项。一个非常好的稳定依赖项例子是 Java EE API。由于企业版的向后兼容性,API 的使用和由此产生的功能在引入新版本时不会中断。
即使 Java EE API 是共享模块的唯一依赖项,它也会将模型绑定到特定版本,并减少改变的自由度。
组织挑战
共享技术和依赖项伴随着组织挑战。开发者和团队的数量越多,所使用的技术和依赖项的影响就越大。团队必须就某些技术、使用的框架和库及其版本达成一致。
如果一个团队想要更改这个依赖图或某些使用的技术中的某些内容,这种更改需要大量的协调和开销。第八章“微服务和系统架构”涵盖了在多个系统中共享代码和工件的问题,以及这是否是可取的。
可重用性考虑
交易的总是可重用性和处理这些问题与简单性和潜在重复之间的权衡。根据自给自足的程度,选择将倾向于一个或另一个。一般来说,协调依赖项、版本和技术成本超过了避免冗余的好处。
然而,一个重要的问题是要问项目模块是垂直还是横向分层。横向分层的例子是典型的三层架构,将集群分为表示层、业务层和数据层。垂直分层意味着根据业务领域分组功能。例如,包括所有技术要求(如 HTTP 端点或数据库访问)在内的账户、订单或文章模块。这两种类型的模块都有可能被重用。
在现实中,像模型这样的横向分层模块更有可能被其他项目共享。这类模块自然具有更小的依赖性种类,理想情况下为零。相反,垂直分层模块将包含实现细节并期望某些情况,例如容器的配置方式。同样,这很大程度上取决于要共享的模块中使用的科技。
项目工件
让我们退一步,看看我们企业应用的部署工件。通常,一个应用程序会产生一个单一的工件,用于运行我们的软件。即使最终使用了多个多模块,这些模块最终也会归结为一个或少数几个工件。因此,在大多数情况下,所有这些结构都会再次简化为单个 JAR 或 WAR 文件。考虑到模块的可重用性,这并不一定是既定的,这引发了是否每个项目都需要多个模块的问题。最终,引入和管理子项目,无论是垂直的还是水平的,都需要一定的开发者努力。
诚然,如果只重建已更改的子项目,那么拆分代码库可以提高构建性能。然而,在Apache Maven和Gradle以及第四章轻量级 Java EE中,我们将看到将单个合理设计的项目构建成一个单一工件是足够快的,而且通常还有其他方面负责使构建变慢。
每个工件一个项目
建议将企业项目打包成一个单一的部署工件,该工件源自单个项目模块。部署工件的数量和结构映射了软件项目的结构。如果项目产生了其他工件,它们也将被组织在单独的项目模块中。这使项目结构易于理解和轻量。
通常,企业项目将产生一个可发布的 JAR 或 WAR 文件,源自单个项目模块。然而,有时我们确实有很好的理由创建在项目之间共享的模块。这些模块随后被合理地构建为自己的项目模块,构建自己的工件,例如 JAR 文件。
对于多模块项目,还有其他动机。验证已部署的企业应用程序的系统测试可能不依赖于生产代码,从外部进行。在某些情况下,将这些测试组织为多模块项目的一部分,作为单独的项目模块是有意义的。
另一个例子是前端技术,这些技术与后端应用程序只是松散耦合。随着现代以客户端为中心的 JavaScript 框架越来越被使用,与后端的耦合也降低了。前端开发和的生命周期可能不同于后端应用程序。因此,将技术拆分为几个子项目,甚至几个软件项目是有意义的。为现代前端技术结构化这一主题涵盖了如何应对这些情况。
然而,这些情况也符合将更广泛意义上的工件映射到项目模块的概念。系统测试项目是独立于生产代码使用和执行的。开发和构建前端项目也可能与后端部分不同。可能还有其他情况,在这种情况下也是建议的。
Java EE 的构建系统
项目模块被指定为构建系统的模块。我们是否可以遵循简单的方式有一个单一的项目或多个项目;例如,受系统测试的驱动,我们将它们作为构建过程的一部分进行构建和执行。
一个好的构建系统需要提供某些功能。它的主要任务是编译源代码并将二进制文件打包成工件。所需的依赖项也会被解决并用于编译或打包。依赖项在几个范围内是必需的,例如在编译、测试或运行时。不同的范围定义指定了依赖项是否与工件一起打包。
项目应该以可靠、可重复的方式构建。具有相同项目内容和构建配置的多个构建必须产生相同的结果。这对于实施持续交付(CD)管道非常重要,这些管道可以实现可重复构建。也就是说,构建系统必须能够在持续集成(CI)服务器上运行,例如Jenkins或TeamCity。这要求软件提供命令行界面,特别是对于基于 Unix 的系统。第六章,应用程序开发工作流程,将展示持续交付背后的动机。
构建系统将由在各种环境和操作系统上工作的软件工程师使用,这也应该得到支持。对于基于 JVM 的构建系统,这种可移植性通常是默认的。可能存在项目有特定要求的情况,例如需要在特定环境中构建的本机代码。然而,对于 Java 企业应用来说,这种情况通常并不存在。
通常,构建过程应该尽可能快地运行。启动和配置构建系统不应花费太多时间。构建时间越长,周转时间越高,工程师从构建管道中获得的反馈就越慢。在第四章,轻量级 Java EE,我们将更详细地介绍这个主题。
在撰写本文时,Apache Maven 是最受大多数 Java 开发者熟悉的、最常用的构建系统。
Maven 是一个基于 Java 的构建系统,通过 XML 进行配置。它的项目由所谓的项目对象模型(POM)定义。Maven 利用约定优于配置的方法,最大限度地减少了所需的配置。默认配置非常适合 Java 应用程序。
另一个使用率很高的构建工具是 Gradle。Gradle 是一个提供基于 Groovy 的领域特定语言(DSL)的构建工具,可以配置完全可扩展和可脚本化的项目构建。由于 Groovy 是一种完整的编程语言,Gradle 构建脚本自然强大且灵活。
Gradle 和 Maven 都包括复杂的依赖管理,非常适合构建基于 Java 的项目。当然,还有其他构建系统,例如 SBT,但是 Gradle 和 Maven 无疑是使用最广泛的,将在下一节中介绍。
Apache Maven
Apache Maven 在基于 Java 的项目中广泛使用,并且为大多数企业开发者所熟知。这种广泛的使用和工具的熟悉性当然是一个优势。
Maven 基于约定优于配置的方法,这简化了直接使用场景。然而,Maven 的配置并不总是提供灵活性。实际上,这种不灵活性有时是一个特性。由于更改默认 Maven 项目结构和构建过程很麻烦,大多数 Java 企业项目都以非常相似和熟悉的方式出现。新开发者可以轻松地找到项目构建配置的路径。
以下代码片段显示了 Maven 项目结构的典型示例:

这对于大多数企业 Java 开发者来说都很熟悉。这个示例 Web 应用程序被打包成一个 WAR 文件。
Apache Maven 的一个缺点是其定义使用的构建插件和依赖关系的方式相对不透明。在不明确指定插件版本(如Maven 编译插件)的情况下使用默认构建约定可能会导致不希望的变化。这违反了可重复构建的原则。
因此,需要可重复性的项目通常会在 POM 中显式指定并覆盖插件依赖关系版本。这样做,项目将始终使用相同的版本进行构建,即使默认插件版本发生变化。
超级 POM 定义是指定确切插件版本的另一种常见解决方案。项目 POM 可以从父项目中继承并减少样板插件定义。
开发者可以使用显示应用默认配置和潜在继承后结果的有效 POM视图。
Maven POM 的一个典型问题是企业项目非常经常过度使用 XML 定义。它们过早地引入了本应由构建约定覆盖的插件或配置。以下代码片段显示了 Java EE 8 项目的最小 POM 要求:
<project
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0
http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<groupId>com.example.cars</groupId>
<artifactId>car-manufacture</artifactId>
<version>1.0.1</version>
<packaging>war</packaging>
<dependencies>
<dependency>
<groupId>javax</groupId>
<artifactId>javaee-api</artifactId>
<version>8.0</version>
<scope>provided</scope>
</dependency>
</dependencies>
<build>
<finalName>car-manufacture</finalName>
</build>
<properties>
<maven.compiler.source>1.8</maven.compiler.source>
<maven.compiler.target>1.8</maven.compiler.target>
<failOnMissingWebXml>false</failOnMissingWebXml>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
</properties>
</project>
汽车制造应用程序构建在一个 WAR 工件中。finalName覆盖了 WAR 文件的隐含名称,这里结果为car-manufacture.war。
指定的 Java EE 8 API 是直接企业解决方案所需的生产依赖的唯一依赖。第四章,轻量级 Java EE将深入探讨项目依赖及其影响。
提供的properties标签消除了显式配置构建插件的需求。按照约定,Maven 插件使用属性进行配置。指定这些属性将重新配置使用的插件,而无需显式声明完整定义。
属性使得项目使用 Java SE 8 进行构建,所有源文件都被视为 UTF-8 编码。WAR 文件不需要包含web.xml部署描述符;这就是为什么我们指示 Maven 在缺少描述符时不要使构建失败。在过去,Servlet API 需要部署描述符来配置和映射应用程序的 Servlet。然而,自从 Servlet API 版本 3 推出以来,web.xml描述符不再是必需的;Servlet 可以通过注解进行配置。
Maven 在其构建过程中定义了几个阶段,例如编译、测试或打包。根据所选阶段,将执行多个步骤。例如,触发打包阶段将编译main以及test源代码,运行测试用例,并将所有类和资源打包到工件中。
Maven 构建命令在 IDE 或mvn命令行中触发,例如,作为mvn package。此命令触发打包阶段,从而生成打包的工件。有关 Apache Maven 的阶段和功能更详细的信息,可以在其官方文档中找到。
Gradle
在撰写本文时,Gradle 在 Java 企业项目中的使用不如 Apache Maven 普遍。这可能是因为企业开发者通常不熟悉 Gradle 使用的 Groovy 等动态 JVM 语言。然而,编写 Gradle 构建文件并不需要深入了解 Groovy。
Gradle 带来了许多好处,最重要的是其灵活性。开发者可以利用编程语言的全部功能来定义和可能自定义项目构建。
Gradle 将在后台保持一个守护进程的运行,该守护进程在第一次构建后将被重用,以加快后续构建执行的效率。它还会跟踪构建输入和输出,以及自上次构建执行以来是否进行了更改。这使得系统可以缓存步骤并减少开发构建时间。
然而,根据项目的复杂性和使用的依赖项,这种优化可能甚至不是必需的。第四章,轻量级 Java EE将涵盖项目依赖项和零依赖应用程序的影响。
以下代码片段显示了 Gradle 项目的构建结构:

如您所见,结构相当类似于 Maven 项目,区别在于默认情况下构建的二进制文件放置在build目录中。
对于没有 Gradle 安装的环境,Gradle 项目通常包含一个包装脚本。
以下代码演示了一个build.script文件的示例:
plugins {
id 'war'
}
repositories {
mavenCentral()
}
dependencies {
providedCompile 'javax:javaee-api:8.0'
}
Gradle 构建任务通过命令行触发,使用gradle或提供的包装脚本。例如,执行gradle build是mvn package的类似物,编译源代码,执行测试并构建工件。
拥有一个完整的编程语言定义构建文件有其一定的优势。随着构建脚本被视为代码,开发者被鼓励为变得过于复杂的定义应用清洁代码原则。例如,复杂的构建步骤可以被重构为几个可读的方法。
然而,这种力量也带来了过度工程化构建的危险。正如所说,Apache Maven 的不可灵活性可以被认为是一种特性;轻松定制构建脚本的可行性最终导致构建定义非常具体于项目。与 Maven 相比,过度定制的构建可能成为不熟悉项目的开发者的障碍。
经验表明,绝大多数企业级项目构建相当相似。这引发了这样一个问题:Gradle 提供的灵活性是否是必需的。与产品开发不同,没有特殊要求的项目,使用 Maven 作为构建系统已经足够。
因此,本书的其余部分将在需要构建系统时使用 Maven 作为示例。然而,所有代码示例同样适用于使用 Gradle。
为现代前端技术进行结构化
在了解了企业级系统的现代构建系统之后,让我们看看如何将前端技术集成到后端。
传统上,这相当直接。在大多数情况下,Web 应用程序的前端是服务器端渲染的 HTML 页面,由 JSP 或 JSF 驱动。HTML 是在服务器上按需制作的,即按请求制作,然后返回给客户端。为了实现这一点,JSP 或 JSF 页面必须位于后端。因此,整个企业应用程序将作为一个单一工件进行打包和部署。
进入 JavaScript 框架
随着新的前端技术,基本上是复杂的 JavaScript 框架,尤其是单页应用程序的出现,这个前提已经发生了很大的变化。Web 前端框架变得更加以客户端为中心,并且比过去包含了更多的业务逻辑。在服务器端,这意味着后端和前端之间的交互从细粒度方法转变为更粗粒度、业务用例方法。
因此,随着 JavaScript 框架越来越以客户端为中心和强大,前端和后端之间的通信从紧密耦合的请求和响应转变为更API-like的使用,通常是 JSON 通过 HTTP。这也意味着服务器端变得更加客户端无关。例如,仅通过RESTful-like、JSON 格式的 API 进行通信,使得原生或移动客户端,如智能手机,可以使用与前端相同的 API。
我们在许多企业项目中看到了这种趋势。然而,有人可能会争论将更多逻辑放入客户端的相关性,或者是否在服务器端和客户端之间有一个混合解决方案,即部分在服务器端渲染,部分在客户端渲染,更为合适。不深入探讨这个话题,让我们看看几个关键点。
数据或内容的准备将在服务器端进行得更快。服务器端有更多的能力和资源可用。服务器还可以利用诸如缓存等功能,并利用看到整个图景的优势。
高级前端技术通常包括一种利用所谓的hashbang页面的导航逻辑。一个 hashbang 页面的 URL 示例是/car-manufacture/#!/cars/1234。例如,这些页面,比如car 1234,并不位于服务器上,而是在客户端渲染。该子页面的 URL 是在 hash 符号之后确定的,这在通过 HTTP 请求资源时不会被考虑。这意味着客户端请求一个通用的入口页面,然后执行整个导航逻辑,包括渲染子页面。这显然减少了请求数量,但缺点是服务器无法支持准备或预渲染内容;所有事情都在客户端发生。一些大公司,如 Twitter,最初追求这种方法,但后来又放弃了,原因就在于此。特别是,在移动设备上查看这些页面会带来一定的挑战。由于潜在的缓慢移动连接和较少的计算能力,在这些设备上渲染和执行复杂客户端逻辑确实比显示预渲染的 HTML 要花费更长的时间。
与静态类型的高级语言如 Java 相比,JavaScript 前端确实存在动态类型语言在编程过程中引入更多潜在错误的问题,这些问题本可以通过编译器来避免。正因为如此,我们看到了像 TypeScript 这样的更高级的前端技术出现,它引入了静态类型和更高级的语言特性,这些特性最终被转换成 JavaScript。
组织现代前端
然而,无论选择哪种具体的前端技术,企业项目都比过去具有更复杂的前端。这带来了如何组织日常开发工作的新挑战。通常,前端和后端的工作周期会有所不同。一些开发者典型地会将自己视为更偏向后端,而其他人则更偏向前端。即使团队仅由全栈开发者组成,随着时间的推移,一些事实上的角色很可能会出现。
因此,根据所使用的技术的不同,将前端分离成一个单独的项目是有意义的。正如之前所说,一旦软件的某个部分是单独发货或具有与其余部分不同的生命周期,创建一个专门的项目模块就很有意义。
如果前端技术可以在不依赖任何后端依赖(除了 HTTP 使用)的情况下部署,组织项目就相当直接。该项目可以单独构建和部署在 Web 服务器上,并将使用来自客户端的一个或多个后端。这个项目只包含静态资源,如 HTML、JavaScript 或 CSS 文件,这些文件被传输到客户端并在那里执行。除了 HTTP API 之外,将没有对使用的后端有紧密的技术依赖。
这个方面显然需要在开发初期就很好地沟通,并在后端侧进行文档记录。通常,后端定义 HTTP 资源,以 JSON 格式提供所需的内容,如果需要,可以由查询参数进行过滤。JSON 格式之所以受欢迎,是因为 JavaScript 客户端代码可以直接将响应作为 JavaScript 对象使用,而无需进行任何其他转换。
如果前端将与后端一起作为单个工件部署,则项目结构需要更多的协调。该工件包含技术层的两个层面,并在构建时编译和打包。在开发过程中,如果前端开发的周期与后端侧不同,这种组合可能并不一定有帮助。目前专注于前端开发的程序员可能不想每次都构建后端部分。同样,后端技术等待可能缓慢的 JavaScript 编译和打包也是如此。
在这种情况下,将项目拆分成几个可以单独构建的模块是有意义的。已经证明有效的方法是将前端模块打包成一个单独的模块,并将其作为后端模块的依赖项引入,然后后端模块将一起打包它。通过这样做,前端模块可以清楚地单独构建,而后端开发者也可以通过使用他们最新的前端版本来重新构建后端部分。因此,两边的构建时间都减少了。
为了实现这一功能,Servlet API 可以提供静态资源,这些资源不仅打包在存档中,还打包在包含的 JAR 文件中。位于 WAR 文件中包含的 JAR 文件的META-INF/resources下的资源,也由 Servlet 容器提供。前端项目包含所有必需的前端技术、框架和工具,并构建一个单独的 JAR 文件。
这使得开发者能够将前端项目与后端项目分开,以适应不同的生命周期。
本书余下的部分将专注于通过机器到机器通信(如 Web 服务)可访问的后端技术和业务用例。
企业项目代码结构
在看到我们如何组织企业项目结构之后,让我们更仔细地看看项目内部的结构。假设我们已经建模了一个在规模和责任上合理的企业系统,我们现在将项目的关注点映射到代码结构中。
之前,我们讨论了垂直与水平模块层。这正是我们在结构化项目时需要考虑的方面之一。
企业项目中的情况
传统上,典型企业项目的结构是一个三层架构。三层意味着三个技术驱动的层,即表示层、业务层和数据层。换句话说,项目是水平组织的,有三个子模块或包。
理念是将关注点从数据层、业务层以及它们与表示层分离。因此,低层上的功能不能有任何对高层功能的依赖,只有相反。业务层不能使用表示层的功能,只有相反。对于数据层不依赖于业务层也是如此。
每个技术驱动的层或模块都有其自身的内部依赖关系,这些依赖关系不能从外部使用。例如,只有数据层能够使用数据库,从业务层的直接调用是不可能的。
另一个动机是能够在不影响其他层的情况下交换实现细节。如果数据库技术被改为另一种技术,从理论上讲,这不会影响其他两层,因为数据层封装了这些细节。如果表示技术发生变化,情况也是如此。实际上,甚至可以开发几个表示层,所有这些层都使用相同的业务层组件,至少如果这些层被组织为独立的模块的话。
我们已经看到了关于通过技术关注点组织和分离责任必要性的激烈讨论,这些讨论大多来自高级架构师。然而,这种方法有一些缺点。
水平与垂直分层
清洁代码全部关于旨在被人类而非机器理解的代码。设计领域和组织职责也是如此。我们希望找到易于让工程师了解项目内容的结构。
在已经高度抽象的技术关注点上进行结构化时,挑战在于软件的目的和领域被模糊和隐藏在抽象的较低层。当不熟悉项目的人查看代码结构时,他们首先看到的是三个技术层,尽管在某些情况下名称和数字可能有所不同。这至少看起来熟悉,但它并没有告诉他们实际的领域。
软件工程师寻求理解领域模块,而不一定是技术层。
例如,当触及账户功能时,开发者会考虑与账户领域相关的所有内容,而不是一次性查看所有数据库访问类。除此之外,开发者很少搜索所有数据库访问类,而是寻找处理当前领域逻辑的那个单一类。
当需要对系统进行更改时,也是如此。功能上的更改更有可能影响单个或少数业务领域的所有技术层,但几乎不会一次性影响单个技术层中的所有类。例如,将字段更改为用户账户可能会影响用户模型、数据库访问、业务用例,甚至展示逻辑,但不一定影响其他所有模型类。
为了更清晰地表达开发者感兴趣的部分,让我再举一个例子。想象一个家庭将衣物整理在一个大衣柜里。他们可以将所有家庭成员的裤子集中在一个抽屉里,同时为所有袜子、所有衬衫分别设置单独的抽屉。但当家庭成员尝试穿衣时,他们不太可能一次性寻找所有裤子。相反,他们只对个人的衣物感兴趣,无论是裤子、衬衫、袜子还是其他东西。因此,他们首先按几个衣柜区域组织衣物是有意义的,每个家庭成员一个,然后按技术衣物方面进行结构化,理想情况下遵循类似的架构。同样的情况也适用于软件职责。
以业务驱动结构
Uncle Bob 曾经写过关于尖叫架构的文章,这种架构应该首先向工程师说明整个企业项目的目的。这个想法是,当查看建筑蓝图和看到结构和详细的内部布局时,你立刻就能知道:这是一座房子,这是一座图书馆,这是一座火车站。软件系统也应该如此。你应该能够查看项目结构,并能够说:这是一个会计系统,这是一个书店库存系统,这是一个订单管理系统。这是否是我们大多数项目的实际情况?或者,查看最高层级的模块和包是否更多地告诉我们:这是一个 Spring 应用程序,这个系统有一个表示层、业务层和数据层,这个系统使用 Hazelcast 缓存?
技术实现对我们开发者来说当然很重要。但再次强调,我们首先关注的是业务问题。遵循这种方法,这些方面也应该反映在项目和模块结构中。
最重要的是,这意味着我们的领域应该反映在应用程序结构中。仅仅通过查看最高层级的包名,就应该能很好地了解软件试图做什么。因此,我们首先关注业务问题,其次是实现细节。
建筑的蓝图计划首先会构建一个关于建筑的概念,如何分隔房间,以及门和窗户的位置。然后,作为次要优先级,它们可能会指定使用的材料、砖块和混凝土的类型。
作为对微服务的一个展望,考虑以下内容:设计垂直模块使得团队能够更轻松地将应用程序拆分为多个应用程序的系统。通过查看模块依赖关系,例如通过静态代码分析,可以描绘出系统之间集成点的位置。这些集成点将以某种形式的应用程序间通信出现。从理论上讲,我们然后可以取那个单独的模块,加上最少的管道,将其打包为一个独立的、自给自足的应用程序。
关于命名的一个观点:通过使用术语模块,我们现在关注的是以 Java 包和子包实现的业务驱动模块,而不是构建项目模块。术语模块更多地作为一个概念,而不是严格的技术实现。
设计合理的模块
更实际一些,我们如何找到合理大小和结构的模块?
将业务关注点放在首位,一个好的开始是绘制应用程序所有责任和使用情况的概述。这可能是一部分头脑风暴会议的一部分,理想情况下是与业务领域专家一起进行,如果这一步之前还没有完成。应用程序有哪些责任?我们有哪些由业务驱动的使用案例?哪些连贯的功能可以观察到?对这些问题的回答已经给出了一个很好的想法,即哪些模块可能被表示,而不必关注外部系统、实现细节或框架选择。
在这一步中,我们已经开始考虑这些业务关注点之间的依赖关系。依赖关系是判断模块是否应该拆分或,特别是在发现循环依赖时,应该合并在一起的有用指标。从更高层次开始构建这些概述图,并在几次迭代中逐步向下工作,将给出更清晰的图像,了解应用程序的业务内容。一般来说,确定的模块应该与领域专家识别的业务方面很好地匹配。
以一个在线购物应用程序为例,它可以识别用于用户、推荐、文章、支付和运输的模块。这些将反映为基础领域模块:

确定的模块代表了我们应用程序中的基础 Java 包。
在这些考虑因素上投入一些努力是有意义的。然而,一如既往,任何确定性的结构或实现,无论是在代码层面还是模块层面,都应该能够在以后进行更改。新的需求可能会出现,或者一旦开发者开始深入研究领域,可能会有更好的理解。无论在哪个层面,迭代重构都将提高系统的质量。
第八章,微服务和系统架构,将展示在设计包含分布式应用的系统时的类似动机和方法。特别是,将讨论领域驱动设计的边界上下文和上下文映射方法。
实现包结构
假设我们已经找到了合适的 Java 基础包作为起点。现在,如何实现内部包结构,即使用哪些子包?
包内容
首先,让我们看看垂直切片模块的内容。由于它是根据业务关注点建模的,因此该模块将包括实现某些功能所需的一切。
首先,该模块包括用于用例的技术入口点,例如 HTTP 端点、表示框架控制器或 JMS 端点。这些类和方法通常使用 Java EE 原则,如控制反转,以便在容器中调用,一旦某些通信触达应用程序。
启动实际用例的功能是下一个同样重要的关注点。它们通常与技术端点不同,因为它们不包含任何通信逻辑。业务用例边界是我们领域逻辑的入口点。它们作为托管豆实现,通常是无状态的会话豆,换句话说,是无状态的 EJB 或 CDI 管理豆。
边界负责启动和实现业务逻辑。在用例逻辑仅包含几个步骤的情况下,边界可以足够地包含整个逻辑在业务方法或类定义中的私有方法中。然后不需要其他代理。对于绝大多数用例,边界会将逻辑委派给相应的服务。这些代理具有更细粒度的责任。根据领域,这包括实现详细业务逻辑或访问外部系统,如数据库。遵循领域驱动设计语言,这些类包括服务、事务脚本、工厂和存储库。
下一种类型的对象都是通常被认为是 model 内容的类,例如实体、值对象和传输对象。这些类代表领域中的实体,但也可以,并且应该实现业务逻辑。例如,在数据库中管理的实体豆、其他 POJO 和枚举。
在某些情况下,包可能还包含具有业务或技术责任的拦截器等横切关注点。现在所有这些类型的组件都必须在模块内组织。
水平包分层
如果我们要组织模块内容,我们的第一次尝试可能就是通过技术分层来设计内部包结构。首先按业务关注点切片,然后是技术关注点,至少听起来是合理的。
在 users 包中,这意味着拥有如 controller、business 或 core、model、data 和 client 等子包。通过遵循这种方法,我们根据技术类别将 users 包内的责任分割开来。为了保持一致性,项目中所有其他模块和包都会根据其内容拥有类似的包。这种想法类似于三层架构,但位于领域模块内部。
其中一个子包将被视为技术入口点,例如 controller。这个包将包含启动用例逻辑的通信端点,并作为应用程序外的入口点。以下显示了水平组织化的 users 包的结构:

这种结构如下所示在 Java 包中实现:

平坦模块包
组织模块内容的一个更简单、更直接的方法是将所有相关类直接放入此模块包中的扁平层次结构中。对于users包来说,这意味着将所有类,包括用户相关的用例入口点、用户数据库访问代码、潜在的外部系统功能,以及用户实体类本身,直接放入此包中。
根据模块的复杂性,这可以是一个干净、直接的方法,或者随着时间的推移变得过于无序。特别是实体、值对象和传输对象可以达到一定数量的类,如果放入一个单独的包中,将极大地降低清晰度和概览性。然而,从这种方法开始并稍后重构是非常有意义的。
下图显示了示例users包的包结构:

这种方法的优点是它得到了 Java 语言的良好支持。默认情况下,Java 类和方法具有包私有可见性。这一事实与将所有类组织在一个地方相结合,利用了封装和可见性实践。希望从包外部访问的组件具有公共可见性;所有仅在此包内部访问的类和方法定义了包私有可见性。因此,包可以封装所有内部关注点。
实体控制边界
应对模块包中的类数量,存在另一种类似技术分层的方法,但包定义更少且更清晰。想法是根据模块的使用案例边界来结构化,即后续的业务逻辑组件,以及哪些是实体类。
这侧重于根据模块包的责任来组织模块包,但与水平分层相比,在顶层包层中包含的技术细节较少。边界包包含用例启动器、边界,这些边界可以从系统外部访问。这些类通常代表 HTTP 端点、消息驱动的豆类、前端相关的控制器,或者简单地是企业 Java Bean。它们将实现业务驱动的用例,并可选择委托给位于可选控制包中的后续类。实体包包含模块中的所有名词,即领域实体或传输对象。
Ivar Jacobson 为以下组织模块的方式提出了术语实体控制边界:

包
让我们更深入地了解一下边界包。其理念是所有从前端或系统外部发起的业务用例都从这里启动。创建、更新或删除用户的调用首先到达这个包中的类。根据用例的复杂程度,边界要么完全处理逻辑,要么在变得过于复杂之前委托给控制层。
对于 Java 企业应用,边界包中的类实现为托管 Bean。如前所述,通常在这里使用 EJB。
如果边界中的逻辑变得过于复杂,无法在一个类中管理,我们将逻辑重构为在边界中使用的委托。这些委托或控制被放置在控制包中。它们通常执行更详细的企业逻辑或通过在边界中启动的技术事务处理数据库或外部系统访问。
这种结构增加了内聚性和可复用性,并遵循单一职责原则。一旦我们引入这些抽象层,业务用例的结构就变得更加易于阅读。你可以从将边界视为用例的入口点开始,依次回溯每个委托步骤。
在领域驱动设计语言中,控制包的内容包括服务、事务脚本、工厂和存储库。然而,对于业务用例而言,存在控制包是可选的。
在我们的领域核心,我们有所有实体和价值对象。这些,连同传输对象,构成了我们的领域模块模型,即用例通常处理的那些对象。它们组织在实体包中,这是实体控制边界模式的最后一个包。
那么,关于与表示相关的组件和横切关注点,如拦截器或框架管道逻辑呢?幸运的是,在现代 Java EE 项目中,所需的框架管道逻辑被限制在一定的范围内,正如我们将在第三章,“实现现代 Java 企业应用”中看到的。所需的东西很少,例如使用应用程序激活器类启动 JAX-RS,这些都被放置在我们的项目根包或特定的platform包中。对于横切关注点也是如此,例如那些不绑定到特定模块,而是整个应用程序的由技术动机驱动的拦截器。这些类的数量通常不多;如果是这样,那么一个专门的包是有意义的。拥有这样一个平台包的危险在于,它自然会诱使开发者将其他组件也放入其中。这个地方只是用来放置少数平台特定类;其他所有东西都应该位于其自己的业务动机模块包中。
以下是一个使用实体控制边界模式的users模块的示例:

包访问
并非从实体控制边界模式的每个包的所有访问都是允许的或是有意义的。一般来说,逻辑流程从边界开始,向下到控制包和实体包。因此,边界包因此对控制(如果存在)和实体包都有依赖。不允许使用其他模块的边界,因为这不会有意义,因为边界代表一个业务用例。访问另一个边界意味着调用应该是独立、独立用例的东西。因此,边界只能向下的层次结构到控制。
然而,从边界到其他模块控制的依赖和调用是被允许的,在某些情况下也是有意义的。开发者必须注意,在从其他模块访问组件时,事务作用域仍然被正确选择。当访问其他模块的控制时,它们也会与该外国模块的实体一起工作或返回这些实体。这种情况发生在非平凡用例中,只要注意责任分配得当,并且控制和使用实体正确,就不会成为问题。
控制可以访问其他模块的控制、自己的以及外国实体。与边界相同的原因,控制调用任何边界的功能是没有意义的。这相当于在运行中的用例内启动新的顶级业务用例。
实体只能依赖于其他实体。在某些情况下,可能需要在控制上设置导入,例如,如果存在可以实施复杂逻辑的 JPA 实体监听器或 JSON-B 类型转换器。这些由技术驱动的案例是例外,在这种情况下,为了简单起见,允许导入这些类。理想情况下,这些实体支持组件,如实体监听器或转换器,应直接位于实体包中。由于其他依赖和代理的使用,这个前提并不总是能够得到满足,这不应该导致过于复杂的技术解决方案。
这也引出了另一个更普遍的话题。
不要过度强制架构
无论你选择哪种架构模式,应用程序的主要优先级应该是业务领域。这既适用于寻找合理的、由领域驱动的模块,也适用于如何在模块内结构化包,以便开发者可以以最少的努力与之合作。
这是一点需要注意的重要事项:开发者应该能够在不过于复杂或过度强制的结构和架构下工作。我们过去已经看到了太多例子,故意使用技术驱动的层或过于严格的模式,只是为了符合书本和满足某些约束。但这些约束通常是自发的,并不能满足任何更高的目的。我们应该理智地重新考虑需要什么,什么只是让开发过程膨胀。当你有时间的时候,搜索一下载货文化编程这个术语,你会找到一个有趣的现实世界故事,讲述的是在不质疑其目的的情况下遵循规则和仪式。
因此,不要过度复杂化或过度强化架构。如果有一种简单直接的方法可以满足当前的需求,那就去做吧。这不仅适用于过早的重构,也适用于架构设计。如果将几个类放入一个命名良好的包中可以达到目的,并且清楚地说明了理由,为什么不呢?如果一个业务用例边界类已经能够满足整个简单逻辑,为什么还要引入空委托?
跟随架构模式所付出的代价,即使不是在所有地方都需要,是统一性与简单性之间的权衡。让所有包、模块和项目都展示相同的模式和结构,对开发者来说是一个熟悉的画面。然而,在第八章微服务与系统架构中,我们将更详细地看到,最终,统一性是一个不太可能在整个组织或单个项目中实现的目标。制作更简单且最终更灵活的东西的好处,在很多情况下超过了统一性。
同样,过度尝试使用技术层封装实现也是一样。确实,模块以及类应该封装实现细节并提供干净、清晰的接口。然而,这些责任可以并且应该包含在单个、理想上自给自足的包或类中。通过技术术语打包模块的关注点最终会将细节暴露给模块的其余部分,例如,使用数据库或连接到外部系统的客户端。首先按领域动机组织,使我们能够将功能封装到单个责任点中,对模块或应用的其余部分来说是透明的。
为了防止意外滥用打包方式,最简单、最透明的方法是引入静态代码分析。类和整个包中的包导入可以被扫描和分析,以检测和防止不希望的依赖。这代表了一种安全措施,类似于测试用例,以避免粗心大意的错误。静态代码分析通常作为构建过程的一部分在持续集成服务器上运行,因为它们可能需要一些时间来构建。在第六章“应用程序开发工作流程”中,我们将更深入地探讨这个话题。
摘要
企业软件应该以解决业务问题为主要优先级来构建,导致以业务驱动应用和技术,而不是以技术驱动解决方案。业务用例最终将为公司创造收入。
如果可能的话,企业应用应该在一个构建项目中开发每个工件,并保持在版本控制之下。将项目拆分成几个独立的构建模块,最终归结为一个单一工件,并不会增加太多价值。对于粗略的项目结构,建议垂直而不是水平地组织软件模块。这意味着按照业务而不是技术问题来组织。查看项目结构应该立即让开发者了解项目的领域和责任。
单个应用程序模块可以以最简单的方式设计为一个单一的、平面的 Java 包。如果每个模块的类数量较少,这是推荐的。对于更复杂的模块,使用如实体控制边界等模式添加另一个分层层是有意义的。
应该提醒软件工程师不要过度强调软件架构。经过深思熟虑的设计和官僚组织当然在很大程度上支持开发者构建高质量的软件。然而,合理设计和过度工程之间总是有一个快乐的平衡点。
在看到企业项目的课程结构和如何设计模块之后,让我们深入一层,看看如何实现项目模块。下一章将向您展示使用 Java EE 实现企业应用需要哪些步骤。
第三章:实施现代 Java 企业应用程序
现在我们已经看到了项目中包含哪些组件以及如何找到和构建合理大小的模块和包,让我们更接地气地讨论 Java EE 的话题。首先考虑业务问题,并遵循领域驱动设计的实践来识别边界上下文和包含我们领域所有内容的模块,这当然是有意义的。
让我们看看如何实现已识别的业务模块和用例。
本章将涵盖:
-
如何实现应用程序用例边界
-
Java EE 核心领域组件是什么
-
使用 Java EE 进行设计模式和领域驱动设计
-
应用程序通信
-
如何集成持久化
-
技术横切关注点和异步行为
-
Java EE 的概念和原则
-
如何实现可维护的代码
用例边界
根据领域关注点组织包,使我们达到一个架构结构,其中实际业务而不是技术细节得到反映。
业务用例处理实现业务目的所需的所有逻辑,使用我们模块的所有内容。它们作为进入应用程序领域的起点。用例通过系统边界公开和调用。企业系统提供与外部世界的通信接口,主要通过 Web 服务或基于 Web 的前端,调用业务功能。
在启动新项目时,首先从领域逻辑开始是有意义的,不考虑系统边界或任何技术实现细节。这包括构建领域所有内容、设计类型、依赖和责任,并将这些原型编码化。正如我们将在本章中看到的,实际的领域逻辑主要是用纯 Java 实现的。初始模型可以自给自足,仅使用代码级别测试进行测试。在找到一个足够成熟的领域模型后,我们将针对领域模块之外的技术问题进行目标定位,例如访问数据库或外部系统,以及系统端点。
在 Java EE 应用程序中,边界是通过托管 Bean 实现的,即企业 JavaBean(EJB)或Java 的上下文和依赖注入(CDI)托管 Bean。主题“EJB 和 CDI - 区分和集成”将展示这些技术的差异和重要性。
根据单个用例的复杂性,我们引入了委托,这些委托作为 CDI 托管 Bean 或 EJB 实现,具体取决于需求。这些委托位于控制包中。实体作为 POJO 实现,可选地注解以集成技术功能,例如指定数据库映射或序列化。
现代 Java EE 的核心领域组件
纯 Java 与 CDI 和 EJB 结合,形成了现代 Java EE 应用程序的核心领域组件。为什么称之为核心领域?正如所述,我们希望关注实际业务。有些方面、组件和功能在其核心上服务于业务目的,而其他方面只是支持,使业务领域可访问,或满足其他技术需求。
Java EE 提供了许多 API,支持实现数十个技术需求。尽管大多数 API 都是技术驱动的。然而,Java EE 平台最大的优势是,可以使用最少的代码影响实现干净的 Java 业务逻辑。为此所需的 API 主要是 CDI 和 EJB。其他 API,如 JPA、JAX-RS、JSON-P 等,由于技术驱动的原因,其引入具有次要优先级。
管理 Bean,无论是 CDI 还是 EJB,都作为注解 Java 类实现,不需要任何技术超类或接口。在过去,这被称为无接口视图。如今,这已成为默认情况。扩展类会模糊领域视图,并且在可测试性方面也存在其他缺点。一个现代框架尽可能地简单和精简地集成自身。
EJB 和 CDI - 区别与集成
现在的问题是,是否使用 EJB 或 CDI 管理的 Bean。
通常,EJB 提供了更多开箱即用的功能。CDI 管理的 Bean 提供了一种相对较轻的替代方案。这些技术的主要区别是什么,以及它们如何影响开发者的工作?
第一个区别在于作用域。EJB 会话 Bean 可以是无状态的,即在客户端请求期间活跃,也可以是有状态的,即在客户端的 HTTP 会话生命周期内活跃,或者单例。CDI 管理的 Bean 具有类似的作用域,还有更多可能性,例如添加自定义作用域和默认的依赖作用域,该作用域根据其注入点的生命周期而活跃。主题“作用域”将更详细地处理 Bean 的作用域。
EJB 和 CDI Bean 之间的另一个区别是,EJB 隐式包含某些横切关注点,例如监控、事务、异常处理以及为单例 Bean 管理并发。例如,调用 EJB 业务方法隐式启动一个技术事务,该事务在方法执行期间活跃,并集成数据源或外部系统。
此外,无状态 EJB 在使用后会被池化。这意味着在无状态会话 Bean 的业务方法被调用后,Bean 实例可以并且将被容器重用。由于这个原因,EJB 的性能略优于每次其作用域需要时都会实例化的 CDI Bean。
实际上,技术差异对开发者的工作影响不大。除了使用不同的注解外,这两种技术都可以用于相同的界面和感觉。Java EE 的发展方向是更开放地选择这两种技术;例如,自从 Java EE 8 以来,仅使用 CDI 就可以处理异步事件,而不仅仅是 EJB。
然而,CDI 提供的功能集成是 Java EE API 的最大特性之一。仅依赖注入、CDI 生产者和事件就是有效应对各种情况的手段。
最常用的 CDI 功能是使用@Inject注解进行依赖注入。注入是构建的,无论哪种 Java EE 技术管理豆,它对开发者来说“只需工作”。你可以混合和匹配 CDI 豆和 EJBs,使用所有作用域;框架将负责在哪个作用域中实例化或使用哪些豆。这使使用更加灵活,例如,当短作用域的豆被注入到长作用域的豆中时;例如,当会话作用域的豆被注入到单例中时。
此功能以这种方式支持业务领域,使得边界和控制可以注入所需的依赖项,而无需担心它们的实例化或管理。
以下代码演示了如何将作为无状态会话豆实现的边界注入所需的控件。
import javax.ejb.Stateless;
import javax.inject.Inject;
@Stateless
public class CarManufacturer {
@Inject
CarFactory carFactory;
@Inject
CarStorage carStorage;
public Car manufactureCar(Specification spec) {
Car car = carFactory.createCar(spec);
carStorage.store(car);
return car;
}
}
CarManufacturer类代表一个无状态的 EJB。注入的CarFactory和CarStorage豆被实现为依赖作用域的 CDI 豆,它们将被实例化并注入到 EJB 中。Java EE 平台通过启用使用@Inject注解注入任何特定项目豆来简化依赖项解析。这并非总是如此;在过去,使用@EJB注解注入 EJB。@Inject简化了 Java EE 中的使用。
仔细的读者可能已经注意到了基于字段注入与包私有 Java 作用域。基于字段的注入对类的内容影响最小——因为可以避免自定义构造函数。包私有可见性允许开发者设置和注入测试作用域中的依赖项。我们将在第七章测试中介绍这个主题和潜在的替代方案。
CDI 生产者
CDI 生产者是 Java EE 的另一个特别有用的功能,可以帮助实现各种类型的工厂。生产者主要实现为生产者方法,提供可以注入到其他管理豆中的对象。这解耦了创建和配置逻辑与使用。当需要注入除管理豆类型之外的定制类型时,生产者非常有用。
以下显示了 CDI 生产者方法的定义:
import javax.enterprise.inject.Produces;
public class CarFactoryProducer {
@Produces
public CarFactory exposeCarFactory() {
CarFactory factory = new BMWCarFactory();
// use custom logic
return factory;
}
}
如前所述的CarManufacturer示例中所示,CarFactory类型可以通过@Inject简单地注入。CDI 在需要CarFactory实例时调用exposeCarFactory()方法,并将返回的对象插入到注入点。
这些技术已经涵盖了核心领域逻辑用例的大部分需求。
发射领域事件
CDI 为那些需要进一步解耦业务功能的情况提供了一个事件功能。Bean 可以触发事件对象,这些对象作为有效载荷,并在事件观察者中处理。通过发射和处理 CDI 事件,我们可以将主要业务逻辑从处理事件的侧面方面解耦。这个想法特别适合那些业务领域已经包含事件概念的使用案例。默认情况下,CDI 事件以同步方式处理;在它们被触发的地方中断执行。CDI 事件也可以异步处理或在技术事务的生命周期中的特定点处理。
以下代码演示了如何在业务用例中定义和触发 CDI 事件:
import javax.enterprise.event.Event;
@Stateless
public class CarManufacturer {
@Inject
CarFactory carFactory;
@Inject
Event<CarCreated> carCreated;
public Car manufactureCar(Specification spec) {
Car car = carFactory.createCar(spec);
carCreated.fire(new CarCreated(spec));
return car;
}
}
CarCreated事件是不可变的,并包含与领域事件相关的信息,例如汽车规格。该事件在CreatedCarListener类中处理,该类位于控制包中:
import javax.enterprise.event.Observes;
public class CreatedCarListener {
public void onCarCreated(@Observes CarCreated event) {
Specification spec = event.getSpecification();
// handle event
}
}
因此,监听器与主要业务逻辑解耦。CDI 容器将负责连接事件处理功能,并同步调用onCarCreated()方法。
主题“执行流程”,展示了事件如何在事务的生命周期中的特定点异步触发和处理。
CDI 事件是将领域事件定义与处理它们解耦的一种方式。事件处理逻辑可以更改或增强,而无需触及汽车制造商组件。
作用域
当应用程序中保持的状态持续时间超过单个请求的持续时间时,Bean 作用域对于这种情况非常重要。
如果整个业务流程都可以以无状态的方式实现,只需执行一些逻辑并在之后丢弃所有状态,作用域定义就相当直接。具有依赖作用域的 CDI 无状态会话 Bean 已经满足了许多这些案例。
EJB 单例作用域和 CDI 应用程序作用域也经常被使用。Bean 类型的单例实例是存储或缓存具有长期生命周期的信息的直接方式。除了所有复杂的缓存技术之外,包含简单集合或映射的单一实例,具有管理的并发性,仍然是设计特定于应用程序的、易失性存储的最简单方式。单一实例还提供了一个单一的责任点,对于某些原因需要以受限方式访问的功能。
EJB 和 CDI 实体的作用域最后一个是会话作用域,它与客户端的 HTTP 会话绑定。只要用户的会话保持活跃,这种作用域的 Bean 就会保持活跃并复用其所有状态。然而,在状态 Bean 中存储会话数据会引入一个挑战,即客户端需要再次连接到相同的应用服务器。这当然是可以实现的,但会阻止设计无状态的、易于管理的应用。如果应用变得不可用,所有临时会话数据也会丢失。在现代企业应用中,状态通常存储在数据库或缓存中以优化目的。因此,会话作用域的 Bean 不再被广泛使用。
CDI 管理 Bean 带有更多内置的作用域,即会话作用域或默认的依赖作用域。也有可能为特殊需求添加自定义作用域。然而,经验表明,内置的作用域通常足以满足大多数企业应用的需求。CDI 规范提供了有关如何扩展平台和开发自定义作用域的更多信息。
正如你所见,我们已可以使用这些 Java EE 核心组件实现很多功能。在探讨集成技术,如 HTTP 通信或数据库访问之前,让我们先深入了解我们核心领域中使用的设计模式。
Java EE 中的模式
关于设计模式已经有很多论述。最突出且总是被引用的例子是《设计模式》这本书,由“四人帮”(GoF)所著。它描述了在软件设计中使用特定实现模式解决的常见情况。
尽管特定模式的设计和动机至今仍然有效,但实际的实现可能已经改变,尤其是在企业领域。除了适用于所有类型应用的知名设计模式外,还有很多与企业相关的模式出现。特别是,过去出现了许多与 J2EE 相关的企业模式。由于我们现在处于 Java EE 8 时代,不再是 J2EE,现在有更简单的方法来实现各种模式,以应对特定情况。
重新审视设计模式
GoF 书中描述的设计模式被分为创建型、结构型和行为型。每个模式都描述了软件中的典型挑战,并展示了如何应对和解决这些情况。它们代表实现蓝图,不依赖于任何特定技术。这就是为什么这些模式的想法可以在不精确匹配描述的实现的情况下实现。在 Java SE 8 和 EE 8 的现代世界中,我们比过去有更多的语言特性可用。我想展示一些四人帮的设计模式、它们的动机以及如何在 Java EE 中实现它们。
单例
单例模式是一个众所周知的设计模式,或者有些人可能会认为它是一个反模式。单例在整个应用程序中每个类只有一个实例。这种模式的动机是能够在中央位置存储状态以及能够协调动作。单例确实有存在的理由。如果某些状态需要在多个消费者之间可靠地共享,那么一个单一的入口点无疑是简单的方法。
然而,有一些需要注意的点。拥有单一责任点也引入了需要管理的并发。因此,单例需要是线程安全的。话虽如此,我们应该记住,单例本身并不易于扩展,因为只有一个实例。由于包含的数据结构,我们引入的同步越多,我们的类在并发访问方面的扩展性就越差。然而,根据具体的使用情况,这可能是也可能不是问题。
GoF(设计模式:可复用面向对象软件的基础)书中描述了一个静态单例实例,该实例由单例类管理。在 Java EE 中,单例的概念直接集成到 EJBs(企业 JavaBeans)中的单例会话 Bean 和 CDIs(上下文依赖注入)的应用程序作用域中。这些定义将创建一个用于所有客户端的单例管理 Bean。
以下是一个单例 EJB 的示例:
import javax.ejb.Singleton;
@Singleton
public class CarStorage {
private final Map<String, Car> cars = new HashMap<>();
public void store(Car car) {
cars.put(car.getId(), car);
}
}
在使用 EJB 单例会话 Bean 或 CDI 应用程序作用域 Bean 实现单例方面存在一些差异。
默认情况下,容器管理 EJB 单例的并发。这确保了同一时间只执行一个公共业务方法。可以通过提供@Lock注解来改变这种行为,该注解分别声明方法为写锁或读锁,其中 Bean 充当读写锁。所有 EJB 单例业务方法都是隐式写锁定的。以下是一个使用具有容器管理并发和锁注解的 EJB 的示例:
import javax.ejb.Lock;
import javax.ejb.LockType;
@Singleton
public class CarStorage {
private final Map<String, Car> cars = new HashMap<>();
@Lock
public void store(Car car) {
cars.put(car.getId(), car);
}
@Lock(LockType.READ)
public Car retrieve(String id) {
return cars.get(id);
}
}
可以通过使用 Bean 管理并发来关闭并发。然后 Bean 将被并发调用,实现本身必须确保线程安全。例如,使用线程安全的数据结构不需要 EJB 单例管理并发访问。EJB 实例的业务方法将并行调用,类似于 CDI 应用程序作用域 Bean:
import javax.ejb.ConcurrencyManagement;
import javax.ejb.ConcurrencyManagementType;
@Singleton
@ConcurrencyManagement(ConcurrencyManagementType.BEAN)
public class CarStorage {
private final Map<String, Car> cars = new ConcurrentHashMap<>();
public void store(Car car) {
cars.put(car.getId(), car);
}
public Car retrieve(String id) {
return cars.get(id);
}
}
CDI 应用程序作用域的 Bean 不限制并发访问,实现本身必须处理并发问题。
这些解决方案解决需要单例的情况;例如,需要在整个应用程序中共享内存中的状态。
CDI 应用程序作用域的 Bean 或具有 Bean 管理并发和线程安全数据结构的 EJB 单例提供了一个应用程序范围内的、非集群的内存缓存,扩展性非常好。如果不需要分布式处理,这是一个简单而优雅的解决方案。
EJB 单例的另一个广泛使用的场景是在应用启动时调用单个进程的能力。通过声明@Startup注解,该 Bean 将在应用启动时实例化和准备,调用@PostConstruct方法。可以为所有 EJB 定义启动过程,但使用单例我们可以实现需要恰好设置一次的过程。
抽象工厂
GoF 抽象工厂模式旨在将对象的创建与其使用分离。创建复杂对象可能需要了解某些先决条件、实现细节或要使用的实现类。工厂帮助我们创建这些对象,而无需深入了解内部。在本章的后面,我们将讨论与该模式密切相关的问题域驱动设计工厂。动机是相同的。抽象工厂旨在拥有多个抽象类型的实现,其中工厂本身也是一个抽象类型。功能的使用者针对接口进行开发,而具体的工厂将产生并返回具体的实例。
可能存在一个抽象的GermanCarFactory,具体实现为BMWFactory和PorscheFactory。这两个汽车工厂可能分别产生GermanCar的一些实现,无论是BMWCar还是PorscheCar。只想拥有一些德国汽车的客户端不会关心工厂将使用哪个实际的实现类。
在 Java EE 世界中,我们已经有了一个强大的功能,实际上是一个工厂框架,即 CDI。CDI 提供了大量功能来创建和注入特定类型的实例。虽然动机和结果相同,但在细节实现上有所不同。实际上,根据用例,实现抽象工厂的方法有很多。让我们看看其中的一些。
管理 Bean 可以注入具体或抽象的实例,甚至是参数化类型。如果我们想在当前的 Bean 中只有一个实例,我们直接注入一个GermanCar:
@Stateless
public class CarEnthusiast {
@Inject
GermanCar car;
...
}
如果存在多个GermanCar类型的实现,那么在这一点上将会出现依赖解析异常,因为容器不知道应该注入哪一辆实际的汽车。为了解决这个问题,我们可以引入显式请求特定类型的限定符。我们可以使用可用的@Named限定符和定义的字符串值;然而,这样做不会引入类型安全。CDI 为我们提供了指定自己的类型安全限定符的可能性,这些限定符将匹配我们的用例:
@BMW
public class BMWCar implements GermanCar {
...
}
@Porsche
public class PorscheCar implements GermanCar {
...
}
标准化是自定义运行时保留注解,自身被@Qualifier注解,通常还带有@Documented:
import javax.inject.Qualifier;
import java.lang.annotation.Documented;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
@Qualifier
@Documented
@Retention(RetentionPolicy.RUNTIME)
public @interface BMW {
}
限定符在注入点指定。它们限定注入的类型,并将注入与实际使用的类型解耦:
@Stateless
public class CarEnthusiast {
@Inject
@BMW
GermanCar car;
...
}
获取CarEnthusiast的实例现在将创建并注入一个依赖范围的BMWCar,因为这种类型与注入点匹配。
我们现在甚至可以定义一个宝马汽车的子类型,它将在不改变注入点的情况下使用。这是通过用不同的实现来专门化BMWCar类型来实现的。ElectricBMWCar类型是BMWCar的子类,并指定了@Specializes注解:
import javax.enterprise.inject.Specializes;
@Specializes
public class ElectricBMWCar extends BMWCar {
...
}
专用豆继承了其父类型的类型和限定符,并将透明地用于代替父类型。在这个例子中,使用@BMW限定符注入GermanCar将提供一个ElectricBMWCar的实例。
然而,为了更接近书中描述的设计模式,我们也可以定义一个用于创建所需数量汽车的汽车工厂类型:
public interface GermanCarManufacturer {
GermanCar manufactureCar();
}
这个汽车工厂以不同的具体方式实现:
@BMW
public class BMWCarManufacturer implements GermanCarManufacturer {
@Override
public GermanCar manufactureCar() {
return new BMWCar();
}
}
@Porsche
public class PorscheCarManufacturer implements GermanCarManufacturer {
@Override
public GermanCar manufactureCar() {
return new PorscheCar();
}
}
这样做,客户端现在可以直接注入并使用一个制造商来创建新的德国汽车:
@Stateless
public class CarEnthusiast {
@Inject @BMW
GermanCarManufacturer carManufacturer;
// create German cars
}
显式定义和指定的类型注入,例如我们的两款德国汽车,为实现提供了很大的灵活性。
工厂方法
要理解工厂方法,让我们看看另一个具有类似动机但实现方式不同的模式。工厂方法定义了作为特定类型上方法的工厂。没有单个类负责创建特定实例;相反,创建成为定义为一个域类部分的工厂方法的职责。
例如,让我们考虑一辆使用其记录的行程来生成驾驶员日志簿的汽车。在汽车类型中包含一个createDriverLog()方法,该方法返回日志簿值类型,这是完全合理的,因为该类本身可以以自给自足的方式提供逻辑。这些解决方案将纯粹在 Java 中实现,无需任何框架或注解:
public class Car {
...
public LogBook createDriverLog() {
// create logbook statement
}
}
正如我们将在本章后面看到的那样,领域驱动设计工厂不区分抽象工厂和工厂方法。它们更多地针对领域的动机。在某些情况下,将工厂封装为方法,与其他类的职责一起定义是有意义的。在其他情况下,如果创建逻辑是那种特定的、以单独类形式存在的单一责任点,则更合适。一般来说,将创建逻辑放入域类型是可取的,因为它可能利用该域类的其他功能和属性。
让我们看看 CDI 生产者。生产者被定义为用于动态查找和注入特定类型实例的方法或字段。我们对字段包含的值或方法返回的值有完全的灵活性。我们可以同样指定限定符以确保生产者不会与其他可能产生的类型冲突。定义生产方法豆也可以包含用于生产者的进一步属性:
import javax.enterprise.inject.Produces;
public class BMWCarManufacturer {
...
@Produces
@BMW
public GermanCar manufactureCar() {
// use properties
...
}
}
这与作为 CDI 生产者实现的工厂方法的想法相匹配。
需要考虑产生的实例的范围。与其他任何 CDI 管理 Bean 一样,生产者默认是依赖范围的。范围定义了管理 Bean 的生命周期以及它们的注入方式。它影响生产者方法将被调用的频率。对于默认范围,当调用管理 Bean 实例化时,方法对每个注入实例调用一次。每次注入产生值的 Bean 被注入时,都会调用生产者方法。如果该 Bean 有更长的生命周期,那么在该期间不会再次调用生产者方法。
在本章的后面部分,我们将看到 CDI 生产者更复杂的用法。
对象池
对象池设计模式是为了性能优化而设计的。池背后的动机是避免不断创建所需对象和依赖项的新实例,通过在对象池中保留它们更长时间来实现。所需实例从这个对象池中检索出来,并在使用后释放。
这个概念已经以不同形式内置到 Java EE 容器中。如前所述,无状态会话 Bean 是池化的。这也是它们表现异常出色的原因。然而,开发者必须意识到实例正在被重用;在使用后,实例不得保留任何状态。容器保留了一组这些实例。
另一个例子是数据库连接的池化。数据库连接的初始化成本相当高,因此保留几个供以后使用是有意义的。根据持久化实现的不同,一旦请求新的查询,这些连接就会被重用。
在企业应用程序中,线程也会被池化。在 Java 服务器环境中,客户端请求通常会导致一个 Java 线程来处理逻辑。处理完请求后,这些线程将被再次重用。线程池配置以及拥有不同的池是进一步性能优化的一个重要主题。我们将在第九章“监控、性能和日志”中介绍这个主题。
开发者通常不会自己实现对象池模式。容器已经为实例、线程和数据库包含了这种模式。应用程序开发者隐式地使用了这些可用功能。
装饰者
另一个众所周知的设计模式是装饰者模式。这个模式允许我们向对象添加行为,而不影响该类的其他对象。这种行为通常可以与几个子类型组合。
一个好的例子是食物。每个人在口味和成分上都有自己的偏好。以咖啡为例。我们可以喝纯黑咖啡,加牛奶,加糖,牛奶和糖都加,甚至加糖浆、奶油,或者未来可能流行的任何东西。而且这还没有考虑到不同的冲泡咖啡的方式。
下面的示例展示了使用纯 Java 实现的装饰者模式。
我们指定以下 Coffee 类型,它可以被 CoffeeGarnish 子类型装饰:
public interface Coffee {
double getCaffeine();
double getCalories();
}
public class CoffeeGarnish implements Coffee {
private final Coffee coffee;
protected CoffeeGarnish(Coffee coffee) {
this.coffee = coffee;
}
@Override
public double getCaffeine() {
return coffee.getCaffeine();
}
@Override
public double getCalories() {
return coffee.getCalories();
}
}
默认的咖啡装饰只委托给其父咖啡。可能有多个咖啡的实现:
public class BlackCoffee implements Coffee {
@Override
public double getCaffeine() {
return 100.0;
}
@Override
public double getCalories() {
return 0;
}
}
除了常规的黑咖啡外,我们还指定了一些装饰:
public class MilkCoffee extends CoffeeGarnish {
protected MilkCoffee(Coffee coffee) {
super(coffee);
}
@Override
public double getCalories() {
return super.getCalories() + 20.0;
}
}
public class SugarCoffee extends CoffeeGarnish {
protected SugarCoffee(Coffee coffee) {
super(coffee);
}
@Override
public double getCalories() {
return super.getCalories() + 30.0;
}
}
public class CreamCoffee extends CoffeeGarnish {
protected CreamCoffee(Coffee coffee) {
super(coffee);
}
@Override
public double getCalories() {
return super.getCalories() + 100.0;
}
}
使用咖啡类型,我们可以组合我们想要的具有特定行为的咖啡:
Coffee coffee = new CreamCoffee(new SugarCoffee(new BlackCoffee()));
coffee.getCaffeine(); // 100.0
coffee.getCalories(); // 130.0
JDK 中装饰器模式的一个例子是 InputStream 类,它可以为文件、字节数组等添加特定行为。
在 Java EE 中,我们再次利用 CDI(Contexts and Dependency Injection)提供的装饰器功能。装饰器为 Bean 添加特定行为。对注入 Bean 的调用将调用装饰器而不是实际的 Bean;装饰器添加特定行为并将调用委托给 Bean 实例。原始 Bean 类型成为装饰器的所谓代理:
public interface CoffeeMaker {
void makeCoffee();
}
public class FilterCoffeeMaker implements CoffeeMaker {
@Override
public void makeCoffee() {
// brew coffee
}
}
代理类型必须是接口。CountingCoffeeMaker 装饰了现有的咖啡机功能:
import javax.decorator.Decorator;
import javax.decorator.Delegate;
import javax.enterprise.inject.Any;
@Decorator
public class CountingCoffeeMaker implements CoffeeMaker {
private static final int MAX_COFFEES = 3;
private int count;
@Inject
@Any
@Delegate
CoffeeMaker coffeeMaker;
@Override
public void makeCoffee() {
if (count >= MAX_COFFEES)
throw new IllegalStateException("Reached maximum coffee limit.");
count++;
coffeeMaker.makeCoffee();
}
}
装饰器功能通过 beans.xml 描述符激活。
<?xml version="1.0" encoding="UTF-8"?>
<beans
xsi:schemaLocation="http://xmlns.jcp.org/xml/ns/javaee
http://xmlns.jcp.org/xml/ns/javaee/beans_1_1.xsd"
bean-discovery-mode="all">
<decorators>
<class>com.example.coffee.CountingCoffeeMaker</class>
</decorators>
</beans>
激活装饰器后,CoffeeMaker 类型的注入实例将使用装饰后的功能。这不会改变原始实现:
public class CoffeeConsumer {
@Inject
CoffeeMaker coffeeMaker;
...
}
托管 Bean 可以有多个装饰器。如果需要,可以使用 Java EE 的 @Priority 注解在装饰器上指定顺序。
这种 CDI 功能适用于托管 Bean。根据我们是否想向我们的领域模型类或涉及的服务添加额外的行为,我们将使用模式,要么使用纯 Java,如最初所述,要么使用 CDI 装饰器。
门面
门面设计模式用于提供对某些功能的干净且简单的接口。封装和抽象层无疑是编写代码时最重要的原则之一。我们引入门面,将复杂的功能或难以使用的遗留组件封装到更简单的接口中。因此,门面是抽象的一个典型例子。
让我们考虑一个咖啡店中相当复杂的设置。有磨豆机、咖啡机、秤和各种工具在使用中,所有这些都需要相应地配置:
public class BaristaCoffeeShop {
private BeanStore beanStore;
private Grinder grinder;
private EspressoMachine espressoMachine;
private Scale scale;
private Thermometer thermometer;
private Hygrometer hygrometer;
public GroundBeans grindBeans(Beans beans, double weight) { ... }
public Beans fetchBeans(BeanType type) { ... }
public double getTemperature() { ... }
public double getHumidity() { ... }
public Coffee makeEspresso(GroundBeans beans, Settings settings) { ... }
}
当然,可以争论这个类已经需要重构。然而,遗留类可能不容易更改。我们将引入一个扮演门面的咖啡师:
@Stateless
public class Barista {
@Inject
BaristaCoffeeShop coffeeShop;
public Coffee makeCoffee() {
// check temperature & humidity
// calculate amount of beans & machine settings
// fetch & grind beans
// operate espresso machine
}
}
在 Java EE 领域,最突出的门面示例是由 EJB 实现的边界。它们为我们业务域中的业务用例提供了门面。除此之外,门面还可以使用各种托管 Bean 实现。门面适当地委托和编排复杂逻辑。精心选择的抽象可以提高软件设计,并且是我们努力追求的目标。
代理
代理设计模式可能是 Java EE 中包含的最明显的设计模式之一。注入的 bean 引用几乎在所有情况下都不是实际实例的引用,而是一个代理。代理是围绕实例的薄包装,可以添加某些功能。客户端甚至没有意识到它与代理而不是实际对象交互。
代理允许在企业环境中实现所需的横切功能,例如拦截器、事务、日志记录或监控。它们最初也是执行依赖注入所必需的。
应用程序开发者通常不会直接使用代理模式。然而,了解代理模式的一般工作原理以及在 Java EE 平台上的具体使用方法是推荐的。
观察者
观察者设计模式描述了在整体状态发生变化时,一个对象如何管理和通知观察者。观察者在主题上注册自己,稍后将被通知。观察者的通知可以是同步或异步的。
如前所述,CDI 包含一个事件功能,该功能实现了观察者模式。开发者不需要自己处理注册和通知逻辑;他们只需通过注解声明松耦合即可。正如在 现代 Java EE 的核心领域组件 主题中所示,Event<T> 类型以及 @Observes 注解声明了事件发布和观察。在 执行流程 主题中,我们将介绍异步 CDI 事件。
策略
策略设计模式用于在运行时动态选择一个实现算法,即策略。该模式用于根据情况选择不同的业务算法,例如。
根据情况,我们可以有多种方式来利用策略模式。我们可以将算法的不同实现定义为单独的类。Java SE 8 包含了可以用于轻量级策略实现的 lambda 方法和方法引用功能:
import java.util.function.Function;
public class Greeter {
private Function<String, String> strategy;
String greet(String name) {
return strategy.apply(name) + ", my name is Duke";
}
public static void main(String[] args) {
Greeter greeter = new Greeter();
Function<String, String> formalGreeting = name -> "Dear " + name;
Function<String, String> informalGreeting = name -> "Hey " + name;
greeter.strategy = formalGreeting;
String greeting = greeter.greet("Java");
System.out.println(greeting);
}
}
示例表明,功能接口可以用于动态定义在运行时应用和选择的策略。
在 Java EE 环境中,我们又可以利用 CDI 依赖注入。为了展示 CDI 支持任何 Java 类型,我们将使用一个表示功能接口的策略的相同示例。问候策略由 Function 类型表示:
public class Greeter {
@Inject
Function<String, String> greetingStrategy;
public String greet(String name) {
return greetingStrategy.apply(name);
}
}
一个 CDI 生产者方法可以动态选择问候策略:
public class GreetingStrategyExposer {
private Function<String, String> formalGreeting = name -> "Dear " + name;
private Function<String, String> informalGreeting = name -> "Hey " + name;
@Produces
public Function<String, String> exposeStrategy() {
// select a strategy
...
return strategy;
}
}
为了完成示例,让我们引入用于算法实现的特定类。CDI 能够注入所有可以动态选择的特定类型的实例。
GreetingStrategy 类型在白天适宜时是可选择的:
public interface GreetingStrategy {
boolean isAppropriate(LocalTime localTime);
String greet(String name);
}
public class MorningGreetingStrategy implements GreetingStrategy {
@Override
public boolean isAppropriate(LocalTime localTime) {
...
}
@Override
public String greet(String name) {
return "Good morning, " + name;
}
}
public class AfternoonGreetingStrategy implements GreetingStrategy { ... }
public class EveningGreetingStrategy implements GreetingStrategy { ... }
CDI 生产者可以注入所有可能的 GreetingStrategy 实例,并根据它们的规范进行选择:
public class GreetingStrategySelector {
@Inject
@Any
Instance<GreetingStrategy> strategies;
@Produces
public Function<String, String> exposeStrategy() {
for (GreetingStrategy strategy : strategies) {
if (strategy.isAppropriate(LocalTime.now()))
return strategy::greet;
}
throw new IllegalStateException("Couldn't find an appropriate greeting");
}
}
@Any限定符在任何一个托管 Bean 上隐式存在。具有Instance类型和此限定符的注入点会注入所有匹配相应类型的实例,这里是指GreetingStrategy。Instance类型允许我们动态获取和限定特定类型的实例。它实现了对所有合格类型的迭代器。
通过提供自定义选择逻辑,我们选择了一个合适的策略,然后将其注入到问候者中。
CDI 提供了多种指定和选择不同策略的方法。根据具体情况,依赖注入可以用来将选择逻辑与使用逻辑分离。
进一步的模式
除了提到的使用特定 Java EE 功能实现的模式之外,还有一些设计模式仍然使用纯 Java 实现,正如 GoF 书中所描述的。提供的列表当然并不完整,但它包括了在企业项目中通常使用的模式。
有些设计模式是 Java EE 的核心,例如代理模式。另一个例子是中介者模式,它封装了一组对象之间的通信。例如,为了设计松耦合的通信,我们不会自己实现这个模式,而是使用实现它的 API 功能,例如 CDI 事件。
还有许多其他模式在 Java EE API 中用得不多,但会使用纯 Java 来实现。根据实际情况,CDI 可以用来支持对象的创建和实例化。这些模式的例子包括原型、构建器、适配器、桥接、组合、享元、责任链、状态和访问者。
再次查看企业 API,我们会发现,例如,在 JSON-P API 中大量使用了构建器模式。我参考了《设计模式》这本书,由四人帮编写,以了解进一步的用法和模式。
领域驱动设计
现在我们已经看到了 GoF 设计模式在 Java EE 时代的实现。除此之外,我想指出一些在我们核心领域应用的模式和概念,然后再继续探讨更纯粹的技术问题。Eric Evans 所著的《领域驱动设计》一书详细描述了这些模式和概念,它们支持构建尽可能准确地匹配实际业务领域的软件模型。特别是,强调了与领域专家沟通、共享通用、无处不在的领域语言、深入理解底层领域模型以及逐步重构它的重要性。领域驱动设计还引入了软件世界中的某些概念,如仓库、服务、工厂或聚合。
现在的问题是,这些概念是否以及如何用 Java 企业版实现?领域驱动设计始终旨在将应用程序的重要方面直接包含到领域模型中,而不是仅仅作为服务或事务脚本的一部分“外部”。我们将看到这一事实如何与 EJB 或 CDI 管理的豆很好地结合。
服务
领域驱动设计语言定义了服务的概念。服务负责编排各种业务逻辑过程。通常,它们是使用案例的入口点,并创建或管理领域模型的对象。服务将单一的业务流程步骤保持在一起。
如果你将这个概念与实体控制边界打包的思想和内容相对应,你会发现它满足了边界或控制相同的用途。因此,在 Java EE 中,这些服务将因此实现为 EJB 或 CDI 管理的豆。代表用例入口点的服务实现为边界;而那些编排进一步业务逻辑、访问数据库或外部系统的服务代表控制。
实体
领域驱动设计还定义了所谓的实体。正如其名称所暗示的,实体本质上代表了业务域实体。这些实体是特定领域内某个概念的可识别实例。用户、文章和汽车都是此类实体的例子。对于领域来说,实体能够被单独识别是很重要的。用户 John Doe 或用户 John Smith 调用某个用例时,这一点是有区别的。这一方面将实体与值对象区分开来。
实体以及其他模型对象,都实现为普通的 Java 类。仅为了业务域的功能,不需要框架支持。理想情况下,实体已经封装了某些在实体类型内部自包含的业务逻辑。这意味着我们不仅会使用具有属性以及 getter 和 setter 方法的简单 POJO 进行建模,还会包含对实体进行操作的业务相关方法。将业务逻辑直接集成到业务实体的核心中,增加了内聚性、理解性,并遵循单一职责原则。
通常,实体以及其他领域模型类型都会保存在数据库中。Java EE 支持使用 JPA 进行对象关系映射,用于持久化和检索对象及其对象层次结构。实际上,用于声明实体类型的 JPA 注解被称为 @Entity。在后续的子章节中,我们将详细看到 JPA 如何以最小的对模型类的影响来支持持久化领域模型类型。
值对象
不形成可识别实体但只包含特定 值 的业务域类型被称为值对象。值对象最好是不可变的,因此是可重用的,因为其内容不能改变。Java 枚举是这种类型的良好例子。任何身份不重要的对象都将实现为值对象。例如,对于 Java 枚举,返回 Status.ACCEPTED 的哪个实例并不重要,这里甚至只有一个枚举实例被用于所有地方。对于领域中的许多类型也是如此,例如地址。只要指向 42 Wallaby Way, Sydney 的地址值保持不变,我们引用哪个地址实例就无关紧要了。
根据值集是否有限,值对象要么被建模为枚举或 POJO,理想情况下是不可变的。不可变性代表了值对象的概念,并减少了潜在错误的可能性。改变多个位置共享的可变对象可能会导致不可预见的副作用。
由于值对象不是直接标识的,因此它们也不会直接在数据库中持久化和管理。它们当然可以作为对象图的一部分间接持久化,从实体或聚合中引用。JPA 支持管理非实体或聚合的对象的持久化。
聚合
聚合是领域驱动设计语言中的一个概念,有时会让开发者感到困惑。聚合是由几个实体或值对象组成的复杂模型,形成一个整体。出于一致性的原因,这个对象集合应该作为一个整体来访问和管理。直接访问某些包含对象的访问方法可能会导致不一致和潜在的错误。聚合背后的想法是为所有操作提供一个根对象。一个很好的例子是由四个轮子、发动机、底盘等组成的汽车。每当需要进行某些操作,如 drive,它将在整个汽车上调用,可能同时涉及多个对象。
聚合是定义对象层次结构根的实体。它们作为包含业务域功能并持有实体和值对象引用的普通 Java 类来实现。
因此,也可以使用 JPA 来持久化聚合。所有持久化操作都是在聚合上触发的,即根对象,并且会级联到其包含的对象上。JPA 支持持久化复杂对象层次结构,我们将在后面的子章节中看到这一点。
仓库
说到数据库访问,领域驱动设计定义了仓库,用于管理实体的持久性和一致性。仓库背后的动机是拥有一个单一的责任点,使得领域模型能够以一致性为前提进行持久化。定义这些功能不应使领域模型代码充斥着持久化实现细节。因此,领域驱动设计定义了仓库的概念,以自给自足和一致的方式封装这些操作。
仓库是特定实体类型持久化操作的入口点。由于只需要识别聚合和实体的实例,因此只有这些类型需要仓库。
在 Java EE 和 JPA 中,已经存在一个与仓库概念很好地匹配的功能,即 JPA 的EntityManager。实体管理器用于持久化、检索和管理定义为实体或潜在对象层次结构的对象。JPA 管理对象需要是可识别的实体的事实完美符合领域驱动设计理念中实体所设定的约束。
实体管理器被注入并用于托管 Bean 中。这符合服务作为边界或控制的想法,即服务旨在编排业务用例,在此处通过调用实体管理器来提供实体的持久性。
工厂
领域驱动设计工厂背后的动机是,创建领域对象可能需要比仅仅调用构造函数更复杂的逻辑和约束。创建一致的领域对象可能需要执行验证或复杂的过程。因此,我们将创建逻辑定义在特定的方法或类中,这些方法或类封装了这种逻辑,使其与领域其他部分隔离开。
这与之前讨论的抽象工厂和工厂方法设计模式背后的动机相同。因此,使用 CDI 特性实现的相同实现也适用于此处。实际上,CDI 规范就是一个工厂功能。
领域对象工厂也可以实现为另一个领域模型类(如实体)的一部分的方法。这些解决方案将完全用 Java 实现,不需要任何框架或注解。在工厂方法设计模式中讨论的汽车驾驶员日志簿功能是工厂方法包含在领域实体中的良好示例。如果领域类本身能够以自给自足的方式提供逻辑,那么在那里包含工厂逻辑也是完全合理的。
领域事件
领域事件代表与业务领域相关的事件。它们通常来自业务用例,并具有特定的领域语义。领域事件的例子包括UserLoggedIn(用户登录)、ActiclePurchased(文章购买)或CoffeeBrewFinished(咖啡煮制完成)。
领域事件通常实现为包含所需信息的值对象。在 Java 中,我们将事件实现为不可变的 POJOs。过去发生的事件不能在以后更改,因此强烈建议使它们不可变。如前所述,我们可以使用 CDI 事件功能以松耦合的方式发布和观察事件。在 CDI 中,所有 Java 类型都可以用作发布事件。因此,领域事件的概念是一个业务定义,而不是技术定义。
领域事件对于事件存储和事件驱动架构尤为重要,我们将在第八章“微服务和系统架构”中广泛讨论。
企业应用程序中的外部和横切关注点
现在我们已经看到了实现应用程序中领域逻辑所需的概念和实现。从理论上讲,实现独立业务逻辑已经足够;然而,如果它们不能从系统外部访问,这些用例将不会为顾客提供太多价值。
因此,让我们来看看由技术驱动的外部和横切关注点。这些功能不是业务域的核心,但同样需要实现。技术驱动的关注点的例子包括访问外部系统或数据库、配置应用程序或缓存。
与外部系统的通信
与外部世界的沟通是企业应用程序最重要的技术方面之一。没有这种沟通,应用程序几乎无法为顾客带来任何价值。
如何选择通信技术
当企业系统需要通信时,就会产生使用哪种通信协议和技术的问题。有许多同步和异步通信形式可供选择。有一些考虑因素需要在开始时考虑。
所选语言和框架支持哪些通信技术?是否存在需要某种形式通信的现有系统?系统是以同步还是异步方式交换信息?工程师团队熟悉哪种解决方案?系统是否位于一个高性能至关重要的环境中?
再次从商业角度来看,系统间的通信是必要的,并且不应该妨碍实施业务用例。话虽如此,信息交换最初应该以直接的方式实现,匹配特定的领域,无论通信是同步还是异步进行的。这些考虑不仅对实际实施有重大影响,而且对整个用例是否与所选解决方案匹配也有很大影响。因此,这是需要首先询问的问题之一,即通信是同步还是异步进行的。同步通信确保了交换信息的一致性和顺序。然而,与异步调用相比,它性能较低,并且不会无限扩展。异步通信导致涉及系统的耦合更松散,提高了整体性能以及开销,并允许系统并非始终可靠可用的情况。出于简单性的考虑,企业应用程序通常使用同步通信,并且也考虑到一致性。
选择的通信方式不仅需要语言和框架的支持,还需要使用到的环境和工具的支持。环境和网络设置对通信是否有任何限制?实际上,这正是过去广泛选择 SOAP 协议的原因之一;它能够通过网络端口80进行传输,这是大多数网络配置所允许的。工具支持,尤其是在开发和调试目的下,是另一个重要的方面。这也是 HTTP 被广泛使用的原因。
在 Java 世界中,可以说大多数现有的通信解决方案都得到了支持,无论是原生支持,如 HTTP,还是通过第三方库。这当然不是其他技术的情况。例如,SOAP 协议就存在这个问题。SOAP 协议的实现实际上只在 Java 和.NET 应用程序中看到。其他技术通常选择不同的通信形式。
通信技术的性能是需要考虑的问题,不仅在高性能环境中。与进程间或进程内通信相比,通过网络交换信息总是引入了巨大的开销。问题是这个开销有多大。这本质上涉及到信息的密度和处理消息或有效负载的性能。交换的信息是二进制格式还是纯文本格式?内容类型代表哪种格式?一般来说,具有高信息密度和低冗余的二进制格式性能更好,并且传输的数据量更小,但调试和理解也更困难。
另一个重要方面是通信解决方案的灵活性。所选技术不应过多地限制信息的交换。理想情况下,协议支持不同的通信方式;例如,同步和异步通信、二进制格式或超媒体。由于我们应用程序的主要关注点是业务逻辑,因此所选技术理想上应适应整体需求。
在今天的系统中,使用最广泛的通信协议是 HTTP。这有几个原因。HTTP 被各种语言平台、框架和库广泛支持。工具选择种类繁多,并且该协议为大多数软件工程师所熟知。HTTP 对如何使用它没有太多限制,因此可以应用于各种信息交换。它可以用于实现同步或异步通信、超媒体或直接调用远程功能,如远程过程调用。然而,HTTP 鼓励某些使用方式。我们将在下一个主题中讨论语义 HTTP、远程过程调用和 REST。
有些通信协议并非必然,但通常是建立在 HTTP 之上的。过去最突出的例子是 SOAP;一个较新的例子是 gRPC。这两种协议都实现了远程过程调用方法。远程过程调用代表了一种在网络上调用另一个系统函数的直接形式。需要指定输入和输出值。SOAP 通过 XML 格式实现了这些远程过程调用,而 gRPC 使用二进制协议缓冲区来序列化数据结构。
根据业务需求中通信的同步或异步行为,强烈建议一致地实现这种行为。一般来说,你应该避免混合同步或异步行为。将包含异步逻辑的服务以同步方式包装是没有意义的。调用者将被阻塞,直到异步过程完成,整个功能将无法扩展。相反,有时使用异步通信来封装长时间运行的同步过程是有意义的。这包括无法更改的外部系统或遗留应用程序。客户端组件将在单独的线程中连接到系统,允许调用线程立即继续。客户端线程要么在同步过程完成前阻塞,要么使用轮询。然而,最好根据业务需求来建模系统和通信风格。
有很多通信协议和格式可供选择,其中很多是专有的。建议工程师们了解不同的概念和一般的通信方式。通信技术会变化,但数据交换的原则是永恒的。截至撰写本书时,HTTP 是最广泛使用的通信协议。这可以说是需要实现的最重要技术之一,它被广泛理解,并且拥有强大的工具支持。
同步 HTTP 通信
今天的企业系统中,大部分同步通信都是通过 HTTP 实现的。企业应用程序暴露出 HTTP 端点,客户端通过这些端点进行访问。这些端点通常以 Web 服务或 Web 前端的形式存在,通过 HTTP 传输 HTML。
Web 服务可以以各种方式设计和指定。在 simplest 的形式中,我们只想通过有线方式调用另一个系统的函数。这个函数需要指定输入和输出值。这些函数或远程过程调用(RPC)在这种情况下通过 HTTP 实现,通常使用 XML 格式来指定参数参数。在 J2EE 时代,这类 Web 服务相当普遍。最突出的例子是 SOAP 协议,它通过 JAX-WS 标准实现。然而,SOAP 协议及其 XML 格式使用起来相当繁琐,并且除了 Java 和.NET 之外的其他语言支持不佳。
在今天的系统中,使用其概念和约束的 REST 架构风格被使用得更为频繁。
表示状态转移
由 Roy T. Fielding 发起的表示状态转移(REST)的理念和约束,提供了一种适合企业应用需求很多方面的 Web 服务架构风格。这些理念导致系统与接口耦合更加松散,接口以统一和直接的方式被各种客户端访问。
REST 的统一接口约束要求在基于 Web 的系统中使用 URI 在请求中标识资源。这些资源代表我们的领域实体;例如,用户或文章,它们通过企业应用程序的 URL 单独标识。也就是说,URL 不再代表 RPC 方法,而是实际的领域实体。这些表示以统一的方式修改,在 HTTP 中使用 GET、POST、DELETE、PATCH 或 PUT 等 HTTP 方法。实体可以以客户端请求的不同格式表示,如 XML 或 JSON。如果服务器支持,客户端可以自由选择是否以 XML 或 JSON 表示访问特定的用户。
统一接口约束的另一个方面是利用超媒体作为应用程序状态的动力。超媒体意味着使用超链接将相关资源链接在一起。传输到客户端的 REST 资源可以包括具有语义链接关系的其他资源的链接。如果某个用户包含有关其经理的信息,则可以使用指向第二个用户(经理)资源的链接来序列化这些信息。
以下是一个包含在 JSON 响应中的超媒体链接的书籍表示示例:
{
"name": "Java",
"author": "Duke",
"isbn": "123-2-34-456789-0",
"_links": {
"self": "https://api.example.com/books/12345",
"author": "https://api.example.com/authors/2345",
"related-books": "https://api.example.com/books/12345/related"
}
}
在为人类设计的网站上,这些链接是主要方面之一。在超媒体 API 中,这些链接被 REST 客户端用于在 API 中导航。可发现性的概念减少了涉及系统的耦合度,并增加了其可扩展性。如果完全接受这一概念,客户端只需知道 API 的入口点,并使用语义链接关系(如related-books)发现可用资源。他们将遵循已知的联系,使用提供的 URL。
在大多数 REST API 中,仅让客户端通过 HTTP GET 方法跟踪链接并获取资源表示是不够的。信息是通过改变状态的方法(如 POST 或 PUT)和包含有效载荷的请求体来交换的。超媒体也支持这些所谓的动作,使用超媒体控制。动作不仅描述目标 URL,还描述 HTTP 方法和发送所需的信息。
以下展示了使用动作概念的一个更复杂的超媒体示例。此示例展示了 Siren 内容类型,旨在让您了解超媒体响应的潜在内容:
{
"class": [ "book" ],
"properties": {
"isbn": "123-2-34-456789-0",
"name": "Java",
"author": "Duke",
"availability": "IN_STOCK",
"price": 29.99
}
"actions": [
{
"name": "add-to-cart",
"title": "Add Book to cart",
"method": "POST",
"href": "http://api.example.com/shopping-cart",
"type": "application/json",
"fields": [
{ "name": "isbn", "type": "text" },
{ "name": "quantity", "type": "number" }
]
}
],
"links": [
{ "rel": [ "self" ], "href": "http://api.example.com/books/1234" }
]
}
这是启用超媒体控制的内容类型的一个示例。在撰写本书时,尚无任何超媒体启用的内容类型,如 Siren、HAL 或 JSON-LD 成为标准或事实上的标准。然而,此 Siren 内容类型应足以传达链接和动作的概念。
使用超媒体将客户端与服务器解耦。首先,URL 的责任完全在服务器端。客户端不能对 URL 的创建方式做出任何假设;例如,认为书籍资源位于/books/1234下,这是由路径/books/加上书籍 ID 构成的。我们在现实世界的项目中已经看到了许多将这些假设重复到客户端的 URL 逻辑。
解耦的下一个方面是服务器上状态的变化方式。例如,客户端需要向/shopping-cart发送包含特定 JSON 结构的 JSON 内容类型的 POST 指令不再内置于客户端,而是动态检索。客户端将仅通过其关系或名称(此处为add-to-cart)以及操作中提供的信息来引用超媒体操作。通过使用这种方法,客户端只需要知道“添加到购物车”操作的业务含义以及所需 ISBN 和数量字段的来源。这无疑是客户端逻辑。字段值可以从资源表示本身或从客户端过程中检索。例如,书籍的数量可以以 UI 中的下拉字段的形式呈现。
使用超媒体的一个潜在优势是将业务逻辑与客户端解耦。通过使用链接和操作来引导客户端访问可用资源,可用链接和操作中包含的信息被隐式地告知客户端,在当前系统状态下哪些用例是可能的。例如,假设只有具有特定可用性的书籍才能添加到购物车中。实现此行为的客户端,即仅在这些情况下显示“添加到购物车”按钮的客户端,需要了解此逻辑。客户端功能随后将检查书籍的可用性是否符合标准,等等。从技术上讲,这种业务逻辑应仅位于服务器端。通过动态提供链接和操作到可用资源,服务器规定在当前状态下哪些功能是可能的。因此,“添加到购物车”操作只有在书籍实际上可以添加到购物车时才会被包含。因此,客户端逻辑简化为检查是否包含具有已知关系或名称的链接和操作。因此,客户端只有在响应中提供了相应的操作时,才会显示一个活动的“添加到购物车”按钮。
随着 Java EE 的出现,REST 架构风格越来越受到关注。虽然大多数现有的 Web 服务并没有实现 REST 架构风格定义的所有约束,特别是超媒体,但它们通常被认为是 REST 服务。
关于 REST 约束的更多信息,我建议您参考 Roy T. Fielding 的论文《Architectural Styles and the Design of Network-based Software Architectures》。
Java API for RESTful web services
在 Java EE 中,Java API for RESTful web services(JAX-RS)用于定义和访问 REST 服务。JAX-RS 在 Java 生态系统中被广泛使用,甚至被其他企业技术所使用。开发者特别喜欢声明式开发模型,这使得以高效的方式开发 REST 服务变得容易。
所说的 JAX-RS 资源指定了在特定 URL 下可用的 REST 资源。JAX-RS 资源是资源类中的方法,在通过特定 HTTP 方法访问 URL 时实现业务逻辑。以下是一个用户 JAX-RS 资源类的示例:
import javax.ws.rs.Path;
import javax.ws.rs.GET;
import javax.ws.rs.Produces;
import javax.ws.rs.core.MediaType;
@Path("users")
@Produces(MediaType.APPLICATION_JSON)
public class UsersResource {
@Inject
UserStore userStore;
@GET
public List<User> getUsers() {
return userStore.getUsers();
}
}
getUsers() 方法是 JAX-RS 资源方法,当客户端执行 HTTP 调用 GET .../users 时,容器将调用此方法。随后,用户列表以 JSON 格式返回给客户端,即包含每个用户 JSON 对象的 JSON 数组。这是通过 @Produces 注解指定的,在这里将隐式使用 Java API for JSON Binding (JSON-B) 将 Java 类型映射到其相应的 JSON 表示形式。
在这里,你可以看到控制反转原则的应用。我们不需要自己连接或注册 URL,使用 @Path 注解的声明就足够了。对于将 Java 类型映射到表示形式,如 JSON,也是如此。我们以声明的方式指定我们想要提供的表示格式。其余的由容器处理。JAX-RS 实现还负责所需的 HTTP 通信。通过返回一个对象,这里是指用户列表,JAX-RS 隐式假设 HTTP 状态码 200 OK,并将其与我们的 JSON 表示形式一起返回给客户端。
为了将 JAX-RS 资源注册到容器中,应用程序可以发送 Application 的子类,该子类启动 JAX-RS 运行时。使用 @ApplicationPath 注解自动将提供的路径注册为 Servlet。以下是一个 JAX-RS 配置类的示例,这对于大多数用例来说是足够的:
import javax.ws.rs.ApplicationPath;
import javax.ws.rs.core.Application;
@ApplicationPath("resources")
public class JAXRSConfiguration extends Application {
// no configuration required
}
JAX-RS,以及 Java EE 伞下的其他标准,都使用了约定优于配置的原则。此 REST 资源默认行为对于大多数用例来说是合理的。如果不满足要求,则可以通过自定义逻辑覆盖默认行为。这也是为什么 JAX-RS 等框架提供了高效的编程模型。默认情况可以非常快速地实现,同时还有进一步扩展的选项。
让我们来看一个更全面的例子。假设我们想要在由客户端使用我们的 REST 服务提供的系统中创建一个新用户。遵循 HTTP 语义,这个操作将是对用户资源的 POST 请求,因为我们正在创建一个可能尚未被识别的新资源。POST 方法和 PUT 方法之间的区别在于,后者是全能的,只更改由提供的表示形式访问的资源,而 POST 将创建以新 URL 形式的新资源。这里就是这种情况。我们正在创建一个新用户,该用户将可以通过一个新生成的 URL 来识别。如果新用户的资源被创建,客户端应被引导到该 URL。对于创建资源,这通常通过201 Created状态码来实现,该状态码表示已成功创建新资源,以及包含资源所在 URL 的Location头。
为了满足这一要求,我们必须在我们的 JAX-RS 资源中提供更多信息。以下是如何在createUser()方法中实现这一点的示例:
import javax.ws.rs.Consumes;
import javax.ws.rs.PathParam;
import javax.ws.rs.POST;
import javax.ws.rs.core.Context;
import javax.ws.rs.core.Response;
import javax.ws.rs.core.UriInfo;
@Path("users")
@Produces(MediaType.APPLICATION_JSON)
@Consumes(MediaType.APPLICATION_JSON)
public class UsersResource {
@Inject
UserStore userStore;
@Context
UriInfo uriInfo;
@GET
public List<User> getUsers() {
return userStore.getUsers();
}
@GET
@Path("{id}")
public User getUser(@PathParam("id") long id) {
return userStore.getUser(id);
}
@POST
public Response createUser(User user) {
long id = userStore.create(user);
URI userUri = uriInfo.getBaseUriBuilder()
.path(UsersResource.class)
.path(UsersResource.class, "getUser")
.build(id);
return Response.created(userUri).build();
}
}
我们利用了 JAX-RS 中包含的UriInfo功能,这样我们就不需要在构建新 URL 时重复自己。该功能使用资源类注解中已经存在的路径信息。Response方法用于通过构建模式方法指定实际的 HTTP 响应。JAX-RS 注意到我们方法的返回类型现在是一个响应规范,并将相应地响应用户。通过这种方法,我们对客户端的响应有完全的控制和灵活性。
如您所见,这些方法是我们的业务用例的入口点。我们注入了UserStore边界,在我们的情况下,它被实现为 EJB,提供返回用户列表和创建新用户的逻辑。
JAX-RS 提供了一种高效且直接的方法来通过 RESTful Web 服务公开业务功能。如果默认行为足够,开发者不需要编写任何低级 HTTP 管道代码。
映射 HTTP 内容类型
为了尽可能给开发者提供生产力,Java EE 包括将 POJO 透明映射到 JSON 或 XML 的标准。您看到的 JAX-RS 示例隐式使用了 JSON-B 将我们的User类型映射到 JSON 对象和数组。
这再次使用了约定优于配置的原则。如果没有其他指定,JSON-B 假定将 POJO 属性直接映射为 JSON 对象键值对。用户的id也出现在 JSON 输出中。
对于 Java Architecture for XML Binding(JAXB)及其 XML 绑定,这种情况同样适用,它比 JSON-B 更早地包含在 Java EE 中。这两个标准都支持使用放置在映射的 Java 类型上的注解的声明性配置方法。如果我们即将更改类型的 JSON 表示,我们可以注释相应的字段:
import javax.json.bind.annotation.JsonbProperty;
import javax.json.bind.annotation.JsonbTransient;
public class User {
@JsonbTransient
private long id;
@JsonbProperty("username")
private String name;
...
}
如果我们想要实现更复杂的资源映射,例如在之前展示的超媒体书籍示例中,我们可以使用声明性映射方法来实现。例如,要将链接映射到书籍资源,我们可以使用包含链接和链接关系的映射:
public class Book {
@JsonbTransient
private long id;
private String name;
private String author;
private String isbn;
@JsonbProperty("_links")
private Map<String, URI> links;
...
}
这些链接已在 JAX-RS 资源中适当设置:
@Path("books")
@Produces(MediaType.APPLICATION_JSON)
public class BooksResource {
@Inject
BookStore bookStore;
@Context
UriInfo uriInfo;
@GET
public List<Book> getBooks() {
List<Book> books = bookStore.getBooks();
books.forEach(this::addLinks);
return books;
}
@GET
@Path("{id}")
public Book getBook(@PathParam("id") long id) {
Book book = bookStore.getBook(id);
addLinks(book);
return book;
}
private void addLinks(Book book) {
URI selfUri = uriInfo.getBaseUriBuilder()
.path(BooksResource.class)
.path(BooksResource.class, "getBook")
.build(book.getId());
book.getLinks().put("self", selfUri);
// other links
}
}
书籍列表的输出将类似于以下内容:
[
{
"name": "Java",
"author": "Duke",
"isbn": "123-2-34-456789-0",
"_links": {
"self": "https://api.example.com/books/12345",
"author": "https://api.example.com/authors/2345",
"related-books": "https://api.example.com/books/12345/related"
}
},
...
]
使用这种方法,我们现在可以以编程方式引入客户端内使用和跟踪的关系链接。然而,使用超媒体方法很快就会达到这样一个点,即声明性映射给模型带来了太多的开销。链接和关系的映射已经不再是业务领域的一部分,而是一种技术必要性,因此应该受到质疑。我们可以引入传输对象类型,将技术映射与领域模型分开。但这肯定会引入大量的重复,并使我们的项目充斥着许多对业务无价值的类。
另一个需要面对的挑战是超媒体所需的灵活性。即使是使用超媒体控制的简单示例,我们也希望根据系统的当前状态指定和包含链接和操作。超媒体的本质就是控制客户端的流程并将它们引导到某些资源。例如,如果一本书有库存或账户上有一定的信用额度,客户端响应应仅包括下订单的操作。这要求响应映射可以根据需求进行更改。由于声明性映射在运行时无法轻易更改,我们需要一个更灵活的方法。
自从 Java EE 7 以来,就有 Java API for JSON Processing(JSON-P)标准,它以类似构建器模式的方式提供程序化映射 JSON 结构。我们可以简单地调用构建器类型 JsonObjectBuilder 或 JsonArrayBuilder 来创建任意复杂的结构:
import javax.json.Json;
import javax.json.JsonObject;
...
JsonObject object = Json.createObjectBuilder()
.add("hello", Json.createArrayBuilder()
.add("hello")
.build())
.add("key", "value")
.build();
生成的 JSON 对象如下所示:
{
"hello": [
"hello"
],
"key": "value"
}
尤其是在需要大量灵活性的情况下,例如在超媒体中,这种方法非常有帮助。JSON-P 标准,以及 JSON-B 或 JAXB,与 JAX-RS 无缝集成。返回 JSON-P 类型(如 JsonObject)的 JAX-RS 资源方法将自动返回相应的响应内容类型。无需进一步配置。让我们看看包含资源链接的示例是如何使用 JSON-P 实现的。
import javax.json.JsonArray;
import javax.json.stream.JsonCollectors;
@Path("books")
public class BooksResource {
@Inject
BookStore bookStore;
@Context
UriInfo uriInfo;
@GET
public JsonArray getBooks() {
return bookStore.getBooks().stream()
.map(this::buildBookJson)
.collect(JsonCollectors.toJsonArray());
}
@GET
@Path("{id}")
public JsonObject getBook(@PathParam("id") long id) {
Book book = bookStore.getBook(id);
return buildBookJson(book);
}
private JsonObject buildBookJson(Book book) {
URI selfUri = uriInfo.getBaseUriBuilder()
.path(BooksResource.class)
.path(BooksResource.class, "getBook")
.build(book.getId());
URI authorUri = ...
return Json.createObjectBuilder()
.add("name", book.getName())
.add("author", book.getName())
.add("isbn", book.getName())
.add("_links", Json.createObjectBuilder()
.add("self", selfUri.toString())
.add("author", authorUri.toString()))
.build();
}
}
JSON-P 对象是通过使用构建器模式方法动态创建的。我们对所需的输出有完全的灵活性。如果通信需要一个与当前模型不同的实体表示,这种方法使用 JSON-P 也是可取的。在过去,项目总是引入传输对象或 DTO 来实现这个目的。在这里,JSON-P 对象实际上是传输对象。通过使用这种方法,我们消除了需要另一个类来复制模型实体大多数结构的需要。
然而,这个例子中也有一些重复。现在,结果 JSON 对象的属性名由字符串提供。为了稍微重构这个例子,我们会引入一个单一责任点,例如一个负责从模型实体创建 JSON-P 对象的托管豆。
例如,这个EntityBuilder豆将被注入到这个和其他 JAX-RS 资源类中。然后重复仍然存在,但封装在单一责任点中,并从多个资源类中重用。以下代码显示了用于书籍和可能映射到 JSON 的其他对象的示例EntityBuilder。
public class EntityBuilder {
public JsonObject buildForBook(Book book, URI selfUri) {
return Json.createObjectBuilder()
...
}
}
如果某些端点或外部系统的表示与我们的模型不同,没有其他缺点的情况下,我们无法完全避免重复。通过使用这种方法,我们将映射逻辑从模型中解耦,并具有完全的灵活性。POJO 属性的映射发生在构建器模式调用中。与引入单独的传输对象类并在另一个功能中映射它们相比,这导致更少的混淆,最终代码更少。
让我们再次使用添加到购物车 Siren 动作来举例说明超媒体。这个例子给出了超媒体响应潜在内容的想法。对于这样的响应,输出需要是动态和灵活的,取决于应用程序的状态。现在我们可以想象程序化映射方法(如 JSON-P)的灵活性和强度。这种输出实际上不适用于声明性 POJO 映射,这会引入一个相当复杂的对象图。在 Java EE 中,建议使用 JSON-P 或第三方依赖项来实现所需的内容类型,以实现单一职责。
对于将 Java 对象映射到 JSON 或 XML 有效载荷,JAXB、JSON-B 和 JSON-P 提供了与其他 Java EE 标准的无缝集成,例如 JAX-RS。除了我们刚刚看到的 JAX-RS 集成之外,我们还可以集成 CDI 注入;这种互操作性对所有现代 Java EE 标准都适用。
JSON-B 类型适配器能够映射 JSON-B 所不知道的自定义 Java 类型。它们将自定义 Java 类型转换为已知和可映射的类型。一个典型的例子是将对象引用序列化为标识符:
import javax.json.bind.annotation.JsonbTypeAdapter;
public class Employee {
@JsonbTransient
private long id;
private String name;
private String email;
@JsonbTypeAdapter(value = OrganizationTypeAdapter.class)
private Organization organization;
...
}
在organization字段上指定的类型适配器用于将引用表示为组织的 ID。为了解析该引用,我们需要查找有效的组织。这个功能可以简单地注入到 JSON-B 类型适配器中:
import javax.json.bind.adapter.JsonbAdapter;
public class OrganizationTypeAdapter implements JsonbAdapter<Organization, String> {
@Inject
OrganizationStore organizationStore;
@Override
public String adaptToJson(Organization organization) {
return String.valueOf(organization.getId());
}
@Override
public Organization adaptFromJson(String string) {
long id = Long.parseLong(string);
Organization organization = organizationStore.getOrganization(id);
if (organization == null)
throw new IllegalArgumentException("Could not find organization for ID " + string);
return organization;
}
}
此示例已经展示了拥有几个相互协作的标准的好处。开发者可以简单地使用和集成这些功能,而无需花费时间在配置和管道上。
验证请求
JAX-RS 为我们系统提供了 HTTP 端点的集成。这包括将请求和响应映射到我们应用程序的 Java 类型。然而,为了防止系统被滥用,客户端请求需要进行验证。
Bean Validation标准提供了各种类型的验证。其想法是将验证约束,如此字段不能为 null、此整数不能为负或此加薪必须符合公司政策,声明为 Java 类型和属性。该标准已经包含了通常所需的技术驱动约束。可以添加自定义约束,特别是那些由业务功能或验证驱动的约束。这不仅从技术角度,而且从领域角度来看都很有趣。可以使用此标准实现由领域驱动的验证逻辑。
通过注解方法参数、返回类型或属性为@Valid来激活验证。虽然验证可以在应用程序的许多地方应用,但对于端点来说尤为重要。将@Valid注解到 JAX-RS 资源方法参数上会验证请求体或参数。如果验证失败,JAX-RS 会自动以表示客户端错误的 HTTP 状态码响应 HTTP 请求。
以下演示了用户验证的集成:
import javax.validation.Valid;
import javax.validation.constraints.NotNull;
@Path("users")
@Produces(MediaType.APPLICATION_JSON)
@Consumes(MediaType.APPLICATION_JSON)
public class UsersResource {
...
@POST
public Response createUser(@Valid @NotNull User user) {
...
}
}
用户类型被注解了验证约束:
import javax.validation.constraints.Email;
import javax.validation.constraints.NotBlank;
public class User {
@JsonbTransient
private long id;
@NotBlank
private String name;
@Email
private String email;
...
}
放置在 JAX-RS 方法上的注解告诉实现,一旦客户端请求到达,就立即验证请求体。请求体必须可用,不能为null,并且根据用户类型的配置是有效的。用户的姓名属性限制为不能为空;也就是说,它不应该为null或只包含空白字符。用户的电子邮件属性必须符合有效的电子邮件地址格式。这些约束在验证用户对象时生效。
在内部,Bean Validation 中包含的Validator用于验证对象。如果验证失败,验证器将抛出ConstraintViolationException异常。此验证器功能也可以通过依赖注入以编程方式获得。如果验证失败,JAX-RS 会自动调用验证器并向客户端发送适当的响应。
这个例子在非法 HTTP POST 调用到/users/资源时将失败,例如提供没有名称的用户表示。这导致400 Bad Request状态码,这是 JAX-RS 对失败的客户端验证的默认行为。
如果客户端需要更多关于请求被拒绝原因的信息,可以扩展默认行为。验证器抛出的违反异常可以映射到带有 JAX-RS 异常映射器功能的 HTTP 响应。异常映射器处理从 JAX-RS 资源方法抛出的异常,并将其转换为适当的客户端响应。以下是一个针对ConstraintViolationExceptions的此类ExceptionMapper的示例:
import javax.validation.ConstraintViolationException;
import javax.ws.rs.ext.ExceptionMapper;
import javax.ws.rs.ext.Provider;
@Provider
public class ValidationExceptionMapper implements ExceptionMapper<ConstraintViolationException> {
@Override
public Response toResponse(ConstraintViolationException exception) {
Response.ResponseBuilder builder = Response.status(Response.Status.BAD_REQUEST);
exception.getConstraintViolations()
.forEach(v -> {
builder.header("Error-Description", ...);
});
return builder.build();
}
}
异常映射器是 JAX-RS 运行时的提供者。提供者要么在 JAX-RS 基本应用程序类中通过编程方式配置,或者,如这里所示,使用@Provider注解以声明方式配置。JAX-RS 运行时会扫描类以查找提供者,并自动应用它们。
异常映射器已注册用于给定的异常类型及其子类型。这里由 JAX-RS 资源方法抛出的所有约束违反异常都将映射到客户端响应,包括导致验证失败的字段的基本描述。违反消息是 Bean Validation 提供的一种功能,提供人类可读的、全局的消息。
如果内置的验证约束不足以进行验证,可以使用自定义验证约束。这对于特定于域的验证规则尤其必要。例如,用户名可能需要基于系统当前状态进行更复杂的验证。在这个例子中,创建新用户时用户名不得被占用。还可以设置格式或允许字符的其他约束,显然:
public class User {
@JsonbTransient
private long id;
@NotBlank
@UserNameNotTaken
private String name;
@Email
private String email;
...
}
@UserNameNotTaken注解是由我们的应用程序定义的自定义验证约束。验证约束委托给约束验证器,即实际执行验证的类。约束验证器可以访问注解对象,例如本例中的类或字段。自定义功能检查提供的对象是否有效。验证方法可以使用ConstraintValidatorContext来控制自定义违反,包括消息和更多信息。
以下显示了自定义约束定义:
import javax.validation.Constraint;
import javax.validation.Payload;
@Constraint(validatedBy = UserNameNotTakenValidator.class)
@Documented
@Retention(RUNTIME)
@Target({METHOD, FIELD, ANNOTATION_TYPE, CONSTRUCTOR, PARAMETER, TYPE_USE})
public @interface UserNameNotTaken {
String message() default "";
Class<?>[] groups() default {};
Class<? extends Payload>[] payload() default {};
}
我们的约束条件由UserNameNotTakenValidator类验证:
import javax.validation.ConstraintValidator;
import javax.validation.ConstraintValidatorContext;
public class UserNameNotTakenValidator implements ConstraintValidator<UserNameNotTaken, String> {
@Inject
UserStore userStore;
public void initialize(UserNameNotTaken constraint) {
// nothing to do
}
public boolean isValid(String string, ConstraintValidatorContext context) {
return !userStore.isNameTaken(string);
}
}
与其他标准一样,约束验证器可以使用依赖注入来使用托管豆。这对于需要调用控件的自定义验证逻辑来说通常是非常需要的。在这个例子中,验证器注入了UserStore。再次强调,我们可以在 Java EE 的范围内重用不同的标准。
自定义验证约束通常是由业务领域驱动的。将复杂的、组合的验证逻辑封装到这样的自定义约束中是有意义的。当应用这种方法时,它也利用了单一职责原则,将验证逻辑分离成一个单独的验证器,而不是分散在原子约束中。
Bean Validation 为需要为同一类型提供不同验证方式的场景提供了更复杂的功能。因此,使用组的概念将某些约束组合在一起形成组,这些组可以单独进行验证。关于这方面的更多信息,我建议读者参考 Bean Validation 规范。
如前所述,HTTP JSON 负载也可以使用 JSON-P 标准在 JAX-RS 中进行映射。这同样适用于 HTTP 请求体。请求体参数可以作为包含 JSON 结构的 JSON-P 类型提供,这些结构是动态读取的。同样,如果对象结构与模型类型不同或需要更多的灵活性,使用 JSON-P 类型表示请求体也是有意义的。对于这种情况,验证提供的对象尤为重要,因为 JSON-P 结构可以是任意的。为了依赖请求对象上存在某些 JSON 属性,这些对象使用自定义验证约束进行验证。
由于 JSON-P 对象是程序性构建的,并且没有预定义的类型,程序员没有像 Java 类型那样注释字段的方法。因此,在请求体参数上使用自定义验证约束。自定义约束定义了特定请求体的有效 JSON 对象的结构。以下代码展示了在 JAX-RS 资源方法中集成验证的 JSON-P 类型:
@Path("users")
@Produces(MediaType.APPLICATION_JSON)
@Consumes(MediaType.APPLICATION_JSON)
public class UsersResource {
...
@POST
public Response createUser(@Valid @ValidUser JsonObject json) {
User user = readUser(json);
long id = userStore.create(user);
...
}
private User readUser(JsonObject object) {
...
}
}
自定义验证约束 ValidUser 引用了所使用的约束验证器。由于提供的 JSON-P 对象的结构是任意的,验证器必须检查属性的存在和类型:
@Constraint(validatedBy = ValidUserValidator.class)
@Documented
@Retention(RUNTIME)
@Target({METHOD, FIELD, ANNOTATION_TYPE, CONSTRUCTOR, PARAMETER, TYPE_USE})
public @interface ValidUser {
String message() default "";
Class<?>[] groups() default {};
Class<? extends Payload>[] payload() default {};
}
自定义约束验证器也适用于 JSON-P 类型:
public class ValidUserValidator implements ConstraintValidator<ValidUser, JsonObject> {
public void initialize(ValidUser constraint) {
// nothing to do
}
public boolean isValid(JsonObject json, ConstraintValidatorContext context) {
...
}
}
在提供的 JSON-P 对象经过验证后,定义的属性可以安全地提取。这个例子展示了如何在 JAX-RS 方法中集成和验证灵活的、程序化的类型。资源类将请求体提取到领域实体类型中,并使用边界来调用业务用例。
映射错误
如我们在前面的例子中所看到的,JAX-RS 提供了将异常映射到自定义响应的能力。这是一个有用的功能,可以在不影响生产代码工作流程的情况下实现透明的自定义错误处理。
处理 EJB 时常见的问题是在任何非 EJB 上下文中访问时,任何抛出的异常都会被包装在EJBException中;例如,请求作用域的 JAX-RS 资源。这使得异常处理变得相当繁琐,因为必须解包EJBException以检查原因。
通过使用@ApplicationException注解自定义异常类型,不会将原因包装:
import javax.ejb.ApplicationException;
@ApplicationException
public class GreetingException extends RuntimeException {
public GreetingException(String message) {
super(message);
}
}
调用一个抛出GreetingException的 EJB 不会导致包装EJBException并直接产生异常类型。然后,应用程序可以定义一个针对实际GreetingException类型的 JAX-RS 异常映射器,类似于映射约束违规的映射器。
指定@ApplicationException(rollback = true)将导致容器在异常发生时回滚活动事务。
访问外部系统
我们已经看到我们的业务领域是如何通过 HTTP 从外部访问的。
为了执行业务逻辑,大多数企业应用还需要访问其他外部系统。外部系统不包括我们应用拥有的数据库。通常,外部系统位于应用域之外。它们存在于另一个边界上下文中。
为了访问外部 HTTP 服务,我们将客户端组件集成到我们的项目中,通常作为一个单独的控制。这个控制类封装了与外部系统通信所需的功能。建议仔细构建接口,不要将领域关注点与通信实现细节混合。这些细节包括潜在的负载映射、通信协议、如果使用 HTTP,则包括 HTTP 信息,以及任何与核心领域无关的其他方面。
JAX-RS 附带了一个复杂的客户端功能,以生产方式访问 HTTP 服务。它为资源类提供了相同类型的映射功能。以下代码表示一个控制,它访问外部系统来订购咖啡豆:
import javax.annotation.PostConstruct;
import javax.annotation.PreDestroy;
import javax.enterprise.context.ApplicationScoped;
import javax.ws.rs.client.*;
import java.util.concurrent.TimeUnit;
@ApplicationScoped
public class CoffeePurchaser {
private Client client;
private WebTarget target;
@PostConstruct
private void initClient() {
client = ClientBuilder.newClient();
target = client.target("http://coffee.example.com/beans/purchases/");
}
public OrderId purchaseBeans(BeanType type) {
// construct purchase payload from type
Purchase purchase = ...
BeanOrder beanOrder = target
.request(MediaType.APPLICATION_JSON_TYPE)
.post(Entity.json(purchase))
.readEntity(BeanOrder.class);
return beanOrder.getId();
}
@PreDestroy
public void closeClient() {
client.close();
}
}
JAX-RS 客户端由客户端构建器构建并配置,使用 Web 目标来访问 URL。这些目标可以使用 URI 构建器功能进行修改,类似于 JAX-RS 资源中的功能。目标用于构建新的调用,这些调用代表实际的 HTTP 调用。调用可以根据 HTTP 信息进行配置,例如内容类型、头信息,以及映射的 Java 类型的特定细节。
在这个例子中,指向外部 URL 的目标会使用 HTTP POST 方法构建一个新的针对 JSON 内容类型的请求。预期的返回 JSON 结构应可映射到BeanOrder对象。客户端执行进一步逻辑以提取必要的信息。
容器关闭时,客户端实例将在@PreDestroy方法中正确关闭,以防止资源泄露。
消费 HTTP 时的稳定性
然而,这个例子在弹性方面缺少一些方面。在没有进一步考虑的情况下,将其称为客户端控制可能会导致不受欢迎的行为。
客户端请求会阻塞,直到 HTTP 调用成功返回或连接超时。HTTP 连接超时配置取决于 JAX-RS 实现,在某些技术中设置为无限阻塞。对于具有弹性的客户端来说,这显然是不可接受的。连接可能会永远等待,阻塞线程,在最坏的情况下,如果所有可用线程都卡在那个位置,等待它们各自的 HTTP 连接完成,可能会阻塞整个应用程序。为了防止这种情况,我们配置客户端使用自定义连接超时。
超时值取决于应用程序,特别是到外部系统的网络配置。HTTP 超时的合理值各不相同。为了获得合理的超时值,建议收集到外部系统的延迟统计数据。对于负载和网络延迟变化很大的系统,例如在特定季节选择性高利用率的电子商务系统,应考虑变化的性质。
HTTP 连接超时是指建立连接之前允许的最大时间。其值应该较小。HTTP 读取超时指定了读取数据需要等待的时间。其值取决于所消费的外部服务的性质。根据收集到的统计数据,配置读取超时的一个良好起点是计算平均响应时间加上三倍的标准差。我们将在第九章监控、性能和日志中介绍性能和服务背压的主题。
下面的示例展示了如何配置 HTTP 连接和读取超时:
@ApplicationScoped
public class CoffeePurchaser {
...
@PostConstruct
private void initClient() {
client = ClientBuilder.newBuilder()
.connectTimeout(100, TimeUnit.MILLISECONDS)
.readTimeout(2, TimeUnit.SECONDS)
.build();
target = client.target("http://coffee.example.com/beans/purchases/");
}
...
}
客户端调用可能会导致潜在的错误。外部服务可能会返回意外的状态码、意外的响应或根本不响应。在实现客户端组件时需要考虑这一点。
readResponse()客户端调用期望响应为 HTTP 状态码SUCCESSFUL家族,并且响应体可以从请求的内容类型映射到给定的 Java 类型。如果出现问题,将抛出RuntimeException。运行时异常使工程师能够编写不混淆 try-catch 块的代码,但也要求他们意识到潜在的错误。
客户端方法可以捕获运行时异常,以防止它们被抛向调用域服务。还有另一种更简洁的可能性,即使用拦截器。拦截器提供跨切面功能,这些功能在应用时不需要紧密耦合到装饰功能。例如,当外部系统无法提供合理的响应时,这个客户端方法应该故意返回null。
下面的示例演示了一个拦截器,该拦截器拦截方法调用并在发生的异常上应用此行为。此拦截器通过注解 CoffeePurchaser 控制的方方法进行集成:
import javax.interceptor.AroundInvoke;
import javax.interceptor.Interceptor;
import javax.interceptor.InvocationContext;
@Interceptor
public class FailureToNullInterceptor {
@AroundInvoke
public Object aroundInvoke(InvocationContext context) {
try {
return context.proceed();
} catch (Exception e) {
...
return null;
}
}
}
purchaseBean() 方法被注解为 @Interceptors(FailureToNullInterceptor.class)。这激活了针对该方法的横切关注点。
在容错方面,客户端功能可以包括更多逻辑。如果有多个系统可用,客户端可以在不同的系统上重试失败的调用。然后,只有在最后手段的情况下,调用才会失败且没有结果。
在 横切关注点 这一主题中,我们将看到如何实现更多的横切关注点。
访问超媒体 REST 服务
应用 REST 约束的 HTTP Web 服务,特别是在超媒体方面,需要在客户端侧有更复杂的逻辑。服务指导客户端访问需要以特定方式访问的相应资源。超媒体解耦服务并使 API 功能,如可扩展性和发现成为可能,但也需要在客户端侧有更多动态和逻辑。
之前给出的 Siren 内容类型示例给人留下了服务响应如何指导 REST 客户端进行后续调用的印象。假设客户端检索订单的响应并希望跟随 add-to-cart 动作:
{
... example as shown before
... properties of book resource
"actions": [
{
"name": "add-to-cart",
"title": "Add Book to cart",
"method": "POST",
"href": "http://api.example.com/shopping-cart",
"type": "application/json",
"fields": [
{ "name": "isbn", "type": "text" },
{ "name": "quantity", "type": "number" }
]
}
],
"links": ...
}
客户端仅耦合于对 add-to-cart 动作的业务含义以及如何为 ISBN 和数量提供字段值信息的了解。这当然是需要实现客户端领域逻辑。关于如何使用哪种 HTTP 方法访问后续资源(购物车),以及内容类型现在是动态的而不是嵌入到客户端中的信息。
为了将书籍添加到购物车,客户端首先访问书籍的资源。随后调用 add-to-cart 用例,提取指定超媒体动作的信息。所需字段的信息需要通过调用提供。然后客户端访问第二个资源,使用 REST 服务和控制调用提供的信息:
public class BookClient {
@Inject
EntityMapper entityMapper;
public Book retrieveBook(URI uri) {
Entity book = retrieveEntity(uri);
return entityMapper.decodeBook(uri, book.getProperties());
}
public void addToCart(Book book, int quantity) {
Entity bookEntity = retrieveEntity(book.getUri());
JsonObjectBuilder properties = Json.createObjectBuilder();
properties.add("quantity", quantity);
Entity entity = entityMapper.encodeBook(book);
entity.getProperties().forEach(properties::add);
performAction(bookEntity, "add-to-cart", properties.build());
}
private Entity retrieveEntity(URI uri) {
...
}
private void performAction(Entity entity, String actionName,
JsonObject properties) {
...
}
}
Entity 类型封装了超媒体实体类型的信息。EntityMapper 负责将内容类型映射到领域模型,反之亦然。在这个例子中,动作所需的所有字段都来自资源的属性以及提供的 quantity 参数。为了实现某种动态性,所有实体属性都被添加到一个映射中,并传递给 performAction() 方法。根据服务器指定的动作,所需字段从这个映射中提取出来。如果需要更多字段,客户端逻辑显然必须更改。
将访问超媒体服务的逻辑以及将领域模型映射到内容类型封装到单独的委托中,这确实是有意义的。访问 REST 服务的功能也可以合理地由库来替代。
你可能会注意到 URI 现在已经泄露到客户端类的公共接口中。这并非偶然,而是为了在多个用例调用中识别资源。尽管如此,URI 作为资源的通用标识符进入业务领域。由于从技术 ID 创建 URL 的逻辑位于客户端,因此实体资源的整个 URL 成为标识符。然而,在设计客户端控件时,工程师应该注意公共接口。特别是,关于与外部系统通信的信息不应泄露到领域。使用超媒体很好地支持了这种做法。所有必需的传输信息都是动态检索和使用的。遵循超媒体响应的导航逻辑位于客户端控件中。
本例旨在让读者了解客户端如何使用超媒体 REST 服务。
异步通信和消息
异步通信导致系统之间的耦合更加松散。它通常会增加整体响应性以及开销,并使系统在不可靠的情况下也能正常工作的场景成为可能。在概念或技术层面上,存在许多设计异步通信的方式。异步通信并不意味着在技术层面上不能进行同步调用。业务流程可以以异步方式构建,模拟一个或多个未立即执行或处理同步调用。例如,API 可以提供同步方法来创建长时间运行的过程,稍后经常轮询更新。
在技术层面上,异步通信通常以消息为导向的方式设计,使用消息队列或发布-订阅模式实现。应用程序仅直接与消息队列或代理进行通信,消息不会直接传递给特定的接收者。
让我们来看看实现异步通信的各种方法。
异步 HTTP 通信
HTTP 通信的请求响应模型通常涉及同步通信。客户端在服务器上请求资源,并阻塞直到响应被传输。因此,使用 HTTP 的异步通信通常在概念上实现。同步 HTTP 调用可以触发长时间运行的业务流程。外部系统随后可以通过另一种机制通知调用者,或者提供轮询更新的功能。
例如,一个复杂的用户管理系统提供了创建用户的方法。假设用户需要作为更长运行异步业务流程的一部分在外部系统中注册和验证。那么应用程序将提供 HTTP 功能,如POST /users/,这启动了创建新用户的流程。然而,调用这个用例并不能保证用户能够成功创建和注册。该 HTTP 端点的响应只会确认尝试创建新用户;例如,通过202 已接受状态码。这表示请求已被接受,但并不一定已经完全处理。Location头字段可以用来指向客户端可以轮询更新部分完成用户资源的地址。
在技术层面上,HTTP 不仅支持同步调用。在子章节 服务器发送事件 中,我们将以服务器发送事件为例,探讨作为一个使用异步消息导向通信的 HTTP 标准的例子。
消息导向通信
消息导向通信通过异步发送的消息交换信息,通常使用消息队列或发布-订阅模式实现。它提供了解耦系统的优势,因为应用程序只直接与消息队列或代理进行通信。这种解耦不仅影响对系统和所用技术的依赖,还通过异步消息解耦业务流程,影响通信的本质。
消息队列是消息被发送到其中,然后一次由一个消费者消费的队列。在企业系统中,消息队列通常通过消息导向中间件(MOM)实现。我们过去经常看到这些 MOM 解决方案,例如 ActiveMQ、RabbitMQ 或 WebSphere MQ 等消息队列系统。
发布-订阅模式描述了订阅主题并接收发送到该主题的消息的消费者。订阅者注册主题并接收由发布者发送的消息。这个概念对于涉及更多对等体的场景具有良好的可扩展性。消息导向中间件通常可以用来利用消息队列和发布-订阅方法的优势。
然而,除了异步通信的一般情况外,面向消息的解决方案也存在某些不足。首先需要注意的方面是消息的可靠投递。生产者以异步的、发送后即忘的方式发送消息。工程师必须了解消息投递的定义和支持的语义,即消息是否会被接收最多一次、至少一次或恰好一次。选择支持特定投递语义的技术,尤其是恰好一次语义,将对可扩展性和吞吐量产生影响。在第八章微服务与系统架构中,我们将详细讨论该主题,当讨论事件驱动应用程序时。
对于 Java EE 应用程序,可以使用Java 消息服务(JMS)API 来集成面向消息的中间件解决方案。JMS API 支持消息队列和发布-订阅方法。它只定义了接口,并由实际的消息导向中间件解决方案实现。
然而,JMS API 的开发者接受度不高,并且在撰写本文时,可以说在当前系统中使用得并不多。与其他标准相比,编程模型并不那么直接和高效。面向消息通信的另一个趋势是,与传统 MOM 解决方案相比,更轻量级的解决方案越来越受欢迎。截至目前,许多这些面向消息的解决方案都是通过专有 API 集成的。此类解决方案的例子是 Apache Kafka,它利用了消息队列和发布-订阅模型。第八章微服务与系统架构展示了 Apache Kafka 作为 MOM 解决方案集成到 Java EE 应用程序中的示例。
服务器端发送的事件
服务器端发送的事件(SSE)是一个异步的、基于 HTTP 的发布-订阅技术的例子。它提供了一个易于使用的单向流式通信协议。客户端可以通过请求一个保持开放连接的 HTTP 资源来注册一个主题。服务器通过这些活跃的 HTTP 连接向连接的客户端发送消息。客户端不能直接进行通信,但只能打开和关闭到流式端点的连接。这种轻量级解决方案适用于需要广播更新的用例,例如社交媒体更新、股票价格或新闻源。
服务器将基于 UTF-8 的文本数据作为内容类型text/event-stream推送到之前已注册主题的客户端。以下展示了事件的格式:
data: This is a message
event: namedmessage
data: This message has an event name
id: 10
data: This message has an id which will be sent as
'last event ID' if the client reconnects
由于服务器端发送的事件基于 HTTP,因此它们很容易集成到现有的网络或开发工具中。SSE 原生支持事件 ID 和重新连接。重新连接到流式端点的客户端会提供最后接收的事件 ID,以继续订阅他们离开的地方。
JAX-RS 在服务器端和客户端都支持服务器发送事件。使用 JAX-RS 资源定义 SSE 流端点如下:
import javax.ws.rs.DefaultValue;
import javax.ws.rs.HeaderParam;
import javax.ws.rs.InternalServerErrorException;
import javax.ws.rs.core.HttpHeaders;
import javax.ws.rs.sse.*;
@Path("events-examples")
@Singleton
public class EventsResource {
@Context
Sse sse;
private SseBroadcaster sseBroadcaster;
private int lastEventId;
private List<String> messages = new ArrayList<>();
@PostConstruct
public void initSse() {
sseBroadcaster = sse.newBroadcaster();
sseBroadcaster.onError((o, e) -> {
...
});
}
@GET
@Lock(READ)
@Produces(MediaType.SERVER_SENT_EVENTS)
public void itemEvents(@HeaderParam(HttpHeaders.LAST_EVENT_ID_HEADER)
@DefaultValue("-1") int lastEventId,
@Context SseEventSink eventSink) {
if (lastEventId >= 0)
replayLastMessages(lastEventId, eventSink);
sseBroadcaster.register(eventSink);
}
private void replayLastMessages(int lastEventId, SseEventSink eventSink) {
try {
for (int i = lastEventId; i < messages.size(); i++) {
eventSink.send(createEvent(messages.get(i), i + 1));
}
} catch (Exception e) {
throw new InternalServerErrorException("Could not replay messages ", e);
}
}
private OutboundSseEvent createEvent(String message, int id) {
return sse.newEventBuilder().id(String.valueOf(id)).data(message).build();
}
@Lock(WRITE)
public void onEvent(@Observes DomainEvent domainEvent) {
String message = domainEvent.getContents();
messages.add(message);
OutboundSseEvent event = createEvent(message, ++lastEventId);
sseBroadcaster.broadcast(event);
}
}
text/event-stream 内容类型用于服务器发送事件。已注册的 SseEventSink 指示 JAX-RS 保持客户端连接打开,以便通过广播发送未来的事件。SSE 标准定义 Last-Event-ID 头控制事件流将从中继续的位置。在这个例子中,服务器将重新发送在客户端断开连接期间发布的消息。
itemEvents() 方法实现了流注册,并在需要时立即向该客户端重发缺失的事件。输出注册到客户端后,客户端将与所有其他活跃客户端一起接收使用 Sse 创建的未来消息。
我们的 enterprise application 的异步集成是通过观察到的 DomainEvent 实现的。每当在应用中的某个地方触发此类 CDI 事件时,活跃的 SSE 客户端将接收到一条消息。
JAX-RS 还支持消费 SSE。SseEventSource 提供了一个打开 SSE 端点连接的功能。它注册了一个事件监听器,一旦有消息到达就会被调用:
import java.util.function.Consumer;
public class SseClient {
private final WebTarget target = ClientBuilder.newClient().target("...");
private SseEventSource eventSource;
public void connect(Consumer<String> dataConsumer) {
eventSource = SseEventSource.target(target).build();
eventSource.register(
item -> dataConsumer.accept(item.readData()),
Throwable::printStackTrace,
() -> System.out.println("completed"));
eventSource.open();
}
public void disconnect() {
if (eventSource != null)
eventSource.close();
}
}
在 SseEventSource 成功打开连接后,当前线程将继续。在这种情况下,监听器 dataConsumer#accept 将在事件到达时被调用。SseEventSource 将处理由 SSE 标准定义的所有必需处理。例如,包括在连接丢失后重新连接和发送 Last-Event-ID 头。
客户端也有可能使用更复杂的解决方案,通过手动控制头和重新连接。因此,从常规 Web 目标请求 SseEventInput 类型时,使用 text/event-stream 内容类型。有关更多信息,请参阅 JAX-RS 规范。
服务器发送事件提供了一个易于使用的单向流解决方案,通过 HTTP 集成到 Java EE 技术中。
WebSocket
服务器发送事件与支持双向通信的更强大的 WebSocket 技术竞争。由 IETF 标准化的 WebSocket 是面向消息的发布/订阅通信的另一个例子。它原本打算用于基于浏览器的应用程序,但也可以用于任何客户端/服务器消息交换。WebSocket 通常使用与 HTTP 端点相同的端口,但使用自己的基于 TCP 的协议。
Java EE 支持 WebSocket,作为 Java API for WebSocket 的一部分。它包括服务器端和客户端的支持。
服务器端端点定义的编程模型再次与整体 Java EE 图景相匹配。端点可以使用程序化或声明性、注解驱动的途径定义。后者在端点类上添加注解,类似于 JAX-RS 资源的编程模型:
import javax.websocket.*;
import javax.websocket.server.ServerEndpoint;
@ServerEndpoint(value = "/chat", decoders = ChatMessageDecoder.class, encoders = ChatMessageEncoder.class)
public class ChatServer {
@Inject
ChatHandler chatHandler;
@OnOpen
public void openSession(Session session) {
...
}
@OnMessage
public void onMessage(ChatMessage message, Session session) {
chatHandler.store(message);
}
@OnClose
public void closeSession(Session session) {
...
}
}
服务器端点类的注解方法将在启动会话、到达的消息和关闭的连接上分别被调用。会话代表两个端点之间的对话。
WebSocket 端点可以分别定义解码器和编码器,以便将自定义 Java 类型映射到二进制或纯文本数据,反之亦然。此示例指定了一个用于聊天消息的自定义类型,它使用自定义解码器和编码器进行映射。类似于 JAX-RS,WebSocket 自带对常用可序列化 Java 类型(如字符串)的默认序列化功能。以下代码演示了我们自定义域类型的编码器:
import javax.websocket.EncodeException;
import javax.websocket.Encoder;
import javax.websocket.EndpointConfig;
public class ChatMessageEncoder implements Encoder.Binary<ChatMessage> {
@Override
public ByteBuffer encode(ChatMessage object) throws EncodeException {
...
}
...
}
这些类型对应于 JAX-RS 标准中的MessageBodyWriter和MessageBodyReader类型。以下显示了相应的消息解码器:
import javax.websocket.DecodeException;
import javax.websocket.Decoder;
import javax.websocket.EndpointConfig;
public class ChatMessageDecoder implements Decoder.Binary<ChatMessage> {
@Override
public ChatMessage decode(ByteBuffer bytes) throws DecodeException {
...
}
...
}
客户端端点与服务器端点定义类似。区别在于只有 WebSocket 服务器会在路径上监听新的连接。
WebSocket API 的客户端功能不仅可以在企业环境中使用,也可以在 Java SE 应用程序中使用。对于客户端的 JAX-RS 也是如此。实现 WebSocket 客户端端点留作读者的练习。
WebSocket 以及服务器端发送的事件提供了高度集成的、面向消息的技术。当然,应用程序选择使用什么高度取决于业务需求、现有环境和通信的性质。
连接企业技术
一些需要从应用程序集成的外部企业系统不提供标准接口或 Java API。遗留系统以及组织内部使用的其他系统可能属于此类。Java EE 连接器架构(JCA)API 可以将这些所谓的企业信息系统(EIS)集成到 Java EE 应用程序中。EIS 的例子包括交易处理系统、消息系统或专有数据库。
JCA 资源适配器是可以部署的 EE 组件,它们将信息系统集成到应用程序中。它们包括连接、事务、安全或生命周期管理之类的合约。与其他连接技术相比,信息系统可以更好地集成到应用程序中。资源适配器打包为资源适配器存档(RAR),并且可以使用javax.resource包及其子包的功能在应用程序中访问。一些 EIS 供应商为其系统提供资源适配器。有关开发和使用资源适配器的信息,请参阅 JCA 规范。
JCA 为外部信息系统提供了多种集成可能性。然而,该标准并未得到广泛使用,并且企业工程师对其接受度不高。开发资源适配器相当繁琐,JCA API 在开发者中并不知名,公司通常选择以其他方式集成系统。实际上,应该考虑是否将编写资源适配器的努力优先于使用其他集成技术来集成信息系统。其他解决方案包括 Apache Camel 或 Mule ESB 等集成框架。
数据库系统
大多数企业应用使用数据库系统作为其持久化存储。数据库是企业系统的核心,包含应用程序的数据。截至目前,数据已经成为最重要的商品之一。公司花费大量时间和精力收集、保护和利用数据。
企业系统中表示状态的方式有多种,然而,关系型数据库仍然是最受欢迎的。其概念和用法被充分理解和集成在企业技术中。
集成 RDBMS 系统
Java 持久化 API(JPA)用于将关系型数据库系统集成到企业应用程序中。与 J2EE 时代的过时方法相比,JPA 与基于领域驱动设计概念的领域模型集成良好。持久化实体不会引入太多开销,也不会对模型设置太多约束。这允许首先构建领域模型,专注于业务方面,然后集成持久化层。
持久化作为处理业务用例的必要部分集成到领域。根据用例的复杂性,持久化功能可以在专用控件中或直接在边界中调用。领域驱动设计定义了仓库的概念,正如之前提到的,它与 JPA 实体管理器的职责相匹配。实体管理器用于获取、管理和持久化实体以及执行查询。其接口被抽象化,目的是以通用方式使用。
在 J2EE 时代,数据访问对象(DAO)模式被广泛使用。这种模式的动机是为了抽象和封装访问数据的功能。这包括访问的存储系统类型,如关系型数据库管理系统(RDBMS)、面向对象数据库、LDAP 系统或文件。虽然这种推理确实有道理,但在 Java EE 时代,遵循该模式对于大多数用例并非必需。
大多数企业应用程序使用支持 SQL 和 JDBC 的关系数据库。JPA 已经抽象了 RDBMS 系统,因此工程师通常不需要处理供应商特定的细节。将使用的存储系统的性质更改为 RDBMS 以外的任何东西都会影响应用程序的代码。由于 JPA 很好地集成到领域模型中,映射领域实体类型到存储不再需要使用传输对象。直接映射领域实体类型是集成持久性而不产生太多开销的生产性方法。对于简单的用例,例如持久化和检索实体,因此不需要 DAO 方法。然而,对于涉及复杂数据库查询的情况,将此功能封装到单独的控制中是有意义的。这些存储库包含特定实体类型的全部持久性。然而,建议从简单的方法开始,只有在复杂性增加时才将持久性重构为单一责任点。
边界或控制分别获得一个实体管理器来管理实体的持久性。以下展示了如何将实体管理器集成到边界中:
import javax.persistence.EntityManager;
import javax.persistence.PersistenceContext;
@Stateless
public class PersonAdministration {
@PersistenceContext
EntityManager entityManager;
public void createPerson(Person person) {
entityManager.persist(person);
}
public void updateAddress(long personId, Address newAddress) {
Person person = entityManager.find(Person.class, personId);
if (person == null)
throw new IllegalArgumentException("Could not find person with ID " + personId);
person.setAddress(newAddress);
}
}
在创建新人员时进行的persist()操作使人员成为一个受管理的实体。一旦事务提交,它就会被添加到数据库中,并且可以通过其分配的 ID 稍后获取。updateAddress()方法展示了这一点。通过其 ID 检索人员实体到受管理的实体。实体中的所有更改,例如更改其地址,将在事务提交时同步到数据库中。
映射领域模型
如前所述,实体、聚合和值对象通过 JPA 集成,而不对模型引入许多约束。实体以及聚合都表示为 JPA 实体:
import javax.persistence.*;
@Entity
@Table(name = "persons")
public class Person {
@Id
@GeneratedValue
private long id;
@Basic(optional = false)
private String name;
@Embedded
private Address address;
...
}
@Embeddable
public class Address {
@Basic(optional = false)
private String streetName;
@Basic(optional = false)
private String postalCode;
@Basic(optional = false)
private String city;
...
}
人员类型是一个实体。它需要一个 ID 来识别,这个 ID 将是persons表的主键。每个属性都以某种方式映射到数据库中,具体取决于类型的性质和关系。人员的名字是一个简单的基于文本的列。
地址是一个不可识别的值对象。从领域角度来看,我们指的地址是哪一个并不重要,只要值匹配即可。因此,地址不是一个实体,所以不会被映射到 JPA 中。值对象可以通过 JPA 可嵌入类型来实现。这些类型的属性将被映射到引用它们的实体的表中的额外列。由于人员实体包含一个特定的地址值,因此地址属性将是人员表的一部分。
由多个实体组成的根聚合可以通过配置要映射到适当数据库列和表的关系来实现。例如,汽车由引擎、一个或多个座椅、底盘和许多其他部件组成。其中一些是实体,它们可以作为单独的对象被识别和访问。汽车制造商可以识别整个汽车或只是引擎,并相应地进行维修或更换。数据库映射也可以放在现有的域模型之上。
以下代码片段显示了汽车域实体,包括 JPA 映射:
import javax.persistence.CascadeType;
import javax.persistence.OneToMany;
import javax.persistence.OneToOne;
@Entity
@Table(name = "cars")
public class Car {
@Id
@GeneratedValue
private long id;
@OneToOne(optional = false, cascade = CascadeType.ALL)
private Engine engine;
@OneToMany(cascade = CascadeType.ALL)
private Set<Seat> seats = new HashSet<>();
...
}
座椅包含在一个集合中。HashSet用于新的Car实例;应避免使用null的 Java 集合。
引擎代表我们域中的另一个实体:
import javax.persistence.EnumType;
import javax.persistence.Enumerated;
@Entity
@Table(name = "engines")
public class Engine {
@Id
@GeneratedValue
private long id;
@Basic(optional = false)
@Enumerated(EnumType.STRING)
private EngineType type;
private double ccm;
...
}
汽车座椅也代表实体,可以通过它们的 ID 来识别:
@Entity
@Table(name = "seats")
public class Seat {
@Id
@GeneratedValue
private long id;
@Basic(optional = false)
@Enumerated(EnumType.STRING)
private SeatMaterial material;
@Basic(optional = false)
@Enumerated(EnumType.STRING)
private SeatShape shape;
...
}
所有实体,无论是从其他实体引用还是独立存在,都需要在持久化上下文中进行管理。如果汽车的引擎被新的实体替换,这也需要单独持久化。持久化操作要么在单个实体上显式调用,要么从对象层次结构中级联。级联是在实体关系上指定的。以下代码展示了从服务中持久化新汽车引擎的两种方法:
public void replaceEngine(long carIdentifier, Engine engine) {
Car car = entityManager.find(Car.class, carIdentifier);
car.replaceEngine(engine);
// car is already managed, engine needs to be persisted
entityManager.persist(engine);
}
在从其标识符加载汽车后,它成为一个管理实体。引擎仍然需要持久化。第一种方法在服务中显式持久化引擎。
第二种方法从汽车聚合中级联合并操作,该操作也处理新实体:
public void replaceEngine(long carIdentifier, Engine engine) {
Car car = entityManager.find(Car.class, carIdentifier);
car.replaceEngine(engine);
// merge operation is applied on the car and all cascading relations
entityManager.merge(car);
}
高度建议采用后一种方法。聚合根负责维护整体状态的整数和一致性。当所有操作都从根实体启动和级联时,完整性可以更可靠地实现。
集成数据库系统
实体管理器在持久化上下文中管理持久化实体。它使用单个持久化单元,该单元对应于一个数据库实例。持久化单元包括所有管理的实体、实体管理器和映射配置。如果只访问一个数据库实例,则可以直接获取实体管理器,如前例所示。持久化上下文注解随后引用唯一的持久化单元。
持久化单元在persistence.xml描述符文件中指定,该文件位于META-INF目录下。在现代 Java EE 中,这是使用基于 XML 的配置的少数几个情况之一。持久化描述符定义了持久化单元和可选配置。数据源仅通过其 JNDI 名称引用,以便将访问数据库实例的配置与应用程序配置分开。数据源的实际配置在应用服务器中指定。如果应用服务器只包含一个使用单个数据库的应用程序,则开发人员可以使用应用服务器的默认数据源。在这种情况下,可以省略数据源名称。
下面的片段显示了包含单个持久化单元使用默认数据源的persistence.xml文件示例:
<?xml version="1.0" encoding="UTF-8"?>
<persistence version="2.2"
xsi:schemaLocation="http://xmlns.jcp.org/xml/ns/persistence
http://xmlns.jcp.org/xml/ns/persistence/persistence_2_2.xsd">
<persistence-unit name="vehicle" transaction-type="JTA">
</persistence-unit>
</persistence>
此示例对于大多数企业应用来说已经足够。
下一个片段演示了一个包含多个数据源持久化单元定义的persistence.xml文件:
<?xml version="1.0" encoding="UTF-8"?>
<persistence version="2.2"
xsi:schemaLocation="http://xmlns.jcp.org/xml/ns/persistence
http://xmlns.jcp.org/xml/ns/persistence/persistence_2_2.xsd">
<persistence-unit name="vehicle" transaction-type="JTA">
<jta-data-source>jdbc/VehicleDB</jta-data-source>
</persistence-unit>
<persistence-unit name="order" transaction-type="JTA">
<jta-data-source>jdbc/OrderDB</jta-data-source>
</persistence-unit>
</persistence>
注入实体管理器需要通过其名称引用所需的持久化单元。实体管理器始终对应于一个使用单个持久化单元的单个持久化上下文。以下CarManagement定义展示了在多个持久化单元环境中的前一个示例:
@Stateless
public class CarManagement {
@PersistenceContext(unitName = "vehicle")
EntityManager entityManager;
public void replaceEngine(long carIdentifier, Engine engine) {
Car car = entityManager.find(Car.class, carIdentifier);
car.replaceEngine(engine);
// merge operation is applied on the car and all cascading relations
entityManager.merge(car);
}
}
可选地,可以通过使用 CDI 生产者字段简化特定实体管理器的注入。通过使用自定义限定符显式生成实体管理器,可以以类型安全的方式实现注入:
public class EntityManagerExposer {
@Produces
@VehicleDB
@PersistenceContext(unitName = "vehicle")
private EntityManager vehicleEntityManager;
@Produces
@OrderDB
@PersistenceContext(unitName = "order")
private EntityManager orderEntityManager;
}
现在可以注入生成的实体管理器,现在使用@Inject和类型安全的限定符:
public class CarManagement {
@Inject
@VehicleDB
EntityManager entityManager;
...
}
此方法可以简化在许多位置注入不同实体管理器的环境中的使用。
还有其他将域模型映射到数据库的可能方法。数据库映射也可以在 XML 文件中定义。然而,J2EE 中的过去方法已经表明,使用注解进行声明性配置可以更有效地使用。对域模型进行注解也提供了更好的概览。
事务
持久化操作需要在事务上下文中执行。被修改的管理实体在事务提交时间同步到数据源。因此,事务跨越修改操作,通常是整个业务用例。
如果边界实现为 EJB,则在默认情况下,业务方法执行期间会激活一个事务。这与 JPA 持久化在应用程序中涉及的典型场景相匹配。
通过 CDI 管理豆,使用@Transactional注解其方法,可以实现相同的行为。事务边界在业务方法进入时指定特定的行为。默认情况下,此行为定义了一个事务是REQUIRED;也就是说,如果调用上下文已经在活动事务中执行,则创建或重用事务。
REQUIRES_NEW行为将始终启动一个新的事务,该事务独立执行,并在方法和新事务完成后恢复潜在的前一个事务。这对于处理大量数据且可以分几个独立事务处理的长运行业务流程非常有用。
其他事务行为也是可能的,例如强制执行一个已经激活的事务或完全不支持事务。这可以通过在业务方法上添加@Transactional注解来配置。EJB 隐式定义了REQUIRED事务。
RDBMS 系统很好地集成到 Java EE 应用程序中。遵循约定优于配置的原则,典型的用例以生产方式实现。
关系型数据库与 NoSQL
在过去几年里,数据库技术发生了许多变化,尤其是在分布式方面。然而,传统的数据库关系型数据库仍然是今天最常用的选择。它们最显著的特征是基于表的数据库模式和事务行为。
NoSQL(非 SQL 或不仅限于 SQL)数据库系统提供的数据形式不同于关系型表格。这些形式包括文档存储、键值存储、列存储和图数据库。大多数 NoSQL 数据库在可用性、可扩展性和网络分区容错性方面做出了妥协,以牺牲一致性。NoSQL 背后的理念是不使用关系型表格结构的完全支持,ACID事务(原子性、一致性、隔离性、持久性)、外键以及表连接,以支持水平扩展。这回到了著名的 CAP 定理。CAP定理(一致性、可用性、分区容错性)声称,分布式数据存储无法保证最多两个指定的约束。由于分布式网络不可靠(分区容错性),系统基本上可以选择是否保证一致性或水平扩展。大多数 NoSQL 数据库选择可扩展性而不是一致性。在选择数据存储技术时,需要考虑这一事实。
NoSQL 系统的原因在于关系型数据库的不足。最大的问题是支持 ACID 的关系型数据库在水平扩展方面表现不佳。数据库系统是企业系统的核心,通常由多个应用服务器访问。需要一致更新数据需要在中央位置同步。这种同步发生在业务用例的技术事务中。需要复制并保持一致性的数据库系统需要在彼此之间维护分布式事务。然而,分布式事务无法扩展,并且可能在每个解决方案中都不可靠。
尽管如此,关系型数据库系统对于大多数企业应用来说已经足够扩展。如果水平扩展成为问题,以至于集中式数据库不再是可行的选择,一个解决方案是使用事件驱动架构等方法来分割持久化。我们将在第八章“微服务和系统架构”中详细讨论这个主题。
NoSQL 数据库也有一些缺点,特别是在事务行为方面。数据是否需要以事务方式持久化,很大程度上取决于应用程序的业务需求。经验表明,在几乎所有的企业系统中,至少有一部分持久化需要可靠性;即事务。然而,有时会有不同类型的数据。某些领域模型更为关键,需要事务处理,而其他数据可能可以重新计算或重新生成;例如,统计数据、推荐或缓存数据。对于后一种类型的数据,NoSQL 数据存储可能是一个不错的选择。
在撰写本文时,还没有出现任何 NoSQL 系统成为标准或事实标准。许多系统在概念和用法上也有很大的差异。Java EE 8 中也没有包含针对 NoSQL 的标准。
因此,访问 NoSQL 系统通常是通过使用供应商提供的 Java API 实现的。这些 API 使用了更低级别的标准,如 JDBC 或它们的专有 API。
横切关注点
企业应用需要一些技术驱动的横切关注点。这些关注点的例子包括事务、日志记录、缓存、弹性、监控、安全和其他非功能性需求。即使是仅针对业务的系统,用例也需要一定数量的技术管道。
我们刚才在处理事务时看到了一个非功能性横切关注点的例子。Java EE 不需要工程师花费太多时间和精力来集成事务行为。对于其它横切关注点也是如此。
Java EE 拦截器是横切关注点的典型例子。遵循面向切面编程的概念,横切关注点的实现与被装饰的功能分离。管理 Bean 的方法可以被装饰以定义拦截器,这些拦截器会中断执行并执行所需的任务。拦截器对被拦截方法的执行拥有完全控制权,包括返回值和抛出的异常。为了与其它 API 的理念相匹配,拦截器以轻量级的方式集成,不对被装饰功能设置太多约束。
之前在 HTTP 客户端类中透明处理错误的例子展示了拦截器的使用。业务方法也可以使用自定义拦截器绑定进行装饰。以下演示了一个通过自定义注解实现的业务驱动流程跟踪切面:
@Stateless
public class CarManufacturer {
...
@Tracked(ProcessTracker.Category.MANUFACTURER)
public Car manufactureCar(Specification spec) {
...
}
}
Tracked 注解定义了一个所谓的拦截器绑定。注解参数代表一个非绑定值,用于配置拦截器:
import javax.enterprise.util.Nonbinding;
import javax.interceptor.InterceptorBinding;
@InterceptorBinding
@Inherited
@Documented
@Target({TYPE, METHOD})
@Retention(RUNTIME)
public @interface Tracked {
@Nonbinding
ProcessTracker.Category value();
}
拦截器通过绑定注解来激活:
import javax.annotation.Priority;
@Tracked(ProcessTracker.Category.UNUSED)
@Interceptor
@Priority(Interceptor.Priority.APPLICATION)
public class TrackingInterceptor {
@Inject
ProcessTracker processTracker;
@AroundInvoke
public Object aroundInvoke(InvocationContext context) throws Exception {
Tracked tracked = resolveAnnotation(context);
if (tracked != null) {
ProcessTracker.Category category = tracked.value();
processTracker.track(category);
}
return context.proceed();
}
private Tracked resolveAnnotation(InvocationContext context) {
Function<AnnotatedElement, Tracked> extractor = c -> c.getAnnotation(Tracked.class);
Method method = context.getMethod();
Tracked tracked = extractor.apply(method);
return tracked != null ? tracked : extractor.apply(method.getDeclaringClass());
}
}
默认情况下,通过拦截器绑定绑定的拦截器是禁用的。拦截器必须通过指定优先级(如本例中所示)来显式启用,或者可以在 beans.xml 描述符中激活它。
<?xml version="1.0" encoding="UTF-8"?>
<beans
xsi:schemaLocation="http://xmlns.jcp.org/xml/ns/javaee
http://xmlns.jcp.org/xml/ns/javaee/beans_1_1.xsd"
bean-discovery-mode="all">
<interceptors>
<class>com.example.cars.processes.TrackingInterceptor</class>
</interceptors>
</beans>
拦截器可以使用反射来检索潜在的注解参数,例如示例中的流程跟踪类别。拦截器绑定可以放置在方法或类级别。
拦截器在不紧密耦合的情况下装饰方法上的行为。它们特别适用于需要将横切关注点添加到大量功能中的场景。
拦截器类似于 CDI 装饰器。这两个概念都是通过封装在另一个地方的行为来装饰托管 Bean。区别在于装饰器主要用于装饰业务逻辑,而业务逻辑通常也只针对装饰的 Bean。然而,拦截器主要用于技术问题。它们提供了更广泛的使用,使得可以注解所有类型的 Bean。这两个概念都是实现横切关注点的有用功能。
配置应用程序
应用程序行为不能硬编码,但需要动态定义,可以通过配置来实现。配置的实现方式取决于应用程序和动态行为的性质。
需要配置哪些方面?是否足够定义已包含在已发布工件中的配置文件?是否需要从外部配置打包的应用程序?是否需要在运行时更改行为?
在应用程序构建后不需要更改的配置可以轻松地在项目中实现,即在源代码中。假设我们需要更多的灵活性。
在 Java 环境中,最直接的方法是提供包含配置值键值对的属性文件。需要检索配置值以便在代码中使用。当然,可以编写程序提供属性值的 Java 组件。在 Java EE 环境中,将使用依赖注入来检索此类组件。在撰写本文时,尚无 Java EE 标准支持开箱即用的配置。然而,使用 CDI 功能可以在几行代码中提供此功能。以下是一个可能的解决方案,它允许注入通过键标识的配置值:
@Stateless
public class CarManufacturer {
@Inject
@Config("car.default.color")
String defaultColor;
public Car manufactureCar(Specification spec) {
// use defaultColor
}
}
为了明确地注入配置值,例如作为字符串提供的值,需要像 @Config 这样的限定符。这个自定义限定符在我们的应用程序中定义。目标是注入通过提供的键标识的值:
@Qualifier
@Documented
@Retention(RUNTIME)
public @interface Config {
@Nonbinding
String value();
}
CDI 生产者负责检索和提供特定的配置值:
import javax.enterprise.inject.spi.InjectionPoint;
import java.io.*;
import java.util.Properties;
@ApplicationScoped
public class ConfigurationExposer {
private final Properties properties = new Properties();
@PostConstruct
private void initProperties() {
try (InputStream inputStream = ConfigurationExposer.class
.getResourceAsStream("/application.properties")) {
properties.load(inputStream);
} catch (IOException e) {
throw new IllegalStateException("Could not init configuration", e);
}
}
@Produces
@Config("")
public String exposeConfig(InjectionPoint injectionPoint) {
Config config = injectionPoint.getAnnotated().getAnnotation(Config.class);
if (config != null)
return properties.getProperty(config.value());
return null;
}
}
@Config 注解中的引用键是一个非绑定属性,因为所有注入的值都由我们的 CDI 生产者方法处理。CDI 提供的 InjectionPoint 包含有关依赖注入指定位置的信息。生产者检索带有实际配置键的注解,并使用它来查找配置属性。期望属性文件 application.properties 存在于类路径中。这种方法包括在运行时需要可用的配置值。由于属性映射只初始化一次,因此加载后值将不会改变。配置暴露 Bean 是应用程序范围的,只将所需值加载到属性映射中一次。
如果场景需要运行时更改配置,则生产者方法必须重新加载配置文件。生产者方法的范围定义了配置值的生命周期,以及该方法将被调用的频率。
此示例使用纯 Java EE 实现配置。有一些第三方 CDI 扩展提供了类似的功能,以及更复杂的功能。在撰写本文时,此类解决方案的一个常用示例是 Apache Deltaspike。
除了企业技术之外,还需要考虑容器运行的环境;特别是,因为容器技术对运行时环境施加了某些限制。第五章,Java EE 的容器和云环境涵盖了现代环境及其对 Java EE 运行时的影响,包括如何设计动态配置。
CDI 生产者的强大之处在于它们的灵活性。任何配置源都可以轻松附加以暴露配置值。
缓存
缓存是一种技术驱动的横切关注点,一旦应用程序面临性能问题,例如缓慢的外部系统、昂贵且可缓存的计算或大量数据,它就会变得有趣。一般来说,缓存旨在通过在可能更快的缓存中存储难以检索的数据来降低响应时间。一个典型的例子是将外部系统或数据库的响应保留在内存中。
在实现缓存之前,需要提出的一个问题是是否需要缓存,甚至是否可能。有些数据不适合缓存,例如需要按需计算的数据。如果情况和数据可能适合缓存,那么是否可能采用除了缓存之外的另一种解决方案,这取决于具体情况。缓存会引入重复和接收过时信息的机会,一般来说,对于大多数企业应用程序,应该避免使用缓存。例如,如果数据库操作太慢,建议考虑是否可以通过其他方式,如索引,来帮助。
这在很大程度上取决于具体情况和所需的缓存解决方案。一般来说,在应用程序内存中直接缓存已经解决了许多场景。
在应用程序中缓存信息最直接的方式是在单一位置。单例 bean 完美地符合这种场景。一个自然适合缓存目的的数据结构是 Java Map类型。
之前展示的CarStorage代码片段代表了一个包含线程安全 map 以存储数据的单例 EJB,该存储被注入并用于其他管理 bean:
@Singleton
@ConcurrencyManagement(ConcurrencyManagementType.BEAN)
public class CarStorage {
private final Map<String, Car> cars = new ConcurrentHashMap<>();
public void store(Car car) {
cars.put(car.getId(), car);
}
public Car retrieve(String id) {
return cars.get(id);
}
}
如果需要更多的灵活性,例如从文件中预加载缓存内容,bean 可以使用post-construct和pre-destroy方法来控制生命周期。为了保证在应用程序启动时执行功能,EJB 使用@Startup注解:
@Singleton
@Startup
@ConcurrencyManagement(ConcurrencyManagementType.BEAN)
public class CarStorage {
...
@PostConstruct
private void loadStorage() {
// load contents from file
}
@PreDestroy
private void writeStorage() {
// write contents to file
}
}
可以使用拦截器以透明的方式添加缓存,而无需程序化注入和使用缓存。拦截器在调用业务方法之前中断执行,并将返回缓存值。最突出的例子是Java 临时缓存 API(JCache)的CacheResult功能。JCache 是一个针对 Java EE 的标准,但截至编写本书时,它尚未包含在总规范中。对于添加 JCache 功能的应用程序,符合条件的业务方法使用@CacheResult注解,并由特定的缓存透明地提供服务。
JCache 通常为简单 Java EE 解决方案不足的场景提供复杂的缓存功能。这包括由 JCache 实现提供的分布式缓存。截至今天,通常使用的缓存解决方案是Hazelcast、Infinispan或Ehcache。这在需要将多个缓存与特定关注点(如缓存驱逐)集成时尤其如此。JCache 及其实现提供了强大的解决方案。
执行流程
在企业应用程序中运行的业务流程描述了某些流程执行的流程。对于触发的用例,这包括同步请求-响应方法或异步处理触发流程。
用例调用在单独的线程中运行,每个请求或调用一个线程。容器创建这些线程,并在调用成功处理后将其池化以供重用。默认情况下,应用程序类中定义的业务流程以及横切关注点,如事务,都是顺序执行的。
同步执行
对于由 HTTP 请求触发并涉及数据库查询的典型用例,其工作方式如下。请求线程处理进入边界的请求;例如,通过控制反转原则,JAX-RS 资源方法由容器调用。资源注入并使用UserManagement EJB,该 EJB 也被容器间接调用。所有由代理执行的操作都是在同步术语下进行的。EJB 将使用实体管理器存储一个新的User实体,一旦启动当前活动事务的业务方法返回,容器将尝试将事务提交到数据库。根据事务结果,边界资源方法继续并构建客户端响应。所有这些都是在客户端调用阻塞并等待响应的同时同步发生的。
同步执行包括处理同步 CDI 事件。事件解耦了触发域事件和处理它们的过程;然而,事件处理是同步进行的。存在几种事务观察者方法。通过指定事务阶段,事件将在事务提交时间处理,分别是在完成前、完成后、仅在事务失败后或成功事务后。默认情况下,或者在没有活动事务时,CDI 事件在它们被触发时立即处理。这使工程师能够实现复杂解决方案;例如,涉及仅在实体成功添加到数据库之后发生的事件。再次强调,在所有情况下,这种处理都是同步执行的。
异步执行
虽然同步执行流程满足了许多业务用例,但其他场景需要异步行为。Java EE 环境对应用程序在多线程方面设置了一些限制。容器管理和池化资源与线程。容器之外的外部并发实用工具不了解这些线程。因此,应用程序的代码不应该启动和管理自己的线程,而应该使用 Java EE 功能来这样做。有几个 API 原生支持异步性。
异步 EJB 方法
调用异步行为的一种简单方法是使用 @Asynchronous 注解 EJB 业务方法或 EJB 类。对这些方法的调用将立即返回,可选地带有 Future 响应类型。它们在单独的、容器管理的线程中执行。这种用法适用于简单场景,但仅限于 EJB:
import javax.ejb.Asynchronous;
@Asynchronous
@Stateless
public class Calculator {
public void calculatePi(long decimalPlaces) {
// this may run for a long time
}
}
管理执行器服务
对于在 CDI 管理豆或使用 Java SE 并发工具中进行异步执行,Java EE 包括容器管理的 ExecutorService 和 ScheduledExecutorService 功能。这些用于在容器管理的线程中执行异步任务。ManagedExecutorService 和 ManagedScheduledExecutorService 的实例被注入到应用程序代码中。这些实例可以用来执行它们自己的可运行逻辑;然而,当与 Java SE 并发工具(如可完成未来)结合使用时,它们表现得尤为出色。以下展示了使用容器管理的线程创建可完成未来的示例:
import javax.annotation.Resource;
import javax.enterprise.concurrent.ManagedExecutorService;
import java.util.Random;
import java.util.concurrent.CompletableFuture;
@Stateless
public class Calculator {
@Resource
ManagedExecutorService mes;
public CompletableFuture<Double> calculateRandomPi(int maxDecimalPlaces) {
return CompletableFuture.supplyAsync(() -> new Random().nextInt(maxDecimalPlaces) + 1, mes)
.thenApply(this::calculatePi);
}
private double calculatePi(long decimalPlaces) {
...
}
}
计算器豆返回一个类型为 completable future of double 的值,该值在调用上下文恢复时可能仍在计算。可以询问该计算是否已完成。它还可以组合到后续执行中。无论在企业应用中需要多少新线程,都应使用 Java EE 功能来管理它们。
异步 CDI 事件
CDI 事件也可以异步处理。同样,容器将为执行事件处理提供一个线程。要定义异步事件处理器,方法需要使用 @ObservesAsync 注解,并使用 fireAsync() 方法触发事件。以下代码片段演示了异步 CDI 事件:
@Stateless
public class CarManufacturer {
@Inject
CarFactory carFactory;
@Inject
Event<CarCreated> carCreated;
public Car manufactureCar(Specification spec) {
Car car = carFactory.createCar(spec);
carCreated.fireAsync(new CarCreated(spec));
return car;
}
}
事件处理器在一个自己的、容器管理的线程中被调用:
import javax.enterprise.event.ObservesAsync;
public class CreatedCarListener {
public void onCarCreated(@ObservesAsync CarCreated event) {
// handle event asynchronously
}
}
由于向后兼容性原因,同步 CDI 事件也可以在 EJB 异步方法中处理。因此,事件和处理程序是以同步方式定义的,但处理程序方法是带有 @Asynchronous 注解的 EJB 业务方法。在 Java EE 8 中将异步事件添加到 CDI 标准之前,这是提供此功能的唯一方法。为了避免混淆,应避免在 Java EE 8 及更高版本中使用此实现。
异步中的作用域
由于容器无法对异步任务可能运行多长时间做出任何假设,因此作用域的使用有限。在异步任务开始时可用作为请求作用域或会话作用域的 bean 不保证在整个执行过程中都处于活动状态;请求和会话可能已经很久以前就结束了。因此,运行异步任务的线程,例如由管理执行器服务或异步事件提供的线程,无法访问在原始调用期间处于活动状态的作用域 bean 实例。这也包括访问注入实例的引用,例如,在原始同步执行部分中的 lambda 方法中。
在建模异步任务时必须考虑这一点。所有调用特定信息都需要在任务启动时提供。然而,异步任务可以有自己的作用域 bean 实例。
定时执行
业务用例不仅可以从外部调用,例如通过 HTTP 请求,还可以从应用程序内部产生,例如通过在指定时间运行的作业。
在 Unix 世界中,cron 作业是触发定期作业的知名功能。EJBs 通过 EJB 计时器提供类似的可能性。计时器根据重复模式或特定时间调用业务方法。以下是一个定义每 10 分钟超时的计划计时器的示例:
import javax.ejb.Schedule;
import javax.ejb.Startup;
@Singleton
@Startup
public class PeriodicJob {
@Schedule(minute = "*/10", hour = "*", persistent = false)
public void executeJob() {
// this is executed every 10 minutes
}
}
所有 EJBs,包括单例、有状态或无状态的 bean 都可以定义计时器。然而,在大多数用例中,在单例 bean 上定义计时器是有意义的。超时将在所有活动 bean 上调用,通常希望可靠地调用计划中的作业;也就是说,在单例 bean 上。因此,此示例将 EJB 定义为在应用程序启动时处于活动状态。这保证了计时器从开始执行。
计时器可以定义为持久的,这延长了它们的生命周期,超出 JVM 的生命周期。容器负责保持计时器的持久性,通常在数据库中。在应用程序不可用期间应该执行的持久计时器在启动时被触发。这也使得在多个实例之间共享计时器的可能性。持久计时器与相应的服务器配置是解决需要在多个服务器上精确执行一次的业务流程的简单解决方案。
使用@Schedule注解自动创建的计时器使用类 Unix 的 cron 表达式进行指定。为了获得更大的灵活性,EJB 计时器可以通过容器提供的计时器服务进行编程定义,该服务创建Timers和@Timeout回调方法。
周期性或延迟作业也可以使用容器管理的计划执行器服务在 EJB 容器外部定义。一个在指定延迟后或周期性执行任务的 ManagedScheduledExecutorService 实例可以注入到管理豆中。执行这些任务将使用容器管理的线程:
@ApplicationScoped
public class Periodic {
@Resource
ManagedScheduledExecutorService mses;
public void startAsyncJobs() {
mses.schedule(this::execute, 10, TimeUnit.SECONDS);
mses.scheduleAtFixedRate(this::execute, 60, 10, TimeUnit.SECONDS);
}
private void execute() {
...
}
}
调用 startAsyncJobs() 将导致 execute() 在调用后 10 秒内在一个管理线程中运行,并在初始一分钟过后,每 10 秒连续运行一次。
异步和响应式 JAX-RS
JAX-RS 支持异步行为,以避免在服务器端不必要地阻塞请求线程。即使 HTTP 连接当前正在等待响应,请求线程也可能在服务器上处理长时间运行的过程的同时处理其他请求。请求线程由容器池化,并且这个池只有一定的大小。为了不无谓地占用请求线程,JAX-RS 异步资源方法提交在请求线程返回并可以再次重用之前执行的任务。HTTP 连接在异步任务完成后或超时后恢复并响应。以下代码展示了异步 JAX-RS 资源方法:
@Path("users")
@Consumes(MediaType.APPLICATION_JSON)
public class UsersResource {
@Resource
ManagedExecutorService mes;
...
@POST
public CompletionStage<Response> createUserAsync(User user) {
return CompletableFuture.supplyAsync(() -> createUser(user), mes);
}
private Response createUser(User user) {
userStore.create(user);
return Response.accepted().build();
}
}
为了防止请求线程被占用过长时间,JAX-RS 方法需要快速返回。这是因为资源方法是通过控制反转从容器中调用的。完成阶段的成果将在处理完成后用于恢复客户端连接。
返回完成阶段是 JAX-RS API 中相对较新的方法。如果需要超时声明以及更多关于异步响应的灵活性,可以将 AsyncResponse 类型注入到方法中。以下代码片段展示了这种方法。
import javax.ws.rs.container.AsyncResponse;
import javax.ws.rs.container.Suspended;
@Path("users")
@Consumes(MediaType.APPLICATION_JSON)
public class UsersResource {
@Resource
ManagedExecutorService mes;
...
@POST
public void createUserAsync(User user, @Suspended AsyncResponse response) {
response.setTimeout(5, TimeUnit.SECONDS);
response.setTimeoutHandler(r ->
r.resume(Response.status(Response.Status.SERVICE_UNAVAILABLE).build()));
mes.execute(() -> response.resume(createUser(user)));
}
}
使用自定义超时,客户端请求不会无限期等待,只会等到结果完成或调用超时。然而,计算将继续,因为它是在异步执行的。
对于实现为 EJB 的 JAX-RS 资源,可以使用 @Asynchronous 业务方法来使用执行器服务省略异步调用。
JAX-RS 客户端也支持异步行为。根据需求,在 HTTP 调用期间不阻塞是有意义的。一个先前的例子展示了如何设置客户端请求的超时。对于长时间运行和特别是并行外部系统调用,异步和 响应式 行为提供了好处。
想象有几个提供天气信息的后端。客户端组件访问所有这些后端,并提供平均天气预报。理想情况下,访问系统应该并行发生。
import java.util.stream.Collectors;
@ApplicationScoped
public class WeatherForecast {
private Client client;
private List<WebTarget> targets;
@Resource
ManagedExecutorService mes;
@PostConstruct
private void initClient() {
client = ClientBuilder.newClient();
targets = ...
}
public Forecast getAverageForecast() {
return invokeTargetsAsync()
.stream()
.map(CompletableFuture::join)
.reduce(this::calculateAverage)
.orElseThrow(() -> new IllegalStateException("No weather service available"));
}
private List<CompletableFuture<Forecast>> invokeTargetsAsync() {
return targets.stream()
.map(t -> CompletableFuture.supplyAsync(() -> t
.request(MediaType.APPLICATION_JSON_TYPE)
.get(Forecast.class), mes))
.collect(Collectors.toList());
}
private Forecast calculateAverage(Forecast first, Forecast second) {
...
}
@PreDestroy
public void closeClient() {
client.close();
}
}
invokeTargetsAsync()方法异步调用可用的目标,使用管理执行器服务。返回并使用CompletableFuture处理程序来计算平均结果。调用join()方法将阻塞,直到调用完成,并将提供个别结果。
通过异步调用可用的目标,它们并行调用并等待可能缓慢的资源。等待天气服务资源的时间仅与最慢的响应时间相同,而不是所有响应的总和。
JAX-RS 的最新版本原生支持完成阶段,这减少了应用程序中的样板代码。类似于使用可完成未来,调用立即返回一个完成阶段实例以供进一步使用。以下演示了使用rx()调用实现的响应式 JAX-RS 客户端功能:
public Forecast getAverageForecast() {
return invokeTargetsAsync()
.stream()
.reduce((l, r) -> l.thenCombine(r, this::calculateAverage))
.map(s -> s.toCompletableFuture().join())
.orElseThrow(() -> new IllegalStateException("No weather service available"));
}
private List<CompletionStage<Forecast>> invokeTargetsAsync() {
return targets.stream()
.map(t -> t
.request(MediaType.APPLICATION_JSON_TYPE)
.rx()
.get(Forecast.class))
.collect(Collectors.toList());
}
上述示例不需要查找管理执行器服务。JAX-RS 客户端将内部管理这一点。
在rx()方法引入之前,客户端包含了一个显式的async()调用,其行为类似,但仅返回Future。响应式客户端方法通常更适合项目需求。
如前所述,我们正在使用容器管理的执行器服务,因为我们处于 Java EE 环境中。
现代 Java EE 的概念和设计原则
Java EE API 是围绕整个标准集存在的约定和设计原则构建的。软件工程师在开发应用程序时将发现熟悉的 API 模式和做法。Java EE 旨在保持一致的 API 使用。
对于首先关注业务用例的应用程序,该技术的最重要原则是不阻碍。如前所述,工程师应该能够专注于解决业务问题,而不是将大部分时间花在处理技术或框架问题上。理想情况下,领域逻辑使用普通 Java 实现,并通过注解和方面增强,这些注解和方面能够使企业环境生效,而不影响或模糊领域代码。这意味着技术不需要过多的工程师关注,通过强制过于复杂的约束来实现。在过去,J2EE 需要许多这些过于复杂的解决方案。管理 Bean 以及持久化 Bean 需要实现接口或扩展基类。这模糊了领域逻辑并复杂化了可测试性。
在 Java EE 时代,领域逻辑是在用注解标注的普通 Java 类中实现的,这些注解告诉容器运行时如何应用企业关注点。清洁代码实践通常建议编写易于阅读的代码,而不是可重用性。Java EE 支持这种做法。如果出于某种原因需要替换技术并提取领域逻辑,只需简单地移除相应的注解即可。
正如我们在第七章中将要看到的,测试编程方法高度支持可测试性,因为对于开发者来说,大多数 Java EE 特性不过是一些注解。
在整个 API 中存在的一个设计原则是控制反转(IoC),换句话说,不要调用我们,我们会调用你。我们在应用边界,如 JAX-RS 资源中特别看到这一点。资源方法由注解 Java 方法定义,随后由容器在正确的上下文中调用。对于需要解决生产者或包括横切关注点(如拦截器)的依赖注入也是如此。应用程序开发者可以专注于实现逻辑和定义关系,而将实际的管道工作留给容器。另一个不那么明显的例子是通过 JSON-B 注解声明 Java 对象到 JSON 以及反向映射。这些对象以声明性方式隐式映射,不一定以显式、程序性的方式。
另一个使工程师能够以高效方式使用技术的原则是约定优于配置。默认情况下,Java EE 定义了与大多数用例相匹配的特定行为。如果这不足以满足或不符合要求,行为可以被覆盖,通常在多个层面上。
关于约定优于配置的例子数不胜数。JAX-RS 资源方法将 Java 功能映射到 HTTP 响应就是这样一个方法。如果 JAX-RS 关于响应的默认行为不足够,可以使用Response返回类型。另一个例子是管理 Bean 的指定,通常使用注解来实现。要覆盖这种行为,可以使用beans.xml XML 描述符。对于开发者来说,一个受欢迎的方面是在现代 Java EE 世界中,企业应用程序的开发是实用且高度高效的,通常不需要像过去那样使用大量的 XML。
在开发者生产力的方面,Java EE 的另一个重要设计原则是平台要求容器集成不同的标准。一旦容器支持一组特定的 API,如果整个 Java EE API 都得到支持,那么 API 的实现也必须能够直接集成其他 API。一个很好的例子是 JAX-RS 资源能够使用 JSON-B 映射和 Bean Validation,而无需除了注解之外的其他显式配置。在先前的例子中,我们看到了如何在不需额外努力的情况下,将定义在不同标准中的功能一起使用。这也是 Java EE 平台最大的优势之一。总规范确保了特定标准能够良好地协同工作。开发者可以依赖应用程序服务器提供某些功能和实现。
保持可维护且高质量的代码
开发者普遍认为代码质量是一个值得追求的目标。但并非所有技术都能同样好地支持这一目标。
如前所述,业务逻辑应该是应用程序的主要关注点。如果业务逻辑发生变化或在工作领域后出现新的知识,领域模型以及源代码都需要进行重构。迭代重构是达到并保持模型领域以及源代码总体高质量所必需的。领域驱动设计描述了深化对业务知识理解的努力。
在代码级别重构方面已经有很多论述。在将业务逻辑最初表示为代码并通过测试验证之后,开发者应花一些时间和精力重新思考和改进第一次尝试。这包括标识符名称、方法和类。特别是,命名、抽象层和单一职责是重要的方面。
遵循领域驱动设计的推理,业务领域应尽可能符合其代码表示。这包括,尤其是,领域语言;也就是说,开发人员和业务专家如何讨论某些特性。整个团队的目标是找到一个统一、无处不在的语言,它不仅用于讨论和演示文稿中,而且在代码中也得到了良好的体现。业务知识的细化将通过迭代方法进行。这种方法不仅包括代码级别的重构,还意味着初始模型从一开始就不会完美地匹配所有需求。
因此,所使用的技术应支持模型和代码的变化。过多的限制会在以后变得难以更改。
对于一般的应用程序开发,尤其是对于重构,拥有足够的自动化软件测试覆盖范围至关重要。一旦代码发生变化,回归测试确保没有业务功能被意外损坏。足够的测试用例因此支持重构尝试,为工程师提供清晰的指示,以确定在修改后功能是否仍然按预期工作。理想情况下,技术应通过不限制代码结构来支持可测试性。第七章 测试 将详细介绍这一主题。
为了实现 可重构性,松耦合比紧耦合更受欢迎。如果其中任何一个发生变化,所有明确调用或需要其他组件的功能都需要进行修改。Java EE 在多个方面支持松耦合;例如,依赖注入、事件或横切关注点,如拦截器。所有这些都简化了代码更改。
有一些工具和方法可以衡量质量。特别是,静态代码分析可以收集有关复杂性、耦合、类和包之间的关系以及一般实现的信息。这些手段可以帮助工程师识别潜在问题,并提供软件项目的整体图景。第六章,应用程序开发工作流程涵盖了如何以自动化的方式验证代码质量。
通常,建议持续重构和改进代码质量。软件项目往往被驱动去实现产生收入的新功能,而不是改进现有功能。问题在于,重构和改进质量往往被认为从业务角度来看没有提供任何好处。当然,这并不是真的。为了实现稳定的速度并满足质量地集成新功能,重新考虑现有功能是绝对必要的。理想情况下,周期性的重构周期已经纳入了项目计划。经验表明,项目经理往往没有意识到这个问题。然而,软件工程师团队有责任解决质量的相关性。
摘要
建议工程师首先关注领域和业务逻辑,从用例边界开始,逐步降低抽象层。Java EE 核心领域组件,即 EJB、CDI、CDI 生产者和事件,用于实现纯业务逻辑。其他 Java EE API 主要用于支持业务逻辑的技术需求。正如我们所见,Java EE 以现代方式实现了和鼓励了众多软件设计模式和领域驱动设计的方法。
我们已经看到了如何选择和实现同步和异步的通信方式。通信技术取决于业务需求。特别是 HTTP 在 Java EE 中通过 JAX-RS 被广泛使用和良好支持。REST 是支持松散耦合系统的通信协议架构风格的典范。
Java EE 自带的功能实现了并启用了诸如管理持久性、配置或缓存等技术横切关注点。特别是 CDI 的使用实现了各种技术驱动的用例。所需的异步行为可以通过不同的方式实现。应用程序不应管理自己的线程或并发管理,而应使用 Java EE 功能。容器管理的执行服务、异步 CDI 事件或 EJB 计时器是应该使用的例子。
Java EE 平台的概念和原则支持以业务逻辑为重点开发企业应用程序。特别是不同标准的精益集成、控制反转、约定优于配置以及“不干涉”的原则,都支持这一方面。工程师应该致力于保持高代码质量,这不仅包括代码级别的重构,还包括精炼业务逻辑和团队共享的通用语言。精炼代码质量以及领域模型的适用性是在迭代步骤中发生的。
因此,技术应该支持模型和代码的变化,而不是对解决方案施加过多的限制。Java EE 通过最小化框架对业务代码的影响,并通过允许功能松散耦合来实现这一点。团队应该意识到重构与自动化测试是高质量软件的必要条件。
以下章节将涵盖其他哪些方面使 Java EE 成为开发企业应用程序的现代和合适平台。我们将看到哪些部署方法是可以取的,以及如何为高效的开发工作流程奠定基础。
第四章:轻量级 Java EE
轻量级 Java EE。这甚至可能吗?在过去,J2EE 应用以及特别是应用服务器被认为是一种重量级且繁琐的技术。在一定程度上,这是有道理的。API 的使用相当不便。需要大量的 XML 配置,这最终导致了XDoclet的使用,这是一个基于放入 JavaDoc 注释中的元信息生成 XML 的工具。应用服务器的工作也相当繁琐,尤其是在启动和部署时间方面。
然而,自从 Java EE 名称变更以及特别是从版本 6 开始,这些假设已经不再成立。引入了注解,这些注解最初源于 XDoclet 驱动的 JavaDoc 标签。而且已经发生了许多事情来提高生产力和开发者体验。
本章将涵盖以下主题:
-
什么使技术轻量级?
-
为什么 Java EE 标准有助于减少工作量
-
如何选择项目依赖和归档格式
-
零依赖企业应用的优势
-
现代 Java EE 应用服务器
-
每个应用服务器一个应用的方案
轻量级企业技术
什么使技术轻量级?在容器和云的时代,Java EE 的轻量级、生产力和相关性如何?
轻量级技术的最重要的方面之一是它所提供的生产力和效率。开发团队所花费的时间是宝贵的且昂贵的,因此花费在冗余上的时间越少越好。这包括开发粘合代码、构建项目、编写和执行测试,以及在本地和远程环境中部署软件。理想情况下,工程师可以尽可能多的时间用于实现盈利的业务功能。
因此,技术不应该在业务用例之上增加太多冗余。技术横切关注点当然有必要,但应尽量保持最小化。在前一章中,我们看到了 Java EE 如何使开发者以高效的方式实现业务用例。项目工件构建和部署也应同样旨在最小化所需的时间和精力。
本章和下一章将展示 Java EE 如何支持构建高效的开发工作流程。
为什么需要 Java EE 标准?
Java EE 的一个原则是提供高效的企业 API。如前一章中“现代 Java EE 的概念和设计原则”部分所示,最大的优势之一是能够集成不同的标准而无需开发者进行配置。Java EE 的伞形架构要求不同的标准能够良好地协同工作。企业容器必须满足这一要求。软件工程师只需针对 API 进行开发,让应用服务器完成困难的集成工作。
按照约定优于配置的方法,使用伞形规范的一部分的不同、集成标准不需要初始配置。如前所述的各个示例所示,Java EE 内部不同标准中产生的技术可以很好地协同工作。我们已经看到了一些示例,例如使用 JSON-B 在 JAX-RS 资源中自动将对象映射到 JSON;通过引入单个注解将 Bean Validation 集成到 JAX-RS 和 HTTP 响应中;将管理 Bean 注入由其他标准定义的实例中,例如 Bean Validation 验证器或 JSON-B 类型适配器;或者在 EJB 中管理跨越 JPA 数据库操作的跨技术事务。
使用一个包含各种可重用技术的伞形标准的替代方案是什么?嗯,引入需要与第三方依赖项手动连接的特定供应商的框架。Java EE API 的最大优点之一是开发者可以直接使用整个技术种类;提供高效集成并节省开发者时间,以便他们可以专注于业务用例。
约定优于配置
追求约定优于配置的理念,进一步来说,企业应用可以在没有任何初始配置的情况下进行开发。API 提供了符合大多数用例的默认行为。工程师只有在默认行为不足时才需要付出额外的努力。
这意味着在当今世界,企业项目可以以最小的配置来设置。大量 XML 配置的日子已经过去了。特别是,不提供 Web 前端技术应用可以保持 XML 文件数量最少。
让我们从提供一个 REST 端点的简单应用示例开始,该应用访问数据库和外部系统。REST 端点通过 JAX-RS 集成,JAX-RS 内部使用 servlet 来处理请求。Servlet 传统上是通过位于WEB-INF下的web.xml部署描述符文件进行配置的。然而,JAX-RS 通过在上一章中展示的Application子类和@ApplicationPath注解提供了一个快捷方式。这为提供的路径注册了一个 JAX-RS 应用 servlet。在启动时,项目将扫描与 JAX-RS 相关的类,如资源或提供者。在应用启动后,即使没有提供web.xml文件,REST 端点也可以处理请求。
管理 Bean 传统上使用beans.xml配置文件进行配置。在 Web 归档应用程序中,此文件也位于WEB-INF下。如今,它主要用于指定 Bean 发现模式,即默认情况下考虑哪些 CDI Bean。建议配置bean-discovery-mode为all,而不仅仅是annotated Bean。beans.xml文件可以覆盖所有类型的 CDI Bean 组合,如拦截器、替代方案、装饰器等。根据 CDI 规范,对于最简单的示例,此文件为空就足够了。
JPA 持久化单元是通过META-INF下的persistence.xml文件配置的。如前所述,它包含应用程序中使用的数据源定义。通过在领域模型类中添加注解来配置将 JPA 实体映射到数据库表。这种方法将关注点保持在单一位置,并最小化了 XML 的使用。
对于大多数不包含 Web 前端的企业应用程序,这种配置量已经足够。前端技术,如 JSF,通常通过web.xml和faces-config.xml配置,或者在需要时通过额外的、特定于实现的文件配置。
在过去,供应商特定的配置文件,如jboss-web.xml或glassfish-web.xml,相当常见。在现代 Java EE 世界中,大多数应用程序不再需要这些解决方案。为了允许可移植性,强烈建议首先使用标准 API 实现功能,只有在合理努力内无法实现时才使用供应商特定的功能。对遗留项目的经验表明,这种方法会导致更好的可管理情况。与供应商特定的功能不同,Java EE 标准保证在未来继续工作。
在应用启动时,容器会扫描可用的类以查找注解和已知类型。管理 Bean、资源、实体、扩展和横切关注点会被发现并适当配置。这种机制对开发者来说是一个巨大的好处。他们不需要在配置文件中显式指定所需的类,而可以依赖服务器的发现;这是控制反转的最佳实践。
Java EE 项目的依赖管理
企业项目的依赖管理针对的是在 JDK 之上添加的依赖。这包括编译、测试和运行时所需的依赖。在 Java 企业项目中,需要使用带有provided依赖范围的 Java EE API。由于 API 在应用服务器上可用,因此不需要包含在打包的归档文件中。因此,提供的 Java EE API 对包大小没有影响。
现实世界的企业项目通常包含比这更多的依赖项。第三方依赖项的典型例子包括日志框架,如Slf4j、Log4j或Logback,JSON 映射框架如Jackson,或通用库如Apache Commons。这些依赖项存在一些问题。
首先,第三方依赖项通常不提供,这会增加构建物的大小。这听起来可能不是那么有害,但有一些影响我们将在后面看到。添加到最终构建物中的依赖项越多,构建所需的时间就越长。构建系统需要在每次构建项目时将可能很大的依赖项复制到构建物中。正如我们将在第六章“应用开发工作流程”中看到的,项目构建需要尽可能快。添加到包中的每个依赖项都会增加周转时间。
依赖项及其版本的潜在冲突代表了一个更大的挑战。这包括打包依赖项、传递依赖项以及已经存在于应用服务器上的库。例如,日志框架通常已经存在于容器的类路径中,可能版本不同。使用不同版本引入了潜在的问题,即库的聚合。经验表明,通过传递添加的隐式依赖项是这方面的最大挑战。
除了技术原因之外,在轻率地将依赖项引入软件项目之前,还有一些其他方面需要考虑。例如,依赖项许可可能会成为开发向客户发货的软件产品时的问题。不仅公司被允许使用某些依赖项,而且涉及的许可在软件包中也需要相互兼容。满足许可标准的最简单方法是不添加依赖项,至少如果它们没有商业用途的话。工程师在考虑安全方面也应做出类似的考虑,特别是对于为对安全性有高度要求的行业开发的软件。
我曾经参与过一个消防员工作,负责更新企业项目的使用框架的版本。该项目包含大量的构建依赖项。随着所有包含的第三方依赖项,项目运行时最终包含了所有已知的日志框架。对于 JSON 映射框架也是如此,它们引入了许多版本冲突和运行时依赖项不匹配。这还是在 JSON-B 和 JSON-P 出现之前。我们大部分时间都在配置项目构建,解开并排除项目工件中的传递依赖项。这是使用第三方库时的典型问题。节省项目代码的代价是花费时间和精力配置项目构建,并可能解开依赖项,尤其是如果它们引入了大量传递功能的话。
通过管理构建依赖项,工程师们专注于对业务用例无关紧要的方面。需要问的问题是,当我们引入依赖项的同时,节省一些代码行是否值得。经验表明,在避免重复与轻量级之间的权衡,例如在无依赖项的项目中,往往更倾向于避免重复。一个典型的例子是,为了使用可以用几行代码实现的功能,项目引入了整个 Apache Commons 库。
虽然不重复造轮子,开发可重用的功能版本是良好的实践,但也要考虑其后果。经验表明,引入的依赖项往往被忽视,并且只被有限地利用。其中大部分几乎没有业务价值。
当工程师检查代码质量时,例如使用代码分析工具,还应考虑针对业务用例的依赖项与项目代码的比例,以及管道。对于依赖项有一个简单的方法可以应用。在引入第三方依赖项之前,考虑几个问题。添加的功能是否增加了业务价值?它节省了多少项目代码?对最终工件的影响有多大?
例如,假设汽车制造应用程序的一部分用例是使用专有的 Java API 与特定工厂软件进行通信。显然,这种通信对于实现业务目标至关重要,将这个依赖项包含在项目中是非常有意义的。相反,添加不同的日志框架几乎不会提高应用程序的业务价值。此外,第九章监控、性能和日志将讨论传统日志的问题。
然而,为了避免不必要地增加构建大小,关键依赖项可以安装在应用程序服务器上,并在项目的构建中声明为提供。
在第一章中,我们看到了共享业务依赖(如共享模型)的困难。理想情况下,应用程序尽可能自给自足。第八章 微服务和系统架构 将深入探讨自包含系统和共享无架构的动机。
关于技术依赖,然而,Java EE API 已经包含了大多数企业应用所需的技术。理想情况下,工程师开发零依赖的 Java EE 应用程序,这些应用程序被打包成仅包含相关类的薄部署工件。如果某些用例需要第三方依赖,它们可以安装在容器中。目标是使部署工件具有轻量级的足迹。
对于生产代码,这意味着只包含提供的依赖项,理想情况下,只包含 Java EE API。然而,测试依赖项却是一个不同的情况;软件测试需要一些额外的技术。第七章 测试 涵盖了测试范围所需的依赖项。
轻量级打包应用程序的方式
零依赖应用程序的方法简化了许多项目构建的担忧。由于没有包含任何依赖项,因此无需管理第三方依赖项的版本或冲突。
这种方法简化了哪些其他方面?无论使用 Gradle 还是 Maven,项目构建在不需要添加任何内容到工件时总是表现出最佳性能。包的大小直接影响构建时间。在零依赖的应用程序的情况下,只包含编译后的类,即只有实际的业务逻辑。因此,生成的构建时间是最小的。所有构建时间都用于编译项目的类、运行测试用例以及将类打包成薄部署工件。采用这种方法构建应该只需几秒钟。是的,几秒钟。一般来说,任何超过 10 秒的项目构建都应该重新考虑。
当然,这条规则对项目构建施加了一定的压力。它自然要求避免包含任何较大的依赖或实现;这些应由应用程序服务器提供。测试运行时间通常是另一个防止快速构建的方面。第七章 测试 将阐明如何以有效的方式开发测试。
快速构建是创建零依赖应用程序的一个好处。另一个影响是快速工件传输。构建的工件,如 WAR 或 JAR 文件,通常保存在工件存储库中,以供以后使用,例如Sonatype Nexus或JFrog Artifactory。如果只涉及少量数据,通过网络传输这些工件会大大加快速度。这适用于所有类型的工件部署。无论构建的存档被发送到何处,较小的体积总是有回报的,尤其是在工作流程经常执行的情况下,例如持续交付。
重新审视实践并剥离所有不提供价值的部分,其目标也针对了应用程序的打包方式。传统上,企业应用程序是以 EAR 文件的形式交付的。这些文件的结构包括一个 Web 存档、一个 WAR 文件和一个或多个企业 JAR 文件。企业 JAR 存档包含了业务逻辑,通常是用 EJB 实现的。Web 存档包含了与业务逻辑通过本地或远程 EJB 进行通信的 Web 服务和前端技术。然而,这种分离并不是必要的,因为所有组件都部署在单个服务器实例上。
在多个子存档中打包几个技术问题不再是必需的。所有业务逻辑以及 Web 服务和横切关注点都打包到一个单一的 WAR 文件中。这极大地简化了项目设置以及构建过程。应用程序不需要在多个层次结构中压缩,只是为了在单个服务器实例上再次解压缩。包含在容器中部署的所需业务代码的 WAR 文件是薄型工件的最佳实现。正因为如此,部署薄型 WAR 文件比相应的 EAR 文件要快。
以下演示了一个典型的薄型 Web 应用程序工件的内容:

部署工件只包含实现业务用例所需的类,没有特定技术的实现,只有最小配置。特别是,不包括库 JAR 文件。
Java EE 平台的架构鼓励轻量级工件。这是因为平台将 API 与实现分离。开发者只针对 API 编程;应用程序服务器实现 API。这使得只发送业务逻辑成为可能,这些业务逻辑包括轻量级工件中的某些方面。除了避免依赖冲突和构建供应商独立解决方案的明显好处外,这种方法还使快速部署成为可能。工件包含的内容越少,容器端需要解包的内容就越少。因此,将企业应用程序打包成一个单一的 WAR 文件是非常推荐的。
在过去的一年里,我们看到了越来越多的兴趣在于将企业应用程序作为胖JAR 进行打包,也就是说,将应用程序与其实现一起打包。胖部署工件的方法通常在企业框架,如 Spring 框架中使用。这些方法的背后动机是,所需的依赖项和框架的版本被明确指定,并随业务逻辑一起打包。胖部署工件可以创建为胖 WAR,部署在 servlet 容器上,或者作为独立的、可执行的 JAR 启动。因此,打包为胖 JAR 的 Java EE 应用程序将企业容器与应用程序一起打包在一个可执行的 JAR 中。然而,正如之前所述,如果将第三方依赖项添加到工件中,构建、打包和部署时间将大大增加。
经验表明,将企业实现与应用程序一起明确打包,在大多数情况下不是技术上的原因,而是业务政治上的原因。公司内部对应用程序服务器和 Java 安装缺乏灵活性的运营环境,特别是在版本升级方面,有时迫使开发者寻找解决方案。使用较新技术构建的企业应用程序不能部署在较旧的现有服务器安装上。有时,业务政治上更容易的解决方案是完全忽略现有安装,并直接执行独立的 JAR,这只需要特定的 Java 版本。然而,虽然这些解决方案无疑是合理的,但技术上更合理的解决方案是将应用程序打包到瘦部署工件中。有趣的是,正如我们将在下一章中看到的,在 Linux 容器中打包软件具有两种方法的优势。
另一种有趣的方法允许将整个应用程序作为一个可执行的包进行打包,并保持快速的工作流程进行瘦部署。几个应用程序服务器供应商提供将自定义应用程序容器作为可执行的 JAR 进行打包的解决方案,在启动时将瘦应用程序作为附加参数部署。通过这样做,整个包包括业务逻辑和实现,并作为一个独立的应用程序启动。应用程序仍然与其运行时分离,并打包为所谓的空JAR 或 WAR 文件。这种方法在没有使用 Linux 容器的情况下,如果需要这种灵活性,尤其有意义。
作为结论,强烈建议构建瘦部署工件,理想情况下是瘦 WAR 文件。如果出于业务政治原因这种方法不可行,空 JAR 可以提供一个合理的解决方案。然而,正如我们将在下一章中看到的,容器技术,如 Docker,不需要使用可执行 JAR 方法,并提供相同的优势。
Java EE 应用服务器
除了 API 的生产力之外,还有什么让企业技术变得轻量级?运行时和企业容器呢?
开发者经常抱怨 J2EE 应用服务器运行太慢、使用起来太繁琐、难以操控。安装大小和内存消耗相当高。通常,许多应用程序在服务器实例上并行运行,同时各自重新部署。这种方法有时会引入额外的挑战,例如类加载器层次结构问题。
现代应用服务器远非这种负面形象。大多数应用服务器都针对启动和部署时间进行了大量优化。特别是,服务器内部模块方法,如开放服务网关倡议(OSGi),通过按需加载所需模块,支持完整的 Java EE API,极大地加快了操作速度。在资源使用方面,与过去相比,应用服务器也取得了很大的进步。现代容器消耗的内存比桌面计算机上运行的浏览器实例要少。例如,一个Apache TomEE实例在一秒钟内启动,磁盘上消耗不到 40 兆字节,内存消耗不到 30 兆字节。
管理 bean 的性能开销同样可以忽略不计。实际上,与 CDI 管理 bean 和其他框架,如 Spring 框架相比,无状态 EJBs 表现出最佳结果。这是因为无状态会话 bean 在调用业务方法后会被池化和复用。
此外,应用服务器还管理着连接池和线程池,并允许工程师直接使用这些数据,无需引入自定义指标,从而能够直接从容器中收集统计信息和性能洞察。容器负责提供对这些方面的监控。DevOps 工程师可以直接使用这些数据,而无需引入自定义指标。
除了这些方面,应用服务器还管理着 bean 实例和生命周期、资源以及数据库事务,正如我们在上一章所看到的。
这就是拥有应用容器的意义所在。它执行运行企业应用所需的工作;软件工程师负责处理业务逻辑。容器提供并管理所需资源,并且根据标准被迫提供已部署应用的洞察。由于许多厂商在优化所需技术方面投入了大量努力,资源开销可以保持较低水平。
应用服务器的安装大小仍然比其他企业框架要大一些。截至本书编写时,供应商们正在努力提供更小、按需运行的运行时,以满足应用程序的需求。MicroProfile 创新项目包括几个应用服务器供应商,它们定义了与 Java EE 遮罩相补充的附加企业配置文件。这些配置文件也是从 Java EE 标准中组装的。这对于开发者来说是一个非常有趣的方法,因为它不需要在应用程序方面进行任何更改。运行时,即包含的标准集,将根据应用程序的需求进行调整,以满足其业务逻辑。
每个应用服务器一个应用程序
传统上,由于安装大小大和启动时间长,应用服务器被用来部署多个,如果不是几十个企业应用程序。服务器实例被多个团队共享,有时是整个公司。这带来了一定的不灵活性,类似于共享应用程序模型。团队不能简单地选择新的 JDK 或服务器版本,或者在没有与其他团队协调的情况下重启或重新配置应用服务器。这自然会阻碍快速和高效的过程,并使持续交付变得复杂。
在团队协作方法、项目和应用程序生命周期方面,因此最简单的方法是在专用应用服务器上部署应用程序。DevOps 团队对其版本、配置和生命周期拥有完全控制权。这种方法简化了流程,避免了与其他团队和技术冲突等潜在问题。部署多个应用程序可能引入的层次化类加载问题也得到了避免。
应用服务器当然代表了一个相当大的结构,仅用于单个应用程序。然而,正如我们之前所看到的,应用服务器的安装大小已经与过去相比有所下降。除此之外,开发者应该更加关注部署实体的尺寸,因为这些是开发工作流程中的移动部分。在持续交付方法中,应用程序可能每天都会被构建和打包多次。项目构建和传输实体的时间越长,周转时间就越长。这会影响每一个构建,并在一天中累积大量的开销。应用服务器并不经常安装和运输。因此,建议将应用程序部署到单个、专用的 Java EE 服务器:

在下一章中,我们将看到容器技术,如 Docker,如何支持这种方法。将应用程序,包括整个堆栈作为容器,直到操作系统,都作为容器运输,鼓励每个应用服务器一个应用程序的方法。
摘要
多个 Java EE 标准与约定优于配置方法的无缝集成,最大限度地减少了开发者需要做的样板工作。因此,现代企业应用程序的配置被保持在最低限度。特别是默认约定,适用于大多数企业应用程序,并且只有在需要时才允许覆盖配置,这提高了开发者的生产力。
企业应用程序应尽量减少其依赖性,理想情况下仅依赖于提供的 Java EE API。只有在业务上是必需的,而不是技术上的必需时,才应添加第三方依赖。
Java EE 应用程序应打包为轻量级的 WAR 文件,遵循零依赖的方法。这有助于减少构建和发布应用程序所需的时间。
现代 Java EE 应用程序远非重型 J2EE 运行时的负面形象。它们启动和部署速度快,并试图减少内存影响。虽然应用服务器可能不是最轻量级的运行时,但它们提供了足够的好处,例如集成技术或管理生命周期、连接、事务或线程,否则这些都需要实现。
为了简化应用程序的生命周期和部署,建议每个应用程序服务器部署一个应用程序。这消除了几个潜在挑战,并且完美地适应了现代容器技术世界。下一章将向您展示在云平台时代的这个现代世界,容器技术是什么,以及 Java EE 如何融入这幅画面。
第五章:Java EE 的容器和云环境
过去几年,对容器以及云技术的兴趣很大。绝大多数构建软件的公司至少在考虑将这些环境迁移到这些现代方法。在我最近的所有项目中,这些技术都是讨论的焦点。特别是,引入容器编排技术极大地影响了应用程序的运行方式。
容器技术的益处是什么?为什么公司应该关注云计算?似乎很多这些担忧都被用作流行语,作为一种银弹方法。本章将探讨这些技术背后的动机。我们还将看看 Java EE 平台是否为这个新世界做好了准备。
本章将涵盖:
-
基础设施即代码如何支持运营
-
容器技术和编排
-
为什么 Java EE 特别适合这些技术
-
云平台及其动机
-
12 因素,云原生企业应用程序
动机与目标
容器、容器编排和云环境背后的动机是什么?为什么我们在这个领域看到如此强劲的动力?
传统上,企业应用程序部署的工作方式如下。应用程序开发者实现了某些业务逻辑并将应用程序构建成一个打包的工件。这个工件被手动部署到由人工管理的应用程序服务器上。在服务器部署或重新配置期间,应用程序通常面临停机时间。
自然地,这种方法是一个相当高风险的过程。人工任务容易出错,并且不能保证每次都以相同的方式进行执行。人类在执行自动化、重复性工作方面相当糟糕。例如安装应用程序服务器、操作系统和服务器等过程,需要精确的文档,特别是为了未来的可重复性。
在过去,操作团队的典型任务是使用票务系统进行订购并手动执行。这样做,服务器安装和配置的风险是将系统转变为不可重复的状态。设置一个与当前环境相同的新环境需要大量的手动调查。
操作任务需要自动化和可重复。安装新的服务器、操作系统或运行时应该始终以完全相同的方式进行执行。自动化流程不仅加快了执行速度,还引入了透明度,揭示了哪些精确步骤已被执行。重新安装环境应该产生与之前完全相同的运行时,包括所有配置和设置。
这也包括应用程序的部署和配置。而不是手动构建和分发应用程序,持续集成服务器负责以自动化、可靠和可重复的方式构建软件。CI 服务器作为软件构建的“黄金真相”来源。在那里产生的工件被部署到所有相关环境中。软件工件在持续集成服务器上构建一次,然后通过集成和端到端测试进行验证,直到最终进入生产环境。因此,部署到生产环境的同一应用程序二进制文件在部署前已经得到了可靠的测试。
另一个非常重要的方面是明确使用软件的版本。这包括所有使用的软件依赖项,从应用程序服务器和 Java 运行时,到操作系统及其二进制文件。重新构建或重新安装软件应每次都产生完全相同的状态。软件依赖项是一个复杂的话题,伴随着许多潜在错误的可能性。应用程序被测试以在具有特定配置和依赖项的特定环境中正常工作。为了确保应用程序按预期工作,它被以在之前已验证的配置中发货。
这个方面也意味着用于验证应用程序行为的测试和预演环境应尽可能接近生产环境。从理论上讲,这个限制听起来是合理的。从经验来看,所使用的环境在软件版本、网络配置、数据库、外部系统、服务器实例数量等方面与生产环境差异很大。为了正确测试应用程序,这些差异应尽可能消除。在“容器”部分,我们将看到容器技术如何在这里提供支持。
基础设施即代码
要实现可重复的环境,一个合理的结论是利用基础设施即代码(IaC)。其理念是所有必需的步骤、配置和版本都明确地定义为代码。这些代码定义直接用于配置基础设施。基础设施即代码可以以程序形式实现,例如脚本,或者以声明方式实现。后一种方法指定了期望的目标状态,并使用额外的工具执行。无论哪种方法被优先考虑,关键是整个环境都作为代码指定,以自动化、可靠和可重复的方式执行,始终产生相同的结果。
无论如何,这种方法意味着将手动步骤保持在最低限度。基础设施即代码最简单的形式是 shell 脚本。脚本应从头到尾执行,无需人工干预。所有 IaC 解决方案都适用同样的原则。
自然地,安装和配置环境的责任从运维团队更多地转向了开发者。由于开发团队对所需的运行时设置了一定的要求,因此所有工程团队共同工作是合理的。这就是 DevOps 运动背后的理念。在过去,操作的心态和方法往往是在应用开发者实现了软件并直接将软件和责任传递给运维团队,而运维团队没有进一步的参与。生产中的潜在错误主要涉及运维团队。这个不幸的过程不仅导致了工程团队之间的紧张关系,而且最终降低了质量。然而,整体目标应该是交付高质量的软件,以满足其目的。
这个目标需要应用开发者的问责制。通过将所有必需的基础设施、配置和软件定义为代码,所有工程团队自然地协同工作。DevOps 旨在实现整个软件团队的问责制。基础设施即代码是一个先决条件,它增加了可重复性、自动化,并最终提高了软件质量。
在“容器”和“容器编排框架”这个主题中,我们将看到所展示的技术是如何实现基础设施即代码(IaC)的。
稳定性和生产就绪性
持续交付的实践包括为了提高软件的质量和价值需要做什么。这包括应用程序的稳定性。重新配置和重新部署软件不必导致任何停机时间。新特性和错误修复不必仅在维护窗口期间发布。理想情况下,企业软件可以持续改进并向前发展。
一种零停机时间的方法需要一定的努力。为了避免应用程序不可用,至少需要同时存在另一个软件实例。前端需要一个负载均衡器或代理服务器将流量引导到可用的实例。蓝绿部署利用了这种技术:

应用程序实例及其数据库由负载均衡器进行复制和代理。涉及的应用程序通常代表不同的软件版本。从蓝色路径切换到绿色路径,反之亦然,可以立即更改版本,而无需任何停机时间。其他形式的蓝绿部署可以包括多个应用程序实例的场景,这些实例都配置为使用相同的数据库实例。
显然,这种方法并不一定需要使用一些闪亮的新技术。我们过去看到过使用自建解决方案实现零停机时间的蓝绿部署。然而,现代技术支持这些技术,无需太多工程努力即可提高稳定性、质量和生产就绪性。
容器
近年来,对Linux 容器技术的兴趣日益浓厚。从技术上讲,这种方法并不新颖。像Solaris这样的 Linux 操作系统很久以前就支持容器。然而,Docker通过提供以统一方式构建、管理和运输容器的功能,在这一技术上实现了突破。
容器和虚拟机(VMs)之间的区别是什么?是什么让容器如此有趣?
虚拟机就像计算机中的计算机。它们允许从外部轻松管理运行时,例如快速且理想地以自动化的方式创建、启动、停止和分发机器。如果需要设置新的服务器,可以部署所需类型的蓝图或镜像,而无需每次从头开始安装软件。可以拍摄运行环境的快照以轻松备份当前状态。
在许多方面,容器表现得就像虚拟机。它们与主机以及其他容器分离,在自己的网络和文件系统中运行,并且可能拥有自己的资源。区别在于虚拟机在硬件抽象层上运行,模拟包括操作系统在内的计算机,而容器则直接在主机的内核中运行。与其它内核进程不同,容器通过操作系统功能与系统其他部分分离。它们管理自己的文件系统。因此,容器表现得像独立的机器,但具有原生性能,而没有抽象层的开销。虚拟机的性能自然会因抽象而降低。而虚拟机在操作系统选择上提供了完全的灵活性,容器则始终在相同的内核中运行,因此与宿主操作系统的版本相同。因此,容器不需要携带自己的 Linux 内核,可以最小化到所需的二进制文件。
容器技术,如 Docker,提供了一种统一的方式来构建、运行和分发容器。Docker 将构建容器镜像定义为基础设施即代码(IaC),这再次实现了自动化、可靠性和可重复性。Dockerfile 定义了安装应用程序及其依赖项(例如应用程序容器和 Java 运行时)所需的全部步骤。Dockerfile 中的每一步都对应于在镜像构建时执行的命令。一旦从镜像启动容器,它应该包含完成其任务所需的一切。
容器通常包含一个 Unix 进程,代表一个运行中的服务,例如应用程序服务器、Web 服务器或数据库。如果企业系统由多个运行中的服务器组成,它们将在各自的容器中运行。
Docker 容器的最大优点之一是它们使用 写时复制 文件系统。每个构建步骤,以及之后的每个运行中的容器,都在一个分层文件系统上操作,该文件系统不会改变其层,而只是在上面添加新的层。因此,构建的镜像包含多个层。
从镜像创建的容器总是以相同的初始状态启动。运行中的容器可能会作为新的、临时的文件系统层修改文件,一旦容器停止,这些层就会被丢弃。因此,默认情况下,Docker 容器是无状态的运行时环境。这鼓励了可重复性的想法。每个持久行为都需要明确定义。
当重新构建和重新分发镜像时,多个层是有益的。Docker 缓存中间层,并且只重新构建和重新传输已更改的内容。
例如,镜像构建可能由多个步骤组成。首先添加系统二进制文件,然后是 Java 运行时,一个应用程序服务器,最后是我们的应用程序。当对应用程序进行更改并需要新的构建时,只需重新执行最后一步;之前的步骤被缓存。同样,对于通过网络传输镜像也是如此。只有已更改且在目标仓库中尚不存在的层才会实际重新传输。
以下说明了 Docker 镜像的层及其各自的分发:

Docker 镜像要么从头开始构建,即从一个空起点开始,要么基于现有的基础镜像构建。有大量的基础镜像可供选择,包括所有主要的 Linux 发行版,包含包管理器,典型的环境栈以及基于 Java 的镜像。基础镜像是一种在共同基础上构建的方式,并为所有生成的镜像提供基本功能。例如,使用包含 Java 运行时安装的基础镜像是有意义的。如果这个镜像需要更新,例如,为了修复安全问题,所有依赖的镜像都可以重新构建,并通过更新基础镜像版本来接收新内容。正如之前所说,软件构建需要可重复性。因此,我们总是需要为软件工件(如镜像)指定明确的版本。
从先前构建的 Docker 镜像启动的容器需要访问这些镜像。这些镜像通过 Docker 仓库进行分发,例如公开可用的 DockerHub 或公司内部的镜像仓库来分发自己的镜像。本地构建的镜像被推送到这些仓库,并在稍后启动新容器的环境中检索。
容器中的 Java EE
结果表明,分层文件系统的方法与 Java EE 将应用程序与运行时分离的方法相匹配。轻量级部署工件仅包含实际的业务逻辑,这部分内容会发生变化,并且每次都需要重建。这些工件部署到一个企业容器上,这个容器不经常改变。Docker 容器镜像是通过逐步、分层构建的。构建企业应用程序镜像包括操作系统基础镜像、Java 运行时、应用程序服务器,最后是应用程序。如果只有应用程序层发生变化,那么只有这一步需要重新执行和重新传输 - 其他所有层只接触一次然后缓存。
轻量级部署工件利用了层的优势,因为只需要重建和重新分配几 KB 的内容。因此,零依赖的应用程序是使用容器的推荐方式。
如前一章所述,每个应用程序服务器部署一个应用程序是有意义的。容器执行单个进程,在这种情况下是包含应用程序的应用程序服务器。因此,应用程序服务器需要在容器中运行的专用容器上运行,这个容器也包含在容器中。应用程序服务器和应用程序都是在镜像构建时添加的。潜在的配置,例如关于数据源、连接池或服务器模块的配置,也是在构建时进行的,通常是通过添加自定义配置文件来实现的。由于容器属于单个应用程序,因此这些组件的配置不会影响其他任何东西。
一旦从镜像启动容器,它应该已经包含完成其工作所需的一切。应用程序以及所有必需的配置必须已经存在。因此,应用程序不再部署到之前运行的容器上,而是在镜像构建时添加,以便在容器运行时存在。这通常是通过将部署工件放入容器的自动部署目录中实现的。一旦配置的应用程序服务器启动,应用程序就会被部署。
容器镜像只构建一次,然后在所有环境中执行。遵循之前可重复工件的想法,在生产中运行的同一样件必须事先进行测试。因此,经过验证的相同 Docker 镜像将被发布到生产环境中。
但如果应用程序在不同的环境中配置不同怎么办?如果需要与不同的外部系统或数据库进行通信怎么办?为了不干扰多个环境,至少使用的数据库实例将不同。在容器中交付的应用程序是从相同的镜像启动的,但有时仍然需要一些变化。
Docker 提供了改变运行容器多个方面的可能性。这包括网络配置、添加卷,即注入位于 Docker 主机上的文件和目录,或添加 Unix 环境变量。环境差异由容器编排从容器外部添加。镜像只为特定版本构建一次,在不同环境中使用和可能修改。这带来了巨大的优势,即这些配置差异不是建模到应用程序中,而是从外部进行管理。这一点对于网络和连接应用程序以及外部系统也是如此,我们将在接下来的章节中看到。
顺便说一句,Linux 容器解决了由于灵活性原因,将应用程序及其实现打包在一起进行运输的商业和政治动机问题。由于容器包含了运行时以及所有必需的依赖项,包括 Java 运行时,因此基础设施只需提供 Docker 运行时即可。所有使用的技术及其版本都是开发团队的责任。
以下代码片段展示了构建企业应用程序hello-cloud到WildFly基础镜像的Dockerfile定义。
FROM jboss/wildfly:10.0.0.Final
COPY target/hello-cloud.war /opt/jboss/wildfly/standalone/deployments/
Dockerfile指定了特定版本的jboss/wildfly基础镜像,该镜像已经包含了 Java 8 运行时和 WildFly 应用程序服务器。它位于应用程序的项目目录中,指向之前由 Maven 构建的hello-cloud.war存档文件。WAR 文件被复制到 WildFly 的自动部署目录,并在容器运行时在该位置可用。jboss/wildfly基础镜像已经指定了运行命令,即如何运行应用程序服务器,这由Dockerfile继承。因此,它不需要再指定命令。在 Docker 构建后,生成的镜像将包含从jboss/wildfly基础镜像中的一切,包括hello-cloud应用程序。这与从头开始安装 WildFly 应用程序服务器并将 WAR 文件添加到自动部署目录的方法相同。在分发构建的镜像时,只需传输包含薄 WAR 文件的附加层即可。
Java EE 平台的部署模型适合容器世界。将应用程序与企业容器分离利用了 copy-on-write 文件系统的使用,最小化了构建、分发或部署所花费的时间。
容器编排框架
让我们从容器抽象层上升一级。容器包括运行特定服务所需的一切,作为无状态、自包含的工件。然而,容器需要被编排以在正确的网络中运行,能够与其他服务通信,并在需要时以正确的配置启动。直接的方法是开发自制的脚本以运行所需的容器。然而,为了实现更灵活的解决方案,同时也能实现生产就绪性,如零停机时间,建议使用容器编排框架。
如Kubernetes、DC/OS或Docker Compose之类的容器编排框架不仅负责运行容器,还要适当地编排、连接和配置它们。对于容器技术同样适用的动机和原则也适用:自动化、可重复性和基础设施即代码(IaC)。软件工程师将期望的目标状态定义为代码,并让编排工具可靠地设置所需的环境。
在深入研究特定的编排解决方案之前,让我们更详细地看看这些基本概念。
编排框架使我们能够将多个容器连接在一起。这通常涉及通过 DNS 使用逻辑名称进行服务查找。如果使用多个物理主机,框架将在这些节点上解析 IP 地址。理想情况下,运行在容器中的应用程序只需连接到外部系统,使用由容器编排解析的逻辑服务名称。例如,一个使用vehicle数据库的汽车制造应用程序通过vehicle-db主机名进行连接。然后,该主机名通过 DNS 解析,具体取决于应用程序运行的环境。通过逻辑名称连接可以减少应用程序代码中所需的配置,因为配置的连接始终相同。编排只是连接所需的实例。
这适用于所有提供的系统。应用程序、数据库和其他服务器都被抽象为逻辑服务名称,这些名称在运行时被访问和解析。
根据其环境配置容器是编排框架解决的问题的另一个方面。一般来说,建议减少应用程序中所需的配置。然而,有些情况下可能需要一些配置工作。框架的责任是根据具体情况动态注入文件或环境变量,以提供容器配置。
一些容器编排框架提供的生产就绪功能是它们最大的优势之一。应用程序的持续开发会触发新的项目构建,并导致新的容器镜像版本的产生。运行中的容器需要被从这些新版本启动的容器所替换。为了避免停机时间,容器编排使用零停机部署方法来交换运行中的容器。
同样地,容器编排使得通过扩展容器实例的数量来增加工作负载成为可能。在过去,某些应用程序同时运行在多个实例上。如果需要增加实例数量,就必须提供更多的应用程序服务器。在容器世界中,通过简单地启动更多的无状态应用程序容器来实现相同的目标。开发者增加配置的容器副本数;编排框架通过启动更多的容器实例来实现这一变化。
为了在生产环境中运行容器,必须考虑一些编排方面的问题。经验表明,一些公司倾向于构建自己的解决方案,而不是使用既定标准技术。然而,容器编排框架已经很好地解决了这些问题,并且强烈建议至少考虑它们。
实现容器编排
我们现在已经看到了容器编排框架所面临的挑战。本节将向您展示Kubernetes的核心概念,这是一个最初由 Google 开发来运行其工作负载的解决方案。在撰写本书时,Kubernetes 具有巨大的动力,也是其他编排解决方案(如 RedHat 的OpenShift)的基础。我选择这个解决方案是因为它的普及,同时也因为我相信它非常擅长编排工作。然而,重要的点不在于理解所选技术,而在于其背后的动机和概念。
Kubernetes 在节点集群中运行和管理 Linux 容器。Kubernetes 主节点编排工作节点,这些节点执行实际工作,即运行容器。软件工程师通过主节点提供的 API、基于 Web 的 GUI 或命令行工具来控制集群。
运行中的集群由特定类型的所谓资源组成。Kubernetes 的核心资源类型是pods、deployments和services。一个 pod 是一个原子工作负载单元,运行一个或多个 Linux 容器。这意味着应用程序是在 pod 中运行的。
Pod 可以作为独立、单一的资源启动和管理。然而,不直接指定单独的 Pod,而是定义一个部署,封装并管理运行中的 Pod,这样做非常有意义。部署提供了生产就绪功能,如 Pod 的扩展和缩减或滚动更新。它们负责以指定版本可靠地运行我们的应用程序。
系统定义服务以便从集群外部或其他容器内连接到运行中的应用程序。服务提供了上一节中描述的逻辑抽象,它包含了一组 Pod。所有运行特定应用程序的 Pod 都被单个服务抽象,该服务将流量导向活动 Pod。服务路由到活动 Pod 的组合以及管理版本滚动更新的部署使得零停机部署成为可能。应用程序始终通过服务访问,这些服务指向相应的 Pod。
所有核心资源在 Kubernetes 命名空间内都是唯一的。命名空间封装资源聚合,并可用于建模不同的环境。例如,指向集群外部的系统中的服务可以在不同的命名空间中配置不同。使用外部系统的应用程序始终使用相同的逻辑服务名称,这些名称指向不同的端点。
Kubernetes 支持使用 JSON 或 YAML 文件定义资源作为 IaC。YAML 格式是一种人类可读的数据序列化格式,是 JSON 的超集。它已成为 Kubernetes 中的事实标准。
以下代码片段显示了hello-cloud应用程序服务的定义:
---
kind: Service
apiVersion: v1
metadata:
name: hello-cloud
spec:
selector:
app: hello-cloud
ports:
- port: 8080
---
示例指定了一个服务,该服务将端口8080上的流量导向由部署定义的hello-cloudPod。
以下展示了hello-cloud部署:
---
kind: Deployment
apiVersion: apps/v1beta1
metadata:
name: hello-cloud
spec:
replicas: 1
template:
metadata:
labels:
app: hello-cloud
spec:
containers:
- name: hello-cloud
image: docker.example.com/hello-cloud:1
imagePullPolicy: IfNotPresent
livenessProbe:
httpGet:
path: /
port: 8080
readinessProbe:
httpGet:
path: /hello-cloud/resources/hello
port: 8080
restartPolicy: Always
---
部署指定了一个来自给定模板的 Pod,并使用提供的 Docker 镜像。一旦部署创建,Kubernetes 就会尝试通过从镜像启动容器并使用指定的探针测试容器的健康状态来满足 Pod 规范。
容器镜像docker.example.com/hello-cloud:1包含了之前构建并分发到 Docker 注册表的商业应用程序。
所有这些资源定义都可以通过使用基于 Web 的 GUI 或 CLI 应用到 Kubernetes 集群。
在创建部署和服务之后,hello-cloud应用程序可以通过服务在集群内部访问。要从集群外部访问,需要定义一个路由,例如使用入口。入口资源使用特定规则将流量路由到服务。以下是一个示例入口资源,它使hello-cloud服务可用:
---
kind: Ingress
apiVersion: extensions/v1beta1
metadata:
name: hello-cloud
spec:
rules:
- host: hello.example.com
http:
paths:
- path: /
backend:
serviceName: hello-cloud
servicePort: 8080
---
这些资源现在指定了整个应用,该应用部署到了 Kubernetes 集群中,可以从外部访问,并在集群内部以逻辑服务的形式抽象化。如果其他应用需要与应用通信,它们可以通过 Kubernetes 内部的、可解析的 hello-cloud DNS 主机名和端口 8080 来实现。
以下图示展示了 hello-cloud 应用程序的一个示例设置,其中包含三个副本的 pod,这些 pod 在一个由两个节点组成的 Kubernetes 集群中运行:

除了使用逻辑名称进行服务查找之外,一些应用程序仍然需要额外的配置。因此,Kubernetes 以及其他编排技术都有在运行时动态将文件和环境变量插入容器的可能性。为此,使用了基于键值对的 配置映射 概念。配置映射的内容可以作为文件提供,动态挂载到容器中。以下定义了一个示例配置映射,指定了属性文件的内容:
---
kind: ConfigMap
apiVersion: v1
metadata:
name: hello-cloud-config
data:
application.properties: |
hello.greeting=Hello from Kubernetes
hello.name=Java EE
---
配置映射正在被用来将内容作为文件挂载到容器中。配置映射的键将被用作文件名,挂载到一个目录中,其值代表文件内容。pod 定义指定了挂载为卷的配置映射的使用。以下展示了 hello-cloud 应用的先前部署定义,使用挂载卷中的 hello-cloud-config:
---
kind: Deployment
apiVersion: apps/v1beta1
metadata:
name: hello-cloud
spec:
replicas: 1
template:
metadata:
labels:
app: hello-cloud
spec:
containers:
- name: hello-cloud
image: docker.example.com/hello-cloud:1
imagePullPolicy: IfNotPresent
volumeMounts:
- name: config-volume
mountPath: /opt/config
livenessProbe:
httpGet:
path: /
port: 8080
readinessProbe:
httpGet:
path: /hello-cloud/resources/hello
port: 8080
volumes:
- name: config-volume
configMap:
name: hello-cloud-config
restartPolicy: Always
---
部署定义了一个卷,该卷引用了 hello-cloud-config 配置映射。该卷挂载到 /opt/config 路径,导致配置映射的所有键值对作为文件插入到这个目录中。使用前面演示的配置映射,这将导致一个包含 hello.greeting 和 hello.name 键条目的 application.properties 文件。应用期望在运行时文件位于这个位置。
不同的环境将指定不同的配置映射内容,这取决于所需的配置值。使用动态文件配置应用是一种方法。也有可能注入和覆盖特定的环境变量。以下代码片段也展示了这个例子。当配置值的数量有限时,这种方法是可取的:
# similar to previous example
# ...
image: docker.example.com/hello-cloud:1
imagePullPolicy: IfNotPresent
env:
- name: GREETING_HELLO_NAME
valueFrom:
configMapRef:
name: hello-cloud-config
key: hello.name
livenessProbe:
# ...
应用需要配置凭证,例如用于授权外部系统或作为数据库访问。这些凭证最好配置在不同于非关键配置值的地方。除了配置映射之外,Kubernetes 因此还包括了 机密 的概念。这些与配置映射类似,也代表键值对,但对人类来说是 Base64 编码的数据。机密及其内容通常不会作为基础设施代码序列化,因为凭证不应拥有无限制的访问权限。
一种常见的做法是使用环境变量在容器中使凭证可访问。以下代码片段展示了如何将秘密 hello-cloud-secret 中配置的值包含到 hello-cloud 应用程序中:
# similar to previous example
# ...
image: docker.example.com/hello-cloud:1
imagePullPolicy: IfNotPresent
env:
- name: TOP_SECRET
valueFrom:
secretKeyRef:
name: hello-cloud-secret
key: topsecret
livenessProbe:
# ...
环境变量 TOP_SECRET 是通过引用秘密 hello-cloud-secret 中的 topsecret 键创建的。这个环境变量在容器运行时可用,可以从运行进程中使用。
一些打包在容器中的应用程序不能仅作为无状态应用程序运行。数据库是这种情况的典型例子。由于容器在其进程退出后会被丢弃,因此它们的文件系统内容也会消失。像数据库这样的服务需要持久状态。为了解决这个问题,Kubernetes 包括 持久卷。正如其名所示,这些卷在 Pod 的生命周期之外可用。持久卷动态提供在 Pod 中使用并在其退出后保留的文件和目录。
持久卷由网络附加存储或云存储服务提供支持,具体取决于集群安装。这使得在容器编排集群中运行数据库等存储服务也成为可能。然而,作为一般建议,容器中的持久状态应避免使用。
YAML IaC 定义保存在应用程序仓库的版本控制下。下一章将介绍如何将文件内容应用于 Kubernetes 集群,作为持续交付管道的一部分。
Java EE 在编排容器中
编排框架在集群环境中编排和集成企业应用程序。它大大减轻了使用应用程序技术的负担。容器编排也极大地简化了配置应用程序和连接外部服务的方式。本节将展示这一点。
连接外部服务
客户端控制需要连接以集成外部服务的 URL。传统上,这些 URL 已在文件中配置,可能在各种环境中有所不同。在编排环境中,应用程序可以通过 DNS 使用逻辑名称解析外部服务。以下代码片段展示了如何连接到 cloud processor 应用程序:
@ApplicationScoped
public class HelloCloudProcessor {
private Client client;
private WebTarget target;
@PostConstruct
private void initClient() {
client = ClientBuilder...
target = client.target("http://cloud-processor:8080/processor/resources/hello");
}
public String processGreeting() {
...
}
}
同样的情况也适用于其他 URL,例如数据源定义。应用程序服务器配置可以简单地指向数据库服务的名称,并在运行时使用它解析相应的实例。
配置编排应用程序
通过逻辑名称解析服务已经消除了应用程序中的大量配置。由于所有环境中都使用相同的容器镜像,可能需要从编排环境中插入不同的配置。如图中先例所示,Kubernetes 配置映射处理这种情况。"hello-cloud"应用程序期望在运行时,属性文件位于/opt/config/application.properties下。因此,项目代码将访问此位置。以下演示了使用 CDI 生产者集成属性文件:
public class HelloGreeter {
@Inject
@Config("hello.greeting")
String greeting;
@Inject
@Config("hello.name")
String greetingName;
public String processGreeting() {
return greeting + ", " + greetingName;
}
}
CDI 生产者定义与之前展示的配置示例类似:
@ApplicationScoped
public class ConfigurationExposer {
private final Properties properties = new Properties();
@PostConstruct
private void initProperties() {
try (InputStream inputStream =
new FileInputStream("/opt/config/application.properties")) {
properties.load(inputStream);
} catch (IOException e) {
throw new IllegalStateException("Could not init configuration", e);
}
}
@Produces
@Config("")
public String exposeConfig(InjectionPoint injectionPoint) {
Config config = injectionPoint.getAnnotated().getAnnotation(Config.class);
if (config != null)
return properties.getProperty(config.value());
return null;
}
}
@Config限定符的定义与第三章中实现现代 Java 企业应用程序的先例类似。应用程序将属性文件的内容加载到属性映射中,并使用 CDI 生成配置值。所有受管理的 Bean 都可以注入这些值,这些值来自 Kubernetes 配置映射。
为了实现秘密配置值,Kubernetes 包含了之前展示的秘密概念。一种常见的做法是使用环境变量在容器中使秘密内容可访问。
Java 应用程序使用System.getenv()方法来访问环境变量。此功能分别用于秘密和配置映射值。
展示的方法和示例使企业应用程序能够在容器编排集群中部署、管理和配置。它们对于大多数用例来说是足够的。
12 要素应用程序和 Java EE
在撰写本书时,12 要素应用程序已成为开发软件即服务(SaaS)应用程序的一种方式。12 要素应用程序方法定义了 12 个软件开发原则。这些原则背后的动机旨在最小化时间和精力,避免软件侵蚀,并拥抱持续交付和云平台。
换句话说,12 要素旨在以现代方式实现企业应用程序。其中一些原则对大多数工程师来说很显然,而其他原则似乎与构建企业应用程序的常见做法相矛盾。
12 要素列表包括:
-
I. 使用版本控制跟踪一个代码库,进行多次部署
-
II. 明确声明并隔离依赖项
-
III. 在环境中存储配置
-
IV. 将后端服务视为附加资源
-
V. 严格分离构建和运行阶段
-
VI. 以一个或多个无状态进程执行应用程序
-
VII. 通过端口绑定导出服务
-
VIII. 通过进程模型进行扩展
-
IX. 通过快速启动和优雅关闭最大化鲁棒性
-
X. 尽可能使开发、测试和生产的相似性最大化
-
XI. 将日志视为事件流
-
XII. 将管理/管理任务作为一次性进程运行
以下解释了每个原则的动机及其在 Java EE 中的实现。
在版本控制中跟踪一个代码库,多个部署
这一原则对软件工程师来说听起来相当明显,即声明软件代码应保持在版本控制下,一个仓库,即使是对于多个部署。部署与软件实例相关,运行在特定的环境中。因此,单个应用程序的代码库在单个仓库中进行跟踪,而不是每个应用程序或相反,包含所有可能不同环境的规范。
这一原则利用了开发者的生产力,因为所有信息都位于一个仓库下。它对选择的技术无关紧要,因此也支持 Java EE 应用程序。
仓库应包含构建和运行企业应用程序所需的所有源文件。除了 Java 源代码和配置文件外,还包括基础设施即代码。
明确声明并隔离依赖
运行应用程序所需的软件依赖及其版本必须明确指定。这不仅包括应用程序编程所依赖的依赖项,例如第三方 API,还包括对 Java 运行时或操作系统的隐式依赖。明确指定所需的版本可以大大减少生产中的兼容性问题。在开发工作流程中,软件版本的组合已经足够测试。在重建二进制文件时版本不同会引入潜在问题。因此,建议明确声明所有软件版本以减少错误概率并实现可重复性。
容器技术通过明确指定所有软件安装步骤简化了这一原则。应明确声明使用的基镜像的版本,以便镜像重建产生相同的结果。因此,应避免使用 Docker 的latest标签,而应使用确定的版本。Docker 文件中指定的所有软件安装也应指向明确的版本。带有或没有缓存的 Docker 重建应产生相同的结果。
Java 应用程序使用构建系统指定其依赖项。第一章已经介绍了使用 Maven 和 Gradle 实现可重复构建所必需的内容。在 Java EE 应用程序中,这些依赖项理想情况下应最小化到 Java EE API。
在可能的情况下,建议指定明确的依赖版本,而不仅仅是最新版本。只有使用明确版本的软件才能可靠地进行测试。
将依赖项隔离是软件团队分布式开发的一个必要条件。软件工件应通过定义良好的流程,例如工件仓库,可被访问。在软件构建过程中添加的依赖项,无论是否为 Java 运行时安装、Java 工件或操作系统组件,都需要从中央位置分发。例如,Maven Central、DockerHub或公司内部仓库都支持这种做法。
在环境中存储配置
应用程序配置,如数据库、外部系统或凭证等不同环境下的配置,需要在运行时存在。这种配置不应反映在源代码中,而应能够从应用程序外部动态修改。这意味着配置是通过文件、环境变量或其他外部因素检索的。
容器技术和编排框架如前所述支持这些方法。不同环境,如测试、预发布和生产的配置存储在 Kubernetes 配置映射中,并在 Pod 的卷或环境变量中动态使用。
12 个因素原则指出,应用程序“ [...] 应在环境变量中存储配置”。环境变量是插入特定变体的一种直接方式,所有类型的技术都支持。然而,如果配置应用程序涉及大量单个配置值,工程师可能考虑使用容器卷中包含的配置文件。
将后端服务视为附加资源
在应用程序中访问的数据库和外部系统被称为资源。对于系统来说,一个外部服务或数据库是否是应用程序的一部分不应有任何区别。资源应以松散耦合的方式附加到应用程序上。外部系统和数据库应能够被新的实例替换,而不会影响应用程序。
应用程序抽象了访问的外部系统,首先是使用的通信技术。例如,通过 HTTP 或 JDBC 进行通信抽象了实现,并使系统能够被其他系统替换。通过这样做,应用程序仅与其合同耦合:通信协议和定义的模式。JPA、JAX-RS 和 JSON-B 是支持此方法的示例。
容器编排框架将这种方法进一步发展,并将服务抽象为逻辑名称。如前所述,应用程序可以使用服务名称作为主机名,由 DNS 解析。
通常,应用程序开发者应该将系统松散耦合在一起,理想情况下只依赖于协议和模式。在代码级别,后端服务被抽象为独立的组件,例如具有干净接口的单独控件。这样,如果附加资源发生变化,可以最小化更改。
严格分离构建和运行阶段
此原则建议将应用程序构建、部署和运行过程分开。这是 Java 企业开发者所熟知的方法。应用程序二进制文件在单独的步骤中构建、部署和运行。软件或配置更改分别在源代码或部署步骤中发生,而不是直接在生产环境中。部署步骤将应用程序二进制文件和潜在配置组合在一起。定义良好的变更和发布管理流程保持企业软件的完整性。
对于绝大多数软件项目,将步骤分开并在持续集成服务器中编排阶段是常见的做法。这是确保可靠性和可重复性的必要条件。第六章,应用程序开发工作流程深入探讨了这一主题。
将应用程序作为单个或多个无状态进程执行
理想情况下,应用程序作为无状态进程运行,每个用例都是独立执行的,不会影响其他正在运行的进程。潜在状态要么存储在附加资源(如数据库)中,要么被丢弃。因此,比单个请求持续时间长的会话状态违反了这一原则。传统用户会话状态的挑战在于它仅存在于本地应用程序实例中,不可从其他实例访问。在负载均衡器上需要所谓的粘性会话是未拥有无状态应用程序的指标。
许多现代技术支持这种方法。例如,具有写时复制的文件系统的 Docker 容器。停止的容器将被丢弃,因此它们的所有状态也将消失。无状态 EJBs 基于类似动机。然而,无状态会话 bean 的实例是池化和重复使用的,因此开发人员需要确保在业务用例调用后没有状态保留。
企业应用程序应该能够在不影响其行为的情况下从头开始重新启动。这也意味着应用程序除了通过定义良好的附加资源外,不共享任何状态。
通过端口绑定导出服务
Web 应用程序传统上部署到特定的软件堆栈。例如,Java 企业应用程序部署到企业或 Servlet 容器,而服务器端脚本语言(如 PHP)则在 Web 服务器上运行。因此,应用程序依赖于它们的即时运行时。
12 因素原则建议开发自给自足的应用程序,通过网络端口公开其功能。由于基于 Web 的企业应用程序将通过网络进行通信,因此将服务绑定到端口是最低耦合的方式。
在容器中运行的 Java EE 应用程序支持这种方法,仅导出一个用于与应用程序通信的端口。容器只依赖于 Linux 内核,因此应用程序运行时是透明的。容器编排框架利用这一想法,通过逻辑名称和端口将服务连接到 Pod,如前一个示例所示。Java EE 支持容器使用,因此也支持这一原则。
通过进程模型进行扩展
现代应用程序及其环境应在工作负载增加时能够实现可伸缩性。理想情况下,应用程序能够水平扩展,而不仅仅是垂直扩展。区别在于,水平扩展旨在向软件中添加更多单独的、自包含的节点,而垂直扩展则是增加单个节点或进程的资源。然而,垂直扩展是有限的,因为物理节点上的资源不能无限增加。
12 因素应用描述了通过添加更多自包含的、无共享进程来向软件中添加并发的程序。工作负载应在多个物理主机之间可分配,通过增加进程数量来实现。进程代表处理系统工作负载的请求或工作线程。
这种方法显示了在无共享方式中实现无状态应用程序的必要性。运行无状态 Java 企业应用程序的容器使系统能够进行扩展。Kubernetes 通过管理副本数量来管理部署中的可伸缩性。
然而,企业应用程序的瓶颈通常是应用程序实例而不是中央数据库。第八章《微服务与系统架构》和第九章《监控、性能和日志》涵盖了分布式系统中的可伸缩性和 Java EE 项目中的一般性能问题。
通过快速启动和优雅关闭最大化鲁棒性
第四章,《轻量级 Java EE》已经展示了快速迭代的重要性。12 因素应用的原则要求能够实现速度和弹性的技术。为了快速扩展,软件应在几秒钟内启动,使其能够处理不断增长的工作负载。
应用程序关闭应该优雅地完成正在进行的请求,并妥善关闭所有打开的连接和资源。特别是当关闭信号发生时执行的请求和事务应该被适当地完成,而不是恶意地终止客户端用例。在 Unix 进程方法中,关闭信号以 SIGTERM 信号的形式发送。Linux 容器以相同的方式停止,给容器进程一个优雅关闭的机会。当构建容器镜像时,开发者应该注意进程正确处理 Unix 信号,以便在接收到 SIGTERM 信号时,应用程序服务器能够优雅地关闭。
Java EE 支持快速启动和优雅关闭。如前所述,现代应用服务器可以在几秒钟内启动和部署应用程序。
由于应用服务器管理着豆类、资源、池化和线程,它们会在 JVM 关闭时妥善关闭资源。开发者不需要自己处理这一方面。管理自定义资源或需要关闭的处理程序,使用预销毁方法来实现适当的关闭。以下是一个使用 JAX-RS 客户端处理程序的客户端控制示例,该处理程序在服务器关闭时被关闭:
@ApplicationScoped
public class CoffeePurchaser {
private Client client;
...
@PreDestroy
public void closeClient() {
client.close();
}
}
平台保证一旦应用服务器关闭,所有管理豆类的预销毁方法都会被调用一次。
尽可能保持开发、预发布和生产的相似性
这个 12 要素原则旨在最小化环境之间的差异。
企业应用程序在开发过程的各个环境之间通常存在相当大的差异。有开发环境,可能有几个,例如本地工作站或专用服务器环境,最后是生产环境。这些环境在开发过程中部署的软件工件版本和配置方面存在差异。在环境中同时拥有不同版本的时间跨度越长,这种差异就越大。
团队和人员之间也存在差异。传统上,软件开发者维护自己的开发环境,而运维团队则负责生产环境。这引入了潜在的沟通、流程和使用的技术的差距。
环境之间的技术差异包含最大的风险。与生产环境使用不同工具、技术、外部服务和配置的开发或测试环境,会引入这些差异可能导致错误的风险。软件在这些环境中自动测试,然后再部署到生产环境中。任何未测试的生产差异都可能并最终引入本可以预防的错误。对于在开发或本地环境中交换工具、后端服务或使用的堆栈以轻量级替代品也是同样的情况。
因此,建议尽可能保持环境相似。特别是,容器技术和编排框架高度支持这种方法。正如我们之前看到的,配置、服务和技术的差异被最小化或至少通过环境明确定义。理想情况下,软件景观在开发、测试环境、预生产和生产环境中是相同的。如果不可能实现这一点,服务抽象以及环境管理的配置支持有助于管理差异。
通过持续交付的使用来处理时间和人员差异,这不仅从技术角度,也从组织角度出发。整体的生产时间应尽可能小,以便快速交付特性和错误修复。实施持续交付自然会推动团队和责任的整合。DevOps 运动描述了所有工程师对整体软件的责任。这导致了一种文化,即所有团队都紧密合作或合并成单一的软件工程师团队。
将日志视为事件流
企业应用程序传统上会将日志写入磁盘上的日志文件。一些工程师认为,这些信息是了解应用程序最重要的洞察之一。软件项目通常包括配置这些日志文件的内容和格式。然而,将日志数据存储在日志文件中首先只是一个输出格式,通常每行只有一个日志事件。
12 因素应用原则认为,日志应该被视为由应用程序发出的日志事件流。然而,应用程序不应关心路由和将日志文件存储到特定输出格式。相反,它们将日志记录到进程的标准输出。标准输出被运行时环境捕获和处理。
这种方法对于大多数企业开发者来说并不常见,因为所有日志框架、输出格式和工具都存在。然而,在许多服务并行运行的环境中,仍然需要外部捕获和处理日志事件。例如,Elasticsearch、Logstash和Kibana等解决方案已经证明在处理和解析来自多个来源的日志事件复杂情况方面表现良好。将日志事件存储在日志文件中并不一定支持这些方法。
将日志记录到应用程序的标准输出不仅简化了开发,因为路由和存储不再是应用程序的责任。它还减少了对外部依赖的需求,例如日志框架。零依赖应用程序支持这种方法。环境,如容器编排框架,负责捕获和路由事件流。在第九章“监控、性能和日志”中,我们将讨论日志、其必要性和不足之处。
将管理/管理任务作为一次性进程运行
这个原则描述了管理或管理任务应该作为独立的短暂进程来执行。理想的技术支持在运行环境中操作的 shell 中的命令执行。
尽管容器封装了 Unix 进程,但它们提供了执行单个命令或打开远程 shell 到容器中的额外功能。因此,工程师可以执行 Java EE 应用服务器提供的管理和行政脚本。然而,在 Java EE 应用程序中,所需的管理和管理任务数量有限。容器运行应用程序服务器进程,该进程自动部署应用程序;不需要进一步的应用程序生命周期管理。
管理任务通常用于调试和故障排除目的。因此,容器和容器编排框架提供了打开远程 shell 到容器或执行一次性命令的可能性。除此之外,第九章“监控、性能和日志”将向您展示收集有关企业应用程序的进一步监控信息所必需的内容。
12 要素的动机是开发无状态、可扩展的企业应用程序,这些应用程序采用持续交付和现代环境平台,优化开发中花费的时间和精力,并试图避免软件侵蚀。12 要素应用程序与其底层系统有一个清晰的合同,并且理想情况下有声明性基础设施定义。
云、云原生及其优势
在撰写本书时,对云平台有很大的兴趣。我们目前看到大型公司正在将他们的 IT 基础设施迁移到云服务中。但云平台究竟有哪些好处呢?
首先,我们必须意识到,现代环境不一定必须运行在云平台之上。容器技术和容器编排框架的所有好处都可以通过公司内部基础设施实现。最初,在本地安装 Kubernetes 或 OpenShift 等平台为软件开发团队提供相同的优势。事实上,容器运行时最大的好处之一是抽象容器运行的 环境。那么,为什么云平台对公司来说很有趣呢?
如本书开头所述,软件世界正在比以往任何时候都更快地发展。公司跟上其业务趋势的关键是拥抱敏捷和快速移动。新产品及其新特性的上市时间需要尽可能短。以迭代步骤进行,适应客户需求并持续改进软件满足这一需求。为了实现这一目标,IT 基础设施以及软件工程的各个方面都需要快速和灵活。新环境应通过自动化、可靠和可重复的过程来设置。持续软件交付的相同原则也适用于服务器环境。云平台提供了这种可能性。
想要拥抱敏捷并适应客户需求的公司需要问自己一个问题:配置新环境需要多长时间? 这是快速适应的前提。配置全新的环境应该是几分钟内的事情,不应该需要过于复杂的流程,理想情况下不需要人为干预。正如之前所说,在本地实现这样的方法是完全可能的。然而,云服务提供这些好处是现成的,拥有足够的、可扩展的资源。基础设施即服务(IaaS)或平台即服务(PaaS)提供的服务可以减轻公司的许多工作负担,使他们能够专注于构建自己的产品。
尽管如此,大型公司在面对云服务时往往持怀疑态度,尤其是在数据安全方面。有趣的是,项目经验表明,当将基础设施环境比较到实际层面时,由复杂企业运营的云平台提供的环境通常比大多数本地环境更安全。云平台提供商投入了大量时间和精力来构建适当的解决方案。特别是将云平台服务与编排解决方案相结合,如 Docker Compose、Kubernetes 或 OpenShift,具有很大的潜力。
有趣的是,公司将其 IT 迁移到云中的主要论点之一是出于经济原因。从经验来看,许多公司希望通过使用云平台来节省成本。实际上,当考虑到将整个迁移和转型过程,包括团队、技术和最重要的是专业知识,都纳入考量时,本地解决方案通常仍然更便宜。然而,云服务的主要优势在于灵活性和快速移动的能力。如果一个 IT 公司维护一个良好的协调景观,包括自动化、可靠和可重复的过程,那么保持并持续改进这种方法是明智的。话虽如此,关于现代环境的问题,与其说是关于是否使用云平台,不如说是关于流程、团队心态和合理的技术。
云原生
除了对云技术的兴趣之外,对术语云原生也非常感兴趣,它描述了除了遵循 12 要素之外,与云平台有强烈关系的应用程序。云原生和 12 要素应用不是同义词;云原生包括 12 要素,以及其他一些内容。
云原生应用旨在在云 PaaS 服务上运行,利用其所有优势和挑战,拥抱容器技术和弹性可伸缩性。它们旨在提供现代、可扩展、无状态和具有弹性的应用程序,可在现代编排环境中进行管理。与术语native所暗示的相反,遵循此方法的应用程序不一定必须作为green-field项目来构建,从第一天起就支持云技术。
云原生应用除了 12 要素之外,重要的方面还包括监控和应用健康问题,这可以总结为遥测。企业应用的遥测包括响应性、监控、特定领域的洞察、健康检查和调试。正如我们之前所看到的,容器编排至少支持我们解决最后两个问题:健康检查和调试。运行中的应用程序会被探测,以确定它们是否仍然存活且健康。通过评估日志事件流、连接到运行中的容器或执行进程,可以进行调试和故障排除。
应用程序监控需要由运行中的容器暴露出来。这需要软件开发人员付出更多努力。特定领域的指标需要由业务专家首先定义。这取决于哪些指标对业务部门感兴趣,并且将由应用程序暴露出来。技术指标也来自运行中的应用程序。第九章,监控、性能和日志,涵盖了关于现代环境中的监控主题。
12 要素不包括的另一个方面是 API 及其安全性。SaaS 应用程序通过暴露的 API 进行通信,这些 API 必须为其他开发团队所知晓。Web 服务的性质和结构需要在开发过程中进行文档化和协商。这在 HTTP API 没有实现超媒体的情况下尤其如此。应用程序需要了解交换信息的性质和结构——理想情况下,在开发过程的早期就应如此。这还包括身份验证和授权。应用程序开发人员应该意识到在与其他服务通信之前需要解决的网络安全机制。一般来说,在开发之后才考虑安全方面是不明智的。第十章,安全,涵盖了关于云环境和集成到 Java EE 应用程序中的这个主题。
为了为所有拥抱云平台的技术构建一个伞状结构,几家软件供应商共同成立了云原生计算基金会。它是 Linux 基金会的一部分,代表云原生开源软件的基础。它包含技术,可以编排、管理、监控、跟踪或以其他方式支持在现代环境中运行的容器化微服务。截至撰写本书时,Cloud Native Computing Foundation 的技术项目示例包括Kubernetes、Prometheus、OpenTracing或containerd。
摘要
运营任务需要自动化。设置应用程序环境应始终产生相同的结果,包括安装、网络和配置。容器技术和基础设施即代码通过定义、自动化和分发软件安装和配置来支持这一点。它们满足了快速且可重复地重建软件和系统的必要性。
基础设施即代码定义指定了所需的基础设施及其所有依赖项,作为应用程序代码的一部分,并保持在版本控制之下。这种方法支持 DevOps 运动背后的理念。不仅定义应用程序,还包括其运行时以及所有要求的责任,将不同的团队聚集在一起。所有工程师都应该有责任交付服务于商业目的的高质量软件。
容器技术,如 Docker,提供了一种统一的方式来构建、管理和运输容器。Docker 的写时复制层文件系统使我们能够通过仅重新执行已更改的步骤来最小化构建和发布时间。Java EE 零依赖应用程序通过将应用程序逻辑与其实现分离,鼓励使用容器技术。因此,变化的层只包含业务代码。
容器编排框架,如 Kubernetes,管理容器的生命周期、网络和外部配置。它们负责查找服务,提供生产就绪性,如零停机时间部署,以及扩展和缩减应用程序实例。容器编排支持基础设施即代码定义,这些定义包含应用程序所需的整个运行时环境的配置。
12 因子和云原生方法旨在以最短的时间和最小的努力开发现代企业应用,避免软件侵蚀,并支持持续交付和云平台。12 因子原则针对软件依赖、配置、依赖服务、运行时环境、日志和行政流程。同样,云原生应用旨在构建在云平台上运行良好的企业软件,支持监控、弹性、应用健康和安全。由于这些方法不受特定技术的限制,它们可以使用 Java EE 实现。我们已经看到了遵循这些原则的动机。
下一章将向您展示如何构建基于容器技术的生产力应用开发工作流程。
第六章:应用程序开发工作流程
在上一章中,我们看到了软件公司快速行动的必要性。这对基础设施和运行时环境以及工程师团队的合作方式都有影响。现代环境的动机在于可扩展性、灵活性和最小化时间和精力。
开发工作流程甚至比基础设施本身更重要。从编写源代码到运行的应用程序在生产中运行的全过程都应该以合理和高效的方式进行指定。再次强调,在快速变化的世界中快速行动意味着这些流程应该尽可能自动化和可靠地运行,尽可能减少人为干预。
本章将涵盖以下主题:
-
持续交付的动机和必要性
-
生产力管道的内容
-
如何自动化所有涉及步骤
-
如何可持续地确保和提升软件质量
-
所需的团队文化和习惯
生产力开发工作流程的动机和目标
在开发工作流程中快速行动旨在通过快速周转来提供快速反馈。为了提高生产力,负责应用程序行为的开发者需要及时验证实施的功能和错误修复。这包括在构建、软件测试和部署上花费的时间。
生产力工作流程的关键是自动化。软件工程师应该尽可能多地花时间在设计、实施和讨论业务逻辑上,尽可能少地花在横切关注点和重复性任务上。计算机被设计用来快速且可靠地执行确定性和直接的任务。然而,人类在设计和思考创造性、复杂任务方面更擅长。因此,不需要太多决策的简单、直接的过程应该由软件执行。
构建系统是一个良好的起点。它们自动化编译、解决依赖关系和软件项目的打包。持续集成服务器将这种方法进一步推进。它们协调整个开发工作流程,从构建工件到自动化测试和部署。持续集成服务器是软件交付的黄金真相来源。它们在中央位置持续集成所有开发者的工作,确保项目处于可发货状态。
持续交付通过在每次构建时自动将构建的软件发送到某些环境,继续了持续集成的做法。由于软件更改在进入生产之前必须得到适当的验证,因此应用程序首先部署到测试和预生产环境中。所有部署操作都必须确保环境已准备好并正确配置,并且已正确推出。自动和手动端到端测试确保软件按预期工作。然后通过手动触发自动化部署以半自动化的方式将软件部署到生产环境中。
持续交付与持续部署之间的区别在于,后者在满足质量要求的情况下,会自动将每个提交的软件版本部署到生产环境中。
所有这些方法都最大限度地减少了开发者干预的需求,最大限度地减少了周转时间,并提高了生产力。
理想情况下,持续交付方法不仅支持推出,还支持可靠的回滚。尽管软件版本在验证之前,但有时出于某些原因需要回滚。在这种情况下,可以通过提交一个将撤销最近更改的新版本,或者回滚到工作状态的方式来向前推进。
如前所述,软件应以可靠的方式进行构建。所有使用的技术的版本,例如构建依赖项或应用程序服务器,都应明确指定。重新构建的应用程序和容器会产生相同的结果。同样,开发工作流程的管道步骤也应产生相同的输出。确保在测试环境中验证过的相同应用程序工件随后被部署到生产环境中至关重要。在本章的后面部分,我们将介绍如何实现可重复、可重复和独立的构建。
在可靠性方面,自动化流程也是一个重要方面。特别是,由软件执行而不是人为干预的部署远不太可能出错。所有必要的管道步骤都得到了很好的定义,并且在每次执行时都隐式验证。这为自动化流程建立了信心,最终比手动执行流程更可靠。
验证和测试是持续交付的重要前提。经验表明,绝大多数软件测试都可以以自动化的方式进行执行。下一章将深入探讨这一主题。除了测试之外,质量保证还涵盖了项目在架构和代码质量方面的软件质量。
持续交付工作流程包括构建、测试、运输和以生产化和自动化的方式部署软件所需的所有步骤。让我们看看如何构建有效的工作流程。
实现开发工作流程
持续交付管道由多个按顺序或并行执行的管道构建步骤组成。所有步骤都是作为单个构建的一部分执行的。构建通常由提交或更确切地说,是将代码更改推送到版本控制中触发。
以下将探讨持续交付管道的各个方面。这些通用步骤与所使用的技术无关。
下图展示了简化后的持续交付管道的高级概述。这些步骤在持续集成服务器上执行,并使用外部仓库,如版本控制、工件和容器仓库:

版本控制一切
开发者普遍认为源代码应该被纳入版本控制。分布式版本控制工具,如 Git,已被广泛接受为最先进的工具。然而,正如之前提到的,除了应用程序源代码外,还有更多资产需要跟踪。
基础设施即代码背后的动机是将所有需要部署应用程序的工件保存在一个中央位置。对应用程序、配置或环境所做的所有更改都表示为代码并提交到仓库。基础设施即代码利用可重复性和自动化。进一步采取这种方法还包括将持续交付管道定义为代码。管道即代码部分将以广泛使用的 Jenkins 服务器为例介绍这种方法。
正如我们在上一章中看到的,12 因素应用的第一原则实际上是将构建和运行应用程序所需的所有文件和工件保存在一个仓库中。
持续交付管道的第一步是从版本控制仓库检出特定的提交。使用分布式版本控制系统的团队需要将所需状态同步到集中式仓库。持续集成服务器从历史中的特定提交状态开始启动构建过程。
选择特定提交版本而不是最新状态的原因是为了实现可重复性。只有基于特定提交的构建才能可靠地产生相同的结果。这只有在构建是从带有特定提交的版本控制检查中开始的条件下才可能实现。检查入操作通常从相应的提交版本触发构建。
检出仓库状态提供了所有必要的源代码和文件。下一步是构建软件工件。
构建二进制文件
正如我们在第一章中看到的,术语二进制文件包括所有运行企业应用程序的可执行工件。项目仓库仅包含源代码和基础设施所需文件和工件。二进制文件由持续集成服务器构建。
管道中的一个步骤负责构建这些二进制文件并以可靠的方式使其可用。
Java 艺术品
在 Java EE 中,二进制文件首先包括以归档形式打包的企业应用程序。遵循零依赖应用程序的方法会导致将项目构建和打包到一个薄的 WAR 文件中,其中只包含应用程序的业务逻辑。此构建操作包括解决所需的依赖项、编译 Java 源代码,并将二进制类和其他文件打包到归档中。WAR 文件是构建管道中产生的第一个艺术品。
应用程序艺术品使用 Maven 或 Gradle 等构建系统构建,这些系统安装在 CI 服务器上并执行。通常,项目构建已经执行了基本的代码级别测试。无需容器运行时即可在代码级别执行的测试可以在管道早期验证类和组件的行为。快速失败和尽可能早地中断构建的持续交付方法最小化了周转时间。
如果需要,构建系统可以将艺术品发布到艺术品仓库。例如,Sonatype Nexus 或 JFrog Artifactory 这样的艺术品仓库保存构建的艺术品版本以供以后检索。然而,如果应用程序以 Linux 容器形式分发,艺术品不一定需要部署到仓库。
如第二章所示,设计和结构化 Java 企业应用程序,Java 项目通过命令 mvn package 使用 Maven 构建。包阶段编译所有 Java 生产源代码,编译和执行测试源代码,并将应用程序(在我们的例子中)打包到 WAR 文件中。CI 服务器执行类似此命令的构建系统命令,在本地工作区目录中构建艺术品。可以使用 mvn deploy 命令将艺术品部署到艺术品仓库,用于后续步骤;或者它可以直接从工作区目录中获取。
艺术品版本
如前所述,构建系统需要以可靠的方式生成艺术品。这要求 Java 艺术品以独特的版本构建和归档,以便以后可以识别。软件测试验证企业应用程序的特定版本。后续部署需要引用后续构建步骤中的相同版本。能够识别和引用不同的艺术品版本是必要的。这对于所有二进制文件都适用。
12 个因素原则之一是明确声明依赖项,不仅适用于正在使用的依赖项,还适用于它们的版本。如前所述,这对于容器构建同样适用。指定的 Docker 基础镜像以及安装的软件应通过它们的版本明确、唯一地标识。
然而,指定 Java 构建为快照版本是很常见的,例如,0.1-SNAPSHOT。与发布版本不同,快照代表当前正在开发的软件状态。依赖关系解析始终尝试在存在多个快照版本时包含最新的快照,类似于 Docker 的latest标签。快照背后的工作流程是在开发水平足够时,将快照版本发布到唯一编号的版本。
然而,快照版本与持续交付的理念相矛盾。在持续交付管道中,每个提交都是一个潜在的生产部署候选者。快照版本自然不适用于生产部署。这意味着工作流程需要将快照版本更改为发布版本,一旦软件版本得到充分验证。然而,一旦构建完成,Java 工件就不应该被更改。经过验证的相同工件应该用于部署。因此,快照版本不适合持续交付管道。
遵循广泛采用的语义版本化方法,应用开发者需要关注其版本的向后兼容性。语义版本化描述了如1.1.0、1.0.0-beta或1.0.1+b102这样的软件版本。为了表示既适用于持续交付又提供语义版本化元数据的版本,带有唯一构建元数据的正确编号版本是一个很好的解决方案。例如,1.0.1+b102表示主版本为1、次版本为0、修订版本为1和构建编号102。加号后面的部分代表可选的构建元数据。即使在一连串构建中语义版本没有改变,产生的工件仍然可以识别。这些工件可以被发布到工件仓库,并通过这些版本号在以后检索。
这种版本化方法针对的是企业级应用项目,而不是产品。同时拥有多个已发布和受支持版本的产品,需要更复杂的版本化工作流程。
在撰写本文时,还没有关于容器版本化的既定标准。一些公司遵循语义版本化方法,而其他公司则仅使用 CI 服务器构建编号或提交哈希。所有这些方法都是有效的,只要容器镜像不使用相同的标签重建或分发两次。单个构建必须产生一个独特的容器镜像版本。
构建容器
容器镜像也代表二进制文件,因为它们包含了运行中的应用程序,包括运行时和操作系统二进制文件。为了构建容器镜像,需要存在基础镜像以及所有在构建时添加的工件。如果它们在构建环境中不存在,基础镜像会隐式地被检索。
对于在 Dockerfile 中定义的每个构建步骤,都会在上一层之上添加一个镜像层。最后但同样重要的是,将刚刚构建的应用程序添加到容器镜像构建中。如前所述,Java EE 应用程序容器由一个安装和配置的应用服务器组成,该服务器在运行时自动部署 Web 存档。
这个镜像构建是由 CI 服务器作为流水线的一部分来执行的。一个解决方案是安装 Docker 运行时,就像 Maven 构建系统一样。然后,流水线步骤简单地调用在作业工作空间目录中的类似docker build -t docker.example.com/hello-cloud:1 .的镜像构建。例如,Docker 镜像构建会将 Maven 的target目录下的 WAR 文件添加到容器中。
构建的镜像会根据构建号或其他唯一信息标记为镜像名称和唯一标记。Docker 镜像名称暗示了它们将被推送到哪个注册表。例如,docker.example.com/hello-cloud:1这样的镜像标识符将隐式地从主机docker.example.com传输。在大多数情况下,流水线会将镜像推送到 Docker 注册表,通常是公司特定的注册表。
根据公司的流程,Docker 镜像也可以作为流水线的一部分进行重新标记。例如,特殊的标记,如latest标记,可以指向实际构建的最新版本等。这是通过显式重新标记镜像来实现的,使得两个标识符指向同一个镜像。与 Java 归档不同,Docker 镜像可以在不更改其内容的情况下重新标记。第二个标记还需要推送到仓库。然而,本章的其余部分将向您展示,使用latest版本,如 Docker 的latest标记来引用镜像并不是必需的。实际上,与快照版本类似,建议避免使用latest版本。在所有工件版本中保持明确性可以减少错误的可能性。
一些工程师认为,如果 CI 服务器本身运行在 Docker 容器中,在 CI 服务器内部运行 Docker 构建可能不是最佳选择。Docker 镜像构建会临时运行容器。当然,可以在容器中运行容器或将运行时连接到另一个 Docker 主机,而不必将整个平台暴露于潜在的安全风险。然而,一些公司选择在 CI 服务器之外构建镜像。例如,OpenShift,一个建立在 Kubernetes 之上的 PaaS,提供了包含 CI 服务器和镜像构建功能的构建功能。因此,可以从 CI 服务器编排镜像构建,然后在 OpenShift 平台上构建。这为在 CI 服务器上直接构建容器镜像提供了另一种选择。
质量保证
Java 工件构建已经执行了一些基本的质量保证。它执行包含的代码级别测试,例如单元测试。一个合理的管道由几个测试范围和场景组成,它们都有略微不同的优势和劣势。包含的单元测试在代码级别运行,可以在没有任何进一步运行环境的情况下执行。它们的目的是验证单个类和组件的行为,并在测试失败的情况下提供快速反馈。我们将在下一章中看到,单元测试需要独立且快速地运行。
测试结果通常从 CI 服务器记录下来,以便于可见性和监控。使管道步骤的结果可见是持续交付的重要方面。CI 服务器可以跟踪通过的单位测试数量,并显示随时间的变化趋势。
可用的构建系统插件可以跟踪执行测试的代码覆盖率。覆盖率显示了在测试运行期间代码库的哪些部分已被执行。一般来说,更高的代码覆盖率是可取的。然而,覆盖率的高百分比本身并不能说明测试的质量和测试断言的覆盖率。测试结果及其覆盖率只是几个质量特性之一。
源代码已经可以提供关于软件质量的很多信息。所谓的静态代码分析在项目静态源代码文件上执行某些质量检查,而不执行它们。这种分析收集有关代码语句、类和方法的大小、类和包之间的依赖关系以及方法复杂性的信息。静态代码分析已经可以在源代码中找到潜在的错误,例如未正确关闭的资源。
SonarQube 是最知名的代码质量工具之一。它通过关联不同分析方法的结果,如静态代码分析或测试覆盖率,来提供关于软件项目质量的信息。合并的信息被用来为软件工程师和架构师提供有用的质量指标。例如,哪些方法是复杂的但同时又经过充分测试的?哪些组件和类在大小和复杂性方面最大,因此是重构的候选者?哪些包存在循环依赖,可能包含应该合并在一起的组件?测试覆盖率是如何随时间演变的?有多少代码分析警告和错误,以及这个数字是如何随时间演变的?
遵循一些关于静态代码分析的基本指南是明智的。一些指标只是从粗略的角度提供了关于软件质量的见解。测试覆盖率就是一个例子。覆盖率高的项目并不一定意味着软件经过良好的测试;断言语句可能是不切实际的或不足够的。然而,测试覆盖率的趋势确实可以提供关于质量的信息,例如,软件测试是否添加了新的和现有的功能以及错误修复。
也有一些指标应该严格遵守。代码分析警告和错误就是其中之一。警告和错误会告诉工程师有关代码风格和质量违规的问题。它们是关于需要修复的问题的指标。
首先,不应该有编译或分析警告。要么构建通过了足够的质量检查,一个绿灯;要么质量不足以部署,一个红灯。两者之间没有合理的中间状态。软件团队需要明确哪些问题是合理的并且需要解决,哪些不是。因此,项目中表明轻微问题的警告被视为错误;如果有充分的理由解决它们,工程师必须这样做,否则构建应该失败。如果检测到的错误或警告代表了一个假阳性,它不会被解决;相反,它必须由过程忽略。在这种情况下,构建是成功的。
采用这种方法可以实现零警告政策。项目构建和分析中始终存在大量错误和警告,即使它们不是关键的,也会引入某些问题。现有的警告和错误会模糊项目的质量视图。工程师无法一眼看出数百个问题是否真的是问题。此外,已经存在大量问题会削弱工程师修复新引入的警告的积极性。例如,想象一个房屋状况极差,墙壁损坏,窗户破碎。如果再有一扇窗户破碎,没有人会在意。但是,一个最近破碎的窗户,而其他方面都保养得很好的房子,会促使负责人采取行动。对于软件质量检查也是如此。如果有数百个警告,没有人会关心最后一次提交中新引入的违规行为。因此,项目的质量违规数量应该是零。构建或代码分析中的错误应该中断管道构建。要么项目代码需要修复,要么需要调整质量规则以解决问题。
代码质量工具,如 SonarQube,在构建管道步骤中集成。由于质量分析仅操作静态输入,该步骤可以轻松并行化到下一个管道步骤。如果质量门不接受结果,构建将失败,工程师需要在继续开发之前解决该问题。这是将质量集成到管道中的重要方面。分析不仅应该提供见解,还应该积极阻止执行以强制采取行动。
部署
在构建二进制文件之后,或在软件质量正在验证期间,企业应用程序将被部署。根据项目情况,通常有几个用于测试目的的环境,例如测试或预发布,当然还有生产环境。如前所述,这些环境应尽可能相似。这大大简化了 CI 服务器编排的部署过程。
部署应用程序的过程通常涉及将刚刚构建的版本中的二进制文件部署到环境中。根据基础设施的外观,这可以通过简单的脚本或更复杂的技术来实现。原则应该是相同的,二进制文件以及配置应以自动和可靠的方式提供给环境。在这个步骤中,还将执行应用程序或环境可能需要的潜在准备步骤。
现代环境,例如容器编排框架,支持基础设施即代码。基础设施配置被捕获在项目仓库中的文件中,并在部署时应用于所有环境。潜在差异,例如 Kubernetes 配置映射的内容,也在仓库中以不同的表现形式表示。
使用 IaC 以及容器比自制的 shell 脚本提供更高的可靠性。应用程序应始终以幂等的方式部署,无论环境处于何种状态。由于容器镜像包含整个堆栈,结果与从头开始安装软件相同。所需的环境配置也通过 IaC 文件应用。
新的容器镜像版本可以通过编排框架以多种方式部署。有一些命令会明确设置 Kubernetes 部署中使用的 Docker 镜像。然而,为了满足可靠性和可重复性的要求,只编辑基础设施即代码文件并在集群上应用它们是有意义的。这确保了配置文件是唯一的真相来源。CI 服务器可以编辑 IaC 文件中的镜像定义,并将更改提交到 VCS 仓库。
如前一章所述,Docker 镜像在 Kubernetes 部署定义中指定:
# deployment definition similar to previous chapter
# ...
spec:
containers:
- name: hello-cloud
image: docker.example.com/hello-cloud:1
imagePullPolicy: IfNotPresent
livenessProbe:
# ...
这些镜像定义在 CI 服务器过程中更新,并应用于 Kubernetes 集群。CI 服务器通过kubectl CLI 执行 Kubernetes 命令。这是与 Kubernetes 集群通信的标准方式。kubectl apply -f <file>将包含 YAML 或 JSON 定义的文件或目录的基础设施即代码内容应用。管道步骤执行与此类似的命令,提供在项目仓库中更新的 Kubernetes 文件。
采用这种方法使得基础设施即代码文件既包含环境的当前状态,也包含工程师所做的更改。所有更新都是通过将相应版本的 Kubernetes 文件应用到集群中来进行的。集群旨在满足新的期望状态,包含新的镜像版本,因此将执行滚动更新。在触发此更新后,CI 服务器将验证部署是否已成功执行。可以通过类似于kubectl rollout status <deployment>的命令来跟踪 Kubernetes 的滚动操作,该命令等待部署成功滚动或失败。
此过程将在所有环境中执行。如果为多个环境使用单个部署定义,则只需更新一次镜像标签定义,当然。
为了给出一个更具体的例子,以下展示了 Maven 项目的潜在配置文件结构:

hello-cloud.yaml文件包含多个 Kubernetes 资源定义。这可以通过用三条横线(---)分隔每个 YAML 对象定义来实现。为每种资源类型提供单独的文件,例如deployment.yaml、service.yaml等,也是同样可行的。Kubernetes 可以处理这两种方法。YAML 对象中的kind类型定义指示资源的类型。
上一章展示了容器编排框架如何实现零停机时间部署。将新镜像版本应用到由 CI 服务器编排的环境中也实现了这一目标。因此,环境将能够一次至少服务一个活动应用程序。这种方法对于生产环境尤为重要。
配置
理想情况下,基础设施即代码涵盖了定义整个环境所需的所有方面,包括运行时、网络和配置。使用容器技术和容器编排极大地支持和简化了这种方法。如前所述,机密内容,如凭证,不应置于版本控制之下。这应该由管理员手动在环境中进行配置。
在多个环境中不同的配置可以使用项目存储库中的多个文件来表示。例如,为每个环境包含子文件夹是有意义的。以下图像展示了示例:

configmap.yaml 文件的内容包括特定的配置映射内容以及可能的不同命名空间定义。如前一章所述,Kubernetes 命名空间是一种区分环境的方式。以下代码展示了特定生产配置映射的示例:
---
kind: ConfigMap
apiVersion: v1
metadata:
name: hello-cloud-config
namespace: production
data:
application.properties: |
hello.greeting=Hello production
hello.name=Java EE
---
凭证
由于安全原因,通常不会在项目存储库中包含秘密内容,如凭证。管理员通常会在特定环境中手动配置它们。与其他 Kubernetes 资源类似,秘密绑定到特定的命名空间。
如果一个项目需要多个秘密,例如,针对各种外部系统的特定凭证,手动配置它们可能会变得繁琐且难以跟踪。配置的秘密必须以安全的形式进行文档化和跟踪,且在项目存储库之外。
另一种方法是在存储库中存储加密凭证,这些凭证可以使用单个主密钥解密。因此,存储库可以安全地包含配置的凭证,以加密形式存在,同时仍然安全,不会泄露秘密。运行中的应用程序将使用动态提供的主密钥来解密配置的凭证。这种方法提供了安全性和可管理性。
让我们看看一个潜在的解决方案。加密的配置值可以安全地存储在 Kubernetes 配置映射中,因为解密后的值只会对容器进程可见。项目可以在配置映射定义中将加密凭证与其他配置值一起定义为代码。管理员为每个环境添加一个包含用于对称加密凭证的主密钥的秘密。这个主密钥被提供给运行中的容器,例如,使用前面看到的作为环境变量。运行中的应用程序使用这个单一的环境变量来解密所有加密的凭证值。
根据所使用的技术和算法,一个解决方案是在加载属性文件时直接使用 Java EE 应用程序解密凭证。为了提供一个使用最新加密算法的安全解决方案,应在运行时安装Java 密码学扩展(JCE)。另一种方法是部署应用程序之前先解密值。
数据迁移
使用数据库存储其状态的程序绑定到特定的数据库模式。通常,模式的变化需要应用程序模型进行更改,反之亦然。随着应用程序的积极开发和领域模型的持续精炼和重构,模型最终将需要数据库模式进行更改。新添加的模型类或其属性需要也在数据库中持久化。重构或删除的类和属性也应该在数据库中进行迁移,以避免模式出现分歧。
然而,数据迁移比代码更改更困难。无状态应用程序可以简单地用新版本替换,其中包含新的功能。然而,包含应用程序状态的数据库需要在模式更改时仔细迁移状态。
这种情况发生在迁移脚本中。关系型数据库支持在保持数据完整性的同时更改其表。这些脚本在部署软件的新版本之前执行,确保数据库模式与应用程序匹配。
在使用零停机时间方法部署应用程序时,需要记住一个重要的方面。滚动更新将至少在环境中同时运行一个活动实例。这导致旧软件版本和新软件版本在短时间内同时激活。编排应该注意,应用程序应该优雅地启动和关闭,分别让正在进行的请求完成其工作。连接到中央数据库实例的应用程序将导致多个应用程序版本同时访问数据库。这要求应用程序支持所谓的N-1 兼容性。当前的应用程序版本需要与加上和减去一个版本的相同数据库模式版本一起工作。
为了支持 N-1 兼容性,滚动更新方法需要同时部署新的应用程序版本和更新数据库模式,确保版本之间的差异不超过一个版本。这意味着,相应的数据库迁移将在应用程序部署之前执行。因此,数据库模式和应用程序都通过小迁移步骤而不是跳跃式地发展。
然而,这种方法并不简单,涉及一定的规划和谨慎。特别是,应用程序版本回滚需要特别注意。
添加数据库结构
向数据库模式添加表或表列相对简单。新的表或列不会与旧的应用程序版本冲突,因为它们对它们来说是未知的。
由新的领域实体产生的新表可以直接添加到模式中,从而产生版本N+1。
定义某些约束(如 not null 或 unique)的新表列需要考虑表当前的状态。旧的应用程序版本仍然可以向表中写入;它将忽略新列。因此,约束不一定能够满足。新列首先必须是 可空的 并且没有其他约束。新的应用程序版本必须处理该列中的空值,可能是来自旧应用程序版本的 null 值。
只有在当前部署完成后,下一个版本(N+2)才会包含正确的约束。这意味着添加定义约束的列至少需要两个单独的部署。第一个部署添加了列,并以 null-安全的方式增强了应用程序的模型。第二个部署确保所有包含的值满足列约束,添加约束,并移除 null-安全行为。当然,这些步骤仅在列的目标状态定义约束时才是必需的。
回滚到旧版本的方式类似。回滚到中间部署(从 N+2 到 N+1)需要再次移除约束。
回滚到原始状态(N+0)将删除整个列。然而,数据迁移不应删除未转移到其他地方的数据。回滚到没有列的状态也可以简单地不更改列,以避免丢失数据。业务专家必须回答的问题是:在此期间添加的数据会发生什么?故意不删除这些数据可能是一个合理的方法。然而,当再次添加列时,滚动脚本需要考虑已存在的列。
更改数据库结构
更改现有的数据库表或列更为复杂。无论是重命名列还是更改类型或约束,过渡必须在几个步骤中执行。直接重命名或更改列会导致与已部署的应用程序实例不兼容;更改需要中间列。
让我们用一个例子来检查这种方法。假设汽车实体有一个属性 color,必须设置,在数据库列 color 中表示。假设它将被重构为数据库列中的 chassis color 或 chassis_color。
与之前的方法类似,更改是在几次部署中执行的。第一次部署添加了一个可空列 chassis_color。应用程序代码被增强以使用新的模型属性。由于较旧的应用程序版本尚不了解该属性,因此在第一次部署期间并非从所有位置可靠地写入,因此第一个代码版本仍然从旧的、color 列中读取颜色,但将值写入旧列和新列。
下一次部署的迁移脚本通过用color列的内容覆盖chassis_color列来更新缺失的列值。通过这样做,确保新列被一致地填充。同时,也将非空约束添加到新列。然后,应用程序代码版本将只从新的列读取,但仍然写入两个列,因为旧版本在短时间内仍然活跃。
下一次部署步骤将color列的非空约束移除。这个版本的应用程序代码不再使用旧列,而是读取和写入chassis_color。
接下来和最终的部署将删除color列。现在所有数据已经逐步转移到新的chassis_color列。应用程序代码不再包含旧模型属性。
更改列类型或外键约束需要类似的步骤。以零停机时间逐步迁移数据库的唯一方法是分小步骤迁移,使用中间列和属性。建议对迁移脚本和应用程序代码进行几个只包含这些更改的提交。
与之前的方法类似,回滚迁移必须在数据库脚本和代码更改的反向顺序中执行。
删除数据库结构
删除表或列比更改它们更直接。一旦领域模型的一些属性不再需要,它们的用法就可以从应用程序中移除。
首次部署将应用程序代码修改为停止从数据库列读取,但仍继续向其写入。这是为了确保旧版本仍然可以读取除null之外的其他值。
下一次部署将从数据库列移除一个可能的非空约束。应用程序代码停止向该列写入。在这一步中,模型属性的出现已经可以从代码库中移除。
最终部署步骤将删除该列。如前所述,是否实际删除列数据高度依赖于业务用例。回滚脚本需要重新创建已删除的列,这意味着之前的数据已经丢失。
实施迁移
正如我们所看到的,数据迁移必须分几个步骤执行。部署前的回滚脚本以及部署。这意味着应用程序支持 N-1 兼容性,并且一次只执行一个部署。
迁移过程需要执行多个软件版本发布,每个版本在应用程序代码和模式迁移脚本上都是一致的。工程师需要相应地规划他们的提交。及时执行完整的模式迁移是明智的,以保持数据库模式整洁,并确保持续迁移不会被简单地遗忘。
相应模型重构的本质在于,现有数据是否需要保留或可以丢弃。一般来说,建议不要丢弃数据。这意味着不要删除包含不存在于其他地方的数据的结构。
正如我们在示例中看到的,迁移将以优雅的步骤进行;特别是在数据库约束方面,如非空或引用完整性约束。迁移脚本应该是健壮的。例如,迁移不应该在尝试创建已存在的列时失败。它们可能已经存在于之前的回滚中。一般来说,提前思考和测试不同的滚动和回滚场景是有意义的。
工程师在更新表内容时需要考虑更新时间。一次性更新大型表将花费相当多的时间,数据可能会被锁定。这需要提前考虑;理想情况下,通过在单独的数据库中测试脚本来实现。对于涉及大量数据的情况,可以通过按 ID 分区数据等方式,将更新步骤分批执行。
所有滚动和回滚迁移脚本都应该位于项目存储库中。数据库模式包括一个与编号迁移脚本相对应的模式版本。这个版本作为元数据存储在数据库中,与当前的架构状态一起。在每次部署之前,数据库模式都会迁移到期望的版本。紧接着,具有相应版本的程序被部署,确保版本之间不会超过一个版本。
在容器编排框架中,这意味着在通过滚动更新部署新应用程序版本之前,需要执行数据库迁移。由于可能有多个 pod 副本,这个过程必须是幂等的。将数据库架构迁移到同一版本两次,必须产生相同的结果。Kubernetes pods 可以定义所谓的初始化容器,这些容器在真实容器启动之前执行一次性过程。初始化容器是互斥运行的。它们必须成功退出,然后才能启动实际的 pod 容器进程。
以下代码片段展示了initContainer的一个示例:
# ...
spec:
containers:
- name: hello-cloud
image: .../hello-cloud:1
initContainers:
- name: migrate-vehicle-db
image: postgres
command: ['/migrate.sh', '$VERSION']
# ...
前面的例子表明,初始化容器镜像包含了连接到数据库实例的正确工具以及所有最近的迁移脚本。为了实现这一点,这个镜像也被构建到管道中,包括从存储库中获取的所有迁移脚本。
然而,有许多方法可以迁移数据库模式。这里的重要方面是,需要先执行幂等迁移,同时不进行第二次部署操作。相应版本的迁移脚本将按顺序执行,直到版本匹配。脚本执行后,数据库中的元数据版本也会更新。
代码版本和数据库版本之间的关联可以在项目仓库中跟踪。例如,包含在提交版本中的最新部署脚本对应于所需的数据库模式。构建元数据部分更深入地讨论了所需的元数据和存储位置。
由于所选择的迁移解决方案高度依赖于项目的技术,因此这里没有一种可以展示的万能解药方法。以下示例提供了一个可能的解决方案,关于迁移文件结构和执行过程的伪代码。它展示了将之前讨论的color列更改为chassis_color的迁移文件示例:

上述示例展示了将数据库模式版本迁移到所需状态的部署和回滚脚本。部署脚本004_remove_color.sql通过删除之前展示的示例中的color列,将模式版本转换为版本4。相应的回滚脚本003_add_color.sql将模式回滚到版本3,其中color列仍然存在;换句话说,版本3包含color列,而版本4不包含,这两个迁移文件能够来回回滚。
以下展示了执行迁移的脚本的伪代码。在调用脚本时,要迁移的目标版本作为参数提供:
current_version = select the current schema version stored in the database
if current_version == desired_version
exit, nothing to do
if current_version < desired_version
folder = /rollouts/
script_sequence = range from current_version + 1 to desired_version
if current_version > desired_version
folder = /rollbacks/
script_sequence = range from current_version - 1 to desired_version
for i in script_sequence
execute script in folder/i_*.sql
update schema version to i
此迁移脚本在实际部署之前在初始化容器中执行。
测试
验证管道步骤的输出是持续交付中最重要方面之一。它通过在上线前检测潜在错误来提高软件质量。适当的验证在过程中创造了可靠性。通过编写软件测试,尤其是回归测试,开发者对更改和重构功能变得自信。最终,软件测试使我们能够自动化开发过程。
构建二进制文件已经执行了代码级别的测试。项目中的其他测试可能需要在单独的管道步骤中执行,具体取决于它们是否在代码级别或运行容器中操作。特别是端到端测试需要运行环境。
在应用程序已部署到测试环境后,可以执行端到端测试。通常,一个项目包含几个测试层,具有不同的责任,在单独的步骤中运行。根据项目和使用的技术的不同,可能会有各种各样的测试。始终的做法是执行流水线步骤并充分验证结果。通过这样做,可以最小化破坏新或现有功能以及引入潜在错误的风险。特别是,具有生产就绪特性的容器编排框架支持公司实现交付可扩展、高可用性、高质量的企业应用程序的目标。第七章测试涵盖了测试的所有不同表现形式,包括其在持续交付流水线中的执行。
考试不及格将立即导致流水线停止,并阻止相应的二进制文件进一步使用。这是实现快速反馈并加强软件质量过程的一个重要方面。工程师应绝对避免绕过正常流程的步骤和其他快速修复。这些做法与持续改进和将质量融入持续交付流程的理念相矛盾,并最终导致错误。如果测试或质量门失败,构建必须中断,或者必须更改应用程序的代码或验证。
考试不及格不仅应该中断构建,还应该提供关于为什么该步骤失败以及记录结果的见解。这是构建元数据的一部分。
构建元数据
构建元数据记录了在构建执行过程中收集的所有信息。特别是,所有资产的具体版本应被跟踪以供进一步参考。
从头到尾运行的构建不一定需要更多信息。步骤在一个运行中执行,直到构建中断或成功完成。然而,如果需要引用或重新执行特定的步骤或工件,则需要更多信息。
艺术品版本是这种必要性的主要例子。WAR 文件及其内容对应于 VCS 提交历史中的一个特定版本。为了跟踪从部署的应用程序中起源的提交,这些信息需要被跟踪。对于容器镜像版本也是如此。为了识别容器的起源和内容,版本需要可追溯。数据库模式版本是另一个例子。数据库模式版本通过遵循 N-1 兼容性匹配特定应用程序版本,包括上一个和下一个版本。需要迁移数据库模式的部署必须知道要迁移到的数据库模式版本,以便达到所需的应用程序版本。
当流程允许推出特定应用程序版本时,需要特别要求构建元数据。通常,连续交付部署会向前推进到当前存储库版本。然而,特别是涉及数据库模式和迁移时,将环境滚动到任意状态的可能性是一个巨大的好处。理论上,这个过程是这样的:获取这个特定的应用程序版本,并执行所有必要的操作,以便在该特定环境中运行它,无论推出是向前还是向后移动。
为了提高可追溯性和可重现性,建议跟踪有关构建的质量信息。这包括,例如,自动化测试结果、手动测试或代码质量分析的结果。然后部署步骤能够在部署前验证特定元数据的存在。
存储元数据有许多可能的解决方案。一些工件存储库,如 JFrog Artifactory,提供了将构建工件与自定义元数据链接的可能性。
另一种方法是使用持续集成服务器来跟踪这些信息。这听起来像是存储构建元数据的良好选择;然而,根据持续集成服务器的操作和设置方式,不一定建议用它来存储持久数据。旧构建可以被丢弃并丢失信息。
通常,存储工件和信息时,真实点的数量应该保持较低,并且明确定义。因此,使用工件存储库来存储元数据确实是有意义的。
另一种更定制化的解决方案是使用公司版本控制系统(VCS)存储库来跟踪某些信息。使用,例如,Git 来存储元数据的一个大优点是它提供了持久数据及其结构的完全灵活性。持续集成服务器已经包含访问 VCS 存储库的功能,因此不需要特定的供应商工具。存储库可以存储所有类型的信息,这些信息作为文件持久化,例如记录的测试结果。
不论如何实现,元数据存储库在管道的多个点被访问,例如在执行部署时。
迁移到生产环境
连续交付管道的最后一步是将代码部署到生产环境。这种部署可以是手动触发的,或者在实施足够的验证和自动化测试后自动触发。绝大多数公司使用手动触发的部署。但即使管道不是从一开始就“完全”进行,连续交付通过自动化所有必要的步骤也能提供巨大的好处。
管道然后只有两个启动点:触发执行的仓库初始提交,以及所有步骤经过手动和自动验证后的最终部署到生产环境。
在容器编排环境中,部署到生产环境,即部署到单独的命名空间或单独的集群,与部署到测试环境的方式相同。由于基础设施作为代码的定义与之前执行的定义相似或理想上是相同的,这项技术降低了生产环境中环境不匹配的风险。
分支模型
软件开发流程可以利用不同的分支模型。软件分支从同一源头出现,但在开发状态上有所不同,以便能够并行开发多个开发阶段。
尤其是功能分支是一种流行的方法。功能分支创建一个单独的分支,用于开发特定的软件功能。在功能完成后,该分支被合并到主分支或主干。在功能开发期间,主分支和其他分支保持不变。
另一种分支模型是使用发布分支。发布分支包含特定版本的单一软件发布。想法是有一个专门的点用于发布版本,可以在此处添加错误修复和功能。所有应用于特定发布的对主分支所做的更改也都在发布分支中执行。
然而,这样的分支模型与持续交付的理念相矛盾。例如,功能分支会推迟功能集成到主分支。新功能集成的延迟越长,潜在的合并冲突的可能性就越大。因此,只有在功能分支是短期存在并且及时集成到主分支的情况下,才建议在持续交付管道中使用功能分支。
同时发布版本和在这些版本上工作与持续发布版本的理念相矛盾。理想情况下,实现的功能应尽可能快地发布到生产环境中。
至少对于企业项目来说是这样的。持续的生命周期意味着每个提交都是一个潜在的生产部署候选者。在主分支上集成和应用工作是有意义的,这样就可以尽早集成和部署功能,并通过自动化测试进行验证。因此,持续交付和持续部署的分支模型相当直接。更改直接应用于主分支,由构建管道构建、验证和部署。
通常不需要手动标记发布。在持续交付管道中的每个提交都隐式地有资格发布并部署到生产环境中,除非自动化验证发现错误。
下图显示了持续部署分支模型的概念:

单个功能分支保持短期存在,并及时合并回master分支。发布版本在构建成功时隐式创建。失败的构建不会导致生产环境的部署。
然而,对于产品以及库来说,建议采用不同的分支模型。由于存在多个支持的主要和次要版本以及它们潜在的错误修复,为不同的发布版本实现分支是有意义的。例如,发布版本分支如v1.0.2可以用于继续支持错误修复,例如到v1.0.3,而主要开发则继续在新版本上进行,例如v2.2.0。
技术
在设计持续交付管道时,问题仍然是使用哪种技术。这不仅包括 CI 服务器本身,还包括开发工作流程中使用的所有工具,如版本控制、工件存储库、构建系统和运行时技术。
使用的具体技术取决于实际需求以及团队熟悉程度。以下示例将使用 Jenkins、Git、Maven、Docker 和 Kubernetes。截至编写本书时,这些技术被广泛使用。然而,对于工程师来说,理解其背后的原理和动机更为重要。技术本身是可以互换的。
无论选择什么工具,建议使用工具的预期用途。经验表明,工具往往被错误地用于更适合使用不同技术执行的任务。一个典型的例子是构建系统,例如 Maven。项目通常定义的构建过程承担的责任不仅仅是构建工件。
不将构建容器或部署软件到工件构建中的责任混合在一起是有意义的。这些关注点最好通过连续集成服务器直接实现。将这些步骤引入构建过程是不必要的,会不必要地将构建技术与环境耦合。
因此,建议以直接的方式使用工具的预期用途。例如,建议通过相应的 Docker 二进制文件而不是构建系统插件来构建 Docker 容器。所需的高级抽象层最好在管道即代码定义中添加,如下面的示例所示。
管道即代码
我们之前看到了将配置表示为代码的好处,主要是基础设施即代码文件。同样的动机导致了管道即代码定义,即指定 CI 服务器管道步骤的配置。
在过去,许多 CI 服务器,如 Jenkins,需要手动配置。CI 服务器作业必须经过繁琐的操作来构建管道。特别是,为新应用程序或其功能分支重建管道需要繁琐的手动工作。
代码化流水线定义指定了作为软件项目一部分的持续交付流水线。CI 服务器根据脚本构建并执行流水线,这极大地简化了定义和重用项目构建流水线。
有许多 CI 服务器支持将流水线定义作为代码。最重要的方面是工程师理解这项技术的动机和好处。以下展示了 Jenkins 的示例,Jenkins 是 Java 生态系统中最广泛使用的 CI 服务器之一。
Jenkins 的用户可以在 Jenkinsfile 中构建流水线,该文件使用 Groovy DSL 定义。Groovy 是一种可选类型的动态 JVM 语言,非常适合 DSL 和脚本。Gradle 构建脚本也使用 Groovy DSL。
以下示例展示了 Java 企业项目的非常简单的流水线步骤。这些示例旨在提供一个对执行过程的粗略理解。有关 Jenkinsfile 的完整信息、其语法和语义,请参阅文档。
以下展示了一个基本的 Jenkinsfile 示例,其中包含了一个基本的流水线定义。
node {
prepare()
stage('build') {
build()
}
parallel failFast: false,
'integration-test': {
stage('integration-test') {
integrationTest()
}
},
'analysis': {
stage('analysis') {
analysis()
}
}
stage('system-test') {
systemTest()
}
stage('performance-test') {
performanceTest()
}
stage('deploy') {
deployProduction()
}
}
// method definitions
stage 定义指的是 Jenkins 流水线中的步骤。由于 Groovy 脚本提供了一种完整的编程语言,因此可以并且建议应用清洁代码实践,以生成可读的代码。因此,特定步骤的内容被重构为分离的方法,所有这些方法都在相同的抽象层。
例如,prepare() 步骤封装了几个执行过程以满足构建前提条件,例如检出构建仓库。以下代码展示了其方法定义:
def prepare() {
deleteCachedDirs()
checkoutGitRepos()
prepareMetaInfo()
}
构建阶段还封装了几个子步骤,从执行 Maven 构建、记录元数据和测试结果,到构建 Docker 镜像。以下代码展示了其方法定义:
def build() {
buildMaven()
testReports()
publishArtifact()
addBuildMetaInfo()
buildPushDocker(dockerImage, 'cars')
buildPushDocker(databaseMigrationDockerImage, 'cars/deployment/database-migration')
addDockerMetaInfo()
}
这些示例提供了如何定义和封装特定行为到步骤的见解。提供详细的 Jenkinsfile 示例超出了本书的范围。我将向您展示必要的粗略步骤,以给出所需逻辑执行的概念,以及如何在这些流水线脚本中以可读和高效的方式定义它们。然而,实际的实现却高度依赖于项目。
Jenkins 流水线定义提供了包含所谓的流水线库的可能性。这些是包含常用功能的预定义库,旨在简化使用并减少多个项目中的重复。建议将某些功能外包到公司特定的库定义中,特别是关于环境特定的功能。
以下示例展示了将 汽车制造 应用程序部署到 Kubernetes 环境的过程。在将特定镜像和数据库模式版本部署到 Kubernetes 命名空间时,会在构建流水线内部调用 deploy() 方法:
def deploy(String namespace, String dockerImage, String databaseVersion) {
echo "deploying $dockerImage to Kubernetes $namespace"
updateDeploymentImages(dockerImage, namespace, databaseVersion)
applyDeployment(namespace)
watchRollout(namespace)
}
def updateDeploymentImages(String dockerImage, String namespace, String databaseVersion) {
updateImage(dockerImage, 'cars/deployment/$namespace/*.yaml')
updateDatabaseVersion(databaseVersion 'cars/deployment/$namespace/*.yaml')
dir('cars') {
commitPush("[jenkins] updated $namespace image to $dockerImage" +
" and database version $databaseVersion")
}
}
def applyDeployment(namespace) {
sh "kubectl apply --namespace=$namespace -f car-manufacture/deployment/$namespace/"
}
def watchRollout(namespace) {
sh "kubectl rollout status --namespace=$namespace deployments car-manufacture"
}
此示例更新并提交了 VCS 存储库中的 Kubernetes YAML 定义。执行将基础设施即代码应用到 Kubernetes 命名空间,并等待部署完成。
这些示例旨在让读者了解如何将持续交付管道作为管道即代码定义与容器编排框架(如 Kubernetes)集成。如前所述,还可以利用管道库来封装常用的kubectl shell 命令。动态语言如 Groovy 允许工程师以可读的方式开发管道脚本,并以与其他代码相同的努力对待它们。
Java EE 工作流程
展示的示例涵盖了通用的 Java 构建管道,当然,这些也适用于 Java EE。实际上,使用 Java Enterprise 高度支持高效的开发管道。快速构建以及因此快速的开发者反馈对于有效的持续交付工作流程至关重要。
无依赖的应用程序,尤其是当打包在容器中时,如我们在第四章中看到的,轻量级 Java EE,利用了这些原则。打包的工件或容器层中的企业应用程序,分别只包含针对 API 开发的业务逻辑。应用程序容器提供实现。
持续交付管道从无依赖的应用程序中受益,因为涉及的构建和分发步骤分别只需要短的执行和传输时间。工件构建以及容器构建尽可能快地运行,只复制绝对必要的部分。同样,发布和部署工件以及容器层只包含所需业务关注点,以最小化传输时间。这导致快速周转和快速反馈。
拥有有效的管道对于在开发团队中实施持续交付文化至关重要。由于管道运行速度快,提供快速反馈,并增加了软件质量达到标准的信心,工程师们更有动力尽早和频繁地提交代码。
如前所述,构建时间不应超过几秒钟。包括端到端测试在内的构建管道执行不应超过几分钟,理想情况下甚至更快。
付出努力使构建和管道运行更快应该是工程团队的目标。在工作日中,开发者经常构建和提交项目。每次提交都会导致一个持续交付构建,这可能是生产部署的潜在候选者。如果整个流程仅比例如多 1 分钟,那么每次构建软件时,团队中的所有开发者都要多等 1 分钟。可以想象,这种延迟随着时间的推移会累积成很大的数字。如果开发者必须等待结果,他们可能会倾向于更少地提交代码。
因此,提高管道的稳定性和性能是对团队生产力的长期投资。那些在出现错误时能更快地中断构建并提供快速、有用反馈的测试和步骤应尽可能早地运行。如果某些端到端测试由于项目性质和测试本身的性质而不可避免地运行时间更长,它们可以被定义为单独的下游管道步骤,以避免延迟早期验证的反馈。可以并行运行的步骤,如静态代码分析,应该并行运行,以加快整体执行速度。使用现代的 Java EE 开发方法极大地支持构建高效的建设管道。
然而,技术只是有效持续交付的一个方面。引入持续交付对开发团队的文化产生了更大的影响。让我们更深入地了解一下这一点。
持续交付文化和团队习惯
有效的持续交付依赖于健康的团队文化。如果团队不遵循持续交付的原则和建议,即使最好的技术也帮助不大。如果没有足够的软件测试来验证已部署的软件,实现自动化部署的管道几乎没有价值。如果开发者很少提交他们的更改,使得集成变得困难和繁琐,最积极的 CI 服务器也帮不上忙。如果团队不对失败的测试做出反应,或者在最坏的情况下,将测试执行设置为忽略,那么全面测试覆盖和代码质量检查就没有价值。
责任
持续交付始于对软件负责。如前所述,对于 DevOps 运动来说,开发者仅仅构建他们的软件并让其他团队处理潜在的错误是不够的。创建并拥有应用程序的开发团队了解其责任、使用的技术以及在潜在错误发生时的故障排除。
想象一家只有一位开发者的小型初创公司,这位开发者负责整个应用程序。显然,这个人必须处理所有技术问题,例如开发、构建、部署以及解决应用程序的故障。他将拥有关于应用程序内部的最佳知识,并能有效地解决潜在问题。显然,这种单一责任点的方法与可扩展性相反,只适用于小型团队。
在更大的公司中,有更多的应用程序、更多的开发者和更多的团队,他们有不同的责任。在分割和转移责任方面的挑战在于转移知识。理想情况下,这种知识是在一个紧密合作、共同开发相同软件的工程师团队中传播的。就像在小型初创公司一样,开发应用程序的座右铭应该是:“你构建它,你就运行它”。对于单一团队来说,这只有在中央、明确且自动化的流程支持下才有可能。实施持续交付管道实现了这些流程,以确保可靠地发布软件。
管理和改进这些流程成为整个工程师团队的责任,而不再是运维问题。所有开发者都有责任构建和发布对业务有价值的软件。这当然涉及一些职责或团队习惯。
提早且频繁地提交代码
持续交付必须由整个团队共同实践。负责功能或错误修复的开发者应该尽早且频繁地将代码提交到主分支。这对于实现持续集成至关重要。在将更改合并到主分支之前的时间越长,合并和集成功能就越困难。一次性添加复杂功能与软件持续进化的理念相矛盾。尚未对用户可见的功能可以通过功能开关排除。
经常提交代码鼓励开发者从一开始就编写足够的、自动化的软件测试。这当然是在开发过程中需要付出的努力,但长期来看总是会有回报。在开发功能时,工程师们对其功能性和边界有清晰的认识。从一开始就包含单元测试以及复杂的端到端测试,比在功能编写完成后进行要容易得多。
尤其对于经验较少的开发者来说,重要的是要指出,提交功能的前期版本并不是什么可耻的事情,而是开发过程的一部分。尚未重构且看起来并不完美的代码,但满足需求并提供商业价值,可以在第二次运行中清理。在开发过程中尽早提交代码比推迟到最后一刻不提交要更有帮助。
立即修复问题
立即解决构建错误是另一个需要培养的重要团队习惯。失败的测试不应被忽视或推迟,而应尽快修复。频繁失败且未得到妥善处理的构建会降低所有团队成员的生产力。例如,一个失败的测试使得项目无法构建,这会阻止其他开发者集成和验证他们的功能。然而,由于测试失败或质量违规导致的失败构建是验证工作有效的迹象,显然比误报(即错误地显示为绿色构建)要好得多。然而,重要的是在项目构建失败时尽快修复。开发者应该在将代码推送到中央仓库之前,在自己的本地机器上执行基本的快速验证,例如构建 Java 项目并执行代码级别的测试。他们应该注意不要滥用管道来寻找不必要的错误,这些错误会无端打扰其他团队成员。
如前所述,编译器或代码分析警告应被视为破坏构建的错误。这引入了零警告政策,敦促工程师修复问题或调整验证。因此,构建、编译或代码风格警告也是破坏构建的错误,需要尽快修复。
导致构建中断的团队成员应该是首先调查根本原因的人。然而,保持管道处于健康状态是整个团队的责任。这回到了整个团队对整个项目负责的原则。不应该有专属的代码所有权,也就是说,项目的一部分应该只被单个团队成员所熟知。通常情况下,编写特定功能的开发者对该功能有更好的了解。尽管如此,在所有情况下,团队应该能够处理项目的所有领域并修复潜在的问题。
可见性
持续交付所提供的可见性是另一个重要方面。包括提交、构建、验证和部署在内的整个开发过程都可以在一个地方进行跟踪和理解。在持续交付管道中,哪些可见性方面是重要的?
首先,需要表示软件是否处于可发货状态。这包括构建在编译、测试和代码分析方面的健康状况。仪表板或所谓的极端反馈设备,例如物理的绿色和红色 LED 灯,可以提供关于这一点的快速概述。
一个合理的构建可见性在构建成功时不应信息过载,但在构建失败时应提供清晰直接的洞察。这再次遵循了构建中不存在警告的原则;要么成功通过,没有其他事情要做,要么失败并需要采取行动。提供这种“绿色或红色”信息的仪表板或其他设备已经提供了有用的洞察。这些可见性工具应该对所有团队成员可访问,以促进协作。
然而,为了避免过多地干扰日常开发,首先通知负责构建中断的人员是有意义的。他们可能拥有进一步的知识,如何在必要时不会打扰到同事的工作中修复构建。CI 服务器提供发送电子邮件、使用聊天通信或其他形式通知的功能。这既提高了软件质量,也提高了开发者的生产力。
在构建过程中收集的信息可以用来衡量软件项目的质量。这首先包括构建和测试结果以及代码质量指标,如测试覆盖率。这些信息可以随时间显示,以提供关于软件质量的洞察和趋势。
其他非常有趣的元数据与构建管道本身有关。构建通常需要多长时间?一天中有多少次构建?构建失败有多频繁?最常见的失败原因是什么?失败的构建需要多长时间才能修复(恢复时间)?对这些问题的回答提供了关于持续交付过程质量的宝贵洞察。
收集到的信息作为进一步改进流程的良好起点。持续交付的可见性不仅照亮了当前项目状态,还可以将工程师的注意力引向某些热点。总体目标是持续改进软件。
持续改进
持续交付的整体心态旨在以一致的质量交付软件。自动化流程鼓励使用质量验证。
当然,良好的软件质量不是免费的。足够的测试用例以及代码质量分析需要一定的时间和精力。然而,自动化和持续提高质量,在达到初始阈值后,将长期带来回报,并最终导致更好的软件。
新功能和发现的错误在开发过程中需要充分验证,以确保功能按预期工作。通过自动化测试并将它们作为回归测试,开发者可以确信不会有新的错误在未来破坏功能。对于代码质量分析也是如此。一旦分析设置好了适当的规则,并且发现的错误被消除,它就确保了不会有新的违规行为进入软件。如果出现新的误报违规,规则会被调整,并将防止未来出现新的误报。
引入新的测试场景,如端到端测试,也极大地支持了这种方法。回归测试越来越减少新引入的错误的几率。再次强调,自动化是关键。正如我们在第七章“测试”中将会看到的,人类干预对于定义合理的测试场景是有帮助的。然而,将这些测试自动化并使其成为流程的一部分对于软件质量至关重要。通过这样做,随着时间的推移,质量会不断提高。
当然,这要求工程师将一定的优先级放在质量改进上。提高软件质量以及重构不会为业务带来任何即时利益。这些努力将长期产生回报——通过以恒定的速度继续产生新功能或以确信不会破坏其他东西的确定性来改变现有行为。
摘要
生产力开发工作流程需要快速周转时间和快速反馈。自动化重复性任务最小化了在构建、测试和部署上花费的时间。零依赖的 Java EE 应用程序通过最小化构建、发布和部署时间来支持快速反馈。
定义哪些错误类别会破坏构建非常重要。开发者应该意识到,构建要么因为合法错误而失败,要么通过,没有任何可以抱怨的地方。对构建结果没有影响的警告几乎没有价值。
数据迁移是另一个需要考虑的重要话题。部署无状态应用程序相对容易;需要考虑的是需要与应用程序代码匹配的数据库模式。通过滚动更新和迁移脚本,这些脚本以小批量方式推出修改,使得应用程序可以零停机时间部署。因此,应用程序需要支持 N-1 兼容性。
持续交付依赖于健康的团队文化。仅仅实施技术必要性是不够的;所有软件工程师都需要接受这些原则。潜在的构建问题、测试结果、软件质量和部署状态应该对整个软件团队可见。
持续交付流程支持软件的持续改进。添加的验证步骤,例如自动软件测试,每次构建应用程序时都会运行,从而实现回归测试并避免特定错误再次发生。当然,这要求开发者投入精力进行质量改进。在长期来看,持续交付所付出的努力将得到回报。
下一章将继续探讨软件质量领域,并将涵盖测试企业应用程序的内容。
第七章:测试
如前一章所述,持续交付管道允许开发者以恒定的速度和质量发布软件。为了达到这一质量标准,需要自动化软件测试。从事功能开发的工作者希望确保一切按预期工作。当软件项目发展、变化并可能破坏现有行为时,这一点尤为重要。开发者需要确保没有引入不希望出现的副作用。
理想情况下,构建管道中包含的软件测试足够,无需进一步的手动验证,即可部署到生产环境中。
本章将涵盖以下主题:
-
软件测试的要求
-
不同的测试级别和范围
-
单元测试、组件测试、集成测试、系统测试和性能测试
-
如何在本地运行测试场景
-
如何构建可维护的测试
-
需要的测试技术
测试的必要性
测试是确保在后期生产中某个功能以特定方式行为所必需的。在所有类型的制造业务中,测试是过程的一个自然部分。一辆汽车有无数个部件需要独立以及相互依赖地进行测试。没有人愿意驾驶一辆在真实街道上首次进行测试跑车的汽车。
测试模拟生产行为,并在安全环境中验证组件。在测试运行期间损坏的制造部件是积极的事情;它们只是指出了潜在的错误,而且仅仅损失了时间和材料。在生产中损坏的部件可能造成更大的损害。
对于软件测试来说,也是如此。测试失败是积极的事情,最坏的情况是浪费了一些时间和精力,最好的情况是防止潜在的缺陷进入生产。
如前所述,测试需要在尽可能少的人类交互下运行。人类擅长思考合理的测试用例和构建创意测试场景。然而,计算机在执行这些任务方面更胜一筹。在给出明确的验证指令后,计算机也能很好地验证复杂的测试。随着时间的推移,软件变得越来越复杂,手动验证行为所需的努力越来越大,而且随着时间的推移更容易出错。计算机在重复性任务上表现更好,也更可靠。
可靠的自动化软件测试是快速发展的先决条件。自动化测试可以多次执行,验证整个应用程序。构建每天运行多次,每次都执行所有测试——即使只有微小的更改——并允许经过验证的版本投入生产。如果由人类执行测试,这是不可行的。
自动化测试提高了持续交付过程的可靠性和信心。对于直接进入生产的持续部署来说,绝对需要足够的自动化测试场景。当所有提交都是生产部署的潜在候选者时,所有软件行为都必须在事先得到充分验证。没有这种自动化验证,持续部署将不可能实现。
优秀测试的要求
当今的软件世界普遍认为测试对工作软件至关重要。但什么是一个好的软件测试?我们必须测试哪些软件组件?更重要的是,我们如何开发精心设计的测试?
通常,测试应满足以下要求:
-
可预测性
-
隔离性
-
可靠性
-
快速执行
-
自动化
-
可维护性
以下描述了这些要求。
可预测性
首先,软件测试必须是稳定的、可预测的和可重复的。必须可预测地产生相同的测试用例结果,即通过或失败。有时通过有时失败的测试根本没有任何帮助。它们要么通过提供假阳性结果来分散开发者的注意力,要么通过提供假阴性结果来抑制实际的错误。
需要考虑的情况包括当前时间、时区、地区、随机生成数据以及其他可能干扰的测试的并发执行。测试场景应可预测且明确设置,以便这些情况不会影响结果。如果测试的功能实际上受到这些因素的影响,这是一个需要考虑不同配置的额外测试场景的迹象。
隔离性
预测性的要求也与隔离性相关。测试用例必须独立运行,不得影响其他测试。更改和维护测试用例也不应影响其他测试场景。
除了利用可预测性和可维护性之外,隔离测试还对错误的可重复性有影响。复杂的测试场景可能包含许多关注点和责任,这可能会使找到失败测试的根本原因变得困难。然而,具有较小范围的隔离测试可以限制原因的可能性,并使开发者能够更快地找到错误。
企业项目通常具有的几个测试范围,我们将在本章后面看到,也伴随着几个测试隔离层。范围较小的测试,如单元测试,比例如端到端测试运行得更隔离。在不同的范围内编写测试用例,意味着不同的测试隔离层,这当然是有意义的。
可靠性
理想情况下,项目的软件测试应可靠地测试所有功能。口头禅应该是:通过测试的软件适合生产使用。这当然是一个值得追求的目标,例如通过持续改进。
使用持续交付和特别是持续部署需要可靠且充足的测试环境。软件测试是生产部署前的最终质量屏障。
通过的可靠测试不应需要任何进一步的交互。因此,如果整体执行成功,它们不应输出冗长的日志。虽然执行过程中发生的事情的详细解释对于失败的测试非常有帮助,但在通过运行中它变得分散注意力。
快速执行
如前所述,测试需要快速执行。快速运行的测试是提供快速反馈的开发管道的必要条件。特别是随着测试数量随着时间的推移通过持续改进而增加,保持管道有效率的唯一方法就是保持测试执行时间低。
通常,测试执行花费最多的时间是启动测试技术。特别是使用嵌入式容器的集成测试,消耗了大量的启动时间。实际执行测试所花费的时间在大多数情况下并不是一个大问题。
耗费大量时间的测试与质量持续改进的理念相矛盾。随着项目中新增加的测试用例和场景越多,整体测试执行时间就越长,反馈就越慢。特别是在快速变化的世界中,软件测试需要尽可能快地执行。本章的其余部分将向您展示我们如何实现这一目标,特别是在端到端测试场景方面。
自动化
自动化是快速反馈的前提。持续交付管道步骤应尽可能减少人工干预。测试场景也是如此。执行软件测试和验证其结果应完全且可靠地运行,无需人工交互。
测试用例定义了功能的预期行为,并验证结果是否符合预期。然后,测试将可靠地通过,无需额外通知,或者在详细说明的情况下失败。通过测试不应需要任何进一步的人工交互。
尤其是具有大量或复杂测试数据的场景,在自动化测试用例方面代表了一定的挑战。为了处理这个问题,工程师应该以可维护的方式编写测试用例。
可维护性
开发测试用例是一回事。在功能发生变化时保持高效且覆盖良好的测试用例是另一回事。测试场景设计不当的挑战在于,一旦生产功能发生变化,测试也需要相应地改变,这需要大量的时间和精力。
编写测试用例需要与生产代码相同的关注和努力。经验表明,如果没有投入这种努力,测试用例中会包含大量的重复和多重责任。与生产代码一样,测试代码也需要重构。
应该能够在不花费太多努力的情况下更改或扩展测试场景。特别是需要更改的测试数据需要有效地表示。
可维护的测试是企业项目的先决条件,这些项目具有适当的测试覆盖率,同时对其业务逻辑的变化具有灵活性。能够适应快速变化的世界需要可调整的测试场景。
需要测试的内容
在我们深入探讨如何创建有效、快速、可靠、自动化和可维护的测试用例之前,让我们先看看需要测试哪些资产。这些测试包括代码层测试以及端到端测试。代码层测试基于项目的源代码,通常在开发和构建时执行,而端到端测试则对所有类型的运行应用程序进行操作。
根据测试范围,我们将在下一节中了解,存在不同的测试层,无论是测试操作在类、多个组件、企业应用程序还是整个环境中。在所有情况下,测试对象都需要从外部关注点中隔离出来。测试的本质是在特定条件下验证某些行为。测试对象周围的环境,如测试用例以及使用的组件,必须相应地与测试对象交互。因此,测试用例将控制测试对象。这不仅包括代码级别的测试,还包括模拟和模拟外部系统的端到端测试。
最重要的是,软件测试应该验证业务行为。所有指定的用例都必须执行某些逻辑,这些逻辑在生产部署之前必须经过测试。因此,软件测试应该验证应用程序是否满足业务需求。同时,还需要涵盖特殊情况和边缘情况以及负面测试。
例如,测试身份验证功能不仅需要验证用户能否使用正确的凭据登录,还需要验证他们不能使用错误的凭据登录。这个例子中的一个边缘情况是验证身份验证组件在用户成功登录后立即通知密码即将到期的用户。
除了业务行为之外,还需要测试技术方面和横切组件。需要验证访问的数据库和外部系统以及通信形式,以确保团队的工作效率。这些关注点最好在端到端测试中进行测试。
在所有情况下,测试对象在测试过程中不应被修改,而应按照在生产环境中工作的方式运行。这对于创建不会在以后改变其行为的可靠测试至关重要。对于代码级别的测试,这只需要确保所有相关组件的内容相同。对于端到端测试,这包括整个企业应用程序以及应用程序运行时的安装和配置。
测试范围的定义
需要考虑的测试范围和责任有几个。以下将介绍本章剩余部分将涵盖的不同范围。
某些命名,如集成测试,在各个企业项目中使用得模糊不清。本子章节定义了用于本书其余部分的统一测试范围名称。
单元测试
单元测试验证应用程序单个单元的行为。单元测试通常代表一个类,在某些情况下是一两个相互依赖的类。
单元测试在代码级别上操作。它们通常在开发期间在 IDE 中执行,也是应用程序打包前的构建过程的一部分。单元测试是所有测试范围中执行时间最短的。它们仅执行可以在代码级别上轻松实例化的有限功能。单元的潜在依赖通过模拟或哑类进行模拟。
组件测试
组件测试验证一个连贯组件的行为。它们不仅跨越单个单元,而且仍然在代码级别上操作。组件测试旨在将多个组件集成在一起,这验证了相互依赖的行为,而无需设置容器环境。
组件测试的范围是在不运行可能缓慢的模拟环境中的应用程序的情况下提供比单元测试更多的集成。与单元测试类似,它们使用模拟功能来界定和模拟测试边界。不需要嵌入式或远程企业容器。
集成测试
对于集成测试代表什么以及如何设计它们,存在很多分歧。目标集成可以在多个级别发生。
我将使用这个术语,因为它在 Java 生态系统中被广泛使用,并且在 Maven 约定中表示。集成测试在代码级别上运行,提供多个单元和组件的集成,并且通常运行一些更复杂或更简单的测试框架。这是与组件测试的主要区别。
集成测试的范围与组件测试相似,也集成多个单元;然而,重点是集成。这种集成更多与技术相关而不是与业务相关。例如,管理豆可以使用 CDI 注入通过限定符或 CDI 生产者获取某些依赖。开发者需要验证 CDI管道是否已经正确完成,也就是说,已经使用了正确的注解,而无需将应用程序部署到服务器。
测试框架启动一个嵌入式运行时,该运行时会构建几个组件,并对它们执行代码级别的测试。
然而,组件测试仅关注业务逻辑,并且局限于简单依赖,这些依赖容易在没有复杂容器的情况下解决。一般来说,组件测试更适合测试业务用例,因为它们包含的移动部件较少,运行速度更快。
系统测试
“系统测试”一词有时也模糊地使用。在此上下文中,该术语涵盖了所有运行应用程序或整个系统的测试用例,以端到端的方式验证用例。有时分别使用“验收测试”或“集成测试”等术语。然而,本书始终使用“系统测试”一词来指代端到端测试。
系统测试对于验证已部署的应用程序按预期工作非常重要,包括业务逻辑和技术问题。虽然大多数业务逻辑应该已经通过单元和组件测试得到覆盖,但系统测试验证的是整体行为,包括所有外部系统,是否符合预期。这包括功能如何在系统景观中集成和交互。
为了让一个应用程序提供价值,仅仅包含业务逻辑是不够的,还需要考虑如何访问这些逻辑。这需要在端到端的方式下进行验证。
由于本书针对的是后端应用程序,因此在此不考虑 UI 级别测试;这包括 UI 端到端测试以及 UI 响应性测试。开发者通常使用如Arquillian Graphene等测试技术来开发 UI 测试。本章中描述的系统测试方法也适用于 UI 级别测试。
性能测试
性能测试验证了系统在特定工作负载下的响应性和正确行为方面的非功能性方面。
需要确保应用程序不仅能在实验室条件下提供业务价值,而且在生产环境中也能如此。在生产环境中,系统的负载可能会因应用程序的性质和使用案例而大幅变化。公开可用的应用程序也面临着成为拒绝服务攻击目标的危险。
性能测试是检测由应用程序引起的潜在性能问题的有用工具。例如,这包括资源泄漏、配置错误、死锁情况或缺少超时。将应用程序置于模拟的工作负载下将这些问题暴露出来。
然而,正如我们在第九章“监控、性能和日志记录”中将要看到的,性能测试并不一定有助于预测生产环境的响应性或调整应用程序的性能。它们应该用作防止明显错误的障碍,提供快速的反馈。
在本书的剩余部分,我将使用“性能测试”一词来描述性能测试以及将应用程序置于性能负载下的负载或压力测试。
压力测试
与性能测试类似,压力测试旨在将系统置于一定的压力之下,以验证在异常情况下的正确行为。虽然性能测试主要针对应用程序在责任和稳定性方面的性能,但压力测试可以涵盖所有试图使系统崩溃的方面和尝试。
这包括无效调用、忽视通信契约,或来自环境的随机、意外事件。这里列出的测试列表并不全面,并且超出了本书的范围。
然而,为了举几个例子,压力测试可能验证对 HTTP 连接的误用,例如 SYN 洪水、DDoS 攻击、基础设施意外关闭,或者更进一步,所谓的模糊或猴子测试。
创建包含大量压力测试的复杂测试框架实际上超出了大多数项目的范围。然而,对于企业项目来说,包含一些与使用环境相匹配的合理压力测试是有意义的。
实现测试
在动机、需求和不同范围之后,让我们更深入地了解一下如何在 Java 企业项目中编写测试用例。
单元测试
单元测试验证应用程序各个单元的行为。在 Java EE 应用程序中,这通常涉及单个实体、边界和控制类。
为了对单个类进行单元测试,不需要详尽的测试用例。理想情况下,实例化测试对象并设置最小依赖关系应该足以调用和验证其业务功能。
现代 Java EE 支持这种做法。Java EE 组件,如 EJB 以及 CDI 管理豆,可以通过简单地实例化类以直接方式测试。正如我们之前看到的,现代企业组件代表普通的 Java 对象,包括注解,而不需要扩展或实现技术驱动的超类或接口,即所谓的无接口视图。
这允许测试实例化 EJB 或 CDI 类,并按需将它们连接起来。用于测试案例无关的代理,如注入的控制,被模拟掉。通过这样做,我们定义了测试用例的边界,应该测试什么,以及什么是不相关的。模拟的代理使能够验证测试对象的交互。
模拟对象模拟其实例类型的实际行为。在模拟对象上调用方法通常只会返回虚拟或模拟值。测试对象不知道它们正在与模拟对象通信。模拟对象的行为以及调用方法的验证都在测试场景内进行控制。
实现
让我们从对 Java EE 核心组件的单元测试开始。CarManufacturer 边界执行某些业务逻辑并调用 CarFactory 代理控制:
@Stateless
public class CarManufacturer {
@Inject
CarFactory carFactory;
@PersistenceContext
EntityManager entityManager;
public Car manufactureCar(Specification spec) {
Car car = carFactory.createCar(spec);
entityManager.merge(car);
return car;
}
}
由于 EJB 边界是一个普通的 Java 类,它可以在单元测试中实例化和设置。最常用的 Java 单元测试技术是 JUnit 与 Mockito 用于模拟。以下代码片段展示了汽车制造商测试用例,实例化边界测试对象并使用 Mockito 模拟使用的代理:
import org.junit.Before;
import org.junit.Test;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.*;
public class CarManufacturerTest {
private CarManufacturer testObject;
@Before
public void setUp() {
testObject = new CarManufacturer();
testObject.carFactory = mock(CarFactory.class);
testObject.entityManager = mock(EntityManager.class);
}
@Test
public void test() {
Specification spec = ...
Car car = ...
when(testObject.entityManager.merge(any())).then(a -> a.getArgument(0));
when(testObject.carFactory.createCar(any())).thenReturn(car);
assertThat(testObject.manufactureCar(spec)).isEqualTo(car);
verify(testObject.carFactory).createCar(spec);
verify(testObject.entityManager).merge(car);
}
}
JUnit 框架在测试执行期间一次性实例化 CarManufacturerTest 测试类。
@Before 方法,这里的 setUp(),在每次 @Test 方法运行之前都会执行。同样,被 @After 注解的方法在每次测试运行之后执行。然而,@BeforeClass 和 @AfterClass 方法,分别在测试类执行前和执行后只执行一次。
Mockito 方法,如 mock()、when() 或 verify(),分别用于创建、设置和验证模拟行为。模拟对象被指示以某种方式行为。测试执行后,它们可以验证是否对其调用了某些功能。
这确实是一个简单的例子,但它包含了单元测试核心组件的精髓。不需要进一步的自定义测试运行器,也不需要嵌入式容器来验证边界行为的边界。与自定义运行器相比,JUnit 框架可以以非常高的速率运行单元测试。在现代硬件上,数百个这样的例子将很快被执行。启动时间短,其余的只是 Java 代码执行,测试框架的额外开销很小。
一些读者可能已经注意到了 CarManufacturer 类的包私有可见性。这是为了提供更好的可测试性,以便能够在实例化类上设置代理。位于与边界相同包中的测试类能够修改其依赖项。然而,工程师可能会认为这违反了边界的封装。从理论上讲,他们是正确的,但一旦组件在企业容器中运行,任何调用者都无法修改引用。引用的对象不是实际的代理,而是一个代理,因此 CDI 实现可以防止误用。当然,可以使用反射或基于构造函数的注入来注入模拟对象。然而,字段注入与在测试用例中直接设置依赖项相结合提供了更好的可读性,同时保持了相同的生产行为。截至目前,许多企业项目已同意使用字段依赖注入和包私有可见性。
另一个讨论点是是否使用自定义 JUnit 运行器,如 MockitoJUnitRunner,与自定义模拟注解或如前所述的普通设置方法一起使用。以下代码片段展示了使用自定义运行器的更密集的示例:
import org.junit.runner.RunWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.MockitoJUnitRunner;
@RunWith(MockitoJUnitRunner.class)
public class CarManufacturerTest {
@InjectMocks
private CarManufacturer testObject;
@Mock
private CarFactory carFactory;
@Mock
private EntityManager entityManager;
@Test
public void test() {
...
when(carFactory.createCar(any())).thenReturn(car);
...
verify(carFactory).createCar(spec);
}
}
使用自定义 Mockito 运行器允许开发者用更少的代码配置测试,并在服务类中定义具有私有可见性的注入。使用如前所述的普通方法提供了更复杂的模拟场景的更多灵活性。然而,确实使用哪种方法来运行和定义 Mockito 模拟是一个口味问题。
参数化测试是 JUnit 的附加功能,用于定义在场景上相似但输入和输出数据不同的测试用例。manufactureCar()方法可以用各种输入数据进行测试,从而产生略有不同的结果。参数化测试用例使开发这些场景更加高效。以下代码片段显示了此类测试用例的示例:
import org.junit.runners.Parameterized;
@RunWith(Parameterized.class)
public class CarManufacturerMassTest {
private CarManufacturer testObject;
@Parameterized.Parameter(0)
public Color chassisColor;
@Parameterized.Parameter(1)
public EngineType engineType;
@Before
public void setUp() {
testObject = new CarManufacturer();
testObject.carFactory = mock(CarFactory.class);
...
}
@Test
public void test() {
// chassisColor & engineType
...
}
@Parameterized.Parameters(name = "chassis: {0}, engine type: {1}")
public static Collection<Object[]> testData() {
return Arrays.asList(
new Object[]{Color.RED, EngineType.DIESEL, ...},
new Object[]{Color.BLACK, EngineType.DIESEL, ...}
);
}
}
参数化测试类根据@Parameters测试数据方法中的数据实例化和执行。返回集合中的每个元素都会导致单独的测试执行。测试类填充其参数属性,并像往常一样继续文本执行。测试数据包含测试输入参数以及预期值。
这种参数化方法的好处是它允许开发者通过在testData()方法中添加另一行来简单地添加新的测试用例。前面的示例显示了使用模拟的参数化单元测试的组合。这种组合仅使用之前描述的纯 Mockito 方法,而不是使用MockitoJUnitRunner。
技术
这些示例使用 JUnit 4,在撰写本文时,它是使用最广泛的单元测试框架版本。Mockito 用于模拟对象,并为大多数用例提供了足够的灵活性。为了断言条件,这些示例使用AssertJ作为测试匹配库。它提供了使用生产方法链调用来验证对象状态的功能。
这些技术作为所需测试方面的示例。然而,这里的重点不是规定某些功能,而是展示针对这些测试要求的具体、合理的选择。提供类似功能和益处的其他技术同样值得推荐。
另一个广泛使用的技术典型示例是 Hamcrest 匹配器作为测试匹配库,或者较少使用的TestNG单元测试框架。
到你阅读这篇文章的时候,JUnit 版本 5 已经出现,它提供了一些额外的功能,特别是在动态测试方面。动态测试与参数化测试有类似的动机,通过程序化和动态地定义测试用例。
组件测试
组件测试验证一个连贯组件的行为。它们提供了比单元测试更多的集成,而不需要在模拟环境中运行应用程序。
动机
为了测试集成,需要验证由几个相互依赖的类表示的连贯功能的行为。组件测试应该尽可能快地运行,同时仍然隔离功能,即测试连贯单元。因此,它们旨在通过集成比单元测试更多的逻辑来提供快速的反馈。组件测试验证业务用例,从边界到涉及的控制。
由于大多数受管理的 Bean 使用相当直接的代理,因此可以进行代码级别的组件测试。注入的类型在大多数情况下可以直接解析,无需接口或限定符,甚至在没有嵌入式容器的情况下也可以实例化和注入。这使得组件测试可以使用与单元测试相同的测试框架来实现。所需的代理和模拟作为测试用例的一部分进行设置。我们想要展示的测试场景从业务用例的开始到注入的控制。
以下示例将检查如何使用一些基本的代码质量实践来实现组件测试,这些实践有助于编写可维护的测试。
实现
想象一下在之前“单元测试”部分中展示的整个制造汽车用例需要被测试。使用代理CarFactory创建一辆车,然后将其持久化到数据库中。测试持久化层超出了这个测试范围,因此实体管理器将被模拟。
以下代码片段展示了针对制造汽车用例的组件测试:
public class ManufactureCarTest {
private CarManufacturer carManufacturer;
@Before
public void setUp() {
carManufacturer = new CarManufacturer();
carManufacturer.carFactory = new CarFactory();
carManufacturer.entityManager = mock(EntityManager.class);
}
@Test
public void test() {
when(carManufacturer.entityManager.merge(any())).then(a -> a.getArgument(0));
Specification spec = ...
Car expected = ...
assertThat(carManufacturer.manufactureCar(spec)).isEqualTo(expected);
verify(carManufacturer.entityManager).merge(any(Car.class));
}
}
上述示例与之前的示例非常相似,唯一的区别是CarFactory被实例化,使用了实际的业务逻辑。代表测试用例边界的模拟验证了正确的行为。
然而,尽管这种方法对于简单的用例是有效的,但在更复杂、更接近现实世界的场景中,它有些天真。测试用例的边界正如在测试类中看到的那样,对于CarFactory代理来说,要自给自足且不注入更多的控制。当然,所有作为组件测试一部分的相互依赖的单位都可以定义代理。根据测试的性质和用例,这些嵌套代理也需要被实例化或模拟。
这最终会导致在设置测试用例时需要付出很多努力。我们可以利用测试框架功能,如 Mockito 注解在这里。这样做,测试用例会注入所有涉及测试用例的类。开发者指定其中哪些将被实例化或模拟。Mockito 提供了解析引用的功能,这对于大多数用例来说是足够的。
以下代码片段展示了类似场景的组件测试,这次使用了一个具有嵌套依赖项AssemblyLine和Automation的CarFactory代理。这些在测试用例中被模拟:
@RunWith(MockitoJUnitRunner.class)
public class ManufactureCarTest {
@InjectMocks
private CarManufacturer carManufacturer;
@InjectMocks
private CarFactory carFactory;
@Mock
private EntityManager entityManager;
@Mock
private AssemblyLine assemblyLine;
@Mock
private Automation automation;
@Before
public void setUp() {
carManufacturer.carFactory = carFactory;
// setup required mock behavior such as ...
when(assemblyLine.assemble()).thenReturn(...);
}
@Test
public void test() {
Specification spec = ...
Car expected = ...
assertThat(carManufacturer.manufactureCar(spec)).isEqualTo(expected);
verify(carManufacturer.entityManager).merge(any(Car.class));
}
}
Mockito 的@InjectMocks功能尝试使用在测试用例中注入为@Mock的模拟对象解析对象引用。这些引用是通过反射设置的。如果边界或控制定义了新的代理,它们至少需要在测试用例中定义为@Mock对象,以防止NullPointerException。然而,这种方法只部分改善了情况,因为它导致测试类中定义了大量的依赖项。
一个随着组件测试数量不断增长的企事业项目,如果仅采用这种方法,将会引入大量的冗余和重复。
为了使测试代码更简洁并限制这种重复,我们可以为特定的用例场景引入一个测试超类。这个超类将包含所有@Mock和@InjectMock定义,设置所需的依赖、委派和模拟。然而,这样的测试超类也包含大量的隐式逻辑,这些委派在扩展的测试用例中被定义和使用。这种方法导致测试用例与常用的超类紧密耦合,最终导致测试用例的隐式耦合。
委派测试组件
使用委派而不是扩展更为可取。
依赖于所使用组件的模拟和验证逻辑被委派到单独的测试对象中。因此,委派封装并单独管理这种逻辑。
以下代码片段展示了使用定义汽车制造和汽车工厂依赖的组件的测试用例:
public class ManufactureCarTest {
private CarManufacturerComponent carManufacturer;
private CarFactoryComponent carFactory;
@Before
public void setUp() {
carFactory = new CarFactoryComponent();
carManufacturer = new CarManufacturerComponent(carFactory);
}
@Test
public void test() {
Specification spec = ...
Car expected = ...
assertThat(carManufacturer.manufactureCar(spec)).isEqualTo(expected);
carManufacturer.verifyManufacture(expected);
carFactory.verifyCarCreation(spec);
}
}
组件测试依赖指定了声明的依赖和模拟,包括测试用例的设置和验证行为。其理念是定义可在多个组件测试中复用的组件,连接相似逻辑。
以下代码片段展示了CarManufacturerComponent的定义:
public class CarManufacturerComponent extends CarManufacturer {
public CarManufacturerComponent(CarFactoryComponent carFactoryComponent) {
entityManager = mock(EntityManager.class);
carFactory = carFactoryComponent;
}
public void verifyManufacture(Car car) {
verify(entityManager).merge(car);
}
}
该类位于与CarManufacturer类相同的包中,但在测试源下。它可以继承边界以添加模拟和验证逻辑。在这个例子中,它依赖于CarFactory组件,该组件还提供了额外的测试逻辑:
public class CarFactoryComponent extends CarFactory {
public CarFactoryComponent() {
automation = mock(Automation.class);
assemblyLine = mock(AssemblyLine.class);
when(automation.isAutomated()).thenReturn(true);
}
public void verifyCarCreation(Specification spec) {
verify(assemblyLine).assemble(spec);
verify(automation).isAutomated();
}
}
这些组件作为可复用的测试对象,连接特定的依赖并配置模拟行为。它们可以在多个组件测试中复用,并在不影响使用的情况下进行增强。
这些示例旨在提供一个想法,以便编写可维护的测试。对于被复用的组件,应考虑更多的重构方法,例如,使用类似于构建器模式的配置来满足不同情况。本章中关于维护测试数据和场景的部分包含了更多关于如何编写可维护的测试代码的信息。
组件测试的优点是它们运行速度与单元测试相当,但又能验证更复杂的集成逻辑。复杂的逻辑通过委派和封装来解决,从而提高可维护性。设置所需的代码和开销有限。
使用组件测试来验证一致的业务逻辑是有意义的。用例调用在业务级别进行测试,而技术低级方面则通过模拟来处理。
技术
这些示例再次展示了纯 JUnit 和 Mockito 测试方法。通过一些代码质量实践,当然可以写出可维护的、密集的测试用例,且配置开销有限。
如前所述实现的组件测试是连接使用简单依赖注入的组件的实用方法。如果生产代码使用了 CDI 生产者和限定符,测试组件的注入逻辑将相应地改变。
组件测试旨在验证一致单元的业务用例行为。它们通常不验证技术连接。建议使用集成测试来验证 CDI 注入是否正确使用,例如,在自定义限定符、生产者或作用域方面。
然而,有一些测试技术可以为测试用例提供依赖注入。这些技术的例子包括CDI-Unit或更复杂的Aquillian 测试框架。使用这些框架的测试用例在容器中运行,无论是嵌入式还是远程,并且能够进一步验证组件的集成。
复杂的测试框架确实提供了更接近企业应用程序的测试用例,但也带来了应用程序启动缓慢的挑战。容器通常在每个测试用例中执行和配置,通常需要几百毫秒或更多时间。这听起来并不多,但随着测试数量的增加,很快就会累积起来。
因此,对于旨在仅验证业务行为的组件测试,更快速、轻量级的方法,如所展示的方法,是更可取的。由于它们的快速特性,组件测试以及单元测试默认情况下在项目构建期间执行。它们应该是验证应用程序业务逻辑的默认方式。
下面的示例展示了使用模拟容器的代码级集成测试。
集成测试
组件测试通过隔离和快速测试验证一致的业务逻辑。这些测试不涵盖复杂的 Java EE 集成行为,如注入、自定义限定符、CDI 事件或作用域。
集成测试旨在验证企业系统内组件之间的技术协作。这包括诸如 Java EE 核心组件的配置、与外部系统的通信或持久性等问题。Java EE 组件是否正确注解?JSON-B 映射是否产生所需的 JSON 格式?JPA ORM 映射是否定义得当?
代码级集成测试背后的理念是通过验证正确的集成来提供更快的反馈,而无需构建和部署应用程序到测试环境。
嵌入式容器
由于单元测试技术不了解 Java EE 的具体细节,集成测试需要更复杂的测试功能,以容器形式存在。有几种技术可以启动嵌入式容器,并使应用程序的部分功能可用。
以下是一个例子:CDI-Unit。它提供了在 CDI 容器中运行测试用例的功能,进一步使开发者能够增强和修改其配置。CDI-Unit 扫描测试对象的依赖项并相应地配置它们。所需的模拟和特定的测试行为以声明性方式定义。在测试用例中设置了一个管理 Bean,例如汽车制造商边界,包括所有必需的依赖项和模拟。
此方法可以检测配置错误,例如缺少 CDI 注解。以下代码片段展示了与之前的组件测试类似的汽车制造测试,它实例化了边界:
import org.jglue.cdiunit.CdiRunner;
@RunWith(CdiRunner.class)
public class ManufactureCarIT {
@Inject
CarManufacturer carManufacturer;
@Mock
EntityManager entityManager;
@Before
public void setUp() {
carManufacturer.entityManager = entityManager;
}
@Test
public void test() {
Specification spec = ...
Car expected = ...
assertThat(carManufacturer.manufactureCar(spec)).isEqualTo(expected);
verify(entityManager).merge(expected);
}
}
自定义 JUnit 运行器检测注入到测试用例中的 Bean,并相应地解析它们。由于 CDI-Unit 只支持 CDI 标准而不支持完整的 Java EE API,测试明确模拟并设置实体管理器。所有其他使用的控制,如汽车工厂、自动化和装配线,都实例化并注入。
CDI-Unit 测试可以增强以服务于更复杂的场景。可以生成在测试范围内使用的 Bean。
然而,这项技术肯定有其局限性。CDI-Unit 有助于快速验证配置和管理 Bean 的协作。
另一种更复杂的应用程序测试技术是 Arquillian。Arquillian 将集成测试用例打包成可部署的存档,管理企业容器,无论是嵌入的还是远程的,并部署、执行和验证测试存档。它使得根据场景增强测试用例以自定义测试行为成为可能。
Arquillian 的优势在于它支持具有完整 Java EE 支持的容器。这使得集成测试可以在更接近生产环境的场景中运行。
以下代码片段展示了将汽车制造商边界部署到由 Arquillian 管理的嵌入式企业容器中的简单示例:
import org.jboss.arquillian.container.test.api.Deployment;
import org.jboss.arquillian.junit.Arquillian;
import org.jboss.shrinkwrap.api.ShrinkWrap;
import org.jboss.shrinkwrap.api.asset.EmptyAsset;
import org.jboss.shrinkwrap.api.spec.WebArchive;
@RunWith(Arquillian.class)
public class ManufactureCarIT {
@Inject
CarManufacturer carManufacturer;
@Deployment
public static WebArchive createDeployment() {
return ShrinkWrap.create(WebArchive.class)
.addClasses(CarManufacturer.class)
// ... add other required dependencies
.addAsWebInfResource(EmptyAsset.INSTANCE, "beans.xml");
}
@Test
public void test() {
Specification spec = ...
Car expected = ...
assertThat(carManufacturer.manufactureCar(spec)).isEqualTo(expected);
}
}
此测试用例将创建一个动态 Web 存档,包含边界和所需的代表,并将其部署到嵌入的容器中。测试本身可以注入并调用特定组件的方法。
容器不一定要以嵌入的方式运行,也可以是托管或远程容器。运行时间超过仅测试执行的容器避免了容器启动时间,并可以更快地执行测试。
执行这些集成测试将需要相对较长的时间,但操作更接近生产环境。在应用程序发货之前,将在开发过程中检测到配置不当的管理 Bean。通过包括位于测试范围内的自定义 Bean 定义,Arquillian 的灵活性和定制性使得实际的测试场景成为可能。
然而,这个例子只是略微触及了嵌入式容器测试的功能。可以使用 Arquillian 等测试框架来验证容器配置、通信、持久化和 UI 的集成。在本章的其余部分,我们将看到在模拟或嵌入式环境中运行的集成测试的不足之处。
嵌入式数据库
域实体持久化的映射通常使用 JPA 注解定义。在实际服务器部署之前验证此映射可以防止粗心大意造成的错误并节省时间。
为了验证正确的数据库映射,需要一个数据库。除了使用部署环境数据库实例外,嵌入式数据库提供了类似的验证和快速反馈。在 Arquillian 等框架上运行的嵌入式容器测试可以用来访问此功能。然而,对于基本验证,应用程序不需要在容器内运行。
JPA 带有在任意 Java SE 环境中独立运行的可能性。我们可以利用这一点来编写测试用例,将 JPA 配置连接到嵌入式或本地数据库。
想象一个在汽车制造厂制造和组装的汽车部件。汽车部件领域实体使用 JPA 映射如下:
@Entity
@Table(name = "car_parts")
public class CarPart {
@Id
@GeneratedValue
private long id;
@Basic(optional = false)
private String order;
@Enumerated(STRING)
@Basic(optional = false)
private PartType type;
...
}
为了验证正确的持久化,测试实体 Bean 至少应该被持久化并从数据库中重新加载。以下代码片段显示了一个设置独立 JPA 持久化的集成测试:
import javax.persistence.EntityTransaction;
import javax.persistence.Persistence;
public class CarPartIT {
private EntityManager entityManager;
private EntityTransaction transaction;
@Before
public void setUp() {
entityManager = Persistence.createEntityManagerFactory("it").createEntityManager();
transaction = entityManager.getTransaction();
}
@Test
public void test() {
transaction.begin();
CarPart part = new CarPart();
part.setOrder("123");
part.setType(PartType.CHASSIS);
entityManager.merge(part);
transaction.commit();
}
}
由于持久化是独立运行的,没有容器负责处理事务。测试用例以编程方式执行此操作,以及设置实体管理器,使用持久化单元it。持久化单元在测试范围的persistence.xml中配置。为此测试目的,配置一个本地事务性资源单元就足够了:
<?xml version="1.0" encoding="UTF-8"?>
<persistence version="2.2"
xsi:schemaLocation="http://xmlns.jcp.org/xml/ns/persistence
http://xmlns.jcp.org/xml/ns/persistence/persistence_2_2.xsd">
<persistence-unit name="it" transaction-type="RESOURCE_LOCAL">
<class>com.example.cars.entity.CarPart</class>
<exclude-unlisted-classes>true</exclude-unlisted-classes>
<properties>
<property name="javax.persistence.jdbc.url" value="jdbc:derby:./it;create=true"/>
<property name="javax.persistence.jdbc.driver" value="org.apache.derby.jdbc.EmbeddedDriver"/>
<property name="javax.persistence.schema-generation.database.action" value="drop-and-create"/>
</properties>
</persistence-unit>
</persistence>
涉及的实体类,如CarPart,必须明确指定,因为没有容器负责处理注解扫描。JDBC 配置指向一个嵌入式数据库,在这种情况下是Apache Derby。
企业项目不包括 Java EE 实现,只包括 API。因此,添加了一个 JPA 实现,如EclipseLink,以及 Derby 数据库作为测试依赖项。
此集成测试通过在本地验证持久化映射,为配置不匹配和粗心大意造成的错误提供更快的反馈。例如,所示测试用例会失败,因为CarPart类型的order属性无法映射,因为order是保留的 SQL 关键字。解决方案是更改列映射,例如,通过使用@Column(name = "part_order")重命名列。
这是在配置持久化时开发者常犯的错误的一个典型例子。防止这些错误(否则在部署时间之前不会被发现),可以提供更快的反馈并节省时间和精力。
当然,这种方法不会发现所有数据库相关的集成不匹配。没有使用容器,并且持久化错误,例如与并发事务相关的错误,在完全的系统测试执行之前不会被发现。尽管如此,它仍然在管道中提供了一个有用的初步验证。
运行集成测试
仔细阅读的读者可能已经注意到了集成测试的命名惯例,以 IT 结尾表示集成测试。这种命名源于 Maven 的命名惯例,不包括不匹配 Test 命名模式的测试类,在 test 阶段。以 IT 结尾的类将由不同的生命周期插件运行。
这种方法支持开发者构建有效的开发流程,因为代码级别的集成测试不一定要在第一次构建步骤中运行,这取决于它们所需的时间。以 Maven 为例,Failsafe 插件在项目构建完成后运行集成测试,使用命令 mvn failsafe:integration-test failsafe:verify。
当然,IDE 支持运行以 Test 命名的测试以及其他命名约定。
Gradle 并不考虑这种命名结构。为了达到相同的目标,Gradle 项目会使用多组分别执行的测试源代码。
代码级别集成测试与系统测试
代码级别的测试,如单元、组件或集成测试,在开发过程中提供快速反馈。它们使开发者能够验证隔离组件的业务逻辑是否按预期工作,以及整体配置是否合理。
集成测试的缺点
然而,为了验证应用程序的生产行为,这些测试是不够的。在技术、配置或运行时可能会有差异,最终导致测试用例存在差距。例如,不同版本的 enterprise 容器、整个应用程序部署后配置不匹配、不同的数据库实现或 JSON 序列化差异。
最终,应用程序在生产环境中运行。在相当于生产环境的环境中验证行为是非常有意义的。
当然,建议构建几个测试范围,以便既有具有隔离范围的测试和更快的反馈,也有集成测试。代码级别集成测试的缺点是它们通常需要花费大量时间。
在我过去的项目中,运行容器(如 Arquillian)的集成测试通常负责大部分构建时间,导致构建时间长达 10 分钟以上。这极大地减缓了持续交付管道,导致反馈慢和构建次数减少。为了解决这一不足,尝试在 Arquillian 测试中使用远程或管理的容器。它们将具有比测试运行更长的生命周期,并消除启动时间。
代码层面的集成测试是快速验证应用配置的有用方法,这是单元测试或组件测试无法测试的内容。它们不适合测试业务逻辑。
在模拟环境(如嵌入式容器)上部署整个应用的集成测试提供了一定的价值,但不足以验证生产行为,因为它们并不等同于生产环境。无论在代码层面还是模拟环境中,集成测试往往会减慢整体流程。
系统测试的不足
系统测试以端到端的方式测试部署到类似生产环境中的应用程序,提供了最具代表性的验证。由于它们在持续交付管道的较晚阶段运行,因此提供了较慢的反馈。例如,验证 HTTP 端点的 JSON 映射的测试用例在提供反馈给工程师之前会花费更长的时间。
应对和维护复杂的测试场景是一个需要相当多的时间和精力的方面。企业应用程序需要定义和维护测试数据和配置,尤其是在涉及许多外部系统的情况下。例如,验证在汽车制造应用程序中创建汽车的端到端测试需要设置外部关注点,如装配线以及测试数据。管理这些场景涉及一定的努力。
端到端测试试图使用与生产环境类似的外部系统和数据库。这引入了处理不可用或错误环境的挑战。不可用的外部系统或数据库会导致测试失败;然而,应用程序对此测试失败并不负责。这种情况违反了可预测性的要求,即测试不应依赖于提供假阳性的外部因素。因此,建议系统测试模拟测试中不属于测试应用程序的外部系统。这样做可以构建可预测的端到端测试。子章节“系统测试”涵盖了如何实现这种方法。
结论
代码层面的单元和组件测试验证了隔离的、特定的业务逻辑。它们提供即时反馈,并防止粗心大意造成的错误。特别是组件测试涵盖了与业务相关的软件单元的集成。
组件测试的界定在于它们在没有模拟容器的情况下运行,以编程方式设置测试用例。集成测试依赖于控制反转,类似于涉及较少开发者努力的连接组件的应用容器。然而,使用单元测试技术以编程方式构建可维护的测试用例最终会导致更有效的测试。在本章的“维护测试数据和场景”部分,我们将看到哪些方法支持我们构建高效的测试用例。
集成测试验证应用程序组件的技术集成以及配置。它们的反馈速度肯定比将应用程序作为管道的一部分部署要快。然而,与生产环境相比,集成测试提供的验证并不充分。
它们非常适合提供对常见错误和粗心大意的初步基本屏障。由于启动集成测试通常需要相当长的时间,因此运行有限数量的测试是非常有意义的。理想情况下,测试框架如 Arquillian 部署到管理或远程容器中,这些容器在单个测试用例之外继续运行。
系统测试以最类似生产的方式验证应用程序的行为。它们提供最终的反馈,即整个企业应用程序是否按预期工作,包括业务和技术方面。为了构建可预测的测试场景,考虑外部因素,如数据库和外部系统,非常重要。
编制测试用例,尤其是复杂的测试场景,需要花费大量的时间和精力。问题是应该在何处投入这种努力最有意义?为了测试业务逻辑,特别是协调一致的组件,建议使用组件测试。集成测试不能提供最终的验证,但仍然需要一定的时间和精力。使用少数几个集成测试以快速获得集成反馈是有意义的,但不应用于测试业务逻辑。开发者还可以找到在多个测试范围内重用创建的场景的方法,例如集成测试和系统测试。
总体目标应该是最大限度地减少创建和维护测试用例所需的时间和精力,最大限度地减少整体管道执行时间,并最大限度地提高应用程序验证覆盖率。
系统测试
系统测试是对已部署的企业应用程序进行的。该应用程序包含与生产环境中相同的代码、配置和运行时。测试用例使用外部通信,例如 HTTP,来启动用例。它们验证整体结果,例如 HTTP 响应、数据库状态或与外部系统的通信,是否符合应用程序的预期。
系统测试回答了“测试什么”的问题:与生产环境中运行方式相同的应用程序,排除外部关注点,通过其常规接口访问。外部关注点将被模拟,确保测试的可预测性,并使通信验证成为可能。这取决于场景,所使用的数据库是否被视为应用程序的一部分并按类似方式使用,或者也被模拟。
管理测试场景
系统测试场景可能会变得相当复杂,涉及多个关注点,并模糊了实际要测试的实际用例。
为了管理场景的复杂性,首先在不编写实际代码的情况下制定测试用例的流程是有意义的。在注释中定义所需的步骤,或者在纸上先定义,可以很好地概述测试场景的内容。在之后根据合理的抽象层实现实际的测试用例,将导致更易于维护的测试用例,并可能具有可重用的功能。我们将在本子章节的后面通过一个示例来介绍这种方法。
考虑测试数据是很重要的。一个场景承担的责任越多,定义和维护测试数据的复杂度就越高。对在测试用例中常用到的测试数据功能投入一些努力是有意义的。根据应用的性质和其领域,甚至可能有必要为这个定义一个特定的工程师角色。提供可重用的、有效使用的功能可以提供一些缓解;然而,仍然可能至少需要定义和记录常见的测试数据和场景。
最终,忽视测试数据的复杂性是没有帮助的。如果应用领域确实包括复杂的场景,通过省略某些测试用例或推迟测试场景到生产来忽略这种情况,并不能提高应用程序的质量。
为了制作可预测的隔离测试用例,场景应该尽可能无状态和自给自足。测试用例应该有一个类似于生产的起点,而不是依赖于系统的某种状态。它们应该考虑同时运行的其他潜在测试和用途。
例如,创建一辆新车不应假设现有汽车的数量。测试用例不应验证在创建之前所有汽车的列表是否为空,或者之后它只包含创建的汽车。它更验证的是创建的汽车是否是列表的一部分。
同样的原因,应避免系统测试对环境生命周期产生影响。在涉及外部系统的情况下,有必要控制模拟系统的行为。如果可能,这些情况应限制在最小范围内,以便能够并行执行其他场景。
模拟外部关注点
系统测试场景以与生产相同的方式使用外部系统。然而,类似于单元测试和组件测试中的模拟对象,系统测试模拟和模拟外部系统。通过这种方式,消除了应用不负责的潜在问题。系统测试在专用环境中运行,例如由容器编排框架提供。测试对象是唯一的应用程序,以与生产相同的方式部署、执行和配置。
模拟的外部系统配置为在应用程序访问时提供预期的行为。类似于模拟对象,它们根据用例验证正确的通信。
对于大多数用例,使用的数据库不会进行模拟。如果需要,测试场景可以管理并填充数据库内容,作为测试生命周期的部分。
容器编排通过将系统抽象为服务来强烈支持这些努力。Pod 镜像可以被其他实现替换,而不会影响被测试的应用程序。模拟的服务可以在运行中的系统测试中访问和配置,定义行为和外部测试数据。

点线说明了作为测试场景一部分的模拟系统的控制和验证。运行中的应用程序将像往常一样使用外部服务,不同之处在于这个服务实际上是由模拟支持的。
设计系统测试
系统测试作为持续交付管道中的一个步骤运行。它们连接到测试环境中的运行应用程序,调用业务用例,并验证整体结果。
系统测试案例通常不会影响应用程序的生命周期。应用程序作为 CD 管道的一部分提前部署。如果需要,系统测试可以控制外部模拟的状态和行为以及数据库的内容。
通常来说,将系统测试作为独立的构建项目开发是有意义的,没有任何代码依赖项。由于系统测试从外部访问应用程序,因此不应影响系统的使用方式。系统测试针对应用程序的端点合同进行开发。同样,系统测试不应使用应用程序的一部分类或功能,例如使用应用程序的 JSON 映射类。将技术和系统访问定义为独立的构建项目,可以防止由现有功能引起的不希望出现的副作用。系统测试项目可以位于应用程序项目旁边的同一存储库中。
以下示例将从自顶向下的方法构建系统测试,定义测试场景和适当的抽象层。
汽车制造应用程序的业务用例通过 HTTP 访问。它们涉及外部系统和数据库访问。为了验证汽车的创建,系统测试将连接到运行中的应用程序,就像现实世界的用例一样。
为了管理测试场景,首先使用带有注释作为占位符的逻辑步骤来构建案例,然后在这些抽象层中实现:
public class CarCreationTest {
@Test
public void testCarCreation() {
// verify car 1234 is not included in list of cars
// create car
// with ID 1234,
// diesel engine
// and red color
// verify car 1234 has
// diesel engine
// and red color
// verify car 1234 is included in list of cars
// verify assembly line instruction for car 1234
}
}
这些注释代表了在测试创建汽车时执行和验证的逻辑步骤。它们与业务相关,而不是与技术实现相关。
我们将这些注释实现为私有方法,或者更好的是,拥有自己的代理。代理封装了技术问题以及潜在的生命周期行为:
我们定义了CarManufacturer和AssemblyLine代理,它们抽象了应用程序和代理的访问和行为。它们作为系统测试的一部分定义,与应用程序代码中具有相同名称的管理豆没有关系。系统测试项目代码是独立定义的。它也可以使用不同的技术实现,只要依赖于应用程序的通信接口。
下面的代码片段展示了代理的集成。汽车创建系统测试仅包含与实现相关的业务逻辑,代理实现实际的调用。这利用了可读性和可维护性良好的测试用例。类似的系统测试将重用代理功能:
import javax.ws.rs.core.GenericType;
public class CarCreationTest {
private CarManufacturer carManufacturer;
private AssemblyLine assemblyLine;
@Before
public void setUp() {
carManufacturer = new CarManufacturer();
assemblyLine = new AssemblyLine();
carManufacturer.verifyRunning();
assemblyLine.initBehavior();
}
@Test
public void testCarCreation() {
String id = "X123A345";
EngineType engine = EngineType.DIESEL;
Color color = Color.RED;
verifyCarNotExistent(id);
String carId = carManufacturer.createCar(id, engine, color);
assertThat(carId).isEqualTo(id);
verifyCar(id, engine, color);
verifyCarExistent(id);
assemblyLine.verifyInstructions(id);
}
private void verifyCarExistent(String id) {
List<Car> cars = carManufacturer.getCarList();
if (cars.stream().noneMatch(c -> c.getId().equals(id)))
fail("Car with ID '" + id + "' not existent");
}
private void verifyCarNotExistent(String id) {
List<Car> cars = carManufacturer.getCarList();
if (cars.stream().anyMatch(c -> c.getId().equals(id)))
fail("Car with ID '" + id + "' existed before");
}
private void verifyCar(String carId, EngineType engine, Color color) {
Car car = carManufacturer.getCar(carId);
assertThat(car.getEngine()).isEqualTo(engine);
assertThat(car.getColor()).isEqualTo(color);
}
}
这是一个应用程序系统测试的基本示例。例如CarManufacturer这样的代理处理低级通信和验证:
public class CarManufacturer {
private static final int STARTUP_TIMEOUT = 30;
private static final String CARS_URI = "http://test.car-manufacture.example.com/" +
"car-manufacture/resources/cars";
private WebTarget carsTarget;
private Client client;
public CarManufacturer() {
client = ClientBuilder.newClient();
carsTarget = client.target(URI.create(CARS_URI));
}
public void verifyRunning() {
long timeout = System.currentTimeMillis() + STARTUP_TIMEOUT * 1000;
while (!isSuccessful(carsTarget.request().head())) {
// waiting until STARTUP_TIMEOUT, then fail
...
}
}
private boolean isSuccessful(Response response) {
return response.getStatusInfo().getFamily() == Response.Status.Family.SUCCESSFUL;
}
public Car getCar(String carId) {
Response response = carsTarget.path(carId).request(APPLICATION_JSON_TYPE).get();
assertStatus(response, Response.Status.OK);
return response.readEntity(Car.class);
}
public List<Car> getCarList() {
Response response = carsTarget.request(APPLICATION_JSON_TYPE).get();
assertStatus(response, Response.Status.OK);
return response.readEntity(new GenericType<List<Car>>() {
});
}
public String createCar(String id, EngineType engine, Color color) {
JsonObject json = Json.createObjectBuilder()
.add("identifier", id)
.add("engine-type", engine.name())
.add("color", color.name());
Response response = carsTarget.request()
.post(Entity.json(json));
assertStatus(response, Response.Status.CREATED);
return extractId(response.getLocation());
}
private void assertStatus(Response response, Response.Status expectedStatus) {
assertThat(response.getStatus()).isEqualTo(expectedStatus.getStatusCode());
}
...
}
测试代理配置在汽车制造测试环境中。此配置可以通过 Java 系统属性或环境变量进行配置,以便使测试可重用于多个环境。
如果代理需要连接到测试用例的生命周期,它可以定义为 JUnit 4 规则或 JUnit 5 扩展模型。
此示例通过 HTTP 连接到一个正在运行的汽车制造应用程序。它可以创建和读取汽车,映射和验证响应。读者可能已经注意到,代理封装了通信内部细节,例如 HTTP URL、状态码或 JSON 映射。其公共接口仅包含与测试场景的业务域相关的类,例如Car或EngineType。系统测试中使用的域实体类型不必与在应用程序中定义的类型相匹配。出于简单起见,系统测试可以使用不同、更简单的类型,这些类型对于给定的场景是足够的。
部署和控制外部模拟
我们刚刚看到了如何将系统测试连接到一个正在运行的企业应用程序。但我们如何控制和管理应用程序用例内部使用的外部系统呢?
可以使用模拟服务器技术,如WireMock,来模拟外部系统。WireMock 作为一个独立的 Web 服务器运行,配置为相应地回答特定请求。它像一个代码级别的测试模拟对象,模拟并验证行为。
使用容器编排框架进行系统测试的好处是,服务可以很容易地被模拟服务器所替代。系统测试环境的外部系统基础设施作为代码配置可以包含一个 WireMock Docker 镜像,它将代替实际系统执行。
以下代码片段展示了用于组装线系统的示例 Kubernetes 配置,使用运行中的 Pod 中的 WireMock Docker 镜像:
---
kind: Service
apiVersion: v1
metadata:
name: assembly-line
namespace: systemtest
spec:
selector:
app: assembly-line
ports:
- port: 8080
---
kind: Deployment
apiVersion: apps/v1beta1
metadata:
name: assembly-line
namespace: systemtest
spec:
replicas: 1
template:
metadata:
labels:
app: assembly-line
spec:
containers:
- name: assembly-line
image: docker.example.com/wiremock:2.6
restartPolicy: Always
---
系统测试连接到该服务,使用管理 URL 来设置和修改模拟服务器的行为。
以下代码片段展示了使用 WireMock API 控制服务的AssemblyLine测试代表的实现:
import static com.github.tomakehurst.wiremock.client.ResponseDefinitionBuilder.okForJson;
import static com.github.tomakehurst.wiremock.client.WireMock.*;
import static java.util.Collections.singletonMap;
public class AssemblyLine {
public void initBehavior() {
configureFor("http://test.assembly.example.com", 80);
resetAllRequests();
stubFor(get(urlPathMatching("/assembly-line/processes/[0-9A-Z]+"))
.willReturn(okForJson(singletonMap("status", "IN_PROGRESS"))));
stubFor(post(urlPathMatching("/assembly-line/processes"))
.willReturn(status(202)));
}
public void verifyInstructions(String id) {
verify(postRequestedFor(urlEqualTo("/assembly-line/processes/" + id))
.withRequestBody(carProcessBody()));
}
...
}
初始行为指示 WireMock 实例适当地响应 HTTP 请求。在测试用例期间,如果需要表示更复杂的过程和对话,行为也可以被修改。
如果更复杂的测试场景涉及异步通信,如长时间运行的过程,测试用例可以使用轮询等待验证。
定义好的汽车制造商和组装线代表可以在多个测试场景中重复使用。某些情况可能需要互斥地运行系统测试。
在“维护测试数据和场景”部分,我们将看到哪些进一步的方法和途径支持开发者编写可维护的测试用例。
性能测试
性能测试验证系统在响应性方面的非功能性需求。它们不验证业务逻辑,而是验证应用程序的技术、实现和配置。
在生产系统中,系统的负载可能会有很大的变化。这对于公开可用的应用程序来说尤其如此。
动机
与验证业务行为的测试类似,测试应用程序或其组件是否可能满足生产中的性能预期是有帮助的。动机是防止由于引入的错误而导致的性能大幅下降。
在构建性能测试场景时,考虑应用程序逻辑是很重要的。某些调用比其他调用执行更昂贵的进程。通常,在考虑请求的频率和性质时,在现实的生产场景之后构建性能测试是有意义的。
例如,浏览在线商店的访客与实际执行购买交易的客户之间的比例应该以某种方式反映现实世界。
然而,构建执行昂贵调用测试也是合理的,以检测系统在压力下可能出现的问题。
在第九章“监控、性能和日志记录”中,我们将了解为什么在生产环境之外进行性能测试是探索应用程序极限和潜在瓶颈的糟糕工具。与其投入大量精力来构建复杂的性能测试场景,不如将精力投入到对生产系统技术洞察的投资中。
然而,我们仍将看到一些如何构建简单的负载测试的技术,这些测试将模拟应用程序的压力,以发现明显的问题。
一个合理的尝试是模拟正常负载,增加并发用户数量,并探索应用程序何时变得无响应。如果响应性比早期测试运行更快地下降,这可能会表明存在问题。
关键性能指标
关键性能指标提供了关于应用程序在正常行为以及模拟工作负载下的响应性的信息。有几种指标可以直接影响用户,例如响应时间或错误率。这些仪表代表了系统的状态,并将提供关于其在性能测试下行为的洞察。指示的值将根据并发用户数量以及测试场景而变化。
一个有趣的洞察是应用程序的响应时间——包括所有传输在内的响应客户端请求所需的时间。它直接影响所提供服务的质量。如果响应时间低于某个阈值,可能会发生超时,取消并失败请求。延迟时间是服务器接收到请求的第一个字节所需的时间。它主要取决于网络设置。
在性能测试期间,特别有趣的是观察响应时间和延迟时间与平均值的比较。当增加应用程序的负载时,在某个点上应用程序将变得无响应。这种无响应性可能源于各种原因。例如,可用的连接或线程可能已被消耗,可能会发生超时,或者数据库乐观锁定可能失败。请求错误率表示失败请求的比例。
在特定时间间隔内并发用户数量或负载大小会影响应用程序的性能指标,需要在测试结果中考虑。用户数量越多,系统所承受的压力就越大,这取决于请求的性质。这个数字与并发事务的数量相关,在这种情况下是技术事务,它表明应用程序一次可以处理多少事务。
CPU 和内存利用率提供了关于应用程序资源的洞察。虽然这些值并不一定说明应用程序的健康状况,但它们代表了负载模拟期间资源消耗的趋势。
同样,整体吞吐量表示服务器在任何时刻向连接用户传输的总数据量。
关键性能指标提供了关于应用程序响应性的见解。它们有助于在开发过程中积累经验,特别是趋势。这种经验可以用来验证未来的应用程序版本。特别是在技术、实现或配置更改后,性能测试可以指示潜在的性能影响。
开发性能测试
设计接近现实世界的性能测试场景是有意义的。性能测试技术应支持不仅能够增加大量用户,还能模拟用户行为的场景。典型的行为可能是,例如,用户访问主页,登录,点击链接到文章,将文章添加到购物车,并执行购买。
可用的性能测试技术有多种。在撰写本文时,最常用的可能是Gatling和Apache JMeter。
Apache JMeter 执行将应用程序置于负载下的测试场景,并从测试执行中生成报告。它使用基于 XML 的配置,支持多种或自定义通信协议,并且可以用于回放记录的负载测试场景。Apache JMeter 定义了包含所谓采样器和逻辑控制器的测试计划。它们用于定义模拟用户行为的测试场景。JMeter 是分布式的,使用主/从架构,可以从多个方向生成负载。它提供了一个图形用户界面,用于编辑测试计划配置。命令行工具在本地或持续集成服务器上执行测试。
Gatling 提供了一个类似性能测试解决方案,但它以 Scala 编写的程序方式定义测试场景。因此,它在定义测试场景、虚拟用户行为以及测试进展方面提供了很多灵活性。Gatling 还可以记录和重用用户行为。由于测试是程序定义的,因此有许多灵活的解决方案,例如从外部源动态提供案例。所谓的检查和断言用于验证单个测试请求或整个测试案例是否成功。
与 JMeter 不同,Gatling 在单个主机上运行,而不是分布式。
以下代码片段显示了简单 Gatling 模拟的 Scala 定义:
import io.gatling.core.Predef._
import io.gatling.core.structure.ScenarioBuilder
import io.gatling.http.Predef._
import io.gatling.http.protocol.HttpProtocolBuilder
import scala.concurrent.duration._
class CarCreationSimulation extends Simulation {
val httpConf: HttpProtocolBuilder = http
.baseURL("http://test.car-manufacture.example.com/car-manufacture/resources")
.acceptHeader("*/*")
val scn: ScenarioBuilder = scenario("create_car")
.exec(http("request_1")
.get("/cars"))
.exec(http("request_1")
.post("/cars")
.body(StringBody("""{"id": "X123A234", "color": "RED", "engine": "DIESEL"}""")).asJSON
.check(header("Location").saveAs("locationHeader")))
.exec(http("request_1")
.get("${locationHeader}"))
pause(1 second)
setUp(
scn.inject(rampUsersPerSec(10).to(20).during(10 seconds))
).protocols(httpConf)
.constantPauses
}
create_car场景涉及三个客户端请求,这些请求检索所有汽车,创建一辆汽车,并跟踪创建的资源。场景配置了多个虚拟用户。用户数量从10开始,在10秒运行时间内增加到20用户。
模拟通过命令行触发,并在运行环境中执行。Gatling 提供 HTML 文件格式的测试结果。以下代码片段显示了 Gatling 测试示例的 HTML 输出:

这个例子给出了使用 Gatling 测试可能实现的内容。
由于性能测试应该在一定程度上反映现实用户场景,因此使用现有系统测试场景进行性能测试是有意义的。除了程序定义用户行为外,预先录制的测试运行还可以用于从外部源(如 Web 服务器日志文件)输入数据。
见解
执行性能测试的目的与其说是关注绿色或红色的结果,不如说是从运行中获得见解。测试运行期间收集测试报告和应用程序的行为。这些收集使我们能够获得经验并发现性能的趋势。
虽然性能测试可以独立执行,但它们理想情况下应作为持续交付管道的一部分持续运行。在没有影响管道步骤结果的情况下获得这些见解已经很有帮助了。收集了一些指标后,工程师可以考虑如果测量的性能与通常的预期相比有大幅下降,则将性能运行定义为失败。
这与持续改进的理念相符,或者在这种情况下,避免响应性下降。
在本地运行测试
上一章介绍了开发工作流程和持续交付管道。对于现代企业应用程序来说,定义一个有效的管道至关重要。然而,尽管 CI 服务器负责所有构建、测试和部署步骤,软件工程师仍然需要在本地环境中进行构建和测试。
使用适当的测试的持续交付管道充分验证企业应用程序按预期工作。然而,仅依赖管道的不足之处在于工程师在将更改推送到中央存储库之后才会收到反馈。虽然这是持续集成的理念,但在提交更改之前,开发者仍然希望对他们的更改有确定性。
提交包含粗心大意的错误更改会通过不必要的破坏构建来打扰其他团队成员。通过在本地验证提交可以防止易于检测的错误。这当然可以在代码级别的测试中实现,例如单元测试、组件测试和集成测试,这些测试也在本地环境中运行。在提交之前执行代码级别的测试可以防止大多数错误。
当开发技术或横切关注点,如拦截器或 JAX-RS JSON 映射时,工程师在将更改提交到管道之前也希望得到反馈。如前所述,针对实际运行的应用程序进行的系统测试提供了最真实的验证。
对于本地环境,开发者可以编写复杂的集成测试,在嵌入式容器中运行,以获得更快的反馈。然而,正如我们之前所看到的,这需要相当多的时间和精力,并且仍然不能可靠地覆盖所有情况。
使用容器技术使工程师能够在多个环境中运行相同的软件镜像,包括本地环境。主要操作系统都有可用的 Docker 安装。本地机器可以像在生产环境中一样运行 Docker 容器,如果需要,可以设置自定义配置或连接自己的网络。
这使我们能够在本地环境中运行完整的系统测试。虽然这一步不一定需要在开发期间执行,但对于想要验证集成行为的开发者来说很有帮助。
开发者可以在本地执行构建和测试步骤,类似于持续交付管道。通过命令行运行步骤极大地简化了这种方法。Docker run 命令使我们能够根据本地主机动态配置卷、网络或环境变量。
为了自动化这个过程,将单独的构建、部署和测试命令组合到 shell 脚本中。
以下代码片段展示了 Bash 脚本执行多个步骤的一个示例。Bash 脚本也可以在 Windows 上通过 Unix 控制台模拟器运行:
#!/bin/bash
set -e
cd hello-cloud/
# build
mvn package
docker build -t hello-cloud .
# deploy
docker run -d \
--name hello-cloud-st \
-p 8080:8080 \
-v $(pwd)/config/local/application.properties:/opt/config/application.properties \
hello-cloud
# system tests
cd ../hello-cloud-st/
mvn test
# stopping environment
docker stop hello-cloud-st
*hello-cloud* 应用程序包含在 hello-cloud/ 子目录中,并使用 Maven 和 Docker 构建。Docker run 命令配置了一个自定义属性文件。这与第五章容器和云环境与 Java EE 中展示的编排配置示例类似。
hello-cloud-st/ 目录包含连接到运行中的应用程序的系统测试。为了将系统测试指向本地环境,可以调整本地机器的 hosts 配置。Maven 测试运行执行系统测试。
这种方法使开发者能够在执行在持续交付管道中以及必要时本地执行的完整系统测试中验证行为。
如果系统测试场景需要多个外部系统,它们将作为 Docker 容器同等运行,类似于测试环境。在容器编排环境中运行的应用程序使用逻辑服务名称来解析外部系统。对于作为自定义 Docker 网络一部分的原生运行 Docker 容器,这也是可能的。Docker 在相同网络中运行的容器中解析容器名称。
这种方法用于在本地运行各种服务,特别是运行模拟服务器特别有用。
以下代码片段展示了运行本地测试环境的思想示例:
#!/bin/bash
# previous steps omitted
docker run -d \
--name assembly-line \
-p 8181:8080 \
docker.example.com/wiremock:2.6
docker run -d \
--name car-manufacture-st \
-p 8080:8080 \
car-manufacture
# ...
与系统测试示例类似,WireMock 服务器将作为测试用例的一部分进行配置。本地环境需要确保主机名指向相应的 localhost 容器。
对于更复杂的设置,在容器编排集群中运行服务也是有意义的。Kubernetes 或 OpenShift 提供了本地安装选项。容器编排抽象化了集群节点。因此,对于基础设施即代码定义,集群是本地运行、作为单个节点、在本地服务器环境中还是在云中运行,都没有区别。
这使得工程师可以使用与测试环境相同的定义。运行本地的 Kubernetes 安装将 shell 脚本简化为几个kubectl命令。
如果在本地安装 Kubernetes 或 OpenShift 太大,可以使用 Docker Compose 等编排替代方案作为轻量级替代品。Docker Compose 还定义了多容器环境和它们的配置在基础设施即代码文件中 - 可通过单个命令执行。它提供了与 Kubernetes 相似的好处。Arquillian Cube 是另一种复杂的编排和运行 Docker 容器的方法。
通过脚本在本地自动化步骤,极大地提高了开发者的生产力。在本地机器上运行系统测试通过提供更快的反馈和更少的干扰来使工程师受益。
维护测试数据和场景
测试用例验证当应用程序部署到生产环境时将按预期行为。测试还确保在开发新功能时预期仍然得到满足。
然而,仅仅定义一次测试场景和测试数据是不够的。业务逻辑会随着时间的推移而发展和变化,测试用例需要适应。
可维护测试的重要性
对于编写和管理测试用例,创建可维护的测试代码至关重要。随着时间的推移,测试用例的数量会增加。为了在开发过程中保持生产力,需要在测试代码质量上投入一些时间和精力。
对于生产代码,每个工程师都同意代码质量是一个重要要求。由于测试不是运行在生产环境中的应用程序的一部分,它们通常被不同对待。经验表明,开发者很少在测试代码质量上投入时间和精力。然而,测试用例的质量对开发者的生产力有巨大影响。
有一些迹象表明测试编写得不好。
缺乏测试质量的迹象
通常来说,过多时间花在测试代码上而不是生产代码上,可能是设计或构建测试不佳的迹象。正在实施或更改的功能会导致一些测试失败。测试代码能有多快适应?需要更改多少测试数据或功能?向现有代码库中添加测试用例有多容易?
被忽略超过非常短时间的失败测试也是测试质量潜在缺陷的指标。如果测试用例在逻辑上仍然相关,它需要被稳定下来并修复。如果它变得过时,它应该被删除。然而,为了节省修复测试用例所需的时间和精力,不应该删除测试,因为当测试场景在逻辑上仍然相关时。
复制粘贴测试代码也应该是一个令人警觉的信号。这种做法在企业项目中相当普遍,尤其是在测试场景的行为略有不同时。复制粘贴违反了不要重复自己(DRY)原则,并引入了大量重复,使得未来的更改变得昂贵。
测试代码质量
虽然生产代码的质量对于保持恒定的开发速度很重要,但测试代码的质量也同样重要。然而,测试通常并没有得到同等的对待。经验表明,企业项目很少投入时间和精力去重构测试代码。
通常,对于高代码质量的做法适用于测试代码,就像它们适用于实时代码一样。某些原则对于测试尤为重要。
首先,DRY 原则当然很重要。在代码层面,这意味着避免重复定义、测试流程以及包含微小差异的代码重复。
对于测试数据,同样的原则也适用。经验表明,使用类似测试数据的多个测试用例场景会诱使开发者使用复制粘贴。然而,这样做一旦测试数据发生变化,就会导致代码库难以维护。
对于断言和模拟验证也是如此。逐个直接在测试方法中应用的断言语句和验证同样会导致重复和维护挑战。
通常,测试代码质量的最大问题是缺少抽象层。测试用例往往包含不同的方面和责任。它们将业务与技术问题混合在一起。
让我给出一个伪代码中编写不良的系统测试的例子:
@Test
public void testCarCreation() {
id = "X123A345"
engine = EngineType.DIESEL
color = Color.RED
// verify car X123A345 not existent
response = carsTarget.request().get()
assertThat(response.status).is(OK)
cars = response.readEntity(List<Car>)
if (cars.stream().anyMatch(c -> c.getId().equals(id)))
fail("Car with ID '" + id + "' existed before")
// create car X123A345
JsonObject json = Json.createObjectBuilder()
.add("identifier", id)
.add("engine-type", engine.name())
.add("color", color.name())
response = carsTarget.request().post(Entity.json(json))
assertThat(response.status).is(CREATED)
assertThat(response.header(LOCATION)).contains(id)
// verify car X123A345
response = carsTarget.path(id).request().get()
assertThat(response.status).is(OK)
car = response.readEntity(Car)
assertThat(car.engine).is(engine)
assertThat(car.color).is(color)
// verify car X123A345 existent
// ... similar invocations as before
if (cars.stream().noneMatch(c -> c.getId().equals(id)))
fail("Car with ID '" + id + "' not existent");
}
读者可能已经注意到,理解测试用例需要付出相当大的努力。内联注释提供了一些帮助,但这类注释通常只是代码结构不佳的标志。
然而,这个例子与之前精心制作的系统测试示例相似。
这些测试用例的挑战不仅在于它们更难理解。将多个关注点,无论是技术还是业务驱动的,混合到一个类中,或者甚至是一个方法中,会导致代码重复,并排除可维护性。如果汽车制造服务的有效负载发生变化怎么办?如果测试用例的逻辑流程发生变化怎么办?如果需要编写具有类似流程但不同数据的新的测试用例怎么办?开发者是否需要复制粘贴所有代码并修改少数几个方面?或者如果整体通信从 HTTP 变为其他协议怎么办?
对于测试用例,最重要的代码质量原则是应用适当的抽象层以及委托。
开发者需要问自己这个测试场景有哪些关注点。有测试逻辑流程,验证按照所需步骤创建一辆车。有通信部分,涉及 HTTP 调用和 JSON 映射。可能还涉及外部系统,可能表示为需要控制的模拟服务器。并且需要对这些不同方面进行断言和验证。
这就是为什么我们精心设计了之前的系统测试示例,其中包含多个组件,它们都涉及不同的责任。应该有一个组件用于访问正在测试的应用程序,包括所有所需的通信实现细节。在之前的示例中,这是汽车制造商委托的责任。
类似于装配线委托,为每个涉及的模拟系统添加一个组件是有意义的。这些组件封装了模拟服务器的配置、控制和验证行为。
在测试业务级别进行的验证也应建议外包,无论是私有方法还是委托,具体取决于情况。测试委托可以再次将逻辑封装到更多的抽象层中,如果技术或测试用例需要的话。
所有这些委托类和方法都成为单一的责任点。它们在所有类似的测试用例中都被重用。潜在的变化只会影响责任点,而不会影响测试用例的其他部分。
这需要定义组件之间的清晰接口,这些接口不会泄露实现细节。因此,对于系统测试范围来说,有一个专门的、简单的模型表示是有意义的。这个模型可以简单地直接实现,可能比生产代码具有更少的类型安全性。
一种合理的绿色田野方法,类似于之前的系统测试示例,是从编写注释开始,在向下进行抽象层时不断用代表者替换它们。这从测试逻辑上首先执行的内容开始,其次是实现细节。遵循这种方法自然地避免了业务和技术测试关注点的混合。它还使得支持编写测试技术的集成更简单,例如 Cucumber-JVM 或 FitNesse。
测试技术支持
一些测试技术也支持编写可维护的测试。例如,AssertJ 提供了创建自定义断言的可能性。在我们的测试用例中,汽车需要验证封装在汽车规范中的正确引擎和颜色。自定义断言可以减少测试范围内的整体重复。
以下是一个用于验证汽车的定制 AssertJ 断言示例:
import org.assertj.core.api.AbstractAssert;
public class CarAssert extends AbstractAssert<CarAssert, Car> {
public CarAssert(Car actual) {
super(actual, CarAssert.class);
}
public static CarAssert assertThat(Car actual) {
return new CarAssert(actual);
}
public CarAssert isEnvironmentalFriendly() {
isNotNull();
if (actual.getSpecification().getEngine() != EngineType.ELECTRIC) {
failWithMessage("Expected car with environmental friendly engine but was <%s>",
actual.getEngine());
}
return this;
}
public CarAssert satisfies(Specification spec) {
...
}
public CarAssert hasColor(Color color) {
isNotNull();
if (!Objects.equals(actual.getColor(), color)) {
failWithMessage("Expected car's color to be <%s> but was <%s>",
color, actual.getColor());
}
return this;
}
public CarAssert hasEngine(EngineType type) {
...
}
}
断言在测试范围内可以使用。必须选择正确的 CarAssert 类的静态导入以用于 assertThat() 方法:
assertThat(car)
.hasColor(Color.BLACK)
.isEnvironmentalFriendly();
本章中的示例展示了主要使用 Java、JUnit 和 Mockito 编写的测试,除了嵌入式应用程序容器和 Gatling。还有许多其他使用不同框架以及动态 JVM 语言的测试技术。
这个例子中有一个著名的例子是使用 Groovy 的 Spock 测试框架。这项技术的动机是编写更简洁、更易于维护的测试。由于 Groovy 或 Scala 等动态 JVM 语言比纯 Java 更简洁,这个想法听起来是合理的。
测试框架,如 Spock,确实可以产生需要最少代码的测试用例。它们利用动态 JVM 语言的功能,例如更宽松的方法名称,如 def "car X123A234 should be created"()。Spock 测试还提供了低成本的清晰可读性。
然而,如果关注测试代码质量,所有测试技术都可以编写可读的测试。特别是,可维护性更多的是一个关于精心设计的测试用例和适当的抽象层的问题,而不是使用的技术。一旦测试用例变得相当复杂,技术对可维护性的影响就变得不那么相关了。
在选择测试技术时,团队对该技术的熟悉程度也应考虑。在撰写本文时,企业级 Java 开发者通常对动态 JVM 语言不太熟悉。
然而,测试代码质量应该比使用的技术更重要。将软件工程的良好实践应用于测试应被视为强制性的,使用其他测试框架作为可选的。频繁重构测试用例可以增加测试组件的可维护性和可重用性,从而提高软件项目的质量。
摘要
测试需要在模拟环境中验证软件功能。软件测试应该可预测、隔离、可靠、快速,并以自动化的方式进行。为了使项目生命周期更加高效,保持测试的可维护性非常重要。
单元测试验证应用程序单个单元的行为,通常是单个实体、边界或控制类。组件测试验证一致组件的行为。集成测试满足验证 Java EE 组件交互的需求。数据库集成测试使用嵌入式数据库与独立的 JPA 一起验证持久化映射。系统测试验证在真实环境中运行的应用程序。容器编排强烈支持运行带有潜在模拟应用程序的系统测试环境。
在将功能推送到中央仓库之前验证其功能,工程师需要能够在本地环境中运行测试。包含粗心大意的错误更改会通过不必要的破坏构建来打扰其他团队成员。Docker、Docker Compose 和 Kubernetes 也可以在本地环境中运行,使开发者能够事先验证行为。建议编写简单的自动化脚本,包括所需的步骤。
为了实现恒定的开发速度,需要开发可维护的测试用例。一般来说,测试代码应该与生产代码具有相似的质量。这包括重构、适当的抽象层和软件质量。
这些方法实际上比使用动态 JVM 语言引入复杂的测试框架更有帮助。虽然像 Spock 这样的框架确实能够使测试用例易于阅读,但遵循软件工艺的正确实践对整体测试代码质量的影响更为积极,尤其是在测试场景变得复杂时。无论使用什么测试技术,软件工程师都应关注测试代码质量,以保持测试用例的可维护性。
以下章节将涵盖分布式系统和微服务架构的主题。
第八章:微服务和系统架构
前几章介绍了如何使用 Java EE 开发单一的企业应用程序。现代应用程序包含基础设施和配置定义作为代码,使得能够在自动化的方式下创建环境,无论是在本地还是在云平台上。持续交付管道与足够的自动化测试案例一起,使得能够以高质量和高效能交付企业应用程序。现代零依赖 Java EE 方法支持这些努力。
企业系统很少具有单一责任,这些责任可以合理地映射到单一的企业应用程序中。传统上,企业应用程序将商业的多个方面结合到单体应用程序中。问题是,这种构建分布式系统的方法是否可取。
本章将涵盖:
-
分布背后的动机
-
分布式系统的可能性和挑战
-
如何设计相互依赖的应用程序
-
应用程序边界、API 和文档
-
一致性、可扩展性、挑战和解决方案
-
事件溯源、事件驱动架构和 CQRS
-
微服务架构
-
Java EE 如何适应微服务世界
-
如何实现弹性通信
分布式系统背后的动机
应该首先询问的第一个问题是分布的需求。设计分布式系统背后有几个技术动机。
典型的企业场景本质上都是分布式的。分布在不同地点的用户或其他系统需要与一个服务进行通信。这需要在网络上发生。
另一个原因是可扩展性。如果一个单一的应用程序达到无法可靠地服务整体客户负载的程度,业务逻辑就需要分布到多个主机上。
类似的推理旨在提高系统的容错性。单一应用程序代表单一故障点;如果单一应用程序不可用,服务将无法被客户端使用。将服务分布到多个位置可以增加可用性和弹性。
同时,也有其他一些不那么技术驱动的动机。一个应用程序代表某些业务责任。在领域驱动设计语言中,它们包含在应用程序的边界上下文中。边界上下文包括应用程序的业务关注点、逻辑和数据,并将其与外部关注点区分开来。
与工程师将代码责任聚类到包和模块中一样,在系统规模上构建上下文也肯定是有意义的。一致的业务逻辑和功能作为单独应用程序的一部分被分组到单独的服务中。数据和模式也是边界上下文的一部分。因此,它可以封装到几个数据库实例中,这些实例由相应的分布式应用程序拥有。
分布的挑战
在所有这些动机中,尤其是像可扩展性这样的技术动机,为什么工程师不应该将一切分散呢?分散伴随着一定的开销。
通常,系统提炼出的业务逻辑之上的整体开销将乘以涉及的应用程序数量。例如,一个单一、单体应用程序需要一个监控解决方案。分散这个应用程序将导致所有结果应用程序也需要被监控。
通信开销
在分布式中,首先,系统之间通信存在一定的开销成本。
技术在单个进程内的通信非常有效。调用应用程序部分的功能几乎没有开销。一旦需要进程间或远程通信,工程师必须定义接口抽象。需要定义和使用 HTTP 等通信协议来交换信息。
这需要一定的时间和努力。应用程序之间的通信需要定义、实现和维护。在单个应用程序中,通信体现在方法调用上。
所需的通信也成为业务用例的担忧。不能再假设某些功能或数据可以无开销地使用。与分布式系统的通信成为应用程序的责任。
性能开销
在一开始分散应用程序会降低整体系统的性能。计算机网络比单个主机内的通信要慢。因此,网络总是伴随着一定的性能开销。
性能开销不仅由通信本身引起,还包括同步的需要。在单个进程内的同步已经消耗了一定的处理时间,当涉及到分布式时,这种影响更大。
然而,尽管性能开销存在,但随着应用的扩展,分布式最终会增加系统的整体性能。与单个实例相比,水平扩展总是伴随着一定的性能开销。
组织开销
包含多个应用程序的分布式系统当然需要比单个应用程序更多的组织努力。
多个应用程序意味着需要管理的多个部署。部署新版本可能会影响依赖的应用程序。团队需要确保部署的应用程序版本能够良好地协同工作。单一、单体应用程序不受此影响,因为它们在自身内部是一致的。
此外,多个应用在多个项目和存储库中开发,通常由多个开发团队完成。特别是,拥有多个团队需要沟通,不一定是技术上的,而是与人类相关的沟通。与部署应用一样,需要就职责、系统边界和依赖达成一致。
如何设计系统景观
在涉及所有这些挑战和开销的情况下,许多场景仍然需要分布式处理。重要的是要提到,分布式系统必须有足够的动机。分布式处理伴随着成本和风险。如果不是必须分布式处理,构建单体应用始终是首选。
现在,让我们来看看如何设计合理的系统景观,以满足业务需求。
上下文映射和边界上下文
边界上下文定义了应用在业务逻辑、行为和数据所有权方面的职责。所谓的上下文映射,如领域驱动设计中所描述的,代表了整个系统景观。它显示了应用的个别职责、上下文和所属关系。因此,边界上下文适合在上下文映射中展示它们之间如何交换信息。
下图显示了汽车领域的上下文映射,包括两个边界上下文:

在设计和划分应用之前,考虑系统的不同职责是明智的。一旦记录了系统的上下文映射,通常很快就会出现对应用职责的缺乏清晰性。
上下文映射不仅在项目定义的初期有帮助,而且在业务功能发生变化后重新审视和细化职责时也有帮助。为了防止分布式应用的边界和所属关系分离,建议不时地反思它们。
关注点的分离
应该清楚地定义应用程序的职责,并与其他应用程序区分开来。
就像在代码级别一样,几个应用的关注点应该被分离。单一职责原则在这里同样适用。
应用程序的关注点包括所有业务关注点、应用边界和拥有的数据。随着业务逻辑随时间发展和变化,这些关注点应该不时地重新审视。这可能导致应用被分割或合并成单个应用。上下文映射中出现的职责和关注点应在系统的应用中得到反映。
数据和数据所有权是分布式应用的一个重要方面。业务流程,作为边界上下文的一部分,定义了用例中涉及的数据。所有数据是特定应用的关注点,并且仅通过定义的边界进行共享。需要从其他,远程应用负责的数据的用例需要通过远程调用相应的用例来检索信息。
团队
当设计分布式系统时,团队和组织结构是其他需要考虑的重要方面,因为截至撰写本书时,软件是由人类开发的。考虑到康威定律,即组织的沟通结构最终会渗透到构建的系统之中,团队应该与系统中的应用类似地进行定义。
或者换句话说,一个应用只由一个团队开发是有意义的。根据责任和规模,一个团队可以潜在地创建多个应用。
再次,与项目代码结构相比,这是水平与垂直模块层叠的类似方法。与业务驱动的模块结构类似,因此团队是垂直组织的,代表着上下文地图的结构。例如,而不是在软件架构、开发或运维上有几个专家团队,将会有汽车制造、装配线和订单管理团队。
项目生命周期
由于个人团队参与开发分布式系统,应用将拥有独立的工程生命周期。这包括团队运作的方式,例如,他们如何组织他们的冲刺周期。
部署周期和计划也来自项目生命周期。为了使整个系统保持一致和功能,需要定义对其他应用部署的潜在依赖。这不仅仅针对应用的可用性。
部署的应用版本需要兼容。为了确保这一点,依赖的应用需要在上下文地图中明确表示。当依赖的服务引入更改时,团队将不得不进行沟通。
再次,绘制一个清晰的上下文地图,包含边界上下文,有助于定义相互依赖的应用及其责任。
如何设计系统接口
在定义了系统景观的责任之后,需要指定依赖系统的边界。
在前面的章节中,我们看到了各种通信协议及其实现方法。除了实际实现之外,现在的问题是:如何设计应用的接口?在分布式系统中,哪些方面需要考虑?
API 考虑因素
系统中的应用是基于它们的业务责任划分的。
同样,应用程序的 API 也应代表业务逻辑。公开的 API 代表了某个应用程序包含的业务用例。这意味着业务领域专家可以在没有任何进一步的技术知识的情况下,从 API 中识别出公开的业务用例。
商业用例理想情况下应提供清晰、简洁的接口。调用用例不应需要比作为业务逻辑的一部分更多的技术动机的通信步骤或细节。例如,如果“创建汽车”用例可以作为单一操作调用,那么“汽车制造”应用程序的 API 就不应需要多次调用提供技术细节。
一个 API 应该通过清晰、简洁的接口抽象业务逻辑。
因此,API 应该与应用程序的实现解耦。接口实现应独立于所选技术。这也意味着选择了一种不设置太多约束于所用技术的通信格式。
因此,优先选择基于标准协议(如 HTTP)的技术是有意义的。工程师更有可能了解常用协议,因为它们被各种技术和工具所支持。在 HTTP 网络服务中创建应用程序接口允许在支持 HTTP 的任何技术中开发客户端。
使用标准协议的清晰、简洁接口抽象业务逻辑,也使得在使用的实现、技术和平台上进行更改成为可能。仅公开 HTTP 服务的 Java 企业应用程序可以用其他实现替换其技术,而无需要求依赖的客户端进行更改。
接口管理
应用程序接口在开发过程中经常发生变化。
新的商业用例被包含进来,现有的用例被细化。问题是,这些更改如何在 API 中反映出来?
企业应用程序的性质和环境决定了 API 需要有多稳定。如果项目团队负责服务、所有客户端及其生命周期,API 可以引入任意更改,这些更改同时反映在客户端上。如果涉及的应用程序的生命周期由于某种原因相同,情况也是如此。
通常,分布式系统的生命周期并不那么紧密耦合。对于任何其他客户端/服务器模型,或者具有不同生命周期的应用程序,API 不得破坏现有客户端。这意味着 API 是完全向后兼容的,不引入破坏性更改。
抗变 API
在设计接口时有一些原则可以防止不必要的中断。例如,引入新的、可选的有效负载数据不应破坏合同。技术应该具有弹性,只要提供所有必要的数据,它就可以继续工作。这与“在所做的事情上保守,在所接受的事情上自由”的想法相符。
因此,在不破坏客户端的情况下,应该能够添加新的、可选的功能或数据。但如果是现有逻辑发生变化呢?
破坏业务逻辑
这里需要问的问题是 API 中的破坏性变更对业务用例意味着什么。应用程序的过去行为是否不再有效?客户端是否必须从现在开始停止工作?
这相当于,例如,一个广泛使用的智能手机应用的供应商决定破坏现有版本,并强迫用户更新到其最新版本。在继续使用现有功能方面,这样做可能没有必要性。
如果由于某种原因,现有的用例不能再“原样”使用,应考虑一些额外的、补偿性的业务逻辑。
超媒体 REST 和版本控制
超媒体 REST API 可以在这方面提供一些缓解。特别是,超媒体控制提供了通过动态定义资源链接和操作来演进 API 的能力。REST 服务的客户端将适应访问服务的变更,并考虑性地忽略未知的功能。
一个经常建议的可能性是对 API 进行版本控制。这意味着引入不同的操作或资源,例如/car-manufacture/v1/cars,其中版本是 API 的标识部分。然而,对 API 进行版本控制与干净接口的理念相矛盾。特别是,由于 REST API 资源代表领域实体,引入多个“版本”的汽车在商业术语上没有意义。汽车实体由其 URI 标识。将 URI 更改以反映业务功能的变化意味着对汽车身份的更改。
有时需要同一领域实体的几个不同表示或版本,例如包含不同属性集的 JSON 映射。通过 HTTP 接口,这可以通过内容协商实现,通过定义内容类型参数。例如,可以通过内容类型如application/json;vnd.example.car+v2请求同一辆车的不同 JSON 表示,如果相应的服务支持的话。
管理接口是分布式系统的一个相关主题。建议在考虑向后兼容性的情况下提前仔细设计 API。应优先考虑额外的操作,以防止 API 破坏现有功能,而不是破坏客户端的干净接口。
记录边界
定义 API 以调用应用程序业务逻辑的应用程序边界需要对其客户端公开,例如系统内的其他应用程序。问题是,需要记录哪些信息?
应用程序的边界上下文是上下文图的一部分。因此,领域责任应该是清晰的。应用程序在其上下文中完成某些业务用例。
这种领域信息需要首先记录。客户端应该了解应用程序提供的内容。这包括用例以及交换的信息和数据所有权。
汽车制造应用程序的责任是根据提供的精确规格组装汽车。制造汽车的状态信息由应用程序在整个组装过程中拥有,直到汽车到达生产线末端并准备交付。应用程序可以被轮询以提供有关汽车创建过程的更新状态。
应用程序的领域描述应包含客户端所需的信息,职责明确,但不要过于冗长,仅暴露客户端需要知道的内容。
除了业务领域,还需要记录技术方面的内容。客户端应用程序需要针对系统的 API 进行编程。它们需要有关通信协议以及数据格式的信息。
我们在本书的第二章中介绍了几个通信协议及其实现方法。在撰写本文时,最常用的协议之一是 HTTP,以及 JSON 或 XML 内容类型。以 HTTP 为例,需要记录什么内容?
HTTP 端点,尤其是遵循 REST 约束的端点,将领域实体作为资源表示,可以通过 URL 进行定位。首先需要记录可用的 URL。客户端将连接到这些 URL 以执行某些业务用例。例如,/car-manufacture/cars/<car-id> URL 将引用由其标识符指定的特定汽车。
需要记录具有详细映射信息的内容类型。客户端需要了解所使用内容类型中的结构和属性。
例如,为了创建汽车而提供的汽车规格包含一个标识符、一个引擎类型和一个底盘颜色。JSON 格式如下所示:
{
"identifier": "<car-identifier>",
"engine-type": "<engine-type>",
"color": "<chassis-color>"
}
需要记录类型和可用的值。这将指向业务领域知识,以及引擎类型背后的语义。这一点很重要,即内容类型以及信息的语义都需要进行记录。
在 HTTP 的情况下,需要记录的方面将更多,例如可能需要的头部信息、由网络服务提供的状态码等。
所有这些文档当然取决于所使用的技术和通信协议。然而,业务领域也应该包含在文档中,提供所需的所有上下文。
应用程序的 API 文档是软件项目的一部分。它需要与应用程序一起以特定版本发布。
为了确保文档与应用程序的版本一致,它应该是项目存储库的一部分,位于版本控制之下。因此,强烈建议使用基于文本的文档格式,而不是像 Word 文档这样的二进制格式。轻量级标记语言如AsciiDoc或Markdown在过去已经证明了自己非常有效。
在项目内直接维护文档,紧邻应用程序的源代码,可以确保创建与开发的服务一致的文档版本。工程师能够一步完成更改。这样做可以防止文档和服务版本出现分歧。
根据通信技术,在记录应用程序边界方面有很多工具支持。例如,对于 HTTP Web 服务,OpenAPI 规范与Swagger作为文档框架被广泛使用。Swagger 将 API 定义输出为可浏览的 HTML,这使得开发者能够轻松地识别提供的资源及其用法。
然而,使用超媒体 REST 服务消除了服务文档的最大必要性。通过在链接中提供有关哪些资源可用,消除了记录 URL 的需求。实际上,服务器重新获得了如何构造 URL 的控制权。客户端只需输入一个入口点,例如/car-manufacture/,然后根据它们的关系遵循提供的超媒体链接。关于汽车 URL 由哪些部分组成的知识仅存在于服务器端,并且明确没有进行文档记录。
这对于超媒体控制尤其如此,它不仅指导客户端访问资源,还提供有关如何消费这些资源的信息。例如,汽车制造服务告诉客户端如何执行create-car动作:需要向/car-manufacture/cars发送 POST 请求,包括一个 JSON 内容类型的请求体,其中包含identifier、engine-type和color属性。
客户需要了解所有关系和动作名称的语义,以及它们的属性和来源。这当然属于客户端逻辑。有关如何消费 API 的所有信息都成为 API 的一部分。因此,设计 REST 服务消除了大量文档的需求。
一致性与可扩展性
当然,分布式系统进行通信是必要的。由于计算机网络不能被认为是可靠的,甚至在公司内部网络中也是如此,可靠的通信是必需的。为了确保正确的行为,业务用例需要以可靠的方式进行通信。
在本书的早期,我们介绍了所谓的 CAP 定理,该定理声称分布式数据存储无法保证最多两个指定的约束。系统可以有效地选择它们是否想要保证一致性或水平可扩展性。这极大地影响了分布式世界中的通信。
通常,企业系统在其用例中应该是一致的。业务逻辑应该将整体系统从一个一致状态转换到另一个不同的一致状态。
在分布式系统中,一个整体一致的状态意味着与外部关注点通信的用例必须确保被调用的外部逻辑也遵守一致性。这种方法导致分布式事务。在系统上被调用的用例将以“全有或全无”的方式执行,包括所有外部系统。这意味着需要对所有涉及的分布式功能进行锁定,直到每个分布式应用程序都成功完成其职责。
自然地,这种方法无法扩展。系统是分布式的这一事实要求这种事务编排需要在可能缓慢的网络上进行。这引入了一个瓶颈,导致锁定情况,因为涉及的应用程序必须阻塞并等待相对较长的时间。
一般而言,同步、一致性的通信仅适用于一次不涉及超过两个应用程序的应用程序。性能测试以及生产经验表明,所选的通信场景是否足够好地扩展以适应给定的用例和环境。
使用异步通信的动机是可扩展性。异步通信的分布式系统在定义上不会始终一致。异步通信可以在逻辑层面上发生,其中同步调用仅启动业务逻辑,而不等待一致的结果。
让我们来看看分布式应用程序中异步、最终一致通信背后的动机和设计。
事件溯源、事件驱动架构和 CQRS
传统上,企业应用程序是使用基于原子的创建、读取、更新、删除(CRUD)的模型方法构建的。
系统的当前状态,包括领域实体的状态,反映在关系数据库中。如果一个领域实体被更新,该实体的新状态(包括所有属性)将被放入数据库,而旧状态则消失。
CRUD 方法要求应用程序保持一致性。为了确保领域实体的状态正确反映,所有用例调用都必须以一致的方式进行执行,同步对实体的修改。
基于 CRUD 的系统缺点
这种同步也是我们通常构建应用程序的 CRUD 系统的一个缺点。
可扩展性
所需的同步防止了系统无限扩展。所有事务都在关系数据库实例上执行,如果系统需要扩展,最终会引入瓶颈。
这最终成为处理大量工作负载或大量用户的情况的挑战。然而,对于绝大多数企业应用程序来说,关系数据库的可扩展性是足够的。
竞争事务
基于 CRUD 模型的另一个挑战是处理竞争事务。包括相同领域实体并同时操作的业务用例需要确保实体的最终状态是一致的。
同时编辑用户姓名和更新其账户信用额度不应导致更新丢失。实现必须确保这两个事务的整体结果是仍然一致的。
依赖于乐观锁定的竞争事务通常会导致事务失败。这绝对不是从用户的角度来看的理想情况,但至少保持了一致性,而不是压制一个事务在空间中丢失的情况。
然而,遵循这种方法可能会引起不必要的锁定。从业务理论的角度来看,应该可以同时编辑用户的姓名和账户信用额度。
可重现性
由于应用程序只存储其当前状态,所有关于先前状态的历史信息都消失了。状态总是被新的更新覆盖。
这使得很难重现应用程序如何进入当前状态。如果当前状态是从其原始用例调用中错误计算出来的,那么后来就没有可能修复这种情况。
一些场景明确要求可重现性以符合法律条款。因此,一些应用程序包括审计日志,这些日志在信息发生时立即永久写入系统。
事件源
事件源是一种解决基于 CRUD(创建、读取、更新、删除)系统可重现性不足的方法。
事件源系统通过过去发生的原子事件来计算系统的当前状态。这些事件代表了单个业务用例的调用,包括调用中提供的信息。
当前状态不是永久持久化的,而是通过依次应用所有事件而产生的。这些事件本身发生在过去,是不可变的。
例如,一个具有其特征的用户是从与其相关的所有事件中计算出来的。依次应用UserCreated、UserApproved和UserNameChanged创建用户当前的表示,直到最近的那个事件:

事件包含自给自足的信息,主要与相应的用例相关。例如,一个UserNameChanged事件包含时间戳和用户更改后的名称,而不是其他与用户无关的信息。因此,事件的信息是原子的。
事件永远不会被更改或删除。如果从应用程序中删除了一个领域实体,将会有一个相应的删除事件,例如UserDeleted。在应用所有事件后,系统的当前状态将不再包含此用户。
优势
事件溯源的应用程序包含所有其信息在原子事件中。因此,完整的记录和上下文,即它是如何进入当前状态的,都是可用的。为了调试目的重现当前状态,所有事件及其对系统的个别修改都可以被视为。
系统发生的一切都被原子存储的事实有几个好处,不仅限于调试目的。
测试可以利用这些信息在系统测试中重放生产系统中发生的一切。然后测试能够重新执行生产中发生的确切业务用例调用。这对于系统和性能测试来说是一个优势。
对于使用原子信息来收集关于应用程序使用情况的洞察的统计也是如此。这使人们能够在应用程序部署后设计用例和洞察。
假设一位经理想知道在应用程序运行两年后的周一创建了多少用户。在基于 CRUD 的系统上,在用例被调用时,该信息必须被显式地持久化。过去未明确请求的用例只能作为新功能添加,并将在未来增加价值。
使用事件溯源,这些功能是可能的。由于系统发生的一切信息都被存储,因此未来开发的使用案例能够操作过去发生的数据。
然而,这些优势当然可以在不需要分布式系统的情况下实现。一个单体、独立的应用程序可以基于事件溯源构建其模型,从中获得相同的优势。
最终一致的现实世界
在我们进一步探讨分布式系统的一致性和可伸缩性之前,让我们看看现实世界的一致性是如何的。企业应用程序通常旨在提供完全的一致性。然而,现实世界却是高度分布的,根本不一致。
想象你饿了,想吃一个汉堡。所以你去了餐馆,坐在桌子旁,告诉服务员你想要一个汉堡。服务员会接受你的订单。现在,尽管你的订单已被接受,但这并不一定意味着你将收到你的餐点。点餐的过程并不完全一致。
在这一点上可能会出很多问题。例如,厨师可能会告诉服务员,不幸的是,最后一个汉堡肉饼已经被使用,当天不会再有汉堡了。所以尽管你的订单在事务上已被接受,服务员会回来告诉你订单无法实现。
现在,服务员可能不会要求你离开,而是会向你推荐一道替代菜品。如果你饿了,并且对替代品满意,你最终可能会得到一份餐点。
这就是高度分布式现实世界处理业务用例事务的方式。
如果餐厅以完全一致的方式建模,场景将有所不同。为了确保只有在能够提供准备好的餐点的情况下才接受订单,整个餐厅都需要被锁定。顾客必须等待并保持对话,直到服务员进入厨房并从厨师那里订购餐点。由于在订购后可能会发生许多其他问题,整个订单事务实际上必须阻塞,直到餐点完全准备好。
显然,这种方法是不可行的。相反,现实世界完全是关于协作、意图,并在意图无法实现时最终处理问题。
这意味着实际世界以最终一致的方式运行。最终,餐厅系统将达到一致状态,但并不一定是所有时候,这导致最初接受实际上不可能的订单。
实际世界的过程被表示为意图或命令,例如点一份汉堡,以及原子结果或事件,例如订单已被接受。随后,事件将导致新的命令,从而产生新的结果或失败。
事件驱动架构
现在,回到分布式系统的话题。与餐厅一样,通过分布式事务以一致方式通信的分布式系统将无法扩展。
事件驱动架构解决了这个问题。在这些架构中,通信通过可靠发布和消费的异步事件进行。
通过这样做,一致的业务用例事务被分割成多个、规模更小的、自身一致的事务。这导致整体业务用例最终达到一致。
让我们看看在事件驱动架构中如何表示点汉堡用例的例子。餐厅系统至少由两个分布式应用程序组成,即服务员和厨师。餐厅应用程序通过监听彼此的事件进行通信。客户端应用程序将与服务员通信,以启动用例:

客户在服务员应用程序中下单,这会触发OrderPlaced事件。一旦事件可靠地发布,orderMeal()方法的调用就会返回。因此,客户端能够并行执行其他工作。
厨师系统接收到OrderPlaced事件并验证订单是否可以使用当前可用的原料。如果订单无法实现,厨师会发出不同的事件,例如OrderFailedInsufficientIngredients。在这种情况下,服务员会将订单状态更新为失败。
当餐点准备成功时,服务员会接收到MealPreparationStarted事件并更新订单状态,这会导致OrderStarted。如果客户询问服务员他们的订单状态,服务员可以相应地回答。
在某个时刻,餐点准备过程将会完成,从而触发一个MealPrepared事件,通知服务员送餐。
事件驱动架构中的最终一致性
订单用例最终是一致的。可靠地发布事件仍然确保所有客户端最终都会知道他们订单的状态。
如果处理订单不是立即发生,或者由于某些原因订单会失败,这可能是可以接受的。然而,绝不能发生订单因应用程序不可用而丢失在系统中的情况。在发布事件时需要确保这一点。
这里仍然涉及交易,但规模较小,且不涉及外部系统。这样做使得分布式系统能够覆盖事务性用例,同时仍然能够实现水平扩展。
对于像事件驱动架构这样的方法,需要一定的可靠性是一个重要的方面,在设计解决方案和选择技术时应该予以考虑。
进入 CQRS
现在让我们结合事件驱动架构和事件溯源背后的动机。
事件驱动架构通过原子事件进行通信。利用这种方法,并通过事件作为系统真相的来源来构建系统(即事件溯源),是有意义的。这样做结合了两种方法的好处,使得系统能够实现水平扩展和事件溯源。
问题是如何对基于事件的域模型的事件驱动应用程序进行建模?以及如何高效地计算并返回域实体的当前状态?
命令查询责任分离(CQRS)原则描述了如何对这些应用程序进行建模。它是事件驱动架构的结果,并基于事件溯源。
原则
如其名所示,CQRS 将命令和查询的责任分开,即写入和读取。
命令通过最终产生事件来改变系统的状态。它不允许返回任何数据。命令要么成功,产生零个或多个事件,要么失败并返回错误。事件产生是可靠的。
查询检索并返回数据,不会对系统产生副作用。它不允许修改状态。
以 Java 代码为例,命令就像一个void doSomething()方法,它改变状态。查询就像一个 getter String getSomething(),它不会影响系统的状态。这些原则听起来很简单,但它们对系统的架构有一些影响。
命令和查询的责任被分割成几个关注点,使得 CQRS 应用程序可以成为完全独立的、要么写入要么读取的应用程序。那么,如何设计和实现这种方法呢?
设计
遵循事件驱动架构,写入和读取系统仅通过事件进行通信。事件通过事件存储或事件中心进行分发。除了产生事件的写入系统和消费事件以更新其内部状态的读取系统之外,没有其他耦合。
下面的代码片段显示了 CQRS 系统的架构:

命令和查询服务从事件存储中消费事件。这是它们之间通信的唯一方式。
所有服务维护一个当前状态表示,该表示反映了领域实体的状态。实体例如是餐点订单或汽车,包括它们属性的最新状态。这种状态保存在内存中或在数据库中持久化。
这些表示只是使系统能够包含当前状态。黄金真相来源是事件存储中包含的原子事件。
所有应用程序实例通过消费和应用事件存储中的事件来单独更新它们的状态表示。
命令服务包含启动系统更改的业务逻辑。它们在通过其状态表示进行可能的命令验证后,通过事件存储产生事件。
为了使信息流清晰,让我们通过一个示例餐点订单来分析:

客户在命令服务实例中下单。在对其表示进行可能的验证后,命令服务将OrderPlaced事件发送到事件存储。如果事件发布成功,orderMeal()方法返回。客户可以继续其执行。
命令服务可以为后续检索创建一个餐点标识符,例如,作为一个通用唯一标识符:

事件存储会将事件发布给所有消费者,并相应地更新他们的内部表示。客户端可以使用其标识符在查询服务中访问餐点的状态。查询服务将响应其最新的订单表示。
为了继续订单处理,一个调用潜在后续命令的权威机构也会处理该事件:

事件处理器将监听OrderPlaced事件并调用厨师系统的prepareMeal()用例。此后续命令可能产生新事件。
“使用 Java EE 实现微服务”这一章节涵盖了如何实现 CQRS 等内容。
优点
CQRS 使分布式应用程序不仅能够水平扩展,而且可以在其读写关注点上独立扩展。例如,查询服务的副本可以与命令服务的数量不同。
企业应用程序的读写负载通常分布不均。通常,读操作远高于写操作的数量。在这些情况下,可以独立扩展读实例的数量。在基于 CRUD 的系统,这是不可能的。
另一个优点是,每个服务都可以相应地优化其状态表示。例如,将领域实体持久化存储在关系型数据库中可能不是每个情况的最佳方法。也可以仅在内存中存储表示,并在应用程序启动时重新计算所有事件。关键是写实例和读实例都可以自由选择并优化其表示以适应环境。
这种方法的副作用是,CQRS 还提供了读侧故障转移可用性。如果事件存储不可用,则无法发布新事件,因此无法在系统上调用修改状态的用例。在基于 CRUD 的系统,这相当于数据库宕机。然而,在 CQRS 系统中,至少查询服务仍然可以从其表示中提供最新的状态。
CQRS 系统的状态表示也解决了事件源系统的可伸缩性问题。事件源系统从原子事件计算当前应用程序状态。每次操作调用时执行此操作将随着时间的推移变得越来越慢,因为事件越来越多。命令和查询服务的表示通过持续应用最近的事件消除了这种需求。
缺点
构建 CQRS 系统不仅有益,也有缺点。
构建这些系统可能的最大缺点之一是,大多数开发者不熟悉其概念、设计和实现。当这种方法在企业项目中选择时,这将会引入困难。与基于 CRUD 的系统不同,CQRS 将需要额外的培训和专业知识。
与任何分布式系统一样,与 CRUD 方法相比,CQRS 系统中涉及的应用程序自然更多。正如之前在一般分布式系统中所述,这需要一些额外的工作。此外,还需要一个事件存储。
与所展示的示例不同,命令和查询两侧必须在两个或更多独立的应用程序中是强制性的。只要功能仅通过事件存储发布的事件进行通信,两者都可以位于同一应用程序中。例如,这会导致一个服务员和厨师应用程序仍然可以水平扩展。如果不需要单独扩展写入和读取两侧,这是一个合理的权衡。
通信
构建 CQRS 系统是实现异步、最终一致通信的一种方法。正如我们在本书中之前所看到的,有许多通信形式,包括同步和异步。
为了使应用程序可伸缩,分布式系统不应依赖于涉及多个系统的同步通信。这会导致分布式事务。
实现具有技术无关、同步通信协议的可伸缩性的一种方法是对逻辑上异步过程进行建模。例如,可以使用 HTTP 等通信协议来触发异步发生的处理,同时调用者立即返回。这引入了最终一致性,但使系统能够扩展。
这还涉及到考虑使分布式系统的应用程序在系统内部和外部通信中是否有所区别。CQRS 通过提供外部接口来实现这一点,例如使用 HTTP,而服务本身通过事件存储进行通信。通过统一协议访问的异步过程建模在这里没有区别。
通常,在设计分布式系统时,建议优先考虑可用性,即可伸缩性,而不是一致性。有许多可能的方法,CQRS 就是其中之一,它结合了异步通信和事件溯源。
以下部分涵盖了自给自足应用程序的必要性。
微服务架构
我们看到了分布式系统的动机、挑战和好处,以及一些处理通信和一致性的方法。现在我们将关注分布式应用程序的架构。
企业中的数据和技术共享
企业中一个常见的想法是共享和重用技术以及常用的数据。我们之前讨论了共享 Java 模块及其不足。那么,在分布式系统中共享通用技术或数据模型又如何呢?
构成企业系统的多个应用通常使用类似的技术来实现。这对于由单个团队或紧密合作的团队构建的应用来说是很自然的。这样做往往会产生共享在应用中重用的技术的想法。
项目可以使用常用的模块来消除整个系统中的重复。一个典型的做法是共享模型。组织内部可能只有一个模块在所有项目中都被重用。
共享模型引发了一个问题,即是否正在重用潜在的持久化领域实体或传输对象。领域实体在数据库中持久化后甚至可以直接从数据库系统中检索,对吧?
常用的数据库与分布式系统完全矛盾。它们紧密耦合了涉及的应用。模式或技术的变更将应用和项目生命周期焊接在一起。常用的数据库实例阻止了应用的可扩展性。这消除了分布式系统背后的动机。
这同样适用于一般的技术共享。如前几章所示,常用的模块和依赖关系在实现中引入了技术约束。它们将应用耦合起来,限制了它们在变更和生命周期中的独立性。即使这些变更不会影响应用的边界,团队也必须进行沟通和讨论。
从系统上下文图中的领域知识和职责来看,共享数据和技术的意义不大。确实存在一些系统间需要共享技术接触点。
然而,关键在于实现应用,这些应用一方面依赖于其业务职责,另一方面依赖于记录的通信协议。因此,建议选择潜在的重复和独立性,而不是在技术上的耦合。
在系统上下文图中共享其他关注点而不是接触点应该会提醒工程师。应用的不同职责应该清楚地表明常用的模型或数据位于不同的上下文中。各个应用只对其关注点负责。
无共享架构
考虑到这些想法,建议构建不共享任何公共技术或数据的程序。它们在通信和业务职责上履行了应用边界合同。
无共享架构在技术、潜在使用的库、它们的数据及其模式方面是独立的。它们可以自由选择实现和潜在的持久化技术。
如果分布式系统中应用程序的实现从 Python 更改为 Java,只要其 HTTP 接口的合同仍然满足,就不应该对其他应用程序产生影响。
如果其他应用程序需要数据,这需要在上下文映射中明确定义,要求应用程序通过其业务逻辑接口公开数据。数据库不共享。
无共享架构允许具有独立生命周期的应用程序,这些应用程序除了明确定义的合同外,不依赖任何其他东西。团队可以自由选择技术和项目生命周期。技术和包括数据库在内的数据都属于应用程序。
相互依赖的系统
无共享架构最终必须与其他应用程序协作。定义的合同必须满足、记录并传达。
这一点是,无共享架构仅依赖于定义的合同和责任。在业务逻辑发生变化时,合同被重新定义并传达。仅应用程序团队负责如何实现合同。
相互依赖的系统由几个具有良好定义接口的无共享应用程序组成。使用的接口应该是技术无关的,以避免对使用的实现设置约束。
这是微服务架构背后的理念。微服务由几个相互依赖的应用程序组成,这些应用程序实现它们各自的企业责任,并结合起来解决问题。
微服务的名称并不一定说明应用程序的大小。应用程序应由单个开发团队构建。出于组织原因,团队规模不应过大。亚马逊经常引用的一个观点是,整个团队应该能够靠两块披萨生存。
在构建微服务之前,应该考虑分布式系统背后的动机。如果没有实际需要分布式系统,应避免使用。坚持使用具有合理责任的单体应用程序是首选。
通常,构建微服务架构的方法是将责任过大或团队和生命周期分歧的单体应用程序分割成多个部分。这与重构方法类似。将变得过大的类重构为多个代理通常比一开始就尝试引入一个完美的场景更有效。
通常,在考虑业务需求、系统的上下文映射以及他们的开发团队和生命周期时,总是建议这样做。
12 因子和云原生应用程序
第五章,使用 Java EE 的容器和云环境,介绍了 12 要素和云原生应用程序的方法。它们强烈支持微服务架构。
特别是,通过容器化、无状态和可扩展的企业应用程序原则,可以实现具有相互依赖、分布式应用程序的无共享方法。
12 要素原则和云和容器环境的有效性支持团队以可管理的开销和高生产力开发微服务。
然而,企业系统并不一定需要分布式才能符合 12 要素或云原生原则。这些方法当然也适用于构建单体应用程序。
何时使用微服务,何时不使用微服务
近年来,微服务架构在软件行业中受到了一些炒作。
总是随着炒作,工程师应该问自己某些热门词汇背后的东西,以及实施它们是否有意义。总是建议研究新技术和方法。不一定建议立即应用它们。
使用微服务的理由与使用分布式系统的一般理由相同。有技术原因,例如需要独立部署生命周期的应用程序。
同时,也有一些原因是受业务需求以及团队和项目工作模式中的情况驱动的。
可扩展性是微服务架构经常引用的动机。正如我们在事件驱动架构中看到的,单体应用程序无法无限扩展。问题是可扩展性是否真正是一个问题。
有大公司使用单体应用程序处理大量用户的业务逻辑。在考虑将分布式作为缓解扩展问题的手段之前,应该收集性能洞察和统计数据。
工程师应避免仅仅因为相信“银弹”方法而使用微服务架构。这很容易发生在“热门词汇驱动”的会议和对话中,解决方案的选择基于有限的或没有证据支持的要求。微服务当然提供了好处,但也带来了时间和努力上的代价。无论如何,将责任分割到多个应用程序的要求和动机应该是清晰的。
使用 Java EE 实现微服务
现在来谈谈如何使用企业 Java 构建微服务。
在各种讨论和会议中,Java EE 被认为对于微服务来说过于重量级且笨重。虽然这对于 J2EE 技术和方法确实如此,但 Java EE 提供了现代、精简的企业应用程序开发方式。第四章,轻量级 Java EE 和 第五章,Java EE 与容器和云环境涵盖了这些方面,特别是在现代环境方面。
Java EE 确实非常适合编写微服务应用程序。容器技术和编排支持该平台,尤其是由于 Java EE 将业务逻辑与实现分离。
零依赖应用程序
使用 Java EE 的微服务理想地构建为零依赖应用程序。
薄型 WAR 应用程序部署在现代企业容器中,这些容器可以用于运输。这最小化了部署时间。Java EE 部署工件应仅包含提供的依赖项;如果有合理的需要添加第三方依赖项,它们应安装在应用程序服务器中。容器技术简化了这种方法。
这也符合无共享架构的理念。团队负责特定于应用程序的技术,在这种情况下是包括库在内的应用程序服务器安装。例如 Dockerfile 这样的基础设施代码定义,使开发团队能够以有效的方式完成这项工作。
应用服务器
采用这种方法,应用程序服务器以容器形式交付,其中只包含单个应用程序。"一个应用程序对应一个应用程序服务器" 的方法也符合无共享架构的理念。
问题是,如果单个服务器实例只包含一个应用程序,应用服务器是否会引入过多的开销。在过去,存储和内存占用确实很大。
现代应用服务器在这方面有了显著提升。例如,TomEE 这样的服务器基于容器,其占用内存仅为 150 MB 及以下,请注意,这包括了服务器、Java 运行时和操作系统。由于动态加载功能,内存消耗也得到了显著改善。
在企业项目中,安装大小通常不是需要关注的问题,尤其是如果它们没有超过所有界限的话。更重要的是构建和交付的工件的大小。应用程序工件,在某些技术中包含数兆字节的依赖项,被构建和传输多次。运行时只在每个环境中安装一次。
如 Docker 这样的容器技术利用分层文件系统,鼓励将组件保持小型化。零依赖应用程序支持这种方法。
让每个持续交付的构建只传输几 KB 的数据,比在基础安装中节省几个 MB 的数据要明智得多。
如果还需要缩小安装大小,一些应用供应商提供定制容器以满足所需标准的方法,特别是 MicroProfile 倡议,它包括多个应用服务器供应商,并定义了精简配置文件。
Java EE 微服务不需要作为独立 JAR 文件分发。相反,在容器中分发的应用程序应利用分层文件系统的使用,并在基础镜像中运行的企业的容器上部署。独立 JAR 文件与此原则相悖。
有可能通过所谓的空 JAR 将独立 JAR 文件与轻量级部署相结合。然而,在使用容器时,这种方法并不是必需的。
实现应用边界
让我们继续探讨使用 Java EE 实现应用边界的实现。这实际上是一个比实现问题更系统架构的问题。
微服务之间的通信应使用技术无关的协议。如前所述,Java EE 在 HTTP 和 REST 服务方面都大力支持 HTTP,并使用超媒体。
下一章将介绍 CQRS 系统中使用 Apache Kafka 实现的发布/订阅消息的异步通信。
实现 CQRS
在本章的早期,我们看到了事件源、事件驱动架构和 CQRS 背后的动机和概念。CQRS 为创建实现可扩展、最终一致业务用例的分布式应用提供了一种有趣的方法。
在撰写本文时,对 CQRS(Command Query Responsibility Segregation)的兴趣很大,但公司内部对如何使用它的了解却很少。已经出现了一些框架和技术,旨在实现这种方法。然而,CQRS 是一种架构风格,开发 CQRS 系统并不需要特定的框架。
让我们仔细看看一种使用 Java EE 的方法。
系统接口
CQRS 系统接口用于系统外部来启动业务用例。例如,客户端访问服务员系统来订购汉堡。
这些接口用于外部,理想情况下使用一种技术无关的协议实现。
对于类似 REST 的 HTTP 服务,这意味着命令服务实现修改资源的 HTTP 方法,例如 POST、DELETE、PATCH 或 PUT。查询服务通常只实现通过 GET 查询的资源。
在我们的例子中,这意味着客户端通过命令服务资源 POST 一个新的餐点订单。同样,餐点订单通过查询服务的 GET 资源检索。
这些 HTTP 接口涉及外部通信。内部应用通过使用事件中心发布的事件进行通信。
使用 Apache Kafka 的示例场景
在这个例子中,我将使用 Apache Kafka 作为分布式消息代理,提供高性能和高吞吐量。它是支持发布/订阅方法等多种消息技术的一个例子。
在撰写本文时,Apache Kafka 没有实现所有 JMS 语义。以下示例将使用 Kafka 的供应商特定客户端 API。
Apache Kafka 的发布/订阅方法将消息组织在主题中。它可以配置为启用事务性事件生产者和有序事件消费,这是事件驱动架构需要确保的,以便创建可靠的使用案例。
Kafka 代理是分布式的,并使用所谓的消费者组来管理消息主题和分区。检查 Kafka 的架构超出了本书的范围,建议在选择此技术时进一步查阅其文档。
简而言之,消息被发布到主题中,每个消费者组只消费一次。消费者组包含一个或多个消费者,并保证恰好有一个消费者将处理使用事务性生产者发布的消息。
CQRS 系统需要在多个位置消费消息。对特定主题感兴趣的应用程序将消费消息并更新它们的内部表示。因此,所有这些更新消费者都将收到一个事件。还有使用事件进一步处理业务逻辑的事件处理器。每个主题恰好需要一个事件处理器来处理事件,否则进程可能会多次运行或根本不运行。
因此,Kafka 消费者组的概念被这样使用,即每个应用程序有一个更新消费者组,每个主题有一个事件处理器组。这使所有实例都能接收事件,但可靠地只有一个命令服务来处理业务逻辑。通过这样做,实例能够扩展而不会影响整体系统的结果:

集成 Java EE
为了将 Apache Kafka 集群集成到应用程序中,本示例将使用 Kafka 的 Java API。
应用程序连接到 Kafka,在它们的更新消费者和事件处理器中消费消息。发布事件也是如此。
应用的技术应该从应用程序的其他部分封装起来。为了集成事件,开发者可以使用适合此场景的功能:CDI 事件。
CDI 事件
领域事件包含特定事件的 数据、时间戳以及引用领域实体的标识符。
下面的代码片段展示了抽象的 MealEvent 和 OrderPlaced 事件的示例:
public abstract class MealEvent {
private final Instant instant;
protected MealEvent() {
instant = Instant.now();
}
protected MealEvent(Instant instant) {
Objects.requireNonNull(instant);
this.instant = instant;
}
...
}
public class OrderPlaced extends MealEvent {
private final OrderInfo orderInfo;
public OrderPlaced(OrderInfo orderInfo) {
this.orderInfo = orderInfo;
}
public OrderPlaced(OrderInfo orderInfo, Instant instant) {
super(instant);
this.orderInfo = orderInfo;
}
...
}
像这样的领域事件是应用程序的核心。领域实体表示是从这些事件计算出来的。
将其集成到 Kafka 中确保这些事件通过 CDI 触发。它们被观察在相应的功能中,分别更新状态表示或调用后续命令。
事件处理器
以下代码片段显示了厨师系统的事件处理器,调用命令服务的功能:
@Singleton
public class OrderEventHandler {
@Inject
MealPreparationService mealService;
public void handle(@Observes OrderPlaced event) {
mealService.prepareMeal(event.getOrderInfo());
}
}
事件处理器消费事件并将调用后续餐准备用例的边界。prepareMeal()方法本身将导致零个或多个事件,在这种情况下,要么是MealPreparationStarted,要么是OrderFailedInsufficientIngredients:
public class MealPreparationService {
@Inject
EventProducer eventProducer;
@Inject
IngredientStore ingredientStore;
public void prepareMeal(OrderInfo orderInfo) {
// use ingredientStore to check availability
if (...)
eventProducer.publish(new OrderFailedInsufficientIngredients());
else
eventProducer.publish(new MealPreparationStarted(orderInfo));
}
}
事件生产者将可靠地将事件发布到 Kafka 集群。如果发布失败,整个事件处理必须失败,稍后重试。
状态表示
更新状态表示的消费者也消费 CDI 事件。以下代码片段显示了包含餐订单状态表示的 bean:
@Stateless
public class MealOrders {
@PersistenceContext
EntityManager entityManager;
public MealOrder get(UUID orderId) {
return entityManager.find(MealOrder.class, orderId.toString());
}
public void apply(@Observes OrderPlaced event) {
MealOrder order = new MealOrder(event.getOrderInfo());
entityManager.persist(order);
}
public void apply(@Observes OrderStarted event) {
apply(event.getOrderId(), MealOrder::start);
}
public void apply(@Observes MealDelivered event) {
apply(event.getOrderId(), MealOrder::deliver);
}
private void apply(UUID orderId, Consumer<MealOrder> consumer) {
MealOrder order = entityManager.find(MealOrder.class, orderId.toString());
if (order != null)
consumer.accept(order);
}
}
这个简单的例子代表了关系型数据库中餐订单的状态。一旦新的 CDI 事件到达,订单的状态就会更新。当前状态可以通过get()方法检索。
餐订单领域实体通过 JPA 持久化。它包含通过观察 CDI 事件更新的订单状态:
@Entity
@Table("meal_orders")
public class MealOrder {
@Id
private String orderId;
@Embedded
private MealSpecification specification;
@Enumerated(EnumType.STRING)
private OrderState state;
private MealOrder() {
// required for JPA
}
public MealOrder(OrderInfo orderInfo) {
orderId = orderInfo.getOrderId().toString();
state = OrderState.PLACED;
// define specifications
}
public void start() {
state = OrderState.STARTED;
}
public void deliver() {
state = OrderState.DELIVERED;
}
...
}
消费 Kafka 消息
消息消费的部分封装了消息中心与整个应用程序的其他部分。它是通过在到达的消息上触发 CDI 事件来集成的。这当然特定于 Kafka API,应被视为一个示例解决方案。
更新消费者通过其消费者组连接到特定的主题。启动单例 bean 确保消费者将在应用程序启动时启动。一个容器管理的执行器服务在其自己的线程中运行事件消费者:
@Startup
@Singleton
public class OrderUpdateConsumer {
private EventConsumer eventConsumer;
@Resource
ManagedExecutorService mes;
@Inject
Properties kafkaProperties;
@Inject
Event<MealEvent> events;
@PostConstruct
private void init() {
String orders = kafkaProperties.getProperty("topic.orders");
eventConsumer = new EventConsumer(kafkaProperties,
ev -> events.fire(ev), orders);
mes.execute(eventConsumer);
}
@PreDestroy
public void close() {
eventConsumer.stop();
}
}
应用特定的 Kafka 属性通过 CDI 生产者公开。它们包含相应的消费者组。
事件消费者执行实际的消费:
import org.apache.kafka.clients.consumer.KafkaConsumer;
import java.util.function.Consumer;
import static java.util.Arrays.asList;
public class EventConsumer implements Runnable {
private final KafkaConsumer<String, MealEvent> consumer;
private final Consumer<MealEvent> eventConsumer;
private final AtomicBoolean closed = new AtomicBoolean();
public EventConsumer(Properties kafkaProperties,
Consumer<MealEvent> eventConsumer, String... topics) {
this.eventConsumer = eventConsumer;
consumer = new KafkaConsumer<>(kafkaProperties);
consumer.subscribe(asList(topics));
}
@Override
public void run() {
try {
while (!closed.get()) {
consume();
}
} catch (WakeupException e) {
// will wakeup for closing
} finally {
consumer.close();
}
}
private void consume() {
ConsumerRecords<String, MealEvent> records =
consumer.poll(Long.MAX_VALUE);
for (ConsumerRecord<String, MealEvent> record : records) {
eventConsumer.accept(record.value());
}
consumer.commitSync();
}
public void stop() {
closed.set(true);
consumer.wakeup();
}
}
消费的 Kafka 记录会导致新的 CDI 事件。配置的属性分别使用 JSON 序列化和反序列化器来映射领域事件类。
通过 CDI 触发并成功消费的事件被提交到 Kafka。CDI 事件是同步触发的,以确保在提交之前所有进程都可靠地完成。
生成 Kafka 消息
事件生产者将领域事件发布到消息中心。这是同步发生的,以确保消息在系统中。一旦传输被确认,EventProducer#publish方法的调用返回:
import org.apache.kafka.clients.producer.KafkaProducer;
import org.apache.kafka.clients.producer.Producer;
@ApplicationScoped
public class EventProducer {
private Producer<String, MealEvent> producer;
private String topic;
@Inject
Properties kafkaProperties;
@PostConstruct
private void init() {
producer = new KafkaProducer<>(kafkaProperties);
topic = kafkaProperties.getProperty("topics.order");
producer.initTransactions();
}
public void publish(MealEvent event) {
ProducerRecord<String, MealEvent> record = new ProducerRecord<>(topic, event);
try {
producer.beginTransaction();
producer.send(record);
producer.commitTransaction();
} catch (ProducerFencedException e) {
producer.close();
} catch (KafkaException e) {
producer.abortTransaction();
}
}
@PreDestroy
public void close() {
producer.close();
}
}
详细介绍 Kafka 生产者 API 超出了本书的范围。然而,需要确保事件可靠地发送。事件生产者 bean 封装了这个逻辑。
这些示例演示了集成 Kafka 的一种可能性。
如前所述,Java EE 连接器架构(JCA)是将外部关注点集成到应用程序容器的另一种可能性。在撰写本文时,存在一些供应商特定的容器解决方案,它们通过 JCA 集成消息。对于集成 Kafka 等消息中心现有解决方案是一个有趣的替代方案。然而,建议应用程序开发者将技术特定细节封装到单一责任点,并在应用程序中使用标准的 Java EE 功能。
应用程序边界
CQRS 系统的应用程序通过事件进行内部通信。外部,可以提供其他协议,如 HTTP。
例如,服务员系统的查询和命令功能通过 JAX-RS 公开。命令服务提供放置餐点订单的功能。它使用事件生产者发布结果事件:
public class OrderService {
@Inject
EventProducer eventProducer;
public void orderMeal(OrderInfo orderInfo) {
eventProducer.publish(new OrderPlaced(orderInfo));
}
void cancelOrder(UUID orderId, String reason) {
eventProducer.publish(new OrderCancelled(orderId, reason));
}
void startOrder(UUID orderId) {
eventProducer.publish(new OrderStarted(orderId));
}
void deliverMeal(UUID orderId) {
eventProducer.publish(new MealDelivered(orderId));
}
}
orderMeal() 方法由 HTTP 端点调用。其他方法由服务员系统的事件处理器调用。它们将导致由事件中心传递的新事件。
在这里不直接触发事件或调用内部功能的原因是,此应用程序位于分布式环境中。可能会有其他服务员系统实例消费事件中心并相应地更新它们的表示。
命令服务包含一个 JAX-RS 资源,用于订购餐点:
@Path("orders")
public class OrdersResource {
@Inject
OrderService orderService;
@Context
UriInfo uriInfo;
@POST
public Response orderMeal(JsonObject order) {
OrderInfo orderInfo = createOrderInfo(order);
orderService.orderMeal(orderInfo);
URI uri = uriInfo...
return Response.accepted().header(HttpHeaders.LOCATION, uri).build();
}
...
}
查询服务公开了餐点订单表示。它从数据库中加载域实体的当前状态,如 MealOrders 中所示。查询服务的 JAX-RS 资源使用此功能。
如果服务员系统作为一个单一实例发布,包含命令和查询服务,则可以将这些资源合并。不过,需要确保服务之间不通过事件机制以外的任何方式进行交叉通信。以下代码片段显示了查询服务端点:
@Path("orders")
public class OrdersResource {
@Inject
MealOrders mealOrders;
@GET
@Path("{id}")
public JsonObject getOrder(@PathParam("id") UUID orderId) {
MealOrder order = mealOrders.get(orderId);
if (order == null)
throw new NotFoundException();
// create JSON response
return Json.createObjectBuilder()...
}
}
这些示例并不全面,但旨在让读者了解将 CQRS 概念和消息中心集成到 Java EE 应用程序中。
进一步集成 CQRS 概念
事件源系统的一个好处是,可以重新播放完整的原子事件集,例如,在测试场景中。系统测试针对生产中实际发生的使用案例进行验证。审计日志也是免费的,因为它是应用程序核心的一部分。
此方法还使我们能够更改业务功能并重放某些事件,无论是为了修复错误和纠正行为,还是将事件信息应用于新功能。这使得将新功能应用于事件,仿佛它们从应用程序第一天起就是一部分成为可能。
如果厨师系统添加了持续计算餐食准备平均时间的功能,事件可以被重新投递以重新计算表示。因此,数据库内容将被重置,事件仅重新投递给更新消费者,这导致新的表示被计算并持久化。Kafka 可以显式地重新投递事件。
然而,事件仅用于更新状态表示,在重放期间不会触发新的命令。否则,系统最终会陷入不一致的状态。所展示的示例通过为事件处理器定义一个专用的 Kafka 消费者组来实现这一点,该组不会被重置以重新分配事件给事件处理器。只有更新消费者重新消费事件,以重新计算内部状态表示。
重点是,由于使用了事件溯源,CQRS 系统可以启用更多的用例。捕获和重放事件的可能性,以及包含的上下文和历史信息,使得广泛的应用场景成为可能。
分布式时代的 Java EE
微服务架构和分布式系统自然需要涉及多个单一、单体应用程序的通信。根据选择的协议和通信技术,有许多实现 Java EE 通信的方法。
在实现通信时有一些方面需要考虑。例如,参与微服务系统的外部应用程序需要发现服务实例。为了不紧密耦合应用程序和配置,查找服务应该是动态的,而不是配置静态的主机或 IP 地址。
云原生原则的弹性也关系到通信。由于网络可能随时失败,当连接速度减慢或断开时,应用程序的健康状况不应受到影响。应用程序应该保护自己免受潜在错误传播到应用程序中的影响。
发现服务
服务发现可以通过多种方式发生,从 DNS 查找到更复杂的场景,其中查找是业务逻辑的一部分,根据情况提供不同的端点。它通常封装了从应用程序关注点中对外部系统的寻址。理想情况下,应用程序逻辑仅命名它需要与之通信的逻辑服务,而实际的查找由外部执行。
这取决于所使用的环境和运行时,企业开发者有哪些可能性。容器技术提供了通过名称链接服务的功能,从而从应用程序中移除了工作和责任。客户端通过链接或服务名称作为主机名进行连接,这些名称由容器技术解析。
这种方法适用于 Docker 容器和容器编排,如 Docker Compose、Kubernetes 或 OpenShift。所有通信问题仅使用逻辑服务名称和端口来建立连接。这也符合 12 因素原则。
由于查找工作是在环境中执行的,因此应用程序只需指定所需的服务名称。这对于所有外部通信都适用,例如 HTTP 连接、数据库或消息中心。第五章,Java EE 的容器和云环境展示了这一点的示例。
弹性通信
网络通信不可靠,可能以各种方式中断。连接可能会超时,服务可能不可用,响应缓慢,或者提供意外的答案。
为了不让错误传播到应用程序中,通信需要具有弹性。
验证响应
首先,这意味着客户端验证和错误处理。与使用的通信技术无关,应用程序不能依赖于外部系统提供未损坏或未简单错误的响应。
这并不意味着客户端必须立即拒绝所有不符合应用程序理解的完美响应。包含比预期更多信息或格式略有不同但仍然可理解的响应,不应导致立即错误。遵循“在行动上保守,在接受上宽容”的原则,包含足够信息以供应用程序执行其工作的消息应被接受。例如,JSON 响应中的额外、未知属性不应导致拒绝映射对象。
打破超时和断路
执行同步调用外部系统的客户端会阻塞后续执行,直到外部系统响应。调用可能会失败,减慢执行速度,或者在最坏的情况下,实际上会导致整个应用程序崩溃。在实现客户端时,牢记这一事实至关重要。
首先,客户端连接应始终设置合理的超时时间,正如第三章中类似地展示在实现现代 Java 企业应用程序中。超时可以防止应用程序陷入死锁状态。
如前所述,Java EE 拦截器可以用来防止潜在的运行时异常传播到业务逻辑。
所说的断路器进一步采取了防止级联失败的方法。它们通过定义错误或超时阈值来确保客户端调用安全,并在失败的情况下防止进一步的调用。断路器方法源于电气工程模型,即建筑物中内置的断路器,通过打开电路来截断连接,以防止进一步的损害。
客户端断路器类似地打开其电路,即阻止进一步的调用,以避免损害应用程序或外部系统。断路器通常允许错误和超时在一定范围内发生,然后切断连接一段时间,或者直到电路被手动关闭。
Java EE 应用程序可以通过拦截器实现断路器。它们可以添加复杂的逻辑来决定何时以及如何打开和关闭电路,例如,测量失败次数和超时时间。
以下展示了伪代码中一种可能的断路器方法。拦截器行为被注释到客户端方法中,类似于本书前面展示的客户端拦截器示例:
@Interceptor
public class CircuitBreaker {
...
@AroundInvoke
public Object aroundInvoke(InvocationContext context) {
// close circuit after recovery time
if (circuit.isOpen())
return null;
try {
return context.proceed();
} catch (Exception e) {
// record exception
// increase failure counter
// open circuit if failure exceeds threshold
return null;
}
}
}
类似地,断路器可以测量服务时间,如果服务变得过于缓慢,则打开其电路,除了 HTTP 客户端超时之外。
有一些开源 Java EE 库可用于此目的,例如 Java EE 专家 Adam Bien 的Breakr。它取决于技术要求、逻辑的复杂性、何时打开和关闭电路,以及是否需要第三方依赖项。
为了构建零依赖的应用程序,潜在的库应该安装到容器中,而不是与应用程序工件一起分发。
隔离舱
船只包含隔离舱,将船体分成几个区域。如果船体在某些位置出现泄漏,只有单个区域被水填满,整个船只可能仍然能够浮起。
隔离舱模式将这个想法应用到企业应用程序中。如果应用程序的某些组件失败或由于工作负载而达到容量极限,则其余的应用程序仍然应该能够实现其目的。当然,这高度依赖于业务用例。
一个例子是将业务流程的线程执行与 HTTP 端点分离。应用程序服务器管理一个请求线程池。例如,如果单个业务组件失败并阻止所有传入请求,所有可用的请求线程最终都将被占用。结果是,在其他业务用例中无法调用,因为请求线程不可用。这可能发生在客户端没有实现适当的超时、连接到已关闭的系统并阻止执行的情况下。
使用异步 JAX-RS 资源与专用管理执行器服务可以缓解这个问题。如本书前面所见,JAX-RS 资源可以在单独的、容器管理的线程中调用业务功能,以防止整体执行使用请求线程。多个组件可以使用独立的线程池,这可以防止故障扩散。
由于应用程序服务器负责管理线程,因此应按照 Java EE 标准实现此方法。其理念是定义可注入到所需位置的专用执行器服务。
Adam Bien 开发的开源库 Porcupine 使用这种方法创建专用执行器服务,这些服务使用 ManagedThreadFactory 定义具有容器管理的线程的线程池。这些专用执行器服务可以适当配置和监控。
以下代码片段展示了批量头模式的一个示例,结合了异步 JAX-RS 资源和专用执行器服务:
import com.airhacks.porcupine.execution.boundary.Dedicated;
import java.util.concurrent.ExecutorService;
@Path("users")
@Produces(MediaType.APPLICATION_JSON)
public class UsersResource {
@Inject
@Dedicated("custom-name")
ExecutorService executor;
@GET
public CompletionStage<Response> get() {
return CompletableFuture
.supplyAsync(this::getUsers, executor)
.thenApply(s -> Response.ok(s).build());
}
...
}
业务用例在执行器服务提供的托管线程中执行,以便请求线程返回并处理其他请求。这使得即使这部分过载,其他应用程序功能仍能继续运行,并利用 custom-name 执行器的所有线程。
以下将检查如何配置自定义执行器服务。
握手和推回
另一种以弹性方式通信的方法是 握手 和 背压。其理念是,负载中的通信伙伴通知另一方,然后另一方退后并减轻负载。在这里,握手意味着调用方有方法询问服务是否可以处理更多请求。背压通过在达到限制时通知客户端或推回请求来减少系统负载。
这两种方法的结合形成了一种弹性且有效的通信形式。
应用程序当前负载状态的信息可以通过 HTTP 资源或通过头部字段提供。客户端随后会考虑这些信息。
一种更直接的方法是在服务器资源完全利用时简单地拒绝客户端请求。建议开发者注意池化行为,例如在执行器服务中,以及它们如何处理队列满的情况。异常情况下,建议终止客户端请求,以避免不必要的超时。
以下示例展示了使用 Porcupine 库的场景。一个业务功能通过专用执行器服务执行,该服务将被配置为终止被拒绝的执行。客户端将立即收到 503 服务不可用 的响应,表示当前服务无法处理请求。
JAX-RS 资源与上一个示例类似。custom-name 执行器通过专用配置器配置为终止被拒绝的执行。ExecutorConfigurator 是 Porcupine 库的一部分。以下展示了自定义配置:
import com.airhacks.porcupine.configuration.control.ExecutorConfigurator;
import com.airhacks.porcupine.execution.control.ExecutorConfiguration;
@Specializes
public class CustomExecutorConfigurator extends ExecutorConfigurator {
@Override
public ExecutorConfiguration defaultConfigurator() {
return super.defaultConfigurator();
}
@Override
public ExecutorConfiguration forPipeline(String name) {
if ("custom-name".equals(name)) {
return new ExecutorConfiguration.Builder().
abortPolicy().
build();
}
return super.forPipeline(name);
}
}
由于队列满而被拒绝的执行将导致 RejectedExecutionException。此异常通过 JAX-RS 功能映射:
import java.util.concurrent.RejectedExecutionException; @Provider
public class RejectedExecutionHandler
implements ExceptionMapper<RejectedExecutionException> {
@Override
public Response toResponse(RejectedExecutionException exception) {
return Response.status(Response.Status.SERVICE_UNAVAILABLE)
.build();
}
}
客户端请求如果超出服务器限制,将立即导致错误响应。客户端调用可以考虑到这一点并采取适当的行动。例如,类似断路器模式的功能可以防止客户端立即进行后续调用。
当构建需要满足服务级别协议(SLA)的多个服务场景时,背压(Backpressure)是有帮助的。第九章 监控、性能和日志 将会涵盖这个主题。
更多关于弹性的内容
除了通信的弹性之外,微服务还旨在提高服务质量和服务可用性。应用程序应在出现故障的情况下能够扩展和自我修复。
使用如 Kubernetes 之类的容器编排技术支持这种方法。支持逻辑服务的 Pod 可以扩展以处理更多的负载。服务在容器之间平衡负载。根据集群当前的工作负载,有自动扩展实例上下文的可能性。
Kubernetes 旨在最大化服务正常运行时间。它管理存活性和就绪性探测以检测故障并可能启动新的容器。在部署过程中出现错误时,它将保持当前运行的服务不变,直到新版本能够处理流量。
这些方法由运行时环境管理,不是应用程序的一部分。建议在企业应用程序中尽量减少非功能性、横切关注点。
摘要
分布式系统背后有多种动机。尽管在通信、性能和组织方面引入了某些挑战和开销,但分布式往往是必要的。
为了设计系统景观,需要考虑表示个别责任的系统上下文图。建议设计清晰、简洁的应用程序 API,理想情况下使用标准通信协议实现。在引入破坏性变更之前,工程师以及业务专家需要问自己是否有必要强制客户端功能停止工作。同样,API 应该设计得具有弹性,防止不必要的中断,换句话说:在行动上保守,在接受上宽容。
构建分布式应用程序的工程师需要意识到一致性和可伸缩性之间的权衡。使用涉及外部系统的同步通信的大多数应用程序可能会足够好地扩展。应避免分布式事务。
为了异步通信,应用程序可以基于事件驱动架构。CQRS 原则结合了事件驱动架构和事件源背后的动机。虽然 CQRS 确实提供了有趣的解决方案,但它只有在需要分布式应用程序的情况下才有意义。
微服务架构之间不共享通用的技术或数据。无共享架构可以自由选择实现和持久化技术。以容器形式打包的零依赖 Java EE 应用非常适合微服务。每个应用服务器一个应用的方案与无共享架构的理念相匹配。Java EE 应用在容器编排框架中运行时,支持开发微服务架构的许多方面,例如服务发现、通过超时进行弹性通信、断路器或防波堤。
下一章将涵盖性能、监控和日志记录的主题。
第九章:监控、性能和日志记录
我们已经看到了如何使用 Java EE 构建现代、可扩展和有弹性的微服务。特别是关于向微服务添加弹性和技术横切的部分,这是我们希望进一步探讨的主题。
企业应用程序在远离用户的服务器环境中运行。为了提供对系统的洞察,我们需要增加可见性。有多种方法可以实现这一方面的遥测,包括监控、健康检查、跟踪或日志记录。本章涵盖了每种方法的理由以及哪些对企业应用程序是有意义的。
在本章中,我们将涵盖以下主题:
-
商业和技术指标
-
集成 Prometheus
-
如何满足性能需求
-
Java 性能诊断模型
-
监控和采样技术
-
为什么传统的日志记录是有害的
-
在现代世界中监控、日志记录和跟踪
-
性能测试的适用性
商业指标
在业务流程中的可见性对于业务相关人员来说至关重要,以便看到并解释企业系统内部发生的事情。与业务相关的指标允许评估流程的有效性。如果没有对流程的可见性,企业应用程序就像一个黑盒。
与业务相关的指标是业务专家的无价资产。它们提供了关于用例如何执行的具体领域信息。显然,哪些指标是有兴趣的取决于领域。
每小时能创建多少辆车?销售了多少篇文章以及金额是多少?转换率是多少?有多少用户参与了电子邮件营销活动?这些都是领域特定关键性能指标的例子。业务专家必须为特定领域定义这些指标。
企业应用程序必须发出来自业务流程中各个点的信息。这种信息的性质取决于实际领域。在许多情况下,业务指标源于执行业务流程期间发生的领域事件。
以每小时创建的汽车数量为例。汽车创建用例发出相应的CarCreated领域事件,这些事件被收集以供未来统计。而计算转换率则涉及更多的信息。
业务专家必须定义关键性能指标的含义和来源。定义和收集这些指标成为用例的一部分。发出这些信息是应用程序的责任。
区分业务驱动和技术驱动的指标非常重要。尽管业务指标提供了高价值的见解,但它们直接受到技术指标的影响。一个技术指标示例是服务响应时间,它反过来又受到其他技术指标的影响。子章节技术指标将进一步探讨此主题。
因此,业务专家不仅必须关注监控的业务方面,还要关注应用程序响应性的技术影响。
收集业务指标
与业务相关的指标允许业务专家评估企业系统的有效性。这些指标为业务领域的特定部分提供了有价值的见解。应用程序负责在其用例中收集与业务相关的指标。
例如,汽车制造包执行的业务逻辑可以发出某些指标,例如每小时创建的汽车数量。
从业务角度来看,相关的指标通常源自领域事件。建议在汽车成功制造后,立即在用例中定义和发出领域事件,例如CarCreated。这些事件被收集并用于以特定业务指标的形式推导更多信息。
CarCreated事件在边界作为 CDI 事件触发,并可以在单独的统计收集器中观察到。以下代码片段展示了作为用例一部分触发的事件:
@Stateless
public class CarManufacturer {
@Inject
CarFactory carFactory;
@Inject
Event<CarCreated> carCreated;
@PersistenceContext
EntityManager entityManager;
public Car manufactureCar(Specification spec) {
Car car = carFactory.createCar(spec);
entityManager.merge(car);
carCreated.fire(new CarCreated(spec));
return car;
}
}
边界触发通知成功创建汽车的 CDI 事件。相应的处理与业务流程解耦,并且在此处不涉及任何其他逻辑。该事件将在单独的应用程序作用域 bean 中观察到。同步 CDI 事件可以定义在特定事务阶段进行处理。以下事务观察者确保只有成功的数据库事务被测量:
import javax.enterprise.event.TransactionPhase;
@ApplicationScoped
public class ManufacturingStatistics {
public void carCreated(@Observes(during =
TransactionPhase.AFTER_SUCCESS) Specification spec) {
// gather statistics about car creation with
// provided specification
// e.g. increase counters
}
}
事件信息被收集并进一步处理,以提供业务指标。根据情况,可能需要更多与业务相关的数据。
将相关信息建模为领域事件与业务定义相匹配,并解耦了用例与统计计算。
除了定义领域事件外,根据情况和要求,信息还可以通过横切组件,如拦截器进行收集。在最简单的情况下,指标被仪器化和收集在原语中。应用程序开发人员必须考虑 bean 作用域,以避免因作用域不正确而丢弃收集到的数据。
发出指标
度量通常不会在应用程序中持久化,而是在环境中的另一个系统中,例如外部监控解决方案。这简化了度量的实现;企业应用程序将信息保留在内存中并发射指定的度量。外部监控解决方案抓取和处理这些度量。
可以使用几种技术来发射和收集度量。例如,度量可以格式化为自定义 JSON 字符串,并通过 HTTP 端点公开。
作为云原生计算基金会的一部分的监控解决方案,并且截至目前,它具有巨大的动力,是Prometheus。Prometheus 是一种监控和警报技术,它抓取、高效存储和查询时间序列数据。它收集通过 HTTP 以特定格式发射的某些服务的数据。Prometheus 在抓取和存储数据方面非常强大。
对于与业务相关的信息和图表,可以在其基础上构建其他解决方案。与 Prometheus 兼容且提供许多吸引人图表可能性的技术是Grafana。Grafana 本身不存储时间序列数据,而是使用 Prometheus 等源查询和显示时间序列。
下面的截图显示了 Grafana 仪表板的示例:

仪表板的概念为业务专家提供了可见性,并综合了相关信息。根据需求和动机,相关信息被组合成图表,提供概述和洞察。仪表板提供了根据目标群体查询和自定义时间序列表示的能力。
进入 Prometheus
以下示例显示了如何将 Prometheus 集成到 Java EE 中。这是可能的监控解决方案之一,旨在给读者一个如何巧妙地集成与业务相关的度量的想法。
应用程序将以 Prometheus 输出格式发射收集到的度量。Prometheus 实例抓取并存储这些信息,如下面的图所示:

开发者可以实现自定义功能来收集和发射信息,或者使用 Prometheus 的 Client API,该 API 已经包含了几种度量类型。
如下所示,有多个 Prometheus 度量类型:
-
最常用的一个是计数器,它代表一个递增的数值。它计算发生的事件。
-
仪表是一个上下波动的数值。它可以用来衡量诸如转化率、温度或周转率等值。
-
直方图和摘要是更复杂的度量类型,用于在桶中对观测值进行采样。它们通常观察度量分布。例如,制造一辆汽车需要多长时间,这些值的变化有多大,以及它们的分布情况如何?
Prometheus 度量有一个名称和标签,标签是一组键值对。时间序列由度量的名称和一组标签来识别。标签可以看作是参数,将整体信息量进行分片。
使用标签的计数器度量表示示例为 cars_manufactured_total{color="RED", engine="DIESEL"}。cars_manufactured_total 计数器包括由其颜色和发动机类型指定的所有制造汽车的总数。收集的度量可以在以后查询提供的标签信息。
使用 Java EE 实现
以下统计实现观察了之前指定的域事件并将信息存储在 Prometheus 计数器度量中:
import io.prometheus.client.Counter;
@ApplicationScoped
public class ManufacturingStatistics {
private Counter createdCars;
@PostConstruct
private void initMetrics() {
createdCars = Counter.build("cars_manufactured_total",
"Total number of manufactured cars")
.labelNames("color", "engine")
.register();
}
public void carCreated(@Observes(during =
TransactionPhase.AFTER_SUCCESS) Specification spec) {
createdCars.labels(spec.getColor().name(),
spec.getEngine().name()).inc();
}
}
计数器度量被创建并注册到 Prometheus 客户端 API。测量的值由汽车的颜色和 engine 类型进行限定,这些类型在抓取值时被考虑在内。
为了发出此信息,可以包含 Prometheus servlet 库。这以正确的格式输出所有已注册的度量。监控 servlet 通过 web.xml 进行配置。也可以通过访问 CollectorRegistry.defaultRegistry 来包含一个 JAX-RS 资源以发出数据。
发出的输出将类似于以下内容:
...
cars_manufactured_total{color="RED", engine="DIESEL"} 4.0
cars_manufactured_total{color="BLACK", engine="DIESEL"} 1.0
Java EE 组件,如 CDI 事件,以简洁的方式支持开发者在应用程序中集成域事件度量。在前面的示例中,ManufacturingStatistics 类是唯一依赖于 Prometheus API 的点。
强烈建议将 Prometheus 客户端 API 作为单独的容器镜像层包含,而不是包含在应用程序工件中。
监控解决方案抓取并进一步处理提供的信息,以收集所需的业务度量。随着时间的推移抓取制造汽车的计数器会导致每小时创建的汽车数量。此度量可以查询汽车的总数,以及特定颜色和发动机组合。定义业务度量的查询也可以根据需求进行适应和优化。理想情况下,应用程序会发出所需的原子业务相关度量。
环境集成
应用程序通过 HTTP 发出业务相关的度量。Prometheus 实例抓取并存储这些数据,并通过查询、图形和外部解决方案(如 Grafana)使其可用。
在容器编排中,Prometheus 实例在集群内部运行。这消除了配置外部可访问监控端点的必要性。Prometheus 与 Kubernetes 集成以发现应用程序实例。由于每个应用程序实例分别发出其监控度量,Prometheus 需要单独访问每个应用程序 pod,从而累积所有实例的信息。
Prometheus 配置存储在配置映射中或作为基础镜像的一部分。实例配置为每n秒访问一次应用程序和导出器,以抓取时间序列。有关配置 Prometheus 的详细信息,请参阅其当前文档。
这是将业务监控集成到云原生应用程序中的一种可能解决方案。
建议用业务用例中出现的域事件来表示与业务相关的指标。将所选的监控解决方案集成应从域逻辑中透明地完成,而不应过度依赖供应商。
在分布式系统中满足性能要求
响应性是企业应用程序的一个重要非技术性要求。只有当客户端请求能在合理的时间内得到服务时,系统才提供业务价值。
在分布式系统中满足性能要求需要考虑所有参与的应用程序。
企业应用程序通常需要满足服务水平协议(SLA)。SLA 通常定义了可用性或响应时间的阈值。
服务水平协议
为了计算和满足 SLA,考虑哪些流程和应用程序包含在业务用例中非常重要,特别是在同步通信方面。同步调用外部系统的应用程序的性能直接取决于这些调用的性能。如前所述,应避免分布式事务。
根据其本质,只有当所有应用程序都表现良好并协同工作时,才能满足 SLA。每个应用程序都会影响依赖系统的 SLA。这不仅涉及系统中最慢的应用程序,还涉及所有参与的服务。
例如,如果包括对两个应用程序的同步调用,每个应用程序都保证 99.995%的可用性,那么根据定义,达到 99.995%的可用性是不可能的。结果 SLA 是 99.99%,每个参与系统的值相乘。
对于保证响应时间也是如此。每个涉及的系统都会减慢整体响应速度,导致总响应时间是所有 SLA 时间的总和。
在分布式系统中实现 SLA
让我们看看如何在分布式系统中实现 SLA 的例子,假设企业应用程序位于一个高性能场景中,其中满足保证响应时间是至关重要的。该应用程序同步与一个或多个提供必要信息的后端系统通信。整个系统需要满足 200 毫秒的 SLA 响应时间。
在这种场景下,后端应用程序通过应用背压和预防性地拒绝无法满足保证 SLA 的请求来支持满足 SLA 时间。这样做,原始应用程序有机会使用可能及时响应的其他后端服务。
为了适当地配置连接池,工程师需要知道后端系统的平均响应时间,这里为 20 毫秒。相应的业务功能通过使用专门的托管执行器服务定义一个专用的线程池。线程池可以单独配置。
配置是通过遵循一些步骤来实现的:工程师配置线程池的最大限制加上最大队列大小,以确保 SLA 时间是平均响应时间的n倍。这里的n,即10,是系统一次将处理的请求数量的最大值,包括最大池大小和最大队列大小限制。任何超出这个数字的请求都会被服务暂时不可用响应立即拒绝。这是基于计算,如果当前处理的请求数量超过n,新请求很可能会超过计算出的 200 毫秒的 SLA 时间。
立即拒绝请求听起来像是一种严厉的回应,但通过这样做,客户端应用程序有机会重试不同的后端,而无需在单次调用中浪费整个 SLA 时间。这是一个在多个后端的高性能场景中的案例,其中满足 SLA 具有很高的优先级。
这种场景的实现与上一章中的背压示例类似。如果第一次调用失败,客户端会使用不同的后端作为后备。这隐式地使客户端具有弹性,因为它使用多个后端作为后备。后端服务隐式地应用了隔离舱模式。单个不可用的功能不会影响应用程序的其他部分。
解决性能问题
技术指标,如响应时间、吞吐量、错误率或可用时间,表明系统的响应性。只要应用程序的响应性在可接受的范围内,就没有其他指标需要考虑。性能不足意味着系统的服务级别协议(SLA)没有得到满足,也就是说响应时间过高或客户端请求失败。那么问题来了:需要改变什么来改善这种情况?
约束理论
如果系统所需的负载增加,理想情况下吞吐量也会增加。约束理论基于这样一个假设,即至少有一个约束将限制系统的吞吐量。因此,约束或瓶颈导致性能下降。
就像链条的强度取决于最薄弱的环节一样,限制性资源限制了系统的整体性能或某些功能的性能。它阻止了应用程序在其他资源未充分利用的情况下处理更多的负载。只有通过增加限制性资源的流量,即消除瓶颈,才能提高吞吐量。如果系统是围绕瓶颈进行优化的,而不是消除它,那么整体系统的响应性不会提高,甚至可能降低。
因此,确定瓶颈至关重要。在针对限制性瓶颈之前,整体性能不会提高。
例如,将更多的 CPU 功率投入到高 CPU 利用率的程序中可能无法帮助实现更好的性能。也许程序表现不佳是因为其他根本原因,而不仅仅是 CPU 不足。
这里重要的是要提到,限制性约束可能位于应用程序之外。在单一、单体应用程序中,这包括硬件和操作系统,以及所有运行进程。如果其他在同一硬件上运行的软件大量使用网络适配器,那么应用程序的网络 I/O 和整体性能也会受到影响,即使根本原因,即限制性约束,不在应用程序的责任范围内。
因此,检查性能问题需要考虑的不仅仅是应用程序本身。在同一硬件上运行的整个进程集都可能影响应用程序的性能,这取决于其他进程如何利用系统资源。
在分布式环境中,性能分析还涉及所有相互依赖的应用程序,以及它们之间的通信。为了确定限制性资源,必须考虑系统的整体情况。
由于应用程序是相互连接的,提高单个系统的响应性将影响其他系统,甚至可能降低整体响应性。再次强调,试图改善错误方面,如围绕瓶颈进行优化,不仅不会提高,反而很可能会降低整体性能。连接到代表瓶颈的外部系统的应用程序,会对外部系统施加一定的压力。如果调整的是应用程序的性能,而不是外部应用程序,那么对后者的负载和压力会增加,这最终会导致整体响应性变差。
在分布式系统中,所有相互依赖的应用程序都涉及其中,这使得解决性能问题变得更加复杂。
使用 jPDM 识别性能回归
Java 性能诊断模型(jPDM)是一个性能诊断模型,它抽象了系统的复杂性。它有助于解释系统的性能计数器,从而理解我们为什么经历性能退化的根本原因。
识别性能退化的挑战在于,特定的场景是无数影响因素的结果,其中许多因素是与应用程序外部相关的。jPDM 及其相关方法有助于处理这种复杂性。
在响应性方面,有无数的事情可能会出错,但它们将以有限的方式出错。因此,性能退化可以分类为不同的表现形式。将会有一些典型的问题形式,在无数、不同的场景和根本原因中涌现。为了识别不同的类别,我们将利用诊断模型。
jPDM 识别我们系统的重要子系统、它们的角色、功能和属性。子系统之间相互交互。该模型有助于识别测量子系统活动水平和交互的工具。有助于研究和分析系统及情况的性能、方法和流程,都包含在这个模型中。
子系统
Java 应用程序环境中的子系统包括:操作系统(包括硬件)、Java 虚拟机、应用程序和演员。子系统利用它们相应的底层子系统来执行它们的任务。
下图显示了 jPDM 子系统之间是如何相互作用的:

演员
演员在广义上是指系统的用户。这包括最终用户、批处理过程或外部系统,具体取决于用例。
通过使用该系统,演员将生成工作负载。演员的特性包括负载因子,即涉及多少用户,以及速度,即用户请求的处理速度。这些特性会影响整体情况,类似于所有其他子系统的特性。
演员本身并不代表性能问题,他们只是使用应用程序。话虽如此,如果系统的性能未达到预期,限制性约束不应在演员中寻找;演员及其生成的工作负载是系统必须处理的情境的一部分。
应用程序
企业应用程序包含业务逻辑算法。部分业务用例包括分配内存、调度线程和使用外部资源。
该应用程序将使用框架和 Java 语言功能来实现这一点。它最终直接或间接地使用 JVM 代码和配置,这样应用程序就对 JVM 产生了一定的负载。
JVM
Java 虚拟机(JVM)解释并执行应用程序的字节码。它负责内存管理——包括分配以及垃圾回收。为了提高程序的性能,已经实施了许多优化技术,例如 Java HotSpot 性能引擎的即时编译(JIT)。
JVM 利用操作系统资源来分配内存、运行线程或使用网络或磁盘 I/O。
操作系统和硬件
计算机的硬件组件,如 CPU、内存、磁盘和网络 I/O,定义了系统的资源。它们包含某些属性,例如容量或速度。
由于硬件组件代表不可共享的资源,操作系统在进程之间分配硬件。操作系统提供系统级资源并为 CPU 调度线程。
因此,该模型考虑了整个系统,包括硬件。企业应用程序可能不会单独在系统硬件上运行。其他进程使用硬件组件,从而影响应用程序的性能。同时尝试访问网络的多个进程将导致比独立运行每个进程更差的响应性。
jPDM 实例—生产情况
生产系统中的特定情况是 jPDM 模型的实例。它们包含所有它们的属性、特征和特定的瓶颈。
任何一个子系统的任何变化都会导致具有不同属性和特征的不同情况,从而在模型中产生不同的实例。例如,改变系统负载可能会导致完全不同的瓶颈。
这也是为什么在生产环境之外的环境中进行性能测试可能会导致潜在的不同瓶颈。不同的环境至少有一个不同的操作系统和硬件情况,不一定是在使用的硬件和配置,而是在整个操作系统进程的状态。因此,模拟场景,如性能测试,不允许得出关于瓶颈或性能优化的结论。它们代表了一个不同的 jPDM 实例。
由于我们使用该模型来最终分析性能问题,以下方法只有在存在实际性能问题时才有意义。如果没有问题,即定义的 SLA 得到满足,就没有什么需要调查或采取行动的。
分析 jPDM 实例
jPDM 用于协助调查性能回归。模型之外的策略、流程和工具有助于识别限制性约束。
每个子系统都有其独特的属性和资源集,在系统中扮演着特定的角色。我们使用特定的工具来暴露特定的性能指标并监控子系统之间的交互。
回顾约束理论,我们想要调查生产情况中的限制约束,即 jPDM 的一个实例。工具有助于进行调查。在调查中考虑整体系统是很重要的。硬件由所有操作系统进程共享。因此,主导性可能是由单个进程或在该硬件上运行的所有进程的总和引起的。
首先,我们调查 CPU 的主导消费者以及 CPU 的利用率。CPU 消耗模式将引导我们到包含瓶颈的子系统。
为了调查主导消费者,我们使用决策树。它指示 CPU 时间花费在哪里——在内核空间、用户空间还是空闲状态。以下图表显示了决策树:

图中的圆形节点代表 CPU 的主导消费者。颜色代表 jPDM 子系统。遵循决策树将引导我们到包含瓶颈的子系统。确定子系统将性能回归缩小到特定类别。然后我们使用进一步的工具来分析 jPDM 的实例,即实际情况。
由于性能问题可能源于无数的事物,我们需要遵循一个过程来缩小原因。如果我们不遵循一个过程,而是盲目地“窥视和戳探”或猜测,我们不仅会浪费时间和精力,而且可能会错误地将症状识别为实际的限制因素。
CPU 的主导消费者表示 CPU 时间花费在哪里。这是调查情况的重要信息。仅仅查看 CPU 的整体利用率是不够的。仅此信息既不能给我们提供很多关于瓶颈存在与否的证据,也不能帮助我们找到主导消费者。CPU 使用率为 60%并不能告诉我们 CPU 是否是限制资源,也就是说,增加更多的 CPU 是否会提高整体响应速度。需要更详细地分析 CPU 时间。
首先,我们来看 CPU 用户时间和系统时间之间的比率。这表明 CPU 时间是否在内核中花费的时间比预期更长,从而判断操作系统是否是 CPU 的主导消费者。
主导消费者 - 操作系统
当操作系统被要求比通常应该工作得更努力时,它主导了 CPU 的消耗。这意味着过多的 CPU 时间被用于资源和管理设备。这包括网络和磁盘 I/O、锁、内存管理或上下文切换。
如果 CPU 系统时间超过用户时间的某个百分比值,操作系统是主导消费者。jPDM 根据分析无数生产情况的经验,将 10%作为阈值值。这意味着如果 CPU 系统时间超过用户时间的 10%,瓶颈包含在操作系统子系统内。
在这种情况下,我们使用操作系统工具进一步调查问题,例如vmstat、perf、netstat等。
例如,一个企业应用程序通过执行大量单独查询检索数据库条目,给操作系统管理所有这些数据库连接带来了很大压力。建立每个网络连接所花费的额外开销最终会主导整个系统,并代表系统中的约束资源。因此,调查这种情况显示了在内核中建立网络连接时花费了大量 CPU 时间。
主导消费者 - 无
如果 CPU 时间没有确定操作系统是主导消费者,我们继续遵循决策树并分析 CPU 是否空闲。如果是这种情况,这意味着还有 CPU 时间可用,但无法消耗。
由于我们正在分析 SLA 未满足的情况,即整体系统在给定负载下表现不佳,一个充分饱和的情况将充分利用 CPU。因此,空闲 CPU 时间表明存在活跃性问题。
需要调查的是为什么线程没有被操作系统调度。这可能有多个原因,例如空连接或线程池、死锁情况或响应缓慢的外部系统。线程的状态将指示约束的原因。我们再次使用操作系统工具来调查这种情况。
这种问题类别的一个例子是当同步访问响应缓慢的外部系统时。这将导致等待网络 I/O 的线程无法运行。这与主导操作系统消耗的区别在于,线程不是积极执行工作而是在等待被调度。
主导消费者 - JVM
到目前为止,主导消费者不包含在应用程序或 JVM 子系统内。如果 CPU 时间没有过多地花费在内核或空闲,我们开始在 JVM 中进行调查。
由于 JVM 负责内存管理,其性能将指示潜在的内存问题。主要垃圾收集(GC)日志,以及JMX工具帮助调查场景。
内存泄漏会导致内存使用量增加和垃圾收集器运行过度,占用 CPU 资源。不高效的内存使用同样会导致垃圾收集过度。GC 执行最终导致 JVM 成为 CPU 的主导消费者。
这又是为什么遵循 jPDM 决策树流程很重要的另一个例子。性能问题出现在高 CPU 使用率中,尽管在这种情况下实际的瓶颈是内存泄漏。
到目前为止,性能问题的主要原因是与内存相关,主要是由于应用程序日志导致的广泛字符串对象创建。
主消费者—应用程序
如果 JVM 分析没有表明内存问题,最终应用程序是 CPU 的主消费者。这意味着应用程序代码本身负责瓶颈。特别是运行复杂算法过多的应用程序过度利用 CPU。
与应用程序相关的分析将导致以下结论:问题起源于应用程序的哪个部分以及问题可能如何解决。这意味着应用程序要么包含次优代码,要么在给定资源下达到了可能的极限,最终需要水平或垂直扩展。
结论
解决性能问题的方法是首先通过遵循特定流程来调查情况,尝试通过调查来表征回归。在确定了限制性资源之后,再采取进一步步骤来解决问题。在可能修复了情况之后,需要在生产中进行重复测量。重要的是不要在没有验证这些更改确实提供了预期结果的情况下更改行为或配置。
jPDM 方法通过应用统一的解决流程,不考虑应用程序代码来调查性能回归。
需要哪些工具和指标来应用这种方法?
根据生产中的系统,操作系统自带工具以及与 Java 运行时相关的工具都是有用的。由于所有方面都考虑了操作系统级别的整体系统,而不仅仅是应用程序本身,因此操作系统工具和底层指标比特定于应用程序的工具更有帮助。
然而,应用程序的技术指标,如响应时间或吞吐量,是首先关注的焦点,它们表明了应用程序的服务质量。如果这些指标表明存在性能问题,那么使用底层指标和工具进行调查是有意义的。
下一节将探讨如何收集应用程序的技术指标。
技术指标
技术指标表明系统的负载和响应能力。这些指标的典型例子包括响应时间以及吞吐量,通常分别以每秒请求数或事务数的形式收集。它们提供了关于整体系统当前性能的信息。
这些指标最终将对其他与业务相关的指标产生影响。同时,正如我们在上一节中看到的,这些指标只是指标,并且自身受到许多其他技术方面的影响,即 jPDM 子系统的所有属性。
因此,应用程序的性能受到许多技术因素的影响。那么,除了响应时间、吞吐量、错误率和正常运行时间之外,还应该合理收集哪些技术指标呢?
技术指标的类型
技术指标主要关注应用程序服务的质量,如响应时间或吞吐量。它们是代表应用程序响应性的指标,可能指出潜在的性能问题。这些信息可以用来创建关于趋势和应用程序高峰的统计数据。
这种洞察增加了及时预见潜在故障和性能问题的可能性。这在技术上等同于对其他方面是黑盒系统的业务洞察。这些指标本身并不能得出关于性能问题的根本原因或约束资源的可靠结论。
低级技术信息包括资源消耗、线程、池化、事务或会话。再次强调,仅凭这些信息并不能直接指导工程师找到潜在的瓶颈。
如前所述,有必要检查在特定硬件上运行的所有内容的整体情况。操作系统信息是最佳的信息来源。为了解决性能问题,操作系统以及应用程序工具都需要考虑这一点。
这并不意味着应用程序或 JVM 运行时发出的技术信息毫无价值。应用程序特定的指标可以帮助解决性能问题。重要的是要记住,仅凭这些指标可能会导致关于系统性能调整时约束资源的错误假设。
高频监控与采样
通常,监控的目的是以每秒多次收集的方式收集高频技术指标。这种高频收集的问题在于它严重影响了系统的性能。即使没有性能退化,指标也可能被收集。
正如之前提到的,仅凭应用程序级别的指标,如资源消耗,在识别潜在的性能约束方面帮助不大。同样,收集数据会干扰系统的响应性。
与高频监控相比,建议以较低频率采样指标,例如每分钟仅采样几次。统计总体背后的理论表明,这些少量样本足以代表总体数据。
采样信息应尽可能少地影响应用程序的性能。所有后续的调查、指标查询或计算都应该在带外进行;也就是说,外包给一个不会影响运行应用程序的系统。因此,从存储、查询和显示信息中分离出采样信息的担忧。
收集技术指标
应用程序是收集技术指标的好地方,理想情况下是在系统边界处。同样有可能在潜在的代理服务器中收集这些指标。
应用程序服务器已经发出了一些技术相关的指标,例如关于资源消耗、线程、连接池、事务或会话的信息。一些解决方案还提供了 Java 代理,用于采样和发出技术相关的信息。
传统上,应用程序服务器需要通过 JMX 提供技术相关的指标。这个功能是管理 API 的一部分,但在项目中从未被广泛使用。其中一个原因是模型和 API 相当繁琐。
然而,提到 Java EE 应用程序服务器需要收集和提供其资源的数据是有帮助的。容器通过 JMX 发出这些信息。有几种方法可以抓取这些信息。
有所谓的导出器可用,这些应用程序要么作为独立运行,要么作为Java 代理运行,它们访问 JMX 信息并通过 HTTP 发出。例如,Prometheus JMX 导出器,它以前面的类似格式导出信息,就是这种方法的例子。这种方法的好处是它不会向应用程序添加依赖项。
Java 代理的安装和配置是在应用程序服务器中完成的,在基本容器镜像层中。这再次强调了容器不应将应用程序的工件与实现细节耦合的原则。
边界指标
专门针对应用程序的指标,例如响应时间、吞吐量、正常运行时间或错误率,可以在系统边界处收集。这可以通过拦截器或过滤器来实现,具体取决于情况。与 HTTP 相关的监控可以通过 servlet 过滤器收集,适用于任何基于 servlet 技术的技术,例如 JAX-RS。
以下代码片段显示了一个 servlet 过滤器,它收集了 Prometheus 直方图指标中的响应时间和吞吐量:
import javax.servlet.*;
import javax.servlet.annotation.WebFilter;
import javax.servlet.http.HttpServletRequest;
@WebFilter(urlPatterns = "/*")
public class MetricsCollectorFilter implements Filter {
private Histogram requestDuration;
@Override
public void init(FilterConfig filterConfig) throws ServletException {
requestDuration = Histogram.build("request_duration_seconds",
"Duration of HTTP requests in seconds")
.buckets(0.1, 0.4, 1.0)
.labelNames("request_uri")
.register();
}
public void doFilter(ServletRequest req, ServletResponse res,
FilterChain chain) throws IOException, ServletException {
if (!(req instanceof HttpServletRequest)) {
chain.doFilter(req, res);
return;
}
String url = ((HttpServletRequest) req).getRequestURI();
try (Histogram.Timer ignored = requestDuration
.labels(url).startTimer()) {
chain.doFilter(req, res);
}
}
@Override
public void destroy() {
// nothing to do
}
}
此指标与之前提到的与业务相关的示例类似注册,并通过 Prometheus 输出格式发出。直方图桶收集四个桶中的时间,指定的时间为 0.1、0.4 或 1.0 秒,以及以上所有时间。这些桶配置需要根据 SLA 进行调整。
servlet 过滤器在所有资源路径上都是激活的,并将收集每个路径的统计数据。
记录和跟踪
从历史上看,日志在企业应用程序中具有相当高的重要性。我们看到了许多日志框架的实现和关于如何实现合理日志的所谓最佳实践。
日志通常用于调试、跟踪、日志记录、监控和输出错误。一般来说,所有开发者认为相对重要但未向用户明确显示的信息都被放入日志中。在几乎所有情况下,这包括将日志记录到文件中。
传统日志的不足
这种在企业项目中非常常见的方法带来了一些问题。
性能
传统的日志记录,尤其是广泛使用的日志调用,会创建大量的字符串对象。即使是旨在减少不必要的字符串连接的 API,如Slf4J,也会导致高内存率。所有这些对象在使用后都需要进行垃圾回收,这会利用 CPU。
将日志事件存储为字符串消息是一种冗长的信息存储方式。选择不同的格式,主要是二进制格式,将大大减少消息大小,并导致更高效的内存消耗和更高的吞吐量。
存储在缓冲区或直接在磁盘上的日志消息需要与其他日志调用同步。同步日志记录器最终会导致文件在单个调用中写入。所有同时进行的日志调用都需要同步,以确保记录的事件按正确顺序出现。这间接地将原本完全无关的功能耦合在一起,降低了本质上独立的函数的并行性,并对整体性能产生负面影响。随着日志消息数量的增加,由于同步导致的线程阻塞概率也会增加。
另一个问题在于,日志框架通常不会直接将日志消息写入磁盘;相反,它们使用多层缓冲。这种优化技术伴随着一定的管理开销,这并不会改善情况。建议同步文件操作尽可能与最小开销的层一起工作。
位于 NFS 存储上的日志文件会进一步降低整体性能,因为写操作会两次击中操作系统 I/O,涉及文件系统和网络调用。为了管理和持久化日志文件,网络存储通常是首选解决方案,特别是对于需要持久卷的容器编排。
通常,经验表明,日志对应用程序性能的影响最大。这主要是因为字符串日志消息对内存的影响。
日志级别
日志解决方案包括通过日志级别指定日志条目的重要性,例如 debug、info、warning 或 error。开发者可能会问自己为特定调用选择哪个日志级别。
有几个层级的做法听起来确实合理,因为生产系统可以指定比开发运行更高的日志级别,这样就不会在生产中产生太多数据。
这种情况下的挑战是,在生产环境中,通常在需要时没有可用的调试日志信息。可能需要额外洞察的错误情况没有这方面的信息。包含跟踪信息的调试或跟踪日志级别被关闭。
选择日志级别始终是在关于应包含哪些信息之间进行权衡。在开发中进行调试最好使用实际的调试工具,这些工具连接到正在运行的应用程序,可能是在远程。调试或跟踪日志在生产环境中通常不可用,因此提供的益处很少。
虽然定义多个日志级别可能源于良好的意图,但在生产系统中的实际使用添加的价值很少。
日志格式
传统的日志解决方案指定特定的日志布局,该布局将日志消息格式化到生成的日志文件中。应用程序需要管理创建、滚动和格式化与业务逻辑无关的日志文件。
许多企业应用程序都附带第三方日志依赖项,这些依赖项实现了这种功能,但没有任何业务价值。
选择特定的纯文本日志格式是应用程序开发者需要做出的另一个决定。在可由人类和机器读取的日志条目格式和系统性能之间存在着权衡。结果通常是双方都不满意的最差妥协;既难以阅读又对系统性能有巨大影响的字符串日志格式。
选择以最高密度存储信息的二进制格式会更合理。然后,人类可以使用工具使消息可见。
数据量
大量日志引入了包含在日志文件中的大量数据。特别是,用于调试和跟踪目的的日志会导致大文件,这些文件难以解析且成本高昂。
通常,解析日志格式会引入不必要的开销。可能具有技术相关性的信息首先以特定格式序列化,只是为了在稍后检查日志时再次解析。
在本子节稍后,我们将看到其他有哪些解决方案。
混淆
与不合理检查的异常处理一样,日志会模糊源代码中的业务逻辑。这在许多项目中常见的样板日志模式中尤其如此。
日志语句在代码中占用太多空间,尤其是会吸引开发者的注意力。
一些日志技术,如 Slf4j,提供了以可读方式格式化字符串的功能,同时避免了立即的字符串连接。但是,日志语句仍然添加了与业务问题无关的模糊调用。
如果在横切组件(如拦截器)中添加调试日志语句,情况显然就不太一样了。然而,这些情况大多是为了跟踪目的而添加日志。我们将在下一小节中看到,有更多适合这种需求的解决方案。
应用程序的担忧
正如我们在 12 因子应用程序中看到的,选择日志文件和消息格式不是应用程序的担忧。
尤其是那些承诺提供更简单日志解决方案的日志框架,会将技术驱动的第三方依赖项添加到应用程序中;这些依赖项没有直接的业务价值。
如果事件或消息具有业务价值,那么应该优先考虑使用其他解决方案。以下展示了传统日志是如何被误用于这些其他应用程序的担忧。
技术选择不当
传统日志,以及它在大多数企业项目中的使用方式,对于更适合使用不同方法处理的问题来说,是一个次优选择。
问题是:开发者到底想记录什么?比如,关于当前资源消耗的指标?或者与业务相关的信息,比如制造的汽车?我们应该记录像由应用程序 A 发起,调用后续应用程序 B 的请求这样的调试和跟踪信息吗?关于发生的异常呢?
仔细阅读的读者会发现,对于传统日志记录的大多数用例,使用其他方法处理会更好。
如果日志用于调试或调试跟踪应用程序,使用跟踪或调试级别的做法帮助不大。在生产环境中不可用的信息无法重现潜在的错误。然而,在生产环境中记录大量调试或跟踪事件,由于磁盘 I/O、同步和内存消耗,将影响应用程序的响应性。调试与并发相关的错误甚至可能导致不同的结果,因为执行顺序已被修改。
对于调试功能,在开发期间使用实际的调试器功能(如连接到运行中的应用程序的 IDE)更为可取。用于业务动机的日志记录,如我们将在本章后面看到的,最好通过适当的日志记录解决方案来完成。纯文本日志消息当然不是理想的解决方案。选择的技术应尽量减少对应用程序的性能影响。
实现日志记录背后相同动机的另一种方法是引入事件溯源。这使得领域事件成为应用程序核心模型的一部分。
由业务动机驱动的跟踪,这也应该是业务用例的一部分,使用适当的解决方案实现。正如我们将在下一小节中看到的,有更多适合的跟踪解决方案,这些解决方案需要更少的解析,并且性能影响更小。跟踪解决方案还支持跨微服务的信息和请求的整合。
存储在日志消息中的监控信息,通过使用适当的监控解决方案来管理会更好。这种方法不仅性能更佳,而且也是以适当的数据结构发出信息更有效的方式。本章前面看到的例子说明了监控数据和可能的解决方案。
日志记录也传统上被用来输出那些在应用程序中无法适当处理的异常和错误。这可以说是日志记录的唯一合理用途。结合其他可能捕获错误的潜在指标,例如系统边界的错误率计数器,记录的异常可能有助于开发者调查错误。
然而,只有当错误和异常确实与应用程序相关,并且可以由开发者解决时,才应该记录它们。在部署了监控和警报解决方案的情况下,查看日志的需求应表明应用程序存在严重问题。
容器化世界的日志记录
12 个因素原则之一是将日志视为事件流。这包括处理日志文件不应是企业应用程序关注点的想法。日志事件应简单地输出到进程的标准输出。
应用程序的运行环境会整合和处理日志流。有针对所有参与应用程序的统一访问解决方案,可以部署到环境中。应用程序部署的运行环境负责处理日志流。fluentd,作为云原生计算基金会的一部分,统一了分布式环境中日志事件的访问。
应用程序开发者应尽可能简单地将使用的日志技术视为一种工具。应用程序容器被配置为将所有服务器和应用程序日志事件输出到标准输出。这种方法简化了企业开发者的工作,并使他们能够更多地关注解决实际业务问题。
正如我们所看到的,在传统方式下,应用程序开发者合理可以记录的信息并不多。监控、日志记录或跟踪解决方案,以及事件溯源,可以以更合适的方式解决需求。
与无需复杂日志文件处理的输出到标准输出的日志记录相结合,就没有必要使用复杂的日志框架。这支持零依赖的应用程序,并使开发者能够专注于业务关注点。
因此,建议避免使用第三方日志框架,以及写入传统的日志文件。管理日志轮换、日志条目格式、级别和框架依赖,以及配置的需求,就不再必要了。
然而,以下内容可能对企业开发者来说似乎有些矛盾。
使用 Java 的System.out和System.err标准输出能力是记录输出的简单、12 因素方法。这直接写入同步输出,而没有不必要的缓冲层。
重要的是要提到,通过这种方法输出数据将不会执行。引入的同步,再次将应用程序中原本独立的各个部分联系在一起。如果将进程的输出抓取并由显卡发出,性能将进一步降低。
将日志记录到控制台仅意味着输出异常,正如 Java 类型的名称所表明的——异常。在其他所有情况下,工程师必须问自己为什么一开始就想输出信息,或者是否有其他更合适的解决方案。因此,记录的错误应表明一个需要工程行动的致命问题。不应期望在生产环境中接收到此日志输出;在这种情况下,性能可能会受到忽视。
为了输出致命错误信息,Java EE 应用程序可以使用 CDI 特性以及 Java SE 8 功能接口来提供统一的日志功能:
public class LoggerExposer {
@Produces
public Consumer<Throwable> fatalErrorConsumer() {
return Throwable::printStackTrace;
}
}
然后,Consumer<Throwable>日志记录器可以注入到其他 bean 中,并使用消费者类型的accept()方法进行日志记录。如果希望有一个更易读的接口,可以通过@Inject注入定义一个薄的日志记录器外观类型,如下所示:
public class ErrorLogger {
public void fatal(Throwable throwable) {
throwable.printStackTrace();
}
}
这种方法对于企业开发者来说可能看起来是反直觉的,尤其是在不使用日志框架的情况下进行日志记录。使用复杂的日志框架,该框架用于将输出再次定向到标准输出,引入了开销,最终导致相同的结果。一些开发者可能此时更喜欢使用 JDK 日志。
然而,提供复杂的日志接口,从而给应用程序开发者提供输出各种信息的机会,特别是可读性强的字符串,是适得其反的。这就是为什么代码示例只允许在致命错误情况下输出可抛出类型。
重要的是要注意以下几个方面:
-
应避免传统的日志记录,并使用更适合的解决方案
-
只有那些例外情况,即理想情况下永远不会发生,的致命错误情况才应该被记录
-
建议容器化应用程序将日志事件输出到标准输出
-
应用程序日志和接口应尽可能简单,防止开发者过度使用
日志记录
如果需要作为业务逻辑一部分的日志记录,有比使用日志框架更好的方法。日志记录的要求可能是审计法规,例如交易系统的情况。
如果业务逻辑需要日志记录,则应相应地将其视为业务需求。有可用的日志记录技术,它以比传统日志记录更高的密度和更低的延迟同步持久化所需信息。这些解决方案的例子是Chronicle Queue,它允许我们以高吞吐量和低延迟存储消息。
应用程序领域可以将信息建模为领域事件,并将其直接持久化到日志记录解决方案中。如前所述,另一种方法是基于事件源模型构建应用程序。审计信息随后已成为应用程序模型的一部分。
跟踪
跟踪用于重现特定场景和请求流。它已经在回溯复杂的应用程序过程中很有帮助,但在涉及多个应用程序和实例时尤其有帮助。
然而,重要的是要指出,跟踪系统需要有业务需求,而不是技术需求,类似于日志记录。
跟踪不是调试或性能跟踪系统的良好技术。它将对性能产生一定影响,并且在解决性能回归方面帮助不大。需要优化其性能的相互依赖的分布式应用程序最好仅发出有关其服务质量的信息,例如响应时间。采样技术可以充分收集指示应用程序中性能问题的信息。
然而,让我们看看由业务动机驱动的跟踪,以跟踪涉及的组件和系统。
下面的图显示了涉及多个应用程序实例及其组件的特定请求的跟踪。

跟踪也可以显示在时间轴上,以显示同步调用,如下面的图所示:

跟踪包括有关哪些应用程序或应用程序组件已参与以及单个调用持续了多长时间的信息。
传统上,日志文件已用于此,通过记录每个方法或组件调用的开始和结束,包括一个关联 ID,例如线程标识符。有可能将关联 ID 包含到仅用于单个源请求并随后在后续应用程序中重用和记录的日志中。这导致跟踪跨越多个应用程序。
在日志记录的情况下,跟踪信息是从多个日志文件中累积的;例如,使用如ELK堆栈之类的解决方案。跟踪日志通常以横切方式实现;例如,使用日志过滤器和中继器,以免混淆代码。
然而,使用日志文件进行追踪是不推荐的。即使是经历适度负载的企业应用程序也会引入大量的日志条目,这些条目被写入文件。每个请求都需要许多日志条目。
基于文件的 I/O 和所需的日志格式序列化通常对这个方法来说太重了,并且极大地影响了性能。追踪到日志文件格式引入了大量的数据,之后还需要再次解析。
有一些追踪解决方案提供了更好的匹配。
现代世界的追踪
在过去几个月和几年中,出现了多个旨在最小化对系统性能影响的追踪解决方案。
OpenTracing 是一个标准、供应商中立的追踪技术,它是云原生计算基金会的一部分。它定义了追踪的概念和语义,并支持分布式应用程序的追踪。它由多个追踪技术实现,例如 Zipkin、Jaeger 或 Hawkular。
层次化追踪由多个跨度组成,类似于之前图中所示。一个跨度可以是另一个跨度的子跨度或跟随者。
在前面的例子中,汽车制造组件跨度是负载均衡跨度的一个子跨度。持久化跨度跟随客户端跨度,因为它们的调用是顺序发生的。
OpenTracing API 的跨度包括时间跨度、操作名称、上下文信息,以及可选的标签和日志集合。操作名称和标签与之前在 进入 Prometheus 部分中描述的 Prometheus 度量名称和标签有些相似。日志描述了诸如跨度消息等信息。
单个跨度的示例是 createCar,带有标签 color=RED 和 engine=DIESEL,以及日志字段 message 的 Car successfully created。
以下代码片段展示了在 汽车制造 应用程序中使用 OpenTracing Java API 的示例。它支持 Java 的 try-with-resource 功能。
import io.opentracing.ActiveSpan;
import io.opentracing.Tracer;
@Stateless
public class CarManufacturer {
@Inject
Tracer tracer;
public Car manufactureCar(Specification spec) {
try (ActiveSpan span = tracer.buildSpan("createCar")
.withTag("color", spec.getColor().name())
.withTag("engine", spec.getEngine().name())
.startActive()) {
// perform business logic
span.log("Car successfully created");
}
}
}
创建的跨度开始活动并作为子跨度添加到可能存在的父跨度中。Tracer 由一个 CDI 生产者产生,该生产者依赖于特定的 OpenTracing 实现。
显然,这种方法会大量混淆代码,应该将其移动到横切组件,例如拦截器。追踪拦截器绑定可以装饰方法并提取有关方法名称和参数的信息。
根据追踪跨度中包含的所需信息,拦截器绑定可以被增强以提供更多信息,例如操作名称。
以下代码片段展示了使用拦截器绑定以简洁方式添加追踪的业务方法。实现拦截器留给读者作为练习:
@Stateless
public class CarManufacturer {
...
@Traced(operation = "createCar")
public Car manufactureCar(Specification spec) {
// perform business logic
}
}
追踪信息通过跨度上下文和载体传递到后续应用程序中。它们使参与的应用程序能够添加自己的追踪信息。
收集的数据可以通过使用的 OpenTracing 实现提取。对于 JAX-RS 资源和服务端客户端等技术,有可用的过滤器和拦截器实现,这些实现可以透明地添加所需的调试信息到调用中,例如使用 HTTP 头。
这种跟踪方式对系统性能的影响远小于传统日志记录。它定义了精确的步骤和系统,这些系统会检测业务逻辑流程。然而,如前所述,需要业务需求来实现跟踪解决方案。
典型的性能问题
性能问题伴随着典型的症状,如响应时间变慢或成为更慢,超时,甚至完全不可用的服务。错误率指示了后者。
当性能问题出现时,需要问的问题是实际限制资源,即瓶颈是什么。问题起源于哪里?如前所述,建议工程师遵循一个考虑整体情况(包括硬件和操作系统)的调查过程,以找到限制因素。不应有猜测和过早的决定。
性能问题可能有无数的根本原因。其中大部分源于编码错误或配置错误,而不是实际工作负载超过可用资源。现代应用程序服务器可以处理大量的负载,直到性能成为问题。
然而,经验表明,存在典型的性能问题根本原因。以下将展示最严重的一些。
工程师被指示正确调查问题,而不是遵循所谓的最佳实践和过早优化。
日志记录和内存消耗
传统的日志记录,例如将字符串格式的日志消息写入文件,是性能不佳的最常见根本原因。本章已经描述了这些问题及其可行的解决方案。
性能不佳的最大原因是大量字符串对象的创建和随之而来的内存消耗。一般来说,高内存消耗代表了一个主要性能问题。这不仅仅是由日志记录引起的,还包括缓存中的高内存率、内存泄漏或大量对象创建。
由于 JVM 管理内存的垃圾回收,这些高内存率会导致垃圾回收器运行,试图释放未使用的内存。垃圾回收利用 CPU。这种情况不会因为单次回收运行而得到解决,从而导致后续的 GC 执行和 CPU 使用率升高。如果无法释放足够的内存,无论是由于内存泄漏还是高负载和高消耗,这种情况就会发生。即使系统没有因为OutOfMemoryError而崩溃,CPU 使用率也可能有效地使应用程序停滞。
垃圾收集日志、堆转储和测量可以帮助调查这些问题。JMX 工具提供了关于内存分布和潜在热点方面的见解。
如果业务逻辑以简洁、直接的方式使用短期对象实现,内存问题就远不太可能发生。
过早优化
在企业项目中,经常发生开发者试图在没有适当验证的情况下过早优化应用程序的情况。例如,使用缓存、配置池以及应用程序服务器行为,但在调整前后没有进行足够的测量。
在没有确定性能问题之前,强烈建议不要考虑使用这些优化。在更改设置之前,进行适当的性能采样和测量,以及调查限制性资源,是必要的。
在绝大多数情况下,遵循约定优于配置原则就足够了。这适用于 JVM 运行时以及应用程序服务器。如果开发者采用默认应用程序服务器配置的纯 Java EE 方法,他们不太可能遇到过早优化的问题。
如果技术指标表明当前的方法不足以满足生产工作负载,那么才需要引入变更。此外,工程师应该随着时间的推移验证变更的必要性。技术会变化,之前运行版本中提供补救措施的优化可能不再是最佳解决方案。
约定优于配置的方法以及首先采用默认配置也要求最少的初始努力。
再次,经验表明,许多问题源于在事先没有适当验证的情况下过早引入变更。
关系型数据库
关系型数据库通常被视为性能不足的替罪羊。通常,应用程序服务器以多个实例部署,所有实例都连接到单个数据库实例。这是由于 CAP 定理确保一致性的必要条件。
数据库作为一个单一的责任点或失败点,注定会成为瓶颈。然而,工程师必须考虑适当的测量来验证这个假设。
如果指标表明数据库响应速度慢于可接受的范围,那么首先要调查根本原因。如果数据库查询是导致响应缓慢的原因,建议工程师查看执行的查询。是否有大量数据被加载?如果是,所有这些数据都是必要的吗?或者应用程序稍后会对其进行过滤和减少?在某些情况下,数据库查询加载的数据可能比所需的更多。
这也是一个与业务相关的问题,特别是对于检索数据,是否一切都需要。在这种情况下,更具体的数据库查询,如预先过滤结果或大小限制(如分页),可能会有所帮助。
数据库在连接和过滤数据时表现非常出色。在数据库实例中直接执行更复杂的查询通常优于将所有所需数据加载到应用程序的内存中并执行查询。在 Java 中可以定义复杂的嵌套 SQL 查询并在数据库中执行它们。然而,企业应用程序应该避免直接在数据库中使用存储过程来定义业务逻辑查询。与业务相关的逻辑应该位于应用程序中。
一个典型的配置错误是忽略了在查询中使用的相关数据库列的索引。在许多项目中,仅通过定义适当的索引就可以将整体性能提高几个因素。
通常,特定用例的洞察测量通常可以提供关于问题可能起源于哪里的良好见解。
在某些场景中,经常更新数据的查询往往会导致乐观锁定错误。这源于领域实体同时被更新。乐观锁定与其说是技术问题,不如说是业务问题。服务错误率将表明这些问题。
如果业务用例要求实体经常同时更改,开发团队可以考虑将功能更改为基于事件的模型。同样,如前所述,通过引入最终一致性,事件溯源和事件驱动架构消除了这种情况。
如果性能问题纯粹源于工作负载和并发访问,那么最终需要不同的数据模型,例如使用 CQRS 实现的事件驱动架构。然而,通常可以通过其他方式解决这个问题。绝大多数企业应用程序使用关系数据库就可以很好地扩展。
通信
大多数与通信相关的性能问题都是由于同步通信引起的。这个领域的大多数问题都源于缺少超时,导致客户端调用无限期地阻塞并造成死锁情况。这种情况发生在没有配置客户端超时并且调用的系统不可用的情况下。
如果配置的超时时间过长,则会发生一个不那么关键但同样不完善的情况。这会导致系统等待时间过长,减慢进程并阻塞线程。
如前所述,为客户端调用配置超时提供了简单但有效的缓解措施。
高响应时间和低吞吐量可能有多个原因。性能分析可以提供关于时间花费在哪里的见解。
还有一些其他潜在的瓶颈,例如有效载荷大小。数据是作为纯文本发送还是作为二进制数据发送,在有效载荷大小上可能会有很大的差异。使用不完善算法或技术的序列化也可能降低响应速度。然而,除非应用程序位于高性能环境中,否则这些担忧通常是可以忽略的。
如果需要多个同步调用,如果可能的话,它们应该并行发生,使用容器管理的线程;例如,由管理执行器服务提供。这样可以避免不必要地使应用程序等待。
通常情况下,应避免跨越多个事务性系统的用例,例如使用分布式事务的数据库。如前所述,分布式事务无法扩展。业务用例应考虑有效地异步处理。
线程和池
为了重用线程以及连接,应用程序容器管理池。请求的线程不一定需要创建,而是从池中重用。
池用于控制系统特定部分的负载。选择合适的池大小可以使系统很好地饱和,但防止其过载。这是因为空池会导致挂起或拒绝请求。该池的所有线程和连接已经被充分利用。
通过定义专用的线程池,bulkhead 模式可以防止系统的不同部分相互影响。这限制了资源短缺到一个可能有问题功能。在某些情况下,如遗留系统可能已知会导致问题。作为专用线程池实现的 bulkheads 以及超时配置有助于保持应用程序的健康。
空池可能源于该池当前负载异常高,或者获取的资源比预期的时间长得多。在任何情况下,建议不要简单地增加相应的池大小,而是调查问题根源。描述的调查技术以及 JMX 洞察力和线程转储将帮助您找到瓶颈,以及潜在的编程错误,如死锁、配置不当的超时或资源泄漏。在少数情况下,池的短缺实际上可能源于高负载。
池的大小和配置在应用程序容器中完成。工程师必须在重新配置服务器前后在生产环境中进行适当的性能采样。
性能测试
性能测试的挑战在于测试是在模拟环境中运行的。
模拟环境适用于其他类型的测试,例如系统测试,因为某些方面被抽象化。例如,模拟服务器可以模拟与生产环境相似的行为。
然而,与功能测试不同,验证系统的响应性需要考虑环境中的所有因素。最终,应用程序是在实际硬件上运行的,因此硬件以及整体情况都会影响应用程序的性能。在模拟环境中的系统性能永远不会在生产环境中表现得一样。因此,性能测试不是可靠地找到性能瓶颈的方法。
在许多情况下,应用程序在生产环境中的性能可能比性能测试中表现更好,这取决于所有直接和即将到来的影响因素。例如,HotSpot JVM 在高负载下表现更佳。
因此,调查性能限制只能在生产环境中进行。如前所述,jPDM 调查过程,结合应用于生产系统的采样技术和工具,将识别瓶颈。
性能和压力测试有助于发现明显的代码或配置错误,例如资源泄露、严重配置错误、超时缺失或死锁。这些错误将在部署到生产之前被发现。性能测试还可以捕捉到随时间推移的性能趋势,并在整体响应性下降时警告工程师。然而,这可能仅表明潜在问题,但不应该导致工程师过早得出结论。
性能和压力测试只有在相互依赖的应用程序网络中才有意义。这是因为所有涉及的系统和数据库的依赖性和性能影响。设置需要尽可能接近生产环境。
即使如此,结果也不会与生产环境相同。工程师们对此要有清醒的认识。因此,在性能测试之后的性能优化永远不能完全代表真实情况。
对于性能调优来说,重要的是在生产环境中结合调查过程和采样。持续交付技术有助于快速将配置更改应用到生产环境中。然后工程师可以使用采样和性能洞察来查看更改设置是否改善了整体解决方案。再次强调,需要考虑整个系统。仅仅调整单个应用程序而不考虑整个系统可能会对整体情况产生负面影响。
摘要
与业务相关的指标可以为企业应用程序提供有价值的见解。这些指标是业务用例的一部分,因此应被视为如此。业务指标最终会受到其他技术指标的影响。因此,建议也要监控这些指标。
约束理论描述了系统中将存在一个或多个限制性约束,这些约束会阻止系统无限增加其吞吐量。因此,为了提高应用程序的性能,需要消除这些限制性约束。jPDM 通过首先找到 CPU 的主消费者,并使用适当的工具进一步调查性能问题,帮助识别这些限制性约束。建议按照这个考虑整体情况的过程来调查潜在的瓶颈,而不是盲目地窥视和戳探。
与使用高频监控相比,建议工程师以低频采样技术指标,并查询、计算和调查异常情况。这将对应用程序的性能产生极小的影响。分布式应用程序需要满足服务等级协议(SLAs)。背压方法以及舱壁模式可以帮助实现高度响应和弹性的企业系统。
由于多种原因,包括负面的性能影响,应避免使用传统的日志记录。企业应用程序建议仅在发生致命的意外错误时输出日志事件,这些错误以尽可能直接的方式写入标准输出。对于所有其他动机,如调试、跟踪、日志记录或监控,有更合适的解决方案。
在模拟环境中运行的性能和压力测试可以用来发现应用程序中的明显错误。环境应该尽可能接近生产环境,包括所有涉及的应用程序和数据库。对于任何其他理由,特别是关于应用程序预期性能、瓶颈或优化的声明,性能测试并不有帮助,甚至可能导致错误的假设。
下一章将涵盖应用程序安全性的主题。
第十章:安全
到目前为止,本书中讨论的大部分主题都没有涉及安全这个话题。这是一个经常被忽视的话题,在一些实际项目中,只有在为时已晚时才会引起关注。
开发人员和项目经理将安全视为一种必要的恶,而不是为业务带来巨大利益的东西。尽管如此,它是一个利益相关者必须了解的话题。
在云计算和分布式应用的年代,许多要求都发生了变化。本章将探讨过去的情况以及今天的要求。它将涵盖如何使用现代 Java EE 实现安全:
-
从过去学到的安全经验
-
企业安全原则
-
现代安全解决方案
-
如何使用现代 Java EE 实现安全
从过去学到的经验教训
在当今世界,IT 安全是一个非常重要的方面。大多数人已经意识到,如果滥用,信息技术可以造成很多危害。
在过去半个世纪的计算机领域,我们可以从安全方面学到很多东西,这不仅仅适用于企业软件。
让我们回顾一下企业应用开发过去的一些经验教训。在过去的几年里,最大的安全问题在于加密和管理凭据的方法。
如果正确应用,加密和签名数据是保持秘密的一种极其安全的方式。它完全取决于所使用的算法和密钥长度。
有很多加密和散列算法最终证明不够安全。DES是一个例子,以及常用的MD5散列算法。截至本书编写时,AES使用 192 位或 256 位密钥长度被认为是安全的。对于散列算法,建议使用至少 256 位的SHA-2或SHA-3。
作为应用程序一部分存储的用户凭据不得以纯文本形式存储。过去已经发生了太多的安全漏洞,特别是针对存储密码的数据库。此外,简单地散列密码而不提供适当的密码盐是不被鼓励的。
通常,对于企业开发者来说,如果可能的话,最好不要自己实现安全功能。公司的想法是创建自己的安全实现,这些实现没有在其他地方使用过,因此提供了一种隐蔽性安全。然而,这实际上产生了相反的效果,除非有安全专家的参与,否则实际上会导致更不安全的解决方案。
大多数企业安全需求不需要他们自己的、定制的实现。企业框架及其实现已经包含了经过众多用例良好测试的相应功能。我们将在本章后面探讨这些 Java 企业版的 API。
如果应用程序需要自定义加密的使用,那么必须使用运行时或第三方依赖项提供的实现。出于这个原因,Java 平台提供了 Java Cryptography Extension (JCE)。它提供了现代加密和散列算法的实现。
通常,应用程序只有在业务用例绝对需要时才应处理和存储安全信息。特别是对于身份验证和授权,有一些方法可以避免在多个系统中存储用户凭据。
现代世界的安全
应用程序的更多分布导致对保护通信的需求更高。交换信息的完整性需要得到保证。同样,人们意识到加密的必要性,尤其是在加密通信时。
在今天的商业世界中,工程师有哪些可能性?他们在实现安全时应该遵循哪些原则?
安全原则
在企业应用程序中实现安全时应遵循一些基本原则。以下列表旨在提供基本思想,并不旨在详尽无遗。
加密通信
首先,重要的是要提到,在互联网上发生的所有外部通信都必须加密。通常的做法是通过 TLS 使用受信任的证书来完成。这对于 HTTP 以及其他通信协议都是可能的。
使用的证书的真实性必须在运行时由实现进行验证。它们必须由受信任的内部或外部证书颁发机构保证。
在应用程序中不安全地接受任何证书应避免,无论是生产环境还是其他环境。这意味着提供了并使用了正确签名的证书来进行通信。
委派安全关注
在存储用户信息方面,今天的方法是在可能的情况下将身份验证和授权委派给安全提供者。这意味着企业应用程序不会存储安全信息,而是请求第三方,一个受信任的安全提供者。
在分布式环境中,这一点尤其有趣,因为多个应用程序为外部世界提供了潜在的端点。安全信息移动到单一的责任点。
安全问题通常不是核心业务逻辑的一部分。应用程序将请求受信任的安全提供者系统验证用户请求的安全性。安全提供者充当安全单一责任点。
存在着去中心化的安全协议,例如 OAuth 或 OpenID,它们实现了这种方法。
将责任委托给受信任的安全提供商消除了在企业系统中共享密码的需要。用户直接针对安全提供商进行身份验证。需要了解用户安全信息的应用程序将提供不直接包含机密数据的会话令牌。
然而,这一原则主要针对包括应用程序用户作为个人的通信。
正确处理用户凭证
如果由于某种原因应用程序自行管理用户认证,它绝不应该永久以明文形式存储密码和令牌。这引入了严重的安全风险。即使应用程序或数据库对外部世界有足够的保护,保护凭证免受内部泄露也很重要。
需要在应用程序内部管理的密码必须仅通过适当的哈希算法和如盐值等方法存储。这样做可以防止来自公司内部和外部任何恶意攻击。建议咨询像开放网络应用安全项目(OWASP)这样的安全信息组织。它们提供关于安全方法和算法的现代建议。
避免在版本控制中存储凭证
正如你不应该轻视安全凭证一样,开发者也不应该在版本控制的项目仓库中存储明文凭证。即使仓库是在公司内部托管,这也引入了安全风险。
凭证将永久地显示在仓库的历史记录中。
如第五章“使用 Java EE 的容器和云环境”所示,云环境具有将秘密配置值注入应用程序的功能。此功能可用于提供外部配置的秘密凭证。
包含测试
应用程序需要负责的安全机制需要得到适当的系统测试。任何包含的认证和授权都必须作为持续交付管道的一部分进行验证。这意味着你应该在自动化测试中验证功能,不仅验证一次,而且在软件更改后持续验证。
对于与安全相关的测试,包括负面测试尤为重要。例如,测试必须验证错误的凭证或权限不足不允许你执行特定的应用程序功能。
可能性和解决方案
在几个基本但重要的安全原则之后,让我们来看看可能的安全协议和解决方案。
加密通信
加密通信通常意味着通信使用TLS 加密,作为传输层通信协议的一部分。证书用于加密和签名通信。当然,能够依赖证书至关重要。
公司通常运营自己的证书颁发机构,并在他们的计算机和软件中预先安装根 CA。这对于内部网络来说确实是有道理的。与从官方机构请求所有内部服务的证书相比,这减少了开销和潜在的成本。
需要由操作系统或平台预先安装的官方证书颁发机构之一签名的公开受信任的证书。
加密通信不验证用户,除非正在使用单个客户端证书。它为安全、可信的通信奠定了基础。
基于协议的认证
一些通信协议自带认证功能,例如带有基本或摘要认证的 HTTP。这些功能是通信协议的一部分,通常在工具和框架中得到很好的支持。
它们通常依赖于通信已经被安全加密,否则这将使信息对能够读取它的各方可访问,如果他们拦截了通信。这一点对于确保通过加密通信提供基于协议的认证,对于应用程序开发者来说非常重要。
基于协议的安全凭证通常直接在每条消息中提供。这简化了客户端调用,因为不需要进行多个认证步骤,例如在交换令牌时。第一个客户端调用就可以交换信息。
去中心化安全
其他不直接在客户端调用中包含凭证的方法会首先获取安全令牌,然后在提供令牌之后发出实际通信。这朝着去中心化安全的方向发展。
为了将安全与应用程序解耦,企业系统可以将身份提供者作为认证或授权的中心点,分别包括在内。这把安全担忧从应用程序委托给提供者。
身份提供者授权第三方,如企业应用程序,而无需与他们直接交换凭证。最终用户被重定向到身份提供者,不会将安全信息交给企业应用程序。第三方只有在获得访问权限时才会收到信息,这些信息包含在它们可以验证的令牌中。
这种三方认证避免了让企业应用程序承担安全责任。验证用户提供的资料是否正确,责任转移到身份提供者。
这种方法的例子之一是单点登录(SSO)机制。它们在大公司中相当常用,要求用户只进行一次身份验证,并在所有由 SSO 保护的服务中重复使用信息。SSO 系统验证用户并向相应的应用程序提供所需用户信息。用户只需登录一次。
另一种方法是使用分散式访问委托协议,例如 OAuth、OpenID 和 OpenID Connect。它们代表客户端、第三方应用程序和身份提供者之间交换安全信息的三方安全工作流程。这种想法与单点登录机制类似。然而,这些协议允许用户决定哪个单独的应用程序将接收用户信息。应用程序接收用户访问令牌,例如,以JSON Web Tokens的形式,这些令牌通过身份提供者进行验证,而不是实际的凭证。
分散式访问委托协议及其实现超出了本书的范围。企业系统的责任是拦截并重定向用户身份验证到身份提供者。根据系统架构,这可能是由代理服务器或应用程序本身负责。
现在有开源解决方案实现了分散式安全。一项有趣的技术是Keycloak,它是一个身份和访问管理解决方案。它附带各种客户端适配器,并支持标准协议,如 OAuth 或 OpenID Connect,这使得保护应用程序和服务变得容易。
代理
封装与企业应用程序通信的代理服务器可以增加安全方面,例如加密通信。例如,Web 代理服务器支持通过 HTTPS 的 TLS 加密。
问题是工程师是否想要在网络、内部和外部通信之间做出区分。内部网络中的通信通常是未加密的。根据交换信息的性质,在大多数情况下,互联网通信应该是加密的。
代理服务器可以用于在网络边界终止加密,即所谓的TLS 终止。代理服务器分别加密所有传出信息和解密所有传入信息。
同样有可能使用不同网络的不同证书重新加密通信。
现代环境中的集成
现代环境旨在支持今天的网络安全需求。容器编排框架提供软件代理服务器和网关的配置,这些服务器和网关公开服务;例如,Kubernetes 的ingress资源以及 OpenShift 的routes支持集群外部流量的 TLS 加密。
为了提供诸如凭证或私钥之类的秘密值,编排框架提供了“秘密”功能。如前所述,这使我们能够将秘密配置分别提供到环境中。第五章,Java EE 的容器和云环境探讨了这是如何实现的。
这使得应用程序以及一般的配置可以使用秘密值。如果需要,这些秘密值可以注入到容器运行时。
在 Java EE 应用程序中实现安全
在了解了当今世界最常见的安全方法之后,让我们看看如何使用 Java EE 实现安全。
在所有 Java 版本中,Java EE 版本 8 旨在解决安全方面的问题。它包含一个安全 API,该 API 简化并统一了开发者的集成。
透明安全
在最简单的情况下,可以通过代理 Web 服务器,如Apache或nginx,在 Web 应用程序中实现安全。在这种情况下,安全责任对应用程序来说是透明的。
如果企业应用程序不需要处理用户作为域实体,这种情况通常会出现。
Servlets
为了保护 Java EE 应用程序提供的 Web 服务,通常在 servlet 层使用安全。对于所有建立在 servlet 之上的技术,如 JAX-RS,也是如此。安全功能是通过 servlet 部署描述符配置的,即web.xml文件。
这可以通过多种方式发生,例如基于表单的认证、HTTP 基本访问认证或客户端证书。
类似地,像 Keycloak 这样的安全解决方案会提供自己的适配器和 servlet 过滤器的实现。开发者通常只需要配置这些组件以使用安全提供者。
Java 受保护实体和角色
Java 安全受保护实体和角色分别代表身份和授权角色。受保护实体和角色通常以厂商特定的方式在应用服务器中配置。在执行过程中,认证请求绑定到受保护实体。
在执行工作流程中使用相关角色的一个例子是使用常见的安全注解,如@RolesAllowed。这种声明式方法检查受保护实体是否被正确授权,否则将导致安全异常:
import javax.annotation.security.RolesAllowed;
@Stateless
public class CarManufacturer {
...
@RolesAllowed("worker")
public Car manufactureCar(Specification spec) {
...
}
@RolesAllowed("factory-admin")
public void reconfigureMachine(...) {
...
}
除了厂商特定的解决方案之外,用户和角色可以扩展以包含特定领域的相关信息。为了实现这一点,Principal安全类型得到了增强。
可以注入通过其名称识别的受保护实体,并提供一个特殊化。容器负责用户识别,例如,通过使用基于表单的认证。
这种方法在 Java EE 版本 8 之前特别推荐。然而,现代应用程序可能会使用身份存储来表示特定领域的用户信息。
JASPIC
Java 容器身份验证服务提供者接口(JASPIC)是一个定义身份验证服务提供者接口的标准。它包括所谓的服务器身份验证模块(SAM),可插入的身份验证组件,这些组件被添加到应用程序服务器中。
本标准提供了强大且灵活的方式来实现身份验证。服务器供应商可以提供他们自己的 SAMs 实现。然而,许多开发者认为使用 JASPIC 标准实现身份验证模块相当繁琐。这就是为什么 JASPIC 标准在企业项目中并不广泛使用。
安全 API
安全 API 1.0 与 Java EE 8 一起发布。这个标准的想法是为开发者提供更简单易用的现代安全方法。这些方法以供应商独立的方式实现,无需锁定到特定解决方案。
让我们来看看安全 API 包括哪些内容。
身份验证机制
首先,安全 API 包括HttpAuthenticationMechanism,它以较少的开发工作量提供了 JASPIC 标准的特性。它被指定用于 servlet 上下文。
应用程序开发者只需要定义一个自定义的HttpAuthenticationModule并在web.xml部署描述符中配置身份验证。我们将在本章后面讨论自定义安全实现。
Java EE 容器已经预装了基本、默认和自定义表单身份验证的预定义 HTTP 身份验证机制。开发者可以以最小的努力使用这些预定义功能。在我们看到示例之前,让我们看看如何存储用户信息。
身份存储
安全 API 中也增加了身份存储的概念。身份存储以轻量级、便携的方式提供用户的身份验证和授权信息。它们提供了一种统一的方式来访问这些信息。
IdentityStore类型验证调用者的凭证并访问其信息。类似于 HTTP 身份验证机制,应用程序容器需要为 LDAP 和数据库访问提供身份存储。
下面的示例展示了使用容器提供的安全功能的一个例子:
import javax.security.enterprise.authentication.mechanism.http.*;
import javax.security.enterprise.identitystore.DatabaseIdentityStoreDefinition;
import javax.security.enterprise.identitystore.IdentityStore;
@BasicAuthenticationMechanismDefinition(realmName = "car-realm")
@DatabaseIdentityStoreDefinition(
dataSourceLookup = "java:comp/UserDS",
callerQuery = "select password from users where name = ?",
useFor = IdentityStore.ValidationType.VALIDATE
)
public class SecurityConfig {
// nothing to configure
}
应用程序开发者只需要提供这个注解类。这种方法为测试目的提供了简单直接的安全定义。
通常,企业项目可能需要更多的自定义方法。组织通常有自己的身份验证和授权方式,这些方式需要集成。
自定义安全
下面的示例展示了更复杂的情况。
为了提供自定义认证,应用程序开发者实现了一个自定义的HttpAuthenticationMechanism,特别是validateRequest()方法。该类只需作为 CDI bean 对容器可见即可。其余的工作由应用程序容器完成。这简化了开发者的安全集成。
以下是一个基本示例,其中使用伪代码表示实际的认证:
import javax.security.enterprise.AuthenticationException;
import javax.security.enterprise.authentication.mechanism.http.*;
import javax.security.enterprise.credential.UsernamePasswordCredential;
import javax.security.enterprise.identitystore.CredentialValidationResult;
import javax.security.enterprise.identitystore.IdentityStoreHandler;
@ApplicationScoped
public class TestAuthenticationMechanism implements
HttpAuthenticationMechanism {
@Inject
IdentityStoreHandler identityStoreHandler;
@Override
public AuthenticationStatus validateRequest(HttpServletRequest request,
HttpServletResponse response,
HttpMessageContext httpMessageContext)
throws AuthenticationException {
// get the authentication information
String name = request.get...
String password = request.get...
if (name != null && password != null) {
CredentialValidationResult result = identityStoreHandler
.validate(new UsernamePasswordCredential(name,
password));
return httpMessageContext.notifyContainerAboutLogin(result);
}
return httpMessageContext.doNothing();
}
}
validateRequest()实现访问 HTTP 请求中包含的用户信息,例如通过 HTTP 头。它使用IdentityStoreHandler将验证委托给身份存储库。验证结果包含提供给安全 HTTP 消息上下文的结果。
根据要求,还需要实现自定义身份处理程序实现。它可以提供自定义认证和授权方法。
如果使用去中心化安全协议,如 OAuth,则自定义身份处理程序将实现安全访问令牌验证。
以下显示了一个自定义身份存储库:
import javax.security.enterprise.identitystore.IdentityStore;
@ApplicationScoped
public class TestIdentityStore implements IdentityStore {
public CredentialValidationResult validate(UsernamePasswordCredential
usernamePasswordCredential) {
// custom authentication or authorization
// if valid
return new CredentialValidationResult(username, roles);
// or in case of invalid credentials
return CredentialValidationResult.INVALID_RESULT;
}
}
使用web.xml servlet 部署描述符来指定受保护资源。应用程序容器负责集成:
<security-constraint>
<web-resource-collection>
<web-resource-name>Protected pages</web-resource-name>
<url-pattern>/management</url-pattern>
</web-resource-collection>
<auth-constraint>
<role-name>admin-role</role-name>
</auth-constraint>
</security-constraint>
HTTP 认证机制提供了一种简单而灵活的方式来实现 JASPIC 安全。与纯 JASPIC 方法相比,其实现更简单。
它提供了拦截通信流的可能性,可以将应用程序与第三方安全提供商集成。
访问安全信息
企业应用程序有时需要功能来访问用户授权信息,作为业务逻辑的一部分。安全 API 使我们能够以统一的方式检索这些信息。
它包含SecurityContext类型,该类型提供了一种程序化方式来检索有关调用者主体及其角色的信息。SecurityContext可以注入到任何管理 bean 中。它还与 servlet 认证配置集成,并提供有关调用者是否允许访问特定 HTTP 资源的信息。
以下显示了SecurityContext的一个示例用法:
import javax.security.enterprise.SecurityContext;
@Stateless
public class CompanyProcesses {
@Inject
SecurityContext securityContext;
public void executeProcess() {
executeUserProcess();
if (securityContext.isCallerInRole("admin")) {
String name = securityContext.getCallerPrincipal().getName();
executeAdminProcess(name);
}
}
...
}
安全 API 的想法是与之前 Java EE 版本中的现有功能集成。这意味着,例如,@RolesAllowed注解使用与SecurityContext相同的角色信息。开发者可以继续依赖现有的标准功能。
摘要
在当今世界,IT 安全是一个非常重要的方面。在过去,一些最大的安全问题包括弱加密和散列算法、密码的持久化方式以及自制的安全实现。一些重要的安全原则包括加密通信、使用外部、可信的安全提供者进行认证和授权、避免在版本控制下保留凭证,以及包括验证保护的测试场景。
通信通常在传输层使用 TLS 进行加密。使用的证书应该由公司内部或官方证书机构正确签名。其他方法包括使用协议层的安全功能,例如在加密通信之上使用 HTTP 基本认证。
通过包含可信的身份提供者,去中心化安全将认证和授权责任从应用程序中分离出来。单点登录以及去中心化访问委托协议是此类示例。
在 Java EE 应用程序边界内实现安全通常是在 Servlets 之上进行的。Java EE 8 中引入的 Security API 旨在提供更简单、统一的方法来处理 Java EE 应用程序中的安全问题。HTTP 认证机制是提供更易用 JASPIC 功能的示例。身份存储提供用户的认证和授权信息。
Security API 的理念是与现有功能集成并提供统一的访问机制。包含的功能应该足够保护企业应用程序在 HTTP 方面的安全。
第十一章:结论
我希望这本书中我们所学的所有内容都能为如何构建现代、轻量级、面向业务的企业应用程序提供有价值的见解。也许这本书甚至能消除一些过时的最佳实践。
我们已经看到了现代版本的 Java EE 如何融入一个全新的软件开发世界,它拥抱了容器技术、云平台、自动化、持续交付以及更多。
企业开发中的动机
正如我们在本书中多次看到的,工程团队在开发软件时应遵循正确的动机。企业系统的主要焦点应该是其业务动机。在能够为顾客提供价值之前,应用程序的领域和业务用例需要明确。最终,能够完成业务功能的实际工作软件才是产生收入的关键。
开发者可以随着时间的推移问自己一个有用的问题:我们正在做的事情是否有助于解决业务问题?
以满足客户需求为目的的软件因此主要关注满足业务用例。满足次要需求的技术,如通信、持久性或分发,则次之。所选解决方案应首先解决业务需求。
因此,技术、编程语言和框架理想情况下应支持在不增加太多开销的情况下实现用例。建议工程师团队选择他们既高效又熟悉的、同时符合这一要求的技术。
云和持续交付
我们已经看到了在快速变化的世界中快速行动的必要性。重视敏捷性和对客户需求、上市时间或更好的生产时间的响应性非常重要。最好的功能只有在客户手中才能提供价值。
使用有助于实现这一目标的概念和技术是有意义的,例如持续交付、自动化、基础设施即代码和自动软件测试。
这就是现代环境和云技术最大的好处:快速行动的能力。使用明确定义的规范,可以在几分钟内创建新项目、功能或测试场景的应用程序环境。特别是,基础设施即代码和容器技术支持这些尝试。软件开发者将环境配置与应用程序代码一起交付,这些代码包含在项目的存储库中。
因此,定义企业软件的所有内容成为整个工程团队的责任。开发人员和运维工程师都希望推出对用户有价值的软件。整个软件团队对实现这一目标负有责任。
这也包括软件质量保证的主题。只有当适当的、自动化的质量验证机制到位时,才能以快速的速度交付功能。需要人工干预且运行不可靠或不够快的测试会阻碍快速流程,并阻止开发者进行更有用的工作。投资于自动化、充分且可靠的测试用例,这些测试用例在构建时考虑到可维护性和代码质量,是必要的。
Java EE 的相关性
我们已经看到了 Java EE 如何实现这一切。该平台通过允许开发者编写代码而不设置太多约束来支持关注业务需求。可以通过首先遵循领域需求来设计和实现用例。
技术本身并不需要关注。在大多数情况下,仅对业务逻辑进行注解就足够了,这会导致应用程序容器添加所需的技术必要性。Java EE 标准的方法,如 JAX-RS、JPA 或 JSON-B,以最小的努力完成所需的技术集成。
Java EE 平台特别使得工程师能够无缝集成多个标准,而无需进行配置工作。考虑到 Java EE 原则编写的 JSR 规范使得这一点成为可能。
现代 Java EE 必须与 J2EE 的旧时代有所不同。实际上,编程模型和运行时与 J2EE 几乎没有关系。
由于平台具有向后兼容的特性,过时的方法仍然可行,但自那时起技术已经取得了很大的进步。编程模型和设计模式已经被重新审视并大大简化。特别是,过去在实现由技术驱动的接口层次和超类时的模式限制已经消失。开发者能够专注于业务领域,而不是技术。
Java EE 标准的性质允许公司实现供应商独立的应用程序。这避免了在技术方面的供应商锁定。开发者也不专门接受针对特定供应商技术的培训。我们已经看到了很多团队,他们只熟悉已经过时的供应商。
Java EE 技术不仅用于服务器端。例如 JAX-RS、JSON-P 或 CDI 这样的标准为 Java SE 应用程序也提供了有价值的益处。使用开发者熟悉的标准化技术实现某些功能,如 HTTP 客户端,是有意义的。
Java EE 8 中引入的 API 更新
本书专注于使用 Java EE 8 的企业应用程序。
在这个版本的过程中,某些标准已经得到了更新。以下是最重要的新特性和标准。
CDI 2.0
自 Java EE 8 和 CDI 2.0 以来,事件不仅可以通过同步方式处理。正如我们在本书中之前看到的,CDI 原生支持异步处理事件。实际上,如果事件观察者方法是 EJB 的业务方法,并注解为@Asynchronous,在此之前这是唯一可行的方法。
为了发射和处理异步 CDI 事件,发布方使用fireAsync方法。观察者方法参数被注解为@ObservesAsync。
CDI 2.0 引入的另一个新事件功能是能够对事件观察者进行排序。因此,在事件观察者方法中指定了 Java EE 平台中广为人知的@Priority注解:
public void onCarCreated(@Observes @Priority(100) CarCreated event) {
System.out.println("first: " + newCoffee);
}
public void alsoOnCarCreated(@Observes @Priority(200) CarCreated event) {
System.out.println("second: " + newCoffee);
}
这种方法保证了事件观察者按照指定的顺序被调用,优先级较低的先调用。开发者应该考虑是否需要排序事件处理器会违反松耦合和单一责任原则。
CDI 2.0 最大的特点是集成到企业容器之外,提供了在 Java SE 应用程序中使用 CDI 的可能性。这个想法是 Java SE 应用程序也可以使用复杂的依赖注入标准的特性。这旨在增加 CDI 在 Java EE 世界之外的可接受度。
JAX-RS 2.1
JAX-RS 2.1 的 2.1 版本主要针对反应式客户端、SSE 以及更好地集成到 JSON-B 等标准。除此之外,还增加了一些小的改进。
反应式编程越来越被广泛使用,特别是客户端获得了新的反应式功能来执行 HTTP 调用并直接返回所谓的反应式类型。这种类型的一个例子是CompletionStage类型。这个类型是原生支持的;其他类型和库可以通过扩展添加。
为了进行反应式调用,使用Invocation.Builder的rx()方法。
正如本书中所示,JAX-RS 2.1 在客户端和服务器端都支持 SSE。SSE 标准代表了一种轻量级、单向的消息协议,它使用 HTTP 上的纯文本消息。
为了与 Java EE 平台的传统方法相匹配,Java EE 8 中添加的 JSON-B 标准被无缝集成到 JAX-RS 中。这意味着,类似于 JAXB,用作请求或响应体的 Java 类型分别隐式映射到 JSON。
类似地,JSON-P 1.1 和 Bean Validation 2.0 的新特性也被包含在 JAX-RS 中。这是可能的,因为规范将特定功能转发到相应的标准。
被整合到 JAX-RS 中的较小更新包括添加了与同名的 HTTP 方法的@PATCH注解。尽管在 JAX-RS 之前已经可以支持提供的 HTTP 方法之外的 HTTP 方法,但它简化了需要此功能的开发者的使用。
另一个虽小但确实有用的改进是,在 JAX-RS 客户端中包含了标准化的 HTTP 超时方法。connectTimeout 和 readTimeout 构建方法处理配置的超时。许多项目都需要这种配置,这以前导致了包含供应商特定的功能。
我们在 第三章,《实现现代 Java 企业应用程序》中看到了这些功能的实现。
JSON-B 1.0
JSON-B 是一种新的标准,它将 Java 类型映射到 JSON 结构,反之亦然。类似于用于 XML 的 JAXB,它提供了声明式映射对象的功能。
在 Java EE 生态系统内,该标准的最大优势是应用程序不再需要依赖特定的供应商实现。通常,JSON 映射框架阻止企业应用程序以可移植的方式构建。它们增加了与现有框架版本断开运行时依赖的风险。
JSON-B 通过提供标准化的 JSON 映射来解决此问题。不再需要打包自定义映射框架,如 Jackson 或 Johnzon。
JSON-P 1.1
在 Java EE 7 中引入的 JSON-P 1.0,提供了一个强大的功能,可以编程创建和读取 JSON 结构。版本 1.1 主要包括对常见 JSON 标准的支持。
这些 IETF 标准之一是 JSON Pointer(RFC 6901)。它定义了一种查询 JSON 结构和值的语法。通过使用指针,例如 "/0/user/address",JSON 值被引用,类似于 XML 世界中的 XPath。
该功能包含在通过 Json.createPointer() 方法创建的 JsonPointer 类型中,类似于现有的 JSON-P API。
另一个新支持的标准是 JSON Patch(RFC 6902)。RFC 6902 定义了所谓的补丁和修改方法,这些方法应用于现有的 JSON 结构。
JSON 1.1 支持通过 Json.createPatch 或 Json.createPatchBuilder 分别创建 JSON 补丁。相应的 JSON-P 类型是 JsonPatch。
第三个支持的 IETF 标准是 JSON Merge Patch(RFC 7386)。该标准通过合并现有的 JSON 结构来创建新的结构。JSON-P 通过 Json.createMergeDiff 或 Json.createMergePatch 分别支持创建合并补丁,结果生成 JsonMergePatch 类型。
除了这些支持的 IETF 标准,JSON-P 1.1 还包括一些简化 API 使用的较小功能。一个例子是通过预定义的流收集器,如 JsonCollectors.toJsonArray() 方法,支持 Java SE 8 流。另一个小的改进是,通过 Json.createValue,可以从 Java 字符串和原始数据类型创建 JSON-P 值类型。
Bean Validation 2.0
Java EE 8 将 Bean Validation 版本更新到 2.0。除了包括新的预定义约束外,它主要针对对 Java SE 8 的支持。
Java SE 8 的支持包括多个不同配置的验证约束注解。Java 8 日期和时间 API 的类型现在得到支持;例如,通过使用 @Past LocalDate date。
容器类型中包含的值也可以通过参数化类型注解单独验证。例如,Map<String, @Valid Customer> customers、List<@NotNull String> strings 和 Optional<@NotNull String> getResult() 就是这样的例子。
Bean Validation 2.0 包含了新的预定义约束。例如,@Email 验证电子邮件地址。@Negative 和 @Positive 验证数值。@NotEmpty 确保集合、映射、数组或字符串不为空或 null。@NotBlank 验证字符串不单纯由空白字符组成。
这些约束是一个有用的默认功能,可以避免手动定义这些约束。
JPA 2.2
Java EE 8 更新了 JPA 规范到版本 2.2。这个版本主要针对 Java SE 8 的特性。
与 Bean Validation 类似,Java SE 8 的支持包括日期和时间 API。例如,LocalDate 或 LocalDateTime 类型现在原生支持实体属性。
版本 2.2 使得可以使用 getResultStream() 方法返回查询结果,不仅作为 List<T>,还可以作为 Stream<T>。以下代码片段展示了这一点:
Stream<Car> cars = entityManager
.createNamedQuery(Car.FIND_TWO_SEATERS, Car.class)
.getResultStream();
cars.map(...)
JPA 2.2 最终添加了对使用 CDI 的 @Inject 将管理 Bean 注入属性转换器的支持。这增加了自定义属性转换器的使用和场景数量。类似于 JSON-B 等其他标准,更好的 CDI 集成鼓励 Java EE 组件的重用。
此外,版本 2.2 还增加了可重复注解,例如 @JoinColumn、@NamedQuery 或 @NamedEntityGraph。由于 Java SE 8 允许重复相同的注解类型多次,因此开发者不再需要使用相应的分组注解,如 @JoinColumns 来实现这些功能。
安全性 1.0
如上一章所述,Security 1.0 旨在简化安全关注点集成到 Java EE 应用程序中。因此,鼓励开发者使用诸如 JASPIC 等强大的功能。
我们在上一章中看到了 HTTP 身份验证机制、身份存储和安全上下文的功能和用法。
Servlet 4.0
在撰写本书时,HTTP/1.1 是主要使用的 HTTP 版本。HTTP/2 针对过去 Web 应用程序 HTTP 性能的不足。特别是,请求基于 Web 系统的多个资源可能会由于涉及的大量连接而导致性能不佳。HTTP 2 的第二个版本通过多路复用、流水线、压缩头和服务器推送来降低延迟并最大化吞吐量。
与 1.1 相比,HTTP/2 的大多数更改不会影响工程师的工作。Servlet 容器在底层处理 HTTP 关注点。这一例外是服务器推送功能。
服务器推送(Server Push)工作原理是服务器直接发送与客户端请求的资源相关的 HTTP 响应,基于这样的假设:客户端也可能需要这些资源。这允许服务器发送客户端未明确请求的资源。这是一种性能优化技术,在网页中主要涉及样式表、JavaScript 代码和其他资产。
Servlet API 通过使用PushBuilder类型支持服务器推送消息,该类型是通过HttpServletRequest.newPushBuilder()方法实例化的。
JSF 2.3
Java 服务器端面(Java Server Faces)是构建以服务器为中心、基于组件的 HTML UI 的传统方式。Java EE 8 附带更新的 JSF 版本 2.3。
版本更新的主要改进包括更好的 CDI、WebSocket 和 AJAX 集成、类级别的 Bean 验证,以及 Java SE 8 的支持。
由于本书的重点显然在后端,因此它不包括太多关于 JSF 的内容。
JCP 和参与
Java 社区进程(JCP)定义了构成 Java SE 和 EE 平台的标准,包括 Java EE 总标准本身。这些单独的标准被定义为Java 规范请求(JSR),每个形成所谓的专家小组,由参与企业软件的专家和公司组成。
这个想法是标准化在现实世界项目中证明效果良好的技术。来自这些现实世界项目的公司和个人的经验被汇集起来,形成供应商独立的 Java 企业标准。
对于公司和个人来说,都强烈建议参与 JCP。它提供了形成标准和 Java 技术未来的能力,以及在这个技术中获得知识。JCP 的开放流程使开发者能够了解 Java EE 未来版本将如何呈现。
个人和公司也可以遵循标准化流程,即使他们没有参与 JCP。他们可以审查标准的草案状态并提供对专家小组的反馈。
专家小组确实欢迎在形成规范时收到建设性的反馈。从现实世界项目中获得反馈和经验对于制定更适合行业需求的标准非常有帮助。
我也参与了 Java EE 8 的塑造,是两个专家小组的成员,即 JAX-RS 2.1 和 JSON-P 1.1。在这个过程中,我获得了大量的知识,并鼓励企业 Java 开发者关注 JCP 中的流程。
MicroProfile
MicroProfile 倡议背后的动机是在 Java EE 标准的基础上构建,创建较小规模的配置文件,针对微服务架构,并尝试与标准化无关的功能。多个应用服务器供应商参与了这一倡议,形成了供应商同意的事实标准。
支持 MicroProfile 的服务器应用程序为运行仅需要较小一组标准的 Java EE 应用程序提供了机会,在第一个版本中这包括 JAX-RS、CDI 和 JSON-P。同样,应用服务器供应商提供了将运行时精简到特定所需标准集的能力。
这些方法的优势在于它们不会向企业项目添加依赖,而是仅仅优化了运行时。开发者仍然使用相同的 Java EE 标准技术编写他们的应用程序。
Eclipse Enterprise for Java
在 2017 年 9 月,就在出版这本书之前,Java EE 和 JCP 的监护人 Oracle 宣布将 Java EE 平台及其标准转移到开源基金会,这产生了Eclipse Enterprise for Java(EE4J)。这些计划旨在降低希望贡献的公司和开发者的门槛,并最终使技术更加开放。
无论这些计划的实现看起来如何,重要的是要提到这些计划包括保留平台的本性。本书中提出的方法和技巧将在企业 Java 的未来中依然适用。
我可以重复我过去关于 JCP 内参与的信息。然而,企业 Java 标准化过程的体现,我鼓励工程师和公司关注 Eclipse Enterprise for Java,并参与定义企业标准。集体知识和实际经验有助于塑造 Java EE 的标准,并将有助于未来塑造企业 Java。
附录:链接和进一步资源
在本书中,我们涵盖了与 Java EE 相关的许多主题。有一些资料帮助我在不同地方构建了内容。为了继续您的学习之旅,您可以按照书中出现的顺序参考以下资源和参考:
-
Java 企业平台:
www.oracle.com/technetwork/java/javaee/overview/index.html -
Java 社区过程:
jcp.org/en/home/index -
《代码整洁之道》,罗伯特·C·马丁(Uncle Bob)
-
《设计模式:可复用面向对象软件元素》,Erich Gamma 等人
-
《领域驱动设计》,埃里克·埃文斯
-
《尖叫架构》,罗伯特·C·马丁(Uncle Bob):
8thlight.com/blog/uncle-bob/2011/09/30/Screaming-Architecture.html -
康威定律,Mel Conway:
www.melconway.com/Home/Conways_Law.html -
Apache Maven:
maven.apache.org -
Gradle:
gradle.org -
Servlet API 4:
www.jcp.org/en/jsr/detail?id=369 -
实体控制边界,Ivar Jacobson
-
Java EE 8 (JSR 366):
jcp.org/en/jsr/detail?id=366 -
企业 JavaBeans 3.2 (JSR 345):
jcp.org/en/jsr/detail?id=345 -
Java 2.0 的上下文和依赖注入(JSR 365):
jcp.org/en/jsr/detail?id=365 -
简单对象访问协议(SOAP):
www.w3.org/TR/soap/ -
Java API for RESTful Web Services 2.1 (JSR 370):
jcp.org/en/jsr/detail?id=370 -
Roy T. Fielding,网络软件架构风格与设计
-
Siren:
github.com/kevinswiber/siren -
Java API for JSON 绑定 1.0 (JSR 367):
jcp.org/en/jsr/detail?id=367 -
Java API for JSON Processing 1.1 (JSR 374):
jcp.org/en/jsr/detail?id=374 -
Java XML 绑定 2.0 (JSR 222):
jcp.org/en/jsr/detail?id=222 -
Bean 验证 2.0, (JSR 380):
jcp.org/en/jsr/detail?id=380 -
Java 消息服务 2.0 (JSR 343):
jcp.org/en/jsr/detail?id=343 -
服务器端事件:
www.w3.org/TR/eventsource/ -
WebSocket 协议(RFC 6455):
tools.ietf.org/html/rfc6455 -
Java WebSocket API (JSR 356):
jcp.org/en/jsr/detail?id=365 -
Enterprise JavaBeans / Interceptors API 1.2 (JSR 318):
jcp.org/en/jsr/detail?id=318 -
Java Temporary Caching API (JSR 107):
jcp.org/en/jsr/detail?id=107 -
MicroProfile:
microprofile.io -
Docker Documentation:
docs.docker.com -
Kubernetes Documentation:
kubernetes.io/docs/home -
OpenShift Documentation:
docs.openshift.com -
Cloud Native Computing Foundation:
www.cncf.io -
The 12-factor app:
12factor.net -
Beyond the 12 Factor App, Kevin Hoffman:
content.pivotal.io/ebooks/beyond-the-12-factor-app -
Jenkins:
jenkins.io -
Using a Jenkinsfile, Documentation:
jenkins.io/doc/book/pipeline/jenkinsfile -
Semantic Versioning:
semver.org -
JUnit 4:
junit.org/junit4 -
Mockito:
site.mockito.org -
Arquillian:
arquillian.org -
CDI-Unit:
bryncooke.github.io/cdi-unit -
AssertJ:
joel-costigliola.github.io/assertj -
TestNG:
testng.org/doc -
WireMock:
wiremock.org -
Gatling:
gatling.io -
Apache JMeter:
jmeter.apache.org -
Cucumber-JVM:
cucumber.io/docs/reference/jvm -
FitNesse:
fitnesse.org -
Prometheus:
prometheus.io -
Grafana:
grafana.com -
fluentd:
www.fluentd.org -
Chronicle Queue:
chronicle.software/products/chronicle-queue -
OpenTracing:
opentracing.io -
AsciiDoc:
asciidoc.org -
Markdown:
daringfireball.net/projects/markdown -
OpenAPI:
www.openapis.org -
Swagger:
swagger.io -
Porcupine, Adam Bien:
github.com/AdamBien/porcupine -
Breakr, Adam Bien:
github.com/AdamBien/breakr -
OWASP:
www.owasp.org -
OAuth:
oauth.net -
OpenID:
openid.net -
JSON Web Tokens:
jwt.io -
Java 容器认证服务提供者接口 (JSR 196):
www.jcp.org/en/jsr/detail?id=196


浙公网安备 33010602011771号