[tenancy] Laravelでマルチテナント対応するときに見るページ

[tenancy] Laravelでマルチテナント対応するときに見るページ

複数の企業などのテナント単位に分かれたデータベースに対して、一つのLaravelアプリケーションで処理するマルチテナントアプリケーションを実装する方法です。

マルチテナントとは

Saas型のサービスを提供する上で高いリソース効率・運用効率を得るために採用する形態です。
今回は各テナントごとにデータベースを分ける方法で採ります。

詳しく説明しているサイトがあるので参考にして下さい。

Laravelでマルチテナントを実装する

Laravel 8では公式でマルチテナントに関する機能はサポートされていません。
マルチテナント対応するパッケージがいくつか提供されているため、その中でも実績と将来性、開発体制が整っている tenancy/tenancyを利用してマルチテナント対応を進めていきたいと思います。

Laravelは多機能なフレームワークです。
単純なDB接続だけであれば独自で実装することも出来ますが、Eloquent・クエリビルダ・マイグレーション・シーダー・コンソールコマンド・キュー・ブロードキャスト・ファイル・ログ…と様々な機能がマルチテナントに関わってきます。
自分で実装すると沼にハマるので辞めたほうが良いでしょう。

tenancy/tenancy

マルチテナントに対応するパッケージはいくつか存在しますが、 tenancy / tenancy が有名です。
tenacy/tenancyは、GitHubのStar 2.2kを持つtenancy / multi-tenantの後継のパッケージです。

HTTPリクエストの他、コンソールコマンドやキューなど幅広く対応しています。

ただし、公式ドキュメントが非常に薄っぺらいのが問題です。

インストールする

Installation

tenancy/tenancyは、それぞれの機能が別パッケージでばらばらになっており、全部入りの tenancy/tenancy パッケージと、ベースのフレームワークのみで必要なパッケージを別途インストールする tenancy/framework の二通りが存在します。

tenancy/tenancy の方がシンプルですが、不要なパッケージも含むため tenancy/framework を入れて必要な機能(パッケージ)を構築していく方法が推奨されています。

結局のところ、全部入りのものを入れたところで各機能で必要な設定を行わなければならないことには変わりません。

ベースのフレームワークをcomposerからインストールします。

composer require tenancy/framework

テナントモデルを実装する

データベースを分ける単位となるテナントを実装します。
ユーザーごとにデータベースを分ける場合は、Laravel標準ではUserモデルに実装します。
よくある単位は所属する組織でOrganizationやCompanyといったモデルに実装することも多いでしょう。

テナントモデルのマイグレーションを作成する

database/migrations/create_organization_table.php

<?php

use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;

class CreateOrganizationsTable extends Migration
{
    /**
     * Run the migrations.
     *
     * @return void
     */
    public function up()
    {
        Schema::create('organizations', function (Blueprint $table) {
            $table->id();
            $table->string('name')->unique();
            $table->timestamps();
        });
    }

    /**
     * Reverse the migrations.
     *
     * @return void
     */
    public function down()
    {
        Schema::dropIfExists('organizations');
    }
}

マイグレーションを実行しておきます。

php artisan migrate

テナントモデルを作成する

tenancyはテナントを識別するとき Tenant インターフェースが実装されているモデルを対象とします。
Organizationモデルを作成し、Tenantインターフェースを実装していきます。

app/Models/Organization.php

<?php

namespace App\Models;

use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Http\Request;
use Tenancy\Identification\Contracts\Tenant;

class TenantModel extends Model implements Tenant
{
    use HasFactory;

    protected $fillable = [
        'name',
        'subdomain',
    ];

    protected $dispatchesEvents = [
        'created' => \Tenancy\Tenant\Events\Created::class,
        'updated' => \Tenancy\Tenant\Events\Updated::class,
        'deleted' => \Tenancy\Tenant\Events\Deleted::class,
    ];

    /**
     * The attribute of the Model to use for the key.
     *
     * @return string
     */
    public function getTenantKeyName(): string
    {
        return 'name';
    }

    /**
     * The actual value of the key for the tenant Model.
     *
     * @return string|int
     */
    public function getTenantKey()
    {
        return $this->name;
    }

    /**
     * A unique identifier, eg class or table to distinguish this tenant Model.
     *
     * @return string
     */
    public function getTenantIdentifier(): string
    {
        return get_class($this);
    }
}
AllowsTenantIdentificationトレイトでシンプルに実装する

公式ドキュメント通りの実装をする場合は自分でインターフェースを実装せずともTenancy\Identification\Concerns\AllowsTenantIdentificationトレイトをuseするだけでもOKです。

個人的には何かあったときにトレイトで実装されている箇所が原因だと気付きづらくなるので、自分で実装する方法を選択しました。

インターフェースで実装していること

getTenantKeyNamegetTenantKeyは、テナントであることを判定するユニークな値を持つカラムを指定します。

getTenantIdentifierでは各affectsのパッケージでインスタンス化するテナントモデルを指定します。
特別な理由がない限り自身のテナントモデルです。

これらはフレームワーク側でテナントを識別・実体化するために使用されます。

ライフサイクルフック

$dispatchesEventsで、created, updated, deletedごとにイベントをセットします。
これによって、Eloquent経由でテナントモデルを追加・更新・削除するタイミングで関連する処理を行うことが出来ます。


・テナントが追加されたらテナント用のデータベースを作成してマイグレーションを実行する
・テナントが更新されたらマイグレーションを実行する
・テナントが削除されたらテナント用のデータベースを削除する

テナントを識別するためにResolverに登録する

テナントモデルを作った後、識別対象としてResolverに登録する必要があります。
Laravel標準で存在しているAppServiceProviderのregisterメソッドで以下のように追加します。

app/Providers/AppServiceProvider

<?php declare(strict_types=1);

namespace App\Providers;

use App\Models\Organization;
use Illuminate\Support\ServiceProvider;
use Tenancy\Identification\Contracts\ResolvesTenants;

class AppServiceProvider extends ServiceProvider
{
    /**
     * Register any application services.
     *
     * @return void
     */
    public function register()
    {
        $this->app->resolving(ResolvesTenants::class, function (ResolvesTenants $resolver) {
            $resolver->addModel(Organization::class);
            return $resolver;
        });
    }

    /**
     * Bootstrap any application services.
     *
     * @return void
     */
    public function boot()
    {
        //
    }
}

データベースドライバをインストールする

tenancyは2021年5月現在、SQLiteとMySQLの二つのデータベースに対応しています。
使用するデータベースごとにパッケージが提供されているため、合わせてセットアップします。

PostgreSQLには `未対応` で、現在対応中です。
もうすぐリリースされるよ!と2021年1月に返信があってから、あまり進展がないようです。
https://discuss.tenancy.dev/d/126-tenancy-v1-supports-postgresql

MySQLデータベースドライバ

Database Driver: MySQL

MySQLのバージョンは 5.7.6以上 に対応しています。

インストールする

composerからインストールします。

composer require tenancy/db-driver-mysql

affects connections

Eloquentやクエリビルダからデータベースへアクセスする時、テナントのデータベースに接続するようにします。

インストールする
composer require tenancy/affects-connections
テナント接続構成を解決する

tenancyの拡張パッケージは基本的に Event/Listner で構成していきます。
パッケージをインストールすることでEventがdispatchされるため、Listnerを作成・登録してconfigを差し替えたりしていきます。

テナントを解決してConfigureを構成するListnerを作成する

まずは識別されたテナントをConfigure構成イベントに渡すと共にEventをdispatchするListnerを作成します。
ここでdispatchしたEventはConfigureTenantConnectionが購読し、設定を引き継ぎます。

/app/Listeners/ResolveTenantConnection.php

<?php declare(strict_types=1);

namespace App\Listeners;

use Tenancy\Identification\Contracts\Tenant;
use Tenancy\Affects\Connections\Contracts\ProvidesConfiguration;
use Tenancy\Affects\Connections\Events\Resolving;
use Tenancy\Affects\Connections\Events\Drivers\Configuring;

class ResolveTenantConnection implements ProvidesConfiguration
{
    public function handle(Resolving $event)
    {
        return $this;
    }

    public function configure(Tenant $tenant): array
    {
        $config = [];

        event(new Configuring($tenant, $config, $this));

        return $config;
    }
}
Listnerをセットする

Laravelの EventServiceProvider の$listenにListenするイベント(key)と、Listner(value)をセットします。

/app/Providers/EventServiceProvider.php

<?php declare(strict_types=1);

namespace App\Providers;

use Illuminate\Foundation\Support\Providers\EventServiceProvider as ServiceProvider;
use Illuminate\Support\Facades\Event;

class EventServiceProvider extends ServiceProvider
{
    /**
     * The event listener mappings for the application.
     *
     * @var array
     */
    protected $listen = [
        \Tenancy\Affects\Connections\Events\Resolving::class => [
            App\Listeners\ResolveTenantConnection\ResolveTenantConnection::class,
        ],
    ];

    /**
     * Register any events for your application.
     *
     * @return void
     */
    public function boot()
    {
        //
    }
}

テナントのマイグレーション

テナントの追加・変更時にマイグレーションを実行します。

composer hooks-migrationをインストール

composer require tenancy/hooks-migration

テナントのマイグレーションファイルのパスを指定

リスナーでマイグレーションファイルが配置されているパスを指定します。
指定は database_pathにdatabaseからの相対パスをセットします。

例では database/tenant/migrations にマイグレーションファイルを配置した場合のパス指定例です。

ConfigureTenantMigrations.php

<?php declare(strict_types=1);

namespace App\Listeners;

use Tenancy\Hooks\Migration\Events\ConfigureMigrations;
use Tenancy\Tenant\Events\Deleted;

class ConfigureTenantMigrations
{
    public function handle(ConfigureMigrations $event)
    {
        if ($event->event->tenant) {
            if ($event->event instanceof Deleted) {
                $event->disable();
            } else {
                $event->path(database_path('tenant/migrations'));
            }
        }
    }
}

Listnerをセットする

/app/Providers/EventServiceProvider.php

<?php declare(strict_types=1);

namespace App\Providers;

use Illuminate\Foundation\Support\Providers\EventServiceProvider as ServiceProvider;
use Illuminate\Support\Facades\Event;
use App\Listeners\ConfigureTenantMigrations;

class EventServiceProvider extends ServiceProvider
{
    /**
     * The event listener mappings for the application.
     *
     * @var array
     */
    protected $listen = [
      \Tenancy\Hooks\Migration\Events\ConfigureMigrations::class => [
          ConfigureTenantMigrations::class,
      ],
    ];

    /**
     * Register any events for your application.
     *
     * @return void
     */
    public function boot()
    {
        //
    }
}

テナントのシーダー

テナントをEloquentから作成時にシーダーを実行する場合は、seed にシーダーをセットします。
このhookはTenantの更新・削除にも実行されるため、その場合はシーダーが実行されないように条件を追加します。

ConfigureTenantSeeds.php

<?php declare(strict_types=1);

namespace App\Listeners;

use Database\Tenant\Seeders\DatabaseSeeder;
use Tenancy\Hooks\Migration\Events\ConfigureSeeds;
use Tenancy\Tenant\Events\Deleted;
use Tenancy\Tenant\Events\Updated;

class ConfigureTenantSeeds
{
    public function handle(ConfigureSeeds $event)
    {
        if ($event->event->tenant) {
            if ($event->event instanceof Deleted
                || $event->event instanceof Updated) {
                $event->disable();
            } else {
                $event->seed(DatabaseSeeder::class);
            }
        }
    }
}

Listnerをセットする

/app/Providers/EventServiceProvider.php

<?php declare(strict_types=1);

namespace App\Providers;

use Illuminate\Foundation\Support\Providers\EventServiceProvider as ServiceProvider;
use Illuminate\Support\Facades\Event;
use App\Listeners\ConfigureTenantSeeds;

class EventServiceProvider extends ServiceProvider
{
    /**
     * The event listener mappings for the application.
     *
     * @var array
     */
    protected $listen = [
      \Tenancy\Hooks\Migration\Events\ConfigureSeeds::class => [
        ConfigureTenantSeeds::class,
      ],
    ];

    /**
     * Register any events for your application.
     *
     * @return void
     */
    public function boot()
    {
        //
    }
}

テスト

2021年6月時点ではテナントのユニットテストに関してフォローされておらず、独自で実装する必要があります。

GitHubにそれらしいリポジトリはありますが、入れてみてもエラーとなり機能しませんでした。
https://github.com/tenancy/testing

.env.testing

ユニットテスト実行時に参照される.env.testingにテストで接続するテナントのデータベース名を追加します。

.env.testing

<?php declare(strict_types=1);

TENANT_SLUG=test

DB_CONNECTION=mysql
DB_HOST=db
DB_PORT=3306
DB_DATABASE=test_central
DB_USERNAME=root
DB_PASSWORD=root

TestCase.phpを拡張する フックを活用するタイプ

ユニットテストのベースクラスに必要な機能を拡張します。
tenancyを追加するとマイグレーションやトランザクションを行ってくれる use RefreshDatabase は使えなくなるので独自で処理する必要があります。

・テストケース実行時に初回のみテナントを削除→作成することでマイグレーション(refresh)を行う
・作成したテナントをセットしてテナント情報を解決する

テスト初回実行時にmigrate:refreshが流れるため、通常のテストより時間が掛かります。
パフォーマンスを優先してテストケースごとにクリアしないため、完全にクリアな環境でテストケースが実行されない場合があります。いい感じに拡張して使われていたらコメント欄にご寄贈下さい。→ 2021/07/05 マイグレーションを活用するタイプを追記しました

TestCase.php

<?php declare(strict_types=1);

namespace Tests;

use App\Models\TenantModel;
use Illuminate\Foundation\Testing\TestCase as BaseTestCase;
use Illuminate\Support\Facades\Artisan;
use Tenancy\Facades\Tenancy;

abstract class TestCase extends BaseTestCase
{
    use CreatesApplication;

    protected string $tenantSlug;
    protected ?TenantModel $tenant;

    protected bool $refreshDatabase = false;

    protected function setUp(): void
    {
        parent::setUp();

        $this->tenantSlug = env('TENANT_SLUG', 'test');
        $this->bootTenancy();

        if ($this->refreshDatabase) {
            Tenancy::getTenantConnection()->beginTransaction();
        }
    }

    protected static bool $isMigration = false;

    protected function bootTenancy(): void
    {
        if (self::$isMigration === false) {

            // テナント・データベースをクリア
            TenantModel::where('slug', $this->tenantSlug)->first()?->delete();

            // テナント作成・マイグレーションを実行
            TenantModel::factory()->create(['slug' => $this->tenantSlug]);

            self::$isMigration = true;
        }
        $this->tenant = TenantModel::where('slug', $this->tenantSlug)->first();

        Tenancy::setTenant($this->tenant);
    }

    protected function tearDown(): void
    {
        if ($this->refreshDatabase) {
            Tenancy::getTenantConnection()->rollback();
        }

        parent::tearDown();
    }
}

使い方

<?php declare(strict_types=1);

namespace Tests\Unit\

use Tests\TestCase;

class SampleTest extends TestCase
{
    // use RefreshDatabaseと実行と同じ
    protected bool $refreshDatabase = true;

    public function test_sample(): void
    {
    }
}

TestCase.phpを拡張する マイグレーションを活用するタイプ

※必要なソースだけ先に記載しておきます。
 簡単な解説は後日追記します。

/tests/TestCase.php
 

<?php

declare(strict_types=1);

namespace Tests;

use App\Models\TenantDataModel;
use Illuminate\Foundation\Testing\TestCase as BaseTestCase;
use Tenancy\Facades\Tenancy;

abstract class TestCase extends BaseTestCase
{
    use CreatesApplication;
    use RefreshDatabaseTenant;

    protected array $connectionsToTransact = [];

    protected string $tenantSlug;
    protected ?TenantModel $tenant;

    protected function setUp(): void
    {
        parent::setUp();

        $this->tenantSlug = env('TENANT_SLUG', 'test');
        $this->bootTenancy();
    }

    /**
     * テナンシーをセットアップする
     */
    protected function bootTenancy(): void
    {
        $this->tenant = TenantModel::where('slug', $this->tenantSlug)->first();

        if ($this->tenant === null) {
            $this->tenant = TenantDataModel::factory()->create(['slug' => $this->tenantSlug]);
        }

        $this->connectionsToTransact = [env('DB_CONNECTION'), 'tenant'];

        Tenancy::setTenant($this->tenant);

        $this->setUpTenantTraits();
    }

