Hình dung một template như một lá thư mẫu có chỗ trống: "Kính gửi ___, đơn ___ của bạn đã xong". Máy chủ làm đúng một việc trộn-thư — điền từng chỗ trống bằng dữ liệu thật rồi gửi đi trang hoàn chỉnh. Không phải mọi ứng dụng đều cần React. Bài này về cách Spring render HTML.
Bật
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-thymeleaf</artifactId>
</dependency>
Auto-configuration đặt thư mục template ở src/main/resources/templates/ và hậu tố .html.
Controller trả tên view
@Controller // KHÔNG phải @RestController
class TrangController {
@GetMapping("/don/{id}")
String xem(@PathVariable String id, Model model) {
model.addAttribute("don", svc.lay(id));
return "don/chi-tiet"; // -> templates/don/chi-tiet.html
}
}
Khác biệt then chốt với bài 12: @RestController trả dữ liệu, @Controller trả tên view. Nhầm hai cái này là nguyên nhân số một của lỗi "trình duyệt hiện ra chữ don/chi-tiet".
Model là túi dữ liệu truyền sang template — là xấp dữ liệu để trộn vào các chỗ trống.
Template
<div th:text="${don.ma}">mã mẫu</div>
<div th:if="${don.daHuy}">Đơn đã huỷ</div>
<tr th:each="m : ${don.mucs}">
<td th:text="${m.ten}"></td>
</tr>
<a th:href="@{/don/{id}(id=${don.ma})}">chi tiết</a>
Điểm mạnh riêng của Thymeleaf: tệp vẫn là HTML hợp lệ. Mở trực tiếp trong trình duyệt thì thấy "mã mẫu" thay vì lỗi cú pháp — tờ thư mẫu đã đọc được sẵn với chữ giữ-chỗ, nên người làm giao diện soát được bố cục mà không cần chạy ứng dụng.
Cú pháp @{...} cho URL rất đáng dùng: nó tự thêm context path, nên ứng dụng chạy ở / hay /app đều đúng.
Nó tự thoát ký tự
<div th:text="${duLieuNguoiDungNhap}"></div>
th:text luôn thoát HTML. Người dùng nhập <script>alert(1)</script> thì nó hiện ra dưới dạng chữ, không chạy — người thư ký điền đúng từng ký tự vào chỗ trống chứ không đọc <script> như một mệnh lệnh.
Đây là mặc định an toàn, và nó là lý do chính để dùng công cụ template thay vì tự ghép chuỗi HTML.
<div th:utext="${htmlTinCay}"></div>
th:utext không thoát. Chỉ dùng cho HTML bạn tự sinh hoặc đã làm sạch — với nội dung người dùng nhập, phải qua một bộ làm sạch như jsoup trước.
Layout
<!-- fragments/layout.html -->
<div th:fragment="header">...</div>
<!-- trang dùng -->
<div th:replace="~{fragments/layout :: header}"></div>
Với layout đầy đủ, thymeleaf-layout-dialect cho phép trang con kế thừa trang khung — gọn hơn nhiều so với chèn fragment ở mọi trang.
Một cái bẫy đã gặp: th:replace chạy trước th:each. Muốn lặp một fragment thì đặt th:each ở thẻ bao ngoài, không đặt cùng thẻ với th:replace.
Tài nguyên tĩnh
Spring Boot phục vụ tự động từ bốn thư mục trong src/main/resources:
static/ -> /**
public/
resources/
META-INF/resources/
Tệp static/css/style.css truy cập bằng /css/style.css.
Cache cho sản xuất:
spring:
web:
resources:
cache:
cachecontrol:
max-age: 365d
chain:
strategy:
content:
enabled: true
paths: /**
content strategy thêm mã băm nội dung vào tên tệp, nên bạn cache một năm mà vẫn cập nhật ngay khi sửa. Trong template dùng @{/css/style.css} và Thymeleaf tự viết ra tên có băm.
Khi nào chọn render phía máy chủ
Chọn Thymeleaf khi: trang chủ yếu là nội dung (blog, tài liệu, trang giới thiệu), khu quản trị nội bộ, SEO quan trọng, đội nhỏ không muốn duy trì hai codebase, hoặc bạn muốn một tệp jar là xong.
Chọn SPA khi: giao diện nhiều tương tác trạng thái phức tạp, cần offline, có đội frontend riêng, hoặc cùng một API phục vụ cả web lẫn di động.
Blog này chạy Spring Boot với Thymeleaf, và đó là quyết định đúng cho nó: nội dung là chính, SEO quan trọng, và một người duy trì.
Có lựa chọn thứ ba đang phổ biến lại: HTMX cộng Thymeleaf — server trả về mảnh HTML, HTMX thay phần trang. Nó cho phần lớn tính tương tác của SPA mà không cần build pipeline JavaScript.
Cái bẫy Content-Security-Policy
Nếu bạn bật CSP không có unsafe-inline — và bạn nên bật — thì mọi <script> viết thẳng trong template sẽ không chạy trên trình duyệt thật.
Điều khó chịu là test MockMvc vẫn xanh, vì nó không thực thi JavaScript. Lỗi chỉ lộ ra khi mở trình duyệt.
Quy ước thay thế: đưa script ra tệp trong static/js/, và thay onclick="..." bằng thuộc tính data-* cộng một trình xử lý chung.
Nếu chỉ thử một thứ sau bài này, đặt hai cái chỗ-trống cạnh nhau trong ba mươi giây: tạo một endpoint trả về chuỗi <b>đậm</b> và hiển thị bằng cả th:text lẫn th:utext. Cái đầu hiện ra đúng chuỗi <b>đậm</b>, cái sau hiện chữ đậm. Ba mươi giây đó là toàn bộ khác biệt giữa an toàn và một lỗ hổng XSS.
Mẫu số chung
Template phía máy chủ với thoát ký tự bật sẵn có ở khắp nơi — th:text của Thymeleaf, Jinja2 của Python, ERB của Rails, html/template của Go, và cả JSX của React — và cái mặc-định-thoát ấy là hòn đá tảng về bảo mật: bộ máy tách bạch dữ liệu và mã đánh dấu, chèn một giá trị vào như chữ trơ, không bao giờ diễn giải nó thành HTML; muốn chèn markup thì bạn phải cố ý chọn ra khỏi mặc định (th:utext, dangerouslySetInnerHTML, |safe) — và đó đúng là chỗ XSS trú ngụ. Cùng một ranh giới với truy vấn tham số hoá (dữ liệu, không phải mã) và sợi chỉ đừng-tin-đầu-vào: nội dung lạ là trơ cho tới khi bạn chủ động ban phép. Lại là an-toàn-mặc-định — đường an toàn không tốn thao tác nào, đường nguy hiểm đòi một hành động có ý thức.
Điều thứ hai: render ở đâu là một lựa chọn kiến trúc thật, không phải mốt. Phía máy chủ (nặng nội dung, cần SEO, một codebase, một tệp jar) so với SPA phía client (tương tác trạng thái phong phú, cần offline, một API nuôi cả web lẫn di động), cùng những con đường giữa như HTMX. Khớp nó với bài toán — "không phải app nào cũng cần React" đúng cùng một kiểu phán đoán công-cụ-theo-bài-toán như sức nặng framework hay REST-với-RPC — và xuyên suốt là thói quen giữ cái an toàn làm mặc định, cái nguy hiểm phải tường minh: th:utext hiếm khi dùng, một CSP không unsafe-inline (để một đoạn script nội tuyến lỡ tay đơn giản là không chạy), tên tệp gắn mã băm để cache một năm vẫn cập nhật ngay khi bạn sửa.
Ngày mai: Filter, Interceptor và các mẫu middleware thực dụng.