基于 Spring AOP 實現(xiàn)的 通用實驗組件 AB實驗/AB測試

什么是AB實驗

AB Test 實驗一般有 2 個目的:

  1. 判斷哪個更好:例如,有 2 個 UI 設計帆疟,究竟是 A 更好一些鹉究,還是 B 更好一些,我們需要實驗判定
  2. 計算收益:例如踪宠,最近新上線了一個直播功能自赔,那么直播功能究竟給平臺帶了來多少額外的 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);
    }
}
最后編輯于
?著作權歸作者所有,轉載或內(nèi)容合作請聯(lián)系作者
  • 序言:七十年代末精刷,一起剝皮案震驚了整個濱河市拗胜,隨后出現(xiàn)的幾起案子,更是在濱河造成了極大的恐慌怒允,老刑警劉巖埂软,帶你破解...
    沈念sama閱讀 211,123評論 6 490
  • 序言:濱河連續(xù)發(fā)生了三起死亡事件,死亡現(xiàn)場離奇詭異纫事,居然都是意外死亡勘畔,警方通過查閱死者的電腦和手機,發(fā)現(xiàn)死者居然都...
    沈念sama閱讀 90,031評論 2 384
  • 文/潘曉璐 我一進店門丽惶,熙熙樓的掌柜王于貴愁眉苦臉地迎上來咖杂,“玉大人,你說我怎么就攤上這事蚊夫∷咦郑” “怎么了?”我有些...
    開封第一講書人閱讀 156,723評論 0 345
  • 文/不壞的土叔 我叫張陵知纷,是天一觀的道長壤圃。 經(jīng)常有香客問我,道長琅轧,這世上最難降的妖魔是什么伍绳? 我笑而不...
    開封第一講書人閱讀 56,357評論 1 283
  • 正文 為了忘掉前任,我火速辦了婚禮乍桂,結果婚禮上冲杀,老公的妹妹穿的比我還像新娘效床。我一直安慰自己,他們只是感情好权谁,可當我...
    茶點故事閱讀 65,412評論 5 384
  • 文/花漫 我一把揭開白布剩檀。 她就那樣靜靜地躺著,像睡著了一般旺芽。 火紅的嫁衣襯著肌膚如雪沪猴。 梳的紋絲不亂的頭發(fā)上,一...
    開封第一講書人閱讀 49,760評論 1 289
  • 那天采章,我揣著相機與錄音运嗜,去河邊找鬼。 笑死悯舟,一個胖子當著我的面吹牛担租,可吹牛的內(nèi)容都是我干的。 我是一名探鬼主播抵怎,決...
    沈念sama閱讀 38,904評論 3 405
  • 文/蒼蘭香墨 我猛地睜開眼奋救,長吁一口氣:“原來是場噩夢啊……” “哼!你這毒婦竟也來了便贵?” 一聲冷哼從身側響起,我...
    開封第一講書人閱讀 37,672評論 0 266
  • 序言:老撾萬榮一對情侶失蹤冗荸,失蹤者是張志新(化名)和其女友劉穎承璃,沒想到半個月后,有當?shù)厝嗽跇淞掷锇l(fā)現(xiàn)了一具尸體蚌本,經(jīng)...
    沈念sama閱讀 44,118評論 1 303
  • 正文 獨居荒郊野嶺守林人離奇死亡盔粹,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內(nèi)容為張勛視角 年9月15日...
    茶點故事閱讀 36,456評論 2 325
  • 正文 我和宋清朗相戀三年,在試婚紗的時候發(fā)現(xiàn)自己被綠了程癌。 大學時的朋友給我發(fā)了我未婚夫和他白月光在一起吃飯的照片舷嗡。...
    茶點故事閱讀 38,599評論 1 340
  • 序言:一個原本活蹦亂跳的男人離奇死亡,死狀恐怖嵌莉,靈堂內(nèi)的尸體忽然破棺而出进萄,到底是詐尸還是另有隱情,我是刑警寧澤锐峭,帶...
    沈念sama閱讀 34,264評論 4 328
  • 正文 年R本政府宣布中鼠,位于F島的核電站,受9級特大地震影響沿癞,放射性物質(zhì)發(fā)生泄漏援雇。R本人自食惡果不足惜,卻給世界環(huán)境...
    茶點故事閱讀 39,857評論 3 312
  • 文/蒙蒙 一椎扬、第九天 我趴在偏房一處隱蔽的房頂上張望惫搏。 院中可真熱鬧具温,春花似錦、人聲如沸筐赔。這莊子的主人今日做“春日...
    開封第一講書人閱讀 30,731評論 0 21
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽川陆。三九已至剂习,卻和暖如春,著一層夾襖步出監(jiān)牢的瞬間较沪,已是汗流浹背鳞绕。 一陣腳步聲響...
    開封第一講書人閱讀 31,956評論 1 264
  • 我被黑心中介騙來泰國打工, 沒想到剛下飛機就差點兒被人妖公主榨干…… 1. 我叫王不留尸曼,地道東北人们何。 一個月前我還...
    沈念sama閱讀 46,286評論 2 360
  • 正文 我出身青樓,卻偏偏與公主長得像控轿,于是被迫代替她去往敵國和親冤竹。 傳聞我的和親對象是個殘疾皇子,可洞房花燭夜當晚...
    茶點故事閱讀 43,465評論 2 348

推薦閱讀更多精彩內(nèi)容