Backend

99 Ngày Spring — Ngày 29: Auditing với Spring Data JPA

SSite Admin
25 tháng 08, 2026 9 phút đọc 0 lượt xem
99 Ngày Spring — Ngày 29: Auditing với Spring Data JPA

Ngày 28 khép lại bằng lời hứa: để Spring tự ghi ai-làm-gì-lúc-nào. Bài toán quen mặt trong mọi hệ thống: bảng nào rồi cũng cần created_at/updated_at để truy vết, mà điền tay thì người nhớ người quên. Spring Data JPA Auditing giải quyết trọn gói bằng bốn annotation @CreatedDate, @LastModifiedDate, @CreatedBy, @LastModifiedBy cùng một interface AuditorAware. Hôm nay ta dựng BaseEntity dùng chung, dạy Spring biết "ai đang thao tác", soi những chỗ auditing không chạy, và viết test tất định cho nó — mảnh ghép cuối trước bài checkpoint CRUD ngày mai.

Sketchnote Ngày 29: Auditing — BaseEntity với @CreatedDate/@LastModifiedDate, AuditorAware cho @CreatedBy, bẫy bulk update không chạy listener và test với DateTimeProvider

BaseEntity — bốn cột hộ chiếu tự điền

// Bốn cột "hộ chiếu" mà bảng nào rồi cũng cần:
// created_at | updated_at | created_by | updated_by
// Điền tay thì thiếu sót là chắc chắn — Spring Data điền HỘ, tự động và đồng nhất.

// Bước 1 — bật auditing (một lần cho cả ứng dụng)
@Configuration
@EnableJpaAuditing
public class AuditConfig { }

// Bước 2 — lớp nền dùng chung cho mọi entity
@MappedSuperclass                                  // KHÔNG phải bảng — chỉ góp cột cho con
@EntityListeners(AuditingEntityListener.class)     // listener điền giá trị lúc persist/update
public abstract class BaseEntity {

    @CreatedDate
    @Column(nullable = false, updatable = false)   // updatable=false: khóa cứng lúc UPDATE
    private Instant createdAt;

    @LastModifiedDate
    @Column(nullable = false)
    private Instant updatedAt;

    // getter... (không setter — hai cột này KHÔNG ai được điền tay)
}

// Bước 3 — mọi entity chỉ việc kế thừa
@Entity
public class CongViec extends BaseEntity {
    @Id @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
    private String tieuDe;
    // ... createdAt/updatedAt tự có, tự điền
}

repo.save(new CongViec("Viết bài Ngày 29"));
// INSERT ... created_at = 2026-08-25T11:10:00Z, updated_at = 2026-08-25T11:10:00Z
// Dùng Instant (UTC — Ngày 26) thay vì LocalDateTime: server đổi timezone không lệch giờ
  • Ba mảnh ghép: @EnableJpaAuditing bật hạ tầng, AuditingEntityListener nghe sự kiện persist/update của JPA để điền giá trị, và @MappedSuperclass cho phép mọi entity kế thừa cột mà không sinh bảng riêng — mỗi entity con chỉ còn extends BaseEntity là xong.

  • Chọn Instant (Ngày 26) cho cột thời gian: mốc UTC tuyệt đối, server đổi múi giờ hay đội deploy sang region khác cũng không lệch; LocalDateTime thiếu offset nên 09:00 của nó là 09:00 ở đâu không ai biết.

  • updatable = false trên createdAt là chốt chặn tầng mapping: dù code ở đâu lỡ gán lại, Hibernate loại cột này khỏi câu UPDATE — thời điểm sinh của bản ghi là bất biến (đúng tinh thần bài Java hôm nay), và không có setter để không ai điền tay.

AuditorAware — dạy Spring biết "ai"

// "Khi nào" đã xong — giờ đến "AI": @CreatedBy / @LastModifiedBy
@MappedSuperclass
@EntityListeners(AuditingEntityListener.class)
public abstract class BaseEntity {
    @CreatedDate  @Column(nullable = false, updatable = false)
    private Instant createdAt;

    @LastModifiedDate @Column(nullable = false)
    private Instant updatedAt;

    @CreatedBy    @Column(updatable = false)
    private String createdBy;                      // kiểu tùy ta chọn: String, Long userId...

    @LastModifiedBy
    private String updatedBy;
}

// Spring KHÔNG tự biết "ai" — ta dạy nó qua AuditorAware<T>:
@Component
public class NguoiDungHienTai implements AuditorAware<String> {
    @Override
    public Optional<String> getCurrentAuditor() {
        // Tạm thời lấy từ header/context của ta (giai đoạn Security - Ngày 41+ -
        // sẽ thay bằng SecurityContextHolder, CHỈ sửa duy nhất chỗ này):
        return Optional.ofNullable(RequestContext.tenDangNhap())
                       .or(() -> Optional.of("system"));    // batch job, scheduler...
    }
}
// Optional.empty() → cột by để null — hợp lệ cho tiến trình không có người dùng

// Trỏ @EnableJpaAuditing vào bean (chỉ cần khi có NHIỀU AuditorAware):
@EnableJpaAuditing(auditorAwareRef = "nguoiDungHienTai")
  • @CreatedBy/@LastModifiedBy điền danh tính — nhưng Spring không thể tự biết ai đang gọi: AuditorAware<T> là hợp đồng một method getCurrentAuditor() để ta chỉ cho nó; kiểu T tự chọn — String username hay Long userId đều được, miễn khớp kiểu field.

  • Chưa học Security (Giai đoạn 5 — Ngày 41+) thì auditor lấy từ context tự quản; điểm đẹp của thiết kế này là một điểm thay: khi có Spring Security, chỉ sửa duy nhất bean AuditorAware sang đọc SecurityContextHolder — không entity nào phải đổi.

  • Trả Optional.empty() là lựa chọn hợp lệ cho tiến trình không có người dùng (scheduler, migration): cột by để null thay vì bịa tên; còn muốn phân biệt rõ thì quy ước "system" — quan trọng là nhất quán toàn hệ thống.

Những chỗ auditing không chạy — và vì sao

// Auditing chạy trên LIFECYCLE EVENT của JPA — hiểu điều đó là hiểu mọi giới hạn

// 1. Bulk update/delete KHÔNG đi qua entity → listener KHÔNG chạy
@Modifying
@Query("update CongViec c set c.trangThai = :st where c.hanChot < :truoc")
int dongHangLoat(TrangThai st, Instant truoc);
// → updated_at/updated_by GIỮ NGUYÊN — tự set trong JPQL nếu cần:
//   set c.trangThai = :st, c.updatedAt = CURRENT_TIMESTAMP
// JdbcTemplate / native query: cũng vô hình với auditing như vậy.

// 2. save() một entity "tự lắp" id — merge coi là UPDATE hay INSERT?
CongViec cv = new CongViec();
cv.setId(42L);                    // id có sẵn → Spring Data gọi merge (Ngày 25)
repo.save(cv);                    // merge SELECT trước: có row → là update
// → @CreatedDate không điền lại (đúng!), nhưng nếu row CHƯA có mà id tự đặt
//   → persist coi là entity mới, mọi cột audit điền từ đầu. Hiểu luồng để khỏi ngạc nhiên.

// 3. updatable = false là chốt chặn thứ hai
cv.setCreatedAt(Instant.EPOCH);   // ai đó "nghịch" setter (nếu lỡ có)
repo.save(cv);
// Hibernate BỎ QUA cột created_at trong câu UPDATE → giá trị gốc an toàn

// 4. Chỉ đổi field audit thì KHÔNG có dirty → không có UPDATE nào cả
//    (auditing "ăn theo" update thật — nó không tự tạo update)

// 5. Truy vết SQL thật — bạn của mọi bài JPA (Ngày 27):
//   spring.jpa.properties.hibernate.format_sql=true
//   logging.level.org.hibernate.SQL=DEBUG
  • Chìa khóa nằm ở cơ chế: auditing bám vào lifecycle event của entity — nên mọi con đường né entity đều né luôn auditing: @Modifying @Query (Ngày 24), native query, JdbcTemplate. Bulk update mà cần dấu vết thì tự set updatedAt ngay trong JPQL — hoặc chấp nhận và ghi chú rõ.

  • Hiểu save() = persist-hoặc-merge (Ngày 25) giải thích hành vi cột audit: entity mới → điền đủ; entity có id và row tồn tại → merge là update, @CreatedDate giữ nguyên. Lệch khỏi hiểu này thường do tự đặt id — luồng đi khác là giá trị khác.

  • Auditing ăn theo update thật: không có field nào dirty thì không có câu UPDATE nào để đính updatedAt vào. Nghi ngờ gì cứ bật log SQL (Ngày 27) — nhìn câu lệnh thật trả lời nhanh hơn mọi suy đoán.

Test auditing cho tất định

// Test auditing với @DataJpaTest — hai điều phải nhớ
@DataJpaTest
@Import(AuditConfig.class)          // ①  slice test KHÔNG quét @Configuration của bạn
class AuditingTest {                //     → thiếu dòng này là createdAt null, đỏ cả buổi

    @Autowired CongViecRepository repo;
    @Autowired TestEntityManager em;

    @Test
    void createdAt_duoc_dien_khi_persist() {
        CongViec cv = repo.save(new CongViec("test"));
        assertThat(cv.getCreatedAt()).isNotNull();
        assertThat(cv.getUpdatedAt()).isEqualTo(cv.getCreatedAt());
    }

    @Test
    void updatedAt_doi_khi_update_con_createdAt_thi_khong() {
        CongViec cv = repo.saveAndFlush(new CongViec("test"));
        Instant lucTao = cv.getCreatedAt();

        cv.setTieuDe("sửa tiêu đề");
        repo.saveAndFlush(cv);       // flush để UPDATE chạy thật (Ngày 25)

        assertThat(cv.getCreatedAt()).isEqualTo(lucTao);          // khóa cứng
        assertThat(cv.getUpdatedAt()).isAfterOrEqualTo(lucTao);   // đã tiến lên
    }
}

// ② Thời gian tất định — kim đồng hồ giả cho auditing:
@TestConfiguration
class FixedClockConfig {
    static final Instant BAY_GIO = Instant.parse("2026-08-25T11:10:00Z");

    @Bean
    DateTimeProvider dateTimeProvider() {          // auditing hỏi giờ qua bean này
        return () -> Optional.of(BAY_GIO);
    }
}
// @EnableJpaAuditing(dateTimeProviderRef = "dateTimeProvider")
// → createdAt LUÔN bằng BAY_GIO trong test: assert chính xác, không isNotNull() chung chung
  • @DataJpaTestslice test — chỉ dựng tầng JPA, không quét @Configuration của ứng dụng: quên @Import(AuditConfig.class)createdAt null và mất cả buổi debug — lỗi gặp nhiều nhất khi test auditing.

  • Cặp test tối thiểu nói đủ hợp đồng: persist điền createdAt = updatedAt; update đẩy updatedAt tiến lên còn createdAt đứng yên — nhớ saveAndFlush để UPDATE chạy thật thay vì đợi commit (Ngày 25).

  • DateTimeProviderkim đồng hồ giả: auditing hỏi giờ qua bean này nên test có thể đóng băng thời gian và assert chính xác từng giá trị thay vì isNotNull chung chung — cùng triết lý xử lý thời gian tất định sẽ gặp lại ở bài testing sau.

Khi cần cả lịch sử: nhìn sang Envers

  • Auditing hôm nay trả lời "lần cuối ai sửa, lúc nào" — nó ghi đè: không biết trước đó giá trị là gì. Khi nghiệp vụ đòi cả dòng thời gian (giá đơn hàng đã đổi thế nào, ai duyệt từng bước), đó là bài toán audit log đầy đủ.

  • Hibernate Envers (@Audited) chụp snapshot mỗi lần thay đổi vào bảng _AUD kèm số revision — truy ngược được trạng thái tại bất kỳ thời điểm nào; đổi lại mỗi update tốn thêm insert và schema phình gấp đôi.

  • Quy tắc chọn thực dụng: mọi bảng nên có 4 cột auditing (rẻ, đủ cho vận hành); riêng bảng nhạy cảm (tiền, phê duyệt, phân quyền) mới thêm Envers hoặc bảng lịch sử tự thiết kế — đừng audit-đầy-đủ cả schema chỉ vì "tiện thể".

Bài tập nhỏ

  • Dựng BaseEntity đủ 4 cột + AuditConfig, cho CongViec kế thừa; save rồi soi SQL log xem created_at nằm trong câu INSERT thật.

  • Viết AuditorAware đọc header X-User (qua RequestContextHolder); gọi API bằng hai user khác nhau và kiểm tra created_by/updated_by trong DB.

  • Chạy một @Modifying @Query update hàng loạt, xác nhận updated_at không đổi; sửa JPQL tự set và chạy lại.

  • Thử gán createdAt bằng reflection hoặc setter tạm rồi update — chứng minh updatable = false giữ nguyên giá trị trong DB.

  • Viết cặp test với DateTimeProvider cố định: assert createdAt đúng bằng mốc giả, và test thứ hai chứng minh @DataJpaTest thiếu @Import thì auditing tắt.

Kết luận

Auditing gói trong bốn ý: BaseEntity + @EnableJpaAuditing + AuditingEntityListener cho thời điểm, AuditorAware dạy Spring danh tính — một điểm thay khi Security xuất hiện; auditing bám lifecycle event nên bulk update và JDBC vô hình với nó; test cần @Import config và DateTimeProvider đóng băng thời gian; còn lịch sử đầy đủ là chuyện của Envers. Ngày 30 là bài checkpoint của cả giai đoạn: dựng một CRUD API hoàn chỉnh từ controller qua service xuống repository — validation, phân trang, xử lý lỗi, transaction và auditing hôm nay cùng vào một chỗ. Hẹn gặp lại!

S

Site Admin

Engineer and writer. Building things with TypeScript and distributed systems.

Bình luận (0)

Bạn cần đăng nhập bằng Google để bình luận.

Hãy là người bình luận đầu tiên.

Bài viết liên quan

99 Ngày Java — Ngày 29: Immutable & defensive copy

Công thức lớp bất biến, vì sao final chưa đủ và defensive copy hai chiều, List.copyOf vs unmodifiableList — và món quà thread-safe không cần lock.

25 thg 8, 202611 phút0
99 Ngày Java — Ngày 28: Regex trong Java

Pattern & Matcher, cú pháp dùng hằng ngày, group có tên để trích xuất, replaceAll/split với các bẫy — và ranh giới nơi regex nên dừng lại.

24 thg 8, 202611 phút7
99 Ngày Spring — Ngày 28: Transaction & @Transactional

ACID, proxy đứng sau @Transactional, propagation REQUIRED vs REQUIRES_NEW, quy tắc rollback với bất ngờ checked exception — và bẫy self-invocation.

24 thg 8, 202612 phút10