Rails Webook

自社のECを開発している会社で働いています。Rails情報やサービスを成長させる方法を書いていきます

Rails5.2から入ったActiveStorageのソースコードを読んでみた

f:id:nipe880324:20180818220123p:plain:w420

Active Storageの実装にいくつか興味があるところがあったので、いくつかピックアップしてソースコードリーディングをしてみました。
ライブラリということもあり、ビジネスロジックのややこいところもないため、シンプルで読みやすいコードでしたのでぜひ興味があるかたは読んでみてください。

ソースコードはRails 5.2.1時点のものです。
読む際は、Active Storageの概要 | Rails ガイドを軽く読んでおいて挙動を軽く確認しておくと読みやすいと思います。

1. Active Storageのディレクトリ構成

Active Storageの主なディレクトリ構成は次のようになっています。
app配下では、Active StorageをRailsで使うために必要なコントローラー、モデル、ジョブ、Javascriptが実装されています。
lib配下では、ほぼRailsに依存しない形で、ActiveStorageモジュールが定義されています。
このモジュール内では、主に下記4機能を実装しています。

  • Attached: has_one_attached, has_many_attachedのマクロを実装
  • Service: ファイルをストラージ(Disk, S3, GCS, AzureStorageなど)にアップロードやダウンロード、削除する機能を実装
  • Analyzer: blobからmetadataを取得する機能を実装
  • Previewer: 画像以外のファイル(PDFやVideoなど)を表示する機能を実装(※内部的にはプレビュー用のツールのコマンドをラップしているだけ)

Active Storageの主要なディレクトリ構成

activestorage
├── app
│   ├── controllers/active_storage/ : ActiveRecord::Blobへの参照やダイレクトアップロードの署名つきURL取得などのコントローラー
│   ├── javascript/activestorage/ : ダイレクトアップロード時のJSの処理を記述
│   ├── jobs/active_storage/ : analyze(blobからmetadata取得)とpurge(ファイル削除)のジョブを定義
│   └── models/active_storage/ : ActiveStorage::Blob, ActiveStorage::Attachmentの定義などのモデル
├── config/routes.rb : app/controllers/active_storageのコントローラーへのルートを追加
├── db/migrate/20170806125915_create_active_storage_tables.rb : active_storage_blobsとactive_storage_attachmentsを作成
├── lib
│   └── active_storage
│       ├── active_storage.rb : ActiveStorageモジュールの定義。Attached, Service, Analyzer, Previewerを読み込んでいる
│       ├── attached.rb
│       ├── attached/ : has_one_attached/has_many_attachedのマクロを定義
│       ├── service.rb : サービスの抽象クラス
│       ├── service/ : Serviceの具象クラス(Disk, S3, GCS, AzureStorage, Mirrorクラスがある)
│       ├── analyzer.rb: Analyzerの抽象クラス
│       ├── analyzer/ : Analyzerの具象クラス(画像とビデオ、Nullクラスがある)
│       ├── previewer.rb : Previewerの抽象クラス
│       └── previewer/ : Previewerの具象クラス(PDFとビデオがある)
└── test

2. storage.ymlからServiceクラスを作成する処理

storage.ymlからServiceクラスをどのように作成しているかといった流れをみていきます。
大まかな流れとしては次の通りです。

  • 初期化時に、YAML.loadでyamlファイルをパースし
  • const_getでサービスを作り
  • クラス変数のActiveStorage::Blob.serviceに作成したServiceのインスタンスを設定

storage.ymldevelopment.rbが下記のように記載されている前提でソースをみていきます。

  • storage.yml
# config/storage.yml
local:
  service: Disk
  root: <%= Rails.root.join("storage") %>
  • development.yml
config.active_storage.service = :local

Railsの初期化処理中のactive_storage_blobがロードされた時にYAMLを読み込み、ActiveStorage::Service.configureでServiceを作成し、ActiveStorage::Blob.serviceに設定しています。

# activestorage/lib/engine.rb
module ActiveStorage
  class Engine < Rails::Engine # :nodoc:

    ...

    initializer "active_storage.services" do
      ActiveSupport.on_load(:active_storage_blob) do
        # development.rbの設定を取得。active_storage.serviceにはlocal, :amazon, :gcs などが入る想定
        if config_choice = Rails.configuration.active_storage.service
          configs = Rails.configuration.active_storage.service_configurations ||= begin
            config_file = Pathname.new(Rails.root.join("config/storage.yml"))
            raise("Couldn't find Active Storage configuration in #{config_file}") unless config_file.exist?

            require "yaml"
            require "erb"

            # storage.ymlの読み込み、erbを使ってrubyコードを埋め込めるようにしている
            YAML.load(ERB.new(config_file.read).result) || {}
          rescue Psych::SyntaxError => e
            raise "YAML syntax error occurred while parsing #{config_file}. " \
                  "Please note that YAML must be consistently indented using spaces. Tabs are not allowed. " \
                  "Error: #{e.message}"
          end

          ActiveStorage::Blob.service =
            begin
              # configureメソッドでクラスを作成している
              ActiveStorage::Service.configure config_choice, configs
            rescue => e
              raise e, "Cannot load `Rails.config.active_storage.service`:\n#{e.message}", e.backtrace
            end
        end
      end
    end
  end
end

では、ActiveStorage::Service.configureの中身をみていきます。 ActiveStorage::Congiguratorに初期化処理を委譲しています。

# activestorage/lib/active_storage/service.rb
class Service
  extend ActiveSupport::Autoload
  autoload :Configurator

  class_attribute :url_expires_in, default: 5.minutes

  class << self
    # Configure an Active Storage service by name from a set of configurations,
    # typically loaded from a YAML file. The Active Storage engine uses this
    # to set the global Active Storage service when the app boots.
    # service_name: :local, :amazon, :gcsなどが入ってくる
    # configurations: storage.ymlの内容がhashで入ってくる
    # {"test"=>{"service"=>"Disk", "root"=>"/Users/yanagisawa/pj/rails_samples/sample_active_storage/tmp/storage"}, "local"=>{"service"=>"Disk", "root"=>"/Users/yanagisawa/pj/rails_samples/sample_active_storage/storage"}, "amazon"=>{"service"=>"S3", "access_key_id"=>nil, "secret_access_key"=>nil, "region"=>"ap-northeast-1", "bucket"=>"rails-webook-test-bucket"}}
    def configure(service_name, configurations)
      Configurator.build(service_name, configurations)
    end

    ...
end

ActiveStroage::Congiguratorでは、YAMLの設定内容からクラスを作っています。 resolveメソッドで、YAMLのserviceで設定した値のクラスをロードし、const_getでクラスを取得しています。 そして、取得したクラスのbuildメソッドでインスタンスを作成しています。このとき、YAMLのservice以外の値でそれぞれのサービスの設定をしています。(Diskの場合ファイル配置先のroot、S3の場合バケット名やIAMのアクセスキーやシークレットなどを渡す)

# activestorage/lib/active_storage/service/congigurator.rb

# frozen_string_literal: true

module ActiveStorage
  class Service::Configurator #:nodoc:
    attr_reader :configurations

    # service_name = :local
    # configrurations = { "local" => { "service" => "Disk", "root" => "/Users/hoge/sample_active_storage/storage" } }
    def self.build(service_name, configurations)
      new(configurations).build(service_name)
    end

    def initialize(configurations)
      @configurations = configurations.deep_symbolize_keys
    end

    def build(service_name)
      config = config_for(service_name.to_sym)
      resolve(config.fetch(:service)).build(**config, configurator: self)
    end

    private
      def config_for(name)
        configurations.fetch name do
          raise "Missing configuration for the #{name.inspect} Active Storage service. Configurations available for #{configurations.keys.inspect}"
        end
      end

      def resolve(class_name) # class_neme = :Disk
        require "active_storage/service/#{class_name.to_s.underscore}_service"
        ActiveStorage::Service.const_get(:"#{class_name}Service")
      end
  end
end

