第一部分 SAAS系统如何设计
SAAS概念
何为SAAS(软件即服务),我们更多的会将SAAS作为一种业务形式,SAAS系统具备如下几个基本概念
1 租户
通常我们会把一个业务系统复制给很多客户,这些客户在SAAS系统中被称为租户,例如:**CRM系统的客户有海尔,西门子,海信,格力,这些客户就是SAAS系统中的客户,我们将这些公司或企业称之为租户。这些租户具有如下一些共性的抽象
包含租户的唯一身份标识(企业唯一码等),租户的服务周期(要么是合同期限,服务期限之类的)通常称之为周期,租户的应用(有些系统称之为模块,具体指能完成某个业务动作,具备闭环的流程,可插拔的),租户的客户(比如说海尔的分销商,格力的分销商)等等。总是我们是将更多的客户的共性进行了抽象后得到的数据模型,这个时候我们就有了租户的数据模型,而这个模型很重要,后续我们会继续的讲解。
2 计算资源
在SAAS系统中,我们把业务系统的本身包含对业务数据的处理过程,称之为计算资源,通俗的讲,就是软件本身,因为不同的业务系统采用的技术形态不同,例如专注于数据处理的系统使用的是大数据CDH的技术盏,而业务流程处理使用的SSM框架,分布式部署Dubbo,Springcloud的技术盏,而这些程序都承担了完成某些重要业务的重要任务,我们将其看作是计算资源。
3 存储资源
任何业务处理都离不开数据,这个数据包含但不限于数据库,文本文件,图片,音频,视频等等形式存在的数据,这些数据都有被存储能够使用的要求,所以我们将这些数据的存储位置称之为存储资源
4 逻辑隔离与物理隔离
逻辑隔离是指在同样的物理设备上通过一些标识区分出资源所在的位置,这种隔离的方式能够最大化的使用物理资源,但是随着数据量的增长出口的带宽是它的瓶颈
物理隔离指在资源存储在独立的空间上,这种独享的情况下,不会与其他用户产生冲突,同时管理的难度也更大
所以我们在系统SAAS过程中会根据不同的场景选择以上两种方式的一种
SAAS系统的简单逻辑图
图1
相信在这个简单的图形上,已经大概表述出了SAAS系统是怎样的一种形式,简单的说就是多个租户使用同样的计算资源完成了各自的业务,并把数据存储在自己的存储资源的过程,而完成这个过程就是把系统SAAS化
在上面我们赘述了很多概念,这也是为了让读者更好的理解什么是SAAS化的必经之路,那么接下来进入干货阶段,我是如何将一个使用DUBBO作为主要开发框架的软件SAAS化的,其实其他框架和语言类型的系统也是完全可以参考这个思路的
在这里需要说明的是,我们的团队很推崇使用领域驱动设计(DDD)对业务进行建模,这里包含了核心域,子域,通用域,限界上下文等概念,这种设计理念给我们解决问题带来了很多的帮助
第二部分 应用SAAS化的过程
为核心域(租户)建模
抽象租户 :
1. 租户的生命周期,即一次合同合作的周期
2. 租户的元数据,租户数据的定义数据,
3. 租户的基础数据,如租户的秘钥,子机构,用户等
简单的数据类型如下:
很明显我有了一张描述租户的数据表,我要对它进行CRUD的操作,所以我们就建立了这样的一个服务
这个是TenantDomain对象
然后有了这样一个接口
Ok 这样就完成了第一步,我们建立了一个核心的租户域服务,为应用提供获取租户领域对象TenantDomainVo这个对象,这个对象里包含了租户的唯一标识,租期,简写的前缀等信息,提供给我们的应用来使用
绑定租户信息到应用上下文
首先要说明一点在SAAS化过程中为了让业务处理过程不要过度的修改自己的逻辑,把系统的职责与业务的职责解耦,我们把租户的信息看做是系统的信息,而非业务信息,也就是说不让业务处理的逻辑代码明显的感知到租户信息,这里我们需要使用隐形传参这样一个概念其实这也是Dubbo框架的一个特性,我们来讲一个下是如何实现的,
对Dubbo框架有一定了解的同学都知道如下图
简述一下就是消费者去注册中心拿到提供者的URL地址然后发起一个请求,传递一个序列化的业务对象等待提供者处理完成以后返回
通过Dubbo源码我们了解到dubbo有个叫RPCContext的上下文的类,OK,如果在以上的场景里面我们就可以通过RPCContext.set的方式进行TenantDomainVo对象的的隐性传递,不过事与愿违的情况是我们会遇到这样的场景,一个服务即是服务消费者,同时又是服务提供者这个时候要在好多代码里面显示的使用 RPCContext.set,get这种方式就显得不那么优雅,好在Dubbo框架给我们提供了一种叫SPI的机制,在它的filter层我们通过扩展一个消费者侧,提供者侧的拦截器就更优雅的完成了参数的隐性传递,代码是以下的这个样子
首先我们创建一个线程的本地变量用来存放TenantDomainVo对象
public class SaasParameter {
// 创建线程局部变量,并初始化值
private static ThreadLocal<TenantDomainVo> memberIdThreadLocal = new ThreadLocal<TenantDomainVo>() {
protected TenantDomainVo initialValue() {
return new TenantDomainVo();
};
};
// 提供线程局部变量set方法
public static void setTenantDomain(TenantDomainVo tenantdomain) {
memberIdThreadLocal.set(tenantdomain);
}
// 提供线程局部变量get方法
public static TenantDomainVo getTenantDomain() {
return memberIdThreadLocal.get();
}
}
之后我们来扩展两个拦截器
消费者侧的
@Activate(group = Constants.CONSUMER, value = "SaasConsumerFilter")
public class SaasConsumerFilter implements Filter {
private Logger logger = LoggerFactory.getLogger(SaasConsumerFilter.class);
@Override
public Result invoke(Invoker<?> invoker, Invocation invocation) {
try {
// 把SassParameter里面的值放到dubbo监听的invoke里面
invocation.getAttachments().put("tid", SaasParameter.getTenantDomain().getTid().toString());
invocation.getAttachments().put("tprefix", SaasParameter.getTenantDomain().getTenantPrefix());
} catch (Exception e) {
logger.warn("sassparameter:" + e.getMessage());
}
Result result = invoker.invoke(invocation);
return result;
}
}
提供者侧的
@Activate(group = Constants.PROVIDER, value = "SaasProviderFilter")
public class SaasProviderFilter implements Filter {
private Logger logger = LoggerFactory.getLogger(SaasProviderFilter.class);
@Override
public Result invoke(Invoker<?> invoker, Invocation invocation) {
try {
Integer tid = Integer.valueOf(invocation.getAttachments().get("tid"));
String tprefix = invocation.getAttachments().get("tprefix");
TenantDomainVo tdomain = new TenantDomainVo();
tdomain.setTid(tid);
tdomain.setTenantPrefix(tprefix);
SaasParameter.setTenantDomain(tdomain);
} catch (Exception e) {
logger.warn("sassparameter:" + e.getMessage());
}
return invoker.invoke(invocation);
}
}
让SPI机制生效
实现存储资源(数据库)的动态切换
从前面的概念来看存储资源并非只有数据库一种,我们这里使用数据库为例结合代码来讲解如何做到根据上下文中的租户信息完成数据源的动态切换。
第一步,在我们现在的系统中通常会把系统水平拆分成若干个应用,假设每个应用会有独立的MySql数据源
例如 db_crm_user_租户1,
db_crm_user_租户2,
db_crm_user_租户n,
这里我们不讨论结构的差异,假设他们都是相同的,只是存储着不同租户的crm用户信息而已。
那么加上租户的概念后我们要抽象出这样一个数据结构
存储的数据大概是这样的(请自动忽略拼音什么的,因为那不是我弄进去的)
第二步,搞个查询接口呗,是这样的
第三步,我们来实现动态的数据源切换,先上代码,再讲思路
先来一个类镇场面
package com.kaitaiming.framework.saas.dynamicdatasourcesV2;
import org.springframework.util.StringUtils;
/**
*
* @author yuyang
*
*/
public class DataSourceUtil {
private static final String JDBC_URL_ARGS = "?useUnicode=true&characterEncoding=UTF-8&useOldAliasMetadataBehavior=true&zeroDateTimeBehavior=convertToNull";
private static final String CONNECTION_PROPERTIES = "config.decrypt=true;config.decrypt.key=";
/**
* 拼接数据源的spring bean key
*/
public static String getDataSourceBeanKey(String tenantKey) {
if (!StringUtils.hasText(tenantKey)) {
return null;
}
return tenantKey;
}
/**
* 拼接完整的JDBC URL
*/
public static String getJDBCUrl(String baseUrl) {
if (!StringUtils.hasText(baseUrl)) {
return null;
}
return baseUrl + JDBC_URL_ARGS;
}
/**
* 拼接完整的Druid连接属性
*/
public static String getConnectionProperties(String publicKey) {
if (!StringUtils.hasText(publicKey)) {
return null;
}
return CONNECTION_PROPERTIES + publicKey;
}
}
再来一个处理动态数据源的类
package com.kaitaiming.framework.saas.dynamicdatasourcesV2;
import javax.sql.DataSource;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.ApplicationContext;
import org.springframework.context.annotation.Lazy;
import org.springframework.jdbc.datasource.lookup.AbstractRoutingDataSource;
import org.springframework.stereotype.Component;
import org.springframework.util.StringUtils;
import com.kaitaiming.framework.dubbo.saasfilter.SaasParameter;
import com.kaitaiming.tenant.domain.service.TenantService;
/**
*
* @author yuyang
*
*/
public class DynamicDataSource extends AbstractRoutingDataSource {
@Autowired
private ApplicationContext applicationContext;
@Lazy
@Autowired
private DynamicDataSourceSummoner summoner;
private String APP_ID;
public String getAPP_ID() {
return APP_ID;
}
public void setAPP_ID(String aPP_ID) {
APP_ID = aPP_ID;
summoner.setAPP_ID(APP_ID);
}
@Override
protected String determineCurrentLookupKey() {
String tenantKey = SaasParameter.getTenantDomain().getTenantPrefix();
return DataSourceUtil.getDataSourceBeanKey(tenantKey);
}
@Override
protected DataSource determineTargetDataSource() {
String tenantKey = SaasParameter.getTenantDomain().getTenantPrefix();
String beanKey = DataSourceUtil.getDataSourceBeanKey(tenantKey);
if (!StringUtils.hasText(tenantKey) || applicationContext.containsBean(beanKey)) {
return super.determineTargetDataSource();
}
// else { 新注册的数据源没有被初始化到容器中的处理
// summoner.registerDynamicDataSources();
// }
return super.determineTargetDataSource();
}
}
最后是初始化的类
package com.kaitaiming.framework.saas.dynamicdatasourcesV2;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.support.AbstractBeanDefinition;
import org.springframework.beans.factory.support.BeanDefinitionBuilder;
import org.springframework.beans.factory.support.DefaultListableBeanFactory;
import org.springframework.context.ApplicationListener;
import org.springframework.context.ConfigurableApplicationContext;
import org.springframework.context.event.ContextRefreshedEvent;
import org.springframework.jdbc.datasource.DataSourceUtils;
import org.springframework.stereotype.Component;
import org.springframework.stereotype.Service;
import org.springframework.util.CollectionUtils;
import com.alibaba.druid.filter.config.ConfigTools;
import com.alibaba.druid.pool.DruidDataSource;
import com.google.common.collect.Maps;
import com.kaitaiming.tenant.domain.entity.TenantDbData;
import com.kaitaiming.tenant.domain.service.TenantService;
/**
*
* @author yuyang
*
*/
@Component
@Slf4j
public class DynamicDataSourceSummoner implements ApplicationListener<ContextRefreshedEvent> {
// 跟spring-data-source.xml的默认数据源id保持一致
private static final String DEFAULT_DATA_SOURCE_BEAN_KEY = "defaultDataSource";
@Autowired
private ConfigurableApplicationContext applicationContext;
@Autowired
private DynamicDataSource dynamicDataSource;
@Autowired
private TenantService tenantservice;
private static boolean loaded = false;
private String APP_ID;
public String getAPP_ID() {
return APP_ID;
}
public void setAPP_ID(String aPP_ID) {
APP_ID = aPP_ID;
}
/**
* Spring加载完成后执行
*/
@Override
public void onApplicationEvent(ContextRefreshedEvent event) {
// 防止重复执行
if (!loaded) {
loaded = true;
try {
registerDynamicDataSources();
} catch (Exception e) {
e.printStackTrace();
log.error("数据源初始化失败, Exception:", e);
}
}
}
/**
* 从数据库读取租户的DB配置,并动态注入Spring容器
*/
public void registerDynamicDataSources() {
System.out.println(APP_ID);
// 获取所有租户的DB配置当前获取的是全部, 需要增加根据appid获取,业务线获取
List<TenantDbData> tenantConfigEntities = tenantservice.findTenantMysqlDbData(APP_ID);
if (CollectionUtils.isEmpty(tenantConfigEntities)) {
throw new IllegalStateException("应用程序初始化失败,请先配置数据源------------检查tennatdomain数据连接配置是否正确");
}
// 把数据源bean注册到容器中
addDataSourceBeans(tenantConfigEntities);
}
/**
* 根据DataSource创建bean并注册到容器中
*/
private void addDataSourceBeans(List<TenantDbData> tenantConfigEntities) {
Map<Object, Object> targetDataSources = Maps.newLinkedHashMap();
DefaultListableBeanFactory beanFactory = (DefaultListableBeanFactory) applicationContext.getAutowireCapableBeanFactory();
for (TenantDbData entity : tenantConfigEntities) {
String beanKey = DataSourceUtil.getDataSourceBeanKey(entity.getTenantPrefix());
// 如果该数据源已经在spring里面注册过,则不重新注册
if (applicationContext.containsBean(beanKey)) {
DruidDataSource existsDataSource = applicationContext.getBean(beanKey, DruidDataSource.class);
if (isSameDataSource(existsDataSource, entity)) {
continue;
}
}
// 组装bean
AbstractBeanDefinition beanDefinition = getBeanDefinition(entity, beanKey);
// 注册bean
beanFactory.registerBeanDefinition(beanKey, beanDefinition);
// 放入map中,注意一定是刚才创建bean对象
targetDataSources.put(beanKey, applicationContext.getBean(beanKey));
}
log.info("dynamic datasources init : " + targetDataSources.keySet());
// 将创建的map对象set到 targetDataSources;
dynamicDataSource.setTargetDataSources(targetDataSources);
// 必须执行此操作,才会重新初始化AbstractRoutingDataSource 中的
// resolvedDataSources,也只有这样,动态切换才会起效
dynamicDataSource.afterPropertiesSet();
}
/**
* 组装数据源spring bean
*/
private AbstractBeanDefinition getBeanDefinition(TenantDbData entity, String beanKey) {
BeanDefinitionBuilder builder = BeanDefinitionBuilder.genericBeanDefinition(DruidDataSource.class);
builder.getBeanDefinition().setAttribute("id", beanKey);
// 其他配置继承defaultDataSource
builder.setParentName(DEFAULT_DATA_SOURCE_BEAN_KEY);
builder.setInitMethodName("init");
builder.setDestroyMethodName("close");
builder.addPropertyValue("name", beanKey);
builder.addPropertyValue("url", DataSourceUtil.getJDBCUrl(entity.getDbUrl()));
builder.addPropertyValue("username", entity.getDbUserName());
builder.addPropertyValue("password", entity.getDbPassWord());
return builder.getBeanDefinition();
}
/**
* 判断Spring容器里面的DataSource与数据库的DataSource信息是否一致
* 备注:这里没有判断public_key,因为另外三个信息基本可以确定唯一了
*/
private boolean isSameDataSource(DruidDataSource existsDataSource, TenantDbData entity) {
boolean sameUrl = Objects.equals(existsDataSource.getUrl(), DataSourceUtil.getJDBCUrl(entity.getDbUrl()));
if (!sameUrl) {
return false;
}
boolean sameUser = Objects.equals(existsDataSource.getUsername(), entity.getDbUserName());
if (!sameUser) {
return false;
}
try {
String decryptPassword = ConfigTools.decrypt(entity.getDbName(), entity.getDbPassWord());
return Objects.equals(existsDataSource.getPassword(), decryptPassword);
} catch (Exception e) {
log.error("数据源密码校验失败,Exception:{}", entity.getDbName(), ":" + entity.getDbPassWord(), e);
return false;
}
}
}
然后我们这样用
这个思路就是在系统启动的时候加载租户的数据源到一个Map集合里面,用Key,value的形式存储起来,之后借助org.springframework.jdbc.datasource的AbstractRoutingDataSource的route在建立连接的时候连接到指定的key对应的 数据源value上,这样就完成了数据源的动态切换,当然这只针对了mysql,用些系统会用到mongodb等其他类型的数据库存储机制,其设计的过程是一样的,如果没有route的机制,那就需要自己写一个,好在spring提供了AOP,这样实现rout变得的很简单
最后系统请求的时序图应该是这个样子的
部署的逻辑图符合图1的样子
第三部分 总结
一个系统的设计是抽象与具象的结合,我们要根据输入的信息来进行高度的抽象,同时也要把这些抽象的设计具象到代码中,在这里我还是像读者朋友,尤其是后端开发岗位上的伙伴们推荐一下领域驱动设计这本书,换种思考方式能让你思路大开,最后祝愿所有的技术小伙伴都能成为大牛,真正用技术驱动这个社会的改变,做回技术人的初衷。