(This post has been updated at blog.hcf.dev with a later version of Spring Boot.)
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.
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.4.1</version>
</dependency>
<dependency>
<groupId>org.webjars</groupId>
<artifactId>jquery</artifactId>
<version>3.4.1</version>
</dependency>
<dependency>
<groupId>org.webjars</groupId>
<artifactId>popper.js</artifactId>
<version>1.15.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.4.1/jquery.js"></script>
<script src="/webjars/bootstrap/4.4.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
. ↩
Top comments (0)