一.什么是SAAS系统
SAAS全称 Software as a Service,软件即服务。本人接触SAAS也在近两年;在我的理解,SAAS不是特指某种系统,它是提供某类产品的系统服务平台,让第三方公司可以直接在平台上租用一个相对独立的系统在线使用,比如OA,ERP等各类管理系统。SAAS概念出来之前,公司想要一个管理系统,只能自己招团队开发,或者请外包公司做一个,但是由于中小型企业本身开发团队能力限制,而且购买系统成本又太高,加上本身的功能需求并不高,所以这种形式就导致厂商很难抉择;
SAAS的出现就是解决这一痛点,做一套基本符合中小型企业需求的平台,让企业通过租用系统的方式来使用系统;SAAS厂商通过数据隔离的方式,让每个租用系统的企业都只能看到自己的数据;这样厂商不需要开发和维护团队,也不需要服务器等硬件资源,直接通过在线的方式就可以使用系统;
SAAS系统的优点是“开箱即用”,因为SAAS厂商做的系统符合大部分企业的需求,只需要租用开通账号即可马上使用;SAAS系统的缺点则是定制化服务比较困难,所以SAAS系统的使用者一般是中小型企业;
二. 租户之间数据隔离方式
SAAS系统直接数据隔离主要分为三种
隔离方式 | 优点 | 缺点 |
单数据库 | 成本最低 | 基于代码层面隔离,不适合租户数量多的厂商,且定制服务开发难度大 |
租户独立数据库 | 租户数据隔离彻底,定制服务相对方便 | 部署成本略高 |
租户独立数据库+独立应用 | 隔离程度最高,适合定制服务开发 | 部署成本高,维护成本高 |
三. 代码实现
本人之前做的SAAS是属于单数据类型,公司考虑成本问题,但是开发起来难度比其他两种大;这次我将使用独立数据库的方式实现多租户功能
系统库主要存储系统开租户信息和人员信息,负责登入,授权,付费,权限生命周期管理业务应用配置由系统库基础配置+租户库定制配置组成租户对应用的定制。
租户在进入应用时就全局缓存数据源标签对应的租户库基础信息都会在系统和租户库同步保证业务所有数据来自于同意数据源针对SAAS部署和单租户部署区别,单租户部署的话只需把系统库当成业务库启动即可。
3.1 数据库设计
数据库分为系统库和租户数据库;
平台系统库,主要用于保存租户的信息,包含用户,租户的数据库连接信息等,同时平台的管理后台相关数据也将放在此库中;
---------------------------------------------------------------------------------------------------------------------
其他三张表则是租户对应的数据库,便于测试我创建了三张表,表结构都是一致的
---------------------------------------------------------------------------------------------------------------------
在系统库中配置如下(主要测试可行性,密码等隐私字段没有做加密处理)
3.2 项目目录结构
项目采用架构 springboot+mybaitsplus+mysql+springsecurity+jwt,数据库连接池使用alibaba的druid。
├── com.lwj.demo ├── common -- 工具类、枚举、常量等 ├── config -- 配置相关 ├── exception -- 自定义异常和全局异常处理 ├── security -- springsecurity相关,用于处理登录和接口权限校验
├── starter -- spring容器初始化完成事件,用于初始化租户的数据源 ├── tenant -- 租户相关接口业务 ├── system -- 系统级别相关接口业务 ├── config -- 实现多数据源的核心代码
3.3 动态数据源如何实现
我们都知道,java提供一个DataSource接口去定义获取数据库连接的标准。
spring的org.springframework.jdbc.datasource中的AbstractDataSource抽象类就实现的该接口。我们用springboot时,默认不需要关注数据库源(DataSource),因为springboot有默认的数据源配置。
如果我们需要自定义数据源时,将我们自己定义数据源对象放入SqlSessionFactory中即可。
spring提供一个AbstractRoutingDataSource的抽象类让我们可以去选择不同的数据源,我们实现多数据源的时候就可以使用该抽象类类;通过源码我们不难看出,该抽象类有两个map保存我们设置的所有DataSource对象,其中 targetDataSources 是我们初始化的时候设置的所有数据源对象集合;而resolvedDataSources是解析处理之后的数据源集合。resolvedDefaultDataSource则是默认的数据源,在没有找到其他数据源时使用默认数据源;
AbstractRoutingDataSource实现了InitializingBean接口,在spring容器创建bean对象时,会调用接口的afterPropertiesSet方法。这个方法默认情况下将在所有的属性被初始化后调用,在init前调用。
所以我们可以看到AbstractRoutingDataSource在afterPropertiesSet方法中就是对targetDataSource进行处理,把里面的key和value它想要的值。
determineTargetDataSource方法则是实现切换数据源的逻辑代码,在获取数据库连接时,该方法会被调用(具体可以看getConnection方法);
可以看到,就是拿当前的key去resolvedDataSources中获取对应的数据源。其中lookupKey就是通过下面的抽象方法实现的,所以要想实现动态数据源,就得继承该抽象类,然后重写determineCurrentLookupKey就行了;当key找不到对应的数据源,并且lenientFallback是ture(是否对默认数据源应用宽限回退)时数据源等于默认数据源;简单理解就是,如果找不到对应的数据源是否使用默认的数据源;
不过发现AbstractRoutingDataSource虽然可以实现动态数据源的切换,但是resolvedDataSources只会再初始化时加载一次。所以我们如果想在运行的时候加入新的数据源,需要重新设置数据源,再调用afterPropertiesSet方法。
如果数据源多的话,毕竟影响系统运行;所以我直接对AbstractRoutingDataSource源码进行修改,继承AbstractDataSource类;方便直接添加数据源,不用重新加载一边;
3.4 加入到系统中
主要逻辑为,使用TOKEN保存用户登录的信息(或者把登录信息存入redis,和token对应起来),使用AOP拦截请求,解析token之后存入ThreadLocal(线程私有内存)中,自定义的AbstractDataSource对象获取租户id对应的DataSource对象,拿到相应的数据库连接,然后处理对应的业务。流程图如下所示:
具体代码:
首先写连接池配置,因为我们是动态的数据源,所以我这里只写一份,然后其他连接池直接复用配置信息即可
对应的配置类bean,通过该基本配置类去生成连接池对象;
@Data @Slf4j @ConfigurationProperties(prefix = "spring.datasource.druid") public class DynamicDatabaseProperties { private String driverClassName; private String url; private String username; private String password; private int initialSize; private int maxActive; private int maxWait; private int minIdle; private boolean poolPreparedStatements; private int maxPoolPreparedStatementPerConnectionSize; private String validationQuery; private boolean testOnBorrow; private boolean testOnReturn; private boolean testWhileIdle; private int timeBetweenEvictionRunsMillis; private String filters; private int minEvictableIdleTimeMillis; /** * 获取租户的数据源默认配置 * * @param tenantDataInfo 租户信息 * @param isSystem 是否是系统数据源 * @return com.alibaba.druid.pool.DruidDataSource */ public DruidDataSource getBaseDataSource(TenantData tenantDataInfo, boolean isSystem) { DruidDataSource dds = new DruidDataSource(); if (isSystem) { dds.setUrl(url); dds.setUsername(username); dds.setPassword(password); }else { dds.setUrl(tenantDataInfo.getUrl()); dds.setUsername(tenantDataInfo.getUsername()); dds.setPassword(tenantDataInfo.getPassword()); } dds.setDriverClassName(driverClassName); dds.setInitialSize(initialSize); dds.setMaxActive(maxActive); dds.setMaxWait(maxWait); dds.setMinIdle(minIdle); dds.setPoolPreparedStatements(poolPreparedStatements); dds.setMaxPoolPreparedStatementPerConnectionSize(maxPoolPreparedStatementPerConnectionSize); dds.setValidationQuery(validationQuery); dds.setTestOnBorrow(testOnBorrow); dds.setTestOnReturn(testOnReturn); dds.setTestWhileIdle(testWhileIdle); dds.setTimeBetweenEvictionRunsMillis(timeBetweenEvictionRunsMillis); try { dds.setFilters(filters); } catch (SQLException e) { log.error(e.getMessage(), e); } dds.setMinEvictableIdleTimeMillis(minEvictableIdleTimeMillis); return dds; } /** * 用户项目启动时候初始化所有数据源 * * @param tenantDataInfoList 所有租户信息 * @param resolvedDataSources 数据源对象Map集合 * @return java.util.Map<java.lang.Object, java.lang.Object> */ public Map<Object, Object> initDataSource(List<TenantData> tenantDataInfoList, Map<Object, DataSource> resolvedDataSources) { Map<Object, Object> targetDataSources = new HashMap<>(resolvedDataSources); for (TenantData tenantDataInfo : tenantDataInfoList) { targetDataSources.put(tenantDataInfo.getTenantId(), getBaseDataSource(tenantDataInfo, false)); } return targetDataSources; } }
配置加载自定义数据源
@Configuration @EnableConfigurationProperties(value = DynamicDatabaseProperties.class) public class DynamicDatabaseConfiguration { private final DynamicDatabaseProperties dynamicDatabaseProperties; public DynamicDatabaseConfiguration(DynamicDatabaseProperties dynamicDatabaseProperties) { this.dynamicDatabaseProperties = dynamicDatabaseProperties; } /** * 设置数据源,默认一个系统数据源 * * @return javax.sql.DataSource */ @Bean @Primary public DataSource multipleDataSource() { DynamicDataSource dynamicDataSource = new DynamicDataSource(); Map<Object, Object> targetDataSources = CollectionUtils.newHashMap(1); targetDataSources.put(SystemConstant.ROOT_PARENT_ID, systemDataSource()); dynamicDataSource.setTargetDataSources(targetDataSources); dynamicDataSource.setDefaultTargetDataSource(systemDataSource()); return dynamicDataSource; } /** * 系统数据源,也是默认数据源。连接的是系统数据库,系统数据源必须先加载,才能获取租户的连接信息 * * @return javax.sql.DataSource */ @Bean @Primary public DataSource systemDataSource() { return dynamicDatabaseProperties.getBaseDataSource(null, true); } /** * mybatis的会话工厂,这改成使用自定义动态数据源,覆盖默认的的SqlSessionFactory * * @return org.apache.ibatis.session.SqlSessionFactory */ @Bean("sqlSessionFactory") public SqlSessionFactory sqlSessionFactory() throws Exception { MybatisSqlSessionFactoryBean sqlSessionFactory = new MybatisSqlSessionFactoryBean(); sqlSessionFactory.setDataSource(multipleDataSource()); MybatisConfiguration configuration = new MybatisConfiguration(); configuration.setJdbcTypeForNull(JdbcType.NULL); configuration.setMapUnderscoreToCamelCase(true); configuration.setCacheEnabled(false); sqlSessionFactory.setConfiguration(configuration); return sqlSessionFactory.getObject(); } @Bean(name = "multipleTransactionManager") @Primary public DataSourceTransactionManager multipleTransactionManager(@Qualifier("multipleDataSource") DataSource dataSource) { return new DataSourceTransactionManager(dataSource); } }
自定义的动态数据源类关键代码
public class DynamicDataSource extends AbstractDataSource implements InitializingBean { @Nullable private Map<Object, Object> targetDataSources; @Nullable private Object defaultTargetDataSource; private boolean lenientFallback = true; private DataSourceLookup dataSourceLookup = new JndiDataSourceLookup(); @Nullable private Map<Object, DataSource> resolvedDataSources; @Nullable private DataSource resolvedDefaultDataSource;/** * 直接加入数据源 * * @param key 数据源的key * @param value 数据源 */ public synchronized void addDataSources(@NonNull Object key, @NonNull DataSource value) { assert this.resolvedDataSources != null; DataSource dataSource = this.resolveSpecifiedDataSource(value); this.resolvedDataSources.put(key, dataSource); } @Override public void afterPropertiesSet() { if (this.targetDataSources == null) { throw new IllegalArgumentException("Property 'targetDataSources' is required"); } else { this.resolvedDataSources = CollectionUtils.newHashMap(this.targetDataSources.size()); this.targetDataSources.forEach((key, value) -> { Object lookupKey = this.resolveSpecifiedLookupKey(key); DataSource dataSource = this.resolveSpecifiedDataSource(value); this.resolvedDataSources.put(lookupKey, dataSource); }); if (this.defaultTargetDataSource != null) { this.resolvedDefaultDataSource = this.resolveSpecifiedDataSource(this.defaultTargetDataSource); } } }protected DataSource resolveSpecifiedDataSource(Object dataSource) throws IllegalArgumentException { if (dataSource instanceof DataSource) { return (DataSource) dataSource; } else if (dataSource instanceof String) { return this.dataSourceLookup.getDataSource((String) dataSource); } else { throw new IllegalArgumentException("Illegal data source value - only [javax.sql.DataSource] and String supported: " + dataSource); } } @Override public Connection getConnection() throws SQLException { return this.determineTargetDataSource().getConnection(); } @Override public Connection getConnection(String username, String password) throws SQLException { return this.determineTargetDataSource().getConnection(username, password); }protected DataSource determineTargetDataSource() { Assert.notNull(this.resolvedDataSources, "DataSource router not initialized"); Object lookupKey = this.determineCurrentLookupKey(); DataSource dataSource = this.resolvedDataSources.get(lookupKey); if (dataSource == null && (this.lenientFallback || lookupKey == null)) { dataSource = this.resolvedDefaultDataSource; } // try { // DruidDataSource druidDataSource = (DruidDataSource) dataSource; // String url = druidDataSource.getConnection().getMetaData().getURL(); // System.out.println("连接地址:" + url); // } catch (SQLException throwables) { // throwables.printStackTrace(); // } if (dataSource == null) { throw new IllegalStateException("Cannot determine target DataSource for lookup key [" + lookupKey + "]"); } else { return dataSource; } } protected Object determineCurrentLookupKey() { // 返回要使用的数据源的key LoginInfo tenant = LoginInfoHolder.getTenant(); if (tenant == null) { tenant = new LoginInfo(); } return tenant.getTenantId(); } }
存放当前请求的用户信息对象,保存在ThreadLocal中
public class LoginInfoHolder { private static final ThreadLocal<LoginInfo> CONTEXT = new ThreadLocal<>(); public static void setTenant(LoginInfo loginInfo) { CONTEXT.set(loginInfo); } public static LoginInfo getTenant() { return CONTEXT.get(); } public static void clear() { CONTEXT.remove(); } }
AOP的切面拦截,拦截所有请求,解析token,存入LoginInfoHolder 对象中。这里还加入一个自定义的注解,实现直接对类或者单个接口设置使用的数据源;
@Slf4j @Aspect @Order(1) @Component public class DataSourceAspect { @Pointcut("execution (* com.lwj.demo..controller.*.*(..))") public void dataPointCut() { } @Before("dataPointCut()") public void before(JoinPoint joinPoint) { //获取请求对象 ServletRequestAttributes requestAttributes = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes(); if (requestAttributes == null) { return; } HttpServletRequest request = requestAttributes.getRequest(); // 从token中解析用户信息 String token = request.getHeader(JwtUtil.TOKEN_HEADER); LoginInfo loginInfo = LoginInfo.getLoginInfoByToken(token); if (loginInfo == null) { loginInfo = new LoginInfo(); } MethodSignature signature = (MethodSignature) joinPoint.getSignature(); Method method = signature.getMethod(); MyDataSource myDataSource = null; //优先判断方法上的注解 if (method.isAnnotationPresent(MyDataSource.class)) { myDataSource = method.getAnnotation(MyDataSource.class); } else if (method.getDeclaringClass().isAnnotationPresent(MyDataSource.class)) { //其次判断类上的注解 myDataSource = method.getDeclaringClass().getAnnotation(MyDataSource.class); } if (myDataSource != null) { DataSourceType dataSourceType = myDataSource.type(); log.info("this is datasource: " + dataSourceType); if (dataSourceType.equals(DataSourceType.TENANT)) { loginInfo.setTenantId(myDataSource.value()); } } LoginInfoHolder.setTenant(loginInfo); } @After("dataPointCut()") public void after(JoinPoint joinPoint) { LoginInfoHolder.clear(); } }
最后定义ContextRefreshedEvent 事件,会在Spring容器初始化完成会触发该事件,对所有租户进行数据源初始化
@Component @Slf4j public class InitDataSourceConfiguration implements ApplicationListener<ContextRefreshedEvent> { private final TenantDataInfoService tenantDataInfoService; private final DynamicDatabaseProperties dynamicDatabaseProperties; @Qualifier("multipleDataSource") @Autowired private DataSource dataSource; public InitDataSourceConfiguration(TenantDataInfoService tenantDataInfoService, DynamicDatabaseProperties dynamicDatabaseProperties) { this.tenantDataInfoService = tenantDataInfoService; this.dynamicDatabaseProperties = dynamicDatabaseProperties; } @Override public void onApplicationEvent(ContextRefreshedEvent event) { if (event.getApplicationContext().getParent() == null) { log.info("----------初始化动态数据源------------"); List<TenantData> tenantData = tenantDataInfoService.listAllTenantDataInfo(); log.info("数据源列表:{}", tenantData); DynamicDataSource dynamicDataSource = (DynamicDataSource) dataSource; Map<Object, DataSource> resolvedDataSources = dynamicDataSource.getResolvedDataSources(); Map<Object, Object> targetDataSources = dynamicDatabaseProperties.initDataSource(tenantData, resolvedDataSources); Set<Map.Entry<Object, Object>> entries = targetDataSources.entrySet(); for (Map.Entry entry : entries) { try { DruidDataSource druidDataSource = (DruidDataSource) entry.getValue(); String url = druidDataSource.getConnection().getMetaData().getURL(); log.debug("连接地址:{}:{}", entry.getKey(), url); } catch (SQLException throwables) { throwables.printStackTrace(); } } dynamicDataSource.setTargetDataSources(targetDataSources); dynamicDataSource.afterPropertiesSet(); } } }
对于在启动中新增数据源,可以用接口实现,这边我给一个简单的例子
public boolean addTenantDataInfo(AddDataSourceReqVo reqVo) { Tenant tenant = tenantService.getTenantById(reqVo.getTenantId()); if (tenant == null) { throw new BaseException("租户不存在"); } TenantData tenantData = new TenantData().setTenantId(reqVo.getTenantId()) .setUrl(reqVo.getUrl()) .setUsername(reqVo.getUsername()) .setPassword(reqVo.getPassword()); boolean save = this.save(tenantData); if (save) { DynamicDataSource dynamicDataSource = (DynamicDataSource) dataSource; dynamicDataSource.addDataSources(tenant.getId(), dynamicDatabaseProperties.getBaseDataSource(tenantData, false)); } return save; }
以上就是具体的实现流程了,接下来测试一下;加入一个简单测试接口,获取test表的信息
租户对应的数据库,tenant_one,tenant_tow,tenant_three;创建一张test表,信息分别如下
---------------------------------------------------------------------------------------------------------------------
PostMan分别登录以下用户,租户对应 1、2、3;获取token,然后再调用测试接口;
登录接口,获取token
访问测试接口,结果如下:
---------------------------------------------------------------------------------------------------------------------
---------------------------------------------------------------------------------------------------------------------
可以看到,接口和参数都一样,但是不同的token,返回的结果是对应租户的数据;
整个项目流程还是比较简单的,我这边只是给出一个解决方案,如果大家有更好的方案可以留言交流。
项目源码:https://gitee.com/luowenjie98/dynamic-data-source