SpringBoot:拦截器从请求中读取特定字段并将其设置在响应中

SpringBoot: Interceptor to read particular field from request and set it in the response

我们的 Spring Rest Controller 处理的所有请求和响应都有一个具有特定值的公共部分:

{
    "common": {
        "requestId": "foo-bar-123",
        "otherKey1": "value1",
        "otherKey2": "value2",
        "otherKey3": "value3"
    },
    ...
}

目前我所有的控制器功能都在读取 common 并将其手动复制到响应中。我想把它移到某种拦截器中。

我尝试使用 ControllerAdviceThreadLocal:

@ControllerAdvice
public class RequestResponseAdvice extends RequestBodyAdviceAdapter
    implements ResponseBodyAdvice<MyGenericPojo> {

  private ThreadLocal<Common> commonThreadLocal = new ThreadLocal<>();

  /* Request */

  @Override
  public boolean supports(
      MethodParameter methodParameter, Type type, Class<? extends HttpMessageConverter<?>> aClass) {
    return MyGenericPojo.class.isAssignableFrom(methodParameter.getParameterType());
  }

  @Override
  public Object afterBodyRead(
      Object body,
      HttpInputMessage inputMessage,
      MethodParameter parameter,
      Type targetType,
      Class<? extends HttpMessageConverter<?>> converterType) {
    var common = (MyGenericPojo)body.getCommon();
    if (common.getRequestId() == null) {
       common.setRequestId(generateNewRequestId()); 
    }
    commonThreadLocal(common);
    return body;
  }

  /* Response */

  @Override
  public boolean supports(
      MethodParameter returnType, Class<? extends HttpMessageConverter<?>> converterType) {
    return MyGenericPojo.class.isAssignableFrom(returnType.getParameterType());
  }

  @Override
  public MyGenericPojo beforeBodyWrite(
      MyGenericPojo body,
      MethodParameter returnType,
      MediaType selectedContentType,
      Class<? extends HttpMessageConverter<?>> selectedConverterType,
      ServerHttpRequest request,
      ServerHttpResponse response) {
    body.setCommon(commonThreadLocal.get());
    commonThreadLocal.remove();
    return body;
  }
}

这在我测试一次发送一个请求时有效。但是,当多个请求到来时,是否保证 afterBodyReadbeforeBodyWrite 在同一个线程中被调用?

如果不是,或者甚至不是,最好的方法是什么?

如果只是从请求复制到响应的元数据,您可以执行以下操作之一:

1- 将元数据存储在 request/response header 中,然后使用过滤器进行复制:

@WebFilter(filterName="MetaDatatFilter", urlPatterns ={"/*"})
public class MyFilter implements Filter{
    @Override
    public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain)
        throws IOException, ServletException {

    HttpServletRequest httpServletRequest = (HttpServletRequest) request;
    HttpServletResponse httpServletResponse = (HttpServletResponse) response;        
    httpServletResponse.setHeader("metaData", httpServletRequest.getHeader("metaData"));        
}

}

2- 将工作转移到服务层,在那里您可以通过可重用的通用方法来完成处理,或者通过 AOP 运行

public void copyMetaData(whatEverType request,whatEverType response) {
    response.setMeta(request.getMeta);

}

快速回答:RequestBodyAdviceResponseBodyAdvice 在同一个线程中为一个请求调用。

您可以在以下位置调试实现:ServletInvocableHandlerMethod#invokeAndHandle

虽然您这样做并不安全:

  • ThreadLocal 应该定义为 static final,否则它类似于任何其他 class 属性
  • 正文中抛出的异常将跳过 ResponseBodyAdvice 的调用(因此不会删除线程本地数据)

"More safe way":使请求体支持任何class(不仅仅是MyGenericPojo),在afterBodyRead方法中:

  • 第一次通话ThreadLocal#remove
  • 检查类型是否为 MyGenericPojo 然后将公共数据设置为 threadlocal

我觉得不需要你自己的ThreadLocal你可以使用请求属性。

@Override
public Object afterBodyRead(
        Object body,
        HttpInputMessage inputMessage,
        MethodParameter parameter,
        Type targetType,
        Class<? extends HttpMessageConverter<?>> converterType) {

    var common = ((MyGenericPojo) body).getCommon();
    if (common.getRequestId() == null) {
        common.setRequestId(generateNewRequestId());
    }

    Optional.ofNullable((ServletRequestAttributes) RequestContextHolder.getRequestAttributes())
            .map(ServletRequestAttributes::getRequest)
            .ifPresent(request -> {request.setAttribute(Common.class.getName(), common);});

    return body;
}


@Override
public MyGenericPojo beforeBodyWrite(
        MyGenericPojo body,
        MethodParameter returnType,
        MediaType selectedContentType,
        Class<? extends HttpMessageConverter<?>> selectedConverterType,
        ServerHttpRequest request,
        ServerHttpResponse response) {

    Optional.ofNullable(RequestContextHolder.getRequestAttributes())
            .map(rc -> rc.getAttribute(Common.class.getName(), RequestAttributes.SCOPE_REQUEST))
            .ifPresent(o -> {
                Common common = (Common) o;
                body.setCommon(common);
            });

    return body;
}

编辑

Optional可以换成

RequestContextHolder.getRequestAttributes().setAttribute(Common.class.getName(),common,RequestAttributes.SCOPE_REQUEST);

RequestContextHolder.getRequestAttributes().getAttribute(Common.class.getName(),RequestAttributes.SCOPE_REQUEST);

编辑 2

关于线程安全

1) 标准的基于 servlet 的 Spring Web 应用程序我们有一个线程每个请求的场景。请求由其中一个工作线程通过所有过滤器和例程进行处理。处理链将从头到尾由同一个线程执行。因此 afterBodyReadbeforeBodyWrite 保证由同一个线程针对给定请求执行。

2) 您的 RequestResponseAdvice 本身是无状态的。我们使用 RequestContextHolder.getRequestAttributes() 这是 ThreadLocal 并声明为

private static final ThreadLocal<RequestAttributes> requestAttributesHolder =
        new NamedThreadLocal<>("Request attributes");

ThreadLocal javadoc 指出:

his class provides thread-local variables. These variables differ from their normal counterparts in that each thread that accesses one (via its get or set method) has its own, independently initialized copy of the variable.

所以我没有看到这个 sulotion 有任何线程安全问题。

我也已经回答了这个问题,但我更喜欢用另一种方式来解决这类问题

在这种情况下我会使用 Aspect-s。

我已将其包含在一个文件中,但您应该创建适当的单独文件 类。

@Aspect
@Component
public class CommonEnricher {

    // annotation to mark methods that should be intercepted
    @Retention(RetentionPolicy.RUNTIME)
    @Target(ElementType.METHOD)
    public @interface EnrichWithCommon {
    }


    @Configuration
    @EnableAspectJAutoProxy
    public static class CommonEnricherConfig {}

    // Around query to select methods annotiated with @EnrichWithCommon
    @Around("@annotation(com.example.CommonEnricher.EnrichWithCommon)")
    public Object enrich(ProceedingJoinPoint joinPoint) throws Throwable {
        MyGenericPojo myGenericPojo = (MyGenericPojo) joinPoint.getArgs()[0];

        var common = myGenericPojo.getCommon();
        if (common.getRequestId() == null) {
            common.setRequestId(UUID.randomUUID().toString());
        }

        //actual rest controller  method invocation
        MyGenericPojo res = (MyGenericPojo) joinPoint.proceed();

        //adding common to body
        res.setCommon(common);
        return res;
    }

    //example controller
    @RestController
    @RequestMapping("/")
    public static class MyRestController {

        @PostMapping("/test" )
        @EnrichWithCommon // mark method to intercept
        public MyGenericPojo test(@RequestBody  MyGenericPojo myGenericPojo) {
            return myGenericPojo;
        }
    }
}

我们这里有一个注释 @EnrichWithCommon,它标记了应该发生富集的端点。