xfyuan
xfyuan A Chinese software engineer living and working in Chengdu. I love creating the future in digital worlds, big and small.

GraphQL on Rails——启程

GraphQL on Rails——启程

本文已获得原作者(Dmitry Tsepelev)、(Polina Gurtovaya)和 Evil Martians 授权许可进行翻译。原文是 Rails + React 使用 GraphQL的系列教程第一篇,介绍了以 Rails 作为后端,React + Apollo 作为前端,如何经过基础的配置,构建一个简单图书馆列表页面。

【正文如下】

引言

这是一个在后端使用 Rails、前端使用 React/Apollo 来开发 GraphQL 应用程序的旅行者指导。跟随该系列教程可通过范例学到既有基础的、也有高级的主题内容,让你领略现代技术的威力。

GraphQL 是我们在任何地方(博客、会议、播客,甚至报纸)都能见到的新颖事物之一。听起来你应该抓紧时间,尽快开始以 GraphQL 而非 REST 来重写应用程序,对吧?事实并非如此。记住:没有银弹。在进行决策之前理解该技术的优劣是完全有必要的。

本系列中,我们将给你一个 GraphQL 应用程序开发的简洁指南,不止谈到其优点,也会讨论其注意事项乃至陷阱(当然,还有如何处理它们的方法)。

GraphQL in a nutshell

根据规范,GraphQL是一种查询语言runtime(或执行引擎)。查询语言,按照定义,描述了如何与一个信息系统进行通信。Runtime 则负责实现数据的查询。

每个 GraphQL 应用程序的核心都在于一个 schema:它以有向图的形式描述底层数据。Runtime 必须根据该 schema(及规范中的一些通用规则)来执行查询。这意味着,每个有效的 GraphQL 服务端都以相同的方式运行查询,并以相同的格式返回相同 schema 的数据。换句话说,schema 就是客户端应了解到的关于 API 的一切。

下面是一个简单的 GraphQL 查询的例子:

1
2
3
4
5
6
7
8
9
query getProduct($id: Int!) {
  product(id: $id) {
    id
    title
    manufacturer {
      name
    }
  }
}

让我们来一行一行解析它:

  • 我们定义了一个具名查询(getProduct是操作名),接收单独一个参数($id)。操作名是可选的,但它会对可读性有所帮助,也能用于前端进行缓存。
  • 我们从 schema 的“根”上“选择”了product字段,并传递$id值作为参数。
  • 我们描述了期望获取的那些字段:该场景中,是想要得到 product 的idtitle,以及 manufacturer 的name

本质上,一个查询代表了 schema 的一个子图,这带来了 GraphQL 的第一个好处——我们可以在单个查询中,仅获取自己所需要的数据,也可以获取一次所需的所有数据。

这样,我们就解决了传统 REST API 的一个常见问题——overfetching(过量获取)

另一个关于 GraphQL schema 的明显特性是它们为强类型的(strongly typed):客户端和 runtime 两边都确保了从应用程序的类型系统角度看,所传递的数据是合法的。例如,如果有人错误传递了一个字符串的值作为$id给上面的查询,客户端就会因抛出异常而失败,甚至不会尝试执行查询。

最后但并非最终的一个好处是 schema 的自省:客户端可以从 schema 自身来学习 API,而无需任何额外的文档资源。

那么,我们已经了解了 GraphQL 的不少理论部分。现在该来做一些代码练习了,以确保你不会明早起来就忘掉一切。

What are we going to build?

通过这个系列,我们将构建一个代表“Martian Library”的应用程序——一个影视、书籍及其他与《红色星球》有关的事物的个人在线收藏。

https://cdn.evilmartians.com/front/posts/graphql-on-rails-1-from-zero-to-the-first-query/application-32576ed.png

对于本教程,我们将使用:

  • 后端使用Ruby 2.6 和 Rails 6(RC 版本在此)【译者注:Rails 6 正式版目前已经发布了】
  • 前端使用 Node.js 9+,React 16.3+,和 Apollo(客户端版本 2+),要确保你已经根据指导安装了 yarn。

你可以在这里找到源码——别忘了在首次运行前执行bundle install && yarn installMaster 分支是该项目的当前最新状态。

Setting up a new Rails project

如果阅读本文的时候 Rails 6.0 还没有发布,那么你可能需要先安装 rc 版本:

1
2
3
$ gem install rails --pre
$ rails -v
=> Rails 6.0.0.rc1

现在我们就可以来运行下面这个超级长的rails new命令了:

1
$ rails new martian-library -d postgresql --skip-action-mailbox --skip-action-text --skip-spring --webpack=react -T --skip-turbolinks

比起 Rails 官方的“主厨精选”,我们更喜欢自己来定制:略去所不需要的框架和库,选择 PostgreSQL 作为数据库,以预配置的 Webpacker 来使用 React,去掉了测试(别担心——我们会很快加上 RSpec 的)。

在你开始之前,强烈建议关闭config/application.rb内所有不必要的生成器:

1
2
3
4
5
6
7
config.generators do |g|
  g.test_framework  false
  g.stylesheets     false
  g.javascripts     false
  g.helper          false
  g.channel         assets: false
end

Preparing the data model

我们需要至少两个 model:

  • Item来描述任何我们想要存储在图书馆中的实体(书籍、电影等)。
  • User来代表应用程序里能够管理收藏品中这些 items 的用户。

让我们来生成它们:

1
2
$ rails g model User first_name last_name email
$ rails g model Item title description:text image_url user:references

别忘了添加has_many :items的关联关系到app/models/user.rb

1
2
3
4
# app/models/user.rb
class User < ApplicationRecord
  has_many :items, dependent: :destroy
end

来添加一些预生成的数据到db/seeds.rb

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
# db/seeds.rb
john = User.create!(
  email: "john.doe@example.com",
  first_name: "John",
  last_name: "Doe"
)

jane = User.create!(
  email: "jane.doe@example.com",
  first_name: "Jane",
  last_name: "Doe"
)

Item.create!(
  [
    {
      title: "Martian Chronicles",
      description: "Cult book by Ray Bradbury",
      user: john,
      image_url: "https://upload.wikimedia.org/wikipedia/en/4/45/The-Martian-Chronicles.jpg"
    },
    {
      title: "The Martian",
      description: "Novel by Andy Weir about an astronaut stranded on Mars trying to survive",
      user: john,
      image_url: "https://upload.wikimedia.org/wikipedia/en/c/c3/The_Martian_2014.jpg"
    },
    {
      title: "Doom",
      description: "A group of Marines is sent to the red planet via an ancient " \
                   "Martian portal called the Ark to deal with an outbreak of a mutagenic virus",
      user: jane,
      image_url: "https://upload.wikimedia.org/wikipedia/en/5/57/Doom_cover_art.jpg"
    },
    {
      title: "Mars Attacks!",
      description: "Earth is invaded by Martians with unbeatable weapons and a cruel sense of humor",
      user: jane,
      image_url: "https://upload.wikimedia.org/wikipedia/en/b/bd/Mars_attacks_ver1.jpg"
    }
  ]
)

最后,我们就可以来初始化数据库了:

1
$ rails db:create db:migrate db:seed

现在我们已经往自己的系统里塞入了一些内容,那就来添加访问它们的方式吧!

Adding a GraphQL endpoint

为了“制作”我们的 GraphQL API,将使用graphql-ruby gem:

1
2
3
4
# First, add it to the Gemfile
$ bundle add graphql --version="~> 1.9"
# Then, run the generator
$ rails generate graphql:install

你可能会惊讶于一个最小化的graphql-ruby应用程序所需文件的数量:如下的样板就是我们为上述所有物品所支付的代价。

https://cdn.evilmartians.com/front/posts/graphql-on-rails-1-from-zero-to-the-first-query/generator-d6a5280.png

