木匣子

Web/Game/Programming/Life etc.

给 Apollo-Link 打补丁

公司最近上线了一个主要由我负责的 React 项目。Frontend Team Lead 希望把 Graphql 请求由 POST 方式改成 GET 方式,这样就可以借助 CDN 进行缓存。

由于项目中使用的是 Apollo Client,于是只需要简单地对 ApolloHttpLink 加上一条配置即可:

const client = new ApolloClient({
  link: ApolloLink.from([
    // ...
    createHttpLink({
      // ...
      useGETForQueries: true,
    }),
  ]),
  cache: new InMemoryCache(),
});

export default client;

不过我检查了一下项目中的一些 query 定义。有些 query 还是挺大的,其中最长的那个请求有 2288 个字符。我印象中 Internet Explorer 似乎有最长 URL 限制,于是下载了个微软官网的 IE11 虚拟机,随手做了一下测试。

不过在实际测试中,IE11 依然可以通过 XHR 发出这个超过 2083 字符的 GET 请求。但是如果把请求地址复制到地址栏,则会被截断。由于我们只需要保证 IE11 能运行即可,那么是否放任不管就好了呢?我仔细看了一下这个请求,里面有大量的 %20:

http://localhost/graphql/?query=query%20GetTippings(%24masterEventId%3A%20ID!%2C%20%24tipsterId%3A%20ID!)%20%7B%0A%20%20quaddie(masterEventId%3A%20%24masterEventId%2C%20tipsterId%3A%20%24tipsterId)%20%7B%0A%20%20%20%20quaddieLegs%20%7B%0A%20%20%20%20%20%20eventId%0A%20%20%20%20%20%20leg%0A%20%20%20%20%20%20raceNumber%0A%20%20%20%20%20%20runners%20%7B%0A%20%20%20%20%20%20%20%20outcomeId%0A%20%20%20%20%20%20%20%20raceEntryItemCode%0A%20%20%20%20%20%20%20%20__typename%0A%20%20%20%20%20%20%7D%0A%20%20%20%20%20%20__typename%0A%20%20%20%20%7D%0A%20%20%20%20__typename%0A%20%20%7D%0A%20%20suggestedBets(masterEventId%3A%20%24masterEventId%2C%20tipsterId%3A%20%24tipsterId)%20%7B%0A%20%20%20%20raceNumber%0A%20%20%20%20eventId%0A%20%20%20%20type%0A%20%20%20%20comment%0A%20%20%20%20horse%20%7B%0A%20%20%20%20%20%20name%0A%20%20%20%20%20%20outcomeId%0A%20%20%20%20%20%20__typename%0A%20%20%20%20%7D%0A%20%20%20%20__typename%0A%20%20%7D%0A%20%20tippingInformation(masterEventId%3A%20%24masterEventId%2C%20tipsterId%3A%20%24tipsterId)%20%7B%0A%20%20%20%20eventId%0A%20%20%20%20raceNumber%0A%20%20%20%20raceName%0A%20%20%20%20raceDistance%0A%20%20%20%20raceSilkUrl%0A%20%20%20%20raceFormUrl%0A%20%20%20%20raceStatus%0A%20%20%20%20raceReplayVideo%20%7B%0A%20%20%20%20%20%20id%0A%20%20%20%20%20%20__typename%0A%20%20%20%20%7D%0A%20%20%20%20startDateTime%0A%20%20%20%20condition%0A%20%20%20%20comment%0A%20%20%20%20selections%20%7B%0A%20%20%20%20%20%20barrierNumber%0A%20%20%20%20%20%20horseName%0A%20%20%20%20%20%20jockeyName%0A%20%20%20%20%20%20trainerName%0A%20%20%20%20%20%20position%0A%20%20%20%20%20%20outcomeId%0A%20%20%20%20%20%20isScratched%0A%20%20%20%20%20%20tipBetType%0A%20%20%20%20%20%20highlights%20%7B%0A%20%20%20%20%20%20%20%20key%0A%20%20%20%20%20%20%20%20positive%0A%20%20%20%20%20%20%20%20__typename%0A%20%20%20%20%20%20%7D%0A%20%20%20%20%20%20winPrice%20%7B%0A%20%20%20%20%20%20%20%20fixedMarketId%0A%20%20%20%20%20%20%20%20price%0A%20%20%20%20%20%20%20%20url%0A%20%20%20%20%20%20%20%20__typename%0A%20%20%20%20%20%20%7D%0A%20%20%20%20%20%20__typename%0A%20%20%20%20%7D%0A%20%20%20%20__typename%0A%20%20%7D%0A%7D%0A&operationName=GetTippings&variables=%7B%22masterEventId%22%3A1616546%2C%22tipsterId%22%3A%22c42cf854-6089-438d-a00a-0c0905b327cd%22%7D

使用 decodeURI() 解码后可以发现地址包含了完整格式的 graphql 请求定义。而这些空格大部是可以去除的:

http://localost/graphql/?query=query GetTippings(%24masterEventId%3A ID!%2C %24tipsterId%3A ID!) {
  quaddie(masterEventId%3A %24masterEventId%2C tipsterId%3A %24tipsterId) {
    quaddieLegs {
      eventId
      leg
      raceNumber
      runners {
        outcomeId
        raceEntryItemCode
        __typename
      }
      __typename
    }
    __typename
  }
  suggestedBets(masterEventId%3A %24masterEventId%2C tipsterId%3A %24tipsterId) {
    raceNumber
    eventId
    type
    comment
    horse {
      name
      outcomeId
      __typename
    }
    __typename
  }
  tippingInformation(masterEventId%3A %24masterEventId%2C tipsterId%3A %24tipsterId) {
    eventId
    raceNumber
    raceName
    raceDistance
    raceSilkUrl
    raceFormUrl
    raceStatus
    raceReplayVideo {
      id
      __typename
    }
    startDateTime
    condition
    comment
    selections {
      barrierNumber
      horseName
      jockeyName
      trainerName
      position
      outcomeId
      isScratched
      tipBetType
      highlights {
        key
        positive
        __typename
      }
      winPrice {
        fixedMarketId
        price
        url
        __typename
      }
      __typename
    }
    __typename
  }
}
&operationName=GetTippings&variables={"masterEventId"%3A1616546%2C"tipsterId"%3A"c42cf854-6089-438d-a00a-0c0905b327cd"}

为了节省数据传输量,对请求做压缩处理是十分必要的。估算一下,这大概能够省下大概一半的空间。最终效果:

http://localhost/graphql/?query=query%20GetTippings(%24masterEventId%3AID!%24tipsterId%3AID!){quaddie(masterEventId%3A%24masterEventId%20tipsterId%3A%24tipsterId){quaddieLegs{eventId%20leg%20raceNumber%20runners{outcomeId%20raceEntryItemCode%20__typename}__typename}__typename}suggestedBets(masterEventId%3A%24masterEventId%20tipsterId%3A%24tipsterId){raceNumber%20eventId%20type%20comment%20horse{name%20outcomeId%20__typename}__typename}tippingInformation(masterEventId%3A%24masterEventId%20tipsterId%3A%24tipsterId){eventId%20raceNumber%20raceName%20raceDistance%20raceSilkUrl%20raceFormUrl%20raceStatus%20raceReplayVideo{id%20__typename}startDateTime%20condition%20comment%20selections{barrierNumber%20horseName%20jockeyName%20trainerName%20position%20outcomeId%20isScratched%20tipBetType%20highlights{key%20positive%20__typename}winPrice{fixedMarketId%20price%20url%20__typename}__typename}__typename}}&operationName=GetTippings&variables={%22masterEventId%22%3A1616546%2C%22tipsterId%22%3A%22c42cf854-6089-438d-a00a-0c0905b327cd%22}

那么我们要如何实现压缩 GET 请求中的 query 字段呢?最理想的情况是 Apollo Client 直接提供了这个配置。但是我查遍了官方文档和原码,也没有找到这个配置。但是我找到了一些线索:

  1. query 的来源,由 graphql/language/printer 这个包通过解析 graphql-tag 生成的语法树再输入成 query 的主体,预格式化,带了大量无用的空格。意味着即使你直接把源码的空格去掉,在这里依然会给你填充回去。
  2. 早在 2018 年就有人提出了类似需求,并且在 2019 年 graphql-js 官方针对该需求已经实现了 stripIgnoredCharacters() 来实现压缩 query 。
  3. 2020 年初有开发者将其引入 Apollo-Link,并提交了 Pull Request,但目前还没有被合并。

此外,Apollo 官方早前引入了另一种解决方案。客户端发送一段较短的 hash 给服务端。如果服务端能识别该 hash,则直接返回结果。若不能,则先向客户端索要 query 原文,并在服务端缓存。这样就可以服务之后的请求。

这个方案需要服务端引入 Apollo Engine。不太适用我们项目。于是我还是倾向于直接在前端搞定这个问题。于是折衷的方案是先在前端做一个补丁,然后等后期 Apollo 合并上面的 PR 之后,再升级到最新版本。

那么要如何打这个补丁呢?我原本是想将这个库 checkout 到本地进行修改,然后在 webpack 配置中将该库 resolve 到本地修改后的版本。这个方法以前用过,完全可行。

不过脑子里突然冒出一个想法:越来越多的开源库使用 es6 module,并且为了方便测试,都会把内部的函数暴露出来。为何不直接把现有包中的函数替换掉?

这里之所以可以函数替换是因为 apollo-http-link 和 apollo-link-http-common 提供了 cjs bundle 作为 package 的 main 入口,并且 webpack 配置时使用了 main 优先作为入口。而 cjs 的 exports 没作检查。 如果使用 es module 的话,export const * 是无法替换的。
import * as apolloLinkHttpCommon from 'apollo-link-http-common';
import { stripIgnoredCharacters } from 'graphql';
const { selectHttpOptionsAndBody } = apolloLinkHttpCommon;
// TODO: refactor this when https://github.com/apollographql/apollo-link/pull/1241 is merged
apolloLinkHttpCommon.selectHttpOptionsAndBody = (...args) => {
  const orig = selectHttpOptionsAndBody(...args);
  orig.body.query = stripIgnoredCharacters(orig.body.query);
  return orig;
};

这个方法虽然 quick and dirty 但是也能正确通过编译。做为一段过渡期的方案,非常容易维护。不过这种方法并不是没有坑。如果原有的函数被其它文件直接引用。而修改后的版本只会影响通过间接引用的部分。它之所以能工作,我们可以从打包后的代码看出来:

// ...
// var apollo_link_http_common_1 = require("apollo-link-http-common");
i=n("e2b05ee7bdfc55645129");
// ...
function c() {
    // ...
    // var _b = apollo_link_http_common_1.selectHttpOptionsAndBody(operation, apollo_link_http_common_1.fallbackHttpConfig, linkConfig, contextConfig)
    b=i.selectHttpOptionsAndBody(e,i.fallbackHttpConfig,s,v)
    // ...
}

它们都是在运行时对导出的函数进行引用。所以最终使用的是修改后的版本。一个小点子,替我省下了好多时间!


Updated At 2020-09-01:继文给 Apollo-Link 打补丁 II