☰
SpringBoot分层代码还要手敲吗?我把生成结果逐层拆开看了一遍
2026/9/25 18:49:19 网站建设 项目流程

SpringBoot分层代码还要手敲吗?我把生成结果逐层拆开看了一遍

我盯着 IDE 左侧那一片刚冒出来的包,controller、service、mapper、entity、dto、vo,第一反应不是省事了,而是发毛:这玩意儿我一行没写,它凭什么就能跑?

干 Java 后端五年,我手敲过的 Controller 少说几百个。分层这套东西早就是肌肉记忆了:Entity 对应表,Mapper 管 SQL,Service 写业务,Controller 接参数,DTO 收请求,VO 出响应。熟到什么程度呢?新建一个模块,我能在十分钟之内把骨架搭完,剩下的就是填肉。

所以当我用飞算JavaAI 的/后端开发指令把课程管理后台的后端代码一次性生成出来的时候,我心里那点小骄傲是有点碎的。但碎归碎,作为这块的负责人,我不能看一眼结构就说"不错"然后提交。分层代码的坑从来不在目录长什么样,而在层与层之间那些没人写在文档里的约定:事务加在哪一层、DTO 和 VO 到底隔不隔、Mapper 用 XML 还是注解、Entity 能不能直接往外吐。

这篇我就把这次拿到的分层代码从 Controller 一路扒到 Entity,逐层说清楚我实际拿到了什么、哪些对得上我的预期、哪些差得离谱、我改了哪些地方。最后给一份我自己一直在用的人工复核清单,你照着过一遍,能省下不少返工。

技术栈固定:Spring Boot + MyBatis-Plus + MySQL。

一、先把结果摊开:一共拿到多少东西

执行指令之后,后端工程落在backend目录下。我数了一下,com.example.course下面六个包,一共 23 个 Java 文件,加上resources/mapper里 1 个 XML、application.yml和pom.xml,总共不到 30 个文件。

第一印象是这样的:

层级文件数我的第一印象后续是否大改
controller3接口齐全,注解用法偏老改了注入方式和参数校验
service6(3 接口 + 3 实现)接口和实现分离,这点意外事务边界全部重写
mapper3纯注解,无 XML联表查询改成 XML
entity4和表结构对得上补了逻辑删除和填充
dto / vo7有分层意识,但复用过重拆了创建/更新 DTO

这里我先说一个总体判断:结构层面的活,它干得比我快,也比我规范。命名、包路径、接口与实现分离这些,完全不用我操心。真正需要我出手的,全是那些"文档里没写、但线上会出事"的地方。

我特意对比了一下我上个月手敲的同规模模块,包名、类名、方法命名几乎一模一样,连PageResult这种通用包装类的字段名都是对的。这说明一件事:分层结构这种强模式化的东西,本来就该被自动化掉,人在这里较劲没有意义。

所以别把心思花在检查目录对不对上,那是浪费时间。往下看每一层的细节,那里才藏着真问题。另外提醒一句,生成之前上游文档一定要跑全——/需求分析、/前后端设计这些没跑,它拿不到接口约定,生成出来的 Controller 会跟前端对不上,那时候返工成本比手敲还高。

二、Controller 层:接口是齐的,但有三处我受不了

拿到的CourseController长这样,我只保留几个代表性方法:

@RestController@RequestMapping("/api/course")publicclassCourseController{@AutowiredprivateCourseServicecourseService;@GetMapping("/list")publicResult<PageResult<CourseVO>>list(CourseQueryDTOquery){returnResult.ok(courseService.list(query));}@PostMapping("/create")publicResult<Long>create(@RequestBody@ValidatedCourseCreateDTOdto){returnResult.ok(courseService.create(dto));}@PostMapping("/update")publicResult<Void>update(@RequestBody@ValidatedCourseUpdateDTOdto){courseService.update(dto);returnResult.ok();}}

平心而论,这代码能跑,也能读懂。但我改了三个地方。

第一,字段注入换成了构造器注入。@Autowired打在字段上,写起来爽,代价是单元测试里你没法在不启动 Spring 容器的情况下塞一个 mock 进去。我统一换成了@RequiredArgsConstructor+private final。顺带一提,字段注入还容易在循环依赖时给你来一记闷棍,构造器注入至少能在启动阶段就把问题暴露出来。

