DDD思想落地·一

1,017 阅读8分钟

前言

DDD不是一套框架,而是一种架构思想,所以在代码层面缺乏了足够的约束,导致DDD在实际应用中上手门槛很高,甚至可以说绝大部分人都对 DDD 的理解有所偏差。

如何将一个 Monolith1应用合理的拆分为多个微服务成为了各大论坛的热门话题,而 DDD 里面的 Bounded Context 2(限界上下文)的思想为微服务拆分提供了一套合理的框架。而在今天,在一个所有的东西都能被称之为“服务”的时代(XAAS), DDD 的思想让我们能冷静下来,去思考到底哪些东西可以被服务化拆分,哪些逻辑需要聚合,才能带来最小的维护成本,而不是简单的去追求开发效率。

下面试着通过合理的代码结构、框架和约束,来降低 DDD 的实践门槛, 提升代码质量、可测试性、安全性、健壮性。

Domain Primitive

就好像在学任何语言时首先需要了解的是基础数据类型一样,在全面了解 DDD 之前,首先给大家介绍一个最基础的概念: Domain Primitive3(DP)。

Primitive 的定义是:

不从任何其他事物发展而来,初级的形成或生长的早期阶段

就好像 Integer、String 是所有编程语言的Primitive一样,在 DDD 里, DP 可以说是一切模型、方法、架构的基础,而就像 Integer、String 一样, DP 又是无所不在的。

先对 DP 做一个全面的介绍和分析,从案例入手,看看为什么 DP 是一个强大的概念。

案例分析 一

一个新应用在全国通过 地推业务员做推广,需要做一个用户注册系统,
同时希望在用户注册后能够通过用户电话(先假设仅限座机)的地域(区号)对业务员发奖金。

先不要去纠结这个根据用户电话去发奖金的业务逻辑是否合理,也先不要去管用户是否应该在注册时和业务员做绑定,这里我们看的主要还是如何更加合理的去实现这个逻辑。
一个简单的用户和用户注册的代码实现如下:

    public class RegistrationServiceImpl : IRegistrationService
    {
        private SalesRepRepository _salesRepRepo;
        private UserRepository _userRepo;

        public RegistrationServiceImpl(SalesRepRepository salesRepRepo, UserRepository userRepo)
        {
            _salesRepRepo = salesRepRepo;
            _userRepo = userRepo;
        }

        public User Register(string name, string phone, string address)
        {
            if (string.IsNullOrWhiteSpace(name))
            {
                throw new ValidationException("name");
            }
            if (string.IsNullOrWhiteSpace(phone) || !IsValidPhoneNumber(phone))
            {
                throw new ValidationException("phone");
            }
            // 此处省略address的校验逻辑 取电话号里的区号,然后通过区号找到区域内的SalesRep
            string areaCode = null;
            string[] areas = new string[] { "0571", "021", "010" };
            for (int i = 0; i < phone.Length; i++)
            {
                string prefix = phone.Substring(0, i);
                if (areas.Contains(prefix))
                {
                    areaCode = prefix;
                    break;
                }
            }

            var rep = _salesRepRepo.FindRep(areaCode);
            // 最后创建用户,落盘,然后返回
            User user = new User
            {
                Name = name,
                Phone = phone,
                Address = address
            };
            if (rep != null)
            {
                user.RepId = rep.RepId;
            }

            return _userRepo.Save(user);
        }

        /// <summary>
        /// 验证电话号码
        /// </summary>
        private bool IsValidPhoneNumber(string phone)
        {
            string pattern = "^0[1-9]{2,3}-?\\d{8}$";
            return Regex.IsMatch(phone, pattern);
        }
    }

我们日常绝大部分代码和模型其实都跟这个是类似的,乍一看貌似没啥问题,但我们再深入 一步,从以下四个维度去分析一下:

  1. 接口的清晰度(可阅读性)
  2. 数据验证和错误处理
  3. 业务逻辑代码的清晰度
  4. 可测试性

问题 1 - 接口的清晰度

以下的代码是一段编译器完全不会报错的,很难通过看代码就能发现的 bug :

 _service.Register("小明", "浙江省杭州市余杭区文三西路 969 号", "0571-12345678");

当然,在真实代码中运行时会报错,但这种bug是在运行时被发现的,而不是在编译时。普通的CodeReview也很难发现这种问题,很有可能是代码上线后才会被暴露出来。
这里的思考是,有没有办法在编码时就避免这种可能会出现的问题?

另外一种常见的,特别是在查询服务中容易出现的例子如下:

User FindByName(string name);
User FindByPhone(string phone);
User FindByNameAndPhone(string name, string phone);

