您的位置:  首页 > 技术杂谈 > 正文

长文多图:结合DDD讲清楚编写技术方案的七大维度

2022-01-18 15:00 https://my.oschina.net/u/4850089/blog/5401788 JAVA前线 次阅读 条评论

欢迎大家关注公众号「JAVA前线」查看更多精彩分享文章,主要包括源码分析、实际应用、架构思维、职场分享、产品思考等等,同时欢迎大家加我个人微信「java_front」一起交流学习

 

1 为什么要写技术方案

回顾软件开发的历史进程,我们可以将其分为程序设计时代、程序系统时代和软件工程时代三大历史阶段。

在程序设计时代(1946-1956),软件开发主要依赖于个人编程技巧,技术文档只要存在个人开发者的大脑即可,因为没有沟通和协作需要,编写技术文档也不具有紧迫性。

在程序系统时代(1956-1968),计算机性能显著提升,应用范围和规模逐步扩大,以至于依靠个人无法完成软件的开发,所以出现了团队合作。在早期团队合作过程中,开发者仍然保持了早期各自为战的开发习惯,即使出现了一些方法论雏形,也无法从根本上控制沟通和协作的巨大成本,软件危机就此出现。1968年国际学术会议提出了软件危机和软件工程的概念。

软件危机的定义是落后的软件生产方式无法满足迅速增长的计算机软件需求,从而导致开发与维护过程中出现一系列严重问题的现象。软件的工程定义是建立并使用完善的工程化原则,以较经济的手段获得能在实际机器上有效运行的可靠软件的一系列方法

从此软件开发进入工程化阶段,也应运而生了大量开发方法论和开发模型。其中标准和完善的文档是软件工程重要组成部分,可以很大程度上减少沟通和协作成本,而技术方案又是技术文档重要组成部分。
 

2 技术方案要体现什么

软件系统生命周期包括定义、开发、运维、消亡这四大阶段。定义阶段包括定义问题、可行性研究和需求分析。开发阶段包括概要设计、详细设计、编码和测试。运维阶段包括更正性维护、适应性维护、预防性维护和完善性维护。消亡阶段包括系统报废、优雅下线和遗留系统。

 

 

生命周期每个阶段固然有各自的重要性,但是开发者更应该关注定义阶段与开发阶段。定义阶段需要解决为什么开发(why)、需求是什么(what)两个问题,开发阶段需要解决怎么设计,怎么编码,怎么测试(how)三个问题。

技术方案是否需要体现定义和开发的所有子阶段?我认为也无必要。问题定义和可行性研究主要由产品经理负责,测试阶段主要由测试人员负责,开发者可以关注但不是必须体现在技术方案。我认为技术方案必须要体现需求分析、概要设计、详细设计、编码四个子阶段。
 

3 七大维度

我认为一份完整技术方案应该至少具有七大维度,每个维度描述系统的一个侧面,组合在一起最终描绘出整个系统,这些维度分别是:

四色分领域
用例看功能
流程三剑客
领域与数据
纵横做设计
分层看架构
接口看对接

本文我们分析一个足球运动员信息管理系统,这个系统我们可能也都没有做过,正好一起分析这个系统。需要说明本文着重介绍方法论的落地,业务细节难以面面俱到。

 

3.1 四色分领域

3.1.1 流程梳理

首先梳理业务流程,这里有两个问题需要考虑,第一个问题是从什么视角去梳理?因为不同的人看到的流程是不一样的。答案是取决于系统需要解决什么问题,因为我们要管理运动员从转会到上场比赛整条链路信息,所以从运动员视角出发是一个合适的选择。

第二个问题是对业务不熟悉怎么办?因为我们不是体育和运动专家,并不清楚整条链路的业务细节。答案是梳理流程时一定要有业务专家在场,因为没有真实业务细节,无法领域驱动设计。同理在互联网梳理复杂业务流程时,一定要有对相关业务熟悉的产品经理或者运营一起参与。

