Giới thiệu Spring Data JPA và JpaRepository

Xem thử

Một nhóm có lớp truy cập dữ liệu hơn hai nghìn dòng JDBC và Hibernate viết tay, với từng DAO một: StudentDao, CourseDao, OrderDao, mỗi cái cùng các phương thức findById, save, delete, findAll chép nhau. Mỗi thay đổi nhỏ (thêm một cột) kéo theo sửa bảy chỗ, và mỗi lần ai đó quên đóng một Session thì lại có một rò rỉ kết nối. Khi nhóm chuyển sang Spring Data JPA, các DAO biến thành các interface rỗng và mã giảm đi một nửa. Nhưng ba tuần sau, một báo cáo tới: trang quản trị chậm đột ngột. Nguyên nhân là một lời gọi save trong vòng lặp mà mọi người cho là "đơn giản": mỗi lời gọi gửi hai câu SQL thay vì một, và người dùng đang lưu năm nghìn dòng.

Spring Data JPA làm biến mất phần lớn mã lặp nhưng không làm biến mất SQL. Bài này mở nó ra: bạn sẽ chạy một repository trên MySQL thật, ghi lại từng câu SQL mà mỗi phương thức gửi, và học cách đọc chúng để biết save làm gì, đọc tốn bao nhiêu, và các thao tác hàng loạt có thật sự là hàng loạt không.

Sau bài này, bạn có thể

  • Giải thích repository là gì (một interface cộng một proxy) và nó dựa trên Hibernate bạn đã học.
  • Dự đoán SQL của save với thực thể mới, thực thể tách rời và thực thể đã được quản lý.
  • So chi phí của findById, existsById, count và getReferenceById.
  • Viết truy vấn dẫn xuất đơn giản và biết nó sinh where thế nào.
  • Phân biệt deleteById với deleteAllInBatch, và hiểu giới hạn của saveAll.
  • Bắt lỗi trùng khóa bằng đúng loại ngoại lệ của Spring.

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, Spring Boot 4.1.1, MySQL 8.4.9). Bản sao lưu cả dự án nằm ở kho mã của khóa học, thư mục 09-spring-data-jpa/01-jparepository, 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ầnPhiên bản đã kiểm chứngGhi chú
JDK17 trở lênjava -version
Maven3.9 trở lênmvn -v
Spring Boot4.1.1khai báo ở parent trong pom.xml; quản lý Hibernate và Spring Data
MySQL8.4 (image mysql:8.4)cổng 3307 của máy bạn
Dockerbất kỳ bản gần đâyMySQL cho demo và Testcontainers cho test

Cấu trúc

text
01-jparepository/
  docker-compose.yml           MySQL 8.4, database shop, user app (cổng 3307)
  pom.xml                       parent Boot 4.1.1, starter data-jpa, mysql-connector-j, test, testcontainers
  src/main/java/dev/canhdominich/boot/jpa/
    JpaApplication.java         @SpringBootApplication
    Level.java                  enum BASIC, ADVANCED
    Student.java                entity: id tự tăng, tên, email duy nhất, level
    StudentRepository.java      interface JpaRepository có hai truy vấn dẫn xuất
    SqlRecorder.java            StatementInspector ghi lại mọi câu SQL
    RepositoryDemo.java         chạy các thí nghiệm và in SQL

Khai báo thư viện: pom.xml

pom.xml

Dự án Maven dùng spring-boot-starter-parent 4.1.1 với spring-boot-starter-data-jpa (Spring Data JPA, Hibernate, HikariCP), mysql-connector-j (driver, phạm vi runtime) và spring-boot-starter-test; testcontainers-mysql chạy MySQL thật trong bộ test. Plugin exec-maven-plugin chạy RepositoryDemo bằng mvn -q compile exec:exec.

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>

    <parent>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-parent</artifactId>
        <version>4.1.1</version>
        <relativePath/>
    </parent>

    <groupId>dev.canhdominich.javabackend</groupId>
    <artifactId>jpa-repository</artifactId>
    <version>1.0.0</version>
    <name>Spring Data JPA - JpaRepository</name>

    <properties>
        <java.version>17</java.version>
        <main.class>dev.canhdominich.boot.jpa.RepositoryDemo</main.class>
    </properties>

    <dependencies>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-data-jpa</artifactId>
        </dependency>
        <dependency>
            <groupId>com.mysql</groupId>
            <artifactId>mysql-connector-j</artifactId>
            <scope>runtime</scope>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-test</artifactId>
            <scope>test</scope>
        </dependency>
        <dependency>
            <groupId>org.testcontainers</groupId>
            <artifactId>testcontainers-mysql</artifactId>
            <scope>test</scope>
        </dependency>
    </dependencies>

    <build>
        <plugins>
            <plugin>
                <groupId>org.springframework.boot</groupId>
                <artifactId>spring-boot-maven-plugin</artifactId>
            </plugin>
            <plugin>
                <groupId>org.codehaus.mojo</groupId>
                <artifactId>exec-maven-plugin</artifactId>
                <configuration>
                    <executable>java</executable>
                    <arguments>
                        <argument>-classpath</argument>
                        <classpath/>
                        <argument>${main.class}</argument>
                    </arguments>
                </configuration>
            </plugin>
        </plugins>
    </build>

</project>

Cấu hình Docker Compose

docker-compose.yml

Khởi động MySQL 8.4 bằng một lệnh: tạo database shop, tài khoản app với mật khẩu app_pw, và mở cổng 3307 của máy bạn (thay đổi được bằng biến MYSQL_PORT). Phần healthcheck cho docker compose ps biết khi nào MySQL đã sẵn sàng.

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

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

Một repository chỉ là một interface

Thực thể của bài là Student: một id tự tăng, tên, email duy nhất và một cấp độ. Các annotation (@Entity, @Id, @GeneratedValue, @Column(unique = true), @Enumerated) là những thứ bạn đã học ở chương Hibernate:

src/main/java/dev/canhdominich/boot/jpa/Student.java

java
package dev.canhdominich.boot.jpa;

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;

@Entity
public class Student {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    private String name;

    @Column(unique = true)
    private String email;

    @Enumerated(EnumType.STRING)
    private Level level;

    protected Student() {
    }

    public Student(String name, String email, Level level) {
        this.name = name;
        this.email = email;
        this.level = level;
    }

    public Long getId() {
        return id;
    }

    public String getName() {
        return name;
    }

    public void setName(String name) {
        this.name = name;
    }

    public String getEmail() {
        return email;
    }

    public Level getLevel() {
        return level;
    }
}

Level là một enum lưu dạng chuỗi (STRING) như bài ánh xạ đã khuyên:

src/main/java/dev/canhdominich/boot/jpa/Level.java

java
package dev.canhdominich.boot.jpa;

public enum Level {
    BASIC,
    ADVANCED
}

Phần mới nằm ở StudentRepository. Đó là một interface không có thân phương thức nào, kế thừa JpaRepository<Student, Long> (kiểu thực thể và kiểu khóa chính). Hai phương thức khai báo thêm không có cài đặt: Spring Data đọc tên của chúng (findByEmail, findByLevel) và sinh truy vấn tương ứng:

src/main/java/dev/canhdominich/boot/jpa/StudentRepository.java

java
package dev.canhdominich.boot.jpa;

import java.util.List;
import java.util.Optional;
import org.springframework.data.jpa.repository.JpaRepository;

public interface StudentRepository extends JpaRepository<Student, Long> {

    Optional<Student> findByEmail(String email);

    List<Student> findByLevel(Level level);
}

JpaApplication là lớp ứng dụng như các bài Boot trước. Nhờ starter spring-boot-starter-data-jpa, Boot tự cấu hình Hibernate, DataSource (HikariCP) và quét các interface repository:

src/main/java/dev/canhdominich/boot/jpa/JpaApplication.java

java
package dev.canhdominich.boot.jpa;

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;

@SpringBootApplication
public class JpaApplication {

    public static void main(String[] args) {
        SpringApplication.run(JpaApplication.class, args);
    }
}

Chương trình đo cần ghi lại SQL. SqlRecorder là một StatementInspector của Hibernate, giống các bài trước, lưu mọi câu SQL trước khi gửi xuống MySQL:

src/main/java/dev/canhdominich/boot/jpa/SqlRecorder.java

java
package dev.canhdominich.boot.jpa;

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);
        }
    }
}

RepositoryDemo khởi động ứng dụng (không có web), nối vào MySQL ở cổng 3307 (hoặc theo biến môi trường JDBC_URL, DB_USER, DB_PASSWORD), để Hibernate tạo bảng student (ddl-auto=create-drop) và chạy lần lượt các thao tác, in số câu SQL và nội dung từng câu:

src/main/java/dev/canhdominich/boot/jpa/RepositoryDemo.java

java
package dev.canhdominich.boot.jpa;

import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import org.springframework.aop.framework.AopProxyUtils;
import org.springframework.boot.SpringApplication;
import org.springframework.context.ConfigurableApplicationContext;
import org.springframework.transaction.support.TransactionTemplate;

public class RepositoryDemo {

    public static void main(String[] args) {
        run(env("JDBC_URL", "jdbc:mysql://localhost:3307/shop"), env("DB_USER", "app"), env("DB_PASSWORD", "app_pw")).forEach(System.out::println);
    }

    public static List<String> run(String url, String user, String password) {
        Map<String, Object> properties = new HashMap<>();
        properties.put("spring.datasource.url", url);
        properties.put("spring.datasource.username", user);
        properties.put("spring.datasource.password", password);
        properties.put("spring.jpa.hibernate.ddl-auto", "create-drop");
        properties.put("spring.jpa.properties.hibernate.session_factory.statement_inspector", SqlRecorder.class.getName());
        properties.put("spring.main.web-application-type", "none");
        properties.put("spring.main.banner-mode", "off");
        properties.put("logging.level.root", "OFF");
        SpringApplication application = new SpringApplication(JpaApplication.class);
        application.setDefaultProperties(properties);
        List<String> lines = new ArrayList<>();
        try (ConfigurableApplicationContext context = application.run()) {
            StudentRepository repository = context.getBean(StudentRepository.class);
            TransactionTemplate transaction = context.getBean(TransactionTemplate.class);
            lines.add("repository object: " + repository.getClass().getSimpleName().replaceAll("[0-9]+$", "") + " (proxy), target " + AopProxyUtils.ultimateTargetClass(repository).getSimpleName());
            lines.add("-- save");
            SqlRecorder.clear();
            Student an = repository.save(new Student("An", "an@example.com", Level.BASIC));
            report(lines, "save(new Student) gives id " + (an.getId() != null));
            Student found = repository.findById(an.getId()).orElseThrow();
            found.setName("An Nguyen");
            SqlRecorder.clear();
            repository.save(found);
            report(lines, "save(detached student with an id)");
            SqlRecorder.clear();
            transaction.executeWithoutResult(status -> repository.findById(an.getId()).orElseThrow().setName("An N."));
            report(lines, "change inside one transaction, no save call");
            lines.add("-- reading");
            SqlRecorder.clear();
            repository.findById(an.getId());
            report(lines, "findById");
            SqlRecorder.clear();
            repository.existsById(an.getId());
            report(lines, "existsById");
            SqlRecorder.clear();
            repository.count();
            report(lines, "count");
            SqlRecorder.clear();
            transaction.executeWithoutResult(status -> repository.getReferenceById(an.getId()));
            report(lines, "getReferenceById, id only used");
            SqlRecorder.clear();
            transaction.executeWithoutResult(status -> repository.getReferenceById(an.getId()).getName());
            report(lines, "getReferenceById, then getName");
            lines.add("-- derived queries");
            SqlRecorder.clear();
            repository.findByEmail("an@example.com");
            report(lines, "findByEmail");
            SqlRecorder.clear();
            repository.findByLevel(Level.BASIC);
            report(lines, "findByLevel");
            lines.add("-- saving and deleting several");
            SqlRecorder.clear();
            repository.saveAll(List.of(new Student("B1", "b1@example.com", Level.BASIC), new Student("B2", "b2@example.com", Level.BASIC), new Student("B3", "b3@example.com", Level.ADVANCED)));
            report(lines, "saveAll of 3");
            SqlRecorder.clear();
            repository.deleteById(an.getId());
            report(lines, "deleteById");
            SqlRecorder.clear();
            repository.deleteAllInBatch();
            report(lines, "deleteAllInBatch");
            lines.add("-- a duplicate email");
            repository.save(new Student("C", "c@example.com", Level.BASIC));
            try {
                repository.save(new Student("C again", "c@example.com", Level.BASIC));
            } catch (RuntimeException e) {
                List<String> chain = new ArrayList<>();
                for (Throwable t = e; t != null; t = t.getCause()) {
                    chain.add(t.getClass().getSimpleName());
                }
                lines.add("exception chain: " + chain);
            }
        }
        return lines;
    }

