领域建模应对软件复杂性初体验

前言

在互联网各个大小厂维护过业务系统的程序员们,应该都抱怨过编写得很烂的业务代码,大部分业务系统的代码都是过程式的面条,前人挖坑后人填坑,几乎是每迭代一次就能埋下一个坑,需求只要实现了就行,完全不追求设计美感。我们每天都在使用的各大APP或网站,我丝毫不怀疑,它们后台系统,一定充斥着可读性差毫无设计美感与OO思想的代码,这些过程式的面条代码,耗费了程序员的主要精力。博主常常和同事开玩笑,如果公司各类业务系统的代码全部开源,股价至少跌一半。

面向过程的风格——数据与方法分离

常见的面向过程风格的代码就是数据定义在一个类中,方法定义在另一个类中。你可能会觉得,这么明显的面向过程风格的代码,谁会这么写呢?实际上,如果你是基于MVC三层结构做Web方面的后端开发,这样的代码你可能天天都在写。

传统的MVC结构分为Model 层、Controller层、View层。不过,在做前后端分离之后,三层结构在后端开发中会稍微有些调整,被分为Controller层、Service层、Repository层。Controller层负责暴露接口给前端调用,Service层负责核心业务逻辑,Repository层负责数据读写。而在每一层中,我们又会定义相应的VO(View Object)、DTO(Data Transfer Object)、Entity。一般情况下,VO、DTO、Entity中只会定义数据,不会定义方法,所有操作这些数据的业务逻辑都定义在对应的Controller类、Service类、Repository类中,这就是典型的面向过程的编程风格。这种开发模式叫作基于贫血模型的开发模式,也是现在非常常用的一种Web项目的开发模式。

DDD的思想

传统的MVC等模型只关心数据,这些数据对象除了简单setter/getter方法外,没有任何业务方法

以银行账户Account为案例,Account有存款和取款等业务行为,但是传统经典的方式是将存款和取款行为放在账号的服务AccountService中,而不是放在Account对象本身之中,但现实中的存款和取款是一个Account自身自带的行为,并不需要单独开一个什么服务才能让Account完成存款和取款。在这里,现实世界中真实的业务模式被技术框架给绑架了,你看到的代码与真实的业务模式并不是一致的。

DDD的宗旨是,领域模型必须准确反映业务语言,DDD采用OO思想,在写的过程中要求针对业务对类进行职责分明的封装,并且需要将不同类的领域边界划分清楚,一个类体现的是"业务的高度浓缩",也就是一个类既具备能力(行为)也具备属性(数据)

DDD初实践

博主从事了几年电商及互联网金融的业务系统开发,近些年参与了一款类似淘金币的活动积分系统的开发与维护,该积分是金本位的,其定位是充当B端商家向C端消费者投放的权益,用于拉新及促消费,消费者通过参加商家在平台上投放的互动型任务,完成任务即可获取奖品,奖品就是一定数额的积分,消费者完成任务后,系统就将积分从商家账户“转账”到消费者账户,平台会在商家账户的每一笔支出操作中收取一定比例的“渠道服务费”,“渠道服务费”以积分的形式从商家账户划到平台账户中,不同级别的商家议价能力不同,费率也各不相同。C端消费者账户里的积分可在消费时抵扣,也可向平台兑换一些券或礼品,此时系统将积分从消费者账户“转账”到平台账户。业务规则限定商家账户之间不能相互转账,对于消费者账户的支出,平台不收取任何“渠道服务费”。

以上业务背景中的积分户就与银行账户很像了,只不过这里提到的积分类似Q币一样的代币,不是电子人民币,只能在限定的平台或场景下使用。下面以该积分系统中积分从B端商家账户发放给C端消费者的业务处理为例,来演示一下使用传统贫血模型和使用领域模式的区别。

贫血模型的实现

积分账户模型PointAccount是贫血模型,只有简单的getter/setter方法,是对数据的封装。

// 积分户模型
public class PointAccount {
    private String id;
    private String type;
    private String status;
    private double amount;
    // ... get/set方法
}

