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.

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:
@EnableJpaAuditingbật hạ tầng,AuditingEntityListenernghe sự kiện persist/update của JPA để điền giá trị, và@MappedSuperclasscho phép mọi entity kế thừa cột mà không sinh bảng riêng — mỗi entity con chỉ cònextends BaseEntitylà 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;LocalDateTimethiếu offset nên 09:00 của nó là 09:00 ở đâu không ai biết.updatable = falsetrêncreatedAtlà 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 methodgetCurrentAuditor()để ta chỉ cho nó; kiểuTtự chọn —Stringusername hayLonguserId đề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
AuditorAwaresang đọcSecurityContextHolder— 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 đểnullthay 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=DEBUGChì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ự setupdatedAtngay 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,@CreatedDategiữ 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
updatedAtvà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@DataJpaTestlà slice test — chỉ dựng tầng JPA, không quét@Configurationcủa ứng dụng: quên@Import(AuditConfig.class)làcreatedAtnull 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 đẩyupdatedAttiến lên còncreatedAtđứng yên — nhớsaveAndFlushđể UPDATE chạy thật thay vì đợi commit (Ngày 25).DateTimeProviderlà kim đồ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ìisNotNullchung 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_AUDkè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, choCongVieckế thừa; save rồi soi SQL log xemcreated_atnằm trong câu INSERT thật.Viết
AuditorAwaređọc headerX-User(quaRequestContextHolder); gọi API bằng hai user khác nhau và kiểm tracreated_by/updated_bytrong DB.Chạy một
@Modifying @Queryupdate hàng loạt, xác nhậnupdated_atkhông đổi; sửa JPQL tự set và chạy lại.Thử gán
createdAtbằng reflection hoặc setter tạm rồi update — chứng minhupdatable = falsegiữ nguyên giá trị trong DB.Viết cặp test với
DateTimeProvidercố định: assertcreatedAtđúng bằng mốc giả, và test thứ hai chứng minh@DataJpaTestthiếu@Importthì 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!
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.


