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

Ada*_*dam 4 java spring jackson spring-boot

介绍

我们在 Spring Boot 中实现了一个 REST API。目前它在序列化时返回所有字段。所以它返回类似

{
    "foo": "A",
    "bar": null,
    "baz": "C",
}
Run Code Online (Sandbox Code Playgroud)

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

{
    "foo": "A",
    "baz": "C",
}
Run Code Online (Sandbox Code Playgroud)

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

{
    "foo": "A",
    "bar": "B",
    "baz": "C",
}
Run Code Online (Sandbox Code Playgroud)

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

我能找到的最接近的是这是转向通过查询参数漂亮的印刷。当我尝试做类似的事情时,它适用于漂亮的打印。但是,对于包含,它适用于我启动 API 后的第一个请求,但之后每个其他请求都从第一个请求中获取值。我可以看到它正在通过断点设置它,并且我还针对相同的参数添加了漂亮打印,仅用于诊断目的。

我尝试过的细节

我们的 API 基于使用 Swagger Codegen 服务器存根生成的 API。我们使用委托模式,所以它生成一个控制器,它只有一个自动连接的委托和一个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;
    }
}
Run Code Online (Sandbox Code Playgroud)

委托是一个接口,其中每个端点都包含一个函数。这些返回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.
}
Run Code Online (Sandbox Code Playgroud)

我们有一个对象,我们称之为ApiContext。这是我们调用的自定义范围的范围ApiCallScoped- 基本上是每个请求,但它处理异步并复制到创建的线程。我们已经实现了一些东西HandlerInterceptorAdapter(尽管我们的@Component不是@Bean像上面的漂亮打印示例中的那样)。我们在其中创建了上下文,preHandle所以我想将它添加到那里来设置对象映射器属性。撇开一些清理工作,这看起来像:

@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;
  }
}
Run Code Online (Sandbox Code Playgroud)

ApiContext我们看标题。我试过

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);
    }
  }
}
Run Code Online (Sandbox Code Playgroud)

我试图把它放在注入ObjectMapper,通过Jackson2ObjectMapperBuilder并还经Jackson2ObjectMapperBuilder。我已经尝试过(我认为)所有不同的组合。发生的情况是美化部分对每个请求都有效,但空包含仅对第一个请求有效,之后它保持在该值。代码正在运行(美化工作,在调试器中通过并看到它)并且当我尝试设置包含属性但它没有使用它们时不会抛出错误。

然后我们有一个@Component实现委托接口的。getObjectMapper从我们的返回映射器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
}
Run Code Online (Sandbox Code Playgroud)

ContextServiceImpl@ApiCallScoped@Component。通过所有其他方式获得的 ApiContext 是每个请求的,但映射器的行为不像我预期的那样。

它产生什么

例如,如果我的第一个请求将标头设置为 false(漂亮的打印,不包括空值),那么我会得到响应

{
    "foo": "A",
    "baz": "C"
}
Run Code Online (Sandbox Code Playgroud)

(哪个是正确的)。发送没有标头的后续请求(不要漂亮打印,包括空值)返回

{"foo": "A","baz": "C"}
Run Code Online (Sandbox Code Playgroud)

这是错误的 - 它没有空值 - 尽管漂亮的打印被正确关闭。后续请求有/无头返回与上述两个示例相同,具体取决于头值。

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

{"foo": "A","bar":null,"baz": "C"}
Run Code Online (Sandbox Code Playgroud)

(哪个是正确的)。但随后的请求返回时带有标头

{
    "foo": "A",
    "bar": null,
    "baz": "C"
}
Run Code Online (Sandbox Code Playgroud)

这是错误的-它确实有空-虽然漂亮印刷被正确打开。带有/不带标头的后续请求将返回与上述相同的请求,具体取决于标头值。

我的问题

为什么它尊重漂亮的印刷品而不是属性包含,有没有办法让它像我想要的那样工作?

更新

我认为问题在于 Jackson 缓存了它为每个对象使用的序列化程序。我想这是设计使然 - 它们可能是使用反射生成的并且相当昂贵。如果我使用标头调用一个端点(启动 API 后的第一次),它返回而没有空值。美好的。无论是否存在标头,后续调用都没有空值。没那么好。但是,如果我然后在没有标头的情况下调用另一个相关的端点(在启动 API 后第一次),它会返回主对象(很好)的空值,但两个响应共有的一些子对象没有空值(如这些对象的序列化程序已被缓存 - 不太好)。

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

Mạn*_*yễn 5

你把它弄得太复杂了。

ObjectMapper应该初始化或像你这样每个请求重新配置。在这里查看原因

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

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

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

    }
}
Run Code Online (Sandbox Code Playgroud)

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

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();    }
}
Run Code Online (Sandbox Code Playgroud)
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();
    }
}
Run Code Online (Sandbox Code Playgroud)

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

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);
    }
}
Run Code Online (Sandbox Code Playgroud)