首先,我们来看看 schema,martian_library_schema.rb

1
2
3
4
5
# app/graphql/martian_library_schema.rb
class MartianLibrarySchema < GraphQL::Schema
  query(Types::QueryType)
  mutation(Types::MutationType)
end

该 schema 宣布了所有 query 都应该在Types::QueryType,而所有 mutation 都应该在Types::MutationType。我们将在本系列教程的第二部分来深入探讨 mutation。本文的目标则是学习如何编写和执行 query。因此,让我们打开types/query_type.rb 类——它是所有 query 的入口。里面有什么呢?

1
2
3
4
5
6
7
8
9
10
11
12
13
14
# app/graphql/types/query_type.rb
module Types
  class QueryType < Types::BaseObject
    # Add root-level fields here.
    # They will be entry points for queries on your schema.

    # TODO: remove me
    field :test_field, String, null: false,
      description: "An example field added by the generator"
    def test_field
      "Hello World!"
    end
  end
end

这证明了QueryType就是一个通用 type:其继承于Types::BaseObject(我们会把它用作所有 type 的基本类),并且它有 field 定义——我们数据图的节点。唯一使得QueryType有所不同的是 GraphQL 需要这个 type 必须存在(而mutationsubscription 两种 type 是可选而非必须)。

注意到上面的代码实际上仅是一个”hello world”了吗?在继续往下走之前(且大量的代码使你厌倦),我们会向你展示如何在浏览器中获取该“hello world”的内容。

让我们来看下生成器已经往config/routes.rb中添加了什么:

1
2
3
4
5
# config/routes.rb
Rails.application.routes.draw do
  mount GraphiQL::Rails::Engine, at: "/graphiql", graphql_path: "/graphql" if Rails.env.development?
  post "/graphql", to: "graphql#execute"
end

Mount 的GraphiQL::Rails::Engine让我们能使用一个称为 GraphiQL 的 web 界面来测试自己的 query 和 mutation。如前所述,schema 是可被检查的,而 GraphiQL 则使用这个特性为我们来构建交互文档。来试一试吧!

1
2
# Let's run a Rails web server
$ rails s

在浏览器中打开 http://localhost:3000/graphiql:

https://cdn.evilmartians.com/front/posts/graphql-on-rails-1-from-zero-to-the-first-query/graphiql-4633e9d.png

在左侧窗口,你可以输入一个 query 来执行,然后点击“play”按钮(或按下Ctrl/Cmd+Enter),即可在右侧窗口看到响应结果。点击右上角的“Docs”链接,你就可以浏览 schema。

来看下日志——我们想要知道当按下执行按钮时发生了什么。

https://cdn.evilmartians.com/front/posts/graphql-on-rails-1-from-zero-to-the-first-query/execute_log-e654371.png

请求被发送到GraphlController,其也是由graphql gem 的生成器添加到应用程序的。

看一眼GraphlController#execute方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
# app/controllers/graphql_controller.rb
def execute
  variables = ensure_hash(params[:variables])
  query = params[:query]
  operation_name = params[:operationName]
  context = {
    # Query context goes here, for example:
    # current_user: current_user,
  }
  result = GraphqlSchema.execute(
    query,
    variables: variables,
    context: context,
    operation_name: operation_name
  )
  render json: result
rescue StandardError => e
  raise e unless Rails.env.development?

  handle_error_in_development e
end

该方法调用了GraphqlSchema#execute方法,以如下参数:

  • queryvariables分别代表一个 query 字符串和客户端发送的参数;
  • context是一个任意 hash,在 query 执行的任何地方都是可用的;
  • operation_name从进来的请求中取出一个命名操作来执行(可以为空)。

所有的魔法都发生在这个方法内:它解析 query,检测所有将被用来构建响应的 type,并决定所有被请求到的字段。我们唯一需要做的事就是定义这些 type,并声明字段应该被怎样决定。

What’s in the Martian Library?

让我们从“hello world”转到更真实的东西:从Types::QueryType移除范例内容并注册一个称为:items的字段,其将返回所有图书馆的 items。我们也需要为该字段添加一个 resolver 方法(resolver 方法名必须匹配字段名):

1
2
3
4
5
6
7
8
9
10
11
12
13
# app/graphql/types/query_type.rb
module Types
  class QueryType < Types::BaseObject
    field :items,
          [Types::ItemType],
          null: false,
          description: "Returns a list of items in the martian library"

    def items
      Item.all
    end
  end
end

每个字段定义都包含一个名称,一个其结果类型,及一些选项。:null是需要的,必须设为true或者false。我们也定义了可选的:description——为字段添加易于阅读的信息是一种好的实践:它会被自动添加到文档中,为开发者提供更多相关信息。对于结果类型的数组表示,[Types::ItemType],意味着字段的值必须是一个数组,且其每个元素都必须是Types::ItemType类型。

但我们还没有定义ItemType,对吧?幸运的是,graphql gem 给了一个方便的生成器:

1
$ rails g graphql:object item

现在我们就可以修改新创建的app/graphql/types/item_type.rb为想要的样子了。

1
2
3
4
5
6
7
8
9
# app/graphql/types/item_type.rb
module Types
  class ItemType < Types::BaseObject
    field :id, ID, null: false
    field :title, String, null: false
    field :description, String, null: true
    field :image_url, String, null: true
  end
end

如上所见,我们在ItemType中暴露了三个字段:

  • 非 null 的字段,idtitle
  • 可为 null 的字段description

我们的执行引擎解析决定字段时是使用如下算法(略有简化):

  • 首先,它在 type 类自身内查找同名定义的方法(如同前面我们在QueryType中对items做的一样);我们可以使用object方法来访问被解析决定的对象。
  • 如果没有找到这样定义的方法,它就尝试在object上去调用同名方法。

我们在 type 类中没有定义任何方法,因此假定底层实现了所有字段的方法。

回到http://localhost:3000/graphiql,执行如下 query,确认在响应中获取到了所有 items 的列表:

1
2
3
4
5
6
7
{
  items {
    id
    title
    description
  }
}

到目前为止,我们还没有添加任何体现 graph 威力的功能——当前的 graph 深度只有一层。让我们来添加一个非初始节点到ItemType上,让 graph 复杂一点。比如,添加一个user字段来代表 item 的创建者:

1
2
3
4
5
6
7
# app/graphql/types/item_type.rb
module Types
  class ItemType < Types::BaseObject
    # ...
    field :user, Types::UserType, null: false
  end
end

重复使用相同的生成器来创建一个新的 type 类:

1
$ rails g graphql:object user

这一次我们还想要添加一个计算字段——full_name

1
2
3
4
5
6
7
8
9
10
11
12
13
# app/graphql/types/user_type.rb
module Types
  class UserType < Types::BaseObject
    field :id, ID, null: false
    field :email, String, null: false
    field :full_name, String, null: false

    def full_name
      # `object` references the user instance
      [object.first_name, object.last_name].compact.join(" ")
    end
  end
end

使用如下 query 来跟 items 一起获取 users:

1
2
3
4
5
6
7
8
9
10
{
  items {
    id
    title
    user {
      id
      email
    }
  }
}

到这一步时,我们就可以把目光从后端移到前端了。让我们来为这个 API 构建一个客户端吧!

Configuring the frontend application

正如已经提到的,我们推荐你安装Apollo 框架来处理客户端的 GraphQL。

要让一切顺利运转,我们需要安装所有需要的依赖库:

1
$ yarn add apollo-client apollo-cache-inmemory apollo-link-http apollo-link-error apollo-link graphql graphql-tag react-apollo

