マルチテナントとは

マルチテナントはSlackに代表されるような主にSaas(Softeware as a Service)を提供する企業で使われている仕組みで複数のユーザで1つのアプリケーションのインスタンスを共有しますがデータはユーザ毎に独立して保持することができます。各ユーザのことをショッピングモールの店舗にたとえてテナントと呼びます。1つのアプリケーションのインスタンスを共有するため契約したプランによって使える機能の制限はありますがどのユーザも同じ機能を利用することができます。そのためある一人のユーザだけに特別なカスタマイズを行うといったことはマルチテナントでは行うのは困難です。

本文書はLaravel用のマルチテナントパッケージstancl/tenancyの動作確認を行います。マルチテナントのパッケージには本ブログでも以前紹介したhyn/multi-tenantパッケージも存在します。

stancl/tenancyの特徴

stancl/tenancyでは各テナントへのアクセスはサブドメインを利用して行います。reffect.co.jpというドメインにfooという名前のテナントを追加した場合はfoo.reffect.co.jpでアクセスを行います。barという名前のテナントであればbar.reffect.co.jpでアクセスを行います。

stancl/tenancyではサブドメイン毎つまりユーザ毎にデータベース、テーブルが別々に作成されます。1つのテーブルを複数のユーザ(テナント)で共有することはありません。

メインのドメインであるreffect.co.jpも使うことができます。メインのドメインのことをこのパッケージではCentral Domainと読んでいます。

環境の構築

stancl/tenancyパッケージを使用するためにはLaravelのバージョンが6.0以上である必要があります。

Laravelのインストール

stancl/tenancyパッケージをインストールする前にLaravelのインストールを行っておく必要があります。下記のコマンドではプロジェクト名にlaravel_tenancyとつけていますが名前は任意です。このディレクトリの下にLaravelがインストールされます。


$ composer create-project --prefer-dist laravel/laravel laravel_tenancy

インストールして動作確認を行なったLaravelのバージョンは6.11.0です。


$ php artisan -V
Laravel Framework 6.11.0

stancl/tenancyのパッケージのインストール

Laravelのインストールが完了したらstancl/tenancyのパッケージのインストールを行います。


$ composer require stancl/tenancy

インストール後はLaravel内の既存のファイルに対しstancl/tenancyパッケージを利用するためのコード追加や更新作業が必要になります。この設定は手動でも行うことができますが自動のツールが準備されています。

php artisan tenancy:installを行うと必要な追加/更新を自動で行ってくれます。途中でtenantsの管理に必要なテーブルを作成するマイグレーションファイルを作成するか聞かれるのでYesとします。


 $ php artisan tenancy:install
Installing stancl/tenancy...
✔️  Created config/tenancy.php
✔️  Set middleware priority
✔️  Created routes/tenant.php

This package lets you store data about tenants either in Redis or in a relational database like MySQL. To store data about tenants in a relational database, you need a few database tables.

 Do you wish to publish the migrations that create these tables? (yes/no) [yes]:
 > yes

✔️  Created migrations. Remember to run [php artisan migrate]!
✔️  Created database/migrations/tenant folder.
✨️ stancl/tenancy installed successfully.

Laravelの動作確認

処理が完了したらphp artisan servコマンドで開発用サーバの起動を行います。


 $ php artisan serv

開発サーバ起動後にブラウザでlocalhost:8000でアクセスを行います。通常ではLaravelの初期画面が表示されますが、Connection refusedエラーが発生します。

stancl/tenancyパッケージではドメイン名が重要なのでIPアドレスではなくドメイン名を利用してアクセスを行います。
エラーが発生
エラーが発生

stancl/tenancyパッケージを入れ初期設定を行った後にLaravelにアクセスするとドメインの情報がdomains(後ほど作成)テーブルに存在するかどうかチェックを行うようになります。

domainsテーブルはテナントの情報が保存されているのでメインドメインのlocalhostにアクセスする場合はdomainsテーブルでチェックを行う必要がありません。これを回避するためにconfig¥tenancy.phpファイルのexempt_domainsのパラメータにlocalhostを設定します。


'exempt_domains' => [ // e.g. domains which host landing pages, sign up pages, etc
    'localhost',
],
exemptには除外という意味があります。ドメインlocalhostをチェックの対象から除外することができます

設定後にlocalhost:8000にアクセスするとLaravelの初期画面が表示されます。

Laravelの初期画面
Laravelの初期画面

データベースの設定

