从软件复杂度的角度去理解DDD

Posted 轻风博客

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了从软件复杂度的角度去理解DDD相关的知识,希望对你有一定的参考价值。

从我们作为业务开发主要的职责深入到DDD的本质是什么?复杂度应处理?规范设计怎么做?本文将全方位为大家解答。

从我们作为业务开发主要的职责深入到DDD的本质是什么?复杂度应处理?规范设计怎么做?本文将全方位为大家解答。

一、作为业务开发,我们的主要的职责是什么的

 

业务开发的职责

在文章的开始我想和大家一起思考一个问题:作为一个工程开发,我们最主要的职责是什么? 
我极度认可 <<浅谈什么是技术一号位>>文章的观点 - 切实解决业务问题才是每一个工程开发最主要的职责 - 所以每个业务开发都必须要结合业务的视角去思考自己系统的建设和发展,而不是只是做一个“编程的”码农。
这里摘录一下文章中要点
  • 技术一号位是负责使用技术能力解决业务问题,提供稳定可靠的技术支撑;
  • 负责向业务各方提供各种必要的技术支撑,通过合理的数据分析为业务决策提供依据;
  • 通过对技术领域的积累和发展,通过业务领域的理解和落地影响业务决策;
  • 负责构建梯队完整、能力全面、制度完善的技术团队来支撑业务发展。
文中也提到了虽然不是每个人都负责一块完成的业务,也不是每个人都带领团队,但是至少每个人都是自己所负责的那块系统的技术一号位。

 

业务在实际开展中遇到的问题

那实际业务开展中,业务到底会遇到有哪些问题呢?我们按业务的生命周期进行切分,然后具体查看每个业务生命周期的诉求:
  • 业务启动期:业务能力快速搭建 - 系统提供快速试错的能力
  • 业务发展期:业务能力扩展 - 系统需要支持原来越多的业务功能
  • 业务平台期:业务能力复制 - 系统需要支持原来越多的业务场景
  • 业务衰退期:业务能力创新 - 系统提高生产力延长业务的生命周期

我们技术要做的事情是:在业务验证没有问题的情况下,如果尽可能的延长业务的发展和平台期,让业务获取的利益最大化。所以为了支持业务的发展,业务的本身的功能支持诉求以及业务对技术的要求也会越来多,在这种情况下考验软件开发人员的一个非常关键的能力就是: 软件复杂度的控制的能力

 

软件复杂度

软件复杂度其实是一种多维度的概念,其可能来源于多个方面,前阿里资深技术专家李运华在他的《从0开始学架构的》课程中从6个方面阐述了软件复杂度【2】,列举如下:
  • 高性能
    • 单机性能
    • 集群性能
  • 高可用

    • 计算高可用
    • 存储高可用
  • 可扩展性

  • 低成本
  • 安全
  • 规模
    • 业务规模
    • 系统物理规模

二、DDD的本质是什么

DDD本质上我认为就是一种减低软件复杂度的手段, 其推荐的方法论可以适用于上面包括了业务规模,可扩展性两个维度的复杂度应对。其实业务规模的复杂度的处理包括了对可扩展性的支持。
DDD实施给系统之后,我们依然需要关注系统其它的复杂度,这里列举一些示例措施:
  • 容量规划
  • 架构设计
  • 数据库设计
  • 缓存设计
  • 框架选型
  • 发布方案
  • 数据迁移、同步方案
  • 分库分表方案
  • 回滚方案
  • 高并发解决方案
  • 一致性选型
  • 性能压测方案
  • 监控报警方案
那么我们进一步对业务规模的复杂度进行拆解,又分为下面两类:
1、领域复杂度
  • 领域模型描述问题域的准确性

2、技术实现的复杂性

代码没有按照业务绑定的”分析模型”去编码,软件变成一个大泥潭
  • 软件的可扩展性较差
  • 软件变成面向过程
  • 分层不合理
  • 没有规范

那DDD是如何处理上面提到的软件复杂度的?

  • 提供了一个领域划分的方法:让软件系统产生边界。
  • 提供一个一系列的战略模式:限界上下文的映射,分层架构等。
  • 提供一个一系列的战术模式:如何规划领域层 内部

DDD不是什么?

  • 不光光只是一种编程方法
  • 不光光只是一种架构风格
  • 不具体指导如何具体建模

三、复杂度处理-领域模型描述问题域的准确性

DDD的原名是模型驱动的设计方法:通过领域模型(Domain Model)捕捉领域知识,使用领域模型构造更易维护的软件。

 

合理性证明

DDD的核心思想,大家都清楚,就是分析模型要和代码模型保持一致。 
那么如果不保持一致到底会产生什么样的负面影响
如果技术实现和业务实现不在用一水平线上,那技术模型的行进路线只会考虑劈开技术障碍并且可能会撞在未来的业务障碍的墙上。这样就很容易出现,业务持续演进等技术想实现的时候,却发现当前的实现依赖于“业务不会这样发展”的假设上。这也是为什么会出现现在众多业务需求,技术无法实现或者是需要花大量时间去实现的原因。
但是如果技术和业务通过统一语言打破知识的壁垒保持一致,那么如果后面技术遇到问题即是业务碰到的问题,业务人员需求的变更和迭代会自然而然的帮助技术同学越过一些门槛。也就是说业务方与技术方参与到对方的工作中,就在双方之间带来了更好的协同,形成1+1>2的功效。

 

什么是问题域

根据百度百科的解释【3】 在软件工程中,问题域是指待开发系统的应用领域,即在客观世界中由该系统处理的业务范围
那么问题域内的组成是什么呢?就是我们的域模型。 
这里直接摘抄一段前阿里P10"阿白"在阿里内部发表的域模型的观点:
域模型(domain model)英文又称为问题域模型(problem space model)。维基百科(Wikipedia)对它的定义是” A conceptual model of all the topics related to a specific problem” 可以翻译成:“域模型是针对某个特定问题的所有相关方面的抽象模型”。这个定义有几个要点:第一是“特定问题”, 也即是说域模型是针对性某个问题域而言的, 脱离的这个特定问题,域模型的构建其实不存在一个最优或者是最合理的构建。第二是抽象, 域模型是一个抽象模型, 不是对某个问题的各个相关方面的一个映射, 也不是解决方案的构建。 

 

如何实现问题域的分析

在 DDD 中,Eric Evans 提倡出一种叫做知识消化(Knowledge Crunching)的方法帮助我们去提炼领域模型。简单来说就是五个步骤:
  • 关联模型与软件实现;
  • 基于模型提取统一语言;
  • 开发富含知识的模型;
  • 精炼模型;
  • 头脑风暴与试验。

开发人员和业务专家在一起通过一个个业务用例仔细讨论应用程序的应用场景,从而使得业务人员深刻理解业务知识,开发人员和业务人员就重要的业务概念建立起统一的语言,开发人员将这些概念根据业务用例的上下文抽象出模型,并且这些模型将会最终成为最终软件实现中的领域模型。随后随着更多的业务用例的输入,开发人员和业务人员会逐渐对已经构建的模型进行精化,并且也会用新的用例去检验之前构建模型的合法性和适用性。
DDD在这一步其实没有给出详实标准的如何建模的方法,毕竟建模还是来自于每个人的世界观,其过程还是倾向于经验的。但是还是有不少人总结一些标准的建模方法论例如:

1 四色原型法  

http://apframework.com/2020/03/22/ddd-color/

2 用例分析法 

https://baike.baidu.com/item/%E7%94%A8%E4%BE%8B%E5%88%86%E6%9E%90/2859078?fr=aladdin

 

问题域的拆分

大家应该发现上面的知识消化的流程是一个非常耗时和复杂耗脑力的过程, 涉及到产品,业务,技术等多方团队, 所以为了让有限的资源投入到最最核心的子域,我们需要对问题域进行这份,把重点的精力放到最核心的领域上。
核心领域一定是业务价值最高的,而非技术难度最高或者是基础设施框架部分。 
要切分问题域,首先需要了解问题域的种类:

1 通用域: 非应用独有的,多个应用都会有的功能。例如发送邮件,触达等

2 核心域:和竞争对手区别开来的区域,或者是在市场上被赋予了竞争优势的区域。

3 支撑子域:其余的区域
如何确定核心域,这里有几个提示:
  • 系统哪部分最难用
  • 手动处理过程阻止了他们进行了根据创造性, 有附加值的工作
  • 哪些修改能提高收益
  • 哪些修改能提高运营效率

取哪些提示,取决于业务系统的性质。

那如何决定支撑子域/通用子域,以及支撑子域/通用子域的切分呢?
目前在我查阅的资料中,还暂时没有人提及到具体的操作方法,感觉主要还是依靠经验主义在做划分。我个人总结了一个方法,主要就是就是关注业务的核心实体和核心流程。以核心实体和核心流程作为切分支撑子域的基础。
核心实体:核心实体是存在于核心流程中,对核心流程的决策和扭转可能起到关键的作用。有的时候业务上为了能让核心实体在业务流程中起到更大或者更高效的作用,会添加一些让核心实体更好服务于业务流程一些业务功能,从而使业务实体从整体上看变得相对复杂,这个时候我们应该以核心实体为基础进行切割,把所有和核心实体CRUD相关的操作还有让其变得更高效的业务功能划分为单独的一个领域。
核心流程: 当某个业务流程足够复杂也可以当成一个子域。
在实现领域驱动设计【4】书中,提到了为在线拍卖网站系统划分问题域的一个例子,我们以此来验证上面等构想
划分子域
  • 卖家 + 会员身份:这两者都是核心实体,网站可能为了让促进会员能够多参与拍卖可能提供了分层,或者积分等工功能。网站为了能让卖家能够更加提供更加有拍卖价值或者是转化率高的品类可能为卖家提供了数据分析等业务功能。 
  • 名册:这也就是核心实体,网站会对名册提供一系列拍卖相关的功能,例如倒计时,一口价等,所以也需要形成一个领域。 
  • 拍卖:网站最核心的业务流程,核心域无疑。
  • 争议解决:买卖家的售后冲突解决流程向来很复杂,所以会独立成为一个域无疑。

四、复杂度处理-进一步降低问题域的复杂度-限界上下文

 

限界上下文的诞生背景

一般情况下,一个复杂系统由一系列的模型来表示解答域, 理想状态是一个子域一个模型。但是有些当业务需要且系统复杂的时候,一个模型可能被多个域共享,这个时候这个模型的概念可能变得不清楚。因此为了保护这些模型概念的完整性, 清晰的定义模型的责任边界很重要。
实现领域驱动设计【4】书中举了下面这个例子:
为了维护模型的概念的完整性,最直观的方法就是为这个模型化一个边界,e.g. 这个商品所表现的意思就是履约的时候用到的"商品",而不是下单的时候的"商品"。只要有一个这样的边界定义,系统就会但是出现多个边界,毕竟"商品"在不同业务上下文中有不同的含义, 例如库存域的货品,物流域的运输品, 价格域的商品等等。这样的一个边界就是DDD的“限界上下文”。 
限界上下文给人直观的感受其实和子域很像,我很早以前曾读过一些关于微服务的书籍,也提到过要把DDD中的限界上下文作为微服务划分的重要依据。这里其实就给我很大的疑惑:
1 限界上下文到底是怎么划分的?我们划分限界上下文难道真的是用一个基础概念,然后找这个基础概念不同的“上下文”吗?
2 限界上下文和子域到底区别是啥?

 

限界上下文的本质

DDD理论中提到了DDD的四个边界 
所以在DDD中是把限界上下文作为某个子域的内部模块的划分,其实无论是子域的划分,限界上下文的识别,和聚合的划分他们的本质是一样的,他们都是对复杂问题的分解之后,然后归类分组。只不过“聚合”面向的是领域层内部,“领域”划分面向的是业务问题域,而“限界上下文”面向的是解答域,但是我跟倾向于把限界上下文理解为更加深一层次的业务问题域的划分,而不是面向的解答域。 
如果这样看的话,那么其实就可以回答上面的疑问, 领域和限界上下文没有本质的区别,就像树的父节点和字节点一样都是树节点。而限界上下文的划分完全可以使用子域划分的理论。(可以回顾下上面问题域拆分的段落)

 

