ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

Spring Boot 三层架构实战:Controller-Service-Mapper 分层与 JPA/MyBatis-Plus 选择

Spring Boot 三层架构实战:Controller-Service-Mapper 分层与 JPA/MyBatis-Plus 选择 1. 三层架构在 Spring Boot 场景下的“真身”1.1 三层到底是哪三层很多刚学 Spring Boot 的同学会把三层架构当成一个“高大上的架构模式”其实它拆开来看非常朴素Controller表现层— Service业务层— Repository/Mapper数据访问层。在 Spring Boot 项目里这条线就是所有业务请求的主干道HTTP 请求进来Controller 先接住参数然后转身调用 Service 里的方法Service 处理完业务逻辑之后再去调用 JPA 的 Repository 或者 MyBatis-Plus 的 Mapper 完成数据库读写。这个结构没什么玄学它就是一条严格单向的依赖链。上层知道下层存在下层完全不知道上层是谁。Service 不依赖 Controller 的返回格式Repository 也不关心 Service 到底要做什么业务。正是这种单向性让一个 Spring Boot 工程在团队协作和后期维护时变得可控数据库要改表你大概率只需要动实体和 Repository/Mapper页面接口要改格式你只需要动 Controller 和 DTO。1.2 和 MVC 三层架构的关系被别人问烂了的一次澄清“MVC 三层架构”是网上最常见的搜索组合词。这里必须先说清楚一件事MVC 和三层架构不是同一个维度的概念。MVC 讲的是 Model、View、Controller 三者之间的交互模式重点在“界面怎么响应操作”它在 Web 项目里通常体现为前端页面、控制器、模型之间的分工。而三层架构讲的是单个后端应用内部的纵向分层重点在“业务代码怎么组织”。Spring MVC 这个模块只是把 Controller 这一层接进了 Spring 生态你在项目里看到的 Controller 确实承担了 MVC 里的 Controller 角色但 MVC 本身并不规定你 Service 该怎么写、数据访问层该用 JPA 还是 MP。所以“mvc三层架构”这个提法严格说是把两个维度的东西搅在一起了。你真正在 Spring Boot 项目里遵守的是Controller-Service-Repository/Mapper 的后端三层前端再单独走 MVC 模式两者不冲突。1.3 “三层”是不是被过度解读了现在还能搜到“数字孪生三层架构”这种说法什么感知层、网络层、应用层跟后端的三层架构完全不是一个概念但背后都是同一种分层思维把一个复杂系统按照职责切成横向区块区块之间通过定义好的接口协作。在 Spring Boot 项目内部三层架构之所以能活到今天是因为它和框架本身的抽象方式严丝合缝。Spring 的 IoC 容器天然就是按“组件”来管理的你定义一个Controller、一个Service、一个Repository容器就把它们装配成一条链。与其说这是架构设计的胜利不如说是 Spring Boot 帮我们把三层架构的“管道”铺好了你只需要按规范往里面丢代码。这也是为什么有人觉得三层架构“太简单、没技术含量”——我再补充一句三层架构简单但守住分层纪律不简单。现实中大量项目最后都会出现“Controller 里写 SQL”“Service 里写页面返回逻辑”“Repository 接口里塞了一堆查询条件拼接”的情况。分层不是摆设是一座项目写烂之后才知道有多重要的防火墙。2. JPA 和 MyBatis-Plus同一套三层里的两种数据访问风格2.1 Repository 与 Mapper叫法不同职责相同在三层架构里最底层的数据访问接口JPA 叫RepositoryMyBatis-Plus 叫Mapper。名字不一样干的活是一样接收 Service 层传来的参数执行数据读写把结果回传给 Service。但背后的设计哲学差得很远。Spring Data JPA 是“对象优先”的思路你管的是实体EntitySQL 由框架根据方法名或者Query帮你生成MyBatis-Plus 是“SQL 优先”的思路虽然它也帮你封装了 BaseMapper 通用 CRUD但一旦涉及复杂查询你还是要手写 XML 里的 SQL 语句把数据库操作的决定权握在自己手里。2.2 Spring Data JPA 的“自动”到底自动在哪里JPA 最让人又爱又恨的就是“自动”。比如你定义了一个Lecture实体Entity Table(name lecture) public class Lecture { Id GeneratedValue(strategy GenerationType.IDENTITY) private Long id; Column(nullable false, length 100) private String title; private String speaker; Column(name total_seat) private Integer totalSeat; Column(name booked_seat) private Integer bookedSeat; private LocalDateTime publishTime; // 构造器、getter/setter 省略 }你几乎不用写建表语句启动时 Hibernate 就能根据注解把表建出来。再定义一个接口public interface LectureRepository extends JpaRepositoryLecture, Long { ListLecture findBySpeakerContaining(String keyword); }findBySpeakerContaining这句话Spring Data JPA 会解析方法名自动生成SELECT * FROM lecture WHERE speaker LIKE ?这样的 SQL。你连Query都不需要写这是 JPA 最大的效率来源。但代价是你一旦把查询出来的实体丢进事务实体就进入“托管状态”。同一个事务里你对实体对象调用setXxx等事务提交时Hibernate 会做脏检查自动把变更 UPDATE 回数据库。这就是网上那个高频问题“jpa查询出来的数据在修改的话自动修改数据库了”的由来。2.3 MyBatis-Plus 的“灵活”体现在哪里MyBatis-Plus 在简单 CRUD 上一点都不比 JPA 慢。你继承一个BaseMapperT增删改查直接开箱即用Mapper public interface LectureMapper extends BaseMapperLecture { ListLecture searchLecture(Param(keyword) String keyword); }比 JPA 更直白的是selectById、selectList、insert、updateById这些方法你一眼就能看懂它去数据库做了什么不会像 JPA 那样有“方法名解析”这一层魔法。复杂统计类需求MP 就用 XML 手写 SQL性能调优和 SQL 审查的空间比 JPA 大得多DBA 可以直接把你的 XML 拿过去 review而不是去猜 JPQL 最终会翻译成什么 SQL。2.4 同一套三层里实际项目怎么选我在多个项目里两种都用过给一个比较务实的对比表对比维度Spring Data JPAMyBatis-Plus核心思路对象/持久化上下文优先SQL映射优先简单CRUD基本零代码BaseMapper 零代码复杂查询Query/JPQL 表达受限XML 原生 SQL 灵活自动建表支持开发期方便通常靠升级脚本管理脏检查/自动更新有需注意没有改完必须显式 update上手学习成本偏抽象中文资料多直白适合场景实体关系复杂、迭代快的业务SQL 复杂、团队 DBA 主导我的个人组合习惯是业务主链路用 Spring Data JPA因为它处理级联、关联查询、事务边界更优雅报表、统计、跨表聚合这类重 SQL 用 MyBatis-Plus直接把 XML 当成 SQL 资产管理。这样两个框架会在同一个 Service 里共存Repository 管写Mapper 管读一层归一层反而让三层架构的数据访问层变得更加立体。3. 一个能跑的骨架以校园讲座预约系统为例的三层实现3.1 工程目录与“第一个 Spring Boot 程序”网上搜“第1关第一个spring boot程序”的人很多其实一个 Spring Boot 项目从这个目录就能看出三层架构的影子src/main/java/com/example/lecture ├── LectureApplication.java ├── controller │ └── LectureController.java ├── service │ ├── LectureService.java │ └── impl │ └── LectureServiceImpl.java ├── repository │ └── LectureRepository.java ├── mapper │ └── LectureMapper.java └── entity ├── Lecture.java └── Appointment.javaLectureApplication只负责启动Controller 在外层接请求Service 做业务Repository/Mapper 管数据。这本就是三层架构在 Spring Boot 里的落地形态。3.2 实体层JPA 注解和 MP 注解可以共存实体层在三层架构里不算单独一层但它是 Repository 和 Mapper 共同面对的数据模型。如果你在同一项目里混用 JPA 和 MyBatis-Plus实体注解就要小心JPA 用Entity、Table、ColumnMyBatis-Plus 用TableName、TableId。常见做法是主实体用 JPA 注解管理MyBatis-Plus 的 Mapper 也能直接映射这个实体字段。因为 MyBatis-Plus 对实体没有强注解要求默认把驼峰字段转下划线和 JPA 的Column(name total_seat)不冲突。不过我不建议在同一个实体上同时堆两套注解那会让实体层变成“四不像”。更稳妥的是JPA 实体归 JPA 管MP 如果需要独立映射就在mapper包里单独定义查询返回的 DTO 或 VO。3.3 数据访问层Repository 和 Mapper 各司其职先看 JPA 的 Repositorypublic interface LectureRepository extends JpaRepositoryLecture, Long { OptionalLecture findById(Long id); ListLecture findBySpeakerContaining(String keyword); }再看 MyBatis-Plus 的 MapperMapper public interface LectureMapper extends BaseMapperLecture { ListLecture searchLecture(Param(keyword) String keyword); }如果searchLecture里的 SQL 不复杂你也可以直接用Select写在接口上Select(SELECT * FROM lecture WHERE title LIKE CONCAT(%, #{keyword}, %)) ListLecture searchLecture(Param(keyword) String keyword);但更常见的项目实践中复杂 SQL 会放到 XML 里维护这个后面第 4 节单独讲配置。3.4 Service 层事务边界和业务规则都放在这里Service 是三层架构里最“心脏”的一层。举一个讲座预约的典型场景Service public class LectureServiceImpl implements LectureService { private final LectureRepository lectureRepository; private final AppointmentRepository appointmentRepository; public LectureServiceImpl(LectureRepository lectureRepository, AppointmentRepository appointmentRepository) { this.lectureRepository lectureRepository; this.appointmentRepository appointmentRepository; } Override Transactional public void bookLecture(Long lectureId, String username) { Lecture lecture lectureRepository.findById(lectureId) .orElseThrow(() - new IllegalArgumentException(讲座不存在)); if (lecture.getBookedSeat() lecture.getTotalSeat()) { throw new IllegalStateException(讲座座位已满); } lecture.setBookedSeat(lecture.getBookedSeat() 1); appointmentRepository.save(new Appointment(lectureId, username)); } }注意这里Transactional必须标在 Service 层实现类的方法上而不是 Controller。因为事务要覆盖“判断座位、更新剩余座位、写入预约记录”这多个步骤任何一个步骤失败整个预约都要回滚。如果只把事务放在 Repository 层那么lectureRepository.save()成功之后预约记录插入失败座位数就被白白扣掉了。JPA 的另一个特性在这里也体现得很明显lecture.setBookedSeat(...)之后我并没有手动调用save但在事务提交时Hibernate 的脏检查会自动把bookedSeat更新回数据库。MyBatis-Plus 的实体则不会有这个行为你必须显式updateById或者update这是两种数据访问层最核心的体验差异。3.5 Controller 层接口翻译官不当业务大脑Controller 在三层架构里只做三件事接收参数、调用 Service、返回结果。判断座位够不够、计算价格、组装复杂数据这些都不应该在 Controller 里出现。RestController RequestMapping(/api/lectures) public class LectureController { private final LectureService lectureService; public LectureController(LectureService lectureService) { this.lectureService lectureService; } GetMapping(/{id}) public LectureVO detail(PathVariable Long id) { return lectureService.getLectureDetail(id); } PostMapping(/{id}/book) public void book(PathVariable Long id, RequestBody BookRequest request) { lectureService.bookLecture(id, request.getUsername()); } }我见过不少项目在 Controller 里直接塞if (xxx null) return XXX之类的业务判断短期看是少写了一层调用长期看就是业务规则碎片化。等需求一改你会发现同一个规则散落在好几个接口里这是三层架构被写烂的最典型症状。3.6 实体和 DTO 到底要不要分离这个问题争议最大我的建议是分。哪怕是校园讲座预约这种小系统也不要直接在 Controller 里返回Lecture实体。原因很简单实体字段和数据库列强绑定一旦你调整表结构或者不想暴露某些字段接口格式就被连带破坏。在 Service 层里做一个LectureVOpublic class LectureVO { private Long id; private String title; private String speaker; private Integer remainingSeat; // getter/setter }剩余座位数直接在 Service 里通过totalSeat - bookedSeat算出来Controller 只负责把 VO 序列化成 JSON。这样数据库表换个字段名接口可以完全不变。4. JPA 脏检查、MP 同名 XML两个最常见的“事故现场”4.1 JPA 查询出来的数据被“偷偷改掉”的完整链路网上那个热词“jpa查询出来的数据在修改的话自动修改数据库了”几乎每个用 JPA 的人都遇到过。我第一次碰到也觉得离谱但搞懂原理之后就发现这是 JPA 最核心的机制之一。JPA 的实体有四种状态其中“托管状态”是关键。当你通过findById查到实体并且当前存在一个活动事务Hibernate 会把这份实体的原始快照存放在持久化上下文Persistence Context里。事务提交前Hibernate 会执行脏检查把当前实体属性与快照逐一对比只要有差异就会自动生成 UPDATE 语句。所以会出现这种情况GetMapping(/{id}) Transactional public LectureVO getLecture(PathVariable Long id) { Lecture lecture lectureRepository.findById(id).get(); lecture.setViewCount(lecture.getViewCount() 1); // 我只是想统计浏览量 return toVO(lecture); }这段代码本意只是“查出来看一眼”但因为方法上有事务、实体会被托管setViewCount之后提交事务时数据库里那行记录的浏览量就真的被改了。可怕的是很多新手根本不知道自己改过。怎么避免三个实用手段查询类方法一律Transactional(readOnly true)。加上这个之后Hibernate 会得到提示通常把 FlushMode 调整为手动避免事务提交时自动刷库改动大概率不会落到数据库。在事务内尽早把实体转成 DTO/VO让实体的引用不要一路传到 Controller 甚至前端。如果不小心把实体返回给了前端页面表单提交时又把它反序列化成对象再传回来那问题会更大——此时它是一个游离实体merge可能把不该改的字段也覆盖掉。所以实体真的不要跑到 Controller 外面去。4.2 MyBatis-Plus XML 与 Mapper 同名文件放不进“同一个文件夹”的配置另一个高频搜索是“spring boot 项目使用mybatis-plus xml与mapper在同一个文件夹下应该如何配置”。很多人习惯把LectureMapper.java和LectureMapper.xml放在同一个com/example/lecture/mapper包里直观且好找。但在 Maven 默认规则下src/main/java只编译.java文件.xml不会被复制进最终的 classes 目录于是启动时就是找不到 binder 对应的 XML。如果你的确想让 XML 和 Mapper 接口保持在同一个 java 目录下需要给 Maven 的pom.xml加一个资源配置build resources resource directorysrc/main/java/directory includes include**/*.xml/include /includes /resource /resources /build但我个人不推荐这么干。因为把 XML 混在 java 源码目录里不仅打出来的包里文件结构变怪新版 Spring Boot 和 IDE 在某些情况下还会因为资源覆盖问题出幺蛾子。更稳妥的方案是把 XML 统一放在src/main/resources/mapper/目录下并在application.yml里声明扫描位置mybatis-plus: mapper-locations: classpath*:mapper/**/*.xml type-aliases-package: com.example.lecture.entity然后LectureMapper.java和resources/mapper/LectureMapper.xml通过 namespace 绑定?xml version1.0 encodingUTF-8 ? !DOCTYPE mapper PUBLIC -//mybatis.org//DTD Mapper 3.0//EN http://mybatis.org/dtd/mybatis-3-mapper.dtd mapper namespacecom.example.lecture.mapper.LectureMapper select idsearchLecture resultTypecom.example.lecture.entity.Lecture SELECT * FROM lecture WHERE title LIKE CONCAT(%, #{keyword}, %) /select /mapper这里有两个经常踩的雷namespace 必须和 Mapper 接口全限定名一致id必须和接口的方法名一致否则启动阶段 MyBatis 就报 binding 异常。另外参数占位符尽量用#{}不要用${}。#{}对应 JDBC 的PreparedStatement占位符能防住 SQL 注入${}是字符串拼接只有你确定参数是白名单值时才该考虑。4.3 顺带说一个 MyBatis-Plus 分页失灵的问题很多项目一开始用 BaseMapper 的selectPage发现分页 SQL 没有真正 limit这是因为 MP 需要显式配置分页插件。常见的配置如下Configuration public class MybatisPlusConfig { Bean public MybatisPlusInterceptor mybatisPlusInterceptor() { MybatisPlusInterceptor interceptor new MybatisPlusInterceptor(); interceptor.addInnerInterceptor(new PaginationInnerInterceptor(DbType.MYSQL)); return interceptor; } }不配这个插件selectPage只是“假装分页”实际 SQL 还是全表查询。这类配置项不写在官方 CRUD 文档里不踩一次是真的想不起来。5. 三层架构 vs 六边形架构为什么 Java 生态里三层仍是主流5.1 三层架构的朴素逻辑打败了“更先进”的设计网上常有人问“为什么 Java 大部分使用三层架构而不使用六边形架构”。我的回答很直接因为大多数 Java Web 项目的复杂度和团队协作模式三层架构已经够用了。三层架构不是被淘汰的旧东西而是对“分层思想”做的最简抽象。Controller 对应入口适配Service 对应业务用例Repository/Mapper 对应持久化细节每一层都有明确的“可替换”边界。它之所以在 Spring Boot 生态里长盛不衰是因为 Spring 的组件模型和它天然匹配Controller、Service、Repository这些注解本身就是为这条链条准备的。团队新成员哪怕没读过 DDD看一遍目录结构也能知道该往哪里写代码。5.2 六边形架构解决什么问题六边形架构端口适配器的核心是让领域层完全独立于技术细节。Controller、Repository 全都是适配器连接的都是领域层暴露的端口接口。这样做的好处非常明显数据库从 MySQL 换成 Oracle或者从 REST 接口换成 gRPC领域层代码一行都不用改。但代价也不小。你会在工程里多出一堆 port 接口、应用服务、领域服务、防腐层一个简单 CRUD 可能要过四五个类调试链路变长新人维护成本显著上升。对一个典型的“校园讲座预约”“商城后台”“餐饮 SaaS”项目来说这种抽象往往只是技术上显得很前卫实际并没有让交付速度变快。5.3 我个人在项目里的取舍我自己经历过从“无脑三层”到“项目里强上六边形”再到“按需选择”的过程。现在我的原则很简单业务领域复杂、有大量业务规则、状态机、规则引擎需求的项目我会认真考虑六边形或 DDD 分层把核心领域隔离在适配器之外。典型的 CRUD 业务系统、管理后台、预约系统三层架构就是成本最低的正确选择。Service 层大胆写业务逻辑Controller 保持干净Repository/Mapper 各管各的这就够了。不要在 CRUD 项目里为了“架构高级感”强行套六边形。三层架构不是低端货它只是不装。真正让你项目变烂的从来不是用了哪套架构而是没人守住分层边界Controller 里塞 SQL、Service 里拼 JSON、实体到处乱传这种乱象哪怕换成十边形架构一样会烂。最后再分享一个小技巧不管用 JPA 还是 MyBatis-PlusDTO 和实体的转换尽量收敛在 Service 层完成Controller 不要直接拿实体往外丢。这样一来将来你就算把 JPA 换成 MP、或者反过来Controller 和前端接口基本不用动。我在这两个转换方向上都实测过这个习惯省下来的改接口时间远比当初多写那几个 DTO 类的时间值钱。
返回列表