Spring Boot 构建 Modulith 指南

本文将教你如何使用 Spring Boot 构建 Modulith,并使用 Spring Modulith 项目的特性。Modulith 是一种软件架构模式,假设将你的单体应用程序组织成逻辑模块。这些模块应尽可能独立于彼此。Modulith 平衡了单体架构和基于微服务的架构。它可以成为组织应用程序的目标模型。但你也可以将其视为从单体架构向基于微服务的方法迁移的过渡阶段。Spring Modulith 将帮助我们构建结构良好的 Spring Boot 应用程序,并验证逻辑模块之间的依赖关系。

我们将比较当前的方法和基于微服务的架构。为此,我们将实现与我最近一篇关于 使用 Spring Cloud 和 Spring Boot 3 构建微服务 的文章中所描述的非常相似的功能。

源码

你可以克隆我的 GitHub Repository,然后按照说明操作即可。

在开始之前,我们先来看看下图。它展示了我们示例系统的架构。我们有三个独立的模块,它们相互通信:employee(员工)、department(部门)和 organization(组织)。此外还有 gateway(网关)模块。它负责将内部服务作为 REST 端点暴露在应用之外。我们的模块使用 Spring Modulith 项目提供的支持向 Zipkin 实例发送追踪。

spring boot modulith 架构

如果你想将其与先前提到的文章中描述的类似微服务架构进行比较,这是该架构的图。

spring boot 微服务架构

让我们来看看代码的结构。默认情况下,main 的每个直接子包都被视为一个应用模块包。因此有四个应用模块:departmentemployeegatewayorganization。每个模块都包含向其他模块开放的 “provided interfaces”(提供的接口)。我们需要将它们放在应用模块根目录下。其他模块不能访问应用模块子包中的任何类或 bean。我们将在接下来的章节中详细介绍。

src/main/java
└── pl
    └── piomin
        └── services
            ├── OrganizationAddEvent.java
            ├── OrganizationRemoveEvent.java
            ├── SpringModulith.java
            ├── department
            │   ├── DepartmentDTO.java
            │   ├── DepartmentExternalAPI.java
            │   ├── DepartmentInternalAPI.java
            │   ├── management
            │   │   ├── DepartmentManagement.java
            │   │   └── package-info.java
            │   ├── mapper
            │   │   └── DepartmentMapper.java
            │   ├── model
            │   │   └── Department.java
            │   └── repository
            │       └── DepartmentRepository.java
            ├── employee
            │   ├── EmployeeDTO.java
            │   ├── EmployeeExternalAPI.java
            │   ├── EmployeeInternalAPI.java
            │   ├── management
            │   │   └── EmployeeManagement.java
            │   ├── mapper
            │   │   └── EmployeeMapper.java
            │   ├── model
            │   │   └── Employee.java
            │   └── repository
            │       └── EmployeeRepository.java
            ├── gateway
            │   └── GatewayManagement.java
            └── organization
                ├── OrganizationDTO.java
                ├── OrganizationExternalAPI.java
                ├── management
                │   └── OrganizationManagement.java
                ├── mapper
                │   └── OrganizationMapper.java
                ├── model
                │   └── Organization.java
                └── repository
                    └── OrganizationRepository.java

依赖

让我们来看看所需的依赖。我们的应用会暴露一些 REST 端点并连接到嵌入式 H2 数据库。因此,我们需要包含 Spring Web 和 Spring Data JPA。为了使用 Spring Modulith,我们必须添加 spring-modulith-starter-core Starter。我们还将在实体和 DTO 类之间做一些映射。因此,添加了 mapstruct,它可以简化 Java Bean 之间的映射。

<dependency>
  <groupId>org.springframework.boot</groupId>
  <artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>
<dependency>
  <groupId>org.springframework.boot</groupId>
  <artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
  <groupId>org.springframework.modulith</groupId>
  <artifactId>spring-modulith-starter-core</artifactId>
</dependency>
<dependency>
  <groupId>org.springframework.modulith</groupId>
  <artifactId>spring-modulith-starter-jpa</artifactId>
</dependency>
<dependency>
  <groupId>com.h2database</groupId>
  <artifactId>h2</artifactId>
  <scope>runtime</scope>
</dependency>
<dependency>
  <groupId>org.mapstruct</groupId>
  <artifactId>mapstruct</artifactId>
  <version>1.5.5.Final</version>
