Phoenix 与 Rails 有何不同

本文关于 OO 和 FP 观点有根本性错误,OO 和 FP 并不是互斥的关系, 应该说与函数式编程对应的是指令式编程

对于不了解 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) |> 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 的结构更为松散,对需求变化的应对能力也更强。

再看看 View 层

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 来开发应用, 都应该学学不同的框架(思想)。