The first article in this series, “Handling time zones in distributed systems, Part 1,” discussed how to consider customers’ time zones. It’s a complex topic, and after a bit of discussion, I recommended that the most efficient Java solution uses the JSR 310 java.time API. This article shows how to implement zoned date and time operations into a REST-based end-to-end service called FizzBus.
The code will run on Java 18 or later. If you have installed the correct JDK version, you are ready to clone the project and run it locally, either within your favorite IDE or from the command line as follows.
First, clone the ZonedDT-in-distributed-system project from my GitHub account.
Next, go to the project root and run the following Maven command, after making sure that your environment is set to Java 18:
[mtaman]:~ mvn spring-boot:run
Alternatively, you can open the project in your IDE and run the app main class, com.sxi.lab.fizzbus.FizzBusApplication.
You can access the code’s Swagger documentation at http://localhost:8090/api/v1/fizz-bus/doc/index.html; this will be used for testing the application.
What is the FizzBus service?
First, let me explain what the FizzBus service is. It’s a simple example of a fleet management system developed as a Spring Boot–based project with RESTful-based APIs for customers.
The FizzBus service uses RESTful-based controllers to connect to business logic that resides in the services layer. The business service layer connects to a database through the repository layer to show how to implement zoned date and time operations in distributed systems correctly—that is, by considering different users’ time zones with Daylight Saving Time (DST) when saving, updating, and searching for data in the database using the architecture shown in Figure 1.
Figure 1. FizzBus service architecture
Because this is a Spring Boot–based application, the persistence layer is implemented with Spring Data, and I use the H2 Database Engine for persistence. The repository layer consumes this data layer to provide core Create, Read, Update, and Delete (CRUD) functionality to the business logic implemented on the service layer. The controller defines the REST endpoints with the appropriate HTTP methods and is documented using Swagger APIs.
The controller uses the Data Transfer Object (DTO) pattern to map the entity attributes into the required API payload attributes. This decouples domain entities from the API request/response payload data. Data transfer objects are implemented as Java records. I use the MapStruct model mapper library to convert Java records into JPA domain model entities and vice versa in the service layer to avoid manual function mapping. It supports deep object mapping as well.
Exception handling is managed with a control advisor to keep the API controllers much cleaner. It is implemented as a shared-component application bean managed by the Spring Inversion of Control (IoC) container. Furthermore, the exception advisor will handle all the application-specific errors; usually, you don’t need to throw language default exceptions to other layers to maintain application layers more independently.
The standard date and time formatting and conversion methods are defined through a utility class that is shared between all layers.
I won’t go into details to explain every piece of the project code. Instead, I’ll explain the essential parts that highlight the areas concerned with the date and time implementation for receiving data from the client, parsing it, saving it to the database, and fetching it back to return it to the client in the client’s time zone. Be sure to explore the code in my GitHub account.
Date and time end-to-end implementation
When you’re using Java 8’s Date and Time API, consider the following:
- The Instant class is for computers, while the date and time variant classes are for humans. The Instant class is the natural way of representing the time for computers, but it is often useless to humans. It’s usually preferred for storage (for example, in a database), but you may need to use other classes such as ZonedDateTime when presenting data to a user.
- The database connection should always be set to Coordinated Universal Time (UTC). Fortunately, this is a configuration setting you make when creating the connection pool; I’ll show you how to make conversion automatic.
- The time zone should always be present for conversion and for searching from Instant to any date and time equivalent classes.
- JPA date and time fields could be Instant, LocalDate for only date storage, or ZonedDateTime as a time stamp equivalent.
- In the API controller, the value object class could contain the date and time fields in the form of LocalDateTime or a String to be parsed later.
For handling conversions, I’ll show you how to use some utility classes attached to the service layer to do the manual part. You’ll also use framework-provided features in the database to handle automatic conversions.
Automatically force database date and time conversions to UTC
You can use version 5.2 or later of Hibernate Object/Relational Mapping (ORM) to perform the automatic conversion to UTC by adding the following configuration, based on your project nature:
◉ If you are using a standalone JPA-based implementation or Jakarta EE, add the following configuration property into the properties.xml configuration file:
<property name="hibernate.jdbc.time_zone" value="UTC"/>
◉ If you are using Spring Boot, add this property to the application.properties yaml file:
spring.jpa.properties.hibernate.jdbc.time_zone = UTC
Based on previous settings from persisting data, all date and time attributes will be converted into UTC by the framework itself. Because FizzBus is a Spring Boot–based project, this property is set in the application.yaml file located under the resources folder. Using this setting will help remove redundant code needed for manual conversion between client-specific zoned date and time information to UTC when date and time fields are saved to the database. Instead, all such fields will automatically be converted to UTC by the framework before information is sent to the database.
Database design considerations
Let’s start with the database where the data will reside; remember, I am using H2. Figure 2 is the entity relationship diagram (ERD) for the FizzBus application.
Figure 2. FizzBus entity relationship diagram
The heart of the application is the TRIP table. As you can see in the ERD, the TRIP table has the columns shown in Table 1.
Table 1. Columns in the TRIP table
The START_ON and END_AT columns are of type timestamp (called TIMESTAMP in H2), which holds all the date and time information plus time zone information. (In this application, the time zone will be UTC.) And if you need to save only the date part, for example, your customer’s birthday, the column should be of date type. By the way, when you design your database, please don’t define everything as a timestamp, because that adds complexity and wastes storage. Use timestamp only where it’s needed.
When the application is launched, all the tables will be prepopulated with data except for the trip data. You’ll use REST APIs to create trips and then search for and retrieve the trips.
You can check all the data from the database by visiting the database console at http://localhost:8090/api/v1/fizz-bus/db-console and filling in the following attributes:
Driver class: org.h2.Driver
JDBC URL: jdbc:h2:./fbdb/FizzBusDB
User name: sa
Password: sa
Click test. You should see “Test successful” in a green bar below the login bar. Next, click the connect button. On the left side, click any table, and from the right-side console, click the run button, and check the data.
The prepopulated data will be used to create a trip. The scripts used to populate the database are located under the resources/db folder, which is automatically picked by the framework after the database is created.
Domain-model design considerations
The domain-model relationship diagram for FizzBus, shown in Figure 3, is connected to data tables in the form of JPA entities. It is like the ERD, except it shows the mapping of relevant attributes in Java. The most vital part to consider is that any database field of type timestamp is mapped to one of the following java.time package classes (ZonedDateTime, OffsetDateTime, and Instant). For example, the date type is mapped to java.time.LocalDate.
Figure 3. FizzBus domain-model relationship diagram
As you can see in Figure 3, the Trip entity fields named startOn and endAt are of type ZonedDateTime, and the Customer entity field named birthdate is of type LocalDate.
FizzBus REST endpoints
Figure 4 shows the exposed REST APIs for this application.
Figure 4. FizzBus Service Swagger API
Those REST endpoints (which can be accessed from http://localhost:8090/api/v1/fizz-bus/doc/index.html) manage the trip CRUD operations; you’ll use them to save and retrieve trip details in multiple time zones. All endpoints are exposed using the Swagger user interface tool.
Here is an example of how the payload looks; I need to pass it by using either the POST or BATCH HTTP method to create one or more trips.
Using the Swagger API, expand the POST tab, click the try it out button, and then add a JSON payload in the request body input field, as follows:
{
"timezone": "Europe/Belgrade",
"start_on": "2022-07-13T01:32:08.213Z",
"end_at": "2022-07-13T01:59:08.101Z",
"distance": 210.2,
"status": "Started",
"car_id": 1,
"driver_id": 1,
"customer_id": 1
}
Click the execute button to create the trip.
If you want to use the curl command-line tool instead of Swagger, you can create a trip as follows:
curl -X 'POST' \
'http://localhost:8090/api/v1/fizz-bus/trips' \
-H 'accept: */*' \
-H 'Content-Type: application/json' \
-d '{
"timezone": "Europe/Belgrade",
"start_on": "2022-07-13T01:32:08.213Z",
"end_at": "2022-07-13T01:59:08.101Z",
"distance": 210.2,
"status": "Started",
"car_id": 1,
"driver_id": 1,
"customer_id": 1
}'
Once this data is passed to the controller, the controller will create a trip entity that persists in the database.
You can fetch the saved data in any time zone by getting trip details. The resource path is marked with query parameters to search for a trip by ID (with a value of 1). You need to determine in what time zone you are going to access this specific data (Europe/Sofia, although the data is created in a different time zone). You can use Swagger or use curl as follows:
curl -X 'GET' \
'http://localhost:8090/api/v1/fizz-bus/trips/1?tz=Europe%2FSofia' \
-H 'accept: */*'
The result is the following payload:
{
"record_timezone": "Europe/Belgrade",
"id": 1,
"start_on": "2022-07-13 00:32:08",
"end_at": "2022-07-13 00:59:08",
"record_age": "1 days",
"distance": 210.2,
"status": "Started",
"car": {
"id": 1,
"model": "Toyota",
"color": "Green",
"chassis_number": "C100",
"branch": "Moscow Office",
"company": "FizzBus"
},
"driver": {
"id": 1,
"name": "Osvaldo Walter",
"license_number": "IO48464"
},
"customer": {
"id": 1,
"name": "Adam Leblanc",
"birthdate": "1984-10-15"
}
}
This payload provides information about the driver, the customer, and the car, in addition to other data that is critical to analyze in this example, such as the start_on and the end_at fields after they are fetched for different time zones. The program will evaluate the record age usually once after creating a trip from one time zone. Because the data is converted to a specific UTC time while the trip is saved to the database, you can access it from multiple time zones.
FizzBus service test execution plan
Now it’s time to run a test execution plan that will reveal how the system saves data in one time zone and fetches the same record in different time zones. Table 2 shows the plan to be executed against the FizzBus service.
Table 2. Test execution plan
Here are the steps of the plan.
1. First, a request is sent to start a trip in the Africa/Cairo time zone.
2. The application will convert the time to UTC format because the Spring configuration is set for that. You’ll see what a UTC recorded time in the database looks like.
3. Finally, the same record will be retrieved in the four different “fetched” time zones shown in Table 2 and the start and end dates will be checked. You’ll see what the record age is, and this record age should not have been changed, showing that the conversion is accurate.
For reference, you can examine a time zone map.
Use Swagger to create a trip with the following payload:
{
"timezone": "Africa/Cairo",
"start_on": "2022-07-13T01:32:08.213Z",
"end_at": "2022-07-13T01:59:08.101Z",
"distance": 510.2,
"status": "Ended",
"car_id": 1,
"driver_id": 1,
"customer_id": 1
}
Pay attention to the timezone field to create this trip and the start_on and end_at field values according to the Cairo time zone. When I created the record, I saw the HTTP status of 201, showing the record was created successfully.
Table 3 shows a snapshot from the database record just saved.
Table 3. Snapshot of the saved database record
As expected, the times for the start_on and end_at fields were converted to UTC times by default (see Table 4). Notice the difference, which is 2 hours behind the original request’s date and time values. The day is changed as well to be 12.07.2022.
Table 4. Converted UTC values
Now, you can fetch the same record in the same time zone to see whether the time is appearing accurately. Using the Swagger API, you can use either the get all trips or get trip by id endpoint. The time zone is Africa/Cairo. The result is correctly retrieved, and the date and time are accurately presented to indicate the original request, as follows:
{
"record_timezone": "Africa/Cairo",
"start_on": "2022-07-13 01:32:08",
"end_at": "2022-07-13 01:59:08",
"record_age": "3 days",
"id": 1,
………
}
Note that the record age is 3 days, which is correct because I ran the code on 17.07.2022. You can check the DateTimeUtil class’s calculateRecordAge() method implementation to see how the record age is calculated. Also, note that the record_timezone is the original time zone used to create the record, so it will never change for this record.
Now, retrieve the same record using the other four time zones. First retrieve the record with the time zone Europe/Belgrade, GMT+2, as follows:
{
"record_timezone": "Africa/Cairo",
"start_on": "2022-07-13 01:32:08",
"end_at": "2022-07-13 01:59:08",
"record_age": "3 days",
"id": 1,
………
}
You get the same payload at the same time because Belgrade and Cairo in the summer have the same time zone, GMT+2.
Now, retrieve the same record with the time zone of America/Los_Angeles, GMT-7, as follows:
{
"record_timezone": "Africa/Cairo",
"start_on": "2022-07-12 16:32:08",
"end_at": "2022-07-12 16:59:08",
"record_age": "3 days",
"id": 1,
………
}
You get the same payload but in a different time because the time is converted to the Los Angeles, California, time zone, and notice that the record age is the same. So, there is no difference in the record age regardless of which time zone you are accessing the record from.
Next, access the record in the time zone of Australia/Sydney, GMT+10, as follows:
{
"record_timezone": "Africa/Cairo",
"start_on": "2022-07-12 09:32:08",
"end_at": "2022-07-12 09:59:08",
"record_age": "3 days",
"id": 1,
………
}
Again, this time you get the date and time converted to the Australia/Sydney time zone, and the record age is the same. I will leave the final test of retrieving the record for the Europe/Sofia time zone for you.
Application layer design considerations
It may be helpful to explore the data flow from when the “create trip” API is called until the data is saved to the database and the record is fetched using the fetching APIs.
Creating a trip journey. The TripController is the entry point of the application. To create a trip, the addTrip(@RequestBody @Valid TripRequest trip) method is called, which is mapped to the HTTP POST. This method takes a TripRequest object that is mapped to the payload used to create a trip. It is a Java record, as follows:
public record TripRequest(
@NotBlank String timezone,
@JsonProperty("start_on") @NotNull @FutureOrPresent LocalDateTime startOn,
@JsonProperty("end_at") @NotNull @Future LocalDateTime endAt,
@Positive double distance,
@NotBlank String status,
@JsonProperty("car_id") @NotNull @Positive long carId,
@JsonProperty("driver_id") @NotNull @Positive long driverId,
@JsonProperty("customer_id") @NotNull @Positive long customerId) {
}
The record captures the customer dates in a variable of type LocalDateTime, alongside the customer time zone. Of course, in real-world applications, you would get the customer’s time zone from customer settings saved in the system, such as a user profile or device time-zone settings indicated by the user’s browser.
Once the JSON payload is validated and mapped to the TripRequest, it is passed to the TripService.add() method, which converts the trip request DTO to a Trip domain model entity to be saved in the database.
public long add(TripRequest tripRequest) {
return tripRepository.save(tripMapper.toModel(tripRequest)).getId();
}
The conversion is done by the TripMapper.toModel() method, which takes a TripRequest object and returns a domain entity Trip to be saved. As described earlier, I used the MapStruct library to do this mapping automatically; check the TripMapper for full implementation details.
A crucial part of the TripMapper is the mapping from LocalDateTime variables with a specified time zone to entity ZonedDateTime variables using my custom DateTimeUtil.parseDateTime() method.
public static ZonedDateTime parseDateTime(LocalDateTime timestamp, String timezone) {
return ZonedDateTime.of(timestamp, ZoneId.of(timezone));
}
Once mapping happens successfully, the returned Trip model is passed to the TripRepository.save(Trip trip) method to save it to the database. During the save process, the database driver will convert the ZonedDateTime variable automatically into UTC format.
Fetching a trip journey. To retrieve a trip by ID using HTTP GET, the framework invokes the TripController.oneTrip(@PathVariable String id, @RequestParam("tz") String timezone) method, which returns a TripResponse. The time zone passed to this method could be omitted entirely and fetched from user settings, so only the ID is required to fetch the trip.
To make a call with a specific trip ID and user time zone, the method will call the TripService.findbyId(long Id, String timezone). The service method needs to call the database with a specific ID, so if the trip is found, TripMapper.toView(Trip trip, String timezone) is used to map the domain Trip entity to TripResponse; otherwise, if the trip is not found, the NotFoundException is thrown, as follows:
public TripResponse findById(Long tripId, String timezone) {
return tripRepository
.findById(tripId)
.map(trip -> tripMapper.toView(trip, timezone))
.orElseThrow(() -> new NotFoundException(Trip.class, tripId));
}
The TripResponse only needs to represent the dates that already exist in the database according to the customer’s time zone, so its type is String, as follows:
@JsonInclude(NON_NULL)
public record TripResponse(
long id,
@JsonProperty("record_timezone") String timezone,
@JsonProperty("start_on") String startOn,
@JsonProperty("end_at") String endAt,
@JsonProperty("record_age") String recordAge
………) {
}\
The TripMapper will use the DateTimeUtil.toString(ZonedDateTime zonedDateTime, String timezone) method to convert and format the Trip domain entity ZonedDateTime variables into the preferred customer time zone using the following implementation:
public static String toString(ZonedDateTime zonedDateTime, String timezone) {
return zonedDateTime.toInstant()
.atZone(ZoneId.of(timezone))
.format(DateTimeFormatter.ofPattern(DATE_TIME_PATTERN));
}
Finally, after the correct mapping, the final response is returned to the customer successfully.
Source: oracle.com
0 comments:
Post a Comment