案例
案例代码来源我们某产品:
public void rollbackOrgPackage(Map<String, Object> oldOrgPackage, String orgName) throw ApigwException, ParseException {
if (StringUtil.isEmpyt(orgName)) {
throw new BackParameterException(...);
}
for (Entry<String, Object> orgPackage: oldOrgPackage.entrySet()) {
switch ( orgPackage.getKey() ) {
case "orgAssets":
List<TApigwOrgAsset> orgAssets = (List<ApigwOrgAsset>) orgPackage.getValue();
List<TApigwAssetContent> orgAssetContents = (List<TApigwAssetContent>) oldOrgPackage.get('orgAssetContents');
try {
if (orgAssets != null) {
for (TApigwOrgAsset orgAsset: orgAssets) {
aTApigwAssetContentMapper.deleteByPrimaryKey(orgAsset.getAstid());
aTApigwOrgAssetMapp.deleteByOrgName(orgName, orgAsset.getZone());
}
}
if (orgAssets == null || orgAssetContents == null) {
break;
}
for(TApigwOrgAsset aTApigwOrgAsset: orgAssets){
aTApigwOrgAssetMapper.insert(aTApigwOrgAsset);
}
for(TApigwAssetContent orgAssetConent: orgAssetContents){
aTApigwAssetConentMapper.insert(orgAssetConent);
}
} catch (Excetption e) {
threw new ApigwExcepiton(...)
}
break;
case "orgService":
... // 省略
case "orgConfigGroups":
... // 省略
case "orgVariables":
... // 省略
}
}
}
上面的代码存在典型的switch惊悚的坏味道。每个Switch块较大,嵌套比较深,在Swith中又存在for循环。还存其它的坏味道:
- 重复轮子:StringUtil是自己写的,并没有采用apache common包的StringUtils
- 命名问题:oldOrgPackage存储多个对象建议采用复数oldOrgPackages,aTApigwAssetContentMapper建议是ApigwAssetContentMapper,因为a, T(ype)没有太多意义,对于类型接口,在Java,不建议加T,I前辍
- 关联问题:一个swtich分支中同时处理orgAsset与orgAssetContent,而他们都是oldOrgPackage Map中的Key
- 穷举问题:switch的case通常建议是Enum值,静态分析工具/IDE也能帮助扫描,从而避免case分支的遗落
- 事务问题:rollbackOrgPackage方法操作多个Mapper,需要加事务保护,当然事务可以加在调用此方法的外层,但建议事务粒度最小化就近原则,避免上层使用时遗漏
- 性能问题:采用for循环来insert,未采用批量插入,存在多条sql操作
背后的知识点
一看到switch语句,可能马上想到采用多态来替换解决(来自重构一书), 重构步骤如下:
- switch语句常常根据type code(型别码)进行选择,需提取与该type code相关的函数或class
- Extract Method(提炼函数)将switch语句提炼到一个独立函数中, 如handleOrgAsset, hanleOrgService等独立方法
- Move Method(搬移函数)将它搬移到需要多态性的那个类里,如把方法搬移一个RollbackOrgPackageHandler类中
- Replace Type Code with Subclass(以子类取代类型码)或 Replace Type Code with State/Strategy(以状态/策略取代类型码),如每个typeCode的子类RollbackOrgAssetHandler, RollbackOrgServiceHandler等,都继承RollbackOrgPackageHandler的handle方法。
- 完成这样继承结构后,有一个新类聚合所有RollbackOrgPackageHandler子类,遍历所有oldOrgPackages,分发给各个子类的handle方法处理
采用多态对于案例中的代码也显得有些厚重了(代码过多),解决此Switch问题,提取相应的函数即可,本文不再展开。
批量插入
前面提到采用for循环来insert,未采用批量插入,存在多条sql操作,这种操作是低效,那有没有办法解决?
案例代码是采用MyBatis框架,那在对应的Mapper定义中增加saveAll方法来支持批量插入。
pubic interface ApigwOrgAssetMapper {
void insert(ApigwOrgAsset apigwOrgAsset);
void saveAll(List<ApigwOrgAsset>) assets);
}
saveAll对应的SQL如下。为了呈现SQL,还是以xml的方式配置来讲解,建议新项目采用注解或Provider动态SQL的方式。
<insert id="saveAll" parameterType="list" >
INSERT INTO ORG_ASSET (field1, field2)
VALUES
<foreach collection="list" item="it" separator=",">
(#{it.field1},#{it.field2})
</foreach>
</insert>
VALUES后面可以跟多个(a_field1, a_field2), (b_field1, b_field2)是MySQL的私有写法,并不是SQL标准,并且有一次批量插入个数上限:语句的长度默认是不能超过4M。
Oracle有两种写法,也是非SQL标准:
<insert id="saveAll" parameterType="java.util.List" useGeneratedKeys="false">
INSERT ALL
<foreach item="it" index="index" collection="list">
INTO ORG_ASSET (field1, field2) VALUES (#{it.field1},#{it.field2})
</foreach>
SELECT 1 FROM DUAL
</insert>
另一种写法SQL是:insert into table(...) (select ... from dual) union all (select ... from dual)
缺点:采用MyBatis就会面临不同数据库之间需要采用不同的xml。
我们再来看一下JDBC的Statement接口是怎么支持批量操作:
- Statement.addBatch(sql):添加要批量执行SQL语句
- Statement.executeBatch():执行批处理SQL语句
- Statement.clearBatch():清除批处理命令
PreparedStatement也支持批量操作,示例如下:
conn = JdbcUtils.getConnection();
String sql = "INSERT INTO ORG_ASSET(field1, field2) values(?,?)";
st = conn.prepareStatement(sql);
for (int i=1;i< 2000;i++){
st.setInt(1, i);
st.setString(2, "field2_" + i);
st.addBatch();
if (i%1000==0) {
st.executeBatch();
st.clearBatch();
}
}
st.executeBatch();
Mybatis执行SQL底层接口是Executor,两个实现类:
- BaseExecutor:用于一级缓存及基础的操作,又分为三个子类
- SimpleExecutor:一次执行一条SQL
- BatchExecutor:通过批量操作来优化性能,即采用上面JDBC的executeBatch执行
- ReuseExecutor:会缓存同一个sql的Statement,省去Statement的重新创建,优化性能
- CachingExecutor:用于二级缓存
若在Mybatis中采用Batch方式,则采用如下方式:
try(SqlSession session = sqlSessionFactory.openSession(ExecutorType.BATCH)) {
ApigwOrgAssetMapper mapper = session.getMapper(ApigwOrgAssetMapper.class);
for (ApigwOrgAsset apigwOrgAsset: orgAssets) {
mapper.insert(apigwOrgAsset);
}
session.flushStatements();
}
若在Mybatis采用动态Provider方式,可参见:Batch Insert Support中的样例
try(SqlSession session = sqlSessionFactory.openSession(ExecutorType.BATCH)) {
SimpleTableMapper mapper = session.getMapper(SimpleTableMapper.class);
List<SimpleTableRecord> records = getRecordsToInsert(); // not shown
BatchInsert<SimpleTableRecord> batchInsert = insert(records)
.into(simpleTable)
.map(id).toProperty("id")
.map(firstName).toProperty("firstName")
.map(lastName).toProperty("lastName")
.map(birthDate).toProperty("birthDate")
.map(employed).toProperty("employed")
.map(occupation).toProperty("occupation")
.build()
.render(RenderingStrategies.MYBATIS3);
batchInsert.insertStatements().forEach(mapper::insert);
session.commit();
}
现有我们也有不少项目采用JPA来做ORM,JPA由于底层默认采用Hibernate,而Hibernate相比MyBatis封装对数据库更解耦,对于批量操作来得更简单:
public interface XXEntityRepository extends JpaRepository<XXEntity, String> {
}
// 拿到Repository之后,则可以批量插入,更新
repository.saveAll(list);
默认情况下,它并不会采用JDBC的executeBatch执行,还需要配置:
spring.jpa.properties.hibernate.jdbc.batch_size=500
spring.jpa.properties.hibernate.jdbc.batch_versioned_data=true
spring.jpa.properties.hibernate.order_inserts=true
spring.jpa.properties.hibernate.order_updates=true
另对于MySQL,还可以配置rewriteBatchedStatements=true,实现高性能的批量插入。
- MySQL JDBC连接URL字符串中需要新增一个参数rewriteBatchedStatements=true ,5.1.13以上版本才支持
- MySQL JDBC驱动在默认情况下会无视executeBatch()语句,只有把rewriteBatchedStatements参数置为true, 驱动才会帮你批量执行SQL
Mybatis Or JPA
现在网络上有着不少Jpa和Mybatis的对比与争论,国内使用MyBatis的比较多。就我见到的项目而言,Mybatis在我司使用较早,并没有跟随MyBatis新发展,积累如下问题:
- 早期SQL要写在xml中,维护不方便,未采用注解的方式
- 早期没有采用XXXStatementProvider.build().render()风格来减少原生SQL,缺少灵活性,代码也较多,注:依赖 mybatis-dynamic-sql
- 早期不少项目未使用mybatis-plus,连简单的 CRUD 操作也需要写SQL,存在相似SQL
- 由于要支持多种数据库的Paging翻页,需要写不同的SQL,采用Provider中拼SQL,存在注入的风险
普通的CRUD:
- MyBatis:可以采用mybatis-plus来简化
- JPA:内置CrudRepository
对于动态SQL(如where条件不固定的场景),目前MyBatis与JPA都支持比较好:
- MyBatis: 提供XXXStatementProvider.build()方式
- JPA:提供Specification与ExampleMatcher两种方式
对于Paging翻页:
- MyBatis:采用mybatis-plus的翻页插件,而mybatis的SelectStatementProvider的Limit与Offset方式需要数据库支持limit来offset,存在不同数据库的切换工作量
- JPA:支持Repository的方法支持Pagable参数,底层通过hibernate dialect来实现不同数据库的适配
缓存,都提供接口来对缓存扩展,默认支持:
- MyBatis:一级缓存(session缓存),二级缓存(mapper级别的缓存)
- JPA:由Hibernate实现,一级缓存(session缓存),二级缓存(跨entityManager)
整体来说,我对JPA与MyBatis使用并没有倾向性,但无论使用谁,都建议多一些深入,使用最新特性:
- JPA(Hibernate):比较复杂,重量型,功能齐全,与数据库高度解耦合,完整的ORM
- MyBatis:比较简单,轻量型,它并非是完整的ORM,而是SQL Mapping
破窗效应
破窗效应理论认为环境中的不良现象如果被放任存在,会诱使人们仿效,甚至变本加厉。
前文对案例的代码例举几个坏味道,从风格来看都是一些小问题。但由于项目组人员流动的问题,不断会有新人的加入。而新人往往是从学习前辈的代码开始,而技术又不断地发展,过了一段时间之后,你会惊奇发现:那些看似较小且不重要的问题,会对你产生巨大的影响。
本文重点讲解了一下SQL的批量操作,以及MyBatis与JPA的公共点,本意是想让大家对使用的技术多一些深入,不断地采用它他们提供的新特性来简化我们的开发。旧的API或使用方式或许就像一扇破旧的窗户存在哪里。当发现第一扇破窗户,就需要赶快去修补,不然软件就会随着窗户一样,一扇扇的被打破,慢慢的腐化下去。
记住Later=Never,会使我们越来越缺少动力。受破窗效益、本身的惰性以及自我要求不严格都导致了代码质量的下降。通过“修复所有的破窗”,关注小的细节,清理老旧的代码,解决很多小看似不重要的细节问题。经过时间的积累,再从头来看,虽然我们并没有做大型的改动,但是软件像改头换面一样。
结语
本文通过案例中一处代码要采用批量插入来展开,介绍了MyBatis与JPA对批量操作如何支持,以及他们一些其它对比,引申到我们使用MyBatis由于时间的推移,受示范破窗效应,我们还停留在采用旧的技术方式来实现。我们的CleanCode目标不是那些冰冷的指标数据,而是要采用的技术逐渐迭代更新换代。也需要我们有意识地深入关注细节,搞清其中原理,才能有效地清理老旧的代码,作出采用新的技术特性的示范,避免破窗效应。