GraphQL Ruby和實際開發
这篇文章是GLOBIS 2020年度圣诞日历的第19天的文章。
我们在开发新的服务时,后端使用Ruby on Rails + GraphQL,前端使用React。在后端,我们使用GraphQL Ruby作为一种库。我将写下我们在实际开发中使用GraphQL Ruby的情况。
为什么选择采用GraphQL + React?
在我们目前正在开发的新服务中,我们选择在服务器端使用GraphQL,在前端使用React.js。
在Globevest中,我们在服务端主要采用React.js作为前端技术,以确保在开发启动期间有足够的知识和资源支持。对于服务器端,我们使用Ruby on Rails,并实施了基于Swagger的API和基于Grape gem的REST类API。然而,我们遇到了一些与REST API相关的问题,以及从启动时就存在的Ruby on Rails视图和无法维护的Swaggerfile之间的分离问题。
在Globis的学习平台和Globis Unlimited中,我们部分地积极使用GraphQL。我们在前端领域积累了一定的知识。我们意识到GraphQL与Apollo client的结合与TS具有很高的亲和性,并且前端开发体验得到了好评。手动构建可变的前端数据可以加速开发过程。
考虑到使用 Swagger 和使用 GraphQL 的优点和缺点进行比较,采用 GraphQL 的方式更有效率地设计了API,这是我们的选择。
免责声明
我們在此提及,技術選擇是在2019年8月進行的。如果在2020年12月之後進行技術選擇,可能需要仔細考慮是否要採用這種結構。
关于库的结构组成
我们过去的内部服务中,前端和服务器端的代码仓库是分别的,但是我们向前端工程师们提出了很大的要求,要求他们将其改为单一代码库。
有两个原因。第一个原因是,如果像现在这样前端和服务器分工合作,几乎所有的沟通成本都将作为服务器和前端接口的沟通成本支付,将这个成本压缩到一个仓库中,以达到最小化的目的。第二个原因是,为了最小化由前端和服务器部署时间差引起的前端错误以及部署时间协调的沟通成本。然而,对于第二个原因,对于现有服务器和服务进行SPA转换等情况,由于服务器稳定,即使前端仓库分开,开发速度也可以得到充分的维持。 Monorepo是为了解决新产品的问题,因此在参考本文时,请仔细考虑目标服务是新服务还是现有服务(接口是否不稳定,接口是否稳定)。
实际的存储库结构
关于实际的存储库结构,如下所示。
.
├── CHANGELOG.md
├── Dockerfile
├── Gemfile
├── Gemfile.lock
├── Procfile
├── README.md
├── app/
├── app.json
│ ├── controllers/
│ ├── forms/
│ ├── graphql/
│ │ ├── mutations/
│ │ ├── resolvers/
│ │ └── types/
│ ├── jobs/
│ ├── mailers/
│ ├── models/
│ ├── policies/
│ ├── uploaders/
│ └── views/
├── bin/
├── buildspec.yaml
├── codegen.yml
├── config/
│ ├── environments/
│ ├── initializers/
│ ├── locales/
│ └── settings/
├── config.ru
├── db/
│ ├── migrate/
│ └── seeds/
├── docker-compose.yml
├── lib/
│ ├── assets/
│ └── tasks/
├── node_modules/
├── package.json
├── packages/ ---------------------------- (1)
│ ├── controlpanel/
│ │ ├── index.js
│ │ ├── node_modules/
│ │ ├── package.json
│ │ └── webpack.config.ts
│ └── client/
│ ├── App.tsx
│ ├── api/
│ ├── assets/
│ ├── babel.config.js
│ ├── components/
│ ├── constants/
│ ├── containers/
│ ├── graphql/
│ ├── index.html
│ ├── index.less
│ ├── index.tsx
│ ├── node_modules/
│ ├── package.json
│ ├── routes/
│ ├── test-helpers/
│ ├── types/
│ ├── utils/
│ └── webpack.config.ts
├── prettier.config.js
├── public/
│ ├── packs/
│ ├── static/
│ │ └── images
│ └── uploads/
├── regconfig.json
├── renovate.json
├── schema.graphql
├── schema.json
├── ship.config.js
├── spec/
├── tsconfig.json
├── vendor/
└── yarn.lock
(1)使用yarn workspace,我们停止使用sprockets,并使用前端技术栈来管理前端资源。controlpanel目录负责Rails创建的管理界面的资源,client目录负责服务端的资源。
关于GraphQL Ruby的目录结构
GraphQL的目录结构如下所示。
app/graphql
├── schema.rb
├── mutations/
├── resolvers/
└── types/
开始操作后的更改内容
在「入门」的「构建模式」部分(https://graphql-ruby.org/getting_started#build-a-schema),有示例代码。根据这段代码,似乎要直接在query_type.rb中定义根类型,但很快就会发现这种设计是有缺陷的。如果是一个简单的博客系统,允许评论之类的功能,也许这种设计还可以接受,但实际上,我们所维护的系统更加复杂。
查询类型
关于QueryType的定义如下,采用了简单明了的定义。
虽然自定义解析器并不被官方推荐,但它在提高可读性和防止查询类型膨胀方面具有重要的优点,因此我们决定进行实现。
module Types
class QueryType < Types::BaseObject
field :user, resolver: Resolvers::UserResolver
field :hoge, resolver: Resolvers::HogeResolver
field :huga, resolver: Resolvers::HugaResolver
end
end
解决者
解析器目录
module Resolvers
class UserResolver < Resolvers::BaseResolver
description 'Find an User by ID. Require ID'
argument :id, ID, required: true
type Types::UserType, null: false
def resolve(id:)
_class_name, id = GraphQL::Schema::UniqueWithinType.decode(id)
User.find!(id)
end
end
end
种类
在这些类型中,我们放置了用户定义的类型,并且其定义如下所示。为了应对 N+1 问题以及相关的授权设置,我们将在后文中提及,请参考。
module Types
class UserType < Types::BaseObject
field :id, ID, null: false
field :name, String, null: false
field :comments, Types::Comments.connection_type, null: false do
argument :comment_id, ID, required: false, loads: Types::CommentType, as: :comment
end
def comments(comment_id:)
# comments の実装
end
end
end
模式优先的开发
GraphQL Ruby采用了代码优先的方法,但在我们的实际开发中,我们使用基于架构的前端和后端开发,采用了架构优先的方法。
由于这是一个新的开发项目,基本上在增加功能方面,无论是后端还是前端都需要进行工作。在开发的初期,我们会协作编辑schema.graphql,并确定应该达到的模式,这将涉及前端和后端的合作。schema.graphql 是一个文本文件,可以在 GitHub 上轻松查看,因此易于形成共识。
以前,我们在聊天中进行了一次会议,会议形式是要在〇〇类型中添加〇〇字段,但是实际上以编写代码的形式进行使我们更容易共享认知和整体感知。
在开发中,schema.graphql 是最抽象的部分,前端和后端都会朝着这个抽象方向进行实现。
基于本地文化的传统和价值观,制定基本的设计原则来建立数据库的结构。
我写了GraphQL模式是一个抽象的东西,被客户端和后端所依赖的。在模式设计中,向抽象方向靠拢是很重要的。比如说,如果有一个叫UserType的类型定义,在后端中可能会有
# Table name: users
#
# id :bigint(8) not null, primary key
# name :string(255) not null
# role :integer(4) default("member"), not null
# Table name: user_passwords
#
# id :bigint(8) not null, primary key
# email :string(255) not null
# encrypted_password :string(255) default(""), not null
尽管使用了各种各样的表格(模型)进行组合,
在使用GraphQL时,我们应该将UserType简化为一个,以使客户端更直观。
module Types
class UserType < Types::BaseObject
field :id, ID, null: false
field :email, String, null: true
field :name, String, null: false
field :role, Types::UserRoleType, null: false
通过这样做,客户可以直观地发出查询,而无需关注数据库的结构。
针对N+1问题的具体解决方法。
在GraphQL Ruby中解决N+1问题,有一个著名的解决方案叫做graphql-batch,但是我们使用的是一个名为batch-loader的Gem。
原因是graphql-batch依赖于一个不太维护的Gem叫做Promise,而batch-loader是一个依赖较少、实际代码基础较小且易于理解的Gem。
该机制基于延迟评估的原则,通过以Proc#source_location作为键来缓存对函数的调用,并同时执行值的读取和缓存。
对于实际操作,我推荐Gem的作者提供的易于理解的解释。 https://speakerdeck.com/exaspark/batching-a-powerful-way-to-solve-n-plus-1-queries
具體的發生N+1問題的部分實例和應對方法,舉例如下:
例如有以下這種Type:
module Types
class FavoriteType < Types::BaseObject
field :id, ID, null: false
field :user, Types::UserType, null: false
field :post, Types::PostType, null: false
我认为您将意识到内部是一个关系表,但是持有返回与其他不同表相关的Type的字段的Type,当被从某处以集合的形式调用时,很容易产生N+1问题。
重要的是要意识到无法从Type的代码中知道如何调用此Type。
在GraphQL Ruby类型定义中,当调用方以集合的形式调用时,更容易注意到N+1问题,但是实际解决N+1问题的实现最好放在被调用的Type内部。将类型连接多个表的信息仅有该类型自己知道,这将使知识集中在一起的代码。
在Type作为集合从外部调用时,考虑对数据库的访问将会很有帮助。
如果使用BatchLoader的话,解决方案的代码如下所示:
def user
BatchLoader.for(object.user_id).batch do |user_ids, loader|
User.where(id: user_ids).each { |user| loader.call(user.id, user) }
end
end
記法可能有些怪癖,但在程式區塊內可以自由進行preload等操作,因此具有彈性。
作為困難點,它依賴於源代碼的位置,所以如果使用者希望方便地修改,則需要進行其他的改進工作。目前,在Types文件夾中存在許多類似上述的代碼。
對於認可的觀點
使用GraphQL::Pro
graphql-ruby有一个付费的pro许可证,作为其中一个功能,它支持与Pundit gem的集成。为了成功地利用这个集成,需要一些小窍门。
ObjectId的序列化
在GraphQL::Pro中,可以将Policy文件应用到ActiveRecord实例上作为pundit的集成。在相关文档的”Authorizing Loaded Objects”部分中,有一句表述为”Mutations can automatically load and authorize objects by ID using the loads: option”,通过在loads选项中传递类型定义,可以自动应用指定的Pundit Policy文件。
为了使用此集成,您需要直接从对象ID分配ActiveRecord实例,但默认设置下,ActiveRecord的ID会直接传递给客户端,并且在服务器端查询时使用这个ID。由于需要仅通过ID唯一地分配ActiveRecord实例,因此需要在ID中存储所有这些信息。
如果有一个能够为每个模型分配唯一 ID 的系统,则不需要这样做,但由于我们当前是通过 ActiveRecord 连接到 MySQL 数据库,因此这是不现实的。
在 graphql-ruby 存储库的 object_identification.md 文档中,您可以找到示例代码,通过参考这些代码,将 ActiveRecord 的类型名称和 ID 结合起来进行序列化和反序列化,从而获得一个用作全局系统唯一标识符的值。
在中国家常用的一个选择是:虽然存在一个问题,即”option”只能在input类型的字段中工作,但是当我想要在普通字段的argument中使用从该ObjectId传递给pundit的方法时,我找到了一个问题,所以我发送了一个补丁。这样一来,即使不特意定义InputType,也可以使用loads选项。
module Types
class UserType < Types::BaseObject
field :finished, Boolean, null: false do
argument :courseid, ID, required: false, loads: Types::CourseType
end
错误通知
首先,我们将确保在查询QueryType的字段时不会产生错误。我们将最小化授权错误,并基本上采用缩小范围的方式进行处理。
GraphQL的好处之一是可以根据架构自由组装。然而,如果设置了在调用〇〇Type的〇〇field时可能发生错误的状态,查询构建方必须记住这一点。这样一来,即使自动生成的文档也变得无用,前端无法实现舒适的开发目标。
因此,通常情况下,会返回错误的是Mutation。
我們基本上使用GraphQL::Execution::Errors來通過rescue_from通知錯誤。在這篇文章中,我們參考了這些方式並使用此文章中新增的功能來返回錯誤。
class MyProductSchema < GraphQL::Schema
use GraphQL::Execution::Errors
rescue_from ActiveRecord::RecordInvalid do |err, _obj, _args, _ctx, _field|
raise GraphQL::ExecutionError.new(
err,
{
extensions:
{
code: 'INVALID_INPUT',
exception: { **err.record&.errors&.details, object: err.record.class&.name },
},
},
)
end
在公式的错误处理文章中,也提到了GraphQL::Execution::Errors成为默认选项。
想要尝试的事情
虽然我们实际上还没有实施,但是有一种尝试的思路,就像以下的文章中所提到的,将Mutation的返回值设为QueyType,让客户端可以自由地组合返回值。
请提供原文并给出一个选项。
在Payload上添加refetch field来映射QueyType。
type CreateIssuePayload {
clientMutationId: String
refetch: Query!
}
在GraphQL-Ruby中可以通过以下方式实现对此的重新描述:
module Mutations
class CreateIssue < BaseMutation
argument :title, String, required: true
argument :description, String, required: true
field :refetch, Types::QueryType, null: false
def resolve(title:, description:)
# Mutationの実装
{refetch: Types::QueryType }
end
end
end
end
由於客戶端可以自由設計Mutation的返回值,因此可以減少調用函數的次數。
总之(Chinese)
GraphQL-Ruby是一个规模庞大且复杂的Gem。维护者们非常活跃,每天都在添加新功能。我们认为仍有许多功能我们自己还没有完全利用。我们的目标仅仅是”使产品开发更容易”,所以我们希望按照该目标进行实施。