一次动态接口替换经历
接口改造
背景
现有旧订单接口 orderDetail
,该接口会返回全量节点,部分节点不会使用因此造成了冗余,给数据库造成了较大压力,因此改造新接口 basicOrderDetail(xxx)
支持传入需要赋值的节点,方便赋值。
有如下改造方案:
- 现有多个其他部分接口调用当前
orderDetail
接口获取订单详情,并且再调用订单详情的getter()
方法,统计所有调用订单接口的方法,以及这些方法内部调用订单详情getter()
方法; - 将旧接口替换为新接口,并传入需要赋值的节点。
上游服务对该接口的 qps 访问量约为:
难点
难点在于调用 getter()
方法的地方太多,无法手动一个个统计,工作量巨大。
解决方案
新接口如何设计?
// 调用旧接口伪代码
Response resp = orderDetail(request.orderId(), "");
// Response 中返回了全量节点值
// 新接口设计
// 1.各服务自定义封装 Request,并设置需要赋值的节点信息
class NewRequest {
int orderId;
List<ItemFlag> flags; // ItemFlag 为枚举类,对应 Response 中各字段信息
}
// 2.服务调用时构造 Request,然后发起请求,这样依赖就同意了调用格式,各服务以统一形式调用新接口,同时可以设置需要赋值的节点
NewResponse resp = basicOrderDetail(new NewRequest(1, Arrays.asList(ItemFlag.xxx, ItemFlag.xxx)));
其他服务中获取字段信息 getter()
方法如何识别替换?
这是一类对方法增强的功能,所以很自然联想到动态代理,SpringBoot 中可以通过 注解+AOP切面实现。
方案一:注解+AOP
- 在不修改原代码逻辑的基础上,新增自定义注解 @ItemRecord;
- 将注解标注在获取
OrderDetail
结果的方法上; - 定义对应切面,以
@Pointcut("@annotation(xxx.xxx.xxx.ItemRecord)")
为切点,并定义切点的AfterReturning
通知,这样在运行时就能获取到方法返回的结果中哪些字段非空,但是无法确定各接口实际需要的节点值。
方案二:编译时注解+Processor
- 定义两个编译时注解,一个用于获取 OrderDetail 类型变量,并统计获取代码中
getter()
方法; - 再定义一个编译时注解,用于替换 OrderDetail 的获取方法,替换为 BasicOrderDetail 获取方法。
但是这种方式解析语法树过于麻烦,实行起来复杂。
方案三:Cglib 动态代理
初步思路:
- 对原始接口返回的对象 OrderDetail 创建动态代理对象,每当调用该对象的
getter()
方法时,触发增强后的getter()
方法,记录下对应的执行线路、当前方法调用所在的外层方法名、该服务方法需要getter()
的字段信息,记录上述信息存储到内存 HashMap 中; - 在第一步操作记录完毕后,针对调用老接口
orderDetail
的服务对象创建动态代理对象,之后再调用orderDetail
老接口时,方法内部会代替调用增强后的代理对象执行增强后的方法,方法内只针对部分目标属性赋值,避免了全量赋值。
复现 Demo:
重点步骤:
- 创建 App 对象的动态代理类,收集
getter()
方法所需的属性,并存储 Map。
-
创建非全量新接口,修改老接口逻辑,老接口执行时判断代理类是否为空,不为空调用代理方法,否则调用旧方法。新接口构造代理方法所需参数,调用代理方法。
-
代理方法会根据请求参数中枚举的信息,针对指定字段赋值。
结果
改造后接口如何验证数据正确率?
- 可以先对接口 Mock 编写简单的测试用例确保接口逻辑正确,对于一些边界情况也可以模拟到;
- 将下游服务打包为镜像发布到测试环境,找测试构造产品id 对接口调用,在日志平台抓包查看接口返回的结构体是否正确;
- 将服务发布到生产环境的镜像集群上,该集群不提供生产服务,而对接生产流量,将生产流量数据同时运行在镜像集群、生产集群,然后在 diff 平台对比响应报文结果。可以一次拉取1000个报文流量,重复多次保证数据正确性;
- 将服务发布到生产环境,通过灰度切流的方式逐步替换旧服务。
考虑到代码可读性、后续对代码的可维护性,最终没有采取动态代理方案,而是对旧接口的调用进行日志埋点,收集方法调用栈信息,确定当前接口具体使用了哪些字段。然后逐个对接口调用进行改造,确保后续代码可维护。