本文要点
- Vert.x 兼容 Java 9,可一起用于构建应用程序。
- 很多 Java 类库仍然不支持模块化。
- 对“自动模块”要格外小心(一些类库还没有成为模块)。
- Java 内置的 Nashorn JavaScript 运行环境对于 Vert.x 的应用程序来说十分有用。
这篇文章将介绍如何使用 Eclipse Vert.x 设计和开发一个基于消息驱动的响应式持续集成(CI)系统。我们将利用 Java 平台模块系统(JPMS)来构建一个由多个模块组成的应用程序,模块之间通过定义好的接口进行通信。
有了 JPMS,架构师和开发者就可以使用模块来重构大型的遗留系统,或者用它们来创建新的应用程序。不过,要在模块系统中使用已有的 Java 类库并不是件容易的事。因此,我们也会探讨在使用 JPMS 过程中可能遇到的各种问题,以及如何解决这些问题。
先让我们来定义这个 CI 系统的最小可用产品(MVP),我们将把它构建成 Docker 原生系统。这个系统需要提供如下特性,并通过 REST API 暴露出来:
- 支持针对仓库的 CRUD 操作。一个仓库代表一个项目,并带有 Git 仓库的连接地址。
- 支持“管道即代码”。管道定义了构建流程,并使用 JavaScript 来定义,JavaScript 脚本文件可以与代码保存在一起。
- 提供用于启动或关闭管道的 API。管道的一个实例就代表一次构建过程。
定义好 MVP 后,就可以开始构建我们的系统了。首先要创建项目的骨架,可以使用 IntelliJ 提供的多模块 Gradle 项目模板来创建骨架。因为要使用 JDK 9,所以最好可以选择最新版的 Gradle(在写这篇文章是最新版是 4.4.1)。我们还需要添加 Jigsaw 插件,并把代码兼容性设置为 Java 9。项目的主文件“build.gradle”应该看起来像下面这样:
与其他大多数系统一样,我们将会有一个公共库,用来放置实体类、工具类、共享常量、查询解析器等。我们把这个公共库定义成一个Java 9 模块。
之前已经讲过,Java 9 模块是接口、类和资源文件的集合,具有自描述的特点,而且有自己的名字。JPMS 引入了“module-info.java”文件,开发者用它定义模块的公共契约和对其他模块的依赖。我们也将使用这个文件来命名我们的模块,并指定对其他模块的依赖以及模块自身暴露出来的公共包。
下图是module-info.java 文件的示例代码:
每个“module-info.java”文件都以关键字“module”作为开头,后面跟上模块的名字。用在包命名上的反向域名命名方式也可以用在模块的命名上。
代码块中有两个新的关键词——“exports”和“requires”。“exports”用于声明由该模块暴露出来的公共包,也就是模块的公共API。“requires”用于声明对其他模块的依赖。
那么,问题来了,如果一个Java 9 模块的依赖包并不是模块,那该怎么办?这个时候,自动模块就派上用场了。
正如它的名字告诉我们的那样,非模块的JAR 包会被自动转成模块,并基于JAR 包的名字来生成模块名。模块名的生成遵循这样的规则:以JAR 包文件开头,去掉扩展名,用点号替代连字符,如果有版本号就把版本号去掉。这样的话,“vert-core-3.5.0.jar”对应的模块名就是“vertx.core”。不过,这种方式不一定都能奏效,后面我们会举一个与Netty 依赖包相关的例子。
除了核心模块,我们还要定义其他一些模块,用于访问数据库、用户认证、运行引擎以及与CI 系统中的其他插件交互。
在介绍了Java 模块化的一些概念后,接下来让我们来聊聊Vert.x。Vert.x 是一个工具套件,提供了非阻塞的API,也就是说,Vert.x 应用程序只需要使用很少的线程就可以处理大量的并发请求。Vert.x 采用了multi-reactor 模式来达到这个目的。
熟悉JavaScript 的开发者或许还记得单线程事件循环模型,multi-reactor 模式与之类似,只不过它使用了多个线程。Vert.x 根据给定服务器的CPU 核数创建相应个数的事件循环对象。
Vert.x 还提供了另一种基于 actor 的并发模型。在 Vert.x 生态系统中,actor 被称为“verticle”,verticle 之间通过 JSON 消息进行通信,这些消息通过事件总线进行传送。我们还可以指定部署多少个 verticle 实例。
事件总线可以是一个集群,使用集群管理器来管理,比如 Hazelcast 或 Zookeeper。我们可以把运行在 Vert.x 实例上的 verticle 或 verticle 组合看成是微服务。mutli-reactor 模型、verticle 和事件总线让 Vert.x 应用程序具备了高响应式、高弹性的特点,因此,我们可以说 Vert.x 应用程序是反应式的。
现在让我们来看看这个 CI 系统的整体流程:
如上图所示,有好几个verticle 通过Vert.x 事件总线进行通信。要注意,图中的插件也是verticle。Server verticle 是CI 系统的入口,对外暴露了一个REST API,命令行或GUI 客户端可以通过这个API 指定代码仓库的连接地址、创建和运行构建管道。
下面的代码告诉我们如何在Vert.x 中定义API 和路由:
我们使用Vert.x 的Web 类库来定义REST API,而且所有的路由均以“/api/v1/”作为前缀。Vert.x 还提供了很多其他类库,用于快速开发反应式应用程序。
例如,我们可以使用Web API 类库来设计一个基于OpenAPI 3 的应用程序API,这个类库会帮我们处理好请求验证和安全验证问题。Vert.x 的OAuth 类库可用来提高应用程序API 的安全性,OAuth 厂商可以是谷歌、Facebook,也可以自定义。
在上一张图片中,Engine verticle 负责协调管道的执行。在客户端调用Server verticle 提供的API 之后,Server verticle 向Engine verticle 发送消息,Engine verticle 在收到消息之后会初始化一个新的flow 对象。
flow 对象实际上是一个简单的状态机,用来跟踪管道的执行状态。在任意时刻,flow 对象可能处于这三种状态中的一种:setup、run 或 teardown。它会根据输入消息来改变状态。在进入一个新的状态时,flow 对象会触发一个事件,并将事件发送到事件总线。
注册到事件总线上插件会处理这些消息,并把处理结果通过事件总线异步传回。下面的代码演示了如何注册一个消息处理器、创建 flow 对象以及处理流入的消息:
Engine verticle 也负责定位和部署其他插件或 verticle。我们使用了在 Java 6 中引入并在 Java 9 中改进过的服务加载器机制,用它在服务器启动过程中定位和部署插件。为了更好地理解服务加载机制,有必要讨论一下服务和服务提供者。
服务其实就是一个已知的接口或类(通常是抽象类),而服务提供者则是服务的具体实现。ServiceLoader 类用于加载实现了给定服务的服务提供者。我们可以在模块中声明它使用了某个特定的服务,然后使用 ServiceLoader 来定位和加载部署在运行环境中的服务提供者。
例如,server 模块声明了它要使用 Plugin 接口,workspace 模块则声明它将提供两个实现了 Plugin 接口的服务。
因此,server 模块在启动的时候,它会调用ServiceLoader,找到两个插件,然后把它们部署成verticle:
插件会完成很多工作,包括注册消息处理器,用于处理感兴趣的管道事件。例如,workspace 插件负责同步Git 代码,而脚本解析器插件负责扫描workspace,找出和执行管道脚本文件(使用JavaScript 编写)。执行完脚本文件会生成一些shell 命令,Docker 容器中的脚本执行器插件会执行这些命令。因为Vert.x 使用了Java 内置的Nashorn 引擎,所以完全可以运行JavaScript 代码。要知道,Vert.x 还可以支持JavaScript、Kotlin 和Groovy。
下面是管道脚本文件的部分代码:
收到消息后,脚本执行器插件将会下载Docker 镜像、创建容器并执行shell 命令。
Docker 的 REST API 通常是通过基于 unix domain socket 的 HTTP 来暴露的,并非传统的基于 TCP socket 的 HTTP(S)。这也是 Vert.x 得以发挥其作用的地方之一,我们可以使用 Vert.x 的异步客户端与 Docker 进行交互,而不是使用普通的同步阻塞式方案。
如果项目中包含了由 Netty 提供的原生传输类库,Vert.x 就会转而使用原生传输。如果我们在“build.gradle”和“module-info.java”文件中指定了类似“netty-transport-native-kqueue”这样的依赖,就会发生这样的情况。
或许 Vert.x 下一个版本会支持基于 unix domain socket 的 HTTP。目前,我们可以通过修改 Vert.x 类库的少量代码来解决这个问题。与 Docker 引擎交互的插件代码看起来是这样的:
加入非模块JAR 包“netty-transport-native-kqueue-4.1.15.Final-osx-x86_64.jar”作为依赖将会自动生成一个模块名,不过因为JAR 包含了Java 关键字native,我们的应用程序无法正常编译。
Netty 将在下一个版本中解决这个问题,而在问题得到解决之前,我们可以在 JAR 包的 manifest 文件中加入“Automatic-Module-Name”来绕过这个问题。为此,我们需要解压 JAR 包,修改“MANIFEST.MF”文件,加入“Automatic-Module-Name: io.netty.transport.kqueue”,然后通过下面的命令重新打包:
可以通过下面的命令来验证在manifest 文件中指定的自动模块名是否可以被正确识别:
我们还要使用相同的命令解决其他非模块JAR 包的命名问题。
接下来就可以构建和运行我们的CI 系统了。下面是运行应用程序的命令:
为了支持JPMS,我们在“javac”和“java”命令中增加了一些新的参数,这些参数告诉Java 编译器和运行时,使用模块路径和模块JAR 包来替代原先的类路径。
一些值得注意的参数:
“-p”或“—module-path”用于告诉Java 系统在指定的目录中查找Java 模块。
“-m”或“—module”用于指定模块和主类。
在这篇文章里,我们基于Vert.x 设计了一个模块化的微服务应用程序。我们使用了JPMS 和JDK 9,并构建了一个Docker 原生的CI 系统。可以在 GitHub 上下载相关代码,了解如何基于 Vert.x 和模块化系统开发一个小型的自包含模块化 Java 应用程序。
关于作者
Uday Tatiraju 是 Oracle 的首席工程师,在电子商务平台、搜索引擎、后端系统和 Web 与移动编程领域拥有十年的开发经验。
查看英文原文: Building a CI System with Java 9 Modules and Vert.x Microservices
评论