半年前入职新公司以来,一直参与一个维护型的大型JAVA项目。项目是一个交易系统,基于一个非常老的框架搭建的(大概98年的一个框架),框架本身的设计思想不错,没有应用任何开源框架,简单明了。但是公司平时在设计和代码质量把控方面做得不够到位,导致现在代码的质量可以说是高偶合、低类聚的典型,随便改点什么都无法简单评估影响范围,大部分需求变更都不得不把业务和开发人员集中起来,开个大半天的会议讨论。经过无数次的吐槽与沟通,终于说服了领导,来次翻天覆地的重构。
写这篇博客的目的并不是关于重构的,而是在重构完成后遇到的技术问题,在此记录下来与大家分享。
首先,不得不先简单介绍下项目的情况,项目是一个交易系统,技术上很简单,就是一个servlet,对外提供接口。外部调用方封装报文参数以http方式请求,系统内部解析报文,然后交由一个个的step依次处理,最后返回结果。就这么简单,大致流程如下图所示:
调用方的一次请求我们称之为一次交易,每个交易根据报文参数来决定使用哪些Step来完成不同的业务。Parser负责解析外部调用方传入的参数;每个Step相当于一个独立的、可复用的业务模块。比如创建一个用户,Step1负责创建用户的基本信息,Step2负责创建用户的安全信息,其它Step可以负责创建会员的资产信息等或者做一些权限验证、日志记录等,这样的设计初衷是为了复用Step;Wrapper把内部处理封装成结果返回给调用方。整个过程很容易用一段简单代码来表示:
public void process() {
long start = System.currentTimeMillis();
Connection conn = null;
try {
conn = ds.getConnection();
processStep1(conn);
processStep2(conn);
...
processStepN(conn);
long end = System.currentTimeMillis();
if (end - start > limit) {
conn.rollback();
return;
}
conn.commit();
} catch (SQLException e) {
if (null != conn)
conn.rollback();
} finally {
if (null != conn) conn.close();
}
}
从代码上看,框架确实很简单,一开始从数据源拿到一个DB Connection,然后把Connection传入一个个的Step(Step内部直接使用这个Connection对象操作数据库)顺序处理,最后判断是否超时来决定是否提交事务。既然这么简单了,为什么还要重构呢?那是因为业务都在这些Step里面,有大量的Step,而且里面的代码...这里省略1000字。代码质量是重构一原因之一,最主要的是目前项目的代码风格是完全面向过程式的,没有一丁点的面向对象的感觉,维护的成本实在太高,所以重构是采用的是Domain-Driven Design这种纯面向对象的设计方式进行的,关于DDD大家可以自行搜索一下相关的文章,网上不少。
因为是一个大型项目,所以不可能一下子完全重构,而是一个模块一个模块、循序渐进地重构,还需要考虑新旧代码的兼容性,所以第一轮重构后的代码大致如下:
try {
conn = ds.getConnection();
processStep1(conn);
processStep2(conn);
...
//重构后某个Step变成了
Factory factory = ContextLoader
.getCurrentWebApplicationContext().getBean("factory", Factory.class);
DomainObject doObj = factory.create..();
doObj.doSomething();
processStepN(conn);
long end = System.currentTimeMillis();
if (end - start > limit) {
conn.rollback();
return;
}
conn.commit();
}
上面只是重构了某个Step,但完成整个交易的功能,必须兼容原来的代码。从代码可以看出,这里我们使用了spring(持久层使用了spring jdbc),从Context中取得工厂,通过工厂创建一个领域对象doObj,之后就可以使用领域对象去实现具体的业务了。等全部的Step都重构完成后,就可以完全丢掉现有的框架设计了。
不知道大家有没有看出问题,我们有个超时的判断,超过响应时间是要回滚的。但是使用spring jdbc我们一般只是声明一个DataSource,它自己通过data source去获取connection,这样spring jdbc使用的connection与我们代码中使用的connection肯定不是同一个对象,这样代码中的connection就无法控制doObj对数据库的提交与回滚了,起码超时回滚这个业务就肯定会出问题了。
摆在面前的只有两条路:
- 把某个交易中所涉及的全部Step都重构了,这个交易中完全去掉step,不再代码中维护connection,而是把事务的控制交给spring。这个做法可行,而且也是重构的最终目标,但是时间有限,在短时间内重构完一个交易涉及的全部Step,工作量实在不小,而且测试风险也很大。
- 想办法解决
各方面的压力,使得我没有选择,只能想办法解决。代码中的connection相当于new出来的一个对象,而spring中的DataSource又是一个singleton对象,这还不是一个Ioc的问题,因为spring jdbc需要的是一个DataSource,而我们只有Connection,所以我们必须实现一个新的DataSource,并且覆盖它的getConnection方法使它返回我们的代码中new出来的Connection对象。DataSource与我们的框架代码之间好像没有任何关系,怎么能才让它返回我们new出来的Connection呢?感谢我的同事老朱,讨论中他提到了ThreadLocal类,是啊,一次交易过程就是一个线程的一次执行过程,我们可以把Connection保存在ThreadLocal对象中去。
关于ThreadLocal类,简单说就是通过ThreadLocal,可以在同一程线内(不同的方法、代码段等任何地方)共享信息。
首先,创建一个类,通过ThreadLocal用来存取Connection对象。
public class ConnThreadLocal {
private static final ThreadLocal<Connection> local
= new ThreadLocal<Connection>();
public static void addConn(Connection conn) {
local.set(conn);
}
public static Connection getConn() {
return local.get();
}
}
接着,修改框架代码,在获得Connection对象后,保存到ThreadLocal中去。
try {
conn = ds.getConnection();
ConnThreadLocal.addConn(conn); //把Connection对象保存到ThreadLocal中
processStep1(conn);
processStep2(conn);
Factory factory = ContextLoader
.getCurrentWebApplicationContext().getBean("factory", Factory.class);
DomainObject doObj = factory.create..();
doObj.doSomething();
processStepN(conn);
long end = System.currentTimeMillis();
if (end - start > limit) {
conn.rollback();
return;
}
conn.commit();
}
最后,再实现一个新的DataSource,并且让spring jdbc使用这个新的DataSource我们的问题就解决啦!!!
public class CustomDataSource extends AbstractDataSource {
@Override
public Connection getConnection() throws SQLException {
return ConnThreadLocal.getConn();
}
@Override
public Connection getConnection(String username, String password)
throws SQLException {
return this.getConnection();
}
}
至此,一切就绪,上环境,测试。Duang.....出错啦,Connection is closed....,这个错误出现在doObj.doSomething方法中第二次数据库访问,怎么会这样呢?网上查阅了一翻,原来spring jdbc在每次数据库访问之后,都会调用Connection的close方法把Connection还给连接池,之后可以再通过连接池获取可用的连接。设计上非常合理,但是苦了我哎,只能想办法让CustomDataSource返回的链接不能被spring jdbc关闭,为此,需要一个新的Connection类:
public class CustomConnection implements java.sql.Connection {
private java.sql.Connection conn;
public CustomConnection(java.sql.Connection conn) {
this.conn = conn;
}
@Override
public Statement createStatement() throws SQLException {
return conn.createStatement();
}
@Override
public void close() throws SQLException {
//覆盖close方法,不让spring关闭
}
}
CustomConnection类内部维护一个真正可用的Connection对象,除了close方法,所有其它方法都委托这个对象去做。再修改CustomDataSource,使之返回CustomConnection对象。
public class CustomDataSource extends AbstractDataSource {
@Override
public Connection getConnection() throws SQLException {
return new CustomConnection(ConnThreadLocal.getConn());
}
@Override
public Connection getConnection(String username, String password)
throws SQLException {
return this.getConnection();
}
}
这样一来,spring jdbc获取的Connection对象就是我们框架代码里面的那个Connection对象了,并且spring jdbc关闭不了,至此,一切问题解决。