本篇学习指南
读完你能
  • 分清 Controller / Service / Mapper 各自该写什么代码
  • 正确使用日志、事务、参数校验,不让代码「裸奔」
  • 避开新人最常见的代码坏味道(魔法值、吞异常、日志拼接)
阅读时长
约 12 分钟 · 对照自己代码边读边查
前置知识
先读 04-命名与接口;了解 Spring Boot
打个比方:三层架构
把后端代码想象成一家餐厅:Controller 是服务员——只负责「接待客人(接收请求)、传话、上菜(返回结果)」,不该自己炒菜;Service 是厨师——核心业务逻辑都在这,决定怎么做这道菜、用哪些食材;Mapper 是仓库管理员——只管「存取食材(读写数据库)」,不关心菜怎么做。服务员越俎代庖去炒菜、厨师自己跑去仓库翻东西,都是职责混乱——代码会越堆越乱。
概念入门
理解三层架构
Controller(控制器)
接客的前台。接收前端请求,校验参数,调用 Service,返回结果。不写业务逻辑。
一个请求的完整数据流
前端发起请求
POST /api/iot/driver-instance
DTO
↓ 携带 DTO
Controller
@Validated 校验 DTO → 调用 Service → 返回 Result
Result<T>
↓ 调用
ServiceImpl
业务逻辑 · 事务边界 · 调用 Mapper
@Transactional
↓ 调用
Mapper
执行 SQL · 返回 Entity 列表
Entity
↓ 查库
MySQL 数据库
iot_driver 表
DTO
入参(前端→后端)
Result
统一返回
@Transactional
事务
Entity
数据库实体
Service(服务)
干活的中台。定义业务接口,ServiceImpl 写具体实现。事务加在这一层。
Mapper(映射器)
查库的后台。负责和数据库交互,执行 SQL。
DTO / VO / Entity
为什么不直接用一个类?因为各层职责不同:DTO 是前端传来的入参,VO 是返回给前端的出参,Entity 是对应数据库表的实体。分开是为了各层解耦、安全隔离。
事务 Transaction
一组操作要么全成功、要么全回滚。就像转账——A 扣钱和 B 加钱必须同时成功或同时失败。
三层架构就像餐厅的分工:前台接单 → 后厨做菜 → 仓库取料
Controller 前台
接请求、校验参数、返回结果
↓ 调用
Service 后厨
写业务逻辑、管事务
↓ 调用
Mapper 仓库
查数据库、执行 SQL
1
分层与校验异常规范
3.5 Controller / Service / 校验 / 异常 分层规范

本节代码示例以 EasyPro 驱动模块(SaveDriverDTO / modbus-01)为样板,分层规范适用于所有产品。

分层职责:

层职责禁止
Controller参数接收与校验、调用 Service、组装 Result<T>写业务逻辑、直连 Mapper/DB
Service(接口)业务逻辑定义—
ServiceImpl业务逻辑实现、事务边界返回 Result<T>(那是 Controller 的活)
Mapper数据访问,单表/联表 SQL写业务判断
职责清晰(各司其职)
JAVA
// Controller:只接收+组装
@PostMapping
public Result<Long> save(@Validated @RequestBody SaveDTO dto) {
    return Result.ok(service.save(dto));
}
// ServiceImpl:业务+事务
@Transactional(rollbackFor = Exception.class)
public Long save(SaveDTO dto) { ... }
职责越界(面条代码)
JAVA
// Controller 里写业务+直连DB
@PostMapping
public Result save(@RequestBody SaveDTO dto) {
    if (dto.getName()==null) // 校验散落
        return Result.fail();
    mapper.insert(dto); // 跳层
    // 业务逻辑全堆这里...
}
Controller 越俎代庖=没法复用、没法加事务、没法统一校验

参数校验:

  • Controller 入参统一用 @Validated(支持方法级参数校验,功能超集)。
  • 实体类字段用 JSR-303 注解:@NotNull @NotBlank @Size @Min @Max @Pattern。
  • 校验失败由全局异常处理器统一兜底,Controller 内不写 if (xxx == null) 防御。
JAVA
// DTO
@Data
public class SaveDriverDTO {
    @Schema(description = "驱动名称", example = "modbus-01")
    @NotBlank(message = "驱动名称不能为空")
    private String name;

    @Schema(description = "端口", example = "502")
    @Min(value = 1, message = "端口非法")
    private Integer port;
}

// Controller
@PostMapping
public Result<Void> save(@Validated @RequestBody SaveDriverDTO dto) {
    driverService.save(dto);
    return Result.success();
}

异常处理(统一由全局异常处理器 GlobalExceptionHandler 兜底):

  • 业务异常:抛 ServiceException(或项目既有的业务异常基类),由全局处理器转 Result.fail(msg)。
  • 校验异常:MethodArgumentNotValidException / ConstraintViolationException 由全局处理器拦截,提取首个错误信息返回。
  • Controller 与 Service 禁止 try-catch 吞异常后返回 null/默认值;让异常向上传播由全局处理器统一处理。
  • 禁止用返回值表达"失败"(如返回 null 表示出错),改用异常。

为什么不让 try-catch 吞异常?「吞掉」异常后返回 null,调用方根本不知道出错了:一个查询默默返回空,前端显示空白,排查时无从下手。让异常抛出去,GlobalExceptionHandler 会统一转成带错误码的响应、同时记日志。出错就要让它当场抛出来,而不是悄悄返回假数据。

Controller 内禁止:

  • 直接 @Autowired Mapper 或 SqlSession。
  • 多个不相关业务方法堆在一个 Controller(按资源拆分)。
  • 在 Controller 拼 SQL 或写循环逻辑。
