业务中台-领域驱动设计的落地策略

Overview

通过对业务中台的了解,中台通过两部分组成,即业务中台和数据中台,而业务中台在实际应用中的架构体现是微服务,其实说微服务也不准确,是微服务的一个主流设计思想,领域驱动设计(DDD-Domain-Driver Design)。

疑问:

微服务和领域驱动到底啥关系?

微服务和领域驱动的结合

微服务的理解

而我对微服务的理解更多的是,微服务实际上是一个思想,我对微服务的理解:

微服务是一种架构思想,按照业务来拆分架构,一个业务即一个服务,业务是微服务的最小执行单元,服务之间完全解耦/隔离,每个服务可以有自己的框架和数据库,每个服务遵守单一职责原则,微服务的落地体现是分布式开发,为分布式开发提供一种可行的实现策略。

可以发现,微服务的最小执行单元是业务,即微服务是面向业务开发的,那么业务如何拆分?按照什么标准拆分?

DDD-Domain Driver Design

DDD的概念产生于Eric Evans 在他的《Domain Driver design》一书中,是对面向对象设计的增强,这个概念不是在微服务中提出的,是构建复杂软件的方法论。

领域模型有两个特别重要的概念

子域和限界上下文

子域

子域是领域的一部分,领域驱动为每一个子域定义单独的领域模型,每一个子域对应一个业务,分析并识别业务的不同专业领域,分析出的子域和实际业务是非常接近的。

bounded context:

DDD将领域模型(即子域)的边界称之为限界上下文。

小结

DDD的思想可以在微服务中很好地使用,子域和bounded context 可以很好的和服务进行匹配,所以说DDD是微服务众多思想中很主流的思想。

疑问:

领域驱动看起来确实很好,但是有学习的必要么?

DDD必会成为未来微服务的主流,并影响到所有微服务开发者

随着中台的提出,为数据化经济提出了可行的落地方案。

数字化转型的进程似乎成了一个必然的历史进程。

依据:

1.央行19年的各种渠道的吹风:中国央行自己的数字货币,DCEP。

2.2020年5月13日下午,国家发展改革委官网发布“[数字化转型伙伴行动]”倡议。

3.两会政府报告,频繁提到的“互联网+”。

4.数博会,数字中国建设峰会,智博会的成功举行。

小结

可以看出数字化转型可算是雨露均沾,既有了具体可行的技术实现,又有中央政府的扶持,所以有理由可以相信,未来中台开发可能是众多程序员可以选择的一个新的出路,为了避免内卷,只有跳出存量内卷,进行增量竞争这一条路。

中台有两个部分组成:业务中台,数据中台。本文会对业务中台的具体实现,领域驱动设计切入,进行梳理和内化为我自己的思维系统中。

疑问:

既然领域驱动的前途看起来很光明,那么我们以前的开发逻辑到底是啥?

领域驱动和数据驱动

其实我们这些程序员大多数使用的三层架构或者四层架构,大多数都是基于数据库驱动的,即所有业务落实到代码中,实际上是按照数据库中的表来进行划分的。比如常见的UserController,UserSevice,UserDao,等等,这样的代码逻辑,完完全全就是数据库·表驱动,这鞋代码的产生是依托于数据中的表,那么必然也会受限于数据中的表,其实我们平时写代码会陷入一个很烦恼的代码无法按照业务来优化的恶性循环中:

1.按照数据库表作为一个业务的实现维度。

2.在这个按照表为维度的逻辑中,寻找这个业务中的主表,并实现相关业务。

3.功能实现之后发现,这个代码逻辑是以表为边界区分的,这时再想按照业务优化已经办不到了。

数据库驱动的痛点

1.业务无法聚合

相同或相似的业务代码无法抽成一个共用的代码,即无法对业务进行聚合,即使抽出来也只是为了共用而共用,无非就是抽出一个公共方法,在公共方法中添加一堆不后人不知所云的参数,不同维度的参数,这些参数可能来自不同的对象,不同的属性,代码阅读起来极差,完全没有重用的满足感,清晰度,不应该说根本没有清晰度可言。

2.业务代码成了需求文档的极度抽象的体现:

