Spring 引导:控制每个请求的序列化响应中是否存在空字段

Spring Boot: Control if null fields present in serialized response per request

简介

我们在 Spring Boot 中实现了 REST API。目前它 return 序列化时的所有字段。所以它 return 类似于

{
    "foo": "A",
    "bar": null,
    "baz": "C",
}

我们想要不 return 空字段的选项,所以它只会 return

{
    "foo": "A",
    "baz": "C",
}

对于那种情况 - 但仍然(如果 bar 有一个值)

{
    "foo": "A",
    "bar": "B",
    "baz": "C",
}

我知道你可以通过应用程序属性将它引导到不 return 空值,但这是一个现有的 AI,如果字段丢失,一些针对它实现的应用程序可能会在反序列化时失败。因此,我们想让调用客户端来引导它。我们的想法是让您可以发送 header:X-OurCompany-IncludeNulls; false。这将允许客户进行选择,我们最初会默认为 true,但可能会随着时间的推移以可管理的方式更改默认值。

我能找到的最近的是 this,它通过查询参数转向 pretty-printing。当我尝试做类似的事情时,它适用于 pretty-printing。但是,对于包含,它适用于我启动 API 后的第一个请求,但之后每个其他请求都从第一个请求获取值。我可以看到它正在通过断点设置它,而且我还针对同一参数添加了 pretty-print 只是为了诊断目的。

我尝试的详细信息

我们的 API 基于使用 Swagger Codegen 服务器存根生成的一个。我们使用委托模式,所以它生成一个控制器,它只有一个 auto-wired 委托和一个 getDelegate

@Controller
public class BookingsApiController implements BookingsApi {

    private final BookingsApiDelegate delegate;

    @org.springframework.beans.factory.annotation.Autowired
    public BookingsApiController(BookingsApiDelegate delegate) {
        this.delegate = delegate;
    }

    @Override
    public BookingsApiDelegate getDelegate() {
        return delegate;
    }
}

委托是一个接口,每个端点都包含一个函数。这些 return CompletableFuture<ResponseEntity<T>>(其中 T 是该响应的类型)。它也是一个 getObjectMapper(),我假设它是 Spring 用来序列化响应的内容?

public interface BookingsApiDelegate {

    Logger log = LoggerFactory.getLogger(BookingsApi.class);

    default Optional<ObjectMapper> getObjectMapper() {
        return Optional.empty();
    }

    default Optional<HttpServletRequest> getRequest() {
        return Optional.empty();
    }

    default Optional<String> getAcceptHeader() {
        return getRequest().map(r -> r.getHeader("Accept"));
    }
    
    // Functions per endpoint here.  By default returns Not Implemented.
}

我们有一个 object,我们称之为 ApiContext。这是我们称之为 ApiCallScoped 的自定义范围的范围 - 基本上是 per-request 但它处理异步并复制到创建的线程。我们已经有了实现 HandlerInterceptorAdapter 的东西(尽管我们的是 @Component 而不是上面 pretty-print 示例中的 @Bean )。我们在 preHandle 中创建上下文,所以我想将它添加到那里以设置 object 映射器属性。撇开一些 clean-up 这看起来像:

@Component
public class RestContextInterceptor extends HandlerInterceptorAdapter {

  @Autowired
  private ContextService apiContextService;
  @Autowired
  private RestRequestLogger requestLogger;
  @Autowired
  private ObjectMapper mapper;
  @Autowired
  private Jackson2ObjectMapperBuilder objectMapperBuilder;
  @Autowired
  private MappingJackson2HttpMessageConverter mappingJackson2HttpMessageConverter;

  @Override
  public boolean preHandle(final HttpServletRequest request, final HttpServletResponse response,
      final Object handler) throws Exception {
    requestLogger.requestStart(request);

    if (request.getAttribute("apiCallContext") == null) {
      ApiCallContext conversationContext;
      ApiContext apiContext = readApiContext(request, mapper, objectMapperBuilder, mappingJackson2HttpMessageConverter);
      if (apiContext == null) {
        conversationContext = new ApiCallContext("local-" + UUID.randomUUID().toString());
      } else {
        conversationContext = new ApiCallContext(apiContext.getTransId());
      }
      ApiCallContextHolder.setContext(conversationContext);
      request.setAttribute("apiCallContext", conversationContext);

      if (apiContext != null) {
        apiContextService.setContext(apiContext);
      }
    } else {
      ApiCallContextHolder.setContext((ApiCallContext) request.getAttribute("apiCallContext"));
    }

    return true;
  }

  private static ApiContext readApiContext(
      final HttpServletRequest request,
      final ObjectMapper mapper,
      final Jackson2ObjectMapperBuilder objectMapperBuilder,
      final MappingJackson2HttpMessageConverter mappingJackson2HttpMessageConverter) {
    if (request.getHeader(ApiContext.SYSTEM_JWT_HEADER) != null) {
      return new ApiContext(Optional.of(request), mapper, objectMapperBuilder, mappingJackson2HttpMessageConverter);
    }
    return null;
  }
}

