DDD 设计之服务端落地实践

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

什么是 DDD

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

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

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

DDD 能带来什么

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

DDD 不能带来什么

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

DDD 落地

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

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

DDD 编写的代码所属层次

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

如下图所示

image.png

示例

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

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 apps;
    
    public Project(Long id, List apps) {
        this.id = id;
        this.apps = apps;
    }
    
    public List 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 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. 确立实体与实体之间的关系

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

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

概念不一致

关系不一致

你可能感兴趣的:(DDD 设计之服务端落地实践)