Dawn's Blogs

分享技术 记录成长

0%

数据库表设计

日常行为返利配置表 daily_behavior_rebate:用户行为返利配置,可以为签到、支付等配置返利,返利类型支持多种(在返利消息消费者处使用策略模式,进行不同返利类型的返利结算)。

1
2
3
4
5
6
7
8
9
10
11
12
CREATE TABLE `daily_behavior_rebate` (
`id` int unsigned NOT NULL AUTO_INCREMENT COMMENT '自增ID',
`behavior_type` varchar(16) NOT NULL COMMENT '行为类型(sign 签到、openai_pay 支付)',
`rebate_desc` varchar(128) NOT NULL COMMENT '返利描述',
`rebate_type` varchar(16) NOT NULL COMMENT '返利类型(sku 活动库存充值商品、integral 用户活动积分)',
`rebate_config` varchar(32) NOT NULL COMMENT '返利配置',
`state` varchar(12) NOT NULL COMMENT '状态(open 开启、close 关闭)',
`create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
`update_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
PRIMARY KEY (`id`),
KEY `idx_behavior_type` (`behavior_type`)
) ENGINE=InnoDB AUTO_INCREMENT=3 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='日常行为返利活动配置'

用户返利行为订单表 user_behavior_rebate_order(分库分表):记录用户返利行为的订单。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
CREATE TABLE `user_behavior_rebate_order_000` (
`id` int unsigned NOT NULL AUTO_INCREMENT COMMENT '自增ID',
`user_id` varchar(32) NOT NULL COMMENT '用户ID',
`order_id` varchar(12) NOT NULL COMMENT '订单ID',
`behavior_type` varchar(16) NOT NULL COMMENT '行为类型(sign 签到、openai_pay 支付)',
`rebate_desc` varchar(128) NOT NULL COMMENT '返利描述',
`rebate_type` varchar(16) NOT NULL COMMENT '返利类型(sku 活动库存充值商品、integral 用户活动积分)',
`rebate_config` varchar(32) NOT NULL COMMENT '返利配置【sku值,积分值】',
`out_business_no` varchar(64) NOT NULL COMMENT '业务防重ID - 外部透传,方便查询使用',
`biz_id` varchar(128) NOT NULL COMMENT '业务ID - 拼接的唯一值。拼接 out_business_no + 自身枚举',
`create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
`update_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
PRIMARY KEY (`id`),
UNIQUE KEY `uq_order_id` (`order_id`),
UNIQUE KEY `uq_biz_id` (`biz_id`),
KEY `idx_user_id` (`user_id`)
) ENGINE=InnoDB AUTO_INCREMENT=3 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='用户行为返利流水订单表'

返利行为

用户返利行为的流程如下:

  1. 首先根据用户行为类型查询返利配置,一个行为可能对应多个返利配置(如一次签到可能下发多张优惠券,每一个优惠券都是一个返利配置)。
  2. 根据所有返利配置创建(一个或)多个返利订单,在一个事务写入所有返利订单、写入发送 MQ 返利消息表 task
  3. 使用线程池,每一个线程都发送 MQ 消息、并更新 task 任务状态
  4. 使用定时任务进行消息的补偿发送。查询 task 表中失败或者 create 超时的任务,并重写发送 MQ 消息、更新 task 任务状态。
  5. 返利 MQ 消息消费者,可以调用创建 sku 订单、增加积分等服务,进行返利结算并返回 ACK。

image-20240523210435509

数据库表设计

用户中奖记录表 user_award_record(分库分表):用户中奖的记录。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
CREATE TABLE `user_award_record_000` ( 
`id` int unsigned NOT NULL AUTO_INCREMENT COMMENT '自增ID',
`user_id` varchar(32) NOT NULL COMMENT '用户ID',
`activity_id` bigint NOT NULL COMMENT '活动ID',
`strategy_id` bigint NOT NULL COMMENT '抽奖策略ID',
`order_id` varchar(12) NOT NULL COMMENT '抽奖订单ID【作为幂等使用】',
`award_id` int NOT NULL COMMENT '奖品ID',
`award_title` varchar(128) NOT NULL COMMENT '奖品标题(名称)',
`award_time` datetime NOT NULL COMMENT '中奖时间',
`award_state` varchar(16) NOT NULL DEFAULT 'create' COMMENT '奖品状态;create-创建、completed-发奖完成',
`create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
`update_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
PRIMARY KEY (`id`),
UNIQUE KEY `uq_order_id` (`order_id`),
KEY `idx_user_id` (`user_id`),
KEY `idx_activity_id` (`activity_id`),
KEY `idx_award_id` (`strategy_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='用户中奖记录表';

写入中奖记录

写入中奖记录的流程

  1. 在一个事务中,保存中奖记录、修改抽奖订单状态为已使用、保存 Task 发送消息任务。
  2. 发送 MQ 消息,通知奖品发货(可以优化为异步发送 MQ 消息),消息发送成功后,修改 Task 任务状态为已完成。
  3. 开启一个定时任务,定时查询 task 表中发送失败的消息,进行发奖消息的补偿重发(此过程用线程池实现,每一次查出十条消息,每一条消息开启一个线程进行消息补偿重发)。
  4. 奖品发货消息消费者监听 MQ 消息,进行奖品发货,利用 MQ 消息的不丢机制,保证奖品一定发货。

在抽奖与写入中奖信息之间,使用消息队列连接,保证用户抽到奖品后中奖信息一定能够入库

image-20240523203527221

创建 sku 订单

在创建 sku 订单时,分为三个步骤:

  1. 查询基础信息,包括查询 sku 实体信息、活动信息(优先缓存)、活动次数信息(优先缓存)。
  2. 活动责任链校验:
    1. 活动基础信息校验,包括活动的开始结束时间、活动的状态、从 redis 中获取到的库存。
    2. sku 库存校验,与之前的奖品库存校验方法基本一致。
  3. 以数据库事务的方式,进行sku 订单入库、更新活动账户信息:
    1. sku 订单入库,以外部业务 ID 作为唯一索引,保证 sku 订单不重复创建。
    2. 更新活动账户总表、日表、月表,增加可用抽奖和总抽奖次数。

活动责任链校验

活动责任链校验:

  1. 活动基础信息校验,包括活动的开始结束时间、活动的状态、从 redis 中获取到的库存(如果获取到的库存小于等于 0,则说明 sku 库存一定不足)。
  2. sku 库存校验,与之前的奖品库存校验方法基本一致。
    • 采用 decr+setnx 保证不超卖,使用延迟队列扣减数据库中的库存,定时任务保证数据库和缓存最终一致性。
    • 不同的是,当库存等于 0 时,发送 MQ 消息直接通知数据库更新库存为 0,并且清空延迟队列。这一步主要是为了减少额外的性能开销,如果库存已经到 0 了,后续定时任务也不需要执行了直接更新数据库库存为 0 即可。也不需要添加到 task 任务表,这里消息丢失对业务没有影响,只是会消耗一些算力,用于一个一个递减活动库存。

image-20240523202515316

sku 订单入库

以事务的方式,进行sku 订单入库、更新活动账户信息。包括:

  1. sku 订单入库,以外部业务 ID 作为唯一索引,保证 sku 订单不重复创建。如一个用户在一天之内的签到,业务 ID 都是相同的;每一笔订单产生的业务 ID 也是相同的。
  2. 更新活动账户总表、日表、月表,增加可用抽奖和总抽奖次数。
    • 在更新活动账户总表时,如果用户记录不存在,则在活动账户总表中创建记录。
    • 在更新活动账户日/月表时,如果用户记录不存在则不会创建。因为这是在用户实际抽奖时才进行创建的。

以 user_id 作为分库键,可以保证事务的发生都在一个数据库中,不会出现分布式事务

image-20240523201909941

核心代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
transactionTemplate.execute(status -> {
try{
// 1. 写入订单
raffleActivityOrderDao.insert(raffleActivityOrder);
// 2. 更新账户
int count = raffleActivityAccountDao.updateAccountQuota(raffleActivityAccount);
// 3. 创建账户 - 更新为0,则账户不存在,创新新账户。
if (count == 0) {
raffleActivityAccountDao.insert(raffleActivityAccount);
}
// 4. 更新账户 - 月
raffleActivityAccountMonthDao.addAccountQuota(raffleActivityAccountMonth);
// 5. 更新账户 - 日
raffleActivityAccountDayDao.addAccountQuota(raffleActivityAccountDay);

return 1;
} catch (DuplicateKeyException e) {
status.setRollbackOnly();
log.error("写入订单记录,唯一索引冲突 userId: {} activityId: {} sku: {}", activityOrderEntity.getUserId(), activityOrderEntity.getActivityId(), activityOrderEntity.getSku(), e);
throw new AppException(Constants.ResponseCode.INDEX_DUP.getCode(), Constants.ResponseCode.INDEX_DUP.getInfo());
}
});

创建抽奖订单

在用户抽奖前需要创建抽奖订单,为什么需要先创建订单再进行抽奖?

  1. 增加容错性。整个抽奖流程长,有策略计算、信息入库,如果抽奖过程中出现了错误,下一次抽奖直接使用上次未完成的抽奖订单即可。保证了失败的抽奖流程,不会消耗有效的抽奖次数。
  2. 方便后续扩展。后续实现积分抽奖功能,可以直接将积分兑换成未使用的抽奖订单。

image-20240523203028364

数据库设计

数据库表的设计可以通过两个维度去考虑:创建 sku 订单(领取抽奖机会)和创建抽奖订单(抽奖)。

创建 sku 订单

  • 抽奖活动表 raffle_activity:活动名称、开始结束时间、抽奖策略等基本活动信息。
  • 参与次数表 raffle_activity_count:与 sku 绑定,即用户下单 sku 后,用户活动账户增加的次数。
  • 活动 sku 表 raffle_activity_sku:将活动作为一种商品。可以实现比如签到给一次抽奖机会、下单给两次抽奖机会这样的场景,一种场景对应于一条 sku 记录。
  • 活动下单记录 raffle_activity_order(分库分表):当用户领取活动抽奖机会时,会产生一条 sku 下单记录,并增加活动账户表(总、月、日)中的抽奖次数、剩余抽奖次数(在一个事务中)。

1

创建抽奖订单

用户活动账户总表 raffle_activity_account(分库不分表):记录用户总的抽奖次数、当前月/日抽奖次数。

用户活动账户月表 raffle_activity_account_month(分库不分表):记录用户每月的抽奖次数和剩余抽奖次数,相当于用户每月抽奖次数的流水记录,运营需要。

用户活动账户日表 raffle_activity_account_day(分库不分表):记录用户每日的抽奖参与次数和剩余抽奖次数,相当于用户每日抽奖次数的流水记录,运营需要。

用户抽奖订单表 user_raffle_order(分库分表):每一次用户抽奖都会生成一个用户抽奖订单,每次抽奖前都会查看用户是否有未消费的订单。保证用户在抽奖时,只会消耗一次抽奖机会。

任务表 task(分库不分表):用于发送MQ消息。通过任务扫描发送,发送补偿消息,是一种兜底策略。

2

活动装配

整体流程

活动装配的就是缓存预热,主要是预热 sku 库存、将活动信息加载到缓存中。

  • 首先查询活动下配置的所有 sku,并把 sku 库存、次数配置(raffle_activity_count)加载到缓存中。
  • 其次查询活动配置信息,将活动加载到缓存中。

3

代码实现

代码实现:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
public boolean assembleActivitySkuByActivityId(Long activityId) {
List<ActivitySkuEntity> activitySkuEntityList = activityRepository.queryActivitySkuListByActivityId(activityId);
for (ActivitySkuEntity activitySkuEntity : activitySkuEntityList) {
Long sku = activitySkuEntity.getSku();
cacheActivitySkuStockCount(sku, activitySkuEntity.getStockCountSurplus());

// 预热活动次数【查询时预热到缓存】
activityRepository.queryActivityCountByActivityCountId(activitySkuEntity.getActivityCountId());

}

// 预热活动【查询时预热到缓存】
activityRepository.queryActivityByActivityId(activityId);

return true;
}

数据库设计

抽奖策略配置表 strategy:配置抽奖策略的基本信息。

1
2
3
4
5
6
7
8
9
10
CREATE TABLE `strategy` (
`id` bigint unsigned NOT NULL AUTO_INCREMENT COMMENT '自增ID',
`strategy_id` bigint NOT NULL COMMENT '抽奖策略ID',
`strategy_desc` varchar(128) NOT NULL COMMENT '抽奖策略描述',
`rule_models` varchar(256) DEFAULT NULL COMMENT '规则模型,rule配置的模型同步到此表,便于使用',
`create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
`update_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
PRIMARY KEY (`id`),
KEY `idx_strategy_id` (`strategy_id`)
) ENGINE=InnoDB AUTO_INCREMENT=7 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='抽奖策略'

抽奖策略奖品配置表 strategy_award:配置奖品的中奖概率,以及奖品的库存。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
CREATE TABLE `strategy_award` (
`id` bigint unsigned NOT NULL AUTO_INCREMENT COMMENT '自增ID',
`strategy_id` bigint NOT NULL COMMENT '抽奖策略ID',
`award_id` int NOT NULL COMMENT '抽奖奖品ID - 内部流转使用',
`award_title` varchar(128) NOT NULL COMMENT '抽奖奖品标题',
`award_subtitle` varchar(128) DEFAULT NULL COMMENT '抽奖奖品副标题',
`award_count` int NOT NULL DEFAULT '0' COMMENT '奖品库存总量',
`award_count_surplus` int NOT NULL DEFAULT '0' COMMENT '奖品库存剩余',
`award_rate` decimal(6,4) NOT NULL COMMENT '奖品中奖概率',
`rule_models` varchar(256) DEFAULT NULL COMMENT '规则模型,rule配置的模型同步到此表,便于使用',
`sort` int NOT NULL DEFAULT '0' COMMENT '排序',
`create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
`update_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '修改时间',
PRIMARY KEY (`id`),
KEY `idx_strategy_id_award_id` (`strategy_id`,`award_id`)
) ENGINE=InnoDB AUTO_INCREMENT=30 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='抽奖策略奖品概率'

抽奖策略规则表 strategy_rule:若 award_id 为空,则为策略级别的规则(抽奖前责任链中的规则,包括黑名单 black_list 和权重抽奖 rule_weight)。若 award_id 非空,则为奖品级别的规则。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
CREATE TABLE `strategy_rule` (
`id` bigint unsigned NOT NULL AUTO_INCREMENT COMMENT '自增ID',
`strategy_id` int NOT NULL COMMENT '抽奖策略ID',
`award_id` int DEFAULT NULL COMMENT '抽奖奖品ID【规则类型为策略,则不需要奖品ID】',
`rule_type` tinyint(1) NOT NULL DEFAULT '0' COMMENT '抽象规则类型;1-策略规则、2-奖品规则',
`rule_model` varchar(16) NOT NULL COMMENT '抽奖规则类型【black_list rule_weight】',
`rule_value` varchar(256) NOT NULL COMMENT '抽奖规则比值',
`rule_desc` varchar(128) NOT NULL COMMENT '抽奖规则描述',
`create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
`update_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
PRIMARY KEY (`id`),
UNIQUE KEY `uq_strategy_id_rule_model` (`strategy_id`,`rule_model`),
KEY `idx_strategy_id_award_id` (`strategy_id`,`award_id`)
) ENGINE=InnoDB AUTO_INCREMENT=17 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='抽奖策略规则'

策略装配

抽奖策略装配实际上是在做缓存预热,将奖品的剩余库存以及概率表存储在 redis 中,防止缓存穿透和缓存击穿(剩余库存标识了策略 ID,如果抽奖时在缓存中查询不到剩余库存,则会返回错误。)。策略装配的流程:

  1. 从数据库中查询策略奖品配置(优先从 redis 缓存中获取),得到该策略下的所有策略奖品信息(包括奖品信息、库存、每个奖品的概率)。
  2. 缓存奖品库存,为后续用户抽奖时扣减库存 decr 做准备。
  3. 装配中奖概率到 redis,装配概率表(redis hash 存储,value 为奖品 ID)和概率范围(用于在抽奖时获取随机范围)
    1. 装配默认中奖概率,即该策略下的全量中奖概率。首先获取概率表,然后进行乱序,转为 map 后存入 redis。
    2. 装配权重策略中奖概率

image-20240523161309017

概率装配

在装配中奖概率时,需要获取最小概率和获取概率总和,概率范围=概率总和/最小概率。

为什么概率范围=概率总和/最小概率,每个策略的概率总和不一定是 1 吗?

是的,比如在权重规则中,因为到达一定权重后,得到的奖品范围缩小了,所以概率总和小于 1。总之,这是一种健壮性的体现,概率不为 1 时也不会出错。

中奖概率表计算方法改进

以前的问题

概率范围=概率总和/最小概率,并向上取整,这是存在问题的。

如考虑两个概率 0.00011 和 0.99989,最小概率为 1 / 0.00011 = 9090.9090,向上取整后得到概率范围为 9091。

实际在计算时,9091 * 0.99989 =9 089.999,取成 9090,在概率表中占 9090 个位置。9091* 0.00011 = 1.00001,取成 2,在概率表中占 2 个位置。所以概率表的实际长度是 9092 而非 9091,所以记录的概率范围(9091)是错误的。

如果全部向下取整呢

最小概率为 1 / 0.00011 = 9090.9090,向下取整后得到概率范围为 9090。

9090 * 0.99989 =9 089.0001,取成 9089,在概率表中占 9089 个位置。9090 * 0.00011 = 0.999,取 0,在概率表中占 0 个位置。这种情况该列表的实际长度小于概率范围,并且概率小的奖品占位为零,根本取不到。

改进后的方法

在改进后的方法,不直接计算概率范围。而是根据最小概率值,计算出百分比、千分比的整数值,然后之间填充概率表,以概率表的实际长度作为概率范围。

  1. 找到范围内最小的概率值,比如 0.1、0.02、0.003,需要找到的值是 0.003。
  2. 基于上一步找到的最小值,0.003 就可以计算出百分比、千分比的整数值。这里就是1000。
  3. 那么「概率 * 1000」分别占比100个、20个、3个,总计是123个。
  4. 后续的抽奖就用 123 作为随机数的范围值,在概率表中 100 个为概率为 0.1 的奖品,20 个为概率为 0.02 的奖品,3 个为 概率为 0.003 的奖品。
  5. 概率范围取概率表的实际长度

代码实现

代码实现如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
private void assembleLotteryStrategy(String key, List<StrategyAwardEntity> strategyAwardEntities) {
// 1. 获取最小概率值
BigDecimal minAwardRate = strategyAwardEntities.stream()
.map(StrategyAwardEntity::getAwardRate)
.min(BigDecimal::compareTo)
.orElse(BigDecimal.ZERO);

// 2. 循环计算找到概率范围值
BigDecimal rateRange = BigDecimal.valueOf(convert(minAwardRate.doubleValue()));

// 3. 生成策略奖品概率查找表「这里指需要在list集合中,存放上对应的奖品占位即可,占位越多等于概率越高」
List<Integer> strategyAwardSearchRateTables = new ArrayList<>(rateRange.intValue());
for (StrategyAwardEntity strategyAward : strategyAwardEntities) {
Integer awardId = strategyAward.getAwardId();
BigDecimal awardRate = strategyAward.getAwardRate();
// 计算出每个概率值需要存放到查找表的数量,循环填充
for (int i = 0; i < rateRange.multiply(awardRate).intValue(); i++) {
strategyAwardSearchRateTables.add(awardId);
}
}

// 4. 对存储的奖品进行乱序操作
Collections.shuffle(strategyAwardSearchRateTables);

// 5. 生成出Map集合,key值,对应的就是后续的概率值。通过概率来获得对应的奖品ID
Map<Integer, Integer> shuffleStrategyAwardSearchRateTable = new LinkedHashMap<>();
for (int i = 0; i < strategyAwardSearchRateTables.size(); i++) {
shuffleStrategyAwardSearchRateTable.put(i, strategyAwardSearchRateTables.get(i));
}

// 6. 存放到 Redis
repository.storeStrategyAwardSearchRateTable(key, shuffleStrategyAwardSearchRateTable.size(), shuffleStrategyAwardSearchRateTable);
}

DDD

DDD(Domain-Driven Design 领域驱动设计),目的是对软件所涉及到的领域进行建模,以应对系统规模过大时引起的软件复杂性的问题。整个过程大概是这样的,开发团队和领域专家一起通过通用语言(Ubiquitous Language)去理解和消化领域知识,从领域知识中提取和划分为一个一个的子领域(核心子域,通用子域,支撑子域),并在子领域上建立模型,再重复以上步骤,这样周而复始,构建出一套符合当前领域的模型。

架构分层

DDD 有多种分层结构,以下是其中一种:

  • 接口定义 api 层:因为微服务中引用的 RPC 需要对外提供接口的描述信息(DTO 的定义也在这里),也就是调用方在使用的时候,需要引入 Jar 包,让调用方好能依赖接口的定义做代理。
  • 应用封装 app 层:应用启动和配置的一层,如一些 aop 切面或者 config 配置,可以把它理解为专门为了启动服务而存在的。
  • 领域封装 domain 层:领域模型服务,在一层中会有一个个细分的领域服务,在每个服务包中会有【模型、仓库、服务】三个部分。
  • 基础设施 infrastructure 层:基础层依赖于 domain 领域层,因为在 domain 层定义了仓储接口需要在基础层实现。这是依赖倒置的一种设计方式。包括:
    • 数据库操作。
    • 缓存操作。
    • 消息队列操作。
  • 触发器 trigger 层:触发器层,一般也被叫做 adapter 适配器层。用于提供接口实现、消息接收、任务执行等。包括:
    • HTTP 接口实现(Controller),实现 api 层。
    • RPC 实现,实现 api 层。
    • MQ Listener,MQ 消费者。
    • Job 任务执行。
  • 类型定义 types 层:通用类型定义层,包括基本的 Response、常量 Constants 和通用的枚举。会被其他的层进行引用。
  • 领域编排 case 层:领域编排层,一般对于较大且复杂的的项目,为了更好的防腐和提供通用的服务,一般会添加 case/application 层,用于对 domain 领域的逻辑进行封装组合处理。

img

领域分层

DDD 领域驱动设计的中心,主要在于领域模型的设计,以领域所需,驱动功能实现和数据建模。一个领域服务下面会有多个领域模型,每个领域模型都是一个充血结构。领域模型由以下三个部分组成:

  • model 模型对象:
    • 实体对象 entity:表示具有唯一标识的业务实体,例如订单、商品、用户等。
    • 值对象 valobj:表示没有唯一标识的业务实体,例如商品的名称、描述、价格等。
    • 聚合对象 aggregate:由实体对象和值对象聚合而成,通常一个聚合对象内完成一个事务,保证对象之间的一致性和完整性。
  • repository :仓储服务。传递的对象可以是聚合对象、实体对象,返回的结果可以是实体对象、值对象。仅仅定义仓储服务接口,由基础层 infrastructure 实现,是一种依赖倒置的结构。可以天然的隔离 PO 数据库持久化对象被引用。
  • service:服务设计。

img

如何大营销平台的领域模型(战略设计)

业务流程图如下:

img

用例图

首先根据业务需求画系统用例图,用例图是用户与系统交互的最简表示形式,展现了用户和与他相关的用例之间的关系。通过用例图,人们可以获知系统不同种类的用户和用例。用例图,也可以等同于是用户故事。

img

事件风暴定义

首先定义事件风暴,这样才能让产品、研发、测试、运营等了解业务的伙伴,都能在同一个语言下完成系统建模。

  • 蓝色 - 决策命令(系统操作),是用户发起的行为动作,如;开始签到、开始抽奖、查看额度等。
  • 黄色 - 领域事件,过去时态描述。如;签到完成、抽奖完成、奖品发放完成。它所阐述的都是这个领域要完成的终态。
  • 粉色 - 外部系统,如系统需要调用外部的接口完成流程。
  • 红色 - 业务流程,用于串联决策命令到领域事件,所实现的业务流程。一些简单的场景则直接有决策命令到领域事件就可以了。
  • 绿色 - 只读模型,做一些读取数据的动作,没有写库的操作。
  • 棕色 - 领域对象,每个决策命令的发起,都是含有一个对应的领域对象。

寻找领域事件

挖掘领域事件的过程,就是一堆人头脑风暴的过程。根据产品 PRD 文档,一起开会梳理有哪些领域事件。其实大多数领域事件一个人都可以想到,只是有些部分小的场景和将来可能产生的事件不一定覆盖全。所以要通过产品、测试、以及团队的架构师,一起讨论。

img

识别领域角色和对象

在确定了领域事件以后,接下来要做的就是通过决策命令串联领域事件,并填充上所需要的领域对象

img

划出领域边界

有了识别出来的领域角色的流程,就可以非常容易的划分出领域边界了。先在事件风暴图上圈出领域边界,之后在单独提供领域划分。

img

img

Bean 的加载

Bean 的加载方式

Bean 的加载方式包括:

  • XML 配置文件方式导入。

    • 使用 bean 标签导入。
    • 使用 @ImportResource 导入配置文件。
  • @Component 注解和 @Bean 注解。

    • @Component 注解。
    • @Configuration + @Bean 注解。
    • 类实现 FactoryBean 接口,并使用 @Bean 修饰方法返回实现 FactoryBean 的实现类。
  • @Import 导入。

    • @Import 直接导入 Bean 类、配置类。
    • @Import 导入 ImportSelector 接口。
    • @Import 导入 ImportBeanDefinitionRegistrar 接口。
    • @Import 导入 BeanDefinitionRegistryPostProcessor 接口。
  • 在 IOC 容器初始化完成后再注册 Bean,即 AnnotationConfigApplicationContext 调用 register 方法注册 bean。

下面说明 ImportSelector 接口、ImportBeanDefinitionRegitrar 接口、BeanDefinitionRegistryPostProcessor 接口。

ImportSelector 接口可以根据注解元数据 AnnotationMetadata 进行一系列判断,返回的是需要被导入的全路径名。

1
2
3
4
5
6
7
8
9
10
11
12
public MyImportSelector implements ImportSelector {
public String[] selectImports(AnnotationMetadata metadata) {
// 。。。
// 可以根据 AnnotationMetadata 做出一系列判断,如:
boolean flag = metadata.hasAnnotation("org.springframework.context.annotation.Import");
if flag {
return []String{/* ... */};
}

return []String{/* ... */};
}
}

ImportBeanDefinitionRegistrar 接口,不仅可以通过元数据 AnnotationMetadata 进行判断,而且可以通过 BeanDefinition 注册器来手动注册 Bean,控制 Bean 的注册过程。

1
2
3
4
5
6
7
8
9
10
public MyImportBeanDefinitionRegistrar implements InportBeanDefinitionRegistrar {
public void registerBeanDefinitions(AnnotationMetadata metadata, BeanDefinitionRegistry registry) {
// 。。。
// 使用 AnnotationMetadata 做出判断

// 注册 bean
BeanDefinition beanDefinition = BeanDefinitionBuilder.rootBeanDefinition(xxx.class).getBeanDefinition();
registry.registerBeanDefinition("beanName", beanDefinition);
}
}

BeanDefinitionRegistryPostProcessor 接口,通过 BeanDefinition 注册器注册 bean,实现对 bean 的最终裁定(可以覆盖前面 bean 的注册)。

Bean 的加载控制

Bean 的加载控制就是根据特定情况进行选择性的加载,控制 Bean 的加载过程。可以分为两种方式,编程式和注解式。

  • 编程式:
    • @Import 导入 ImportSelector 接口。
    • @Import 导入 ImportBeanDefinitionRegistrar 接口。
    • @Import 导入 BeanDefinitionRegistryPostProcessor 接口。
    • AnnotationConfigApplicationContext 调用 register 方法注册 bean。
  • 注解式:
    • 使用 @Conditional 注解及其衍生注解 @ConditionalOnXxx 设置 Bean 的加载条件。

自动配置

SpringBoot 在启动时会扫描外部引用 jar 包中的 META-INF/spring.factories 文件,将文件中配置的类型信息加载到 Spring 容器,并执行类中定义的各种操作。对于外部 jar 来说,只需要按照 SpringBoot 定义的标准,就能将自己的功能装置进 SpringBoot。

从 SpringBoot 3.0 开始,自动配置包的路径从 META-INF/spring.factories 改为 META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports。

引入 starter 之后,可以通过少量注解和一些简单的配置就能使用第三方组件提供的功能了,通过注解或者一些简单的配置就能在 Spring Boot 的帮助下实现某块功能。

原理

SpringBoot 的核心注解 @SpringBootApplication ,主要包含三个注解 @SpringBootConfiguration、@ComponentScan、@EnableAutoConfiguration。

  • @SpringBootConfiguration:可以看作是 @Configuration,允许在上下文中注册额外的 bean 或导入其他配置类。
  • @ComponentScan:扫描被 @Component 注解的 Bean,注解默认会扫描启动类所在的包下所有的类 ,可以自定义不扫描某些 bean。同时,容器中将排除 TypeExcludeFilter AutoConfigurationExcludeFilter
  • @EnableAutoConfiguration:开启自动配置,是自动配置的关键注解。
1
2
3
4
5
6
7
8
9
10
@Target({ElementType.TYPE})
@Retention(RetentionPolicy.RUNTIME)
@Documented
@Inherited
@SpringBootConfiguration
@ComponentScan
@EnableAutoConfiguration
public @interface SpringBootApplication {

}

@EnableAutoConfiguration

@EnableAutoConfiguration 其中包含了两个主要注解:

  • @AutoConfigurationPackage:将 @SpringBootApplication 路径下的所有 Bean 注册到容器中。
  • @Import({AutoConfigurationImportSelector.class}):加载自动装配类。
1
2
3
4
5
6
7
8
9
10
11
12
13
@Target({ElementType.TYPE})
@Retention(RetentionPolicy.RUNTIME)
@Documented
@Inherited
@AutoConfigurationPackage //作用:将main包下的所有组件注册到容器中
@Import({AutoConfigurationImportSelector.class}) //加载自动装配类 xxxAutoconfiguration
public @interface EnableAutoConfiguration {
String ENABLED_OVERRIDE_PROPERTY = "spring.boot.enableautoconfiguration";

Class<?>[] exclude() default {};

String[] excludeName() default {};
}

AutoConfigurationImportSelector 加载自动装配类

AutoConfigurationImportSelector 的声明如下:

1
2
3
4
5
6
7
8
9
10
11
public class AutoConfigurationImportSelector implements DeferredImportSelector, BeanClassLoaderAware, ResourceLoaderAware, BeanFactoryAware, EnvironmentAware, Ordered {

}

public interface DeferredImportSelector extends ImportSelector {

}

public interface ImportSelector {
String[] selectImports(AnnotationMetadata var1);
}

AutoConfigurationImportSelector 实现了 ImportSelector 类,作用是获取所有符合条件的类的全限定类名,这些类需要被加载到 IoC 容器中。通过 getAutoConfigurationEntry 方法来实现获取所有需要自动配置的全限定类名。

1
2
3
4
5
6
7
8
9
10
11
12
13
private static final String[] NO_IMPORTS = new String[0];

public String[] selectImports(AnnotationMetadata annotationMetadata) {
// <1>.判断自动装配开关是否打开
if (!this.isEnabled(annotationMetadata)) {
return NO_IMPORTS;
} else {
//<2>.获取所有需要装配的bean
AutoConfigurationMetadata autoConfigurationMetadata = AutoConfigurationMetadataLoader.loadMetadata(this.beanClassLoader);
AutoConfigurationImportSelector.AutoConfigurationEntry autoConfigurationEntry = this.getAutoConfigurationEntry(autoConfigurationMetadata, annotationMetadata);
return StringUtils.toStringArray(autoConfigurationEntry.getConfigurations());
}
}

getAutoConfigurationEntry 方法

getAutoConfigurationEntry 方法如下,主要有流程:

  1. 判断自动装配开关是否打开,默认为 true。
  2. 获取 @EnableAutoConfiguration 注解中的 exclude 和 excludeName 属性。
  3. 获取所有自动装配的配置类,读取所有引入 jar 包下的 META-INF/spring.factories 文件。
  4. 自动配装配的配置类通过 @ConditionalOnXxx 进行选择性的加载控制,在 getAutoConfigurationEntry 中剔除不需要被加载的配置。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
private static final AutoConfigurationEntry EMPTY_ENTRY = new AutoConfigurationEntry();

AutoConfigurationEntry getAutoConfigurationEntry(AutoConfigurationMetadata autoConfigurationMetadata, AnnotationMetadata annotationMetadata) {
//<1>.
if (!this.isEnabled(annotationMetadata)) {
return EMPTY_ENTRY;
} else {
//<2>.
AnnotationAttributes attributes = this.getAttributes(annotationMetadata);
//<3>.
List<String> configurations = this.getCandidateConfigurations(annotationMetadata, attributes);
//<4>.
configurations = this.removeDuplicates(configurations);
Set<String> exclusions = this.getExclusions(annotationMetadata, attributes);
this.checkExcludedClasses(configurations, exclusions);
configurations.removeAll(exclusions);
configurations = this.filter(configurations, autoConfigurationMetadata);
this.fireAutoConfigurationImportEvents(configurations, exclusions);
return new AutoConfigurationImportSelector.AutoConfigurationEntry(configurations, exclusions);
}
}

自实现 starter

了解了自动配置的原理后,自实现 starter 的流程如下:

  1. 引入 spring boot 相关依赖
  2. 创建自动配置类,用 @ConditionalOnXxx 修饰
  3. 将自动配置类填写到 META-INF/spring.factories 文件下
  4. 新建工程引入 xxx-spring-boot-starter

缓存

Spring 框架提供了透明添加缓存到应用程序的支持。 只要通过使用 @EnableCaching 注解启用缓存支持,Spring Boot就会自动配置缓存基础设施。

Spring Cache

Spring Boot 提供的缓存包括:JCache、Redis、Simple 等。通过 spring.cache.type 配置参数来指定缓存类型,当该值为 None 时为完全禁用缓存。

在方法上使用 @Cachable、@CacheEvict、@CachePut 表示缓存的行为。

Simple

Simple 作为默认的缓存实现,使用 ConcurrentHashMap 作为缓存存储。

Redis

如果已经引入了 spring-boot-starter-redis,缓存的配置信息可以通过 spring.cache.redis.* 来设置。

如果需要对配置信息进行更多的控制,可以考虑注册一个 RedisCacheManagerBuilderCustomizer bean。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
import java.time.Duration;

import org.springframework.boot.autoconfigure.cache.RedisCacheManagerBuilderCustomizer;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.redis.cache.RedisCacheConfiguration;

@Configuration(proxyBeanMethods = false)
public class MyRedisCacheManagerConfiguration {

@Bean
public RedisCacheManagerBuilderCustomizer myRedisCacheManagerBuilderCustomizer() {
return (builder) -> builder
.withCacheConfiguration("cache1", RedisCacheConfiguration
.defaultCacheConfig().entryTtl(Duration.ofSeconds(10)))
.withCacheConfiguration("cache2", RedisCacheConfiguration
.defaultCacheConfig().entryTtl(Duration.ofMinutes(1)));

}

}

@Configuration 注解:

  • @Configuration 是 Spring 框架提供的一个元注解,用于表示一个类是一个配置类。
  • 配置类通常包含 @Bean 注解,定义了创建和配置 Bean 的方法。
  • Spring 会扫描 @Configuration 类,并将其中的 @Bean 方法注册为 Spring 容器中的 Bean(name 默认为方法名)。

@Configuration 注解的 proxyBeanMethods 属性:用于控制@Bean方法的代理行为,默认为 true。

  • 当 proxyBeanMethods 属性设置为 true 时,Spring 会对 @Configuration 类进行 CGLIB 代理。调用 @Bean 方法时,Spring 会检查是否已经存在该 Bean,如果存在,则直接返回已存在的 Bean,否则调用方法创建新的 Bean 并缓存起来。
  • 当 proxyBeanMethods 属性设置为 false 时,会禁用 CGLIB 代理。这种情况下,每一次调用 @Bean 都会执行一次方法体,不会缓存 Bean 对象,适用于那些需要每次返回新实例的场景

JetCache

JetCache 是一个基于 Java 的缓存系统封装,提供统一的 API 和注解来简化缓存的使用。 JetCache 提供了比 SpringCache 更加强大的注解,可以原生的支持 TTL、两级缓存、分布式自动刷新,还提供了 Cache 接口用于手工缓存操作,支持 Spring Boot、支持统计信息。 目前有四个实现:

  • 远程缓存:
    • redis
    • tair
  • 本地缓存:
    • caffeine
    • linked hash map

@EnableMethodCache、@EnableCreateCacheAnnotation 这两个注解分别激活 @Cached 和 @CreateCache 注解。也就是说,可以直接使用 @Cached 实现方法缓存,或者使用创建一个 Cache 实例两种方式,来使用 JetCache。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
package com.company.mypackage;

import com.alicp.jetcache.anno.config.EnableCreateCacheAnnotation;
import com.alicp.jetcache.anno.config.EnableMethodCache;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;

@SpringBootApplication
@EnableMethodCache(basePackages = "com.company.mypackage")
@EnableCreateCacheAnnotation
public class MySpringBootApp {
public static void main(String[] args) {
SpringApplication.run(MySpringBootApp.class);
}
}

配置信息参见:https://github.com/alibaba/jetcache/blob/master/docs/CN/Config.md

创建 Cache 实例

有两种方法创建 Cache 实例,分别是 CacheManager 和 @CacheCreate 注解(在jetcache 2.7 版本CreateCache注解已经废弃)。

  • 使用 CacheManager 可以创建 Cache 实例,area 和 name 相同的情况下,它和 Cached 注解使用同一个 Cache 实例。
1
2
3
4
5
6
7
8
9
10
11
12
13
@Autowired
private CacheManager cacheManager;
private Cache<String, UserDO> userCache;

@PostConstruct
public void init() {
QuickConfig qc = QuickConfig.newBuilder("userCache")
.expire(Duration.ofSeconds(100))
.cacheType(CacheType.BOTH) // two level cache
.syncLocal(true) // invalidate local cache in all jvm process after update
.build();
userCache = cacheManager.getOrCreateCache(qc);
}
  • 使用 @CreateCache 注解创建一个Cache实例,在jetcache 2.7 版本CreateCache注解已经废弃
1
2
@CreateCache(expire = 100)
private Cache<Long, UserDO> userCache;

方法缓存

在 spring 环境下,使用 @Cached 注解可以为一个方法添加缓存,@CacheUpdate 用于更新缓存,@CacheInvalidate 用于移除缓存元素。

1
2
3
4
5
6
7
8
9
10
public interface UserService {
@Cached(name="userCache.", key="#userId", expire = 3600)
User getUserById(long userId);

@CacheUpdate(name="userCache.", key="#user.userId", value="#user")
void updateUser(User user);

@CacheInvalidate(name="userCache.", key="#userId")
void deleteUser(long userId);
}

定时任务

Quartz

Spring Boot 提供了 Quartz 的 starter,spring-boot-starter-quartz。如果 Quartz 可用,就会自动配置一个 Scheduler。以下类型的 Bean 会被自动装配并与 Scheduler 关联:

  • JobDetail:定义了一个特定的 Job。 JobDetail 实例可以通过 JobBuilder API建立。
  • Calendar
  • Trigger:定义了一个特定的 Job 何时被触发。

定义任务

通过继承 QuartzJobBean 来定义自己的任务,重写 executeInternal 方法,并且可以通过 setter 注入属性。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
import org.quartz.JobExecutionContext;
import org.quartz.JobExecutionException;

import org.springframework.scheduling.quartz.QuartzJobBean;

public class MySampleJob extends QuartzJobBean {

private MyService myService;

private String name;

// Inject "MyService" bean
public void setMyService(MyService myService) {
this.myService = myService;
}

// Inject the "name" job data property
public void setName(String name) {
this.name = name;
}

@Override
protected void executeInternal(JobExecutionContext context) throws JobExecutionException {
this.myService.someMethod(context.getFireTime(), this.name);
}

}

持久化任务

默认情况下,使用内存中的 JobStore。 然而,如果你的应用程序中有一个 DataSource Bean,并且相应地配置了 spring.quartz.job-store-type 属性,也可以配置一个基于 JDBC 的 store。

要让 Quartz 使用应用程序主 DataSource 以外的 DataSource,可以声明一个 DataSource bean,用 @QuartzDataSource 注释其 @Bean 方法。这样做可以确保Quartz特定的数据源被 SchedulerFactoryBean 和schema初始化所使用。

同样地,为了让 Quartz 使用应用程序的主 TransactionManager 以外的 TransactionManager,需要声明一个 TransactionManager Bean,用 @QuartzTransactionManager 注释其 @Bean 方法。

默认情况下,通过配置创建的Job不会覆盖已经从持久性 JobStore 中读取的已注册Job。 要启用覆盖现有作业定义,需要设置 spring.quartz.overwrite-existing-jobs 属性。

Spring Task

Spring 原生的支持定时任务功能,使用 @EnableScheduling 注解开启定时任务功能,使用 @Scheduled 注解指定定时任务。

1
2
3
4
5
6
7
@Component
class MyBean {
@Scheduled(cron = "1 * * * * ?")
public void myTask() {
// ...
}
}

Redis

通过引入 spring-boot-start-data-redis,就可以在 Spring Boot 中集成 Redis;同时也可以引入 spring-boot-starter-data-redis-reactive,支持与 Redis 的响应式异步交互。

使用

通过引入 starter,就可以注入一个自动配置的 RedisConnectionFactory、StringRedisTemplate、RedisTemplate。通过 StringRedisTemplate 就可以与 Redis 进行交互。

客户端

Spring Boot 支持两种 Redis 客户端,分别是 lettuce 和 jedis,lettuce 是默认的 Redis 客户端。

区别

lettuce 与 jedis 区别:

  • jedis 连接 Redis 服务器是直连模式,当多线程模式下使用 jedis 会存在线程安全问题,解决方案可以通过配置连接池使每个连接专用,但是会产生性能问题。
  • lettcus 基于 Netty 框架进行与 Redis 服务器连接,底层设计中采用 StatefulRedisConnection。StatefulRedisConnection 自身是线程安全的,可以保障并发访问安全问题,一个连接可以被多线程复用

MongoDB

通过引入 spring-boot-start-data-mongodb,就可以在 Spring Boot 中集成 MongoDB;同时也可以引入 spring-boot-starter-data-mongodb-reactive,支持与 MongoDB 的响应式异步交互。

可以注入 MongoTemplate 类来操作 MongoDB。

ElasticSearch

通过引入 spring-boot-start-data-elasticsearch,就可以在 Spring Boot 中集成 ES;同时也可以引入 spring-boot-starter-data-elasticsearch-reactive,支持与 ES 的响应式异步交互。

Spring Boot支持多个客户端。

  • 官方低级别的 REST 客户端(low-level REST client)
  • 官方的 Java API 客户端
  • Spring Data Elasticsearch 提供的 ReactiveElasticsearchClient

通过 ElasticsearchTemplate 来操作 ES。