Ruby on Rails best practices

Best practice nói nôm na dễ hiểu là cách viết, trình bày code của mình làm sao cho đẹp, gọn gàng,… và đặc biệt là dễ tái sử dụng. Trong bài này tôi sẽ nêu 1 vài ý kiến cá nhân về việc viết code Ruby on Rails như thế nào cho hợp lý mà các bạn Junior hoặc Low-Mid developer nên lưu ý và có thể học theo.

Fat Model, Skinny Controller

Khi học lập trình tôi nghĩ ai cũng nghe qua từ MVC. “Fat Model, Skinny Controller” có thể gọi là 1 quy tắc để viết M và C trong MVC 1 cách tốt hơn. Cụ thể hơn, tất cả non-response-related logic nên bỏ vào Model. “Skinny” controller nên chỉ chứa code liên kết giữa View và Model thôi. Nói khó hiểu quá, các bạn nhìn ví dụ đơn giản sau đây:

Ví dụ tôi có Controller cần lấy Published Post và Unpublished Post, show ra ở View, tôi tạo 1 Controller như sau:

def index
  @published_posts = Post.where('published_at <= ?', Time.now)
  @unpublished_posts = Post.where('published_at IS NULL OR published_at > ?', Time.now)
end

Các bạn thấy tôi viết tất cả logic lấy Published và Unpublished Post ở Controller, tưởng tượng sau này tôi cần lấy thêm Author, DB của tôi phải Join rất nhiều table, logic lằng nhằng hơn thì Controller ngày càng phình to và rất phức tạp và khó debug.

Thay vì để như vậy tôi có thể viết lại Controller như sau:

def index
  @published_posts = Post.published
  @unpublished_posts = Post.unpublished
end

Sau đó tôi move tất cả logic liên quan đến việc lấy Post vào Model, tôi được Model như vầy:

scope :published, ->(timestamp = Time.now) { where('published_at <= ?', timestamp) }
scope :unpublished, ->(timestamp = Time.now) { where('published_at IS NULL OR published_at > ?', timestamp) }


Domain Objects (No More Fat Models)

Fat Model, Skinny Controller là 1 bước khởi đầu khá tốt cho các bạn mới, nó thật sự rất hữu ích nếu app có logic đơn giản, không quá phức tạp. Nhưng nếu app của tôi ngày càng scale-up, chắc chắn một ngày nào đó Model cũng trở nên không những “fat” mà còn “béo phì”.

Tôi nghĩ developer nào cũng biết SOLID principle, những bạn nào chưa biết bây giờ là lúc tìm hiểu rồi. Tôi muốn đề cập đến Model Single Responsibility, nhưng mà Single Responsibility là gì? Nói cho dễ hiểu là một model chỉ nên đảm nhận 1 nhiệm vụ duy nhất. Tôi không nên lạm dụng Fat model (mà tôi đề cập ở trên), bỏ tất cả business logic vào hết trong model, lúc này sẽ vi phạm nguyên tắc Single Responsibility.

Business logic nên bỏ vào domain objects.

Domain Object là những class được tạo ra chỉ đảm nhận 1 nhiệm vụ xử lý 1 problem duy nhất. Thực tế, tôi nên cố gắng làm cho app của tôi không những skinny controllers mà còn skinny models và skinny views luôn. Kiến trúc này nên được gọi là kiến thức lập trình cơ bản chứ không nên phụ thuộc vào framework mà tôi đang sử dụng.

Ví dụ:

Bây giờ giả sử tôi là nhà phân phối 1 service nào đó, tiền tôi móc từ khách hàng thông qua commission. Nếu tôi lấy 15% commission, nhà đầu tư sẽ lấy thêm của tôi 3% + 30¢.

Có nghĩa là số tiền mà tôi sẽ nhận sẽ là amount*0.15 - (amount*0.03 + 0.30)

Tôi được model như sau:

# app/models/order.rb
class Order < ActiveRecord::Base
  SERVICE_COMMISSION = 0.15
  STRIPE_PERCENTAGE_COMMISSION = 0.029
  STRIPE_FIXED_COMMISSION = 0.30

  ...

  def commission
    amount*SERVICE_COMMISSION - stripe_commission  
  end

  private

  def stripe_commission
    amount*STRIPE_PERCENTAGE_COMMISSION + STRIPE_FIXED_COMMISSION
  end
end

Bây giờ nếu tôi viết thêm phương thức payment mới, tôi sẽ không thể cứ viết thêm function trong model được.

Tôi sẽ chuyển hết business logic vào domain object như sau:

# app/models/order.rb
class Order < ActiveRecord::Base
  ...
  # No reference to commission calculation
end

