阅读 3 分钟

回到单体

微服务这几年一直都很火,之前做系统设计的时候也很容易下意识就往微服务上靠。比如一个商城系统,很自然就会想到用户服务、订单服务、库存服务、支付服务、优惠券服务。

拆完之后结构看起来确实很清晰,每个服务只负责自己的事情。但是开发一段时间后就会发现,很多复杂度不是消失了,而是从代码里跑到了服务调用里。

一个本来很简单的下单流程,可能要调用订单、库存、支付、消息好几个服务。中间只要有一个接口超时或者数据状态不对,就要开始查日志、看链路、补偿数据。以前一个事务能解决的问题,现在可能要考虑幂等、重试、消息可靠性这些东西。

所以现在有些项目又开始从微服务回到单体。当然这个单体不是以前那种什么代码都堆在一起的单体,而是更像一种多模块单体

为什么要回到单体

微服务比较适合团队很多、业务边界稳定、不同模块需要独立发布或者独立扩容的情况。如果项目还没到这个阶段,过早拆成微服务反而会比较痛苦。

比如常见的问题有这些:

  1. 本地开发要启动很多服务。
  2. 服务之间联调比较麻烦。
  3. 简单的方法调用变成了 HTTP 或 RPC 调用。
  4. 一个功能出问题,要跨多个服务查日志。
  5. 数据一致性变复杂了。

如果团队规模不大,业务也还在快速变化,那单体反而会简单很多。代码在一个项目里,调试方便,事务也比较直接。真正需要注意的是不要把单体写成一团乱麻。

多模块单体

多模块单体可以理解为:运行时是一个应用,但是代码上分成多个业务模块。

比如还是一个商城系统,可以这样拆:

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

orderinventorypayment 还是不同的业务模块,只是它们不再是几个单独部署的服务。模块之间可以直接调用,也可以通过事件通信。

这里最重要的是 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

这样 orderinventorypayment 就是三个模块。

校验模块依赖

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 做的事情也比较实用,主要就是帮我们识别模块、校验依赖、处理模块事件、生成文档。它解决不了所有架构问题,但是可以让“模块化单体”这件事不只是停留在口头约定上。

输入关键词开始搜索