批量将"".equals()替换成StringUtils.isEmpty()

甲方前一段时间使用360代码卫士对我们项目进行了“代码审计”,其中一个问题是“使用equals()来判断字符串是否为空”。由于甲方只关心问题数量,不关心问题性质(甚至连算不算问题都不在乎),我们只能硬着头皮去按甲方要求修改。

修改的必要性

首先看一下equals()和length()的实现:

public boolean equals(Object anObject) {
    if (this == anObject) {
        return true;
    }
    if (anObject instanceof String) {
        String anotherString = (String)anObject;
        int n = value.length;
        if (n == anotherString.value.length) {
            char v1[] = value;
            char v2[] = anotherString.value;
            int i = 0;
            while (n-- != 0) {
                if (v1[i] != v2[i])
                    return false;
                i++;
            }
            return true;
        }
    }
    return false;
}

public int length() {
    return value.length;
}

很明显,equals也是先比较字符串长度,而且空串长度是0,即使进入while处也跑不起来,因此在业务系统这种意义上不会有任何性能差异。另一方面,我们进行了简单的性能测试(循环一千万次),结果同样表明完全没有必要改。

施工

为了交差,我们不得不去做没有必要的事情,所以我们需要考虑一种代价最小、没有副作用的改法。考虑到业务系统并不差调用一个函数的时间,我们干脆直接使用StringUtils工具类,既增加代码可读性,也便于装逼。代码通过awk和bash进行自动处理。

检查公共函数

先确认import org.apache.commons.lang.StringUtils是否能正确导入。如果不能,那么需要导入jar包(commons-lang.jar)。

equals.awk

# 注意:mac系统要使用gawk

BEGIN {
    imported = 0;
    last_import_line = -1;
    bufflen = 0;
    changed = 0;
}

# 修正第一类equals判断问题 "".equals(xxx),提取括号里的内容,pos需要是恰好为括号的位置,如果失败则返回-1。
function extract1(str, pos,        i, level, endpos, result, len, arr) {
    level = 0;
    endpos = -1;
    result = "";

    len = length(str);
    split(str, arr, "");

    for (i=pos; i<=length(arr); i++) {
        result = result arr[i];

        if (arr[i]=="(") {
            level++;
        } else if (arr[i]==")") {
            level--;
            if (level == 0) {
                endpos=i;
                break;
            }
        }
    }

    if (endpos == -1) {
        return -1;
    } else {
        return result;
    }
}

# 修正第一类equals判断问题: "".equals(xxx)
function fix_equals1(str,      result, m, txt, tmp) {
    result = str;
    m = 0;

    while (1) {
        m = match(substr(result, m + 1), /[^\\]""\.equals/);
        if (m > 0) {
            txt = extract1(result, m + 10);
            if (txt != -1) {
                result = substr(result, 1, m) "StringUtils.isEmpty" txt substr(result, m + 10 + length(txt));
                m = 0;
            }
        } else {
            break;
        }
    }

    return result;
}

# 修正第二类equals判断问题xxx.equals(""),提取其中的xxx,匹配规则:括号、字母、数字,碰到别的东西就停止
function extract2(str, pos,     i, level, result, ch, arr) {
    level = 0;
    split(str, arr, "");
    result = "";

    for (i = pos; i > 0; i--) {
        ch = arr[i];
        if (match(ch, /[a-zA-Z0-9_ \t\.]/)) {
            # 放行
        } else if (ch == ")") {
            level++;
        } else if (ch == "(") {
            level--;
            if (level < 0) {
                break;
            }
        } else {
            if (level == 0) {
                break;
            }
        }
        result = ch result;
    }
    return result;
}

# 修正第二类equals判断问题:xxx.equals("")
function fix_equals2(str,    result, m, txt, tmp) {
    result = str;
    m = 0;

    while (1) {
        m = match(substr(result, m + 1), /\.equals\(""\)/);
        if (m > 0) {
            txt = extract2(result, m - 1);
            if (txt != "") {
                result = substr(result, 1, m - length(txt) - 1) "StringUtils.isEmpty(" txt ")" substr(result, m + 11);
                m = 0;
            }
        } else {
            break;
        }
    }

    return result;
}

/^import.*;$/ {
    # 检查有没有import字符串工具类
    if (imported == 0 && match($0, /StringUtils;/)) {
        imported = 1;
    }
    last_import_line = NR;
}

{
    line = $0;

    line = fix_equals1(line);
    line = fix_equals2(line);

    gsub(/!StringUtils\.isEmpty/, "StringUtils.isNotEmpty", line);

    # 实际上不必要的trim。如果你的项目确实会出现空格,请谨慎处理。
    line = gensub(/StringUtils\.isEmpty\(([a-zA-Z0-9_ ]+)\.trim\(\)[ ]*\)/, "StringUtils.isEmpty(\\1)", "g", line);
    line = gensub(/StringUtils\.isNotEmpty\(([a-zA-Z0-9_ ]+)\.trim\(\)[ ]*\)/, "StringUtils.isNotEmpty(\\1)", "g", line);

    if ($0 != line) {
        changed = 1;
    }

    # 在完成分析import之前不要直接输出,以便插入import StringUtils语句
    buff[bufflen + 1] = line;
    bufflen = bufflen + 1;
}

END {
    for (i = 1; i <= bufflen; i++) {
        print buff[i];

        # 插入 import StringUtils。如果有import语句就插到所有import后面,否则放在第二行(正常情况下package com.xxx是第一行)
        if (imported != 1 && (i == last_import_line || last_import_line == -1) && changed == 1) {
            print "import org.apache.commons.lang.StringUtils;";
            imported = 1;
        }
    }
}

equals.sh

#!/bin/bash
export LC_ALL=zh_CN.GBK         # 代码用UTF-8的,本行需要删掉
cd 代码所在路径
find . -name "*.java" | while read file; do
    echo $file
    awk -f equals.awk $file > _tmp.java         # macOS需要用gawk,安装命令brew install gawk或port install gawk
    cp _tmp.java $file
done
rm _tmp.java

运行程序

编译程序,发现程序出现了很多编译错误,这些错误都是因类型不一致导致的低级错误,例如字符串与日期比较、对着int值进行Interger.valueOf、判断int值是否为空串等等。这样看来,无用功做得倒也值了。

已知问题

str != null && !"".equals(str)以及str == null || "".equals(str)转换之后会变成str != null && StringUtils.isNotEmpty(str)str == null || StringUtils.isEmpty(str),但空指针判断已经没有必要,应当删除。

后续

写脚本和调试脚本花了半天时间,下午脚本执行完成,然后手工处理了一些暴露出来的代码错误。隔壁两位新员工分别改另外两类简单问题,结果各花了一星期,因此非常建议大家学习一两门辅助语言,并且自动化处理事情。