上一篇分享了增加将Active Stroage的直接上传功能添加到Rails+GraphQL应用程序的技巧。
现在我们知道如何上传,下一步:通过GraphQL API公开附件的URL
- 处理N+1查询
- 用户能够请求特定的图像变体
N + 1问题:批量加载到救援
让我们首先尝试以天真的方式将avatarUrl字段添加到我们的User类型中:
module Types
class User < GraphQL::Schema::Object
field :id, ID, null: false
field :name, String, null: false
field :avatar_url, String, null: true
def avatar_url
# That's an official way for generating
# Active Storage blobs URLs outside of controllers
Rails.application.routes.url_helpers
.rails_blob_url(user.avatar)
end
end
end
假设我们有一个返回所有用户的端点,例如{ users { name avatarUrl } }。如果您在开发中运行此查询并查看控制台中的Rails服务器日志,您将看到如下内容:
D, [2019-04-15T22:46:45.916467 #2500] DEBUG -- : User Load (0.9ms) SELECT users".* FROM "users"
D, [2019-04-15T22:46:45.919362 #2500] DEBUG -- : ActiveStorage::Attachment Load (0.9ms) SELECT "active_storage_attachments".* FROM "active_storage_attachments" WHERE "active_storage_attachments"."record_type" = $1 AND "active_storage_attachments"."name" = $2 AND "active_storage_attachments"."record_id" = $3 [["record_type", "User"], ["name", "avatar"], ["record_id", 12]]
D, [2019-04-15T22:46:45.922420 #2500] DEBUG -- : ActiveStorage::Blob Load (1.0ms) SELECT "active_storage_blobs".* FROM "active_storage_blobs" WHERE "active_storage_blobs"."id" = $1 [["id", 9]]
D, [2019-04-15T22:46:45.919362 #2500] DEBUG -- : ActiveStorage::Attachment Load (0.9ms) SELECT "active_storage_attachments".* FROM "active_storage_attachments" WHERE "active_storage_attachments"."record_type" = $1 AND "active_storage_attachments"."name" = $2 AND "active_storage_attachments"."record_id" = $3 [["record_type", "User"], ["name", "avatar"], ["record_id", 13]]
D, [2019-04-15T22:46:45.922420 #2500] DEBUG -- : ActiveStorage::Blob Load (1.0ms) SELECT "active_storage_blobs".* FROM "active_storage_blobs" WHERE "active_storage_blobs"."id" = $1 [["id", 10]]
D, [2019-04-15T22:46:45.919362 #2500] DEBUG -- : ActiveStorage::Attachment Load (0.9ms) SELECT "active_storage_attachments".* FROM "active_storage_attachments" WHERE "active_storage_attachments"."record_type" = $1 AND "active_storage_attachments"."name" = $2 AND "active_storage_attachments"."record_id" = $3 [["record_type", "User"], ["name", "avatar"], ["record_id", 14]]
D, [2019-04-15T22:46:45.922420 #2500] DEBUG -- : ActiveStorage::Blob Load (1.0ms) SELECT "active_storage_blobs".* FROM "active_storage_blobs" WHERE "active_storage_blobs"."id" = $1 [["id", 15]]
对于每个用户,我们加载一个ActiveStorage::Attachment
和一个ActiveStorage::Blob
记录:2 * N + 1个记录(其中N是用户数)。
我们已经在“Rails 5.2:Active Storage and beyond”帖子中讨论了这个问题,所以,我不打算在此重复技术细节。
tl; dr对于经典的 Rails应用程序,我们有一个内置的预加载附件范围(例如User.with_attached_avatar)或者可以自己生成范围,了解Active Storage命名内部关联的方式。
GraphQL使预加载数据有点棘手 - 我们事先不知道客户端需要哪些数据,并且不能只添加with_attached_
这就是为什么经典的预压方法(includes
,eager_load
等)并不是建造GraphQL的API非常有帮助。相反,大多数应用程序使用批量加载技术。
在Ruby应用程序中执行此操作的方法之一是通过Shopify 添加graphql-batch
gem。它提供了一个核心API,用于编写具有类似Promise接口的批处理加载器。
虽然默认情况下没有批量加载器包含在gem中,但association_loader
我们可以使用一个示例来完成我们的任务(更确切地说,我们使用这个支持范围和嵌套关联的增强版本)。
让我们用它来解决我们的N + 1问题:
def avatar_url
AssociationLoader.for(
object.class,
# We should provide the same arguments as
# the `preload` or `includes` call when do a classic preloading
avatar_attachment: :blob
).load(object).then do |avatar|
next if avatar.nil?
Rails.application.routes.url_helpers.rails_blob_url(avatar)
end
end
注意:then
我们上面使用的方法不是#yield_self
别名,它是promise.rb
gem提供的API 。
代码看起来有点过载,但它可以工作,并且仅根据用户数量进行3次查询。继续阅读,看看我们如何将其转化为人性化的API。
处理变种
我们希望利用GraphQL的强大功能,并允许客户指定所需的图像变体(例如,拇指,封面等):
从代码的角度来看,我们希望执行以下操作:
user.avatar.variant(:thumb) # == user.avatar.variant(resize_to_fill: [64, 64])
不幸的是,Active Storage还没有变体的概念(预定义的,命名的转换)。当PR(或其变体)合并时,这可能会包含在Rails 6.x(其中x> 0)中。
我们决定不再等待和实现这个功能我们自己:这个小补丁通过@bibendi增加定义YAML文件名为变种的能力:
# config/transformations.yml
thumb:
convert: jpg
resize_to_fill: [64, 64]
medium:
convert: jpg
resize_to_fill: [200, 200]
由于我们对应用程序中的所有附件都具有相同的转换设置,因此这种全局配置对我们很有用。
现在我们需要将此功能集成到我们的API中。
首先,我们在代表特定变体的模式中添加一个枚举类型transformations.yml
:
class ImageVariant < GraphQL::Schema::Enum
description <<~DESC
Image variant generated with libvips via the image_processing gem.
Read more about options here https://github.com/janko/image_processing/blob/master/doc/vips.md#methods
DESC
ActiveStorage.transformations.each do |key, options|
value key.to_s, options.map { |k, v| "#{k}: #{v}" }.join("\n"), value: key
end
end
感谢Ruby的元编程特性,我们可以使用配置对象动态定义我们的类型 - 我们transfromations.yml
和ImageVariant
枚举将始终保持同步!
最后,让我们更新我们的字段定义以支持变体:
module Types
class User < GraphQL::Schema::Object
field :avatar_url, String, null: true do
argument :variant, ImageVariant, required: false
end
def avatar_url(variant: nil)
AssociationLoader.for(
object.class,
avatar_attachment: :blob
).load(object).then do |avatar|
next if avatar.nil?
avatar = avatar.variant(variant) if variant
Rails.application.routes.url_helpers.url_for(avatar)
end
end
end
end
额外奖励:添加字段扩展名
每次我们想要将附件url字段添加到类型时添加这么多代码似乎不是一个优雅的解决方案,是吗?
在寻找更好的选择时,我找到了一个Field Extensions API graphql-ruby
。“看起来就像我在找什么!”,我想。
让我先向您展示最终的字段定义:
field :avatar_url, String, null: true, extensions: [ImageUrlField]
而已!没有更多argument-s和装载机。添加扩展程序使一切都按照我们想要的方式工作!
这是扩展的带注释的代码:
class ImageUrlField < GraphQL::Schema::FieldExtension
attr_reader :attachment_assoc
def apply
# Here we try to define the attachment name:
# - it could be set explicitly via extension options
# - or we imply that is the same as the field name w/o "_url"
# suffix (e.g., "avatar_url" => "avatar")
attachment = options&.[](:attachment) ||
field.original_name.to_s.sub(/_url$/, "")
# that's the name of the Active Record association
@attachment_assoc = "#{attachment}_attachment"
# Defining an argument for the field
field.argument(
:variant,
ImageVariant,
required: false
)
end
# This method resolves (as it states) the field itself
# (it's the same as defining a method within a type)
def resolve(object:, arguments:, **rest)
AssociationLoader.for(
object.class,
# that's where we use our association name
attachment_assoc => :blob
)
end
# This method is called if the result of the `resolve`
# is a lazy value (e.g., a Promise – like in our case)
def after_resolve(value:, arguments:, object:, **rest)
return if value.nil?
variant = arguments.fetch(:variant, :medium)
value = value.variant(variant) if variant
Rails.application.routes.url_helpers.url_for(value)
end
end