基于 SpringBoot AOP实现的 通用实验组件 AB实验
什么是AB实验
AB Test 实验一般有 2 个目的:
- 判断哪个更好:例如,有 2 个 UI 设计,究竟是 A 更好一些,还是 B 更好一些,我们需要实验判定
- 计算收益:例如,最近新上线了一个直播功能,那么直播功能究竟给平台带了来多少额外的 DAU,多少额外的使用时长,多少直播以外的视频观看时长等
以上例子取自文章 : 什么是 A/B 测试?: https://www.zhihu.com/question/20045543
实际上, 一个产品需求, 可能会有多种落地策略(重点:不一定就是2种), 选取小部分流量, 通过AB实验实现分流, 最终根据实验结构选择最终的落地方案.
为什么要做AB实验
If you are not running experiments,you are probably not growing!——by Sean Ellis
Sean Ellis 是增长黑客模型(AARRR)之父,增长黑客模型中提到的一个重要思想就是“AB实验”。
从某种意义上讲,自然界早就给了我们足够多的启示。为了适应多变的环境,生物群体每天都在发生基因的变异,最终物竞天择,适者生存,留下了最好的基因。这个精巧绝伦的生物算法恐怕是造物者布置的最成功的AB实验吧。
详情可以查看下面文章链接, 这里不再赘述.
本文首发|微信公众号 友盟数据服务 (ID:umengcom),转载请注明出处
BAT 都在用的方法,详解 A/B 测试的那些坑!:https://leeguoren.blog.csdn.net/article/details/103994848
基于后端的AB实验实现方案
举一个场景, 假设有如下产品需求 : 对于商品信息展示页面, 对于商品名称的展示上有两个方案, 但是不知道哪个方案好, 所以需要做个测试一下;
方案一 : 在商品名称改成 “Success” ; 方案二 : 在商品名称改成 “Fail” ;
需求就是这么个需求, 接下来看看怎么实现吧! 如有雷同, 纯属巧合~
效果显示
后端接口定义
服务端口 : 8080
测试接口 :
接口协议 : Http , 方法 : GET , URL : /experiment/experimentableTest
返回数据结构 :
{
"code": 200,
"msg": "ok",
"data": "Success",
"traceId": "a8002fa2-3fdf-450d-8c9e-e4ff4bed078c"
}
效果展示
执行 Curl 调用接口 :
curl -X GET "http://localhost:8080/experiment/experimentableTest" -H "accept: */*"
结果 : 50% 的机率返回 "data": "Success"; 50% 的机率返回 "data": "Fail";
实现思路
我们这里主要讲讲, 如何业务实现逻辑分流? 至于实验实现算法、投放人群分离……等等这些本文不涉及, 也讲不完
对于 Java 服务, 一般会有统一服务配置管理系统(例如: Apollo、Nacos), 配置管理系统利用 Key : Value 方式帮我们管理着配置信息;
在 Java 服务实现上, 获取从这些开源的配置管理系统上获取配置信息, 也是非常简单的, 如使用@Value, 下面是一个使用的例子 :
@Value("${value:experimentableTest}")
private String name;
如果在 getName 的时候, 能够根据不同场景获取不同的Value, 不就可以支持上面的AB实验了吗? 下文就是围绕这一点来实现的.
**为什么是 Spring AOP ? **
因为希望实验能力对业务开发无感知、不改动当前以后的代码又希望原有代码能用上这样的能力, 这不跟 AOP 的能力相吻合了吗? 反正我就想到这上了.
代码实现
- 开放 HTTP 接口
@Slf4j
@RestController
@RequestMapping("experiment")
public class ExperimentController {
@Resource
private ExperimentService experimentService;
@GetMapping("experimentableTest")
public RetResult<String> experimentableTest() {
String name = experimentService.getName();
return RetResult.success(name);
}
}
- Service 业务处理, 提供商品名称查询能力, getName() 方法返回从配置中心拿到的名称; 默认配置是 experimentableTest, 我们希望 getName() 根据场景返回 Success 和 Fail.
@Slf4j
@Getter
@Service
@Experimentable
public class ExperimentService {
@Value("${value:experimentableTest}")
private String name;
}
划重点~下面开始讲实验组件的编码实现了
- 自定义一个功能标记注解:可实验 @Experimentable, 在需要织入实验能力的Class上加上, 如 ExperimentService
/**
* 功能标记注解:可实验
*/
@Target({ElementType.TYPE})
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface Experimentable {
}
- 配置AB实验方案, 因为本文是一个示例, 怎么简单怎么来, 通过订单常量方式实现, 具体如下 : 对于 name 这个字段, 有 "Fail", "Success" 两种展示方案;
/**
* 实验配置示例
*/
public class ExperimentSettingDemo {
/**
* 实验参数配置
*/
public static final Map<String, List<String>> EXPERIMENT_SETTINGMAP;
/**
* 实验参数配置
*/
public static final Set<String> EXPERIMENT_PROPERTY_NAME;
static {
EXPERIMENT_SETTINGMAP = new HashMap<>();
EXPERIMENT_SETTINGMAP.put("name", Lists.newArrayList("Fail", "Success"));
EXPERIMENT_PROPERTY_NAME = EXPERIMENT_SETTINGMAP.keySet();
}
}
- 定义实验室接口, 一个实验室, 应该有下面两个功能 : 判断当前配置是不是AB实验配置; 根据配置查询实验配置值;
/**
* 实验室接口
*/
public interface Laboratory {
/**
* 判断当前目标方法是不是需要进行实验
*
* @param proceedingJoinPoint 切点
* @param experimentSettingMap 实验配置
* @return 方法可实验性校验结果
*/
FunctionExperimentableResult inExperiment(ProceedingJoinPoint proceedingJoinPoint, Map<String, List<String>> experimentSettingMap) throws NoSuchFieldException;
/**
* 查询属性对应的实验值
*
* @param experimentPropertyName 实验属性名称
* @param propertyTypeClass 属性类型
* @return 实验值
*/
Object queryExperimentValue(String experimentPropertyName, Class<?> propertyTypeClass) throws InvocationTargetException, NoSuchMethodException, InstantiationException, IllegalAccessException;
}
- Spring AOP 切面, 实现了对 ExperimentService 的 getName() 方法的增强; 同时, 作为实验室 Laboratory 的实现, 实现了 “判断当前配置是不是AB实验配置” 和 “根据配置查询实验配置值” 的能力
- 判断当前配置是不是AB实验配置 : 根据配置文件 ExperimentSettingDemo 来判断
- 根据配置查询实验配置值 : 本例子的方案是多个实验值随机选择
@Slf4j
@Aspect
@Component
public class ExperimentAspect implements Laboratory {
/**
* 定义切点
*/
@Pointcut("@within(com.eden.springbootwebdemo.web.experiment.Experimentable)")
public void pointCut() {
}
/**
* @param proceedingJoinPoint 被织入的目标
* @return 方法执行结果
*/
@Around("pointCut()")
public Object doAround(ProceedingJoinPoint proceedingJoinPoint) {
Object object;
try {
object = proceedingJoinPoint.proceed();
} catch (Throwable throwable) {
throw new RuntimeException(throwable);
}
FunctionExperimentableResult functionExperimentableResult;
try {
functionExperimentableResult = inExperiment(proceedingJoinPoint, ExperimentSettingDemo.EXPERIMENT_SETTINGMAP);
} catch (RuntimeException | NoSuchFieldException exception) {
log.error("ExperimentAspect-未知异常", exception);
return object;
}
if (functionExperimentableResult.isExperimentable()) {
try {
return queryExperimentValue(functionExperimentableResult.getPropertyName(), functionExperimentableResult.getPropertyTypeClass());
} catch (NoSuchMethodException | InvocationTargetException | InstantiationException | IllegalAccessException e) {
log.error("ExperimentAspect-结果解析异常", e);
throw new RuntimeException("ExperimentAspect-结果解析异常", e);
}
}
return object;
}
/**
* 判断当前目标方法是不是需要进行实验
*
* @param proceedingJoinPoint 切点
* @param experimentSettingMap 实验配置
* @return 方法可实验性校验结果
*/
@Override
public FunctionExperimentableResult inExperiment(ProceedingJoinPoint proceedingJoinPoint, Map<String, List<String>> experimentSettingMap) throws NoSuchFieldException {
FunctionExperimentableResult functionExperimentableResult = new FunctionExperimentableResult();
// 实验配置是否有数据
if (null == experimentSettingMap || experimentSettingMap.isEmpty()) {
return functionExperimentableResult;
}
// 是否在实验中
Object target = proceedingJoinPoint.getTarget();
String targetMethodName = proceedingJoinPoint.getSignature().getName();
BeanInfo targetBeanInfo;
try {
targetBeanInfo = Introspector.getBeanInfo(target.getClass());
} catch (IntrospectionException e) {
throw new RuntimeException(e);
}
Optional<PropertyDescriptor> propertyDescriptorOptional = Arrays.stream(targetBeanInfo.getPropertyDescriptors())
.filter(item -> item.getReadMethod().getName().equals(targetMethodName)).findFirst();
if (propertyDescriptorOptional.isPresent()) {
PropertyDescriptor propertyDescriptor = propertyDescriptorOptional.get();
String propertyName = propertyDescriptor.getName();
Value valueAnnotation = proceedingJoinPoint.getTarget().getClass().getDeclaredField(propertyName).getDeclaredAnnotation(Value.class);
if (null != valueAnnotation && ExperimentSettingDemo.EXPERIMENT_PROPERTY_NAME.contains(propertyName)) {
functionExperimentableResult.setExperimentable(true);
functionExperimentableResult.setPropertyTypeClass(propertyDescriptor.getPropertyType());
functionExperimentableResult.setPropertyName(propertyName);
}
}
return functionExperimentableResult;
}
/**
* 查询属性对应的实验值
*
* @param experimentPropertyName 实验属性名称
* @param propertyTypeClass 属性类型
* @return 实验值
*/
@Override
public Object queryExperimentValue(String experimentPropertyName, Class<?> propertyTypeClass) throws InvocationTargetException, NoSuchMethodException, InstantiationException, IllegalAccessException {
List<String> experimentReturnStringValues = ExperimentSettingDemo.EXPERIMENT_SETTINGMAP.get(experimentPropertyName);
// 几个配置随机选一个返回
int index = RandomUtils.nextInt(0, experimentReturnStringValues.size());
return StringCastUtil.cast(experimentReturnStringValues.get(index), propertyTypeClass);
}
}
- 判断当前目标方法是不是需要进行实验结果包装类, 不是很重要 FunctionExperimentableResult
@Getter
@Setter
@ToString
public class FunctionExperimentableResult {
/**
* 可实验性
*/
private boolean experimentable = false;
/***
* 实验属性名称
*/
private String propertyName = null;
/***
* 方法返回结果类型
*/
private Class<?> propertyTypeClass = null;
}
**主要代码已经讲完了, 讲讲其他一些小工具类 **
- 将字符串转值对象的工具类(仅支持转基本类型), todo 不知是否有其他工具可选, 有的话还望通知我下.
/**
* 将字符串转值对象的工具类(仅支持转基本类型)
* todo 不知是否有其他工具可选
*/
public class StringCastUtil {
private static final Map<Class<?>, Class<?>> BASIC_TYPE_CLASS_MAP;
private static final Set<Class<?>> basicTypeClassSet;
static {
BASIC_TYPE_CLASS_MAP = new HashMap<>(32);
BASIC_TYPE_CLASS_MAP.put(byte.class, Byte.class);
BASIC_TYPE_CLASS_MAP.put(short.class, Short.class);
BASIC_TYPE_CLASS_MAP.put(int.class, Integer.class);
BASIC_TYPE_CLASS_MAP.put(long.class, Long.class);
BASIC_TYPE_CLASS_MAP.put(float.class, Float.class);
BASIC_TYPE_CLASS_MAP.put(double.class, Double.class);
BASIC_TYPE_CLASS_MAP.put(boolean.class, Boolean.class);
BASIC_TYPE_CLASS_MAP.put(char.class, Character.class);
BASIC_TYPE_CLASS_MAP.put(Byte.class, Byte.class);
BASIC_TYPE_CLASS_MAP.put(Short.class, Short.class);
BASIC_TYPE_CLASS_MAP.put(Integer.class, Integer.class);
BASIC_TYPE_CLASS_MAP.put(Long.class, Long.class);
BASIC_TYPE_CLASS_MAP.put(Float.class, Float.class);
BASIC_TYPE_CLASS_MAP.put(Double.class, Double.class);
BASIC_TYPE_CLASS_MAP.put(Boolean.class, Boolean.class);
BASIC_TYPE_CLASS_MAP.put(Character.class, Character.class);
basicTypeClassSet = BASIC_TYPE_CLASS_MAP.keySet();
}
/**
* 将字符串转成值对象
*
* @param valueString 值字符串
* @param valueTypeClass 值类型
* @return 值对象
*/
public static Object cast(String valueString, Class<?> valueTypeClass) throws NoSuchMethodException, InvocationTargetException, InstantiationException, IllegalAccessException {
if (String.class.equals(valueTypeClass)) {
return valueString;
}
if (basicTypeClassSet.contains(valueTypeClass)) {
return BASIC_TYPE_CLASS_MAP.get(valueTypeClass).getConstructor(String.class).newInstance(valueString);
}
throw new RuntimeException("不支持的属性类型, valueTypeClass = {}" + valueTypeClass);
}
}