RareJob Tech Blog

レアジョブテクノロジーズのエンジニア・デザイナーによる技術ブログです

Tenancy for Laravelを試してみる

こんにちは。
プロダクト開発部 PROGOS•SMART Method開発グループ
所属の奥山と申します。

レアテクは今年4月、株式会社レアジョブの子会社として誕生した会社です。
誕生と同時に自分も入社し、以降、SMART Methodの開発に携わらせていただいてます。

さて、テックブログを書くと分かったのが1週間前くらいなのですが、
(※これは自分がスケジュール把握してなかったせいです。突然割り振られるものではありません。)
その時、ちょうど少しばかり引っかかっていたキーワードが
SaaS、マルチテナント」でした。

理由を言語化すると複数、しかも意外と一つにまとまらなくて混乱したので、「その場のノリ」とします。

概観

書いてあることは下記の通りです。

  • マルチテナントアーキテクチャに関する用語
  • Tenancy for Laravelをハンズオン形式で紹介
  • ハンズオンをした所感

マルチテナントアーキテクチャの調査

用語:

  • SaaS:ライセンス購入ではなく、定期的にお支払いいただき、自社で開発しているソフトウェアにアクセスしてご利用いただくサービス。
    お客様のデータは自社で管理しているサーバ内で管理する。ビジネスモデルの文脈もあるが割愛する。
  • テナント:自社のSaaSに契約いただいているお客様1単位(企業単位、個人事業主単位、など)を表現する。
  • マルチテナント:契約しているお客様が複数いらっしゃる状態を指す。

マルチテナントについていくつか資料を読んだのですが、AWSの動画の内容がしっくりきたので、 その中からいくつか紹介いたします。 内容は全て英語のため、自分の方で理解した内容を書きます。

インフラ構成の方針:

  • サイロ:DBやサーバー、アプリケーションなどが単一のテナントで実行されている環境
  • プール:DBやサーバー、アプリケーションなどが複数のテナント間で共有されている環境
  • ブリッジ:サイロとプールを混ぜたもの

その他

  • お客さんを一意に識別する方法についての一例
  • テナント登録が実行されたら、どう実行環境を立ち上げるか?
  • メトリクスについて
  • アプリケーション・インフラをひっくるめたレベルで実装する方法の例
  • 顧客ごとの課金に関する話

などについても言及されてました。
理解した話ではないので細かく言及しません。

概観がなんとなく掴めたところで、実物を触ってみたいと思います。
候補として、AWSの動画の中で示されている実装パターンのハンズオンがあります。
しかし、まずは自分が慣れた道具でマルチテナントに触れてみたいと思いました。
Laravelでマルチテナントを実現するサードパーティライブラリはいくつかあるのですが、
こちらのドキュメントを読み、Tenancy for Laravelに手軽さを感じ、選択しました。

Tenancy for Laravel ハンズオン

方針:

  • テナントがどう作成されるか確認します
  • CentralとTenantの間でデータがどうシェアされるか確認します

前提となる開発環境:

  • Mac M1チップ macOS monterey 12.3
  • PHP8.1.10
  • Laravel8.83.23
  • MySQL8.0.30
  • composer が利用できる

概念説明:

インストール:

composer create-project laravel/laravel tenancy "8.*"
cd tenancy
composer require stancl/tenancy
php artisan tenancy:install
php artisan migrate

ここまでやると

  • database/migrations のなかにtenantディレクトリができます。
  • config/tenant.phpが作成されます。

ファイル構成

対象のDBには下記のようにテーブルが作成されます。

mysql> show tables;
+------------------------+
| Tables_in_tenancy      |
+------------------------+
| domains                |
| failed_jobs            |
| migrations             |
| password_resets        |
| personal_access_tokens |
| tenants                |
| users                  |
+------------------------+
7 rows in set (0.00 sec)

対象ファイルへの追記作業:

下記のようにファイルに対して指定した内容を記述していきます。

config/app.php

 /*
  * Application Service Providers...
  */
 App\Providers\AppServiceProvider::class,
 App\Providers\AuthServiceProvider::class,
 // App\Providers\BroadcastServiceProvider::class,
 App\Providers\EventServiceProvider::class,
 App\Providers\RouteServiceProvider::class,
 App\Providers\TenancyServiceProvider::class, // これを追加する

app/ModelsにTenant.phpを作成。

<?php

namespace App\Models;

use Stancl\Tenancy\Database\Models\Tenant as BaseTenant;
use Stancl\Tenancy\Contracts\TenantWithDatabase;
use Stancl\Tenancy\Database\Concerns\HasDatabase;
use Stancl\Tenancy\Database\Concerns\HasDomains;

class Tenant extends BaseTenant implements TenantWithDatabase
{
    use HasDatabase, HasDomains;
}

config/tenancy.php

<?php
// tenant_model のvalueの箇所を書き換える
'tenant_model' => \App\Models\Tenant::class, 

app/Providers/RouteServiceProvider.php

<?php

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

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

protected function centralDomains(): array
{
    return config('tenancy.central_domains');
}

同ファイルのbootメソッド

<?php

public function boot()
{
    $this->configureRateLimiting();

    $this->routes(function () {
        $this->mapApiRoutes();
        $this->mapWebRoutes();
    });
}

database/migrations/2014_10_12_000000_create_users_table.php を database/migrations/tenant/2014_10_12_000000_create_users_table.php に移動する。

テナント作成を実体験する:

$ php artisan tinker
>>> $tenant1 = App\Models\Tenant::create(['id' => 'foo']);
=> App\Models\Tenant {#3653
     id: "foo",
     data: null,
     updated_at: "2022-09-07 12:48:59",
     tenancy_db_name: "tenantfoo",
   }

>>> $tenant1->domains()->create(['domain' => 'foo.localhost']);
=> Stancl\Tenancy\Database\Models\Domain {#4525
     domain: "foo.localhost",
     tenant_id: "foo",
     updated_at: "2022-09-07 12:49:03",
     created_at: "2022-09-07 12:49:03",
     id: 1,
     tenant: App\Models\Tenant {#4678
       id: "foo",
       created_at: "2022-09-07 12:48:59",
       updated_at: "2022-09-07 12:48:59",
       data: null,
       tenancy_db_name: "tenantfoo",
     },
   }

ここまでやってMySQLのコンソールで確認してみると

mysql> show databases;
+--------------------+
| Database           |
+--------------------+
| information_schema |
| mysql              |
| performance_schema |
| sys                |
| tenancy            |
| tenantfoo          |
+--------------------+

とtenantfooというデータベースが増えてます。 テーブル構成は下記の通りです。

mysql> show tables;
+---------------------+
| Tables_in_tenantbar |
+---------------------+
| migrations          |
| users               |
+---------------------+
2 rows in set (0.00 sec)

 テーブルの中身はそれぞれ下記の通りです。

mysql> select * from migrations;
+----+--------------------------------------+-------+
| id | migration                            | batch |
+----+--------------------------------------+-------+
|  1 | 2014_10_12_000000_create_users_table |     1 |
+----+--------------------------------------+-------+
1 row in set (0.00 sec)

mysql> select * from users;
Empty set (0.00 sec)

tinkerで引き続き下記のように実行してみます。

>>> $tenant2 = App\Models\Tenant::create(['id' => 'bar']);
=> App\Models\Tenant {#4680
     id: "bar",
     data: null,
     updated_at: "2022-09-07 12:49:08",
     tenancy_db_name: "tenantbar",
   }

>>> $tenant2->domains()->create(['domain' => 'bar.localhost']);
=> Stancl\Tenancy\Database\Models\Domain {#4672
     domain: "bar.localhost",
     tenant_id: "bar",
     updated_at: "2022-09-07 12:49:13",
     created_at: "2022-09-07 12:49:13",
     id: 2,
     tenant: App\Models\Tenant {#3688
       id: "bar",
       created_at: "2022-09-07 12:49:08",
       updated_at: "2022-09-07 12:49:08",
       data: null,
       tenancy_db_name: "tenantbar",
     },
   }

すると、tenantbarという名前のデータベースが増えてます。

mysql> show databases;
+--------------------+
| Database           |
+--------------------+
| information_schema |
| mysql              |
| performance_schema |
| sys                |
| tenancy            |
| tenantbar          |
| tenantfoo          |
+--------------------+
7 rows in set (0.00 sec)

試しにtenancyのdomainsとtenantsテーブルをみてみると、下記の通りとなります。

