一站式解決使用列舉的各種痛點

Coder小黑發表於2020-05-06

如果變數值僅有有限的可選值,那麼用列舉類來定義常量是一個很常規的操作。

但是在業務程式碼中,我們不希望依賴 ordinary() 進行業務運算,而是自定義數字屬性,避免列舉值的增減調序造成影響。

@Getter
@AllArgsConstructor
public enum CourseType {

    PICTURE(102, "圖文"),
    AUDIO(103, "音訊"),
    VIDEO(104, "視訊"),
    ;

    private final int index;
    private final String name;
}

但也正是因為使用了自定義的數字屬性,很多框架自帶的列舉轉化功能也就不再適用了。因此,我們需要自己來擴充套件相應的轉化機制,這其中包括:

  1. SpringMVC 列舉轉換器
  2. ORM 列舉對映
  3. JSON 序列化和反序列化

自定義 SpringMVC 列舉轉換器

明確需求

以上文的 CourseType 為例,我們希望達到的效果是:

前端傳參時給我們列舉的 index 值,在 controller 中,我們可以直接使用 CourseType 來接收,由框架負責完成 indexCourseType 的轉換。

@GetMapping("/list")
public void list(@RequestParam CourseType courseType) {
    // do something
}

SpringMVC 自帶列舉轉換器

SpringMVC 自帶了兩個和列舉相關的轉換器:

  • org.springframework.core.convert.support.StringToEnumConverterFactory
  • org.springframework.boot.convert.StringToEnumIgnoringCaseConverterFactory

這兩個轉換器是通過呼叫列舉的 valueOf 方法來進行轉換的,感興趣的同學可以自行查閱原始碼。

實現自定義列舉轉換器

雖然這兩個轉換器不能滿足我們的需求,但它也給我們帶來了思路,我們可以通過模仿這兩個轉換器來實現我們的需求:

  1. 實現 ConverterFactory 介面,該介面要求我們返回 Converter,這是一個典型的工廠設計模式
  2. 實現 Converter 介面,完成自定義數字屬性到列舉類的轉化

廢話不多說,上原始碼:

/**
 * springMVC 列舉類的轉換器
 * 如果列舉類中有工廠方法(靜態方法)被標記為{@link EnumConvertMethod },則呼叫該方法轉為列舉物件
 */
@SuppressWarnings("all")
public class EnumMvcConverterFactory implements ConverterFactory<String, Enum<?>> {

    private final ConcurrentMap<Class<? extends Enum<?>>, EnumMvcConverterHolder> holderMapper = new ConcurrentHashMap<>();


    @Override
    public <T extends Enum<?>> Converter<String, T> getConverter(Class<T> targetType) {
        EnumMvcConverterHolder holder = holderMapper.computeIfAbsent(targetType, EnumMvcConverterHolder::createHolder);
        return (Converter<String, T>) holder.converter;
    }


    @AllArgsConstructor
    static class EnumMvcConverterHolder {
        @Nullable
        final EnumMvcConverter<?> converter;

        static EnumMvcConverterHolder createHolder(Class<?> targetType) {
            List<Method> methodList = MethodUtils.getMethodsListWithAnnotation(targetType, EnumConvertMethod.class, false, true);
            if (CollectionUtils.isEmpty(methodList)) {
                return new EnumMvcConverterHolder(null);
            }
            Assert.isTrue(methodList.size() == 1, "@EnumConvertMethod 只能標記在一個工廠方法(靜態方法)上");
            Method method = methodList.get(0);
            Assert.isTrue(Modifier.isStatic(method.getModifiers()), "@EnumConvertMethod 只能標記在工廠方法(靜態方法)上");
            return new EnumMvcConverterHolder(new EnumMvcConverter<>(method));
        }

    }

    static class EnumMvcConverter<T extends Enum<T>> implements Converter<String, T> {

        private final Method method;

        public EnumMvcConverter(Method method) {
            this.method = method;
            this.method.setAccessible(true);
        }

        @Override
        public T convert(String source) {
            if (source.isEmpty()) {
                // reset the enum value to null.
                return null;
            }
            try {
                return (T) method.invoke(null, Integer.valueOf(source));
            } catch (Exception e) {
                throw new IllegalArgumentException(e);
            }
        }

    }


}

  • EnumMvcConverterFactory :工廠類,用於建立 EnumMvcConverter

  • EnumMvcConverter:自定義列舉轉換器,完成自定義數字屬性到列舉類的轉化

  • EnumConvertMethod:自定義註解,在自定義列舉類的工廠方法上標記該註解,用於 EnumMvcConverter 來進行列舉轉換

EnumConvertMethod 的具體原始碼如下:

@Target({ElementType.METHOD})
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface EnumConvertMethod {
}

怎麼使用

1、註冊 EnumMvcConverterFactory

@Configuration
public class MvcConfiguration implements WebMvcConfigurer {

    @Bean
    public EnumMvcConverterFactory enumMvcConverterFactory() {
        return new EnumMvcConverterFactory();
    }

    @Override
    public void addFormatters(FormatterRegistry registry) {
        // org.springframework.core.convert.support.GenericConversionService.ConvertersForPair.add
        // this.converters.addFirst(converter);
        // 所以我們自定義的會放在前面
        registry.addConverterFactory(enumMvcConverterFactory());
    }
}

2、在自定義列舉中提供一個工廠方法,完成自定義數字屬性到列舉類的轉化,同時在該工廠方法上新增 @EnumConvertMethod 註解

@Getter
@AllArgsConstructor
public enum CourseType {

