处理枚举类 Value 和 Label 之间映射的几种方案

起因

项目中存在非常非常多的枚举 label 和 value 之间的映射场景,比如:实体类中存在 scene 字段,值可以枚举,分别为 INSURANCE(保险)、OFFLINE(线下)、OTHER(其他)。

当此字段出现在查询接口返回中,需要将对应的中文返回给前端显示。当此字段出现在请求保存接口中,前端会将值上传到后端接口中,后端需要校验上传的值。

之前做法是,对校验方法进行封装,使用 if 编码判断该字段是否需要进行枚举类的映射和校验。项目代码量增加,冗余代码越来越多。

设想是,使用注解,在单个字段上加上注解就能够实现校验和映射。如下:

public class TestBean {
    @EnumCheck(target = MchSceneEnum.class) // 校验枚举值是否合法
    @EnumLabel(target = MchSceneEnum.class) // 输出到前端时映射为 label 文字
    private String scene;
}
复制代码

处理枚举值输入校验

结合 Spring Validate 技术,定义自定义注解,创建注解校验器,如下:

@Target(ElementType.FIELD)
@Retention(RetentionPolicy.RUNTIME)
@Documented
// validatedBy 属性与校验器 EnumCheckValidator 相关联
@Constraint(validatedBy = {EnumCheckValidator.class})
public @interface EnumCheck {
    // 接收枚举类型
    Class<?> target();
    
    // 非必填字段,用于 spring validate
    String regexp() default "";
    String message() default "值必须在枚举值内中选填";
    Class<?>[] groups() default {};
    Class<? extends Payload>[] payload() default {};
}
复制代码

相应的校验器

public class EnumCheckValidator implements ConstraintValidator<EnumCheck, String> {
    Class targetClass;

    @Override
    public void initialize(EnumCheck constraintAnnotation) {
        targetClass = constraintAnnotation.target();
    }

    @Override
    public boolean isValid(String value, ConstraintValidatorContext context) {
        if (!targetClass.isEnum()) {
            return false;
        }
        // 枚举类的成员
        Object[] enumInstances = targetClass.getEnumConstants();
        // 枚举类的所有 value 值集合
        Set<String> values = new HashSet<>();
        try {
            for (Object enumInstance : enumInstances) {
                Method getValue = targetClass.getMethod("getValue");
                Object valueObj = getValue.invoke(enumInstance, null);
                values.add(valueObj.toString());
            }
        }
        catch (Exception e) {
            return false;
        }
        return values.contains(value);
    }
}
复制代码

要求枚举类中需要有 getValue 方法,大概如下:

public enum MchSceneEnum implements LabelAndValue<String> {
    INSURANCE("INSURANCE", "保险"),
    OTHER("OTHER", "非保险");

    MchSceneEnum(String value, String label){
        this.value = value;
        this.label = label;
    }

    private String value;
    private String label;

    @Override
    public String getValue() {
        return this.value;
    }

    @Override
    public String getLabel() {
        return this.label;
    }
}
复制代码