以上积分户模型中只有数据,没有行为,真正处理积分转移的业务逻辑都被写在了PointSendService的实现里。

public interface PointSendService {
    public boolean send(String fromId, String toId, String platformId, double amount);
}

public class PointSendServiceImpl implements PointSendService {

    private PointAccountDao pointAccountDao;

    private TransactionTemplate transactionTemplate;

    private TransactionLogDao transactionLogDao;

    @Override
    public boolean send(String fromId, String toId, String platformId, double amount) {
        PointAccount merchantAccount = pointAccountDao.find(fromId);
        PointAccount consumerAccount = pointAccountDao.find(toId);
        PointAccount platformAccount = pointAccountDao.find(platformId);

        double newAmount = 0.0, chargeAmount = 0.0;
        // SKA商户是举足轻重的大品牌,议价能力强,费率4%,每向消费者发放100个积分,平台收取4个,实际支出104个
        if ("SuperKeyMerchant".equals(merchantAccount.getType())) {
            chargeAmount = amount * 0.04;
            newAmount = merchantAccount.getAmount() - amount - chargeAmount;
        }
         // KA商户议价能力强,费率6%
        else if ("KeyMerchant".equals(merchantAccount.getType())) {
            chargeAmount = amount * 0.06;
            newAmount = merchantAccount.getAmount() - amount - chargeAmount;
        } 
        // 占据大多数的长尾商户,费率8%
        else if ("LongTailMerchant".equals(merchantAccount.getType())) {
            chargeAmount = amount * 0.08;
            newAmount = merchantAccount.getAmount() - amount - chargeAmount;
        }
        if (newAmount < 0) {
            throw new UnsupportedOperationException("...");
        }
        merchantAccount.setAmount(newAmount);
        consumerAccount.setAmount(consumerAccount.getAmount() + amount);
        platformAccount.setAmount(platformAccount.getAmount() + chargeAmount);
        TransactionLog transactionLog = new TransactionLog(merchantId, consumerId, amount);
        return transactionTemplate.execute(action -> {
            pointAccountDao.update(merchantAccount);
            pointAccountDao.update(consumerAccount);
            pointAccountDao.update(platformAccount);
            transactionLogDao.insert(transactionLog);
            return true;
        });
    }
}

上面的代码完全是面向过程的代码风格,大部分程序员接到需求后稍作设计就直接开始写了,写出来也通常就是这类“通俗易懂”的代码。代码中有一段if-else分支逻辑,平台扣费时将商户作了分层,不同类型的商家积分户在支出积分时收取不同费率的“渠道服务费”,这样写并不优雅。同样的业务逻辑,接下来就让我们看一下用DDD是怎么做的。

领域模型的实现

DDD的宗旨是,领域模型必须准确反映业务语言,领域模型中是业务逻辑的封装,这里的业务逻辑就包括行为和数据。基于DDD思想设计出来的积分户领域模型如下,除了账户的一些基本属性外,也包括deposit/credit/getChargeAmount等方法。类比上面提到的银行账户,存款和取款是一个账户自身自带的行为,并不需要单独开一个什么服务才能让账户拥有存款和取款的能力。

// 积分账户领域模型
public class PointAccount {
    private String id;
    private String type;
    private String status;
    private double amount;
    // 渠道扣费计算服务
    private ChannelChargeService channelChargeService;
    
    public PointAccount(String id, String type, double amount) {
        this.id = id;
        this.type = type;
        this.status = "NORMAL";
        this.amount = amount;
        // 如果使用DI框架,这里可以写成配置项来做注入,以下仅作演示
        this.channelChargeService = ChannelChargeStrategyFactory.getChannelChargeStrategy(type);
    }
	// 收取
    void deposit(double amount) {
        this.amount = this.amount + amount;
    }

    void credit(double amount) {
        // 积分支出时,要一并扣除渠道扣费
        double newAmount = this.amount - amount - this.getChargeAmount(amount);
        if (newAmount < 0) {
            throw new UnsupportedOperationException("...");
        }
        this.amount = newAmount;
    }
	// 计算渠道扣费
    double getChargeAmount(double amount) {
        return this.channelChargeService.getChargeAmount(amount);
    }
}

平台收取的“渠道服务费”是在积分户的每一笔支出时,都要计算的,虽然只有商家账户在支出时才会被收取“服务费”,但这是业务定下的规则,计算渠道扣费的行为依然属于积分户这个领域模型,扣费策略作为积分户的属性注入到积分户模型中。多种不同的渠道扣费策略可以采用策略模式 + 工厂模式来抽象。

// 渠道扣费服务
public interface ChannelChargeService {
    double getChargeAmount(double amount);
    String getAccountType();
}
// 长尾商户的渠道扣费策略,费率8%
public class LongTailMerchantChannelChargeService implements ChannelChargeService {

    @Override
    public double getChargeAmount(double amount) {
        return amount * 0.08;
    }
    @Override
    public String getAccountType() {
        return "LongTailMerchant";
    }
}

// KA商户的渠道扣费策略,费率6%
public class KeyMerchantChannelChargeService implements ChannelChargeService {

    @Override
    public double getChargeAmount(double amount) {
        return amount * 0.06;
    }
    @Override
    public String getAccountType() {
        return "KeyMerchant";
    }
}
// SKA商户的渠道扣费策略,费率4%
public class SuperKeyMerchantChannelChargeService implements ChannelChargeService {

    @Override
    public double getChargeAmount(double amount) {
        return amount * 0.04;
    }
    @Override
    public String getAccountType() {
        return "SuperKeyMerchant";
    }
}

// 消费者的渠道扣费策略,费率0
public class ConsumerChannelChargeService implements ChannelChargeService {

    @Override
    public double getChargeAmount(double amount) {
        return 0.0;
    }
    @Override
    public String getAccountType() {
        return "Consumer";
    }
}

// 渠道扣费策略工厂类
// 如果使用Spring等管理bean的DI框架
// 可以基于ApplicationContext来实现工厂类
public class ChannelChargeStrategyFactory {
	// 我们用Map来缓存策略,根据type直接从Map中获取对应的策略
	// 从而避免if-else分支判断逻辑
    private Map<String, ChannelChargeService> channelChargeStrategyMap = Maps.newHashMap();
	// 为了演示,这里用静态代码块来生成扣费策略服务
    static { 
        channelChargeStrategyMap.put("Consumer", new ConsumerChannelChargeService()); 
        channelChargeStrategyMap.put("SuperKeyMerchant", new SuperKeyMerchantChannelChargeService()); 
        channelChargeStrategyMap.put("KeyMerchant", new KeyMerchantChannelChargeService());
        channelChargeStrategyMap.put("LongTail", new LongTailMerchantChannelChargeService());
    } 
	// 为了演示,用静态方法从工厂中取扣费策略
    public static ChannelChargeService getChannelChargeService(String accountType) { 
        return channelChargeStrategyMap.get(accountType); 
    }
}

积分在多个积分户上的转移则抽象成领域服务来实现,由于领域模型是充血模型,领域服务只需要引用各个Domain Entity,简单地使用实体自身的行为就可以完成业务逻辑——业务逻辑就是多个实体行为的组合

public class PointSendDomainServiceImpl implements PointSendService {
    
    private PointAccountRepository pointAccountRepository;

    private TransactionTemplate transactionTemplate;

    private TransactionLogRepository transactionLogRepository;

    @Override
    public boolean boolean send(String fromId, String toId, String platformId, double amount) {
        PointAccount merchantAccount = pointAccountRepository.find(fromId);
        PointAccount consumerAccount = pointAccountRepository.find(toId);
        PointAccount platformAccount = pointAccountRepository.find(platformId);

        merchantAccount.credit(amount);
        consumerAccount.deposit(amount);
        platformAccount.deposit(merchantAccount.getChargeAmount(amount));

        TransactionLog transactionLog = new TransactionLog(merchantId, consumerId, amount);
        return transactionTemplate.execute(action -> {
            pointAccountRepository.save(merchantAccount);
            pointAccountRepository.save(consumerAccount);
            pointAccountRepository.save(platformAccount);
            transactionLogRepository.save(transactionLog);
            return true;
        });
    }
}

