リファクタリングRuby: サブクラスをレジストリに置き換える(翻訳)

こんにちは、hachi8833です。今回はRailsのリファクタリングについての翻訳記事をお送りいたします。

概要

原著者より許諾をいただいて翻訳・公開いたします。

リファクタリングRuby: サブクラスをレジストリに置き換える(翻訳)

あるとき私のチームに、レガシーなRailsアプリ全体にレスポンシブな画像を実装するという面倒なタスクが舞い降りてきました。そのアプリはそれまで、クライアントのデバイスやviewport属性などおかまいなしに、縦横比率の合わない巨大な画像ファイルをWebブラウザに送信していたのです。ページ読み込みでユーザーが待たされるためにユーザーエクスペリエンスが損なわれ、コンバージョン率も低下していました。

この問題を解決するために、画像をリアルタイムで変換するオンザフライ画像サーバーをプロビジョニングしてCDNレイヤーに配置しました。最初のタスクは、このコンセプトでうまくいくかどうかを本番サーバーで小規模に試すことでした。配置した場所は本番サーバーですが、ホームページから離して配置したのでトラフィックはそれほどありませんでした。

最初のバージョン

使われているすべての基本インフラに合うレスポンシブ画像用のgemを探しましたが、今回の目的に合う既存のgemは残念ながら見当たりませんでした。そこで、以下の要件を満たすソリューションの開発をスタートさせました。

  • 1つの画像(ActiveRecordモデル)を、1つのレスポンシブ画像に1対1対応させる
  • レスポンシブ画像が複数フォーマット(HTML/CSS/JSON)に対応する
  • フォーマットはアプリの内部外部ともに共有でき、プリロードやJS統合などに利用できる

解説: レスポンシブ画像とは、異なる画像サイズやデバイス/ピクセル比に応じて複数の画像ファイルをまとめたものです。
しばらくかかって、次のようなResponsiveImageクラスをこしらえました(実物とは異なります)。

class ResponsiveImage
  class << self
    attr_reader :versions

    def inherited(subclass)
      subclass.version ImageVersion.new(:original)
    end

    def version(*args)
      new_version = ImageVersion.new(*args)
      (@versions ||= {}).merge!(new_version.id => new_version)
    end  
  end

  version ImageVersion.new(:original)

  def initiliaze(image)
    @image = image
  end

  def url(version_id)
    # version_idに対応するURLを生成
  end

  # 表現形式を展開するメソッドをここに置く...
end

ご覧のとおり、このクラスは次のようなサブクラスに画像のバージョンの定義と収集機能を提供するためのものです。

class FreebieMainImage < ResponsiveImage
  version :sm, 173, 130, [nil, 599]
  version :md, 241, 181, [600, 991]

  # その他のバージョン...
end
# 属性は記事用に簡略化されています

上の2行目で宣言されている173×130ピクセルの画像は、viewportが0〜599ピクセルの場合に使われます。同様に、継承したフックで自動的にすべてのサブクラスの:originalバージョンが1つ宣言されます。宣言されたサイズに応じた画像をリクエストできるよう、次のコードで取得する画像サーバーへのURLはバージョンに応じて変わります。

# :sm バージョンのURLを返す
responsive_image.url(:sm)

ResponsiveImageオブジェクトは、最終的には表現形式の異なる画像を取得するときの単なるプロキシになりますので、特殊化したオブジェクトに次のように委譲されます。

class ResponsiveImage
  # ...

  def to_picture
    PictureTag.new(self)
  end

  # ...
end
# PictureTagなどのオブジェクトは、ResponsiveImageのバージョンとURLを読み出してHTML/CSS/JSON出力を生成する

このときの最終的なビューは次のとおりです。

<%= FreebieMainImage.new(freebie.main_image).to_picture %>

上のERBは<picture>タグを出力します。FreebieMainImageで宣言された全バージョンの表示ルールとURLがこのタグに含まれます。

よくなった点

上の実装は、私たちの問題を解決するのにうってつけでしたが、いくつか問題が残りました。中でも、1つのエンドポイントに対するレスポンシブ画像を使いまわす必要があったことと、そのためにオブジェクトのインスタンス生成を何度も行わなければならないのがよくないと思えました。

画像使い回しのためにコントローラを使うのはいかにも悪手です。他の全コントローラで同じことをするはめになりがちだからです。

class FreebiesController
  def show
    freebie = Freebie.find(params[:id])

    @freebie = FreebiePresenter.new(freebie)
    @main_image = FreebieResponsiveMainImage.new(@freebie.main_image)
  end
end

何らかの形でコードを抽象化して、再利用しやすくする必要がありました。しばらく考えた末、レスポンス画像のファクトリーをモデルでグループ化するのが適切であると思えてきました。

このときのざっくりした設計は「Freebieモデルには1つのmain_imageがあり、コレクションでFreebieMainImage用の適切なプレゼンターを自動的に決定して、結果をハッシュ的なインターフェイス経由で渡す」というものです。
ざっくりとですが、以下のような青写真的コードを作ってみました。

# "freebie"モデルを読み取って、対応する正しい画像に対応付ける
collection = ResponsiveImageCollection.new(freebie)

# 最終的にレスポンシブ画像を出力する
puts collection[:main_image].to_picture

このときは最終的に以下のような実装になりました。

class ResponsiveImageCollection
  def initialize(model)
    @model = model
    @images = {}
  end

  def [](image_id)
    @images[image_id] ||= begin
      image = @model.public_send(image_id)
      klass = find_responsive_image_class(image_id)

      klass.new(image)
    end
  end

  private

  def find_responsive_image_class(image_id)
    "#{@model.class.model_name.name}#{image_id.to_s.camelize}".constantize
  end
end

ご覧のように、画像のレスポンシブなクラスの探索はRailsらしい書き方になっています。モデル名がFreebieで画像名がmain_imageの場合は、FreebieMainImageというクラスを探索します。

FreebiePresenterの以下のメソッドによって、コードでのアクセスが容易になり、コントローラも汚さずに済むようになりました。

class FreebiePresenter
  # ...

  def images
    @images ||= ResponsiveImageCollection.new(self)
  end

  # ...
end

このときのビューは最終的に以下のようになりました。

<%= @freebie.images[:main_image].to_picture %>

最初のバージョンのメリット

以下の理由によってリリースはスムーズに行われ、上のコードも技術的には適切でした。

  • concernが分離されたことでモデルがレスポンシブ対応のために膨れ上がらずに済んだ。この方法は昔のRailsらしいソリューションに似ています。
  • ResponsiveImageは個別のImageと結合していない。
  • #versionメソッドがマクロとしてよくできている。
  • (今見れば言い訳がましいのは承知で)ResponsiveImageCollectionは当初のリリース要件を満たしている。リソースは1対1で対応付けられ、かつコアシステムとの結合はまったく見られなかった。

これでよしということでリリースされました。

第2のバージョン

概念の実証(POC)に成功したこのシステムをデプロイした後、いよいよこのシステムを多数のページで実装することになったのですが、そこで新たに問題点がいくつか判明し、それらについても考慮が必要になりました。

  • 1つのImageに対して複数のResponsiveImageが対応し(1対多)、複数のResponsiveImageが複数のImageに対応する(多対多)

さっそく計画を立案し、新たな変更に対応しました。

問題の発覚

最初のいくつかを実装し始めてみて、レスポンシブサブクラスを何度も何度も宣言しなければならないことに気づきました。

class SaleIndexImage < ResponsiveImage
  # Versions...
end

class SaleMainImage < ResponsiveImage
  # Versions...
end

class SaleFooImage < ResponsiveImage
  # Versions...
end

class SaleBarImage < ResponsiveImage
  # Versions...