使用示例: (注:本文省略 Spring Validate 入门介绍,默认已支持该框架

// 测试 vo,用于接收前端输入的 scene 值
public class TestReqVo {
    @EnumCheck(target = MchSceneEnum.class)
    private String scene;
}

// 测试 Controller
@RequestMapping("/test")
@RestController
public class TestController {
    // org.springframework.validation.annotation.Validated
    public Stirng void add(@Validated TestVo vo) {
        return ""; // 逻辑省略
    }
}
复制代码

添加 Validated 注解后,Spring 框架会接收到前端参数后,自动校验输入值。规则即为上文所定义的校验器逻辑。

Spring Validate 框架在校验失败时,默认抛出 org.springframework.validation.BindException 异常。为优雅地处理该异常,可结合 Spring 全局异常处理技术(@ControllerAdvice),大致处理如下:

@ControllerAdvice
public class ControllerExceptionHandler {
    @ExceptionHandler(BindException.class)
    public String handleBindException(BindException ex) {
        BindingResult result = ex.getBindingResult();
        if (result.hasErrors()) {
            FieldError fieldError = result.getFieldError();
        }
        return fieldError.getDefaultMessage(); // 注解定义的提示文字
    }
}
复制代码

处理枚举值输出映射

方案一:Controller 层处理:jackson

前后端交互时,若是查询接口,前端要求的是枚举值的 label,用于展示。如 scene 字段值为 INSURENCE 时,前端需要展现的是保险两字。

一开始想到利用 jackson 框架的 @JsonSerialize 注解,但是此注解不能再额外增加自己定义的参数,在本文中就是需要序列化的对应的枚举类型,拿不到此参数就不知道需要输出什么 label。

接着尝试了 MappingJackson2HttpMessageConverter 中注入自定义的 objectMapper 来实现(继承),但是此种方法也不能拿到字段上面的自定义注解。

最后在网上终于找到了资料:Jackson使用ContextualSerializer在序列化时获取字段注解的属性

解决了获取字段上面自定义注解问题,就可以开始编码实现了,先定义注解:

@Target(ElementType.FIELD)
@Retention(RetentionPolicy.RUNTIME)
@Documented
@JacksonAnnotationsInside
@JsonSerialize(using = EnumSerializer.class)
public @interface EnumLabel {
    Class<?> target();
}
复制代码

将 JsonSerialize 作用于在自定义注解上,当使用 EnumLabel 注解时,也相当于使用了 JsonSerialize 注解。using 属性值填自定义序列化器的类型。EnumSerializer 定义如下:

public class EnumSerializer extends JsonSerializer<Object> implements ContextualSerializer {
    private Map<String, String> valueLabelMap;
    public EnumSerializer(Map<String, String> valueLabelMap) {
        this.valueLabelMap = valueLabelMap;
    }

    @Override
    public void serialize(Object value, JsonGenerator jgen, SerializerProvider provider) throws IOException {
        String actValue = (String) value;
        if (this.valueLabelMap != null) {
            actValue = valueLabelMap.getOrDefault(value, value.toString());
        }
        jgen.writeString(actValue);
    }

    @Override
    public JsonSerializer<?> createContextual(SerializerProvider serializerProvider, BeanProperty beanProperty) throws JsonMappingException {
        if (beanProperty != null) {  // 为空直接跳过
            // 只对 String 类型生效
            if (Objects.equals(beanProperty.getType().getRawClass(), String.class)) {
                EnumLabel enumLabel = beanProperty.getAnnotation(EnumLabel.class);
                if (enumLabel != null) {
                    // key:getValue value:getLabel
                    Object[] enumInstances = enumLabel.target().getEnumConstants();  // 枚举类的成员
                    Map<String, String> map = new HashMap<>(enumInstances.length);
                    try {
                        for (Object enumInstance : enumInstances) {
                            Method getValue = enumLabel.target().getMethod("getValue");
                            Object valueObj = getValue.invoke(enumInstance);
                            Method getLabel = enumLabel.target().getMethod("getLabel");
                            Object labelObj = getLabel.invoke(enumInstance);
                            map.put(valueObj.toString(), labelObj.toString());
                        }
                    } catch (Exception e) {
                        e.printStackTrace();
                    }
                    return new EnumSerializer(map);
                }
            }
            return serializerProvider.findValueSerializer(beanProperty.getType(), beanProperty);
        }
        return serializerProvider.findNullValueSerializer(beanProperty);
    }
}
复制代码

示例中的 createContextual 方法只会被调用一次,所以对性能不会有影响。

自定义注解使用方法如下:target 中填对应的枚举类型

public class TestRespVo {
    @EnumLabel(target = MchSceneEnum.class)
    private String scene;
}
复制代码

若查询到数据值为 "INSURANCE",当 Spring 输出到前端时,会将该值对应的中文描述重新写入,即 "保险"。

方案二:Dao 层处理:Mybatis

此方案我暂时没有采用,因为对参数的类型有要求,pojo 类中的字段类型必须为对应的枚举,与方案一相比,此方案较不灵活。

大致实现见此文:MyBatis里字段到枚举类型的转换/映射

猜你喜欢

转载自juejin.im/post/5e3d3c97518825490369329d