从0到1:RuoYi-Vue-Pro多租户架构设计与实战指南

从0到1:RuoYi-Vue-Pro多租户架构设计与实战指南

【免费下载链接】ruoyi-vue-pro 🔥 官方推荐 🔥 RuoYi-Vue 全新 Pro 版本,优化重构所有功能。基于 Spring Boot + MyBatis Plus + Vue & Element 实现的后台管理系统 + 微信小程序,支持 RBAC 动态权限、数据权限、SaaS 多租户、Flowable 工作流、三方登录、支付、短信、商城、CRM、ERP、AI 等功能。你的 ⭐️ Star ⭐️,是作者生发的动力! 【免费下载链接】ruoyi-vue-pro 项目地址: https://gitcode.com/yudaocode/ruoyi-vue-pro

引言:多租户架构的痛点与解决方案

在企业级应用开发中,如何高效管理多个租户的数据隔离与资源共享是一个核心挑战。传统的独立部署模式不仅运维成本高昂,而且难以快速响应租户的定制化需求。RuoYi-Vue-Pro作为一款成熟的开源后台管理系统,其内置的SaaS(Software as a Service,软件即服务)多租户功能为这一问题提供了优雅的解决方案。

本文将深入剖析RuoYi-Vue-Pro的多租户架构设计,从核心原理到实际应用,帮助开发者全面掌握多租户系统的实现方法。通过阅读本文,您将能够:

  • 理解多租户架构的三种实现模式及其优缺点
  • 掌握RuoYi-Vue-Pro中多租户功能的核心组件与工作流程
  • 学会如何配置和扩展多租户系统以满足不同业务需求
  • 解决多租户环境下常见的技术难题,如数据隔离、权限控制等

多租户架构概述

多租户模式对比

多租户架构主要有三种实现模式,每种模式都有其适用场景和优缺点:

模式实现方式优点缺点适用场景
独立数据库为每个租户提供独立的数据库实例隔离级别最高,数据安全有保障,可定制性强硬件成本高,维护复杂,扩展性差对数据隔离要求极高的金融、医疗等行业
共享数据库,独立Schema多个租户共享一个数据库实例,但每个租户拥有独立的Schema隔离级别中等,维护成本较低,扩展性较好数据库资源竞争可能影响性能中小型企业应用,租户数量适中
共享数据库,共享Schema所有租户共享同一个数据库和Schema,通过租户ID区分数据硬件成本最低,维护简单,扩展性最好隔离级别最低,数据安全依赖于应用层控制租户数量多、定制化需求少的SaaS应用

RuoYi-Vue-Pro采用的是共享数据库,共享Schema的模式,通过在数据表中添加租户ID字段实现数据隔离。这种设计在资源利用率和扩展性之间取得了很好的平衡,非常适合中小型SaaS应用。

RuoYi-Vue-Pro多租户核心特性

RuoYi-Vue-Pro的多租户功能具有以下核心特性:

  • 基于MyBatis-Plus的TenantLineInnerInterceptor实现SQL自动拦截与租户ID注入
  • 灵活的租户上下文管理,支持通过请求头、Cookie等方式传递租户信息
  • 细粒度的忽略规则配置,可针对URL、表名、缓存等设置租户忽略策略
  • 完善的租户权限控制,防止租户间数据越权访问
  • 多租户与缓存、消息队列等中间件的无缝集成

RuoYi-Vue-Pro多租户核心实现

整体架构设计

RuoYi-Vue-Pro的多租户功能主要通过以下几个核心组件实现:

mermaid

核心组件解析

1. 租户上下文管理:TenantContextHolder

TenantContextHolder是整个多租户系统的核心,负责在当前线程中存储和传递租户信息。其主要代码实现如下:

public class TenantContextHolder {
    /**
     * 当前租户编号
     */
    private static final ThreadLocal<Long> TENANT_ID = new TransmittableThreadLocal<>();
    
    /**
     * 是否忽略租户
     */
    private static final ThreadLocal<Boolean> IGNORE = new TransmittableThreadLocal<>();
    
    // 获取租户ID
    public static Long getTenantId() {
        return TENANT_ID.get();
    }
    
    // 设置租户ID
    public static void setTenantId(Long tenantId) {
        TENANT_ID.set(tenantId);
    }
    
    // 设置是否忽略租户
    public static void setIgnore(Boolean ignore) {
        IGNORE.set(ignore);
    }
    
