Mybatis Plus 多租戶架構實現(完美教程)


一、背景介紹

 

 

 

  多租戶技術或稱多重租賃技術,簡稱SaaS,是一種軟件架構技術,是實現如何在多用戶環境下(此處的多用戶一般是面向企業用戶)共用相同的系統或程序組件,並且可確保各用戶間數據的隔離性。
  簡單講:在一台服務器上運行單個應用實例,它為多個租戶(客戶)提供服務。從定義中我們可以理解:多租戶是一種架構,目的是為了讓多用戶環境下使用同一套程序,且保證用戶間數據隔離。那么重點就很淺顯易懂了,多租戶的重點就是同一套程序下實現多用戶數據的隔離。

二、基礎介紹

  近日有些朋友向我討教關於多租戶設計方案,正好公司做的也是多租戶系統,功能實現不是我開發的,之前也沒有細致的了解實現過程。借此機會,結合公司現有的多租戶方案及其網上瀏覽的租戶方案設計,自己實現了一套技術方案,可以解決共享數據庫或共享數據表。

三、數據隔離技術方案

1.獨立數據庫

  即一個租戶一個數據庫,這種方案的用戶數據隔離級別最高,安全性最好,但成本較高。
  優點:為不同的租戶提供獨立的數據庫,有助於簡化數據模型的擴展設計,滿足不同租戶的獨特需求;如果出現故障,恢復數據比較簡單。
  缺點:增多了數據庫的安裝數量,隨之帶來維護成本和購置成本的增加。

2.共享數據庫,獨立 Schema

  多個或所有租戶共享Database,但是每個租戶一個Schema(也可叫做一個user)。底層庫比如是:DB2、ORACLE等,一個數據庫下可以有多個SCHEMA。
  優點:為安全性要求較高的租戶提供了一定程度的邏輯數據隔離,並不是完全隔離;每個數據庫可支持更多的租戶數量。
  缺點:如果出現故障,數據恢復比較困難,因為恢復數據庫將牽涉到其他租戶的數據;

3.共享數據庫,共享 Schema,共享數據表  

  即租戶共享同一個Database、同一個Schema,但在表中增加TenantID多租戶的數據字段。這是共享程度最高、隔離級別最低的模式。
  簡單來講,即每插入一條數據時都需要有一個客戶的標識。這樣才能在同一張表中區分出不同客戶的數據,這也是我們系統目前用到的(tenant_id)
  優點:三種方案比較,第三種方案的維護和購置成本最低,允許每個數據庫支持的租戶數量最多。
  缺點:隔離級別最低,安全性最低,需要在設計開發時加大對安全的開發量; 數據備份和恢復最困難,需要逐表逐條備份和還原。

四、優點介紹

   本次租戶業務實現看似是一個方案,其實是結合了獨立數據庫、共享數據庫,共享 Schema,共享數據表類實現的兩個方案,具體根據您的需求來設計。

  由於多租戶中我們不止是每個租戶都存在一個客戶標識,可能每個表中都存在創建人、更新人、刪除標識,這次我也是集成了方案解決。

     根據自己的需求,靈活變更業務邏輯,業務代碼高可用、注釋完善、易上手。

     網上的教程大部分都是基於mybatis-plus的TenantLineInnerInterceptor 實現所有的租戶通過tenant_id來處理多租戶之間打數據隔離,這個局限性太低了,而我實現的可靈活自定義實現。

五、業務實現

  注意:以下實現主要列出核心編碼實現,完整代碼放在文章最下方。如有不足之處,希望各位IT界大佬多多指教,謝謝!

1.導入maven jar包

 1 <dependencies>
 2         <dependency>
 3             <groupId>org.springframework.boot</groupId>
 4             <artifactId>spring-boot-starter-jdbc</artifactId>
 5         </dependency>
 6         <dependency>
 7             <groupId>org.springframework.boot</groupId>
 8             <artifactId>spring-boot-starter-web</artifactId>
 9         </dependency>
10 
11         <dependency>
12             <groupId>mysql</groupId>
13             <artifactId>mysql-connector-java</artifactId>
14             <scope>runtime</scope>
15         </dependency>
16         <dependency>
17             <groupId>org.springframework.boot</groupId>
18             <artifactId>spring-boot-starter-test</artifactId>
19             <scope>test</scope>
20         </dependency>
21         <dependency>
22             <groupId>org.apache.commons</groupId>
23             <artifactId>commons-lang3</artifactId>
24         </dependency>
25         <!--阿里數據庫連接池 -->
26         <dependency>
27             <groupId>com.alibaba</groupId>
28             <artifactId>druid-spring-boot-starter</artifactId>
29             <version>1.1.21</version>
30         </dependency>
31         <dependency>
32             <groupId>com.baomidou</groupId>
33             <artifactId>mybatis-plus-boot-starter</artifactId>
34             <version>3.4.1</version>
35         </dependency>
36         <dependency>
37             <groupId>org.projectlombok</groupId>
38             <artifactId>lombok</artifactId>
39             <optional>true</optional>
40         </dependency>
41         <dependency>
42             <groupId>com.baomidou</groupId>
43             <artifactId>mybatis-plus-extension</artifactId>
44             <version>3.4.1</version>
45         </dependency>
46     </dependencies>

