面向萌新的 Phoenix 教程
一个面向在 mix phx.new 后不知如何编写的萌新的教程。
写在前面
RoR 类型的网络框架1,素来以开发效率高而闻名,因为其提供了大量的预制代码(脚手架)。但是这种「开发效率高」往往面对的是有丰富经验的资深开发者。
但是从新入手的角度来讲,这些动辄数千行的预制代码反而变成了门槛。
所以严格来讲,这个教程是面向和我一样被网上的宣传骗进来的萌新们,旨在从一种构建的角度来对 Phoenix 进行学习。
Prerequisite
虽然是「面向萌新」,但如果想要看懂本文章,还是需要一点点的基础的。主要包括:
- 对网络开发的基本认识
- 了解客户端、服务器、请求、响应等基本概念
- 知道 MVC 的三个字母分别代表了什么
- 了解 API 请求(网络概念上的)、HTML 以及 CSS/JavaScript 等概念
- 了解一门编程语言,能够用该语言独自编写简单的程序
- 操作过该语言的包管理应用
- 对 Erlang 或 Elixir 的基本了解
- 要知道 Application 是什么(这是和其他编程语言较大的不同)
- 了解创建新应用/库的指令、可以添加依赖并且执行应用
以下是加分项:
以及最重要的,安装了 Elixir 。
从单文件 Plug 服务开始
Plug 在 Elixir 生态中的角色相对比较特殊,它不是一个服务器,也不是一个网络框架。它允许开发者可以根据其编写/组织对外部的请求连接进行一系列操作的「插头」来编写自己的网络应用。
简单来说,它「规定」了一种可行的处理网络请求的乐高积木的形状以及组织方式,并且提供了部分预制的积木(和脚手架不同的是你只管用,不需要注意细节)。
这种「协议」与「实现」解耦的方式,在 Elixir 的开发中非常常见。
从静态文件服务开始
为了直观理解,我们来看一段代码。这是一个基于 Plug 和 Bandit(一个 Web 服务器适配器)的微型静态文件服务。
对于没有 Erlang/Elixir/Scala 等语言开发经历的人,看到下面的代码,可能觉得有点奇怪,但是完全可以试着猜一下这个子句函数/模块是干什么的。一般来讲,你所猜到的意思和实际的功能相差不会很大。
来源于 simple_blog_engine 中开发服务器的代码(移除了 SSE 广播、从配置文件中读取等高阶功能)。
defmodule GES233.SimpleServer do
# 想要运行,需要调用
# Bandit.start_link(plug: GES233.SimpleServer)
# 这个函数。
use Plug.Builder
defmodule HTMLLoader do
use Plug.Builder
def init(opts), do: opts
@root_path "priv/generated"
def call(%Plug.Conn{path_info: path} = conn, _opts) do
cond do
"index.html" in path ->
path = "#{@root_path}/#{Enum.join(path, "/")}"
html = File.read!(path)
conn
|> put_resp_content_type("text/html; charset=utf-8")
|> resp(200, html)
|> halt()
true ->
conn
end
end
end
@assets_path "priv/assets/"
plug Plug.Logger
plug :redirect_index
plug HTMLLoader
plug Plug.Static, at: "/", from: @assets_path
plug :not_found
def redirect_index(%Plug.Conn{path_info: path} = conn, _opts) do
case path |> Enum.filter(&String.contains?(&1, ".")) do
[] ->
%{conn | path_info: path ++ ["index.html"]}
_ ->
conn
end
end
def not_found(conn, _) do
send_resp(conn, 404, "not found")
end
end注意
这些代码需要设置好静态资源以及网页本体才可以正常运行,也需要在
mix.exs中添加对应依赖项。
那一串的 plug ... ,其实就是一系列的「插头」:
REQUEST
↓
┌─────────────────────────┐
│ Plug.Logger │ 记录日志
├─────────────────────────┤
│ :redirect_index │ 无文件后缀
│ │ → 加 index.html
├─────────────────────────┤
│ HTMLLoader │ 有 index.html
│ │ → 读取文件
│ │ → RESPONSE or pass
├─────────────────────────┤
│ Plug.Static │ 处理其他静态文件
│ │ → RESPONSE or pass
├─────────────────────────┤
│ :not_found │ 兜底
│ │ → RESPONSE (404)
└─────────────────────────┘
注:
其实有更好的方式,请求先过
Plug.Static,再把:redirect_index以及HTMLLoader的功能合二为一,再兜底。这么写的原因,一是我写这个业务的时候 Elixir 还并不是很好;二是实际的应用有
/sse来负责重加载界面,请求先过:sse,再是以上这些 plugs。
我们可以看到,有一个「线索」或「上下文」,横亘这一串积木之中。这种
input |> foo() |> bar() ... 的形式也是 FP
语言的一种特色。
作为对比,如果你用过 Sanic ,你会发现,数据存在 \(\mathrm{Request} \rightarrow \mathrm{Response}\) 的转换。
# https://github.com/GES233/Chestnut
# chestnut/infra/web/blueprints/plain/index.py
from sanic import Blueprint
from sanic.request import Request
from sanic.response import HTTPResponse
from .render import launch_render as render
from ....helpers.config.page import PageConfig
from ....deps.i18n.language import parseheaders
async def index(request: Request) -> HTTPResponse:
# Language.
language = parseheaders(request.headers)
# Page Info.
request.ctx.page_config.load_items(**PageConfig.addtitle(role="Index"))
# Return.
return await render(request, "launch.html")但在 Plug 中,这条线索是「同质性」的,名字叫做
%Plug.Conn{} ,是「结构体」(可以粗暴地理解成 Python 的
dataclass ,但是没有类,或者是附带着一些函数的 namedtuple)。
也因此,相比「乐高积木」更恰当的比喻是「透镜」:
- 透过以及透出的光是同质性的
- 单个透镜对光进行了处理
- 一系列透镜可以组成复杂的仪器/设备
但下文还是以「乐高积木」作为主要的比喻方式,因为大家(包括我)对积木更了解些。
框架的目的是降低开发复杂度
本节标题源于此前看了 廖雪峰老师的博文 的思考。
在实际应用环境下,往往需要面对如下情况:
- 需要处理
/users/1,/posts/114514这样的动态路由,和存在不可变变量2一样,这里的「动态」是编写程序时无法确定的存在 - 某个包含表单的界面需要处理 GET 、 POST 等一系列方法
- 涉及从数据库读取用户信息、鉴权、查看用户的请求、保存到数据库中的复杂请求
如果执意要用 plug 手搓应用,也不是不可以,但是有可能面对编写大量的重复代码、潜在的(编写时很难看到的)错误等情况。
框架的本质,就是大量通用的预制代码,它来处理那些潜在的、繁琐的细节。
Phoenix 做了什么
介绍
项目实操
Talk is cheap, show me the code.
灵感来自于暂停的 GES233/MortalDrinksElixir ,我本来想直接把项目搬过来的,但我到目前好没写好兼容 Elixir 对象的 Scheme 风格 miniKanren 解释器和支持 2D/3D 混合画面的 JS Canvas 渲染引擎。
小准备
嵌入式 Phoenix LiveView 应用
WebUI 本体:
defmodule WebUI do
defmodule TinyLive do
use Phoenix.LiveView, layout: {__MODULE__, :live}
def render("live.html", assigns) do
~H"""
<!DOCTYPE html>
<html>
<head>
<meta name="csrf-token" content={Plug.CSRFProtection.get_csrf_token()} />
<title>System Monitor</title>
<style>
body { background: #000; color: #0f0; font-family: 'Courier New', monospace; margin: 0; padding: 20px; }
.grid { display: grid; grid-template-columns: 1fr 1fr; gap: 20px; height: 95vh; }
.panel { border: 2px solid #333; padding: 10px; overflow: hidden; }
.active { border-color: #0f0; box-shadow: 0 0 10px #0f0; }
pre {
font-family: "CaskaydiaCove Nerd Font Mono", Consolas, "Courier New", monospace;
}
</style>
<script src="https://cdn.jsdelivr.net/npm/phoenix@1.8.1/priv/static/phoenix.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/phoenix_live_view@1.1.19/priv/static/phoenix_live_view.min.js"></script>
<script>
if (!window.liveSocket) {
let csrfToken = document.querySelector("meta[name='csrf-token']").getAttribute("content");
window.liveSocket = new window.LiveView.LiveSocket("/live", window.Phoenix.Socket, {
params: {_csrf_token: csrfToken}
});
}
window.liveSocket.connect();
window.liveSocket.enableDebug();
</script>
</head>
<body>
<%= @inner_content %>
</body>
</html>
"""
end
def render(assigns) do
~H"""
<div class="grid">
<div class="panel">
<h3>// SOURCE_CODE</h3>
<%= @code_snippet %>
</div>
<div class="panel active">
<h3>// VISUALIZATION</h3>
<p>Status: <%= @status %></p>
<p>Tick: <%= @tick %></p>
</div>
</div>
"""
end
def mount(_params, _session, socket) do
{:ok, socket}
end
end
defmodule Router do
use Phoenix.Router
import Phoenix.LiveView.Router
pipeline :browser do
plug :accepts, ["html"]
plug :fetch_session
plug :protect_from_forgery
plug :put_secure_browser_headers
plug :put_root_layout, {TinyLive, :live}
end
scope "/" do
pipe_through :browser
live "/", TinyLive
end
end
defmodule Endpoint do
use Phoenix.Endpoint, otp_app: :web_ui
socket "/live", Phoenix.LiveView.Socket
plug Plug.Session,
store: :cookie,
key: "_web_ui_key",
signing_salt: "CUSTARD_PUDDING_VANILLA_ICECREAM_AND_STRAWBERRY_PANCAKE"
plug WebUI.Interface.Router
end
defmodule ErrorHTML do
def render(template, _assigns) do
Phoenix.Controller.status_message_from_template(template)
end
end
end应用:
defmodule YourApp.Application do
@moduledoc false
use Application
def start(_start_type, _start_args) do
Application.put_env(:mord_ex, WebUI.Endpoint,
http: [ip: {127, 0, 0, 1}, port: 4000],
server: true,
live_view: [signing_salt: "pNL)*>._*uxE71Us0ULc"],
secret_key_base: "7s7EGQuNjDzKANP7jD0KDCpUyuNvwQ612KHXR6X2v7V3ERJq9vi0p5U0rda3RbJB",
pubsub_server: WebUI.PubSub,
adapter: Bandit.PhoenixAdapter,
render_errors: [
formats: [html: WebUI.ErrorHTML],
layout: false
]
)
children = [
{Phoenix.PubSub, name: WebUI.PubSub},
WebUI.Endpoint
]
opts = [strategy: :one_for_one, name: YourApp.Supervisor]
Supervisor.start_link(children, opts)
end
end