美文网首页
基于 SpringBoot AOP实现的 通用实验组件 AB实验

基于 SpringBoot AOP实现的 通用实验组件 AB实验

作者: 灰气球 | 来源:发表于2022-03-02 23:09 被阅读0次

    什么是AB实验

    AB Test 实验一般有 2 个目的:

    1. 判断哪个更好:例如,有 2 个 UI 设计,究竟是 A 更好一些,还是 B 更好一些,我们需要实验判定
    2. 计算收益:例如,最近新上线了一个直播功能,那么直播功能究竟给平台带了来多少额外的 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);
        }
    }
    

    相关文章

      网友评论

          本文标题:基于 SpringBoot AOP实现的 通用实验组件 AB实验

          本文链接:https://www.haomeiwen.com/subject/gjxyrrtx.html