通过上面的DDD重构后,原来集中在PointSendService中的逻辑,被分散到Domain Service和Domain Entity对象中,而ChannelChargeService虽然不是实体,但它的实现也是符合面向对象的七大基本原则的。

领域建模的好处

DDD最大的好处是:接触到需求第一步就是考虑领域模型,而不是将其切割成数据和行为,然后数据用数据库实现数据部分,使用接口服务实现行为部分,最后造成需求的首肢分离。DDD的宗旨是先考虑业务语义,而不是数据。DDD强调业务抽象和面向对象编程,而不是过程式业务逻辑实现,DDD将业务语义显性化,显性化就是将隐式的业务逻辑从一推if-else里面抽取出来,用通用语言去命名、去写代码、去扩展,让其变成显示概念,比如“渠道扣费策略”这个重要的业务概念,按照过程式接口服务的写法,各种扣费逻辑杂糅在积分转移的逻辑中没有突显出来,增加了代码复杂度,而领域模型的实现中将其用策略模式抽象出来,不仅提高了代码的可读性,可扩展性也好了很多。

如何定义领域服务

有一些领域动作,我们无法将它们划归到某个实体或者值对象中,它们不属于任何一个Domain Entity而是需要关联多个Domain Entity,这样的领域行为就很适合从领域中被抽出来声明成一个领域服务。例如前面积分发放的例子,pointSend这个行为发生在两个账号之间,归属于PointAccount并不合适,一个PointAccount没有必要去关联其他的PointAccount,抽象出PointSendDomainService来处理多个Entity之间的关联行为,再适合不过。

识别领域服务,主要看它是否满足以下三个特征:

  1. 这个行为代表了一个领域概念,这个领域概念无法自然地隶属于一个实体或者值对象
  2. 被执行的操作涉及到领域中的多个对象,不能归属于领域内某一个实体
  3. 操作是无状态的

如果将原本从属于单个Entity的行为全部抽象成领域服务(例如上面例子中最初的那个PointSendServiceImpl),那么领域模型就会自动退化成贫血模型,代码也就逐渐转化为过程式的编程,那DDD就没有任何意义了。在抽象领域服务时,既不能勉强将行为放到不符合对象定义的对象中,破坏对象的内聚性,也不能不加思考的把行为全部塞到领域服务中,从而退化成面向过程的编程。

聚合根

聚合根(Aggregate)把一组有相同生命周期、在业务上不可分割的实体和值对象放在一起考虑,只有根实体可以对外暴露引用,也是一种内聚性的表现。只有Aggregate的根Entity才能直接通过数据库查询获取,其他对象要通过关联来发现。外部对象不能引用除根Entity之外的任何内部对象。同时Aggregate内部的对象可以保持对其他Aggregate根的引用。

以电商业务为例,Account(账户,有买家/卖家之分)是Customer(客户信息)Entity、Contact(联系方式,值对象)和Address(地址,值对象)的聚合根。TradeOrder(交易订单)是PayOrder(支付单)Entity、ItemOrder(商品订单)Entity、FulfilmentOrder(履约单)Entity的聚合根,因为支付单、商品订单和履约单都是因为交易才产生的,后三类订单Model中要标记自己从属于哪一个交易订单,它们的生命周期与交易订单强关联,支付订单完结时,交易订单可能只是状态发生了变化,而商品订单和履约单此时可能还只是初始状态,当履约单终结时(例如买家确认收货且没有发生退换货),交易订单才完结。交易显然是发生在两个Account(买家与卖家)之间的,TradeOrder作为聚合根,可以保持对参与本次交易的两个Account的引用。

领域层是需要不断迭代的

