初识 Phoenix Framework

系列 - Elixir Phoenix Framework

Phoenix Framework 是一个 MVC web 框架,与 Ruby 的 Rails 和 Python 的 Django 类似,是整个 Elixir 社区的核心项目之一,推荐阅读 Phoenix 文档

我们使用 Phoenix (v1.5.7) 前,需要安装相关依赖与 mix

  • Elixir (>= v1.6)
  • Erlang (>= 20)
  • node.js [optional] (>= 5.0.0)
  • Database [default=PostgreSQL]
  • inotify-tools [linux]

erlang 与 elixir 是运行时环境,数据库方面使用同为社区维护的 Ecto 来操作,Phoenix 使用 node.js 的原因是使用 webpack 编译静态资源,当然你可以只开发 API 不使用静态资源

Phoenix 提供了非常有用的实时重新加载功能,不过 Linux 用户需要安装 inotify-tools 才能使用

我们使用 mix 来创建一个 Phoenix 项目

1
mix phx.new awesome

如果你没有 phx.new 这个命令,你需要先使用 mix 安装一下

1
mix archive.install hex phx_new

项目创建完成之后,我们可以看到终端中有提示,在 config/dev.exs 中配置程序并执行 mix ecto.create,配置文件中的 Repo 是数据库设置,Endpoint 配置的是网站相关的内容

这时 mix 会为我们创建一个 Phoenix 项目,默认的数据库是 PostgreSQL,如果应用不使用数据库则加上tag --no-ecto 即可,如果要使用其他数据库只需要加上 tag --database db

webpack 用于管理静态资源,添加 tag --no-webpack 即可禁止使用 webpack, --no-html 则会不生成HTML视图层,我们在完成 Rest API 程序时可以使用到这两个参数

配置完成之后,使用 mix phx.server 即可运行服务器了!

我们要写这个网站,首先要搞清楚目录关系,有关这个网站的所有的代码将在 lib/awesome_web 中实现,简单的做一个了解

  • endpoint.exHTTP 请求的入口
  • router.ex路由 定义
  • gettext.ex 通过 Gettext 提供了国际化支持
  • telemeter.ex 是一个 Supervisor 相关的程序
  • controllers控制器 实现,请求将通过路由分派到控制器上
  • views视图 实现
  • templates模板 实现

我们来看看路由情况,可以看到 scope "/" 这个空间下有一个 API,绑定的函数是 PageController.index,即浏览器发送请求 GET / 时将会执行绑定的函数。那看看控制器吧,就一句 render(conn, "index.html") 渲染页面,渲染的是哪里的 index.html,还得看视图层 page_view.ex,这会默认去找 templates/page 下的文件进行渲染,整个最简单的流程就是这样

我们实现一个页面,通过请求 GET /hello 展示 Hello, Phoenix!

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
# router.ex
get("/hello", HelloController, :index)

# controllers/hello_controller.ex
defmodule AwesomeWeb.HelloController do
  use AwesomeWeb, :controller
  def index(conn, _params), do: render(conn, "index.html")
end

# views/hello_view.ex
defmodule AwesomeWeb.HelloView do
  use AwesomeWeb, :view
end

# templates/hello/index.html.eex
<div class="phx-hero">
  <h2>Hello, Phoenix!</h2>
</div>

效果还不错,不过太麻烦了,写页面岂不是要累死!好消息!Phoenix 提供了一些 phx.gen.* 的工具可以生成相关代码,减少重复的工作

我们先简单的生成一个 HTML 页面,然后按照提示添加路由并迁移数据库

1
mix phx.gen.html User User users username:string:unique email:string:unique password:string

之后访问 /users 就好,就完成了,简直暴力!!!最暴力的就是,后台开启的服务可以不需要关闭!!!你就能直接访问新加的页面了!!!

稍微说一说 resources("/users", UserController) 的作用,这其实是实现了一堆 CRUD API,然后用一条命令路由所有 CRUD 方法,也可以将它等价替换为:

1
2
3
4
5
6
7
8
get("/users", UserController, :index)
get("/users/:id/edit", UserController, :edit)
get("/users/new", UserController, :new)
get("/users/:id", UserController, :show)
post("/users", UserController, :create)
patch("/users/:id", UserController, :update)
put("/users/:id", UserController, :update)
delete("/users/:id", UserController, :delete)