2.數據庫表,可以建兩個庫進行模擬

 1 CREATE TABLE `tenant` (
 2   `id` int(11) NOT NULL AUTO_INCREMENT COMMENT '主鍵id',
 3   `name` varchar(255) DEFAULT NULL COMMENT '租戶名稱',
 4   `tenant_id` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci DEFAULT NULL COMMENT '租戶id',
 5   `create_time` datetime DEFAULT CURRENT_TIMESTAMP COMMENT '創建時間',
 6   `create_by` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci DEFAULT NULL COMMENT '創建人',
 7   `update_time` datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '修改時間',
 8   `update_by` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci DEFAULT NULL COMMENT '修改人',
 9   `is_delete` tinyint(1) DEFAULT '0' COMMENT '1刪除 0未刪除 默認0',
10   PRIMARY KEY (`id`)
11 ) ENGINE=InnoDB AUTO_INCREMENT=12 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='企業表';

 3.我們實現Mybtis中Interceptor,來攔截我們的接口,處理sql。

  注意:handleReplace 方法中的 tenantProperties.getTenantTable()就是獲取庫的名稱,這個需要根據你業務,用戶登錄后存儲租戶標識,此處根據不同的用戶獲取不同的標識,切換數據庫,我在本案例中只是指定了某個庫區執行sql。

 
        
  1 /**
  2  * @author: fuzongle
  3  * @description:  攔截器
  4  **/
  5 
  6 @Slf4j
  7 @Intercepts({
  8         @Signature(type = Executor.class, method = "query", args = {MappedStatement.class, Object.class, RowBounds.class, ResultHandler.class}),
  9         @Signature(type = Executor.class, method = "update", args = {MappedStatement.class, Object.class})
 10 })
 11 public class SqlLogInterceptor implements Interceptor {
 12 
 13     @Autowired
 14     private TenantProperties tenantProperties;
 15 
 16     public static class CustomSqlSource implements SqlSource {
 17 
 18         private BoundSql boundSql;
 19 
 20         protected CustomSqlSource(BoundSql boundSql){
 21             this.boundSql = boundSql;
 22         }
 23 
 24         @Override
 25         public BoundSql getBoundSql(Object o) {
 26             return boundSql;
 27         }
 28     }
 29 
 30     @Override
 31     public Object intercept(Invocation invocation) throws Throwable {
 32         MappedStatement ms = (MappedStatement)invocation.getArgs()[0];
 33         Object parameterObject = null;
 34         InterceptorIgnore annotation = null;
 35         Class<?> clazz = Class.forName(ms.getId().substring(0, ms.getId().lastIndexOf(".")));
 36         Method[] methods = clazz.getDeclaredMethods();
 37         for (Method method : methods) {
 38            annotation = method.getAnnotation(InterceptorIgnore.class);
 39         }
 40         if(invocation.getArgs().length > 1){
 41             parameterObject = invocation.getArgs()[1];
 42         }
 43 
 44         BoundSql boundSql = ms.getBoundSql(parameterObject);
 45         String sql = boundSql.getSql();
 46 
 47         if (tenantProperties.getEnable() || annotation == null) {
 48             sql = handleReplace(boundSql.getSql());
 49         }
 50         BoundSql newBoundSql = new BoundSql(
 51                 ms.getConfiguration(),
 52                 sql, //sql替換  唯一發生改變的地方
 53                 boundSql.getParameterMappings(),
 54                 boundSql.getParameterObject()
 55         );
 56 
 57         MappedStatement.Builder build = new MappedStatement.Builder(
 58                 ms.getConfiguration(),
 59                 ms.getId(),
 60                 new CustomSqlSource(newBoundSql),
 61                 ms.getSqlCommandType()
 62         );
 63         build.resource(ms.getResource());
 64         build.fetchSize(ms.getFetchSize());
 65         build.statementType(ms.getStatementType());
 66         build.keyGenerator(ms.getKeyGenerator());
 67         build.timeout(ms.getTimeout());
 68         build.parameterMap(ms.getParameterMap());
 69         build.resultMaps(ms.getResultMaps());
 70         build.cache(ms.getCache());
 71 
 72         MappedStatement newStmt = build.build();
 73         //替換原來的MappedStatement
 74         invocation.getArgs()[0] = newStmt;
 75 
 76         return invocation.proceed();
 77     }
 78    // 核心業務處理
 79     private String handleReplace(String sql) throws JSQLParserException {
 80         Statement stmt = CCJSqlParserUtil.parse(sql);
 81         //需要sql校驗。
 82         String schemeName = String.format("`%s`", tenantProperties.getTenantTable());
 83         if(stmt instanceof Insert){
 84             Insert insert = (Insert)stmt;
 85             return SqlParser.doInsert(insert, schemeName);
 86         }else if(stmt instanceof Update){
 87             Update update = (Update) stmt;
 88             return SqlParser.doUpdate(update, schemeName);
 89         }else if(stmt instanceof Delete){
 90             Delete delete = (Delete) stmt;
 91             return SqlParser.doDelete(delete, schemeName);
 92         }else if(stmt instanceof Select){
 93             Select select = (Select)stmt;
 94             return SqlParser.doSelect(select, schemeName);
 95         }
 96         throw new RuntimeException("非法sql!");
 97     }
 98 
 99 
100 
101 
102 
103 }

4.新增語句處理

 1 public static String doInsert(Insert insert, String schemaName) {
 2         String tableName = insert.getTable().getName();
 3         //校驗系統非追加表
 4         if (tenantProperties.getIgnoreTables().contains(tableName)){
 5             return insert.toString();
 6         }
 7         //獲取表對應實體路勁
 8         String entityPath = EntityTableCache.getInstance().getCacheData(tableName).toString();
 9         //判斷實體是否有createdBy屬性,追加create_by
10         if (EntityUtils.isHaveAttr(entityPath, TenantGlobalColumnHandler.COLUMN_CREATED_BY_ENTITY)) {
11             handleColumnsAndExpressions(insert, TenantGlobalColumnHandler.COLUMN_CREATED_BY);
12         }
13         //判斷實體是否有updateBy屬性,追加update_by
14         if (EntityUtils.isHaveAttr(entityPath,TenantGlobalColumnHandler.COLUMN_UPDATED_BY_ENTITY)) {
15             handleColumnsAndExpressions(insert,TenantGlobalColumnHandler.COLUMN_UPDATED_BY);
16         }
17         //追加tenant_id
18         insert.getColumns().add(new Column(TenantGlobalColumnHandler.COLUMN_TENANT_ID));
19         ((ExpressionList) insert.getItemsList()).getExpressions().add(TenantGlobalColumnHandler.getTenantId());
20         //是否設置庫名
21         Table table = insert.getTable();
22         table.setSchemaName(schemaName);
23         insert.setTable(table);
24         return insert.toString();
25     }