ApiContext我们看一下header。我试过了

public final class ApiContext implements Context {
  private final ObjectMapper mapper;
  public ApiContext(
      final Optional<HttpServletRequest> request,
      final ObjectMapper mapper,
      final Jackson2ObjectMapperBuilder objectMapperBuilder,
      final MappingJackson2HttpMessageConverter mappingJackson2HttpMessageConverter
  ) {
    if (!request.isPresent()) {
      throw new InvalidSessionException("No request found");
    }

    if (getBooleanFromHeader(request, NULLMODE_HEADER).orElse(DEFAULT_INCLUDE_NULL_FIELDS_IN_OUTPUT)) {
      objectMapperBuilder.serializationInclusion(JsonInclude.Include.ALWAYS);
      objectMapperBuilder.indentOutput(false);
      mappingJackson2HttpMessageConverter.getObjectMapper().setPropertyInclusion(
          JsonInclude.Value.construct(JsonInclude.Include.ALWAYS, JsonInclude.Include.ALWAYS));
      mapper.setPropertyInclusion(
          JsonInclude.Value.construct(JsonInclude.Include.ALWAYS, JsonInclude.Include.ALWAYS));
    } else {
      objectMapperBuilder.serializationInclusion(JsonInclude.Include.NON_EMPTY);
      objectMapperBuilder.indentOutput(true);
      mappingJackson2HttpMessageConverter.getObjectMapper().setPropertyInclusion(
          JsonInclude.Value.construct(JsonInclude.Include.NON_EMPTY, JsonInclude.Include.NON_EMPTY));
      mapper.setPropertyInclusion(
          JsonInclude.Value.construct(JsonInclude.Include.NON_EMPTY, JsonInclude.Include.NON_EMPTY));
    }
    objectMapperBuilder.configure(mapper);
    this.mapper = objectMapperBuilder.build().copy();
  }

  @Override
  public ObjectMapper getMapper() {
    return mapper;
  }

  private static Optional<Boolean> getBooleanFromHeader(final Optional<HttpServletRequest> request, final String key) {
    String value = request.get().getHeader(key);
    if (value == null) {
      return Optional.empty();
    }

    value = value.trim();
    if (StringUtils.isEmpty(value)) {
      return Optional.empty();
    }

    switch (value.toLowerCase()) {
      case "true":
        return Optional.of(true);
      case "1":
        return Optional.of(true);
      default:
        return Optional.of(false);
    }
  }
}

我已经尝试通过 Jackson2ObjectMapperBuilderJackson2ObjectMapperBuilder 在注入的 ObjectMapper 上设置它。我试过(我认为)所有不同的组合。发生的情况是 prettify 部分根据请求工作,但 null-inclusion 仅在第一个请求上工作,之后它保持在该值。代码是 运行(美化工作,在调试器中经过并看到它)并且当我尝试设置包含属性但它没有使用它们时没有抛出错误。

然后我们有一个实现委托接口的 @ComponentgetObjectMapper returns 来自我们的 ApiContext

的映射器
@Component
public class BookingsApi extends ApiDelegateBase implements BookingsApiDelegate {

  private final HttpServletRequest request;

  @Autowired
  private ContextService contextService;

  @Autowired
  public BookingsApi(final ObjectMapper objectMapper, final HttpServletRequest request) {
    this.request = request;
  }

  public Optional<ObjectMapper> getObjectMapper() {
    if (contextService.getContextOrNull() == null) {
      return Optional.empty();
    }
    return Optional.ofNullable(contextService.getContext().getMapper());
  }

  public Optional<HttpServletRequest> getRequest() {
    return Optional.ofNullable(request);
  }

  // Implement function for each request
}

ContextServiceImpl@ApiCallScoped@Component。通过它获得的 ApiContext 在所有其他方面都是 per-request,但是映射器的行为并不像我预期的那样。

它产生什么

例如如果我的第一个请求将 header 设置为 false(pretty-print,不包括空值),那么我会得到响应

{
    "foo": "A",
    "baz": "C"
}

(正确)。发送不带 header 的后续请求(不漂亮打印,包含空值)returns

{"foo": "A","baz": "C"}

这是错误的 - 它没有空值 - 尽管 pretty-printing 被正确关闭。后续请求with/without header return同上两个例子,取决于header值。

另一方面,如果我的第一个请求不包含 header(不要漂亮打印,请包含空值)我得到

{"foo": "A","bar":null,"baz": "C"}

(正确)。但是随后在 return

上使用 header 的请求
{
    "foo": "A",
    "bar": null,
    "baz": "C"
}

这是错误的 - 它确实有空值 - 虽然 pretty-printing 已正确打开。后续请求with/without header return同上,取决于header值。

我的问题

为什么它尊重 pretty-print 但不尊重 属性 包含,有没有办法让它像我想要的那样工作?

更新