2
日志 / 事务 / 常量 / 注释
3.6 日志 / 事务 / 常量 / 注释 代码规范

日志(统一用 Lombok @Slf4j):

  • 类上加 @Slf4j,用 log.info()/log.error(),禁止手动 LoggerFactory.getLogger()。
  • 用占位符 {},禁止字符串拼接(性能与可读性):
JAVA
// [✓] 占位符
log.info("驱动实例启动成功, id={}, name={}", id, name);

// [✗] 字符串拼接
log.info("驱动实例启动成功, id=" + id + ", name=" + name);
  • 日志级别:error(异常堆栈)/ warn(可恢复异常)/ info(关键业务节点)/ debug(调试,生产关闭)。
  • 循环内禁止 info 级日志,用 debug 或聚合后打印。
  • 敏感信息脱敏:密码、token、加密狗 key、客户密钥等禁止明文打印;必要时用脱敏工具或 ***。

为什么循环内禁止 info 日志?一次批量同步循环一千次,就是一千条几乎相同的 info——日志文件被瞬间刷爆,真正关键的节点日志被淹没,磁盘和检索都遭殃。循环内的过程细节用 debug(生产默认关闭),或者循环结束后聚合打一条汇总(总数、成功数、失败数)。

事务:

  • @Transactional 加在 ServiceImpl 方法上(非 Controller、非 Service 接口)。
  • 默认 rollbackFor = Exception.class(否则只回滚 RuntimeException)。
  • 事务方法内禁止:远程调用(HTTP/RPC,会拉长事务持锁时间)、长时间循环、文件 IO。
  • 只读查询用 @Transactional(readOnly = true)。

为什么 @Transactional 必须加在 ServiceImpl,而不是 Controller?事务的本质是「占住一个数据库连接和一串行锁,直到提交」。Controller 加事务,会把参数校验、JSON 序列化这些与数据库无关的耗时也圈进事务里,连接被白白占住,高并发时连接池迅速耗尽。ServiceImpl 是业务动作的边界——一个事务对应一个完整业务动作,回滚粒度才正确;多个 Controller 复用同一业务方法时,也天然共享同一套事务语义。

常量与枚举:

  • 魔法值禁止硬编码,提取为 static final 常量或枚举:
JAVA
// [✗]
if (status == 1) { ... }

// [✓] 常量 / 枚举
if (Objects.equals(status, DriverStatus.ENABLED.getCode())) { ... }
  • 状态码、类型标识统一用枚举(<语义>Enum,实现带 code/desc 字段),禁止散落的 int 常量。
  • 字符串常量集中到各模块的 Constants 类,命名 UPPER_SNAKE_CASE。

注释与 JavaDoc:

  • 公共类、公共方法、抽象方法必须有 JavaDoc,含 @param @return @throws(参考现有 Result.java、SwaggerConfig.java 风格,含 @author 与创建时间)。
  • 注释解释 why(为什么这么做),而非 what(代码本身已表达)。
  • 临时调试代码(System.out.println、注释掉的代码块)禁止提交,由 §2.7 Hook 拦截。
  • TODO/FIXME 需带责任人:// TODO(zhangsan 2026-08): xxx。

Lombok 使用约定:

  • 实体/DTO/VO:@Data(含 getter/setter/toString)。
  • 链式调用场景:@Accessors(chain = true)(参考 Result.java)。
  • 构造器:@RequiredArgsConstructor 用于注入 final 依赖,替代 @Autowired 字段注入。
JAVA
@Service
@RequiredArgsConstructor
public class DriverServiceImpl implements DriverService {
    private final DriverMapper driverMapper;   // final + 构造器注入,优于 @Autowired
}

为什么推荐构造器注入,而不是 @Autowired 字段注入?字段注入把依赖「藏」在类内部:脱离 Spring 容器就没法构造这个类,单元测试只能靠反射塞依赖;这个类依赖了多少东西,也要翻遍整个类才知道。构造器注入的依赖是 final 的——不可变、构造时必须给齐、编译期就能发现缺依赖,类的依赖清单在构造器签名上一目了然。

新手常见错误
循环日志、事务失效、吞异常、魔法值:review 里见得最多的几类问题都在这里
我在 Controller 里直接调了 Mapper 查数据,省得写 Service,有什么不对?
跳过 Service 等于让服务员直接去仓库拿东西,短期省事,但业务逻辑散落在 Controller 里,没法复用、没法统一加事务。正确的链路是 Controller → Service → Mapper:Controller 只做参数接收和结果组装,业务逻辑放 ServiceImpl,数据库操作放 Mapper。检查清单第 8 项会查这个。
我日志写成了 log.info("用户" + name + "登录"),能用啊,为什么要改成占位符?
字符串拼接在日志级别被关闭时也会先拼好字符串(浪费性能),而且拼接复杂对象容易 NPE。用占位符 log.info("用户{}登录", name),关闭该级别时不会执行拼接,更快也更安全。
我写了 @Transactional 但没加 rollbackFor,报错时没回滚?
Spring 默认只对 RuntimeException 回滚,遇到受检异常(如 IOException)不会回滚,数据可能写了一半。必须显式写 @Transactional(rollbackFor = Exception.class),保证任何异常都回滚。
代码里我写了 if(status == 1),1 是什么意思我自己都忘了?
这就是魔法值——含义不明的裸数字。三个月后没人知道 1 代表「启用」还是「禁用」。提取成常量或枚举:if(status == StatusEnum.ENABLED.getCode()),一看就懂。检查清单最后一项会查魔法值。