caltalys.dev
Save entity xong, tìm trong HashSet không thấy nó đâu
size() bằng 1, iterate thì nhìn thấy nó, mà contains() trả false. Chuyện danh tính của entity khi id được cấp sau lúc nó vào Set.
Thằng em nhắn, và lần này nó gửi kèm một video quay màn hình debugger, vì nó sợ mình không tin.
size() bằng 1 - đúng. Em iterate cái Set và nhìn thấy entity nằm trong đó, sờ được, in ra được. Nhưng contains() trả false. Vật nằm ngay trước mắt mà hỏi ‘có nó không’ thì bảo không. Đây là vật lý lượng tử hả anh? Code của nó, rút gọn:
@Data // Lombok - để ý cái này
@AllArgsConstructor // vì @Data chỉ sinh constructor rỗng
@Entity
public class Tag {
@Id @GeneratedValue
private Long id;
private String name;
}
Set<Tag> tags = new HashSet<>();
Tag tag = new Tag("spring");
tags.add(tag); // vào Set lúc CHƯA save - id còn null
repo.save(tag); // id được cấp: 42
tags.contains(tag); // → false ?!
tags.iterator().next() == tag; // → true. CHÍNH NÓ. vẫn nằm đó.
Không phải lượng tử. Là một vụ đổi danh tính giữa chừng - và để thấy nó, mình phải dựng lại một khái niệm mà series này từng chạm tới từ rất lâu, lâu đến mức bạn quên là phải. Nên coi như chưa ai học gì, mình kể từ đầu.
Một cấu trúc băm - HashSet, HashMap - về bản chất là một dãy ngăn tủ đánh số. Khi bạn add một vật, nó không đi hỏi từng ngăn. Nó hỏi chính vật đó đúng một câu: “mã số của mày là gì?” - câu đó tên là hashCode() - rồi lấy mã số tính ra số ngăn, và cất vật vào ngăn đó. Xong. Từ giây phút ấy, tủ không nhớ mặt vật. Nó chỉ nhớ quy trình: muốn tìm lại, hỏi lại mã số, tính lại số ngăn, mở đúng ngăn đó ra xem. Nói cách khác: trong một cấu trúc băm, bạn không phải là bạn - bạn là cái mã số của bạn. Vật và key là một. Đổi mã số là đổi danh tính, và đổi danh tính là biến mất - không phải biến khỏi tủ, mà biến khỏi khả năng được tìm thấy.
(Nếu bạn đã đi cùng series từ mùa proxy, chỗ này có mùi quen: cái Map của @Cacheable cũng chỉ biết mỗi cái key, và hai method từng cầm nhầm đời nhau vì trùng key. Hôm nay là đúng luật đó, chiều ngược lại: không phải hai kẻ chung một mã - mà một kẻ tự đổi mã giữa chừng.)
Giờ hỏi: mã số của Tag tính từ đâu? Nhìn cái @Data của Lombok - nó sinh hộ equals và hashCode trên mọi field, gồm cả id. Nghe hợp lý: hai entity là một khi mọi field như nhau, id là field quan trọng nhất, đưa vào chứ sao không.
Và đây là chỗ Hibernate bước vào phá luật chơi. id của entity không phải hằng số - nó là @GeneratedValue: null lúc bạn new, và chỉ được cấp khi cuốn sổ làm thủ tục khai sinh. Tức là giá trị nền tảng nhất của cái mã số… thay đổi vào một thời điểm bạn không kiểm soát - đúng cái kiểu thời-điểm-ngoài-tầm-mắt mà series này mở màn.
Tua chậm vụ án. tags.add(tag) - tủ hỏi mã số. hashCode tính trên (id=null, name="spring"), ra ví dụ 7100. Tủ cất vào ngăn 71. Rồi repo.save(tag) - cuốn sổ khai sinh, id từ null thành 42. Không ai báo cái tủ. Vật vẫn nằm nguyên ngăn 71 - tủ có bao giờ dọn lại đâu. Rồi contains(tag) - tủ hỏi mã số bây giờ: tính trên (id=42, name="spring"), ra 9455. Mở ngăn 94. Trống. “Không có.”
Vật ở ngăn 71. Tủ tìm ngăn 94. iterate thì thấy - vì iterate là đi dọc mọi ngăn, không cần mã số. contains thì không - vì contains tin vào mã số, và mã số đã nói dối. Không phải lượng tử. Là một người đổi số căn cước sau khi đã ghi danh, rồi thắc mắc sao gọi tên cũ không ai thưa.
Không có bug ở đâu cả - theo nghĩa quen thuộc của series. HashSet chạy đúng như được viết. Lombok sinh code đúng như được bảo. Hibernate cấp id đúng lịch của nó. Chỉ là ba luật đúng đắn ấy chưa từng được giới thiệu với nhau, và contract của hashCode - mã số không được đổi khi vật còn nằm trong tủ - bị phá ở đúng cái khe giữa add và save.
Chưa hết. Còn một quả mìn thứ hai, và nó nối thẳng về tờ giấy hẹn bài trước. Nhưng phải gọi đúng tên thủ phạm, vì có hai kẻ gài mìn và chúng gài ở hai chỗ khác nhau.
Kẻ thứ nhất là equals do IDE sinh - template mặc định của IntelliJ lẫn Eclipse - mở màn bằng phép kiểm tra lớp: if (o == null || getClass() != o.getClass()) return false. Nghe chặt chẽ. Nhưng bạn vừa học hôm qua rằng thứ Hibernate đưa bạn không phải lúc nào cũng là class thật: một @ManyToOne lazy là một Tag$HibernateProxy$aBc123 - subclass, sinh lúc runtime. Đặt entity thật cạnh người đóng thế của chính nó: getClass() một bên là Tag, một bên là Tag$HibernateProxy$... - khác nhau - equals trả false trước cả khi kịp so id. Hai object đại diện cùng một hàng trong DB, tủ coi là hai người lạ.
Còn Lombok thì không mắc lỗi đó - và đây là chỗ mình phải tự đính chính, vì bản nháp đầu của chính bài này đổ tội nhầm cho nó. Mở mã nguồn Lombok ra đọc thì thấy @Data sinh if (!(o instanceof Tag)) return false;, rồi thêm một cú other.canEqual(this) cũng bằng instanceof. Người đóng thế là một Tag qua kế thừa, nên nó qua được cả hai cửa.
Lombok gài mìn ở chỗ khác, và tinh vi hơn. Để so cho hết mọi field, equals buộc phải gọi getter trên other - mà other là một tờ giấy hẹn chưa khởi tạo. Đo được:
proxy da khoi tao TRUOC equals ? false
equals(that, proxy) = true <- qua duoc instanceof
proxy da khoi tao SAU equals ? true <- mot cau SELECT vua chay
equals NGOAI transaction -> LazyInitializationException
Bạn viết một phép so sánh, nhận về một cú chạm database. Và nếu cuốn sổ đã đóng - đúng cái tình huống bài trước - thì phép so sánh ấy nổ. Trớ trêu nhất: cú canEqual mà Lombok thêm vào để cho đúng đắn về kế thừa lại chính là cú gọi đầu tiên đánh thức người đóng thế.
Cùng một bệnh danh tính, hai biểu hiện: IDE để lớp vỏ che mất danh tính, còn Lombok thì phải đánh thức vật thật mới so được - mà đánh thức thì tốn tiền, hoặc nổ. Lần này không phải mã số đổi theo thời gian, mà danh tính bị lớp vỏ xen vào giữa.
Vậy viết equals/hashCode cho entity thế nào cho sống được? Ba đường, xếp theo thứ tự mình khuyên.
Một - nếu entity có business key: một field (hay bộ field) mang danh tính nghiệp vụ thật, bất biến từ lúc new - mã ISBN của sách, email của user, name duy nhất của tag. Dùng đúng nó cho equals/hashCode, bỏ id ra. Mã số bất biến từ khoảnh khắc đầu tiên, tủ không bao giờ bị lừa. Đây là đáp án đẹp nhất - khi nghiệp vụ cho bạn cái key đó.
Hai - khi không có business key (đa số bảng ngoài đời): dùng id cho equals, nhưng hashCode trả hằng số:
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (!(o instanceof Tag other)) return false; // instanceof - không phải getClass()
return id != null && id.equals(other.getId());
}
@Override
public int hashCode() {
return Tag.class.hashCode(); // hằng số - mọi Tag chung một ngăn
}
Một chi tiết trong đoạn code trên chịu lực nhiều hơn vẻ ngoài của nó: other.getId() - getter, không phải other.id. IDE sẽ gợi ý bạn đọc thẳng field cho gọn, và đó là lúc nó hỏng im lặng: nếu other là người đóng thế thì field của chính lớp vỏ ấy rỗng, luôn luôn. Chỉ getter mới chuyển được câu hỏi sang vật thật. Bạn đã gặp đúng luật này ở bài final method mùa một, chỉ khác lò đúc.
Trông ngược đời - hashCode hằng số thì băm làm gì? - nhưng đọc kỹ nó giải đúng cả hai quả mìn. Mã số không bao giờ đổi (hằng mà), nên vật không bao giờ lạc ngăn qua ranh giới save. instanceof thay getClass(), nên người đóng thế - vốn là một Tag qua kế thừa - qua được cửa kiểm tra, và phần so id (giấy hẹn nào cũng mang id thật) làm nốt phần còn lại. Cái giá: mọi Tag trong một tủ dồn chung một ngăn, contains trong ngăn đó thành duyệt tuần tự - với collection vài chục vài trăm phần tử của một entity, chẳng ai đo nổi khác biệt. Đây là cái giá rẻ nhất bạn từng trả trong series này.
Ba - đường phòng thủ: đừng bắt entity sống hai đời trong một cấu trúc băm - đừng add lúc transient rồi save trong khi nó còn nằm trong Set. Nhưng nói thật, luật kiểu “đừng làm X” phân tán khắp codebase thì trước sau cũng có người quên; hai đường trên sửa tại gốc, một chỗ, cho tất cả.
Muốn tự thấy tận mắt, năm phút: lấy đúng đoạn code đầu bài, in tag.hashCode() trước và sau save() - hai con số khác nhau là toàn bộ vụ án nằm trên hai dòng log. Rồi đổi hashCode sang bản hằng số, chạy lại, xem contains sống dậy.
Còn nguyên lý, thứ sống lâu hơn Lombok lẫn Hibernate: một vật tham gia cấu trúc tra cứu theo danh tính, thì danh tính phải bất biến trong suốt thời gian tham gia. Đổi key của document đang nằm trong index. Produce lại một record với key khác, rồi trông đợi nó vẫn nằm cùng partition với bản cũ. Đổi tên file đang được symlink trỏ tới. Cùng một tai nạn: hệ thống không tìm bằng mắt - nó tìm bằng danh tính, và danh tính vừa đổi sau lưng nó.
Thằng em đọc xong, gõ một câu dài hơn thường lệ:
Sống tử tế với tất cả các danh tính cùng lúc.
id là danh tính với database, hashCode là danh tính với cái Set. Hibernate cấp cái thứ nhất vào lúc của nó, còn em để cái thứ hai ăn theo cái thứ nhất - nên có một khoảnh khắc entity của em đổi tên mà một trong hai thế giới không được báo. Bug nằm ở cái khe giữa hai thế giới. Đúng rồi đó. Và em vừa tự nói ra bài học của cả mùa mà không nhận ra: em chưa bao giờ thao tác trực tiếp lên thứ gì - luôn có một cuốn sổ, một cái tủ, một tờ giấy hẹn đứng giữa, và mỗi đứa nhìn em bằng một danh tính riêng. Sống với ORM là sống tử tế với tất cả các danh tính đó cùng lúc. 😃
Tài nguyên đi kèm
-
ZIP Source demo - tập 3.4
JDK 17.
mvn spring-boot:run→Episode34Runnerin khối[EP 3.4]:hashCodecủaTagđổi sausave()nênset.contains()trảfalse, cònStableTagthì không.https://caltalys.dev/downloads/book1/3.4-spring-deep-dive-demo.zip