枚举处理规范

Posted by 帝八哥 on September 1, 2019

枚举处理规范

– 写在前面的话

约定配置确不假, 怎能封堵个性化?
三天两夜增白发, 欲破困局撸源码.
东海飞鱼穿浪花, 西湖静水邻古刹.
春去秋来接冬夏, 梦里还是那个她.

背景

枚举在项目使用中, 高频使用的是其值(value)和业务名称(name), 但在使用过程中命名的字段参差不齐, 易造成混乱.

问题1: 对于枚举的属性字段, 有的用code表示值, 有的用code表示名称…, 能否对枚举进行统一约束?

目前框架处理的约定如下

  • 枚举做请求参数
    • SpringBoot 2.x 默认使用StringToEnumIgnoringCaseConverterFactory处理
  • 枚举做响应
    • 使用枚举的默认序列化toString, 即Enum.name()
  • 枚举与数据库的映射
    • mybatis-spring-boot-start 2.x提供了Enum与数据库值映射的默认处理,
      默认是EnumTypeHandlerEnum.name(), 存储枚举类的名称,
      可配置为EnumOrdinalTypeHandlerEnum.original(), 存储枚举类的索引

但是, 实际应用中, 枚举带有的业务含义, 往往通过其属性字段反映

  • 枚举类的名称name用于代码(区别于枚举Enum.name())
  • 枚举类的业务中文名称viewName往往用于展示
  • 枚举类的值value存于数据库(区别于Enum.original()),
    若使用Enum.original()存于数据库, 那么枚举类定义顺序则一定不能变更, 降低开发友好性

问题2: 如何实现以下规则?
1.数字value存于数据库
2.字符串name用于指示业务含义, 可用于前端交互
3.(可选)字符串viewName则是业务含义的中文表达, 可用于前端交互

如果一并解决问题1,2, 如何实现?

方案

  • 定义一个接口IEnum
    • 包含getValue()待实现方法, 约束枚举类值的字段为value
    • 包含getName()待实现方法, 约束枚举类名称的字段为name
    • viewName作为可选项, 增加getViewName()默认方法, 默认返回空串, 子类可复写
  • 业务枚举类实现IEnum, 约束业务枚举类获取值和名称的方法(字段)
  • 对于请求参数对象中含有枚举字段, 以及响应对象中含有枚举字段, 自定义实现HttpMessageConverter消息转换器
    • 请求枚举转换规则: valuenameviewName
    • 响应枚举转换规则: viewNamenameEnum.name()
  • 对于数据库存储枚举值value, 自定义实现TypeHandler转换器,
    由于框架本身提供了默认枚举转换器, 自定义实现中对于那些未实现BaseXXX接口的处理做默认处理, 保留一定的兼容性

实现

  • 1.枚举约束, 基类接口
  • 2.请求响应枚举序列化转换器
  • 3.数据库枚举值映射处理器

1.1 枚举基类

/**
 * 枚举接口
 */
public interface IEnum {
    /**
     * 要存入数据库的类型值
     *
     * @return
     */
    Integer getValue();

    /**
     * 业务或代码上的类型名称(中文|英文)
     *
     * @return
     */
    String getName();

    /**
     * 类型别名, 可用于前端展示
     * 默认为空串
     *
     * @return
     */
    default String getViewName() {
        return StringUtils.EMPTY;
    }
}

1.2 枚举子类(示例)

/**
 * 成功与否状态枚举
 */
public enum SuccessStatusEnum implements IEnum {

    SUCCESSFUL(1, "Success"),
    FAILED(0, "Failed"),

    ;

    @Getter
    private Integer value;

    @Getter
    private String name;

    SuccessStatusEnum(Integer value, String name) {
        this.value = value;
        this.name = name;
    }
}

2. 请求与响应枚举转换处理器

  • 2.1 请求消息转换器
  • 2.1.1 枚举请求转换器工具类EnumRequestJSONConverterUtil
/**
 * 枚举请求转换器工具类
 *
 * @author caofanCPU
 */
public class EnumRequestJSONConverterUtil {

    @SuppressWarnings({"unchecked"})
    public static EnumRequestJSONConverter build() {
        return new EnumRequestJSONConverter();
    }

}
  • 2.1.2 枚举请求反序列化处理器EnumRequestJSONConverter
/**
 * 请求字段对应枚举类型序列化处理器
 * 如果发现是自定义IEnum的子类, 走自定义枚举转换: value -> name -> viewName
 * 否则, 走默认枚举转换: Enum.original() -> Enum.name(), 参见: StringToEnumIgnoringCaseConverterFactory
 *
 * @author caofanCPU
 */
@Slf4j
public class EnumRequestJSONConverter<E extends Enum<E>> extends JsonDeserializer<E> implements
        ContextualDeserializer {

    private Class<E> enumType;

    public EnumRequestJSONConverter() {
    }

    private EnumRequestJSONConverter(Class<E> enumType) {
        this.enumType = enumType;
    }

    @Override
    public E deserialize(JsonParser jsonParser, DeserializationContext deserializationContext)
            throws IOException {
        String source = jsonParser.getText();
        if (StringUtils.isBlank(source)) {
            log.warn("请求传参枚举字段为空, 则目标枚举类[{}]转换为null", this.enumType.getSimpleName());
            return null;
        }
        return IEnum.class.isAssignableFrom(enumType) ? this.customEnumParse(enumType, source) : this.originEnumParse(enumType, source);
    }

    @Override
    @SuppressWarnings("unchecked")
    public JsonDeserializer<?> createContextual(DeserializationContext deserializationContext, BeanProperty beanProperty)
            throws JsonMappingException {
        Class<?> rawClass = deserializationContext.getContextualType().getRawClass();
        return new EnumRequestJSONConverter(rawClass);
    }

    /**
     * 转换顺序: value -> name -> viewName
     *
     * @param
     * @param source
     * @return
     */
    private E customEnumParse(Class<E> enumType, String source) {
        Integer value = null;
        E resultEnum = null;
        try {
            value = Integer.parseInt(source);
        } catch (Exception e) {
            // do nothing
        }
        E[] enumConstants = enumType.getEnumConstants();
        for (E enumConstant : enumConstants) {
            if (!(enumConstant instanceof IEnum)) {
                continue;
            }
            IEnum temp = (IEnum) enumConstant;
            if ((Objects.nonNull(value) && value.equals(temp.getValue())
                    || source.equals(temp.getName())
                    || source.equals(temp.getViewName()))) {
                resultEnum = enumConstant;
            }
        }
        if (Objects.isNull(resultEnum)) {
            log.error("接口传参枚举转换错误, 原因: 传值[{}], 目标枚举类[{}]", source, this.enumType.getSimpleName());
            throw new RuntimeException("参数非法, [" + this.enumType.getSimpleName() + "]不存在枚举值[" + source + "]");
        }
        return resultEnum;
    }

    private E originEnumParse(Class<E> enumType, String source) {
        if (source.isEmpty()) {
            return null;
        }
        source = source.trim();
        try {
            return Enum.valueOf(enumType, source);
        } catch (Exception ex) {
            return findEnum(enumType, source);
        }
    }

    private E findEnum(Class<E> enumType, String source) {
        String name = getLettersAndDigits(source);
        for (E candidate : EnumSet.allOf(enumType)) {
            if (getLettersAndDigits(candidate.name()).equals(name)) {
                return candidate;
            }
        }
        throw new IllegalArgumentException("No enum constant "
                + this.enumType.getCanonicalName() + "." + source);
    }

    private String getLettersAndDigits(String name) {
        StringBuilder canonicalName = new StringBuilder(name.length());
        name.chars().map((c) -> (char) c).filter(Character::isLetterOrDigit)
                .map(Character::toLowerCase).forEach(canonicalName::append);
        return canonicalName.toString();
    }
}
  • 2.2 响应消息转换器
  • 2.2.1 枚举响应转换器工具类EnumResponseJSONConverterUtil
/**
 * 枚举响应转换器
 */
public class EnumResponseJSONConverterUtil {

    public static JsonSerializer build() {
        return new EnumResponseJSONConverter();
    }
}
  • 2.2.2 枚举响应序列化处理器EnumResponseJSONConverter
