Server side web application là ứng dụng web mà HTML page được kết xuất ngay tại web server rồi trả về cho trình duyệt. Ngược lại Client side rendered web application là ứng dụng web một phần web app được tải về từ server rồi tiếp tục tạo các yêu cầu gửi lên REST API lấy dữ liệu và sinh ra HTML tại trình duyệt. Bài viết này trình bày về Thymeleaf View Template Engine rất phổ biến khi lập trình Spring Boot. Bạn có thể tải mã nguồn ví dụ tại đây.

Giao diện khi chạy thử ứng dụng

1. View Template Engine là gì và có những chức năng căn bản nào?

Có quá nhiều web framework Ruby On Rails, C# ASP.net Core, JavaScript Node.js, PHP Laravel hay Java Spring Boot.... Nhưng một khi là những web framework nổi tiếng và phổ biến, thì chúng đều có chung đặc điểm:

  • Cho phép sử dụng 1 đến vài loại view template engine
  • Trả về REST API
  • Xử lý request theo đường dẫn, tham số, body và loại request (GET, POST, PUT, PATCH, DELETE) và trả về response
  • Quản lý session.
  • Gán và đọc cookie
  • Tạo chuỗi thành phần trung gian để xử lý request ~ middle ware
  • Xác thực (authentication) - phân quyền (authorization)
  • Tích hợp các thư viện kết nối CSDL khác nhau, gửi nhận message....

Hơi dài dòng tý, nhưng bài hôm nay tập trung về View Template Engine Thymeleaf. Công thức chung là :

Data + View Template ==> HTML

Chức năng căn bản của View Template Engine phải có:

  1. Đổ dữ liệu thường vào những ô trống (place holder)
  2. Vòng lặp để hiện thị dữ liệu dạng mảng: xuất ra dạng bảng, list, nhiều check box, nhiều radio box
  3. Điều kiện: if then else, ternary, switch case
  4. Khai báo biến
  5. Viết biểu thức
  6. Tái sử dụng, kế thừa từng phần template
  7. Thêm hoặc thay thế thuộc tính class, style... của thẻ HTML

Chức năng nâng cao của View Template Engine:

  1. Có các hàm xử lý chuỗi, list, date
  2. Sử dụng hàm viết bằng ngôn ngữ lập trình của web framework
  3. Tác động vào CSS, JavaScript
  4. Caching một phần template để không phải làm lại, tiết kiệm thời gian sinh mã HTML

Có hai tiêu chí rất quan trọng:

  1. Tốc độ xử lý: thời gian khớp dữ liệu vào view template phải nhanh, tốn ít CPU, RAM.
  2. Cú pháp view template không làm vỡ cấu trúc trang web. Khi Web Designer chuyển giao static HTML page cho lập trình viên. Lập trình viên bổ xung các mã View Template vào trang static HTML page. Nếu trang đó bị biến đổi khiến không thể xem được trên trình duyệt thì cú pháp view template engine tồi, obstrusive view template engine (can thiệp thô bạo vào HTML)!

Tôi không có đánh giá đo đạc tốc độ Thymeleaf, nhưng một điều chắc chắn Thymeleaf là view template được ưa dùng bởi các Java web framework. Nó có cú pháp rất thân thiện với HTML, chỉ bổ xung thuộc tính vào thẻ HTML sẵn có.

2. Thực hành cú pháp Thymeleaf

2.1 Các loại biểu thức

  • ${...}: biểu thức thay giá trị của biến vào template
  • *{...}: biểu thức thay thuộc tính của biến vào template. Hay dùng với form post
  • #{...}: message expression, biểu thức thay chuỗi đa ngôn ngữ từ file resource.
  • @{...}: link expression, biểu thức liên kết
  • ~{...}: fragement expression, biểu thức mảnh

2.2 Đổ dữ liệu sử dụng ${...}, th:textth:utext

th:text đổ text thuần không thẻ HTML vào view template

th:utext đổ text có thẻ HTML, CSS vào view template

Phương thức ở HomeController.java

@GetMapping("/text")
public String demoText(Model model) {
    Person tom = new Person("Tom", "USA", "1976-12-01", "male");
    model.addAttribute("person", tom);
    model.addAttribute("message", "<h2>Display <span style='color:red'>HTML</span> inside</h2>");
    return "text";
}

Cú pháp Thymeleaf ở file text.html

<div th:text="${person.name + ' : ' + person.nationality}"></div>
<div th:text="${person.name} + ' : ' + ${person.nationality}"></div>
<div th:text="|${person.name}  :  ${person.nationality}|"></div>
<div th:utext="${message}"></div>
<div th:text="${person}"></div>

Kết quả web trả về là

  • Giải thích: "${...}" để đánh dấu biểu thức thay biến vào chỗ trống.
  • "${person.name + ' : ' + person.nationality}" biểu thức cộng 2 hai biến
  • "${person.name} + ' : ' + ${person.nationality}" cộng hai biểu thức
  • "|${person.name} : ${person.nationality}|" cú pháp thay thế biến. Chú ý hai ký tự pipe | ở hai đầu
  • <div th:utext="${message}"></div> thì chuyên xuất chuỗi chứa thẻ HTML, CSS
  • th:text="${person}" xuất ra cả person.toString()

Đường dẫn có 2 loại: tuyệt đối và tương đối. Tham số đường dẫn cũng có hai loại: loại dùng dấu cách / và loại dùng tham số trong query string sau ký từ ? kiểu như https://google.com?q=keyword. Chúng ta có thể động hoá đường dẫn bằng cách gán biến trả về từ controller.

Hàm trả về ở HomeController.java

@GetMapping("/link")
public String linkExpression(Model model) {
    model.addAttribute("dynamiclink", "products");
    return "link";
}

cùng với template

<a th:href="@{/about}">Abosolute Link</a><br>
<a th:href="@{~/topic/thymeleaf}">Relative link 1</a><br>
<a th:href="@{topic/thymeleaf}">Relative link 2</a><br>
<a th:href="@{/about(foo='bar',tom='jerry')}">Query string parameters</a><br>
<a th:href="|/${dynamiclink}|">dynamic link 1</a><br>
<a th:href="@{${dynamiclink}}">dynamic link 2</a><br>
<a th:href="@{/page/(id=${dynamiclink})}">dynamic link 3</a><br>

sẽ cho kết quả

<a href="/about">Abosolute Link</a><br>
<a href="/topic/thymeleaf">Relative link 1</a><br>
<a href="topic/thymeleaf">Relative link 2</a><br>
<a href="/about?foo=bar&amp;tom=jerry">Query string parameters</a><br>
<a href="/products">dynamic link 1</a><br>
<a href="products">dynamic link 2</a><br>
<a href="/page/?id=products">dynamic link 3</a><br>

2.4 Tái sử dụng bằng th:fragment, th:insert, th:replace

Web site có nhiều trang khác nhau, để người dùng dễ dàng sử dụng thì các trang này cần có một số phần nên đồng nhất ví dụ như header, menu, footer, side bar.

Thymeleaf cung cấp:

  • th:fragment để định nghĩa một mảnh HTML cần tái sử dụng nhiều lần
  • th:insert để chèn một mảnh HTML định nghĩa trước đó vào trong thẻ hiện tại
  • th:replace để thay thế một mảnh HTML định nghĩa trước đó cho thẻ hiện tại

Có thể truyền tham số vào trong một fragment, khiến cho fragment đó hiển thị dữ liệu linh động hơn: th:fragment="header(title)" : truyền title vào fragment có tên là header. Xem code tại file template.html

