转转价格系统DDD实践

5,082 阅读10分钟

客观的理解DDD

DDD,即领域驱动设计,不仅带给我们一套新的概念,还提供了一套全新的设计思路,应用在构建大型复杂软件系统之上。

相对于DDD,我们使用的传统的设计思路,常被称为数据驱动设计,常被应用于中小型的项目。互联网的项目,往往是快速迭代,起初一个小项目,慢慢会演化为一个中大型的项目,在演化过程中,很容易出现架构腐化,内部各模块的边界不清晰,耦合严重,所谓牵一发而动全身,而此时,往往会重构的方法来解决。然而重构毕竟是个耗时费力,对业务而言收益不直观的事情,所以人们常常想,在长期的迭代中,如果能有一些方式能够让系统一直保持稳定的架构,清晰的逻辑,那么就能够节省很多成本,甚至可以节省撰写文档的成本(代码即文档)。

对于问题的解决方案往往很多,Eric Evans为我们总结了一套DDD理论。其实解决问题的思路,不仅限于DDD,或者说,我们所理解的DDD,可以不同于Eric Evans所总结的那样,只要是为解决问题行之有效的方式,都是值得推崇的。

DDD与传统的设计思路,二者其实没必要把各个点拿出来对比,其各有各的优势和劣势,一概的追捧和否定某一个,都是不客观的。我们解决不同类型的问题,会用不同的手段。在解决一个问题时,尽可能用最简单的思路。对于那种不会进行过多迭代的小型系统而言,没必要使用DDD(或者DDD全套),它反而会给你带来更多的问题,保持它的精简是很有必要的。而大型的系统,或持续朝着大型系统方向迭代的系统,一些在小型系统中不容易凸显的问题,就会慢慢被放大,凸显(比如逻辑臃肿,模块边界不清晰)。使用简单的设计思路,往往无法约束这些问题的扩大。这个时候,就需要更多的细节规范来抑制问题产生,发展。因此也可能会产生更多的概念,这也是DDD概念很多的原因。

对于DDD的众多概念,学习成本会变高,更是为落地增加了很多困难。一个项目下来,也许会伴随着一个担忧,那就是“一不小心就回到了传统方式上”,最后觉得自己做了个“四不像”。其实我们要做的DDD,并不是说我们要完全按照Eric Evans所总结的那样把所有内容都按照理论概念来实践,它只能起到指导作用,具体的情况,要结合自身公司,项目组成员,业务情况等因素来决定。这就好比是,马克思主义理论,在中国的实践,要结合中国的具体国情才可以。为了DDD而DDD,是没有意义的。始终关注我们的目的,是个十分重要的原则,目的也决定了我们遇到一些细节技术的抉择时如果做出取舍。

DDD在转转价格系统的实践过程

业务理解

转转价格系统(估价器),是个十分复杂的系统,它承载着转转回收以及众多卖场的价格计算和等级计算能力,同时提供价格实验能力。由于系统的复杂性高,以下介绍的内容,是系统的一个简版。

价格系统估价的大致流程是,估价器拿到验机报告后,首先进行验机报告的解析,然后将验机报告转换为估价项。然后根据请求的参数,找到合适的报价流程,在报价流程中执行其所配置的报价方式进行价格计算。

因为计算价格是区分多种不同的场景的,比如如转转C2B线上回收,转转门店回收,转转B2C卖场,转转门店零售卖场等。不同的场景需要关联的参数配置和报价方式都有所不同,所以我们这里抽象出一个概念“场景”,用来关联这些参数配置和报价方式。

对价格的计算,一定是建立在客观的商品各项情况,成色等基础上的。转转作为专业的二手交易平台,是能够产出专业的验机报告的。那么对价格的计算,就依赖验机报告给出的数据。

转转的验机报告中的数据,都是验机工程师填写的比较专业的,详细的,丰富的验机项,如果直接拿给运营人员对其进行价格的维护,会有较大的维护成本。所以,需要将验机报告的项,通过一种关系,转换为人工易维护的估价项,后续运营人员对价格进行维护时,就比较方便高效。这一步就是估价项转换。

估价项转换好后,就要执行估价流程了。估价流程封装了某一个或某几个估价的方式或估价的算法,我们暂且叫它估价方式,如人工价格表,等级价格,算法模型等。除此之外,估价流程还封装了不同估价方式的执行过程,如B2C零售卖场优先使用算法模型,如果无法给出价格,则使用人工价格表。最后根据这些逻辑,输出一个价格。

在开始实践之前,对于业务的理解十分重要,对于各个概念要做到统一语言,也就是消除团队成员之间的理解的偏差。我们的目标,就是构建一个架构良好,可测试性强,学习成本低,易于扩展和维护的系统。

战略设计

通过对业务逻辑的理解,我们可以得到以下的子域划分:

  • 场景子域,通用域,为其他域提供配置参数。
  • 验机报告解析子域,支撑域,为估价提供前置的数据支撑。
  • 估价项转换子域,支撑域,也是为估价提供前置的数据支撑。
  • 报价流程子域,支撑域,为报价提供流程的封装。
  • 报价方式子域,核心域,提供报价的计算方式,这是业务的核心,需要花费主要的精力。

战术设计

这一步我们对限界上下文做设计。这里每一个限界上下文对应一个子域,得到的领域模型详细设计。例如下图为验机报告解析上下文,绿色代表实体,黄色代表值对象。此上下文依赖外部的验机报告服务,使用防腐层来做适配。该上下文输出解析后的验机报告。对于验机报告,每一个商品有唯一一份,存在唯一标识,所以属于一个实体。验机报告中,应该包含商品的品类,品牌,型号,以及它的验机项,这三者都属于值对象,被聚合为验机报告实体。

上下文集成

