對(duì)于不了解 Elixir 語(yǔ)言的同學(xué),說(shuō)到 Elixir,腦中的印象估計(jì)就是 “那個(gè)語(yǔ)法和 Ruby 很像的函數(shù)式編程語(yǔ)言“。
同樣的,說(shuō)起 Phoenix 框架,無(wú)非就是另一個(gè) Rails-like 的 Web 框架。
不過(guò)我最近正好有機(jī)會(huì)同時(shí)使用這兩種框架,感覺(jué)兩者的差異還是非常大的,趁此機(jī)會(huì)總結(jié)一下。
兩者同為 MVC 框架,但是 Ruby 是一門(mén)很徹底的面向?qū)ο蟮恼Z(yǔ)言(當(dāng)然它也有函數(shù)式的特性),而 Elixir 是純函數(shù)式語(yǔ)言。
那么我們就來(lái)看看這兩種編程范式在構(gòu)建 MVC 應(yīng)用時(shí)有哪些不同(所有對(duì)比都使用框架的默認(rèn)配置)。
先看入口 Controller 吧
Controller 用于接收外部請(qǐng)求,并調(diào)用其他模塊來(lái)完成業(yè)務(wù)邏輯,然后返回結(jié)果。
一般來(lái)說(shuō) Controller 中不要包含業(yè)務(wù)邏輯,要盡量簡(jiǎn)潔。
單個(gè)的 Controller 并沒(méi)有什么好談的,不過(guò)當(dāng)它們組合起來(lái)時(shí)呢?
拿最常見(jiàn)的用戶驗(yàn)證功能來(lái)說(shuō),Rails 是這樣做的
class ProjectsController < ApplicationController
def index
# ...
end
end
class ApplicationController < ActionController::Base
before_action :authenticate
private
def authenticate
# use devise or implement it by yourself
end
end
defmodule AuctionWeb.Admin.ProjectController do
use AuctionWeb, :controller
plug Guardian.Plug.EnsureResource, handler: AuctionWeb.Admin.AuthErrorHandler
def index(conn, _params) do
# ...
end
end
pipeline :unauthorized do
plug :fetch_session
end
pipeline :authorized do
plug :fetch_session
# use guarian or implement auth plug by yourself
plug Guardian.Plug.Pipeline, module: Auction.Guardian,
error_handler: Auction.AuthErrorHandler
plug Guardian.Plug.VerifySession
plug Guardian.Plug.LoadResource
end
scope "/", AuctionWeb do
pipe_throught :unauthorized
post "/sign-in", UserController, :sign_in
# other pages that no login needed
end
scope "/", AuctionWeb do
pipe_throught :authorized
get "/projects", ProjectController, :index
# other pages must login first
end
不難發(fā)現(xiàn)兩者在對(duì) Controller 附加功能時(shí)策略的不同。
Rails 使用的是繼承,通過(guò)在父類(lèi) Controller 中定義 before_action
方法來(lái)增加功能。
而 Phoenix 則是使用管道(pipeline)來(lái)組合功能的,route 的配置不僅僅是 http url 的映射,
也是功能的組合配置,有點(diǎn)像 Java 的 Spring 框架,通過(guò)配置文件來(lái)實(shí)現(xiàn)解藕。
那么如果有多個(gè)功能呢,假設(shè)有 a b c d 四個(gè)功能,而 project controller 只需要 a 和 c 兩個(gè)功能。
那該如何實(shí)現(xiàn)呢?
Rails 可以這么寫(xiě):
class ProjectsController < ApplicationController
skip_before_action :function_b, :function_d
def index
# ...
end
end
class ApplicationController < ActionController::Base
before_action :function_a, :function_b, :function_c, :function_d
private
# define function a b c d
end
或者
class ProjectsController < ACController
def index
# ...
end
end
class ACController < ActionController::Base
before_action :function_a, :function_c
private
# define function a c
end
那么如果 order_controller
需要方法 a
b
和 c
呢?
可以定義一個(gè) BCController
, 然后讓 order_controller
繼承 BCController
或者繼承 ABCDController
,然后在 order_controller
中 skip 掉 d
繼承需要子類(lèi)了解自己父類(lèi)的細(xì)節(jié),視情況 skip 掉自己不需要的。
這在父類(lèi)職責(zé)很多的情況下,會(huì)加重子類(lèi)的負(fù)擔(dān)。
這就是單繼承的缺點(diǎn)。 Rails 使用這種方法自然也就繼承了這個(gè)缺點(diǎn)。
至于 Phoenix 的寫(xiě)法就靈活多了。可以這么寫(xiě):
pipeline :ac do
# plug a
# plug c
end
pipeline :abc do
# plug a
# plug b
# plug c
end
scope "/", AuctionWeb do
pipe_throught :ac
# project
end
scope "/", AuctionWeb do
pipe_throught :abc
# order
end
或者
defmodule AuctionWeb.Admin.ProjectController do
use AuctionWeb, :controller
plug Plug.A
plug Plug.C
def index(conn, _params) do
# ...
end
end
defmodule AuctionWeb.Admin.OrderController do
use AuctionWeb, :controller
plug Plug.A
plug Plug.B
plug Plug.C
def index(conn, _params) do
# ...
end
end
非常靈活,通用的情況可以在 routes 中統(tǒng)一配置,對(duì)于特殊情況也可以在具體 controller 中定義專(zhuān)門(mén)的 Plug, 這是單繼承無(wú)法實(shí)現(xiàn)的。
Phoenix 的配置方法能讓 controller 更符合單一職責(zé)原則,不需要關(guān)心權(quán)限驗(yàn)證,請(qǐng)求格式之類(lèi)的功能,只需要專(zhuān)心處理業(yè)務(wù)邏輯的指派和結(jié)果的返回。
再來(lái)看看應(yīng)用的核心 model 吧
Model 是一個(gè)應(yīng)用的核心,理所當(dāng)然的,這一層的職責(zé)也是最多的。
那么,Rails 中的 Model,它的職責(zé)有哪些呢?
- 持久化數(shù)據(jù) (
model.save
model.update
等) - 展示數(shù)據(jù) (
model.full_name
model.to_json
等) - 創(chuàng)建和查找數(shù)據(jù) (
Model.find(id)
Model.create(attrs)
等)
這些職責(zé)明顯太多了,不符合單一職責(zé)原則。
這里就講一個(gè)例子,Rails 中常見(jiàn)的 N + 1 query 問(wèn)題。
表面上,出現(xiàn) N + 1 問(wèn)題會(huì)頻繁訪問(wèn)數(shù)據(jù)庫(kù)影響性能,但究其根本,就是職責(zé)的劃分不明確,(訪問(wèn)數(shù)據(jù)庫(kù)的功能和頁(yè)面展示的功能混在一起)。
而 Phoenix 使用 Repo (倉(cāng)儲(chǔ)模式)分離了與數(shù)據(jù)庫(kù)交互的相關(guān)職責(zé),在 Phoenix 中,一個(gè) model 就是內(nèi)存中的一個(gè)數(shù)據(jù)結(jié)構(gòu),
無(wú)論怎么折騰,都不會(huì)和數(shù)據(jù)庫(kù)產(chǎn)生關(guān)系,其實(shí)這也是純函數(shù)式編程范式的一個(gè)“副作用”,model 無(wú)法“自己”進(jìn)行數(shù)據(jù)庫(kù)操作。
說(shuō)到數(shù)據(jù)庫(kù),順便提一下,分離職責(zé)后,測(cè)試也會(huì)變得更容易,想象一下,
如果 Rails 要測(cè)試一個(gè) Model 的序列化結(jié)果,就必須先在測(cè)試數(shù)據(jù)庫(kù)中新建一條測(cè)試記錄, 然后才能對(duì)這個(gè) model 進(jìn)行測(cè)試,
無(wú)論要測(cè)試的功能是不是和數(shù)據(jù)庫(kù)有關(guān),這也是一種浪費(fèi)。
另外 Rails 中的 Model 還肩負(fù)著 validation 的職責(zé),它具有一定的 context 能力,
比如 on: :create
on: :update
,新版的 Rails 還增加了 context 的聲明。
不過(guò)這樣的設(shè)計(jì)在我看來(lái)只會(huì)讓 model 變得更大更亂,遠(yuǎn)不如 Phoenix 的組裝式的 valiate 來(lái)的方便。
# user can only change name
def user_changeset(%Category{} = category, params \\ %{}) do
category
|> cast(params, [:name])
|> validate_required([:name])
end
# admin can change name and priority
def admin_changeset(%Category{} = category, params \\ %{}) do
category
|> cast(params, [:name, :priority])
|> validate_required([:name])
end
針對(duì)上面提到的三點(diǎn),我們來(lái)試著用 phoenix “矯枉過(guò)正”一下, 因?yàn)?Elixir 是一門(mén)純函數(shù)式的語(yǔ)言, 只有屬性,沒(méi)有方法。
持久化數(shù)據(jù)
前面提到了 Phoenix 中,Model 無(wú)法自己對(duì)自己進(jìn)行持久化。
它的解決方法是引入 Repo 和 ChangeSet 所有持久化的操作,用 ChangeSet 進(jìn)行驗(yàn)證和過(guò)濾,
再經(jīng)由 Repo 保存到數(shù)據(jù)庫(kù),這個(gè)過(guò)程中可以用 ChangeSet 來(lái)對(duì)驗(yàn)證和過(guò)濾進(jìn)行更細(xì)粒度的劃分,
Repo 在有多種數(shù)據(jù)源的情況下也更靈活。
bid
|> cast(attrs, @permit_keys)
|> validate_required(@required_keys)
|> price_validation
|> product_expire_validation
|> apply_invoice_number
|> Repo.insert()
其中 permit_keys
和 required_keys
都可以很容易的進(jìn)行修改。
展示數(shù)據(jù)
這塊內(nèi)容會(huì)在下面的 View 層中細(xì)說(shuō)。
創(chuàng)建和查找數(shù)據(jù)
Rails 中查找和創(chuàng)建數(shù)據(jù)都是調(diào)用的 Model 的類(lèi)方法。雖然類(lèi)和實(shí)例是不同的,但是代碼都是寫(xiě)在類(lèi)中的。。。
這也會(huì)導(dǎo)致 Model 的膨脹,雖然 ActiveRecord 的鏈?zhǔn)秸{(diào)用和 scope 等做法可以大幅減少編寫(xiě)的代碼量,
但是從設(shè)計(jì)上說(shuō)問(wèn)題依舊,而且對(duì)鏈?zhǔn)秸{(diào)用的濫用也是對(duì)代碼質(zhì)量的巨大危害(很容易就會(huì)在 view 中調(diào)用很多數(shù)據(jù)庫(kù)相關(guān)操作)。
而 Phoenix 中,還是通過(guò) Repo 來(lái)創(chuàng)建和查找數(shù)據(jù)的。這樣一來(lái),所有的查找都可以放到一個(gè)統(tǒng)一的地方,不用都擠在 Model 中了。
相比 Rails 的鏈?zhǔn)秸{(diào)用,Phoenix 中可以通過(guò)組合 query 來(lái)達(dá)到類(lèi)似的效果,并且最終還是需要通過(guò) Repo 來(lái)訪問(wèn)數(shù)據(jù)庫(kù),
在分離職責(zé)的同時(shí)又保留了靈活性。
def products_by(user) do
from p in Product,
where: p.user_id == ^user.id,
order_by: [desc: :expire_at]
end
def pending_products_by(user) do
products_by(user) |> where([p], p.expire_at <= ^DateTime.utc_now)
end
def successful_products_by(user) do
from p in products_by(user),
where: p.expire_at >= ^DateTime.utc_now
end
Repo.query(xxx)
總體來(lái)說(shuō),Phoenix 的結(jié)構(gòu)更為松散,對(duì)需求變化的應(yīng)對(duì)能力也更強(qiáng)。
再看看 V
View 層當(dāng)然也是有邏輯的,比如現(xiàn)在有一個(gè)需求,已知用戶在注冊(cè)后(需要填寫(xiě) email),可以選擇進(jìn)一步輸入完整個(gè)人信息。
如果沒(méi)有輸入過(guò)個(gè)人信息,則在個(gè)人面板只能看到自己的 email 地址,有完整信息則顯示全名。
Phoenix 有一個(gè) View 層,其實(shí) Rails 有一個(gè)對(duì)應(yīng)的層叫 helper。
因?yàn)?Rails 的 helper 一般都是純函數(shù)的實(shí)現(xiàn),所以兩者并沒(méi)什么區(qū)別,
唯一的小問(wèn)題就是 Rails 的 helper 方法在默認(rèn)設(shè)置下會(huì)被全部引入,容易產(chǎn)生函數(shù)名重復(fù)的問(wèn)題。
def display_name(user) do
case user.profile do
nil -> user.email
_ -> user.profile.full_name
end
end
View 方面兩者相差不大,不過(guò) Phoenix 的純函數(shù)特性讓強(qiáng)迫我們?nèi)ナ褂?View 層,
而 Rails 雖然有同樣功能的 helper,但是因?yàn)樵?model 中寫(xiě)邏輯太方便了,反而容易造成職責(zé)的劃分不清。
最后說(shuō)說(shuō) Phoenix 1.3 引入的 Context 概念
在一個(gè) Rails 應(yīng)用中,在編寫(xiě)跨表(模型)的業(yè)務(wù)邏輯時(shí),總會(huì)覺(jué)得沒(méi)地方下手,
如果放在 model 中,那么本來(lái)就已經(jīng)很臃腫的 model 就會(huì)更加膨脹,
如果放在 controller 或 view 中,這兩個(gè)地方又明顯不是存放大量業(yè)務(wù)邏輯代碼的地方。
而 Phoenix 從 1.3 開(kāi)始引入了 Context 這個(gè)概念來(lái)解決這個(gè)問(wèn)題。
Context 是 Domain Drive Design 中的一個(gè)概念。簡(jiǎn)單來(lái)舉個(gè)例子。
一個(gè)用戶,可以登陸系統(tǒng),可以對(duì)產(chǎn)品下單,這兩個(gè)是相對(duì)獨(dú)立的功能。
在登陸上下文中,不需要關(guān)注和訂單相關(guān)的用戶信息。
在下單時(shí),雖然有要求用戶必須先登陸,但那也是 controller 的職責(zé),
對(duì)于訂單模塊而言,只需要知道是哪個(gè)用戶要下單即可,并不需要知道 auth 的細(xì)節(jié)。
在 Rails 中,通常需要這樣的代碼來(lái)實(shí)現(xiàn)。
登陸:
user = User.find(email: xxx)
if user
if user.authentication
# login
else
# error
end
else
# error
end
下單:
current_user.orders.create(params)
這樣的問(wèn)題是,controller 需要知道太多的 model 的細(xì)節(jié),登陸例子中處理了太多邏輯細(xì)節(jié),用戶不存在和用戶存在但密碼不對(duì)。下單例子中涉及到了 user 和 order 的數(shù)據(jù)結(jié)構(gòu)的關(guān)系細(xì)節(jié)。不符合最少知識(shí)原則。
而 Phoenix 中,使用 Context,就可以減少 controller 與 model 之間的耦合。
Controller 只和 Context 有交集,不需要知道 model 的內(nèi)部構(gòu)造(user.orders)和接口(user.authication)。
總結(jié)
看到這里,一定有同學(xué)會(huì)說(shuō),Rails 中可以使用 Service 層,或者通過(guò) concerns 等方法把邏輯抽取到其他地方。
我想說(shuō),首先,本文開(kāi)頭就說(shuō)了我對(duì)兩者的比較是基于默認(rèn)配置下的,而且,如果你這么想了,那正是我所希望的,框架除了能方便開(kāi)發(fā)者之外,更應(yīng)該是一種最佳實(shí)戰(zhàn)的學(xué)習(xí),Rails 普及了 RESTful 和約定大于配置的思想。
那么 Phoenix 值得我們學(xué)習(xí)的就是對(duì)于邏輯的分離(職責(zé)的劃分),不論你是否真的要用 Phoenix 來(lái)開(kāi)發(fā)應(yīng)用,都應(yīng)該學(xué)學(xué)不同的框架(思想)。