编辑推荐: |
本文来自于Uday Tatiraju,要点: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应用程序。
|