improve bitter code: '不可避免'的重复

备注: 示例中的代码并不是真实代码的完全拷贝

一段分批处理的逻辑

周六做code diff的时候发现B项目一段颇长的处理逻辑(40行左右)。处理流程是这样的, 从页面上取到一批数据之后,用这批数据封装参数进行后台调用(远程调用)。为了避免数据调用超时, 对这批数据进行分批多次调用。代码如下所示(现实的代码比这个要复杂些,并且没有使用subList方法):

List batchdatas = ...;
int batchsize = 10; // load from parameter

int datasize = batchdatas.size();
if(datasize > batchsize){
    int batch = datasize / batchsize;
    for(int i=0;i<batch;i++){
        List inparams = new ArrayList();
        List batchdata = batchdatas.subList(batchsize*i, batchsize*(i+1));
        inparams.add(new CEntityList(batchdata));
        RemoteCall.commonInvoke(operator, inparams, "CheckCmd");
    }

    int left = datasize % batchsize;
    if(left != 0){
        List inparams = new ArrayList();
        List batchdata = batchdatas.subList(batchsize*batch, datasize);
        inparams.add(new CEntityList(batchdata));
        RemoteCall.commonInvoke(operator, inparams, "CheckCmd");
    }
}
else {
    List inparams = new ArrayList();
    inparams.add(new CEntityList(batchdatas));
    RemoteCall.commonInvoke(operator, inparams, "CheckCmd");
}

从代码的实现看,思路还是比较清晰的。如果不足一次,就一次提交。否则计算出一共要分多少次, 然后逐次处理提交,最后还要判断是否还有剩余的,如果有就再处理一次。

代码显现出来的问题也比较明显,就是远程调用的逻辑出现了重复。

算法小调整,避免重复

有没什么办法可以避免重复? 或许有些童鞋第一反应是给这几句代码抽取成小方法。 不过这里有更好的办法,首先细想一下就会发现不足一次的判断(datasize > batchsize)不是必要的, 如果计算批次而是计算每次的起始点和结束点,上面两个分支也可以合并一下。调整后代码如下:

List batchdatas = ...;
int batchsize = 10; // load from parameter

int datasize = batchdatas.size();
for(int startidx=0;startidx<datasize;startidx+=batchsize){
    int endidx = (startidx+batchsize) > datasize ? datasize : (startidx+batchsize);

    List inparams = new ArrayList();
    List batchdata = batchdatas.subList(startidx, endidx);
    inparams.add(new CEntityList(batchdata));
    RemoteCall.commonInvoke(operator, inparams, "CheckCmd");
}

其中计算endidx使用了一个三元表达式,使用三元表达式用来替代一些简单的if-else语句是个实用的小技巧。 代码量缩小为原来的三分之一,代码少了,维护量也轻松了。

类似这样的代码也并不少见,例如计算总页数的分页逻辑有下面的写法

// 常用做法
int totalpage = totalsize / pagesize;
if(totalsize % pagesize != 0){
    totalpage++;
}

// 另外一种写法
int totalpage = (totalsize + pagesize - 1) / pagesize;

基本功很重要

java是一门比较古板的语言,大多数情况下,写出来的代码也是大同小异的。 同时,java相关框架又特别的多,很容易拣了芝麻丢了西瓜。 以来面试的童鞋为例,连基本算法的时间复杂度都没弄清楚的人不在少数,所以 在项目代码中,经常看到化简为繁的代码,现在也很习惯了。

"improve bitter code"系列文章:

improve bitter code: 看不懂的正则表达式

备注: 示例中的代码并不是真实代码的完全拷贝

偶然的发现

今天好奇浏览了一下N项目的代码变更历史,发现有人提交了一段关于校验文件格式的代码。 其中包括一段校验日期格式的java代码。代码是这样的:

String validDateStr = // read from file lines
String regex = "(([0-9]{3}[1-9]|[0-9]{2}[1-9][0-9]{1}|[0-9]{1}[1-9][0-9]{2}|[1-9][0-9]{3})(((0[13578]|1[02])(0[1-9]|[12][0-9]|3[01]))|((0[469]|11)(0[1-9]|[12][0-9]|30))|(02(0[1-9]|[1][0-9]|2[0-8]))))|((([0-9]{2})(0[48]|[2468][048]|[13579][26])|((0[48]|[2468][048]|[3579][26])00))0229)";
if(validDateStr.matches(regex)){
    // do something
}

看到这个正则表达式,我立马纠结了,这个正则表达式不知是什么意思。 虽然前几天写代码的同事来问过怎么写校验日期的正则,我当时比较忙,叫他找找有没有现成的。 这次看到这个正则,还是被雷了一把。

于是我问了一下,原来这个正则是校验日期格式,不过加了闰年的判断,所以变得相当复杂。 我晚上还去搜了一下,大概是出自这里的吧!不同的是文中判断的是YYYY-MM-DD的格式,而同事的代码 是判断YYYYMMDD的格式,显得更为难懂。

