Powered by SmartDoc

Rails, Injected

2005/2/1 (rev.3)
Jamis Buck 著、Tadashi kaneda 訳
ウェブフレームワークにおける依存性注入の利点

目次

お断り

本ドキュメントで述べられているアプローチは、Railsに取り込むかどうかが検討されていた。しかしながらデイビッドは、外部のDIフレームワークへの依存性を高めるのではなく、ここで記述されている各ポイントを分けて、ソリューションを実装することに決めた。また、彼のソリューションは、下記のNeedleベースのアプローチよりはRailsにマッチする。下記で議論されている各問題に対する既存のRailsのソリューションをお見せしよう。

1. イントロ

依存性注入(Dependency Injection,略してDI,他に、"制御の反転"(inversion of control、略してIOCとして知られる)は、数多くの小さくアジャイルでお互いに疎結合なコンポネントの生成を促す強力なデザインパターンである。このパターンは、別のところ(*1)で詳細に記述されているので、本稿では、一般的なDIの利点については議論しない。代わりに、本稿ではDIがウェブアプリケーションフレームワーク、特にRuby on Rails(*2)(あるいはもっと単純に、"Rails")にもたらす利点について注目する。

Ruby用に実装されたDIはいくつか存在するが、本稿ではNeedle(*3)を扱う。

2. 概念の説明

Railsの現在の実装(これを書いている時点ではバージョン0.8)では、実行時のコンフィグを定義するデータを特定する手段を提供する。このコンフィグはenvironmentsといい、プライマリのデータベース接続情報が含まれている。

現時点の実装では、environmentsは、2,3の異なるクラスにプロパティを設定し、各環境ごとに適切なデータベース設定をロードする簡単なRubyスクリプトとして実装されている。よって、"production"環境を用いるアプリケーションには、単に"production.rb"スクリプトが必要になるだけだ。

下記の概念実証のための実装ではenvironmentの機能はシングルトンオブジェクトでカプセル化してある。また、いくつかのRailsコンポネントにメソッドを追加している。というのは、Railsコンポネント(モデル、コントローラ、あるいはビュー)は、常にenvironmentのDIコンテナへ便利なアクセスがあるからだ。

この概念実証の実装には2つのファイルが含まれていて、両方ともRailsの全てを結ぶ(つまり、配布可能な"railties")コードの一部になる。

A. Railsの拡張

最初のファイルは、ActiveRecordActionControllerActionView、そしてActionMailerモジュールの基本クラスを拡張したものだ。これらはレジストリへの参照をインクルードするように拡張されている。

base-enhance.rb
require 'active_record' 
require 'action_controller' 
require 'action_mailer' 
require 'action_view' 

# レジストリインスタンスを含むクラスの振る舞いを定義するモジュール

module RegistryContainer 

# このクラスや全サブクラスに影響するレジストリインスタンスを返す
def registry 
  self.class.registry 
end 

# #registry=, #reistry, #register メソッドはクラス変数を使うので、
# このメソッドによって、動的に追加されなければならない
def self.included( mod ) 
  super mod.class_eval <<-EOF, __FILE__, __LINE__+1 
  def self.registry=( reg ) 
    @@registry = reg 
  end 

  def self.registry 
    @@registry 
  end 

  def self.register( name ) 
    registry.register( name ) { self } 
  end 

  def self.service( *args ) 
    base = registry 
base = args.shift unless String === args.first || Symbol === args.first

      if args.empty? 
        raise ArgumentError, "specify a service to reference" 
      end 

    args.each do |model| 
      const_set Inflector.camelize(model.to_s), 
        Needle::Lifecycle::Proxy.new { base[model.to_sym] } 
      end 
    end 
  EOF 
end 
end 

class ActiveRecord::Base 
  include RegistryContainer 

  class << self 
    alias :old_inherited :inherited 

    # 自動的にこのクラスをサービスとしてクラスのレジストリに追加する
    # このサービスは、クラス名にunderscoreして、demodulizeしてsymbolizeしたもの
    def inherited( klass ) 
      old_inherited klass 
klass.register( Inflector.underscore( Inflector.demodulize( klass.name ) \
    ).to_sym )
    end 
  end 
end 

class ActionController::Base 
  include RegistryContainer 
end 

class ActionMailer::Base 
  include RegistryContainer 
end 

class ActionView::Base 
  include RegistryContainer 
end

特に、ActiveRecord::Baseクラスは全てのサブクラスが自動的にレジストリに登録されるように拡張されている。この拡張は必要なものではないが、便利なので。でも、もし開発者がレジストリのARに追加したいのであれば、下記のように手動で行う必要がある:

register-manually.rb
class MyActiveRecord < ActiveRecord::Base 
  register :my_active_record 
  ... 
end

これは、例えば、もしコントローラやビューが(いかなる理由であれ)DIコンテナで自分自身を登録したい場合に採られるアプローチだ。

self.serviceメソッドにも注目してほしい。これにより開発者は使おうとしているサービスを(名前で)特定することができ、サービスにアクセスするのに定数を利用することができる。ここで、あるメール通知サービスが:email_notificationという名前でレジストリに登録されているという状況を想定してみよう。そしてあるコントローラが(たとえば)以下を行うことでこのサービスにアクセスできた。

constants-as-services.rb
class FooController < ActionController::Base 
 # サービスを宣言する
  service :email_notifier 

  def save 
    if something_failed 
      # 定数をつかってサービスを起動させる
      EmailNotifier.send_notification( "something failed!" ) 
      raise "something failed!" 
    end 
    ... 
  end 

  ... 
end

次のセクション、"Environment"ではサービスの宣言と起動に関するもっと便利なメソッドをみてみよう。

B.環境

概念実証の次は、environmentシングルトンそのもののの実装だ。これはさっきのファイルより若干複雑になる。これは以下のようなものだ。

  1. Needle::Registry(Rails::Registry)のサブクラスを定義する。
  2. ロードパスの配列を宣言する。これはアプリケーションのルートからの相対パスで、グローバルロードパスに追加される。
  3. レジストリをインスタンス化する。
  4. いろんなサービスをレジストリに登録する。
  5. 必要となるRailsコンポネント用にロギングをセットアップする。
  6. 利用するenvironmentを選択する手段を提供する
env.rb
require 'singleton' 
require 'active_record' 
require 'action_controller' 
require 'action_mailer' 
require 'action_view' 
require 'yaml' 
require 'needle' 
require 'base-enhance' 

module Rails 
  # Needleレジストリクラス簡単なサブクラス
  # コンポネントに特化した名前空間を簡単に追加できるようにする
  class Registry < Needle::Registry 
  
    # 指定した名前の名前空間が存在しなければ生成する。
    # とにかく名前空間を返す
    def component_space( name ) 
      unless has_key?( name ) 
        namespace name 
      end 
      
      self[ name ] 
    end 

  end 
  
  # Railsのenvironment管理をカプセル化するシングルトンクラス
  class Environment 
    include Singleton 

    # 追加するロードパスを加える配列
    ADDITIONAL_LOAD_PATHS = 
      %w{app/models app/controllers app/helpers config lib vendor} 
      
    # 利用するレジストリへのリファレンス
    attr_reader :registry 

    # 新規のEnvironmentを生成する。シングルトンクラスなので、
    # 直接は呼び出せない。シングルトンインスタンスが生成されたら1回しか起動できない。
    def initialize 
      # ハッシュの各値に対し#replaceを実行することにより、$:変数を簡単に
      # 更新できるようにロードパスのハッシュを生成する
      # (Environment#setを参照のこと)
      @load_paths = ADDITIONAL_LOAD_PATHS.inject( Hash.new ) do |h,k| 
        $:.unshift( h[k] = "" ) 
	h 
      end 

      @registry = Rails::Registry.new :logs => { :device => STDOUT } 

      # アプリケーションのルートディレクトリを登録する。
      # デフォルトは、CWD
      @registry.register( :application_root ) { Dir.pwd } 

      # ファイルを再ロードしなくても参照できるようにデータベース設定をサービスにロードする。
      # これにより、クライアントがデータベース設定サービスを再定義するだけで、
      # データベース設定をオーバライドできるようにする
      @registry.register :database_configurations do |c,p| 
        YAML::load(File.open("#{c.application_root}/config/database.yml")) 
      end 

      # カレントのデータベース設定を返す。'prototype'モデルを使うので、、
      # ブロックは各リクエストに対し実行される。さもないと
      # current_environmentが変更されても、database_configurationサービスに
      # 変更が反映されない
@registry.register( :database_configuration, :model => :prototype ) do
        @registry.database_configurations[ @registry.current_environment ] 
      end 
      
      # カレントのシステムログファイルの位置を返す。'prototype'モデルを使うので、
      # ブロックは各リクエストに対し実行される。さもないと、
      # current_environmentが変更されても、system_log_fileサービスに変更が反映されない
      #
      # これにより、クライアントはログが吐かれる場所をsystem_log_fileサービス
      # を登録するだけで変更できるようになる
@registry.register( :system_log_file, :model => :prototype ) do |c,p|
        "#{c.application_root}/log/#{c.current_environment}.log" 
      end 

      ActiveRecord::Base.registry = @registry.component_space :model 
ActionController::Base.registry = @registry.component_space :controller
      ActionMailer::Base.registry = @registry.component_space :mailer 
      ActionView::Base.registry = @registry.component_space :view 
      ActiveRecord::Base.logger = @registry.logs.get "[active-record]" 
ActionController::Base.logger = @registry.logs.get "[action-controller]"
      ActionMailer::Base.logger = @registry.logs.get "[action-mailer]" 
    end 

    # 新規のenvironmentを名前により選択する。唯一の制限は、デフォルトで
    # 指定された名前のDBのコンフィグが存在しないといけないこと、
    # ないと失敗してしまう。
    #
    # +app_root+ パラメータの指定あれば、プロジェクトのルートディレクトリである。
    # 全アプリケーションコンポネントはこのディレクトリからの相対パスで参照される。

    def set( environment_name, app_root=nil ) 
      @registry.application_root.replace( app_root ) if app_root 

      @load_paths.each do |k,v| 
      @load_paths[k].replace "#{@registry.application_root}/#{k}" 
    end 

    ActionController::Base.template_root = 
    ActionMailer::Base.template_root = 
      "#{@registry.application_root}/app/views/" 

    @registry.register( :current_environment ) { environment_name } 
    @registry.logs.write_to(@registry.system_log_file) 
ActiveRecord::Base.establish_connection(@registry.database_configuration)
      true 
    end 

    # environmentを設定するための便利なメソッド。
    #
    # Environment.instance.set "production" の代わりに
    # 
    # 
    # Environment.set "production" みたいなことができる。
    #
    def self.set( environment_name, app_root=nil ) 
      instance.set( environment_name, app_root ) 
    end 

# A convenience accessor for accessing the Rails registry. This allows you

    # Railsレジストリにアクセスするための便利なアクセサ。
    #
    # Environment.instance.registry の代わりに
    # 
    # Environment.registry みたいなことができる。
    # 
    def self.registry 
      instance.registry 
    end 

  end 

  module BaseConvenienceMethods 
    def self.included( mod ) 
      super 
      mod.extend ClassMethods 
    end 

  module ClassMethods 
    def model( *args ) 
      service registry[:model], *args 
    end 

    def mailer( *args ) 
      service registry[:mailer], *args 
    end 
  end 
end 

class ActiveRecord::Base 
 include BaseConvenienceMethods 
end 

class ActionController::Base 
  include BaseConvenienceMethods 
end 

class ActionView::Base 
  include BaseConvenienceMethods 
end 

class ActionMailer::Base 
  include BaseConvenienceMethods 
end 
end

全てのアプリケーション用にいろんなサービスを自動で登録することに注意するように。

:application_root これはアプリケーションのルートディレクトリを指定する。デフォルトはCWDだが、Environment#setを呼べば、別のディレクトリを指定できる。
:database_configurations これはわかっているデータベースコンフィグレーションのハッシュであり、(アプリケーションルートディレクトリからの相対パスで)config/database.ymlファイルからロードされる。
:database_configuration これは、その時点で選択されたデータベース設定であり、その時点で選択されているenvironmentに基づいている
:system_log_file これは書き出されているログファイルのファイル名である。
:current_environment これはその時点で選択されているenvironment名である。

特に、:application_rootサービスによりアプリケーションはFile.dirname(__FILE__)みたいなことをしなくても自分のルートディレクトリを発見することができる。

:application_rootのデフォルトをCWDに設定することでEnvironmentはirbでうまく動く。単にirbを起動して、require 'env'して、Rails::Environment.setを使って使いたい環境を設定する。プロジェクトのルートでirbを起動するのと同じように、ルートディレクトリを明示的に設定したり、ディレクトリを明示的にロードパスに追加する心配をする必要はない。

最後に、ファイルの最後の方のBaseConvenienceMethodモジュールにも注目してほしい。このモジュールは、Railsの各プライマリコンポネントにインクルードされるので、modelコンポネントやmailerコンポネントへ簡単にアクセスできる。系統情報を表示するようなアプリケーションを想定してほしい。

pedigree_example.rb
require 'active_record' 
require 'pedigree' 
class PedigreeController < ActionController::Base 
  model :pedigree 

  def show 
@pedigree = Pedigree.load( @params['id'], @params['generations'].to_i )
  end 

  ... 
end

これで簡単に(もっと大事なことは、後方互換性を保ちつつ)モデルサービスを定数のように参照することができる。

C. Selecting an Environment

先述の2つのファイルをいったんいっしょにしてしまえば、config/environmentsディレクトリにproduction.rbtest.rbを作れば、全部後方互換性を保持できる。例えば、これは、production.rbスクリプトの完全版だ。

require 'env' 
Rails::Environment.set "production", File.dirname(__FILE__)+"/../.."

ここでやってることは"production"環境を選ぶことと、明示的にアプリケーションルートを設定してるだけだ。(これで、実際のウェブ環境で正しく動作する。)test.rbスクリプトは"production"が"test"に置き換わっただけでほとんど同じものだ。

3. Benefits

前章で触れた実装にはいくつかの利点がある。特に、テストが簡単、バックエンドの実装が柔軟にできる、サービスが共有できる、メソッドいんたせぷしょん、そして設定n柔軟性だ。

A. Testing

Railsではすでに、モデル、コントローラ、そしてビューを含む、アプリケーション中の全てのパーツのための強力なユニットテストのためのフレームワークが提供されている。しかしながら、今の実装では、コントローラとモデル間は密接に絡み合っているので、別の実行コードやモデルからルーチンを使わないでコントローラのユニットテストを実施することはできない。それらはテストされる機能とは大部分は無関係にある。

注:Railsでは、もはやモデルとコントローラ間を密接につなげることはしない。後述するように、コントローラをテストする際に簡単にモックオブジェクトを注入することができるmodelキーワードをサポートしている。

モデルをコントローラから切り離すことにより、開発者はユニットテストを行う際に、実際のモデルの代わりにモックのARを代用するすることができる。モックARはコントローラで必要となる機能のサブセットを実装する必要があるだけで、ハードコーディング(あるいはスクリプティング)されたデータを返すことができ、テストにより一貫性をもたらし、必要ないルーチンへの依存性を減らすことができる。

下記の例を検討してみよう。これは、本を記録するオンラインアプリケーションだ。これはアプリケーションが知っている著者のリストを表示する機能を実装するあるモデル/コントローラの断片である。

author.rb
class Author < ActiveRecord::Base 
  ... 
end
author_controller.rb
class AuthorsController < ActiveController::Base 
  model :author 

  def list 
    @authors = Author.find_all 
  end 

  ... 
end

上述の例では、コントローラで使われるAuthor ARのインタフェースは#find_allだけだ。したがって、ユニットテストでは、開発者はこのメソッドを実装してAuthorのモックを返すようなオブジェクトが1つ必要なだけだ。(実際のAuthorは使えない。というのは、ARをインスタンス化するにはコネクションを必要とするからだ。また、こういったことを必要なくすことがこの目的だ)。

author_controller_test.rb
class AuthorsControllerTest < Test::Unit::TestCase 

  MockAuthorService = Struct.new( :find_all ) 
  MockAuthor = Struct.new( :name, :books ) 

  def setup 
    @controller = AuthorsController.new 
    @request, @response = 
ActionController::TestRequest.new, ActionController::TestResponse.new
    AuthorsController.registry.namespace_define! :model do 
      author do 
       MockAuthorService.new( 
         # 複数のbook,1つのbook, bookがない場合をテストする。
	 [ MockAuthor.new( "Robert Jordan", [ "Eye of the World", "The Great Hunt" \
    ] ),
	   MockAuthor.new( "Roger Zelazny", [ "Nine Princes in Amber" ] ), 
	   MockAuthor.new( "Katherine Kurtz", [] ) ] 
       ) 
     end 
   end 
 end 

 def test_list 
   ... 
 end 
end

もし、モックARを作って登録するのにある種のscaffoldのインフラを書けば、これはもっと簡単にできるだろう。このように、フィクスチャを作るのは少しやっかいだけど、機能をを確実にする。

注:今のRailsでは、モックの実装をtest/mocks/testingディレクトリに置けばユニットテストで簡単にモックモデルオブジェクトを使うことができる。Railsのテスティングフレームワークは自動的にモックオブジェクトを見つけ出して利用する。

B. Flexible Backend Selection

ほとんどのRailsアプリケーションではActiveRecordをモデルの実装として利用するが、開発者は非SQLなバックエンド(つまり、フラットファイルへのデータストア)と、(ActiveRecordを介した)SQLバックエンドを両方とも扱いたいと思うだろう。 アプリケーションでは、モデル以外ではSQLを使わないように設計されていると想定されているが、求められているのは、モデルの実装をアプリケーションの内外に取り外す、ということだ。

レジストリはこれができるようにするための理想のレベルの抽象になる。例えば、レジストリにあるActiveRecordのモデルクラスを登録する代わりに、非SQLバックエンドのモデル実装を登録しても、コントローラにはそれがわからないので、インタフェースと対話することしかせず、処理方法がわかっているとみなすオブジェクトにメッセージを送る。

下記のコード断片を検討してみよう。これは、先ほどのAuthorクラスのフラットファイルバージョンを実装する擬似コードだ。(このFlatFileSystem::Baseクラスは想像上のクラスで、ActiveRecord::Baseの基本的な機能のうちのいくつかを真似たもの、と仮定している。register_self_asメソッドは単に自分自身を名前付きのサービスとしてモデルレジストリに登録する。)

author_flat_file.rb
require 'yaml' 
class AuthorFlatFile < FlatFileSystem::Base 
  register_self_as :author 

  def self.find_all 
    authors = [] 
    Dir["#{file_root}/authors/*.yml"].each do |file| 
      attrs = YAML.load(File.read(file)) 
      authors << new( attrs ) 
    end 
    authors 
  end 

  def initialize( attrs={} ) 

    ... 
  end 
end

ここで重要なのは、インタフェースだけだという点には注目してほしい。Authorのように振舞う限り、このコントローラは実際にどのように実装されているかについては気にしない。

C. Sharing Services

Needleをサービスロケータとして使えば、アプリケーションの全てのレベル間でサービス簡単に共有されるかもしれない。このように共有されるサービスの1つはログメッセージを書き込むためのロガーだ。実際、Needleはこの目的のために統合されたロギングサブシステムに付随する。ロガーへのハンドルを取得するには、:logsサービスを問い合わせる。

book.rb
class Book < ActiveRecord::Base 
  ... 
  private 
    def after_save 
      log.info "Book #{name} was just saved" 
    end 

    def log 
      @log ||= registry.logs.get( "Book" ) 
    end 

  ... 
end
book_controller.rb
class BookController < ActionController::Base 
  ... 
  def delete 
    unless has_access_to_delete 
      log.warn "Someone tried to delete a book without authorization!" 
      raise InvalidAuthorization, "you can't do that!" 
    end 
    ... 
  end 

  private 

    def log 
      @log ||= registry.logs.get( "BookController" ) 
    end 
  ... 
  end

このlogsサービスは一度インスタンス化され、その1つのインスタンスが、透過的に異なるコンポネント間でその後共有される。(インスタンス化されるのが1つだけというのは、はNeedleがデフォルトですべてのサービスに対するシングルトンの多重化一貫性に従うということによる)。

注:ロギングは悪い例だ。というのは、Railsではロギングがちゃんとサポートされているからだ。さらに、最近のRailsのリリースでは、ここで述べたのと同じ方法でsystem-globalサービスを宣言し使うために、serviceキーワードがサポートされている。

D. Method Interception

NeedleのようなDIコンテナを使う他の利点は、AOP風のアドバイスをレジストリ内の任意のサービスに追加できるようになる点だ。これにより、デバッグでメソッドを叩いたときにどんなパラメータを持っているかトレースが取得するなど他にも役に立つ。

Needleでこのような構築はインタセプタと呼ばれる。例えば(各メソッドの起動をパラメータの値と合わせてロギングするために)ロギングインタセプタをサービスに追加するにはあなたのコードのどこかで以下のようにするだけでよい。

interceptor_logging.rb
  ... 
  Rails::Environment.registry. 
    intercept( :author ). 
    with { |c| c.logging_interceptor } 
  ...

たったこれだけだ。一度これを追加してしまえば、:authorサービスにある任意のメソッドの任意の起動はパラメータ値、返り値、発生した例外の記述のすべてがロギングされるようになる。

メソッドインターセプションにより、ユーザのクリデンシャルに基づいてメソッドへのアクセスを制限する、のようなことができるようになる。メッセージを期待したフォーマットで入出力を行うアダプタのように動く、あるいは引数を分析したら、全く別のところでメッセージを処理すべきあである場合に全く異なるレシーバへメッセージの起動をリダイレクトするといったこともできるようになる。

E. 柔軟なコンフィグレーション

文字列や配列、ハッシュなどのような「スタティックな」オブジェクトを含め、どんな種類であれオブジェクトでもレジストリに投入するのは簡単だ。なので、レジストリはアプリケーションのどこからでもアクセスできるようにしたいコンフィグレーション情報を格納するのに理想的な場所なのである。

このようにレジストリをコンフィグレーション置き場として使ったものがアプリケーションのためのコンフィグレーションデータベースである。どんなアプリケーションでも、データベースへ接続するのに使う値のハッシュを取得するのに:database_configurationサービスを参照するようにすることができる。

このコンフィグレーションをレジストリに入れるようにすることで、アプリケーションのどんな部分でも簡単にコンフィグレーションを切り出すことができるようになる。例えば、あるオブジェクトは自分自身をあるイベントの通知に関心のあるグループの配列に追加することができる。するとそのイベントの主たるオブザーバはその配列をレジストリから読み込み、イベントが発生すると、配列に格納されている各オブジェクトそれぞれにノーティフィケーションを送信する。

レジストリに格納することが可能なこれとは別の種類のコンフィグレーション情報は、テンプレートファイルや、ログファイル、コントローラの実装のようなものの場所だ。

注:Railsではこのような情報を格納するのにenvironmentsを使う。

4. 今後の方向性

これまで述べてきたように、本稿で取り上げた実装は概念の実証にすぎないし、ここで取り上げただけでなくもっといろんなことが可能だ。

例えば以下のようなものがある。

ここに挙げたすべてが望ましいわけではない。ポイントは、こうすべきであるということではなく、これまでトリッキーに実装されてきたが、依存性注入の到来とサービスロケーションにより、実現可能になっている、ということである。

よりデータドリブンな方法でカレントの環境をアプリケーションに指定するためのよりよい方法があるかどうかを判断するためにconfig/environmentsディレクトリにより多くの考慮を含めることができるようになった。例えば、development/staging/productionの3つの環境システムを指定することができるようになったことは望ましいことだと思う。次のレベルにコードを更改するたびに毎回カレントの環境を参照するディスパッチャコードを変更しないといけないとなるとバグの温床にならないように気をつけないといけない。

5. 終わりに

依存性注入は、いろんなコンポネントを使ったアプリケーションを構築するのに大変便利なものだ。Railsは複雑な状況にうまくスケールするように設計されているが、Raisアプリケーションも例外ではない。

ここでおススメした実装方法は、もし開発者が使いたくないのであれば、邪魔にはならないし、使いたいのであれば、不便さを解消する、そういうものである。

また、この実装では、(前述したように)考慮すべきことがらは増えるが、config/environmentsディレクトをきれいにする。

6. Footnotes

  1. マーチン・ファウラー:制御の逆転と依存性注入パターン、Jim Weirich: Rubyにおける依存性注入
  2. Ruby-on-Rails, http://www.rubyonrails.org/
  3. RubyのためのNeedle依存性注入, http://needle.rubyforge.org/