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_<smth>到每个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的强大功能,并允许客户指定所需的图像变体(例如,拇指,封面等):

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

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

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 216,039评论 6 498
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 92,223评论 3 392
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 161,916评论 0 351
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 58,009评论 1 291
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 67,030评论 6 388
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 51,011评论 1 295
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 39,934评论 3 416
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 38,754评论 0 271
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 45,202评论 1 309
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 37,433评论 2 331
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 39,590评论 1 346
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 35,321评论 5 342
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 40,917评论 3 325
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 31,568评论 0 21
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 32,738评论 1 268
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 47,583评论 2 368
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 44,482评论 2 352

推荐阅读更多精彩内容