Phoenix 与 Rails 有何不同

对于不了解 Elixir 语言的同学,说到 Elixir,脑中的印象估计就是 “那个语法和 Ruby 很像的函数式编程语言“。
同样的,说起 Phoenix 框架,无非就是另一个 Rails-like 的 Web 框架。

不过我最近正好有机会同时使用这两种框架,感觉两者的差异还是非常大的,趁此机会总结一下。

两者同为 MVC 框架,但是 Ruby 是一门很彻底的面向对象的语言(当然它也有函数式的特性),而 Elixir 是纯函数式语言。
那么我们就来看看这两种编程范式在构建 MVC 应用时有哪些不同(所有对比都使用框架的默认配置)。

先看入口 Controller 吧

Controller 用于接收外部请求,并调用其他模块来完成业务逻辑,然后返回结果。
一般来说 Controller 中不要包含业务逻辑,要尽量简洁。
单个的 Controller 并没有什么好谈的,不过当它们组合起来时呢?

拿最常见的用户验证功能来说,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

不难发现两者在对 Controller 附加功能时策略的不同。
Rails 使用的是继承,通过在父类 Controller 中定义 before_action 方法来增加功能。

而 Phoenix 则是使用管道(pipeline)来组合功能的,route 的配置不仅仅是 http url 的映射,
也是功能的组合配置,有点像 Java 的 Spring 框架,通过配置文件来实现解藕。

那么如果有多个功能呢,假设有 a b c d 四个功能,而 project controller 只需要 a 和 c 两个功能。
那该如何实现呢?

Rails 可以这么写:

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 呢?

可以定义一个 BCController , 然后让 order_controller 继承 BCController

或者继承 ABCDController ,然后在 order_controller 中 skip 掉 d

继承需要子类了解自己父类的细节,视情况 skip 掉自己不需要的。
这在父类职责很多的情况下,会加重子类的负担。

这就是单继承的缺点。 Rails 使用这种方法自然也就继承了这个缺点。

至于 Phoenix 的写法就灵活多了。可以这么写:

      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 中统一配置,对于特殊情况也可以在具体 controller 中定义专门的 Plug, 这是单继承无法实现的。

Phoenix 的配置方法能让 controller 更符合单一职责原则,不需要关心权限验证,请求格式之类的功能,只需要专心处理业务逻辑的指派和结果的返回。

再来看看应用的核心 model 吧

Model 是一个应用的核心,理所当然的,这一层的职责也是最多的。

那么,Rails 中的 Model,它的职责有哪些呢?

  1. 持久化数据 (model.save model.update 等)
  2. 展示数据 (model.full_name model.to_json 等)
  3. 创建和查找数据 ( Model.find(id) Model.create(attrs) 等)

这些职责明显太多了,不符合单一职责原则。

这里就讲一个例子,Rails 中常见的 N + 1 query 问题。
表面上,出现 N + 1 问题会频繁访问数据库影响性能,但究其根本,就是职责的划分不明确,(访问数据库的功能和页面展示的功能混在一起)。

而 Phoenix 使用 Repo (仓储模式)分离了与数据库交互的相关职责,在 Phoenix 中,一个 model 就是内存中的一个数据结构,
无论怎么折腾,都不会和数据库产生关系,其实这也是纯函数式编程范式的一个“副作用”,model 无法“自己”进行数据库操作。

说到数据库,顺便提一下,分离职责后,测试也会变得更容易,想象一下,
如果 Rails 要测试一个 Model 的序列化结果,就必须先在测试数据库中新建一条测试记录, 然后才能对这个 model 进行测试,
无论要测试的功能是不是和数据库有关,这也是一种浪费。

另外 Rails 中的 Model 还肩负着 validation 的职责,它具有一定的 context 能力,
比如 on: :create on: :update ,新版的 Rails 还增加了 context 的声明。
不过这样的设计在我看来只会让 model 变得更大更乱,远不如 Phoenix 的组装式的 valiate 来的方便。

# 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

针对上面提到的三点,我们来试着用 phoenix “矫枉过正”一下, 因为 Elixir 是一门纯函数式的语言, 只有属性,没有方法。

持久化数据

前面提到了 Phoenix 中,Model 无法自己对自己进行持久化。
它的解决方法是引入 Repo 和 ChangeSet 所有持久化的操作,用 ChangeSet 进行验证和过滤,
再经由 Repo 保存到数据库,这个过程中可以用 ChangeSet 来对验证和过滤进行更细粒度的划分,
Repo 在有多种数据源的情况下也更灵活。

bid
|> cast(attrs, @permit_keys)
|> validate_required(@required_keys)
|> price_validation
|> product_expire_validation
|> apply_invoice_number
|> Repo.insert()

其中 permit_keysrequired_keys 都可以很容易的进行修改。

展示数据

这块内容会在下面的 View 层中细说。

创建和查找数据

Rails 中查找和创建数据都是调用的 Model 的类方法。虽然类和实例是不同的,但是代码都是写在类中的。。。
这也会导致 Model 的膨胀,虽然 ActiveRecord 的链式调用和 scope 等做法可以大幅减少编写的代码量,
但是从设计上说问题依旧,而且对链式调用的滥用也是对代码质量的巨大危害(很容易就会在 view 中调用很多数据库相关操作)。