第二,URL 语义改成 RESTful。/create、/update这种动词式路径,看着像 RPC 不像 HTTP 接口。我改成了POST /api/course和PUT /api/course/{id}。这不是洁癖,是因为后面要接网关做权限,路径规范化能省很多正则。

第三,@Validated补了分组。这个坑我在第四篇文章里提过一嘴,这里展开说。创建和更新用的 DTO 如果共用一份,创建时id是空的,更新时id必填,用同一套校验规则必然打架。生成的代码里虽然有CourseCreateDTO和CourseUpdateDTO两个类,但校验注解没标分组,等于白拆。

改完之后是这样:

@RestController@RequestMapping("/api/course")@RequiredArgsConstructorpublicclassCourseController{privatefinalCourseServicecourseService;@GetMapping("/page")publicResult<PageResult<CourseVO>>page(CourseQueryDTOquery){returnResult.ok(courseService.page(query));}@GetMapping("/{id}")publicResult<CourseVO>detail(@PathVariableLongid){returnResult.ok(courseService.detail(id));}@PostMappingpublicResult<Long>create(@RequestBody@Validated(CreateGroup.class)CourseCreateDTOdto){returnResult.ok(courseService.create(dto));}@PutMapping("/{id}/status")publicResult<Void>changeStatus(@PathVariableLongid,@RequestBody@ValidCourseStatusDTOdto){courseService.changeStatus(id,dto);returnResult.ok();}}

还有一个细节:Controller 里我没写任何 try-catch,也没做任何业务判断。这件事我是刻意的。Controller 就是一个转接层,参数校验完直接丢给 Service,一旦你在里面写业务,事务边界就跟着乱了。

我见过最离谱的一个案例是,有人在 Controller 里循环调用 Service 的保存方法,还纳闷为什么加了事务不回滚——循环在 Controller,事务在 Service,每一次调用都是一个独立事务,当然回滚不了。这种问题排查起来特别费劲,因为代码看着"该有的都有"。

分页参数我也要说一句。生成的代码里pageNum、pageSize是直接透传到new Page<>()的,没有上限。这意味着前端传个pageSize=100000进来,你的服务会老老实实去查十万条数据。我后来在 DTO 里加了@Max(value = 200, message = "每页最多200条"),这个改动很不显眼,但能挡住一次误操作打挂数据库。

三、Service 层:接口和实现都给了,事务边界得自己划

Service 是这次让我最意外的一层。它老老实实拆了CourseService接口和CourseServiceImpl实现,没有图省事把实现直接写在接口里。很多手敲代码的人反而不爱拆,觉得多余。

但事务的问题,它是真不管。

生成的实现里,changeStatus长这样——更新课程状态、写一条操作日志、再更新分类下的课程计数,三步操作,一个@Transactional都没有。这不是它疏忽,是它不知道这三步在你业务里算不算一个原子操作。事务边界是业务语义,不是技术语法,这是我认为生成代码最难跨越的一道坎。

我重写后的版本:

