MongoDB多租户方案设计
一、前言
多租户技术(英语:multi-tenancy technology)或称多重租赁技术,是一种软件架构技术,它是在探讨与实现如何于多用户的环境下共用相同的系统或程序组件,并且仍可确保各用户间数据的隔离性。简单来说是指一个单独的实例可以为多个组织服务。 在多租户技术的加持下,服务提供商不必为每个组织单独部署一套数据库、应用服务程序,首先节省了服务器等硬件资源,其次软件服务的运维工作也将变得简单,最后还可以结合虚拟机化技术或容器技术充分最大化利用硬件资源,节约成本。 SaaS,是Software-as-a-Service的缩写名称,意思为软件即服务,即通过网络提供软件服务。 多重租赁技术是SaaS的重要特性,近几年国内SaaS热度不断攀升。其实SaaS解决方案并未无懈可击,比如SaaS企业个性化技术不是很成熟、数据安全性相比私有化部署差距甚远等等。
二、常见的多租户方案
即一个租户一个数据库,这种方案的用户数据隔离级别最高,安全性最好,但成本也高。
即多个或所有租户共享Database,但一个Tenant一个Schema。
即租户共享同一个Database、同一个Schema,但在表中通过TenantID区分租户的数据。这是共享程度最高、隔离级别最低的模式。
对于MySQL数据库应用来讲,通常采用第三种方案,通过在表中增加TenantID区分租户的数据,如果ORM框架使用的是Mybatis,你可以通过自定义SQL拦截器,实现租户字段TenantID自动补全,也可以通过开源框架Mybatis-Plus的多租户插件。 而对于MongoDB数据库,由于我本人接触的时间也不是很长,目前暂未找到进行自定义语句拦截的开源方案,打算通过动态切换MongoDB的方式实现租户资源切换。如果你打算自行开发一套MongoDB多租户拦截插件,你需要对MongoDB语法树和执行引擎比较熟悉,相比动态切换MongoDB来说难度较大。
三、MongoDB 多租户方案
CentOS 7.9 MongoDB 安装和使用
1.pom.xml
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-mongodb</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
<optional>true</optional>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-configuration-processor</artifactId>
<optional>true</optional>
</dependency>
2.application.yml
spring:
data:
mongodb:
host: localhost
port: 27017
database: tenant-default
username: admin
password: 123456
authentication-database: admin
auto-index-creation: false
logging:
level:
org.springframework.data.mongodb.core: debug
3.multi-mongo-spring-boot-starter
D:.
│ pom.xml
│
├─src
│ └─main
│ ├─java
│ │ └─com
│ │ └─example
│ │ └─demo
│ │ └─mongo
│ │ ├─autoconfigure
│ │ │ MongoMultiTenantAutoConfiguration.java 自动配置类
│ │ │
│ │ ├─context
│ │ │ MongoContextHolder.java ThreadLocal DB上下文
│ │ │
│ │ ├─factory
│ │ │ MongoMultiTenantFactory.java MongoDB数据库工厂类(非连接工厂)
│ │ │
│ │ ├─filter
│ │ │ MongoContextFilter.java Web Filter
│ │ │ OrderedMongoContextFilter.java Ordered Web Filter
│ │ │
│ │ └─provider
│ │ MongoMultiTenantNameProvider.java 多租户DB名称提供者(接口)
│ │
│ └─resources
│ └─META-INF
│ spring.factories
4.代码
- MongoMultiTenantAutoConfiguration
package com.example.demo.mongo.autoconfigure;
import com.example.demo.mongo.factory.MongoMultiTenantFactory;
import com.example.demo.mongo.filter.MongoContextFilter;
import com.example.demo.mongo.filter.OrderedMongoContextFilter;
import com.example.demo.mongo.helper.MongoMultiTenantHelper;
import com.example.demo.mongo.provider.MongoMultiTenantNameProvider;
import com.mongodb.client.MongoClient;
import org.springframework.boot.autoconfigure.AutoConfigureAfter;
import org.springframework.boot.autoconfigure.condition.ConditionalOnBean;
import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;
import org.springframework.boot.autoconfigure.condition.ConditionalOnWebApplication;
import org.springframework.boot.autoconfigure.mongo.MongoAutoConfiguration;
import org.springframework.boot.autoconfigure.mongo.MongoProperties;
import org.springframework.boot.autoconfigure.web.servlet.ConditionalOnMissingFilterBean;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.mongodb.MongoDatabaseFactory;
import org.springframework.data.mongodb.core.MongoDatabaseFactorySupport;
import org.springframework.data.mongodb.core.MongoTemplate;
@Configuration(proxyBeanMethods = false)
@AutoConfigureAfter(MongoAutoConfiguration.class)
public class MongoMultiTenantAutoConfiguration {
@Bean
@ConditionalOnMissingBean(MongoDatabaseFactory.class)
MongoDatabaseFactorySupport<?> mongoDatabaseFactory(MongoClient mongoClient, MongoProperties properties) {
return new MongoMultiTenantFactory(mongoClient, properties.getMongoClientDatabase());
}
@Bean
@ConditionalOnMissingBean(MongoMultiTenantNameProvider.class)
public MongoMultiTenantNameProvider defaultTenantProvider(MongoProperties properties) {
return properties::getDatabase;
}
@Bean
@ConditionalOnWebApplication
@ConditionalOnBean(MongoMultiTenantNameProvider.class)
@ConditionalOnMissingBean(MongoContextFilter.class)
@ConditionalOnMissingFilterBean(MongoContextFilter.class)
public MongoContextFilter mongodbContextFilter(MongoMultiTenantNameProvider mongoMultiTenantNameProvider) {
return new OrderedMongoContextFilter(mongoMultiTenantNameProvider);
}
}
package com.example.demo.mongo.context;
import com.example.demo.mongo.filter.MongoContextFilter;
public abstract class MongoContextHolder {
private static final ThreadLocal<String> context = new InheritableThreadLocal<>();
public static void setDbName(String dbName) {
context.set(dbName);
}
public static String getDbName() {
return context.get();
}
public static void reset() {
context.remove();
}
}
package com.example.demo.mongo.factory;
import com.example.demo.mongo.context.MongoContextHolder;
import com.mongodb.client.MongoClient;
import com.mongodb.client.MongoDatabase;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.data.mongodb.core.SimpleMongoClientDatabaseFactory;
import org.springframework.util.StringUtils;
public class MongoMultiTenantFactory extends SimpleMongoClientDatabaseFactory {
private static final Logger logger = LoggerFactory.getLogger(MongoMultiTenantFactory.class);
public MongoMultiTenantFactory(MongoClient mongoClient, String databaseName) {
super(mongoClient, databaseName);
}
@Override
protected MongoDatabase doGetMongoDatabase(String dbName) {
final String context = MongoContextHolder.getDbName();
String target = dbName;
if (StringUtils.hasLength(context)) {
target = context;
logger.debug("MongoDB switch to {}", context);
}
return super.doGetMongoDatabase(target);
}
}
package com.example.demo.mongo.filter;
import com.example.demo.mongo.context.MongoContextHolder;
import com.example.demo.mongo.provider.MongoMultiTenantNameProvider;
import org.springframework.web.filter.OncePerRequestFilter;
import javax.servlet.FilterChain;
import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
public class MongoContextFilter extends OncePerRequestFilter {
private MongoMultiTenantNameProvider mongoMultiTenantNameProvider;
public MongoContextFilter(MongoMultiTenantNameProvider mongoMultiTenantNameProvider) {
this.mongoMultiTenantNameProvider = mongoMultiTenantNameProvider;
}
@Override
protected boolean shouldNotFilterAsyncDispatch() {
return false;
}
@Override
protected boolean shouldNotFilterErrorDispatch() {
return false;
}
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
initContextHolders();
try {
filterChain.doFilter(request, response);
} finally {
resetContextHolders();
}
}
private void initContextHolders() {
final String dbName = mongoMultiTenantNameProvider.getTenantMongodbName();
MongoContextHolder.setDbName(dbName);
}
private void resetContextHolders() {
MongoContextHolder.reset();
}
}
- OrderedMongoContextFilter
package com.example.demo.mongo.filter;
import com.example.demo.mongo.provider.MongoMultiTenantNameProvider;
import org.springframework.core.Ordered;
public class OrderedMongoContextFilter extends MongoContextFilter implements Ordered {
private int order = Ordered.LOWEST_PRECEDENCE;
public OrderedMongoContextFilter(MongoMultiTenantNameProvider mongoMultiTenantNameProvider) {
super(mongoMultiTenantNameProvider);
}
@Override
public int getOrder() {
return order;
}
public void setOrder(int order) {
this.order = order;
}
}
- MongoMultiTenantNameProvider
package com.example.demo.mongo.provider;
public interface MongoMultiTenantNameProvider {
String getTenantMongodbName();
}
MongoDB租户数据库名提供者,根据业务情况自定义,可以简单的将租户ID作为数据库,也可以根据租户ID经过一定的处理逻辑生成一个唯一的数据库名称。
package com.example.demo.bussiness.config;
import com.example.demo.common.context.TenantContextHolder;
import com.example.demo.mongo.provider.MongoMultiTenantNameProvider;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@Configuration
public class MongoConfiguration {
@Bean
public MongoMultiTenantNameProvider mongodbTenantProvider() {
return TenantContextHolder::getTenant;
}
}
四、调用链
Web Request (以查询数据为例)
=>自定义Web Filter,请求拦截顺序默认最低
com.example.demo.mongo.filter.MongoContextFilter#doFilterInternal
=>通过多租户DB名称提供者获取数据库名称(仅提供Function接口,需要业务方实现具体方法,并将其注入到Spring容器中,例如可以简单的将租户ID作为DB名称TenantContextHolder::getTenant )
com.example.demo.mongo.provider.MongoMultiTenantNameProvider#getTenantMongodbName
=>向ThreadLocal DB上下文写入数据库名称
com.example.demo.mongo.context.MongoContextHolder#setDbName
=> 调用Spring Data Mongo API
org.springframework.data.mongodb.repository.MongoRepository#findAll()
=>
org.springframework.data.mongodb.repository.support.SimpleMongoRepository#findAll(org.springframework.data.mongodb.core.query.Query)
=> MongoTemplate 作为mongoOperations 的默认实现类注入到Spring容器中
org.springframework.data.mongodb.core.MongoTemplate#find(org.springframework.data.mongodb.core.query.Query, java.lang.Class<T>, java.lang.String)
=> 省略MongoTemplate 内部调用部分
org.springframework.data.mongodb.core.MongoTemplate#doGetDatabase
=> 使用MongoDatabaseUtils 工具类,从MongoDatabaseFactory 工厂类获取MongoDatabase
org.springframework.data.mongodb.MongoDatabaseUtils#getDatabase(org.springframework.data.mongodb.MongoDatabaseFactory, org.springframework.data.mongodb.SessionSynchronization)
=>调用抽象类MongoDatabaseFactorySupport 方法
org.springframework.data.mongodb.core.MongoDatabaseFactorySupport#getMongoDatabase(java.lang.String)
=>调用MongoDatabaseFactorySupport 具体实现类(自定义的MongoMultiTenantFactory.java)的doGetMongoDatabase 方法
com.example.demo.mongo.factory.MongoMultiTenantFactory#doGetMongoDatabase
=>从ThreadLocal DB上下文获取数据库名称
com.example.demo.mongo.context.MongoContextHolder#getDbName
=>省略中间一大部分调用代码
=>继续执行MongoContextFilter 剩余代码
com.example.demo.mongo.filter.MongoContextFilter#doFilterInternal
=>清理ThreadLocal DB上下文
com.example.demo.mongo.context.MongoContextHolder#reset
=>返回数据
说明:从上面调用可以看出Spring Data Mongo本质是调用MongoTemplate模板类中的方法,所有你也不用担心直接使用MongoTemplate会不会有问题。
|