引言#
マルチテナントは主にデータの隔離に使用され、通常は SAAS システム内で、ソフトウェアをサービスとして企業に提供します。各企業は一つのテナントであり、各テナントは自テナント内のデータのみを見ることができます。したがって、各テナントに対してデータの隔離が必要です。
データ隔離#
マルチテナントのデータ隔離ソリューションは、三つの種類に分けることができます:
DATASOURCE モード:独立したデータベース
SCHEMA モード:共有データベース、独立したスキーマ
COLUMN モード:共有データベース、共有スキーマ、共有データテーブル
DATASOURCE モード#
一つのテナントに一つのデータベース。このソリューションのユーザーデータ隔離レベルは最高で、安全性も最も高いですが、コストも高いです。
DATASOURCE モード
利点:異なるテナントに独立したデータベースを提供し、データモデルの拡張設計を簡素化し、異なるテナントの独自のニーズを満たすのに役立ちます;故障が発生した場合、データの復元が比較的簡単です。
欠点:データベースのインストール数が増加し、それに伴いメンテナンスコストと購入コストが増加します。
SCHEMA モード#
複数またはすべてのテナントがデータベースを共有しますが、一つのテナントに一つのテーブルがあります。
SCHEMA モード
利点:安全性の要求が高いテナントに対して一定の論理データ隔離を提供しますが、完全な隔離ではありません;各データベースはより多くのテナント数をサポートできます。
欠点:故障が発生した場合、データの復元が比較的困難です。なぜなら、データベースの復元は他のテナントのデータに関わるからです;テナントを跨いでデータを集計する必要がある場合、一定の困難があります。
COLUMN モード#
共有データベース、共有データ構造。テナントは同じデータベースと同じテーブルを共有しますが、テーブル内で tenant_id フィールドを通じてテナントのデータを区別します。これは共有度が最も高く、隔離レベルが最も低いモードです。
COLUMN モード
利点:メンテナンスと購入コストが最も低く、各データベースがサポートできるテナント数が最も多いです。
欠点:隔離レベルが最も低く、安全性も最低で、設計開発時に安全性に対する開発量を増やす必要があります;データのバックアップと復元が最も困難で、テーブルごとに逐次バックアップと復元が必要です。
まとめ#
ほとんどの場合、DATASOURCE と COLUMN モードが最もよく使用されます。
予算が十分であるか、テナントが安全性や隔離性を高く要求する場合はDATASOURCE モードを選択します。
通常の場合はCOLUMN モードで十分であり、開発と運用が簡単で、最少のサーバーで最大のテナントにサービスを提供します。
原理#
以下に、最も一般的なCOLUMN 共有データベースのモードを用いてマルチテナントの実装原理を説明します。
あなたはすでに気づいていると思いますが、このモードでは通常の単一テナントアプリケーションとの唯一の違いは、データベーステーブルに新たにtenant_idフィールドが追加されていることです。
その通り、このフィールドはユニークなテナント識別子であり、プログラムはこのフィールドを使用して各テナントのデータを判断する必要があります。
あなたは考えるかもしれません、では SQL を書くときにテナントフィールドを直接結合すればいいのではないか?
もちろんそうすることもできますが、明らかに優雅ではありません。
mybatis plus マルチテナントプラグイン#
では、どのように優雅なコードを書いてマルチテナントを実現するのでしょうか?
mybatis は国内の開発者が最もよく使用し、最も慣れ親しんでいる ORM フレームワークで、ネイティブなマルチテナントプラグインを提供しています。
公式マルチテナントプラグイン
例
public interface TenantLineHandler {
/**
* テナントID値の式を取得します。単一のID値のみサポートします。
* <p>
*
* @return テナントID値の式
*/
Expression getTenantId();
/**
* テナントフィールド名を取得します。
* <p>
* デフォルトのフィールド名は: tenant_id
*
* @return テナントフィールド名
*/
default String getTenantIdColumn() {
// このフィールドが固定でない場合は、SqlInjectionUtils.checkで安全性を確認してください。
return "tenant_id";
}
/**
* テーブル名に基づいてマルチテナント条件の結合を無視するかどうかを判断します。
* <p>
* デフォルトでは、すべて解析し、マルチテナント条件を結合する必要があります。
*
* @param tableName テーブル名
* @return 無視するかどうか、true:無視する、false:解析してマルチテナント条件を結合する必要がある
*/
default boolean ignoreTable(String tableName) {
return false;
}
}
説明:
マルチテナント!= 権限フィルタリング、乱用しないでください。テナント間は完全に隔離されています!!!
マルチテナントを有効にすると、すべての実行されるメソッドの SQL は処理されます。
自作の SQL は規範に従って記述してください(SQL が複数のテーブルに関わる場合、各テーブルにはエイリアスを付ける必要があります。特に inner join の場合は標準の inner join を記述してください)。
このプラグインを使用すると、SQL の結合操作は mybatis-plus フレームワークが行ってくれます。その実装方法はページングプラグイン(インターセプター)に基づいています。
進階#
上記は単純な例に過ぎません。次に、ruoyi-vue-plus
スキャフォールドを用いてマルチテナントの機能を拡張します。
yml 設定#
yml 設定ファイルに以下の設定を追加します。
# マルチテナント設定
tenant:
# マルチテナントモードを有効にするか
enable: true
# テナントフィールド名
column: tenant_id
# マルチテナントを判断する必要があるテーブル名
includes:
- sr_card
- sr_room
- sr_seat
- sr_room_seat
- sr_swiper
- sys_oss
コンポーネントに対応する設定ファイルの値を記述します。
@Data
@Component
@ConfigurationProperties(prefix = "tenant")
public class TenantProperties {
/**
* マルチテナントモードを有効にするか
*/
private Boolean enable;
/**
* テナントフィールド名
*/
private String column;
/**
* マルチテナントを判断する必要があるテーブル名
*/
private List<String> includes;
}
mybatis-plus 設定#
次に、mybatis-plus の設定ファイルにマルチテナントプラグインを登録し、プラグインのロジックを記述します。
@Resource
private TenantProperties tenantProperties;
@Bean
public MybatisPlusInterceptor mybatisPlusInterceptor() {
MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor();
if (tenantProperties.getEnable()) {
// マルチテナントプラグイン
interceptor.addInnerInterceptor(tenantLineInnerInterceptor());
}
// データ権限処理
interceptor.addInnerInterceptor(dataPermissionInterceptor());
// ページングプラグイン
interceptor.addInnerInterceptor(paginationInnerInterceptor());
// 楽観ロックプラグイン
interceptor.addInnerInterceptor(optimisticLockerInnerInterceptor());
return interceptor;
}
まず、設定ファイルをインポートし、マルチテナントプラグインを登録します。注意すべきは、マルチテナントプラグインはページングプラグインの前に配置する必要があることです。
次に、対応するロジックを記述します。
public TenantLineInnerInterceptor tenantLineInnerInterceptor() {
return new TenantLineInnerInterceptor(new TenantLineHandler() {
@Override
public Expression getTenantId() {
try {
LoginUser loginUser = LoginUserShareMap.getLoginUser();
if (loginUser == null) {
System.err.println("現在ログインしていないユーザー!");
//マルチテナントモード、テナントIDはnull
return new LongValue(0L);
} else {
//マルチテナントモード、テナントIDは現在のユーザーから取得
return new LongValue(loginUser.getUserId());
}
} catch (Exception e) {
throw new RuntimeException("現在のログインユーザー情報の取得に失敗しました!", e);
}
}
@Override
public String getTenantIdColumn() {
// データベースのテナントIDの列名
System.err.println("現在のテナント列名:" + tenantProperties.getColumn());
return tenantProperties.getColumn();
}
// これはdefaultメソッドで、デフォルトでfalseを返し、すべてのテーブルがマルチテナント条件を結合する必要があることを示します。
@Override
public boolean ignoreTable(String tableName) {
LoginUser loginUser = LoginUserShareMap.getLoginUser();
// ログインしているかどうかを判断し、ログインしている場合はフィルタリング
if (loginUser != null) {
// プラットフォームのスーパーユーザーかどうかを判断し、スーパーユーザーであればすべてのデータ権限を持ちます。
if (!LoginHelper.isAdmin()) {
// テナントをフィルタリングする必要があるテーブル
List<String> includes = tenantProperties.getIncludes();
return !includes.contains(tableName);
}
}
return true;
}
});
}
ここで注意すべき点がいくつかあります。
- デフォルトでは、
ruoyi-vue-plus
はプロジェクトの初期化時にデータベーステーブルの設定情報を読み込みます。また、フレームワークは sa-token を使用してログイン検証を行っています。プロジェクトの初期化時にこのメソッドを実行すると、sa-token を使用してログインユーザーを取得すると、web コンテキストを取得できないというエラーが発生します。 - ログイン状態を保存するためのコンポーネントを使用できます。通常、
ThreadLocal
を使用しますが、注意が必要です。このフレームワークはスレッドプールを使用しているため、ThreadLocal
を使用すると予期しないエラーが発生する可能性があります。たとえば、高い同時実行性や高負荷の状況では、ユーザーがログインしているスレッドは A ですが、テナントを判断するスレッドは B であり、これら 2 つのスレッドのThreadLocal
は異なります。
特定 SQL フィルタリング#
マルチテナントプラグインはテーブルベースのフィルタリングを提供していますが、より細かい粒度のSQLベースのフィルタリングは提供していません。
ただし、実現するための注釈を提供しています。
注釈 | mybatis-plus バージョン |
---|---|
@InterceptorIgnore(tenantLine = "true") | Mybatis-Plus3.4+ |
@SqlParser(filter=true) | Mybatis-Plus3.4- |
注意が必要なのは、MP バージョンが 3.1.1 以下の場合、@SqlParser(filter=true)
を使用するには以下の設定が必要です。
# SQL解析キャッシュ注釈を有効にします。MPバージョンが3.1.1以上の場合は設定は不要です。
mybatis-plus:
global-config:
sql-parser-cache: true
mapper 内で使用するだけです。
public interface TenantMapper extends BaseMapper<Tenant> {
/**
* 自定Wrapper, @SqlParser(filter = true)注釈はSQL解析を行わず、テナントの追加条件がないことを示します。
*
* @return
*/
@SqlParser(filter = true)
@Select("SELECT count(5) FROM t_tenant ")
public Integer myCount();
}