Tới nội dung chính
caltalys.dev
Thời điểm / Lifecycle

@Value của tôi bị null, và Spring không hề báo lỗi?

Kẻ đến quá sớm thì không được phục vụ - chuyện cái List được đổ đầy sau lưng bạn, và vì sao @Transactional trong @PostConstruct cũng im lặng vì cùng một lý do.

Đồ hình trừu tượng của tập 0.1 trong Quyển Một
Caltalys 13 phút đọc

Mấy hôm trước, thằng em trong team nhắn cho mình. Nó viết một class nhỏ, đại khái thế này:

@Component
public class MyConfigTweaker implements BeanFactoryPostProcessor {
    @Value("${app.timeout}")
    private int timeout; // luôn luôn là 0
}

app.timeout: 5000 nằm chình ình trong application.yml. App chạy. Không exception, không warning, không một dòng log nào. Chỉ là timeout bằng 0. Nó debug tới hai giờ sáng rồi nhắn:

thang em - 02:07 Anh ơi, hay là Spring nó ghét em?

Mình cười. Không phải cười nó - mình cũng từng ngồi đúng chỗ đó, cũng chừng ấy giờ đêm, vài năm trước. Spring không ghét nó. Nó chỉ đến quá sớm.

Nhưng để hiểu “sớm” nghĩa là gì, chúng ta cần lùi lại một bước, và nhìn xem Spring thật ra làm gì trong vài giây kể từ lúc bạn bấm Run.

Trước hết, thống nhất ba từ, vì junior rất hay lẫn. Bean là object thật, có địa chỉ trong bộ nhớ, do Spring new ra. BeanDefinition không phải object đó - nó là bản mô tả về object đó: class nào, scope gì, constructor cần tham số nào, init-method tên gì. Nó chỉ là dữ liệu, nằm trong một cái Map, key là tên bean. Và cái Map đó nằm trong BeanFactory - cỗ máy đọc bản mô tả rồi đúc ra bean. Còn ApplicationContext mà ai cũng quen mặt thì chỉ là lớp vỏ bọc ngoài BeanFactory, thêm event, thêm i18n, thêm resource loading.

Nói gọn: BeanDefinition là bản vẽ, BeanFactory là công trường, Bean là căn hộ.

Bây giờ, bức tranh toàn cảnh. Khi bạn gọi SpringApplication.run(), mọi thứ quan trọng đều xảy ra bên trong một method duy nhất tên là AbstractApplicationContext.refresh(). Nó dài khoảng hai trăm dòng, và gọi tuần tự mười hai bước từ trên xuống. Mình sẽ không kể cả mười hai. Chỉ cần bốn nhịp, và bốn nhịp này là toàn bộ bài viết hôm nay.

Nhịp một - vẽ bản vẽ. Spring quét classpath, đọc @ComponentScan, @Configuration, @Bean, @Component, xử lý @Conditional, và biến tất cả thành BeanDefinition đổ vào Map. Chưa có một object nào của bạn được tạo ra. Chỉ có mô tả về chúng.

Nhịp hai - sửa bản vẽ. Spring dừng lại và mở một cửa sổ: ai muốn sửa bản vẽ thì sửa bây giờ, trước khi bê tông được đổ. Người được quyền bước qua cửa sổ đó là các BeanFactoryPostProcessor (BFPP) - chính là cái interface mà thằng em kia implement. Spring new chúng ra, đưa cả cái Map cho chúng, cho chúng sửa thoải mái. PropertySourcesPlaceholderConfigurer là một BFPP có sẵn, chạy ở đây để thay ${...} trong các BeanDefinition bằng giá trị thật.

Nói thật với bạn một chuyện: nhịp một và nhịp hai thật ra nằm cùng trong một method, tên là invokeBeanFactoryPostProcessors(). Spring chia nó làm hai lượt. Lượt đầu chạy BeanDefinitionRegistryPostProcessor - trong đó có ConfigurationClassPostProcessor, thằng chuyên quét classpath - để thêm BeanDefinition. Lượt sau chạy BFPP thường để sửa BeanDefinition đã có. Mình tách thành hai nhịp cho dễ nhớ, nhưng bạn đọc source sẽ thấy chúng liền một mạch.

Nhịp ba - đổ đầy cái danh sách. Đây là nhịp mà đa số người bỏ qua, và nó là nhịp quan trọng nhất.

