DDD战术篇:领域模型的应用

领域驱动设计DDD在战术建模(后文简称建模,除非特别说明)上提供了一个元模型体系(如下图),通过这个元模型我们会对战略建模过程中识别出来的问题子域进行抽象,而通过抽象来指导最后的落地实现。

(DDD构建的元模型元素脑图)

这里我们谈的战术阶段实际就是这样一个抽象过程。这个抽象过程由于元模型的存在实际是一定程度模式化的。这样的好处是并非只能技术人员参与建模,业务人员经过一定的培训也是完全可以理解的。在带领不少团队实践建模的过程中,业务人员参与战术设计也是我要求的。

由于已经有不少书籍介绍DDD的元模型,这里我们就不再赘述,转而谈谈这个抽象过程中大家经常遇到的一些困惑。这些比较常见的问题可能是DDD元模型未来演进需要解决的,但我们仍然要注意业务问题和架构设计的多样性,不要过度规范,以至于过犹不及。

业务对象的抽象

通过对业务问题的子域划分,我们找到了一些关键的业务对象。在开始进行抽象前一个必须的步骤就是“讲故事”!

讲什么故事呢?关于这个子域解决的业务问题或者提供的业务能力的故事。既然是故事,就必须有清晰的业务场景和业务对象之间的交互。这件事情看起来是如此自然和简单,然则一个团队里能够站起来有条不紊陈述清楚的却没有几人。读到这里的读者不妨停下来试试,你是否能够把现在你所做的业务在两三分钟内场景化地描述出来?

这么做显然目的是让我们能够比较完整地思考我们所要提炼和抽象的业务对象有哪些。只有当我们能够“讲”清楚业务场景的时候,才应该开始抽象的步骤。对于一个业务对象,我们常见的抽象可以是“实体”(Entity)和“值对象”(Value Object)。

这两个抽象方式在定义上的区别是,实体需要给予一个唯一标识,而值对象不需要(可以通过属性集合标识)。当然另外一个经常引用的区别是,实体应该是有一个连续的生命周期的,比如我们在一个订单跟踪领域里抽象订单为一个实体,那么每个订单应该有一个唯一识别号,订单也应该有从下单创建到最后交货完成的生命周期。

显然,如果不增加其它约束条件,值对象的抽象是没有意义的,都用实体不就行了?但如果我们稍微思考一下一个实体的管理成本,比如需要保证生命周期中实体状态的一致性,那么我们就会发现值对象变得很简单很可爱。当一个对象在我们(抽象)的世界里不能改变的时候,一切都变得简单了,这个对象被创建后只能被引用,当没有引用时我们可以把它交给垃圾回收自动处理。

随着高并发、分布式系统的普及,实际上我们在对业务对象抽象的第一步思考是能否用值对象。如果大家实现的技术架构采用函数范式的语言(类似Closure),那么首先考虑值对象抽象可能就是一个建模原则了。

对象抽象初步完成后,一定要再重复一次之前的故事来审视一下我们的建模。经历这个抽象过程后,参与讨论的每个人都应该发现自己更清晰业务的需求和需要提供的能力了。

聚合的封装

DDD元模型中一个核心概念叫“聚合”(Aggregate)。这个从建筑学来的名词非常形象,建筑学上我们翻译为“骨料”,是形成混凝土的重要元素,也是为什么混凝土如此坚固的基础。

(混凝土里的一种骨料)

同理,在DDD建模中,聚合也是我们构建领域模型的基础,并且每个聚合都是内聚性很高的组合。聚合本身完成了我们对骨干业务规则的封装,减小了我们实现过程中出错的可能。

以上面那个订单跟踪领域为例,假设我们允许一个订单下存在多个子订单,而每个子订单也是可以独立配送的,这种情况下我们抽象出“子订单”这个实体。显然订单和子订单存在业务逻辑上的一致性,没有订单的时候不应该创建子订单,更新子订单的时候应该同时“通知”所属的订单。这个时候如果采用把订单和子订单聚合起来的封装就很有必要了。

采用聚合抽象的结果就是访问每个子订单都需要从相关的订单入口(i.e., 订单为聚合根),存取时我们都是以这个聚合为基本单位,即包含了订单和订单下面的所有子订单。显然这样的好处是在订单跟踪这个领域模型里,订单作为一个聚合存在,我们只需要一次性梳理清楚订单和子订单的逻辑关系,就不需要在未来每次引用时都考虑这里面的业务规则了。

(订单跟踪领域的订单聚合)

在建模过程中,很多团队并没有努力思考聚合的存在。封装这个在技术实现领域的基本原则在建模时却很少被重视起来。开篇提到在战术建模过程中强调业务领域人员的参与也是为了解决这个问题,聚合的识别实际是针对业务规则的封装,当我们不理解业务规则的时候是无法做出是否封装的判断的。

一言以蔽之,识别聚合是认知潜在核心业务规则的过程,而定义出来的聚合是在大家共识基础上对核心业务规则的封装。

领域服务的定义

在最初的元模型定义里,领域服务让不少人纠结,一个经典的例子是在账户管理领域里对“转账”这个业务行为的抽象。由于转账本身是作用在至少两个账户上的,所以把转账作为一个账户的行为显然是不合适的。那么如果我们把转账名词化抽象成一个实体呢?感觉也是比较别扭,毕竟转账是依附于账户存在的。

这个时候DDD在元模型里提出了服务(Service)这个抽象,转账被抽象为一个服务感觉就顺畅多了。同样道理,在我们上面的订单跟踪领域里,如果跟踪的过程中需要进行短信的通知,一个比较好的建模就是抽象出一个“通知”服务来完成。

