Vert.x,Web - Restful API
将通过Vert.x Web编写一个前后分离的Web应用,做为Vert.x Web学习小结。本文为后端部分,后端实现业务逻辑,并通过RESTfull接口给前端(Web页面)调用。
案例概述
假设我们要设计一个人力资源(HR)系统,要实现对员工信息的增删改查。我们将前端和后端设计成两个Verticle,这样可以实现灵活的部署,可以将前端和后端部署在一个JVM上,也可以部署到不同的JVM或者不同的服务器上。
员工信息存放在MySQL数据库中,所以需要先创建对应数据库和表:
create database hr;
use hr;
create table emp (
empno int not null auto_increment,
ename varchar(24),
job varchar(16),
constraint emp_pk primary key(empno)
);
insert into emp values(7369, 'SMITH', 'CLERK');
insert into emp values(7499, 'ALLEN', 'SALESMAN');
insert into emp values(7521, 'WARD', 'SALESMAN');
insert into emp values(7566, 'JONES', 'MANAGER');
insert into emp values(7654, 'MARTIN', 'SALESMAN');
insert into emp values(7698, 'BLAKE', 'MANAGER');
insert into emp values(7782, 'CLARK', 'MANAGER');
insert into emp values(7788, 'SCOTT', 'ANALYST');
insert into emp values(7839, 'KING', 'PRESIDENT');
insert into emp values(7844, 'TURNER', 'SALESMAN');
insert into emp values(7876, 'ADAMS', 'CLERK');
insert into emp values(7900, 'JAMES', 'CLERK');
insert into emp values(7902, 'FORD', 'ANALYST');
insert into emp values(7934, 'MILLER', 'CLERK');
后端Restfull实现
后端设计如下的Restful AIP:
请求方法 请求路径 功能说明
---------- ----------------------------- -------------
GET /api/v1/hr/employees 获取员工列表
POST /api/v1/hr/employees 创建新员工
GET /api/v1/hr/employees/{empNo} 获取员工信息
DELETE /api/v1/hr/employees/{empNo} 删除一个员工
PUT /api/v1/hr/employees/{empNo} 修改员工信息
因为需要使用HTTP并访问MySQL数据库,所以需要在项目中引入相关依赖:
<dependency>
<groupId>io.vertx</groupId>
<artifactId>vertx-core</artifactId>
<version>4.5.10</version>
</dependency>
<dependency>
<groupId>io.vertx</groupId>
<artifactId>vertx-web</artifactId>
<version>4.5.10</version>
</dependency>
<dependency>
<groupId>io.vertx</groupId>
<artifactId>vertx-mysql-client</artifactId>
<version>4.5.10</version>
</dependency>
数据库连接
创建SQL客户端用于访问数据库,程序架构大致如下:
public class HrWebService extends AbstractVerticle {
public HrWebService() {
MySQLConnectOptions connectOptions = new MySQLConnectOptions()
.setHost("127.0.0.1").setPort(3306)
.setUser("root").setPassword("Passw0rd")
.setDatabase("hr").setConnectTimeout(2000)
.addProperty("autoReconnect", "true")
.addProperty("useSSL","false")
.addProperty("rewriteBatchedStatements", "true");
PoolOptions poolOptions = new PoolOptions().setMaxSize(5);
client = MySQLBuilder.client().using(vertx)
.with(poolOptions)
.connectingTo(connectOptions)
.build();
}
@Override
public void stop() throws Exception {
if (null != client) { //停止时候释放连接。
client.close();
}
}
@Override
public void start() throws Exception {
HttpServer server = vertx.createHttpServer();
Router router = Router.router(vertx);
// 在这里添加路由...
server.requestHandler(router).listen(8081);
}
}
接口基础部分
因为POST/PUT方法需要使用请求体传递数据,所以需要允许请求体,为避免受到攻击,设置请求体的最大大小为100KB。
router.route("/api/v1/hr/*").handler(BodyHandler.create().setBodyLimit(100 * 1024));
出于安全性,浏览器通常会限制脚本内发起的跨源HTTP请求。所以需要添加相关的CORS响应标头,来允许跨域访问,否则前端调用会报错。
router.route("/api/v1/hr/*").handler(newCorsHandler());
public static CorsHandler newCorsHandler() {
/** 设置支持跨域访问/CORS */
Set<String> allowedHeaders = new HashSet<>();
allowedHeaders.add("x-requested-with");
allowedHeaders.add("Access-Control-Allow-Origin");
allowedHeaders.add("origin");
allowedHeaders.add("Content-Type");
allowedHeaders.add("accept");
allowedHeaders.add("X-PINGARUNER");
Set<HttpMethod> allowedMethods = new HashSet<>();
allowedMethods.add(HttpMethod.GET);
allowedMethods.add(HttpMethod.POST);
allowedMethods.add(HttpMethod.OPTIONS);
allowedMethods.add(HttpMethod.DELETE);
allowedMethods.add(HttpMethod.PATCH);
allowedMethods.add(HttpMethod.PUT);
return CorsHandler.create()
.addOrigin("*") // Access-Control-Allow-Origin
.allowedHeaders(allowedHeaders) // Access-Control-Request-Method
.allowedMethods(allowedMethods); // Access-Control-Request-Headers
}
最后为(HR)应用设置一个路由错误处理器:
router.route("/api/v1/hr/*").failureHandler(this::defaultErrorHandler);
public void defaultErrorHandler(RoutingContext routingContext) {
Throwable exception = routingContext.failure();
int statusCode = routingContext.statusCode();
服务器记录日志
HttpServerRequest request = routingContext.request();
String method = request.method().name();
String uri = request.absoluteURI();
LOGGER.log(Level.SEVERE, method + " " + uri + ", statusCode: " + statusCode, exception);
返回错误信息
HttpServerResponse response = routingContext.response();
response.setStatusCode(statusCode); // 必须设置, 默认: 200 OK
// response.setStatusMessage(exception.getMessage()); // 可覆盖, 默认是statusCode对应的错误信息。
// 返回Json格式错误信息: {"error":{"code":500, "message":"Error message here"}}
JsonArray errorArray = new JsonArray().add(new JsonObject().put("code", statusCode))
.add(new JsonObject().put("message", exception.getMessage()));
JsonObject respObj = new JsonObject().put("error", errorArray);
response.end(respObj.toString());
}
这里设计上,客户端需要通过HTTP的statusCode来判断请求的释放成功,正常走API的结果解析,错误走这个错误结果解析。也可以在内部出错的时候(status code)500,依然返回200,只是把错误信息和代码放在返回的json中,可以根据自己需要规划。
获取员工列表接口
该接口用于获取员工列表。因为员工数量比较多,需要支持分页。
请求路径: GET /api/v1/hr/employees
请求参数:
page , 整型, 非必选, 请求数据的分页页码, 默认值: 1
limit, 整型, 非必选, 请求数据的分页大小, 默认值: 5
返回结果:
count , 整型, 总记录数。
data , 数组, 员工信息的数组。数据结构, 对应emp表的行。
successful, 布尔类型, 请求是否成功。
duration , 整型, 服务端处理请求的时间(毫秒)。
接口需要总记录数和请求页码的数据,实现上通过2条语句获取,通过Future.all方法将两个异步查询组合在一起,并将结果返回:
router.route(HttpMethod.GET, "/api/v1/hr/employees").handler(this::getEmployees);
public void getEmployees(RoutingContext routingContext) {
long startTime = System.currentTimeMillis();
// 获取url请求参数
HttpServerRequest request = routingContext.request();
String p = request.getParam("page", "1"); // 获取url请求参数page,默认第1页。
String l = request.getParam("limit", "5"); // 获取参数limit,默认值5。
int page = Integer.parseInt(p);
int rowCount = Integer.parseInt(l);
int offset = (page - 1) * rowCount; // 计算记录偏移值。
HttpServerResponse response = routingContext.response();
response.putHeader("content-type", "application/json");
JsonObject resultObject = new JsonObject(); // 用于保存结果。
String sqlText1 = "select empno, ename, job from emp order by empno desc limit ?, ?";
Future<RowSet<Row>> future1 = client.preparedQuery(sqlText1).execute(Tuple.of(offset, rowCount))
.onSuccess(rows -> {
JsonArray resultArray = new JsonArray(); //保存查询结果集(Array)
for (Row row : rows) {
JsonObject json = row.toJson();
resultArray.add(json);
}
resultObject.put("data", resultArray);
});
String sqlText2 = "select count(empno) as cnt from emp";
Future<RowSet<Row>> future2 = client.preparedQuery(sqlText2).execute().onSuccess(rows -> {
for (Row row : rows) {
resultObject.put("count", row.getValue("cnt")); // 总记录数,通常前端计算分页用。
}
});
Future.all(future1, future2).onComplete(ar -> { // 组合两个查询,两个异步都完成时候返回完成。
if (ar.succeeded()) {
resultObject.put("successful", true); // 设置请求结果为成功。
long endTime = System.currentTimeMillis();
resultObject.put("duration", endTime - startTime); // 计算执行时间。
response.end(resultObject.toString()); // 返回API结果。
} else {
routingContext.fail(ar.cause());
}
});
}
通过Postman测试接口:GET http://127.0.0.1:8081/api/v1/hr/employees?page=2&limit=2
关闭数据库,模拟失败调用,再次执行接口调用:GET http://127.0.0.1:8081/api/v1/hr/employees?page=2&limit=2
root@localhost [hr]> shutdown ;
Query OK, 0 rows affected (0.00 sec)
创建新员工接口
该接口用创建新员工。
请求路径: POST /api/v1/hr/employees
请求参数:
ename, 字符串, 必选, 新员工姓名。
job, 字符串, 必选, 新员工职位。
返回结果:
empno, 整型, 新员工编号。
代码实现上,获取(Body)请求参数,插入数据库后,API返回员工编号(empno):
router.route(HttpMethod.POST, "/api/v1/hr/employees").handler(this::newEmployee);
public void newEmployee(RoutingContext routingContext) {
JsonObject empObject = routingContext.body().asJsonObject();
String ename = empObject.getString("ename");
String job = empObject.getString("job");
if (StringUtils.isBlank(ename) || StringUtils.isBlank(job)) { // apache commons-lang3
// 有两种方式抛出失败: 调用routingContext.fail方法,并返回处理器方法或者抛出RuntimeException异常。
routingContext.fail(new Exception("员工名或者职位不能为空白。"));
return ; // 注意, 必须函数返回,否则还会继续调用后续代码。
//throw new RuntimeException("员工名或者职位不能为空白。");
}
String sqlText = "insert into emp (ename, job) values (?, ?)";
client.preparedQuery(sqlText).execute(Tuple.of(ename, job)).onSuccess(rows -> {
long lastInsertId = rows.property(MySQLClient.LAST_INSERTED_ID);
HttpServerResponse response = routingContext.response();
response.putHeader("content-type", "application/json");
JsonObject responseObject = new JsonObject();
responseObject.put("empno", lastInsertId);
response.end(responseObject.toString());
}).onFailure(exception -> {
routingContext.fail(exception);
});
}
通过Postman测试接口,正常调用:
模拟错误参数,job为空字符串。
删除员工信息接口
该接口用于删除员工信息。
请求路径: DELETE /api/v1/hr/employees/{empNo}
请求参数:
empNo, 整型, 必选, 需要删除的员工编号。
需要url的路径参数方式获取员工编号:
router.route(HttpMethod.DELETE, "/api/v1/hr/employees/:empNo").handler(this::deleteEmployee);
public void deleteEmployee(RoutingContext routingContext) {
String en = routingContext.pathParam("empNo");
int empNo = 0;
try {
empNo = Integer.parseInt(en);
} catch (NumberFormatException e) {
routingContext.fail(new Exception("无效的请求路径, " + e.getMessage(), e));
return;
}
String sqlText = "delete from emp where empno = ?";
client.preparedQuery(sqlText).execute(Tuple.of(empNo)).onSuccess(rows -> {
HttpServerResponse response = routingContext.response();
response.end();
});
}
测试接口:
失败调用。
修改员工信息接口
该接口用于修改员工信息。
请求路径: PUT /api/v1/hr/employees/{empNo}
请求路径: POST /api/v1/hr/employees
请求参数:
empno, 整型,必选,需要修改的员工编号。
ename, 字符串, 必选, 新的员工姓名。
job, 字符串, 必选, 新的员工职位。
返回结果:
rows, 整型,已修改的记录数。
实现代码如下:
router.route(HttpMethod.PUT, "/api/v1/hr/employees/:empNo").handler(this::updateEmployee);
public void updateEmployee(RoutingContext routingContext) {
HttpServerResponse response = routingContext.response();
response.putHeader("content-type", "application/json");
String en = routingContext.pathParam("empNo");
int empNo = 0;
try {
empNo = Integer.parseInt(en);
} catch (NumberFormatException e) {
throw new RuntimeException("无效的请求路径, " + e.getMessage(), e);
}
JsonObject empObject = routingContext.body().asJsonObject();
String newEname = empObject.getString("ename");
String newJob = empObject.getString("job");
if (StringUtils.isBlank(newEname) || StringUtils.isBlank(newJob)) {
throw new RuntimeException("新的员工名或者职位不能为空。");
}
String sqlText = "update emp set ename=?, job=? where empno = ?";
client.preparedQuery(sqlText).execute(Tuple.of(newEname, newJob, empNo)).onSuccess(rows -> {
response.end("{\"rows\": " + rows.rowCount() + "}");
}).onFailure(exception -> {
routingContext.fail(exception);
});
}
测试接口:
失败调用:
至此,后端部分已经编写完成,下一文章将实现前端调用和展示部分。