SpringBoot分层代码还要手敲吗我把生成结果逐层拆开看了一遍我盯着 IDE 左侧那一片刚冒出来的包controller、service、mapper、entity、dto、vo第一反应不是省事了而是发毛这玩意儿我一行没写它凭什么就能跑干 Java 后端五年我手敲过的 Controller 少说几百个。分层这套东西早就是肌肉记忆了Entity 对应表Mapper 管 SQLService 写业务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接口齐全注解用法偏老改了注入方式和参数校验service63 接口 3 实现接口和实现分离这点意外事务边界全部重写mapper3纯注解无 XML联表查询改成 XMLentity4和表结构对得上补了逻辑删除和填充dto / vo7有分层意识但复用过重拆了创建/更新 DTO这里我先说一个总体判断结构层面的活它干得比我快也比我规范。命名、包路径、接口与实现分离这些完全不用我操心。真正需要我出手的全是那些文档里没写、但线上会出事的地方。我特意对比了一下我上个月手敲的同规模模块包名、类名、方法命名几乎一模一样连PageResult这种通用包装类的字段名都是对的。这说明一件事分层结构这种强模式化的东西本来就该被自动化掉人在这里较劲没有意义。所以别把心思花在检查目录对不对上那是浪费时间。往下看每一层的细节那里才藏着真问题。另外提醒一句生成之前上游文档一定要跑全——/需求分析、/前后端设计这些没跑它拿不到接口约定生成出来的 Controller 会跟前端对不上那时候返工成本比手敲还高。二、Controller 层接口是齐的但有三处我受不了拿到的CourseController长这样我只保留几个代表性方法RestControllerRequestMapping(/api/course)publicclassCourseController{AutowiredprivateCourseServicecourseService;GetMapping(/list)publicResultPageResultCourseVOlist(CourseQueryDTOquery){returnResult.ok(courseService.list(query));}PostMapping(/create)publicResultLongcreate(RequestBodyValidatedCourseCreateDTOdto){returnResult.ok(courseService.create(dto));}PostMapping(/update)publicResultVoidupdate(RequestBodyValidatedCourseUpdateDTOdto){courseService.update(dto);returnResult.ok();}}平心而论这代码能跑也能读懂。但我改了三个地方。第一字段注入换成了构造器注入。Autowired打在字段上写起来爽代价是单元测试里你没法在不启动 Spring 容器的情况下塞一个 mock 进去。我统一换成了RequiredArgsConstructorprivate final。顺带一提字段注入还容易在循环依赖时给你来一记闷棍构造器注入至少能在启动阶段就把问题暴露出来。第二URL 语义改成 RESTful。/create、/update这种动词式路径看着像 RPC 不像 HTTP 接口。我改成了POST /api/course和PUT /api/course/{id}。这不是洁癖是因为后面要接网关做权限路径规范化能省很多正则。第三Validated补了分组。这个坑我在第四篇文章里提过一嘴这里展开说。创建和更新用的 DTO 如果共用一份创建时id是空的更新时id必填用同一套校验规则必然打架。生成的代码里虽然有CourseCreateDTO和CourseUpdateDTO两个类但校验注解没标分组等于白拆。改完之后是这样RestControllerRequestMapping(/api/course)RequiredArgsConstructorpublicclassCourseController{privatefinalCourseServicecourseService;GetMapping(/page)publicResultPageResultCourseVOpage(CourseQueryDTOquery){returnResult.ok(courseService.page(query));}GetMapping(/{id})publicResultCourseVOdetail(PathVariableLongid){returnResult.ok(courseService.detail(id));}PostMappingpublicResultLongcreate(RequestBodyValidated(CreateGroup.class)CourseCreateDTOdto){returnResult.ok(courseService.create(dto));}PutMapping(/{id}/status)publicResultVoidchangeStatus(PathVariableLongid,RequestBodyValidCourseStatusDTOdto){courseService.changeStatus(id,dto);returnResult.ok();}}还有一个细节Controller 里我没写任何 try-catch也没做任何业务判断。这件事我是刻意的。Controller 就是一个转接层参数校验完直接丢给 Service一旦你在里面写业务事务边界就跟着乱了。我见过最离谱的一个案例是有人在 Controller 里循环调用 Service 的保存方法还纳闷为什么加了事务不回滚——循环在 Controller事务在 Service每一次调用都是一个独立事务当然回滚不了。这种问题排查起来特别费劲因为代码看着该有的都有。分页参数我也要说一句。生成的代码里pageNum、pageSize是直接透传到new Page()的没有上限。这意味着前端传个pageSize100000进来你的服务会老老实实去查十万条数据。我后来在 DTO 里加了Max(value 200, message 每页最多200条)这个改动很不显眼但能挡住一次误操作打挂数据库。三、Service 层接口和实现都给了事务边界得自己划Service 是这次让我最意外的一层。它老老实实拆了CourseService接口和CourseServiceImpl实现没有图省事把实现直接写在接口里。很多手敲代码的人反而不爱拆觉得多余。但事务的问题它是真不管。生成的实现里changeStatus长这样——更新课程状态、写一条操作日志、再更新分类下的课程计数三步操作一个Transactional都没有。这不是它疏忽是它不知道这三步在你业务里算不算一个原子操作。事务边界是业务语义不是技术语法这是我认为生成代码最难跨越的一道坎。我重写后的版本publicinterfaceCourseService{PageResultCourseVOpage(CourseQueryDTOquery);CourseVOdetail(Longid);Longcreate(CourseCreateDTOdto);voidchangeStatus(Longid,CourseStatusDTOdto);}ServiceRequiredArgsConstructorpublicclassCourseServiceImplimplementsCourseService{privatefinalCourseMappercourseMapper;privatefinalCourseLogMappercourseLogMapper;OverridepublicPageResultCourseVOpage(CourseQueryDTOquery){PageCoursepagenewPage(query.getPageNum(),query.getPageSize());LambdaQueryWrapperCoursewrappernewLambdaQueryWrapperCourse().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);ListCourseVOrecordspage.getRecords().stream().map(this::toVO).collect(Collectors.toList());returnnewPageResult(page.getTotal(),records);}OverrideTransactional(rollbackForException.class)publicvoidchangeStatus(Longid,CourseStatusDTOdto){CoursecoursecourseMapper.selectById(id);if(coursenull){thrownewBizException(课程不存在);}if(course.getStatus().equals(dto.getTargetStatus())){return;}CourseupdatenewCourse();update.setId(id);update.setStatus(dto.getTargetStatus());courseMapper.updateById(update);CourseLoglognewCourseLog();log.setCourseId(id);log.setFromStatus(course.getStatus());log.setToStatus(dto.getTargetStatus());log.setOperator(dto.getOperator());courseLogMapper.insert(log);}privateCourseVOtoVO(Courseentity){CourseVOvonewCourseVO();BeanUtils.copyProperties(entity,vo);vo.setStatusName(CourseStatusEnum.getNameByCode(entity.getStatus()));returnvo;}}划事务边界时我给自己定了三条规矩写在这里只加在会产生多次写操作的方法上。单表 update 不需要事务数据库自己保证原子性。rollbackFor必须写Exception.class。Spring 默认只在抛出RuntimeException和Error时回滚你抛个受检异常它就不管了这是我早年踩过的坑。事务方法里绝不吞异常。我见过太多try { ... } catch (Exception e) { log.error(e); }写在事务方法里业务明明失败了事务照样提交数据直接脏掉。还有个隐蔽的坑要提醒同类内部方法调用不会走代理this.xxx()调带事务的方法事务是不生效的。生成的代码里如果有这种自调用你肉眼很难发现。我的处理办法是拆到另一个 Service或者注入自己Lazy防循环依赖别用AopContext那玩意儿对后续维护的人太不友好。四、Mapper 层XML 还是注解我纠结了一阵生成结果里CourseMapper是一个纯接口继承BaseMapperCourse一个自定义方法都没有全靠 MyBatis-Plus 的LambdaQueryWrapper撑着。MapperpublicinterfaceCourseMapperextendsBaseMapperCourse{}单表 CRUD 这样写没问题清爽。但课程列表页要联category表查分类名、联teacher表查讲师名还要按分类分组统计这种多表关联LambdaQueryWrapper就有点力不从心了。硬写的话你要么在 Java 里查三次再拼装典型的 N1要么写出来的代码读起来像天书。我的选择是单表走 MyBatis-Plus多表关联走 XML。理由很实际——XML 里的动态 SQL 有完整语法提示where、if、foreach一眼能看懂联表 SQL 也能直接扔到 Navicat 里跑一下验证结果。注解方式写多行 SQL 得拼字符串少一个空格就报错调试体验极差。这是我自己补的 Mapper 和 XMLMapperpublicinterfaceCourseMapperextendsBaseMapperCourse{ListCourseListVOselectCourseList(Param(query)CourseQueryDTOquery);intcountByCategoryId(Param(categoryId)LongcategoryId);}?xml version1.0 encodingUTF-8?!DOCTYPEmapperPUBLIC-//mybatis.org//DTD Mapper 3.0//ENhttp://mybatis.org/dtd/mybatis-3-mapper.dtdmappernamespacecom.example.course.mapper.CourseMapperresultMapidCourseListMaptypecom.example.course.vo.CourseListVOidpropertyidcolumnid/resultpropertytitlecolumntitle/resultpropertystatuscolumnstatus/resultpropertycategoryNamecolumncategory_name/resultpropertyteacherNamecolumnteacher_name//resultMapsqlidcourseColumnsc.id, c.title, c.status, c.price, c.create_time, ca.name AS category_name, t.name AS teacher_name/sqlselectidselectCourseListresultMapCourseListMapSELECTincluderefidcourseColumns/FROM course c LEFT JOIN category ca ON c.category_id ca.id LEFT JOIN teacher t ON c.teacher_id t.idwherec.deleted 0iftestquery.title ! null and query.title ! AND c.title LIKE CONCAT(%, #{query.title}, %)/ififtestquery.status ! nullAND c.status #{query.status}/ififtestquery.categoryId ! nullAND c.category_id #{query.categoryId}/if/whereORDER BY c.create_time DESC/selectselectidcountByCategoryIdresultTypeintSELECT COUNT(1) FROM course WHERE category_id #{categoryId} AND deleted 0/select/mapperapplication.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、主键策略都有但漏了两样东西逻辑删除标记和自动填充。没有TableLogicdeleteById就是物理删除课程数据一旦被误删就找不回来了。没有填充createTime和updateTime全得手写 set迟早有人忘。DataTableName(course)publicclassCourse{TableId(typeIdType.AUTO)privateLongid;privateStringtitle;privateLongcategoryId;privateLongteacherId;privateIntegerstatus;privateBigDecimalprice;TableField(fillFieldFill.INSERT)privateLocalDateTimecreateTime;TableField(fillFieldFill.INSERT_UPDATE)privateLocalDateTimeupdateTime;TableLogicprivateIntegerdeleted;}配合一个MetaObjectHandler做填充这活儿一次配好后面所有表都受益。DTO 这层我动得最多。前面说了创建和更新的校验分组这里补另一个更要命的问题VO 里混进了不该出去的字段。生成的代码里CourseVO直接 copy 了 Entity 的全部字段包括costPrice成本价和commissionRate分成比例。列表接口一把这些数据吐给前端等于把商业底牌公开了。这种事在代码评审里经常漏因为功能测试完全看不出来——页面不显示这个字段你就以为没问题但响应体里它实实在在躺着F12 一打开全看见。我的做法是 VO 单独定义只写前端真正需要的字段并且明确禁止用BeanUtils.copyProperties(entity, vo)一把梭DatapublicclassCourseCreateDTO{NotBlank(message课程标题不能为空,groupsCreateGroup.class)Size(max100,message课程标题最多100个字)privateStringtitle;NotNull(message分类不能为空,groupsCreateGroup.class)privateLongcategoryId;NotNull(message价格不能为空,groupsCreateGroup.class)DecimalMin(value0.00,message价格不能为负数)privateBigDecimalprice;privateStringdescription;}DatapublicclassCourseVO{privateLongid;privateStringtitle;privateIntegerstatus;privateStringstatusName;privateBigDecimalprice;privateStringcategoryName;JsonFormat(patternyyyy-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 必须按操作拆因为写入参数之间的校验规则差异是实打实的。这个判断标准帮我省了很多纠结。六、逐层改写记录我到底动了什么汇总一下这次的改动量方便你评估工作量Controller3 个类的注入方式全换URL 语义改 RESTful校验注解补分组。耗时大约 40 分钟主要是改 URL 时前端调用也得跟着改。Service事务注解补了 4 处重划 2 处边界拆掉 1 个自调用。这个最费脑子我反复对着接口文档确认了每个写操作的原子范围前后花了快两个小时。Mapper新增 3 个联表方法从注解改 XML。写了大概一小时其中半小时在 Navicat 里调 SQL。Entity4 个类补TableLogic和填充注解加了一个MetaObjectHandler。20 分钟。DTO/VO拆了 2 个 DTO重写了 3 个 VO 去掉敏感字段。半小时。加起来四个半小时左右。相比之下这些代码如果纯手敲我保守估计要两天。这个对比是我愿意继续用这套流程的真实原因——它省掉的是打字时间不是思考时间。思考的部分一点没少甚至因为要复核想得比以前更多。七、生成代码人工复核清单下面这份清单是我这几轮实战攒下来的每次拿到分层代码我就从上往下过一遍。每一项都写清了合格的标志别凭感觉判断。层级检查项合格标志最常见的坑Controller依赖注入方式构造器注入字段是finalAutowired字段注入导致单测难写Controller参数校验Valid/Validated带分组创建与更新共用校验规则字段打架Controller是否有业务逻辑方法体不超过 5 行业务写进 Controller事务跟着失效Controller返回包装统一ResultT有的接口裸返回前端解析裂开Service事务注解多写操作必有Transactional(rollbackFor Exception.class)默认只回滚运行时异常Service异常吞咽事务方法内无 try-catch 吃掉异常业务失败但事务照常提交Service自调用无this.调用同类的其他 public 方法代理不生效事务静默失效Service空值处理查不到数据时抛业务异常或返回明确结果空指针直接 500Mapper联表方式多表查询走 XML单表走 MP强行用 Wrapper 写联表代码不可读MapperXML 扫描mapper-locations配置正确报Invalid bound statementMapper分页配了PaginationInnerInterceptor分页插件没注册total永远为 0Entity逻辑删除有TableLogic且全局配置一致物理删数据删完找不回Entity时间字段有TableField(fill ...)和处理器有人忘了 set时间字段为 nullEntity字段与表一致开启驼峰映射后字段名是驼峰写成下划线报Unknown columnDTO创建/更新隔离两个 DTO校验分组不同共用一个 DTOid必填校验冲突VO敏感字段无成本、利润、内部比例类字段copyProperties 一把梭数据泄漏VO枚举描述状态码附带中文名前端自己维护一份映射两边不同步这张表我建议直接存成团队的代码评审模板。新人拿到生成代码不知道该看什么照着走一遍基本能拦住八成问题。写在最后说句扎心的分层代码这事儿真正值钱的从来不是那几百行 Java是你知道事务该加到哪一层、知道 VO 里哪个字段不能往外吐、知道BeanUtils.copyProperties什么时候会坑你。工具把打字这件事干掉了反而把这些判断的价值放大了——以前你还能靠我写了两天刷存在感现在代码十分钟就出来了你剩下能拿出手的就只有判断力了。所以别问分层代码还要不要手敲该问的是我有没有本事在十分钟内看出它哪里不对。作者简介5 年 Java 后端正在往全栈挪。相信工具能放大人的能力但放大的是你本来就有的东西。