    /**
     * テナント用のトレイトをセットアップする
     */
    private function setUpTenantTraits(): void
    {
        $uses = array_flip(class_uses_recursive(static::class));

        if (isset($uses[RefreshDatabaseTenant::class])) {
            $this->refreshDatabase();
        }
    }
}

 

/tests/RefreshDatabaseTenant.php

<?php

declare(strict_types=1);

namespace Tests;

use Illuminate\Contracts\Console\Kernel;
use Illuminate\Foundation\Testing\RefreshDatabaseState;
use Tenancy;

trait RefreshDatabaseTenant
{
    /**
     * Define hooks to migrate the database before and after each test.
     */
    public function refreshDatabase(): void
    {
        $this->usingInMemoryDatabase()
                        ? $this->refreshInMemoryDatabase()
                        : $this->refreshTestDatabase();
    }

    /**
     * Begin a database transaction on the testing database.
     */
    public function beginDatabaseTransaction(): void
    {
        $database = $this->app->make('db');

        foreach ($this->connectionsToTransact() as $name) {
            $connection = $database->connection($name);
            $dispatcher = $connection->getEventDispatcher();

            $connection->unsetEventDispatcher();
            $connection->beginTransaction();
            $connection->setEventDispatcher($dispatcher);
        }

        $this->beforeApplicationDestroyed(function () use ($database): void {
            foreach ($this->connectionsToTransact() as $name) {
                $connection = $database->connection($name);
                $dispatcher = $connection->getEventDispatcher();

                $connection->unsetEventDispatcher();
                $connection->rollback();
                $connection->setEventDispatcher($dispatcher);
                $connection->disconnect();
            }
        });
    }

    /**
     * Determine if an in-memory database is being used.
     *
     * @return bool
     */
    protected function usingInMemoryDatabase()
    {
        $default = config('database.default');

        return config("database.connections.{$default}.database") === ':memory:';
    }

    /**
     * Refresh the in-memory database.
     */
    protected function refreshInMemoryDatabase(): void
    {
        // セントラル
        $this->artisan('migrate', $this->migrateUsing());

        // テナント
        $tenantSlug = Tenancy::getTenant()->slug;
        $this->artisan('tenant:migrate '.$tenantSlug, $this->migrateUsing());

        $this->app[Kernel::class]->setArtisan(null);
    }

    /**
     * The parameters that should be used when running "migrate".
     *
     * @return array
     */
    protected function migrateUsing()
    {
        return [
            '--seed' => $this->shouldSeed(),
        ];
    }

    /**
     * Refresh a conventional test database.
     */
    protected function refreshTestDatabase(): void
    {
        if (!RefreshDatabaseState::$migrated) {
            // セントラル
            // freshするとテナント情報が消えるため、消さずに残す
            $this->artisan('migrate', $this->migrateFreshUsing());

            // テナント
            $tenantSlug = Tenancy::getTenant()->slug;
            $this->artisan('tenant:migrate:fresh '.$tenantSlug, $this->migrateFreshUsing());

            $this->app[Kernel::class]->setArtisan(null);

            RefreshDatabaseState::$migrated = true;
        }

        $this->beginDatabaseTransaction();
    }

    /**
     * The parameters that should be used when running "migrate:fresh".
     *
     * @return array
     */
    protected function migrateFreshUsing()
    {
        $seeder = $this->seeder();

        return [];

        return array_merge(
            [
                '--drop-views' => $this->shouldDropViews(),
                '--drop-types' => $this->shouldDropTypes(),
            ],
            $seeder ? ['--seeder' => $seeder] : ['--seed' => $this->shouldSeed()]
        );
    }

    /**
     * The database connections that should have transactions.
     *
     * @return array
     */
    protected function connectionsToTransact()
    {
        return property_exists($this, 'connectionsToTransact')
                            ? $this->connectionsToTransact : [null];
    }

    /**
     * Determine if views should be dropped when refreshing the database.
     *
     * @return bool
     */
    protected function shouldDropViews()
    {
        return property_exists($this, 'dropViews') ? $this->dropViews : false;
    }

    /**
     * Determine if types should be dropped when refreshing the database.
     *
     * @return bool
     */
    protected function shouldDropTypes()
    {
        return property_exists($this, 'dropTypes') ? $this->dropTypes : false;
    }

    /**
     * Determine if the seed task should be run when refreshing the database.
     *
     * @return bool
     */
    protected function shouldSeed()
    {
        return property_exists($this, 'seed') ? $this->seed : false;
    }

    /**
     * Determine the specific seeder class that should be used when refreshing the database.
     *
     * @return mixed
     */
    protected function seeder()
    {
        return property_exists($this, 'seeder') ? $this->seeder : false;
    }
}

 

/app/Console/Commands/TenantMigrate.php

<?php

declare(strict_types=1);

namespace App\Console\Commands;

use App\Listeners\ConfigureTenantMigrations;
use App\Models\TenantDataModel;
use Illuminate\Console\Command;
use Tenancy;
use Tenancy\Identification\Contracts\ResolvesTenants;

class TenantMigrate extends Command
{
    /**
     * The name and signature of the console command.
     *
     * @var string
     */
    protected $signature = 'tenant:migrate {tenant?}
                            {--seed : Indicates if the seed task should be re-run}
                            ';

    /**
     * The console command description.
     *
     * @var string
     */
    protected $description = 'すべてのテナントのマイグレーションを実行します。';

    /**
     * Create a new command instance.
     */
    public function __construct(protected ResolvesTenants $resolver)
    {
        parent::__construct();
    }

    /**
     * Execute the console command.
     *
     * @return int
     */
    public function handle()
    {
        $tenantDataModel = $this->resolver->findModel(TenantDataModel::class);

        $tenantDataModel->all()->each(function (TenantDataModel $tenant): void {
            // 実行時引数で実行対象のテナントを指定している場合、
            // 指定テナントのみ実行する
            if (!blank($this->argument('tenant'))
                && $this->argument('tenant') !== $tenant->getTenantKey()) {
                return;
            }

            Tenancy::setTenant($tenant);

            $this->info('tenant: '.$tenant->slug);
            $this->call('migrate', array_filter([
                '--database' => 'tenant',
                '--path' => database_path(ConfigureTenantMigrations::$DATABASE_PATH),
                '--realpath' => true,
                '--seed' => $this->option('seed'),
                '--tenant' => $tenant->slug,
            ]));
        });
    }
}

 

/app/Console/Commands/TenantMigrateFresh.php

<?php

declare(strict_types=1);

namespace App\Console\Commands;

use App\Listeners\ConfigureTenantMigrations;
use App\Models\TenantModel;
use Illuminate\Console\Command;
use Tenancy;
use Tenancy\Identification\Contracts\ResolvesTenants;

class TenantMigrateFresh extends Command
{
    /**
     * The name and signature of the console command.
     *
     * @var string
     */
    protected $signature = 'tenant:migrate:fresh
                            {tenant?}
                            {--drop-types= : Drop all tables and types (Postgres only)}
                            {--drop-views= : Drop all tables and views}
                            {--seed : Indicates if the seed task should be re-run}
                            ';

    /**
     * The console command description.
     *
     * @var string
     */
    protected $description = 'すべてのテナントをクリアした後にマイグレーションを実行します。';

    /**
     * Create a new command instance.
     */
    public function __construct(protected ResolvesTenants $resolver)
    {
        parent::__construct();
    }

    /**
     * Execute the console command.
     *
     * @return int
     */
    public function handle()
    {
        $tenantModel = $this->resolver->findModel(TenantDataModel::class);

        $tenantModel->all()->each(function (TenantModel $tenant): void {
            // 実行時引数で実行対象のテナントを指定している場合、
            // 指定テナントのみ実行する
            if (!blank($this->argument('tenant'))
                && $this->argument('tenant') !== $tenant->getTenantKey()) {
                return;
            }

            Tenancy::setTenant($tenant);

            $this->info('tenant: '.$tenant->slug);
            $this->call('migrate:fresh', array_filter([
                '--database' => 'tenant',
                '--drop-types' => $this->option('drop-types'),
                '--drop-views' => $this->option('drop-views'),
                '--path' => database_path(ConfigureTenantMigrations::$DATABASE_PATH),
                '--realpath' => true,
                '--seed' => $this->option('seed'),
                '--tenant' => $tenant->slug,
            ]));
        });
    }
}

 

プログラミングカテゴリの最新記事