在刚刚接触DDD时,一个比较常见的错误是没有为行为找到一个适当的对象,就直接抽象成领域服务或下沉到Domain Model中,程序员总是手里拿着锤子看谁都像钉子。哪些能力应该放在Domain层,这不是一个能立刻回答的问题,而是需要在实践不能思考迭代。在现实业务中,很多的功能是用例特有的,如果盲目地将它们收拢到Domain层并不能带来多大的益处,相反还会造成Domain层的膨胀,影响复用性和业务语义的表达。

因此,我们应用采用持续迭代 + 能力下沉的策略,即不能强求一次性设计出完备的Domain层,也不用把所有业务功能都放到Domain层,而是只对那些经实践证明能在多个场景中被复用的能力作下沉,而不能被复用的,暂时放在App层。这种循序渐进的能力下沉策略更加符合实际也更敏捷,前提是我们承认领域模型不可能一蹴而就,而是迭代演进出来的

能力下沉的过程如下图所示,假设两个use case,它们的step3与step1有类似的能力,就可以考虑将其下沉到Domain层,增加代码复用性。指导能力下沉的关键指标是复用性与内聚性。一旦出现重复代码,我们就需要考虑将其封装,将其内聚到恰当的实体上或者下沉到合适的层级上(Domain Service还是Domain Model)。

比如,在以上积分系统的账户域,在积分发放时,经常需要判断一个积分户是否已经被冻结,如果账户被冻结,那么账户中的积分就不能发放起到保护资产的作用,这种基本能力就可以直接下沉到Domain Model层。

public class PointAccount {
    private String id;
    private String type;
    private String status;
    private double amount;
    // 渠道扣费计算服务
    private ChannelChargeService channelChargeService;

    // 判断账户是否已被冻结
    public boolean isFrozen() {
        return "FROZEN".equals(status);
    }
}
    

DDD不是银弹

DDD不是银弹,到底是使用过程式的开发风格还是使用领域模型没有对错之分,关键看是否合适。

我个人也是反对过度设计的,因此对于简单且要求快速迭代的业务场景或者成员不稳定的技术团队,我建议还是使用MVC思想,其优点是足够简单、直观和易上手。虽然从Controller层到Service层到Repository层一捅到底非常简单粗暴,但业务逻辑与依赖关系一目了然。

但对于高度复杂的业务场景,再这么玩就不行了,因为一旦业务变得复杂,MVC+事务的方案就很难应对,容易造成代码的“一锅粥”,系统的腐化速度和复杂性呈指数级上升。目前比较有效的治理办法就是领域建模,因为领域模型是面向对象的,在封装业务逻辑的同时,提升了对象的内聚性和重用性,因为使用了通用语言,使得隐藏的业务逻辑得到显性化表达,使得复杂性治理成为可能。

总结

DDD的“水”很深,所有的一切,都建立在领域的划分上,如何保证项目组所有人都能理解这个领域划分并且各司其职?你今天的团队成员都理解并达成一致了,如何保证明天来的新员工也能理解?如果最开始作的领域划分的不正确呢?万一有什么关系到业务生死攸关的需求要紧急上线,开发测试一律要越快越好,写出了打破领域规范的代码,后面怎么补救?

在大型的复杂业务系统中,实践DDD对程序员的要求非常高,程序员必须对业务要有很深的理解,任何对业务理解不够的地方都有可能造成边界的划分不明确。而对于一些追求快速迭代本身又很复杂的互联网业务项目,同一个项目组中不同角色的同学对需求的理解经常千差万别,沟通时词汇不统一,失焦以及各说各话的情况比比皆是,如果还面对着开发团队不稳定人员频繁流动的问题,那么想要实践DDD更是异常艰难

其实,Github上比较受欢迎的Java项目几乎没有一个是使用DDD进行开发的,都是朴实无华的标准MVC思想,代码基本都是是简单的逻辑堆砌,风格上行为与数据分离,或许这就是当前大型工程类项目的最有效实践方式吧。

  • 0
    点赞
  • 2
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值