12/08/2018, 14:30

Enum trong Rails - con dao hai lưỡi

Với những người đã và đang làm việc với Ruby on Rails, hẳn khái niệm Enum không còn gì xa lạ với các bạn. Enum được đưa vào Rails nhằm mục đích khiến code đọc hiểu dễ hơn, đẩy lùi những con số vô hồn và thay bằng các ngôn từ dễ hiểu. Tuy nhiên Enum cũng có những vấn đề, mà nếu không biết và sử ...

Với những người đã và đang làm việc với Ruby on Rails, hẳn khái niệm Enum không còn gì xa lạ với các bạn. Enum được đưa vào Rails nhằm mục đích khiến code đọc hiểu dễ hơn, đẩy lùi những con số vô hồn và thay bằng các ngôn từ dễ hiểu.

Tuy nhiên Enum cũng có những vấn đề, mà nếu không biết và sử dụng không cẩn thận, chúng ta có thể đối mặt với những hiểm họa khôn lường.

Trong khuôn khổ bài viết này, mình sẽ trình bày về bản chất của Enum cũng như những lưu ý được đúc kết từ vài (chục) lần hiển thị lỗi 500 trên trang web.

Theo định nghĩa từ API Document của Rails thì :

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

Điều này có nghĩa là trường được định nghĩa là enum thông qua Rails sẽ có giá trị khi lưu trong DB là số, còn chúng ta có thể thông qua những dòng text đầy ý nghĩa để truy xuất chúng.

Để hiểu hơn chúng ta hãy cùng xem qua một vài ví dụ.

Ta sẽ định nghĩa một Model có tên là Human trong Rails như sau :

class Human < ActiveRecord::Base
  enum status: [ :dead, :alive ]
end

Như các bạn thấy, class Human sẽ có một thuộc tính có tên là status, tương ứng với cột status trong database, trường này sẽ nhận 2 giá trị trạng thái tương ứng với 2 symbol đã định nghĩa là :dead và :alive.

Với thiết lập như trên, Rails sẽ tự động gán giá trị 0 cho trạng thái :dead và 1 cho trạng thái :alive. Và migration để tạo bảng humen tương ứng với model Human sẽ như sau:

class CreateHumen < ActiveRecord::Migration
  def change
    create_table :humen do |t|
    	t.string :name
    	t.integer :status
    end
  end
end

Sau khi migrate và thử rails console, chúng ta sẽ thấy một vài điều như sau :

irb(main):024:0> h = Human.new
=> #<Human id: nil, name: nil, status: nil>
irb(main):025:0> h.dead?
=> false
irb(main):026:0> h.alive?
=> false
irb(main):027:0> 

Một instance Human được tạo ra sẽ được bổ sung 2 hàm dead? và alive? tương ứng với 2 giá trị đã khai báo của enum. Lúc mới khởi tạo, do giá trị status của instance đang là nil nên 2 hàm này đều trả về kết quả là false, tương ứng với ý nghĩa là người này ko sống cũng không chết (Dead or Alive, no I'm the other =)) )

Và đó là điểm nguy hiểm đầu tiên của enum. Nếu không khai báo giá trị mặc định cho trường dùng làm enum, thì giá trị của trường đó khi mới khởi tạo sẽ là nil và mọi hàm gán cho giá trị đó đều trả về False Thử tưởng tượng bạn có 1 logic hiển thị dựa trên status như này :

render "giay_khai_sinh" if h.alive?
render "giay_bao_tu" if h.dead?

Và kết quả là sẽ không có cái giấy khai sinh hay báo tử nào được show ra cả, vì instance h đang không sống, cũng không chết (yaoming)

Nếu chúng ta đổi logic thành như này :

if h.alive?
  render "giay_khai_sinh"
else 
  render "giay_bao_tu"
end

hoặc như này :

if h.dead?
  render "giay_bao_tu"
else 
  render "giay_khai_sinh"
end

Thì mọi việc thậm chí còn TỆ hơn, khi mà giấy khai sinh hoặc giấy báo tử sẽ được show khi trạng thái của status là nil hoặc dead/alive. Điều này gây ra sự thiếu nhất quán trong logic, khi mà view có thể được show ra bởi 1 trong 2 loại giá trị, mà 1 trong 2 gía trị đó là cái mà chúng ta không mong muốn. Giả sử trong giấy báo tử hoặc giấy khai sinh có một vài trường được show hoặc được set dựa trên status dead hoặc alive của instance Human, khi đó giá trị sẽ không được thể hiện chính xác, thậm chí đôi khi là Something went wrong (yaoming)

Để khắc phục vấn đề này, điều đầu tiên mà tôi muốn khuyên các bạn là, với các trường sử dụng enum, hãy set giá trị mặc định cho nó và đừng bao giờ cho phép nó nil, tức là null: false. Chúng ta sẽ sửa lại migration như sau :

class CreateHumen < ActiveRecord::Migration
  def change
    create_table :humen do |t|
    	t.string :name
    	t.integer :status, null: false, default: 0
    end
  end
end

Chạy lại migration và test trên console xem nào :

irb(main):005:0> Human.new.dead?
=> true
irb(main):006:0> Human.new.alive?
=> false
irb(main):007:0> Human.new.status
=> "dead"
irb(main):009:0> Human.new
=> #<Human id: nil, name: nil, status: 0>

Lúc này trường status đã có giá trị mặc định là 0, do đó khi khởi tạo một instance Human, mặc định status là dead, khá an toàn để bạn thao tác. Chúng ta để thêm null: false để tránh các trường hợp set giá trị nil mà vẫn lưu vào được DB như sau :

irb(main):013:0> h = Human.new
=> #<Human id: nil, name: nil, status: 0>
irb(main):014:0> h.status = nil
=> nil
irb(main):015:0> h.save
   (0.1ms)  begin transaction
  SQL (0.4ms)  INSERT INTO "humen" ("status") VALUES (?)  [["status", nil]]
SQLite3::ConstraintException: NOT NULL constraint failed: humen.status: INSERT INTO "humen" ("status") VALUES (?)
   (0.0ms)  rollback transaction

Nhìn ở đoạn code trên, bạn có thể thấy 1 điều là : Chúng ta có thể set giá trị nil cho trường status. Do đó nếu ở DB ko có ràng buộc null: false, chúng ta có thể lưu giá trị nil cho trường status lúc nào tùy thích, và lúc này logic lại trở nên như trường hợp ở trên (facepalm). Vì thế, khi làm việc với các trường enum, nên set giá trị mặc định và không cho phép được nil

Vậy với các giá trị không phải nil nhưng nằm ngoài 2 giá trị dead và alive thì sao? Thật may là Rails đã xử lý hộ chúng ta rồi :

irb(main):038:0> h.status = 1
=> 1
irb(main):039:0> h.alive?
=> true
irb(main):040:0> h.status = "alive"
=> "alive"
irb(main):041:0> h.alive?
=> true
irb(main):042:0> h.status = :alive
=> :alive
irb(main):043:0> h.alive?
=> true
irb(main):044:0> h
=> #<Human id: nil, name: nil, status: 1>

irb(main):032:0> h.status = "zombie"
ArgumentError: 'zombie' is not a valid status
irb(main):034:0* h.status = 2
ArgumentError: '2' is not a valid status

Một exception có tên là ArgumentError sẽ được throw ra khi bạn set giá trị status không phải là các giá trị của enum. Ở ví dụ trên, bạn có thể thấy chúng ta có thể set giá trị là 1, :alive, "alive" đều được. Vậy các giá trị được set cho trường của enum sẽ bao gồm các loại sau :

  1. Giá trị số lưu trong DB : 0, 1
  2. String tương ứng với symbol của enum : dead, alive
  3. Symbol của enum : :dead, :alive

Ngoài các giá trị này, khi set các giá trị bất kỳ (ngoài giá trị nil đã nói ở trước), đều có exception ArgumentError. Vì thế để tránh sai sót khi set giá trị cho enum, ngoài việc set trực tiếp các bạn có thể dùm hàm alive! hoặc dead! như sau :

irb(main):046:0> h.alive!
   (0.1ms)  begin transaction
  SQL (0.2ms)  INSERT INTO "humen" ("status") VALUES (?)  [["status", 1]]
   (85.1ms)  commit transaction
=> true
irb(main):047:0> h.dead!
   (0.1ms)  begin transaction
  SQL (0.8ms)  UPDATE "humen" SET "status" = ? WHERE "humen"."id" = ?  [["status", 0], ["id", 2]]
   (94.9ms)  commit transaction
=> true

2 hàm trên tương đương với việc set h.status = 0 hoặc h.status = 1, tuy nhiên gọi bằng cách trên sẽ khiến code dễ đọc (sống dậy đi tên người kia, chết đi tên người kia =)) ) và không bị Exception nếu chẳng may gõ nhầm h.status = :death hoặc h.status = 11.

Tiếp theo, nếu sau này model Human của chúng ta mở rộng, tiến hóa thành chủng loài siêu nhân hoặc bị zombie hóa, lúc này trường status sẽ cần lưu thêm vài trạng thái nữa, ví dụ như :mutant hoặc :zombie, và chúng ta sẽ sửa lại model Human như sau :

class Human < ActiveRecord::Base
	enum status: [:dead, :alive, :mutant, :zombie]
end

Theo dự đoán của giới chuyên môn, 2 giá trị :mutant và :zombie sẽ tương ứng với giá trị 2 và 3 trong database, chúng ta hãy đi kiểm tra điều này :

