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 Locale s the user may select |
zones | A sorted List of TimeZone s 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="'↻'"/>
</form>
</main>
...
Two attributes must be added to the Model
by the GET
/clock/time
method, the List
s of Locale
s and TimeZone
s 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 Locale
s and TimeZone
s:
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
@RequestParam
s 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
.
↩