さーて、お待ちかねのROM.rb入門の第3回はRepositoryから。
前回の記事はRelationを説明していますので、読んでない人は参考までに。 izumisy-tech.hatenablog.com
Repository
リポジトリと聞くと文脈的にPoEAAやDDDっぽい雰囲気を感じる人が多いのではないかと思う。
どちらも、最も重要な部分はレイヤード・アーキテクチャの考え方で責務を分離することなのではないかと思うが*1、ROM.rbのリポジトリも、公式のコア・コンセプトの説明*2によるとレイヤード・アーキテクチャの考え方と近く、ドメイン・ロジックとデータアクセス・レイヤを粗結合なものにするためのboundary(境界)としての役割を担っている。
An important function of repositories is to act as a boundary between the data access logic and Application Domain logic. This boundary helps to reduce the complexity of rehydrating your entities and keeps a direct dependency on a particular datastore out of your domain.
リポジトリの重要な点は、ドメイン・レイヤからデータアクセス・レイヤの直接的な依存を追い出す(keeps a direct dependency on a particular datastore out of your domain)というところだ。
このコンセプトと対象的なアプローチはActiveRecordパターンだ。ActiveRecordパターンはあえてドメイン・モデルをデータアクセス・レイヤのデータ構造を粗結合にすることによって、インピーダンス・ミスマッチ*3を減らし、高速なアプリケーション開発を可能にしている。
だが一方で、ActiveRecordパターンでは一挙に「モデル」と呼ばれる責務がデータアクセス・レイヤとドメイン・モデルを兼ねてしまうため、どうしてもモデルが肥大化し見通しが悪くなってしまう場合が多い。この点、ROM.rbではActiveRecordと異なり、データアクセス・レイヤとドメイン・レイヤの間にboundary(境界)としてリポジトリを挟み、それぞれの責務を異なるものとして粗結合な設計を出来るようにしようとしている。
Relationとの違い
前回の記事で説明したRelationは、Repositoryがドメインレイヤに近い存在であるのに対して、どちらかといえばインフラストラクチャ・レイヤに近い存在だ。
Relations ... provide APIs for reading the data from various databases, and low-level interfaces for making changes in the databases. Relations are adapter-specific, which means that each adapter provides its own relation specialization, exposing interfaces that make it easy to leverage the features of your database.
つまり、RelationがHTTPやSQLなどのような、アプリケーション本体の外側にあるアダプタ固有(adapter-specific)の操作をAPIとして提供するのに対して、RepositoryはそれらのRelationを更にドメイン・レイヤにふさわしい形でラップし、ドメイン・モデルがRelation(アダプタ)の操作から影響を受けないようにするためのレイヤであるということだ。
それでは実際に使ってみよう。
require "rom" require "rom/sql" # # ## Relationを定義 # # ROMのRelationは`ROM::Relation[:アダプタ名]`を継承したクラスとして定義する # associationsブロックの中で`belongs_to`や`has_many`などの関連も定義をする # ここで定義したRelationは下で出てくるROMの初期化の際にROMへの登録を行う必要がある。 # module Relations class Users < ROM::Relation[:sql] schema(:users) do # Relationの関連定義はActiveRecordと似ている。 # has_many, belongs_toやhas_many-throughなどがあるのでドキュメントを参照のこと # http://rom-rb.org/learn/sql/associations/ associations do has_many :books end # schemaブロックの中では関連の定義に加えて以下のように明示的に # Relationのカラムを定義できる。この場合、プライマリ・キーは # `Types::Serial`である必要がある attribute :id, Types::Serial attribute :name, Types::String attribute :age, Types::Int end # Repositoryの中で呼び出せるクラスメソッドを以下のように定義できる # ROM.rbのSQLアダプタ(rom-sql)は内部でSequalizeを使っているので # where句などもその記法に従う def by_pk(id) where(id: id) end def adult where{age > 20} end end class Books < ROM::Relation[:sql] schema(:books) do associations do belongs_to :user end # booksの場合にはuserに対してbelongs_toで関連を持っているので # その関連に対して`Types::ForeignKey(:users)`のように明示的に # 利用する外部キーを指定できる。ない場合には自動で推論される。 attribute :id, Types::Serial attribute :title, Types::Int attribute :user_id, Types::ForeignKey(:users) end end end # # ## Repositoryを定義 # # Repositoryからは定義されているRelationを触ることができる # module Repositories class User < ROM::Repository[:users] # ROM.rbではRepositoryがRelationに対してどのようなアクセスをできるのか # という点を明確に制限できる。ここでは:createをコマンドとして有効化しているが # :create以外にも:updateや:deleteがある。 commands :create # 以下のようにクラスメソッドを定義できる。 # これらのデータはROM::Structによってラップされて返される # ここではusersのみを読んでいるが、booksも同様にメソッドのなかでアクセスできる def adults users.adult end # `aggregate`メソッドを使うことによって、Relationで定義されている # 関連Relationのデータを子要素としてフェッチできる # `aggregates`を呼ぶ場合には明示的にRelationを指定しなくてもよさそう。 def all aggregate(:books) end def by_id(id) aggregate(:books).by_pk(id).one end end end # # ## ROMにRelationを登録 # # 上で定義した2つのRelationをROMに登録 # config = ROM::Configuration.new(:sql, "sqlite::memory") config.register_relation(Relations::Users) config.register_relation(Relations::Books) rom = ROM.container(config) # SQLiteでテーブルを作るマイグレーションを適用 migration = ROM::SQL.migration(rom) do change do create_table(:users) do primary_key :id string :name integer :age end create_table(:books) do primary_key :id foreign_key :user_id, :users string :title end end end gateway = rom.gateways[:default] migration.apply(gateway.connection, :up) # # ## Repositoryを使ってみる # # RepositoryはRelationとは異なり、ROMへ登録するのではなく、ROMのインスタンスを # コンストラクタDIすることでRepositoryのインスタンスを作るという形になる。 # userRepository = Repositories::User.new(rom) userRepository.create(name: "Justine", age: 10) userRepository.create(name: "Jessy", age: 18) userRepository.create(name: "Michael", age: 23) # テストのためにBooksテーブルにRelation経由でデータを入れてみる # (外部キー成約があるので、userレコードを作ったあとじゃないとダメ) books = rom.relations[:books] books.insert(title: "Good Book", user_id: 1) books.insert(title: "Nice Book", user_id: 1) # # ### Repositoryからレコードを取得 # # ここでは`by_id`や`adults`などの、Repositories::Userの中で定義した # クラスメソッドを呼び出すことができる。Relationの結果が常にハッシュなのに対して # Repositoryによる取得の返り値はROM::Structという構造体の形になる。 # これはデフォルトでtrueになっている`auto_struct`をfalseに指定することで # 止めることができる # users = userRepository.all users.each { |user| p user } # => #<ROM::Struct::User id=1 name="Justine" age=10 books=[ # #<ROM::Struct::Book id=1 title="Good Book" user_id=1>, # #<ROM::Struct::Book id=2 title="Nice Book" user_id=1> # ]> # #<ROM::Struct::User id=2 name="Jessy" age=18 books=[]> # #<ROM::Struct::User id=3 name="Michael" age=23 books=[]> single_user = userRepository.by_id(1) p single_user # => #<ROM::Struct::User id=1 name="Justine" age=10 books=[ # #<ROM::Struct::Book id=1 title="Good Book" user_id=1>, # #<ROM::Struct::Book id=2 title="Nice Book" user_id=1> # ]> users = userRepository.adults users.each { |user| p user } # => #<ROM::Struct::User id=3 name="Michael" age=23>
このように、RepositoryはRelationをドメインモデル(ROM::Struct)へと変換するインターフェイスの役割を果たしていることが分かる。
加えて、たとえばRepositoryが返すドメインモデルのインスタンスを独自のクラス定義にマッピングしたくなるかもしれない。その場合にはauto_struct
をtrueに維持したうえで、ROM::Struct
を継承した独自のクラスにRepositoryの中でマッピングするよう指定できる。そのやり方に関しては、次回以降説明していこうと思う。