Blog-Archiv

Sonntag, 2. Januar 2022

Java Timezones Minimal REST Servlet

There is just one free timezone REST service on the web: http://worldtimeapi.org/. Here you can query the date, time, UTC-offset, and whether daylight saving time (DST) currently is active, for any location. Try e.g. Europe/Vienna.

This article is about a small Java 11 Servlet 4.0.1 microservice that provides a REST/JSON interface (API), and a JSP-page that calls that REST interface via the new AJAX fetch() function. Subject is displaying Java timezones in a web browser (they are not yet available in JavaScript).

Occasion was that the new way to write user-interfaces is meant to be
   'calling REST services from different devices like browsers, phones, desktop-applications'.
That reminds me of the 1990ies when we had to implement desktop user-interfaces for WINDOWS, UNIX and MACINTOSH systems separately ... looks like we are back there, despite of the Java write-once-run-everywhere promise that also included user-interfaces. However, let's try it.

Introduction

This is a big article. Here is a screenshot of the resulting web UI, called from Europe/Vienna timezone, filtered for zones containing "Singapore":

Purpose was to have an overview of the different timezones offsets (GMT), seeing the time differences, seeing whether daylight saving time (DST) is used by a zone, and whether it is in action at a chosen date. Here is another screenshot, showing the result-treetable when calling the servlet without flter:

Writing this web app I learnt a lot about the new way to write servlets, JSP, the new java.time package and its old counterparts, and how date/time is handled in web browsers and the according input fields. It was interesting to configure and build the servlet with Maven and Eclipse, and then run it with two different web-servers: Jetty 10 (from command line) and Tomcat 9 (from Eclipse).

Minimal Servlet 4.0

The maven-archetype-webapp is nice for setting up a minimal servlet, but it generates more sources than needed. WEB-INF/web.xml can be omitted when using the @WebServlet annotation (on the Java class extending HttpServlety), assumed that the servlet-container can process such annotations. Tomcat 9 and Jetty 10 can, and both support JSP translation without additional plugins or dependencies. That means you can use Java code inside a JSP page that contains also HTML, CSS and JavaScript. A babylonical mix, not even Eclipse always succeeds in syntax-highlighting that correctly!

Following is the file and folder structure of the timezones servlet Maven project:

timezones
pom.xml
src
main
webapp
ZonesTreeBuilder.js
index.jsp
java
fri
servlets
timezones
TimezonesServlet.java
ZonesTreeBuilder.java
ZonesTreeModel.java
ZonesTreeView.java

You find the implementations in the following. Here is the Maven pom.xml:

<project
	xmlns="http://maven.apache.org/POM/4.0.0"
	xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
	xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/maven-v4_0_0.xsd">
  <modelVersion>4.0.0</modelVersion>

  <groupId>fri.servlets</groupId>
  <artifactId>timezones</artifactId>	<!-- is also default servlet contextPath -->
  <version>1.0-SNAPSHOT</version>

  <packaging>war</packaging>
  
  <properties>
    <!-- fix missing web.xml, needed when using servlet annotations-->
    <failOnMissingWebXml>false</failOnMissingWebXml>
    <!-- fix Eclipse bug "Dynamic Web Module 4.0 requires Java 1.8 or newer" -->
    <maven.compiler.source>11</maven.compiler.source>
    <maven.compiler.target>11</maven.compiler.target>
  </properties>

  <dependencies>
    <dependency>
      <groupId>javax.servlet</groupId>
      <artifactId>javax.servlet-api</artifactId>
      <version>4.0.1</version>
      <scope>provided</scope>
    </dependency>

    <dependency>
      <groupId>org.json</groupId>
      <artifactId>json</artifactId>
      <version>20211205</version>
    </dependency>

  </dependencies>

</project>

So a minimal servlet depends on the javax.servlet-api library and nothing else. I use the org.json artifact for creating JSON in the REST interface, it is not needed for a servlet.

If you want to use Jetty as test server, then append following after <dependencies>: (click to expand)
  <build>
    <plugins>
      <!-- Jetty web server, optional for command-line 'mvn jetty:run'.
           Version 11.0.7 does not work with servlet 4.0, thus it's version 10. -->
      <plugin>
        <groupId>org.eclipse.jetty</groupId>
        <artifactId>jetty-maven-plugin</artifactId>
        <version>10.0.7</version>
        <configuration>
          <scanIntervalSeconds>2</scanIntervalSeconds>
          <webApp>
            <contextPath>/${artifactId}</contextPath>
          </webApp>
          <httpConnector>
            <port>8080</port>
          </httpConnector>
        </configuration>
      </plugin>
      
    </plugins>

  </build>

With this plugin you will be able to run Jetty by opening a command-terminal, changing to the project folder, and entering the command mvn jetty:run there.

Mind the <contextPath>/${artifactId}</contextPath> element, this makes it possible to use the same servlet context path as Tomcat, without duplicating code.


Servlet Implementation

Two sources make up the servlet facade:

→ index.jsp is the user interface and base page, providing manual input of query parameters. On action it will call the REST interface to fetch timezones according to the parameters. This UI will show when called with an URL like

http://localhost:8080/timezones

→ TimezonesServlet.java represents the REST/JSON interface, callable by URLs like

http://localhost:8080/timezones/zones?day=2021-12-31&time=23:59&zone=Europe/Vienna&filter=Singapore

So timezones is the servlet context path, leading to the UI, and zones is the REST path part, followed by runtime-estimated URL parameters, leading to JSON.

Only the HTTP GET method is supported. As there is no PUT and POST, there is also no HTML form element in index.jsp. This is a read-only servlet, without any log-in authentication.

Client: index.jsp

The user interface.

  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
<!DOCTYPE html>
<html>
<!-- 
  Provides manual parameter input, showing on URL
  http://localhost:8080/timezones
-->
<head>
  <meta name="viewport" content="width=device-width, initial-scale=1" />
  <meta charset="UTF-8" />
        
  <title>Java Timezones Servlet</title>

  <script src="ZonesTreeBuilder.js"></script>  <!-- contains generateHtml() -->
 
  <!-- functions related to input fields and AJAX-calls -->
  <script>
    const fetchTimezones = function()  {
      /* clear result element */
      timezonesDisplay.innerHTML = "";
      
      if ( ! dayField.value ) /* happens when clearing date field */
        return;
      
      /* get user input values and build a REST-URL by prepending "zones" */
      const requestUrl = window.location.href+
        "zones"+
        "?day="+encodeURIComponent(dayField.value)+
        "&time="+encodeURIComponent(timeField.value)+
        "&zone="+encodeURIComponent(zoneField.value)+
        "&filter="+encodeURIComponent(filterField.value);
      
      const expandRoots = (filterField.value != "");
      
      /* make an AJAX-call to REST server with parameterized URL */
      fetch(requestUrl)
        .then(response => checkFetchResponse(response))
        .then(json => timezonesDisplay.innerHTML = generateHtml(json, expandRoots))
        .catch(error => alert(error));
    };
    
    const checkFetchResponse = function(response) {
      if ( ! response.ok )
        throw new Error("Network error, status: "+response.status+", statusText: "+response.statusText);
         
      const contentType = response.headers.get("content-type");
      if ( ! contentType.includes("application/json") )
        throw new Error("Response should be JSON!");
         
      return response.json();
    };
    
    /* Expand/Collapse tree button callbacks */
    const expandAll = function() {
      expandOrCollapse(true);
    };
    const collapseAll = function() {
      expandOrCollapse(false);
    };

    const expandOrCollapse = function(expand) {
      for (const detailsElement of timezonesDisplay.querySelectorAll("details"))
        detailsElement.open = expand;
    };
  </script>

</head>

<body>
  <%
    pageContext.setAttribute("zoneIds", java.time.ZoneId.getAvailableZoneIds()
        .stream()
        .filter(id -> id.contains("/") && 
            id.startsWith("Etc/") == false && 
            id.startsWith("SystemV/") == false)
        .sorted()
        .map(id -> id.equals(java.time.ZoneId.systemDefault().getId()) 
            ? "<option selected>"+id+"</option>"
            : "<option>"+id+"</option>")
        .collect(java.util.stream.Collectors.joining())
    );
  %>

  <h2>Welcome to Java <%=System.getProperty("java.version")%> Timezones!</h2>
  
  <!-- REST parameter inputs -->
  <table style="border: 1px solid gray; border-radius: 0.4em; padding: 0.6em;">
    <tr>
      <td>* Day</td>
      <td><input id="day" type="date" onchange="fetchTimezones();"/></td>
      <td>Time</td>
      <td><input id="time" type="time"/></td>
    </tr>
    <tr>
      <td>Zone</td>
      <td colspan="3"><select id="zone" onchange="fetchTimezones();">${zoneIds}</select></td>
    </tr>
    <tr>
      <td>Filter</td>
      <td colspan="2"><input id="filter" type="text" onchange="fetchTimezones();" style="width: 8.5em;"/></td>
      <td><input id="fetch" type="button" value="Fetch" onclick="fetchTimezones();"/></td>
    </tr>
  </table>
  
  <!-- glossary -->
  <table style="padding: 0.6em;">
    <tr><td>DST</td><td>&rarr; Daylight Saving Time</td></tr>
    <tr><td>GMT</td><td>&rarr; Greenwich Mean Time</td></tr>
  </table>
  
  <!-- tree control buttons -->
  <div>
    <input type="button" value="Expand All" onclick="expandAll();"/>
    <input type="button" value="Collapse All" onclick="collapseAll();"/>
  </div>

  <div id="timezones">  <!-- tree output area -->
  </div>
  
  <!-- initialization, executed once on page-load -->
  <script>
    /* get global fields into constants */
    const dayField = document.getElementById("day");
    const timeField = document.getElementById("time");
    const zoneField = document.getElementById("zone");
    const filterField = document.getElementById("filter");
    const timezonesDisplay = document.getElementById("timezones");
    
    /* put current day and time into "day" and "time" fields */
    const dt = new Date();
    const twoDigits = (value) => (value < 10 ? "0" : "")+value;
    dayField.value = dt.getFullYear()+"-"+twoDigits(dt.getMonth() + 1)+"-"+twoDigits(dt.getDate());
    timeField.value = twoDigits(dt.getHours())+":"+twoDigits(dt.getMinutes());
    
    /* add change-listener to disable fetch-button when no day is chosen */
    dayField.addEventListener("change", function()  {
      const fetchButton = document.getElementById("fetch");
      fetchButton.disabled = dayField.value ? false : true;
    });
  </script>

</body>
</html>

Now this is big. It consists of several parts that I will describe step by step.

ZoneTreeBuilder.js in Head

Referenced in line 13. Here is a screenshot of what it can produce:

This timezones tree is made up by HTML DETAIL elements.

Here is the JavaScript ZonesTreeBuilder.js implementation, turning JSON into HTML. (Click to expand)
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
/**
 * Build a DETAILS tree HTML text from server's time zones JSON.
 * @param zonesJson the JSON object that carries time zone data.
 * @param expandRoots when given and true, tree roots will be expanded.
 * @return HTML text that contains time zones informations.
 */
const generateHtml = function(zonesJson, expandRoots)
{
  /**
   * Builds headline, expand-buttons and tree, including CSS style.
   * @param htmlPrintln function that prints HTML into a buffer.
   */
  const outputTree = function(htmlPrintln)
  {
    const printTreeStyles = function() {
      htmlPrintln("<style>");

      htmlPrintln("  details.tree {");
      htmlPrintln("    padding-left: 1em;");
      htmlPrintln("    font-weight: bold;");
      htmlPrintln("  }");
      htmlPrintln("  div.tree {");
      htmlPrintln("    padding-left: 2.06em;");
      htmlPrintln("    font-style: italic;");
      htmlPrintln("    font-weight: bold;");
      htmlPrintln("  }");
      htmlPrintln("  details.tree details.tree, details.tree div.tree {");
      htmlPrintln("    font-weight: normal;");
      htmlPrintln("  }");
      /* render zone-IDs with fixed width so that DST is aligned like in a table */
      htmlPrintln("  div.tree span.zoneIdSpace {");
      htmlPrintln("    display: inline-block;");
      htmlPrintln("    width: 19em;"); /* fits also "America/North_Dakota/New_Salem" */
      htmlPrintln("  }");

      htmlPrintln("</style>");
    };
    
    const printTree = function() {
      htmlPrintln(
        "<p><b>"+zonesJson.queryDay+"</b> &rarr; "+
        zonesJson.offsets.length+" zone offsets.</p>");

      for (const offset of zonesJson.offsets) {
        htmlPrintln(
            "<details "+(expandRoots ? "open" : "")+" class='tree'><summary>"+
            offset.offsetHours+" hours off GMT: "+
            offset.zonedDateTime+
            "</summary>");
	    
        for (const displayName of offset.displayNames) {
          htmlPrintln(
              "<details open class='tree'><summary>"+
              displayName.displayName+
              "</summary>");

          for (const zone of displayName.zones) {
            htmlPrintln(
                "<div class='tree'>"+
                "<span class='zoneIdSpace'>"+zone.zoneId+"</span>"+
                (zone.usesDaylightSavingTime ? printDaylightSavingTimes(zone) : "")+
                "</div>");
          } /* end zones */

          htmlPrintln("</details>");
        } /* end displayNames */

        htmlPrintln("</details>");
      } /* end offsets */
    };
    
    const printDaylightSavingTimes = function(zone) {
      var s = "Uses DST, "+zone.previousTransition+"^"+zone.nextTransition;
      if (zone.inDaylightSavingTime)
        s += "&nbsp;&nbsp;&nbsp; &#x2600; In DST, "+zone.standardOffsetHours+"+"+zone.correctionHours;
      return s;
    };

    /* enclosing DIV, style prevents inherited text-align destroying tree indents */
    htmlPrintln("<div style='text-align: initial;'>");
    printTreeStyles();
    printTree();
    htmlPrintln("</div>");
  };


  /** Call outputTree() and return outputText */
  var outputText;
  outputTree(nextLine => outputText = (outputText ? outputText+"\n" : "")+nextLine);
  return outputText;

};

On line 88 - 90 you see how generateHtml() works: it provides a lambda that writes HTML text into a result buffer and calls outputTree() with it. Then it returns the result buffer.

The outputTree() function starting on line 13 contains several sub-functions. On line 80 - 83 you see how it works. It writes an enclosing DIV into the given HTML-writer, then calls all sub-functions, then closes the DIV.

The printTreeStyles() function on line 15 generates CSS necessary to turn the DETAIL elements into an indented tree. The CSS is nested in a local <style> element.

The printTree() function on line 39 finally prints timezone contents. It iterates through the JSON model with three nested loops, generating HTML accordingly. The innermost loop calls printDaylightSavingTimes() for rendering zone informations concerning daylight saving times. Each loop closes its open DETAIL at end.
So, on JavaScript side, other than in Java, there is no model visitor, instead the model is traversed directly by implementing loops. Even when there was a visitor, it would have to be identical with the Java visitor, which is the big code-duplication problem here.

This JS code knows the nature of the model built by the Java server (ZoneTreeModel.java and ZoneTreeView.java). Both must use the same tree structure and property names.
This may be the biggest weakness of REST apps!

More Script Functions in Head

Line 16 to 64.

The fetchTimezones() function calls the REST interface. Line 21 would deny when no day was chosen. You see the URL built from line 25 to 30, the REST path part on line 26. Four parameters go into the URL: day, time, zone and filter. Line 35 uses the built-in JavaScript method fetch() to read timezones from the Java server. This works with Promises. The checkFetchResponse() function on line 41 asserts the result and turns it into JSON. The lambda on line 37 then turns the JSON into HTML, by calling the generateHtml() function in ZoneTreeBuilder.js, and sets it into the timezonesDisplay HTML element. Result will be an expandable tree of timezone offsets, display names, and zones with DST infos.

On line 52 - 63 there are functions that can fully expand or collapse the timezones tree. They are called by the according input buttons.

Building Zone Choice by JSP-Java

Line 69 to 81.

The <% %> match on line 69 is a JSP tag where you can insert Java code. This is evaluated before delivering HTML to the browser. I used it to build together the choice of all zone-names available in Java. The default zone is selected through line 76 and 77. The JSP page-context attribute zoneIds contains a list of HTML <option> elements afterwards.

In line 95 you find the usage of the zoneIds attribute. It is simply expanded into an HTML select element.

Parameter Inputs

Line 86 to 102.

The day is a mandatory parameter, although the REST servlet would default to current day when not given.

The time chooser is a browser-specific thing, Firefox renders it with AM/PM for English locale.

The zone choice is also part of the parameter panel. It allows to virtually move to the chosen timezone before calculating offsets, times and DST.

Last not least you can restrict the number of displayed timezones by a filter pattern that is evaluated by zonId.contains(filter) on the server. That filter is a good example that filtering makes sense also on server side, because it minimizes the network load of transferring HTML elements.

The "Expand All" and "Collapse All" buttons on line 111 to 114 control the timezones result tree.

Line 116 and 117 finally contain the timezones result panel, intially empty.

Initialization Script on Bottom

Line 120 to 139.

This is executed when all HTML page elements were set up. Global variables reference the parameter input fields for later use (line 122 - 126).

Line 129 - 132 put the current day and time into the according input fields. This was not easy to implement, because JS Date does not provide the right strings that we can set into such fields. Thus I tried to build them through toISOString(), but this displayed the UTC time (without GMT offset) instead of the local time. Further an input field of type time (and also date) always requires two-digit numbers. This is the reason for the twoDigit lambda on line 130 that is then called when building together the local date and time in ISO-format: "2022-01-02" and "08:01". The forms "2022-1-2" and "8:1" won't be accepted.

Line 135 - 138 install a change-listener onto the day-field, so that the "Fetch" button will be disabled when no day is chosen.

User interfaces always are complex. This JSP page contains several technologies and programming languages. For sure it was the most time-consuming part of this project. What now follows is the easier part: Java only!

Server: TimezonesServlet.java

I left out the package definition fri.servlets.timezones and all imports, coming from java.io, java.time, java.util.function, javax.servlet, javax.servlet.annotation and javax.servlet.http.

The @WebServlet annotation on line 1 is a hint for the servlet container (web server) that this class is a servlet class. This replaces the servlet definitions in web.xml, you don't need that file any more. The ("/zones") path tells the container for which URL the servlet is to call, relative to the servlet context path timezones that is the Maven artifact identifier in pom.xml.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
@WebServlet("/zones")
public class TimezonesServlet extends HttpServlet
{
  @Override
  public void doGet(HttpServletRequest request, HttpServletResponse response)
      throws ServletException, IOException
  {
    final String day = request.getParameter("day");
    final String time = request.getParameter("time");
    
    if (day != null || time != null)  { 
      // coming from http://localhost:8080/timezones/zones?day=2012-12-30&time=15:30
      final String zone = request.getParameter("zone");
      final String filter = request.getParameter("filter");
      final String responseText = dispatch(day, time, zone, filter);
      
      response.setContentType("application/json");
      final PrintWriter output = response.getWriter();
      output.println(responseText);
      output.close();
    }
    else  {  // no day or time given, back to ground at http:///localhost:8080/timezones/
      getServletContext().getRequestDispatcher("/index.jsp").forward(request, response);
    }
  }
  
  private String dispatch(String day, String time, String zone, String filter)  {
    if (day == null || day.length() <= 0)
      day = LocalDate.now().toString();
    
    if (time == null || time.length() <= 0)
      time = LocalTime.now().toString();
    
    final String theZoneId = (zone != null && zone.length() > 0) ? zone : ZoneId.systemDefault().getId();
    final ZoneId theZone = ZoneId.of(theZoneId);
    
    final String theFilter = (filter != null && filter.length() > 0) ? filter.toLowerCase() : null;
    
    final Predicate<String> zoneIdFilter = (theFilter != null)
        ? id -> id.toLowerCase().contains(theFilter)
        : null;

    final long millis = LocalDate.parse(day)
        .atTime(LocalTime.parse(time))
        .atZone(theZone)
        .toInstant()
        .toEpochMilli();
 
    final JSONObject jsonObject = new ZonesTreeView().buildJson(zoneIdFilter, millis, theZone);
    return jsonObject.toString();
  }
}

To serve HTTP GET calls, I override the HttpServlet.doGet() method. From line 8 to 14 you see the same URL parameter names as in index.jsp in function fetchTimezones(). In case neither day nor time was provided in the request parameters, the call is redirected to the index.jsp UI. Else the dispatch() method is called, and the returned JSON is then printed into the response (line 17 - 20).

The dispatch() method evaluates its parameters and provides defaults when needed (current day, current time, default zone, line 28 - 37). From line 39 to 47, parameters for ZonesTreeView are built together, which is then called on line 49. The resulting JSON object is returned, serialized as text, in line 50.

That's all for the servlet class. The remaining part is business logic.

Model

The structure of the inner classes outlines the tree structure, which consists of three levels:

  1. timezone offsets (in hours)
  2. display-names (grouping of zones)
  3. zones, carrying DST (daylight saving times)
Following is a typical Java model, outlining timezones as defined in the new java.time API. It provides a visitor to free callers from the burden of duplicating model traversals. (Click to expand)
  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
public class ZonesTreeModel
{
  public class Offset
  {
    public class DisplayName
    {
      public class Zone
      {
        public final ZoneId zone;
        
        public Zone(ZoneId zone) {
          this.zone = zone;
        }
        
        public int correctionMillis()  {
          return offset - standardOffsetMillis();  // offset taken from outer instance
        }
        public int standardOffsetMillis() {
          return zone.getRules().getStandardOffset(instant).getTotalSeconds() * 1000;  // instant taken from outer instance
        }
        public boolean inDaylightSavingTime()  {
          return zone.getRules().isDaylightSavings(instant);
        }
        public boolean usesDaylightSavingTime() {
          return zone.getRules().nextTransition(instant) != null;
        }
        public LocalDate[] daylightSavingTimeTransitions() {
          if (usesDaylightSavingTime() == false)
            return null;
          
          final ZoneRules rules = zone.getRules();
          final ZoneOffsetTransition nextTransition = rules.nextTransition(instant);
          final LocalDateTime next = nextTransition.getDateTimeBefore();
          final ZoneOffsetTransition previousTransition = rules.previousTransition(instant);
          final LocalDateTime previous = previousTransition.getDateTimeAfter();
          
          return new LocalDate[] { previous.toLocalDate(), next.toLocalDate() };
        }
        
        public void accept(ZonesTreeModel.Visitor visitor)  {
          visitor.visitZone(this);
        }
      }
      
      public final String displayName;
      private final List<Zone> zones = new ArrayList<>();
      
      public DisplayName(String displayName) {
        this.displayName = displayName;
      }
      
      void add(Zone zone)  {
        zones.add(zone);
      }
      public List<Zone> zones()  {
        return Collections.unmodifiableList(zones);
      }
      
      public void accept(ZonesTreeModel.Visitor visitor)  {
        visitor.visitDisplayName(this);
        for (Zone zone : zones)
          zone.accept(visitor);
      }
    }
    
    public final int offset;
    public final ZonedDateTime zonedDateTime;
    private final List<DisplayName> displayNames = new ArrayList<>();
    
    public Offset(int offset, ZoneId zone) {
      this.offset = offset;
      this.zonedDateTime = ZonedDateTime.ofInstant(Instant.ofEpochMilli(zonedMillisSince1970 + offset), zone);
    }
    
    void add(DisplayName displayName)  {
      displayNames.add(displayName);
    }
    public List<DisplayName> displayNames()  {
      return Collections.unmodifiableList(displayNames);
    }
    
    public void accept(ZonesTreeModel.Visitor visitor)  {
      visitor.visitOffset(this);
      for (DisplayName displayName : displayNames)
        displayName.accept(visitor);
    }
  }
  
  private final long zonedMillisSince1970;
  private final Instant instant;
  private final List<Offset> offsets = new ArrayList<>();
  
