低级错误,跨年的礼物 - 用substring识别跨年
1月4日放完元旦回公司上班,早上正摸鱼呢,哦,不对,正学习呢,看到了公众号推送的文章:12月31日写成13月1日引发重大 Bug,程序员新年就要被“祭天”?。具体是怎么写出的bug那就不得而知了,难道这位自己手动解析时间戳??
还没等跟我同事浩哥吐槽这篇文章呢,跨年的bug来到我们头上了。业务在群里@我们,投诉说一个公文系统的公文编号在新的一年里没有重置从1开始,而是在2020年的基础上继续递增延续……可我记得这个功能是有实现的,代码我还瞄过一眼,尽管代码不是我写的(不是甩锅)。
好吧,看代码排查了,代码不复杂顺着看下来,尽管我一眼就看出问题了,但我只能说我这这这…
1. 业务需求背景
公文系统,每一个业务流程都有一个唯一的业务编号,简化为:简称[年]XXXX号,如:粤[2020]0403号、鄂[2019]0724号。简称为所在省份,方括号中为年份,后面代表该年第几号文件。
业务需求:每年1月1日开始,编号从1开始编号,4位数字,不足补零。
2. 功能设计
因为每个省份的编号肯定是不统一的,所以维护了一张省份简称及公文编号表,如下:
序号 | 省份代号 | 省份简称 | 公文编号(已用的最大编号) | 最后更改时间 |
---|---|---|---|---|
1 | 020 | 粤 | 403 | 2016-4-3 12:20:20 |
2 | 027 | 鄂 | 724 | 2017-7-24 15:34:35 |
3 | 010 | 京 | 104 | 2018-10-4 16:00:00 |
… | … | … | … | … |
代码逻辑:
-
生成公文编号时,根据省份代号查询上表,获取省份简称、公文编号、最后更改时间并锁表;
-
取当前系统时间,跟最后更改时间作比较。
如果是同一年,公文编号+1作为要生成的公文编号,更新表中公文编号及最后更改时间;
如果不是同一年,公文编号置为1,作为要生成的公文编号,更新表中公文编号及最后更改时间。
这代码逻辑本身这么设计也ok,问题在于比较是否为同一年的代码上…
3. bug
代码简单复原如下:
/**
* 获取公文编号
* @Author Nile(576109623@qq.com)
* @Date 0:11 2021/1/10
* @param lastUpdateTime : 最后更新时间
* @param no : 已使用的公文编号
* @return : int
*/
public int getOfficialNo(Date lastUpdateTime, int no) {
SimpleDateFormat format = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");
String now = format.format(new Date());
String last = format.format(lastUpdateTime);
if (StringUtils.substring(now, 0, 3).equals(StringUtils.substring(last, 0, 3))) {
// 年份一样,递增,返回
return ++no;
} else {
// 年份不同,返回1
return 1;
}
}
看出问题没有!!比较年份是将时间转为字符串,然后取前4位进行比较。ok,这种方式笔者也就不吐槽了,关键是:StringUtils.substring(String str, int start, int end) 这个api用错了!!
传入的两个序号start和end,截取的字符串是不包含end位的,简单说就是包头不包尾,源码如下,注释中也给出了栗子,参数end的解释为(exclusive排除):
/**
* @param end the position to end at (exclusive), negative means
* count back from the end of the String by this many characters
*/
而最让人啼笑皆非的是这个bug去年没有发生,系统是2019年上线。2019跨到2020,对比前三位201和202,不一致,所以公文号重置为1了。今年是2020跨到2021,对比前三位202和202,一致,不重置继续递增。
好吧,我服了。
package org.apache.commons.lang;
/**
* <p>Gets a substring from the specified String avoiding exceptions.</p>
*
* <p>A negative start position can be used to start/end <code>n</code>
* characters from the end of the String.</p>
*
* <p>The returned substring starts with the character in the <code>start</code>
* position and ends before the <code>end</code> position. All position counting is
* zero-based -- i.e., to start at the beginning of the string use
* <code>start = 0</code>. Negative start and end positions can be used to
* specify offsets relative to the end of the String.</p>
*
* <p>If <code>start</code> is not strictly to the left of <code>end</code>, ""
* is returned.</p>
*
* <pre>
* StringUtils.substring(null, *, *) = null
* StringUtils.substring("", * , *) = "";
* StringUtils.substring("abc", 0, 2) = "ab"
* StringUtils.substring("abc", 2, 0) = ""
* StringUtils.substring("abc", 2, 4) = "c"
* StringUtils.substring("abc", 4, 6) = ""
* StringUtils.substring("abc", 2, 2) = ""
* StringUtils.substring("abc", -2, -1) = "b"
* StringUtils.substring("abc", -4, 2) = "ab"
* </pre>
*
* @param str the String to get the substring from, may be null
* @param start the position to start from, negative means
* count back from the end of the String by this many characters
* @param end the position to end at (exclusive), negative means
* count back from the end of the String by this many characters
* @return substring from start position to end positon,
* <code>null</code> if null String input
*/
public static String substring(String str, int start, int end) {
if (str == null) {
return null;
}
// handle negatives
if (end < 0) {
end = str.length() + end; // remember end is negative
}
if (start < 0) {
start = str.length() + start; // remember start is negative
}
// check length next
if (end > str.length()) {
end = str.length();
}
// if start is greater than end, return ""
if (start > end) {
return EMPTY;
}
if (start < 0) {
start = 0;
}
if (end < 0) {
end = 0;
}
return str.substring(start, end);
}
}
4. 解决
方案:
-
不管,就按这个顺序继续递增,反正一年一个省份的公文数量也不会超过5000,4位数够用两年了。(这么回复业务可以打包走人了)
-
更新数据库表,将2中表字段“公文编号”所有省份统一更新为5000,然后大家一起从5001出发,欧耶。(这踏马跟方案1有什么区别)
-
统计每一个省份2021年已使用的公文号数量,将表字段“公文编号”置为该数量,这样就不影响系统的继续使用。如:统计广东2021年已发文4篇,将广东的“公文编号”置为4,接下去发文的编号就会是:粤[2021]0005号。但2021年已生成的编号就要更改数据从1开始,不然到了年底就重号了,好在是新年第一天,公文审批还没有结束,全国的发文数也还在可接受范围内。
准备更新脚本、去机房更新数据,3个人半天没了。
修复代码,上线。(2021年底前上线就行,这个好狗)
5. End
其实像这样的api,类似的还有list的切割。
List<Integer> ints = new ArrayList<>();
// 截取第0-999这1000条数据,第二位不要理解为条数,因为第一位不一定是0
List<Integer> list = ints.subList(0,1000);
之前开发的时候笔者也被坑过,好在单元测试发现了。各位共勉、谨记,单元测试记得跑。
我们这边修复完了正吐槽呢,隔壁组项目经理说他们的另一个公文系统忘记设计这个功能了,产品设计时业务也没有提这个需求,哈哈哈哈哈哈,容我笑会。那他们只能按4中的1方案执行了,明年再重置。
另外一个组,说跨年后他们系统的公文号跳号了,就是生成的公文号都是奇数,跨年欢乐多,你们高兴就好。
6. 巨人的肩膀
本来是想写一点关于日期的,但网上已经有很多文章了,就不折腾了,之前收藏的一篇文章:JavaSE基础:扩展Java 8 日期操作,介绍了LocalDate的使用,可以很方便的获取年月日,Date可以转为LocalDate。
Date date = new Date();
LocalDate localDate = date.toInstant().atZone(ZoneId.systemDefault()).toLocalDate();
int year = localDate.getYear();
int month = localDate.getMonthValue();
int day = localDate.getDayOfMonth();