DDD - Domain Primitive

简单的业务案例

假设现在在做一个简单的数据统计系统,地推员输入客户的姓名和手机号。根据客户手机号的归属地和所属运营商,将客户群体分组,分配给相应销售组,有销售组跟进后续的业务。

DDD - Domain Primitive_第1张图片

分析:

根据上面需求,我们需要提供一个注册服务,这个注册服务的入参应该是客户的姓名和手机号。服务内部根据这个手机号查询对应的归属地编号和运营商编号。在根据这两个值获取分组号最后将客户的姓名、手机号、分组号封装成一个对象,存入数据表。梳理下来业务逻辑很简单下面是一个简单版本的代码实现。

下面代码中定义了一个User类,一个注册接口的具体实现类注册方法中先对参数进行校验。然后通过手机号分别获得归属地编号和运营商编号再通过这两个编号去查询数据表获取分组编号,最后构造用户对象来存入数据表。这样写看上去没有什么问题。如果是一个小工程或者说迭代低频甚至说短期内有可能下线的系统。这样写一点问题都没有,可以称得上又好又块但如果将这样的代码卸载一个迭代频繁的大工程里其实是存在一些隐患(这里只是用一个简单的场景举例,看看下面这段代码有没有什么改进的空间或者设计上不合理的地方)。

public class RegistrationController : Controller
    {
        private SalesrepRepRepository salesrepRep;

        public User Rigister(string name, string phone) 
        {
            //参数校验
            if (name == null) throw new ArgumentNullException(nameof(name));
            if (phone == null) throw new ArgumentNullException(nameof(phone));
            if (IsValidPhoneNumber(phone)) throw new Exception(nameof(phone));

            //获取手机号归属地编号和运营商编号,然后通过编号找到区域内的salesrep
            string areaCode = GetAreaCode(phone);
            string operatorCode = GetOperatorCode(phone);
            Salesrep rep = salesrepRep.FindRep(areaCode, operatorCode);
            
            //最后创建用户、落库、然后返回
            User user = new User();
            user.Name = name;
            user.Phone = phone;
            if (rep != null) 
            {
                user.SalesrepRep = rep.repId;
            }
            return salesrepRep.Save(user);
        }

        private bool IsValidPhoneNumber(string phone)
        {
            string pattern = "^0[1-9]{2,3}-?\\d{8}$";
            return Regex.Match(phone, pattern).Success;
        }

        private string GetOperatorCode(string phone)
        {
            //...
        }

        private string GetAreaCode(string phone)
        {
            //...
        }
    }

下面我们主要从三个方面去审视上面这段代码。

审视点1:接口语义与参数校验

第一点就是接口语义和参数校验Register方法存在了两个类型为string的参数。第一个为用户名第二个为手机号,方法内部一开始对其合法性进行了校验,当这段代码被编译后方法只会保留参数类型,而不会保留参数名如果这段代码存在其目录里或被其他程序集集成时,其他工程师并不了解这个方法的内部逻辑或者说仅仅因为失误,他很有可能会颠倒参数的顺序。

比如进行了如下的调用:

Rigister(“1861630000”,“zhangsan”)

这种错误虽然低级但是人人都有可能出现,无法完全避免并且在代码编译期间是无法检查出来的,此外假设在未来系统开始支持通过用户名和身份证号注册(身份证号也是一个string)。

这时Register方法可能就要被改造成RigisterByPhone和RigisterByIdCarid两个方法,通过不同的方法名来支持不同的语义,加入之后又要支持同时通过手机号和身份证号注册呢?那么又得频繁的修改,这么看来原来的接口定义并不完善。

public User RigisterByPhone(string name, string phone);
public User RigisterByIdCarid(string name,string id);
public User RigisterByPhoneAndIdCarid(string name,string phone,string id);

我们的目标是要让接口语义接口明确无歧义,可扩展性能够强一点最好还能带有一定的自检性。

接口定义修改目标:

  • 1.语义明确无歧义

  • 2.拓展性强一些

  • 3.具有自检性

再来看方法内一开始对参数的校验逻辑如果存在多个类似的方法,那么每个方法都要在开头进行校验。每个方法的开头一定会存在大量的重复代码而且一旦某种类型的参数校验逻辑需要修改那么每个地方都还要一一检查修改这显然不符合“开闭原则”。

//参数校验
if (name == null) throw new ArgumentNullException(nameof(name));
if (phone == null) throw new ArgumentNullException(nameof(phone));
if (IsValidPhoneNumber(phone)) throw new Exception(nameof(phone));

有的小伙伴可能会很自然的想到将这些校验逻辑封装进入某个工具类这样就能一定成都的重复使用。比如改造成下面这个样子,这样做虽然解决了一些问题但不算是一种最佳实践,因为他还存在两个缺点:

  • 1.业务方法内还是需要主动调用工具类来进行校验,如果校验失败需要抛出异常,这样在业务方法中把参数异常和业务异常逻辑混合了起来不太合理。

  • 2.一旦参数类型越来越多那么工具类中的校验逻辑会随之不断膨胀后续也不太好维护。

pulick class ValidationUtil
{
   public bool IsValidPhone(string phone){...}
   public bool IsValidName(string name){...}
}

综合上述接口语义和参数校验的相关问题,我们想一想有没有更加优雅的实现方式,先来梳理一下想要达成的目标。

修改目标:

  • 1.接口语义明确,可拓展性强,最好带有自检性

  • 2.参数校验逻辑复用,内聚

  • 3.参数校验异常和业务逻辑异常接口

在最上面的接口示例中,接口语义不明确使用了多个相同基本类型(string)的参数可扩展性不强的原因是使用了基本类型和参数个数也写死了,那么我们是否可以使用自定义类型来代替?自定义类型中应该包含哪些信息呢?基本属性肯定比不可少,此外我们是否可以将对属性的校验逻辑封装到这个自定义类型中呢?这样接口就只会接收到通过校验的参数,这样做还有一个好处就是自然而然的将参数校验异常于业务逻辑异常区分开来,比如我们可以将方法签名改造成这个样子:

public User Register(string name,PhoneNumber phone)

新建一个自定义类PhoneNumber这样我们在构建PhoneNumber对象时就会只想校验逻辑,确保被构建出来的对象一定是合法的。不需要业务方法内部去做校验逻辑而且将不同类型的校验逻辑内聚到了它自身之中,不会再分散再工具类中进行管理。此外方法的前面中因为使用了自定义类型,不仅语义清晰而且再编译器内就会进行强类型校验避免传参数乱序这种低级错误。

public class PhoneNumber
    {
        private string number;
        private readonly string pattern = "^0[1-9]{2,3}-?\\d{8}$";

        public string Number { get => number; private set => number = value; }

        public  PhoneNumber(string number) 
        {
            if (string.IsNullOrWhiteSpace(number))
            {
                throw new Exception("number不能为空");
            }
            else if(IsValidPhoneNumber(number))
            {
                throw new Exception("number格式错误");
            }

            Number = number;
        }

        private bool IsValidPhoneNumber(string number)
        {
            string pattern = "^0[1-9]{2,3}-?\\d{8}$";
            return Regex.Match(number, pattern).Success;
        }
    }

此时代码就改造成这样个样子,可以看出接口的语义更加清晰拥有了一定的可扩展性。对参数的校验逻辑也更加内聚了。

public class RegistrationController : Controller
    {
        private SalesrepRepRepository salesrepRep;

        public User Rigister(string name, PhoneNumber phone) 
        {
            //获取手机号归属地编号和运营商编号,然后通过编号找到区域内的salesrep
            string areaCode = GetAreaCode(phone);
            string operatorCode = GetOperatorCode(phone);
            Salesrep rep = salesrepRep.FindRep(areaCode, operatorCode);

            User user = new User();
            user.Name = name;
            user.Phone = phone;
            if (rep != null) 
            {
                user.SalesrepRep = rep.repId;
            }
            return salesrepRep.Save(user);
        }

        private string GetOperatorCode(string phone)
        {
            //...
        }

        private string GetAreaCode(string phone)
        {
            //...
        }
    }

审视点2:核心业务逻辑清晰度

经过改造后的代码虽然优雅了一些,我们不妨思考一下它是否真的”纯粹“。

public class RegistrationController : Controller
{
    private SalesrepRepRepository salesrepRep;  
    public User Rigister(string name, PhoneNumber phone) 
    {
        //获取手机号归属地编号和运营商编号,然后通过编号找到区域内的salesrep
        string areaCode = GetAreaCode(phone);
        string operatorCode = GetOperatorCode(phone);
        Salesrep rep = salesrepRep.FindRep(areaCode, operatorCode);

        User user = new User();
        user.Name = name;
        user.Phone = phone;
        if (rep != null) 
        {
            user.SalesrepRep = rep.repId;
        }
        return salesrepRep.Save(user);
    }

    private string GetOperatorCode(string phone)
    {
        //...
    }

    private string GetAreaCode(string phone)
    {
        //...
    }
}

RegistrationController是用于对用户进行注册的服务那么它所承担的职责应该仅仅限定为”注册“。注册最本质的行为就是”拿到用户信息并保存起来“这样就能够保持业务逻辑的简介易读,而下面这段代码中存在两个行为一个是”获取手机号的归属地编码“,一个是”获取运营商编码“把他们放在”注册“这个业务域里其实并不合适,什么逻辑应该归属于那个业务域,这就是对”领域“的理解。就像如何对微服务进行边界限定一样,不同的理解角度会产生不同的领域模型划分,那回到代码获取”获取归属地信息“,”获取运营商信息“这些逻辑并不应该属于注册这个领域。那我们为什么要在Rigister方法里写这些逻辑呢?仅仅是为了适配FindRep这个接口来对原始的参数进行处理拼接,就像是拿胶水来进行缝缝补补这样的逻辑称为”胶水逻辑“。那么如何改造这些”胶水逻辑“才合理呢?

这里有两种思路:

  • 1.改造接口的入参,假设该方法的入参类型为PhoneNumber这在抽象商就是合理的,不必再Register方法内进行胶水操作了。

  • 2.那假如FindRep是个外部接口,我们没有办法去修改入参类型,怎么办呢?想”获取手机号的归属地编码“,”获取运营商编码“这两个行为都是获取手机号相关属性应该内聚在手机号这个类型中,这在抽象商也是合理的。因此PhoneNumber类应该进一步优化。

public class PhoneNumber
 {
        private string number;
        private readonly string pattern = "^0[1-9]{2,3}-?\\d{8}$";

        public string Number { get => number; private set => number = value; }

        public  PhoneNumber(string number) 
        {
            if (string.IsNullOrWhiteSpace(number))
            {
                throw new Exception("number不能为空");
            }
            else if(IsValidPhoneNumber(number))
            {
                throw new Exception("number格式错误");
            }

            Number = number;
        }

        private bool IsValidPhoneNumber(string number)
        {
            string pattern = "^0[1-9]{2,3}-?\\d{8}$";
            return Regex.Match(number, pattern).Success;
        }

        public string GetOperatorCode(string phone)
        {
            //...
            return "1";
        }

        public string GetAreaCode(string phone)
        {
            //...
            return "1";
        }
    }

优化完之后的注册方法内业务逻辑变得非常清晰,只留下了”注册“这个领域内最本质的两个操作获取用户信息并保存数据。

public class RegistrationController : Controller
    {
        private SalesrepRepRepository salesrepRep;

        public User Rigister(string name, PhoneNumber phone) 
        {
            Salesrep rep = salesrepRep.FindRep(phone.GetAreaCode(), phone.GetOperatorCode());

            User user = new User();
            user.Name = name;
            user.Phone = phone;
            if (rep != null) 
            {
                user.SalesrepRep = rep.repId;
            }
            return salesrepRep.Save(user);
        }
    }

这个例子比较简单即使”获取手机号的归属地编码“,”获取运营商编码“两个行为耦合在注册方法中也并没有对可维护性造成很大的破坏,但是当你遇到了实际复杂业务时,如果一个业务方法内包含各种胶水操作,胶水操作又以来其他胶水操作时那你可能就只能小心翼翼地的进行”屎上雕花“了。

审视点3:单元测试可行性

很多同学可能对写单元测试感到头疼,需要做到高覆盖率非常麻烦。不写不仅跑不过CI而且心里会有点慌,像上面的例子通过对PhoneNumber逻辑的内聚对业务方法内逻辑简化,写单元测试的效率就能够得到极大的提升。而且PhoneNumber这种类型的改动平吕会比较小一旦写了完善的测试用例,复用性会很高随着后面业务方法越来越多,业务方法内部的这个逻辑越来越复杂单元测试的维护成本只会越来越低。在传统的类中只包含属性和get set ,这里的PhoneNumber却包含了初始化、校验、属性处理等多种逻辑这其实就是DDD和传统MVC开发的重要差异点之一。只包含属性值属于贫血模型而PhoneNumber不仅拥有属性还包含了与其属性相关的职责是充血模型的一种,充血模型也有强弱程度之分。

我们在这里将PhoneNumber这种类型称为DP(Domain Primitive)就像int、string是所有编程语言的Primitive一样。

定义:在DDD里,DP可以说是一切模型、方法、架构的基础。它是在特定领域、拥有精准定义、可以自我验证、拥有行为的对象。可以是领域最小组成部分。

DP三原则:

  • 让隐性的概念显性化

  • 让隐性的上下文显性化

  • 封装多对象行为

我们通过下面这个例子来解释一下DP的三原则。

public class PhoneNumber
 {
        private string number;
        private readonly string pattern = "^0[1-9]{2,3}-?\\d{8}$";

        public string Number { get => number; private set => number = value; }

        public  PhoneNumber(string number) 
        {
            if (string.IsNullOrWhiteSpace(number))
            {
                throw new Exception("number不能为空");
            }
            else if(IsValidPhoneNumber(number))
            {
                throw new Exception("number格式错误");
            }

            Number = number;
        }

        private bool IsValidPhoneNumber(string number)
        {
            string pattern = "^0[1-9]{2,3}-?\\d{8}$";
            return Regex.Match(number, pattern).Success;
        }

        public string GetOperatorCode(string phone)
        {
            //...
            return "1";
        }

        public string GetAreaCode(string phone)
        {
            //...
            return "1";
        }
 }
  • 1.”让隐性的概念显性化“像归属地编号、运营商编号其实是属于电话号码这个事物的隐性属性,如果使用string类型来定义电话号码那么这个隐性属性就难以体现出来,上述案例中我们通过自定义类型PhoneNumber通过赋予它行为来显性化了这两个概念。

  • 2.”让隐性的上下文显性化“在上述案例中没有用到这条原则,所以没有体现出来那么我举个例子比如手机号其实不仅仅是一串数字,全世界手机号的区号分配都是根据国际电信连门(ITU)的E.1223和E.164标准所分配的。这是一套默认的协议,在不同的区号分配下就算是一串相同的数字他们代表的含义也是不一样的,这里区号协议就是手机号字符串隐性的上下文。如果我们的PhoneNumber类加上了这个属性比如说像这样:

public class PhoneNumber
    {
        private string number;
        private string protocol;
        //...
    }

那么就将隐性的上下文显性地表达出来了,如果真的有一天全世界区号协议换了那么我们的系统改动起来就会非常方便。

  • 3.”封装多对象行为“一个DP可以封装其他多个DP行为。

学习软件工程上的方法论不像算法题,会了就是会了。而工程设计往往让人处于好像会了但又没会的状态。

ref:https://www.bilibili.com/video/BV11q4y1q74f

你可能感兴趣的:(java,开发语言)