caltalys.dev
@Transactional gọi từ chính class mình lại không chạy
Lời gọi từ trong chính class không đi ra ngoài cái vỏ, nên không ai kịp làm gì cả. Bài này giải thích vì sao đừng bao giờ tin this.
Thằng em nhắn, và lần này nó có tiến bộ. Nó không hỏi “Spring ghét em à” nữa. Nó gửi kèm bằng chứng:
@Service
public class OrderService {
public void placeOrder(Order order) {
validate(order);
save(order); // gọi thẳng, không qua đâu cả
}
@Transactional
public void save(Order order) {
repo.insert(order);
repo.insertHistory(order); // dòng này ném exception
}
}
insertHistory nổ. Đáng lẽ cả save() phải rollback, order không được nằm lại trong DB. Nhưng nó nằm lại. Một nửa dữ liệu ghi xong, một nửa không, và không có transaction nào để kéo ngược.
log.info("{}", AopUtils.isAopProxy(orderService)); // true
log.info("{}", orderService.getClass().getName()); // OrderService$$SpringCGLIB$$0
save() không final, không private, public sạch sẽ. Vỏ dựng được rồi. Sao vẫn không có transaction? Nó hỏi đúng câu rồi đấy.
Bài trước mình có treo một câu, nhớ không: advice chỉ chạy khi lời gọi đi vào từ ngoài, qua cái vỏ. Hôm đó mình cho bạn ba chỗ cái vỏ không dựng được - final method, final class, private method. Rồi mình hỏi: còn nếu vỏ dựng được đàng hoàng, mà lời gọi vẫn không đi qua nó thì sao?
Đây. Hôm nay là hôm đó.
Và để trả lời, mình phải nói một chuyện mà bài trước cố tình chưa nói hết. Mình bảo bạn: proxy là lớp vỏ bọc quanh target. Câu đó đúng, nhưng nó gợi một hình ảnh sai. Bạn hình dung một cái hộp bọc quanh món đồ - vẫn là một món đồ, thêm lớp giấy gói.
Không phải. Proxy và target là hai object. Hai địa chỉ khác nhau trong bộ nhớ. Hai thứ tồn tại song song, cùng lúc, cả đời ứng dụng.
Có một chi tiết trong tài liệu Spring chứng minh chuyện đó gọn hơn mọi lời giải thích: nó ghi rằng constructor của object được proxy không bị gọi hai lần, vì proxy được tạo qua Objenesis - một thư viện chuyên đúc object mà bỏ qua constructor.
Đọc lại câu đó chậm một lần. Nếu proxy chỉ là target được khoác thêm áo, sao lại có chuyện “gọi constructor hai lần” để mà phải tránh? Chỉ có một lý do: đúc proxy là đúc một object mới. Spring phải đi đường vòng qua Objenesis để cái object mới đó không chạy constructor của bạn thêm lần nữa.
Nên bức tranh thật là thế này. Target là OrderService bạn viết, có repo, có state, được đúc đủ bảy chặng. Proxy là một OrderService$$SpringCGLIB$$0 - con ruột, đúng, nhưng là một đứa con rỗng ruột: constructor không chạy, field không được inject, nó chẳng có repo nào cả. Thứ duy nhất nó có là một tham chiếu tới target, và một chồng advice.
Proxy không chứa target. Proxy trỏ tới target.
Bean nằm trong container là proxy. Bean được inject vào chỗ khác là proxy. Khi controller gọi orderService.placeOrder(o), nó gọi vào proxy. Proxy xem placeOrder có @Transactional không - không có - nên nó chẳng làm gì, chuyển thẳng lời gọi sang target.
Và từ giây phút đó, chúng ta đã ở bên trong target.
this trong placeOrder() là gì? Là target. Object thật. Không phải proxy - proxy đang đứng ngoài kia, đã làm xong việc của nó và không còn ai gọi tới nữa.
Nên khi target chạy tới dòng save(order) - mà save(order) chính là this.save(order), Java chỉ cho bạn viết tắt - nó gọi thẳng vào method save() của chính nó. Một lời gọi Java bình thường, từ một object tới chính object đó. Ngắn nhất có thể. Không ai đứng giữa.
Proxy? Proxy không biết gì cả. Nó không có mặt trong lời gọi này. Nó ở ngoài cổng, và lời gọi này diễn ra trong nhà.
Không có bug ở đâu cả. @Transactional vẫn ở đó, vẫn được đọc, proxy vẫn có một advice cho save() - advice đó chỉ đơn giản là không có cơ hội chạy, vì không ai đi qua cửa. Chẳng ai báo bạn.
Đây là chỗ mình muốn bạn dừng một nhịp:
Lời gọi nội bộ không đi qua cửa, nên lời khai không có ai đọc.
Cái vỏ chỉ chặn được cửa vào. Mọi thứ xảy ra bên trong ngôi nhà, nó mù tịt.
Nhưng khoan. Nếu vậy thì có một chỗ trong Spring đáng lẽ phải hỏng, mà nó lại không hỏng.
@Configuration
public class AppConfig {
@Bean
public DataSource dataSource() { ... }
@Bean
public JdbcTemplate jdbcTemplate() {
return new JdbcTemplate(dataSource()); // this.dataSource() - self-invocation!
}
}
Bạn viết cái này mỗi ngày. Gọi dataSource() ba lần từ ba @Bean method khác nhau, và bạn nhận về cùng một DataSource - đúng singleton, không phải ba cái. Ai cũng biết chuyện đó. Không ai thấy lạ.
Nhưng theo đúng những gì mình vừa nói, nó phải hỏng chứ? dataSource() cũng là this.dataSource(). Cũng là buddy method call. Cũng không đi qua vỏ nào. Lẽ ra mỗi lần gọi phải new ra một DataSource mới.
Sao chỗ này this lại chạy, mà chỗ kia thì không?
Câu trả lời không nằm ở CGLIB. Cả hai đều là CGLIB. Nó nằm ở thời điểm.
Nhớ bốn nhịp của bài đầu tiên không? Nhịp hai - sửa bản vẽ - là lúc các BeanFactoryPostProcessor được cho phép sờ vào BeanDefinition trước khi bean ra đời. Và một trong những BFPP chạy ở đó là ConfigurationClassPostProcessor, thằng bạn đã gặp rồi, thằng chuyên quét classpath.
Nó làm thêm một việc nữa mà hôm đó mình chưa kể: nó sửa luôn bản vẽ của chính các class @Configuration. BeanDefinition của AppConfig bị trỏ sang một subclass CGLIB, trong đó mỗi @Bean method được override để hỏi container trước: “cái bean này có rồi chưa? Có rồi thì trả cái cũ.”
Rồi tới nhịp bốn, Spring đúc bean từ bản vẽ đã sửa. Cái object ra lò không phải AppConfig của bạn. Nó là cái subclass. Chỉ một object duy nhất. Không có target nào riêng cả.
Nên khi jdbcTemplate() gọi this.dataSource(), this là subclass - và method dataSource() mà nó gọi trúng chính là bản đã được override. Logic nằm ngay trong người nó. Không cần đi qua cửa nào, vì không có cửa nào: nhà và người gác cổng là một.
Còn @Transactional thì ngược lại. AutoProxyCreator là một BPP, và nó ra tay ở chặng (7) - sau khi target đã được đúc xong, đã inject đủ, đã chạy @PostConstruct. Lúc đó đã quá muộn để biến target thành thứ khác. Cái duy nhất làm được là đúc thêm một object nữa đứng chắn phía trước.
Sửa bản vẽ thì được một object. Bọc thành phẩm thì được hai. Và cái khe giữa hai object đó chính là chỗ this rơi xuống.
Hai triệu chứng, và chúng ngược nhau như hai mặt của một tờ giấy: một chỗ this không chạy, một chỗ this lại chạy. Nhưng cùng một câu hỏi duy nhất - lời gọi này có phải đi qua ai không? - và câu trả lời được quyết định từ lúc nào can thiệp, chứ không phải bằng gì.
Vậy sửa cái save() kia thế nào?
Tài liệu Spring xếp hạng rất rõ, và mình đồng ý với thứ tự đó.
Tốt nhất: tách save() sang một bean khác, rồi inject vào. Lúc đó orderService gọi orderStorage.save(o) - một object gọi sang một object khác, đi qua cửa, gặp proxy của orderStorage. Transaction chạy. Nghe như né việc, nhưng nó là cách ít xâm lấn nhất, và thường thì nó lộ ra rằng hai method đó vốn dĩ thuộc về hai trách nhiệm khác nhau.
Cách hai: self-injection - inject chính mình vào mình, rồi gọi self.save(o) thay vì this.save(o). Cái self đó là proxy. Đi qua cửa. Chạy. Trông kỳ quặc, nhưng nó thành thật: nó nói toạc ra rằng bạn cần đi vòng ra ngoài rồi bước vào lại.
Cách ba: AopContext.currentProxy(). Tài liệu Spring viết một câu hiếm gặp về cách này - đại ý “chúng tôi ngần ngại khi chỉ ra nó” - vì nó trói code của bạn vào Spring AOP, và class của bạn từ đó phải tự biết mình đang bị proxy. Nó còn đòi bạn bật exposeProxy trước. Biết là có, đừng dùng.
Ba cách, một hình dạng chung: chúng đều đưa lời gọi ra khỏi nhà rồi cho nó gõ cửa lại. Không cách nào làm cái vỏ nhìn được vào bên trong, vì cái vỏ về bản chất không nhìn được vào bên trong. Nó là một object khác.
Cái giá thì rõ rồi. Bạn được @Transactional một dòng, đổi lại bạn nhận một luật ngầm mà không ai viết trong code: hai method trong cùng một class không nhìn thấy annotation của nhau. Luật đó không có ở đâu trong file bạn đang đọc. Nó nằm ở một object thứ hai mà bạn chưa từng thấy mặt, sinh ra ở một chặng bạn không có mặt.
Lại là một tầng trung gian. Lần này nó không giấu một cái List hay một cái Map - nó giấu chính nó. Bạn tưởng bạn đang cầm OrderService. Bạn đang cầm một đứa trông giống hệt, đứng trước cửa, và nó chỉ soát vé của người đi từ ngoài vào.
Nếu muốn tự thấy tận mắt: đặt breakpoint trong save(), rồi nhìn this trong debugger. Gọi từ controller - this là OrderService, không phải $$SpringCGLIB$$0. Cái proxy đã đứng ngoài, đã làm xong việc, và đã biến mất khỏi stack. Rồi thêm một breakpoint trong TransactionInterceptor.invoke() và xem nó có bị chạm vào không khi bạn gọi qua this. Không. Im lặng tuyệt đối, đúng nghĩa đen.
Còn nguyên lý, thứ sống lâu hơn Spring: một kẻ đứng giữa chỉ chặn được thứ đi ngang qua nó. Nghe hiển nhiên đến mức buồn cười, nhưng gần như mọi bug proxy trên đời đều là người ta quên mất câu này. Middleware không thấy lời gọi nội bộ trong handler. Service mesh không thấy hai class nói chuyện trong cùng một process. Cache layer không thấy hàm gọi hàm. Cùng một câu, khác chỗ đứng.
Thằng em ngồi im một lúc lâu hơn mọi lần, rồi nhắn:
this của chính mình. Đúng rồi đó. Ra ngoài rồi gõ cửa, thì cửa mới soát vé được. 😃
Tài nguyên đi kèm
-
ZIP Source demo - tập 1.2
JDK 17.
mvn spring-boot:run→Episode12Runnerin khối[EP 1.2]:OrderServiceđúng là proxy, nhưngplaceOrder()gọithis.save()nên@Transactionalbốc hơi.https://caltalys.dev/downloads/book1/1.2-spring-deep-dive-demo.zip