  public ZonesTreeModel(long millisSince1970, ZoneId zone) {
    // calculate zoned millis from UTC millis
    this.zonedMillisSince1970 = millisSince1970 - zone.getRules().getOffset(Instant.ofEpochMilli(millisSince1970)).getTotalSeconds() * 1000;
    this.instant = Instant.ofEpochMilli(zonedMillisSince1970);
  }
  
  void add(Offset offset)  {
    offsets.add(offset);
  }
  public List<Offset> offsets()  {
    return Collections.unmodifiableList(offsets);
  }
  
  public void accept(ZonesTreeModel.Visitor visitor)  {
    visitor.visitZoneTree(this);
    for (Offset offset : offsets)
      offset.accept(visitor);
  }
  
  /**
   * Visitor implementations don't need to duplicate structural loops.
   */
  public interface Visitor
  {
    void visitZoneTree(ZonesTreeModel tree);
    void visitOffset(Offset offset);
    void visitDisplayName(DisplayName displayName);
    void visitZone(Zone zone);
  }
}

On line 15 - 38 you can see how the model refers to Java timezones for calculating offsets and retrieving DST and other information.

Line 115 - 121 define the Visitor interface for viewing the model. The model implements visiting by the accept(Visitor) method on every tree level.

Builder

The builder creates a ZonesTreeModel from Java timezones. (Click to expand)
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
public class ZonesTreeBuilder
{
  public ZonesTreeModel buildTree(Predicate<String> zoneIdFilter, long millisSince1970, ZoneId zoneId)  {
    final String[] zoneIds = ZoneId.getAvailableZoneIds()
        .stream()
        .filter(zoneIdFilter)
        .collect(Collectors.toList())
        .toArray(String[]::new);
    
    final ZonesTreeModel tree = new ZonesTreeModel(millisSince1970, zoneId);
    
    buildOffset2ListOfTimeZonesMap(millisSince1970, zoneIds)
      .entrySet()
      .stream()
      .sorted((e1, e2) -> e2.getKey() - e1.getKey())  // positive offsets first, e.g. Pacific/Tongatapu
      .forEach(offsetEntry -> {
        final Offset offset = tree.new Offset(offsetEntry.getKey(), zoneId);
        tree.add(offset);
        
        buildDisplayName2ListOfTimeZonesMap(offsetEntry.getValue())
          .entrySet()
          .stream()
          .sorted((e1, e2) -> e1.getKey().compareTo(e2.getKey()))
          .forEach(displayEntry -> {
            final DisplayName displayName = offset.new DisplayName(displayEntry.getKey());
            offset.add(displayName);
            
            displayEntry.getValue()
              .stream()
              .sorted((z1, z2) -> z1.getId().compareTo(z2.getId()))
              .forEach(timeZone -> {
                final Zone zone = displayName.new Zone(timeZone);
                displayName.add(zone);
              });
              
          });
    });
    
    return tree;
  }
  
  private Map<Integer,List<ZoneId>> buildOffset2ListOfTimeZonesMap(long millisSince1970, String[] zoneIds)  {
    final Instant instant = Instant.ofEpochMilli(millisSince1970);
    return Arrays.stream(zoneIds)
                .map(zoneId -> ZoneId.of(zoneId))
                .collect(
                    Collectors.groupingBy(
                        zone -> zone.getRules().getOffset(instant).getTotalSeconds() * 1000,
                        Collectors.toList()
                    )
                );
    }

  private Map<String,List<ZoneId>> buildDisplayName2ListOfTimeZonesMap(List<ZoneId> zones)  {
    return zones.stream()
                .collect(
                    Collectors.groupingBy(
                        zone -> zone.getDisplayName(TextStyle.FULL, Locale.getDefault()),
                        Collectors.toList()
                    )
                );
  }
  
}

Everything is done through functional stream code. Too complicated to explain :-)

View

