文章目录
- 前言
- Phoenix的web层概念
- Plug
- Endpoint
- Router
- Scope
- Pipeline
- Controller
- Action
- Component
- 一次请求
前言
Elixir和Phoenix的作者也是Rails社区的核心开发者,如果是之前接触过Ruby on Rails的开发者,对Phoenix也许不会感到太陌生。笔者没有接触过Ruby on Rails,只能从Go语言的经验和角度出发去对比理解。如果说一开始Phoenix的确脱胎于Rails,经过这么多版本的迭代,加上Elixir的语法特点,Phoenix也一定产生了一些特有的变化。上一期我们学习了Phoenix入门篇,书接上回,这次我们重点介绍下Phoenix框架中的一些概念,并继续探索它的架构设计思想。希望本文能帮助你开始Phoenix开发之旅,即便最后你决定不适用Phoenix,它的架构思想也一定会对你有所启发。
首先我们来思考两个问题:
- 网络框架要解决的核心问题是什么?
- 框架的核心价值是什么?
相信每个人对这两个问题都有不同的见解,下面也仅仅是我个人对这两个问题一些看法。
我们可以先简单的认为网络服务就是在反复做一件事:接收请求,返回响应。所谓请求就是一个网络资源,它由一个网络路径标识。所以网络框架要解决的核心问题就是决定不同的路径应该由哪个函数来发出响应,其实也就是要解决路由的问题。这一点Go标准库就做的十分纯粹,所以用Go标准库来写一些小型的后端服务是非常简单惬意的。
那么纯粹的代价是什么呢,这就是网络框架的核心价值问题。框架的核心价值是效率,包括开发效率和维护成本。懂事的框架会帮你做许多琐碎的事,让你可以专注于业务逻辑。比如日志,监控等这些虽然不是核心功能,但是对于一个线上系统来说又必不可少。其次框架会为你提供一套行为规范,在你的项目越来越大时,不会陷入混乱而不得不重构。代码生成也是一个好东西,可以节省不少时间。此外,框架的复杂程度和学习成本也值得考虑,如果对于一个简单的服务,也必须要先看完几百页的说明书才能把服务跑起来,那么就要好好考虑一下这几百页的说明书值不值得看了。
另外扯一点题外话,不要提前解决还未遇到的问题。不知道大家在看到网上哪些大谈架构或者起着牛逼哄哄标题的文章时,会不会产生技术焦虑。事实上部分技术文章并不那么纯粹,都是带着引流目的的。制造焦虑也是一种营销手段,倒不是说不应该去学习,然而我们首先要理解的是问题而不是技术本身。框架也好,架构也好,什么也好,它们存在的意义是解决问题,而不是带来问题,或者说它们能解决的问题远大于其带来的问题。古人云:任何事情都是有代价的。理解了问题才能对阵下药,切勿乱投医。
言归正传,在继续阅读之前,希望你已经阅读过了Phoenix入门篇。需要说明的是,本篇主要是介绍相关概念,多是以生成项目时的初始代码为例,也不会深入代码细节,与写代码相关的细节我会在稍后的实战篇中介绍。
Phoenix的web层概念
Phoenix在v1.3版本对目录结构做了一次大的调整,有了我们今天看到的结构。经过这次调整之后,业务逻辑和web接口彻底分离。我们不再是开发一个以web为核心的应用,而是开发一个具有web接口的系统。这有很么区别呢?在以前端为核心的应用中,我们很容易从前端出发去思考后端业务逻辑,导致边界逐渐模糊,逻辑慢慢往controller里面移动。但实际上我们应该首先考虑业务逻辑,基于此来提web接口。
Phoenix也是一个MVC的框架,它把MVC中的V和C抽到了web层,构成了应用的web接口,对应着 lib/hello_web
目录, hello
是你的项目名,我们还是沿用Phoenix入门篇中的示例。
在web接口中,Phoenix抽象出了plug,endpoint,router,scope,pipeline,controller,action,component等概念,它们的整体关系如下图所示。
Plug
Plug贯穿了整个Phoenix的web接口,我们可以在web层的各个地方看到plug的身影。可以说Plug是Phoenix的核心,理解Plug是理解Phoenix框架的关键。Plug既是一个抽象概念,也有对应实体,我们需要区分一下不同地方出现的plug的含义。
作为抽象概念,Plug是一个连接转换器,它接受一个连接,经过一些处理,最后返回一个连接。然后这个连接还可以交给下一个Plug继续处理,构成一个连接转换的链条,这正是Phoenix处理请求的核心机制。听起来有点像中间件,虽然Phoenix的中间件也是通过Plug机制实现的,但是Plug本身不同于我们所熟知的中间件概念,这一点一定要分清。这里的“连接”指的是客户端连接,用 %Plug.conn{}
结构体表示。你可以把它想象成糖葫芦,连接就是竹签,Plug就是竹签上的山楂。
Plug可以是一个函数,也可以是一个模块。
如果是函数Plug,第一个参数一定是 %Plug.conn{}
结构体,第二个参数取决于应用Plug时传递的内容。第一个参数是Phoenix自动传入的,稍后我们会介绍第二个参数是如何传递的。最后函数一定要返回一个 %Plug.conn{}
结构体。举个例子:
def func_plug(conn, _opts) do# do somethingconn
end
如果是模块Plug,需要定义两个函数: init/1
和 call/2
。 call/2
的函数签名和上面的函数Plug是一样的,第一个参数是 %Plug.conn{}
,由Phoenix自动传入;第二个参数是 init/1
函数的返回值,也是由Phoenix自动传入。 init/1
入参的传递方式和上面的函数Plug的第二个参数的传递方式是一样的,稍后会介绍。举个例子:
defmodule HelloWeb.Plugs.ModulePlug do import Plug.Conndef init(default), do: default def call(conn, _default) do # do somethingconnend
end
plug
的第二个身份是宏,有了Plug(连接转换器)之后,需要将它应用起来,这就是 plug
宏做的事情。它接受一个Plug和一个可选参数(默认为 []
),这个可选参数正是函数Plug的第二个参数,也是模块Plug的 init/1
函数的参数。例如我们可以像下面这样使用上面的两个Plug:
plug :func_plug
plug HelloWeb.Plugs.ModulePlug, "your params"
当然你也可以写成带括号的形式,它们仅仅是语法上的区别:
plug(:func_plug)
plug(HelloWeb.Plugs.ModulePlug, "your params")
我们在许多文件里面都能看到 plug
的身影,比如 lib/hello_web/endpoint.ex
, lib/hello_web/router.ex
等。因为无论是endpoint,router还是controller,它们本质上都是Plug。
Endpoint
Endpoint是整个后端服务的入口,对应着 lib/hello_web/endpoint.ex
文件。一个endpoint会做为一个opt应用启动,phoenix本质上还是一个elixir应用,在 lib/hello/application.ex
文件的 start
函数中可以看到endpoint是做为opt启动的。
def start(_type, _args) dochildren = [...# Start the Endpoint (http/https)HelloWeb.Endpoint...]# See https://hexdocs.pm/elixir/Supervisor.html# for other strategies and supported optionsopts = [strategy: :one_for_one, name: Hello.Supervisor]Supervisor.start_link(children, opts)
end
Endpoint做为入口,除了做为opt应用,它里面还“插入”了许多公共的Plug,其中最重要的是下面这行代码:
plug HelloWeb.Router
通过这行代码,我们将Router(路由器)带了进来,它会负责请求的分发,也就是解决web框架的那个最核心的问题。
Router
Router就是我们熟知的路由器,它本质上也是一个Plug,对应着 lib/hello_web/router.ex
文件。让我们来看下Phoenix为我们生成的路由器代码:
scope "/", HelloWeb dopipe_through :browserget "/", PageController, :homeend
先来看 get "/", PageController, :home
这行代码,它注册了一个根路由 /
,方法是GET,这也是Phoenix欢迎页的路由。 PageController
是我们的控制器, :home
是此路由对应的处理函数,在Phoenix中称为Action。
除了 get
,Phoenix也为其他HTTP方法定义了对应的宏,如 head
, post
, put
等,他们都定义在 Phoenix.Router
模块下。
虽然示例中路由被放到了Scope内,但这并不是必须的。
Scope
在Router中,路由代码被放到了 scope
代码块内。Scope其实就是组路由,它可以对具有相同前缀的路由进行分组,并且可以对组内路由设置公共中间件。比如在Router示例代码中的 scope
下面还有这么一行代码: pipe_through :browser
。
pipe_through
是应用中间件的宏,而 :browser
就是中间件。不过在Phoenix中,它们被称为Pipeline。
Pipeline
在Phoenix中,中间件被称为Pipeline,由 pipeline
宏来定义。借鉴图形学的概念,这里其实把Pipeline翻译为管线更加合适,但是从概念理解上,它等同于中间件。
Pipeline本质上就是一个Plug的列表,如下图所示:
如果整个web层是一串大糖葫芦,那么pipeline就是一串小糖葫芦。 :broswer
管线的定义如下:
pipeline :browser doplug :accepts, ["html"]plug :fetch_sessionplug :fetch_live_flashplug :put_root_layout, html: {HelloWeb.Layouts, :root}plug :protect_from_forgeryplug :put_secure_browser_headers
end
Pipeline内是通过 plug
宏插入的一系列Plug,是不是很像串糖葫芦。连接在真正交给Controller之前,会首先依次经过Pipeline中的Plug的处理。注意 plug :put_root_layout, html: {HelloWeb.Layouts, :root}
这行代码,它会像浏览器写回网页的公共部分,如布局。这样在Controller中就只需要返回具体的HTML内容了。
除了 :browser
,Phoenix还生成了一个 :api
Pipeline,当我们编写API接口时可以使用它。
pipeline :api doplug :accepts, ["json"]
end
Controller
Controller就是我们熟知的控制器了,不出意外,它本质上也是一个Plug,对应的代码在 lib/hello_web/controllers
目录下,我们看到的欢迎页就是 page_controller.ex
渲染出来的,源码如下:
defmodule HelloWeb.PageController douse HelloWeb, :controllerdef home(conn, _params) do# The home page is often custom made,# so skip the default app layout.render(conn, :home, layout: false)end
end
欢迎页的Controller非常简单,只有一个 home/2
函数用来渲染页面。在Phoenix中,它也称为Action。
虽然Controller和Router看起来都不太像我们前面介绍的Plug,但它们的确是Plug,这就是Elixir宏的魔力。
Action
HTTP请求方法方法除了有名词属性,还有动词属性。比如针对某个路径的一次GET请求,这个动作就称为Action。在Controller中,我们也将用来处理连接请求的函数称为Action。
一个函数要作为Action,必须接受两个参数,第一个是表示连接的 %Plug.conn{}
结构体,第二个是记录着HTTP请求参数的map。在Action中,不应该有过多的业务逻辑,只保留web相关的逻辑和渲染相关的代码,保持Controller层尽量扁平。
在示例中,我们通过 render(conn, :home, layout: false)
来渲染欢迎页。 render
函数是 Phoenix.Controller
模块提供的,它有三个参数。第一个是HTTP连接,也就是 %Plug.conn{}
结构体;第二个参数是HTTP模版,可以是二进制的模版数据,也可以是一个返回模版的函数;第三个参数是渲染模版的参数,可以是关键字(Keyword),也可以是map。
注意 render
的第二个参数 :home
,这是一个返回HTML模板的函数,在Phoenix中被称为Component。
Component
Component是一个返回HTML模版的函数,当然它也需要接受一个map或keyword参数。我们可以粗浅的理解Component就是HTML模板。它对应的是lib/hello_web/controller/page_html.ex
文件。
前面我们看到渲染欢迎页的Component是 :home
函数,但是当我们打开 page_html.ex
后却怎么也找不到这个函数。这是怎么回事呢?
我们先来看下Controller和Component的命名:Controller文件叫 page_controller.ex
,Component文件名叫 page_html.ex
,模块名是 HelloWeb.PageHTML
。这些名称可都不是随便起的,注意看它们的规律,都是page开头,加一个有意义的后缀,page其实无所谓,只要相同就行,但后缀是有讲究的。我们在 render
函数中直接引用了 :home
函数,并没有指定模块名,Phoenix能找到它就是因为这些命名规约。
我们还是没有找到 :home
函数,只有这样一行代码: embed_templates "page_html/*"
。而在controllers目录下,的确有page_html这么一个目录,里面有一个名为 home.html.heex
的文件,home?难道说它们之间也有着某种神秘的联系?
没错, home.html.heex
文件会预编译为 page_html.ex
里面一个名为 home
的函数。Phoenix使用的是HEEx(HTML+EEx)模板语言,EEx是一个Elixir表达式求值的库。HEEx模板文件以 .heex
做为扩展名,所以 home.html.heex
的三个部分的含义分别是Component(函数)名称,模板类型,HEEx扩展名。
对于比较长的模板,建议使用这种独立文件的形式,而对于一些简短的模板,则可以使用函数的形式,例如:
def home(_assigns) do ~H"""Hello World! """
end
你会发现在 lib/hello_web
目录下也有一个独立的 components
目录,它里面存放的也是Component,不过是网页公共部分,如布局等相关的模板。
一次请求
根据前面对概念的梳理,我们也能大致看到一条请求在Phoenix中处理的脉络。现在我们再次对一个HTTP请求的处理流程做一个完整的回顾与总结。
请求首先到达Endpoint,位于 lib/hello_web/endpoint.ex
,在这里有许多Plug会对请求做第一轮的处理。而在这众多的Plug之中,Router也是其中之一,在Endpoint的最后,请求被交给了Router。
Router位于 lib/hello_web/router.ex
,它主要做了两件事,分组分发与中间件。请求在这里先经过Pipeline处理,然后交由对应的Controller。
Controller位于 lib/hello_web/controllers
目录下,新项目只有 page_controller.ex
这一个Controller,其中定义了Action,它们是请求处理的最后一环。在Action中会调用业务逻辑和Component,将数据与视图结合,形成最终的响应,发送回客户端。
以上就是一次请求处理的流程,没有看错,就是这么的简单:Endpoint→Router→Controller→Action。我们暂时省略了代码上的一些细节,如果你非常熟悉Elixir的话,结合hexdocs中的文档,应该不难理解这些代码。此外在Router,Controller等里面使用的 use
宏对应的源码都在 lib/hello_web/world_web.ex
文件中。
整个请求的过程应该不难理解,你可以试着修改模板或者打印一些日志,看看页面的变化和控制台输出,日志使用的是Erlang的 :logger
模块。
本章完,下期见。