干货 | 后微服务时代,领域驱动设计在携程国际火车票的实践

2021-08-13 11:44:20 浏览数 (1)

作者简介

Ma Ning,携程国际火车票后端开发工程师,关注系统架构、微服务、高可用等技术领域。

一、前言

领域驱动设计(Domain-Driven Design,简称 DDD)是一种软件开发设计思想,其旨在以领域为核心,让软件系统在实现时准确地基于对真实业务过程的建模,专注于业务问题域的需要。

DDD将软件系统设计分为了2个部分:战略设计和战术设计,战略设计用于提炼问题域并塑造应用程序的架构,战术设计用于帮助创建用于复杂有界上下文的有效模型。基于此,DDD强调专注于核心领域,通过协作对公共语言和知识进行提炼,并且持续致力于领域的知识提炼,让模型持续发展。

本文基于DDD思想,在携程国际火车票中台预订系统项目进行实践。

二、实践背景

本文以国际火车票中台预订系统项目的创单流程为例,其服务结构下图所示:

伪代码如下所示:

代码语言:javascript复制
@Override
protected CreateOrderResponse execute(CreateOrderRequest request) {
    // 1、参数校验
    if (!validate(request)) {
        throw new BusinessException(P2pBookingResultCode.PARAM);
    }
    if (orderMapper.select(request.getOrderId()) != null) {
        throw new BusinessException(P2pBookingResultCode.ORDER_EXISTS);
    }
    
    // 2、初始化订单
    OrderDao orderDao = new OrderDao();
    orderDao.setOrderId(request.getOrderId());
    orderDao.setOrderStatus(100);
    orderMapper.insert(orderDao);
    // 初始化乘客信息
    PassengerDao passengerDao = new PassengerDao();
    ...
    passengerMapper.insert(passengerDao);
    
    // 3、转换汇率
    ExchangeRate exchangeRate = exchangeService.getExchangeRate(originCurrency, targetCurrency);
    
    // 4、购买保险
    if (isBuyInsurance(request)) {
        // 调用保险服务
        InsuranceInfo insuranceInfo = insuranceService.buyInsurance(request);
        // 保存保险信息
        InsuranceDao insuranceDao = new InsuranceDao();
        ...
        insuranceMapper.insert(insuranceDao);
    }
    
    // 5、供应商创单
    SupplierOrder supplierOrder = supplierService.createOrder(request, exchangeRate);
    // 保存供应商订单信息
    SupplierOrderDao supplierOrderDao = new SupplierOrderDao();
    ...
    supplierOrderMapper.insert(SupplierOrderDao);
    
    // 6、保存订单信息
    orderDao = new orderDao();
    orderDao.setOrderId(request.getOrderId);
    orderDao.setOrderStatus(OrderStatusEnum.WAIT_FOR_PAY.getCode());
    ...
    orderMapper.update(orderDao);
    
    // 7、发送超时支付取消消息
    messageProducer.push(MessageQueueConstants.TOPIC_TIMEOUT_CANCEL, "orderId", String.valueOf(orderDao.getOrderId()), appSettingProp.getTimeoutMinutes(), TimeUnit.MINUTES);
    
    // 8、返回结果
    return mappingResponse(orderDao, orderInsuranceEntity, exchangeRateResponse);
}

2.1 控制层臃肿

在传统的互联网软件架构中,通常都会采用MVC三层架构,其是一种古老且经典的软件设计模式,基于分层架构的思想,将整个程序分为了Model、View和Controller三层:

  • Model(模型层):最底下一层,是核心的数据,也就是程序需要操作的数据或信息;
  • View(视图层):最上面一层,直接面向最终用户的视图,它是提供给用户的操作界面,是程序的外壳;
  • Controller(控制层):中间的一层,就是整个程序的逻辑控制核心,它负责根据视图层输入的指令选取数据层的数据,然后对其进行相应操作产生最终结果;

