Hanami - 軌道外的風景
前言
大家自助旅行時喜歡搭車還是自駕呢?有經驗的旅人會說「看情況」。熱門景點大多交通便利,雖然行程多少會受到路線和班次限制,但整體來說一般還是會因為各種方便,選擇搭乘大眾交通工具吧。那如果是要去比較特別的景點的話呢?或者是雖然是要去四通八達的地方,但帶了一家老小呢?你確定你真的要帶著父母(或小孩)和大包小包的行李,在沒有電梯的月台狂奔去趕兩小時才一班的車嗎?這種情況下,正常人都會勸你們跟團或自駕的。
專案開發其實也是類似的狀況。敝公司的讀者想必對 Ruby on Rails 都很熟悉;Rails 自 2005 年發展至今,除了框架自身已相當成熟之外,周邊更有完整的生態系和社群支撐。也因為如此,即便 Ruby 每年都被宣告死亡,在競爭激烈、兵貴神速的市場上,Rails 仍然是業界「快速開發」的首選。
「速度是非常強力的武器,充滿魅力又帥氣,但即便如此也不是無敵」排球少年第 290 話
然而,就是這個然而,Rails 雖然「各種方便」但絕非萬能。Rails 的哲學是以遵循慣例來換取開發速度,更重要的是市面上大部分的業務需求也確實都能在這個框架下得到很好的成效。但換個角度來說,當你的業務需求就是和所謂的 The Rails Way 無法順利配合,或者你真的就是不喜歡 Rails 包山包海的設計思維的話,Ruby 工程師有其他的選項嗎?
答案是有的!Ruby 社群其實一直都有類似的討論和專案,像是今年 Rubyconf Taiwan 就有勇者挑戰了筆者個人也滿喜歡的 Trailblazer(開拓者們建立鐵道的辛酸血淚史 by WenchuanLin),我們最近也有在客戶的 Rails 專案中引進 operators 的概念來整理商業邏輯。不過,今天要介紹的不是 Trailblazer,而是常和它一起被提到的輕量 MVC 框架,Hanami。
Hanami 原本的專案名稱是 Lotus,但後來為了避免和 IBM 的 Lotus 商標混淆就改名了
有用過 Lotus Notes 的老人請舉個手。Hanami 就是日文的「賞花」,除了和原本的蓮花相關,也帶了向源自日本的 Ruby 致敬的意義。
世界上沒有完美的技術解決方案,大多數時間我們都是在不同的問題中做取捨(trade-off)。就像自駕雖然給了旅人自由的移動手段,但取而代之的是你必須自己看地圖找路、注意行車安全,還需要掌握加油站、休息站、停車場資訊等,絕對不會比較輕鬆。
如果說 Rails 的特色是鋪好了一套包山包海的軌道,只要照著 The Rails Way 往前進就能到達目的地,那麼 Hanami 的賣點則正好相反:「The web, with simplicity. 」千萬別誤會,這裡說的 simplicity 指的是回歸「單純」,不是說開發上比較簡單(笑)大略整理一下 Hanami 的特色:
- 背後哲學是 Domain-Driven Design
- 尊崇 PORO(plain old ruby object),不對 Ruby core class/stdlib 做 monkey patch
AcitveSupport 表示 - 主張顯式優於隱含(explicit over implicit)
- 遵循單一功能原則(single responsibility principle)
- 不做 DB 層的 callback
雖然好像一直在重覆這點,不過 Hanami 和 Rails 真的是完全不同的哲學(笑)Hanami 重視的是專案長期的維護性,這一派的開發者大多認為長期來看黑魔法反而有害(例如框架在背後偷偷做了太多東西,最後卡來卡去互相傷害,像是最近這則 Rails issue#36844),因此在設計上採用了完全不同的作法。當然,也因為這樣,相較大家習慣的 Rails,Hanami 自然也是少了很多「開發上的便利性」,更無法否認的是它周圍的生態圈也並不完整不過這邊的心態大多是「這不需要用 gem 啊為什麼不自己刻就好」XD。
由於本文撰寫時 Hanami 的穩定版本為 1.3.3,但「據說」近期會釋出變動頗大的 2.0,為了避免短時間內就被改版的杯具,下面只會以官網提供的範例大致介紹一下現況。如果讀者有興趣的話,建議可以自己走一次官方教學文件體驗看看。雖然大家回到工作上應該還是繼續以寫 Rails 為主,但如果 Hanami 的思維能為各位帶來一些不同的刺激,那筆者寫這篇文章也算是有點意義了。
建立專案
$ gem install hanami
$ hanami new bookshelf
$ bundle install
$ hanami server
咦?怎麼好像有點眼熟?不過來看看 Hanami 幫我們建了哪些東西:
$ cd bookshelf
$ tree -L 1
.
├── Gemfile
├── README.md
├── Rakefile
├── apps
├── config
├── config.ru
├── db
├── lib
├── public
└── spec
乍看之下依然是 87 分相似,但其實已經出現重大分岐了。Hanami 的架構是將產品的核心(application core)和其對外介面(delivery mechanism)徹底分開;前者安排在 lib
目錄下:
$ tree lib
lib
├── bookshelf
│ ├── entities
│ ├── mailers
│ │ └── templates
│ └── repositories
└── bookshelf.rb
後者則以應用程式為單位,各自座落在 apps
目錄內,每個應用程式另有各自的 controller、view 等。預設的應用程式目錄為 web。
$ tree apps/web
apps/web
├── application.rb
├── assets
│ ├── favicon.ico
│ ├── images
│ ├── javascripts
│ └── stylesheets
├── config
│ └── routes.rb
├── controllers
├── templates
│ └── application.html.erb
└── views
└── application_layout.rb
Application Core
對照 Rails 就是 MVC 的 model 部分,自動產生的指令也非常眼熟:
$ hanami generate model book
create lib/bookshelf/entities/book.rb
create lib/bookshelf/repositories/book_repository.rb
create db/migrations/20170406230335_create_books.rb
create spec/bookshelf/entities/book_spec.rb
create spec/bookshelf/repositories/book_repository_spec.rb
Model => Entity & Repository
不過在 Hanami 裡並沒有 model
這個目錄,而是依 DDD 的概念,分成 entity
和 repository
兩部分。簡單來說,entity
是商業邏輯的核心,是用來表現持久層(persistance layer)資料的 immutable 物件。實際上和持久層互動的則是 repository
(和 git 那個無關)。假設我們已經建好 migration,實際操作起來大概是這種感覺:
$ hanami console
irb> repository = BookRepository.new
irb> book = repository.create(title: 'Hanami')
irb> book.title # => 'Hanami'
Hanami::Repository
提供了 create
, update
, delete
, all
, find
, first
, last
, clear
的介面。要注意的是,這些全都是 private 方法!這是因為在 Hanami 的思維裡,開發者不應該直接把整串的 query 曝露在外面,像是:
BookRepository.new.where(author_id: 23).order(:published_at).limit(8)
而是應該要寫出意圖明確的 API:
# lib/bookshelf/repositories/book_repository.rb
class BookRepository < Hanami::Repository
def most_recent_by_author(author, limit: 8)
books
.where(author_id: author.id)
.order(:published_at)
.limit(limit)
end
end
除了程式意圖明確之外,這樣做的好處還有方便做孤立測試,也可以避免程式和底層資料庫的耦合。這部分的實做是以 ROM 和 Sequel 為基礎,有興趣的話可以再自行研究。
另外比較特別的是,由於 Hanami 的 validation 和 Rails 的作法有根本上的不同,entity
層是完全不處理 validation 的(後述)。
Delivery Mechanism
在 Hanami 的架構中,業務邏輯是共用的(集中在上述的 lib
裡),但業務邏輯的應用可以有很多種,例如網站(apps/web
)、管理介面(apps/admin
)、對外 API(apps/api
)等。每個對外介面有自己的目錄,裡面有各自的 controller 和 view 等內容。如果用 Rails 的說法,等於是從設計上就鼓勵你把東西拆成 engine 來做。
Controller => Action
指令開始和 Rails 不太一樣了!由於同一個專案內可以有多個應用程式,因此需要在 generator 中指定。另外要注意的是,參數是 action
不是 controller
(但檔案是建在 controllers
目錄裡):
$ hanami generate action web dashboard#index
create apps/web/controllers/dashboard/index.rb
create apps/web/views/dashboard/index.rb
create apps/web/templates/dashboard/index.html.erb
create spec/web/controllers/dashboard/index_spec.rb
create spec/web/views/dashboard/index_spec.rb
insert apps/web/config/routes.rb
相對於 Rails 是習慣把 RESTful 的七大神器都寫在同一個 controller 上,Hanami 的作法是把每個動作(action
)拆成單獨的檔案,大概是長這樣:
# apps/web/controllers/dashboard/index.rb
module Web
module Controllers
module Dashboard
class Index
include Web::Action
expose :greeting
def call(params)
@greeting = "Hello"
end
end
end
end
end
這裡的 convention 是每個 action 都要實做 call(params)
的方法,再用 expose
將值開放給 view/template 使用。而 Hanami 將每個 action 拆開來,最大的好處就是方便測試,像是可以輕鬆模擬 request 的 header 和 params:
# spec/web/controllers/users/show_spec.rb
require_relative '../../../../apps/web/controllers/users/show'
RSpec.describe Web::Controllers::Users::Show do
let(:action) { Web::Controllers::Users::Show.new }
let(:format) { 'application/json' }
let(:user_id) { '23' }
it "is successful" do
response = action.call(id: user_id, 'HTTP_ACCEPT' => format)
expect(response[0]).to eq(200)
expect(response[1]['Content-Type']).to eq("#{ format }; charset=utf-8")
expect(response[2]).to eq(["ID: #{ user_id }"])
end
end
View => View & Template
相形下之 view 還滿單純的;有仔細看 generator 的產出的話,會發現 Hanami 也同時幫我們產生好了 views
和 templates
的檔案。其中 view
是負責把 template
render 出來的物件,前者大致可以用 decorator 來想像,後者就是等同 Rails 的 view(Hanami 支援包括 erb、slim、haml 在內的各種常見 template engine)。請注意範例中使用了在 controller 中被 expose
開放的值:
# apps/web/views/dashboard/index.rb
module Web
module View
module Dashboard
class Index
include Web::View
def welcome_message
greeting + " and welcome"
end
end
end
end
end
# app/web/templates/dashboard/index.html.haml
%h1 Dashboard
%p= welcome_message
表單
Hanami 本身提供了內建的 form builder,支援所有 HTML5 的表單元件。其最大特色在於「form builder 語法不因 template engine 不同而改變」:
# erb
<%=
form_for :book, routes.books_path do
div do
label 'Title'
text_field :title
end
submit 'Create'
end
%>
# 產出以下 html
<form action="/books" id="book-form" method="POST">
<div>
<label>Title</label>
<input type="text" name="book[title]" id="book-id" value="">
</div>
<button type="submit">Create</button>
</form>
理想上是要能做到像 erb 那樣,一整段可以直接覆製貼上的 block,不過目前 haml/slim 的支援還有點美中不足,需要自己補上前綴記號:
# haml/slim
= form_for: book, routes.books_path do
- div do
- label 'Title'
- text_field :title
- submit 'Create'
雖然也是可以在 haml/slim 裡寫 inline ruby 再去輸出,不過那樣的話還不如直接寫在view
裡算了:
# apps/web/views/books/new.rb
module Web
module Views
module Books
class New
include Web::View
def form
form_for :book, routes.books_path do
text_field :title
submit 'Create'
end
end
end
end
end
end
# app/web/templates/books/new.html.haml
= form
不過筆者個人的哲學是 view
不該處理 HTML(反之,template
不該處理邏輯),不管上面哪種寫法心裡都有點抗拒,所以還是希望這點在 Hanami 2.0 裡能有所改善。
資料驗證
前面提到 Hanami 和 Rails 在資料驗證上有根本的不同。請回想一下:Rails 的作法是先把物件生出來,再對物件去做驗證。這固然有它方便的地方,但隱藏的風險是我們會產生出很多「有問題的物件」。Hanami 的作法則是先針對 params 做驗證,驗證成功才會產生物件,等於是從源頭迴避掉上述的問題。
除此之外更不一樣的是,在 Hanami 的思維中,「對 params 的驗證」和「寫入持久層時的驗證」這兩件事也是分開的!這也是為什麼 entity
本身不會去做資料驗證(因為先驗證成功了才會有 entity
),Hanami::Validations
也故意沒有提供常見的 uniqueness 驗證,而是鼓勵開發者自己去處理 database constraint error。
話是這麼說,但具體該怎麼實做似乎還沒有結論,然後 Hanami 作者本人是建議寫在 interactor 上。Interator 的實做請參考官方文件。
那麼簡單看一下驗證的範例:
# apps/web/controllers/signup/create.rb
module Web
module Controllers
module Signup
class Create
include Web::Action
params do
required(:name).filled(:str?)
required(:email).filled(:str?, format?: /@/).confirmation
required(:password).filled(:str?).confirmation
end
def call(params)
if params.valid?
# ...
else
# ...
end
end
end
end
end
end
不過要講 Hanami 的 validation,就必須來看它底層的 dry-validation。dry-rb 也是筆者一直在關心的專案,其中最喜歡的正好就是 dry-validation 所以剛好順便推坑!dry-rb 的 gem 都可以單獨運作,手上如果有 Rails 的 side project 的話可以拿去玩看看,相信會再也不想碰 ActiveRecord validation 感到身心舒暢,大致看幾個範例就好:
# params 一定要有 :name,需要有值,值需為字串,且長度在 3~10 之間
required(:name).filled(:str?, :size?(3..10))
# 也可以寫成 block
required(:name) { filled? & str? & size?(3..10) }
# params 不一定要包括 :name,如果有送的話也不一定要有值,但若有值的話,需要是字串
optional(:name).maybe(:str?)
# params 一定要有 :age,需要有值,值需為整數,且需要大於 18
required(:age).filled(:int?, :gt?(18))
當然也可以自建規則(predicates):
require 'hanami/validations'
class Signup
include Hanami::Validations
predicate :url?, message: 'must be an URL' do |current|
# ...
end
validations do
required(:website) { url? }
end
end
總之是個寫起來很開心的工具,介面很乾淨,誠心推薦大家試用 XD
周邊生態系
這塊是 Rails 的無敵強項,天下武功唯快不破,而 Rails 的神速正是建立在這個豐厚的生態系之上。年輕的 Hanami 顯然還在努力中,有興趣的讀者可以自行參考官方團隊推薦的套件,基本的東西像是前端套件、資料分頁、檔案上傳、身份驗證等也都是有的。當然如果你有心投入開源技術開發的話,這裡還是一片藍海等你來貢獻唷~
小結
如果你只是在寫單純的 CRUD 網站的話,Hanami 不一定會是最適合的工具。但請想像一下:今天有個功能,資料流有至少三個入口,需要一次處理超過四層以上的巢狀資料,隨便就會影響到五、六張資料表,有錯綜複雜的資料驗證規則和上線前才知道的各種特例,最後還要視情境同步到不同資料表或打外部 API 並持續追蹤狀態…不要說不可能,以上是血淚實例(顯示為對著 ActiveRecord 痛哭)
商業需求總是瞬息萬變,認真的專案做到最後一定會超出一般的 CRUD 的範籌。這種時候,從設計上就鼓勵甚至是強迫開發者要「把東西拆小,使之不互相影響,方便測試和串連」的 Hanami 架構,對「專案的長期可維護性」是很有價值的。
最後也誠心推薦大家可以看一下 Hanami 作者 @jodosha 今年的演講,裡面提到的 FP/OOP callable object 概念非常值得深思,也是貫穿 Hanami 運作方式的核心。基於這個想法而串連在一起的 Hanami、ROM、dry-rb 等專案今後會對 Ruby 帶來哪些改變,也非常令人期待!
參考資料
- Hanami Guides:官方教學文件
- Awesome Hanami:周邊生態系
- Hanami Cookbook
- Rubyist Magazine: HanamiはRubyの救世主(メシア)となるか、愚かな星と散るのか
- What I learned building an app in Hanami
- Rails vs Hanami
- Controllers: the Rails way vs the Hanami way
- RottonSoftware: Validating records in Hanami
- RottonSoftware: Hanami - Entities and Repositories
- Invalid Object Is An Anti-Pattern
- Luca Guidi - Hanami 2.0 - rubyday 2019
- Hanami 2.0 demo app