13

飞哥讲代码17:写好代码就要深入细节

 3 years ago
source link: http://lanlingzi.cn/post/technical/2020/1129_code/
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.

飞哥讲代码17:写好代码就要深入细节

2020-11-29

  |   技术  

  |  

4058 字 ~9分钟

案例代码来源我们某产品:

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操作

1.1 背后的知识点

一看到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问题,提取相应的函数即可,本文不再展开。

2 批量插入

前面提到采用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

3 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

4 破窗效应

破窗效应理论认为环境中的不良现象如果被放任存在,会诱使人们仿效,甚至变本加厉。

前文对案例的代码例举几个坏味道,从风格来看都是一些小问题。但由于项目组人员流动的问题,不断会有新人的加入。而新人往往是从学习前辈的代码开始,而技术又不断地发展,过了一段时间之后,你会惊奇发现:那些看似较小且不重要的问题,会对你产生巨大的影响。

本文重点讲解了一下SQL的批量操作,以及MyBatis与JPA的公共点,本意是想让大家对使用的技术多一些深入,不断地采用它他们提供的新特性来简化我们的开发。旧的API或使用方式或许就像一扇破旧的窗户存在哪里。当发现第一扇破窗户,就需要赶快去修补,不然软件就会随着窗户一样,一扇扇的被打破,慢慢的腐化下去。

记住Later=Never,会使我们越来越缺少动力。受破窗效益、本身的惰性以及自我要求不严格都导致了代码质量的下降。通过“修复所有的破窗”,关注小的细节,清理老旧的代码,解决很多小看似不重要的细节问题。经过时间的积累,再从头来看,虽然我们并没有做大型的改动,但是软件像改头换面一样。

本文通过案例中一处代码要采用批量插入来展开,介绍了MyBatis与JPA对批量操作如何支持,以及他们一些其它对比,引申到我们使用MyBatis由于时间的推移,受示范破窗效应,我们还停留在采用旧的技术方式来实现。我们的CleanCode目标不是那些冰冷的指标数据,而是要采用的技术逐渐迭代更新换代。也需要我们有意识地深入关注细节,搞清其中原理,才能有效地清理老旧的代码,作出采用新的技术特性的示范,避免破窗效应。


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK