Rails 关于在 Rails Model 中使用 Enum (枚举) 的若干总结

IChou · 发布于 2016年01月08日 · 最后由 IChou 回复于 2017年07月27日 · 4161 次阅读
3035
本帖已被设为精华帖!

在 Rails 的 ActiveRecord 中,有一个 ActiveRecord::Enum 的 Module,即枚举对象。

其官方说明:

Declare an enum attribute where the values map to integers in the database, but can be queried by name.

给数据库中的整型字段声明一个一一对应的枚举属性值,这个值可以以字面量用于查询。

拿到具体的运用场景中去考虑,Enum 的主要用于数据库中类似于 状态(status) 的字段,这类字段用不同的 整数(Integer) 来表示不用的状态。如果不使用 Enum,那就意味着我们代码中会出现很多表示状态的数字,他们可能会出现在查询条件里,也可能会出现在判断条件里,除非你记得或者拿着数据字典去看,否则你很难理解这段代码的意义。

代码中,以数字方式去表述数据状态,导致代码可读性被破坏,这样的数字称为 ‘魔鬼数字’。

Enum 就是 rails 用来消灭魔鬼数字的工具。


ActiveRecord::Enum 与 Mysql 的 Enum 有何不同

枚举的功能,是为了解决数据库相关的问题,那么当然的,数据库本身大多都含有枚举的功能。

以 Mysql 为例,mysql 的字段类型中有一个 ENUM 的类型:

CREATE TABLE person(
    name VARCHAR(255),
    gender ENUM('Male', 'Female')
);

这样就设置了一个叫 gender 的 ENUM 字段,其值为: {NULL: NULL, 1: 'Male', 2: 'Female'}, 在使用 SQL 的时候,数字键值(index of the value)和定义的字面量(actual constant literal)是通用的。

既然数据库的 ENUM 已是如此的方便,为什么很多时候我们还是不愿意使用它呢?最大的问题在于 ENUM 的属性值在建表的时候就已经固定了下来,一旦到了后期需要加一个状态,那么就意味着需要改字段。而且目前各种数据库对于 ENUM 的处理方式也并非是完全一致的,给 ORM 也带来的不小的问题。

ActiveRecord::Enum 在实现上,和对外键的处理方式是一样的,并不直接使用数据库自身的 Enum 和 外键,而是通过在 Model 中来维护这些关系。实际在数据库中存的只是单纯的 Integer,这样就避免了 Enum 属性变动需要修改数据库结构的问题。


ActiveRecord::Enum 的使用

具体使用请参见官方文档

class Conversation < ActiveRecord::Base
  enum status: { active: 0, waiting: 1 ,archived: 2 }
  # or
  enum status: [ :active, :waiting, :archived ]
end

声明之后,会多出以下一些方法(methods):

conversation.active! # 改写状态为 active
conversation.active? # 检查状态是否为 active

conversation.status     # => "active" 输出为字面量
conversation[:status]   # => 0 输出仍为数据库真实值

conversation.status = 2            # => "archived"
conversation.status = 'archived'   # => "archived"
conversation.status = :archived    # => "archived" 赋值时,三者等价

# 自动添加 Scope
Conversation.active    # 等价于 Conversation.where status: 0

# 获得一个名为 statuses 的 Hash
Conversation.statuses # => { "active" => 0, "waiting" => 1, "archived" => 2 }

Rails 中使用 enum 的一些总结

  1. 不要使用数据库的 enum,理由如上

  2. enum 会自动添加 scope

  3. 作为条件查询时,不能使用字面量,需要转义成对应的 Integer(目前的 rails 版本中是这样的,这也导致 enum 有点鸡肋)

  4. 取用时,method 和 [] 取得的值不同,一个是字面量(String),一个是整数型 Integer

  5. 对 enum 字段赋值时,若非整数型,便必须为 enum attribute 中的一个,否则会抛出 ArgumentError 错误

  6. 赋值时,字面量 和 整型 是等效的

  7. 结合 local(本地化) 翻译一起使用,效果更好

  8. 在 Rails 的 edge 版本中,字面量可以直接用于做查询条件 Rails 5 中已经支持字面量做查询条件了 参考:http://edgeapi.rubyonrails.org/classes/ActiveRecord/Enum.html

  9. 状态只有开和关的(1和0),或数字本身就已经能很好的表达语意,可以选择不用 enum

  10. 定义时,推荐使用 hash,使用数组可能会给以后的变更带来麻烦 见#6

  11. enum 的字面量最好避开 model 已有的 method name,一个 Model 中有多个 enum 时,字面量不要重复 见#6


参考文章:When you should and should NOT use ENUM data type

共收到 21 条回复
3873

翻译成“枚举”恰当吗?

我觉得enum的主要适用场景是某个model 的attribute是分类的,比如一条日志的可见权限:所有人可见,好用可见,仅自己可见

我觉得要理解成“可数”的,枚举容易想到遍历

23698

@xworm感觉总结的棒棒的。 发现了一个小问题,在第三块代码的第五行。

conversation.status = 1 # => "archived"

整数型1对应的是waiting而不是archived

3790

:plus1:

3035

#1楼 @cqcn1991 这个翻译其实我也是不赞同的,但是身边的同事和朋友几乎都是这么称呼的,于是也就没那么在意了 现在我一般是 在作动词的语境中,把 枚举 理解为遍历,在作名词的语境中,理解为 枚举类型(Enum)

“可数” 虽然表达了它的意思,但是有点太接近日常用语,可能会引出歧义。不造还有没有更好的翻译

3035

#2楼 @perky123 感谢指正

2650

补充几个踩过的坑

  • 定义enum时自动增加的各个值对应的?!方法是model范围内有效的。如果不同的字段有同名的值的时候一定要注意
# user.rb
  enum status: [:temporary, :active, :deleted]
  enum admin_status: [:active, :super_admin]

# rails console
irb(main):001:0> u = User.neww
ArgumentError: You tried to define an enum named "admin_status" on the model "User", but this will generate a instance method "active?", which is already defined by another enum.
...
  • 修改以前定义好的enum时需要注意历史数据
# 原来是这样的定义
enum status: [:temporary, :active, :deleted]
# 修改后,
enum status: [:temporary, :active, :waiting, :deleted]
# 如果这样修改的话,以前的`deleted`的数据修改后就变成`:waiting`了!!! :sleepy: 

所以,不推荐使用[:temporary, :active, :deleted]的方式,尽量使用{ temporary: 1, active: 2, deleted: 3]这样的明确定义数值的方式

  • 自动增加的status=方法,参数是不允许的值的时候会抛出ArgumentError。在直接从网页上接收status的值的场景下一定要注意。 这种情况只有在运行的时候才会报错所以异常危险!!!
irb(main):004:0> u1.status = 'aaa'
ArgumentError: 'aaa' is not a valid status
    from /Users/blueplanet/sandbox/enum_test/vendor/bundle/ruby/2.3.0/gems/activerecord-4.2.5/lib/active_record/enum.rb:104:in `block (3 levels) in enum'
    from (irb):4
  • 由于会自动增加scope,所以要注意不要覆盖掉Rails默认的scope。比如enum status: { none: 0, active: 1, deleted: 2}就会覆盖掉Rails4增加的nonescope了。
3035

#6楼 @blueplanet 已经加到总结里面 哇咔咔

A908ae

这个我也用过,尽量不要用数字,直接用Hash来访问。

class Conversation < ActiveRecord::Base
  enum status: [:active, :waiting, :archived]

  scope :select_active, -> { where(:status => Conversation.statuses[:active]) }
  scope :select_deleted, -> { where(:status => Conversation.statuses[:waiting]) }
end
3790

#8楼 @adamshen 默认有 scope,你自己再弄一个,基于啥考虑?

A908ae

#10楼 @qinfanpeng 这个例子确实举的不好,定义有点多余。我的原意是在Model里这种方式提取属性值,不用数字,类似这样。

class Conversation < ActiveRecord::Base
  enum status: [:active, :waiting, :archived]

  scope :not_active, -> { where("status <> ?", Conversation.statuses[:active]) }
end
``
3035

#11楼 @adamshen 定义了 enum 就不要再用数字了,否则使用 enum 的意义何在 当然, scope 和 enum 通常是写在同一个地方,所以在 scope 中使用数字其实并不会太影响代码阅读,我觉得还可以接受

查询不能使用字面量的问题会在以后的版本中解决,我以前一直以为是 rails5 中会加入这个特性,现在 beta 版已经出来了,不知道是否已经加入,我还没来得及去看

至于如何优雅的通过字面量取到对应的数字,稍微包装一下也可以有很好用的方法,我待会儿 po 一个我的解决方案,大家也可以分享一下自己的见解

3035

在 model 中,或者 concerns 中加入这个方法

def self.es(*keys)
  @es ||= defined_enums.inject({}) { |es, (_, h)| es.merge! h }
  keys.size > 1 ? keys.map { |k| @es[k.to_s] } : @es[keys.pop.to_s]
end

如果有这样一个 model

class Conversation < ActiveRecord::Base
  enum status: { active: 0, waiting: 1, archived: 2 }
  enum roles: { admin: 0, editor: 1, guest: 2 }
  # ...
end

# 可以这样使用
Conversation.es( :active, :waiting )  # => [ 0, 1 ]
Conversation.es( :admin )      # => 0
Conversation.where status: Conversation.es( :active, :waiting ), roles: Conversation.es( :admin )

我这样做的原因

  1. 可以传入多个量返回一个数组,以便于构建 Hash 的查询条件,我不太喜欢 <> 这样写查询
  2. 我需要的只是 字面量 -> 整型 的翻译,并不关心它具体属于哪一个 enum
  3. enum 的字面量不能重复,所以不会有互相污染的问题
A908ae

#13楼 @xworm 哈哈。高明。赞。

15420

conversation.status # => "active" 输出为字面量 这儿取字面量得到的是英文,如果我想得到中文,比如active 表示 启用,只能通过国际化的配置翻译吗?

3035

#15楼 @pathbox 是的 或者枚举字面量直接就设置成中文 哈哈哈~~😄

De6df3 huacnlee 将本帖设为了精华贴 07月08日 11:16
8345

#16楼 @IChou 比起直接用Enum还快些 👍

29283

学习了👍

20楼 已删除
19780

#13楼 @IChou wow! awsome!

553

conversation[:status] # => 0 输出仍为数据库真实值

真的试过吗? 这个输出的还是string

3035
553brook 回复

谢谢提醒 这是 Rails 5 更新的 feature,有空我更新一下文章

需要 登录 后方可回复, 如果你还没有账号请点击这里 注册