Spring Boot 3 枚举统一转换:Spring MVC + Jackson + Spring Data JPA / MyBatis-Plus

在业务系统中,状态、类型、来源、支付方式等字段非常适合使用 Java 枚举。

但一个枚举字段从数据库到前端,实际上会经过多层类型系统:

1
2
3
4
数据库             Java/JPA              Spring MVC / Jackson          前端
INT <----> ItemType <----> 1 / "1" <----> number
1 APARTMENT 1 1
2 ROOM 2 2

如果没有统一设计,项目里很容易出现这些问题:

  • 数据库存 1,Java 却收到 APARTMENT
  • GET ?type=1 无法转换成枚举
  • POST {"type":1} 反序列化失败
  • JPA 默认把枚举存成序号或字符串
  • 前端、后端、数据库分别维护不同的枚举含义
  • 新增枚举值以后,到处写 if/else
  • 无效枚举值有的返回 null,有的直接报 500

本文参考一篇关于 Spring MVC、MyBatis-Plus 与枚举转换的文章,将其中的思路改造成一套适用于:

Spring Boot 3 + Spring MVC + Jackson + Spring Data JPA / MyBatis-Plus + Vue/TypeScript

的完整实现。

1. 先确定枚举字段的统一约定

本文采用下面的约定:

表示形式
数据库 INT,例如 12
Java Entity / DTO ItemType 枚举
URL Query / Path 参数 "1""2"
JSON 请求 12
JSON 响应 12
Vue / TypeScript number

例如:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
public enum ItemType {

APARTMENT(1, "公寓"),
ROOM(2, "房间");

private final Integer code;
private final String description;

ItemType(Integer code, String description) {
this.code = code;
this.description = description;
}

public Integer getCode() {
return code;
}

public String getDescription() {
return description;
}
}

前端永远只需要关心:

1
2
3
{
"type": 1
}

业务代码内部则可以直接写:

1
2
3
if (item.getType() == ItemType.APARTMENT) {
// 公寓业务
}

而不是:

1
2
3
if (item.getType() == 1) {
// 魔法数字越来越多
}

这就是本文要实现的目标。


2. 为什么不直接使用 @Enumerated

JPA 原生支持:

1
2
@Enumerated(EnumType.ORDINAL)
private ItemType type;

或者:

1
2
@Enumerated(EnumType.STRING)
private ItemType type;

但是对于业务枚举,我通常不推荐 ORDINAL

假设原来的枚举是:

1
2
3
4
public enum ItemType {
APARTMENT,
ROOM
}

数据库保存:

1
2
APARTMENT -> 0
ROOM -> 1

后来有人调整成:

1
2
3
4
5
public enum ItemType {
SHOP,
APARTMENT,
ROOM
}

此时:

1
2
3
SHOP      -> 0
APARTMENT -> 1
ROOM -> 2

历史数据的含义直接发生变化。

这类问题属于典型的“代码跑得很好,数据已经悄悄坏了”。

EnumType.STRINGORDINAL 安全,但数据库会保存:

1
2
APARTMENT
ROOM

如果希望数据库、API 都使用稳定的业务 code:

1
2
1 = 公寓
2 = 房间

更适合使用:

1
AttributeConverter<ItemType, Integer>

另外需要注意:

使用 AttributeConverter 的枚举字段不要再同时标注 @Enumerated

JPA 对 @Enumerated 与属性转换器同时使用并没有定义为可移植的组合。


3. 数据库设计

示例表:

1
2
3
4
5
6
7
8
9
10
CREATE TABLE label_info
(
id BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键',
type INT NOT NULL COMMENT '类型:1-公寓,2-房间',
name VARCHAR(100) NOT NULL COMMENT '标签名称',
create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
update_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (id),
INDEX idx_label_info_type (type)
) COMMENT = '标签信息';

测试数据:

1
2
3
4
INSERT INTO label_info(type, name)
VALUES (1, '近地铁'),
(1, '可做饭'),
(2, '独立卫生间');

这里不建议直接使用 MySQL 的 ENUM 类型。

业务枚举应该由 Java 代码和数据库迁移脚本共同管理,数据库字段保持普通数字类型即可。


4. 定义统一的枚举接口

项目中不会只有一个枚举,所以先抽象公共接口。

1
2
3
4
5
6
7
8
package com.example.demo.enums;

public interface BaseCodeEnum {

Integer getCode();

String getDescription();
}

所有需要参与“前端、Spring MVC、Jackson、JPA”转换的枚举都实现这个接口。


5. 定义统一的枚举查找工具

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
package com.example.demo.enums;

import java.util.Arrays;

public final class EnumUtils {

private EnumUtils() {
}

public static <E extends Enum<E> & BaseCodeEnum> E fromCode(
Class<E> enumType,
Integer code) {

if (code == null) {
return null;
}

return Arrays.stream(enumType.getEnumConstants())
.filter(item -> item.getCode().equals(code))
.findFirst()
.orElseThrow(() ->
new IllegalArgumentException(
"非法枚举值,enum=" + enumType.getSimpleName()
+ ", code=" + code
)
);
}
}

这里有一个很重要的原则:

遇到未知 code 时不要静默返回 null

例如数据库中出现:

1
type = 99

如果 99 已经不是合法业务值,最好立即暴露数据问题,而不是把它悄悄转换成 null,然后在更远的地方产生一个莫名其妙的 NPE。


6. 定义业务枚举

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
package com.example.demo.enums;

import com.fasterxml.jackson.annotation.JsonCreator;
import com.fasterxml.jackson.annotation.JsonValue;

public enum ItemType implements BaseCodeEnum {

APARTMENT(1, "公寓"),
ROOM(2, "房间");

private final Integer code;
private final String description;

ItemType(Integer code, String description) {
this.code = code;
this.description = description;
}

@Override
@JsonValue
public Integer getCode() {
return code;
}

@Override
public String getDescription() {
return description;
}

@JsonCreator(mode = JsonCreator.Mode.DELEGATING)
public static ItemType fromCode(Integer code) {
return EnumUtils.fromCode(ItemType.class, code);
}
}

这里的两个 Jackson 注解分别处理:

1
@JsonValue

负责:

1
Java Enum -> JSON

例如:

1
ItemType.APARTMENT

序列化为:

1
1

而不是默认的:

1
"APARTMENT"

@JsonCreator 负责:

1
JSON -> Java Enum

例如:

1
2
3
{
"type": 1
}

转换成:

1
ItemType.APARTMENT

所以 JSON 的完整转换路径是:

1
2
3
4
5
6
7
8
9
JSON 1
|
| Jackson @JsonCreator
v
ItemType.APARTMENT
|
| Jackson @JsonValue
v
JSON 1

7. Spring MVC 中 URL 参数为什么还需要 Converter

这里很容易混淆。

下面这个请求:

1
GET /api/labels?type=1

Controller:

1
2
3
4
@GetMapping
public List<LabelResponse> list(
@RequestParam(required = false) ItemType type) {
}

这里的 type=1 并不是 JSON。

它是 HTTP Query Parameter,本质上先是:

1
String source = "1";

Spring MVC 需要完成:

1
2
3
String "1"
->
ItemType.APARTMENT

这套转换走的是 Spring 的 ConversionService,而不是 Jackson。

默认情况下,Spring 对字符串到枚举的转换更适合:

1
"APARTMENT" -> ItemType.APARTMENT

但我们的 API 希望:

1
"1" -> ItemType.APARTMENT

因此需要自定义 ConverterFactory


8. 使用 ConverterFactory 统一处理所有枚举

不要为每一个枚举都写:

1
2
3
4
StringToItemTypeConverter
StringToOrderStatusConverter
StringToPayTypeConverter
StringToSourceTypeConverter

因为转换逻辑完全一样。

Spring 提供的 ConverterFactory 就是解决这个问题的。

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
47
48
49
50
51
52
53
54
55
56
57
package com.example.demo.web.converter;

import com.example.demo.enums.BaseCodeEnum;
import org.springframework.core.convert.converter.Converter;
import org.springframework.core.convert.converter.ConverterFactory;

public class StringToCodeEnumConverterFactory
implements ConverterFactory<String, BaseCodeEnum> {

@Override
public <T extends BaseCodeEnum> Converter<String, T> getConverter(
Class<T> targetType) {

if (!targetType.isEnum()) {
throw new IllegalArgumentException(
targetType.getName() + " 不是枚举类型"
);
}

return source -> convert(source, targetType);
}

private <T extends BaseCodeEnum> T convert(
String source,
Class<T> targetType) {

if (source == null || source.isBlank()) {
return null;
}

final Integer code;

try {
code = Integer.valueOf(source);
} catch (NumberFormatException e) {
throw new IllegalArgumentException(
"非法枚举值:" + source,
e
);
}

T[] enumConstants = targetType.getEnumConstants();

for (T enumConstant : enumConstants) {
if (enumConstant.getCode().equals(code)) {
return enumConstant;
}
}

throw new IllegalArgumentException(
"非法枚举值,enum="
+ targetType.getSimpleName()
+ ", code="
+ code
);
}
}

9. 注册 Spring MVC ConverterFactory

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
package com.example.demo.config;

import com.example.demo.web.converter.StringToCodeEnumConverterFactory;
import org.springframework.context.annotation.Configuration;
import org.springframework.format.FormatterRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;

@Configuration
public class WebMvcConfiguration implements WebMvcConfigurer {

@Override
public void addFormatters(FormatterRegistry registry) {
registry.addConverterFactory(
new StringToCodeEnumConverterFactory()
);
}
}

现在:

1
GET /api/labels?type=1

可以自动转换成:

1
ItemType.APARTMENT

而:

1
GET /api/labels?type=2

自动转换成:

1
ItemType.ROOM

这套 Converter 同样可以服务于:

1
2
@RequestParam
@PathVariable

例如:

1
2
3
4
@GetMapping("/type/{type}")
public List<LabelResponse> listByType(
@PathVariable ItemType type) {
}

调用:

1
GET /api/labels/type/1

Spring MVC 会得到:

1
ItemType.APARTMENT

10. Spring MVC Converter 和 Jackson 不是一回事

这是整个设计里最需要记住的一点。

URL 参数

1
GET /api/labels?type=1

转换链:

1
2
3
4
5
6
7
8
9
10
11
HTTP Query String
|
v
Spring MVC
ConversionService
|
v
ConverterFactory
|
v
ItemType.APARTMENT

JSON 请求体

1
2
POST /api/labels
Content-Type: application/json
1
2
3
4
{
"type": 1,
"name": "近地铁"
}

转换链:

1
2
3
4
5
6
7
8
9
10
11
12
13
JSON
|
v
HttpMessageConverter
|
v
Jackson ObjectMapper
|
v
@JsonCreator
|
v
ItemType.APARTMENT

所以:

ConverterFactory 解决不了 @RequestBody 的 JSON 枚举反序列化。

反过来也一样:

@JsonCreator 不能替代 @RequestParam 的 Spring MVC 类型转换。

两套机制职责不同。


11. 使用 JPA AttributeConverter 做数据库转换

接下来解决:

1
2
3
数据库 INT
<->
Java ItemType

JPA 标准提供了:

1
AttributeConverter<X, Y>

其中:

1
2
X = Java Entity 属性类型
Y = 数据库列对应的基础类型

对于当前场景:

1
2
X = ItemType
Y = Integer

12. 抽象一个 JPA 枚举 Converter 基类

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
package com.example.demo.jpa.converter;

import com.example.demo.enums.BaseCodeEnum;
import com.example.demo.enums.EnumUtils;
import jakarta.persistence.AttributeConverter;

public abstract class AbstractCodeEnumJpaConverter<
E extends Enum<E> & BaseCodeEnum>
implements AttributeConverter<E, Integer> {

private final Class<E> enumType;

protected AbstractCodeEnumJpaConverter(Class<E> enumType) {
this.enumType = enumType;
}

@Override
public Integer convertToDatabaseColumn(E attribute) {
return attribute == null ? null : attribute.getCode();
}

@Override
public E convertToEntityAttribute(Integer dbData) {
return EnumUtils.fromCode(enumType, dbData);
}
}

以后每个业务枚举只需要写一个非常薄的 Converter。


13. ItemType 的 JPA Converter

1
2
3
4
5
6
7
8
9
10
11
12
13
package com.example.demo.jpa.converter;

import com.example.demo.enums.ItemType;
import jakarta.persistence.Converter;

@Converter(autoApply = true)
public class ItemTypeJpaConverter
extends AbstractCodeEnumJpaConverter<ItemType> {

public ItemTypeJpaConverter() {
super(ItemType.class);
}
}

autoApply = true 表示:

Entity 中所有 ItemType 类型的普通持久化属性,默认自动使用该 Converter。

于是实体字段不需要再重复写:

1
@Convert(converter = ItemTypeJpaConverter.class)

当然,如果项目更喜欢“显式优于隐式”,也可以写成:

1
2
@Converter(autoApply = false)
public class ItemTypeJpaConverter ...

然后实体上:

1
2
@Convert(converter = ItemTypeJpaConverter.class)
private ItemType type;

二选一即可。

不要同时再加:

1
@Enumerated

14. Entity

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
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
package com.example.demo.entity;

import com.example.demo.enums.ItemType;
import jakarta.persistence.*;

import java.time.LocalDateTime;

@Entity
@Table(name = "label_info")
public class LabelInfo {

@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;

@Column(name = "type", nullable = false)
private ItemType type;

@Column(name = "name", nullable = false, length = 100)
private String name;

@Column(name = "create_time", nullable = false)
private LocalDateTime createTime;

@Column(name = "update_time", nullable = false)
private LocalDateTime updateTime;

public Long getId() {
return id;
}

public ItemType getType() {
return type;
}

public void setType(ItemType type) {
this.type = type;
}

public String getName() {
return name;
}

public void setName(String name) {
this.name = name;
}

public LocalDateTime getCreateTime() {
return createTime;
}

public void setCreateTime(LocalDateTime createTime) {
this.createTime = createTime;
}

public LocalDateTime getUpdateTime() {
return updateTime;
}

public void setUpdateTime(LocalDateTime updateTime) {
this.updateTime = updateTime;
}
}

Entity 中依然使用强类型:

1
private ItemType type;

而不是:

1
private Integer type;

这样业务代码就不会到处出现:

1
if (type == 1)

15. Spring Data JPA Repository

1
2
3
4
5
6
7
8
9
10
11
12
13
package com.example.demo.repository;

import com.example.demo.entity.LabelInfo;
import com.example.demo.enums.ItemType;
import org.springframework.data.jpa.repository.JpaRepository;

import java.util.List;

public interface LabelInfoRepository
extends JpaRepository<LabelInfo, Long> {

List<LabelInfo> findByType(ItemType type);
}

查询时可以直接传枚举:

1
repository.findByType(ItemType.APARTMENT);

不用手工:

1
repository.findByType(1);

业务层不应该知道数据库使用的是 1 还是 2

数据库存储细节应该由 JPA Converter 隔离掉。


16. MyBatis-Plus 方案:使用 @EnumValue

Spring Data JPA 通过:

1
AttributeConverter<ItemType, Integer>

解决:

1
Java Enum <-> DB INT

而 MyBatis-Plus 对业务枚举提供了更直接的支持。

最常用的方式是在数据库值对应的枚举字段上添加:

1
@EnumValue

例如:

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
47
48
49
package com.example.demo.enums;

import com.baomidou.mybatisplus.annotation.EnumValue;
import com.fasterxml.jackson.annotation.JsonCreator;
import com.fasterxml.jackson.annotation.JsonValue;

public enum ItemType implements BaseCodeEnum {

APARTMENT(1, "公寓"),
ROOM(2, "房间");

/**
* MyBatis-Plus:
* 指定数据库真正保存的是 code。
*/
@EnumValue
private final Integer code;

private final String description;

ItemType(Integer code, String description) {
this.code = code;
this.description = description;
}

/**
* Jackson:
* JSON 响应输出数字 code。
*/
@Override
@JsonValue
public Integer getCode() {
return code;
}

@Override
public String getDescription() {
return description;
}

/**
* Jackson:
* JSON 请求中的数字 code 转成枚举。
*/
@JsonCreator(mode = JsonCreator.Mode.DELEGATING)
public static ItemType fromCode(Integer code) {
return EnumUtils.fromCode(ItemType.class, code);
}
}

于是同一个字段上的职责可以理解为:

1
2
3
4
5
6
7
8
9
10
11
@EnumValue
|
+---- MyBatis-Plus <-> DB

@JsonValue / @JsonCreator
|
+---- Jackson <-> JSON

Spring ConverterFactory
|
+---- Query / Path <-> Java Enum

例如:

1
ItemType.APARTMENT

写入数据库时:

1
1

数据库读取:

1
1

转换回:

1
ItemType.APARTMENT

这和 JPA 的:

1
AttributeConverter<ItemType, Integer>

解决的是同一个层面的问题,只是实现机制不同。


17. MyBatis-Plus 方案二:实现 IEnum

MyBatis-Plus 还支持让枚举实现:

1
IEnum<T>

例如:

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
package com.example.demo.enums;

import com.baomidou.mybatisplus.annotation.IEnum;
import com.fasterxml.jackson.annotation.JsonCreator;
import com.fasterxml.jackson.annotation.JsonValue;

public enum ItemType
implements BaseCodeEnum, IEnum<Integer> {

APARTMENT(1, "公寓"),
ROOM(2, "房间");

private final Integer code;
private final String description;

ItemType(Integer code, String description) {
this.code = code;
this.description = description;
}

@Override
@JsonValue
public Integer getCode() {
return code;
}

@Override
public String getDescription() {
return description;
}

/**
* MyBatis-Plus 存入数据库的值。
*/
@Override
public Integer getValue() {
return code;
}

@JsonCreator(mode = JsonCreator.Mode.DELEGATING)
public static ItemType fromCode(Integer code) {
return EnumUtils.fromCode(ItemType.class, code);
}
}

@EnumValueIEnum 二选一即可。

个人更推荐:

1
@EnumValue

原因很简单:

1
2
3
侵入更小
代码更少
一眼就能看出哪个字段是数据库值

IEnum 更适合项目已经统一要求所有 MyBatis-Plus 枚举实现同一个接口的情况。

需要注意的是,这两种方案都会让枚举依赖 MyBatis-Plus。

如果你的领域层要求完全不依赖 ORM 框架,可以继续使用:

1
BaseCodeEnum

然后在 MyBatis 中编写自己的:

1
TypeHandler

不过普通业务系统没有必要为了“绝对纯洁”多写一大堆模板代码,@EnumValue 通常已经足够实用。


18. MyBatis-Plus Entity

MyBatis-Plus 实体可以直接把字段定义成枚举:

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
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
package com.example.demo.entity;

import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import com.example.demo.enums.ItemType;

import java.time.LocalDateTime;

@TableName("label_info")
public class LabelInfoMp {

@TableId(type = IdType.AUTO)
private Long id;

private ItemType type;

private String name;

private LocalDateTime createTime;

private LocalDateTime updateTime;

public Long getId() {
return id;
}

public void setId(Long id) {
this.id = id;
}

public ItemType getType() {
return type;
}

public void setType(ItemType type) {
this.type = type;
}

public String getName() {
return name;
}

public void setName(String name) {
this.name = name;
}

public LocalDateTime getCreateTime() {
return createTime;
}

public void setCreateTime(LocalDateTime createTime) {
this.createTime = createTime;
}

public LocalDateTime getUpdateTime() {
return updateTime;
}

public void setUpdateTime(LocalDateTime updateTime) {
this.updateTime = updateTime;
}
}

数据库仍然是:

1
type INT NOT NULL

Java 中仍然是:

1
private ItemType type;

这点和 JPA 的最终效果一致:

1
2
3
4
DB                     Java
--------------------------------
1 <-> APARTMENT
2 <-> ROOM

19. MyBatis-Plus Mapper

Mapper:

1
2
3
4
5
6
7
8
9
10
package com.example.demo.mapper;

import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.example.demo.entity.LabelInfoMp;
import org.apache.ibatis.annotations.Mapper;

@Mapper
public interface LabelInfoMapper
extends BaseMapper<LabelInfoMp> {
}

插入:

1
2
3
4
5
LabelInfoMp label = new LabelInfoMp();
label.setType(ItemType.APARTMENT);
label.setName("近地铁");

labelInfoMapper.insert(label);

Java 中传入的是:

1
ItemType.APARTMENT

MyBatis-Plus 根据:

1
2
@EnumValue
private final Integer code;

最终写入:

1
2
INSERT INTO label_info(type, name)
VALUES (1, '近地铁');

查询数据库得到:

1
type = 1

又会映射回:

1
ItemType.APARTMENT

因此业务层完全不需要自己写:

1
ItemType.fromCode(entity.getType())

ORM 层已经完成了转换。


20. MyBatis-Plus LambdaQueryWrapper 查询枚举

查询条件也应该直接使用枚举:

1
2
3
4
5
6
7
List<LabelInfoMp> list = labelInfoMapper.selectList(
new LambdaQueryWrapper<LabelInfoMp>()
.eq(
LabelInfoMp::getType,
ItemType.APARTMENT
)
);

不要为了数据库是 INT 就退化成:

1
.eq("type", 1)

推荐始终保持:

1
2
3
4
.eq(
LabelInfoMp::getType,
ItemType.APARTMENT
)

动态条件:

1
2
3
4
5
6
7
8
9
10
11
public List<LabelInfoMp> list(ItemType type) {

return labelInfoMapper.selectList(
new LambdaQueryWrapper<LabelInfoMp>()
.eq(
type != null,
LabelInfoMp::getType,
type
)
);
}

Controller:

1
2
3
4
5
6
@GetMapping
public List<LabelInfoMp> list(
@RequestParam(required = false) ItemType type) {

return service.list(type);
}

调用:

1
GET /api/labels?type=1

Spring MVC 首先转换:

1
2
3
4
"1"
|
v
ItemType.APARTMENT

MyBatis-Plus 再转换:

1
2
3
4
ItemType.APARTMENT
|
v
1

最终 SQL:

1
2
3
4
5
6
7
8
SELECT
id,
type,
name,
create_time,
update_time
FROM label_info
WHERE type = 1;

21. MyBatis-Plus XML 查询

MyBatis-Plus 最大的优势之一,是复杂报表或动态 SQL 场景下仍然可以自然使用 MyBatis XML。

Mapper:

1
2
3
4
5
6
7
8
@Mapper
public interface LabelInfoMapper
extends BaseMapper<LabelInfoMp> {

List<LabelInfoMp> selectByType(
@Param("type") ItemType type
);
}

XML:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper
PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
"https://mybatis.org/dtd/mybatis-3-mapper.dtd">

<mapper namespace="com.example.demo.mapper.LabelInfoMapper">

<select id="selectByType"
resultType="com.example.demo.entity.LabelInfoMp">
SELECT
id,
type,
name,
create_time,
update_time
FROM label_info
WHERE type = #{type}
</select>

</mapper>

Java 调用:

1
mapper.selectByType(ItemType.APARTMENT);

业务代码传的是:

1
ItemType.APARTMENT

数据库参数仍然按声明的枚举值处理。

如果 SQL 比较复杂:

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
<select id="selectList"
resultType="com.example.demo.entity.LabelInfoMp">

SELECT
id,
type,
name,
create_time,
update_time
FROM label_info

<where>

<if test="type != null">
AND type = #{type}
</if>