end

# まだまだたくさんあります

それでやっと、レスポンシブ画像ごとにいちいちクラスをこしらえるのはかなりダルいということに気づいたのでした。

当初は扱う画像が少なかったため、このダルさに気づけなかったのでした。最小限のコードで問題を解決してリリースにこぎつけられたのですからそれはよしとしましょう。しかしそのソリューションは必然的に新しい要件に発展し、コードの荒い部分を改善しなければならなくなりました。

私たちは何度も自問自答を重ねた末、ひとつ面白いことに気づきました。このサブクラスの役割は、データを集中化することなのではないかと。

ということは、このサブクラスはほぼ継承として振る舞うべきのではないでしょうか?

確かに、「Y is a X」のような「is a」関係の表現に最適なのは継承です。実際、現状のサブクラスは「is a」関係の要件を満たしておらず、代わりに、継承したメソッドにデータを提供するときの設定ハブとして振る舞っています。

どんな場合であっても、メソッドや実装の詳細を単に共有するだけの目的で継承を使ってはならない。この種の間違いが見つかった場合は、設計の改善が必要な可能性が高い。

当初は気づけませんでしたが、実際私たちの方法では、探索をクラス名で行ったりしていた副作用のために後になるほどコードが複雑になってしまいました。SOLID指向のコードであればこれでもよいのですが、今回は該当しません。

解決方法

どうやら今回の問題は、ファクトリーに設定を提供するレジストリで対応する方がふさわしそうです。まだピンとこない方もいると思いますが、しばらくお付き合いください。

問題解決のためのリファクタリングの第一歩は、#versionsをクラスメソッドからインスタンスメソッドに移動することです。ただしクラスメソッドはまだ残しておきます。

変更後も動作は変わらず、さらに、サブクラスをいちいち作らなくてもレスポンシブ画像をオンザフライでインスタンス化できるようになりました。

freebie_main_image = ResponsiveImage.new(freebie.main_image,
  ImageVersion.new(:sm, 173, 130, [nil, 599]),
  ImageVersion.new(:md, 241, 181, [600, 991])
])

#versionsをインスタンスメソッド化し始めたので、次はクラスのすべてのサイト呼び出しをインスタンスメソッド化します(この手順は非掲載です)。

この時点ではまだサブクラスを排除できていません。

FreebieMainImage.new(freebie.main_image)

サブクラスの排除は少々面倒です。最初に、サブクラス中心の設定を取り除くためにレジストリを作成します。

class ResponsiveConfig
  def self.config
    yield instance
  end

  include Singleton

  def initialize
    @config = { default: [] }
  end

  def add(id, versions)
    @config[id] = versions.map { |args| ImageVersion.new(*args) }
  end

  def fetch(id)
    @config.fetch(id)
  end
end

次に、すべてのサブクラスにあるバージョンをレジストリに移動します。具体的には、Railsイニシャライザに以下のコードを配置します。

ResponsiveConfig.config do |config|
  config.add :freebie_main_image, [
    [:sm, 173, 130, [nil, 599]],
    [:md, 241, 181, [600, 991]]
  ]

  config.add :freebie_hero_image, [
    [:sm, 173, 130, [nil, 599]],
    [:md, 241, 181, [600, 991]]
  ]
end

上のコードでは、#addメソッドが設定idとバージョン情報の配列を受け取ります。これらはStructオブジェクトにマップされます。

続いてResponsiveImageCollectionで探索対象をサブクラスから設定キーに変えます。

これでやっと、ResponsiveImage.versionメソッドとResponsiveImage.inheritedメソッドを削除できるようになりました。削除した後のクラスは非常にシンプルです。

ただしまだ作業が残っています。

最後に残った結合

ここまでのリファクタリングは比較的やさしい方でしたが、まだ問題が残っています。今のままでは、同じレスポンシブ設定を2回使いまわそうとしてもできません。探索するコレクションクラスが命名方法にハードコードされてしまっているからです。

