作者简介:大家好,我是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。
如果希望对入参进行限制,可以对POJO的set方法进行约束:
然而抛异常并不是最优解,虽然确实最终阻止了错误发生,但是太迟了!调用者在编写代码时仍然可能在毫不知情的情况下写出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 |
最重要的不是范围太大,而是基本类型的范围不能按我们的需要改变。即,可选范围不能根据业务定制。
那我们只剩一条路:根据业务自定义类型。
基本类型无法自定义,所以我们只能新建引用类型。再具体点,就是新建一个类:
怎样才能限制Xxx类只有7个元素呢?
不要走回头路:
Xxx.MONDAY和WeekDay.MONDAY本质上没啥区别,就是换了个类名而已。
但是IDEA的错误提示却给了我们灵感:
也就是说,此时restDay需要的是Xxx类型的变量,而不是Xxx.MONDAY。解决问题的一个思路是想办法把Xxx.MONDAY变成Xxx类型。
这听起来很诡异,Xxx.MONDAY竟然是Xxx类型?!
先别想这么多,按这个思路写一下。是不是这样:
class Xxx {
public Xxx MONDAY;
}
也即是说,字段类型是Xxx。
所以原先的代码可以改成这样:
OK,employee.setRestDay(Xxx.MONDAY)总算通过了。
我们再把类名改一下,换个有意义的名字:
初步完成,但别急,停下来仔细看看图中的代码,尝试理解。
理解了吧?
现在我告诉你,上面的代码还是有问题。
类型确实限制为WeekDay,但并没有限制范围。我们完全可以不从WeekDay拿,自己在外面new一个即可:
如何限制外部随意创建某个类的呢?对,单例模式:
外界只能从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修饰的属性:
为什么提示我属性可能没有初始化呢?这就要看大家final掌握得如何了。final关键字的赋值有以下几种方式:
- 显式赋值:private final Integer code = 1
- 静态代码块/代码块赋值
- 构造器赋值
因为final变量只能赋值一次(不可变),如果不赋值就是默认值,是没有意义的。就好比你想要一个水桶,希望可以存水,但是它的默认值是水泥,而且出厂以后就不能改了...结果你拿到一个装满水泥的水桶,毫无意义。
所以JDK会强制你给final字段赋值,以保证final字段存的是你期望的值。而创建对象时一定会经历显式赋值、代码块赋值、构造器赋值三个时期,只要在任意一个时期为final字段赋值即可保证对象创建后必然有初始值。
然而构造器有点特殊,因为一个对象可以同时拥有多个构造器。即使准备了Constructor A为final字段初始化,调用者仍可以使用无参构造或者Constructor B创建(假设B不给final字段赋值),如此一来final还是没有被赋值。
所以,当前案例使用final字段时必须禁用无参构造,强制走有参构造,确保final字段初始化。
代码修改如下:
- 去除空参构造 、设置唯一的有参构造为private,禁止外界new对象并强制为final字段赋值
- 提供getter方法(不需要setter,因为反正字段是final,无法改变)
枚举与数据库
一部分人可能从来没试过用MyBatis向数据库插入带有复杂类型的POJO:
你们可能认为,最坏的结果是序列化存入JSON:
但实际上即使我把数据库Column设置为JSON类型也无法插入restDay:
因为如果不作任何配置,MyBatis默认只能处理简单类型和常见的引用类型,比如String、Integer等,对于复杂类型(自定义类、枚举)会自动忽略:
那么,如何处理POJO中复杂类型的字段呢?
通常来说我们会写一个转换器,不论存入还是取出,都要经过转换器:
- 存入:从restDay中取出code存入数据库
- 取出:根据code找到对应的WeekDay赋值给restDay
数据库实际存储的一般不会是整个WeekDay对象,而是WeekDay.code或者WeekDay.desc。
具体如何转换复杂对象,我们会在后续章节介绍。
但我个人有时懒得写转换器,都是直接用简单类型:
这个时候也就不存在什么转换了,你可以理解为就是以前的方式,就是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数组收集所有枚举单例对象。
这样我们就控制住了数值范围。当然,这个并不是编译期错误,本质上还是和一开始的处理方式一样:
讲完数据存入,接下来聊聊取出数据后怎么处理。
枚举与前端
比如我们从数据库查出一个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的字段上直接使用枚举类型,但是要编写相对应的转换器:
关于MyBatis如何转换枚举,请参考后面的章节。
在本文中,我们退而求其次,演示了把restDay字段设置为Integer,然后人工转换的办法:
《阿里巴巴开发手册》中关于枚举有以下描述:
总之,不推荐返回值对象中直接使用枚举。
但大家肯定见过Result类中的这种写法:
但它并没有把枚举对象返回,ExceptionCodeEnum作为入参传入后,其实就被分解为code和desc了,是很普通的Integer和String类型。
以上是对山寨版"枚举"的讨论,下篇我们将讲解正版枚举的用法,但篇幅会短很多,因为和山寨"枚举"太像了,只要再介绍一下正版枚举的其他特性即可。
作者简介:大家好,我是smart哥,前中兴通讯、美团架构师,现某互联网公司CTO
进群,大家一起学习,一起进步,一起对抗互联网寒冬