MVC三层架构模式,将软件架构分为了三层,就可以让软件实现模块化,使三层相互独立,修改外观或者变更数据都不需要修改其他层,方便了维护和升级。但是这种软件架构中模型层只关注数据,控制层只关注行为,随着迭代的不断演化,业务逻辑越来越复杂,便会导致整个控制层的代码量越来越多,而模型层和视图层的变更却很少,最终导致整个控制层变得十分臃肿,从而失去了分层的意义。

2.2 过度耦合

在业务初期,程序的功能都非常简单,此时系统结构逻辑是清晰的,但是随着程序的不断迭代,一方面会导致业务逻辑越来越复杂,系统逐渐冗余,模块之间彼此关联,软件架构设计模式逐渐向“大泥球”模式(BBoM,Big Ball of Mud)发展;另一方面系统会调用越来越多的第三方服务,从而导致数据格式不兼容,业务逻辑无法复用。

在出票系统中,除了订单相关的功能外,还包括了保险、汇率、供应商订单等多个服务接口,同时包括保险、供应商订单、乘客等多个模块的功能及存储均耦合在出票流程的控制层中,使得我们在维护代码时,修改一个模块的功能可能会影响到其他功能模块。

另一方面,如汇率服务这种第三方接口也会存在结构不稳定的情况,当其API签名发生变化或者服务不可靠需要寻找其他可替代的服务时,整个核心逻辑都会随之更改,迁移成本也是巨大的。

2.3 失血模型

失血模型是指领域对象里只有get和set方法的POJO,所有业务逻辑都不包含在内而是放在控制层中,该模型对象的缺点就是不够面向对象,对象只是数据的载体,几乎只做传输介质之用,它是没有生命、没有行为的。

与失血模型相对应的就是充血模型,充血模型就是会包含此领域相关的业务逻辑等,同时也可以包含持久化操作,它的优点对象自洽程度很高,表达能力很强,可复用性很高,更加符合面向对象的思想。

对于创单流程中的对象几乎都是使用的失血模型,虽然可以完成功能的实现,但是在系统逐渐迭代,业务逻辑逐渐复杂后,采用失血模型会导致业务逻辑。状态散落在大量的方法中,使得代码的意图渐渐不够明确,代码的复用性下降。

三、DDD设计

通过上文的背景介绍,我们基于DDD思想对携程国际火车票中台预订系统做出了一定的重构,使系统实现高内聚、低耦合。

3.1 系统设计

Evic Evans将软件系统的设计分为2个部分:战略设计和战术设计。战略设计提出了域、子域、限界上下文等概念,主要用于指导我们如何拆分一个复杂的系统,战术设计提出了实体、值对象、聚合、工厂、仓储。领域事件等概念,主要用于指导我们对于拆分出来的单个域如何进行落地,以及落地过程中需要遵循的原则。

3.1.1 战略设计
通用语言

对于国际火车票中台预定系统,我们定义了预定的通用语言:

  • 通过用户搜索条件调用供应商下单;
  • 记录供应商相关数据用于财务统计;
  • 根据用户选定币种做汇率转换;
  • 根据用户选择购买保险;
领域

在通过软件实现一个业务系统时,建立一个领域模型是非常重要和必要的。因为领域模型是整个软件的核心,其是对某个边界的领域的一个抽象,反映了领域内用户业务需求的本质,开发者便可以仅关注领域边界内所需关注的部分。同时领域对象与技术实现无关,仅反映业务,领域模型贯穿软件分析、设计,以及开发的整个过程。领域专家、设计人员、开发人员通过领域模型进行交流,彼此共享知识与信息。因为大家面向的都是同一个模型,所以可以防止需求走样,可以让软件设计开发人员做出来的软件真正满足需求。

基于此,我们将预定系统划分为了对客订单和对供应商订单两个子域,对客订单负责处理客户需要,对供应商订单负责记录供应商侧的相关数据用于财务统计。

限界上下文

划分限界上下文主要是想传达一种领域设计的思考方式,通过建模来划分清楚业务领域的边界,划分关系如下所示:

在上图左侧的PS代表合作关系(Partner Ship),右侧的ACL表示防腐层(Anticorruption Layer),即右侧几个上下文均是外部领域,需要通过防腐层来转换交互,以隔离业务。

3.1.2 战术设计

上文提到的失血模型,绝大多数来自于数据库的Dao对象,因为Dao对象仅仅是数据库结构的映射,没有包含业务逻辑,这样就会导致业务逻辑、校验逻辑散落在各个service层,不易维护。为了解决这个问题,DDD将领域模型与数据模型做了区分,前者用于内聚自身行为,后者用于业务数据的持久化,仓储就是用来链接这两层的对象,数据模型又可以分为实体和值对象。

实体

实体(Entity)是指领域中可以由唯一标识进行区分的,且具有生命周期的对象,例如上文中的订单就是一个实体,其可以通过订单号进行唯一标识,且订单在整个预定系统中状态会发生改变。

值对象

值对象(Value Object)是指没有唯一标识的对象,也就是我们不需要关心对象是哪个,只需要关心对象是什么,例如上文中的行程上下文,故我们不能提供其set方法,行程如果需要改变应该整个对象更新掉。

聚合根

聚合(Aggregate)是指通过定义对象之间清晰的所属关系和边界来实现领域模型的内聚,并避免了错综复杂的难以维护的对象关系网的形成。聚合是一组相关对象的集合,每个聚合有一个根和边界,聚合根(Aggregate Root)是这个聚合的根节点,其必须是一个实体,边界定义了聚合内部有哪些实体或值对象。聚合内部的对象可以相互引用,对外通过聚合根进行交互。

仓储

仓储(repository)就是对领域的存储和访问进行统一管理的对象,聚合根被创建出来后进行持久化都需要跟数据库打交道,这样我们就需要一个类似数据库访问层的东西来管理领域对象。

3.2 架构设计

DDD有多种分层架构模式,包括四层架构模式、五层架构模式、六层架构模式等,其核心均是定义一层领域层对领域对象及其关系进行建模,从传统的MVC三层架构中将领域抽出,但是依然是高层组件依赖低层组件,不同层次之间的耦合无法消除,故本文采用的是一种改进的分层架构模型:六边形架构,其结构如下所示:

六边形架构采用依赖倒置原则优化了传统的分层架构,低层组件应该依赖于高层组件提供的接口,即无论高层还是低层都依赖于抽象,这样使得整个架构变平。六边形中每条不同的边代表了不同类型的端口,端口要么处理输入,要么处理输出,这样就将外界与系统内部进行了隔离,对于每种外部类型,都需要一个适配器与之对应。

六边形架构的最大特点就是将技术与业务进行分离,六边形内部核心就是领域模型及不同领域的逻辑编排,领域模型外部的基础设施层就是为领域模型提供技术实现以及外部系统的适配,因为技术选型在项目之初就已经选定完成并且随着项目迭代也很少会发生更改,所以业务人员可以将更多的精力放在领域模型的更新上面。

如上文介绍的三方接口结构不稳定情况,也可以通过适配器转化为内部模型,防止修改成本过高。同时,对于外部请求,无论是通过rpc,REST、HTTP还是通过MQ等方式,均可通过适配器对输入进行转化,控制权由此交给内部区域进行处理。同时,上文战术设计中的仓储(repository)的实现也可以看作是持久化适配器,该适配器用于访问先前存储的聚合实例或者保存新的聚合实例,我们可以通过不同方式实现仓储适配器,如MySQL、Redis等。

四、DDD实现

通过上文分析,本文以国际火车票中台预订系统项目作为DDD实践落地。

4.1 项目架构

根据DDD六边形架构原理,系统架构如上图所示,总共分为了4层:

  • gateway:项目入口,其中包括rpc、mq等不同入口;
  • infrastructure:基础设施层,一方面用作防腐,提供不同入口、出口的适配,另一方面实现领域层的接口提供技术实现;
  • application:应用层,用于逻辑编排、管理、调度,突出核心逻辑,尽可能轻薄;
  • domain:领域层,定义领域模型,对领域模型进行建模;

4.2 领域对象