DiskServiceS3Serviceなど各サービスクラスはAcitveStorage::Serviceを継承しており、MirrorServiceを除いて現在はオーバーライドしていないためAcitveStorage::Service.buildが呼び出されます。
中身はYAMLの設定値をnewに渡しているのみです。

# activestorage/lib/active_storage/service.rb
module ActiveStorage
  class Service
    ...

    class << self
      ...

      # Override in subclasses that stitch together multiple services and hence
      # need to build additional services using the configurator.
      #
      # Passes the configurator and all of the service's config as keyword args.
      #
      # See MirrorService for an example.
      def build(configurator:, service: nil, **service_config) #:nodoc:
        new(**service_config)
      end
    end
  end
end

各クラスの初期化メソッドでは、それぞれのサービス用に値を設定しています。

# activestorage/lib/active_storage/service/disk_service.rb
module ActiveStorage
  class Service::DiskService < Service
    attr_reader :root

    def initialize(root:)
      @root = root
    end

    ...
  end
end

# activestorage/lib/active_storage/service/s3_service.rb
module ActiveStorage
  class Service::S3Service < Service
    attr_reader :client, :bucket, :upload_options

    def initialize(bucket:, upload: {}, **options)
      @client = Aws::S3::Resource.new(**options)
      @bucket = @client.bucket(bucket)

      @upload_options = upload
    end

    ...
  end
end

# activestorage/lib/active_storage/service/gcs_service.rb
module ActiveStorage
  class Service::GCSService < Service
    def initialize(**config)
      @config = config
    end

    ...
  end
end

このようにして、YAMLで設定値を記載し、その設定値を使って特定のクラスを生成するという実装をみました。

3. 各サービスごとのファイル操作の実装をどのようにわけているか

シンプルに基底クラスでファイル操作のインターフェースを定義し、継承したクラスでストレージに合わせた実装をしています。 具体的にはActiveStorage::Servieを基底クラスとしてuploaddownloadurlなどインターフェースを定義し、DiskServiceS3Serviceなどで具体的な実装をしています。

また、ActiveStorage::Blobはクラス変数のserviceDiskServiceS3Serviceなどのサービスインスタンスを保持しています。 これは、初期化時にstorage.ymlからインスタンスが生成され、service変数に設定されます。 基本的には、ActiveStorage::Blobは、参照URLの取得、ファイルのアップロード、ファイルの削除などは、そのサービスインスタンスに委譲するような作りになっています。

f:id:nipe880324:20180820232803p:plain:w420

ActiveStorage::Blobではレコードの操作は自分で、ストレージのファイルの操作はservceクラスに委譲しているのがわかります。

# activestorage/app/models/active_storage/blob.rb
class ActiveStorage::Blob < ActiveRecord::Base
  # サービスインスタンスを保持するクラス変数
  class_attribute :service

  def service_url(expires_in: service.url_expires_in, disposition: :inline, filename: nil, **options)
    filename = ActiveStorage::Filename.wrap(filename || self.filename)

    service.url key, expires_in: expires_in, filename: filename, content_type: content_type,
      disposition: forcibly_serve_as_binary? ? :attachment : disposition, **options
  end

  def service_url_for_direct_upload(expires_in: service.url_expires_in)
    service.url_for_direct_upload key, expires_in: expires_in, content_type: content_type, content_length: byte_size, checksum: checksum
  end

  def service_headers_for_direct_upload
    service.headers_for_direct_upload key, filename: filename, content_type: content_type, content_length: byte_size, checksum: checksum
  end

  def upload(io)
    self.checksum     = compute_checksum_in_chunks(io)
    self.content_type = extract_content_type(io)
    self.byte_size    = io.size
    self.identified   = true

    service.upload(key, io, checksum: checksum)
  end

  def download(&block)
    service.download key, &block
  end


  def delete
    service.delete(key)
    service.delete_prefixed("variants/#{key}/") if image?
  end

  def purge
    delete
    destroy
  end

  ...
end

サービスクラスの基底クラスのActiveStorage::Serviceではインターフェースのみを実装し、未実装にしています。

# activestorage/lib/active_storage/service.rb
module ActiveStorage
  class Service
    ...

    # Upload the +io+ to the +key+ specified. If a +checksum+ is provided, the service will
    # ensure a match when the upload has completed or raise an ActiveStorage::IntegrityError.
    def upload(key, io, checksum: nil)
      raise NotImplementedError
    end

    # Return the content of the file at the +key+.
    def download(key)
      raise NotImplementedError
    end

    # Return the partial content in the byte +range+ of the file at the +key+.
    def download_chunk(key, range)
      raise NotImplementedError
    end

    # Delete the file at the +key+.
    def delete(key)
      raise NotImplementedError
    end

    # Return +true+ if a file exists at the +key+.
    def exist?(key)
      raise NotImplementedError
    end

    # Returns a signed, temporary URL for the file at the +key+. The URL will be valid for the amount
    # of seconds specified in +expires_in+. You most also provide the +disposition+ (+:inline+ or +:attachment+),
    # +filename+, and +content_type+ that you wish the file to be served with on request.
    def url(key, expires_in:, disposition:, filename:, content_type:)
      raise NotImplementedError
    end

    # Returns a signed, temporary URL that a direct upload file can be PUT to on the +key+.
    # The URL will be valid for the amount of seconds specified in +expires_in+.
    # You must also provide the +content_type+, +content_length+, and +checksum+ of the file
    # that will be uploaded. All these attributes will be validated by the service upon upload.
    def url_for_direct_upload(key, expires_in:, content_type:, content_length:, checksum:)
      raise NotImplementedError
    end
  end
end

そして、各サブクラスでストレージに合わせた実装をしています。
ここではuploadメソッドの実装について、Disk、S3、GCPで見比べてみます。
基本的には既にあるライブラリを使ってファイルをアップロードしています。

# activestorage/lib/active_storage/service/disk_service.rb
def upload(key, io, checksum: nil)
  instrument :upload, key: key, checksum: checksum do
    IO.copy_stream(io, make_path_for(key))
    ensure_integrity_of(key, checksum) if checksum
  end
end


# activestorage/lib/active_storage/service/s3_service.rb
def upload(key, io, checksum: nil)
  instrument :upload, key: key, checksum: checksum do
    begin
      object_for(key).put(upload_options.merge(body: io, content_md5: checksum))
    rescue Aws::S3::Errors::BadDigest
      raise ActiveStorage::IntegrityError
    end
  end
end


# activestorage/lib/active_storage/service/gcs_service.rb
def upload(key, io, checksum: nil)
  instrument :upload, key: key, checksum: checksum do
    begin
      # The official GCS client library doesn't allow us to create a file with no Content-Type metadata.
      # We need the file we create to have no Content-Type so we can control it via the response-content-type
      # param in signed URLs. Workaround: let the GCS client create the file with an inferred
      # Content-Type (usually "application/octet-stream") then clear it.
      bucket.create_file(io, key, md5: checksum).update do |file|
        file.content_type = nil
      end
    rescue Google::Cloud::InvalidArgumentError
      raise ActiveStorage::IntegrityError
    end
  end
end

このように、基底クラスでインターフェースを定義し、継承を使って実装をそれぞれわけるといったオブジェクト指向のよくあるサンプルの実例をみれました。

4. has_one_attached / has_many_attached の実装

has_one_attachedhas_many_attachedを使うことで既存のモデルにファイルを添付できるようになりますが、具体的にどのような仕組みで実現されているか見ていきます。

class User < ApplicationRecord
  has_one_attached :avatar
end

# user.avatarが定義され、さらにattached?というファイルが添付されてるかどうかがわかる
user = User.new
user.avatar.attached? #=> false

まずは、enigineでActiveRecordにActiveStorage::Attached::Macrosを拡張しています。

# activestorage/lib/active_storage/engine.rb
module ActiveStorage
  class Engine < Rails::Engine # :nodoc:
    ...
    initializer "active_storage.attached" do
      require "active_storage/attached"

      ActiveSupport.on_load(:active_record) do
        extend ActiveStorage::Attached::Macros
      end
    end
    ...
  end
end

ActiveStorage::Attached::Macros内では、has_one_attachedメソッドとhas_many_attachedメソッドを定義しています。

# activestorage/lib/active_storage/attached/macro.rb
module ActiveStorage
  module Attached::Macros
    def has_one_attached(name, dependent: :purge_later)
      ...
    end

    def has_many_attached(name, dependent: :purge_later)
      ...
    end
  end
end

has_one_attachedhas_many_attachedはどちらも同じような実装なので、has_one_attachedの実装を詳細に見ていきます。
参照メソッドと代入メソッドをそれぞれ定義しています。
また、ActiveStorage::AttachmentとActiveStorage::Blobへのアソシエーションも作成しています。
削除時のコールバックで、ファイルを削除(purge_later)やファイルへの参照を削除(detach)をするようにしています。

この中で、特徴的なのは、ActiveStorage::Attached::Oneというインスタンスを作っているところだと思います。

# activestorage/lib/active_storage/attached/macro.rb
module ActiveStorage
  module Attached::Macros
    def has_one_attached(name, dependent: :purge_later)
      class_eval <<-CODE, __FILE__, __LINE__ + 1
        def #{name}
          @active_storage_attached_#{name} ||= ActiveStorage::Attached::One.new("#{name}", self, dependent: #{dependent == :purge_later ? ":purge_later" : "false"})
        end

        def #{name}=(attachable)
          #{name}.attach(attachable)
        end
      CODE

      has_one :"#{name}_attachment", -> { where(name: name) }, class_name: "ActiveStorage::Attachment", as: :record, inverse_of: :record, dependent: false
      has_one :"#{name}_blob", through: :"#{name}_attachment", class_name: "ActiveStorage::Blob", source: :blob

      scope :"with_attached_#{name}", -> { includes("#{name}_attachment": :blob) }

      if dependent == :purge_later
        after_destroy_commit { public_send(name).purge_later }
      else
        before_destroy { public_send(name).detach }
      end
    end

    ...
  end
end

ActiveStorage::Attached::Oneの実装をみていきます。 recordhas_one_attachedを記載したモデルのインスタンスになります。 attachemntメソッドがいろいろなメソッドが呼ばれており、さらにattachmentメソッドは、先ほど定義したActiveStorage::Attachmentへの参照を返すことがわかります。 そのため、Oneクラスはファイルを添付したいモデルとActiveStorage::Attachmentを仲介するようなクラスであることがわかります。 このクラスがあることでファイルを添付したモデルにファイル添付のロジックが入り込まなくなるのでスッキリしたコードがかけるメリットがあるのかなと思います。

# activestorage/lib/active_storage/attached/one.rb
module ActiveStorage
  class Attached::One < Attached
    delegate_missing_to :attachment

    def attachment
      record.public_send("#{name}_attachment")
    end

    # Associates a given attachment with the current record, saving it to the database.
    #
    #   person.avatar.attach(params[:avatar]) # ActionDispatch::Http::UploadedFile object
    #   person.avatar.attach(params[:signed_blob_id]) # Signed reference to blob from direct upload
    #   person.avatar.attach(io: File.open("/path/to/face.jpg"), filename: "face.jpg", content_type: "image/jpg")
    #   person.avatar.attach(avatar_blob) # ActiveStorage::Blob object
    def attach(attachable)
      blob_was = blob if attached?
      blob = create_blob_from(attachable)

      unless blob == blob_was
        transaction do
          detach
          write_attachment build_attachment(blob: blob)
        end

        blob_was.purge_later if blob_was && dependent == :purge_later
      end
    end

    def attached?
      attachment.present?
    end

    def detach
      if attached?
        attachment.destroy
        write_attachment nil
      end
    end

    def purge
      if attached?
        attachment.purge
        write_attachment nil
      end
    end

    def purge_later
      if attached?
        attachment.purge_later
      end
    end

    private
      delegate :transaction, to: :record

      def build_attachment(blob:)
        ActiveStorage::Attachment.new(record: record, name: name, blob: blob)
      end

      def write_attachment(attachment)
        record.public_send("#{name}_attachment=", attachment)
      end
  end
end

以上です。