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

封面: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 三套地址,切换环境靠注释,迟早把测试库地址带上生产。拆分的目的是让环境差异显式化。

按环境拆成四个文件:

text
application.yml            # 公共配置(数据源结构、通用参数)
application-dev.yml        # 开发环境
application-test.yml       # 测试环境
application-prod.yml       # 生产环境

拆分原则:环境间有差异的配置,比如数据源、端口、第三方地址,下沉到环境文件;公共行为,比如线程池大小、业务开关,留在主文件。配置项按数据源、服务端口、第三方组件、自定义业务参数分组。

预期结果:启动时用 --spring.profiles.active=prod 指定环境,生产包不会误连测试库。

二、依赖治理:把版本冲突关在编译期 ​

1. 用依赖树定位冲突 ​

第三方库一多,传递依赖很容易把同一个 jar 的多个版本都拖进来。编译期相安无事,运行期随机炸出 ClassNotFoundException 或 NoSuchMethodError。这类问题最难的地方是复现不稳定,报错还指不到根因。

排查第一步,是把冲突来源揪出来:

bash
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 才会级联:

java
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:

java
@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}:

xml
<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> 收敛出参 ​

出参格式五花八门,前端每接一个接口写一套解析,联调成本全耗在这上面。统一响应体就是把出参收敛成一种结构。

java
@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 拦截所有异常,做三件事:分级、转码、脱敏。

java
@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:请求规范链路,统一响应、参数校验、全局异常在入口收口

图 1:请求规范链路,统一响应、参数校验、全局异常在入口收口

3. 六个环节的速查表 ​

阶段坑一句话解法
依赖治理传递依赖版本冲突,运行期 ClassNotFounddependency:tree 定位 + 父 POM 统一版本 + exclusion 收敛
持久层MyBatis / MyBatis-Plus 配置打架先定选型,数据源、扫描路径、插件顺序收拢到一处
参数校验@Valid 加了不生效嵌套对象补 @Valid、校验注解用对包、方法级校验加类级 @Validated
链路追踪日志上下文丢失Filter 生成 traceId 放 MDC,pattern 用 %X{traceId},线程池要透传
响应规范出参格式五花八门Result<T> 统一响应体 + 请求基类
异常处理堆栈直接甩给前端@RestControllerAdvice 分级拦截 + 错误码标准化 + 兜底脱敏

常见错误与排错 ​

  1. 现象:本地启动正常,测试环境一启动就报 NoSuchMethodError。原因:传递依赖带进了同一个 jar 的旧版本,编译期不校验运行期方法签名。解法:跑 mvn dependency:tree -Dincludes=... 找到多条版本,父 POM 锁版本,冲突依赖加 exclusion。

  2. 现象:DTO 里的 items 传空对象也能过校验。原因:外层 @Validated 不会级联到嵌套对象。解法:嵌套字段补 @Valid,再确认注解包名是 jakarta.validation.constraints。

  3. 现象:两个不相干请求的日志里出现同一个 traceId。原因:Filter 里 MDC.clear() 漏在 finally 之外,线程复用后残留。解法:清理放进 finally,异步任务提交前 copy MDC。

  4. 现象:接口异常时前端收到整段堆栈。原因:只处理了业务异常,没有 Exception 兜底。解法:@RestControllerAdvice 加兜底方法,日志用 log.error 记全量,对外返回 SYSTEM_ERROR。

本篇小结 ​

  • .gitignore 和多环境配置要在第一次提交前定好,后面再补成本翻倍。
  • 依赖冲突用 dependency:tree 定位,父 POM 统一版本,子模块不写版本号。
  • 嵌套校验补 @Valid,方法级校验加类级 @Validated,两者缺一不可。
  • traceId 进 MDC 要配 finally 清理,异步链路要显式透传。
  • 统一响应和全局异常是入口的两道收口,业务代码只抛 BizException。