<if test="name != null and name != ''">
AND name LIKE CONCAT('%', #{name}, '%')
</if>

</where>

ORDER BY id DESC

</select>

Java 层仍然不需要出现:

1
type.getCode()

更不需要:

1
type == 1

这种设计对复杂报表场景尤其友好:

1
2
3
SQL 的复杂性留在 SQL 层
枚举的业务语义留在 Java 层
枚举与数据库 code 的转换交给 TypeHandler

22. MyBatis-Plus 为什么能自动转换

MyBatis 本身有:

1
2
EnumTypeHandler
EnumOrdinalTypeHandler

分别可以按:

1
2
枚举名称
枚举 ordinal

进行持久化。

MyBatis-Plus 在此基础上提供:

1
MybatisEnumTypeHandler

用于处理声明过业务值的枚举。

例如:

1
2
@EnumValue
private final Integer code;

或者:

1
implements IEnum<Integer>

MyBatis-Plus 就知道真正应该写入数据库的是:

1
code

而不是:

1
APARTMENT

也不是:

1
ordinal()

在当前 MyBatis-Plus 版本中,通用枚举可以直接使用,不需要再配置旧版本中的:

1
2
mybatis-plus:
type-enums-package: xxx

这个配置已经不是新版本的推荐方式。

因此现代项目里通常只需要:

1
@EnumValue

即可完成:

1
Java Enum <-> DB Value

23. Spring Boot 3 引入 MyBatis-Plus

Spring Boot 3 项目使用对应的 Boot 3 Starter:

1
2
3
4
<dependency>
<groupId>com.baomidou</groupId>
<artifactId>mybatis-plus-spring-boot3-starter</artifactId>
</dependency>

如果你的项目已经通过:

1
<dependencyManagement>

或者公司统一 BOM 管理版本,这里不需要在每个业务模块重复写版本号。

Spring Data JPA 则通常使用:

1
2
3
4
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>

二者并不是互斥的技术。

同一个大型系统里完全可能存在:

1
2
3
4
5
6
7
8
9
简单 CRUD / 聚合关系
|
v
Spring Data JPA

复杂报表 / 多表动态 SQL
|
v
MyBatis-Plus

关键不是强迫整个系统只能选一个 ORM,而是让枚举协议保持一致。


24. 同一个项目同时使用 JPA 和 MyBatis-Plus 怎么办

有些项目会同时存在:

1
2
3
Spring Data JPA
+
MyBatis-Plus

这时有两种做法。

24.1 方案一:同一个枚举同时服务两个 ORM

可以:

1
2
3
4
5
6
7
8
9
10
public enum ItemType implements BaseCodeEnum {

APARTMENT(1, "公寓"),
ROOM(2, "房间");

@EnumValue
private final Integer code;

...
}

JPA:

1
2
3
4
5
6
7
8
@Converter(autoApply = true)
public class ItemTypeJpaConverter
extends AbstractCodeEnumJpaConverter<ItemType> {

public ItemTypeJpaConverter() {
super(ItemType.class);
}
}

于是:

1
2
3
4
5
6
7
8
9
ItemType.code
|
+---- @EnumValue ------------> MyBatis-Plus
|
+---- AttributeConverter ----> JPA
|
+---- @JsonValue ------------> JSON Response
|
+---- @JsonCreator ----------> JSON Request

这种方案最省代码。

缺点是:

1
ItemType

会直接依赖:

1
com.baomidou.mybatisplus.annotation.EnumValue

24.2 方案二:领域枚举完全不依赖 ORM

枚举只保留:

1
2
public enum ItemType implements BaseCodeEnum {
}

然后:

1
2
3
4
5
6
7
JPA
|
+--> AttributeConverter

MyBatis / MyBatis-Plus
|
+--> 自定义 TypeHandler

这种方式架构边界更干净。

适合:

  • Domain 模块要求零 ORM 依赖
  • 枚举定义放在独立公共模块
  • 同一个 Domain 被多个持久化实现复用
  • 对六边形架构 / Clean Architecture 边界要求较高

普通后台业务系统则没有必要过度设计。


25. JPA 与 MyBatis-Plus 枚举映射对比

两种方案实现的最终目标完全一致:

1
DB INT <-> Java Enum

区别主要在实现方式。

对比项 Spring Data JPA MyBatis-Plus
推荐机制 AttributeConverter @EnumValue / IEnum
Java 字段 ItemType ItemType
DB 字段 INT INT
查询条件 直接传枚举 直接传枚举
每个枚举额外代码 通常需要一个 Converter @EnumValue 基本即可
ORM 侵入枚举 可以做到无侵入 @EnumValue / IEnum 会依赖 MP
CRUD Repository 很方便 BaseMapper 很方便
动态 SQL Specification / Criteria 等 Wrapper / XML 更直接
复杂 SQL 通常需要 JPQL / Native SQL XML 非常自然
ORM 标准化 Jakarta Persistence 标准 MyBatis-Plus 框架能力
映射控制 ORM 属性转换 TypeHandler 体系
适合场景 领域模型、常规 CRUD SQL 驱动、复杂查询、报表

如果项目本身就是:

1
Spring Data JPA

推荐:

1
AttributeConverter

如果项目本身就是:

1
MyBatis-Plus

推荐:

1
@EnumValue

如果项目两者都有,则完全可以共用:

1
code + description

这个枚举业务协议。


26. 两种 ORM 下完整链路对比

Spring Data JPA

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
GET ?type=1
|
v
Spring MVC ConverterFactory
|
v
ItemType.APARTMENT
|
v
Repository
|
v
AttributeConverter
|
v
DB type = 1

读取:

1
2
3
4
5
6
7
8
9
10
11
12
13
DB type = 1
|
v
AttributeConverter
|
v
ItemType.APARTMENT
|
v
Jackson @JsonValue
|
v
JSON 1

MyBatis-Plus

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
GET ?type=1
|
v
Spring MVC ConverterFactory
|
v
ItemType.APARTMENT
|
v
BaseMapper / Wrapper / XML
|
v
MybatisEnumTypeHandler
|
v
@EnumValue code = 1
|
v
DB type = 1

读取:

1
2
3
4
5
6
7
8
9
10
11
12
13
DB type = 1
|
v
MybatisEnumTypeHandler
|
v
ItemType.APARTMENT
|
v
Jackson @JsonValue
|
v
JSON 1

可以发现:

1
2
3
前端
Spring MVC
Jackson

这三层根本不用关心底层到底是:

1
JPA

还是:

1
MyBatis-Plus

变化只发生在 Persistence Layer。

这也是比较合理的分层方式。


27. DTO

建议 Controller 不要直接把 Entity 暴露给前端。

请求:

1
2
3
4
5
6
7
8
9
package com.example.demo.dto;

import com.example.demo.enums.ItemType;

public record CreateLabelRequest(
ItemType type,
String name
) {
}

响应:

1
2
3
4
5
6
7
8
9
10
package com.example.demo.dto;

import com.example.demo.enums.ItemType;

public record LabelResponse(
Long id,
ItemType type,
String name
) {
}

由于 ItemType#getCode() 上存在:

1
@JsonValue

所以:

1
2
3
4
5
new LabelResponse(
1L,
ItemType.APARTMENT,
"近地铁"
);

最终 JSON 是:

1
2
3
4
5
{
"id": 1,
"type": 1,
"name": "近地铁"
}

而不是:

1
2
3
4
5
{
"id": 1,
"type": "APARTMENT",
"name": "近地铁"
}

28. Service

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
47
48
49
50
51
52
53
54
package com.example.demo.service;

import com.example.demo.dto.CreateLabelRequest;
import com.example.demo.dto.LabelResponse;
import com.example.demo.entity.LabelInfo;
import com.example.demo.enums.ItemType;
import com.example.demo.repository.LabelInfoRepository;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

import java.time.LocalDateTime;
import java.util.List;

@Service
public class LabelInfoService {

private final LabelInfoRepository repository;

public LabelInfoService(LabelInfoRepository repository) {
this.repository = repository;
}

@Transactional(readOnly = true)
public List<LabelResponse> list(ItemType type) {

List<LabelInfo> entities = type == null
? repository.findAll()
: repository.findByType(type);

return entities.stream()
.map(this::toResponse)
.toList();
}

@Transactional
public LabelResponse create(CreateLabelRequest request) {

LabelInfo entity = new LabelInfo();
entity.setType(request.type());
entity.setName(request.name());
entity.setCreateTime(LocalDateTime.now());
entity.setUpdateTime(LocalDateTime.now());

return toResponse(repository.save(entity));
}

private LabelResponse toResponse(LabelInfo entity) {
return new LabelResponse(
entity.getId(),
entity.getType(),
entity.getName()
);
}
}

29. Controller

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
package com.example.demo.controller;

import com.example.demo.dto.CreateLabelRequest;
import com.example.demo.dto.LabelResponse;
import com.example.demo.enums.ItemType;
import com.example.demo.service.LabelInfoService;
import org.springframework.web.bind.annotation.*;

import java.util.List;

@RestController
@RequestMapping("/api/labels")
public class LabelInfoController {

private final LabelInfoService service;

public LabelInfoController(LabelInfoService service) {
this.service = service;
}

@GetMapping
public List<LabelResponse> list(
@RequestParam(required = false) ItemType type) {

return service.list(type);
}

@PostMapping
public LabelResponse create(
@RequestBody CreateLabelRequest request) {

return service.create(request);
}
}

30. GET 请求完整转换过程

前端请求:

1
GET /api/labels?type=1

第一步,HTTP 中的:

1
type=1

本质是字符串:

1
"1"

Spring MVC:

1
2
3
4
5
6
7
8
9
10
@RequestParam
|
v
ConversionService
|
v
StringToCodeEnumConverterFactory
|
v
ItemType.APARTMENT

Controller 得到:

1
ItemType.APARTMENT

调用:

1
repository.findByType(ItemType.APARTMENT);

JPA:

1
2
3
4
5
6
7
ItemType.APARTMENT
|
v
ItemTypeJpaConverter
|
v
1

最终 SQL 的逻辑类似:

1
2
3
4
5
6
7
8
SELECT
id,
type,
name,
create_time,
update_time
FROM label_info
WHERE type = 1;

查询结果:

1
type = 1

JPA 再通过:

1
ItemTypeJpaConverter

转换回:

1
ItemType.APARTMENT

最后 Jackson:

1
@JsonValue

再次把:

1
ItemType.APARTMENT

输出为:

1
1

最终响应:

1
2
3
4
5
6
7
8
9
10
11
12
[
{
"id": 1,
"type": 1,
"name": "近地铁"
},
{
"id": 2,
"type": 1,
"name": "可做饭"
}
]

整个链路:

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
前端
type=1
|
v
Spring MVC ConverterFactory
|
v
ItemType.APARTMENT
|
v
Spring Data JPA
|
v
AttributeConverter
|
v
DB type=1
|
v
AttributeConverter
|
v
ItemType.APARTMENT
|
v
Jackson @JsonValue
|
v
JSON type=1

31. POST 请求完整转换过程

请求:

1
2
POST /api/labels
Content-Type: application/json
1
2
3
4
{
"type": 2,
"name": "独立卫生间"
}

Jackson 读取:

1
2

调用:

1
ItemType.fromCode(2)

得到:

1
ItemType.ROOM

Controller 实际收到:

1
2
3
4
CreateLabelRequest(
ItemType.ROOM,
"独立卫生间"
)

保存:

1
entity.setType(ItemType.ROOM);

JPA Converter:

1
ItemType.ROOM -> 2

数据库:

1
2
INSERT INTO label_info(type, name)
VALUES (2, '独立卫生间');

响应阶段:

1
2
3
4
5
6
7
ItemType.ROOM
|
v
@JsonValue
|
v
2

最终:

1
2
3
4
5
{
"id": 3,
"type": 2,
"name": "独立卫生间"
}

32. Vue / TypeScript 如何定义

前端也不要到处直接写:

1
2
if (type === 1) {
}

可以统一定义:

1
2
3
4
5
6
7
export const ItemType = {
APARTMENT: 1,
ROOM: 2
} as const

export type ItemType =
typeof ItemType[keyof typeof ItemType]

下拉选项:

1
2
3
4
5
6
7
8
9
10
export const itemTypeOptions = [
{
label: '公寓',
value: ItemType.APARTMENT
},
{
label: '房间',
value: ItemType.ROOM
}
]

查询:

1
2
3
4
5
6
7
import axios from 'axios'

axios.get('/api/labels', {
params: {
type: ItemType.APARTMENT
}
})

浏览器发送:

1
GET /api/labels?type=1

新增:

1
2
3
4
axios.post('/api/labels', {
type: ItemType.ROOM,
name: '独立卫生间'
})

请求:

1
2
3
4
{
"type": 2,
"name": "独立卫生间"
}

这样前后端协议始终保持数字 code。


33. 如果前端还需要中文名称怎么办

有两种方式。

方案一:前端自己维护枚举字典

适合非常稳定的枚举:

1
2
3
4
export const itemTypeOptions = [
{ label: '公寓', value: 1 },
{ label: '房间', value: 2 }
]

优点:

  • 简单
  • 不需要额外请求
  • 页面加载快

缺点:

  • Java 和 TypeScript 各维护一份

方案二:后端提供枚举字典接口

定义:

1
2
3
4
5
public record EnumOption(
Integer value,
String label
) {
}

Controller:

1
2
3
4
5
6
7
8
9
10
@GetMapping("/item-types")
public List<EnumOption> itemTypes() {

return Arrays.stream(ItemType.values())
.map(item -> new EnumOption(
item.getCode(),
item.getDescription()
))
.toList();
}

响应:

1
2
3
4
5
6
7
8
9
10
[
{
"value": 1,
"label": "公寓"
},
{
"value": 2,
"label": "房间"
}
]

前端:

1
const { data } = await axios.get('/api/labels/item-types')

适合:

  • 枚举很多
  • 后端是枚举定义的唯一事实来源
  • 多个前端共同使用同一套 API
  • 枚举描述可能调整

企业项目里,如果枚举数量比较多,我更推荐后端统一暴露字典接口。


34. 枚举非法值统一返回 400

如果请求:

1
GET /api/labels?type=99

或者:

1
2
3
4
{
"type": 99,
"name": "错误数据"
}

应该返回:

1
400 Bad Request

而不是 500。

Spring Boot 3 可以直接使用 ProblemDetail

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
47
48
49
package com.example.demo.web;

import org.springframework.http.HttpStatus;
import org.springframework.http.ProblemDetail;
import org.springframework.http.ResponseEntity;
import org.springframework.http.converter.HttpMessageNotReadableException;
import org.springframework.web.bind.MethodArgumentTypeMismatchException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;

@RestControllerAdvice
public class GlobalExceptionHandler {

@ExceptionHandler(MethodArgumentTypeMismatchException.class)
public ResponseEntity<ProblemDetail> handleTypeMismatch(
MethodArgumentTypeMismatchException exception) {

ProblemDetail detail = ProblemDetail.forStatus(
HttpStatus.BAD_REQUEST
);

detail.setTitle("请求参数错误");
detail.setDetail(
"参数 " + exception.getName()
+ " 的值非法:"
+ exception.getValue()
);

return ResponseEntity
.badRequest()
.body(detail);
}

@ExceptionHandler(HttpMessageNotReadableException.class)
public ResponseEntity<ProblemDetail> handleMessageNotReadable(
HttpMessageNotReadableException exception) {

ProblemDetail detail = ProblemDetail.forStatus(
HttpStatus.BAD_REQUEST
);

detail.setTitle("请求体格式错误");
detail.setDetail("请求 JSON 中存在非法字段值");

return ResponseEntity
.badRequest()
.body(detail);
}
}

例如:

1
GET /api/labels?type=99

返回:

1
2
3
4
5
6
{
"type": "about:blank",
"title": "请求参数错误",
"status": 400,
"detail": "参数 type 的值非法:99"
}

生产环境不建议直接把底层异常堆栈或 Java 类名返回给前端。


35. 一个完整枚举最终长什么样

最终一个普通业务枚举只需要:

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
package com.example.demo.enums;

import com.fasterxml.jackson.annotation.JsonCreator;
import com.fasterxml.jackson.annotation.JsonValue;

public enum ItemType implements BaseCodeEnum {

APARTMENT(1, "公寓"),
ROOM(2, "房间");

private final Integer code;
private final String description;

ItemType(Integer code, String description) {
this.code = code;
this.description = description;
}

@Override
@JsonValue
public Integer getCode() {
return code;
}

@Override
public String getDescription() {
return description;
}

@JsonCreator(mode = JsonCreator.Mode.DELEGATING)
public static ItemType fromCode(Integer code) {
return EnumUtils.fromCode(ItemType.class, code);
}
}

再配一个 JPA Converter:

1
2
3
4
5
6
7
8
@Converter(autoApply = true)
public class ItemTypeJpaConverter
extends AbstractCodeEnumJpaConverter<ItemType> {

public ItemTypeJpaConverter() {
super(ItemType.class);
}
}

Spring MVC 的:

1
StringToCodeEnumConverterFactory

只需要全局定义一次。


36. 为什么 JPA 这里没有完全做成一个通用 Converter

可能有人会想到这样写:

1
2
3
4
@Converter(autoApply = true)
public class BaseCodeEnumJpaConverter
implements AttributeConverter<BaseCodeEnum, Integer> {
}

看起来这样所有枚举都不用再写:

1
2
3
ItemTypeJpaConverter
OrderStatusJpaConverter
PayTypeJpaConverter

但不建议这么做。

JPA 的 AttributeConverter<X, Y> 需要根据明确的属性 Java 类型决定 Converter 是否适用。

例如:

1
AttributeConverter<ItemType, Integer>

JPA 很明确知道:

1
ItemType -> Integer

而:

1
AttributeConverter<BaseCodeEnum, Integer>

面对实体属性:

1
private ItemType type;

是否把“实现了接口”视为自动匹配,并不是一个应该依赖的可移植假设。

而且从数据库读到:

1
1

通用 Converter 本身也不知道应该构造:

1
ItemType.APARTMENT

还是:

1
OrderStatus.CREATED

因此更合理的做法是:

1
2
3
4
5
6
7
8
9
10
公共转换算法
|
v
AbstractCodeEnumJpaConverter
|
+--> ItemTypeJpaConverter
|
+--> OrderStatusJpaConverter
|
+--> PayTypeJpaConverter

每个枚举只增加一个几行代码的类型声明。

这点和 Spring MVC 不一样。

Spring MVC 的 ConverterFactory 在运行时能够拿到:

1
Class<T> targetType

因此它知道本次需要转换成哪个具体枚举。


37. 推荐的项目目录

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
src/main/java
└── com.example.demo
├── config
│ └── WebMvcConfiguration.java

├── controller
│ └── LabelInfoController.java

├── dto
│ ├── CreateLabelRequest.java
│ ├── EnumOption.java
│ └── LabelResponse.java

├── entity
│ └── LabelInfo.java

├── enums
│ ├── BaseCodeEnum.java
│ ├── EnumUtils.java
│ └── ItemType.java

├── jpa
│ └── converter
│ ├── AbstractCodeEnumJpaConverter.java
│ └── ItemTypeJpaConverter.java

├── repository
│ └── LabelInfoRepository.java

├── service
│ └── LabelInfoService.java

└── web
├── GlobalExceptionHandler.java
└── converter
└── StringToCodeEnumConverterFactory.java

38. 最终转换关系

整个系统可以归纳成四套机制。

场景 输入 输出 负责组件
Query / Path "1" ItemType.APARTMENT Spring MVC ConverterFactory
JSON 请求 1 ItemType.APARTMENT Jackson @JsonCreator
JSON 响应 ItemType.APARTMENT 1 Jackson @JsonValue
JPA 写库 ItemType.APARTMENT 1 AttributeConverter
JPA 读库 1 ItemType.APARTMENT AttributeConverter

也就是说:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
                    Spring MVC
ConverterFactory
|
HTTP "1" ------------>|

v
ItemType.APARTMENT
/ \
/ \
v v
AttributeConverter Jackson
| @JsonValue
v |
DB INT 1 v
JSON number 1

这才是一个完整的枚举转换体系。


39. 常见错误

39.1 使用 ordinal()

不要:

1
itemType.ordinal()

业务 code 应该显式定义:

1
APARTMENT(1, "公寓")

code 属于稳定的业务协议,枚举声明顺序不是。


39.2 Controller 参数全部写 Integer

例如:

1
2
3
4
5
6
@GetMapping
public void list(@RequestParam Integer type) {

if (type == 1) {
}
}

这样虽然“能跑”,但会导致类型信息从 Controller 开始就丢失。

推荐:

1
2
3
@GetMapping
public void list(@RequestParam ItemType type) {
}

Spring MVC 在边界完成转换,进入业务层以后全部使用强类型。


39.3 Entity 中直接保存 Integer

不要为了数据库方便写:

1
private Integer type;

然后业务里:

1
2
if (entity.getType() == 1) {
}

推荐:

1
private ItemType type;

让 JPA Converter 负责持久化细节。


39.4 认为 @JsonValue 能处理 Query 参数

不能。

1
GET ?type=1

走的是 Spring MVC ConversionService

1
2
3
{
"type": 1
}

才走 Jackson。

这是两条独立链路。


39.5 同时使用 @Enumerated@Convert

不要这样:

1
2
3
@Enumerated(EnumType.STRING)
@Convert(converter = ItemTypeJpaConverter.class)
private ItemType type;

如果选择 AttributeConverter,就让它完整负责该字段的 DB 映射。


39.6 非法 code 返回 null

不要:

1
return null;

推荐 fail-fast:

1
2
3
throw new IllegalArgumentException(
"非法枚举值:" + code
);

否则脏数据会一直向后传播。


40. 企业项目进一步优化

如果项目里只有五六个枚举,目前的方案已经足够。

如果项目里有几十甚至上百个业务枚举,可以继续做三件事。

40.1 缓存 code 到 enum 的映射

当前:

1
Arrays.stream(enumType.getEnumConstants())

每次会遍历枚举。

枚举数量一般很小,所以通常不是性能问题。

但如果转换频率极高,可以按枚举类型缓存:

1
2
3
Class<ItemType>
->
Map<Integer, ItemType>

把查找复杂度从线性遍历变成哈希查找。


40.2 自动生成前端枚举

如果前后端都是自己维护,可以进一步把 Java 枚举作为唯一事实来源,然后:

1
2
3
4
5
6
7
Java Enum
|
v
构建期代码生成
|
v
TypeScript Enum / const

从根源上避免:

1
2
后端:ROOM = 2
前端:ROOM = 3

这种协议漂移。


40.3 建立统一枚举字典接口

例如:

1
GET /api/enums

返回:

1
2
3
4
5
6
7
8
9
10
11
12
{
"itemType": [
{
"value": 1,
"label": "公寓"
},
{
"value": 2,
"label": "房间"
}
]
}

前端表单、筛选器、详情展示都从同一个接口获取。

对于后台管理系统非常实用。


41. 推荐规范

最终建议把项目规范定成:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
1. 所有业务枚举至少包含 code + description

2. code 一旦发布,不随意修改语义

3. 禁止使用 ordinal 作为数据库业务值

4. 数据库存稳定业务 code

5. Entity 使用枚举,不使用 Integer 魔法数字

6. Spring MVC Query / Path 使用 ConverterFactory

7. JSON 使用 Jackson @JsonValue + @JsonCreator

8. Spring Data JPA 使用 AttributeConverter

9. AttributeConverter 与 @Enumerated 不同时使用

10. 非法枚举值 fail-fast,并统一转成 HTTP 400

11. Controller / Service / Repository 内部尽量始终使用枚举

12. 前后端协议统一使用数字 code

最终达到:

1
2
3
4
5
6
数据库简单
前端简单
API 稳定
Java 强类型
业务代码没有魔法数字
转换逻辑集中管理

42. 实际项目怎么选

如果只是问:

JPA 和 MyBatis-Plus 哪个枚举转换方式更好?

其实没有必要硬分胜负。

对于 JPA:

1
AttributeConverter

就是最自然的标准扩展点。

对于 MyBatis-Plus:

1
@EnumValue

就是最简单直接的方式。

真正应该统一的是整个项目的业务协议:

1
2
3
4
5
数据库:稳定 code
Java:强类型 Enum
HTTP Query / Path:code
JSON:code
前端:code

然后在持久化层根据技术栈分别接入:

1
2
3
4
5
6
7
8
9
10
11
12
                   BaseCodeEnum
|
+-----------+-----------+
| |
v v
Spring Data JPA MyBatis-Plus
AttributeConverter @EnumValue
| |
+-----------+-----------+
|
v
DB INT

如果项目中:

  • 简单 CRUD 使用 JPA
  • 复杂报表使用 MyBatis-Plus
  • 两种持久化技术同时存在

也完全没有问题。

枚举本身的:

1
2
3
4
code
description
JSON 协议
HTTP 参数协议

保持一致即可。


43. 总结

原始问题看起来只是:

“数据库的 1 怎么变成 Java 枚举?”

实际上,一个 Web 项目至少有三条完全不同的转换链路:

1
2
3
HTTP 参数       -> Spring MVC ConversionService
JSON -> Jackson
数据库字段 -> JPA AttributeConverter / MyBatis-Plus Enum TypeHandler

如果只解决其中一层,枚举转换迟早还会在另外一层出问题。持久化层则应根据项目实际使用 Spring Data JPA 还是 MyBatis-Plus,分别选择 AttributeConverter@EnumValue / IEnum

因此更推荐把它设计成:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
                    ┌──────────────────────────┐
│ BaseCodeEnum │
│ code + description │
└────────────┬─────────────┘

ItemType / OrderStatus

┌────────────────────────┼────────────────────────┐
│ │ │
v v v
Spring MVC Jackson Spring Data JPA
ConverterFactory JsonCreator/JsonValue AttributeConverter
│ │ │
v v v
Query/Path String JSON number DB INT

对外始终是:

1
2
3
1
2
3

对内始终是:

1
2
ItemType.APARTMENT
ItemType.ROOM

这就是比较适合 Spring Boot 3 项目的枚举统一转换方式。


参考资料

  1. CSDN:SpringBoot + Vue 尚庭公寓实战——根据类型查询标签列表接口实现
    https://blog.csdn.net/weixin_53961667/article/details/139561983

  2. Spring Framework Reference:Spring Type Conversion
    https://docs.spring.io/spring-framework/reference/core/validation/convert.html

  3. Spring Framework Reference:Spring MVC Type Conversion
    https://docs.spring.io/spring-framework/reference/web/webmvc/mvc-controller/ann-methods/typeconversion.html

  4. Spring Framework Reference:@RequestParam
    https://docs.spring.io/spring-framework/reference/web/webmvc/mvc-controller/ann-methods/requestparam.html

  5. Jakarta Persistence:AttributeConverter
    https://jakarta.ee/specifications/persistence/3.2/apidocs/jakarta.persistence/jakarta/persistence/attributeconverter

  6. Jakarta Persistence:@Converter
    https://jakarta.ee/specifications/persistence/3.2/apidocs/jakarta.persistence/jakarta/persistence/converter

  7. Jackson:@JsonValue
    https://fasterxml.github.io/jackson-annotations/javadoc/2.10/com/fasterxml/jackson/annotation/JsonValue.html

  8. Jackson:@JsonCreator
    https://fasterxml.github.io/jackson-annotations/javadoc/2.9/com/fasterxml/jackson/annotation/JsonCreator.html

  9. MyBatis-Plus:自动映射枚举
    https://baomidou.com/guides/auto-convert-enum/

  10. MyBatis-Plus:配置说明与默认枚举 TypeHandler
    https://baomidou.com/reference/

  11. MyBatis-Plus:Spring Boot 3 安装
    https://baomidou.com/getting-started/install/

  12. MyBatis 官方文档
    https://mybatis.org/mybatis-3/


Spring Boot 3 枚举统一转换:Spring MVC + Jackson + Spring Data JPA / MyBatis-Plus
https://allendericdalexander.github.io/2026/08/10/java/spring/spring-mvc-jpa-mybatis-plus-enum-converter/
作者
AtLuoFu
发布于
2026年8月10日
许可协议