こんにちは、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行目で宣言されている173x130ピクセルの画像は、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_image
をrandom_image
で再利用できるようになりました!もちろん、他のどの画像でも再利用できます。
今度のコードは、ERB呼び出し側で何も変更されていないので、非常に使いやすくなりました。
<%= freebie.images[:main_image].to_picture %>
もちろん、このコレクションクラスはもっと柔軟にできます。たとえばモデルとの結合をやめてもよいでしょう。コレクションをモデルと結合する理由はもうありません。
ただし、モデルとの結合は機能の一部なのでそのままにしました。現状でも他のコレクションは簡単に作れますし、それに必要な柔軟性は既に備わっているからです。
最後に
今回ご紹介したコードは、ブログ記事用にかなり手を加えてあります。また、実際には実装がここまでややこしくなったわけではありませんのでご了承ください。
要点
- 実行するタスクの変更量が多い場合は、分割して小さくしてから攻略しよう。
- 未来に備えすぎないこと: 今解決に必要なコードだけを実装しよう。問題を見据えて、システムが自然に進化するコードを書こう。
- 継承が常に悪いわけではない: ただし継承がふさわしくない問題もたくさんある
- サブクラスをデータ共有だけのために使ってないかどうか注意しよう: 最適な方法がないか常に自問自答しよう
最後までお読みいただき、ありがとうございました。今日が皆さまにとってよい日でありますように。