ARTICLE DETAIL

资讯详情

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

Spring Boot三层架构与数据访问层设计:职责边界与工程落地实践

Spring Boot三层架构与数据访问层设计:职责边界与工程落地实践 1. 三层架构到底在分什么前阵子在技术群里看到有人问“IntelliJ IDEA 社区版怎么用 Spring Boot”其实这个问题的答案很简单社区版导入 Maven 工程装好 JDK在 pom.xml 里加上 Spring Boot 依赖右键运行带main方法的启动类就行。开发 Spring Boot 项目并不依赖旗舰版的那堆可视化工具真正的难点从来不是 IDE而是项目变复杂之后的结构怎么控制。Controller 里写 SQL、Service 里拼查询条件、一个方法几百行、数据访问层直接操作 HttpSession……这些场景我见过不止一次。到了这一步再看“三层架构 数据访问层”才会意识到它不是面试八股而是 Java EE 应用在真实业务里最基础、也最顶得住压力的组织方式。1.1 Controller、Service、Repository 的职责边界三层架构不是三层代码堆在一起而是三个职责边界。Controller 层只负责 HTTP 语义比如接收参数、做基础参数校验、调用 Service、把结果转成响应对象Service 层负责业务规则、事务边界、编排多个数据访问操作Repository/DAO 层负责持久化也就是 SQL 或 ORM 操作。这个边界一旦模糊后面所有维护成本都会爆炸。层次核心职责依赖方向常见的乱象Controller 层HTTP 参数接收、状态码、接口协议Service 层在 Controller 里写业务判断、直接调用 RepositoryService 层业务规则、事务边界、跨表编排Repository 层、其他 Service一个 Service 方法几百行SQL 散落在业务代码里Repository/DAO 层持久化、SQL/ORM 映射Entity、DataSourceMapper 里写复杂业务规则甚至拼动态条件举个例子办公用品管理系统的资产领用场景用户在页面上提交领用申请Controller 拿到ApplyRequest后先做Validated参数校验然后调用AssetService.apply(userId, request)Service 里要检查库存、扣减资产数量、生成领用记录这些步骤组合在一个事务里最后 Repository 只负责updateStock、insertReceiveRecord这些原子操作。如果反过来让 Controller 直接去操作资产表库存扣减和领用记录写入就很难保证一致因为事务边界容易失控。1.2 分层是为了控制依赖方向不是堆目录很多项目分层了还是乱是因为只学了目录结构没学依赖方向。Controller 依赖 Service 接口Service 依赖 Repository 接口Repository 依赖数据库抽象这个方向必须是单向的。一旦出现service反向依赖controller或者repository反向调用service代码很快会变成一团麻。理解依赖方向有个很生活化的类比前台、后厨、仓库。前台接单后把订单递给后厨后厨需要食材时找仓库仓库不关心客人怎么吃。前台不能自己去仓库翻货后厨也不能跑到前台替客人下单。工程里的依赖也是同样的道理高层模块应该依赖抽象而不是依赖具体实现。Repository 层用接口描述“我要什么”Service 层不关心这条 SQL 是 MySQL 写的还是 Oracle 写的。依赖方向清晰还有个直接收益测试可以 mock。Service 层做单元测试时只需要 mock Repository 接口不需要连真实数据库。如果业务逻辑直接和 SQL 绑定想测一个库存边界条件就得搭一套数据库环境费时费力。所以三层架构的价值在于控制变化改数据库、换 ORM、调整接口协议都不至于牵连全链路。1.3 从 Web 到物联网分层思想是通用的有朋友问到“物联网三层架构在现实中的具体应用”其实分层不只是 Java EE 的专利。物联网的感知层、网络层、应用层和 Controller、Service、Repository 的逻辑很像感知层采集数据对应数据访问层网络层负责传输对应服务间通信应用层对数据做业务处理对应 Controller Service。感知设备换了、协议从 4G 变成 NB-IoT上层的应用逻辑可以基本不动。这种思想也解释了为什么 Spring Boot 项目里的数据访问层值得单独拿出来设计。数据库可能是 MySQL、PostgreSQL也可能是一张 Redis 缓存但对上层业务来说它应该只是一个“能存能查”的抽象。如果你在数据访问层留好了口子未来从 MySQL 切到其他存储Service 代码基本不用改如果没留好所有 Service 都直接拼 JDBC那每次存储变更都是一次大手术。2. 数据访问层的核心设计数据访问层往往被当成“写 SQL 的地方”但它的设计质量直接影响整个系统的稳定性。在做 Spring Boot 数据访问层之前必须先回答几个问题用 MyBatis 还是 Spring Data JPA接口怎么定义实体类能不能直接给前端这些问题没有标准答案但有适合场景的答案。2.1 选型MyBatis 还是 Spring Data JPA国内企业项目里MyBatis 和 Spring Data JPA 是两大主流。Spring Data JPA 的优势是 CRUD 快实体关系管理靠注解适合领域模型清晰、关联关系稳定的系统MyBatis 的优势是 SQL 完全可控复杂的多表关联、报表统计、动态条件写起来更直接适合传统企业管理软件。我之前带一个办公用品管理系统的项目选了 MyBatis原因是这个系统有大量“按条件翻页查资产”“统计各类别领用数量”这类场景。JPA 做这些需要写复杂Specification或 JPQL反而不如 MyBatis 的 XML 直观。如果是一个订单系统或者内容管理后台实体内聚性强JPA 的repository.findAll(example)能省不少样板代码。选型表可以参考维度MyBatisSpring Data JPASQL 控制力强SQL 自己写弱复杂查询要 JPQL/SpecificationCRUD 效率要写基础 SQL极快派生查询即可动态条件XMLwhereif很好处理比较绕多表关联灵活SQL 想怎么写怎么写实体关系设计好才不别扭团队上手传统团队更熟悉需要一定的 ORM 功底常见坑字段映射、SQL 注入N1、懒加载、缓存一致性选定一个就用一个不要两套混着写。混用 MyBatis 和 JPA 表面上是两边都占便宜实际上是两份维护成本事务边界还容易混乱。2.2 Repository/DAO 接口应该长什么样以办公用品管理系统为例资产查询是典型的列表页场景按关键字模糊搜索、按状态过滤、分页返回。Repository 接口可以定义成Mapper public interface AssetMapper { int insert(Asset asset); int updateStatus(Param(assetId) Long assetId, Param(status) Integer status); ListAssetVO selectPage(Param(offset) int offset, Param(limit) int limit, Param(keyword) String keyword, Param(status) Integer status); long count(Param(keyword) String keyword, Param(status) Integer status); }接口只描述数据操作不描述业务规则。比如这里的方法叫updateStatus不叫approveAsset因为“审批”是 Service 层的业务语义Repository 只知道“把某条记录的状态字段改成多少”。一旦把业务动词写进数据访问层后续审批规则变化时数据层就得跟着改。接口返回类型也值得注意。查询结果优先用业务需要的 DTO 或实体不要用MapString, Object当万能容器。偶尔批量报表用 Map 可以理解但如果所有查询都返回 Map调用方根本不知道里面有哪些 key编译器也帮不了你改一个字段要全局搜字符串维护成本极高。参数多了就封装一个查询 DTO像AssetQueryDTO一样把keyword、status、pageNum、pageSize打包传递而不是让接口挂十几个参数。2.3 Entity、DTO 和 VO 的边界数据访问层里最容易被忽略的是对象边界。Asset实体类对应数据库表字段和表结构一一对应AssetQueryDTO是查询条件对象AssetVO是返回给前端页面的视图对象。很多项目图省事直接把实体类序列化返回给前端这是个大坑。比如资产表里有个status字段表示库存状态内部还有个category_id数据库设计时还有create_time。前端页面只需要看资产名称、分类名称、价格你直接把实体返回等哪天表结构加了字段internal_remark哪怕前端不需要也会被 Jackson 序列化出去这是接口层面的信息泄露。更实际的问题是实体里的categoryId前端要的是categoryName这时候你就得在 Service 层拼装 DTO。设计边界不一定要很重但至少要有一个稳定的对外结构。Controller 返回AssetVOService 里从 Repository 查出Asset再补充分类名称组装成AssetVO。数据库字段怎么改只要 Mapper 映射和 Service 装配逻辑调整前端看到的接口可以保持不变。3. Spring Boot 工程落地实操落到 Spring Boot 工程里三层架构的落地需要有清晰的项目结构、合理的数据源配置、正确的事务边界以及 SQL 层的动态查询能力。下面用办公用品管理系统的资产模块串一遍这套结构可以直接搬到大多数业务系统上。3.1 一个可复制的工程结构建议把包结构按“技术层次 业务模块”组织。先分 controller、service、mapper、entity、dto、common再在每个层次里按业务模块建子包避免 controller 目录下面堆几十个类。资产模块可以参考com.example.asset ├── controller │ ├── asset/AssetController.java │ └── category/CategoryController.java ├── service │ ├── asset/AssetService.java │ └── asset/impl/AssetServiceImpl.java ├── mapper │ ├── AssetMapper.java │ └── AssetMapper.xml ├── entity │ └── Asset.java ├── dto │ ├── AssetQueryDTO.java │ └── AssetVO.java └── common └── PageResult.java这样一个结构最大的好处是“找资料快”。新同学接手时看到AssetController就知道入口在这顺着AssetService往下摸到AssetMapper.xml整条链路一眼到底。特别是定位问题时三层各做各的事接口协议问题看 controller业务口径问题看 serviceSQL 问题看 mapper不用到处翻。Controller 里的方法尽量只做三件事解析请求参数、调用 Service、把 Service 返回值包装成 HTTP 响应。参数校验用Validated放在 DTO 字段上不要在 Controller 里写if (xxx null)这种逻辑。Service 层的接口要面向业务命名比如applyAsset、returnAsset不要叫doAsset。3.2 application.yml 里的数据源与连接池参数数据访问层配置集中在application.yml。用 MySQL HikariCP MyBatis 时配置大概是spring: datasource: url: jdbc:mysql://127.0.0.1:3306/asset_db?useUnicodetruecharacterEncodingutf8serverTimezoneAsia/ShanghaiuseSSLfalse username: root password: change_me driver-class-name: com.mysql.cj.jdbc.Driver hikari: maximum-pool-size: 20 minimum-idle: 5 idle-timeout: 600000 connection-timeout: 30000 pool-name: AssetHikariPool mybatis: mapper-locations: classpath:/mapper/*.xml type-aliases-package: com.example.asset.entity configuration: map-underscore-to-camel-case: true log-impl: org.apache.ibatis.logging.stdout.StdOutImpl这里面有几个参数值得解释。map-underscore-to-camel-case必须打开否则数据库里的create_time映射不到 Java 实体里的createTime属性查出来全是 null。log-impl开发阶段打开可以打印 SQL方便排查问题但生产环境要换成org.apache.ibatis.logging.slf4j.Slf4jImpl或者彻底关掉避免大量 SQL 日志拖垮性能。HikariCP 的连接池参数不要无脑抄。maximum-pool-size默认是 10如果系统里有定时任务、批量导入、报表导出同时跑10 个连接很容易打满。但也不能一味调大连接池越大数据库压力越高。一般管理类系统从 10~20 起步再根据监控指标调整。connection-timeout单位是毫秒30000 表示 30 秒拿不到连接就报错这个值可以根据接口耗时调整。3.3 Service 事务边界与分页查询事务边界是 Service 层最容易出问题的地方。以资产入库为例既要插入资产表又要更新分类统计表这两个操作必须在一个事务里完成要么都成功要么都回滚Service public class AssetServiceImpl implements AssetService { private final AssetMapper assetMapper; public AssetServiceImpl(AssetMapper assetMapper) { this.assetMapper assetMapper; } Override Transactional(rollbackFor Exception.class) public void createAsset(Asset asset) { assetMapper.insert(asset); assetMapper.increaseCategoryCount(asset.getCategoryId(), 1); } }Transactional(rollbackFor Exception.class)这个写法是必须养成的习惯。Spring 默认只在抛出 RuntimeException 或 Error 时才回滚如果方法里抛出一个 checked exception比如IOException事务不会回滚。把rollbackFor设为Exception.class相当于告诉 Spring只要是异常就回滚这个语义对业务系统更安全。分页查询尽量不放事务里。读操作不需要事务如果给查询方法加了Transactional它会持有数据库连接直到方法结束高并发下连接池很容易被占满。分页代码可以这样写public PageResultAssetVO pageAssets(AssetQueryDTO query) { int pageNum query.getPageNum() null ? 1 : query.getPageNum(); int pageSize query.getPageSize() null ? 10 : query.getPageSize(); int offset (pageNum - 1) * pageSize; ListAssetVO records assetMapper.selectPage(offset, pageSize, query.getKeyword(), query.getStatus()); long total assetMapper.count(query.getKeyword(), query.getStatus()); return PageResult.of(records, total, pageNum, pageSize); }手动计算offset而不是依赖 PageHelper是我踩过几次坑之后的选择。PageHelper 很灵活但startPage和查询之间只要多夹一条 SQL分页就可能失效排查起来费劲。手动limit offset逻辑简单SQL 完全可控代价只是多写一两行计算换来的是稳。3.4 动态 SQL 与多表关联的 XML 写法MyBatis 真正的威力在 XML 的动态 SQL。资产列表页需要支持关键字模糊搜索、状态过滤、分类关联名称可以这样写select idselectPage resultTypecom.example.asset.dto.AssetVO select a.id, a.asset_no, a.name, a.category_id, c.category_name, a.status, a.price, a.create_time from asset a left join category c on a.category_id c.id where if testkeyword ! null and keyword ! and (a.asset_no like concat(%, #{keyword}, %) or a.name like concat(%, #{keyword}, %)) /if if teststatus ! null and a.status #{status} /if /where order by a.create_time desc limit #{offset}, #{limit} /selectwhere会自动去掉第一个多余的 and避免写where 11这种别扭的 SQL。关键字匹配一定要用concat(%, #{keyword}, %)不要图省事写%${keyword}%那样会被 SQL 注入。如果遇到更复杂的动态查询比如多个状态值、时间范围、多表排序XML 里加foreach、choose都能处理但注意别把业务规则写进 SQLXML 里只做数据筛选和排序。4. 对外接口、监控与版本差异三层架构落地以后工程能跑通只是第一步。线上系统还要回答三个问题给第三方开放的接口放哪里系统怎么监控Spring Boot 版本升级时数据访问层哪些地方会踩坑4.1 给第三方开放的接口应该放在哪里群里有人问“Spring Boot 对外提供的接口给第三方应该放在哪里是单独服务还是放在对应业务模块”这个问题没有唯一答案但有清晰的原则第三方接口和内部接口必须隔离不能共用一套参数验收标准和异常处理。如果只是给长期合作方开放一两个查询接口规模不大可以在同一个应用里单独建一个open包用 URL 前缀区分比如内部接口走/api/admin/**第三方接口走/open/api/v1/**。这些/open接口单独配置鉴权拦截器、限流、版本号、错误码规范。给第三方返回的错误信息、状态码含义要非常明确因为人家会按照你的接口文档写调用方代码不能像内部接口那样只甩一个“系统异常”。如果第三方接口越来越多或者调用方之间需要互相隔离我建议拆成独立服务。独立服务只暴露对外 API内部通过 RPC 或 HTTP 调用核心领域服务不让第三方直接穿透到数据库层。判断标准很简单你看这个服务会不会因为某个外部客户的需求频繁改接口、发版本如果会就拆出去。国际支付、供应链对接、企业微信回调这类场景基本都值得独立服务。4.2 Actuator 与 Spring Boot Admin 补上监控层三层架构关心的是“代码怎么组织”线上运营还要关心“系统现在什么状态”。Spring Boot 的 Actuator 是监控的底座先加依赖dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-actuator/artifactId /dependency然后配置要暴露的端点management: endpoints: web: exposure: include: health,info,metrics,threaddump,heapdump endpoint: health: show-details: always注意不要配置成health默认只暴露存活检查metrics能看到 JVM 内存、线程数、HikariCP 连接池活跃连接数这些指标能直接反映数据访问层的健康度。比如资产领用高峰时接口变慢先看hikaricp.connections.active如果一直顶着maximum-pool-size那就是连接池不够或者有连接泄漏不用靠猜。Spring Boot Admin 是在 Actuator 之上的可视化面板SBA Server 负责展示业务服务作为 Client 接入。它能聚合多服务状态、查看日志级别、实时线程情况。对小团队来说Actuator 定时健康检查已经够用如果服务数量上来了再上 Spring Boot Admin 也不迟。4.3 Spring Boot 2.3.x、2.6.x、3.x 的进阶坑现在不少存量项目还跑在 2.3.x新项目已经用 Spring Boot 3。数据访问层升级时要注意几个关键差异对比项2.3.x2.6.x3.x最低 JDK8817包名javax.*javax.*jakarta.*Spring Security 写法SecurityConfigurerAdapter同左SecurityFilterChainSpringfox Swagger兼容兼容性差不支持易用替代方案springfoxspringdocspringdoc最影响数据访问层的其实是包名变化。Spring Boot 3 里所有javax.persistence.*、javax.sql.*、javax.servlet.*都换成了jakarta.*如果你用的是 JPA升级后 Entity 的Entity、Table注解包名要全部改批量替换一下就是几分钟的事。MyBatis 本身的注解基本没受影响但和 Spring Boot 3 匹配的mybatis-spring-boot-starter版本必须用新版本老版本启动时会直接报兼容错误。另外Spring Boot 2.6 开始把默认的路径匹配策略改成了PathPatternParser如果项目里老版本 Springfox 想升级到 2.6启动时经常会遇到空指针老老实实换成 springdoc 或者改路径匹配策略不要硬扛。社区版 IDEA 想跑 Spring Boot 3JDK 17 装好就行不需要特定 IDE 功能。5. 常见问题与排查技巧实录这部分全是实际项目里踩过的坑整理成速查表遇到类似的可以直接照方抓药。5.1 懒加载导致 LazyInitializationException用的是 Spring Data JPA查询一条资产记录返回给前端前访问它的category对象抛LazyInitializationException原因是 Service 事务结束后 Session 已经关了懒加载集合没法再初始化。排查思路是别在事务外面访问懒加载属性要么在 Service 里用join fetch显式关联查询要么干脆用 DTO 投影只查需要的字段。我的习惯是列表页和详情页全部用 DTO 投影暂时不需要的字段坚决不查。实体类只做写入操作的载体。这样虽然多定义几个 DTO但每个接口使用的字段是确定的不会因为某次访问了懒加载属性导致整个接口挂掉。5.2 事务失效的四个经典场景事务失效是数据访问层的高发问题常见四类方法不是 public 导致代理不生效同类内部方法自调用导致事务注解被跳过异常被 try-catch 吞掉导致事务感知不到抛出 checked exception 但没配rollbackFor。Service public class AssetServiceImpl { public void batchCreate() { createAsset(); // 自调用这里的事务注解不生效 } Transactional(rollbackFor Exception.class) public void createAsset() { // ... } }处理办法很简单自调用拆到另一个 Service 或者注入自身的代理对象异常要么在边界抛出要么显式TransactionAspectSupport.currentTransactionStatus().setRollbackOnly()。最省心的做法是让 Service 方法的异常尽量抛出去Controller 层统一捕获不要在事务方法内部 catch 掉。5.3 HikariCP 连接池被打满现象是接口偶尔超时日志里出现HikariPool-1 - Connection is not available这种情况基本是连接池不够或者有连接泄漏。排查步骤先看 Actuator 的hikaricp.connections.active指标如果持续接近最大值再用SHOW PROCESSLIST看数据库侧睡眠连接有没有很多。我遇到过一次特别隐蔽的泄漏定时任务里自己封装了一个 JDBCConnectiontry-with-resources 写漏了导致每个执行周期泄漏两个连接跑一周后接口集体变慢。后来把所有手动获取连接的代码都改成JdbcTemplate或 MyBatis池内连接由框架管理这种问题才彻底消失。5.4 MyBatis 查询结果全是 nullSQL 能查出来但 Java 对象里全是 null大概率是列名映射问题。数据库字段是create_timeJava 属性是createTime没开map-underscore-to-camel-case就一定匹配不上。打开后又发现关联查询的字段对不上那就用resultMap显式定义映射关系。排查时先把 MyBatis 的log-impl打开看它打印出的 SQL 和参数确定 SQL 结果集到底返回了什么列。如果列名和属性名完全对不上直接在 SQL 里起别名是最快的办法比如select a.create_time as createTime。5.5 PageHelper 分页失效PageHelper.startPage(pageNum, pageSize)明明写了但查询结果还是全量数据大概率是 startPage 后没有紧跟查询或者中间执行了其他 SQL。PageHelper 的拦截器机制决定了它只拦截第一个查询后面再插一个select就会把分页作用到错误的 SQL 上。手动分页不存在这个问题所以我后来基本不用 PageHelper。手写limit #{offset}, #{limit}时注意offset别传错pageSize要限制最大值防止有人把几千条数据一次拉到前端。做数据访问层这么久我最大的体会是不管用 MyBatis 还是 JPA不管分页用插件还是手写核心都要落在职责清晰上。每次写查询前先想清楚“这个查询是给哪个页面用的、要返回哪些字段、能不能抽成 DTO”想明白了再动手写 SQL。这样看似慢一点但后面改需求、排故障都会省很多时间。
返回列表