领域驱动设计与落地

好长时间没有写过总结,趁着周末无事,总结下关于DDD(领域驱动设计)落地的一些心得。

领域问题与解决方案

首先,让我们来认识两组名词:问题域和解答域、固有复杂度和额外复杂度。

问题域和解答域

所谓问题域,指的就是我们开发软件所要解决的问题。我们分析问题的时候,首先要忘掉其如何落地,如何实现,专心分析问题领域例面的逻辑关系。我通常喜欢将问题领域例面的名词罗列出来,然后将他们之间的依赖和相互作用用人类所能理解的语言描述出来。概念和概念之间的关系描述所使用的名词和动词,在领域驱动设计例面,被称之为领域通用语言。这样说有点抽象,我举个例子,如果我们要写一个软件,描述一个猫吃鱼的过程,我们通过这句话,能分析出这个需求中所涉及到的领域名词:猫、鱼,以及它们之间的关系——吃,更进一步能确定出谁是被吃的一方。当需求人员与开发人员交流的时候,一提到猫,就知道是这个软件中发起吃这个动作的角色,明确了这点之后,所有领域概念之间的关系就明晰了,我们开发出来的软件就不会偏离需求太远了。

解答域实际上就是用来实现问题域描述问题的手段。比如我们用Java,用Go,用Python都可以实现猫吃鱼的需求,但是用Java解决问题的方式和用Go解决问题的方式可能不太一样,这些都是解答域关心的范畴。

固有复杂度和额外复杂度

这两个名词是怎么回事呢?额外复杂度是相对固有复杂度来说的,指的是我们的技术在解决问题的过程中引入的一些复杂度。比如我们的需求是注册一个用户,但是我们在实现的时候,需要将这个用户的信息放在数据库中,以求得用户注册信息能够持久化。实时上,使用数据库并不是我们需求的一部分,如果我们的内存能够持久的保存数据,我们就不需要引入数据库来解决这个问题了;如果我们把这个用户的信息写在账本里,那么我们就是使用了另一种技术,同样,这种技术也会带来额外的一些复杂度,比如管理和查找账本。这就是技术选型带来的额外复杂度。而固有复杂度,就是我们要解决的问题固有的复杂度,一般我们称之为“业务逻辑”。

之所以引出这两组概念,想分别从不同角度描述领域问题与软件实现之间的关系。众所周知,面对相同的问题,采用不同的技术方案,解决成本也不尽相同。所以,我这里想和大家一起讨论下如何能降低解决问题所花费的成本。

需求

一个朋友曾经向我提出过一个问题:在一个系统中,一个账户下面有两个子账户,收款账户和付款账户。用户只能从付款账户里面付钱,也只能使用收款账户收钱,每一次转账,都要记录流水。如何使用DDD的方式描述这个场景?

面向对象的设计

在我看来,DDD算是一种面向对象设计思想更接近落地的一种方案,所以,让我们先从最纯粹的面相对象的角度来分析这个问题。

  • 收款账户和付款账户都是一种账户,虽然在这个场景下它们都行为有所不同,但是从外部看来,它们只是泛化账户都一种实现
  • 转账的操作发生在收款账户和付款账户之间,账户本身只能有扣款和充值操作,控制转账的这个流程的角色显然不能是账户
  • 记录流水本身也不应该是账户的职责,同样,也不应该算转账流程的一部分

所以我们有了下面的设计

组件调用时序图

如上所示,转账服务调用账户的增加和减少账户余额的方法,在账户更新余额的方法校验通过后,更新了自己的余额,同时发出了账户余额变更的事件,对余额事件变更有兴趣的AccountEventLogger,监听到事件之后,打印了事件。

实现代码如下(Java 版)


abstract class Account {

    List<AccountEventListener> listeners;

    /**
     * 账户id
     */
    String accountId;

    /**
     * 账户余额
     */
    Float amount;

    /**
     * 取款
     *
     * @param amount
     */
    abstract void withdraw(Float amount);

