问题概述 枚举值在db中存储一般是整形或者字符串,在代码里一般是枚举。我们的需求是在model里使用一种自定义类型,能够:
校验用户提供的数据在枚举允许范围内,否则自动报错;
在输出给用户时,json序列化自动加上枚举的中文含义;
自动通过json反序列化到该类型;
存入db时自动转化为对应的整形和字符串;
从db中取出数据时自动转为该自定义类型;
其中1通过自定义validator实现,2/3通过自定义序列化/反序列化实现,4/5通过自定义typeHandler实现。
数据字典 枚举值有两种实现方式:写死在代码里,或者使用数据字典存储在db/redis里。下面只介绍数据字典存储在db里的方案,其他几种情况处理手段类似。假设数据字典sys_dict
包括以下几列:
1 2 3 feat_code varchar (64 ) default '' not null comment '英文代码' , value_str varchar (32 ) default '' not null comment '枚举值' , value_cn varchar (128 ) default '' not null comment '枚举值(中文)' ,
并且使用以下服务:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 @Service public class SysDictService extends ServiceImpl <SysDictMapper , SysDict > { @Cacheable(cacheNames = Constants.AUTO_CACHE_PREFIX) public Map<String, String> getCodeEnums (@NotNull String code) { Wrapper<SysDict> wrapper = new QueryWrapper<SysDict>() .eq(SysDict.COL_FEAT_CODE, code); return this .list(wrapper).stream().collect( Collectors.toMap( SysDict::getValueStr, SysDict::getValueCN ) ); } public boolean isValidValue (String key, String value) { Wrapper<SysDict> wrapper = new QueryWrapper<SysDict>() .eq(SysDict.COL_FEAT_CODE, key) .eq(SysDict.COL_VALUE_STR, value); return this .getOne(wrapper) != null ; } }
定义自定义类型 使用内置枚举类型 mybatis-plus其实内置了这部分支持,不过他是直接将枚举值替换成对应的中文。我们在此基础上进行扩展:
1 2 3 4 5 6 7 8 9 10 11 12 13 public interface IDictEnum <T extends Serializable > extends IEnum <T > { @JsonValue @Override T getValue () ; String getDictCode () ; }
使用的时候,自定义枚举类型实现上述接口。例如:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 public enum CorpKindEnum implements IDictEnum <Integer > { CORPORATION(0 ), GOVERNMENT(1 ), COMMON(2 ); private Integer v; CorpKindEnum(Integer v) { this .v = v; } @Override public Integer getValue () { return v; } @Override public String getDictCode () { return "CORP_KIND_CODE" ; } }
这是一个整形枚举,我们使用CORP_KIND_CODE
作为feat_code
,在数据字典里插入对应的值和中文释疑。自定义Json序列化如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 @Component public class DictEnumSerializer extends JsonSerializer <IDictEnum > { private static SysDictService service; @Autowired public DictEnumSerializer (SysDictService service) { DictEnumSerializer.service = service; } public DictEnumSerializer () { super (); } @Override public void serialize (IDictEnum value, JsonGenerator generator, SerializerProvider provider) throws IOException { if (value == null ) { generator.writeNull(); return ; } generator.writeObject(value.getValue()); generator.writeFieldName(generator.getOutputContext().getCurrentName() + "CN" ); generator.writeString(getEnumDesc(value)); } @Override public Class handledType () { return IDictEnum.class; } private String getEnumDesc (IDictEnum dictEnum) { Map<String, String> map = service.getCodeEnums(dictEnum.getDictCode()); return map.get(dictEnum.getValue().toString()); } }
这里在序列化的时候将字段名后面加上CN
,同时通过数据字典查询对应的中文含义注入。
由于我们这里扩展了mybatis-plus的内置类型,使用时就不需要加上typeHandler的注解了。
问题 : jackson在通过整数反序列化到枚举时,无法通过指定的值来反序列化,而是通过整数的顺序(ordinal)来进行的。如果枚举类型不是从0-N这种赋值,则反序列化结果不正确。这是一个存在历史很悠久的bug 。
解决方案是在枚举里自定义JsonCreator
:
1 2 3 4 5 6 7 8 9 @JsonCreator public static CorpKindEnum fromValue (Integer v) { for (CorpKindEnum element : values()) { if (element.getValue().equals(v)) { return element; } } return null ; }
由于Java语言的限制,我们无法扩展内置的enum
类型,所以每个自定义枚举上都要自己加上JsonCreator
。一个简单的解决方案是全部使用字符串枚举,当然这会比使用integer更慢,也比tinyint/smallint更占数据库空间。
使用自定义类 上述方案可以解决大部分枚举的问题。但是有些情况下,枚举值特别多,我们又不想在代码里全部列出来,这时候使用内置枚举类型就不太合适了。我们自定义一个抽象类:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 @EqualsAndHashCode @ToString public abstract class BaseDictValue <T extends Serializable > implements IDictEnum <T > { @Setter private T value; public BaseDictValue (T value) { this .value = value; } @Override public T getValue () { return value; } }
可以看到实际上和刚才的接口是一致的,不再赘述。然后我们实现自定义的typeHandler:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 @Slf4j public class DictValueHandler <E extends BaseDictValue <?>> extends BaseTypeHandler <E > { private final Class<E> type; public DictValueHandler (Class<E> type) { if (type == null ) { throw new IllegalArgumentException("Type argument cannot be null" ); } this .type = type; } @Override public void setNonNullParameter (PreparedStatement ps, int i, E e, JdbcType jdbcType) throws SQLException { ps.setObject(i, e.getValue()); } private E convertResult (Object vs) { if (vs == null ) { return null ; } try { if (vs instanceof Number) { return type.getConstructor(Integer.class).newInstance(vs); } return type.getConstructor(String.class).newInstance(vs); } catch (Exception e) { log.error("fail to construct dict value" , e); return null ; } } @Override public E getNullableResult (ResultSet resultSet, String s) throws SQLException { return convertResult(resultSet.getObject(s)); } @Override public E getNullableResult (ResultSet resultSet, int i) throws SQLException { return convertResult(resultSet.getObject(i)); } @Override public E getNullableResult (CallableStatement callableStatement, int i) throws SQLException { return convertResult(callableStatement.getObject(i)); } }
然后自定义序列化仍然复用刚才的即可(因为该抽象类实现了对应的接口)。
最后将刚才的枚举类改成这样:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 @EqualsAndHashCode(callSuper = true) @ToString(callSuper=true) public class CorpKindEnum extends BaseDictValue <Integer > { public static final Integer CORPORATION = 0 ; @JsonCreator public CorpKindEnum (Integer v) { super (v); } @Override public String getDictCode () { return "CORP_KIND_CODE" ; } }
这里必须要实现一个单参数构造函数,用来转换对应的基础类型,并将其标注为@JsonCreator
用来反序列化。
注意事项
如果要使用mybatis-plus自带的函数进行增删改查,需要在model的@TableName
里标注autoResultMap = true
;并在对应的字段的@TableField
里标注typeHandler = DictValueHandler.class
,这样才能正确的进行orm类型转换;
同样,如果自己写resultMap想要进行类型转换,也要在xml里指定上述typeHandler
。
参数校验 这个简单,写一个自定义注解:
1 2 3 4 5 6 7 8 @Target(ElementType.FIELD) @Retention(RetentionPolicy.RUNTIME) @Constraint(validatedBy = DictFieldValidator.class) public @interface DictField { String message () default "参数错误,枚举值不在允许范围内" ; Class<?>[] groups() default {}; Class<? extends Payload>[] payload() default {}; }
然后实现校验逻辑:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 @Component public class DictFieldValidator implements ConstraintValidator <DictField , Object > { @Autowired private SysDictService service; @Override public boolean isValid (Object o, ConstraintValidatorContext constraintValidatorContext) { if (o instanceof IDictEnum) { IDictEnum v = (IDictEnum) o; return service.isValidValue(v.getDictCode(), v.getValue().toString()); } return false ; } }
用的时候,在对应的字段上加上@DictField
注解,对应的类参数前面加上@Valid
或者@Validated
就可以了.
题外话
mybatis-plus自带了一个JacksonTypeHandler
,可以将数据库字段转换成任意类型,它的原理和上面是一致的。
上述Jackson的Serializer
需要通过SimpleModule
手动注册上去。在springboot中可以通过bean
进行配置。这里给一个参考配置:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 @Bean public ObjectMapper objectMapper () { ObjectMapper objectMapper = new ObjectMapper(); objectMapper.disable(SerializationFeature.FAIL_ON_EMPTY_BEANS); objectMapper.disable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES); objectMapper.configure(JsonGenerator.Feature.WRITE_BIGDECIMAL_AS_PLAIN, true ); DictEnumSerializer dictEnumSerializer = new DictEnumSerializer(); SimpleModule simpleModule = new SimpleModule(); simpleModule.addSerializer(dictEnumSerializer); JavaTimeModule javaTimeModule = new JavaTimeModule(); javaTimeModule.addSerializer(LocalDateTime.class, new LocalDateTimeSerializer(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss" ))); javaTimeModule.addSerializer(LocalDate.class, new LocalDateSerializer(DateTimeFormatter.ofPattern("yyyy-MM-dd" ))); javaTimeModule.addSerializer(LocalTime.class, new LocalTimeSerializer(DateTimeFormatter.ofPattern("HH:mm:ss" ))); javaTimeModule.addDeserializer(LocalDateTime.class, new LocalDateTimeDeserializer(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss" ))); javaTimeModule.addDeserializer(LocalDate.class, new LocalDateDeserializer(DateTimeFormatter.ofPattern("yyyy-MM-dd" ))); javaTimeModule.addDeserializer(LocalTime.class, new LocalTimeDeserializer(DateTimeFormatter.ofPattern("HH:mm:ss" ))); javaTimeModule.addSerializer(Date.class, new DateSerializer(false , new SimpleDateFormat("yyyy-MM-dd HH:mm:ss" ))); objectMapper.registerModule(javaTimeModule); objectMapper.registerModule(simpleModule); return objectMapper; }