上下文映射

上下文的映射是什么, 简单来说就是描述不同上下文之间的关系的描述。举个例子
DDD对于限界上下文直接提炼了几种方式,这里这边阿里内部文章《领域驱动设计:软件复杂性应对之道》解释的比较好,描述如下:
shared kernel
共享内核 shared kernel :通常是共享核心领域或者是一组通用子领域。

customer/supplier

客户/供应商关系 customer/supplier:上下游关系。不同客户需要协商来平衡,上游团队需要有自动测试套件。

conformist

跟随者模式 conformist:单方面跟随模式。上游的设计质量较好,容易兼容,可以采用严格遵循上游团队的模型。

anticorruption layer

防腐层 anticorruption layer:防腐层、隔离层,使用 facade or adapter 等模式。可以减少其它系统变动对本系统的影响。

separate way

各行其道 separate way:声明一个与其它上下文毫无关联的 bounded context,使开发人员能够在这个小范围内找到简单、专用的解决方案。

open host service

开放主机服务 open host service:开放子系统供其他系统访问。其核心思想是开放出一个标准的各个领域都认可的协议,减轻各个领域实施ACL的负担和成本。

published language

共享语言 published language:把一个良好文档化、能够表达领域信息的共享语言作为公共的通信媒介,必要时在其它信息与该语言之间进行转换。
在当前电商领域的范畴,目前我个人觉得只有ACL,Seperate Way, publish language 有比较好可行性,其他的关系都不是很靠谱:
  • shared kernel:如果使用共享二方库,谁来维护这个二方库,如何防止在不同上下文使用不同kernal版本所带来的问题。 
如果一定能保证shared kernel的维护在一个团队内,且所有使用shared kernel版本一定能保持一致, 那是可以使用的。 
  • customer/supplier:我曾经因为汇率包升级而去重构一个应用,因为汇率包变更太大,且应用没有防腐层,所以不论从开发还是测试都是非常痛苦的过程。
  • conformist:和customer/supplier类似, 但是在互联网领域没有靠谱的设计, 只有有人维护和没有人维护的设计。conformist从长期来看其实就是customer/supplier。
  • Open Host Service 没有任何一个领域保证自己的接口一定不会变,就算不会,其他领域的同学会相信吗,他们会忍住不用ACL吗?如果他们用ACL,OHS的意义何在?
  • publish language 目前阿里内部MTOP,TOP等协议正是使用这样的协议。

另外限界上下文之间真的能够随便无规则无条件的互相依赖,互相调用吗?在下面的章节将会解释论述。

五、复杂度处理 - 分层不合理

架构分层主要的作用就是关注点隔离,如果和今天的话题联系起来就是领域模型和技术的关注点隔离(领域和存储,领域和展示)。

 

传统的三层架构

这种传统架构的缺点
1、业务逻辑层和数据访问层有明显的耦合。 
2、没有领域的概念,所有的逻辑沉淀到service中。
所以传统架构只能针对小型的,没有过多的业务逻辑场景。由于这种架构能够保有领域能力的沉淀,所以在现在电商业务场景基本不会被使用。

 

六边形架构

Alistair Cockburn在 2005 年时演示了 六边形架构
从六边形架构开始,其强调了领域模型。并且确立了领域模型的核心位置,以及其不应该依赖于其他的层次。六边形架构也强化了适配器的概念,其还把适配器分类为input适配器,和output适配器。所有input适配器用于对接不用的外部请求形式, 所有output适配器用于对外部的依赖 (e.g. 数据库, 外部服务,内存调用等)
这种结构模式树立了以领域模型为核心的先河,但是其忽略了在领域层中跨模型业务逻辑的实现方式-领域服务的沉淀。这也间接导致了其需要强化应用层,并且通过应用层和output适配器的联合去完成一些可以应该在领域层应该完成的事情。 

 

洋葱架构

洋葱架构的提出更加进化了一步,推出了域服务层,并且支持域服务层是支持了那些需要多个领域实体联合中作用的领域逻辑. 其层次由外向内依次是领域模型,领域服务,应用服务和外层的基础设施和用户终端。其依赖的关系也只能是由外向内. 在洋葱结构中其把存储层,文件系统和网络服务放到了基础设施层。由于基础设施和用户终端一样在最外层,所以洋葱架构也提倡用依赖倒置来解决应用逻辑和基础设施的耦合问题。
洋葱架构的架构图从其依赖顺序上来看,其依赖应用层必须先依赖域服务层,再依赖域模型层, 这样很容易造成领域模型的逻辑外泄到领域服务层,造成领域模型变成贫血模型。

 

DDD 架构

 

DDD的架构大家都非常熟悉了,领域服务和领域模型都归属域领域层。适配层依赖域应用层,应用层依赖域领域层,也可以直接直接调用基础设施层(大多数是查询场景)。领域层理论上不依赖于任何层次,其通过依赖倒置和基础设施层产生关联。在DDD架构中,应用层是可以通过直接访问聚合根(某个实体类),并进行方法的执行和操作的。应用层也可以直接访问基础设施层。可以看出DDD的架构其实更加的贴切实际一些。 
上面这张图也很好的阐述了DDD各个架构层次依赖的关系。

 

CQRS

 

很多情况产品构建出来的数据展示,需要横跨几个领域的数据的支撑,也就是我们日常构建的大宽表,在这种情况使用CQRS模式可以完美解决这个问题。其主导视图模型和领域模型分开,让领域模型更加专注业务逻辑,流程和规则而非业务视图。 
CQRS的思想很简单,就是把服务中对数据的更新操作(Command)和读取操作(Query)分离, 一部分逻辑只处理和数据更新有关的业务,另外一部分只处理和数据读取有关的逻辑。这种处理方式,可以让我们辛苦构建的领域模型不被业务中所需要的这类视图需求所干扰。 
CQRS 的两种实现方式
基于event- sourcing
不基于event - sourcing
以上的图片摘自于文章《CQRS模式及其应用》

 

我们团队里面的架构实践

 

在我们自己的应用中我们构建了基于COLA【5】规范的层次架构,如下图: 
我们也对自己的架构定义的一些额外的规范:

1、依赖关系(除了依赖倒置)只能是从上当下;

2、同层之间永远不能互相依赖;

3、如果同层之间需要互相用到对方的服务,那么就需要下沉出一层。例如在上图中,我们的业务层就分为了两层 "Executor"层次和 “Handler”层此, Handler层次用来保存业务的一些通用逻辑。

六、复杂度 - 软件变成一个大泥潭

从这一章节开始介绍DDD的"战术模式",也就是向大家介绍DDD是如何构建和组织自己领域层的。值得一提的是在DDD中,领域的划分, 领域层次的建立, 领域之间关系的建立我们一般叫做DDD的"战略模式",而此章节提到的值对象,实体,域服务,工厂,repository, 聚合/聚合根, 领域事件等都是DDD的战术模式。战略模式的重要性是要远大于DDD的战术模式的,我们如果在领域划分,领域通信协议,分层方面没有大的问题, 那么即使再糟糕系统整体也还是可控的。 
在领域层面, DDD通过聚合/聚合根的概念来划分单个领域中的类似于类集合的边界,从而降低单个领域层的复杂度。DDD通过实体,值对象,领域服务,repository, factory 来规划集合内部的类组织, 另外DDD也通过领域事件来处理领域之间的交互,来匹配异步和需要解耦的业务场景。

 

实体

 

当我们需要考虑一个对象的个性特征,获取需要区分对象的时候,就需要引入实体。一般我们发现实体概念,是在和业务产品人员或者领域专家讨论发现的那些需要有唯一标示性或者生命周期连续性很重要的时候。
举个例子加入用户需要预定酒店,如果领域专家说了我们定了A酒店了,就不能定B酒店了,哪怕A,B其他的属性完全一样。从领域专家扣中我们可以识别出酒店是有唯一标示性的,且哪怕A,B属性一样,也不能认为A,B 是一样的,这也说明了酒店的唯一性不是从属性来的。这两点我们可以推断酒店是一个实体。唯一标示性可以是现实有意义的,例如工商注册号,也可以无现实意义,例如数据库主键。 

实体建模的注意点

1、为实体分配唯一标识符
  • 现实意义标识符
  • 人工生成的标识
    • 自增
    • guid/uuid/
    • 数据库主键
    • 自定义sequence

2、验证和不变行

实体必须自己负责自己保持自己状态的合法性 (validation) 和不变性(Invariants)。他们的区别是合法性是根据上下文的,而不变性是不用考虑上下文且必须正确的。例如酒店必须有房间这个就是不变性,而酒店的营业时间就是validation. 一般使用规则和规约模式来实现validation和invariants。

规模模式: 

https://baijiahao.baidu.com/s?id=1717403406288752234&wfr=spider&for=pc
3、聚焦在行为,而不是属性状态
不要暴露属性给外面,如果外面得到属性,很可能就自己实现了一些领域逻辑,那么领域逻辑就外漏了。 
4、把一些行为逻辑下方到值对象中
需要警惕实体逻辑膨胀,从而混绕了实体所要表达的概念。 
例如预定是一个实体, 现在要加上逻辑预定的天数不能小于N天。这个时候我们可以为Booking 抽象出 Stay 对象,让Stay对象去管理规则逻辑。而不是让预定这个实体去做。让预定只关注预定。 
5、不要为世界建模
不要过度设计,只要满足需求就好。不要让技术需求污染领域设计,除非真的万不得已。
6、分布式的设计
不需要让领域概念横跨多个bounded context, 如果我们域模型所涉及的概念横跨了,我们就需要用两种设计方法.
  1. 只是用id引用
  2. value objects

 

值对象

 

什么时候需要使用到值对象?

  • 概念需要凸显的时候。 
e.g. 拍卖系统的能够一口价获取拍卖的价格, 就算是我们用一个int 就能表示也需要用类来凸显概念
public class WinningBid
...
public int Price get; private set;
...
  • 表述一个描述性的,但是没有实体编号的概念的时候。 

值对象的特征

  • 无标识:他们只是标识对象的属性。
  • 基于属性的相等性: 所有的属性值相等即值对象相等
  • 富含行为:值对象实现业务概念的抽象,其也有自己的行为
  • 内聚:将不同的相关属性组成一个概念整体,例如Money, 是由一个long 和一个currency组成的
  • 不变性:值对象是不变的对象,如果需要改变属性,那最好是建立一个新的对象并且进行值对象替换。如果一定是需要改变,那就需要考虑设置为值对象是否合理。
不变性是值对象非常重要的一个属性,是可以保障值对象不会被"坏味道"代码侵入的一个原则之一。 例如如果一个值对象引入了另外一个类实例, 另外一个值对象也引入了相同的类实例, 如果值对象允许改动,当一个值对象对这个类实例的内容进行修改,势必会影响另外一个值对象。 所以最安全的方式还是通过对象替换的方式。

 

域服务 

 

什么时候用域服务

发现和多个实体相关联,但是放入任何一个单独的实体都不适合,这个适合用域服务.

域服务应该包含什么内容

域服务应该包含业务/系统流程和业务规则,不应该包含技术的元素在内,技术的元素都应该在业务服务(Application Service)中实现。

应用服务与领域服务的区别:

