DDD 设计之服务端落地实践

本篇内容来源于本人部门的开发经验总结--注者:廖同窗数据库

什么是 DDD

DDD 全称领域驱动设计,分为战略设计和战术设计两个层次。咱们在此讨论的均属于战术设计范畴。bash

DDD 战术设计本质上是面向对象的一种设计方法。根本目的与面向对象一致,仍然是为了解决软件项目中不断增加的复杂性问题。架构

DDD 的适应范围比面向对象设计要狭窄,但据咱们的实践,至少在服务端开发的领域,DDD 能很好地产生他的效用。app

DDD 能带来什么

  • 统一术语,下降团队沟通成本
  • 提升代码可读性,甚至达到无文档化(代码即文档)
  • 提升代码复用性
  • 带来灵活性,拥抱变化

DDD 不能带来什么

  • 性能
  • bug
  • 一劳永逸的设计
  • ……

DDD 落地

DDD 一词起源于 Eric Evans 的一本书**《领域驱动设计——软件核心复杂性应对之道》**。许多同窗应该都知道,而且多少看过这本书,可是大多数人都会以为很是抽象、难以理解,看完后也不知道该如何将这些理论运用到实践中去。我我的的见解是,其实并非这本书难以理解,而是这本书诞生于 C/S 架构流行的年代,里面许多案例实际上是以 C/S 的角度去举例的。而咱们如今流行的是 B/S 架构的软件,而且许多框架(如 Spring)几乎已经成为了服务端软件开发的必选项,若是只是照搬书上的那些例子,天然是没法很好地进行落地的。框架

如下谈及的内容是我在带领团队的过程当中总结出来的一些 DDD 在服务端的落地实践,并不表明适合全部团队或全部技术栈。ide

DDD 编写的代码所属层次

咱们把 DDD 设计的相关代码放到 Domain 层,这一层是介于经典三层架构中 Service 与 DAO 层之间的特殊的一层,但严格意义上来讲仍是属于 Service 层(处理业务逻辑),能够想象成在原先的 Service 层上又划分了一层出来。函数

以下图所示微服务

image.png

示例

下面是咱们在 JAVA 工程中采用的一个 DDD 包结构规范post

TODO::性能

实体

以标识做为其基本定义的对象称为实体 - Eric Evans

换句话说,即全部实体必须有一个惟一标识。

在咱们的实践中,咱们通常使用 id 字段做为实体的惟一标识。若是要区别某个对象是否一个实体,只要看他是否有 id 便可。

实体除了惟一标识外,每每还有不少其它属性,所以实体每每还会依赖一个仓储对象。有关仓储,会在后面说起。

一个典型的实体定义以下:

public class Project {
    private Long id;
    private ProjectRepository repo;
    
    public Project(Long id, ProjectRepository repo) {
        this.id = id;
        this.repo = repo;
    }
    
    public ProjectDO data() {
        return repo.selectById(this.id);
    }
}
复制代码

引用

咱们建议实体间的聚合采用软关联的方式,缘由是在服务端开发中,这种有状态的对象朝生夕灭的状况很是常见(服务端要管理的对象很是多,不可能将全部实体都存在内存中,通常一个请求过来时会建立对象,请求结束后在下一次 GC 这个对象就会被销毁),而实体之间的关联多是很是复杂的,每次使用时都构建一个完整的聚合很是不划算。

能够看看如下两种方式的区别:

硬关联
public class Project {
    private Long id;
    private List<Application> apps;
    
    public Project(Long id, List<Application> apps) {
        this.id = id;
        this.apps = apps;
    }
    
    public List<Application> listApplications() {
        return this.apps;
    }
}
复制代码
软关联
public class Project {
    private Long id;
    private ApplicationManager applicationManager;
    
    public Project(Long id, ApplicationManager applicationManager) {
        this.id = id;
        this.applicationManager = applicationManager;
    }
    
    public List<Application> listApplications() {
        return this.listAllApplicationId()
            .stream()
            .map(id -> applicationManager.get(id))
            .collect(Collectors.toList())
    }
}
复制代码

FAQ

Q: 实体定义方法时是否可使用值类型

A: 能够,但通常状况下不建议(特殊状况能够这样作,如考虑性能等问题的时候),由于这会致使方法的复用性大大下降。即便这样作了,也应该尽可能返回较通用的值对象(如 DO),应避免使用 DTO, VO 等。

工厂

虽然在上面咱们采用了软关联的方式创建实体之间的引用关系,但这并不表明要构建一个实体就很是简单了,缘由是咱们的实体除了依赖其它实体外,每每还须要依赖许多其它对象(如领域服务Manager仓储等),而且随着业务的变化,实体的依赖每每还会随之发生变化,若是仍是经过传统的 new 方式去建立一个实体,会产生一些灾难性的问题:

  • 使用者必须清楚实体的建立细节,这会大大增长代码的复杂度
  • 每当实体的构造方式发生变化时,不得不调整全部建立实体的代码逻辑以解决代码编译问题

综上,工厂的概念依然有必要存在于服务端 DDD 中。

通用实现

一个通用 Factory 的实现示例以下

public abstract class Factory {
    private static ProjectRepository projectRepository;
    
    public void setProjectRepository(ProjectRepository projectRepository) {
        this.projectRepository = projectRepository;
    }
    
    public Project newProject(Long id) {
        return new Project(id, projectRepository);
    }
}
复制代码

这种实现要求咱们在应用启动的时候,经过钩子函数去为这个 Factory 把全部要用到的对象准备好,每当 Factory 须要的依赖变化时,都得调整这个钩子函数,稍显麻烦。如今服务端已经有许多很是成熟、方便的 IoC 框架(如 Spring),有条件的时候咱们也会结合这些框架来实现 Factory。

结合 Spring

一个基于 Spring 实现的 Factory 以下

@Component
public class Factory {
    @Autowired
    private ProjectRepository projectRepository;
    
    public Project newProject(Long id) {
        return new Project(id, projectRepository);
    }
}
复制代码

实体管理者(Manager)

咱们称其为 Manager,对应的实际上是 Eric Evans 在书中提到的仓储(实体仓储)。为何咱们不使用仓储这个概念呢?缘由是在服务端开发中自己就有仓储**(数据仓储,也叫 DAO)**这个概念。为了不概念混淆,咱们使用了另外一个概念 Manager。

与 Eric Evans 的仓储概念定义一致,Manager 能够为使用者提供实体的建立删除条件查询操做。

Manager 每每还须要依赖仓储(查询持久化数据)及工厂(建立实体),而且能够发布事件

仓储

上面提到咱们用 Manager 这个概念代替了本来 Evans 说的仓储概念,那么咱们如今说起的仓储概念又是用来作什么的呢?

咱们这里定义的仓储只负责与持久化数据打交道,即数据仓储。为何不直接使用 ORM?是由于咱们考虑到在如今流行的微服务架构中,服务拆分、沉淀是很常常发生的事。原先的大服务中,某个实体的数据多是经过 ORM 去查询数据库获得的,而在拆分后,就变成了经过远程调用去获取了。为了解决这一问题,咱们使用仓储这一律念使得持久化数据的操做过程变得透明,若是发生服务拆分沉淀,那么咱们的领域层不须要作任何修改(只要概念的定义没有发生变化),只要调整仓储层的实现便可。

一些使用原则

  • 实体不该该依赖属于其它实体的仓储
  • 实体不该该绕过仓储直接访问数据(如直接操做 ORM 框架)

领域服务

领域服务用于处理一些在概念上不属于实体的操做,这些操做本质上每每是一些活动行为,而且是无状态的。对于这类操做,将其强制进行归类会显得很是别扭,因而便引入了领域服务这一律念。须要明确的是,其与三层架构的 Service 层(应用服务)并非一个概念。另外与 Evans 在书中说起的示例不一样,为了不混乱,咱们通常不会为领域服务的类命名加上 Service 后缀

示例

在某个管理主机的应用中,能够指定主机执行一些 Shell 命令,而且会将输出所有存储起来。但因为该操做执行频繁,所以输出记录会至关庞大,须要须要定时查找超过 15 天的执行记录并将其清理。

在以上背景中,存在几个实体:Host、Exec、ExecOutput。从咱们的描述中可知,咱们须要完成的这个操做没法归类到任何一个实体中,所以咱们须要一个 ExecClearer 的领域服务来帮助咱们完成该操做。

因为领域服务是无状态的,所以咱们通常将其定义为单例

@Compoment
public class ExecClearer {
    private ExecManager execManager;
    
    public void clearOutDated(Integer interval) {
        // 如下实现代码与咱们要说明的内容无关,能够无视
        OutDatedExecFinder finder = new OutDatedExecFinder(interval, execManager);
        while (finder.hasNext()) {
            finder.nextCollection()
                .stream().forEach(Exec::destroy);
        }
    }
}
复制代码

在其它地方,咱们能够直接注入该领域服务,并使用

@Slf4j
@Component
public class ExecScheduledTask {

    @Autowired
    private ExecClearer clearer;

    @Value("${exec.output.interval.days:15}")
    private Integer intervalDays;

    @Scheduled(cron = "0 0 0 * * ?")
    public void deleteExecData() {
        log.info("starting clear exec data, intervalDays=>{}", intervalDays);
        clearer.clearOutDated(intervalDays);
        log.info("clear exec data end");
    }
}
复制代码

领域事件

在咱们的领域活动(实体、Manager 等操做)中会出现一系列的重要的事件,而这些事件的订阅者,每每须要对这些事件做出响应(例如,新增用户后,可能会触发一系列动做:发送欢迎信息、发放优惠券等等)。领域事件能够简单地理解为是发布订阅模式在 DDD 中的一种运用。

在咱们的实践中,通常采用事件总线来快速地发布一个领域事件。

事件总线的接口定义通常以下

public interface EventBus {
    void post(Event event);
}
复制代码

经过调用 EventBus.post() 方法,咱们能够快速发布一个事件。

同时咱们还会提供一个抽象类 AbstractEventPublisher

public class AbstractEventPublisher implements EventPublisher {
    private EventBus eventBus;

    public void setEventBus(EventBus eventBus) {
        this.eventBus = eventBus;
    }

    @Override
    public void publish(Event event) {
        if (eventBus != null) {
            eventBus.post(event);
        } else {
            log.warn("event bus is null. event " + event.getClass() + " will not be published!");
        }
    }
}
复制代码
public interface EventPublisher {
    void publish(Event event);
}
复制代码

这样咱们可让实体或 Manager 继承自 AbstractEventPublisher,其便有了发布事件的能力。至于如何订阅并处理这些事件,取决于 EventBus 的实现方式。举个例子,咱们通常使用 Guava 的 EventBus,定义相关的 handler 并注册到 EventBus 中即可方便地处理这些事件

@Component
public class DomainEventBus extends EventBus implements InitializingBean {
    @Autowired
    private FooEventHandler fooEventHandler;

    @Override
    public void afterPropertiesSet() {
        this.register(fooEventHandler);
    }
}
复制代码
@Component
@Slf4j
public class FooEventHandler implements DomainEventHandler {
    @Override
    @Subscribe
    public void listen(ProjectCreatEvent e) {
        // do something here...
    }
}
复制代码

限界上下文

顾名思义,在实际系统中会有很是多的业务上下文。对于这些业务上下文,可能会重复出现不少同名实体,这些实体有多是同一个概念,也有可能不是。

任何概念都有他适用的范围,咱们在讨论的时候必定要明晰咱们所讨论的这些概念所处的一个上下文是什么,不然咱们的沟通就有可能不在同一个频道上。

单元测试

采用 DDD 的编码模式后,业务逻辑主要汇集在实体中,原三层架构中的 Service 层会变得很是“薄”。所以,单元测试主要会针对实体领域服务等进行编写。

DDD 设计

理解了 DDD 中的所有概念,也并不意味着就能作出一个好的设计了。

DDD 的设计最重要的是作好如下几点:

  1. 准确地定义实体
  2. 准确地定义实体应该有哪些方法
  3. 确立实体与实体之间的关系

实体的设计实际上是一个建模的过程。面向对象的设计方法本质就是将现实世界的对象关系以简化的形式提炼为模型

模型是现实世界的一种简化,但不该该与现实世界冲突。

概念不一致

关系不一致

相关文章
相关标签/搜索