【小知識】spring攔截器獲取到介面資訊並上報

noname發表於2022-05-27

背景

系統需要上報每次的請求資訊,並上報資料給監控平臺。

問題

獲取介面返回物件

系統介面是RestController,返回的結果都是@ResponseBody物件。上報資料時,需要解析返回結果物件,提取物件中的狀態碼。從response物件中獲取返回結果物件,之前是在filter中通過ContentCachingResponseWrapper方式來獲取:

public class AccessLogFilter extends OncePerRequestFilter implements Ordered {
    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)
            throws ServletException, IOException {
        long biginTime = System.currentTimeMillis();
        HttpServletRequest httpServletRequest = request;
        HttpServletResponse httpServletResponse = response;
        if (!(httpServletRequest instanceof ContentCachingRequestWrapper)) {
            httpServletRequest = new ContentCachingRequestWrapper(request);
        }
        if (!(httpServletResponse instanceof ContentCachingResponseWrapper)) {
            httpServletResponse = new ContentCachingResponseWrapper(response);
        }

        try {
            …… // 其他操作
            filterChain.doFilter(httpServletRequest, httpServletResponse);
            String responseBody = invokeHttpByteResponseData((ContentCachingResponseWrapper) httpServletResponse);
            …… // 上報等其他操作
        } finally {
            ((ContentCachingResponseWrapper) httpServletResponse).copyBodyToResponse();
            AccessLogEntityHolder.remove();
        }
    }   
        
    public String invokeHttpByteResponseData(ContentCachingResponseWrapper response) {
        try {
            String charset = getResponseCharset(response);
            return IOUtils.toString(response.getContentAsByteArray(), charset);
        } catch (IOException e) {
            throw new RuntimeException("訪問日誌解析器解析介面返回資料異常!", e);
        }
    }


    protected String getResponseCharset(ContentCachingResponseWrapper response) {
        if (response.getContentType() == null) {
            return StandardCharsets.UTF_8.name();
        }
        boolean isStream = response.getContentType()
                .equalsIgnoreCase(MediaType.APPLICATION_OCTET_STREAM_VALUE);
        return isStream && StringUtils.isNotBlank(response.getCharacterEncoding()) ? response.getCharacterEncoding()
                : StandardCharsets.UTF_8.name();
    }    
    
}

現在由於擔心跟引入的一個第三次外掛包裡的filter有衝突,改為使用Interceptor方式。而spring的Interceptor無法像filter那樣構建新的requestresponse
這裡的解決方案是,通過ControllerAdvice來獲取儲存物件,即ControllerAdvice裡的beforeBodyWrite方法,在執行時,將引數裡的body暫時儲存起來,這裡的儲存,採用了ThreadLocal方案。
方案如下:

@ControllerAdvice
public classXXXMetricInterceptor implements HandlerInterceptor, Ordered, ResponseBodyAdvice<Object> {

    private static final ThreadLocal<Object> resultBodyThreadLocal = new ThreadLocal();

    @Override
    public boolean supports(MethodParameter returnType, Class<? extends HttpMessageConverter<?>> converterType) {
        return true;
    }

    @Override
    public Object beforeBodyWrite(Object body, MethodParameter returnType, MediaType selectedContentType,
            Class<? extends HttpMessageConverter<?>> selectedConverterType, ServerHttpRequest request,
            ServerHttpResponse response) {
        resultBodyThreadLocal.set(body);
        return body;
    }

    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler)
            throws Exception {
        return true;
    }

    @Override
    public void postHandle(HttpServletRequest request, HttpServletResponse response, Object handler,
            ModelAndView modelAndView) throws Exception {
        
    }

    @Override
    public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex)
            throws Exception {
    }
    
}      

但是以上有個問題,開發的Interceptor是個公共元件,允許其他專案擴充套件,也就是說,到時候配置方式是在@Configuration裡配置:

@Bean
@ConditionalOnMissingBean(XXXMetricInterceptor.class)
public XXXMetricInterceptor getXXXMetricInterceptor() {
    return newXXXMetricInterceptor();
}

而以上方案由於@ControllerAdvice註解裡包含了@Component,無法做@ConditionalOnMissingBean判斷,所以改為將@ControllerAdvice部分獨立取出,然後在Interceptor裡注入:

public class XXXMetricInterceptor implements HandlerInterceptor, Ordered {

    @Autowired
    private XXXResponseBodyStorage responseBodyStorage;
    
}

@ControllerAdvice
public class XXXResponseBodyStorage implements Ordered, ResponseBodyAdvice<Object> {

    private static final ThreadLocal<Object> resultBodyThreadLocal = new ThreadLocal();

    @Override
    public boolean supports(MethodParameter returnType, Class<? extends HttpMessageConverter<?>> converterType) {
        return enable;
    }

    @Override
    public Object beforeBodyWrite(Object body, MethodParameter returnType, MediaType selectedContentType,
            Class<? extends HttpMessageConverter<?>> selectedConverterType, ServerHttpRequest request,
            ServerHttpResponse response) {
        resultBodyThreadLocal.set(body);
        return body;
    }

    public Object get() {
        return resultBodyThreadLocal.get();
    }

    public void remove() {
        resultBodyThreadLocal.remove();
    }

    @Override
    public int getOrder() {
        return Ordered.LOWEST_PRECEDENCE;
    }
}

注:spring允許多個ControllerAdvice物件存在,實際專案中已經存在專門對結果做轉換的ControllerAdvice物件。

獲取環境

由於不同的環境(如testprod),要上報資料到不同的地方,所以在初始化時,需要對環境做判斷,這裡可以通過初始化(@PostConstruct)方法裡,取applicationContext.getEnvironment().getActiveProfiles()來判斷,但是經過測試發現:

  1. 如果沒有對XXXMetricInterceptor做繼承擴充套件的話(XXXMetricInterceptor放在公共包裡,以jar的方式被引入),getActiveProfiles方法能取到值。
  2. 如果在實際專案中對XXXMetricInterceptor做了繼承擴充套件,那麼@PostConstruct方法裡getActiveProfiles返回的是空。

解決方案是調整初始化的時間點,改為在spring的application可用時再初始化:

public class XXXMetricInterceptor implements ApplicationListener<ApplicationReadyEvent>, HandlerInterceptor, Ordered {

    @Override
    public void onApplicationEvent(ApplicationReadyEvent event) {
        String[] activeProfiles =
                SpringContextUtil.getApplicationContext() == null ? null : SpringContextUtil.getActiveProfile();
        ……
    }
    
}

資料上報

剛開始資料尚博啊是放在攔截器的PostHandler方法裡:

public class XXXMetricInterceptor implements ApplicationListener<ApplicationReadyEvent>, HandlerInterceptor, Ordered {

    @Override
    public void postHandle(HttpServletRequest request, HttpServletResponse response, Object handler,
            ModelAndView modelAndView) throws Exception {
            …… // 資料上報
    }
}

測試發現,當介面傳送異常時,並不會進入到postHandle,之後改為在afterCompletion方法裡:

public class XXXMetricInterceptor implements ApplicationListener<ApplicationReadyEvent>, HandlerInterceptor, Ordered {

    @Override
    public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex)
            throws Exception {
        try {
            …… //資料上報
        } catch (Exception e) {
            ……
        } finally {
            responseBodyStorage.remove();
        }
    }
    
}  

如果介面發生異常,會先經過@ExceptionHandler的處理,之後進入ControllerAdvice環節,再之後進入到afterCompletion中。

相關文章