當一個 Phoenix 專案運行一陣子之後, 可能有 LiveView 的網頁頁面, 提供給第三方廠商或是手機 App 的 JSON API, 還有一個後台管理介面。
假如我們要實作一些非常基本的邏輯在我們假想的 CMS 系統:
只有編輯可以發布貼文, 或是, 內文要有內容才能發佈。
邏輯本身沒什麼, 真正花時間的是要想這些規定要寫在哪邊?
而且當 app 有介面的時候 (網頁, 手機 App, 後台管理介面), 到底會有幾個地方需要知道某個規定?
功能: 一個有 title 跟 body 的 Post, 可以建草稿, 可以發布,
只有編輯可以發布, 內文要有內容才能發佈。
先用一般 Phoenix 專案的寫法寫一次, 再用 Ash 寫一次。
migration, 手寫:
# priv/repo/migrations/20260915000000_create_posts.exs
defmodule DemoCms.Repo.Migrations.CreatePosts do
use Ecto.Migration
def change do
create table(:posts, primary_key: false) do
add :id, :binary_id, primary_key: true
add :title, :string, null: false
add :body, :text
add :status, :string, null: false, default: "draft"
add :published_at, :utc_datetime
timestamps(type: :utc_datetime)
end
end
end
schema 跟它的 changeset:
# lib/demo_cms/content/post.ex
defmodule DemoCms.Content.Post do
use Ecto.Schema
import Ecto.Changeset
@primary_key {:id, :binary_id, autogenerate: true}
schema "posts" do
field :title, :string
field :body, :string
field :status, Ecto.Enum, values: [:draft, :published], default: :draft
field :published_at, :utc_datetime
timestamps(type: :utc_datetime)
end
def draft_changeset(post, attrs) do
post
|> cast(attrs, [:title, :body])
|> validate_required([:title])
end
def publish_changeset(post) do
post
|> change(status: :published, published_at: DateTime.utc_now(:second))
|> validate_required([:body], message: "cannot publish an empty post")
end
end
context, 「只有編輯可以發布」這條規定住在這裡:
# lib/demo_cms/content.ex
defmodule DemoCms.Content do
import Ecto.Query
alias DemoCms.Repo
alias DemoCms.Content.Post
def list_published_posts do
Repo.all(from p in Post, where: p.status == :published, order_by: [desc: p.published_at])
end
def get_post!(id), do: Repo.get!(Post, id)
def create_draft(attrs) do
%Post{}
|> Post.draft_changeset(attrs)
|> Repo.insert()
end
def publish_post(%{role: :editor}, %Post{} = post) do
post
|> Post.publish_changeset()
|> Repo.update()
end
def publish_post(_user, _post), do: {:error, :unauthorized}
end
兩個呼叫端, 一個 LiveView, 一個 JSON controller:
# lib/demo_cms_web/live/post_live/show.ex
def handle_event("publish", _params, socket) do
case Content.publish_post(socket.assigns.current_user, socket.assigns.post) do
{:ok, post} -> {:noreply, assign(socket, post: post)}
{:error, :unauthorized} -> {:noreply, put_flash(socket, :error, "Not allowed")}
{:error, changeset} -> {:noreply, put_flash(socket, :error, error_text(changeset))}
end
end
# lib/demo_cms_web/controllers/api/post_controller.ex
def publish(conn, %{"id" => id}) do
post = Content.get_post!(id)
case Content.publish_post(conn.assigns.current_user, post) do
{:ok, post} -> json(conn, %{data: post_json(post)})
{:error, :unauthorized} -> send_resp(conn, 403, "")
{:error, changeset} -> conn |> put_status(422) |> json(%{errors: errors_json(changeset)})
end
end
resource。這一個檔案取代了 schema、兩個 changeset, 還有 context 的主要內容:
# lib/demo_cms/content/post.ex
defmodule DemoCms.Content.Post do
use Ash.Resource,
domain: DemoCms.Content,
data_layer: AshPostgres.DataLayer,
authorizers: [Ash.Policy.Authorizer]
postgres do
table "posts"
repo DemoCms.Repo
end
actions do
defaults [:read]
read :published do
filter expr(status == :published)
prepare build(sort: [published_at: :desc])
end
create :create_draft do
accept [:title, :body]
end
update :publish do
validate present(:body), message: "cannot publish an empty post"
change set_attribute(:status, :published)
change set_attribute(:published_at, &DateTime.utc_now/0)
end
end
policies do
policy action_type(:read) do
authorize_if always()
end
policy action(:create_draft) do
authorize_if actor_present()
end
policy action(:publish) do
authorize_if actor_attribute_equals(:role, :editor)
end
end
attributes do
uuid_primary_key :id
attribute :title, :string, allow_nil?: false, public?: true
attribute :body, :string, public?: true
attribute :status, :atom, constraints: [one_of: [:draft, :published]], default: :draft
attribute :published_at, :utc_datetime
timestamps()
end
end
domain, 對外的函式名稱放在這裡:
# lib/demo_cms/content.ex
defmodule DemoCms.Content do
use Ash.Domain
resources do
resource DemoCms.Content.Post do
define :list_published_posts, action: :published
define :get_post, action: :read, get_by: [:id]
define :create_draft, action: :create_draft
define :publish, action: :publish
end
end
end
migration 不用手寫, 從 attributes 區塊產生:
mix ash.codegen create_posts
一樣的兩個呼叫端:
# LiveView
def handle_event("publish", _params, socket) do
case Content.publish(socket.assigns.post, actor: socket.assigns.current_user) do
{:ok, post} -> {:noreply, assign(socket, post: post)}
{:error, error} -> {:noreply, put_flash(socket, :error, Exception.message(error))}
end
end
# JSON controller
def publish(conn, %{"id" => id}) do
post = Content.get_post!(id)
case Content.publish(post, actor: conn.assigns.current_user) do
{:ok, post} -> json(conn, %{data: post_json(post)})
{:error, %Ash.Error.Forbidden{}} -> send_resp(conn, 403, "")
{:error, %Ash.Error.Invalid{} = error} -> conn |> put_status(422) |> json(%{errors: errors_json(error)})
end
end
「內文要有內容」寫在哪
publish_changeset/1
:publish 這個 action「只有編輯可以」寫在哪
publish_post/2 的 pattern match:publish 的 policy這兩條規定真正生效的地方
publish_post/2
:publish 裡面; 沒有其他方式可以執行它migration
在 Phoenix + Ecto 的版本如果後台加了一個批次發布的按鈕, 然後用 Repo.update_all 來做, 如果沒有特別寫的話兩條規定都會被跳過, 沒人發現
Ash 的話, 用 Ash.bulk_update 跑 :publish 這個 action, 每一筆都會跑同樣的 validation 跟同樣的 policy, 因為規定是掛在 action 上, 而不是掛在一個大家應該要記得但是容易忘記呼叫的函式上。
這些差別在累積久了之後, 隨著功能越來越多. 如這個系列要做的 CMS, 公開頁面, 編輯後台, headless API, 背景排程等等,有規章小心的開發也會開始累積例外, 漸漸的讓系統不好維護擴充
validate present(:body)、actor_attribute_equals、define ... get_by: 剛開始會有種在 Elixir 裡面學另一個小語言的感覺.:publish 回傳 Forbidden 的時候, 你要知道怎麼問它「是哪一條 policy 說不行」