如何优雅地从 MSSQL Datetime2 转换为 java.time.Instant

How to elegantly convert from MSSQL Datetime2 to java.time.Instant

我有一个简单的 spring 引导 REST API 应用程序,使用普通 jdbc 从 MSSQL 数据库中获取数据。我试图弄清楚如何最好地从数据库中检索 DATETIME2 列(它不存储时区信息),并将其序列化为 UTC 时间戳(并在代码中将其视为一般)。

我的数据库服务器时区设置为 UTC。我知道存储到此列的所有内容都存储为 UTC,不幸的是我无法更改列类型。它有点像遗留数据库,所以所有需要获取的日期都会有同样的问题,因此寻找一个干净整洁的解决方案。

理想情况下,在我的 Java 应用程序中,我希望我所有的“日期”字段都是 java.time.Instant 类型,因为它易于处理并且会序列化为 json看起来像 "someDate": "2022-05-30T15:04:06.559896Z".

我看到的选项是:

  1. 使用自定义 RowMapper 来做类似 myModel.setDate(rs.getTimestamp("Date").toLocalDateTime().toInstant(ZoneOffset.UTC)); 的事情,但这看起来很冗长。我想我可以把它藏在一些实用程序中 class static function?
  2. 处处使用LocalDateTime,做myModel.setDate(rs.getTimestamp("Date").toLocalDateTime())。但是随后杰克逊将在没有时区信息的情况下对其进行序列化。
  3. 在启动时将整个应用程序时区设置为 UTC。但这可能会被其他代码改变,从我读到的内容来看,这通常是个坏主意。

我不确定我是否发现了问题。

根据定义,

Instant 值是 UTC,并且 java.sql.Timestamps 除了数据库设置隐含的区域外没有区域。您知道数据库是严格的 UTC。这对您来说是幸运的,因为它消除了一次 error-prone 转换。然后,在给定 java.sql.Timestamp#toInstant() 的情况下,读取 java.sql.Timestamps 并在 运行 时将它们保持为 Instants 是微不足道的。不要通过 LocalDateTime.

转换

这与在您的应用程序中设置任何“默认”时区无关。设计和编写您的代码,以便在内部(即 运行 时间内存和数据库)您只处理 UTC(即瞬间)。您应该将瞬间转换为本地任何东西的唯一点是在外部接口点......即

  • 当输出 date/time 值时,供人类使用或供需要特定时区的其他软件使用。
  • 从用户或其他程序读取 date/time 值时(如果不明确,您需要知道任何隐含区域)

将您的“默认”时区保留为您的环境为您提供的任何时区。然后,无论你的代码在运行ning哪里,都会产生有意义的本地dates/times.

建立一个严格的规则,只在内部处理 UTC。这将使对您的代码的推理在长 运行.

中变得更加简单

我想唯一真正的绊脚石是意识到必须在本地区域完成取决于本地条件的事情,例如日期边界......但是编写代码以在内部“思考”UTC。

警告: 我不是 Spring.

的用户

时刻对比not-a-moment

您需要弄清楚 date-time 处理的一个基本问题:力矩与 not-a-moment。

  • “时刻”是指时间轴上的特定点。甚至不用考虑时区之类的问题,我们都知道时间在向前流动,一次一个时刻。每个时刻对于世界各地的每个人来说都是同时的(坚持牛顿时间,在这里忽略爱因斯坦相对论)。要跟踪 Java 中的某个时刻,请使用 InstantOffsetDateTimeZonedDateTime。这是表示时间轴上特定点的三种不同方式。
  • “not-a-moment”是指带有 time-of-day 的日期,但缺少时区或 offset-from-UTC 的上下文。如果我在没有时区上下文的情况下对你说“明天中午给我打电话”,你将无法知道你应该在日本东京中午、法国图卢兹中午还是中午打电话在美国俄亥俄州托莱多——三个截然不同的时刻,相隔几个小时。对于 not-a-moment,使用 LocalDateTime.

所以永远不要将 LocalDateTime 与其他三个 class、InstantOffsetDateTimeZonedDateTime 混用。你会混合 your apples with your oranges.

你说:

I would ideally like all my "date" fields to be of type java.time.Instant

是的,我同意通常使用 Instant 作为任何 Java 对象跟踪的成员字段。这通常是个好主意——但只是暂时的。对于 not-a-moment,如上所述,您应该改用 LocalDateTime

TIMESTAMP WITH TIME ZONE

另一个问题,Instant 未映射到 JDBC 4.2 及更高版本中。一些 JDBC 驱动程序可以选择处理一个 Instant 对象,但这不是必需的。

因此将您的 Instant 转换为 OffsetDateTimeOffsetDateTime class 在 JDBC 中映射到类似于 SQL-standard 类型 TIMESTAMP WITH TIME ZONE 的数据库列.

OffsetDateTime odt = instant.atOffset( Offset.UTC ) ;

正在写入数据库。

myPreparedStatement.setObject( … , odt ) ;  // Pass your `OffsetDateTime` object. 

检索。

OffsetDateTime odt = myResultSet.getObject( … , OffsetDateTime.class ) ;

