一、背景
在系统开发的过程中,有时会遇到调用第三方接口的情况。由于第三方接口的不确定性,可能出现,在上线一段时间后,第三方公司对调用的接口进行了升级,原先老的接口不再维护的情况。这时内部需要重新修改代码调用第三方新的接口。
在调用新接口的过程中存在如下问题:
1、新接口是否稳定,如何检验
2、新接口返回的数据结构跟老接口不一致如何处理
3、原始接口流量较大,直接切换到新接口出问题了怎么办
4、如何验证新老接口的差异性,即在请求参数相同的情况下,新老接口公共字段返回的值是否一致
使用abTest来灰度发布可以解决以上的四个问题。
二、abtest基本概念
A/B 测试是一种对比分析方法,通过对流量进行细分和随机实验,并监控和跟踪实验效果,来判断实验所代表的策略的可行性和有效性。 A/B 测试解决的是策略优化的问题,即从多个可选策略里找出最优策略。常见的应用场景包括:
- 灰度发布:技术&算法迭代
- 功能优化:界面模块、样式风格、交互方式等
- 内容优化:推广海报、落地页、内容模块、文案等
- 运营优化:运营策略、沟通话术等
三、最佳实践
3.1 业务场景
回到背景中的4个问题,假设第三方有api1接口,用于返回人员信息。之后第三方公司重新开发了api2接口用来实现api1的功能,在api1的基础上又返回了其他更多的信息,并且表示api1以后不再维护。
graph TD
内部系统 --> |原始调用|api1
内部系统 --> |新调用|api2
这时内部系统需要修改原始代码,将api1的部分全部换成api2。最简单的做法就是直接修改代码,如下所示。
Person person = api1();// 老接口
---------------------------------
PersonNew personNew = api2();
Person person = convert(personNew);
然后直接上线。但是这么做存在这么几个问题。
1、第三方提供的新接口可能不稳定,由于是第三方接口,内部没有办法保证接口一定没有问题。特别是如果调用这个第三方接口的流量
如果很大,直接全部切换成api2的话就可能直接造成线上事故。这种情况在互联网企业是无法接受的。
2、调用api2,内部没有办法知道新接口返回的结果是不是跟老接口一模一样,虽然第三方企业承诺完全兼容api1,但是从稳定性的角度
考虑,内部还是要对api2进行一下线上验证。
所以考虑到这两个问题,我们可以利用AbTest来进行灰度,已经流量回放。
3.2 解决思路
利用abtest,我们可以完美解决以上两个问题。abtest核心思想就是从大流量中随机抽出一部分流量进行试验。所以按照这个思路,我们可以从总的流量中抽取少量流量同时调用api1和api2,在调用的过程中监控api2的成功率,相应时间,95线,99线等若干指标,来验证api2的稳定性。同时由于参数相同,我们可以对比api1和api2的返回情况,记录两个接口的返回结果差异性。从而验证新接口是否完全兼容老接口。
在验证过程中如果发现api2接口不稳定或者结果与api1不相符,可以通知第三方公司对接口进行修复,在api2稳定性和返回结果都符合预期的情况下,可以逐渐加大api2的流量。最终对api2放量到100%,平滑完成对api1的切换工作。
3.3 代码模拟
在实验过程中,创建一个OuterApi类用来表示第三方的接口,里面有api1和api2两个方法。每个方法传入一个idCard,表示根据身份证号返回具体人员信息。在api1和api2中通过一个随机数来设置每次返回人员信息的年龄,从而模拟每次接口的不同返回。
在api2中除了返回api1中国所有的属性外,额外增加了人员的性别,用来表示新接口的增加的属性部分。
public class OuterApi {
private Random random = new Random();
public Person api1(String idCard) {
Person person = new Person();
person.setName("小张");
if (random.nextDouble() < 0.5) {
person.setAge("13");
} else {
person.setAge("15");
}
person.setIdCard(idCard);
return person;
}
public PersonNew api2(String idCard) {
PersonNew personNew = new PersonNew();
personNew.setName("小张");
if (random.nextDouble() < 0.5) {
personNew.setAge("13");
} else {
personNew.setAge("15");
}
personNew.setSex("1");
personNew.setIdCard(idCard);
return personNew;
}
}
创建了一个AbTestHelper类,用来实现灰度和流量回放功能。实现灰度的过程中,需要有个配置项,来决定灰度流量的百分比以及是否开启流量回放工能。配置项的配置如下所示,含义如注释所示。真实生产环境下,该配置一般放置在配置中心中,或redis等缓存中,在模拟实验时,直接放入一个名为abtest.properties文件中表示。
#流量回放开关
trafficPlaybackSwitch = true
#灰度控制比例 分号前表示中总份数,分号后面表示命中灰度的份数
grayConfig = 100;0-49
AbTestHelper类代码如下所示。
package abTest;
import java.io.InputStream;
import java.util.Comparator;
import java.util.Properties;
import java.util.function.BiConsumer;
import java.util.function.Function;
import java.util.function.Supplier;
public class AbTestHelper<T, R> {
// 新老接口的比较器
private Comparator<T> comparable;
// 新老接口有差异时的反馈函数
private BiConsumer<T, T> diffCallBack;
// 新老接口返回值转换函数
private Function<R, T> convert;
public AbTestHelper(Comparator<T> comparable, BiConsumer<T, T> diffCallBack, Function<R, T> convert) {
this.comparable = comparable;
this.diffCallBack = diffCallBack;
this.convert = convert;
}
/**
* abest比较
*
* @param aSupplier 原接口
* @param bSupplier 新接口
* @param grayKey 灰度的key
* @return
*/
public T doABTest(Supplier<T> aSupplier, Supplier<R> bSupplier, int grayKey) {
GrayConfig grayConfig = new GrayConfig(getConfig("grayConfig"));
T result = null;
R tmpResult = null;
boolean hitGrayKey = false;
if (grayConfig.hitGray(grayKey)) {
hitGrayKey = true;
tmpResult = bSupplier.get();
System.out.println("命中灰度");
} else {
result = aSupplier.get();
System.out.println("未命中灰度");
}
// 命中灰度key,将新接口结果转换回老接口
if (hitGrayKey) {
result = convert.apply(tmpResult);
}
// 是否开启流量回放
if (Boolean.parseBoolean(getConfig("trafficPlaybackSwitch"))) {
flowPlayBack(hitGrayKey, aSupplier, bSupplier, result);
}
return result;
}
private static String getConfig(String key) {
String value;
try {
Properties properties = new Properties();
InputStream in = AbTestHelper.class.getClassLoader().getResourceAsStream("abTest.properties");
properties.load(in);
value = properties.getProperty(key);
} catch (Exception e) {
value = null;
// ignore
}
return value;
}
/**
* 一般流量回放会放到一个线程池中异步执行,避免影响主流程响应
*
* @param hitGrayKey
* @param aSupplier
* @param bSupplier
* @param result
*/
private void flowPlayBack(boolean hitGrayKey, Supplier<T> aSupplier, Supplier<R> bSupplier, T result) {
if (hitGrayKey) {
T aResult = aSupplier.get();
if (comparable.compare(aResult, result) != 0) {
diffCallBack.accept(aResult, result);
}
} else {
R bResult = bSupplier.get();
T tmpResult = convert.apply(bResult);
if (comparable.compare(result, tmpResult) != 0) {
diffCallBack.accept(result, tmpResult);
}
}
}
}
每次调用第三方api时就会创建一个helper类。创建该类时,需要传入比较器、反馈函数、转换器等。在最终调用过程中需要传入新老接口以及灰度的key。在代码中灰度策略使用了grayKey % totalPercent来判断哪些id调用新接口。
最后是一个总的模拟入口,
package abTest;
import java.util.Random;
import org.apache.commons.lang3.StringUtils;
public class MainTest {
public static void main(String[] args) throws InterruptedException {
OuterApi api = new OuterApi();
AbTestHelper<Person, PersonNew> helper = new AbTestHelper<>((person, person1) -> {
if (person == null || person1 == null) {
return -1;
}
if (StringUtils.equals(person.getAge(), person1.getAge()) && StringUtils.equals(person.getIdCard(), person1.getIdCard()) &&
StringUtils.equals(person.getName(), person1.getName())) {
return 0;
}
return -1;
}, (aResult, bResult) -> {
// 当发现两者结果不相同时,一般是通过埋点的方式上报。这里简单的输出一下各自接口的值
System.out.println("新旧接口返回值不相同,原始接口获取的年龄:" + aResult.getAge() + ",新接口获取的年龄:" + bResult.getAge());
}, personNew -> {
Person person = new Person();
person.setIdCard(personNew.getIdCard());
person.setAge(personNew.getAge());
person.setName(personNew.getName());
return person;
});
Random random = new Random();
while (true) {
String idCard = random.nextInt(100) + "";
Person result = helper.doABTest(() -> api.api1(idCard), () -> api.api2(idCard), Integer.parseInt(idCard));
System.out.println(result.toString());
System.out.println("==========");
Thread.sleep(1000);
}
}
}
随机传入一个身份证号,并且根据身份证号返回指定对象。模拟结果如下所示。
完整代码参考:github.com/Tyoukai/tec…