前文提到DDD要解决的一个重要问题就是对象的失血问题,即对象不能仅作为数据的载体而没有行为,如上文代码中的参数校验应该是其自身的行为而非外部进行校验,通过适配器转换为内部对象就可以完成自身参数校验的行为,代码如下所示:

代码语言:javascript复制
public class CreateOrderRequest extends CommonRequest {

    private List<SolutionOfferPair> outSolutionOfferPairList;
    private List<SolutionOfferPair> returnSolutionOfferPairList;
    private String transactionNo;
    ...
    private Contact contact;
    private List<Passenger> passengerInfoList;
    private boolean isSplitOrder;
    private boolean randomAssigned;
    private List<ExtraInfo> extraInfos;

    @Override
    public void requestCheck() {
        if (StringUtils.isEmpty(splitPlanId) && CollectionUtil.isEmpty(outSolutionOfferPairList)) {
            throw new BusinessException(ResponseCodeEnum.PARAM_ERROR);
        }
        ...
    }
}

4.3 战术设计实现

本文以订单聚合根为例具体说明战术设计的实现。

聚合根

聚合根中包含了实体和值对象,同时聚合根与仅有getter、setter的业务对象不同,其将业务逻辑也封装在内,提高了内聚性,同时将仓储封装在内,为聚合根提供持久化操作。

代码语言:javascript复制
public class P2pOrder {

    private P2pOrderRepository repository;

    @Getter
    private long orderId;
    @Getter
    private OrderMasterModel orderMasterModel;
    @Getter
    private List<OrderItemModel> orderItemModels;

    public P2pOrder(P2pOrderRepository repository, long orderId) {
        this.repository = repository;
        this.orderId = orderId;
        orderInfoModel = new OrderInfoModel();
        orderItemModels = new ArrayList<>();
    }
    
    public boolean find() {
        return repository.find(this);
    }

    public void createOrder(CreateOrderRequest request) {
        if (find()) {
            throw new BusinessException(ResponseCodeEnum.ORDER_EXISTED);
        }
        this.orderMasterModel.createOrderMaster(request);
        repository.createP2pOrder(this);
        // 发送超时支付取消消息
        pushDelayMessage(this);
    }
}

实体

实体是指会存在状态变更的类,比如order,其可以提供订单的变更状态等。

代码语言:javascript复制
@Getter
public class OrderMasterModel {

    private OrderStatusEnum orderStatus;
    private LocalDateTime ticketTime;
    private LocalDateTime expirationTime;
    private String lang;
    ...

    public void init(CreateOrderRequest request) {
        this.channelName = request.getChannelMetaInfo().getChannel();
        this.orderStatus = OrderStatusEnum.SEAT_BOOKING;
        ...
    }

    public void ticketing() {
        if (this.orderStatus != OrderStatusEnum.WAIT_FOR_PAY) {
            throw new BusinessException(ResponseCodeEnum.ORDER_STATUS_ERROR);
        }
        this.orderStatus = OrderStatusEnum.TICKETING;
    }
}

值对象

而值对象是指仅作为描述没有唯一标识的类,比如行程信息,行程信息变更应该是整个行程信息进行变更而不是提供方法进行修改,故本文针对值对象的构造方法进行私有化处理,并仅提供静态方法用于重新创建对象。

代码语言:javascript复制
@Getter
public class OrderSegmentModel {
    private long orderSegmentId;
    private short sequence;
    private TravelTypeEnum direction;
    private String  segmentType;
    private String departureLocationCode;
    private String departureLocationName;
    private String arriveLocationCode;
    private String arriveLocationName;
    ...
        
    private OrderSegmentModel() {}

    public static OrderSegmentModel init(OrderSegment orderSegment, short sequence) {
        OrderSegmentModel model = new OrderSegmentModel();
        model.orderSegmentId = Long.valueOf(orderFareId   "0"   orderSegment.getSegmentId());

        model.sequence = sequence;
        if (Objects.nonNull(orderSegment.getDepartureLocation())) {
            model.departureLocationCode = orderSegment.getDepartureLocation().getLocationCode();
            model.departureLocationName = ConvertUtil.getLocationName(orderSegment.getDepartureLocation());
        }
        if (Objects.nonNull(orderSegment.getArrivalLocation())) {
            model.arriveLocationCode = orderSegment.getArrivalLocation().getLocationCode();
            model.arriveLocationName = ConvertUtil.getLocationName(orderSegment.getArrivalLocation());
        }

        model.departureTime = DateUtil.parseStringToDateTime(orderSegment.getDepartureDateTime(), DateUtil.YYYY_MM_DDHHmm);
        model.arriveTime = DateUtil.parseStringToDateTime(orderSegment.getArrivalDateTime(), DateUtil.YYYY_MM_DDHHmm);
        ...
        return model;
    }
仓储

仓储封装于聚合根内部,不用于外部调用,故通过工厂方法将仓储注入聚合根中。

代码语言:javascript复制
@Slf4j
@Component
public class OrderFactory {

    @Autowired
    private OrderIdGenerator orderIdGenerator;
    @Autowired
    private P2pOrderRepository repository;

    public P2pOrder create(CreateOrderRequest request) {
        long orderId = orderIdGenerator.generateOrderId();
        if (orderId < 1) {
            log.error("fail to gen order id");
            throw new BusinessException(ResponseCodeEnum.FAIL_GEN_ORDER_ID);
        }
        return new P2pOrder(repository, orderId);
    }
}

仓储用于链接领域层与数据层,使领域对象与DAO隔离,使我们软件更加健壮。

代码语言:javascript复制
@Slf4j
@Component
public class P2pOrderRepositoryImpl implements P2pOrderRepository {

    @Autowired
    private OrderMapper orderMapper;

    @Override
    public boolean createP2pOrder(P2pOrder p2pOrder) {
        OrderMasterEntity orderMasterEntity = new OrderMasterEntity;
        orderMasterEntity.setOrderId(p2pOrder.getOrderId());
         orderMasterEntity.setOrderStatus( p2pOrder.getOrderMasterModel().getOrderStatus().getCode());
        ...
        return orderMapper.insert(orderMasterEntity) > 0;
    }
}

防腐层

防腐层,又称为适配层,在对外部上下文的访问中,就需要引入防腐层对外部上下文进行一次转义,这样就可以将外部上下文转化为内部模型,防止因为外部更改导致改动影响过大。仓储也是防腐层的一种,因为其隔离了数据库的DAO对象,转化为了内部的实体和值对象。在本系统中,也需要对外部的汇率服务、保险服务等引入防腐层的概念。

4.4 服务结构

通过DDD思想进行建模,并采用DDD的六边形架构,重构后的服务结构如下:

五、总结

本文基于携程国际火车票出票系统对领域驱动设计进行实践,通过对出票系统中多个领域的划分使业务逻辑更加清晰,使得代码易于维护和迭代;并通过领域驱动设计的六边形架构将业务与技术进行了隔离,突出业务重点,使代码易于阅读;加入防腐层使外部上下文与内部模型进行隔离,防止外部对象侵蚀;将迭代需求转化为各个领域模型的更新,以领域来驱动后续功能开发,使其变得可控,避免了软件架构设计模式变成“大泥球”模式。

鉴于作者经验有限,对领域驱动的理解难免会有不足之处,欢迎大家共同探讨,共同提高。

参考文献

[1] Scott Millett 等著, 蒲成 译; 领域驱动设计模式、原理与实践(Patterns, Principles, and Practices of Domain-Driven Design); 清华大学出版社, 2016

[2] Evic Evans 著, 赵俐 等译; 领域驱动设计:软件核心复杂性应对之道; 人民邮电出版社, 2010

[3] 领域驱动设计在互联网业务开发中的实践

[4] 阿里技术专家详解DDD系列 第二讲 - 应用架构

[5] 基于 DDD 思想的酒店报价重构实践

[6] DDD(领域驱动设计)总结

[7] 谈谈MVC模式

[8] 阿里技术专家详解DDD系列 第三讲 - Repository模式

[9] 领域驱动设计详解:是什么、为什么、怎么做?

[10] 领域建模在有赞客户领域的实践

[11] DDD分层架构的三种模式

0 人点赞