虽然代码都说是按照需求文档写的,但是以数据库驱动这个思路实现的代码,和需求文档根本对应不起来,必须得从开发那里拿到这块的表设计,结合表,一层一层的debug才能推测出这块功能的逻辑,需求文档和实现代码对应不起来,而变成了玄学,代码成了需求文档季度抽象的体现。

疑问:

领域驱动既然是一个很好解决业务无法聚合的思路,但是如何落地呢?

DDD的落地

通过相关资料的学习,DDD的落地,可以有两种策略:

1.按照DDD思想设计出一套负荷领域驱动设计思想的技术架构:

Cola,可乐框架,这个框架已经迭代到了3.0,COLA 作为应用架构,已经被选入阿里云的 Java 应用初始化的应用架构选项之一,可以对这个框架的未来期望一下。

2.单纯的作为一种思想

如果单纯的将之作为一个种架构思想,那么会在代码层面缺乏了足够的约束,导致DDD想要落地很难,缺少标准化的约束,每个人对之的理解都不同,变得非常抽象,如果想要将之运用到实际中会门槛会非常高,需要系统的学习DDD,而且还有可能发现就算系统性的学习了,也很难将之实施,暂时将之作为思想并形成一套合理约束落地的策略是:

阿里技术专家详解DDD的一套可以落地的策略帖子。

本文会对阿里这一套帖子进行分析和内化成自己的理解,形成一个自己的系统性思维。

所以本帖会对原贴提出的大量案例进行引用,如有侵权,请告知,立刻删帖。

notice:

领域驱动中什么是业务:

不是所有的代码都叫业务,所有代码写的过程都叫业务,在领域驱动中,业务指的是流程的流转,按照流程来区分限界上下文。

Domain Primitive

概念理解:

这个是阿里那一套帖子中提出的一个概念,原贴对之解释:

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

并对Primitive解释为:

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


image.png

ByMe:

初看这个解释,感觉很抽象,通过对他这个帖子看完好的理解为:

DP是DDD中的一个基础概念,是DDD中可以执行的一个最小单元,最直接的体现是,将业务相关的参数定义在一个特定的领域中(比如一个class文件),封装成一个具有精准定义,自我验证,拥有行为的ValueObject。

notice:

1.行为

即拥有相关业务代码。

2.Value Object

值集合的对象。简称VO,可不是View Object。区别于Entity,拥有id,是一个表的实例,而VO没有ID,更多的强调数据,不需要对应任何表,只是一个数据的集合,一个值对象,他的一个最大特点是,** Immutable这个值对象自从被创建出来后不会被改变,所以说这个对象中的属性最好是被private final修饰的**。

原文中的case:

《阿里技术专家详解 DDD 系列- Domain Primitive》原文中通过三个case来引出了DP的三个概念:

1.将隐性的概念显性化

case:

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

数据库驱动代码和领域驱动代码的比较
原文中通过两种思想的代码进行对比,体现出DDD代码实现业务的优越性

数据库驱动code

public class User {
    Long userId;
    String name;
    String phone;
    String address;
    Long repId;
}

public class RegistrationServiceImpl implements RegistrationService {

    private SalesRepRepository salesRepRepo;
    private UserRepository userRepo;

    public User register(String name, String phone, String address) 
      throws ValidationException {
        // 校验逻辑
        if (name == null || name.length() == 0) {
            throw new ValidationException("name");
        }
        if (phone == null || !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 (Arrays.asList(areas).contains(prefix)) {
                areaCode = prefix;
                break;
            }
        }
        SalesRep rep = salesRepRepo.findRep(areaCode);

        // 最后创建用户,落盘,然后返回
        User user = new User();
        user.name = name;
        user.phone = phone;
        user.address = address;
        if (rep != null) {
            user.repId = rep.repId;
        }

        return userRepo.save(user);
    }

    private boolean isValidPhoneNumber(String phone) {
        String pattern = "^0[1-9]{2,3}-?\\d{8}$";
        return phone.matches(pattern);
    }
}

原文中通过四个维度来分析了这类代码的问题:
1.接口的清晰度

User register(String, String, String);

这样的方法参数,其实是对业务的隐形化处理,并没有体现出接口的业务,如果参数不符合业务逻辑,在编译过程中和review中也不会发现

service.register("殷浩", "浙江省杭州市余杭区文三西路969号", "0571-12345678");

这个参数顺序明显是错误的。
2.数据验证和错误处理

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

这样和中心业务无关的校验,体现在了核心业务代码中,如果有多个类似的业务那么每个接口都需要写一遍这种校验代码,而且比如后期再补充了不同的校验规则,那么每个代码中都需要改动。
而且这种代码会抛出异常,所有引用之的代码都需要trycatch。
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 (Arrays.asList(areas).contains(prefix)) {
        areaCode = prefix;
        break;
    }
}
SalesRep rep = salesRepRepo.findRep(areaCode);

这段其实是一个胶水代码,从入参中抽取部分参数,调用外部依赖,获取更多数据,再从新获取的数据中抽取部分数据为作其他用。
为了复用以上代码,数据驱动开发会对之抽取出对应方法

String areaCode = findAreaCode(phone);
SalesRep rep = salesRepRepo.findRep(areaCode);

而为了复用以上的方法,可能会抽离出一个静态工具类 PhoneUtils 。但是这里要思考的是,静态工具类是否是最好的实现方式呢?当你的项目里充斥着大量的静态工具类,业务代码散在多个文件当中时,你是否还能找到核心的业务逻辑呢?
4.可测试性
数据驱动会产生大量的测试用例
假如一个方法有N个参数,每个方法有M个校验逻辑,那么就会有MN个测试用例,如果有P个方法中都使用了这个参数,那么用例数==MN*P
而用例数和代码质量成反比,即降低测试成本 == 提升代码质量

领域驱动设计-DP code

public class PhoneNumber {
 
   private final 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;
   }

   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 number.matches(pattern);
   }

}
public class User {
    UserId userId;
    Name name;
    PhoneNumber phone;
    Address address;
    RepId repId;
}

public User register(
  @NotNull Name name,
  @NotNull PhoneNumber phone,
  @NotNull Address address
) {
    // 找到区域内的SalesRep
    SalesRep rep = salesRepRepo.findRep(phone.getAreaCode());

    // 最后创建用户,落盘,然后返回,这部分代码实际上也能用Builder解决
    User user = new User();
    user.name = name;
    user.phone = phone;
    user.address = address;
    if (rep != null) {
        user.repId = rep.repId;
    }

    return userRepo.saveUser(user);
}

同样原文中也是通过相同的四个角度比较了一下DP实现的优越性
1.接口的清晰度

public User register(Name, PhoneNumber, Address)

而之前容易出现的bug,如果按照现在的写法

service.register(new Name("殷浩"), new Address("浙江省杭州市余杭区文三西路969号"), new PhoneNumber("0571-12345678"));

让接口 API 变得很干净,易拓展。
2.数据验证和错误处理

public User register(
  @NotNull Name name,
  @NotNull PhoneNumber phone,
  @NotNull Address address
) // no

核心代码中完全没有verify,也不会抛ValidationException


image.png

notice:
但是在实际验证的时候发现,这个不会抛出异常和他文中不符


image.png

这种必须对异常进行处理,一旦这里抛出异常,调用放就必须进行异常处理


image.png

3.业务代码的清晰度

SalesRep rep = salesRepRepo.findRep(phone.getAreaCode());
User user = xxx;
return userRepo.save(user);

原来的胶水代码变成了phone.getAreaCode(), PhoneNumber 类的一个计算属性 getAreaCode让代码清晰度大大提升。而且胶水代码通常都不可复用,但是使用了 DP 后,变成了可复用、可测试的代码。我们能看到,在刨除了数据验证代码、胶水代码之后,剩下的都是核心业务逻辑。
4.可测试性
将PhoneNumber 抽出去成为DP后的TC


image.png

参数依然为N,因为业务因为封装到了DP中,参数必然符合业务,如果不符合业务,也不会进入核心代码中,所以只需要考虑非null。原文虽是这么说的,但是实际是否会是这样呢?

2.将 隐性的 上下文 显性化

case:

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

传统code:

public void pay(BigDecimal money, Long recipientId) {
    BankService.transfer(money, "CNY", recipientId);
}

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

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

参是支付金额 + 支付货币
原有代码变为

public void pay(Money money, Long recipientId) {
    BankService.transfer(money, recipientId);
}

这点我感觉很显然并不是所有DP都具备的特性,但是比如类似金钱这一类业务场景,应该触动肌肉记忆想到这个上下文。

3.封装 多对象 行为

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

public void pay(Money money, Currency targetCurrency, Long recipientId) {
    if (money.getCurrency().equals(targetCurrency)) {
        BankService.transfer(money, recipientId);
    } else {
        BigDecimal rate = ExchangeService.getRate(money.getCurrency(), targetCurrency);
        BigDecimal targetAmount = money.getAmount().multiply(new BigDecimal(rate));
        Money targetMoney = new Money(targetAmount, targetCurrency);
        BankService.transfer(targetMoney, recipientId);
    }
}

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

这个case最大的问题在于,金额的计算被包含在了支付的服务中,涉及到的对象也有2个 Currency ,2 个 Money ,1 个 BigDecimal ,总共 5 个对象。这种涉及到多个对象的业务逻辑。

DP Code;

@Value
public class ExchangeRate {
    private BigDecimal rate;
    private Currency from;
    private Currency to;

    public ExchangeRate(BigDecimal 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.getCurrency()));
        BigDecimal targetAmount = fromMoney.getAmount().multiply(rate);
        return new Money(targetAmount, to);
    }
}
public void pay(Money money, Currency targetCurrency, Long recipientId) {
    ExchangeRate rate = ExchangeService.getRate(money.getCurrency(), targetCurrency);
    Money targetMoney = rate.exchange(money);
    BankService.transfer(targetMoney, recipientId);
}

这样货币换算的相关逻辑就不在核心业务中了,而这条原则似乎是在说:
DP不光是一个属性的类,而且可以是多个value 属性的类,而是对一个业务相关对象都可以封装到一个类中,统一管理。

Domain Primitive的使用创景

原文中对之使用创景进行了介绍:

  • 有格式限制的 String:比如Name,PhoneNumber,OrderNumber,ZipCode,Address等
  • 有限制的Integer:比如OrderId(>0),Percentage(0-100%),Quantity(>=0)等
  • 可枚举的 int :比如 Status(一般不用Enum因为反序列化问题)
  • Double 或 BigDecimal:一般用到的 Double 或 BigDecimal 都是有业务含义的,比如 Temperature、Money、Amount、ExchangeRate、Rating 等
  • 复杂的数据结构:比如 Map> 等,尽量能把 Map 的所有操作包装掉,仅暴露必要行为

通过原文中的介绍可以形成这么个思路:

最开始要实施DP,可能更多的要从方法参数这个角度入手,或者Entity的有限制的属性入手,如果这些参数或属性有业务中明显的限制,就都可以考虑通过DP来统一抽取。

Domain Primitive 和 DDD 里 Value Object 的区别

在 DDD 中, Value Object 这个概念其实已经存在:

在 Evans 的 DDD 蓝皮书中,Value Object 更多的是一个非 Entity 的值对象
在Vernon的IDDD红皮书中,作者更多的关注了Value Object的Immutability、Equals方法、Factory方法等
Domain Primitive 是 Value Object 的进阶版,在原始 VO 的基础上要求每个 DP 拥有概念的整体,而不仅仅是值对象。在 VO 的 Immutable 基础上增加了 Validity 和行为。当然同样的要求无副作用(side-effect free)。

byMe:

就如DDD是对面向对象的增强,DP是对VO的增强。对之增加了业务校验,和行为(业务代码)。

Domain Primitive 和 Data Transfer Object (DTO) 的区别

image.png

byMe:

可以看出,DTO作为数据传输的对象,是没有行为的,而DP是强调行为的,DP中的数据是一个闭环,数据是高度相关的,单个DP就可以实现一部分业务。

DP 依旧强调 Immutable

因为DP是VO的增强版,其依旧强调不可变,这个业务数据,被创建起,在所有所在业务的事务中,不能被改变。

老应用的重构流程

文中最后对重构老应用提出了实现流程:**

**在新应用中使用 DP 是比较简单的,但在老应用中使用 DP 是可以遵循以下流程按部就班的升级。在此用本文的第一个 case 为例。

▍第一步 - 创建 Domain Primitive,收集所有 DP 行为

在前文中,我们发现取电话号的区号这个是一个可以独立出来的、可以放入 PhoneNumber 这个 Class 的逻辑。类似的,在真实的项目中,以前散落在各个服务或工具类里面的代码,可以都抽出来放在 DP 里,成为 DP 自己的行为或属性。这里面的原则是:所有抽离出来的方法要做到无状态,比如原来是 static 的方法。如果原来的方法有状态变更,需要将改变状态的部分和不改状态的部分分离,然后将无状态的部分融入 DP 。因为 DP 本身不能带状态,所以一切需要改变状态的代码都不属于 DP 的范畴。

(代码参考 PhoneNumber 的代码,这里不再重复)

▍第二步 - 替换数据校验和无状态逻辑

为了保障现有方法的兼容性,在第二步不会去修改接口的签名,而是通过代码替换原有的校验逻辑和根 DP 相关的业务逻辑。比如:**

public User register(String name, String phone, String address)
        throws ValidationException {
    if (name == null || name.length() == 0) {
        throw new ValidationException("name");
    }
    if (phone == null || !isValidPhoneNumber(phone)) {
        throw new ValidationException("phone");
    }
    
    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 (Arrays.asList(areas).contains(prefix)) {
            areaCode = prefix;
            break;
        }
    }
    SalesRep rep = salesRepRepo.findRep(areaCode);
    // 其他代码...
}

通过 DP 替换代码后:

public User register(String name, String phone, String address)
        throws ValidationException {
    
    Name _name = new Name(name);
    PhoneNumber _phone = new PhoneNumber(phone);
    Address _address = new Address(address);
    
    SalesRep rep = salesRepRepo.findRep(_phone.getAreaCode());
    // 其他代码...
}

通过 new PhoneNumber(phone) 这种代码,替代了原有的校验代码。

通过 _phone.getAreaCode() 替换了原有的无状态的业务逻辑。

▍第三步 - 创建新接口

创建新接口,将DP的代码提升到接口参数层:

public User register(Name name, PhoneNumber phone, Address address) {
    SalesRep rep = salesRepRepo.findRep(phone.getAreaCode());
}

第四步 - 修改外部调用

外部调用方需要修改调用链路,比如:

service.register("殷浩", "0571-12345678", "浙江省杭州市余杭区文三西路969号");

改为:

service.register(new Name("殷浩"), new PhoneNumber("0571-12345678"), new Address("浙江省杭州市余杭区文三西路969号"));

总结:

本文逻辑:

1.通过 中台 引出业务中台的实现思路,微服务和DDD的结合
2..通过 微服务和DDD的结合 引出 我对两者概念的一个理解,并介绍了DDD中两个比较重要的元素:子域和限界上下文,同时引出学习这个必要性。
3.通过介绍中台的技术实现和政府扶持,展望了一下其的前途,并引出DDD的落地方案。
4.通过介绍通过两个实现方式:将之实例化为框架和阿里的一个系列帖子将之当为一种思想,因为暂时对Cola框架的学习没有足够动力,所以重点学习阿里的系列贴,引出对阿里系列帖子的学习,进而引出DP
5.学习DP。

内化的点

1.代码实现的逻辑的维度 数据驱动,领域驱动,领域驱动的优点,数据驱动的痛点。
2.代码业务优化的维度:

  • 接口清晰度 通过接口参数是否能够显示业务
  • 数据验证和错误处理 数据校验是否统一维护,数据校验的依赖性,比如文中区号实际上依赖电话号中的规则体现出的。
  • 业务逻辑代码的清晰度 胶水代码是否存在于核心业务代码中,区分核心业务代码和非核心业务代码,是一个让DP很好切入的点。
  • 可测试性。 降低测试成本 == 提升代码质量

新思想

  • 领域驱动:与其说其是面向对象的增强,不如说是面向业务的开发,提供了一个新的写代码的角度。
  • DP:对业务中所有 有行为的数据进行封装。一个VO的增强,对VO有明确的业务定义,数据校验和行为。
  • 数据行为:如果一个对象里面没有除了set,get,这类功能方法,没有业务逻辑的方法,那么这类对象是无行为对象对象,相反的,数据对象是有业务方法的,即有行为。
  • VO:数据对象,相比于Entity,无id,不需要和数据库表对应,有行为。
  • VO的Immutable特点:数据对象即原先代码中的变量,在事务内不能被改变。

参考

阿里技术专家详解 DDD 系列- Domain Primitive

你可能感兴趣的:(业务中台-领域驱动设计的落地策略)