/**
 * 响应字段枚举类型序列化处理器
 * 如果发现是自定义IEnum的子类, 走自定义枚举转换: viewName -> name -> Enum.name()
 * 否则, 取Enum.name()
 *
 * @author caofanCPU
 */
@Slf4j
public class EnumResponseJSONConverter<E extends Enum<E>> extends JsonSerializer<E> {

    @Override
    public void serialize(E enumInstance, JsonGenerator jsonGenerator, SerializerProvider serializerProvider) {
        if (Objects.isNull(enumInstance)) {
            return;
        }
        String name;
        if (enumInstance instanceof IEnum) {
            // 自定义
            IEnum temp = (IEnum) enumInstance;
            name = StringUtils.isNotBlank(temp.getViewName()) ? temp.getViewName() : temp.getName();
            if (StringUtils.isBlank(name)) {
                name = enumInstance.name();
            }
        } else {
            // 默认
            name = enumInstance.name();
        }

        try {
            jsonGenerator.writeString(name);
        } catch (IOException ex) {
            log.error("枚举响应转换异常, 枚举[{}]原因: {}", enumInstance.name(), ex);
        }
    }
}
  • 2.3 个性化Http请求响应消息转换处理器工具类MappingJackson2HttpMessageConverterUtil
    借助Jackson2ObjectMapperBuilder + JDK8新特性 + 时间处理
/**
 * 自定义消息转换器工具类
 * 1.过滤null字段
 * 2.自定义响应枚举转换 + 请求枚举转换
 * 3.时间处理(包含时区): "2019-12-31 13:14:15", 
 *   项目当前限定只支持LocalXXX, 首推LocalDateTime
 *   也可考虑使用Instant
 * 4.jackson底层默认UTF8编码
 *
 * @author caofanCPU
 */
public class MappingJackson2HttpMessageConverterUtil {

    @SuppressWarnings("unchecked")
    public static MappingJackson2HttpMessageConverter build() {
        return new MappingJackson2HttpMessageConverter(
                Jackson2ObjectMapperBuilder
                        .json()
                        .serializationInclusion(JsonInclude.Include.NON_NULL)
                        .modules(Lists.newArrayList(
                                new Jdk8Module(),
                                new ParameterNamesModule(),
                                new JavaTimeModule()
                                        .addSerializer(LocalDateTime.class, new LocalDateTimeSerializer(DateTimeFormatter.ofPattern(DateUtil.DATETIME_FORMAT_SIMPLE)))
                                        .addSerializer(LocalDate.class, new LocalDateSerializer(DateTimeFormatter.ofPattern(DateUtil.DATE_FORMAT_SIMPLE)))
                                        .addSerializer(LocalTime.class, new LocalTimeSerializer(DateTimeFormatter.ofPattern(DateUtil.TIME_FORMAT_SIMPLE)))
                                        .addDeserializer(LocalDateTime.class, new LocalDateTimeDeserializer(DateTimeFormatter.ofPattern(DateUtil.DATETIME_FORMAT_SIMPLE)))
                                        .addDeserializer(LocalDate.class, new LocalDateDeserializer(DateTimeFormatter.ofPattern(DateUtil.DATE_FORMAT_SIMPLE)))
                                        .addDeserializer(LocalTime.class, new LocalTimeDeserializer(DateTimeFormatter.ofPattern(DateUtil.TIME_FORMAT_SIMPLE))),
                                // 请求枚举类型的反序列化转换器 + 响应枚举类型的序列化转换器
                                new SimpleModule().addDeserializer(Enum.class, EnumRequestJSONConverterUtil.build())
                                        .addSerializer(Enum.class, EnumResponseJSONConverterUtil.build()),
                                new JsonComponentModule()
                                )
                        )
                        .featuresToDisable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS)
                        .featuresToDisable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES)
                        .featuresToEnable(MapperFeature.PROPAGATE_TRANSIENT_MARKER)
                        .build()
                        .setTimeZone(TimeZone.getTimeZone(ZoneId.systemDefault()))
        );
    }
}
  • 2.4 注册Sring的WebApplicationContext
    使用@Configuration + 实现WebMvcConfigurer完成, 请留意doc给出的采坑心得
/**
 * WEB配置
 * 可增加拦截器、异步/跨域支持
 * 注意: 
 *         1.@EnableWebMvc + implements WebMvcConfigurer
 *         2.extends WebMvcConfigurationSupport
 *         都会覆盖@EnableAutoConfiguration关于WebMvcAutoConfiguration的配置
 *         例如: 请求参数时间格式/响应字段为NULL剔除
 *         因此, 推荐使用 implements WebMvcConfigurer方式, 保留原有配置
 *         3.自定义消息转换器, 推荐直接使用注册Bean
 *         也可使用复写extendMessageConverters()方法,
 *         但是注意: 不要使用configureMessageConverters, 该方法要么不起作用, 要么关闭了默认配置
 *
 * @author caofan
 */
@Configuration
@Slf4j
public class WebConfig implements WebMvcConfigurer {

    /**
     * 剔除响应对象中为NULL的字段
     * 请求枚举类转换: value -> name -> viewName
     * 响应枚举类转换: viewName -> name
     *
     */
    @Bean(name = "customerMappingJackson2HttpMessageConverter")
    public HttpMessageConverter customerMappingJackson2HttpMessageConverter() {
        log.info("枚举请求&&响应转换器初始化完成!");
        return MappingJackson2HttpMessageConverterUtil.build();
    }
}

3.数据库枚举映射处理器

  • 3.1 自定义MyBatis枚举类型转换处理器
/**
 * MyBatis枚举类型转换处理器
 *
 * @author caofanCPU
 */
@Slf4j
public class BaseMybatisEnumTypeHandler<E extends Enum<E>> extends BaseTypeHandler<E> {

    private final Class<E> type;
    private final E[] enums;

    public BaseMybatisEnumTypeHandler(Class<E> type) {
        if (Objects.isNull(type)) {
            throw new IllegalArgumentException("Type argument cannot be null");
        }
        this.type = type;
        this.enums = type.getEnumConstants();
        if (Objects.isNull(this.enums)) {
            throw new IllegalArgumentException(type.getSimpleName() + " does not represent an enum type.");
        }
    }

    @Override
    public void setNonNullParameter(PreparedStatement ps, int i, E parameter, JdbcType jdbcType)
            throws SQLException {
        ps.setInt(i, ((IEnum) parameter).getValue());
    }

    @Override
    public E getNullableResult(ResultSet rs, String columnName)
            throws SQLException {
        int value = rs.getInt(columnName);
        return rs.wasNull() ? null : valueOf(value);
    }

    @Override
    public E getNullableResult(ResultSet rs, int columnIndex)
            throws SQLException {
        int value = rs.getInt(columnIndex);
        return rs.wasNull() ? null : valueOf(value);
    }

    @Override
    public E getNullableResult(CallableStatement cs, int columnIndex)
            throws SQLException {
        int value = cs.getInt(columnIndex);
        return cs.wasNull() ? null : valueOf(value);
    }

    private E valueOf(int value) {
        E resultEnum = null;
        for (E enumConstant : enums) {
            if (!(enumConstant instanceof IEnum)) {
                continue;
            }
            IEnum temp = (IEnum) enumConstant;
            if (value == temp.getValue()) {
                resultEnum = enumConstant;
            }
        }
        if (Objects.isNull(resultEnum)) {
            log.error("枚举转换异常: 值[{}]无法转换为枚举类[{}]", value, type.getSimpleName());
            throw new RuntimeException("Cannot convert " + value + " to " + type.getSimpleName() + " by enum value.");
        }
        return resultEnum;
    }
}

  • 3.2 枚举类型自动转换器AutoDispatchMyBatisEnumTypeHandler
/**
 * 枚举类型自动转换器
 */
@SuppressWarnings("unchecked")
@Slf4j
public class AutoDispatchMyBatisEnumTypeHandler<E extends Enum<E>> extends BaseTypeHandler<E> {

    private BaseTypeHandler typeHandler;

