这篇博客依然是解决一个实际问题的记录,而且肯定不是一个普适性的解决方案,但还是希望能够为有类似需求的同道提供一些思路,涉及到的知识点有SpringMVC的拦截器和Mybatis动态切换数据库。
背景是这样的:之前搭建了服务端的程序,也就是之前三篇Spring+Mybatis博客的写作初衷,这个服务端程序是作为现有程序的补充,用来获取一些不方便在系统封装方法中直接操作的数据。功能是很简单的,系统设计之初呢,考虑到有正式和测试两个数据库,于是就分别部署到两个服务器上了,但是有个问题,每次代码更新之后,都要部署两遍,我这样的懒癌晚期实在是不能忍的。最后想到的解决方案是在发送请求时在URL最后附上标记,服务端通过URL拦截,进行数据库的切换。
核心灵感来自 Spring+SpringMVC+Mybatis 多数据源整合,为基本是厚重脸皮照搬,尊重原作者townkoim的知识产权,在数据切换这块我就不多赘述了,主要还是把如何拦截URL的实现介绍一遍。
- MyBatis切换数据源
这部分可以按照原著来,如果是照着我之前的博客搭建的环境,只需要看2,3,5步就可以了,我们以前的数据源配置都是直接指定数据源,这里数据源的配置多了一层自定义类DynamicDataSource
,通过返回数据库名来获取配置好的bean数据源,达到切换数据源的目的。在前博客的基础上,需要修改或添加的内容如下:
//为每个请求保存各自的数据源名称
public class DataSourceContextHolder {
private final static ThreadLocal<String> contextHolder = new ThreadLocal<String>();
public static void setDBName(String dbName){
contextHolder.set(dbName);
}
public static String getDBName(){
return contextHolder.get();
}
}
//数据源名称常量,因为接口属性默认final static,就偷懒这么写了
public interface DataSourceName {
String GCI_DB = "gci_db";
String TEST_DB = "test_db";
}
//返回数据源名称,Spring会自动选择对应的配置Bean数据源
public class DynamicDataSource extends AbstractRoutingDataSource{
@Override
protected Object determineCurrentLookupKey() {
return DataSourceContextHolder.getDBName();
}
}
<!-- 引入dbconfig.properties属性文件 -->
<context:property-placeholder location="classpath:dbconfig.properties"/>
<!--测试数据库-->
<bean name="TestDataSource" class="com.alibaba.druid.pool.DruidDataSource" init-method="init" destroy-method="close">
<property name="driverClassName" value="${oracle_driverClassName}"/>
<property name="url" value="${gci_jdbc_url_test}"/>
<property name="username" value="${gci_jdbc_username}"/>
<property name="password" value="${gci_jdbc_password}"/>
<!-- 初始化连接大小 -->
<property name="initialSize" value="0"/>
<!-- 连接池最大使用连接数量 -->
<property name="maxActive" value="20"/>
<!-- 连接池最大空闲 -->
<property name="maxIdle" value="20"/>
<!-- 连接池最小空闲 -->
<property name="minIdle" value="0"/>
<!-- 获取连接最大等待时间 -->
<property name="maxWait" value="60000"/>
<property name="testOnBorrow" value="false"/>
<property name="testOnReturn" value="false"/>
<property name="testWhileIdle" value="true"/>
<!-- 配置间隔多久才进行一次检测,检测需要关闭的空闲连接,单位是毫秒 -->
<property name="timeBetweenEvictionRunsMillis" value="60000"/>
<!-- 配置一个连接在池中最小生存的时间,单位是毫秒 -->
<property name="minEvictableIdleTimeMillis" value="25200000"/>
<!-- 打开removeAbandoned功能 -->
<property name="removeAbandoned" value="true"/>
<!-- 1800秒,也就是30分钟 -->
<property name="removeAbandonedTimeout" value="1800"/>
<!-- 关闭abanded连接时输出错误日志 -->
<property name="logAbandoned" value="true"/>
<!-- 监控数据库 -->
<!-- <property name="filters" value="stat" /> -->
<property name="filters" value="mergeStat"/>
</bean>
<!--正式数据库-->
<bean name="GCIDataSource" class="com.alibaba.druid.pool.DruidDataSource" init-method="init" destroy-method="close">
<property name="driverClassName" value="${oracle_driverClassName}"/>
<property name="url" value="${gci_jdbc_url}"/>
<property name="username" value="${gci_jdbc_username}"/>
<property name="password" value="${gci_jdbc_password}"/>
<!-- 初始化连接大小 -->
<property name="initialSize" value="0"/>
<!-- 连接池最大使用连接数量 -->
<property name="maxActive" value="40"/>
<!-- 连接池最大空闲 -->
<property name="maxIdle" value="40"/>
<!-- 连接池最小空闲 -->
<property name="minIdle" value="0"/>
<!-- 获取连接最大等待时间 -->
<property name="maxWait" value="60000"/>
<property name="testOnBorrow" value="false"/>
<property name="testOnReturn" value="false"/>
<property name="testWhileIdle" value="true"/>
<!-- 配置间隔多久才进行一次检测,检测需要关闭的空闲连接,单位是毫秒 -->
<property name="timeBetweenEvictionRunsMillis" value="60000"/>
<!-- 配置一个连接在池中最小生存的时间,单位是毫秒 -->
<property name="minEvictableIdleTimeMillis" value="25200000"/>
<!-- 打开removeAbandoned功能 -->
<property name="removeAbandoned" value="true"/>
<!-- 1800秒,也就是30分钟 -->
<property name="removeAbandonedTimeout" value="1800"/>
<!-- 关闭abanded连接时输出错误日志 -->
<property name="logAbandoned" value="true"/>
<!-- 监控数据库 -->
<!-- <property name="filters" value="stat" /> -->
<property name="filters" value="mergeStat"/>
</bean>
<bean id="dataSource" class="com.gcoreinc.datasource.DynamicDataSource">
<property name="targetDataSources">
<map key-type="java.lang.String">
<entry key="gci_db" value-ref="GCIDataSource"/>
<entry key="test_db" value-ref="TestDataSource"/>
</map>
</property>
<property name="defaultTargetDataSource" ref="GCIDataSource"/>
</bean>
<bean id="SqlSessionFactory" class="org.mybatis.spring.SqlSessionFactoryBean">
<property name="dataSource" ref="dataSource"/>
<!--<property name="configLocation" value="classpath:mybatis.xml"/>-->
<property name="mapperLocations" value="classpath:mapper/*Mapper.xml"/>
<property name="typeAliasesPackage" value="com.gcoreinc.domain"/>
</bean>
这里数据源配置了连接池的属性等,用的是阿里巴巴的德鲁伊,如果已有配置好的数据源,不需要做修改,只要在dataSouce
的bean里面修改对应的数据源名字就可以了。
2. Spring对URL的分发和拦截
为了通过URL来区分数据源,且对现有系统的影响最小,我决定在URL之后跟上/test
来表示这是测试数据库请求,然后定义了一个自定义拦截器URLInterceptor
public class URLInterceptor extends HandlerInterceptorAdapter {
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
System.out.println(request.getRequestURL().toString());
}
}
然后发现并不会进入拦截器,而是直接404了,找到原因是在拦截器之前,会先进入分发器DispatcherServlet
,有没有觉得眼熟,这其实就是我们在web.xml
中配置过的: <servlet-class>org.springframework.web.servlet.DispatcherServlet</servlet-class>
,点开这个类,找到noHandlerFound
方法:
public class DispatcherServlet extends FrameworkServlet {
protected void noHandlerFound(HttpServletRequest request, HttpServletResponse response) throws Exception {
if(pageNotFoundLogger.isWarnEnabled()) {
pageNotFoundLogger.warn("No mapping found for HTTP request with URI [" + getRequestUri(request) + "] in DispatcherServlet with name \'" + this.getServletName() + "\'");
}
if(this.throwExceptionIfNoHandlerFound) {
ServletServerHttpRequest sshr = new ServletServerHttpRequest(request);
throw new NoHandlerFoundException(sshr.getMethod().name(), sshr.getServletRequest().getRequestURI(), sshr.getHeaders());
} else {
response.sendError(404);
}
}
}
看到这里就有思路了,noHandlerFound
方法是protected的,这个就等于是extends
啊,于是我们只要自定义个类MyDispatcherServlet
(名字比较Low)重写此方法,碰到/test
结尾的就只截取前面的URL然后重定向,设置数据源为test。
public class MyDispatcherServlet extends DispatcherServlet{
private final static String INTERCEPT_STR = "/test";
@Override
protected void noHandlerFound(HttpServletRequest request, HttpServletResponse response) throws Exception {
String url = request.getRequestURL().toString();
boolean isTest = request.getRequestURI().endsWith(INTERCEPT_STR);
url = isTest ? url.substring(0, url.length() - INTERCEPT_STR.length()) : "notFound.jsp";
DataSourceContextHolder.setDBName(isTest ? DataSourceName.TEST_DB : DataSourceName.GCI_DB)
response.sendRedirect(url);
}
}
但是经过实验又失败了,因为重定向之后属于一个新的请求了,而我们存储数据名用的是ThreadLoacl
,所以在重定向之前设置的DataSourceContextHolder.setDBName(DataSourceName.TEST_DB)
失效了,但是重定向之后,我们的URLInterceptor
可以拦截到了。
然而public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler)
只有3个参数,其中重定向的请求request和dispatcher的request数据不共享的,所以不能通过dispatcher
向interceptor
传test信息了,我尝试过response
传送cookie,但是cookie获取之后消除不了,最后只能通过param
传参,即在URL最后附上?db=test_db'
来实现,这样的话可以省略dispatcher
步骤,直接进入interceptor
,所以最后的URLInterceptor
如下:
public class URLInterceptor extends HandlerInterceptorAdapter {
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
String param = request.getParameter("db");
DataSourceContextHolder.setDBName(DataSourceName.TEST_DB.equals(param) ? DataSourceName.TEST_DB : DataSourceName.GCI_DB);
return true;
}
}
通过这个自定义拦截器,我们会拦截所有的请求,然后进行数据源的切换,而业务代码,我一行都没改,这就是AOP的好处了。
对了,有关SpringMVC拦截器的详细解说,可以参看Spring MVC拦截器
在前端,我们可以做一个开关,来全局或者细粒度的控制测试环境或者正式环境,然后一般都有一个HttpUtil类,进行get,post的操作,我们只需要有一个最终的URL生成方法,根据需求在最后加上?db=test_db
,这样我们控制数据源切换就只需要调整前端的开关了。而且服务端只要部署一次。
对Spring的了解逐步深入中,不免有一些谬误的地方,如果有大神路过请无情指出,谢谢。