作者 | 刘涛
审校 | 重楼
随着软件应用程序复杂程度的不断提升,对可扩展性、模块化以及清晰度的管理变得极为关键。
Spring Boot的多模块结构能够让你对应用程序的不同部分进行独立管理,如此一来,你的团队能够分别开展组件的开发、测试以及部署工作。这种结构使得代码维持着井然有序的状态,并具备模块化特性,不管是对于微服务,还是大型整体式系统,均颇具实用价值。
在本教程当中,你将会构建一个多模块的Spring Boot项目,每个模块都专门负责特定的职责。你将学习如何设置模块、配置模块间的通信、处理错误、施行基于JWT(JSON Web Token:是一种开放标准(RFC 7519),用于在认证服务器(签发者)、客户端(如浏览器或移动应用)和资源服务器(接收者)之间安全地传输包含用户等实体相关信息(通过头部、载荷和签名组成)的一种数据格式,以方便对资源访问进行验证。)的安全性策略以及使用Docker进行部署。
必备知识
- 具备Spring Boot和Maven的基础知识
- 熟悉Docker和CI/CD概念(可选但有帮助)
目录
1.为何采用多模块项目?
2.项目结构与架构
3.如何设置父项目
4.如何创建模块
5.模块间通信
6.常见陷阱及解决方案
7.测试策略及配置
8.错误处理与日志记录
9.安全与JWT集成
10.使用Docker和CI/CD进行部署
11.最佳实践与高级用例
12.结论与关键要点
1.为何采用多模块项目?
在单模块项目中,各个组件(指代码中的功能单元,像数据库访问层组件、业务逻辑处理组件、用户界面展示组件等)之间往往存在紧密的耦合关系,这种设计使得编程人员在扩展项目规模和管理复杂代码库时面临诸多挑战。然而,相比之下,采用多模块结构则能够带来一系列显著的优势:
- 模块化:每个模块都聚焦于特定任务,如用户管理或库存管理,从而简化了管理流程和故障排除工作。
- 团队可扩展性:团队可以在不同的模块上各自独立开展工作,此举能够最大限度地减少冲突的发生,提高生产效率。
- 灵活部署:模块可以独立部署或更新,这对于微服务或具有众多功能的大型应用程序而言特别有益。
现实案例
以某个大型电子商务应用程序为例,其架构可以清晰地划分为以下几个模块:
- 客户管理模块:主要负责处理客户资料、偏好设置以及身份认证等功能。
- 产品管理模块:专注于管理产品的详细信息、库存量以及定价策略。
- 订单处理模块:负责管理订单信息、支付流程以及订单追踪等功能。
- 库存管理模块:负责监控库存水平,并处理与供应商之间的订单事宜。
案例学习:网飞(Netflix)
为了阐明这些优势,让我们来研究一下网飞(Netflix:全球最大的流媒体视频服务平台之一)是如何采用多模块架构的。
网飞是有效运用此种方式(多模块架构)并通过微服务架构实现目标的领先典范。网飞的每个微服务均专注于特定的功能,例如用户认证、内容推荐或者流媒体服务。
多模块化结构使网飞能够高效地扩展其业务运营、独立部署更新,并保持高可用性和高性能。通过解耦服务的架构设计,网飞构建了一个强大而灵活的系统,能够同时服务全球数以百万计的用户,确保内容的无缝传输,有效支撑起这个规模庞大且不断动态发展的平台。
这种架构设计不仅提升了系统的可扩展能力,同时加强了故障隔离机制,使网飞能够快速进行创新并及时响应用户需求。
2.项目结构与架构
现在让我们回归到示例项目中,你的多模块Spring Boot项目将会使用5个关键模块,其布局情况如下:
codespring-boot-multi-module/
├── common/ # Shared utilities and constants
├── domain/ # Domain entities
├── repository/ # Data access layer (DAL)
├── service/ # Business logic
└── web/ # Main Spring Boot application and controllers
每个模块均具有其特定的功用:
- Common(通用模块):存放跨模块共享的工具类、常量和配置文件。
- Domain(域模块):包含应用程序的数据模型定义。
- Repository(仓储模块):负责数据库访问和操作。
- Service(服务模块):实现业务逻辑封装。
- Web(网络模块):定义REST API接口,作为应用程序的入口层。
这种结构遵循了关注点分离原则(Separation of Concerns:软件工程的一个基本原则,将程序分解为互不重叠的功能模块,使每个模块专注于解决特定的问题领域),其中每一层都保持独立并处理自身特定的逻辑。
下方的图表展示了各个模块:
3.如何设置父项目
步骤1:创建根项目
让我们运行以下命令来创建Maven父项目:
mvn archetype:generate -DgroupId=com.example -DartifactId=spring-boot-multi-module -DarchetypeArtifactId=maven-archetype-quickstart -DinteractiveMode=false cd spring-boot-multi-module
步骤2:配置父项目的pom.xml文件
在pom.xml文件中,定义依赖项和模块:
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://www.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<groupId>com.example</groupId>
<artifactId>spring-boot-multi-module</artifactId>
<version>1.0-SNAPSHOT</version>
<packaging>pom</packaging>
<modules>
<module>common</module>
<module>domain</module>
<module>repository</module>
<module>service</module>
<module>web</module>
</modules>
<properties>
<java.version>11</java.version>
<spring.boot.version>2.5.4</spring.boot.version>
</properties>
<dependencyManagement>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-dependencies</artifactId>
<version>${spring.boot.version}</version>
<type>pom</type>
<scope>import</scope>
</dependency>
</dependencies>
</dependencyManagement>
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
</plugins>
</build>
</project>
该pom.xml文件对依赖项和配置进行集中管理,从而让跨模块共享设置的管理变得更为便捷。
4.如何创建模块
通用模块
创建一个通用模块以定义共享的工具,例如日期格式化器。创建此模块并添加一个示例工具类:
mvn archetype:generate -DgroupId=com.example.common -DartifactId=common -DarchetypeArtifactId=maven-archetype-quickstart -DinteractiveMode=false
日期格式化工具:
package com.example.common;
import java.time.LocalDate;
import java.time.format.DateTimeFormatter;
public class DateUtils {
public static String formatDate(LocalDate date) {
return date.format(DateTimeFormatter.ofPattern("yyyy-MM-dd"));
}
}
域模块
在域模块中定义数据模型:
package com.example.domain;
import javax.persistence.Entity;
import javax.persistence.Id;
@Entity
public class User {
@Id
private Long id;
private String name;
// Getters and Setters
}
仓储模块(Repository)
创建仓储模块以管理数据访问。以下是一个基本的存储库接口:
package com.example.repository;
import com.example.domain.User;
import org.springframework.data.jpa.repository.JpaRepository;
public interface UserRepository extends JpaRepository<User, Long> {}
服务模块(Service)
创建服务模块以涵盖业务逻辑。以下是一个服务类的示例:
package com.example.service;
import com.example.domain.User;
import com.example.repository.UserRepository;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
@Service
public class UserService {
@Autowired
private UserRepository userRepository;
public User getUserById(Long id) {
return userRepository.findById(id).orElse(null);
}
}
网络模块(Web)
网络模块作为REST API层。
@RestController
public class UserController {
@Autowired
private UserService userService;
@GetMapping("/users/{id}")
public User getUserById(@PathVariable Long id) {
return userService.getUserById(id);
}
}
5.模块间通信
为了消除模块间的直接依赖,我们可以借助REST API(表述性状态转移应用程序接口)或消息代理(例如Kafka)来实现模块间的通信。这种做法确保了模块间的松耦合性,使得每个模块都能够独立地进行通信交流。
下图清晰地展示了模块间是如何相互通信的:
该图呈现出不同系统组件如何协同运作以高效处理请求。
Web模块负责处理传入的 API 请求,并将其转发给涵盖业务逻辑的服务模块。其后,服务模块与仓储模块进行交互,以从数据库中获取或者更新数据。这种分层方法确保了每个模块都能够独立运行,进而提升了系统的灵活性与维护的便捷性。
以使用Feign客户端(Feign Clients :Spring Cloud 提供的一个声明式的 HTTP 客户端工具,用于简化微服务间的调用)为例:
在模块间通信的场景中,使用Feign客户端等工具是达成服务间松耦合(loose coupling:一种软件架构设计原则,目标是减少系统组件之间的相互依赖)的有效方式。
Feign客户端允许一个模块通过REST API调用无缝地与另一个模块进行通信,而无需建立直接依赖。这种方法与前面所描述的分层架构高度契合,其中服务模块能够使用Feign客户端从其他服务或微服务中获取数据,而非直接访问数据库或者硬编码 HTTP 请求。
该方式不仅简化了代码,还通过隔离服务依赖增强了系统的可扩展性与可维护性。
@FeignClient(name = "userServiceClient", url = "http://localhost:8081")
public interface UserServiceClient {
@GetMapping("/users/{id}")
User getUserById(@PathVariable("id") Long id);
}
6.常见陷阱及解决方案
在实施多模块架构时,你可能会遇到一些挑战。以下是一些常见的陷阱及其解决方案:
- 循环依赖:模块之间可能会不经意地相互依赖,形成循环依赖的情况,从而增加构建和部署的复杂程度。解决方案:精心设计模块接口,并使用依赖管理工具在开发的早期阶段检测并解决循环依赖问题。
- 过度工程化:存在创建过多模块的风险,导致不必要的复杂性产生。解决方案:从最小的一组模块开始,只有在有明确需求时才进一步进行拆分,确保每个模块都有清晰明确的职责。
- 配置不一致:管理多个模块的配置可能会导致不一致性。解决方案:使用集中式配置管理工具(如Spring Cloud Config)来保持模块间配置的一致性。
- 通信开销:模块间通信可能会引入延迟和复杂性。解决方案:通过使用高效的协议来优化通信,并在适当的情况下考虑使用异步消息传递来减少延迟。
- 测试复杂性:由于模块间的交互,多模块项目的测试可能会更加复杂。
解决方案:推行一个稳健的测试策略,涵盖针对单个模块的单元测试以及针对模块间交互的集成测试。
通过了解这些陷阱并应用这些解决方案,你可以有效地管理多模块架构的复杂性,并确保开发过程的顺利进行。
7.测试策略及配置
在多模块的设置当中,独立地测试每个模块,并将其当作一个单元来进行测试,这一点至关重要。
单元测试
在这里,我们将使用JUnit和Mockito(Java中两个常见测试工具)来执行单元测试:
@RunWith(MockitoJUnitRunner.class)
public class UserServiceTest {
@Mock
private UserRepository userRepository;
@InjectMocks
private UserService userService;
@Test
public void testGetUserById() {
User user = new User();
user.setId(1L);
user.setName("John");
Mockito.when(userRepository.findById(1L)).thenReturn(Optional.of(user));
User result = userService.getUserById(1L);
assertEquals("John", result.getName());
}
}
集成测试
我们将使用带有内存数据库的Testcontainers(一个支持 Java 测试的库,它提供了轻量级的、一次性的容器实例支持。它让测试人员在测试中使用真实的数据库、消息队列等服务,而不是模拟这些服务)进行集成测试:
@Testcontainers
@ExtendWith(SpringExtension.class)
@SpringBootTest
public class UserServiceIntegrationTest {
@Container
private static PostgreSQLContainer<?> postgresqlContainer = new PostgreSQLContainer<>("postgres:latest");
@Autowired
private UserService userService;
@Test
public void testFindById() {
User user = userService.getUserById(1L);
assertNotNull(user);
}
}
8.错误处理与日志记录
错误处理和日志记录是确保应用程序可靠运行且具备可调试性的重要手段。
错误处理
在本节中,我们将探讨如何使用全局异常处理器在Spring Boot应用程序中优雅地处理错误。通过使用@ControllerAdvice注解,我们将建立一种集中捕获和响应错误的方式,以保持代码的整洁和响应的一致性。
@ControllerAdvice
public class GlobalExceptionHandler {
@ExceptionHandler(UserNotFoundException.class)
public ResponseEntity<String> handleUserNotFoundException(UserNotFoundException ex) {
return new ResponseEntity<>("User not found", HttpStatus.NOT_FOUND);
}
}
在上面的代码示例中,我们定义了一个GlobalExceptionHandler,用于捕获任何UserNotFoundException异常,并返回一个友好的消息,如“用户未找到”,同时附带404状态码。这样,你就不需要在每个控制器中都处理这个异常了——只需在一个地方处理即可!
现在,让我们来看一下这个流程图。整个流程是这样的:当客户端向Web模块发送请求时,如果一切顺利,你将会收到一个成功的响应。但是,如果出现问题,比如系统未能找到指定的用户,那么这个错误就会被全局异常处理器捕获。这个处理器会记录问题详情,并向客户端返回一个清晰、结构化的响应。
这种方法确保了用户能够收到明确的错误信息,同时保持了应用程序内部的安全性和隐蔽性。
日志记录
在每个模块中进行结构化日志记录可以提高可追踪性和调试效率。你可以使用像Logback这样的集中式日志记录系统,并包含用于追踪请求的相关ID。
9.安全与JWT集成
在本节中,我们将详细介绍如何配置JSON Web Tokens (JWT) 以增强应用程序的安全性。通过JWT,我们能够保护各个终端节点,并根据用户的角色来控制他们对应用程序不同部分的访问权限。为了实现这一目标,我们将在SecurityConfig类中进行配置,该类将负责定义谁有权访问应用程序中的哪些资源。
@EnableWebSecurity
public class SecurityConfig extends WebSecurityConfigurerAdapter {
@Override
protected void configure(HttpSecurity http) throws Exception {
http.authorizeRequests()
.antMatchers("/admin/**").hasRole("ADMIN")
.antMatchers("/user/**").hasAnyRole("USER", "ADMIN")
.anyRequest().authenticated()
.and()
.oauth2ResourceServer().jwt();
}
}
在以上的代码示例中,你可以看到我们如何定义访问规则:
- /admin/** 终端节点仅限于具有ADMIN(管理员)角色的用户访问。
- /user/** 终端节点可以由具有USER或ADMIN角色的用户访问。
- 任何其他请求都需要用户进行身份验证。
接下来,我们要在应用程序中进行相关配置,具体而言,就是使用.oauth2ResourceServer().jwt()这种方式来验证传入的令牌。这样做的目的在于,只有经过验证为有效的令牌所对应的请求,才能够被允许访问我们受保护终端节点,从而确保了系统的安全性和资源访问的合法性。
现在,让我们通过图示来了解一下流程。当客户端发送请求以访问资源时,安全过滤器会首先检查所提供的JWT令牌是否有效。如果令牌有效,访问请求将继续传递到服务模块以获取或处理数据。如果无效,则访问立即被拒绝,并且客户端会收到一个异常响应。
这一流程确保了只有通过身份验证的用户才能访问敏感资源,从而保证了应用程序的安全性。
10.使用Docker和CI/CD进行部署
在本节中,我们会使用 Docker 将每个模块进行容器化处理。这样做的目的是让我们的应用程序能够更便捷地在不同环境中进行部署并保持一致运行。同时,我们还将使用GitHub Actions来设置持续集成/持续部署(CI/CD)管道。当然,如果你更倾向于使用Jenkins,也可以选择它。自动化该过程,可以确保你推送的任何更改都能自动进行构建、测试和部署。
第1步:使用Docker进行容器化处理
我们首先为Web模块创建一个Dockerfile:
FROM openjdk:11-jre-slim
COPY target/web-1.0-SNAPSHOT.jar app.jar
ENTRYPOINT ["java", "-jar", "/app.jar"]
在这里,我们使用Java 11的轻量级版本来保持较小的图像尺寸。然后将编译后的.jar文件复制到容器中,并将其设置为在容器启动时运行。
第2步:使用Docker Compose进行多模块部署
接下来,我们使用Docker Compose文件将多个模块编排在一起:
version: '3'
services:
web:
build: ./web
ports:
- "8080:8080"
service:
build: ./service
ports:
- "8081:8081"
通过这种配置,我们能够同时运行Web模块和服务模块,从而只需执行一个单独的命令就可轻松启动整个应用程序。每个服务都是在其对应的独立目录中构建的,并且我们为访问这些服务公开了所需的端口。
使用GitHub Actions 的CI/CD示例
name: CI Pipeline
on: [push, pull_request]
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- name: Set up JDK 11
uses: actions/setup-java@v2
with:
java-version: '11'
- name: Build with Maven
run: mvn clean install
每当你向代码仓库推送新代码或提交创建拉取请求时,该管道就会自动触发启动。它会执行一系列操作,首先签出你提交的代码,接着配置好所需的Java开发环境,然后运行Maven构建流程,从而确保整个项目能够正常运行。
11.最佳实践与高级用例
以下最佳实践确保了代码的可维护性和可扩展性。
最佳实践
- 避免循环依赖:在设计和构建应用程序的各个模块时,须确保模块之间没有循环引用,以避免构建问题(例如编译错误、无限循环的依赖解析等)。
- 明确职责分离:在模块化设计中,每个模块应当被赋予并专注于单一的、明确的职责。
- 集中配置:集中管理配置信息,提高系统的可管理性和一致性,确保整个系统在不同环境下都能保持一致的行为和表现。
高级用例 - 使用Kafka进行异步消息传递:Kafka作为分布式消息平台,实现了服务之间的解耦通信,并允许模块可以异步地发布和订阅事件。
- 使用Feign作为REST客户端:使用Feign在模块内部调用服务。定义一个Feign客户端接口用于通信。
- 性能缓存:在服务模块中使用Spring Cache来优化数据检索。
12.结论与关键要点
多模块Spring Boot项目具有诸多优势,它提供了模块化、可扩展性和易于维护的特点。
在本教程中,你学习了如何设置模块、管理模块间通信、处理异常情况、增强系统安全性,并利用Docker技术实现部署。
遵循最佳实践,结合消息传递和缓存等高级技术手段,将进一步优化你的多模块架构,确保其更加稳健、高效,完美适配生产环境需求。
译者介绍
刘涛,51CTO社区编辑,某大型央企系统上线检测管控负责人。
原文标题:How to Build Multi-Module Projects in Spring Boot for Scalable Microservices,作者:Birks Sachdev