Java 时间与休息 API

Java Time & Rest API

我已经修改了多篇文章和讨论,但我仍然有一些不确定性:我不确定我是否应该使用 Instant 或任何其他类型来存储预订——在“在线预订”的意义上(因此来自不同 countries/time 区域的参与者需要在时间轴上的同一时刻会面)。我倾向于使用 LocalDateTime,因为数据库和后端设置为 UTC,并且传入的“创建预订”json 消息包含 ISO 8601(带偏移量)startDateTime 和 endDateTime 字段。

让我们进行此设置:1. 数据库(UTC、Oracle、MSSQL),Java 11 后端(UTC,SpringBoot,Spring JDBC 模板) , Angular 13 前端。

来源:

  1. https://apiux.com/2013/03/20/5-laws-api-dates-and-times
  2. https://medium.com/decisionbrain/dates-time-in-modern-java-4ed9d5848a3e
  3. java.time.LocalDate vs Instant for a 'business date'
  4. https://mattgreencroft.blogspot.com/2014/12/java-8-time-choosing-right-object.html

目前清楚的是:

下一个:

例如文章 2) 告诉我们:许多应用程序只能使用 LocalDate、LocalTime 和 Instant 编写,并在 UI 层添加时区。

而 3) 说明:因此商业应用程序开发人员最常使用 Instant 和 ZonedDateTime classes。几乎所有的后端、数据库、业务逻辑、数据持久化、数据交换都应该采用 UTC。但是为了向用户展示,您需要调整到用户期望的时区。这就是 ZonedDateTime class.

的目的

我们来总结验证例子:

LocalTime

LocalDate

LocalDate + 时区(在单独的数据库列中)

LocalDateTime

Instant

都没有。对于给定地点的约会(无论是视频通话会议还是理发师的约会),您需要 ZonedDateTime.

故事时间!

假设你计划在 2024 年夏天去阿姆斯特丹的某个地方,出于某种奇怪的原因,你想确保自己看起来很棒,所以你有点疯狂,决定去理发店预约,因为14:00,2024 年 5 月 2 日(星期三),在阿姆斯特丹的某个特定理发店。

我们可以精确地计算出在你的理发店约会结束之前需要经过多少秒:会有某个时刻,你可以告诉地球上的任何人等待确切的秒数然后拍手.他们什么时候拍手?您预约的确切时间。当然,如果有人要在加利福尼亚鼓掌,那将是加利福尼亚的非常清晨,但你可以这样做。这就是重点 - 你在谈论一个特定的时刻,这意味着它会是一天中的不同时间,具体取决于地球上的哪个地方。

但是,这甚至不是假设:欧盟不久前决定欧盟应该停止夏令时。欧盟目前正忙于其他一些事情,因此该指令的执行仍在讨论中,但已经决定 - 所以现在的辩论是关于 何时 欧盟在夏季停止时间和哪个时区过去被称为 'central european time',被绝大多数欧盟国家使用(这是一个非常大的时区),需要变成。

值得注意的是,欧盟尚未就此做出决定。事实上,每个国家仍然可以自由选择他们喜欢的东西。因此,很合理的是,例如。荷兰位于这个非常大的区域的西部边缘,也是您指定理发店的地方,将决定坚持目前的做法 'winter time',因为德国很可能会这样做,而且两国公民都在增长而是习惯于在两个国家同时进行。但是,坚持夏令时也是很有道理的。我们还不知道。

总有一天,在议会中,一把锤子会落下来。锤子落下的那一刻,正是荷兰时区定义正式改变的那一刻,假设他们选择坚持冬季时间,在锤子落下的那一刻,你的理发师约会刚刚移动了 1 小时。它仍在 14:00,2024 年 5 月 2 日,在阿姆斯特丹 - 但在锤子落在海牙的荷兰议会的那一刻,您的约会时间已经改变了 3600 秒。如果您及时将这一时刻存储在 UTC 中,它 现在将是错误的 。如果您以剩余秒数或从 UTC 纪元 1970 年午夜算起的秒数来存储这个时刻,它现在是错误的。所以不要以这些方式存储约会。

这解释了 Instant+timezone 和 ZonedDateTime objects 之间的实用差异。

A ZonedDateTime object 表示“我有一个约会,在 Europe/Amsterdam,14:00,2024 年 5 月 2 日。

鉴于这正是它所代表的意思,如果您的 tzdata 文件已更新以反映锤子的落下,那没关系。它仍然是 2024 年 5 月 2 日 14:00。就 'how many seconds until the appointment' 而言,object 表示的时间会在您的 JVM 使用新的 tz 更新时偏移 1 小时信息。

Instant不一样。 Instant 表示 'how many seconds until that moment' 的精确时刻。将该约会 存储为 Instant 的行为将涉及计算直到它发生的秒数,然后存储 that。而且,正如我们刚刚确定的那样,这是不正确的,这意味着您将错过预约。这不是约会的工作方式(根据年、月、日、小时、分钟和秒,以及可能隐含的时区工作——任何人类使用的议程都不是基于 'what does your day look like 134823958150154 seconds from now?' 的想法)-并且您应该始终根据其工作方式使用数据类型(和存储数据),而不是对其进行某种转换,因为如果您存储转换,您 'encode' 您对它的依赖进入您的存储和东西出错如果您的转换不再有效。这就是为什么将其存储为 Instant 是这里的错误。

这个故事还解释了为什么唯一 non-stupid 存储时区信息的方法是 Europe/Amsterdam - 因为像“CET”(欧洲中部时间)这样的东西可能在 2 年后甚至不存在.绝对不能保证今天 CET 中的许多国家中的每一个最后一个都会在欧盟指令长牙时选择相同的区域,无论何时。因此,如果您将约会存储为 'in the CET zone',那么到了 2024 年,您所知道的就是您现在不知道理发店的约会实际是什么时候。没那么有用。

正在协调见面时间,wthout 地点的上下文

您经常需要协调会面时间,而不是会面地点 - 例如虚拟会议。通常这些仍然是 'localized'(某人被视为主持人,他们计划会议,例如 17:30 他们的时间,所有参与者存储 ZonedDateTime 构建,他们的议程系统会告诉他们何时在适当的时间登录该会议,无论他们在地球上的哪个位置)。所以我就这么做了。

但鉴于会议是虚拟的,'a timezone is implied' 不再必须是真实的。在这种情况下,可以将内容存储为 Instant(而区域为 UTC 的 ZonedDateTimeInstant 的概念非常接近,基本上没有任何区别) ,在这种情况下,例如锤子落下的那一刻,荷兰的某人会看到他们的约会提前了一个小时。假设他们做对了,并向他们的日历工具表明约会在 UTC 时区。

您可能需要这个。但更常见的是使用 'host zone' 路线。 (没有太大区别;日历工具需要支持保存约会 时区的概念(而不是将其转换为本地时区的正确时间,因为如果主机区域或目标区域决定更改其 tz 数据,这将中断,这种情况一直发生 - 一旦日历工具支持此功能,您可以选择使用 UTC 作为此类虚拟会议的主机区域).

数据库

您或多或少正确地得出结论,有 3 个根本不同的日期和时间概念,因此 完全不兼容;您无法在不丢失任何东西的情况下将一个转换为另一个。它们是:

  • LocalDate(以及 LocalTime 和 LocalDateTime):如果您想叫醒 08:00 并且您希望闹钟继续在本地 08:00 叫醒您,即使您飞往不同的区域,这就是你想要的。
  • ZonedDateTime:用于约会。
  • 即时:记录从现在 X 秒或过去 X 秒发生或将要发生的事情;灌篮,例如日志消息上的时间戳,显然是用于 space 东西的时间戳(太阳耀斑将发生在 X - 当荷兰议会中的那把锤子落下时,一份报纸计算出太阳耀斑的确切日期,小时, 和 minute 并且早些时候打印出来,现在是错误的,当然,他们必须打印更正。如果他们打印了一个瞬间,虽然这对我们人类来说可能很笨重,但他们就不必这样做了) .

不幸的是,大多数语言(包括 java 过时的 java.util.Datejava.util.Calendar 方法)都搞砸了,没有意识到时间就是那么复杂,上面在不丢失重要细微差别的情况下,无法转换 3 个概念。

因此,DB 有时也玩得又快又松。更重要的是,JDBC 通常是 java 应用程序用来与数据库对话的 'glue',有一个 API 涉及类型 java.sql.Datejava.sql.Timestamp,这两个都是基于 j.u.Date(他们对其进行了扩展),而 j.u.Date,尽管名称如此,但 等同于 Instant - 它存储 moment-in-time,首先不存储时区,如果时区向上并更改您的定义,它会中断。

因此,您必须使用实际的 JDBC 调用来输入和输出正确的数据类型是很棘手的。理论正确答案直接是LDT和ZDT:

ResultSet rs = somePreparedStatement.query();
/* WRONG */ {
  Date x = rs.getDate(1);
  ZonedDateTime y = convertToZDT(x);
}
/* RIGHT */ {
  ZonedDateTime y = rs.getObject(1, ZonedDateTime.class);
}

但是,许多 JDBC 驱动程序不支持此功能。很痛苦。同样,要用日期替换准备好的语句中的问号,正确的做法是 ps.setObject(1, someZdtInstance),而不是 j.u.Date 实例甚至 java.sql.Date 实例。他们是否支持,oof。你必须检查 - 许多人不需要。

我建议您查看 DB 文档,了解它们支持的日期类型及其实际存储方式。如果听起来不错(也就是说,它存储实际的年、月、日、小时、分钟、秒和完整的时区名称,所有这些都在一个列中),请使用它。如果 none 的数据类型支持,请自行创建 7 列。

假设您找到了一种数据类型来完成这项工作,请确定 .getObject(x, ZDT.class) 是否有效,以及 ps.setObject(x, zdtInstance)。希望如此。如果是这样,那就是你的答案。如果没有,请四处寻找解决方法:编写转换代码并检查到达的内容 - 毕竟,如果数据库正在存储实际的 y/m/d/h/m/s/zone 信息,并且您给它一个 j.u.Timestamp object 没有 的信息,然后数据库将不得不再次将其转换回 ymdhmsz 信息。假设一切顺利,那么此解决方法不会对您造成破坏。即使约会是在阿姆斯特丹,那把锤子也会时不时地落下来。

store Booking – in the sense of “Online booking” (so participants that are from different countries/time zones needs meet in the same moment on the timeline). I tend to use LocalDateTime, since DB and Backend is set to UTC and since incoming “create booking” json message contains ISO 8601 (with offset) startDateTime & endDateTime fields.

您没有真正定义“预订”的含义。

如果你的意思是精确定位一个时刻,时间轴上的一个特定点,那么LocalDateTime正是错误的class。

例如,假设一家导弹发射公司在日本、德国和美国得克萨斯州休斯敦设有办事处。所有办公室的员工都希望在发布期间参加集体电话会议。我们将指定启动时间与 UTC 的偏移量为零。为此,我们使用 Instant class。下面字符串中的 Z 表示偏移量为零,+00:00,发音为“Zulu”。

Instant launch = Instant.parse( "2022-05-23T05:31:23.000Z" ) ;

让我们告诉每个办公室他们加入电话会议的截止日期。

ZonedDateTime tokyo = launch.atZone( ZoneId.of( "Asia/Tokyo" ) ) ;
ZonedDateTime berlin = launch.atZone( ZoneId.of( "Europe/Berlin" ) ) ;
ZonedDateTime chicago = launch.atZone( ZoneId.of( "America/Chicago" ) ) ;  // Houston time zone is "America/Chicago".

我们有四个 date-time object,它们都代表同一个时刻。

至于你的数据库session和后台服务器的当前默认时区,应该与你的编程无关。作为一名程序员,这些是你无法控制的,并且可能会发生变化。所以请务必指定您的 desired/expected 时区。

至于你问题的其余部分,我无法辨别具体问题。所以我所能做的就是评论你的例子。

LocalTime

Company has a policy that lunchtime starts at 12:30 PM at each of its factories worldwide.

是的,LocalTime 代表世界上任何地方的 time-of-day 都是正确的。

请问印度德里的工厂是否开始午餐。

LocalTime lunchStart = LocalTime.of( 12 , 30 ) ;
ZoneId zKolkata = ZoneId.of( "Asia/Kolkata" ) ;
ZonedDateTime nowKolkata = ZonedDateTime.now( zKolkata ) ;
boolean isBeforeLunchKolkata = nowKolkata.toLocalTime().isBefore( lunchStart ) ;

实际上,我们在那里有一个微妙的错误。在某些地区的某些日期,时间 12:30 可能不存在。例如,假设该日期的时区有一个夏令时 (DST) 转换,从中午到下午 1 点“提前”一小时。那样的话,就不会有12:30了。时间 13:30 将是“新的 12:30”。

要修复此错误,让我们从 nowKolkata 调整为使用一天中的不同时间。本次调整过程中,java.time会考虑夏令时等异常情况,并进行处理。

请注意,移动到新的 time-of-day 会导致新的单独 ZonedDateTimejava.time class 使用 immutable objects.

ZonedDateTime lunchTodayKolkata = nowKolkata.with( lunchStart ) ;

现在我们应该重写午餐是否开始的测试。

boolean isBeforeLunchKolkata = nowKolkata.isBefore( lunchTodayKolkata ) ;

让我们问一下午餐还有多久。 Duration class表示不依附于时间轴的时间跨度,在hours-minutes-seconds.

的尺度上
Duration duration = Duration.between( nowKolkata , lunchTodayKolkata ) ;

我们可以使用该持续时间作为确定该工厂是否开始午餐的另一种方式。如果持续时间为负,我们知道我们已经过了午餐时间——这意味着我们必须回到过去才能看到午餐时间开始。

boolean isAfterLunchKolkata = duration.isNegative() ;
boolean isBeforeLunchKolkata = ! duration.isNegative() ;

LocalDate

LocalDate class 仅表示日期,没有 time-of-day,也没有时区或 offset-from-UTC.

的上下文

请注意,对于任何给定时刻,全球各地的日期因地区而异。所以现在在日本东京可能是“明天”,同时在美国俄亥俄州托莱多可能是“昨天”。因此 LocalDate 就时间线而言本质上是模棱两可的。

Birthday - if it were crucial to know someone’s age to the very day, then a date-only value is not enough. With only a date, a person appearing to turn 18 years old in Tokyo Japan would still be 17 in Toledo Ohio US.

如果您想准确知道某人的年龄,除了出生日期和出生时区之外,您还需要他们的出生时间。仅仅出生地的日期和时区不足以将他们的年龄缩小到 18 岁的第一刻。

美国俄亥俄州托莱多所在的时区是America/New_York

LocalDate birthDate = LocalDate.of( 2000 , Month.JANUARY , 23 ) ;
LocalTime birthTime = LocalTime.of( 3 , 30 ) ;
ZoneId birthZone = ZoneId.of( "America/New_York" ) ;  
ZonedDateTime birthMoment = ZonedDateTime.of( birthDate , birthTime , birthZone ) ;

要准确判断此人是否年满 18 岁,请在出生时加上 18 岁。然后与当前时刻进行比较。

ZonedDateTime turning18 = birthMoment.plusYears( 18 ) ;
ZonedDateTime nowInBirthZone = ZonedDateTime.now( birthZone ) ;
boolean is18 = turning18.isBefore( nowInBirthZone ) ;

Due date in a contract. If stated as only a date, a stakeholder in Japan will see a task as overdue while another stakeholder in Toledo sees the task as on-time.

正确。

需要时间精度的合同必须注明日期、time-of-day 和时区。

假设合同在明年 5 月 23 日之后到期,如美国芝加哥伊利诺伊州所示。

我建议避免使用“午夜”这个不精确的概念。专注于特定区域中特定日期的一天的第一时刻。所以“23日之后”就是24日的第一刻。

某些区域中的某些日期的开始时间可能不是 00:00。让java.time确定一天的第一时刻。

LocalDate ld = LocalDate.of( 2023 , Month.MAY , 23 ) ;
ZoneId zChicago = ZoneId.of( "America/Chicago" ) ;
ZonedDateTime expiration = ld.plusDays( 1 ).atStartOfDay( zChicago ) ;
boolean isContractInEffect = ZonedDateTime.now( zChicago ).isBefore( expiration ) ; 

LocalDateTime

Christmas starts at midnight on the 25th of December 2015.

当您指的是任何地区的日期和时间时,请使用LocalDateTime

圣诞节在2015年12月25日的第一刻开始在每个地方。所以是的,使用 LocalDateTime.

LocalDateTime xmas2015 = LocalDateTime.of( 2015 , Month.DECEMBER , 25 , 0 , 0 , 0 , 0 ) ;

class 故意缺少时区或 offset-from-UTC 的上下文。因此,这个 class 的 object 就时间线而言本质上是模棱两可的。这个class不能代表一个时刻。

换句话说,圣诞老人在到达日本之前到达Kiribati(最先进的时区,比UTC早14小时),到达印度更晚,到达欧洲更晚,等等在。红色的雪橇彻夜追逐着世界上一个又一个地区的新的黎明。

让我们确定圣诞节在约旦开始的那一刻。

ZoneId zAmman = ZoneId.of( "Asia/Amman" ) ;
ZonedDateTime xmasAmman = xmas2015.atZone( zAmman) ;

将那一刻调整为美国科罗拉多州丹佛市的时区。

ZoneId zDenver = ZoneId.of( "America/Denver" ) ;
ZonedDateTime xmasStartingInAmmanAsSeenInDenver = xmasAmman.withZoneSameInstant( zDenver ) ;

如果你审问那个xmasStartingInAmmanAsSeenInDenverobject,你会你会看到当圣诞节在约旦开始时,丹佛的日期仍然是 24 日,也就是圣诞节的前一天。圣诞节要过几个小时才能到达科罗拉多州。

Booking – in the sense of “local” dentist appointment

未来打算坚持分配的time-of-day而不考虑对时区规则的任何更改的约会必须作为三个单独的部分进行跟踪:

  • 与 time-of-day
  • 约会
  • 解释该日期和时间所依据的时区。
  • 持续时间,约会将持续多长时间。

让我们预约明年 1 月 23 日下午 3 点在新西兰。

LocalDateTime ldt = LocalDateTime.of( 2023 , 1 , 23 , 15 , 0 , 0 , 0 ) ;
ZoneId z = ZoneId.of( "Pacific/Auckland" ) ;
Duration d = Duration.ofHours( 1 ) ;

这三项中的每一项都必须存储在您的数据库中。

  • 第一个可以存储在类似于 SQL 标准类型 TIMESTAMP WITHOUT TIME ZONE 的列中。注意“WITHOUT”,而不是“WITH”。
  • 时区可以存储为文本,通过其standardized name格式为Continent/Region。切勿使用 2-4 个字母 pseudo-zones,例如 ISTCST
  • 我会以标准 ISO 8601 格式存储持续时间,PnYnMnDTnHnMnSP 标记开始,T 将两部分分开。所以 1 小时是 PT1H.

当您需要创建一个时间表时,您必须确定一个时刻。为此,请组合您的零件。

LocalDateTime ldt = myResultSet.getObject( … , LocalDateTime.class ) ;
ZoneId z = ZoneId.of( myResultSet.getString( … ) ) ;
Duration d = Duration.parse( myResultSet.getString( … ) ) ;

ZonedDateTime start = ldt.atZone( z ) ;
ZonedDateTime end = start.plus( d ) ;

我和其他人已经多次解释过这个,所以搜索以了解更多信息。关键在于,世界各地的政客都表现出频繁更改其管辖范围内时区规则的倾向。而且他们这样做几乎没有预先警告。因此,下午 3 点约会的时间可能会比您现在预计的早一个小时,或者晚 half-hour,或者谁知道呢。期望您感兴趣的时区保持稳定是注定您的软件最终会失败。你可能认为我偏执。让我们在几年后回顾一下……当您预订应用程序中的所有客户都在错误的时间到达时。

Instant

Some critical function must start every day at 1:30am (LocalDateTime & ZonedDateTime are wrong in this case, because e.g. during switching from/to standard time to summer time, function runs twice/not at all).

不,不正确,不是 Instant。如果您想每天在 1:30 上午 运行 执行一项任务,则需要结合使用 LocalTimeZoneId。这在概念上与上面的约会讨论相似。

如果我们希望芝加哥办公室的服务器在凌晨 运行 该任务,我们将定义 date-time 和区域。

ZoneId z = ZoneId.of( "America/Chicago" ) ;
LocalTime targetTime = LocalTime.of( 1 , 30 ) ;

要确定下一个运行,捕捉当前时刻。

ZonedDateTime now = ZonedDateTime.now( z ) ;

看看我们是否在目标时间之前,然后计算等待时间。我们使用“not after”作为“if before or equal to”的缩写。想想如果当前时刻恰好是正好 1:30:00.000000000 AM.

if( ! now.toLocalTime().isAfter( targetTime ) ) {  // If before or equal to the target time.
    Duration d = Duration.between( now , now.with( targetTime ) ) ;
    …
}

如果今天的目标时间已过,请添加一天。然后计算到下一个 运行.

的时间
else {  // Else, after target time. Move to next day.
    LocalDate tomorrow = now.toLocalDate().plusDays( 1 ) ;
    ZonedDateTime nextRun = ZonedDateTime.of( tomorrow , targetTime , z ) ;
    Duration d = Duration.between( now , nextRun ) ;
    …
}

如果您指定的 time-of-day 在那个日期不存在于那个区域,ZonedDateTime.of 方法会自动调整。

如果您在同一本地时间使用执行程序服务 运行 您的任务,则您不能将 ScheduledExecutorService 与方法 scheduleAtFixedRatescheduleWithFixedDelay 一起使用。使用具有单个延迟的 schedule 方法。

将对该执行程序服务的引用传递给您任务的构造函数,即您的 Runnable/Callable。然后编写您的任务,使其最后一件事以新的 newly-calculated 延迟提交给执行程序服务。在大多数日子里,延迟将是 24 小时。但在夏令时 (DST) 切换等异常日子,延迟可能约为 23 小时或 25 小时。所以每个任务 运行 调度下一个任务 运行.