Nội dung bài viết
  1. Tổng quan về Java Web và luồng hoạt động HTTP
  2. Servlet - Trái tim của Java Web
  3. JSP và mô hình MVC Model 2
  4. JDBC - Kết nối cơ sở dữ liệu
  5. Thực chiến: Xây dựng ứng dụng quản lý sản phẩm CRUD
  6. Những bẫy thực chiến và bảo mật
  7. Từ Servlet/JSP đến Spring Framework

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.

Mục tiêu của bài học:
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.

Ghi nhớ:
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:

HTTP Request
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 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:

HTML Form
<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:

HTTP Request
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:

Java
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.

Phân biệt quan trọng:
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:

HelloServlet.java
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.

Servlet Lifecycle
@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().

HttpServlet
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.

Servlet Lifecycle
@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.

Bẫy cần nhớ:
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:

UnsafeServlet.java
@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:

SafeServlet.java
@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:

Java
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.

Java
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():

Java
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.

Java
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:

Java
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

Java
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:

Java
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:

Java
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ụ:

Java
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ụ:

Java
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.

Java - PRG rõ ràng
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:

Java
@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.

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.

Ghi nhớ:
@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:

hello.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 <% ... %>.

Scriptlet Example
<%
    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.

JSP Expression
<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ụ:

JSP Directive
<%@ 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

Không nên sử dụng cho ứng dụng mới
<%
    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ị

products/list.jsp
<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.

Nguyên tắc thiết kế:
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:

Product.java
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.

Expression Language
${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:

JSP
<%@ 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ụ:

JSP - Output an toàn hơn
<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:

JDBC Connection
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:

JDBC Query
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:

Không an toàn
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:

An toàn hơn
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ố.

Nguyên tắc vàng:
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:

JDBC INSERT
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:

JDBC UPDATE
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:

JDBC DELETE
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.

Transaction Example
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ý.

Lưu ý thực chiến:
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.

pom.xml
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0
         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:

Terminal / CMD
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.

Lưu ý:
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.

MySQL SQL
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.

MySQL SQL
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:

Windows PowerShell
$env:DB_USERNAME = "product_app"
$env:DB_PASSWORD = "YourActualPassword"
Linux / macOS
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.

Product.java
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:

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:

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.

Phạm vi của ví dụ:
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.

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.

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à:

Cách viết được khuyến nghị
<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:

JSP Directive
<%@ 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.

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.

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à:

ProductServlet.java
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:

ProductServlet.java
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.

ProductServlet.java
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.

ProductServlet.java
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.

ProductServlet.java
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:

SQL UPDATE
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:

JSP Form
<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.

ProductServlet.java
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:

SQL DELETE
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.

Ghi nhớ:
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.

Terminal
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:

Không an toàn
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.

Ba lỗ hổng - ba vấn đề khác nhau:
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:

CSRF Token - Minh họa
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:

JSP Form
<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ụ:

HTML Form
<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:

ProductServlet.java
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.

Điểm mấu chốt:
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.

Servlet thuần
@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:

Spring MVC - Minh họa
@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.

À, hóa ra Spring sinh ra để giải quyết chính xác những nỗi đau này!

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.

Thông điệp cuối cù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.