2021-08-31自定义枚举序列化器

源码 https://gitee.com/eric-tutorial/SpringCloud-multiple-gradle
本文内容是:SpringBoot+Mybatisplus中枚举正反序列化的实际应用

本文基于SpringBoot+Mybatisplus 框架就Java枚举的正反序列化的实际应用进行一次分析与研究,此外顺便带上DAO层关于枚举的操作,使得程序中完全使用枚举编程。由于SpringBoot内置的json处理器是jackson,所以本文的json相关处理也就是采用默认的jackson。

背景

N久之前,leo曾经问我枚举的应用,我清楚地记得菜鸟教程(https://www.runoob.com/)上面有这样一段话。

image.png

当时我还找到了给leo看,说这玩意要被取代了.现在看来是我断章取义了。因为那时候很少会接触到枚举,所以我以为这玩意真的没救了。

现在看来,看多了不去实践与思考,正应了一句话“尽信书不如无书”。

最近《阿里巴巴开发规范-嵩山版版》有下面的一句话,可能会与接下来的内容相冲。这里为啥不能返回枚举类型?大概率因为集团内部的RPC调用的时候,版本升级无法正确兼容。

image.png

无论枚举要怎么使用,我还是按照自己的相关需求来实践了一把,由于项目中有很多枚举,使用和管理起来非常晕乎乎的。需要把枚举与Integer转来转去,前端传输过来了一个Integer,需要手动将Integer转成枚举,存储到数据库的时候,又得将枚举转成Integer保存。如果纯粹使用Integer传值,编码又不能知道这个数字代表啥意思,最后找来找去。不光是后端很是晕乎乎的。前端由于也只接受了Integer,需要显示文字的时候,只能前后端共同定,一旦后端修改了枚举,那么前端必须同步修改。所以我在网上找了一些解决办法,但是都不尽人意。最后折腾了jackson源码并求助于jackson的维护者解决了枚举正反序列化的问题。

先看下一般工程的基本模型

image.png

本文的重点是枚举的正反序列化,但是为了让整个枚举在工程中的应用比较完整,也会描述下枚举在DAO层的操作。jackson的正反序列化主要应用在Controller层的参数接收与结果返回。在参数接收的时候有两种形式,一种的前端通过表单提交的数据,另一种是从body提交的json数据,两种有很大的区别,在Controller的方法里面主要体现在body提交的json数据需要在对象前面加上@RequestBody.当然两者本质上有点区别,由于表单提交的不是json,所以无法采用json反序列化,但是本文中会顺带描述到表单提交的数据如何转换成枚举。

show you code

工程源代码

https://gitee.com/eric-tutorial/SpringCloud-multiple-gradle

篇幅有限,只讲述重点代码逻辑,完整的可以参考源代码。项目基于Gradle构建.

定义枚举

public enum GenderEnum  {
    BOY(100, "男"), GIRL(200, "女"),UNKNOWN(0, "未知");
    private final Integer code;
    private final String description;
    GenderEnum(int code, String description) {
        this.code = code;
        this.description = description;
    }
}

接受参数的对象

@Data
public class UserParam {
    @NotBlank(message = "name不能为空")
    String name;
    @NotNull(message = "gender为100或者200")
    GenderEnum gender;
    @NotNull(message = "age不能为空")
    Integer age;
}

Controller POST方法

    @PostMapping("add/body")
    public BaseResponseVO saveBody(@Valid @RequestBody UserParam userParam) {
        UserModel userModel = userService.add(userParam);
        return BaseResponseVO.success(userModel);
    }

上面代码可以看出来框架在接受参数的时候将网络传输过来的数据进行了反序列化,在返回给前端的时候进行了正序列化成json返回的。默认的jackson是无法直接按照GenderEnum中的code来正反序列化枚举的,因为jackson有一套自己的枚举序列化机制,从源代码中看出来,它是按照name和ordinal来正反序列化的。但是这个不能满足我自己定义的code和description来正反序列化的需求。因此我在网上搜了下,看看有木有人完成这样的需求,我想这个需求应该比较正常,网上一搜果然有很多。很快就有了下面的代码(最后发现都是采用默认的jackson枚举正反序列化器,并不满足需求)。

自定义的枚举序列化器

面向接口编程

为我需要正序列化的枚举统一定义了一个接口.所以需要参与正序列化的枚举都得实现这个接口.

public interface BaseEnum {
    /**
     * Code integer.
     *
     * @return the integer
     */
    Integer code();
    /**
     * Description string.
     *
     * @return the string
     */
    String description();
}

正序列化器

@Slf4j
public class BaseEnumSerializer extends JsonSerializer<BaseEnum> {
    @Override
    public void serialize(BaseEnum value, JsonGenerator gen, SerializerProvider serializerProvider) throws IOException {
        log.info("\n====>开始序列化[{}]", value);
        gen.writeStartObject();
        gen.writeNumberField("code", value.code());
        gen.writeStringField("description", value.description());
        gen.writeEndObject();
    }
}

效果就是既返回code和description,前端既知道code也知道description.description可以直接显示,code可以用来返回给后端的操作.前端再也不用同步修改description了,也不需要自己判断code是啥意思,直接显示description即可.皆大欢喜.

反序列化器

@Slf4j
public class BaseEnumDeserializer extends JsonDeserializer<BaseEnum> {
    @Override
    public BaseEnum deserialize(JsonParser p, DeserializationContext ctxt) throws IOException, JsonProcessingException {
        try {
            //前端输入的值
            String inputParameter = p.getText();
            if (StringUtils.isBlank(inputParameter)) {
                return null;
            }
            JsonStreamContext parsingContext = p.getParsingContext();
            String currentName = parsingContext.getCurrentName();//字段名
            Object currentValue = parsingContext.getCurrentValue();//前端注入的对象(ResDTO)
            Field field = ReflectionUtils.getField(currentValue.getClass(), currentName);               // 通过对象和属性名获取属性的类型
            // 获取对应得枚举类
            Class enumClass = field.getType();
            // 根据对应的值和枚举类获取相应的枚举值
            BaseEnum anEnum = DefaultInputJsonToEnum.getEnum(inputParameter, enumClass);
            log.info("\n====>测试反序列化枚举[{}]==>[{}.{}]", inputParameter, anEnum.getClass(), anEnum);
            return anEnum;
        } catch (Exception e) {
            throw new RuntimeException(e);
        }
    }
}

效果就是反序列化器用来解决参数接受的时候,将前端传过来的code转成Enum.方便枚举在程序中的操作,降低程序的复杂度,使编码更加简单,代码清晰明了.

注入到SpringBoot框架中

    @Bean
    public Jackson2ObjectMapperBuilderCustomizer enumCustomizer() {
//        将枚举转成json返回给前端
        return jacksonObjectMapperBuilder -> {
//            自定义序列化器注入
            Map<Class<?>, JsonSerializer<?>> serializers = new LinkedHashMap<>();
            serializers.put(BaseEnum.class, new BaseEnumSerializer());
            jacksonObjectMapperBuilder.serializersByType(serializers);
//            自定义反序列化器注入,这里的注入貌似效果不行
            Map<Class<?>, JsonDeserializer<?>> deserializers = new LinkedHashMap<>();
            deserializers.put(BaseEnum.class, new BaseEnumDeserializer());
            jacksonObjectMapperBuilder.deserializersByType(deserializers);
        };
    }

经过测试,枚举序列化后返回到前端的效果如下,与期望的效果一致,这样的好处就是前端不需要管数字是啥意思,直接显示description即可,无论后端枚举是否修改,前端都不需要关心了。


image.png

经过反复测试与人分享成果的时候,发现一个非常严重的问题,虽然前端接收参数的时候也可以反序列化成枚举,但是实际上没有按照code来反序列化。最后只能把jackson的源代码拉下来调试,经过调试发现,jackson反序列化的时候一直使用的是默认的枚举反序列化器,并没有使用自定义枚举反序列化器。

com.fasterxml.jackson.databind.deser.BasicDeserializerFactory#createEnumDeserializer

  /**
     * Factory method for constructing serializers of {@link Enum} types.
     */
    @Override
    public JsonDeserializer<?> createEnumDeserializer(DeserializationContext ctxt,
            JavaType type, BeanDescription beanDesc)
        throws JsonMappingException
    {
        final DeserializationConfig config = ctxt.getConfig();
        final Class<?> enumClass = type.getRawClass();
        // 23-Nov-2010, tatu: Custom deserializer?
        JsonDeserializer<?> deser = _findCustomEnumDeserializer(enumClass, config, beanDesc);
        if (deser == null) {
            // 12-Feb-2020, tatu: while we can't really create real deserializer for `Enum.class`,
            //    it is necessary to allow it in one specific case: see [databind#2605] for details
            //    but basically it can be used as polymorphic base.
            //    We could check `type.getTypeHandler()` to look for that case but seems like we
            //    may as well simply create placeholder (AbstractDeserializer) regardless
            if (enumClass == Enum.class) {
                return AbstractDeserializer.constructForNonPOJO(beanDesc);
            }
            ValueInstantiator valueInstantiator = _constructDefaultValueInstantiator(ctxt, beanDesc);
            SettableBeanProperty[] creatorProps = (valueInstantiator == null) ? null
                    : valueInstantiator.getFromObjectArguments(ctxt.getConfig());
            // May have @JsonCreator for static factory method:
            for (AnnotatedMethod factory : beanDesc.getFactoryMethods()) {
                if (_hasCreatorAnnotation(ctxt, factory)) {
                    if (factory.getParameterCount() == 0) { // [databind#960]
                        deser = EnumDeserializer.deserializerForNoArgsCreator(config, enumClass, factory);
                        break;
                    }
                    Class<?> returnType = factory.getRawReturnType();
                    // usually should be class, but may be just plain Enum<?> (for Enum.valueOf()?)
                    if (returnType.isAssignableFrom(enumClass)) {
                        deser = EnumDeserializer.deserializerForCreator(config, enumClass, factory, valueInstantiator, creatorProps);
                        break;
                    }
                }
            }
            // Need to consider @JsonValue if one found
            if (deser == null) {
                deser = new EnumDeserializer(constructEnumResolver(enumClass,
                        config, beanDesc.findJsonValueAccessor()),
                        config.isEnabled(MapperFeature.ACCEPT_CASE_INSENSITIVE_ENUMS));
            }
        }
        // and then post-process it too
        if (_factoryConfig.hasDeserializerModifiers()) {
            for (BeanDeserializerModifier mod : _factoryConfig.deserializerModifiers()) {
                deser = mod.modifyEnumDeserializer(config, type, beanDesc, deser);
            }
        }
        return deser;
    }

从上面可以看出来枚举反系列化器是怎么找到的.仔细阅读后发现,上面并没有按照接口 BaseEnum 来查找反序列化器,这也是为啥自定义的反序列化器没有生效的原因.

既然我发现了这个问题,我直接在github拉下来了jackson代码,然后修改成按照接口查找自定义反序列化器的方式提交了我的代码.于是下面的代码就来了

    List<JavaType> interfaces = type.getInterfaces();
    for (JavaType javaType : interfaces) {
        Class<?> rawClass = javaType.getRawClass();
        deser = _findCustomEnumDeserializer(rawClass, config, beanDesc);
        if (deser != null) {
            return deser;
        }
    }

pull request之后,管理者很快给我回复了。我们来回扯了几个回合之后,我们得到一个更加合理的解决办法. 这个问题,这个也是本文的重点。就是重写查找枚举反序列化器的方法,把我写的代码放在一个重写类里面即可.

https://github.com/FasterXML/jackson-databind/pull/2842

依据开闭原则,修改源代码的事情不太能发生,管理者说修改违背了原有的思想,所以我的PR最后被我自己关闭了。

com.fasterxml.jackson.databind.module.SimpleDeserializers#findEnumDeserializer

    @Override
    public JsonDeserializer<?> findEnumDeserializer(Class<?> type,
            DeserializationConfig config, BeanDescription beanDesc)
        throws JsonMappingException
    {
        if (_classMappings == null) {
            return null;
        }
        JsonDeserializer<?> deser = _classMappings.get(new ClassKey(type));
        if (deser == null) {
            // 29-Sep-2019, tatu: Not 100% sure this is workable logic but leaving
            //   as is (wrt [databind#2457]. Probably works ok since this covers direct
            //   sub-classes of `Enum`; but even if custom sub-classes aren't, unlikely
            //   mapping for those ever requested for deserialization
            if (_hasEnumDeserializer && type.isEnum()) {
                deser = _classMappings.get(new ClassKey(Enum.class));
            }
        }
        return deser;
    }

从上面看出来这个就是查找枚举反序列化器的逻辑,重写SimpleDeserializers类即可.上面这个代码是无法按照接口找到反序列化器的,所以重写它,让它按照我期望的接口方式找到即可,最后也成功了.

此外还从源码中分析出来 为啥有的枚举反序列化就能正常,但是有的不能完成翻序列化。原来默认的枚举反序列化器是按照ordinal来反序列化的,也就是说只有当code与ordinal一致的时候就会造成一种假象, 以为是code反序列化来的,其实依旧是ordinal反序列化来的。

从下面代码中可以看出来,枚举存储在数组中,而ordinal刚好是下标.

com.fasterxml.jackson.databind.deser.std.EnumDeserializer#deserialize

 @Override
    public Object deserialize(JsonParser p, DeserializationContext ctxt) throws IOException
    {
        JsonToken curr = p.currentToken();
        // Usually should just get string value:
        if (curr == JsonToken.VALUE_STRING || curr == JsonToken.FIELD_NAME) {
            CompactStringObjectMap lookup = ctxt.isEnabled(DeserializationFeature.READ_ENUMS_USING_TO_STRING)
                    ? _getToStringLookup(ctxt) : _lookupByName;
            final String name = p.getText();
            Object result = lookup.find(name);
            if (result == null) {
                return _deserializeAltString(p, ctxt, lookup, name);
            }
            return result;
        }
        // But let's consider int acceptable as well (if within ordinal range)
        if (curr == JsonToken.VALUE_NUMBER_INT) {
            // ... unless told not to do that
            int index = p.getIntValue();
            if (ctxt.isEnabled(DeserializationFeature.FAIL_ON_NUMBERS_FOR_ENUMS)) {
                return ctxt.handleWeirdNumberValue(_enumClass(), index,
                        "not allowed to deserialize Enum value out of number: disable DeserializationConfig.DeserializationFeature.FAIL_ON_NUMBERS_FOR_ENUMS to allow"
                        );
            }
            if (index >= 0 && index < _enumsByIndex.length) {
                return _enumsByIndex[index];
            }
            if ((_enumDefaultValue != null)
                    && ctxt.isEnabled(DeserializationFeature.READ_UNKNOWN_ENUM_VALUES_USING_DEFAULT_VALUE)) {
                return _enumDefaultValue;
            }
            if (!ctxt.isEnabled(DeserializationFeature.READ_UNKNOWN_ENUM_VALUES_AS_NULL)) {
                return ctxt.handleWeirdNumberValue(_enumClass(), index,
                        "index value outside legal index range [0..%s]",
                        _enumsByIndex.length-1);
            }
            return null;
        }
        return _deserializeOther(p, ctxt);
    } 
image.png

image.png
image.png

Java的枚举本质上是java.lang.Enum.class,自带有ordinal和name两个属性。ordinal可以理解成数组的下标。

调试过程中最让人百思不得解的是,自定义的正反枚举序列化器,序列化器是可以按照自己定义的接口来序列化,但是反序列化不行。最后经过反复调试,发现正反序列化过程有点区别,正序列化的时候会找父类找接口,按照父类或者接口定义的序列化器来序列化。而反序列化的时候不会。体会一下,可以理解成一个正序列化的时候,准确度可以忽略,反正都是丢出去的。但是反序列化的时候必须保证精度,否则无法正确反序列化,那么对应的对象无法获取到正确的值。瞎扯一下.好比,银行存钱的时候不需要密码,取钱的时候就需要密码一样,看似一个对称的过程,但是校验机制还是有点区别的,可以细细体会这种方式的必要性。

重写SimpleDeserializers的findEnumDeserializer方法

重写了这个方法之后,把我原本写在源代码的逻辑搬出来了,很快就解决了枚举无法找到自定义反序列化器的问题。

public class SimpleDeserializersWrapper extends SimpleDeserializers {
    static final Logger logger = LoggerFactory.getLogger(SimpleDeserializersWrapper.class);
    @Override
    public JsonDeserializer<?> findEnumDeserializer(Class<?> type, DeserializationConfig config, BeanDescription beanDesc) throws JsonMappingException {
        JsonDeserializer<?> enumDeserializer = super.findEnumDeserializer(type, config, beanDesc);
        if (enumDeserializer != null) {
            return enumDeserializer;
        }
        for (Class<?> typeInterface : type.getInterfaces()) {
            enumDeserializer = this._classMappings.get(new ClassKey(typeInterface));
            if (enumDeserializer != null) {
                logger.info("\n====>重写枚举查找逻辑[{}]",enumDeserializer);
                return enumDeserializer;
            }
        }
        return null;
    }
}

换种方式注入到SpringBoot

放弃之前的注入方式,换用新的注入方式向jackson注册重写的类SimpleDeserializersWrapper。

  @Bean
    public ObjectMapper objectMapper(Jackson2ObjectMapperBuilder builder) {
        SimpleDeserializersWrapper deserializers = new SimpleDeserializersWrapper();
        deserializers.addDeserializer(BaseEnum.class, new BaseEnumDeserializer());
        SimpleModule simpleModule = new SimpleModule();
        simpleModule.setDeserializers(deserializers);
        simpleModule.addSerializer(BaseEnum.class, new BaseEnumSerializer());
        ObjectMapper objectMapper = builder.createXmlMapper(false).build();
        objectMapper.registerModule(simpleModule);
        return objectMapper;
    }

时间等序列化

一般来说,会在Date上满加上时间序列化的注解@JsonFormat,但是也可以针对Date自定义正反序列化器,就可以很轻松解决问题。

仔细阅读jackson的源代码你会发现这个还是里面有很多的默认序列化器,用来解决一些常用的类型序列化.

表单提交的数据转成枚举

表单提交的数据与jackson没有关系,主要与SpringWebMVC有关系,所以具体可以看工程源代码,应用比较简单,但是底层原理可以看看Spring源代码。表单提交的数据与jackson没有关系,主要与SpringWebMVC有关系,所以具体可以看工程源代码,应用比较简单,但是底层原理可以看看Spring源代码。


image.png

DAO 层处理枚举存到数据库

具体就是在枚举的属性上面上一个注解

   @EnumValue//标记数据库存的值是code
   private final Integer code;

此外在yaml配置文件中指定枚举所在的包。

mybatis-plus:
  type-enums-package: hxy.dream.entity.enums

上面两步,就是借助mybatis-plus完成了枚举存储到数据库,与读取的时候转换的问题。这个比较简单,框架也就是做这些事情的,让开发者专注于业务,而不是实现技术的本身(不是说不要钻研技术底层原理)。

参考 mybatis-plus:https://mp.baomidou.com/guide/enum.html

总结

以上的操作完成了枚举的从前端接收,反序列化成枚举对象在程序中表达。然后再存储到数据库中。从数据库中取code转成枚举,在程序中表达,再序列化枚举后传输给前端。一个非常完整的循环,基本上满足了程序中对枚举使用的需求。

源码 https://gitee.com/eric-tutorial/SpringCloud-multiple-gradle

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 158,847评论 4 362
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 67,208评论 1 292
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 108,587评论 0 243
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 43,942评论 0 205
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 52,332评论 3 287
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 40,587评论 1 218
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 31,853评论 2 312
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 30,568评论 0 198
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 34,273评论 1 242
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 30,542评论 2 246
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 32,033评论 1 260
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 28,373评论 2 253
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 33,031评论 3 236
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 26,073评论 0 8
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 26,830评论 0 195
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 35,628评论 2 274
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 35,537评论 2 269

推荐阅读更多精彩内容