# lib/commission.rb
class Commission
  SERVICE_COMMISSION = 0.15

  def self.calculate(payment_method, model)
    model.amount*SERVICE_COMMISSION - payment_commission(payment_method, model)  
  end

  private

  def self.payment_commission(payment_method, model)
    # There are better ways to implement a static registry,
    # this is only for illustration purposes.
    Object.const_get("#{payment_method}Commission").calculate(model)
  end
end

# lib/stripe_commission.rb
class StripeCommission
  STRIPE_PERCENTAGE_COMMISSION = 0.03
  STRIPE_FIXED_COMMISSION = 0.30

  def self.calculate(model)
    model.amount*STRIPE_PERCENTAGE_COMMISSION
      + STRIPE_PERCENTAGE_COMMISSION
  end
end

# app/controllers/orders_controller.rb
class OrdersController < ApplicationController
  def create
    @order = Order.new(order_params)
    @order.commission = Commission.calculate("Stripe", @order)
    ...
  end
end

Những lợi ích mà kiến trúc này đem lại như sau:

  • Cực kỳ dễ dàng apply unit test.
  • Work với tất cả object có amount
  • Làm cho model của tôi nhỏ gọn hơn, dễ dàng xác định nhiệm vụ chính và có tính liên kết cao.
  • Rất dễ scale up model bằng nguyên tắc addition, not modification.

Thông thường tôi để Domain Object trong thư mục lib. Để làm được giống tôi bạn nhớ thêm nó vào autoload_paths:

# config/application.rb
config.autoload_paths << Rails.root.join('lib')


Convention Over Configuration

Trong Rails chúng ta thường làm việc với view, model, controller. Có khi nào bạn để ý tên của chúng tương tự giống giống nhau không!?

Ví dụ tôi có database tên Order, sau đó model cũng tên order rồi controller cũng lại có tên order_controller. Trong controller tôi tạo 2 action là newedit, thế là view tôi cũng có newedit view.

Thường chúng ta làm Rails sẽ không để ý điều này. Những cái tôi nói nãy giờ chính là Convention Over Configuration.

Để giảm thiểu chúng ta phải config quá nhiều để xây dựng app nên Rails đã xây dựng bộ rules giúp chúng ta dễ xây dựng application hơn. Đúng là tôi có thể tự nghĩ ra rules cho riêng mình, nhưng tôi khuyên đối với các bạn beginner và tất cả chúng ta luôn, nên follow theo convention mà Rails cung cấp.

Follow theo convention của Rails giúp tôi viết code nhanh hơn, giữ cho code của tôi gọn gàng và dễ đọc, đồng thời giúp tôi dễ dàng navigate qua lại giữa model, view và controller,… trong application.

Rails convention cũng giúp gỡ bỏ những rào cản tiếp cận Rails cho các bạn beginner. Có rất nhiều convention trong Rails thậm chí tôi cũng không cần phải biết, nhưng tôi hoàn toàn có thể tạo ra 1 application với cấu trúc tuyêt vời mặc dù không hiểu tại sao nó lại như vậy. Rails đã cover hết cho tôi rồi.

Ví dụ khi tôi muốn tạo new application tôi chỉ cần chạy câu lệnh sau rails new app_name. Rails sẽ tự động tạo cho tôi hơn 70 files và folders bao gồm kiến trúc và thành phần nền tảng của application. Thật sự rất khỏe!

Chú ý khi sử dụng default_scope

Rails cung cấp default_scope để mặc định tự động điều chỉnh phạm vi của model.

class Post
  default_scope ->{ where(published: true).order(created_at: :desc) }
end

Đoạn code ở trên giúp tôi tự động lấy những bài Post đã được published khi tôi thực hiện bất kỳ query nào liên quan đến model Post.

Post.all # will only list published posts 

Đoạn code này nhìn vào tưởng đơn giản vô hại, nhưng thực chất chứa rất nhiều side-effect ẩn mà tôi không mong muốn.

default_scope và order

Như đoạn code trên tôi đã add 1 cái order vào trong default_scope, giờ nếu tôi gọi order trên Post sẽ làm cho double order condition thay vì override order trong default_scope.

Post.order(updated_at: :desc)
SELECT "posts".* FROM "posts" WHERE "posts"."published" = 't' ORDER BY "posts"."created_at" DESC, "posts"."updated_at" DESC

Đây chắc chắc là điều tôi không mong muốn, nhưng tôi có thể sủ dụng except để bỏ đi order condition trong default_scope trước khi thực hiện query:

Post.except(:order).order(updated_at: :desc)
SELECT "posts".* FROM "posts" WHERE "posts"."published" = 't' ORDER BY "posts"."updated_at" DESC

default_scope và model initialization

default_scope đồng thời cũng sửa đổi default state của model khi initialized. Trong đoạn code ở trên, Post mặc định sẽ được thêm where(published: true). Nên khi tôi tạo 1 bài Post mới cũng sẽ bị ảnh hưởng và tự động thêm published: true vào luôn.

Post.new # => <Post published: true>

unscoped

Sử dụng unscoped có thể giúp tôi clear default_scope trước khi thự hiện query để tránh side_effect không mong muốn:

Post.unscoped.new # => <Post>

unscoped và Model Associations

Giả sử tôi có relationship giữa PostUser

class Post < ApplicationRecord
  belongs_to :user
  default_scope ->{ where(published: true).order(created_at: :desc) }
end

class User < ApplicationRecord
  has_many :posts
end

Giờ giả sử tôi muốn lấy Post thuộc về user_id = 1, tôi thực hiện như sau:

user = User.find(1)
user.posts
SELECT "posts".* FROM "posts" WHERE "posts"."published" = true AND "posts"."user_id" = 1 ORDER BY "posts"."created_at" DESC

Nhưng nếu giờ tôi sử dụng uncoped, nó sẽ clear luôn điều kiện user_id cũng như default_scope ở Post luôn

user.posts.unscoped
SELECT "posts".* FROM "posts"

Don’t Repeat Yourself (DRY)

Cụm từ này tôi nghĩ là khá quen thuộc với developer rồi. Nói dễ hiểu là tôi cố gắng làm cho code của mình càng dễ sử dụng lại càng tốt, viết gọn chúng lại cất vào đâu đó, để khi cần sử dụng thì gọi nó ra, không nên cứ chỗ nào cần lại copy paste thêm 1 block code y chang vậy nữa thì nhìn hơi phèn.

Fat Model, Skinny Controller cũng là 1 dạng DRY, bởi vì tôi sẽ viết logic trong model, khi controller cần tôi sẽ gọi chúng ra.

# Post model
scope :unpublished, ->(timestamp = Time.now) { where('published_at IS NULL OR published_at > ?', timestamp) } 


# Any controller
def index
    ....
    @unpublished_posts = Post.unpublished
    ....
end

def others
    ...
    @unpublished_posts = Post.unpublished
    ...
end

You Ain’t Gonna Need it (YAGNI)

Nếu tôi đang phân vân “Không biết có cần viết function này không?” thì 100% chắc chắc là tôi sẽ không cần implement nó. Nếu tôi vẫn cố chấp implement những function giống như vậy sẽ dẫn tới 1 vài problem sau:

Overengineering

Nếu product của tôi viết ra phức tạp 1 cách quá đáng mà đáng lẽ ra không cần phải như vậy, thì product của tôi đang bị over engineering. Thường thường những function như “chưa cần thiết bây giờ, nhưng chắc sau này cần” thì tôi chắc chắc 100% function sẽ không bao giờ cần, hoặc có cần thì cũng sẽ viết lại hoặc refactor te tua.

Code Bloat

Dịch dễ hiểu là viết code phức tạp không cần thiết. Nó sẽ làm cho code của tôi trở nên trừu tượng hóa, dư thừa và không đúng với design pattern, code trở nên khó hiểu, dễ gây nhầm lẫn, khó maintain và nguy hiểm nhất là đọc vào rất ức chế, tăng sự nóng giận.

Feature Creep

Nếu bạn đang thêm vào các tính năng không cần thiết mới, ngày càng lan man, đi xa với core function của app làm cho app trở nên phức tạp quá đáng 1 cách không cần thiết thì app bạn đang bị feature creep.

Tất cả những problem ở trên sẽ làm cho thời gian development kéo dài không cần thiết, tốn công sức, nguồn nhân lực, tiền bạc và đặc biệt là gây ức chế cho developer.

Giải pháp

KISS – Keep it simple, stupid

Theo KISS, hệ thống hoạt động tốt nhất là hệ thống nên thiết kế 1 cách đơn giản nhất, giảm thiểu sự phức tạp không cần thiết. Có thể đạt được khi tuân theo nguyên tắc “Single Responsibility” trong SOLID.

YAGNI – You Ain’t Gonna Need it

Continuous Refactoring

Product nên được improve 1 cách đều đặn. Khi refactor, tôi có thể đảm bảo rằng product được follow theo những best practice tốt nhất, code luôn luôn được hoàn thiện và cải thiện chất lượng, performance và không bị biến thành công việc vá lỗi.

Trên đây là những điều hay mà tôi nghĩ các bạn beginner nên đọc và làm theo nếu chưa biết. Hy vọng sẽ giúp ích được cho các bạn có thêm chút kiến thức cơ bản.

Happy hacking 🙂