刚刚那个用户界面,我们只限制了所有字段必填,显然这种限制是不够的,我们应该清楚都需要哪些限制,如果添加这些限制,接下来我们以 username 为例

  • 必填与唯一
  • 英文字母、数字及下划线
  • 长度 3 ~ 15
  • 保留 admin, administratorroot

需求搞定了,我们知道这些需求第一个已经完成了,是 Phoenix 帮我们完成的,那看看源代码是怎么完成的吧。请打开 lib/awesome/account/user.ex 并查看源码,这是一个 Etco 所定义的模型,可以直接与数据库交互,而查文档的话也应该查 Ecto 的文档

1
2
3
4
5
6
7
8
@doc false
def changeset(user, attrs) do
  user
  |> cast(attrs, [:username, :email, :password])
  |> validate_required([:username, :email, :password])
  |> unique_constraint(:username)
  |> unique_constraint(:email)
end

changeset/2 是 Ecto 的回调函数,一般对数据进行 过滤验证约束 操作,调用方式

1
2
3
changeset = User.changeset(%User{}, %{username: "Example", email: "i@example.com"})
{:error, changeset} = Repo.insert(changeset)
changeset.errors #=> [password: {"can't be blank", []}]

OK,下来我们就开始仔细解读下这个函数,validate_required 是指明哪些字段必填,而 unique_constraint 则是约束字段唯一,那现在我们需要再添加一些代码来实现需求

1
2
3
|> validate_exclusion(:username, ~w(admin administrator root)) # 保留用户名
|> validate_format(:username, ~r/^[a-zA-Z0-9_]+$/) # 对格式进行要求
|> validate_length(:username, [min: 3, max: 15]) # 限制长度

Plug 运行在 Phoenix 的 HTTP 层,所有链接都与 Plug 打交道,并且 Endpoint 、 Router 、 Controller 都属于 Plug

一个符合 plug 的函数需要接受一个连接,和相关的选项作为参数,并且最终返回这个连接

1
2
3
def put_headers(conn, kvs) do
  Enum.reduce(kvs, conn, fn ({k, v}, conn) -> Plug.Conn.put_resp_header(conn, k, v) end)
end

我们可以用 plug 将操作流式串联起来,将一个请求所需要的操作流水式的串联起来

1
2
3
4
5
6
7
8
defmodule AwesomeWeb.HelloController do
  use AwesomeWeb, :controller

  plug :put_haeders, %{content_encoding: "gzip", cache_control: "max-age=3600"}
  plug :put_layout, "bare.html"

  # Other Operators
end

Plug 的另一种类型是 module plug,它被定义在 module 中,可以将整个 module 当作一个 plug 放入处理流程中,因此这个 module 需要符合一定的规范

  • init/1:初始化传递给 call/2 的参数或选项
  • call/2:处理链接,与 function plug 差不多

我们可以试一试写一个模块 plug,功能是把 :locale 键值对放到连接流里,以便让后面的其他 plugs 控制器和页面等也能使用

1
2
3
4
5
6
7
8
9
defmodule HelloPhoenixWeb.Plugs.Locale do
  import Plug.Conn
  @locales ["en", "fr", "de"]
  def init(default), do: default
  def call(%Plug.Conn{params: %{"locale" => loc}} = conn, _default) when loc in @locales do
    assign(conn, :locale, loc)
  end
  def call(conn, default), do: assign(conn, :locale, default)
end

我们将 Locale Plug 串入 router 中即可

1
2
3
4
5
6
7
8
defmodule HelloPhoenixWeb.Router do
  use HelloPhoenixWeb, :router
  pipeline :browser do
    ...
    plug HelloPhoenixWeb.Plugs.Locale "en"
  end
  ...
end

路由是 Phoenix 应用的重要组成部分,可以将对应的 HTTP 请求映射到 controller/action, 处理实时 channel,还为路由之前的中间件定义了一系列的转换功能

pipeline 可以定义一种类型的操作,这种操作可以定义一系列 plug 为以后定义的 API 使用。scope 操作可以定义一组 API 的作用域,并且可以指定 pipe 类型来获取 plug 操作

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
pipeline :browser do
  plug :accepts, ["html"]
  plug :fetch_session
  plug :fetch_flash
  plug :protect_from_forgery
  plug :put_secure_browser_headers
end
scope "/", HelloPhoenixWeb do
  pipe_through :browser
  get("/", PageController, :index)
end

Phoenix 可以使用命令 mix phx.routes 查看所有已定义的 API

1
2
 page_path  GET     /         HelloPhoenixWeb.PageController :index
