Sự cố NullPointerException do xung đột Bean giữa Root và Child Context trong Spring MVC

Bối cảnh sự cố

Trong quá trình phát triển ứng dụng Web với Spring MVC, việc sử dụng các annotation như @Service, @Controller giúp loại bỏ nhiều cấu hình XML cồng kềnh. Tuy nhiên, nếu không hiểu rõ cơ chế hoạt động của chúng kết hợp với vòng đời container, chúng ta rất dễ rơi vào những lỗi runtime khó lường.

Sự cố bắt đầu khi cần tích hợp một API bên ngoài. Lớp ExternalApiConnector được triển khai để gọi HTTP request, và mọi thứ hoạt động hoàn hảo ở môi trường unit test cục bộ. Tuy nhiên, sau khi triển khai lên server test, ứng dụng lập tức ném ra NullPointerException với thông báo lỗi chỉ ra rằng URL request là null:

ERROR [http-nio-8080-exec-1] HttpClientExecutor.execute - Target host is not specified
org.apache.http.ProtocolException: Target host is not specified

Điều kỳ lạ là giá trị URL đã được khai báo rõ ràng trong file cấu hình XML, và unit test trước đó đã verify thành công. Quá trình kiểm tra tỉ mỉ cho thấy: lỗi chỉ xuất hiện khi lớp ExternalApiConnector được đánh dấu bằng annotation @Service. Nếu gỡ bỏ annotation này, chương trình lại chạy bình thường.

Định vị vấn đề qua Log và Memory Dump

Khi giữ nguyên @Service, log khởi động của Spring tiết lộ một manh mối quan trọng: định nghĩa của Bean đang bị ghi đè (Overriding):

INFO DefaultListableBeanFactory - Overriding bean definition for bean 'externalApiConnector': 
replacing [Generic bean: class [com.example.platform.integration.ExternalApiConnector]] 
with [Generic bean: class [com.example.platform.integration.ExternalApiConnector] 
defined in class path resource [infra/api-connectors.xml]]

Log cho thấy bean tạo bằng @Service đã bị thay thế bởi bean cấu hình từ file XML, nơi mà các thuộc tính (như URL, apiKey) được inject đầy đủ. Theo lý thuyết, việc ghi đè này sẽ giữ lại bean cấu hình đúng, vậy tại sao URL vẫn là null?

Để đào sâu hơn, ta sử dụng công cụ jmap có sẵn của JDK để kiểm tra số lượng instance của ExternalApiConnector thực sự tồn tại trong bộ nhớ JVM:

$ jmap -histo:live 20881 | grep ExternalApiConnector
  154:             2            112  com.example.platform.integration.ExternalApiConnector

Kết quả trả về 2 instance! Điều này vi phạm cơ chế mặc định Singleton của Spring Bean. Tiếp theo, ta dump toàn bộ heap ra file để phân tích:

$ jmap -dump:format=b,file=/tmp/heap_dump.hprof 20881

Sử dụng MAT (Memory Analyzer Tool) hoặc JHat để duyệt file dump, ta lần ra 2 đối tượng ExternalApiConnector và kiểm tra trạng thái thuộc tính của chúng:

Đối tượng 1 (0x5a1b2c3d):

endpoint: "https://api.test.example.com/v1"
apiKey: "e3b0c44298fc1c14"
requestTimeout: 5000

Đối tượng 2 (0x7f8e9d0a):

endpoint: null
apiKey: null
requestTimeout: 0

Rõ ràng, Đối tượng 1 đã được Spring inject thuộc tính thành công từ file XML, trong khi Đối tượng 2 hoàn toàn rỗng (chỉ chứa giá trị default của Java). Ứng dụng đang sử dụng Đối tượng 2, đó là nguyên nhân trực tiếp của NullPointerException.

Truy vết Reference và Nguyên nhân cốt lõi

Bằng cách sử dụng MAT để trace các tham chiếu (incoming references) đến 2 đối tượng này, ta phát hiện ra sự khác biệt then chốt:

  • Đối tượng 1 (đầy đủ thuộc tính) được tham chiếu bởi XmlWebApplicationContext thuộc sở hữu của ContextLoaderListener.
  • Đối tượng 2 (thuộc tính null) được tham chiếu bởi một XmlWebApplicationContext khác, thuộc sở hữu của DispatcherServlet.

Trong kiến trúc Spring MVC chuẩn, ContextLoaderListener khởi tạo một Root WebApplicationContext chứa các Bean nền tảng (Service, DAO, DataSource...). Đồng thời, DispatcherServlet khởi tạo một Child WebApplicationContext chứa các Bean liên quan đến web (Controller, ViewResolver...), và thiết lập Root Context là parent của nó.

Khi một Controller (nằm trong Child Context) yêu cầu Bean ExternalApiConnector, cơ chế tìm kiếm của Spring sẽ quét Child Context trước. Nếu tìm thấy, nó sử dụng ngay mà không cần hỏi Parent Context.

Nguyên nhân sâu xa xuất phát từ file cấu hình web-servlet.xml của DispatcherServlet:

<context:component-scan base-package="com.example.platform" />

Lệnh scan này quét qua toàn bộ package, bao gồm cả lớp ExternalApiConnector có đánh dấu @Service. Kết quả là Child Context tự động tạo ra một Bean mới (Đối tượng 2) mà không hề có cấu hình property nào đi kèm. Trong khi đó, ở Root Context, Bean cũng được tạo bởi @Service nhưng ngay sau đó bị file XML infra/api-connectors.xml ghi đè và inject thuộc tính thành công (Đối tượng 1). Controller lấy Bean từ Child Context, và dĩ nhiên nhận được một Bean "bán thành phẩm" với toàn bộ thuộc tính là null.

Giải pháp cấu hình Component Scan

Để đảm bảo hệ thống chỉ tồn tại một instance của ExternalApiConnector với đầy đủ property, cần phải ngăn chặn Child Context quét các annotation không thuộc về nó. Giải pháp là sử dụng các filter của <context:component-scan>.

Cập nhật lại web-servlet.xml (Child Context) chỉ để quét @Controller:

<context:component-scan base-package="com.example.platform" use-default-filters="false">
    <context:include-filter type="annotation" 
        expression="org.springframework.stereotype.Controller" />
</context:component-scan>

Thuộc tính use-default-filters="false" vô hiệu hóa việc tự động quét các annotation như @Component, @Service, @Repository. Kết hợp với include-filter, ta ép DispatcherServlet chỉ tạo bean từ @Controller.

Đồng thời, trong file root-context.xml (Root Context), cấu hình quét loại trừ @Controller:

<context:component-scan base-package="com.example.platform">
    <context:exclude-filter type="annotation" 
        expression="org.springframework.stereotype.Controller" />
</context:component-scan>

Với sự tách biệt này, các Bean dạng Service/Repository sẽ chỉ được tạo duy nhất trong Root Context và được inject cấu hình XML chính xác, loại bỏ hoàn toàn nguy cơ xung đột Bean giữa hai tầng Context. Trong kiến trúc Spring MVC, ContextLoaderListener lưu trữ Root WebApplicationContext vào ServletContext với key WebApplicationContext.ROOT_WEB_APPLICATION_CONTEXT_ATTRIBUTE. DispatcherServlet lấy Root Context ra làm parent cho Child Context của riêng nó. Child Context có thể tham chiếu lên Parent Context để sử dụng Service Bean, nhưng Parent Context không thể nhìn thấy các Bean nằm trong Child Context, tạo ra một ranh giới phụ thuộc rõ ràng và an toàn.

Thẻ: Spring MVC WebApplicationContext Component Scan Heap Dump Bean Conflict

Đăng vào ngày 30 tháng 9 lúc 10:10