如何優雅的在Java應用中實現全域性列舉處理
背景描述
為了表達某一個屬性,具備一組可選的範圍,我們一般會採用兩種方式。列舉類和資料字典,兩者具有各自的優點。列舉類寫在Java程式碼中,方便編寫相應的判斷邏輯,程式碼可讀性高,列舉類中的屬性是可提前預估和確定的。資料字典,一般儲存在資料庫,不便於編寫判斷和分支邏輯,因為資料如果有所變動,那麼對應的程式碼邏輯很有可能失效,強依賴資料庫資料的正確性,資料字典中對應的屬性對業務影響並不大,日常開發中常用做分類,打標籤使用,屬性的多少無法估計。
目前基本上沒有一個很好的全域性處理列舉類的方案,所以我就自己綜合各方面資料寫了一個。
程式碼
架構還在不斷完善中,程式碼不一定可以跑起來,不過關於列舉的配置已經完成,大家可以閱讀並參考借鑑:pretty-demo
前言
大多數公司處理列舉的時候,會自定義一個列舉轉換工具類,或者在列舉類中編寫一個靜態方法實現Integer轉換列舉的方式。比如:
// 靜態方法方式 public enum GenderEnum { // 程式碼略 public static GenderEnum get(int value) { for (GenderEnum item : GenderEnum.values()) { if (value == item.getValue()) { return item; } } return null; } } // 工具類方式 public class EnumUtil { public static <E extends Enumerable> E of(@Nonnull Class<E> classType, int value) { for (E enumConstant : classType.getEnumConstants()) { if (value == enumConstant.getValue()) { return enumConstant; } } return null; } } GenderEnum gender = EnumUtil.of(GenderEnum.class,1);
這種方式很麻煩,或者需要手動編寫對應的靜態方法,或者需要手動呼叫工具類進行轉換。
解決方案
為了方便起見,我做了一個全域性列舉值轉換的方案,這個方案可以實現前端通過傳遞int到服務端,服務端自動轉換成列舉類,進行相應的業務判斷之後,再以數字的形式存到資料庫;我們在查資料的時候,又能將資料庫的數字轉換成java列舉類,在處理完對應的業務邏輯之後,將列舉和列舉類對應的展示資訊一起傳遞到前臺,前臺不需要維護這個列舉類和展示資訊的對應關係,同時展示資訊支援國際化處理,具體的方案如下:
1、基於約定大於配置的原則,制定統一的列舉類的編寫規則。大概規則如下:
- 每個列舉類有兩個欄位: int value(存資料庫),String key(通過key找對應的i18n文字資訊)。這塊需要細細討論下,列舉值通常存資料庫有存int值,也有存String值,各有利弊。存int的好處就是體積小,如果列舉的值是包含規律的,比如-1是刪除,0是預處理,1是處理,2是處理完成,那麼我們所有非刪除資料,我們可以不使用 status in ( 0,1,2)這種方式,而轉換為 status >= 0 ; 存String的話,好處就是可讀性高,直接能從資料庫的值中明白對應的狀態,劣勢就是佔的體積大點。當然這些都是相對的,存int的時候,我們可以完善好註釋,也具備好的可讀性。如果int換成String,佔的體積多的那一點,其實也可以忽略不計的。
- 列舉列舉類需要繼承統一介面,提供相應的方法供通用處理列舉時候使用。
下面是列舉介面和一個列舉示例:
public interface Enumerable<E extends Enumerable> { /** * 獲取在i18n檔案中對應的 key * @return key */ @Nonnull String getKey(); /** * 獲取最終儲存到資料庫的值 * @return 值 */ @Nonnull int getValue(); /** * 獲取 key 對應的文字資訊 * @return 文字資訊 */ @Nonnull default String getText() { return I18nMessageUtil.getMessage(this.getKey(), null); } } public enum GenderEnum implements Enumerable { /** 男 */ MALE(1, "male"), /** 女 */ FEMALE(2, "female"); private int value; private String key; GenderEnum(int value, String key) { this.value = value; this.key = key; } @Override public String getKey() { return this.key; } @Override public int getValue() { return this.value; } }
我們要做的就是,每個我們編寫的列舉類,都需要按這樣的方式進行編寫,按照規範定義的列舉類方便下面統一編寫。
2、我們分析下controller層面的資料進和出,從而處理好列舉類和int值的轉換,在Spring MVC中,框架幫我們做了資料型別的轉換,所以我們以 Spring MVC作為切入點。前臺傳送到服務端的請求,一般有引數在url中和body中兩種方式為主,分別以get請求和post請求配合@RequestBody為代表。
【入參】get方法為代表,請求的MediaType為"application/x-www-form-urlencoded",此時將 int 轉換成列舉,我們註冊一個新的Converter,如果spring MVC判斷到一個值要轉換成我們定義的列舉類物件時,呼叫我們設定的這個轉換器
@Configuration public class MvcConfiguration implements WebMvcConfigurer, WebBindingInitializer { /** * [get]請求中,將int值轉換成列舉類 * @param registry */ @Override public void addFormatters(FormatterRegistry registry) { registry.addConverterFactory(new EnumConverterFactory()); } } public class EnumConverterFactory implements ConverterFactory<String, Enumerable> { private final Map<Class, Converter> converterCache = new WeakHashMap<>(); @Override @SuppressWarnings({"rawtypes", "unchecked"}) public <T extends Enumerable> Converter<String, T> getConverter(@Nonnull Class<T> targetType) { return converterCache.computeIfAbsent(targetType, k -> converterCache.put(k, new EnumConverter(k)) ); } protected class EnumConverter<T extends Enumerable> implements Converter<Integer, T> { private final Class<T> enumType; public EnumConverter(@Nonnull Class<T> enumType) { this.enumType = enumType; } @Override public T convert(@Nonnull Integer value) { return EnumUtil.of(this.enumType, value); } } }
【入參】post為代表,將 int 轉換成列舉。這塊我們和前臺達成一個約定( Ajax中applicationType),所有在body中的資料必須為json格式。同樣後臺@RequestBody對應的引數的請求的MediaType為"application/json",spring MVC中對於Json格式的資料,預設使用 Jackson2HttpMessageConverter。在Jackson轉換成實體時候,有@JsonCreator和@JsonValue兩個註解可以用,但是感覺還是有點麻煩。為了統一處理,我們需要修改Jackson對列舉類的序列化和反序列的支援。配置如下:
@Configuration @Slf4j public class JacksonConfiguration { /** * Jackson的轉換器 * @return */ @Bean @Primary @SuppressWarnings({"rawtypes", "unchecked"}) public MappingJackson2HttpMessageConverter mappingJacksonHttpMessageConverter() { final MappingJackson2HttpMessageConverter converter = new MappingJackson2HttpMessageConverter(); ObjectMapper objectMapper = converter.getObjectMapper(); // Include.NON_EMPTY 屬性為 空("") 或者為 NULL 都不序列化,則返回的json是沒有這個欄位的。這樣對移動端會更省流量 objectMapper.setSerializationInclusion(JsonInclude.Include.NON_EMPTY); // 反序列化時候,遇到多餘的欄位不失敗,忽略 objectMapper.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false); // 允許出現特殊字元和轉義符 objectMapper.configure(JsonParser.Feature.ALLOW_UNQUOTED_CONTROL_CHARS, true); // 允許出現單引號 objectMapper.configure(JsonParser.Feature.ALLOW_SINGLE_QUOTES, true); SimpleModule customerModule = new SimpleModule(); customerModule.addDeserializer(String.class, new StringTrimDeserializer(String.class)); customerModule.addDeserializer(Enumerable.class, new EnumDeserializer(Enumerable.class)); customerModule.addSerializer(Enumerable.class, new EnumSerializer(Enumerable.class)); objectMapper.registerModule(customerModule); converter.setSupportedMediaTypes(ImmutableList.of(MediaType.TEXT_HTML, MediaType.APPLICATION_JSON)); return converter; } } public class EnumDeserializer<E extends Enumerable> extends StdDeserializer<E> { private Class<E> enumType; public EnumDeserializer(@Nonnull Class<E> enumType) { super(enumType); this.enumType = enumType; } @Override public E deserialize(JsonParser jsonParser, DeserializationContext deserializationContext) throws IOException { return EnumUtil.of(this.enumType, jsonParser.getIntValue()); } }
【出參】當我們查詢出結果,要展示給前臺的時候,我們會對結果集增加@ResponseBody註解,這時候會呼叫Jackson的序列化方法,所以我們增加了列舉類的序列配置。如果我們只簡單的將列舉轉換成 int 給前臺,那麼前臺需要維護這個列舉類的 int 和對應展示資訊的關係。所以這塊我們將值和展示資訊一同返給前臺,減輕前臺的工作壓力。
// 註冊列舉類序列化處理類 customerModule.addSerializer(Enumerable.class, new EnumSerializer(Enumerable.class)); public class EnumSerializer extends StdSerializer<Enumerable> { public EnumSerializer(@Nonnull Class<Enumerable> type) { super(type); } @Override public void serialize(Enumerable enumerable, JsonGenerator jsonGenerator, SerializerProvider serializerProvider) throws IOException { jsonGenerator.writeStartObject(); jsonGenerator.writeNumberField("value", enumerable.getValue()); jsonGenerator.writeStringField("text", enumerable.getText()); jsonGenerator.writeEndObject(); } }
這樣關於入參和出參的配置都完成了,我們可以保證,所有前臺傳遞到後臺的 int 都會自動轉換成列舉類。如果返回的資料有列舉類,列舉類也會包含值和展示文字,方便簡單。
3、儲存層關於列舉類的轉換。這裡選的 ORM 框架為 Mybatis ,但是你如果翻看官網 ,官網的資料只提供了兩個方案,就是通過列舉隱藏欄位name和ordinal的轉換,沒有一個通用列舉的解決方案。但是通過翻看 github 中的issue 和release 記錄,發現在 3.4.5版本中就提供了對應的自定義列舉處理配置,這塊不需要我們做過多的配置,我們直接增加 mybatis-spring-boot-starter 的依賴,直接配置對應的Yaml 檔案就實現了功能。
application.yml -- mybatis: configuration: default-enum-type-handler: github.shiyajian.pretty.config.enums.EnumTypeHandler
public class EnumTypeHandler<E extends Enumerable> extends BaseTypeHandler<E> { private Class<E> enumType; public EnumTypeHandler() { /* instance */ } public EnumTypeHandler(@Nonnull Class<E> enumType) { this.enumType = enumType; } @Override public void setNonNullParameter(PreparedStatement preparedStatement, int i, E e, JdbcType jdbcType) throws SQLException { preparedStatement.setInt(i, e.getValue()); } @Override public E getNullableResult(ResultSet rs, String columnName) throws SQLException { int value = rs.getInt(columnName); return rs.wasNull() ? null : EnumUtil.of(this.enumType, value); } @Override public E getNullableResult(ResultSet rs, int columnIndex) throws SQLException { int value = rs.getInt(columnIndex); return rs.wasNull() ? null : EnumUtil.of(this.enumType, value); } @Override public E getNullableResult(CallableStatement cs, int columnIndex) throws SQLException { int value = cs.getInt(columnIndex); return cs.wasNull() ? null : EnumUtil.of(this.enumType, value); } }
這樣我們就完成了從前臺頁面到業務程式碼到資料庫的儲存,從資料庫查詢到業務程式碼再到頁面的列舉類轉換。整個專案中完全不需要再手動去處理列舉類了。我們的開發流程簡單了很多。
結語
一個好的方案並不需要多麼高大上的技術,比如各種反射,各種設計模式,只要設計合理,就是簡單易用,類似中國古代的榫卯。
ps: 打算3月中旬辭職去杭州,3年多點,有需要招人的可以先交流交流:微信(q408859832),個人吹牛扯淡技術探討qq群(757696438)