文章目录
1. DDD四层架构
技术:SpringBoot、Mybatis、Dubbo、MQ、Redis、Mysql、ELK、分库分表、Otter
架构:DDD 领域驱动设计、充血模型、设计模式
-
应用层{application}
应用服务位于应用层。用来表述应用和用户行为,负责服务的组合、编排和转发,负责处理业务用例的执行顺序以及结果的拼装。
应用层的服务包括应用服务和领域事件相关服务。
应用服务可对微服务内的领域服务以及微服务外的应用服务进行组合和编排,或者对基础层如文件、缓存等数据直接操作形成应用服务,对外提供粗粒度的服务。
领域事件服务包括两类:领域事件的发布和订阅。通过事件总线和消息队列实现异步数据传输,实现微服务之间的解耦。 -
领域层{domain}
领域服务位于领域层,为完成领域中跨实体或值对象的操作转换而封装的服务,领域服务以与实体和值对象相同的方式参与实施过程。
领域服务对同一个实体的一个或多个方法进行组合和封装,或对多个不同实体的操作进行组合或编排,对外暴露成领域服务。领域服务封装了核心的业务逻辑。实体自身的行为在实体类内部实现,向上封装成领域服务暴露。
为隐藏领域层的业务逻辑实现,所有领域方法和服务等均须通过领域服务对外暴露。
为实现微服务内聚合之间的解耦,原则上禁止跨聚合的领域服务调用和跨聚合的数据相互关联。 -
基础层{infrastructure}
基础服务位于基础层。为各层提供资源服务(如数据库、缓存等),实现各层的解耦,降低外部资源变化对业务逻辑的影响。
基础服务主要为仓储服务,通过依赖反转的方式为各层提供基础资源服务,领域服务和应用服务调用仓储服务接口,利用仓储实现持久化数据对象或直接访问基础资源。 -
接口层{interfaces}
接口服务位于用户接口层,用于处理用户发送的Restful请求和解析用户输入的配置文件等,并将信息传递给应用层。
综上,就是对 DDD 领域驱动设计的一个基本描述,不过也不用过于神秘化DDD,我们可以把DDD四层架构和MVC三层架构架构理解为家里的格局,三居和四居,只不过DDD是在MVC的基础上可以更加明确了房间的布局,可能效果上就像你原来有个三居中没有独立的书房,现在四居了你可以有一个自己的小空间了。
那么,这里还有一点就是DDD结构它是一种充血模型结构,所有的服务实现都以领域为核心,应用层定义接口,领域层实现接口,领域层定义数据仓储,基础层实现数据仓储中关于DAO和Redis的操作,但同时几方又有互相的依赖。那么这样的结构再开发独立领域提供 http 接口时候,并不会有什么问题体现出来。但如果这个时候需要引入 RPC 框架,就会暴露问题了,因为使用 RPC 框架的时候,需要对外提供描述接口信息的 Jar 让外部调用方引入才可以通过反射调用到具体的方法提供者,那么这个时候,RPC 需要暴露出来,而 DDD 的系统结构又比较耦合,怎么进行模块化的分离就成了问题点。所以我们本章节在模块系统结构搭建的时候,也是以解决此项问题为核心进行处理的。
DDD + RPC,模块分离系统搭建:
如果按照模块化拆分,那么会需要做一些处理,包括:
- 应用层,不再给领域层定义接口,而是自行处理对领域层接口的包装。否则领域层既引入了应用层的Jar,应用层也引入了领域层的Jar,就会出现循环依赖的问题。
- 基础层中的数据仓储的定义也需要从领域层剥离,否则也会出现循环依赖的问题。
RPC 层定义接口描述,包括:入参Req、出参Res、DTO对象,接口信息,这些内容定义出来的Jar给接口层使用,也给外部调用方使用。
2. 项目搭建
按照现有工程的结构模块分层,包括:
lottery-application,应用层,引用:domain
lottery-common,通用包,引用:无
lottery-domain,领域层,引用:infrastructure
lottery-infrastructure,基础层,引用:无
lottery-interfaces,接口层,引用:application、rpc
lottery-rpc,RPC接口定义层,引用:common
在此分层结构和依赖引用下,各层级模块不能循环依赖
,同时 lottery-interfaces 作为系统的 war
包工程,在构建工程时候需要依赖于 POM 中配置的相关信息。那这里就需要注意下,作为 LotterySystem 工程下的主 pom.xml 需要完成对 SpringBoot 父文件的依赖
,此外还需要定义一些用于其他模块可以引入的配置信息,比如:jdk版本
、编码方式
等。而其他层在依赖于工程总 pom.xml 后还需要配置自己的信息。
lottery-interfaces 是整个程序的出口,也是用于构建 War 包的工程模块,所以你会看到一个 打包方式 war
的配置。
在 SpringBoot 的使用中,你会看到各种 xxx-starter,它们这些组件的包装都是用于完成桥梁的作用,把一些服务交给 SpringBoot 启动时候初始化或者加载配置等操作。
3. 抽奖获得库表设计
4. 抽奖策略领域模块开发
- 在库表设计上我们把抽奖需要的策略配置和策略明细,它们的关系是1vn。
- 另外为了让抽奖策略成为可以独立配置和使用的领域模块,在策略表用不引入活动ID信息的配置。因为在建设领域模块的时候,我们需要把让这部分的领域实现具有可独立运行的特性,不让它被业务逻辑污染,它只是一种无业务逻辑的通用共性的功能领域模块,在业务组合的过程中可以使用此功能领域提供的标准接口。
- 通过这样的设计实现,就可以满足于不同业务场景的灵活调用,例如:
有些业务场景是需要你直接来进行抽奖反馈中奖信息发送给用户,但还有一些因为用户下单支付才满足抽奖条件的场景对应的奖品是需要延时到账的
,避免用户在下单后又进行退单,这样造成了刷单的风险。所以有时候你的设计是与业务场景息息相关的
DDD相比较MVC的好处
mvc架构时我们的po类里面很多的类,并且dao下面也是,它们都是在一个包下,在项目越来越大时,不同service调用dao、po可能这种引用的关系就很混乱,而DDD架构其实从包的角度就区分开来了,引用关系变得很清晰。
领会策略模式:在这个项目中的话其实是根据不同的抽奖算法,可以算是不同的抽奖策略,最终提供统一的接口包装满足不同的抽奖功能调用。
定义策略接口,对接口做不同的实现,即表示不同的抽奖策略,我们其实是在数据库表字段加了策略方式,根据这个表查出来的策略方式选择合适的策略方法,我们将策略是写到map中的,key就是数据库字段的值。 当然大体的架子就是一个接口,接口不同实现类,包括这个拿策略的方式。
5. 模板模式处理抽奖流程
把抽奖流程标准化,需要考虑的一条思路线包括:
- 根据入参策略ID获取抽奖策略配置
- 校验和处理抽奖策略的数据初始化到内存
- 获取那些被排除掉的抽奖列表,这些奖品可能是已经奖品库存为空,或者因为风控策略不能给这个用户薅羊毛的奖品
- 执行抽奖算法
- 包装中奖结果
以上这些步骤就是需要在抽奖执行类的方法中需要处理的内容,如果是在一个类的一个方法中,顺序开发这些内容也是可以实现的。但这样的代码实现过程是不易于维护的,也不太方便在各个流程节点扩展其他功能
,也会使一个类的代码越来越庞大,因此对于这种可以制定标准流程的功能逻辑,通常使用模板方法模式
是非常合适的。
- DrawConfig:配置抽奖策略,SingleRateRandomDrawAlgorithm、EntiretyRateRandomDrawAlgorithm
- DrawStrategySupport:提供抽奖策略
数据支持
,便于查询策略配置、奖品信息。通过这样的方式隔离职责
。 - AbstractDrawBase:
抽象类定义模板方法流程
,在抽象类的 doDrawExec 方法中,处理整个抽奖流程,并提供在流程中需要使用到的抽象方法,由 DrawExecImpl 服务逻辑中做具体实现。
6. 简单工厂模式:省去很多if判断(优点)
搭建发奖领域服务。
介绍:定义一个创建对象
的接口,让其子类自己决定实例化哪一个工厂类
,工厂模式使其创建过程延迟
到子类进行。
当前是在domain 领域层的建设,当各项核心的领域服务开发完成以后,则会在 application 层做服务编排
流程处理的开发。例如:从用户参与抽奖活动、过滤规则、执行抽奖、存放结果、发送奖品等内容的链路处理。涉及的领域如下:
目录结构
核心:分别是:goods 商品处理、factory 工厂🏭
- goods:包装适配各类奖品的发放逻辑,虽然我们目前的抽奖系统仅是给用户返回一个中奖描述,但在实际的业务场景中,是真实的调用优惠券、兑换码、物流发货等操作,而这些内容经过封装后就可以在自己的商品类下实现了。
- factory:工厂模式通过调用方提供发奖类型,返回对应的发奖服务。通过这样由具体的子类决定返回结果,并做相应的业务处理。从而不至于让领域层包装太多的频繁变化的业务属性,因为如果你的核心功能域是在做业务逻辑封装,就会就会变得非常庞大且混乱。
IDistributionGoods:抽象出配送货物接口,把各类奖品模拟成货物、配送代表着发货,包括虚拟奖品和实物奖品
口述: 工厂模式其实就是new对象的,造对象的,当然这只是最基础的一个认识,工厂模式根据用户传入的需求匹配我们需要的服务,这个的话就是将实现类初始化到map中,factory根据用户传入的挑选特定的服务,像抽奖系统,中奖后我们要给用户发奖,发奖类型也很多,比如优惠卷、兑换码、红包、实物产品等等。我们定义一个接口:配送货物接口,然后根据发奖类型写实现,最后工厂模式根据中奖类型来返回与中奖类型一致的服务。
7. 活动领域的配置与状态
活动领域层需要提供的功能包括:活动创建、活动状态处理和用户领取活动
- 活动的创建:添加活动配置、添加奖品配置、添加策略配置、添加策略明细配置,这些都是在同一个注解事务配置下进行处理 @Transactional(rollbackFor = Exception.class)
- 状态变更(
状态模式
):
状态模式:类的行为是基于它的状态改变的,这种类型的设计模式属于行为型模式。它描述的是一个行为下的多种状态变更,比如我们最常见的一个网站的页面,在你登录与不登录下展示的内容是略有差异的(不登录不能展示个人信息),而这种登录与不登录就是我们通过改变状态,而让整个行为发生了变化。
在上图中也可以看到我们的流程节点中包括了各个状态到下一个状态扭转的关联条件,比如;审核通过才能到活动中,而不能从编辑中直接到活动中,而这些状态的转变就是我们要完成的场景处理。
大部分程序员基本都开发过类似的业务场景,需要对活动或者一些配置需要审核后才能对外发布,而这个审核的过程往往会随着系统的重要程度而设立多级控制,来保证一个活动可以安全上线,避免造成误操作引起资损。
状态模式结构:stateflow 状态流转运用的状态模式,主要包括抽象出状态抽象类AbstractState 和对应的 event 包下的状态处理,最终使用 StateHandlerImpl 来提供对外的接口服务。
口述::定义一个活动状态的抽象类,抽象类包含全部的状态,对每个状态创建一个类继承抽象类,重写里面的所有状态方法,完成状态转换的逻辑:有的状态可以转换有的不行,提供一个状态处理的接口,写对应实现类,用户在调用时只需调用状态转换方法就ok,因为这个方法会根据当前状态在map中获取到对应的状态类,执行******状态转变方法,因为这个类继承了抽象类,实现类里面包含了所有的状态转换的方法,有的可以转变有的状态不能转变,不能转变的我们直接返回info信息写不能转变,能转变的我们就要改数据库字段,这个我们传入数据库新状态和旧状态
,旧状态在where条件里面,只有旧状态是什么什么那么好我们可以改变状态,改变到新状态,也相当于一个保障吧,。
mybatis插入list:
8. ID生成策略领域开发
使用策略模式把三种生成ID的算法进行统一包装,由调用方决定使用哪种生成ID的策略。
雪花算法
本章节使用的是工具包 hutool 包装好的工具类,一般在实际使用雪花算法时需要做一些优化处理,比如支持时间回拨、支持手工插入、简短生成长度、提升生成速度等。日期拼接
和随机数
工具包生成方式,都需要自己保证唯一性,一般使用此方式生成的ID,都用在单表中,本身可以在数据库配置唯一ID。那为什么不用自增ID,因为自增ID通常容易被外界知晓你的运营数据,以及后续需要做数据迁移到分库分表中都会有些麻烦
关于 ID 的生成因为有三种不同 ID 用于在不同的场景下;
订单号:唯一、大量、订单创建时使用、分库分表
活动号:唯一、少量、活动创建时使用、单库单表
策略号:唯一、少量、活动创建时使用、单库单表
IIdGenerator,定义生成ID的策略接口。RandomNumeric、ShortCode、SnowFlake,是三种生成ID的策略。
IdContext,ID生成上下文,也就是从这里提供策略配置服务。
每种ID策略获取到的ID结果都有所不同,这也是为了适合不同类型的ID使用,避免同样长度的ID造成混乱。比如订单ID用在活动ID生成上,就会显得有些混乱
9. 分库分表组件开发
分库分表操作主要有垂直拆分和水平拆分:
- 垂直拆分:指按照业务将表进行分类,分布到不同的数据库上,这样也就将数据的压力分担到不同的库上面。最终一个数据库由很多表的构成,每个表对应着不同的业务,也就是专库专用。
- 水平拆分:如果垂直拆分后遇到单机瓶颈,可以使用水平拆分。相对于垂直拆分的区别是:垂直拆分是把不同的表拆到不同的数据库中,而本章节需要实现的水平拆分,是把同一个表拆到不同的数据库中。如:user_001、user_002
那么,这样的一个数据库路由设计要包括哪些技术知识点呢?
- 是关于 AOP 切面拦截的使用,这是因为需要给使用数据库路由的方法做上标记,便于处理分库分表逻辑。
- 数据源的切换操作,既然有分库那么就会涉及在多个数据源间进行链接切换,以便把数据分配给不同的数据库。
- 数据库表寻址操作,一条数据分配到哪个数据库,哪张表,都需要进行索引计算。在方法调用的过程中最终通过 ThreadLocal 记录。
- 为了能让数据均匀的分配到不同的库表中去,还需要考虑如何进行数据散列的操作,不能分库分表后,让数据都集中在某个库的某个表,这样就失去了分库分表的意义。
实现步骤:
-
数据源配置提取:凡注册到Spring容器内的bean,实现了EnvironmentAware接口重写setEnvironment方法后,在工程启动时可以获得application.properties的配置文件配置的属性值。
-
数据源切换
在结合 SpringBoot 开发的 Starter 中,需要提供一个 DataSource 的实例化对象,那么这个对象我们就放在 DataSourceAutoConfig 来实现,并且这里提供的数据源是可以动态变换的,也就是支持动态切换数据源。 -
切面拦截:在 AOP 的切面拦截中需要完成;数据库路由计算、扰动函数加强散列、计算库表索引、设置到 ThreadLocal 传递数据源,
- 提取了库表乘积的数量,把它当成 HashMap 一样的长度进行使用。
- 接下来使用和 HashMap 一样的扰动函数逻辑,让数据分散的更加散列(哈希值右移,混合了高8位,执行异或操作):(size - 1) & (dbKeyAttr.hashCode() ^ (dbKeyAttr.hashCode() >>> 16));
- 当计算完总长度上的一个索引位置后,还需要把这个位置折算到库表中,看看总体长度的索引因为落到哪个库哪个表。
- 最后是把这个计算的索引信息存放到 ThreadLocal 中,用于传递在方法调用过程中可以提取到索引信息。
-
Mybatis 拦截器处理分表:
- 那么我们可以基于 Mybatis 拦截器进行处理,通过拦截 SQL 语句动态修改添加分表信息,再设置回 Mybatis 执行 SQL 中。
- 此外再完善一些分库分表路由的操作,比如配置默认的分库分表字段以及单字段入参时默认取此字段作为路由字段。
10. 声明事务领取活动领域开发
基于模板模式开发领取活动领域,因为在领取活动中需要进行活动的日期、库存、状态等校验,并处理扣减库存、添加用户领取信息、封装结果等一系列流程操作,因此使用抽象类定义模板模式更为妥当。
问题:如果一个场景需要在同一个事务下,连续操作不同的DAO操作,那么就会涉及到在 DAO 上使用注解 @DBRouter(key = “uId”) 反复切换路由的操作。虽然都是一个数据源,但这样切换后,事务就没法处理了。
解决:这里选择了一个较低的成本的解决方案,就是把数据源的切换放在事务处理前,而事务操作也通过编程式编码进行处理。
编程:
- 拆解路由算法策略,单独提供路由方法:把路由算法拆解出来,无论是切面中还是硬编码,都通过这个方法进行计算路由
public interface IDBRouterStrategy {
void doRouter(String dbKeyAttr);
void clear();
}
- 配置事务处理对象:
创建路由策略对象,便于切面和硬编码注入使用。
创建事务对象,用于编程式事务引入
@Bean
public IDBRouterStrategy dbRouterStrategy(DBRouterConfig dbRouterConfig) {
return new DBRouterStrategyHashCode(dbRouterConfig);
}
@Bean
public TransactionTemplate transactionTemplate(DataSource dataSource) {
DataSourceTransactionManager dataSourceTransactionManager = new DataSourceTransactionManager();
dataSourceTransactionManager.setDataSource(dataSource);
TransactionTemplate transactionTemplate = new TransactionTemplate();
transactionTemplate.setTransactionManager(dataSourceTransactionManager);
transactionTemplate.setPropagationBehaviorName("PROPAGATION_REQUIRED");
return transactionTemplate;
}
- 活动领取模板抽象类
抽象类 BaseActivityPartake 继承数据支撑类并实现接口方法 IActivityPartake#doPartake
在领取活动 doPartake 方法中,先是通过父类提供的数据服务,获取到活动账单,再定义三个抽象方法:活动信息校验处理、扣减活动库存、领取活动,依次顺序解决活动的领取操作。
public abstract class BaseActivityPartake extends ActivityPartakeSupport implements IActivityPartake {
@Override
public PartakeResult doPartake(PartakeReq req) {
// 查询活动账单
ActivityBillVO activityBillVO = super.queryActivityBill(req);
// 活动信息校验处理【活动库存、状态、日期、个人参与次数】
Result checkResult = this.checkActivityBill(req, activityBillVO);
if (!Constants.ResponseCode.SUCCESS.getCode().equals(checkResult.getCode())) {
return new PartakeResult(checkResult.getCode(), checkResult.getInfo());
}
// 扣减活动库存【目前为直接对配置库中的 lottery.activity 直接操作表扣减库存,后续优化为Redis扣减】
Result subtractionActivityResult = this.subtractionActivityStock(req);
if (!Constants.ResponseCode.SUCCESS.getCode().equals(subtractionActivityResult.getCode())) {
return new PartakeResult(subtractionActivityResult.getCode(), subtractionActivityResult.getInfo());
}
// 领取活动信息【个人用户把活动信息写入到用户表】
Result grabResult = this.grabActivity(req, activityBillVO);
if (!Constants.ResponseCode.SUCCESS.getCode().equals(grabResult.getCode())) {
return new PartakeResult(grabResult.getCode(), grabResult.getInfo());
}
// 封装结果【返回的策略ID,用于继续完成抽奖步骤】
PartakeResult partakeResult = new PartakeResult(Constants.ResponseCode.SUCCESS.getCode(), Constants.ResponseCode.SUCCESS.getInfo());
partakeResult.setStrategyId(activityBillVO.getStrategyId());
return partakeResult;
}
/**
* 活动信息校验处理,把活动库存、状态、日期、个人参与次数
*
* @param partake 参与活动请求
* @param bill 活动账单
* @return 校验结果
*/
protected abstract Result checkActivityBill(PartakeReq partake, ActivityBillVO bill);
/**
* 扣减活动库存
*
* @param req 参与活动请求
* @return 扣减结果
*/
protected abstract Result subtractionActivityStock(PartakeReq req);
/**
* 领取活动
*
* @param partake 参与活动请求
* @param bill 活动账单
* @return 领取结果
*/
protected abstract Result grabActivity(PartakeReq partake, ActivityBillVO bill);
}
- 领取活动编程式事务处理:
dbRouter.doRouter(partake.getuId()); 是编程式处理分库分表
,如果在不需要使用事务的场景下,直接使用注解配置到DAO方法上即可。两个方式不能混用
transactionTemplate.execute 是编程式事务
,用的就是路由中间件提供的事务对象,通过这样的方式也可以更加方便的处理细节的回滚,而不需要抛异常处理
。
@Service
public class ActivityPartakeImpl extends BaseActivityPartake {
private Logger logger = LoggerFactory.getLogger(ActivityPartakeImpl.class);
@Override
protected Result grabActivity(PartakeReq partake, ActivityBillVO bill) {
try {
dbRouter.doRouter(partake.getuId());
return transactionTemplate.execute(status -> {
try {
// 扣减个人已参与次数
int updateCount = userTakeActivityRepository.subtractionLeftCount(bill.getActivityId(), bill.getActivityName(), bill.getTakeCount(), bill.getUserTakeLeftCount(), partake.getuId(), partake.getPartakeDate());
if (0 == updateCount) {
status.setRollbackOnly();
logger.error("领取活动,扣减个人已参与次数失败 activityId:{} uId:{}", partake.getActivityId(), partake.getuId());
return Result.buildResult(Constants.ResponseCode.NO_UPDATE);
}
// 插入领取活动信息
Long takeId = idGeneratorMap.get(Constants.Ids.SnowFlake).nextId();
userTakeActivityRepository.takeActivity(bill.getActivityId(), bill.getActivityName(), bill.getTakeCount(), bill.getUserTakeLeftCount(), partake.getuId(), partake.getPartakeDate(), takeId);
} catch (DuplicateKeyException e) {
status.setRollbackOnly();
logger.error("领取活动,唯一索引冲突 activityId:{} uId:{}", partake.getActivityId(), partake.getuId(), e);
return Result.buildResult(Constants.ResponseCode.INDEX_DUP);
}
return Result.buildSuccessResult();
});
} finally {
dbRouter.clear();
}
}
}
11. 在应用层编排抽奖过程
描述:在 application 应用层调用领域服务功能,编排抽奖
过程,包括:领取活动
、执行抽奖
、落库结果
,这其中还有一部分待实现的发送 MQ
消息,后续处理。
编排流程:
代码:
- 领取活动增加判断和返回领取单ID
- 活动领域中主要是领取活动新增加了第1步的查询流程和修改第5步返回takeId
- 查询是否存在未执行抽奖领取活动单。在SQL查询当前活动ID,用户最早领取但未消费的一条记录【这部分一般会有业务流程限制,比如是否处理最先还是最新领取单,要根据自己的业务实际场景进行处理】
- this.grabActivity 方法,用户领取活动时候,新增记录:strategy_id、state 两个字段,这两个字段就是为了处理用户对领取镜像记录的二次处理未执行抽奖的领取单,以及state状态控制事务操作的幂等性
- 抽奖活动流程编排
流程口述:
1 先领取活动,这时候先看看user_take_activity表中state有没有为0的:表示领取了活动但是抽奖失败的,这时候就直接拿这个记录执行抽奖,要是没有,则先去user_take_activity_count和activity表中查看看activity中有没有活动库存,user_take_activity_count表中有没有用户可领取的活动,有的话领取活动,减user_take_activity_count表中用户可用的活动数,并添加活动记录到user_take_activity表中,以上是领取活动的逻辑;
2 . 之后执行抽奖逻辑,拿到uId、strategyId、takeId(对应user_take_activity表的活动记录)后,抽奖,选择抽奖算法:全局概率还是单体概率,拿到抽奖后的奖品id,奖品的信息,
3. 将抽奖的结果落库,这里将user_take_activity表中的state 修改为1 ,where state为0的条件,相当于乐观锁,表示活动只能用一次,用完需要重新领取活动,即重新在user_take_activity增加一条记录,上面是落库的第一步,先改state,再将用户id奖品等等信息写入user_strategy_export_?,这里是分库分表,路由到特定的表,之后 将信息写入user_strategy_export_?表中,因为是两个表,而且是不同库的不同表,这里采用编程式事务,避免事务失效,如下图:
4. 而后MQ发消息触发发奖流程,发货
5. 最后返回结果,结果中有code、info、还有奖品信息。
12 规则引擎量化人群参与活动
描述:使用组合模式
搭建用于量化人群的规则引擎,用于用户参与活动之前,通过规则引擎过滤
性别、年龄、首单消费、消费金额、忠实用户等各类身份来量化出具体可参与的抽奖活动。通过这样的方式控制运营成本和精细化运营。
运用组合模式搭建规则引擎领域服务,包括:logic 逻辑过滤器
、engine 引擎执行器
组合模式:组合模式解决这样的问题,当我们的要处理的对象可以生成一颗树形结构
,而我们要对树上的节点和叶子进行操作时,它能够提供一致的方式
,而不用考虑它是节点还是叶子
库表设计:组合模式的特点就像是搭建出一棵二叉树
,而库表中则需要把这样一颗二叉树存放进去,那么这里就需要包括:树根
、树茎
、子叶
、果实
。在具体的逻辑实现中则需要通过子叶判断走哪个树茎以及最终筛选出一个果实来
。
- 基于量化决策引擎,
筛选
用户身份标签,找到符合参与的活动号
。拿到活动号后,就可以参与到具体的抽奖活动中了。 - 通常量化决策引擎也是一种用于
差异化人群的规则过滤器
,不只是可以过滤出活动,也可以用于活动唯独的过滤,判断是否可以参与到这个抽奖活动中。
- 首先可以看下黑色框框的模拟指导树结构;1、11、12、111、112、121、122,这是一组树结构的ID,并由节点串联组合出一棵关系树。
- 接下来是类图部分,左侧是从LogicFilter开始定义适配的决策过滤器,BaseLogic是对接口的实现,提供最基本的通用方法。UserAgeFilter、UserGenerFilter,是两个具体的实现类用于判断年龄和性别。
- 最后则是对这颗可以被组织出来的决策树,进行执行的引擎。同样定义了引擎接口和基础的配置,在配置里面设定了需要的模式决策节点。
口述组合模式来规则化用户可参与的活动::
组合模式针对处理的是一颗树形结构
,我们用组合模式来规则化用户可参与的活动,其实就是筛选用户身份标签,找到符合参与的活动号。拿到活动号后,就可以参与到具体的抽奖活动中了。
活动号的信息存储在叶子节点中,非叶子节点是一系列的判断条件,比如年龄、性别、等等,由这些筛选条件可以组合成一棵树的结构,比如我们筛选条件是男性大于20岁,首先根据年龄这个节点的判断,我们走了左分支,左分支是男右分支是女,然后再从左分支中的年龄这个判断节点,再判断年龄,比如年龄大于20岁走的是右分支,这样逐层的进行条件的过滤,直到找到叶子节点,当前非叶子节点的条件的化还可以有其他的,最后经过层层筛选找到叶子节点,也就找到了活动的信息,表示用户能够参与的活动,实现了对用户的筛选,匹配出所对应的活动,然后参加活动执行抽奖。
其实这里的组合模式的化也就比较明了了,定义一个接口声明节点的过滤方法,这个过滤方法就是按照当前节点的筛选条件,然后选出走左分支还是右分支,这个方法是非叶子节点和叶子节点所共有的,非叶子节点和叶子节点都需要实现接口,这部分我们定义为logic 逻辑过滤器,就是选节点因为走哪个分支的;
之后,我们又定义了engine 引擎执行器,这部分的话其实就是对树的处理了,获得到根节点,从根节点开始,每一个节点都调用logic 逻辑过滤器,决定当前节点应该怎么走,直到一步一步走到叶子节点,我们实现了对用户的一个层层筛选,最后得到用户满足什么什么活动,因为其实叶子节点中就存了活动的信息,之后用户拿到了活动信息后就能参与活动,执行抽奖逻辑了。
13. 门面接口封装和对象转换
使用门面模式
,比如我们代码中到controller,其实就是一个门面类,供前端来调用.门面中封装了各个子系统的调用逻辑,提供一个统一的接口供外界调用。
开发日志:
- 补充 lottery-application 应用层对规则引擎的调用,添加接口方法 IActivityProcess#doRuleQuantificationCrowd
- 删掉 lottery-rpc 测试内容,新增加抽奖活动展台接口 ILotteryActivityBooth,并添加两个抽奖的接口方法,普通抽奖和量化人群抽奖。
- 开发 lottery-interfaces 接口层,对抽奖活动的封装,并对外提供抽奖服务。
- lottery-interfaces 是
对 lottery-rpc 接口定义的具体实现
,在 rpc 接口定义层还会定义出 DTO、REQ、RES 对象 - lottery-interfaces 包括 facade 门面接口、assembler 对象转换操作
接口层代码:
@Controller
public class LotteryActivityBooth implements ILotteryActivityBooth {
private Logger logger = LoggerFactory.getLogger(LotteryActivityBooth.class);
@Resource
private IActivityProcess activityProcess;
@Resource
private IMapping<DrawAwardVO, AwardDTO> awardMapping;
@Override
public DrawRes doDraw(DrawReq drawReq) {
try {
logger.info("抽奖,开始 uId:{} activityId:{}", drawReq.getuId(), drawReq.getActivityId());
// 1. 执行抽奖
DrawProcessResult drawProcessResult = activityProcess.doDrawProcess(new DrawProcessReq(drawReq.getuId(), drawReq.getActivityId()));
if (!Constants.ResponseCode.SUCCESS.getCode().equals(drawProcessResult.getCode())) {
logger.error("抽奖,失败(抽奖过程异常) uId:{} activityId:{}", drawReq.getuId(), drawReq.getActivityId());
return new DrawRes(drawProcessResult.getCode(), drawProcessResult.getInfo());
}
// 2. 数据转换
DrawAwardVO drawAwardVO = drawProcessResult.getDrawAwardVO();
AwardDTO awardDTO = awardMapping.sourceToTarget(drawAwardVO);
awardDTO.setActivityId(drawReq.getActivityId());
// 3. 封装数据
DrawRes drawRes = new DrawRes(Constants.ResponseCode.SUCCESS.getCode(), Constants.ResponseCode.SUCCESS.getInfo());
drawRes.setAwardDTO(awardDTO);
logger.info("抽奖,完成 uId:{} activityId:{} drawRes:{}", drawReq.getuId(), drawReq.getActivityId(), JSON.toJSONString(drawRes));
return drawRes;
} catch (Exception e) {
logger.error("抽奖,失败 uId:{} activityId:{} reqJson:{}", drawReq.getuId(), drawReq.getActivityId(), JSON.toJSONString(drawReq), e);
return new DrawRes(Constants.ResponseCode.UN_ERROR.getCode(), Constants.ResponseCode.UN_ERROR.getInfo());
}
}
@Override
public DrawRes doQuantificationDraw(QuantificationDrawReq quantificationDrawReq) {
try {
logger.info("量化人群抽奖,开始 uId:{} treeId:{}", quantificationDrawReq.getuId(), quantificationDrawReq.getTreeId());
// 1. 执行规则引擎,获取用户可以参与的活动号
RuleQuantificationCrowdResult ruleQuantificationCrowdResult = activityProcess.doRuleQuantificationCrowd(new DecisionMatterReq(quantificationDrawReq.getuId(), quantificationDrawReq.getTreeId(), quantificationDrawReq.getValMap()));
if (!Constants.ResponseCode.SUCCESS.getCode().equals(ruleQuantificationCrowdResult.getCode())) {
logger.error("量化人群抽奖,失败(规则引擎执行异常) uId:{} treeId:{}", quantificationDrawReq.getuId(), quantificationDrawReq.getTreeId());
return new DrawRes(ruleQuantificationCrowdResult.getCode(), ruleQuantificationCrowdResult.getInfo());
}
// 2. 执行抽奖
Long activityId = ruleQuantificationCrowdResult.getActivityId();
DrawProcessResult drawProcessResult = activityProcess.doDrawProcess(new DrawProcessReq(quantificationDrawReq.getuId(), activityId));
if (!Constants.ResponseCode.SUCCESS.getCode().equals(drawProcessResult.getCode())) {
logger.error("量化人群抽奖,失败(抽奖过程异常) uId:{} treeId:{}", quantificationDrawReq.getuId(), quantificationDrawReq.getTreeId());
return new DrawRes(drawProcessResult.getCode(), drawProcessResult.getInfo());
}
// 3. 数据转换
DrawAwardVO drawAwardVO = drawProcessResult.getDrawAwardVO();
AwardDTO awardDTO = awardMapping.sourceToTarget(drawAwardVO);
awardDTO.setActivityId(activityId);
// 4. 封装数据
DrawRes drawRes = new DrawRes(Constants.ResponseCode.SUCCESS.getCode(), Constants.ResponseCode.SUCCESS.getInfo());
drawRes.setAwardDTO(awardDTO);
logger.info("量化人群抽奖,完成 uId:{} treeId:{} drawRes:{}", quantificationDrawReq.getuId(), quantificationDrawReq.getTreeId(), JSON.toJSONString(drawRes));
return drawRes;
} catch (Exception e) {
logger.error("量化人群抽奖,失败 uId:{} treeId:{} reqJson:{}", quantificationDrawReq.getuId(), quantificationDrawReq.getTreeId(), JSON.toJSONString(quantificationDrawReq), e);
return new DrawRes(Constants.ResponseCode.UN_ERROR.getCode(), Constants.ResponseCode.UN_ERROR.getInfo());
}
}
}
- 接口层实现rpc接口
- 在抽奖活动展台的类中主要实现了两个接口方法,
指定活动抽奖(doDraw)
、量化人群抽奖(doQuantificationDraw)
对象转换:
@Mapper(componentModel = "spring", unmappedTargetPolicy = ReportingPolicy.IGNORE, unmappedSourcePolicy = ReportingPolicy.IGNORE)
public interface AwardMapping extends IMapping<DrawAwardVO, AwardDTO> {
@Mapping(target = "userId", source = "uId")
@Override
AwardDTO sourceToTarget(DrawAwardVO var1);
@Override
DrawAwardVO targetToSource(AwardDTO var1);
}
- 定义接口 AwardMapping 继承 IMapping<DrawAwardVO, AwardDTO> 做对象转换操作
- 如果一些接口字段在两个对象间不是同名的,则需要进行配置,就像 uId -> userId
14. 使用MQ解耦抽奖发货流程
描述:使用MQ消息的特性,把用户抽奖到发货到流程进行解耦
。这个过程中包括了消息的发送
、库表中状态的更新
、消息的接收消费
、发奖状态的处理
等。
发送消息:创建一个类,我们会把所有的生产消息都放到 KafkaProducer 中,并对外提供一个可以发送 MQ 消息的方法。
消费消息: 对应的就是处理逻辑了,根据奖品类型在工厂拿到对应的实现类。
@Component
public class LotteryInvoiceListener {
private Logger logger = LoggerFactory.getLogger(LotteryInvoiceListener.class);
@Resource
private DistributionGoodsFactory distributionGoodsFactory;
@KafkaListener(topics = "lottery_invoice", groupId = "lottery")
public void onMessage(ConsumerRecord<?, ?> record, Acknowledgment ack, @Header(KafkaHeaders.RECEIVED_TOPIC) String topic) {
Optional<?> message = Optional.ofNullable(record.value());
// 1. 判断消息是否存在
if (!message.isPresent()) {
return;
}
// 2. 处理 MQ 消息
try {
// 1. 转化对象(或者你也可以重写Serializer<T>)
InvoiceVO invoiceVO = JSON.parseObject((String) message.get(), InvoiceVO.class);
// 2. 获取发送奖品工厂,执行发奖
IDistributionGoods distributionGoodsService = distributionGoodsFactory.getDistributionGoodsService(invoiceVO.getAwardType());
DistributionRes distributionRes = distributionGoodsService.doDistribution(new GoodsReq(invoiceVO.getuId(), invoiceVO.getOrderId(), invoiceVO.getAwardId(), invoiceVO.getAwardName(), invoiceVO.getAwardContent()));
Assert.isTrue(Constants.AwardState.SUCCESS.getCode().equals(distributionRes.getCode()), distributionRes.getInfo());
// 3. 打印日志
logger.info("消费MQ消息,完成 topic:{} bizId:{} 发奖结果:{}", topic, invoiceVO.getuId(), JSON.toJSONString(distributionRes));
// 4. 消息消费完成
ack.acknowledge();
} catch (Exception e) {
// 发奖环节失败,消息重试。所有到环节,发货、更新库,都需要保证幂等。
logger.error("消费MQ消息,失败 topic:{} message:{}", topic, message.get());
throw e;
}
}
}
- 每一个 MQ 消息的消费都会有一个对应的 XxxListener 来处理消息体,如果你使用一些其他的 MQ 可能还会看到一些抽象类来处理 MQ 消息集合。
- 在这个 LotteryInvoiceListener 消息监听类中,主要就是通过消息中的发奖类型获取到对应的奖品发货工厂,处理奖品的发送操作。
- 在奖品发送操作中,已经补全了 DistributionBase#updateUserAwardState 更新奖品发送状态的操作。
抽奖流程解耦:
public DrawProcessResult doDrawProcess(DrawProcessReq req) {
// 1. 领取活动
// 2. 执行抽奖
// 3. 结果落库
// 4. 发送MQ,触发发奖流程
InvoiceVO invoiceVO = buildInvoiceVO(drawOrderVO);
ListenableFuture<SendResult<String, Object>> future = kafkaProducer.sendLotteryInvoice(invoiceVO);
future.addCallback(new ListenableFutureCallback<SendResult<String, Object>>() {
@Override
public void onSuccess(SendResult<String, Object> stringObjectSendResult) {
// 4.1 MQ 消息发送完成,更新数据库表 user_strategy_export.mq_state = 1
activityPartake.updateInvoiceMqState(invoiceVO.getuId(), invoiceVO.getOrderId(), Constants.MQState.COMPLETE.getCode());
}
@Override
public void onFailure(Throwable throwable) {
// 4.2 MQ 消息发送失败,更新数据库表 user_strategy_export.mq_state = 2 【等待定时任务扫码补偿MQ消息】
activityPartake.updateInvoiceMqState(invoiceVO.getuId(), invoiceVO.getOrderId(), Constants.MQState.FAIL.getCode());
}
});
// 5. 返回结果
return new DrawProcessResult(Constants.ResponseCode.SUCCESS.getCode(), Constants.ResponseCode.SUCCESS.getInfo(), drawAwardVO);
}
-
消息发送完毕后进行
回调
处理,更新数据库中 MQ 发送的状态,如果有MQ 发送失败
则更新数据库mq_state = 2
这里还有可能在更新库表状态的时候失败
,但没关系这些都会被 worker 补偿处理掉,一种是发送 MQ 失败,另外一种是== MQ 状态为 0 但很久都没有发送 MQ ==那么也可以触发发送。成功则mq_state =1 -
现在从用户
领取活动
、执行抽奖
、结果落库
,到发送MQ处理后续发奖的流程
就解耦
了,因为用户只需要知道自己中奖了,但发奖到货是可以等待的,毕竟发送虚拟商品的等待时间并不会很长,而实物商品走物流就更可以接受了。所以对于这样的流程进行解耦是非常有必要的,否则你的程序逻辑会让用户在界面等待更久的时间
。
15.扫描库表补偿发货单MQ消息
我们发了消息,当消息发送成功后修改状态为1,消息发送失败时修改状态为 2,我们用xxl-job扫描数据库,将mq状态为 0和2扫描出来,重新发送这些消息(消息补偿) :0 表示可能消息发送完但是库表更新失败,2就是发送失败的。
当消息发送成功,消息消费者拿到消息,从工厂中根据奖品类型选择对应的奖品发货类,执行发奖
总的流程:
右边为消息补偿,下边为消息发送成功后的消费处理,也就是发货,
16 设计滑动库存分布式锁处理活动秒杀
==口述:==在领取活动流程中,我们先判断是否用户已经领取了活动但是抽奖失败的情况。这个时候用户已经领取了活动,此时就不需要执行领取活动逻辑了,直接返回领取的活动就ok了,如果用户还没有领取活动,
此时:查活动库存、查用户可领取的次数、再进行活动信息的校验,
通过后:原先是直接在数据库进行减库存操作,现在:我们将减库存放到Redis中,并且不是对活动编号加锁,因为这样的话可能这样在极端临界情况下会出现秒杀解锁失败
,导致库存有剩余但不能下单的情况
。所以需要增加锁的颗粒度
,以滑动库存剩余编号
的方式进行加锁,例如 100001_1、100001_2、100001_3,这样的话比如说有100个库存,那就可以让100个用户拿到不同的锁,不会出现这种库存还有但是拿不到锁的情况,
活动领取完成后,其实这个时候只是把缓存的库存扣掉了,但数据库中的库存并没有扣减,所以我们需要发送一个 MQ 消息,来对数据库中的库存进行处理。
发送 MQ 消息进行异步更新数据库中活动库存,做最终数据一致性处理。这一部分如果你的系统并发体量较大,还需要把 MQ 的数据不要直接对库更新,而是更新到缓存中,并使用定时任务进行处理缓存和数据库库存同步,减少对数据库的操作次数(定时任务可以控制次数,不会出现对数据库访问量特别大的情况)
还存在的问题:秒杀的设计原则是保证不超卖,但不一定保证100个库存完全消耗掉,因为可能会有一些锁失败的情况。不过在库存数据最终一致性任务处理下,基本保证3个9到4个9的可用率还是没问题的。