阅读 3 分钟
回到单体
微服务这几年一直都很火,之前做系统设计的时候也很容易下意识就往微服务上靠。比如一个商城系统,很自然就会想到用户服务、订单服务、库存服务、支付服务、优惠券服务。
拆完之后结构看起来确实很清晰,每个服务只负责自己的事情。但是开发一段时间后就会发现,很多复杂度不是消失了,而是从代码里跑到了服务调用里。
一个本来很简单的下单流程,可能要调用订单、库存、支付、消息好几个服务。中间只要有一个接口超时或者数据状态不对,就要开始查日志、看链路、补偿数据。以前一个事务能解决的问题,现在可能要考虑幂等、重试、消息可靠性这些东西。
所以现在有些项目又开始从微服务回到单体。当然这个单体不是以前那种什么代码都堆在一起的单体,而是更像一种多模块单体。
为什么要回到单体
微服务比较适合团队很多、业务边界稳定、不同模块需要独立发布或者独立扩容的情况。如果项目还没到这个阶段,过早拆成微服务反而会比较痛苦。
比如常见的问题有这些:
- 本地开发要启动很多服务。
- 服务之间联调比较麻烦。
- 简单的方法调用变成了 HTTP 或 RPC 调用。
- 一个功能出问题,要跨多个服务查日志。
- 数据一致性变复杂了。
如果团队规模不大,业务也还在快速变化,那单体反而会简单很多。代码在一个项目里,调试方便,事务也比较直接。真正需要注意的是不要把单体写成一团乱麻。
多模块单体
多模块单体可以理解为:运行时是一个应用,但是代码上分成多个业务模块。
比如还是一个商城系统,可以这样拆:
src/main/java/com/example/shop
├── ShopApplication.java
├── order
│ ├── OrderService.java
│ ├── Order.java
│ └── internal
│ └── OrderRepository.java
├── inventory
│ ├── InventoryService.java
│ └── internal
│ └── InventoryRepository.java
└── payment
├── PaymentService.java
└── internal
└── PaymentClient.java
order、inventory、payment 还是不同的业务模块,只是它们不再是几个单独部署的服务。模块之间可以直接调用,也可以通过事件通信。
这里最重要的是 internal 这个包。简单来说,放在 internal 里面的东西就是模块内部实现,其他模块不应该直接用。比如订单模块可以调用库存模块暴露出来的 InventoryService,但是不应该直接去用 InventoryRepository。
Spring Modulith
Spring Modulith 就是用来做这种模块化单体的。它不是一个新框架,还是基于 Spring Boot,只是在 Spring Boot 项目上加了一些模块识别、模块校验、事件发布、文档生成的能力。
先引入依赖:
<dependency>
<groupId>org.springframework.modulith</groupId>
<artifactId>spring-modulith-starter-core</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.modulith</groupId>
<artifactId>spring-modulith-starter-test</artifactId>
<scope>test</scope>
</dependency>
Spring Modulith 默认会把主应用包下面的一级子包识别成模块,比如:
com.example.shop.order
com.example.shop.inventory
com.example.shop.payment
这样 order、inventory、payment 就是三个模块。
校验模块依赖
Spring Modulith 最实用的地方我觉得是可以校验模块依赖。比如写一个测试:
package com.example.shop;
import org.junit.jupiter.api.Test;
import org.springframework.modulith.core.ApplicationModules;
class ModularityTests {
@Test
void verifiesModularStructure() {
ApplicationModules modules = ApplicationModules.of(ShopApplication.class);
modules.verify();
}
}
这个测试会检查模块之间的依赖关系。如果 order 模块直接引用了 inventory.internal.InventoryRepository,测试就会失败。
这点很有用,因为单体项目最大的问题就是时间长了边界会慢慢消失。刚开始大家都说要按模块来,后面为了赶需求就开始互相引用内部类,最后又变成了传统大单体。
有了这个测试,至少可以在 CI 里挡一下。
模块之间发布事件
有些模块之间不一定要直接调用,比如订单创建之后,库存需要扣减,消息需要通知。这种情况可以使用事件。
先定义一个事件:
package com.example.shop.order;
import java.util.UUID;
public record OrderCreated(UUID orderId) {
}
订单模块发布事件:
package com.example.shop.order;
import org.springframework.context.ApplicationEventPublisher;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service
public class OrderService {
private final ApplicationEventPublisher events;
public OrderService(ApplicationEventPublisher events) {
this.events = events;
}
@Transactional
public void createOrder(CreateOrderCommand command) {
Order order = Order.create(command);
// 保存订单
events.publishEvent(new OrderCreated(order.id()));
}
}
库存模块监听事件:
package com.example.shop.inventory;
import com.example.shop.order.OrderCreated;
import org.springframework.modulith.events.ApplicationModuleListener;
import org.springframework.stereotype.Service;
@Service
public class InventoryEventHandler {
private final InventoryService inventoryService;
public InventoryEventHandler(InventoryService inventoryService) {
this.inventoryService = inventoryService;
}
@ApplicationModuleListener
void on(OrderCreated event) {
inventoryService.reserve(event.orderId());
}
}
这里用的是 @ApplicationModuleListener,它和普通的 @EventListener 类似,但是更适合 Spring Modulith 的模块事件。默认会在事务提交之后再处理事件,这样就不会出现订单还没提交,库存模块就先处理的情况。
生成模块文档
Spring Modulith 还可以根据项目结构生成模块文档:
package com.example.shop;
import org.junit.jupiter.api.Test;
import org.springframework.modulith.core.ApplicationModules;
import org.springframework.modulith.docs.Documenter;
class DocumentationTests {
@Test
void writeDocumentation() {
ApplicationModules modules = ApplicationModules.of(ShopApplication.class);
new Documenter(modules).writeDocumentation();
}
}
生成的文档可以看到模块之间的依赖关系。这个功能不一定每天都会用,但是在项目变大之后,拿来看当前模块到底是怎么互相依赖的还是挺方便的。
单独测试模块
还可以使用 @ApplicationModuleTest 单独测试某个模块:
package com.example.shop.order;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.modulith.test.ApplicationModuleTest;
@ApplicationModuleTest
class OrderModuleTests {
@Autowired
OrderService orderService;
@Test
void createsOrder() {
orderService.createOrder(new CreateOrderCommand(/* ... */));
}
}
这个测试只会启动当前模块和它需要的依赖,比直接启动整个 Spring Boot 应用要轻一些。另一方面,如果一个模块测试必须带上一堆不相关的模块,那也说明这个模块可能已经不够独立了。
总结
微服务不是不好,只是它有自己的使用成本。对于很多中小型项目来说,多模块单体可能是一个更舒服的选择。
它仍然是一个单体应用,所以开发、调试、部署都会简单一些。同时又通过模块边界避免代码全部混在一起。以后如果某个模块真的需要独立部署,也可以再拆出去。
Spring Modulith 做的事情也比较实用,主要就是帮我们识别模块、校验依赖、处理模块事件、生成文档。它解决不了所有架构问题,但是可以让“模块化单体”这件事不只是停留在口头约定上。