来看下所安装的一些库:

  • 我们使用graphql-tag构建第一个 query。
  • apollo-client是一个通用的、与框架无关的库,来执行并缓存 GraphQL 请求。
  • apollo-cache-inmemory是一个 Apollo 缓存的存储实现。
  • react-apollo包含一套 React 组件来展示数据。
  • apollo-link与其他 linksapollo-client的操作(你可以在这里找到更多细节)实现了一个中间件模式。

现在我们需要为前端应用程序创建一个入口。从packs目录移除hello_react.jsx并添加index.js

1
$ rm app/javascript/packs/hello_react.jsx && touch app/javascript/packs/index.js

为了调试目的,加入如下内容:

1
2
// app/javascript/packs/index.js
console.log('👻');

生成一个用于前端的 controller:

1
$ rails g controller Library index --skip-routes

更新app/views/library/index.html.erb以包含 React 根元素及一个到 packjavascript_pack_tag

1
2
3
<!-- app/views/library/index.html.erb -->
<div id="root" />
<%= javascript_pack_tag 'index' %>

最后,在config/routes.rb注册一个新的路由:

1
2
# config/routes.rb
root 'library#index'

重启 Rails server,确认看到那个 👻 出现在浏览器的 console 中。

Configuring Apollo

创建一个文件来存储 Apollo 的配置:

1
$ mkdir -p app/javascript/utils && touch app/javascript/utils/apollo.js

该文件中,我们想要配置 Apollo 应用的两个核心东西,客户端和缓存(或更准确地说,是创建二者的函数):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// app/javascript/utils/apollo.js

// client
import { ApolloClient } from 'apollo-client';
// cache
import { InMemoryCache } from 'apollo-cache-inmemory';
// links
import { HttpLink } from 'apollo-link-http';
import { onError } from 'apollo-link-error';
import { ApolloLink, Observable } from 'apollo-link';
export const createCache = () => {
  const cache = new InMemoryCache();
  if (process.env.NODE_ENV === 'development') {
    window.secretVariableToStoreCache = cache;
  }
  return cache;
};

让我们花一点时间来看看缓存是如何工作的。

每个 query 响应结果都被放到缓存中(相应的请求通常被用做缓存的 key)。在进行请求之前,apollo-client确保响应结果还未被缓存,而如果其已被缓存——请求就不会被执行。该行为是可配置化的:比如,我们可以为某一个特别请求关闭缓存,或者让客户端查找一个不同的 query 的缓存数据。

关于缓存机制,对本教程而言,一个我们需要了解的重要事情是,默认情况下,缓存的 key 是id__typename的组合串。因此,获取同样对象两次也只会导致一个请求。

回到代码上来。由于我们使用 HTTP POST 作为传输,所以需要附带一个适当的 CSRF token 到每个请求上以通过 Rails 中的 forgery protection check。我们可以从meta[name="csrf-token"]拿到它(其是通过<%= csrf_meta_tags %>生成的):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
// app/javascript/utils/apollo.js
// ...
// getToken from meta tags
const getToken = () =>
  document.querySelector('meta[name="csrf-token"]').getAttribute('content');
const token = getToken();
const setTokenForOperation = async operation =>
  operation.setContext({
    headers: {
      'X-CSRF-Token': token,
    },
  });
// link with token
const createLinkWithToken = () =>
  new ApolloLink(
    (operation, forward) =>
      new Observable(observer => {
        let handle;
        Promise.resolve(operation)
          .then(setTokenForOperation)
          .then(() => {
            handle = forward(operation).subscribe({
              next: observer.next.bind(observer),
              error: observer.error.bind(observer),
              complete: observer.complete.bind(observer),
            });
          })
          .catch(observer.error.bind(observer));
        return () => {
          if (handle) handle.unsubscribe();
        };
      })
  );

来看下我们如何记录错误日志:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// app/javascript/utils/apollo.js
//...
// log erors
const logError = (error) => console.error(error);
// create error link
const createErrorLink = () => onError(({ graphQLErrors, networkError, operation }) => {
  if (graphQLErrors) {
    logError('GraphQL - Error', {
      errors: graphQLErrors,
      operationName: operation.operationName,
      variables: operation.variables,
    });
  }
  if (networkError) {
    logError('GraphQL - NetworkError', networkError);
  }
})

生产环境上,更好的做法是使用异常追踪服务(exception tracking service)(比如,Sentry 或者 Honeybadger):只用覆盖logError函数把错误发送到外部系统即可。

曙光在前了——让我们把入口告知客户端以进行查询:

1
2
3
4
5
6
7
// app/javascript/utils/apollo.js
//...
// http link
const createHttpLink = () => new HttpLink({
  uri: '/graphql',
  credentials: 'include',
})

最后,我们就可以创建 Apollo 客户端的实例了:

1
2
3
4
5
6
7
8
9
10
11
12
// app/javascript/utils/apollo.js
//...
export const createClient = (cache, requestLink) => {
  return new ApolloClient({
    link: ApolloLink.from([
      createErrorLink(),
      createLinkWithToken(),
      createHttpLink(),
    ]),
    cache,
  });
};

The very first query

我们将使用provider pattern来把客户端实例传给 React 组件:

1
$ mkdir -p app/javascript/components/Provider && touch app/javascript/components/Provider/index.js

这是我们第一次使用react-apolloApolloProvider组件:

1
2
3
4
5
6
7
8
9
10
// app/javascript/components/Provider/index.js
import React from 'react';
import { ApolloProvider } from 'react-apollo';
import { createCache, createClient } from '../../utils/apollo';

export default ({ children }) => (
  <ApolloProvider client={createClient(createCache())}>
    {children}
  </ApolloProvider>
);

修改index.js以使用新创建的 provider:

1
2
3
4
5
6
// app/javascript/packs/index.js
import React from 'react';
import { render } from 'react-dom';
import Provider from '../components/Provider';

render(<Provider>👻</Provider>, document.querySelector('#root'));

如果你使用了Webpacker v3,则需要导入babel-polyfill以用上诸如 async/await等很酷的 JavaScript 特性。不用担心 polyfill 的大小。babel-preset-env会帮你移除掉所不需要的一起。

我们来创建一个Library组件,在页面上展示 items 的列表:

1
$ mkdir -p app/javascript/components/Library && touch app/javascript/components/Library/index.js

我们会使用react-apolloQuery组件,接收query字符串作为 property 以获取所 mount 的数据:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
// app/javascript/components/Library/index.js
import React from 'react';
import { Query } from 'react-apollo';
import gql from 'graphql-tag';

const LibraryQuery = gql`
  {
    items {
      id
      title
      user {
        email
      }
    }
  }
`;

export default () => (
  <Query query={LibraryQuery}>
    {({ data, loading }) => (
      <div>
        {loading
          ? 'loading...'
          : data.items.map(({ title, id, user }) => (
              <div key={id}>
                <b>{title}</b> {user ? `added by ${user.email}` : null}
              </div>
            ))}
      </div>
    )}
  </Query>
);

我们可以通过相应的loadingdata property 分别访问载入状态和已加载数据(使用所谓的render-props 模式传递)。

别忘了把组件添加到主页面上:

1
2
3
4
5
6
7
8
9
10
11
12
// app/javascript/packs/index.js
import React from 'react';
import { render } from 'react-dom';
import Provider from '../components/Provider';
import Library from '../components/Library';

render(
  <Provider>
    <Library />
  </Provider>,
  document.querySelector('#root')
);

如果你刷新页面,将会看到 items 列表,以及添加它们的用户的 email:

https://cdn.evilmartians.com/front/posts/graphql-on-rails-1-from-zero-to-the-first-query/items-b791b9f.png

祝贺你!你刚刚迈出了通向 GraphQL 的第一步。很棒!

…And the very first problem

一切看起来都工作得很好,但来看一眼我们的服务端日志:

https://cdn.evilmartians.com/front/posts/graphql-on-rails-1-from-zero-to-the-first-query/n_plus_one-77d1121.png

SQL 查询SELECT * FROM users WHERE id = ?被执行了四次,意味着我们撞上了著名的 N+1 问题——服务端对集合中的每个 item 都进行了一次查询,以获取相应的用户信息。

在修复这个问题之前,我们需要确保进行代码调整是安全的,不会搞坏任何东西——所以,来写测试吧,少年!

Writing some specs

现在该来安装配置 RSpec 了,更准确地说,是rspec-rails gem:

1
2
3
4
# Add gem to the Gemfile
$ bundle add rspec-rails --version="4.0.0.beta2" --group="development,test"
# Generate the initial configuration
$ rails generate rspec:install

为了易于生成测试数据,也安装上 factory_bot

1
$ bundle add factory_bot_rails --version="~> 5.0" --group="development,test"

为了让 factory 方法(createbuild等)在测试中全局可见,添加config.include FactoryBot::Syntax::Methodsrails_helper.rb中。

由于我们在添加 Factory Bot 之前就创建了 model,所以我们得手动生成 factory。单独创建一个文件,spec/factories.rb,如下:

1
2
3
4
5
6
7
8
9
10
11
12
# spec/factories.rb
FactoryBot.define do
  factory :user do
    # Use sequence to make sure that the value is unique
    sequence(:email) { |n| "user-#{n}@example.com" }
  end

  factory :item do
    sequence(:title) { |n| "item-#{n}" }
    user
  end
end

现在已经准备好写我们的第一个测试了。来为QueryType创建一个 spec 文件:

1
2
$ mkdir -p spec/graphql/types
$ touch spec/graphql/types/query_type_spec.rb

最简单的 query 测试,就像下面这样:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
# spec/graphql/types/query_type_spec.rb
require "rails_helper"

RSpec.describe Types::QueryType do
  describe "items" do
    let!(:items) { create_pair(:item) }

    let(:query) do
      %(query {
        items {
          title
        }
      })
    end

    subject(:result) do
      MartianLibrarySchema.execute(query).as_json
    end

    it "returns all items" do
      expect(result.dig("data", "items")).to match_array(
        items.map { |item| { "title" => item.title } }
      )
    end
  end
end

首先,我们创建在数据库中创建一对 items。然后,定义了要被测试的 query 和subject(result),后者通过调用MartianLibrarySchema.execute方法所得到。还记得我们在GraphqlController#execute那里有一行类似的代码吗?

这个用例非常简单:我们对execute的调用既没有传递variables也没有传递context,当然,有需要的时候我们显然可以这么做。

现在,我们就有足够自信来修复上面的 N+1 问题了!

GraphQL vs. N+1 problem

最简单的避免 N+1 问题的方式是使用 eager loading。我们这里,需要在进行查询以获取QueryType中的 items 时预加载用户:

1
2
3
4
5
6
7
8
9
10
# /app/graphql/types/query_type.rb
module Types
  class QueryType < Types::BaseObject
    # ...

    def items
      Item.preload(:user)
    end
  end
end

这个方案在简单的场景下是有用的,但并非十分高效:比如,如下代码也会预加载用户,即使客户端不需要它们时:

1
2
3
items {
  title
}

要讨论解决 N+1 问题的其他方式,值得单独写一篇文章,已经超出了本文的范畴。

大多数解决方案都不外乎以下两种:

本文就到这儿了!我们学习了关于 GraphQL 的很多东西,完成了配置后端和前端应用程序的常规工作,进行了第一个查询,甚至还发现并修复了第一个 bug。而这只是我们旅程中微小的一步(尽管文章的篇幅并不微小)。我们会很快回来的,届时将推出如何使用 GraphQL 的 mutation 来操作数据,以及 subscription 来使数据保持最新的内容。敬请关注!

Rating:

comments powered by Disqus