Cấu hình và CRUD với Hibernate
Xem thửNhóm kiểm thử vừa dựng xong môi trường staging bằng cách sao chép tệp cấu hình của máy lập trình, trong đó có dòng hibernate.hbm2ddl.auto=create. Hai tuần sau, trong lúc chuẩn bị buổi demo cho khách hàng, một lập trình viên khởi động lại dịch vụ để nạp bản vá nhỏ. Dữ liệu demo của tuần trước, gồm mấy trăm khách hàng mẫu mà nhóm bán hàng đã nhập tỉ mỉ, biến mất sạch. Hibernate không lỗi gì cả. Nó chỉ làm đúng điều dòng cấu hình đó bảo: mỗi lần khởi động, xóa các bảng của entity rồi tạo lại bảng trống.
Cấu hình Hibernate trông như vài dòng thuộc tính vô hại, nhưng mỗi dòng đổi hành vi thật: nó chọn bộ quản lý kết nối, quyết định có được phép sửa hoặc xóa bảng không, quyết định SQL có được ghi ra hay không. Bài này đo từng lựa chọn thay vì kể lại tài liệu: bốn cách cấu hình cho cùng một kết quả, cái gì quyết định pool kết nối, năm chế độ hbm2ddl.auto trên một bảng thật, rồi CRUD hoàn chỉnh cùng các bẫy giao dịch.
Sau bài này, bạn có thể
- Cấu hình Hibernate bằng bốn cách (mã, tệp thuộc tính,
hibernate.cfg.xml,persistence.xml) và chọn cách phù hợp với bí mật ở ngoài tệp. - Biết bộ quản lý kết nối nào đang chạy, vì sao, và cách đặt cỡ pool.
- Chọn đúng chế độ
hbm2ddl.autocho từng môi trường và nêu hậu quả của sai lầm. - Viết CRUD trong một giao dịch có kiểm soát bằng một lớp
UnitOfWork, và đọc câu SQL mà từng thao tác sinh ra. - Nhận ra bốn bẫy: HQL hàng loạt làm entity bị cũ, entity detached, quên commit, rollback không hoàn tác bộ nhớ.
Chuẩn bị môi trường và dự án
Mọi thí nghiệm của bài chạy trong một dự án Maven nhỏ. Mục này nói rõ cần cài gì, khai báo thư viện nào và cấu hình ra sao; mã của từng lớp được trình bày ngay tại chỗ nó được dùng, kèm giải thích, nên bạn không cần mở nơi nào khác. Số liệu và thông báo lỗi trong bài được đo từ chính mã đó (JDK 17, MySQL 8.4.9, Hibernate ORM 7.4.13.Final với hibernate-hikaricp, HikariCP 7.1.0). Bản sao lưu cả dự án nằm ở kho mã của khóa học, thư mục 05-hibernate/02-cau-hinh-va-crud, chỉ để bạn clone cho nhanh nếu không muốn tự gõ lại.
Yêu cầu môi trường
| Thành phần | Phiên bản đã kiểm chứng | Ghi chú |
|---|---|---|
| JDK | 17 trở lên | java -version |
| Maven | 3.9 trở lên | mvn -v |
| MySQL | 8.4 (image mysql:8.4) | |
| Hibernate ORM | 7.4.13.Final (kèm hibernate-hikaricp) | khai báo trong pom.xml |
| HikariCP | 7.1.0 | khai báo trong pom.xml |
| MySQL Connector/J | 26.7.0 | khai báo trong pom.xml |
| Docker | bất kỳ bản gần đây | MySQL cho demo và Testcontainers cho test |
Cấu trúc
02-cau-hinh-va-crud/
docker-compose.yml MySQL 8.4, database shop, user app (cổng 3307)
pom.xml
src/main/resources/
hibernate.cfg.xml cấu hình dạng XML
META-INF/persistence.xml cấu hình theo chuẩn JPA
lab/hibernate-lab.properties cấu hình dạng tệp thuộc tính
src/main/java/dev/canhdominich/hibernate/crud/
DbConfig.java đọc URL, user, mật khẩu từ biến môi trường
Student.java entity có cột email duy nhất
SqlRecorder.java StatementInspector ghi lại mọi câu SQL
Configurations.java bốn cách dựng SessionFactory và hai bộ quản lý kết nối
UnitOfWork.java chạy công việc trong một Session và một giao dịch
ConfigurationDemo.java so sánh dialect và bộ quản lý kết nối của các cách cấu hình
SchemaDemo.java các chế độ hbm2ddl.auto trên bảng thật
CrudDemo.java tạo, đọc, sửa, xóa, HQL hàng loạt, dòng không tồn tại
TransactionPitfallsDemo.java entity detached, quên commit, ràng buộc, rollback
Khai báo thư viện: pom.xml
pom.xml
Dự án Maven Java 17 với hibernate-core và hibernate-hikaricp 7.4.13.Final, HikariCP 7.1.0 khai báo tường minh (cần ở thời điểm biên dịch để chương trình hỏi cỡ pool), Connector/J, slf4j-simple, cùng JUnit, Testcontainers và AssertJ cho test.
<?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>dev.canhdominich.javabackend</groupId>
<artifactId>hibernate-cau-hinh-crud</artifactId>
<version>1.0.0</version>
<name>Hibernate - Cau hinh va CRUD</name>
<properties>
<maven.compiler.release>17</maven.compiler.release>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
<main.class>dev.canhdominich.hibernate.crud.CrudDemo</main.class>
<main.arg>none</main.arg>
<jvm.heap>-Xmx512m</jvm.heap>
<mysql.connector.version>26.7.0</mysql.connector.version>
<hibernate.version>7.4.13.Final</hibernate.version>
<junit.version>6.0.3</junit.version>
<testcontainers.version>2.0.5</testcontainers.version>
<assertj.version>3.27.7</assertj.version>
</properties>
<dependencies>
<dependency>
<groupId>com.mysql</groupId>
<artifactId>mysql-connector-j</artifactId>
<version>${mysql.connector.version}</version>
</dependency>
<dependency>
<groupId>org.hibernate.orm</groupId>
<artifactId>hibernate-core</artifactId>
<version>${hibernate.version}</version>
</dependency>
<dependency>
<groupId>org.hibernate.orm</groupId>
<artifactId>hibernate-hikaricp</artifactId>
<version>${hibernate.version}</version>
</dependency>
<dependency>
<groupId>com.zaxxer</groupId>
<artifactId>HikariCP</artifactId>
<version>7.1.0</version>
</dependency>
<dependency>
<groupId>org.slf4j</groupId>
<artifactId>slf4j-simple</artifactId>
<version>2.0.17</version>
</dependency>
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter</artifactId>
<version>${junit.version}</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>testcontainers-junit-jupiter</artifactId>
<version>${testcontainers.version}</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>testcontainers-mysql</artifactId>
<version>${testcontainers.version}</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.assertj</groupId>
<artifactId>assertj-core</artifactId>
<version>${assertj.version}</version>
<scope>test</scope>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-surefire-plugin</artifactId>
<version>3.5.6</version>
</plugin>
<plugin>
<groupId>org.codehaus.mojo</groupId>
<artifactId>exec-maven-plugin</artifactId>
<version>3.6.3</version>
<configuration>
<executable>java</executable>
<arguments>
<argument>${jvm.heap}</argument>
<argument>-classpath</argument>
<classpath/>
<argument>${main.class}</argument>
<argument>${main.arg}</argument>
</arguments>
</configuration>
</plugin>
</plugins>
</build>
</project>
Cấu hình Docker Compose
docker-compose.yml
File này khởi động MySQL 8.4 bằng một lệnh. Nó tạo database shop, tài khoản ứng dụng app (mật khẩu app_pw) và tài khoản quản trị root (mật khẩu root_pw), mở cổng 3307 của máy bạn tới cổng 3306 trong container, và khai báo healthcheck bằng mysqladmin ping để docker compose ps báo healthy khi MySQL đã sẵn sàng nhận kết nối.
services:
mysql:
image: mysql:8.4
container_name: jdbc-lab-mysql
environment:
MYSQL_ROOT_PASSWORD: root_pw
MYSQL_DATABASE: shop
MYSQL_USER: app
MYSQL_PASSWORD: app_pw
ports:
- "${MYSQL_PORT:-3307}:3306"
healthcheck:
test: ["CMD", "mysqladmin", "ping", "-h", "127.0.0.1", "-uroot", "-proot_pw"]
interval: 3s
timeout: 3s
retries: 30
Khởi động và kiểm tra MySQL:
docker compose up -d
docker compose ps
Chờ cột STATUS hiện healthy. MySQL lắng nghe ở cổng 3307 của máy bạn; nếu bạn đang chạy một lab khác dùng cùng cổng, chạy docker compose down ở lab đó trước. Biến môi trường mặc định của chương trình: JDBC_URL=jdbc:mysql://localhost:3307/shop, DB_USER=app, DB_PASSWORD=app_pw.
Cấu hình: bốn cách, một kết quả
Hibernate đọc cấu hình từ một tập thuộc tính (hibernate.connection.url, hibernate.hbm2ddl.auto...) và một danh sách lớp entity. Cách bạn đưa chúng vào có bốn kiểu. Dự án dùng chung ba thứ phụ trợ. DbConfig giữ thông tin kết nối từ biến môi trường (để mật khẩu không nằm trong tệp cấu hình):
src/main/java/dev/canhdominich/hibernate/crud/DbConfig.java
package dev.canhdominich.hibernate.crud;
public record DbConfig(String url, String user, String password) {
public static DbConfig fromEnvironment() {
return new DbConfig(
env("JDBC_URL", "jdbc:mysql://localhost:3307/shop"),
env("DB_USER", "app"),
env("DB_PASSWORD", "app_pw"));
}
public DbConfig withUrl(String newUrl) {
return new DbConfig(newUrl, user, password);
}
public DbConfig withPassword(String newPassword) {
return new DbConfig(url, user, newPassword);
}
private static String env(String name, String fallback) {
String value = System.getenv(name);
return value == null || value.isBlank() ? fallback : value;
}
}
SqlRecorder là StatementInspector ghi lại mọi câu SQL, như ở bài trước, để các thí nghiệm in ra được:
src/main/java/dev/canhdominich/hibernate/crud/SqlRecorder.java
package dev.canhdominich.hibernate.crud;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import org.hibernate.resource.jdbc.spi.StatementInspector;
public class SqlRecorder implements StatementInspector {
private static final List<String> STATEMENTS = Collections.synchronizedList(new ArrayList<>());
@Override
public String inspect(String sql) {
STATEMENTS.add(sql.replaceAll("\\s+", " ").trim());
return sql;
}
public static void clear() {
STATEMENTS.clear();
}
public static List<String> statements() {
synchronized (STATEMENTS) {
return new ArrayList<>(STATEMENTS);
}
}
public static void print(String title) {
System.out.println(title);
List<String> list = statements();
if (list.isEmpty()) {
System.out.println(" (no SQL)");
}
list.forEach(sql -> System.out.println(" " + sql));
clear();
}
}
Và entity Student, lần này có cột email duy nhất (unique = true) để bài có thể gây ra vi phạm ràng buộc:
src/main/java/dev/canhdominich/hibernate/crud/Student.java
package dev.canhdominich.hibernate.crud;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.EnumType;
import jakarta.persistence.Enumerated;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
@Entity
@Table(name = "student")
public class Student {
public enum Level {
BASIC, ADVANCED
}
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(name = "full_name", nullable = false, length = 100)
private String fullName;
@Column(nullable = false, unique = true, length = 100)
private String email;
@Enumerated(EnumType.STRING)
@Column(nullable = false, length = 20)
private Level level = Level.BASIC;
protected Student() {
}
public Student(String fullName, String email) {
this.fullName = fullName;
this.email = email;
}
public Long getId() {
return id;
}
public String getFullName() {
return fullName;
}
public void setFullName(String fullName) {
this.fullName = fullName;
}
public String getEmail() {
return email;
}
public Level getLevel() {
return level;
}
public void setLevel(Level level) {
this.level = level;
}
@Override
public String toString() {
return "Student[" + id + ", " + fullName + ", " + level + "]";
}
}
Bốn cách dựng SessionFactory nằm trong lớp Configurations:
src/main/java/dev/canhdominich/hibernate/crud/Configurations.java
package dev.canhdominich.hibernate.crud;
import jakarta.persistence.EntityManagerFactory;
import jakarta.persistence.Persistence;
import java.io.IOException;
import java.io.InputStream;
import java.util.HashMap;
import java.util.Map;
import java.util.Properties;
import org.hibernate.SessionFactory;
import org.hibernate.cfg.Configuration;
public final class Configurations {
private Configurations() {
}
private static Map<String, String> connection(DbConfig config) {
Map<String, String> map = new HashMap<>();
map.put("hibernate.connection.url", config.url());
map.put("hibernate.connection.username", config.user());
map.put("hibernate.connection.password", config.password());
return map;
}
public static SessionFactory programmatic(DbConfig config) {
Configuration configuration = new Configuration()
.setProperty("hibernate.hbm2ddl.auto", "create-drop")
.setProperty("hibernate.session_factory.statement_inspector", SqlRecorder.class.getName())
.addAnnotatedClass(Student.class);
connection(config).forEach(configuration::setProperty);
return configuration.buildSessionFactory();
}
public static SessionFactory fromPropertiesFile(DbConfig config) throws IOException {
Properties properties = new Properties();
try (InputStream in = Configurations.class.getResourceAsStream("/lab/hibernate-lab.properties")) {
properties.load(in);
}
Configuration configuration = new Configuration().addProperties(properties).addAnnotatedClass(Student.class);
connection(config).forEach(configuration::setProperty);
return configuration.buildSessionFactory();
}
public static SessionFactory fromCfgXml(DbConfig config) {
Configuration configuration = new Configuration().configure();
connection(config).forEach(configuration::setProperty);
return configuration.buildSessionFactory();
}
public static EntityManagerFactory fromPersistenceXml(DbConfig config) {
Map<String, Object> overrides = new HashMap<>(connection(config));
return Persistence.createEntityManagerFactory("lab", overrides);
}
public static SessionFactory withHikari(DbConfig config, int poolSize) {
Configuration configuration = new Configuration()
.setProperty("hibernate.hbm2ddl.auto", "create-drop")
.setProperty("hibernate.connection.provider_class", "org.hibernate.hikaricp.internal.HikariCPConnectionProvider")
.setProperty("hibernate.hikari.maximumPoolSize", String.valueOf(poolSize))
.setProperty("hibernate.session_factory.statement_inspector", SqlRecorder.class.getName())
.addAnnotatedClass(Student.class);
connection(config).forEach(configuration::setProperty);
return configuration.buildSessionFactory();
}
public static SessionFactory withBuiltInPool(DbConfig config) {
Configuration configuration = new Configuration()
.setProperty("hibernate.hbm2ddl.auto", "create-drop")
.setProperty("hibernate.connection.provider_class", "org.hibernate.engine.jdbc.connections.internal.DriverManagerConnectionProvider")
.addAnnotatedClass(Student.class);
connection(config).forEach(configuration::setProperty);
return configuration.buildSessionFactory();
}
}
Ba cách đầu cùng dùng các tệp sau. hibernate.cfg.xml là XML mà configure() đọc từ classpath:
src/main/resources/hibernate.cfg.xml
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE hibernate-configuration PUBLIC "-//Hibernate/Hibernate Configuration DTD 3.0//EN" "http://www.hibernate.org/dtd/hibernate-configuration-3.0.dtd">
<hibernate-configuration>
<session-factory>
<property name="hibernate.hbm2ddl.auto">create-drop</property>
<property name="hibernate.session_factory.statement_inspector">dev.canhdominich.hibernate.crud.SqlRecorder</property>
<mapping class="dev.canhdominich.hibernate.crud.Student"/>
</session-factory>
</hibernate-configuration>
persistence.xml là cấu hình theo chuẩn JPA, đặt ở META-INF; địa chỉ kết nối được truyền vào lúc gọi createEntityManagerFactory:
src/main/resources/META-INF/persistence.xml
<?xml version="1.0" encoding="UTF-8"?>
<persistence xmlns="https://jakarta.ee/xml/ns/persistence" version="3.2">
<persistence-unit name="lab">
<class>dev.canhdominich.hibernate.crud.Student</class>
<properties>
<property name="hibernate.hbm2ddl.auto" value="create-drop"/>
<property name="hibernate.session_factory.statement_inspector" value="dev.canhdominich.hibernate.crud.SqlRecorder"/>
</properties>
</persistence-unit>
</persistence>
Và một tệp thuộc tính để chương trình tự nạp:
src/main/resources/lab/hibernate-lab.properties
hibernate.hbm2ddl.auto=create-drop
hibernate.session_factory.statement_inspector=dev.canhdominich.hibernate.crud.SqlRecorder
Chương trình ConfigurationDemo dựng SessionFactory bằng từng cách rồi hỏi dialect cùng bộ quản lý kết nối đang dùng:
src/main/java/dev/canhdominich/hibernate/crud/ConfigurationDemo.java
package dev.canhdominich.hibernate.crud;
import jakarta.persistence.EntityManagerFactory;
import org.hibernate.SessionFactory;
import org.hibernate.dialect.Dialect;
import org.hibernate.engine.jdbc.connections.spi.ConnectionProvider;
import org.hibernate.engine.spi.SessionFactoryImplementor;
public final class ConfigurationDemo {
private ConfigurationDemo() {
}
public static void main(String[] args) throws Exception {
DbConfig config = DbConfig.fromEnvironment();
describe("programmatic Configuration ", Configurations.programmatic(config));
describe("hibernate.properties-style file ", Configurations.fromPropertiesFile(config));
describe("hibernate.cfg.xml ", Configurations.fromCfgXml(config));
try (EntityManagerFactory emf = Configurations.fromPersistenceXml(config)) {
describe("persistence.xml (JPA bootstrap) ", emf.unwrap(SessionFactory.class));
}
describe("hikaricp module, pool size 7 ", Configurations.withHikari(config, 7));
describe("built-in pool forced explicitly ", Configurations.withBuiltInPool(config));
}
static void describe(String label, SessionFactory factory) {
try (factory) {
SessionFactoryImplementor implementor = (SessionFactoryImplementor) factory;
Dialect dialect = implementor.getJdbcServices().getDialect();
ConnectionProvider provider = implementor.getServiceRegistry().getService(ConnectionProvider.class);
String detail = provider.isUnwrappableAs(com.zaxxer.hikari.HikariDataSource.class)
? " maximumPoolSize=" + provider.unwrap(com.zaxxer.hikari.HikariDataSource.class).getMaximumPoolSize() : "";
System.out.println(label + " dialect=" + dialect.getClass().getSimpleName() + " connectionProvider=" + provider.getClass().getSimpleName() + detail);
}
}
}
Chạy MySQL bằng Docker Compose rồi chạy bằng mvn -q compile exec:exec -Dmain.class=dev.canhdominich.hibernate.crud.ConfigurationDemo (xem mục Chuẩn bị ở đầu bài để có docker-compose.yml và pom.xml). Kết quả:
programmatic Configuration dialect=MySQLDialect connectionProvider=HikariCPConnectionProvider maximumPoolSize=10
hibernate.properties-style file dialect=MySQLDialect connectionProvider=HikariCPConnectionProvider maximumPoolSize=10
hibernate.cfg.xml dialect=MySQLDialect connectionProvider=HikariCPConnectionProvider maximumPoolSize=10
persistence.xml (JPA bootstrap) dialect=MySQLDialect connectionProvider=HikariCPConnectionProvider maximumPoolSize=10
hikaricp module, pool size 7 dialect=MySQLDialect connectionProvider=HikariCPConnectionProvider maximumPoolSize=7
built-in pool forced explicitly dialect=MySQLDialect connectionProvider=DriverManagerConnectionProvider
| Cách | Nơi khai báo | Hợp khi |
|---|---|---|
Bằng mã (Configuration) | Trong chương trình | Cấu hình tính toán lúc chạy, hoặc nhúng trong ứng dụng nhỏ |
| Tệp thuộc tính | Tệp khóa=giá trị | Dễ đổi theo môi trường |
hibernate.cfg.xml | XML trên classpath | Cách native cổ điển, liệt kê cả lớp entity |
persistence.xml | XML theo chuẩn JPA | Muốn mã không phụ thuộc Hibernate (dùng EntityManagerFactory) |
Bốn cách cho cùng kết quả: cùng dialect MySQLDialect (Hibernate tự nhận ra từ kết nối, không cần khai báo hibernate.dialect). Quy tắc thực dụng: cấu trúc (entity, hành vi) ở tệp hoặc mã, còn bí mật và địa chỉ kết nối ở biến môi trường, đúng như Configurations làm bằng connection(config).
Cách đọc hình: bốn ô bên trái là bốn cách khai báo; tất cả đổ vào cùng một SessionFactory với cùng dialect. Ô nét đứt bên phải nhắc rằng địa chỉ và mật khẩu kết nối được thêm từ biến môi trường chứ không nằm trong tệp.
Bộ quản lý kết nối do classpath quyết định
Nhìn lại kết quả ở trên: cả bốn cách đều ra HikariCPConnectionProvider với maximumPoolSize=10, dù không dòng nào chọn HikariCP. Lý do: dự án có hibernate-hikaricp trên classpath, và Hibernate tự chọn nó khi thấy module đó. Hai dòng cuối của kết quả cho thấy hai cách điều khiển: hibernate.hikari.maximumPoolSize=7 đổi cỡ pool (đo qua getMaximumPoolSize() là 7), và hibernate.connection.provider_class buộc dùng bộ có sẵn DriverManagerConnectionProvider. Ở bài trước, không có module HikariCP, Hibernate dùng bộ có sẵn này (log ghi Pool: DriverManagerConnectionProvider, tối đa 20 kết nối).
Bài học thực tế: pool kết nối là phần của hiệu năng, và nó có thể đổi chỉ vì thêm hoặc bớt một jar. Hãy luôn đọc dòng log khởi động về pool, và đặt cỡ pool tường minh (kiến thức từ bài kết nối MySQL: tổng pool của mọi bản sao phải nhỏ hơn max_connections).
Cách đọc hình: hàng trên: chỉ cần jar hibernate-hikaricp trên classpath là Hibernate dùng HikariCP với cỡ mặc định 10. Hàng giữa: đặt hikari.maximumPoolSize=7 thì pool có 7 kết nối. Hàng dưới: đặt provider_class tường minh để buộc bộ có sẵn.
hbm2ddl.auto: Hibernate có được sửa schema không
Thuộc tính hibernate.hbm2ddl.auto quyết định công cụ schema làm gì với bảng khi khởi động. Chương trình SchemaDemo thử từng chế độ trên bảng student thật: dựng sẵn một bảng cũ thiếu cột level và có thêm cột legacy_notes mà entity không biết, rồi cho Hibernate khởi động với từng chế độ:
src/main/java/dev/canhdominich/hibernate/crud/SchemaDemo.java
package dev.canhdominich.hibernate.crud;
import java.sql.Connection;
import java.sql.DriverManager;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.sql.Statement;
import java.util.ArrayList;
import java.util.List;
import org.hibernate.SessionFactory;
import org.hibernate.cfg.Configuration;
public final class SchemaDemo {
private static DbConfig CONFIG;
private SchemaDemo() {
}
public static void main(String[] args) throws SQLException {
run(DbConfig.fromEnvironment());
}
public static void run(DbConfig config) throws SQLException {
CONFIG = config;
System.out.println("-- no table at all");
reset("DROP TABLE IF EXISTS student");
attempt("none", "none");
attempt("validate", "validate");
System.out.println("-- an old table that lacks the column level and has an extra legacy_notes column");
reset("DROP TABLE IF EXISTS student", "CREATE TABLE student (id BIGINT AUTO_INCREMENT PRIMARY KEY, full_name VARCHAR(100) NOT NULL, email VARCHAR(100) NOT NULL UNIQUE, legacy_notes TEXT)");
attempt("validate", "validate");
attempt("update", "update");
attempt("update again", "update");
System.out.println("-- create and create-drop");
attempt("create", "create");
attempt("create-drop", "create-drop");
reset("DROP TABLE IF EXISTS student");
}
static void attempt(String label, String mode) throws SQLException {
try {
SessionFactory factory = new Configuration()
.setProperty("hibernate.connection.url", CONFIG.url())
.setProperty("hibernate.connection.username", CONFIG.user())
.setProperty("hibernate.connection.password", CONFIG.password())
.setProperty("hibernate.hbm2ddl.auto", mode)
.addAnnotatedClass(Student.class)
.buildSessionFactory();
factory.close();
System.out.println(String.format("%-14s started, columns after: %s", label, columns()));
} catch (RuntimeException e) {
Throwable root = e;
while (root.getCause() != null && root.getCause() != root) {
root = root.getCause();
}
System.out.println(String.format("%-14s FAILED %s: %s", label, root.getClass().getSimpleName(), root.getMessage()));
}
}
static List<String> columns() throws SQLException {
List<String> names = new ArrayList<>();
try (Connection connection = open(); Statement statement = connection.createStatement(); ResultSet rs = statement.executeQuery("SELECT column_name FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = 'student' ORDER BY ordinal_position")) {
while (rs.next()) {
names.add(rs.getString(1));
}
}
return names;
}
static void reset(String... statements) throws SQLException {
try (Connection connection = open(); Statement statement = connection.createStatement()) {
for (String sql : statements) {
statement.execute(sql);
}
}
}
private static Connection open() throws SQLException {
return DriverManager.getConnection(CONFIG.url(), CONFIG.user(), CONFIG.password());
}
}
Chạy bằng mvn -q exec:exec -Dmain.class=dev.canhdominich.hibernate.crud.SchemaDemo. Kết quả:
-- no table at all
none started, columns after: []
validate FAILED SchemaManagementException: Schema validation: missing table [student]
-- an old table that lacks the column level and has an extra legacy_notes column
validate FAILED SchemaManagementException: Schema validation: missing column [level] in table [student]
update started, columns after: [id, full_name, email, legacy_notes, level]
update again started, columns after: [id, full_name, email, legacy_notes, level]
-- create and create-drop
create started, columns after: [id, email, full_name, level]
create-drop started, columns after: []
| Chế độ | Hành vi đo được | Dùng ở đâu |
|---|---|---|
none | Không làm gì: khởi động êm dù chưa có bảng, lỗi chỉ đến ở câu truy vấn đầu tiên | Môi trường thật khi schema do công cụ khác quản lý |
validate | Chỉ kiểm tra: ném SchemaManagementException với thông điệp cụ thể (missing table [student], missing column [level] in table [student]) và ứng dụng không khởi động | Môi trường thật: lệch schema bị phát hiện ngay |
update | Thêm cái còn thiếu (đã thêm level) nhưng không bao giờ xóa (legacy_notes vẫn còn), chạy lại không đổi gì | Tiện lúc phát triển; tránh ở môi trường thật |
create | Xóa bảng rồi tạo lại: dữ liệu cũ mất, legacy_notes biến mất, thứ tự cột đổi | Chỉ thí nghiệm |
create-drop | Như create, và xóa bảng khi đóng SessionFactory (cột sau đó là []) | Thí nghiệm và test |
Hoạt cảnh dưới đây diễn lại validate và update trên bảng lệch:
Đang tải Animation…
Đây là lý do create trong tệp cấu hình dùng chung xóa dữ liệu staging ở câu chuyện mở đầu. Quy tắc: create, create-drop chỉ cho test và thí nghiệm; môi trường thật dùng validate (hoặc none) và đổi schema bằng một công cụ migration như Flyway (khóa học sẽ dùng ở dự án Capstone), nơi mỗi thay đổi là một tệp SQL có phiên bản, được xem xét và chạy đúng một lần. update nghe tiện nhưng che sự lệch: nó vá bằng cách thêm, không bao giờ bớt, nên cột thừa tích tụ mà không ai hay.
Cách đọc hình: none không làm gì và lỗi đến muộn; validate ném lỗi cụ thể và dừng ứng dụng; update thêm cột thiếu nhưng giữ mọi thứ thừa; create và create-drop tạo lại bảng và xóa dữ liệu.
CRUD trong một giao dịch có kiểm soát
Mọi thao tác ghi cần một giao dịch. Lớp UnitOfWork đóng gói khuôn: mở Session, bắt đầu giao dịch, chạy công việc, commit, và rollback nếu có ngoại lệ (rồi ném lại), luôn đóng Session:
src/main/java/dev/canhdominich/hibernate/crud/UnitOfWork.java
package dev.canhdominich.hibernate.crud;
import java.util.function.Consumer;
import java.util.function.Function;
import org.hibernate.Session;
import org.hibernate.SessionFactory;
import org.hibernate.Transaction;
public final class UnitOfWork {
private UnitOfWork() {
}
public static <T> T inTransaction(SessionFactory factory, Function<Session, T> work) {
try (Session session = factory.openSession()) {
Transaction tx = session.beginTransaction();
try {
T result = work.apply(session);
tx.commit();
return result;
} catch (RuntimeException e) {
if (tx.isActive()) {
tx.rollback();
}
throw e;
}
}
}
public static void run(SessionFactory factory, Consumer<Session> work) {
inTransaction(factory, session -> {
work.accept(session);
return null;
});
}
}
Chương trình CrudDemo dùng nó để làm đủ bốn thao tác và vài trường hợp biên, in SQL của từng bước:
src/main/java/dev/canhdominich/hibernate/crud/CrudDemo.java
package dev.canhdominich.hibernate.crud;
import org.hibernate.Session;
import org.hibernate.SessionFactory;
import org.hibernate.Transaction;
public final class CrudDemo {
private CrudDemo() {
}
public static void main(String[] args) {
try (SessionFactory factory = Configurations.programmatic(DbConfig.fromEnvironment())) {
long id = create(factory);
read(factory, id);
update(factory, id);
bulkUpdateIsInvisibleToManagedEntities(factory, id);
delete(factory, id);
missingRows(factory, id);
}
}
static long create(SessionFactory factory) {
SqlRecorder.clear();
long id = UnitOfWork.inTransaction(factory, session -> {
Student student = new Student("Nguyễn Văn An", "an@example.com");
session.persist(student);
return student.getId();
});
SqlRecorder.print("-- create: persist inside one transaction");
return id;
}
static void read(SessionFactory factory, long id) {
UnitOfWork.run(factory, session -> System.out.println("-- read: " + session.find(Student.class, id)));
SqlRecorder.print("-- SQL of the read");
}
static void update(SessionFactory factory, long id) {
UnitOfWork.run(factory, session -> session.find(Student.class, id).setFullName("Nguyễn Văn An (cập nhật)"));
SqlRecorder.print("-- update: load, change, commit");
}
static void bulkUpdateIsInvisibleToManagedEntities(SessionFactory factory, long id) {
try (Session session = factory.openSession()) {
Transaction tx = session.beginTransaction();
Student student = session.find(Student.class, id);
int rows = session.createMutationQuery("update Student s set s.level = :level where s.id = :id").setParameter("level", Student.Level.ADVANCED).setParameter("id", id).executeUpdate();
System.out.println("-- HQL bulk update changed " + rows + " row; the managed object still says " + student.getLevel());
session.refresh(student);
System.out.println("-- after refresh() the object says " + student.getLevel());
tx.commit();
}
SqlRecorder.print("-- SQL of the bulk update scenario");
}
static void delete(SessionFactory factory, long id) {
UnitOfWork.run(factory, session -> session.remove(session.find(Student.class, id)));
SqlRecorder.print("-- delete: load then remove");
}
static void missingRows(SessionFactory factory, long id) {
UnitOfWork.run(factory, session -> {
System.out.println("-- find() of a missing row returns " + session.find(Student.class, id));
Student reference = session.getReference(Student.class, id);
try {
reference.getFullName();
} catch (RuntimeException e) {
System.out.println("-- getReference() of a missing row fails only when used: " + e.getClass().getSimpleName());
}
});
}
}
Chạy bằng mvn -q exec:exec -Dmain.class=dev.canhdominich.hibernate.crud.CrudDemo. Kết quả (phần CRUD):
-- create: persist inside one transaction
insert into student (email,full_name,level) values (?,?,?)
-- read: Student[1, Nguyễn Văn An, BASIC]
-- SQL of the read
select s1_0.id,s1_0.email,s1_0.full_name,s1_0.level from student s1_0 where s1_0.id=?
-- update: load, change, commit
select s1_0.id,s1_0.email,s1_0.full_name,s1_0.level from student s1_0 where s1_0.id=?
update student set email=?,full_name=?,level=? where id=?
-- delete: load then remove
select s1_0.id,s1_0.email,s1_0.full_name,s1_0.level from student s1_0 where s1_0.id=?
delete from student where id=?
-- find() of a missing row returns null
-- getReference() of a missing row fails only when used: ObjectNotFoundException
Từng thao tác trong thực tế:
- Create:
persist(với khóaIDENTITY,INSERTchạy ngay). - Read:
findtrảnullnếu không có dòng;getReferencekhông báo lỗi lúc gọi mà chỉ némObjectNotFoundExceptionkhi bạn dùng proxy (thông tin hay bị bỏ sót khi kiểm tra tồn tại). - Update: nạp, sửa, commit; không có lời gọi cập nhật.
- Delete: muốn xóa, phải có entity:
remove(find(...))chạy mộtSELECTrồiDELETE. Muốn xóa không cần nạp, dùng HQL (mục sau), với cái giá riêng.
Cách đọc hình: mỗi hàng là một thao tác và câu SQL sinh ra: persist chèn ngay, find một SELECT, cập nhật nạp rồi UPDATE ở commit, xóa nạp rồi DELETE.
HQL hàng loạt không cập nhật entity trong bộ nhớ
Câu HQL update/delete chạy thẳng trong cơ sở dữ liệu và không đi qua persistence context. CrudDemo nạp một sinh viên, chạy update Student s set s.level = :level trên chính dòng đó, rồi đọc lại đối tượng:
-- HQL bulk update changed 1 row; the managed object still says BASIC
-- after refresh() the object says ADVANCED
-- SQL of the bulk update scenario
select s1_0.id,s1_0.email,s1_0.full_name,s1_0.level from student s1_0 where s1_0.id=?
update student s1_0 set level=? where s1_0.id=?
select s1_0.id,s1_0.email,s1_0.full_name,s1_0.level from student s1_0 where s1_0.id=?
Cơ sở dữ liệu đã đổi nhưng đối tượng trong bộ nhớ vẫn nói BASIC (cũ) cho tới khi refresh() chạy thêm một SELECT nữa. Bài học: sau một câu HQL hàng loạt, dùng refresh hoặc clear cho các entity có thể bị ảnh hưởng, nếu không phần code sau đọc dữ liệu cũ.
Cách đọc hình: sinh viên được nạp với level là BASIC; câu HQL hàng loạt đổi dòng trong cơ sở dữ liệu nhưng entity vẫn đọc ra BASIC; sau refresh nó đọc lại từ cơ sở dữ liệu và thành ADVANCED.
Bốn bẫy giao dịch
Chương trình TransactionPitfallsDemo tái hiện bốn bẫy, mỗi bẫy kèm kết quả đo:
src/main/java/dev/canhdominich/hibernate/crud/TransactionPitfallsDemo.java
package dev.canhdominich.hibernate.crud;
import org.hibernate.Session;
import org.hibernate.SessionFactory;
import org.hibernate.Transaction;
public final class TransactionPitfallsDemo {
private TransactionPitfallsDemo() {
}
public static void main(String[] args) {
try (SessionFactory factory = Configurations.programmatic(DbConfig.fromEnvironment())) {
reset(factory);
detachedObjects(factory);
reset(factory);
forgottenCommit(factory);
constraintViolation(factory);
rollbackKeepsMemoryState(factory);
}
}
static void reset(SessionFactory factory) {
UnitOfWork.run(factory, session -> {
session.createMutationQuery("delete from Student").executeUpdate();
session.persist(new Student("An", "an@example.com"));
});
}
static long rows(SessionFactory factory) {
try (Session session = factory.openSession()) {
return session.createQuery("select count(s) from Student s", Long.class).getSingleResult();
}
}
static void detachedObjects(SessionFactory factory) {
Student detached;
try (Session session = factory.openSession()) {
detached = session.createQuery("from Student", Student.class).getSingleResult();
}
try {
UnitOfWork.run(factory, session -> session.persist(detached));
} catch (RuntimeException e) {
System.out.println("-- persist(detached): " + e.getClass().getSimpleName() + ": " + e.getMessage());
}
try {
UnitOfWork.run(factory, session -> session.remove(detached));
System.out.println("-- remove(detached): no exception, rows left = " + rows(factory));
} catch (RuntimeException e) {
System.out.println("-- remove(detached): " + e.getClass().getSimpleName() + ": " + e.getMessage());
}
}
static void forgottenCommit(SessionFactory factory) {
try (Session session = factory.openSession()) {
session.beginTransaction();
session.persist(new Student("Forgotten", "forgotten@example.com"));
}
try (Session session = factory.openSession()) {
Long count = session.createQuery("select count(s) from Student s where s.email = 'forgotten@example.com'", Long.class).getSingleResult();
System.out.println("-- a Session closed without commit: rows stored = " + count);
}
}
static void constraintViolation(SessionFactory factory) {
try (Session session = factory.openSession()) {
Transaction tx = session.beginTransaction();
try {
session.persist(new Student("Duplicate", "an@example.com"));
tx.commit();
} catch (RuntimeException e) {
System.out.println("-- duplicate email, raised by persist() because IDENTITY inserts at once: " + e.getClass().getSimpleName() + ", cause " + e.getCause().getClass().getSimpleName());
System.out.println("-- transaction status after the failure: " + tx.getStatus() + ", active = " + tx.isActive());
}
}
}
static void rollbackKeepsMemoryState(SessionFactory factory) {
try (Session session = factory.openSession()) {
Transaction tx = session.beginTransaction();
Student student = session.createQuery("from Student s where s.email = 'an@example.com'", Student.class).getSingleResult();
student.setFullName("Changed then rolled back");
tx.rollback();
System.out.println("-- after rollback the object in memory still says: " + student.getFullName());
}
try (Session session = factory.openSession()) {
System.out.println("-- and the database says: " + session.createQuery("from Student s where s.email = 'an@example.com'", Student.class).getSingleResult().getFullName());
}
}
}
Chạy bằng mvn -q exec:exec -Dmain.class=dev.canhdominich.hibernate.crud.TransactionPitfallsDemo:
-- persist(detached): EntityExistsException: Detached entity passed to persist: dev.canhdominich.hibernate.crud.Student
-- remove(detached): no exception, rows left = 0
-- a Session closed without commit: rows stored = 0
-- duplicate email, raised by persist() because IDENTITY inserts at once: ConstraintViolationException, cause SQLIntegrityConstraintViolationException
-- transaction status after the failure: MARKED_ROLLBACK, active = true
-- after rollback the object in memory still says: Changed then rolled back
-- and the database says: An
- Entity detached:
persistmột entity đã có id (detached) némEntityExistsException. Ngược lạiremovemột entity detached không ném lỗi ở phiên bản này mà xóa dòng thật (rows left = 0); tài liệu và mã Hibernate cũ có thể nói khác, nên đừng dựa vào ngoại lệ để phát hiện lỗi. - Quên commit:
Sessionđóng mà không commit thì không có gì được lưu (rows stored = 0). Không lỗi, không cảnh báo: dữ liệu biến mất lặng lẽ. - Vi phạm ràng buộc: chèn email trùng ném
ConstraintViolationException(nguyên nhân gốc là lỗi của JDBC) ngay ởpersist, không phải ở commit, vì khóaIDENTITYbuộcINSERTchạy ngay; sau lỗi giao dịch ở trạng tháiMARKED_ROLLBACK, chỉ còn rollback. - Rollback không hoàn tác bộ nhớ: sau
rollback()cơ sở dữ liệu giữ nguyên (An) nhưng đối tượng Java vẫn mang giá trị đã sửa (Changed then rolled back). Sau lỗi hoặc rollback hãy bỏSessionvà các entity của nó, đừng tiếp tục dùng.
Cách đọc hình: hai ô trên là hai hành vi với entity detached (persist ném lỗi, remove lại xóa thật). Ô thứ ba là Session đóng không commit nên không lưu gì. Hai hàng dưới: vi phạm ràng buộc đưa giao dịch về MARKED_ROLLBACK, và rollback khôi phục cơ sở dữ liệu nhưng không khôi phục đối tượng trong bộ nhớ.
Quyết định thiết kế và đánh đổi
| Quyết định | Lựa chọn | Đánh đổi |
|---|---|---|
| Cách cấu hình | Cấu trúc ở tệp hoặc mã, bí mật ở biến môi trường | Hai nơi để tra khi tìm một giá trị |
| Pool kết nối | Chọn tường minh, đặt cỡ, đọc log khởi động | Phải theo dõi khi thêm hoặc bớt module |
hbm2ddl.auto | validate ở môi trường thật, create-drop ở test | Cần công cụ migration để đổi schema |
| Giao dịch | Một UnitOfWork bao mọi thao tác ghi | Giao dịch quá dài giữ khóa lâu |
| Cập nhật hàng loạt | HQL kèm refresh hoặc clear | Bỏ qua persistence context nên phải tự đồng bộ |
| Sau lỗi | Bỏ Session và entity của nó | Phải mở Session mới và nạp lại |
Playbook: gặp sự cố cấu hình và giao dịch thì làm gì
| Triệu chứng | Kiểm tra đầu tiên | Nguyên nhân hay gặp | Cách xử lý |
|---|---|---|---|
| Dữ liệu biến mất sau khởi động lại | Giá trị hbm2ddl.auto | create hoặc create-drop ở môi trường có dữ liệu | Đổi sang validate ngay, khôi phục từ bản sao lưu |
Ứng dụng không khởi động với Schema validation | Thông điệp nêu bảng hoặc cột nào | Schema lệch so với entity | Viết migration, không để Hibernate tự sửa |
| Số kết nối khác mong đợi | Log về ConnectionProvider | Thêm hoặc bớt hibernate-hikaricp | Chọn tường minh và đặt cỡ pool |
| Không thấy dữ liệu đã "lưu" | Có commit không | Quên commit hoặc rollback | Dùng UnitOfWork, kiểm tra giao dịch |
| Dữ liệu đọc ra cũ sau cập nhật hàng loạt | Có dùng HQL update không | Persistence context không biết | refresh hoặc clear |
EntityExistsException khi lưu | Entity có id chưa | Truyền entity detached cho persist | Dùng merge, hoặc nạp lại |
Tiếp tục dùng Session sau lỗi cho kết quả lạ | Trạng thái giao dịch | MARKED_ROLLBACK, đối tượng đã sửa | Bỏ Session, mở cái mới |
Lỗi thường gặp khi chạy lab
| Triệu chứng | Nguyên nhân | Cách xử lý |
|---|---|---|
Bind for 0.0.0.0:3307 failed: port is already allocated | Cổng 3307 đã bị chiếm (ví dụ bởi lab trước chưa tắt) | docker compose down ở lab trước, hoặc MYSQL_PORT=3308 docker compose up -d rồi export JDBC_URL=jdbc:mysql://localhost:3308/shop |
SchemaManagementException khi chạy CrudDemo | Bảng student còn sót từ SchemaDemo với cấu trúc cũ | Chạy SchemaDemo đến hết (nó dọn bảng ở cuối) hoặc DROP TABLE student trong database shop |
Could not find a valid Docker environment khi mvn test | Docker chưa chạy | Bật Docker rồi chạy lại |
Bài thực hành
Làm trong dự án của bài (thư mục 05-hibernate/02-cau-hinh-va-crud). Sau mỗi bước, so kết quả với tiêu chí nghiệm thu.
- So bốn cách cấu hình.
docker compose up -d, chạyConfigurationDemo. Tiêu chí: cả bốn choMySQLDialectvàHikariCPConnectionProvider. - Đổi cỡ pool. Đặt
hikari.maximumPoolSizelà 3 trongwithHikari. Tiêu chí: dòng tương ứng báomaximumPoolSize=3. - Thử các chế độ schema. Chạy
SchemaDemo. Tiêu chí:validatelỗi đúng thông điệp,updategiữlegacy_notes,createlàm nó biến mất. - Chạy CRUD. Chạy
CrudDemo. Tiêu chí: giải thích vì saoupdatevàdeleteđều có mộtSELECTđi trước. - Làm entity bị cũ. Trong
bulkUpdateIsInvisibleToManagedEntities, thayrefreshbằngsession.clear()rồifindlại. Tiêu chí: giá trị đúng saufind; giải thích khác biệt vớirefresh. - Gây từng bẫy. Chạy
TransactionPitfallsDemo. Tiêu chí: bảy dòng giống bảng trong bài. - Chạy bộ test (tùy chọn). Bộ test tự động của dự án nằm trong bản sao lưu ở kho mã của khóa học; sau khi clone,
mvn testchoTests run: 5, Failures: 0, Errors: 0. Dọn dẹp bằngdocker compose down -v.
Hiểu lầm và giới hạn
- "
updatelà chế độ an toàn vì không xóa gì." Nó không xóa nhưng che sự lệch và tích tụ cột thừa. - "
removetrên entity detached luôn bị từ chối." Ở Hibernate 7.4.13 nó xóa dòng thật. - "Lỗi ràng buộc luôn đến lúc commit." Với khóa
IDENTITYnó đến ngay ởpersist. - "Rollback đưa mọi thứ về như cũ." Chỉ cơ sở dữ liệu; đối tượng Java giữ giá trị đã sửa.
- Số đo thuộc về Hibernate 7.4.13, MySQL 8.4.9 và
hibernate-hikaricptrên classpath. Bài chưa thử các tham số pool khác (thời gian chờ, kiểm tra rò rỉ), tích hợp giao dịch với Spring, mức cô lập giao dịch của Hibernate, và công cụ migration; phần sau của khóa trình bày.
Tự kiểm tra
- Bạn không khai báo pool nào mà Hibernate vẫn dùng HikariCP. Vì sao, và làm sao biết?
- Vì sao
hbm2ddl.auto=updatekhông phù hợp với môi trường thật dù không xóa dữ liệu? getReferencecủa một id không tồn tại có ném lỗi ngay không?- Sau một câu HQL
updatehàng loạt, vì sao entity trong bộ nhớ vẫn mang giá trị cũ? - Một
persistvi phạm ràng buộc duy nhất ném lỗi ở đâu với khóaIDENTITY, và nên làm gì vớiSessionsau đó?
Gợi ý đáp án. (1) Jar hibernate-hikaricp trên classpath làm Hibernate tự chọn nó; đọc log khởi động hoặc hỏi ConnectionProvider. (2) Nó vá bằng cách thêm, không bao giờ bớt, nên che sự lệch và tích tụ cột thừa; dùng validate kèm công cụ migration. (3) Không: chỉ ném ObjectNotFoundException khi dùng proxy. (4) HQL hàng loạt chạy thẳng trong cơ sở dữ liệu và bỏ qua persistence context; cần refresh hoặc clear. (5) Ngay ở persist (vì INSERT chạy ngay); giao dịch ở trạng thái MARKED_ROLLBACK, nên rollback và bỏ Session.
Điều cần nhớ
- Bốn cách cấu hình cho cùng một kết quả; bí mật ở biến môi trường.
- Bộ quản lý kết nối do classpath và thuộc tính quyết định; đặt tường minh và đọc log khởi động.
validatecho môi trường thật,create-dropcho test;updatechỉ thêm và che sự lệch; đổi schema bằng công cụ migration.- Ghi trong một
UnitOfWork; xóa và cập nhật nạp entity trước; HQL hàng loạt bỏ qua persistence context. - Quên commit mất dữ liệu lặng lẽ; rollback không hoàn tác đối tượng trong bộ nhớ.
Đọc thêm
Bài tiếp theo đi vào ánh xạ: annotation cho entity, các loại quan hệ, cascade và HQL, cùng lỗi LazyInitializationException và vấn đề N+1 đầu tiên.