这篇博客依然是解决一个实际问题的记录,而且肯定不是一个普适性的解决方案,但还是希望能够为有类似需求的同道提供一些思路,涉及到的知识点有SpringMVC的拦截器和Mybatis动态切换数据库。
背景是这样的:之前搭建了服务端的程序,也就是之前三篇Spring+Mybatis博客的写作初衷,这个服务端程序是作为现有程序的补充,用来获取一些不方便在系统封装方法中直接操作的数据。功能是很简单的,系统设计之初呢,考虑到有正式和测试两个数据库,于是就分别部署到两个服务器上了,但是有个问题,每次代码更新之后,都要部署两遍,我这样的懒癌晚期实在是不能忍的。最后想到的解决方案是在发送请求时在URL最后附上标记,服务端通过URL拦截,进行数据库的切换。
核心灵感来自 Spring+SpringMVC+Mybatis 多数据源整合,为基本是厚重脸皮照搬,尊重原作者townkoim的知识产权,在数据切换这块我就不多赘述了,主要还是把如何拦截URL的实现介绍一遍。

  1. 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数据不共享的,所以不能通过dispatcherinterceptor传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的了解逐步深入中,不免有一些谬误的地方,如果有大神路过请无情指出,谢谢。