快速入门-搭建一个典型的微服务应用
项目介绍
示例微服务应用由 4 个微服务组成。
- 应用网关:负责接收前端的请求,并将请求转发给后端服务处理。应用网关还负责简单的认证功能。
- 文件服务: 提供一个上传文件接口,和删除文件接口。
- 用户管理: 提供登录认证逻辑实现。
- 静态页面托管 HTML/JS/CSS等。
本地微服务引擎本是CSE 微服务引擎的简化版本,用于本地开发调试。它包含3个服务。
- 服务中心:负责服务注册和发现。
- 配置中心:负责集中配置管理。
- 控制台:提供前端管理界面。
安装本地微服务引擎
下载本地微服务引擎:https://support.huaweicloud.com/devg-servicestage/ss-devg-0036.html
启动:
start.bat
访问:
http://localhost:30106/#/cse/service/dashboard
运行示例微服务应用
下载示例微服务应用:
git clone https://github.com/apache/servicecomb-samples.git
进入本示例项目:
cd porter_springboot
编译项目:
mvn clean install
初始化数据库:本地安装 mysql 数据库, 并且执行目录user-service\src\main\resources\config下面的脚本 create_db_user.sql 。
启动:数据库使用 root/root 登录, 如果数据库密码为自定义值,需要修改脚本后运行。
start_all.bat
访问:http://localhost:9090/ui/login.html 登录用户为 admin 或者 guest, 密码为 test。登录以后可以上传一个文件、根据文件 ID 删除一个文件。
了解示例微服务应用项目结构和开发过程
Maven: 依赖管理
Java-chassis 采用依赖。 依赖关系管理本身并不复杂,但是在项目升级、集成新的第三方软件的时候,容易出现冲突。开发者需要深刻理解 Maven 依赖管理的一些技巧,才能够快速解决这些冲突。
示例微服务项目通过 maven depdendency management 引入 java-chassis 的依赖管理。
<dependencyManagement>
<dependencies>
<dependency>
<groupId>org.apache.servicecomb</groupId>
<artifactId>java-chassis-dependencies</artifactId>
<version>${servicecomb.version}</version>
<type>pom</type>
<scope>import</scope>
</dependency>
</dependencies>
</dependencyManagement>
依赖关系管理并不会在项目中引入三方件,只是告诉项目中,如果引入某个三方件,这个三方件的版本,需要按照 java-chassis-dependencies 里面指定的版本引入。因为 java-chassis-dependencies 引入的三方件经过大量的集成测试,因此能够极少的降低三方件软件冲突的风险。
学习材料:使用maven管理复杂依赖关系的技巧
打包方式
JAVA 项目一般有两种打包方式:Flat 方式和 Spring Boot 方式。
- Flat方式:应用编译以后,项目依赖的 jar 包放到一个独立的 lib 目录。
- Spring Boot 方式:应用编译以后,将项目的 class, 和依赖的 jar 包都打包在一个 jar 文件中。
Spring Boot方式在分发应用的时候更加简单,所以项目采用 Spring Boot 方式打包。需要在 POM 中引入 Spring Boot 的打包插件。
<pluginManagement>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
<version>2.1.6.RELEASE</version>
<executions>
<execution>
<goals>
<goal>repackage</goal>
</goals>
<configuration>
<mainClass>${main.class}</mainClass>
</configuration>
</execution>
</executions>
</plugin>
</plugins>
</pluginManagement>
编译选项
Java-chassis 的运行依赖于编译选项中增加 -parameters , 可以通过 maven compiler plugin 指定。
<pluginManagement>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<version>3.1</version>
<configuration>
<source>1.8</source>
<target>1.8</target>
<compilerArgument>-parameters</compilerArgument>
</configuration>
</plugin>
</plugins>
</pluginManagement>
运行环境和启动类
Java-chassis 开发非常灵活,支持不同的运行环境。比如打包为 war,运行于 tomcat 中;和 spring boot 集成,运行于 embedded tomcat 中;使用轻量级的 HTTP 服务器独立运行。 示例微服务项目的 user-service、file service、website 都采用和 spring boot 集成,运行于 embedded tomcat 中。gateway-service使用轻量级的 HTTP 服务器独立运行。
user-service、file-service都引入了如下依赖:
<dependency>
<groupId>org.apache.servicecomb</groupId>
<artifactId>java-chassis-spring-boot-starter-servlet</artifactId>
</dependency>
学习材料:java-chassis 与 spring boot 集成的原理
java-chassis可以采用 spring 的方式启动,也可以采用 spring boot方式启动。 示例项目采用 Spring Boot 的方式启动,启用 java-chasiss 框架,需要在启动类上加上 @EnableServiceComb 。
@SpringBootApplication
@EnableServiceComb
public class FileMain {
public static void main(String[] args) throws Exception {
try {
SpringApplication.run(FileMain.class, args);
} catch (Exception e) {
e.printStackTrace();
}
}
}
gateway-service 运行于轻量级的 HTTP 服务器,启动类有些特殊,不需要启动 spring boot 的 embedded tomcat. WebApplicationType设置为NONE.
@SpringBootApplication
@EnableServiceComb
public class GatewayMain {
public static void main(String[] args) throws Exception {
try {
new SpringApplicationBuilder().web(WebApplicationType.NONE).sources(GatewayMain.class).run(args);
} catch (Exception e) {
e.printStackTrace();
}
}
}
Provider 开发: jaxrs, spring mvc, 透明RPC开发和契约
通过定义一个简单的接口,接口计算 a + b 的返回值并返回。
- 不管采用哪种方式开发的接口,都会生成契约。契约采用 Open API 进行描述,如果服务监听的协议是 REST,那么任何一种方式开发的接口,都可以通过浏览器直接访问。每种开发方式生成的契约有差异,意味着访问方式不一样。jaxrs、spring mvc采用了 annotation 描述与 HTTP 的映射关系,访问方式更加简单直接;透明 RPC 方式生成的契约将所有参数包装为一个 POST 的 body 参数,使用起来没有那么直接。
- 不管采用哪种方式开发的接口,都可以监听多种协议,比如 REST、HIGHWAY,修改监听协议不需要修改业务代码。监听 HIGHWAY 协议,不能通过浏览器直接访问接口,需要通过 Consumer API 进行访问。
- 从契约可以看出,jaxrs 在 REST 语言方面最规范。spring mvc 的 REST 语义存在很多二义性,比如 @RequestParam 既可以表示 query 参数,也可以表示 form 参数,生成的契约只能够采用 query 参数来表达。根据大量的开发实践,建议 Provider 开发采用 jaxrs 。熟悉 spring boot 的开发者可能倾向于选择 spring mvc, java-chassis 允许一个服务里面的不同接口采用不一样的方式定义,这些接口可以并存。
- 生成的契约会打印到服务启动日志中,也会注册到服务中心,通过服务中心控制台查看。服务中心控制台还提供了对 REST 接口进行测试的功能,非常方便。
JaxRS 方式定义服务:
@RestSchema(schemaId = "HelloWorldJaxRS")
@Path("/jaxrs")
@Produces(MediaType.APPLICATION_JSON)
public class HelloWorldJaxRS {
@Path("/add")
@POST
public int add(@FormParam("a") int a, @FormParam("b") int b) {
return a + b;
}
}
自动生成的契约:
swagger: "2.0"
info:
version: "1.0.0"
title: "swagger definition for org.apache.servicecomb.samples.porter.user.service.HelloWorldJaxRS"
x-java-interface: "gen.swagger.HelloWorldJaxRSIntf"
basePath: "/jaxrs"
schemes:
- "http"
consumes:
- "application/json"
produces:
- "application/json"
paths:
/add:
post:
operationId: "add"
parameters:
- name: "a"
in: "formData"
required: false
type: "integer"
default: 0
format: "int32"
- name: "b"
in: "formData"
required: false
type: "integer"
default: 0
format: "int32"
responses:
"200":
description: "response of 200"
schema:
type: "integer"
format: "int32"
Spring MVC 方式定义服务:
@RestSchema(schemaId = "HelloWorldSpringMVC")
@RequestMapping(path = "/springmvc", produces = MediaType.APPLICATION_JSON_VALUE)
public class HelloWorldSpringMVC {
@PostMapping(path = "/add")
public int add(@RequestParam("a") int a, @RequestParam("b") int b) {
return a + b;
}
}
自动生成的契约:
swagger: "2.0"
info:
version: "1.0.0"
title: "swagger definition for org.apache.servicecomb.samples.porter.user.service.HelloWorldSpringMVC"
x-java-interface: "gen.swagger.HelloWorldSpringMVCIntf"
basePath: "/springmvc"
schemes:
- "http"
consumes:
- "application/json"
produces:
- "application/json"
paths:
/add:
post:
operationId: "add"
parameters:
- name: "a"
in: "query"
required: true
type: "integer"
format: "int32"
- name: "b"
in: "query"
required: true
type: "integer"
format: "int32"
responses:
"200":
description: "response of 200"
schema:
type: "integer"
format: "int32"
透明 RPC 方式定义服务:
@RpcSchema(schemaId = "HelloWorldRPC")
public class HelloWorldRPC {
public int add(int a, int b) {
return a + b;
}
}
自动生成的契约:
swagger: "2.0"
info:
version: "1.0.0"
title: "swagger definition for org.apache.servicecomb.samples.porter.user.service.HelloWorldRPC"
x-java-interface: "gen.swagger.HelloWorldRPCIntf"
basePath: "/HelloWorldRPC"
schemes:
- "http"
consumes:
- "application/json"
produces:
- "application/json"
paths:
/add:
post:
operationId: "add"
parameters:
- in: "body"
name: "addBody"
required: false
schema:
$ref: "#/definitions/addBody"
responses:
"200":
description: "response of 200"
schema:
type: "integer"
format: "int32"
definitions:
addBody:
type: "object"
properties:
a:
type: "integer"
format: "int32"
b:
type: "integer"
format: "int32"
x-java-class: "gen.swagger.addBody"
Consumer开发: RestTemplate,透明RPC开发
Consumer 可以通过三种方式访问 Provider 的接口。
- 采用透明 RPC 的方式;
- 采用 RestTemplate 的方式;
- 采用 InvokerUtils 访问;
- 不管服务端监听的是 REST,还是 HIGHWAY,都可以采用这三种方式访问。根据大量的开发经验,建议采用透明 RPC 的方式。因为这种方式代码最简洁,可移植性最好,以后需要对代码进行重构,或者切换新的框架的时候,透明 RPC 的方式都更加容易移植。
采用透明 RPC 的方式:透明 RPC 的方式, Consumer 可以自定义接口,接口不需要包含 Provider 的所有方法,也不要求参数顺序一致,Consumer和 Provider 完全解耦,非常灵活。从下面的 Consumer 代码可以看出,使用透明 RRC 的方式最简洁,不同的 Provider 接口,调用方式都一样;采用 RestTemplate 方式调用,构造参数的时候,需要根据契约来构造,Provider 的契约不同,因此 Consumer 构造的参数也不同;InvokerUtils 的方式,也需要根据契约来构造参数,参数名称和个数需要保持和契约一致。
@RestSchema(schemaId = "HelloworldTest")
@Path("/test")
public class HelloworldTest {
public static interface Consumer {
int add(int a, int b);
}
@RpcReference(microserviceName = "user-service", schemaId = "HelloWorldJaxRS")
private Consumer jaxrsConsumer;
@RpcReference(microserviceName = "user-service", schemaId = "HelloWorldSpringMVC")
private Consumer springmvcConsumer;
@RpcReference(microserviceName = "user-service", schemaId = "HelloWorldSpringMVC")
private Consumer rpcConsumer;
RestTemplate restTemplate = RestTemplateBuilder.create();
@Path("/test")
@GET
public int test() {
int total = 0;
total += jaxrsConsumer.add(1, 1);
total += springmvcConsumer.add(1, 1);
total += rpcConsumer.add(1, 1);
HttpHeaders formHeaders = new HttpHeaders();
formHeaders.setContentType(MediaType.APPLICATION_FORM_URLENCODED);
Map<String, Object> map = new HashMap<>();
map.put("a", 1);
map.put("b", 2);
HttpEntity<Map<String, Object>> formEntiry = new HttpEntity<>(map, formHeaders);
total += restTemplate.postForEntity("cse://user-service/jaxrs/add", formEntiry, int.class).getBody();
total += restTemplate.postForEntity("cse://user-service/springmvc/add?a=1&b=1", null, int.class).getBody();
Map<String, Object> swaggerArguments = new HashMap<>();
swaggerArguments.put("a", 1);
swaggerArguments.put("b", 1);
total += (int) InvokerUtils.syncInvoke("user-service", "HelloWorldJaxRS", "add", swaggerArguments, int.class);
total += (int) InvokerUtils.syncInvoke("user-service", "HelloWorldSpringMVC", "add", swaggerArguments, int.class);
return total;
}
}
使用异步
通过异步,可以提升资源消耗和提高性能。但是使用异步,也给开发者的开发能力提出了一些要求,比如异步执行逻辑里面不能包含阻塞操作。java-chassis 可以在 Provider 和 Consumer 使用 CompletableFuture 来使用异步。Provider 和 Consumer 的异步是完全独立的,即 provider 可以使用异步,Consumer 不使用异步,反之亦然。
Provider定义:
@RestSchema(schemaId = "HelloWorldJaxRS")
@Path("/jaxrs")
@Produces(MediaType.APPLICATION_JSON)
public class HelloWorldJaxRS {
@Path("/add")
@POST
public CompletableFuture<Integer> add(@FormParam("a") int a, @FormParam("b") int b) {
return CompletableFuture.completedFuture(a + b);
}
}
Consumer定义:
public static interface Consumer {
CompletableFuture<Integer> add(int a, int b);
}
Edge Service 的路由策略
Edge Service 提供了非常多的路由策略,默认的策略使用服务名作为前缀,还提供了采用 URL 进行正则匹配的路由策略。
servicecomb:
http:
dispatcher:
edge:
url:
enabled: true
pattern: /(.*) ## 默认值,一般不需要配置
mappings:
businessV1:
prefixSegmentCount: 1
path: "/url/business/v1/.*"
microserviceName: business
versionRule: 1.0.0-2.0.0
businessV2:
prefixSegmentCount: 1
path: "/url/business/v2/.*"
microserviceName: business
versionRule: 2.0.0-3.0.0
理解请求处理流程
从 Consumer 调用 Provider, 会经过下面的处理流程。运行模型对应于 Handler 链。可以开发自定义的 Handler, 来对请求拦截,进行一些列的治理。java-chassis 提供的大部分治理能力,都是通过 Handler 实现。通过Handler还可以实现异常处理、调用链跟踪、认证鉴权等很多功能。进一步打开 REST 处理流程,还有 HttpServerFilter 或者 HttpClientFilter。 当需要使用 HTTP 来做一些控制,比如读取和设置 cookie,需要扩展。下面是微服务A调用微服务B的全部执行过程。 ServiceComb基于事件实现了这些过程的metrics统计,阅读代码也非常便利,可以打开 Invocation , 查看 onXXX 方法被调用的地方,就能够找到各个环节执行的入口。
- 开始
- A 执行 Handler
- A 执行 HttpClientFilter
- A 发送请求
- B 收到请求
- B 执行 HttpServerFilter
- B 执行 Handler
- B 执行 业务逻辑
- B 执行 Handler
- B 执行 HttpServerFilter
- B 发送响应
- A 收到响应
- A 执行 HttpClientFilter
- A 执行 Handler
- 结束
常见的治理和开发功能
常用的治理能力,使用 java-chassis 都是开箱即用的,不需要开发任何代码,只需要在 microservice.yaml 里面增加配置项,极大的简化了开发者的开发工作量。
处理链配置(Handler)
- Provider 和 Consumer 的处理链独立配置。Handler 在处理链的位置,决定了它的执行顺序。
- Consumer的处理链,还可以结合实际情况,在访问某个具体的服务的时候,指定不一样的处理链。
servicecomb:
handler:
chain:
Provider:
default: qps-flowcontrol-provider
Consumer:
default: qps-flowcontrol-consumer,loadbalance,fault-injection-consumer
负载均衡
ervicecomb:
loadbalance:
strategy:
name: RoundRobin # Support RoundRobin,Random,WeightedResponse,SessionStickiness
流量控制
servicecomb:
flowcontrol:
Provider:
qps:
enabled: true
global:
limit: 10000
灰度发布
servicecomb:
routeRule:
provider: | #服务名
- precedence: 2 #优先级
match: #匹配策略
headers: #header匹配
region:
regex: 'regoin[0-9]*'
caseInsensitive: false # 是否区分大小写,默认为false,区分大小写
type:
exact: gray
route: #路由规则
- weight: 100 #权重值
tags:
version: {version1}
app: {appId}
针对业务的开发部署、参数设置建议
升级到 java chassis 的最新版本
升级到最新版本会存在一些工作量,并解决三方软件冲突等问题。及时升级到最新版本也有很多好处:(1)老版本已知bug得到修复、配置参数调优、在业务中使用新功能(比如下文很多配置项可能在新版本才有);(2)得到更加及时的社区支持。因为老版本时间很长,使用者不多,维护者也多数不能够接触老版本问题,使用新版本,使用的问题会得到更加快速的回复;(3)安全漏洞得到及时修复,三方软件漏洞得到及时升级。
ServiceComb提供了一个很好的说明,讲解为什么升级,及其好处和问题:http://servicecomb.gitee.io/servicecomb-java-chassis-doc/java-chassis/zh_CN/featured-topics/compatibility.html
升级也会存在一些风险,升级前建议阅读升级指导,识别变化是否和业务应用场景有关,提前应对风险:http://servicecomb.gitee.io/servicecomb-java-chassis-doc/java-chassis/zh_CN/featured-topics/upgrading.html
合理配置日志文件
(1)使用 log4j2 或者 logback 输出日志。 将日志输出到文件,不要依赖于容器的 stdout。
(2)打开 metrics 日志,将 metrics 日志输出到独立的文件,比如 metrics.log, 而将业务日志输出到另外的文件,比如 servicecomb.log。
(3)打开 access log , 将 access log 输出到独立的日志文件。
(4)格式化打印业务日志,日志里面包含 trace id。可以独立开发一个 Handler,配置在 Provider Handler的最前面,Handler在接收到请求后打印一条日志,处理完成了打印一条日志,对于问题界定,使用AOM快速检索相关日志等非常有帮助。
合理配置线程池参数
建议开始之前通过文章: http://servicecomb.gitee.io/servicecomb-java-chassis-doc/java-chassis/zh_CN/general-development/thread-model.html 了解 java chassis 的线程池设置原理。 下面列出一些常见的配置项和建议。性能优化和业务自身的性能有很大关系,不同的场景参数设置适用于不同的场景,需要具体分析。 为了简单, 下面分两种场景介绍:开始之前,需要对业务的性能做一些基本的摸底,对常见的接口进行测试,看看时延。
- 业务性能很好的情况
单个接口的时延普遍低于10ms,少量接口低于100ms
业务性能很好的时候,为了让业务系统具备更好的可预测性,防止JVM垃圾回收、网络波动、突发流量等对性能的稳定性造成冲击,需要能够快速丢弃请求,并配合重试等措施,以保障波动情况下系统性能可预测,同时不会出现偶然的业务失败,影响体验。
# vertical 实例数,新版本保持默认值即可。建议配置为8~10,一般不需要修改。
servicecomb.rest.server.verticle-count: 10
# 默认值为 Integer.MAX_VALUE, 一般不需要修改
servicecomb.rest.server.connection-limit:
# 默认值60秒,一般不需要修改
servicecomb.rest.server.connection.idleTimeoutInSeconds: 60
# vertical 实例数,新版本保持默认值即可。建议配置为8~10,一般不需要修改。
servicecomb.rest.client.verticle-count: 0
# 一个客户端与服务器建立的最大连接数为 verticle-count * maxPoolSize, 不要超过线程数。 这里是 10*50=500. 实例非常多的场景,要减小单个实例的连接数。
servicecomb.rest.client.connection.maxPoolSize: 50
#默认值30 秒,一般不需要修改. 为服务端的一半
servicecomb.rest.client.connection.idleTimeoutInSeconds
# 线程池组数,建议 2~4
servicecomb.executor.default.group: 2
# 建议 50~200
servicecomb.executor.default.thread-per-group: 100
# 线程池排队队列大小,高性能场景不要使用默认值,以快速丢弃请求
servicecomb.executor.default.maxQueueSize-per-group: 10000
# 高性能场景配置小的排队超时时间,快速丢弃请求
servicecomb.rest.server.requestWaitInPoolTimeout: 100
# 设置比较短的超时时间,快速丢弃请求, 但是不建议这个值小于1秒,可能导致很多问题。
servicecomb.request.timeout=5000
- 业务性能不那么好的情况
单个接口的时延普遍高于100ms,时延高通常是由于业务代码存在IO、资源等等待,CPU利用率上出去导致的。如果是由于计算复杂导致的,调优会变得复杂。
当业务性能不太好的时候,下面几个参数值需要调大,否则业务会大量阻塞。业务性能不好,通过调大参数能够保证系统的吞吐量,应对突发流量来临时带来的业务失败。不过这个是以牺牲用户体验为代价的。需要关注下面的一些配置项:
# 默认值60秒,一般不需要修改
servicecomb.rest.server.connection.idleTimeoutInSeconds: 120000
#默认值30 秒,一般不需要修改. 为服务端的一半
servicecomb.rest.client.connection.idleTimeoutInSeconds: 90000
# 线程池组数,建议 2~4
servicecomb.executor.default.group: 4
# 建议 50~200
servicecomb.executor.default.thread-per-group: 100
# 线程池排队队列大小,性能不好的情况下需要排队
servicecomb.executor.default.maxQueueSize-per-group: 100000
# 高性能场景配置小的排队超时时间,快速丢弃请求
servicecomb.rest.server.requestWaitInPoolTimeout: 100
servicecomb.request.timeout=30000
ServiceComb 2.3.0 提供了更加高级的功能, 可以帮助解决快速失败和全局超时设置,参考: http://servicecomb.gitee.io/servicecomb-java-chassis-doc/java-chassis/zh_CN/references-handlers/fail-retry.html
合理的规划系统架构
一般都会采用一级或者二级网关让系统具备更好的弹性。并且梳理流量、负责重试,使得系统具备更好的应对突发流量的能力。
下面有二级网关的架构建议,这个架构被大型用户使用过,供参考。
合理规划资源
在合理规划线程池参数里面提到了线程池等配置信息。 这个信息还需要结合资源规划进行合理配置。在开发测试环境,资源分配倾向于分配比较小的初始资源,以节省测试成本。但是对于生产环境,需要尽可能保证系统运行的可预期性,避免临时申请大量资源或者释放资源,在整个软件运行生命周期,核心资源池应该保持稳定。 分配较小的资源可能导致系统运行不稳定:比如容器可能将大量微服务分配到一个节点,请求量上来后,部分实例由于资源不足,就会被杀掉,分配到其他节点,系统处于波动和不稳定状态。
资源分配的核心包括 CPU 和 内存,生产环境,可以先给一个规划表,给出每个微服务的资源占用限制,然后一次性申请到稳定运行需要的资源。
线程池设置等会影响资源规划,通常增加一个线程,内存占用会增加1M。比如如果业务线程池设置2000个线程,那么建议至少给这个微服务分配不小于 2G 的初始内存。
合理规划治理参数
servicecomb:
loadbalance:
isolation:
enabled: true
errorThresholdPercentage: 0
enableRequestThreshold: 5
singleTestTime: 60000
continuousFailureThreshold: 2
maxSingleTestWindow: 60000 # 为了保证在并发情况下只有一个实例放通,会锁定放通实例。这个时间表示最大锁定时间。
minIsolationTime: 3000 # 最短隔离时间。并发情况下,实例隔离后进行中的请求可能快速刷新隔离状态,增加最短隔离时间。
recoverImmediatelyWhenSuccess:true # 放通实例,如果调用成功,立即清除统计状态,保证后续请求能够使用该实例。
实例隔离能够在一个实例故障的时候,短暂的不往这个实例发送消息,防止产生雪崩效应。
建议对耗时的请求配置独立线程池。系统中可能有一些特别耗时的任务,这些任务如果和其他请求在一个业务线程池处理,偶然的大并发,这些耗时任务可能将系统资源全部占据, 其他请求也得不到处理。
servicecomb.executors.Provider.${schemaId}.${operationId}: custom-executor