Active Storage 遇见GraphQL :公开附件的URL

上一篇分享了增加将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_到每个Active Record集合(因为当我们不需要这些数据时会增加额外的开销)。

这就是为什么经典的预压方法(includeseager_load等)并不是建造GraphQL的API非常有帮助。相反,大多数应用程序使用批量加载技术。

在Ruby应用程序中执行此操作的方法之一是通过Shopify 添加graphql-batchgem。它提供了一个核心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.rbgem提供的API 。

代码看起来有点过载,但它可以工作,并且仅根据用户数量进行3次查询。继续阅读,看看我们如何将其转化为人性化的API。

处理变种

我们希望利用GraphQL的强大功能,并允许客户指定所需的图像变体(例如,拇指,封面等):

API示例

从代码的角度来看,我们希望执行以下操作:

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.ymlImageVariant枚举将始终保持同步!

image.png

最后,让我们更新我们的字段定义以支持变体:

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

你可能感兴趣的:(Active Storage 遇见GraphQL :公开附件的URL)