ARTICLE DETAIL

资讯详情

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

t3code三层架构实践:后端业务代码分层的完整落地指南

t3code三层架构实践:后端业务代码分层的完整落地指南 t3code 这个项目名乍看像个开源框架的代号其实它背后一直说的是后端开发里最朴素、也最容易被低估的问题你怎么组织业务代码才不至于让项目在半年后变成一锅粥。我在一线写代码十来年见过太多项目一开始只有几十个接口三个月后 Controller 里堆了上百行业务 SQLService 互相调用绕成环DTO 满天飞改一个字段要翻遍五六个文件。t3code 本质上就是一套我在多个项目里反复调整后沉淀下来的三层架构代码实践——Three-Tier Code把表现层、业务层、数据访问层拆清楚把依赖方向定死把每一层之间数据流动的规则定明白。这篇博文就围绕 t3code 的完整落地过程展开覆盖分层设计、代码组织、实操示例、问题排查和复杂场景演化适合刚接触架构的后端开发也适合被现有代码折磨过、想重新理清模块边界的老手。1. 整体设计拆解三层架构到底解决什么问题1.1 一个让所有后端都头疼的场景先抛一个场景需求方说下单时要检查库存、扣减库存、生成订单、发一条通知。新手最容易写成什么一个createOrder方法搞定先查库存再UPDATE库存表再INSERT订单表再调消息服务。起初确实跑得通但后面问题会一个个浮上来下单逻辑里突然插入了一段短信发送代码支付接口也想复用库存扣减被迫把这部分复制粘贴过去接口返回结构一会儿是Map一会儿是裸对象前端对接全靠猜业务规则和 SQL 混在一起DBA 想优化一条慢查询得从 Controller 一路查到 Mapper。这类问题的根子不在某个人的编码水平而在于代码没有边界。t3code 给出的解法非常朴素把系统纵向切成三块——表现层、业务层、数据访问层每一层只允许做自己该做的事并且强制规定依赖方向只能从上往下。这样设计的好处最直接的一点就是改动局部化。数据库表结构变了多半只动数据层前端字段调整多半只动表现层真正核心的业务规则在中间业务层被保护起来不会被两边频繁变化的需求波及。还有一种更隐蔽的价值是团队协作时的“免沟通区”。两个人同时改订单模块一个专注写 Controller 和参数校验另一个专注写 Service 里的下单规则只要接口签名提前约定好代码冲突概率大幅下降。对新人来说看到一个类就知道它属于哪一层、能干什么、不能干什么上手成本也低很多。1.2 三层各管一段边界画清楚先明确一下三层各自的职责避免出现“我知道要分层但不知道‘什么代码放哪层’”的尴尬。表现层平时也叫接口层、Controller 层它管的事情就四件接收参数、做最基础的格式校验、调用业务层、把结果组装成响应对象。这里要特别强调一个原则表现层不应该出现任何业务规则。比如“下单时库存不足返回 500”这种判断就不该写在 Controller 里Controller 只需要知道调用成功还是抛了异常至于库存是否足够、要不要回滚那是业务层的工作。业务层是 t3code 的心脏。它负责用例编排、业务规则校验、事务控制、跨数据源的协调。同样拿下单举例业务层要做的是先校验参数比如商品是否下架、再查库存、再扣库存、再生成订单、再发送通知整个过程包在一个事务里。业务规则都收拢在这里最大的好处是可以脱离 HTTP 和数据库单独测试——直接 new 一个 Service 出来传一个假的 Repository就能把下单流程跑通。数据访问层最纯粹就是跟数据库打交道定义 Entity、写 Repository、处理 SQL 映射、管理基础的数据读写。注意这层不该有业务判断也不该有事务逻辑事务通常开在业务层数据层只负责执行。我经常跟团队说把数据层想象成一个“数据快递员”它只负责准确取货、送货至于这批货用来干什么不归它管。这样切完之后整个系统的数据流向就固定下来表现层接收外部请求转成业务层认识的 DTO业务层处理完再让数据层落库反过来查询的时候原路返回。每一层都只跟紧邻的下一层打交道不会出现 Controller 直接注入 Repository、Service 里拼 HTML 之类的事。这个规定看着死板但实际写起来反而轻松因为每写一个方法你根本不用思考“这个方法该放哪”位置是天然的。2. 核心细节解析层与层之间怎么配合2.1 先定目录再写代码很多项目分层失败的真正原因不是没有三层而是目录结构没定清楚最后层和模块全搅在一起。t3code 目录设计有个推荐套路先按业务模块拆再按技术层次分而且每一层都用独立的后缀或包围名标清楚。一个典型的后端项目结构长这样com/company/project ├── controller # 表现层 │ ├── OrderController.java │ └── request/ # 入参对象 │ └── response/ # 出参对象 ├── service # 业务层 │ ├── OrderService.java │ ├── impl/ │ └── dto/ # 层间传输对象 ├── repository # 数据访问层 │ ├── entity/ │ └── mapper/ └── common # 跨层通用件异常、常量、工具类很多人会纠结service/impl这个目录要不要保留我的经验是如果 Service 只有一个实现没必要抽接口直接一个类就行抽了反而是负担只有当同一业务有多个实现策略或者明确要为测试替换实现时再考虑接口加实现。后面细讲。模块维度上建议按业务域组织比如订单、用户、支付各自一个模块模块内再分 controller/service/repository。这样当订单模块膨胀到上千个文件时你能快速定位不至于在一个巨大的 controller 目录里翻几百个文件。按层打包也不是完全不行小项目里反而更直观但项目一大就要靠文件名猜归属体验很差。还有个细节容易忽略common 目录要克制。跨层通用的异常类、返回值包装类可以放但不要什么都往里塞。我在项目里见过common/util里躺着十几个工具类结果所有层都依赖它任何工具类改动都导致全部模块重新编译这不是分层是制造耦合。2.2 Entity、DTO、VO三层各自的数据载体t3code 里最容易吵起来的就是“数据对象怎么定义”。我见过最省事的做法是直接在 Controller 里返回 Entity让 ORM 框架把数据库表结构直接序列化成 JSON 给前端。这个做法早期很爽后期很痛表里的字段名暴露给前端任何数据库结构调整都直接影响接口某些大字段或内部状态也裸奔出去安全性和可维护性都有问题。所以 t3code 定了三个清晰的数据载体Entity是数据层专用的跟表结构一一对应一个字段对应一个列名不掺任何业务含义。它应该只活在 Repository 和 Service 之间最底线是不能直接传给 Controller 返回给前端。DTO是业务层的数据传输载体。它描述一次业务交互中需要哪些数据字段可能来自多张表也可能是计算后的结果。它存在的意义是让业务层的入参和出参稳定不随表结构变化。举个例子下单接口需要的CreateOrderDTO里面可能有userId、skuId、quantity、addressId这跟底层订单表的字段并非一一对应DTO 把这些业务含义明确下来。VO是表现层的视图对象专门用来定义接口返回给前端的结构。VO 可以按前端需要做字段裁剪比如把 BigDecimal 金额格式化成字符串把时间戳转成指定格式或者把订单明细嵌套进去。简单说Entity 是给数据库看的DTO 是给业务看的VO 是给前端看的。很多项目死在转换代码的繁琐上。我的建议是不要指望框架自动映射解决一切。像 BeanUtils 拷贝字段确实简单但字段名一旦不一致或者源对象和目标对象的字段类型有细微差别就会产生运行时错误。在关键链路上手写转换逻辑更可靠哪怕多几行代码至少编译期能发现问题非关键路径再用映射工具提速。另外层间转换放在哪一层也有讲究我习惯放在 Service 的边界处Controller 拿到的就是可直接返回的 VOService 负责把 Entity 组装成 VO 或把入参 DTO 转换成业务对象这样数据层完全不知道外部长什么样。2.3 依赖注入让上层拿接口而不是找实现三层架构里最常见的一个反问是为什么 Controller 不直接 new 一个 Service这就要回到依赖管理了。如果 Controller 直接new OrderService()那么 Service 的一切内部依赖比如它依赖的 Repository都要由 Controller 来创建这一层层的创建关系会像滚雪球一样越滚越大最后所有类都在互相 new没人分得清谁该负责谁的生命周期。t3code 的做法是统一用依赖注入容器管理对象生命周期。Controller 只声明自己需要一个OrderServiceSpring 之类的容器负责在启动时找到实现并塞进来Service 只声明自己需要一个OrderRepository容器再负责注入。这样每一层都只知道“我需要什么”不关心“这个东西怎么来的”。写代码的人唯一要遵守的规则是通过接口或类构造器接收依赖不要自己去 new 依赖对象。具体到实现方式一定优先选构造器注入。它的好处很明显对象被创建时依赖一定齐了不存在“先 new 出来再慢慢 set 依赖”的半初始化状态单元测试时直接 new 一个 Service把假 Repository 传进去就行不需要启动整个容器还能直观看出一个类依赖了多少东西如果构造器参数超过五六个通常意味着这个类职责过重该拆分。还有一个经验之谈不是每个 Service 都要先写接口再写实现。如果只有一个实现而且没有可预见的第二种实现那直接公开类、让容器注入类即可。强制给所有类制造接口只会消耗额外的维护成本让代码结构看起来“规范”但实际全是噪音。什么时候需要接口当这个 Service 有多套实现策略比如订单价格计算有普通用户价和会员价、或者要方便测试替换真实依赖时再抽接口其余情况保持简单。3. 实操过程用 t3code 风格写一个订单模块讲了这么多原则不如直接看一个完整的订单模块怎么落地。下面的代码示例基于 Java 生态和 Spring Boot但思路迁移到其他语言是一样的。3.1 数据层先跟表结构对齐假设有一张订单主表CREATE TABLE orders ( id BIGINT AUTO_INCREMENT PRIMARY KEY, user_id BIGINT NOT NULL, sku_id BIGINT NOT NULL, quantity INT NOT NULL, total_amount DECIMAL(12, 2) NOT NULL, status TINYINT NOT NULL DEFAULT 0, version INT NOT NULL DEFAULT 0, created_at DATETIME NOT NULL, updated_at DATETIME NOT NULL );比较讲究的做法是Entity 跟表结构严格对齐字段名保持数据库公共风格不在这一层做任何业务命名上的美化。public class OrderEntity { private Long id; private Long userId; private Long skuId; private Integer quantity; private BigDecimal totalAmount; private Integer status; private Integer version; private LocalDateTime createdAt; private LocalDateTime updatedAt; // getter / setter 省略 }Repository 只做基础数据操作比如根据 ID 查一条、根据条件分页查、插入、更新。不要把一套“查订单且统计用户积分且返回前端展示名”的逻辑写在这里那属于业务层组装的事情。Repository public class OrderRepository { // 这里使用 MyBatis-Plus 或 JdbcTemplate 均可核心是方法只做一件事 public OrderEntity findById(Long id) { // 执行 SQL: select * from orders where id ? } public OrderEntity findByLockVersion(Long id, Integer version) { // 执行 SQL: select * from orders where id ? and version ? } public int insert(OrderEntity entity) { // 执行 insert返回影响行数 } public int updateWithVersion(OrderEntity entity) { // 执行 update ... where id ? and version ? } }这里有两个实操要点值得单独说。第一点是乐观锁实现。表单里故意留了version字段这是做并发控制最常见的手段。扣减库存时不要只执行UPDATE stock SET quantity quantity - 1 WHERE sku_id ?那行写法在并发下会丢更新。正确姿势是先查出当前 version更新时在 SQL 的 where 条件里带上 version同时把 version1如果影响行数为 0说明数据被其他人改过了业务层再决定重试还是报错。这笔账算下来不会亏代价不过是一个字段、一条 where 条件却能把最棘手的并发问题挡住一半。第二点是批量操作的取舍。数据层里尽量避免在 for 循环里单条插入那会产生大量小事务和网络往返。要么用批量插入 SQL要么用事务里的分批提交。当然这也要看数据库连接池够不够用不能一概而论。我的习惯是单次操作在 100 条以内且接口流量不大时直接循环也没啥问题一旦数据量上来优先用批量接口。3.2 业务层把规则和服务放在这里业务层是订单模块里代码量最大、也最需要小心设计的地方。先看一个完整的下单流程Service public class OrderService { private final OrderRepository orderRepository; private final StockRepository stockRepository; private final UserRepository userRepository; public OrderService(OrderRepository orderRepository, StockRepository stockRepository, UserRepository userRepository) { this.orderRepository orderRepository; this.stockRepository stockRepository; this.userRepository userRepository; } Transactional public OrderVO createOrder(CreateOrderDTO dto) { // 1. 业务校验 UserEntity user userRepository.findById(dto.getUserId()); if (user null || user.getStatus() ! 1) { throw new BizException(用户不可用); } // 2. 扣减库存带乐观锁 boolean deducted stockRepository.deductStock(dto.getSkuId(), dto.getQuantity()); if (!deducted) { throw new BizException(库存不足); } // 3. 构建订单 OrderEntity order new OrderEntity(); order.setUserId(dto.getUserId()); order.setSkuId(dto.getSkuId()); order.setQuantity(dto.getQuantity()); order.setTotalAmount(calculateAmount(dto)); order.setStatus(0); order.setVersion(0); orderRepository.insert(order); // 4. 返回 VO return toVO(order); } private BigDecimal calculateAmount(CreateOrderDTO dto) { // 价格计算规则可能涉及会员折扣、优惠券等 return BigDecimal.valueOf(100).multiply(BigDecimal.valueOf(dto.getQuantity())); } private OrderVO toVO(OrderEntity order) { OrderVO vo new OrderVO(); vo.setOrderId(order.getId()); vo.setStatus(order.getStatus()); return vo; } }注意几个决定成败的细节。事务注解要出现在业务层而不是数据层或表现层。为什么因为事务的本质是“多个数据操作要么全部成功、要么全部回滚”而这个“多个操作”的编排逻辑是由业务层负责的。如果事务开在数据层那一次下单涉及库存扣减和订单插入两个操作就没办法组成一个原子操作如果事务开在表现层那一切 HTTP 请求都包在事务里数据库连接会被长时间占用同时 Controller 里的非业务代码也被强行纳入事务完全没道理。所以 t3code 的规律是事务和业务用例同生共死。再就是业务层不要出现具体 SQL。有人会在 Service 里 JdbcTemplate 一把梭查库存、扣库存、更新订单全写完了。这样做的唯一好处是少写几个类坏处是数据库变更必须连带改业务代码测试也更难做。把 SQL 收拢到 Repository 之后业务层真正关注的只有业务规则和流程编排代码读起来像在讲一个用例故事而不是在看执行计划。业务层还承担着一个很关键的任务定义异常和业务状态。比如库存不足、用户被禁用、订单状态不允许取消都不应该通过返回 null 或者用布尔值悄悄表达。我习惯在业务层抛出明确的业务异常由表现层统一翻译成 HTTP 状态码和错误信息。这样业务层测试时可以直接断言抛出了什么异常比断言一个魔法值可靠得多。3.3 表现层入口拿参、出口给响应表现层代码的核心是“薄”。看一个 Controller 模板RestController RequestMapping(/orders) public class OrderController { private final OrderService orderService; public OrderController(OrderService orderService) { this.orderService orderService; } PostMapping public ApiResponseOrderVO create(RequestBody Valid CreateOrderRequest request) { CreateOrderDTO dto new CreateOrderDTO(); dto.setUserId(request.getUserId()); dto.setSkuId(request.getSkuId()); dto.setQuantity(request.getQuantity()); OrderVO vo orderService.createOrder(dto); return ApiResponse.success(vo); } GetMapping(/{id}) public ApiResponseOrderVO getById(PathVariable Long id) { return ApiResponse.success(orderService.getOrderById(id)); } }CreateOrderRequest是专门用来接收前端 JSON 参数的入参对象上面带着校验注解比如NotNull、Min(1)之类。这样做的好处是校验失败会在进入业务层之前就拦住业务层不需要重复判断参数是不是 null。不过也别指望注解校验能覆盖所有规则——像“库存不足”“商品已下架”这种依赖数据库状态的校验必须留在业务层。一句话总结能静态判断的校验放表现层动态判断的业务规则放业务层。响应体统一用ApiResponse包装固定成code、message、data三段再配合一个全局异常处理器把业务异常翻译成对应状态码。异常处理这块要强调不要用异常控制业务正常流程。比如用户取消订单如果取消成功就正常返回如果不允许取消抛业务异常没问题。但不要把“执行成功与否”寄托在捕获某个特定异常上——比如用 try-catch 包裹stockRepository.deductStock后靠异常来判断库存不足代码可读性极差。用返回值判断状态比用异常跳转要清晰得多。3.4 全链路一次下单请求如何走完三层串一遍整个调用链路你就能直观感受到分层的价值。前端 POST/orders请求 JSON 先落到OrderController.createController 从请求体里取出参数手动组装成CreateOrderDTO交给OrderService.createOrderService 先查用户状态再调StockRepository.deductStock扣库存扣成功就构建OrderEntity通过OrderRepository.insert落库最后把OrderEntity转换成OrderVO返回Controller 拿到OrderVO后包一层ApiResponse响应给前端。这条链路上有几处“边界关卡”值得留意。Controller 和 Service 之间DTO 的转换是双向下棋的地方Controller 接收前端入参Service 决定业务数据怎么流转。Service 和 Repository 之间Entity 是唯一允许出现的对象业务层不应该把 VO 往下传数据层也不应该把数据库字段原样抛到上层。任何一个环节出现跨层对象穿越比如直接把 Entity 返回给前端、或者 Repository 方法签名里出现CreateOrderRequest都应该视为设计走样。有人会问这样多转几次对象性能会不会变差我的回答是这种转换的开销在现代应用里几乎可以忽略而它换来的解耦价值极高。真正需要担心性能的是数据库的 N1 查询、大事务、锁等待这些而不是几个对象的字段拷贝。业务代码优先写清晰等到压力测试真发现这里有问题再针对性优化也不迟。4. 常见问题与排查技巧实录4.1 Entity 穿透三层序列化炸在脸上这是团队里最容易出现的问题。一开始为了省事Controller 直接return orderRepository.findById(1)前端拿到订单表所有字段包括version、updatedAt这些不该暴露的东西。后来数据库加了一个内部备注字段前端联调时发现响应里多出一个字段或者因为 Entity 里加了某个非数据库字段导致 JSON 序列化死循环接口直接 500。这类问题的排查其实不难只要一看到 Controller 的返回类型是 Entity或者 Entity 类里出现了JsonIgnore、JsonProperty这类注解就说明分层边界破了。纠正办法就是把返回类型改成 VO并且在代码评审里把这条作为红线。我个人还会在 Entity 类注释里写一句“本类禁止直接出现在 Controller 返回值中。”这个提示在团队协作里比想象中管用。4.2 事务开在业务层却控制不住数据层连接有次排查一个线上故障高峰期数据库连接被打满。一看日志发现某个 Service 方法上有Transactional里面又循环调了几十次 Repository 逐个插入导致一个请求占着数据库连接长达好几秒流量一大连接池瞬间耗尽。事务本身没错错的是事务范围失控。排查思路很简单先看事务注解标在哪里再看方法体内有没有网络请求、循环写操作、远程调用。如果事务方法里有调用其他系统的 HTTP 请求那就更要命了——事务会一直攥着数据库连接等待远端响应。正确姿势是事务只覆盖真正需要原子性的数据库操作远程调用尽量放到事务提交之后再执行批量写入要么分成合理的批次要么改用真正的批量 SQL。这类问题通常不在代码评审阶段暴露得等线上性能指标出现异常才能发现。我建议项目里做一次系统性的“事务边界审查”把每个Transactional方法列出来看方法里到底做了什么。这个表做出来后你会对业务逻辑的健康程度一目了然。4.3 异常层层包装日志里全是噪音刚开始建项目时大家喜欢在每层 catch 一遍异常再重新抛个新异常数据层抛DataAccessException业务层 catch 后包装成BizExceptionController 又 catch 一遍记日志再抛ApiException。结果一条 SQL 超时日志里出现三四条堆栈真正的根因埋在最底下排查效率极低。我的建议是分层确实可以定义不同的异常类型但不要每层都做机械包装。数据层抛出技术异常原样上抛业务层把技术异常转成业务异常时一定要把原始异常作为 cause 传进去表现层不要重复打印堆栈统一交给全局异常处理器记录一次。这样一条异常链路从后到前只有一个出口、一次记录日志干净得多。排查时如果发现日志里同一个异常出现多次堆栈基本就能断定存在多重包装。这时你需要做的不是补齐更多 catch 块而是删掉中间层那些无意义的 catch。4.4 排查速查表症状可能原因排查方向Controller 返回了数据库多余字段Entity 直接当响应对象改成 VO切断跨层返回一个请求数据库连接被占很久事务范围过大包含远程调用或大批量循环写缩小事务边界远程调用移到事务外日志出现重复堆栈多层 catch 重复包装异常清理不必要的 catch保留全局异常出口扣库存后数据不一致乐观锁未实现或事务边界不对检查 version 字段更新逻辑和事务覆盖范围Service 方法参数巨多该类职责过重拆分 Service 或引入 DTO 聚合参数这张表是我在项目复盘时常用的检查清单每次代码重构后都会过一遍。它不能解决所有问题但能快速把大多数分层代码的“病灶”定位出来。5. 复杂项目里还能怎么演化5.1 加一条事件通道解耦跨层通知三层架构最常被诟病的一点是业务层要发短信、发消息、写日志时总免不了直接调用别的服务导致业务层依赖越来越重。一个很自然的演化就是引入领域事件。具体做法是业务层只负责把“订单已创建”这个事实发布成一个事件对象由事件总线或消息队列异步消费。发送短信、同步搜索索引、触发优惠券计算都变成事件的订阅方。这个做法的核心收益是业务层的“下单用例”不再关心到底有多少后续动作后续加一个“下单后赠送积分”业务层不用改一行代码只新增一个监听器即可。这个演化属于 t3code 的自然延伸分层解决的是纵向边界事件解决的是横向解耦。但注意不要一上来就上 Kafka、RocketMQ小项目用个进程内事件总线就够了。只有确定需要跨服务、高可靠消息时才引入消息中间件否则那是给自己找运维负担。5.2 业务复杂到一定程度分层的下一站是什么项目做到两三年业务规则越来越多你会感受到纯三层架构的一些吃力订单状态流转散落在多个 Service 方法里价格计算规则越来越长Service 膨胀成上千行的“上帝类”。这时候可以考虑往领域驱动设计DDD的方向调整。DDD 和三层架构并不冲突它更像是把原来业务层继续细分引入领域对象、领域服务、仓储接口。原来业务层里的“业务规则”下沉到领域对象里业务层本身变成用例编排层。比如订单的“取消”操作原来可能是 OrderService 里一个方法做一堆 if 判断在 DDD 下可以在 Order 聚合根上定义一个cancel()方法由 Order 自己维护状态转移规则。这套玩法需要一定的建模功底但和 t3code 的核心理念一致——都是让规则和数据按边界归位。如果你的项目出现了“一个 Service 方法超过两百行且里面大量 if 判断订单状态”的情况就该考虑往 DDD 方向重构了。不过我也要泼一盆冷水不要为了 DDD 而 DDD如果你的业务就是简单 CRUD勉强引入聚合、值对象只会制造无谓复杂度。t3code 的三层是地基DDD 是在地基之上按需加盖。5.3 微服务拆分后的分层长什么样把单体拆成微服务后很多人误以为三层架构就没用了。实际恰恰相反每个微服务内部几乎都还是三层结构只是三层的职责边界发生了变化。数据访问层彻底收归本服务不再允许跨服务访问别人的表表现层变成了给对方服务暴露的 OpenAPI 接口业务层不仅要处理本服务内部规则还要编排对其他服务的调用。这时候需要谨慎处理的是“分布式事务”问题纯三层时代的一个事务在微服务里变成了跨服务的多步操作原来的Transactional只管得了本地数据库。常见的补偿思路是本地消息表、事务消息、Saga 模式这些都可以看作是 t3code 中“业务层编排”职责的外延。有种更极端的做法叫 “bff” 模式专门搞一层聚合层对外提供大而全的接口对内调用各个微服务。说白了这一层本质上还是个表现层只是在旧的 Controller 之上又套了一层“门面”。所以不用被“微服务”三个字吓住分层的思想内核没变变的只是边界的粒度和位置。我自己把这些年做 t3code 的经验浓缩成一句话分层的本质不是规定代码放哪个包而是控制依赖的方向。只要依赖方向是稳定的、数据载体是清晰的、事务和异常边界是明确的哪怕你的目录命名跟我的不完全一样这个项目的底座就是稳的。真到了线上出了问题你翻开日志能快速定位到层改起代码来敢说“我只动这一块”这就是 t3code 带给我最大的安全感。
返回列表