    // 判断是否忽略租户
    public static boolean isIgnore() {
        return Boolean.TRUE.equals(IGNORE.get());
    }
    
    // 清除上下文信息
    public static void clear() {
        TENANT_ID.remove();
        IGNORE.remove();
    }
}

这里使用了TransmittableThreadLocal而非普通的ThreadLocal,是为了解决线程池环境下租户信息传递的问题。TransmittableThreadLocal能够在使用线程池等池化技术时,自动传递ThreadLocal中的值,确保租户信息在异步操作中不会丢失。

2. 数据层拦截:TenantDatabaseInterceptor

TenantDatabaseInterceptor实现了MyBatis-Plus的TenantLineHandler接口,负责在SQL执行过程中自动注入租户ID条件。其核心代码如下:

public class TenantDatabaseInterceptor implements TenantLineHandler {
    /**
     * 忽略的表
     */
    private final Map<String, Boolean> ignoreTables = new HashMap<>();
    
    @Override
    public Expression getTenantId() {
        return new LongValue(TenantContextHolder.getRequiredTenantId());
    }
    
    @Override
    public boolean ignoreTable(String tableName) {
        // 情况一:全局忽略多租户
        if (TenantContextHolder.isIgnore()) {
            return true;
        }
        // 情况二:忽略多租户的表
        tableName = SqlParserUtils.removeWrapperSymbol(tableName);
        Boolean ignore = ignoreTables.get(tableName.toLowerCase());
        if (ignore == null) {
            ignore = computeIgnoreTable(tableName);
            synchronized (ignoreTables) {
                addIgnoreTable(tableName, ignore);
            }
        }
        return ignore;
    }
    
    private boolean computeIgnoreTable(String tableName) {
        // 找不到的表,说明不是项目里的,不进行拦截(忽略租户)
        TableInfo tableInfo = TableInfoHelper.getTableInfo(tableName);
        if (tableInfo == null) {
            return true;
        }
        // 如果继承了 TenantBaseDO 基类,显然不忽略租户
        if (TenantBaseDO.class.isAssignableFrom(tableInfo.getEntityType())) {
            return false;
        }
        // 如果添加了 @TenantIgnore 注解,则忽略租户
        TenantIgnore tenantIgnore = tableInfo.getEntityType().getAnnotation(TenantIgnore.class);
        return tenantIgnore != null;
    }
}

该拦截器的主要工作流程是:

  1. 获取当前线程中的租户ID
  2. 判断当前表是否需要忽略租户过滤
  3. 如果不需要忽略,则自动在SQL的WHERE子句中添加租户ID条件
3. 自动配置:YudaoTenantAutoConfiguration

YudaoTenantAutoConfiguration是多租户功能的自动配置类,负责注册相关的Bean:

@AutoConfiguration
@ConditionalOnProperty(prefix = "yudao.tenant", value = "enable", matchIfMissing = true)
@EnableConfigurationProperties(TenantProperties.class)
public class YudaoTenantAutoConfiguration {
    
    // 注册租户服务
    @Bean
    public TenantFrameworkService tenantFrameworkService(TenantCommonApi tenantApi) {
        return new TenantFrameworkServiceImpl(tenantApi);
    }
    
    // 注册租户忽略AOP切面
    @Bean
    public TenantIgnoreAspect tenantIgnoreAspect() {
        return new TenantIgnoreAspect();
    }
    
    // 注册MyBatis-Plus租户拦截器
    @Bean
    public TenantLineInnerInterceptor tenantLineInnerInterceptor(TenantProperties properties,
                                                                 MybatisPlusInterceptor interceptor) {
        TenantLineInnerInterceptor inner = new TenantLineInnerInterceptor(new TenantDatabaseInterceptor(properties));
        // 添加到interceptor中,需要加在首个,主要是为了在分页插件前面
        MyBatisUtils.addInterceptor(interceptor, inner, 0);
        return inner;
    }
    
    // 注册租户上下文Web过滤器
    @Bean
    public FilterRegistrationBean<TenantContextWebFilter> tenantContextWebFilter() {
        FilterRegistrationBean<TenantContextWebFilter> registrationBean = new FilterRegistrationBean<>();
        registrationBean.setFilter(new TenantContextWebFilter());
        registrationBean.setOrder(WebFilterOrderEnum.TENANT_CONTEXT_FILTER);
        return registrationBean;
    }
    
    // 注册租户安全过滤器
    @Bean
    public FilterRegistrationBean<TenantSecurityWebFilter> tenantSecurityWebFilter(TenantProperties tenantProperties,
                                                                                   WebProperties webProperties,
                                                                                   GlobalExceptionHandler globalExceptionHandler,
                                                                                   TenantFrameworkService tenantFrameworkService) {
        FilterRegistrationBean<TenantSecurityWebFilter> registrationBean = new FilterRegistrationBean<>();
        registrationBean.setFilter(new TenantSecurityWebFilter(webProperties, tenantProperties, getTenantIgnoreUrls(),
                globalExceptionHandler, tenantFrameworkService));
        registrationBean.setOrder(WebFilterOrderEnum.TENANT_SECURITY_FILTER);
        return registrationBean;
    }
    
    // 其他Bean注册...
}

通过@ConditionalOnProperty注解,我们可以通过配置文件灵活地开启或关闭多租户功能。默认情况下,多租户功能是开启的。

4. 安全控制:TenantSecurityWebFilter

TenantSecurityWebFilter负责在Web请求处理过程中进行租户相关的安全校验:

public class TenantSecurityWebFilter extends ApiRequestFilter {
    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain)
            throws ServletException, IOException {
        Long tenantId = TenantContextHolder.getTenantId();
        // 1. 登录用户,校验是否有权限访问该租户,避免越权问题
        LoginUser user = SecurityFrameworkUtils.getLoginUser();
        if (user != null) {
            // 如果获取不到租户编号,则尝试使用登录用户的租户编号
            if (tenantId == null) {
                tenantId = user.getTenantId();
                TenantContextHolder.setTenantId(tenantId);
            // 如果传递了租户编号,则进行比对,避免越权
            } else if (!Objects.equals(user.getTenantId(), TenantContextHolder.getTenantId())) {
                log.error("[doFilterInternal][租户({}) User({}/{}) 越权访问租户({}) URL({}/{})]",
                        user.getTenantId(), user.getId(), user.getUserType(),
                        TenantContextHolder.getTenantId(), request.getRequestURI(), request.getMethod());
                ServletUtils.writeJSON(response, CommonResult.error(GlobalErrorCodeConstants.FORBIDDEN.getCode(),
                        "您无权访问该租户的数据"));
                return;
            }
        }
        
        // 如果非允许忽略租户的URL,则校验租户是否合法
        if (!isIgnoreUrl(request)) {
            // 2. 如果请求未带租户的编号,不允许访问
            if (tenantId == null) {
                log.error("[doFilterInternal][URL({}/{}) 未传递租户编号]", request.getRequestURI(), request.getMethod());
                ServletUtils.writeJSON(response, CommonResult.error(GlobalErrorCodeConstants.BAD_REQUEST.getCode(),
                        "请求的租户标识未传递,请进行排查"));
                return;
            }
            // 3. 校验租户是否合法,例如是否被禁用、是否到期
            try {
                tenantFrameworkService.validTenant(tenantId);
            } catch (Throwable ex) {
                CommonResult<?> result = globalExceptionHandler.allExceptionHandler(request, ex);
                ServletUtils.writeJSON(response, result);
                return;
            }
        } else { 
            // 如果是允许忽略租户的URL,若未传递租户编号,则默认忽略租户编号
            if (tenantId == null) {
                TenantContextHolder.setIgnore(true);
            }
        }
        
        // 继续过滤
        chain.doFilter(request, response);
    }
}

该过滤器的主要职责包括:

  1. 处理租户ID的传递与验证,防止租户间的越权访问
  2. 校验租户状态,确保只有合法租户能够访问系统
  3. 实现URL级别的租户过滤忽略规则
5. 配置属性:TenantProperties

TenantProperties类定义了多租户功能的可配置属性:

@ConfigurationProperties(prefix = "yudao.tenant")
@Data
public class TenantProperties {
    /**
     * 租户是否开启
     */
    private static final Boolean ENABLE_DEFAULT = true;
    
    /**
     * 是否开启
     */
    private Boolean enable = ENABLE_DEFAULT;
    
    /**
     * 需要忽略多租户的请求
     */
    private Set<String> ignoreUrls = new HashSet<>();
    
    /**
     * 需要忽略跨租户访问的请求
     */
    private Set<String> ignoreVisitUrls = Collections.emptySet();
    
    /**
     * 需要忽略多租户的表
     */
    private Set<String> ignoreTables = Collections.emptySet();
    
