消除协同部门的工作阻碍 —— Mock 平台设计

531 阅读9分钟

前言

随着业务团队不断扩大,往往一个项目中很多重要的接口都是其他团队提供的。例如我们日常的需求中,有些接口依赖风控团队提供,但是风控是一个单独的团队,他们的工作安排和我们的团队是不一致的,他们有自己的工作进度。这样会导致的一个问题就是有时候我们自己这边已经完成,但是风控还没开发完。

虽然我们在单元测试阶段可以通过本地 Mock 来解决,但是提交到测试环境,测试小伙伴进行冒烟测试的时候,风控的接口就会成为阻碍。为了解决这个问题,我们需要打造一个 Mock 平台。

当协同部门的接口未提供时,我们可以让这次请求返回我们预先设置的响应体,而不用真正的请求到风控服务。也就是我们暂时假设这个接口已经可以返回预期的正确响应。

这样做的好处是我们可以在所依赖的第三方接口未提供时,先保证自己的业务逻辑是正确的,先保证自己团队的进度不受阻碍。

Github 源码

源码已分享到 Github li-mock-demo

为什么需要 Mock 平台

其实在上一段已经解释了,一方面是为了防止其他部门的阻碍,另一方面某些外部接口,例如第三方支付接口,需要调用支付通道。这种接口只要第一次对接成功,上线之后。后续我们在测试环境通常是Mock 结果的,节省和支付公司的人力沟通交互的时间,因为通常要支付成功的结果是需要让第三方公司帮忙处理的。

如何设计 Mock 平台

请求图示

image.png

当我们请求目标服务时,先判断这次请求的资源路径是否有 Mock 配置,如果有,那么不管对方的服务在不在线,我们都应该返回 Mock 配置中的响应。如果没有,再进行真实的目标服务调用。

条件表达式支持

我们的 Mock 配置需要支持一些条件,例如请求参数中某个参数的值等于特定设置的值,才走 Mock 返回。 例如 body.request.userId == 1body.request.userName == '张三'

想一想为什么需要支持条件表达式?可能存在的场景是同一个接口,被两个需求涉及到,这两个需求分别是两个测试同学负责。有了条件表达式的支持,两个同学就可以并行互不干扰的进行 Mock 操作。

针对 A 同学的测试用户,返回 AMock 响应,针对 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 响应 ,否则就进行真实的调用。那么我们可以将请求参数全部封装到一个 Mapkey 是 body,然后使用 SPEL 表达式对其解析。

我们将 FeignClient 的方法 method(arg0,arg1,arg2)的所有参数抽象成一个 HashMapkey = 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 表达式 解析的时候,由于我们将请求参数全都抽象成了 HashMapSPEL 表达式 解析 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 解决问题的思路。

如果这篇文章对你有帮助,记得点赞加关注!你的支持就是我继续创作的动力!