DDD系列:从 MVC 三层架构说起

DDD系列:从 MVC 三层架构说起
上一篇文章中我们先简单聊了一下 DDD 是什么以及为什么要学习领域驱动设计。不过上一篇里面已经提到了不少 MVC 项目常见的问题比如 Service 越来越胖、业务规则散落、数据库驱动开发等等。所以这一篇就不重复讲 DDD 的概念了我们把视角收回来先认真看一下我们最熟悉的 MVC 三层架构。为什么要先讲 MVC因为大多数 Java 后端项目基本都是从这一套架构开始的。只有先看清楚 MVC 三层架构到底解决了什么问题、又在哪些场景下开始吃力后面再聊贫血模型、充血模型、领域模型、聚合根的时候才不会觉得 DDD 是在硬凑概念。这一篇只做一件事从日常开发角度把 MVC 三层架构捋一遍。一、MVC 三层架构是什么我们平时说的 MVC 三层架构在 Java Web 项目里面一般会落成下面这种结构controller service mapper entity dto vo如果项目稍微规范一点可能会再细分一下controller service service.impl mapper entity dto vo query config从请求链路看大概就是这样简单来说Controller负责接收请求和返回结果Service负责处理业务逻辑Mapper / Dao负责和数据库打交道Entity一般对应数据库表DTO / Query一般用于接口入参VO一般用于接口出参。比如一个最简单的用户新增接口基本就是下面这个流程。Controller 接收前端请求RestControllerRequestMapping(/users)publicclassUserController{privatefinalUserServiceuserService;publicUserController(UserServiceuserService){this.userServiceuserService;}PostMappingpublicLongcreateUser(RequestBodyUserCreateDTOrequest){returnuserService.createUser(request);}}Service 处理用户新增逻辑ServicepublicclassUserServiceImplimplementsUserService{privatefinalUserMapperuserMapper;publicUserServiceImpl(UserMapperuserMapper){this.userMapperuserMapper;}OverridepublicLongcreateUser(UserCreateDTOrequest){UserusernewUser();user.setUsername(request.getUsername());user.setPhone(request.getPhone());user.setStatus(1);userMapper.insert(user);returnuser.getId();}}Mapper 负责落库publicinterfaceUserMapper{intinsert(Useruser);}这就是我们最常见的一套写法。对于简单业务来说这样写没有任何问题。代码路径很短请求从 Controller 进来到 Service 处理再到 Mapper 落库最后返回结果。定位问题也很直接。接口不通看 Controller。业务结果不对看 Service。SQL 有问题看 Mapper。这也是 MVC 三层架构非常受欢迎的原因。二、MVC 为什么好用讲 DDD 之前我们得先承认一件事MVC 三层架构真的很好用。尤其是对大多数后台管理系统来说它简单、直接、成本低。1. 学习成本低Controller、Service、Mapper 这几个名字本身就很容易理解。新人接手项目时只要知道请求先到 Controller业务逻辑写在 Service数据库操作写在 Mapper基本就能开始看代码。这点很重要。很多项目不是一个人从头写到尾而是中间不断有人加入、离开、交接。结构太复杂的话新人光理解工程分层就要花很久。MVC 的优势就在于它足够直观。2. 开发效率高对于简单 CRUD 来说MVC 的开发效率非常高。比如后台常见的这些功能用户管理角色管理菜单管理字典配置参数配置操作日志查询。这类功能大多数就是单表或者少量关联表的增删改查。这时候我们直接写 Controller、Service、Mapper很快就能把接口做出来。如果这种业务也强行拆领域模型、聚合根、仓储、防腐层反而会显得很重。所以不是一讲 DDD就要否定 MVC。简单业务用 MVC本身就是很合适的。3. 技术职责清晰MVC 至少帮我们做了一件很重要的事情把不同技术职责分开了。比如Controller 不直接写 SQLMapper 不关心 HTTP 请求Service 不直接处理前端响应格式DTO 和 VO 可以隔离入参、出参。虽然这种分层还不是业务维度的分层但在工程维度上已经足够清楚。如果团队规范执行得好Controller 保持薄一点Service 方法不要乱写Mapper 只负责数据库操作一个普通系统完全可以维护得不错。三、一个需求在 MVC 里是怎么落地的接下来我们换一个更真实一点的角度。平时我们接到一个需求大概会经历下面这个过程当然真实工作里不一定每一步都这么标准但大体差不多产品提出需求后端理解需求设计数据库表写 Entity、Mapper写 Service 业务逻辑写 Controller 接口前后端联调测试、上线。这个流程看起来非常自然。比如现在要做一个“创建订单”的接口我们很容易先想到表order order_item product user然后根据表写 EntitypublicclassOrder{privateLongid;privateStringorderNo;privateLonguserId;privateBigDecimaltotalAmount;privateIntegerstatus;}再写 MapperpublicinterfaceOrderMapper{intinsert(Orderorder);OrderselectById(Longid);}最后把逻辑写到 ServiceTransactional(rollbackForException.class)publicLongcreateOrder(CreateOrderDTOrequest){// 查询商品ProductproductproductMapper.selectById(request.getProductId());if(productnull){thrownewIllegalArgumentException(商品不存在);}// 校验库存if(product.getStock()request.getQuantity()){thrownewIllegalStateException(库存不足);}// 计算金额BigDecimaltotalAmountproduct.getPrice().multiply(BigDecimal.valueOf(request.getQuantity()));// 保存订单OrderordernewOrder();order.setOrderNo(orderNoGenerator.next());order.setUserId(request.getUserId());order.setTotalAmount(totalAmount);order.setStatus(1);orderMapper.insert(order);// 扣减库存productMapper.deductStock(request.getProductId(),request.getQuantity());returnorder.getId();}这段代码在早期版本里其实挺清楚的。它从上到下完成了下单流程查商品校验库存算金额保存订单扣减库存。如果需求就到这里结束那问题不大。但是业务往往不会停在这里。四、MVC 的问题通常不是一开始就出现的MVC 项目的问题一般不是第一天就出现。刚开始的时候大家都写得挺规矩。Controller 很薄Service 方法也不长Mapper 只写 SQL代码看起来清清爽爽。真正的问题通常出现在项目持续迭代之后。还是下单接口后面业务可能会继续加优惠券积分抵扣会员折扣满减活动预售商品虚拟商品风控校验渠道差异下单后发送消息下单后同步第三方系统。这时候我们很自然会继续往 Service 里面加逻辑Transactional(rollbackForException.class)publicLongcreateOrder(CreateOrderDTOrequest){// 查询用户UseruseruserMapper.selectById(request.getUserId());if(usernull){thrownewIllegalArgumentException(用户不存在);}// 查询商品ProductproductproductMapper.selectById(request.getProductId());if(productnull){thrownewIllegalArgumentException(商品不存在);}// 校验库存if(product.getStock()request.getQuantity()){thrownewIllegalStateException(库存不足);}// 计算基础金额BigDecimaltotalAmountproduct.getPrice().multiply(BigDecimal.valueOf(request.getQuantity()));// 处理优惠券if(request.getCouponId()!null){CouponcouponcouponMapper.selectById(request.getCouponId());if(couponnull||!coupon.canUse()){thrownewIllegalStateException(优惠券不可用);}totalAmounttotalAmount.subtract(coupon.getDiscountAmount());}// 处理会员折扣if(user.getMemberLevel()!null){totalAmountcalculateMemberDiscount(totalAmount,user.getMemberLevel());}// 风控校验riskClient.checkOrder(request.getUserId(),totalAmount);// 保存订单OrderordernewOrder();order.setOrderNo(orderNoGenerator.next());order.setUserId(user.getId());order.setTotalAmount(totalAmount);order.setStatus(1);orderMapper.insert(order);// 扣减库存productMapper.deductStock(product.getId(),request.getQuantity());// 发送消息orderMessageProducer.sendOrderCreatedMessage(order.getId());returnorder.getId();}这段代码也不是不能跑。甚至很多线上系统就是这么跑的。问题是它已经开始把很多不同性质的逻辑揉在一起了用户校验商品校验库存规则金额计算优惠券规则会员折扣规则风控调用订单保存库存扣减MQ 消息发送。这里面有业务规则也有技术动作。有当前系统内部逻辑也有外部系统调用。有核心下单逻辑也有下单后的附加动作。短期看这样写最快。长期看这个 Service 方法会越来越不好改。五、Service 变胖之后会带来什么问题Service 变胖以后最明显的问题不是代码行数多而是职责开始混在一起。1. 改一个规则要看一堆上下文比如现在要改优惠券规则。理论上我们只想改“优惠券怎么使用”。但如果优惠券逻辑直接写在下单 Service 里面那我们就必须顺着整个下单流程看优惠券是在会员折扣前使用还是后使用优惠券金额会不会影响风控校验优惠券使用失败时库存有没有被扣优惠券状态什么时候更新事务回滚会不会影响优惠券。也就是说一个局部规则被写进了大流程里后面每次修改都要理解整段流程。这就是维护成本。2. 类似逻辑容易复制粘贴再比如创建订单、预创建订单、再次购买、购物车结算这几个功能可能都需要计算金额。如果一开始没有抽出清晰的金额计算逻辑后面很容易出现这种情况A 方法复制了一份金额计算B 方法复制后改了一个 ifC 方法又补了一个特殊渠道判断D 方法漏了某个优惠规则。时间一长同一个业务含义在不同地方有不同实现。这时候修 Bug 就会很难受。你以为修了一处结果另一处还有旧逻辑。3. 外部系统变动会影响内部业务Service 里直接调外部系统也很常见。比如UserInfoDTOuserInfouserClient.getUserInfo(request.getUserId());如果用户服务返回字段改了本系统的下单逻辑就要改。如果类似调用散落在多个 Service 里那改动范围会更大。这也是为什么后面 DDD 会讲防腐层。不过这一篇先不展开先记住一个问题就行外部系统的变化不应该大范围污染内部业务逻辑。4. 业务语言越来越弱还有一个问题比较隐蔽就是代码里的业务语言会越来越弱。比如我们经常会看到这些方法handleOrder processOrder updateStatus saveData doCreate这些名字不是不能用但业务含义比较模糊。方法里面可能什么都干。一旦代码里到处都是handle、process、updateStatus后面接手的人就只能点进去看实现。如果代码能直接表达业务动作比如createOrder cancelOrder payOrder confirmReceipt applyRefund理解成本就会低很多。DDD 后面强调的“通用语言”其实也和这个有关。不过通用语言、限界上下文这些内容会放到战略设计部分讲这里先不提前展开。六、MVC 本身没有告诉我们业务边界在哪里说到底MVC 主要解决的是工程分层问题。它告诉我们请求入口放 Controller业务流程放 Service数据访问放 Mapper。但它没有明确告诉我们哪些业务规则应该放在一起哪些逻辑应该隔离哪个对象应该负责哪个行为外部系统变化应该怎么隔离一个状态流转规则应该由谁保证。这些问题在简单业务里不明显。因为简单业务就算都写在 Service 里也不会太乱。但是业务复杂之后如果所有规则都默认放进 ServiceService 就会变成一个万能容器。这也是上一篇文章说的真正麻烦的不是技术框架而是业务逻辑没有被管理好。七、那 MVC 项目是不是就不能维护了当然不是。很多 MVC 项目只要做好基本规范也可以维护得很好。比如Controller 不写业务逻辑Service 方法不要过长复杂逻辑及时抽方法相同业务规则不要复制粘贴外部系统调用做一层封装状态值用枚举表达查询和命令逻辑适当分开关键业务补单元测试。这些做法不一定非要上 DDD 才能做。比如我们可以先把金额计算抽出来privateBigDecimalcalculateOrderAmount(Productproduct,Useruser,CreateOrderDTOrequest){BigDecimaltotalAmountproduct.getPrice().multiply(BigDecimal.valueOf(request.getQuantity()));if(request.getCouponId()!null){CouponcouponcouponMapper.selectById(request.getCouponId());if(couponnull||!coupon.canUse()){thrownewIllegalStateException(优惠券不可用);}totalAmounttotalAmount.subtract(coupon.getDiscountAmount());}if(user.getMemberLevel()!null){totalAmountcalculateMemberDiscount(totalAmount,user.getMemberLevel());}returntotalAmount;}这样至少主流程会清楚一点publicLongcreateOrder(CreateOrderDTOrequest){UserusergetUser(request.getUserId());ProductproductgetProduct(request.getProductId());checkStock(product,request.getQuantity());BigDecimaltotalAmountcalculateOrderAmount(product,user,request);OrderordersaveOrder(user,product,request,totalAmount);deductStock(product,request.getQuantity());sendOrderCreatedMessage(order.getId());returnorder.getId();}这依然是 MVC 写法但可读性已经比一大坨代码好很多。所以我的理解是DDD 不是 MVC 项目失控之后唯一的救命药。在业务还没有复杂到一定程度之前先把 MVC 写规范本身就能解决很多问题。八、什么时候开始考虑 DDD 思想那什么时候 MVC 开始不太够用呢我觉得可以看几个信号。1. Service 方法越来越像流程脚本一个方法里面又查库、又校验、又算金额、又调外部系统、又发消息而且每一步都有各种 if/else。这时候 Service 已经不只是“业务服务”了更像一个流程脚本。2. 同一条业务规则散落在很多地方比如订单取消规则。创建订单里判断一次支付回调里判断一次后台关闭订单里判断一次定时任务里又判断一次。后面规则一变所有地方都要改。这种就说明业务规则缺少一个稳定的归属位置。3. 状态流转越来越复杂比如订单状态、审批状态、支付状态、售后状态。状态一多规则就会变复杂。哪些状态可以流转到哪些状态哪些操作只能在特定状态下执行如果全靠 Service 里面写 if/else很容易乱。4. 外部系统变化频繁影响内部逻辑如果外部接口一改本系统多个 Service 都要跟着改这说明外部依赖没有被隔离好。这时候就可以考虑适配器、防腐层这类做法。5. 大家对同一个业务词理解不一致比如“订单关闭”“订单取消”“订单作废”“退款关闭”不同人理解不一样代码里也写得不一样。这时候问题已经不只是代码问题而是业务语言没有统一。后面讲限界上下文和通用语言时会专门说这个问题。