Phoenix 與 Rails 有何不同

對(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 bc 呢?

可以定義一個(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é)有哪些呢?

  1. 持久化數(shù)據(jù) (model.save model.update 等)
  2. 展示數(shù)據(jù) (model.full_name model.to_json 等)
  3. 創(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_keysrequired_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) |&gt; 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é)不同的框架(思想)。

最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請(qǐng)聯(lián)系作者
平臺(tái)聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點(diǎn),簡(jiǎn)書(shū)系信息發(fā)布平臺(tái),僅提供信息存儲(chǔ)服務(wù)。

推薦閱讀更多精彩內(nèi)容

  • Spring Cloud為開(kāi)發(fā)人員提供了快速構(gòu)建分布式系統(tǒng)中一些常見(jiàn)模式的工具(例如配置管理,服務(wù)發(fā)現(xiàn),斷路器,智...
    卡卡羅2017閱讀 134,915評(píng)論 18 139
  • 用到的組件 1、通過(guò)CocoaPods安裝 2、第三方類(lèi)庫(kù)安裝 3、第三方服務(wù) 友盟社會(huì)化分享組件 友盟用戶反饋 ...
    SunnyLeong閱讀 14,688評(píng)論 1 180
  • Android 自定義View的各種姿勢(shì)1 Activity的顯示之ViewRootImpl詳解 Activity...
    passiontim閱讀 173,242評(píng)論 25 708
  • 學(xué)了幾節(jié)美術(shù)課的大寶,似乎愛(ài)上了畫(huà)畫(huà),繼續(xù)努力
    軒萌媽閱讀 157評(píng)論 0 1
  • 荀息是春秋晉國(guó)大夫,他向晉王請(qǐng)求,用寶馬、美玉向虞國(guó)借道,征伐虢國(guó)。晉王說(shuō),寶馬、美玉都是好東西啊。荀息說(shuō),向虞國(guó)...
    李煒微言閱讀 866評(píng)論 0 0