    /**
     * 需要忽略多租户的Spring Cache缓存
     */
    private Set<String> ignoreCaches = Collections.emptySet();
}

通过这些配置,我们可以灵活地控制多租户功能的开关,以及设置需要忽略租户过滤的URL、表名和缓存等。

多租户功能实战

配置多租户属性

在application.yml中添加如下配置,即可开启并定制多租户功能:

yudao:
  tenant:
    enable: true  # 是否开启多租户功能,默认为true
    ignore-urls:  # 需要忽略多租户的URL
      - /api/auth/login
      - /api/auth/logout
      - /api/captcha/image
    ignore-tables:  # 需要忽略多租户的表
      - sys_config
      - sys_dict_data
    ignore-caches:  # 需要忽略多租户的缓存
      - menu
      - dict

实体类集成多租户

要让实体类支持多租户,只需让其继承TenantBaseDO类即可:

@Data
@TableName("sys_user")
public class SysUser extends TenantBaseDO {
    // 字段定义...
}

// TenantBaseDO类定义
public abstract class TenantBaseDO extends BaseDO {
    /**
     * 租户ID
     */
    @TableField(value = "tenant_id", fill = FieldFill.INSERT)
    private Long tenantId;
    
    // getter和setter...
}

TenantBaseDO类中定义了tenantId字段,并使用MyBatis-Plus的@TableField注解指定了自动填充策略。这样,在插入数据时,租户ID会自动填充到tenantId字段中。

忽略多租户过滤

在某些场景下,我们可能需要忽略多租户过滤,例如查询系统级别的公共数据。RuoYi-Vue-Pro提供了多种忽略方式:

1. 注解方式

在Mapper接口方法上添加@TenantIgnore注解:

public interface SysDictDataMapper extends BaseMapper<SysDictData> {
    
    @TenantIgnore  // 忽略该方法的多租户过滤
    List<SysDictData> selectByDictType(String dictType);
}

在Service类或方法上添加@TenantIgnore注解:

@Service
@TenantIgnore  // 忽略该Service所有方法的多租户过滤
public class SysDictDataServiceImpl implements SysDictDataService {
    // 实现...
}

// 或

@Service
public class SysDictDataServiceImpl implements SysDictDataService {
    
    @TenantIgnore  // 忽略该方法的多租户过滤
    @Override
    public List<SysDictData> getByDictType(String dictType) {
        // 实现...
    }
}

在Controller类或方法上添加@TenantIgnore注解:

@RestController
@RequestMapping("/api/dict")
@TenantIgnore  // 忽略该Controller所有接口的多租户过滤
public class SysDictController {
    // 接口定义...
}

// 或

@RestController
@RequestMapping("/api/dict")
public class SysDictController {
    
    @TenantIgnore  // 忽略该接口的多租户过滤
    @GetMapping("/type/{dictType}")
    public CommonResult<List<SysDictDataRespVO>> getDictDataList(@PathVariable String dictType) {
        // 实现...
    }
}

在实体类上添加@TenantIgnore注解,忽略整个实体类的多租户过滤:

@Data
@TableName("sys_dict_data")
@TenantIgnore  // 忽略该实体类的多租户过滤
public class SysDictData extends BaseDO {
    // 字段定义...
}
2. 编程方式

通过TenantContextHolder手动设置忽略多租户:

TenantContextHolder.setIgnore(true);
try {
    // 执行不需要多租户过滤的操作
    return sysDictDataMapper.selectByDictType(dictType);
} finally {
    // 恢复默认设置
    TenantContextHolder.setIgnore(false);
}

多租户缓存处理

在多租户环境下,缓存的处理需要特别注意,避免不同租户的数据相互干扰。RuoYi-Vue-Pro提供了TenantRedisCacheManager来处理多租户缓存:

@Bean
@Primary  // 引入租户时,tenantRedisCacheManager为主Bean
public RedisCacheManager tenantRedisCacheManager(RedisTemplate<String, Object> redisTemplate,
                                                 RedisCacheConfiguration redisCacheConfiguration,
                                                 YudaoCacheProperties yudaoCacheProperties,
                                                 TenantProperties tenantProperties) {
    // 创建RedisCacheWriter对象
    RedisConnectionFactory connectionFactory = Objects.requireNonNull(redisTemplate.getConnectionFactory());
    RedisCacheWriter cacheWriter = RedisCacheWriter.nonLockingRedisCacheWriter(connectionFactory,
            BatchStrategies.scan(yudaoCacheProperties.getRedisScanBatchSize()));
    // 创建TenantRedisCacheManager对象
    return new TenantRedisCacheManager(cacheWriter, redisCacheConfiguration, tenantProperties.getIgnoreCaches());
}

TenantRedisCacheManager会自动在缓存Key中添加租户ID前缀,确保不同租户的缓存数据相互隔离。例如,租户1和租户2的用户缓存Key会分别变成"tenant:1:user:100"和"tenant:2:user:100"。

多租户权限控制

RuoYi-Vue-Pro提供了完善的多租户权限控制机制,通过TenantSecurityWebFilter实现:

// 登录用户的租户权限校验
LoginUser user = SecurityFrameworkUtils.getLoginUser();
if (user != null) {
    // 如果获取不到租户编号,则尝试使用登录用户的租户编号
    if (tenantId == null) {
        tenantId = user.getTenantId();
        TenantContextHolder.setTenantId(tenantId);
    // 如果传递了租户编号,则进行比对,避免越权
    } else if (!Objects.equals(user.getTenantId(), TenantContextHolder.getTenantId())) {
        log.error("[doFilterInternal][租户({}) User({}/{}) 越权访问租户({}) URL({}/{})]",
                user.getTenantId(), user.getId(), user.getUserType(),
                TenantContextHolder.getTenantId(), request.getRequestURI(), request.getMethod());
        ServletUtils.writeJSON(response, CommonResult.error(GlobalErrorCodeConstants.FORBIDDEN.getCode(),
                "您无权访问该租户的数据"));
        return;
    }
}

这段代码确保了用户只能访问自己租户的数据,防止了租户间的越权访问。

多租户高级特性

动态数据源路由

在一些复杂场景下,可能需要为不同的租户配置不同的数据源。RuoYi-Vue-Pro支持基于租户的动态数据源路由:

public class TenantDynamicDataSource extends AbstractRoutingDataSource {
    @Override
    protected Object determineCurrentLookupKey() {
        return TenantContextHolder.getTenantId();
    }
}

通过实现AbstractRoutingDataSource类,并重写determineCurrentLookupKey方法,我们可以根据当前租户ID动态选择数据源。

租户定制化配置

RuoYi-Vue-Pro支持为不同租户提供定制化的配置:

@Service
public class TenantConfigServiceImpl implements TenantConfigService {
    @Autowired
    private SysConfigMapper sysConfigMapper;
    
    @Override
    public String getConfigValue(String configKey) {
        Long tenantId = TenantContextHolder.getTenantId();
        // 先查询租户自定义配置
        SysConfig config = sysConfigMapper.selectByTenantIdAndKey(tenantId, configKey);
        if (config != null) {
            return config.getConfigValue();
        }
        // 如果租户没有自定义配置,则查询系统默认配置
        return sysConfigMapper.selectByKey(configKey);
    }
}

通过这种方式,每个租户可以拥有自己的定制化配置,同时继承系统的默认配置。

结语

RuoYi-Vue-Pro的多租户架构设计充分考虑了企业级应用的实际需求,通过灵活的配置和完善的功能,为SaaS应用开发提供了强有力的支持。本文从核心原理到实际应用,详细介绍了RuoYi-Vue-Pro多租户功能的实现细节和使用方法。

多租户架构是SaaS应用的核心技术之一,掌握它不仅能够帮助我们更好地理解RuoYi-Vue-Pro的设计思想,也能为我们构建自己的SaaS应用提供宝贵的参考。希望本文能够对您有所帮助,欢迎在评论区交流讨论。

最后,如果您觉得本文对您有帮助,请不要吝啬您的Star,您的支持是我们持续开源的动力!

【免费下载链接】ruoyi-vue-pro 🔥 官方推荐 🔥 RuoYi-Vue 全新 Pro 版本,优化重构所有功能。基于 Spring Boot + MyBatis Plus + Vue & Element 实现的后台管理系统 + 微信小程序,支持 RBAC 动态权限、数据权限、SaaS 多租户、Flowable 工作流、三方登录、支付、短信、商城、CRM、ERP、AI 等功能。你的 ⭐️ Star ⭐️,是作者生发的动力! 【免费下载链接】ruoyi-vue-pro 项目地址: https://gitcode.com/yudaocode/ruoyi-vue-pro

创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值