我认为问题在于 Jackson 根据 object 缓存了它使用的序列化程序。我想这是设计使然——它们可能是使用反射生成的,而且相当昂贵。如果我用 header 调用一个端点(在开始 API 后第一次),它 return 没有空值。美好的。随后的调用都没有空值,无论 header 是否存在。不太好。但是,如果我随后在没有 header 的情况下调用另一个相关的端点(在开始 API 后第一次),它 returns 主要 object 为空值(很好)但是对于两个响应共有的某些 sub-objects 没有空值(因为那些 object 的序列化程序已被缓存 - 不太好)。

我看到 object 映射器有一些视图概念。有没有办法用这些来解决这个?所以它每个 object 有两个缓存的序列化器,并选择了正确的一个? (我会尝试研究这个,还没有时间,但如果有人知道我在正确或错误的轨道上,那就太好了!)

你把它搞得太复杂了。

另外 ObjectMapper 不应像您那样根据请求进行初始化或重新配置。

注意:以下配置完全不依赖于您的 ApiContext 或 ApiScope,请在使用此代码之前删除那些 类 中的所有 ObjectMapper 自定义。您可以创建一个裸机 spring 启动应用程序来测试代码。

首先需要一种方法来检测您的请求是空包含还是排除

import javax.servlet.http.HttpServletRequest;

import org.springframework.web.context.request.RequestAttributes;
import org.springframework.web.context.request.RequestContextHolder;
import org.springframework.web.context.request.ServletRequestAttributes;

public class RequestUtil {

    public static boolean isNullInclusionRequest() {
        RequestAttributes requestAttrs = RequestContextHolder.currentRequestAttributes();
        if (!(requestAttrs instanceof ServletRequestAttributes)) {
            return false;
        }
        HttpServletRequest servletRequest = ((ServletRequestAttributes)requestAttrs).getRequest();
        return "true".equalsIgnoreCase(servletRequest.getHeader(NULLMODE_HEADER));
    }

    private RequestUtil() {

    }
}

其次,声明您的自定义消息序列化程序

import java.lang.reflect.Type;

import com.fasterxml.jackson.databind.ObjectMapper;
import org.springframework.core.Ordered;
import org.springframework.core.annotation.Order;
import org.springframework.http.MediaType;
import org.springframework.http.converter.json.MappingJackson2HttpMessageConverter;

@Order(Ordered.HIGHEST_PRECEDENCE) // Need this to be in the first of the serializers
public class NullExclusionMessageConverter extends MappingJackson2HttpMessageConverter {

    public NullExclusionMessageConverter(ObjectMapper nullExclusionMapper) {
        super(nullExclusionMapper);
    }

    @Override
    public boolean canRead(Type type, Class<?> contextClass, MediaType mediaType) {
        // Do not use this for reading. You can try it if needed
        return false;
    }

    @Override
    public boolean canWrite(Class<?> clazz, MediaType mediaType) {
        return super.canWrite(clazz, mediaType) && !RequestUtil.isNullInclusionRequest();    }
}
import java.lang.reflect.Type;

import com.fasterxml.jackson.databind.ObjectMapper;
import org.springframework.core.Ordered;
import org.springframework.core.annotation.Order;
import org.springframework.http.MediaType;
import org.springframework.http.converter.json.MappingJackson2HttpMessageConverter;

@Order(Ordered.HIGHEST_PRECEDENCE)
public class NullInclusionMessageConverter extends MappingJackson2HttpMessageConverter {

    public NullInclusionMessageConverter(ObjectMapper nullInclusionMapper) {
        super(nullInclusionMapper);
    }

    @Override
    public boolean canRead(Type type, Class<?> contextClass, MediaType mediaType) {
        // Do not use this for reading. You can try it if needed
        return false;
    }

    @Override
    public boolean canWrite(Class<?> clazz, MediaType mediaType) {
        return super.canWrite(clazz, mediaType) && RequestUtil.isNullInclusionRequest();
    }
}

三、注册自定义消息转换器:

import com.fasterxml.jackson.annotation.JsonInclude;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.SerializationFeature;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.http.converter.json.Jackson2ObjectMapperBuilder;

@Configuration
public class JacksonConfiguration {

    @Bean
    public NullInclusionMessageConverter nullInclusionMessageConverter(Jackson2ObjectMapperBuilder builder) {
        ObjectMapper objectMapper = builder.build();
        objectMapper.setSerializationInclusion(JsonInclude.Include.ALWAYS);
        objectMapper.enable(SerializationFeature.INDENT_OUTPUT);
        return new NullInclusionMessageConverter(objectMapper);
    }

    @Bean
    public NullExclusionMessageConverter nullExclusionJacksonMessageConverter(Jackson2ObjectMapperBuilder builder) {
        ObjectMapper objectMapper = builder.build();
        objectMapper.setSerializationInclusion(JsonInclude.Include.NON_EMPTY);
        objectMapper.disable(SerializationFeature.INDENT_OUTPUT);
        return new NullExclusionMessageConverter(objectMapper);
    }
}