感興趣的朋友,可以關注微信服務號“猿學堂社區”,或加入“猿學堂社區”微信交流群
版權聲明:本文由作者自行翻譯,未經作者授權,不得隨意轉發。
通過第一次迭代,我們得到了一個可工作的Wiki應用。然而它的實現存在以下問題:
- HTTP請求處理和數據庫訪問代碼交織在相同的方法中。
- 大量配置數據(如端口號、JDBC驅動等)是代碼中的硬編碼字符串。
3.1 架構和技術選擇
第二次迭代是關于重構代碼為獨立可重用Verticle的:
我們將部署兩個Verticle來處理HTTP請求,一個Verticle封裝數據庫持久化。由此產生的Verticle將沒有相互的直接引用,它們將只商定事件總線中的目的地名稱以及消息格式。這種方式提供了一個簡單但有效的解耦。
發送到事件總線的消息將解碼為JSON。雖然Vert.x的事件總線支持靈活的串行化方案用于高要求或者高度定制的上下文,但是使用JSON數據通常是明智的選擇。使用JSON的另一個優勢是它是一種語言無關的格式。由于Vert.x是支持多語言的,對于使用不同語言編寫的Verticle之間的通訊,JSON是非常理想的。
3.2 HTTP Server Verticle
Verticle類開端及start方法看起來如下:
public class HttpServerVerticle extends AbstractVerticle {
private static final Logger LOGGER = LoggerFactory.getLogger(HttpServerVerticle.class);
public static final String CONFIG_HTTP_SERVER_PORT = "http.server.port"; ①
public static final String CONFIG_WIKIDB_QUEUE = "wikidb.queue";
private String wikiDbQueue = "wikidb.queue";
@Override
public void start(Future<Void> startFuture) throws Exception {
wikiDbQueue = config().getString(CONFIG_WIKIDB_QUEUE, "wikidb.queue"); ②
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);
int portNumber = config().getInteger(CONFIG_HTTP_SERVER_PORT, 8080); ③
server
.requestHandler(router::accept)
.listen(portNumber, ar -> {
if (ar.succeeded()) {
LOGGER.info("HTTP server running on port " + portNumber);
startFuture.complete();
} else {
LOGGER.error("Could not start a HTTP server", ar.cause());
startFuture.fail(ar.cause());
}
});
}
// (...)
① 我們暴露了公開的常量用于Verticle配置參數:HTTP端口號以及發送消息到數據庫Verticle的事件總線目的地名稱。
② AbstractVerticle#config()方法允許訪問已提供的Verticle配置。對于沒有指定值的情況,第二個參數是默認值。
③ 配置值不只可以是字符串,也可以是整數、布爾值以及復雜的JSON數據等。
該類剩余部分主要是提取HTTP部分的代碼,以前的數據庫代碼通過事件總線消息替換。這是indexHandler方法的代碼:
private final FreeMarkerTemplateEngine templateEngine = FreeMarkerTemplateEngine.create();
private void indexHandler(RoutingContext context) {
DeliveryOptions options = new DeliveryOptions().addHeader("action", "all-pages"); ②
vertx.eventBus().send(wikiDbQueue, new JsonObject(), options, reply -> { ①
if (reply.succeeded()) {
JsonObject body = (JsonObject) reply.result().body(); ③
context.put("title", "Wiki home");
context.put("pages", body.getJsonArray("pages").getList());
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(reply.cause());
}
});
}
① vertx對象提供了對事件總線的訪問,我們發送一個消息到數據庫Verticle的隊列。
② 傳遞選項(DeliveryOptions)允許我們指定頭、有效載荷(payload)編解碼器和超時時間。
③ 一旦成功,回復包含有效載荷。
正如我們所看到的,事件總線消息由一個消息體和選項組成,它可以選擇性地期待一個答復。對于沒有預期答復的情況,有一個send方法的變體,它沒有Handler參數。
我們將有效載荷編碼為JSON對象,并通過一個稱為action的消息頭指定數據庫Verticle應該執行哪個操作。
Verticle的剩余代碼就是路由器處理器,同樣使用事件總線獲取和存儲數據:
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 requestedPage = context.request().getParam("page");
JsonObject request = new JsonObject().put("page", requestedPage);
DeliveryOptions options = new DeliveryOptions().addHeader("action",
"get-page");
vertx.eventBus().send(
wikiDbQueue,
request,
options,
reply -> {
if (reply.succeeded()) {
JsonObject body = (JsonObject) reply.result().body();
boolean found = body.getBoolean("found");
String rawContent = body.getString("rawContent",
EMPTY_PAGE_MARKDOWN);
context.put("title", requestedPage);
context.put("id", body.getInteger("id", -1));
context.put("newPage", found ? "no" : "yes");
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(reply.cause());
}
});
}
private void pageUpdateHandler(RoutingContext context) {
String title = context.request().getParam("title");
JsonObject request = new JsonObject()
.put("id", context.request().getParam("id"))
.put("title", title)
.put("markdown", context.request().getParam("markdown"));
DeliveryOptions options = new DeliveryOptions();
if ("yes".equals(context.request().getParam("newPage"))) {
options.addHeader("action", "create-page");
} else {
options.addHeader("action", "save-page");
}
vertx.eventBus().send(wikiDbQueue, request, options, reply -> {
if (reply.succeeded()) {
context.response().setStatusCode(303);
context.response().putHeader("Location", "/wiki/" + title);
context.response().end();
} else {
context.fail(reply.cause());
}
});
}
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();
}
private void pageDeletionHandler(RoutingContext context) {
String id = context.request().getParam("id");
JsonObject request = new JsonObject().put("id", id);
DeliveryOptions options = new DeliveryOptions().addHeader("action",
"delete-page");
vertx.eventBus().send(wikiDbQueue, request, options, reply -> {
if (reply.succeeded()) {
context.response().setStatusCode(303);
context.response().putHeader("Location", "/");
context.response().end();
} else {
context.fail(reply.cause());
}
});
}
3.3 數據庫Verticle
使用JDBC鏈接到一個數據庫當然需要數據庫驅動以及配置,這些我們在第一次迭代中采用硬編碼的方式實現。
3.3.1 配置SQL查詢
在將前面Verticle的硬編碼值轉換為配置參數的同時,我們還可以進一步從一個配置文件中加載SQL查詢。
查詢語句將從一個文件中加載,這個文件名作為一個配置參數傳遞,如果沒有提供則從一個默認資源加載。這個方法的優點是Verticle可以適配不同的JDBC驅動和SQL方言。
Verticle類的開端包含了主要的配置鍵的定義:
public class WikiDatabaseVerticle extends AbstractVerticle {
public static final String CONFIG_WIKIDB_JDBC_URL = "wikidb.jdbc.url";
public static final String CONFIG_WIKIDB_JDBC_DRIVER_CLASS = "wikidb.jdbc.driver_class";
public static final String CONFIG_WIKIDB_JDBC_MAX_POOL_SIZE = "wikidb.jdbc.max_pool_size";
public static final String CONFIG_WIKIDB_SQL_QUERIES_RESOURCE_FILE = "wikidb.sqlqueries.resource.file";
public static final String CONFIG_WIKIDB_QUEUE = "wikidb.queue";
private static final Logger LOGGER = LoggerFactory.getLogger(WikiDatabaseVerticle.class);
// (...)
SQL查詢存儲在一個Properties文件中,使用HSQLDB的默認文件位于src/main/resources/db-queries.properties:
create-pages-table=create table if not exists Pages (Id integer identity primary key, Name varchar(255) unique, Content
clob)
get-page=select Id, Content from Pages where Name = ?
create-page=insert into Pages values (NULL, ?, ?)
save-page=update Pages set Content = ? where Id = ?
all-pages=select Name from Pages
delete-page=delete from Pages where Id = ?
WikiDdatabaseVerticle中的以下代碼用于從文件中加載SQL查詢,并將它們放到一個map中:
private enum SqlQuery {
CREATE_PAGES_TABLE,
ALL_PAGES,
GET_PAGE,
CREATE_PAGE,
SAVE_PAGE,
DELETE_PAGE
}
private final HashMap<SqlQuery, String> sqlQueries = new HashMap<>();
private void loadSqlQueries() throws IOException {
String queriesFile = config().getString(CONFIG_WIKIDB_SQL_QUERIES_RESOURCE_FILE);
InputStream queriesInputStream;
if (queriesFile != null) {
queriesInputStream = new FileInputStream(queriesFile);
} else {
queriesInputStream = getClass().getResourceAsStream("/db-queries.properties");
}
Properties queriesProps = new Properties();
queriesProps.load(queriesInputStream);
queriesInputStream.close();
sqlQueries.put(SqlQuery.CREATE_PAGES_TABLE, queriesProps.getProperty("create-pages-table"));
sqlQueries.put(SqlQuery.ALL_PAGES, queriesProps.getProperty("all-pages"));
sqlQueries.put(SqlQuery.GET_PAGE, queriesProps.getProperty("get-page"));
sqlQueries.put(SqlQuery.CREATE_PAGE, queriesProps.getProperty("create-page"));
sqlQueries.put(SqlQuery.SAVE_PAGE, queriesProps.getProperty("save-page"));
sqlQueries.put(SqlQuery.DELETE_PAGE, queriesProps.getProperty("delete-page"));
}
在接下來的代碼中,我們使用SqlQuery枚舉類型以避免字符串常量。Verticle的start方法代碼如下:
private JDBCClient dbClient;
@Override
public void start(Future<Void> startFuture) throws Exception {
/*
* Note: this uses blocking APIs, but data is small...
*/
loadSqlQueries(); ①
dbClient = JDBCClient.createShared(vertx, new JsonObject()
.put("url", config().getString(CONFIG_WIKIDB_JDBC_URL, "jdbc:hsqldb:file:db/wiki"))
.put("driver_class", config().getString(CONFIG_WIKIDB_JDBC_DRIVER_CLASS, "org.hsqldb.jdbcDriver"))
.put("max_pool_size", config().getInteger(CONFIG_WIKIDB_JDBC_MAX_POOL_SIZE, 30)));
dbClient.getConnection(ar -> {
if (ar.failed()) {
LOGGER.error("Could not open a database connection", ar.cause());
startFuture.fail(ar.cause());
} else {
SQLConnection connection = ar.result();
connection.execute(sqlQueries.get(SqlQuery.CREATE_PAGES_TABLE), create -> { ②
connection.close();
if (create.failed()) {
LOGGER.error("Database preparation error", create.cause());
startFuture.fail(create.cause());
} else {
vertx.eventBus().consumer(config().getString(CONFIG_WIKIDB_QUEUE, "wikidb.queue"), this::onMessage); ③
startFuture.complete();
}
});
}
});
}
① 有趣的是,我們打破了Vert.x中的一個重要的原則——就是避免阻塞API,但是因為沒有異步API來訪問類路徑上的資源,所以我們的選擇是受限的。我們可以使用Vert.x的executeBlocking方法將阻塞的I/O操作從事件循環轉移到工作者線程,但是由于數據非常小,這么做沒有明顯的效益。
② 這兒是使用SQL查詢的一個示例。
③ consumer方法注冊了一個事件總線目的地Handler。
3.3.2 分發請求
事件總線消息的Handler是onMessage方法:
public enum ErrorCodes {
NO_ACTION_SPECIFIED,
BAD_ACTION,
DB_ERROR
}
public void onMessage(Message<JsonObject> message) {
if (!message.headers().contains("action")) {
LOGGER.error("No action header specified for message with headers {} and body {}",
message.headers(), message.body().encodePrettily());
message.fail(ErrorCodes.NO_ACTION_SPECIFIED.ordinal(), "No action header specified");
return;
}
String action = message.headers().get("action");
switch (action) {
case "all-pages":
fetchAllPages(message);
break;
case "get-page":
fetchPage(message);
break;
case "create-page":
createPage(message);
break;
case "save-page":
savePage(message);
break;
case "delete-page":
deletePage(message);
break;
default:
message.fail(ErrorCodes.BAD_ACTION.ordinal(), "Bad action: " + action);
}
}
我們為錯誤定義了一個ErrorCodes枚舉,用來報回消息發送者。為此,Message類的fail方法提供了一個便捷方式答復錯誤,原消息發送者得到一個失敗的AsyncResult.
3.3.3 減少JDBC客戶端樣板文件(譯者注:原文使用的是boilerplate,應該指的是那些重復的代碼)
截止目前,我們可以看到執行一個SQL查詢的完整交互:
- 獲取一個鏈接
- 執行請求
- 釋放鏈接
這導致對每個異步操作需要異常處理的地方都需要編碼,如下:
dbClient.getConnection(car -> {
if (car.succeeded()) {
SQLConnection connection = car.result();
connection.query(sqlQueries.get(SqlQuery.ALL_PAGES), res -> {
connection.close();
if (res.succeeded()) {
List<String> pages = res.result()
.getResults()
.stream()
.map(json -> json.getString(0))
.sorted()
.collect(Collectors.toList());
message.reply(new JsonObject().put("pages", new JsonArray(pages)));
} else {
reportQueryError(message, res.cause());
}
});
} else {
reportQueryError(message, car.cause());
}
});
自從Vert.x 3.5.0開始,JDBC客戶端現在支持一次性(one-shot)操作,獲取一個鏈接執行一個SQL操作,并且在內部釋放。與前面相同的代碼現在簡化如下:
dbClient.query(sqlQueries.get(SqlQuery.ALL_PAGES), res -> {
if (res.succeeded()) {
List<String> pages = res.result()
.getResults()
.stream()
.map(json -> json.getString(0))
.sorted()
.collect(Collectors.toList());
message.reply(new JsonObject().put("pages", new JsonArray(pages)));
} else {
reportQueryError(message, res.cause());
}
});
這對于獲取數據庫鏈接執行一個單獨操作的情況非常有用。但就性能而言,需要注意的是,對于鏈式的SQL操作,重用數據庫鏈接會更好。
該類的剩余部分包含了onMessage分發接收消息時的私有方法調用:
private void fetchAllPages(Message<JsonObject> message) {
dbClient.query(sqlQueries.get(SqlQuery.ALL_PAGES), res -> {
if (res.succeeded()) {
List<String> pages = res.result()
.getResults()
.stream()
.map(json -> json.getString(0))
.sorted()
.collect(Collectors.toList());
message.reply(new JsonObject().put("pages", new JsonArray(pages)));
} else {
reportQueryError(message, res.cause());
}
});
}
private void fetchPage(Message<JsonObject> message) {
String requestedPage = message.body().getString("page");
JsonArray params = new JsonArray().add(requestedPage);
dbClient.queryWithParams(sqlQueries.get(SqlQuery.GET_PAGE), params, fetch -> {
if (fetch.succeeded()) {
JsonObject response = new JsonObject();
ResultSet resultSet = fetch.result();
if (resultSet.getNumRows() == 0) {
response.put("found", false);
} else {
response.put("found", true);
JsonArray row = resultSet.getResults().get(0);
response.put("id", row.getInteger(0));
response.put("rawContent", row.getString(1));
}
message.reply(response);
} else {
reportQueryError(message, fetch.cause());
}
});
}
private void createPage(Message<JsonObject> message) {
JsonObject request = message.body();
JsonArray data = new JsonArray()
.add(request.getString("title"))
.add(request.getString("markdown"));
dbClient.updateWithParams(sqlQueries.get(SqlQuery.CREATE_PAGE), data, res -> {
if (res.succeeded()) {
message.reply("ok");
} else {
reportQueryError(message, res.cause());
}
});
}
private void savePage(Message<JsonObject> message) {
JsonObject request = message.body();
JsonArray data = new JsonArray()
.add(request.getString("markdown"))
.add(request.getString("id"));
dbClient.updateWithParams(sqlQueries.get(SqlQuery.SAVE_PAGE), data, res -> {
if (res.succeeded()) {
message.reply("ok");
} else {
reportQueryError(message, res.cause());
}
});
}
private void deletePage(Message<JsonObject> message) {
JsonArray data = new JsonArray().add(message.body().getString("id"));
dbClient.updateWithParams(sqlQueries.get(SqlQuery.DELETE_PAGE), data, res -> {
if (res.succeeded()) {
message.reply("ok");
} else {
reportQueryError(message, res.cause());
}
});
}
private void reportQueryError(Message<JsonObject> message, Throwable cause) {
LOGGER.error("Database query error", cause);
message.fail(ErrorCodes.DB_ERROR.ordinal(), cause.getMessage());
}
3.4 從主Verticle部署Verticle
我們依舊有一個MainVerticle類,但它不是像首次迭代一樣包含所有邏輯,它的唯一目的是啟動應用并且部署其它Verticle。
這些代碼包括部署一個WikiDatabaseVerticle實例和兩個HttpServerVerticle實例:
public class MainVerticle extends AbstractVerticle {
@Override
public void start(Future<Void> startFuture) throws Exception {
Future<String> dbVerticleDeployment = Future.future(); ①
vertx.deployVerticle(new WikiDatabaseVerticle(), dbVerticleDeployment.completer()); ②
dbVerticleDeployment.compose(id -> { ③
Future<String> httpVerticleDeployment = Future.future();
vertx.deployVerticle("io.vertx.guides.wiki.HttpServerVerticle", ④
new DeploymentOptions().setInstances(2), ⑤
httpVerticleDeployment.completer());
return httpVerticleDeployment; ⑥
}).setHandler(ar -> { ⑦
if (ar.succeeded()) {
startFuture.complete();
} else {
startFuture.fail(ar.cause());
}
});
}
}
① 部署Verticle是一個異步操作,因此我們需要一個Future。String參數類型是因為一個Verticle部署成功時會返回一個標識。
② 一種選擇是使用new創建一個Verticle實例,傳遞對象引用給deploy方法。completer返回值是一個處理器,簡單的完成future。
③ 使用compose的順序組合允許在一個操作之后運行另一個異步操作。當初始化future成功完成之后,調用組合方法。
④ 指定一個類名字符串也是部署Verticle的一種選項。對于其它JVM語言來說,基于字符串的慣例(conventions)允許指定一個模塊/腳本。
⑤ DeploymentOption類允許指定一些參數,尤其部署的實例個數。
⑥ 組合方法返回下一個future。它的完成將會觸發組合操作的完成。
⑦ 我們定義了一個Handler,以便完成MainVerticle的啟動future。
精明的讀者可能會感到驚奇,我們怎么可以在同一個端口部署HTTP Server代碼兩次,并且對于每個實例都不期望出現由于TCP端口已經被占用而導致的任何錯誤。對于許多Web框架,我們需要選擇不同的TCP端口,并且有一個前端HTTP代理來執行端口之間的負載平衡。
對于Vert.x則不需要這么做,多個Verticle可以共享相同的TCP端口號。傳入的連接只是簡單的通過接收線程以輪轉的方式分發。