CSE(Cloud Service Engine) Java SDK是华为推出的产品级微服务开发框架,已经在华为内部多个大型产品上得到了使用和验证。使用CSE Java SDK开发微服务,可以最大化的简化开发门槛,提升产品上线速度。同时可以获得微服务运行时高可靠性保证、运行时动态治理等一系列开箱即用的能力。
为了描述简单,本文会使用CSE指代CSE Java SDK,使用ServiceComb指代ServiceComb Java Chassis。
开发者可以通过微服务引擎华为云官网了解CSE。在CSE帮助中心可以获取更多产品信息,如有疑问,可通过CSE论坛进行咨询。
本文帮助开发者开发一个完整的微服务应用。通过一个典型的应用场景,展现一个微服务应用需要解决那些问题,在不同的章节里面,会详细解释解决解决这些问题的技术原理和实现过程。CSE SDK主要功能是提供了完善的开箱即用的治理能力的RPC框架,完成完整的业务开发,还会涉及到集成和使用其他技术,这些内容也会加以详细解释。
这个应用场景,是通过收集了一些CSE用户的真实业务场景提取出来的。具体包括:
在这个应用中,尽可能让服务小、每个微服务完全独立,没有代码上的依赖,服务之间通过REST接口相互访问。为了达到这个目的,可能会有些重复代码(包括配置类文件如pom.xml、数据模型类文件等)。开发者可以结合实际情况选择是否提供公共模块,来避免这种情况。在这个项目中选择的是用重复代码来换取自由度的方案。
在实际的代码中,我们还会遵循其他一些和微服务开发有关的原则,包括无状态设计等。这里的例子的目的是搭建一个商业可用的微服务,因此我们会在架构设计、方案设计上也给出一定的建议以及说明这样处理的目的。
本专题的涉及的代码均托管在github: https://github.com/huawei-microservice-demo/porter 。开发者可以clone一份供学习使用,或者作为正式项目的模板。
开始前,给应用取一个名字porter。
根据User Story,应用至少包含用户管理、文件管理等微服务,每个微服务都行使不一样的功能。下面是分解后设计的微服务结构:
为了可靠性,这些服务都应该支持分布式集群部署。因此在业务逻辑中涉及到并发和负载均衡的场景,都需要考虑无状态设计。可以给网关配置域名或者在上层再挂一个弹性负载均衡器,实现网关的多实例部署。
微服务设计好以后,可以通过已有项目快速搭建项目架子。可以从:https://github.com/huawei-microservice-demo/porter 下载该项目。
初始的项目是一个maven项目,主要内容包括pom.xml文件、microservice.yam文件和一个Main函数。microservice.yam文件配置了微服务的基本信息和访问服务中心的地址。
在技术选择上,界面完全由html/js/css等构成,不采用其他动态技术。因此只需要有一个可以支持静态页面的服务即可。在架构图中,界面的请求需要被网关转发,并且需要支持多实例部署,因此界面服务需要增加的功能是服务注册和发现。CSE提供了两种方法集成和使用J2EE:
为了部署简单,我们的示例选择了第二种方式。第一种方式也是很简单的,可以参考示例:https://github.com/huawei-microservice-demo/HouseApp/tree/master/customer-website 。
在Spring Boot中提供静态页面服务,核心问题是解决服务注册、发现能力。在Spring Boot的Embeded Tomcat中使用CSE的服务注册发现,需要完成如下步骤:
依赖关系定义了对于Spring Boot的依赖和CSE的依赖。
org.apache.servicecomb
spring-boot-starter-transport
org.springframework.boot
spring-boot-starter-web
按照Spring Boot的惯例,需要声明项目的parent。尽管这个步骤不是必须的,但是不增加时需要手工配置很多编译插件,非常繁琐。
org.springframework.boot
spring-boot-starter-parent
1.4.5.RELEASE
需要注意配置项cse.rest.address的端口与application.yml的server.port保持一致。application.yml是Spring Boot的配置文件,用于指定Embeded Tomcat的监听端口。microservice.yam的信息用于服务注册。另外也需要注意一下配置项servicecomb.rest.servlet.urlPattern,当使用@EnableServiceComb时,会加载CSE提供的REST框架org.apache.servicecomb.transport.rest.servlet. RestServlet,而且默认接管了/*的请求。在我们的场景下,仅仅需要提供web页面,不需要提供REST服务,这个配置项的含义就是将它的路径改为一个和静态页面不冲突的路径,以保证静态页面能够被正常访问。
APPLICATION_ID: porter
service_description:
name: porter-website
version: 0.0.1
cse:
rest:
address: 0.0.0.0:9093
servicecomb:
rest:
servlet:
urlPattern: /servicecomb/rest/*
按照Spring Boot的惯例,静态页面需要放到源代码的resources/static目录。项目开始前,增加了如下静态页面和目录:
css
js
index.html
@SpringBootApplication
@EnableServiceComb
public class WebsiteMain {
public static void main(final String[] args) {
SpringApplication.run(WebsiteMain.class, args);
}
}
经过以上的步骤,界面服务就开发完成了。通过运行WebsiteMain,就可以通过http://localhost:9093 来访问。
文件上传功能、用户管理功能都只需要提供REST接口,在技术选型上,使用CSE提供的轻量级REST框架。开发新的微服务都涉及到配置微服务信息,写一个新的Main函数,这些公共步骤在文档前面已经描述,后续文档会省略这些内容。
依赖关系定义了对于Spring Boot的依赖和CSE的依赖。通过dependencyManagement机制管理了CSE依赖,开发者不需要将CSE的项目作为自己的parent。启用CSE,只需要在pom中引入cse-solution-service-engine模块。
com.huawei.paas.cse
cse-dependency
2.3.12
pom
import
com.huawei.paas.cse
cse-solution-service-engine
服务接口定义上有3种选择:RPC、Spring MVC、JAX RS。 这里选择了Sping MVC,相比RPC需要额外增加Annotation,灵活性在于接口即可以通过RPC的方式在服务内部访问,也可以通过浏览器访问,期望前台js脚本开发者也能够对照生成的契约完成开发。CSE开发框架在这方面提供了很大的便利。
@RestSchema(schemaId = "file")
@RequestMapping(path = "/")
public class FileServiceEndpoint {
@Autowired
private FileService fileService;
/**
* 上传文件接口,用户上传一个文件,返回文件ID。
*/
@PostMapping(path = "/upload", produces = MediaType.TEXT_PLAIN_VALUE)
public String uploadFile(@RequestPart(name = "fileName") MultipartFile file) {
return fileService.uploadFile(file);
}
/**
* 删除文件接口。指定ID,返回删除成功还是失败.
*/
@DeleteMapping(path = "/delete", produces = MediaType.APPLICATION_JSON_VALUE)
public boolean deleteFile(@RequestParam(name = "id") String id) {
return fileService.deleteFile(id);
}
}
为了实现不同方式的文件存储,将实现抽象出来FileService。为了简单,当前只提供了本地文件实现。这个实现限制了该服务无法进行多实例部署。可以考虑使用对象存储服务器、分布式文件系统等满足存储要求。
需要在microservice.yaml中增加cse.uploads.directory配置项,指定临时目录的路径。需要保证目录有写权限。默认情况下如果没设置临时目录,不允许启用上传功能。如果使用网关,网关也需要增加这个配置项。
Upload Example
Upload Example
可以使用Postman等工具测试删除接口:
DELETE http://localhost:9091/delete?id=ba6bd8a2-d31a-42cd-a1be-9fb3d6ab4c82
还可以使用CSE开发一个客户端测试这些接口,对于自动化测试用例是非常有用的。这里不再详细说明。
本节介绍如何通过网关转发请求。CSE提供了非常灵活的网关服务,开发者能够非常简单的实现CSE开发的微服务之间的转发,网关拥有CSE客户端一样的服务治理能力。同时,开发者可以使用vert.x暴漏的HTTP API,实现非常灵活的转发控制。
CSE的网关服务由一系列的VertxHttpDispatcher组成,开发者通过继承AbstractEdgeDispatcher,来实现自己的转发机制。为了实现gateway-service将请求转发到file-service,定义了如下规则:
通过网关:DELETE http://localhost:9090/api/file-service/delete
达到这个目的的代码如下,在请求处理的时候,使用EdgeInvocation,可以实现CSE的请求转发,并开启各种治理功能。下面代码的核心内容是定义转发规则regex。
public class ApiDispatcher extends AbstractEdgeDispatcher {
@Override
public int getOrder() {
return 10002;
}
@Override
public void init(Router router) {
String regex = "/api/([^\\/]+)/(.*)";
router.routeWithRegex(regex).handler(CookieHandler.create());
router.routeWithRegex(regex).handler(createBodyHandler());
router.routeWithRegex(regex).failureHandler(this::onFailure).handler(this::onRequest);
}
protected void onRequest(RoutingContext context) {
Map pathParams = context.pathParams();
String microserviceName = pathParams.get("param0");
String path = "/" + pathParams.get("param1");
EdgeInvocation invoker = new EdgeInvocation();
invoker.init(microserviceName, context, path, httpServerFilters);
invoker.edgeInvoke();
}
}
为了实现gateway-service将请求转发到porter-website,定义了如下规则:
直接请求porter-website: GET http://localhost:9093/index.html
通过网关:GET http://localhost:9090/ui/porter-website/index.html
UI静态页面信息不需要实现治理能力(服务治理能力需要契约,静态页面不存在接口契约),因此直接使用vert.x的API实现请求转发。在下面的代码中,还使用CSE API做了服务发现,并实现了一个简单的RoundRobin负载均衡策略,从而允许porter-website也进行多实例部署。
public class UiDispatcher extends AbstractEdgeDispatcher {
private static Logger LOGGER = LoggerFactory.getLogger(UiDispatcher.class);
private static Vertx vertx = VertxUtils.getOrCreateVertxByName("web-client", null);
private static HttpClient httpClient = vertx.createHttpClient(new HttpClientOptions());
private Map discoveryTrees = new ConcurrentHashMapEx<>();
private AtomicInteger counter = new AtomicInteger(0);
@Override
public int getOrder() {
return 10001;
}
@Override
public void init(Router router) {
String regex = "/ui/([^\\/]+)/(.*)";
router.routeWithRegex(regex).failureHandler(this::onFailure).handler(this::onRequest);
}
protected void onRequest(RoutingContext context) {
Map pathParams = context.pathParams();
String microserviceName = pathParams.get("param0");
String path = "/" + pathParams.get("param1");
URI uri = chooseServer(microserviceName);
if (uri == null) {
context.response().setStatusCode(404);
context.response().end();
return;
}
// 使用HttpClient转发请求
HttpClientRequest clietRequest =
httpClient.request(context.request().method(),
uri.getPort(),
uri.getHost(),
"/" + path,
clientResponse -> {
context.request().response().setChunked(true);
context.request().response().setStatusCode(clientResponse.statusCode());
context.request().response().headers().setAll(clientResponse.headers());
clientResponse.handler(data -> {
context.request().response().write(data);
});
clientResponse.endHandler((v) -> context.request().response().end());
});
clietRequest.setChunked(true);
clietRequest.headers().setAll(context.request().headers());
context.request().handler(data -> {
clietRequest.write(data);
});
context.request().endHandler((v) -> clietRequest.end());
}
private URI chooseServer(String serviceName) {
URI uri = null;
DiscoveryContext context = new DiscoveryContext();
context.setInputParameters(serviceName);
DiscoveryTree discoveryTree = discoveryTrees.computeIfAbsent(serviceName, key -> {
return new DiscoveryTree();
});
VersionedCache serversVersionedCache = discoveryTree.discovery(context,
RegistryUtils.getAppId(),
serviceName,
DefinitionConst.VERSION_RULE_ALL);
Map servers = serversVersionedCache.data();
String[] endpoints = asArray(servers);
if (endpoints.length > 0) {
int index = Math.abs(counter.getAndIncrement() % endpoints.length);
String endpoint = endpoints[index];
try {
uri = new URI(endpoint);
} catch (URISyntaxException e) {
LOGGER.error("", e);
}
}
return uri;
}
private String[] asArray(Map servers) {
List endpoints = new LinkedList<>();
for (MicroserviceInstance instance : servers.values()) {
endpoints.addAll(instance.getEndpoints());
}
return endpoints.toArray(new String[endpoints.size()]);
}
}
完成VertxHttpDispatcher开发后,需要通过SPI的方式加载到系统中,需要增加META-INF/services/org.apache.servicecomb.transport.rest.vertx.VertxHttpDispatcher配置文件,并将增加的两个实现写入该配置文件中。
网关服务开发完成后,所有的用户请求都可以通过网关来发送。开发者通过通过设置防火墙等机制,限制用户直接访问内部服务,保证内部服务的安全。
CSE本身并未集成数据库访问功能,访问数据库可以使用第三方提供的组件。这里选择了MyBatis说明如何访问数据库。开发者也可以直接参考:http://www.mybatis.org/spring/zh/index.html ,这里给出一个快速集成参考。在本章中涉及到建表等数据库操作的时候,数据库选用MySQL。
本应用提供了非常简单的用户管理和基于角色的鉴权机制。因此我们设计了非常简单的用户表,表格包含了用户名称及用户所属的角色。为了测试的目的,还插入了两个用户数据,其中密码采用SHA256进行单向加密保存。
CREATE DATABASE IF NOT EXISTS porter_user_db;
USE porter_user_db;
DROP TABLE IF EXISTS T_USER;
CREATE TABLE `T_USER` (
`ID` INTEGER(20) NOT NULL COMMENT '用户ID',
`USER_NAME` VARCHAR(64) NOT NULL COMMENT '用户名称',
`PASSWORD` VARCHAR(64) NOT NULL COMMENT '用户密码',
`ROLE_NAME` VARCHAR(64) NOT NULL COMMENT '角色名称',
PRIMARY KEY (`ID`)
);
insert into T_USER(ID, USER_NAME, PASSWORD, ROLE_NAME) values(1, "admin", "n4bQgYhMfWWaL+qgxVrQFaO/TxsrC4Is0V1sFbDwCgg=", "admin");
insert into T_USER(ID, USER_NAME, PASSWORD, ROLE_NAME) values(2, "guest", "n4bQgYhMfWWaL+qgxVrQFaO/TxsrC4Is0V1sFbDwCgg=", "guest");
org.mybatis
mybatis
3.4.5
mysql
mysql-connector-java
org.apache.commons
commons-dbcp2
org.mybatis
mybatis-spring
1.3.0
org.springframework
spring-jdbc
compile
org.springframework
spring-aop
org.springframework
spring-context-support
org.springframework
spring-tx
### mybatis-config.xml
### user.bean.xml
经过上面的配置,数据库访问相关开发已经完成了。 结合User Story,可以先设计一个login的服务接口。 这个服务在UserServiceEndpoint里面进行定义。
@PostMapping(path = "/login", produces = MediaType.APPLICATION_JSON_VALUE)
public SessionInfo login(@RequestParam(name = "userName") String userName,
@RequestParam(name = "password") String password)
传统的WEB容器都提供了会话管理,在微服务架构下,这些会话管理存在很多的限制,如果需要做到弹性扩缩容,则需要做大量的定制。 在porter中,我们使用user-service做会话管理,可以通过login和session两个接口创建和获取会话信息。会话信息持久化到数据库中,从而实现微服务本身的无状态,微服务可以弹性扩缩容。在更大规模并发或者高性能要求的情况下,可以考虑将会话信息存储到高速缓存。
@PostMapping(path = "/login", produces = MediaType.APPLICATION_JSON_VALUE)
public SessionInfo login(@RequestParam(name = "userName") String userName,
@RequestParam(name = "password") String password)
接口会返回SessionInfo,这些必要的信息,会在后续的鉴权、认证操作中起到很大的方便。
经过以上的开发,就可以启动用户服务,配置数据库和插入相关数据,从界面访问这个接口。
#### 访问login接口的HTTP请求和响应
#Request
POST http://localhost:9090/api/user-service/login
Content-Type: application/x-www-form-urlencoded
userName=admin&password=test
#Response
{
"id": 0,
"sessiondId": "1be646c0-50cb-4c0a-968d-2a512775f5e8",
"userName": "guest",
"roleName": "guest",
"creationTime": null,
"activeTime": null
}
同时新增了会话管理的数据表设计:
CREATE TABLE `T_SESSION` (
`ID` INTEGER(8) NOT NULL AUTO_INCREMENT COMMENT '唯一标识',
`SESSION_ID` VARCHAR(64) NOT NULL COMMENT '临时会话ID',
`USER_NAME` VARCHAR(64) NOT NULL COMMENT '用户名称',
`ROLE_NAME` VARCHAR(64) NOT NULL COMMENT '角色名称',
`CREATION_TIME` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
`ACTIVE_TIME` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '最近活跃时间',
PRIMARY KEY (`ID`)
);
会话管理和认证都在gateway-service进行,鉴权则需要使用到用户信息。为了让微服务获取用户信息的时候,不至于再查询user-service,我们利用了CSE提供的Context机制,在Context里面存储了session信息,所有的微服务都可以直接从Context里面取到session信息,非常方便和灵活。完成这个功能有如下几个关键步骤:
EdgeInvocation invoker = new EdgeInvocation() {
// 认证鉴权:构造Invocation的时候,设置会话信息。如果是认证请求,则添加Cookie。
protected void createInvocation(Object[] args) {
super.createInvocation(args);
// 既从cookie里面读取会话ID,也从header里面读取,方便各种独立的测试工具联调
String sessionId = context.request().getHeader("session-id");
if (sessionId != null) {
this.invocation.addContext("session-id", sessionId);
} else {
Cookie sessionCookie = context.getCookie("session-id");
if (sessionCookie != null) {
this.invocation.addContext("session-id", sessionCookie.getValue());
}
}
}
};
public class AuthHandler implements Handler {
private RestTemplate restTemplate = RestTemplateBuilder.create();
@Override
public void handle(Invocation invocation, AsyncResponse asyncResponse) throws Exception {
if (invocation.getMicroserviceName().equals("user-service")
&& (invocation.getOperationName().equals("login")
|| (invocation.getOperationName().equals("getSession")))) {
invocation.next(asyncResponse);
} else {
// check session
String sessionId = invocation.getContext("session-id");
if (sessionId == null) {
throw new InvocationException(405, "", "session is not valid.");
}
SessionInfo sessionInfo =
restTemplate.getForObject("cse://user-service/session?sessionId=" + sessionId, SessionInfo.class);
if (sessionInfo == null) {
throw new InvocationException(405, "", "session is not valid.");
}
// 将会话信息传递给后面的微服务。后面的微服务可以从context获取到会话信息,从而可以进行鉴权等。
invocation.addContext("session-id", sessionId);
invocation.addContext("session-info", JsonUtils.writeValueAsString(sessionInfo));
invocation.next(asyncResponse);
}
}
}
启用该Hanlder,需要增加cse.handler.xml文件
并且在microservice.yaml中启用auth,通过下面的配置项可以覆盖cse-solution-service-engine提供的默认handler链。将新增加的auth处理链放到流控之后。
cse:
handler:
chain:
Consumer:
default: perf-stats,qps-flowcontrol-consumer,auth,loadbalance,bizkeeper-consumer
@DeleteMapping(path = "/delete", produces = MediaType.APPLICATION_JSON_VALUE)
public boolean deleteFile(@RequestParam(name = "id") String id) {
String session = ContextUtils.getInvocationContext().getContext("session-info");
if (session == null) {
throw new InvocationException(403, "", "not allowed");
} else {
SessionInfo sessionInfo = null;
try {
sessionInfo = JsonUtils.readValue(session.getBytes("UTF-8"), SessionInfo.class);
} catch (Exception e) {
throw new InvocationException(403, "", "session not allowed");
}
if (sessionInfo == null || !sessionInfo.getRoleName().equals("admin")) {
throw new InvocationException(403, "", "not allowed");
}
}
return fileService.deleteFile(id);
}
到这里为止,认证、会话管理和鉴权的逻辑基本已经完成了。可以通过Postman等工具进行流程相关的测试。
#### 会话管理接口调用示例,调用删除文件接口。使用guest用户的会话的情况。
#Request
DELETE http://localhost:9090/api/file-service/delete?id=ba6bd8a2-d31a-42cd-a1be-9fb3d6ab4c82
session-id: 1be646c0-50cb-4c0a-968d-2a512775f5e8
#Response
{
"message": "not allowed"
}
登录
实现登陆逻辑。登陆首先调用后台登陆接口,登陆成功后设置会话cookie:
function loginAction() {
var username = document.getElementById("username").value;
var password = document.getElementById("paasword").value;
var formData = {};
formData.userName = username;
formData.password = password;
$.ajax({
type: 'POST',
url: "/api/user-service/login",
data: formData,
success: function (data) {
setCookie("session-id", data.sessiondId, false);
window.alert('登陆成功!');
},
error: function(data) {
console.log(data);
window.alert('登陆失败!' + data);
},
async: true
});
}
HTTP协议已逐渐被标记为不安全,配置HTTPS可以防止用户数据被窃取和篡改,提升了安全性。考虑到性能的影响,我们只在网关使用HTTPS接入,内部服务之间仍然使用HTTP。
使用HTTPS之前,需要准备证书。通常是向权威机构申请,这样的证书才会被浏览器等设备标记为可信。在这个例子中,我们使用通过工具已经生成好的证书。并且将自己的证书通过PKCS12格式存储在server.p12文件中,将CA的证书使用JKS格式存储在trust.jks中。
网关启用HTTP只需要在监听的端口中增加sslEnabled配置项:
cse:
rest:
address: 0.0.0.0:9090 ?sslEnabled=true
然后增加ssl相关的配置。下面的配置包含了TLS的协议、是否认证对端以及证书和密码信息。其中com.huawei.cse.porter.gateway.EdgeSSLCustom用于证书路径和证书密码的转换,不实现的时候,默认从当前目录读取证书文件,证书的密码明文存储。当业务需要做一些高级安全特性,比如密码保护的时候,可以通过扩展这个类实现。
ssl.protocols: TLSv1.2
ssl.authPeer: false
ssl.checkCN.host: false
ssl.trustStore: trust.jks
ssl.trustStoreType: JKS
ssl.trustStoreValue: Changeme_123
ssl.keyStore: server.p12
ssl.keyStoreType: PKCS12
ssl.keyStoreValue: Changeme_123
ssl.crl: revoke.crl
ssl.sslCustomClass: com.huawei.cse.porter.gateway.EdgeSSLCustom
开发完成后,访问界面就可以通过https进行了:https://localhost:9090/ui/porter-website/index.html