5.刪除語句處理

 1 public static String doDelete(Delete delete, String schemaName) {
 2         String tableName = delete.getTable().getName();
 3         //校驗系統非追加表
 4         if (tenantProperties.getIgnoreTables().contains(tableName)){
 5             return delete.toString();
 6         }
 7         //構建where條件
 8         BinaryExpression binaryExpression = andExpression(delete.getTable(), delete.getWhere());
 9         //追加where條件
10         delete.setWhere(binaryExpression);
11         //設置庫名
12         Table t = delete.getTable();
13         t.setSchemaName(schemaName);
14         delete.setTable(t);
15         return delete.toString();
16     }

6.修改語句處理 

 1 public static String doUpdate(Update update, String schemaName) throws JSQLParserException{
 2         String tableName = update.getTable().getName();
 3         //校驗系統非追加表
 4         if (tenantProperties.getIgnoreTables().contains(tableName)){
 5             return update.toString();
 6         }
 7         //構建where條件
 8         BinaryExpression binaryExpression = andExpression(update.getTable(), update.getWhere());
 9         //追加where條件
10         update.setWhere(binaryExpression);
11         //獲取表對應實體路勁
12         String entityPath = EntityTableCache.getInstance().getCacheData(tableName).toString();
13         //判斷實體是否有updateBy屬性,追加update_by
14         if (EntityUtils.isHaveAttr(entityPath,TenantGlobalColumnHandler.COLUMN_UPDATED_BY_ENTITY)) {
15             handleColumnsAndExpressions(update,TenantGlobalColumnHandler.COLUMN_UPDATED_BY);
16         }
17 
18         //追加庫名
19         StringBuilder buffer = new StringBuilder();
20         Table tb = update.getTable();
21         tb.setSchemaName(schemaName);
22         update.setTable(tb);
23         // 處理from
24         FromItem fromItem = update.getFromItem();
25         if (fromItem != null) {
26             Table tf = (Table) fromItem;
27             tf.setSchemaName(schemaName);
28         }
29         // 處理join
30         List<Join> joins = update.getJoins();
31         if (joins != null && joins.size() > 0) {
32             for (Object object : joins) {
33                 Join t = (Join) object;
34                 Table rightItem = (Table) t.getRightItem();
35                 rightItem.setSchemaName(schemaName);
36                 System.out.println();
37             }
38         }
39         ExpressionDeParser expressionDeParser = new ExpressionDeParser();
40         UpdateDeParser p = new UpdateDeParser(expressionDeParser, null, buffer);
41         expressionDeParser.setBuffer(buffer);
42         p.deParse(update);
43 
44         return update.toString();
45     }

7.查詢語句處理,如果不滿足您的業務可參考Mybatis Puls中的TenantSqlParser自定義追加條件。

 1 public static String doSelect(Select select, String schemaName){
 2         processPlainSelect((PlainSelect) select.getSelectBody());
 3         StringBuilder buffer = new StringBuilder();
 4         ExpressionDeParser expressionDeParser = new ExpressionDeParser();
 5         SQLParserSelect parser = new SQLParserSelect(expressionDeParser, buffer);
 6         parser.setSchemaName(schemaName);
 7         expressionDeParser.setSelectVisitor(parser);
 8         expressionDeParser.setBuffer(buffer);
 9         select.getSelectBody().accept(parser);
10 
11         return buffer.toString();
12     }

六、執行過程

1.新增

2.刪除

3.修改

 4.查詢

 七、源碼地址:https://gitee.com/fuzongle

   

 


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM