简体   繁体   中英

Java API and client app - how to handle timezone properly

I'm developing a Java API that is being used by an android app. I have arrived to a point where I need to correctly handle date and time taking into account timezones.

One of the features of the app is to book some sort of service specifying a date and a time. There is the possibility for the user to cancel the booking and if the cancellation occurs 24h before the booking starts, the client gets all the amount refunded.

Now let's say the server is in London(gmt 0) and the user in Spain(gmt +1) and a booking start on 25-02-2015 at 16:00:00.

When the user cancels a booking the server needs to make the difference between NOW() and the booking start date. So if the user (in spain) makes a cancelation the 24-02-2015 at 17:00:00(spain time, 23hours before booking ; therefore he doesn't get full refund) when the server checks the difference, because the NOW (it is 16:00:00 in UK) the result will be 24h and therefore will refund full amount wrongly.

My question is here. How do I correctly get the RIGHT amount of hours depending on the user timezone ? I'm not very happy with sending the client time zone in the cancellation request because the value can easily be tricked by the user.

What is a good practice when storing date and time sever side ? Should I store them in the server time and use an extra field to know the client timezone offset's ?

The Answer by Baranauskas is correct.

Work in UTC

Generally all your business logic, data exchange, data storage, and serialization, should be done in UTC . Apply a time zone only where required/expected, such as presentation to a user.

java.time

Definitely use java.time framework built into Java 8 and later.

By default, the java.time classes use standard ISO 8601 formats for parsing/generating textual representations of date-time values. Otherwise use a DateTimeFormatter object.

The Local… types are generic, not applying to any locality, having no time zone or offset-from-UTC . Generally not used in business apps as they are not actual moments on the timeline. Used here for parsing a date string and a time string separately, combining, and then applying the known time zone for Spain. Doing so produces a ZonedDateTime , an actual moment on the timeline.

LocalDate dateBooking = LocalDate.parse ( "2016-02-25" ); // Parses strings in standard ISO 8601 format.
LocalTime timeBooking = LocalTime.parse ( "16:00:00" );
LocalDateTime localBooking = LocalDateTime.of ( dateBooking , timeBooking );
ZoneId zoneId = ZoneId.of ( "Europe/Madrid" );
ZonedDateTime zonedBooking = localBooking.atZone ( zoneId );

From that we can extract an Instant , a moment on the timeline in UTC.

Instant booking = zonedBooking.toInstant ();

Calculate our 24 hours of required notice for cancellations. Note that 24-hours is not the same as “one day” as days vary in length because of anomalies such as Daylight Saving Time (DST).

Instant twentyFourHoursEarlier = booking.minus ( 24 , ChronoUnit.HOURS );

Get the current moment for the user in process of cancelling. Here we simulate by using the date-time specified in the Question. For shorter code, we adjust by an hour because this parse method handles only UTC ( Z ) strings. The Question stated 17:00 in Spain time; Europe/Madrid is one hour ahead of UTC so we subtract an hour for 16:00Z.

Instant cancellation = Instant.parse ( "2016-02-24T16:00:00Z" );  // 17:00 in Europe/Madrid is 16:00Z.
//  Instant cancellation = Instant.now ();  // Use this in real code.

Compare Instant objects by calling the isBefore or isAfter methods.

Boolean cancelledEarlyEnough = cancellation.isBefore ( twentyFourHoursEarlier );

A Duration represents a span of time as a total number of seconds plus a fraction of a second in nanoseconds. We use this here to help with the mathematics, verifying we got the expected result of 23 hours. The toString method uses the standard ISO 8601 durations format of PT23H where P marks the beginning, T separates the years-months-days portion from hours-minutes-seconds portion, and H means “hours”.

Duration cancellationNotice = Duration.between ( cancellation , booking );

Dump to console.

System.out.println ( "zonedBooking: " + zonedBooking + " | booking: " + booking + " | twentyFourHoursEarlier: " + twentyFourHoursEarlier + " | cancellation: " + cancellation + " | cancelledEarlyEnough: " + cancelledEarlyEnough + " | cancellationNotice: " + cancellationNotice );

zonedBooking: 2016-02-25T16:00+01:00[Europe/Madrid] | booking: 2016-02-25T15:00:00Z | twentyFourHoursEarlier: 2016-02-24T15:00:00Z | cancellation: 2016-02-24T16:00:00Z | cancelledEarlyEnough: false | cancellationNotice: PT23H

Present to user by applying their desired/expected time zone. Specify an Instant and a ZoneId to get a ZonedDateTime .

ZoneId zoneId_Presentation = ZoneId.of( "Europe/Madrid" );
ZonedDateTime zdtBooking = ZonedDateTime.ofInstant( booking , zoneId_Presentation );
ZonedDateTime zdtCancellationGrace = ZonedDateTime.ofInstant( twentyFourHoursEarlier , zoneId_Presentation );
ZonedDateTime zdtCancellation = ZonedDateTime.ofInstant( cancellation , zoneId_Presentation );

Let java.time localize for you by specifying a short-medium-long flag and a Locale for the human language in which to (a) translate name of day/month, (b) use cultural norms for ordering of the date-time parts, choosing comma versus period, and such.

DateTimeFormatter formatter = DateTimeFormatter.ofLocalizedDateTime( FormatStyle.FULL );
formatter = formatter.withLocale( new Locale("es", "ES") );
String output = zdtBooking.format( formatter );

jueves 25 de febrero de 2016 16H00' CET

Ignore Server Time Zone

Your Question mentioned the server's time zone. You should never depend on the server's time zone as it is out of your control as a programmer, and it is easily changed with little thought by a sysadmin. In addition, your JVM's current default time zone may be based on the that of the host operating system. But not necessarily. A flag on the command-line launching of the JVM may set the JVM's default time zone. Even more dangerous: Any code on any thread of any app within the JVM can set the time zone and immediately affect your app -- during runtime!

If you do not specify a time zone, the JVM's current default is implicitly applied. Always specify your desired/expected time zone explicitly, as seen in code above.

The above also goes for the current default Locale . Always specify the desired/expected Locale rather than depending implicitly on the JVM's current default.

The plane is leaving at the same moment in time, no matter where in the world you are. This is also known as a timestamp. For such situations, storing a timestamp would be a proper solution. In java, current timestamp may be retrieved via System.currentTimeMillis() . This value does not depend on the time zone of your server and contains amount of millis since 1970 in UTC.

When user books the flight, you will need to convert user's selected time+timezone into the timestamp. When displaying, timestamps should be converted to user's timezone.

Validation with timestamps is a simple operation then: planeTakeOffTs - NOW > 24*60*60*1000

You may also want to use such library as joda-time (which has been included into inspired Java v8 java.time) to handle the dates and time.

Basil Bourque , to answer your last comment, I did the test below. As you can see, setting the timezone has effect to LocalDateTime and fortunately. Where you right is this is not the right solution and should be used if we do not have another solution.

public static void main(String[] args) {

    Instant instant = Instant.now();
    LocalDateTime local = LocalDateTime.now();
    ZonedDateTime zone = ZonedDateTime.now();

    System.out.println("====== WITHOUT 'UTC' TIME ZONE ======");
    System.out.println("instant           : " + instant );
    System.out.println("local             : " + local);
    System.out.println("zone              : " + zone);
    System.out.println("instant converted : " + instant.atZone(ZoneId.of("Europe/Paris")).toLocalDateTime());
    System.out.println("====================================");

    TimeZone.setDefault(TimeZone.getTimeZone("UTC"));

    Instant instant2 = Instant.now();
    LocalDateTime local2 = LocalDateTime.now();
    ZonedDateTime zone2 = ZonedDateTime.now();

    System.out.println("====== WITH 'UTC' TIME ZONE ======");
    System.out.println("instant2           : " + instant2 );
    System.out.println("local2             : " + local2);
    System.out.println("zone2              : " + zone2);
    System.out.println("instant2 converted : " + instant2.atZone(ZoneId.of("Europe/Paris")).toLocalDateTime());
    System.out.println("==================================");
}

And the output :

====== WITHOUT 'UTC' TIME ZONE ======
instant           : 2019-02-14T17:14:15.598Z
local             : 2019-02-14T18:14:15.682
zone              : 2019-02-14T18:14:15.692+01:00[Europe/Paris]
instant converted : 2019-02-14T18:14:15.598
====================================
====== WITH 'UTC' TIME ZONE ======
instant2           : 2019-02-14T17:14:15.702Z
local2             : 2019-02-14T17:14:15.702
zone2              : 2019-02-14T17:14:15.702Z[UTC]
instant2 converted : 2019-02-14T18:14:15.702
==================================

Note that as many developers use Hibernate, it provides this property hibernate.jdbc.time_zone which helps a lot when dealing whith date. Setting this property to UTC has effect to LocalDateTime and ZonedDateTime .

The technical post webpages of this site follow the CC BY-SA 4.0 protocol. If you need to reprint, please indicate the site URL or the original address.Any question please contact:yoyou2525@163.com.

 
粤ICP备18138465号  © 2020-2024 STACKOOM.COM