原文作者:Piotr Mińkowski
原文地址:https://dzone.com/articles/quick-guide-to-microservices-with-spring-boot-20-e
在我的博客里面已经有了很多关于 Spring Boot 和 Spring Cloud 微服务开发的文章,而这篇文章的主要目的便是对这些微服务的开发框架的一些关键组件做一个简单的总结。本文会涉及到这些主题:
在我们讨论源码之前,我们先看一看下面这张图。下图描绘了我们接下来所设计的一个示例系统的架构。在这个系统里有 3 个独立的微服务。它们会通过服务发现来完成服务的登记,并从配置服务器里面拉取相关属性,然后与其他微服务进行交互。
目前 Spring Cloud 的最新版本是 Finchley.M9
。spring-cloud-dependencies
的版本应该在依赖项目里面得到声明。
<?xml version="1.0" encoding="UTF-8"?>
<dependencyManagement>
<dependencies>
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-dependencies</artifactId>
<version>Finchley.M9</version>
<type>pom</type>
<scope>import</scope>
</dependency>
</dependencies>
</dependencyManagement>
现在,我们来进一步地考虑一下使用 Spring Cloud 来构建一个基于微服务的系统的步骤。我们首先从配置服务器开始。
本文设计的示例项目的源码可以在 GitHub 里面找到。
为了能在项目里面使用 Spring Cloud Config 的功能,我们首先需要把 spring-cloud-config-server
加到依赖项目里面。
<?xml version="1.0" encoding="UTF-8"?>
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-config-server</artifactId>
</dependency>
然后给应用加上 @EnableConfigServer
注解来让应用在启动的时候运行集成的配置服务器。
@SpringBootApplication
@EnableConfigServer
public class ConfigApplication {
public static void main(String[] args) {
new SpringApplicationBuilder(ConfigApplication.class).run(args);
}
}
在默认情况下,Spring Cloud Config 服务器会在 Git 仓库里面保存配置信息的数据。这在生产环境里面是个不错的选择,不过在本文所设计的示例里面里面就有点过分了。毕竟我们可以把全部属性都写进 classpath 里面,而 Spring Cloud Config 会默认地在 classpath:/
、classpath:/config
、file:./config
里面查找配置文件,这样构建一个配置服务器其实是非常简单的事情。
我们会把全部配置文件都放到 src/main/resources/config
里面。YAML 配置文件的文件名应该跟对应的服务的名字保持一致。比如 discovery-service 服务的 YAML 配置文件名路径就是 src/main/resources/config/discovery-service.yml
。
这里有两个重点。如果要基于一个文件系统作为后端来构建一个配置服务器,那就得启用 Spring Boot 的原生属性(在启动应用的时候加上 --spring.profiles.active=native
参数即可)。在这里我同时也把配置服务器的端口号从默认的 8888 改成了 8061(在 bootstrap.yml
配置文件里面设置 server.port
参数即可)。
在完成构建配置服务器之后,包括 discovery-service 在内的所有应用都要把 spring-cloud-starter-config
加进依赖项目里面来启动各自的针对配置服务器的客户端。除此之外,我们还应该加上 spring-cloud-starter-netflix-eureka-server
这个依赖。
<?xml version="1.0" encoding="UTF-8"?>
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-netflix-eureka-server</artifactId>
</dependency>
然后给 discovery-service 服务的对应应用加上 @EnableEurekaServer
来在应用启动的时候启动集成的服务发现服务器。
@SpringBootApplication
@EnableEurekaServer
public class DiscoveryApplication {
public static void main(String[] args) {
new SpringApplicationBuilder(DiscoveryApplication.class).run(args);
}
}
这一应用需要从配置服务器里面拉取配置信息。其实只需要在客户端的配置信息里面给出应用名称还有配置服务器的连接设置就可以了。
spring:
application:
name: discovery-service
cloud:
config:
uri: http://localhost:8088
之前也提到过,discovery-service.yml
这一配置文件应该放在 config-service
模块里面。然而,我必须对下面的配置信息解释一下。首先,我们把 Eureka 的运行端口从默认值 8761 改成了 8061。其次,就一个独立出来的 Eureka 实例来说,我们应该禁用掉它的服务登记和发现功能。
server:
port: 8061
eureka:
instance:
hostname: localhost
client:
registerWithEureka: false
fetchRegistry: false
serviceUrl:
defaultZone: http://${eureka.instance.hostname}:${server.port}/eureka/
现在,在运行了带有集成的 Eureka 服务器的应用之后,我们应该能看到下面这些日志信息:
在成功运行这一应用之后,我们就可以通过访问 http://localhost:8061
来查看 Eureka 的配置界面了。
我们的微服务在启动的时候需要执行一些动作。它首先要从 config-service
里面拉取配置信息,然后通过 discovery-service 完成服务的登记,接着开放 HTTP API,并自动生成 API 文档。为了实现这若干步骤,我们需要在 pom.xml
里面加一些依赖项目。为了获取配置信息,我们应该加上 spring-cloud-starter-config
这一项目。为了启用服务登记和发现功能,我们还应该加上 spring-cloud-starter-netflix-eureka-client
,然后给应用加上 @EnableDiscoveryClient
注解。为了让 Spring Boot 应用自动生成 API 文档,我们还应该加上 springfox-swagger2
项目,然后给应用加上 @EnableSwagger2
注解。
下面便是我们编写示例微服务所用到的完整依赖项目列表:
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-netflix-eureka-client</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-config</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>io.springfox</groupId>
<artifactId>springfox-swagger2</artifactId>
<version>2.8.0</version>
</dependency>
下面便是加上了 Discovery Client 还有 Swagger2 的微服务的应用类。
@SpringBootApplication
@EnableDiscoveryClient
@EnableSwagger2
public class EmployeeApplication {
public static void main(String[] args) {
SpringApplication.run(EmployeeApplication.class, args);
}
@Bean
public Docket swaggerApi() {
return new Docket(DocumentationType.SWAGGER_2)
.select()
.apis(RequestHandlerSelectors.basePackage("pl.piomin.services.employee.controller"))
.paths(PathSelectors.any())
.build()
.apiInfo(new ApiInfoBuilder().version("1.0").title("Employee API").description("Documentation Employee API v1.0").build());
}
...
}
这一应用需要向一个远程服务器拉取配置信息,因此我们也应该给出一个 bootstrap.yml
文件来指定服务名还有服务器的 URL。其实这也是配置优先启动方法的一个例子,采用这个方法的应用会首先连接配置服务器,然后从远程配置服务器拉取服务发现服务器的地址。这里同样也有一种发现优先启动方法,采用此方法的应用会从服务发现服务器拉取配置服务器的地址。
spring:
application:
name: employee-service
cloud:
config:
uri: http://localhost:8088
这一应用的配置信息其实不多。下面便是写在配置服务器里面的这一应用的配置信息,只包括了 HTTP API 的运行端口还有 Eureka 服务器的 URL。不过我还在配置服务器上放了一份 employee-service-instance2.yml
文件,为这一应用设置了一个不同的端口。这样,我们就可以运行同一服务的两个不同的实例,只需要在启动应用的时候加上 spring.profiles.active=instance2
参数即可,其中 employee-service
的第二个实例会运行在 9090 端口上。若按照默认设置,那么微服务会运行在 8090 端口上。
server:
port: 9090
eureka:
client:
serviceUrl:
defaultZone: http://localhost:8061/eureka/
下面就是服务的 REST 控制器类的实现。它对员工信息的添加和基于不同条件的查找提供了实现。
@RestController
public class EmployeeController {
private static final Logger LOGGER = LoggerFactory.getLogger(EmployeeController.class);
@Autowired
EmployeeRepository repository;
@PostMapping
public Employee add(@RequestBody Employee employee) {
LOGGER.info("Employee add: {}", employee);
return repository.add(employee);
}
@GetMapping("/{id}")
public Employee findById(@PathVariable("id") Long id) {
LOGGER.info("Employee find: id={}", id);
return repository.findById(id);
}
@GetMapping
public List findAll() {
LOGGER.info("Employee find");
return repository.findAll();
}
@GetMapping("/department/{departmentId}")
public List findByDepartment(@PathVariable("departmentId") Long departmentId) {
LOGGER.info("Employee find: departmentId={}", departmentId);
return repository.findByDepartment(departmentId);
}
@GetMapping("/organization/{organizationId}")
public List findByOrganization(@PathVariable("organizationId") Long organizationId) {
LOGGER.info("Employee find: organizationId={}", organizationId);
return repository.findByOrganization(organizationId);
}
}
我们已经构建并启动了一个微服务了。现在我们便加上另外一些微服务来产生一些交互。下图便展示了我们所编写的 3 个微服务(organization-service
、department-service
、employee-ervice
)间的交互流模式。organization-service
微服务会通过 department-service
的端点(endpoint)获取带有下属员工列表的部门列表(GET /organization/{organizationId}/with-employees
)和不带员工列表的部门列表(GET /organization/{organizationId}
),还会直接通过 employee-service
获取没有用部门来进行划分的员工列表。department-service
微服务则能够获取特定部门下的员工列表。
在上述情形里面,orgranization-service
还有 department-service
应该持有别的微服务的知识,并与其他微服务进行交互。对此,我们需要加上另外一个依赖项目 spring-cloud-starter-openfeign
。Spring Cloud Open Feign 是一个声明式的 REST 客户端,会使用 Ribbon 客户端的负载均衡器来和其他微服务进行交互。
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-openfeign</artifactId>
</dependency>
Open Feign 有个替代方案是 Spring 框架里面加上了 @LoadBalaced
注解的 RestTemplete
。不过,Feign 对定义 REST 客户端给出了一种更加优雅的方法,因此我也更倾向于 Feign。在加入 Feign 这一依赖项目之后,我们就可以在应用变量加上 @EnableFeignClients
注解来启用 Feign 的客户端功能了。
@SpringBootApplication
@EnableDiscoveryClient
@EnableFeignClients
@EnableSwagger2
public class OrganizationApplication {
public static void main(String[] args) {
SpringApplication.run(OrganizationApplication.class, args);
}
// ...
}
现在我们需要定义客户端的接口。鉴于 organization-service
跟其余 2 个微服务都有交互,我们应该构建 2 个接口,一个接口对应一个微服务。每个客户端接口都要加上 @FeignClient
注解,且要往注解里面填入 name
字段。这一字段应该和这一接口所对应的已登记的微服务名称保持一致。下面就是调用 employee-service
开放的端点 GET /organization/{organizationId}
的客户端的一个接口。
@FeignClient(name = "employee-service")
public interface EmployeeClient {
@GetMapping("/organization/{organizationId}")
List findByOrganization(@PathVariable("organizationId") Long organizationId);
}
第二个 organization-service
内置的客户端接口调用了 department-service
的两个端点。一个是 GET /organization/{organizationId}
,只会返回现存的部门列表;另一个是 GET /organization/{organizationId}/with-employees
,会返回部门列表以及每个部门下属的员工列表。
@FeignClient(name = "department-service")
public interface DepartmentClient {
@GetMapping("/organization/{organizationId}")
public List findByOrganization(@PathVariable("organizationId") Long organizationId);
@GetMapping("/organization/{organizationId}/with-employees")
public List findByOrganizationWithEmployees(@PathVariable("organizationId") Long organizationId);
}
最后便可以将这两个 Feign 客户端作为 Bean 注入到 REST 控制器类里面。然后我们便能通过调用 DepartmentClient
还有 EmployeeClient
里面的方法来调用相应的 REST 端点。
@RestController
public class OrganizationController {
private static final Logger LOGGER = LoggerFactory.getLogger(OrganizationController.class);
@Autowired
OrganizationRepository repository;
@Autowired
DepartmentClient departmentClient;
@Autowired
EmployeeClient employeeClient;
// ...
@GetMapping("/{id}")
public Organization findById(@PathVariable("id") Long id) {
LOGGER.info("Organization find: id={}", id);
return repository.findById(id);
}
@GetMapping("/{id}/with-departments")
public Organization findByIdWithDepartments(@PathVariable("id") Long id) {
LOGGER.info("Organization find: id={}", id);
Organization organization = repository.findById(id);
organization.setDepartments(departmentClient.findByOrganization(organization.getId()));
return organization;
}
@GetMapping("/{id}/with-departments-and-employees")
public Organization findByIdWithDepartmentsAndEmployees(@PathVariable("id") Long id) {
LOGGER.info("Organization find: id={}", id);
Organization organization = repository.findById(id);
organization.setDepartments(departmentClient.findByOrganizationWithEmployees(organization.getId()));
return organization;
}
@GetMapping("/{id}/with-employees")
public Organization findByIdWithEmployees(@PathVariable("id") Long id) {
LOGGER.info("Organization find: id={}", id);
Organization organization = repository.findById(id);
organization.setEmployees(employeeClient.findByOrganization(organization.getId()));
return organization;
}
}
Spring Cloud Gateway 算是一个比较新的 Spring Cloud 项目。它是基于 Spring Framework 5、Project Reactor 还有 Spring Boot 2.0 而构建的,需要依赖 Spring Boot 还有 Spring Webflux 所提供的 Netty 运行库来运行。过去为 Spring Cloud 项目里的微服务提供 API 网关功能的一直是 Spring Cloud Netflix Zuul,而现在 Spring Cloud Gateway 则成为了一个很不错的替代方案。
示例项目里的 gateway-service
模块给出了 API 网关的实现。为了实现 API 网关,我们首先还是要把 cloud-starter-gateway
加到依赖项目里面:
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-gateway</artifactId>
</dependency>
我们还需要在这一模块启用服务发现功能,毕竟 gateway-service
得把 Eureka 整合进来才能发现下游的服务并为它们提供路由功能。我们所设计的微服务开放的各个端点的 API 文档也会在网关这里的到开放。这也是引入 Swagger2 的一个好处。
@SpringBootApplication
@EnableDiscoveryClient
@EnableSwagger2
public class GatewayApplication {
public static void main(String[] args) {
SpringApplication.run(GatewayApplication.class, args);
}
}
Spring Cloud Gateway 有 3 个基本配置组件:路由、转发条件,和过滤器。路由是网关的基础组件,有一个目标 URI 地址列表,转发条件列表还有过滤器列表。转发条件则负责匹配传入 HTTP 请求的内容,比如首部或参数。过滤器则会修改某个请求,并在将其转发给下游服务之前和之后作出响应。这些组件都可以用配置属性来设置。我们会把针对我们的微服务的路由的配置属性放在配置服务器的 gateway-service.yml
里面。
不过在这之前,我们应该设置 spring.cloud.gateway.discovery.locator.enabled=true
来确保服务发现功能跟网关的路由功能整合到了一起。然后我们便可以开始定义路由规则了。这里我们会用 Path Route Predicate Factory 来匹配传入请求,用 RewritePath GatewayFilter Factory 来修改请求目标的路径,使后者能适配为下游服务的格式,其中 URI 参数所表示的是目标服务在服务发现服务器上的登记名称。
接下来我们便来看一看下面的路由配置定义。举个例子,为了能让 organization-service
能通过网关的 /orgranization/**
路径来调用,我们就应该定义转发条件为 Path=/organization/**
,然后从请求路径里除掉前缀 /organization
,毕竟目标服务是在 /**
路径下开放的。另外,我们可以用 lb://organization-serivce
这一 URI 值来表示从 Eureka 服务器拉取来的目标服务的地址。
spring:
cloud:
gateway:
discovery:
locator:
enabled: true
routes:
- id: employee-service
uri: lb://employee-service
predicates:
- Path=/employee/**
filters:
- RewritePath=/employee/(?<path>.*), /$\{path}
- id: department-service
uri: lb://department-service
predicates:
- Path=/department/**
filters:
- RewritePath=/department/(?<path>.*), /$\{path}
- id: organization-service
uri: lb://organization-service
predicates:
- Path=/organization/**
filters:
- RewritePath=/organization/(?<path>.*), /$\{path}
所有被 @EnableSwagger2
注解过的 Spring Boot 微服务都会在 /v2/api-docs
这一路径下开放它们的 Swagger API 文档。不过,我们也时常会希望能将各个微服务的 API 文档整合到一起 - 其实也就是整合到 API 网关上。为了达成这一目的,我们需要在 gateway-service
模块给出一个 SwaggerResourcesProvider
接口的实现类,然后将其封装成一个 Bean 以便注入到其他类里面。这一实现类会承担定义 Swagger 文档资源位置的列表的任务,而这一列表就是应用所应呈现的内容。下面会给出 SwaggerResourecesProvider
的一个实现。它会基于 Spring Cloud Gateway 的配置属性去使用服务发现功能来获取所需要的资源的位置。
然而,SpringFox Swagger 并没有为 Spring WebFlux 提供支持。也就是说,若要把 SpringFox Swagger 加到这一网关项目的依赖项目里,那应用可就启动不了了。为了把 Swagger2 集成进项目里面,我们也只能先运行一个基于 Spring Cloud Netflix Zuul 的网关了,希望 Swagger 将来能支持 WebFlux。
我编写了一个 proxy-service
模块来运行一个基于 Netflix Zuul 的网关来替代 gateway-service
。下面便是内置进网关的,封装成 Bean 的 SwaggerResourcesProvider
的实现类。它用了 ZuulProperties
这一 Bean 来动态地将路由定义加载到一个列表里面。
@Configuration
public class ProxyApi {
@Autowired
ZuulProperties properties;
@Primary
@Bean
public SwaggerResourcesProvider swaggerResourcesProvider() {
return () -> {
List resources = new ArrayList();
properties.getRoutes().values().stream()
.forEach(route -> resources.add(createResource(route.getServiceId(), route.getId(), "2.0")));
return resources;
};
}
private SwaggerResource createResource(String name, String location, String version) {
SwaggerResource swaggerResource = new SwaggerResource();
swaggerResource.setName(name);
swaggerResource.setLocation("/" + location + "/v2/api-docs");
swaggerResource.setSwaggerVersion(version);
return swaggerResource;
}
}
下图是示例项目的 Swagger 界面,可以通过地址 http://localhost:8060/swagger-ui.html
来访问。
我们再来看看整个系统的架构,如下图所示。我们从 organization-service
的视角看起。在系统启动之后,organization-service
会通过地址 localhost:8088 连接 config-service
(1)。然后,利用从远程配置服务器获取的设置信息,它就能在 Eureka 服务器进行服务的登记 (2)。 在 organization-service
的端点被外部客户端通过网关(地址为 localhost:8060)调用时 (3),请求会被转发到 organization-service
的其中一个已登记的实例 (4)。然后,在要调用 department-service
的端点的情况下,organization-service
会在 Eureka 里面查找 department-service
的地址 (5),并调用对应的端点 (6)。最终,department-service
又会调用 employee-service
的端点。这一请求带来的负载会通过 Ribbon 均匀分摊到两个 employee-service
上 (7)。
我们再来访问 http://localhost:8061
来看看 Eureka 的主界面。主界面应该登记着 4 个微服务的实例,分别有 1 个 organization-service
和 department-service
还有 2 个 employee-service
。
现在我们试试调用 http://localhost:8060/organization/1/with-departments-and-employees
这个端点。其返回的结果应该跟下图所示的差不多:
这一步非常简单。其实我们只要把 spring-cloud-starter-sleuth
这一依赖项目加到每个微服务还有网关的项目里面就可以了。
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-sleuth</artifactId>
</dependency>
具体而言,我们会把默认的日志格式改成 %d{yyyy-MM-dd HH:mm:ss} ${LOG_LEVEL_PATTERN:-%5p} %m%n
。下图就展示了我们的三种微服务所各自生成的日志。Spring Cloud Stream 会在每行输出包含在一个中括号 []
里面的四个条目。其中对我们而言最重要的是第二个条目,它表示的是 traceId
字段。每当系统接收到一个传入 HTTP 请求的时候,系统就会给它设置一个 traceId
。