Spring Boot Part 4: Spring MVC with Thymeleaf

This series of articles will examine Spring Boot features. This fourth installment discusses Spring MVC, templating in Spring, and creates a simple internationalized clock application as an example. The clock application will allow the user to select Locale and TimeZone.

Complete source code for the series and for this part are available on Github.

Note that this post’s details and the example source code has been updated for Spring Boot version 2.5.3 so some output may show older Spring Boot versions.

Theory of Operation

The Controller will provide methods to service GET and POST requests at /clock/time and update the Model with:

Attribute Name Type
locale User-selected Locale
zone User-selected TimeZone
timestamp Current Date
date DateFormat to display date (based on Locale and TimeZone)
time DateFormat to display time (based on Locale and TimeZone
locales A sorted List of Locales the user may select
zones A sorted List of TimeZones the user may select

The View will use Thymeleaf technology which may be included with a reasonable configuration simply by including the corresponding “starter” in the POM. The developer’s primary responsibility is write the corresponding Thymeleaf template.

For this project, Bootstrap is used as the CSS Framework.1

The required dependencies are included in the pom.xml:

<?xml version="1.0" encoding="UTF-8"?>
<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">
  ...
  <dependencies verbose="true">
    ...
    <dependency>
      <groupId>org.springframework.boot</groupId>
      <artifactId>spring-boot-starter-thymeleaf</artifactId>
    </dependency>
    ...
    <dependency>
      <groupId>org.webjars</groupId>
      <artifactId>webjars-locator-core</artifactId>
    </dependency>
    <dependency>
      <groupId>org.webjars</groupId>
      <artifactId>bootstrap</artifactId>
      <version>4.6.0-1</version>
    </dependency>
    <dependency>
      <groupId>org.webjars</groupId>
      <artifactId>jquery</artifactId>
      <version>3.6.0</version>
    </dependency>
  </dependencies>
  ...
</project>

The following subsections describe the View, Controller, and Model.

View

The Controller will serve requests at /clock/time and the Thymeleaf ViewResolver (with the default configuration) will look for the corresponding template at classpath:/templates/clock/time.html (note the /templates/ superdirectory and the .html suffix). The template with the <main/> element is shown below. The XML Namespace “th” is defined for Thymeleaf and a number of “th:*” attributes are used. For example, the Bootstrap artifact paths are wrapped in “th:href” and “th:src” attributes with values expressed in Thymeleaf standard expression syntax.

<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org" th:xmlns="@{http://www.w3.org/1999/xhtml}">
  <head>
    <meta charset="UTF-8"/>
    <meta name="viewport" content="width=device-width,initial-scale=1,shrink-to-fit=no"/>
    <link rel="stylesheet" th:href="@{/webjars/bootstrap/css/bootstrap.css}"/>
  </head>
  <body>
    <!--[if lte IE 9]>
      <p class="browserupgrade" th:utext="#{browserupgrade}"/>
    <![endif]-->
    <main class="container">
      ...
    </main>
    <script th:src="@{/webjars/jquery/jquery.js}" th:text="''"/>
    <script th:src="@{/webjars/bootstrap/js/bootstrap.js}" th:text="''"/>
  </body>
</html>

The following shows the template’s rendered HTML. The Bootstrap artifacts’ paths have been rendered “href” and “src” attributes (with version handling in their paths as decribed in part 2) and with the Thymeleaf expressions evaluated.

<!DOCTYPE html>
<html xmlns="http://www.w3.org/1999/xhtml">
  <head>
    <meta charset="UTF-8"/>
    <meta name="viewport" content="width=device-width,initial-scale=1,shrink-to-fit=no"/>
    <link rel="stylesheet" href="/webjars/bootstrap/4.4.1/css/bootstrap.css"/>
  </head>
  <body>
    <!--[if lte IE 9]>
      <p class="browserupgrade">You are using an <strong>outdated</strong> browser.  Please <a href="https://browsehappy.com/">upgrade your browser</a> to improve your experience and security.</p>
    <![endif]-->
    <main class="container">
      ...
    </main>
    <script src="/webjars/jquery/3.6.0/jquery.js"></script>
    <script src="/webjars/bootstrap/4.6.0-1/js/bootstrap.js"></script>
  </body>
</html>

Spring provides a message catalog facility which allows <p class="browserupgrade" th:utext="#{browserupgrade}"/> to be evaluated from the message.properties. The Tutorial: Thymeleaf + Spring provides a reference for these and other features. Tutorial: Using Thymeleaf provides the reference for standard expression syntax, the available “th:*” attributes and elements, and available expression objects.

The initial implementation of the clock View is:

    ...
    <main class="container">
      <div class="jumbotron">
        <h1 class="text-center" th:text="${time.format(timestamp)}"/>
        <h1 class="text-center" th:text="${date.format(timestamp)}"/>
      </div>
    </main>
    ...

It output will be discussed in detail in the next section after discussing the Controller implementation and the population of the Model but note that the View requires the Model provide the time, date, and timestamp attributes as laid out above.

Controller and Model

The Controller is implemented by a class annotated with @Controller, ClockController. The implementation of the GET /clock/time is outlined below:

@Controller
@RequestMapping(value = { "/clock/" })
@NoArgsConstructor @ToString @Log4j2
public class ClockController {
    ...
    @RequestMapping(method = { RequestMethod.GET }, value = { "time" })
    public void get(Model model, Locale locale, TimeZone zone) {
        model.addAttribute("locale", locale);
        model.addAttribute("zone", zone);

        DateFormat date = DateFormat.getDateInstance(DateFormat.LONG, locale);
        DateFormat time = DateFormat.getTimeInstance(DateFormat.MEDIUM, locale);

        model.addAttribute("date", date);
        model.addAttribute("time", time);

        for (Object object : model.asMap().values()) {
            if (object instanceof DateFormat) {
                ((DateFormat) object).setTimeZone(zone);
            }
        }

        Date timestamp = new Date();

        model.addAttribute("timestamp", timestamp);
    }
    ...
}

The parameters are Model, Locale, and TimeZone, all injected by Spring. A complete list of available method parameters and return types with their respective semantics, may be found at Handler Methods.

The method updates the Model with the user Locale and TimeZone, the current timestamp, and the time and date DateFormat to render the clock display. Since the method returns void, the view resolves to the Thymeleaf template at classpath:/templates/clock/time.html (as described above). Alternatively, the method may return a String with a name (path) of a template. Spring then evaluates the template with the Model for the output which results in:

    ...
    <main class="container">
      <div class="jumbotron">
        <h1 class="text-center">11:59:59 AM</h1>
        <h1 class="text-center">December 24, 2019</h1>
      </div>
    </main>
    ...

which renders to:

Of course, this implementation does not yet allow the user to customize their Locale or TimeZone. The next section adds this functionality.

Adding User Customization

To allow user customization, first a form allowing the user to select Locale and TimeZone must be added.

    ...
    <main class="container">
      ...
      <form class="row" method="post" th:action="${#request.servletPath}">
        <select class="col-lg" name="languageTag">
          <option th:each="option : ${locales}"
                  th:with="selected = ${option.equals(locale)},
                           value = ${option.toLanguageTag()},
                           display = ${option.getDisplayName(locale)},
                           text = ${value + ' - ' + display}"
                  th:selected="${selected}" th:value="${value}" th:text="${text}"/>
        </select>
        <select class="col-lg" name="zoneID">
          <option th:each="option : ${zones}"
                  th:with="selected = ${option.equals(zone)},
                           value = ${option.ID},
                           display = ${option.getDisplayName(locale)},
                           text = ${value + ' - ' + display}"
                  th:selected="${selected}" th:value="${value}" th:text="${text}"/>
        </select>
        <button class="col-sm-1" type="submit" th:text="'&#8635;'"/>
      </form>
    </main>
    ...

Two attributes must be added to the Model by the GET /clock/time method, the Lists of Locales and TimeZones from which the user may select.2

    private static final List<Locale> LOCALES =
        Stream.of(Locale.getAvailableLocales())
        .filter(t -> (! t.toString().equals("")))
        .collect(Collectors.toList());
    private static final List<TimeZone> ZONES =
        Stream.of(TimeZone.getAvailableIDs())
        .map(t -> TimeZone.getTimeZone(t))
        .collect(Collectors.toList());

    @RequestMapping(method = { RequestMethod.GET }, value = { "time" })
    public void get(Model model, Locale locale, TimeZone zone, ...) {
        ...
        Collator collator = Collator.getInstance(locale);
        List<Locale> locales =
            LOCALES.stream()
            .sorted(Comparator.comparing(Locale::toLanguageTag, collator))
            .collect(Collectors.toList());
        List<TimeZone> zones =
            ZONES.stream()
            .sorted(Comparator
                    .comparingInt(TimeZone::getRawOffset)
                    .thenComparingInt(TimeZone::getDSTSavings)
                    .thenComparing(TimeZone::getID, collator))
            .collect(Collectors.toList());

        model.addAttribute("locales", locales);
        model.addAttribute("zones", zones);
        ...
    }

Key to the implementation is the use of the “th:each” attribute where the node is evaluated each member of the List. The “th:with” attribute allows variables to be defined and referenced within the scope of the corresponding node. Partial output is shown below.

    ...
    <main class="container">
      ...
      <form class="row" method="post" action="/clock/time">
        <select class="col-lg" name="languageTag">
          <option value="ar">ar - Arabic</option>
          <option value="ar-AE">ar-AE - Arabic (United Arab Emirates)</option>
          ...
          <option value="en-US" selected="selected">en-US - English (United States)</option>
          ...
          <option value="zh-TW">zh-TW - Chinese (Taiwan)</option>
        </select>
        <select class="col-lg" name="zoneID">
          <option value="Etc/GMT+12">Etc/GMT+12 - GMT-12:00</option>
          <option value="Etc/GMT+11">Etc/GMT+11 - GMT-11:00</option>
          ...
          <option value="PST8PDT" selected="selected">PST8PDT - Pacific Standard Time</option>
          ...
          <option value="Pacific/Kiritimati">Pacific/Kiritimati - Line Is. Time</option>
        </select>
        <button class="col-sm-1" type="submit">↻</button>
      </form>
    </main>
    ...

The updated View provides to selection lists and a form POST button:

A new method is added to the Controller to handle the POST /clock/time request. Note the HttpServletRequest and HttpSession parameters.

    @RequestMapping(method = { RequestMethod.POST }, value = { "time" })
    public String post(HttpServletRequest request, HttpSession session,
                       @RequestParam Map<String,String> form) {
        for (Map.Entry<String,String> entry : form.entrySet()) {
            session.setAttribute(entry.getKey(), entry.getValue());
        }

        return "redirect:" + request.getServletPath();
    }

The selected Locale languageTag and TimeZone zoneID are written to the @RequestParam-annotated Map with keys languageTag and zoneID, respectively. The Map key-value pairs are written into the HttpSession (automatically managed by Spring) attributes.3 Adding the prefix “redirect:” instructs Spring to respond with a 302 to cause the browser to make a request to the new URL: GET /clock/time. That method must be modified to set Locale based on the session languageTag and/or TimeZone based on zoneID if specified (accessed via @SessionAttribute).4

    @RequestMapping(method = { RequestMethod.GET }, value = { "time" })
    public void get(Model model, Locale locale, TimeZone zone,
                    @SessionAttribute Optional<String> languageTag,
                    @SessionAttribute Optional<String> zoneID) {
        if (languageTag.isPresent()) {
            locale = Locale.forLanguageTag(languageTag.get());
        }

        if (zoneID.isPresent()) {
            zone = TimeZone.getTimeZone(zoneID.get());
        }

        model.addAttribute("locale", locale);
        model.addAttribute("zone", zone);
        ...
    }

A couple of alternative Locales and TimeZones:

Summary

This article demonstrates Spring MVC with Thymeleaf templates by implementing a simple, but internationalized, clock web application.

[1] Part 2 of this series demonstrated the inclusion of Bulma artifacts and the use of Bootstrap here is to provide contrast.

[2] Arguably, the sorting of the lists should be part of the View and included in the template but that would overly complicate the implementation.

[3] An alternative strategy would be to include the POST @RequestParams as query parameters in the redirected URL.

[4] To avoid specifying the @SessionAttribute name attribute, the Java code must be compiled with the javac -parameters option so the method parameter names are available through reflection. Please see the configuration of the maven-compiler-plugin plug-in in the pom.xml.