我经常会用静态方法来帮助技术人员理解服务的抽象(虽然服务并不一定用静态方法来实现)。服务本身就像一个静态方法一样,拥有一定的逻辑但不持有任何的信息,从整个领域来看也不存在不同“版本”的同一个服务。

一个经常困扰大家的问题是对Service这个词语的限定,有的分层架构设计里会出现领域服务(Domain Service)和应用服务(Applicaiton Service)。大多数时候应用服务在领域服务的上层,直接对外部提供接口。如果存在这样的分层,那么领域服务就不应该直接对外,而应该通过应用服务。

举个例子,前面的订单消息通知如果是一个领域服务,在完成订单状态变化时创建通知消息,而最后的通知以短信的方式发给设定的人群,这样就应该有一个相应的应用服务,包含了具体的业务场景处理逻辑。之后也可能有一个邮件通知的应用服务,同样调用了这个通知领域服务,但通过邮件渠道来完成最终的业务场景。

由于微服务架构的流行,每个子领域的粒度已经相当细了,很多时候已经没有这样的领域服务和应用服务的区分了。当然从简单性角度出发这是好事情。在整个建模过程中,服务的抽象往往是最不确定的,也是最值得大家反复斟酌的地方。

Repositories的使用

Repositories是一个非常容易被误解的抽象,很多人会直接联想到具体的数据存储。在初期采用DDD建模的时候,我经常刻意回避这个抽象,避免让大家陷入思考紊乱。

这个抽象概念实际可以追溯到Martin Fowler的Object Query模式。另外一个相关概念是DAO(Data Access Object),都是用来简化需要存储的数据和对应的业务对象之间的映射关系。不同的是Repositories针对更加粗颗粒度的抽象,在DDD这个方法里我们可以认为映射对象是我们的聚合。针对每个实体在实现时候也可能创造出对应的DAO(比如采用Hibernate这样的ORM框架),但显然在建模过程中不是我们需要关注的。

那么Repositories的抽象为什么是必要的呢?让我们再回到订单跟踪这个例子,通知订单状态发生变化的服务在发出通知前,需要定位到订单的信息(可能包括订单的相关干系人和子订单的信息)。通知作为一个服务是不应该持有具体订单信息的,这个时候我们就需要通过Repositories的抽象来建立对订单这个聚合的查询,即有一个订单的repo,而具体的查询逻辑应该在这个repo中。

这样的抽象在需要存储和查询值对象的时候也是必要的。假设我们分析订单查询这个领域,在这个领域里订单记录显然已经不允许修改了,自然的抽象方式就是值对象。同时一个查询的服务来持有具体的查询逻辑(比如按时间或用户)是合理的。外部应用直接调取了查询服务(接口)并给出规定的参数,我们就需要一个订单记录的repo来持有跟存储相关的查询逻辑。当然这并不是说有一个查询就一定有一个repo与之对应,如果查询的逻辑非常简单,未尝不可以让服务直接针对数据存储实现。记住我们抽象的目标是让建模更简单,抽象过程中应该保持灵活。

限界上下文的意义

经过最近10多年的演进,我们在如何支撑一个组织的规模化上达成了一些基本的共识。我们知道微服务架构(Microservices)能够帮助我们把成百上千的工程师们组织起来,而小团队的自组织性是至关重要的。我们也逐步就如何能够在技术和业务团队之间明确沟通“架构”这个难题上找到了DDD。那么DDD和微服务架构的关系是什么呢?很多人会提到限界上下文(Bounded Context)。

我曾经就这个话题专门撰文一篇(DDD&Microservices)。一个限界上下文封装了一个相对独立子领域的领域模型和服务。限界上下文地图描述了各个子领域之间的集成调用关系。这个定义某种意义上和我们的微服务划分不谋而合:以提供业务能力为导向的、自治的、独立部署单元。所以虽然我们不能百分百依据限界上下文划分服务,但限界上下文,或者说是DDD,绝对是我们设计微服务架构的重要方法之一。

如果我们再追溯到DDD的战略设计,我们会发现在问题域上,DDD通过子问题域(subdomain)的划分就已经进行了针对业务能力的分解,而限界上下文在解决方案域中完成了进一步分解。当然我们不能完全认为子问题域和限界上下文有严格意义上的一对一关系,但大多数情况下一个子问题域是会被设计成一个或多个限界上下文的。子域subdomain和限界上下文某种意义上是互相印证的,重点在区分问题域和解决方案域,这是落地DDD最困难的地方,也是判断一个架构师能力进阶的分水岭。

战术建模小结

DDD的建模元素比较简洁,本文中叙述的元模型应该是满足了大多数场景下的建模。毛主席曾经有一句名言“战略上要藐视敌人 战术上要重视敌人”,就架构设计来说我们没有敌人,业务需求是我们的朋友。所以在领域驱动的架构设计方面,咱们需要的是“战略上要重视朋友,战术上要简化建模”。希望这句话能够帮助正在实践DDD的团队重新思考自己在战略问题域的投入和重视程度,不要挥舞着战术模型的大锤到处寻找实际不存在的钉子。

在这里我们也希望通过第一届DDD China建立起一个架构设计人员的交流平台。期待更多的中国技术人员能够通过这个平台和世界一流架构大师们建立起沟通的渠道,不仅在战略层面,也在战术层面和所有人一起分享讨论关于DDD的一切。


更多精彩洞见,请关注微信公众号:思特沃克

Share

DDD实战篇:分层架构的代码结构

不同于其它的架构方法,领域驱动设计DDD(Domain Driven Design)提出了从业务设计到代码实现一致性的要求,不再对分析模型和实现模型进行区分。也就是说从代码的结构中我们可以直接理解业务的设计,命名得当的话,非程序人员也可以“读”代码。

然而在整个DDD的建模过程中,我们更多关注的是核心领域模型的建立,我们认为完成业务的需求就是在领域模型上的一系列操作(应用)。这些操作包括了对核心实体状态的改变,领域事件的存储,领域服务的调用等。在良好的领域模型之上,实现这些应用应该是轻松而愉快的。

笔者经历过很多次DDD的建模工作坊,在经历了数天一轮又一轮激烈讨论和不厌其烦的审视之后,大家欣慰地看着白板上各种颜色纸贴所展示出来的领域模型,成就感写满大家的脸庞。就在这个大功告成的时刻,往往会有人问:这个模型我们怎么落地呢?然后大家脸上的愉悦消失了,换上了对细节就是魔鬼的焦虑。但这是我们不可避免的实现细节,DDD的原始方法论中虽然给出了“分层架构”(Layered Architecture)的元模型,但如何分层却没有明确定义。

分层架构

在DDD方法提出后的数年里,分层架构的具体实现也经历了几代演进,直到Martin Fowler提炼出下图的分层实现架构后,才逐步为大家所认可。DDD的方法也得到了有效的补充,模型落地的问题也变得更容易,核心领域模型的范围也做出了比较明确的定义:包括了Domain,Service Layer和Repositories。

(Martin Fowler总结提出的分层架构实现,注意“Resources”是基于RESTful架构的抽象,我们也可以理解为更通用的针对外界的接口Interface。而HTTP Client主要是针对互联网的通信协议,Gateways实际才是交换过程中组装信息的逻辑所在。)

我们的核心实体(Entity)和值对象(Value Object)应该在Domain层,定义的领域服务(Domain Service)在Service Layer,而针对实体和值对象的存储和查询逻辑都应该在Repositories层。值得注意的是,不要把Entity的属性和行为分离到Domain和Service两层中去实现,即所谓的贫血模型,事实证明这样的实现方式会造成很大的维护问题。DDD战术建模中的元模型定义不应该在实现过程中被改变,作为元模型中元素之一的实体本身就应该包含针对自身的行为定义。

基于这个模型,下面我们来谈谈更具体的代码结构。对于这个分层架构还有疑惑的读者可以精读一下Martin的原文。有意思的一点是,这个模型的叙述实际是在微服务架构的测试文章中,其中深意值得大家体会。

这里需要明确的是,我们谈论代码结构的时候,针对的是一个经过DDD建模后的子问题域(参见战略设计篇),这是我们明确的组件化边界。是否进一步组件化,比如按照限界上下文(Bounded Context)模块化,或采用微服务架构服务化,核心实体都是进一步可能采用的组件化方法。从抽象层面讲,老马提炼的分层架构适用于面向业务的服务化架构,所以如果要进一步组件化也是可以按照这个代码结构来完成的。

总体的代码目录结构如下:

- DDD-Sample/src/
    domain
    gateways
    interface
    repositories
    services

这个目录结构一一对应了前文的分层架构图。完整的案例代码请从GitHub下载

可以看到实际上我们并没有建立外部存储(Data Mappers/ORM)和对外通信(HTTP Client)的目录。从领域模型和应用的角度,这两者都是我们不必关心的,能够验证整个领域模型的输入和输出就足够了。至于什么样的外部存储和外部通信机制是可以被“注入”的。这样的隔离是实现可独立部署服务的基础,也是我们能够测试领域模型实现的要求。

模型表达

根据分层架构确立了代码结构后,我们需要首先定义清楚我们的模型。如前面讲到的,这里主要涉及的是从战术建模过程中得到的核心实体和服务的定义。我们利用C++头文件(.h文件)来展示一个Domain模型的定义,案例灵感来源于DDD原著里的集装箱货运例子。

namespace domain{
struct Entity
{
    int getId();
protected:
    int id;
};

struct AggregateRoot: Entity
{
};

struct ValueObject
{
};

struct Provider
{

};

struct Delivery: ValueObject
{
    Delivery(int);
    int AfterDays;
};

struct Cargo: AggregateRoot
{
    Cargo(Delivery*, int);
    ~Cargo();
    void Delay(int);
private:
    Delivery* delivery;
};
}

这个实现首先申明了元模型实体Entity和值对象ValueObject。实体一定会有一个标识id。在实体的基础上声明了DDD中的重要元素聚合根 AggregateRoot。根据定义,聚合根本身就应该是一个实体,所以AggregateRoot继承了Entity。

这个案例中我们定义了一个实体Cargo,同时也是一个聚合根。Delivery是一个值对象。虽然这里为了实现效率采用的是struct,在C++里可以理解为定义一个class类。

依赖关系

代码目录结构并不能表达分层体系中各层的依赖关系,比如Domain层是不应该依赖于其它任何一层的。维护各层的依赖关系是至关重要的,很多团队在实施的过程中都没有能够建立起这样的工程纪律,最后造成代码结构的混乱,领域模型也被打破。

根据分层架构的规则,我们可以看到示例中的代码结构如下图。

Domain是不依赖于任何的其它对象的。Repositories是依赖于Domain的,实现如下:引用了model.h。

#include "model.h"
#include <vector>

using namespace domain;

namespace repositories {
struct Repository
{
};
...

Services是依赖于Domain和Repositories的,实现如下:引用了model.h和repository.h

#include "model.h"
#include "repository.h"

using namespace domain;
using namespace repositories;

namespace services {
struct CargoProvider : Provider {
    virtual void Confirm(Cargo* cargo){};
};

struct CargoService {
    ... ...
};
...

为了维护合理的依赖关系,依赖注入(Depedency Injection)是需要经常采用的实现模式,它作为解耦合的一种方法相信大家都不会陌生,具体定义参见这里

在测试构建时,我们利用了一个IoC框架(依赖注入的实现)来构造了一个Api,并且把相关的依赖(如CargoService)注入给了这个Api。这样既没有破坏Interface和Service的单向依赖关系,又解决了测试过程中Api的实例化要求。

auto provider = std::make_shared< StubCargoProvider >();

api::Api* createApi()  {
    ContainerBuilder builder;
    builder.registerType< CargoRepository >().singleInstance();
    builder.registerInstance(provider).as<CargoProvider>();
    builder.registerType< CargoService >().singleInstance();
    builder.registerType<api::Api>().singleInstance();

    auto container = builder.build();

    std::shared_ptr<api::Api> api = container->resolve<api::Api>();

    return api.get();
}

测试实现

有了领域模型,大家自然会想着如何去实现业务应用了,而实现应用的过程中一定会考虑到单元测试的设计。在构建高质量软件过程中,单元测试已经成为了标准规范,但高质量的单元测试却是困扰很多团队的普遍问题。很多时候设计测试比实现应用本身更加困难。

这里很难有一个固定标准来评判某个时间点的单元测试质量,但一个核心的原则是让用例尽量测试业务需求而不是实现方式本身。满足业务需求是我们的目标,实现方式可能有多种,我们不希望需要持续重构的实现代码影响到我们的测试用例。比如针对实现过程中的某个函数进行入参和出参的单元测试,当这个函数发生一点改变(即使是重命名),我们也需要改动测试。

测试驱动开发TDD无疑是一种好的实践,如果应用得当,它确实能够实现我们上述的原则,并且能够帮助我们交流业务的需求。比较有意思的是,在基于DDD建立的核心模型之上应用TDD似乎更加顺理成章。类比DDD和TDD虽然是不恰当的,但我们会发现两者在遵循的原则上是一致的,即都是面向业务做分解和设计:DDD就整个业务问题域进行了分解,形成子问题域;TDD就业务需求在实现时进行任务分解,从简单场景到复杂场景逐步通过测试驱动出实现。下面的测试用例展现了在核心模型上的TDD过程。

TEST(bc_demo_test, create_cargo)
{
    api::CreateCargoMsg* msg = new api::CreateCargoMsg();
    msg->Id = ID;
    msg->AfterDays = AFTER_DAYS;
    createCargo(msg);
    EXPECT_EQ(msg->Id, provider->cargo_id);
    EXPECT_EQ(msg->AfterDays, provider->after_days);
}

上面测试了收到一条创建信息后实例化一个Cargo的简单场景,要求创建后的Cargo的标识id跟信息里的一致,并且出货的日期一致。这个测试驱动出来一个Interface的Api::CreateCargo。

下面是另外一个测试推迟delay的场景,同样我们看到了驱动出的Api::Delay的实现。

TEST(bc_demo_test, delay_cargo)
{
    api::Api* api = createApi();
    api::CreateCargoMsg* msg = new api::CreateCargoMsg();
    msg->Id = ID;
    msg->AfterDays = AFTER_DAYS;
    api->CreateCargo(msg);
    api->Delay(ID,2);
    EXPECT_EQ(ID, provider->cargo_id);
    EXPECT_EQ(12, provider->after_days);
}

长期以来对于TDD这个实践大家都有架构设计上的疑惑,很多资深架构师担心完全从业务需求驱动出实现没法形成有效的技术架构,而且每次实现的重构成本都可能很高。DDD的引入从某种程度上解决了这个顾虑,通过前期的战略和战术建模确定了核心领域架构,这个架构是通过预先综合讨论决策的,考虑了更广阔的业务问题,较之TDD应用的业务需求层面更加宏观。在已有核心模型基础上我们也会发现测试用例的设计更容易从应用视角出发,从而降低了测试设计的难度。

关于预先设计

如果没有读战略篇直接看本文的读者肯定会提出关于预先设计的顾虑,毕竟DDD是被敏捷开发圈子认可的一种架构方式,其目标应该是构建架构模型的响应力。而这里给大家的更多是模式化的实现过程,好似从建模到代码一切都预先设计好了。

值得强调的是,我们仍然反对前期设计的大而全(Big-Design-Up-Front,BDUF)。 但我们应该认可前期对核心领域模型的分析和设计,这样能够帮助我们更快地响应后续的业务变化(即在核心模型之上的应用)。这不代表着核心领域模型未来会一成不变,或者不能改变,而是经过统一建模的核心部分变化频率较之外部应用会低很多。如果核心领域模型也变化剧烈,那么我们可能就要考虑是否业务发生了根本性的变化,需要建立新的模型。

另外不能忘记我们预先定义的模型也是被局限在一个分解出来的核心问题域里的,也就是说我们并不希望一口气把整个复杂的业务领域里的所有模型都建立起来。这种范围的局限某种程度上也限制了我们预先设计的范围,促使我们更多用迭代的方式来看待建模工作本身。

最后显然我们应该有一个核心团队来守护核心领域模型,这不代表着任何模型的设计和改动都必须由这个团队的人做出(虽然有不少的团队确实是这样落地DDD的)。我们期望的是任何对核心模型的改动都能够通过这个核心团队来促进更大范围的交流和沟通。检验一个模型是否落地的唯一标准是应用这个模型的团队能否就模型本身达成共识。在这点上我们看到很多团队持续通过代码走查(code review)的方式在线上和线下实践基于核心模型的交流,从而起到了真正意义上的“守护”作用,让模型本身成为团队的共同责任。

实践DDD时仍然需要遵循“模型是用来交流的”的这一核心原则。我们希望本文介绍的方法及模式能够帮助大家更容易地交流领域模型,也算是对DDD战略和战术设计的一点补充。


更多精彩洞见,请关注微信公众号:思特沃克

Share

DDD战略篇:架构设计的响应力

当敏捷宣言的17位签署者在2001年喊出“响应变化胜于遵循计划”这样的口号时,鲜有组织会真正把这句话当回事儿,甚至很多经验丰富的管理者会认为好的计划是成功的一半,遵循计划就是另外一半。然而在时下的第四次工业革命浪潮中,可能很多管理者已经不会简单满足于“响应”,而是选择主动发起变化了。不确定性管理成了这个时代的主旋律,企业的响应力成了成败的关键。

随着这种趋势的深入,架构设计这个技术管理领域也被推到了风暴边缘。“稳定”这个过去我们用来形容好系统的词语似乎已经失去原有的含义,很多人开始用“健壮”这个词语来形容好的系统。比如Netflix公司采用的Chaos Monkey机制随机主动关停线上服务而不会造成整个服务生态宕机的作法更多的是在测试系统的健壮性,保证不会因为某个局部的问题而造成全身瘫痪。

然而架构的健壮性却比较难于定义和测试,以至于很多时候咱们在架构设计上还是在追求稳定性。在一个典型的企业IT组织里,当你询问一位资深工程师架构设计时,往往会得到一张搭积木一样的“架构图”。

图的底层是各种数据存储(从经典的Oracle到大数据标配的Hadoop),图的中间是类似Kafka这样的消息管道和传统的ESB(消息总线),上层则是各种业务应用(包括各种Web应用和移动的APP)。

仿佛这是一个流行的“稳定”架构设计。

(示意:典型的IT系统架构图)

当询问这样的架构是否合理时,不少人会告诉你问题可大了:这不是云时代的服务化架构。原因是这个架构的大部分组件,如数据存储,都已经可以完全“托管”给云平台了。于是乎,很多企业架构师又开始寻找像过去ESB一样能够对接各种云平台的PaaS了,然后抱怨现在的PaaS没有当年的ESB“稳定”。

两个核心问题却很少被提及:

  1. 当年基于ESB集成的SOA服务化架构解耦出的组件不但没有提升效率,反而增加了系统后续修改的复杂度。
  2. 看似“以不变应万变”的架构并不能支撑多样化的业务需求,最后各个业务部门仍然有一套自己的IT系统,即便是画出来的架构图惊人的相似(多少次有人惊呼“这就是我们之前那个工作流系统~”)。

就这两个核心痛点,让我们一起来谈谈架构设计面临的挑战和应对方式。

什么是架构设计

由于软件设计是一个复杂度很高的活动,“通过组件化完成关注点分离从而降低局部复杂度”很早就成为了咱们这个行业的共识。前面提到的数据存储、消息管道等“模块”在某种意义上都是组件化的产物。这样的好处是在不同系统里遇到同样的功能需求时可以复用。在云服务崛起的今天,这样的组件以“服务”的形式更容易为我们所采用。

当然技术出身的架构师们在架构设计的时候或多或少都有一种“搭积木”的感觉。大家都非常关注Kafaka有哪些功能,K8S是不是比Mesos功能更全,以及Akka是不是稳定。就像走进一个家装公司,在选择了“套餐”之后有工程人员给你介绍地砖和木地板用哪个品牌更好。

回到咱们的第二个核心痛点,如果只是这样的搭积木,为什么咱们总是在面对新变化、新需求的时候发现需要新的组装方式或新的组件呢?这样的架构设计对比直接按照需求实现(不考虑架构)有什么优势呢?

这里我们应该回到架构设计的本质,即为什么我们要在代码实现前做设计。显然如果去掉设计这个过程,大家会说问题这么复杂,如何下手啊?所以设计首先是要解决问题的复杂度。于是有人做了一个架构,交给了一个团队去实现,很快发现实现的架构和设计完全是两张皮。当然原因很明确——缺少了交流和沟通,所以设计其次是要建立团队协作沟通的共识

假设我们产生了一个团队都达成共识的架构设计,大家都兢兢业业把设计变成了现实。一个长期困扰软件行业的问题出现了,需求总是在变化,无论预先设计如何“精确”,总是发现下一个坑就在不远处。相信很多技术人员都有这样的经历,结果往往是情况越来越糟糕,也就是我们常说的架构腐化了,最后大家不得不接受重写。这些经历让我们逐步明确了软件架构设计的实质是让系统能够更快地响应外界业务的变化,并且使得系统能够持续演进。在遇到变化时不需要从头开始,保证实现成本得到有效控制。

面向业务变化而架构

基于上面的架构设计定义,关键因素就是业务变化。显然这个时代的业务变化是很快的,甚至很多业务主动在变,不变则亡是很多行业目前的共识。变化速度给架构设计带来了很大挑战,一个移动APP可能需要在一周内上线,然而为了支撑这个移动APP的后台服务,平台发布窗口是每两个月一次。这样的不匹配在IT领域里是随处可见的现实,我们习惯性地认为后台天然就很重因此很慢,只可能在牺牲质量的情况下满足这样的速度。

然而事实上这样的健壮架构确实是存在的,看看身边现在无处不在的互联网,又有哪一个企业的架构比之复杂呢。互联网系统的组件是一个个网站,每个网站完成着自己的业务功能更新,从新闻发布到在线聊天。而各个站点又是紧密互联的,聊天网站可能把新闻网站拿到的信息实时推送给在线的用户。每个网站都是独立的小单元,面向互联网用户提供着一定的业务服务。好的网站也根据用户的反馈在不停升级和变化,但这样的变化并不影响用户使用其它的网站。

从互联网架构我们可以学到什么呢?从架构设计角度我认为以下三点是关键。

  1. 让我们的组件划分尽量靠近变化的原点,对于互联网来说就是用户和业务,这样的划分能够让我们将变化“隔离”在一定的范围(组件)内,从而帮助我们有效减少改变点。
  2. 组件之间能够互相调用,但彼此之间不应该有强依赖,即各自完成的业务是相对独立的,不会因为一方掉线而牵连另外一方,比如新闻网站挂掉了,聊天网站应该继续正常提供服务,可能提示用户暂时无法提供新闻信息而已。
  3. 组件在业务上是鼓励复用的,正是这样的复用才成就了今天的互联网,我们不会每个网站都去实现一个强大的搜索引擎。而被“复用”最多的网站显然会受到追捧,成为明星业务。当然架构上这样的网站必然是健壮的。

上面的三点毫无疑问都指向了业务,从业务出发、面向业务变化是我们现代架构设计成功的关键

架构设计的核心实质是保证面对业务变化时我们能够有足够快的响应能力。

这种响应力体现在新需求(变化)的实现速度上,也体现在我们组件的复用上,在实现过程中现有架构和代码变化点的数量也是技术人员能够切身体会到的。面对日新月异的数字化时代,组织的整体关注点都应该集中到变化的原点,即业务上,而架构应该服务于这种组织模式,让这样的模式落地变得自然。

对比之前的传统SOA架构,这个思路的变化是本质性的。类似工业总线(ESB)这样的组件化其实是面向技术的,希望通过技术平台的灵活性来解决业务变化的多样性。虽然短时间能够收到一定的成效,长期看必然把自身做成瓶颈,因为所有业务的变化最后都堆积到了这个技术组件来解决。这也回答了为什么实施了传统SOA架构的企业最后都发现响应速度其实并没有提升起来。

面向业务变化而架构就要求首先理解业务的核心问题,即有针对性地进行关注点分离来找到相对内聚的业务活动形成子问题域。子问题域内部是相对稳定的,即未来的变化频率不会很高,而子问题边界是很容易变化的,比如在一个物流系统中:计算货物从A地到B地的路径是相对固定的,计算包裹的体积及归类也是相对固定的,但根据包裹的体积优化路径却经常会根据业务条件而变化。

(子问题域的划分)

面对业务的变化也要求我们的架构必须是演进的,因为业务的变化点也会随着时间推移发生着变化。这意味着在一款较长生命周期的软件产品中,不会出现类似ESB这样的重型组件,相反的我们追求的是一些面向业务服务的轻量级组件,它们的持续演进也会造成老组件的合并,新组件的重新拆分。当然这也成了现代微服务架构成功的基础条件之一。

打造架构响应力的方法

如果认同了上述现代架构的真正意义,大家一定会问怎么才能打造这样的高响应力架构呢?

领域驱动设计方法DDD(Domain Driven Design)为我们提供了很好的切入点。这个2003年就总结出来的方法终于在10多年后重新走入了架构师的视野,而这一次大家已经意识到了这种方法在这个快速变化时代的重要性。DDD通过以下两个模式去有效解决了文章开始提到的两大痛点:

  1. 让团队中各个角色(从业务到开发测试)都能够采用统一的架构语言,从而避免组件划分过程中的边界错位。
  2. 让业务架构和系统架构形成绑定关系,从而建立针对业务变化的高响应力架构。

这两点是DDD的核心,也是为什么时下全球架构圈在进一步向DDD这个方向靠拢的原因。DDD明确了业务和系统架构上的绑定关系,并提供了一套元语言来帮助各个角色有效交流架构设计。

(DDD的基本方法)

在战略层面,DDD非常强调针对业务问题的分析和分解,通过识别核心问题域来降低分析的复杂度。在战术层面,DDD强调通过识别问题域里的不同业务上下文来进行面向业务需求的组件化。最后在实现层面利用成熟的技术模式屏蔽掉技术细节的复杂度。

在这里我们也希望通过第一届DDD China建立起一个架构设计人员的交流平台。期待更多的中国技术人员能够通过这个平台和世界一流架构大师们建立起沟通的渠道,不仅在战略层面,也在战术层面和所有人一起分享讨论关于DDD的一切。


更多精彩洞见,请关注微信公众号:思特沃克

Share

DDD & Microservices

Microservices(微服务架构)和DDD(领域驱动设计)是时下最炙手可热的两个技术词汇。在最近两年的咨询工作中总是会被不同的团队和角色询问,由此也促使我思考为什么这两个技术词汇被这么深入人心的绑定,它们之间的关系是什么呢?

服务于更高的业务响应力

首先从两个词汇的发明来看它们是没有因果关系的。DDD是Eric Evans于2003年出版的书名,同时也是这个架构设计方法名的起源。DDD的想法是让我们的软件实现和一个演进的架构模型保持一致,而这个演进的模型来自于我们的业务需求。这种演进式设计方法在当时看来还是比较挑战的,更为流行的解决架构设计复杂度的方法是分层:比如数据架构、服务架构、中间件架构等。MVC在互联网应用开发领域也基本成为了标配。

时间很快过了10年,Martin Fowler和ThoughtWorks英国架构师James Lewis坐下来一起分析了好几个能够持续演进的大型复杂系统,总结出了9大核心特质,然后用Microservices来定义了拥有这些特质的架构。之后由于Google、Netflix、Amazon等一系列明星企业都对号入座,Microservices开始风靡整个软件业。这时候很多人会问微服务架构是怎么设计出来的,业界人士会说DDD是一个好方法,其中也包括微服务定义者Martin Fowler,毕竟DDD原书的序是他给著的;)于是乎DDD开始在被定义10年后火了。

从我个人角度来看,如果真的需要找到因果关系的话,最根本的驱动力来自于科技时代对软件系统(数字化)响应力要求的不断提升,而系统的复杂度却随着业务的多元化而与日俱增。如何驾驭这样的高复杂度成了每个企业必须面对的挑战,以至于业界开始把这种模型总结为响应力企业Responsive Enterprise),而模型中总结的大部分原则都是为了更好的适应环境不确定性带来的高复杂度。

从业务视角分离复杂度

每个人能够认知的复杂度都是有限的,在面对高复杂度的时候我们会做关注点分离,这是一个最基本的哲学原则。显然在针对复杂业务场景进行建模时,我们也会应用此原则。这个时候去分离关注点一般可以从两个维度出发:

  • 技术维度分离,类似MVC这样的分层思想是我们广泛接受的。
  • 业务维度分离,根据不同的业态划分系统,比如按售前、销售、售后划分。

以上两个维度没有孰优孰劣之分,在处理复杂问题的时候一定都会用上,但为了能够高效响应业务的变化,微服务的架构更强调业务维度的关注点分离来应对高复杂度。这是显著区别于传统SOA架构的特质之一,比如诞生于传统SOA时代的ESB(工业服务总线)就是一个典型的技术关注点分离出来的中间件。随着业务的变化,我们也看到ESB成为了一个架构上的反模式,即大量的业务规则和流程被封装在了ESB里,让ESB成为了不可驾驭的复杂度之源,以至于破坏了SOA架构之前承诺的各种优势。当然Microservices架构并非是新一代SOA架构这么简单,已经有不少文章在讨论这个话题,本文就不在展开了。

所以从本质上作为一种架构设计方法的DDD和作为一种架构风格的Microservices都是为着追求高响应力目标而从业务视角去分离复杂度的手段。

如果这个时代你还觉得自己的架构不需要这种响应力,我建议你问问身边维护3年以上系统的朋友或同事们,他们会告诉你这是怎样的一种痛苦。实际上很多企业对这种响应力的追求已经很“疯狂”了,这也是微服务的两位定义者可能都始料未及的。

他们在定义文章中带着很强警告语气让大家慎用,但在这个科技时代,微服务架构实施的可能风险对比高响应力在未来可能带来的市场机会几乎可以忽略不计。一个Netflix的成功就足以让大部分企业毫不犹豫的选择微服务作为自身的架构风格。

业务和技术渐进统一的架构设计

如果Microservices和DDD在目标上达成了上文的统一,那么在具体做法上和以前有什么不同呢?

为了解释清楚这个问题让我们极简化架构设计为以下三个层面工作:

  • 业务架构:根据业务需求设计业务模块及交互关系。
  • 系统架构:根据业务需求设计系统和子系统的模块。
  • 技术架构:根据业务需求决定采用的技术及框架。

显然这三者在具体一个架构设计活动中应该是有先后顺序的,但并非一定是孰先孰后,比如一个简单的web应用,很多人会说MVC是标配了(首先确定了系统架构),或者有人说用RoR快(首先确定了技术架构)。在给定的业务场景里,也许这样的顺序是合理的。

架构设计工作分层及传统意义上的负责人

这个时候咱们增加复杂业务需求快速市场变化这两个环境变量,这个顺序就变得很有意思了。于是我们听到不少走出初创期的互联网服务平台开始“重写”他们的系统(从PHP到Java),很多文章开始反思MVC带来的僵化(臃肿的展现层)。经历了这样变迁的架构师们都会感同身受的出来为DDD站台,其原因就是“跳过”(或“后补”)业务架构显然表明设计出来的架构关注点并不在业务的响应力上,因为业务的可能变化点并没有被分析出来指导系统和技术架构的设计。

DDD的核心诉求就是能够让业务架构和系统架构形成绑定关系,从而当我们去响应业务变化调整业务架构时,系统架构的改变是随之自发的。

这个变化的结果有两个:

  • 业务架构的梳理和系统架构的梳理是同步渐进的,其结果是划分出的业务上下文和系统模块结构是绑定的。
  • 技术架构是解耦的,可以根据划分出来的业务上下文的系统架构选择最合适的实现技术。

第一点显然也是我们产生微服务划分所必须遵循的,因为微服务追求的是业务层面的复用,所以设计出来的系统必须是跟业务一致的。第二点更是微服务架构的特质:“去中心化”的治理技术和数据管理。 作为架构设计的方法,DDD的各种实践,包括最近流行的Event Storming(事件风暴)实际上都是促进业务和系统架构梳理的渐进式认知。

在一次DDD工作坊中,一位同事给出了“你们连业务故事都讲不清楚,还有必要继续做架构设计吗?”这样的经典评论。而DDD的整个方法也没有涉及具体的技术架构实现,这个选型的权利很多时候被“下放”给了真正的开发团队。

值得一提的是采用DDD这种架构设计方法并不一定就产生Mircoservices这种架构风格,往往会推荐用大颗粒度的服务来包含业务分析过程中发现的不确定点,以避免拆分后变化过度频繁带来的双向修改成本。

跨职能协作的架构设计

业务和系统的渐进认知改变了很多之前的架构工作模式,在采用DDD的过程中,很容易感受到业务专家的重要性。而如果还有人寄希望让业务能够一次性给架构师讲清楚需求,那我建议抱有这样希望的同学去亲身参加一次自己不熟悉业务领域的架构设计讨论。你会很容易得出结论“原来业务也不懂他要什么”。当然业务人员听说要参加某种(软件)架构设计方法时心里也一定是抵触的。

DDD成功运用的基础就是创造让业务和系统这两种不同认知模型逐步统一的环境。

业务架构和系统架构设计

所以“不幸”的是如果你不能建立一个跨业务和技术的新型架构设计小组,你的DDD实践就没有成功的基础,继而采用微服务架构可能就会是一场灾难。幸运的是这种跨职能组织结构已经是前文中“采用”微服务架构企业(如Amazon)的标配,你不必再论证这件事情的可实施性。剩下的关键就是如何能够让不同背景的人们协作起来。这也是大家可以看到DDD领域的下一个热点,类似Event Storming这样的模式化协作工作坊会更多的出现在大家的视线里。

永无终止的DDD和演进的Microservices

DDD是容易上瘾的,当大家发现原来通过这个建模过程业务专家更了解服务划分(系统模块),架构设计更懂业务需求,这种协作会成为常态。在这个tech@core的时代,这样的融合将成为企业的核心竞争力。

当然刚开始采用DDD方法的时候,请不要认为每个系统搞一次所谓的DDD工作坊就能够找到最佳的服务划分了。业务的变化是持续的,而每次业务架构变化必然牵动系统架构的变化。良好的领域架构绑定了业务和系统,让双方人员能够用统一语言交流,这件事情建立不易,而持续运作更难。

成功的DDD方法运用是贯穿系统的整个生命周期的,这个过程中业务和技术的协作是持续发生的。

Microservices的最后一个特质:“演进式”设计 – 也明确了设计是一种持续的活动。DDD提供了一种符合这个微服务特质的工作方法,让演进能够落地。值得一提的是就笔者最近的经验,这个演进过程中最难认知到变化的就是DDD里最显而易见的“统一语言”。当大家形成了一个业务概念-“客户”后,少有团队能够持续审视这个“客户”是否随着市场的变化而发生了含义的变迁。

对比传统的SOA,微服务的拆分也是动态的,禚娴静在自己的文章中描述一个系统采用微服务架构历程中服务拆分的演变。这里不会有一个ESB来以不变应万变,这种幻想在过去的10年里已经被数次打脸。DDD的好处是让业务和技术人员都能够在合作中理解这种变化,而不至于陷入业务人员抱怨技术架构不知所谓,技术人员觉得业务人员朝三暮四的尴尬。

你需要成为那个高个子!

Martin Fowler在Microservies的定义文章中画了下面的图,评论“你必须有那个高度”来隐喻微服务实施的能力要求。就架构建模方面来说我认为DDD应该是一个团队必须去掌握的,包括这个团队的业务人员和产品设计人员。

微服务前置条件示意

很有意思的是目前Service Design也是全球用户体验设计领域的一个热门话题,从用户视角出发去设计整个服务链条。比如时下热门的共享单车,一个成功的服务设计应该是从用户开始有用车需求触发到最后完成骑行缴费离开,而不仅仅是去设计一辆能够互联网解锁的自行车。

我们可以找到很多Service Deisgn和DDD在原则上的相似之处,比如用户中心和协同设计。借用上面的高个子说法:

在业务需求认知和跨职能协作方面你一定需要成为高个子!


更多精彩洞见,请关注微信公众号:思特沃克

Share

用DDD实现打卡系统

这是我们DDD workshop的作业,仅供练习。

案例1. 一家咨询服务公司的Timesheet系统

需求

1) 公司的所有员工能够登陆到系统填写每周工作的时间、内容。

2)公司有两部分员工,一类是办公室人员,一类是咨询人员;

3)咨询人员是为某个项目工作,在每个项目里的角色不尽相同;每个项目的Timesheet要求也不同,根据角色的不同有不同的定义,比如开发人员要求填写工作的story号等。每个项目的PM可以批准项目成员填写的Timesheet内容。

4)办公室人员的工作是与办公室的事情相关,有很多的工作项目,每个办公室要求的内容不同。每个办公室经理可以批准办公室人员填写的Timesheet内容。

问题空间与子域

问题空间

一家咨询服务公司的Timesheet系统。

子域:

领域:这个公司的Timesheet
子域:这家公司的Timesheet业务及为其服务的一系列活动。

其业务活动分析如下:

PM定义项目Timesheet填写模版,项目成员登陆系统,选定项目,展示模版,填写内容。PM批准其填写的Timesheet。
办公室管理人员定义办公室Timesheet填写模版,办公室人员登陆系统,选定工作项目,展示模版,填写内容。

  • Timesheet子域 – 核心子域
  • 项目模版资源 – 支撑子域
  • 办公室模版子域- 支撑子域
  • 项目及人员信息子域- 支撑子域
  • 办公室及人员信息子域- 支撑子域
  • 用户登陆身份子域- 通用子域

解决方案与限界上下文

限界上下文是解空间的内容,要求限界上下文中的术语是通用的无歧义的。在Project Template域中存在项目经理,而在Timesheet中则没有项目经理这个概念,只有timesheet批准人这么一个术语。
01

限界上下文映射

02

 

通用术语

03

如何划分上下文

这里面最重要的依然是两个概念,子域与限界上下文。如何定义和划分限界上下文是一个需要根据当前需求和客户一起讨论确定的。

限界上下文的不同决定了解空间的不同,限界上下文不易过早缩小,否则会带来很多小系统或者小模块,使得集成方式变得过于复杂。限界上下文过大,有可能会导致大而全的解空间,使得问题过于复杂,领域混乱。那么如果限界上下文中的术语没有歧义,是否就可以选择大而为之呢?

这一题感觉可以将Project TemplateContext 与 Timesheet Context合并,减少复杂性。

Share