Thứ 6 tuần trước mình và anh Hoài (CTO) ở lại công ty tận 7 rưỡi tối để hỗ trợ tracing bug và xử lý gấp incident liên quan cùng đồng nghiệp trong nhóm.
Thấy ông anh đồng nghiệp rơi vào bế tắc vì hướng tiếp cận vấn đề không đúng, mình có đề nghị tracing bug theo một hướng khác, sau một hồi thuyết phục mãi thì ông anh kia cũng chịu đi theo hướng mình đề nghị và đã thừa nhận lỗi.
Lỗi này có liên quan đến việc xử lý logic ở lớp Filter trong dự án Spring Boot.
Mục Lục
Lỗi khi xử lý Filter ở đây là gì
Đơn giản như này, thi thoảng phía mobile app không gửi thông tin liên quan đến device-id (có thể là null, có thể là rỗng) xuống cho backend, thành ra khi khách hàng thực hiện đăng kí thiết bị và trust-device thì không thể nào thực hiện được vì logic phía dưới Backend đang cần có 1 cặp thông tin device-id
+ device-code
để xử lý các tác vụ khác.
Cái này thì phía mobile app khả năng cũng chưa điều tra ra được nguyên nhân gốc, nhưng thôi phía Backend cũng đã tìm cách hỗ trợ work around trước bằng một giải pháp tạm thời để khách hàng họ có thể login lại được, tránh những trải nghiệm xấu của khách hàng.
Giải pháp là trong những trường hợp nhận được device-id = null
hoặc device-id =''
từ phía mobile, Backend sẽ thực hiện ghi đè giá trị device-to
ken vào device-id
, device-token
thì ít có khả năng bị null hoặc rỗng rồi, còn nếu device-token mà rỗng nữa thì thôi mobile app tự đi mà sửa.

Cách xử lý của đồng nghiệp mình đó là chỉ ghi đè lại phương thức getHeader(String name)

Oke giờ phân tích một chút cái đoạn code xử lý này, nhìn qua thì CÓ VẺ LÀ ỔN
, hãy cùng phân tích xem nó làm những gì?
- Lấy giá trị của
device-id
vàdevice-token
từ request gốc - Kiểm tra nếu
device-id
là rỗng, tạo một HttpServletRequestWrapper bao bọc request gốc - Bên trong wrapper, xử lý ghi đè lại phương thức
getHeader(String name)
- Truyền wrappedRequest vào chain.doFilter
- Nếu
device-id
không rỗng, truyền request gốc vàochain.doFilter
Logic ghi đè ở bước 3 nghĩa là nếu ai đó hỏi header có tên là device-id
thì trả về giá trị từ device-token
, ngược lại sẽ gọi super.getHeader(name)
để lấy giá trị từ request gốc.
Và sau đấy thì là Controller chính dẫn tới việc bị lỗi, mình đính kèm vào một controller example thôi, tuy nhiên controller gốc cũng gần tương tự, nhưng mình không muốn chia sẻ main controller.

Giờ sẽ chạy project lên lên và check kết quả xem thế nào nhé


Chúng ta sẽ debug ở Filter sau đó sẽ tới Controller

Theo như ảnh thì ban đầu request gốc có device-id
rỗng, sau quá trình override thì giá trị device-id
ở trong wrappedRequest đã được lấy bằng với device-token
.
Tiếp theo chúng ta debug vào Controller, thì thấy rằng giá trị deviceId vẫn bị rỗng

Tạm chưa nói đến việc device-id
lấy từ @RequestHeader tại sao lại rỗng nhé, mình sẽ thêm một đoạn code nữa để lấy trực tiếp device-id
và device-token
từ HttpServletRequest
để có thêm bằng chứng.


