什么是DDD?万字长文案例带你由浅入深领域驱动设计

2,223 阅读39分钟

导读: 对于后端开发来说,大家或许过多崇尚于技术上的问题,或许是某个中间件的性能,某个微服务组件的强大?业务对于大部分人来说,只是CRUD,诚然,一切的需求落地就是对于各种数据库的增删改查。但是我们着手于现实社会,计算机的兴起就是为了帮助人们解决现实中存在的问题,Web技术的发展,就是为了解决一个个实际的需求与业务。再高层的技术落地也是为了业务所服务,这就引出了一个大家时常忽略的问题,如何去治理一个超大型业务项目,如何去规范约束代码。

在我们习惯了J2EE的开发模式后,Action/Service/DAO这种分层模式,会很自然地写出过程式代码,而学到的很多关于OO理论的也毫无用武之地。使用这种开发方式,对象只是数据的载体,没有行为。以数据为中心,以数据库ER设计作驱动。分层架构在这种开发模式下,可以理解为是对数据移动、处理和实现的过程。

前言

我更愿意称DDD是一种架构思想,而不是一种“框架”,框架给人一种死板的感觉。事实也是这样,自然世界是复杂多变的,业务随着需求的变化也在不断的改变,仅采用固定的一套框架,告诉你,我们用DDD框架,什么代码应该放在哪个包,这并不是DDD的目的。我们来谈谈目前大家应该都了解的MVC架构。MVC分层架构是非常优秀的一种架构体系 ,ORM框架的盛行更是助燃了MVC的发展。在Mybatis的帮助下,我们的代码结构不可避免会有如下几个包,controller,service,mapper,pojo。借用工具,我们的pojo层的实现变得死板简单,仅仅只是与数据库表对应,面向对象编程的思想逐渐转化为面向数据库表编程。在十年前,大部分的商业应用还处于单机时代,分布式微服务的概念还未盛行。大家的API调用大多都是基于REST接口的外部调用。

在之后,服务概念开始冒头,从一开始的SOA架构,到后面更加细致的微服务架构,甚至于如今盛行的所谓“云原生”,“serverless架构”。微服务崇尚的是业务的拆分,拆分为高内聚低耦合的服务,而DDD也是同样着重于业务的视角,两者在追求同样的目标达到了上下文统一。微服务场景下,服务调用方式不仅是REST,RPC以及消息队列的调用方式变得更加常见,服务之间的依赖关系变得错综复杂,如何去合理拆分服务也成为了一大挑战和难题。一个电商系统拆分,难道全部笼统的拆分于用户模块,商家模块,商品模块,店铺模块......DDD的思想是领域驱动设计,Domain Driven Design,这种思想让我们能真正冷静下来,去思考到底哪些东西可以被服务化拆分,哪些逻辑需要聚合,才能带来最小的维护成本,而不是简单的去追求开发效率。Gordon Moore提出软件设计好坏的标准是,高内聚低耦合,无论是单机时代,再到分布式时代,乃至今天的微服务云原生时代,这一言论依然可行,那么什么是高内聚与低耦合,DDD思想如何帮助我们设计出一个优秀的软件架构呢?

Domain Primitive

DP是DDD架构中的核心角色,也可以理解为最小单位,他明显区别于MVC架构中的model,最简单理解就是,充血模型与贫血模型。Domain Primitive 是一个在特定领域里,拥有精准定义的、可自我验证的、拥有行为的 Value Object 。下面我用一个订单的简单例子介绍一下什么是贫血模型,什么又是充血模型。

贫血模型

贫血模型是一种将业务逻辑与数据分离的设计方式。在这种模型中,领域对象通常只包含数据属性,而业务逻辑则分布在服务类(Service)中。领域对象在这种设计中更像是数据的载体,而实际的操作和逻辑处理则由独立的服务类负责。以下代码中,Order类仅包含订单的属性,这些属性一般和我们的数据库表对应,而所有的逻辑均放在我们的Service服务层进行处理。这种模型较为简单,并且比较清晰,但是他不符合高内聚的特点,是一种低内聚的设计,可惜的是,目前市面上绝大多数的教学,以及初学者都是应用这种模型进行编码。

public class Order {
    private String orderId;
    private List<OrderItem> items;
​
    // getters and setters
}
​
public class OrderService {
    public void addItem(Order order, OrderItem item) {
        order.getItems().add(item);
    }
​
    public void removeItem(Order order, OrderItem item) {
        order.getItems().remove(item);
    }
​
    public double calculateTotalPrice(Order order) {
        return order.getItems().stream().mapToDouble(OrderItem::getPrice).sum();
    }
}
​

充血模型

充血模型是一种将业务逻辑和数据紧密结合的设计方式。在这种模型中,领域对象不仅包含数据属性,还包含操作这些数据的业务逻辑方法。也就是说,领域对象不仅仅是数据的载体,还负责自身的行为和状态管理。充血模型提倡将业务逻辑尽量放在领域对象内部,这样可以提高代码的内聚性和可维护性。可以看到以下Order类不仅仅包含了必要的属性,还封装了部分增删改查函数,这边实例代码没有涉及到dao层,实际上充血模型是可以与持久层直接结合,对数据库进行操作的。领域驱动设计的最重要一环,就是如何应用充血模型。

public class Order {
    private String orderId;
    private List<OrderItem> items;
​
    public Order(String orderId) {
        this.orderId = orderId;
        this.items = new ArrayList<>();
    }
​
    public void addItem(OrderItem item) {
        items.add(item);
    }
​
    public void removeItem(OrderItem item) {
        items.remove(item);
    }
​
    public double calculateTotalPrice() {
        return items.stream().mapToDouble(OrderItem::getPrice).sum();
    }
​
    // getters and setters
}
​

样例

好了,了解了贫血模型和充血模型,我们用一个实际的业务例子来讲一下DP。这个业务逻辑是这样的

一个招商系统需要在全国范围内进行招商,商家需要注册进这个招商系统,根据商家经营的商品种类,对商家的type进行区分,并且根据商家负责人的手机号,划定这个商家的地域。

我们先不要管这个业务合理不合理,我们着手于实践,这个需求拿到我们手上,我们用MVC的方式去拆分那便是一个Merchant(商家类),包含了商家的基本属性,一个Respository用来于持久层交互,然后在服务层拼凑实现我们的业务。代码如下:

数据模型(pojo)

 public class Merchant {
    private Long id;
    private String name;
    private String phone;
    private String productCategory;
    private String type;
    private String region;
​
    // getters and setters
}
​

数据访问层(dao)

import java.util.List;
​
public interface MerchantRepository {
    void save(Merchant merchant);
    Merchant findById(Long id);
    List<Merchant> findAll();
    // 其他数据访问方法
}

服务层(service)

public class MerchantService {
    private MerchantRepository merchantRepository;
​
    public MerchantService(MerchantRepository merchantRepository) {
        this.merchantRepository = merchantRepository;
    }
​
    public void registerMerchant(Merchant merchant) {
        merchant.setType(determineType(merchant.getProductCategory()));
        merchant.setRegion(determineRegion(merchant.getPhone()));
        merchantRepository.save(merchant);
    }
​
    private String determineType(String productCategory) {
        // 根据商品种类确定商家类型的逻辑
        if ("电子产品".equals(productCategory)) {
            return "电子类";
        } else if ("食品".equals(productCategory)) {
            return "食品类";
        } else {
            return "其他类";
        }
    }
​
    private String determineRegion(String phone) {
        // 根据手机号划定地域的逻辑,假设通过手机号前几位确定地域
        if (phone.startsWith("13") || phone.startsWith("14")) {
            return "华北";
        } else if (phone.startsWith("15") || phone.startsWith("16")) {
            return "华东";
        } else {
            return "其他";
        }
    }
}

我们发现了什么特点,那就是我们从Merchant类看过去,只能看到他的基础属性,而关于这个商家的业务属性,我们却一点都看不出来,然而大面积的代码沉积在service层中,一个service不仅仅是只操作一个pojo,然而关于这个pojo的所有操作竟然全部集成在service中,这是否违背了软件设计的高内聚的特性?接下来我们看看由领域驱动设计DP模型修改后的代码架构应该是怎样的。

public class Merchant {
    private Long id;
    private String name;
    private String phone;
    private String productCategory;
    private String type;
    private String region;
​
    public Merchant(String name, String phone, String productCategory) {
        this.name = name;
        this.phone = phone;
        this.productCategory = productCategory;
        this.type = determineType();
        this.region = determineRegion();
    }
​
    private String determineType() {
        // 根据商品种类确定商家类型的逻辑
        if ("电子产品".equals(productCategory)) {
            return "电子类";
        } else if ("食品".equals(productCategory)) {
            return "食品类";
        } else {
            return "其他类";
        }
    }
​
    private String determineRegion() {
        // 根据手机号划定地域的逻辑,假设通过手机号前几位确定地域
        if (phone.startsWith("13") || phone.startsWith("14")) {
            return "华北";
        } else if (phone.startsWith("15") || phone.startsWith("16")) {
            return "华东";
        } else {
            return "其他";
        }
    }
​
    // getters and setters
}
​

首先我们很明显的感觉出来,这个Merchant类比起前面传统模型代码量肿胀了很多,并且我们通过构造方法,可以直接构造出一个合适的Merchant对象出来,最直观的就是把service类的一些代码聚集到了我们的domain层中,这便是高内聚的体现。这些代码本身就是描述我们Merchant属性的特点的,难道不应该由这个domain自己来处理这些逻辑吗?

但是,以上给出的例子,还不是很“纯血”的DP。

跟着我的思路,我们现在来看这个注册商家的需求,假设需要以下一个接口

注册(商家名称,手机号),那么自然的我们会想到这样设计接口

public void register(String name,String phoneNumber);

在Java代码中,对于一个方法来说,所有的参数名在编译后消失,那么我们的代码在编译后其实是这样的

public void register(String,String)

那么假设我们在service层这样调用接口,是否会出错呢?

merchant.register("17720778576","AAA潮鞋直营店");

答案是不会的,虽然接口定义是name和phoneNumber,但是这两个参数都是String,我们传递手机号和商铺名字进去,自然是正确的,为什么会报错呢?或许现在还有些读者会看不明白,这样写有什么问题。问题就是我们定义的接口是先传入name再传入phoneNumber。而样例中我先传入了phoneNumber。

问题是这样在我们的代码编译过程中是不会出问题的,这个bug就会留到测试中显示出为业务问题,一段良好的代码应该具有更少的测试风险,测试诚然是必不可少的工作,但是倘若代码本身就具有自我检查机制,那么测试的工作量是否会减少,并且对于排查错误以及代码健壮性来说,都有极佳的意义。

那么我们要怎样改进这个方法。

首先就是把隐形的概念显性化,我们把phoneNumber和name中隐藏的业务逻辑显性化。创建PhoneNumber类和Name类。

public class Name {
    private final String name;
​
    public Name(String name) {
        if (name == null || name.isEmpty()) {
            throw new ValidationException("姓名不能为空");
        }
        this.name = name;
    }
​
    public String getName() {
        return name;
    }
}
import java.util.Arrays;
import java.util.regex.Pattern;
​
public class PhoneNumber {
    private final String number;
​
    public PhoneNumber(String number) {
        if (number == null) {
            throw new ValidationException("number不能为空");
        } else if (!isValid(number)) {
            throw new ValidationException("number格式错误");
        }
        this.number = number;
    }
​
    public String getNumber() {
        return number;
    }
​
    public String getAreaCode() {
        for (int i = 0; i < number.length(); i++) {
            String prefix = number.substring(0, i + 1);
            if (isAreaCode(prefix)) {
                return prefix;
            }
        }
        return null;
    }
​
    private static boolean isAreaCode(String prefix) {
        String[] areas = new String[]{"0571", "021", "010"};
        return Arrays.asList(areas).contains(prefix);
    }
​
    public static boolean isValid(String number) {
        String pattern = "^0?[1-9]{2,3}-?\d{8}$";
        return Pattern.matches(pattern, number);
    }
}
​

领域对象改为

public class Merchant {
    private Name name;
    private PhoneNumber phoneNumber;
    private String type;
    private String region;
​
    public Merchant(Name name, PhoneNumber phoneNumber) {
        this.name = name;
        this.phoneNumber = phoneNumber;
        this.type = determineType();
        this.region = determineRegion();
    }
​
    private String determineType() {
        // 假设我们可以通过名字或其他方式来确定类型
        if (name.getName().contains("潮鞋")) {
            return "鞋类";
        } else {
            return "其他";
        }
    }
​
    private String determineRegion() {
        return phoneNumber.getAreaCode();
    }
​
    public Name getName() {
        return name;
    }
​
    public PhoneNumber getPhoneNumber() {
        return phoneNumber;
    }
​
    public String getType() {
        return type;
    }
​
    public String getRegion() {
        return region;
    }
}
​

那么我们在真正调用这个register函数的时候,就需要改成如下调用。

register(new Name("AAA潮鞋专卖"),new PhoneNumber("17720778576"));

这样即便我们是跨层调用,业务属性也非常明显的显示出来,也再也不会出现,参数传递顺序错误的问题。假设出现

register(new PhoneNumber("17720778576"),new Name("AAA潮鞋专卖"));

那么编译器就会报错,无该函数的定义。这就把测试的工作提前到开发阶段完成,提高了代码的健壮性。

那么到这里,到底什么是DP,直白的说,就是这边我们创建的PhoneNumber和Name类,而这个Merchant便是由DP组成的domain service。也就是领域层模型

应用架构

何为架构,这边引用一位阿里技术大佬的描述

架构这个词源于英文里的“Architecture“,源头是土木工程里的“建筑”和“结构”,而架构里的”架“同时又包含了”架子“(scaffolding)的含义,意指能快速搭建起来的固定结构。而今天的应用架构,意指软件系统中固定不变的代码结构、设计模式、规范和组件间的通信方式。在应用开发中架构之所以是最重要的第一步,因为一个好的架构能让系统安全、稳定、快速迭代。在一个团队内通过规定一个固定的架构设计,可以让团队内能力参差不齐的同学们都能有一个统一的开发规范,降低沟通成本,提升效率和代码质量。

在做架构设计时,一个好的架构应该需要实现以下几个目标:

  • 独立于框架: 架构不应该依赖某个外部的库或框架,不应该被框架的结构所束缚。
  • 独立于UI: 前台展示的样式可能会随时发生变化(今天可能是网页、明天可能变成console、后天是独立app),但是底层架构不应该随之而变化。
  • 独立于底层数据源: 无论今天你用MySQL、Oracle还是MongoDB、CouchDB,甚至使用文件系统,软件架构不应该因为不同的底层数据储存方式而产生巨大改变。
  • 独立于外部依赖: 无论外部依赖如何变更、升级,业务的核心逻辑不应该随之而大幅变化。
  • 可测试: 无论外部依赖了什么数据库、硬件、UI或者服务,业务的逻辑应该都能够快速被验证正确性。

这就好像是建筑中的楼宇,一个好的楼宇,无论内部承载了什么人、有什么样的活动、还是外部有什么风雨,一栋楼都应该屹立不倒,而且可以确保它不会倒。但是今天我们在做业务研发时,更多的会去关注一些宏观的架构,比如SOA架构、微服务架构,而忽略了应用内部的架构设计,很容易导致代码逻辑混乱,很难维护,容易产生bug而且很难发现。今天,我希望能够通过案例的分析和重构,来推演出一套高质量的DDD架构。

了解了DP和充血模型的概念,那么一个DDD的应用架构如何落地,又或者说。我们为什么需要DDD架构思想,一个MVC系统如何向DDD架构转变呢?

我们继续引出一个具体的业务例子,非常简单,那就是实现一个跨币种的转账功能。

这个转账功能涉及到的业务点就是实现转入转出业务,并且涉及到跨币种还需要实时的去查看汇率,并且给出相应计算。这么简单的业务经过初步的技术选型后,可能会拆解成以下需求步骤

跨币种转账样例

1、 从MySql数据库中找到转出和转入的账户,选择用 MyBatis 的 mapper 实现 DAO;

2、 从一个RPC服务提供的汇率服务获取转账的汇率信息,假设是google提供

3、 计算需要转出的金额,确保账户有足够余额。

4、 实现转入和转出操作,扣除手续费,保存数据库;

5、 发送 Kafka 审计消息,以便审计和对账用;

代码实现如下

@Service
public class AccountService {
​
    private final AccountMapper accountMapper;
    private final ExchangeRateService exchangeRateService;
    private final AuditMessageProducer auditMessageProducer;
​
    public AccountService(AccountMapper accountMapper, ExchangeRateService exchangeRateService, AuditMessageProducer auditMessageProducer) {
        this.accountMapper = accountMapper;
        this.exchangeRateService = exchangeRateService;
        this.auditMessageProducer = auditMessageProducer;
    }
​
    @Transactional
    public void transfer(Long fromAccountId, Long toAccountId, Double amount, String fromCurrency, String toCurrency) {
        validateParameters(fromAccountId, toAccountId, amount, fromCurrency, toCurrency);
​
        // 1. 从数据库中找到转出和转入的账户
        Account fromAccount = accountMapper.findAccountById(fromAccountId);
        Account toAccount = accountMapper.findAccountById(toAccountId);
​
        // 2. 从RPC服务获取汇率信息
        Double exchangeRate = googleExchangeRateService.getExchangeRate(fromCurrency, toCurrency);
​
        // 3. 计算需要转出的金额,确保账户有足够余额
        Double amountInFromCurrency = amount / exchangeRate;
        if (fromAccount.getBalance() < amountInFromCurrency) {
            throw new InsufficientBalanceException("账户余额不足");
        }
​
        // 4. 实现转入和转出操作,扣除手续费,保存数据库
        Double fee = calculateFee(amountInFromCurrency);
        fromAccount.setBalance(fromAccount.getBalance() - amountInFromCurrency - fee);
        toAccount.setBalance(toAccount.getBalance() + amount);
​
        accountMapper.updateAccount(fromAccount);
        accountMapper.updateAccount(toAccount);
​
        // 5. 发送Kafka审计消息,以便审计和对账用
        auditMessageProducer.sendAuditMessage(createAuditMessage(fromAccount, toAccount, amount, fromCurrency, toCurrency, fee));
    }
​
    private void validateParameters(Long fromAccountId, Long toAccountId, Double amount, String fromCurrency, String toCurrency) {
        if (fromAccountId == null || toAccountId == null) {
            throw new IllegalArgumentException("账户ID不能为空");
        }
        if (amount == null || amount <= 0) {
            throw new IllegalArgumentException("转账金额必须大于0");
        }
        if (!StringUtils.hasText(fromCurrency) || !StringUtils.hasText(toCurrency)) {
            throw new IllegalArgumentException("货币类型不能为空");
        }
    }
​
    private Double calculateFee(Double amount) {
        // 假设手续费为转出金额的1%
        return amount * 0.01;
    }
​
    private AuditMessage createAuditMessage(Account fromAccount, Account toAccount, Double amount, String fromCurrency, String toCurrency, Double fee) {
        AuditMessage message = new AuditMessage();
        message.setFromAccountId(fromAccount.getId());
        message.setToAccountId(toAccount.getId());
        message.setAmount(amount);
        message.setFromCurrency(fromCurrency);
        message.setToCurrency(toCurrency);
        message.setFee(fee);
        message.setTimestamp(System.currentTimeMillis());
        return message;
    }
}
​

