添加链接
link管理
链接快照平台
  • 输入网页链接,自动生成快照
  • 标签化管理网页链接

GraphQL 实践(一): GraphQL 入门 #51

@nodejh

Description

GraphQL 实践系列文章包含以下几篇文章,将在近期更新:

GraphQL 实践(一): GraphQL 入门

在 Egg 中使用 GraphQL

GraphQL 如何实现文件上传

GraphQL 订阅

GraphQL N + 1 问题

GraphQL 最佳实践

本文代码: https://github.com/nodejh/egg-plugin-graphql/tree/master/examples/graphql-start

作为前端,你一定很痛恨接口文档写的很差,你一定很抱怨接口字段冗余,你一定很讨厌要请求多个接口才能得到想要的数据。作为后端,你一定很痛恨写接口文档,你一定很会抱怨为什么前端不自己去取想要的值,你一定讨厌前端对你的接口指指点点。

如何解决前后端的分歧?如何提高开发效率?那就是 GraphQL。

GraphQL 是一个 API 查询语言,跟 RESTful API 是同一类的技术。换句话说,GraphQL 是一个可以替代 RESTful API 的产品。

接下来本文就详细介绍一下基于 Node.js 的 GraphQL 实践。

相关技术栈:

  • 后端: Egg.js
  • GraphQL Server: apollo-server
  • Egg 中的 GraphQL 插件: egg-plugin-graphql
  • 前端: react-apollo
  • GraphQL 简介

    相信大家都已经了解,Egg.js 应用一般都是是经典的 MVC 模式:

  • router (路由)
  • controller
  • service
  • public (view)
  • 我们一般会在 router 中定义接口,也就是 RESTful API 中的路由,如 router.get('/api/user', controller.user.index) ,然后在 controller 实现控制器的逻辑,一般是参数校验、权限控制、调用 service 以及返回结果。 service 中则是业务的具体实现,比如操作数据库等。

    而当我们使用 GraphQL 的时候,替代的则是 router controller 。因为 GraphQL 已经帮我们实现了路由和参数校验,并且我们可以在 GraphQL 中进行服务( service )的调用。

    那么 GraphQL 是怎么实现的呢?这就需要引出 GraphQL 的两个概念: schema resolver

    Schema

    Schema 是 GraphQL 中对数据的描述,与 TypeScript 中的类型定义有点类似。举个例子:

    enum Gender {
      FEMALE
    type User {
      name: String!
      gender: Gender!
      tags: [String!]!
    

    如上所示,Gender 是一个枚举类型,它的值是 MALE、FEMALE 和 NONE 之一。

    User 是一个 GraphQL 对象类型,拥有 nametags 两个字段。其中 name 的类型是 String!! 表示这个字段是非空的。[String!]! 表示一个 String 数组,这个数组内元素非空,tags 也非空。

    schema 中有两个特殊的类型:查询(query)和变更类型(mutation)。每一个 GraphQL 服务都有一个 query 类型,可能有一个 mutation 类型。query 用于查询数据,mutation 用于创建或更新数据。这两个类型和常规对象类型一样,但是它们之所以特殊,是因为它们定义了每一个 GraphQL 查询的入口,相当于 RESTful 中的路由。

    假设我现在需要两个接口,分别是查询所有用户信息和根据 name 查询对应用户信息,则 schema 中需要一个 Query 类型,且其上有 users 和 user 字段:

    type Query {
      "查询所有用户列表"
      users: [User!]!
      "根据 name 查询对应的用户信息"
      user(name: String!): User
    

    从 schema 中可以看出, GraphQL 是强类型的,其类型主要有对象类型、枚举类型、标量类型、接口、联合和输入类型。

    标量类型是 GraphQL 查询的叶子节点,对应的字段没有任何次级字段。GraphQL 的默认标量类型有:

  • Int:有符号 32 位整数。
  • Float:有符号双精度浮点值。
  • String:UTF‐8 字符序列。
    Boolean:true 或者 false。
  • ID:ID 标量类型表示一个唯一标识符,通常用以重新获取对象或者作为缓存中的键。ID 类型使用和 String 一样的方式序列化;然而将其定义为 ID 意味着并不需要人类可读型。如 MongoDB 的 id 字段。
  • 我们也可以通过 scalar 字段自定义其他标量,如 scalar Date

    关于 GraphQL 类型详细的说明可以参考文档:Schema 和类型

    Resolver

    定义好了 schema 之后,就需要实现 schema 中的 query 或 mutation。在 GraphQL 中,每个类型的每个字段都由一个 resolver 函数支持,当一个字段被执行时,相应的 resolver 被调用以产生下一个值。

    简单来说,就是 schema 中的每个字段,都需要对应一个 resolver 函数,也就相当于 RESTful 中的 controller,resolver 函数是对 GraphQL 入口的实现。

    以前面的 Schema 为例,我们需要定义 Query 中 users 和 user 两个字段的 resolver 函数:

    const resolvers = {
      Query: {
        users: () => [{ name: "Jack", gender: "MALE", tags: ["Alibaba"] }],
        user: (parent, args, context, info) => {
          const { name } = args;
          // find user by name...
          return { name, gender: "MALE", tags: ["Alibaba"] }
    

    resolver 函数也支持 promise,如:

    const resolvers = {
      Query: {
        users: async (parent, info, context) => {
          return await context.service.user.findAll();
    

    为了更直观看到效果,我们可以通过 apollo-server 将前面写的 schema 和 resolvers 都启动起来:

    $ mkdir server
    $ npm init -y
    $ npm install apollo-server --save
    // server/index.js
    const { ApolloServer, gql } = require('apollo-server');
    // The GraphQL schema
    const typeDefs = gql`
      enum Gender {
        FEMALE
      type User {
        name: String!
        gender: Gender!
        tags: [String!]!
      type Query {
        "查询所有用户列表"
        users: [User!]!
        "根据 name 查询对应的用户信息"
        user(name: String!): User
    // A map of functions which return data for the schema.
    const resolvers = {
      Query: {
        users: () => [{ name: "Jack", gender: "MALE", tags: ["Alibaba"] }, { name: 'Joe', gender: 'MALE', tags: [] }],
        user: (parent, args, context, info) => {
          const { name } = args;
          // find user by name...
          return { name, gender: "MALE", tags: [name] }
    const server = new ApolloServer({
      typeDefs,
      resolvers,
    });
    server.listen().then(({ url }) => {
      console.log(`🚀 Server ready at ${url}`);
    });

    通过 node index.js 启动之后在浏览器中打开对应的 URL(默认是 http://localhost:4000),就可以看到一个强大的 GraphQL 开发者工具(playground),我们可以在左侧查询语句查询,执行后右侧就会显示对应的数据。

    查询 query

    请求你所要的数据,不多不少

    在 RESTful API 中,我们一般是通过接口查询数据,如查询所有用户列表可能是通过 HTTP GET 方法请求 /api/users 接口;而在 GraphQL 中,没有了路由的概念,取而代之的是入口,类似的,我们可以通过 GraphQL 的查询语句对 GraphQL Schema 中的入口进行查询。

    查询条件:

    query
    
    
    
    
        
     {
      users {
    

    该查询的含义是,查询 users,并且返回 name 字段。查询结果如下:

    "data": { "users": [ "name": "Jack" "name": "Joe"

    如果我们还需要得到 gendertags,则可以这样写:

    query {
      users {
        gender
    

    查询结果:

    "data": { "users": [ "name": "Jack", "gender": "MALE", "tags": [ "Alibaba" "name": "Joe", "gender": "MALE", "tags": []

    对比 RESTful API,GraphQL 的查询语句多了对返回字段描述。我们需要什么字段,则查询什么字段,这就解决了字段冗余的问题。同时 GraphQL 查询也总能得到可预测的结果,查询结果的字段一定是与查询条件一一对应的。

    获取多个资源,只用一个请求

    当然,GraphQL 的能力不止于此。

    设想另一个场景:查询所有用户名列表,并且返回用户名为 "Jack" 的详细信息。

    如果使用 RESTful API,我们可能需要发起两个 HTTP 请求分别查询 /api/users/api/user?name=Jack 接口。但使用 GraphQL,我们只需要定义一个查询条件即可:

    query GetUser {
      users {
      user(name: "Jack") {
        gender
    

    在这个查询中,我们查询了两个入口:

  • 查询 users,返回 name 字段
  • 以 { "name": "Jack" } 为参数查询user,返回 namegendertags 字段
  • 通过浏览器开发者工具查看执行查询条件后的请求,可以发现只发送了一个请求,参数分别为 operationName queryvariables

  • operationName 是我们定义的操作名称,也就是 GetUser,可以省略
  • query 是查询条件
  • variables 是变量,上面的查询中暂时没有使用到变量,所以现在是空对象
  • GraphQL 服务器接收到这个 HTTP 请求后,就会根据查询条件对 Schema 进行解析,也就是根据查询的字段,执行 Schema 中对应字段的 resolver 函数。

    这里需要注意的是,查询(query)字段时,是并行执行,而变更(mutation)字段时,是线性执行,一个接着一个。

    这也是 GraphQL 的强大之处,我们只需要写好 schema 以及 resolver,GraphQL 会自动根据查询语句帮我们实现服务的编排。这也解决了前后端协作中的另一个问题:前端需要聚合多个接口才能获取想要的数据。

    变更 mutation

    前面基本都在说数据的获取,但是任何完整的数据平台也都需要一个改变服务端数据的方法。

    在 RESTful API 中,任何请求都可能最后导致一些服务端副作用,但是约定上建议不要使用 GET 请求来修改数据。GraphQL 也是类似,技术上而言,任何查询都可以被实现为导致数据写入,但 GraphQL 建立了一个规范,任何修改数据的操作都应该使用 mutation 来发送。

    比如创建一个用户,首先 schema 应该使用 Mutation 来定义:

    input UserInput {
      name: String!
      gender: Gender!
    type Mutation {
      createUser(user: UserInput!): User!
      createUserTag(tag: String!): User!
    

    input 表示输入对象,看上去和普通对象一摸一样,除了关键字是 input 而不是 type。它的特别之处在于,输入对象可以用在复杂的参数中,经常是 mutation 的参数,比如上面 createUser 的参数。

    定义了 Mutation 之后,同样需要定义对应的 resolver 函数:

    const resolvers = {
      Query: {
        // ...
      Mutation: {
        createUser: (parent, args, context, info) => {
          const { user: { name, gender } } = args;
          // insert user to db...
          return { name, gender, tags: [] };
        createUserTag: (parent, args, context, info) => {
          const { tag } = args;
          return { name: "Jack", gender: "MALE", tags: [ tag ] }
    

    于是我们就可以像下面这样来请求创建用户的 GraphQL 接口了:

    mutation CreateUser {
      createUser(user: { "name": "Jack", "gender": "MALE" }) {
        gender
    

    或者使用变量:

    mutation CreateUser($user: UserInput!) {
      createUser(user: $user) {
        gender
    

    在开发者工具中,可以在左下角的 QUERY VARIABLES 面板中添加变量:

    关于查询和变更的更多内容,可以参考 GraphQL 的文档:查询和变更

    到此为止,我们已经使用 GraphQL 构建了一个简单的服务端及接口,并且在 playground 中进行了查询和变更。那么如何在前端中使用 GraphQL 接口呢?

    前面我们已经知道 GraphQL 每一次执行,其实是向服务端发起的一个 HTTP 请求。但如果每次我们都自己手动去构建请求参数还是挺麻烦的,好在有很多开源的 GraphQL Client 简化了我们的工作,比较推荐的依旧是 Apollo Client,它支持 React/Vue/Angular 等多种框架。

    初始化项目

    以 React.js 为例,首先初始化一个项目:

    $ npx create-react-app client

    然后安装 GraphQL Client 相关依赖包:

    $ cd client
    $ yarn add apollo-boost @apollo/react-hooks graphql

    由于 create-react-app 默认使用的 yarn,所以我们在前端使用 yarn 安装依赖包。当然也可以使用 npm:npm install apollo-boost @apollo/react-hooks graphql --save。

    创建 Client

    在安装完依赖包之后,就可以创建一个 GraphQL 的 Client,用来向 GraphQL 服务端请求数据。

    参照下述代码修改 src/index.js, 其中 uri 就是我们 GraphQL 服务端的地址,ApolloProvider 中存储了所有 GraphQL 数据,所以建议将其作为应用的根组件。

    // ...
    import ApolloClient from 'apollo-boost';
    import { ApolloProvider } from '@apollo/react-hooks';
    // ...
    const client = new ApolloClient({
      uri: 'http://localhost:4000',
    });
    const Root = () => (
      <ApolloProvider client={client}>
        <App />
      </ApolloProvider>
    ReactDOM.render(<Root />, document.getElementById('root'));

    通常对于一个 React 项目,我们需要用 Redux 来获取数据,需要写各种 actions、dispatch,非常繁琐。但现在使用 GraphQL,一切都变得非常简单!

    @apollo/react-hooks 提供了 useQueryuseMutation 等 hooks 可以让我们非常方便地获取数据。

    import React from 'react';
    import gql from 'graphql-tag';
    import { useQuery } from '@apollo/react-hooks';
    // 定义查询语句
    const GET_USERS = gql`
    query {
      users {
    function Users() {
      // 使用 useQuery hook 获取数据
      const { loading, error, data } = useQuery(GET_USERS);
      if (loading) return 'Loading...';
      if (error) return `Error! ${error.message}`;
      return (
          {data.users.map(user => <p>{user.name}</p>)}
        </div>
    export default Users;

    首先我们定义了一个查询语句,就跟在 playground 中定义的一样,只不过这里需要将其放在 gql 中。然后使用 useQuery 获取数据,useQuery 返回 loading error data 等属性,我们就可以很方便地处理页面的加载状态和接口失败情况。

    在前面也提到了,GraphQL 的查询是可预测的,根据查询条件,我们也可以清楚知道,data 属性一定会有一个 users 字段,且每个 user 有且只有 name 字段。

    需要注意的是,如果在服务端的 GraphQL Schema 中,name 允许为空,则 user.name 则可能为 null;如果 name 不允许为空,则当服务端 user.name 为 null 的时候,服务端就会报错。

    通过 yarn start 启动前端,然后在浏览器中就可以看到如下的页面:

    带参数的查询

    带参数的查询也非常简单,我们只需要在查询语句中定义参数变量名称,并在 useQueryvariables 中把变量传递进去。

    在下面的代码中,GET_USER 这个查询的变量名称是 String 类型的 $userName,且不能为空。通过 variables 传递变量值的时候,我们只需要使用 userName 即可。

    import React from 'react';
    import gql from 'graphql-tag';
    import { useQuery } from '@apollo/react-hooks';
    // 定义查询语句
    const GET_USER = gql`
    query GET_USER($userName: String!) {
      user(name: $userName) {
        gender
    function User() {
      // 使用 useQuery hook 获取数据
      const { loading, error, data } = useQuery(GET_USER, {
        variables: {
          userName: 'Jack',
      });
      if (loading) return 'Loading...';
      if (error) return `Error! ${error.message}`;
      return (
          <p>Name: {data.user.name}</p>
          <p>Gender: {data.user.gender}</p>
          <p>Tags: {data.user.tags.join(',')}</p>
        </div>
    export default User;

    通过前面简单的了解就可以感受到使用 GraphQL,不管是服务端还是前端,写起来都非常简单,因为 GraphQL 以及其生态中的工具帮我们做了很多工作,节省了很多开发成本。

    在开发服务端的时候,我们的 service 可以更原子化,不用关心前端到底需要什么字段,一切都可以面向后端的最佳实践,根据 GraphQL Schema 来编写 service;而前端则可以根据 Schema 来自由组合数据和服务,不必再频繁要求后端增减字段或接口。

    在开发前端的时候,则更简单了,不用再繁琐地重复编写各种 action,直接用 hooks 就能实现数据的获取和更新,也很简单就能知道 loading 和异常状态,简单直接且高效。

    GraphQL Playground 则为我们提供了调试和文档服务。我们可以在 playground 中方便地调试 GraphQL 接口,并且有各种语法提示,不必再使用 postman 等 HTTP 工具去请求接口。同时 playground 还自带了文档功能,其文档就是根据 GraphQL Schema 自动生成的,这样文档可以随着代码实时更新,开发者再也不用花时间去写文档,使用者也能时刻看到最新最准确的文档。

    总的来说,使用 GraphQL 能极大提高开发效率。