GraphQL 在前端的应用
总篇110篇 2021年第1篇
当我们面临复杂的业务场景时,接口数量和复杂度的激增一直是工程师不可避免的棘手问题。
在之家的海外业务中,前端技术首次应用 GraphQL 来解决此类棘手问题,在应用的过程中,我们积累了一系列一手经验,特借此次机会分享出来。
下面,让我们从一个简单的视频页面开始。
一个视频页面及数据如下:
// 视频主体
{
video_url : ‘https://video.domain.com/1’ ,
title : ‘Manstory Urus’ ,
author : ‘jerry’ ,
isFollow : 0 ,
brand_id : 1 ,
series_id : 1 ,
tags : [ ‘ferrari’ ]
}
此时需求要新增推荐车辆,页面如下,后端根据功能通常会新增一个接口,新的数据如下:
// 视频主体
{
video_url : ‘https://video.domain.com/1’ ,
title : ‘Manstory Urus’ ,
author : ‘jerry’ ,
isFollow : 0 ,
brand_id : 1 ,
series_id : 1 ,
tags : [ ‘ferrari’ ]
}
// 推荐车辆 根据视频主体的品牌、车系id获取
{
head_img : ‘https://img.domain.com/1’ ,
car_name : ‘MERCEDES-BENZ S CLASS S320 CDI L AUTO FSH == LWB == Limousine ==’ ,
price : 6999 ,
}
此时又需要展示点赞数量,新的数据结构为:
// 视频主体
{
video_url : ‘https://video.domain.com/1’ ,
title : ‘Manstory Urus’ ,
author : ‘jerry’ ,
isFollow : 0 ,
brand_id : 1 ,
series_id : 1 ,
tags : [ ‘ferrari’ ],
like_count : 1 // 新增字段
}
// 推荐车辆 根据视频主体的品牌、车系id获取
{
head_img : ‘https://img.domain.com/1’ ,
car_name : ‘MERCEDES-BENZ S CLASS S320 CDI L AUTO FSH == LWB == Limousine ==’ ,
price : 6999 ,
}
看似只是新增一个字段,但是需要重新排期,而且数据结构的变动不确定是否会导致客户端受到影响。
那么移动端呢?移动端将原有的 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: {
user : async (_, { 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 : {
userid : 1 ,
name : “张三” ,
company : [
{
company_id : 1 ,
name : “汽车之家” ,
},
{
company_id : 2 ,
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 getUserInfo($userid: 1) {
user(userid: $userid) {
userid
name
company {
company_id
name
}
}
}
,
operationName : “getUserInfo”
}
})
GraphQL 推荐在定义参数时使用 variables
,query 就是静态查询语句,这样的查询语句可复用性更高。
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 : {
userid : 1
},
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 : {
user : async (_, { user_token }, { dataSources }) => dataSources.get_user_info({ user_token }),
},
User : { // 这里是 User 实体,不是 query
orderList : async (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 对于我们来说是一个新鲜事物,那么有什么现成的工具可以提升我们的工作效率呢?
-
调试工具
-
Apollo Server Playground,Apollo Server 自带的调试网页
-
graphql-ide
-
altair-graphql-client
-
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。
-
定义自定义类型
scalar BigInt
-
定义 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
{
returncode : 0 ,
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] = {
returncode : 0 ,
result : obj,
};
}
});
}
return {
data : {
…res.data,
},
extensions : {
…res.extensions,
timeline : req.context.timeline, // 原始接口请求时间
},
};
},
3. 安全限制
每一个线上服务的安全性方面都是不容忽视的,那么 GraphQL 有哪些安全方面的问题吗?
-
并行查询层级过多
query getUserInfo {
user(userid: 1) {
userid
name
}
user(userid: 2) {
userid
name
}
user(userid: 3) {
userid
name
}
…
}
如果 user
一直不断增加,甚至增加查询别的内容,会对服务器造成很大的影响,所以需要对并行查询层级进行限制。
-
嵌套层级过多
query getUserFriends {
user {
userid
name
friends {
userid
name
friends {
userid
name
friends {
userid
name
friends {
userid
name
}
}
}
}
}
}
-
关闭 GraphQL 调试工具
4. 请求数据不更新
服务器端请求使用的是 Apollo Server 推荐的 apollo-datasource-rest
,一个从 REST API 获取数据的包,需要注意的是 apollo-datasource-rest
会对 GET 方法有默认的缓存,这部分文档并没有写清,在使用时会出现数据不更新的情况,可以参考下面的代码配置。
// get 方法设置 cacheOptions.ttl = 0,缓存时间为 0
get (path, params, {
…options,
cacheOptions : {
ttl : 0 ,
…options.cacheOptions,
},
}
// 收到返回值后将 memoizedResults 中的这次缓存删除
didReceiveResponse(response, request) {
this .memoizedResults.delete( this .cacheKeyFor(request));
return super .didReceiveResponse(response, request);
}
-
总结
接入了 GraphQL 后,可以对前端的接口请求数做一个有效的控制,过滤冗余字段,虽然最优解还是由后端来进行,数据源直接对接数据库,但是由于种种原因,前端自己来实现也不失于一种好的解决方案。
-
作者简介: