
基于Spring的多租户系统的简单实现
前言H3
对于一个完整的将web 应用程序转换为 SaaS 应用程序的过程而言,需要满足以下7个条件:
- 应用程序必须支持多租户
- 应用程序必须具备某种程度的自助注册功能。
- 必须具备订阅/记账机制。
- 应用程序必须能够有效地扩展。
- 必须能够监视、配置和管理应用程序和租户。
- 必须有一种机制能够支持惟一的用户标识和身份验证。
- 必须有一种机制能够支持对每个租户进行某种程度的自定义。
本文主要讨论的是实现SaaS的核心,支持多租户。
理论基础H3
实现多租户的方式,大致分为三种
单独的数据库
将租户的数据分离到单独的数据库需要较高的硬件成本和维护成本,但是数据的隔离性更好,安全性更高。
共享的数据库,单独的Schema
这种方式减少了一定的成本,并且也拥有较好的逻辑数据隔离性。但是当数据库崩溃的时候较难恢复,恢复一个租户的数据需要恢复整个数据库,意味着不管其他的租户数据有没有失败,所有的数据都会被覆盖。
共享的数据库,共享的Schema
这种方式的硬件成本和维护成本是最小的。但是所有的压力都聚集到了应用这端,数据的隔离,安全性等问题都需要应用端来处理。
实现思路H3
本文主要针对第三种实现方式,在应用层实现多租户的功能,具体的实现是基于spring framework的。
应用层实现多租户的重点,需要解决以下两个问题:
- 用户所属租户的验证。用户所属的租户决定了用户所访问的数据。具体的实现可以是通过用户登录验证机制,查找用户所属的租户;或者是通过URL的子域,path,参数等等绑定到租户,通过过滤器设置用户所属的租户。
- 租户数据的隔离和访问。租户对数据库的访问方式决定了数据表的隔离方式。比如说,我们可以让不同的租户使用不同的数据库用户来访问数据库,在数据库层对需要租户隔离的表创建动态视图,动态视图的条件就是TANANTID等于当前的数据库用户的租户ID。如果不同用户的数据库连接是隔离的,那么都不用不同的数据库,直接在设置connection的session用户变量为租户名或者租户ID,再进行动态视图的隔离。
具体实现H3
用户所属租户的验证H4
本文使用Servletpath绑定到租户的方式来实现租户的验证。
容器启动的时候,注册所有的租户的business name(租户的一个标示)绑定到servlet mapping中:
bash
@Bean(name = DispatcherServletAutoConfiguration.DEFAULT_DISPATCHER_SERVLET_REGISTRATION_BEAN_NAME)public ServletRegistrationBean dispatcherServletRegistration() {Iterable<Tenant> tenants = this.tenantRepository.findAll();ServletRegistrationBean registration = new ServletRegistrationBean(dispatcherServlet(), getServletMappings(tenants));registration.setName(DispatcherServletAutoConfiguration.DEFAULT_DISPATCHER_SERVLET_BEAN_NAME);if (this.multipartConfig != null) {registration.setMultipartConfig(this.multipartConfig);}return registration;}
可以对映射的mapping进行一定的混淆工作,避免租户间恶意的访问。然后再实现一个拦截器将访问请求的servlet path解析为business name:
bash
@Overridepublic boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {String businessName = resolve(urlPathHelper.getServletPath(request));request.setAttribute(TENANT_BUSINESS_NAME_KEY, businessName);//TODO other process}
数据库的数据隔离H4
对所有的需要租户隔离的业务表添加tenant_dbu(租户的数据库用户),tenant_id(租户的ID)。然后创建动态视图,获取当前的数据库用户:
bash
CREATE TABLE inventory (id BIGINT IDENTITY PRIMARY KEY,name VARCHAR(55),tenant_dbu VARCHAR(16) NOT NULL ,tenant_id BIGINT NOT NULL);
bash
CREATE VIEW inventory_vw ASSELECT id, nameFROM inventoryWHERE tenant_dbu = CURRENT_USER;
同时再实现一个触发器,再往租户业务表中插入的时候,插入正确的当前的数据库用户。
bash
CREATE TRIGGER tr_inventory_before_insertBEFORE INSERT ON inventoryREFERENCING NEW AS newrow FOR EACH ROWBEGIN ATOMICIF (CURRENT_USER = 'root') THENSET newrow.tenant_dbu = CURRENT_USER;END IF;END
应用对数据隔离的实现H4
上述在数据库层简单地实现了对数据的隔离,最终还需要进行正确的操作,才能保证访问到正确的数据。
这里需要实现一个能够进行动态路由的数据源,不同的租户使用不同的数据库用户的链接。
这里使用Spring提供的AbstractRoutingDataSource。首先需要正确的初始化所有的数据源:
bash
protected void registerDataSource(Iterable<Tenant> tenants) {Map<Object, Object> targetDataSources = new HashMap<>();for (Tenant tenant : tenants) {DataSourceBuilder factory = DataSourceBuilder.create(this.properties.getClassLoader()).url(this.properties.getUrl()).username(tenant.getDbu()).password(tenant.getEdbpwd());targetDataSources.put(tenant.getBusinessName(), factory.build());}((AbstractRoutingDataSource) this.dataSource).setTargetDataSources(targetDataSources);((AbstractRoutingDataSource) this.dataSource).afterPropertiesSet();}
在用户进行访问的时候,设置当前租户的business name,以进行对数据源的路由:
bash
@Overridepublic boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {String businessName = (String)request.getAttribute(TenantResolveInterceptor.TENANT_BUSINESS_NAME_KEY);TenantContextHolder.setBusinessName(businessName);return true;}
bash
public class TenantContextHolder {private static final ThreadLocal<String> contextHolder =new ThreadLocal<String>();public static void setBusinessName(String businessName) {Assert.notNull(businessName, "businessName cannot be null");contextHolder.set(businessName);}public static String getBusinessName() {return contextHolder.get();}public static void clearBusinessName() {contextHolder.remove();}}
其他的一些优化H4
系统中有一些资源是属于各个用户的,有一些资源的系统级别的。我们不希望租户访问到一些系统页面,也不希望系统访问一些租户数据。可以通过拦截器进行一定的拦截:
bash
// restrict the accessHandlerMethod method = (HandlerMethod) handler;TenantResource tenantResource = method.getMethodAnnotation(TenantResource.class);RootResource rootResource = method.getMethodAnnotation(RootResource.class);boolean isRootResource = false;// get annotation from class when no annotation is specifiedif (tenantResource == null && rootResource == null) {tenantResource = AnnotationUtils.findAnnotation(method.getBeanType(), TenantResource.class);rootResource = AnnotationUtils.findAnnotation(method.getBeanType(), RootResource.class);}// still with no annotation, set defaultif (tenantResource == null && rootResource == null) {isRootResource = true;}// tenant resourceif (tenantResource != null && StringUtils.isEmpty(businessName)) {throw new NoHandlerFoundException(request.getMethod(), request.getRequestURI(), null);}// root resourceif ((rootResource != null || isRootResource) && !StringUtils.isEmpty(businessName)) {throw new NoHandlerFoundException(request.getMethod(), request.getRequestURI(), null);}
我们自己是实现了两个注解@RootResource,`@TenantResource,用来标记资源是租户级别的还是系统级别的。
完整的示例请参考。
写在最后H3
本文只是抛砖引玉,简单地实现了基于SaaS的系统的部分功能,期待您的反馈。
评论
新的评论
上一篇
为什么AMD
关于JS的模块化,阮一峰的博客上有篇很好的文章,请参考, 模块的写法 , AMD规范 。 本文翻译自RequireJS官方文档 WHY AMD? , 更为详细地阐述了为什么需要AMD。 模块化的目的 什么是JavaScript模块?它们的目的是什么? 定义:如何将一段代码封装…
下一篇
基于Grunt的前端项目的完整构建
本文主要简述一个前端项目使用grunt来进行构建的过程。项目本身是基于 RequireJS 的,样式则使用了 Less 来书写。 项目结构 先来看一下项目整体的目录结构。 Gruntfile 通常我们需要grunt分别在开发,以及部署时为我们进行构建。 运行时构建 运行时构建需…
