从零开始 Spring Boot 16:枚举

从零开始 Spring Boot 16:枚举

spring boot

图源:简书 (jianshu.com)

在开发Web应用时,无法避免的是会定义一些“离散值”,比如书籍类型,包含艺术、小说、工程书籍等。在数据库中,我们一般会使用整数来表示这些值,比如1代表小说,2代表艺术,3代表工程相关书籍等。但在代码中使用整数来表示类型可读性就很差了,以前一般使用类常量来表示这些值,但更好的做法是使用枚举。

关于Java中枚举的基本知识,可以阅读Java编程笔记19:枚举 - 魔芋红茶’s blog (icexmoon.cn)

下面通过在我们的图书应用中引入枚举类型来说明如何在Spring Boot项目中使用枚举,以及相关的注意事项。

下面的示例代码都将由从零开始 Spring Boot 15:Http Client - 魔芋红茶’s blog (icexmoon.cn)中的最终代码修改而来,相关完整代码见learn_spring_boot/ch15 (github.com)

为了演示,我们首先需要在项目相关的数据库中添加一个表示书籍类型的字段:

CREATE TABLE `book` (
  `id` int NOT NULL AUTO_INCREMENT,
  `name` varchar(45) NOT NULL,
  `description` text NOT NULL,
  `user_id` int NOT NULL,
  `type` tinyint NOT NULL DEFAULT '5' COMMENT '书籍类型 1艺术 2小说 3科幻 4历史 5其它',
  PRIMARY KEY (`id`)
) ENGINE=InnoDB AUTO_INCREMENT=18 DEFAULT CHARSET=utf8mb3

book表的type字段表示书籍类型。

IEnum

然后我们就需要考虑如何将数据库中整数读取为枚举。

因为我们项目使用了MyBatis Plus,所以可以很容易地通过其定义的通用枚举接口IEnum<T>来实现这一点。只要让自定义枚举实现这个接口,MyBatis Plus在执行SQL后就会自动完成相关的类型转换,Entity相关数据实体就会有相应的枚举常量。

但实际使用中我发现往往需要给实现了IEnum的枚举实现一个额外的getDesc方法用于返回枚举的说明文字,但因为该方法不在IEnum定义中,有时候只有IEnum引用就很难调用该方法。因此我会定义一个扩展自IEnum的自定义接口作为从数据库加载的枚举类型的实现接口:

public interface IDescEnum<T extends Serializable> extends IEnum<T> {
    String getDesc();
}

然后就需要创建一个实现了IDescEnum接口的枚举:

public enum BookType implements IDescEnum<Integer> {
    ART(1, "艺术"), NOVEL(2, "小说"), SF(3, "科幻"), HISTORY(4, "历史"), OTHER(5, "其它");

    BookType(Integer value, String desc) {
        this.value = value;
        this.desc = desc;
    }

    private Integer value;
    private String desc;

    @Override
    public String getDesc() {
        return desc;
    }

    @Override
    public Integer getValue() {
        return value;
    }
}

当然还要在数据实体(DAO)中添加枚举类型:

public class Book implements Serializable {
	...
    private BookType type;
}

很简单,这样就可以使用MyBatis Plus提供的相关API查询数据库,查询后的Book实体会以枚举的形式保存type属性。当然添加和更新时也会将枚举常量转换成对应的整数来保存到数据库。

输入输出

@JsonValue

现在我们已经处理好了应用和数据库之间的枚举转换,但还需要处理HTTP请求和应用之间的枚举转换。

正常情况下我们都希望通过接口传入的枚举相关参数是整数形式。比如在添加图书的时候,需要添加图书类型,传入的参数可能是这样:

{
    "name": "自由与和平",
    "desc": "自由与和平",
    "type": 1
}

但实际如果你将DTO中的相关属性定义为枚举,默认情况下接受的是常量的字面量,比如:

{
    "name": "自由与和平",
    "desc": "自由与和平",
    "type": "ART"
}

当然也可以使用整数,但枚举常量对应的整数并不是我们定义的value,而是其在enum中的定义顺序,该值可以通过Enum.ordinal方法获取。这个值从0开始,在上边的示例中,枚举常量ART的值是0,NOVEL是1,以此类推。

我认为这样存在一些问题,一来不直观,从enum定义中很难确认具体的枚举常量对应的值,除非数一遍。二来这个值依赖在枚举中定义的顺序,也就是说定义后就不能随便移动位置了,否则就会将入参解析为另一个枚举常量。

实际上这些默认的将入参解析为枚举常量的行为都是依赖于Spring Boot中的JSON解析器的,Spring Boot默认的JSON解析器是Jackson,而Jackson默认对JSON中的枚举类型的解析行为就是上面介绍的那样。

所以要想改变这种默认行为,让传入参数中的枚举类型按照我们定义的IDescEnumvalue属性进行解析,就需要调整Jackson。

幸运的是Jackson提供一个注解@JsonValue可以很容易地做到这点:

public enum BookType implements IDescEnum<Integer> {
	...
    @JsonValue
    private Integer value;
    private String desc;
	...
}

就像上面展示的,只要给枚举中的value属性添加@JsonValue,传入参数中的枚举类型就会自动按照其value值转换为对应的枚举常量,此外,返回值中的枚举常量也会被转换为相应的value值。

可以很容易编写一个简单示例来验证这一点:

    @Data
    private static class EnumTestDTO {
        @ApiModelProperty(value = "书籍类型", required = true, allowEmptyValue = false)
        @NotNull
        private BookType type;
    }

    @ApiOperation("测试枚举传递和返回")
    @PostMapping("/enum-test")
    public Result enumTest(@RequestBody EnumTestDTO dto) {
        log.debug("type:" + dto.getType());
        return Result.success(dto.getType());
    }

进行测试:

image-20220805092547649

即使传入的JSON串中的type类型是字符串,也能同样获得正确的结果:

image-20220805092733780

但是像原本那样传递枚举常量的字面量就会出错:

image-20220805092931187

这说明我们已经改变了Spring Boot默认的对入参和返回值中的枚举类型的处理行为。

上面这么做需要使用@JsonValue注解,实际上可以通过给Jackson添加默认的类型处理器来“自动”处理自定义注解,具体可以阅读从零开始 Spring Boot 25:MyBatis II - 红茶的个人站点 (icexmoon.cn)一文中的TypeHandler一节。

Converter

虽然到这里看起来一切都表现的很好,但如果入参不是以请求报文体中JSON串的方式传递,而是通过url参数或者查询字符串,再或者表单提交,就会出现问题:

    @ApiOperation("测试通过路径参数传递枚举")
    @PostMapping("/enum-test2/{type}")
    public Result enumTest2(@ApiParam("书籍类型") @NotNull @PathVariable BookType type){
        log.debug("type:" + type);
        return Result.success(type);
    }

在上边这个接口中,我们试图用路径参数而非报文体接收枚举类型的参数,如果测试,就会报错:

image-20220805104401376

报错信息说的很详细,路径参数中的参数类型都是String,系统并不知道如何将String转换为目标的枚举类型。虽然Jackson实际上是知道如何处理此类问题的,但是jackson的本职工作只是处理JSON格式的字符串,url中的路径参数显然不是,这就需要我们自己编写相应的处理程序。

要想在Spring Boot中预处理某些参数的类型,让其从一种类型转换为目标类型,我们只需要实现Spring框架提供的Converter接口即可:

public class Str2EnumConverter<T extends Enum<?> & IEnum<Integer>> implements Converter<String, T> {
    Class<T> cls;

    Str2EnumConverter(Class<T> cls) {
        this.cls = cls;
    }

    @Override
    public T convert(String source) {
        T[] enumConstants = cls.getEnumConstants();
        Integer sourceVal = Integer.valueOf(source);
        for (T enumInstance : enumConstants) {
            if (sourceVal.equals(enumInstance.getValue())){
                return enumInstance;
            }
        }
        return null;
    }
}

因为我这里期望转换的目标类型是“实现了IEnum接口的枚举”,所以目标类型参数定义为T extends Enum<?> & IEnum<Integer>

虽然一般用类型参数定义枚举时使用T extends Enum<T>,但是这不能这样做,因为下边的需要定义的配套的工厂类中的类型参数T1需要定义为这里T的子类型,这就会导致需要让T1 extends Enum<T1>,但显然T1是没法同时满足T1 extends TT1 extends Enum<T1>的(泛型定义限制)。

此外还需要实现相应的工厂类:

public class Str2EnumConverterFactory<T extends Enum<?> & IEnum<Integer>> implements ConverterFactory<String, T> {

    @Override
    public <T1 extends T> Converter<String, T1> getConverter(Class<T1> targetType) {
        return new Str2EnumConverter<>(targetType);
    }
}

最后只要在系统配置中“注册”工厂对象即可:

@Configuration
public class MyWebAppConfigurer extends WebMvcConfigurationSupport {
    @Autowired
    private SysProperties sysProperties;

    @Override
    public void addFormatters(FormatterRegistry registry) {
        registry.addConverterFactory(new Str2EnumConverterFactory<>());
    }
    ...
}

注意,不推荐像上面这样通过继承WebMvcConfigurationSupport类的方式添加Converter,因为这样会导致SpringBoot的相关自动配置失效,会影响其他功能,正常的方式应该为:

// ...
@Configuration
public class MyWebAppConfigurer implements WebMvcConfigurer {
    @Override
    public void addFormatters(FormatterRegistry registry) {
        registry.addConverterFactory(new Str2EnumConverterFactory<>());
    }
}

这样做不会破坏Spring Boot的自动配置。

现在再测试:

image-20220805110027080

可以看到,url中的路径参数被正确地转换成了枚举常量。

当然,如果路径参数的值不合法,比如http://localhost:8080/enum-test2/99或者http://localhost:8080/enum-test2/ss,也会有相应的报错信息,这里不一一说明。

swagger

现在一切都表现的很好,我们的应用可以接收和返回枚举类型,也可以从数据库读取和保存枚举类型。但是,如果你对枚举的处理停留在这个层面,并将接口直接提供给前端使用,前端就会看到如下的swagger文档:

image-20220805110809201

就像之前说的,这样时符合Spring Boot默认的枚举转换规则的,但是显然不符合我们改造后的规则,所以我们需要对Swagger进行改造,让其能显示枚举对应的整形值。

先添加一个用于标记需要处理的枚举的注解:

@Target({ElementType.TYPE})
@Retention(RetentionPolicy.RUNTIME)
public @interface SwaggerDisplayEnum {
    String index() default "index";
    String name() default "name";
}

关于注解的更多介绍可以阅读Java编程笔记20:注解 - 魔芋红茶’s blog (icexmoon.cn)

为枚举类型添加上注解:

@SwaggerDisplayEnum(index = "value", name = "desc")
public enum BookType implements IDescEnum<Integer> {
	...
}

这里注解的index属性应当填写枚举中存放整形值的属性名称,这里就是value,而name属性应当填写枚举中存放枚举常量说明文字的属性名称,这里是desc

实际上在这个项目中,绝大多数枚举都会通过实现IDescEnum接口的方式创建,所以让注解的index属性默认值是value更为方便,这里只是为了演示注解属性的用途而没有这样做。

要改变Swagger的行为,可以通过实现其提供的XXXPlugin接口来实现。Swagger本身提供多种Plugin接口,可以修改接口文档不同位置的内容,具体到这里,我们只需要实现ModelPropertyBuilderPlugin就可以修改@ApiModelProperty注解定义的枚举在接口文档中的描述信息:

package cn.icexmoon.demo.books.system.swagger;
// ...
@Component
public class EnumModelPropertyBuilderPlugin implements ModelPropertyBuilderPlugin {
    @Override
    public void apply(ModelPropertyContext context) {
        Optional<BeanPropertyDefinition> optional = context.getBeanPropertyDefinition();
        if (!optional.isPresent()) {
            return;
        }

        final Class<?> fieldType = optional.get().getField().getRawType();
        addDescForEnum(context, fieldType);
    }

    @Override
    public boolean supports(DocumentationType documentationType) {
        return true;
    }

    private void addDescForEnum(ModelPropertyContext context, Class<?> fieldType) {
        if (Enum.class.isAssignableFrom(fieldType)) {
            SwaggerDisplayEnum annotation = AnnotationUtils.findAnnotation(fieldType, SwaggerDisplayEnum.class);
            if (annotation != null) {
                Object[] enumConstants = fieldType.getEnumConstants();
                List<TwoTuple<String>> enumKeyValues = EnumPluginUtils.getEnumKeyValues(enumConstants, annotation);
                List<String> displayValues = enumKeyValues.stream().map(tuple -> tuple.getFirst() + ":" + tuple.getSecond()).collect(Collectors.toList());
                List<String> availableValues = enumKeyValues.stream().map(tuple -> tuple.getFirst()).collect(Collectors.toList());
                PropertySpecificationBuilder specificationBuilder = context.getSpecificationBuilder();
                Field descField = ReflectionUtils.findField(specificationBuilder.getClass(), "description");
                descField.setAccessible(true);
                String joinText = null;
                try {
                    joinText = (String) descField.get(specificationBuilder);
                } catch (IllegalAccessException e) {
                    e.printStackTrace();
                }
                joinText = joinText + "(" + String.join("; ", displayValues) + ")";
                specificationBuilder.type(new ModelSpecificationBuilder().scalarModel(ScalarType.INTEGER).build());
                specificationBuilder.description(joinText);
                AllowableListValues allowableListValues = new AllowableListValues(availableValues, "Integer");
                specificationBuilder.enumerationFacet(builder -> builder.allowedValues(allowableListValues));
            }
        }
    }
}

这里需要实现ModelPropertyBuilderPlugin的两个方法:

  • apply,接收一个表示上下文的对象,用于获取所需的信息。我们需要在这个方法中处理主要逻辑,需要判断当前上下文的注解是否为要处理的注解,如果是,就获取枚举的所有常量,并拼接出枚举说明文字及合法的整形值,然后再通过注解的Builder填充注解的相应属性。
  • supports,这个方法返回true就可以让我们自定义的Plugin组件生效。

这里的代码是基于网上找到的代码修改后的,适用于3.0.0版本的swagger依赖。

现在再打开接口文档页面,JSON格式传入的参数长这样:

image-20221030115044493

感谢网友小纯洁.的指点,这里类型和Enum合法值不完美的问题已经得到解决。

当然,上面的这种方式只能处理JSON形式传入的参数,如果是路径参数或者表单形式提交的参数就需要通过实现EnumParameterBuilderPlugin的方式来实现:

@Component
public class EnumParameterBuilderPlugin implements ParameterBuilderPlugin {

    @Override
    public void apply(ParameterContext context) {
        Class<?> type = context.resolvedMethodParameter().getParameterType().getErasedType();
        if (Enum.class.isAssignableFrom(type)) {
            SwaggerDisplayEnum annotation = AnnotationUtils.findAnnotation(type, SwaggerDisplayEnum.class);
            if (annotation != null) {
                Object[] enumConstants = type.getEnumConstants();
                List<TwoTuple<String>> enumKeyValues = EnumPluginUtils.getEnumKeyValues(enumConstants, annotation);
                List<String> displayValues = enumKeyValues.stream().map(tuple -> tuple.getFirst()).collect(Collectors.toList());
                List<String> keyValues = enumKeyValues.stream().map(tuple -> tuple.getFirst() + ":" + tuple.getSecond()).collect(Collectors.toList());
                RequestParameterBuilder requestParameterBuilder = context.requestParameterBuilder();
                String description;
                try {
                    Field descField = RequestParameterBuilder.class.getDeclaredField("description");
                    descField.setAccessible(true);
                    description = descField.get(requestParameterBuilder) + "(" + String.join("; ", keyValues) + ")";
                } catch (IllegalAccessException | NoSuchFieldException e) {
                    e.printStackTrace();
                    throw new RuntimeException(e);
                }
                requestParameterBuilder.description(description);
                requestParameterBuilder.query(simpleParameterSpecificationBuilder -> simpleParameterSpecificationBuilder
                        .model(modelSpecificationBuilde -> modelSpecificationBuilde.scalarModel(ScalarType.INTEGER))
                        .enumerationFacet(enumerationElementFacetBuilder -> enumerationElementFacetBuilder.allowedValues(displayValues)));
            }
        }
    }


    @Override
    public boolean supports(DocumentationType documentationType) {
        return true;
    }
}

这两个Plugin实现类有一段共用的代码:

public class EnumPluginUtils {
    public static List<TwoTuple<String>> getEnumKeyValues(Object[] enumConstants, SwaggerDisplayEnum annotation){
        List<TwoTuple<String>> enumKeyValues = Arrays.stream(enumConstants).filter(Objects::nonNull).map(item -> {
            String keyAttrName = annotation.index();
            String valAttrName = annotation.name();
            Class<?> currentClass = item.getClass();
            Field indexField = ReflectionUtils.findField(currentClass, keyAttrName);
            ReflectionUtils.makeAccessible(indexField);
            Object value = ReflectionUtils.getField(indexField, item);
            Field descField = ReflectionUtils.findField(currentClass, valAttrName);
            ReflectionUtils.makeAccessible(descField);
            Object desc = ReflectionUtils.getField(descField, item);
            return new TwoTuple<>(value.toString(), desc.toString());

        }).collect(Collectors.toList());
        return enumKeyValues;
    }
}

现在再来看接口文档:

image-20221030115540934

@ApiParam注解修饰的表单提交的枚举类型参数也同样自动填充了说明信息与合法的整型值列表。

至此,Spring Boot中的枚举相关内容就介绍完了,谢谢阅读。

本文最终的完整示例代码见learn_spring_boot/ch16 (github.com)

参考资料

  • 1
    点赞
  • 2
    收藏
  • 打赏
    打赏
  • 9
    评论
©️2022 CSDN 皮肤主题:精致技术 设计师:CSDN官方博客 返回首页
评论 9

打赏作者

魔芋红茶

你的鼓励将是我创作的最大动力

¥2 ¥4 ¥6 ¥10 ¥20
输入1-500的整数
余额支付 (余额:-- )
扫码支付
扫码支付:¥2
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、C币套餐、付费专栏及课程。

余额充值