从0到1:构建SpringBoot项目脚手架的设计思路与实战
以我的开源项目 springboot-project-seed 为例,聊聊如何将重复劳动转化为可复用的架构资产。
开篇:一个后端开发的日常困境
相信每一位后端开发都有过这样的经历:
接手一个新项目,从零开始搭建工程。选依赖、配框架、写拦截器、封装响应结构、处理异常……一套流程走下来,少则半天,多则一两天。更让人头疼的是,团队里每个人搭建的风格还不一样——有人用Lombok,有人不用;有人统一异常处理,有人直接在Controller里try-catch;有人规范了响应格式,有人随心所欲。
结果就是:代码风格千差万别,维护成本居高不下,新人上手一脸茫然。
我自己就深有体会。在一个大型J2EE项目里,后端清一色用Spring Boot,但每个人写出来的代码却像是出自不同的团队。这显然不是技术能力的问题,而是缺少一套统一的规范和可复用的基础框架。
于是,我决定做一个项目种子(Seed Project)——把那些重复性的、共性的工作抽取出来,封装成一个可复用的脚手架。以后每开一个新项目,直接基于这个种子迭代,只关注业务逻辑就好。
这就是 springboot-project-seed 的由来。
今天这篇文章,我会带着你从0到1,完整复盘这个脚手架的设计思路和实现细节。它已经在GitHub上开源,收获了21颗星和14个fork,希望对你也有帮助。
第一部分:先想清楚,我们要做什么
在动手写代码之前,先明确目标。
这个脚手架要解决什么问题?
- 统一技术栈:规定好Spring Boot版本、JDK版本、依赖管理工具,避免各搞一套。
- 统一代码风格:封装通用的响应结构、异常处理、日志切面,让所有API的行为保持一致。
- 提升开发效率:把那些"每个项目都要写一遍"的基础设施代码抽出来,开箱即用。
- 沉淀最佳实践:把日常开发中积累的实用功能(文件分片上传、WebSocket推送、Kafka消费者管理等)固化到项目中。
技术选型一览
| 组件 | 选型 | 理由 |
|---|---|---|
| 核心框架 | Spring Boot 3.0.1 | 相比2.x系列,性能有显著提升,且支持JDK17 |
| JDK | OpenJDK 17 | LTS版本,长期维护,Spring Boot 3.x强制要求 |
| 构建工具 | Maven / Gradle | 双选支持,满足不同习惯 |
| 数据库 | PostgreSQL 15 | 功能强大的开源关系型数据库,同时支持MySQL切换 |
| 消息队列 | Kafka 2.12-2.5.1 | 高吞吐、分布式,适合大规模消息场景 |
| ORM | MyBatis / JPA | 灵活切换,项目中已配置好模板 |
项目结构一览
springboot-project-seed/
├── src/main/java/com/kyyee/
│ ├── Application.java # 启动类
│ ├── common/ # 通用模块
│ │ ├── config/ # 配置类(WebSocket、Kafka、数据源等)
│ │ ├── enums/ # 枚举(错误码等)
│ │ ├── exception/ # 自定义异常 & 全局异常处理器
│ │ ├── interceptor/ # MVC拦截器
│ │ ├── aspect/ # AOP切面(日志等)
│ │ └── utils/ # 工具类
│ ├── controller/ # 控制器层
│ ├── service/ # 业务逻辑层
│ ├── dao/ # 数据访问层
│ └── model/ # 实体类 & DTO
├── src/main/resources/
│ ├── application.yml # 主配置文件
│ ├── application-dev.yml # 开发环境配置
│ └── application-prod.yml # 生产环境配置
├── docker/Dockerfile # Docker构建脚本
└── pom.xml / build.gradle # 依赖管理
结构清晰,分层明确。下面我们用一张图来展示整个脚手架的模块关系:
接下来,我们深入到每一个核心模块,看看具体是怎么实现的。
第二部分:基石——统一响应与异常处理
这是每个Web后端项目最基础也最重要的两个部分。如果连API的返回格式都不统一,前后端联调就是一场灾难。
2.1 统一HTTP响应结构
我们先定义一个通用的响应类 Res<T>,所有的API接口都返回这个格式:
@Data
public final class Res<T> {
private static final String SUCCESS_CODE = "0000000000";
private String code = SUCCESS_CODE;
private String msg = "请求成功";
private T data;
// 私有构造方法,强制使用静态工厂方法创建实例
private Res() {}
public static Res<Object> success() {
return new Res<>();
}
public static <T> Res<T> success(T data) {
return new Res<>(data);
}
public static Res<Object> error(String code, String msg) {
return new Res<>(code, msg);
}
public static Res<Object> error(IErrorCode error) {
return new Res<>(error.getCode(), error.getMsg());
}
@JsonIgnore
public boolean isSuccess() {
return SUCCESS_CODE.equals(code);
}
}
设计要点:
- 使用泛型,让
data字段可以承载任意类型的数据。 - 提供
success()和error()静态工厂方法,语义清晰。 - 通过
@JsonIgnore让isSuccess()不在JSON序列化时输出,仅用于程序判断。
对应的错误码枚举 BaseErrorCode:
public enum BaseErrorCode implements IErrorCode {
SUCCESS("0000000000", "操作成功"),
SYS_INTERNAL_ERROR("9999999999", "系统内部错误"),
INVALID_PARAM_ERROR("A000000001", "参数校验错误"),
API_NOT_EXIST_ERROR("A000000002", "请求的API不存在"),
HTTP_REQUEST_METHOD_NOT_SUPPORTED_ERROR("A000000003", "HTTP请求方法不支持"),
FILE_SIZE_ERROR("A000000004", "文件大小超出限制"),
CALL_FAILED("A000000005", "远程调用失败"),
SQL_EXCEPTION("A000000006", "SQL执行异常");
// ...
}
有了统一的响应结构,前端只需要按照 code 判断成功或失败,然后取 data 展示即可。
2.2 全局异常处理——让Controller层告别try-catch
如果每个Controller方法里都写一堆try-catch,代码会变得臃肿不堪。利用Spring Boot的 @ControllerAdvice,我们可以将异常处理统一收口。
下图展示了全局异常处理的工作流程:
核心实现:GlobalExceptionHandler.java
@ControllerAdvice
@Slf4j
public class GlobalExceptionHandler {
// 处理参数校验异常(@Valid校验失败)
@ExceptionHandler(MethodArgumentNotValidException.class)
@ResponseBody
public ResponseEntity<Object> methodArgumentNotValidException(MethodArgumentNotValidException e) {
log.error("Params valid exception, URI={}", SessionHelper.getRequest().getRequestURI(), e);
// 将多个字段的校验错误拼接成一条消息
String message = buildValidationMessage(e.getBindingResult());
final Res<Object> res = Res.error(BaseErrorCode.INVALID_PARAM_ERROR.getCode(), message);
return new ResponseEntity<>(res, HttpStatus.BAD_REQUEST);
}
// 处理业务自定义异常
@ExceptionHandler(BaseException.class)
@ResponseBody
public ResponseEntity<Object> baseException(BaseException e) {
log.error("Business exception, message={}", e.getMessage(), e);
final Res<Object> res = Res.error(e.getCode(), e.getMessage());
return new ResponseEntity<>(res, HttpStatus.INTERNAL_SERVER_ERROR);
}
// 处理所有未捕获的异常——最后的兜底
@ExceptionHandler(Exception.class)
@ResponseBody
public ResponseEntity<Object> globalHandle(Exception e) {
log.error("Unhandled exception", e);
final Res<Object> res = Res.of(BaseErrorCode.SYS_INTERNAL_ERROR);
return new ResponseEntity<>(res, HttpStatus.INTERNAL_SERVER_ERROR);
}
}
这样做的好处:
- Controller层极其干净——只需要专注业务逻辑,不需要关心异常处理。
- 用户体验一致——所有异常最终都以统一的JSON格式返回。
- 便于监控——所有异常日志都集中打印,方便排查问题。
举个例子,现在在Controller里写一个接口:
@PostMapping("/user")
public Res<UserVO> createUser(@Valid @RequestBody UserCreateDTO dto) {
UserVO user = userService.create(dto);
return Res.success(user);
}
如果 dto 校验失败(比如用户名不能为空),框架会自动抛出 MethodArgumentNotValidException,然后被 GlobalExceptionHandler 捕获,返回:
{
"code": "A000000001",
"msg": "用户名不能为空;邮箱格式不正确",
"data": null
}
不需要在Controller里写任何异常处理的代码。
第三部分:提升——日志切面与用户上下文
有了基础响应和异常处理,项目已经"能跑"了。但要让它"好用",还需要一些提升开发体验的组件。
3.1 请求日志切面——让每个接口的调用链路一目了然
生产环境排查问题时,最怕的就是"这个接口什么时候被调用的?传参是什么?返回了什么?"——没有日志,一切都靠猜。
我们通过AOP统一记录所有Controller的请求和响应,整个流程如下:
@Aspect
@Component
@Slf4j
public class RequestAspect {
// 匹配所有Controller中的RequestMapping方法
@Pointcut("execution(* com.kyyee..controller..*.*(..))")
private void request() {}
@Around("request()")
public Object doAround(ProceedingJoinPoint joinPoint) throws Throwable {
ServletRequestAttributes attributes = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
HttpServletRequest request = attributes.getRequest();
String requestURI = request.getRequestURI();
// 打印请求入参
log.info("REQUEST {} : {}", requestURI, JSON.toString(joinPoint.getArgs()));
long startTime = System.currentTimeMillis();
try {
Object result = joinPoint.proceed();
// 打印响应结果和耗时
log.info("RESPONSE {} : {}ms, data: {}", requestURI, System.currentTimeMillis() - startTime, result);
return result;
} catch (Throwable e) {
log.error("REQUEST {} failed, args: {}", requestURI, JSON.toString(joinPoint.getArgs()), e);
throw e;
}
}
}
效果:每次接口调用,控制台会清晰地打印出请求参数、响应数据、耗时,排查问题效率大大提升。
3.2 用户上下文传递——ThreadLocal的妙用
在微服务或前后端分离项目中,通常会将用户信息放在Header中传递(如 username:admin&usercode:admin)。我们需要在请求链路中随时获取当前用户信息,而不是在每个方法里层层传参。
解决方案:利用 HandlerInterceptor + ThreadLocal,其工作原理如下:
第一步:拦截器解析Header:
public class BaseHeaderInterceptor extends HandlerInterceptorAdapter {
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) {
String user = request.getHeader(GlobalConstant.USER);
if (StringUtils.hasText(user)) {
// 解析Header中的用户信息
User userInfo = parseUserInfo(user);
// 存入ThreadLocal
UserHandler.setUser(userInfo);
}
return true; // 继续向下传递
}
@Override
public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) {
// 请求结束后清除,防止内存泄漏
UserHandler.remove();
}
}
第二步:在业务代码中随时获取用户信息:
@Service
public class OrderService {
public OrderVO createOrder(OrderCreateDTO dto) {
User currentUser = UserHandler.getUser();
// 使用 currentUser.getUserCode() 进行业务处理
}
}
核心原理:ThreadLocal 为每个线程维护一份独立的变量副本,请求到达时通过拦截器注入用户信息,请求结束时清除。这样在整个请求链路中(Controller → Service → DAO)都可以随时获取用户信息,无需显式传参。
第四部分:进阶——异步处理与定时任务
现代后端系统离不开异步和定时任务。来看看脚手架里是怎么做的。
4.1 异步任务——@Async让方法"飞"起来
Spring Boot 使用 @Async 注解非常方便,只需要两步:
第一步:开启异步支持:
@Configuration
@EnableAsync
public class AsyncConfig implements AsyncConfigurer {
@Override
public Executor getAsyncExecutor() {
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
executor.setCorePoolSize(10);
executor.setMaxPoolSize(50);
executor.setQueueCapacity(100);
executor.initialize();
return executor;
}
}
第二步:在方法上添加 @Async 注解:
@Service
public class NotificationService {
@Async
public void sendNotification(String message) {
// 耗时操作,比如发送邮件、推送WebSocket消息
// 这个方法被调用时会立即返回,在后台线程池中执行
}
}
异步任务的执行流程:
实际应用场景:用户下单成功后,异步发送订单通知、推送WebSocket消息,不影响主流程的响应时间。
4.2 定时任务——一个实用的Kafka消费者重启检测
定时任务最常见的用法是定期执行数据统计、缓存刷新等。这里分享一个实战中遇到的场景:Kafka消费者偶尔会掉线,需要定时检测并自动重启。
@Component
@Slf4j
public class KafkaConsumerRestartTask {
@Resource
private KafkaAdmin kafkaAdmin;
@Resource
private KafkaListenerEndpointRegistry endpointRegistry;
// 每隔5分钟执行一次检测
@Scheduled(cron = "${kyyee.config.kafka.container.restart-corn:0 0/5 * * * ?}")
public void consumerRestart() {
try (AdminClient client = createAdminClient()) {
// 1. 获取当前消费者组订阅的topic列表
Set<String> assignedTopics = getAssignedTopics(client);
// 2. 获取所有业务topic
List<String> allBusinessTopics = getBusinessTopics();
// 3. 找出那些没有被订阅的topic(说明消费者掉线了)
List<String> unassignedTopics = allBusinessTopics.stream()
.filter(t -> !assignedTopics.contains(t))
.collect(Collectors.toList());
if (unassignedTopics.isEmpty()) {
return;
}
// 4. 找到监听这些topic的Kafka容器,重启它们
for (MessageListenerContainer container : endpointRegistry.getAllListenerContainers()) {
if (container.getContainerProperties().getTopics().containsAny(unassignedTopics)) {
container.stop();
container.start();
log.info("Restarted Kafka consumer for topics: {}", unassignedTopics);
}
}
} catch (Exception e) {
log.error("Kafka consumer restart failed", e);
}
}
}
价值:在生产环境中,Kafka消费者可能因为网络抖动、Rebalance超时等原因掉线。这个定时任务就像一个"守护进程",自动检测并恢复,大大减少了运维的人工干预。
第五部分:实战功能——文件分片上传与WebSocket
这两个功能是实际业务中非常常见的需求,脚手架里都给出了完整的示例实现。
5.1 文件分片上传
大文件上传如果一次性读取到内存,很容易导致OOM。分片上传的思路是:前端将文件切成多个小块,后端逐块接收并保存,最后再合并。
在 FileServiceImpl.java 中,核心逻辑如下:
@Service
public class FileServiceImpl implements FileService {
private static final String UPLOAD_DIR = "/data/uploads/";
@Override
public void uploadChunk(String fileId, int chunkIndex, int totalChunks, MultipartFile chunk) {
String chunkDir = UPLOAD_DIR + fileId + "/";
File dir = new File(chunkDir);
if (!dir.exists()) {
dir.mkdirs();
}
// 每个分片单独保存为一个临时文件
chunk.transferTo(new File(chunkDir + chunkIndex));
// 记录分片上传进度到Redis或数据库
}
@Override
public String mergeChunks(String fileId, String fileName) {
String chunkDir = UPLOAD_DIR + fileId + "/";
String targetPath = UPLOAD_DIR + fileName;
// 按顺序读取所有分片,合并成一个文件
try (FileOutputStream fos = new FileOutputStream(targetPath)) {
List<File> chunks = Arrays.stream(new File(chunkDir).listFiles())
.sorted(Comparator.comparingInt(f -> Integer.parseInt(f.getName())))
.collect(Collectors.toList());
for (File chunk : chunks) {
Files.copy(chunk.toPath(), fos);
}
// 清理临时分片文件
FileUtils.deleteDirectory(new File(chunkDir));
} catch (IOException e) {
throw new BaseException("文件合并失败", e);
}
return targetPath;
}
}
配套前端:项目中的 file.js 和 file.html 提供了完整的前端示例,展示了如何使用 Blob.slice() 进行文件切片,并并发上传。
5.2 WebSocket消息推送
WebSocket在实时通知、聊天、数据推送等场景中非常实用。脚手架里配置了完整的WebSocket支持,其消息流转过程如下:
配置类:
@Configuration
@EnableWebSocketMessageBroker
public class WebSocketConfiguration implements WebSocketMessageBrokerConfigurer {
@Override
public void registerStompEndpoints(StompEndpointRegistry registry) {
registry.addEndpoint("/websocket") // WebSocket连接端点
.setAllowedOriginPatterns("*")
.withSockJS(); // 支持SockJS降级方案
}
@Override
public void configureMessageBroker(MessageBrokerRegistry registry) {
registry.enableSimpleBroker("/topic"); // 消息推送前缀
registry.setApplicationDestinationPrefixes("/app");
}
}
消息发送器:
@Component
public class WebSocketSender {
@Resource
private SimpMessagingTemplate messagingTemplate;
public void sendMessageToAll(String destination, Object message) {
// 向所有订阅了 /topic/xxx 的客户端推送消息
messagingTemplate.convertAndSend(destination, message);
}
public void sendMessageToUser(String userId, String destination, Object message) {
// 向特定用户推送消息
messagingTemplate.convertAndSendToUser(userId, destination, message);
}
}
使用示例:在订单状态变更时,主动向客户端推送通知。
@Service
public class OrderService {
@Resource
private WebSocketSender webSocketSender;
public void updateOrderStatus(String orderId, String status) {
// 更新订单状态...
// 推送消息给前端
webSocketSender.sendMessageToAll("/topic/order/" + orderId,
Map.of("orderId", orderId, "status", status));
}
}
第六部分:部署与运维——Docker化支持
项目不光要能开发,还要能方便地部署。脚手架中包含了 Dockerfile,让你可以快速构建镜像并容器化运行。
FROM openjdk:17-jdk-slim AS builder
WORKDIR /app
COPY . .
RUN ./mvnw clean package -DskipTests
FROM openjdk:17-jdk-slim
WORKDIR /app
COPY --from=builder /app/target/*.jar app.jar
EXPOSE 8080
ENTRYPOINT ["java", "-jar", "app.jar"]
构建和运行:
# 构建镜像
docker build -t springboot-project-seed:latest .
# 运行容器
docker run -p 8080:8080 springboot-project-seed:latest
配合 application-prod.yml 中的生产环境配置(如数据库连接、Kafka地址),可以实现环境隔离,一套代码在不同环境跑。
第七部分:如何使用这个脚手架
如果你觉得这个项目对你有帮助,想在自己的项目中使用,步骤非常简单:
-
克隆项目:
git clone https://github.com/kyyee/springboot-project-seed.git -
用IDEA打开,选择
pom.xml或build.gradle,Maven/Gradle会自动下载依赖。 -
修改配置:在
application-dev.yml中配置你的PostgreSQL连接信息和Kafka地址。 -
启动应用:运行
Application.java中的main方法。 -
验证:访问
http://localhost:8080,可以看到示例接口的返回。 -
开始你的业务开发:在
controller、service、dao包下添加你自己的业务代码。
需要定制的话:
- 想换数据库?修改
application.yml中的spring.profiles.active为mysql,再调整连接配置即可。 - 不需要Kafka?在
pom.xml中移除相关依赖,删除Kafka相关的配置类就好。
总结与感悟
回顾一下我们做了什么
从0到1,我们构建了一个基于Spring Boot 3.x的项目脚手架,它包含:
| 模块 | 功能 |
|---|---|
| 统一响应 | 标准的 {code, msg, data} JSON格式 |
| 全局异常处理 | 统一的异常捕获、日志记录、友好提示 |
| 请求日志AOP | 自动记录入参、出参、耗时 |
| 用户上下文 | 通过ThreadLocal在请求链路中传递用户信息 |
| 异步任务 | 支持@Async,提升接口响应速度 |
| 定时任务 | 示例:Kafka消费者掉线自动恢复 |
| 文件分片上传 | 大文件分片接收、合并 |
| WebSocket推送 | 实时消息推送示例 |
| 多数据源 | 支持主从或不同业务库 |
| Docker化 | 一键构建镜像,方便部署 |
一些思考
做这个脚手架,最大的收获不是代码本身,而是「抽象」的能力。
当你第三次从零搭建一个Spring Boot项目时,你会开始思考:哪些东西是每个项目都需要的?哪些是通用的?哪些是可以配置的?把这些共性抽出来,封装成可复用的组件,其实就是架构设计的雏形。
正如我在README里写的:
"利用面向对象的思想,抽取这类Web后端api项目的共同之处封装成一个项目种子。以后再开发类似的项目,就能直接在这个项目种子上迭代,减少重复劳动。"
下一步的规划
- 升级到Spring Boot 3.2+:随着Spring Boot版本的迭代,持续跟进新特性。
- 集成Spring Cloud:让脚手架支持微服务架构。
- 完善单元测试:提供测试基类和示例,提升代码质量。
- 添加更多数据库示例:如MongoDB、Redis的集成。
写在最后
这个项目已经在GitHub上开源,如果你觉得对你有帮助,欢迎Star或Fork。如果在使用中发现问题,或者有好的建议,非常欢迎提 Issue 或 Pull Request,一起把它做得更好。
项目地址:https://github.com/kyyee/springboot-project-seed
我的个人博客:https://yanglei.ltd —— 我会在这里分享更多技术文章和思考,欢迎来逛逛。
生而为学,死亦无悔。愿我们在技术的道路上,不断精进,彼此成就。