初识GraphQL

GraphQL 是一种 API 查询语言,可以让客户端按需请求需要的数据,避免了 REST API 中的过度请求和响应数据的情况,虽然已经出现不少年份了,但一直没有去尝试使用过,最近有空学习了一下,稍作整理。

<!--more-->

参考

1. 背景

在 REST API 中,客户端发送 HTTP 请求到服务器,服务器返回整个资源的响应。但有时候客户端只需要获取资源中的一部分,这时候就会请求多次才能获取所需的数据,导致了过度请求和响应数据的问题。

GraphQL 的思路是:客户端发送查询语句到服务器,查询语句只包含需要获取的数据字段,服务器返回查询语句中请求的数据,而不是整个资源。这样,客户端可以根据需要获取所需的数据,避免了过度请求和响应数据的情况,也提高了应用性能。

先来看一个简单的例子:查询用户id为123用户的的名称和邮箱,以及发表的10篇文章,每篇文章返回对应的标题和内容。

在REST API中,如果后端为了解耦,定义了每个接口代表一种资源,那么这查询就需要多个接口来完成

type UserInfo = {
  name:string, 
  email:string
}
type Post = {
  title: sting, 
  content: string,
}

await Promise.all([
  fetchUserInfo<BaseResponse<{UserInfo>>({id:123}),
    fetchUserPosts<BaseResponse<Post[]>>({id:123})
  // ... 其他查询接口
])

前端需要自己从多个接口中组装需要的数据,还需要维护每个接口之前的请求关系,考虑Race Condition等情况。

此外,接口返回的数据完全是由后端的模型控制的,比如后端定义的Post模型可能还有其他的字段

type Post = {
  title: sting, 
  content: string,
  createDate: string,
  id: string
}

即使前端不需要Post数据上面的createDateid字段,后端也会在这个接口里面一起返回(除非后端单独提供了一个只查询titlecontent的接口,或者在接口参数里面增加指定字段相关参数之类的功能),这样就会带来数据的冗余,增加接口的流量成本和传输时间。

后端控制数据响应的另外一个问题是需要考虑API版本兼容的问题,比如在版本1中fetchUserInfo接口返回了nameemail字段,后面版本迭代,只返回name接口字段,那些使用了老版本接口的客户端,由于依赖了email字段,出于兼容性的考虑,就无法直接在原本的fetchUserInfo上修改,而是需要使用fetchUserInfo/v2之类的接口。由于App升级都有版本覆盖率的问题,无法保证所有用户百分之百升级到最新版本,因此这种接口兼容的在App开发中更为常见。

再来看看通过GraphQL 查询(具体的语法细节后面会提到)

query {
  user(id: 123) {
    name
    email
    posts(limit: 10) {
      title
      content
    }
    // ... 其他查询数据
  }
}

只需要一个接口就能拿到所有预期的数据,数据格式整好是查询语句定义的格式,没有接口竞态、没有冗余数据!

此外,所有数据都是有客户端自己定义的,也就彻底消除了API兼容的问题。

GraphQL 还提供了强类型系统,可以在编译时检查查询语句的正确性,并提供了详细的文档和工具支持。这样客户端的查询语句里面如果指定了错误的字段,就会在编译阶段进行提示,这使得客户端和服务器可以更好地协作,从而更快、更高效地构建和维护 API。

2. 示例代码

社区提供了丰富的工具,满足在各种项目中使用GraphQL的需求。

对前端来说,流行的GraphQL 客户端包括 Apollo Client、Relay、urql ,可以非常方便地查询数据。

后端项目语言繁多,主流语言如Java、PHP、GO、NodeJS等都有相关的GraphQL服务端工具,如graphqljs、apollo-server、graphql-yoga等。

先来一段代码体验在NodeJS后端服务中提供GraphQL查询的功能。

2.1. 服务端

首先,需要安装依赖包 express、express-graphql 和 graphql

npm install express express-graphql graphql

然后,创建一个 index.js 文件,编写以下代码

const express = require('express');
const { graphqlHTTP } = require('express-graphql');
const { buildSchema } = require('graphql');

// 定义 GraphQL Schema
const schema = buildSchema(`
  type Query {
    hello: String
  }
`);

// 定义 GraphQL Resolver
const root = {
  hello: () => 'Hello World!'
};

// 创建 Express 实例
const app = express();

// 添加 GraphQL 中间件
app.use('/graphql', graphqlHTTP({
  schema: schema,
  rootValue: root,
  graphiql: true
}));

// 启动服务
app.listen(3000, () => {
  console.log('GraphQL API Server is running at http://localhost:3000/graphql');
});

启动服务node index.js,然后访问http://localhost:3000/graphql,就可以看见对应的可视化查询界面

2.2. 客户端

测试一下,输入查询语句然后运行

{
    msg
}

打开控制台可以看见实际上是发送了POST请求出去,对应的参数

{"query":"query {\n  hello\n}","variables":null}

在页面右侧可以看见接口响应

{
  "data": {
    "hello": "Hello World"
  }
}

从客户端的角度来看,需要接口返回什么数据,完全掌握在自己的手中了。

3. 核心概念

接下来我们来看看GraphQL 的主要概念:

  • Schema:GraphQL 的核心是由一个 Schema 定义的类型系统,它描述了 API 支持的数据类型、操作和数据之间的关系

    • Type Definitions:类型定义,可以定义对象类型、标量类型、枚举类型和接口类型等
    • Resolver:负责将 Query 和 Mutation 转换为实际数据的函数
  • Operation Type:操作类型

    • Query:用于从服务器获取数据的 GraphQL 请求,对应CURD中的R,GraphQL主要的功能是用于查询
    • Mutation:用于在服务器上更改数据的 GraphQL 请求,对应CURD中的CUD
    • Substription:当数据发生更改,进行消息推送
  • Fragment:用于将查询分解为可重用部分的 GraphQL 特性。

一个完整的 GraphQL 查询一般需要经过三个步骤:描述数据、请求数据和得到结果,从概念上来看即

  • 服务端通过Type Definitions 定义数据类型
  • 客户端通过Query描述要查询的数据
  • 服务端通过Resolver解析请求获得数据,然后返回

接下来我们来细看一下相关的概念

3.1. Type Definitions

参考:Schema 和类型

GraphQL Schema的定义通常使用GraphQL Schema Definition Language(SDL)进行,SDL使用一种类似于GraphQL查询语言的语法来描述类型、字段和关系,引入SDL的好处是:GraphQL服务可以用任何的服务端编程语言来实现~。

Schema描述了API中的所有数据类型以及数据之间的关系,包括查询(Query)、变更(Mutation)、订阅(Subscription)等操作的类型和输入参数,定义了客户端可以查询的所有字段以及它们的类型和返回值,从而使客户端能够精确地了解服务器上哪些数据是可用的。

在定义Schema之后,客户端就可以使用GraphQL查询语言来查询API,并从服务器上获取数据。

一个 GraphQL schema 中的最基本的组件是对象类型,每个对象类型都由一个或多个字段组成,而每个字段都具有一个名称、一个返回类型以及可能的参数

type User {
    id: Int!, // !表示非空
  name: String,
  email: String,
  posts: [Post]
}

type Post {
    title: String,
    content: String
}

对象类型定义看起来跟TypeScript非常像,

  • IntString都是内置的标量类型,此外还有BooleanFloatID
  • UserPost是自定义的对象类型
  • [Post]表示的是列表类型,返回的是Array<Post>数组

枚举类型,枚举类型表示一组可能的值

enum Colors {
  RED
  GREEN
  BLUE
}

接口类型,接口类型定义了一组相关对象类型的共同字段。

interface UserInfo {
  id: ID!
  name: String!
}
// User1User2类型都具备了接口的所有字段,然后各自包含自己的字段
type User1 implements UserInfo {
  id: ID!
  name: String!
  emmail: String
}
type User2 implements UserInfo {
  id: ID!
  name: String!
  address: String
}

接口类型在需要返回一组不同的类型时比较有用。此外还有联合类型和输出类型,这里不再展开。

数据类型定义完成之后,就需要定义查询和变更类型了,QueryMutation是内置的两种特殊类型

schema {
  query: Query
  mutation: Mutation
}

每一个 GraphQL 服务都有一个 query 类型,可能有一个 mutation 类型,

下面展示了一个简单的查询语句声明

type Query {
  msg: String,
  user(id: Int): User
}

有了这个声明之后,客户端就可以用对应的Query来查询数据了

3.2. Query

GraphQL 查询语言使用类似 JSON 的语法,上面我们已经展示过一段查询语言

query QueryUserInfoAndPosts {
  user(id: 123) {
    name
    email
    posts(limit: 10) {
      title
      content
    }
  }
}

其中

  • query是操作类型关键字,这里是查询,还可以是mutation 或subscription`
  • QueryUserInfoAndPosts是操作名称,一个语义化的名字,有具体的含义,也更容易调试和日志追踪
  • 第一个{}里面的部分就是查询部分

查询部分具有以下基本语法:

查询字段:可以在查询中指定要获取的字段

{
  user {
    name
    email
  }
}

返回的结果

{    
    "data": {
        "user": {
            "name": "this is my name",
            "email": "xx@xx.com"
        }
    }
}

参数:可以使用参数来限制查询结果,下面展示了查询id为123的用户的昵称

{
  user(id: 123) {
    name
  }
}

别名:使用别名为字段分配自定义名称

{
  user1: user(id: 1) {
    name
  }
   user2: user(id: 2) {
    name
  }
}

在处理多个同名字段时很有用

{    
    "data": {
        "user1": {
            "name": "this is my name",
        },
        "user2": {
            "name": "this is my name2",
        }
    }
}

嵌套查询:可以使用嵌套查询来获取与根查询相关的附加信息,客户端需要的字段,都可以放在query里面

{
  user(id: 123) {
    name
    posts(limit: 10) {
      title
    }
  }
}

片段:可以使用片段来重用查询中的字段集,这样就不用重复写相同的结构了

fragment userInfo on User {
  name
  email
  phone
  friends {
    name,
  }
}
{
  user1: user(id: 1) {
       ...userInfo
  }
  user2: user(id: 2) {
      ...userInfo
  }
}

变量:可以使用变量来将查询参数化,上面的user(id: 1)是通过字面量的形式声明的查询参数,如果要动态传递id,拼接查询字符串并不是一个很合理的做法,更常规的做法是使用变量

query QueryUserInfoAndPosts($uid: Number) {
  user(id: $uid) {
    name
  }
}

指令:指令用于动态控制某些字段是否需要查询返回,

query QueryUserInfoAndPosts($uid: Number, $withPosts:Boolean!) {
  user(id: $uid) {
    name
    posts @include(if: $withPosts) {
        name
    }
  }
}

只有在$withPosts变量为true时才会返回posts相关字段

3.3. Resolvers

GraphQL Schema由两部分组成:类型定义(Type Definitions)和解析器(Resolvers)。类型定义描述了所有的GraphQL类型以及它们的字段和关系,而解析器则实现了这些类型和字段的具体行为。

解析器是一组函数,它们将GraphQL请求中的字段映射到具体的数据源,并返回相应的结果。每个字段都有一个解析器函数,该函数负责从数据源中获取字段的值。解析器还可以处理参数、进行验证和过滤,以及执行与数据源相关的任何其他逻辑。

比如对于下面这个查询

query {
  hello
}

对应的解析函数应该是

Query: {
  hello (parent, args, context, info) {
    return ...
  }
}

每个参数的含义

  • parent:当前上一个解析函数的返回值

  • args:查询中传入的参数

  • context:提供给所有解析器的上下文信息

  • info:一个保存与当前查询相关的字段特定信息以及 schema 详细信息的值

不同的graphql工具库提供的定义解析函数的方式可能有区别,但大致都遵循这些基本参数,比如上面示例代码中的express-graphql

// 定义 GraphQL Resolver
const root = {
  // hello world
  hello: () => 'Hello World!',
  // 带查询参数的resolver
  user({id}){
    // 也可以从其他各种数据库中根据参数查找数据
    const list = [{id:1,name:'user1',email:'1@xx.com'}, {id:2,name:'user2',email:'2@xx.com'}]
    return list.find(row=>row.id===id)
  },
  users(){
    return Db.user.findAll();
  }
};

4. 小结

至此,对于GraphQL就有了基本的

  • 对于前端而言,需要了解查询语法、类型定义,然后选择项目合适的graphql客户端工具
  • 对于后端而言,需要了解类型定义、解析器实现等,然后选择醒目合适的graphql服务端工具

可见如果想要在项目中落地GraphQL,需要前后端共同配置来完成,GraphQL这两年并没有如预期那样飞速发展起来,感觉主要的原因包括

  • 虽然提升了查询效率,减少了查询次数,但数据库的查询可能会成为性能瓶颈,对数据库的查询次数很多,需要合并优化方案
  • 利好前端、但需要后端进行大规模改造,在分工明确的大团队可能不太容易执行

最后,GraphQL并不是用来替代REST API的,具体的技术选型,还是得看业务合不合适,业务优先为主。