前言
随着业务团队不断扩大,往往一个项目中很多重要的接口都是其他团队提供的。例如我们日常的需求中,有些接口依赖风控团队提供,但是风控是一个单独的团队,他们的工作安排和我们的团队是不一致的,他们有自己的工作进度。这样会导致的一个问题就是有时候我们自己这边已经完成,但是风控还没开发完。
虽然我们在单元测试阶段可以通过本地 Mock 来解决,但是提交到测试环境,测试小伙伴进行冒烟测试的时候,风控的接口就会成为阻碍。为了解决这个问题,我们需要打造一个 Mock 平台。
当协同部门的接口未提供时,我们可以让这次请求返回我们预先设置的响应体,而不用真正的请求到风控服务。也就是我们暂时假设这个接口已经可以返回预期的正确响应。
这样做的好处是我们可以在所依赖的第三方接口未提供时,先保证自己的业务逻辑是正确的,先保证自己团队的进度不受阻碍。
Github 源码
源码已分享到 Github li-mock-demo
为什么需要 Mock 平台
其实在上一段已经解释了,一方面是为了防止其他部门的阻碍,另一方面某些外部接口,例如第三方支付接口,需要调用支付通道。这种接口只要第一次对接成功,上线之后。后续我们在测试环境通常是Mock 结果的,节省和支付公司的人力沟通交互的时间,因为通常要支付成功的结果是需要让第三方公司帮忙处理的。
如何设计 Mock 平台
请求图示
当我们请求目标服务时,先判断这次请求的资源路径是否有 Mock 配置,如果有,那么不管对方的服务在不在线,我们都应该返回 Mock 配置中的响应。如果没有,再进行真实的目标服务调用。
条件表达式支持
我们的 Mock 配置需要支持一些条件,例如请求参数中某个参数的值等于特定设置的值,才走 Mock 返回。
例如 body.request.userId == 1,body.request.userName == '张三'。
想一想为什么需要支持条件表达式?可能存在的场景是同一个接口,被两个需求涉及到,这两个需求分别是两个测试同学负责。有了条件表达式的支持,两个同学就可以并行互不干扰的进行
Mock操作。针对
A同学的测试用户,返回A的Mock响应,针对B同学的测试用户,返回 B 的Mock响应
请求拦截
在请求到达目标服务之前就需要拦截,查询 Mock 配置,这样即使对方接口还没发布到环境上,甚至服务实例不存在,也不影响我们团队的测试进度,实现 Mock 与真实完全隔离。
技术选型
这里选用比较熟悉,也最简单的 OpenFeign 作为跨服务调用工具。
SpringCloud 2024.0.0,SpringBoot 3.4.0,JDK 22,MySQL 8.0.31
表设计
主配置表
CREATE TABLE `mock_config` (
`id` int NOT NULL AUTO_INCREMENT,
`initiator_service` varchar(100) DEFAULT NULL COMMENT '发起方服务id',
`target_service` varchar(100) NOT NULL COMMENT '目标服务id',
`uri` varchar(255) NOT NULL COMMENT '请求资源路径',
`creator` varchar(255) DEFAULT NULL COMMENT '创建人',
`crt_time` datetime DEFAULT NULL COMMENT '创建时间',
`upt_time` datetime DEFAULT NULL COMMENT '更新时间',
`deleted` tinyint NOT NULL DEFAULT '0' COMMENT '删除标志',
PRIMARY KEY (`id`) USING BTREE
) ENGINE=InnoDB COMMENT 'mock 主配置表';
主配置关联的配置项表
CREATE TABLE `mock_config_item` (
`id` int NOT NULL AUTO_INCREMENT COMMENT '主键',
`config_id` int NOT NULL COMMENT '配置 id',
`status` tinyint(1) NOT NULL DEFAULT '0' COMMENT '状态 1:启用 0:禁用',
`expression` text COMMENT 'mock 匹配表达式',
`mock_response` text COMMENT 'mock JSON 响应体',
`creator` varchar(255) DEFAULT NULL COMMENT '创建人',
`crt_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
`upt_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
`deleted` tinyint NOT NULL DEFAULT '0' COMMENT '删除标志',
PRIMARY KEY (`id`)
) ENGINE=InnoDB COMMENT 'mock 配置项表';
mock_config 表确定唯一的请求资源,
发起方服务(initiator_service) + 目标服务(target_service) + 资源(uri)
mock_config_item 表配置多个不同的 Mock 规则,利用表达式控制,同时表达式可以有多个条件,因此 expression 是一个可包含 && 、 || 的复杂表达式,使用 SPEL 解析
OpenFeign 拦截器
我们在 OpenFeign 系列的文章里面介绍过,OpenFeign 在请求调用目标前会执行拦截器,我们可以实现 feign.RequestInterceptor 定义自己的拦截器
/**
* Feign 调用的时候请求拦截器
*/
@Slf4j
public class FeignRequestInterceptor implements RequestInterceptor {
@Override
public void apply(RequestTemplate template) {
//获取请求 uri
//获取目标服务名
//查询mock是否命中
}
}
在拦截器中,我们获取到请求的 uri,目标服务,发起方服务(就是当前服务),这三个就可以确定唯一的请求资源,然后查询 Mock 配置,如果命中,在这里我们给请求头 header 中添加上标记
template.header(MockKeyConst.MATCH_MOCK_CONFIG_ITEM_ID, item.getId().toString());
然后在负载均衡调用器中,用这个 header 来判断要继续调用目标服务还是返回命中的 Mock 响应体。
SPEL 表达式解析 Mock 条件
上面提到我们期望配置的 Mock 可以设定一定的条件,如果请求参数满足一定的条件,我们才让这次请求走 Mock 响应 ,否则就进行真实的调用。那么我们可以将请求参数全部封装到一个 Map 中 key 是 body,然后使用 SPEL 表达式对其解析。
我们将 FeignClient 的方法 method(arg0,arg1,arg2)的所有参数抽象成一个 HashMap ,key = body。例如下面几个接口
@GetMapping("/rcs/apply/query")
Object rcsApplyQuery(@RequestParam("applyNo") String applyNo,@RequestParam("memberId") String memberId);
@PostMapping("/rcs/apply")
Object rcsApply(@RequestBody RcsApplyRequest request);
@GetMapping("/rcs/apply/query-map")
Object rcsApplyQueryMap(@SpringQueryMap RcsApplyRequest request);
@GetMapping(value = {"/rcs/apply/{applyNo}/{testParam}"})
Object rcsApplyQueryPathVariable(@PathVariable String applyNo,@PathVariable String testParam);
RcsApplyRequest 类
@Data
public class RcsApplyRequest {
private String applyNo;
private String gateId;
}
- 对于第一个和第四个接口我们可以配置的
Mock条件表达式为"body.applyNo == 'x'"或者"body.memberId == 'x'" - 对于第二和第三个接口我们可以配置的
Mock条件表达式为body.request.applyNo == 'x'或者body.request.gateId == 'C017'
这里使用
body.param1.param11的方式只是一个预先定义好的mock表达式的规则,我认为这个规则普遍会比较容易理解,你也可以定义自己熟悉的规则
在使用 SPEL 表达式 解析的时候,由于我们将请求参数全都抽象成了 HashMap ,SPEL 表达式 解析 HashMap 的语法是 #body['key1']['key11']['key111'],所以我们需要将上面表达式 . 的语法转换成 [] 语法,提供一个方法
/**
* 将 body.arg0.x.x == 'x' 转换成 #body['arg0']['x']['x'] == 'x'
* <p>
* "body.request.applyNo == 'applyNo' && body.request.gateId == 'C017'"
* 变成
* "#body['request']['applyNo'] == 'applyNo' && #body['request']['gateId'] == 'C017'"
*/
public static String transferToSpel(String originExpression) {
if (originExpression.contains("&&") && originExpression.contains("||")) {
throw new RuntimeException("暂不支持复杂表达式,仅支持 全部 && 或者全部 ||");
}
boolean containsAnd = originExpression.contains("&&");
return transferByOperator(originExpression, containsAnd ? "&&" : "\|\|");
}
private static String transferByOperator(String originExpression, String operator) {
List<String> singleExpression = new ArrayList<>();
for (String item : originExpression.split(operator)) {
String[] first = item.split("==");
if (first.length > 2) {
throw new RuntimeException("表达式错误");
}
String[] leftArr = first[0].trim().split("\.");
StringBuilder leftExpr = new StringBuilder();
for (int i = 0; i < leftArr.length; i++) {
if (i == 0) {
leftExpr.append("#").append(leftArr[i]);
} else {
leftExpr.append("['").append(leftArr[i]).append("']");
}
}
String result = leftExpr + " == " + first[1];
singleExpression.add(result);
}
return singleExpression.stream().collect(Collectors.joining(" " + (operator.contains("&&") ? "&&" : "||") + " "));
}
然后再用 SPEL 解析,示例代码
// 用 EL 表达式解析条件
ExpressionParser parser = new SpelExpressionParser();
StandardEvaluationContext context = new StandardEvaluationContext();
context.setVariable("body", response.getRequestBody());//请求参数
Boolean match = parser.parseExpression(transferToSpel(expression)).getValue(context, Boolean.class);
自定义 FeignBlockingLoadBalancerClient
命中 Mock 之后,由于在拦截器中无法让请求直接返回结果,所以在上面的拦截器中命中 Mock 之后我们先给请求头 header 中添加一个标记,值就是命中的 Mock 配置项的主键 id
template.header(MockKeyConst.MATCH_MOCK_CONFIG_ITEM_ID, item.getId().toString());
然后拦截器执行完毕之后,OpenFeign 的调用会走到负载均衡调用器 FeignBlockingLoadBalancerClient。观察它的 execute() 方法源码,当然是没有提供有帮助的操作,所以我们只能继承这个类,定义自己的负载均衡调用器,重写它的 execute() 方法。
实现逻辑很简单,我们只需要在真实调用前,也就是 super.execute() 代码前加一段逻辑,如果请求头的 header 中有命中的 mock 标记,我们就返回 Mock 配置的响应体,否则就放过,直接调用 super.execute()。
public class CustomFeignBlockingLoadBalancerClient extends FeignBlockingLoadBalancerClient {
private final MockConfigService mockConfigService;
public CustomFeignBlockingLoadBalancerClient(Client delegate, LoadBalancerClient loadBalancerClient, LoadBalancerClientFactory loadBalancerClientFactory,
List<LoadBalancerFeignRequestTransformer> transformers, MockConfigService mockConfigService) {
super(delegate, loadBalancerClient, loadBalancerClientFactory, transformers);
this.mockConfigService = mockConfigService;
}
/**
* 自定义请求处理
* */
@Override
public Response execute(Request request, Request.Options options) throws IOException {
Map<String, Collection<String>> requestHeaders = request.headers();
if(requestHeaders.containsKey(MockKeyConst.MATCH_MOCK_CONFIG_ITEM_ID)){
//表示需要 mock
Collection<String> headerValueList = requestHeaders.get(MockKeyConst.MATCH_MOCK_CONFIG_ITEM_ID);
String mockItemId = headerValueList.stream().findFirst().orElseThrow();
MockConfigItem configItem = mockConfigService.getMockConfigItem(Integer.parseInt(mockItemId));
//返回 mock 的响应
Map<String, Collection<String>> headers = new HashMap<>();
headers.put("connection",List.of("keep-alive"));
headers.put("content-type",List.of("application/json"));
headers.put("keep-alive",List.of("timeout=60"));
headers.put("transfer-encoding",List.of("chunked"));
byte[] bytes = configItem.getMockResponse().getBytes();
return Response.builder().status(HttpStatus.OK.value()).request(request)
.protocolVersion(Request.ProtocolVersion.HTTP_1_1)
.headers(headers)
.body(new ByteArrayInputStream(bytes),bytes.length)
.build();
}
//本次请求没有命中 mock ,直接真实调用
return super.execute(request, options);
}
}
请求策略处理
前面我们将请求参数全部抽象成 body.param1.param11.param111 的方式。但是 FeignClient 的接口形式比较多,有的是路径变量 @Pathvariable,有的是 查询请求对象@SpringQueryMap,有的是请求体 @RequestBody。
不同类型的接口我们需要用不同的方式处理,来抽象请求参数,所以这里可以使用策略模式,提供几种处理策略,也方便扩展。
AllPathVariableResolveStrategy处理@PathVariable的请求AllRequestParamResolveStrategy处理@RequestParam的请求SpringQueryMapResolveStrategy处理@SpringQueryMap的请求RequestBodyResolveStrategy处理@RequestBody的请求
我们也可以根据实际业务场景再扩展其他组合类型的请求,这里不赘述。
把 Mock 平台化
上述已经实现了 Mock 功能,只是暂时是每个服务调用自己的数据库,由于我们需要将 Mock 平台化,应用于所有服务,乃至整个公司的团队。所以我们需要单独抽出一个微服务,这个微服务专门负责 Mock 相关。当然也可以不单独整一个微服务,让每个微服务扩展数据源,只要保证所有的 Mock 配置在一个数据库中即可,这里不再赘述。
结语
以前在小公司工作的时候确实没有这个感受,当团队越来越大,公司规模很大的时候。其他团队的进度我们是不能预估的,也无法干涉,所以当涉及到可能的阻碍时,通过 Mock 接口是一个很不错的选择。
当然 Mock 平台的实现方式很多,这里只是随便选择了一种简单的方式。我们应注重的是用 Mock 解决问题的思路。