mysql> select * from domains;
+----+---------------+-----------+---------------------+---------------------+
| id | domain        | tenant_id | created_at          | updated_at          |
+----+---------------+-----------+---------------------+---------------------+
|  1 | foo.localhost | foo       | 2022-09-07 12:49:03 | 2022-09-07 12:49:03 |
|  2 | bar.localhost | bar       | 2022-09-07 12:49:13 | 2022-09-07 12:49:13 |
+----+---------------+-----------+---------------------+---------------------+
2 rows in set (0.00 sec)

mysql> select * from tenants;
+-----+---------------------+---------------------+----------------------------------+
| id  | created_at          | updated_at          | data                             |
+-----+---------------------+---------------------+----------------------------------+
| bar | 2022-09-07 12:49:08 | 2022-09-07 12:49:08 | {"tenancy_db_name": "tenantbar"} |
| foo | 2022-09-07 12:48:59 | 2022-09-07 12:48:59 | {"tenancy_db_name": "tenantfoo"} |
+-----+---------------------+---------------------+----------------------------------+
2 rows in set (0.00 sec)

ここまででお分かりのように、Tenantモデルを用いて作成処理を実行すると

  • 1テナントごとにデータベースが自動で作成され
  • マイグレーションファイルが実行済みとなりテーブルが作成される

ことがわかります。

アプリケーション起動前の準備:

routes/tenant.phpの該当箇所を下記のように変更します。 それぞれのテナントで/tenant-web にアクセス可能となります。

<?php

省略

Route::middleware([
    'web',
    InitializeTenancyByDomain::class,
    PreventAccessFromCentralDomains::class,
])->group(function () {
    Route::get('/', function () {
        return 'This is your multi-tenant application. The id of the current tenant is ' . tenant('id');
    });
    // ここを追加。フルパスで指定する必要あり。
    Route::get('/tenant-web', 'App\Http\Controllers\HandsOnController@index');
});

routes/web.phpに下記のように追記します。

routes/web.php

Route::get('/central-web', 'HandsOnController@index');

app/Http/Controllers/HandsOnController.phpを作成します。

<?php

namespace App\Http\Controllers;

use Illuminate\Http\Request;

class HandsOnController extends Controller
{
    public function index()
    {
        $tenantId = tenant('id') ?? 'Central';
        return "Now, you are in ".$tenantId;
    }
}

テナントにアクセスしてみる:

この時点で3つのURLを叩くことができます。

localhost:8000 // Central Route
foo.localhost:8000 // Tenant Route (foo)
bar.localhost:8000 // Tenant Route(bar)

アプリケーションを起動してアクセスしてみます。

php artisan serve

例えば

foo.localhost:8000/tenant-web

にアクセスすると、下記イメージのようになります。

foo.localhost:8000/tenant-web

localhost:8000/central-web

とすれば、下記イメージのようになります。

localhost:8000/central-web

状況整理:

  • 1つのドメインに対して1つのデータベースが割り当てられています。
  • AWSの動画で紹介される用語を使えば、サイロに近いと思われます。
    ※ただし、あちらは実行環境の方の話をしているので厳密には違います。

少しいじる:

Central Routeの方で、テナント情報を集約してみたいと思います。
ECサイトで言えば、「お客様の情報をまとめてリストとして表示したい」に近しい行為かと思います。

確認用データを準備します。
database/seeders/DatabaseSeeder.php を下記のように変更します。

<?php

namespace Database\Seeders;

use Illuminate\Database\Seeder;
use App\Models\Tenant;
use App\Models\User;

class DatabaseSeeder extends Seeder
{
    /**
     * Seed the application's database.
     *
     * @return void
     */
    public function run()
    {
        // \App\Models\User::factory(10)->create();
        Tenant::all()->runForEach(function () {
            User::factory()->create();
        });
    }
}

下記コマンドでテナントのDBのみにデータが入ります。

php artisan tenants:seed

実際にMySQLのコンソールでデータを見ると、下記の通りになります。

mysql> use tenancy;
Reading table information for completion of table and column names
You can turn off this feature to get a quicker startup with -A

Database changed
mysql> select * from users;
Empty set (0.01 sec)

mysql> use tenantbar;
Reading table information for completion of table and column names
You can turn off this feature to get a quicker startup with -A

Database changed
mysql> select * from users;
+----+------------------------+--------------------+---------------------+--------------------------------------------------------------+----------------+---------------------+---------------------+
| id | name                   | email              | email_verified_at   | password                                                     | remember_token | created_at          | updated_at          |
+----+------------------------+--------------------+---------------------+--------------------------------------------------------------+----------------+---------------------+---------------------+
|  1 | Mrs. Bryana Langosh MD | sean52@example.org | 2022-09-07 14:23:48 | $2y$10$92IXUNpkjO0rOQ5byMi.Ye4oKoEa3Ro9llC/.og/at2.uheWG/igi | D4audYw0BN     | 2022-09-07 14:23:48 | 2022-09-07 14:23:48 |
|  2 | Alexane Murazik MD     | xdoyle@example.org | 2022-09-07 14:23:48 | $2y$10$92IXUNpkjO0rOQ5byMi.Ye4oKoEa3Ro9llC/.og/at2.uheWG/igi | fcVff6ssPX     | 2022-09-07 14:23:48 | 2022-09-07 14:23:48 |
+----+------------------------+--------------------+---------------------+--------------------------------------------------------------+----------------+---------------------+---------------------+
2 rows in set (0.00 sec)

mysql> use tenantfoo;
Reading table information for completion of table and column names
You can turn off this feature to get a quicker startup with -A

Database changed
mysql> select * from users;
+----+------------------------+-------------------------+---------------------+--------------------------------------------------------------+----------------+---------------------+---------------------+
| id | name                   | email                   | email_verified_at   | password                                                     | remember_token | created_at          | updated_at          |
+----+------------------------+-------------------------+---------------------+--------------------------------------------------------------+----------------+---------------------+---------------------+
|  1 | Ms. Shemar Stoltenberg | delta23@example.com     | 2022-09-07 14:23:48 | $2y$10$92IXUNpkjO0rOQ5byMi.Ye4oKoEa3Ro9llC/.og/at2.uheWG/igi | JwdwYb4Wji     | 2022-09-07 14:23:48 | 2022-09-07 14:23:48 |
|  2 | Lambert Little         | dare.lester@example.net | 2022-09-07 14:23:48 | $2y$10$92IXUNpkjO0rOQ5byMi.Ye4oKoEa3Ro9llC/.og/at2.uheWG/igi | CcjBpEFH9b     | 2022-09-07 14:23:48 | 2022-09-07 14:23:48 |
+----+------------------------+-------------------------+---------------------+--------------------------------------------------------------+----------------+---------------------+---------------------+
2 rows in set (0.00 sec)

先ほど作成したapp/Http/Controllers/HandsOnController.phpに下記のようにメソッドを追加します。

<?php
省略

public function aggregate()
    {
        $aggregate = collect();
        $tenants = Tenant::all();
        foreach($tenants as $tenant){
            tenancy()->initialize($tenant->id);
            $aggregate = $aggregate->merge(User::all());
        }

        dd($aggregate->pluck('name'));
    }

最後に、config/app.php

Route::get('/aggregate', 'HandsOnController@aggregate');

を追加します。

localhost:8000/aggregate にアクセスすると

Illuminate\Support\Collection {#342 ▼
  #items: array:4 [▼
    0 => "Mrs. Bryana Langosh MD"
    1 => "Alexane Murazik MD"
    2 => "Ms. Shemar Stoltenberg"
    3 => "Lambert Little"
  ]
  #escapeWhenCastingToString: false
}

と表示され、確かに集約できます。

補足すると、テナントの方に実装すれば、テナント側でも呼び出せてしまいます。

barテナントでfooテナントが見れる状態

この辺りはコーディング、インフラ両方の側面で縛りをかける必要があるとわかりました。

以上、ハンズオンでした。

所感

思いついた順に述べます。

  • テナントが別テナントにアクセスできないよう仕組みを考える重要性を擬似的に体験できて良かったです。
  • テナント登録からログインの導線をどう実装するのが良いか考える必要があります。
    公式ドキュメントにスポンサーコンテンツがあり、そこでシングルサインオンに関する言及がなされているようです。
  • インフラに関する知識と経験の重要性をさらに強く認識したため、消化不良感があります。
  • AWSの動画内で言及されている内容について少し消化できたので、実際のハンズオンを見つつ、他人に噛み砕いて説明しながら実装できることを目的として理解を深めてみようかと思います。

参考にしたコンテンツ

AWS re:Invent 2021 - SaaS architecture patterns: From concept to implementation - YouTube
Tenancy for Laravel