假设足球业务专家梳理出了业务流程,运动员提出转会,协商一致后到新俱乐部体检,体检通过就进行签约。进入新俱乐部后进行训练,训练指标达标后上场比赛,赛后参加新闻发布会。实际流程会复杂很多,本文还是着重讲解方法论。

 

 

3.1.2 四色建模

(1) 时标对象

四色建模第一种颜色是红色,表示时标对象。时标对象是四色建模最重要的对象,可以理解为核心业务单据。在业务过程中一定要对关键业务留下单据,通过这些单据可以追溯整个业务流程。

时标对象具有两个特点:第一是事实不可变性,记录了过去某个时间点或时间段内发生的事实。第二是责任可追溯性,记录了管理者关注的信息。现在我们分析本系统时标对象有哪些,需要留下哪些核心业务单据。

转会对应转会单据,体检对应体检单据,签合同对应合同单据,训练对应训练指标单据,比赛对应比赛指标单据,新闻发布会对应采访单据。根据分析绘制如下时标对象:

 

 

(2) 参与方、地、物

这三类对象在四色建模中用绿色表示,我们以电商场景为例进行说明。用户支付购买商家的商品时,用户和商家是参与方。物流系统发货时配送单据需要有配送地址对象,地址对象就是地。订单需要商品对象,物流配送需要有货品,商品和货品就是物。

我们分析本例可以知道参与方包含总经理、队医、教练、球迷、记者,地包含训练地址、比赛地址、采访地址,物包含签名球衣和签名足球:

 

 

(3) 角色对象

在四色建模中用黄色表示,这类对象表示参与方、地、物以什么角色参与到业务流程:

 

 

(4) 描述对象

我们可以为对象增加相关描述信息,在四色建模中用蓝色表示:

 

 

3.1.3 划分领域

在四色建模过程中我们体会到时标对象是最重要的对象,因为其承载了业务系统核心单据。在划分领域时我们同样离不开时标对象,通过收敛相关时标对象划分领域。

 

 

3.1.4 领域事件

当业务系统发生一件事情时,如果本领域或其它领域有后续动作跟进,那么我们把这件事情称为领域事件,这个事件需要被感知。

例如球员比赛受伤,这是比赛子域事件,但是医疗和训练子域是需要感知的,那么比赛子域就发出一个事件,医疗和训练子域会订阅。球员比赛取得进球,这也是比赛子域事件,但是训练和合同子域也会关注这个事件,所以比赛子域也会发出一个比赛进球事件,训练和合同子域会订阅。

通过事件交互有一个问题需要注意,通过事件订阅实现业务只能采用最终一致性,需要放弃强一致性,可能会引入新的复杂度需要权衡。

 

 

3.2 用例看功能

目前为止领域已经确定了,大领域已经拆分成了小领域,我们已经不再束手无策,而是可以对小领域进行用例分析了。用例图由参与者和用例组成,目的是回答这样一个问题:什么人使用系统干什么事。

下图表示在比赛领域,运动员视角(什么人)使用系统进行进球统计,助攻统计,犯规统计,跑动距离统计,比赛评分统计,传球成功率统计,受伤统计(干什么事),同理也可以选择四色建模中其它参与者视角绘制用例图。

 

 

include关键字表示包含关系。例如比赛是基用例,包含了进球统计,助攻统计,犯规统计,跑动距离统计,比赛评分统计,传球成功率统计,受伤统计七个子用例。包含关系表示法有两个优点:第一是可以清晰地组织子用例,第二是有利于子用例复用,例如主教练视角用例图也包含比赛评分,那么就可以直接指向比赛评分子用例。

extend关键字表示扩展关系。例如点球统计是进球统计的扩展,因为不一定可以获得点球,所以点球统计即使不存在,也不会影响进球统计功能。黄牌统计、红牌统计是犯规统计的扩展,因为普通犯规不会获得红黄牌,所以红黄牌统计不存在,也不会影响犯规统计功能。

用例图不关心实现细节,而是从外部视角描述系统功能,即使不了解实现细节的人,通过看用例图也可以快速了解系统功能,这个特性规定了用例图不宜过于复杂,能够说明核心功能即可。
 

3.3 流程三剑客

用例图是从外部视角描述系统,但是分析系统总是要深入系统内部的,其中流程视图就是描述系统内如何流转的视图。

活动图、序列图、状态机图是流程视图中最重要的三种视图,我们称为流程三剑客。三者侧重点有所不同:活动图侧重于逻辑分支,顺序图侧重于交互,状态机图侧重于状态流转。

 

3.3.1 活动图

活动图适合描述复杂逻辑分支,设想这样一种业务场景,球队需要选拔一名球员成为足球先生,选拔标准如下:前场、中场、后场、门将各选出一名候选球员。前场队员依次比较进球数、助攻数,中场队员依次比较助攻数、抢断数,后场队员依次比较解围数、抢断数,门将依次比较扑救数、扑点数,如果所有指标均相同则抽签。每个位置有人选之后,全体教练组投票,如果投票数相同则抽签。

业界流传着一句话:一图胜千言,其中一个重要原因是文字是线性的,所以表达逻辑分支能力不如流程视图,而在流程视图中表达逻辑分支能力最强正是活动图。

 

 

3.3.2 顺序图

顺序图侧重于交互,适合按照时间顺序体现一个业务流程中交互细节,但是顺序图并不擅长体现复杂逻辑分支。

如果某个逻辑分支特别重要,可以选择再画一个顺序图。例如支付流程中有支付成功正常流程,也有支付失败异常流程,这两个流程都非常重要,所以可以用两张顺序图体现。回到本文实例,我们可以通过顺序图体现球员从提出转会到比赛全流程。

 

 

3.3.3 状态机图

假设一条数据有ABC三种状态,从正常业务角度来看,状态只能从A流转到B,再从B流转到C,不能乱序也不可逆。但是可能出现这种异常情况:数据当前状态为A,接收异步消息更改状态,B消息由于延时晚于C消息,最终导致状态先改为C再改为B,那么此时状态就是错误的。

状态机图侧重于状态流转,说明了哪些状态之间可以相互流转,在实际开发中再结合状态机代码模式,可以解决上述状态异常情况。回到本文实例,我们可以通过状态机图表示球员从提出转会到签约整个状态流程。

 

 

3.4 领域与数据

上述章节从功能层面和流程层面进行了系统分析,现在从数据层分析系统,我们首先对比两组概念:值对象与实体,领域对象与数据对象。

实体是具有唯一标识的对象,唯一标识会伴随实体对象整个生命周期并且不可变更。值对象本质上是属性的集合,没有唯一标识。

领域对象与数据对象一个重要的区别是值对象存储方式。领域对象在包含值对象的同时也保留了值对象的业务含义,而数据对象可以使用更加松散的结构保存值对象,简化数据库设计。

现在我们需要管理足球运动员基本信息和比赛数据,对应领域模型和数据模型应该如何设计?姓名、身高、体重是一名运动员本质属性,加上唯一编号可以对应实体对象。跑动距离,传球成功率,进球数是运动员比赛表现,这些属性的集合可以对应值对象。

 

 

我们根据图示编写领域对象与数据对象代码:

// 数据对象
public class FootballPlayerDO {
    private Long id;
    private String name;
    private Integer height;
    private Integer weight;
    private String gamePerformance;
}

// 领域对象
public class FootballPlayerDMO {
    private Long id;
    private String name;
    private Integer height;
    private Integer weight;
    private GamePerformanceVO gamePerformanceVO;
}

public class GamePerformanceVO {
    private Double runDistance;
    private Double passSuccess;
    private Integer scoreNum;
}

为什么要采用JSON存储值对象?因为脚本化是一种拓展灵活性的方式,脚本化不仅指使用groovy、QLExpress脚本增强系统灵活性,还包括松散可扩展的数据结构。数据模型抽象出了姓名、身高、体重这些基本属性,对于频繁变化的比赛表现属性,这些属性值可能经常变化,甚至属性本身也是经常变化,可能会加上射门次数,突破次数等,所以采用松散结构进行存储。

如果需要根据JSON结构中KEY进行检索,例如查询进球数大于5的球员,这也不是没有办法。我们可以将MySQL表中数据平铺到ES中,一条数据根据JSON KEY平铺变成为多条数据,这样就可以进行检索了。
 

3.5 纵横做设计

复杂业务之所以复杂,一个重要原因是涉及角色或者类型较多,很难平铺直叙地进行设计,所以我们需要增加分析维度。其中最常见的是增加横向和纵向两个维度,本文也着重讨论两个维度。总体而言横向扩展的是思考广度,纵向扩展的是思考深度,对应到系统设计而言可以总结为:纵向做隔离,横向做编排。

我们首先分析一个下单场景。当前有ABC三种订单类型:A订单价格9折,物流最大重量不能超过9公斤,不支持退款。B订单价格8折,物流最大重量不能超过8公斤,支持退款。C订单价格7折,物流最大重量不能超过7公斤,支持退款。按照需求字面含义平铺直叙地写代码也并不难:

public class OrderServiceImpl implements OrderService {

    @Resource
    private OrderMapper orderMapper;

    @Override
    public void createOrder(OrderBO orderBO) {
        if (null == orderBO) {
            throw new RuntimeException("参数异常");
        }
        if (OrderTypeEnum.isNotValid(orderBO.getType())) {
            throw new RuntimeException("参数异常");
        }
        // A类型订单
        if (OrderTypeEnum.A_TYPE.getCode().equals(orderBO.getType())) {
            orderBO.setPrice(orderBO.getPrice() * 0.9);
            if (orderBO.getWeight() > 9) {
                throw new RuntimeException("超过物流最大重量");
            }
            orderBO.setRefundSupport(Boolean.FALSE);
        }
        // B类型订单
        else if (OrderTypeEnum.B_TYPE.getCode().equals(orderBO.getType())) {
            orderBO.setPrice(orderBO.getPrice() * 0.8);
            if (orderBO.getWeight() > 8) {
                throw new RuntimeException("超过物流最大重量");
            }
            orderBO.setRefundSupport(Boolean.TRUE);
        }
        // C类型订单
        else if (OrderTypeEnum.C_TYPE.getCode().equals(orderBO.getType())) {
            orderBO.setPrice(orderBO.getPrice() * 0.7);
            if (orderBO.getWeight() > 7) {
                throw new RuntimeException("超过物流最大重量");
            }
            orderBO.setRefundSupport(Boolean.TRUE);
        }
        // 保存数据
        OrderDO orderDO = new OrderDO();
        BeanUtils.copyProperties(orderBO, orderDO);
        orderMapper.insert(orderDO);
    }
}

上述代码从功能上完全可以实现业务需求,但是程序员不仅要满足功能,还需要思考代码的可维护性。如果新增一种订单类型,或者新增一个订单属性处理逻辑,那么我们就要在上述逻辑中新增代码,如果处理不慎就会影响原有逻辑。

为了避免牵一发而动全身这种情况,设计模式中的开闭原则要求我们面向新增开放,面向修改关闭,我认为这是设计模式中最重要的一条原则。

需求变化通过扩展,而不是通过修改已有代码实现,这样就保证代码稳定性。扩展也不是随意扩展,因为事先定义了算法,扩展也是根据算法扩展,用抽象构建框架,用实现扩展细节。标准意义的二十三种设计模式说到底最终都是在遵循开闭原则。

如何改变平铺直叙的思考方式?这就要为问题分析加上纵向和横向两个维度,我选择使用分析矩阵方法,其中纵向表示策略,横向表示场景:

 

 

3.5.1 纵向做隔离

纵向维度表示策略,不同策略在逻辑上和业务上应该是隔离的,本实例包括优惠策略、物流策略和退款策略,策略作为抽象,不同订单类型去扩展这个抽象,策略模式非常适合这种场景。本文详细分析优惠策略,物流策略和退款策略同理。

// 优惠策略
public interface DiscountStrategy {
    public void discount(OrderBO orderBO);
}

// A类型优惠策略
@Component
public class TypeADiscountStrategy implements DiscountStrategy {

    @Override
    public void discount(OrderBO orderBO) {
        orderBO.setPrice(orderBO.getPrice() * 0.9);
    }
}

// B类型优惠策略
@Component
public class TypeBDiscountStrategy implements DiscountStrategy {

    @Override
    public void discount(OrderBO orderBO) {
        orderBO.setPrice(orderBO.getPrice() * 0.8);
    }
}

// C类型优惠策略
@Component
public class TypeCDiscountStrategy implements DiscountStrategy {

    @Override
    public void discount(OrderBO orderBO) {
        orderBO.setPrice(orderBO.getPrice() * 0.7);
    }
}

// 优惠策略工厂
@Component
public class DiscountStrategyFactory implements InitializingBean {
    private Map<String, DiscountStrategy> strategyMap = new HashMap<>();

    @Resource
    private TypeADiscountStrategy typeADiscountStrategy;
    @Resource
    private TypeBDiscountStrategy typeBDiscountStrategy;
    @Resource
    private TypeCDiscountStrategy typeCDiscountStrategy;

    public DiscountStrategy getStrategy(String type) {
        return strategyMap.get(type);
    }

    @Override
    public void afterPropertiesSet() throws Exception {
        strategyMap.put(OrderTypeEnum.A_TYPE.getCode(), typeADiscountStrategy);
        strategyMap.put(OrderTypeEnum.B_TYPE.getCode(), typeBDiscountStrategy);
        strategyMap.put(OrderTypeEnum.C_TYPE.getCode(), typeCDiscountStrategy);
    }
}

// 优惠策略执行
@Component
public class DiscountStrategyExecutor {
    private DiscountStrategyFactory discountStrategyFactory;

    public void discount(OrderBO orderBO) {
        DiscountStrategy discountStrategy = discountStrategyFactory.getStrategy(orderBO.getType());
        if (null == discountStrategy) {
            throw new RuntimeException("无优惠策略");
        }
        discountStrategy.discount(orderBO);
    }
}

 

3.5.2 横向做编排

横向维度表示场景,一种订单类型在广义上可以认为是一种业务场景,在场景中将独立的策略进行串联,模板方法设计模式适用于这种场景。

模板方法模式一般使用抽象类定义算法骨架,同时定义一些抽象方法,这些抽象方法延迟到子类实现,这样子类不仅遵守了算法骨架约定,也实现了自己的算法。既保证了规约也兼顾灵活性,这就是用抽象构建框架,用实现扩展细节。

// 创建订单服务
public interface CreateOrderService {
    public void createOrder(OrderBO orderBO);
}

// 抽象创建订单流程
public abstract class AbstractCreateOrderFlow {

    @Resource
    private OrderMapper orderMapper;

    public void createOrder(OrderBO orderBO) {
        // 参数校验
        if (null == orderBO) {
            throw new RuntimeException("参数异常");
        }
        if (OrderTypeEnum.isNotValid(orderBO.getType())) {
            throw new RuntimeException("参数异常");
        }
        // 计算优惠
        discount(orderBO);
        // 计算重量
        weighing(orderBO);
        // 退款支持
        supportRefund(orderBO);
        // 保存数据
        OrderDO orderDO = new OrderDO();
        BeanUtils.copyProperties(orderBO, orderDO);
        orderMapper.insert(orderDO);
    }

    public abstract void discount(OrderBO orderBO);

    public abstract void weighing(OrderBO orderBO);

    public abstract void supportRefund(OrderBO orderBO);
}

// 实现创建订单流程
@Service
public class CreateOrderFlow extends AbstractCreateOrderFlow {

    @Resource
    private DiscountStrategyExecutor discountStrategyExecutor;
    @Resource
    private ExpressStrategyExecutor expressStrategyExecutor;
    @Resource
    private RefundStrategyExecutor refundStrategyExecutor;

    @Override
    public void discount(OrderBO orderBO) {
        discountStrategyExecutor.discount(orderBO);
    }

    @Override
    public void weighing(OrderBO orderBO) {
        expressStrategyExecutor.weighing(orderBO);
    }

    @Override
    public void supportRefund(OrderBO orderBO) {
        refundStrategyExecutor.supportRefund(orderBO);
    }
}

 

3.5.3 综合应用

上述实例业务和代码并不复杂,其实复杂业务场景也不过是简单场景的叠加、组合和交织,无外乎也是通过纵向做隔离、横向做编排寻求答案。

 

 

纵向维度抽象出能力池这个概念,能力池中包含许多能力,不同的能力按照不同业务维度聚合,例如优惠能力池,物流能力池,退款能力池。我们可以看到两种程度的隔离性,能力池之间相互隔离,能力之间也相互隔离。

横向维度将能力从能力池选出来,按照业务需求串联在一起,形成不同业务流程。因为能力可以任意组合,所以体现了很强的灵活性。除此之外,不同能力既可以串行执行,如果不同能力之间没有依赖关系,也可以如同流程Y一样并行执行,提升执行效率。

此时我们回到本文足球运动员管理系统,如果采用纵向和横向思维分析3.3.1足球先生选拔业务场景可以得到下图:

 

 

纵向隔离出进攻能力池,防守能力池,门将能力池,横向编排出前场、中场、后场、门将四个流程,在不同流程中可以任意从能力池中选择能力进行组合,而不是编写冗长的判断逻辑,显著提升了代码可扩展性。

 

3.6 分层看架构

系统架构总体而言分为两个层次,第一种层次是指本项目在整个公司位于哪一层次。持久层、缓存层、中间件、业务中台、服务层、网关层、客户端和代理层是常见的分层架构,大多数情况下业务需求最终会体现在服务层,不同的业务领域对应不同的微服务。

 

 

第二种层次是指本项目内部代码的组织方式,一般可以分为接口层,访问层,业务层,领域层,外部访问层和基础层。

(1) api

接口层:提供面向外部接口声明和DTO

(2) controller

访问层:提供HTTP访问入口

(3) service

业务层:提供BO对象,领域层和业务层都包含业务,但是用途不同。业务层可以组合不同领域业务,并且可以增加流控、监控、日志、权限控制切面,相较于领域层更为丰富

(4) domain

领域层:提供DMO、VO、事件、DO和数据访问,核心是根据领域进行分包,领域内高内聚,领域间低耦合

(5) dependency

外部访问层:在这个模块中调用外部RPC服务,解析返回码和返回数据

(6) infrastructure

基础层:包含通用基础功能,例如基础工具,缓存工具,打印日志,消息发送

 

 

本文仅展开领域层进行分析。领域层核心是按照领域进行分包,并且提供DMO、VO、事件、DO和数据访问,领域内高内聚,领域间低耦合。

 

 

3.7 接口看对接

一个接口代码编写完成后,那么这个接口如何调用,输入和输出参数是什么,这些问题需要在接口文档中得到回答。

接口文档生成有两种方式,第一种方式是自动生成,例如使用Swagger框架,第二种方式是手工生成。自动生成的优点是代码即文档,还具有调试功能,在公司内部进行联调时非常方便。但是如果接口是提供给外部第三方使用,那么还是需要手工编写接口文档。一个接口核心描述无外乎接口名称、接口说明、输入参数、输出参数,其它信息根据需要再增加。

 

 

4 文章总结

本文通过一个业务实例介绍了技术方案的七大维度:四色分领域、用例看功能、流程三剑客、领域与数据、纵横做设计、分层看架构、接口看对接。每个维度描述系统的一个侧面,组合在一起最终描绘出整个系统。

在实际开发中如果需求不复杂,那么也不是七个维度都要体现,而是根据实际情况取舍,能够把方案说清楚即可,希望本文对大家有所帮助。

欢迎大家关注公众号「JAVA前线」查看更多精彩分享文章,主要包括源码分析、实际应用、架构思维、职场分享、产品思考等等,同时欢迎大家加我个人微信「java_front」一起交流学习

展开阅读全文
  • 0
    感动
  • 0
    路过
  • 0
    高兴
  • 0
    难过
  • 0
    搞笑
  • 0
    无聊
  • 0
    愤怒
  • 0
    同情
热度排行
友情链接