以上这么一大段代码,全部聚合在service层,大家可以反思一下,在我们开发中是不是绝大多数的代码都是这样编写的。这就好似一道庞大的模拟题,我们只需要在service层描述出这个需求的过程。这种很常见的代码样式被叫做Transaction Script(事务脚本)。虽然这种类似于脚本的写法在功能上没有什么问题,但是长久来看,他有以下几个很大的问题:可维护性差、可扩展性差、可测试性差。那么我们接下来开始包丁解牛,这个service代码里面到底做了哪些事情?

MVC架构下的Servive职责

  1. 参数校验
  private void validateParameters(Long fromAccountId, Long toAccountId, Double amount, String fromCurrency, String toCurrency) {
        if (fromAccountId == null || toAccountId == null) {
            throw new IllegalArgumentException("账户ID不能为空");
        }
        if (amount == null || amount <= 0) {
            throw new IllegalArgumentException("转账金额必须大于0");
        }
        if (!StringUtils.hasText(fromCurrency) || !StringUtils.hasText(toCurrency)) {
            throw new IllegalArgumentException("货币类型不能为空");
        }
    }
  1. 数据存储
    Double fee = calculateFee(amountInFromCurrency);
    fromAccount.setBalance(fromAccount.getBalance() - amountInFromCurrency - fee);
    toAccount.setBalance(toAccount.getBalance() + amount);
    accountMapper.updateAccount(fromAccount);
    accountMapper.updateAccount(toAccount);
  1. 调用外部服务
Double exchangeRate = exchangeRateService.getExchangeRate(fromCurrency, toCurrency);
  1. 业务计算
   private Double calculateFee(Double amount) {
        // 假设手续费为转出金额的1%
        return amount * 0.01;
    }
  1. 发送消息
  auditMessageProducer.sendAuditMessage(createAuditMessage(fromAccount, toAccount, amount, fromCurrency, toCurrency, fee));

遇到的问题

我们来列出这样的代码架构的问题

  • 可维护性差

一个应用最大的成本一般都不是来自于开发阶段,而是应用整个生命周期的总维护成本,所以代码的可维护性代表了最终成本。

可维护性 = 当依赖变化时,有多少代码需要随之改变。

首先我们依赖于Account类,它是纯映射于数据库表的,数据库字段的变更是及其常见的,因此这个类要跟着数据库字段的改变而改变,他是及其不稳定的。并且我们显示的依赖了kafka,外部rpc服务,mybatis等。倘若有一天jar包依赖升级,或者我们不使用kafka改为rocketmq,又或者mybatis出现重大漏洞,需要迁移ORM层?那便是灾难性的代码重构体验,但是这些组件本是为业务服务的,而如今业务没有变化,只是因为组件本身的问题,我们便要对业务层代码做这么深的重构,这就涉及到一个问题。

代码的腐败,特别是一个大型项目中,对于外部组件高度耦合的代码变得难以维护,假设我们发现Mybatis出了一个新的版本,性能提升百分之五十,但是调用方式API有了很大的变化,采用这种架构的代码由于代码耦合,想要更换新版本需要耗费的精力和危险性大大增加,于是即使我们深刻的明白,更换新版本是可以直接提升系统性能,但是碍于种种,只能推迟这个计划。

  • 可拓展性差

我们的业务逻辑这么长,但是却只能使用在register这个方法中,业务无法复用,且业务逻辑和数据库格式高度耦合,有新的需求进来,我们又只能新建一个方法,再次用脚本描述的方法来完成这新的一题“模拟题 ”,久而久之,代码变得不可维护,代码量和架构也变得混乱模糊不清。

  • 可测试性差

参考以上的一段代码,这种代码有极低的可测试性:

  • 设施搭建困难:当代码中强依赖了数据库、第三方服务、中间件等外部依赖之后,想要完整跑通一个测试用例需要确保所有依赖都能跑起来,这个在项目早期是及其困难的。在项目后期也会由于各种系统的不稳定性而导致测试无法通过。
  • 运行耗时长:大多数的外部依赖调用都是I/O密集型,如跨网络调用、磁盘调用等,而这种I/O调用在测试时需要耗时很久。另一个经常依赖的是笨重的框架如Spring,启动Spring容器通常需要很久。当一个测试用例需要花超过10秒钟才能跑通时,绝大部分开发都不会很频繁的测试。
  • 耦合度高:假如一段脚本中有A、B、C三个子步骤,而每个步骤有N个可能的状态,当多个子步骤耦合度高时,为了完整覆盖所有用例,最多需要有N * N * N个测试用例。当耦合的子步骤越多时,需要的测试用例呈指数级增长。

我们回到软件工程最基本的设计三大理念

  • 单一性原则:单一性原则要求一个对象/类应该只有一个变更的原因。但是在以上的service代码中,无论是mybatis的升级,kafka组件的替换,外部服务的替换,业务的变更,都需要我们去修改这段代码。
  • 依赖反转原则:依赖反转原则要求在代码中依赖抽象,而不是具体的实现。但是这段代码中,我们对于持久层,消息队列层,服务层都依赖于具体的实现
  • 开闭原则:开放封闭原则指开放扩展,但是封闭修改。在这个案例中手续费的计算是一个固定的方法,当我们需要更改手续费的时候,就要直接修改这个方法,而不是做扩展。

