出处:掘金

原作者:AirMan


架构设计的意义更多的就是在解决系统的反复的维护和迭代时,如何降低成本,这也是架构分层的意义所在

传统的 MVC 架构在前期开发的时候,就是很省事且刚开始的代码逻辑也比较清晰,但是随着业务的发展 MVC 架构是不可避免的会发生腐化现象

领域驱动设计(DDD)是一种软件设计方法,它提供了用切割工程模型的各类技巧(如:领域、界限上下文、实体、值对象、聚合、工厂、仓储等)。通过 DDD 的指导思想,我们可以在前期投入更多的时间,更加合理的规划出可持续迭代的工程设计 —— 项目像搭积木一样运行

核心概念

充血模型(Rich Domain Model)

指将对象的属性信息行为逻辑聚合到一个类中,一个充血模型的对象要具有属性(数据),方法(业务逻辑,业务行为)。与之对应的叫贫血模型,在贫血模型中只有数据属性,没有业务逻辑,业务逻辑全放在 MVC 架构的 Service 层中了。举个例子就是下面的内容:

/* 贫血模型 */
public class Order {
    private Long id;
    private Long userId;
    private BigDecimal amount;
    // 没有任何行为,所有逻辑都在OrderService
}
/* 充血模型 */
public class Order {
    private Long id;
    private Long userId;
    private BigDecimal amount;
 
    // 领域行为
    public void pay(Payment payment) {
        // 支付逻辑
    }
 
    public void cancel() {
        // 取消逻辑
    }
}

领域模型(Domain Model)

是对业务领域核心概念的建模,是承载业务规则和业务逻辑的核心对象。领域模型不是单纯的数据结构,而是要把行为(业务方法)和数据(属性)结合起来,所以领域模型的目标是反映现实世界的业务场景和规则。每一个领域服务都是有界限的

在 DDD 架构中,推荐的领域模型实现方式就是充血模型。换句话说,领域模型的实现应当是充血的。也就是说,领域模型对象要有自己的业务逻辑和行为,而不是只做“数据搬运工”。

==领域模型 = 充血模型 + 聚合、实体、值对象、工厂等构件==,领域模型是 DDD 的核心,“充血”只是领域模型的实现原则

实体(Entity)

是指在业务中具有唯一标识的对象,实体的主要特征就是持续的身份(即使属性发现变化,但是它的身份不变,它一直是那个实体)。例如 User,Order 都属于实体,订单可以被修改(比如改收货地址),但它的 OrderId 永远不会变

public class User {
    private Long id; // 唯一标识
    private String name;
    private int age;
 
    // 行为方法
}

值对象(Value Object)

值对象没有唯一标识,它是通过属性值来判断相等性的。值对象关注属性而非像实体一样关注身份,属性是可替换的,不可变的!例如地址 Address,只要内容完全一样的地址就是同一个值对象,没有单独的身份

// 不需要 id,创建后不能更改
public class Address {
    private final String province; // final 修饰
    private final String city;     // final 修饰
    private final String street;   // final 修饰
 
    // 构造后不可修改
    public Address(String province, String city, String street) {
        this.province = province;
        this.city = city;
        this.street = street;
    }
 
    // 没有 set 方法
}

为什么说属性是可替换的,不可变的?

先回顾实体,实体他是有自己的身份的,实体关心的”是谁”,对于实体来说”身份证号码是唯一的”,哪怕属性变了,只要 ID 一样就是同一个人!再来看值对象,值对象没有身份,关心的是”是什么”,对于值对象来说”内容完全一样才算一样”,比如地址中的城市街道要完全一样才算一样!

那就能理解"可替换,不可变"的由来了,只要值是一样的,就可以相互替换。比如你的快递地址,如果内容一样,就能换成任何一个相同内容的 Address 对象,业务完全没影响。实体就不同了,实体不能随意更换,且实体的身份也是不一样的。通常值对象都会设计为不可变(immutable),因为不可变不会让对象中途被修改,从而导致系统混乱,这样一来值对象一旦创建,只能整体替换,不能部分更改

聚合(Aggregate)

聚合就是将紧密相关的实体和值对象组织在一起,形成一个有边界的业务模块

==聚合 = 聚合根 + 其他实体/值对象==。在聚合中有一个聚合根(Aggregate Root),只有通过聚合根才能访问或修改聚合内容的其他对象

用例子来解释就是:Order 类中,有 orderId(唯一标识),OrderItem(订单项),Address(地址)是其内部成员。那么 Order 就是聚合根,对于 OrderItem 的操作必须通过 Order 这个聚合根进行操作,不可以直接操作 OrderItem

public class Order {                 // 聚合根
    private Long orderId;            // 只有聚合根拥有唯一标识
    private List<OrderItem> items;   // 子实体
    private Address address;         // 值对象
 
    // 聚合内部行为
    public void addItem(OrderItem item) { ... }
    public void changeAddress(Address address) { ... }
}

为什么聚合根需要一个唯一标识?从上面不难看出,聚合根是聚合的代表以及入口,外部的直接只关心聚合根的 id,其他成员对象即使有自己的 id 也不能作为聚合整体的唯一标识。只要是需要引用或者操作这个聚合,就只能通过聚合根的唯一标识 id 来完成

外部系统、仓库、数据库、消息传递等,只需记住 orderId,就能找到整个 Order 聚合及其内部所有内容

聚合这样做的好处就是,聚合根是唯一的操作入口,保证数据不被乱改。并且事务只需要覆盖一个聚合,性能也高,易于维护,并且聚合之间可以通过领域事件解耦协作

仓储(Repository)

仓储是领域模型和持久化技术之间的桥梁,是“面向领域”的数据访问对象。首先明白仓储的本质就是面向领域的接口,是”用业务的视角”去查找和保存领域对象,不直接关系底层数据库/缓存的实现细节。

换句话说仓储让我们在领域中只关心业务的实现,然后通过暴露的接口去查询和保存领域对象。领域只写接口,基础设施拼命适配就好了

举例子就是假设我们有一个 OrderRepository 仓储接口

  • 查询订单时可以先查缓存(Redis),没命中再查数据库(MySQL);
  • 保存订单时要先写数据库,再写缓存;
  • 订单归档后可能写入文件存储或上传到远程归档服务

在领域层中,仓储接口如下,领域服务只关心“查找、保存订单”,而不管底层是 MySQL、Redis,还是接口:

public interface OrderRepository {
    Order findById(Long id); // 根据id查Order实体对象
    void save(Order order);  // 保存Order实体对象
    void delete(Long id);    // 根据id删除实体对象
}

在基础设施层实现具体的细节,不是在领域中实现:

public class OrderRepositoryImpl implements OrderRepository {
    private final RedisTemplate redisTemplate;
    private final OrderDao orderDao;          // MyBatis
    private final FileStorageService fileStorageService;
    private final ArchiveRemoteService archiveRemoteService;
 
    public OrderRepositoryImpl(RedisTemplate redisTemplate,
                              OrderDao orderDao,
                              FileStorageService fileStorageService,
                              ArchiveRemoteService archiveRemoteService) {
        this.redisTemplate = redisTemplate;
        this.orderDao = orderDao;
        this.fileStorageService = fileStorageService;
        this.archiveRemoteService = archiveRemoteService;
    }
 
    @Override
    public Order findById(Long id) {
        // 1. 先查缓存
        Order order = redisTemplate.get("order:" + id);
        if (order != null) {
            return order;
        }
        // 2. 缓存没有,查数据库
        order = orderDao.selectById(id);
        if (order != null) {
            // 3. 放入缓存
            redisTemplate.set("order:" + id, order, 1, TimeUnit.HOURS);
        }
        return order;
    }
 
    @Override
    public void save(Order order) {
        // 1. 先写数据库
        orderDao.save(order);
        // 2. 同步缓存
        redisTemplate.set("order:" + order.getId(), order, 1, TimeUnit.HOURS);
    }
 
    @Override
    public void delete(Long id) {
        // 1. 删除数据库
        orderDao.delete(id);
        // 2. 删除缓存
        redisTemplate.delete("order:" + id);
    }
 
    // 新增:订单归档,归档到文件+远程服务
    public void archive(Order order) {
        // 1. 写入本地归档文件
        fileStorageService.save(order);
        // 2. 上传归档数据到远程归档中心
        archiveRemoteService.upload(order);
    }
}

适配器(Adapter)

适配器是外部系统的桥梁,适配器也是像领域暴露接口,而具体的实现是基础设施层实现的。所有和“外部世界”打交道的场景,都可以做成“领域接口 + 适配器实现”模式。举个例子就是领域层需要”给手机号发验证码”,不用关心怎么发,找什么渠道发。只需要定义接口:

// 包名:domain/service/SmsSender.java
public interface SmsSender {
    void sendCode(String mobile, String code);
}

而在基础设施层中实现接口,完成具体的细节:

// 包名:infrastructure/adapter/AliyunSmsSender.java
public class AliyunSmsSender implements SmsSender {
    private final AliyunSmsClient aliyunSmsClient;
 
    public AliyunSmsSender(AliyunSmsClient aliyunSmsClient) {
        this.aliyunSmsClient = aliyunSmsClient;
    }
 
    @Override
    public void sendCode(String mobile, String code) {
        // 封装第三方API细节
        aliyunSmsClient.send(mobile, code);
    }
}

如果以后想换腾讯云,也只需加一个实现,但是领域层是无感知的,也就是说业务代码不用改动,只需要切换注入的适配器就可以了:

public class TencentSmsSender implements SmsSender {
    // ...同理
}

再举个例子 Repository 和 Adapter 的关系就像是:

  • 仓储就像你的“业务对象仓库”,专门管你的货物(领域对象)怎么存怎么取
  • 适配器就像“对外通讯员”,帮你和快递公司、银行、外部政府系统打交道,把你业务上的指令转成各种“外部系统”能懂的操作

领域编排(Application Layer)

领域编排就好比组装领域模块的胶水,就是把多个“界限上下文”或“领域服务”串联起来,完成一个跨领域的完整业务流程。而这个编排的工作是由应用层(Application Layer)完成的,应用层就像是搭积木一样,将每个领域模块编排组装,实现高阶的复杂业务。当然能搭积木的前提就是因为领域模块是解耦、独立的,所以编排层可以根据不同业务需求,灵活“组装”各种领域服务,实现高复用和灵活扩展

举个例子就是现在有一个业务场景:“用户开户成功,自动赠送优惠券并发送短信通知”,现在划分了三个领域服务:用户域(User)负责开户,营销域(Marketing)负责发券,通知域(Notify)负责发短信

那么在应用层(Application Layer)中就有如下代码,它不需要关心具体怎么开户,怎么发卷,怎么发短信,只需要关心流程怎么串起来

public class OpenAccountOrchestrationService {
    private final UserService userService;
    private final MarketingService marketingService;
    private final NotifyService notifyService;
 
    public void openAccountAndGiftCoupon(String userId) {
        userService.openAccount(userId);
        marketingService.giftCoupon(userId);
        notifyService.sendAccountOpenMessage(userId);
    }
}

触发器层(Trigger Layer)

触发器就好比 MVC 中的 Controller 层,但是 DDD 架构中触发方式不止是 HTTP 接口,还有 MQ,定时任务,WebSocket 等等。触发器层和 MVC 的 Controller 层实现的目标都是一致的,即与业务解耦,只负责接受外部输入和触发业务的信号,而不写任何业务逻辑。所以 Trigger 层和 MVC 中的 Controller 层都是很干净,简洁的

@RestController
@RequestMapping("/user")
public class UserController {
    private final UserOrchestrationService userOrchestrationService;
 
    public UserController(UserOrchestrationService userOrchestrationService) {
        this.userOrchestrationService = userOrchestrationService;
    }
 
    @PostMapping("/register")
    public ResultVO register(@RequestBody RegisterRequest req) {
        // 参数校验、权限校验(可用注解/AOP处理)
        userOrchestrationService.registerAndGiftCoupon(req.getMobile());
        return ResultVO.success();
    }
}
@Component
public class UserRegisterListener {
    private final UserOrchestrationService userOrchestrationService;
 
    @KafkaListener(topics = "user.register")
    public void onUserRegister(String message) {
        // 解析消息内容
        String mobile = parseMobile(message);
        userOrchestrationService.registerAndGiftCoupon(mobile);
    }
}
@Component
public class CouponExpireScheduler {
    private final CouponOrchestrationService couponOrchestrationService;
 
    @Scheduled(cron = "0 0 2 * * ?") // 每天凌晨2点
    public void expireCoupons() {
        couponOrchestrationService.expireOverdueCoupons();
    }
}

讲到这里,就不得不提一下领域编排存在的必要性了。如果项目很小,我们允许在触发器层直接调用领域服务,减少层级。如果业务复杂、多领域协作、或未来业务流程可能多变,就比较适合添加领域编排了

例子

最后以“用户开户并自动赠送优惠券”的业务为例,帮助更好的理解 DDD 项目结构

src/
├── domain/                         # 领域层(核心业务对象和规则)
│   ├── user/
│   │   ├── User.java               # 实体/聚合
│   │   ├── UserRepository.java     # 仓储接口
│   │   ├── UserService.java        # 领域服务
│   ├── coupon/
│   │   ├── Coupon.java
│   │   ├── CouponRepository.java
│   │   ├── CouponService.java
│   ├── notify/
│   │   ├── SmsSender.java          # 适配器接口
│   │   ├── NotifyService.java

├── application/                    # 应用/编排层(领域编排/业务流程)
│   ├── OpenAccountOrchestrator.java # 开户编排(聚合各领域服务)
│   ├── CouponExpireOrchestrator.java # 优惠券到期编排

├── trigger/                        # 触发器层(接入点/外部入口)
│   ├── OpenAccountController.java   # HTTP 接口
│   ├── OpenAccountListener.java     # MQ监听(如开户成功消息触发)
│   ├── CouponExpireScheduler.java   # 定时任务

├── infrastructure/                 # 基础设施层(技术实现细节)
│   ├── repository/
│   │   ├── UserRepositoryImpl.java  # 仓储实现(MyBatis/JPA等)
│   │   ├── CouponRepositoryImpl.java
│   ├── adapter/
│   │   ├── AliyunSmsSender.java     # 适配器实现(接阿里云短信)
│   │   ├── RedisCacheAdapter.java   # 适配缓存
│   ├── config/
│   │   ├── DataSourceConfig.java    # 数据库/Redis/MQ等配置

├── common/                         # 通用工具、基础VO、异常定义等
│   ├── ResultVO.java
│   ├── BizException.java

└── MainApplication.java            # 程序入口