本章紀錄關於 Phoenix, Ecto 建立的架構,關於 Router 與 Controller 的 Pattern Matching 以及建立資料流的方式。
注意,Phoenix 相關的架構都會有一定的時效性,本篇文章是在 2021 用 1.5.9 版,很有可能會遇到不同的寫法,但大致上核心理念是差不多的。
Phoenix 這個角色本身就是一個 MVC 框架,然而它本身就自帶一些指令,可以幫助我們快速的建立好 CRUD API + HTML,而且建立的同時,會連帶建立出 Ecto 專用的資料定義。
建立 Model with Relations
建立資料三大常用指令:
1. gen.html: 會建立出 CRUD HTML + Controller + Ecto CRUD Model API 以及 Ecto 資料定義
2. gen.context 只會建立出 Ecto CRUD Model API 以及 Ecto 資料定義
3. gen_schema 只會建立出 Ecto 資料定義
視情況看需不需要增加 View, Controller 內容來決定是否要用這三者其中哪者。
首先,這篇文章要透過建立一個只有標題、內容的文章部落格,以及對每篇文章 (Post) 建立一些附屬的屬性來了解 Data Relations,藉由此 Examples 來變成一個規劃藍圖。
關聯性規劃是這樣的:
*是 [關聯方式] + table 名稱
裡面唯有 belongs_to 會是 DB 資料定義 (foriegn_key),其餘的都會在程式中透過 Ecto 自動幫助你關聯這些資料 (join_through 就會拿自己的 id 去 many-to-many table 比較另外一個 data), has_many, has_one 則也是會拿自己的 id 去對應的 belongs_to 搜尋,只要透過 Repo.Preload 方法呼叫即可。
- 語言 Languages (一種語言) *languages
- 有很多文章 *Has-many: posts
- 文章 Posts (一篇文章) *posts
- 有很多個類別 Categories *Many-To-Many join_through: post_categories
- 屬於一種語言 *belongs_to: language
- 類別 Categories (一個類別) *categories
- 有很多個文章 Posts *Many-To-Many join_through: posts_categories
- 很多文章對應很多類別,很多類別對應很多文章 * Many-To-Many: posts_categories
建立 Language
mix phx.gen.html Languages Language languages name:string
關於指令的欄位看法,有兩種記憶方式:
1. mix phx.gen.html [ecto api models 名稱] [struct 資料定義名稱] [資料庫 table 名稱] .....[各種定義]
2. mix phx.gen.html [複數] [單數] [資料表名稱(s複數)] .....[各種定義]
優先建立 Language ,是因為 Post 會需要依賴這個 Language 當作 reference ,省下一點時間另外建立 Relations。
建立 Post
接著,要建立一個文章,文章建立雖然還沒有類別,但可以先把 reference 語言加上去。
mix phx.gen.html Posts Post posts title:string content:string language_id:references:languages
修改 Posts 與 Languages 關聯性
依照剛才規劃的關聯性表,在 /lib/hello/posts/post.ex,修改成這樣的欄位:
defmodule Hello.Posts.Post do
use Ecto.Schema
import Ecto.Changeset
schema "post" do
field :content, :string
field :title, :string
# field :language, :id # 替換成下方的方式
# 注意第一個參數是單數,然後接著給的是 struct 結構,然後告訴這個 db 要把 post 的外來鍵命名為 language_id
belongs_to :language, Hello.Languages.Language, foreign_key: :language_id
timestamps()
end
@doc false
def changeset(post, attrs) do
post
|> cast(attrs, [:title, :content]) # 如果有那些欄位要從 controller 或 ecto model api 加進去,比方說 language_id,那就要在這裡多寫 -> cast(attrs, [:title, :content, :language_id]) 不然他不會新增進去資料庫喔
|> validate_required([:title, :content]) # 這裡是新增時必要的欄位,如果不再欄位或是資料有少就會噴錯誤告訴你此欄位不能留空
end
end
/lib/hello/languages/language.ex:
defmodule Hello.Languages.Language do
use Ecto.Schema
import Ecto.Changeset
schema "languages" do
field :name, :string
# 注意這裡是複數個文章 + s,後面帶入 Post 的 struct 結構
has_many :posts, Hello.Posts.Post
timestamps()
end
@doc false
def changeset(language, attrs) do
language
|> cast(attrs, [:name])
|> validate_required([:name])
end
end
defmodule Hello.Repo.Migrations.CreatePost do
use Ecto.Migration
def change do
create table(:post) do
add :title, :string
add :content, :string
# 列為參考 foreign_key 定義
add :language_id, references(:languages, on_delete: :nothing)
timestamps()
end
create index(:post, [:language_id]) # foreign_key 的 index 要放進來
end
end
建立 Category
一篇文章有對應很多個 Category,因此在這裡就會碰到一個 Many-To-Many 關聯性的需求出現,設計 many-to-many 的步驟是:
- 多一張表,存放兩個資料之間的 id,表的名稱會是: [資料1 複數 s]_[資料 2 複數 s]
像是這個 chapter 的例子就是: categories_posts。 - 手動建立 ecto migration ,自己產生一個表 (不含在任何定義中)
- 兩個資料雖然沒有 foreign_key 但是可以寫 associations 的 many_to_many 關聯性定義
所以,現在要先建立 category,再來說 many_to_many 要怎樣建立。
mix phx.gen.html Categories Category categories name:string
這裡只是很單純的建立一個 category,什麼關聯性 references 都不用寫,因為不需要。
接著,要開始設計 Many To Many 表了,要先用 ecto 的指令建立出 migration 的腳本,然後自己編輯新增:
mix ecto.gen.migration create_categories_posts
編輯生產出來的 migration 腳本 hello/priv/repo/migrations/20210626160659_create_categories_posts.exs:
defmodule Hello.Repo.Migrations.CreateCategoriesPosts do
use Ecto.Migration
def change do
# 雙複數,建立一張表
create table(:categories_posts) do
add :category_id, references(:categories) # 注意這裡 references 到的是複數 s
add :post_id, references(:posts) # 注意這裡 references 到的是複數 s
end
# 建立單獨的 index, 可 unique 是因為一個文章不會重疊相同的 category 超過 1 次,e.g: #美食 #美食 #美食
create unique_index(:categories_posts, [:category_id, :post_id])
end
end
defmodule Hello.Posts.Post do
use Ecto.Schema
import Ecto.Changeset
schema "posts" do
field :content, :string
field :title, :string
# 注意第一個參數是單數,然後接著給的是 struct 結構,然後告訴這個 db 要把 post 的外來鍵命名為 language_id
belongs_to :language, Hello.Languages.Language, foreign_key: :language_id
# 新增多對多關聯性定義
# 注意這裡用的是複數, 第二個參數要給定 struct 結構,然後告訴 ecto 你要透過 categories_posts 這張表加入這筆資料
many_to_many :categories, Hello.Categories.Category, join_through: "categories_posts"
timestamps()
end
@doc false
def changeset(post, attrs) do
post
|> cast(attrs, [:title, :content])
|> validate_required([:title, :content])
end
end
defmodule Hello.Categories.Category do
use Ecto.Schema
import Ecto.Changeset
schema "categories" do
field :name, :string
# 新增多對多關聯性定義
# 注意這裡用的是複數, 第二個參數要給定 struct 結構,然後告訴 ecto 你要透過 categories_posts 這張表加入這筆資料
many_to_many :posts, Hello.Posts.Post, join_through: "categories_posts"
timestamps()
end
@doc false
def changeset(category, attrs) do
category
|> cast(attrs, [:name])
|> validate_required([:name])
end
end
alias Hello.Categories alias Hello.Repo Categories.get_category!(2) # 發現 post 寫 not loaded Categories.get_category!(2) |> Repo.preload([:posts]) # 發現 posts 都被讀到了
Ecto Model API 層的操作法
什麼是 Ecto Model API? 在 Phoenix 專案架構中,可以透過以下結構說明來理解 lib 有什麼:
- lib
- hello <- 很單純的資料定義、資料操作,資料操作就是 Ecto Model API
- (目錄) categories
- category.ex <- 資料定義 (資料庫、changeset、插入資料要檢查、轉型)
- (目錄) languages
- language.ex <- 資料定義
- (目錄) posts
- post.ex <- 資料定義
- (檔案) categories <- Ecto Model API: 包含 CRUD 操作、自訂操作
- (檔案) languages <- Ecto Model API
- (檔案) posts <- Ecto Model API
- ....略
- hello_web <- Controller, Views 以及 Router 各種 web 物件都放在這裡
- ...略
由此大致可知,你的 Model 單數為檔名的檔案,會用來處理資料庫定義、資料轉換 (如明文密碼轉 hash、cast、changeset)。
複數為檔名的檔案,會用來處理 Repo 執行、查詢、寫 query 查詢、或做外部資料處理、read file、write file、send email、send sms、add queue job 等等。
在資料定義中,如果是寫 accounts 等帳號密碼使用者定義,還可以找到有 field 含有 virtual: true 屬性,讓資料不會寫進資料庫,而是要透過 changeset 之前,把 virtual 自己做密碼 hash,然後傳到真正的資料庫欄位,再寫入。
比方說帳號密碼會是這樣做:
- field passowrd, :string, virtual: true < 不會寫入資料庫,待轉換成 hash
- field password_hash, :string <- 會寫入資料庫,不過你要自己轉換寫入
在 Model API 中依照 Category id 列出 Post (關聯性操作)
在 /lib/hello/posts.ex 檔案中,下面加入一個 function:
def list_post_by_category(category_id) do
# ^ pin 運算子是為了讓 category_id 變數變成一個確切固定的顯值,而且再也不會變動,已不是"變"數
query = from p in Post,
join: cp in "categories_posts",
on: cp.category_id == ^category_id,
distinct: p.id, # 選取不重複的 post id
select: p
Repo.all(query)
end
然後,在 iex -S mix 中,就可以這麼呼叫:
Hello.Posts.list_post_by_category(2)
建立 Many-To-Many 的 Category 與 Post 綁定
要幫 Post 加上各種 category 分類,可以這麼做:
在 /lib/hello/posts.ex 檔案中,在 create_post 這附近做修改:
def create_post(attrs \\ %{}) do
%Post{}
|> Post.changeset(attrs)
|> Repo.insert()
end
# 新增
# multiple select
def bind_post_categories(post_id, category_ids) do
for c_id <- category_ids do
Repo.insert_all "categories_posts", [ %{
"category_id"=> c_id,
"post_id" => post_id
} ], returning: [:id]
end
# 另一總做法也可以用 Enum.map()
# bind_post_with_categories = Enum.map(category_ids, fn c_id ->
# { category_id: c_id, post_id: post_id}
# end)
#然後丟到 insert_all 後面那個陣列
end
讓 Language id 預設可以帶入 Post 一起新增進資料庫
defmodule Hello.Posts.Post do
use Ecto.Schema
import Ecto.Changeset
schema "posts" do
field :content, :string
field :title, :string
# 注意第一個參數是單數,然後接著給的是 struct 結構,然後告訴這個 db 要把 post 的外來鍵命名為 language_id
belongs_to :language, Hello.Languages.Language, foreign_key: :language_id
# 新增多對多關聯性定義
# 注意這裡用的是複數, 第二個參數要給定 struct 結構,然後告訴 ecto 你要透過 categories_posts 這張表加入這筆資料
many_to_many :categories, Hello.Categories.Category, join_through: "categories_posts"
timestamps()
end
@doc false
def changeset(post, attrs) do
# 新增 cast, validate_required
post
|> cast(attrs, [:title, :content, :language_id])
|> validate_required([:title, :content, :language_id])
end
end
Phoenix Controller / Views
首先,要先針對剛才 generate 出來的 html 做套用到 router 上才會看到視覺化的結果,要修改的是 lib/hello_web/router.ex:
scope "/", HelloWeb do
pipe_through :browser
get "/", PageController, :index
resources "/posts", PostController
resources "/categories", CategoryController
resources "/languages", LanguageController
end
alias Hello.Categories alias Hello.Languages # 在 :new, :edit 的時候,查詢一下 languages 列表 # :loca_categories 是對應到 load_categories 這個 func plug :load_languages when action in [:new, :edit] # 在 :new, :edit 的時候,查詢一下 categories 列表 # :loca_categories 是對應到 load_categories 這個 func plug :load_categories when action in [:new, :edit] defp load_languages(conn, _) do languages = Languages.list_languages() conn |> assign(:languages, languages) end defp load_categories(conn, _) do categories = Categories.list_categories() conn |> assign(:categories, categories) end
現在,在 /posts/new 中雖然還沒有東西,但現在就要加上去了,在 /lib/hello_web/templates/post/form.html.eex 這個檔案看一下:
為什麼要看這個檔案? 因為原本 controller 呼叫的是 /lib/hello_web/templates/post/new.html.eex,可是這個檔案內部有呼叫渲染表單出來。
對這個檔案新增兩個選項出來
/lib/hello_web/templates/post/form.html.eex:
<%= form_for @changeset, @action, fn f -> %>
<%= if @changeset.action do %>
<div class="alert alert-danger">
<p>Oops, something went wrong! Please check the errors below.</p>
</div>
<% end %>
<%= label f, :title %>
<%= text_input f, :title %>
<%= error_tag f, :title %>
<%= label f, :content %>
<%= text_input f, :content %>
<%= error_tag f, :content %>
<!-- Enum.map(@languages, &{&1.name, &1.id}) 這個用法是把它變成 { name:XXX, id: XXX } 形式列成列表, & 是 fn x, x 簡化為值的本身,就變成用 & 呼叫-->
<%= label f, :language_id %>
<%= select f, :language_id, Enum.map(@languages, &{&1.name, &1.id}), prompt: "Choose Language" %>
<%= error_tag f, :language_id %>
<!-- Enum.map(@categories, &{&1.name, &1.id}) 這個用法是把它變成 { name:XXX, id: XXX } 形式列成列表, & 是 fn x, x 簡化為值的本身,就變成用 & 呼叫-->
<%= label f, :category_ids %>
<%= multiple_select f, :category_ids, Enum.map(@categories, &{&1.name, &1.id}), prompt: "Choose Category" %>
<%= error_tag f, :category_ids %>
<div>
<%= submit "Save" %>
</div>
<% end %>
接著,需要修改 Controller 接收到額外這兩個參數要做什麼事,首先, language_id 不需要做任何事,因為 Model Struct 中早就會做 cast 把 language_id 轉換,問題應該就會在 categories 要新增出來,怎麼做。
def create(conn, %{"post" => post_params}) do
# 把字串轉換為 int
# ["1", "2"] -> [1, 2]
category_ids_int = Enum.map(post_params["category_ids"], fn str_id ->
{int_id, _p} = Integer.parse(str_id)
int_id # 回傳
end)
case Posts.create_post(post_params) do
{:ok, post} ->
# 在這裡做文章 category 新增
Posts.bind_post_categories(post.id, category_ids_int)
conn
|> put_flash(:info, "Post created successfully.")
|> redirect(to: Routes.post_path(conn, :show, post))
{:error, %Ecto.Changeset{} = changeset} ->
render(conn, "new.html", changeset: changeset)
end
end
如此一來,在 /posts/new 就可以看到各種選項可以被新增了。
列出分類的文章
大致上,就是要讓 Categories 頁面可以 show 出只有包含 category_id 的 post 頁面。
在這裡,嘗試兩種做法來顯示 category:
- Query: ?category_id=1 在 Controller 中做 Pattern Matching
- Routing: /posts/cate/:category_id 在 Controller 讀取
作法 1
這個方法希望用 /posts?category_id=1 這個方式來顯示,他的做法是在 post_controller.ex 建立 pattern matching 的 index function:
# 注意 pattern matching 有順序性,這個有比對值得要先
def index(conn, %{ "category_id" => category_id }) do
# 除錯時可以看到他會不會進來 pattern matching
IO.puts("=============================================")
IO.inspect(category_id)
IO.puts("=============================================")
# 字串的 categroy_id 應該要轉成數字
{ category_id_int, _p } = Integer.parse(category_id)
post = Posts.list_post_by_category(category_id_int)
render(conn, "index.html", post: post)
end
# 預設不 care params 的要放後面
def index(conn, _params) do
post = Posts.list_post()
render(conn, "index.html", post: post)
end
注意,pattern matching 要加在 index 預設值的上方,才有可能會 fall-in,關於 pattern matching,也可以想成是 switch case,如果你提早進入 case 如果你沒有繼續讓 case 往下做,那下面的 case 也不會被 matching 到。
在這裡的例子,你可以想像你的 switch case 的 default 比 case 提早寫,所以會直接進入 default。
直接在網址找: /posts?category_id=2 ,就可以發現他會過濾文章了,而且不加的時候,可以看到第一個 matching 完全不會進去,看終端機有沒有 ===== 就知道了。
然後,還希望可以從 /categories 這個頁面可以連過來這個網站還要附加參數,要怎麼做?
在 /lib/hello_web/templates/cateroy/index.html.eex 中,新增一個 link:
<%= for category <- @categories do %>
<tr>
<td><%= category.name %></td>
<td>
<span><%= link "Show", to: Routes.category_path(@conn, :show, category) %></span>
<span><%= link "Edit", to: Routes.category_path(@conn, :edit, category) %></span>
<span><%= link "Delete", to: Routes.category_path(@conn, :delete, category), method: :delete, data: [confirm: "Are you sure?"] %></span>
<!-- 注意這裡的 routes 是單數, Routes.____ 有哪些,可以用 mix phx.routes 指令看到 -->
<!-- 注意,方法一是用 query stirng 當作參數, post_path 最後一個參數要用 map %{ ... } -->
<span><%= link "Posts", to: Routes.post_path(@conn, :index, %{ "conference_id" => category.id }) %></span>
</td>
</tr>
<% end %>
第三個參數,如果在這裡都不加,就等於是找原始沒有 query string pattern matching 的 controller function。
作法 2
作法二要嘗試的是新增到 Router 去,看能不能用 url params 處理,像是這樣:
/posts/cate/:category_id
由於上面已經用過 Pattern Matching 而且在這裡的做法會一模一樣,因此這裡想換成讓 Router 去執行特定的 Controller: list_by_category。
直接新增到 post_controller:
def list_by_category(conn, %{ "category_id" => category_id }) do
# 除錯時可以看到他會不會進來 pattern matching
IO.puts("=============================================")
IO.inspect(category_id)
IO.puts("=============================================")
# 字串的 categroy_id 應該要轉成數字
{ category_id_int, _p } = Integer.parse(category_id)
post = Posts.list_post_by_category(category_id_int)
render(conn, "index.html", post: post)
end
這個 function 跟 index 是完全一模一樣的。
現在,要在 router.ex 中新增一個 router 進來:
scope "/", HelloWeb do
pipe_through :browser
get "/", PageController, :index
resources "/posts", PostController
resources "/categories", CategoryController
resources "/languages", LanguageController
# 新增
get "/posts/cate/:category_id", PostController, :list_by_category
end不過此刻,在 /categories 頁面中,要跳轉過來的方式就完全不同,需要注意 Routes.xxx_path 是不同的,請注意 /lib/hello_web/templates/cateroy/index.html.eex 會像這樣:
<!-- 注意,方法二是用 url params 當作參數, post_path 最後一個參數要依序使用單獨參數 --> <!-- 換言之,如果你的 rotuer 是 /cate/:category_id/:a/:b ,那你也必須給成: --> <!-- Routes.post_path(@conn, :list_by_category, category.id, "a", "b" ) --> <span><%= link "Posts", to: Routes.post_path(@conn, :list_by_category, category.id ) %></span>
然後,在 /categories/ 就可以看到點擊按鈕,會跳到 /posts/cate/2 這樣的 router。
Pipeline 小記:
個人的見解,對於很多 Functions work 呼叫都會有一系列的整合 Function,例如 Controller 本身會做很多的事,而為了 keep code dry,把很多功能拆成 function ,然後在組合的時候,用 pipeline (|>) 把它們的工作串接再一起,也就是用來掩飾複雜工作的一種用法。
Reference:
http://blog.plataformatec.com.br/2016/05/ectos-insert_all-and-schemaless-queries/
https://stackoverflow.com/questions/44879027/how-to-make-scaffold-for-two-entities-relations-with-elixir-phoenix
沒有留言:
張貼留言