代码规范
研发开发规范
- 分清 Controller / Service / Mapper 各自该写什么代码
- 正确使用日志、事务、参数校验,不让代码「裸奔」
- 避开新人最常见的代码坏味道(魔法值、吞异常、日志拼接)
- Controller(控制器)
- 接客的前台。接收前端请求,校验参数,调用 Service,返回结果。不写业务逻辑。
- Service(服务)
- 干活的中台。定义业务接口,ServiceImpl 写具体实现。事务加在这一层。
- Mapper(映射器)
- 查库的后台。负责和数据库交互,执行 SQL。
- DTO / VO / Entity
- 为什么不直接用一个类?因为各层职责不同:DTO 是前端传来的入参,VO 是返回给前端的出参,Entity 是对应数据库表的实体。分开是为了各层解耦、安全隔离。
- 事务 Transaction
- 一组操作要么全成功、要么全回滚。就像转账——A 扣钱和 B 加钱必须同时成功或同时失败。
本节代码示例以 EasyPro 驱动模块(SaveDriverDTO / modbus-01)为样板,分层规范适用于所有产品。
分层职责:
| 层 | 职责 | 禁止 |
|---|---|---|
| Controller | 参数接收与校验、调用 Service、组装 Result<T> | 写业务逻辑、直连 Mapper/DB |
| Service(接口) | 业务逻辑定义 | — |
| ServiceImpl | 业务逻辑实现、事务边界 | 返回 Result<T>(那是 Controller 的活) |
| Mapper | 数据访问,单表/联表 SQL | 写业务判断 |
职责清晰(各司其职)
// 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) { ... }
职责越界(面条代码)
// Controller 里写业务+直连DB @PostMapping public Result save(@RequestBody SaveDTO dto) { if (dto.getName()==null) // 校验散落 return Result.fail(); mapper.insert(dto); // 跳层 // 业务逻辑全堆这里... }
参数校验:
- Controller 入参统一用
@Validated(支持方法级参数校验,功能超集)。 - 实体类字段用 JSR-303 注解:
@NotNull@NotBlank@Size@Min@Max@Pattern。 - 校验失败由全局异常处理器统一兜底,Controller 内不写
if (xxx == null)防御。
// 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 内禁止:
- 直接
@AutowiredMapper 或SqlSession。 - 多个不相关业务方法堆在一个 Controller(按资源拆分)。
- 在 Controller 拼 SQL 或写循环逻辑。
日志(统一用 Lombok @Slf4j):
- 类上加
@Slf4j,用log.info()/log.error(),禁止手动LoggerFactory.getLogger()。 - 用占位符
{},禁止字符串拼接(性能与可读性):
// [✓] 占位符
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常量或枚举:
// [✗]
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字段注入。
@Service
@RequiredArgsConstructor
public class DriverServiceImpl implements DriverService {
private final DriverMapper driverMapper; // final + 构造器注入,优于 @Autowired
}为什么推荐构造器注入,而不是 @Autowired 字段注入?字段注入把依赖「藏」在类内部:脱离 Spring 容器就没法构造这个类,单元测试只能靠反射塞依赖;这个类依赖了多少东西,也要翻遍整个类才知道。构造器注入的依赖是 final 的——不可变、构造时必须给齐、编译期就能发现缺依赖,类的依赖清单在构造器签名上一目了然。
我在 Controller 里直接调了 Mapper 查数据,省得写 Service,有什么不对?
我日志写成了 log.info("用户" + name + "登录"),能用啊,为什么要改成占位符?
log.info("用户{}登录", name),关闭该级别时不会执行拼接,更快也更安全。我写了 @Transactional 但没加 rollbackFor,报错时没回滚?
RuntimeException 回滚,遇到受检异常(如 IOException)不会回滚,数据可能写了一半。必须显式写 @Transactional(rollbackFor = Exception.class),保证任何异常都回滚。代码里我写了 if(status == 1),1 是什么意思我自己都忘了?
1 代表「启用」还是「禁用」。提取成常量或枚举:if(status == StatusEnum.ENABLED.getCode()),一看就懂。检查清单最后一项会查魔法值。