《Growing Rails Applications in Practice》 重點整理

Rails 是一個讓開發者們可以很快速的實作網站功能框架。但談到網站的日漸成長,程式碼變得混亂、難以閱讀維護,往往是多數 Rails 專案中,過了甜蜜期之後最大的痛。面對混亂的自訂 controller action、肥大的 model、難以追查的 callbacks 時,許多開發者會嘗試引入不同的設計模式,嘗試整理整頓好錯綜複雜的邏輯。但這些外來的設計模式,除了會增加專案的學習成本,通常也只適用在特定的情況,無法作為 Rails application 的通則來使用。

《Growing Rails Applications in Practice》是一本由位於德國的 Ruby on Rails 技術顧問公司 makandra 的兩位共同創辦人所撰寫的書。跟其他的架構方法比起來,在本書會看到的幾乎都是 Rails 哲學的延伸,透過重新理解 Controller、Model 等核心觀念,我們也能夠以一般 Rails 都相當熟稔的工具及概念,打造可規模化,亂度與規模曲線接近水平的 Rails 應用。個人覺得可以作為一個在各種 Rails 專案中通用的架構方法和共識。

這本書以 40 個務實的秘訣,由下而上的勾勒出我們該用什麼方來思考,可以把 Rails 專案寫得整齊、漂亮,適合有些實務經驗的 Rails 開發者。在閱讀本書後,我照著自己的理解將內容重新整理架構成一張心智圖,讓自己在還不熟悉此書的觀念時,可以在專案實作或是討論的當下,透過圖中的脈絡快速地把一些做法重新裝載進腦中。以此為基礎,本篇將以 top-down 的方式,以不長的篇幅嘗試分享在本書中的一些重點概念(會參雜一些個人的理解和觀點在裡面)。如果大家有興趣的話,相當推薦可以一讀本作

本書大綱

原書中共分為三個部分:〈New rules for Rails〉,旨在修正讀者的一些壞習慣,打下良好慣例,為往後的規模奠定良好基礎。〈Creating a system for growth〉中會教導我們在面臨更多需求時,如何整理收納越來越多的程式碼,讓專案保持容易理解、容易維護。以及 〈Building applications to last〉,若要讓專案可以長久生存,如何在做眼下的決策時同時考慮未來的維護、如何謹慎地選用新技術,包括第三方的 gem 或是新的技術潮流。

本篇重點整理將照著圖表,以另一個、貼近專案特性的角度切入:(相較於其他類型的框架)我們該如何看待一個 Rails app、其 controllers、models、stylesheets、技術棧、測試以及升級。

總論:Rails 的網站世界觀

我一直認為在選擇技術時,分辨不同技術之間的世界觀是相當重要的。除了可以利用語言、框架的本質漂亮地完成任務,也能避免強迫一項工具用不符合本性的方式做事而徒增繁瑣。

而一個 Rails 網站的核心觀念是什麼呢?我自己(依照《Growing Rails Applications in Practice》給的觀念)認為是——表單。

想像一間銀行、或商店、或公務機關,每天都會有諸如存提款單、進貨單收據、戶口登記文件等各種表單被填寫並存放在檔案櫃中,或是被取出查閱。有些表單的被提交會觸發其他的表單被填寫或修改,例如銀行開戶申請書 (被謄寫成帳戶文件等)、商家訂購確認單(產生揀貨單、出貨單、收據)、戶籍變更申請(提交的目的是修改戶籍文件)。也有些表單在我們填寫完成後是不需要被留存的,像是促銷訊息退訂單(商家收到後只要單純地把我們的個資刪掉就好了)。

而在這些地方,通常都有服務員協助我們填寫表單、告知資訊(總不可能讓人直接去檔案櫃翻找資料吧)、或依檔案上的資料提供相應的服務(例如提款)。

察覺到了嗎?上述的表單其實就是 Rails Model 和 Form Object,服務人員是 Controller,都是 Rails 網站的核心觀念。

這種體系可以讓各種無論規模大小的組織運作多年不墜,也就表示運用 Rails 的基本原則,也是可以打造各種複雜應用的——說穿了只是把傳統由人力處理的規則自動化。只是在沒有既定體系可以參考、以及對 Rails 世界觀不熟悉的狀況下,我們常常會犯下三種錯誤:

太多沒規範的潛規則

這是許多 Rails 新手會犯的錯誤,誤認 Rails Model 等於 database record,於是把許多業務邏輯都寫在 controller 裡。就好比是太多事情只有服務員知道,想瞭解運作原理需要問過太多人,也很難一聲號令就改變規則,因為牽動的利益關係太複雜了(誤)。在 Rails app 裡呈現的後果就是難以改變或增加功能、難以測試。

讓表單超越了表單

大部分有一定經驗的 Rails 開發者都知道要把業務邏輯搬進 model,但常常會陷入另一個麻煩——一個 model 做的事情太多了。最常見的狀況是發生在 User 這個 model 上:因為使用者作為我們服務的主角,如果要把邏輯移出 controller,第一個會想到有關聯的 model 就是 User 了:無論是註冊、登入、跑導覽、User#add_to_cart(product)User#order(product) ⋯⋯是說那個 User 已經不是表單,而是個人了吧。也許這就像在每位顧客上門時,我們都派一位知道如何處理一切事情的代理人,為每個人貼身服務。很尊榮沒錯,但不覺得那位代理人知道的事情太多了嗎?隨著規則越來越複雜,再優秀的人處理事情時很容易在腦中亂掉(對開發者來說很難閱讀和修改)。

這樣的情況也許不只在 User,有時候在 Order,或任何貼近業務主體的 model 都有可能發生。

有些人可能會用「拆 concern」的作法嘗試解決這個問題,讓 model「看起來」小一點,但我個人覺得這種做法沒有解決邏輯混亂、權責不分的根本問題,只是把混亂拆開並藏起來而已。因此自己並不會把拆 concern 當作 refactor 時考量順位太高的做法。

沒有善用表單

如果你是一位想購買傢俱的顧客,選好型號後來到訂購櫃檯,原本想說報上電話、地址和貨號就能完成訂購,但沒想到服務人員卻把揀貨單、貨運出貨單、收據通通都堆到面前給你填(沒有善用 Form Object)。或是,在你完成付款並將單據交給服務人員時,對方將你的訂單找出來、把付款資訊填上去後,卻忘了寫出貨單(在一個 model 綁了太雜的 callback)。這些環節都讓整個流程難以被看懂、容易出錯。比較理想的做法是,我們可以設計一份訂購表單,當這個表單被提交後自動地產生揀貨單、貨運出貨單和收據。又或是在需要先接單、後付款的情況,設計一份付款回報單,並且在回報單底下標明接受付款時也要連帶把出貨單也開好。

以上是個人心中 Rails 專案中業務邏輯的世界觀和可能長歪的狀況,在~~打了一連串的高空~~之後,以下就讓我們分成各部分,逐一揭露《Growing Rails Applications in Practice》書中理想的 Rails application 是什麼樣態吧!


Controller

以下來看看 controller 相關的要點整理:

不要放邏輯

不要把任何業務邏輯放在 controller 裡!這樣會讓程式碼變得很亂、零散而且不容易測試。我認為一個值得放在心上的要點是隨時都要維持 rails console 好用,這麼一來開發者無論如何都需要將邏輯封裝進 model,並讓他們擁有容易理解、操作的 API 介面。

存在目的:接合 request 和 model

最顯著的就是認證(authentication,現在這個人是誰)和授權(authorization,這個人是否有權做出這個要求)、處理和轉遞 reuqest 的參數(params)、載入與初始化 models、決定要 render 哪個 view 等。

優良準則:讓一切 RESTful、都是 CRUD!

先看看一些常見不 RESTful、CRUD 的例子:

  • POST /orders/[id]/ship (orders#ship) - 將訂單出貨
  • POST /invitations/[id]/accept (invitations#accept) - 接受邀請
  • POST /posts/[id]/publish (posts#publish) - 發佈文章

雖然這樣使用 custom action 還是十分直覺好懂,但有幾個缺點:

  1. 增加理解成本:容易寫出太多 custom action,就像在一些 RESTful resource 底下又掛著一包 custom API,遇到一組就需要重新讀懂一組。
  2. 需要自己設計 custom action 的成功、錯誤處理,並且也沒有明確的慣例。
  3. 要如何實作 custom action 也沒有明確的慣例,無法避免開發者把邏輯寫在 controller 裡的傾向。

再來看看書中建議的作法:

  • POST /orders/[id]/shipments (orders/shipments#create) - 將訂單出貨
  • POST /invitations/[id]/acceptions (invitations/acceptions#create) - 接受邀請
  • POST /posts/[id]/publication (posts/publication#create) - 發佈文章

除了避免太多 custom action 問題外,我們還能獲得一些好處:

  1. 每個 controller 都只有 CRUD 的任務,可以最大幅度借力於 Rails 的 user-facting model(於 model 篇後述)、確保 controller 層輕薄化。
  2. 一個 controller 就只對應一個 model,引導開發者往拆分 model、而非把所有邏輯都掛在少數幾個 model 上來思考。
  3. 因為設計 controller 的思路就只有一種,就算專案龐大,想略讀過每隻 controller 來了解專案的功能時也會相當省力——真的需要了解細節再去讀 model 就好。

有時候我甚至不使用 delete 和 destroy,而用如 XXX::Cancellation 等 form object 來達成。一個稍具規模的網站上通常很難有東西被邏輯上的「刪除」,仔細想久一點的話我們通常能找到更精確的看待方式,例如撤銷、隱藏或還原。(而且先有這些 form object 的話,未來還能無痛接上 GraphQL 當 Mutation 用哦)

Blueprint

書中給了一個 controller 的初始樣版,在寫新 controller 的時候可以參考使用。以實際案例來說,TripBook 專案中的 controller 幾乎都是遵照這個準則寫出來的。

關於抽象化 controller (Controller Abstractions)

既然每個 controller 做的事都差不多,長得也差不多,感覺完全可以做一個通用的 controller,然後用在所有地方呢(可以參考老闆之前寫的文章:手刻 RESTful DRY Controller)。

但本書作者其實不太建議預設使用這種做法,因為有這麼做的話幾個缺點:

  1. 這麼做會使得日後若需要做不太一樣的 controller 都變成在 hack,一直用 hack 解決問題不是個理想的狀態。
  2. 程式碼閱讀起來較不流暢,雖然檔案中的行數變少了,但反而比較難難一眼看出這個 controller 到底做了什麼事,又在那邊安插了新的行為。

Code 寫久了,開發者大多都會對重複的東西相當敏銳,但在動手收納程式碼之前,可以想一下遇到的狀況是「剛好長得一樣」,還是「邏輯上的等價」,如果是前者的話,其實就不需要、也不應該抽象化成同一個模組的。用這個前提思考的話,也就不難判斷眼下的狀況適不適合選用 Controller Abstractions 囉。

一句話解釋 controller

如果用這個脈絡來概括地描述 controller,我會說它是「Request 和 Model 之間、既薄且規律的轉介層」。


Model

接下來看重頭戲、整個 Rails app 的首要擔當——Model。藉由 Active Model 來做 user-facing model 是個非常高效率的體驗。User-facing model,也就是世界觀一段所比喻的「表單」,是個讓使用者完成特定動作的主要角色,它的主要工作包括了驗證使用者輸入的資料、在有問題的欄位上加上錯誤訊息、以及在其生命週期完成各種附帶動作等。

準則:遵循 Active Record 來設計 Model

Active Record 物件本身給了我們很多方式來修改資料,讓我們可以依照情境選擇合適的:

# w/ attribute accessor
user.company = "5xRuby"
user.save!

# w/ update attributes
user.update_attributes!(company: "5xRuby")

# w/ assign attributes
user.assign_attributes(company: "5xRuby")
user.save!

但對習慣設計 OOP 的開發者來說,會有個大困擾:

class User < ActiveRecord::Base
  # ...

  def activate!
    transaction do
      update_attributes!(activated: true)
      Membership.create!(user: self)
    end
  end
end

我們自訂了一個 activate! 的 method 來啟用使用者,如此一來也就規定了要修改 activated 這個欄位(來啟用使用者)時,一定要透過這個 method,否則會讓資料出錯。但是我們要如何防止其他天真無邪的夥伴誤用了諸如 user.update_attributes!(activated: true) 的方式來啟用使用者呢?或許我們可以逐一覆寫這些 Active Record 的原生 method 來擋掉所有不合法的操作,但這些 method 實在太多、要正確的覆寫掉也十分麻煩,可以想像我們一定會邊寫邊抱怨 Active Record 用起來怎麼這麼麻煩、製造的問題比解決的問題還多。

為了避開這個問題,不妨順著 Active Record 的思路來做這件事:

class User < ActiveRecord::Base
  # ...
  after_save :create_membership_if_activating

  private

  def create_membership_if_activating
    return unless activated_changed?(to: true)
    Membership.create!(user: self)
  end
end

利用 callback 來檢查修改並做出相對的動作,這麽一來就不用特別規定我們 model 要如何使用,無論其他人想用什麼 Active Record API 來啟用使用者,都不會出問題囉。而且 Rails 還會幫我們把 callbacks 都包進 transaction,不用怕忘記。

那如果我們想規定已經 activated 的使用者不能被 deactivate 的話,要怎麼做呢?我們可以利用 validator 來達成:

class User < ActiveRecord::Base
  # ...
  validate :cannot_deactivate

  private

  def cannot_deactivate
    return unless activated_changed?(from: true)
    errors.add(:activated, "can't deactivate a activated user")
  end
end

以上其實也可以寫成一行解決(但可能比較難讀)的寫法:

class User < ActiveRecord::Base
  # ...
  validates :activated, acceptance: { message: "can't deactivate a activated user" }, if: :activated_was
end

如此一來就可以有效地在 Active Record 層來限制無論如何修改使用者都不會被 deactivate 了。一般來說,我們希望能透過這個方式來加上足夠的限制,確保不會出現不合法的資料變動。

Active Model API

縱使有很多 API 可以利用,Active Record Model 的核心功能是:

  • 新增資料。
  • 修改資料。
  • 修改資料時不會立即執行修改,我們可以任意的做一些變動,然後在執行前檢視會做的修改和檢查錯誤。
  • 執行修改,一但資料通過驗證檢查,所有需要的更動會在同一個 transaction 中寫入到資料庫。

藉著遵循和善用這套設計,我們可以在不少地方借力於它:

  • 開發者們不用重新理解每個 model 要怎麼使用,也可以放心地直接嘗試操作來瞭解業務邏輯。
  • 我們的 controller 會變得一致且輕薄,如 controller 一段所述。
  • 透過 Active Model Errors 和圍繞著它的 view helpers,我們的 Rails view 可以被顯著地簡化,再也不需要紛亂地在 controller 裡到處用變數把各種狀態和訊息傳進 view 處理了。
  • Model 將不太可能因為被誤用而把資料改到不合理的狀態。
  • 可以尋找各種圍繞著 Active Record 的 gem 裝起來用。

Non-persist Models (i.e. Form Models)

把會影響到眾多 Active Record Model 的動作封裝成 Form Model(或稱 Form Object)、並從 controller 或肥大的 model 抽取出來是常見的 refactor 手法。

一般來說,我們可以讓這些 Form Models 去 include ActiveModel::Model 來獲得 Active Model 的 callbacks、validation 等諸多功能,並自己寫 save/save! 方法來跑 validation 然後把 run_callbacks 包進 transaction 執行⋯⋯等一系列的準備之後,就可以當作是一般 Active Record Model 來使用了。不過如果想要更方便簡單的作法,可以直接使用本書作者撰寫的「active_type」這個 gem,只要讓 class 繼承 ActiveType::Object,就可以用如下方式實作如轉帳表單(實際上是在分別的兩個帳戶中建立交易紀錄):

class Bank::Transaction < ActiveType::Object
  nests_one :source_account, scope: proc { Bank::Account }
  nests_one :target_account, scope: proc { Bank::Account }
  attribute :amount, :integer
  attribute :note, :string

  validates :source_account, :target_account, :amount, presence: true
  validate :source_account_has_enough_founds

  after_save :create_transfer_in_entry
  after_save :create_transfer_out_entry

  private

  # ...
end

電商出貨單(會建立運輸記錄以及更新訂單項目):

class Order::Item::Shipment < ActiveType::Object
  nests_many :order_items
  attribute :shipping_code, :string

  validates :shipping_code, presence: true
  validate :validate_order_items_paied

  after_save :create_delivery
  after_save :update_order_items

  private

  # ...
end

交友邀請(建立單向 Friendship):

class FriendInvitation < ActiveType::Object
  nests_one :user, scope: proc { User }
  nests_one :invitee, scope: proc { User }

  validates :user, :invitee, presence: true
  validate :validate_friendship_does_not_exists

  after_save :create_pending_friendship

  private

  def validate_friendship_does_not_exists
    return unless Friendship.where(user: user, friend: invitee).exists?
    errors.add(:invitee, :friendship_or_invitation_already_exists)
  end

  def create_pending_friendship
    Friendship.create!(user: user, friend: invitee, accepted: false)
  end
end

以及交友邀請回覆表單(補齊雙向 Friendship 或是刪除單向 Friendship):

class FriendInvitationReply < ActiveType::Object
  nests_one :user, scope: proc { User }
  nests_one :inviter, scope: proc { User }
  attribute :accept, :boolean

  validates :user, :inviter, :accept, presence: true
  validate :validate_invitation_exists

  after_save :accept_or_destroy_friendship
  after_save :create_other_side_friendship_if_accept

  def invitation_friendship
    if [@invitation_friendshi](http://twitter.com/invitation_friendshi)p.present? &&
       [@invitation_friendshi](http://twitter.com/invitation_friendshi)p_user == user &&
       [@invitation_friendshi](http://twitter.com/invitation_friendshi)p_inviter == inviter
      return [@invitation_friendshi](http://twitter.com/invitation_friendshi)p

    [@invitation_friendshi](http://twitter.com/invitation_friendshi)p_user = user
    [@invitation_friendshi](http://twitter.com/invitation_friendshi)p_inviter = inviter
    [@invitation_friendshi](http://twitter.com/invitation_friendshi)p = Friendship.find_by(user: inviter, friend: user, accepted: false)
  end

  private

  def validate_invitation_exists
    return if user.blank?
    return if inviter.blank?
    errors.add(:inviter, :friend_invitation_from_user_does_not_exists) if invitation_friendship.blank?
  end

  def accept_or_destroy_friendship
    if accept
      invitation_friendship.update_attributes!(accepted: true)
    else
      invitation_friendship.destroy!
    end
  end

  def create_other_side_friendship_if_accept
    return unless accepted
    Friendship.create!(user: user, friend: inviter, accepted: true)
  end
end

Active Record Models: Core Model and Form Model

至於那些 backed by database table 的 Active Record Models,也可以利用原生的 Core Model、以及擴充而成的 Form Model,來達成程式碼整頓、情境分離。

先回想一下,一個過大的 model 會有什麼問題呢?

  • 大家會擔心操作 model 時,會不會有隱藏的、意料之外的 callback 被觸發:也許只是想要跑個 script 在所有訂單的物流追蹤碼上加個 prefix,但訂單 model 上的某個 callback 判斷物流追蹤碼被更改就是出貨行為,要寄通知信給消費者,結果幾千封信就這麼被誤寄出去了。
  • 可能會因為有 model 突然被加上在某種情境防止使用者錯誤操作的 validator 或 callback,而導致其他會自動操作該 model 的程式無法運作。
  • 不僅是 model 本身,連測試程式也會變得十分冗長、難以閱讀。

一般常見「拆成 concern」的做法,只能解決程式碼過長,並無法實際解決單一類別複雜度過高的狀況,所以在用功能來分類程式碼之前,不妨可以先考慮用目的分類。以 User 來說,就可以拆成基本功能的 Core Model:

class User < ApplicationRecord
  scope :activated, -> { where.not(activated_at: nil) }

  has_one :profile, autosave: true
  has_many :posts

  validates :name, presence: true
  validates :username, uniqueness: { case_sensitive: false },
                       format: { with: /\A[0-9A-Za-z_]+\Z/ },
                       allow_nil: true

  before_validation :nilify_blanks

  def display_name
    username.present? ? "@#{username} (#{name})" : name
  end

  private

  def nilify_blanks
    self.username = username.presence
  end
end

⋯⋯以及在各種情境下操作時使用的 Form Model,如註冊時:

class User::AsRegister < ActiveType::Record[User]
  attribute :password, :string
  attribute :password_confirmation, :string

  validates :password, :password_confirmation, presence: true
  validate :password_and_confirmation_match

  before_save :set_encrypted_password
  after_save :send_welcome_email

  private

  def password_and_confirmation_match
    # ...
  end

  def send_welcome_email
    # ...
  end
end

更新個人檔案時:

class User::AsUpdateProfile < ActiveType::Record[User]
  validates :bio, presence: true
  # ...
end

或是後來增加的、詢問並確認使用者的聯絡資料正確時:

class User::AsUpdateSecurityInfo < ActiveType::Record[User]
  validates :email, :mobile, presence: true
  # ...
end

如果要整理一下 Core Model 跟 Form Model 分別該放什麼,大致上可以這麼說:

Core Model

  • 定義資料關聯 (associations)。
  • 最關鍵需要的 validators。
  • 通用來尋找或操作 model 的方法,例如 scope。

Form Model

  • 只在某一個情境下需要的 validators。
  • 虛擬屬性,例如某個可以用逗號分隔來輸入 tags,但實際上會用 HABTM 來儲存這些 tags 的欄位。
  • 只需要在某一個情境觸發的 callback,例如修改密碼後的安全通知信。

對於這些 Form Model,我們可以把它們用 namespace 整理到 Core Model 底下:

models
├── user.rb                   # User
├── user
│   ├── as_register.rb        # User::AsRegister
│   ├── as_update_profile.rb  # User::AsUpdateProfile
│   └── as_update_account.rb  # User::AsUpdateAccount
└── ...

當然需要的話,Form Model 的整理方式還是可以搭配 Concern 一起用:

module User::PasswordConfigurable
  extend ActiveSupport::Concern

  included do
    attribute :password, :string
    attribute :password_confirmation, :string

    validates :password, :password_confirmation, presence: true
    validate :password_and_confirmation_match

    before_save :set_encrypted_password
    after_save :send_welcome_email
  end

  private

  # ...
end

class User::AsRegister < ActiveType::Record[User]
  include PasswordConfigurable

  after_save :send_welcome_email

  # ...
end

class User::AsUpdateAccount < ActiveType::Record[User]
  include PasswordConfigurable
  # ...
end

Service Object

有些「不是一包欄位 + 表單驗證 + callback」的服務,就可以不用 Active Record/Active Model 的方式處理,而改用 Pain Ruby Object 來封裝、移出 model 就好。一些例子:

  • Note.dump_to_excel(path)Note::ExcelExport.save_to(path)
  • Project#changesProject::ChangeReport.new(project).changes
  • Invoice.to_pdfInvoice::PDFFenderer.render(invoice)

善用 Namespace 來組織 Models

隨著 Rails 專案的成長,如果我們把所有 Model 都攤平地放在 app/models 目錄下的話,乍看之下可能會變得有點恐怖:

app/models
├── badge.rb
├── board.rb
├── board_moderator.rb
├── board_post.rb
├── board_subscription.rb
├── collection.rb
├── collection_item.rb
├── comment.rb
├── comment_upvote.rb
├── post.rb
├── post_attachment.rb
├── post_edit_suggestion.rb
├── post_image_attachment.rb
├── post_link_attachment.rb
├── post_tag.rb
├── post_upvote.rb
├── private_message.rb
├── private_message_channel.rb
├── private_message_channel_user.rb
├── profile.rb
├── profile_badge.rb
├── read_later_item.rb
├── tag.rb
├── tag_subscription.rb
├── user.rb
└── user_follow.rb

經過 namespace 來將有密切關聯的 model 聚合在一起之後,可以變成這樣:

├── badge.rb
├── board.rb
├── board
│   ├── moderator.rb
│   ├── post.rb
│   └── subscription.rb
├── collection.rb
├── collection
│   └── item.rb
├── post.rb
├── post
│   ├── attachment.rb
│   ├── comment.rb
│   ├── comment
│   ├── edit_suggestion.rb
│   ├── image_attachment.rb
│   ├── link_attachment.rb
│   ├── tag.rb
│   └── upvote.rb
├── private_message.rb
├── private_message
│   ├── channel.rb
│   └── channel
├── tag.rb
├── tag
│   └── subscription.rb
├── user.rb
└── user
    ├── follow.rb
    ├── profile
    ├── profile.rb
    └── read_later_item.rb

一眼望下,就不難看出有哪些主要功能:

├── badge.rb
├── board.rb
├── board
├── collection.rb
├── collection
├── post.rb
├── post
├── private_message.rb
├── private_message
├── tag.rb
├── tag
├── user.rb
└── user

閱讀起來是不是輕鬆多了呢?


View/StyleSheets

Maintainable CSS is hard.
—— “Growing Rails Applications in Practice

本書介紹的,其實就是 BEM — Block Element Modifier 這個 CSS 架構方法。書中給了一些視角、範例和訣竅,不過礙於篇幅,在此就不贅述了,大家可以直接看書中的內容。


Stack

No Problem should ever have to be solved twice.
—— “The Hacker Attitude”, “How To Become A Hacker” by Eric Steven Raymond

接著是關於技術棧。在開發應用的時候,幾乎大家都同意不要重造輪子,而應該透過瞭解與使用現有技術工具來解決問題。但如果過度濫用工具,又容易把程式寫得分崩離析、難以維護。

You own your stack

在採用別人寫好的程式碼時,並不是輕鬆地把套件裝進專案中就沒事了。開發者需要對此付出責任——畢竟套件的作者一般來說並沒有責任幫你維護套件,因此一但安裝並使用別人寫的程式碼,它就是你的了。未來的維護、升級、安全性更新,都可能需要由使用者自己動手處理,甚至連套件所有相依的套件也包括在內。

安裝新的套件進專案是需要經過審慎考慮的。可以參考以下幾個考量點:

  • 程式碼品質
  • 有無自動化測試
  • 使用者多寡、issues 的回覆速度、是否還有被維護?
  • 自己有能力接手維護嗎?
  • 套件為專案提供的功能,和持續更新整合、甚至未來接手維護的代價是否相稱?

Think before accepting storage services into your stack

關於 app 背後的資料服務,其實也要經過評估。它帶來的便利性效能,是否可以消弭多出來的部署與維護成本?在初期我們可以不需要 Redis、ElasticSearch 或 Sidekiq,日後再移轉也不遲。

Try to use existing tools in your stack before adding new technology

舉例來說,與其部署 Redis(並增加新的維護成本),在效能不是問題的狀況先用一個新的 SQL table 來儲存 key-value 資料其實不是壞事。

Use service objects as API adaptor to swap to a new solution later

其實就是設計模式的 Adapter pattern。透過自己撰寫 API wrapper,並讓其他部分的程式使用自己封裝過後的 API,比起直接在專案內到處呼叫原始 API,不僅能有更佳的表達性,也能避免往後想換掉服務的困擾。

例如我們可以把原先使用 SQL LIKE 提供搜尋功能的 service object,直接抽換成使用 ElasticSearch 來實作,其他使用到搜尋功能的程式碼就完全不需要修改,甚至其他人也不會發現實作已經換掉了!(不過如果要設計出合邏輯又容易同時被 SQL LIKE 和 ElasticSearch 實作的 API wrapper,也需要同時認識需求、SQL 以及 ElasticSearch 才行,因此沒事多玩新技術對工作是有相當幫助的。)

Test new design patterns/techniques before using it

軟體是個知識更迭相當快的領域,不時就會有人提出新的方法、設計模式、框架來更有效率地完成工作。但是新潮的技術不見得會對所有的狀況來說都更好,與其一頭栽下並搞砸一切,可以選擇漸進的採用方式,例如:

  • 先寫一個獨立的專案來展示和驗證概念
  • 改寫既有程式的其中一個小部分,並比較差異
  • 逐漸嘗試把舊有程式翻新,並同時使用新方法來實作新功能

Tests

然後是寫了會安心,但想要足夠安全感又會覺得煩躁的自動化測試。

一般狀況下,效果最顯著的兩種測試會是單元測試以及 E2E 整合測試。

單元測試

  • 描述一個類別與他的方法的行爲
  • 可以當作是一種文件
  • 盡量涵蓋各種極端狀況

E2E 整合測試

  • 從使用者的角度出發
  • 確定使用流程與規格一致
  • 確定功能對使用者來說沒有壞掉,尤其是在修改或增加其他功能之後

另外,撰寫測試也可以將以下考量放在心上:

The Dark House Rule

想像整個 app 是一棟漆黑的房子,每個測試都是一盞可以照亮這㡖房子的燈,也許不需要做到燈火通明,但可以用亮度足以讓大家覺得舒服、沒有恐怖的漆黑角落作為標準。

TDD

透過 TDD(Test-Driven Development,測試驅動開發),可以讓開發者先以撰寫測試——也就會是使用、測試者的視角——來為介面做出更好的設計,是個可以被鼓勵採用的開發流程。


Upgrade of Rails

Gems increases the cost of upgrades

在 Gemfile 裡寫下 gem 'xxx' 是一個普遍可以加速完成功能的方式,但開發者需要意識到它是有代價的。如果 gem 的作者失去興趣或因為其他原因無法繼續維護更新 gem 的話,你願意接手維護它、或找其他替代方案把它更換掉嗎?

一般來說,我們會裝進 Rails app 的 gem 有兩種:提供 API 給我們呼叫的 library,和提供抽象化助力的 framework。性質越接近後者的通常代價越大,因為它和整個 app 的耦合關係會越高,容易在需要升級 Rails 時出現相容性問題,或在想要移除或抽換時會相當痛苦。

Instead of monkey-patching…

如果想要使用的 gem 差了一角無法滿足需求、或是有改進空間的話,與其在自己的 app 中覆蓋、改寫它,不如 fork 一份來修改並發個 PR 回去。除了能從中學習和做出貢獻之外,其實也可以借力於 gem 的作者和社群來繼續維護你的需要、不怕每次升級都要重 patch 一次。

Don’t upgrade to the bleeding edge

升級到最新的版本通常是不必要的,尤其是主版號的升級,等到次版號跳了兩到三版再升通常比較不會踩雷——如此一來也可以給各個 gem 的作者有時間讓自己的 gem 穩定一點地支援新版的 Rails。


總結

雖然有了令人幸福的 Ruby、方便的高階抽象化、高效率的 CoC(Convention over Configuration,不是 Code of Conduct),Rails 這個框架套餐本身給的導引,用來建構中大型網站是還會漸感乏力的。如果手上有逐漸長大的 Rails 網站、看到這篇整理也覺得鞭辟入裡的話,就快找這本書來讀吧!

祝大家的 Rails app 都能夠 live long and prosper。