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

2022-03-02  本文已影响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 的能力相吻合了吗? 反正我就想到这上了.

代码实现

@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);
    }

}
@Slf4j
@Getter
@Service
@Experimentable
public class ExperimentService {

    @Value("${value:experimentableTest}")
    private String name;

}

划重点~下面开始讲实验组件的编码实现了

/**
 * 功能标记注解:可实验
 */
@Target({ElementType.TYPE})
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface Experimentable {
}
/**
 * 实验配置示例
 */
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();
    }
}
/**
 * 实验室接口
 */
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;
}
@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);
    }

}
@Getter
@Setter
@ToString
public class FunctionExperimentableResult {

    /**
     * 可实验性
     */
    private boolean experimentable = false;

    /***
     * 实验属性名称
     */
    private String propertyName = null;

    /***
     * 方法返回结果类型
     */
    private Class<?> propertyTypeClass = null;

}

**主要代码已经讲完了, 讲讲其他一些小工具类 **

/**
 * 将字符串转值对象的工具类(仅支持转基本类型)
 * 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);
    }
}
上一篇下一篇

猜你喜欢

热点阅读