实习踩坑之路:LocalDateTime计算间隔天数,compareTo/Period的beetween方法导致的bug

1,872 阅读4分钟

业务场景

我这个地方是要做一个批处理操作,拿到一个List对象列表,判断对象列表的会员服务到期时间是什么时候,如果到期时间和现在当前时间相隔1天,也就是小于两天,我就要对此次操作失败,把以前的状态更改回来,所以加了一个

    @Transactional(propagation = Propagation.REQUIRED,rollbackFor = Exception.class)

注解 问题是,我刚开始用LocalDateTime的compareTo方法比较时间是对的,但是后来发现如果这个想差间隔大于了一个月的到的间隔天数就不对了,具体如下

问题复现

    public static void main(String[] args) {
        LocalDateTime now = LocalDateTime.now();
        LocalDateTime expireTime = now.plusDays(3);
        System.out.println("间隔天数:" + expireTime.compareTo(now));
    }
运行结果:间隔天数:3
    public static void main(String[] args) {
        LocalDateTime now = LocalDateTime.now();
        LocalDateTime expireTime = now.plusDays(30);
        System.out.println("间隔天数:" + expireTime.compareTo(now));
    }
运行结果:间隔天数:1

为什么会出现这个状况呢?

compareTo方法

    public int compareTo(ChronoLocalDateTime<?> other) {
        if (other instanceof LocalDateTime) {
            return compareTo0((LocalDateTime) other);
        }
        return ChronoLocalDateTime.super.compareTo(other);
    }

    private int compareTo0(LocalDateTime other) {
        int cmp = date.compareTo0(other.toLocalDate());
        if (cmp == 0) {
            cmp = time.compareTo(other.toLocalTime());
        }
        return cmp;
    }

我们的参数传进来后会首先进行一个compareTo0的方法,这个方法就有猫腻了

    int compareTo0(LocalDate otherDate) {
    	//首先计算年份差距
        int cmp = (year - otherDate.year);
        //年份差距是否=0
        if (cmp == 0) {
        	//同一年的话计算月份差
            cmp = (month - otherDate.month);
            //同理
            if (cmp == 0) {
                cmp = (day - otherDate.day);
            }
        }
        return cmp;
    }

上面代码注释的意思就是说: 这个方法会先进行年份的比较,在进行月份、天数,最后返回cmp,那我们看到这里之后就知道,只要年份/月份不是同一年/同一月,那么cmp就不会是0了,返回的也就不是天数差距,而是年份/月份差距

然后代码回到了

 private int compareTo0(LocalDateTime other) {
        int cmp = date.compareTo0(other.toLocalDate());
        //返回到了这里。发现返回的cmp不是0那直接返回了,如果是0
        //则继续进行小时/分钟/秒数/毫秒之差
        if (cmp == 0) {
            cmp = time.compareTo(other.toLocalTime());
        }
        return cmp;
    }

至此我们就明白了compareTo为啥会出错了,LocalDate同理compareTo方法同理就是不计算小时/分钟/秒数这些了

Period的beetween方法

这个方法也是如果间隔大于1个月那么他就不是返回的间隔天数了

    public static void main(String[] args) {
        LocalDate now = LocalDate.now();
        LocalDate expireTime = now.plusDays(38);
        Period day = Period.between(now,expireTime);
        System.out.println("间隔天数:" + day.getDays());
    }
运行结果:间隔天数:7

Period的beetween方法源码是调用了unitl方法

    public Period until(ChronoLocalDate endDateExclusive) {
        LocalDate end = LocalDate.from(endDateExclusive);
        long totalMonths = end.getProlepticMonth() - this.getProlepticMonth();  // safe
        int days = end.day - this.day; 
        if (totalMonths > 0 && days < 0) {
            totalMonths--;
            LocalDate calcDate = this.plusMonths(totalMonths);
            days = (int) (end.toEpochDay() - calcDate.toEpochDay());  // safe
        } else if (totalMonths < 0 && days > 0) {
            totalMonths++;
            days -= end.lengthOfMonth();
        }
        long years = totalMonths / 12;  // safe
        int months = (int) (totalMonths % 12);  // safe
        return Period.of(Math.toIntExact(years), months, days);
    }

上面代码主要是在int days = end.day - this.day; 这个地方 在这里插入图片描述 这个地方只是把两个day相减,并没有去考虑月份上的差别所以会返回一个7天

解决办法

LocalDate/LocalDateTime的unitl方法

    public static void main(String[] args) {
        LocalDate now = LocalDate.now();
        LocalDate expireTime = now.plusDays(38);
        System.out.println("间隔天数:" + now.until(expireTime,ChronoUnit.DAYS));
    }
运行结果:间隔天数:38

原理

    public long until(Temporal endExclusive, TemporalUnit unit) {
        LocalDate end = LocalDate.from(endExclusive);
        if (unit instanceof ChronoUnit) {
            switch ((ChronoUnit) unit) {
                case DAYS: return daysUntil(end);
                case WEEKS: return daysUntil(end) / 7;
                case MONTHS: return monthsUntil(end);
                case YEARS: return monthsUntil(end) / 12;
                case DECADES: return monthsUntil(end) / 120;
                case CENTURIES: return monthsUntil(end) / 1200;
                case MILLENNIA: return monthsUntil(end) / 12000;
                case ERAS: return end.getLong(ERA) - getLong(ERA);
            }
            throw new UnsupportedTemporalTypeException("Unsupported unit: " + unit);
        }
        return unit.between(this, end);
    }

根据单位选择不同的算法

    long daysUntil(LocalDate end) {
        return end.toEpochDay() - toEpochDay();  // no overflow
    }
   public long toEpochDay() {
        long y = year;
        long m = month;
        long total = 0;
        total += 365 * y;
        if (y >= 0) {
            total += (y + 3) / 4 - (y + 99) / 100 + (y + 399) / 400;
        } else {
            total -= y / -4 - y / -100 + y / -400;
        }
        total += ((367 * m - 362) / 12);
        total += day - 1;
        if (m > 2) {
            total--;
            if (isLeapYear() == false) {
                total--;
            }
        }
        return total - DAYS_0000_TO_1970;
    }

到这里你就明白了,这就是去了跟计算机元年的秒数差然后转化成天数

工具类

import org.apache.commons.lang.StringUtils;


import java.time.LocalDate;
import java.time.ZoneId;
import java.time.format.DateTimeFormatter;
import java.time.temporal.ChronoUnit;
import java.util.Date;

/**
 * LocalDate 工具类
 * @author huacheng
 * @date 2021-12-23 14:43
 */
public class LocalDateUtils {

    /**
     * 计算当前日期与{@code endDate}的间隔天数
     *
     * @param endDate
     * @return 间隔天数
     */
    public static long localDateCompare(LocalDate endDate){
        return LocalDate.now().until(endDate, ChronoUnit.DAYS);
    }

    /**
     * 计算日期{@code startDate}与{@code endDate}的间隔天数
     *
     * @param startDate
     * @param endDate
     * @return 间隔天数
     */
    public static long localDateCompare(LocalDate startDate, LocalDate endDate){
        return startDate.until(endDate, ChronoUnit.DAYS);
    }

    /**
     * 字符串转换成日期
     * @param strDate 日期字符串
     * @param pattern 日期的格式
     */
    public static LocalDate stringToLocalDate(String strDate, String pattern) {
        if (StringUtils.isBlank(strDate)){
            return null;
        }
        DateTimeFormatter fmt = DateTimeFormatter.ofPattern(pattern);
        return LocalDate.parse(strDate, fmt);
    }

    /**
     * Date转LocalDate
     * @param date
     */
    public static LocalDate dateToLocalDate(Date date) {
        if(null == date) {
            return null;
        }
        return date.toInstant().atZone(ZoneId.systemDefault()).toLocalDate();
    }
}