枚举类型是编程中常用的数据类型,几乎每个项目都会用到。但很多人只是简单用一下,用常量或者简单的枚举表示状态,没有深入思考枚举的架构设计。

实际上,在高可用高并发的系统中,枚举的设计会影响代码的可维护性、扩展性和性能。一个好的枚举设计,能让代码更清晰、更易扩展、更不容易出错;一个差的枚举设计,会导致代码混乱、扩展困难、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也加入了枚举),枚举会用得越来越多。掌握枚举的最佳实践,写出高质量的枚举代码,是每个开发者的基本功。

希望这篇文章能帮你更好地设计枚举。如果你有其他的经验或者问题,欢迎在评论区交流。

祝大家都能写出优雅、高效、易维护的代码。