使用LocalDate类计算指定天数的日期差异

12

我正在使用 openjdk 版本 1.8.0_112-release 进行开发,但需要支持以前的 JDK 版本(Java-8 之前)- 因此不能使用 java.time

我正在编写一个实用类来计算日期,以查看保存的日期是否在当前日期之前,这意味着它已过期。

然而,我不确定我是否以正确的方式完成了这个工作。我正在使用 LocalDate 类来计算天数。从用户点击保存的日期和时间开始计算过期日。该日期将被保存,然后对比这个保存的日期和时间与当前日期和时间 - 即当用户登录时。

这是最好的方法吗?我想继续使用 LocalDate 类。

import org.threeten.bp.LocalDate;

public final class Utilities {
    private Utilities() {}

    public static boolean hasDateExpired(int days, LocalDate savedDate, LocalDate currentDate) {
        boolean hasExpired = false;

        if(savedDate != null && currentDate != null) {
            /* has expired if the saved date plus the specified days is still before the current date (today) */
            if(savedDate.plusDays(days).isBefore(currentDate)) {
                hasExpired = true;
            }       
        }

        return hasExpired;
    }
}

我是这样使用这个类的:

private void showDialogToIndicateLicenseHasExpired() {
    final LocalDate currentDate = LocalDate.now();
    final int DAYS_TO_EXPIRE = 3;
    final LocalDate savedDate = user.getSavedDate();

    if(hasDateExpired(DAYS_TO_EXPIRE, savedDate, currentDate)) {
        /* License has expired, warn the user */    
    }
}

我正在寻找一个考虑到时区的解决方案。如果一项许可证在3天后过期,而用户前往不同的时区。也就是说,他们可能会根据小时数领先或落后。许可证仍应该过期。


3
如果你正在使用Java 8,你不需要使用org.threeten.bp,你可以使用java.time.LocalDate类。 - user7605325
正如Hugo所说,org.threeten.bp来自ThreeTen-Backport项目。该库将java.time的大部分功能移植到Java 6和Java 7中。在Java 8和Java 9中不需要进行后移植,因为java.time已经内置了。 - Basil Bourque
请问,到底何时应该过期呢? 是在保存时间后的72小时内(3 * 24 = 72)? 还是直到午夜? 如果用户在5月26日上午11:30点击了保存,那么在数据保存的时区中,有效期是否应在5月29日和5月30日之间的午夜到期? 或者,第三种选择是在同一时钟时间的3天后? 最后一个选项可能会在夏令时(夏季时间)转换时产生71或73个小时的差异。 - Ole V.V.
1
根据@OleV.V.的评论,应该从用户保存日期开始计算72小时。因此,午夜应该被忽略,因为我们正在倒计时。 - ant2009
不要编写自己的时区代码,使用库 https://www.youtube.com/watch?v=-5wpm-gesOY - Testo Testini
7个回答

10

你的代码基本上没问题,我会用基本相同的方式来编写,只是会有一两个细节不同。

正如Hugo已经指出的那样,我建议使用java.time.LocalDate,并且放弃使用ThreeTen Backport(除非你的代码需要在Java 6或7上运行)。

时区

你应该决定在哪个时区计算天数。此外,我更喜欢在你的代码中明确指定时区。如果你的系统仅在你自己的时区中使用,选择很容易,只需明确说明即可。例如:

    final LocalDate currentDate = LocalDate.now(ZoneId.of("Asia/Hong_Kong"));

请填写相关的区域 ID。这样即使有一天程序在设置有误的计算机上运行,也能保证程序正常工作。如果您的系统是全球性的,您可能需要使用协调世界时 (UTC),例如:
    final LocalDate currentDate = LocalDate.now(ZoneOffset.UTC);

当用户点击保存时,您需要执行类似的操作来保存日期,以确保数据一致性。

72小时

编辑:我从您的评论中了解到,您想要测量3天,即72小时,从保存时间开始确定许可证是否过期。为此,LocalDate 不提供足够的信息。它只是一个没有时钟时间的日期,例如2017年5月26日。还有其他一些选择:

  • Instant 是一个时间点(甚至有纳秒精度)。这是简单的解决方案,确保在72小时后许可证过期,无论用户是否移动到另一个时区。
  • ZonedDateTime 表示日期、时间和时区,例如 2017年5月29日19:21:33.783(GMT+08:00[亚洲/香港])。如果您想提醒用户保存时间,ZonedDateTime 将允许您使用计算保存日期时采用的时区呈现该信息。
  • 最后,OffsetDateTime 也可以工作,但它似乎并没有给您两个选项的优势,因此我不会详细说明这个选项。

由于瞬间在所有时区都相同,因此在获取当前瞬间时不需要指定时区:

    final Instant currentDate = Instant.now();

将3天添加到 InstantLocalDate 稍有不同,但其余逻辑相同:

public static boolean hasDateExpired(int days, Instant savedDate, Instant currentDate) {
    boolean hasExpired = false;

    if(savedDate != null && currentDate != null) {
        /* has expired if the saved date plus the specified days is still before the current date (today) */
        if (savedDate.plus(days, ChronoUnit.DAYS).isBefore(currentDate)) {
            hasExpired = true;
        }       
    }

    return hasExpired;
}

ZonedDateTime 的使用与代码中的 LocalDate 完全相同:

    final ZonedDateTime currentDate = ZonedDateTime.now(ZoneId.of("Asia/Hong_Kong"));

如果您想从程序运行的JVM中获取当前时区设置:

    final ZonedDateTime currentDate = ZonedDateTime.now(ZoneId.systemDefault());

现在如果你声明public static boolean hasDateExpired(int days, ZonedDateTime savedDate, ZonedDateTime currentDate),你可以像以往一样:

        /* has expired if the saved date plus the specified days is still before the current date (today) */
        if (savedDate.plusDays(days).isBefore(currentDate)) {
            hasExpired = true;
        }       

即使两个ZonedDateTime对象位于不同的时区,这也将执行正确的比较。 因此,无论用户前往哪个时区,他/她在许可到期之前都不会获得更少或更多的小时数。


1
是的,这是迄今为止发布的一个重要答案。时区对于确定当前日期非常关键。因此,期望/所需的时区应该明确指定。隐式依赖于JVM的当前默认时区会导致不可靠的行为,因为该默认值可能是意外的,并且甚至可以在运行时(!)由JVM中任何应用程序中的任何代码更改。 - Basil Bourque
如果用户前往不仅位于亚洲的不同时区,这个方案是否可行? - ant2009
1
@ant2009 的意思是你很可能想要在同一时区计算currentDatesavedDate,否则使用包含时区的日期。即使在移动并在不同客户端测试日期的情况下,这也应该有效。 - YoYo

8
您可以使用ChronoUnit.DAYS(在org.threeten.bp.temporal包中,或者如果您使用Java 8本机类,则在java.time.temporal中)来计算2个LocalDate对象之间的天数:
if (savedDate != null && currentDate != null) {
    if (ChronoUnit.DAYS.between(savedDate, currentDate) > days) {
        hasExpired = true;
    }
}

编辑(赏金解释后)

在这个测试中,我使用的是 threetenbp版本1.3.4

由于您希望即使用户处于不同的时区也能正常工作,因此不应该使用 LocalDate,因为该类不能处理时区问题。

我认为最好的解决方案是使用 Instant 类。它表示一个时间点,在任何时区都是相同的(此刻,世界上每个人都处于相同的瞬间,尽管本地日期和时间可能因所在位置而异)。

实际上,Instant 总是以协调世界时(UTC)为基准,这是一种与时区无关的标准,非常适合您的情况(因为您希望进行与用户所在时区无关的计算)。

因此,您的 savedDatecurrentDate 都必须是 Instant 类型,并且您应该计算它们之间的差异。

现在,有一个微妙的细节。您希望在3天后过期。对于我编写的代码,我做出了以下假设:

  • 3天 = 72小时
  • 在72小时后的1秒钟内,它就过期了