可以看到,一个转账的简单例子,看似合理的设计,却违背了软件工程设计的三大原则。

如何重构

我们需要对代码重构才能解决这些问题。在这之前,我们先画出以上有问题的代码的依赖图

image.png

我们发现在业务层对下层基建层有很明显的强依赖,耦合度很高,所以需要对上图节点做抽象处理,来降低对外部依赖的耦合度。

抽象数据存储层

这一层的操作其实是比较简单的,首先我们来看到第一个问题,我们Service中操作的对象与数据库强关联,且这个对象本身只是对数据库的简单映射,自身不带有业务属性。那么要解决这个问题,很自然就联想到我们上文提到的DP和Domain Service的概念。首先我们应该保留和数据库直接映射的类,命名为AccoutDo,Do类与数据库直接映射,同时我们又需要有DomainService类,也就是我们在Service层中直接使用的Accout类,这个Accout和数据库有一定关系,但是他并不是直接依赖于数据库的,他不但有数据库字段的属性,他还能有自身的业务行为。简单代码示范如下。

@Data
public class Account {
    //可以看到,这边的属性我们使用了DP做处理,这样也顺便解决了参数校验的问题
    private AccountId id;
    private AccountNumber accountNumber;
    private UserId userId;
    private Money available;
    private Money dailyLimit;
 //这里带有了这个domain自身的一些高内聚的方法函数
    public void withdraw(Money money) {
        // 转出
    }
​
    public void deposit(Money money) {
        // 转入
    }
}
@Data
public class AccountDo {
    //直接与数据库一一映射
    private Long id;
    private String accountNumber;
    private Long userId;
    private Double available;
    private Double dailyLimit;
}

在这边我们还需要一个DAO,这个DAO很好理解,就是对应数据库类型的一个真实操作,比如Mysql的insert,update,然后我们还需要一个Repository。

这时候这个Repository需要做两件事

  • 提供数据库操作的通用抽象接口(例如save(我们无需去关注底层是什么数据库,是用insert还是update))
  • 进行Account到AccountDo的映射

代码样例

public interface AccountRepository {
    Account find(AccountId id);
    Account find(AccountNumber accountNumber);
    Account find(UserId userId);
    Account save(Account account);
}
​
public class AccountRepositoryImpl implements AccountRepository {
​
    @Autowired
    private AccountMapper accountDAO;
​
    @Autowired
    private AccountBuilder accountBuilder;
​
    @Override
    public Account find(AccountId id) {
        AccountDO accountDO = accountDAO.selectById(id.getValue());
        return accountBuilder.toAccount(accountDO);
    }
​
    @Override
    public Account find(AccountNumber accountNumber) {
        AccountDO accountDO = accountDAO.selectByAccountNumber(accountNumber.getValue());
        return accountBuilder.toAccount(accountDO);
    }
​
    @Override
    public Account find(UserId userId) {
        AccountDO accountDO = accountDAO.selectByUserId(userId.getId());
        return accountBuilder.toAccount(accountDO);
    }
​
    @Override
    public Account save(Account account) {
        AccountDO accountDO = accountBuilder.fromAccount(account);
        if (accountDO.getId() == null) {
            accountDAO.insert(accountDO);
        } else {
            accountDAO.update(accountDO);
        }
        return accountBuilder.toAccount(accountDO);
    }
​
}

image.png

经过这样的架构转变,我们看到计算更新等业务操作也是直接依赖于Accout这个领域对象,然后领域对象依赖于Repository做映射,最下层才依赖具体的Mapper实现,就解决了业务层依赖于具体实现的问题。当我们需要更换数据库或者ORM的时候,Service层代码完全不需要改变,只需要去改变相应的Mapper层实现即可。这便是单一职责原则。

反腐层

在这里我想先引出一个概念。Anti-Corruption Layer(防腐层或ACL)。

很多时候我们的系统会去依赖其他的系统,而被依赖的系统可能包含不合理的数据结构、API、协议或技术实现,如果对外部系统强依赖,会导致我们的系统被”腐蚀“。这个时候,通过在系统间加入一个防腐层,能够有效的隔离外部依赖和内部逻辑,无论外部如何变更,内部代码可以尽可能的保持不变。

目的就是让我们的Service依赖于抽象而不是依赖于具体实现,真正的转换是通过反腐层去做的,而我们调用的永远是一个固定的抽象的业务接口,具体的适配器转换模式,下沉到防腐层中去实现。

借助反腐层抽象第三方服务

我们这里有一个第三方的服务, googleExchangeRateService,他是依赖于google的。我们假设google服务返回的是这个json

{
  "source": "USD",
  "rates": {
    "EUR": 0.8534,
    "JPY": 110.32,
    "GBP": 0.7542,
    "CNY": 6.4567
  },
  "timestamp": 1625123456
}

但是将来某一天google服务不可用,或者收费变贵了,我们想改用雅虎服务,返回的json格式是这样的

{
  "base": "USD",
  "date": "2024-07-01",
  "rates": {
    "EUR": 0.8456,
    "JPY": 111.20,
    "GBP": 0.7498,
    "CNY": 6.5102
  }
}

因为我们在Service层强依赖google服务,所以需要对业务代码再次进行修改。

引入ACL层后,我们可以抽象出一个统一的json格式

{
  "source": "USD",
  "date": "2024-07-01",
  "rates": {
    "EUR": {
      "rate": 0.8534,
    },
    "JPY": {
      "rate": 110.32,
    },
    "GBP": {
      "rate": 0.7542,
    },
    "CNY": {
      "rate": 6.4567,
    }
  }
}

做一个适配器模式的转换,无论我们从什么数据源获取到汇率数据,我们都使用这个ACL层的适配器转换为我们需要的数据格式,所以在我们的Service业务层就可以依赖于抽象,也就是ExchangeRateService,无论这个Service底层依赖于哪个第三方的服务,返回的数据格式经过防腐层都是永远固定不变的。

同样的对于MQ,我们也可以抽象中间件,无论是RocketMQ,kafka,我们都可以抽象为MessageQueue。并且使用ACL层做一次适配。适配多种MQ实现,这样我们的业务层只依赖于抽象的mq,而不是依赖于具体的mq实现。

封装业务逻辑

这一步就是把部分业务逻辑再内聚到我们的domain里面,比如汇率计算。

         //  从RPC服务获取汇率信息
        Double exchangeRate = googleExchangeRateService.getExchangeRate(fromCurrency, toCurrency);
        //  计算需要转出的金额,确保账户有足够余额
        Double amountInFromCurrency = amount / exchangeRate;
        if (fromAccount.getBalance() < amountInFromCurrency) {
            throw new InsufficientBalanceException("账户余额不足");
        }

这些代码就可以内聚到domain之中,封装为getFromCurrency()方法

最后我们业务层代码就可以只写成

Double amountInFromCurrency = getFromCurrency(amount);

结果展示

经过以上种种重构,最后我们的业务代码会变得无比干净

public class TransferServiceImplNew implements TransferService {
​
    private AccountRepository accountRepository;
    private AuditMessageProducer auditMessageProducer;
    private ExchangeRateService exchangeRateService;
    private AccountTransferService accountTransferService;
​
    @Override
    public Result<Boolean> transfer(Long sourceUserId, String targetAccountNumber, BigDecimal targetAmount, String targetCurrency) {
        // 参数校验
        Money targetMoney = new Money(targetAmount, new Currency(targetCurrency));
​
        // 读数据
        Account sourceAccount = accountRepository.find(new UserId(sourceUserId));
        Account targetAccount = accountRepository.find(new AccountNumber(targetAccountNumber));
        ExchangeRate exchangeRate = exchangeRateService.getExchangeRate(sourceAccount.getCurrency(), targetMoney.getCurrency());
​
        // 业务逻辑
        accountTransferService.transfer(sourceAccount, targetAccount, targetMoney, exchangeRate);
​
        // 保存数据
        accountRepository.save(sourceAccount);
        accountRepository.save(targetAccount);
​
        // 发送审计消息
        AuditMessage message = new AuditMessage(sourceAccount, targetAccount, targetMoney);
        auditMessageProducer.send(message);
​
        return Result.success(true);
    }
}

对比重构前的代码,你是否能体会到DDD架构思想带来的改变?

可以看出来,经过重构后的代码有以下几个特征:

  • 业务逻辑清晰,数据存储和业务逻辑完全分隔。
  • Entity、Domain Primitive、Domain Service都是独立的对象,没有任何外部依赖,但是却包含了所有核心业务逻辑,可以单独完整测试。
  • 原有的TransferService不再包括任何计算逻辑,仅仅作为组件编排,所有逻辑均delegate到其他组件。这种仅包含Orchestration(编排)的服务叫做Application Service(应用服务)。

项目分包样例

那么真正落地一个DDD项目,我们该如何分模块分包呢。由于业务的复杂性,以及每个公司落地DDD风格的不同,我不能给出一个绝对的答案,以下仅仅是抛砖引玉,给出一个比较基础简单的分包。

Types模块

类比于Integer,String,Double这些type,我们的DP也是一个个包含业务校验逻辑的Type,因此Types模块中我们存放DP

Domain模块

Domain 模块是核心业务逻辑的集中地,包含有状态的Entity、领域服务Domain Service、以及各种外部依赖的接口类(如Repository、ACL、中间件等。Domain模块仅依赖Types模块,也是纯 POJO 。

Application模块

这个模块主要依赖于Domain模块,他就是我们MVC结构中的service模块,用于组装拓展Domain中的业务逻辑,形成真正具体的业务需求函数

Infrastructure模块

基建模块,依赖于具体的基建实现,包括DB,MQ,ORM层的依赖。

Web模块

包含Controller等相关代码,视图层,把REST接口暴露

章节总结

DDD架构不是一个具体的特殊的架构模式,他的理念是所有传统代码经过合理的重构要达到的终点,在当下爆炸的微服务时代,业务呈井喷发展,我们推崇敏捷开发的同时,更要注重代码的健壮性,以及可维护性。DDD架构能够有效的解决传统架构中的问题。

  • 高可维护性:当外部依赖变更时,内部代码只用变更跟外部对接的模块,其他业务逻辑不变。
  • 高可扩展性:做新功能时,绝大部分的代码都能复用,仅需要增加核心业务逻辑即可。
  • 高可测试性:每个拆分出来的模块都符合单一性原则,绝大部分不依赖框架,可以快速的单元测试,做到100%覆盖。

理论补充

领域(Domain)

  • 领域是指业务问题的特定部分或范围,通常涉及特定的业务概念和规则集合。一个领域可能包括多个相关的子领域。

子领域(Subdomain)

  • 子领域是领域中更小的部分,通常围绕一个特定的业务子集展开,有时可以作为独立的领域来管理。

限界上下文(Bounded Context)

  • 限界上下文是在DDD中用来定义领域模型边界的概念,它定义了领域模型在特定上下文中的含义和使用规则。一个很形象的隐喻:细胞质所以能够存在,是因为细胞膜限定了什么在细胞内,什么在细胞外,并且确定了什么物质可以通过细胞膜。
  • 一个大型的领域可能会被分成多个限界上下文,每个上下文都有自己的领域模型和业务语言,它们通过显式的接口和映射来进行交互。

在一个电商系统中订单管理是一个领域,包括订单创建、支付、配送等业务。商品管理可以是一个子领域,涵盖商品发布、库存管理、价格策略等。在订单管理领域中,可能有一个用于订单创建和支付的限界上下文,另一个用于订单配送和物流管理的限界上下文。

实体

当一个对象由其标识(而不是属性)区分时,这种对象称为实体(Entity)。

例:最简单的,公安系统的身份信息录入,对于人的模拟,即认为是实体,因为每个人是独一无二的,且其具有唯一标识(如公安系统分发的身份证号码)。

值对象

值对象

当一个对象用于对事务进行描述而没有唯一标识时,它被称作值对象(Value Object)。

例:比如颜色信息,我们只需要知道{“name”:“黑色”,”css”:“#000000”}这样的值信息就能够满足要求了,这避免了我们对标识追踪带来的系统复杂性。它具有不变性、相等性和可替换性。

聚合

在领域驱动设计(DDD)中,聚合是一个核心概念,它帮助开发者管理复杂性,特别是在处理大量相关对象时。

聚合是由紧密关联的实体和值对象组成,是修改和保存数据的基本单位。每个聚合都有一个仓库,用于保存聚合的数据。

聚合有一个聚合根和上下文边界,边界根据业务需求和内聚原则,定义了聚合应该包含哪些实体和值对象,而聚合之间是松耦合的,这样设计的微服务,会很自然地实现高内聚、低耦合。

聚合在 DDD 分层架构中是领域层的一部分,领域层可以包含多个聚合,共同实现核心业务逻辑。实体在聚合内以充血模型实现业务能力,保证业务逻辑的高内聚。

跨多个实体的业务逻辑通过领域服务来实现,跨多个聚合的业务逻辑通过应用服务来实现。

聚合根

Aggregate(聚合)是一组相关对象的集合,作为一个整体被外界访问,聚合根(Aggregate Root)是这个聚合的根节点。

聚合是一个非常重要的概念,核心领域往往都需要用聚合来表达。其次,聚合在技术上有非常高的价值,可以指导详细设计。

聚合由根实体,值对象和实体组成。聚合根可以理解为是一系列聚合的管理者,他具有全局唯一id。聚合之间协作的时候,聚合根是对外的接口人,通过自己的ID关联其他聚合。外部对象不能直接访问聚合内实体。

领域服务

一些重要的领域行为或操作,可以归类为领域服务。它既不是实体,也不是值对象的范畴。可以理解为domain service中的一些内聚操作

如何设计聚合根?

1.选择合适的聚合根

聚合根应该是一个聚合中最重要的实体,其他实体和值对象通过聚合根关联。我们要考虑在这个业务中,哪个实体在业务中扮演核心角色。可以有效的管理封装聚合的行为。

2.聚合最小化原则

聚合应该尽可能的小,只包含必须由聚合根直接管理的实体和值对象。提高内聚性。

3.封装业务规则

聚合根应该封装聚合内部的业务规则,任何对聚合内部数据的修改,都应该通过聚合根的方法进行访问,这样聚合根才能保证聚合的状态的一致性。

4.一致性边界定义

聚合的状态应该是最小的一致性边界,保持自身聚合的一致性,事务应该在单个聚合范围内实现,不能跨聚合操作。

聚合根设计样例

我们以电商平台的订单系统为例,看看聚合和聚合根是如何设计和使用的。

在订单系统中,订单通常作为聚合根,因为订单是整个业务流程中最核心的业务对象。

订单聚合包括多个实体对象,如订单明细、支付信息、收货信息等。聚合中包含了实体也包含了值对象(DP)。

订单明细:聚合内的实体,每个订单明细代表订单中的一个购买项。它包括商品ID、购买数量、单价和小计。订单明细与订单紧密关联,其生命周期由订单聚合根管理。

支付信息:也是聚合内的实体,支付信息记录了支付方式(如信用卡、支付宝、微信支付)、支付状态、支付金额和支付时间等,这些信息对于完成交易和进行财务核算至关重要。

收货信息:通常是一个值对象,包含省、城市、街道和邮编等信息。因为它没有独立的标识,仅仅描述了一个地理位置。

通过设计正确的聚合,订单系统的业务操作会非常清晰,并能够集中管理。

下面列举一些常见的业务操作,介绍聚合是如何被使用的。

订单创建操作

当客户选完商品,并提交订单时,系统会触发订单创建的流程。

系统首先创建一个新的订单聚合实例,此实例以订单为聚合根。订单聚合根包含了必要的信息,如订单编号、订单初始状态等。

客户选定的每个商品都会作为订单明细,添加到订单中。每个订单明细实体包括商品ID、购买数量和单价等信息。这些订单明细在创建过程中由订单聚合根动态管理,确保数据的完整性。

客户提供的收货地址被创建为值对象,并与订单聚合关联。同时,初始化的支付信息也会被设置为一个实体,包括支付方式和支付状态等信息。

这边我想强调一下,值对象是不可变的。当订单创建完毕后,这个商品的详细信息或者价格发生了更改,不应该影响到这条订单里面的数据,也就是可以理解为,订单创建的瞬间,里面的值对象应该是快照。

支付处理

当客户进行支付时,支付信息实体会被更新,包括记录支付金额、支付方式、支付时间等。订单聚合根确保支付信息与订单的状态保持一致性。

支付成功后,订单聚合根会将订单状态更新为“已支付”,这是通过聚合内部的业务逻辑完成,确保所有数据一致。

发货处理

在订单准备发货时,订单聚合根会验证存储的收货地址信息的完整性和准确性。如果地址不完整,可能会要求客户提供更多信息,或进行二次确认。

一旦发货地址验证无误,且商品准备就绪,订单聚合根将订单状态更新为“已发货”。随后,实际物流操作开始进行,并在系统中记录和跟踪发货的过程信息。

代码演示

聚合根的定义

import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.List;
import java.util.UUID;
​
public class Order {
    private String orderId;
    private List<OrderItem> orderItems;
    private PaymentInformation paymentInformation;
    private ShippingAddress shippingAddress;
    private OrderStatus status;
​
  public Order(OrderRequest request) {
        this.orderId = UUID.randomUUID().toString();
        this.orderItems = request.getItems();
        this.paymentInformation = request.getPaymentInformation();
        this.shippingAddress = request.getShippingAddress();
        this.status = OrderStatus.CREATED;
    }
​
    // 添加订单明细
    public void addOrderItem(OrderItem orderItem) {
        orderItems.add(orderItem);
    }
​
    // 获取订单总金额
    public double getTotalAmount() {
        double totalAmount = 0.0;
        for (OrderItem item : orderItems) {
            totalAmount += item.getTotalPrice();
        }
        return totalAmount;
    }
​
    // 支付订单
    public void processPayment(String paymentMethod, double amount) {
        if (!paymentInformation.isPaid()) {
            paymentInformation = new PaymentInformation(paymentMethod, amount);
            paymentInformation.markAsPaid(LocalDateTime.now());
            status = OrderStatus.PAID; // 更新订单状态为已支付
        }
    }
​
    // 设置发货地址
    public void setShippingAddress(ShippingAddress shippingAddress) {
        this.shippingAddress = shippingAddress;
    }
​
    // 获取订单状态
    public OrderStatus getStatus() {
        return status;
    }
​
    // 枚举类型定义订单状态
    public enum OrderStatus {
        CREATED,
        PAID,
        SHIPPED,
        DELIVERED,
        CANCELLED
    }
}

实体以及值对象定义

public class OrderItem {
    private String productId;
    private int quantity;
    private double unitPrice;
​
    // Constructors, getters, setters
    public OrderItem(String productId, int quantity, double unitPrice) {
        this.productId = productId;
        this.quantity = quantity;
        this.unitPrice = unitPrice;
    }
​
    // Getters
    public String getProductId() {
        return productId;
    }
​
    public int getQuantity() {
        return quantity;
    }
​
    public double getUnitPrice() {
        return unitPrice;
    }
​
    public double getTotalPrice() {
        return quantity * unitPrice;
    }
}
public class PaymentInformation {
    private String paymentMethod;
    private double amount;
    private boolean paid;
    private LocalDateTime paymentTime;
​
    // Constructors, getters, setters
    public PaymentInformation(String paymentMethod, double amount) {
        this.paymentMethod = paymentMethod;
        this.amount = amount;
        this.paid = false; // 默认未支付
    }
​
    public void markAsPaid(LocalDateTime paymentTime) {
        this.paid = true;
        this.paymentTime = paymentTime;
    }
​
    // Getters
    public String getPaymentMethod() {
        return paymentMethod;
    }
​
    public double getAmount() {
        return amount;
    }
​
    public boolean isPaid() {
        return paid;
    }
​
    public LocalDateTime getPaymentTime() {
        return paymentTime;
    }
}
public class ShippingAddress {
    private String province;
    private String city;
    private String street;
    private String postalCode;
​
    // Constructors, getters, setters
    public ShippingAddress(String province, String city, String street, String postalCode) {
        this.province = province;
        this.city = city;
        this.street = street;
        this.postalCode = postalCode;
    }
​
    // Getters
    public String getProvince() {
        return province;
    }
​
    public String getCity() {
        return city;
    }
​
    public String getStreet() {
        return street;
    }
​
    public String getPostalCode() {
        return postalCode;
    }
}

订单请求对象Request

import java.util.List;
​
public class OrderRequest {
    private List<OrderItem> items;
    private PaymentInformation paymentInformation;
    private ShippingAddress shippingAddress;
​
    // Getters and setters
    public List<OrderItem> getItems() {
        return items;
    }
​
    public void setItems(List<OrderItem> items) {
        this.items = items;
    }
​
    public PaymentInformation getPaymentInformation() {
        return paymentInformation;
    }
​
    public void setPaymentInformation(PaymentInformation paymentInformation) {
        this.paymentInformation = paymentInformation;
    }
​
    public ShippingAddress getShippingAddress() {
        return shippingAddress;
    }
​
    public void setShippingAddress(ShippingAddress shippingAddress) {
        this.shippingAddress = shippingAddress;
    }
}

Controller层演示

import org.springframework.web.bind.annotation.*;
​
@RestController
@RequestMapping("/orders")
public class OrderController {
​
    @PostMapping("/create")
    public Order createOrder(@RequestBody OrderRequest request) {
        // 创建订单
        Order order = new Order(request);
​
        // 模拟支付过程
        order.processPayment(request.getPaymentInformation().getPaymentMethod(), request.getPaymentInformation().getAmount());
​
        // 返回创建的订单
        return order;
    }
​
    @PostMapping("/{orderId}/pay")
    public Order payOrder(@PathVariable String orderId, @RequestParam String paymentMethod, @RequestParam double amount) {
        // 此处应该从数据库或缓存中获取订单信息,这里简化为示例
        Order order = retrieveOrderFromDatabase(orderId);
​
        // 处理支付
        order.processPayment(paymentMethod, amount);
​
        // 更新订单状态等操作
​
        // 返回更新后的订单
        return order;
    }
​
    @PutMapping("/{orderId}/shipping-address")
    public Order updateShippingAddress(@PathVariable String orderId, @RequestBody ShippingAddress shippingAddress) {
        // 此处应该从数据库或缓存中获取订单信息,这里简化为示例
        Order order = retrieveOrderFromDatabase(orderId);
​
        // 更新发货地址
        order.setShippingAddress(shippingAddress);
​
        // 返回更新后的订单
        return order;
    }
​
}
​

可以看到,我们的的service层变得很薄的。而且我们的整个订单聚合内聚性极强,支付信息,收货信息等聚合也全部都是通过聚合根Order来控制,整个订单聚合保持了一致性。

总结

领域驱动设计(DDD)是软件开发的一场革命,它将我们从技术细节的泥潭中解放出来,让我们重新聚焦于业务本身。DDD不是一套僵化的规则,而是一种思想,一种将业务领域知识置于软件开发核心的哲学。

通过DDD,我们构建的不再是简单的代码,而是一个个鲜活的业务模型。这些模型,如实体、值对象、聚合,它们携带着业务的DNA,它们的行为和属性定义了业务的逻辑和流程。这种以业务为中心的建模方式,让软件系统更加贴近真实世界,更加易于理解和维护。

DDD提倡的分层架构,将业务逻辑与技术实现解耦,使得系统更加灵活,更易于适应变化。它鼓励我们在不同的层次上使用最适合的技术,而不必为了技术而牺牲业务的清晰性和可维护性。

限界上下文的概念,更是DDD中的点睛之笔。它教会我们如何在庞大的业务领域中划定边界,定义清晰的业务规则和模型,同时通过上下文映射来实现不同业务领域间的协调和交互。

而充血模型的应用,则是对传统贫血模型的一种颠覆。它将业务逻辑重新赋予领域对象,让对象本身具备行为和决策能力,从而提升了代码的内聚性和可读性。

在微服务架构大行其道的今天,DDD的原则和模式更显价值。它指导我们在服务拆分时保持业务的完整性,避免业务逻辑的碎片化,构建出既灵活又健壮的微服务系统。

DDD是一种精神,一种追求业务与技术和谐统一的精神。它不是终点,而是起点,引导我们在软件开发的道路上不断探索和前行。拥抱DDD,就是拥抱一种更加人性化、更加富有创造力的软件开发方式。