上下文集成可以简单理解为,每一个上下文都是什么样的关系。从概念上讲,上下文集成关系有很多种:

  • 分离方式 separate way
  • 客户-供应 customer/supplier
  • 发布-订阅 publisher/subscriber
  • 开放主机服务和发布语言 open host service, publicshed language
  • 防腐层 anti corruption layer
  • 尊奉者 conformist
  • 共享内核 shared kernel
  • 合作者 partnership

这其中很多概念应用较少,引入过多的概念对于我们解决问题可能没有太大意义,这里只采用“合作者”,“开放主机服务和发布语言”和“防腐层”。“合作者”能够表示出本系统中两个限界上线文的依赖关系,后两者能够表示出,与外部系统的限界上线文的依赖关系。其中“PS”代表合作关系,“U”代表上游,“D”代表下游,这里的上下游和依赖方向正好相反。“ACL”代表防腐层,与“OHS/PL”开发主机服务和发布语言搭配使用。

架构设计

架构理论发展至今,各种新型的架构不断出现。除了我们常用的分层架构外,还有整洁架构,六边形架构,洋葱架构,CQRS架构等。各有特色,各有利弊。出于学习成本,团队成员经验的角度考虑,这里采用松散型(可跨层调用)的分层架构。

api层作接口定义层,被其他服务所依赖。application层作应用服务层,实现api层的接口。domain作领域层,实现核心的业务逻辑。infrastructure作基础数据层,为上层提供数据接口和外部调用的防腐。

除了核心的估价业务逻辑外,系统还包含后台维护的功能,这一部分多为数据的增删查改操作,逻辑简单,可以不进入domain层,由application层直接从infrastructure获取。

工程结构和架构一致,在此基础上,每一层可能都会依赖相同的一些常量,工具,和基本算法,这些内容可以单独封装为一个common的包。于是得到如下工程结构:

evaluation_sys
 - api
 - application
 - domain
 - infrastructure
 - common

业务逻辑实现

在使用传统的mvc模式下,我们往往使用三层架构,即controller,service,dao或者其类似的方式。这种架构会把所有的业务逻辑堆积在service之下,领域实体只做数据传输,没有行为。随着项目的迭代,可能出现service臃肿的情况,大量业务逻辑,把service搞成一个胖子,业务逻辑就会变得混乱不堪,理解和维护成本极大。

然而我们希望代码不仅仅是用来执行的,更是用来阅读的,表达的业务逻辑给人一目了然,一看就懂,是我们追求的。好的代码结构,就是要把各个业务逻辑按一定原则拆分开,再用一种机制将它们很好的组织在一起。按照DDD的思想,应用服务编排领域服务,用以描述业务主干逻辑,每个领域的细节逻辑由领域服务封装实现,这就把逻辑做了鲜明的分离。

如价格系统中,估价的应用服务中是这样实现的:

public EvaluateResult eval(Scenario scenario, EvaluateContext context) {
    // 获得验机报告
    QcReport report = qcReportService.parseReport(context.getQcCode());
    // 估价项转换
    EvaluateItems evaluateItems = evaluateItemsService.transfer(report, scenario);
    // 执行估价流程
    EvaluateResult result = evaluateProcessService.evaluate(scenario, context, evaluateItems);
    // 返回结果
    return presult;
}

其次DDD提倡领域对象拥有行为,这不仅仅是更加符合面向对象所讲的,让对象贴近客观世界,而且又一次的划分了逻辑,让领域服务中的主干逻辑和细节的逻辑实现做了鲜明的分离。如估价流程的领域服务是这样实现的:

public class EvaluateProcessService {
    public EvaluateResult evaluate(Scenario scenario, EvaluateContext context, EvaluateItems evaluateItems) {
        // 获取估价方式(或估价算法)
        List<EvaluateAlgorithm> algorithms = EvaluateAlgorithmFactory.create(scenario);
        // 获得估价流程
        EvaluateProcess process = EvaluateProcessFactory.create(scenario, context, algorithms, evaluateItems);
        // 执行估价流程
        reutrn process.evaluate();
    }
    // ...
}

其中一种估价流程的实现是这样的:

/**
 * 取最高价的估价流程实现
 */
public class MaxPriceEvaluateProcess implements EvaluateProcess {
    // 对象属性
    private Scenario scenario;
    private EvaluateProcess context;
    private List<EvaluateAlgorithm> algorithms;
    private EvaluateItems evaluateItems;
    

    /**
     * 对象行为,计算价格
     */
    public EvaluateResult evaluate() {
        long maxPrice = 0;
        // 遍历算法,分别计算价格
        for (EvaluateAlgorithm algorithm : algorithms) {
            long price = algorithm.calculate(context, evaluateItems);
            if (maxPrice < price) {
                maxPrice = price;
            }
        }
        return new EvaluateResult(maxPrice);
    }

    // ...
}

经过一级一级的逻辑拆分和组织,最终让代码有极强的可读性,更加符合人的思考问题的方式,让维护,学习更加容易。

写在最后

DDD实践,需要花费大量的精力去学习理论,概念和前人实践的案例,过程中还会出现很多的问题,很多的抉择,能够得到一个满意的结果,绝不是一件轻松的事情。所以,再次强调,一定要明确自己的目的,我们不应该怀着赶时髦的心态去实践它,应理性的思考是否真正的需要它。 当然对于DDD的实践,书中所讲述的思想,案例,往往不是全部适用于你的项目,哪些适合自己,哪些可以解决自己的问题,才是我们应该思考的。(全文完)

转转研发中心及业界小伙伴们的技术学习交流平台,定期分享一线的实战经验及业界前沿的技术话题。 关注公众号「转转技术」(综合性)、「大转转FE」(专注于FE)、「转转QA」(专注于QA),更多干货实践,欢迎交流分享~