Các bạn đã nhìn ra sự khác biệt chưa? giá trị deviceId
được lấy từ annotation @RequestHeader
bị rỗng, còn giá trị deviceId
lấy từ HttpServletRequest
thì lấy được giá trị, và giá trị đó chính bằng device-token
ở trong logic Filter.
Vậy thì tại sao lại có sự khác biệt như vậy, nguyên nhân là do đâu?
Nguyên nhân cốt lõi gây ra lỗi
Nguyên nhân gốc rễ nằm ở cách Spring MVC xử lý việc phân giải giá trị cho tham số được đánh dấu bằng annotation @RequestHeader
so với việc gọi trực tiếp phương thức request.getHeader().
Khi gọi request.getHeader() trực tiếp

Trong đoạn code này chúng ta đang thực thi phương thức getHeader trên chính đối tượng request
mà Controller nhận được, và request đó ở đây chính là đối tượng wrappedRequest
đã được xử lý ở lớp Filter (được truyền vào ở đoạn chain.doFilter )
Logic ghi đè đã được áp dụng, nên chúng ta nhận được giá trị device-id bằng với device-token như mong muốn.
Khi Spring MVC xử lý @RequestHeader(“device-id”)
Còn khi sử dụng anntotation @RequestHeader thì Spring MVC không chỉ đơn giản gọi request.getHeader()
để lấy giá trị, nó sử dụng một cơ chế linh hoạt và mạnh mẽ hơn gọi là HandlerMethodArgumentResolver.
Cụ thể cho @RequestHeader
là RequestHeaderMethodArgumentResolver

RequestHeaderMethodArgumentResolver
cần thực hiện nhiều việc hơn là chỉ lấy giá trị của một header, kiểm tra sự tồn tại của header (đặc biệt khi required=true), xử lý trường hợp header có nhiều giá trị, thực hiện chuyển đổi kiểu nếu cần,…
Để làm được điều này, resolver không chỉ dựa vào getHeader(String name) mà nó sẽ sử dụng thêm các phương thức khác của HttpServletRequest
liên quan đến header, bao gồm:
- getHeaders(String name): Trả về một Enumeration<String>, trong trường hợp nếu một header có thể được gửi nhiều lần với các giá trị khác nhau
- getHeaderNames(): Trả về một Enumeration<String> chứa tên của tất cả các header có trong request
Debug một chút vào thẳng trong class HttpServletRequestWrapper
và RequestHeaderMethodArgumentResolver
Trước tiên là đi qua class DeviceIdFilter



Lúc này các bạn sẽ thấy rằng giá trị lấy ra từ hàm getHeaders(String name)
chỉ có mảng với phần tử giá trị rỗng (cho header name là device-id)
Đến đây thì các bạn đã thấy điểm mấu chốt của vấn đề rồi đó, HttpServletRequestWrapper ban đầu trong phần logic Filter thì chỉ ghi đè phương thức getHeader(String name)
thôi, và đang không hề ghi đè hai phương thức getHeaders(String name)
và getHeaderNames()
.
Khi Resolver gọi getHeaders()
hoặc getHeaderNames()
trên đối tượng wrappedRequest này, do không được ghi đè nên lời gọi hàm sẽ được chuyển tiếp về request gốc (super.getHeaders(), super.getHeaderNames()).

Và kết quả là Spring MVC lấy thông tin về sự tồn tại và danh sách các header từ request gốc (request gốc nhé, chứ không phải wrappedRequest), hoàn toàn “không biết” về logic thay thế giá trị mà chúng ta đã định nghĩa riêng ở trong hàm getHeader(String name)
.
Thành ra là @RequestHeader
không phản ánh được sự thay đổi và đang trả về giá trị rỗng ban đầu của request gốc khi ta truyền vào ở Postman.
Cách xử lý
Oke vấn đề đã được phát hiện chính xác, giờ chúng ta sẽ xử lý bằng cách là ghi đè thêm hai phương thức getHeaders
và getHeaderNames
.
@Override
public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain)
throws IOException, ServletException {
HttpServletRequest httpRequest = (HttpServletRequest) request;
String deviceId = httpRequest.getHeader(DEVICE_ID);
String deviceToken = httpRequest.getHeader(DEVICE_TOKEN);
log.info("DeviceIdFilter executing. Original device-id: '{}', device-token: '{}'", deviceId, deviceToken);
if (StringUtils.isBlank(deviceId)) {
HttpServletRequestWrapper wrappedRequest = new HttpServletRequestWrapper(httpRequest) {
@Override
public String getHeader(String name) {
if (DEVICE_ID.equalsIgnoreCase(name)) {
return deviceToken;
}
return super.getHeader(name);
}
@Override
public Enumeration<String> getHeaders(String name) {
if (DEVICE_ID.equalsIgnoreCase(name)) {
return Collections.enumeration(Collections.singletonList(deviceToken));
}
return super.getHeaders(name);
}
@Override
public Enumeration<String> getHeaderNames() {
var names = Collections.list(super.getHeaderNames());
if (!names.contains(DEVICE_ID)) {
names.add(DEVICE_ID);
}
return Collections.enumeration(names);
}
};
var newDeviceId = wrappedRequest.getHeader(DEVICE_ID);
var newDeviceToken = wrappedRequest.getHeader(DEVICE_TOKEN);
chain.doFilter(wrappedRequest, response);
} else {
chain.doFilter(request, response);
}
}
Xong khởi động lại dự án và debug vào Controller xem sao nhé, vẫn truyền những tham số device-id, device-code, device-token vào header request ở Postman như đầu bài viết.


Lúc này biến device-id
đã lấy giá trị chính xác theo device-token
rồi đó, vấn đề đã được giải quyết hoàn toàn.
Giải pháp khác
Có một số phương án khác để giải quyết vấn đề trong bài viết, nhưng cách tiếp cận không dễ, và một số phương án sẽ có những pros and cons nhất định khi triển khai, nhưng mình vẫn cứ đưa ra một số phương án để mọi người cùng nắm và có thêm góc nhìn khác nhé.
PA 1: Xử lý logic trong Controller


- Ưu điểm: Rất đơn giản, không cần Filter phức tạp, không cần Wrapper, không cần cấu hình Spring MVC đặc biệt, dễ hiểu
- Nhược điểm: Logic xử lý header bị “rò rỉ” ở tầng Controller, nếu nhiều endpoint cần logic này, các bạn sẽ phải lặp lại code hoặc tạo một phương thức helper, nó không thực sự “sửa” hành vi của @RequestHeader
PA 2: Custom lại HandlerMethodArgumentResolver
Tạo một annotation @DeviceId
để custom riêng cho logic xác định device-id
import java.lang.annotation.Documented;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
import org.springframework.core.annotation.AliasFor;
@Target(ElementType.PARAMETER)
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface DeviceId {
@AliasFor("name")
String value() default "";
@AliasFor("value")
String name() default "";
}
Sau đó cần tạo một class mới implement HandlerMethodArgumentResolver
, trong phương thức supportsParameter, kiểm tra xem tham số có phải là String và có annotation @DeviceId("device-id")
hay không
import javax.servlet.http.HttpServletRequest;
import org.springframework.core.MethodParameter;
import org.springframework.web.bind.support.WebDataBinderFactory;
import org.springframework.web.context.request.NativeWebRequest;
import org.springframework.web.method.support.HandlerMethodArgumentResolver;
import org.springframework.web.method.support.ModelAndViewContainer;
public class DeviceIdArgumentResolver implements HandlerMethodArgumentResolver {
@Override
public boolean supportsParameter(MethodParameter parameter) {
var headerAnnotation = parameter.getParameterAnnotation(DeviceId.class);
return parameter.getParameterType().equals(String.class)
&& headerAnnotation != null
&& "device-id".equalsIgnoreCase(headerAnnotation.value());
}
@Override
public Object resolveArgument(MethodParameter parameter,
ModelAndViewContainer mavContainer,
NativeWebRequest webRequest,
WebDataBinderFactory binderFactory) {
var request = webRequest.getNativeRequest(HttpServletRequest.class);
var deviceId = request.getHeader("device-id");
var deviceToken = request.getHeader("device-token");
return (deviceId == null || deviceId.isBlank()) ? deviceToken : deviceId;
}
}
Đăng ký Resolver trong WebMvcConfigurer
import java.util.List;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.method.support.HandlerMethodArgumentResolver;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
@Configuration
public class WebConfig implements WebMvcConfigurer {
@Override
public void addArgumentResolvers(List<HandlerMethodArgumentResolver> resolvers) {
resolvers.add(new DeviceIdArgumentResolver());
}
}
Sau đó sử dụng trong Controller và debug vào để xem kết quả
@PostMapping("/why-miss-header")
public ResponseEntity<Object> testMissingDeviceId(
@DeviceId("device-id") String deviceId,
@RequestHeader("device-code") String deviceCode) {
return ResponseEntity.ok("cafeincode");
}

Cách này cũng hay đấy tuy nhiên là nó gây mất tính nhất quán trong team, trong dự án, người khác/người mới có thể sẽ không biết hết về logic bạn tự customize giá trị device-id
, xong vẫn sử dụng annotation @RequestHeader
sẽ khiến logic bị sai, cũng nên phải cân nhắc khá kĩ.
PA 3: Sử dụng HandlerInterceptor và Request Attributes
Ở cách này thì thay vì xử lý device-id
ở controller bằng @RequestHeader
, chúng ta sẽ dùng một HandlerInterceptor
với mục đích để:
- Tính toán giá trị
effectiveDeviceId
từ 2 header:device-id
vàdevice-token
- Gắn nó vào
HttpServletRequest
dưới dạng attribute
Đầu tiên tiên tạo một class DeviceIdInterceptor
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import org.springframework.stereotype.Component;
import org.springframework.web.servlet.HandlerInterceptor;
@Component
public class DeviceIdInterceptor implements HandlerInterceptor {
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) {
var deviceId = request.getHeader("device-id");
var deviceToken = request.getHeader("device-token");
var effectiveDeviceId = (deviceId == null || deviceId.isBlank()) ? deviceToken : deviceId;
request.setAttribute("effectiveDeviceId", effectiveDeviceId);
return true;
}
}
Đăng ký Interceptor này với Spring
import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
@Configuration
public class WebConfig implements WebMvcConfigurer {
private final DeviceIdInterceptor deviceIdInterceptor;
public WebConfig(DeviceIdInterceptor deviceIdInterceptor) {
this.deviceIdInterceptor = deviceIdInterceptor;
}
@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(deviceIdInterceptor)
.addPathPatterns("/**");
}
}
Sau đó chỉ cần sử dụng trong Controller
@PostMapping("/why-miss-header")
public ResponseEntity<Object> testMissingDeviceId(
@RequestAttribute("effectiveDeviceId") String effectiveDeviceId,
@RequestHeader("device-code") String deviceCode) {
return ResponseEntity.ok("cafeincode");
}
Các bạn start project lên xong rồi debug vào class DeviceIdInterceptor nhé


