ARTICLE DETAIL

资讯详情

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

三层架构实战:用Controller-Service-DAO重构混乱代码,打造清晰工程

三层架构实战:用Controller-Service-DAO重构混乱代码,打造清晰工程 前阵子接手一个维护了三年的老系统打开一个Controller类1800多行里面塞满了SQL拼接、状态判断、Excel导出甚至还有定时任务的逻辑。改一个字段要顺着调用链翻十来个文件每次发版都战战兢兢。那段时间我一直在想到底怎么把代码从这种状态里捞出来。后来花了两个周末把核心业务拆出来重写做成一个名为t3code的示例工程——T3就是Three-Tier三层架构表示层、业务逻辑层、数据访问层各管各的事。这篇文章就是基于t3code这个项目沉淀下来的完整总结包括目录结构、层间边界、核心代码、踩过的坑。如果你正在写企业级后端或者刚接手一个逻辑混乱的老项目这篇应该对你有用。1. t3code的设计思路为什么要框在三层里1.1 三层架构的初衷与边界三层架构不是新东西但它至今仍是后端工程化最稳的骨架。它的核心思想是职责分离Controller不写业务Service不碰SQLDAO只做数据存取。听起来简单实际操作中边界感很难拿捏。我在t3code里对每一层的职责做了严格定义表现层Controller层接收HTTP请求、做参数格式校验、调用业务层、把结果转成响应的VO。业务逻辑层Service层处理业务规则、事务控制、状态流转、跨聚合的编排。数据访问层DAO/Repository层只负责对数据库的增删改查不掺和业务判断。这个边界定义直接决定了代码能不能良性演化。拿订单创建举例校验用户是否在黑名单应该放在Service层因为这是业务规则校验下单请求里quantity是否大于0放在Controller层因为这是入参合法性把订单记录insert进数据库放在DAO层因为在那一层我们只关注持久化。1.2 层与层的依赖方向三层架构的依赖方向是单向的Controller依赖ServiceService依赖DAO。反向依赖一旦出现架构就瞬间崩坏。我在t3code里用接口隔离依赖就是让调用方只依赖接口定义不依赖实现细节。这么做有两个好处可替换性以后从MySQL换到PostgreSQL或者引入缓存做读写分离DAO的实现类可以替换调用方代码不用改。可测试性Service层的单元测试可以直接Mock一个DAO接口不用启动Spring容器。依赖方向是架构的交通规则违反规则短期看不出问题等模块多了以后改一个需求会牵出一堆连锁修改那个酸爽我经历过。1.3 t3code的完整目录结构t3code的代码组织方式如下我直接放出目录结构给你参考t3code ├── pom.xml ├── src/main/java/com/t3code │ ├── T3codeApplication.java # 启动类 │ ├── controller # 表现层 │ │ ├── OrderController.java │ │ └── support/GlobalExceptionHandler.java │ ├── facade # 门面服务可选层 │ │ └── OrderFacade.java │ ├── service # 业务逻辑层 │ │ ├── OrderService.java # 接口 │ │ ├── OrderServiceImpl.java # 实现 │ │ └── model/ │ ├── dao # 数据访问层 │ │ ├── OrderDAO.java │ │ ├── OrderDAOImpl.java │ │ └── mapper/ (或 Repository) │ ├── domain # 领域模型 │ │ ├── Order.java │ │ └── OrderItem.java │ ├── dto # 入参/出参对象 │ │ ├── CreateOrderRequest.java │ │ └── OrderResponse.java │ ├── enums # 枚举 │ │ └── OrderStatus.java │ └── common # 通用组件 │ ├── Result.java │ ├── BusinessException.java │ └── PageResult.java └── src/main/resources ├── application.yml └── mapper/ (MyBatis XML)注意这里的domain目录放的是与数据库表结构对应的实体类dto目录放的是接口入参和返回对象。我在实际项目里用domain实体做持久化用dto做接口通信两层之间通过转换器互转。有的团队会把这两者合并成一个对象到处传省事是省事但后患无穷这一点在第2.4节细讲。2. 核心代码的骨架与关键实现2.1 实体与数据结构的设计实体设计是分层架构的地基。我在t3code里选了一个非常典型的业务场景——订单系统包含订单主表、订单明细表、商品表。订单实体的核心字段如下public class Order { private Long id; private String orderNo; // 业务订单号 private Long userId; // 下单用户 private Integer totalAmount; // 总金额单位分 private OrderStatus status; // 状态枚举 private LocalDateTime createdAt; private LocalDateTime updatedAt; }实体和数据库字段一一对应不掺额外的逻辑。有个细节金额一律以分int/long为单位存储避免double精度问题。这是我在生产环境吃过大亏后养成的习惯单价0.1元的商品买三件double一算变成0.30000000000000004订单金额对不上账。用整数分既精确又方便展示时转换。状态用枚举而不是魔法数字也是t3code里的一条铁律。订单状态从待支付到已支付到已发货这些字符串散落在业务代码里的话总有一天会有人把已支付写成已付款然后排查半天bug。枚举集中管理编译期就能发现拼写错误。2.2 数据访问层只做数据存取数据访问层的接口设计我坚持一个方法做一件事。下面这段代码是t3code里订单DAO接口public interface OrderDAO { Order findById(Long id); ListOrder findByUserId(Long userId, PageQuery pageQuery); int insert(Order order); int updateStatus(Long id, OrderStatus status); int countByUserId(Long userId); }你可能会问为什么不像很多人写的那样直接搞一个OrderMapper接口里面塞十来个查询方法关键是语义要清晰。findById是主键查询findByUserId是列表查询updateStatus明确告诉你只更新状态字段那setStatusAndTotalAmount不该出现在这里因为那是业务规则应该由Service控制。DAO实现类不允许出现这样的代码——先查出来再判断或者循环里再调一次查询。这是N1问题的温床我在第4.3节专门讲。数据访问层需要保持无脑它不关心订单能不能创建只负责把订单数据写进去。如果用的是MyBatisXML里SQL的参数映射要小心别用#{param1}这种位置参数可读性太差用Param(userId)或者封装成查询对象。t3code里我用了一个OrderQuery对象做复杂查询入参字段有status、userId、startTime、endTime、pageNum、pageSize一个对象传参增删字段都方便。2.3 业务逻辑层串联规则与事务业务逻辑层是分层架构里最重的一层也是设计难度最高的一层。t3code里创建订单的Service逻辑是这样的Service public class OrderServiceImpl implements OrderService { private final OrderDAO orderDAO; private final OrderItemDAO orderItemDAO; private final ProductDAO productDAO; private final IdGenerator idGenerator; Override Transactional(rollbackFor Exception.class) public OrderVO createOrder(CreateOrderRequest request) { // 1. 参数校验业务规则 User user userDAO.findById(request.getUserId()); if (user null || user.getStatus() ! UserStatus.NORMAL) { throw new BusinessException(用户不存在或已被禁用); } // 2. 幂等校验 if (orderDAO.findByOrderNo(request.getClientToken()) ! null) { throw new BusinessException(重复下单); } // 3. 组装并保存订单 Order order OrderBuilder.create(request); order.setOrderNo(idGenerator.generate()); order.setStatus(OrderStatus.CREATED); orderDAO.insert(order); // 4. 更新商品库存 productDAO.decreaseStock(request.getProductId(), request.getQuantity()); // 5. 返回组装好的结果 return OrderAssembler.toVO(order); } }这段代码集中体现了业务层该干的事用户状态校验是业务规则不该放在Controller。幂等校验保证同一个订单请求不会被处理两次属于重要的业务约束。Transactional保证订单表和库存表要么同时成功要么同时回滚这是业务层的另一个关键职责。有一点必须提醒事务不要拆到Controller层。我在一些老代码里看到Transactional打在Controller方法上一个请求进来就开启一个大事务如果业务里还有远程调用那事务持有时间会非常长数据库连接直接占满系统就挂了。事务范围应该控制在业务方法内部且只包含必要的数据操作。2.4 表现层只做参数校验与结果适配Controller层在t3code里被克制得很薄。我见过太多Controller里写满业务逻辑的例子所以我的Controller只做三件事RestController RequestMapping(/api/orders) public class OrderController { private final OrderService orderService; PostMapping public ResultOrderVO create(RequestBody Valid CreateOrderRequest request) { // Controller只做参数校验业务交给Service OrderVO orderVO orderService.createOrder(request); return Result.success(orderVO); } }Valid负责格式校验比如quantity不能为空、productId必须大于0Service负责业务校验用户是否存在、库存是否充足。这两种校验的边界要分清否则Controller里会出现一堆if (order.getAmount() 10000)之类的业务判断时间一长又变成上帝Controller。这里有一个关键设计——DTO与VO的分离。我专门建了三个对象类型DTO数据传输对象接收前端入参如CreateOrderRequest。VO视图对象返回给前端的数据如OrderVO字段按需组织。Entity/domain对象和数据库表结构对应如Order。三者互不通用通过转化器进行转换。这样最直观的好处是你不必因为数据库加了createdBy字段就把前端的返回结构也带出这个字段前端要求返回totalAmount带货币符号只需要在VO里加一个格式化字段不会污染实体。有些团队为了减少类数量直接把Entity返回给前端。短平快的项目可以这么干但只要业务字段稍微复杂一点你会发现要么Entity被前端需求绑架要么前端拿到一堆不需要的字段。t3code坚持三对象分离这是我在大量项目中验证过最稳的套路。3. 让t3code跑起来的完整实操3.1 快速搭建与依赖准备技术栈我选的是Java 17 Spring Boot 3.x MyBatis前端接JSON。如果你用的不是Java思想同样适用后面会提。创建一个Spring Boot项目pom里引入必要依赖dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-web/artifactId /dependency dependency groupIdorg.mybatis.spring.boot/groupId artifactIdmybatis-spring-boot-starter/artifactId version3.0.3/version /dependency dependency groupIdcom.mysql/groupId artifactIdmysql-connector-j/artifactId scoperuntime/scope /dependency数据库就用最简单的订单表我在t3code里初始化了这样两张表CREATE TABLE t_order ( id BIGINT PRIMARY KEY AUTO_INCREMENT, order_no VARCHAR(64) NOT NULL UNIQUE, user_id BIGINT NOT NULL, total_amount INT NOT NULL, status TINYINT NOT NULL, created_at DATETIME NOT NULL, updated_at DATETIME NOT NULL ); CREATE TABLE t_order_item ( id BIGINT PRIMARY KEY AUTO_INCREMENT, order_id BIGINT NOT NULL, product_id BIGINT NOT NULL, product_name VARCHAR(128) NOT NULL, price INT NOT NULL, quantity INT NOT NULL );表结构保持简单但足够演示三层之间的数据流转。这里有个实践笔记order_no必须加唯一索引因为幂等校验依赖它来兜底只靠应用层判断会存在并发窗口。3.2 一个完整业务场景的实现过程我建议你按照这样的顺序写代码先建实体和DAO层再写Service接口和实现最后写Controller。这个顺序跟依赖方向一致每个阶段都能本地运行验证。实际动手的步骤我列一下编写Order和OrderItem实体类对应两张表。编写OrderDAO接口及MyBatis XML映射或者用注解SQL。编写OrderService接口定义createOrder( CreateOrderRequest OrderQuery等形式的方法签名。实现OrderServiceImpl注入DAO完成业务编排和事务控制。编写OrderController处理接口入参和返回。编写OrderAssembler转换器完成DTO/VO/Entity之间的互转。每一步都可以单独测试。我第一次实现t3code时特意在Service里故意抛了一个RuntimeException验证订单表和明细表到底有没有同时回滚。结果发现事务配置漏了rollbackFor Exception.class导致抛出的检查型异常没有触发回滚——这个细节在Spring事务里特别容易踩后面详细说。3.3 层间对象传递的规范与选择写代码时层的边界容易被图省事打破。t3code里我定了几条强制规范你直接抄作业就行Controller不允许importdomain包里的实体类。返回给前端的一律是VO。Service方法入参可以是DTO但内部要用的时候先转成实体。DAO方法的返回值只能是实体类型、基本类型或分页结果对象。禁止在Mapper XML里写复杂的业务判断SQL比如if status 4 then ...之类的那应该是Service层做的事。这些规范看起来很约束但能保证一个项目活过三年。我见过一个反例为了省事把整个CreateOrderRequest直接传给DAODAO拿到前端对象后判空校验、算总价、设置默认状态业务逻辑全堆在XML的script标签里后来改一个需求要改三层代码简直灾难。再说一下分页。t3code里分页参数走PageQuery返回走PageResultT这两个类放在common包所有分页接口统一用public class PageResultT { private ListT list; private long total; private int pageNum; private int pageSize; }分页查询的count和list查询建议分开写这样SQL可以针对count查询优化避免MySQL在数据量大时count耗时长。实际项目中大表的分页count往往是最拖慢接口的元凶。4. 实操中踩过的坑和排查实录4.1 循环依赖Service互相引用的死结t3code开发到订单和支付模块时我踩到了循环依赖。OrderServiceImpl需要调用PaymentService而PaymentServiceImpl又要调OrderService查询订单状态。Spring Boot 2.6之后默认禁止循环依赖启动直接报错The dependencies of some of the beans in the application context form a cycle这个问题的根因是设计上没有划清职责。正确做法是把查询订单状态这个公共能力抽到单独的OrderQueryService或者把支付回调里的订单状态变更逻辑放到OrderService里让PaymentService只依赖OrderService的单向接口。你也可以用Lazy解掉循环依赖的报错但那是在掩盖问题。长期维护时循环依赖会让模块之间纠缠不清最好一开始就在架构评审里禁止。t3code的约定是Service之间允许单向依赖不允许互相依赖如果出现双向需求一定是职责没分对。4.2 事务失效一个被忽略的同类调用问题t3code在重构时发现一个经典问题某个Service方法里调用了同类中的另一个Transactional方法结果事务根本没生效。Service public class OrderServiceImpl { Transactional public void createOrder(...) { ... this.updateStockAfterPayment(...); // 同类调用事务注解失效 } Transactional(propagation Propagation.REQUIRES_NEW) public void updateStockAfterPayment(...) { ... } }原因在于Spring的声明式事务是通过AOP代理实现的。同类调用走的是this引用没有经过代理对象所以注解不生效。排查这类问题时可以从两个方向处理把需要独立事务的方法放到另一个Service类里。自己注入ApplicationContext拿代理对象但不推荐代码变丑且容易误用。另一个高频问题是事务里夹了RPC远程调用。订单创建后要通知消息中心如果通知失败了整个事务回滚会导致数据库操作也被撤销。这在分布式场景下非常尴尬我的建议是事务内只做数据库操作把发MQ、调用外部系统的动作放到事务提交后的事件监听里比如Spring的TransactionalEventListener。4.3 查询性能分页与N1排查N1查询是DAO层最典型的性能坑。写这样的代码时一定要警惕ListOrder orders orderDAO.findByUserId(userId); for (Order order : orders) { ListOrderItem items orderItemDAO.findByOrderId(order.getId()); // 循环查询 }用户有100条订单就会发101条SQL。t3code里我改成一次性查出订单再用IN查询批量取出明细按订单ID分组再装配ListOrder orders orderDAO.findByUserId(userId); ListLong orderIds orders.stream().map(Order::getId).collect(toList()); ListOrderItem items orderItemDAO.findByOrderIds(orderIds); MapLong, ListOrderItem itemMap items.stream().collect(groupingBy(OrderItem::getOrderId));两条SQL解决所有数据。代码多了几行但性能提升一个量级。做后端这种事能干一次就能感受到查询优化的真实价值。另外分页插件如PageHelper虽然好用但使用不当会拖垮大表。t3code里对超过百万行的大表我宁可手写LIMIT #{offset}, #{pageSize}配合合适的索引也不让插件自动套count。像订单列表这种业务还应该加时间范围条件让count的代价可控。4.4 对象转换的陷阱DTO、VO、Entity三套对象互相拷贝很容易出低级错误。我在t3code里不使用BeanUtils.copyProperties盲拷贝因为字段名一旦对不上拷贝会静默丢失数据排查起来天昏地暗。两个方案供你参考字段少的对象手写转换器显式赋值如orderVO.setOrderNo(order.getOrderNo())。字段多的对象用MapStruct做编译期映射属性不匹配时编译直接报错。MapStruct是我在t3code里主推的方案因为它在编译期生成转换代码性能好类型不安全问题能在开发阶段暴露。对比一下反射拷贝的性能开销和隐藏风险都更高生产环境不建议依赖它做高频接口的转换。5. 什么时候不该用三层架构以及怎么演进5.1 小型项目与简单CRUD场景三层架构不是万能钥匙。如果你只是做一个内部管理后台几十张表全是简单的增删改查业务逻辑几乎为零那你强行套Controller-Service-DAO只会产生大量没有任何业务含义的Service方法。这种项目用MyBatis-Plus的ServiceCRUD或者直接用Spring Data JPA效率更高维护成本反而更低。我判断是否使用三层架构的标准是业务规则的数量。当Service层出现了状态机、跨表事务、复杂的业务编排时分层的收益才开始显现。如果所谓的Service方法只是把DAO查到的结果原样返回那这个Service层就是摆设还不如砍掉。5.2 三层架构与DDD的关系前后端分离后三层架构里的表现层含义被进一步压缩很多团队从Service层里又派生出了Application Service应用服务层。t3code里我加了一个facade目录就是折中方案——让Controller调FacadeFacade组织多个Service完成一个用例。如果你的业务复杂度继续上升三层架构会逐渐逼近它的天花板。这时候可以考虑往DDD领域驱动设计演进以聚合根为核心组织领域模型DAO层替换为Repository接口业务规则下沉到领域对象里。这不是推翻三层架构而是把Service层变薄把核心业务逻辑放到domain模型。我在架构演进上总结出一个心得从三层到DDD是平滑演进不是推倒重来。你可以先把一个最核心的业务模块往DDD方向重构跑顺了再逐步推广。5.3 t3code后续还能怎么玩我给t3code规划了几个后续升级方向供参考引入Redis缓存只读接口查缓存写操作先更新DB再删缓存解决缓存与数据库的一致性问题。把消息队列接进来订单创建事件发布到MQ下游服务消费实现订单状态的异步流转。在DAO层之上引入读写分离主库写、从库读进一步压榨数据库性能。增加单元测试覆盖Service层的核心业务规则利用DAO层接口Mock让重构时心里有底。这些升级方向每一条都是独立的实践主题但它们的共同前提是底层代码的分层边界足够清晰。如果分层烂了加缓存加MQ只会让系统更乱。t3code做的就是把地基夯实。我在实际操作中最深的体会是分层架构最大的价值不是代码规整好看而是降低后续每一次改动的认知成本。你在Service层找到订单状态流转的逻辑在DAO层找到SQL在Controller层调整返回结构每一件事都只需要面对最小范围内的代码。持久化框架可以换、接口风格可以改但分层的骨架一旦搭对系统就稳住了。对刚起步的团队来说抄t3code的目录结构和依赖规则比研究一堆复杂的架构理论有效得多。
返回列表