</dependency>

应用模块的结构

我们以 employee 模块为例,分析模块的结构。模块 根目录 下的所有接口/类都可以被其他模块调用(绿色)。其他模块不能调用模块子包中的任何接口/类(红色)。

spring boot modulith 代码结构

应用的实现并不复杂。下面是我们的 Employee 实体类:

// pl.piomin.services.model.Employee

@Entity
public class Employee {

   @Id
   @GeneratedValue(strategy = GenerationType.IDENTITY)
   private Long id;
   private Long organizationId;
   private Long departmentId;
   private String name;
   private int age;
   private String position;

   // get / set 方法省略
}

我们使用 Spring Data JPA Repository 与 H2 数据库交互。使用 Spring Data 的投影查询功能返回 DTO 对象,而不是实体类。

// pl.piomin.services.repository.EmployeeRepository

public interface EmployeeRepository extends CrudRepository<Employee, Long> {
   List<EmployeeDTO> findByDepartmentId(Long departmentId);
   List<EmployeeDTO> findByOrganizationId(Long organizationId);
   void deleteByOrganizationId(Long organizationId);
}

Record DTO 如下。它暴露在模块之外,因为其他模块必须访问 Employee 数据。我们不想直接公开实体类,因此 DTO 是一种非常有用的模式。

// pl.piomin.services.EmployeeDTO

public record EmployeeDTO(Long id,
                          Long organizationId,
                          Long departmentId,
                          String name,
                          int age,
                          String position) {
}

使用 mapstruct 在实体和 DTO 之间定义一个 mapper。

// pl.piomin.services.mapper.EmployeeMapper

@Mapper(componentModel = MappingConstants.ComponentModel.SPRING)
public interface EmployeeMapper {
    EmployeeDTO employeeToEmployeeDTO(Employee employee);
    Employee employeeDTOToEmployee(EmployeeDTO employeeDTO);
}

我们希望将 main 模块的 @Service 的实现细节隐藏在其他模块之后。因此,我们将通过接口公开所需的方法。其他模块将使用该接口来调用 @Service 的方法。InternalAPI 后缀表示该接口仅用于模块之间的内部使用。

// pl.piomin.services.EmployeeInternalAPI

public interface EmployeeInternalAPI {

   List<EmployeeDTO> getEmployeesByDepartmentId(Long id);
   List<EmployeeDTO> getEmployeesByOrganizationId(Long id);

}

为了将一些 @Service 方法作为 REST 端点公开到应用之外,我们在接口名称中使用 ExternalAPI 后缀。对于 employee 模块,我们只公开添加新增 Employee 的方法。

// pl.piomin.services.EmployeeExternalAPI

public interface EmployeeExternalAPI {
   EmployeeDTO add(EmployeeDTO employee);
}

我们的管理 @Service 实现了外部(external)和内部(internal)接口。这里有 department 和 organization 模块使用的两个内部方法(1)、作为 REST 端点暴露的一个外部方法(2)以及处理来自其他模块的异步事件的方法(3)。我们稍后来解最后一个方法。

// pl.piomin.services.employee.management.EmployeeManagement

@Service
public class EmployeeManagement implements EmployeeInternalAPI, 
                                           EmployeeExternalAPI {

   private static final Logger LOG = LoggerFactory
      .getLogger(EmployeeManagement.class);
   private EmployeeRepository repository;
   private EmployeeMapper mapper;

   public EmployeeManagement(EmployeeRepository repository,
                             EmployeeMapper mapper) {
      this.repository = repository;
      this.mapper = mapper;
   }

   @Override // (1)
   public List<EmployeeDTO> getEmployeesByDepartmentId(Long departmentId) {
      return repository.findByDepartmentId(departmentId);
   }

   @Override // (1)
   public List<EmployeeDTO> getEmployeesByOrganizationId(Long id) {
      return repository.findByOrganizationId(id);
   }

   @Override
   @Transactional // (2)
   public EmployeeDTO add(EmployeeDTO employee) {
      Employee emp = mapper.employeeDTOToEmployee(employee);
      return mapper.employeeToEmployeeDTO(repository.save(emp));
   }

   @ApplicationModuleListener // (3)
   void onRemovedOrganizationEvent(OrganizationRemoveEvent event) {
      LOG.info("onRemovedOrganizationEvent(orgId={})", event.getId());
      repository.deleteByOrganizationId(event.getId());
   }

}

使用 Spring Modulith 验证依赖

让我们切换到 department 模块。它需要访问 employee 模块公开的数据。为此,它将使用 EmployeeInternalAPI 接口中提供的方法。EmployeeManagement 类的实现应该对 department 模块隐藏起来。然而,假设 department 模块直接调用 EmployeeManagement Bean。下面是 DepartmentManagement 实现的片段:

@Service
public class DepartmentManagement {

   private DepartmentRepository repository;
   private EmployeeManagement employeeManagement;
   private DepartmentMapper mapper;

   public DepartmentManagement(DepartmentRepository repository,
                               EmployeeManagement employeeManagement,
                               DepartmentMapper mapper) {
      this.repository = repository;
      this.employeeManagement = employeeManagement;
      this.mapper = mapper;
   }

   public DepartmentDTO getDepartmentByIdWithEmployees(Long id) {
      DepartmentDTO d = repository.findDTOById(id);
      List<EmployeeDTO> dtos = employeeManagement
         .getEmployeesByDepartmentId(id);
      d.employees().addAll(dtos);
      return d;
   }
}

这就是 Spring Modulith 项目的用武之地。例如,我们可以创建 JUnit 测试来验证应用模块之间的依赖关系。一旦我们违反了 Modulith 规则,测试将失败。让我们看看如何使用 Spring Modulith ApplicationModules 类提供的 verify() 方法:

public class SpringModulithTests {

   ApplicationModules modules = ApplicationModules.of(SpringModulith.class);

   @Test
   void shouldBeCompliant() {
      modules.verify();
   }
}

这是上述 DepartmentManagement Bean 实现的测试结果:

DepartmentManagement Bean 实现的测结果

现在,让我们以正确的方式来做。首先,DepartmentDTO Record 使用 EmployeeDTO。这种关系仅在 DTO 级别表示。

// pl.piomin.services.department.DepartmentDTO

public record DepartmentDTO(Long id,
                            Long organizationId,
                            String name,
                            List<EmployeeDTO> employees) {
    public DepartmentDTO(Long id, Long organizationId, String name) {
        this(id, organizationId, name, new ArrayList<>());
    }
}

实体与相应的数据库表之间没有任何关系。我们希望我们的模块在数据库层面上是独立的。虽然表之间没有关系,但我们仍然使用一个数据库。下面是 DepartmentEntity 类:

// pl.piomin.services.department.model.Department

@Entity
public class Department {

   @Id
   @GeneratedValue(strategy = GenerationType.IDENTITY)
   private Long id;
   private Long organizationId;
   private String name;

   // ... GETTERS/SETTERS
}

和以前一样,实体和 DTO 之间有一个 mapper 进行转换:

// pl.piomin.services.department.mapper.DepartmentMapper

@Mapper(componentModel = MappingConstants.ComponentModel.SPRING)
public interface DepartmentMapper {
   DepartmentDTO departmentToEmployeeDTO(Department department);
   Department departmentDTOToEmployee(DepartmentDTO departmentDTO);
}

Repository 接口如下:

// pl.piomin.services.department.repository.DepartmentRepository

public interface DepartmentRepository extends CrudRepository<Department, Long> {

   @Query("""
          SELECT new pl.piomin.services.department.DepartmentDTO(d.id, d.organizationId, d.name)
          FROM Department d
          WHERE d.id = :id
          """)
   DepartmentDTO findDTOById(Long id);

   @Query("""
          SELECT new pl.piomin.services.department.DepartmentDTO(d.id, d.organizationId, d.name)
          FROM Department d
          WHERE d.organizationId = :organizationId
          """)
   List<DepartmentDTO> findByOrganizationId(Long organizationId);

   void deleteByOrganizationId(Long organizationId);
}

department 模块调用 employee 模块公开的方法,但也为 organization 模块提供方法。再次创建 *InternalAPI 接口。

// pl.piomin.services.department.DepartmentInternalAPI

public interface DepartmentInternalAPI {
    List<DepartmentDTO> getDepartmentsByOrganizationId(Long id);
}

这是一个接口,其中包含作为 REST 端点对外公开的方法:

// pl.piomin.services.department.DepartmentExternalAPI

public interface DepartmentExternalAPI {
    DepartmentDTO getDepartmentByIdWithEmployees(Long id);
    DepartmentDTO add(DepartmentDTO department);
}

最后,我们可以实现 DepartmentManagement Bean。同样,它包含一个用于同步调用的方法和两个用于异步处理事件的方法(用 @ApplicationModuleListener 注解)。

// pl.piomin.services.department.management.DepartmentManagement

@Service
public class DepartmentManagement implements DepartmentInternalAPI, DepartmentExternalAPI {

   private static final Logger LOG = LoggerFactory
      .getLogger(DepartmentManagement.class);
   private DepartmentRepository repository;
   private EmployeeInternalAPI employeeInternalAPI;
   private DepartmentMapper mapper;

   public DepartmentManagement(DepartmentRepository repository,
                               EmployeeInternalAPI employeeInternalAPI,
                               DepartmentMapper mapper) {
      this.repository = repository;
      this.employeeInternalAPI = employeeInternalAPI;
      this.mapper = mapper;
   }

   @Override
   public DepartmentDTO getDepartmentByIdWithEmployees(Long id) {
      DepartmentDTO d = repository.findDTOById(id);
      List<EmployeeDTO> dtos = employeeInternalAPI
         .getEmployeesByDepartmentId(id);
      d.employees().addAll(dtos);
      return d;
   }

   @ApplicationModuleListener
   void onNewOrganizationEvent(OrganizationAddEvent event) {
      LOG.info("onNewOrganizationEvent(orgId={})", event.getId());
      add(new DepartmentDTO(null, event.getId(), "HR"));
      add(new DepartmentDTO(null, event.getId(), "Management"));
   }

   @ApplicationModuleListener
   void onRemovedOrganizationEvent(OrganizationRemoveEvent event) {
      LOG.info("onRemovedOrganizationEvent(orgId={})", event.getId());
      repository.deleteByOrganizationId(event.getId());
   }

   @Override
   public DepartmentDTO add(DepartmentDTO department) {
      return mapper.departmentToEmployeeDTO(
         repository.save(mapper.departmentDTOToEmployee(department))
      );
   }

   @Override
   public List<DepartmentDTO> getDepartmentsByOrganizationId(Long id) {
      return repository.findByOrganizationId(id);
   }
}

处理异步事件

到目前为止,我们讨论的是应用模块之间的同步通信。这通常是我们需要的最常见的通信方式。不过,在某些情况下,我们可以依赖模块之间交换的异步事件。Spring Boot 和 Spring Modulith 支持这种方式。它基于 Spring ApplicationEvent 机制。

切换到 organization 模块。在 OrganizationManagement 模块中,我们不仅实现了几个同步操作,还使用 ApplicationEventPublisher Bean (1) 发送了一些 Spring 事件。这些事件会在添加(2)和删除(3)组织后发布。例如,假设我们要删除组织,我们也应该删除所有部门和员工。我们可以在 departmentemployee 模块端异步处理这些操作。我们的事件对象包含组织的 id

// pl.piomin.services.organization.management.OrganizationManagement

@Service
public class OrganizationManagement implements OrganizationExternalAPI {

   private final ApplicationEventPublisher events; // (1)
   private final OrganizationRepository repository;
   private final DepartmentInternalAPI departmentInternalAPI;
   private final EmployeeInternalAPI employeeInternalAPI;
   private final OrganizationMapper mapper;

   public OrganizationManagement(ApplicationEventPublisher events,
                                 OrganizationRepository repository,
                                 DepartmentInternalAPI departmentInternalAPI,
                                 EmployeeInternalAPI employeeInternalAPI,
                                 OrganizationMapper mapper) {
      this.events = events;
      this.repository = repository;
      this.departmentInternalAPI = departmentInternalAPI;
      this.employeeInternalAPI = employeeInternalAPI;
      this.mapper = mapper;
   }

   @Override
   public OrganizationDTO findByIdWithEmployees(Long id) {
      OrganizationDTO dto = repository.findDTOById(id);
      List<EmployeeDTO> dtos = employeeInternalAPI.getEmployeesByOrganizationId(id);
      dto.employees().addAll(dtos);
      return dto;
   }

   @Override
   public OrganizationDTO findByIdWithDepartments(Long id) {
      OrganizationDTO dto = repository.findDTOById(id);
      List<DepartmentDTO> dtos = departmentInternalAPI.getDepartmentsByOrganizationId(id);
      dto.departments().addAll(dtos);
      return dto;
   }

   @Override
   public OrganizationDTO findByIdWithDepartmentsAndEmployees(Long id) {
      OrganizationDTO dto = repository.findDTOById(id);
      List<DepartmentDTO> dtos = departmentInternalAPI.getDepartmentsByOrganizationIdWithEmployees(id);
      dto.departments().addAll(dtos);
      return dto;
   }

   @Override
   @Transactional
   public OrganizationDTO add(OrganizationDTO organization) {
      OrganizationDTO dto = mapper.organizationToOrganizationDTO(
          repository.save(mapper.organizationDTOToOrganization(organization))
      );
      events.publishEvent(new OrganizationAddEvent(dto.id())); // (2)
      return dto;
   }

   @Override
   @Transactional
   public void remove(Long id) {
      repository.deleteById(id);
      events.publishEvent(new OrganizationRemoveEvent(id)); // (3)
   }

}

然后,其他模块可以接收 Application Event。为了处理事件,我们可以使用 Spring Modulith 提供的 @ApplicationModuleListener 注解。它是三个不同 Spring 注解的快捷方式:@Async@Transactional@TransactionalEventListener。在 DepartmentManagement 代码片段中,我们处理传入的事件。对于新创建的组织,我们添加了两个默认部门。在删除组织后,我们会删除之前分配给该组织的所有部门。

@ApplicationModuleListener
void onNewOrganizationEvent(OrganizationAddEvent event) {
   LOG.info("onNewOrganizationEvent(orgId={})", event.getId());
   add(new DepartmentDTO(null, event.getId(), "HR"));
   add(new DepartmentDTO(null, event.getId(), "Management"));
}

@ApplicationModuleListener
void onRemovedOrganizationEvent(OrganizationRemoveEvent event) {
   LOG.info("onRemovedOrganizationEvent(orgId={})", event.getId());
   repository.deleteByOrganizationId(event.getId());
}

EmployeeDepartment 中也有类似的方法来处理 OrganizationRemoveEvent

@ApplicationModuleListener
void onRemovedOrganizationEvent(OrganizationRemoveEvent event) {
   LOG.info("onRemovedOrganizationEvent(orgId={})", event.getId());
   repository.deleteByOrganizationId(event.getId());
}

Spring Modulith 提供了一个智能的机制来测试事件处理。我们可以通过将测试放置在正确的包中来为特定模块创建测试。例如,对于测试 department 模块,它位于 pl.piomin.services.department 包中。我们需要使用 @ApplicationModuleTest 注解标记测试类。有三种不同的 bootstrap mode 可用:STANDALONE(独立模式)、DIRECT_DEPENDENCIES(直接依赖模式)和 ALL_DEPENDENCIES(所有依赖模式)。Spring Modulith 提供了 Scenario 抽象。它可以在 @ApplicationModuleTest 测试中声明为测试方法的参数。借助该对象,我们只需一行代码就能定义发布事件的场景并验证结果。

// pl.piomin.services.department.DepartmentModuleTests

@ApplicationModuleTest(ApplicationModuleTest.BootstrapMode.DIRECT_DEPENDENCIES)
@TestMethodOrder(MethodOrderer.OrderAnnotation.class)
public class DepartmentModuleTests {

    private static final long TEST_ID = 100;

    @Autowired
    DepartmentRepository repository;

    @Test
    @Order(1)
    void shouldAddDepartmentsOnEvent(Scenario scenario) {
        scenario.publish(new OrganizationAddEvent(TEST_ID))
                .andWaitForStateChange(() -> repository.findByOrganizationId(TEST_ID))
                .andVerify(result -> {assert !result.isEmpty();});
    }

    @Test
    @Order(2)
    void shouldRemoveDepartmentsOnEvent(Scenario scenario) {
        scenario.publish(new OrganizationRemoveEvent(TEST_ID))
                .andWaitForStateChange(() -> repository.findByOrganizationId(TEST_ID))
                .andVerify(result -> {assert result.isEmpty();});
    }
}

通过 REST 向外部公开模块 API

最后,让我们切换到应用的最后一个模块 - gateway(网关)。它的作用不大。它只负责通过 REST 端点向应用外部公开一些模块服务。第一步,我们需要注入所有 *ExternalAPI Bean。