第二个假设对于我实现解决方案非常重要。我考虑以下情况:

  1. currentDatesavedDate 晚不到72小时 - 未过期
  2. currentDate 恰好在 savedDate 后72小时 - 未过期(或已过期?请参见下面的注释)
  3. currentDatesavedDate 后超过72小时(即使只有一小部分时间) - 已过期

Instant 类具有纳秒精度,因此在情况3中,即使是在72小时后的1纳秒之后,我也认为它已经过期:

import org.threeten.bp.Instant;
import org.threeten.bp.temporal.ChronoUnit;

public static boolean hasDateExpired(int days, Instant savedDate, Instant currentDate) {
    boolean hasExpired = false;

    if (savedDate != null && currentDate != null) {
        // nanoseconds between savedDate and currentDate > number of nanoseconds in the specified number of days
        if (ChronoUnit.NANOS.between(savedDate, currentDate) > days * ChronoUnit.DAYS.getDuration().toNanos()) {
            hasExpired = true;
        }
    }

    return hasExpired;
}

请注意,我使用ChronoUnit.DAYS.getDuration().toNanos()来获取一天内的纳秒数。最好依赖API而不是硬编码大量容易出错的数字。
我进行了一些测试,使用相同时区和不同时区的日期。我使用了ZonedDateTime.toInstant()方法将日期转换为Instant
import org.threeten.bp.ZoneId;
import org.threeten.bp.ZonedDateTime;

// testing in the same timezone
ZoneId sp = ZoneId.of("America/Sao_Paulo");
// savedDate: 22/05/2017 10:00 in Sao Paulo timezone
Instant savedDate = ZonedDateTime.of(2017, 5, 22, 10, 0, 0, 0, sp).toInstant();
// 1 nanosecond before expires (returns false - not expired)
System.out.println(hasDateExpired(3, savedDate, ZonedDateTime.of(2017, 5, 25, 9, 59, 59, 999999999, sp).toInstant()));
// exactly 3 days (72 hours) after saved date (returns false - not expired)
System.out.println(hasDateExpired(3, savedDate, ZonedDateTime.of(2017, 5, 25, 10, 0, 0, 0, sp).toInstant()));
// 1 nanosecond after 3 days (72 hours) (returns true - expired)
System.out.println(hasDateExpired(3, savedDate, ZonedDateTime.of(2017, 5, 25, 10, 0, 0, 1, sp).toInstant()));

// testing in different timezones (savedDate in Sao Paulo, currentDate in London)
ZoneId london = ZoneId.of("Europe/London");
// In 22/05/2017, London will be in summer time, so 10h in Sao Paulo = 14h in London
// 1 nanosecond before expires (returns false - not expired)
System.out.println(hasDateExpired(3, savedDate, ZonedDateTime.of(2017, 5, 25, 13, 59, 59, 999999999, london).toInstant()));
// exactly 3 days (72 hours) after saved date (returns false - not expired)
System.out.println(hasDateExpired(3, savedDate, ZonedDateTime.of(2017, 5, 25, 14, 0, 0, 0, london).toInstant()));
// 1 nanosecond after 3 days (72 hours) (returns true - expired)
System.out.println(hasDateExpired(3, savedDate, ZonedDateTime.of(2017, 5, 25, 14, 0, 0, 1, london).toInstant()));

注意:对于情况 2 (currentDate 恰好是 保存日期(savedDate) 后的72小时 - 未过期)——如果您希望此项过期,只需更改上面的 if 语句以使用 >= 而不是 >

if (ChronoUnit.NANOS.between(savedDate, currentDate) >= days * ChronoUnit.DAYS.getDuration().toNanos()) {
    ... // it returns "true" for case 2
}

如果您不需要纳秒级精度,只想比较日期之间的天数,您可以按照@Ole V.V的答案中所述的方法进行操作。我相信我们的答案非常相似(虽然我不确定这些代码是否等效),但我还没有测试足够多的情况来检查它们是否在特定情况下有所不同。


我必须继续使用org.threeten.bp.LocalDate,因为我们需要支持之前的版本。 - ant2009
1
没问题,代码基本上是一样的。Threeten 是一个后移库,它实现了大部分功能。 - user7605325
@ant2009 在看到赏金说明(它必须在不同的时区中运行)后,我已经更新了答案。 - user7605325

5
The Answer by Hugo和Ole V.V.的答案都是正确的,Ole V.V.的答案最重要, 因为时区对于确定当前日期至关重要。

Period

另一个在这项工作中有用的类是Period类。该类表示一段时间跨度,不附加于时间线上,以年、月和日的数量表示。

请注意,该类不适合表示需要回答此问题所需的经过时间,因为这种表示方式是按年,然后按月,最后是任何剩余天数“分块”的。因此,如果LocalDate.between(start,stop)用于几周的时间量,则结果可能是“两个月零三天”之类的内容。请注意,出于这个原因,该类不实现Comparable接口,因为除非我们知道涉及哪些特定月份,否则一个月的一对不能被认为比另一对大或小。

我们可以使用这个类来代表问题中提到的两天宽限期。这样做可以使我们的代码更加自我说明。最好传递这种类型的对象,而不是仅传递一个整数。
Period grace = Period.ofDays( 2 ) ;

LocalDate start = LocalDate.of( 2017 , Month.JANUARY , 23 ).plusDays( grace ) ;
LocalDate stop = LocalDate.of( 2017 , Month.MARCH , 7 ) ;

我们使用ChronoUnit来计算经过的天数。
int days = ChronoUnit.DAYS.between( start , stop ) ;

持续时间

顺便提一下,Duration类与Period类相似,表示未附加到时间线的时间跨度。但是,Duration表示整秒数加上以纳秒为单位解析的小数秒总和。从此可以计算出通用的24小时天数(而非基于日期的天数)、小时数、分钟数、秒数和小数秒数。请记住,天不总是长达24小时;在美国,由于夏令时的原因,它们目前可能是23、24或25小时长。

这个问题涉及基于日期的天数,而不是24小时的时间段。因此,在这里使用Duration类是不合适的。


关于java.time

java.time框架是Java 8及以上版本内置的。这些类取代了老旧的遗留日期时间类,如java.util.DateCalendarSimpleDateFormat

Joda-Time项目现在处于维护模式,建议迁移到java.time类。

要了解更多信息,请查看Oracle教程。并在Stack Overflow上搜索许多示例和解释。规范是JSR 310
在哪里获取java.time类?

ThreeTen-Extra 项目扩展了 java.time 的附加类。该项目是 java.time 可能未来添加的试验场。您可能会在这里找到一些有用的类,例如 IntervalYearWeekYearQuarter更多


我必须继续使用org.threeten.bp.LocalDate,因为我们需要支持之前的版本。 - ant2009
2
@ant2009,那么你在问题的第一行就不应该说你正在使用Java 8。 - Basil Bourque

2

我认为更好的做法是使用这个:

Duration.between(currentDate.atStartOfDay(), savedDate.atStartOfDay()).toDays() > days;

Duration类位于java.time包中。


1
(A) 在计算基于日期的天数时,您需要使用Period,而不是Duration。 (B) 为什么说“更好”呢?在我看来,这两种方法都同样适用。 - Basil Bourque
1
@BasilBourque,(A)我想要如上所述的“持续时间”。(B)这种解决方案更好,因为我可以解决这个任务,并且如果我需要获取另一个时间单位的持续时间,这个解决方案也可以很好地工作。这是基于我的个人经验得出的主观意见。 - eg04lt3r
1
@BasilBourque,你知道Period是如何计算天数的吗?它在所描述的情况下如何使用? - eg04lt3r
1
Period 明确为 LocalDate 构建。如果您的意见未能解决问题中指定的特定需求,则其“主观意见”是无关紧要的。 - Basil Bourque
1
@BasilBourque,我的解决方案在问题中失败了吗? - eg04lt3r

1

由于这个问题没有得到足够的回复,我添加了另一个答案:

我使用了"SimpleDateFormat.setTimeZone(TimeZone.getTimeZone("UTC"));"来将时区设置为UTC。因此不再有时区(所有日期/时间都将设置为UTC)。

savedDate被设置为UTC。

dateTimeNow也被设置为UTC,并将过期的“天数”(负数)加到dateTimeNow中

一个新的日期expiresDate使用dateTimeNow的长毫秒数。

检查如果savedDate.before(expiresDate)


package com.chocksaway;

import java.text.ParseException;
import java.text.SimpleDateFormat;
import java.util.Calendar;
import java.util.Date;
import java.util.TimeZone;

public class ExpiredDate {
    private static final long DAY_IN_MS = 1000 * 60 * 60 * 24;

    private static boolean hasDateExpired(int days, java.util.Date savedDate) throws ParseException {
        SimpleDateFormat dateFormatUtc = new SimpleDateFormat("yyyy-MMM-dd HH:mm:ss");
        dateFormatUtc.setTimeZone(TimeZone.getTimeZone("UTC"));

        // Local Date / time zone
        SimpleDateFormat dateFormatLocal = new SimpleDateFormat("yyyy-MMM-dd HH:mm:ss");

        // Date / time in UTC
        savedDate = dateFormatLocal.parse( dateFormatUtc.format(savedDate));

        Date dateTimeNow = dateFormatLocal.parse( dateFormatUtc.format(new Date()));

        long expires = dateTimeNow.getTime() + (DAY_IN_MS * days);

        Date expiresDate = new Date(expires);

        System.out.println("savedDate \t\t" + savedDate + "\nexpiresDate \t" + expiresDate);

        return savedDate.before(expiresDate);
    }

    public static void main(String[] args) throws ParseException {
        Calendar cal = Calendar.getInstance();
        cal.add(Calendar.DATE, 0);

        if (ExpiredDate.hasDateExpired(-2, cal.getTime())) {
            System.out.println("expired");
        } else {
            System.out.println("not expired");
        }

        System.out.print("\n");

        cal.add(Calendar.DATE, -3);

        if (ExpiredDate.hasDateExpired(-2, cal.getTime())) {
            System.out.println("expired");
        } else {
            System.out.println("not expired");
        }
    }
}

运行此代码会产生以下输出:
savedDate       Mon Jun 05 15:03:24 BST 2017
expiresDate     Sat Jun 03 15:03:24 BST 2017
not expired

savedDate       Fri Jun 02 15:03:24 BST 2017
expiresDate     Sat Jun 03 15:03:24 BST 2017
expired

所有日期/时间均为协调世界时。第一个未过期。第二个已过期(savedDate在expiresDate之前)。


0

简洁易懂

public static boolean hasDateExpired(int days, java.util.Date savedDate) {
    long expires = savedDate().getTime() + (86_400_000L * days);
    return System.currentTimeMillis() > expires;
}

在旧版JRE上运行良好。Date.getTime()返回的是毫秒数UTC,因此时区甚至不是一个因素。神奇的86,400,000是一天的毫秒数。

如果您只使用long类型的savedTime,而不是使用java.util.Date,那么您可以进一步简化此过程。


这种方法不涉及时区。 - chocksaway
@chocksaway 噢。这正是她所说的,也是OP想要的,看一下他在问题上的评论就知道了。 - Durandal

0

我建立了一个简单的实用类ExpiredDate,包括时区(例如CET),过期日期,过期天数和小时毫秒差。

我使用java.util.Date和Date.before(expiredDate):

通过检查日期(Date())乘以到期天数加上(时区差乘以到期天数)是否在过期日期之前来判断。

任何早于过期日期的日期都被视为“过期”。

通过添加(i)+(ii)创建新日期:

(i)。我使用一天中的毫秒数(DAY_IN_MS = 1000 * 60 * 60 * 24),并将其与(数量的)到期天数相乘。

+

(ii) 为了处理不同的时区,我会找到默认时区(对于我来说是BST)和传入ExpiredDate的时区(例如CET)之间的毫秒数。对于CET,差异是一个小时,即3600000毫秒。这将乘以(过期天数的数量)。

parseDate() 返回新日期。

如果新日期过期日期之前,则将过期设置为True。 dateTimeWithExpire.before(expiredDate);

我创建了3个测试:

  1. 将过期日期设置为7天,并使expireDays = 3

    未过期(7天大于3天)

  2. 将到期日期/时间设置为dateTimeWithExpire,expireDays设置为2天

    未过期 - 因为CET时区每天添加两个小时(每天一个小时)至dateTimeWithExpire

  3. 将过期日期设置为1天,并使expireDays = 2(1天小于2天)

已过期


package com.chocksaway;

import java.text.ParseException;
import java.util.Calendar;
import java.util.Date;
import java.util.TimeZone;

public class ExpiredDate {
    /**
     * milliseconds in a day
    */
    private static final long DAY_IN_MS = 1000 * 60 * 60 * 24;
    private String timeZone;
    private Date expiredDate;
    private int expireDays;
    private int differenceInHoursMillis;

    /**
     *
     * @param timeZone - valid timezone
     * @param expiredDate - the fixed date for expiry
     * @param expireDays - the number of days to expire
    */
    private ExpiredDate(String timeZone, Date expiredDate, int expireDays) {
        this.expiredDate = expiredDate;
        this.expireDays = expireDays;
        this.timeZone = timeZone;

        long currentTime = System.currentTimeMillis();
        int zoneOffset =   TimeZone.getTimeZone(timeZone).getOffset(currentTime);
        int defaultOffset = TimeZone.getDefault().getOffset(currentTime);

        /**
         * Example:
         *     TimeZone.getTimeZone(timeZone) is BST
         *     timeZone is CET
         *
         *     There is one hours difference, which is 3600000 milliseconds
         *
         */

        this.differenceInHoursMillis = (zoneOffset - defaultOffset);
    }


    /**
     *
     * Subtract a number of expire days from the date
     *
     * @param dateTimeNow - the date and time now
     * @return - the date and time minus the number of expired days
     *           + (difference in hours for timezone * expiryDays)
     *
     */
    private Date parseDate(Date dateTimeNow) {
        return new Date(dateTimeNow.getTime() - (expireDays * DAY_IN_MS) + (this.differenceInHoursMillis * expireDays));
    }


    private boolean hasDateExpired(Date currentDate) {
        Date dateTimeWithExpire = parseDate(currentDate);

        return dateTimeWithExpire.before(expiredDate);
    }


    public static void main(String[] args) throws ParseException {

        /* Set the expiry date 7 days, and expireDays = 3
        *
        * Not expired
        */

        Calendar cal = Calendar.getInstance();
        cal.add(Calendar.DATE, -7);

        ExpiredDate expired = new ExpiredDate("CET", cal.getTime(), 3);

        Date dateTimeNow = new Date();

        if (expired.hasDateExpired(dateTimeNow)) {
            System.out.println("expired");
        } else {
            System.out.println("NOT expired");

        }

        /* Set the expiry date / time, and expireDays to 2 days
         *  Not expired - because the CET timezone adds two hours to the dateTimeWithExpire
        */


        cal = Calendar.getInstance();
        cal.add(Calendar.DATE, -2);

        expired = new ExpiredDate("CET", cal.getTime(), 2);

        dateTimeNow = new Date();

        if (expired.hasDateExpired(dateTimeNow)) {
            System.out.println("expired");
        } else {
            System.out.println("NOT expired");

        }

        /* Set the expiry date 1 days, and expireDays = 2
        *
        * expired
        */

        cal = Calendar.getInstance();
        cal.add(Calendar.DATE, -1);

        expired = new ExpiredDate("CET", cal.getTime(), 2);

        dateTimeNow = new Date();

        if (expired.hasDateExpired(dateTimeNow)) {
            System.out.println("expired");
        } else {
            System.out.println("NOT expired");

        }

     } 
 }

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接