Trong hành trình học Java, sau khi đã nắm được Java Core, JVM, Collections, OOP và lập trình đa luồng, một câu hỏi rất tự nhiên sẽ xuất hiện: Làm thế nào để biến chương trình Java thành một ứng dụng Web mà người dùng có thể truy cập bằng trình duyệt?
Ngày nay, khi xây dựng một ứng dụng Web bằng Spring Boot, chúng ta có thể viết Controller với vài annotation, khai báo một Service, sử dụng Repository rồi để framework xử lý phần lớn công việc còn lại. Nhưng trước khi những tiện ích đó xuất hiện, lập trình viên phải tự hiểu và xử lý rất nhiều việc: nhận HTTP Request, đọc tham số, định tuyến URL, gọi nghiệp vụ, truy vấn database, tạo HTML và gửi HTTP Response về trình duyệt.
Đó chính là thế giới của Servlet, JSP và JDBC.
Không chỉ học thuộc API, mà phải hiểu được toàn bộ đường đi của một yêu cầu Web: từ lúc người dùng nhấn nút trên trình duyệt, qua HTTP, Tomcat, Servlet, DAO, MySQL rồi quay trở lại trình duyệt.
Sau bài học, chúng ta sẽ tự tay xây dựng một ứng dụng quản lý sản phẩm với đầy đủ bốn chức năng CRUD, đồng thời hiểu được tại sao Spring Framework ra đời và giải quyết những vấn đề gì.
Ứng dụng thực hành sẽ sử dụng Java, Apache Tomcat, JSP, Servlet, JDBC và MySQL. Các ví dụ sử dụng Jakarta Servlet 6.0, tương thích với Tomcat 10.1 và Java 11 trở lên. Để thuận tiện, chúng ta sẽ dùng Java 17, Maven và MySQL 8.x.
Lưu ý về phiên bản: Tomcat 10 trở lên sử dụng namespace jakarta.servlet.*, trong khi Tomcat 9 sử dụng javax.servlet.*. Hai namespace này không thể thay thế trực tiếp cho nhau. Bài viết sử dụng thống nhất Jakarta Servlet để tránh trộn lẫn API.
1. Tổng quan về Java Web và luồng hoạt động HTTP
Trước khi viết bất kỳ dòng Servlet nào, chúng ta cần trả lời ba câu hỏi:
- Trình duyệt giao tiếp với Java application bằng cách nào? - Ai tiếp nhận HTTP Request và gọi Java code của chúng ta? - Kết quả xử lý được gửi ngược về trình duyệt như thế nào?
Nếu chưa hiểu ba vấn đề này, Servlet rất dễ trở thành một tập hợp những method và annotation khó nhớ. Khi hiểu được bản chất, chúng ta sẽ thấy các thành phần liên kết với nhau rất logic.
1.1. Mô hình Client - Server trong lập trình Web
Hãy tưởng tượng chúng ta truy cập một website bán hàng để xem danh sách sản phẩm.
Trên máy tính cá nhân, trình duyệt Chrome đóng vai trò Client. Ứng dụng Java Web chạy trên một máy chủ đóng vai trò Server. Client gửi yêu cầu; Server tiếp nhận, xử lý và trả kết quả.
CLIENT - SERVER MODEL
Client Server
(Web Browser) (Java Web App)
| |
| HTTP Request |
|----------------------------->|
| |
| Apache Tomcat
| |
| Servlet
| |
| Service
| |
| DAO
| |
| MySQL
| |
| HTTP Response |
|<-----------------------------|
| |
Render HTML Process Request
Quá trình trên có thể diễn giải bằng một ví dụ thực tế:
1. Người dùng mở trình duyệt và truy cập http://localhost:8080/product-app/products.
2. Trình duyệt gửi HTTP Request đến máy chủ đang lắng nghe tại cổng 8080.
3. Tomcat tiếp nhận Request và tìm Servlet được ánh xạ với đường dẫn /products.
4. Servlet gọi tầng DAO để lấy danh sách sản phẩm từ MySQL.
5. DAO trả về danh sách đối tượng Java cho Servlet.
6. Servlet chuyển dữ liệu cho JSP để tạo HTML.
7. Tomcat gửi HTTP Response chứa HTML về trình duyệt.
8. Trình duyệt phân tích HTML và hiển thị danh sách sản phẩm.
Điểm quan trọng là trình duyệt không trực tiếp gọi Java method như productDao.findAll(). Trình duyệt chỉ giao tiếp với máy chủ thông qua giao thức mạng, chẳng hạn HTTP hoặc HTTPS.
Client không cần biết Server viết bằng Java, PHP, C# hay JavaScript. Client chỉ cần gửi đúng Request theo giao thức mà Server hỗ trợ.
Ngược lại, Java application cũng không cần biết người dùng đang sử dụng Chrome, Firefox hay một ứng dụng di động, miễn là Client gửi Request hợp lệ.
1.2. HTTP Request và HTTP Response
HTTP (Hypertext Transfer Protocol) là giao thức cho phép Client và Server trao đổi dữ liệu. HTTP hoạt động theo mô hình Request - Response: Client gửi yêu cầu, Server trả lời yêu cầu đó.
Một HTTP Request thường chứa các thành phần sau:
HTTP Request
│
├── Request Line
│ ├── Method: GET / POST / PUT / DELETE
│ ├── Path: /products
│ └── HTTP Version
│
├── Headers
│ ├── Host
│ ├── Accept
│ ├── Content-Type
│ └── Cookie
│
└── Body (optional)
└── Form data / JSON / binary data
Ví dụ, khi người dùng truy cập danh sách sản phẩm, trình duyệt có thể gửi Request tương tự như sau:
GET /product-app/products HTTP/1.1
Host: localhost:8080
Accept: text/html
Connection: keep-alive
Ở đây:
- GET: Method dùng để yêu cầu lấy tài nguyên.
- /product-app/products: Đường dẫn mà Client muốn truy cập.
- HTTP/1.1: Phiên bản giao thức trong ví dụ.
- Host: Tên máy chủ và cổng đích.
- Accept: Các loại nội dung mà Client có thể tiếp nhận.
Sau khi xử lý, Server trả về HTTP Response.
HTTP/1.1 200 OK
Content-Type: text/html; charset=UTF-8
Cache-Control: no-store
<!DOCTYPE html>
<html>
<head>
<title>Danh sách sản phẩm</title>
</head>
<body>
<h1>Danh sách sản phẩm</h1>
</body>
</html>
Response cũng có ba nhóm thành phần chính:
1. Status Line: Chứa phiên bản HTTP và mã trạng thái. 2. Headers: Mô tả loại nội dung, chính sách cache, cookie và những metadata khác. 3. Body: Chứa dữ liệu phản hồi, chẳng hạn HTML, JSON, hình ảnh hoặc nội dung khác.
Đây chính là dữ liệu mà các API như HttpServletRequest và HttpServletResponse giúp chúng ta xử lý thuận tiện bên trong Java.
1.2.1. GET và POST khác nhau như thế nào?
Hai HTTP Method mà chúng ta sẽ sử dụng nhiều nhất trong ứng dụng CRUD đầu tiên là GET và POST.
| Tiêu chí | GET | POST |
|---|---|---|
| Mục đích thông thường | Lấy dữ liệu hoặc biểu diễn tài nguyên | Gửi dữ liệu để xử lý hoặc tạo tài nguyên |
| Tham số thường gặp | Query string trong URL | Request body |
| Ví dụ | /products?id=10 |
POST /products/create |
| Idempotent | Có theo ngữ nghĩa HTTP | Không mặc định |
| Dùng cho thao tác xóa? | Không nên | Có thể dùng trong form HTML truyền thống |
Idempotent nghĩa là việc thực hiện cùng một yêu cầu nhiều lần có cùng hiệu quả dự kiến trên trạng thái của hệ thống như khi thực hiện một lần. Ví dụ, gọi GET để đọc danh sách nhiều lần không được làm thay đổi dữ liệu nghiệp vụ.
GET không phải là phương thức chỉ dành cho đọc trong mọi tình huống thực tế, nhưng theo ngữ nghĩa HTTP, GET là phương thức an toàn: Client không nên sử dụng nó để yêu cầu thay đổi trạng thái nghiệp vụ.
Với POST, dữ liệu biểu mẫu thường nằm trong Request Body. Tuy nhiên, POST không tự động bảo vệ dữ liệu khỏi bị đọc trên đường truyền. Khi truyền dữ liệu nhạy cảm, cần sử dụng HTTPS.
Ví dụ một form thêm sản phẩm:
<form action="products/create" method="post">
<label for="name">Tên sản phẩm</label>
<input type="text" id="name" name="name" required>
<label for="price">Giá sản phẩm</label>
<input type="number" id="price" name="price"
min="0.01" step="0.01" required>
<button type="submit">Thêm sản phẩm</button>
</form>
Khi nhấn nút submit, trình duyệt gửi Request tương tự như sau:
POST /product-app/products/create HTTP/1.1
Host: localhost:8080
Content-Type: application/x-www-form-urlencoded
name=Mechanical+Keyboard&price=89.99
Servlet có thể đọc dữ liệu bằng request.getParameter("name") và request.getParameter("price"). Chúng ta sẽ tìm hiểu chi tiết API này ở phần tiếp theo.
1.2.2. HTTP Status Codes
HTTP Status Code giúp Client biết kết quả xử lý Request ở mức giao thức. Khi viết Web application, việc trả đúng Status Code giúp ứng dụng dễ kiểm thử, dễ tích hợp và dễ xử lý lỗi.
| Mã | Ý nghĩa | Tình huống thường gặp |
|---|---|---|
200 OK |
Yêu cầu thành công | Hiển thị danh sách sản phẩm |
201 Created |
Tài nguyên mới được tạo | API tạo sản phẩm thành công |
204 No Content |
Thành công, không có nội dung phản hồi | API xóa sản phẩm |
301 / 302 |
Chuyển hướng | Điều hướng sang URL khác |
303 See Other |
Chuyển hướng đến tài nguyên khác, thường dùng sau POST | PRG sau khi thêm sản phẩm |
400 Bad Request |
Request không hợp lệ | Tham số không đúng định dạng |
401 Unauthorized |
Chưa xác thực hợp lệ | Chưa đăng nhập hoặc thông tin xác thực không hợp lệ |
403 Forbidden |
Không được phép thực hiện thao tác | Không có quyền xóa sản phẩm |
404 Not Found |
Không tìm thấy tài nguyên | Sản phẩm không tồn tại |
405 Method Not Allowed |
Method không được hỗ trợ | Gọi GET vào endpoint chỉ hỗ trợ POST |
500 Internal Server Error |
Lỗi phía Server | Lỗi không được xử lý trong ứng dụng |
Trong ứng dụng Web truyền thống, người dùng thường nhìn thấy một trang HTML thay vì JSON chứa Status Code. Tuy nhiên, phía dưới HTTP Response vẫn có mã trạng thái tương ứng.
Ví dụ, khi Servlet gọi:
response.sendError(
HttpServletResponse.SC_NOT_FOUND,
"Không tìm thấy sản phẩm"
);
Container sẽ xử lý việc tạo phản hồi lỗi tương ứng, có thể sử dụng trang lỗi đã cấu hình trong ứng dụng.
1.3. Apache Tomcat và Web Container
Đến đây, chúng ta đã hiểu Client gửi HTTP Request và Server trả về HTTP Response. Nhưng một câu hỏi khác xuất hiện: Ai đứng giữa HTTP và Java Servlet để điều phối toàn bộ quá trình này?
Câu trả lời trong ứng dụng của chúng ta là Apache Tomcat.
Tomcat là một Java Servlet Container, đồng thời cung cấp HTTP Connector và các thành phần cần thiết để triển khai ứng dụng Web Java truyền thống. Với Tomcat 10.1, chúng ta có môi trường tương thích với Jakarta Servlet 6.0.
Có thể hình dung kiến trúc như sau:
Web Browser
|
| HTTP
v
Tomcat Connector
|
v
HTTP Request Processing
|
v
Servlet Container
|
+--------------------+
| |
v v
URL Mapping Servlet Lifecycle
| |
+---------+----------+
|
v
ProductServlet
|
v
ProductService
|
v
ProductDAO
|
v
MySQL
Tomcat không đơn giản chỉ là một chương trình để chạy file Java. Nó cung cấp một môi trường thực thi Web application và thực hiện nhiều công việc mà nếu không có container, chúng ta phải tự xây dựng.
Các trách nhiệm quan trọng của Tomcat gồm:
- Tiếp nhận và phân tích HTTP Request thông qua Connector. - Xác định Web application và Servlet cần xử lý dựa trên URL Mapping. - Quản lý vòng đời của Servlet. - Tạo các đối tượng Request và Response để Servlet làm việc. - Cung cấp cơ chế forward, dispatch, session và các dịch vụ Servlet Container khác. - Quản lý tài nguyên ứng dụng trong quá trình triển khai và dừng ứng dụng.
Ví dụ, khi triển khai ứng dụng tại context path /product-app, URL:
http://localhost:8080/product-app/products
| | |
| | +-- Servlet Mapping: /products
| +------------- Context Path: /product-app
+------------------------- Host và Port
Trong ví dụ trên:
- localhost: Máy đang chạy ứng dụng.
- 8080: Cổng HTTP của Tomcat theo cấu hình mặc định thường gặp.
- /product-app: Context Path của ứng dụng.
- /products: Đường dẫn được ánh xạ đến Servlet.
JVM là môi trường thực thi bytecode Java.
Tomcat là Servlet Container chạy trên Java runtime.
Servlet là thành phần Java xử lý HTTP Request trong container.
Ba khái niệm này liên quan chặt chẽ với nhau nhưng không phải là một.
1.4. Luồng xử lý một HTTP Request từ đầu đến cuối
Hãy theo dõi một tình huống cụ thể: người dùng truy cập trang danh sách sản phẩm.
1. Browser
|
| GET /product-app/products
v
2. Tomcat Connector
|
v
3. Servlet Container
|
| Tìm URL Mapping
v
4. ProductServlet.doGet()
|
| Gọi DAO
v
5. ProductDAO.findAll()
|
| SQL SELECT
v
6. MySQL Database
|
| ResultSet
v
7. ProductDAO
|
| List<Product>
v
8. ProductServlet
|
| request.setAttribute(...)
| RequestDispatcher.forward(...)
v
9. products/list.jsp
|
| Render HTML
v
10. HTTP Response
|
v
11. Browser renders page
Điểm mấu chốt là Servlet không bắt buộc phải tự viết toàn bộ HTML. Servlet có thể lấy dữ liệu từ Model, lưu dữ liệu vào Request Attribute rồi chuyển quyền xử lý sang JSP.
Đây chính là nền tảng của mô hình MVC Model 2 mà chúng ta sẽ xây dựng trong bài học.
2. Servlet - Trái tim của Java Web
Nếu HTTP là ngôn ngữ giao tiếp giữa Client và Server, thì Servlet chính là một trong những thành phần giúp chúng ta xử lý ngôn ngữ đó bằng Java.
2.1. Servlet là gì?
Servlet là một thành phần Java chạy bên trong Servlet Container, thường được sử dụng để tiếp nhận và xử lý Request, sau đó tạo Response tương ứng.
Trong Jakarta Servlet API, chúng ta thường xây dựng HTTP Servlet bằng cách kế thừa HttpServlet.
Ví dụ Servlet đầu tiên:
package com.example.web;
import jakarta.servlet.ServletException;
import jakarta.servlet.annotation.WebServlet;
import jakarta.servlet.http.HttpServlet;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import java.io.IOException;
@WebServlet("/hello")
public class HelloServlet extends HttpServlet {
@Override
protected void doGet(
HttpServletRequest request,
HttpServletResponse response)
throws ServletException, IOException {
response.setContentType("text/html");
response.setCharacterEncoding("UTF-8");
response.getWriter().println(
"<h1>Hello Java Web!</h1>"
);
}
}
Sau khi triển khai ứng dụng, chúng ta có thể truy cập:
http://localhost:8080/product-app/hello
Trình duyệt sẽ nhận được HTML chứa dòng chữ Hello Java Web!.
Hãy phân tích từng phần:
1. @WebServlet("/hello"): Khai báo URL Mapping cho Servlet.
2. extends HttpServlet: Kế thừa các khả năng xử lý HTTP có sẵn.
3. doGet(): Xử lý Request sử dụng HTTP GET.
4. HttpServletRequest: Đại diện cho Request được container cung cấp.
5. HttpServletResponse: Đại diện cho Response mà Servlet sẽ tạo.
6. response.getWriter(): Ghi nội dung văn bản vào Response Body.
Đoạn code này giúp chúng ta hiểu bản chất của Servlet, nhưng chưa phải cách tổ chức phù hợp cho một ứng dụng thực tế. Nếu tiếp tục viết toàn bộ HTML trong Servlet, code sẽ nhanh chóng trở nên khó bảo trì.
2.2. Vòng đời của Servlet
Đây là một chủ đề rất quan trọng vì Servlet không hoạt động giống một đối tượng thông thường được tạo mới sau mỗi lần người dùng truy cập.
Servlet Container quản lý vòng đời của Servlet. Trong một vòng đời điển hình, container thực hiện các bước sau:
Servlet Class
|
v
Create Servlet Instance
|
v
init()
|
v
service()
|
+---- Request 1
|
+---- Request 2
|
+---- Request 3
|
v
destroy()
|
v
Servlet Removed
Ba method quan trọng nhất là init(), service() và destroy().
A. init() - Khởi tạo Servlet
Container gọi init() khi khởi tạo Servlet, thông thường chỉ một lần trong vòng đời của instance đó. Đây là nơi có thể thực hiện công việc khởi tạo tài nguyên gắn với vòng đời Servlet.
@Override
public void init() throws ServletException {
System.out.println("Servlet initialized");
}
Trong ứng dụng thực tế, chúng ta không nên tự tạo một JDBC Connection duy nhất rồi dùng chung cho mọi Request trong suốt vòng đời Servlet. Connection cần được quản lý theo cơ chế phù hợp, thường là lấy từ Connection Pool và đóng lại sau khi sử dụng.
B. service() - Tiếp nhận và phân phối Request
Container gọi service() để xử lý các Request. Với HttpServlet, method này phân phối Request theo HTTP Method đến các method tương ứng như doGet(), doPost(), doPut() hoặc doDelete().
protected void doGet(
HttpServletRequest request,
HttpServletResponse response)
throws ServletException, IOException {
// Xử lý HTTP GET
}
protected void doPost(
HttpServletRequest request,
HttpServletResponse response)
throws ServletException, IOException {
// Xử lý HTTP POST
}
Thông thường, chúng ta không cần override service() trong ứng dụng CRUD cơ bản. Hãy override doGet() và doPost() để giữ code rõ ràng.
C. destroy() - Giải phóng tài nguyên
Khi Servlet bị loại bỏ khỏi hoạt động, container có thể gọi destroy() để ứng dụng giải phóng tài nguyên đã khởi tạo.
@Override
public void destroy() {
System.out.println("Servlet destroyed");
}
Không nên nhầm destroy() với việc đóng tất cả JDBC Connection của ứng dụng nếu các Connection đó được quản lý bởi Connection Pool. Chúng ta chỉ giải phóng tài nguyên thực sự thuộc quyền quản lý của Servlet.
Trong điều kiện hoạt động thông thường, một Servlet instance có thể xử lý nhiều Request. Container có thể gọi các method xử lý Request đồng thời trên cùng instance.
Vì vậy, không được lưu dữ liệu riêng của từng người dùng vào instance field có thể thay đổi, chẳng hạn
private String currentUser;. Việc đó có thể gây ra Race Condition và làm lẫn dữ liệu giữa các Request.
2.2.1. Servlet và Multithreading
Giả sử có ba người dùng truy cập website gần như cùng lúc.
ProductServlet
|
+---------+---------+
| | |
v v v
Thread 1 Thread 2 Thread 3
Request A Request B Request C
Container có thể sử dụng nhiều thread để xử lý các Request. Nếu Servlet chứa một biến instance có thể thay đổi, những thread này có thể cùng đọc và ghi biến đó.
Ví dụ không an toàn:
@WebServlet("/unsafe")
public class UnsafeServlet extends HttpServlet {
private String username;
@Override
protected void doGet(
HttpServletRequest request,
HttpServletResponse response)
throws IOException {
username = request.getParameter("username");
response.getWriter().println(username);
}
}
Giả sử Request A gửi username=Alice và Request B gửi username=Bob. Hai thread có thể ghi đè giá trị của nhau trước khi Response được tạo.
Cách an toàn hơn là dùng biến local trong method:
@WebServlet("/safe")
public class SafeServlet extends HttpServlet {
@Override
protected void doGet(
HttpServletRequest request,
HttpServletResponse response)
throws IOException {
String username = request.getParameter("username");
response.getWriter().println(username);
}
}
Biến local thuộc về lần thực thi method tương ứng. Tuy nhiên, nếu biến local chứa reference đến một object mutable được chia sẻ giữa các thread thì vẫn cần xem xét vấn đề đồng bộ hóa.
2.3. HttpServletRequest - Đọc dữ liệu từ Client
HttpServletRequest cung cấp các API để đọc thông tin của HTTP Request. Đây là công cụ mà chúng ta sẽ sử dụng liên tục trong ứng dụng CRUD.
A. Đọc tham số từ URL
Giả sử người dùng truy cập URL:
http://localhost:8080/product-app/products?id=15
Servlet có thể đọc tham số id bằng code sau:
String id = request.getParameter("id");
Điểm cần chú ý là getParameter() trả về String, không phải int. Nếu muốn dùng giá trị đó để truy vấn database, chúng ta phải kiểm tra và chuyển kiểu.
int productId;
try {
productId = Integer.parseInt(
request.getParameter("id")
);
} catch (NumberFormatException e) {
response.sendError(
HttpServletResponse.SC_BAD_REQUEST,
"ID sản phẩm không hợp lệ"
);
return;
}
Đoạn code trên xử lý trường hợp tham số không tồn tại hoặc không phải số nguyên hợp lệ. Trong ứng dụng thực tế, chúng ta cũng cần xác định quy tắc đối với ID bằng 0 hoặc số âm.
B. Đọc dữ liệu từ POST Form
Với HTML Form sử dụng application/x-www-form-urlencoded, chúng ta vẫn sử dụng getParameter():
String name = request.getParameter("name");
String priceText = request.getParameter("price");
Để xử lý tiếng Việt trong Form, chúng ta cần cấu hình encoding trước khi đọc tham số POST.
request.setCharacterEncoding("UTF-8");
String name = request.getParameter("name");
Việc thiết lập encoding cần thực hiện trước lần đọc tham số đầu tiên. Nếu đọc tham số rồi mới thiết lập encoding, việc thay đổi có thể không còn tác dụng đối với dữ liệu đã được phân tích.
C. Đọc HTTP Headers
Headers cung cấp thông tin bổ sung về Request. Chúng ta có thể đọc chúng bằng các method sau:
String userAgent = request.getHeader("User-Agent");
String accept = request.getHeader("Accept");
String contentType = request.getContentType();
String method = request.getMethod();
String requestUri = request.getRequestURI();
Những thông tin này hữu ích khi kiểm tra Request, xử lý nội dung được gửi lên hoặc ghi log. Không nên dùng các Header do Client kiểm soát làm căn cứ xác thực người dùng hoặc phân quyền nếu không có cơ chế xác minh phù hợp.
D. Đọc Query String và lấy thông tin Session
String queryString = request.getQueryString();
String contextPath = request.getContextPath();
String servletPath = request.getServletPath();
HttpSession session = request.getSession();
session.setAttribute("username", "Alice");
Trong đó:
- getContextPath(): Lấy context path của ứng dụng.
- getServletPath(): Lấy phần đường dẫn được ánh xạ đến Servlet.
- getSession(): Lấy Session hiện có hoặc tạo Session mới.
Nếu chỉ muốn lấy Session hiện có mà không tạo mới, chúng ta có thể dùng request.getSession(false).
2.3.1. HttpServletResponse - Trả dữ liệu về Client
Nếu Request là dữ liệu đầu vào thì Response là kết quả đầu ra mà Server gửi về Client.
Ba thao tác thường gặp nhất là:
- Ghi nội dung Response Body. - Thiết lập Status Code và Headers. - Chuyển hướng trình duyệt đến URL khác.
Ví dụ tạo Response HTML:
response.setContentType("text/html; charset=UTF-8");
response.setStatus(HttpServletResponse.SC_OK);
response.getWriter().println(
"<h1>Danh sách sản phẩm</h1>"
);
Với Response JSON, chúng ta có thể thiết lập Content Type tương ứng:
response.setContentType("application/json");
response.setCharacterEncoding("UTF-8");
response.getWriter().write(
"{\"id\":1,\"name\":\"Keyboard\"}"
);
Trong ví dụ trên, JSON được tạo thủ công để minh họa. Khi xây dựng REST API thực tế, nên sử dụng thư viện JSON phù hợp thay vì tự nối chuỗi dữ liệu, nhằm tránh lỗi định dạng và lỗi escape.
2.4. Forward và Redirect - Hai cơ chế không thể nhầm lẫn
Đây là một trong những chủ đề quan trọng nhất khi xây dựng ứng dụng Servlet/JSP. Cả hai cơ chế đều có thể khiến người dùng nhìn thấy một trang khác, nhưng cách hoạt động hoàn toàn khác nhau.
A. Forward - Chuyển tiếp nội bộ phía Server
Forward sử dụng RequestDispatcher để chuyển việc xử lý Request sang Servlet hoặc JSP khác trong cùng ứng dụng. Client không gửi thêm một HTTP Request mới.
Browser
|
| Request A
v
ProductServlet
|
| setAttribute("products", ...)
|
| forward()
v
products/list.jsp
|
| Render HTML
v
Browser receives Response A
Ví dụ:
request.setAttribute("message", "Hello from Servlet");
request.getRequestDispatcher("/WEB-INF/views/hello.jsp")
.forward(request, response);
JSP có thể đọc dữ liệu được lưu bằng request.setAttribute(). Vì vẫn sử dụng cùng Request nên Request Attribute được giữ nguyên khi forward.
Forward thường được sử dụng để chuyển dữ liệu từ Controller sang View.
B. Redirect - Yêu cầu trình duyệt truy cập URL khác
Redirect yêu cầu Client gửi một Request mới đến URL được chỉ định trong Response.
Browser | | POST /products/create v ProductServlet | | Insert database | | Redirect response | Location: /product-app/products v Browser | | GET /product-app/products v ProductServlet | v List page
Ví dụ:
response.sendRedirect(
request.getContextPath() + "/products"
);
Trong ứng dụng CRUD, Redirect rất hữu ích sau khi tạo, sửa hoặc xóa thành công. Nó giúp tránh tình trạng người dùng nhấn Refresh rồi vô tình gửi lại POST Request.
C. So sánh Forward và Redirect
| Tiêu chí | Forward | Redirect |
|---|---|---|
| Nơi xử lý | Phía Server | Client gửi Request mới |
| Số HTTP Request | Một Request ban đầu | Thường có Request ban đầu và Request mới |
| URL trên trình duyệt | Không đổi | Thay đổi theo URL đích |
| Request Attribute | Được giữ khi forward cùng Request | Không tự động giữ qua Request mới |
| Sử dụng phổ biến | Controller chuyển sang JSP | Chuyển hướng sau thao tác ghi dữ liệu |
D. Post/Redirect/Get (PRG)
PRG là mẫu thiết kế thường dùng cho HTML Form:
POST | v Process Form | v Redirect (303 See Other) | v GET | v Render Result Page
Đối với ứng dụng Web truyền thống, chúng ta có thể dùng sendRedirect() để thực hiện Redirect. Khi cần kiểm soát rõ ngữ nghĩa HTTP sau POST, có thể trả về Status Code 303 See Other và Header Location.
String target = response.encodeRedirectURL(
request.getContextPath() + "/products"
);
response.setStatus(HttpServletResponse.SC_SEE_OTHER);
response.setHeader("Location", target);
Redirect không thay thế việc chống gửi trùng ở mức nghiệp vụ. Với các thao tác thanh toán hoặc tạo giao dịch quan trọng, chúng ta còn cần cơ chế idempotency và các biện pháp bảo vệ thích hợp.
2.4.1. URL Mapping bằng @WebServlet
URL Mapping giúp Tomcat biết Servlet nào chịu trách nhiệm xử lý một đường dẫn nhất định.
Cách đơn giản nhất là sử dụng annotation:
@WebServlet("/products")
public class ProductServlet extends HttpServlet {
// ...
}
Với khai báo trên, Servlet được ánh xạ đến đường dẫn /products bên trong ứng dụng.
Ngoài annotation, chúng ta có thể cấu hình URL Mapping trong WEB-INF/web.xml.
<?xml version="1.0" encoding="UTF-8"?>
<web-app xmlns="https://jakarta.ee/xml/ns/jakartaee"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="https://jakarta.ee/xml/ns/jakartaee
https://jakarta.ee/xml/ns/jakartaee/web-app_6_0.xsd"
version="6.0">
<servlet>
<servlet-name>ProductServlet</servlet-name>
<servlet-class>
com.example.controller.ProductServlet
</servlet-class>
</servlet>
<servlet-mapping>
<servlet-name>ProductServlet</servlet-name>
<url-pattern>/products</url-pattern>
</servlet-mapping>
</web-app>
Với dự án nhỏ, annotation giúp cấu hình ngắn gọn. Với hệ thống có nhiều cấu hình tập trung, web.xml vẫn có thể hữu ích.
@WebServlet("/products") không phải là URL đầy đủ. Context Path được xác định bởi cấu hình triển khai ứng dụng, còn URL Mapping là phần đường dẫn do Servlet đảm nhiệm.
3. JSP và mô hình MVC Model 2
Ở phần trước, chúng ta đã tạo được một Servlet trả HTML về trình duyệt. Cách làm đó hoạt động, nhưng có một vấn đề: HTML và Java đang bị trộn lẫn trong cùng một class.
Hãy tưởng tượng một trang danh sách sản phẩm có bảng HTML, CSS, nút sửa, nút xóa, phân trang và thông báo lỗi. Nếu toàn bộ giao diện được viết bằng response.getWriter().println(), việc sửa giao diện sẽ nhanh chóng trở thành một cơn ác mộng.
JSP ra đời để giải quyết một phần vấn đề đó bằng cách cho phép xây dựng trang Web từ HTML và sử dụng các cơ chế tích hợp với Java Web để hiển thị dữ liệu động.
3.1. JSP thực sự là gì?
JSP (Jakarta Server Pages, trước đây gọi là JavaServer Pages) là công nghệ phía Server cho phép tạo nội dung Web động. JSP thường được dùng làm tầng View trong kiến trúc MVC Model 2.
Một điểm rất quan trọng: JSP không được trình duyệt thực thi trực tiếp. JSP được xử lý ở phía Server và trong cơ chế triển khai truyền thống, nội dung JSP được chuyển thành Servlet rồi biên dịch, thực thi trong Servlet Container.
products/list.jsp
|
v
JSP Translation
|
v
Generated Servlet
|
v
Java Compilation
|
v
Servlet Execution
|
v
HTML Response
|
v
Browser
Quá trình dịch và biên dịch JSP thường được thực hiện khi trang được truy cập lần đầu hoặc khi cần biên dịch lại. Những lần truy cập sau thường sử dụng Servlet đã được biên dịch, miễn là trang không cần biên dịch lại.
Ví dụ một file JSP:
<!DOCTYPE html>
<html lang="vi">
<head>
<meta charset="UTF-8">
<title>Java Web</title>
</head>
<body>
<h1>Xin chào Java Web!</h1>
</body>
</html>
Đây gần như là HTML thông thường. Điểm khác biệt là JSP có thể sử dụng Expression Language, JSTL và các cơ chế hiển thị dữ liệu được cung cấp bởi Jakarta Pages và hệ sinh thái liên quan.
3.2. Các thành phần cơ bản của JSP
JSP truyền thống có ba thành phần thường được nhắc đến trong các tài liệu nhập môn: Scriptlets, Expressions và Directives.
A. Scriptlet - Nhúng Java code vào JSP
Scriptlet sử dụng cú pháp <% ... %>.
<%
String username = "Alice";
int age = 25;
%>
<p>Username: <%= username %></p>
<p>Age: <%= age %></p>
Đoạn code trên cho thấy JSP có thể thực thi Java code. Tuy nhiên, đây không phải là phong cách nên sử dụng khi xây dựng ứng dụng mới.
Vì sao?
- Logic nghiệp vụ bị trộn với giao diện. - Code HTML khó đọc khi xen kẽ nhiều khối Java. - Khó kiểm thử độc lập và khó tái sử dụng logic. - Tăng nguy cơ tạo ra lỗ hổng XSS nếu xuất dữ liệu không an toàn.
B. Expression - Xuất giá trị ra Response
JSP Expression có cú pháp <%= expression %>. Nó đánh giá biểu thức và ghi giá trị vào nội dung Response.
<p>Current time: <%= new java.util.Date() %></p>
Cú pháp này hữu ích để hiểu JSP hoạt động như thế nào, nhưng trong code mới nên ưu tiên EL và JSTL. Đặc biệt, JSP Expression không tự động escape HTML, nên không nên dùng nó để xuất trực tiếp dữ liệu do người dùng cung cấp.
C. Directive - Chỉ thị cho JSP Container
Directive cung cấp thông tin cho quá trình dịch JSP. Có ba directive quan trọng trong JSP truyền thống:
- <%@ page ... %>: Cấu hình trang JSP.
- <%@ include file="..." %>: Bao gồm nội dung file tại thời điểm dịch JSP.
- <%@ taglib ... %>: Khai báo thư viện tag như JSTL.
Ví dụ:
<%@ page contentType="text/html; charset=UTF-8"
pageEncoding="UTF-8" %>
Directive trên giúp cấu hình kiểu nội dung và encoding của JSP. Khi tạo ứng dụng mới, chúng ta vẫn cần chú ý encoding thống nhất giữa HTML, Request, Response và database.
3.2.1. Tại sao không nên viết Java code trong JSP?
Hãy so sánh hai cách hiển thị danh sách sản phẩm.
Cách 1: Trộn Java và HTML
<%
List<Product> products =
(List<Product>) request.getAttribute("products");
for (Product p : products) {
%>
<tr>
<td><%= p.getName() %></td>
<td><%= p.getPrice() %></td>
</tr>
<%
}
%>
Cách 2: Controller chuẩn bị dữ liệu, JSP tập trung hiển thị
<c:forEach var="product" items="${products}">
<tr>
<td><c:out value="${product.name}"/></td>
<td><c:out value="${product.price}"/></td>
</tr>
</c:forEach>
Cách thứ hai giúp người đọc nhanh chóng nhận ra cấu trúc HTML và dữ liệu cần hiển thị. Phần xử lý truy vấn database, chuyển đổi kiểu dữ liệu và kiểm tra nghiệp vụ không xuất hiện trong JSP.
Controller quyết định cần làm gì.
Model chứa dữ liệu và logic nghiệp vụ.
View quyết định dữ liệu được trình bày như thế nào.
Một View tốt không nên phải biết SQL nào đã được thực thi để tạo ra dữ liệu.
3.3. Mô hình MVC Model 2
MVC là viết tắt của Model - View - Controller. Đây là mô hình tổ chức phần mềm giúp phân tách trách nhiệm của các thành phần trong ứng dụng.
Trong Java Web truyền thống, chúng ta thường triển khai MVC Model 2 như sau:
Client
|
v
Controller
Servlet
|
+---------+---------+
| |
v v
Model View
Java Classes JSP
|
v
DAO/JDBC
|
v
Database
A. Model
Model biểu diễn dữ liệu và các quy tắc nghiệp vụ của ứng dụng. Trong bài thực hành, Model gồm các lớp như Product, và có thể được mở rộng với các lớp Service xử lý nghiệp vụ.
B. View
View chịu trách nhiệm hiển thị dữ liệu cho người dùng. Chúng ta sử dụng JSP để tạo HTML, kết hợp với CSS và JavaScript ở phía Client.
C. Controller
Controller tiếp nhận Request, đọc tham số, kiểm tra dữ liệu đầu vào, gọi tầng xử lý nghiệp vụ và lựa chọn View hoặc URL tiếp theo. Trong ứng dụng này, Controller là các Servlet.
D. DAO - Data Access Object
DAO là một mẫu tổ chức code thường được sử dụng để tách phần truy cập dữ liệu ra khỏi nghiệp vụ. DAO không phải một thành phần bắt buộc của MVC, nhưng là một lựa chọn thực tế giúp Model và Controller không phải trực tiếp viết SQL.
Ví dụ luồng hiển thị danh sách sản phẩm:
Browser
|
v
ProductServlet
|
| productDAO.findAll()
v
ProductDAO
|
| JDBC
v
MySQL
|
| List<Product>
v
ProductServlet
|
| request.setAttribute("products", ...)
|
| forward()
v
list.jsp
|
v
HTML Response
Mỗi thành phần chỉ tập trung vào nhiệm vụ của mình. Đây chính là nền tảng của Clean Code và Separation of Concerns: tách biệt các mối quan tâm để phần mềm dễ kiểm thử, thay đổi và bảo trì.
3.3.1. JavaBean là gì?
Trong nhiều ứng dụng Java Web truyền thống, chúng ta sử dụng JavaBean để biểu diễn dữ liệu. Một JavaBean theo quy ước truyền thống thường có constructor không tham số, các thuộc tính được truy cập qua getter/setter và triển khai Serializable khi cần khả năng tuần tự hóa.
Ví dụ lớp Product:
package com.example.model;
import java.io.Serializable;
import java.math.BigDecimal;
public class Product implements Serializable {
private int id;
private String name;
private BigDecimal price;
private String description;
public Product() {
}
public Product(
int id,
String name,
BigDecimal price,
String description) {
this.id = id;
this.name = name;
this.price = price;
this.description = description;
}
public int getId() {
return id;
}
public void setId(int id) {
this.id = id;
}
public String getName() {
return name;
}
public void setName(String name) {
this.name = name;
}
public BigDecimal getPrice() {
return price;
}
public void setPrice(BigDecimal price) {
this.price = price;
}
public String getDescription() {
return description;
}
public void setDescription(String description) {
this.description = description;
}
}
Ở đây, chúng ta sử dụng BigDecimal thay vì double để biểu diễn giá sản phẩm. Đây là lựa chọn phù hợp hơn cho các giá trị tiền tệ vì double sử dụng số dấu phẩy động nhị phân và không thể biểu diễn chính xác nhiều số thập phân thông thường.
Trong JSP, getter có thể được truy cập thông qua EL. Ví dụ ${product.name} thường tương ứng với việc truy cập thuộc tính qua getter getName().
3.4. EL, JSTL và bảo mật đầu ra
Để JSP dễ đọc và giảm nhu cầu nhúng Java code, chúng ta sử dụng hai cơ chế quan trọng: Expression Language (EL) và JSTL (Jakarta Standard Tag Library).
EL cho phép truy cập dữ liệu từ các scope và đối tượng JavaBean.
${product.name}
${product.price}
${requestScope.products}
${sessionScope.username}
JSTL cung cấp các tag phục vụ việc lặp dữ liệu, điều kiện và những thao tác trình bày phổ biến.
Ví dụ sử dụng JSTL Core:
<%@ taglib prefix="c" uri="jakarta.tags.core" %>
<c:if test="${not empty products}">
<p>Có sản phẩm trong hệ thống.</p>
</c:if>
<c:forEach var="product" items="${products}">
<p>
<c:out value="${product.name}"/>
</p>
</c:forEach>
Với Jakarta Tags 3.x, URI chuẩn của thư viện Core là jakarta.tags.core. Chúng ta sẽ khai báo dependency JSTL tương ứng trong file Maven ở phần thực hành.
Chú ý bảo mật: EL tự nó không tự động escape mọi dữ liệu đầu ra trong HTML. Đối với nội dung do người dùng nhập, nên sử dụng <c:out> hoặc cơ chế escape phù hợp với ngữ cảnh HTML.
Ví dụ:
<c:out value="${product.name}"/>
Nếu người dùng nhập tên sản phẩm chứa chuỗi HTML hoặc JavaScript độc hại, việc escape đúng ngữ cảnh sẽ giúp giảm nguy cơ Cross-Site Scripting (XSS).
Trong bài thực hành, chúng ta sẽ đặt các file JSP bên trong thư mục WEB-INF/views. Trình duyệt không thể truy cập trực tiếp các JSP này bằng URL thông thường; thay vào đó, Servlet sẽ forward đến chúng.
4. JDBC - Kết nối cơ sở dữ liệu với Java
Đến đây, chúng ta đã có thể nhận HTTP Request và tạo HTML Response. Nhưng nếu dữ liệu sản phẩm chỉ nằm trong một danh sách Java được tạo sẵn, ứng dụng sẽ mất dữ liệu khi khởi động lại và không thể chia sẻ dữ liệu bền vững giữa các lần truy cập.
Chúng ta cần một nơi lưu trữ dữ liệu. Trong bài học này, đó là MySQL.
Vấn đề tiếp theo là: Java giao tiếp với MySQL bằng cách nào?
Câu trả lời là JDBC (Java Database Connectivity).
4.1. JDBC là gì?
JDBC là bộ API của Java dùng để kết nối và thao tác với cơ sở dữ liệu quan hệ thông qua JDBC Driver.
JDBC cung cấp các interface và class quan trọng như:
- DriverManager: Tạo kết nối thông qua JDBC Driver.
- Connection: Đại diện cho một kết nối đến database.
- Statement: Thực thi SQL được tạo thành chuỗi.
- PreparedStatement: Thực thi SQL có tham số.
- ResultSet: Đọc kết quả truy vấn.
- SQLException: Đại diện cho lỗi JDBC/SQL.
Java Application
|
v
JDBC API
|
v
MySQL JDBC Driver
|
v
MySQL Server
|
v
Database
JDBC API là lớp giao tiếp chung. Driver cụ thể chịu trách nhiệm thực hiện giao tiếp với hệ quản trị cơ sở dữ liệu tương ứng.
Ví dụ, để kết nối MySQL, chúng ta sử dụng MySQL Connector/J. Nếu đổi sang PostgreSQL, ứng dụng sẽ cần JDBC Driver phù hợp và có thể phải điều chỉnh SQL theo đặc điểm của database mới.
4.2. Các bước kết nối MySQL bằng JDBC
Để truy vấn dữ liệu bằng JDBC, chúng ta thường thực hiện các bước sau:
1. Obtain JDBC Connection
|
v
2. Prepare SQL Statement
|
v
3. Bind Parameters
|
v
4. Execute SQL
|
v
5. Read ResultSet
|
v
6. Close Resources
Ví dụ kết nối MySQL:
String url =
"jdbc:mysql://localhost:3306/product_db";
String username = System.getenv("DB_USERNAME");
String password = System.getenv("DB_PASSWORD");
try (Connection connection =
DriverManager.getConnection(url, username, password)) {
System.out.println("Connected to MySQL!");
} catch (SQLException e) {
throw new RuntimeException("Cannot connect to database", e);
}
Trong code trên, chúng ta sử dụng biến môi trường để lấy thông tin xác thực thay vì ghi cứng mật khẩu trong source code.
Với MySQL Connector/J hiện đại, JDBC Driver thường được tự động đăng ký khi thư viện nằm trong classpath. Vì vậy, chúng ta thường không cần gọi thủ công Class.forName("com.mysql.cj.jdbc.Driver").
Các thành phần của JDBC URL:
jdbc:mysql://localhost:3306/product_db
| | | |
| | | +-- Database name
| | +---------- Port
| +-------------------- Host
+---------------------------- JDBC protocol
Nếu MySQL chạy trên máy cục bộ với cổng mặc định 3306, URL này cho biết Java cần kết nối đến database product_db.
4.2.1. Statement và executeQuery()
Giả sử chúng ta cần lấy danh sách sản phẩm:
String sql =
"SELECT id, name, price FROM products";
try (Connection connection =
DriverManager.getConnection(url, username, password);
Statement statement = connection.createStatement();
ResultSet rs = statement.executeQuery(sql)) {
while (rs.next()) {
int id = rs.getInt("id");
String name = rs.getString("name");
BigDecimal price = rs.getBigDecimal("price");
System.out.println(id + " - " + name + " - " + price);
}
}
Ở đây:
- executeQuery() thường dùng cho câu lệnh trả về ResultSet, chẳng hạn SELECT.
- rs.next() di chuyển con trỏ đến dòng tiếp theo.
- getInt(), getString(), getBigDecimal() lấy giá trị theo cột.
Chúng ta sử dụng try-with-resources để tự động đóng các tài nguyên JDBC khi ra khỏi khối try. Đây là thói quen rất quan trọng để tránh rò rỉ Connection, Statement và ResultSet.
4.3. PreparedStatement và SQL Injection
Đây là kiến thức bảo mật bắt buộc phải nắm vững khi làm việc với database.
Giả sử người dùng nhập tên sản phẩm vào một ô tìm kiếm. Một lập trình viên thiếu kinh nghiệm có thể tạo SQL như sau:
String name = request.getParameter("name");
String sql =
"SELECT * FROM products WHERE name = '" + name + "'";
Statement statement = connection.createStatement();
ResultSet rs = statement.executeQuery(sql);
Vấn đề là dữ liệu người dùng được nối trực tiếp vào câu SQL. Nếu dữ liệu chứa ký tự đặc biệt hoặc đoạn SQL độc hại, cấu trúc câu truy vấn có thể bị thay đổi.
Đây là một dạng SQL Injection.
Cách an toàn hơn là sử dụng PreparedStatement:
String sql =
"SELECT * FROM products WHERE name = ?";
try (PreparedStatement ps =
connection.prepareStatement(sql)) {
ps.setString(1, name);
try (ResultSet rs = ps.executeQuery()) {
while (rs.next()) {
System.out.println(rs.getString("name"));
}
}
}
Dấu ? là placeholder cho tham số. setString(1, name) gán giá trị cho placeholder đầu tiên.
Database Driver xử lý tham số theo cơ chế của JDBC thay vì để dữ liệu người dùng trở thành một phần của cấu trúc SQL. Đây là phương pháp tiêu chuẩn để ngăn SQL Injection đối với các giá trị tham số.
Không nối trực tiếp dữ liệu đầu vào của người dùng vào SQL.
Sử dụng
PreparedStatement cho các truy vấn có tham số.
PreparedStatement không tự động làm cho mọi câu SQL an toàn nếu chúng ta vẫn nối dữ liệu không tin cậy vào phần cấu trúc SQL. Tên cột hoặc tên bảng động phải được kiểm soát bằng danh sách cho phép.
4.3.1. INSERT, UPDATE và DELETE bằng JDBC
Với các câu lệnh làm thay đổi dữ liệu, chúng ta thường sử dụng executeUpdate(). Method này trả về số dòng bị ảnh hưởng, mặc dù chi tiết phụ thuộc vào loại câu lệnh và JDBC Driver.
INSERT - Thêm sản phẩm:
String sql =
"INSERT INTO products (name, price, description) " +
"VALUES (?, ?, ?)";
try (PreparedStatement ps =
connection.prepareStatement(sql)) {
ps.setString(1, "Mechanical Keyboard");
ps.setBigDecimal(2, new BigDecimal("89.99"));
ps.setString(3, "Bàn phím cơ");
int affectedRows = ps.executeUpdate();
System.out.println("Inserted rows: " + affectedRows);
}
UPDATE - Sửa sản phẩm:
String sql =
"UPDATE products SET name = ?, price = ? WHERE id = ?";
try (PreparedStatement ps =
connection.prepareStatement(sql)) {
ps.setString(1, "Updated Keyboard");
ps.setBigDecimal(2, new BigDecimal("99.99"));
ps.setInt(3, 1);
int affectedRows = ps.executeUpdate();
}
DELETE - Xóa sản phẩm:
String sql = "DELETE FROM products WHERE id = ?";
try (PreparedStatement ps =
connection.prepareStatement(sql)) {
ps.setInt(1, 1);
int affectedRows = ps.executeUpdate();
}
Trong ví dụ cuối, điều kiện WHERE id = ? rất quan trọng. Nếu xóa mà không có điều kiện phù hợp, chúng ta có thể xóa toàn bộ dữ liệu trong bảng.
4.4. DAO, Transaction và quản lý tài nguyên
Nếu mỗi Servlet tự viết SQL, mở Connection và đọc ResultSet thì ứng dụng sẽ nhanh chóng xuất hiện nhiều đoạn code trùng lặp. Chúng ta cần gom phần truy cập database vào một lớp riêng.
Đó là lý do sử dụng DAO.
ProductServlet
|
| findAll()
| create()
| update()
| delete()
v
ProductDAO
|
| JDBC
v
MySQL Database
DAO giúp Controller tập trung vào HTTP Request và điều hướng, còn các câu SQL được quản lý tập trung ở tầng truy cập dữ liệu.
Transaction là gì?
Transaction là một nhóm thao tác dữ liệu được quản lý như một đơn vị logic. Khi transaction thất bại, chúng ta có thể rollback để không giữ lại một phần thay đổi chưa hoàn chỉnh.
Ví dụ chuyển tiền giữa hai tài khoản thường cần bảo đảm cả hai thao tác ghi nợ và ghi có cùng thành công hoặc cùng thất bại.
try (Connection connection =
DriverManager.getConnection(url, username, password)) {
connection.setAutoCommit(false);
try {
// Thực hiện các thao tác SQL liên quan.
connection.commit();
} catch (SQLException e) {
try {
connection.rollback();
} catch (SQLException rollbackError) {
e.addSuppressed(rollbackError);
}
throw e;
}
}
Trong ví dụ trên, transaction được commit khi các thao tác hoàn tất. Nếu xảy ra lỗi SQL, chương trình cố gắng rollback. Trong hệ thống thực tế, chúng ta còn cần xử lý lỗi, retry, timeout và các tình huống mà kết quả commit không xác định rõ do lỗi mạng.
Với ứng dụng CRUD nhỏ, mỗi thao tác đơn giản thường có thể thực hiện trong một câu SQL duy nhất. Tuy nhiên, hiểu Transaction từ đầu sẽ giúp chúng ta tránh thiết kế sai khi nghiệp vụ phát triển.
Connection Pool là gì?
Mở một kết nối database mới cho mỗi Request có thể gây overhead đáng kể. Connection Pool duy trì một nhóm Connection có thể được tái sử dụng, giúp giảm chi phí thiết lập kết nối.
Trong ví dụ thực hành, chúng ta sẽ dùng DriverManager để giữ cho code dễ hiểu. Khi triển khai thực tế, nên cân nhắc Connection Pool như HikariCP hoặc cấu hình DataSource do container quản lý.
Không lưu một JDBC Connection dùng chung trong instance field của Servlet.
Không quên đóng Connection, Statement và ResultSet.
Không để mật khẩu database trong Git.
Không trả stack trace hoặc thông tin kết nối nhạy cảm trực tiếp cho người dùng cuối.
5. Thực chiến: Xây dựng ứng dụng quản lý sản phẩm CRUD
Đây là phần quan trọng nhất của bài học. Chúng ta sẽ xây dựng một ứng dụng Web có thể chạy trên Tomcat, sử dụng MySQL để lưu dữ liệu và áp dụng mô hình MVC Model 2.
Ứng dụng có các chức năng:
Create: Thêm sản phẩm mới. Read: Hiển thị danh sách và chi tiết sản phẩm. Update: Chỉnh sửa thông tin sản phẩm. Delete: Xóa sản phẩm.
Để giữ cho ví dụ tập trung vào bản chất của Java Web, chúng ta sẽ xây dựng DAO, Servlet và JSP trực tiếp thay vì sử dụng Spring. Các thư viện bên ngoài chỉ được dùng khi cần thiết.
5.1. Chuẩn bị môi trường và cấu trúc dự án
Trước khi bắt đầu, hãy chuẩn bị các công cụ sau:
1. JDK 17. 2. Apache Maven. 3. Apache Tomcat 10.1. 4. MySQL Server 8.x. 5. IntelliJ IDEA, Eclipse hoặc IDE Java tương đương.
Chúng ta sẽ tạo một dự án Maven dạng WAR. WAR là định dạng đóng gói phổ biến dành cho Java Web application truyền thống.
Cấu trúc dự án:
product-app/
│
├── pom.xml
│
└── src/
└── main/
├── java/
│ └── com/example/
│ ├── model/
│ │ └── Product.java
│ ├── dao/
│ │ └── ProductDAO.java
│ └── controller/
│ └── ProductServlet.java
│
└── webapp/
├── index.jsp
└── WEB-INF/
└── views/
└── products/
├── list.jsp
├── form.jsp
└── detail.jsp
Trong dự án này:
- model: Chứa lớp biểu diễn dữ liệu.
- dao: Chứa các câu SQL và thao tác JDBC.
- controller: Chứa Servlet điều phối Request.
- WEB-INF/views: Chứa JSP không được truy cập trực tiếp từ trình duyệt.
Trong ví dụ này, chúng ta chưa tạo Service riêng vì nghiệp vụ còn đơn giản. Khi quy tắc nghiệp vụ phát triển, có thể bổ sung tầng Service giữa Controller và DAO mà không cần thay đổi nguyên tắc MVC.
5.1.1. Cấu hình Maven bằng pom.xml
Tạo file pom.xml ở thư mục gốc dự án.
<?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
https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<groupId>com.example</groupId>
<artifactId>product-app</artifactId>
<version>1.0.0</version>
<packaging>war</packaging>
<properties>
<maven.compiler.release>17</maven.compiler.release>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
</properties>
<dependencies>
<dependency>
<groupId>jakarta.servlet</groupId>
<artifactId>jakarta.servlet-api</artifactId>
<version>6.0.0</version>
<scope>provided</scope>
</dependency>
<dependency>
<groupId>com.mysql</groupId>
<artifactId>mysql-connector-j</artifactId>
<version>9.3.0</version>
</dependency>
<dependency>
<groupId>jakarta.servlet.jsp.jstl</groupId>
<artifactId>jakarta.servlet.jsp.jstl-api</artifactId>
<version>3.0.2</version>
</dependency>
<dependency>
<groupId>org.glassfish.web</groupId>
<artifactId>jakarta.servlet.jsp.jstl</artifactId>
<version>3.0.1</version>
</dependency>
</dependencies>
<build>
<finalName>product-app</finalName>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<version>3.13.0</version>
</plugin>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-war-plugin</artifactId>
<version>3.4.0</version>
</plugin>
</plugins>
</build>
</project>
Giải thích các dependency:
- jakarta.servlet-api: Cung cấp các API Servlet. Vì Tomcat cung cấp API này khi chạy ứng dụng nên Maven dùng scope provided.
- mysql-connector-j: Cung cấp JDBC Driver để giao tiếp với MySQL.
- jakarta.servlet.jsp.jstl-api: Cung cấp API JSTL.
- jakarta.servlet.jsp.jstl: Cung cấp implementation JSTL.
Tomcat 10.1 hỗ trợ Servlet 6.0 và Pages 3.1. Để sử dụng JSTL, cần có cả API lẫn implementation tương thích. Khi cập nhật phiên bản thư viện, hãy kiểm tra khả năng tương thích giữa JDK, Tomcat và các dependency.
Chúng ta có thể build dự án bằng lệnh:
mvn clean package
Nếu build thành công, Maven sẽ tạo file:
target/product-app.war
5.1.2. Cấu hình Tomcat
Sau khi build thành công, chúng ta có thể triển khai file WAR vào thư mục webapps của Tomcat.
Ví dụ trên Windows:
apache-tomcat-10.1/
└── webapps/
└── product-app.war
Khởi động Tomcat bằng script bin/startup.bat. Trên Linux hoặc macOS, sử dụng script bin/startup.sh.
Nếu Tomcat chạy thành công trên cổng 8080, ứng dụng thường được truy cập tại:
http://localhost:8080/product-app/
Nếu sử dụng IDE, chúng ta cũng có thể cấu hình Tomcat Server trong IDE và deploy trực tiếp từ dự án Maven.
Tomcat không mặc định cung cấp MySQL Server. Bạn phải cài đặt và khởi động MySQL riêng.
Tomcat cũng không tự động cung cấp MySQL JDBC Driver cho mọi ứng dụng. Trong cấu hình Maven của bài này, Driver được đóng gói trong WAR để ứng dụng có thể sử dụng.
5.2. Tạo database và bảng products
Chúng ta sẽ tạo database có tên product_db và bảng products.
CREATE DATABASE product_db
CHARACTER SET utf8mb4
COLLATE utf8mb4_unicode_ci;
USE product_db;
CREATE TABLE products (
id INT PRIMARY KEY AUTO_INCREMENT,
name VARCHAR(150) NOT NULL,
price DECIMAL(12, 2) NOT NULL,
description VARCHAR(1000),
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT chk_products_price CHECK (price > 0)
);
INSERT INTO products (name, price, description)
VALUES
('Mechanical Keyboard', 89.99, 'Bàn phím cơ'),
('Wireless Mouse', 29.50, 'Chuột không dây'),
('USB-C Hub', 45.00, 'Hub chuyển đổi USB-C');
Giải thích thiết kế bảng:
- id: Khóa chính, tự tăng.
- name: Tên sản phẩm, không được để trống.
- price: Giá sản phẩm, dùng kiểu DECIMAL để biểu diễn giá trị tiền tệ.
- description: Mô tả sản phẩm.
- created_at: Thời điểm bản ghi được tạo.
Ràng buộc CHECK giúp bảo vệ dữ liệu ở tầng database. Ứng dụng vẫn phải kiểm tra dữ liệu đầu vào, vì validation ở tầng Web và ràng buộc database phục vụ các mục đích khác nhau.
5.2.1. Tạo tài khoản database cho ứng dụng
Trong môi trường phát triển, chúng ta có thể tạo một tài khoản riêng cho ứng dụng thay vì dùng tài khoản quản trị MySQL.
CREATE USER 'product_app'@'localhost'
IDENTIFIED BY 'ReplaceWithA_StrongPassword';
GRANT SELECT, INSERT, UPDATE, DELETE
ON product_db.*
TO 'product_app'@'localhost';
Hãy thay mật khẩu minh họa bằng mật khẩu mạnh và không lưu nó vào Git. Cấu hình tài khoản trong môi trường triển khai cần tuân theo chính sách quản lý bí mật của hệ thống.
Thiết lập biến môi trường trước khi chạy Tomcat hoặc ứng dụng:
$env:DB_USERNAME = "product_app" $env:DB_PASSWORD = "YourActualPassword"
export DB_USERNAME=product_app export DB_PASSWORD='YourActualPassword'
Trong ví dụ, chúng ta giả định Tomcat được khởi động từ môi trường đã nhận các biến này. Nếu khởi động Tomcat dưới dạng Windows Service hoặc systemd service, hãy cấu hình biến môi trường trong đúng môi trường của tiến trình Tomcat.
5.3. Xây dựng Model và DAO
Chúng ta đã có bảng database. Bây giờ cần tạo lớp Java biểu diễn sản phẩm và lớp thực hiện các thao tác CRUD.
5.3.1. Tạo lớp Product
Tạo file Product.java trong package com.example.model.
package com.example.model;
import java.io.Serializable;
import java.math.BigDecimal;
public class Product implements Serializable {
private int id;
private String name;
private BigDecimal price;
private String description;
public Product() {
}
public Product(
int id,
String name,
BigDecimal price,
String description) {
this.id = id;
this.name = name;
this.price = price;
this.description = description;
}
public int getId() {
return id;
}
public void setId(int id) {
this.id = id;
}
public String getName() {
return name;
}
public void setName(String name) {
this.name = name;
}
public BigDecimal getPrice() {
return price;
}
public void setPrice(BigDecimal price) {
this.price = price;
}
public String getDescription() {
return description;
}
public void setDescription(String description) {
this.description = description;
}
}
Lớp này chỉ biểu diễn dữ liệu sản phẩm. Nó không biết HTTP là gì, không biết Tomcat là gì và không trực tiếp thực hiện SQL.
5.3.2. Tạo ProductDAO
Đây là lớp chịu trách nhiệm giao tiếp với MySQL. Chúng ta sẽ triển khai đầy đủ các method:
findAll(): Lấy danh sách sản phẩm.
findById(): Tìm sản phẩm theo ID.
create(): Thêm sản phẩm.
update(): Cập nhật sản phẩm.
delete(): Xóa sản phẩm.
Tạo file ProductDAO.java:
package com.example.dao;
import com.example.model.Product;
import java.math.BigDecimal;
import java.sql.Connection;
import java.sql.DriverManager;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.sql.Statement;
import java.util.ArrayList;
import java.util.List;
public class ProductDAO {
private static final String DB_URL =
"jdbc:mysql://localhost:3306/product_db";
private static final String DB_USER =
System.getenv("DB_USERNAME");
private static final String DB_PASSWORD =
System.getenv("DB_PASSWORD");
private Connection getConnection() throws SQLException {
if (DB_USER == null || DB_PASSWORD == null) {
throw new SQLException(
"Database credentials are not configured"
);
}
return DriverManager.getConnection(
DB_URL,
DB_USER,
DB_PASSWORD
);
}
public List<Product> findAll() throws SQLException {
String sql =
"SELECT id, name, price, description " +
"FROM products ORDER BY id DESC";
List<Product> products = new ArrayList<>();
try (Connection connection = getConnection();
PreparedStatement ps = connection.prepareStatement(sql);
ResultSet rs = ps.executeQuery()) {
while (rs.next()) {
products.add(mapRow(rs));
}
}
return products;
}
public Product findById(int id) throws SQLException {
String sql =
"SELECT id, name, price, description " +
"FROM products WHERE id = ?";
try (Connection connection = getConnection();
PreparedStatement ps = connection.prepareStatement(sql)) {
ps.setInt(1, id);
try (ResultSet rs = ps.executeQuery()) {
if (rs.next()) {
return mapRow(rs);
}
}
}
return null;
}
public int create(Product product) throws SQLException {
String sql =
"INSERT INTO products (name, price, description) " +
"VALUES (?, ?, ?)";
try (Connection connection = getConnection();
PreparedStatement ps = connection.prepareStatement(
sql,
Statement.RETURN_GENERATED_KEYS
)) {
ps.setString(1, product.getName());
ps.setBigDecimal(2, product.getPrice());
ps.setString(3, product.getDescription());
int affectedRows = ps.executeUpdate();
if (affectedRows == 0) {
throw new SQLException("Creating product failed");
}
try (ResultSet keys = ps.getGeneratedKeys()) {
if (keys.next()) {
return keys.getInt(1);
}
}
}
throw new SQLException("No generated ID returned");
}
public boolean update(Product product) throws SQLException {
String sql =
"UPDATE products " +
"SET name = ?, price = ?, description = ? " +
"WHERE id = ?";
try (Connection connection = getConnection();
PreparedStatement ps = connection.prepareStatement(sql)) {
ps.setString(1, product.getName());
ps.setBigDecimal(2, product.getPrice());
ps.setString(3, product.getDescription());
ps.setInt(4, product.getId());
return ps.executeUpdate() > 0;
}
}
public boolean delete(int id) throws SQLException {
String sql = "DELETE FROM products WHERE id = ?";
try (Connection connection = getConnection();
PreparedStatement ps = connection.prepareStatement(sql)) {
ps.setInt(1, id);
return ps.executeUpdate() > 0;
}
}
private Product mapRow(ResultSet rs) throws SQLException {
return new Product(
rs.getInt("id"),
rs.getString("name"),
rs.getBigDecimal("price"),
rs.getString("description")
);
}
}
Đây là DAO hoàn chỉnh cho ứng dụng thực hành. Hãy phân tích các điểm quan trọng.
A. Mỗi thao tác sử dụng Connection riêng
Method getConnection() tạo Connection khi cần. Với ví dụ nhập môn, chúng ta dùng DriverManager. Trong production, nên thay bằng DataSource và Connection Pool để quản lý kết nối hiệu quả hơn.
B. Tất cả câu lệnh có tham số đều sử dụng PreparedStatement
Các giá trị id, name, price và description được bind vào placeholder. Không có dữ liệu người dùng nào được nối trực tiếp vào câu SQL.
C. ResultSet được ánh xạ thành Product
Method mapRow() chuyển một dòng dữ liệu từ ResultSet thành đối tượng Product. Cách tổ chức này giảm code lặp khi đọc dữ liệu.
D. DAO không phụ thuộc vào Servlet API
Đây là một ưu điểm quan trọng. Chúng ta có thể sử dụng DAO trong một chương trình Java độc lập, một ứng dụng Web hoặc một ứng dụng khác mà không cần thay đổi cách giao tiếp HTTP.
E. Vì sao không dùng một Connection static?
Connection JDBC không phải một đối tượng nên được chia sẻ tùy tiện giữa các Request đồng thời. Mỗi thao tác ở đây sử dụng Connection riêng và đóng nó sau khi hoàn tất.
Trong triển khai thực tế, hãy sử dụng Connection Pool. Khi dùng DataSource có Pool, việc đóng Connection thường trả Connection về Pool thay vì đóng kết nối vật lý ngay lập tức.
5.4. Xây dựng Controller bằng Servlet
DAO đã hoàn thành, bây giờ chúng ta cần một Servlet để tiếp nhận HTTP Request và gọi DAO.
Thay vì tạo một Servlet cho mỗi URL ngay từ đầu, chúng ta có thể sử dụng một Servlet điều phối các đường dẫn:
/products GET -> List /products/detail GET -> Detail /products/new GET -> Create Form /products/create POST -> Create /products/edit GET -> Edit Form /products/update POST -> Update /products/delete POST -> Delete
Để giữ bài học gọn gàng, chúng ta sẽ sử dụng một Servlet với các nhánh xử lý rõ ràng.
Tạo file ProductServlet.java:
package com.example.controller;
import com.example.dao.ProductDAO;
import com.example.model.Product;
import jakarta.servlet.ServletException;
import jakarta.servlet.annotation.WebServlet;
import jakarta.servlet.http.HttpServlet;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.math.BigDecimal;
import java.sql.SQLException;
import java.util.List;
@WebServlet("/products/*")
public class ProductServlet extends HttpServlet {
private ProductDAO productDAO;
@Override
public void init() throws ServletException {
productDAO = new ProductDAO();
}
@Override
protected void doGet(
HttpServletRequest request,
HttpServletResponse response)
throws ServletException, IOException {
request.setCharacterEncoding("UTF-8");
String path = request.getPathInfo();
try {
if (path == null || "/".equals(path)) {
listProducts(request, response);
} else if ("/detail".equals(path)) {
showDetail(request, response);
} else if ("/new".equals(path)) {
showCreateForm(request, response);
} else if ("/edit".equals(path)) {
showEditForm(request, response);
} else {
response.sendError(
HttpServletResponse.SC_NOT_FOUND
);
}
} catch (SQLException e) {
throw new ServletException(
"Cannot process product request", e
);
}
}
@Override
protected void doPost(
HttpServletRequest request,
HttpServletResponse response)
throws ServletException, IOException {
request.setCharacterEncoding("UTF-8");
String path = request.getPathInfo();
try {
if ("/create".equals(path)) {
createProduct(request, response);
} else if ("/update".equals(path)) {
updateProduct(request, response);
} else if ("/delete".equals(path)) {
deleteProduct(request, response);
} else {
response.sendError(
HttpServletResponse.SC_NOT_FOUND
);
}
} catch (IllegalArgumentException e) {
response.sendError(
HttpServletResponse.SC_BAD_REQUEST,
e.getMessage()
);
} catch (SQLException e) {
throw new ServletException(
"Cannot process product request", e
);
}
}
private void listProducts(
HttpServletRequest request,
HttpServletResponse response)
throws SQLException, ServletException, IOException {
List<Product> products = productDAO.findAll();
request.setAttribute("products", products);
request.getRequestDispatcher(
"/WEB-INF/views/products/list.jsp"
).forward(request, response);
}
private void showDetail(
HttpServletRequest request,
HttpServletResponse response)
throws SQLException, ServletException, IOException {
int id = parseId(request.getParameter("id"));
Product product = productDAO.findById(id);
if (product == null) {
response.sendError(
HttpServletResponse.SC_NOT_FOUND,
"Không tìm thấy sản phẩm"
);
return;
}
request.setAttribute("product", product);
request.getRequestDispatcher(
"/WEB-INF/views/products/detail.jsp"
).forward(request, response);
}
private void showCreateForm(
HttpServletRequest request,
HttpServletResponse response)
throws ServletException, IOException {
request.setAttribute("formAction", "create");
request.setAttribute("formTitle", "Thêm sản phẩm");
request.setAttribute("submitLabel", "Thêm mới");
request.getRequestDispatcher(
"/WEB-INF/views/products/form.jsp"
).forward(request, response);
}
private void showEditForm(
HttpServletRequest request,
HttpServletResponse response)
throws SQLException, ServletException, IOException {
int id = parseId(request.getParameter("id"));
Product product = productDAO.findById(id);
if (product == null) {
response.sendError(
HttpServletResponse.SC_NOT_FOUND,
"Không tìm thấy sản phẩm"
);
return;
}
request.setAttribute("product", product);
request.setAttribute("formAction", "update");
request.setAttribute("formTitle", "Sửa sản phẩm");
request.setAttribute("submitLabel", "Lưu thay đổi");
request.getRequestDispatcher(
"/WEB-INF/views/products/form.jsp"
).forward(request, response);
}
private void createProduct(
HttpServletRequest request,
HttpServletResponse response)
throws SQLException, IOException {
Product product = readProductForm(request);
productDAO.create(product);
redirectToList(request, response);
}
private void updateProduct(
HttpServletRequest request,
HttpServletResponse response)
throws SQLException, IOException {
int id = parseId(request.getParameter("id"));
Product existing = productDAO.findById(id);
if (existing == null) {
response.sendError(
HttpServletResponse.SC_NOT_FOUND,
"Không tìm thấy sản phẩm"
);
return;
}
Product product = readProductForm(request);
product.setId(id);
boolean updated = productDAO.update(product);
if (!updated) {
response.sendError(
HttpServletResponse.SC_NOT_FOUND,
"Không tìm thấy sản phẩm"
);
return;
}
redirectToList(request, response);
}
private void deleteProduct(
HttpServletRequest request,
HttpServletResponse response)
throws SQLException, IOException {
int id = parseId(request.getParameter("id"));
boolean deleted = productDAO.delete(id);
if (!deleted) {
response.sendError(
HttpServletResponse.SC_NOT_FOUND,
"Không tìm thấy sản phẩm"
);
return;
}
redirectToList(request, response);
}
private Product readProductForm(
HttpServletRequest request) {
String name = request.getParameter("name");
String priceText = request.getParameter("price");
String description = request.getParameter("description");
if (name == null || name.trim().isEmpty()) {
throw new IllegalArgumentException(
"Tên sản phẩm không được để trống"
);
}
name = name.trim();
if (name.length() > 150) {
throw new IllegalArgumentException(
"Tên sản phẩm không được vượt quá 150 ký tự"
);
}
if (description != null && description.length() > 1000) {
throw new IllegalArgumentException(
"Mô tả không được vượt quá 1000 ký tự"
);
}
BigDecimal price;
try {
price = new BigDecimal(priceText);
} catch (NumberFormatException | NullPointerException e) {
throw new IllegalArgumentException(
"Giá sản phẩm không hợp lệ"
);
}
if (price.compareTo(BigDecimal.ZERO) <= 0) {
throw new IllegalArgumentException(
"Giá sản phẩm phải lớn hơn 0"
);
}
return new Product(
0,
name,
price,
description
);
}
private int parseId(String value) {
try {
int id = Integer.parseInt(value);
if (id <= 0) {
throw new IllegalArgumentException(
"ID phải là số nguyên dương"
);
}
return id;
} catch (NumberFormatException | NullPointerException e) {
throw new IllegalArgumentException(
"ID sản phẩm không hợp lệ"
);
}
}
private void redirectToList(
HttpServletRequest request,
HttpServletResponse response) throws IOException {
String target = response.encodeRedirectURL(
request.getContextPath() + "/products"
);
response.setStatus(HttpServletResponse.SC_SEE_OTHER);
response.setHeader("Location", target);
}
}
Đoạn Servlet trên triển khai các luồng chính của ứng dụng. Chúng ta cần hiểu cách nó hoạt động trước khi tạo JSP.
A. @WebServlet("/products/*")
Annotation này ánh xạ Servlet đến đường dẫn /products và các đường dẫn con phù hợp. Phần đường dẫn cụ thể được lấy bằng request.getPathInfo().
Ví dụ:
/products -> pathInfo: / /products/new -> pathInfo: /new /products/create -> pathInfo: /create /products/edit?id=1 -> pathInfo: /edit
Query string ?id=1 không thuộc pathInfo. Nó được đọc thông qua request.getParameter("id").
B. doGet() và doPost()
GET được dùng để hiển thị danh sách, chi tiết và biểu mẫu. POST được dùng để thực hiện các thao tác tạo, cập nhật và xóa dữ liệu.
C. request.setAttribute()
Controller đưa dữ liệu vào Request Attribute để JSP sử dụng. Ví dụ, request.setAttribute("products", products) chuyển danh sách sản phẩm sang View.
D. Redirect sau khi ghi dữ liệu
Phương thức redirectToList() trả về HTTP 303 để trình duyệt gửi GET đến danh sách sản phẩm sau khi thao tác ghi hoàn tất. Đây là mẫu PRG.
E. Validation
Controller kiểm tra tên, giá, độ dài mô tả và ID trước khi gọi DAO. Đây là validation phía Server; HTML Form cũng có thể kiểm tra dữ liệu ở phía Client nhưng không thể thay thế validation phía Server.
Servlet trên đủ để học luồng CRUD, nhưng chưa phải một hệ thống production hoàn chỉnh. Khi triển khai thực tế, cần bổ sung CSRF protection, phân quyền, trang lỗi thân thiện, logging, Connection Pool và quy trình validation thống nhất.
5.5. Xây dựng giao diện JSP
Controller đã có thể xử lý Request. Bây giờ chúng ta cần xây dựng View.
Chúng ta sẽ tạo ba JSP:
- list.jsp: Hiển thị danh sách sản phẩm.
- form.jsp: Dùng chung cho form thêm và sửa sản phẩm.
- detail.jsp: Hiển thị chi tiết một sản phẩm.
5.5.1. Trang danh sách sản phẩm
Tạo file src/main/webapp/WEB-INF/views/products/list.jsp.
<%@ page contentType="text/html; charset=UTF-8"
pageEncoding="UTF-8" %>
<%@ taglib prefix="c" uri="jakarta.tags.core" %>
<!DOCTYPE html>
<html lang="vi">
<head>
<meta charset="UTF-8">
<meta name="viewport"
content="width=device-width, initial-scale=1.0">
<title>Quản lý sản phẩm</title>
<style>
body {
font-family: Arial, sans-serif;
max-width: 1100px;
margin: 30px auto;
padding: 0 16px;
color: #222;
}
h1 {
margin-bottom: 20px;
}
.toolbar {
margin-bottom: 20px;
}
.button {
display: inline-block;
padding: 9px 14px;
border-radius: 5px;
text-decoration: none;
border: none;
cursor: pointer;
background: #2563eb;
color: white;
}
.table-wrap {
overflow-x: auto;
}
table {
width: 100%;
border-collapse: collapse;
}
th, td {
border: 1px solid #ddd;
padding: 12px;
text-align: left;
}
th {
background: #f3f4f6;
}
.actions {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.danger {
background: #dc2626;
}
.empty {
padding: 20px;
text-align: center;
color: #666;
}
</style>
</head>
<body>
<h1>Quản lý sản phẩm</h1>
<div class="toolbar">
<a class="button"
href="${pageContext.request.contextPath}/products/new">
+ Thêm sản phẩm
</a>
</div>
<div class="table-wrap">
<table>
<thead>
<tr>
<th>ID</th>
<th>Tên sản phẩm</th>
<th>Giá</th>
<th>Mô tả</th>
<th>Thao tác</th>
</tr>
</thead>
<tbody>
<c:choose>
<c:when test="${empty products}">
<tr>
<td colspan="5" class="empty">
Chưa có sản phẩm nào.
</td>
</tr>
</c:when>
<c:otherwise>
<c:forEach var="product" items="${products}">
<tr>
<td>
<c:out value="${product.id}"/>
</td>
<td>
<c:out value="${product.name}"/>
</td>
<td>
<c:out value="${product.price}"/>
</td>
<td>
<c:out value="${product.description}"/>
</td>
<td>
<div class="actions">
<a href="${pageContext.request.contextPath}/products/detail?id=${product.id}">
Chi tiết
</a>
<a href="${pageContext.request.contextPath}/products/edit?id=${product.id}">
Sửa
</a>
<form method="post"
action="${pageContext.request.contextPath}/products/delete"
onsubmit="return confirm('Bạn có chắc muốn xóa sản phẩm này?');">
<input type="hidden"
name="id"
value="${product.id}">
<button class="button danger"
type="submit">
Xóa
</button>
</form>
</div>
</td>
</tr>
</c:forEach>
</c:otherwise>
</c:choose>
</tbody>
</table>
</div>
</body>
</html>
Hãy chú ý một số chi tiết trong View.
Thứ nhất, JSP không truy vấn database. Danh sách products đã được Controller lấy từ DAO và đưa vào Request Attribute.
Thứ hai, dùng JSTL để lặp dữ liệu. <c:forEach> giúp chúng ta hiển thị từng sản phẩm mà không cần Scriptlet.
Thứ ba, sử dụng c:out để hiển thị dữ liệu. Tag này mặc định escape các ký tự XML/HTML quan trọng trong đầu ra. Đây là biện pháp hữu ích để giảm nguy cơ XSS khi hiển thị tên và mô tả sản phẩm.
Thứ tư, nút Xóa sử dụng POST. Thao tác thay đổi trạng thái không được thực hiện bằng GET. Trong ứng dụng có người dùng thực tế, form này còn phải có CSRF token hợp lệ.
Thứ năm, các đường dẫn sử dụng context path. Điều này giúp ứng dụng hoạt động đúng ngay cả khi tên ứng dụng thay đổi, miễn là URL Mapping vẫn giữ nguyên.
5.5.2. Trang form thêm và sửa sản phẩm
Thay vì tạo hai trang có nội dung gần như giống nhau, chúng ta sẽ sử dụng một JSP cho cả hai thao tác.
Tạo file src/main/webapp/WEB-INF/views/products/form.jsp.
<%@ page contentType="text/html; charset=UTF-8"
pageEncoding="UTF-8" %>
<%@ taglib prefix="c" uri="jakarta.tags.core" %>
<!DOCTYPE html>
<html lang="vi">
<head>
<meta charset="UTF-8">
<meta name="viewport"
content="width=device-width, initial-scale=1.0">
<title><c:out value="${formTitle}"/></title>
<style>
body {
font-family: Arial, sans-serif;
max-width: 650px;
margin: 35px auto;
padding: 0 16px;
}
label {
display: block;
margin-top: 16px;
margin-bottom: 6px;
}
input, textarea {
box-sizing: border-box;
width: 100%;
padding: 10px;
border: 1px solid #ccc;
border-radius: 5px;
}
textarea {
min-height: 120px;
}
button, .back {
display: inline-block;
margin-top: 20px;
padding: 10px 14px;
border: none;
border-radius: 5px;
background: #2563eb;
color: white;
text-decoration: none;
cursor: pointer;
}
</style>
</head>
<body>
<h1><c:out value="${formTitle}"/></h1>
<form method="post"
action="${pageContext.request.contextPath}/products/${formAction}">
<c:if test="${not empty product}">
<input type="hidden"
name="id"
value="${product.id}">
</c:if>
<label for="name">Tên sản phẩm</label>
<input type="text"
id="name"
name="name"
maxlength="150"
required
value="<c:out value='${product.name}'/>">
<label for="price">Giá sản phẩm</label>
<input type="number"
id="price"
name="price"
min="0.01"
step="0.01"
required
value="${product.price}">
<label for="description">Mô tả</label>
<textarea id="description"
name="description"
maxlength="1000"><c:out value="${product.description}"/></textarea>
<button type="submit">
<c:out value="${submitLabel}"/>
</button>
<a class="back"
href="${pageContext.request.contextPath}/products">
Quay lại
</a>
</form>
</body>
</html>
Lưu ý quan trọng về cách escape dữ liệu: Trong thuộc tính HTML như value, nên dùng cơ chế escape đúng ngữ cảnh. Không nên đặt tag <c:out> bên trong chuỗi thuộc tính như một cách escape gián tiếp. Cách viết rõ ràng hơn là:
<input type="text"
id="name"
name="name"
maxlength="150"
required
value="${fn:escapeXml(product.name)}">
Để sử dụng ví dụ trên, cần khai báo JSTL Functions:
<%@ taglib prefix="fn" uri="jakarta.tags.functions" %>
Sau đó, thay dòng input tên sản phẩm trong file form.jsp bằng phiên bản sử dụng fn:escapeXml() ở trên. Việc escape vẫn cần phù hợp với ngữ cảnh HTML thực tế; không sử dụng HTML escaping thay cho JavaScript hoặc URL encoding.
Với giá sản phẩm, vì dữ liệu đã được kiểm tra và chỉ chấp nhận số thập phân hợp lệ ở phía Server, chúng ta có thể sử dụng giá trị số trong thuộc tính input. Nếu mở rộng sang dữ liệu phức tạp hơn, nên chuẩn hóa định dạng hiển thị theo locale và quy ước nghiệp vụ.
Tại sao dùng chung form.jsp?
Vì form tạo và form sửa có cấu trúc gần như giống nhau. Controller chỉ cần truyền các thuộc tính như formTitle, formAction, submitLabel và đối tượng product nếu đang sửa.
Đây là một ví dụ nhỏ của nguyên tắc DRY (Don't Repeat Yourself): tránh lặp lại những phần code có cùng trách nhiệm.
5.5.3. Trang chi tiết sản phẩm
Tạo file src/main/webapp/WEB-INF/views/products/detail.jsp.
<%@ page contentType="text/html; charset=UTF-8"
pageEncoding="UTF-8" %>
<%@ taglib prefix="c" uri="jakarta.tags.core" %>
<!DOCTYPE html>
<html lang="vi">
<head>
<meta charset="UTF-8">
<meta name="viewport"
content="width=device-width, initial-scale=1.0">
<title>Chi tiết sản phẩm</title>
<style>
body {
font-family: Arial, sans-serif;
max-width: 700px;
margin: 35px auto;
padding: 0 16px;
}
.field {
padding: 12px 0;
border-bottom: 1px solid #ddd;
}
.label {
font-weight: bold;
display: inline-block;
min-width: 120px;
}
a {
display: inline-block;
margin-top: 20px;
}
</style>
</head>
<body>
<h1>Chi tiết sản phẩm</h1>
<div class="field">
<span class="label">ID:</span>
<c:out value="${product.id}"/>
</div>
<div class="field">
<span class="label">Tên:</span>
<c:out value="${product.name}"/>
</div>
<div class="field">
<span class="label">Giá:</span>
<c:out value="${product.price}"/>
</div>
<div class="field">
<span class="label">Mô tả:</span>
<c:out value="${product.description}"/>
</div>
<a href="${pageContext.request.contextPath}/products">
Quay lại danh sách
</a>
</body>
</html>
JSP này chỉ hiển thị dữ liệu mà Controller đã cung cấp. Không có câu SQL, không có JDBC Connection và không có logic xử lý HTTP bên trong View.
5.5.4. Trang mặc định index.jsp
Để người dùng có thể truy cập trang chủ của ứng dụng và đi đến danh sách sản phẩm, chúng ta có thể tạo file src/main/webapp/index.jsp.
<%@ page contentType="text/html; charset=UTF-8"
pageEncoding="UTF-8" %>
<!DOCTYPE html>
<html lang="vi">
<head>
<meta charset="UTF-8">
<title>Product App</title>
</head>
<body>
<h1>Product Management</h1>
<p>Ứng dụng quản lý sản phẩm bằng Servlet, JSP và JDBC.</p>
<a href="${pageContext.request.contextPath}/products">
Mở danh sách sản phẩm
</a>
</body>
</html>
Ở đây, trang chủ chỉ là một điểm truy cập đơn giản. Khi cần kiểm soát toàn bộ luồng truy cập, chúng ta có thể sử dụng Servlet làm Controller mặc định.
5.6. Hoàn thiện chức năng Create - Thêm sản phẩm
Chúng ta đã có đủ các thành phần để thêm sản phẩm:
1. Người dùng truy cập GET /products/new.
2. Servlet gọi showCreateForm().
3. Servlet forward sang form.jsp.
4. Người dùng nhập tên, giá và mô tả.
5. Trình duyệt gửi POST đến /products/create.
6. Servlet đọc và kiểm tra dữ liệu.
7. DAO sử dụng PreparedStatement để INSERT vào MySQL.
8. Servlet trả về Redirect 303 đến danh sách sản phẩm.
GET /products/new
|
v
showCreateForm()
|
v
form.jsp
|
| POST /products/create
v
createProduct()
|
v
readProductForm()
|
v
ProductDAO.create()
|
v
MySQL
|
v
HTTP 303
|
v
GET /products
Đoạn xử lý Create trong ProductServlet là:
private void createProduct(
HttpServletRequest request,
HttpServletResponse response)
throws SQLException, IOException {
Product product = readProductForm(request);
productDAO.create(product);
redirectToList(request, response);
}
Điểm quan trọng nhất ở đây không nằm ở số dòng code, mà nằm ở việc mỗi bước có một trách nhiệm riêng:
- Servlet nhận và kiểm tra dữ liệu từ HTTP Request. - DAO chuyển dữ liệu thành câu SQL phù hợp. - Database lưu dữ liệu. - Redirect đưa người dùng về trang danh sách.
Cách kiểm tra:
1. Mở http://localhost:8080/product-app/products/new.
2. Nhập tên sản phẩm, chẳng hạn USB-C Cable.
3. Nhập giá, chẳng hạn 12.50.
4. Nhấn nút Thêm mới.
5. Kiểm tra danh sách sản phẩm và database.
Nếu dữ liệu xuất hiện trong danh sách sau khi gửi form, luồng Create đã hoạt động.
5.7. Hoàn thiện chức năng Read - Đọc sản phẩm
Read gồm hai tình huống phổ biến: lấy danh sách và lấy chi tiết một sản phẩm.
A. Hiển thị danh sách
Khi truy cập:
GET /product-app/products
Servlet gọi:
private void listProducts(
HttpServletRequest request,
HttpServletResponse response)
throws SQLException, ServletException, IOException {
List<Product> products = productDAO.findAll();
request.setAttribute("products", products);
request.getRequestDispatcher(
"/WEB-INF/views/products/list.jsp"
).forward(request, response);
}
Method findAll() trong DAO thực hiện câu lệnh SELECT và trả về List<Product>. Controller đưa danh sách vào Request Attribute, sau đó forward sang JSP.
B. Hiển thị chi tiết sản phẩm
Khi người dùng nhấn Chi tiết, trình duyệt truy cập:
GET /product-app/products/detail?id=1
Servlet đọc ID, truy vấn database và đưa đối tượng Product vào View.
int id = parseId(request.getParameter("id"));
Product product = productDAO.findById(id);
if (product == null) {
response.sendError(
HttpServletResponse.SC_NOT_FOUND,
"Không tìm thấy sản phẩm"
);
return;
}
request.setAttribute("product", product);
request.getRequestDispatcher(
"/WEB-INF/views/products/detail.jsp"
).forward(request, response);
Ở đây, chúng ta không giả định rằng mọi ID đều tồn tại. Nếu sản phẩm không tìm thấy, ứng dụng trả về lỗi 404.
Điều cần nhớ: Không nên lấy toàn bộ bảng dữ liệu trong những hệ thống lớn mà không giới hạn kết quả. Khi số lượng sản phẩm tăng lên, chúng ta cần bổ sung phân trang, lọc, sắp xếp và giới hạn số dòng truy vấn.
5.8. Hoàn thiện chức năng Update - Sửa sản phẩm
Update bao gồm hai bước:
1. Hiển thị form với dữ liệu hiện có. 2. Nhận dữ liệu mới và cập nhật database.
A. Hiển thị form sửa
Khi truy cập:
GET /product-app/products/edit?id=1
Servlet gọi DAO để lấy sản phẩm, sau đó truyền dữ liệu vào form JSP.
Product product = productDAO.findById(id);
if (product == null) {
response.sendError(
HttpServletResponse.SC_NOT_FOUND
);
return;
}
request.setAttribute("product", product);
request.setAttribute("formAction", "update");
request.setAttribute("formTitle", "Sửa sản phẩm");
request.setAttribute("submitLabel", "Lưu thay đổi");
request.getRequestDispatcher(
"/WEB-INF/views/products/form.jsp"
).forward(request, response);
B. Gửi dữ liệu cập nhật
Người dùng chỉnh sửa dữ liệu và gửi POST đến /products/update. Servlet kiểm tra ID và dữ liệu đầu vào, sau đó gọi DAO.
int id = parseId(request.getParameter("id"));
Product existing = productDAO.findById(id);
if (existing == null) {
response.sendError(
HttpServletResponse.SC_NOT_FOUND
);
return;
}
Product product = readProductForm(request);
product.setId(id);
boolean updated = productDAO.update(product);
if (!updated) {
response.sendError(
HttpServletResponse.SC_NOT_FOUND
);
return;
}
redirectToList(request, response);
DAO thực hiện câu lệnh:
UPDATE products
SET name = ?, price = ?, description = ?
WHERE id = ?;
Điều kiện WHERE id = ? đảm bảo câu lệnh chỉ cập nhật sản phẩm được chỉ định.
Một lưu ý thực tế: Số dòng bị ảnh hưởng bằng 0 không phải lúc nào cũng chứng minh bản ghi không tồn tại, vì một số hệ quản trị hoặc cấu hình JDBC có thể trả về 0 nếu giá trị mới giống giá trị cũ. Vì vậy, nếu cần phân biệt chính xác giữa bản ghi không tồn tại và dữ liệu không thay đổi, hãy thiết kế quy trình kiểm tra phù hợp và xem xét các vấn đề đồng thời.
5.9. Hoàn thiện chức năng Delete - Xóa sản phẩm
Trong ứng dụng CRUD này, nút Xóa gửi POST Request đến /products/delete.
Form HTML tương ứng:
<form method="post"
action="${pageContext.request.contextPath}/products/delete">
<input type="hidden" name="id" value="${product.id}">
<button type="submit">Xóa</button>
</form>
Servlet nhận ID, xác thực dữ liệu, gọi DAO và chuyển hướng sau khi xóa.
private void deleteProduct(
HttpServletRequest request,
HttpServletResponse response)
throws SQLException, IOException {
int id = parseId(request.getParameter("id"));
boolean deleted = productDAO.delete(id);
if (!deleted) {
response.sendError(
HttpServletResponse.SC_NOT_FOUND,
"Không tìm thấy sản phẩm"
);
return;
}
redirectToList(request, response);
}
DAO sử dụng:
DELETE FROM products WHERE id = ?;
Không bao giờ sử dụng dữ liệu người dùng để nối trực tiếp vào câu lệnh DELETE.
Trong ứng dụng thực tế, xóa dữ liệu cần được bảo vệ bằng cơ chế xác thực, phân quyền và CSRF token. Hộp thoại xác nhận của JavaScript chỉ giúp giảm thao tác nhầm; nó không phải một biện pháp bảo mật.
GET dùng để đọc.
POST được sử dụng cho các form tạo, sửa và xóa trong ứng dụng này.
PreparedStatement bảo vệ tham số SQL.
CSRF token bảo vệ các thao tác thay đổi trạng thái trước yêu cầu giả mạo từ trang khác.
5.10. Kiểm thử ứng dụng CRUD
Sau khi hoàn thành các file, hãy build và deploy ứng dụng.
mvn clean package
Triển khai file target/product-app.war vào Tomcat, sau đó mở trang:
http://localhost:8080/product-app/products
Danh sách sản phẩm mẫu sẽ xuất hiện nếu kết nối MySQL và các thành phần đã được cấu hình đúng.
Checklist kiểm thử:
1. Read: Mở danh sách, kiểm tra dữ liệu từ MySQL. 2. Create: Thêm sản phẩm mới, kiểm tra bản ghi trong database. 3. Update: Sửa tên và giá sản phẩm, kiểm tra dữ liệu đã được cập nhật. 4. Delete: Xóa một sản phẩm, kiểm tra bản ghi không còn trong danh sách. 5. Validation: Thử gửi tên rỗng, giá âm, giá không phải số và ID không hợp lệ. 6. Unicode: Thử nhập tên và mô tả bằng tiếng Việt. 7. SQL Injection: Thử nhập ký tự SQL đặc biệt để kiểm tra truy vấn vẫn được tham số hóa. 8. HTTP Method: Thử truy cập các endpoint POST bằng GET và kiểm tra kết quả.
Với bài thực hành đầu tiên, chúng ta đã xây dựng được một ứng dụng Web có đầy đủ luồng từ trình duyệt đến database và quay trở lại trình duyệt.
Tuy nhiên, trước khi xem đây là một ứng dụng hoàn chỉnh để triển khai cho người dùng thật, chúng ta cần xem xét một số vấn đề về bảo mật, xử lý lỗi và quản lý tài nguyên.
6. Những bẫy thực chiến và bảo mật trong Servlet/JSP/JDBC
Một chương trình chạy thành công trên máy cá nhân chưa chắc đã an toàn hoặc hoạt động tốt khi có nhiều người dùng cùng truy cập. Những vấn đề sau đây là các lỗi thường gặp trong Java Web truyền thống.
6.1. Thread Safety và vòng đời đối tượng
Như đã đề cập, Servlet instance thường được tái sử dụng để xử lý nhiều Request. Do đó, các thuộc tính có thể thay đổi của Servlet không được mặc định là an toàn khi truy cập đồng thời.
Không nên viết:
public class ProductServlet extends HttpServlet {
private Product currentProduct;
protected void doGet(
HttpServletRequest request,
HttpServletResponse response) {
// Gán và sử dụng currentProduct cho từng Request.
}
}
Nếu hai Request cùng thay đổi currentProduct, một người dùng có thể nhìn thấy dữ liệu của người khác.
Thông thường, nên sử dụng biến local cho dữ liệu thuộc từng Request và lưu dữ liệu chia sẻ vào các thành phần được thiết kế để xử lý đồng thời.
Cũng không nên dùng synchronized một cách tùy tiện để sửa mọi vấn đề. Đồng bộ hóa có thể giảm khả năng xử lý đồng thời và không giải quyết được những vấn đề kiến trúc như chia sẻ sai dữ liệu giữa các người dùng.
6.2. SQL Injection, XSS và CSRF
Đây là ba nhóm lỗ hổng cần phân biệt rõ.
A. SQL Injection
Xảy ra khi dữ liệu không tin cậy làm thay đổi cấu trúc câu SQL. Biện pháp chính là sử dụng PreparedStatement cho giá trị tham số, tránh nối chuỗi dữ liệu người dùng vào SQL.
B. Cross-Site Scripting (XSS)
Xảy ra khi dữ liệu không tin cậy được đưa vào trang Web theo cách khiến trình duyệt thực thi HTML hoặc JavaScript ngoài ý muốn. Hãy escape dữ liệu theo đúng ngữ cảnh và tránh chèn trực tiếp dữ liệu người dùng vào HTML, JavaScript hoặc URL.
C. Cross-Site Request Forgery (CSRF)
Xảy ra khi kẻ tấn công tìm cách khiến trình duyệt của người dùng đã đăng nhập gửi một Request thay đổi trạng thái ngoài ý muốn. Nếu ứng dụng sử dụng Session Cookie để xác thực, các form tạo, sửa và xóa cần được bảo vệ bằng CSRF token hoặc cơ chế phòng vệ tương đương.
SQL Injection: bảo vệ cấu trúc SQL.
XSS: bảo vệ nội dung được trình duyệt hiển thị và thực thi.
CSRF: bảo vệ các thao tác thay đổi trạng thái khỏi Request giả mạo.
Không có một biện pháp đơn lẻ nào giải quyết được cả ba vấn đề.
6.2.1. Bảo vệ form bằng CSRF token
Ứng dụng thực hành ở trên chưa triển khai CSRF token để tập trung vào luồng CRUD. Trong ứng dụng thực tế sử dụng Session Cookie, chúng ta nên tạo token ngẫu nhiên cho Session hoặc biểu mẫu, sau đó kiểm tra token ở Server trước khi thực hiện thao tác thay đổi trạng thái.
Ví dụ ý tưởng trong Servlet:
HttpSession session = request.getSession();
String token = (String) session.getAttribute("csrfToken");
if (token == null) {
token = java.util.UUID.randomUUID().toString();
session.setAttribute("csrfToken", token);
}
request.setAttribute("csrfToken", token);
Trong JSP, token có thể được đưa vào một hidden input:
<input type="hidden"
name="csrfToken"
value="${csrfToken}">
Ở phía Server, trước khi xử lý POST, phải so sánh token nhận được với token lưu trong Session bằng phương pháp so sánh phù hợp và từ chối Request không hợp lệ. Ví dụ trên chỉ minh họa việc tạo và truyền token; nó chưa phải một triển khai CSRF hoàn chỉnh.
Trong ứng dụng thực tế, nên sử dụng một cơ chế CSRF đã được kiểm chứng hoặc triển khai đầy đủ quy trình tạo, kiểm tra và quản lý token. Không nên xem UUID được đưa vào form là đủ nếu Server không kiểm tra token.
6.2.2. Validation ở Client và Server
HTML có thể sử dụng các thuộc tính như required, min, max và maxlength để kiểm tra dữ liệu đầu vào.
Ví dụ:
<input type="number"
name="price"
min="0.01"
step="0.01"
required>
Tuy nhiên, Client có thể bỏ qua validation này bằng cách gửi Request trực tiếp. Vì vậy, Server luôn phải tự kiểm tra dữ liệu trước khi thực hiện thao tác nghiệp vụ.
Trong hệ thống lớn, chúng ta thường tổ chức validation thành các lớp riêng, có thông báo lỗi rõ ràng và có khả năng kiểm thử độc lập.
6.3. Các lỗi triển khai thường gặp
Dưới đây là một số lỗi phổ biến khi mới làm việc với Servlet/JSP/JDBC.
Lỗi 1: HTTP 404 - Not Found
Nguyên nhân thường gặp:
- Context Path không đúng. - URL Mapping không khớp. - Servlet chưa được deploy hoặc ứng dụng chưa khởi động thành công. - URL sử dụng sai chữ hoa, chữ thường hoặc sai đường dẫn.
Hãy kiểm tra URL đầy đủ, context path, annotation @WebServlet và log của Tomcat.
Lỗi 2: HTTP 500 - Internal Server Error
Lỗi 500 có thể xuất hiện khi có SQLException, JSP không biên dịch được, dependency bị thiếu hoặc code phát sinh exception không được xử lý đúng.
Hãy xem log phía Server để tìm nguyên nhân gốc. Không nên chỉ dựa vào trang lỗi hiển thị trên trình duyệt.
Lỗi 3: ClassNotFoundException hoặc No suitable driver
Những lỗi này thường liên quan đến việc JDBC Driver không nằm trong classpath hoặc JDBC URL không đúng.
Với Maven, hãy kiểm tra dependency mysql-connector-j và nội dung WAR sau khi build.
Lỗi 4: JSP báo không tìm thấy tag JSTL
Nguyên nhân có thể do thiếu dependency API hoặc implementation JSTL, URI taglib không đúng hoặc thư viện không tương thích với môi trường Jakarta đang sử dụng.
Lỗi 5: Tiếng Việt bị lỗi font hoặc hiển thị ký tự sai
Hãy kiểm tra encoding ở HTML, JSP, Request và Response. Với form POST, thiết lập request.setCharacterEncoding("UTF-8") trước khi đọc tham số.
Lỗi 6: Không cập nhật được database
Kiểm tra SQL, kiểu dữ liệu, quyền của tài khoản database, các ràng buộc bảng và số dòng bị ảnh hưởng. Không nên chỉ nhìn thấy trang danh sách tải lại rồi kết luận thao tác thành công.
Lỗi 7: Mật khẩu hoặc thông tin database bị lộ
Không commit file chứa mật khẩu thật lên Git. Hãy dùng biến môi trường, secret manager hoặc cơ chế cấu hình an toàn phù hợp với môi trường triển khai.
Lỗi 8: Connection bị rò rỉ
Nếu Connection không được đóng đúng cách, database có thể cạn số lượng kết nối khả dụng. Sử dụng try-with-resources và cân nhắc Connection Pool để giảm nguy cơ.
Lỗi 9: Một Servlet giữ dữ liệu của Request trước đó
Đây thường là lỗi liên quan đến việc sử dụng instance field mutable để lưu dữ liệu riêng của người dùng. Hãy xem lại vòng đời Servlet và các nguyên tắc Thread Safety.
7. Từ Servlet/JSP đến Spring Framework
Đến đây, chúng ta đã xây dựng một ứng dụng Web có thể chạy được bằng Java thuần. Chúng ta đã tự xử lý Request, URL Mapping, JDBC, JSP và các thao tác CRUD.
Vậy câu hỏi cuối cùng là: Nếu Servlet và JDBC đã đủ để xây dựng ứng dụng, tại sao chúng ta cần Spring Framework?
Câu trả lời không phải vì Servlet/JSP lỗi thời hoặc không thể xây dựng hệ thống lớn. Vấn đề là khi ứng dụng ngày càng phức tạp, lượng code hạ tầng, cấu hình và trách nhiệm phải tự quản lý sẽ tăng lên đáng kể.
7.1. Những giới hạn của Servlet/JSP thuần
A. Boilerplate code quá nhiều
Chỉ để nhận một Request và trả về danh sách sản phẩm, chúng ta đã phải tự xử lý khá nhiều bước:
Read HTTP Request
|
v
Parse Parameters
|
v
Validate Input
|
v
Call DAO
|
v
Prepare Attributes
|
v
Forward to JSP
|
v
Handle Exceptions
Trong ứng dụng lớn, mỗi Controller có thể phải lặp lại nhiều đoạn code tương tự. Điều đó làm tăng chi phí phát triển và bảo trì.
B. Quản lý phụ thuộc thủ công
Trong ví dụ của chúng ta, Servlet tự tạo DAO:
productDAO = new ProductDAO();
Với ứng dụng nhỏ, cách làm này khá đơn giản. Nhưng khi hệ thống có nhiều Service, DAO, client gọi API ngoài, cache và các thành phần khác, việc tự tạo và kết nối tất cả đối tượng sẽ trở nên khó quản lý.
Chúng ta còn phải xử lý cách cấu hình, thay thế implementation trong kiểm thử và quản lý vòng đời của từng thành phần.
C. Cấu hình và xử lý lỗi phân tán
Nếu mỗi Servlet tự xử lý validation, exception, logging và điều hướng, những quy tắc chung có thể bị lặp lại hoặc triển khai không nhất quán.
D. Khó tổ chức ứng dụng lớn
Khi nghiệp vụ phát triển, chúng ta cần nhiều tầng rõ ràng hơn: Controller, Service, Repository, validation, transaction management và cơ chế cấu hình tập trung.
Servlet/JSP vẫn có thể triển khai các kiến trúc này, nhưng lập trình viên phải tự tổ chức nhiều thành phần hơn.
E. HTML và logic nghiệp vụ dễ bị trộn lẫn
Nếu lạm dụng Scriptlet trong JSP hoặc viết toàn bộ HTML trong Servlet, việc phát triển giao diện và nghiệp vụ sẽ phụ thuộc chặt chẽ vào nhau. Điều này khiến việc kiểm thử và thay đổi khó khăn hơn.
Vấn đề không phải Servlet không tốt. Servlet là một nền tảng quan trọng của Java Web.
Vấn đề là khi ứng dụng lớn lên, chúng ta cần những công cụ giúp chuẩn hóa cách tổ chức code, quản lý dependency, xử lý Request, validation, transaction và nhiều vấn đề hạ tầng khác.
7.2. Spring giải quyết những vấn đề gì?
Spring Framework được xây dựng để giúp phát triển ứng dụng Java linh hoạt hơn, trong đó hai nền tảng nổi bật là Dependency Injection và Aspect-Oriented Programming. Hệ sinh thái Spring còn cung cấp nhiều cơ chế phục vụ Web, data access, transaction và kiểm thử.
Hãy so sánh một số công việc mà chúng ta đang thực hiện thủ công với các giải pháp phổ biến trong Spring.
| Java Web thuần | Giải pháp trong hệ sinh thái Spring |
|---|---|
URL Mapping bằng @WebServlet |
Spring MVC sử dụng @RequestMapping, @GetMapping, @PostMapping |
| Tự tạo đối tượng DAO, Service | Dependency Injection quản lý và cung cấp dependency |
| Tự đọc và chuyển đổi Request Parameter | Spring MVC hỗ trợ binding và conversion |
| Tự tổ chức validation | Spring MVC tích hợp các cơ chế validation phù hợp |
| Tự cấu hình nhiều thành phần hạ tầng | Spring Boot cung cấp cơ chế auto-configuration |
| Tự quản lý transaction bằng JDBC | Spring Transaction Management hỗ trợ quản lý transaction |
| Tự ánh xạ dữ liệu SQL | Spring JDBC, Spring Data và các công cụ liên quan giúp giảm code truy cập dữ liệu |
A. Spring MVC - Đơn giản hóa xử lý HTTP
Trong Servlet thuần, chúng ta cần tự đọc Request, phân tích tham số và lựa chọn JSP.
@WebServlet("/products")
public class ProductServlet extends HttpServlet {
@Override
protected void doGet(
HttpServletRequest request,
HttpServletResponse response)
throws ServletException, IOException {
// Đọc dữ liệu
// Gọi DAO
// Set Attribute
// Forward đến JSP
}
}
Với Spring MVC, chúng ta có thể biểu diễn luồng tương tự bằng Controller:
@Controller
@RequestMapping("/products")
public class ProductController {
private final ProductService productService;
public ProductController(ProductService productService) {
this.productService = productService;
}
@GetMapping
public String list(Model model) {
model.addAttribute(
"products",
productService.findAll()
);
return "products/list";
}
}
Ở đây, Spring MVC giúp chúng ta không phải tự viết phần lớn boilerplate của Servlet. Dependency ProductService được cung cấp thông qua constructor injection, còn cơ chế định tuyến được khai báo bằng annotation.
Ví dụ này vẫn sử dụng View theo tên để minh họa MVC. Nếu cấu hình ứng dụng sử dụng template engine khác, cách render View sẽ phụ thuộc vào công nghệ được lựa chọn.
B. Dependency Injection - Không cần tự tạo mọi đối tượng
Trong ứng dụng thuần, chúng ta thường tự tạo đối tượng bằng new. Với Dependency Injection, đối tượng được cung cấp bởi một container hoặc cơ chế quản lý dependency.
Spring Container
|
+---- ProductController
|
+---- ProductService
|
+---- ProductRepository
|
v
Dependency Injection
Điều này giúp giảm sự phụ thuộc trực tiếp giữa các lớp, hỗ trợ thay thế implementation và giúp kiểm thử dễ dàng hơn.
C. Spring Boot - Giảm công việc cấu hình
Spring Boot bổ sung cơ chế auto-configuration, starter dependencies và các công cụ triển khai giúp việc tạo ứng dụng Spring nhanh hơn.
Điều đó không có nghĩa Spring Boot loại bỏ hoàn toàn cấu hình. Lập trình viên vẫn cần hiểu dependency, môi trường chạy, database, bảo mật và các lựa chọn kiến trúc của ứng dụng.
D. Spring JDBC và Spring Data
JDBC vẫn là nền tảng giao tiếp với database trong nhiều ứng dụng Java. Spring JDBC giúp giảm boilerplate khi thực hiện truy vấn. Spring Data cung cấp các abstraction để hỗ trợ truy cập dữ liệu theo nhiều cách khác nhau.
Chúng ta không nhất thiết phải dùng ORM ngay từ đầu. Việc hiểu JDBC và SQL trước sẽ giúp lập trình viên hiểu rõ hơn những gì các abstraction đang thực hiện.
Khi hiểu Servlet, chúng ta hiểu cách HTTP Request đi vào ứng dụng.
Khi hiểu JSP, chúng ta hiểu việc tạo View phía Server.
Khi hiểu JDBC, chúng ta hiểu cách Java giao tiếp với database.
Khi hiểu MVC, chúng ta hiểu tại sao cần tách Controller, Model và View.
Từ nền tảng đó, Spring không còn là một tập hợp annotation bí ẩn. Nó trở thành một bộ công cụ giúp chúng ta thực hiện những nguyên tắc thiết kế đã hiểu theo cách chuẩn hóa và hiệu quả hơn.
7.3. Lộ trình học tập tiếp theo
Sau khi hoàn thành bài học, chúng ta có thể tiếp tục theo lộ trình sau:
Java Core
|
v
HTTP Fundamentals
|
v
Servlet Lifecycle
|
v
JSP + MVC Model 2
|
v
JDBC + SQL
|
v
CRUD Application
|
v
Clean Architecture
|
v
Spring Core + Dependency Injection
|
v
Spring MVC
|
v
Spring Boot
|
v
Spring Data / Transactions
|
v
Spring Security + Testing
|
v
Production-ready Web Application
Gợi ý thực hành nâng cao:
1. Tách validation khỏi Servlet bằng một lớp riêng. 2. Thêm tầng Service để xử lý nghiệp vụ. 3. Thay DriverManager bằng DataSource và Connection Pool. 4. Thêm phân trang và tìm kiếm sản phẩm. 5. Thêm CSRF protection và xác thực người dùng. 6. Viết unit test cho các lớp xử lý nghiệp vụ và kiểm thử tích hợp DAO. 7. Tạo REST API trả JSON bên cạnh giao diện JSP. 8. Chuyển ứng dụng sang Spring Boot và so sánh lượng code cần viết.
8. Tổng kết bài học
Trong bài học này, chúng ta đã đi qua toàn bộ hành trình của một ứng dụng Java Web truyền thống: từ HTTP Request, Servlet Container, Servlet Controller, Model, JDBC và database, cho đến JSP tạo HTML Response.
Những kiến thức cốt lõi cần ghi nhớ:
1. HTTP: Là giao thức giao tiếp giữa Client và Server. Request mang thông tin đầu vào; Response chứa kết quả và Status Code. 2. Tomcat: Là Servlet Container quản lý việc triển khai và thực thi Servlet trong ứng dụng Web. 3. Servlet: Tiếp nhận và xử lý Request. Vòng đời gồm init, xử lý Request thông qua service và các method tương ứng, rồi destroy khi instance được loại bỏ. 4. JSP: Tạo giao diện phía Server. Nên ưu tiên EL và JSTL thay vì trộn nhiều Java code trong HTML. 5. MVC Model 2: Phân tách Controller, Model và View để code dễ bảo trì hơn. 6. JDBC: Cung cấp API để Java truy cập database thông qua JDBC Driver. 7. PreparedStatement: Giúp tham số hóa SQL và ngăn SQL Injection trong các giá trị được bind đúng cách. 8. DAO: Tách các thao tác truy cập database khỏi Controller. 9. CRUD: Bốn thao tác cơ bản gồm Create, Read, Update và Delete. 10. Spring: Cung cấp các abstraction và cơ chế tự động hóa giúp giảm boilerplate, quản lý dependency và chuẩn hóa cách xây dựng ứng dụng.
Đừng học Servlet chỉ để ghi nhớ
doGet() hay doPost(). Hãy dùng nó để hiểu cách một HTTP Request thực sự đi vào ứng dụng Java.
Đừng học JDBC chỉ để thuộc
executeQuery() hay executeUpdate(). Hãy hiểu cách Java gửi SQL, nhận kết quả và quản lý tài nguyên database.
Và đừng học MVC chỉ để chia code thành ba thư mục. Hãy hiểu tại sao mỗi thành phần cần một trách nhiệm riêng.
Khi nắm vững những nguyên lý này, bạn sẽ bước vào Spring Framework với một nền tảng vững chắc hơn rất nhiều. Bạn không chỉ biết sử dụng framework, mà còn hiểu được framework đang giúp mình giải quyết những vấn đề nào.