Bootstrap

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);
	});
}

测试接口:
在这里插入图片描述
失败调用:
在这里插入图片描述
至此,后端部分已经编写完成,下一文章将实现前端调用和展示部分。

;