背景问题
在我们实际开发过程中,时常会遇到日期的间隔计算,即计算多少工作日之后的日期,在不考虑法定节假日的情况下也不是那么复杂,毕竟周六、周日是相对固定的,Java语言也提供了丰富的类来处理此问题。
然而,当考虑法定节假日,原先的工作日也许变成了休息日,同样原先的休息日变成了工作日,再加上大多数客户是内网环境,节假日信息不得不维护到数据库,所以复杂度立马提升了N个档次。
由此,这里提供一些思路仅供参考。
准备工作
我们可以通过如下网址获取法定节假日的信息法定节假日。返回数据如下,这里只截取部分数据
json
{
"2024-01-01": {
"date": "2024-01-01",
"name": "元旦",
"isOffDay": true
},
"2024-02-04": {
"date": "2024-02-04",
"name": "春节",
"isOffDay": false
},
"2024-02-10": {
"date": "2024-02-10",
"name": "春节",
"isOffDay": true
},
"2024-02-11": {
"date": "2024-02-11",
"name": "春节",
"isOffDay": true
}
}
在这份数据中,列举了全部的法定节假日调休信息。isOffDay
为true
,表示和节假日相关的周六日、非周六周日休假;比如2024-01-01
为周一,这里为true,表示休假;再比如2024-02-10
,为周六,也表示休假。isOffDay
为false
,表示周六周日照常上班(也就是我们说的调休)。
解决思路(此章节不是重点,可掠过)
对于此问题,我觉得可以从数据库的设计入手。数据库设计有如下几个思路:
- 数据库保存特殊日期的数据 。比如本应该工作的日期变成了节假日,本应该休息的日期变成了工作日。
- 入库逻辑 。就拿上面的数据,如果
isOffDay
为false,我们肯定全部入库。如果isOffDay
为true,还需要判断日期是否为周六日,如果不是需要入库。 - 计算工作日 。需要针对每一天都要判断是否异常,首先按照正常逻辑处理,然后查询数据库,如果异常(数据库存在),将结果取反。比如查询
2024-4-28
以后10个工作日的日期,首先查看2024-4-29
是否为周末,这里是周一。然后查询数据库,数据库不存在。所以为工作日,计1天,由此向后推10个工作日。
- 入库逻辑 。就拿上面的数据,如果
- 数据库保存放假的数据 。
- 入库逻辑 。首先通过Java提供的日期类,计算出周六周日的日期列表。然后根据接口提供的数据,如果
isOffDay
为false
,将此日期在集合中移除;如果isOffDay
为true
,判断是否为周六日,如果不是,加入到集合中。最后将集合保存到数据库。 - 计算工作日。针对每天,需要查询数据库。如果数据库不存在,则工作日+1,否则不变。这里也可以将数据一次性读取,在内存中处理。
- 入库逻辑 。首先通过Java提供的日期类,计算出周六周日的日期列表。然后根据接口提供的数据,如果
- 数据库保存工作日数据 。
-
入库逻辑。这个和存放放假数据相反。
-
查询工作日 。这里可以通过sql就可以查询。比如查询
2024-04-28
后10个工作日日期。sqlselect * from t_work_date where f_date > '2024-04-28' order by f_date limit 10
最后一条数据就是指定的工作日。
-
当然,也可以将所有的数据存放到数据库。增加一个是否工作日的标识。同样可以通过sql搞定。
基于BitMap
上面思路仅供我们了解,不是这次重点。下面我们重点说明BitMap怎么计算工作日指定天数后的日期。我们知道,对于一个日期,它要么是工作,要么休息,我们很容易想到0和1。我们可以将1代表工作日,将0代表休息日。所以针对一年的数据,我们只用365(或者366)个0和1表示就行。接下来,我们同样按照入库逻辑和计算工作日两个方面说明此问题。
入库逻辑
数据库设计
这里我们创建一张表包含两个字段,f_year和f_data。 这里基于postgresql
存储,sql语句如下:
sql
create table t_date(
f_year int2,
data BYTEA
);
主要逻辑
由于下面代码注释很全面,这里就不写处理逻辑了。需要说明的是这里用到了Java提供的BitSet
类。
这个类和其他数组一样,索引也是从0开始的
。
java
/**
* 填充数据。
* @param year 计算的年份
*/
private static BitSet fillData(Integer year){
//返回一年多少天
int days = Year.of(year).length();
//初始化这一年多少天。
BitSet bitSet = new BitSet(days);
//默认0,这里反转,全部变为1
bitSet.flip(0,days);
//计算当前年第一个周六的日期
LocalDate firstSaturday = LocalDate.of(year, 1, 1)
.with(TemporalAdjusters.nextOrSame(DayOfWeek.SATURDAY));
//计算第一个周六在这个月是第几天
int dayOfMonth = firstSaturday.getDayOfMonth();
//如果是第7天,说明1月1日是周日。所以先将第一天放假。
if(dayOfMonth == 7){
bitSet.set(0,false);
}
//当前周六,7天往后循环加,知道当期年最后一天。
for (int i = dayOfMonth; i <= days; i=i+7) {
//由于索引从0开始,所以这里-1,
//周六放假
bitSet.set(i-1,false);
//周日放假
bitSet.set(i,false);
}
//解析接口的数据为JSON。这里需要自行调用接口获取json数据
JSONObject jsonObject = JSONObject.parseObject(json);
jsonObject.forEach((k,v)->{
//k为日期,v:日期信息{"date": "2024-01-01","name": "元旦","isOffDay": true}
LocalDate k1 = LocalDate.parse(k);
//获取当前日期在年份是第几天
int dayOfYear = k1.getDayOfYear();
JSONObject dataInfo = (JSONObject) v;
//当前日期是否放假。true:放假。false:不放假
Boolean isOffDay = dataInfo.getBoolean("isOffDay");
//由于bitSet索引是从0开始,所以这里要减1.
//我们这里存储的刚好和是否放假相反,所以这里取反
bitSet.set(dayOfYear-1,!isOffDay);
});
return bitSet;
}
这里我们计算得到的BitMap数据,并将其打印:
java
private static void printBitSet(BitSet bitSet){
for (int i = 0; i < bitSet.length(); i++) {
if(i % 8 == 0){
System.out.println();
}else if(i % 4 == 0){
System.out.print(" ");
}
System.out.print(bitSet.get(i)?1:0);
}
}
截取部分数据,下面双斜线后面的不是输出内容。
0111 1001 //1.8
1111 0011 //1.16
1110 0111 //1.24
1100 1111 //2.1
1011 1111 //2.9
0000 0000 //2.17
1111 1100 //2.25
我们拿到结果,那么怎么将数据存放的数据库呢?只要将BitSet转换为二进制就可以:
java
byte[] byteArray = bitSet.toByteArray();
这里我们顺便看一下长度:46
,也就是46个字节。
计算工作日
加载到JVM缓存中
java
//byteArray为数据库查询到的f_data二进制数据
BitSet bitSet = BitSet.valueOf(byteArray);
计算工作日
java
/**
* 计算指定日期的工作日
* @param currentDate 当前日期
* @param workDay 工作日长度
* @param bitSet 计算数据
* @return 计算结果
*/
private static LocalDate calWorkData(String currentDate,int workDay,BitSet bitSet){
LocalDate parse = LocalDate.parse(currentDate);
//获取当前日在在一年的第几天
int begin = parse.getDayOfYear();
//将计算结果先赋值当前日期
int last = begin;
//workDay个工作日,这里循环workDay此
//对于其他的算法,这里循环的次数为工作日的次数+放假的次数
for (int i = 0; i < workDay; i++) {
//找到下一天后的第一个设置为1的位置。
//注意nextSetBit这个方法,从索引值(包括索引值)开始计算,所以这里要先+1。
//还有一个方法nextClearBit,表示下一个0的位置。
last = bitSet.nextSetBit(++last);
}
//last就是索引位置,用最后的索引位置-开始的索引的位置,然后将当前日期推后此天数,就是要计算的日期。
LocalDate localDate = parse.plusDays(last - (long)begin);
System.out.println(localDate);
return localDate;
}
存在问题
这里并没有考虑到跨年,有一种思路。由于次年的法定节假日一般是在当年的11月份左右发布。所以在计算下一年记录的时候,将下一年的数据追加到2024年后面。这样,每一条数据的长度就变成92个字节,按照utf8编码,也就是30来个汉字,我们是可以接受的。
存储以及计算复杂度分析
通过上面提供的几种思路,所占用数据库的大小,这里我们做一个对比:
- BitMap:上面我们也计算了。f_year为2个字节,f_data的92个字节 ,共 94个字节。
- 数据库保存特殊日期 :一个日期记录是
2024-04-04
,为10个字节,特殊日期这里至少11个。再加上各种调休。按照平均20天算,需要220个字节。 - 数据库保存放假的数据:周六日(52*2) + 11 = 115天,那么存放字节:115 * 10 = 1150个字节。
- 数据库保存工作日的数据:(365-115) * 10 = 2500个字节
虽然数据库保存特殊日期
与BitMap
差不多,保存放假数据
和保存工作日数据
存储上分别是BitMap
的10倍和20倍。针对计算工作日复杂度,我觉得数据库保存工作日的数据
通过一条sql语句搞定,算是最简单的,另外也没有BitMap
跨年的问题。
总结
BitMap
无论在存储和计算工作日的复杂度上都占有明显的优势。数据库保存工作日的数据
方式,虽然占用空间是BitMap的20多倍,2000个字节也可以忽略不计,由于它计算工作日算是最简单的,也不失为采纳的思路。
思考
上面休假与工作的最小单位为一天,如果为半天,上面又该如何计算求取?