枚举类型是编程中常用的数据类型,几乎每个项目都会用到。但很多人只是简单用一下,用常量或者简单的枚举表示状态,没有深入思考枚举的架构设计。
实际上,在高可用高并发的系统中,枚举的设计会影响代码的可维护性、扩展性和性能。一个好的枚举设计,能让代码更清晰、更易扩展、更不容易出错;一个差的枚举设计,会导致代码混乱、扩展困难、bug频发。
我做后端开发多年,见过很多枚举设计的坑,也总结了一些经验。今天分享枚举类型的架构设计,包括枚举的最佳实践、高级用法、常见坑,以及在高并发场景下的设计思路。希望能帮大家写出更好的枚举代码。
本文主要以Java为例,但很多思路也适用于其他语言(C#、TypeScript、PHP 8.1等支持枚举的语言)。
一、为什么要重视枚举设计
在说具体设计之前,先说说为什么要重视枚举设计。
很多人觉得枚举很简单,就是定义几个常量,没什么好设计的。但实际上,在复杂的业务系统中,枚举用得非常多,状态、类型、结果码、错误码等,都可以用枚举表示。如果枚举设计不好,会带来很多问题:
第一,代码可读性差。 如果枚举命名混乱、没有注释、没有描述,别人读代码的时候不知道每个枚举值代表什么,需要到处查,效率低。
第二,扩展性差。 如果枚举设计得不好,新增一个枚举值需要改很多地方,容易遗漏,导致bug。比如订单状态加了一个新状态,但某个地方的switch没加对应的分支,就会出问题。
第三,性能问题。 在高并发场景下,如果枚举的使用方式不对,比如频繁遍历枚举、用字符串比较、没有缓存等,可能会影响性能。
第四,维护成本高。 枚举散落在各处,没有统一管理,重复定义,改一个地方要改很多处,维护成本高。
所以,枚举虽然简单,但设计好了能大大提升代码质量,设计不好会成为技术债务。值得花时间好好设计。
二、枚举的基础最佳实践
先从基础开始,说说枚举的最佳实践。
1. 用枚举代替常量
很多老代码喜欢用int常量或者String常量表示状态,比如:
public static final int STATUS_NEW = 0;
public static final int STATUS_PAID = 1;
public static final int STATUS_SHIPPED = 2;这种方式的问题是:类型不安全,可以传任意int值;没有描述,可读性差;没有方法,不能封装行为。
应该用枚举代替:
public enum OrderStatus {
NEW, PAID, SHIPPED, COMPLETED, CANCELLED
}枚举是类型安全的,只能传定义好的值,不能传任意值。而且枚举有name()、ordinal()、values()等方法,使用更方便。
2. 枚举值用大写,单词之间用下划线
枚举值的命名规范:全大写,单词之间用下划线。比如ORDERPAID、USERDISABLED。这是Java的标准规范,其他语言也类似。
不要用小写或者驼峰,虽然语法上可能允许,但不符合规范,可读性差。
3. 给枚举加描述字段
简单的枚举只有名字,但实际业务中,我们经常需要给用户展示中文描述。可以给枚举加一个description字段:
public enum OrderStatus {
NEW("待付款"),
PAID("已付款"),
SHIPPED("已发货"),
COMPLETED("已完成"),
CANCELLED("已取消");
private final String description;
OrderStatus(String description) {
this.description = description;
}
public String getDescription() {
return description;
}
}这样在前端展示或者打日志的时候,直接用getDescription()就行,不用到处写if-else转换。
4. 给枚举加code字段
如果枚举需要存数据库或者传输,通常会用一个code值(int或String),而不是枚举名。因为枚举名可能会改,而且枚举名是英文,不适合直接展示。
public enum OrderStatus {
NEW(0, "待付款"),
PAID(1, "已付款"),
SHIPPED(2, "已发货"),
COMPLETED(3, "已完成"),
CANCELLED(4, "已取消");
private final int code;
private final String description;
OrderStatus(int code, String description) {
this.code = code;
this.description = description;
}
public int getCode() {
return code;
}
public String getDescription() {
return description;
}
}code一旦定义就不要改,因为数据库里存的是code,改了会导致数据错乱。枚举名可以改,但code不能改。
5. 提供根据code获取枚举的方法
从数据库或者前端拿到code后,需要转换成枚举。提供一个静态方法:
public static OrderStatus fromCode(int code) {
for (OrderStatus status : values()) {
if (status.code == code) {
return status;
}
}
throw new IllegalArgumentException("Unknown order status code: " + code);
}这样调用方不用自己写循环,直接用OrderStatus.fromCode(code)就行。
注意:如果找不到对应的枚举,是返回null还是抛异常?建议抛异常,因为找不到说明数据有问题,应该尽早暴露。如果业务允许未知值,可以返回null,但要在方法注释里说明。
三、枚举的高级用法
基础用法之外,枚举还有很多高级用法,能让代码更优雅。
1. 枚举实现接口
枚举可以实现接口,这在需要多态的时候很有用。比如:
public interface Status {
int getCode();
String getDescription();
}
public enum OrderStatus implements Status {
// ...
}
public enum PaymentStatus implements Status {
// ...
}这样可以统一处理不同类型的状态,比如写一个通用的方法处理Status接口。
2. 枚举里定义抽象方法
枚举可以定义抽象方法,每个枚举值实现自己的逻辑。这比用switch-case分发更优雅,符合开闭原则。
比如订单状态的操作:
public enum OrderStatus {
NEW {
@Override
public boolean canPay() {
return true;
}
@Override
public boolean canCancel() {
return true;
}
},
PAID {
@Override
public boolean canPay() {
return false;
}
@Override
public boolean canCancel() {
return true;
}
},
// ... 其他状态
;
public abstract boolean canPay();
public abstract boolean canCancel();
}这样调用的时候直接orderStatus.canPay()就行,不用写switch。新增状态的时候,只要实现抽象方法就行,不会遗漏。
3. 用枚举实现单例
枚举是实现单例的最佳方式之一。因为枚举的单例是线程安全的,不会被反射破坏,也不会因为序列化而产生新实例。
public enum Singleton {
INSTANCE;
public void doSomething() {
// ...
}
}用的时候直接Singleton.INSTANCE.doSomething(),简单又安全。《Effective Java》里也推荐用枚举实现单例。
4. 枚举集合用EnumSet和EnumMap
如果需要用枚举做集合的key或者元素,用EnumSet和EnumMap,比普通的HashSet和HashMap性能好很多。因为EnumSet和EnumMap内部是用位运算或者数组实现的,专门针对枚举优化。
Set<OrderStatus> statuses = EnumSet.of(OrderStatus.NEW, OrderStatus.PAID);
Map<OrderStatus, String> map = new EnumMap<>(OrderStatus.class);在高并发场景下,如果需要频繁操作枚举集合,用EnumSet和EnumMap能提升性能。
5. 枚举的策略模式
可以用枚举实现策略模式,把不同的策略封装在枚举里。比如不同的支付方式有不同的支付逻辑:
public enum PaymentType {
ALIPAY {
@Override
public void pay(Order order) {
// 支付宝支付逻辑
}
},
WECHAT {
@Override
public void pay(Order order) {
// 微信支付逻辑
}
};
public abstract void pay(Order order);
}调用的时候paymentType.pay(order),不用写if-else判断支付方式。新增支付方式只要加一个枚举值,实现pay方法就行,符合开闭原则。
四、高并发场景下的枚举设计
在高可用高并发的系统中,枚举的设计有一些特别需要注意的地方。
1. 枚举是不可变的,线程安全
枚举的字段应该是不可变的(final),这样枚举本身就是线程安全的,不需要同步。不要在枚举里放可变的状态,比如计数器、缓存等,如果需要,要做好线程同步。
枚举的构造函数是线程安全的,类加载时初始化一次,之后不会再变。所以枚举里的静态资源初始化是安全的。
2. fromCode方法的性能优化
前面说的fromCode方法是遍历values(),在枚举值少的时候没问题。但如果枚举值很多(比如几十个),而且fromCode调用很频繁(高并发下每个请求都调用),遍历的性能可能不够好。
可以用一个静态Map缓存code到枚举的映射:
private static final Map<Integer, OrderStatus> CODE_MAP = new HashMap<>();
static {
for (OrderStatus status : values()) {
CODE_MAP.put(status.code, status);
}
}
public static OrderStatus fromCode(int code) {
OrderStatus status = CODE_MAP.get(code);
if (status == null) {
throw new IllegalArgumentException("Unknown order status code: " + code);
}
return status;
}这样fromCode的时间复杂度从O(n)变成O(1),高并发下性能更好。用EnumMap也行,但code是int的话用HashMap更方便。
3. 枚举序列化和反序列化
在分布式系统中,枚举需要在网络上传输,序列化和反序列化要注意。
如果用JSON序列化,默认会序列化成枚举名(name)。但枚举名可能会改,建议序列化成code。可以用Jackson的@JsonValue注解:
@JsonValue
public int getCode() {
return code;
}
@JsonCreator
public static OrderStatus fromCode(int code) {
// ...
}这样序列化的时候输出code,反序列化的时候根据code解析,更稳定。
如果用Java原生序列化,枚举的序列化是特殊的,只会写枚举名,不会写字段。反序列化的时候根据name找枚举。所以不要依赖枚举的序列化来传递字段值。
4. 枚举和数据库的映射
用ORM框架(比如MyBatis、JPA)的时候,枚举和数据库的映射要注意。
建议数据库存code(int),不要存枚举名。因为枚举名可能会重构改名,code是稳定的。
MyBatis可以用TypeHandler处理枚举和code的转换。JPA可以用@Enumerated(EnumType.ORDINAL)存序号,但序号不稳定(中间加一个枚举值,后面的序号都变了),建议用@Convert用code转换。
5. 枚举的扩展问题
枚举的一个缺点是不能继承,不能动态扩展。如果业务需要动态增加枚举值,枚举就不适合了。比如商品分类,可能需要后台动态添加,这时候用数据库表而不是枚举。
判断什么时候用枚举:值是固定的、有限的、不经常变的,用枚举。值是动态的、用户可以配置的,用数据库表。
在高并发系统中,如果枚举值需要变更,要注意兼容性。新增枚举值一般是兼容的,但删除或者修改枚举值可能会导致老数据解析失败。所以code一旦定义就不要改,枚举值可以废弃但不要删除。
五、常见的坑
最后说说枚举使用中常见的坑,很多人都踩过。
坑一:用==还是equals比较枚举?
枚举可以用==比较,也可以用equals,效果一样。因为枚举是单例的,每个枚举值只有一个实例。建议用==,更简洁,也不会空指针(如果变量是null,==返回false,equals会抛NPE)。
但注意,如果是从反序列化或者其他地方来的枚举,一定是单例的,所以==没问题。
坑二:枚举的ordinal()不要乱用
ordinal()返回枚举值的序号(从0开始)。不要把ordinal()存数据库或者传输,因为如果在中间加一个枚举值,后面的序号都会变,导致数据错乱。要用自己定义的code字段。
ordinal()只适合用在EnumSet、EnumMap这种内部使用的场景,不要持久化。
坑三:switch枚举遗漏分支
用switch处理枚举的时候,如果新增了枚举值,但switch没加对应的分支,可能会出问题。建议:
- 用枚举的抽象方法代替switch,这样新增枚举值必须实现方法,不会遗漏
- 如果一定要用switch,在default分支抛异常,这样遗漏了会尽早发现
- 用IDE的检查,很多IDE会提示switch没有覆盖所有枚举值
坑四:枚举里放太多逻辑
枚举虽然可以定义方法,但不要把太多业务逻辑放在枚举里。枚举应该是轻量级的,主要表示状态和类型。复杂的业务逻辑应该放在Service或者专门的类里。
如果枚举里的方法越来越复杂,说明该考虑重构了,把逻辑移到专门的类里。
坑五:枚举名和code不一致
有时候枚举名改了,但code没改,或者code改了但数据库里还是旧的,导致不一致。建议:
- code一旦定义就不要改
- 枚举名可以改,但要注意序列化和日志里的引用
- 废弃的枚举值保留,加@Deprecated注解,不要直接删除
六、架构设计的建议
最后总结一些枚举架构设计的建议。
第一,统一管理枚举。 项目中的枚举不要散落在各处,应该放在统一的包下,比如com.xxx.enums。按业务领域分类,比如order包下放订单相关的枚举,payment包下放支付相关的。
第二,枚举设计要考虑扩展。 设计枚举的时候要想到以后可能新增枚举值,用抽象方法或者策略模式,让新增枚举值不需要改很多地方。符合开闭原则。
第三,枚举和常量的选择。 不是所有常量都适合用枚举。如果是一组相关的、有限的值,用枚举。如果是单个的常量(比如超时时间、默认值),用普通常量就行。
第四,文档和注释。 枚举要加注释,说明每个枚举值的含义、什么时候使用、注意事项。code字段的含义也要说明。这样别人用的时候不用猜。
第五,高并发下注意性能。 fromCode方法用Map缓存,集合用EnumSet和EnumMap,序列化用code,这些小优化在高并发下能带来明显的性能提升。
七、写在最后
枚举虽然是一个简单的语言特性,但用好它需要思考。一个好的枚举设计,能让代码更清晰、更易维护、更有扩展性;一个差的枚举设计,会成为技术债务,越积越多。
在高可用高并发的系统中,每一个细节都可能影响系统的稳定性和性能。枚举虽然小,但也值得好好设计。
2021年了,越来越多的语言支持了枚举(PHP 8.1也加入了枚举),枚举会用得越来越多。掌握枚举的最佳实践,写出高质量的枚举代码,是每个开发者的基本功。
希望这篇文章能帮你更好地设计枚举。如果你有其他的经验或者问题,欢迎在评论区交流。
祝大家都能写出优雅、高效、易维护的代码。
评论(0)
暂无评论,快来抢沙发~
评论功能仅对会员开放,请先登录
登录