本文書では簡易的なデータベースであるSQLiteデータベースを利用します。

Laravelのインストールディレクトリのdatabaseディレクトリの下にdatabase.sqliteファイルを作成します。


$ touch database/database.sqlite

.envファイルを開き、デフォルトのMysqlの設定からSQLiteの設定に変更します。下記のDB_CONNECTIONパラメータのみ残しその他のデータベースに関する行は削除します。


DB_CONNECTION=sqlite

テーブルの作成

データベースの設定が完了したので、php artisan migrateコマンドを実行してテーブルの作成を行います。


 $ php artisan migrate
Migration table created successfully.
Migrating: 2014_10_12_000000_create_users_table
Migrated:  2014_10_12_000000_create_users_table (0.01 seconds)
Migrating: 2014_10_12_100000_create_password_resets_table
Migrated:  2014_10_12_100000_create_password_resets_table (0 seconds)
Migrating: 2019_08_19_000000_create_failed_jobs_table
Migrated:  2019_08_19_000000_create_failed_jobs_table (0 seconds)
Migrating: 2019_09_15_000010_create_tenants_table
Migrated:  2019_09_15_000010_create_tenants_table (0 seconds)
Migrating: 2019_09_15_000020_create_domains_table
Migrated:  2019_09_15_000020_create_domains_table (0 seconds)

users, password_resets, failed_jobs以外にdomainsとtenantsの2つのテーブルが作成されていることがログから確認できます。これら2つのテーブルを使ってテナントの管理を行います。

domainsとtenantsのデータベース構成はdatabase/migrationsディレクトリ下にあるマイグレーションファイルで確認することができます。

tenantの設定

tenantの作成

テナント情報を保存するテーブルの作成が完了したので、動作確認のためtinkerを利用して2つのテナントを作成します。このパッケージではサブドメインによってテナントを識別するので作成するテナントにはfooとbarというサブドメインを指定しています。


 $ php artisan tinker
Psy Shell v0.9.12 (PHP 7.2.21 — cli) by Justin Hileman
>>> use Stancl\Tenancy\Tenant;
>>> $tenant1 = Tenant::new()->withDomains('foo.localhost')->save();
=> Stancl\Tenancy\Tenant {#3069
     +data: [
       "id" => "4c35a94a-81d5-4f43-b9b2-13dbe1e3b773",
     ],
     +domains: [
       "foo.localhost",
     ],
     +persisted: true,
   }
>>> $tenant2 = Tenant::new()->withDomains('bar.localhost')->save();
=> Stancl\Tenancy\Tenant {#3112
     +data: [
       "id" => "1e6f55af-0f4e-443a-b0b9-9fd230e56210",
     ],
     +domains: [
       "bar.localhost",
     ],
     +persisted: true,
   }

各テナントには作成時に指定したドメインを指定してアクセスを行います。ブラウザから各テナントにアクセスしてみましょう。foo.localhost:8000にアクセスするとfoo.localhost:8000/app、bar.localhost:8000にアクセスするとbar.localhost:8000/appにリダイレクトされます。

リダイレクト画面ではどちらも同じ内容が表示されているように見えますが、表示されているテナントのIDが異なることが確認できます。

foo.localhostへのアクセス
foo.localhostへのアクセス

[commetn]メッセージは日本語でこれはあなたのマルチドメインアプリケーションです。現在のテナントのIDは66XXXです。[/comment]

bar.localhostへのアクセス
bar.localhostへのアクセス

各ドメインへのアクセスに対するルーティングはroutes¥tenant.phpファイルに記述されています。先程アクセス時に表示されているメッセージが記述されていることが確認できます。


Route::get('/app', function () {
    return 'This is your multi-tenant application. The id of the current tenant is ' . tenant('id');
});

テナント管理テーブルの確認

GUIデータベース管理ソフトであるTablePlusを利用してSQLiteデータベースにアクセスしてtenantsとdomainsテーブルの中身を確認してみます。

domainsテーブルにはドメインとIDの情報、tenantsテーブルにはIDが保存されています。

domainsテーブル
domainsテーブル
tenantsテーブル
tenantsテーブル

テナントへの追加情報の付与

SlackのようなSaasのサービスを提供したい場合は無料プランや有料プランの設定が行えます。このパッケージではtenantsテーブルにプラン情報を保存することも可能です。3つ目のテナントを作成を通してプランの設定方法を確認します。

今回は最初の2つのテナント作成時とは異なりwithDataメソッドを追加しています。


>>> $tenant3 = Tenant::new()->withDomains('freebar.localhost')
->withData(['plan'=>'free'])->save();
=> Stancl\Tenancy\Tenant {#3130
     +data: [
       "plan" => "free",
       "id" => "061850db-a502-40f5-b92a-3a555c630d1c",
     ],
     +domains: [
       "freebar.localhost",
     ],
     +persisted: true,
   }

TablePlusでtenantsテーブルを確認するとwithDataで指定した配列のplan:freeが追加されていることがわかります。

テナントに追加情報を付与
テナントに追加情報を付与

登録したデータはヘルパー関数tenant()のgetメソッドで取得することができます。getメソッドの動作確認のためtenant.phpの/appのルーティングを下記のように書き換えます。


Route::get('/app', function () {
    return 'Current Plan is '. tenant()->get('plan');
});

ブラウザでfreebar.localhost:8000にアクセスするとplan情報が取得できます。

planの情報をヘルパー関数でtenant()で取得
planの情報をヘルパー関数でtenant()で取得
bar.localhost, foo.localhostにアクセスするとplanが設定されていないのでCurrent Plan is と表示されます

各テナントのテーブル作成

stancl/tenancyパッケージではテナント毎に独立したデータベースとテーブルを持ちます。各テナントのテーブルを作成するためにdatabase¥migrations¥tenantディレクトリの下にテーブル作成用のマイグレーションファイルを保存する必要があります。

database¥migrationsの下にあるusers, password_resets, failed_jobsの3つのマイグレーションファイルを複製してtenantディレクトリの下に保存します。

php artisan tenants:migrateコマンドを実行すると作成したtenants毎にテーブルが作成されます。

php artisan migrateではないので注意してください。php artisan migrateはメインのドメインでテーブルを作成する際に利用します。

 $ php artisan tenants:migrate
Tenant: 4c35a94a-81d5-4f43-b9b2-13dbe1e3b773
Migration table created successfully.
Migrating: 2014_10_12_000000_create_users_table
Migrated:  2014_10_12_000000_create_users_table (0.01 seconds)
Migrating: 2014_10_12_100000_create_password_resets_table
Migrated:  2014_10_12_100000_create_password_resets_table (0 seconds)
Migrating: 2019_08_19_000000_create_failed_jobs_table
Migrated:  2019_08_19_000000_create_failed_jobs_table (0 seconds)
Tenant: 1e6f55af-0f4e-443a-b0b9-9fd230e56210
Migration table created successfully.
Migrating: 2014_10_12_000000_create_users_table
Migrated:  2014_10_12_000000_create_users_table (0 seconds)
Migrating: 2014_10_12_100000_create_password_resets_table
Migrated:  2014_10_12_100000_create_password_resets_table (0 seconds)
Migrating: 2019_08_19_000000_create_failed_jobs_table
Migrated:  2019_08_19_000000_create_failed_jobs_table (0 seconds)
Tenant: c7a6eaa9-d210-4588-8425-5c19503f492a
Migration table created successfully.
Migrating: 2014_10_12_000000_create_users_table
Migrated:  2014_10_12_000000_create_users_table (0 seconds)
Migrating: 2014_10_12_100000_create_password_resets_table
Migrated:  2014_10_12_100000_create_password_resets_table (0 seconds)
Migrating: 2019_08_19_000000_create_failed_jobs_table
Migrated:  2019_08_19_000000_create_failed_jobs_table (0 seconds)

SQLiteの場合はデータベース毎にファイルが分かれているので、databaseディレクトリを確認すると各テナントに応じたデータベースファイルが自動で作成されていることが確認できます。各テナントに対応するファイル名はtenant+tenant IDです。


$ ls
database.sqlite
factories
migrations
seeds
tenant1e6f55af-0f4e-443a-b0b9-9fd230e56210
tenant4c35a94a-81d5-4f43-b9b2-13dbe1e3b773
tenantc7a6eaa9-d210-4588-8425-5c19503f492a

ユーザの登録

テナント毎にusersテーブルが作成されたので、テナント毎にユーザを登録できるのか確認をしていきます。

ログイン認証機能の設定

ログイン認証機能を利用するためにlaravel/uiパッケージのインストールを行います。


 $ composer require laravel/ui
Using version ^1.1 for laravel/ui

ログイン認証の機能とvue.jsのインストールを行います。


