基于SPEL实现ABTest服务
ABTest服务
一个实验组,分多个实验,业务需要验证哪个实验更优。
为调用方提供选择哪个实验的服务。
方案
通常基于用户属性信息做分流。我们将用户各种属性信息加工成标签。 该方案基于SPEL 表达式,计算SPEL规则表达式的结果true/false。
入参:
- 实验组名称
- 扩展参数,后续扩展信息
表结构
1个实验组 => N 个实验
1个实验 => 1 个规则组
1个规则组 => N 个规则
1个service => N 个标签
- 实验组表
- 实验表
- 实验扩展信息表
- 规则组表
- 规则表
- 服务和标签关系
规则里的标签和service设计
一个用户可以有N个标签,某些标签归位一类。归的一类就是一个Service,作用就是加工标签信息。
-
定义接口,实现一个加工标签的方法。入参用户信息,出参是Map<String, Object> 标签属性 => 标签值
-
每个Service实现该接口,加工各自标签信息
-
规则表达式里把各自标签写出来
流程
- 接收外部传入的实验组,通过实验组查找所有实验
- 遍历每个实验,根据对应的规则组,找到对应的规则
- 解析规则里的所有标签,再找到标签对应的serviceName
- 计算每个service里的所有标签
- 拿到标签值,SPEL 计算每个规则的表达式结果
代码demo
- 接口 定义加工标签
public interface IBizLabelService {
Map<String, Object> labels(UserInfo userInfo);
}
- 实现类 加工标签
@Service
public class BizLabelAService implements IBizLabelService {
@Override
public Map<String, Object> labels(UserInfo userInfo) {
// 查表、服务...
Map<String, Object> maps = new HashMap<>();
// BizLabelAService 加工的标签值
maps.put("labelA", "abc");
maps.put("labelA1", false);
return maps;
}
}
@Service
public class BizLabelBService implements IBizLabelService {
@Override
public Map<String, Object> labels(UserInfo userInfo) {
// 查表、服务...
Map<String, Object> maps = new HashMap<>();
// BizLabelBService 加工的标签值
maps.put("labelB", true);
maps.put("labelB1", "abc");
return maps;
}
}
- serviceName找对应的service服务 简单利用Spring注入的Map<String, bean>
@Service
public class BizLabelFactory {
@Autowired
private Map<String, IBizLabelService> iBizLabelServiceMap;
public IBizLabelService getBean(String serviceName) {
if (MapUtils.isEmpty(iBizLabelServiceMap)) {
throw new IllegalArgumentException();
}
IBizLabelService iBizLabelService = iBizLabelServiceMap.get(serviceName);
if (Objects.isNull(iBizLabelService)) {
throw new IllegalArgumentException();
}
return iBizLabelService;
}
}
- 主逻辑
- 表达式递归出所有标签
- 标签找serviceName
- serviceName找到service,计算标签值
- 计算Spel表达式
表达式如: #k == v 的spel形式
#labelA == "abc" && #labelB == true
@Service
public class ValidateExpression {
@Autowired
private BizLabelFactory bizLabelFactory;
/**
* 规则组 和 规则 的关系
*/
private static Map<String, List<String>> ruleGroupMaps = new HashMap<String, List<String>>() {{
put("ruleGroupA", Lists.newArrayList("ruleA", "ruleB"));
put("ruleGroupB", Lists.newArrayList("ruleA"));
put("ruleGroupC", Lists.newArrayList("ruleB"));
}};
/**
* 规则对应的表达式
*/
private static Map<String, String> rulesMaps = new HashMap<String, String>() {{
put("ruleA", "#labelA == \"abc\" && #labelA1 == true");
put("ruleB", "#labelB == true && #labelB1 == \"abc\"");
}};
/**
* 标签 和 service的 映射关系
*/
private static Map<String, String> labelServiceMap = new HashMap<String, String>() {{
put("labelA", "bizLabelAService");
put("labelA1", "bizLabelAService");
put("labelB", "bizLabelBService");
put("labelB1", "bizLabelBService");
}};
/**
* @param userInfo
* @param ruleGroupId
* @return
*/
public boolean judge(UserInfo userInfo, String ruleGroupId) {
// 1. 规则组Id 找下辖子规则
List<String> rules = ruleGroupMaps.get(ruleGroupId);
if (CollectionUtils.isEmpty(rules)) {
throw new IllegalArgumentException();
}
for (String rule : rules) {
String condition = rulesMaps.get(rule);
// 2. 子规则 对应表达式,解析出标签service,
Set<String> labelNames = getLabelNames(condition);
Set<String> serviceNames = new HashSet<>();
labelNames.forEach(e -> {
serviceNames.add(labelServiceMap.get(e));
});
Map<String, Object> labelValues = new HashMap<>();
// 再查出标签值
serviceNames.forEach(serviceName -> {
Map<String, Object> labels = bizLabelFactory.getBean(serviceName).labels(userInfo);
labelValues.putAll(labels);
});
// 3. 判断表达式返回
if (!evaluateBooleanExpression(condition, labelValues)) {
return false;
}
}
return true;
}
/**
* SPEL表达式结果
* @param spelExpression
* @param variables
* @return
*/
private static boolean evaluateBooleanExpression(String spelExpression, Map<String, Object> variables) {
// 1. 创建SpEL表达式解析器
ExpressionParser parser = new SpelExpressionParser();
// 2. 创建评估上下文(用于注入变量)
StandardEvaluationContext context = new StandardEvaluationContext();
// 3. 注入所有变量(key为变量名,如"a"对应表达式中的#a)
if (variables != null && !variables.isEmpty()) {
variables.forEach(context::setVariable);
}
// 4. 解析表达式并执行,返回布尔结果
Expression expression = parser.parseExpression(spelExpression);
return expression.getValue(context, Boolean.class);
}
/**
* 规则表达式 获取所有标签
* @param condition
* @return
*/
private static Set<String> getLabelNames(String condition) {
SpelExpressionParser spelExpressionParser = new SpelExpressionParser();
SpelExpression expression = (SpelExpression) spelExpressionParser.parseExpression(condition);
SpelNode ast = expression.getAST();
Set<String> result = new HashSet<String>();
printNode(ast, result);
return result;
}
/**
* 递归出表达式里的所有标签
* @param root
* @param labels
*/
private static void printNode(SpelNode root, Set<String> labels) {
if (root != null && root.getChildCount() == 2) {
printNode(root.getChild(0), labels);
printNode(root.getChild(1), labels);
}
if (root != null) {
if (root instanceof VariableReference) {
labels.add(root.toStringAST().substring(1));
}
}
}
}
其他
表结构sql
-- 1. 实验组表
CREATE TABLE `experiment_group` (
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '实验组主键ID',
`group_name` VARCHAR(128) NOT NULL COMMENT '实验组名称',
`group_desc` VARCHAR(512) DEFAULT '' COMMENT '实验组描述',
`status` TINYINT NOT NULL DEFAULT 1 COMMENT '状态:1-启用 0-禁用',
`creator` VARCHAR(64) NOT NULL COMMENT '创建人',
`create_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
`updater` VARCHAR(64) NOT NULL COMMENT '更新人',
`update_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
PRIMARY KEY (`id`),
UNIQUE KEY `uk_group_name` (`group_name`) -- 实验组名称唯一
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='实验组表';
-- 2. 实验表
CREATE TABLE `experiment` (
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '实验主键ID',
`experiment_group_id` BIGINT UNSIGNED NOT NULL COMMENT '关联实验组ID',
`experiment_name` VARCHAR(128) NOT NULL COMMENT '实验名称',
`experiment_desc` VARCHAR(512) DEFAULT '' COMMENT '实验描述',
`rule_group_id` BIGINT UNSIGNED NOT NULL COMMENT '关联规则组ID',
`status` TINYINT NOT NULL DEFAULT 1 COMMENT '状态:1-运行中 2-已暂停 3-已结束 0-已删除',
`start_time` DATETIME NOT NULL COMMENT '实验开始时间',
`end_time` DATETIME DEFAULT NULL COMMENT '实验结束时间',
`creator` VARCHAR(64) NOT NULL COMMENT '创建人',
`create_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
`updater` VARCHAR(64) NOT NULL COMMENT '更新人',
`update_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
PRIMARY KEY (`id`),
UNIQUE KEY `uk_experiment_name` (`experiment_name`), -- 实验名称唯一
KEY `idx_experiment_group_id` (`experiment_group_id`),
KEY `idx_rule_group_id` (`rule_group_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='实验表';
-- 3. 实验扩展信息表(存储实验的非核心/动态扩展字段)
CREATE TABLE `experiment_ext_info` (
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '扩展信息主键ID',
`experiment_id` BIGINT UNSIGNED NOT NULL COMMENT '关联实验ID',
`ext_key` VARCHAR(64) NOT NULL COMMENT '扩展字段键',
`ext_value` VARCHAR(1024) NOT NULL COMMENT '扩展字段值',
`ext_desc` VARCHAR(256) DEFAULT '' 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 `uk_experiment_key` (`experiment_id`, `ext_key`), -- 同一实验的扩展键唯一
KEY `idx_experiment_id` (`experiment_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='实验扩展信息表';
-- 4. 规则组表
CREATE TABLE `rule_group` (
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '规则组主键ID',
`group_name` VARCHAR(128) NOT NULL COMMENT '规则组名称',
`group_desc` VARCHAR(512) DEFAULT '' COMMENT '规则组描述',
`status` TINYINT NOT NULL DEFAULT 1 COMMENT '状态:1-启用 0-禁用',
`creator` VARCHAR(64) NOT NULL COMMENT '创建人',
`create_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
`updater` VARCHAR(64) NOT NULL COMMENT '更新人',
`update_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
PRIMARY KEY (`id`),
UNIQUE KEY `uk_rule_group_name` (`group_name`) -- 规则组名称唯一
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='规则组表';
-- 5. 规则表
CREATE TABLE `rule` (
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '规则主键ID',
`rule_group_id` BIGINT UNSIGNED NOT NULL COMMENT '关联规则组ID',
`rule_name` VARCHAR(128) NOT NULL COMMENT '规则名称',
`rule_content` TEXT NOT NULL COMMENT '规则内容(SPEL表达式或其他)',
`priority` INT NOT NULL DEFAULT 0 COMMENT '规则优先级(数值越大优先级越高)',
`status` TINYINT NOT NULL DEFAULT 1 COMMENT '状态:1-启用 0-禁用',
`creator` VARCHAR(64) NOT NULL COMMENT '创建人',
`create_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
`updater` VARCHAR(64) NOT NULL COMMENT '更新人',
`update_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
PRIMARY KEY (`id`),
UNIQUE KEY `uk_rule_group_name` (`rule_group_id`, `rule_name`), -- 同一规则组内规则名称唯一
KEY `idx_rule_group_id` (`rule_group_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='规则表';
-- 6. 服务和标签关系表(服务-标签多对多关联)
CREATE TABLE `service_tag_relation` (
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '关系主键ID',
`service_id` VARCHAR(64) NOT NULL COMMENT '服务ID(如微服务唯一标识)',
`service_name` VARCHAR(128) NOT NULL COMMENT '服务名称',
`tag_id` VARCHAR(64) NOT NULL COMMENT '标签ID',
`tag_name` VARCHAR(128) NOT NULL COMMENT '标签名称',
`tag_type` VARCHAR(64) DEFAULT '' 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 `uk_service_tag` (`service_id`, `tag_id`), -- 同一服务-标签组合唯一
KEY `idx_service_id` (`service_id`),
KEY `idx_tag_id` (`tag_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='服务和标签关系表';
总结
巧妙将Spring和 SPEL动态计算特性相结合,使得ABTest的配置更灵活。
当然SPEL表达式除了像ABTest计算boolean值,也一样可以计算返回Object,将其应用在服务里,如果有好的想法就可以更灵活。