BeanFactory giữ một List<BeanPostProcessor>. Ở registerBeanPostProcessors(), Spring new ra thật toàn bộ BeanPostProcessor (BPP) - object thật, có địa chỉ bộ nhớ - rồi nhét hết vào cái List đó. Chúng chưa tác động lên bean nào. Chúng chỉ nằm trong List, chờ.

Cái List này là ai? Là những cái tên bạn dùng hàng ngày mà không biết: AutowiredAnnotationBeanPostProcessor (xử lý @Autowired, @Value), CommonAnnotationBeanPostProcessor (@PostConstruct, @Resource), và các AutoProxyCreator (@Transactional, @Async, @Cacheable).

Nhớ kỹ chữ List này. Nó là chìa khóa của cả bài.

Và để ý cái tên cuối cùng: AutoProxyCreator. Cứ nhớ nó ở đó. Lát nữa nó sẽ quay lại - và về sau nữa, nó là nhân vật chính của cả một câu chuyện khác.

Nhịp bốn - đúc bean, qua bảy chặng.finishBeanFactoryInitialization(), Spring duyệt Map và tạo lần lượt mọi singleton non-lazy - tức mọi bean bạn không gắn @Lazy và không đổi scope: chỉ một instance duy nhất cho cả app, và được tạo ngay lúc khởi động chứ không đợi tới lúc có người gọi.

Mỗi bean đi qua bảy chặng:

  1. Gọi constructor - constructor injection xảy ra ở đây.
  2. Inject field và setter - @Autowired, @Value.
  3. Gọi vài callback Aware cơ bản - Aware là nhóm interface để bean xin Spring một thứ gì đó: BeanNameAware xin cái tên của chính nó, BeanFactoryAware xin cái BeanFactory. Chữ “cơ bản” ở đây quan trọng; họ hàng còn lại của nhà Aware đi đường khác, và lát nữa bạn sẽ thấy vì sao.
  4. @PostConstruct chạy ở đây.
  5. InitializingBean.afterPropertiesSet().
  6. Init-method - cái method bạn tự chỉ định qua @Bean(initMethod = "init").
  7. Proxy cho @Transactional được sinh ra ở đây, chặng cuối cùng - chính AutoProxyCreator ra tay.

Chú ý một điều: cái List<BeanPostProcessor> kia được lôi ra duyệt ở nhiều chặng, không phải một. Mỗi chặng hỏi BPP một câu khác nhau. Chặng (2) hỏi: “mày có muốn inject gì vào bean này không?” - và AutowiredAnnotationBeanPostProcessor giơ tay, xử lý @Value. Chặng (4) hỏi: “trước khi khởi tạo, mày có muốn làm gì không?” - CommonAnnotationBeanPostProcessor giơ tay, chạy @PostConstruct. Chặng (7) hỏi: “mày có muốn bọc bean này lại không?” - AutoProxyCreator giơ tay, sinh proxy.

Cùng một List. Nhiều lần duyệt. Câu hỏi khác nhau.

Bốn nhịp. Bản vẽ, sửa bản vẽ, đổ đầy List BPP, đúc bean qua bảy chặng.

Bây giờ quay lại cái @Value null.

BFPP được new ra ở nhịp hai. @Value được xử lý bởi AutowiredAnnotationBeanPostProcessor - một BPP - mà BPP thì mãi nhịp ba mới được nhét vào List.

Nhưng khoan. Ở đây có một câu hỏi mà bạn nên tự hỏi, và mình đoán bạn đang định hỏi: nếu BFPP cũng là bean, thì Spring new nó ra bằng cách nào, trong khi cỗ máy đúc bean của nhịp bốn còn chưa khởi động?

Câu trả lời: Spring gọi thẳng getBean() cho riêng nhóm BFPP, sớm hơn tất cả.

Và đây là chỗ hầu hết mọi người hiểu sai. getBean() không bỏ qua bảy chặng. Nó chạy đủ. Cả bảy. BFPP của thằng em kia cũng đi qua chặng (2) - chặng inject field - đàng hoàng.

Vấn đề là: chặng (2) không phải một phép màu. Nó chỉ là một vòng for duyệt qua cái List mà mình bảo bạn nhớ, hỏi từng thằng “mày có muốn inject gì không”.

Ở nhịp hai, AutowiredAnnotationBeanPostProcessor chưa nằm trong List.

Vòng for vẫn chạy. Chạy qua những kẻ không quan tâm tới @Value. Không ai giơ tay. Không phải vì Spring bỏ qua chặng đó, mà vì người phụ trách chặng đó chưa vào danh sách. Chưa vào List thì chưa tồn tại, đối với vòng for. Field timeout giữ nguyên giá trị mặc định của int. Là 0.

Không có bug ở đâu cả. Không có ngoại lệ nào cả. Chỉ là một vòng lặp, chạy đúng như nó được viết ra, trên một danh sách chưa đầy.

Vậy sửa thế nào? Cho BFPP implement thêm EnvironmentAware. Nó xin thẳng Spring cái Environment, rồi tự đọc environment.getProperty("app.timeout"). Chạy ngon.

Nhưng khoan đã. Tại sao cái này lại chạy được, trong khi @Value thì không?

Bạn đoán đúng rồi đấy: vì thằng phụ trách EnvironmentAware đã vào List từ trước.

Đây là chỗ trả lời cho chữ “cơ bản” ở chặng (3). BeanNameAwareBeanFactoryAware được BeanFactory gọi thẳng tay, không cần ai giúp. Còn EnvironmentAware thì không: nó phải nhờ một BPP. Và cái BPP đó vào List từ rất sớm - trước cả nhịp một.

Trước nhịp một, refresh() có một bước dọn dẹp nhỏ tên prepareBeanFactory(). Ở đó Spring tự tay nhét sẵn vào List vài BPP hạ tầng, trong đó có ApplicationContextAwareProcessor - thằng lo EnvironmentAware, ApplicationContextAware và họ hàng. Còn AutowiredAnnotationBeanPostProcessor - thằng lo @Value - thì phải đợi tới nhịp ba mới được vào.

Hai thằng, cùng một cơ chế. Cùng một List. Cùng một vòng for. Khác nhau đúng một điều: ai vào danh sách trước.

@Value không hề “bị Spring bỏ rơi trong BFPP”. Nó chỉ đến muộn hơn EnvironmentAware vài dòng code.

Nhịp ba, nhìn lại, hóa ra không phải “gọi đội hoàn thiện tới”. Nó là thời khắc cái List được đổ đầy. Trước nó, ai sinh ra cũng nghèo. Sau nó, ai sinh ra cũng đủ đầy.

Cái gì phải sinh ra sớm để phục vụ người khác, thì bản thân nó không được phục vụ.

Nhớ lấy câu đó. Vì bây giờ mình sẽ kể lại đúng câu chuyện ấy một lần nữa - trên một triệu chứng mà bạn sẽ không ngờ là cùng gốc.

Có một câu hỏi mà junior nào cũng sẽ đâm đầu vào ít nhất một lần trong đời: tại sao @Transactional trong @PostConstruct lại không có tác dụng?

Trả lời được câu này cần một câu về proxy. Khi bạn gắn @Transactional lên một bean, Spring không sửa class của bạn. Nó tráo object: các bean khác, khi inject “bean của bạn”, thật ra nhận về một object khác - một proxy - bọc quanh object thật. Proxy mở transaction, gọi vào trong, rồi commit hoặc rollback. Object thật không biết gì cả.

Việc tráo đó xảy ra ở đâu? Nhìn lại bảy chặng: chặng (7). Chặng cuối cùng. Đó là lúc Spring duyệt List lần cuối và AutoProxyCreator - cái tên mình dặn bạn nhớ ở nhịp ba - giơ tay hỏi “mày có muốn bọc bean này lại không”.

Còn @PostConstruct chạy ở chặng (4).

Nên khi @PostConstruct đang chạy, proxy chưa ra đời. Nó còn ba chặng nữa mới ra đời. this trong @PostConstruct là object trần. Bạn gọi một method @Transactional của chính mình từ đó, lời gọi đi thẳng vào object thật, không qua proxy - vì proxy chưa có. Không transaction nào hết. Chẳng ai báo bạn.

Không phải bug. Là hệ quả số học của thứ tự. Muốn chạy logic khởi tạo có transaction, đợi tới ApplicationRunner - một interface Boot gọi sau khi mọi bean đã xong - hoặc @EventListener(ContextRefreshedEvent.class), cái sự kiện Spring bắn ra khi container dựng xong hết. Lúc đó toàn bộ bean, kể cả proxy, đã sẵn sàng.

Hai triệu chứng. Trông chẳng liên quan gì tới nhau: một cái là số 0 ở nơi đáng lẽ là 5000, một cái là transaction bốc hơi. Nhưng cùng một nguyên nhân, và nguyên nhân ấy nằm ở đúng cùng một chỗ - cái List<BeanPostProcessor> kia. BFPP sinh ra khi List chưa có người phụ trách @Value. Proxy thì mãi lần duyệt List cuối cùng ở chặng (7) mới sinh, nên nó vắng mặt ở chặng (4).

Bốn nhịp nghe rất gọn, nhưng đánh đổi thì không hề nhẹ. Spring trao cho bạn quyền can thiệp vào gần như mọi khớp nối của quá trình khởi động - quyền lực rất lớn, và rất ít framework cho. Nhưng mỗi lần dùng quyền đó là một lần bạn tự đặt mình vào một vị trí trên trục thời gian. Đứng sai chỗ, bạn không nhận được exception. Bạn nhận được sự im lặng. Đó là loại lỗi tệ nhất, vì nó không kêu.

Và nếu bạn để ý, cả hai lần im lặng hôm nay đều có chung một hình dạng. Giữa bạn và thứ bạn tưởng mình đang thao tác, luôn có một lớp trung gian mà bạn không nhìn thấy: lần này là một cái List được đổ đầy sau lưng bạn, và một thời điểm xảy ra ngoài tầm mắt bạn. Bug im lặng chính là cái giá của lớp trung gian đó. Cái List và cái thời điểm là hai khuôn mặt đầu tiên của nó - và một khi bạn đã nhìn ra hình dạng này, bạn sẽ bắt đầu thấy nó ở khắp nơi trong Spring. Cái proxy ở chặng (7) mà ta vừa chạm tới? Đó là một khuôn mặt khác, và nó đủ lớn để xứng một câu chuyện riêng.

Mình nghĩ đó chính là lý do đọc AbstractApplicationContext.refresh() một lần trong đời là khoản đầu tư sinh lời nhất mà một Java dev có thể làm. Hai trăm dòng, và bốn nhịp mình vừa kể nằm gọn trong đó, viết tường minh, gọi lần lượt từ trên xuống. Đọc xong, bạn sẽ không cần nhớ bài này nữa.

Framework rồi sẽ đổi. Boot 4 sẽ có Boot 5, annotation mới sẽ ra, cái cũ sẽ deprecate. Nhưng ý tưởng “tách bản mô tả ra khỏi vật thể, cho phép can thiệp vào bản mô tả trước khi vật thể ra đời, rồi cho phép can thiệp vào vật thể trước khi nó được trao cho người khác” - cái đó là nguyên lý, không phải công cụ. Nó có ở Spring, có ở CDI, có ở Dagger, và sẽ có ở bất cứ hệ thống DI nào bạn gặp trong mười năm tới.

Và cái câu mình bảo bạn nhớ ở giữa bài cũng vậy. Cái gì phải sinh ra sớm để phục vụ người khác, thì bản thân nó không được phục vụ - nó không nói riêng về Spring. Nó nói về mọi thứ có thứ tự khởi tạo, kể cả những thứ không phải phần mềm.

Thằng em kia, nghe xong, im lặng một lúc rồi nhắn:

thang em - 02:31 Vậy là em không cần nhớ thứ tự, em chỉ cần biết ai phục vụ ai.

Đúng rồi đó. Hiểu vì sao thì cái thứ tự nào tự nó hiện ra. 😃

Tài nguyên tập 1

Cheat-sheet tập 1 Toàn bộ cơ chế trên một trang - in ra hoặc lưu về máy được. Source demo - tập 0.1JDK 17. mvn spring-boot:runMyConfigTweaker log [TEST 1] timeout = 0 trong khi application.yml khai 5000: @Value chưa kịp bơm vào BeanFactoryPostProcessor.

Vị trí trong Lớp Trung Gian Vô Hình

Bài này trả lời

  • Một giá trị @Value bị null, ứng dụng vẫn khởi động bình thường
Bản in
Chia sẻ: Facebook X LinkedIn Email
Nhận bài mới qua email
Powered by follow.it