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.

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-token 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ý ban đầu của đồng nghiệp

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

mình thêm hai dòng code này để verify lại ở bước debug

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ì?

  1. Lấy giá trị của device-id và device-token từ request gốc
  2. Kiểm tra nếu device-id là rỗng, tạo một HttpServletRequestWrapper bao bọc request gốc
  3. Bên trong wrapper, xử lý ghi đè lại phương thức getHeader(String name)
  4. Truyền wrappedRequest vào chain.doFilter
  5. Nếu device-id không rỗng, truyền request gốc vào chain.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.

Mô phỏng lại đoạn controller bị lỗi, giá trị deviceId được lấy từ @RequestHeader

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

Start project
Giả lập trường hợp mobile gửi giá trị device-id rỗng, thì sẽ lấy từ device-token (Postman)

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

kết quả debug ở lớp Filter

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

Kết quả deviceId lúc này bị rỗng nè

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-iddevice-token từ HttpServletRequest để có thêm bằng chứng.

thêm 2 đoạn code lấy device-id và device-token từ HttpServletRequest
Kết quả debug sau khi thêm hai đoạn code trên

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

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 HttpServletRequestWrapperRequestHeaderMethodArgumentResolver

Trước tiên là đi qua class DeviceIdFilter

Step 1: bước filter
phương thức getHeaders trong HttpServletRequestWrapper
hàm getHeaderValues trong class RequestHeaderMethodArgumentResolver

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()).

Ở trong Controller chính

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 getHeadersgetHeaderNames.

    @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.

vẫn truyền device-id, device-code, device-token như trước
device-id đã lấy giá trị từ device-token

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");
    }
Using @DeviceId for custom annotation

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-iddevice-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é

DeviceIdInterceptor
giá trị effectiveDeviceId được lấy từ request attribute

Cách này theo mình có một số ưu điểm sau:

  1. Logic xác định effectiveDeviceId (giữa device-iddevice-token) được đóng gói ở interceptor, controller không cần xử lý nữa
  2. 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…)
  3. 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:

  1. 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ị
  2. 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
  3. 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");
    }
debug vào DeviceIdMDCFilter
debug vào controller, deviceId đã lấy đúng giá trị

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: