多租戶設計指南
文檔版本: v2.0.0 最後更新: 2025-12-30 適用對象: 開發團隊、AI Agent、使用 app-server 範本的團隊
本文檔提供多租戶功能的實作指南,包括架構說明、實作方式、使用範例和最佳實踐。
決策理由請參閱:ADR-001: 多租戶數據隔離策略
目錄
1. 架構概覽
設計原則
核心理念:租戶隔離使用應用層維護,搭配 Hibernate Filter 自動過濾,不建立資料庫外鍵約束。
框架化設計:核心工具由 appfuse-server 框架提供,應用程式可選擇是否啟用多租戶功能。
多租戶流程
┌─────────────────────────────────────────────────────────────────┐
│ 1. 認證階段 (AuthController) │
│ - 用戶登入 │
│ - 從 Account 獲取 tenantId │
│ - 注入到 JWT token claims │
└─────────────────────────────────────────────────────────────────┘
↓
┌─────────────────────────────────────────────────────────────────┐
│ 2. 請求處理階段 (TenantContext + TenantIdResolver) │
│ - TenantIdResolver 從認證資訊解析 tenantId │
│ - 支援多種認證方式:OAuth2 JWT、本地 JWT、Basic Auth │
│ - TenantFilterAspect 自動啟用 Hibernate Filter │
└─────────────────────────────────────────────────────────────────┘
↓
┌─────────────────────────────────────────────────────────────────┐
│ 3. Entity 操作階段 (TenantAwareEntity) │
│ - 新增:@PrePersist 自動注入 tenantId │
│ - 更新:@PreUpdate 驗證 tenantId │
│ - 查詢:Hibernate Filter 自動加上 WHERE tenant_id = ? │
└─────────────────────────────────────────────────────────────────┘
2. 框架與應用層分工
多租戶功能採用框架提供工具、應用層選擇啟用的設計:
appfuse-server 框架提供
| 組件 | 說明 |
|---|---|
TenantContext | 租戶上下文工具類 |
TenantContextCleanupFilter | 請求結束後清理 ThreadLocal |
TenantIdResolver | 租戶 ID 解析策略介面 |
JwtClaimTenantIdResolver | OAuth2 JWT 解析實作 |
JwtDetailsTenantIdResolver | 本地 JWT 解析實作 |
UserDetailsTenantIdResolver | Basic Auth 解析實作 |
CompositeTenantIdResolver | 組合多個 resolver |
TenantAwareUserDetails | UserDetails 擴展介面 |
TenantAware | JPA Entity 介面 |
TenantFilterSupport | Hibernate Filter 工具 |
app-server 應用層實作
| 組件 | 說明 |
|---|---|
TenantConfig | 配置 TenantContext 和註冊 Filter |
TenantAwareEntity | 實作 TenantAware 的基類 |
AccountUserDetails | 實作 TenantAwareUserDetails |
TenantFilterAspect | 使用 TenantFilterSupport 的 AOP |
3. 核心組件
3.1 TenantContext(框架)
套件: io.leandev.appfuse.security.tenant
從認證資訊中獲取當前租戶 ID,支援多種認證方式:
// 配置(應用程式啟動時)
TenantContext.configureWithDefaults();
// 或自訂 claim 名稱
TenantContext.configureWithDefaults("tenant_id");
// 或完全自訂
TenantContext.configure(CompositeTenantIdResolver.builder()
.withJwtClaim("org_id")
.withUserDetails()
.build());
使用方式:
// 取得租戶 ID
String tenantId = TenantContext.getCurrentTenantId();
// 嘗試取得(不拋出異常)
String tenantId = TenantContext.getTenantIdOrNull();
// 檢查是否有租戶上下文
if (TenantContext.hasTenantContext()) {
// ...
}
// 在指定租戶上下文中執行(適用於排程任務)
TenantContext.runAs("tenant-123", () -> {
// 這裡的 TenantContext.getCurrentTenantId() 會返回 "tenant-123"
customerService.processAll();
});
3.2 TenantContextCleanupFilter(框架)
套件: io.leandev.appfuse.security.tenant
確保每個 HTTP 請求結束後清理 TenantContext 的 ThreadLocal,防止在使用 Thread Pool 時發生跨請求的租戶 ID 洩漏。
為什麼需要這個 Filter?
- Servlet 容器(如 Tomcat)使用 Thread Pool 處理請求
- 如果某個請求設定了 ThreadLocal 但未清理,下一個請求可能會讀取到錯誤的租戶 ID
- 這是一個潛在的安全風險,可能導致跨租戶數據洩漏
配置方式(在應用層):
@Configuration
public class TenantConfig {
@Bean
public FilterRegistrationBean<TenantContextCleanupFilter> tenantContextCleanupFilter() {
FilterRegistrationBean<TenantContextCleanupFilter> registration = new FilterRegistrationBean<>();
registration.setFilter(new TenantContextCleanupFilter());
registration.addUrlPatterns("/*");
registration.setOrder(Ordered.HIGHEST_PRECEDENCE);
return registration;
}
}
3.3 TenantIdResolver(框架)
套件: io.leandev.appfuse.security.tenant.resolver
策略介面,定義從 Authentication 解析租戶 ID 的邏輯:
@FunctionalInterface
public interface TenantIdResolver {
String resolve(Authentication authentication);
}
預設實作:
| 實作 | 適用場景 | 解析來源 |
|---|---|---|
JwtClaimTenantIdResolver | OAuth2 JWT | authentication.getPrincipal() → Jwt |
JwtDetailsTenantIdResolver | 本地 JWT | authentication.getDetails() → Claims |
UserDetailsTenantIdResolver | Basic Auth | authentication.getPrincipal() → TenantAwareUserDetails |
組合使用:
// 使用預設組合(推薦)
TenantIdResolver resolver = CompositeTenantIdResolver.withDefaults();
// 使用 Builder 自訂
TenantIdResolver resolver = CompositeTenantIdResolver.builder()
.withJwtClaim("tenantId")
.withJwtDetails("tenantId")
.withUserDetails()
.add(customResolver) // 加入自訂 resolver
.build();
3.4 TenantConfig(應用層)
檔案: src/main/java/io/leandev/app/config/TenantConfig.java
配置 TenantContext 使用的 resolver:
@Configuration
public class TenantConfig {
@PostConstruct
public void configureTenantContext() {
// 使用預設配置,支援 OAuth2 JWT、本地 JWT、Basic Auth
TenantContext.configureWithDefaults();
}
}
3.5 TenantAwareEntity(應用層)
檔案: src/main/java/io/leandev/app/entity/base/TenantAwareEntity.java
所有需要租戶隔離的 Entity 繼承此類:
@Getter
@Setter
@MappedSuperclass
@FilterDef(name = "tenantFilter", parameters = @ParamDef(name = "tenantId", type = String.class))
public abstract class TenantAwareEntity extends AuditableBase implements TenantAware {
@Column(name = "tenant_id", length = 36, nullable = false, updatable = false)
@NotBlank
@Size(max = 36)
private String tenantId;
@PrePersist
protected void onPrePersist() {
if (tenantId == null) {
tenantId = TenantContext.getCurrentTenantId();
} else {
validateTenantId();
}
}
@PreUpdate
protected void onPreUpdate() {
validateTenantId();
}
private void validateTenantId() {
String currentTenantId = TenantContext.getCurrentTenantId();
if (!tenantId.equals(currentTenantId)) {
throw new SecurityException(
String.format("Cross-tenant operation not allowed. Entity tenant: %s, Current tenant: %s",
tenantId, currentTenantId)
);
}
}
}
3.6 AccountUserDetails(應用層)
檔案: src/main/java/io/leandev/app/security/AccountUserDetails.java
擴展 Spring Security 的 User,實作 TenantAwareUserDetails:
@Getter
public class AccountUserDetails extends User implements TenantAwareUserDetails {
private final String tenantId;
private final String accountId;
public AccountUserDetails(String username, String password,
Collection<? extends GrantedAuthority> authorities,
String tenantId, String accountId) {
super(username, password, authorities);
this.tenantId = tenantId;
this.accountId = accountId;
}
}
3.7 TenantFilterAspect(應用層)
檔案: src/main/java/io/leandev/app/config/TenantFilterAspect.java
在 @Transactional 方法執行前自動啟用 Hibernate Filter:
@Slf4j
@Aspect
@Component
@Order(1)
public class TenantFilterAspect {
@PersistenceContext
private EntityManager entityManager;
@Before("(" +
"@annotation(org.springframework.transaction.annotation.Transactional) || " +
"@within(org.springframework.transaction.annotation.Transactional)" +
") && within(io.leandev.app.service..*)")
public void enableTenantFilter(JoinPoint joinPoint) {
if (!TenantContext.hasTenantContext()) {
return;
}
String tenantId = TenantContext.getCurrentTenantId();
// 使用框架工具啟用 filter
if (TenantFilterSupport.enableFilter(entityManager, tenantId)) {
log.debug("Enabled tenant filter for tenant: {}", tenantId);
}
}
}
4. 實作步驟
Step 1: 配置 TenantContext
建立 TenantConfig 配置類:
@Configuration
public class TenantConfig {
@PostConstruct
public void configureTenantContext() {
TenantContext.configureWithDefaults();
}
}
Step 2: UserDetails 實作 TenantAwareUserDetails
public class AccountUserDetails extends User implements TenantAwareUserDetails {
private final String tenantId;
@Override
public String getTenantId() {
return tenantId;
}
}
Step 3: Entity 繼承 TenantAwareEntity
@Entity
@Table(name = "customer", indexes = {
@Index(name = "idx_customer_tenant", columnList = "tenant_id"),
@Index(name = "idx_customer_phone", columnList = "phone")
})
@Filter(name = "tenantFilter", condition = "tenant_id = :tenantId")
public class Customer extends TenantAwareEntity {
@Id
@GeneratedValue(strategy = GenerationType.UUID)
private String id;
@Column(length = 100, nullable = false)
private String name;
// ❌ 不要這樣做
// @ManyToOne private Tenant tenant;
// ✅ tenantId 繼承自 TenantAwareEntity
}
Step 4: 建立 TenantFilterAspect
參考 3.6 節的實作。
5. 使用範例
5.1 Service 層
@Service
@RequiredArgsConstructor
@Transactional
public class CustomerService {
private final CustomerRepository customerRepository;
public Customer create(CreateCustomerRequest request) {
Customer customer = new Customer();
customer.setName(request.getName());
customer.setPhone(request.getPhone());
// 不需要:customer.setTenantId(...);
return customerRepository.save(customer);
// @PrePersist 會自動注入 tenantId
}
public List<Customer> findAll() {
return customerRepository.findAll();
// Hibernate Filter 自動加上 WHERE tenant_id = ?
}
}
5.2 排程任務中使用
@Component
public class DailyReportJob {
@Autowired
private TenantRepository tenantRepository;
@Autowired
private ReportService reportService;
@Scheduled(cron = "0 0 2 * * ?")
public void generateDailyReports() {
List<Tenant> tenants = tenantRepository.findAll();
for (Tenant tenant : tenants) {
// 在指定租戶上下文中執行
TenantContext.runAs(tenant.getId(), () -> {
reportService.generateDailyReport();
});
}
}
}
6. 測試指南
6.1 單元測試
@ExtendWith(MockitoExtension.class)
class CustomerServiceTest {
@Mock
private CustomerRepository customerRepository;
@InjectMocks
private CustomerService customerService;
private static final String TENANT_A = "tenant-a";
@BeforeEach
void setUp() {
// 配置 TenantContext(測試環境)
TenantContext.configureWithDefaults();
TenantContext.setCurrentTenantId(TENANT_A);
}
@AfterEach
void tearDown() {
TenantContext.clear();
}
@Test
void testCreate_shouldAutoInjectTenantId() {
// ...
}
}
6.2 使用 runAs 簡化測試
@Test
void testCrossTenantAccessBlocked() {
// 使用 runAs 切換租戶上下文
TenantContext.runAs("tenant-a", () -> {
Customer customerA = customerService.create(new CreateCustomerRequest("Alice", "0912345678"));
TenantContext.runAs("tenant-b", () -> {
// 應該找不到 tenant-A 的客戶
Optional<Customer> found = customerRepository.findById(customerA.getId());
assertFalse(found.isPresent());
});
});
}
7. 常見問題
Q1: 如何自訂租戶 ID 的 claim 名稱?
A: 在配置時指定:
TenantContext.configureWithDefaults("tenant_id"); // 使用 "tenant_id" 而非 "tenantId"
或使用 Builder:
TenantContext.configure(CompositeTenantIdResolver.builder()
.withJwtClaim("org_id")
.withJwtDetails("org_id")
.withUserDetails()
.build());
Q2: 如何查詢所有租戶的數據(管理員功能)?
A: 使用 TenantFilterSupport 停用 Filter:
@Service
public class AdminService {
@Autowired
private EntityManager entityManager;
@Autowired
private CustomerRepository customerRepository;
public List<Customer> findAllCustomersAcrossTenants() {
TenantFilterSupport.disableFilter(entityManager);
return customerRepository.findAll();
}
}
Q3: 如何在排程任務中設定租戶上下文?
A: 使用 TenantContext.runAs() 或 callAs():
// 無返回值
TenantContext.runAs("tenant-123", () -> {
customerService.processAll();
});
// 有返回值
List<Customer> customers = TenantContext.callAs("tenant-123", () -> {
return customerService.findAll();
});
Q4: 為什麼使用 AOP 而非 HandlerInterceptor?
A:
- HandlerInterceptor 在 HTTP 請求開始時執行,此時 Hibernate Session 可能尚未綁定
@Transactional方法開始時會獲取/創建新的 Session- AOP 在事務開始後、方法執行前啟用 filter,確保使用正確的 Session
Q5: 原生 SQL 查詢會自動過濾嗎?
A: ❌ 不會。原生 SQL 需手動加上 WHERE 條件。
建議優先使用 JPQL 或 Specification。
參考資料
框架原始碼(appfuse-server)
io.leandev.appfuse.security.tenant.TenantContextio.leandev.appfuse.security.tenant.TenantAwareUserDetailsio.leandev.appfuse.security.tenant.resolver.*io.leandev.appfuse.jpa.tenant.TenantAwareio.leandev.appfuse.jpa.tenant.TenantFilterSupport
應用層原始碼(參考實作 app-server)
以下是參考實作中的相關檔案,供開發者參考:
io.leandev.app.config.TenantConfigio.leandev.app.entity.base.TenantAwareEntityio.leandev.app.security.AccountUserDetailsio.leandev.app.config.TenantFilterAspect
相關文檔
- 決策記錄: ADR-001: 多租戶數據隔離策略
- 資料層設計: database-design.md - 第 1 章
外部資源
文檔維護者: Development Team + AI Assistant 最後審閱: 2025-12-30