To provide different views onto the model (JSON, CSV, XML, ...), the ZonesTreeView uses the model visitor interface. For now there is just one view, converting the model to JSON objects. (Click to expand)
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
public class ZonesTreeView
{
  public JSONObject buildJson(Predicate<String> zoneIdFilter, long now, ZoneId zoneId) {
    if (zoneIdFilter == null)
      zoneIdFilter = id ->
        id.contains("/") && 
        id.startsWith("Etc/") == false && 
        id.startsWith("SystemV/") == false;

    final JSONObject root = new JSONObject();
    root.put("queryDay", ""+ZonedDateTime.ofInstant(Instant.ofEpochMilli(now), zoneId));
    
    final ZonesTreeModel tree = new ZonesTreeBuilder().buildTree(zoneIdFilter, now, zoneId);
    
    tree.accept(new ZonesTreeModel.Visitor()  {
      private JSONArray offsets;
      private JSONArray displayNames;
      private JSONArray zones;
      
      @Override
      public void visitZoneTree(ZonesTreeModel tree) {
        root.put("offsets", offsets = new JSONArray());
      }
      
      @Override
      public void visitOffset(Offset offset) {
        final JSONObject offsetJsonObject = new JSONObject();
        offsetJsonObject.put("offsetHours", ""+toHours(offset.offset));
        offsetJsonObject.put("zonedDateTime", ""+DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm").format(offset.zonedDateTime));
        displayNames = new JSONArray();
        offsetJsonObject.put("displayNames", displayNames);
        
        offsets.put(offsetJsonObject);
      }
      
      @Override
      public void visitDisplayName(DisplayName displayName) {
        final JSONObject displayNameJsonObject = new JSONObject();
        displayNameJsonObject.put("displayName", displayName.displayName);
        zones = new JSONArray();
        displayNameJsonObject.put("zones", zones);
        
        displayNames.put(displayNameJsonObject);
      }
      
      @Override
      public void visitZone(Zone zone) {
        final JSONObject zoneJsonObject = new JSONObject();
        zoneJsonObject.put("zoneId", zone.zone.getId());
        zoneJsonObject.put("usesDaylightSavingTime", zone.usesDaylightSavingTime());
        if (zone.usesDaylightSavingTime())  {
          final LocalDate[] transitions = zone.daylightSavingTimeTransitions();
          if (transitions != null)  {
            zoneJsonObject.put("previousTransition", ""+transitions[0]);
            zoneJsonObject.put("nextTransition", ""+transitions[1]);
          }
          zoneJsonObject.put("inDaylightSavingTime", zone.inDaylightSavingTime());
          if (zone.inDaylightSavingTime())  {
            zoneJsonObject.put("standardOffsetHours", ""+toHours(zone.standardOffsetMillis()));
            zoneJsonObject.put("correctionHours", ""+toHours(zone.correctionMillis()));
          }
        }
        zones.put(zoneJsonObject);
      }
      
      private String toHours(long millis)  {
        final float hours = (float) millis / (float) 3600000;
        final String hourString = ""+hours;
        final String useless = ".0";
        return hourString.endsWith(useless)
            ? hourString.substring(0, hourString.length() - useless.length())
            : hourString;
      }
    });
    
    return root;
  }

}

Mind that this source uses the same property names as the ZoneTreeBuilder.js JavaScript!

Resume

It took me days to implement this servlet. I struggled with Eclipse project facets, the servlet containers (web servers), different library versions not accepting others, obscurity of servlet URLs in different containers, web browser problems with date, time and the according HTML input elements, programming language and framework gotchas and the necessity to know them all well.

I would have liked to use the application sometimes for presenting the beauty of Java timezones, but how can I use such an application? It is "dynamic web content", so I would have to pay a VPS hosting provider (virtual private server), or a shared container hosting provider, to expose it on the Internet. Or I could start the server on my private laptop, then connect to it with my browser, but can anyone else see that?

Servlets belong to the Internet, it doesn't make sense to implement servlets running on private machines. So I will implement a timezones Swing desktop application for private use only. This is better than having to start up a web server and connect to it with a browser each time you want to look at time zones.

The development of web applications is not over with REST. What is coming is user interfaces that massively duplicate code (model structure and property names). Remember: UIs are the most expensive part of an application! A change in a server model may kill many UI implementations then. After DLL hell, JAR hell, and any other kind of dependency hell, the REST-API hell is in action now.




Keine Kommentare: