DDD分层架构介绍
DDD(Domain-Driven Design 领域驱动设计),目的是对软件所涉及到的领域进行建模,以应对系统规模过大时引起的软件复杂性的问题。开发团队和领域专家一起通过 通用语言(Ubiquitous Language)去理解和消化领域知识,从领域知识中提取和划分为一个一个的子领域(核心子域,通用子域,支撑子域),并在子领域上建立模型,再重复以上步骤,这样周而复始,构建出一套符合当前领域的模型
依靠领域驱动设计的设计思想,通过事件风暴建立领域模型,合理划分领域逻辑和物理边界,建立领域对象及服务矩阵和服务架构图,定义符合DDD分层架构思想的代码结构模型,保证业务模型与代码模型的一致性
通过上述设计思想、方法和过程,指导团队按照DDD设计思想完成微服务设计和开发
服务架构调用关系
接口层{interfaces}
应用层{application}
领域层{domain}
基础层{infrastructure}
基础服务位于基础层。为各层提供资源服务(如数据库、缓存等),实现各层的解耦,降低外部资源变化对业务逻辑的影响
基础服务主要为仓储服务,通过依赖反转的方式为各层提供基础资源服务,领域服务和应用服务调用仓储服务接口,利用仓储实现持久化数据对象或直接访问基础资源
DDD结构它是一种充血模型结构,所有的服务实现都以领域为核心,应用层定义接口,领域层实现接口且定义数据仓储,基础层实现数据仓储中关于DAO和Redis的操作,
同时几方又有互相的依赖那么这样的结构再开发独立领域提供 http 接口时候,并不会有什么问题体现出来
引入RPC的问题
DDD+RPC 模块分离系统搭建
引入RPC的解决方案
如果按照模块化拆分,那么会需要做一些处理,包括:
那么,这样拆分以后就可以按照模块化的结构进行创建系统结构了,每一层按照各自的职责完成各自的功能,同时又不会破坏DDD中领域充血模型的实现
关于 ID 的生成因为有三种不同 ID 用于在不同的场景下;
通过策略模式的使用,来开发策略ID的服务提供。之所以使用策略模式,是因为外部的调用方会需要根据不同的场景来选择出适合的ID生成策略
模版模式的核心点:由抽象类定义抽象方法执行策略,即父类规定了好一系列的执行标准,这些标准的串联成一整套业务流程
在于把抽奖流程标准化,需要考虑的一条思路线包括:
以上这些步骤就是需要在抽奖执行类的方法中需要处理的内容,如果是在一个类的一个方法中,顺序开发这些内容也是可以实现的
但这样的代码实现过程是不易于维护的,也不太方便在各个流程节点扩展其他功能,也会使一个类的代码越来越庞大,因此对于这种可以制定标准流程的功能逻辑,通常使用模板方法模式是非常合适的
简单工厂模式
运用简单工厂设计模式,搭建发奖领域服务。介绍:定义一个创建对象的接口,让其子类自己决定实例化哪一个工厂类,工厂模式使其创建过程延迟到子类进行
关于 award 发奖领域中主要的核心实现在于 service 中的两块功能逻辑实现,分别是:goods 商品处理
、factory 工厂
goods:包装适配各类奖品的发放逻辑,(1:文字描述、2:兑换码、3:优惠券、4:实物奖品)
factory:工厂模式通过调用方提供发奖类型,返回对应的发奖服务。通过这样由具体的子类决定返回结果,并做相应的业务处理。从而不至于让领域层包装太多的频繁变化的业务属性,因为如果你的核心功能域是在做业务逻辑封装,就会就会变得非常庞大且混乱
把四种奖品的发奖,放到一个统一的配置文件类 Map 中,便于通过 AwardType 获取相应的对象,减少 if...else
的使用。
状态模式:类的行为是基于它的状态改变的,这种类型的设计模式属于行为型模式它描述的是一个行为下的多种状态变更
比如一个网站的页面,在你登录与不登录下展示的内容是略有差异的(不登录不能展示个人信息),而这种登录与不登录就是我们通过改变状态,而让整个行为发生了变化
activity 活动领域层包括:deploy、partake、stateflow
stateflow 状态流转运用的状态模式,主要包括抽象出状态抽象类AbstractState 和对应的 event 包下的状态处理,最终使用 StateHandlerImpl 来提供对外的接口服务
//1.定义抽象类
*在整个接口中提供了各项状态流转服务的接口,例如;活动提审、审核通过、审核拒绝、撤审撤销等7个方法。
*在这些方法中所有的入参都是一样的,activityId(活动ID)、currentStatus(当前状态),只有他们的具体实现是不同的
//2.实现类(提审状态)
*提审状态中的流程,比如:待审核状态不可重复提审、非关闭活动不可开启、待审核活动不可执行活动中变更,而:审核通过、审核拒绝、撤销审核、活动关闭,都可以操作
*通过这样的设计模式结构,优化掉原本需要在各个流程节点中的转换使用 ifelse 的场景,这样操作以后也可以更加方便你进行扩展。当然其实这里还可以使用如工作流的方式进行处理
//3.状态流转配置抽象类
*在状态流转配置中,定义好各个流转操作
//4.实现状态处理服务
*在状态流转服务中,通过在 状态组 stateGroup 获取对应的状态处理服务和操作变更状态
策略模式是一种行为模式,也是替代大量if else
的利器
它所能帮你解决的是场景,一般是具有同类可替代的行为逻辑算法场景,可以使用策略模式进行行为包装供给外部使用
不同类型的交易方式(信用卡、支付宝、微信)
生成唯一ID策略(UUID、DB自增、DB+Redis、雪花算法、Leaf算法)等
两种抽奖算法描述,场景A20%、B30%、C50%
总体概率:如果A奖品抽空后,B和C奖品的概率按照 3:5
均分,相当于B奖品中奖概率由 0.3
升为 0.375
单项概率:如果A奖品抽空后,B和C保持目前中奖概率,用户抽奖扔有20%中为A,因A库存抽空则结果展示为未中奖。为了运营成本,通常这种情况的使用的比较多
算法描述
:分别把A、B、C对应的概率值转换成阶梯范围值,A=(0~0.2」、B=(0.2-0.5」、C=(0.5-1.0」,当使用随机数方法生成一个随机数后,与阶梯范围值进行循环比对找到对应的区域,匹配到中奖结果
// 1. 获取策略聚合信息
// 2. 获取抽奖策略,检查该策略是否初始化,解析并初始化中奖概率到散列表(非单项概率,不必存入缓存,因为这部分抽奖算法需要实时处理中奖概率)
程序启动时初始化概率元祖,在初始化完成后使用过程中不允许修改元祖数据,元祖数据作用在于讲百分比内(0.2、0.3、0.5)的数据,转换为一整条数组上分区数据,
通过数据拆分为整条后,再根据0-100中各个区间的奖品信息,使用斐波那契散列计算出索引位置,把奖品数据存放到元祖中
* 斐波那契散列增量,逻辑:黄金分割点:(√5 - 1) / 2 = 0.6180339887,Math.pow(2, 32) * 0.6180339887 = 0x61c88647
* 数组初始化长度 128,保证数据填充时不发生碰撞的最小初始化值
* 1. 把 0.2 转换为 20
* 2. 20 对应的斐波那契值哈希值:(20 * HASH_INCREMENT + HASH_INCREMENT)= -1549107828 HASH_INCREMENT = 0x61c88647
* 3. 再通过哈希值计算索引位置:hashCode & (rateTuple.length - 1) = 12
* 4. 那么tup[14] = 0.2 中奖概率对应的奖品
* 5. 当后续通过随机数获取到1-100的值后,可以直接定位到对应的奖品信息,通过这样的方式把轮训算奖的时间复杂度从O(n) 降低到 0(1)
// 3. 获取不在抽奖范围内的列表,包括:奖品库存为空、风控策略、临时调整等(子类实现)
// 4. 执行抽奖算法(子类实现)
//单项概率
*获取策略对应的元祖
*获取随机索引
*如果中奖ID命中排除奖品列表,则返回NULL,否则返回奖品
//总体概率
*首先要从总的中奖列表中排除掉那些被排除掉的奖品,这些奖品会涉及到概率的值重新计算
*如果排除后剩下的奖品列表小于等于1,则可以直接返回对应信息
*获取随机索引循环与奖品列表中的值进行比对(index累加当前奖品的概率值)
改造方案
修改为 枚举 + 注解 + 扫描方式注入:
1、创建枚举类 lottery-common/src/main/java/cn/itedus/lottery/common/Constants.java
2、创建注解 lottery-domain/src/main/java/cn/itedus/lottery/domain/strategy/annotation/Strategy.java
/**
* 抽奖策略模型注解
*/
@Target({ElementType.TYPE})
@Retention(RetentionPolicy.RUNTIME)
public @interface Strategy {
/**
* 抽奖策略模型枚举
*/
Constants.StrategyMode strategyMode();
}
3、给抽奖策略的实现类增加注解
@Component("entiretyRateRandomDrawAlgorithm")
@Strategy(strategyMode = Constants.StrategyMode.ENTIRETY)
public class EntiretyRateRandomDrawAlgorithm extends BaseAlgorithm {
}
@Component("singleRateRandomDrawAlgorithm")
@Strategy(strategyMode = Constants.StrategyMode.SINGLE)
public class SingleRateRandomDrawAlgorithm extends BaseAlgorithm {
}
4、修改策略注册方法:
/**
* 抽奖统一配置信息类
*/
public class DrawConfig {
@Resource
private List<IDrawAlgorithm> algorithmList = new ArrayList<>();
/**
* 抽奖策略组
*/
protected static Map<Integer, IDrawAlgorithm> drawAlgorithmGroup = new ConcurrentHashMap<>();
@PostConstruct
public void init() {
algorithmList.forEach(algorithm -> {
Strategy strategy = AnnotationUtils.findAnnotation(algorithm.getClass(), Strategy.class);
if (null != strategy) {
drawAlgorithmGroup.put(strategy.strategyMode().getCode(), algorithm);
}
});
}
}
注意事项
public class DrawConfig implements ApplicationContextAware {
private ApplicationContext applicationContext;
protected static Map<Integer, IDrawAlgorithm> drawAlgorithmMap = new ConcurrentHashMap<>();
@PostConstruct
public void init() {
Map<String, Object> strategyModeMap = applicationContext.getBeansWithAnnotation(
StrategyMode.class);
strategyModeMap.entrySet().forEach(r->{
StrategyMode strategyMode = AnnotationUtils.findAnnotation(r.getValue().getClass(),StrategyMode.class);
if(r.getValue() instanceof IDrawAlgorithm){
drawAlgorithmMap.put(strategyMode.strategyMode().getId(), (IDrawAlgorithm)r.getValue());
}
});
}
/**
* 注入 ApplicationContext
* @author 徐明龙 XuMingLong 2021-12-09
* @param applicationContext
* @return void
*/
@Override public void setApplicationContext(ApplicationContext applicationContext) throws BeansException {
this.applicationContext = applicationContext;
}
}
/*
applicationContext 不能从通过其他类获取,必须直接注入到当前类,否则@PostConstruct的方法执行时,其他类不一定已经完成了ApplicationContext的注入
例如使用以下类的getApplicationContext()方法获取applicationContext,获得的是空的对象,因为ApplicationContextAware 的setApplicationContext方法在同一个类里可以保证在@PostConstruct之前调用,但在不同的类里,不能保证
*/
/**
* Spring 工具类
*/
@Component
public class SpringUtils implements ApplicationContextAware {
private static ApplicationContext applicationContext;
@Override
public void setApplicationContext(ApplicationContext applicationContext) throws BeansException {
SpringUtils.applicationContext = applicationContext;
}
public static ApplicationContext getApplicationContext(){
return applicationContext;
}
}
描述:开发一个基于 HashMap 核心设计原理,使用哈希散列+扰动函数的方式,把数据散列到多个库表中的组件
1.开发日志
2.需求分析
数据库路由 需要做什么技术点?
为什么要用分库分表,其实就是由于业务体量较大,数据增长较快,所以需要把用户数据拆分到不同的库表中去,减轻数据库压力
分库分表操作主要有垂直拆分和水平拆分:
数据库路由设计包括的技术知识点
综上,可以看到在数据库和表的数据结构下完成数据存放,我需要用到的技术包括:AOP
、数据源切换
、散列算法
、哈希寻址
、ThreadLoca
l以及SpringBoot的Starter开发方式
等技术。而像哈希散列
、寻址
、数据存放
,其实这样的技术与 HashMap 有太多相似之处
3.技术调研
HashMap、ThreadLocal,两个功能则用了哈希索引、散列算法以及在数据膨胀时候的拉链寻址和开放寻址
1.ThreadLocal
@Test
public void test_idx() {
int hashCode = 0;
for (int i = 0; i < 16; i++) {
hashCode = i * 0x61c88647 + 0x61c88647;
int idx = hashCode & 15;
System.out.println("斐波那契散列:" + idx + " 普通散列:" + (String.valueOf(i).hashCode() & 15));
}
}
斐波那契散列:7 普通散列:0
斐波那契散列:14 普通散列:1
斐波那契散列:5 普通散列:2
斐波那契散列:12 普通散列:3
斐波那契散列:3 普通散列:4
斐波那契散列:10 普通散列:5
斐波那契散列:1 普通散列:6
斐波那契散列:8 普通散列:7
斐波那契散列:15 普通散列:8
斐波那契散列:6 普通散列:9
斐波那契散列:13 普通散列:15
斐波那契散列:4 普通散列:0
斐波那契散列:11 普通散列:1
斐波那契散列:2 普通散列:2
斐波那契散列:9 普通散列:3
斐波那契散列:0 普通散列:4
f(k) = ((k * 2654435769) >> X) << Y对于常见的32位整数而言,也就是 f(k) = (k * 2654435769) >> 28
,黄金分割点:(√5 - 1) / 2 = 0.6180339887
1.618:1 == 1:0.618
2.HashMap
public static int disturbHashIdx(String key, int size) {
return (size - 1) & (key.hashCode() ^ (key.hashCode() >>> 16));
}
-----
//1.定义注解
*@Retention(RetentionPolicy.RUNTIME)
*@Target({ElementType.TYPE, ElementType.METHOD})
*String key() default "";
*它的使用方式是通过方法配置注解,就可以被指定的 AOP 切面进行拦截,拦截后进行相应的数据库路由计算和判断,并切换到相应的操作数据源上
//2.解析路由配置
*dbCount分库的数量 tbCount分表的数量 defalut默认数据库 list:db01,db02(分库数据库的信息)
//2.1 数据源配置提取
*实现EnvironmentAware接口的setEnvironment方法提取配置信息并存放到dataSourceMap中方便后续使用
//3.数据源切换
*在结合 SpringBoot 开发的 Starter 中,需要提供一个 DataSource 的实例化对象,那么这个对象就放在 DataSourceAutoConfig 来实现,并且这里提供的数据源是支持动态切换数据源
*从配置信息中读取数据源信息,进行实例化创建
*数据源创建完成后放到 DynamicDataSource 中,它是一个继承了 AbstractRoutingDataSource 的实现类,这个类里可以存放和读取相应的具体调用的数据源信息
//4.切面拦截
*获取分库分表字段(dbKey),若为空且数据路由配置中也空则抛出异常
*提取了库表乘积的数量,把它当成 HashMap 一样的长度进行使用和HashMap一样的扰动函数逻辑,让数据分散的更加散列
*当计算完总长度上的一个索引位置后,还需要把这个位置折算到库表中,看看总体长度的索引因为落到哪个库哪个表
*最后是把这个计算的索引信息存放到 ThreadLocal 中,用于传递在方法调用过程中可以提取到索引信息
//5.Mybatis拦截器处理分表
*基于 Mybatis 拦截器进行处理,通过拦截 SQL 语句动态修改添加分表信息,再设置回 Mybatis 执行 SQL 中
*再完善一些分库分表路由的操作,比如配置默认的分库分表字段以及单字段入参时默认取此字段作为路由字段
*实现 Interceptor 接口的 intercept 方法,获取StatementHandler、通过自定义注解判断是否进行分表操作、获取SQL并替换SQL表名 USER 为 USER_03、最后通过反射修改SQL语句
*此处会用到正则表达式拦截出匹配的sql,(from|into|update)[\\s]{1,}(\\w{1,})
//验证分库
*打包 db-router-spring-boot-starter
*引入 pom 文件
//1.在需要使用数据库路由的DAO方法上加入注解
*@DBRouter(key = "uId") key 是入参对象中的属性,用于提取作为分库分表路由字段使用
*如果一个表只分库不分表,则它的 sql 语句并不会有什么差异
*如果需要分表,那么则需要在表名后面加入 user_take_activity_${tbIdx} 同时入参对象需要继承 DBRouterBase 这样才可以拿到 tbIdx 分表信息 (这部分内容我们在后续开发中会有体现)
//验证分表
*@DBRouterStrategy(splitTable = true) 配置分表信息,配置后会通过数据库路由组件把sql语句添加上分表字段,比如表 user 修改为 user_003
*@DBRouter(key = "uId") 设置路由字段
*@DBRouter 未配置情况下走默认字段,routerKey: uId
//1.拆解路由算法策略,单独提供路由方法,无论是切面中还是硬编码,都通过这个方法进行计算路由
//2.配置事物处理对象
*创建路由策略对象,便于切面和硬编码注入使用
*创建事务对象,用于编程式事务引入(PROPAGATION_REQUIRED)
*dbRouter.doRouter(partake.getuId()); 是编程式处理分库分表,如果在不需要使用事务的场景下,直接使用注解配置到DAO方法上即可
*transactionTemplate.execute 是编程式事务,用的就是路由中间件提供的事务对象,通过这样的方式也可以更加方便的处理细节的回滚,而不需要抛异常处理
【活动单使用状态 0未使用、1已使用】
状态字段,这个状态字段用于写入中奖信息到 user_strategy_export_000~003 表中时候,两个表可以做一个幂等性的事务在 lottery-application 模块下新增 process 包用于流程编排,其实它也是 service 服务包是对领域功能的封装
活动抽奖流程编排
//1.领取活动
*查询是否存在未执行抽奖领取活动单【user_take_activity 存在 state = 0,领取了但抽奖过程失败的,可以直接返回领取结果继续抽奖】
*查询活动账单(用户ID、活动ID、活动名称、时间、库存剩余、状态、策略ID、人均参与数、已领取次数) 校验:活动状态,日期,库存,个人库存等
*活动信息校验处理【活动库存、状态、日期、个人参与次数】
*扣减活动库存【目前为直接对配置库中的 lottery.activity 直接操作表扣减库存,后续优化为Redis扣减】
*插入领取活动信息【个人用户把活动信息写入到用户表】
*用户领取活动时候,新增记录:strategy_id、state 两个字段,这两个字段就是为了处理用户对领取镜像记录的二次处理未执行抽奖的领取单,以及state状态控制事务操作的幂等性()
//2.执行抽奖
//3.结果落库保存奖品单
//4.发送MQ,触发发奖流程
//5.返回结果
Apache Kafka是一个分布式发布 - 订阅消息系统和一个强大的队列,可以处理大量的数据,并使您能够将消息从一个端点传递到另一个端点。 Kafka适合离线和在线消息消费。 Kafka消息保留在磁盘上,并在群集内复制以防止数据丢失。 Kafka构建在ZooKeeper同步服务之上。 它与Apache Storm和Spark非常好地集成,用于实时流式数据分析。
以下是Kafka的几个好处:
Kafka非常快,并保证零停机和零数据丢失
spring:
kafka:
bootstrap-servers: localhost:9092
producer:
# 发生错误后,消息重发的次数。
retries: 1
#当有多个消息需要被发送到同一个分区时,生产者会把它们放在同一个批次里。该参数指定了一个批次可以使用的内存大小,按照字节数计算。
batch-size: 16384
# 设置生产者内存缓冲区的大小。
buffer-memory: 33554432
# 键的序列化方式
key-serializer: org.apache.kafka.common.serialization.StringSerializer
# 值的序列化方式
value-serializer: org.apache.kafka.common.serialization.StringSerializer
# acks=0 : 生产者在成功写入消息之前不会等待任何来自服务器的响应。
# acks=1 : 只要集群的首领节点收到消息,生产者就会收到一个来自服务器成功响应。
# acks=all :只有当所有参与复制的节点全部收到消息时,生产者才会收到一个来自服务器的成功响应。
acks: 1
consumer:
# 自动提交的时间间隔 在spring boot 2.X 版本中这里采用的是值的类型为Duration 需要符合特定的格式,如1S,1M,2H,5D
auto-commit-interval: 1S
# 该属性指定了消费者在读取一个没有偏移量的分区或者偏移量无效的情况下该作何处理:
# latest(默认值)在偏移量无效的情况下,消费者将从最新的记录开始读取数据(在消费者启动之后生成的记录)
# earliest :在偏移量无效的情况下,消费者将从起始位置读取分区的记录
auto-offset-reset: earliest
# 是否自动提交偏移量,默认值是true,为了避免出现重复数据和数据丢失,可以把它设置为false,然后手动提交偏移量
enable-auto-commit: false
# 键的反序列化方式
key-deserializer: org.apache.kafka.common.serialization.StringDeserializer
# 值的反序列化方式
value-deserializer: org.apache.kafka.common.serialization.StringDeserializer
listener:
# 在侦听器容器中运行的线程数。
concurrency: 5
#listner负责ack,每调用一次,就立即commit
ack-mode: manual_immediate
missing-topics-fatal: false
//1.生产消息
*把所有的生产消息都放到 KafkaProducer 中,并对外提供一个可以发送 MQ 消息的方法
*配置的类型转换为 StringDeserializer 所以发送消息的方式是 JSON 字符串,当然这个编解码器是可以重写的,满足你发送其他类型的数据
//2.消费消息
*判断消息是否存在
*(处理MQ消息) 转换对象(也可以重写Serializer<T>)、获取发送奖品工程,执行发奖、打印日志
消息消费完成(ack.acknowledge())
//3.抽奖流程解耦
*关于MQ的处理,会调动 kafkaProducer.sendLotteryInvoice 发送一个中奖结果的发货单
*消息发送完毕后进行回调处理(future.addCallback),更新数据库中 MQ 发送的状态,若成功则更新mq_status=1否则更新mq_state = 2(等待定时任务扫码补偿MQ消息) 【这里还有可能在更新库表状态的时候失败,但没关系这些都会被 worker 补偿处理掉】
*返回结果
*现在从用户领取活动、执行抽奖、结果落库,到 发送MQ处理后续发奖的流程就解耦了,因为用户只需要知道自己中奖了,但发奖到货是可以等待的,毕竟发送虚拟商品的等待时间并不会很长,而实物商品走物流就更可以接收了。所以对于这样的流程进行解耦是非常有必要的,否则你的程序逻辑会让用户在界面等待更久的时间
扫描待处理的活动列表,状态为:通过、活动中
*通过 -> 时间符合时 -> 活动中
*活动中 -> 时间到期时 -> 关闭
//1.路由组件提供了必要方法
*在路由组件中,提供获取分库数、分表数和设置库表路由,也就是手动设置的操作,这样可以把扫描的路由结果确定下来
//2.消息补偿任务
*验证参数
*获取分布式任务配置参数信息(参数配置格式:1,2,3 也可以是指定扫描一个,也可以配置多个库,按照部署的任务集群进行数量配置,均摊分别扫描效率更高)
*获取分库分表配置下的分表数
*获取当前任务扫描的指定分库
*判断配置指定扫描库数,是否存在
*循环扫描对应表
*扫描库表数据(设置路由、查询数据)
*补偿MQ消息,MQ消息发送完成,更新数据库表 user_strategy_export.mq_state = 1
MQ 消息发送失败,更新数据库表 user_strategy_export.mq_state = 2 【等待定时任务扫码补偿MQ消息】
//1.库存扣减操作
*获取抽奖活动库存 key
*扣减库存,目前占用库存数
*超出库存判断,进行恢复原始库存
*以活动库存占用编号,生成对应加锁Key,细化锁的颗粒度
*生成分布式锁
//2.活动秒杀流程处理
//在领取活动的模板方法中,优化掉原来直接使用数据库行级锁的流程 -> Redis库存的扣减
//扣减库存后,在各个以下的流程节点中,如果有流程失败则进行缓存库存的恢复操作
//4.发送MQ消息,处理数据一致性
//MQ 的发送,只发生在用户首次领取活动时,如果是已经领取活动但因为抽奖等流程失败,二次进入此流程,则不会发送 MQ 消息
则引擎开发需要的相关的配置类表: rule_tree、rule_tree_node、rule_tree_node_line
CREATE TABLE `rule_tree` (
`id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT '主键ID',
`tree_name` varchar(64) DEFAULT NULL COMMENT '规则树NAME',
`tree_desc` varchar(128) DEFAULT NULL COMMENT '规则树描述',
`tree_root_node_id` bigint(20) DEFAULT NULL COMMENT '规则树根ID',
`create_time` datetime DEFAULT NULL COMMENT '创建时间',
`update_time` datetime DEFAULT NULL COMMENT '更新时间',
PRIMARY KEY (`id`)
) ENGINE=InnoDB AUTO_INCREMENT=10002 DEFAULT CHARSET=utf8;
CREATE TABLE `rule_tree_node` (
`id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT '主键ID',
`tree_id` int(2) DEFAULT NULL COMMENT '规则树ID',
`node_type` int(2) DEFAULT NULL COMMENT '节点类型;1子叶、2果实',
`node_value` varchar(32) DEFAULT NULL COMMENT '节点值[nodeType=2];果实值',
`rule_key` varchar(16) DEFAULT NULL COMMENT '规则Key',
`rule_desc` varchar(32) DEFAULT NULL COMMENT '规则描述',
PRIMARY KEY (`id`)
) ENGINE=InnoDB AUTO_INCREMENT=123 DEFAULT CHARSET=utf8;
CREATE TABLE `rule_tree_node_line` (
`id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT '主键ID',
`tree_id` bigint(20) DEFAULT NULL COMMENT '规则树ID',
`node_id_from` bigint(20) DEFAULT NULL COMMENT '节点From',
`node_id_to` bigint(20) DEFAULT NULL COMMENT '节点To',
`rule_limit_type` int(2) DEFAULT NULL COMMENT '限定类型;1:=;2:>;3:<;4:>=;5<=;6:enum[枚举范围];7:果实',
`rule_limit_value` varchar(32) DEFAULT NULL COMMENT '限定值',
PRIMARY KEY (`id`)
) ENGINE=InnoDB AUTO_INCREMENT=7 DEFAULT CHARSET=utf8;
运用组合模式搭建规则引擎领域服务,包括:logic 逻辑过滤器、engine 引擎执行器
商品属性表
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-7ghV47t7-1651902183185)(C:\Users\Administrator\AppData\Roaming\Typora\typora-user-images\image-20220315154451970.png)]
属性分组
属性 & 属性分组关联表
品牌
商品三级分类
品牌分类关联
商品评价回复关系
spu信息
spu属性值
spu信息介绍
spu图片
商品评价
sku信息
sku图片
sku销售属性 & 值
前后分离开发,分为内网部署和外网部署,外网是面向公众访问的。
访问前端项目,可以有手机APP,电脑网页;内网部署的是后端集群,
前端在页面上操作发送请求到后端,在这途中会经过Nginx集群,
Nginx把请求转交给API网关(springcloud gateway)(网关可以根据当
前请求动态地路由到指定的服务,看当前请求是想调用商品服务还是购
物车服务还是检索服务),从路由过来如果请求很多,可以负载均衡地调
用商品服务器中一台(商品服务复制了多份),当商品服务器出现问题也
可以在网关层面对服务进行熔断或降级(使用阿里的sentinel组件),网关
还有其他的功能如认证授权、限流(只放行部分到服务器)等。
到达服务器后进行处理(springboot为微服务),服务与服务可能会相互
调用(使用feign组件),有些请求可能经过登录才能进行(基于OAuth2.0的
认证中心。安全和权限使用springSecurity控制)
服务可能保存了一些数据或者需要使用缓存,我们使用redis集群(分片+哨兵集
群)。持久化使用mysql,读写分离和分库分表。
服务和服务之间会使用消息队列(RabbitMQ),来完成异步解耦,分布式事务
的一致性。有些服务可能需要全文检索,检索商品信息,使用ElaticSearch。
服务可能需要存取数据,使用阿里云的对象存储服务OSS。
项目上线后为了快速定位问题,使用ELK对日志进行处理,使用LogStash收
集业务里的各种日志,把日志存储到ES中,用Kibana可视化页面从ES中检
索出相关信息,帮助我们快速定位问题所在。
在分布式系统中,由于我们每个服务都可能部署在很多台机器,服务和服务
可能相互调用,就得知道彼此都在哪里,所以需要将所有服务都注册到注册
中心。服务从注册中心发现其他服务所在位置(使用阿里Nacos作为注册
中心)。
每个服务的配置众多,为了实现改一处配置相同配置就同步更改,就需要配
置中心,也使用阿里的Nacos,服务从配置中心中动态取配置。
服务追踪,追踪服务调用链哪里出现问题,使用springcloud提供的Sleuth、
Zipkin、Metrics,把每个服务的信息交给开源的Prometheus进行聚合分析,
再由Grafana进行可视化展示,提供Prometheus提供的AlterManager实时
得到服务的警告信息,以短信/邮件的方式告知服务开发人员。
还提供了持续集成和持续部署。项目发布起来后,因为微服务众多,每一个都打
包部署到服务器太麻烦,有了持续集成后开发人员可以将修改后的代码提交到
github,运维人员可以通过自动化工具Jenkins Pipeline将github中获取的代码打
包成docker镜像,最终是由k8s集成docker服务,将服务以docker容器的方式运行。
跨域:指的是浏览器不能执行其他网站的脚本。它是由浏览器的同源策略造成的,是浏览器对javascript施加的安全限制。
同源策略:是指协议,域名,端口都要相同,其中有一个不同都会产生跨域
请求先发送到网关,网关在转发给其他服务 事先都要注册到注册中心
@Configuration
public class GulimallCorsConfiguration {
@Bean
public CorsWebFilter corsWebFilter() {
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
CorsConfiguration corsConfiguration = new CorsConfiguration();
// 配置跨越
corsConfiguration.addAllowedHeader("*"); // 允许那些头
corsConfiguration.addAllowedMethod("*"); // 允许那些请求方式
corsConfiguration.addAllowedOrigin("*"); // 允许请求来源
corsConfiguration.setAllowCredentials(true); // 是否允许携带cookie跨越
// 注册跨越配置
source.registerCorsConfiguration("/**",corsConfiguration);
return new CorsWebFilter(source);
}
}
@Configuration
public class GulimallSessionConfig {
@Bean
public CookieSerializer cookieSerializer(){
DefaultCookieSerializer cookieSerializer = new DefaultCookieSerializer();
cookieSerializer.setDomainName("gulimall.com");
cookieSerializer.setCookieName("GULISESSION");
return cookieSerializer;
}
@Bean
public RedisSerializer<Object> springSessionDefaultRedisSerializer(){
return new GenericJackson2JsonRedisSerializer();
}
}
//application.yml
spring.session.store-type: redis
//1.查出所有分类
//2.组装成父子的树形结构
*找到所有的一级分类
*找到当前一级分类的子分类
*排序
PUT gulimall_product
{
"mappings": {
"properties": {
"skuId": {
"type": "long"
},
"spuId": {
"type": "long"
},
"skuTitle": {
"type": "text",
"analyzer": "ik_smart"
},
"skuPrice": {
"type": "keyword"
},
"skuImg": {
"type": "keyword"
},
"saleCount": {
"type": "long"
},
"hosStock": {
"type": "boolean"
},
"hotScore": {
"type": "long"
},
"brandId": {
"type": "long"
},
"catelogId": {
"type": "long"
},
"brandName": {
"type": "keyword"
},
"brandImg": {
"type": "keyword"
},
"catalogName": {
"type": "keyword"
},
"attrs": {
"type": "nested",
"properties": {
"attrId": {
"type": "long"
},
"attrName": {
"type": "keyword"
},
"attrValue": {
"type": "keyword"
}
}
}
}
}
}
MySQL主从复制原理
canal工作原理
//1.查出当前spuID对应的所有skuID
//2.查出当前spu的所有attr属性ID
*查询需要检索的属性(search_type)
*去重
*封装为List<SkuEsModel.Attrs>
//3.发送远程调用,库存系统查询是否有库存
//4.封装每个sku的信息
//5.调用search服务进行保存
*远程调用成功,修改当前spu的状态
*远程调用失败,重复调用? 接口幂等性? 重试机制?
//1.准备检索请求
*模糊匹配(skuTitle)
*过滤(属性,分类,品牌,价格区间,库存)
*排序(saleCount_asc/desc、skuPrice_asc/desc)
*分页(from=param.getPage() - 1 * PAGE_SIZE size=PAGE_SIZE)
*高亮
*聚合分析
//2.执行检索请求
//3.分析响应数据,封装成需求的格式
*返回所有查询到的商品
*JSON.parseObject()转换对象
*从聚合信息中对(属性信息的处理、品牌信息的处理、分类信息的处理)
*分页信息-页码、总记录数、总页码
*分析每个attrs传过来的查询参数值,取消了面包屑以后,要跳转到那个地方。将请求地址的url里面的当前置空,拿到所有的查询条件,去掉当前
短信认证码
//判断是否为最近60s以内
*是的话60s以内不能再发
//生成随机验证码(10分钟有效期)
oauth2
//授权码模式
*通过浏览器将用户引导到码云三方认证页面上(GET请求)
*码云认证服务器通过回调地址{redirect_uri}将 用户授权码 传递给 回调地址上
*根据获取的code发送POST请求获取accessToken
*根据accessToken发送GET请求获取Gitee用户的消息
*判断用户是登录还是注册
ThreadLocal
继承HandlerInterceptor接口,重写preHandle()和postHandle()
//preHandle() 目标执行之前
*从session中取出登录的用户信息存放到threadLocal中
获取购物车
//获取threadLocal中的登录信息
//根据登录查询中reids存入的购物车信息
添加到购物车
//userId,skuId,nums
*根据userId获取BoundHashOperations<String, Object, Object>
*根据skuId判断当前购物车是否有该商品
*若购物车无此商品CompletableFuture异步编排远程查询当前要添加商品的信息、销售属性并存入redis
*购物车有此商品,修改数量并存入redis
@Configuration
public class FeignConfig {
@Bean("requestInterceptor")
public RequestInterceptor requestInterceptor() {
return new RequestInterceptor() {
@Override
public void apply(RequestTemplate requestTemplate) {
//1.RequestContextHolder拿到刚进来的这个请求
ServletRequestAttributes attributes = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
if (attributes != null){
//老请求
HttpServletRequest request = attributes.getRequest();
//同步请求头数据 Cookie
String cookie = request.getHeader("Cookie");
//给新请求同步了老请求的cookie
requestTemplate.header("Cookie", cookie);
}
}
};
}
}
//在方法中
//1.获取之前的请求
RequestAttributes requestAttributes = RequestContextHolder.getRequestAttributes();
//2.每一个线程都来共享之前的请求数据
RequestContextHolder.setRequestAttributes(requestAttributes);
//1.服务收到消息就回调
*spring.rabbitmq.publisher-confirms=true
//设置确认回调ConfirmCallback
*只要消息抵达Broker就true, (消息丢失),做好消息确认机制(publisher、consumer【手动ack】),每一个发送的消息都在数据库做好记录。定期将失败的消息再次发送一遍
//2.消息正确抵达队列进行回调
*spring.rabbitmq.publisher-return=true
*spring.rabbitmq.template.mandatory=true
//设置确认回调ReturnCallback
*只要消息没有投递给指定的队列(失败回调)
//3.消费端确认(保证每条消息都被正常消费,此时broker才可以删除此消息)
*开启手动签收模式 spring.rabbitmq.listener.simple.acknowledge-mode: manual
*消费者手动确认模式。只要没有明确告诉MQ,货物被签收。没有ack,消息就一直是unacked状态, 即使consumer宕机,消息不会丢失,会重新变为Ready,下一次有新的Consumer连接进来,就会发给他
*签收方式,channel.basicAck(deliveryTag,false)签收;业务成功完成就应该签收
channel.basicNack(deliveryTag,false,true)拒签;业务失败,拒签
*签收过程出现异常,消息重新归队 channel.basicReject(message.getMessageProperties().getDeliveryTag(),true);
// order.delay.queue 绑定 order.release.order
@Bean
public Binding orderCreateOrderBingding() {
return new Binding("order.delay.queue",
Binding.DestinationType.QUEUE,
"order-event-exchange",
"order.create.order",
null);
}
@Bean
public Binding orderReleaseOrderBingding() {
return new Binding("order.release.order.queue",
Binding.DestinationType.QUEUE,
"order-event-exchange",
"order.release.order",
null);
}
/**
* 订单释放直接和库存释放进行绑定
*/
@Bean
public Binding orderReleaseOtherBingding(){
return new Binding("stock.release.stock.queue",
Binding.DestinationType.QUEUE,
"order-event-exchange",
"order.release.other.#",
null);
}
@Bean
public Binding orderSecKillOrrderQueueBinding() {
return new Binding(
"order.seckill.order.queue",
Binding.DestinationType.QUEUE,
"order-event-exchange",
"order.seckill.order",
null);
}
@Bean
public Binding stockReleaseBinding(){
return new Binding("stock.release.stock.queue",
Binding.DestinationType.QUEUE,
"stock-event-exchange",
"stock.release.#",
null);
}
@Bean
public Binding stockLockedBinding(){
return new Binding("stock.delay.queue",
Binding.DestinationType.QUEUE,
"stock-event-exchange",
"stock.locked",
null);
}
//1. 在订单展示页
*避免feign远程调用或者异步调用丢失请求头的操作
*远程查询所有的收货地址列表
*远程查询购物车所有选中的购物项
*查询用户积分等操作
*设置防重令牌
//2.验证防重令牌【令牌的对比和删除必须保证原子性】(lua脚本)
*String script = "if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end";
*原子验证令牌和删除令牌
//3.创建订单,订单项等信息、金额对比
*生成订单号、创建订单信息(运费金额、收获人信息、订单的相关状态信息)
*构建所有订单项数据
*计算价格、积分相关
*金额对比成功
//4.库存锁定,只要有异常回滚订单数据
*@Transactional,按照下单的收货地址,找到一个就近仓库,锁定库存,若没有仓库有这个商品的库存,抛出异常(回滚订单)
*如果每一个商品都锁定成功,将当前商品锁定的工作单记录发给 MQ (延时队列)
*rabbitTemplate.convertAndSend("stock-event-exchange","stock.locked",stockLockedTo)
//5.订单创建成功给MQ发送消息
(放入延时队列中)
rabbitTemplate.convertAndSend("order-event-exchange","order.create.order",
orderCreateTo.getOrder());
//1. 查询当前这个订单的最新状态
*关闭订单
*发送MQ消息 rabbitTemplate.convertAndSend("order-event-exchange","order.release.other", orderTo);
*保证消息一定会发送出去,每一个消息都可以做好日志记录(给数据库保存每一个消息的详细信息)
*定期扫描数据库将失败的消息再发送
*将没发送成功的消息进行重试发送
//扫描最近三天需要参与秒杀的活动
*上架商品
*缓存活动信息
*缓存活动的关联商品信息
*sku的基本信息、设置当前商品的秒杀时间信息、随机码、
使用库存作为分布式的信号量 限流:、 商品可以秒杀的数量作为信号量
//返回当前时间可以参与的秒杀商品信息
*确定当前时间属于哪个秒杀场次
*遍历keys,判断当前的key是否为当前场次
*获取这个秒杀场次需要的所有商品编号
*从redis Hash结构中获取商品信息
//记录耗时时间
//获取当前用户的信息
//获取当前秒杀商品的详细信息
//校验合法性
*校验时间的合法性
*校验随机码和商品id
*验证购物数量是否合理
*验证这个人是否已经购买过, SETEX,占位成功说明从来没有买过,
RSemaphore semaphore = redissonClient.getSemaphore(SeckillConstant.SKU_STOCK_SEMAPHORE + randomCode)
boolean b = semaphore.tryAcquire(num);