6

GraphQL 在前端的应用

 3 years ago
source link: https://mp.weixin.qq.com/s?__biz=MzUyMzg4ODk2NQ%3D%3D&%3Bmid=2247485947&%3Bidx=1&%3Bsn=cf8e48385073fafffb839f86ed20c0aa
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.

总篇110篇 2021年第1篇

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

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

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

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

// 视频主体

{

video_url'https://video.domain.com/1' ,

title'Manstory Urus' ,

author'jerry' ,

isFollow0 ,

brand_id1 ,

series_id1 ,

tags : [ 'ferrari' ]

}

3YzQfqz.png!mobile

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

// 视频主体

{

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 ,

}

E7j2qqM.png!mobile

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

// 视频主体

{

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 ,

}

a26jiej.png!mobile

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

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

iayMFz6.png!mobile

在数据方面 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 接口。

IJNfamQ.png!mobile

  • 性能

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

3A32uef.jpg!mobile

  • 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 缓存的设置,具体流程可参考下方流程图。

JJJVbyM.png!mobile

  • 工具

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

  1. 调试工具

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

IRRjQzV.png!mobile

  • graphql-ide

7jMb22i.png!mobile

  • altair-graphql-client

VZ7jInB.png!mobile

  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

UjiQv2q.jpg!mobile

  • 经验之谈

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 调试工具

IRRjQzV.png!mobile

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

  • 作者简介:

2iIzee6.png!mobile


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK