DDD与数据事务脚本

  • 2022 年 2 月 27 日
  • 筆記

DDD与数据事务脚本

扯淡

相信点进来看这篇文章的同学,大部分是因为标题里面的“DDD”所吸引!DDD并不是一个新技术,如果你百度一下它的历史就会知道,实际上它诞生于2004年, 到现在已经18年,完全是个“古董”,软件开发技术日新月异,DDD确显得很独特,一直不温不火,也未淘汰。有些人为了使用DDD“苦思冥想”、有些人对它保持敬畏,觉得是一种高端的技术,当然也有人觉得这玩意垃圾根本没用。废话不多说. 下面我尝试使用一个最基本的业务场景来讨论下ddd和事务脚本。

假如我们的现在需要做这么一个系统,名字叫做“消息发送系统”。 系统里面存在很多用户,而我们需要做的,就是给指定的用户发送消息

  1. 用户能在收件箱中看到收到的消息,消息有已读,未读状态,消息内容不能为空。
  2. 通过发送用户id、消息内容,就可以发送到用户。注意这里并没有发件人的概念。

数据事务脚本

需求很简单,现在我们开始设计这个功能。相信你只需要学习过一些基本的编程技术,你第一直觉一定会整理出下面这个数据结构


    message{
        id , //消息id
        userId ,//接受用户id
        content ,//消息内容
        state ,//消息状态已读,未读
        ...
    }

当设计出这个数据结构的时候,我们心里面其实认为这个系统的“核心”就完成了, 这TM是真简单啊! 为什么会是这样?我们都知道,计算机最核心的组件是cpu,而cpu就是用来计算处理用户输入的数据,并将结果反馈给用户。而软件运行在计算机上,所能提供的功能实际上也是这样处理数据。当然了,计算机还有磁盘,磁盘可以存储用户输入cpu的数据,也能存储cpu处理完后的数据!按这个逻辑,一个软件(系统)实际上就是在将特定业务场景下的数据输入计算机,计算机处理完后存储这些数据。软件只需要准确的记录下计算过程中产生的所有数据,并正确的存储算据即可实现对应的功能。

软件的不同功能,只是以合适的方式创建、修改、展示数据。上面的message结构,准确的找出了我们“消息发送系统”里面产生的数据。 发送消息,只不过就是新增了这样一个消息数据, 而用户看消息,只是找到所有message中userid为自己的id的消息,显示出来就可以了。这个“消息发送系统”非常简单,其实大的系统也是一样,大系统由很多这样小的系统组成,本质确是一样的。

我们已经找出这个系统会产生的数据结构,当然还需要保存起来,假设将它存入数据库里面(如果你愿意,你也可以存到文件里,或者干脆只放到内存里),那么我们在数据库里面就新建一张message的表,字段就是id,userId,content,state。假设现在有个页面,您可以在这个页面中输入id,userid,content,state,点击提交按钮,软件取到这些值,就写入数据库。伪代码大概是这样:

    var id = input('id');
    var userId = input('userId');
    var content = input('content');
    if(userId == ""){
        err "userid不能为空"
    }
    if(content == ''){
        err "内容不能为空";
    }

    //发送消息
    state = input('state');
    database.exeSql("insert into message values(id,userid,content,state)");

    //查看消息
    messages = database.exeSql("select * from message where userid=userid");

这个时候“大神”告诉你,这个太low了,逻辑和操作数据库全部写一起了, 一点都不面向对象,一个类都没有,你应该封装一个message类。我们现在有orm框架,能直接将对象存到数据库,这个时候你经过不断的学习,你写出了下面的代码:

    id = input('id');
    userId = input('userId');
    content = input('content');
    if(userId == ""){
        err "userid不能为空"
    }
    if(content == ''){
        err "内容不能为空";
    }

    state = input('state');

    //发送消息
    message = new message(id,userid,content,state);
    orm.save(message);

    //查看消息
    messages= orm.getMessageByUserId(userid);

简单到爆,一切都这么自然,很完美这样你的消息系统就完成了。 当然了, “大神”还会告诉你,或者你自己意识到下面这些“硬核知识点”

  1. 将上面message这种跟数据库表对应的对象,称之为PO(Persistent Object)
  2. 将存储message到数据库,或者从数据库中通过相应的条件查找、更新、删除、message对象的功能,封装到一起,叫做dao对象
  3. 前端页面里不同的功能点,最好别直接调用DAO操作存储数据,因为还有一些业务逻辑代码需要编写,为了封装性,需要将不同的功能点封装到不同的类里面,我们叫做sevice类(可能很多同学直接一个数据表对应一个po,对应一个dao,对应一个service),比方说messageServcie
class messageServcie{

    message[] getMessageBuUserId();//通过用户id,获取它收到的消息
    readMessageById(messageId);//将message设置为已读

}

写了这么多,相信大家也看出来了,这就是所谓《数据事务脚本》开发方式的来源。 这种开发方式很直接也很简单。如果你问我有什么问题? 我觉得没什么问题,挺好的。我们以数据为中心,软件只是在处理数据,存储数据而已。

接下来我们说DDD

由于我们所使用的编程语言java、C#等,都是面向对象编程的,也很认可面向对象编程的好处!而数据事务脚本开发方式,核心是数据,虽然里面也有po对象,但是po对象却没有任何行为,这让很多人觉得有点尴尬! 而ddd确说我们的领域对象是有相应的行为的,这也是很多人喜欢ddd的理由。

我们知道对象包括属性和它的行为。而软件,从面向对象的角度,就是在业务范围内设计不同的对象,然后通过对象与对象之间的行为调用,改变对象的状态,从而实现不同的功能,想想我们的现实世界,确实是由一个一个的物体组成的。 汽车是不同的零部件组成,人由不同的器官组成,这也是为什么说面向对象更加容易理解,更加流行的原因。 下面还是以消息发送系统为例。

通过阅读上面“消息发送系统的”需求, 应该很容易发现消息, 用户这些名词。 暂且不管对不对, 先将这些写成类

 class message{
    id , //消息id
    userId ,//接受用户id
    content ,//消息内容
    state//消息状态已读,未读

    message(id,userId,content){ 
        if(userId == ""){
            err "这消息不行"
        }

        if(content == ""){
            err "这消息不行"
        }
        state = "未读";
        this.id=id;
        ....
    }

    ///修改消息状态
    setState(state){
        if(this.state == "已读" && state=="未读"){
            err "已读的消息,不能设置成未读"
        }
        this.state = state;
    }
}

class user{
    id,
    userid,
    messages[],//收件箱中的消息
    addMessage(message){
        message.userid= this.userid;
        this.messages.add(message);
    },

    //将消息设置为已读
    setMessageisReaded(message){
        message.setState("已读");
    }
}


class userService{
    //发送消息
    sendMessage(id,userid,content,state){
        var message = new message(id,userid,content,state);
        var messageBox = findUserMessageBox(userid);//找到收件箱对象
        messageBox.add(message);
    }

    //获取用户收件箱中的消息
    getMessageByUserId(userId){
        var user = findUserByUserId(userid);
        return user.messages;
    }

    //将消息设置为已读
    setMessageisReaded(userId,message){
        var user = findUserByUserId(userid);
        user.setMessageisReaded(message);
    }
}

大家看了上面的代码觉得怎么样? 是不是感觉也很自然, 对象有自己的属性和对象。 这个时候相信有些看到这里的同学, 已经有疑问了“你这是搞着玩吧,数据都不存到数据库的?”。上面说过从面向对象编程的角度,软件就是对象与对象的交互,交互完后对象状态会改变, 比如下说上面的发送消息代码,我们可以理解为:新创建了一个message对象, 通过userid找到用户,然后将message放到用户收到的消息列表中。假如我们的内存是无限的,而且不会丢失,我们还需要存数据吗? 现实当然内存不无限,也会丢失,那么是不是把这个message对象和user对象通过某种方式保存到磁盘,需要的时候取出来就可以了? 存储方式很多比如你直接json序列化写到文件,写到MongoDB,或者存到关系型数据库。 但是这里与数据事务脚本概念已经不一样,数据事务脚本存的是软件运行产生的结构数据, 而这里存的是对象,这一点一定要理解。 所谓的repository就是用来存取对象,dao却是用来存结构化数据,概念有很大的不同!!!

如果你看过一些ddd的文章,你一定知道ddd里面有很多名词:统一语言、事件风暴、限界上下文、领域、子域、支撑域、聚合、聚合根、实体、值对象。 初学者一看,尼玛吓死人这么多的东西! 然后就是一脸懵逼。 实际上面这些词是可以分类的,有些是告诉你怎么和客户沟通,了解需求,有些是告诉你怎么划分不同子系统,有些是告诉你怎么找到设计对象,有些实际上只是给有特征的对象起个名, 有些是给对象与对象的关系起个名。

对于一般开发着来说,找到对象、设计对象才是我们最关心的,因为对象是开发者写代码的基础!至于跟客户沟通、了解需求、划分不同的子系统、对象关系命名这种东西暂且放放! ddd之所以不普及,不是因为需求没沟通好,子系统没划分好,而是因为不知道怎么设计对象,就不知道应该怎么写代码。 对象是一个抽象的东西。有些系统里的对象,是有实物对应的,比如说购物车,我们就很清楚知道有什么属性和行为。而有些没实物对应,就不知道怎么找到对象,也不知道对象应该具有什么行为。一个简单的功能,每个开发者都有不同的理解与抽象,出现不同的设计方案,而且大家都认为自己的合理的。 园子里有很多高手写的ddd文章,如果只是解释介绍上面的名词,一般讨论不起来,只有点赞、叫好的份。因为上面这些名词解释可以说是死的, 大家能看懂就行, 但是如果出现一些ddd设计案例, 就会出现一些不同的设计方案来讨论。而最终当然也是不了了之! 上面我写的消息发送系统代码,初学者一看觉得写的挺好,但是ddd高手一定有不认同的地方!

对象的行为如何划分

对于ddd 对象设计来说,最纠结的是行为应该放到哪个对象? 举个烂大街的例子“图书管理系统”中,读者借书的逻辑,应该设计到哪个对象。一种常见的设计是这样:

    ///读者
    class reader{

        id,

        ///名字,地址,余额,借书记录
        其他属性...

        ///借书
        borrow(book){
             //判断能不能借这本书的一系列逻辑...
            book.setState('已借');
        }

    }

    //书(这里没有定义书数量,假设书都是一本一本)
    class Book{
        id,
        title,
        state,//已借出,未借出
        setState(state){
            if(this.state == "已借" && state == "已借"){
               err "不能再借了";
            }
        }
    }


    class readerService{

        borrow(readerId,bookId){
            book = getBookById(bookId);
            reader = getReaderById(readerId);
            reader.borrow(book);
        }
    }

从代码上能看出借书逻辑写到了reader(读者)对象上,“读者借书”所以借书应该就写到读者上,很符合现实和直觉。但是这里我们需要再仔细思考下。 现实世界中对于读者去图书馆借书,实际上是这样的,读者向图书馆申请借书,图书馆查看图书是否可以借给这个读者(可能这本书已经借出,也有可能这个用户不能借书…),如果能,就将书借给这个读者,然后记录一下,借书业务就完成了。如果你觉得这个描述是对的,那么借书的逻辑还应该放到reader这个对象上吗?显然不是,借书的主要逻辑应该图书馆,或者是图书管理员(取决于业务复杂程度),读者只是一个驱动者!上代码。

    ///读者
    class reader{
        id
        ///名字,地址,余额,借书记录
        其他属性...
    }

    //书
    class Book{
        id,
        title,
        state,//已借出,未借出
        setState(state){
            if(this.state == "已借" && state == "已借"){
               err "不能再借了";
            }
        }
    }


    class BookLibrary{

        borrow(readerId,bookId){
            reader = getReaderById(readerId);//找到这个读者
            book = getBookById(bookId);//找到这本数

            //检查这个读者能不能借这本书...

            book.setState("已借");//将书设置为已借
        }
    }

看上去差不都,实际上也差不多!呵呵… 但是借书这个逻辑不再放到了读者对象上,而是放到了BookLibrary上面。

再举些例子:

  1. “银行账号转账”, 转账逻辑显然也不是账号(account)上面的,账号的行为的主要是这2个:充值(钱变多)和消费(钱变少)。 至于转账应该是“银行”的功能!!!
  2. “论坛用户发帖”, 发帖逻辑也不应该是用户对象上,而应该是论坛本身!
  3. “用户购物”,购物逻辑也不应该是用户对象上的,这是购物软件本身通过操作账号钱包对象,物品,等等一系列对象达到购物这个目的。

这种例子很多,其实这里最主要是。 不能将业务逻辑简单的放到驱动者身上,而是要深入分析一个功能点具体是怎么实现的,是有哪些对象一起交互才完成的功能。 如果全部放到驱动者身上,最终会导致好像所有的功能都应该写到”用户”这个对象上,因为是用户在使用驱动软件。用户通过操作软件界面上的功能点,驱动软件运行。 其实往往出现在软件界面上面的功能点,都应该写到这个软件本身对象(BookLibrary)这个对象上。软件本身再操作不同的领域对象,实现功能!!! 这也就是ddd中所谓application services的来源,将不同类型的功能通过services概念在封装到一起,形成不同的***Service,取名叫做领域服务对象。那有人可能又会说,这样下去是不是所有的业务都到了service里面,其实不是,其他对象有自己的业务逻辑,service需要操作不同的领域对象,实现不同的业务, 比方说:银行转账中,账户充值消费就是账号这个行为的,这里面就应该对账号是不是已经被冻结,余额是否足够消费,进行业务处理。

其实还有一种简单方法来设计对象,面向对象编程一开始就告诉了我们:对象=属性+行为,通过行为修改属性来到达改变对象状态实现不同的功能。也就是说行为跟对象属性是有关联性的,这也是面向对象中所谓的”内聚”。

  1. 如果一个行为跟这个对象上的属性没有任何关联,这个行为放到这个对象上就是不合适的,
  2. 如果一个行为需要好几个对象,而把功能放到这好几个对象中其中一个对象上也是不合理的。可以通过service的方式实现,还有一种方式就是将这个行为独立抽象出一个对象(这取决与业务需要),而这个对象拥有其他需要的对象,比分说银行转账

class transfer{

    //执行转账
    execute(fromAccout,toAccount,amount){

        if(this.check(fromAccout,toAccount)){
            fromAccout.sub(amount);
            fromAccout.add(amount);
        }
        err '不能转';

    }

    //检查是否能转
    check(fromAccout,toAccount){
        //一系列的检查
    }

} 

对于借书这个场景来说,一个借书流程需要,读者对象,书对象,以及一些借书规则,这些信息,这里面任何一个对象都不具备所有信息,比如说,读者对象并不知道读书对象现在是什么状态,也不知道借书有哪些规则。 所以将借书逻辑放到读者对象上是不合适的。

相信对于找出对象的属性一般没问题,所以可以通过先找出对象的属性,然后再通过属性找出它的行为!!!

restfull资源与对象的不匹配

ddd之所以这段时间变得火,主要是因为微服务。 大家都说使用ddd能给微服务带来多少好处(可能最大的好处是能分出不同的子系统(微服务)), 我这里说一个它的坏处。
按照ddd的思路,最终出来的是一个一个的对象。 而rest出来的api是一个一个的资源。ddd每个对象是有不同行为的,而rest中却规定,对于资源的操作应该是统一的(post,delete,get,put)。 对象中不同的行为,应该怎么在rest中表达? 从这个角度来说,ddd与restfull天然需要做一次适配,而ddd与远程对象调用更加合拍(ejb,wcf等)
而如果是传统的数据事务脚本,数据很容易对应到rest中的资源,对于数据的操作无非就是(增,删,查,改),也容易对应到(post,delele,get,put)。

总结

写了这么多,从数据事务脚本的由来,到ddd,再写到怎么设计对象。总的来说就下面几点!

  1. 数据库事务脚本:service–>dao这种方式也挺好,并不是不行!!甚至跟rest配合起来更加“直接”。这也是为什么现在大部分还是数据事务脚本方式的原因(代码中有repository,有domian可不一定是ddd哦)
  2. 在ddd对象设计中不能将业务逻辑简单放到驱动者身上,应该仔细思考,到底发生了什么,不能为了充血而充血! 可以通过先找对象属性,然后再找关联的业务行为。 但是前面也说了,每个人可能抽象出不同的对象,如果对象符合行为和属性内聚这个特性,建议一定要有自信别乱想(这也是很多人纠结的地方)…
  3. ddd与restfull并不是完美匹配的。

当然这只是抛砖引玉,实际上ddd是一套完整的软件开发流程,并不只是设计对象而已, 在实际开发中也还有很多的问题需要思考,比如存储对象,应该放到service中吗? 发送邮件,发送短信这些功能点,写到领域对象,还是service里面,类似的问题很多!! 这里不讨论,这需要大家的开发经验实践,或者去查看一下大神的最佳实践。 就这样了,希望对大家有帮助,谢谢阅读!