<head th:fragment="header(title)">
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <link href="https://cdn.jsdelivr.net/npm/bootstrap@5.0.2/dist/css/bootstrap.min.css" rel="stylesheet" integrity="sha384-EVSTQN3/azprG1Anm3QDgpJLIm9Nao0Yz1ztcQTwFspd3yD65VohhpuuCOmLASjC" crossorigin="anonymous">
  <script src="https://cdn.jsdelivr.net/npm/bootstrap@5.0.2/dist/js/bootstrap.bundle.min.js" integrity="sha384-MrcW6ZMFYlzcLA8Nl+NtUVF0sA7MsXsP1UyJoMp4YLEuNSfAP+JcXn/tWtIaxVXM" crossorigin="anonymous"></script>
  <title th:text="${title}"></title>
</head>

Sử dụng lại fragment, xem ví dụ ở file about.html, products.html, service.html

<html lang="en">
<head th:replace="template.html :: header('about')">
</head>
<body>
  <header th:insert="template.html :: topmenu"> </header>
  <div class="container">
    <p>About us</p>
  </div>
  <header th:replace="template.html :: footer"> </header>
</body>
</html>

2.5 Điều kiện th:if, th:unless

Cần hiển thị icon theo giá trị person.gender

<td>
    <span th:if="${person.gender == 'female'}">🙎🏻‍♀️</span>
    <span th:unless="${person.gender == 'female'}">🙎🏻‍♂️</span>
</td>

Cần bổ xung class để đổi màu nền từng dòng ứng với person.gender dùng ternary condition ${condition} ? true_val : false_val

<tr th:each="person, iStat:${people}" th:classappend="${person.gender=='male'} ? table-primary : table-warning">

2.6 Switch th:switchth:case

Ứng với mỗi user.role có phần giải thích.

<td th:switch="${user.role}">
    <span th:case="admin">Quản lý hệ thống</span>
    <span th:case="editor">Duyệt bài</span>
    <span th:case="author">Tác giả</span>
    <span th:case="user">Người dùng</span>
</td>

2.7 Đổ dữ liệu List Box, Check Box, Radio Box

Cần chuẩn bị dữ liệu dạng List, Array. Xem code ví dụ ở file TravelController.java, TravelRequest.java, travel.html.

@Data
@AllArgsConstructor
public class Country {
  private String name;
  private String code;
}
public enum TravelType {
  BASIC("Basic"),
  ECONOMY("Economy"),
  LUXURY("Luxury"),
  PREMIUM("Premium");

  private String value;

  TravelType(String value) {
    this.value = value;
  }

  public String getValue() {
    return value;
  }
}

Khi người dùng truy cập http://localhost:8080/travel, phương thức này sẽ hứng request, đổ dữ liệu vào view template

@GetMapping("/travel")
public String showTravelForm(Model model) {
    model.addAttribute("countries", repo.getCountries());
    model.addAttribute("travelTypes", TravelType.values());
    model.addAttribute("travelRequest", new TravelRequest());
    return "travel";
}

Hiển thị List Box

Cần sử dụng th:each để duyệt mảng, th:text để hiển thị mô tả từng dòng của list box, th:value thể hiện giá trị của dòng, th:selected đánh dấu dòng nào được người dùng chọn. Giá trị text có thể giống value nhưng không nhất thiết. Đôi khi value dạng số unique ID ứng với primary key, còn text mô tả item.

<label for="countries">Nationality</label>
<select name="nationality" id="nationality">
    <option
    th:each="country:${countries}"
    th:text="${country.name}"
    th:value="${country.code}"
    th:selected="${travelRequest.nationality==country.code}">China</option>
</select>

Hiển thị Check Box

Check Box cho phép người dùng check nhiều ô. Còn Radio Box chỉ cho phép check 1 ô. Chúng ta cần dùng thêm thẻ label để hiển thị mô tả text bên cạnh từng lựa chọn của check box hay radio box.

<label for="visitedCountries">Tick countries you have visited</label><br>
<span th:each="country:${countries}">
    <input type="checkbox" name="visitedCountries"
    th:value="${country.code}"
    th:checked="${#lists.contains(travelRequest.visitedCountries, country.code)}">
    <label th:text="${country.name}" for="visitedCountries"></label><br>
</span>

Hiển thị Radio Box

<label for="travelType">Choose one travel type</label><br>
<span th:each="travel_type:${travelTypes}">
    <input type="radio" name="travelType"
    th:value="${travel_type}"
    th:checked="${travelRequest.travelType.value==travel_type.value}">
    <label th:text="${travel_type.value}" for="travelType"></label><br>
</span>

2.8 Làm sao để chọn mục List Box, check ô Check Box, tick ô Radio Box khi hiển thị lại form? th:selected, th:checked

Đối với List Box và Radio Box, lựa chọn chỉ có một, do đó chúng ta chỉ dùng hàm so sánh giá trị hiện tại của mục/ô có bằng giá trị thuộc tính bản ghi

th:selected="${travelRequest.nationality==country.code}"
th:checked="${travelRequest.travelType.value==travel_type.value}">

Tuy nhiên với radio box, người dùng được phép chọn nhiều ô, do đó chúng ta phải dùng list utility function #lists.contains(list_values, aValue)

th:checked="${#lists.contains(travelRequest.visitedCountries, country.code)}"

2.9 Hiển thị Form và gửi Form lên server: th:action, th:object, th:field

Form để nhập dữ liệu phục vụ cho thao tác tạo mới (Create), sửa đổi (Edit), thậm chí cả xoá (Delete). Có 3 khả năng:

  1. Trình duyệt gửi GET request yêu cầu hiển thị Form để tạo mới. Lúc này các giá trị các trường text trong form sẽ trắng, các trường List Box, Check Box, Radio Box có thể không chọn hoặc chọn giá trị mặc định. Chúng ta cần truyền đối tượng mô tả đầy đủ các trường dữ liệu của Form.
    Ví dụ tạo Form trống trong BMIController.java: model.addAttribute("bmiRequest", new BMIRequest());

    @GetMapping
    public String getBMIForm(Model model) {
     model.addAttribute("bmiRequest", new BMIRequest());
     model.addAttribute("bmiResult", null);
     return "bmi";
    }
  2. Trình duyệt gửi GET request tạo Form nhưng lần này có thêm tham số ID của bản ghi, lấy dữ liệu đổ vào Form

  3. Hứng POST request mà form gửi lên ở Controller

Thymeleaf cung cấp 3 cú pháp để làm việc với Form:

  1. th:action viết biểu thức đường dẫn mà Form sẽ gửi lên server. Nó khác thuộc tính action vốn có của Form ở chỗ, bạn có thể viết biểu thức, biến, so sánh, điều kiện...để động hoá đường dẫn.
  2. th:object khai báo đối tượng chứa dữ liệu các trường để điền vào form.
  3. th:field lấy dữ liệu trong từng thuộc tính của đối tượng đổ vào một trường text.
<form action="#" th:action="@{/bmi}" th:object="${bmiRequest}" method="post">
    <input type="text" placeholder="Your name" th:field="*{name}"/><br><br>
    <input type="text" placeholder="Your email" th:field="*{email}"/><br><br>
    <input type="text" placeholder="Your height" th:field="*{height}"/> (m)<br><br>
    <input type="text" placeholder="Your weight" th:field="*{weight}"/> (kg)<br><br>
    <button type="submit">Calculate BMI</button>
</form>

Khi Form chứa các trường list box, radio box, check box, bạn cần tham khảo mục hướng dẫn đổ dữ liệu vào List box, check box, radio box phía trên. Với các custom component, chúng ta dùng th:text, th:value, th:each, th:class, th:classappend để điền dữ liệu, thay đổi class style.

Phương thức xử lý POST request ở server sẽ có dạng