    private static void report(List<String> lines, String label) {
        List<String> statements = SqlRecorder.statements();
        lines.add(label + " -> " + statements.size() + " statements");
        statements.forEach(sql -> lines.add("    " + sql));
        SqlRecorder.clear();
    }

    private static String env(String name, String fallback) {
        String value = System.getenv(name);
        return value == null || value.isBlank() ? fallback : value;
    }
}

Chạy MySQL (docker compose up -d, xem mục Chuẩn bị) rồi mvn -q compile exec:exec. Kết quả (đã bỏ log ở stderr):

text
repository object: $Proxy (proxy), target SimpleJpaRepository
-- save
save(new Student) gives id true -> 1 statements
    insert into student (email,level,name) values (?,?,?)
save(detached student with an id) -> 2 statements
    select s1_0.id,s1_0.email,s1_0.level,s1_0.name from student s1_0 where s1_0.id=?
    update student set email=?,level=?,name=? where id=?
change inside one transaction, no save call -> 2 statements
    select s1_0.id,s1_0.email,s1_0.level,s1_0.name from student s1_0 where s1_0.id=?
    update student set email=?,level=?,name=? where id=?
-- reading
findById -> 1 statements
    select s1_0.id,s1_0.email,s1_0.level,s1_0.name from student s1_0 where s1_0.id=?
existsById -> 1 statements
    select count(*) from student s1_0 where s1_0.id=?
count -> 1 statements
    select count(*) from student s1_0
getReferenceById, id only used -> 0 statements
getReferenceById, then getName -> 1 statements
    select s1_0.id,s1_0.email,s1_0.level,s1_0.name from student s1_0 where s1_0.id=?
-- derived queries
findByEmail -> 1 statements
    select s1_0.id,s1_0.email,s1_0.level,s1_0.name from student s1_0 where s1_0.email=?
findByLevel -> 1 statements
    select s1_0.id,s1_0.email,s1_0.level,s1_0.name from student s1_0 where s1_0.level=?
-- saving and deleting several
saveAll of 3 -> 3 statements
    insert into student (email,level,name) values (?,?,?)
    insert into student (email,level,name) values (?,?,?)
    insert into student (email,level,name) values (?,?,?)
deleteById -> 2 statements
    select s1_0.id,s1_0.email,s1_0.level,s1_0.name from student s1_0 where s1_0.id=?
    delete from student where id=?
deleteAllInBatch -> 1 statements
    delete s1_0 from student s1_0
-- a duplicate email
exception chain: [DataIntegrityViolationException, ConstraintViolationException, SQLIntegrityConstraintViolationException]

Dòng đầu trả lời câu hỏi "interface này chạy bằng gì?": đối tượng StudentRepository mà Spring tiêm cho bạn là một proxy (tên lớp bắt đầu bằng $Proxy), và đối tượng thật ở sau nó là SimpleJpaRepository, lớp có sẵn của Spring Data cài đặt các phương thức của JpaRepository bằng EntityManager (chính là Hibernate). Không có mã nào bạn viết ở đó.

Cách đọc hình: StudentRepository chỉ là interface. Khi khởi động, Spring tạo một proxy cho nó, proxy chuyển lời gọi cho SimpleJpaRepository, nó dùng EntityManager của Hibernate để gửi SQL tới MySQL.

save: ba con đường tới cơ sở dữ liệu

Ba thí nghiệm đầu cho thấy cùng ý "lưu một thay đổi" nhưng ba kết quả SQL khác nhau:

  • Thực thể mới: 1 câu. save(new Student(...)) chỉ gửi insert. Spring Data xem đối tượng có id null là mới và gọi persist; với khóa tự tăng, Hibernate chèn ngay để biết id.
  • Thực thể tách rời có id: 2 câu. Đối tượng đọc ở một giao dịch trước, sửa tên rồi save lại: kết quả là một select ... where id=? rồi update. Vì có id nên đây không phải đối tượng mới, save gọi merge; để merge, Hibernate phải đọc dòng hiện có và so sánh, rồi mới ghi. Câu update liệt kê mọi cột (như bài Hibernate đã thấy).
  • Thay đổi trong một giao dịch, không gọi save: 2 câu, cũng có update. Đoạn mã đọc sinh viên bằng findById, đổi tên, và thoát khỏi giao dịch mà không gọi save vẫn cho ra update. Đó là dirty checking mà bạn đã học: thực thể đang được quản lý trong ngữ cảnh lưu trữ, nên Hibernate phát hiện thay đổi lúc commit. Hai câu ở đây là select của findById cộng update, không có select thừa của merge.

Nói cách khác, save trên thực thể có id không "thêm" gì ngoài một select: nếu đối tượng đã đang được quản lý trong giao dịch, save là thừa.

Đang tải Animation…

Cách đọc hình: hàng trên: ba con đường và số câu SQL đo được (1, 2, 2). Hàng dưới giải thích: merge cần select trước, dirty checking không cần save, và deleteAllInBatch là một câu cho cả bảng.

Cái bẫy ở đầu bài nằm đây: gọi save trên đối tượng đã có id ngoài giao dịch gửi select rồi update cho mỗi dòng. Với năm nghìn dòng đó là một vạn câu. Cách tránh là đọc và sửa các thực thể trong một giao dịch (bài giao dịch ở cuối chương nói kỹ), hoặc dùng truy vấn cập nhật hàng loạt (bài sau).

Đọc dữ liệu: chọn phương thức theo câu hỏi

  • findById: select mọi cột, where id=?, 1 câu.
  • existsById: chỉ select count(*) ... where id=?: không đọc cột nào, rẻ hơn nếu bạn chỉ hỏi "có không".
  • count: select count(*) from student.
  • getReferenceById: 0 câu nếu bạn chỉ dùng id của nó (nó trả một đối tượng đại diện, không đọc dòng), và chỉ gửi 1 select khi bạn gọi một getter như getName(). Có ích khi bạn chỉ cần một tham chiếu để gắn vào quan hệ mà không muốn đọc dòng thật; bài Hibernate đã nói về proxy kiểu này, và đối tượng đó chỉ dùng được khi giao dịch hoặc Session còn mở.
  • findByEmail và findByLevel: Spring Data đọc tên phương thức và sinh where s1_0.email=? và where s1_0.level=?. Không có chuỗi truy vấn nào do bạn viết; nếu bạn gõ sai tên thuộc tính, lỗi xuất hiện ngay lúc khởi động (bài sau đo).
Hình: câu SQL mà mỗi phương thức đọc gửi tới MySQL

Cách đọc hình: hàng trên: ba phương thức đọc với SQL khác nhau. Hàng dưới: getReferenceById không gửi câu nào cho tới khi bạn đọc một thuộc tính, và các truy vấn dẫn xuất sinh where theo tên.

Thao tác hàng loạt không phải lúc nào cũng hàng loạt

  • saveAll của 3 thực thể: 3 câu insert. Không phải một câu. Với khóa tự tăng (IDENTITY), Hibernate phải chèn từng dòng để lấy id; đây là giới hạn đã đo ở bài hiệu năng Hibernate, và saveAll không vượt qua được nó.
  • deleteById: 2 câu. select đọc thực thể (để Hibernate có đối tượng để xóa, và để áp dụng cascade nếu có) rồi delete ... where id=?.
  • deleteAllInBatch: 1 câu. delete s1_0 from student s1_0: một lệnh xóa cả bảng, không đọc từng dòng. Đổi lại nó bỏ qua ngữ cảnh lưu trữ và cascade, nên chỉ dùng khi đó đúng là điều bạn muốn.

Cách đọc hình: hàng trên: saveAll gửi từng insert vì khóa tự tăng. Hàng dưới: deleteById đọc rồi xóa, còn deleteAllInBatch là một lệnh duy nhất.

Lỗi trùng khóa: bắt đúng ngoại lệ

Lần save cuối gửi một email đã tồn tại và vi phạm ràng buộc duy nhất. Chuỗi ngoại lệ in ra, từ ngoài vào trong: DataIntegrityViolationException (của Spring), ConstraintViolationException (của Hibernate), SQLIntegrityConstraintViolationException (của JDBC). Spring bọc mọi lỗi truy cập dữ liệu vào một họ ngoại lệ riêng (ở đây DataIntegrityViolationException), nên mã của bạn bắt loại của Spring, không phụ thuộc vào Hibernate hay JDBC bên dưới. Chuỗi nguyên nhân vẫn đủ để bạn lần ngược tới lỗi gốc của MySQL khi cần ghi log.

Cách đọc hình: MySQL từ chối lệnh insert thứ hai; lỗi đi ra qua JDBC, Hibernate rồi được Spring bọc lại. Mã của bạn bắt DataIntegrityViolationException.

Nhu cầuPhương thứcSQL đo được
Lưu thực thể mớisave1 insert
Cập nhật thực thể tách rờisaveselect rồi update
Cập nhật trong giao dịchsửa thuộc tính, không cần saveupdate lúc commit
Kiểm tra tồn tạiexistsByIdselect count(*) theo id
Chỉ cần tham chiếugetReferenceById0 câu cho tới khi đọc thuộc tính
Xóa tất cả, bỏ qua cascadedeleteAllInBatch1 delete

Quyết định thiết kế và đánh đổi

Quyết địnhLựa chọnĐánh đổi
Truy cập dữ liệuRepository interfaceÍt mã, nhưng SQL ẩn: phải đo
Cập nhậtTrong giao dịch, bằng cách sửa thuộc tínhCần đúng ranh giới giao dịch
Khóa chínhIDENTITYsaveAll không gom lô được
Truy vấn đơn giảnTruy vấn dẫn xuất theo tênTên dài dần khi điều kiện nhiều (bài sau dùng @Query)
Lỗi truy cập dữ liệuBắt ngoại lệ của SpringChuỗi nguyên nhân dài khi ghi log
Tạo bảngddl-auto=create-drop chỉ trong thí nghiệmThật sự dùng công cụ migration (chương sau)

Playbook: gặp sự cố Spring Data JPA thì làm gì

Triệu chứngKiểm tra đầu tiênNguyên nhân hay gặpCách xử lý
Lưu nhiều dòng chậmĐếm câu SQLsave ngoài giao dịch, mỗi lần select rồi update; hoặc IDENTITYGom vào một giao dịch; đổi cách sinh khóa nếu cần gom lô
Có select thừa trước updateĐối tượng tách rời được save lạimergeSửa trong giao dịch, không gọi save
Chỉ cần biết có tồn tạiĐang dùng findByIdĐọc cả dòngexistsById
DataIntegrityViolationExceptionRàng buộc nàoTrùng khóa duy nhất, thiếu khóa ngoạiKiểm tra trước, hoặc bắt và trả lỗi 409
Repository không được tìm thấyPackage của interfaceNgoài package của @SpringBootApplicationChuyển package hoặc @EnableJpaRepositories
Bảng không được tạospring.jpa.hibernate.ddl-autoMặc định với MySQL không tạo bảngĐặt create-drop trong thí nghiệm, hoặc dùng migration

Lỗi thường gặp khi chạy lab

Triệu chứngNguyên nhânCách xử lý
Bind for 0.0.0.0:3307 failed: port is already allocatedCổng 3307 đã bị chiếmdocker compose down ở lab trước, hoặc MYSQL_PORT=3308 docker compose up -d rồi export JDBC_URL=jdbc:mysql://localhost:3308/shop
Communications link failureMySQL chưa sẵn sàngChờ docker compose ps báo healthy

Bài thực hành