一些可以在网上搜索到的老生常谈:
  • 应用服务里不要处理业务逻辑,只在领域服务里处理业务逻辑。(如何判断某段逻辑是否是业务逻辑?)
  • 领域服务掌握领域知识,而应用服务只是对领域服务的编排。
  • 应用服务是领域服务的客户方,也就是说应用服务会调用领域服务里的方法。
  • 当领域中的某个操作过程不属于实体或者值对象的职责时,需要将个操作放在领域服务中。而且确保领域服务是无状态的(这句话很有意思,也就是说领域服务中不应该有任何记录状态的行为,在任何情况下调用这个服务,它都不会有副作用,也就是说它是个纯内存操作)。
  • 领域服务中包含的是业务逻辑,而应用服务关注的应该是安全和事务等非业务逻辑。
  • 对事务的管理绝对不能放在领域服务层,事务管理需要放在应用服务层。因为和领域模型相关的操作的粒度都很细,无法用于事务管理。而且领域模型也不应该意识到事务的存在。
  • 通常的可以放在应用服务中的逻辑有:参数验证、错误处理、监控日志、事务处理、认证与授权。
除了第一条之外,上面的条例只是举出了应用服务与领域服务两者非常易于告之的差别,但是会有一些“业务逻辑”比较难以取舍, 例如
例如转账操作:

A ->B

A.accountDecrease(10);

B.accountIncrease(10);
我们在现在可以非常肯定的说上面的转账一定在领域服务,因为"转账"就是一个领域概念。但是如果假设世面上所有的银行以前只有存钱和取钱两种功能,"转账"是一个新概念和业务的时候,技术就没有那么容易判别转账是一个临时性的一次性的需求,还是会长久发展。这个时候技术有两个选择:
1、构建转账域服务,让应用服务调用域服务
2、让应用服务获取A,B的实体,然后在应用层直接调用方法,在应用层做事务保持一致性。
如果域能力在其他团队手里,我相信大多数的团队会使用第二种。那遇到这种情况我们到底应该怎么办?我个人的意见和阿里前技术高级专家张建飞在他的文章《一文教会你如何写复杂业务代码》的观点保持一致:

我们在新逻辑出现难以判断的时候优先讲能力放入到app层,如果我们发现会有第二个业务场景使用到了相同的能力,就需要考虑是否应该把此能力下发到领域层以增强内聚性和复用性。

 

工厂

 

  • 只负责复杂逻辑对象的构建,让构建逻辑中心化
  • 减少外部对象对构建对象内部变量的理解
  • 工厂方式不是在任何构建对象的时候使用,一定用在对象构建逻辑复杂,有子依赖或者是有invariant规则的场景。 

 

Repository

 

reponsitory主要用来处理集合根的存储和获取的。提供一个facade接口且是面向domain层的,是domain model和data model的桥梁。其最大的作用就是通过反向依赖的方式充分隔离数据层和领域层。Repository最常见的用法是被applicaiton service层去使用获取聚合根。
在repository实现中,我们一般会有下面的一些逻辑:
  • uniqe ID 的生成
  • 数据库的操作
  • 数据模型到领域模型的相互
  • 横跨多个数据模型构建出一个实体模型。

repository的反模式

  • 定义出比较通用化的接口, e.g.
List<Customer> findBy(CusomterQuery query)
  • 使用了延迟加载,延迟加载就是设计错误的标志, 有可能说明我们聚合的边界不催。 
  • 不要为了报表的诉求使用reponsitory, 领域的case和业务报告很不一样,可能需要多个聚合的数据,这种情况可以考虑用一个离线的store去做,和其他的读服务去做,不见得一定需要用领域的Repository模式。

 

领域事件

 

领域事件所想要解决的问题其实和metaQ消息机制想要解决的问题一致,都是跨领域驱动型业务逻辑实现的最佳方法,让领域和领域之前解耦。
领域事件消费教科书的说法是可以在领域层, 也可以在应用服务层, 但是我觉得领域消息如果用metaQ是这样的消息中间件去实现的话,那用领域层和应用服务层去消费就不是很方便,有可能破坏一些分层原则。所以我个人倾向于在adapter层去承接消费,统一化掉。
当前可以实现领域事件和消费比较方便的工具有:
  • google guava - EventBus
  • COLA框架 - 事件支持

 

聚合/聚合根

 

聚合是什么?

其实聚合的原理和领域划分,限界上下文划分的原理是一致的,都是为了通过归类分组的方式让整个系统宏观上 N * N 的关系复杂度减低为 T * T 的复杂度。 
T远小于N。 
聚合前: 
聚合后

如何划分聚合

1、根据业务规则和不变量来决定。例如 customer 聚合是有一套业务规则来维持的,例如信用卡要存在,必须先有一个customer, 有customer 必须有address, address必须有code.
2、强关联的对象应该放在一块。什么是强关联,那就是必须生命周期是一样的。例如customer和creditcard在,电商网站中,如果customer被删除了,那么他的信用卡也应该被设置为失效的。而订单和客户不一样,客户下了个订单,然后客户注销,但是订单还是一直存在的。所以customer 和 creditcard 在一个聚合中, 而用户和订单则不在。订单里面可以有客户的ID或者是一个值对象。 
3、灵活设置:有些可以根据业务情况,进行可以灵活的设置,下面列举一个论坛系统,帖子和回复聚合思考的例子:
大家都知道一个帖子有多个回复,没有帖子,回复就没有意义;所以很多人就会认为帖子应该聚合回复;但实际上不需要这样,如果你这样做了,那对于一个论坛来说,同一个帖子被多个人同时回复的可能性是非常高的,那这样的话,多个人同时回复一个帖子,就会导致多个人同时修改同一个帖子对象,那就导致大家都回复不了,因为会有并发冲突或者数据库事务的等待超时,因为大家都在修改同一个帖子聚合根;实际上如果我们从业务规则的角度去思考一下,那可以发现,其实帖子和回复之间,只有一个简单的规则,那就是回复一旦被创建,那他所对应的帖子不能被修改即可;这样的话,要实现这个规则其实很简单,把回复作为聚合根,然后把帖子传入回复聚合根的构造函数,然后回复保存帖子ID,然后回复将帖子ID设置为不允许外部修改(private set;即可),这样我们就实现了这个业务规则,同时还做到了多人同时推一个帖子回复时,不会对同一个帖子对象就并发修改,而是每个回复都是并行的往数据库插入一条回复记录即可。-- 摘自阿里内部文档<<关于DDD领域驱动设计中聚合设计的一些思考>>

聚合设计的原则

  1. 聚合是用来封装真正的不变性,而不是简单的将对象组合在一起;
  2. 聚合应尽量设计的小;
  3. 聚合之间的关联通过ID,而不是对象引用;
  4. 聚合内强一致性,聚合之间最终一致性;

什么是聚合根

聚合根就是聚合的入口,聚合外部只能通过聚合根和聚合内部通信。由于聚合外部只能通过聚合根和聚合内部通信, 这也就意味着外部不能操作除聚合根以外的任何类进行数据库操作,因为这样有可能会导致破坏业务的规则。举个例子,一个汽车四个轮子,如果我们用 Repo 直接操作轮子,对轮子采取delete,而这个时候汽车对象的状态却可能是 "running".

如何选出聚合根

1、聚合根一定至少有一个对应的datastore 

2、聚合根一定更够完全描述一组后者多组业务规则(invariant)。绝对不会存在一个业务规则需要多个聚合根联合作用才能做判断的。

3、聚合根一定有自己的独立的生命周期。 

七、规范设计

项目/应用的规范设计的长期价值一定是不可忽视的,规范就相当于一个架构内部组件的收纳的容器,架构指定了这些组件在逻辑上的组织形式,但而规范则是则是妥妥的物理组织形式。如果没有合理清晰的规范设计,项目很快就会因为个人开发习惯的不同,导致物理结构混乱,给接下来的应用/项目的维护和扩展产生影响。
规范设计总共分为两类:

放对位置

  • 程序架构目录

贴好标签

  • 类名约定
  • 方法名约定
  • 错误码约定
  • Domain Event约定
  • 测试约定

下面是我们小团队参考了阿里COLA框架建议的命名规范做出的开发规范:

 

程序架构组织

 

层次
包名
功能
Adapter层
web
处理页面请求的Controller
mtop
处理mtop的请求
hsf
处理hsf的请求
scheduler
处理定时器的请求
message
处理消息的请求
App层
executor
处理request,包括command和query
convertor
包含转换层类的目录
interceptor
包含拦截器目录
extpoint
扩展点目录
extention
扩展实现目录
handler
app 层的一些中间处理逻辑
Domain层
model
领域模型
 
ability
领域能力,包括DomainService
gateway
领域网关,解耦利器
extpoint
扩展点目录
extention
扩展实现目录
Infra层
gatewayimpl
网关实现
mapper
ibatis数据库映射
config
配置信息
Client SDK
api
服务对外透出的API
dto
服务对外的DTO

 

类命名规范

 

种类
对象
示例
API class
增删改服务入参
XXXCmd. 
e.g. MetricsRegistrationCmd
查询服务入参
XXXQry. 
e.g. ListAllMetricsQry
API service
XXXServiceI.java
e.g. MoMetricsAdminServiceI
出参
如果是无需返回返回使用Cola框架的Response, 
如果是单个概念返回使用Cola框架的SingleResponse, 
如果是多个概念返回使用Cola框架的MultiResponse. 
如果概念是某个明确的对象,例如MultiResponse<MoMetricsCO>
如果是复合对象, 使用XXXResult 例如
MultiResponse<ListMetricsValuesResult>
领域层
实体
XXXE.java
值对象
XXXV.java
工厂
XXXFactory.java
Reponsitory
XXXRepository.java
域服务
XXXDomainService.java
防腐服务

从需求的角度去理解Linux系列:总线设备和驱动

笔者成为博客专家后整理以前原创的嵌入式Linux系列博文,现推出以让更多的读者受益。

    《从需求的角度去理解linux系列:总线、设备和驱动》是一篇有关如何学习嵌入式Linux系统的方法论文章,也是从需求的角度去理解Linux系统软件的开篇,期待此系列文章日后会是学习嵌入式Linux的标杆!

 

    这是作者精心撰写的经验总结,希望嵌入式Linux的学习者仔细领会,多读几遍也无妨。

 

一、软件、面向对象、软件框架

软件是为了解决现实问题而产生的,面向对象的软件思维是解决普遍现实问题的一种有效的抽象方法,而软件框架指的是用面向对象的思维去解决某种特定领域的问题而专门设计的一套行之有效的解决方案。

 

一般地,Java/C++编程反映面向对象的软件思维,而像Android Framework、Windows MFC和Linux的QT则代表应用层的软件框架。前述应用框架要解决的问题包括应用消息处理、UI控件显示和处理、资源管理等等。软件框架带来的好处就是对于解决某个领域问题,框架会帮你完成80%的开发工作量,而你只需要完成20%的开发工作量。

 

Linux平台上的各个子系统,如设备驱动模型、input子系统、I2C总线、frame buffer驱动等等都属于软件框架,它是针对特定的硬件体系需求以面向对象的思维去设计的一种软件解决方案,而且已经经过长时间的多平台验证。严格意义上,将子系统归入软件抽象组件会更加贴切,而软件框架表现为一组抽象组件及其组件实例之间的交互。软件框架和软件组件的特点都是解决特点领域问题,可以高度重用设计。

 

Linux系统以C语言开发为主,C语言在教科书上会被认为是过程语言。事实上,面向对象只是一种软件思维,并不局限于某种语言,只不过C++/JAVA在娘胎(编译器)里就已经得到支持,而C语言通过struct数据结构和函数指针一样可以出色地完成面向对象抽象的工作。Linux系统绝对是利用C语言进行面向对象编程的开山鼻祖,处处洋溢着软件艺术的光辉!

二、理解好软件需求是学习好软件框架的前提

对于学习着来说,软件需求(即软件要解决的问题)和软件框架都已经存在。但学习者往往只关注软件框架,因为学习的终极目标也是为了掌握软件框架并使用它来解决自己的问题。对于一般的知识传播者来说(例如学校老师、机构培训师;教科书或者网络文献),往往也是着重于解读软件框架的组成和原理。

 

事实上,对于一个代码量有几万甚至几十万行代码量的软件框架,一开始接触就学习原理和代码并不是好事。这种做法很像是试图从软件框架的学习理解中得出软件需求,有太多的未知就接触源码,那理解过程会非常痛苦,往往会感到非常迷惑。

 

我认为,深入地理解好需求,再去理解软件框架会事半功倍。

 

甚至,当达到一定的水平后,知道了需求,完全可以去猜测软件框架的实现。

三、Linux系统的软件需求


对于软件需求,最容易让人联想到的是一种具体的业务需求,如12306购票业务等等。Linux是一种操作系统,操作系统的软件需求是什么?操作系统是为了给应用层提供良好的接口而进行总线设备驱动管理、内存管理、文件管理、进程管理等等。总线设备驱动管理就是我们今天要谈的主题。Linux平台有各种子系统、各种总线、各种驱动,Linux系统对它们的管理就是软件框架的组成。我们要理解好Linux已有的框架,就要清晰地知晓其解决的问题,也就是其管理了哪些硬件设备,这些硬件设备的特点是什么,这些设备的访问方式是什么。

 

可以说,深入地理解硬件体系是理解好Linux总线设备驱动框架的前提!从面向对象的角度,我们要弄清楚,物理意义上的硬件是什么,而对应的软件对象是如何表述的。

 

以下阐述会重点讲述软件需求,作为以后分析框架的基础。

 

 

四、总线、驱动、设备

1.总线

总线代表着同类设备需要共同遵守的工作时序,不同的总线对于物理电平的要求是不一样的,对于每个比特的电平维持宽度也是不一样,而总线上传递的命令也会有自己的格式约束。如I2C总线、USB总线、PCI总线等等。以I2C总线为例,在同一组I2C总线上连接着不同的I2C设备。

 

2.设备

设备代表真实的、具体的物理器件,在软件上用器件的独特的参数属性来代表该器件。如I2C总线上连接的I2C从设备都有一个标识自己的设备地址,由这个设备地址来确定主设备发过来的命令是否该由它来响应。

 

 

3.驱动

驱动代表着操作设备的方式和流程。对于应用来说,应用程序open打开设备后,接着就read访问这个设备,驱动就是如何实现这个访问的具体的过程。驱动主要包括两部分,第一是通过对SOC的控制寄存器进行编程,按总线要求输出时序和命令,成功地与外围设备进行交互;第二是对第一步中得到的数据进行处理,并向应用层提供特定格式的数据。

 

a.不同总线的设备的驱动过程是不一样的,这个很容易理解,USB鼠标的驱动和I2C EEPROM的读时序肯定是不一样的,访问时序的产生和控制也是驱动的一部分。

 

b.同种总线不同设备类型的设备驱动也是不一样的。如I2C电容屏设备,对于读read来说就是在datasheet规定的地址上去读触摸点的X和Y坐标,而I2C EEPROM的读操作是读取存储的内容,两种设备的datasheet是不一样的,驱动自然是不一样的。

 

c.同种总线的同类设备的设备驱动也可能是不一样的。例如对于触摸屏,TSC2003只支持单点触控,而FT5X06支持多点触摸。在获取触控坐标时,前者只需要获得一个点的数据就返回,而后者则需要先获得当前有几个点的数据,然后再把所有点的坐标都读出来。

 

在驱动的操作中,一般都会用到GPIO和中断等硬件资源,如上图的SDA和SCL会连接到SOC芯片的具体的两个GPIO引脚,而I2C读写时一般都采用中断控制的方式(查询读写是否完成比较低效,浪费CPU)。如果我们在驱动中直接针对具体的引脚来编程,那这个驱动的平台可移植性就比较差,因为不同的产品设计可能引脚不一样。所以,为了提高驱动的可移植性,Linux把驱动要用到的GPIO和中断等资源剥离给设备去管理。即在设备里面包含其自己的设备属性,还包括了其连接到SOC所用到的资源。而驱动重点关注操作的流程和方法。

 

 

 

4.再谈总线

第1点中谈到的总线只是物理意义上的表述,总线就是在行业中制定出标准,明确规定时序的格式。我们在第3点中谈到,在软件层面上,时序的产生和控制由驱动负责。那我们要思考在软件层面上,总线的职责是什么?

 

总线在软件层面主要是负责管理设备和驱动。

 

a.设备要让系统感知自己的存在,设备需要向总线注册自己;同样地,驱动要让系统感知自己的存在,也需要向总线注册自己。设备和总线在初始化时必须要明确自己是哪种总线的,I2C设备和驱动不能向USB总线注册吧。

 

b.多个设备和多个驱动都注册到同一个总线上,那设备怎么找到最适合自己的驱动呢,或者说驱动怎么找到其所支持的设备呢?这个也是由总线负责,总线就像是一个红娘,负责在设备和驱动中牵线。设备会向总线提出自己对驱动的条件(最简单的也是最精确的就是指定对方的名字了),而驱动也会向总线告知自己能够支持的设备的条件(一般是型号ID等,最简单的也可以是设备的名字)。那设备在注册的时候,总线就会遍历注册在它上面的驱动,找到最适合这个设备的驱动,然后填入设备的结构成员中;驱动注册的时候,总线也会遍历注册在其之上的设备,找到其支持的设备(可以是多个,驱动和设备的关系是1:N),并将设备填入驱动的支持列表中。我们称总线这个牵线的行为是match。牵好线之后,设备和驱动之间的交互红娘可不管了。

 

c.总线在匹配设备和驱动之后驱动要考虑一个这样的问题,设备对应的软件数据结构代表着静态的信息,真实的物理设备此时是否正常还不一定,因此驱动需要探测这个设备是否正常。我们称这个行为为probe,至于如何探测,那是驱动才知道干的事情,总线只管吩咐得了。所以我们可以猜测在总线的管理代码中会有这样的逻辑:

 

if(match(device, driver) == OK)

driver->probe();

 

 

 

5.再谈驱动

假设设备正常,探测成功,这时就代表应用程序可以通过驱动来访问操作这个设备了。事实上是这样吗?仔细想想还少了什么东西。应用层通过什么来访问操作这个设备?想起来吗?我们公众号“嵌入式企鹅圈”的第一篇文章《Linux字符设备驱动剖析》中曾清晰地分析了Linux字符设备驱动的开发和访问过程,在开篇即提到应用程序如何访问设备:

 

int fd = open(“设备文件名”);

read(fd, buf, len);

write(fd, buf, len);

 

在这个应用程序中会涉及驱动两个问题,一是设备文件名从何而来,二是应用层的open、read和write对应驱动哪些接口,是如何对应的。这些都是驱动要解决的问题。

 

a.总线匹配设备和驱动之后,驱动探测到设备正常,这时驱动是处于做好准备让应用层来差遣了,但是设备文件名如果没有创建,应用程序也不知从何入手。所以在驱动的probe探测成功之后,立即创建设备文件是最合适的时机。其通过sysfs文件系统、uevent事件通知机制和后台应用服务mdev程序配合能够成功地在/dev目录创建对应的设备文件。

 

b.驱动要提供应用层open、read、write、ioctl等操作的对应接口,而且这些接口要向系统报备(注册)自己,否则系统也不知道怎么调用驱动,因为在上面的描述中从始至终都是设备、驱动和总线三个东西在唱戏,它们跟系统,严格意义是跟Linux的虚拟文件系统和设备文件系统还没建立起关系来。即驱动要包括以下步骤:

 

B1.设备要提供struct file_operation结构定义的接口:

struct file_operations {

int (*open) (struct inode *, struct file *);

int (*ioctl) (struct inode *, struct file *, ...);

ssize_t (*read) (struct file *, char __user *,...);

ssize_t (*write) (struct file *, const char __user *, ...);

…}

 

这些接口将会对应到应用层的设备访问操作。在这些接口中,其会根据第3点中提到的需求去完成自己的操作任务。

 

B2.应用层正常的访问流程是:应用层操作->虚拟文件系统操作->具体文件系统操作->具体设备驱动的操作。虚拟文件系统VFS系统已经存在,具体文件系统操作对于字符设备来说非常简单,我们姑且认为是字符设备文件系统devfs,此时字符设备驱动要做的是将自己的struct file_operations向devfs注册,对应字符设备驱动是cdev_add函数。详细的分析过程可以参考《Linux字符设备驱动剖析》。

 

所以我们可以想象在驱动driver的结构体中有一个probe接口,驱动要实现这个接口,而这个probe接口要完成的工作包括:

 

Driver->probe()

I.探测设备是否正常

II.cdev_add(struct file_operations)注册操作接口

III. device_create()创建设备文件

 

 

 

6.继续谈驱动

做好以上准备即已万事俱备的时候,等着应用程序来访问操作了。通过《Linux字符设备驱动剖析》中open的整个过程,到最后会调用到具体驱动的open,接下来我们就要阐述一下设备驱动的struct file_operations中的接口都要做什么。我们挑几个主要的来讲讲,其余可以自己想象。

 

a.open一般会进行驱动的初始化,可能包括硬件的初始化和软件的初始化。我们在第3点谈驱动的时候,曾说明为了让驱动更具移植性,会将驱动driver过程中使用到的具体GPIO和IRQ中断等资源列入设备device的属性内容。这时device数据结构中断的GPIO和IRQ的标识都来源于SOC datasheet的物理地址定义。我们都知道Linux在运行过程中会使用到SOC的MMU内存管理单元来管理自己的内存,会将内存分为两部分,内核空间(3G-4G)和用户空间(0-3G),这两块地址空间都是虚拟线性地址空间,即程序编译链接之后对应的地址空间,虚拟地址空间需要通过MMU和页表来映射到实际的物理内存空间才能最终访问到物理内存和物理IO等资源。而驱动操作硬件都处在内核空间,在open函数中主要包括以下操作:

 

a1.通过系统提供的资源获取接口获取到GPIO和IRQ等资源

a2.通过ioremap接口将GPIO和IRQ从物理地址空间映射到3G-4G中的虚拟地址空间

a3.根据具体的控制规格设置GPIO和IRQ相关的寄存器。

 

以上初始化的动作可能会出现在驱动probe探测的代码中,那open的接口可以什么都不做。

 

b. read:驱动的open如果成功,那整个访问流程已经成功一大半了,因为open的流程足够漫长和复杂。而read只是从用户空间的fd文件句柄找到所属进程的file文件结构,然后即可找出file_operations->read,其即是驱动的read接口。那就按着外网设备的规格和总线的时候进行操作,达到read设备的目的。Write也一样。

 

c. ioctl一般是对设备进行参数设置。

 

 

 

隆重推荐本人以下原创文章,有助读者系统、全面地理解嵌入式Linux的系统架构和驱动开发!

从需求的角度去理解Linux系列相关博文:

1. Linux字符设备驱动剖析

2.  Linux设备文件的创建和mdev

3. 符设备驱动字、平台设备驱动、设备驱动模型、sysfs的关系

4. Linux模块化机制和module_init

 

5. Linux中断完全分析

6.  Linux input子系统分析之一:软件层次

7. 全网络对Linux input子系统最清晰、详尽的分析

8. 陆续推出Framebuffer、I2C、MTD等子系统的分析

 

微信公众号:嵌入式企鹅圈 

1.忠于Linux源码,百分百原创。

2.从上电第一行代码、系统第一行代码、模块第一行代码、应用第一行代码,深入讲解嵌入式软件生命周期。
3 从需求出发,从架构着眼。



以上是关于从软件复杂度的角度去理解DDD的主要内容,如果未能解决你的问题,请参考以下文章

MASA Framework - DDD设计

从需求的角度去理解Linux系列:总线设备和驱动

从MVC到DDD的架构演进

从需求的角度去理解Linux系列:总线设备和驱动

DDD相对论

DDD专栏8:如何设计支持快速交付的技术中台?

(c)2006-2024 SYSTEM All Rights Reserved IT常识