たとえば、Projectというモデルがあり、そのmain_imageのバージョンを宣言したい場合、project_main_imageというキーが必要になります。

ResponsiveConfig.config do |config|
  config.add :project_main_image, [
    # ここにバージョンがある...
  ]
end

このままでは柔軟性に欠けているのでスケールアップできませんし、命名方法があいまいで覚えにくいという問題に対処できていません。

画像をレスポンシブに明示的に対応付けられるでしょうか?今回は最終的に、要素の組み合わせや個数を好きなだけ繰り返せるようになりました。

最初に、ResponsiveConfigに似たCollectionConfigクラスを作成しました。

class CollectionConfig
  include Singleton

  CollectionMapping = Struct.new(:id, :responsive_config_id, :image_name)

  def self.config
    yield instance
  end

  def initialize
    @config = { default: [] }
  end

  def add(id, mappings)
    @config[id] = mappings.map { |args| CollectionMapping.new(*args) }
  end

  def fetch(id)
    @config.fetch(id)
  end
end

続いて、以下のようにコレクションマッピングを設定します。

CollectionConfig.config do |config|
  config.add :freebie, [
    [:main_image, :freebie_main_image, :main_image],
    [:hero_image, :freebie_hero_image, :hero_image]
  ]
# 最初のパラメータはID(コレクション内部でレスポンシブ画像の識別に使う)

最後に、コレクションクラスを変更して、マッピングを解釈できるようにします。

ついにmain_imagerandom_imageで再利用できるようになりました!もちろん、他のどの画像でも再利用できます。

今度のコードは、ERB呼び出し側で何も変更されていないので、非常に使いやすくなりました。

<%= freebie.images[:main_image].to_picture %>

もちろん、このコレクションクラスはもっと柔軟にできます。たとえばモデルとの結合をやめてもよいでしょう。コレクションをモデルと結合する理由はもうありません。

ただし、モデルとの結合は機能の一部なのでそのままにしました。現状でも他のコレクションは簡単に作れますし、それに必要な柔軟性は既に備わっているからです。

最後に

今回ご紹介したコードは、ブログ記事用にかなり手を加えてあります。また、実際には実装がここまでややこしくなったわけではありませんのでご了承ください。

要点

  • 実行するタスクの変更量が多い場合は、分割して小さくしてから攻略しよう。
  • 未来に備えすぎないこと: 今解決に必要なコードだけを実装しよう。問題を見据えて、システムが自然に進化するコードを書こう。
  • 継承が常に悪いわけではない: ただし継承がふさわしくない問題もたくさんある
  • サブクラスをデータ共有だけのために使ってないかどうか注意しよう: 最適な方法がないか常に自問自答しよう

最後までお読みいただき、ありがとうございました。今日が皆さまにとってよい日でありますように。

Ruby on RailsによるWEBシステム開発、Android/iPhoneアプリ開発、電子書籍配信のことならお任せください この記事を書いた人と働こう! Ruby on Rails の開発なら実績豊富なBPS

この記事の著者

hachi8833

Twitter: @hachi8833、GitHub: @hachi8833

コボラー、ITコンサル、ローカライズ業界、Rails開発を経てTechRachoの編集・記事作成を担当。
これまでにRuby on Rails チュートリアル第2版の半分ほど、Railsガイドの初期翻訳ではほぼすべてを翻訳。その後も折に触れてそれぞれ一部を翻訳。
かと思うと、正規表現の粋を尽くした日本語エラーチェックサービス enno.jpを運営。
実は最近Go言語が好き。
仕事に関係ないすっとこブログ「あけてくれ」は2000年頃から多少の中断をはさんで継続、現在はnote.muに移転。

hachi8833の書いた記事

BPSアドベントカレンダー

週刊Railsウォッチ

インフラ

BigBinary記事より

ActiveSupport探訪シリーズ