本章紀錄關於 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
沒有留言:
張貼留言