PHP + Laravelでマルチテナントをやってみる
ASPってワードはかなり前からあるし、最近ではSaaSとか言うたりするけど、
toCじゃなくて、toBなSaaSの場合、マルチテナントを実現するケースが多いと思う。
マルチテナントのデータの持ち方
SaaSのマルチテナントを実現するデータの持ち方としては、大きく2種類に分けられると思う。
すべてのテーブルにどのテナントのデータかを持つ
テナントごとにデータベースを分ける
これまでは前者で作ることが多かった。
理由としては、データベースを追加したり切り替えたりするコストが小さくなかったり、
システム上で実行するだけでなく、手動で操作する必要があったりしたことも大きい。
ただ、いまとなってはマイグレーションが当たり前になったこともあって、
後者での実装のほうが汎用性が高いのでは?と考えるようになってきた。
ってことで、とりあえずリポジトリを作って動くものを用意してみた。
もともとデータの追加と同時にDBを追加して、向き先を変更するにはどうしたらいいかなー。
ってのを確認していたところからの派生なので、変な名前やけど。
DB種別の大別
テナント情報を格納しているマスタDBと、テナント毎の情報を格納しているテナントDBに大別できる。
マスタDBはどのテナントからもアクセスされる可能性があり、
またどのテナントか分からないユーザでも接続できる必要がある。
認証はすべて一つのログインページで行なうようなケースでは、
認証情報をマスタDBに持っておく必要があり、マスタDBにアクセスすることになる。
テナントDBはテナント毎のデータを格納している。
販売管理のSaaSの場合は、商品情報や、売上情報や、請求情報や、在庫情報など。
他にもどのユーザがテナント内の管理者であるかといった情報も、テナント内でしか使われないのでテナントDBに持っておく。
データベースで区切られるマスタDBとテナントDBはもちろんJOINすることができないので、
連結して何か表示するといったことは必要のないつくりにしておく必要がある。
どのテナントにアクセスしているかの判定
どのテナントにアクセスするのが正しいのか。ということを判断し、接続するDBを変更する必要がある。
よくある判断方法は下記2種類だと思う
サブドメイン
認証時に確定
サブドメイン方式では、認証するよりも先にどのDBにアクセスするのが正しいのかを判定できるという点が便利。
また、セッションクッキーもドメインで分けられるので、別のテナントにアクセスできてしまうリスクはかなり低い。
その反面、レンタルサーバだと証明書やwebサーバの設定の面から、
サブドメインの登録を手動でする必要があったりして、動作要件のハードルは上がる。
認証時に確定はユーザログインによって確定するという方法。
AWSのようにテナントID + ユーザID + パスワードのような方式もあれば、
ユーザID + パスワードで認証し、ユーザ情報にテナントもっているテナントIDから接続するDBを振り分けるケースもある。
見かけないけど、テナントログイン → ユーザログイン。と2段階でログインすることもあり得るかも?
認証するまでどのDBにアクセスすべきかが判定できないので、
認証用ユーザテーブルをすべてのテナントからアクセスできるDBに持っておく必要がある。
マスタDBのユーザ認証情報と、テナントDBのユーザ情報の関連
サブドメイン方式の場合はユーザ情報はすべてテナントDBに持つことができるけど、
そうでなければ認証用情報をマスタDBに、ユーザ個人の情報をテナントDBに持つ必要がある。
この時、マスタDBのユーザテーブルとテナントDBのユーザテーブルに何らかの関連性を持つ必要がある。
関連の持ち方としては下記が想定できる。
マスタDBのユーザテーブルの主キーを、テナントDBのユーザテーブルで外部キーのように持つ
テナントDBのユーザテーブルの主キーを、マスタDBのユーザテーブルで外部キーのように持つ
マスタDBのテナントID + テナントDBのユーザIDで複合ユニークインデックスを作るイメージ
マスタDBのユーザテーブルの主キーは一意なので、テナントDBのユーザテーブルの主キーにも同じ値を入れる
テナントDBのユーザテーブルでは自動採番されない
個人的には同じIDを持っている方が操作しやすいので3番目の同じ値を主キーに持つ方法を選択したい。
実装のお話
ログイン処理
ログインで必要なのはIDでユーザが取得出来て、パスワードの一致が確認できること。
それ以外はsessionに何かしらの認証済みユーザの情報を持っておく必要がある。
逆に言えばそれくらいしかない。
code:UserController.php
public function execLogin(LoginRequest $request): Redirector|Application|RedirectResponse
{
$user = AuthUser::query()->where('email', '=', $request->get('email', ''))->first();
if (is_null($user) || !$user->verify($request->get('password', ''))) {
}
// ログイン済みの情報をsessionに保存
return redirect('/');
}
Middlewareでの認証時にDB登録
Middlewareでは認証済みかの確認と、認証済みならデータベースコネクションの設定をする。
code:AuthenticateUser.php
public function handle(Request $request, Closure $next)
{
$user = AuthUser::query()->
where('id', '=', $request->session()->get(config('const.SESSION_USER_ID')))->first();
Auth::setUser($user);
// 接続するテナントDBのコネクションを作っておく
$tenant = Tenant::query()->where('id', '=', $user->tenant_id)->first();
$connector = new DatabaseConnectionService();
$connector->config($tenant->databaseName());
return $next($request);
}
セッションに保持しているユーザIDを使ってマスタDBから認証用ユーザを取得する。
取得できなければ未ログインってことでログイン画面に飛ばす。
その後マスタデータからテナントのデータベース名を取得し、コネクションを作って完了。
Auth::setUser($user); では、 Auth::user(); でユーザを取りたいため、取得した認証ユーザをセットしておく。
code:DatabaseConnectionService.php
class DatabaseConnectionService
{
public function config(string $database_name, string $connection_name = 'tenant'): void
{
$connection = config('database.connections.' . env('DB_CONNECTION'));
}
}
DatabaseConnectionService はこれだけ。
標準のDBコネクション情報を取ってきて、接続するデータベースだけを変えたものをコネクションに別名でセットしている。
この場合は databse.connections.tenant という名前。
このMiddlewareを通すことで、 tenant というコネクションでテナントDBにアクセスできる。
テナントDBのモデルへの設定
まずマスタDBの認証ユーザはこんなかんじ。
code:Models/AuthUser.php
class AuthUser extends Authenticatable
{
use HasFactory;
protected $connection;
protected $table = "users";
protected $guarded = [];
public $timestamps = false;
public function verify(string $password): bool
{
return password_verify($password, $this->password);
}
public function tenantUser()
{
return User::query()->where('id', '=', $this->id)->first();
}
}
connection は変更せず、デフォルトのデータベースに向いている。
利便性のための小さなメソッドをいくつか追加してるけど、あってもなくてもいい。
次にテナントユーザ。
code:Models/Tenant/User.php
class User extends Model
{
use HasFactory;
protected $connection = 'tenant';
protected $table = 'users';
protected $guarded = [];
public $timestamps = false;
}
一番の違いは $connection に tenant を指定していること。
このモデルは最初からテナントDBを参照するようにできている。
使いたいデータベース毎にモデルを作る必要はあるものの、
マスタDB or テナントDBという振り分けだけでいい。
テナントを増やし、DBが増えるときの動き
テナントを追加する際には同時にDBが作られ、また同時に初期テーブルを追加する必要がある。
他にもそのテナントの一人目のユーザは登録しておかないといけない。
code:TenantController.php
public function register(Request $request): Redirector|Application|RedirectResponse
{
$name = $request->get('name');
$database_name = $request->get('database_name');
$tenant->save();
$auth_user = new AuthUser([
'name' => $request->get('manager_name'),
'email' => $request->get('manager_email'),
'password' => password_hash($request->get('manager_password'), PASSWORD_DEFAULT),
'tenant_id' => $tenant->id,
]);
$auth_user->save();
$this->initializeDatabaseService->initialize($tenant->databaseName(), $auth_user);
return redirect('/tenants');
}
code:InitializeDatabaseService.php
class InitializeDatabaseService
{
public function __construct(
private ?DatabaseConnectionService $databaseConnectionService = null,
)
{
}
public function initialize(string $database_name, ?AuthUser $user = null): void
{
Schema::createDatabase($database_name);
$this->databaseConnectionService->config($database_name, $database_name);
// テーブルの用意
$this->createUsersTable($database_name);
$this->createCustomersTable($database_name);
// 初期ユーザの追加
if (!is_null($user)) {
$tenant_user = new User([
'id' => $user->id,
'name' => $user->name,
'email' => $user->email,
'is_tenant_manager' => true,
]);
$tenant_user->setConnection($database_name);
$tenant_user->save();
}
}
private function createUsersTable(string $connection_name = 'tenant'): void
{
Schema::connection($connection_name)->create('users', function (Blueprint $table) {
$table->integer('id');
$table->string('name');
$table->string('email');
$table->boolean('is_tenant_manager')->default(false);
$table->timestamp("created_at")->default(DB::raw("current_timestamp"));
$table->timestamp("updated_at")->default(DB::raw("current_timestamp"));
});
}
private function createCustomersTable(string $connection_name = 'tenant'): void
{
Schema::connection($connection_name)->create('customers', function (Blueprint $table) {
$table->id();
$table->string('name');
$table->string('address');
$table->string('tel');
$table->timestamp("created_at")->default(DB::raw("current_timestamp"));
$table->timestamp("updated_at")->default(DB::raw("current_timestamp"));
});
}
}
InitializeDatabaseService.php にはマイグレーションファイル的な役割を持たせる。
テナントDB内のすべてのテーブルはここで生成してあげる必要がある。
また、少しだけややこしい点として、テナント管理でテナントを追加するユーザはテナントAに所属しているとすると、
新たに追加されたテナントはテナントBとなる。
つまりこの瞬間だけはテナントをまたいだ操作が必要になる。
そのため、tenantという名前ではなく、データベース名のコネクションを作って使うようにしている。
テナントのユーザを増やし、マスタDBとテナントDBに反映するときの動き
ユーザを追加する際には、マスタDBとテナントDBの両方に登録する必要がある。
code:UserController.php
public function register(Request $request): Redirector|Application|RedirectResponse
{
$login_user = Auth::user();
$name = $request->get('name');
$email = $request->get('email');
$password = $request->get('password');
$master_user->save();
$tenant_user->save();
return redirect('/users');
}
データベースを分けている弊害として、ひとつのトランザクションにまとめられない。
今回はエラー検知のための機構を作りこんではないけど、プロダクションとなる際にはエラーハンドリングは必要。
認証にだけ、テナントにだけユーザが出来るようなケースがあってはいけない。
※つくり的にテナントだけにユーザができることはないけど
おしまい
更新履歴