irb(main):050:0> Human.statuses
=> {"dead"=>0, "alive"=>1, "mutant"=>2, "zombie"=>3}
irb(main):051:0> h = Human.new
=> #<Human id: nil, name: nil, status: 0>
irb(main):052:0> h.status = :mutant
=> :mutant
irb(main):053:0> h.mutant?
=> true
irb(main):054:0> h
=> #<Human id: nil, name: nil, status: 2>
irb(main):055:0> h.status = 3
=> 3
irb(main):056:0> h.zombie?
=> true
irb(main):057:0> h
=> #<Human id: nil, name: nil, status: 3>
irb(main):058:0> h.mutant?
=> false
irb(main):059:0> h.dead?
=> false
irb(main):060:0> 

À quên chưa giới thiệu với các bạn, khi sử dụng enum, ngoài các hàm cho instance, Rails còn ưu ái cho thêm chúng ta 1 hàm cho lớp Human để liệt kê các status có thể có với tên là statuses (số nhiều của status).

Mọi thứ có vẻ khá êm xuôi khi cứ thêm status mới vào cuối array theo kiểu này. Tuy nhiên nếu có một đấng toàn năng nào đó lý sự rằng :mutant > :alive > :zombie > :dead (dấu lớn hơn biểu thị rằng dạng sống phía tay trái cao cấp hơn tay phải) và bắt chúng ta sửa lại như sau :

class Human < ActiveRecord::Base
	enum status: [:mutant, :alive, :zombie, :dead]
end

Lúc này mọi thứ sẽ trở nên Khác, rất khác:

irb(main):076:0> Human.new.mutant?
=> true
irb(main):077:0> h = Human.new
=> #<Human id: nil, name: nil, status: 0>
irb(main):078:0> h.mutant?
=> true
irb(main):079:0> h.dead?
=> false
irb(main):080:0> h.save
   (0.1ms)  begin transaction
  SQL (0.1ms)  INSERT INTO "humen" DEFAULT VALUES
   (69.5ms)  commit transaction
=> true
irb(main):081:0> h.dead?
=> false
irb(main):082:0> h.status
=> "mutant"
irb(main):083:0> h.status = 3
=> 3
irb(main):084:0> h.status
=> "dead"
irb(main):085:0> h.dead?
=> true
irb(main):086:0> h.mutant?
=> false
irb(main):087:0> 

Instance Human lúc đầu sẽ mang giá trị mặc định là :mutant chứ ko phải là :dead nữa, các giá trị trong DB sẽ bị chuyển đổi như sau :

  • 0 - từ :dead thành :mutant - Người chết sống lại và thành siêu nhân
  • 1 - giữ nguyên :alive
  • 2 - từ :mutant thành :zombie - Siêu nhân trở thành zombie vật vờ =))
  • 3 - từ :zombie thành :dead - Zombie chết =))

Chúng ta có thể thấy logic cho trường status đã HOÀN TOÀN BỊ BIẾN ĐỔI, và điều này nếu xét theo tính logic thì tương đương với vài sự kiện to to như là thả bom hạt nhân, hồi sinh người chết, ... Chỉ thay đổi thứ tự trong mảng, nhưng ảnh hưởng đến logic là cực kì lớn, do đó đây chính là điểm cần lưu ý với enum : KHÔNG THAY ĐỔI THỨ TỰ CÁC ENUM ĐÃ ĐƯỢC SỬ DỤNG TRƯỚC ĐÓ, NẾU KHÔNG LOGIC SẼ BỊ THAY ĐỔI THEO.

Để giải quyết / phòng tránh thảm họa này, thật may là Rails cho phép chúng ta set các giá trị status theo kiểu Hash, gồm key và value như sau :

class Human < ActiveRecord::Base
	enum status: {
		mutant: 2, 
		alive: 1,
		zombie: 3,
		dead: 0
	}
end

Lúc này logic sẽ được update lại như thưở ban đầu :

irb(main):092:0> h = Human.new
=> #<Human id: nil, name: nil, status: 0>
irb(main):093:0> h.dead?
=> true
irb(main):094:0> h.mutant!
   (0.1ms)  begin transaction
  SQL (0.2ms)  INSERT INTO "humen" ("status") VALUES (?)  [["status", 2]]
   (98.9ms)  commit transaction
=> true
irb(main):095:0> h
=> #<Human id: 5, name: nil, status: 2>
irb(main):096:0> h.zombie!
   (0.1ms)  begin transaction
  SQL (0.2ms)  UPDATE "humen" SET "status" = ? WHERE "humen"."id" = ?  [["status", 3], ["id", 5]]
   (82.2ms)  commit transaction
=> true
irb(main):097:0> h
=> #<Human id: 5, name: nil, status: 3>

Và như vậy là thế giới đã quay lại như nó vốn phải thế, xin chúc mừng các bạn             </div>
            
            <div class=

0