Runner in the High

技術のことをかくこころみ

入門ROM.rb 第3回: Repository

さーて、お待ちかねの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の中でマッピングするよう指定できる。そのやり方に関しては、次回以降説明していこうと思う。

*1:DDDとレイヤードアーキテクチャはイコールではないが、便宜的に。

*2:http://rom-rb.org/4.0/learn/getting-started/core-concepts/#repositories

*3:簡単に言うと、永続化レイヤ(たとえばデータベース)のデータ構造と永続化対象(たとえばドメインモデル)のデータ構造の整合性の差異