本篇文章是對個人學習 Elixir 之路做的紀錄,學習過程中有點小顛坡,從 2021-01-01 開始正式學習 Elixir 生態,過程中都是看 pragprog 出版社的書居多 (Chris McCord 的那幾本 Phoenix, Meta-programming 和 Craft GraphQL APIs in elixir with absinthe),跟一本歐萊禮的 Introducing Elixir: Getting Started in Functional Programming。
學習障礙主要是我不是買最新 Edition 的書,是舊版的 pdf,舊版的 pdf 所述說的 Phoenix 架構其實有變得比較不太一樣,傳參數的寫法也不同,像是 params \\ :empty 現今也直接用 params \\ %{} 取代了,新版的 Phoenix 那樣跑會直接出錯給你看,還有一些加密套件 comeonin 也不是書上的那種用法了,不過我覺得靠著一些個人的見解跟強勢的經驗法則,還蠻快就克服這些問題,可是對於完全新手來說,其實沒有一個指標性又最新的書可以參考,畢竟我覺得這個生態還蠻缺少知識資源的累積。 但要是對 Elixir 有一個了解之後,其實這些就都不是障礙了,最快速的學習方法是如何盡快的掌握 Elixir,在顯然在舊資源 + Google 的情況下還是可以解決的。
關於這個文章,是 Building GraphQL pattern with Ecto 的上集,會講述建專案、結構到 Ecto 操作資料庫 (Ecto 不是 ORM,他是將資料庫操作框架抽象化成工具,可以通用多個資料庫),而且會寫幾個查詢,讓這些查詢在下集時被使用。
*小提醒: 回查文件 (像是 Ecto),應該是解決問題的好方法,因為 Elixir 生態似乎都是如此,至少官方還有把文件寫得比較好一點
建立一個 Phoenix 專案 For GraphQL APIs
下集,我們要靠 Phoenix 這個 Web 專案當作基底,然後在上面蓋上 GraphQL , Phoenix 本身就是跑在 OTP Server 上,整個專案已經幫我們處理好基本架構的事務,只剩下我們需要對專案進行業務邏輯增減。
直接輸入指令:
mix phx.new drent --no-webpack --no-html
這會直接建立一個 drent 的專案。
專案說明:
我們要建立一個租借的系統,角色大致上有以下規劃:
- Staff 員工
- Profile 職員檔案
- Rental 租借合約
- 每個租借合約都有一個 Staff 員工
- 租借合約有多個 Devices ,建立 Rental - Device Many-to-Many Table
- Device
- 每個 Device 都有一個 Staff 作為擁有者
然後,我們要對這個專案加入 Dependencies,直接打開專案下的 mix.exs:,在 dependencies 處加上下面這三個套件:
defp deps do [ {:absinthe, "~> 1.6"}, {:absinthe_plug, "~> 1.5"}, {:jason, "~> 1.1"},
這三個套件是針對 GraphQL 的支援,也就是 Absinthe 套件。
然後,輸入指令進行安裝:
mix deps.get
接著,你需要一個 PostgreSQL 資料庫,如果這個資料庫不是放在自己的電腦上,則需要到 config/dev.exs 檔案中去修改名稱。
然後,你要進去你的資料庫,去建立一個 DB:
CREATE DATABASE drent_dev;
建立 Business Model 與資料 Model
使用指令自動增加我們需要的 schema:
mix phx.gen.context Users Staff staffs fullname:string mix phx.gen.context Users Profile profiles company:string, role:string mix phx.gen.context Devices Device devices name:string sn:string mix phx.gen.context Rentals Rental rentals title:string reason:string
以上四個 context generation,是針對業務需求所需要的 4 大類別,他會透過指令幫我們預先在 lib/drent/xxx 建好。
專案結構上,這篇文章現在的 Phoenix 是這麼分的:
- lib/drent/ 放 Repository Pattern 相關的類別,也就是這些單純的資料存取操作的工作以及 Typing
- lib/drent_web 放所有與 Web 有關的東西,像是 GraphQL Resovler, REST API, WebSocket, ....etc,其中也包含 MVC 的 Views 和 Controllers
總之,新增這四個大類別之後,需要對他們的資料進行一些增修,總共要更動:
- /lib/drent/devices/device.ex
- /lib/drent/rentals/rental.ex
- /lib/drent/users/staff.ex
- /lib/drent/users/profile.ex
*注意 Phoenix Project 中所有的複數 s 詞,是會自動分出來而且是有意義在的,需要特別在有 s 和沒有 s 之間區分,尤其是 atom symbol 中,像是 :rental 和 :rentals, :staff 和 :staffs。
以下修改中,全部是在改關聯性,只有關聯性需要手動修正,裡面主要用到了 has_one, has_many, many_to_many, 以及屬於的 belongs_to 的操作。
belongs_to 跟 has_one 本質做的事情都一樣,只是所屬的角色不同,有一個人是 has_one,另一個人就要用 belongs_to ,如果不加 belongs_to, Ecto 恐怕不會幫你做很多關聯事務處理,事實上這不是在對資料庫做 Table 更動,Table 更動是等會要做的另一件事: Migration。
/lib/drent/devices/device.ex:
schema "devices" do field :name, :string field :sn, :string # 一個裝置屬於一個擁有者 belongs_to :staff, Drent.Users.Staff, foreign_key: :staff_id # 多個裝置可以有多個租借 many_to_many :rentals, Drent.Rentals.Rental, join_through: "rentals_devices" timestamps() end
schema "rentals" do field :reason, :string field :title, :string # 一個租借屬於一個 staff (要先有 staff 才有租借,所以不是用 has_one, 是 belongs_to) belongs_to :staff, Drent.Users.Staff # 不同的租借可以有不同很多個 devices many_to_many :devices, Drent.Devices.Device, join_through: "rentals_devices" timestamps() end
schema "staffs" do field :fullname, :string # 一個 staff 有一個 profile has_one :profile, Drent.Users.Profile # 一個 staff 有很多的租借 has_many :rentals, Drent.Rentals.Rental # 一個人可以是很多裝置的擁有者 has_many :devices, Drent.Devices.Device timestamps() end
/lib/drent/users/profile.ex:
schema "profiles" do field :company, :string field :role, :string # 一個 profile 屬於一個 staff belongs_to :staff, Drent.Users.Staff timestamps() end
以上,完成對資料模型的更改,現在要做資料庫的關聯性,這要透過 Migrations 處理,原則上 Phoenix 在一開始你 gen 完這些 type models 的時候,都幫你在 priv/repo/migrations/* 建立好了,如果要自己手動建立,可以輸入指令:
mix ecto.gen.migration [your_custom_migration_name]
現在,我們要建立一個租借與裝置 (rentals_devices) 的 many-to-many 表,然後也對 Phoenix 先前建立好的 migration 一起做修改,請輸入指令:
mix ecto.gen.migration create_rentals_devices
然後要對以下檔案做變動:
- /priv/repo/migrations/xxxxxxxx_create_devices.exs
- /priv/repo/migrations/xxxxxxxx_create_rentals.exs
- /priv/repo/migrations/xxxxxxxx_create_profiles.exs
- /priv/repo/migrations/xxxxxxxx_create_rentals_devices.exs
def change do create table(:devices) do add :name, :string add :sn, :string # 是嗎? # 一個裝置屬於一個擁有者 add :staff_id, references(:staffs) timestamps() end # 注意不可這樣寫,這樣一個人只能擁有一個 devices # create unique_index(:devices, [:staff_id]) end
/priv/repo/migrations/xxxxxxxx_create_rentals.exs:
def change do create table(:rentals) do add :title, :string add :reason, :string # 一個租借屬於一個 staff add :staff_id, references(:staffs) timestamps() end end
/priv/repo/migrations/xxxxxxxx_create_profiles.exs:
def change do create table(:profiles) do add :company, :string add :role, :string # profile 是屬於某一個 staff 的 add :staff_id, references(:staffs) timestamps() end end
/priv/repo/migrations/xxxxxxxx_create_rentals_devices.exs:
def change do create table(:rentals_devices) do add :rental_id, references(:rentals) add :device_id, references(:devices) end create unique_index(:rentals_devices, [:rental_id, :device_id]) end
以上都完成後,還剩下最後一個種子 Seeds 需要新增,這是給我們測試資料預設用的,我們甚至可以從這裡了解到一些資料結構方面的樣子,修改 priv/repo/seeds.exs:
alias Drent.Users.Staff alias Drent.Users.Profile alias Drent.Devices.Device alias Drent.Rentals.Rental alias Drent.Repo # 先新增裝置 d1 = %Device{ name: "Macbook Pro '15 2016", sn: "7712-1125-3262-2133" } |> Repo.insert! d2 = %Device{ name: "Mac Pro Server AMD 2019", sn: "8584-1562-1656-1954" } |> Repo.insert! d3 = %Device{ name: "Nighthawk® 12-Stream Dual-Band WiFi 6 Router (up to 6Gbps) with NETGEAR Armor™, MU-MIMO, USB 3.0 ports", sn: "5821-5262-7585-6325" } |> Repo.insert! d4 = %Device{ name: "Surface Laptop i5 8G", sn: "1515-3262-8595-6216" } |> Repo.insert! # 新增 staffs %Staff{ fullname: "Fox", profile: %Profile{ company: "ZFZ" }, # 這個 staff 擁有哪些 devices devices: [ d1, d2 ], # 這個 staff 建立哪些租借合約? 租哪些裝置? rentals: [ %Rental{ reason: "會展需要租借電腦", title: "免費借用", devices: [ d2, d3 ] } ] } |> Repo.insert! %Staff{ fullname: "Jamón", profile: %Profile{ company: "OOP" }, devices: [ d3, d4 ], rentals: [ %Rental{ reason: "會展需要租借電腦", title: "免費借用", devices: [ d1, d3 ] } ] } |> Repo.insert!
現在,可以針對以上的 Migrations 和 Seeds 做完全設定,請直接跑指令:
mix ecto.setup
這個 setup 是針對資料庫一次性的,資料庫跑完一次,就會在資料庫的 migrations 表中記錄,因此如果要退回 setup,會需要 migration 寫 up, down ,或乾脆 drop database 然後重新來過。
要單獨跑 Seeds 也是可以的,指令是:
mix run priv/repo/seeds.exs
如果不想要包再一起寫 seeds.exs 像上面這種子結構 (%XXX{ devices: %VVV{ ... } }) 的形式的話,可以參考 build_assoc, put_assoc (接下來也會提及說明)
Ecto 查詢
一般的 Type Table 查詢,可以直接使用 Repo.get_by! 函數查詢, ! 是如果沒有資料,就會直接跳錯,如果不加驚嘆號,那你就必須自己使用 Pattern Matching 去自己客製化錯誤,等一下會做一個示範,以下是單純取得資料的 get_by,參數很簡單,只要放 Type 進去,後面接上 id 當作參數就好。
直接用指令開啟 iex -S mix 進入互動,然後進行上述的操作:
alias Drent.Repo alias Drent.Users.Staff Repo.get_by!(Staff, id: 1) #可以放任何 match 資料的參數
這裡所謂的 Type,是指在 /lib/drent/ (不是 web) 專案中定義的那些資料,像是上方的例子就是 /lib/drent/Users/Staff.ex 這個資料結構,上面這個查詢是查 Staff 為 1 的人。
然後,在此簡單探討驚嘆號的處理,在真正的程式存取操作中,不加驚嘆號的處理方法會變成:
case Repo.get_by(Staff, id: 1) do nil -> {:error, "NOT EXISTS!"} staff -> {:ok, staff} end
這個 Pattern Matching 就可以幫你自動配對如果是 nil 的 sw case 或有一般變數 (此命名為: staff) 的 sw case,要做什麼事。
查詢方法
另一種查詢方式,也可以用 Build Query 的方式進行,他是這麼寫的:
# 需要引用 Ecto.Query 裡面的那些 macro import Ecto.Query #先定義 query query = from(s in Staff, where: s.id == 1, select: s.fullname) #然後拿去查所有配對到 id == 1 的資料 Repo.all(query)
也可以用另一種 Macro 的查詢方法:
# macro 的查詢方式: query = select(Staff, [s], s.title) Repo.all(query)
將變數放入查詢的特殊作法
Ecto 操作這些 query,必須要使用固定值,傳送固定值的方法是 pin 方法( ^ ) ,而不是使用變數,因為變數會變,可以想像成,把變數序列化 (Serialize) 這樣他就是定值,不會再變了。
# 在 where 中放變數來查詢 fullname = "Fox" # want to query fox matched query = from(s in Staff, where: s.fullname == ^fullname, select: s.fullname) Repo.all(query)
上面這個例子中,把 fullname 視為是傳進來的變數,而在配對的時候,使用了 ^fullname (pin 運算子) 來把值固定下來,這等同於 s.fullname == "Fox" ,Fox 就是 ^ pin 操作後的定值。 [A6]
關聯性查詢
現在,我們隨便查了一個 Repo.get(Staff, 1) ,你會得到 Staff 結構,可是你會發現有 profile 欄位、 devices 欄位都是 Not Loaded ,這些資訊不是從資料庫來的,是從 Elixir 你寫的定義中去定義關聯性的,所以它內建有功能可以幫你帶入這個關聯性。
一查完,得到這樣的資料:
%Drent.Users.Staff{ __meta__: #Ecto.Schema.Metadata<:loaded, "staffs">, devices: #Ecto.Association.NotLoaded<association :devices is not loaded>, fullname: "Fox", id: 1, inserted_at: ~N[2021-05-23 09:22:06], profile: #Ecto.Association.NotLoaded<association :profile is not loaded>, rentals: #Ecto.Association.NotLoaded<association :rentals is not loaded>, updated_at: ~N[2021-05-23 09:22:06] }
# 第一種方法 # 沒有 load 預載狀態 staff = Repo.get(Staff, 1) # 查 id 為 1 的資料 # 幫 Staff 上預載狀態 (預載 devices) Ecto.assoc(staff, :devices) # 幫 Staff 上預載狀態 (預載 profile) Ecto.assoc(staff, :profile) # 幫 Staff 上預載狀態 (預載 rentals) Ecto.assoc(staff, :rentals) # 第二種方法 staff = Repo.get(Staff, 1) staff = Repo.preload(staff, :rentals) # 第三種方法 # 也可以用這種方式預載,查出所有 :rentals 掛在這個 Staff 底下的人,也可以繼續加 ,:devices, :profile... Repo.all(from s in Staff, preload: [:rentals])
查詢優化
query = from(s in Staff, join: r in assoc(s, :rentals), preload: [rentals: r])
Preload JOIN 操作
#欲查詢特定 rentals 的 id,可以直接用 where 比對查詢: Repo.all(from(s in Staff, join: r in assoc(s, :rentals), where: r.id == 1 ,preload: [rentals: r]))
#欲查詢特定 rentals 的 title 狀況,可以使用 like 放在 where 裡面查詢試試看: titlelike = "免費" like_cond = "%#{titlelike}%" query = from(s in Staff, join: r in assoc(s, :rentals), where: like(r.title, ^like_cond) ,preload: [rentals: r])
JOIN 操作
alias Drent.Rentals.Rental alias Drent.Devices.Device query = from r in Rental, join: mdtable in "rentals_devices", on: r.id == mdtable.rental_id, join: d in Device, on: d.id == mdtable.device_id, select: { r, d }
#我們也可以用 where 在 join 中查詢 d.id 為 1 的 query = from r in Rental, join: mdtable in "rentals_devices", on: r.id == mdtable.rental_id, join: d in Device, on: d.id == mdtable.device_id, where: d.id == 1, select: { r, d }
Sub-query with Join 的查法
#也可以用 subquery 做到限制,雖然以下沒有認真細查 pagnition 情境,但用 limit 限制查詢到的資料有限,以節省效能 query_rental_id = 1 minimal_mdtable = from rd in "rentals_devices", where: rd.rental_id == ^query_rental_id, select: %{ rental_id: rd.rental_id, device_id: rd.device_id }, limit: 1 query = from r in Rental, join: mdtable in subquery(minimal_mdtable), on: r.id == mdtable.rental_id, join: d in Device, on: d.id == mdtable.device_id, where: r.id == ^query_rental_id, select: { r, d }
Fragment Raw 片段查法
from s in Staff, where: fragment("staffs.id = ?", 1) == 1 and fragment("lower(?)", "fox") == "fox"
業務邏輯需要的查詢
先寫一些之後要給 GraphQL 用的查詢:
列出使用者:
# 列出使用者 # GraphQL 也許可根據 __typename 去 preload 使用者的資料 # 新增使用者 + 新增 Profile 並同時綁定 alias Drent.Users.Profile alias Drent.Users.Staff alias Drent.Devices.Device alias Drent.Rentals.Rental alias Drent.Repo # 之後要改寫 def 形式 new_user_with_profile = fn(fullname, company) -> # 簡單新增結構 staff = %Staff{ fullname: fullname } # 新增到資料庫 staff = Repo.insert!(staff) # 建立一個 profile 關聯性,而且是綁到 staff 上 (會自動綁到 staff_id 這個地方, staff_id 是自動的!!) profile = Ecto.build_assoc(staff, :profile, %{ company: company }) # 建立 profile 並且含有關聯性 Repo.insert!(profile) end # 呼叫方法: new_user_with_profile.("WAWA", "COOOOLMAN")
新增 Devices 並設定 Devices 屬於某個使用者:
# 新增 Devices 並設定 Devices 屬於某個使用者 new_devices_belong_to_staff = fn(name, sn, staff_id) -> device = %Device{ name: name, sn: sn, staff_id: staff_id # 原本我們做的 belongs_to 具有這個效應 } |> Repo.insert! device end # 呼叫方法: new_devices_belong_to_staff.("NEW THINGS", "1515-2323-2323-6666", 1)
新增租借 + Devices 綁定到 rentals_devices 表:
這個部分就會需要使用 Ecto.Changeset.put_assoc/4 去做儲存,這是給 many_to_many 所使用的建立關聯性方法,build_assoc 是無法處理的,也因為 rentals devices 都沒有 foreign_keys,所以作為替代,要使用 Ecto 變更集跟 put_assoc 處理。
# 新增租借 + Devices 綁定到 rentals_devices 表 # 參數上, title: string, reason: string, staff_id: int # 唯讀 [:devices | devs] 是辨別陣列的 Pattern Matching: # 用法上會像是: [:devices, 1, 2, ,3, 4],一開頭先告訴這個 pattern 有 :devices 這個 symbol 作為起始 # 這樣 devs 就會是 list [1,2,3,4] new_rental_bind_devices = fn (title, reason, staff_id, [:devices | devs]) -> # 新增一個租借,並新增到資料庫 rent = %Rental { title: title, reason: reason, staff_id: staff_id, } |> Repo.insert! # 這是很特別的做法,因為 [:devices | devs ] 傳進來都是 int 陣列, 我們假裝他是預載過的資料 # 因為實際上建立關聯性不需要那麼多資訊 # for 會自動建起 list, 每個元素都是 do 裡面產生的 devices = for d <- devs do %Device{ id: d, __meta__: %Ecto.Schema.Metadata{ source: :devices, state: :loaded } } end # 預載資料 (尤其 :devices 需要關聯性)、建立變更集、將關聯性資料放到 devices 中、更新 rent |> Repo.preload([:devices, :staff]) |> Ecto.Changeset.change() |> Ecto.Changeset.put_assoc(:devices, devices) |> Repo.update() end # 呼叫方法: new_rental_bind_devices.("FREE RENT", "I NEED IT", 1, [:devices, 1, 2, 3])
上述有提到一個很特別的作法: devices = [假預載資料] ,社群中的朋友們認為, devices 應該都要做 Repo.get_by!() 去載入每一個 device 結構,那樣結構就不再是 [ :devices, 1,2,3,4] 而會變成 [ :devices, %Device{}, %Device{}...],而上述這個寫法,也是社群一部分朋友認為建立關聯性只是要用到 id ,需要兩邊的關聯性資料全部都要帶出來才能建立嗎? [A7] [A8] [A9] [A10]
變更集 Changeset, Insert, Update, Remove
Insert, Remove 似乎都是 Repo 內建就幫我們辦到的事情,唯有 Update 還需要再思考一下,變更集是 Ecto 的一個解決方案,他可以用模組的形式做資料更改,也可以做到確認資料的驗證、淺在錯誤、正確性。
詳情可以參考: https://elixirschool.com/zh-hant/lessons/ecto/changesets/
在這裡使用 Changeset 示範如何變更 Staff 姓名 [A11]:
# 修改某個使用者的 fullname change_staffname_by_id = fn(staff_id, fullname) -> alias Drent.Users.Staff alias Drent.Repo Repo.update(Ecto.Changeset.cast(%Staff{ id: (staff_id, }, %{ "fullname" => fullname) }, [:fullname])) end # 呼叫方法: change_staffname_by_id .(1, "FOX 2")
你可能會需要帶出資料,或是像上面使用 %Staff{} 具體有 id 結構的東西,然後做成 changeset,沒辦法單純做更新。 詳情可以參考 [A12] - Chapter 8. Making changes with Ecto.Changeset - 8.1. Can’t I just ... update? : Nope. You can’t.
2021/02/20 著作, 2021/05/24 發佈。
References:
http://blog.plataformatec.com.br/2015/08/working-with-ecto-associations-and-embeds/
https://elixirforum.com/t/how-to-seed-pivot-table-relations/4494/4
https://hexdocs.pm/ecto/Ecto.html#build_assoc/3
https://elixirschool.com/zh-hant/lessons/ecto/associations/
https://hexdocs.pm/ecto/Ecto.Query.html
https://elixirforum.com/t/ecto-not-allowing-string-interpolation-in-fragments/28459/3
https://elixirforum.com/t/seeding-database-with-relationships/13240
https://hexdocs.pm/ecto/Ecto.html#build_assoc/3
https://elixirforum.com/t/help-filtering-many-to-many-associations-with-ecto/4210/4
https://riptutorial.com/elixir/example/6956/pattern-matching-on-a-list
https://hexdocs.pm/ecto/Ecto.Schema.html#has_many/3
https://elixirforum.com/t/build-assoc-vs-put-assoc/24071
https://medium.com/@andreichernykh/thoughts-on-structuring-an-elixir-phoenix-project-cb083a8894ef
[A5] https://stackoverflow.com/questions/39896713/how-can-i-preload-association-and-get-it-returned-in-ecto
[A6] https://stackoverflow.com/questions/33324302/what-are-elixir-bang-functions
[A7] https://hexdocs.pm/ecto/Ecto.Changeset.html#put_assoc/4
[A8] https://gist.github.com/cblavier/356e662fdbc7910cc4bc39737d7851c2
[A9] https://elixirforum.com/t/updating-many-to-many-with-put-assoc-and-an-array-of-ids/8648/2
[A10] https://elixirschool.com/zh-hant/lessons/ecto/associations/#%E5%84%B2%E5%AD%98%E9%97%9C%E8%81%AF%E8%B3%87%E6%96%99
[A11] https://hexdocs.pm/ecto/Ecto.Changeset.html#cast/4
[A12] https://livebook.manning.com/book/phoenix-in-action/chapter-8/9
沒有留言:
張貼留言