hello_path  GET     /hello    HelloPhoenixWeb.HelloController :index

Path helpers 是 Router.Helpers 模块动态产生的函数,命名规则根据 Controller 生成,我们可以执行命令 iex -S mix 运行项目并中查看 Helps

1
2
HelloPhoenixWeb.Router.Helpers.page_path(HelloPhoenixWeb.Endpoint, :index) # "/"
HelloPhoenixWeb.Router.Helpers.hello_path(HelloPhoenixWeb.Endpoint, :index) # "/hello"

Helps 也可以用在 eex 中,示例 page_path(@conn, :index) 的输出即为 "/"

1
<a href="<%= page_path(@conn, :index) %>">To the Welcome Page!</a>

我们添加一系列 //admin 下的 API,phx.routes 查看 API,可以发现 uri、 Controller、function 都是没问题的,但是 helps 都是 review_path,这会引起 helps 函数调用错误

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
review_path  GET     /reviews HelloPhoenixWeb.ReviewController :index
review_path  GET     /reviews/:id/edit HelloPhoenixWeb.ReviewController :edit
review_path  GET     /reviews/new HelloPhoenixWeb.ReviewController :new
review_path  GET     /reviews/:id HelloPhoenixWeb.ReviewController :show
review_path  POST    /reviews HelloPhoenixWeb.ReviewController :create
review_path  PATCH   /reviews/:id HelloPhoenixWeb.ReviewController :update
             PUT     /reviews/:id HelloPhoenixWeb.ReviewController :update
review_path  DELETE  /reviews/:id HelloPhoenixWeb.ReviewController :delete
review_path  GET     /admin/reviews HelloPhoenixWeb.Admin.ReviewController :index
review_path  GET     /admin/reviews/:id/edit HelloPhoenixWeb.Admin.ReviewController :edit
review_path  GET     /admin/reviews/new HelloPhoenixWeb.Admin.ReviewController :new
review_path  GET     /admin/reviews/:id HelloPhoenixWeb.Admin.ReviewController :show
review_path  POST    /admin/reviews HelloPhoenixWeb.Admin.ReviewController :create
review_path  PATCH   /admin/reviews/:id HelloPhoenixWeb.Admin.ReviewController :update
             PUT     /admin/reviews/:id HelloPhoenixWeb.Admin.ReviewController :update
review_path  DELETE  /admin/reviews/:id HelloPhoenixWeb.Admin.ReviewController :delete

我们需要添加 as: :admin 来解决 helps 冲突

1
2
3
4
5
6
7
8
scope "/", HelloPhoenixWeb do
  pipe_through :browser
  resources "/reviews", ReviewController
end

scope "/admin", HelloPhoenixWeb.Admin, as: :admin do
  resources "/reviews", ReviewController
end

Phoenix 控制器是一个类似中间人的角色,里面的函数称为 atcion,它响应路由的 HTTP 请求。action 可以命名为任意名称,但我们一般遵循一些约定

  • index:按照给定的数据渲染一组条目
  • show:渲染一个给定的 id 的独立条目
  • new:渲染一个创建新条目所需的表单
  • create:接收创建的新条并将其存储
  • edit:接收给定 id 的条目,并将其显示在 form 中用以编辑
  • update:接收修改过的 item 并存储
  • delete:接收给定 id 的条目并将其删除

控制器有一些方法渲染内容,最简单的一种是使用 Phoenix 提供的 text/2 方法渲染纯文本,当然也可以有一些其他方法来渲染 json/2html/2

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
def show(conn, %{"id" => id}) do
  text(conn, "Showing id ${id}") # text: Showing id 15
  json(conn, %{id, id}) # json: {"id": "15"}
  html(conn, """
  <html>
      <head>
          <title>Passing an Id</title>
      </head>
      <body>
          <p>You sent in id #{id}</p>
      </body>
  </html>
  """)
end

render/3 是 Phoenix 提供的渲染 View 的方法,View 与 Controller 需要使用相同的名称,且在 Templates 下有对应的模板目录,目录下对应着 Elixir 模板 HTML 文件

1
2
3
4
defmodule HelloPhoenixWeb.HelloConttoller do
  use HelloPhoenixWeb, :controller
  def show(conn, %{"messenger" => messenger}), do: render(conn, "show.html", messenger: messenger)
end

控制器还可以直接返回响应状态

1
def index(conn, _params), do: conn |> send_resp(201, "")