什么是AB實驗
AB Test 實驗一般有 2 個目的:
- 判斷哪個更好:例如,有 2 個 UI 設計帆疟,究竟是 A 更好一些鹉究,還是 B 更好一些,我們需要實驗判定
- 計算收益:例如踪宠,最近新上線了一個直播功能自赔,那么直播功能究竟給平臺帶了來多少額外的 DAU,多少額外的使用時長柳琢,多少直播以外的視頻觀看時長等
以上例子取自文章 : 什么是 A/B 測試匿级?: https://www.zhihu.com/question/20045543
實際上, 一個產(chǎn)品需求, 可能會有多種落地策略(重點:不一就2種,可能有3456種), 選取小部分流量, 通過AB實驗實現(xiàn)分流, 最終根據(jù)實驗結構選擇最終的落地方案.
為什么要做AB實驗
If you are not running experiments蟋滴,you are probably not growing!——by Sean Ellis
Sean Ellis 是增長黑客模型(AARRR)之父痘绎,增長黑客模型中提到的一個重要思想就是“AB實驗”津函。
從某種意義上講,自然界早就給了我們足夠多的啟示孤页。為了適應多變的環(huán)境尔苦,生物群體每天都在發(fā)生基因的變異,最終物競天擇行施,適者生存允坚,留下了最好的基因。這個精巧絕倫的生物算法恐怕是造物者布置的最成功的AB實驗吧蛾号。
AB實驗的必要性可以查看下面文章鏈接, 這里不再贅述.
本文首發(fā)|微信公眾號 友盟數(shù)據(jù)服務 (ID:umengcom)稠项,轉載請注明出處
BAT 都在用的方法,詳解 A/B 測試的那些坑鲜结!:https://leeguoren.blog.csdn.net/article/details/103994848
基于后端的AB實驗實現(xiàn)方案
舉一個場景, 假設有如下產(chǎn)品需求 : 對于商品信息展示頁面, 對于商品名稱的展示上有兩個方案, 但是不知道哪個方案好, 所以需要做個測試一下;
方案一 : 在商品名稱改成 “Success” ; 方案二 : 在商品名稱改成 “Fail” ;
需求就是這么個需求, 接下來看看怎么實現(xiàn)吧! 如有雷同, 純屬巧合~
項目代碼倉庫
下面的代碼實現(xiàn)放在這里哈, 項目可以直接運行.
Github eden2f/springboot-web-demo
Gitee eden2f/springboot-web-demo
效果顯示
后端接口定義
- 服務端口 : 8080
- 測試接口 :
接口協(xié)議 : Http , 方法 : GET , URL : /experiment/experimentableTest
返回數(shù)據(jù)結構 :
{
"code": 200,
"msg": "ok",
"data": "Success",
"traceId": "a8002fa2-3fdf-450d-8c9e-e4ff4bed078c"
}
效果展示
執(zhí)行 Curl 調(diào)用接口 :
curl -X GET "http://localhost:8080/experiment/experimentableTest" -H "accept: */*"
結果 : 50% 的機率返回 "data": "Success"; 50% 的機率返回 "data": "Fail";
實現(xiàn)思路
本文重點講解如何在不更新業(yè)務代碼的情況下, 實現(xiàn)服務端邏輯分流? 至于實驗投放算法的實現(xiàn)展运、投放人群選取……等等這些本文不涉及. 而且是個大課題, 本文也講不完
對于后端服務, 一般有分布式配置中心(例如: Apollo、Nacos), 配置中心一般使用 Key : Value 方式幫我們托管著服務必要的配置信息;
以 Spring 項目為例, 在后端代碼中實現(xiàn)獲取分布式配置中心上的配置信息, 也是非常簡單的, 如使用@Value, 下面是一個獲取配置的使用示例 :
@Value("${value:experimentableTest}")
private String name;
如果能夠在調(diào)用屬性的 Getter 方法時候根據(jù)不同場景獲取不同的實驗值, 再提供一個業(yè)務場景與實驗值的配置管理, 那么就可以無縫支持上面的AB實驗? 本組件也是圍繞這思路來實現(xiàn)的.
為什么是 Spring AOP ?
為了實現(xiàn)AB實驗能力接入對業(yè)務開發(fā)無感, 另一方面當前已經(jīng)存在很多正在運行的代碼, 如何不改動當前的業(yè)務實現(xiàn)又能使其擁有AB實驗的能力? 到這里, 我想到了面向切面編程, 實現(xiàn)上就選取了 Spring 的 AOP.
組件編碼實現(xiàn)
組件使用示例
- 開放 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 業(yè)務處理, 提供商品名稱查詢能力, getName() 方法返回從配置中心拿到的名稱; 默認配置是 experimentableTest, 我們希望 getName() 根據(jù)場景返回 Success 和 Fail.
@Slf4j
@Getter
@Service
@Experimentable
public class ExperimentService {
@Value("${value:experimentableTest}")
private String name;
}
組件實現(xiàn)編碼
劃重點~下面開始講實驗組件的編碼實現(xiàn)了
- 自定義一個功能標記注解:可實驗 @Experimentable, 加在需要增強AB實驗能力的Class上, 如下面的 ExperimentService.
/**
* 功能標記注解:可實驗
*/
@Target({ElementType.TYPE})
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface Experimentable {
}
- 業(yè)務場景與實驗值的映射管理, 為了讀者能快速理解, 本例一切從簡, ExperimentSettingDemo.EXPERIMENT_SETTINGMAP 進行映射管理, Key是屬性名, Value 是一組實驗值. 回到需求, 對于 name 這個字段, 有 "Fail", "Success" 兩種展示方案, 在本例中配置如下 :
/**
* 實驗配置示例
*/
public class ExperimentSettingDemo {
/**
* 實驗參數(shù)配置
*/
public static final Map<String, List<String>> EXPERIMENT_SETTINGMAP;
/**
* 實驗參數(shù)配置
*/
public static final Set<String> EXPERIMENT_PROPERTY_NAME;
static {
EXPERIMENT_SETTINGMAP = new HashMap<>();
EXPERIMENT_SETTINGMAP.put("name", Lists.newArrayList("Fail", "Success"));
EXPERIMENT_SETTINGMAP.put("wallet", Lists.newArrayList("100", "200"));
EXPERIMENT_SETTINGMAP.put("age", Lists.newArrayList("1", "2"));
// 這個配置應該是無效的因為沒有 @Value
EXPERIMENT_SETTINGMAP.put("birthday", Lists.newArrayList("1000", "2000"));
EXPERIMENT_PROPERTY_NAME = EXPERIMENT_SETTINGMAP.keySet();
}
}
- ExperimentAspect 是AB實驗能力增強切面, 實現(xiàn)了對 Experimentable 的對象的屬性 Getter 方法的增強; 同時, 作為實驗室的實現(xiàn), 實現(xiàn)了 “判斷某個屬性是不是是否在實驗中” 和 “根據(jù)實驗屬性和特定場景查詢實驗值” 的能力;
- 判斷某個屬性是不是是否在實驗中 : 讀取 ExperimentSettingDemo 進行判斷
- 根據(jù)實驗屬性和特定場景查詢實驗值 : 多個實驗值隨機選擇
@Slf4j
public class ExperimentInterceptor implements MethodInterceptor {
private static final ExperimentParamMetedata nonExperimentableMetedata = new ExperimentParamMetedata();
public Object invoke(MethodInvocation methodInvocation) throws Throwable {
return ExperimentInterceptor.experimentInvoke(methodInvocation.proceed(), methodInvocation.getThis(), methodInvocation.getMethod().getName());
}
public static Object experimentInvoke(Object originalValue, Object target, String targetMethodName) {
ExperimentParamMetedata experimentParamMetedata;
try {
experimentParamMetedata = ExperimentInterceptor.inExperiment(target, targetMethodName, ExperimentSettingDemo.EXPERIMENT_SETTINGMAP);
} catch (RuntimeException exception) {
log.error("ExperimentAspect-未知異常", exception);
return originalValue;
}
if (experimentParamMetedata.isExperimentable()) {
try {
return Laboratory.queryExperimentValue(experimentParamMetedata.getPropertyName(), experimentParamMetedata.getPropertyTypeClass());
} catch (NoSuchMethodException | InvocationTargetException | InstantiationException | IllegalAccessException e) {
log.error("ExperimentAspect-結果解析異常", e);
throw new RuntimeException("ExperimentAspect-結果解析異常", e);
}
}
return originalValue;
}
/**
* 判斷當前目標方法是不是需要進行實驗
*
* @param experimentSettingMap 實驗配置
* @return 方法可實驗性校驗結果
*/
private static ExperimentParamMetedata inExperiment(Object target, String targetMethodName, Map<String, List<String>> experimentSettingMap) {
Class<?> targetClass = AopUtils.isAopProxy(target) ? AopUtils.getTargetClass(target) : target.getClass();
// 是否在實驗中
NonExperimentable nonExperimentable = AnnotationUtils.findAnnotation(targetClass, NonExperimentable.class);
if (null != nonExperimentable) {
return nonExperimentableMetedata;
}
// 實驗配置是否有數(shù)據(jù)
if (null == experimentSettingMap || experimentSettingMap.isEmpty()) {
return nonExperimentableMetedata;
}
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();
Field propertyField = ReflectionUtils.findField(targetClass, propertyName);
if (propertyField != null) {
Value valueAnnotation = propertyField.getDeclaredAnnotation(Value.class);
if (null != valueAnnotation && ExperimentSettingDemo.EXPERIMENT_PROPERTY_NAME.contains(propertyName)) {
ExperimentParamMetedata experimentParamMetedata = new ExperimentParamMetedata();
experimentParamMetedata.setExperimentable(true);
experimentParamMetedata.setPropertyTypeClass(propertyDescriptor.getPropertyType());
experimentParamMetedata.setPropertyName(propertyName);
return experimentParamMetedata;
}
}
}
return nonExperimentableMetedata;
}
/**
* 查詢屬性對應的實驗值
*
* @param experimentPropertyName 實驗屬性名稱
* @param propertyTypeClass 屬性類型
* @return 實驗值
*/
public static 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);
}
}
- 實驗參數(shù)元數(shù)據(jù)
/**
* 實驗參數(shù)元數(shù)據(jù)
*/
@Getter
@Setter
@ToString
public class ExperimentParamMetedata {
/**
* 可實驗性
*/
private boolean experimentable = false;
/***
* 實驗屬性名稱
*/
private String propertyName = null;
/***
* 方法返回結果類型
*/
private Class<?> propertyTypeClass = null;
}
- 功能標記注解:不必實驗的
/**
* 功能標記注解:不必實驗的
*/
@Target({ElementType.TYPE, ElementType.FIELD})
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface NonExperimentable {
}
- 將字符串轉值對象的工具類(僅支持轉基本類型)
/**
* 將字符串轉值對象的工具類(僅支持轉基本類型)
*/
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);
}
}