自制Http接口调用插件

政采云技术团队.png

三一.png

开篇

今天给大家分享一个故事。

小一是个沉迷 82 年 Coffee 的后端开发,一天不喝躁得慌,日常工作总写些 HTTP 接口、DUBBO 接口。出于职业基本素养,写完的接口小一是要自己调用一下,做个主流程的调通的。

小一调接口的习惯是使用 Postman 工具,偶尔也会直接拼下 curl。一年 365 天,小一每天写一个接口,命名为 helloworld1,hello2world,。。。,365helloworld。日积月累,Postman 列表里堆满了小一拼装的各种接口 Request。

直到有一天,小一实在是受够了拼新的 Request、打开代码复制接口关键词检索 Request,每天都做些重复手艺活,太不优雅了。他暗自下定决心,这次一定要做点什么!

三分钟,小一想出了第一个方案。

我能不能写个脚本,解析一下代码,自动拼出 Request 呐?那我就解决了第一个令人困扰的问题。可第二个问题是在 Postman 工具里,应该怎么办呢。

又过了三分钟,小一想出了第二个方案。

我其实对代码是最熟悉的,依赖 IDE 的全局检索功能,可以很快的找到想要调用的接口的具体代码,这个操作其实对应了 Postman 里检索接口的操作,如果我能在 IDE 里面直接根据接口的方法定义,接着进行下一步,去执行调用这个操作?岂不美哉!IDEA 五花八门的插件,高低得有一个解决类似诉求的。

至此,本篇已经完结,只是小一没找到趁手的插件。

  1. HTTP 接口调用的插件,小一遇到了统一登录拦截的问题,需要自己解决接口调用如何保持登录态。
  2. DUBBO 接口小一遇到了公司的泛化调用平台需要封装 API 的问题,需要自己集成指定的泛化调用平台。

所以小一最终决定自己写一个。

DUBBO 插件由于封装了内部的泛化调用平台,不便公布,下述内容将针对 HTTP 的插件进行讲解。

设计

要进行一个 HTTP 接口调用,关键元素如下:

image-20221026162820331.png

除了从代码里能得到的信息之外,其余的信息都由调用者来提供,即通过配置文件等形式做提前约定。

这里以 SpringMVC 框架为例。

  1. URL 的后半部分,可以在 Controller 实现类的代码描述中得到。

    1. 类上通过 RequestMapping 注解,约定了部分接口路径。
    2. 类的方法上可以通过 RequestMapping、GetMapping、PostMapping、PutMapping、DeleteMapping 等注解,约定了部分接口路径。
  2. HTTP 请求的 Method,同上,RequestMapping 注解的 method 属性,或者 GetMapping、PostMapping、PutMapping、DeleteMapping 等注解直接指明。

  3. 请求参数的数据结构,方法定义里面提及了参数类型,参数名称;可以解析类代码构造出数据结构。

  4. 登录鉴权一般依赖请求头中的特殊 Cookie,不同域名需要的 Cookie 不同,且存在 Cookie 过期失效的问题。如果 Cookie 过期,先是手动登录,再抓包提取 Cookie,又费劲又不优雅。

    1. 调用者得知道,某个域名要什么 Cookie;
    2. 调用者配置给 IDEA;
    3. 浏览器知道 Cookie。
  5. 有些特殊场景下,需要添加一下特殊的请求头,比如解决跨域问题。

编码

跟着官方的文档:plugins.jetbrains.com/docs/intell…

并结合一些开源项目,小一小小的熬了下夜。

URL 解析

设计板块中有提到,URL 后半部分信息在 Controller 实现类的注解中可以找到。

在 IDEA Plugin Platform SDK 中,有一个概念是 PSI,可通过 PSI 部分解决 URL 解析的问题。

PSI(Program Structure Interface)

官方文档:plugins.jetbrains.com/docs/intell…

The Program Structure Interface, commonly referred to as just PSI, is the layer in the IntelliJ Platform responsible for parsing files and creating the syntactic and semantic code model that powers so many of the platform's features.

程序结构接口通常称为 PSI,是负责解析文件并创建句法和语义代码模型的 Intellij 平台中的层,该模型为许多平台的功能提供动力。

在 Java 中,注解 Annotation,在 IDEA Plugin Platform SDK 中有个 PsiAnnotation 接口类。

需要用到以下方法:

  /**
   * Returns the fully qualified name of the annotation class.
   *
   * @return the class name, or null if the annotation is unresolved.
   */
  @Override
  @Nullable
  @NonNls
  String getQualifiedName();
​
  /**
   * Returns the value of the annotation element with the specified name.
   *
   * @param attributeName name of the annotation element for which the value is requested. If it isn't defined in annotation,
   *                      the default value is returned.
   * @return the element value, or null if the annotation does not contain a value for
   *         the element and the element has no default value.
   */
  @Nullable
  PsiAnnotationMemberValue findAttributeValue(@Nullable @NonNls String attributeName);

最终提取注解中 URL Path 的部分代码如下:

private String pickMappingPath(PsiAnnotation[] psiAnnotations) {
    for (PsiAnnotation psiAnnotation : psiAnnotations) {
        SpringRestMappingAnnotationEnum annotationEnum = SpringRestMappingAnnotationEnum.getByAnnotation(psiAnnotation.getQualifiedName());
        if (Objects.isNull(annotationEnum)) {
            continue;
        }
        String text = psiAnnotation.findAttributeValue("value").getText();
        if (text.startsWith(""")) {
            text = text.substring(1);
        }
        if (text.endsWith(""")) {
            text = text.substring(0, text.length() - 1);
        }
        if (text.equals("{}")) {
            return "";
        }
        return text;
    }
    return "";
}

HTTP Method 解析

此处解析 Method 是为了填充默认的请求 Method,实际使用过程中,请求 Method 应该由调用者指定,因此主要针对 GetMapping、PostMapping、PutMapping、DeleteMapping。

//    DEFAULT("org.springframework.web.bind.annotation.RequestMapping"),
//    GET("org.springframework.web.bind.annotation.GetMapping"),
//    POST("org.springframework.web.bind.annotation.PostMapping"),
//    PUT("org.springframework.web.bind.annotation.PutMapping"),
//    DELETE("org.springframework.web.bind.annotation.DeleteMapping"),
​
private String pickMethodMappingType(PsiMethod psiMethod) {
    for (PsiAnnotation psiAnnotation : psiMethod.getAnnotations()) {
        SpringRestMappingAnnotationEnum annotationEnum = SpringRestMappingAnnotationEnum.getByAnnotation(psiAnnotation.getQualifiedName());
        if (Objects.isNull(annotationEnum) || SpringRestMappingAnnotationEnum.DEFAULT.equals(annotationEnum)) {
            continue;
        }
        return annotationEnum.name();
    }
    return SpringRestMappingAnnotationEnum.GET.name();
}

参数结构解析

参数是定义在方法上的,方法 Method,在 IDEA Plugin Platform SDK 中有个 PsiMethod 接口类。

​
  /**
   * Returns the parameter list for the method.
   *
   * @return the parameter list instance.
   */
  @Override
  @NotNull
  PsiParameterList getParameterList();

继而延伸找到 PsiParameter 接口。

​
  /**
   * Returns the name of the element.
   *
   * @return the element name.
   */
  @Nullable
  String getName();
​
  /* This explicit declaration is required to force javac generate bridge method 'JvmType getType()'; without it calling
  JvmParameter#getType() method on instances which weren't recompiled against the new API will cause AbstractMethodError. */
  @NotNull
  @Override
  PsiType getType();

由此,根据方法定义,构造参数的代码出来了:

private List<ParameterDesc> buildParameterDescs(PsiMethod psiMethod) {
  PsiParameter[] psiParameters = psiMethod.getParameterList().getParameters();
  List<ParameterDesc> parameterDescs = Lists.newArrayList();
  for (PsiParameter psiParameter : psiParameters) {
    ParameterDesc parameterDesc = ParameterDesc.builder()
      .name(psiParameter.getName())
      .type(psiParameter.getType().getClass())
      .defaultValue(PsiTypeUtil.getDefaultValue(psiParameter.getType()))
      .build();
    parameterDescs.add(parameterDesc);
  }
  return parameterDescs;
}

由于构造参数时,交互上采用了 Json 格式来展现,故需对不同的基本类型设定默认值。如整型默认 0,字符串默认”“,等等。若是自定义的类作为参数,还需要将该类继续解析结构。

public static Object getDefaultValue(PsiType psiType) {
  if (isPrimitiveType(psiType)) {
    return PsiTypesUtil.getDefaultValue(psiType);
  } else if (isNormalType(psiType)) {
    return NORMAL_TYPES.get(psiType.getPresentableText());
  } else if (isArrayType(psiType)) {
    PsiType deepType = psiType.getDeepComponentType();
    Object defaultValue = getDefaultValue(deepType);
    ArrayList<Object> list = Lists.newArrayList(defaultValue);
    return list;
  } else if (isCollectionType(psiType)) {
    PsiType iterableType = PsiUtil.extractIterableTypeParameter(psiType, false);
    Object defaultValue = getDefaultValue(iterableType);
    ArrayList<Object> list = Lists.newArrayList(defaultValue);
    return list;
  } else if (isMapType(psiType)) {
    return Maps.newHashMap();
  } else if (isEnumType(psiType)) {
    StringBuilder sb = new StringBuilder();
    PsiField[] fieldList = PsiUtil.resolveClassInClassTypeOnly(psiType).getFields();
    if (fieldList != null && fieldList.length > 0) {
      for (PsiField f : fieldList) {
        if (f instanceof PsiEnumConstant) {
          sb.append(f.getName()).append("|");
        }
      }
      sb.deleteCharAt(sb.length() - 1);
    }
    return sb.toString();
  } else {
    PsiClass psiClass = PsiUtil.resolveClassInType(psiType);
    if (CUSTOM_TYPES.containsKey(psiClass.getQualifiedName())) {
      return CUSTOM_TYPES.get(psiClass.getQualifiedName());
    }
    CUSTOM_TYPES.put(psiClass.getQualifiedName(), null);
    JSONObject jsonObject = new JSONObject();
    for (PsiField field : psiClass.getAllFields()) {
      PsiType fieldType = field.getType();
      String name = field.getName();
      if (PASS_KEY.contains(name)) {
        continue;
      }
      Object defaultValue = PsiTypeUtil.getDefaultValue(fieldType);
      jsonObject.put(name, defaultValue);
    }
    CUSTOM_TYPES.put(psiClass.getQualifiedName(), jsonObject);
    return jsonObject;
  }
}

CUSTOM_TYPES 是一个 Map,用来缓存已解析的类结构,避免树形数据结构下递归。

PASS_KEY 是一个 Set,包含了不需要解析的属性,如:serialVersionUID。

NORMAL_TYPES 是一个 Map,存储了基本包装类型及其设定的默认值。

Cookie 提取

Cookie 的提取,需要指定浏览器,当前插件配置中仅提供了 Chrome、Microsoft Edge 两种浏览器选项。

以 Mac 下、Chrome 浏览器举例,浏览器的 Cookie 数据存储在 sqlite 数据库中,完整的本地路径为:/Users/sanyi/Library/Application Support/Google/Chrome/Default/Cookies

通过数据库可视化工具连接该库,查询出 cookies 表数据大致如下,可知真实的值是加密的。

image-20221026193224262.png

连库查数据的代码这里不再赘述。

Cookie 解码

Cookie 解码当前只支持 Mac OSX 的版本。其中依赖钥匙串读取一个应用程序密码。

image-20221026195046390.png

应用程序密码读取代码如下:

// MacKeyringFetchUtil.getMacKeyringPassword("Microsoft Edge Safe Storage")
// MacKeyringFetchUtil.getMacKeyringPassword("Chrome Safe Storage")
​
​
public class MacKeyringFetchUtil {
​
    public static Map<String, String> applicationKeyringMap = Maps.newHashMap();
​
    public static String getMacKeyringPassword(String application) throws IOException {
        log.info("start getMacKeyringPassword,application:[{}]", application);
        if (applicationKeyringMap.containsKey(application)) {
            String keyring = applicationKeyringMap.get(application);
            log.info("finish getMacKeyringPassword, response:[{}]", keyring);
            return keyring;
        }
        Runtime rt = Runtime.getRuntime();
        String[] commands = {"security", "find-generic-password", "-w", "-s", application};
        Process proc = rt.exec(commands);
        BufferedReader stdInput = new BufferedReader(new InputStreamReader(proc.getInputStream()));
        StringBuilder result = new StringBuilder();
        String s;
        while ((s = stdInput.readLine()) != null) {
            result.append(s);
        }
        String keyring = result.toString();
        applicationKeyringMap.put(application, keyring);
        log.info("finish getMacKeyringPassword, response:[{}]", keyring);
        return keyring;
    }
​
}

cookie 解码代码如下:

private static String decryptedValue(BrowserEnum browserEnum, byte[] encryptedBytes) {
  byte[] decryptedBytes;
  try {
    byte[] salt = "saltysalt".getBytes();
    char[] password = browserEnum.fetchCookiesKeyring().toCharArray();
    char[] iv = new char[16];
    Arrays.fill(iv, ' ');
    int keyLength = 16;

    int iterations = 1003;

    PBEKeySpec spec = new PBEKeySpec(password, salt, iterations, keyLength * 8);
    SecretKeyFactory pbkdf2 = SecretKeyFactory.getInstance("PBKDF2WithHmacSHA1");

    byte[] aesKey = pbkdf2.generateSecret(spec).getEncoded();

    SecretKeySpec keySpec = new SecretKeySpec(aesKey, "AES");

    Cipher cipher = Cipher.getInstance("AES/CBC/PKCS5Padding");
    cipher.init(Cipher.DECRYPT_MODE, keySpec, new IvParameterSpec(new String(iv).getBytes()));
    String encryptedString = new String(encryptedBytes);
    log.info("encryptedString is:[{}]", encryptedString);
    // if cookies are encrypted "v10" is a the prefix (has to be removed before decryption)
    if (encryptedString.startsWith("v10")) {
      encryptedBytes = Arrays.copyOfRange(encryptedBytes, 3, encryptedBytes.length);
    }
    decryptedBytes = cipher.doFinal(encryptedBytes);
  } catch (Exception e) {
    decryptedBytes = null;
  }
  if (decryptedBytes == null) {
    return null;
  } else {
    return new String(decryptedBytes);
  }
}

成果

一通操作后,小一得到了下面这个有点作用但是不多的小玩具。

image-20221027142950386.png

image-20221026165439069.png

项目 Github

安装使用请阅读项目 README。

代码已开源(GPL-3.0 license):github.com/threeone-wa…

(各位看官,如果觉得有用的话还请小手一抖,帮忙给项目点个 star)

推荐阅读

指标体系的设计和思考

redis 性能分享

基于gitlab ci_cd实现代码质量管理

sharding-jdbc 分享

Flink checkpoint 算法(下)

招贤纳士

政采云技术团队(Zero),一个富有激情、创造力和执行力的团队,Base 在风景如画的杭州。团队现有 500 多名研发小伙伴,既有来自阿里、华为、网易的“老”兵,也有来自浙大、中科大、杭电等校的新人。团队在日常业务开发之外,还分别在云原生、区块链、人工智能、低代码平台、中间件、大数据、物料体系、工程平台、性能体验、可视化等领域进行技术探索和实践,推动并落地了一系列的内部技术产品,持续探索技术的新边界。此外,团队还纷纷投身社区建设,目前已经是 google flutter、scikit-learn、Apache Dubbo、Apache Rocketmq、Apache Pulsar、CNCF Dapr、Apache DolphinScheduler、alibaba Seata 等众多优秀开源社区的贡献者。如果你想改变一直被事折腾,希望开始折腾事;如果你想改变一直被告诫需要多些想法,却无从破局;如果你想改变你有能力去做成那个结果,却不需要你;如果你想改变你想做成的事需要一个团队去支撑,但没你带人的位置;如果你想改变本来悟性不错,但总是有那一层窗户纸的模糊……如果你相信相信的力量,相信平凡人能成就非凡事,相信能遇到更好的自己。如果你希望参与到随着业务腾飞的过程,亲手推动一个有着深入的业务理解、完善的技术体系、技术创造价值、影响力外溢的技术团队的成长过程,我觉得我们该聊聊。任何时间,等着你写点什么,发给 zcy-tc@cai-inc.com

微信公众号

文章同步发布,政采云技术团队公众号,欢迎关注

政采云技术团队.png