在这个场景下,由于入参都是String类型,不得不在方法名上面加上ByXXX来区分,而FindByNameAndPhone同样也会陷入前面的入参顺序错误的问题,而且和前面的入参不同,这里参数顺序如果输错了,方法不会报错只会返回 null,而这种 bug 更加难被发现。
这里的思考是,有没有办法让方法入参一目了然,避免入参错误导致的 bug ?

问题 2 - 数据验证和错误处理

在前面这段数据校验代码:

 if (string.IsNullOrWhiteSpace(phone) || !IsValidPhoneNumber(phone))
 {
     throw new ValidationException("phone");
 }

在日常编码中经常会出现,一般来说这种代码需要出现在方法的最前端,确保能够fail-fast。但是假设你有多个类似的接口和类似的入参,在每个方法里这段逻辑会被重复。而更严重的是如果未来我们要拓展电话号去包含手机时,很可能需要加入以下代码:

if (phone == null || !IsValidPhoneNumber(phone) || !IsValidCellNumber(phone)) 
{
    throw new ValidationException("phone");
}

如果你有很多个地方用到了phone这个入参,但是有个地方忘记修改了,会造成bug。这是一个DRY4原则被违背时经常会发生的问题。

如果有个新的需求,需要把入参错误的原因返回,那么这段代码就变得更加复杂:

if (phone == null) 
{
    throw new ValidationException("phone 不能为空");
} 
else if (!IsValidPhoneNumber(phone)) 
{
    throw new ValidationException("phone 格式错误");
}

可以想像得到,代码里充斥着大量的类似代码块时,维护成本要有多高。

最后,在这个业务方法里,会(隐性或显性的)抛ValidationException,所以需要外部调用方去try/catch,而业务逻辑异常和数据校验异常被混在了一起,是否是合理的?

所以,有没有一种方法,能够一劳永逸的解决所有校验的问题以及降低后续的维护成本和异常处理成本呢?

问题 3 - 业务代码的清晰度

在这段代码里:

string areaCode = null;
string[] areas = new string[] { "0571", "021", "010" };
for (int i = 0; i < phone.Length; i++)
{
    string prefix = phone.Substring(0, i);
    if (areas.Contains(prefix))
    {
        areaCode = prefix;
        break;
    }
}

var rep = _salesRepRepo.FindRep(areaCode);

实际上出现了另外一种常见的情况,那就是从一些入参里抽取一部分数据,然后调用一个外部依赖获取更多的数据,然后通常从新的数据中再抽取部分数据用作其他的作用。
这种代码通常被称作“胶水代码”,其本质是由于外部依赖的服务的入参并不符合我们原始的入参导致的。比如,如果SalesRepRepository 包含一个FindRepByPhone 的方法,则上面大部分的代码都不必要了。

所以,一个常见的办法是将这段代码抽离出来,变成独立的一个或多个方法:

public static class PhoneUtils
{
    public static string FindAreaCode(String phone)
    {
        for (int i = 0; i < phone.Length; i++)
        {
            string prefix = phone.Substring(0, i);
            if (IsAreaCode(prefix))
            {
                return prefix;
            }
        }
        return null;
    }

    public static bool IsAreaCode(String prefix)
    {
        string[] areas = new string[] { "0571", "021", "010" };
        return areas.Contains(prefix);
    }
}

然后原始代码变为:

string areaCode = PhoneUtils.FindAreaCode(phone);
var rep = _salesRepRepo.FindRep(areaCode);

而为了复用以上的方法,可能会抽离出一个静态工具类PhoneUtils。但是这里要思考的是,静态工具类是否是最好的实现方式呢?当你的项目里充斥着大量的静态工具类,业务代码散在多个文件当中时,你是否还能找到核心的业务逻辑呢?

问题 4 - 可测试性

为了保证代码质量,每个方法里的每个入参的每个可能出现的条件都要有 TC 覆盖。
假如一个方法有 N 个参数,每个参数有 M 个校验逻辑,至少要有 N * M 个 TC 。
如果这时候在该方法中加入一个新的入参字段fax,即使fax和phone的校验逻辑完全一致,为了保证TC覆盖率,也一样需要 M 个新的 TC 。
在日常项目中,这个测试的成本非常之高,导致大量的代码没被覆盖到。而没被测试覆盖到的代码才是最有可能出现问题的地方。在这个情况下,降低测试成本 == 提升代码质量,如何能够降低测试的成本呢?

解决方案

我们回头先重新看一下原始的 use case,并且标注其中可能重要的概念:

一个新应用在全国通过 地推业务员 做推广,需要做一个用户的注册系统,
在用户注册后能够通过用户电话号的区号对业务员发奖金。

在分析了usecase后,发现其中地推业务员、用户本身自带ID属性,属于Entity(实体),而注册系统属于ApplicationService(应用服务),这几个概念已经存在。
但是发现电话号这个概念却完全被隐藏到了代码之中。思考一下:
取电话号的区号的逻辑是否属于用户(用户的区号?)?
是否属于注册服务(注册的区号?)?
如果都不是很贴切,那就说明这个逻辑应该属于一个独立的概念。所以这里引入我们第一个原则:

将隐性的概念显性化(Make Implicit Concepts Explicit)

在这里,我们可以看到,原来电话号仅仅是用户的一个参数,属于隐形概念,但实际上电话号的区号才是真正的业务逻辑,而我们需要将电话号的概念显性化,通过写一个ValueObject

public class PhoneNumber
{
    private readonly string number;

    public string GetNumber()
    {
        return 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 GetAreaCode()
    {
        for (int i = 0; i < number.Length; i++)
        {
            string prefix = number.Substring(0, i);
            if (IsAreaCode(prefix))
            {
                return prefix;
            }
        }
        return null;
    }

    public static bool IsAreaCode(string prefix)
    {
        string[] areas = new string[] { "0571", "021", "010" };
        return areas.Contains(prefix);
    }

    public static bool IsValid(string phone)
    {
        string pattern = "^0[1-9]{2,3}-?\\d{8}$";
        return Regex.IsMatch(phone, pattern);
    }
}

这里面有几个很重要的元素:

