Bối cảnh sự cố
Trong quá trình phát triển ứng dụng Web với Spring MVC, các annotation như @Controller, @Service, @Autowired đã trở thành một phần không thể thiếu. Tuy nhiên, việc sử dụng thiếu cân nhắc có thể dẫn đến những lỗi khó lường. Bài viết này ghi lại quá trình điều tra một lỗi NullPointerException gây ra bởi sự xung đột giữa cấu hình annotation và XML.
Phân tích triệu chứng
Sau khi triển khai một service wrapper cho HTTP API upstream, hệ thống báo lỗi khi gọi đến endpoint:
org.apache.http.ProtocolException: Target host is not specified
at org.apache.http.impl.client.InternalHttpClient.doExecute(InternalHttpClient.java:186)
Thông báo lỗi cho thấy URL request không hợp lệ. Điều kỳ lạ là URL đã được inject qua file XML và unit test tại local vẫn chạy bình thường.
Sau nhiều lần thử nghiệm, phát hiện ra rằng: khi thêm annotation
@Service lên class
QueryPartnerImpl, lỗi xuất hiện. Khi gỡ bỏ annotation, ứng dụng hoạt động bình thường.
Truy vết nguyên nhân
Bước 1: Kiểm tra log khởi động
Log cho thấy bean bị ghi đè:
Overriding bean definition for bean 'queryPartnerImpl':
replacing [Generic bean: class [QueryPartnerImpl]...defined in file [...class]]
with [Generic bean: class [QueryPartnerImpl]...defined in class path resource [sal/service-outer.xml]]
Spring ghi đè bean được định nghĩa bằng annotation bằng bean được định nghĩa trong XML. Điều này vẫn hợp lệ và không gây lỗi.
Bước 2: Kiểm tra instance trong JVM
Sử dụng công cụ jmap để kiểm tra:
$ jmap -histo:live 20881 | grep QueryPartnerImpl
1354: 2 80 com.example.QueryPartnerImpl
Phát hiện quan trọng: có
2 instance của QueryPartnerImpl trong hệ thống, trái ngược với nhận thức "Spring Bean mặc định là singleton".
Bước 3: Phân tích heap dump
Dump heap và phân tích bằng MAT (Memory Analyzer Tool):
$ jmap -dump:format=b,file=/tmp/heap.bin 20881
$ jhat /tmp/heap.bin
Kết quả phân tích hai instance:
Instance 1 (0x6c41b6f80):
- url: "http://test.url.example.com/"
- clientId: "trip_trade"
- clientSecret: "6ee952489a93b51b1ffcadd040ca562e"
Instance 2 (0x7aeafac20):
- url: null
- clientId: null
- clientSecret: null
Instance thứ 2 có tất cả thuộc tính là null - đây chính là nguyên nhân gây ra NullPointerException.
Bước 4: Truy vết tham chiếu đối tượng
Phân tích chuỗi tham chiếu bằng MAT:
Instance 1 được tham chiếu bởi:
ContextLoaderListener -> XmlWebApplicationContext
Instance 2 được tham chiếu bởi:
DispatcherServlet -> XmlWebApplicationContext
Đây chính là chìa khóa của vấn đề!
Nguyên nhân gốc rễ
Trong Spring MVC, tồn tại hai WebApplicationContext:
- Root WebApplicationContext: được tạo bởi ContextLoaderListener, chứa các bean chung như service, repository, datasource.
- Servlet WebApplicationContext: được tạo bởi DispatcherServlet, chứa các bean liên quan đến web như controller, view resolver.
Mối quan hệ giữa chúng là
parent-child. Servlet context có thể truy cập bean từ parent context, nhưng không ngược lại.
Cơ chế xảy ra sự cố
Cấu hình trong
spring-servlet.xml:
<context:component-scan base-package="com.example" />
Cấu hình trong
applicationContext.xml:
<context:component-scan base-package="com.example" />
<import resource="sal/service-outer.xml" />
Trong
service-outer.xml định nghĩa bean queryPartnerImpl với đầy đủ property.
Quy trình xảy ra:
- ContextLoaderListener khởi tạo Root Context, quét và tạo bean từ @Service (với property null), sau đó ghi đè bằng định nghĩa XML (với property đầy đủ).
- DispatcherServlet khởi tạo Servlet Context, quét và tạo bean từ @Service (với property null) - nhưng không có định nghĩa XML để ghi đè.
- Controller trong Servlet Context inject bean từ chính context đó - nhận instance với property null.
Giải pháp
Phương án 1: Tách biệt phạm vi quét
Cấu hình
spring-servlet.xml chỉ quét Controller:
<context:component-scan base-package="com.example"
use-default-filters="false">
<context:include-filter type="annotation"
expression="org.springframework.stereotype.Controller" />
</context:component-scan>
Cấu hình
applicationContext.xml quét tất cả ngoại trừ Controller:
<context:component-scan base-package="com.example">
<context:exclude-filter type="annotation"
expression="org.springframework.stereotype.Controller" />
</context:component-scan>
Phương án 2: Đồng nhất phương thức inject
Nếu sử dụng annotation để định nghĩa bean, hãy dùng
@Value hoặc
@ConfigurationProperties để inject property:
@Service
public class QueryPartnerImpl implements QueryPartner {
@Value("${api.url}")
private String url;
@Value("${api.clientId}")
private String clientId;
@Value("${api.clientSecret}")
private String clientSecret;
}
Bài học kinh nghiệm
- Hiểu rõ kiến trúc parent-child container trong Spring MVC để tránh duplicate bean.
- Phân tách rõ ràng phạm vi component-scan giữa các context.
- Duy trì nhất quán trong phương pháp cấu hình: nếu dùng annotation thì nên inject property cũng bằng annotation.
- Unit test thường chỉ load một context nên không phát hiện được vấn đề này.