如果變數值僅有有限的可選值,那麼用列舉類來定義常量是一個很常規的操作。
但是在業務程式碼中,我們不希望依賴 ordinary()
進行業務運算,而是自定義數字屬性,避免列舉值的增減調序造成影響。
@Getter
@AllArgsConstructor
public enum CourseType {
PICTURE(102, "圖文"),
AUDIO(103, "音訊"),
VIDEO(104, "視訊"),
;
private final int index;
private final String name;
}
但也正是因為使用了自定義的數字屬性,很多框架自帶的列舉轉化功能也就不再適用了。因此,我們需要自己來擴充套件相應的轉化機制,這其中包括:
- SpringMVC 列舉轉換器
- ORM 列舉對映
- JSON 序列化和反序列化
自定義 SpringMVC 列舉轉換器
明確需求
以上文的 CourseType
為例,我們希望達到的效果是:
前端傳參時給我們列舉的 index
值,在 controller 中,我們可以直接使用 CourseType
來接收,由框架負責完成 index
到 CourseType
的轉換。
@GetMapping("/list")
public void list(@RequestParam CourseType courseType) {
// do something
}
SpringMVC 自帶列舉轉換器
SpringMVC 自帶了兩個和列舉相關的轉換器:
- org.springframework.core.convert.support.StringToEnumConverterFactory
- org.springframework.boot.convert.StringToEnumIgnoringCaseConverterFactory
這兩個轉換器是通過呼叫列舉的 valueOf
方法來進行轉換的,感興趣的同學可以自行查閱原始碼。
實現自定義列舉轉換器
雖然這兩個轉換器不能滿足我們的需求,但它也給我們帶來了思路,我們可以通過模仿這兩個轉換器來實現我們的需求:
- 實現 ConverterFactory 介面,該介面要求我們返回 Converter,這是一個典型的工廠設計模式
- 實現 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);
}
}
怎麼生效呢?有兩種方式
- 將 AttributeConverter 註冊到全域性 JPA 容器中,此時需要與 javax.persistence.Converter 配合使用
- 第二種方式是配合 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... 這個我們下期揭曉~~