    PICTURE(102, "圖文"),
    AUDIO(103, "音訊"),
    VIDEO(104, "視訊"),
    ;

    private final int index;
    private final String name;

    private static final Map<Integer, CourseType> mappings;

    static {
        Map<Integer, CourseType> temp = new HashMap<>();
        for (CourseType courseType : values()) {
            temp.put(courseType.index, courseType);
        }
        mappings = Collections.unmodifiableMap(temp);
    }

    @EnumConvertMethod
    @Nullable
    public static CourseType resolve(int index) {
        return mappings.get(index);
    }
}

自定義 ORM 列舉對映

遇到什麼問題

還是以上述的 CourseType 列舉為例,一般業務程式碼的資料都要持久化到 DB 中的。假設,現在有一張課程後設資料表,用於記錄當前課程所屬的型別,我們的 entity 物件可能是這樣的:

@Getter
@Setter
@Entity
@Table(name = "course_meta")
public class CourseMeta {
    private Integer id;

    /**
     * 課程型別,{@link CourseType}
     */
    private Integer type;
}

上述做法是通過 javadoc 註釋的方式來告訴使用方 type 的取值型別是被關聯到了 CourseType。

但是,我們希望通過更清晰的程式碼來避免註釋,讓程式碼不言自明

因此,能不能讓 ORM 在對映的時候,直接把 Integer 型別的 type 對映成 CourseType 列舉呢?答案是可行的。

AttributeConverter

我們當前系統使用的是 Spring Data JPA 框架,是對 JPA 的進一步封裝。因此,本文只提供在 JPA 環境下的解決方案。

在 JPA 規範中,提供了 javax.persistence.AttributeConverter 介面,用於擴充套件物件屬性和資料庫欄位型別的對映。

public class CourseTypeEnumConverter implements AttributeConverter<CourseType, Integer> {

    @Override
    public Integer convertToDatabaseColumn(CourseType attribute) {
        return attribute.getIndex();
    }

    @Override
    public CourseType convertToEntityAttribute(Integer dbData) {
        return CourseType.resolve(dbData);
    }
}

怎麼生效呢?有兩種方式

  1. 將 AttributeConverter 註冊到全域性 JPA 容器中,此時需要與 javax.persistence.Converter 配合使用
  2. 第二種方式是配合 javax.persistence.Convert 使用,在需要的地方指定 AttributeConverter,此時不會全域性生效

本文選擇的是第二種方式,在需要的地方指定 AttributeConverter,具體程式碼如下:

@Getter
@Setter
@Entity
@Table(name = "ourse_meta")
public class CourseMeta {
    private Integer id;

    @Convert(converter = CourseTypeEnumConverter.class)
    private CourseType type;
}

JSON 序列化

到這裡,我們已經解決了 SpringMVC 和 ORM 對自定義列舉的支援,那是不是這樣就足夠了呢?還有什麼問題呢?

SpringMVC 的列舉轉化器只能支援 GET 請求的引數轉化,如果前端提交 JSON 格式的 POST 請求,那還是不支援的。

另外,在給前端輸出 VO 時,預設情況下,還是要手動把列舉型別對映成 Integer 型別,並不能在 VO 中直接使用列舉輸出。

@Data
public class CourseMetaShowVO {
    private Integer id;
    private Integer type;

    public static CourseMetaShowVO of(CourseMeta courseMeta) {
        if (courseMeta == null) {
            return null;
        }
        CourseMetaShowVO vo = new CourseMetaShowVO();
        vo.setId(courseMeta.getId());
        // 手動轉化列舉
        vo.setType(courseMeta.getType().getIndex());
        return vo;
    }
}

@JsonValue 和 @JsonCreator

Jackson 是一個非常強大的 JSON 序列化工具,SpringMVC 預設也是使用 Jackson 作為其 JSON 轉換器。

Jackson 為我們提供了兩個註解,剛好可以解決這個問題。

  • @JsonValue: 在序列化時,只序列化 @JsonValue 註解標註的值
  • @JsonCreator:在反序列化時,呼叫 @JsonCreator 標註的構造器或者工廠方法來建立物件

最後的程式碼如下:

@Getter
@AllArgsConstructor
public enum CourseType {

    PICTURE(102, "圖文"),
    AUDIO(103, "音訊"),
    VIDEO(104, "視訊"),
    ;

    @JsonValue
    private final int index;
    private final String name;

    private static final Map<Integer, CourseType> mappings;

    static {
        Map<Integer, CourseType> temp = new HashMap<>();
        for (CourseType courseType : values()) {
            temp.put(courseType.index, courseType);
        }
        mappings = Collections.unmodifiableMap(temp);
    }

    @EnumConvertMethod
    @JsonCreator(mode = JsonCreator.Mode.DELEGATING)
    @Nullable
    public static CourseType resolve(int index) {
        return mappings.get(index);
    }
}

擴充套件 swagger 對列舉的支援

經過上述的一些自定義轉換器,基本解決了在程式碼中使用列舉的一些痛點。但是,你以為這就夠了嗎?

現在大部分的程式碼都在使用 swagger 來編寫文件,不知道大家有沒有這樣的痛點:

在編寫文件時,需要告訴前端列舉型別有哪些取值,每次增加取值之後,不僅要改程式碼,還要找到對應的取值在哪裡使用了,然後修改 swagger 文件。

反正小黑我覺得這樣做很不爽,那有沒有什麼辦法可以讓 swagger 框架來幫我們自動列舉出所有的列舉數值呢?辦法當然是有的啦!

怎麼做呢?emmm... 這個我們下期揭曉~~

相關文章