# uhy-ddd-hexagonal **Repository Path**: conncity/uhy-ddd-hexagonal ## Basic Information - **Project Name**: uhy-ddd-hexagonal - **Description**: No description available - **Primary Language**: Java - **License**: BSD-3-Clause - **Default Branch**: master - **Homepage**: None - **GVP Project**: No ## Statistics - **Stars**: 0 - **Forks**: 10 - **Created**: 2021-12-18 - **Last Updated**: 2021-12-18 ## Categories & Tags **Categories**: Uncategorized **Tags**: None ## README # 目录 - [目录](#目录) - [基于DDD和六边形架构的会员系统实战](#基于ddd和六边形架构的会员系统实战) - [简介](#简介) - [原始需求](#原始需求) - [卡券](#卡券) - [会员和粉丝](#会员和粉丝) - [卡券分析设计](#卡券分析设计) - [需求分析](#需求分析) - [卡券领域模型](#卡券领域模型) - [卡券状态机](#卡券状态机) - [应用层服务](#应用层服务) - [执行过程](#执行过程) - [贫血模型(面向过程) vs 充血模型(面向对象)](#贫血模型面向过程-vs-充血模型面向对象) - [状态机与领域事件示例代码](#状态机与领域事件示例代码) - [会员/促销员晋级分析设计](#会员促销员晋级分析设计) - [需求分析](#需求分析-1) - [晋级领域模型](#晋级领域模型) - [测试](#测试) - [基于DDD和六边形架构的会员系统微服务结构](#基于ddd和六边形架构的会员系统微服务结构) - [项目结构](#项目结构) - [部署架构](#部署架构) - [测试](#测试-1) - [测试卡券接口](#测试卡券接口) - [基于DDD和六边形架构的微服务设计与实现方法论](#基于ddd和六边形架构的微服务设计与实现方法论) - [Hexagonal Architecture](#hexagonal-architecture) - [Domain Model Desgin](#domain-model-desgin) - [战略模式](#战略模式) - [战术模式](#战术模式) - [基于DDD和六边形架构的典型微服务结构](#基于ddd和六边形架构的典型微服务结构) - [基于六边形架构的项目典型结构](#基于六边形架构的项目典型结构) - [值对象详解](#值对象详解) - [避免贫血模型](#避免贫血模型) - [评价服务设计是否合理](#评价服务设计是否合理) - [参考资料](#参考资料) # 基于DDD和六边形架构的会员系统实战 ## 简介 该项目基于领域驱动设计(DDD)和六边形架构(Hexagonal Architecture)方法论,主要对会员领域的卡券和晋级二个模块进行了分析和设计: 1. 卡券模块主要说明如果对一个业务领域进行分析设计,如何使用聚合、值对象、实体、领域服务、领域事件、资源库、工厂、应用服务、端口和适配器这些战术工具,并给出了状态机的示例,来说明如何封装一个对象的复杂的状态变迁,以及何时发布领域事件。 2. 晋级模块主要说明对业务算法如何分析设计,怎样设计出业务意图清晰的、易于可扩展的、易于分析调试的算法模型。 然后展示了会员基于DDD和六边形架构的微服务结构、部署架构和项目结构;最后给出了基于功能的单元测试,展示了以客户端的角度使用系统。 ## 原始需求 会员业务领域包括会员管理、积分、卡券、交易、钱包、支付、营销、分销、粉丝管理功能,本示例中主要侧重卡券。 ### 卡券 > 基本规则 1. 卡券分为代金券、折扣券、减免券、计次券,在卡券发放时需要定义总的发行数量,确定卡券类型并设置参数(消费额度、减免金额、可用次数、折扣),设置发放条件(每人限领张数、可否转赠、售卖),设置卡券使用范围即核销条件(门店、商品、时段),设置卡券有效期(生效时间、失效时间)。 2. 卡券定义完成后,可以由操作员发送给一批目标会员或粉丝(根据会员等级、标签进行筛选),也可以有选择性地发放给指定的会员或粉丝。 3. 当满足特定条件会自动触发发放卡券,比如会员生日时发放卡券。 4. 会员和粉丝可以直接通过营销活动直接领取卡券,比如在商城中直接领取;营销活动也可以设置奖励是卡券,完成该营销活动可以获得奖励。 5. 会员也可以在商城中购买卡券,这时卡券作为虚拟产品可以交易。 6. 会员可以转让、售卖卡券,前提是卡券定义中设置了可以转让、售卖。 7. 在获得到卡券(发放、领用、购买、转让)后,可以在交易中消费卡券(核销),根据卡券类型减少、折扣、抵扣一定金额。 8. 粉丝能够参加促销活动、获取卡券是一种营销手段,在真正消费时,需要先成为会员(粉丝在消费卡券前,需要成为会员)。 9. 卡券未到达生效时间时为初始状态,在有效期期间为可用状态,消费后为已消费状态,超出失效时间为过期状态。 10. 会员或粉丝可以主动废弃卡券,这是卡券为废弃状态;如果卡券是由于营销活动产生,比如交易消费奖励,而后续营销活动的条件不满足时,比如取消交易,卡券也会自动成为废弃状态。 11. 过期的和废弃的卡券不可以再使用。 12. 已消费状态的卡券随着交易的取消,会重置为可用状态。 13. 在使用卡券之前,需要检查卡券的有效期,确定是否已经过期。 14. 需要每天定期对所有初始状态和可用状态的卡券检查是否可用、是否已经过期。 > 卡券发放和领取场景 1. 会员在商城、门店内购买,这时会产生交易单 2. 会员和粉丝在营销活动获得 3. 会员交易奖励,这时会有来源交易单 4. 会员促销、买赠,这时会有来源交易单 5. 操作员发放给会员和粉丝 6. 会员和粉丝交易活动中自领取 7. 会员关怀自动发放 8. 预发券生效 9. 会员转赠 ### 会员和粉丝 1. 会员基本信息包括会员级别、手机号、姓名 2. 粉丝基本信息包括openid(微信开放平台用户ID)、昵称、关注时间,粉丝可以转化为会员。 3. 会员在晋级降级统计期间内满足晋级条件后可以晋级,没有达到保级条件时会降级;晋级条件和保级条件可以配置,比如期间内交易总金额超过100元或交易超过10笔,且积分达到1000分可以晋级为某一级会员。 ## 卡券分析设计 这部分主要说明如果对一个业务领域进行分析设计,如何使用聚合、值对象、实体、领域服务、领域事件、资源库、工厂、应用服务、端口和适配器这些战术工具,并给出了状态机的示例,来说明如何封装一个对象的复杂的状态变迁,以及何时发布领域事件。 ### 需求分析 ![Coupon Use Case](images/coupon-cases.png) ### 卡券领域模型 从概念领域模型到软件领域模型 From the conceptual domain to the software model ![Coupon Conceptual Model](images/coupon-conceptual-model.png) ![Coupon Software Model](images/coupon-software-model.png) 充血/富血模型(Rich Domain Model)才能准确反映业务意图 Intention,是面向对象开发OOP的自然选择;与之相反的是贫血模型(Anemic Domain Model),站在OOP的对立面,是一种反模式,将过多的细节暴露给客户端,无法保证业务完整性,无法保护业务规则一致性,一般表现为在一个服务(Service)中实现所有业务逻辑,领域对象(Domain Objects)仅仅作为数据载体。简单来说,数据和业务操作在一起是面向对象开发的充血模型,数据和业务操作分离是面向过程的贫血模型。开发产品级应用程序时,采用面向对象开发的充血模型,对设计开发人员有一定门槛;开发快速交付的临时性项目或者对需求不太了解也要交差的应用程序时,可以选择面向过程的贫血模型,对开发人员基本没有门槛,靠搜索引擎就能coding。 原则 Principle: 1. 基于业务规则(Invariant)划分聚合,封装(Encapsulate)业务逻辑,保证业务完整性(completeness)。 2. 设计小聚合,优先使用值对象(Value Object)。 3. 通过唯一标识引用其他聚合(迪米特、低耦合)。 4. 在边界之外使用最终一致性,在一个事务只修改一个聚合对象,通过事件机制达到最终一致性。 5. Rich!!! Domain Model,Responsibility-Driven 职责驱动 通过以下方法可以避免贫血模型: 1. 领域对象使用私有化的setter,暴露有业务含义的方法。上图中的聚合、值对象均采用该方法,getter和setter私有化主要是为了内部使用,并且ORM需要这些方法(虽然建模时尽可能不考虑技术实现)。 2. 在领域对象内部进行校验validate,保证业务完整性。上图中的聚合、值对象均采用该方法。 3. 私有化无参构造函数,对外暴露全参数构造函数;值对象可以使用of()静态方法构建。上图中的聚合、值对象均采用该方法。 4. 谨慎地创建领域服务Domain Services,尽量不要使用。上图中的卡券定义中的generateCoupon()工厂方法涉及到了卡券定义和卡券两个聚合,没有采用领域服务而放在卡券定义中更加合理。 5. 不要使用面向过程的编程方式,采用面向对象编程方式;使用面向对象开发语言并不能总是写出面向对象的程序,这也是领域驱动设计DDD的价值。典型的面向过程是Martin Fowler 在企业架构模式中提出的事务脚本`Transaction Script`。 领域模型中的服务为领域服务,领域服务和应用服务的区别在于: |特征|领域服务Domain Service|应用服务Application Service|说明| |---|---|---|---| |位置|位于领域模型(Domain Model)中|位于应用层(Applicaiton Layer)中|领域模型仅提供给应用层使用,是被保护起来的、不对外暴露的;应用层提供给各个适配器使用,对外提供能力的同时,也定义所需能力接口,最终由适配器实现该接口| |功能|当一个业务规则涉及多个聚合对象,并且该职责放在哪一方都不合适时,通过领域服务来封装该业务规则|提供粗粒度的接口给适配器使用,并且处理安全、事务等职责,应用服务并不封装业务规则,而是调用领域模型中的方法|这些聚合对象可以是同一个类型,也可以是不同类型,关键点是**多个**。| |特性|强调封装Encapsulation|强调Facade级接口| |根源|基于领域概念(concept)与业务规则(invariant)|基于业务场景(scenario)| ### 卡券状态机 ![Coupon State Machine](images/coupon-sm.png) 通过状态机模式,能够很好的控制状态变迁,保证状态切换的正确性;一般在切换状态后发布领域事件,通知消费者在状态切换时做适当的处理。 ### 应用层服务 ![UHY Application Service](images/application-layer.png) 应用层服务采用Facde模式,基于用例场景对外暴露接口,并且处理安全、事务等职责。应用服务是领域模型的直接客户端,是连接领域模型和端口适配器的桥梁。 为了保护领域模型不泄漏,应用服务定义的接口参数和返回值均不使用领域模型中的类型,一般采用简单类型或者DTO。 ### 执行过程 > 发放卡券时序图 ![Give Coupon Sequence](images/give-coupon-seq.png) > 领取卡券时序图 ![Receive Coupon Sequence](images/receive-coupon-seq.png) ### 贫血模型(面向过程) vs 充血模型(面向对象) 消费卡券时序图
面向过程 - Transaction Script - 不推荐 ![Consume Coupon Sequence](images/conpon-trans-script.png) 1. 面向过程的事务脚本在开发简单项目时,有一定优势,快速、简单、无门槛。 2. 在产品化开发时,业务逻辑没有封装、业务完整性无法根本保障,导致产品难以维护、扩展。 3. 代码会趋于集中在某一个类中,例如图中的CouponService。 面向对象 - Responsibility-Oriented - 推荐 ![Consume Coupon Sequence](images/consume-coupon-seq.png) 1. 面向对象,职责清晰,各司其职。 2. 在产品化开发时,封装业务逻辑,保障业务完整性,产品易于维护、扩展。 3. 代码会趋于产生多个小类,每个类对应一个领域概念,封装具体业务规则,完成特定职责。 如果一个职责与多个聚合对象有关,这些对象可以是同一个类型,也可以是不同类型,这时应该建立一个领域服务,封装这些业务逻辑。 ### 状态机与领域事件示例代码 状态机可以作为状态容器类(例如:卡券)的内部类,这样可以访问容器类的私有方法;如果独立创建一个状态机类,为了不暴露容器类的内部细节,则需要创建Supplier或者Consumer来读取或变更容器类的内部状态。 > 采用内部类方式 ```java public class Coupon { private CouponStatus status = CouponStatus.Created; // 采用内部类实现状态机,可以直接访问卡券的private属性和方法 private class CouponStateMachine { Coupon container; public CouponStateMachine(Coupon container) { this.container = container; } private boolean changeStatus(CouponStatus nextStatus) { switch (container.status) { case Created: if (nextStatus == CouponStatus.Consumed) { throw new BusinessException("卡券不能直接从初始状态到消费状态"); } case Activated: if (nextStatus == CouponStatus.Created) { throw new BusinessException("卡券不能从可用状态回退到初始状态"); } break; case Consumed: if (nextStatus == CouponStatus.Consumed) { throw new BusinessException("卡券不能重复核销"); } else if (nextStatus != CouponStatus.Activated) { throw new BusinessException("卡券只能从核销状态回退到可用状态"); } case Expired: throw new BusinessException("卡券过期状态为最终状态,不可以切换状态"); case Cancelled: throw new BusinessException("卡券作废状态为最终状态,不可以切换状态"); } if (nextStatus != container.status) { container.status = nextStatus; return true; } else { return false; } } } } ``` > 采用两个独立的类方式 ```java public class Coupon { protected transient CouponStateMachine sm; protected Coupon() { // StateMachine 通过该观察者变更卡券对象内部状态,卡券对象不对外公开状态变更方法 Consumer watcher = (newStatus) -> this.status = newStatus; sm = new CouponStateMachine(this, watcher); } } public class CouponStateMachine { private Coupon container; // 通过调用{@link Consumer#accept}方法,可以更新卡券对象的内部状态 Consumer watcher; public CouponStateMachine(Coupon coupon, Consumer watcher) { this.container = coupon; this.watcher = watcher; } private boolean changeStatus(CouponStatus nextStatus) { switch (currentStatus()) { case Created: if (nextStatus == CouponStatus.Consumed) { throw new BusinessException("卡券不能直接从初始状态到消费状态"); } case Activated: if (nextStatus == CouponStatus.Created) { throw new BusinessException("卡券不能从可用状态回退到初始状态"); } break; case Consumed: if (nextStatus == CouponStatus.Consumed) { throw new BusinessException("卡券不能重复核销"); } else if (nextStatus != CouponStatus.Activated) { throw new BusinessException("卡券只能从核销状态回退到可用状态"); } case Expired: throw new BusinessException("卡券过期状态为最终状态,不可以切换状态"); case Cancelled: throw new BusinessException("卡券作废状态为最终状态,不可以切换状态"); } if (nextStatus != currentStatus()) { this.watcher.accept(nextStatus); return true; } else { return false; } } } ``` > 状态转移方法及发布领域事件 由于领域模型中仅定义了DomainEventPublisher接口,如果要调用其实例方法,需要通过注册表DomainRegistry获取实例对象,即:DomainRegistry.instance().eventPublisher(),在领域模型中使用其它接口方法与此类似。 ```java public class CouponStateMachine { /** * 尝试转移到{@code CouponStatus.Activated 可用状态} *

* 当前状态为{@code CouponStatus.Created 初始状态},并且卡券在有效期期间,卡券转移到到{@code CouponStatus.Activated 可用状态}。 * 如果成功转移到{@code CouponStatus.Activated 可用状态},则发布{@link CouponActivated}领域事件 */ public void activate() { if (currentStatus() == CouponStatus.Created && container.effectiveTime().inRange(LocalDateTime.now())) { if (this.changeStatus(CouponStatus.Activated)) { DomainRegistry.instance().eventPublisher().publish(new CouponActivated(container.couponId())); } } } /** * 消费卡券状态转移 *

* 状态转移之前,先尝试可用状态转移{@link #activate()}和过期状态转移{@link #expire()} * 如果成功转移到{@code CouponStatus.Consumed 已消费状态},则发布{@link CouponConsumed}领域事件 */ public void consume() { // 先可用状态尝试 this.activate(); // 在过期状态尝试 this.expire(); // if (this.changeStatus(CouponStatus.Consumed)) { DomainRegistry.instance().eventPublisher().publish(new CouponConsumed(container.couponId())); } } /** * 尝试转移到{@code CouponStatus.Expired 过期状态} *

* 当前状态为{@code CouponStatus.Created 初始状态}或{@code CouponStatus.Activated 可用状态},并且超出卡券失效时间,卡券转移到到{@code CouponStatus.Expired 过期状态}。 * 如果成功转移到{@code CouponStatus.Expired 过期状态},则发布{@link CouponExpired}领域事件 */ public void expire() { if ((currentStatus() == CouponStatus.Created || currentStatus() == CouponStatus.Activated) && LocalDateTime.now().isAfter(container.effectiveTime().getToTime())) { if (this.changeStatus(CouponStatus.Expired)) { DomainRegistry.instance().eventPublisher().publish(new CouponExpired(container.couponId())); } } } /** * 取消卡券消费状态转移 *

* 当前状态为{@code CouponStatus.Consumed 已消费状态},卡券转移到到{@code CouponStatus.Activated 可用状态}。 * 同时发布{@link CouponActivated}领域事件 */ public void back() { if (currentStatus() == CouponStatus.Consumed) { if (this.setStatusDirectly(CouponStatus.Activated)) { DomainRegistry.instance().eventPublisher().publish(new CouponActivated(container.couponId())); } } } /** * 废弃卡券状态转移 *

* 如果成功转移到{@code CouponStatus.Cancelled 废弃状态},则发布{@link CouponCancelled}领域事件 */ public void cancel() { if (this.changeStatus(CouponStatus.Cancelled)) { DomainRegistry.instance().eventPublisher().publish(new CouponCancelled(container.couponId())); } } } ``` DomainEventPublisher接口实现类可以基于消息队列,该示例中仅打印日志。 ```java @Bean public DomainEventPublisher domainEventPublisher() { return event -> logger.info("{} {} {}", event.getClazz(), event.getKey(), event.getCreateTime()); } ``` 基于消息队列实现示例。 ```java public class RabbitMqEventPublisher implements DomainEventPublisher { @Autowired RabbitTemplate rabbitTemplate; @Autowired MonitorService monitorService; @Autowired ObjectMapperWrapper jsonWrapper; @Override public boolean publish(DomainEvent event) { Message message = createMessage(event); rabbitTemplate.convertAndSend(MqConst.EXCHANGE_NAME, event.getClass().getSimpleName(), message); return true; } @Override public boolean publishSafe(DomainEvent event) { try { Message message = createMessage(event); rabbitTemplate.convertAndSend(MqConst.EXCHANGE_NAME, event.getClass().getSimpleName(), message); } catch (Exception ex) { monitorService.addPublishFailedEvent(event); } return true; } private Message createMessage(DomainEvent event) { MessageProperties properties = new MessageProperties(); properties.setHeader(MqConst.EVENT_KEY, event.getKey()); byte[] bytes = jsonWrapper.toBytes(event.getEventData()); Message message = new Message(bytes, properties); return message; } } ``` DomainRegistry初始化需要各个接口的实例对象,可以在装配工程中初始化,可以通过DomainRegistryFactoryBean创建DomainRegistry对象。 ```java public class DomainRegistryFactoryBean implements FactoryBean, ApplicationContextAware { ApplicationContext applicationContext; @Autowired DomainEventPublisher eventPublisher; @Autowired CouponArchiveRepository couponArchiveRepository; @Autowired CouponRepository couponRepository; @Override public void setApplicationContext(ApplicationContext applicationContext) throws BeansException { this.applicationContext = applicationContext; } @Override public DomainRegistry getObject() throws Exception { BiFunction beanFinder = (beanName, clazz) -> applicationContext.getBean(beanName, clazz); return new DomainRegistry(couponArchiveRepository, couponRepository, eventPublisher, beanFinder); } @Override public Class getObjectType() { return DomainRegistry.class; } } ``` DomainRegistryFactoryBean实例化。 ```java @Bean public DomainRegistryFactoryBean domainRegistryFactoryBean(ObjectMapper objectMapper) { return new DomainRegistryFactoryBean(); } ``` [返回目录](#目录) ## 会员/促销员晋级分析设计 这部分主要说明对业务算法如何分析设计,怎样设计出业务意图清晰的、易于可扩展的、易于分析调试的算法模型。 ### 需求分析 会员在执行交易、充值等业务时,会触发会员晋级计算,在满足晋级规则时会员等级晋级,也会触发与之关联的分销员晋级计算。
通过分销员推荐分享注册会员时,会触发分销员晋级计算;分销员可以管理一级下级分销员,下级分销员晋级计算同时都会触发上级分销员晋级计算。 ![Upgrade Use Cases](images/upgrade-cases.png) ### 晋级领域模型 ![Upgrade Domain Model](images/upgrade-model.png) > 晋级计算时序图 ![Upgrade Sequence](images/upgrade-sequence.png) ### 测试 > 测试数据 ```json { "name": "分销员级别二晋级方案", "levelId": "Level-two", "keepLevelIfMismatching": true, "upgradeRules": [ { "id": 1, "parentRuleId": 2, "ruleType": "MEMBER_COUNT", "value": 20}, { "id": 3, "parentRuleId": 2, "ruleType": "TRADE_COUNT", "value": 40}, { "id": 2, "parentRuleId": 4, "ruleType": "LOGICAL", "operator": "OR"}, { "id": 4, "parentRuleId": null, "ruleType": "LOGICAL", "operator": "AND"}, { "id": 5, "parentRuleId": 4, "ruleType": "TRADE_SUM", "value": 10000} ] } ``` > 晋级方案执行树 ```bash # 分销员 Level-two 晋级方案 执行树 AND ├──OR │ ├──MEMBER_COUNT 20 │ ├──TRADE_COUNT 40 ├──TRADE_SUM 10000 ``` > 测试数据推演 |#|会员数量|结果|交易数量|结果|交易金额|结果|是否晋级| |---|---|---|---|---|---|---|---| |1th|20|True|10*20|True|500*20|True|True| |2th|20|True|10*20|True|400*20|False|False| |3th|20|True|1*20|False|500*20|True|True| |4th|20|True|1*20|False|400*20|False|False| |5th|10|False|10*10|True|1000*10|True|True| |6th|10|False|10*10|True|800*10|False|False| |7th|10|False|3*10|False|1000*10|True|False| |8th|10|False|3*10|False|800*10|False|False| > 测试代码 ```java public class UpgradeTest { @Test public void test01_upgrade() throws IOException { UpgradeSchema schema = loadUpgradeSchema(); UpgradeService upgradeService = new UpgradeService(); UpgradeExecutionContext executionContext; boolean canUpgrade; // 20 满足 200 满足 10000 满足 executionContext = createExecutionContext(BigDecimal.valueOf(500), 10, 20); canUpgrade = upgradeService.upgrade(schema, executionContext); assertTrue(canUpgrade); // 20 满足 200 满足 8000 不满足 executionContext = createExecutionContext(BigDecimal.valueOf(400), 10, 20); canUpgrade = upgradeService.upgrade(schema, executionContext); assertFalse(canUpgrade); // 20 满足 20 不满足 10000 满足 executionContext = createExecutionContext(BigDecimal.valueOf(500), 1, 20); canUpgrade = upgradeService.upgrade(schema, executionContext); assertTrue(canUpgrade); // 20 满足 20 不满足 8000 不满足 executionContext = createExecutionContext(BigDecimal.valueOf(400), 1, 20); canUpgrade = upgradeService.upgrade(schema, executionContext); assertFalse(canUpgrade); // 10 不满足 100 满足 10000 满足 executionContext = createExecutionContext(BigDecimal.valueOf(1000), 10, 10); canUpgrade = upgradeService.upgrade(schema, executionContext); assertTrue(canUpgrade); // 10 不满足 100 满足 8000 不满足 executionContext = createExecutionContext(BigDecimal.valueOf(800), 10, 10); canUpgrade = upgradeService.upgrade(schema, executionContext); assertFalse(canUpgrade); // 10 不满足 30 不满足 10000 满足 executionContext = createExecutionContext(BigDecimal.valueOf(1000), 3, 10); canUpgrade = upgradeService.upgrade(schema, executionContext); assertFalse(canUpgrade); // 10 不满足 30 不满足 8000 不满足 executionContext = createExecutionContext(BigDecimal.valueOf(800), 3, 10); canUpgrade = upgradeService.upgrade(schema, executionContext); assertFalse(canUpgrade); } private UpgradeExecutionContext createExecutionContext(BigDecimal moneyPerMember, Integer countPerMember, Integer membersCount) { TradeRepository tradeRepository = new MockTradeRepository(moneyPerMember, countPerMember); DistributionRepository distributionRepository = new MockDistributionRepository(membersCount); UpgradeObject upgradeObject = UpgradeObject.of(DistributorId.of("10001L")); return new UpgradeExecutionContext(upgradeObject, tradeRepository, distributionRepository); } private UpgradeSchema loadUpgradeSchema() throws IOException { InputStream is = UpgradeTest.class.getClassLoader().getResourceAsStream("upgrade-schema.json"); ObjectMapper objectMapper = new ObjectMapper(); UpgradeSchema schema = objectMapper.readValue(is, UpgradeSchema.class); return schema; } } ``` [返回目录](#目录) ## 基于DDD和六边形架构的会员系统微服务结构 ![UHY Microservices Architecture](images/uhy-ms-arch.png) `设计时`:Rest和Dubbo适配器依赖应用层服务,应用层服务依赖领域服务,资源库适配器依赖领域服务。 `运行时`:Rest和Dubbo适配器的对象实例依赖应用层服务的对象实例,应用层服务的对象实例依赖资源库适配器的对象实例,应用层服务的对象实例依赖领域模型的对象实例。 `设计关键`:领域模型强调封装(encapsulation, act as architect),应用服务强调协调(collaboration, act as bridge),端口适配器强调技术实现(realization via tech, act as labor) ## 项目结构 ```bash ├── uhy-application-starter # [装配容器] 装配器与运行容器,配置文件,启动类 ├── uhy-application-service # [应用层] 应用服务层,提供场景化服务接口 Scenario-Oriented Facade ├── uhy-domain-model # [领域模型] 最核心部分,存粹的业务,不涉及框架与技术实现 ├── uhy-rest-adapter # [适配器] REST适配器,为外部系统提供基于HTTP协议的REST服务 ├── uhy-repository-adapter # [适配器] 资源库适配器,系统自身使用,采用数据库存储,使用mybatis实现 ├── uhy-mock-repository-adapter # [适配器] 资源库适配器,系统自身使用,采用内存存储,使用caffeine实现 ├── uhy-rpc-adapter # [适配器] RPC适配器,为外部系统提供基于Dubbo的RPC服务 └── uhy-rpc-api # [RPC接口] 提供给外部系统,便于RPC调用本服务,与REST不同,RPC客户端需要使用上游服务提供的API接口定义组件 ``` |组件|含义|功能| |---|---|---| |uhy-application-starter|集成组件|将所有组件集成起来,提供内置的Tomcat容器,提供配置文件等资源,
应用程序最终通过该组件运行,并提供运行时的单元测试| |uhy-application-service|应用服务组件|提供粗粒度接口Facade,提供事务一致性,负责连接领域模型与端口适配器| |uhy-domain-model|领域模型组件|定义领域概念,封装业务规则,负责业务完整性| |uhy-repository-adapter|资源库适配器组件|实现领域模型中定义的资源库接口,可以采用多种存储形式,
例如JVM缓存、Redis缓存、数据库多级存储| |uhy-mock-repository-adapter|模拟资源库适配器组件|实现领域模型中定义的资源库接口,使用RAM存储对象| |uhy-rest-adpater|REST适配器组件|通过REST风格HTTP协议向外部暴露接口,负责与外部交互| |uhy-rpc-adpater|RCP适配器组件|通过RPC风格Dubbo协议向外部暴露接口,负责与外部交互| |uhy-rpc-api|RCP适配器组件|提供给外部系统,便于RPC调用本服务| *各个组件间的依赖关系参见“部署架构”。* ## 部署架构 ![UHY Deploy Architecture](images/uhy-deploy.png) [返回目录](#目录) ## 测试 单元测试是系统设计开发过程中一个非常重要的环节,展现了以客户端的角度如何使用系统,可以拿来与领域专家进行沟通交流。 ### 测试卡券接口 ```java @RunWith(SpringRunner.class) @SpringBootTest(classes = {UhyApplication.class}, webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) @ActiveProfiles("test") public class CouponTest { private static final String test_access_token = "a-access-token"; protected MockMvc mvc; @Autowired WebApplicationContext webApplicationContext; @Autowired ObjectMapper objectMapper; @Before public void setUp() { mvc = MockMvcBuilders.webAppContextSetup(webApplicationContext).build(); } /** * 新增卡券定义 */ @Test public void test01_add_coupon_archive() { // 创建卡券定义 String id = create_an_coupon_archive(); // 查看卡券定义 find_coupon_archive(id, 1000); } /** * 收取卡券 */ @Test public void test03_receive_coupon() { // 创建卡券定义 String archiveId = create_an_coupon_archive(); // 领取卡券 ResponseDto responseDto = receive_a_coupon(archiveId); assertEquals("200", responseDto.getCode()); assertTrue(responseDto.getData() instanceof String); String couponId = (String) responseDto.getData(); // 查看卡券 find_coupon(couponId); // 卡券定义可用数量 find_coupon_archive(archiveId, 999); // 消费卡券 consume_coupon(couponId); // 查看卡券状态 ResponseDto resp2 = find_coupon(couponId); assertEquals(CouponStatus.Consumed.ordinal(), ((Map) resp2.getData()).get("status")); // 再次发放卡券,卡券资格检查不通过 ResponseDto resp3 = receive_a_coupon(archiveId); assertEquals("超出卡券领用限制,每人最多领用1张", resp3.getMessage()); } /** * 根据条件分发卡券 */ @Test public void test04_give_coupon_group() { // 创建卡券定义 String archiveId = create_an_coupon_archive(); // 按条件发放卡券 String body = "label=" + "高富帅" + "&level=" + "Level-one"; String uri = "/coupons/" + archiveId + "/give/group"; RestClient client = new RestClient(mvc, objectMapper, test_access_token); ResponseDto responseDto = client.formPost(uri, body); assertEquals("200", responseDto.getCode()); assertTrue(responseDto.getData() instanceof List); int count = ((List) responseDto.getData()).size(); assertTrue(count > 0); // 卡券定义可用数量 find_coupon_archive(archiveId, 1000 - count); } private String create_an_coupon_archive() { String body = new TestDataLoader(objectMapper).load("classpath:coupon-archive.json"); String uri = "/coupon/define/add"; RestClient client = new RestClient(mvc, objectMapper, test_access_token); ResponseDto responseDto = client.post(uri, body); assertEquals("200", responseDto.getCode()); assertTrue(responseDto.getData() instanceof String); return (String) responseDto.getData(); } // ... } ``` [返回目录](#目录) # 基于DDD和六边形架构的微服务设计与实现方法论 ## Hexagonal Architecture 了解六边形架构之前,需要先理解DIP(依赖反转原则)。首先看下分层架构的基本构成 ![分层架构](images/layered.png) 分层架构以领域模型为核心(BLL层分离出领域模型和应用层),所有其他层都围绕核心领域模型展开,转变成如下结构。 ![基于DIP的架构](images/dip.png) DDD设计思想在结合DIP实现方式,就形成了如下图所示的六边形架构。 ![六边形架构](images/hexagonal-arch.png) 该架构具有如下优势: 1. 聚焦核心业务 2. 端口和适配器易于扩展 3. 易于自动测试(实现一个MOCK适配器即可) 微服务(Micro-services)强调服务的独立性(领域模型)与开放性(适配器),使用六边形架构实现微服务最合适,使用微服务架构时, 最常犯的错误就是依据技术对服务进行拆分,这样一个逻辑上独立完成的服务,被拆分成数据库、消息队列、WEB、OpenAPI、报表、数据同步相关的等等, 一个完整的服务被拆分的七零八落,不容易看到代码全貌,不利于后续部署、运维、维护、扩展。微服务划分方法最佳实践是DDD, 通过该方法能够划分出合理的系统边界,设计出良好的领域模型(Domain Model),而实现微服的最佳实践是六边形架构(Hexagonal Architecture), 六边形架构以领域模型(Domain Model)为核心,通过端口和适配器与外界交互,平等对待所有的外部交互,该架构可以方便地进行测试, 为测试开发一个适配器即可,不受运行时UI、数据库等因素影响。 ## Domain Model Desgin ### 战略模式 确定系统边界,定义清晰的、无歧义的业务概念,确定各个系统间的集成关系,以高内聚、低耦合为指导思想。 ![领域驱动设计战略模式](images/ddd-stragetic.png) |要素|含义|示例| |---|---|---| |Ubiquitous Language|通用语言,针对该领域,在需求、开发、测试之间通用的业务术语|发送短信、发送状态、营销短信、通知短信、账号、子账号、短信模版、模版参数、短信签名| |Bounded Context|限界上下文,主要封装通用语言和领域对象,确定领域边界|对应于用例分析中的边界,外部用户、系统、设备与此边界界定的系统交互| |Context Map|上下文映射,主要描述限界上下文间的集成关系,例如OHS(开放主机服务)、PL(发布语言)、ACL(防腐层、适配层)|外部应用 -> 短信服务(发送短信、子账号申请、发送历史查询、充值、充值历史查询)采用OHS/PL,短信服务 -> 短信服务商服务(发送短信)采用ACL,短信服务商服务 -> 短信服务(短信状态回执)采用ACL| 微服务划分方法最佳实践是采用DDD,按照业务进行服务拆分,通过领域专家、设计、开发、测试人员共同在战略层面(strategic)进行领域建模, 组织通用语言(Ubiquitous Language),确定核心子域(Core Domain)、支撑子域(Supported Domain),确定边界限界上下文(Bounded Context),确定限界上下文间协作模式(Context Map)。 通用语言主要包括业务术语/概念、业务操作、领域知识、业务思维模式、业务意图等,确保团队内成员清晰、全面、深入的掌握业务领域的需求。 *该阶段注意:与具体技术实现无关,不考虑具体技术框架、数据存储技术、中间件等,较少考虑非功能性需求(例如:性能、稳定性、扩展性、安全、兼容性)。* 该阶段的目标是: 1. 确定系统核心概念与能力。 2. 确定系统边界,系统边界的确定至关重要,涉及系统核心能力确定、业务概念作用范围,影响系统间耦合。 3. 确定与外部交互模式,目前主流交互模式有OHS、PL、ACL,通过RESTful方式对外部提供服务,通过ACL规避外部系统对本系统的影响,遵守Postel's Law (Robustness Principle)。 ### 战术模式 使用具体模型元素实现领域模型,以SOLID尤其是DIP为指导思想。 ![领域驱动设计战术模式](images/ddd-tactical.png) |要素|含义| |---|---| |Modules|系统由哪些模块构成| |Entity|描述系统对象结构及其关系,需要持久化,通常涉及CRUD操作| |Value Object|描述领域中一个概念,具有不变性| |Repository|定义数据存取接口| |Domain Service|定义多个聚合交互才能完成的业务| |Domain Event|系统核心业务处理到某些扩展点时发布事件,业务流程处理时一般是借助消息中间件的异步处理,在一个算法或框架中一般是同步的观察者模式| |Application Service|定义粗粒度的、对外开放的Facade接口| |Data Transfer Object|与外部系统交互时所采用的数据结构,是针对某个业务场景的,一般不对外开放Entity| *该阶段注意:具体技术实现不在核心领域模型中体现(具体实现在端口适配器中实现),非功能性需求(例如:性能、稳定性、扩展性、安全、兼容性)也主要在端口适配器中,一致性(事务一致性、最终一致性)一般在应用服务中实现;换句话来说,我们为领域模型要创造条件,将一切与核心业务无关的都分离到领域模型之外,只保留存粹的业务* 该阶段的目标是: 1. 定义系统数据结构及其关系(Entity、Aggregate、Assocaition/Composition) 2. 定义业务领域中用于描述某一个方面的概念Value Object,尽可能使用Value Object而非Entity,例如:Password、EncryptedPassword。代码即设计,能够清晰体现业务意图,而不需要额外增加注释来说明,注释和说明文档一样,都不能及时反映代码现状。 3. 定义Repository接口,体现核心领域模型所需的对象持久化能力,本质原因是由于资源的限制,对象无法永远存在于内存中。 4. 定义Domain Service,体现需要多个聚合Aggregate交互才能完成的业务。 5. 定义核心业务处理到某些扩展点时需要发布的事件(Domain Event),领域模型中只需要定义Event,而无需考虑具体实现方式(一般实现为异步处理)。 ## 基于DDD和六边形架构的典型微服务结构 - 功能视角 ![微服务典型结构-功能视角](images/ddd-hex-ms-functional-perspective.png) - 战术工具视角 ![微服务典型结构-战术工具视角](images/ddd-hex-ms-tactical-tools.png) 特征: 1. 领域模型封装了领域概念、概念完整性、不变性条件(业务规则)、业务算法等,构成了该领域核心价值;封装是保护领域模型完整性、一致性的主要手段,遵守LoD原则。 2. 应用服务是领域模型的直接使用者,介于领域模型与端口适配器之间,对系统外提供粗粒度接口方法,并且负责事务及安全管理,应用服务采用Facade模式。 3. 应用程序通过适配器与外界交互,一般有数据持久化、API、客户端、消费者这几类适配器,这时与具体技术相结合;数据持久化适配器实现了领域模型中定义的资源库接口,属于DIP原则的具体体现。 4. 领域模型变化频度低,具有核心价值的业务不会总是变化,适配器随时可以替换成新的实现,例如可以快速增加模拟测试适配器MockAdapter,这也遵循了OCP原则。 ## 基于六边形架构的项目典型结构 ```shell ├── repository-adapter # 资源库实现适配器 │   ├── impl # 实现接口,缓存、es、数据库等不同数据源在这里统一处理,对外隐藏实现细节 │   ├── cache # 缓存 │   ├── orm # 数据库 │   ├── es # 搜索引擎 ├── rest-adapter # 基于HTTP协议对外开放RESTful接口 │   ├── dto │   ├── converter │   ├── interceptor # 一般进行认证授权,web基于Cookie/Session的服务器会话方式,openapi基于App/Secret的签名验签方式 │   ├── controller # web/openapi controller ├── external-adapter # 对接外部系统适配器,实现应用服务层中定义的服务接口 │   ├── proxy ├── mq-adapter # 消息队列、异步事件处理适配器 │   ├── buffer # 事件缓冲区,达到阈值后或定时任务触发后,批量处理 │   ├── consumer # 事件消费者 │   ├── provider # 实现领域事件发布者接口 ├── domain # 领域模型,最核心部分,存粹的业务,不涉及框架与技术实现 │   ├── module1 # 模块1 │   │   ├── entity # 根实体为聚合根,通常与外界交互都通过聚合根。遵循设计小聚合,优先使用值对象原则,遵循迪米特法则、Tell Don't Ask原则 │   │   ├── vo # 封装多个属性构成一个完整性概念,没有副作用的特性可以用于闭包、Lambda、多线程处理、反应式编程(reactor)中 │   │   ├── domain service # 领域服务,粒度较细,一般涉及多个实体交互的才放到领域服务中处理),与之对应的是Application Service(Facade模式,粒度较粗) │   │   ├── repository # 定义数据持久化接口 │   │   ├── domain event # 领域事件 │   ├── module2 │   ├── module3s │   ├── exceptions │   ├── mechanism # 通用机制,领域事件基类、领域事件发布器等 │   ├── registry # 领域注册表,单例模式,通过DomainRegistry.instance().xRepositry()方式访问领域模型中定义的接口,此接口在适配器中实现(DIP)。 ├── libs # 公共类库 ├── application-service # 应用服务层,提供场景化服务 Scenario-Oriented Facade Service,并且负责安全、事务 │   ├── dto │   ├── security │   ├── service ├── application-starter # 装配器与运行容器,配置文件,启动类 │   └── src │   │   └── main │   │   │   ├── java │   │   │   │   ├── config # 根据配置文件初始化配置类,配置类在adapters模块中定义 │   │   │   │   ├── Application.java # 启动类 │   │   │   ├── resources │   │   │   │   ├── application.yml # 配置文件 │   │   │   │   ├── application-test.yml # 配置文件 │   │   │   │   ├── logback-spring.xml # 日志配置文件 ``` 这种模式可以防止由于不小心导致的domain-model与适配器adapters间的不正确的依赖,也利于项目成员分工。领域模型、应用服务、适配器变更相对独立,容易满足OCP原则。 ## 值对象详解 只要是一个或多个数值、字符串等具有明确业务含义,都应该定义成一个Value Object,比如手机号码、国际区号、邮箱、收货地址、主键、联合主键等,Value Object可以封装多个属性构成一个完整性概念。 Value Object典型结构如下: ```java /** * 手机号码值对象,这里只支持中国手机号码 */ @ValueObject(value = "手机号码", desc = "中国手机号码") public class MobilePhone { // 中国手机号码格式 private static final Pattern pattern = Pattern.compile("1[3-9][0-9]{9}"); // 较为宽泛的正则 private static final int PHONE_LENGTH = 11; // 不考虑0开头的 private String phone; // 手机号码内容 /** * 私有化的构造函数,某些框架通过反射机制创建对象,例如mybatis orm framework * 也可以使用Lombok的{@NoArgsConstructor}实现 */ private MobilePhone() { } /** * 构造函数,使用该构造函数创建{@code MobilePhone}对象,其内部调用{@link #setPhone()} 方法 * * @param phone 手机号码 */ private MobilePhone(String phone) { this.setPhone(phone); } /** * 快捷创建{@code MobilePhone}对象 * * @param phone 手机号码 * @return 手机号码值对象 */ public static MobilePhone of(String phone) { return new MobilePhone(phone); } /** * 快捷创建{@code MobilePhone}对象,当手机号码不存在时,返回{@link Optional}可空对象 * * @param phone 手机号码 * @return 可空的手机号码值对象 */ public static Optional ofNullable(String phone) { if (phone == null || phone.length() == 0) { return Optional.empty(); } else { return Optional.of(new MobilePhone(phone)); } } /** * 获取手机号码,也是通过编码方式唯一能够访问手机号码内容的方法 * * @return 手机号码 */ public String getPhone() { return phone; } /** * 设置手机号码,包含手机号码校验,该方法为私有方法,用于某些框架通过反射机制设置对象属性 * 值对象内部,都要通过该方法设置手机号码 * * @param phone 手机号码 */ private void setPhone(String phone) { if (phone == null || phone.length() == 0) { throw new IllegalParameterException("phone_required", "未传入手机号码"); } if (phone.length() != PHONE_LENGTH) { throw new IllegalParameterException("illegal_phone_format", "手机号码格式不正确"); } if (!pattern.matcher(phone).find()) { throw new IllegalParameterException("illegal_phone_format", "手机号码格式不正确"); } this.phone = phone; } /** * 如果该手机号码可能用于计算hash,比如作为Map的键值,需要实现此方法 * 同时还要重写{@link #equals(java.lang.Object)}方法 * * @return hash值 */ @Override public int hashCode() { return this.phone.hashCode(); } /** * 用于比较手机号码值对象是否相等,通过内部的手机号码进行比较 * 当需要计算hash值时,需要同方法{@link #hashCode()}一起重写 * * @param obj 其他手机号码 * @return 是否相等 */ @Override public boolean equals(Object obj) { if (obj instanceof MobilePhone) { return this.phone.equals(((MobilePhone) obj).getPhone()); } return false; } @Override public String toString() { return this.phone; } } ``` 其特点为: 1. 私有化的无参数构造函数,为某些框架反射所用,同样私有化的getter和setter也是同样道理,常用于基于反射的ORM框架。 2. 全属性做为参数的构造函数。 3. 私有化setter,内部封装了校验逻辑;如果是多个属性,还会作为整体概念维护状态一致性。这体现了值对象的不变性(invariant)。 4. 如果有计算hash的需求,同时重写hashCode()和equals(java.lang.Object)方法。 5. 提供of(XXX)方法便捷创建值对象。 6. 还会重写toString()方法便于调试。 采用充血模型编写领域模型代码才能反映真正的业务意图,ValueObject是一个有效的工具,尤其在方法的参数中使用值对象,可以明确反映方法的功能,还可以避免代码书写错误。没有副作用的特性可以用于闭包、Lambda、多线程处理、反应式编程(reactor)中。 ## 避免贫血模型 1. 使用私有setter 2. 实体自校验 3. 实体封装业务规则 4. 避免使用无参构造函数 5. 再三斟酌是否要创建XXXService,特指领域服务(Domain Service) 6. 谨慎使用ORM 7. 不使用Transaction Script方式开发 8. 使用面向对象开发OOP ## 评价服务设计是否合理 1. 当数据存储方式变化,是否影响应用服务和领域模型? 2. 当业务规则发生变化,是否影响应用服务和端口适配器? 3. 当新增一个端口适配器,是否影响应用服务和领域模型? 4. 当替换掉一个适配器时,是否影响领域模型? 5. 当数据库、缓存Redis、消息队列RabbitMQ等中间件都没有的情况下,能否很方便地搭建可测试的运行环境? [返回目录](#目录) # 参考资料 1. 《Introduction to Microservices》 Chris Richardson 2. 《Microservices vs. Service-Oriented Architecture》 Mark Richards 3. 《Microservices AntiPatterns and Pitfalls》 Mark Richards 4. 《Building Microservices》 Sam Newman 5. 《Service-Based Architectures: Structure, Engineering Practices, and Migration》 Neal Ford, Mark Richards 6. 《领域驱动设计-软件核心复杂性应对之道》 Eric Evans 7. 《实现领域驱动设计》 Vaughn Vernon [返回目录](#目录)