Spring Boot 项目从零到一:架构搭建规范与踩坑清单

新工程脚手架拉下来,CRUD 半小时就能跑通。可上线第一周常常连出三件事:依赖冲突报
ClassNotFoundException,参数校验静默失效,线上日志串不成一条链路。这篇把基础架构搭建期的规范和坑一次讲清。
本篇你能学到
- 用
.gitignore和多环境application.yml把工程初始化做干净 - 用
dependency:tree定位版本冲突,在父 POM 统一依赖版本 - 让
@Valid的嵌套校验和方法级校验真正生效 - 用 MDC 打通 traceId,把一次请求的日志串成一条线
- 用
Result<T>和@RestControllerAdvice统一出参与异常出口
一、工程初始化:从 .gitignore 到多环境配置
1. 先把 .gitignore 写严
工程初始化最容易省的一步就是 .gitignore。省掉之后,每个人 IDE 配置不同,提交进仓库就是天天制造无意义冲突,构建产物混进代码库还会带来二进制冲突。
做法是精准过滤三类文件:
- IDE 文件:
.idea/、*.iml、.vscode/,各自本地保留即可; - 构建产物:
target/、*.class、*.jar,应该由 CI 产出; - 系统临时文件:
.DS_Store、*.log、*.tmp。
预期结果:git status 里只剩源码和配置,第一次提交就是干净的。
2. application.yml 按环境拆分
一份 yml 塞满 dev、test、prod 三套地址,切换环境靠注释,迟早把测试库地址带上生产。拆分的目的是让环境差异显式化。
按环境拆成四个文件:
application.yml # 公共配置(数据源结构、通用参数)
application-dev.yml # 开发环境
application-test.yml # 测试环境
application-prod.yml # 生产环境拆分原则:环境间有差异的配置,比如数据源、端口、第三方地址,下沉到环境文件;公共行为,比如线程池大小、业务开关,留在主文件。配置项按数据源、服务端口、第三方组件、自定义业务参数分组。
预期结果:启动时用 --spring.profiles.active=prod 指定环境,生产包不会误连测试库。
二、依赖治理:把版本冲突关在编译期
1. 用依赖树定位冲突
第三方库一多,传递依赖很容易把同一个 jar 的多个版本都拖进来。编译期相安无事,运行期随机炸出 ClassNotFoundException 或 NoSuchMethodError。这类问题最难的地方是复现不稳定,报错还指不到根因。
排查第一步,是把冲突来源揪出来:
mvn dependency:tree -Dincludes=com.fasterxml.jackson.core:jackson-databind看到同一个坐标出现多个版本,治理分两步走。父 POM 用 <dependencyManagement> 统一声明版本,子模块不写版本号;对冲突的传递依赖用 <exclusions> 收敛,保证同一种依赖全局只有一个版本。
预期结果:依赖树里每个坐标只剩一个版本,运行期不再出现 NoSuchMethodError。
2. MyBatis 与 MyBatis-Plus 只留一个主力
两者同时引入时,SqlSessionFactory、MapperScannerConfigurer、自动配置的加载顺序容易互相干扰。典型症状是 Mapper 扫描不到,或者插件链失效,报错信息还指不到根因。
处理思路是先做选型决策。新项目优先 MyBatis-Plus,自带通用 CRUD、分页插件、代码生成;除非有强烈理由保留裸 MyBatis。
选型定下来后,把数据源、@MapperScan 扫描路径、插件加载顺序统一收进一个持久层配置类。多处配置各写一套,才是互相打架的根源。
预期结果:启动日志里 Mapper 数量与预期一致,分页插件生效,不再出现扫描不到 Mapper 的告警。
三、参数校验:注解加了为什么不生效
1. 三种典型失效写法
Spring Validation 最高频的问题就是注解加了、校验没跑。原因通常落在三个地方,逐个排。
第一,嵌套对象漏了 @Valid。Controller 入参上的 @Validated 只校验 DTO 第一层字段,DTO 里的嵌套对象必须再加 @Valid 才会级联:
public class OrderRequest {
@NotBlank
private String orderNo;
@Valid // 没有这个,items 里面的校验全部失效
private List<OrderItem> items;
}第二,校验注解 import 错包。应该用 javax.validation.constraints.*,Jakarta EE 9+ 则是 jakarta.validation.constraints.*,而不是 Hibernate Validator 的旧包。
第三,方法级校验需要类上的 @Validated。只给 Controller 加 @Valid 对普通方法入参不生效,类上开启之后,方法参数上的约束才会被拦截。
2. 失败之后由谁接住
校验不通过抛的是 MethodArgumentNotValidException。没有统一出口,前端拿到的就是框架默认的一坨堆栈,字段提示也丢了。
做法是在全局异常处理器里接住这个异常,拼出字段名和提示,完整代码见第五节。错误码统一成 PARAM_ERROR,前端按码做提示,不依赖文案。
预期结果:参数错误返回 400 语义加具体字段提示,不再出现整段堆栈。
四、日志链路追踪:MDC + traceId
1. Filter 里生成并注入 traceId
一次请求跨多层调用后,日志散落各处,排查时不知道哪几行属于同一个请求。给每个请求发一个 traceId,写进日志上下文,是成本最低的解法。
第一步,在 Filter 里生成 traceId 并放进 MDC:
@Component
public class TraceIdFilter extends OncePerRequestFilter {
@Override
protected void doFilterInternal(HttpServletRequest request,
HttpServletResponse response,
FilterChain chain) throws ServletException, IOException {
String traceId = request.getHeader("X-Trace-Id"); // 优先接上游传递的
if (StringUtils.isBlank(traceId)) {
traceId = UUID.randomUUID().toString().replace("-", "");
}
MDC.put("traceId", traceId);
response.setHeader("X-Trace-Id", traceId); // 回传给调用方
try {
chain.doFilter(request, response);
} finally {
MDC.clear(); // 线程复用,必须清理,否则 traceId 会"串"到下一个请求
}
}
}这里有三个点要抄走。优先读上游传来的 X-Trace-Id,没有才自己生成;MDC.put 之后整条调用链的日志都带得上;finally 里必须 MDC.clear(),Tomcat 线程是复用的,不清理会让下一个请求背上你的 traceId。
2. 日志 pattern 加占位符
traceId 放进 MDC 只是第一步,日志格式里还得把它打出来,否则 MDC 里存着也没人看。
在 logback 的 pattern 里加 %X{traceId}:
<pattern>%d{yyyy-MM-dd HH:mm:ss} [%thread] [%X{traceId}] %-5level %logger{36} - %msg%n</pattern>异步和线程池场景还要多一步。MDC 基于 ThreadLocal,切线程就丢,需要包装 Runnable,提交前 copy、执行时 set、结束后 clear,或者直接用装饰过的线程池。
预期结果:grep 一个 traceId 就能拿到这次请求的全部日志,定位从翻日志变成秒级检索。
五、统一响应与全局异常
1. Result<T> 收敛出参
出参格式五花八门,前端每接一个接口写一套解析,联调成本全耗在这上面。统一响应体就是把出参收敛成一种结构。
@Data
public class Result<T> {
private String code; // 业务错误码,成功固定 "0000" 之类
private String message; // 给人看的提示
private T data; // 业务数据
public static <T> Result<T> success(T data) {
Result<T> r = new Result<>();
r.code = "0000";
r.message = "success";
r.data = data;
return r;
}
public static <T> Result<T> fail(ErrorCode errorCode) {
Result<T> r = new Result<>();
r.code = errorCode.getCode();
r.message = errorCode.getMessage();
return r;
}
}成功码固定 0000,失败一律走 ErrorCode 枚举。再配一个请求基类收公共分页参数,入参出参都标准化。
预期结果:前端只处理一种响应结构,联调时不用再问这个接口返回什么格式。
2. 全局异常分级、转码、脱敏
异常不统一处理,业务代码里到处都是 try-catch,错误码散落各处,堆栈还可能直接甩给前端。用 @RestControllerAdvice 拦截所有异常,做三件事:分级、转码、脱敏。
@Slf4j
@RestControllerAdvice
public class GlobalExceptionHandler {
/** 参数校验失败:返回 400 语义 + 具体字段提示 */
@ExceptionHandler(MethodArgumentNotValidException.class)
public Result<Void> handleValidException(MethodArgumentNotValidException e) {
String msg = e.getBindingResult().getFieldErrors().stream()
.map(fe -> fe.getField() + " " + fe.getDefaultMessage())
.collect(Collectors.joining("; "));
return Result.fail(ErrorCode.PARAM_ERROR, msg);
}
/** 业务异常:按自定义错误码返回 */
@ExceptionHandler(BizException.class)
public Result<Void> handleBizException(BizException e) {
log.warn("业务异常: code={}, msg={}", e.getCode(), e.getMessage());
return Result.fail(e.getErrorCode());
}
/** 未知异常:兜底,日志记全量堆栈,对外只返回模糊提示(不泄露堆栈细节) */
@ExceptionHandler(Exception.class)
public Result<Void> handleException(Exception e) {
log.error("系统异常", e);
return Result.fail(ErrorCode.SYSTEM_ERROR);
}
}业务代码里只抛 BizException(ErrorCode.XXX),异常到错误码的映射全部收在处理器一处。未知异常兜底返回 SYSTEM_ERROR,堆栈只进日志,不出网关。
预期结果:前端拿到的是错误码和友好提示,日志里留着可排查的全量堆栈。把参数校验、统一响应、全局异常串起来看,就是下面这张图。
配图

图 1:请求规范链路,统一响应、参数校验、全局异常在入口收口
3. 六个环节的速查表
| 阶段 | 坑 | 一句话解法 |
|---|---|---|
| 依赖治理 | 传递依赖版本冲突,运行期 ClassNotFound | dependency:tree 定位 + 父 POM 统一版本 + exclusion 收敛 |
| 持久层 | MyBatis / MyBatis-Plus 配置打架 | 先定选型,数据源、扫描路径、插件顺序收拢到一处 |
| 参数校验 | @Valid 加了不生效 | 嵌套对象补 @Valid、校验注解用对包、方法级校验加类级 @Validated |
| 链路追踪 | 日志上下文丢失 | Filter 生成 traceId 放 MDC,pattern 用 %X{traceId},线程池要透传 |
| 响应规范 | 出参格式五花八门 | Result<T> 统一响应体 + 请求基类 |
| 异常处理 | 堆栈直接甩给前端 | @RestControllerAdvice 分级拦截 + 错误码标准化 + 兜底脱敏 |
常见错误与排错
现象:本地启动正常,测试环境一启动就报
NoSuchMethodError。原因:传递依赖带进了同一个 jar 的旧版本,编译期不校验运行期方法签名。解法:跑mvn dependency:tree -Dincludes=...找到多条版本,父 POM 锁版本,冲突依赖加 exclusion。现象:DTO 里的
items传空对象也能过校验。原因:外层@Validated不会级联到嵌套对象。解法:嵌套字段补@Valid,再确认注解包名是jakarta.validation.constraints。现象:两个不相干请求的日志里出现同一个 traceId。原因:Filter 里
MDC.clear()漏在finally之外,线程复用后残留。解法:清理放进finally,异步任务提交前 copy MDC。现象:接口异常时前端收到整段堆栈。原因:只处理了业务异常,没有
Exception兜底。解法:@RestControllerAdvice加兜底方法,日志用log.error记全量,对外返回SYSTEM_ERROR。
本篇小结
.gitignore和多环境配置要在第一次提交前定好,后面再补成本翻倍。- 依赖冲突用
dependency:tree定位,父 POM 统一版本,子模块不写版本号。 - 嵌套校验补
@Valid,方法级校验加类级@Validated,两者缺一不可。 - traceId 进 MDC 要配
finally清理,异步链路要显式透传。 - 统一响应和全局异常是入口的两道收口,业务代码只抛
BizException。