五倍紅寶石・專業程式教育

五倍紅寶石 ・專業程式教育機構

《Growing Rails Applications in Practice》 重點整理

By 張博凱・12月 31 2019・技術文章
《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 專案中通用的架構方法和共識。

post-《Growing Rails Applications in Practice》 重點整理

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

post-《Growing Rails Applications in Practice》 重點整理

本書大綱

原書中共分為三個部分:〈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 相關的要點整理:

post-《Growing Rails Applications in Practice》 重點整理

不要放邏輯

不要把任何業務邏輯放在 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,也就是世界觀一段所比喻的「表單」,是個讓使用者完成特定動作的主要角色,它的主要工作包括了驗證使用者輸入的資料、在有問題的欄位上加上錯誤訊息、以及在其生命週期完成各種附帶動作等。

post-《Growing Rails Applications in Practice》 重點整理

準則:遵循 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"

post-《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

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

post-《Growing Rails Applications in Practice》 重點整理

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

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

post-《Growing Rails Applications in Practice》 重點整理

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

單元測試

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

E2E 整合測試

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

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

The Dark House Rule

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

TDD

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


Upgrade of Rails

post-《Growing Rails Applications in Practice》 重點整理

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。

post-《Growing Rails Applications in Practice》 重點整理


👩‍🏫 課務小幫手:

✨ 想掌握 Ruby on Rails 觀念和原理嗎?

我們有開設 🏓 Ruby on Rails 實戰課程 課程唷 ❤️️