Ruby
 Computer >> コンピューター >  >> プログラミング >> Ruby

サブドメインで実現するマルチテナントRuby on Railsアプリの構築完全ガイド

マルチテナンシーとは、1つのソフトウェアインスタンスを複数のユーザーグループ(テナント)が共有して利用する形態のことを指します。マルチテナンシーに対応したアプリの優れた例として、Jiraプラットフォームが挙げられます。Jiraでは各企業が mycompany.atlassian.net のような独自のサブドメインを持ってソフトウェアへアクセスします。

本記事では、マルチテナンシーの理論と実践の両面について解説します。アプリケーションに複数テナント対応を実装するための代表的なアプローチをいくつか紹介した後、そのうち2つを実際にRailsアプリケーションへ実装する方法をお見せします。各テナントが独自のサブドメインを持つシンプルなマルチテナントアプリを一緒に構築していきましょう。

この記事を読み終える頃には、次のことができるようになります。

  • さまざまな種類のマルチテナントアプリを理解し、どの場面でどのアプローチを選ぶべきか判断できる
  • 複数テナントに対応したRuby on Railsアプリケーションを構築できる
  • Ruby on Railsアプリケーション内で顧客ごとのカスタムサブドメインを利用できる

なお、本記事の内容を最大限に活かすためには、Railsに関する基礎知識が必要です。

マルチテナンシーの理論

前述のとおり、アプリケーションの機能を複数のユーザーグループが共有アクセスする形態をサポートしている場合、そのアプリはマルチテナンシーに対応していると言えます。分かりやすい例がブログプラットフォームです。メインドメイン配下でユーザーごとに個別のサブドメインが割り当てられます。各ブログは固有のサブドメインを持ちながらも、コメント機能、記事管理、管理ダッシュボードなど、他のブログと同じ機能を共有しています。

表面上は似たように動作するマルチテナントアプリでも、内部実装されているアーキテクチャは異なる場合があります。ここからは、最も一般的かつ効率的とされる方式を見ていきましょう。

データベース行レベル方式

アプリケーションで複数のデータベースを使いたくない場合は、1つの中央データベースで運用する方式を選択できます。この方式では、データベースの各テーブルに tenant_id カラムを持たせ、アプリケーション内で実行されるすべてのクエリでこのカラムを使用して、該当テナントに属するデータのみを取得します。

この構成ではテナントのセットアップが高速で、追加コストも発生せず、どんな環境でも実装可能です。しかし、クエリにテナントIDを含め忘れるとデータ漏洩につながる恐れがあるため注意が必要です。また、特定のユーザーグループ向けにデータベース構造を変更するのは保守が難しくなるというデメリットも頭に入れておきましょう。

スキーマレベル方式

中央データベースは1つのまま、テナントごとに個別のテーブルとスキーマを持たせる方式もあります。データベース行レベル方式との違いは、テナントを切り替える際にクエリを修正するのではなく、テーブルへの検索パスを変更する点です。PostgreSQLでは set search_path ステートメント、MySQLでは use ステートメントを使ってスキーマを切り替えられます。

この方式を採用する前に、利用しているDBサーバーでこれらのステートメントが使えることを確認してください。また、新しいテナントを追加するには新規スキーマの作成とテーブルの作成が必要なため、テナント追加が比較的遅いことにも留意しましょう。

データベースレベル方式

新しいテナントを追加するたびに新しいデータベースを作成するコストを許容できるなら、この方式が適しています。テナント追加の速度に関しては、ここで挙げたアーキテクチャの中で最も遅い方式です。

一方で、別テナントへ切り替えるにはデータベースへの新規接続を確立する必要があるため、データ漏洩が起きにくいのが大きな利点です。さらに、ユーザーグループごとのデータベース構造も柔軟に変更できます。

アプリケーションの雛形を作成する

ここからは2つのアプリケーションを作成します。1つは単一データベースによるマルチテナンシー対応アプリ、もう1つは複数データベースによるマルチテナンシー対応アプリです。両者はテナントの扱い方のアプローチこそ異なりますが、初期設定の手順は同じです。

執筆時点での最新バージョンは、Ruby 2.7.2、Ruby on Rails 6.1 RC1です。システムに適切なバージョンのRubyがインストールされていることを確認し、正しいバージョンのフレームワークをインストールしましょう。

gem install rails -v 6.1.0.rc1

続いて、以下のコマンドでプロジェクトファイルを生成します。

rails _6.1.0.rc1_ new tenantapp -d=mysql

プロジェクトディレクトリに移動してサーバーを起動し、ウェルカム画面が表示されることを確認してください。

cd tenantapp/
rails s

単一データベースによるマルチテナントRailsアプリケーション

それではコードを書いていきましょう。このパートでは、Railsアプリケーションへのマルチテナンシー実装方法を解説します。使用するのは「1つのデータベース・1つのスキーマ」によるアプローチです。

作業の流れは以下のとおりです。

  • 最新版のRubyとRuby on Railsフレームワークを使ってRailsアプリの雛形を作成する
  • scaffoldでモデルをいくつか生成し、操作対象となるデータを用意する
  • 前ステップで作った機能に対し、データベーステーブルとコードを更新してマルチテナンシー対応を追加する
  • カスタムサブドメイン対応を加え、ユーザーが自分のテナントへ簡単にアクセスできるようにする
  • 後ほど自力で実装できる拡張機能のアイデアを探る

テストデータの準備

ここでは、複数の著者が公開した記事を閲覧できるブログプラットフォームを構築すると想定します。

まず、著者情報を保存するAuthorモデルを作成します。

rails g scaffold author slug:string name:string description:string
rake db:create
rake db:migrate

https://localhost:3000/authors にアクセスして、後ほど記事に紐付けるための著者を数名登録しておきましょう。

次に記事(Article)を作成します。

rails g scaffold article title:string content:text
rake db:migrate

マルチテナンシー対応の追加

今回作成するアプリケーションでは、著者がテナントに相当します。各著者は自分の記事にのみアクセスできるべきです。先述のとおり、「1つのデータベース・1つのスキーマ」でマルチテナンシーを実装する場合の重要な要件は、テナントが管理するすべてのモデルに tenant_id フィールドを追加することです。

必要なマイグレーションを作成する

rails g migration AddTenantIdToArticle tenant_id:integer
rake db:migrate

上記のマイグレーションにより、Article モデルに新しいカラムが追加されます。以降のすべてのクエリでこのカラムを使い、現在のテナントに割り当てられたデータのみを取得します。

記事をテナントに紐付ける

このステップでは、記事に著者を割り当てられるようにコードを更新し、選択された著者の記事だけを表示できるようにします。app/controllers/articles_controller.rb を開いて、以下のように変更を加えてください。

class ArticlesController < ApplicationController
 before_action :set_article, only: [:show, :edit, :update, :destroy]
 before_action :set_authors, only: [:edit, :update, :new, :create]
 
 # ...
 
 private
   # Only allow a list of trusted parameters through.
   def article_params
     params.require(:article).permit(:title, :content, :tenant_id)
   end
 
   def set_authors
     @authors = Author.all
   end
end

ビュー側では、アプリに登録済みの著者のコレクションを格納した @authors 変数を利用できます。著者名を含むセレクトフィールドを追加し、適切な tenant_id を設定しましょう。app/views/articles/_form.html.erb を開いて、以下のセクションを追加します。

<div className="field">
 <%= form.label :author %>
 <%= form.select :tenant_id, options_from_collection_for_select(@authors, 'id', 'name', article.tenant_id), include_blank: true %>
</div>

著者を数名登録し、その後いくつか記事を作成して、指定した著者に紐付いた記事だけが表示されるように準備しておきましょう。

カスタムサブドメイン対応の追加

ここでは、名前が「John Doe」でスラッグが johndoe の著者を作成したとします。目標は、https://johndoe.localhost:3000/ にアクセスした際に、John Doeというテナントに関連するデータだけが表示される状態を作ることです。

サブドメインの設定

テナントが設定された状態で記事の閲覧・管理を行いたいので、config/routes.rb を更新し、articlesリソースの定義をconstraintsブロックで囲みます。

Rails.application.routes.draw do
 constraints subdomain: /.*/ do
   resources :articles
 end
 
 resources :authors
end

Railsはデフォルトでトップレベルドメインの長さを1としていますが、localhostを使う場合はこの値を0に設定します。config/environments/development.rb に以下の行を追加してください。

config.action_dispatch.tld_length = 0

サブドメインとマルチテナンシーを連携させる

現状では任意の著者を記事に割り当てられますが、サブドメイン運用時にはそれは望ましくありません。ArticlesController の挙動を変更し、全著者をセットする代わりに、リクエストされたサブドメインに対応する著者をセットするようにします。

 
class ArticlesController < ApplicationController
 before_action :set_author
 before_action :set_article, only: [:show, :edit, :update, :destroy]
 
 # GET /articles
 # GET /articles.json
 def index
   @articles = Article.where(tenant_id: @author.id)
 end
 
 # ...
 
 private
   def set_article
     @article = Article.find_by!(id: params[:id], tenant_id: @author.id)
   end
 
   # ...
 
   def set_author
     @author = Author.find_by!(slug: request.subdomain)
   end
end

コントローラーには以下の変更を加えました。

  • set_authors メソッドの代わりに、サブドメイン経由でリクエストされた著者をセットする set_author メソッドを定義しました。set_article より先に before_filter で呼び出すことが重要です。記事をセットする前に、必ず著者の割り当てが必要だからです。
  • set_article メソッドを更新し、指定されたIDと割り当てられた著者の両方に一致する記事のみを検索するようにしました。現在のテナントがJohnであるのに、Tomが作成した記事が表示されては困ります。
  • indexアクションを更新し、現在のテナントに割り当てられた記事のみを取得するようにしました。

マルチテナントアプリを「1つのデータベース・1つのスキーマ」で開発する場合、すべてのクエリを tenant_id カラムでスコープすることを常に意識しなければなりません。さもないと、リクエストされたテナント以外のデータを表示してしまう恐れがあります。

コントローラーの更新が完了したら、次はフォームビューを修正します。app/views/articles/_form.html.erb を開き、先ほどのセクションをシンプルな隠しフィールドに置き換えてください。

<%= form.hidden_field :tenant_id, value: @author.id %>

変更前は、ユーザーがフォームで記事の著者を選択できました。変更後は、URLのサブドメインによって全アクション共通で著者が決まる仕組みになります。

それではコードをテストしてみましょう。「John Doe」という名前でスラッグが johndoe の著者を新規作成します。次に https://johndoe.localhost:3000/articles/new にアクセスして新しい記事を追加してみてください。記事を追加したら、https://johndoe.localhost:3000/articles の一覧で確認できます。

おめでとうございます! 各著者が独自のサブドメインを持つマルチテナントアプリが完成しました。

さらなる改善ポイント

ここからは、新しい機能を追加してアプリケーションを拡張できます。現状のブログプラットフォームは非常にシンプルなので、コメントセクションを追加してみてはいかがでしょうか。どんな機能を追加する場合でも、新しいモデルに tenant_id カラムを追加し、リソース取得時に必ず使うことを忘れないでください。

専用のscopeを作成し、concernにまとめることもできます。

module Tenantable
 extend ActiveSupport::Concern
 
 included do
   scope :for_author, -> (author) { where(tenant_id: author.id) }
 end
end

そして、テナント向けデータを含むすべてのモデルで利用します。

class Article < ApplicationRecord
 include Tenantable
end
 
author = Author.find(1)
Article.for_author(author)

このアプローチなら、tenant_id カラムの名称変更や、テナント関連データ取得時の条件追加が生じても、修正箇所は1ヶ所だけで済みます。

複数データベースによるマルチテナントRailsアプリケーション

前のセクションでは、単一データベース・単一スキーマで複数テナントをサポートするRailsアプリケーションを構築しました。このアプローチの最大の弱点は、データ漏洩のリスクが高いことです。

幸い、最新版のRuby on Railsフレームワークには複数データベース管理のための組み込みサポートがあります。これを活用して、複数データベースとカスタムサブドメインによるマルチテナントRailsアプリケーションの構築方法を見ていきましょう。

作業の流れは以下のとおりです。

  • 最新版のRubyとRuby on Railsフレームワークを使ってRailsアプリの雛形を作成する
  • scaffoldでモデルをいくつか生成し、操作対象となるデータを用意する
  • カスタムサブドメイン対応を追加する
  • 新しいデータベースの作成と設定によって、テナントを追加する方法を学ぶ
  • 最後に、リクエストされたサブドメインに応じてデータベースを切り替えるようアプリを更新する

テストデータの準備

ここでも、複数の著者が公開した記事を閲覧できるブログプラットフォームを構築すると想定します。今回は各著者に専用のデータベースを割り当てます。

まず、著者の閲覧・作成を支援するscaffold付きで Author モデルを作成します。

rails g scaffold author slug:string name:string description:string
rake db:create
rake db:migrate

https://localhost:3000/authors にアクセスして、Railsが生成した画面を確認してみましょう。

新しい著者を追加する

ここでは、名前が「John Doe」でスラッグが johndoe の著者を作成しました。このスラッグの値は、後ほどサブドメイン経由でどの著者の情報を表示すべきか判定するために使われます。

各著者が独立したデータベースを持つため、John用のデータベースを手動で追加する必要があります。config/database.yml を開いて、以下のように変更してください。

development:
 primary:
   <<: *default
   database: tenantapp_development
 primary_johndoe:
   <<: *default
   database: tenantapp_johndoe_development
   migrations_paths: db/tenants_migrations

テナント用のマイグレーションは専用ディレクトリを使います。中央データベースでは不要だからです。以下の標準コマンドでJohn用のデータベースを作成できます。

rake db:create

記事を追加する

続いて、Article モデルをコントローラーとビュー込みでscaffoldします。

rails g scaffold article title:string content:text --database primary_johndoe

--database パラメータを渡すことで、プライマリデータベースが使用するデフォルトの db/migrations ディレクトリではなく、専用の場所にマイグレーションが配置されるようRailsに指示しています。マイグレーションを実行しましょう。

rake db:migrate

これで db/schema.rbdb/primary_johndoe_schema.rb の2つのスキーマが存在する状態になりました。テナントごとに異なるテーブルを作りたい場合は、config/database.yml 内で対象テナントに固有の migrations_path 値を設定すれば実現できます。本記事では全テナントで同じテーブル構成にしたいので、マイグレーションのパスは共通としています。

マルチテナンシー対応の追加

単一データベースのマルチテナンシー構成では、対象テナントのデータを取得するために、クエリに適切な tenant_id 値を加えるだけで済みました。しかし今回のケースでは各テナントが専用データベースを持つため、データベース間の切り替えが必要です。

Rails 6.1には水平シャーディングの組み込みサポートが搭載されています。シャードとは、データセット全体の一部を保持する水平分割領域のことです。全著者の記事を1つのデータベースの1つのテーブルに格納することも可能ですが、シャーディングのおかげで、同じ構造のテーブル群を複数のデータベースに分散して配置できます。

すべてのモデルの親クラスでシャードを定義しましょう。親クラスは app/models/application_record.rb にあります。

ActiveRecord::Base.connected_to(role: :reading, shard: :johndoe) do
  Article.all # get all articles created by John
end

呼び出しを connected_to ブロックで囲まなかった場合、デフォルトのシャードが使用されます。先へ進む前に、もう1つ変更を加えます。すべてのシャードが同じデータ構造を共有しているため、記事のscaffold時に自動生成された app/models/primary_johndoe_record.rb モデルは削除してしまいましょう。

さらに app/models/article.rb モデルを編集し、親クラスを PrimaryJohndoeRecord から ApplicationRecord へ変更します。

class Article < ApplicationRecord
end

テナントを追加する

現時点ではデータベースに著者が1人しかいません。テナント(データベース)間の切り替え機能をテストするには、もう1人著者を追加する必要があります。https://localhost:3000/authors/new にアクセスして新しい著者を登録してください。ここでは、名前が「Tim Doe」でスラッグが timdoe の著者を追加しました。

新しい著者のレコードができたので、新しいデータベースを定義します。

development:
 primary:
   <<: *default
   database: tenantapp_development
 primary_johndoe:
   <<: *default
   database: tenantapp_johndoe_development
   migrations_paths: db/tenants_migrations
 primary_timdoe:
   <<: *default
   database: tenantapp_timdoe_development
   migrations_paths: db/tenants_migrations

新しいデータベースを作成し、マイグレーションを実行します。

rake db:create
rake db:migrate

最後に ApplicationRecord モデルを更新して、新しいシャードを定義します。

class ApplicationRecord < ActiveRecord::Base
 self.abstract_class = true
 
 connects_to shards: {
   default: { writing: :primary, reading: :primary },
   johndoe: { writing: :primary_johndoe, reading: :primary_johndoe },
   timdoe: { writing: :primary_timdoe, reading: :primary_timdoe },
 }
end

これで各著者向けに記事を作成できます。

ActiveRecord::Base.connected_to(role: :writing, shard: :johndoe) do
  Article.create!(title: 'Article from John', content: 'content')
end
 
ActiveRecord::Base.connected_to(role: :writing, shard: :timdoe) do
  Article.create!(title: 'Article from Tim', content: 'content')
end

カスタムサブドメイン対応の追加

まだ、特定の著者の記事一覧にアクセスできません。JohnとTimのどちらの記事を表示すべきか判断できないからです。この問題はカスタムサブドメインの実装で解決します。https://johndoe.localhost:3000/articles にアクセスすればJohnの記事が、https://timdoe.localhost:3000/articles にアクセスすればTimの記事が表示されるようにしましょう。

サブドメインの設定

テナントが設定された状態で記事の閲覧・管理を行いたいので、config/routes.rb を更新し、articlesリソースの定義をconstraintsブロックで囲みます。

Rails.application.routes.draw do
 constraints subdomain: /.*/ do
   resources :articles
 end
 
 resources :authors
end

Railsはデフォルトでトップレベルドメインの長さを1としていますが、localhostを使う場合はこの値を0に設定します。config/environments/development.rb に以下の行を追加してください。

config.action_dispatch.tld_length = 0

サブドメインとマルチテナンシーを連携させる

現在のテナントのデータベース読み取り処理を統一するため、Tenantable というコントローラーconcernを作成します。これはブロックを受け取り、リクエストされたテナントのコンテキスト内でそのブロックを実行する read_with_tenant メソッドを提供します。

module Tenantable
 extend ActiveSupport::Concern
 
 private
 
 def read_with_tenant(&block)
   author = Author.find_by!(slug: request.subdomain)
 
   ActiveRecord::Base.connected_to(role: :reading, shard: author.slug.to_sym) do
     block.call
   end
 end
end

このファイルを app/controllers/concerns/tenantable.rb として保存し、ArticlesController にincludeします。

class ArticlesController < ApplicationController
 include Tenantable
 
 before_action :set_article, only: [:show, :edit, :update, :destroy]
 
 def index
   read_with_tenant do
     @articles = Article.all
   end
 end
 # ...
end

これで https://johndoe.localhost:3000/articles または https://timdoe.localhost:3000/articles にアクセスすると、それぞれ異なる記事の一覧が表示されます。

フォームから新しい記事を作成したい場合は、write_with_tenant という新しいメソッドを定義し、Tenantable concernと ArticlesController 内のメソッドをそれに合わせて更新する必要があります。

さらなる改善ポイント

上記で紹介したアプローチは、与えられたデータベース接続のコンテキスト内でブロック内のコードを実行する、シンプルなラッパーメソッドにすぎません。より汎用的にするには、サブドメインを解析し、コード実行前に接続を確立するミドルウェアを作成する方法もあります。

ActiveRecord::Base.establish_connection(:primary_timdoe)

最終的なソリューションは、あなたのニーズと、特定テナントに割り当てられたデータを利用したい箇所の数によって変わります。

まとめ

おめでとうございます! あなたは今、2種類のマルチテナントRailsアプリケーションを構築し、モダンなWebアプリケーションにおける複数テナント対応のさまざまな手法についての知識を身につけました。

本記事で学んだ内容を簡単におさらいしましょう。

  • Webアプリケーションのマルチテナンシーには主に3つのレベルがある。データベース行レベル、スキーマレベル、データベースレベルである。
  • 各アプローチには長所と短所があり、ハードウェアの制約やテナント情報の分離レベルに応じて選択すべきである。
  • 外部ライブラリなしに、Ruby on Railsフレームワークだけですべてのマルチテナンシーレベルを実装できる。
  • Ruby on Railsはカスタムサブドメインを標準でサポートしているため、各テナントに固有のサブドメインを割り当てるマルチテナントアプリとの相性は抜群である。

本記事を読んで、マルチテナントRuby on Railsアプリケーションの構築を楽しんでいただけたなら幸いです。

P.S. Ruby Magicの記事を公開と同時にお読みになりたい方は、ぜひRuby Magicニュースレターをご購読ください。見逃すことはありません!

  1. Rails5でのAngularの使用

    あなたは前にその話を聞いたことがあります。分散型で完全に機能するバックエンドAPIと、通常のツールセットで作成されたフロントエンドで実行されているアプリケーションがすでにあります。 次に、Angularに移動します。または、AngularをRailsプロジェクトと統合する方法を探しているだけかもしれません。これは、この方法を好むためです。私たちはあなたを責めません。 このようなアプローチを使用すると、両方の世界を活用して、たとえばRailsとAngularのどちらの機能を使用してフォーマットするかを決定できます。 構築するもの 心配する必要はありません。このチュートリアルは、この目的のた

  2. Rubyでコマンドラインアプリケーション(CLI)を作る方法を徹底解説

    RubyはWebアプリケーション開発のイメージが強く、それ以外の用途があることを忘れられがちです。そこで本記事では、Rubyを使ってコマンドラインアプリケーション(CLI)を構築する方法を詳しく解説します。 まず、皆さんがよく目にするコマンドラインアプリケーションの例を挙げてみましょう。 psql(PostgreSQLの対話型ターミナル) rails bundler gem git CLIアプリケーションの作り方はさまざまですが、この記事では代表的な3つの方法に焦点を当てて紹介します。 この記事で学べること ARGV配列の使い方 OptParseライブラリの使い方 Thor gemの使い