单数据库多租户是最经济的一种方案:一个数据库结构、一个连接、每张租户表上都有一个 organization_id 字段。整个设计建立在一个承诺之上,而这个承诺关乎"遗忘"——团队里没有任何开发者需要记得写 WHERE organization_id = ?,因为忘记一次,就可能泄露另一个客户的数据。
Doctrine 多年来一直有现成的工具。它叫 SQLFilter,大约三十行代码,几乎每篇相关文章都止步于"快乐路径"。真正有意思的不是过滤器本身,而是它"不在场"的地图——因为这张地图才是你真正需要防守的地方。以下内容全部基于 Doctrine ORM 3.6.7 和一套每次提交都会运行的测试套件。
![]()
过滤器本身
OrganizationFilter 继承自 SQLFilter,核心逻辑只有 addFilterConstraint 方法:如果目标实体没有实现 OrganizationOwnedInterface,就返回空字符串;如果实现了,就拼出 organization_id = 参数的 SQL 条件。OrganizationOwnedInterface 是一个标记接口,只有一个方法 getOrganization()。实体通过实现它来加入多租户机制,这就是整个机制的公开 API。没有需要记忆的属性,没有需要继承的基类,也没有在 diff 中不可见的 trait。
过滤器在 doctrine.yaml 中以 enabled: false 声明。这是刻意的,也是第一个值得争论的设计决策:一个在容器中默认开启的过滤器,会在你的 fixtures、迁移脚本、数据修复脚本里都生效,凌晨三点出问题时它会咬你一口。它由"知道提问者是谁"的那一层来开启。
知道提问者是谁的那一层
onKernelRequest 方法订阅了 KernelEvents::REQUEST 事件,优先级设为 7。这个数字不是随便选的——Symfony 的 Firewall 监听器以优先级 8 订阅 kernel.request,所以 7 是 Security::getUser() 已被填充的第一个槽位。优先级更高就拿不到用户,过滤器根本不会启用,这是最糟糕的失败模式,因为页面照样渲染,看起来一切正常。
方法内部先检查是否主请求,再检查路径是否以 /admin 开头,然后从 organizationContext 获取当前组织。如果组织为空就直接返回,否则启用过滤器并设置 organization_id 参数。这就是整个机制的全部。
接下来是真正有用的部分。
第一个不执行的地方:控制台和所有 Messenger worker
CLI 进程里没有 kernel.request 事件。你的命令、你的 cron 任务——
特别声明:以上内容(如有图片或视频亦包括在内)为自媒体平台“网易号”用户上传并发布,本平台仅提供信息存储服务。
Notice: The content above (including the pictures and videos if any) is uploaded and posted by a user of NetEase Hao, which is a social media platform and only provides information storage services.