研发效能 【测试平台开发】云筑网 “天眼” 质量平台系列(四)-- 如何基于 swagger 解决接口文档维护的痛

中建电商-质量部 · August 30, 2021 · Last by 中建电商-质量部 replied at August 31, 2021 · 3348 hits

前言

  对于后端的接口文档,作为测试的各位看官应该都不陌生,而且大家平时没少被以下两种情况困扰吧:
  1.后端开发突然说,这次修改了某个功能的几个接口,前端同学对应修改下,然后测试同学测一测就上线。此时前端和测试同学想必都是一脸懵逼,后端开发这是又改了哪几个接口啊?都改了啥呀?直接去接口文档上看,啥也看不出来,还是只有去一个个的找后端开发确认,效率低下不说还很容易遗漏。
  2.大家在做接口自动化的时候,需要不断的去接口文档中一个个的复制 api 和对应请求参数,还得不断切换到接口文档对应页面查看参数定义,重复工作量极大,效率极低。
  针对以上两个长期困扰大家的痛点,于是催生了 swagger 与天眼平台的结合。下面部分具体介绍了天眼平台是如何解决这两个痛点的,请各位看官收藏后再仔细阅读。如有不足或建议,请积极留言。ღ( ´・ᴗ・· )比心。

一、基于 swagger 的接口文档版本 diff

  对于大部分开发都在使用的 swagger 各位看官应该不陌生吧,简单来说就是一个生成接口文档的第三方类库。开发在代码中引入 swagger 后即可自动生成可视化 RESTful 风格的 Web 服务用于描述和调用接口。
  效果的话,就是下面这样。但是和大部分接口文档一样,它缺少版本管理和版本 diff 功能,后端开发改了某个接口,在接口文档上体现不出来。


   1.将 swagger 接口文档,导入天眼平台数据库
  实现接口文档 diff 的话第一步是解析 swagger 文档将其落库,这是我们天眼平台的接口文档表设计,需要将 swagger 文档解析成对应字段落库。我们支持两种导入方式 json 文件导入和 URL 导入。


  这里强调下网关前缀,因为大部分公司都有网关层,会自动给业务端代码加上类似/api 这种前缀。所以当我们直接用导入文档中的 api 去做自动化时会请求不通,所以增加了一个填写网关前缀的功能,落库时能将接口文档的 api 和网关前缀进行拼接。
  URL 导入是指用户直接输入 swagger 文档的 URL,我们通过字符串截取获取 ip,然后请求 ip+/swagger-resources 得到下面的结果,然后再基于这个返回结果中的 url 拼接 ip,直接访问即可获取到 json 格式的 swagger 文档的文本内容了。

获取到 json 文本内容后,那么我们先看看 swagger json 文件的格式(本次解析针对 swagger3 以上,2 的话结构类似)

解析 JSON 用的是 jackson。主要写一些遇到较多坑的地方吧,每个项目开发人员使用 swagger 的习惯不太一样,这样的话会导致可能你写的解析方法不能覆盖所有的场景,这个也只能根据具体遇到的情况去不断完善了。核心主要解析请求内容,主要是 parameters 和 requestBody 两种,下面的话附上该部分的主要代码~

private static ObjectMapper objectMapper = new ObjectMapper();
public static <T> T readValue(String jsonStr, Class<T> clazz) throws IOException {
    return objectMapper.readValue(jsonStr, clazz);
}


Map<String, Object> map = JsonUtils.readValue(jsonStr, HashMap.class);
@Data
public class RequestVo implements Serializable {
    /**
     * 参数名
     */
    private String name;

    /**
     * 请求类型path/query/body
     */
    private String type;

    /**
     * 参数类型
     */
    private String paramType;

    /**
     * 是否必填
     */
    private Boolean required;

    /**
     * 说明
     */
    private String description;
    /**
     * style
     */
    private String style;
    /**
     * 
     */
    private List<RequestVo> properties;
}
//处理请求参数为parameters
private List<RequestVo> processRequestList(List<LinkedHashMap> parameters, Map<String, ModelAttrVo> componentsMap) {
    List<RequestVo> requestList = new ArrayList<>();
    if (!CollectionUtils.isEmpty(parameters)) {
        for (Map<String, Object> param : parameters) {
            RequestVo request = new RequestVo();
            request.setName(String.valueOf(param.get("name")));
            request.setType(String.valueOf(param.get("in")));
            request.setRequired((Boolean) param.get("required"));
            request.setStyle(String.valueOf(param.get("style")));
            try {
                Map<String, Object> schema = (Map) param.get("schema");
                String paramType = String.valueOf(schema.get("type"));
                if (!StringUtils.isEmpty(schema.get("format"))) {
                    paramType = paramType + "(" + String.valueOf(schema.get("format")) + ")";
                }
                if (!StringUtils.isEmpty(schema.get("$ref"))) {
                    String modelName = String.valueOf(schema.get("$ref"));
                    ModelAttrVo modelAttrVo = componentsMap.get(modelName);
                    paramType = modelAttrVo.getType();
                    request.setDescription(modelAttrVo.getDescription());

                }
 if(!StringUtils.isEmpty(schema.get("enum"))){
                    String enumStr = String.valueOf(schema.get("enum"));
                    if (!StringUtils.isEmpty(param.get("description"))){
                        enumStr= String.valueOf(param.get("description"))+"\t"+enumStr;
                    }

                    request.setDescription(enumStr);
                }else{
                    request.setDescription(String.valueOf(param.get("description")));
                }
                request.setParamType(paramType);
            }catch (Exception e){
            }
            requestList.add(request);
        }
    }
    return requestList;
}


//处理请求为application/json
private List<RequestVo> processRequestList(Map<String, ModelAttrVo> componentsMap, String ref) {
    List<RequestVo> requestList = new ArrayList<>();
    ModelAttrVo modelAttr = componentsMap.get(ref);
    //获取properties大小,
    int pSize = 0;
    if (!StringUtils.isEmpty(modelAttr.getProperties())) {
        pSize = modelAttr.getProperties().size();
    }
    for (int i = 0; i < pSize; i++) {
        requestList = processModelProperties(modelAttr.getProperties(), componentsMap);
    }

    return requestList;
}

private List<RequestVo> processModelProperties(List<ModelAttrVo> properties, Map<String, ModelAttrVo> componentsMap) {
    List<RequestVo> requestList = new ArrayList<>();
    if (!CollectionUtils.isEmpty(properties)) {
        for (ModelAttrVo param : properties) {
            RequestVo request = new RequestVo();
            request.setName(param.getParmName());
            request.setDescription(param.getDescription());
            if (!StringUtils.isEmpty(param.getProperties())) {
                request.setType("body");
                request.setParamType("");
                request.setProperties(processModelProperties(param.getProperties(), componentsMap));
            } else if (StringUtils.isEmpty(param.getProperties())) {
                String name = param.getParmName().substring(0, 1).toUpperCase() + param.getParmName().substring(1);
                if (!StringUtils.isEmpty(componentsMap.get("#/components/schemas/" + name))) {
                    ModelAttrVo modelAttrVo = componentsMap.get("#/components/schemas/" + name);
                    request.setRequired(modelAttrVo.getRequired());
                    request.setParamType(modelAttrVo.getType());
                    request.setDescription(modelAttrVo.getDescription());
                } else {
                    request.setRequired(param.getRequired());
                    request.setParamType(param.getParmType());
              }
            }
            requestList.add(request);
        }
    }
    return requestList;
}

  json 文件导入是指支持用户直接上传 swagger 文档的离线文档文件,解析方式的话和 url 导入大同小异。

  2.接口文档版本控制
  第二步是再次导入 swagger 文档的时候判断接口是否有变化。我们是怎么判断接口怎么变化的了,这里主要使用的zjsonpatch,我们会遍历导入接口文档的每一个接口,和数据库中对应信息进行比对,当存在改变时,我们会将当前数据库中的数据写入到一张历史接口文档信息表中,然后最新接口文档落库并打上接口存在变更的标记。当接口文档没有更新时跳过。

//getDiffJSonArray
private JSONArray getDiffJSonArray(String json1, String json2) {
    ObjectMapper mapper = new ObjectMapper();
    EnumSet<DiffFlags> flags = DiffFlags.dontNormalizeOpIntoMoveAndCopy().clone();
    JsonNode patch = null;
    try {
        patch = JsonDiff.asJson(mapper.readTree(json1), mapper.readTree(json2), flags);
    } catch (JsonProcessingException e) {
        e.printStackTrace();
    }
    return JSONArray.parseArray(patch.toString());
}

  页面效果:

  3.版本 diff
  第三步也就是接口新老版本的 diff 了,使用上面提到的 zjsonpatch 的方法,能返回 [{"op":"remove","path":"/a","value":0},{"op":"add","path":"/b/2","value":0}] 这样的结果,我们可以知道新旧接口的接口文档中有哪些参数发生了哪些改变。获取到这个之后可以使用 JsonPatch.applyInPlace(JsonNode patch, JsonNode source) 将操作绑定到对应接口文档的参数中。这样前端进行渲染的时候就可以直观的标记新老版本的具体修改点。这里的话需要倒序处理对比的结果,不然会导致前端展示的时候新旧接口参数顺序对不上。其中需要注意的是,op 为 add 时,新相对于旧 value 需设置为 add,旧相对于新的 value 设置为 delete(为了前端渲染时,新老版本能行行对应)。下面的话附上代码及最终呈现的状态。

