Điều tra lỗi NullPointerException do xung đột quét Annotation trong Spring MVC

Trong các hệ thống sử dụng Spring MVC, annotation (chú thích) đóng vai trò quan trọng giúp tối ưu hóa cấu hình và tăng tốc độ phát triển. Các annotation phổ biến như @Controller, @Service, hay @Autowired xuất hiện ở hầu hết mọi module. Tuy nhiên, việc sử dụng chúng một cách thiếu kiểm soát, đặc biệt là khi kết hợp giữa cấu hình XML và quét component (component scanning), có thể dẫn đến những lỗi logic khó phát hiện, điển hình là lỗi NullPointerException (NPE) do cơ chế khởi tạo Bean chồng chéo.

Phát sinh sự cố

Vấn đề nảy sinh khi triển khai một dịch vụ gọi API bên ngoài thông qua HTTP. Dịch vụ này đã vượt qua các bài kiểm tra đơn vị (unit test) tại máy cục bộ nhưng lại gây ra lỗi khi chạy trên môi trường staging. Bản tin lỗi chi tiết như sau:

ERROR [qtp-thread-1] 2024-05-10 10:20:05.123 c.v.tech.utils.HttpClientHelper.executeRequest(HttpClientHelper.java:120) 
  Invocation error, target url: null/v1/api/data?query=test
  org.apache.http.ProtocolException: Target host is not specified
  ...

Dựa trên log, nguyên nhân trực tiếp là địa chỉ URL của API bị null. Điều này rất kỳ lạ vì giá trị URL đã được định nghĩa và tiêm (inject) thông qua tệp cấu hình XML. Qua kiểm tra thực nghiệm, lỗi chỉ xảy ra khi lớp RemoteDataProvider được đánh dấu bằng annotation @Service. Nếu gỡ bỏ annotation này, ứng dụng hoạt động bình thường.

Phân tích nguyên nhân

Khi kiểm tra log khởi động của Spring, một thông báo ghi đè Bean (bean overriding) xuất hiện:

INFO [main] o.s.b.f.s.DefaultListableBeanFactory.registerBeanDefinition - 
  Overriding bean definition for bean 'remoteDataProvider': 
  replacing [Generic bean: class [com.v.tech.service.impl.RemoteDataProvider]; defined in file [RemoteDataProvider.class]] 
  with [Generic bean: class [com.v.tech.service.impl.RemoteDataProvider]; defined in class path resource [services-config.xml]]

Thông thường, việc ghi đè này không gây lỗi nếu kết quả cuối cùng là Bean được cấu hình đầy đủ từ XML. Tuy nhiên, trong thực tế, hệ thống dường như đang sử dụng một thực thể (instance) khác không có dữ liệu.

Sử dụng công cụ jmap để kiểm tra số lượng thực thể của lớp RemoteDataProvider đang tồn tại trong bộ nhớ:

$ jmap -histo:live [PID] | grep RemoteDataProvider
120:             2             128  com.v.tech.service.impl.RemoteDataProvider

Kết quả cho thấy có tới 2 instance của lớp này, mặc dù theo mặc định Spring Bean là Singleton. Việc phân tích Heap Dump bằng công cụ MAT (Memory Analysis Tool) hoặc jhat cho thấy:

  • Instance 1: Có đầy đủ các thuộc tính endpointUrl, apiKey... được nạp từ XML.
  • Instance 2: Tất cả các thuộc tính đều là null hoặc giá trị mặc định.

Truy vết tham chiếu (Reference Chain) của hai đối tượng này dẫn đến hai Context khác nhau:

  1. Instance 1 (đầy đủ dữ liệu) thuộc về XmlWebApplicationContext được khởi tạo bởi ContextLoaderListener (Root Context).
  2. Instance 2 (dữ liệu rỗng) thuộc về XmlWebApplicationContext được khởi tạo bởi DispatcherServlet (Servlet Context).

Cơ chế phân cấp WebApplicationContext

Trong Spring MVC, ContextLoaderListener tạo ra Root Context chứa các Bean dùng chung như Service và Repository. DispatcherServlet tạo ra một Child Context chứa các Bean liên quan đến Web như Controller. Child Context có thể truy cập Bean của Root Context, nhưng ngược lại thì không.

Nguyên nhân gốc rễ nằm ở tệp web.xml và cấu hình quét component:

  • Trong applicationContext.xml (Root), lệnh quét <context:component-scan base-package="com.v.tech" /> được kích hoạt, đồng thời Bean cũng được định nghĩa lại bằng XML để tiêm giá trị.
  • Trong spring-servlet.xml (Child), lệnh quét <context:component-scan base-package="com.v.tech" /> cũng được sử dụng mà không có bộ lọc.

Do DispatcherServlet cũng quét qua gói com.v.tech, nó tự tạo thêm một instance của RemoteDataProvider dựa trên annotation @Service. Khi Controller yêu cầu Bean này, nó sẽ ưu tiên lấy từ chính Child Context của nó. Tuy nhiên, instance trong Child Context này chỉ được tạo thô bằng annotation mà không có các giá trị cấu hình từ XML, dẫn đến NullPointerException.

Giải pháp khắc phục

Để giải quyết vấn đề, cần tách biệt phạm vi quét của Root Context và Child Context bằng cách sử dụng include-filterexclude-filter.

Cấu hình cho spring-servlet.xml (chỉ quét Controller):

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

Cấu hình cho applicationContext.xml (quét Service và Repository, loại bỏ Controller):

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

Bằng cách này, mỗi Context chỉ quản lý đúng các thành phần thuộc trách nhiệm của nó. RemoteDataProvider sẽ chỉ tồn tại duy nhất một instance trong Root Context với đầy đủ các tham số được tiêm từ XML.

Bài học rút ra

  1. Cần hiểu rõ mối quan hệ Cha-Con (Parent-Child) giữa ContextLoaderListenerDispatcherServlet để tránh khởi tạo dư thừa.
  2. Khi sử dụng component-scan, luôn xác định rõ phạm vi quét (base-package) và sử dụng các bộ lọc để tránh xung đột giữa cấu hình XML và Annotation.
  3. Việc Unit Test thành công không đảm bảo ứng dụng chạy đúng trong môi trường Web do sự khác biệt về cách nạp Context.

Thẻ: Spring MVC Spring Framework WebApplicationContext Dependency Injection Java

Đăng vào ngày 9 tháng 7 lúc 03:58