而 Phoenix 中,还是通过 Repo 来创建和查找数据的。这样一来,所有的查找都可以放到一个统一的地方,不用都挤在 Model 中了。
相比 Rails 的链式调用,Phoenix 中可以通过组合 query 来达到类似的效果,并且最终还是需要通过 Repo 来访问数据库,
在分离职责的同时又保留了灵活性。

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)

总体来说,Phoenix 的结构更为松散,对需求变化的应对能力也更强。

再看看 V

View 层当然也是有逻辑的,比如现在有一个需求,已知用户在注册后(需要填写 email),可以选择进一步输入完整个人信息。
如果没有输入过个人信息,则在个人面板只能看到自己的 email 地址,有完整信息则显示全名。

Phoenix 有一个 View 层,其实 Rails 有一个对应的层叫 helper。
因为 Rails 的 helper 一般都是纯函数的实现,所以两者并没什么区别,
唯一的小问题就是 Rails 的 helper 方法在默认设置下会被全部引入,容易产生函数名重复的问题。

def display_name(user) do
  case user.profile do
    nil -> user.email
    _   -> user.profile.full_name
  end
end

View 方面两者相差不大,不过 Phoenix 的纯函数特性让强迫我们去使用 View 层,
而 Rails 虽然有同样功能的 helper,但是因为在 model 中写逻辑太方便了,反而容易造成职责的划分不清。

最后说说 Phoenix 1.3 引入的 Context 概念

在一个 Rails 应用中,在编写跨表(模型)的业务逻辑时,总会觉得没地方下手,
如果放在 model 中,那么本来就已经很臃肿的 model 就会更加膨胀,
如果放在 controller 或 view 中,这两个地方又明显不是存放大量业务逻辑代码的地方。
而 Phoenix 从 1.3 开始引入了 Context 这个概念来解决这个问题。

Context 是 Domain Drive Design 中的一个概念。简单来举个例子。
一个用户,可以登陆系统,可以对产品下单,这两个是相对独立的功能。
在登陆上下文中,不需要关注和订单相关的用户信息。
在下单时,虽然有要求用户必须先登陆,但那也是 controller 的职责,
对于订单模块而言,只需要知道是哪个用户要下单即可,并不需要知道 auth 的细节。

在 Rails 中,通常需要这样的代码来实现。

登陆:

user = User.find(email: xxx)
if user
  if user.authentication
    # login
  else
    # error
  end
else
  # error
end

下单:

current_user.orders.create(params)

这样的问题是,controller 需要知道太多的 model 的细节,登陆例子中处理了太多逻辑细节,用户不存在和用户存在但密码不对。下单例子中涉及到了 user 和 order 的数据结构的关系细节。不符合最少知识原则。

而 Phoenix 中,使用 Context,就可以减少 controller 与 model 之间的耦合。

Controller 只和 Context 有交集,不需要知道 model 的内部构造(user.orders)和接口(user.authication)。

总结

看到这里,一定有同学会说,Rails 中可以使用 Service 层,或者通过 concerns 等方法把逻辑抽取到其他地方。
我想说,首先,本文开头就说了我对两者的比较是基于默认配置下的,而且,如果你这么想了,那正是我所希望的,框架除了能方便开发者之外,更应该是一种最佳实战的学习,Rails 普及了 RESTful 和约定大于配置的思想。
那么 Phoenix 值得我们学习的就是对于逻辑的分离(职责的划分),不论你是否真的要用 Phoenix 来开发应用,都应该学学不同的框架(思想)。

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 158,117评论 4 360
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 66,963评论 1 290
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 107,897评论 0 240
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 43,805评论 0 203
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 52,208评论 3 286
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 40,535评论 1 216
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 31,797评论 2 311
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 30,493评论 0 197
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 34,215评论 1 241
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 30,477评论 2 244
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 31,988评论 1 258
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 28,325评论 2 252
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 32,971评论 3 235
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 26,055评论 0 8
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 26,807评论 0 194
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 35,544评论 2 271
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 35,455评论 2 266

推荐阅读更多精彩内容

  • Spring Cloud为开发人员提供了快速构建分布式系统中一些常见模式的工具(例如配置管理,服务发现,断路器,智...
    卡卡罗2017阅读 134,097评论 18 139
  • 用到的组件 1、通过CocoaPods安装 2、第三方类库安装 3、第三方服务 友盟社会化分享组件 友盟用户反馈 ...
    SunnyLeong阅读 14,494评论 1 180
  • Android 自定义View的各种姿势1 Activity的显示之ViewRootImpl详解 Activity...
    passiontim阅读 170,563评论 25 707
  • 学了几节美术课的大宝,似乎爱上了画画,继续努力
    轩萌妈阅读 135评论 0 1
  • 荀息是春秋晋国大夫,他向晋王请求,用宝马、美玉向虞国借道,征伐虢国。晋王说,宝马、美玉都是好东西啊。荀息说,向虞国...
    李炜微言阅读 784评论 0 0