Spring与枚举参数

1. 前言

使用枚举作为参数类型是非常常见的一种手段,但是枚举传参随着使用方式的不同,也存在的些微的差异。

简单来说分为枚举在作为请求的参数依据传递方式不同可以分为两种情况:

  • GET 请求、POST 表单传递
  • Json 数据格式

两者在做数据类型转换的时候是大不一样的。

2. 实例

首先定义一个枚举类:

  package com.avaloninc.domain;
  
  import com.fasterxml.jackson.annotation.JsonValue;
  
  public enum StatusEnum {
    /**
     * Valid status enum.
     */
    VALID(1, "有效"),
    /**
     * Invalid status enum.
     */
    INVALID(2, "无效");
  
    private final int    code;
    private final String value;
  
    StatusEnum(int code, String value) {
      this.code = code;
      this.value = value;
    }
  }
  

然后我们给出如下简单的 Controller:

  package com.avaloninc;
  
  import com.avaloninc.domain.StatusEnum;
  import lombok.Data;
  import org.springframework.boot.SpringApplication;
  import org.springframework.boot.autoconfigure.SpringBootApplication;
  import org.springframework.web.bind.annotation.GetMapping;
  import org.springframework.web.bind.annotation.PostMapping;
  import org.springframework.web.bind.annotation.RequestBody;
  import org.springframework.web.bind.annotation.RequestMapping;
  import org.springframework.web.bind.annotation.RestController;
  
  @SpringBootApplication
  @RestController
  @RequestMapping("api")
  public class Application {
    public static void main(String[] args) {
      SpringApplication.run(Application.class);
    }
  
    @Data
    public static class ListRequest {
      private StatusEnum status;
    }
  
    @Data
    public static class JsonRequest {
      private StatusEnum status;
    }
  
    @GetMapping
    public StatusEnum list(ListRequest listRequest) {
      return listRequest.getStatus();
    }
  
    @PostMapping
    public StatusEnum post(JsonRequest jsonRequest) {
      return jsonRequest.getStatus();
    }
  
    @PostMapping("json")
    public StatusEnum postJson(@RequestBody JsonRequest jsonRequest) {
      return jsonRequest.getStatus();
    }
  }

然后我们发起一个测试:

  # 1. GET
  curl -X GET 'http://localhost:8080/api?status=VALID'
  
  "VALID"
  
  # 2. POST Form
  curl -X POST  http://localhost:8080/api -F status=VALID
  
  "VALID"
  
  # 3. POST json
  curl -X POST http://localhost:8080/api/json 
    -d '{ "status": "VALID"}'
    
  "VALID"

一切正常!

3. 新的需求

现在添加一个需求:我们希望返回的不只是枚举的字面值,而是真正的中文含义比如有效、无效。

需求实现也很简单,给 StatusEnum 增加一个带有 @JsonValue 注解的方法即可。该注解指定了 Jackson 在序列化对象时使用的方法。

    @JsonValue
    public String toValue() {
      return this.value;
    }

测试结果:

  # 1. GET
  curl -X GET 'http://localhost:8080/api?status=VALID'
  
  "有效"
  
  # 2. POST Form
  curl -X POST  http://localhost:8080/api -F status=VALID
  
  "有效"
  
  # 3. POST json
  curl -X POST http://localhost:8080/api/json 
    -d '{ "status": "VALID"}'
    
  {
      "timestamp": 1520709795581,
      "status": 400,
      "error": "Bad Request",
      "exception": "org.springframework.http.converter.HttpMessageNotReadableException",
      "message": "JSON parse error: Can not deserialize value of type com.avaloninc.domain.StatusEnum from String \"VALID\": value not one of declared Enum instance names: [无效, 有效]; nested exception is com.fasterxml.jackson.databind.exc.InvalidFormatException: Can not deserialize value of type com.avaloninc.domain.StatusEnum from String \"VALID\": value not one of declared Enum instance names: [无效, 有效]\n at [Source: java.io.PushbackInputStream@5ba1fa04; line: 2, column: 12] (through reference chain: com.avaloninc.Application$JsonRequest[\"status\"])",
      "path": "/api/json"
  }

原来以 Json 作为参数的请求出了错误。String \"VALID\": value not one of declared Enum instance names: [无效, 有效] 告诉我们 Json 反序列化为 StatusEnum 时只能用 [无效, 有效] 作为值。奇怪的是没有指定 @JsonCreator@JsonCreator 注解可以指定对象反序列化的方法)为什么没有采用默认的行为,即用枚举字面值反序列化,而采用我们序列化使用的字段呢?查看了 @JsonValue 注解的 javadoc 后有所发现:

  • NOTE: when use for Java <code>enum</code>s, one additional feature is
  • that value returned by annotated method is also considered to be the
  • value to deserialize from, not just JSON String to serialize as.
  • This is possible since set of Enum values is constant and it is possible
  • to define mapping, but can not be done in general for POJO types; as such,
  • this is not used for POJO deserialization.*
  • @see JsonCreator

坑爹啊!对于 @JsonValue 来说枚举是一种特殊的场景,定义了序列化后的值反过来也就被用来反序列化。

解决方法也不难,手动指定一个 @JsonCreator 即可:

    @JsonCreator
    public static StatusEnum fromValue(String str) {
      for (StatusEnum statusEnum : StatusEnum.values()) {
        if (statusEnum.name().equals(str)) {
          return statusEnum;
        }
      }
      throw new IllegalArgumentException("Illegal enum " + str);
    }

于是乎问题也解决了!

4. 进一步思考

进一步思考一下,为什么增加了 @JsonValue 注解后 GET 请求和 POST Form 请求没有变化呢?

答案是参数的绑定机制不同。

通过打断点我们可以发现:GET 请求和 POST Form 请求中的字符串到枚举的转化是通过 org.springframework.core.convert.support.StringToEnumConverterFactory 来实现的,其代码如下:

  /*
   * Copyright 2002-2017 the original author or authors.
   *
   * Licensed under the Apache License, Version 2.0 (the "License");
   * you may not use this file except in compliance with the License.
   * You may obtain a copy of the License at
   *
   *      http://www.apache.org/licenses/LICENSE-2.0
   *
   * Unless required by applicable law or agreed to in writing, software
   * distributed under the License is distributed on an "AS IS" BASIS,
   * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
   * See the License for the specific language governing permissions and
   * limitations under the License.
   */
  
  package org.springframework.core.convert.support;
  
  import org.springframework.core.convert.converter.Converter;
  import org.springframework.core.convert.converter.ConverterFactory;
  
  /**
   * Converts from a String to a {@link java.lang.Enum} by calling {@link Enum#valueOf(Class, String)}.
   *
   * @author Keith Donald
   * @author Stephane Nicoll
   * @since 3.0
   */
  @SuppressWarnings({"unchecked", "rawtypes"})
  final class StringToEnumConverterFactory implements ConverterFactory<String, Enum> {
  
      @Override
      public <T extends Enum> Converter<String, T> getConverter(Class<T> targetType) {
          return new StringToEnum(ConversionUtils.getEnumType(targetType));
      }
  
  
      private class StringToEnum<T extends Enum> implements Converter<String, T> {
  
          private final Class<T> enumType;
  
          public StringToEnum(Class<T> enumType) {
              this.enumType = enumType;
          }
  
          @Override
          public T convert(String source) {
              if (source.isEmpty()) {
                  // It's an empty enum identifier: reset the enum value to null.
                  return null;
              }
              return (T) Enum.valueOf(this.enumType, source.trim());
          }
      }
  }

可以发现的是该类实现了接口 ConverterFactory ,通过调用 Enum.valueOf(Class, String) 实现了这个功能。

向下追溯源码可以发现该方法实际上是从一个 Map<String, Enum> 的字典中获取了转换后的实际值,着这个 String 类型的 Key 的获取方式就是 Enum.name() 返回的结果,即枚举的字面值。源码如下:

      /**
       * Returns a map from simple name to enum constant.  This package-private
       * method is used internally by Enum to implement
       * {@code public static <T extends Enum<T>> T valueOf(Class<T>, String)}
       * efficiently.  Note that the map is returned by this method is
       * created lazily on first use.  Typically it won't ever get created.
       */
      Map<String, T> enumConstantDirectory() {
          if (enumConstantDirectory == null) {
              T[] universe = getEnumConstantsShared();
              if (universe == null)
                  throw new IllegalArgumentException(
                      getName() + " is not an enum type");
              Map<String, T> m = new HashMap<>(2 * universe.length);
              for (T constant : universe)
                  m.put(((Enum<?>)constant).name(), constant);
              enumConstantDirectory = m;
          }
          return enumConstantDirectory;
      }
      private volatile transient Map<String, T> enumConstantDirectory = null;

5. 参考文章