マルチテナントのLaravelアプリを作りたいと考えていますか?各テナントのデータベースを分けて管理したり、テナント管理ユーザーを割り当てたり、テナント内で権限管理できるようにしたり、テナントの作成や削除を自動化したり、その後のテナント管理UI構築まで考えた形で作ったりしたいと考えていますか?そのために必要なプラグインのチュートリアルが不親切で意味が分からずイライラしたりしていますか?
もしそうだとしたら、この記事を読んでいるあなたは正しい場所にたどり着いています。
これは、今回の参考記事の冒頭部分の意訳です。(今回の記事に合うように、内容を少し端折ってます)
I would like to show the big thanks to Ashok who is the author of the article referred.
この記事では、Laravelでマルチテナントアプリを作成する方法を、備忘録を兼ねてまとめていきます。お仕事での調査ですが、自社用調査であり、まあこれくらいいいかということで、日本のLaravel応援、日本の技術者応援ということで情報公開します。
※ちなみに、お仕事受けることもできますのでご相談ください。うちはニューヨークの会社です。
正直言って、この方法はとてもよくできています。これまでにあった小手先の技ではなく、完全にコントロールされたテナント管理が可能になります。今使わないとしても、知っておいて損はないはずです。
はじめに
Laravelは、PHPの世界だけではなく様々なフレームワークと比べても大変優秀です。私もこれまで.Net環境なども含めいくつかのMVC Webアプリフレームワークを扱ってきましたが、Laravelは中規模アプリを作成する上で他に勝るとも劣らない環境と言えます。特にそのエコシステムは優秀で、シンプルかつわかりやすいだけでなく、素晴らしいドキュメントやチュートリアルもそろっています。
唯一の問題は、ちょっと複雑なアプリを組もうとすると、途端に情報が少なくなることです。今回のマルチテナントアプリはまさにそうで、英語も含めてほとんど情報がありません。実際にはLaravelでマルチテナントアプリを作ることは、とても簡単なのに。私は調査や最新版への対応のためのコード修正まで全部含めて1日でマルチテナントアプリの基本部分を作り終えました。ここにまとめた内容があれば3時間もあれば十分でしょう。
この記事のゴール
この記事では、マルチテナントアプリの基本部分を作り終えるところまでを具体的に書きます。
マルチテナント実現のため、Laravel Tenancy Hynを利用します。
マルチテナントアプリの仕様
今回の「マルチテナントアプリ」は、以下のような仕様を目指します。
- アプリのソースコードは1つ。テナントごとにコピーしない。
- システム(テナント管理)データベースと、テナントデータベースはわける。
- テナントデータベースは、テナント1つにつき1つとする。
- テナントを作ると、テナントデータベースと専用データベースユーザーが作られる。
- テナントを消すと、テナントデータベースと専用データベースユーザーが削除される。
- システムデータベース用のMigrationとテナントデータベース用のMigrationは分ける。
- コマンドラインからMigrationしたら、システムデータベース用Migrationだけが実行される。
- テナントを作った時、テナントデータベース用Migrationを実行してデータベースを初期化する。
- ユーザー管理はテナントごとに独立させる。
- テナントごとにユーザーの権限を設定できるようにする。
- テナントを作成した時に自動的にテナント管理者に招待状を送付し、パスワード設定させる。
- テナント画面からシステムデータベースの情報にアクセスできる余地を残しておく。
要件
Laravelやマルチテナントライブラリーによって、構築方法やソースコードが違ってきます。参考記事ではTenancy Hyn 5.1を使っていますが、この記事では最新の5.3を使います。5.2からCustomerが廃止されているので、参考記事とはソースコードが異なります。
この記事はWindows 10 ProにXAMPP 7.2.12を導入した状態で書いています。
重要なバージョンは以下です。
- Laravel 5.7
- Tenancy Hyn 5.3
- MariaDB 10.1.37 (XAMPP 7.2.12に入っているもの)
テナントを作成できるところまで作る
空のプロジェクトを作成する
まず空のLaravelプロジェクトを作成します。(必要に応じバージョンを指定してください) 名前はここでは参考記事に倣ってtownhouse
にします。
composer create-project --prefer-dist laravel/laravel townhouse
今回はXAMPP 7.2.12に入っているMariaDB 10.1.37を使うので、Providers/AppServiceProvider.php
のboot()内にStringの長さ制限を追記しておきます。
...
use Illuminate\Support\Facades\Schema;
...
public function boot()
{
//
Schema::defaultStringLength(191);
}
システムデータベースを作成する
システムデータベースを作成します。ここでは、参考記事に倣ってtownhousedb
という名前にします。
さらにシステムデータベースユーザーを作ります。このユーザーは、テナントのデータベースを作成するときに使われるユーザーなので、GRANT OPTION
を指定する必要があります。詳しくはこちら。開発用データベースなら、最上位権限を与えておけばいいでしょう。
Laravelにシステムデータベースを登録する
まずconfig/database.php
のconnections
セクションに、以下を追記します。
// Tenancy
'system' => [
'driver' => env('TENANCY_CONNECTION', 'mysql'),
'host' => env('TENANCY_HOST', '127.0.0.1'),
'port' => env('TENANCY_PORT', '3306'),
'database' => env('TENANCY_DATABASE', 'tenancy'),
'username' => env('TENANCY_USERNAME', 'tenancy'),
'password' => env('TENANCY_PASSWORD', ''),
'unix_socket' => env('DB_SOCKET', ''),
'charset' => 'utf8mb4',
'collation' => 'utf8mb4_unicode_ci',
'prefix' => '',
'strict' => true,
'engine' => null,
]
次に、.env
の末尾に、以下を追記します。
TENANCY_CONNECTION=mysql
TENANCY_HOST=127.0.0.1
TENANCY_PORT=3306
TENANCY_DATABASE=townhousedb
TENANCY_USERNAME=システムユーザー名
TENANCY_PASSWORD=システムユーザーパスワード
TENANCY_USERNAME
とTENANCY_PASSWORD
は、さっき作ったシステムデータベースユーザーの情報を入れます。
マルチテナントライブラリーTenancy Hynをインストールする
※この記事は、Hynバージョン5.3を利用する前提で書かれています。バージョンが変わると大きく仕様が異なってサンプルコードが動作しませんので十分注意してください。特に参考記事で使っている5.1から5.2でCustomerテーブルが廃止されるという大きな変更があります。
composer require hyn/multi-tenant
今回MariaDBを使うので(MySQLでも同じ)、以下の記述を.env
に追記します。詳しくはHynのドキュメントで。
LIMIT_UUID_LENGTH_32=true
さらに、マルチテナントアプリを実現するために重要な「テナントのMigrationをシステムのMigrationと分ける」の準備をしておきます。database/migrations
にある以下の2つのファイルを、テナント用の特別なフォルダdatabase/migrations/tenant
を新しく作ってそこに移動(コピーではない)させます。
2014_10_12_000000_create_users_table.php
2014_10_12_100000_create_password_resets_table.php
これにより、あとでテナントを作った時に、これらのMigrationが自動的に実行されます。特別なコーディングは不要です。
最後に、プロジェクトをマルチテナントアプリにコンバートします。参考記事とは違って、Tenancy Hynの公式の方法を使います。
php artisan vendor:publish --tag=tenancy
Tenancy Hynに必要なテーブルを作ります。
php artisan migrate --database=system
データベースを直接見ると、hostnames
, migrations
, それに websites
のテーブルがあるはずです。users
やpassword_resets
テーブルはないのが正解です。
テナント作成用artisanコマンドを作る
テナント作成のUIは今回は作りません。マルチテナントアプリには必ずしもテナント管理UIが必要というわけではなく、契約されたらエンジニアがテナントを作ればよい場合もありますから、この記事ではシンプルにテナント作成用artisanコマンドを作るにとどめます。動作は単純なので、artisanコマンドを参考にUIをあとで作るのは簡単です。
まずはartisanコマンドのひな型を作ります。
php artisan make:command CreateTenant
出来上がったapp/Console/Commands/CreateTenant.php
の中身を以下のようにします。
<?php
namespace App\Console\Commands;
use App\User;
use Hyn\Tenancy\Contracts\Repositories\HostnameRepository;
use Hyn\Tenancy\Contracts\Repositories\WebsiteRepository;
use Hyn\Tenancy\Environment;
use Hyn\Tenancy\Models\Hostname;
use Hyn\Tenancy\Models\Website;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Hash;
class CreateTenant extends Command
{
protected $signature = 'tenant:create {name} {email} {tenantname}';
protected $description = 'Creates a tenant with the provided name and email address e.g. php artisan tenant:create john john@example.com cafejohn';
public function handle()
{
$name = $this->argument('name');
$email = $this->argument('email');
$tenantname = $this->argument('tenantname');
if ($this->tenantExists($tenantname)) {
$this->error("A tenant with name '{$tenantname}' already exists.");
return;
}
$tenant = $this->registerTenant($name, $email, $tenantname);
app(Environment::class)->tenant($tenant["website"]);
// we'll create a random secure password for our to-be admin
$password = str_random();
$this->addAdmin($name, $email, $password);
$this->info("Tenant '{$tenantname}' is created and is now accessible at {$tenant["hostname"]->fqdn}");
$this->info("Admin {$email} can log in using password {$password}");
}
private function tenantExists($tenantname)
{
$baseUrl = config('app.url_base');
$fqdn = "{$tenantname}.{$baseUrl}";
return Hostname::where('fqdn', $fqdn)->exists();
}
private function registerTenant($name, $email, $tenantname)
{
// create a website
$website = new Website;
app(WebsiteRepository::class)->create($website);
// associate the website with a hostname
$hostname = new Hostname;
$baseUrl = config('app.url_base');
$hostname->fqdn = "{$tenantname}.{$baseUrl}";
app(HostnameRepository::class)->attach($hostname, $website);
return ["hostname"=>$hostname, "website"=>$website];
}
private function addAdmin($name, $email, $password)
{
$admin = User::create(['name' => $name, 'email' => $email, 'password' => Hash::make($password)]);
return $admin;
}
}
中身の詳細な説明は省略しますが、おおよそ、現在指定されたテナント名が存在しないことを確認して、与えられたテナント名でテナントを作り、その中に管理者ユーザーを与えられた名前とEmailで作っています。
この時点ではまだ権限は付与していませんが、この記事のあとでやります。
テナントを実際に作る前に、もうひとつやるべきことがあります。テナントのhostnameのFQDN(Fully Qualified Domain Name、サブドメインを含む完全なドメイン名)を生成するのに必要なベースURLの設定です。テナントは、アプリのベースとなるドメイン名に追加されたサブドメイン名に紐づいたhostnameに紐づいたwebsiteとして管理されます。たとえば、アプリのベースドメイン名がexample.comだとして、cafeというテナントを作ったら、このテナントのFQDNはcafe.example.comになり、cafe.example.comというFQDNは1つのhostnameレコードと紐づいていて、このhostnameは1つのwebsiteと紐づいています。
config/app.php
の末尾あたりと、.env
のAPP_URL
設定あたりで、以下のように追記・変更します。アプリのベースドメインは、例えばexample.com
のようになります。
/*
|--------------------------------------------------------------------------
| Tenancy
|--------------------------------------------------------------------------
|
*/
'url_base' => env('APP_URL_BASE', 'http://localhost'),
];
APP_URL_BASE=アプリのベースドメイン
APP_URL=http://${APP_URL_BASE}
config/app.php
のAPP_URL_BASE
設定は、.env
に設定があればそちらで上書きされることになるので、第2パラメータは重要ではありません。
ベースドメイン名については、あとで実際にこのドメイン名を含むURLでLaravelにアクセスするので、hosts
設定などで実際にアクセスできるようにしておいてください。テナントを作った後にサブドメイン名も追加されるので、その点も注意が必要です。
ではでは、いよいよテナントを作ってみましょう。
php artisan tenant:create john john@example.com cafejohn
うまくいったら以下のように表示されます。
Tenant 'cafejohn' is created and is now accessible at cafejohn.ドメイン名.com
テナント削除用artisanコマンドを作る
同様に削除コマンドを作ります。
php artisan make:command DeleteTenant
<?php
namespace App\Console\Commands;
use Hyn\Tenancy\Contracts\Repositories\HostnameRepository;
use Hyn\Tenancy\Contracts\Repositories\WebsiteRepository;
use Hyn\Tenancy\Environment;
use Hyn\Tenancy\Models\Hostname;
use Hyn\Tenancy\Models\Website;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Config;
class DeleteTenant extends Command
{
protected $signature = 'tenant:delete {tenantname}';
protected $description = 'Deletes a tenant of the provided name. Only available on the local environment e.g. php artisan tenant:delete cafejohn';
public function handle()
{
// because this is a destructive command, we'll only allow to run this command
// if you are on the local environment
if (!app()->isLocal()) {
$this->error('This command is only avilable on the local environment.');
return;
}
$tenantname = $this->argument('tenantname');
$this->deleteTenant($tenantname);
}
private function deleteTenant($tenantname)
{
$baseUrl = config('app.url_base');
$fqdn = "{$tenantname}.{$baseUrl}";
if ($hostname = Hostname::where('fqdn', $fqdn)->firstOrFail()) {
$website = Website::where('id', $hostname->website_id)->firstOrFail();
app(HostnameRepository::class)->delete($hostname, true);
app(WebsiteRepository::class)->delete($website, true);
$this->info("Tenant {$tenantname} successfully deleted.");
}
}
}
特筆することは少ないですが、'if (!app()->isLocal())'の判定で、このコマンドがローカル環境でしか実行できないように制限していることに注目してください。
この削除コマンドでは、テナントを実際に削除するのですが、その時にデータベースやデータベースユーザーまで削除するかなど、条件を指定したいはずです。そのために、設定ファイルtenancy.php
を利用できるようにします。
php artisan vendor:publish
コマンドを実行すると、何をPublishするか聞かれるので、
Provider: Hyn\Tenancy\Providers\Tenants\ConfigurationProvider
を選択します。これによりconfig/tenancy.php
が生成されます。以下の行を探して、変更します。
...
'auto-delete-tenant-directory' => env('TENANCY_DIRECTORY_AUTO_DELETE', false),
...
'auto-delete-tenant-database' => env('TENANCY_DATABASE_AUTO_DELETE', false),
...
'auto-delete-tenant-database-user' => env('TENANCY_DATABASE_AUTO_DELETE_USER', false),
...
さらに、.env
に実際の設定値を追記します。
TENANCY_DIRECTORY_AUTO_DELETE=true
TENANCY_DATABASE_AUTO_DELETE=true
TENANCY_DATABASE_AUTO_DELETE_USER=true
上から、ディレクトリの自動削除、データベースの自動削除、データベースユーザーの自動削除、です。
準備が整ったので、さっきのテナントを消してみましょう。
php artisan tenant:delete cafejohn
データベースをあけて、すべて削除されたことを確認してください。