深入解析Spring使用枚舉接收參數和返回值機制並提供自定義最佳實踐

Spring對應枚舉傳參/返回值默認是用字面量實現的(實際情況更復雜),而《阿里巴巴Java開發手冊》規定接口返回值不可以使用枚舉類型(包括含枚舉類型的POJO對象),爲此,本文探究了Spring內部對枚舉參數的傳遞和處理機制,並提供了一套自定義方案。

一 目標與思路

0 起因

《阿里巴巴Java開發手冊》將接口中枚舉的使用分爲兩類,即 接口參數和接口返回值,並規定:
接口參數可以使用枚舉類型,但接口返回值不可以使用枚舉類型(包括含枚舉類型的POJO對象)

知乎有相關討論和作者親答,詳情可見:Java枚舉什麼不好,《阿里巴巴JAVA開發手冊》對於枚舉規定的考量是什麼?

現摘錄一部分作者回答如下:

由於升級原因,導致雙方的枚舉類不盡相同,在接口解析,類反序列化時出現異常

Java中出現的任何元素,在Gosling的角度都會有背後的思考和邏輯(儘管並非絕對完美,但Java的頂層抽象已經是天才級了),比如:接口、抽象類、註解、和本文提到的枚舉。枚舉有好處,類型安全,清晰直接,還可以使用等號來判斷,也可以用在switch中。它的劣勢也是明顯的,就是不要擴展。可是爲什麼在返回值和參數進行了區分呢,如果不兼容,那麼兩個都有問題,怎麼允許參數可以有枚舉。當時的考慮,如果參數也不能用,那麼枚舉幾乎無用武之地了。參數輸出,畢竟是本地決定的,你本地有的,傳送過去,向前兼容是不會有問題的。但如果是接口返回,就比較噁心了,因爲解析回來的這個枚舉值,可能本地還沒有,這時就會拋出序列化異常。

比如:你的本地枚舉類,有一個天氣Enum:SUNNY, RAINY, CLOUDY,如果根據天氣計算心情的方法:guess(WeatcherEnum xx),傳入這三個值都是可以的。返回值:Weather guess(參數),那麼對方運算後,返回一個SNOWY,本地枚舉裏沒有這個值,傻眼了。

當然,使用 code 照樣不能處理,對此,開發手冊作者的回答如下

主要是從防止這種序列化異常角度來考慮,使用code至少不會出大亂子。而catch序列化異常,有點像catch(NullPointerException e)一樣代碼過度,因爲它是可預檢異常。

1 統一稱謂

假如有一枚舉類如下:

public enum ReturnCodeEnum {
    OK(200),
    ERROR(500)
    ;
    private final int code;
    ReturnCodeEnum(int code){
        this.code=code;
    }
    public int getCode() {
        return code;
    }
}

枚舉實例有兩個默認屬性,nameordinal,可通過 name()和ordinal()方法分別獲得。其中 name 爲枚舉字面量(如 OK),ordinal 爲枚舉實例默認次序(從0開始)
需要注意的是,不建議使用枚舉的 ordinal,因爲枚舉實例應該是無序的,ordinal 提供的順序是不可靠的,所以我們應該使用自定義的枚舉字段 code。

後文爲方便闡述,以 字面量(name)、默認次序(ordinal)和 code來展開闡述。如 OK 的 字面量爲 OK,ordinal 爲 0 ,code爲 200。

2 目標

目標

  1. 直接使用 枚舉類型 接收參數返回值
  2. 系統自動將 參數中的 code 轉換爲 枚舉類型,自動將 返回值中的枚舉類型轉換爲 code

實現效果

對於實現通用code枚舉接口的枚舉類型,有如下效果:

  1. 使用 bean(application/x-www-form-urlencoded)接收時,支持 code 自動轉換爲 枚舉類型,同時兼容 字面量轉換爲枚舉類型。注意:表單接收的參數都視爲 String,即是將String轉爲 枚舉類型
  2. 使用 @RequestBody (application/json)接收時,默認只支持 code 自動轉換爲枚舉類型。如果需要同時支持 code 和 字面量(或者只支持字面量),可以在具體的枚舉類裏添加@JsonCreator註解的方法,下文會給出參考實現。
  3. 可以使用 @RequestParam 和 @PathVariable 接收枚舉類型參數
  4. 使用 @ResponseBody / @RestController(返回 Json)時,默認將 枚舉類型轉換爲 code。
  5. 在接收參數/返回值都不允許使用 ordinal ,這隻會導致混亂。

3 SpringMVC 對 枚舉參數的處理

此處只對 restful 接口進行討論。對於 restful 接口,Spring MVC 的返回值是使用 @ResponseBody 進行處理的。
而參數的接收方式則較多,對於非簡單類型,如 Enum ,一般的接收方法爲 Bean 接收或 @ResponseBody 接收。

Spring使用Bean接收枚舉參數

簡單來說 Spring 默認使用Bean接收枚舉參數時支持 字面量,這也是我們常見的做法。

參考自:Spring與枚舉參數

GET 請求和 POST Form 請求中的字符串到枚舉的轉化是通過 org.springframework.core.convert.support.StringToEnumConverterFactory 來實現的.
該類實現了接口 ConverterFactory ,通過調用 Enum.valueOf(Class, String) 實現了這個功能。
向下追溯源碼可以發現該方法實際上是從一個 Map<String, Enum> 的字典中獲取了轉換後的實際值,着這個 String 類型的 Key 的獲取方式就是 Enum.name() 返回的結果,即枚舉的字面值

Spring使用@RequestBody 接收枚舉參數

簡單來說 Spring使用@RequeseBody 接收枚舉參數時支持 字面量和 ordinal

對於@RequestBody,Spring會將其內容視爲一段 Json,所做工作爲使用 Jackson 完成反序列化。其實現會經過Jackson的EnumDeserializer的deserialize方法。感興趣的可以去看看源碼,這裏不貼出來,講一下思路:

  1. 使用字面量(String)進行反序列化
  2. 判斷是否是 int 類型,如果是使用 ordinal 進行反序列化,如果數字不在 ordinal 裏面,則拋異常
  3. 判斷是否是數組,是的話交由數組處理,否則拋異常

Spring使用@ResponseBody 返回值

如我們平常使用所見,返回的是字面量

4 思路

參照Spring對枚舉參數的處理,我們可以提供覆蓋/替換Spring的處理來達到我們的效果,
經本人測試,比較好的實現方案有(不考慮反射):

  1. 自定義Bean接收枚舉參數規則:
    1. 可行方案
      通過Spring MVC注入特定類型自定義轉換器實現從code到 枚舉的自動轉換
    2. 做法
      使用 WebMvcConfigurer的addFormatters注入自定義ConverterFactory,該工廠負責生成 通用code枚舉接口的實現類對應的轉換器
      詳見第二部分–代碼實現。
    3. 參考資料
      Spring Boot綁定枚舉類型參數
  2. 自定義@RequestBody 和@ResponseBody處理枚舉參數
    1. 可行方案
      使用@JsonValue自定義特定枚舉類的Jackson序列化/反序列化方式

      1. 具體做法
        使用 @JsonValue註解標記 獲取code值的枚舉實例方法。
      2. 注意事項
        該code值是使用jackson序列化/反序列化時枚舉對應的值,會覆蓋原來從字面量反序列化回枚舉的默認實現。
        如果想要保留原來從字面量反序列化回枚舉類的功能,需要自定義一個@JsonCreator 的構造/靜態工廠方法。
      3. 相關代碼
        代碼如下:
        @JsonValue
        public int getCode() {
            return code;
        }
        
        @JsonCreator
        public static ReturnCodeEnum create(String name){
            try{
                return ReturnCodeEnum.valueOf(name);
            }catch (IllegalArgumentException e){
                int code=Integer.parseInt(name);
               for (ReturnCodeEnum value : ReturnCodeEnum.values()) {
        	       if(value.code==code){
            	        return value;
        	       	}
            	}
        	}
        throw new IllegalArgumentException("No element matches "+name);
        }
        
    2. 不可行方案

      1. 替換@RequestBody和@ResponseBody或相關處理器 / 自定義HttpMessageConverter
      2. 使用@JsonCreator在接口層面定義反序列化規則
        • 不可行原因
          @JsonCreator只適用於枚舉類不適用於接口。
          @JsonCreator本質上是要在沒有類實例的時候使用的,所以只能標記在 構造方法或者靜態工廠方法上,接口的話不可行,傳統的接口方法屬實例方法,新增的 default 方法也屬實例方法,另外的 static 方法又不可繼承。所以這個思路只限於具體類型,不適用於接口。
        • 相關資料
          相關Jackson資料參考:
          Github:Jackson註解官方文檔
          Jackson常用註解詳解1 初級2 中級
      3. 適用@JsonDeserialize在接口層面定義反序列化規則
        • 不可行原因
          註解自定義從 json字符串 轉換爲 實體類的方法也只適用於枚舉類不適用於接口。
          使用@JsonDeserialize(using = 自定義反序列化類.class),在自定義Jackson反序列化類實現deserialize(JsonParser p, DeserializationContext ctxt)方法。
          可以獲取 json字符串(即 code),但沒辦法通過接口使用code獲取枚舉對象,理由同上,接口沒有可用的同時可繼承的方法。
        • 相關資料
          自定義Jackson序列化/反序列化類參考:IBM:Jackson 框架的高階應用

二 代碼實現

1 通用code枚舉接口

/**
 * @version V1.0
 * @author: linshenkx
 * @date: 2019/1/12
 * @description: 帶編號的枚舉接口
 */
public interface CodedEnum {
    /**
     * 使用jackson序列化/反序列化時枚舉對應的值
     * 如果想要保留原來從字面量反序列化回枚舉類的功能,
     * 需要自定義一個 @JsonCreator 的構造/靜態工廠方法
     * @return 自定義枚舉code
     */
    @JsonValue
    int getCode();

}

2 轉換器工廠類

代碼實現

/**
 * @version V1.0
 * @author: linshenkx
 * @date: 2019/1/12
 * @description: 帶編號的枚舉轉換器 工廠
 */
public class CodedEnumConverterFactory implements ConverterFactory<String, CodedEnum> {

    /**
     * 目標類型與對應轉換器的Map
     */
    private static final Map<Class,Converter> CONVERTER_MAP=new HashMap<>();

    /**
     * 根據目標類型獲取相應的轉換器
     * @param targetType 目標類型
     * @param <T> CodedEnum的實現類
     * @return
     */
    @Override
    public <T extends CodedEnum> Converter<String, T> getConverter(Class<T> targetType) {
        Converter converter=CONVERTER_MAP.get(targetType);
        if(converter==null){
            converter=new IntegerStrToEnumConverter<>(targetType);
            CONVERTER_MAP.put(targetType,converter);
        }
        return converter;
    }

    /**
     * 將int對應的字符串轉換爲目標類型的轉換器
     * @param <T> 目標類型(CodedEnum的實現類)
     */
    class IntegerStrToEnumConverter<T extends CodedEnum> implements Converter<String,T>{
        private Map<String,T> enumMap=new HashMap<>();

        private IntegerStrToEnumConverter(Class<T> enumType){
            T[] enums=enumType.getEnumConstants();
            for (T e:enums){
                //從 code 反序列化回枚舉
                enumMap.put(e.getCode()+"",e);
                //從枚舉字面量反序列回枚
                //是Spring默認的方案
                //此處添加可避免下面convert方法拋出IllegalArgumentException異常後被系統捕獲再次調用默認方案
                enumMap.put(((Enum)e).name()+"",e);
            }
        }

        @Override
        public T convert(String source) {
            T result=enumMap.get(source);
            if(result==null){
                //拋出該異常後,會調用 spring 的默認轉換方案,即使用 枚舉字面量進行映射
                throw new IllegalArgumentException("No element matches "+source);
            }
            return result;
        }
    }

}

3 Spring MVC 配置類

1 相關知識

  1. Spring Boot 默認提供Spring MVC 自動配置,不需要使用@EnableWebMvc註解
  2. 如果需要配置MVC(攔截器、格式化、視圖等) 請使用添加@Configuration並實現WebMvcConfigurer接口.不要添加@EnableWebMvc註解。
  3. @EnableWebMvc 只能添加到一個@Configuration配置類上,用於導入Spring Web MVC configuration
  4. 如果Spring Boot在classpath裏看到有 spring webmvc 也會自動添加@EnableWebMvc

簡單來說就是在SpringBoot中不要使用@EnableWebMvc,使用@Configuration標記自定義@WebMvcConfigurer類就行,而且該類允許多個同時存在。

相關資料:
Spring註解@EnableWebMvc使用坑點解析
解析@EnableWebMvc 、WebMvcConfigurationSupport和WebMvcConfigurationAdapter
WebMvcConfigurationSupport與WebMvcConfigurer的關係
@EnableWebMvc如何禁止@EnableAutoConfiguration

2 代碼實現

/**
 * @version V1.0
 * @author: linshenkx
 * @date: 2019/1/12
 * @description: 將轉換器工廠添加到Spring
 */
@Configuration
public class CodedEnumWebAppConfigurer implements WebMvcConfigurer {

    @Override
    public void addFormatters(FormatterRegistry registry) {
        registry.addConverterFactory(new CodedEnumConverterFactory());
    }

}

三 測試及分析

1 枚舉類

/**
 * @version V1.0
 * @author: linshenkx
 * @date: 2019/1/13
 * @description: 枚舉類
 */
public enum ReturnCodeEnum implements CodedEnum {

    /**
     * 正常
     */
    OK(200),
    /**
     * 出錯
     */
    ERROR(500)
    ;

    private final int code;

    ReturnCodeEnum(int code){
        this.code=code;
    }

    @Override
    public int getCode() {
        return code;
    }

    @JsonCreator
    public static ReturnCodeEnum create(String name){
        try{
            return ReturnCodeEnum.valueOf(name);
        }catch (IllegalArgumentException e){
            int code=Integer.parseInt(name);
            for (ReturnCodeEnum value : ReturnCodeEnum.values()) {
                if(value.code==code){
                    return value;
                }
            }
        }
        throw new IllegalArgumentException("No element matches "+name);
    }


}

2 包含枚舉類的POJO

/**
 * @version V1.0
 * @author: linshenkx
 * @date: 2019/1/12
 * @description: 枚舉包裝類
 */
@Data
public class MyResult{
    private ReturnCodeEnum returnCode;
    private String message;
}

3 測試類

/**
 * @version V1.0
 * @author: linshenkx
 * @date: 2019/1/12
 * @description: 測試類
 */
@RestController
@RequestMapping("/test")
public class TestController {

  @PostMapping(value = "/enumForm")
  public MyResult testEnumForm(
         @RequestBody MyResult myResult) {
    ReturnCodeEnum status = myResult.getReturnCode();
    System.out.println("name():"+status.name());
    System.out.println("ordinal():"+status.ordinal());
    System.out.println("getCode():"+status.getCode());
    return myResult;
  }

  @PostMapping(value = "/enumJson")
  public MyResult testEnumJson(
          @RequestBody MyResult myResult) {
    ReturnCodeEnum status = myResult.getReturnCode();
    System.out.println("name():"+status.name());
    System.out.println("ordinal():"+status.ordinal());
    System.out.println("getCode():"+status.getCode());
    return myResult;
  }
  
  @PostMapping(value = "/enumPath/{status}")
  public ReturnCodeEnum testEnumPath(
          @PathVariable ReturnCodeEnum status) {
    return status;
  }

  @PostMapping(value = "/enumParam")
  public ReturnCodeEnum testEnumParam(
          @RequestParam ReturnCodeEnum status) {
    return status;
  }
}

另外還需注入上面的轉換器工廠,這裏不再重複貼出。

4 測試結果

預測分析

如上,因爲ReturnCodeEnum 實現了 CodedEnum 接口,並注入對應轉換器工廠,所以可以在 表單提交的時候適用code和字面量接收枚舉參數。
ReturnCodeEnum還寫了@JsonValue註解的方法,所以使用Json傳參/返回值時使用@JsonValue對應的返回值。
因爲我們還想實現Json傳參的時候支持字面量,所以我們在@JsonCreator註解的方法裏寫了支持 code 和字面量,該方法會使@JsonValue 對反序列化的支持失效,所以寫的時候不僅要支持字面量還要支持原本的目的-----code。
由於我們已經覆蓋了原來的序列化/反序列化方式,所以 ordinal 的支持已經失效。
另外,由於我們可以將參數中的String轉化爲枚舉,所以我們也可以直接使用 @PathVariable 和 @RequestParam(Content-Type: multipart/form-data)來傳遞枚舉參數(相關資料:Baeldung:Guide to Spring Type Conversions),但是注意這個時候不能使用 包含枚舉類型的POJO類,除非你再定義一個從簡單類型到複合類型的轉換器。

1 bean接收(application/x-www-form-urlencoded)

  1. 字面量
    OK

    name():OK
    ordinal():0
    getCode():200
    
  2. code
    code

    name():OK
    ordinal():0
    getCode():200
    
  3. ordinal
    ordinal
    拋出異常

    Field error in object ‘myResult’ on field ‘returnCode’: rejected value [0]; codes [typeMismatch.myResult.returnCode,typeMismatch.returnCode,typeMismatch.com.dx.hbdt.system.manager.hongbao.controller.ReturnCodeEnum,typeMismatch]; arguments [org.springframework.context.support.DefaultMessageSourceResolvable: codes [myResult.returnCode,returnCode]; arguments []; default message [returnCode]]; default message [Failed to convert property value of type ‘java.lang.String’ to required type ‘com.dx.hbdt.system.manager.hongbao.controller.ReturnCodeEnum’ for property ‘returnCode’; nested exception is org.springframework.core.convert.ConversionFailedException: Failed to convert from type [java.lang.String] to type [com.dx.hbdt.system.manager.hongbao.controller.ReturnCodeEnum] for value ‘0’; nested exception is java.lang.IllegalArgumentException: No element matches 0]]

2 @RequestBody 接收(application/json)

  1. 字面量
    OK

    name():OK
    ordinal():0
    getCode():200
    
  2. code
    200

    name():OK
    ordinal():0
    getCode():200
    
  3. ordinal
    異常
    調用@JsonCreator的create方法的時候拋出 IllegalArgumentException 異常

  4. 其他
    字符串是要攜帶引號的,如果不攜帶引號會解析錯誤,如{ “returnCode”: OK }
    數字可不攜帶引號,在反序列化成枚舉時仍會看成 String 對待,並獲得與上面相同的效果。

3 @PathVariable

路徑

4 @RequestParam(multipart/form-data)

參數

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章