//获取DiffArrayVo 新相对于旧
private List<DiffArrayVO> processDiffArray(JSONArray jsonArray) {
    List<DiffArrayVO> processList = new LinkedList<>();
    Map<String, DiffArrayVO> tempMap = new HashMap<>();
    for (int i = 0; i < jsonArray.size(); i++) {
        DiffArrayVO tempVo = JSON.parseObject(jsonArray.getString(i), DiffArrayVO.class);
        DiffArrayVO diffArrayVo = new DiffArrayVO();

        switch (tempVo.getOp()) {
            case "add":
                String path = getPathString(tempVo.getPath());
                diffArrayVo.setPath(path);
                diffArrayVo.setOp("add");
                diffArrayVo.setValue("add");
                tempMap.put(path, diffArrayVo);
                break;
            case "remove":
                String path3 = tempVo.getPath();
                diffArrayVo.setOp("add");
                diffArrayVo.setPath(path3);
                diffArrayVo.setValue("XXXXDELETEXXXX");
                processList.add(diffArrayVo);
                break;
            case "replace":
                String path2 = getPathString(tempVo.getPath());
                if (tempVo.getPath().endsWith("/name")) {
                    diffArrayVo.setPath(path2);
                    diffArrayVo.setValue("add");
                    diffArrayVo.setOp("replace");
                    tempMap.put(path2, diffArrayVo);
                } else {
                    diffArrayVo.setPath(path2);
                    diffArrayVo.setValue("update");
                    diffArrayVo.setOp("replace");
                    if (!tempMap.containsKey(path2)) {
                        tempMap.put(path2, diffArrayVo);
                    }
                }

                break;
        }

    }
    for (Map.Entry<String, DiffArrayVo> entry : tempMap.entrySet()) {
        processList.add(entry.getValue());
    }
    return processList;
}

//获取替换后的string
private String proceessDiffStr(List<DiffArrayVO> arraySet, String string) {
    for (int i = arraySet.size() - 1; i >= 0; i--) {
        if (!StringUtils.isEmpty(arraySet.get(i).getOp())) {
            String op = "[" + JSONObject.toJSONString(arraySet.get(i)) + "]";
            string = parmetersSetOp(op, string);
        }
    }
    return string;
}
//原parameters新增op
private String parmetersSetOp(String op, String parmeters) {
    ObjectMapper mapper = new ObjectMapper();
    JsonNode a = null;
    JsonNode b = null;
    try {
        a = mapper.readTree(op);
        b = mapper.readTree(parmeters);
        JsonPatch.applyInPlace(a, b);
    } catch (JsonProcessingException e) {
        e.printStackTrace();
    }
    return b.toString();
}

二、接口文档和接口自动化的打通

  当开发的接口文档维护到了天眼平台上,这时和接口自动化的打通就是一件比较 easy 的事情了。
  在导入的 swagger 接口文档管理列表,我们提供一个自动化的按钮,点击这个按钮时,我们会根据当前接口文档的所属项目和接口 api,判断这个接口是否已经存在于我们的自动化接口列表中,不存在的话,会带上接口的相关信息直接跳转到新建自动化接口页面,已存在的话直接跳转到对应接口的编辑页面。

  当跳转到新建自动化接口详情页面时,前端会根据跳转过来的接口文档的 id,调用对应接口获取该接口的接口文档相关信息,并渲染到页面上。最后的效果如下;
  此时在编写自动化接口和用例时,右边即是对应的接口文档,各种接口基本信息默认填充,不再像以前需要不断的去接口文档各种复制粘贴,也摆脱了一个接口几十上百个参数需要手动拼接请求体的噩梦。此时是不是感觉这个功能对于编写接口自动化来说是 yyds。

共收到 7 条回复 时间 点赞

你们这个是否有 yapi 的 mock 功能呢?

这期展示还没做,下期这个功能会加上。

现在你们这个文档管理实际起到的作用大嘛?

作用挺大的,除了方便我们测试,也挺受前端开发欢迎的,他们之前也被没有版本 diff 这个功能困扰了挺久的。

思路挺好的,但是把文档维护之后,但是 swagger 本身提供的一些 mock 功能又怎么办呢?前端开发要在两个系统来回切换使用?
还是说后续有其他思考?

请教下,第二步再次导入 swagger 文档的时候判断接口是否有变化,这个是怎么去判断项目 swagger 接口有没有更新呢,是每次导入都会遍历一遍接口比对还是其他方式呢

说实话,这块我们还未实现😂
但是,后续规划已经有了,这里可以交流一下我们的计划步调。
1.针对天眼平台自动化测试的时候,有部分依赖第三方服务,且该外部服务不能稳定提供服务,我们支持对自己的自动化接口进行 mock,能为自动化带来助力
2.如果需要为开发提供 mock 功能,这就涉及到基础架构层面的东西,这块和技术总监有讨论过,应该会在不久的将来和大家见面

兔子🐰 [Topic was deleted] 中提及了此贴 18 Feb 16:23
需要 Sign In 后方可回复, 如果你还没有账号请点击这里 Sign Up