要生成一个字符串,其中夹杂着一些动态变化的整数,我们一般是用String.format方法来完成,但是,如果用的不恰当,你可能是得不到正确的整数字符串的。
事情从一个线上崩溃说起,从崩溃堆栈来看,我的一句SQL语句有语法错误,执行的时候出错导致了崩溃。
SQL语句大致的生成如下:
int i = 0;
String querySql = String.format("select * from table1 where id = %d", i);复制代码
完全没有语法问题的可能,本地执行也是麻溜的通过了。
再细看日志,原来是format后的SQL语句,%d本该替换为i的值对应的字符串,结果却变成了乱码,也是导致语法错误的原因。看看其他地方的字符串格式化,发现只有%d的转换出了问题,字符串的转换也是%s的转换是正常的。
所以,String.format在转换数字的时候,出现了不可靠的一些事情。
JDK里这么常用的方法如果不可靠,那肯定是前人踩坑多次,且很有可能还提交过issue了,所以直接上StackOverflow找了一圈,未想到竟没有结果。
那我只好大胆猜测,莫非是线上某些用户设备的字符集是不兼容ASCII码的,所以把数字转换成了别的字符。这个想法很快被组内一些同事否定了,这世上应该没有哪个字符集标准傻到不兼容ASCII吧。
好吧,不乱猜了,大不了"read the fuck source code" .
String#format的源码如想象的那般简单,把模式字符串分解成一个数组,每个数组元素要么是一个纯字符串,要么是一个'%'符号开头的格式串,然后遍历数组,把格式串一个个的替换成target值,再把数组拼接回字符串。
由于只有整数的转换出错,所以重点关注整数的转换过程,其中一段代码略显诡异:
char c = value[j];
sb.append((char) ((c - '0') + zero));复制代码
value是整数对应的ASCII码数组,比如,整数21对应的value数组就是[50,49]。按理说,把这个数组一股脑插入StringBuilder这个实例就万事大吉了,但是偏偏插入前有一个(char) ((c - '0') + zero)的转换过程,把目标字符c减去字符'0'再加上字符zero,看来这一步就是导致转换出乱子的罪魁祸首了,来看zero的值。
char zero = getZero(l); //由于我们调用format方法没有指定locale,所以l=Locale.getDefault();复制代码
再看getZero方法
private char getZero(Locale l) {
if ((l != null) && !l.equals(locale())) {
DecimalFormatSymbols dfs = DecimalFormatSymbols.getInstance(l);
return dfs.getZeroDigit();
}
return zero;
}复制代码
由于locale()方法返回的就是我们传入的locale,所以这里不走if,直接返回类属性zero的值,再看类属性zero的初始化,是在构造方法里面通过调用静态方法来赋值的
private static char getZero(Locale l) {
if ((l != null) && !l.equals(Locale.US)) {
DecimalFormatSymbols dfs = DecimalFormatSymbols.getInstance(l);
return dfs.getZeroDigit();
} else {
return '0';
}
}复制代码
如果locale不是US,我们终究是躲不过上次if语句块里的DecimalFormatSymbols的,这个类的实例化很简单,根据传入的locale初始化一些固定的值,如小数点符号,分组符号,百分符号,还有我们最关注的zeroDigit
/**
* Gets the character used for zero. Different for Arabic, etc.
*/
public char getZeroDigit() {
return zeroDigit;
}
/**
* Sets the character used for zero. Different for Arabic, etc.
*/
public void setZeroDigit(char zeroDigit) {
this.zeroDigit = zeroDigit;
cachedIcuDFS = null;
}复制代码
这两个方法的方法体不重要,重要的线索在注释里:阿拉伯国家的‘0’是不一样的。至于不一样在哪里,把手机语言切换成阿拉伯语,断点调试一下,果然有惊喜:String.format("%d",0).toCharArray()输出的字符数组中,第一个元素值并不是48(对应'0'),而是1632,直接通过String.valueOf((char)1632)转换为字符,得到一个很粗的‘·’字符,这个应该就是阿拉伯人数字(不是阿拉伯数字)里面的0了。Google一下,果然如此:
再实验1633,1634等字符,完全是对应的。同时在我的崩溃日志里面出现的乱码,也正好就是这些东西。
所以,回头来看(char) ((c - '0') + zero)这个转换,就很简单了。可以看出,String.format对数字的转换,并不是我们固有的认为是“0变成'0',1变成'1'”这么简单,而是要把“0变成零,1变成一”(打个比方而已,^__^ 嘻嘻……还好咱中国是习惯用123的,所以中文下format并不会出现一二三)。
事情原因就是这么简单,解决的办法自然有了,要么,调用format的时候传入Locale.US,要么,别用%d配整数,改用%s配字符串。
PS:孟加拉语环境下也有同样的问题,孟加拉语的0对应Unicode里面的2534。