  1. 通过 private readonly string number 确保PhoneNumber是一个ValueObject。(重点强调VO一般都是Immutable5
  2. 校验逻辑都放在了constructor里面,确保只要 PhoneNumber 类被创建出来后,一定是校验通过的。
  3. 之前的FindAreaCode方法变成了PhoneNumber类里的GetAreaCode,突出了AreaCode是PhoneNumber的一个计算属性。

这样做完之后,我们发现把 PhoneNumber 显性化之后,其实是生成了一个 Type(数据类型)和一个 Class(类):

Type 指我们在今后的代码里可以通过 PhoneNumber 去显性的标识电话号这个概念。
Class 指我们可以把所有跟电话号相关的逻辑完整的收集到一个文件里。

这两个概念加起来,构造成了本文标题的 Domain Primitive(DP)。

我们看一下使用 DP 之后代码:

public User Register(UserName name, PhoneNumber phone, Address address)
{
    // 找到区域内的 SalesRep
    var rep = _salesRepRepo.FindRep(phone.GetAreaCode());
    // 最后创建用户,落盘,然后返回,这部分代码实际上也能用 Builder 解决
    var  user = new Model.Entity.User
    {
        Name = name,
        Phone = phone,
        Address = address
    };
    if (rep != null)
    {
        user.RepId = rep.RepId;
    }
    return _userRepo.Save(user);
}

评估 1 - 接口的清晰度

重构后的方法签名变成了很清晰的:

User Register(UserName name, PhoneNumber phone, Address address);

之前容易出现的传参次序bug、参数缺少校验bug,都将被避免。让接口 API 变得很干净,易拓展。

评估 2 - 数据验证和错误处理

重构后的RegistrationServiceImpl.Register(UserName,PhoneNumber,Address)方法里,完全没有了任何数据验证的逻辑,也不会抛ValidationException。原因是因为DP的特性,只要是能够带到入参里的一定是正确的。所以我们把数据验证的工作量前置到了调用方,而调用方本来就是应该提供合法数据的,所以更加合适。

再展开来看,使用 DP 的另一个好处就是代码遵循了 DRY 原则和单一性原则,如果未来需要修改 PhoneNumber 的校验逻辑,只需要在一个文件里修改即可,所有使用到了PhoneNumber 的地方都会生效。

评估 3 - 业务代码的清晰度

var rep = _salesRepRepo.FindRep(phone.GetAreaCode());
var user = xxx;
return _userRepo.Save(user);

除了在业务方法里不需要校验数据之外,原来的一段胶水代码 FindAreaCode 被改为了PhoneNumber 类的一个计算属性 getAreaCode ,让代码清晰度大大提升。而且胶水代码通常都不可复用,但是使用了DP后,变成了可复用、可测试的代码。我们能看到,在刨除了数据验证代码、胶水代码之后,剩下的都是核心业务逻辑。

评估 4 - 可测试性

当我们将 PhoneNumber 抽取出来之后,在来看测试的 TC :

  1. 首先 PhoneNumber 本身还是需要M个测试用例,但是由于我们只需要测试单一对象,每个用例的代码量会大大降低,维护成本降低。
  2. 每个方法里的每个参数,现在只需要覆盖为 null 的情况就可以了,其他的 case 不可能发生(因为只要不是 null 就一定是合法的)

所以,单个方法的 TC 从原来的 N * M 变成了今天的 N + M 。同样的,多个方法的 T C 数量变成了 N + M + P
这个数量一般来说要远低于原来的数量 N* M * P ,让测试成本极大的降低。

案例分析 二

在上文我介绍了 DP 的第一个原则:将隐性的概念显性化。在这里我将介绍 DP 的另外两个原则,用一个新的案例。

转账

假设现在要实现一个功能,让 A 用户可以支付 x 元给用户 B ,可能的实现如下:

public void Pay(decimal money, long recipientId) 
{
    BankService.Transfer(money, "RMB", recipientId);
}

如果这个是境内转账,并且境内的货币永远不变,该方法貌似没啥问题,但如果有一天货币变更了(比如欧元区曾经出现的问题),或者我们需要做跨境转账,该方法是明显的 bug ,因为 money 对应的货币不一定是 RMB 。

在这个 case 里,当我们说“支付x元”时,除了x本身的数字之外,实际上是有一个隐含的概念那就是货币“元”。但是在原始的入参里,之所以只用了decimal的原因是我们认为RMB货币是默认的,是一个隐含的条件,但是在我们写代码时,需要把所有隐性的条件显性化,而这些条件整体组成当前的上下文。所以 DP 的第二个原则是:

将 隐性的 上下文 显性化(Make Implicit Context Explicit)

所以当我们做这个支付功能时,实际上需要的一个入参是支付金额+支付货币。我们可以把这两个概念组合成为一个独立的完整概念:Money

public class Money
{
    private decimal amount;
    private Currency currency;
    public Money(decimal amount, Currency currency)
    {
        this.amount = amount;
        this.currency = currency;
    }
}

而原有的代码则变为:

public void Pay(Money money, long recipientId) 
{
    BankService.Transfer(money, recipientId);
}

通过将默认货币这个隐性的上下文概念显性化,并且和金额合并为Money,我们可以避免很多当前看不出来,但未来可能会暴雷的 bug。

跨境转账

前面的案例升级一下,假设用户可能要做跨境转账从 CNY 到 USD ,并且货币汇率随时在波动:

public void Pay(Money money, Currency targetCurrency, long recipientId)
{
    if (money.Currency.Equals(targetCurrency))
    {
        BankService.Transfer(money, recipientId);
    }
    else
    {
        decimal rate = ExchangeService.GetRate(money.Currency, targetCurrency);
        decimal targetAmount = money.Amount * rate;
        Money targetMoney = new Money(targetAmount, targetCurrency);
        BankService.Transfer(targetMoney, recipientId);
    }
}

在这个 case 里,由于targetCurrency不一定和money的Curreny一致,需要调用一个服务去取汇率,然后做计算。最后用计算后的结果做转账。

这个 case 最大的问题在于,金额的计算被包含在了支付的服务中,涉及到的对象也有 2 个Currency ,2 个 Money ,1 个 BigDecimal ,总共 5 个对象。这种涉及到多个对象的业务逻辑,需要用 DP 包装掉,所以这里引出 DP的第三个原则:

封装 多对象 行为(Encapsulate Multi-Object Behavior)

在这个 case 里,可以将转换汇率的功能,封装到一个叫做 ExchangeRate 的 DP 里:

public class ExchangeRate
{
    private decimal rate;
    private Currency from;
    private Currency to;
    public ExchangeRate(decimal rate, Currency from, Currency to)
    {
        this.rate = rate;
        this.from = from;
        this.to = to;
    }
    public Money Exchange(Money fromMoney)
    {
        notNull(fromMoney);
        isTrue(this.from.Equals(fromMoney.Currency));
        decimal targetAmount = fromMoney.Amount * rate;
        return new Money(targetAmount, to);
    }
}

ExchangeRate 汇率对象,通过封装金额计算逻辑以及各种校验逻辑,让原始代码变得极其简单:

public void Pay(Money money, Currency targetCurrency, long recipientId)
{
    ExchangeRate rate = ExchangeService.GetRate(money.Currency, targetCurrency);
    Money targetMoney = rate.Exchange(money);
    BankService.transfer(targetMoney, recipientId);
}

Footnotes

  1. Monolith [ˈmɔnəliθ] 整体式

  2. Bounded [ˈbaʊndɪd] 有界限的;有限制的;边界模型

  3. Primitive [ˈprɪmətɪv] 原始的

  4. DRY原则 Don’t Repeat Yourself,直译就是不要重复你自己

  5. Immutable [ ɪ'mjuːtəbl ] 不可改变的