Sunday, January 28, 2018

Add time-zone sense . . .

As today's applications run from the Cloud hardware and serve requests coming in from time-zones spread across the globe, date-time-zone handling becomes more sensitive than ever. Applications need to be designed and built with this sensible capability and need to have not just Date-Time sense but also added Zone sense.

Recently, I had to work on adding Timestamp and Zone capability to a Grails3 application. The initial implementation & business-logic around a couple of Date-Time sensitive fields was written just to track Date in MM/dd/yyyy format using java.util.Date. The new requirement was to capture date, timestamp and timezone. Sounds simple, but not really ;). It actually involved a bit of exploration on many fronts including: Java, Hibernate, Grails and PostgreSQL database.

Environment: Grails 3.2.3, Java 8, PostgreSQL on MacOS High Sierra 10.13.2

First Steps - Towards the right direction (Java 8 Date-Time classes)

Java 8 Date Time classes

I did some first-hand reading on Java 8 date-time classes from java.time package and an up-front research on the support for these classes in PostgreaSQL, Hibernate, Spring Boot and Grails.

Java 8 (at last) added fluent date-time-zone API classes that are immutable, thread-safe and based on ISO 8601 calendar. I looked into details of few classes like: Instant, LocalDateTime, ZonedDateTime and OffsetDateTime. From Java docs, OffsetDateTime is the class recommended for modeling date-time concepts in more detail and when communicating with database/networking. After experimenting little bit all these classes, without further exploration I chose java.time.OffsetDateTime class over java.util.Date.

java.time.Instant - an instantaneous point on the time line in UTC zone
e.g 2018-01-21T12:21:12.122Z indicating Jan 21, 2018 12:21 pm 12 seconds and 122 milliseconds, Z stands for UTC timezone)

java.time.LocalDateTime - date-time with no timezone information
e.g. 2018-01-21T07:21:12.122 indicating Sun Jan 21, 2018 07:21 am 12 seconds and 122 milliseconds with no timezone, as my system clock is set to EST, this is the time in EST America/New_York zone

java.time.ZonedDateTime - current date time in the current timezone with offset from UTC
e.g. 2018-01-21T07:21:12.122-05:00[America/New_York] indicating Sun Jan 21, 2018 07:21 am 12 seconds and 122 milliseconds with timezone offset and timezone. As my system clock is set to EST, this is the time in EST America/New_York zone. It's more like LocalDateTime with offset from UTC timezone but with local timezone)

java.time.OffsetDateTime - current date time in the current timezone with offset from UTC
e.g. 2018-01-21T07:21:12.122-05:00 indicating Sun Jan 21, 2018 07:21 am 12 seconds and 122 milliseconds with timezone offset. As my system clock is set to EST, this is the time in EST with -05:00 offset that indicates the zone. It's more like LocalDateTime with offset from UTC timezone. Offset indicates the zone.)

Other useful classes

Next Steps - Along the path (PostgreSQL)

PostgreSQL

It is quite common to leverage Grails Database Migration Plugin in a Grails application to manage database changes. When model changes, running the plugin provided dbm-gorm-diff command generates schema changes as a groovy script. For java.util.Date and java.time Date classes the plugin generated change-log script will have column types set to: TIMESTAMP WITHOUT TIME ZONE for PostgreSQL.

For instance, if a domain class had two new properties:
Agreement.groovy
class Agreement { ... Date signedDate Date expirationDate ... }

Then running dbm-gorm-diff
e.g. grails -Dgrails.env=development dbm-gorm-diff agreement-changes.groovy

would have change-log script generated for the model changes as follows:
databaseChangeLog = { changeSet(author: "Gpottepalem (generated)", id: "1234567890123-1") { createTable(tableName: "agreement") { ... column(name: "signed_date", type: "TIMESTAMP WITHOUT TIME ZONE") { constraints(nullable: "false") } column(name: "expiration_date", type: "TIMESTAMP WITHOUT TIME ZONE") { constraints(nullable: "false") } ... } } }

That clearly indicates NO time-zone details are stored in the database.

Move on - Application Code Changes (Grails)

Grails application code

Step-1 Dependencies - Add compile-time and run-time dependencies to gradle build script.

PostgreSQL jdbc driver
- Use the correct PostgreSQL JDBC driver for Java 8 date time classes support. Make sure you are using JRE 8+ driver. If not, change it:

Hibernate - Next add hibernate Java8 compile time dependency.

Grails - Then add grails Java8 dependency plugin

Following is the Gradle build script with these dependencies added:
build.gradle
dependencies { ... runtime "org.postgresql:postgresql:42.1.4" //Java JDBC 4.2 (JRE 8+) driver for PostgreSQL database compile 'org.hibernate:hibernate-java8 //hibernate java 8 java.time classes support compile "org.grails.plugins:grails-java8" //grails java 8 java.time classes support ... }

Step-2 Domain Model - Change Date types to OffsetDateTime

Agreement.groovy
class Agreement { ... OffsetDateTime signedDate OffsetDateTime expirationDate ... }

Assuming that the model had Date types earlier and the generated dbm change-log script: agreement-changes.groovy was already applied to database, now as the domain object properties are changed from java.util.Date to java.time.OffsetDateTime, we need to generate change-log script for these changes and apply them to database. But, the command dbm-gorm-diff will not generate the change-log script to have the right PostgreSQL type: TYPE TIMESTAMP WITH TIMEZONE. So, it requires a manual dbm change-log like:

e.g. agreement-date-type-changes.groovy
databaseChangeLog = { changeSet(author: 'gpottepalem', id: 'alter signed_date to store time zone') { grailsChange { change { sql.execute('ALTER TABLE myschema.my_table ALTER COLUMN signed_date TYPE TIMESTAMP WITH TIME ZONE') } } } changeSet(author: 'gpottepalem', id: 'alter expiration_date to store time zone') { grailsChange { change { sql.execute('ALTER TABLE myschema.my_table ALTER COLUMN expiration_date TYPE TIMESTAMP WITH TIME ZONE') } } } }

With this schema change applied to database, when the domain object is saved, OffsetDate gets stored in PostgreSQL database with timestamp and offset indicating the time zone.

Step-3 Views - JSON views

If you have JSON views in the application, you need to make sure that the OffsetDateTime fields' data looks good and is as expected in the JSON output produced. Unlike java.util.Date fields, java.time.OffsetDateTime fields output data won't get formatted. The JSON output for OffsetDateTime fields not only contain all of it's direct properties but also recursive properties of all of it's object references accessible via getXXX() methods. e.g. OffsetDateTime's getOffset() method's returned ZoneOffset object's getAvailableZoneIds() method's returned all zoneIds will show up as well.

For instance, if you have a view under grails-app/views/agreement/_agreement.gson like:
model { Agreement agreement } json.agreement { signedOn agreement.signedDate expiresOn agreement.expirationDate }

You will have it rendered in JSON as:
{ "agreement": { "signedOn": { "dayOfMonth": 27, "dayOfWeek": "SATURDAY", "dayOfYear": 27, "hour": 12, "minute": 44, "month": "JANUARY", "monthValue": 1, "nano": 356000000, "offset": { "availableZoneIds": [ "Asia/Aden", "America/Cuiaba", "Etc/GMT+9", ... ], "id": "-05:00", "rules": { "fixedOffset": true, "transitionRules": [], "transitions": [] }, "totalSeconds": -18000 }, "second": 35, "year": 2018 }, "expiresOn": { "dayOfMonth": 27, ... } } }

A quick solution is to simply convert java.time.OffsetDateTime to java.util.Date or simply stringify using toString() depending on your needs. The following gson template:
import java.time.Instant import java.time.OffsetDateTime model { Agreement agreement } json.agreement { signedOn agreement.signedDate.toString() //current zone date-time with offset from UTC expiresOn Date.from(agreement.expirationDate.toInstant()) //OffsetDateTime to Date in UTC timezone }
will produce the output as:
{ "agreement": { "signedOn": "2018-01-27T17:10:57.443-05:00", "expiresOn": "2018-01-27T22:10:57Z" } }

Gotcha

PostgreSQL stores offset when OffsetDateTime field is persisted. But when queried, will not give stored offset back. Instead, gives data in the timezone the database is set (for instance UTC) with no offset. Databases add their own steep learning curve to deal with date-time-zone data ;)

Tip

Grails console is the handy tool for explorations with changes like this in a Grails application.
e.g. A simple Grails script to persist and retrive Agreement object
import com.my.app.Agreement import java.time.OffsetDateTime println Agreement.count() Agreement agreement = new Agreement( signedDate: OffsetDateTime.now(), expirationDate: OffsetDateTime.now().plusYears(1) ).save(flush: true, failOnError: true) println Agreement.count() Agreement.all.each { println "id:${it.id}, signedDate:${it.signedDate} expirationDate:${it.expirationDate}" }

Summary

Date - is the central issue of Y2K problem which costed IT industry billions of dollars and isn't trivial yet. Even in modern languages and frameworks, it hasn't become as primitive as it can become, and dealing with it requires a special care and attention from all levels of the application right from presentation, business logic, and up to the persistence.

In modern Java applications, Java 8's added date.time classes have become better if not any simpler, but is a definitive way to go with, when dealing with date, time and zone in present times!

References


No comments:

Post a Comment