第11讲:使用 Spring Data JPA 实现数据库驱动微服务
在确定了微服务的 API 之后,我们就可以开始微服务的具体实现,微服务在实现时,并不限制所使用的编程语言或框架。虽然微服务的功能各有不同,但大部分微服务都是以数据库来驱动的。也就是说,这些微服务有一个后台数据库,可能是关系型数据库或 NoSQL 数据库。很多开发人员应该都有过开发数据库驱动的应用的经验,数据库驱动的微服务实现,与一般的数据库驱动的应用并没有太大的区别。
因为数据库驱动的应用十分流行,市面上相关的参考资料也非常多,本课时不打算对很多实现细节进行介绍,而是侧重于一些与微服务相关的内容,以及需要特别注意的内容。本课时以乘客管理微服务作为示例进行说明,数据库相关的实现使用 Spring Data JPA 访问 PostgreSQL 数据库。完整的实现请参考示例应用的源代码。
聚合、实体和值对象
随着对象关系映射(Object-Relational Mapping,ORM)以及 Hibernate 这样的框架的流行,数据库驱动的应用的实现变得简单了很多。对象关系映射指的是对象模型和数据库关系模型之间的映射。对象模型由类声明和类之间的引用关系组成,数据库的关系模型指的是数据库中的表和表之间的关系。这两种模型存在阻抗不匹配(Impedance Mismatch)的情况,对象模型可以使用继承和多态,而关系模型则要求对数据进行归一化处理。对象之间的引用方式很简单,而关系模型中则需要定义表的外键。如何在两个模型之间进行映射,这也是 ORM 技术的复杂性所在。
当然,ORM 技术本身并不是很难掌握的技术,Hibernate 这样的框架已经为我们屏蔽了很多底层实现细节。我们需要掌握的只是一些使用模式。比如,对象之间的引用关系,在一对多的映射中,什么时候使用单向关系,什么时候使用双向关系。这些都是有模式可以遵循的。
ORM 技术中最常使用的概念是实体(Entity)。在领域驱动设计中,与模型相关的有 3 个概念,分别是聚合、实体和值对象。聚合是一个抽象的概念,不需要对应到具体的实体。实体需要映射成 ORM 中的实体。值对象通常不会被映射成单一实体,而是作为其他实体的一部分,实体的标识符被映射成数据库的主键。
在乘客管理微服务中,聚合乘客的根实体是乘客,用户地址实体表示乘客所保存的地址,乘客实体中包含对用户地址实体的引用。区分实体和值对象的关键在于,对象是否有各自独立的生命周期。以用户地址为例,每个用户地址都可以被用户创建、更新和删除,因此它们有各自独立的生命周期,也就是说用户地址属于实体。用户地址实体属于聚合乘客的一部分。
领域对象
在创建实体类时,一个需要注意的问题是避免反模式贫血对象,贫血对象指的是对象类中只有属性声明以及属性的 getter 和 setter 方法。贫血对象实际上退化成为属性的数据容器,并没有其他的行为。贫血对象不符合我们对领域对象的期望,领域对象的行为应该是完备的。以乘客对象为例,与用户地址相关的管理功能,都应该添加在乘客对象中。这一点对聚合的根实体尤为重要,聚合的根实体需要负责维护业务逻辑中的不变量。与维护不变量相关的代码都应该直接被添加到实体类中。
当用 JPA 实现数据访问时,我们可以用领域对象类来作为 JPA 的实体类,只需要添加 JPA 的相关注解即可。下面代码中的 Passenger 类是领域对象类,同时也是 JPA 的实体类。Passenger 类中字段都添加了 JPA 的相关注解。Lombok 的注解用来生成 getter、setter、构造器和 toString 方法。Passenger 实体和 UserAddress 实体之间存在一对多关系,使用注解 @OneToMany 来表示。管理 UserAddress 对象的方法都添加在 Passenger 类中了,这体现了 Passenger 类是聚合的根。
@Entity
@Table(name = "passengers")
@Getter
@Setter
@NoArgsConstructor
@ToString
public class Passenger extends BaseEntityWithGeneratedId {
“name”)
(max = 255)
private String name;
“email”)
(max = 255)
private String email;
“mobile_phone_number”)
(max = 255)
private String mobilePhoneNumber;
true)
(name = “passenger_id”, referencedColumnName = “id”,
nullable = false)
private List<UserAddress> userAddresses = new ArrayList<>();
public void addUserAddress(UserAddress userAddress) {
if (userAddress != null) {
userAddresses.add(userAddress);
}
}
public void removeUserAddress(UserAddress userAddress) {
userAddresses.remove(userAddress);
}
public void removeUserAddress(String addressId) {
getUserAddress(addressId).ifPresent(this::removeUserAddress);
}
public Optional<UserAddress> getUserAddress(String addressId) {
return userAddresses.stream()
.filter(address -> Objects.equals(address.getId(), addressId))
.findFirst();
}
}
数据访问
对于一个聚合来说,只有聚合的根实体可以被外部对象所访问,因此只需要对聚合的根实体创建资源库即可。在乘客管理微服务中,我们只需要为乘客实体创建对应的资源库即可。下面代码中的 PassengerRepository 接口是乘客实体对应的资源库。
@Repository
public interface PassengerRepository extends CrudRepository<Passenger, String> {
}
一个需要考虑的问题是数据库表模式的生成。Hibernate 这样的 ORM 框架都支持从实体声明中自动生成和更新数据库表模式。这种做法看起来很简单方便,但是存在很多问题。
第一个问题是数据库表模式的优化问题。为了优化数据库的查询性能,数据库表模式通常需要由专业的人员进行设计。由 Hibernate 这样的框架所生成的数据库表模式只是通用的实现,并没有对特定应用进行优化。
第二个问题是数据库表模式的更新问题。在更新代码时,如果涉及到对数据库表模式的修改,直接使用 Hibernate 提供的更新功能并不是一个好选择。最主要的原因是自动更新的结果并不可控,尤其是需要对已有的数据进行更新时。
更好的做法是手动维护数据库表模式,并使用数据库迁移工具来更新模式。示例应用使用的迁移工具是 Flyway。在乘客管理微服务中,目录 src/main/resources/db/migration 中包含了数据库的迁移脚本。迁移脚本的文件名称类似 V1__init_schema.sql ,其中的前缀 V1 表示的是脚本的版本号。Flyway 会根据当前数据库中记录的版本信息来确定哪些脚本需要运行,并把数据库表模式升级到最新版本。
领域层
领域层包括领域对象和服务实现,服务实现直接使用资源库来对实体进行操作。在设计服务实现的接口时,一个常见的做法是使用领域对象作为参数和返回值。在下面的代码中,PassengerService 类中的 getPassenger 方法的返回值是 Optional 类型,直接引用了领域类 Passenger。
public Optional<Passenger> getPassenger(String passengerId) {}
这种做法既简单又直接,不过却有两个不足之处。
第一个不足之处在于对外部对象暴露了聚合的实体及其引用的对象。外部对象获取到实体的引用之后,是可以通过该对象来修改状态的,可能会产生意想不到的结果。
第二个不足之处是在 Hibernate 的实现上,乘客实体引用了一个用户地址实体的列表。从性能的角度考虑,对于一个乘客对象来说,它的用户地址列表是延迟获取的。也就是说,只有在第一次获取用户地址列表中的元素时,才会从数据库中读取。而读取数据库需要一个打开的 Hibernate 会话。当在 REST API 的控制器中访问 Passenger 对象中的用户地址列表时,为了操作可以成功,就要求 Hibernate 会话仍然处于打开状态,这带来的结果就是 Hibernate 会话的打开时间过长,影响性能。更合理的做法应该是在服务对象的方法退出时,就关闭会话。
综合上面两个原因,直接使用领域对象作为服务对象方法的返回值,并不是一个好的选择,更好的做法是使用值对象作为返回值。值对象作为领域对象中所包含的数据的复制,去掉了领域对象中包含的业务逻辑,只是单纯的作为数据容器。这使得使用者在获取数据的同时,又无法改变内部实体对象的状态。由于转换成值对象的逻辑发生在服务方法内部,并不会影响 Hibernate 会话的关闭。这种做法同时解决了上述两个问题,应该是值得推荐的做法。Spring Data JPA中的配置属性 spring.jpa.open-in-view 可以控制会话是否在控制器中打开,该属性的默认值为 true。在应用了这种模式之后,该属性的值应该被设置为 false 。
下面代码中的 PassengerVO 类是 Passenger 实体对应的值对象。
@Data
@AllArgsConstructor
public class PassengerVO {
@NonNull
private String id;
private String name;
private String email;
private String mobilePhoneNumber;
private List<UserAddressVO> userAddresses;
}
下面的代码给出了服务实现 PassengerService 类的部分代码,PassengerVO 类作为返回值的类型。
@Service
@Transactional
public class PassengerService {
PassengerRepository passengerRepository;
public List<PassengerVO> findAll() {
return Streams.stream(passengerRepository.findAll())
.map(PassengerUtils::createPassengerVO)
.collect(Collectors.toList());
}
}
展示层
对于微服务来说,其展示层就是它们对外提供的 API,这个 API 可以被其他微服务、Web 界面和移动客户端来使用。示例应用使用 JSON 表示的 REST API。对于使用 Spring Boot 和 Spring 框架的微服务实现来说,暴露 REST API 是非常简单的事情,可以 Spring MVC 或 Spring WebFlux。
下面代码给出了乘客服务的 REST API 控制器的部分代码:
@RestController
@RequestMapping("/api/v1")
public class PassengerController {
PassengerService passengerService;
public List<PassengerVO> findAll() {
return passengerService.findAll();
}
}
在第 10 课时中,我提到了 Swagger 的代码生成工具可以生成服务器存根代码,这其中就包括了对 Spring 框架的支持。在采用了 API 优先的策略之后,我们可以从 OpenAPI 文档中生成 API 服务端代码的骨架,并以此作为实际实现的基础。通过这种方式,可以快速创建一个可工作的 API 服务器。
不过需要注意两个问题。首先是 Swagger 代码生成工具创建的项目是基于 Spring Boot 1.5 的。如果你期望使用 Spring Boot 2,那么需要先自己进行升级;其次是通过工具生成的代码并不一定符合你的团队的开发规范,代码生成过程是单向不可逆的。如果 OpenAPI 文档发生了改变,再次生成会覆盖掉之前所做的手动修改。因此,建议的做法是仅在测试中使用自动生成的服务器实现。实际的产品代码应该手动创建和维护。
在开发中的一个常见需求是发送 HTTP 请求来测试 REST API ,如果使用 Postman 或其他工具,可以直接导入 OpenAPI 文档来生成 HTTP 请求的模板,如下图所示,Postman 自动生成了 POST 请求的内容。
总结
数据库驱动的微服务代表了一大类的微服务。通过 Spring Boot 和 Spring 框架,我们可以很容易的创建出暴露 REST API 的数据库驱动的微服务。本课时对数据库驱动的微服务中的重点内容进行了说明,可以帮助掌握重要的知识点。
第12讲:如何基于 JUnit 5 的服务实现单元测试
本课时将介绍“如何使用 JUnit 5 实现服务的单元测试”。
第 11 课时对“数据库驱动的微服务实现”做了简要的介绍,本课时将介绍如何使用 JUnit 5 进行单元测试。你可能会好奇,实现相关的内容比较多却用一个课时来讲解,而内容相对较少的单元测试部分也同样用一个课时?
这是因为市面上与实现相关的参考资料已经非常多了,而单元测试的介绍则相对较少,甚至被忽略了。单元测试的重要性怎么强调都不过分。没有覆盖率足够高的自动化单元测试,就无法安全的更新代码和进行重构。单元测试是开发人员所依赖的安全网。基于这些原因,本课时将对单元测试进行具体的介绍。
JUnit 5 介绍
JUnit 是 Java 单元测试领域中的事实标准,最新版本是 JUnit 5,该版本由 JUnit Platform、JUnit Jupiter 和 JUnit Vintage 组成,这 3 个组件的说明如下表所示:
JUnit Jupiter 的编程模型相比于 JUnit 4 有了很大的改进,推荐在新的项目中使用。下面是一些重要的注解:
下面代码中的 JUnit5Sample 类展示了 @ParameterizedTest、@RepeatedTest 和 @TestFactory 的用法。stringLength方法用来验证字符串的长度,@ValueSource 注解用来提供参数化的测试方法的实际参数。repeatedTest 方法会被重复执行 3 次。dynamicTests 方法返回了一个 DynamicTest 数组作为动态创建的测试。
@DisplayName("JUnit 5 sample")
public class JUnit5Sample {
(strings = {“hello”, “world”})
(“String length”)
void stringLength(String value) {
assertThat(value).hasSize(5);
}
3)
void repeatedTest() {
assertThat(true).isTrue();
}
DynamicTest[] dynamicTests() {
return new DynamicTest[]{
dynamicTest(“Dynamic test 1”, () ->
assertThat(10).isGreaterThan(5)),
dynamicTest(“Dynamic test 2”, () ->
assertThat(“hello”).hasSize(5))
};
}
}
下面给出了 JUnit5Sample 测试的运行结果。
Spring Boot 已经提供了对 JUnit 5 的集成,在项目中可以直接使用。关于 JUnit 5 的更多内容,请参考其他相关资料。
领域对象测试
领域对象类中包含了相关的业务逻辑,我们需要添加相应的单元测试,由于领域对象类通常没有其他依赖,这使得测试起来很方便。
下面代码中的 PassengerTest 是领域对象类 Passenger 的单元测试用例。PassengerTest 中的测试方法都很直接,只需要创建 Passenger 对象,调用其中的方法,再进行验证即可。
@DisplayName("Passenger test")
public class PassengerTest {
private static final Faker faker = new Faker(Locale.CHINA);
(“Add user address”)
public void testAddUserAddress() {
Passenger passenger = createPassenger(1);
passenger.addUserAddress(createUserAddress());
assertThat(passenger.getUserAddresses()).hasSize(2);
}
(“Remove user address”)
public void testRemoveUserAddress() {
Passenger passenger = createPassenger(3);
String addressId = passenger.getUserAddresses().get(0).getId();
passenger.removeUserAddress(addressId);
assertThat(passenger.getUserAddresses()).hasSize(2);
}
(“Get user address”)
public void testGetUserAddress() {
Passenger passenger = createPassenger(2);
String addressId = passenger.getUserAddresses().get(0).getId();
assertThat(passenger.getUserAddress(addressId)).isPresent();
assertThat(passenger.getUserAddress(“invalid”)).isEmpty();
}
private Passenger createPassenger(int numberOfAddresses) {
Passenger passenger = new Passenger();
passenger.generateId();
passenger.setName(faker.name().fullName());
passenger.setEmail(faker.internet().emailAddress());
passenger.setMobilePhoneNumber(faker.phoneNumber().phoneNumber());
int count = Math.max(0, numberOfAddresses);
List<UserAddress> addresses = new ArrayList<>(count);
for (int i = 0; i < count; i++) {
addresses.add(createUserAddress());
}
passenger.setUserAddresses(addresses);
return passenger;
}
private UserAddress createUserAddress() {
UserAddress userAddress = new UserAddress();
userAddress.generateId();
userAddress.setName(faker.pokemon().name());
userAddress.setAddressId(UUID.randomUUID().toString());
userAddress.setAddressName(faker.address().fullAddress());
return userAddress;
}
}
数据库测试
对于数据库驱动的微服务来说,数据库相关的测试是重点,一种常见的做法是在测试时使用专门的数据库来实现,比如 H2 和 HSQLDB,这两个数据库都是纯 Java 语言实现的,支持内存数据库或使用文件存储。从测试的角度来说,这两个数据库可以通过嵌入式的方式在当前 Java 进程中启动,这就降低了测试数据库服务器的管理复杂度。测试中使用的数据本来就是临时性的,只是在测试运行时有用,内存数据库的使用,则进一步省去了管理测试数据库的工作。
Spring Boot 提供了对嵌入式数据库的支持,我们只需要添加 H2 或 HSQLDB 的运行时依赖,Spring Boot 则会在测试时自动配置相应的数据源。
这种做法有一个很大的问题,那就是在运行单元测试时使用的数据库和生产环境的数据库是不一致的。H2 和 HSQLDB 这样的数据库并不适用于生产环境,生产环境中需要使用 PostgreSQL、MySQL 和 SQL Server 这样的数据库。虽然都是使用 JDBC 来访问数据库,我们并不能因此就忽略这些数据库实现之间的区别。这就意味着,通过单元测试的代码,在运行时有可能会由于数据库实现的差异而产生问题。如果发生了这样的情况,则会降低开发人员对单元测试的信任度。
在第 11 课时,我提到了推荐使用数据库迁移工具和手动管理数据库的表模式。如果在单元测试和生产环境中使用不同的数据库实现,则需要维护两套不同的 SQL 脚本,这无疑增加了维护成本。更好的做法是在运行单元测试时,使用与生产环境一样的数据库实现,这看起来很复杂,所幸的是,Docker 可以帮助我们简化很多工作。另外一个附加的好处是,单元测试时 Docker 的使用也与生产环境中的 Kubernetes 中 Docker 的使用保持一致。
在单元测试中使用 Docker 时,我们需要用到 Testcontainers 这个第三方库,以及它提供的 Spring Boot 集成。在进行单元测试时,会启动一个 PostgreSQL 的 Docker 容器,并创建一个数据源来指向这个容器中的 PostgreSQL 服务器,其中的具体工作由 Testcontainers 来完成,我们只需要进行配置即可。
下面的代码是乘客管理服务中 PassengerService 的单元测试用例,其中比较重要的是几个注解的使用。
@DataJpaTest 注解由 Spring Boot 提供,其作用是限制 Spring 在扫描 bean 时的范围,只会选择与 Spring Data JPA 相关的 bean。
@AutoConfigureTestDatabase(replace = Replace.NONE) 的作用是禁止 Spring Boot 用嵌入式数据库替换掉当前的数据源。默认情况下,在运行单元测试时,Spring Boot 会配置一个使用嵌入式数据库的数据源,并替换掉应用中声明的数据源。由于我们使用的是 Docker 容器中的数据库,那么需要禁用这个默认行为。Replace.NONE 的作用是要求 Spring Boot 不进行替换,而是继续使用代码中声明的数据源。
@ContextConfiguration 注解声明了使用的配置类 EmbeddedPostgresConfiguration。
@ImportAutoConfiguration 注解导入了由 Testcontainers 提供的 Spring Boot 自动配置类,用来启动 Docker 容器并提供与数据库连接相关的配置属性。
@TestPropertySource 注解添加了额外的配置属性 embedded.postgresql.docker-image 来设置使用的 PostgreSQL 镜像。
在 PassengerServiceTest 类中,通过 @Autowired 注解注入了 PassengerService 类的实例。PassengerServiceTest 类中的测试用例,可使用 PassengerService 类的方法来完成不同的操作,并验证结果。
@DataJpaTest
@EnableAutoConfiguration
@AutoConfigureTestDatabase(replace = Replace.NONE)
@ComponentScan
@ContextConfiguration(classes = {
EmbeddedPostgresConfiguration.class
})
@ImportAutoConfiguration(classes = {
EmbeddedPostgreSQLDependenciesAutoConfiguration.class,
EmbeddedPostgreSQLBootstrapConfiguration.class
})
@TestPropertySource(properties = {
"embedded.postgresql.docker-image=postgres:12-alpine"
})
@DisplayName("Passenger service test")
public class PassengerServiceTest {
PassengerService passengerService;
(“Create a new passenger”)
public void testCreatePassenger() {
CreatePassengerRequest request = PassengerUtils.buildCreatePassengerRequest(1);
PassengerVO passenger = passengerService.createPassenger(request);
assertThat(passenger.getId()).isNotNull();
assertThat(passenger.getUserAddresses()).hasSize(1);
}
(“Add a user address”)
public void testAddAddress() {
CreatePassengerRequest request = PassengerUtils.buildCreatePassengerRequest(1);
PassengerVO passenger = passengerService.createPassenger(request);
passenger = passengerService
.addAddress(passenger.getId(), PassengerUtils.buildCreateUserAddressRequest());
assertThat(passenger.getUserAddresses()).hasSize(2);
}
(“Delete a user address”)
public void testDeleteAddress() {
CreatePassengerRequest request = PassengerUtils.buildCreatePassengerRequest(3);
PassengerVO passenger = passengerService.createPassenger(request);
String addressId = passenger.getUserAddresses().get(1).getId();
passenger = passengerService.deleteAddress(passenger.getId(), addressId);
assertThat(passenger.getUserAddresses()).hasSize(2);
}
}
下面代码中的 EmbeddedPostgresConfiguration 类用来配置运行单元测试时的数据源。以 embedded.postgresql 开头的属性值由 Testcontainers 生成,包含运行的 PostgreSQL 容器的连接信息。通过这些属性,我创建了一个 HikariDataSource 数据源,在运行单元测试时使用。
@Configuration
public class EmbeddedPostgresConfiguration {
ConfigurableEnvironment environment;
“close”)
public DataSource testDataSource() {
String jdbcUrl = “jdbc:postgresql://
e
m
b
e
d
d
e
d
.
p
o
s
t
g
r
e
s
q
l
.
h
o
s
t
:
{embedded.postgresql.host}:
embedded.postgresql.host:{embedded.postgresql.port}/${embedded.postgresql.schema}”;
HikariConfig hikariConfig = new HikariConfig();
hikariConfig.setDriverClassName(“org.postgresql.Driver”);
hikariConfig.setJdbcUrl(environment.resolvePlaceholders(jdbcUrl));
hikariConfig.setUsername(environment.getProperty(“embedded.postgresql.user”));
hikariConfig.setPassword(environment.getProperty(“embedded.postgresql.password”));
return new HikariDataSource(hikariConfig);
}
}
使用 mock 对象
一个对象通常有很多依赖的对象,这些被依赖的对象又有各自的依赖对象。在对当前对象进行单元测试时,我们希望仅测试当前对象的行为,比如,对象 A 依赖对象 B、C,而对象 B、C 则分别依赖对象 D、E,如下图所示。在编写对象 A 的单元测试用例时,我们希望可以模拟对象 B、C 的行为,从而测试对象 A 在不同情况下的行为,这就需要用到 mock 对象。
mock 对象可以模拟一个对象的行为。举例来说,对象 A 的方法 methodA 调用了对象 B 中的方法 methodB,并根据 methodB 的返回值进行不同的操作。在编写对象 A 的 methodA 方法的测试用例时,则需要覆盖不同的代码路径。对象 B 的 mock 可以很好的解决这个问题。在创建了对象 B 的 mock 之后,就可以直接指定 methodB 的返回值了,从而验证 methodA 在不同情况下的行为。
行程派发服务的 TripServiceTest 类用到了 mock 对象。不过 TripServiceTest 类的逻辑比较复杂,因此我选择另外一个更简单的例子来说明 mock 对象的用法,你也可以直接参考示例应用中的 TripServiceTest 类。
下面代码中的 ActionService 类依赖 ValueUpdater 和 EventPublisher 两个对象,其中 ValueUpdater 的 updateValue 方法用来更新值。如果 updateValue 方法的返回值是 true,则 EventPublisher 的 publishEvent 方法会被调用来发布一个 ValueUpdatedEvent 事件。
Service
public class ActionService {
ValueUpdater valueUpdater;
EventPublisher eventPublisher;
public int performAction(Integer value) {
Integer oldValue = valueUpdater.getValue();
if (valueUpdater.updateValue(value)) {
ValueUpdatedEvent event = new ValueUpdatedEvent(oldValue, value);
eventPublisher.publishEvent(event);
return value * 10;
}
return 0;
}
}
在对 ActionService 类进行单元测试时,我们需要创建 ValueUpdater 和 EventPublisher 的 mock 对象。在下面的代码中,我使用 @MockBean 注解把 ValueUpdater 和 EventPublisher 都声明为 mock 对象。使用 @Captor 注解声明的 eventCaptor 对象用来捕获 EventPublisher 的 publishEvent 方法被调用时的实际参数值。
在 testValueUpdated 方法中,given(valueUpdater.updateValue(value)).willReturn(true) 的作用是指定 ValueUpdater 的 mock 对象的 updateValue 方法在参数为 value 时,其返回值是 true;接着验证 EventPublisher 的 publishEvent 方法被调用一次,并捕获实际的参数值;最后验证 eventCaptor 中捕获的 ValueUpdatedEvent 参数值的内容。
在 testValueNotUpdated 方法中,ValueUpdater 的 mock 对象的 updateValue 方法其返回值被指定为 false,然后验证 EventPublisher 的 publishEvent 方法没有被调用。
@SpringBootTest
@ContextConfiguration(classes = TestConfiguration.class)
@DisplayName("Action service test")
public class ActionServiceTest {
@Autowired
ActionService actionService;
@MockBean
ValueUpdater valueUpdater;
@MockBean
EventPublisher eventPublisher;
@Captor
ArgumentCaptor<ValueUpdatedEvent> eventCaptor;
@Test
@DisplayName(“Value updated”)
public void testValueUpdated() {
int value = 10;
given(valueUpdater.updateValue(value)).willReturn(true);
assertThat(actionService.performAction(value)).isEqualTo(100);
verify(eventPublisher, times(1)).publishEvent(eventCaptor.capture());
assertThat(eventCaptor.getValue()).extracting(ValueUpdatedEvent::getCurrentValue)
.isEqualTo(value);
}
@Test
@DisplayName(“Value not updated”)
public void testValueNotUpdated() {
int value = 10;
given(valueUpdater.updateValue(value)).willReturn(false);
assertThat(actionService.performAction(value)).isEqualTo(0);
verify(eventPublisher, never()).publishEvent(eventCaptor.capture());
}
}
总结
单元测试在微服务开发中起着重要的作用。本课时对单元测试进行了详细的介绍,包括 JUnit 5 介绍,如何测试领域对象,如何使用 Docker 来使用与生产环境相同的数据库进行测试,以及如何在单元测试中使用 mock 对象。
第13讲:如何基于 REST 服务实现集成测试
本课时将介绍“如何实现 REST 服务的集成测试”。
在第 12 课时中,介绍了微服务实现中的单元测试。单元测试只对单个对象进行测试,被测试对象所依赖的其他对象,一般使用 mock 对象来代替,单元测试可以确保每个对象的行为满足对它的期望。不过,这些对象集成在一起之后的行为是否正确,则需要由另外的测试来验证。这就是集成测试要解决的问题,与单元测试不同的是,集成测试一般由测试人员编写。本课时将介绍如何进行服务的集成测试。
集成测试的目标是微服务本身,也就是说,把整个微服务看成是一个黑盒子,只通过微服务暴露的接口来进行测试,这个暴露的接口就是微服务的 API。对微服务的集成测试,实际上是对其 API 的测试。由于本专栏的微服务使用的是 REST API,所以着重介绍 REST API 的集成测试。
REST API 的测试本身并不复杂,只需要发送特定的 HTTP 请求到 REST API 服务器,再验证 HTTP 响应的内容即可。在 Java 中,已经有 Apache HttpClient 和 OkHttp 这样的 HTTP 客户端,可以帮助在 Java 代码中发送 HTTP 请求。在测试中,推荐使用 REST-assured 这样的工具来验证 REST API。
我们可以利用 Spring Boot 提供的集成测试支持来测试微服务的 REST API。在进行测试时,Spring Boot 可以在一个随机端口启动 REST API 服务器来作为测试的目标。本课时选择的测试目标是乘客管理服务的 REST API。
下面介绍 3 种不同的测试 REST API 的方式。
手动发送 HTTP 请求
我们可以用 Spring Test 提供了的 WebTestClient 来编写 REST API 的验证代码。下面代码中的 PassengerControllerTest 类是乘客管理服务 REST API 的测试用例。
PassengerControllerTest 类上的注解与第 12 课时中出现的 PassengerServiceTest 类上的注解存在很多相似性,一个显著的区别是 @SpringBootTest 注解中 webEnvironment 属性的值 WebEnvironment.RANDOM_PORT。这使得 Spring Boot 可以在一个随机的端口上启动 REST API 服务。PassengerControllerTest 类通过依赖注入的方式声明了 WebTestClient 类和 TestRestTemplate 类对象。
WebTestClient 类提供了一个简单的 API 来发送 HTTP 请求并验证响应。在 testCreatePassenger 方法中,使用了 WebTestClient 类的 post 方法发送一个 POST 请求来创建乘客,并验证 HTTP 响应的状态码和 HTTP 头 Location。
TestRestTemplate 类则用来获取 HTTP 响应的内容。在 testRemoveAddress 方法中,TestRestTemplate 类的 postForLocation 方法用来发送 POST 请求,并获取到 HTTP 头 Location 的内容,该内容是访问新创建的乘客对象的 URL。TestRestTemplate 类的 getForObject 方法访问这个 URL 并获取到表示乘客的 PassengerVO 对象。从 PassengerVO 对象中找到乘客的第一个地址的 ID,再构建出访问该地址的 URL。WebTestClient 类的 delete 方法用来发送 DELETE 请求到该地址对应的 URL,接着使用 WebTestClient 类的 get 方法获取乘客信息并验证地址数量。
@SpringBootTest(webEnvironment = WebEnvironment.RANDOM_PORT)
@EnableAutoConfiguration
@ComponentScan
@Import(EmbeddedPostgresConfiguration.class)
@ImportAutoConfiguration(classes = {
EmbeddedPostgreSQLDependenciesAutoConfiguration.class,
EmbeddedPostgreSQLBootstrapConfiguration.class
})
@TestPropertySource(properties = {
"embedded.postgresql.docker-image=postgres:12-alpine"
})
@DisplayName("Passenger controller test")
public class PassengerControllerTest {
private final String baseUri = “/api/v1”;
@Autowired
WebTestClient webClient;
@Autowired
TestRestTemplate restTemplate;
@Test
@DisplayName(“Create a new passenger”)
public void testCreatePassenger() {
webClient.post()
.uri(baseUri)
.bodyValue(PassengerUtils.buildCreatePassengerRequest(1))
.accept(MediaType.APPLICATION_JSON)
.exchange()
.expectStatus().isCreated()
.expectHeader().exists(HttpHeaders.LOCATION);
}
@Test
@DisplayName(“Add a new address”)
public void testAddUserAddress() {
URI passengerUri = restTemplate
.postForLocation(baseUri,
PassengerUtils.buildCreatePassengerRequest(1));
URI addressesUri = ServletUriComponentsBuilder.fromUri(passengerUri)
.path(“/addresses”).build().toUri();
webClient.post().uri(addressesUri)
.bodyValue(PassengerUtils.buildCreateUserAddressRequest())
.exchange()
.expectStatus().isOk()
.expectBody(PassengerVO.class)
.value(hasProperty(“userAddresses”, hasSize(2)));
}
@Test
@DisplayName(“Remove an address”)
public void testRemoveAddress() {
URI passengerUri = restTemplate
.postForLocation(baseUri,
PassengerUtils.buildCreatePassengerRequest(3));
PassengerVO passenger = restTemplate
.getForObject(passengerUri, PassengerVO.class);
String addressId = passenger.getUserAddresses().get(0).getId();
URI addressUri = ServletUriComponentsBuilder.fromUri(passengerUri)
.path(“/addresses/” + addressId).build().toUri();
webClient.delete().uri(addressUri)
.exchange()
.expectStatus().isNoContent();
webClient.get().uri(passengerUri)
.exchange()
.expectStatus().isOk()
.expectBody(PassengerVO.class)
.value(hasProperty(“userAddresses”, hasSize(2)));
}
}
使用 Swagger 客户端
因为微服务实现采用 API 优先的策略,在有 OpenAPI 文档的前提下,我们可以使用 Swagger 代码生成工具来产生客户端代码,并在测试中使用。这种方式的好处是客户端代码屏蔽了 API 的一些细节,比如 API 的访问路径。如果 API 的访问路径发生了变化,那么测试代码并不需要修改,只需要使用新版本的客户端即可。在上一节的代码中,我们需要手动构建不同操作对应的 API 路径,这就产生了不必要的耦合。
使用客户端的另外一个好处是,如果 OpenAPI 文档发生变化,则会造成客户端的接口变化。重新生成新的客户端代码之后,已有测试代码会无法通过编译,开发人员可以在第一时间发现问题并更新测试代码。而使用 HTTP 请求的测试代码,则需要在运行测试时才能发现问题。
乘客管理服务 API 的客户端是示例应用的一个模块,只不过代码都是自动生成的。这里我们需要用到 Swagger 代码生成工具的 Maven 插件,下面的代码给出了该插件的使用方式:
<plugin>
<groupId>io.swagger.codegen.v3</groupId>
<artifactId>swagger-codegen-maven-plugin</artifactId>
<executions>
<execution>
<goals>
<goal>generate</goal>
</goals>
<configuration>
<inputSpec>${project.basedir}/src/main/resources/openapi.yml</inputSpec>
<language>java</language>
<apiPackage>io.vividcode.happyride.passengerservice.client.api</apiPackage>
<generateModels>false</generateModels>
<generateApiTests>false</generateApiTests>
<importMappings>
<importMapping>CreatePassengerRequest=io.vividcode.happyride.passengerservice.api.web.CreatePassengerRequest</importMapping>
<importMapping>CreateUserAddressRequest=io.vividcode.happyride.passengerservice.api.web.CreateUserAddressRequest</importMapping>
<importMapping>PassengerVO=io.vividcode.happyride.passengerservice.api.web.PassengerVO</importMapping>
<importMapping>UserAddressVO=io.vividcode.happyride.passengerservice.api.web.UserAddressVO</importMapping>
</importMappings>
<configOptions>
</configOptions>
</configuration>
</execution>
</executions>
</plugin>
下表给出了该插件的配置项说明。
Swagger 的代码生成工具可以从 OpenAPI 文档的模式声明中产生对应的 Java 模型类。示例应用已经定义了相关的模型类,因此把配置项 generateModels 的值设置为 false 来禁用模型类的生成,同时使用 importMappings 来声明映射关系。
当需要编写 REST API 测试时,我们只需要依赖这个 API 客户端模块即可。下面代码中的 PassengerControllerClientTest 类是使用 API 客户端的测试类。由于 API 服务器的端口是随机的,构造器中的 @LocalServerPort 注解的作用是获取到实际运行时的端口,该端口用来构建 API 客户端访问的服务器地址。
PassengerApi 类是 API 客户端中自动生成的访问 API 的类。在测试方法中,我使用 PassengerApi 类的方法来执行不同的操作,并验证结果。相对于上一节中 WebTestClient 类的用法,使用 PassengerApi 类的代码更加直观易懂。
@SpringBootTest(webEnvironment = WebEnvironment.RANDOM_PORT)
@EnableAutoConfiguration
@ComponentScan
@Import(EmbeddedPostgresConfiguration.class)
@ImportAutoConfiguration(classes = {
EmbeddedPostgreSQLDependenciesAutoConfiguration.class,
EmbeddedPostgreSQLBootstrapConfiguration.class
})
@TestPropertySource(properties = {
"embedded.postgresql.docker-image=postgres:12-alpine"
})
@DisplayName("Passenger controller test")
public class PassengerControllerClientTest {
private final PassengerApi passengerApi;
public PassengerControllerClientTest(@LocalServerPort int serverPort) {
ApiClient apiClient = Configuration.getDefaultApiClient();
apiClient.setBasePath(“http://localhost:” + serverPort + “/api/v1”);
passengerApi = new PassengerApi(apiClient);
}
(“Create a new passenger”)
void testCreatePassenger() {
try {
ApiResponse<Void> response = passengerApi
.createPassengerWithHttpInfo(
PassengerUtils.buildCreatePassengerRequest(1));
assertThat(response.getStatusCode()).isEqualTo(201);
assertThat(response.getHeaders()).containsKey(“Location”);
} catch (ApiException e) {
fail(e);
}
}
(“Add a new address”)
public void testAddUserAddress() {
try {
String passengerId = createPassenger(1);
PassengerVO passenger = passengerApi
.createAddress(PassengerUtils.buildCreateUserAddressRequest(),
passengerId);
assertThat(passenger.getUserAddresses()).hasSize(2);
} catch (ApiException e) {
fail(e);
}
}
(“Remove an address”)
public void testRemoveAddress() {
try {
String passengerId = createPassenger(3);
PassengerVO passenger = passengerApi.getPassenger(passengerId);
String addressId = passenger.getUserAddresses().get(0).getId();
passengerApi.deleteAddress(passengerId, addressId);
passenger = passengerApi.getPassenger(passengerId);
assertThat(passenger.getUserAddresses()).hasSize(2);
} catch (ApiException e) {
fail(e);
}
}
private String createPassenger(int numberOfAddresses) throws ApiException {
ApiResponse<Void> response = passengerApi
.createPassengerWithHttpInfo(
PassengerUtils.buildCreatePassengerRequest(numberOfAddresses));
assertThat(response.getHeaders()).containsKey(“Location”);
String location = response.getHeaders().get(“Location”).get(0);
return StringUtils.substringAfterLast(location, “/”);
}
}
使用 BDD
不管是手动发送 HTTP 请求或是使用 Swagger 客户端,相关的测试用例都需要由测试人员来编写。当应用的业务逻辑比较复杂时,测试人员可能需要了解很多的业务知识,才能编写出正确的测试用例。以保险业务为例,一个理赔申请能否被批准,背后有复杂的业务逻辑来确定。这样的测试用例,如果由测试人员来编写,则可能的结果是测试用例所验证的情况,从业务逻辑上来说是错误的,起不到测试的效果。
更好的做法是由业务人员来编写测试用例,这样可以保证应用的实际行为,满足真实业务的期望。但是业务人员并不懂得编写代码。为了解决这个问题, 我们需要让业务人员以他们所能理解的方式来描述对不同行为的期望,这就是行为驱动开发(Behaviour Driven Development,BDD)的思想。
BDD 的出发点是提供了一种自然语言的方式来描述应用的行为,对行为的描述由 3 个部分组成,分别是前置条件、动作和期望结果,也就是 Given-When-Then 结构,该结构表达的是当对象处于某个状态中时,如果执行该对象的某个动作,所应该产生的结果是什么。比如,对于一个数据结构中常用的栈对象来说,在栈为空的前提下,如果执行栈的弹出动作,那么应该抛出异常;在栈不为空的前提下,如果执行栈的弹出动作,那么返回值应该是栈顶的元素。这样的行为描述,可以很容易转换成测试用例,来验证对象的实际行为。
BDD 一般使用自然语言来描述行为,业务人员使用自然语言来描述行为,形成 BDD 文档,这个文档是业务知识的具体化。我们只需要把这个文档转换成可执行的测试用例,就可以验证代码实现是否满足业务的需求。这个转换的过程需要工具的支持,本课时介绍的工具是 Cucumber。
Cucumber 使用名为 Gherkin 的语言来描述行为,Gherkin 语言使用半结构化的形式。下表给出了 Gherkin 语言中的常用结构。
下面的代码给出了乘客管理服务的 BDD 文档示例,其中的第一个场景描述的是添加地址的行为。前置条件是乘客有 1 个地址,动作是添加一个新的地址,期望的结果是乘客有 2 个地址。第二个场景描述的是删除地址的行为,与第一个场景有类似的描述。
Feature: Address management
Manage a passenger's addresses
Scenario: Add a new address
Given a passenger with 1 addresses
When the passenger adds a new address
Then the passenger has 2 addresses
Scenario: Delete an address
Given a passenger with 3 addresses
When the passenger deletes an address
Then the passenger has 2 addresses
在有了 BDD 文档之后,下一步是如何把文档变成可执行的测试用例,这就需要用到 Cucumber 了。Cucumber 使用步骤定义把 Gherkin 语言中的结构与实际的代码关联起来。Gherkin 语言中的 Given、When 和 Then 等语句,都有与之对应的步骤定义代码。Cucumber 在运行 BDD 文档的场景时,会找到每个语句对应的步骤定义并执行,步骤定义中包含了进行验证的代码。
下面代码中的 AddressStepdefs 类是上面 BDD 场景的步骤定义。AddressStepdefs 类上的注解与上面 PassengerControllerTest 类的注解是相似的,PassengerClient 类是 Swagger 客户端的一个封装,用来执行不同的操作。
在步骤定义中,passengerWithAddresses 方法对应的是 Given 语句“a passenger with {int} addresses”。语句中的 {int} 用来提取语句中 int 类型的变量,作为参数 numberOfAddresses 的值。在对应的步骤中,调用 PassengerClient 类的 createPassenger 方法来创建一个包含指定数量地址的乘客对象,并把乘客 ID 保存在 passengerId 中。passengerAddsAddress 和 passengerDeletesAddress 方法分别对应两个 When 语句,分别执行添加和删除地址的动作;passengerHasAddresses 方法则与 Then 语句相对应,验证乘客的地址数量。
@SpringBootTest(webEnvironment = WebEnvironment.RANDOM_PORT)
@EnableAutoConfiguration
@ContextConfiguration(classes = {
BddTestApplication.class,
PassengerServiceApplication.class
})
@ComponentScan
@Import(EmbeddedPostgresConfiguration.class)
@ImportAutoConfiguration(classes = {
EmbeddedPostgreSQLDependenciesAutoConfiguration.class,
EmbeddedPostgreSQLBootstrapConfiguration.class
})
@TestPropertySource(properties = {
"embedded.postgresql.docker-image=postgres:12-alpine"
})
public class AddressStepdefs {
PassengerClient passengerClient;
private String passengerId;
“a passenger with {int} addresses”)
public void passengerWithAddresses(int numberOfAddresses) {
try {
passengerId = passengerClient.createPassenger(numberOfAddresses);
} catch (ApiException e) {
fail(e);
}
}
“the passenger adds a new address”)
public void passengerAddsAddress() {
try {
passengerClient.addAddress(passengerId);
} catch (ApiException e) {
fail(e);
}
}
“the passenger deletes an address”)
public void passengerDeletesAddress() {
try {
passengerClient.removeAddress(passengerId);
} catch (ApiException e) {
fail(e);
}
}
“the passenger has {int} addresses”)
public void passengerHasAddresses(int numberOfAddresses) {
try {
PassengerVO passenger = passengerClient.getPassenger(passengerId);
assertThat(passenger.getUserAddresses()).hasSize(numberOfAddresses);
} catch (ApiException e) {
fail(e);
}
}
}
实际的测试由 Cucumber 来运行,下面代码中的 PassengerIntegrationTest 类是由 Cucumber 运行的测试类。需要注意的是,Cucumber 只支持 JUnit 4,因此在 Spring Boot 应用中需要添加对 junit-vintage-engine 模块的依赖。
@RunWith(Cucumber.class)
@CucumberOptions(strict = true,
features = "src/test/resources/features",
plugin = {"pretty", "html:target/cucumber"})
public class PassengerIntegrationTest {
}
数据准备
集成测试的一个特点是在运行测试之前,对应用的当前状态有一定的要求,而不是从零开始。比如,如果乘客管理服务的 API 支持对地址的分页、查询和过滤,那么在测试这些功能时,则需要被测试的乘客有足够数量的地址来测试不同的情况。这就要求进行一些数据准备工作。下面是 3 种不同的准备数据的做法。
第一种做法是在测试中通过 JUnit 5 的 @BeforeEach 和 @BeforeAll 注解来标注进行数据准备的方法,数据准备通过测试代码中的 API 调用来完成。比如, 可以在每个测试方法执行之前创建一个包含 100 个地址的乘客对象。
第二种做法是通过 SQL 脚本来初始化测试数据库,一个测试用例可以有与之相对应的 SQL 初始化脚本。在运行测试时,SQL 脚本会被执行来添加测试数据。如果测试人员更习惯编写 SQL 脚本,这种方式比第一种更好。Spring Boot 提供了初始化数据库的支持,会自动查找并执行 CLASSPATH 中的 schema.sql 和 data.sql 文件。
第三种做法是使用专门的数据库 Docker 镜像。在之前的测试中,我们使用的是标准的 PostgreSQL 镜像,其中不包含任何数据,不过可以创建一个包含了完整数据的自定义 PostgreSQL 镜像,这样可以测试应用在更新时的行为。比如,新版本的代码中对数据库的模式进行了修改,一个很重要的测试用例就是已有数据的升级。通过使用包含当前版本数据的 PostgreSQL 镜像,可以很容易的测试数据库的升级。Docker 镜像的标签标识了每个镜像包含的数据集的含义,这些都是本地开发和编写测试用例时可以使用的资产。很多数据库的 Docker 镜像都提供了运行初始化脚本的能力。以 PostgreSQL 的 Docker 镜像为例,只需要把 *.sh 或 *.sql 文件添加到 /docker-entrypoint-initdb.d 目录,就可以初始化数据库。
总结
微服务的集成测试把微服务当成一个黑盒子,并使用其 REST API 来进行测试。本课时介绍了 3 种不同的 REST API 集成测试方式,包括手动发送 HTTP 请求并验证,使用 Swagger 客户端来发送请求,以及使用 BDD 来编写测试用例。
第14讲:事件如何驱动微服务设计与异步消息传递
从本课时开始,我将介绍另外一种类型的微服务,也就是事件驱动的微服务。相对于数据库驱动的微服务,事件驱动的微服务介绍比较少。当然,这并不是因为事件驱动的微服务自身有什么问题,而仅仅是架构设计上不同风格的选择。以编程语言的范式来进行类比,面向对象编程虽然是目前的主流,但是函数式编程仍然有其用武之地。从纯技术的角度来说,事件驱动的架构更适合于微服务架构的应用。不过,在实际开发中的技术选型需要综合多种因素来考虑,尤其是开发团队对技术的熟练程度。了解事件驱动设计的开发人员相对较少。本课时将对事件驱动的基本概念进行介绍。
事件
事件驱动设计中的核心概念是事件(Event),事件是现实世界中的概念,表示已经发生的状况。事件驱动指的是以事件的发布和处理来驱动应用的运行,它的方式符合我们在现实世界中的工作模式,通常都是在事件发生之后,再进行处理。现实世界中的事件多种多样,我们依靠大脑进行创造性的处理。
事件在软件系统的应用也由来已久,最典型的应用是在用户界面中。用户界面的实现通常会维护一个事件循环(Event Loop),当用户界面中的事件产生时,比如按钮点击和鼠标移动,事件的处理器会被调用。对事件的处理都在事件循环中完成,应用开发者只需要为感兴趣的事件添加处理器即可。在事件处理器中,除了正常的处理逻辑之外,还可以发布新的事件,从而触发对应的处理逻辑,产生级联的效果。
软件系统中的事件类型是有限的。每个事件都有一个类型和可选的载荷(Payload)对象。类型用来区分不同的事件,推荐以反转域名来作为事件类型的前缀,类似于 Java 中类的命名。事件类型的简单名称,一般使用名词加上动词被动语态的命名规则,名词是事件的目标对象类型,而动词则是事件所表示的动作。比如,表示乘客创建成功的事件类型是 PassengerCreatedEvent,Passenger 表明事件的目标对象是乘客,Created 则表明事件对应的动作是创建,完整的事件类型是 io.vividcode.happyride.passengerservice.PassengerCreatedEvent。
事件的载荷对象取决于事件的发布者,作为所发布事件的一部分,载荷对象的内容也需要考虑到事件处理器的需求。因为载荷对象中包含了处理事件所需的数据,载荷对象的最终格式是综合多方面考量的结果,载荷对象只需要包含足够多的信息即可。PassengerCreatedEvent 事件的载荷对象,可以包含乘客的全部信息,也可以仅包含乘客的 ID。
事件驱动设计
事件驱动的设计在单体应用中已经得到了应用。事件驱动的最大特点是把方法的调用、调用的执行和调用结果的获取,这 3 个动作进行了时间上的分离。在常见的方法调用中,方法的调用、调用的执行和调用结果的获取是同步进行的。调用者在发出调用请求之后,会等待方法调用的完成,并使用调用结果进行下一步操作。事件驱动把这 3 个动作从时间上进行了分离,变成了异步的操作。
以新乘客注册的场景为例,当乘客完成注册之后,应用需要执行一些初始化的工作。在乘客注册对应的 API 控制器方法中,在创建乘客对象并保存之后,可以直接调用相应的方法来完成初始化,等初始化完成之后,控制器方法才返回。这是典型的同步调用方式。
如果采用事件驱动的方式,在创建乘客对象并保存之后,可以发布一个 PassengerCreatedEvent 事件,当事件发布之后,控制器方法就可以返回。PassengerCreatedEvent 事件的处理器用来完成初始化工作。在引入了事件之后,乘客初始化的动作被分成了两步或三步:第一步是事件的发布,相当于发出方法调用的请求;第二步是事件的处理,由事件处理器来完成;除此之外,某些情况下还存在第三步,那就是事件处理结果的返回,这一步对应于同步调用的方法有返回值的情况。
事件驱动的另外一个好处是可以实现发布者-消费者(Publisher-Subscriber,PubSub)模式。在同步方法调用中,每一次调用只有一个接收者。在下面代码的 createPassenger 方法中,initPassenger1 和 initPassenger2 是初始化乘客对象的两个不同操作,createPassenger 方法和乘客初始化逻辑有紧密的耦合关系。如果以后要增加新的乘客初始化逻辑 initPassenger3 方法,则需要修改 createPassenger 方法来添加对 initPassenger3 方法的调用。
Passenger createPassenger() {
Passenger passenger = new Passenger();
initPassenger1(passenger);
initPassenger2(passenger);
return passenger;
}
如果采用事件驱动的做法,那么 createPassenger 方法只是发布了一个 PassengerCreatedEvent 事件,而 PassengerCreatedEvent 事件可以有多个处理器。当需要增加新的乘客初始化逻辑时,只需要添加新的处理器即可,createPassenger 方法并不需要进行修改。
使用了事件之后,一个简单的同步方法调用,被切分成 2 或 3 段时间上独立的操作。这无疑增加了实现的复杂度,但是减少了调用者和被调用者之间的耦合度。在大部分时候,同步方法调用就足够了。用户界面适合于事件驱动的方式,是由用户界面的交互模式所决定的。
单体应用中使用事件驱动,大部分时候是为了实现 PubSub 模式,实现该模式需要事件总线(Event Bus)的支持,在 Java 中,我们可以用 Guava 中的 EventBus 实现。Spring 框架也有自己的事件系统。下面代码展示了 Guava 的 EventBus 的用法。ChangedEvent 是事件类型,EventHandler 是事件处理器,其中的 @Subscribe 注解声明了处理事件的方法。EventBus 类的 post 方法用来发布事件。
public class EventBusSample {
public static void main(String[] args) {
EventBus eventBus = new EventBus();
eventBus.register(new EventHandler());
eventBus.post(new ChangedEvent());
}
private static class ChangedEvent {
}
private static class EventHandler {
public void onChanged(ChangedEvent event) {
System.out.println(event);
}
}
}
事件发布和处理可以有不同的策略。一种策略是事件按照发布的顺序被依次处理,在一个事件被处理之前,不会进行下一个事件的处理;另外一种策略是事件的处理是并发进行的,事件的处理顺序与发布顺序不一定相同。可以根据需要来选择适合的策略。
单体应用中的事件发布和处理的实现相对简单,因为发布者和处理器都在同一个 JVM 中,不需要考虑事件在不同 JVM 之间的传递和序列化的问题。分布式系统中的事件发布和处理则是另外一个复杂的问题。
事件驱动的微服务
在单体应用中,事件驱动方式的流行度并不高,这主要是因为直接调用不仅简单易用,性能也更好。在微服务架构的应用中,微服务之间的交互使用的是跨进程 API 调用。同步调用其他微服务的 API 并不是简单的事情,需要考虑到被调用的微服务可能出错的情况。同步的微服务 API 调用,要求被调用者在调用发生时是可用的状态,如果被调用者当前不可用,则需要进行重试或进入到错误处理逻辑;如果调用最终失败,则被调用者并不知道请求的存在。
以上面代码中的 createPassenger 方法为例,initPassenger2 方法的实现是调用另外一个微服务的 API,当 createPassenger 方法被调用时,如果该微服务不可用,则调用会失败;当该微服务变得可用时,并不会知道曾经有尝试进行的调用。
由于同步的微服务 API 调用的性能下降了很多,相对于事件发布来说,性能这个优势已经没有了,事件驱动在其他方面的优势得到了体现。同样是 createPassenger 方法的例子,如果方法的实现是发布 PassengerCreatedEvent 事件,只要事件被成功发布,事件就会被持久化,其他的微服务如果添加了对该事件的处理器,那么即便在事件发布时,该微服务不可用也没有影响。当微服务变得可用时,仍然会处理该事件。当考虑到微服务可能失败的情况,使用事件驱动的设计就成了一个很好的选择。
在微服务架构的应用中,如果使用事件驱动的设计,则需要进行消息传递。
消息传递
在微服务架构的应用中,事件的发布和处理需要消息中间件的支持,典型的产品是消息代理(Message Broker)。消息代理负责消息的验证、转换和路由,它负责协调不同应用之间的消息传递,从而降低了不同应用之间的耦合度。消息代理的功能围绕消息展开,对于每一个接收到的消息,消息代理可以进行不同的处理,包括消息持久化、有保证的消息传递和事务管理等。市面上的消息代理实现非常多,开源实现和商业产品都有,常见的选择有 Apache ActiveMQ、Apache Kafka、RabbitMQ、IBM MQ、Amazon MQ和Microsoft Azure Service Bus 等。示例应用使用的是 Apache Kafka。
Apache Kafka 介绍
Apache Kafka 是一个分布式流平台。Kafka 可以发布和订阅记录流,并提供记录流的持久化存储。
Kafka 把记录流组织成主题(Topic),在发布记录时需要指定主题,每个主题在 Kafka 集群上对应一个分片的日志。每个分片都是包含记录的一个有序的、不可变的序列,新的记录被添加到序列的末尾,分片中的每个记录都有一个递增的序号作为其标识符,该序号称为记录的偏移量(Offset)。Kafka 会存储所有发布的记录,直到记录的保存时间超过设置的数据留存时间。
下图是 Kafka 中主题的示意图,图中的主题一共有 3 个分片,每个分片中的小矩形,都表示一个记录,矩形中的数字是记录的偏移量,分片最右边的虚线边框是当前的写入位置。
Kafka 包含了 5 个核心 API,如下表所示。
生产者负责发布记录到选定的主题上。在发布时,生产者负责为记录选择所在的分片,每个主题都可以有多个消费者,消费者以分组的形式来组织,每个消费者以标签的形式来表明所在的分组。对于每个主题中发布的记录,该记录会被发送到每个订阅了该主题的消费者分组中的其中一个消费者,消费者分组可以实现不同的记录处理场景。如果所有的消费者都属于同一个分组,那么记录会在所有的消费者中以负载均衡的方式处理;如果所有的消费者都属于各自独立的分组,那么记录会被广播到所有的消费者。
除了这两种极端场景外,通常的情况是使用从业务需要上进行区分的少量分组,每个分组中包含一定数量的消费者来保证处理速度和进行故障恢复。每个分组中的消费者数量不能超过分片的数量。
消费者只需要维护在分片中的当前偏移量即可,这个偏移量是当前的读取位置,通常的做法是递增该偏移量来顺序读取记录。在需要的时候,还可以把偏移量设置为之前的值来重新处理一些记录,或是跳过一些记录。
消息传递的保证性
在消息传递时,一个需要考虑的重要问题是消息传递的保证性,也就是生产者发布的消息,是否一定可以被消费者接收到。一共有 3 种不同的保证性,如下表所示。
看到上表中的保证性,第一反应是应该使用有且仅有一次的保证性,因为这符合我们对消息传递的预期。不过在一个分布式系统中提供有且仅有一次的保证性,所带来的代价很高,我们需要的是根据实际的需要选择最合适的保证性。比如,如果生产者发布的消息是收集的性能指标数据,那么至多一次的保证性已经足够。丢失一些性能指标数据并不是什么大问题,性能指标数据产生的速度很快,新的数据会迅速产生并替代旧数据。
Kafka 对于不同的保证性都提供了一定的支持,对于有且仅有一次的保证性,在 Kafka 的主题之间传送和处理消息时,可以使用事务性生产者和消费者。Kafka 的流处理 API 提供的也是有且仅有一次的保证性。
如果生产者在发布记录时出现网络错误,生产者并不能确定记录是否被成功提交。当生产者进行重试时,有可能造成记录的重复;在消费者处理记录时,需要保存当前的读取位置。如果当前消费者出错,新的消费者可以从上次读取的位置开始继续进行处理。有两种不同的处理策略。
第一种策略是消费者首先读取记录,然后处理记录,最后保存读取位置。如果消费者在处理完记录之后,在保存读取位置之前出错,新的消费者还会从上次的旧位置开始读取,会造成记录的重复处理。这种策略对应的是至少一次的保证性。
第二种策略是消费者首先读取记录,然后保存读取位置,最后处理记录。如果消费者在保存读取位置之后,在处理记录之前出错,读取的记录实际上并没有被处理,新的消费者会从新位置开始读取。这种策略对应的是至多一次的保证性。
Kafka 默认的是至少一次的保证性。如果需要实现至多一次的保证性,则需要禁用生产者的重试,同时消费者使用上述第二种策略。
Kafka 负责保证同一个分片中记录的顺序性,也就是说记录会严格按照被发布的顺序来消费,这一点对于事件驱动的微服务来说非常重要。如果一个用户创建了订单,然后取消了该订单,对应的两个事件必须按照同样的顺序来处理。如果订单取消的事件先被处理,那么该事件可能会被忽略,而订单最终完成了创建。这显然是不正确的。
总结
事件驱动是微服务架构应用的另外一种设计风格,把同步的微服务 API 调用转换成异步的事件发布和处理。本课时对事件驱动设计进行了介绍,包括它在微服务架构中的应用。示例应用使用 Apache Kafka 作为消息传递的代理,本课时也介绍了 Kafka 的基本概念。