Làm trong dự án của bài (thư mục 09-spring-data-jpa/01-jparepository). Sau mỗi bước, so kết quả với tiêu chí nghiệm thu.

  1. Chạy thí nghiệm. docker compose up -d rồi mvn -q compile exec:exec. Tiêu chí: các số câu SQL khớp bài.
  2. Thêm truy vấn dẫn xuất. Thêm List<Student> findByLevelAndNameContaining(Level level, String part) vào StudentRepository và gọi nó trong RepositoryDemo (sau khi lưu vài sinh viên). Tiêu chí: SQL có where s1_0.level=? and s1_0.name like ? escape '\' (điều kiện Containing thành like).
  3. Bỏ lệnh save thừa. Đã có bước đọc và sửa tên trong một giao dịch; viết thêm một lần đọc và sửa ở ngoài giao dịch rồi gọi save. Tiêu chí: tổng số câu là 3 (select của findById, rồi select và update của merge), so với 2 câu khi làm trong giao dịch; giải thích câu thừa đến từ đâu.
  4. Đo getReferenceById. Gọi getReferenceById với id không tồn tại rồi getName() trong một giao dịch. Tiêu chí: EntityNotFoundException: No row with the given identifier exists for entity [... Student with id '99999'] lúc getName(), không phải lúc gọi getReferenceById.
  5. Gây lỗi trùng. Chạy lại phần "duplicate email" nhưng bắt DataIntegrityViolationException và in getMostSpecificCause().getMessage(). Tiêu chí: thông báo có dạng Duplicate entry 'd@example.com' for key 'student.UK...' (tên chỉ mục do Hibernate sinh).
  6. Chạy bộ test (tùy chọn). Bộ test nằm trong bản sao lưu ở kho mã của khóa học; sau khi clone, mvn test cho Tests run: 6, Failures: 0, Errors: 0 (cần Docker). Dọn dẹp bằng docker compose down -v.

Hiểu lầm và giới hạn

  • "Spring Data JPA thay thế Hibernate." Nó nằm trên Hibernate; mọi hành vi đã học (dirty checking, merge, LAZY) vẫn đúng.
  • "save luôn là một câu." Chỉ khi thực thể mới; thực thể có id thêm một select.
  • "saveAll là lưu hàng loạt." Với khóa tự tăng vẫn từng câu một.
  • "Phải gọi save để cập nhật." Trong một giao dịch, sửa thuộc tính là đủ.
  • Số đo thuộc về Spring Boot 4.1.1, Hibernate do Boot quản lý và MySQL 8.4. Bài chưa nói về @Query, phân trang, quan hệ giữa thực thể, giao dịch và N+1 (các bài sau), cũng như Specification và Auditing.

Tự kiểm tra

  1. StudentRepository không có cài đặt nào: ai thực thi các phương thức của nó?
  2. save trên thực thể có id và ngoài giao dịch gửi những câu SQL nào?
  3. Vì sao thay đổi trong một giao dịch không cần save?
  4. Khác nhau giữa findById, existsById và getReferenceById về SQL?
  5. deleteAllInBatch khác deleteById lặp lại ở điểm nào?

Gợi ý đáp án. (1) Một proxy chuyển lời gọi cho SimpleJpaRepository, dùng EntityManager. (2) select rồi update (merge). (3) Thực thể đang được quản lý; dirty checking tự phát hiện và update lúc commit. (4) findById đọc mọi cột; existsById chỉ đếm; getReferenceById không gửi câu nào cho tới khi đọc thuộc tính. (5) Một lệnh delete cho cả bảng so với một select và một delete mỗi dòng; nó bỏ qua ngữ cảnh lưu trữ và cascade.

Điều cần nhớ

  • Repository là interface cộng proxy SimpleJpaRepository trên Hibernate; SQL vẫn ở đó, hãy đo nó.
  • Thực thể mới: persist (1 câu). Thực thể có id: merge (thêm select). Trong giao dịch: không cần save.
  • Chọn existsById, getReferenceById hay findById theo câu hỏi bạn đặt ra.
  • IDENTITY làm saveAll thành nhiều insert; deleteAllInBatch là một lệnh nhưng bỏ qua cascade.
  • Bắt DataIntegrityViolationException thay vì ngoại lệ của JDBC hay Hibernate.

Đọc thêm

Bài tiếp theo đi vào truy vấn: nhiều cách viết truy vấn (tên, @Query, phân trang, sắp xếp, chiếu) và chi phí của từng cách.