1. ホーム
  2. スクリプト・コラム
  3. ルビートピックス

Rubyのデザインパターン。アダプタパターン実践ガイド

2022-01-31 13:31:23

アダプター・パターン
アダプタパターンは、異なるインタフェースをラップして統一的なインタフェースを提供したり、オブジェクトを別の型のオブジェクトに見せかけたりするために使われます。静的型付けされたプログラミング言語では、型システムの特性を満たすために使うことが多いのですが、Rubyのような弱型付けプログラミング言語では、そのようなことをする必要はありません。それにもかかわらず、私たちにとっては非常に意味のあることなのです。
サードパーティのクラスやライブラリを扱う場合、このような例から始めることが多いです(start out fine)。

def find_nearest_restaurant(locator)
 locator.nearest(:restaurant, self.lat, self.lon)
end


ロケータのインタフェースがあることが前提ですが、find_nearest_restaurantを他のライブラリに対応させたい場合はどうすればよいでしょうか。この際、新たに特殊なシナリオを追加して対応してみるのもいいかもしれない。

def find_nearest_restaurant(locator)
 if locator.is_a? GeoFish
  locator.nearest(:restaurant, self.lat, self.lon)
 elsif locator.is_a? ActsAsFound
  locator.find_food(:lat => self.lat, :lon => self.lon)
 else
  raise NotImplementedError, "#{locator.class.name} is not supported."
 end
end


これはより現実的な解決策です。もしかしたら、もう他のライブラリのサポートについても考える必要がないかもしれません。あるいは、find_nearest_restaurantだけがlocatorを使うシナリオかもしれません。
では、新しいロケータをサポートする必要があった場合はどうでしょうか。そこで、3つの具体的なシナリオがあります。また、find_nearest_hospitalメソッドを実装する必要がある場合はどうでしょうか?そうすると、これら3つの具体的なシナリオを維持しながら、2つの異なる場所を両立させる必要があります。この解決策がもはや実現不可能だと感じたら、アダプタ・パターンを検討する必要があります。
この例では、ActAsFound と同様に GeoFish 用のアダプタを記述することで、残りのコードで現在使用しているライブラリを知る必要がなくなります:.

def find_nearest_hospital(locator)
 locator.find :type => :hospital,
        :lat => self.lat,
        :lon => self.lon
end

locator = GeoFishAdapter.new(geo_fish_locator)
find_nearest_hospital(locator)



意図的に仮定した例はこれで終わりですので、次に実際のコードを見てみましょう。


今朝早く、あなたのリーダーが駆けつけてきました: "急げ、急げ、緊急の仕事だ! 最近のChinaJoyが始まろうとしているのですが、ボスは新しく立ち上げた各ゲームのオンライン人数を視覚的に確認する方法を要求してきました。
日付を見て、まさか!?これから始まるんじゃない、もう始まってるんだ! こんなの間に合うわけがない。
この機能はとてもシンプルで、インターフェースはすでに提供されています。
リーダーが具体的に要件を説明してくれていますね。あなたのゲームには現在3つのサービスがあり、そのうち1つは以前からオープンしており、2つと3つは新しくオープンしたものである。Utility.online_player_count(Fixnum) を呼び出し、各サービスに対応する値を渡すだけで、各サービスのオンラインプレイヤー数、例えば、1番目のサービスは1、2番目のサービスは2、3番目のサービスは3が得られます。存在しないサービスを渡すと、-1が返されます。存在しないサービスを渡すと、-1が返されます。あとは、出来上がったデータをXMLにまとめるだけで、正確な表示機能はリーダーがやってくれます。
まあ、それほど複雑な機能でもなさそうだし、今からやっても遅くはなさそうなので、さっそくコードを書き上げました。
まず、オンライン中の人数をカウントするための親クラスPlayerCountを以下のコードで定義します。

class PlayerCount 
 
  def server_name 
    raise "You should override this method in subclass." 
  end 
   
  def player_count 
    raise "You should override this method in subclass." 
  end 
 
end 


次に、PlayerCountを継承して、以下のように3種類のスーツに対応する3つのstatクラスが定義されています。

class ServerOne < PlayerCount 
 
  def server_name 
    "One Service" 
  end 
   
  def player_count 
    Utility.online_player_count(1) 
  end 
 
end 

class ServerTwo < PlayerCount 
 
  def server_name 
    "Second service" 
  end 
   
  def player_count 
    Utility.online_player_count(2) 
  end 
 
end 

class ServerThree < PlayerCount 
 
  def server_name 
    "Three services" 
  end 
   
  def player_count 
    Utility.online_player_count(3) 
  end 
 
end 



そして、各サービスのデータをXML形式にカプセル化するために、以下のコードでXMLBuilderクラスを定義します。

class XMLBuilder 
 
  def self.build_xml player 
    builder = "" 
    builder << "<root>" 
    builder << "<server>" << player.server_name << "</server>" 
    builder << "<player_count>" << player.player_count.to_s << "</player_count>" 
    builder << "</root>" 
  end 
 
end 


この方法で、すべてのコードが完成し、あるサービスでオンラインのプレイヤー数を確認したい場合は、.NET Frameworkを呼び出すだけです。

XMLBuilder.build_xml(ServerOne.new) 


2番目のサービスのオンラインプレイヤー数を表示するには、単に呼び出すだけです。

XMLBuilder.build_xml(ServerTwo.new) 


3つのサービスのオンラインプレイヤー数を表示するには、電話をかけるだけです。

XMLBuilder.build_xml(ServerThree.new) 


あれ?サービス1のオンラインプレイヤー数をチェックすると、戻り値が常に-1になっていることがわかります。サービス2、3のチェックは問題ありません。
あなたはリーダーを呼び出す必要がありました。"私が書いたコードは問題ないように感じますが、あるサービスでオンライン プレイヤーの数を照会すると必ず-1が返されます、なぜでしょうか。
これは私の問題で、先ほどあなたに明確に説明しませんでした。あるサービスがオープンしてしばらく経つので、オンラインプレイヤー数を照会する機能は、ServerFirstというクラスを使って、ずっと前から利用可能でした。その際、Utility.online_player_count()メソッドは主に新しくオープンした2番目、3番目のサービスのために書いたので、1番目のサービスのクエリー関数を何度もやることはありません。この状況は、アダプタパターンを使用することができます、このパターンは、インターフェイス間の非互換性の問題を解決するためのものですquotが登場します。
実際には、アダプタパターンの使用は非常に簡単です、コアのアイデアは、限り、あなたは2つの互換性のないインターフェイスは、行に正しくドッキングすることができます作ることができるということです。上記のコードでは、XMLBuilderはXMLをまとめるためにPlayerCountを使用し、ServerFirstはPlayerCountを継承していないので、XMLBuilderとServerFirst間のブリッジを構築するアダプタクラスが必要です。ServerOneは間違いなくアダプタ・クラスの役割を果たすでしょう。ServerOneのコードを以下のように修正します。

class ServerOne < PlayerCount 
 
  def initialize 
    @serverFirst = ServerFirst.new 
  end 
 
  def server_name 
    "One Service" 
  end 
   
  def player_count 
    @serverFirst.online_player_count 
  end 
 
end 


このように、ServerOneの適応により、XMLBuilderとServerFirstがうまく連動するようになりました 使うときにServerFirstクラスがあることを知らなくても、普通にServerOneのインスタンスを作ればいいだけなのです。
注目すべきは、Adapterパターンはアーキテクチャをより合理的にするようなパターンではなく、むしろアーキテクチャ設計の不備によるインターフェースのミスマッチを解決するための消火器として機能することが多いということです。より良いアプローチは、将来の状況の可能性を考慮した設計を心がけ、この問題に関してはリーダーから学ばないことだ。

マルチJSON
ActiveSupportでは、JSON形式のデコードを行う際に、JSONライブラリ用のアダプタであるMultiJSONを使用します。これらのライブラリは、それぞれJSONをパースすることができますが、その方法は異なっています。ここでは、ojとyajlそれぞれのアダプタを見てみましょう。(ヒント: コマンドラインで qw multi_json と入力すると、ソースコードを見ることができます)

module MultiJson
 module Adapters
  class Oj < Adapter
   #...
   def load(string, options={})
    options[:symbol_keys] = options.delete(:symbolize_keys)
    ::Oj.load(string, options)
   end
   #...


Oj のアダプタはオプションのハッシュテーブルを変更し、Hash#delete: を使って :symbolize_keys エントリを Oj の :symbol_keys エントリに変換します。

options = {:symbolize_keys => true}
options[:symbol_keys] = options.delete(:symbolize_keys) # => true
options # => {:symbol_keys=> true}


次にMultiJSONは ::Oj.load(string, options) を呼び出します。MultiJSONの適応APIはオリジナルのOj APIと非常によく似ているので、ここで説明する必要はありません。しかし、Oj がどのように参照されているかに気付きましたか? ::Oj は MultiJson::Adapters::Oj ではなく、トップレベルの Oj クラスを参照しています。
次に、MultiJSON が Yajl ライブラリにどのように適応していくかを見てみましょう。

module MultiJson
 module Adapters
  class Yajl < Adapter
   #...
   def load(string, options={})
    ::Yajl::Parser.new(:symbolize_keys => options[:symbolize_keys]).parse(string)
   end
   #...


このアダプタでは、loadメソッドを別の方法で実装しています。yajlの方法では、まずパーサ強度を作成し、引数として渡された文字列でYajl::Parser#parseメソッドを呼び出すようになっています。オプションのハッシュテーブルに関する処理も若干異なります。Yajl に渡されるのは :symbolize_keys の項目だけです。
これらのJSONアダプタは些細なことに見えるかもしれませんが、JSONがパースされる場所ごとにコードを更新することなく、好きなようにライブラリを切り替えることができるようになるのです。
ActiveRecord
多くのJSONライブラリは似たようなパターンになる傾向があり、適応がかなり容易です。しかし、もっと複雑なものを扱う場合はどうなるでしょうか。ActiveRecordには、さまざまなデータベース用のアダプタがあります。PostgreSQLとMySQLは同じSQLデータベースですが、両者には多くの違いがあり、ActiveRecordはアダプターパターンを使ってこれらの違いを隠しているのです。(ヒント: ActiveRecordのコードを見るには、コマンドラインでqw activerecordとタイプしてください)
ActiveRecordのコードベースからlib/connection_adaptersディレクトリを開くと、PostgreSQL、MySQL、SQLite用のアダプタがあります。また、AbstractAdapterというアダプタがあり、これが各アダプタのベースクラスとなっています。AbstractMysqlAdapter は、MysqlAdapter および Mysql2Adapter というふたつの異なる MySQL アダプタの親クラスにあたります。これらのアダプタがどのように連携して動作するのか、実際の例で見てみましょう。
PostgreSQLとMySQLでは、SQLの方言が若干異なって実装されています。SELECT * FROM users というクエリ文は、どちらのデータベースでも問題なく実行できますが、いくつかの型の扱いが若干異なります。MySQLとPostgreSQLでは、時間形式が異なります。中でもPostgreSQLはマイクロ秒レベルの時刻をサポートしていますが、MySQLは最近の安定版リリースまでしかサポートしていません。では、この二つのアダプタはこの違いをどのように扱うのでしょうか?
ActiveRecordはAbstractAdapterのActiveRecord::ConnectionAdapters::Quotingに混入しているquoted_dateを通して日付を参照します。一方、AbstractAdapterの実装は、単に日付をフォーマットするだけです。

def quoted_date(value)
 #...
 value.to_s(:db)
end


RailsのActiveSupportはTime#to_sを拡張して、フォーマット名を表すシンボリック型パラメータを受け取れるようにしました。dbで表されるフォーマットは %Y-%m-%d %H:%M:%S です。

# Examples of common formats:
Time.now.to_s(:db) #=> "2014-02-19 06:08:13"
Time.now.to_s(:short) #=> "19 Feb 06:08"
Time.now.to_s(:rfc822) #=> "Wed, 19 Feb 2014 06:08:13 +0000"


MySQL アダプタはどれも quoted_date メソッドをオーバーライドしておらず、当然この挙動を継承しています。一方、PostgreSQLAdapter では、日付の処理に 2 つの変更が加えられています。

def quoted_date(value)
 result = super
 if value.actors_like?(:time) && value.respond_to?(:usec)
  result = "#{result}. #{sprintf("%06d", value.usec)}"
 end

 if value.year < 0
  result = result.sub(/^-/, "") + " BC"
 end
 result
end



冒頭でsuperメソッドを呼び出しているので、MySQLでフォーマットされたものと同じような日付も取得することができます。次に、値が特定の時間のように見えるかどうかを検出します。これはActiveSupportの拡張メソッドで、オブジェクトがTime型のインスタンスに似ている場合にtrueを返す。これにより、様々なオブジェクトがTimeに似ていると仮定されたことを示すことが容易になる。(ヒント:acts_like? メソッドに興味がありますか? コマンドラインで qw activesupport を実行し、 core_ext/object/acts_like.rb を読んでみてください)
条件の後半では、valueにミリ秒を返すusecメソッドがあるかどうかをチェックします。ミリ秒が見つかった場合は、sprintf メソッドで結果文字列の末尾に追加されます。多くの時間フォーマットと同様に、sprintfはさまざまな方法で数値をフォーマットするために使用することができる。

sprintf("%06d", 32) #=> "000032"
sprintf("%6d", 32) #=> " 32"
sprintf("%d", 32) #=> "32"
sprintf("%.2f", 32) #=> "32.00"


最後に、日付が負の数である場合、PostgreSQLAdapter は "BC" を追加して日付を再フォーマットしますが、これは PostgreSQL データベースで実際に必要とされるものです。

SELECT '2000-01-20'::timestamp;
-- 2000-01-20 00:00:00
SELECT '2000-01-20 BC'::timestamp;
-- 2000-01-20 00:00:00 BC
SELECT '-2000-01-20'::timestamp;
-- ERROR: time zone displacement out of range: "-2000-01-20"


これは、ActiveRecordを複数のAPIに対応させる際のごく小さな方法ですが、データベースの詳細が異なることによる違いや煩わしさを解消することができます。
SQLデータベースを反映したもう一つの違いは、データベーステーブルの作成方法です。主キーの扱いは、PostgreSQLだけでなく、MySQLでも異なります:。

# AbstractMysqlAdapter
NATIVE_DATABASE_TYPES = {
 :primary_key => "int(11) DEFAULT NULL auto_increment PRIMARY KEY",
 #...
}

# PostgreSQLAdapter
NATIVE_DATABASE_TYPES = {
 primary_key: "serial primary key",
 #...
}



これらのアダプタはどちらもActiveRecordで主キーがどのように表現されているかを理解していますが、新しいテーブルを作成する際にはこれを異なるSQL文に変換しています。次にマイグレーションを書いたりクエリを実行したりするときには、ActiveRecordのアダプタとその小さな働きについて考えてみてください。
日付と時刻
MultiJsonとActiveRecordは従来のアダプタを実装していますが、Rubyの柔軟性は別の解決策を可能にします。このような微妙な違いはあるものの、公開されているAPIは極めて類似しています(ヒント:コマンドラインからqw activesupportを実行すると、こちらのコードを見ることができます)。

t = Time.now
t.day #=> 19 (Day of month)
t.wday #=> 3 (Day of week)
t.usec #=> 371552 (Microseconds)
t.to_i #=> 1392871392 (Epoch secconds)

d = DateTime.now
d.day #=> 19 (Day of month)
d.wday #=> 3 (Day of week)
d.usec #=> NoMethodError: undefined method `usec'
d.to_i #=> NoMethodError: undefined method `to_i'



ActiveSupportは、DateTimeとTimeに直接、足りないメソッドを追加して修正することで、両者の差異を滑らかにします。例として、ActiveSupportがDateTime#to_iをどのように定義しているかを紹介します。

class DateTime
 def to_i
  seconds_since_unix_epoch.to_i
 end

 def seconds_since_unix_epoch
  (jd - 2440588) * 86400 - offset_in_seconds + seconds_since_midnight
 end

 def offset_in_seconds
  (offset * 86400).to_i
 end

 def seconds_since_midnight
  sec + (min * 60) + (hour * 3600)
 end
end



サポートに使用する各メソッド seconds_since_unix_epoch, offset_in_seconds, seconds_since_midnight は DateTime に既にある API を使用または拡張して Time のメソッドと一致するものを定義しています。
先ほど見たアダプタが、適応されるオブジェクトに対する外部アダプタだとすれば、今見たアダプタは内部アダプタと呼ぶことができます。外部アダプタとは異なり、この方法は既存の API による制限を受けることになり、厄介な不整合を引き起こす可能性があります。たとえば、ある特殊なシナリオでは DateTime と Time が同じ振る舞いをしないことがあります。

datetime == time #=> true
datetime + 1 #=> 2014-02-26 07:32:39
time + 1 #=> 2014-02-25 07:32:40


1がつくと、DateTimeは1日、Timeは1秒を追加します。これらを使う必要がある場合、ActiveSupportにはchangeやDurationといった、これらの差異に基づく一貫した動作を保証するメソッドやクラスが用意されていることを覚えておく必要があります。
これは良いパターンなのでしょうか?当然便利なのですが、先ほど見ていただいたように、やはりいくつかの違いを意識する必要があります。
まとめ
Railsはデザインパターンを用いて、JSONのパースとデータベースのメンテナンスに統一されたインタフェースを提供しています。Railsのソースコードは、さまざまなデザイン パターンの実例を掘り下げるための避難所です。
今回の演習では、興味深いコードも発見できました。

  • hash[:foo] = hash.delete(:bar) は、ハッシュテーブルの項目名を変更する賢い方法です。
  • ::ClassNameを呼び出すと、トップレベルのクラスが呼び出されます。
  • ActiveSupport は、Time、Date、およびその他のクラスに対して、形式を表すオプションのパラメータ format を追加しています。
  • sprintfは、数値の書式設定に使用できます。

もっと探検したいですか?MultiJsonがどのようにフォーマットを処理し、パースしているのかを見てみましょう。データベースで使用している ActiveRecord アダプタのコードに目を通してください。ActiveSupport の XmlMini for xml アダプタ(MultiJson の JSON アダプタと類似している)を閲覧する。これらには、まだまだ学ぶべきことがたくさんあります。