前言
随着业务团队不断扩大,往往一个项目中很多重要的接口都是其他团队提供的。例如我们日常的需求中,有些接口依赖风控团队提供,但是风控是一个单独的团队,他们的工作安排和我们的团队是不一致的,他们有自己的工作进度。这样会导致的一个问题就是有时候我们自己这边已经完成,但是风控还没开发完。
虽然我们在单元测试阶段可以通过本地 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
解决问题的思路。