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

Thêm một dependency - và cấu hình Jackson của tôi lặng lẽ chết

Auto-configuration nấu sau và chỉ nấu nếu thiếu. Một bean lạ xuất hiện là độc giả duy nhất của spring.jackson.* lặng lẽ rút lui.

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

Thằng em nhắn, mở đầu bằng một câu mà mình đợi từ mười tám bài:

thang em Anh, lần này em không hỏi bug. Em nghĩ em vừa nhìn thấy cỗ máy - và em cần anh xác nhận.

Chuyện là frontend báo: mọi trường ngày tháng trong API đột nhiên đổi format - đang "2026-07-18" chuẩn chỉnh thành một cục timestamp thô. Không ai đụng vào config. Nó git log - commit duy nhất tuần đó: một bạn trong team thêm một thư viện PDF-export vào pom.xml. Một dependency. Không một dòng code.

thang em Em lần theo: trong jar của thư viện đó có một class @Configuration, và trong đó - một method @Bean public ObjectMapper objectMapper(). Nó tự nấu một ObjectMapper riêng cho nhu cầu của nó. Em có dừng lại kiểm một chỗ trước khi kết luận, vì một class @Configuration nằm im trong jar thì không tự nhiên được nạp - nó phải được ai đó @Import, hoặc lọt vào vùng component scan, hoặc có tên trong file AutoConfiguration.imports của một starter. Ở đây là vế giữa, và lý do thì ngớ ngẩn tới mức em phải đọc lại ba lần: thư viện đó publish ở package com.acme.pdf, còn @SpringBootApplication của em nằm ở com.acme. Component scan của chính em quét thẳng vào ruột cái jar và nhặt class đó về. Không ai viết một dòng nào - chỉ là hai cái package tình cờ cùng họ. Và từ khoảnh khắc bean đó tồn tại… toàn bộ cấu hình spring.jackson.* của em - format ngày, timezone, naming strategy - chết sạch, không một dòng log. Câu hỏi của em không phải ‘sửa thế nào’. Câu hỏi là: cơ chế gì cho phép sự có mặt của một bean lạ vô hiệu hóa cả một cụm cấu hình của Boot?

Câu hỏi đúng tầm ra trường. Và câu trả lời sẽ trả nốt một món nợ mà series này gieo rải suốt từ mùa một.

Bạn còn nhớ chuỗi “lời-mời-sẵn” chứ? Bài proxy: Boot lật proxy-target-class thành true sau lưng docs. Bài @Async: Boot mời sẵn người lo transaction nhưng không mời người lo async. Bài lazy: Boot bật sẵn Open-Session-In-View kèm dòng warning không ai đọc. Ba lần, mình đều nói “Boot quyết hộ bạn” - và cố tình chưa nói bằng cái gì. Hôm nay lộ mặt cỗ máy: auto-configuration. Ba chuyện kia không phải ba tính năng rời - chúng là ba sản phẩm của cùng một dây chuyền, và dây chuyền đó vận hành bằng đúng hai nguyên tắc.

Nguyên tắc một - nấu sau. Lúc container dựng bean definitions (nhịp một của bài đầu tiên - vẽ bản vẽ), các class auto-configuration của Boot được cố tình xử lý sau cùng, sau khi mọi @Configuration của bạn và của các thư viện đã vào sổ. Lại là chuyện thứ tự trên trục thời gian - nhưng lần này sự chậm trễ là thiết kế, không phải tai nạn: phải nhìn thấy bàn tiệc đã có món gì rồi mới quyết mình nấu gì.

Nguyên tắc hai - chỉ nấu nếu chưa ai nấu. Mở source JacksonAutoConfiguration mà xem (class vẫn còn tên đó, nhưng từ Boot 4 auto-configuration bị tách thành các module spring-boot-<công-nghệ> nên đường dẫn tìm nó đã đổi): cái method tạo ObjectMapper của Boot đội một annotation bạn sẽ gặp khắp mọi auto-config: @ConditionalOnMissingBean. Dịch sát: “nếu trong sổ đã có một bean loại này - của bất kỳ ai - tôi xin rút.” Boot là vị khách lịch sự nhất bàn tiệc: mang theo đầy đủ nguyên liệu, nhưng thấy chủ nhà đã bày món nào là lặng lẽ cất món đó đi. Không tranh, không hỏi, không thông báo.

Giờ ráp vụ án. Trước kia: không ai khai ObjectMapper → điều kiện “missing” thỏa → Boot nấu - và bean của Boot mới là bean được nuôi bằng cả họ spring.jackson.*: Boot đọc các property đó, áp vào builder, đúc ra một mapper đúng ý bạn. Rồi thư viện PDF kia bước vào, mang theo bean ObjectMapper của riêng nó. Đến lượt Boot vào bếp: nhìn sổ - có rồi - rút. Toàn bộ đường ống spring.jackson.* → builder → mapper không chạy nữa, vì người duy nhất đọc mấy property đó là món ăn Boot vừa cất đi. Property của bạn vẫn nằm trong file, vẫn được nạp vào Environment, vẫn thắng mọi vòng phân định của bài trước - chỉ là không còn ai đọc. Một cụm config chết không phải vì bị ghi đè, mà vì độc giả duy nhất của nó đã rút lui.

Không có bug ở đâu cả - lần cuối cùng mình được nói câu này. Thư viện kia có quyền khai bean. Boot rút lui đúng hợp đồng đã in trên annotation. Cơ chế “chỉ nấu nếu chưa ai nấu” chính là thứ làm Boot dùng được: nó là lý do bạn override được bất kỳ mặc định nào chỉ bằng cách khai bean của mình - không cần file XML, không cần cờ tắt. Mọi mặc định của Boot đều là mặc định-biết-nhường. Cái giá của đức tính đó: sự nhường nhịn diễn ra trong im lặng, và hành vi của app trở thành hàm số của sự có mặt - thêm một jar là đổi kết quả bầu cử, dù jar đó không chạy một dòng nào của bạn.

Một chuyện phải nói trước khi bạn đi dựng lại hiện trường trên máy mình, vì chỗ này đổi theo version - và nó đổi đúng cái diễn viên, không đổi cái cơ chế. Vụ án trên chạy trên Boot 3.x, nơi món Boot dọn sẵn tên là ObjectMapper. Từ Boot 4 (Jackson 3), món đó đổi tên: Boot dọn sẵn JsonMapper và XmlMapper, và tài liệu migration nói thẳng rằng khai một bean ObjectMapper không còn đủ để giành quyền nữa - muốn giành thì phải khai JsonMapper hoặc XmlMapper. Nghĩa là nếu bạn đang chạy Boot 4 và muốn tự tay tái hiện vụ án này, hãy khai một @Bean JsonMapper; khai ObjectMapper thì Boot chẳng buồn rút lui, và bạn sẽ ngồi tự hỏi tại sao config của mình vẫn sống. Cùng một cỗ máy, khác cái tên món ăn. (Cùng lúc đó Jackson2ObjectMapperBuilderCustomizer đổi thành JsonMapperBuilderCustomizer, và một số property đổi chỗ, ví dụ spring.jackson.read.* thành spring.jackson.json.read.*. Boot 4 có spring.jackson.use-jackson2-defaults=true làm lối thoát tạm trong lúc bạn di chuyển.)

Fix cho vụ Jackson thì ngắn: tự khai cái mapper mà Boot đang dọn sẵn - ObjectMapper trên Boot 3.x, JsonMapper từ Boot 4 - hoặc dùng cái customizer tương ứng, cách văn minh hơn vì nó cộng vào thay vì thay thế; hoặc cô lập cái mapper của thư viện kia bằng cách cấp riêng cho nó. Nhưng fix không phải phần đắt của bài - phần đắt là công cụ nhìn thấy cuộc rút lui: chạy app với cờ --debug, và Boot in ra CONDITIONS EVALUATION REPORT - bản tường trình đầy đủ: auto-config nào đã chạy, auto-config nào rút lui, và rút lui vì thấy ai. Vụ án của thằng em nằm trong đó, trắng đen, dưới mục Negative matches, cạnh cái tên jacksonObjectMapper - method dựng ObjectMapper của Boot. Nguyên văn dòng lý do, để bạn grep ra được trong báo cáo thật chứ không phải một bản diễn nôm:

@ConditionalOnMissingBean (types: com.fasterxml.jackson.databind.ObjectMapper;
SearchStrategy: all) found beans of type
'com.fasterxml.jackson.databind.ObjectMapper' objectMapper

Để ý nó không viết chữ nào kiểu “tôi rút lui”. Nó viết đúng cái điều kiện đã trượt và tên của bean làm nó trượt - objectMapper, cái bean mà thư viện PDF mang vào. Cả vụ án nằm trong một dòng, chỉ là dòng đó viết bằng ngôn ngữ của điều kiện chứ không phải ngôn ngữ của hậu quả.

Và bạn không bắt buộc phải chạy --debug rồi soi bằng mắt: cùng cái biên bản đó lấy được bằng code, qua ConditionEvaluationReport.get(beanFactory), in ra đúng một dòng mình cần. Nghĩa là câu hỏi “auto-config X có còn chạy không” biến được thành một cái test - thứ sẽ tự kêu vào ngày ai đó thêm một jar, thay vì đợi tới lúc có người nhận ra ảnh trong PDF sai định dạng ngày. Cỗ máy im lặng, nhưng nó giữ biên bản - chỉ cần bạn biết đường đòi xem.

Còn cặp bài trùng - và đây là cái bẫy cho người đã hiểu bài hôm nay: học được chiêu @ConditionalOnMissingBean hay quá, đem dùng trong chính code mình. Hai config nội bộ của team, mỗi cái khai một bean cùng loại, một cái đội @ConditionalOnMissingBean “cho lành”. Chạy được. Rồi một hôm đổi tên package, hoặc build trên máy khác - hành vi lật ngược. Vì cái điều-kiện-nhìn-sổ chỉ đáng tin khi kẻ nhìn được đảm bảo nhìn sau cùng - auto-config của Boot được đảm bảo điều đó bằng cơ chế nấu-sau; còn giữa các config của bạn với nhau, không ai hứa ai nấu trước ai. Điều kiện “đã có ai nấu chưa?” hỏi vào lúc sổ mới ghi một nửa là câu hỏi nhận câu trả lời ngẫu nhiên. Chính docs của Boot phải in một dòng khuyến cáo hiếm hoi: annotation này chỉ nên dùng trong auto-configuration. Bài học gói lại: nấu-sau và chỉ-nấu-nếu-thiếu là một cặp - tách đôi ra, mỗi nửa đều thành bom.

Nguyên lý, thứ sống lâu hơn Boot: một hệ thống có mặc định-biết-nhường thì hành vi của nó được quyết bởi danh sách những-ai-có-mặt - và mọi thay đổi về sự có mặt đều là thay đổi về hành vi, kể cả khi không ai viết thêm dòng code nào. Service loader nhặt implementation từ classpath. Plugin system đổi tính năng theo thư mục plugins. PATH đổi lệnh python nghĩa là gì theo thứ được cài. Thêm-một-thứ-vào chưa bao giờ là hành động trung lập.

Thằng em đọc xong, gửi đúng cái câu mình chờ:

Boot là một bàn tiệc dọn sẵn, món nào cũng tự cất đi khi thấy mình thừa.

thang em Vậy là ba cái ‘lời mời sẵn’ anh kể suốt từ mùa một - proxy, transaction, OSIV - với vụ Jackson hôm nay, là một cỗ máy duy nhất: một đống mặc định xếp hàng sau cùng, mỗi cái tự hỏi ‘có ai làm chưa?’ rồi tự quyết nấu hay rút. Boot không phải framework. Boot là một bàn tiệc dọn sẵn, món nào cũng tự cất đi khi thấy mình thừa - và nghề của em là biết đọc cái biên bản ai-đã-cất-món-nào.

Đúng rồi đó. Em vừa mô tả chính xác thứ mà cả ngành gọi là convention over configuration - bằng ngôn ngữ của người đã hiểu nó chạy bằng gì. Còn một bài. Bài cuối. Và bài cuối thì không phải mình kể - em kể. 😃

Tài nguyên tập 20

Cheat-sheet tập 20 Toàn bộ cơ chế trên một trang - in ra hoặc lưu về máy được. Source demo - tập 5.3JDK 17. mvn spring-boot:run → Episode53Runner in khối [EP 5.3]: hai context con, một cái để Boot tự nấu ObjectMapper, một cái có thư viện khai sẵn @Bean của nó.

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

Bài này trả lời

  • Một bean tự xuất hiện (hoặc tự biến mất) mà bạn không khai báo
Bản in
Chia sẻ: Facebook X LinkedIn Email
Nhận bài mới qua email
Powered by follow.it