SpringBoot集成MySQL - MyBatis-Plus基于字段隔离的多租户

170 阅读3分钟

什么是多租户?

多租户就是指一套软件系统可以为多个客户(租户)提供服务,这些租户之间相互独立,彼此不可见。例如:一个在线商城系统可以为多个店铺提供服务,每个店铺都有自己独立的商品、订单等数据。

多租户的实现方式

通常有以下两种实现方式:

  1. 数据库级别的隔离

    在同一台数据库中,为每个租户创建一个独立的数据库或者独立的表结构,各个租户之间相互隔离,彼此不可见。

  2. 字段级别的隔离

    在同一张表中,通过某个字段(通常是租户ID)对数据进行隔离。在查询数据时,只查询符合租户ID的数据,从而达到隔离的效果。

基于字段隔离的多租户实现方式

本文介绍如何使用Spring Boot集成MySQL和MyBatis-Plus,实现基于字段隔离的多租户功能。

1. 添加依赖

<!-- Spring Boot -->
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-web</artifactId>
</dependency>
<!-- MySQL -->
<dependency>
    <groupId>mysql</groupId>
    <artifactId>mysql-connector-java</artifactId>
</dependency>
<!-- MyBatis-Plus -->
<dependency>
    <groupId>com.baomidou</groupId>
    <artifactId>mybatis-plus-boot-starter</artifactId>
    <version>${mybatis-plus.version}</version>
</dependency>

2. 数据源配置


spring:
  datasource:
    url: jdbc:mysql://localhost:3306/multi_tenant?useSSL=false&serverTimezone=UTC&characterEncoding=utf8
    username: root
    password: root

3. 实体类定义

@Data
public class User {
    private Long id;
    private String name;
    private Integer age;
    private Long tenantId; // 租户ID
}

4. Mapper接口

public interface UserMapper extends BaseMapper<User> {
}

5. SQL拦截器

MyBatis-Plus提供了SQL拦截器的功能,可以在执行SQL之前、之后对SQL进行处理。我们可以使用SQL拦截器,在执行查询时动态加入租户ID的条件。

@Component
@Intercepts({
        @Signature(type = Executor.class, method = "query", args = {MappedStatement.class, Object.class, RowBounds.class, ResultHandler.class}),
})
public class TenantSqlInterceptor implements Interceptor {

    @Override
    public Object intercept(Invocation invocation) throws Throwable {
        MappedStatement statement = (MappedStatement) invocation.getArgs()[0];
        Object parameter = invocation.getArgs()[1];
        BoundSql boundSql = statement.getBoundSql(parameter);
        String originalSql = boundSql.getSql();
        Object parameterObject = boundSql.getParameterObject();

        // 获取租户ID
        Long tenantId = getTenantIdFromContext();

        // 如果是select语句,则加入租户ID的条件
        if (StatementType.SELECT.equals(statement.getStatementType())) {
            String sql = addTenantCondition(originalSql, tenantId);
            BoundSql newBoundSql = new BoundSql(statement.getConfiguration(), sql, boundSql.getParameterMappings(), parameterObject);
            MappedStatement newStatement = copyFromMappedStatement(statement, new BoundSqlSqlSource(newBoundSql));
            invocation.getArgs()[0] = newStatement;
        }

        return invocation.proceed();
    }

    /**
     * 从当前线程上下文中获取租户ID
     */
    private Long getTenantIdFromContext() {
        // 这里使用ThreadLocal来存储租户信息,具体实现可以根据业务场景进行调整
        return TenantContext.getTenantId();
    }

    /**
     * 在原始SQL语句的基础上加入租户ID的查询条件
     */
    private String addTenantCondition(String originalSql, Long tenantId) {
        if (tenantId == null || "".equals(tenantId)) {
            return originalSql;
        }
        StringBuilder sb = new StringBuilder(originalSql);
        int index = sb.indexOf("where");
        if (index < 0) {
            sb.append(" where tenant_id = ").append(tenantId);
        } else {
            sb.insert(index + 5, " tenant_id = " + tenantId + " and ");
        }
        return sb.toString();
    }

    /**
     * 复制MappedStatement对象并更新BoundSqlSqlSource
     */
    private MappedStatement copyFromMappedStatement(MappedStatement ms, SqlSource newSqlSource) {
        MappedStatement.Builder builder = new MappedStatement.Builder(ms.getConfiguration(), ms.getId(), newSqlSource, ms.getSqlCommandType());
        builder.resource(ms.getResource());
        builder.fetchSize(ms.getFetchSize());
        builder.statementType(ms.getStatementType());
        builder.keyGenerator(ms.getKeyGenerator());
        builder.timeout(ms.getTimeout());
        builder.parameterMap(ms.getParameterMap());
        builder.resultMaps(ms.getResultMaps());
        builder.cache(ms.getCache());
        builder.useCache(ms.isUseCache());
        return builder.build();
    }

    /**
     * 绑定SQL语句和参数映射的类,用于生成新的MappedStatement
     */
    public static class BoundSqlSqlSource implements SqlSource {

        private final BoundSql boundSql;

        public BoundSqlSqlSource(BoundSql boundSql) {
            this.boundSql = boundSql;
        }

        @Override
        public BoundSql getBoundSql(Object parameterObject) {
            return boundSql;
        }
    }
}

6. 配置SQL拦截器


@Configuration
public class MybatisPlusConfig {

    @Autowired
    private TenantSqlInterceptor tenantSqlInterceptor;

    @Bean
    public ConfigurationCustomizer configurationCustomizer() {
        return configuration -> {
            // 添加SQL拦截器
            configuration.addInterceptor(tenantSqlInterceptor);
        };
    }
}

7. 使用ThreadLocal来存储租户ID

为了在整个请求过程中都能够获取到当前的租户ID,我们可以使用ThreadLocal来存储租户信息。

public class TenantContext {

private static final ThreadLocal<Long> tenantIdHolder = new ThreadLocal<>();

public static Long getTenantId() {
    return tenantIdHolder.get();
}

public static void setTenantId(Long tenantId) {
    tenantIdHolder.set(tenantId);
}

public static void clear() {
    tenantIdHolder.remove();
}

}

在每次请求进来时,我们可以从请求头中获取租户ID,并将其存储到ThreadLocal中。

@Component public class TenantHeaderFilter implements Filter {

@Override
public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException {
    HttpServletRequest httpRequest = (HttpServletRequest) request;
    String tenantId = httpRequest.getHeader("X-Tenant-Id");
    if (tenantId != null) {
        try {
            TenantContext.setTenantId(Long.valueOf(tenantId));
        } catch (NumberFormatException e) {
            // 如果租户ID不是数字,则忽略该请求
        }
    }
    chain.doFilter(request, response);
    TenantContext.clear();
}

}

总结

本文介绍了如何使用Spring Boot集成MySQL和MyBatis-Plus,实现基于字段隔离的多租户功能。通过SQL拦截器和ThreadLocal的配合使用,我们可以轻松地对数据进行隔离,为多租户系统提供支持。