publicinterfaceCourseService{PageResult<CourseVO>page(CourseQueryDTOquery);CourseVOdetail(Longid);Longcreate(CourseCreateDTOdto);voidchangeStatus(Longid,CourseStatusDTOdto);}
@Service@RequiredArgsConstructorpublicclassCourseServiceImplimplementsCourseService{privatefinalCourseMappercourseMapper;privatefinalCourseLogMappercourseLogMapper;@OverridepublicPageResult<CourseVO>page(CourseQueryDTOquery){Page<Course>page=newPage<>(query.getPageNum(),query.getPageSize());LambdaQueryWrapper<Course>wrapper=newLambdaQueryWrapper<Course>().like(StringUtils.hasText(query.getTitle()),Course::getTitle,query.getTitle()).eq(query.getStatus()!=null,Course::getStatus,query.getStatus()).eq(query.getCategoryId()!=null,Course::getCategoryId,query.getCategoryId()).orderByDesc(Course::getCreateTime);courseMapper.selectPage(page,wrapper);List<CourseVO>records=page.getRecords().stream().map(this::toVO).collect(Collectors.toList());returnnewPageResult<>(page.getTotal(),records);}@Override@Transactional(rollbackFor=Exception.class)publicvoidchangeStatus(Longid,CourseStatusDTOdto){Coursecourse=courseMapper.selectById(id);if(course==null){thrownewBizException("课程不存在");}if(course.getStatus().equals(dto.getTargetStatus())){return;}Courseupdate=newCourse();update.setId(id);update.setStatus(dto.getTargetStatus());courseMapper.updateById(update);CourseLoglog=newCourseLog();log.setCourseId(id);log.setFromStatus(course.getStatus());log.setToStatus(dto.getTargetStatus());log.setOperator(dto.getOperator());courseLogMapper.insert(log);}privateCourseVOtoVO(Courseentity){CourseVOvo=newCourseVO();BeanUtils.copyProperties(entity,vo);vo.setStatusName(CourseStatusEnum.getNameByCode(entity.getStatus()));returnvo;}}

划事务边界时我给自己定了三条规矩,写在这里:

  1. 只加在会产生多次写操作的方法上。单表 update 不需要事务,数据库自己保证原子性。
  2. rollbackFor必须写Exception.class。Spring 默认只在抛出RuntimeException和Error时回滚,你抛个受检异常它就不管了,这是我早年踩过的坑。
  3. 事务方法里绝不吞异常。我见过太多try { ... } catch (Exception e) { log.error(e); }写在事务方法里,业务明明失败了,事务照样提交,数据直接脏掉。

还有个隐蔽的坑要提醒:同类内部方法调用不会走代理,this.xxx()调带事务的方法,事务是不生效的。生成的代码里如果有这种自调用,你肉眼很难发现。我的处理办法是拆到另一个 Service,或者注入自己(@Lazy防循环依赖),别用AopContext,那玩意儿对后续维护的人太不友好。

四、Mapper 层:XML 还是注解,我纠结了一阵

生成结果里CourseMapper是一个纯接口,继承BaseMapper<Course>,一个自定义方法都没有,全靠 MyBatis-Plus 的LambdaQueryWrapper撑着。

@MapperpublicinterfaceCourseMapperextendsBaseMapper<Course>{}

单表 CRUD 这样写没问题,清爽。但课程列表页要联category表查分类名、联teacher表查讲师名,还要按分类分组统计,这种多表关联LambdaQueryWrapper就有点力不从心了。硬写的话,你要么在 Java 里查三次再拼装(典型的 N+1),要么写出来的代码读起来像天书。

我的选择是:单表走 MyBatis-Plus,多表关联走 XML。理由很实际——XML 里的动态 SQL 有完整语法提示,<where>、<if>、<foreach>一眼能看懂,联表 SQL 也能直接扔到 Navicat 里跑一下验证结果。注解方式写多行 SQL 得拼字符串,少一个空格就报错,调试体验极差。

这是我自己补的 Mapper 和 XML:

@MapperpublicinterfaceCourseMapperextendsBaseMapper<Course>{List<CourseListVO>selectCourseList(@Param("query")CourseQueryDTOquery);intcountByCategoryId(@Param("categoryId")LongcategoryId);}
<?xml version="1.0" encoding="UTF-8"?><!DOCTYPEmapperPUBLIC"-//mybatis.org//DTD Mapper 3.0//EN""http://mybatis.org/dtd/mybatis-3-mapper.dtd"><mappernamespace="com.example.course.mapper.CourseMapper"><resultMapid="CourseListMap"type="com.example.course.vo.CourseListVO"><idproperty="id"column="id"/><resultproperty="title"column="title"/><resultproperty="status"column="status"/><resultproperty="categoryName"column="category_name"/><resultproperty="teacherName"column="teacher_name"/></resultMap><sqlid="courseColumns">c.id, c.title, c.status, c.price, c.create_time, ca.name AS category_name, t.name AS teacher_name</sql><selectid="selectCourseList"resultMap="CourseListMap">SELECT<includerefid="courseColumns"/>FROM course c LEFT JOIN category ca ON c.category_id = ca.id LEFT JOIN teacher t ON c.teacher_id = t.id<where>c.deleted = 0<iftest="query.title != null and query.title != ''">AND c.title LIKE CONCAT('%', #{query.title}, '%')</if><iftest="query.status != null">AND c.status = #{query.status}</if><iftest="query.categoryId != null">AND c.category_id = #{query.categoryId}</if></where>ORDER BY c.create_time DESC</select><selectid="countByCategoryId"resultType="int">SELECT COUNT(1) FROM course WHERE category_id = #{categoryId} AND deleted = 0</select></mapper>

application.yml里记得把 XML 位置和驼峰映射配上,这一步漏了会直接报Invalid bound statement,具体报错长什么样我在下一篇排错记录里完整贴出来:

mybatis-plus:mapper-locations:classpath*:/mapper/**/*.xmltype-aliases-package:com.example.course.entityconfiguration:map-underscore-to-camel-case:truelog-impl:org.apache.ibatis.logging.stdout.StdOutImplglobal-config:db-config:logic-delete-field:deletedlogic-delete-value:1logic-not-delete-value:0

五、Entity 与 DTO/VO:这一层最见分寸感

分层代码里最容易被糊弄的就是这层。Entity 是数据库镜像,DTO 是入参,VO 是出参,三者的边界全靠工程师自觉。

生成结果里 Entity 基本正确,@TableName、主键策略都有,但漏了两样东西:逻辑删除标记和自动填充。没有@TableLogic,deleteById就是物理删除,课程数据一旦被误删就找不回来了。没有填充,createTime和updateTime全得手写 set,迟早有人忘。

@Data@TableName("course")publicclassCourse{@TableId(type=IdType.AUTO)privateLongid;privateStringtitle;privateLongcategoryId;privateLongteacherId;privateIntegerstatus;privateBigDecimalprice;@TableField(fill=FieldFill.INSERT)privateLocalDateTimecreateTime;@TableField(fill=FieldFill.INSERT_UPDATE)privateLocalDateTimeupdateTime;@TableLogicprivateIntegerdeleted;}

配合一个MetaObjectHandler做填充,这活儿一次配好,后面所有表都受益。

DTO 这层我动得最多。前面说了创建和更新的校验分组,这里补另一个更要命的问题:VO 里混进了不该出去的字段。生成的代码里CourseVO直接 copy 了 Entity 的全部字段,包括costPrice(成本价)和commissionRate(分成比例)。列表接口一把这些数据吐给前端,等于把商业底牌公开了。

这种事在代码评审里经常漏,因为功能测试完全看不出来——页面不显示这个字段,你就以为没问题,但响应体里它实实在在躺着,F12 一打开全看见。

我的做法是 VO 单独定义,只写前端真正需要的字段,并且明确禁止用BeanUtils.copyProperties(entity, vo)一把梭:

@DatapublicclassCourseCreateDTO{@NotBlank(message="课程标题不能为空",groups=CreateGroup.class)@Size(max=100,message="课程标题最多100个字")privateStringtitle;@NotNull(message="分类不能为空",groups=CreateGroup.class)privateLongcategoryId;@NotNull(message="价格不能为空",groups=CreateGroup.class)@DecimalMin(value="0.00",message="价格不能为负数")privateBigDecimalprice;privateStringdescription;}
@DatapublicclassCourseVO{privateLongid;privateStringtitle;privateIntegerstatus;privateStringstatusName;privateBigDecimalprice;privateStringcategoryName;@JsonFormat(pattern="yyyy-MM-dd HH:mm:ss")privateLocalDateTimecreateTime;}

注意CourseVO里我加了个statusName,这是状态码的中文描述。这个字段数据库里没有,是我在 Service 里用枚举转换塞进去的。要不要放 VO 里放,我的判断标准是:前端拿到之后能不能省掉一次判断和一次映射。如果能,就放;纯装饰性的,别放,VO 会越来越胖。

VO 里还有个容易忽略的点:时间格式。生成的代码里createTime是LocalDateTime,直接序列化出来是数组或者一长串 ISO 串,前端还得自己格式化一遍。我在 VO 上加了@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss"),顺便统一了时区。这种小事单个看不值一提,攒起来能让前端少写一堆工具函数。

DTO 和 VO 到底要不要严格一一对应?我的看法是不必。一个 VO 可以被多个接口复用(列表和详情用同一个CourseVO完全没问题),但 DTO 必须按操作拆,因为写入参数之间的校验规则差异是实打实的。这个判断标准帮我省了很多纠结。

六、逐层改写记录:我到底动了什么

汇总一下这次的改动量,方便你评估工作量:

  1. Controller:3 个类的注入方式全换,URL 语义改 RESTful,校验注解补分组。耗时大约 40 分钟,主要是改 URL 时前端调用也得跟着改。
  2. Service:事务注解补了 4 处,重划 2 处边界,拆掉 1 个自调用。这个最费脑子,我反复对着接口文档确认了每个写操作的原子范围,前后花了快两个小时。
  3. Mapper:新增 3 个联表方法,从注解改 XML。写了大概一小时,其中半小时在 Navicat 里调 SQL。
  4. Entity:4 个类补@TableLogic和填充注解,加了一个MetaObjectHandler。20 分钟。
  5. DTO/VO:拆了 2 个 DTO,重写了 3 个 VO 去掉敏感字段。半小时。

加起来四个半小时左右。相比之下,这些代码如果纯手敲,我保守估计要两天。这个对比是我愿意继续用这套流程的真实原因——它省掉的是打字时间,不是思考时间。思考的部分一点没少,甚至因为要复核,想得比以前更多。

七、生成代码人工复核清单

下面这份清单是我这几轮实战攒下来的,每次拿到分层代码我就从上往下过一遍。每一项都写清了"合格的标志",别凭感觉判断。

层级检查项合格标志最常见的坑
Controller依赖注入方式构造器注入,字段是final@Autowired字段注入导致单测难写
Controller参数校验@Valid/@Validated带分组创建与更新共用校验规则,字段打架
Controller是否有业务逻辑方法体不超过 5 行业务写进 Controller,事务跟着失效
Controller返回包装统一Result<T>有的接口裸返回,前端解析裂开
Service事务注解多写操作必有@Transactional(rollbackFor = Exception.class)默认只回滚运行时异常
Service异常吞咽事务方法内无 try-catch 吃掉异常业务失败但事务照常提交
Service自调用无this.调用同类的其他 public 方法代理不生效,事务静默失效
Service空值处理查不到数据时抛业务异常或返回明确结果空指针直接 500
Mapper联表方式多表查询走 XML,单表走 MP强行用 Wrapper 写联表,代码不可读
MapperXML 扫描mapper-locations配置正确报Invalid bound statement
Mapper分页配了PaginationInnerInterceptor分页插件没注册,total永远为 0
Entity逻辑删除有@TableLogic且全局配置一致物理删数据,删完找不回
Entity时间字段有@TableField(fill = ...)和处理器有人忘了 set,时间字段为 null
Entity字段与表一致开启驼峰映射后字段名是驼峰写成下划线报Unknown column
DTO创建/更新隔离两个 DTO,校验分组不同共用一个 DTO,id必填校验冲突
VO敏感字段无成本、利润、内部比例类字段copyProperties 一把梭,数据泄漏
VO枚举描述状态码附带中文名前端自己维护一份映射,两边不同步

这张表我建议直接存成团队的代码评审模板。新人拿到生成代码不知道该看什么,照着走一遍基本能拦住八成问题。

写在最后

说句扎心的:分层代码这事儿,真正值钱的从来不是那几百行 Java,是你知道事务该加到哪一层、知道 VO 里哪个字段不能往外吐、知道BeanUtils.copyProperties什么时候会坑你。工具把打字这件事干掉了,反而把这些判断的价值放大了——以前你还能靠"我写了两天"刷存在感,现在代码十分钟就出来了,你剩下能拿出手的,就只有判断力了。

所以别问"分层代码还要不要手敲",该问的是"我有没有本事在十分钟内看出它哪里不对"。


作者简介:5 年 Java 后端,正在往全栈挪。相信工具能放大人的能力,但放大的是你本来就有的东西。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询