设计山寨枚举

作者简介:大家好,我是smart哥,前中兴通讯、美团架构师,现某互联网公司CTO

联系qq:184480602,加我进群,大家一起学习,一起进步,一起对抗互联网寒冬

一个需求

在Employee类中,定义一个字段,用来表示在哪一天休息(星期几)。

最简单的设计是这样的:

@Data
public class Employee {
    /**
     * 指定员工在哪一天休息
     */
    private Integer restDay;
}

使用时,只要传入1~7即可为employee对象指定具体的休息日:

public class EmployeeDemo {
    public static void main(String[] args) {
        Employee employee = new Employee();
        employee.setRestDay(1);
    }
}

@Data
class Employee {
    /**
     * 指定员工在哪一天休息
     */
    private Integer restDay;
}

用常量类改进

但是上面的代码有两个问题:

  • 业务含义不明确,在一部分人的认知里,1代表周日,而不是周一,可能会传错
  • 代码没有做任何限制,调用者可以传入任意数字,甚至是负数,这是不合逻辑的

有人可能想到了用接口常量或类常量来解决以上问题,比如:

public class EmployeeDemo {
    public static void main(String[] args) {
        Employee employee = new Employee();
        employee.setRestDay(WeekDay.MONDAY);
    }
}

@Data
class Employee {
    /**
     * 指定员工在哪一天休息
     */
    private Integer restDay;
}

/**
 * 常量类
 */
class WeekDay {
    public static final Integer MONDAY = 1;
    public static final Integer TUESDAY = 2;
    public static final Integer WEDNESDAY = 3;
    public static final Integer THURSDAY = 4;
    public static final Integer FRIDAY = 5;
    public static final Integer SATURDAY = 6;
    public static final Integer SUNDAY = 7;
}

自定义枚举改进

但实际上,常量类仅仅是解决了第一个问题:业务含义明确,代码可读性提高。但调用者仍然可以随意传参,比如仍然允许传入-1。

设计山寨枚举_第1张图片

如果希望对入参进行限制,可以对POJO的set方法进行约束:

设计山寨枚举_第2张图片

然而抛异常并不是最优解,虽然确实最终阻止了错误发生,但是太迟了!调用者在编写代码时仍然可能在毫不知情的情况下写出setRestDay(-1)这样的语句(IDEA只会提示传入Integer类型,却不会提示范围是1~7)。

《Effective Java》的作者说过:编译期错误优于运行期错误,如果一段代码注定会出错,应该尽早暴露以便在编译期就解决问题。但是Java编译器只会做语法检查,不会做逻辑运算。

怎么办?

要对方法的形参进行限制,无非从两个方面考虑:

  • 变量类型(已约束)
  • 变量范围(未约束)

变量类型已经被定为Integer,很大程度上阻止了String、Double等其他类型的参数传入,但变量的范围还没有得到约束。但是你想过为什么用户能传入-1吗?因为Integer本身的范围就是-2147483648 至 2147483647,包含了-1。

如果存在一种Xxx类型,它只有7个元素,分别代表周一到周日,那么我们把它作为setRestDay(Xxx xxx)的类型,不仅约束了变量类型(只能是Xxx类型),还约束了变量范围(只有7个)!

很明显,Java的8大基本类型都不符合。

基本类型

字节数

位数

最大值

最小值

byte

1byte

8bit

2^7 - 1

-2^7

short

2byte

16bit

2^15 - 1

-2^15

int

4byte

32bit

2^31 - 1

-2^31

long

8byte

64bit

2^63 - 1

-2^63

float

4byte

32bit

3.4028235E38

1.4E - 45

double

8byte

64bit

1.7976931348623157E308

4.9E - 324

char

2byte

16bit

2^16 - 1

0

最重要的不是范围太大,而是基本类型的范围不能按我们的需要改变。即,可选范围不能根据业务定制。

那我们只剩一条路:根据业务自定义类型。

基本类型无法自定义,所以我们只能新建引用类型。再具体点,就是新建一个类

设计山寨枚举_第3张图片

怎样才能限制Xxx类只有7个元素呢?

不要走回头路:

设计山寨枚举_第4张图片

Xxx.MONDAY和WeekDay.MONDAY本质上没啥区别,就是换了个类名而已。

但是IDEA的错误提示却给了我们灵感:

设计山寨枚举_第5张图片

也就是说,此时restDay需要的是Xxx类型的变量,而不是Xxx.MONDAY。解决问题的一个思路是想办法把Xxx.MONDAY变成Xxx类型。

这听起来很诡异,Xxx.MONDAY竟然是Xxx类型?!

先别想这么多,按这个思路写一下。是不是这样:

class Xxx {
    public Xxx MONDAY;
}

也即是说,字段类型是Xxx。

所以原先的代码可以改成这样:

设计山寨枚举_第6张图片

OK,employee.setRestDay(Xxx.MONDAY)总算通过了。

我们再把类名改一下,换个有意义的名字:

设计山寨枚举_第7张图片

初步完成,但别急,停下来仔细看看图中的代码,尝试理解。

理解了吧?

现在我告诉你,上面的代码还是有问题。

类型确实限制为WeekDay,但并没有限制范围。我们完全可以不从WeekDay拿,自己在外面new一个即可:

设计山寨枚举_第8张图片

如何限制外部随意创建某个类的呢?对,单例模式:

设计山寨枚举_第9张图片

外界只能从WeekDay取出设定好的7个对象,这下restDay字段的类型和范围都限制住了。

为枚举添加字段,让含义更明确

通过单例模式,我们新建了WeekDay,既解决了业务含义不明确的问题(MONDAY见名知意),又对入参做了限制(只能从WeekDay获取设定的7个元素)。但我总觉得原先的MONDAY=1更顺眼,MONDAY=new WeekDay()看起来怪怪的。

是的,直面你内心的疑惑:restDay字段的值是WeekDay对象,存入数据库后会变成什么?

我们原本打算用1~7代表一周七天,只不过为了可读性限定范围,才搞了单例模式,但心里还是希望数据库存的是1~7。

所以我们必须让MONDAY、TUESDAY这些对象具备特征,最终和1~7形成对应关系。

由于在我们的项目中,1就代表周一,不希望被更改,所以我们可以给WeekDay加上final修饰的属性:

设计山寨枚举_第10张图片

为什么提示我属性可能没有初始化呢?这就要看大家final掌握得如何了。final关键字的赋值有以下几种方式:

  • 显式赋值:private final Integer code = 1
  • 静态代码块/代码块赋值
  • 构造器赋值

因为final变量只能赋值一次(不可变),如果不赋值就是默认值,是没有意义的。就好比你想要一个水桶,希望可以存水,但是它的默认值是水泥,而且出厂以后就不能改了...结果你拿到一个装满水泥的水桶,毫无意义。

所以JDK会强制你给final字段赋值,以保证final字段存的是你期望的值。而创建对象时一定会经历显式赋值、代码块赋值、构造器赋值三个时期,只要在任意一个时期为final字段赋值即可保证对象创建后必然有初始值。

然而构造器有点特殊,因为一个对象可以同时拥有多个构造器。即使准备了Constructor A为final字段初始化,调用者仍可以使用无参构造或者Constructor B创建(假设B不给final字段赋值),如此一来final还是没有被赋值。

所以,当前案例使用final字段时必须禁用无参构造,强制走有参构造,确保final字段初始化。

代码修改如下:

设计山寨枚举_第11张图片

  • 去除空参构造 、设置唯一的有参构造为private,禁止外界new对象并强制为final字段赋值
  • 提供getter方法(不需要setter,因为反正字段是final,无法改变)

枚举与数据库

一部分人可能从来没试过用MyBatis向数据库插入带有复杂类型的POJO:

设计山寨枚举_第12张图片

你们可能认为,最坏的结果是序列化存入JSON:

设计山寨枚举_第13张图片

但实际上即使我把数据库Column设置为JSON类型也无法插入restDay:

设计山寨枚举_第14张图片

因为如果不作任何配置,MyBatis默认只能处理简单类型和常见的引用类型,比如String、Integer等,对于复杂类型(自定义类、枚举)会自动忽略:

设计山寨枚举_第15张图片

那么,如何处理POJO中复杂类型的字段呢?

通常来说我们会写一个转换器,不论存入还是取出,都要经过转换器:

  • 存入:从restDay中取出code存入数据库
  • 取出:根据code找到对应的WeekDay赋值给restDay

设计山寨枚举_第16张图片

数据库实际存储的一般不会是整个WeekDay对象,而是WeekDay.code或者WeekDay.desc。

具体如何转换复杂对象,我们会在后续章节介绍。

但我个人有时懒得写转换器,都是直接用简单类型:

设计山寨枚举_第17张图片

这个时候也就不存在什么转换了,你可以理解为就是以前的方式,就是Integer restDay。此时类型已经限制成Integer,但范围需要我们自己控制。可以在WeekDay中新增一个of()方法,用来校验前端传来的code是否合法:

@Getter
class WeekDay {
    public static final WeekDay MONDAY;
    public static final WeekDay TUESDAY;
    public static final WeekDay WEDNESDAY;
    public static final WeekDay THURSDAY;
    public static final WeekDay FRIDAY;
    public static final WeekDay SATURDAY;
    public static final WeekDay SUNDAY;

    private static final WeekDay[] VALUES;

    static {
        // 之前说过,final字段赋值有三种形式,现在我们换成静态代码块赋值
        MONDAY = new WeekDay(1, "星期一");
        TUESDAY = new WeekDay(2, "星期二");
        WEDNESDAY = new WeekDay(3, "星期三");
        THURSDAY = new WeekDay(4, "星期四");
        FRIDAY = new WeekDay(5, "星期五");
        SATURDAY = new WeekDay(6, "星期六");
        SUNDAY = new WeekDay(7, "星期日");
        // 在加载类时就收集所有的WeekDay对象
        VALUES = new WeekDay[]{
                MONDAY, TUESDAY, WEDNESDAY, THURSDAY, FRIDAY, SATURDAY, SUNDAY
        };
    }

    /**
     * 校验前端传入的code是否合法
     *
     * @param code
     * @return
     */
    public static WeekDay of(Integer code) {
        for (WeekDay weekDay : VALUES) {
            if (weekDay.code.equals(code)) {
                return weekDay;
            }
        }
        // 如果根据code找不到对应的WeekDay,说明code范围不对,是非法的
        throw new IllegalArgumentException("Invalid Enum code:" + code);
    }

    private WeekDay(Integer code, String desc) {
        this.code = code;
        this.desc = desc;
    }

    private final Integer code;
    private final String desc;
}
// 伪代码
public void saveUser(User user){
    // 校验一下
    WeekDay.of(user.getRestDay);
    // 插入
    userMapper.insertSelective(user);
}

请大家特别注意上面的新写法:用static静态代码块初始化final字段 + 用VALUE数组收集所有枚举单例对象。

这样我们就控制住了数值范围。当然,这个并不是编译期错误,本质上还是和一开始的处理方式一样:

设计山寨枚举_第18张图片

讲完数据存入,接下来聊聊取出数据后怎么处理。

枚举与前端

比如我们从数据库查出一个Employee对象:

{
	"name": "bravo",
	"department": "技术部",
	"restDay": 1
}

难道前端这样写?

if (employee.restDay == 1) {
    $("#restDay").val("星期一");
} else if (employee.restDay == 2) {
    $("#restDay").val("星期二");
} else if (employee.restDay == 3) {
    $("#restDay").val("星期三");
} else if (employee.restDay == 4) {
    $("#restDay").val("星期四");
} else if (employee.restDay == 5) {
    $("#restDay").val("星期五");
} else if (employee.restDay == 6) {
    $("#restDay").val("星期六");
} else if (employee.restDay == 7) {
    $("#restDay").val("星期日");
}

这种转换工作最好在后端完成,理由是:后端更清楚各个状态的对应关系。所以我们应该在接口返回结果之前,就把转换工作完成,最终传递"星期一"而不是1。为此我们需要做两步:

  • Employee新增private String restDayDesc字段
  • 新增 getDescByCode()方法

最终代码:

@Getter
class WeekDay {
    public static final WeekDay MONDAY;
    public static final WeekDay TUESDAY;
    public static final WeekDay WEDNESDAY;
    public static final WeekDay THURSDAY;
    public static final WeekDay FRIDAY;
    public static final WeekDay SATURDAY;
    public static final WeekDay SUNDAY;

    private WeekDay(Integer code, String desc) {
        this.code = code;
        this.desc = desc;
    }
    
    private static final WeekDay[] VALUES;
    
    static {
        MONDAY = new WeekDay(1, "星期一");
        TUESDAY = new WeekDay(2, "星期二");
        WEDNESDAY = new WeekDay(3, "星期三");
        THURSDAY = new WeekDay(4, "星期四");
        FRIDAY = new WeekDay(5, "星期五");
        SATURDAY = new WeekDay(6, "星期六");
        SUNDAY = new WeekDay(7, "星期日");
        VALUES = new WeekDay[]{
                MONDAY, TUESDAY, WEDNESDAY, THURSDAY, FRIDAY, SATURDAY, SUNDAY
        };
    }

    private final Integer code;
    private final String desc;

    // 返回所有的对象
    public static WeekDay[] values() {
        return VALUES;
    }

    // 遍历对象,根据code返回code对应的desc
    public static String getDescByCode(Integer code) {
        WeekDay[] weekDays = WeekDay.values();
        for (WeekDay weekDay : weekDays) {
            if (weekDay.getCode().equals(code)) {
                return weekDay.getDesc();
            }
        }
        throw new IllegalArgumentException("Invalid Enum code:" + code);
    }
}
public User getUser(){
    User user = userMapper.selectByPrimaryKey(1L);
    // 为user设置restDayDesc,方便前端展示
    user.setRestDayDesc(WeekDay.getDescByCode(user.getCode()));
    return user;
}

打印结果

Employee{restDay=1, restDayDesc='星期一'}

后话

正如上面介绍的,你可以在DO的字段上直接使用枚举类型,但是要编写相对应的转换器:

设计山寨枚举_第19张图片

关于MyBatis如何转换枚举,请参考后面的章节。

在本文中,我们退而求其次,演示了把restDay字段设置为Integer,然后人工转换的办法:

设计山寨枚举_第20张图片

《阿里巴巴开发手册》中关于枚举有以下描述:

设计山寨枚举_第21张图片

总之,不推荐返回值对象中直接使用枚举。

但大家肯定见过Result类中的这种写法:

设计山寨枚举_第22张图片

但它并没有把枚举对象返回,ExceptionCodeEnum作为入参传入后,其实就被分解为code和desc了,是很普通的Integer和String类型。

设计山寨枚举_第23张图片

以上是对山寨版"枚举"的讨论,下篇我们将讲解正版枚举的用法,但篇幅会短很多,因为和山寨"枚举"太像了,只要再介绍一下正版枚举的其他特性即可。

作者简介:大家好,我是smart哥,前中兴通讯、美团架构师,现某互联网公司CTO

设计山寨枚举_第24张图片进群,大家一起学习,一起进步,一起对抗互联网寒冬

你可能感兴趣的:(java基础进阶,java,java基础)