$ php artisan ui vue --auth
Vue scaffolding installed successfully.
Please run "npm install && npm run dev" to compile your fresh scaffolding.
Authentication scaffolding generated successfully.

JavaScriptパッケージのインストールとビルドを行います。


 $ npm install && npm run dev

ルーティングの設定

ここまでの設定を行うとweb.phpファイルには認証のためのルーティングが追加されます。


Route::get('/', function () {
    return view('welcome');
});

Auth::routes();

Route::get('/home', 'HomeController@index')->name('home');

メインのlocaslhost:8000(テナントではない)にアクセスするとLoginとRegisterのリンクが表示されどちらのページにアクセス可能です。

LoginとRegisterのリンクが表示
LoginとRegisterのリンクが表示

テナント側のルーティングはweb.phpではなくroutes¥tenant.phpファイルなので、bar.localhost:8000にアクセスすると/appにリダイレクトされます。同様に/loginにアクセスしても/appにリダイレクトされます。

tenant.phpファイルに”/”のルーティングを追加するとページが表示されます。


Route::get('/', function () {
    return view('welcome');
});
bar.localhost:8000にアクセス
bar.localhost:8000にアクセス

しかし、今度はメインのドメインのlocalhost:8000にアクセスすると404 Not Foundが表示されアクセスができなくなります。

404 Not Found
404 Not Found

web.phpとtenants.phpのルーティングでコンフリクト(重複)が発生しているたためです。どちらのファイルにも”/”ルートのルーティングが記述されておりその重複が原因でlocalhost:8000に設定した”/”へのアクセスが行えるなくなるためです。

RouteServiceProvider.phpファイルの記述を変更することでこの問題は回避することができます。

デフォルトのコードは下記のようになっています。


protected function mapWebRoutes()
{
    Route::middleware('web')
            ->namespace($this->namespace)
            ->group(base_path('routes/web.php'));
}

protected function mapApiRoutes()
{
    Route::prefix('api')
            ->middleware('api')
            ->namespace($this->namespace)
            ->group(base_path('routes/api.php'));
}

下記のように変更を行います。


protected function mapWebRoutes()
{
    foreach (config('tenancy.exempt_domains', []) as $domain) {
        Route::middleware('web')
            ->domain($domain)
            ->namespace($this->namespace)
            ->group(base_path('routes/web.php'));
    }
}

protected function mapApiRoutes()
{
    foreach (config('tenancy.exempt_domains', []) as $domain) {
        Route::prefix('api')
            ->middleware('api')
            ->domain($domain)
            ->namespace($this->namespace)
            ->group(base_path('routes/api.php'));
    }
}

設定完了後はlocalhost:8000でもbar.localhost.8000, foo.localhost.8000でも同じ画面が表示されます。

tenant.phpには認証のルーティングも追加する必要があります。


Auth::routes();

Route::get('/home', 'HomeController@index')->name('home');

設定変更後、Registerページに移動するとCSSが適用されていないことがわかります。

CSSが適用されていないログインページ
CSSが適用されていないログインページ

CSSとJavaScriptのリンクはviews¥layouts¥app.blade.phpファイルに記述されているので、ヘルパー関数assetをヘルパー関数mixに変更する必要があります。


<script src="{{ mix('js/app.js') }}" defer></script>

<link href="{{ mix('css/app.css') }}" rel="stylesheet">

設定変更後、もう一度ブラウザでアクセスするとテナントのRegisterページにCSSが適用されていることがわかります。

CSSが適用されたRegiser画面
CSSが適用されたRegiser画面

ユーザの登録

テナントbar.localhostにユーザを登録してみましょう。

ユーザの登録
ユーザの登録

ユーザを登録後にTablePlusを利用してbar.localhostに対応するデータベースにアクセスしusersテーブルに登録したユーザが存在していることが確認できます。

TablePlusでユーザを確認
TablePlusでユーザを確認
テナントデータベースへのアクセスはdomainsテーブルでtenant_idを確認し、そのtenant_idの名前を含むデータベース名を確認するとことで行なえます。

barでログインしているユーザで他のテナント(foo.localhost/home)にアクセスするとfoo.localhost/loginにリダイレクトされます。別のテナントにはログインできないことがわかります。

テナントfoo.localhostにもユーザが登録できることを確認してください。

foo.localhostにユーザ登録
foo.localhostにユーザ登録

テナント毎にユーザが登録することがわかりましたが今後は他の機能の動作確認についても行なっていきます。