    /**
     * 存款
     *
     * @param amount
     */
    abstract void deposite(Float amount);
}

class DrawingAccount extends Account {

    @Override
    void withdraw(Float amount) {
        if (this.amount - amount > 0) {
            this.amount -= amount;
            this.listeners.forEach(accountEventListener ->
                    accountEventListener.handle(new AccountEvent(this.accountId, "取款", amount)));
        }
    }

    @Override
    void deposite(Float amount) {
        throw new IllegalArgumentException("不支持的操作");
    }
}

class DepositingAccount extends Account {

    @Override
    void withdraw(Float amount) {
        throw new IllegalArgumentException("不支持的操作");

    }

    @Override
    void deposite(Float amount) {
        this.amount += amount;
        this.listeners.forEach(accountEventListener ->
                accountEventListener.handle(new AccountEvent(this.accountId, "存款", amount)));
    }
}

public class UserService {
    static void transfer(DepositingAccount depositingAccount, DrawingAccount drawingAccount, Float amount) {
        depositingAccount.deposite(amount);
        drawingAccount.withdraw(amount);
    }
}

interface AccountEventListener {
    void handle(AccountEvent accountEvent);
}

class AccountEvent {
    String accountId;
    String eventType;
    Float amount;
    public AccountEvent(String accountId, String eventType, Float amount) {
        this.accountId = accountId;
        this.eventType = eventType;
        this.amount = amount;
    }
}


public static void main(String...args){
    val listener= (accountEvent)-> System.out.println(accountEvent);
    val listeners=Arrays.asList(listener);
    val drawingAccount=new DrawingAccount("123456",50.0,listeners);
    val depositingAccount=new DepositingAccount("123456",50.0,listeners);
    UserService.transfer(depositingAccount,drawingAccount,10.0);
}

DDD与Spring

上面的例子虽然只是一个简易版的DEMO,但是我想说的是,把这个DEMO改造成其实没有你想象的那么复杂。在 展示如何将它改造成企业级应用之前,我想和大家讨论下企业级日常开发的软件架构的问题。

软件结构划分

我们一般将后台分成controller,service,dao,domain四个层次

  • controller 负责处理web请求
  • service 负责业务逻辑处理
  • dao 负责与持久层的数据库打交道
  • domain 领域实体

处理流程如下图

web请求处理流程

controller接收外部传过来的实体(也可能是dto),然后将实体交给对应的service 进行处理,service 对domain进行处理后交给dao层进行持久化;有时候在分布式系统中,controller层可能会抽象成facade层,也可能叫api层:

服务调用处理流程

因为controller和facade/api的相似性,我们常常把它们统称api层,是我们业务单元和外部程序之间沟通的桥梁。api层本身没有逻辑,只是将外部传过来的数据对service层进行了一层适配,也可以称之为适配层。所以在整体看来,业务子域应该是下面的结构:

849051-20170909215810960-1659673125.png-112.6kB
849051-20170909215810960-1659673125.png-112.6kB

上图来自于《实现领域驱动设计》。仔细观察这张图,我们会发现我们的领域模型不仅在和外部系统打交道的时候需要适配,在和数据库打交道的时候也需要适配。换言之,我们从外部结果观察到的领域模型,可能并不是这个模型真正的样子。

DDD的魅力正式在于此,我们的领域服务不应该和某一种“视图”绑定。

有人说,DDD在落地过程中不需要数据库,这样说有些极端,但是不是没有道理。一旦开始了数据库设计,很可能为了数据存储的便利性,牺牲了领域模型的表达能力。

为了尽可能保留领域模型的表达能力,我们应当最低限度的降低架构带来的复杂度,换句话说,中间件对开发者应该是透明的。许多语言提供元编程的能力,保证在编写业务代码时并不需要开发者关心底层技术实现,只需要关注业务实现就好了,在Java语言中,因为有注解和Aspectj,也能帮我们实现一定程度的元编程。

public abstract class Account {

  @AutoWired
  private  EventBus eventBus;
  
  @AutoWired
  private AccountDao accountDao;