@PostMapping("/path")
public String handleForm(@ModelAttribute Request request, BindingResult bindingResult, Model model) {
    if (! bindingResult.hasErrors()) {
        //Logic xử lý nếu không có lỗi
        //Ghi hoặc cập nhật vào CSDL
    }
    model.addAttribute("key", formObject); //formObject có thể là chính request nếu chúng ta muốn hiển thị lại
    model.addAttribute("foo", valuesForListBox); //đổ dữ liệu vào Listbox
    model.addAttribute("bar", valuesForCheckBox); //đổ dữ liệu vào Checkbox
}

3. Một số cú pháp khác của thymeleaf

3.1 th:block

Thông thường chúng ta sẽ viết cú pháp Thymeleaf dưới dạng các thuộc tính nhúng vào các thẻ HTML. Tuy nhiên có trường hợp th:block lại hành xử giống như một thẻ html. Nó hữu ích khi chúng ta muốn thực hiện logic loop, condition ở cấp độ thẻ HTML chứ không ở cấp độ thuộc tính trong thẻ. Ví dụ ở file thblock.html, nhờ có th:block, chúng ta đặt được điều khiển vòng lặp th:eachth:switch ra ngoài thẻ li code trong sáng hơn nhiều:

<ul>
    <th:block th:each="travel_type : ${travelTypes}" th:switch="${travel_type.value}">
        <li th:case="Premium">
            <span th:text="${travel_type.value}">Premium</span>
            <input type="checkbox" name="Premium" th:value="jet"><label>Private Jet</label>
            <input type="checkbox" name="Premium" th:value="champaign"><label>Champaign</label>
        </li>
        <li th:case="Luxury">
            <span th:text="${travel_type.value}">Luxury</span>
            <input type="checkbox" name="Luxury" th:value="swimmingpool"><label>Swimming Pool</label>
            <input type="checkbox" name="Luxury" th:value="taxi"><label>Airport taxi</label>
        </li>
        <li th:case="*">
            <span th:text="${travel_type.value}">Other</span>
        </li>
    </th:block>
</ul>

3.2 Cú pháp bật tắt thuộc tính theo biểu thức

Có nhiều trường hợp chúng ta cần viết một biểu thức để bật hoặc tắt một thuộc tính trong thẻ HTML. Ví dụ:

  • thuộc tính hidden trong thẻ <input type="text"> phụ thuộc điều kiện so sánh nào đó hãy dùng th:hidden để viết biểu thức
  • thuộc tính loop trong thẻ <audio> phụ thuộc tham số truyền vào hãy dùng th:loop
    th:async    th:autofocus    th:autoplay
    th:checked    th:controls    th:declare
    th:default    th:defer    th:disabled
    th:formnovalidate    th:hidden    th:ismap
    th:loop    th:multiple    th:novalidate
    th:nowrap    th:open    th:pubdate
    th:readonly    th:required    th:reversed
    th:scoped    th:seamless    th:selected

4. Đa ngôn ngữ với thymeleaf

Trong dự án ví dụ tôi cũng đã minh hoạ đa ngôn ngữ ở http://localhost:8080/hello. Bài tiếp theo sẽ hướng dẫn chi tiết. Bài này đã quá dài rồi.

5. Tham khảo thêm

6. Kết luận

  1. Thymeleaf là một view template engine rất đầy đủ, xử lý được hầu hết các trường hợp phức tạp khi kết xuất HTML tại server
  2. Thymeleaf view template engine chính là phần "View" trong design pattern "Model - View - Controller"
  3. Chỉ cần xem kỹ các ví dụ trong bài này các bạn đã xử lý được khoảng 80% yêu cầu khi lập trình server side web Spring Boot
  4. Cuối cùng là nếu bạn muốn làm chủ Spring Boot, cách tốt nhất hãy đăng ký một khoá học lập trình Java Full Stack tại Techmaster. Giáo trình ở Techmaster viết chi tiết, luôn có nhiều ví dụ trực quan để bạn tham khảo và có nhiều dự án mẫu thú vị để bạn thực hành.