// pl.piomin.services.gateway.GatewayManagement

@RestController
@RequestMapping("/api")
public class GatewayManagement {

   private DepartmentExternalAPI departmentExternalAPI;
   private EmployeeExternalAPI employeeExternalAPI;
   private OrganizationExternalAPI organizationExternalAPI;

   public GatewayManagement(DepartmentExternalAPI departmentExternalAPI,
                            EmployeeExternalAPI employeeExternalAPI,
                            OrganizationExternalAPI organizationExternalAPI) {
      this.departmentExternalAPI = departmentExternalAPI;
      this.employeeExternalAPI = employeeExternalAPI;
      this.organizationExternalAPI = organizationExternalAPI;
   }


   @GetMapping("/organizations/{id}/with-departments")
   public OrganizationDTO apiOrganizationWithDepartments(@PathVariable("id") Long id) {
        return organizationExternalAPI.findByIdWithDepartments(id);
   }

   @GetMapping("/organizations/{id}/with-departments-and-employees")
   public OrganizationDTO apiOrganizationWithDepartmentsAndEmployees(@PathVariable("id") Long id) {
      return organizationExternalAPI.findByIdWithDepartmentsAndEmployees(id);
   }

   @PostMapping("/organizations")
   public OrganizationDTO apiAddOrganization(@RequestBody OrganizationDTO o) {
      return organizationExternalAPI.add(o);
   }

   @PostMapping("/employees")
   public EmployeeDTO apiAddEmployee(@RequestBody EmployeeDTO employee) {
      return employeeExternalAPI.add(employee);
   }

   @GetMapping("/departments/{id}/with-employees")
   public DepartmentDTO apiDepartmentWithEmployees(@PathVariable("id") Long id) {
      return departmentExternalAPI.getDepartmentByIdWithEmployees(id);
   }

   @PostMapping("/departments")
   public DepartmentDTO apiAddDepartment(@RequestBody DepartmentDTO department) {
      return departmentExternalAPI.add(department);
   }
}

我们可以使用 Springdoc 来记录应用暴露的 REST API。在 Maven pom.xml 中加入以下依赖:

<dependency>
   <groupId>org.springdoc</groupId>
   <artifactId>springdoc-openapi-starter-webmvc-ui</artifactId>
   <version>2.2.0</version>
</dependency>

使用 mvn spring-boot:run 命令启动应用后,我们就可以通过 http://localhost:8080/swagger-ui.html 地址访问Swagger UI,并查看我们的API文档。

Swagger UI

为了确保一切运行正常,我们可以实现一些基于 REST 的 Spring Boot 测试。我们在此不使用任何特定的 Spring Modulith 支持,只使用 Spring Boot 测试功能。

@SpringBootTest(webEnvironment = 
                SpringBootTest.WebEnvironment.RANDOM_PORT)
@TestMethodOrder(MethodOrderer.OrderAnnotation.class)
public class AppRestControllerTests {

   @Autowired
   TestRestTemplate restTemplate;

   @Test
   @Order(1)
   void shouldAddNewEmployee() {
      EmployeeDTO emp = new EmployeeDTO(null, 1L, 1L, "Test", 30, "HR");
      emp = restTemplate.postForObject("/api/employees", emp, 
                                       EmployeeDTO.class);
      assertNotNull(emp.id());
   }

   @Test
   @Order(1)
   void shouldAddNewDepartment() {
      DepartmentDTO dep = new DepartmentDTO(null, 1L, "Test");
      dep = restTemplate.postForObject("/api/departments", dep, 
                                       DepartmentDTO.class);
      assertNotNull(dep.id());
   }

   @Test
   @Order(2)
   void shouldFindDepartmentWithEmployees() {
      DepartmentDTO dep = restTemplate
         .getForObject("/api/departments/{id}/with-employees",                    
                       DepartmentDTO.class, 1L);
      assertNotNull(dep);
      assertNotNull(dep.id());
   }
}

下面是测试结果:

Spring Boot Rest API 测试结构

Spring Modulith 中文档和 Spring Boot Actuator 的支持

Spring Modulith 提供了一个额外的 Actuator 端点,用于显示 Spring Boot 应用的模块化结构。添加以下 Maven 依赖以使用该支持:

<dependency>
   <groupId>org.springframework.boot</groupId>
   <artifactId>spring-boot-starter-actuator</artifactId>
</dependency>
<dependency>
   <groupId>org.springframework.modulith</groupId>
   <artifactId>spring-modulith-actuator</artifactId>
   <scope>runtime</scope>
</dependency>

然后,在 application.yml 文件中添加以下属性,通过 HTTP 公开所有 Actuator 端点:

# application.yml
management.endpoints.web.exposure.include: "*"

最后,我们可以通过 http://localhost:8080/actuator/modulith 访问 modulith 端点。下面是 JSON 响应:

{
   "department" : {
      "basePackage" : "pl.piomin.services.department",
      "dependencies" : [
         {
            "target" : "employee",
            "types" : [
               "USES_COMPONENT"
            ]
         }
      ],
      "displayName" : "Department"
   },
   "employee" : {
      "basePackage" : "pl.piomin.services.employee",
      "dependencies" : [],
      "displayName" : "Employee"
   },
   "gateway" : {
      "basePackage" : "pl.piomin.services.gateway",
      "dependencies" : [
         {
            "target" : "employee",
            "types" : [
               "USES_COMPONENT"
            ]
         },
         {
            "target" : "department",
            "types" : [
               "USES_COMPONENT"
            ]
         },
         {
            "target" : "organization",
            "types" : [
               "USES_COMPONENT"
            ]
         }
      ],
      "displayName" : "Gateway"
   },
   "organization" : {
      "basePackage" : "pl.piomin.services.organization",
      "dependencies" : [
         {
            "target" : "employee",
            "types" : [
               "USES_COMPONENT"
            ]
         },
         {
            "target" : "department",
            "types" : [
               "USES_COMPONENT"
            ]
         }
      ],
      "displayName" : "Organization"
   }
}

如果你喜欢更图形化的文档形式,我们可以利用 Spring Modulith Documenter 组件。我们不需要包含任何东西,只需准备一个简单的测试,创建并自定义 Documenter 对象即可:

public class SpringModulithTests {

   ApplicationModules modules = ApplicationModules
      .of(SpringModulith.class);

   @Test
   void writeDocumentationSnippets() {
      new Documenter(modules)
             .writeModuleCanvases()
             .writeModulesAsPlantUml()
             .writeIndividualModulesAsPlantUml();
   }
}

运行测试后,Spring Modulith 会在 target/spring-modulith-docs 目录下生成文档文件。让我们来看看应用模块的 UML 图。

应用模块之间的 UML 图

启用可观察性(Observability)

我们可以在应用模块之间使用 Micrometer 启用可观察性。

添加如下依赖,Spring Boot 应用将把它们发送到 Zipkin:

<dependency>
   <groupId>org.springframework.modulith</groupId>
   <artifactId>spring-modulith-observability</artifactId>
  <scope>runtime</scope>
</dependency>
<dependency>
   <groupId>io.micrometer</groupId>
   <artifactId>micrometer-tracing-bridge-otel</artifactId>
</dependency>
<dependency>
   <groupId>io.opentelemetry</groupId>
   <artifactId>opentelemetry-exporter-zipkin</artifactId>
</dependency>

我们还可以将默认采样级别更改为 1.0(100% 追踪)。

management.tracing.sampling.probability: 1.0

我们可以使用 Spring Boot 对 Docker Compose 的支持来启动 Zipkin 和应用。首先,在项目根目录下创建 docker-compose.yml 文件。

version: "3.7"
services:
  zipkin:
    container_name: zipkin
    image: openzipkin/zipkin
    extra_hosts: [ 'host.docker.internal:host-gateway' ]
    ports:
      - "9411:9411"

然后,我们需要在 Maven pom.xml 中添加以下依赖:

<dependency>
   <groupId>org.springframework.boot</groupId>
   <artifactId>spring-boot-docker-compose</artifactId>
</dependency>

启动应用后,Spring Boot 会尝试在 Docker 上运行 Zipkin 容器。要访问 Zipkin 仪表板,请访问 http://localhost:9411。你将看到应用模块之间的追踪可视化。它在异步事件通信方面运行良好。遗憾的是,它不能正确可视化同步通信,但也许我哪里没做对,或者我们需要等待 Spring Modulith 项目的一些改进。

Zipkin 仪表板


参考:https://piotrminkowski.com/2023/10/13/guide-to-modulith-with-spring-boot/