Spring Boot Modulith模块化指南


本文将教您如何使用 Spring Boot 构建 modulith 并使用 Spring Modulith 项目功能。Modulith 是一种软件架构模式,假设将您的整体应用程序组织成逻辑模块。此类模块应尽可能相互独立。

Modulith 平衡整体架构和基于微服务的架构。它可以是您组织应用程序的目标模型。但您也可以将其视为从整体方法迁移到基于微服务的方法期间的过渡阶段。Spring Modulith将帮助我们构建一个结构良好的Spring Boot应用程序并验证逻辑模块之间的依赖关系。

GitHub 存储库


应用模块结构
我们将以模块为例来分析我们模块的结构employee。模块根目录中的所有接口/类都可以从其他模块调用(绿色)。其他模块无法调用模块子包(红色)中的任何接口/类。

让我们看一下代码的结构。默认情况下,主包的每个直接子包都被视为一个 应用程序模块包。所以有四个应用模块:department、employee、gateway、organization。每个模块都包含向其他模块公开的“提供的接口”。我们需要将它们放在应用程序模块根目录中。其他模块无法访问应用程序模块子包中的任何类或 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


依赖关系:

<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实体类:

@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;

   // ... GETTERS/SETTERS
}

我们使用 Spring Data JPA 存储库模式与 H2 数据库进行交互。我们使用 Spring Data 投影功能返回 DTO 对象,而不是实体类。

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


这是我们的 DTO 记录。它暴露在模块外部,因为其他模块必须访问Employee数据。我们不想直接公开实体类,因此 DTO 在这里是一个非常有用的模式。

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

我们还使用mapstruct支持定义实体和 DTO 之间的映射器。

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

服务模块
我们希望将主模块 @Service 的实现细节隐藏在其他模块之后。因此,我们将通过接口公开所需的方法。其他模块将使用该接口调用 @Service 方法。InternalAPI 后缀意味着该接口仅供模块之间内部使用。

public interface EmployeeInternalAPI {

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

}

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

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

我们的管理 @Service 实现了外部和内部接口。这里有部门和组织模块使用的两个内部方法

  • (1)、作为 REST 端点暴露的一个外部方法
  • (2)以及处理来自其他模块的异步事件的方法

@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 Boot 和 Spring Modulith 中支持这种方法。它是基于SpringApplicationEvent机制的。

让我们切换到organization模块。在该OrganizationManagement模块中,我们正在实现多个同步操作,但我们还使用ApplicationEventPublisherbean (1)发送一些 Spring 事件。这些事件在添加(2)和删除(3)组织后传播。例如,假设我们要删除组织,我们还应该删除所有部门和员工。department我们可以在模块端异步处理这些操作employee。我们的事件对象包含id组织的事件对象。

@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)
   }

}

然后,应用事件可以被其他模块接收。为了处理该事件,我们可以使用@ApplicationModuleListenerSpring Modulith 提供的注释。它是三个不同 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());
}

Spring Modulith 自带测试事件处理的智能机制。我们通过将特定模块放入正确的包来创建测试。例如,在 pl.piomin.services.department 包中测试部门模块。我们需要用 @ApplicationModuleTest 来注解测试类。有三种不同的引导模式可供选择:STANDALONE、DIRECT_DEPENDENCIES 和 ALL_DEPENDENCIES。Spring Modulith 提供了 Scenario 抽象。它可以在 @ApplicationModuleTest 测试中声明为测试方法参数。有了这个对象,我们只需一行代码就能定义发布事件的场景并验证结果。

@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
最后,让我们来看看应用程序中的最后一个模块--网关。它的作用不大。它只负责使用 REST 端点向应用程序外部公开一些模块服务。第一步,我们需要注入所有 *ExternalAPI Bean。

@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>

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 端点:
management.endpoints.web.exposure.include: "*"

最后,我们可以调用 http://localhost:8080/actuator/modulith 地址下的 modulith 端点。

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


启用可观察性
我们可以在应用模块之间使用 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。您将看到应用模块之间的跟踪可视化。它在异步事件通信方面运行良好。