感兴趣的朋友,可以关注微信服务号“猿学堂社区”,或加入“猿学堂社区”微信交流群
版权声明:本文由作者自行翻译,未经作者授权,不得随意转发
我们将从第一次迭代开始,最简单的代码可能是使用Vert.x编写一个Wiki。而下一次迭代将在代码库中引入更多的简洁以及适当的测试,我们将看到基于Vert.x的快速原型是一个既简单又现实的目标。
现阶段,Wiki将使用HTML页面的服务端渲染以及通过JDBC链接进行数据持久化。为了完成这些,我们将使用以下库。
- Vert.x Web,虽然Vert.x核心库支持HTTP服务器的创建,但是它未提供优雅的API来处理路由、请求荷载处理等。
- Vert.x JDBC client,提供一套JDBC的异步API。
- Apache FreeMarker,用于渲染服务端页面,它是一个简单的模板引擎。
- Txtmark,用于将Markdown文本渲染为HTML,允许以Markdown编辑Wiki页面。
2.1 引导一个Maven项目
该指南选择使用Apache Maven作为构建工具,主要因为它与主要的集成开发环境集成的非常好。你同样可以使用其它的构建工具,如Gradle。
Vert.x社区在提供了一个可以克隆的模板项目结构。由于你也可能希望使用(Git)版本控制,因此最快的途径是克隆仓库、删除./git目录,然后创建一个新的Git仓库:
git clone https://github.com/vert-x3/vertx-maven-starter.git vertx-wiki
cd vertx-wiki
rm -rf .git
git init
该项目提供了一个样例Verticle以及一个单元测试。你可以安全的删除src/目录下的所有.java文件来修改Wiki,但是在这么做之前,您可以先测试一下项目是否成功构建和运行。
mvn package exec:java
你将注意到Maven项目的pom.xml文件做了两件有意思的事情:
- 它使用Maven Shade Plugin创建了一个包含所有需要依赖的单个JAR的归档文件,后缀为-fat.jar,也称为“FatJar”。
- 它使用Exec Maven Plugin来提供exec:java目标(goal),通过Vert.x的io.vertx.core.Launcher类依次启动应用。这实际上等价于使用Vert.x发行版中提供的vertx命令行工具运行。
最后,你会注意到redeploy.sh和redeploy.bat脚本的存在,你可以相应的使用它们来自动编译和重新部署变更的代码。注意,这样做需要确保脚本中的VERTICLE变量与使用的主Verticle匹配。
另外,Fabric8项目提供了一个Vert.x Maven插件。它包含了初始化、构建、打包及运行一个Vert.x项目的goal。
与克隆Git starter仓库一样生成一个类似项目:
mkdir vertx-wiki
cd vertx-wiki
mvn io.fabric8:vertx-maven-plugin:1.0.7:setup -DvertxVersion=3.5.0
git init
2.2 添加需要的依赖
添加到Maven pom.xml文件中的第一批依赖项是用于Web处理和渲染的:
io.vertx
vertx-web
io.vertx
vertx-web-templ-freemarker
com.github.rjeschke
txtmark
0.13
正如vertx-web-templ-freemarker的名字所示,Vert.x Web对流行的模板引擎提供了可插拔的支持: Handlebars, Jade, MVEL, Pebble, Thymeleaf以及Freemarker。
第二部分依赖是JDBC数据库访问需要的:
io.vertx
vertx-jdbc-client
org.hsqldb
hsqldb
2.3.4
Vert.x JDBC客户端库提供了任何JDBC兼容数据库的访问,当然我们的项目需要在类路径有一个JDBC驱动。
HSQLDB是一款知名的使用Java编写的关系数据库。它在作为嵌入式数据库使用,从而避免需要独立运行第三方数据库服务器的时候非常受欢迎。它还在单元测试以及集成测试方面比较受欢迎,因为它提供了一个(轻快的)内存存储。
HSQLDB作为一个嵌入式数据库,非常适合我们的入门。它存储数据到本地文件,由于HSQLDB库的JAR提供了一个JDBC驱动,Vert.x JDBC配置将非常直接。
Vert.x还提供了专用的MySQL和PostgreSQL客户端库。
当然,你可以使用通用的Vert.x JDBC客户端连接MySQL或PostgreSQL数据库,但是与阻塞的JDBC API相比,通过使用这两个数据库服务器的网络协议,这些库提供了更好的性能。
Vert.x也提供了处理流行的非关系型数据库MongoDB和Redis的库。广泛的社区还提供了与其它存储系统的集成,如Apache Cassandra、OrientDB或ElasticSearch。
2.3 Verticle剖析
我们Wiki的Verticle由一个单独的io.vertx.guides.wiki.MainVerticle类组成。这个类扩展自io.vertx.core.AbstractVerticle(主要提供的Verticle的基类):
- 复写生命周期的start和stop方法。
- 一个名为vertx的保护属性,它是该Verticle被部署到的Vert.x环境的引用。
- 配置对象的访问器,允许向Verticle传递外部配置。
开始,我们的Verticle只需要按下面复写start方法:
public class MainVerticle extends AbstractVerticle {
@Override
public void start(Future startFuture) throws Exception {
startFuture.complete();
}
}
存在两种形式的start(和stop)方法:一个是无参的,另一个有一个future对象引用。无参的变体方法意味着Verticle初始化或者内务阶段总是成功,除非抛出一个异常。包含future对象参数的变体方法提供了一个更细粒度的方法来在最后指示操作是否成功。事实上,一些初始化或清理代码可能需要异步操作,因此通过future对象进行报告理所当然符合异步风格。
2.4 关于future对象和回调的一两句话
Vert.x future不是JDK的future:它们可以组装以及以非阻塞的方式查询。它们应用于异步任务的简单协调,尤其是Verticle部署和检查它们是否部署成功。
Vert.x核心API基于异步事件通知回调。经验丰富的开发人员自然会想到这打开了称为“回调地狱”的大门,多个层次的嵌套回调使得代码难以理解,如该虚构代码所示:
foo.a(1, res1 -> {
if (res1.succeeded()) {
bar.b("abc", 1, res2 -> {
if (res.succeeded()) {
baz.c(res3 -> {
dosomething(res1, res2, res3, res4 -> {
// (...)
});
});
}
});
}
});
虽然核心API被设计成支持Promise和Future,但选择回调实际上是有意思的,因为它允许使用不同的编程抽象。Vert.x是一个总体上非教条的(un-opinionated)项目,而且回调允许不同模型的实现,以更好的应对异步编程:响应式扩展(通过RxJava)、Promise和Future、fiber(使用字节码手段),等。
由于在利用其它诸如RxJava等抽象之前Vert.x所有API都是面向回调的,本指南在第一部分只使用回调,以确保读者熟悉Vert.x的核心概念。从回调开始,在异步代码的多部分之间画一条界线也是比较容易的。一旦回调不总是易于阅读的,这个问题在样例代码中变得明显,我们将引入RxJava支持来展示如何通过考虑处理事件Stream以更好的表示同样的异步代码。
2.5 Wiki Verticle初始化阶段
为了使我们的wiki运行,我们需要执行一个两阶段初始化:
- 我们需要建立一个JDBC数据库链接,还要确保数据库结构准备就绪。
- 我们需要为Web应用启动一个HTTP Server。
每个阶段都可能失败(如HTTP Server的TCP端口已经被占用),因此它们不应并行执行,因为Web应用代码首先需要数据库访问来工作。
为了使我们的代码更整洁,我们为每个阶段定义一个方法,采取返回一个future/promise对象的模式通知每个阶段什么时候完成,以及它是否成功。
private Future prepareDatabase() {
Future future = Future.future();
// (...)
return future;
}
private Future startHttpServer() {
Future future = Future.future();
// (...)
return future;
}
通过每个方法返回一个future对象,start方法的实现变为一个组装(composition):
@Override
public void start(Future startFuture) throws Exception {
Future steps = prepareDatabase().compose(v -> startHttpServer());
steps.setHandler(startFuture.completer());
}
当prepareDatabase的future成功完成时,startHttpServer被调用,steps完成依赖于startHttpServer返回future的结果。如果prepareDatabase遇到错误,startHttpServer永远不会调用,这种情况下,steps future处于failed状态,提示描述错误的异常并完成。
最后steps完成:setHandler定义了一个handler,它在完成时调用。我们这种情况,我们只想使用steps完成startFuture,使用completer方法获取一个handler。这等价于:
Future steps = prepareDatabase().compose(v -> startHttpServer());
steps.setHandler(ar -> { ①
if (ar.succeeded()) {
startFuture.complete();
} else {
startFuture.fail(ar.cause());
}
});
① ar是AsyncResult
2.5.1 数据库初始化
Wiki的数据库结构有唯一的一张表Pages组成,包含以下列:
列名 | 类型 | 描述 |
---|---|---|
Id | 整型 | 主键 |
Name | 字符型 | Wiki页面的名称,必须唯一 |
Content | 文本 | 一个Wiki页面的Markdown文本 |
数据库操作通常是创建、读、更新、删除操作。一开始,我们先简单的将相应的SQL查询存储在MainVerticle类的静态属性中。注意,它们按照HSQLDB理解的SQL方言编写,但是其它关系数据库可能并不一定支持:
private static final String SQL_CREATE_PAGES_TABLE = "create table if not exists Pages (Id integer identity primary key,
Name varchar(255) unique, Content clob)";
private static final String SQL_GET_PAGE = "select Id, Content from Pages where Name = ?"; ①
private static final String SQL_CREATE_PAGE = "insert into Pages values (NULL, ?, ?)";
private static final String SQL_SAVE_PAGE = "update Pages set Content = ? where Id = ?";
private static final String SQL_ALL_PAGES = "select Name from Pages";
private static final String SQL_DELETE_PAGE = "delete from Pages where Id = ?";
① 查询中的?是执行查询时传递数据的占位符,Vert.x JDBC客户端可以由此阻止SQL注入。
应用程序Verticle需要保持一个JDBCClient对象的引用(来自io.vertx.ext.jdbc包)作为对数据库的链接。我们通过使用MainVerticle中的一个属性实现,同时我们还创建了一个通用的logger,来自org.slf4j包:
private JDBCClient dbClient;
private static final Logger LOGGER = LoggerFactory.getLogger(MainVerticle.class);
最后但是同样重要,这是prepareDatabase方法的完整实现。它尝试获取一个JDBC client链接,然后执行一个SQL查询创建Pages表(除非它已经存在)。
private Future prepareDatabase() {
Future future = Future.future();
dbClient = JDBCClient.createShared(vertx, new JsonObject() ①
.put("url", "jdbc:hsqldb:file:db/wiki") ②
.put("driver_class", "org.hsqldb.jdbcDriver") ③
.put("max_pool_size", 30)); ④
dbClient.getConnection(ar -> { ⑤
if (ar.failed()) {
LOGGER.error("Could not open a database connection", ar.cause());
future.fail(ar.cause()); ⑥
} else {
SQLConnection connection = ar.result(); ⑦
connection.execute(SQL_CREATE_PAGES_TABLE, create -> {
connection.close(); ⑧
if (create.failed()) {
LOGGER.error("Database preparation error", create.cause());
future.fail(create.cause());
} else {
future.complete(); ⑨
}
});
}
});
return future;
}
① createShared方法用于创建一个共享的链接,它在vertx实例已知的Verticle之间共享,这一般来说是件好事。
② JDBC客户端链接通过传递一个Vert.x JSON对象来构造。此处url是JDBC URL。
③ 就像url,driver_class指定了使用的JDBC驱动,并指向驱动类。
④ max_pool_size是并发链接的数量。我们此处选择30,这只是一个随意的数字。
⑤ 获得链接是一个异步操作,并且返回给我们一个AsyncResult
⑥ 如果SQL链接不能获取,future的方法完成为fail,AsyncResult通过cause方法提供了异常信息。
⑦ SQLConnection是成功的AsyncResult的结果,我们可以使用它来执行一个SQL查询。
⑧ 在检查SQL查询成功与否之前,我们必须通过调用close释放它,否则JDBC客户端连接池最终会耗尽。
⑨ 我们使用一个success完成future对象的方法。
Vert.x项目提供的SQL数据库模块现在没提供任何超越SQL查询的东西(例如一个对象映射器),因为它们集中于提供数据库的异步访问。
尽管如此,它并未禁止使用来自社区的更先进的模块,我们特别推荐检出项目诸如用于Vert.x的jOOq生成器或者POJO映射器。
2.5.2 关于日志的注记
前面的子章节还引入了一个logger,我们选择的是SLFJ库。Vert.x对于日志也是非教条的(unopinionated):你可以选择任何流行的Java日志库。我们推荐使用SLF4J,因为它是Java生态系统中一个流行的日志抽象和统一库。
我们还推荐使用Logback作为logger实现。集成SLF4J和Logback可以通过添加两个依赖完成,或者只添加logback-classic以指向两个库(顺便说一下,它们来自同一作者)。
ch.qos.logback
logback-classic
1.2.3
默认情况下,SLF4J输出来自Vert.x、Netty、C3PO和Wiki应用的很多日志事件到控制台。我们可以通过添加一个src/main/resources/logback.xml配置文件以减少冗余信息(查看https://logback.qos.ch/了解更多):
%d{HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n
最后但是同样重要,HSQLDB在嵌入式的情况下不能与logger很好的集成。默认情况下,它尝试重新配置日志系统来替代,因此在执行应用时,我们必须通过传递一个-Dhsqldb.reconfig_logging=false属性给Java虚拟机来禁用它。
2.5.3 HTTP Server初始化
HTTP Server使用vertx-web项目轻易的为接收的HTTP请求定义分发路由(dispatching routes)。实际上,Vert.x核心API可以启动HTTP Server,并监听进入的链接,但是它未提供任何能力,比如说依赖于请求URL或者处理请求体指定不同的Handler。这是Router角色,它依赖于URL、HTTP方法等,分发请求到不同的处理Handler。
初始化包括设置请求路由器,然后启动HTTP Server:
private Future startHttpServer() {
Future future = Future.future();
HttpServer server = vertx.createHttpServer(); ①
Router router = Router.router(vertx); ②
router.get("/").handler(this::indexHandler);
router.get("/wiki/:page").handler(this::pageRenderingHandler); ③
router.post().handler(BodyHandler.create()); ④
router.post("/save").handler(this::pageUpdateHandler);
router.post("/create").handler(this::pageCreateHandler);
router.post("/delete").handler(this::pageDeletionHandler);
server.requestHandler(router::accept) ⑤
.listen(8080, ar -> { ⑥
if (ar.succeeded()) {
LOGGER.info("HTTP server running on port 8080");
future.complete();
} else {
LOGGER.error("Could not start a HTTP server", ar.cause());
future.fail(ar.cause());
}
});
return future;
}
① vertx上下文对象提供了创建HTTP服务器、客户端、TCP/UDP服务器和客户端等的方法。
② Router类来自vertx-web: io.vertx.ext.web.Router。
③ 路由有它们自己的Handler,它们可以通过URL和/或HTTP方法定义。为了简化Handler,Java lambda是一个选择,但是对于更复杂的Handler,引用私有方法作为替代是个好主意。注意URL可以支持参数变量:/wiki/:page将匹配一个请求如/wiki/Hello,这种情况下一个page参数可供使用,值为Hello。
④ 这使得所有HTTP POST请求都通过一个第一个Handler,此处是io.vertx.ext.web.handler.BodyHandler。这个Handler自动从HTTP请求解码请求体(如表单提交),接下来可以将其作为Vert.x缓冲对象来操作。
⑤ router对象可以被用作HTTP服务器Handler,它接下来分发请求到上面定义的其它Handler。
⑥ 启动HTTP服务器是一个异步操作,因此一个AsyncResult
2.6 HTTP路由处理(router handler)
startHttpServer方法的HTTP Router实例依据URL模式和HTTP方法指向不同的Handler。每个Handler处理HTTP请求,执行一个数据库查询,并且从FreeMarker模板渲染HTML。
2.6.1 Index页面的Handler
index页面提供了一个列表指向所有的Wiki记录,同时有一个html域(field)来创建一个新Wiki:
它的实现是一个直截了当的select * SQL查询,然后数据传递到FreeMarker引擎渲染HTML响应。
indexHandler方法的代码如下:
private final FreeMarkerTemplateEngine templateEngine = FreeMarkerTemplateEngine.create();
private void indexHandler(RoutingContext context) {
dbClient.getConnection(car -> {
if (car.succeeded()) {
SQLConnection connection = car.result();
connection.query(SQL_ALL_PAGES, res -> {
connection.close();
if (res.succeeded()) {
List pages = res.result() ①
.getResults()
.stream()
.map(json -> json.getString(0))
.sorted()
.collect(Collectors.toList());
context.put("title", "Wiki home"); ②
context.put("pages", pages);
templateEngine.render(context, "templates", "/index.ftl", ar -> { ③
if (ar.succeeded()) {
context.response().putHeader("Content-Type", "text/html");
context.response().end(ar.result()); ④
} else {
context.fail(ar.cause());
}
});
} else {
context.fail(res.cause()); ⑤
}
});
} else {
context.fail(car.cause());
}
});
}
① SQL查询结果作为JsonArray和JsonObject实例返回。
② RoutingContext实例可以被用于设置任意键值数据,这些键值接下来可以从模板中或者链式router handler中获取。
③ 渲染模板是一个异步操作,这导致我们采用通常的AsyncResult处理模式。
④ AsyncResult在成功的情况下包含模板,渲染为一个String,我们可以使用这个值结束(end)HTTP响应流。
⑤ 失败的情况下,RoutingContext的fail方法提供了一个明智(sensible)的方法返回HTTP 500错误到HTTP客户端。
FreeMarker模板位于src/main/resources/templates目录。index.ftl模板代码如下:
<#include "header.ftl">
${context.title}
<#list context.pages>
Pages:
<#items as page>
-
${page}
#items>
<#else>
The wiki is currently empty!
#list>
<#include "footer.ftl">
存储在RoutingContext对象中的Key/Value数据,可以通过Freemarker的context变量使用。
由于大量的模板有通用的header和footer,我们提取下面的代码到header.ftl和footer.ftl中:
header.ftl
${context.title} | A Sample Vert.x-powered Wiki
footer.ftl
2.6.2 Wiki页面渲染Handler
这个Handler处理HTTP GET请求,渲染Wiki 页面,如:
页面还提供了一个按钮来在Markdown中编辑内容。编辑没有独立的Handler和模板,我们简单的依靠JavaScript和CSS来在按钮点击时切换编辑器开和关:
pageRenderingHandler方法的代码如下:
private static final String EMPTY_PAGE_MARKDOWN =
"# A new page\n" +
"\n" +
"Feel-free to write in Markdown!\n";
private void pageRenderingHandler(RoutingContext context) {
String page = context.request().getParam("page"); ①
dbClient.getConnection(car -> {
if (car.succeeded()) {
SQLConnection connection = car.result();
connection.queryWithParams(SQL_GET_PAGE, new JsonArray().add(page), fetch -> { ②
connection.close();
if (fetch.succeeded()) {
JsonArray row = fetch.result().getResults()
.stream()
.findFirst()
.orElseGet(() -> new JsonArray().add(-1).add(EMPTY_PAGE_MARKDOWN));
Integer id = row.getInteger(0);
String rawContent = row.getString(1);
context.put("title", page);
context.put("id", id);
context.put("newPage", fetch.result().getResults().size() == 0 ? "yes" : "no");
context.put("rawContent", rawContent);
context.put("content", Processor.process(rawContent)); ③
context.put("timestamp", new Date().toString());
templateEngine.render(context, "templates", "/page.ftl", ar -> {
if (ar.succeeded()) {
context.response().putHeader("Content-Type", "text/html");
context.response().end(ar.result());
} else {
context.fail(ar.cause());
}
});
} else {
context.fail(fetch.cause());
}
});
} else {
context.fail(car.cause());
}
});
}
① URL参数(/wiki/:name)可以通过context请求对象访问。
② 传递参数值给SQL查询通过一个JsonArray完成,元素按照SQL查询中?符号的顺序。
③ Processor类来自我们使用的txtmark Markdown渲染库。
page.ftl FreeMarker模板代码如下:
<#include "header.ftl">
<#include "footer.ftl">
2.6.3 页面创建Handler
index页面提供了一个html域(field)来创建一个新的Wiki页面,它所在的HTML表单指向的URL由这个Handler管理。这个Handler的处理策略不是在数据库中实际创建一个新的记录,而是简单的带着需要创建的名称定向到一个Wiki页面URL。由于Wiki页面不存在,pageRenderingHandler方法将为新页面使用一个默认的文本,最后用户可以通过编辑并且保存来创建这个页面。
它的Handler是pageCreateHandler方法,它的实现是通过一个303状态码进行的重定向,:
private void pageCreateHandler(RoutingContext context) {
String pageName = context.request().getParam("name");
String location = "/wiki/" + pageName;
if (pageName == null || pageName.isEmpty()) {
location = "/";
}
context.response().setStatusCode(303);
context.response().putHeader("Location", location);
context.response().end();
}
2.6.4 页面保存Handler
pageUpdateHandler方法用于在保存一个Wiki页面时处理HTTP POST请求。这在更新一个已存在的页面(发出一条SQL更新查询)或保存一个新的页面(发出一条SQL插入查询)时发生:
private void pageUpdateHandler(RoutingContext context) {
String id = context.request().getParam("id"); ①
String title = context.request().getParam("title");
String markdown = context.request().getParam("markdown");
boolean newPage = "yes".equals(context.request().getParam("newPage")); ②
dbClient.getConnection(car -> {
if (car.succeeded()) {
SQLConnection connection = car.result();
String sql = newPage ? SQL_CREATE_PAGE : SQL_SAVE_PAGE;
JsonArray params = new JsonArray(); ③
if (newPage) {
params.add(title).add(markdown);
} else {
params.add(markdown).add(id);
}
connection.updateWithParams(sql, params, res -> { ④
connection.close();
if (res.succeeded()) {
context.response().setStatusCode(303); ⑤
context.response().putHeader("Location", "/wiki/" + title);
context.response().end();
} else {
context.fail(res.cause());
}
});
} else {
context.fail(car.cause());
}
});
}
① 表单参数通过一个HTTP POST请求发送,并且可以通过RoutingContext对象访问。注意如果在Router配置链中没有BodyHandler,那么这些值将不是有效的,表单提交的荷载(payload)需要手动从HTTP POST请求荷载中解码。
② 我们通过FreeMarker模板page.ftl中渲染的一个hidden表单域(newPage)来判断我们是在更新已存在的页面还是保存一个新页面。
③ 再一次,使用一个JsonArray传递值来准备(prepareing)带参数的SQL查询。
④ updateWithParams方法用于insert/update/delete SQL查询。
⑤ 成功时,我们简单的重定向到被编辑的页面。
2.6.5 页面删除Handler
pageDeletionHandler方法的实现是明确的:给定一个Wiki记录的标识,它发出一个delete SQL查询,并重定向到Wiki的index页面:
private void pageDeletionHandler(RoutingContext context) {
String id = context.request().getParam("id");
dbClient.getConnection(car -> {
if (car.succeeded()) {
SQLConnection connection = car.result();
connection.updateWithParams(SQL_DELETE_PAGE, new JsonArray().add(id), res -> {
connection.close();
if (res.succeeded()) {
context.response().setStatusCode(303);
context.response().putHeader("Location", "/");
context.response().end();
} else {
context.fail(res.cause());
}
});
} else {
context.fail(car.cause());
}
});
}
2.7 运行应用
到这一步,我们有了一个可工作的、自包含的Wiki应用。
为了运行它,我们首先需要使用Maven构建它:
$mvn clean package
由于构建产生了一个包含了所有需要依赖的JAR(包含Vert.x和一个JDBC数据库),因此运行Wiki简单如:
$ java -jar target/wiki-step-1-1.2.0-fat.jar
你接下来可以将你喜欢的浏览器指向http://localhost:8080,享受使用这个Wiki。