    public AutoDispatchMyBatisEnumTypeHandler(Class<E> enumType) {
        if (Objects.isNull(enumType)) {
            throw new RuntimeException("参数非法, 类型不能为空");
        }
        if (IEnum.class.isAssignableFrom(enumType)) {
            typeHandler = new BaseMybatisEnumTypeHandler(enumType);
            log.info("创建枚举类型: [{}]的自定义DB转换器: [{}]", enumType.getSimpleName(), typeHandler.getClass().getSimpleName());
        } else {
            typeHandler = new EnumOrdinalTypeHandler(enumType);
            log.info("创建枚举类型: [{}]的默认DB转换器: [{}]", enumType.getSimpleName(), typeHandler.getClass().getSimpleName());
        }
    }

    @Override
    public void setNonNullParameter(PreparedStatement ps, int i, E parameter, JdbcType jdbcType)
            throws SQLException {
        typeHandler.setNonNullParameter(ps, i, parameter, jdbcType);
    }

    @Override
    public E getNullableResult(ResultSet rs, String columnName)
            throws SQLException {
        return (E) typeHandler.getNullableResult(rs, columnName);
    }

    @Override
    public E getNullableResult(ResultSet rs, int columnIndex)
            throws SQLException {
        return (E) typeHandler.getNullableResult(rs, columnIndex);
    }

    @Override
    public E getNullableResult(CallableStatement cs, int columnIndex)
            throws SQLException {
        return (E) typeHandler.getNullableResult(cs, columnIndex);
    }
}
  • 3.3 注册数据库枚举转换器
  • (推荐)启动配置文件application.yamlapplication.properties中增加配置
#Mybatis配置(yaml风格)
mybatis:
  configuration:
    default-enum-type-handler: 文件父目录.AutoDispatchMyBatisEnumTypeHandler

# Mybatis配置(properties风格)
mybatis.configuration.default-enum-type-handler: 文件父目录.AutoDispatchMyBatisEnumTypeHandler
  • Bean配置方式
/**
 * MyBatis配置
 * 推荐使用mybatis.configuration.default-enum-type-handler进行配置
 *
 * @see <a href=http://www.mybatis.org/spring-boot-starter/mybatis-spring-boot-autoconfigure/>
 * @author caofanCPU
 */
@Configuration
@Slf4j
@Deprecated
public class MyBatisConfig {

    /**
     * 方式二: 个性化设置默认枚举转换类
     * <p>
     * mybatis-spring-boot-start支持配置自定义枚举转换器, 不推荐用此方式
     */
    @Bean
    @Deprecated
    public ConfigurationCustomizer configurationCustomizer() {
        log.info("自定义枚举转换器注册成功!");
        return configuration -> configuration.setDefaultEnumTypeHandler(AutoDispatchMyBatisEnumTypeHandler.class);
    }


    /**
     * 方式三: 设置sqlSessionFactory属性
     * 依赖: @AutoConfigureAfter(MybatisAutoConfiguration.class)
     *
     * @deprecated 不推荐
     */
//    @Resource
    private SqlSessionFactory sqlSessionFactory;

    /**
     * 注册自定义枚举转换器
     */
//    @PostConstruct
    @Deprecated
    public void customMybatisEnumTypeHandler() {
        TypeHandlerRegistry typeHandlerRegistry = sqlSessionFactory.getConfiguration().getTypeHandlerRegistry();
        typeHandlerRegistry.setDefaultEnumTypeHandler(AutoDispatchMyBatisEnumTypeHandler.class);
        log.info("自定义枚举转换器注册成功!");
    }
}

验证

  • Model实体含枚举字段SuccessEnum status

  • 数据库枚举转换器, 注册成功, 以及将枚举SuccessEnum..SUCCESSFUL转为数字 1(Integer)

  • POSTMan测试结果, 请求字段status使用枚举SuccessEnum.SUCCESSFUL#getName(), 响应字段status使用枚举SuccessEnum..SUCCESSFUL#getName()

参考信息

总结

  • 求🌟🌟, 分享不易, 请老铁给我的github主页的6个项目点赞, 谢谢!
  • 捐赠|Donate, 实践撰文分享实属不易, 您的支持能为更多省时省事的分享提速, 谢谢!

微信


支付宝


MiXin