TIMESTAMP WITHOUT TIME ZONE

对于类似于 SQL-standard 类型 TIMESTAMP WITHOUT TIME ZONE 的数据库列,使用 LocalDateTime class.

正在写入数据库。

myPreparedStatement.setObject( … , ldt ) ; // Pass your `LocalDateTime` object. 

检索。

LocalDateTime ldt = myResultSet.getObject( … , LocalDateTime.class ) ;

指定时区

你说:

My DB server timezone is set to UTC.

那应该是无关紧要的。始终以 依赖 JVM 当前默认时区、主机 OS 当前默认时区或数据库的当前默认时区。作为程序员,所有这些都不在您的控制范围内。

明确指定您的 desired/expected 时区。

从数据库中检索时刻,并调整到所需的时区。

OffsetDateTime odt = myResultSet.getObject( … , OffsetDateTime.class ) ;
ZoneId z = ZoneId.of( "Africa/Tunis" ) ;
ZonedDateTime zdt = odt.atZoneSameInstant( z ) ;

生成本地化到用户首选区域的文本。

Locale locale = Locale.JAPAN ; 
DateTimeFormatter f = DateTimeFormatter.ofLocalizedDateTime( FormatStyle.LONG ).withLocale( locale ) ;
String output = zdt.format( f ) ;

DATETIME2 MS SQL 服务器

MS 中的类型 DATETIME2 类型 SQL 服务器以 [​​=184=] 存储日期,但缺少时区或 offset-from-UTC.[=56= 的上下文]

这正是用于存储时刻的错误类型。如上所述,该类型类似于 SQL 标准类型 TIMESTAMP WITHOUT TIME ZONE,并映射到 Java class LocalDateTime.

根据您的评论,您似乎明白了这一事实:

I know that everything stored to this column is stored as UTC and I cannot change the column type unfortunately. It's a bit of a legacy DB …

我要指出,您不知道该列中的值旨在表示偏移为零的时刻。你可以期待那样,希望那样。但是如果不使用数据库类型系统的保护,你就无法确定。每个用户、每个 DBA 和每个系统管理员都必须始终意识到这种不幸的情况,并且必须始终做正确的事情。你需要很多运气。

我必须提到,理想的解决方案是重构您的数据库,以更正该列数据类型的错误选择。但我知道这可能是一个繁重且具有挑战性的修复。

鉴于这种不幸的情况,没有可行的修复方法,该怎么办?

您列出的选项 1、2 和 3

选项 1

关于您的选项 # 1,是的,这对我来说很有意义。除了两件事:

  • 我会更准确地更改您的模型方法的名称:setInstant。或者使用描述性的公司名称,例如 setInstantWhenContractGoesIntoEffect.
  • 永远不要在 Java 中使用可怕的遗留 date-time classes,例如 Timestamp。改变这个:
myModel.setDate(rs.getTimestamp("Date").toLocalDateTime().toInstant(ZoneOffset.UTC));

…至:

myModel
    .setInstantWhenContractGoesIntoEffect
    ( 
        resultSet
        .getObject( "Date" , LocalDateTime.class )  // Returns a `LocalDateTime` object. 
        .toInstant( ZoneOffset.UTC )                // Returns an `Instant` object.
    )
;

选项 2

至于您的选项#2,我不太确定您的想法。但我的印象是那将是错误的方法。我认为,为了 long-term 在没有“技术债务”的情况下进行维护并避免混淆和事故,最好的方法是“说实话”。当你实际上有驴时,不要假装有斑马。所以:

  • 在数据库方面,要清楚明确地说明您有一个带时间的日期,但缺少偏移量的上下文。添加大量文档来解释这是基于错误的设计,我们打算存储 mome以 UTC 显示的 ts。
  • 在应用程序端,Java 端,只处理 InstantOffsetDateTimeZonedDateTime 对象,因为在数据模型中我们表示时刻.所以使用 classes 代表一个时刻。所以 使用 LocalDateTime 你真正指的是时间轴上的特定点。

显然,数据库端和应用程序端之间存在某种分界线。越过那条线,您必须暂时在 Java 类型和伪造它的数据库类型之间进行转换。你画那条线的地方,那个过渡区,由你决定。

选项 3

至于您的选项 # 3,是的,那将是一个非常糟糕的主意。

设置这样的默认值是不可靠的。任何系统管理员,甚至不幸的 OS 更新,都可能更改 OS 当前的默认时区。对于数据库的当前默认时区也是如此。对于 JVM 当前的默认时区也是如此。

因此,您最终得到了三个可能会发生变化的默认时区,每个时区都会影响您环境的不同部分。更改任何这些地方的当前默认时区会立即影响所有其他依赖该默认时区的软件,而不仅仅是您的特定应用程序。

如上所述,我建议恰恰相反:代码不依赖任何地方的默认时区。

访问默认时区的一个地方是可能,以便向用户展示。但即便如此,如果上下文至关重要,您必须与用户确认 desired/expected 时区。在您确实使用当前默认时区的地方,请明确而不是隐含地这样做。也就是说,进行显式调用,例如 ZoneId.getSystemDefault() 而不是使用省略的可选参数。