iT邦幫忙

2026 iThome 鐵人賽

DAY 3
0
Modern Web

Ash framework, Elixir 的商業邏輯框架系列 第 3 篇

讀懂產生出來的 Domain 與 Resource

  • 分享至 

  • xImage
  •  

Post Resource

我們先來了解 Content 這個 Domain
以及裡面的 Post resource

lib/demo_cms/content.ex:

defmodule DemoCms.Content do
  use Ash.Domain, otp_app: :demo_cms

  resources do
    resource DemoCms.Content.Post
  end
end

lib/demo_cms/content/post.ex:

defmodule DemoCms.Content.Post do
  use Ash.Resource,
    otp_app: :demo_cms,
    domain: DemoCms.Content,
    data_layer: AshPostgres.DataLayer

  postgres do
    table "posts"
    repo DemoCms.Repo
  end

  actions do
    defaults [:read, create: [:title, :body]]
  end

  attributes do
    uuid_primary_key :id

    attribute :title, :string do
      allow_nil? false
      public? true
    end

    attribute :body, :string do
      public? true
    end

    timestamps()
  end
end

還有 config/config.exs 裡的 ash_domains: [DemoCms.Content, DemoCms.Accounts]。

domain

domain 是一群有名字的 resource 集合, 對應 Phoenix 的 context。每個 resource 剛好屬於一個 domain, 沒有被列在某個 domain 的 resources 區塊裡的 resource 不會編譯過。ash_domains 那行 config 是 Ash 的工具 (codegen、admin、API extension) 找 domain 的方式; 它不會去掃 lib/。

之後 domain 也會是這部分 app 對外函式定義的地方。現在它只是一份清單。

use

resource 是一個普通的 Elixir module。use Ash.Resource 的選項講的是它屬於誰、開了什麼:

  • domain: 擁有它的 domain
  • data_layer: 決定這個 data 要存的地方。這裡是 Postgres 所以使用 AshPostgres.DataLayer
    Ash 內建的其他選項有 ETS 的 Ash.DataLayer.Ets
    完全不儲存的 Ash.DataLayer.Simple 等等

每一個開起來的東西都可以在檔案裡加自己的區塊
postgres do ... end
這個是使用 postgres data layer 才有的, 用來指定哪張表、哪個 repo

之後我們加上別的套件如權限也會有對應的工具可以使用

attribute

attributes do
  uuid_primary_key :id

  attribute :title, :string do
    allow_nil? false
    public? true
  end

  attribute :body, :string do
    public? true
  end

  timestamps()
end

attribute 有名字、型別、選項。Ash 內建的型別有 :string、:ci_string、:integer、:boolean、:atom、:utc_datetime、:date、:uuid、:map, 還有 {:array, 型別}; 每種有自己的 constraint。

uuid_primary_key :id 是「一個 :uuid 的 attribute, 當主鍵, create 時由 Ash 產生, 永遠不會是 nil」的簡寫。timestamps() 是 inserted_at 跟 updated_at 的簡寫, 型別 :utc_datetime_usec, 由 Ash 設定, 不是資料庫。

兩個選項承載了大部分的意義:

  • allow_nil? false 是必填。每次 create 跟 update 都會檢查, 在 migration 裡變成了 null: false。
  • public? true 是外界可以看也可以設。attribute 預設是 private 的, 跟 Ecto schema 相反。private 的 attribute 還是可以被 resource 自己的程式碼設定, 但永遠不能從 action 的 input、表單或 API 進來。id 是 public 的, 因為 uuid_primary_key 就是這樣設的。timestamps 是 private 的: 由 Ash 管理, 外面不應該去設它。

用內省看宣告了什麼, 預設值也一起填上:

iex> alias DemoCms.Content.Post
iex> Ash.Resource.Info.attributes(Post) |> Enum.map(&{&1.name, &1.type, &1.allow_nil?, &1.public?})
[
  {:id, Ash.Type.UUID, false, true},
  {:title, Ash.Type.String, false, true},
  {:body, Ash.Type.String, true, true},
  {:inserted_at, Ash.Type.UtcDatetimeUsec, false, false},
  {:updated_at, Ash.Type.UtcDatetimeUsec, false, false}
]

action

actions do
  defaults [:read, create: [:title, :body]]
end

對 resource 的每一個操作都是 action。沒有別的入口。action 有名字、型別、一串 input, 還有規則。五種型別:

  • create、update、destroy: 改一筆 record, 在資料庫 transaction 裡
  • read: 回傳一串 record
  • action: 泛用 action, 回傳值自訂, 給不屬於上面四種的事情用

defaults 產生陽春版。:read 變成一個叫 :read、回傳全部的 action。create: [:title, :body] 變成一個叫 :create 的 action, 它的 accept 清單就是這兩個 attribute; 其他的都不能透過它設定, 不管是不是 public。手寫出來會是:

read :read do
  primary? true
end

create :create do
  primary? true
  accept [:title, :body]
end

primary? true 標記沒有指名 action 時 Ash 會退回去用的那一個。Ash.read(Post) 用 primary read; Ash.create(Post, attrs) 用 primary create。每種型別最多一個。

iex> Ash.Resource.Info.actions(Post) |> Enum.map(&{&1.name, &1.type, &1.primary?})
[{:create, :create, true}, {:read, :read, true}]

iex> Ash.Resource.Info.action(Post, :create).accept
[:title, :body]

:create 跟 :read 目前還沒有用到
之後我們會加上 :publish、:archive 這種 action
而並把對應的商業邏輯寫進去
如發布的規則就會在 :publish 裡面

呼叫 action

Ash.create/2 吃 resource 跟 input 的 map, 跑 primary create action:

iex> {:ok, post} = Ash.create(Post, %{title: "Hello, Ash", body: "First post."})
{:ok,
 %DemoCms.Content.Post{
   id: "882bbad6-760d-4728-9c58-ec34e23100c8",
   title: "Hello, Ash",
   body: "First post.",
   inserted_at: ~U[2026-09-15 16:46:38.945600Z],
   updated_at: ~U[2026-09-15 16:46:38.945600Z]
 }}

這類函式都有驚嘆號版本, 有錯誤的話會直接 raise 而不是回傳 tuple:

iex> Ash.create!(Post, %{body: "no title"})
** (Ash.Error.Invalid)
* attribute title is required

action 收到不合格的 input 會有錯誤, 不會默默丟掉:

iex> Ash.create!(Post, %{title: "x", status: :published})
** (Ash.Error.Invalid)
* No such input `status` for action DemoCms.Content.Post.create

Valid Inputs:
* title
* body

讀取 Post:

iex> Ash.read!(Post) |> Enum.map(& &1.title)
["Hello, Ash"]

iex> Ash.get!(Post, post.id).title
"Hello, Ash"

Ash.create/2 替我們:
使用 Ash.Changeset.for_create/3 幫某個指名的 action 建 changeset: 把每個 input 照 attribute 型別轉型, 拒絕不認識的 input, 套預設值, 跑這個 action 的規則
在用 Ash.create/1 執行:

iex> cs = Ash.Changeset.for_create(Post, :create, %{title: "Via changeset"})
iex> {cs.valid?, cs.attributes}
{true, %{title: "Via changeset"}}

iex> Ash.create!(cs).title
"Via changeset"

跟 Ecto 一樣的形狀, 先建再套用, 差一件事: 這個 changeset 是從 action 的定義建出來的, 不是從我們寫的函式。等 :create 變成有自己規則的 :publish, for_create 跟 for_update 會跑那些規則, 在資料庫介入之前就回報 valid?: false。

底下那一列

iex> DemoCms.Repo.query!("select id, title from posts").rows
[["882bbad6-760d-4728-9c58-ec34e23100c8", "Hello, Ash"]]

資料表是從 attribute 經過 mix ash.codegen 來的。那一列是普通的 Postgres 資料列, DemoCms.Repo 是普通的 Ecto repo。

我們會盡量使用 Ash 提供的工具,有特殊情況再使用 Ecto 原生的功能


上一篇
建立 Ash + Phoenix 專案與相關 task 工具
下一篇
替 Resource 加上欄位
系列文
Ash framework, Elixir 的商業邏輯框架 共 4 篇
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言