阅读 153

GraphQL项目中前端如何预生成Persisted Query

什么是GraphQL?

GraphQL is an open-source data query and manipulation language for APIs, and a runtime for fulfilling queries with existing data.[2] GraphQL was developed internally by Facebook in 2012 before being publicly released in 2015. GraphQL - Wikipedia

简单翻译一下: GraphQL是一个由Facebook在2012年的内部项目孵化并且于2015年正式发布的一个文档型API

GraphQL的用法

GraphQL里面的所有操作归为两类, 一个是query, 一个是mutation Query可以简单理解为 restget请求, 就是一个获取资源的请求. 都需要一段schema来进行描述你想要的数据. 比如这里我们定义了一个方法, 方法是一个query类型的, 刚刚介绍过了GraphQL是一个描述型的API, 那么我们也可以描述一下它.

通过类型是一个 必填的Stringlocale 变量获取地址信息, 返回的数据有 country, province, cities, 其中 cities是由 city, districts构成的.

String 是GraphQL的类型之一

query Address($locale: String!) {
  address(locale: $locale) {
	  country
    province
    cities {
      city
      districts
    }
  }
}
复制代码

我们在前端收到的数据大概是这样

{
  "address": {
    "country": "China"
	  "province": "JiangSu"
    "cities": [{
         "city":"NanJing"
         "districts": "XiCheng"
    }]
  }
}
复制代码

GraphQL的优势

上面这个例子, 在另一个页面也许我只需要country信息就可以了, 那么我的schema可以写成

query Address($locale: String!) {
  address(locale: $locale) {
	  country
  }
}
复制代码

得到的数据会是

{
  "address": {
    "country": "China"
  }
}
复制代码

我在A页面需要country信息, 在B页面需要 country和province信息, 在C页面再多给我返回个cities 以前遇到这种需求, 后端至少得写3个API用来返回,当然前端也得写3个请求去接收, 要么就是直接返回所有数据, 让前端在每个页面都去调用拿到所有数据(在这里就是 country+province+cities), 然后再在不同页面去展示不同的内容就可以了.

但是这样带来了几个坏处:

  1. 我明明只需要部分数据, 你却给我返回了整个对象,不太合理
  2. 如果整个对象过于庞大 甚至你需要多个表查询拼一个对象给我, 那么我只取其中的一小部分而已, 性能开销浪费了
  3. 我只需要部分信息, 你却返回了整个对象, 只要懂得去看console network的人, 就能知道很多他本不该知道的信息了, 不安全.

使用GraphQL就可以避免上述问题, 甚至你也不需要写3个schema, 善用GraphQL FragmentsGraphQL Directives可以帮你解决重复问题.

GraphQL对前端来说的弊端/麻烦

那么GraphQL对前端来说有没有弊端或者麻烦的地方呢? 当然是有的, 这里我们说两个问题.

  1. 请求体积过大带来的网络消耗性能
  2. 被人知道了你整个消息体, 带来的安全问题

请求体积过大带来的网络消耗性能

如果使用过GraphQL的就会知道, 它默认使用的是POST请求, 好处就是, 不论你schema多大, 都可以发送给后端. 但是不足的地方就在于, 没有办法使用http cache, HTTP 缓存 - HTTP | MDN /虽然 HTTP 缓存不是必须的,但重用缓存的资源通常是必要的。然而常见的 HTTP 缓存只能存储 GET 响应,对于其他类型的响应则无能为力。/ 当然, 我们可以将默认的请求类型改为GET, 但是当schema过大的时候 ,就会出问题了.

消息体暴露带来的安全问题

我们在请求的时候, 可以从http请求的Headers里面看到我们的query, 里面有完整的schema,

那么有没有解决这两点的办法呢? 我这里提供一种解决思路, 就是 persisted query

什么是persisted query?

A persisted query is an ID or hash that can be sent to the server instead of the entire GraphQL query string. Automatic persisted queries - Apollo Server - Apollo GraphQL Docs

简单翻译一下就是, 一个短dash代替一个超长的graphql schema

如何使用persisted query?

已经有很多合适的前/后端框架来使用, 我这里说一个前端框架 GitHub - apollographql/apollo-link-persisted-queries: Persisted Query support with Apollo Link

它里面已经有介绍如何使用, 以及工作原理了: How it works

  1. When the client makes a query, it will optimistically send a short (64-byte) cryptographic hash instead of the full query text.
  2. If the backend recognizes the hash, it will retrieve the full text of the query and execute it.
  3. If the backend doesn’t recogize the hash, it will ask the client to send the hash and the query text to it can store them mapped together for future lookups. During this request, the backend will also fulfill the data request. This library is a client implementation for use with Apollo Client by using custom Apollo Link.

预生成persisted query

刚刚我们介绍了, 如何在使用过程中生成. 但是如何预生成呢? 也就是, 在前端部署的过程中或者是在访问页面之前就已经生成好.

为什么要预生成

当然, 还是要问为什么要这么做. 简单来说, 还是为了更好的优化, 试想一下, 如果我已经可以将一个大量访问的schema的变动提前缓存起来, 并且准备好这份数据, 当前端访问的时候, 我直接将这份缓存好的数据扔给前端, 而不是再在后台重新查询拼接, 效率是不是会提高很多呢? 这样的设想完成起来, 需要解决一个最主要的问题, 后端如何在前端没有访问的时候提前预知schema?

如何去预生成

我们这里采用的是, 在前端部署的过程中通过已有schema在node运行生成一段querystring, 通过hash后发给后端, 后端将这段query持久化起来

具体的做法是:

  1. 获取源头 .graphql 文件
  2. 去遍历获得它的fragment
  3. 通过AST给每一个节点上面添加__typename (这一步可能不需要, 因为如果你的请求设置了不带__typename, 就没必要了)
  4. hash它
  5. 后端存储

贴上我的实现代码, 方便直接使用

// parseSchemaToJson
const { resolve, dirname } = require('path')
const { readFileSync } = require('fs')
const { parse: graphqlParse, print: graphqlPrint, Source, visit } = require('graphql')

const TYPENAME_FIELD = {
  kind: 'Field',
  name: {
    kind: 'Name',
    value: '__typename',
  },
};

function isField(selection) {
  return selection.kind === 'Field'
}

function addTypenameToDocument(doc) {
  return visit(doc, {
    SelectionSet: {
      enter(node, _key, parent) {
        // Don't add __typename to OperationDefinitions.
        if (parent && parent.kind === 'OperationDefinition') {
          return
        }
        // No changes if no selections.
        const { selections } = node
        if (!selections) {
          return
        }
        // If selections already have a __typename, or are part of an
        // introspection query, do nothing.
        const skip = selections.some((selection) => {
          return (
            isField(selection) &&
            (selection.name.value === '__typename' || selection.name.value.lastIndexOf('__', 0) === 0)
          )
        })
        if (skip) {
          return
        }
        // If this SelectionSet is @export-ed as an input variable, it should
        // not have a __typename field (see issue #4691).
        const field = parent
        if (isField(field) && field.directives && field.directives.some((d) => d.name.value === 'export')) {
          return
        }
        // Create and return a new SelectionSet with a __typename Field.
        return {
          ...node,
          selections: [...selections, TYPENAME_FIELD],
        }
      },
    },
  })
}

module.exports = function loadGql(filePath) {
  if (!filePath) return null
  try {
    const source = readFileSync(filePath, 'utf8')
    if (!source) return null
    const document = loadSource(source, filePath)

    return graphqlPrint(addTypenameToDocument(document))
  } catch (err) {
    console.log(err)
    return null
  }
}

function loadSource(source, filePath) {
  let document = graphqlParse(new Source(source, 'GraphQL/file'))
  document = extractImports(source, document, filePath)
  return document
}

function extractImports(source, document, filePath) {
  const lines = source.split(/(\r\n|\r|\n)/)

  const imports = []
  lines.forEach((line) => {
    // Find lines that match syntax with `#import "<file>"`
    if (line[0] !== '#') {
      return
    }

    const comment = line.slice(1).split(' ')
    if (comment[0] !== 'import') {
      return
    }

    const filePathMatch = comment[1] && comment[1].match(/^[\"\'](.+)[\"\']/)
    if (!filePathMatch || !filePathMatch.length) {
      throw new Error('#import statement must specify a quoted file path')
    }

    const itemPath = resolve(dirname(filePath), filePathMatch[1])
    imports.push(itemPath)
  })

  const contents = imports.map((path) => [readFileSync(path, 'utf8'), path])

  const nodes = contents.map(([content, fileContext]) => {
    return loadSource(content, fileContext)
  })

  const fragmentDefinitions = nodes.reduce((defs, node) => {
    defs.push(...node.definitions)
    return defs
  }, [])

  return visit(document, {
    enter(node, key, parent, path, ancestors) {
      if (node.kind === 'Document') {
        return {
          definitions: [...fragmentDefinitions, ...node.definitions],
          kind: 'Document',
        }
      }
      return node
    },
  })
}
复制代码
const crypto = require('crypto')
const loadGql = require('./parseSchemaToJson')
const SECRET_KEY = 'TRYITYOURSELF'

const queryStrings = loadGql('yourGraphqlFile.graphql')
const sha256Hash = crypto
  .createHmac('sha256', SECRET_KEY)
  .update(queryStrings)
  .digest('hex')
// Then send to backend
复制代码

上面的parseSchemaToJson里面是处理的fragment的递归的情况. 比如你的fragment里面还有fragment构成的部分, 如果你只有一层fragment构成, 那么可以精简一部分代码, 参考这里apollo-client/transform.ts at master · apollographql/apollo-client · GitHub, 但是使用上述代码也是没有问题的.

参考文献:

如何引入.graphql文件并优雅的使用fragment - WinjayYu - 博客园

关注下面的标签,发现更多相似文章
评论