• 长文多图一步步讲清楚:DDD理论、建模与代码实现全流程



    1 六个问题

    1.1 为什么使用DDD

    DDD方法论核心是将问题不断分解,把大问题分解为小问题,大业务分解小领域,简而言之就是分而治之,各个击破。

    分而治之是指直接面对大业务我们无从下手,需要按照一定方法进行分解,分解为高内聚的小领域,使得业务有边界清晰,而这些小领域是我们有能力处理的,这就是领域驱动设计的核心。

    各个击破是指当问题被拆分为小领域后,因为小领域业务内聚,其子领域高度相关,我们在技术维度可以对其进行详细设计,在管理维度可以按照领域对项目进行分工。需要指出DDD不能替代详细设计,DDD是为了更清晰地行详细设计。

    在微服务流行的互联网行业,当业务逐渐复杂时,技术人员需要解决如何划分微服务边界的问题,DDD这种清晰化业务边界的特性正好可以用来解决这个问题。


    1.2 方法与目标

    我们的目标是将业务划分清晰的边界,而DDD是达成目标的有效方法之一,这一点是需要格外注意的。DDD是方法不是目标,不需要为了使用而使用。例如业务模型比较简单可以很容易分析的业务就不需要使用DDD,还有一些目标是快速验证类型的项目,追求短平快,前期可能也不需要使用领域驱动设计。


    1.3 整体与局部

    领域可以划分多个子领域,子域可以再划分多个子子域,限界上下文本质上也是一种子子域,那么在业务分解时一个业务模块到底是领域、子域还是子子域?

    我认为不用纠结在这个问题,因为这取决于看待这个模块的角度。你认为整体可能是别人的局部,你认为的局部可能是别人的整体,叫什么名字不重要,最重要的是按照高内聚的原则将业务高度相关的模块收敛在一起。


    1.4 粒度粗与细

    业务划分粒度的粗细并没有统一的标准,还是要根据业务需要、开发资源、技术实力等因素综合考量。例如微服务拆分过细反而会增加开发、部署和维护的复杂度,但是拆分过粗可能会导致大量业务高度耦合,开发部署起来是挺快的,但是缺失可维护性和可扩展性,这需要根据实际情况做出权衡。


    1.5 领域与数据

    领域对象与数据对象一个重要的区别是值对象存储方式。在讨论领域对象和数据对象之前,我们首先讨论实体和值对象这一组概念。实体是具有唯一标识的对象,而唯一标识会伴随实体对象整个生命周期并且不可变更。值对象本质上是属性的集合,并没有唯一标识。

    领域对象在包含值对象的同时也保留了值对象的业务含义,而数据对象可以使用更加松散的结构保存值对象,简化数据库设计。

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

    值对象在数据对象中可以用松散的数据结构进行存储,而值对象在领域对象中需要保留其业务含义:


    01 实体与值对象_足球.jpg


    1.6 抽象与灵活

    抽象的核心是找相同,对不同事物提取公因式。实现的核心是找不同,扩展各自的属性和特点。例如模板方法设计模式正是用抽象构建框架,用实现扩展细节。

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


    2 基本概念

    2.1 领域、子域与限界上下文

    这三个词虽然不同但是实际上都是在描述范围这个概念。正如牛顿三定律有其适用范围,程序中变量有其作用域一样,DDD方法论也会将整体业务拆分成不同范围,在同一个范围内进行才可以进行分析和处理。

    限界上下文(Bounded contenxt)比较难理解可以从四个维度分析:

    第一个维度是限界上下文本身含义。限界表示了规定一个边界,上下文表示在这个边界内使用相同语义对象。例如goods这个词,在商品边界内被称为商品,但是快递边界内被称为货物。

    第二个维度是子域与限界上下文关系。子域可以对应一个,也可以对应多个限界上下文。如果子域划分足够小,那么就是限界上下文。如果子域可以再细分,那么可以划分多个限界上下文。

    第三维度是服务如何划分。子域和限界上下文都可以作为微服务,这里微服务是指独立部署的程序进程,具体拆分到什么维度是根据业务需要、开发资源、维护成本、技术实力等因素综合考量。

    第四个维度是交互维度。在同一个限界上下文中实体对象和值对象可以自由交流,在不同限界上下文中必须通过聚合根进行交流。聚合根可以理解为一个按照业务聚合的代理对象。


    2.2 实体、值对象与聚合

    领域模型分为三类:实体、值对象和聚合。实体是具有唯一标识的对象,唯一标识会伴随实体对象整个生命周期并且不可变更。值对象本质上是属性的集合,没有唯一标识。

    聚合包括聚合根和聚合边界两个概念,聚合根可以理解为一个按照业务聚合的代理对象,一个限界上下文企图访问另一个限界上下文内部对象,必须通过聚合根进行访问。例如产品经理作为需求收口人,任何需求应该先提给产品经理,通过产品经理整合后再提给程序员,而不是直接提给开发人员。


    2.3 领域事件

    当某个领域发生一件事情时,如果其它领域有后续动作跟进,我们把这件事情称为领域事件,这个事件需要被感知。

    通过事件交互有一个问题需要注意,通过事件订阅实现业务只能采用最终一致性,需要放弃强一致性,可能会引入新的复杂度需要权衡。同一个进程间事件交互可以用EventBus,跨进程事件交互可以用RocketMQ等消息中间件。


    3 分析七大步骤

    3.1 七大步骤

    每个维度描述系统的一个侧面,组合在一起最终描绘出整个系统,这些维度分别是:

    四色分领域

    用例看功能

    流程三剑客

    领域与数据

    纵横做设计

    分层看架构

    接口看对接

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


    3.2 四色分领域

    3.2.1 流程梳理

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

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

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


    02 流程梳理.jpg


    3.2.2 四色建模

    (1) 时标对象

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

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

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


    03 四色分析_时标对象.jpg


    (2) 参与方、地、物

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

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


    04 四色分析_参与方、地、物.jpg


    (3) 角色对象

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


    05 四色分析_角色.jpg


    (4) 描述对象

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


    06 四色分析_描述.jpg


    3.2.3 划分领域

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


    07 领域划分表格.jpg


    3.2.4 领域事件

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

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

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


    08 领域事件表格.jpg


    3.3 用例看功能

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

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


    03 用例图_蓝色.jpg


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

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

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


    3.4 流程三剑客

    用例图是从外部视角描述系统,但是分析系统总是要深入系统内部的,其中流程视图就是描述系统内如何流转的视图。活动图、序列图、状态机图是流程视图中最重要的三种视图,我们称为流程三剑客。三者侧重点有所不同:活动图侧重于逻辑分支,顺序图侧重于交互,状态机图侧重于状态流转。


    3.4.1 活动图

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

    我们经常说一图胜千言,其中一个重要原因是文字是线性的,所以表达逻辑分支能力不如流程视图,而在流程视图中表达逻辑分支能力最强的是活动图。


    04 活动图.jpg


    3.4.2 顺序图

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

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


    05 序列图.jpg


    3.4.3 状态机图

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

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


    06 状态机图.jpg


    3.5 领域与数据

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

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

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

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


    01 实体与值对象_足球.jpg


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

    // 数据对象
    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结构中KEY进行检索,例如查询进球数大于5的球员,这也不是没有办法。我们可以将MySQL表中数据平铺到ES中,一条数据根据JSON KEY平铺变成多条数据,这样就可以进行检索了。


    3.6 纵横做设计

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

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

    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);
        }
    }
    

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

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

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

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


    07 订单_分析矩阵.jpg


    3.6.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 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.6.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.6.3 综合应用

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


    08 订单_纵向隔离横向编排.jpg


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

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

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


    09 足球_纵向隔离横向编排.jpg


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


    3.7 分层看架构

    3.7.1 维度一

    第一种层次关系是指本项目在整个公司位于哪一层。持久层、缓存层、中间件、业务中台、服务层、网关层、客户端和代理层是常见的分层架构。


    10 总体分层_2_场景化.jpg


    3.7.2 维度二

    第二种层次是指中台和前台的关系。一个系统在业务上通常分为三个端:面向B端用户,面向C端用户,面向运营用户。面对这种情况可以划分前台、中台、后台三类应用:


    变化多端_2.jpg


    第一中台应用承载核心逻辑,暴露核心接口,中台并不要理解所有端数据结构,而是通过client接口暴露相对稳定的数据。

    第二针对面向B端、面向C端、面向运营三种端,各自拆分出一个应用,在此应用中进行转换、适配和裁剪,并且处理各自业务。

    第三什么是大中台、小前台思想?中台提供稳定服务,前台提供灵活入口。

    第四如果后续要做秒杀系统,那么也可以理解其为一个前台应用(seckill-front)聚合各种中台接口。


    3.7.3 维度三

    第三种层次是代码层次结构。分层优点是每层只专注本层工作,可以类比设计模式单一职责原则,或者经济学比较优势原理,每层只做本层最擅长的事情。

    分层缺点是层之间通信时,需要通过适配器,翻译成本层或者下层可以理解的信息,通信成本有所增加。我认为工程分层需要从六个维度思考:

    (1) 单一

    每层只处理一类事情,满足单一职责原则

    (2) 降噪

    信息在每一层进行传输,满足最小知识原则,只向下层传输必要信息

    (3) 适配

    每层都需要一个适配器,翻译信息为本层或者下层可以理解的信息

    (4) 纵向

    纵向做隔离,同一个领域内业务要在本领域内聚

    (5) 横向

    横向做编排,应用层聚合多个领域进行业务编排

    (6) 数据

    数据对象尽量纯净,尽量使用基本类型

    代码可以分为九层结构:

    • 工具层:util
    • 整合层:integration
    • 基础层:infrastructure
    • 领域层:domain
    • 应用层:application
    • 门面层:facade
    • 客户端:client
    • 控制层:controller
    • 启动层:boot

    SpringBoot九层结构_DDD.jpg


    3.8 接口看对接

    当一个接口代码编写完成后,那么这个接口如何调用,输入和输出参数是什么,这些问题需要在接口文档中得到回答。接口文档生成有两种方式:第一种是自动生成,例如使用Swagger,第二种方式是手工生成。

    自动生成优点是代码即文档,还具有调试功能,在公司内部进行联调时非常方便。但是如果接口是提供给外部第三方使用,那么还是需要手工编写接口文档。对于一个接口的描述无外乎接口名称、接口说明、接口协议,输入参数、输出参数信息。


    11 接口文档.jpg


    4 代码详解

    user-demo-service
        -user-demo-service-application
        -user-demo-service-boot
        -user-demo-service-client
        -user-demo-service-controller
        -user-demo-service-domain
        -user-demo-service-facade
        -user-demo-service-infrastructure
        -user-demo-service-integration
        -user-demo-service-util
    

    4.1 util

    工具层承载工具代码

    不依赖本项目其它模块

    只依赖一些通用工具包

    user-demo-service-util
        -/src/main/java
            -date
                -DateUtil.java
            -json
                -JsonUtil.java
            -validate
                -BizValidator.java
    

    4.2 infrastructure

    基础层承载数据访问和entity

    同时承载基础服务(ES、Redis、MQ)


    4.2.1 项目结构

    user-demo-service-infrastructure
        -/src/main/java
            -base
                -service
                    -redis
                        -RedisService.java
                    -mq
                        -ProducerService.java
            -player
                -entity
                    -PlayerEntity.java
                -mapper
                    -PlayerEntityMapper.java
            -game
                -entity
                    -GameEntity.java
                -mapper
                    -GameEntityMapper.java
        -/src/main/resources
            -mybatis
                -sqlmappers
                    -gameEntityMapper.xml
                    -playerEntityMapper.xml
    

    4.2.2 本项目依赖

    • util

    4.2.3 核心代码

    创建运动员数据表:

    CREATE TABLE `player` (
      `id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT '主键',
      `player_id` varchar(256) NOT NULL COMMENT '运动员编号',
      `player_name` varchar(256) NOT NULL COMMENT '运动员名称',
      `height` int(11) NOT NULL COMMENT '身高',
      `weight` int(11) NOT NULL COMMENT '体重',
      `game_performance` text COMMENT '最近一场比赛表现',
      `creator` varchar(256) NOT NULL COMMENT '创建人',
      `updator` varchar(256) NOT NULL COMMENT '修改人',
      `create_time` datetime NOT NULL COMMENT '创建时间',
      `update_time` datetime NOT NULL COMMENT '修改时间',
      PRIMARY KEY (`id`)
    ) ENGINE=InnoDB AUTO_INCREMENT=1 DEFAULT CHARSET=utf8
    

    运动员实体对象,gamePerformance字段作为string保存在数据库,体现了数据层尽量纯净,不要整合过多业务,解析任务应该放在业务层:

    public class PlayerEntity {
        private Long id;
        private String playerId;
        private String playerName;
        private Integer height;
        private Integer weight;
        private String creator;
        private String updator;
        private Date createTime;
        private Date updateTime;
        private String gamePerformance;
    }
    

    运动员Mapper对象:

    @Repository
    public interface PlayerEntityMapper {
        int insert(PlayerEntity record);
        int updateById(PlayerEntity record);
        PlayerEntity selectById(@Param("playerId") String playerId);
    }
    

    4.3 integration

    本层调用外部服务,转换外部DTO成为本项目可以理解对象。


    4.3.1 项目结构

    本项目调用用户中心服务:

    user-demo-service-integration
        -/src/main/java
            -user
                -adapter
                    -UserClientAdapter.java
                -proxy
                    -UserClientProxy.java
                -vo                                    // 本项目对象
                    -UserSimpleAddressVO.java
                    -UserSimpleContactVO.java
                    -UserSimpleBaseInfoVO.java
    

    4.3.2 本项目依赖

    • util

    4.3.3 核心代码

    (1) 外部服务

    // 外部对象
    public class UserInfoClientDTO implements Serializable {
        private String id;
        private String name;
        private Date createTime;
        private Date updateTime;
        private String mobile;
        private String cityCode;
        private String addressDetail;
    }
    
    // 外部服务
    public class UserClientService {
    
        // RPC
        public UserInfoClientDTO getUserInfo(String userId) {
            UserInfoClientDTO userInfo = new UserInfoClientDTO();
            userInfo.setId(userId);
            userInfo.setName(userId);
            userInfo.setCreateTime(DateUtil.now());
            userInfo.setUpdateTime(DateUtil.now());
            userInfo.setMobile("test-mobile");
            userInfo.setCityCode("test-city-code");
            userInfo.setAddressDetail("test-address-detail");
            return userInfo;
        }
    }
    

    (2) 本项目对象

    // 基本对象
    public class UserBaseInfoVO {
        private UserContactVO contactInfo;
        private UserAddressVO addressInfo;
    }
    
    // 地址值对象
    public class UserAddressVO {
        private String cityCode;
        private String addressDetail;
    }
    
    // 联系方式值对象
    public class UserContactVO {
        private String mobile;
    }
    

    (3) 适配器

    public class UserClientAdapter {
    
        public UserBaseInfoVO convert(UserInfoClientDTO userInfo) {
            // 基础信息
            UserBaseInfoVO userBaseInfo = new UserBaseInfoVO();
            // 联系方式
            UserContactVO contactVO = new UserContactVO();
            contactVO.setMobile(userInfo.getMobile());
            userBaseInfo.setContactInfo(contactVO);
            // 地址信息
            UserAddressVO addressVO = new UserAddressVO();
            addressVO.setCityCode(userInfo.getCityCode());
            addressVO.setAddressDetail(userInfo.getAddressDetail());
            userBaseInfo.setAddressInfo(addressVO);
            return userBaseInfo;
        }
    }
    

    (4) 调用外部服务

    public class UserClientProxy {
    
        @Resource
        private UserClientService userClientService;
        @Resource
        private UserClientAdapter userIntegrationAdapter;
    
        // 查询用户
        public UserBaseInfoVO getUserInfo(String userId) {
            UserInfoClientDTO user = userClientService.getUserInfo(userId);
            UserBaseInfoVO result = userIntegrationAdapter.convert(user);
            return result;
        }
    }
    

    4.4 domain

    4.4.1 概念说明

    通过三组对比理解领域层:

    • 领域对象 VS 数据对象
    • 领域对象 VS 业务对象
    • 领域层 VS 应用层

    (1) 领域对象 VS 数据对象

    数据对象使用基本类型保持纯净:

    public class PlayerEntity {
        private Long id;
        private String playerId;
        private String playerName;
        private Integer height;
        private Integer weight;
        private String creator;
        private String updator;
        private Date createTime;
        private Date updateTime;
        private String gamePerformance;
    }
    

    领域对象需要体现业务含义:

    public class PlayerQueryResultDomain {
        private String playerId;
        private String playerName;
        private Integer height;
        private Integer weight;
        private GamePerformanceVO gamePerformance;
    }
    
    public class GamePerformanceVO {
        // 跑动距离
        private Double runDistance;
        // 传球成功率
        private Double passSuccess;
        // 进球数
        private Integer scoreNum;
    }
    

    (2) 领域对象 VS 业务对象

    业务对象同样会体现业务,领域对象和业务对象有什么不同?最大不同是领域对象采用充血模型聚合业务。

    运动员新增业务对象:

    public class PlayerCreateBO {
        private String playerName;
        private Integer height;
        private Integer weight;
        private GamePerformanceVO gamePerformance;
        private MaintainCreateVO maintainInfo;
    }
    

    运动员新增领域对象:

    public class PlayerCreateDomain implements BizValidator {
        private String playerName;
        private Integer height;
        private Integer weight;
        private GamePerformanceVO gamePerformance;
        private MaintainCreateVO maintainInfo;
    
        @Override
        public void validate() {
            if (StringUtils.isEmpty(playerName)) {
                throw new BizException(ErrorCodeBizEnum.ILLEGAL_ARGUMENT);
            }
            if (null == height) {
                throw new BizException(ErrorCodeBizEnum.ILLEGAL_ARGUMENT);
            }
            if (height > 300) {
                throw new BizException(ErrorCodeBizEnum.ILLEGAL_ARGUMENT);
            }
            if (null == weight) {
                throw new BizException(ErrorCodeBizEnum.ILLEGAL_ARGUMENT);
            }
            if (null != gamePerformance) {
                gamePerformance.validate();
            }
            if (null == maintainInfo) {
                throw new BizException(ErrorCodeBizEnum.ILLEGAL_ARGUMENT);
            }
            maintainInfo.validate();
        }
    }
    

    (3) 领域层 VS 应用层

    第一个区别:领域层关注纵向,应用层关注横向。领域层纵向做隔离,本领域业务行为要在本领域内处理完。应用层横向做编排,聚合和编排领域服务。

    第二个区别:应用层可以更加灵活组合不同领域业务,并且可以增加流控、监控、日志、权限,分布式锁,相较于领域层功能更为丰富。


    4.4.2 项目结构

    user-demo-service-domain
        -/src/main/java
            -base
                -domain
                    -BaseDomain.java
                -event
                    -BaseEvent.java
                -vo
                    -BaseVO.java
                    -MaintainCreateVO.java
                    -MaintainUpdateVO.java
            -player
                -adapter
                    -PlayerDomainAdapter.java
                -domain
                    -PlayerCreateDomain.java          // 领域对象 
                    -PlayerUpdateDomain.java
                    -PlayerQueryResultDomain.java
                -event                                // 领域事件
                    -PlayerUpdateEvent.java
                    -PlayerMessageSender.java
                -service                              // 领域服务
                    -PlayerDomainService.java
                -vo                                   // 值对象
                    -GamePerformanceVO.java                
            -game
                -adapter
                    -GameDomainAdapter.java        
                -domain
                    -GameCreateDomain.java
                    -GameUpdateDomain.java
                    -GameQueryResultDomain.java
                -service
                    -GameDomainService.java
    

    4.4.3 本项目依赖

    • util
    • client

    领域对象进行业务校验,所以需要依赖client模块:

    • BizException
    • ErrorCodeBizEnum

    4.4.4 核心代码

    // 修改领域对象
    public class PlayerUpdateDomain extends BaseDomain implements BizValidator {
        private String playerId;
        private String playerName;
        private Integer height;
        private Integer weight;
        private String updator;
        private Date updatetime;
        private GamePerformanceVO gamePerformance;
        private MaintainUpdateVO maintainInfo;
    
        @Override
        public void validate() {
            if (StringUtils.isEmpty(playerId)) {
                throw new BizException(ErrorCodeBizEnum.ILLEGAL_ARGUMENT);
            }
            if (StringUtils.isEmpty(playerName)) {
                throw new BizException(ErrorCodeBizEnum.ILLEGAL_ARGUMENT);
            }
            if (null == height) {
                throw new BizException(ErrorCodeBizEnum.ILLEGAL_ARGUMENT);
            }
            if (height > 300) {
                throw new BizException(ErrorCodeBizEnum.ILLEGAL_ARGUMENT);
            }
            if (null == weight) {
                throw new BizException(ErrorCodeBizEnum.ILLEGAL_ARGUMENT);
            }
            if (null != gamePerformance) {
                gamePerformance.validate();
            }
            if (null == maintainInfo) {
                throw new BizException(ErrorCodeBizEnum.ILLEGAL_ARGUMENT);
            }
            maintainInfo.validate();
        }
    }
    
    // 比赛表现值对象
    public class GamePerformanceVO implements BizValidator {
    
        // 跑动距离
        private Double runDistance;
        // 传球成功率
        private Double passSuccess;
        // 进球数
        private Integer scoreNum;
    
        @Override
        public void validate() {
            if (null == runDistance) {
                throw new BizException(ErrorCodeBizEnum.ILLEGAL_ARGUMENT);
            }
            if (null == passSuccess) {
                throw new BizException(ErrorCodeBizEnum.ILLEGAL_ARGUMENT);
            }
            if (Double.compare(passSuccess, 100) > 0) {
                throw new BizException(ErrorCodeBizEnum.ILLEGAL_ARGUMENT);
            }
            if (null == runDistance) {
                throw new BizException(ErrorCodeBizEnum.ILLEGAL_ARGUMENT);
            }
            if (null == scoreNum) {
                throw new BizException(ErrorCodeBizEnum.ILLEGAL_ARGUMENT);
            }
        }
    }
    
    // 修改人值对象
    public class MaintainUpdateVO implements BizValidator {
    
        // 修改人
        private String updator;
        // 修改时间
        private Date updateTime;
    
        @Override
        public void validate() {
            if (null == updator) {
                throw new BizException(ErrorCodeBizEnum.ILLEGAL_ARGUMENT);
            }
            if (null == updateTime) {
                throw new BizException(ErrorCodeBizEnum.ILLEGAL_ARGUMENT);
            }
        }
    }
    
    // 领域服务
    public class PlayerDomainService {
    
        @Resource
        private UserClientProxy userClientProxy;
        @Resource
        private PlayerRepository playerEntityMapper;
        @Resource
        private PlayerDomainAdapter playerDomainAdapter;
        @Resource
        private PlayerMessageSender playerMessageSender;
    
        public boolean updatePlayer(PlayerUpdateDomain player) {
            AssertUtil.notNull(player, new BizException(ErrorCodeBizEnum.ILLEGAL_ARGUMENT));
            player.validate();
    
            // 更新运动员信息
            PlayerEntity entity = playerDomainAdapter.convertUpdate(player);
            playerEntityMapper.updateById(entity);
    
            // 发送更新消息
            playerMessageSender.sendPlayerUpdatemessage(player);
    
            // 查询用户信息
            UserSimpleBaseInfoVO userInfo 
                = userClientProxy.getUserInfo(player.getMaintainInfo().getUpdator());
            log.info("updatePlayer maintainInfo={}", JacksonUtil.bean2Json(userInfo));
            return true;
        }
    }
    

    4.5 application

    本层关注横向维度聚合领域服务,引出一种新对象称为聚合对象。因为本层需要聚合多个维度,所以需要通过聚合对象聚合多领域属性,例如提交订单需要聚合商品、物流、优惠券多个领域。

    // 订单提交聚合对象
    public class OrderSubmitAgg {
    
        // userId
        private String userId;
    
        // skuId
        private String skuId;
    
        // 购买量
        private Integer quantity;
    
        // 地址信息
        private String addressId;
    
        // 可用优惠券
        private String couponId;
    }
    
    // 订单应用服务
    public class OrderApplicationService {
    
        @Resource
        private OrderDomainService orderDomainService;
        @Resource
        private CouponDomainService couponDomainService;
        @Resource
        private ProductDomainService productDomainService;
    
        // 提交订单
        public String submitOrder(OrderSubmitAgg orderSumbitAgg) {
    
            // 订单编号
            String orderId = generateOrderId();
    
            // 商品校验
            productDomainService.queryBySkuId(orderSumbitAgg.getSkuId());
    
            // 扣减库存
            productDomainService.subStock(orderSumbitAgg.getStockId(), orderSumbitAgg.getQuantity());
    
            // 优惠券校验
            couponDomainService.validate(userId, couponId);
    
            // ......
    
            // 创建订单
            OrderCreateDomain domain 
                = OrderApplicationAdapter.convert(orderSubmitAgg);
            orderDomainService.createOrder(domain);
            return orderId;
        }
    }
    

    4.5.1 项目结构

    user-demo-service-application
        -/src/main/java
            -player
                -adapter
                    -PlayerApplicationAdapter.java
                -agg
                    -PlayerCreateAgg.java
                    -PlayerUpdateAgg.java
                -service
                    -PlayerApplicationService.java
            -game
                -listener
                    -PlayerUpdateListener.java            // 监听运动员更新事件
    

    4.5.2 本项目依赖

    • util
    • domain
    • integration
    • infrastructure

    4.5.3 核心代码

    本项目领域事件交互使用EventBus框架:

    // 运动员应用服务
    public class PlayerApplicationService {
    
        @Resource
        private LogDomainService logDomainService;
        @Resource
        private PlayerDomainService playerDomainService;
        @Resource
        private PlayerApplicationAdapter playerApplicationAdapter;
    
        public boolean updatePlayer(PlayerUpdateAgg agg) {
            // 运动员领域
            boolean result = playerDomainService.updatePlayer(agg.getPlayer());
            // 日志领域
            LogReportDomain logDomain 
                = playerApplicationAdapter.convert(agg.getPlayer().getPlayerName());
            logDomainService.log(logDomain);
            return result;
        }
    }
    
    // 比赛领域监听运动员变更事件
    public class PlayerUpdateListener {
    
        @Resource
        private GameDomainService gameDomainService;
    
        @PostConstruct
        public void init() {
            EventBusManager.register(this);
        }
    
        @Subscribe
        public void listen(PlayerUpdateEvent event) {
            // 更新比赛计划
            gameDomainService.updateGameSchedule();
        }
    }
    

    4.6 facade + client

    设计模式中有一种Facade模式,称为门面模式或者外观模式。这种模式提供一个简洁对外语义,屏蔽内部系统复杂性。

    client承载数据对外传输对象DTO,facade承载对外服务,必须满足最小知识原则,无关信息不必对外透出。这样做有两个优点:

    • 简洁性:对外服务语义明确简洁
    • 安全性:敏感字段不能对外透出

    4.6.1 项目结构

    (1) client

    user-demo-service-client
        -/src/main/java
            -base
                -dto
                    -BaseDTO.java
                -error
                    -BizException.java
                    -BizErrorCode.java
                -event
                    -BaseEventDTO.java
                -result
                    -ResultDTO.java
            -player
                -dto
                    -PlayerCreateDTO.java
                    -PlayerQueryResultDTO.java
                    -PlayerUpdateDTO.java
                -enums
                    -PlayerMessageTypeEnum.java
                -service
                    -PlayerClientService.java
    

    (2) facade

    user-demo-service-facade
        -/src/main/java
            -player
                -adapter
                    -PlayerFacadeAdapter.java
                -impl
                    -PlayerClientServiceImpl.java
            -game
                -adapter
                    -GameFacadeAdapter.java
                -impl
                    -GameClientServiceImpl.java
    

    4.6.2 本项目依赖

    client不依赖本项目其它模块,这一点非常重要:因为client会被外部引用,必须保证本层简洁和安全。

    facade依赖本项目三个模块:

    • domain
    • client
    • application

    4.6.3 核心代码

    (1) DTO

    以查询运动员信息为例,查询结果DTO只封装强业务字段,运动员ID、创建时间、修改时间等业务不强字段无须透出:

    public class PlayerQueryResultDTO implements Serializable {
        private String playerName;
        private Integer height;
        private Integer weight;
        private GamePerformanceDTO gamePerformanceDTO;
    }
    

    (2) 客户端服务

    public interface PlayerClientService {
        public ResultDTO queryById(String playerId);
    }
    

    (3) 适配器

    public class PlayerFacadeAdapter {
    
        // domain -> dto
        public PlayerQueryResultDTO convertQuery(PlayerQueryResultDomain domain) {
            if (null == domain) {
                return null;
            }
            PlayerQueryResultDTO result = new PlayerQueryResultDTO();
            result.setPlayerId(domain.getPlayerId());
            result.setPlayerName(domain.getPlayerName());
            result.setHeight(domain.getHeight());
            result.setWeight(domain.getWeight());
            if (null != domain.getGamePerformance()) {
                GamePerformanceDTO performance 
                    = convertGamePerformance(domain.getGamePerformance());
                result.setGamePerformanceDTO(performance);
            }
            return result;
        }
    }
    

    (4) 服务实现

    本层可以引用applicationService,也可以引用domainService,因为对于类似查询等简单业务场景,没有多领域聚合,可以直接使用领域服务。

    public class PlayerClientServiceImpl implements PlayerClientService {
    
        @Resource
        private PlayerDomainService playerDomainService;
        @Resource
        private PlayerFacadeAdapter playerFacadeAdapter;
    
        @Override
        public ResultDTO queryById(String playerId) {
            PlayerQueryResultDomain resultDomain 
              = playerDomainService.queryPlayerById(playerId);
            if (null == resultDomain) {
                return ResultCommonDTO.success();
            }
            PlayerQueryResultDTO result 
              = playerFacadeAdapter.convertQuery(resultDomain);
            return ResultCommonDTO.success(result);
        }
    }
    

    4.7 controller

    facade服务实现可以作为RPC提供服务,controller则作为本项目HTTP接口提供服务,供前端调用。

    controller需要注意HTTP相关特性,敏感信息例如登陆用户ID不能依赖前端传递,登陆后前端会在请求头带一个登陆用户信息,服务端需要从请求头中获取并解析。


    4.7.1 项目结构

    user-demo-service-controller
        -/src/main/java
            -controller
                -player
                    -PlayerController.java
                -game
                    -GameController.java
    

    4.7.2 本项目依赖

    • facade

    4.7.3 核心代码

    @RestController
    @RequestMapping("/player")
    public class PlayerController {
    
        @Resource
        private PlayerClientService playerClientService;
    
        @PostMapping("/add")
        public ResultDTO add(
            @RequestHeader("test-login-info") String loginUserId, 
            @RequestBody PlayerCreateDTO dto) {
            dto.setCreator(loginUserId);
            ResultCommonDTO resultDTO = playerClientService.addPlayer(dto);
            return resultDTO;
        }
    
        @PostMapping("/update")
        public ResultDTO update(
            @RequestHeader("test-login-info") String loginUserId, 
            @RequestBody PlayerUpdateDTO dto) {
            dto.setUpdator(loginUserId);
            ResultCommonDTO resultDTO = playerClientService.updatePlayer(dto);
            return resultDTO;
        }
    
        @GetMapping("/{playerId}/query")
        public ResultDTO queryById(
            @RequestHeader("test-login-info") String loginUserId, 
            @PathVariable("playerId") String playerId) {
            ResultCommonDTO resultDTO 
                = playerClientService.queryById(playerId);
            return resultDTO;
        }
    }
    

    4.8 boot

    boot作为启动层承载启动入口


    4.8.1 项目结构

    所有模块代码均必须属于com.user.demo.service子路径:

    user-demo-service-boot
        -/src/main/java
            -com.user.demo.service
                -MainApplication.java
    

    4.8.2 依赖本项目

    • 所有模块

    4.8.3 核心代码

    @MapperScan("com.user.demo.service.infrastructure.*.mapper")
    @SpringBootApplication
    public class MainApplication {
        public static void main(final String[] args) {
            SpringApplication.run(MainApplication.class, args);
        }
    }
    

    5 文章总结

    本文第一提出并回答了六个问题,第二介绍了DDD相关基本概念,第三介绍了DDD分析七大步骤,第四介绍了代码分层结构,希望本文对大家有所帮助。欢迎大家关注公众号「JAVA前线」查看更多精彩分享文章,主要包括源码分析、实际应用、架构思维、职场分享、产品思考等等,同时欢迎大家加我个人微信「java_front」一起交流学习。

  • 相关阅读:
    整理MyBatis 2022-8-11
    软件设计模式系列之十——组合模式
    第25节-PhotoShop基础课程-文本工具组
    使用IDEA创建SpringCloud项目
    基于ssm的高校阅读分享推荐系统
    阿里云Redis性能压力测试(二十)
    6.3 - 常见协议及对应的端口号
    ESP32网络开发实例-使用NTP获取当前时间
    人工智能神经网络是什么,人工神经网络应用范围
    软件工程毕业设计课题(11)基于python的毕业设计python校园二手交易系统毕设作品源码
  • 原文地址:https://www.cnblogs.com/javafront/p/17393782.html