保持代码的可读性、可维护性

对于这种拿来的复杂代码,的确很cool,不过即使今天你看懂了,别人不一定看得懂,也难保过些日子自己也看不懂了。

所以通常需要一些保持代码可读性、可维护性的手段:

  1. 加多几段注释,或者把来源url标注一下,就像有人喜欢标注那个需求一样。
  2. 把正则弄成常量,并把验证方法封装起来,只需调用method就可以了。
  3. 选择另外一种比较清晰的实现方式, another way, 或许有惊喜。

应该说,这几种情况都应该考虑一下,例如对于上面的例子来说,要使用这么复杂的正则,加上一些简单的注释 是相当有必要的,至少要说明你是想验证什么样的格式。更进一步,封装到方法里边去,例如

public static boolean isStrictYYYYMMDD(String datestr){
    return datestr.matches(STRICT_YYYYMMDD_REGEX);
}

不过这里有个缺点,只能校验一种日期格式,因为日期格式不像邮箱地址,它的形式多样,这样处理能得到的收益并不是很高。 如果我们可以传递校验日期的格式就更好了。

换个实现方式

换个思路,如果不使用正则表达式会怎样,例如SimpleDateFormat就提供了严格验证的格式,示例代码如下:

public static boolean isStrictYYYYMMDD(String datestr){
    SimpleDateFormat format = new SimpleDateFormat("yyyyMMdd");  
    //设置为严格验证模式
    format.setLenient(false);
    try{
        format.parse(str);
        return true;
    } catch (ParseException e) {
        // ignore exeeption
    }
    return false;
}

如果没有设置为严格验证模式的话,20090230这种日期就会变成20090302。

相对于上面正则的方式来说,代码是多了几行,但是因为格式可以变,灵活性有所提高,代码也容易理解了。 另外一方面,由于SimpleDateFormat非线程安全,必须每次都定义一个,在多次处理的情况下显得有些多余。 当然有个折中的方法就是由客户端代码构造format作为传输传递,这样做还有个好处就是,验证日期格式的方法完全就是通用的。

例如,我们可以提供下面的api和调用方式:

//client
SimpleDateFormat format = // 由客户端代码构建format
boolean isDate = DateUtil.isDateFormat(datestr, format);

//DateUtil api
boolean isDateFormat(String datestr, SimpleDateFormat format);
boolean isDateFormat(String datestr, String formatstr);//单次调用
boolean isStrictDateFormat(String datestr, String formatstr);//单次使用,用于严格处理

在不改变接口的情况下,最初的代码可以调整成以下形式

public static boolean isStrictYYYYMMDD(String datestr){
    return isStrictDateFormat(datestr, DateUtil.YYYYMMDD);
}

总结

  1. 隐藏某些复杂的细节是必要的,提供的接口要simple, clear。
  2. 封装有助于焦距局部代码,即使要更换实现方式,也更加easy。
  3. 可以提供通用可定制接口和常用特殊化接口,方便client调用。
  4. commons-langjoda-time开源库提供了非常成熟的解决方案。

"improve bitter code"系列文章:

improve bitter code: 更友好的链式写法

备注: 示例中的代码并不是真实代码的完全拷贝

无意出现的链式代码

还是前几天的事情,群里边有同事提到一个账单相关的需求,其中涉及到组装打印用数据。 有个新同事在Service里边写了一段这样的代码:

public XXService fillItem(String content, String tag) {
    // some codes
    this.printitems.add(new Item(tag, content));
    return this;
}

有同学不是特别理解为什么要这么写,甚至发出return this是什么对象的疑问。

这段代码原来是这么写的:

public void fillItem(String content, String tag, List printitems) {
    // some codes
    printitems.add(new Item(tag, content));
}

有同学奇怪,为什么再提交一次printitems就会重新加一遍,认为第一段代码的问题出现在return this上面。 这样要说明一下,我们使用的是struts1,所以如果Service实例作为action的实例变量,那也是只有一个对象来的。 所以每次刷新一次重复加一遍就没什么奇怪的了。

回头来看那段新写的代码,出发点是好的。毕竟采用这种方式可以使用链式写法(如下所示),代码有时候会变得很sexy。

XXService.fillItem("a", "A").fillItem("b", "B");

这段代码的问题不在于是否采用链式写法,而是对这种单例,变量生命周期了解不足造成的。 链式写法在代码中很少会使用到,但在设计api,也是可以考虑的。设计的好,可以有效提高代码的编写效率和api的友好型。 例如,我就对java collection api里边的add方法深恶痛绝,就是不能连续add,要add多少个就要写多少行,还真的挺烦的。

采用链式写法的代码

在很多有名的开源框架中,链式写法也不是很少见,下面举几个例子:

很常见的有jquery

$("#name").attr("readonly", true).val("test");

还有mock框架mockito

when(mockedList.get(0)).thenReturn("first");
when(mockedList.get(1)).thenThrow(new RuntimeException());

再看看rails的写法

Post.where('id > 10').limit(20).order('id desc').only(:order, :where)
Client.limit(5).offset(30)

我的看法

那么,如果你想采用链式写法,有什么地方需要注意呢?

  1. 先写一下客户端代码,看调用方式合理么,符合sexy api么?
  2. 采用链式操作的interface相关性比较强,经常一起出现。
  3. 参数parameter一般较少,因为参数多了,代码很容易变得模糊不清。
  4. 链式操作要么容错强(像jquery),要么就得直接抛出异常,对返回值不关心。

"improve bitter code"系列文章:

improve bitter code: 迷惑的boolean参数

备注: 示例中的代码并不是真实代码的完全拷贝

群里的讨论

前两天在开发群里边,有同事讨论一个api: 增加一个取特定序列值的方法。并给出了以下的原型代码:

public Object getSeqValue(String seqname, String maindb){
    if("1".equals(maindb)){
        DbRunContext.setRegion(DbRunContext.MAINDB);
    }
    // more codes
    return XXDao.queryForObject(seqname);
}

其中maindb是因为项目支持多数据库的缘故,传入参数maindb来判断是否走主库还是地市库。 我不知道这个”1”是怎么来的,我随口冒出一句:能不能不传递maindb,谁知道要传递什么呢?

于是很快冒出”改进”方案,换成一个布尔值,代码变成这样:

public Object getSeqValue(String seqname, boolean isMaindb){
    if(isMaindb){
        DbRunContext.setRegion(DbRunContext.MAINDB);
    }
    // more codes
    return XXDao.queryForObject(seqname);
}

很显然这不是一个好的解决办法,我从使用者的角度来说,举了下面的例子:

getSeqValue(seqname, true);
getSeqValue(seqname, false);

单单看上面的调用方式,谁能很清楚的说明分别是什么意思?

很明显,这相当的困难。所以我建议对方法进行重命名,例如

getSeqValueFromMaindb(seqname);
getSeqValue(seqname);

当然,可以考虑把原来的方法换成私有的,这样避免代码重复而不会对调用者造成困惑。

迷惑人的参数随处可见

在我们的代码里边,类似这样的参数困惑随处可见,除了这种布尔值,还有”1”和”0” 这种魔法数字,字符串,因为调用太频繁了,很多人不愿意给他取个好听的名字。

例如区分调用后台逻辑是否起事务,用得太多了,加上老代码就这么用,所以很多同事 没有意识加个IS_TRANSACTION这类的静态变量。看,代码就变成下面的样子:

commonInvoke(operator, cmd, subcmd, "1");
commonInvoke(operator, cmd, subcmd, "0");

参数这东西,对看代码的人来说没什么特别的帮助,参数越多越迷惑。 当两个方法调用就差一个布尔值不一样的时候,那是相当痛苦的。 所以最好还是从方法名上进行区分,保持代码的可读性。对于接口方法,公用方法,更是应该如此。

"improve bitter code"系列文章:

improve bitter code

想法

周五的时候去了趟图书馆,回来的时候已经2点了,又是组织参加每周的代码评审了。 这项活动已经参加了很多次了,但还是经常会遇到相同的问题,即使每天codediff, 也很难避免问题一次次的重复出现。代码基还是不可避免的膨胀,需求变化还是那么的频繁, 人员流动还是那么快,新人还是有那么多不成熟的编码风格。

在这个开发团队里边,也没少唠叨编码规范,codediff也是经常再做,代码评审还是有弄, 但最终的效果,体现在代码上,还远远没有那么乐观。我认为这有好些原因:一方面是历史原因, 代码基庞大,复杂,每天对着这些代码,连好代码是怎样的都没有见过,还能真的无师自通? 另外一方面是各种质量改进活动参与度都不是很高,毕竟目标是完成需求,时间久了热情一点点磨灭, 对这个就更不重视了。

相信很多人看过郑大的代码之丑,里边很多素材就出自我所在的项目组,所以这种代码我也看见不少了, 正因为如此,我觉得我也能写一些相关的内容。在下班坐地铁回来的时候, 我列了一些相关主题,更确信了还是有很多内容可以做文章的。大多数题材来自 项目的代码,自己的想法思路则是来源于平时的实践,工作经历,还有开源代码和各种相关的书籍。

计划

整个系列围绕bitter code来说,毕竟维护现有代码是非常重要的工作。这么个系列名称灵感来源于 代码之丑系列和一本叫bitter java的书籍。虽然我觉得代码之丑的名称不是很好,但内容形式会和 这个系列类似,只是会多加一些上下文和背景。文章内容仍然以代码为主线,写出我对代码的改进意见, 至于发布周期,大约是一个星期争取不少于两篇!!

期待吧!!!

"improve bitter code"系列文章: