最近マルチテナントのシステムを開発することがあり、共通のDBをテナントごとに管理する方法を調べました。
結論
Doctrineの以下機能を使用するとCRUDの各SQLを改変でき、テナントを透過的に制御可能(すなわち毎回WHERE句にテナントの条件を書かなくて良い)。 但し、@OneToOneは期待通りに動作しないため要注意。環境
Doctrine 2.7.4Symfony 4.4.16
SELECTの調査結果
以下の様なSQL Filterを登録。class TenantFilter extends SQLFilter { public function addFilterConstraint(ClassMetadata $targetEntity, $targetTableAlias): string { // 100は実際には可変(後述) return $targetTableAlias.'.tenant_id = 100'; } }
単純なSELECT
$em->find(Parent::class, 1); → SELECT ... FROM parent WHERE id = 1 AND tenant_id = 100;
@OneToMany
$em->find(Parent::class, 1)->getChilds(); → SELECT ... FROM parent WHERE id = 1 AND tenant_id = 100; → SELECT ... FROM child WHERE parent_id = 1 AND tenant_id = 100;
@ManyToOne
@OneToMany同様。@ManyToMany
@OneToMany同様。@OneToOne
$em->find(Example1::class, 1)->getExample2(); → SELECT ... FROM example1 e1 LEFT JOIN example2 e2 ON e2.example1_id = e1.id WHERE e1.id = 1 AND e1.tenant_id = 100; ⇒ example2テーブルへのtenant_id = 100が存在しない
join()
$em->createQueryBuilder('c')->join('c.parent')->where('c.id = 1'); → SELECT ... FROM child c JOIN parent p ON c.parent_id = p.id AND p.tenant_id = 100 WHERE c.id = 1 AND c.tenant_id = 100;
leftJoin()
join()同様。INSERT/UPDATE/DELETEの調査結果
以下の様なDoctrine Event Listenerを登録。class TenantListener { public function prePersist(LifecycleEventArgs $args): void { assert($args->getEntity()->getTenant() === null); // 100は実際には可変 $args->getEntity()->setTenant($this-em->find(Tenant::class, 100)); } public function preUpdate(LifecycleEventArgs $args): void { // 安全のため他テナントを操作していないかチェックする if ($args->getEntity()->getTenant()->getId() !== 100) { throw new \LogicException(); } } public function preRemove(LifecycleEventArgs $args): void { // preUpdate()同様 } }
INSERT
$em->persist(new Example()); → INSERT INTO example (..., tenant_id) VALUES (..., 100);
その他
上記のようにDoctrine FilterのaddFilterConstraint()内でtenant_idを直接設定するとDoctrine Query CacheによりSQLが固定化されてしまい期待通りに動作しない。そのため以下の通りparameterを経由する必要があった。// 外部から呼ぶ(RequestListenerなどから) public function setTenantId(int $tenantId): void { $this->setParameter('tenant_id', $tenantId, Types::INTEGER); } public function addFilterConstraint(ClassMetadata $targetEntity, $targetTableAlias): string { $tenantId = (int)$this->unquoteParameterValue($this->getParameter('tenant_id')); return $targetTableAlias.'.tenant_id = '.$tenantId; } private function unquoteParameterValue(string $value): string { $len = strlen($value); if ($value[0] !== "'" || $value[$len - 1] !== "'") { throw new \LogicException($value); } return str_replace(['\\"', '\\\\'], ['"', '\\'], substr($value, 1, $len - 2)); }