DDD分层架构最佳实践

还在单体应用的时候就是分层架构一说,我们用得最多的就是三层架构。而现在已经是微服务时代,在微服务架构模型比较常用的有几个,例如:整洁架构,CQRS(命令查询分离)以及六边形架构。每种架构模型都有自己的应用场景,但其核心都是“高内聚低耦合”原则。而运用领域驱动设计(DDD)理念以应对日常加速的业务变化对架构的影响,架构的边界越业越清晰,各施其职,这也符合微服务架构的设计思想。以领域驱动设计(DDD)为理念的分层架构已经成为微服务架构实践的最佳实践方法。

一、什么是DDD分层架构

1. 传统三层架构

要了解DDD分层架构,首页先了解传统的三层架构。

image

传统三层架构流程:

  • 第一步考虑的是数据库设计,数据表如何建,表之间的关系如何设计
  • 第二步就是搭建数据访问层,如选一个ORM框架或者拼接SQL操作
  • 第三步就是业务逻辑的实现,由于我们先设计了数据库,我们整个的思考都会围绕着数据库,想着怎么写才能把数据正确地写入数据库中,这时CRUD的标准作法就出现了,也就没有太多考虑面向对象,解耦的事情了,这样的代码对日常的维护自然是越来越困难的
  • 第四步表示层主要面向用户的输出

2. DDD分层架构

image

为了解决高耦合问题并轻松应对以后的系统变化,我们提出了运用领域驱动设计的理念来设计架构。

此段落部分总结来源于欧创新《DDD实践课》的《07 | DDD分层架构:有效降低层与层之间的依赖》读后感

1)领域层

首先我们抛开数据库的困扰,先从业务逻辑入手开始,设计时不再考虑数据库的实现。将以前的业务逻辑层(BLL)拆分成了领域层应用层

领域层聚焦业务对象的业务逻辑实现,体现现实世界业务的逻辑变化。它用来表达业务概念、业务状态和业务规则,对于业务分析可参照:《使用领域驱动设计分析业务

2)应用层

应用层是领域层的上层,依赖领域层,是各聚合的协调和编排,原则上是不包括任何业务逻辑。它以较粗粒度的封闭为前端接口提供支持。除了提供上层调用外,还可以包括事件和消息的订阅。

3) 用户接口层

用户接口层面向用户访问的数据入向接口,可按不同场景提供不一样的用户接口实现。面向Web的可使用http restful的方式提供服务,可增加安全认证、权限校验,日志记录等功能;面向微服务的可使用RPC方式提供服务,可增加限流、熔断等功能。

4) 基础设施层

基础设施层是数据的出向接口,封装数据调用的技术细节。可为其它任意层提供服务,但为了解决耦合的问题采用了依赖倒置原则。其它层只依赖基础设施的接口,于具体实现进行分离。

二、DDD分层代码实现

1. 结构模型

image

2. 目录结构

.
├── pom.xml
└── src
    ├── main
    │   ├── java
    │   │   └── fun
    │   │       └── barryhome
    │   │           └── ddd
    │   │               ├── WalletApplication.java
    │   │               ├── application
    │   │               │   ├── TradeEventProcessor.java
    │   │               │   ├── TradeMQReceiver.java
    │   │               │   └── TradeManager.java
    │   │               ├── constant
    │   │               │   └── MessageConstant.java
    │   │               ├── controller
    │   │               │   ├── TradeController.java
    │   │               │   ├── WalletController.java
    │   │               │   └── dto
    │   │               │       └── TradeDTO.java
    │   │               ├── domain
    │   │               │   ├── TradeService.java
    │   │               │   ├── TradeServiceImpl.java
    │   │               │   ├── enums
    │   │               │   │   ├── InOutFlag.java
    │   │               │   │   ├── TradeStatus.java
    │   │               │   │   ├── TradeType.java
    │   │               │   │   └── WalletStatus.java
    │   │               │   ├── event
    │   │               │   │   └── TradeEvent.java
    │   │               │   ├── model
    │   │               │   │   ├── BaseEntity.java
    │   │               │   │   ├── TradeRecord.java
    │   │               │   │   └── Wallet.java
    │   │               │   └── repository
    │   │               │       ├── TradeRepository.java
    │   │               │       └── WalletRepository.java
    │   │               └── infrastructure
    │   │                   ├── TradeRepositoryImpl.java
    │   │                   ├── WalletRepositoryImpl.java
    │   │                   ├── cache
    │   │                   │   └── Redis.java
    │   │                   ├── client
    │   │                   │   ├── AuthFeignClient.java
    │   │                   │   └── LocalAuthClient.java
    │   │                   ├── jpa
    │   │                   │   ├── JpaTradeRepository.java
    │   │                   │   └── JpaWalletRepository.java
    │   │                   └── mq
    │   │                       └── RabbitMQSender.java
    │   └── resources
    │       ├── application.properties
    │       └── rabbitmq-spring.xml
    └── test
        └── java



此结构为单一微服务的简单结构,各层在同一个模块中。

在大型项目开发过程中,为了达到核心模块的权限控制或更好的灵活性可适当调整结构,可参考《 数字钱包系统》系统结构

3. 领域层实现(domain)

在业务分析(《使用领域驱动设计分析业务》)之后,开始编写代码,首先就是写领域层,创建领域对象领域服务接口

1)领域对象

这里的领域对象包括实体对象、值对象。

实体对象:具有唯一标识,能单独存在且可变化的对象

值对象:不能单独存在或在逻辑层面单独存在无意义,且不可变化的对象

聚合:多个对象的集合,对外是一个整体

聚合根:聚合中可代表整个业务操作的实体对象,通过它提供对外访问操作,它维护聚合内部的数据一致性,它是聚合中对象的管理者

代码示例:

// 交易
public class TradeRecord extends BaseEntity {
    /**
     * 交易号
     */
    @Column(unique = true)
    private String tradeNumber;
    /**
     * 交易金额
     */
    private BigDecimal tradeAmount;
    /**
     * 交易类型
     */
    @Enumerated(EnumType.STRING)
    private TradeType tradeType;
    /**
     * 交易余额
     */
    private BigDecimal balance;
    /**
     * 钱包
     */
    @ManyToOne
    private Wallet wallet;

    /**
     * 交易状态
     */
    @Enumerated(EnumType.STRING)
    private TradeStatus tradeStatus;

    @DomainEvents
    public List<Object> domainEvents() {
        return Collections.singletonList(new TradeEvent(this));
    }
}

// 钱包
public class Wallet extends BaseEntity {

    /**
     * 钱包ID
     */
    @Id
    private String walletId;
    /**
     * 密码
     */
    private String password;
    /**
     * 状态
     */
    @Enumerated(EnumType.STRING)
    private WalletStatus walletStatus = WalletStatus.AVAILABLE;
    /**
     * 用户Id
     */
    private Integer userId;
    /**
     * 余额
     */
    private BigDecimal balance = BigDecimal.ZERO;

}



  • 钱包交易例子的系统设计中,钱包的任何操作如:充值、消息等都是通过交易对象驱动钱包余额的变化

  • 交易对象钱包对象均为实体对象且组成聚合关系,交易对象是钱包交易业务模型的聚合根,代表聚合向外提供调用服务

  • 经过分析交易对象钱包对象为1对多关系(@ManyToOne),这里采用了JPAORM架构,更多JPA实践请参考>>

  • 这里的领域建模使用的是贫血模型,结构简单,职责单一,相互隔离性好但缺乏面向对象设计思想,关于领域建模可参考《领域建模的贫血模型与充血模型

  • domainEvents()为领域事件发布的一种实现,作用是交易对象任何的数据操作都将触发事件的发布,再配合事件订阅实现事件驱动设计模型,当然也可以有别的实现方式

2)领域服务

/**
 * Created on 2020/9/7 11:40 上午
 *
 * @author barry
 * Description: 交易服务
 */
public interface TradeService {

    /**
     * 充值
     *
     * @param tradeRecord
     * @return
     */
    TradeRecord recharge(TradeRecord tradeRecord);

    /**
     * 消费
     *
     * @param tradeRecord
     * @return
     */
    TradeRecord consume(TradeRecord tradeRecord);
}



  • 先定义服务接口,接口的定义需要遵循现实业务的操作,切勿以程序逻辑或数据库逻辑来设计定义出增删改查
  • 主要的思考方向是交易对象对外可提供哪些服务,这种服务的定义是粗粒度高内聚的,切勿将某些具体代码实现层面的方法定义出来
  • 接口的输入输出参数尽量考虑以对象的形式,充分兼容各种场景变化
  • 关于前端需要的复杂查询方法可不在此定义,一般情况下查询并非是一种领域服务且没有数据变化,可单独处理
  • 领域服务的实现主要关注逻辑实现,切勿包含技术基础类代码,比如缓存实现,数据库实现,远程调用等

3)基础设施接口

public interface TradeRepository {
    /**
     * 保存
     * @param tradeRecord
     * @return
     */
    TradeRecord save(TradeRecord tradeRecord);

    /**
     * 查询订单
     * @param tradeNumber
     * @return
     */
    TradeRecord findByTradeNumber(String tradeNumber);

    /**
     * 发送MQ事件消息
     * @param tradeEvent
     */
    void sendMQEvent(TradeEvent tradeEvent);

    /**
     * 获取所有
     * @return
     */
    List<TradeRecord> findAll();
}



  • 基础设施接口放在领域层主要的目的是减少领域层对基础设施层的依赖
  • 接口的设计是不可暴露实现的技术细节,如不能将拼装的SQL作为参数

4. 应用层实现(application)

// 交易服务
@Component
public class TradeManager {

    private final TradeService tradeService;
    public TradeManager(TradeService tradeService) {
        this.tradeService = tradeService;
    }


    // 充值
    @Transactional(rollbackFor = Exception.class)
    public TradeRecord recharge(TradeRecord tradeRecord) {
        return tradeService.recharge(tradeRecord);
    }


     // 消费
    @Transactional(rollbackFor = Exception.class)
    public TradeRecord consume(TradeRecord tradeRecord) {
        return tradeService.consume(tradeRecord);
    }
}

// 交易事件订阅
@Component
public class TradeEventProcessor {

    @Autowired
    private TradeRepository tradeRepository;

    @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT, condition = "# tradeEvent.tradeStatus.name() == 'SUCCEED'")
    public void TradeSucceed(TradeEvent tradeEvent) {
        tradeRepository.sendMQEvent(tradeEvent);
    }
}

// 交易消息订阅
@Component
public class TradeMQReceiver {

    @RabbitListener(queues = "ddd-trade-succeed")
    public void receiveTradeMessage(TradeEvent tradeEvent){
        System.err.println("========MQ Receiver============");
        System.err.println(tradeEvent);
    }
}

应用服务

  • 应用层是很薄的一层,主要用于调用和组合领域服务,切勿包含任何业务逻辑
  • 可包括少量的流程参数判断
  • 由于可能是多个领域服务组合操作调用,如果存在原子性要求可以增加@Transactional事务控制

事件订阅

  • 事件订阅是进程内多个领域操作协作解耦的一种实现方式,它也是进程内所有后续操作的接入口
  • 它与应用服务的组合操作用途不一样,组合是根据场景需求可增可减,但事件订阅后的操作是相对固化的,主要是满足逻辑的一致性要求
  • TransactionPhase.AFTER_COMMIT配置是在前一操作事务完成后再调用,从而减少后续操作对前操作的影响
  • 事件订阅可能会有多个消息主体,为了方便管理最好统一在一个类里处理
  • MQ消息发布一般放在事件订阅中

消息订阅

  • 消息订阅是多个微服务间协作解耦的异步实现方式
  • 消息体尽量以统一的对象包装进行传递,降低对象异构带来的处理难度

5. 基础设施层(infrastructure)

@Repository
public class TradeRepositoryImpl implements TradeRepository {

    private final JpaTradeRepository jpaTradeRepository;
    private final RabbitMQSender rabbitMQSender;
    private final Redis redis;

    public TradeRepositoryImpl(JpaTradeRepository jpaTradeRepository, RabbitMQSender rabbitMQSender, Redis redis) {
        this.jpaTradeRepository = jpaTradeRepository;
        this.rabbitMQSender = rabbitMQSender;
        this.redis = redis;
    }

    @Override
    public TradeRecord save(TradeRecord tradeRecord) {
        return jpaTradeRepository.save(tradeRecord);
    }

    /**
     * 查询订单
     */
    @Override
    public TradeRecord findByTradeNumber(String tradeNumber) {
        TradeRecord tradeRecord = redis.getTrade(tradeNumber);
        if (tradeRecord == null){
            tradeRecord = jpaTradeRepository.findFirstByTradeNumber(tradeNumber);
            // 缓存
            redis.cacheTrade(tradeRecord);
        }

        return tradeRecord;
    }

    /**
     * 发送事件消息
     * @param tradeEvent
     */
    @Override
    public void sendMQEvent(TradeEvent tradeEvent) {
        // 发送消息
        rabbitMQSender.sendMQTradeEvent(tradeEvent);
    }

    /**
     * 获取所有
     */
    @Override
    public List<TradeRecord> findAll() {
        return jpaTradeRepository.findAll();
    }
}

  • 基础设施层是数据的输出向,主要包含数据库、缓存、消息队列、远程访问等的技术实现

  • 基础设计层对外隐藏技术实现细节,提供粗粒度的数据输出服务

  • 数据库操作:领域层传递的是数据对象,在这里可以按数据表的实现方式进行拆分实现

6. 用户接口层(controller)

@RequestMapping("/trade")
@RestController
public class TradeController {

    @Autowired
    private TradeManager tradeManager;

    @Autowired
    private TradeRepository tradeRepository;

    @PostMapping(path = "/recharge")
    public TradeDTO recharge(@RequestBody TradeDTO tradeDTO) {
        return TradeDTO.toDto(tradeManager.recharge(tradeDTO.toEntity()));
    }

    @PostMapping(path = "/consume")
    public TradeDTO consume(@RequestBody TradeDTO tradeDTO) {
        return TradeDTO.toDto(tradeManager.consume(tradeDTO.toEntity()));
    }

    @GetMapping(path = "/{tradeNumber}")
    public TradeDTO findByTradeNumber(@PathVariable("tradeNumber") String tradeNumber){
        return TradeDTO.toDto(tradeRepository.findByTradeNumber(tradeNumber));
    }

}

  • 用户接口层面向终端提供服务支持
  • 可根据不同的场景单独一个模块,面向Web提供http restful,面向服务间API调用提供RPG支持
  • 为Web端提供身份认证和权限验证服务,VO数据转换
  • 为API端提供限流和熔断服务,DTO数据转换
  • 将数据转换从应用层提到用户接口层更方便不同场景之前的需求变化,同时也保证应用层数据格式的统一性

7. 复杂数据查询

以上可见并没有涉及复杂数据查询问题,此问题不涉及业务逻辑处理所以不应该放在领域层处理。

  • 如果复杂数据查询需求较多可采用CQRS模式,将查询单独一个模块处理。如果较少可由基础设施层做数据查询,应用层做数据封装,用户接口层做数据调用
  • JPA不太适合做多表关联的数据库查询操作,可使用其它的灵活性较高的ORM架构
  • 在大数据大并发情况下,多表关联会严重影响数据库性能,可以考虑做宽表查询

三、综述

DDD分层主要解决各层之间耦合度问题,做到各层各施其职互不影响。各层中领域层的设计是整个系统的中枢,最能体现领域驱动设计的核心思想。它的良好设计是保证往后架构的可持续性,可维护性。

四、源代码

文中代码由于篇幅原因有一定省略并不是完整逻辑,如有兴趣请Fork源代码 https://gitee.com/hypier/barry-ddd

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 206,839评论 6 482
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 88,543评论 2 382
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 153,116评论 0 344
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 55,371评论 1 279
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 64,384评论 5 374
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 49,111评论 1 285
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 38,416评论 3 400
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 37,053评论 0 259
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 43,558评论 1 300
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 36,007评论 2 325
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 38,117评论 1 334
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,756评论 4 324
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 39,324评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,315评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,539评论 1 262
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 45,578评论 2 355
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,877评论 2 345

推荐阅读更多精彩内容