Cách này theo mình có một số ưu điểm sau:
- Logic xác định
effectiveDeviceId
(giữadevice-id
vàdevice-token
) được đóng gói ở interceptor, controller không cần xử lý nữa - Interceptor nằm giữa filter và controller, phù hợp để xử lý các logic chung cho mọi request (giống như logging, auth, device…)
- Không cần tạo annotation mới, dùng
@RequestAttribute
có sẵn trong Spring
Ngoài ra, một số nhược điểm của phương án này:
- Yêu cầu phải nhớ đặt
@RequestAttribute
thay vì@RequestHeader
, nếu dev khác không biết, dễ nhầm và lấy sai giá trị - Nếu bạn chỉ cần xử lý 1-2 API đơn lẻ, dùng Interceptor có thể bị overkill, lúc này viết thẳng trong controller hoặc dùng hàm helper lợi hơn
- Có thể bị override hoặc conflict nếu có nhiều Interceptor hoặc Filter khác nhau
PA 4: Đặt giá trị device-id vào MDC
Mục tiêu của phương án này là sử dụng MDC
để lưu effectiveDeviceId
cho mỗi request, giúp dễ dàng log, trace, hoặc sử dụng trong controller/service mà không cần truyền thủ công qua từng layer.
Trước tiên chúng ta sẽ triển khai Filter
hoặc HandlerInterceptor
để set MDC
import java.io.IOException;
import javax.servlet.FilterChain;
import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import org.slf4j.MDC;
import org.springframework.stereotype.Component;
import org.springframework.web.filter.OncePerRequestFilter;
@Component
public class DeviceIdMDCFilter extends OncePerRequestFilter {
private static final String MDC_DEVICE_ID_KEY = "effectiveDeviceId";
@Override
protected void doFilterInternal(HttpServletRequest request,
HttpServletResponse response,
FilterChain filterChain)
throws ServletException, IOException {
try {
var deviceId = request.getHeader("device-id");
var deviceToken = request.getHeader("device-token");
var effectiveDeviceId = (deviceId == null || deviceId.isBlank()) ? deviceToken : deviceId;
MDC.put(MDC_DEVICE_ID_KEY, effectiveDeviceId);
filterChain.doFilter(request, response);
} finally {
MDC.remove(MDC_DEVICE_ID_KEY);
}
}
}
Sau đó sử dụng trực tiếp trong Controller
@PostMapping("/why-miss-header")
public ResponseEntity<Object> testMissingDeviceId(@RequestHeader("device-code") String deviceCode) {
var deviceId = MDC.get("effectiveDeviceId");
return ResponseEntity.ok("cafeincode");
}


Tuy nhiên cách sử dụng MDC không phải là một best practices trong trường hợp này, MDC được thiết kế chủ yếu cho logging, nó giúp chúng ta tự động thêm thông tin ngữ cảnh vào mọi dòng log được tạo ra bởi luồng xử lý request đó.
Các bạn phải rất cẩn thận trong việc put và remove giá trị khỏi MDC để tránh các vấn đề về context trong môi trường đa luồng vì MDC là thread-local, dễ xảy ra rò rỉ nếu không được clear đúng cách.
Khi nhìn vào Controller, ta thấy MDC.get("effectiveDeviceId")
sẽ không rõ ràng bằng việc thấy @RequestHeader (chỉ rõ nó từ header) hay @RequestAttribute (chỉ rõ nó từ attribute được đặt trước đó trong chuỗi xử lý).
Người khác đọc vào code sẽ cần phải biết rằng có một Filter/Interceptor nào đó đã đặt giá trị này vào MDC.
Xem thêm các bài viết nổi bật bên dưới:
- Những thói quen tốt trong ngành phát triển phần mềm
- Tôi đã hoàn thành cự ly Half Marathon Long Biên 2024
- Lần đầu chinh phục thành công Half Marathon
- Phỏng vấn dạo kĩ sư phần mềm 2023
- Viết code với những thói quen tốt này sẽ giúp bạn giỏi hơn
- Kĩ năng quản lý căng thẳng cho Developer
- Làm việc trong môi trường Agile là như thế nào
- Là kĩ sư phần mềm hãy cố gắng giữ gìn sức khỏe bản thân
- Bạn không giỏi lắng nghe như bạn nghĩ đâu
- Rest API: Cách Ngăn Chặn Duplicate Request Hiệu Quả
- Top AI Assistants for Coding That I Use
- Đi phỏng vấn để biết mình tìm kiếm điều gì