GraphQL 在前端的应用

总篇110篇 2021年第1篇

当我们面临复杂的业务场景时,接口数量和复杂度的激增一直是工程师不可避免的棘手问题。

在之家的海外业务中,前端技术首次应用 GraphQL 来解决此类棘手问题,在应用的过程中,我们积累了一系列一手经验,特借此次机会分享出来。

下面,让我们从一个简单的视频页面开始。

一个视频页面及数据如下:

// 视频主体

{

video_url‘https://video.domain.com/1’ ,

title‘Manstory Urus’ ,

author‘jerry’ ,

isFollow0 ,

brand_id1 ,

series_id1 ,

tags : [ ‘ferrari’ ]

}

此时需求要新增推荐车辆,页面如下,后端根据功能通常会新增一个接口,新的数据如下:

// 视频主体

{

video_url‘https://video.domain.com/1’ ,

title‘Manstory Urus’ ,

author‘jerry’ ,

isFollow0 ,

brand_id1 ,

series_id1 ,

tags : [ ‘ferrari’ ]

}

// 推荐车辆 根据视频主体的品牌、车系id获取

{

head_img‘https://img.domain.com/1’ ,

car_name‘MERCEDES-BENZ S CLASS S320 CDI L AUTO FSH == LWB == Limousine ==’ ,

price6999 ,

}

此时又需要展示点赞数量,新的数据结构为:

// 视频主体

{

video_url‘https://video.domain.com/1’ ,

title‘Manstory Urus’ ,

author‘jerry’ ,

isFollow0 ,

brand_id1 ,

series_id1 ,

tags : [ ‘ferrari’ ],

like_count1 // 新增字段

}

// 推荐车辆 根据视频主体的品牌、车系id获取

{

head_img‘https://img.domain.com/1’ ,

car_name‘MERCEDES-BENZ S CLASS S320 CDI L AUTO FSH == LWB == Limousine ==’ ,

price6999 ,

}

看似只是新增一个字段,但是需要重新排期,而且数据结构的变动不确定是否会导致客户端受到影响。

那么移动端呢?移动端将原有的 tags 隐藏了。

在数据方面 PC 由于屏幕尺寸的关系,在界面设计上给用户的信息要比移动端多的多,PC 与移动端在显示的信息上是有差异的,相同的数据下发对于某一端来说会存在浪费,从而加大网络开销。

针对上述的情况可以看出:

  • 页面的 API 接口过多

  • 针对 PC 端和 移动端过滤冗余字段

如果请求数超过了浏览器最大并发请求数,其余请求会在后边排队,且浏览器每发送一个请求,都有域名解析、TCP 握手、服务器响应、浏览器解析等过程,所以需要减少 API 接口并去除冗余字段保证其他端不会受到影响,如果接口由后端来包装,会增加后端的工作量,增加沟通成本(包装后的接口数据结构不是前端想要的),所以如果能将这部分逻辑交给前端,可以提升数据融合的灵活性,前端自己才最了解自己需要什么数据接口,减轻后端的压力,提升前端整体的灵活性。

  • 技术选型

本着哪方受益哪方费力的原则,我们考虑在前端加设一层对于接口的中间层。对于前端开发来说,最熟悉的服务器端语言莫过于 Nodejs,由于 Nodejs 采用的是 V8 引擎,运行的是 JavaScript 代码,对于前端同学来说,学习成本低;事件驱动,非阻塞性 I/O,非常适合对于前端这种 IO 密集型的应用,所以我们考虑基于 Nodejs 进行中间层的搭建,进行接口的合并,处理数据结构。而 GraphQL 和我们的需求十分一致,基于此 GraphQL 进入到了我们的视野。

  • GraphQL 介绍

GraphQL 是一个用于 API 的查询语言,是一个使用基于类型系统来执行查询的服务端运行时。GraphQL 并没有和任何特定数据库或者存储引擎绑定,而是依靠你现有的代码和数据支撑。

  • GraphQL 对你的 API 中的数据提供了一套易于理解的完整描述,使得客户端能够准确地获得它需要的数据。

  • 数据没有任何冗余,也让 API 更容易地随着时间推移而演进,还能用于构建强大的开发者工具。

  • 使用强类型的数据类型定义 schema,保证服务器获取到的数据和我们需要数据一致。

  • 兼容任意数据源,不限于某一特定数据库,甚至可以是 API 接口。

  • 性能

使用新技术之前最需要考虑的就是性能问题,不能提升性能,至少也要和现在保持持平,合并接口对于客户端来说可以提升一些性能,但是使用 GraphQL 处理 API 接口后,相当于在原有基础上多请求了一层,原本可以从客户端直接到 API 服务器,现在需要先到 GraphQL 服务,再到 API 服务器,接口响应时间肯定会有增加,但是在这种情况下 GraphQL 服务和 API 服务之间的通信可以使用内网 ip 访问,省略了 NAT 过程,所以两者的耗时上基本可以持平,不会存在影响。

  • GraphQL 基础概念

Schemas

schema 中的语法和 TypeScript 很相似,如果你曾经使用过 TypeScript,对于 schema 会很好理解。一个 schema 基本的类型就是对象和数组类型,一个复杂的对象类型会被拆解为多个基本的对象类型。schema 定义好需要在 Query 中使用,代表可以查询到最小的实体。

type Query {

user(userid: Int!): User

company(company_id: Int!): Company

}

type User {

userid: Int!

name: String

company: [Company]

}

type Company {

company_id

name

}

schema { query: Query }

resolvers

resolvers 是用来桥接 data-source 和 schema 的,resolver 决定 schema 中的 filed 该如何执行。

Query: {

userasync (_, { userid }, { dataSources }) => dataSources.userAPI.getUserInfo({ userid }),

}

data-source

data-source 可以使用任意的数据源,大到各种数据库,小到 Json 文件,按照现在的需求,在 data-source 中将需要请求的 API 接口进行包装,返回数据,此步骤就是基本的服务器端请求,不做详述。

Query

query 用做读取操作,也就是从服务器获取数据,可以理解为 SQL 中的  select

query getUserInfo {

user {

userid

name

company {

company_id

name

}

}

}

# 对于一个没有 type 的操作,会被视为 query

{

user {

userid

name

company {

company_id

name

}

}

}

// Response

{

data : {

user : {

userid1 ,

name“张三” ,

company : [

{

company_id1 ,

name“汽车之家” ,

},

{

company_id2 ,

name“百度” ,

},

]

}

}

}

  • GraphQL 客户端使用

在客户端上如果我们使用 React 技术构建的客户端应用,我们可以直接使用 Apollo 配套的客户端框架,内置 Redux,可以使用 GraphQL,并且无需管理状态。但是由于我们的客户端应用已经很庞大了,使用 Apollo 客户端对于现有应用来说有些过重,且改造成本很大,考虑到 GraphQL 请求实际就是一个 HTTP 请求,所以我们基于原有的  request 方法封装了对于 GraphQL 的请求方法。

query
variables
operationName
query
variables
operationName

request({

url‘http://test.autohome.com.cn/GraphQL’ ,

body : {  // 也可以替换为变量 query getUserInfo($userid: ${userid}) 此处使用的是 ES6 的模板字符串方法

query

`

query getUserInfo($userid: 1) { 

user(userid: $userid) {

userid

name

company {

company_id

name

}

}

}

,

operationName“getUserInfo”

}

})

GraphQL 推荐在定义参数时使用  variables ,query 就是静态查询语句,这样的查询语句可复用性更高。

export
const getUserInfo = 

`

query getUserInfo($userid: Int!) {

user(userid: $userid) {

userid

name

company {

company_id

name

}

}

}

request({

url‘http://test.autohome.com.cn/GraphQL’ ,

body : {

query : getUserInfo,

variables : {

userid1

},

operationName“getUserInfo”

}

})

  • 合并请求

接口间无依赖关系

那么需要合并请求的接口在 GraphQL 上到底应该怎么用呢?只需要在 query 语句包含多个查询实体,这样 GraphQL 会在服务器将多个接口进行并行请求,并将数据整合后按照 query 语句需要的字段返回。

query getUserInfo($userid: Int!, $goodid: Int!) {

user(userid: $userid) { // http://graphql.domain.com/get_user_info

userid

name

}

goods(goodid: $goodid) { // http://graphql.domain.com/get_goods_info

goodid

name

price

}

order(userid: $userid) { // http://graphql.domain.com/get_user_order

orderid

price

createtime

}

}

接口间存在依赖关系

若合并的接口之间存在依赖关系,我们需要在 schema 定义时就将结构准备好,并且在 resolver 上进行特殊处理。假设页面结构为用户基本数据和用户订单列表及购买的具体商品,根据  user_token 可以查询用户信息获取  userid ,再根据  userid 获取用户的订单列表,对应接口应为根据  user_token 请求 http://graphql.domain.com/get_user_info,成功后根据返回的  userid 请求 http://graphql.domain.com/get_user_order,

具体见代码:

// Schema

type Query { 

user(user_token:  String !): User

order(orderid: Int!): Order

goods(goodid: Int!): Goods

}

type User {

userid : Int!

name:  String

orderList : [Order]  // 此步很关键

}

type Order {

orderid

goodid

createtime

goods : Goods

}

type Goods {

goodid

price

name

}

schema {  query : Query }

// Resolver

Query : {

userasync (_, { user_token }, { dataSources }) => dataSources.get_user_info({ user_token }),

},

User : {  // 这里是 User 实体,不是 query

orderListasync (parent, _, { dataSources }) => dataSources.get_user_order({  userid : parent.userid }),

// parent 指代的是父级的返回值,通过返回的 userid 进行请求

}

query getUserOrderInfo($userid: Int!) {

user(userid: $userid) {  // http://test.autohome.com.cn/get_user_info

userid

name

orderList {  // http://test.autohome.com.cn/get_user_order

orderid

goodid

createtime

goods {

goodid

price

name

}

}

}

}

至此一个存在依赖关系的接口请求就构建好了。

  • APQ 持久化

上文提到 GraphQL 的请求会把查询语句放到参数 query 中,我们都知道 GET 请求受浏览器限制超出会被截断或报错,如果 GraphQL 的 query 查询参数过大也会出现同样的问题,那么应该如何解决呢,将所有请求都使用 POST 方式?即便使用 POST,请求中的字节数大小也是不可忽略的,并且如果想在接口上增加 CDN 缓存又该怎么处理呢?

Apollo Server 提供了一个解决方案,在 GET 请求中将 query 进行 sha256 加密生成 sha256Hash,只传递 sha256Hash,服务器端会根据 sha256Hash 找到对应的 query,若不存在这个 query,会返回异常,客户端根据异常状态决定是否再次发起 POST 请求,将 query 和 sha256Hash 一同传递,服务器端进行查询,并将 sha256Hash 缓存,默认缓存在内存中,也可以选择缓存在 Redis 或 Memcached 中。上面的请求也可以全部使用 POST,但是这样就不能针对 GET 请求进行 CDN 缓存的设置,具体流程可参考下方流程图。

  • 工具

GraphQL 对于我们来说是一个新鲜事物,那么有什么现成的工具可以提升我们的工作效率呢?

  1. 调试工具

  • Apollo Server Playground,Apollo Server 自带的调试网页

  • graphql-ide

  • altair-graphql-client

  1. json2schema 在进行将 json 转化为 schema 时,可以使用一些现成的工具库来方便我们的开发,将更多的时间专注于核心开发上,好钢用在刀刃上,例如 @walmartlabs/json-to-simple-graphql-schema,使用方式如下,其他使用方式自行参考文档,类似的包还有一些,大家可以自行选择。

curl  “https://data.cityofnewyork.us/api/views/kku6-nxdu/rows.json?accessType=DOWNLOAD” \

| npx @walmartlabs/json-to-simple-graphql-schema

  • 经验之谈

1. 超出 Int 类型的数字

GraphQL 中的 Int 类型默认支持 32 位整型,那我们超过 32 位的数字使用什么类型呢?字符串吗?这时就需要使用到自定义类型 scalar。

  1. 定义自定义类型

scalar BigInt

  1. 定义 resolvers

const BigInt =  new GraphQLScalarType({

name‘BigInt’ ,

description :

`’BigInt’类型介于 -(2^53) + 1 和 2^53 – 1 之间` ,

serialize : parseBigInt,

parseValue : parseBigInt,

parseLiteral(ast) {

if (ast.kind === INT) {

const num =  parseInt (ast.value,  10 );

if (num <=  Number .MAX_SAFE_INTEGER && num =   Number .MIN_SAFE_INTEGER) {

return num;

}

}

return null ;

},

});

这部分文档说的比较模糊,具体参数含义如下:

  • name :字段名,与 scalar 定义的字段名一致
  • description :字段类型的描述,某些情况会被用作提示,比如在 Apollo Server 的 Playground 中
  • serialize : 返回给客户端进行的序列化
  • parseValue :将客户端在 variables 中传递的参数转换为自定义类型
  • parseLiteral :将客户端在 query 参数中传递的参数转换为自定义类型 需要注意 parseValue 和 parseLiteral 的区别,如果确定自定义类型不会在参数中传递,可以省略 parseValue 和 parseLiteral。

query getUserInfo( $userid : BigInt!) {

user(userid:  $userid ) {

userid

name

}

}

variables {

userid: 9223372036854775807  // 使用 parseValue 解析

}

query getUserInfo {

user(userid: 9223372036854775807) { // 使用 parseLiteral 解析

userid

name

}

}

2. 格式化返回值

result
returncode
data
data
null
errors
formatResponse

{

returncode0 ,

message‘success’ ,

result : {},

}

{

data : {},

errors : []

}

formatResponse ( res, req ) => {

(res.errors || []).forEach( ( { path, message } ) => {

if (path) {

path.forEach( ( key ) => {

if ( typeof res.data[key] !==  ‘undefined’ ) {

res.data[key] =  JSON .parse(message);

}

});

}

});

if (!res.data.__schema) {

Object .keys(res.data).forEach( ( key ) => {

const obj = res.data[key];

if ( typeof obj.returncode ===  ‘undefined’ ) {

res.data[key] = {

returncode0 ,

result : obj,

};

}

});

}

return {

data : {

…res.data,

},

extensions : {

…res.extensions,

timeline : req.context.timeline,  // 原始接口请求时间

},

};

},

3. 安全限制

每一个线上服务的安全性方面都是不容忽视的,那么 GraphQL 有哪些安全方面的问题吗?

  1. 并行查询层级过多

query getUserInfo {

user(userid: 1) {

userid

name

}

user(userid: 2) {

userid

name

}

user(userid: 3) {

userid

name

}

}

如果  user 一直不断增加,甚至增加查询别的内容,会对服务器造成很大的影响,所以需要对并行查询层级进行限制。

  1. 嵌套层级过多

query getUserFriends {

user {

userid

name

friends {

userid

name

friends {

userid

name

friends {

userid

name

friends {

userid

name

}

}

}

}

}

}

  1. 关闭 GraphQL 调试工具

4. 请求数据不更新

服务器端请求使用的是 Apollo Server 推荐的  apollo-datasource-rest ,一个从 REST API 获取数据的包,需要注意的是  apollo-datasource-rest 会对 GET 方法有默认的缓存,这部分文档并没有写清,在使用时会出现数据不更新的情况,可以参考下面的代码配置。

// get 方法设置 cacheOptions.ttl = 0,缓存时间为 0

get (path, params, {

…options,

cacheOptions : {

ttl0 ,

…options.cacheOptions,

},

}

// 收到返回值后将 memoizedResults 中的这次缓存删除

didReceiveResponse(response, request) {

this .memoizedResults.delete( this .cacheKeyFor(request));

return super .didReceiveResponse(response, request);

}

  • 总结

接入了 GraphQL 后,可以对前端的接口请求数做一个有效的控制,过滤冗余字段,虽然最优解还是由后端来进行,数据源直接对接数据库,但是由于种种原因,前端自己来实现也不失于一种好的解决方案。

  • 作者简介: