springboot版本為2.3.4.RELEASE
#持久層採用JPA
因為saas應用所有租用戶都使用同一個服務和資料庫,為隔離好租戶數據,這裡建立一個BaseSaasEntity
public abstract class BaseSaasEntity { @JsonIgnore @Column(nullable = false, updatable = false) protected Long tenantId; }
裡面只有一個字段tenantId,對應的就是租戶Id,所有租戶業務entity都繼承這個父類別。最後透過tenantId來區分資料是哪個租用戶。
按往常,表建好就該接著對應的模組的CURD。但saas應用最基本的要求就是租戶資料隔離,就是公司B的人不能看到公司A的資料,怎麼過濾呢,這裡上面我們建立的BaseSaasEntity就起作用了,透過區分當前請求是來自那個公司後,在所有tenant業務sql中加上where tenant=?就實現了租戶資料過濾。
如果讓我們在業務中都去加上租戶sql過濾代碼,那工作量不僅大,而且出錯的機率也很大。理想是過濾sql拼接統一放在一起處理,在租用戶業務介面開啟sql過濾。因為JPA是有hibernate實現的,這裡我們可以利用hibernate的一些功能
@MappedSuperclass @Data @FilterDef(name = "tenantFilter", parameters = {@ParamDef(name = "tenantId", type = "long")}) @Filter(condition = "tenant_id=:tenantId", name = "tenantFilter") public abstract class BaseSaasEntity { @JsonIgnore @Column(nullable = false, updatable = false) protected Long tenantId; @PrePersist public void onPrePersist() { if (getTenantId() != null) { return; } Long tenantId = TenantContext.getTenantId(); Check.notNull(tenantId, "租户不存在"); setTenantId(tenantId); } }
Hibernate3 提供了一種創新的方式來處理具有「顯性(visibility)」規則的數據,那就是使用Hibernate 過濾器。 Hibernate 過濾器是全域有效的、具有名字、可以帶有參數的過濾器,對於某個特定的 Hibernate session 您可以選擇是否啟用(或停用)某個過濾器。
這裡我們透過@FilterDef和@Filter預先定義了一個sql過濾條件。然後透過一個@TenantFilter註解來識別介面需要進行資料過濾
@Target({ElementType.TYPE, ElementType.METHOD}) @Retention(RetentionPolicy.RUNTIME) @Documented @Transactional public @interface TenantFilter { boolean readOnly() default true; }
可以看出這個介面是放在方法上,對應的就是Controller層。 @Transactional增加事務註解的意義是因為啟動hibernate filter必須開啟事務,這裡預設是唯讀事務。最後定義一個切面來啟動filter
@Aspect @Slf4j @RequiredArgsConstructor public class TenantSQLAspect { private static final String FILTER_NAME = "tenantFilter"; private final EntityManager entityManager; @SneakyThrows @Around("@annotation(com.lvjusoft.njcommon.annotation.TenantFilter)") public Object aspect(ProceedingJoinPoint joinPoint) { Session session = entityManager.unwrap(Session.class); try { Long tenantId = TenantContext.getTenantId(); Check.notNull(tenantId, "租户不存在"); session.enableFilter(FILTER_NAME).setParameter("tenantId", tenantId); return joinPoint.proceed(); } finally { session.disableFilter(FILTER_NAME); } } }
這裡面的物件就是剛才自訂的@TenantFilter註解,在方法執行前拿到目前租用戶id,開啟filter,這樣租用戶資料隔離就大功告成了,只需要在租戶業務介面上增加@TenantFilter註解即可, 開發只用關心業務代碼。上圖中的TenantContext是當前線程租戶context,通過和前端約定好,接口請求頭中增加租戶id,服務端利用攔截器把獲取到的租戶id緩存在ThreadLocal中
public class IdentityInterceptor extends HandlerInterceptorAdapter { public IdentityInterceptor() { log.info("IdentityInterceptor init"); } @Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) { String token = request.getHeader(AuthConstant.USER_TOKEN_HEADER_NAME); UserContext.setToken(token); String tenantId = request.getHeader(AuthConstant.TENANT_TOKEN_HEADER_NAME); TenantContext.setTenantUUId(tenantId); return true; } @Override public void postHandle(HttpServletRequest request, HttpServletResponse response, Object handler, ModelAndView modelAndView) { UserContext.clear(); TenantContext.clear(); } }
隨著租用戶數的增加,mysql單庫單表的資料一定會達到瓶頸,這裡只採用分庫的手段。利用多重資料來源,將租用戶和資料來源進行多對一的對應。
public class DynamicRoutingDataSource extends AbstractRoutingDataSource { private Map<Object, Object> targetDataSources; public DynamicRoutingDataSource() { targetDataSources =new HashMap<>(); DruidDataSource druidDataSource1 = new DruidDataSource(); druidDataSource1.setUsername("username"); druidDataSource1.setPassword("password"); druidDataSource1.setUrl("jdbc:mysql://localhost:3306/db?useSSL=false&useUnicode=true&characterEncoding=utf-8"); targetDataSources.put("db1",druidDataSource1); DruidDataSource druidDataSource2 = new DruidDataSource(); druidDataSource2.setUsername("username"); druidDataSource2.setPassword("password"); druidDataSource2.setUrl("jdbc:mysql://localhost:3306/db?useSSL=false&useUnicode=true&characterEncoding=utf-8"); targetDataSources.put("db2",druidDataSource1); this.targetDataSources = targetDataSources; super.setTargetDataSources(targetDataSources); super.afterPropertiesSet(); } public void addDataSource(String key, DataSource dataSource) { if (targetDataSources.containsKey(key)) { throw new IllegalArgumentException("dataSource key exist"); } targetDataSources.put(key, dataSource); super.setTargetDataSources(targetDataSources); super.afterPropertiesSet(); } @Override protected Object determineCurrentLookupKey() { return DataSourceContext.getSource(); } }
透過實作AbstractRoutingDataSource來宣告一個動態路由資料來源,在框架使用datesource前,spring會呼叫determineCurrentLookupKey()方法來決定使用哪個資料來源。這裡的DataSourceContext和上面的TenantContext類似,在攔截器中取得到tenantInfo後,找到目前租用戶對應的資料來源key並設定在ThreadLocal。
以上是Springboot多租戶SaaS如何搭建的詳細內容。更多資訊請關注PHP中文網其他相關文章!