    /**
     * 账户id
     */
   private String accountId;

    /**
     * 账户余额
     */
   private Float amount;

    /**
     * 取款
     *
     * @param amount
     */
   public abstract void withdraw(Float amount);

    /**
     * 存款
     *
     * @param amount
     */
  public  abstract void deposite(Float amount);
}

@Configurable
public class DrawingAccount extends Account {

    @Override
    @Transcational
    void withdraw(Float amount) {
        if (this.amount - amount > 0) {
            this.amount -= amount;
            accountDao.update(this)
            eventBus.publish(new AccountEvent(this.accountId, "取款", amount)));
        }
    }

    @Override
    void deposite(Float amount) {
        throw new IllegalArgumentException("不支持的操作");
    }
}

@Configurable
public class DepositingAccount extends Account {

    @Override
   public void withdraw(Float amount) {
        throw new IllegalArgumentException("不支持的操作");

    }

    @Override
    @Transcational
    public void deposite(Float amount) {
        this.amount += amount;
        accountDao.update(this)
        eventBus.publish(new AccountEvent(this.accountId, "存款", amount)));
    }
}

@Service
public class UserService {
    
    @Transcational
    public void transfer(DepositingAccount depositingAccount, DrawingAccount drawingAccount, Float amount) {
        depositingAccount.deposite(amount);
        drawingAccount.withdraw(amount);
    }
}

interface AccountEventListener {
    void handle(AccountEvent accountEvent);
}

public class AccountEvent {
    String accountId;
    String eventType;
    Float amount;
    public AccountEvent(String accountId, String eventType, Float amount) {
        this.accountId = accountId;
        this.eventType = eventType;
        this.amount = amount;
    }
}

请看,最开始的代码瞬间从Demo转变成能用于生产的业务逻辑了。
我们做的只是增加了持久层的接口AccountDao,并且将AccountEventListener的引用替换成了封装了一层的EventBus。可能很多人并没有使用过@Configurable这个注解,使用这个注解需要结合AspectJ的Load-Time-Weaving功能,通俗点,就是在类加载的时候Spring会帮我们将我们的依赖注入给对象。有点像scope=prototype的bean,但不需要从容器中获取,在使用new操作符的时候,就已经将依赖注入进来了。

感谢IOC容器Spring,帮我们隐藏了架构的复杂性,是我们能更专注于业务逻辑。

DDD虽然给我们提供了一套建模原则,但是它并没有绑定具体的实现技术,也没有给出实现生态的建议,给落地实现造成了困扰。我在网上的翻了很多资料,读了很多相关的书,比如《函数响应式领域建模》《Akka应用模式:分布式应用程序设计实践指南》,给出的实现方式大多数是Demo级别的,没有持久层的实现,没有展示层的实现。在企业级开发中,Spring基本上已经成了事实上的标准,所以在Spring生态中实现DDD,也算是强强联合吧。

其实不论是基于AKKA还是基于Spring,DDD落地最复杂的地方有两点:一,领域对象序列化的问题,因为领域对象和数据库视图,展示视图有可能并不匹配,所以互相转换的问题相对于其他方式开发尤其突出;二,是基础组件的装配问题,当我们需要使用数据源的时候,如果没有一个自动装配的技术,领域逻辑将被淹没在各种set/get方法中。

在使用load-time-weaving解决装配问题之后,我们可以使用dozer等映射工具轻松实现数据转换,DDD落地的问题基本上就解决了。

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 219,635评论 6 508
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 93,628评论 3 396
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 165,971评论 0 356
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 58,986评论 1 295
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 68,006评论 6 394
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 51,784评论 1 307
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 40,475评论 3 420
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 39,364评论 0 276
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 45,860评论 1 317
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 38,008评论 3 338
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 40,152评论 1 351
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 35,829评论 5 346
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 41,490评论 3 331
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 32,035评论 0 22
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 33,156评论 1 272
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 48,428评论 3 373
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 45,127评论 2 356

推荐阅读更多精彩内容