【免费下载链接】openframe-oss-lib
Shared libraries between all services
OpenFrame OSS Lib(openframe-oss-lib)是一个共享服务库仓库,其中的多租户隔离方案是它的亮点之一:通过TenantContext(基于 ThreadLocal 的租户上下文)+TenantAwareMongoTemplate(自动注入 tenantId 的 Mongo 模板),让每个租户的数据严格隔离,业务代码几乎"零感知"。本文带你完整拆解这套隔离机制的工作原理。
多租户隔离为什么难?
在 SaaS 或多租户系统中,所有租户共用同一个数据库。如果每条查询都要手动拼接tenantId条件,一旦漏掉一处,就会发生跨租户数据泄漏——这是多租户系统最严重的安全事故之一。
OpenFrame 的解法是"两道防线":
| 防线 | 组件 | 职责 |
|---|---|---|
| 请求层 | TenantContext+TenantContextFilter | 解析并持有"当前请求属于哪个租户" |
| 数据层 | TenantAwareMongoTemplate | 对每条 MongoDB 查询自动注入tenantId过滤条件 |
相关源码分布在三个模块:
- openframe-authorization-service-core/ —— 租户上下文(请求解析)
- openframe-data-mongo-common/ —— 租户标记接口与租户 ID 提供器
- openframe-data-mongo-sync/ —— 租户感知模板与仓储
第一道防线:TenantContext(ThreadLocal 线程上下文)
核心实现只有 20 行,位于 TenantContext.java:
public final class TenantContext { private static final ThreadLocal<String> CURRENT_TENANT = new ThreadLocal<>(); public static void setTenantId(String tenantId) { CURRENT_TENANT.set(tenantId); } public static String getTenantId() { return CURRENT_TENANT.get(); } public static void clear() { CURRENT_TENANT.remove(); } }设计要点:
- 线程隔离:
ThreadLocal保证 Servlet 线程模型下每个请求线程持有独立的租户 ID,互不串扰; - 显式清理:
clear()必须由调用方在请求结束时执行,防止线程复用导致租户信息"粘"到下一个请求; - 零依赖:静态工具类,任何 Service 都能一行取到当前租户:
TenantContext.getTenantId()。
TenantContextFilter:租户身份从哪里来?
TenantContextFilter.java 是OncePerRequestFilter,按优先级从三个来源解析租户 ID:
- URL 路径(最高优先级):形如
/{tenantId}/oauth2/authorize的 OAuth 端点,取第一段路径作为租户 ID(排除login、sso、public等保留词); - 查询参数:
?tenant=<tenantId>显式指定; - HTTP Session:读取 Session 中的
TENANT_ID,保证多步 OAuth2 流程中租户上下文延续。
解析成功后会同时写入TenantContext和 Session。两个关键的安全细节值得注意 ⚠️:
- 租户切换即作废:如果 Session 中已存在的租户 ID 与本次请求不一致,Filter 会直接
session.invalidate(),防止跨租户会话污染(唯一的例外是 onboarding 临时租户向正式租户的迁移); - finally 中清理:
filterChain.doFilter()包在 try/finally 中,请求结束必然执行TenantContext.clear(),杜绝线程泄漏。
💡 官方架构文档对该模块的完整说明见 docs/reference/architecture/authorization-service-core/tenant-context.md。
第二道防线:TenantAwareMongoTemplate(查询自动加租户口径)
TenantAwareMongoTemplate.java 继承自 Spring Data 的MongoTemplate,是整个数据层隔离的心脏。
只隔离"该隔离的"实体:TenantScoped 标记接口
模板不会给所有查询都注入条件,它只处理实现了 TenantScoped 标记接口的实体:
public interface TenantScoped { String getTenantId(); void setTenantId(String tenantId); }所有租户级文档(Machine、Ticket、Notification等)都实现该接口,文档中固定携带tenantId字段。
核心机制:scope() 一行注入
模板重写了find、findOne、count、exists、updateFirst、updateMulti、upsert、remove、findAndRemove等全部 CRUD 入口,统一先经过 scope():
protected Query scope(Query query, Class<?> entityClass) { if (entityClass != null && TenantScoped.class.isAssignableFrom(entityClass) && !query.getQueryObject().containsKey("tenantId")) { query.addCriteria(Criteria.where("tenantId").is(tenantIdProvider.getTenantId())); } return query; }效果是:你写repository.findByMachineId(machineId),模板会自动把查询变成{ "machineId": "...", "tenantId": "当前租户" }——业务代码完全不用关心租户。
连 Spring Data 派生查询也没放过
Spring Data MongoDB 4.x 的派生查询(findByXxx)走的是ExecutableFindOperation流式入口,会绕过普通的find(Query, Class)方法。模板重写了 query(Class) 方法,用 JDK 动态代理包装返回的ExecutableFind,拦截matching(Query)调用注入tenantId——派生查询、as(Class)、inCollection(...)全链路都被覆盖。
两个必须知道的使用约定
聚合查询不自动注入:
aggregate()被刻意留空不重写,聚合管道必须手动添加$match。模板为此提供了便捷方法:mongoTemplate.tenantCriteria(); // 生成 tenantId 匹配条件,用作第一个 $match mongoTemplate.tenantId(); // 用于复合键场景禁止裸用 MongoTemplate:官方安全文档明确要求,租户级数据一律走
TenantAwareMongoTemplate,详见 docs/development/security/README.md。
自动装配:一个开关切换整套隔离
TenantIsolationAutoConfiguration.java 负责装配:
- 由配置项
openframe.tenant-isolation.enabled=true触发(@ConditionalOnProperty); - 将
TenantAwareMongoTemplate以@Primary的mongoTemplateBean 注册到容器,Spring Boot 默认的 MongoTemplate 自动退让——隔离模式下,租户感知模板"就是"唯一的 MongoTemplate; TenantIdProvider使用@Lazy注入,代理在首次查询时才解析真实实现,解耦构造顺序。
租户 ID 从哪来?由TenantIdProvider接口抽象,默认实现 DefaultTenantIdProvider 读取环境变量TENANT_ID(默认值oss)——这正体现了"每租户一个 Pod"的部署模型:隔离不仅靠查询过滤,还靠进程级部署。
仓储层规范:让违规直接编译期可见
为了杜绝绕过模板的"野路子",项目建立了三层约定:
- TenantAwareRepository:租户侧仓储接口的组合注解(
@Repository+ 排除标记),共享服务可通过@EnableMongoRepositories的排除过滤器整体跳过这些仓储; - TenantAwareRepositorySupport:所有自定义仓储实现的基类,强制构造器注入
TenantAwareMongoTemplate,并提供tenantCriteria()/tenantId()便捷方法; - ArchUnit 架构测试:强制
com.openframe.data.repository下所有*RepositoryImpl必须继承 Support 基类、且不允许声明裸MongoTemplate字段——违反规则测试直接失败。
一个典型的租户级文档 + 仓储组合(新增文档流程参考 docs/development/setup/local-development.md):
@Data @Document(collection = "my_collection") public class MyDocument implements TenantScoped { @Id private String id; private String tenantId; // 业务字段... } public interface MyDocumentRepository extends TenantAwareRepository<MyDocument, String> { // 派生查询自动带租户口径 }租户内自增序列:TenantSequence
连自增 ID 也是按租户隔离的。TenantSequence 通过复合唯一索引{'tenantId': 1, 'name': 1}实现"每租户一套编号",例如工单号(ticket_number)各租户从 1 开始互不干扰——查询时同样依赖模板自动注入的tenantId过滤,无需复合键查询。
总结:这套隔离方案的设计精髓
| 设计点 | 做法 | 价值 |
|---|---|---|
| 上下文持有 | ThreadLocal + 请求级 Filter | 线程隔离、无状态传递、显式清理 |
| 身份解析 | 路径 > 查询参数 > Session 三级回退 | 兼容 OAuth 端点、显式指定与多步流程 |
| 会话安全 | 租户切换即作废 Session | 阻断跨租户会话污染 |
| 数据隔离 | 模板层统一scope()注入 | 业务代码零感知,杜绝漏拼条件 |
| 派生查询覆盖 | 动态代理拦截matching() | Spring Data 4.x 全入口无死角 |
| 架构守护 | 组合注解 + 基类 + ArchUnit | 违规用法在 CI 直接暴露 |
| 部署模型 | 每租户一个 Pod +TENANT_ID环境变量 | 查询过滤之外的进程级双保险 |
📚 延伸阅读:
- 整体架构:docs/development/architecture/README.md(其中"Multi-tenancy: ThreadLocal TenantContext"为关键设计决策)
- 安全最佳实践:docs/development/security/README.md
- 仓储层架构文档:docs/reference/architecture/data-mongo-sync-repositories/data-mongo-sync-repositories.md
对新手来说,理解这套机制后做三件事就够了:给租户级文档实现TenantScoped、让仓储接口继承TenantAwareRepository、聚合查询记得手动加tenantCriteria()——剩下的,交给框架。
【免费下载链接】openframe-oss-lib
Shared libraries between all services
相关推荐
Cuba微框架入门:如何用Ruby快速构建轻量级Web应用
Cuba微框架入门:如何用Ruby快速构建轻量级Web应用 Cuba是一个基于Ruby的轻量级Web微框架,以其简洁的设计和高效的性能成为开发者构建小型Web应
深度解析G9v3-39A5B:5B激活参数实现39B模型性能的Mixture-of-Experts架构揭秘
深度解析G9v3 39A5B:5B激活参数实现39B模型性能的Mixture of Experts架构揭秘 G9v3 39A5B是AI9Stars团队开发的 M
npx skills命令调试终极指南:高效排查技能安装与使用故障
npx skills命令调试终极指南:高效排查技能安装与使用故障 当我们使用npx skills管理AI代理技能时,经常会遇到各种令人困惑的问题:技能安装失败、
AI 技能CLI开发工具人工智能
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考