檢視原始碼 情境
必要條件:本指南假設您已閱讀請求生命週期指南。
必要條件:本指南假設您已閱讀Ecto 指南。
到目前為止,我們建置了頁面,透過路由器連接控制器動作,並瞭解 Ecto 如何驗證及儲存資料。現在是時候透過撰寫與更廣泛的 Elixir 應用程式互動的網頁功能將所有功能整合在一起了。
在建置 Phoenix 專案時,我們首先建置 Elixir 應用程式。Phoenix 的工作是提供網頁介面以供 Elixir 應用程式使用。我們當然會使用模組及函式撰寫應用程式,但我們常常會賦予特定模組特定責任並為其命名,例如控制器、路由器和動態檢視。
與其他所有元件一樣,Phoenix 中的 context 是模組,但具有劃分界線及群組功能的獨特責任。換句話說,context 可以讓我們推理及討論應用程式設計。
關於 context 的思考
Context 是專門的模組,用於公開及群組相關功能。例如,不論您使用 Elixir 標準函式庫中的 Logger.info/1
或 Stream.map/2
,您就是在存取不同的 context。在 Elixir 內部,記錄器是由多個模組組成的,但我們永遠不會直接與那些模組互動。我們會呼叫Logger
模組,原因在於它公開並群組所有記錄功能。
透過將公開及群組相關功能的模組命名為context,我們可以協助開發人員識別這些模式並進行討論。在一天的最後,context 就像模組一樣,就像控制器、檢視等。
在 Phoenix 中,脈絡經常封裝資料存取與資料驗證。它們通常會與資料庫或 API 通信。總體而言,可以將它們視為解構並隔離應用程式部分的界線。讓我們運用這些概念來建置我們的網頁應用程式。我們的目標是建置一個電子商務系統,在其中我們可以展示產品、允許使用者將產品加入購物車,並完成訂單。
如何閱讀此指南:對於初級和中級 Elixir 程式設計師而言,使用脈絡產生器都是快速上手且審慎撰寫應用程式的絕佳方法。本指南專注於這些讀者。
新增目錄脈絡
電子商務平台在程式碼庫中具有廣泛的耦合,因此思考撰寫定義良好的模組非常重要。有此考量後,我們的目標是建置一組產品目錄 API,針對系統中可用的產品進行建立、更新和刪除。我們將從展示產品的基本功能開始,稍後會新增購物車功能。我們將瞭解從具備隔離界線的穩固基礎開始,如何讓我們能在新增功能時自然擴充應用程式。
Phoenix 包含 mix phx.gen.html
、mix phx.gen.json
、mix phx.gen.live
和 mix phx.gen.context
產生器,它們將隔離應用程式中功能的概念套用到脈絡中。這些產生器是快速上手的絕佳方法,且 Phoenix 會在適當的方向提示您擴充應用程式。讓我們將這些工具用於我們的新產品目錄脈絡中。
若要執行脈絡產生器,我們需要想出一個模組名稱,來將我們正在建構的相關功能分組。在 Ecto 指南 中,我們瞭解了如何使用變更集和儲存庫來驗證和保留使用者架構,但我們並沒有將這整合到廣泛的應用程式中。事實上,我們完全沒有思考過應用程式中的「使用者」應該存在哪裡。讓我們退一步,思考系統的不同部分。我們知道我們會有產品在銷售頁面上展示,以及產品描述、訂價等資訊。除了銷售產品之外,我們知道我們需要支援購物車、結帳等功能。雖然所購買的產品與購物車和結帳流程相關,但展示產品和管理我們產品的「展覽」與追蹤使用者已放入購物車的品項或下單的方式截然不同。對於我們的產品詳細資料和展示我們可供銷售的產品的管理而言,Catalog
脈絡是一個理想的地方。
命名事物很困難
如果您在試圖想出上下文名稱時遇到瓶頸,而系統中的群組功能尚未清楚,您可以簡單使用要建立的資源的複數形式。舉例來說,用於管理產品的
產品
上下文。隨著您的應用程式越來越龐大,而系統的各個部分也變得清晰,您可以簡單地將上下文重新命名為更精確的內容。
要開始我們的目錄上下文,我們將使用 mix phx.gen.html
,它會建立一個上下文模組,包含 Ecto 存取權,用於建立、更新和刪除產品,以及網頁檔案,例如用於介面的控制器和範本。在您的專案根目錄執行以下指令
$ mix phx.gen.html Catalog Product products title:string \
description:string price:decimal views:integer
* creating lib/hello_web/controllers/product_controller.ex
* creating lib/hello_web/controllers/product_html/edit.html.heex
* creating lib/hello_web/controllers/product_html/index.html.heex
* creating lib/hello_web/controllers/product_html/new.html.heex
* creating lib/hello_web/controllers/product_html/show.html.heex
* creating lib/hello_web/controllers/product_html/product_form.html.heex
* creating lib/hello_web/controllers/product_html.ex
* creating test/hello_web/controllers/product_controller_test.exs
* creating lib/hello/catalog/product.ex
* creating priv/repo/migrations/20210201185747_create_products.exs
* creating lib/hello/catalog.ex
* injecting lib/hello/catalog.ex
* creating test/hello/catalog_test.exs
* injecting test/hello/catalog_test.exs
* creating test/support/fixtures/catalog_fixtures.ex
* injecting test/support/fixtures/catalog_fixtures.ex
Add the resource to your browser scope in lib/hello_web/router.ex:
resources "/products", ProductController
Remember to update your repository by running migrations:
$ mix ecto.migrate
正如預期般,Phoenix 在 lib/hello_web/
中產生了網頁檔案。我們也可以看到,我們的上下文檔案產生於 lib/hello/catalog.ex
檔案中,而我們的產品架構則產生於同名的目錄中。請注意 lib/hello
與 lib/hello_web
的差異。我們有一個 目錄
模組,可作為產品目錄功能的公開 API,同時也有 目錄。產品
結構,它是一個 Ecto 架構,用於轉譯和驗證產品資料。Phoenix 也為我們提供了網頁和上下文測試,同時也包含了透過 Hello.目錄
上下文建立實體的測試輔助函式,我們稍後會檢視。現在,讓我們依據主控台說明按部就班,在 lib/hello_web/router.ex
中新增路由
scope "/", HelloWeb do
pipe_through :browser
get "/", PageController, :index
+ resources "/products", ProductController
end
隨著新路由的到位,Phoenix 提醒我們透過執行 mix ecto.migrate
來更新我們的儲存庫,但首先我們必須對 priv/repo/migrations/*_create_products.exs
中產生的遷移做一些微調
def change do
create table(:products) do
add :title, :string
add :description, :string
- add :price, :decimal
+ add :price, :decimal, precision: 15, scale: 6, null: false
- add :views, :integer
+ add :views, :integer, default: 0, null: false
timestamps()
end
我們將價格欄位修改為特定的 15 位精度、6 位小數,同時加上非空約束。這樣可以確保我們以適當的精度來儲存貨幣,以進行任何數學運算。接著,我們在檢視計數中新增一個預設值和非空約束。隨著變更到位,我們準備將我們的資料庫遷移上去了。現在就動手吧
$ mix ecto.migrate
14:09:02.260 [info] == Running 20210201185747 Hello.Repo.Migrations.CreateProducts.change/0 forward
14:09:02.262 [info] create table products
14:09:02.273 [info] == Migrated 20210201185747 in 0.0s
在我們跳入產生的程式碼之前,讓我們使用 mix phx.server
啟動伺服器,並拜訪 https://127.0.0.1:4000/products。讓我們按一下「新增產品」連結並按一下「儲存」按鈕,而沒有提供任何輸入。我們應該會看到以下輸出
Oops, something went wrong! Please check the errors below.
當我們提交表單時,我們可以看到所有驗證錯誤與輸入並排顯示。很好!一開始,上下文產生器便將架構欄位包含在我們的表單範本中,而我們可以看到,我們對必填輸入的預設驗證有效。讓我們輸入一些範例產品資料,然後重新提交表單
Product created successfully.
Title: Metaprogramming Elixir
Description: Write Less Code, Get More Done (and Have Fun!)
Price: 15.000000
Views: 0
若我們追蹤「Back」連結,我們得到了所有產品的清單,其中應該包含剛建立的那個。同樣地,我們可以更新或刪除該記錄。現在我們已看到其在瀏覽器的運作方式,可以來看看產生的程式碼了。
從產生器開始
那個小小的 mix phx.gen.html
指令實際上達成了令人驚訝的效果。我們獲得了大量的開箱即用功能,可用於建立、更新和刪除目錄中的產品。這遠非一個功能齊全的應用程式,但是請記住,產生器首先是學習工具,也是您開始建構實際功能的起點。產生程式碼不能解決您所有的問題,但它會教您 Phoenix 的裡裡外外,並在設計您的應用程式時引導您走向正確的思維模式。
讓我們先看看在 lib/hello_web/controllers/product_controller.ex
中產生的 ProductController
defmodule HelloWeb.ProductController do
use HelloWeb, :controller
alias Hello.Catalog
alias Hello.Catalog.Product
def index(conn, _params) do
products = Catalog.list_products()
render(conn, :index, products: products)
end
def new(conn, _params) do
changeset = Catalog.change_product(%Product{})
render(conn, :new, changeset: changeset)
end
def create(conn, %{"product" => product_params}) do
case Catalog.create_product(product_params) do
{:ok, product} ->
conn
|> put_flash(:info, "Product created successfully.")
|> redirect(to: ~p"/products/#{product}")
{:error, %Ecto.Changeset{} = changeset} ->
render(conn, :new, changeset: changeset)
end
end
def show(conn, %{"id" => id}) do
product = Catalog.get_product!(id)
render(conn, :show, product: product)
end
...
end
我們在 控制器指南 中看過控制器如何運作,所以這段程式碼可能不怎麼令人驚訝。值得注意的是我們的控制器如何呼叫至 Catalog
內容。我們可以看到 index
動作透過 Catalog.list_products/0
擷取產品清單,以及如何在 create
動作中透過 Catalog.create_product/1
將產品保存下來。我們還沒有看過目錄內容,所以我們還不知道產品的擷取和建立是如何在底層發生的 – 但重點就是這樣。我們的 Phoenix 控制器是網路介面,可通往我們更大的應用程式。它不應該關注從資料庫中擷取或儲存至儲存空間中產品的細節。我們只關心指示我們的應用程式為我們執行一些工作。這很棒,因為我們的商業邏輯和儲存細節與我們應用程式的網路層是分離的。如果我們稍後切換到全文資料儲存引擎來擷取產品而非 SQL 查詢,那麼我們的控制器無須變更。同樣地,我們可以從我們應用程式中的任何其他介面重新使用我們的內容程式碼,無論它是通道、混合任務或匯入 CSV 資料的長期執行的程序。
在我們的 create
動作中,當我們成功建立產品時,我們使用 Phoenix.Controller.put_flash/3
來顯示成功訊息,然後我們重新導向至路由器的產品顯示頁面。相反地,如果 Catalog.create_product/1
失敗,我們會建置我們的 "new.html"
範本,並傳遞 Ecto changeset,以便從範本中移除錯誤訊息。
接下來,讓我們深入探討並查看我們在 lib/hello/catalog.ex
中的 目錄
上下文
defmodule Hello.Catalog do
@moduledoc """
The Catalog context.
"""
import Ecto.Query, warn: false
alias Hello.Repo
alias Hello.Catalog.Product
@doc """
Returns the list of products.
## Examples
iex> list_products()
[%Product{}, ...]
"""
def list_products do
Repo.all(Product)
end
...
end
此模塊將成為我們系統中所有產品目錄功能的公開 API。例如,除了產品詳細資訊管理之外,我們還可以處理產品類別分類和產品變體,例如可選尺寸、邊飾等。如果我們查看 list_products/0
函數,我們可以看到產品提取的私有詳細資訊。而且非常簡單。我們呼叫了 Repo.all(Product)
。我們在 Ecto 指南 中看過 Ecto repo 查詢是如何運作的,因此這個呼叫看起來應該是熟悉的。我們的 list_products
函數是一種概括的函數名稱,它指定我們程式碼的意圖,也就是列出產品。我們使用 Repo 從 PostgreSQL 資料庫提取產品的意圖詳細資訊對我們的呼叫者而言是隱藏的。這是一個常見的主題,我們將在使用 Phoenix 產生器時看到一遍又一遍。Phoenix 會讓我們思考我們的應用程式中有不同的職責,然後將這些不同的區域封裝在命名良好的模塊和函數中,以明確我們程式碼的意圖,同時封裝細部資訊。
現在我們知道如何提取資料,但如何保存產品?我們來看一下 Catalog.create_product/1
函數。
@doc """
Creates a product.
## Examples
iex> create_product(%{field: value})
{:ok, %Product{}}
iex> create_product(%{field: bad_value})
{:error, %Ecto.Changeset{}}
"""
def create_product(attrs \\ %{}) do
%Product{}
|> Product.changeset(attrs)
|> Repo.insert()
end
這裡的文件比程式碼還要多,但有一些重點需要強調。首先,我們可以看到我們的 Ecto repo 被用在資料庫存取的底層。你可能也注意到了對 Product.changeset/2
的呼叫。我們之前討論過變更集,現在我們在我們的上下文中看到它們的動作。
如果我們在 lib/hello/catalog/product.ex
中開啟 Product
架構,它看起來會立即熟悉。
defmodule Hello.Catalog.Product do
use Ecto.Schema
import Ecto.Changeset
schema "products" do
field :description, :string
field :price, :decimal
field :title, :string
field :views, :integer
timestamps()
end
@doc false
def changeset(product, attrs) do
product
|> cast(attrs, [:title, :description, :price, :views])
|> validate_required([:title, :description, :price, :views])
end
end
這正是我們在執行 mix phx.gen.schema
時所看到的,除了我們在 changeset/2
函數上方看到 @doc false
以外。這告訴我們,雖然這個函數可以公開呼叫,但它並不屬於公開上下文的 API。建立變更集的呼叫者會透過上下文 API 進行建立。例如,Catalog.create_product/1
會呼叫我們的 Product.changeset/2
,以從使用者輸入建立變更集。呼叫者,例如我們的控制器動作,不會直接存取 Product.changeset/2
。與我們的產品變更集的所有互動都透過公開的 Catalog
上下文完成。
加入目錄函數
如我們所見,你的內容模組是專門的模組,用於公開和群組相關的功能。Phoenix 會產生通用函式,例如 list_products
和 update_product
,但它們僅作為你擴充業務邏輯和應用程式的基礎。讓我們追蹤產品頁面檢視次數,新增目錄的基本功能之一。
對於任何電子商務系統而言,追蹤產品頁面被檢視多少次的能力對於行銷、建議、排名等至關重要。雖然我們可以嘗試使用現有的 Catalog.update_product
函式,在 Catalog.update_product(product, %{views: product.views + 1})
這一行的附近,這不僅容易產生競爭條件,而且會要求呼叫者對於我們的目錄系統過於了解。為了了解為什麼會存在競爭條件,讓我們逐步了解事件的可能執行狀況
直覺上,你會假設以下事件
- 使用者 1 載入次數為 13 的產品頁面
- 使用者 1 儲存次數為 14 的產品頁面
- 使用者 2 載入次數為 14 的產品頁面
- 使用者 2 儲存次數為 15 的產品頁面
在實務上會這樣發生
- 使用者 1 載入次數為 13 的產品頁面
- 使用者 2 載入次數為 13 的產品頁面
- 使用者 1 儲存次數為 14 的產品頁面
- 使用者 2 儲存次數為 14 的產品頁面
競爭條件會使得這成為更新現有資料表的不穩定方式,因為多個呼叫者可能會更新過時的檢視值。有更好的方法。
讓我們思考一個描述我們想達成目標的函式。以下是如何使用它
product = Catalog.inc_page_views(product)
看來很棒。我們的呼叫者對於這個函式的功能不會感到困惑,而且我們可以在原子操作中中斷增量運算,以防止競爭條件。
開啟你的目錄內容 (lib/hello/catalog.ex
),並新增這個新的函式
def inc_page_views(%Product{} = product) do
{1, [%Product{views: views}]} =
from(p in Product, where: p.id == ^product.id, select: [:views])
|> Repo.update_all(inc: [views: 1])
put_in(product.views, views)
end
我們建立一個查詢,以擷取給定 ID 的目前產品,然後傳遞給 Repo.update_all
。Ecto 的 Repo.update_all
允許我們對資料庫執行批次更新,且非常適合以原子方式更新值,例如增加我們的檢視次數。儲存庫操作的結果會傳回更新的記錄數,以及 select
選項指定的選取的架構值。當我們收到新的產品檢視時,我們使用 put_in(product.views, views)
將新的檢視次數放入產品結構中。
隨著內容函式的定位,我們在產品控制器中加以使用。更新 lib/hello_web/controllers/product_controller.ex
中的 show
動作,以呼叫我們的新函式
def show(conn, %{"id" => id}) do
product =
id
|> Catalog.get_product!()
|> Catalog.inc_page_views()
render(conn, :show, product: product)
end
我們修改了 show
動作,將我們擷取的產品輸出至 Catalog.inc_page_views/1
,這將會傳回更新的產品。然後我們照之前一樣呈現範本。讓我們試試看。重新整理你的其中一個產品頁面數次,並觀察檢視次數增加。
我們也可以在 ecto 編譯記錄中看到原子更新的實際應用
[debug] QUERY OK source="products" db=0.5ms idle=834.5ms
UPDATE "products" AS p0 SET "views" = p0."views" + $1 WHERE (p0."id" = $2) RETURNING p0."views" [1, 1]
做得很好!
正如我們所見,使用上下文進行設計能為您建立穩固的基礎,供您擴展應用程式。使用離散且定義完善的 API,同時揭露系統意圖,這使您可以撰寫使用可重複使用程式碼的可維護性更高的應用程式。現在我們知道了如何開始擴充我們的文字 API,接下來就讓我們探索處理上下文中內的關聯性。
上下文關聯性
我們的基本目錄功能很不錯,但讓我們更上一層樓,為產品分類。許多電子商務解決方案允許對產品進行不同的分類,例如將產品標記為時尚、電動工具等等。如果我們日後需要開始支援多個類別,則從產品與類別之間的一對一關聯性開始將來會導致重大的程式碼變更。讓我們建立一個類別關聯,這將使我們能夠從每個產品開始追蹤單一類別,但隨著功能的增長,我們在以後可以輕鬆地提供更多支援。
現在,類別將僅包含文字資訊。我們的首要任務是決定在應用程式中放置類別的位置。我們有 目錄
上下文,它管理我們的產品展示。產品分類非常適合於此。Phoenix 也夠聰明,可以在現有上下文中產生程式碼,這使新增資源至上下文的過程變得非常輕鬆。在專案根目錄處執行以下命令
有時難以判斷兩個資源是否屬於同一個上下文。在這種情況下,優先考量每個資源使用不同的上下文,必要時在以後重構。否則,您很容易最終會產生由鬆散關聯的實體組成的龐大上下文。此外,請記住,兩個資源相關並不一定表示它們屬於同一個上下文,否則您很快就會產生一個龐大的上下文,因為應用程式中的大多數資源彼此相連。總結來說:如果您不確定,您應該優先考量分開的模組(上下文)。
$ mix phx.gen.context Catalog Category categories \
title:string:unique
You are generating into an existing context.
...
Would you like to proceed? [Yn] y
* creating lib/hello/catalog/category.ex
* creating priv/repo/migrations/20210203192325_create_categories.exs
* injecting lib/hello/catalog.ex
* injecting test/hello/catalog_test.exs
* injecting test/support/fixtures/catalog_fixtures.ex
Remember to update your repository by running migrations:
$ mix ecto.migrate
這次,我們使用 mix phx.gen.context
,就像 mix phx.gen.html
,只是它不會幫我們產生 Web 檔案。由於我們已經有控制器和範本用來管理產品了,我們可以將新的分類功能整合到現有的網路表單和產品展示頁。我們可以看到我們現在有了新的 Category
schema,它與我們的產品 schema 並列於 lib/hello/catalog/category.ex
,而 Phoenix 告訴我們,它在我們現有的目錄背景中「注入」了分類功能的新函數。這些注入的函數看起來與我們的產品函數非常相似,並有新的函數,例如 create_category
、list_categories
,等等。在我們繼續上移之前,我們需要執行第二次的程式碼生成。我們的分類 schema 非常適合於在系統中表述個別分類,但我們需要支援產品和分類之間的 M:N 關係。很幸運的是,ecto 允許我們使用聯結表來簡單執行這個動作,因此讓我們使用 ecto.gen.migration
指令現在就產生這個聯結表
$ mix ecto.gen.migration create_product_categories
* creating priv/repo/migrations/20210203192958_create_product_categories.exs
接下來,我們來開啟新的遷移檔案,並將下列程式碼新增到 change
函數
defmodule Hello.Repo.Migrations.CreateProductCategories do
use Ecto.Migration
def change do
create table(:product_categories, primary_key: false) do
add :product_id, references(:products, on_delete: :delete_all)
add :category_id, references(:categories, on_delete: :delete_all)
end
create index(:product_categories, [:product_id])
create unique_index(:product_categories, [:category_id, :product_id])
end
end
我們建立一個 product_categories
表格,並使用了 primary_key: false
選項,因為我們的聯結表不需要主鍵。接下來我們定義了我們的 :product_id
和 :category_id
外來鍵欄位,並傳遞 on_delete: :delete_all
來確保資料庫在連結的產品或分類被刪除時,會清除我們的聯結表記錄。透過使用資料庫限制,我們在資料庫層面強制資料完整性,而不是依賴於特別用途且容易出錯的應用程式邏輯。
接下來我們為外來鍵建立索引,其中一個是要確保產品不會有重複分類的唯一索引。請注意,由於 category_id
處於多欄索引的最左前綴,因此我們不一定要為其建立單一欄索引,這對資料庫優化器來說已經足夠了。另一方面,新增一個多餘的索引只會在寫入時增加負擔。
遷移準備就緒之後,我們可以繼續上移。
$ mix ecto.migrate
18:20:36.489 [info] == Running 20210222231834 Hello.Repo.Migrations.CreateCategories.change/0 forward
18:20:36.493 [info] create table categories
18:20:36.508 [info] create index categories_title_index
18:20:36.512 [info] == Migrated 20210222231834 in 0.0s
18:20:36.547 [info] == Running 20210222231930 Hello.Repo.Migrations.CreateProductCategories.change/0 forward
18:20:36.547 [info] create table product_categories
18:20:36.557 [info] create index product_categories_product_id_index
18:20:36.560 [info] create index product_categories_category_id_product_id_index
18:20:36.562 [info] == Migrated 20210222231930 in 0.0s
現在我們有了一個 Catalog.Product
schema 和一個用來關聯產品和分類的聯結表,我們幾乎可以開始連結我們的新功能。在深入探討之前,我們首先需要有真正的分類,可以在我們的 Web 使用者介面中進行選擇。讓我們快速地在應用程式的種子中設定一些新的分類。將下列程式碼新增到 priv/repo/seeds.exs
中的種子檔案
for title <- ["Home Improvement", "Power Tools", "Gardening", "Books", "Education"] do
{:ok, _} = Hello.Catalog.create_category(%{title: title})
end
我們只要在分類標題的清單上列舉,並使用我們的目錄背景中產生的 create_category/1
函數,就可以儲存新的記錄。我們可以用 mix run
執行種子
$ mix run priv/repo/seeds.exs
[debug] QUERY OK db=3.1ms decode=1.1ms queue=0.7ms idle=2.2ms
INSERT INTO "categories" ("title","inserted_at","updated_at") VALUES ($1,$2,$3) RETURNING "id" ["Home Improvement", ~N[2021-02-03 19:39:53], ~N[2021-02-03 19:39:53]]
[debug] QUERY OK db=1.2ms queue=1.3ms idle=12.3ms
INSERT INTO "categories" ("title","inserted_at","updated_at") VALUES ($1,$2,$3) RETURNING "id" ["Power Tools", ~N[2021-02-03 19:39:53], ~N[2021-02-03 19:39:53]]
[debug] QUERY OK db=1.1ms queue=1.1ms idle=15.1ms
INSERT INTO "categories" ("title","inserted_at","updated_at") VALUES ($1,$2,$3) RETURNING "id" ["Gardening", ~N[2021-02-03 19:39:53], ~N[2021-02-03 19:39:53]]
[debug] QUERY OK db=2.4ms queue=1.0ms idle=17.6ms
INSERT INTO "categories" ("title","inserted_at","updated_at") VALUES ($1,$2,$3) RETURNING "id" ["Books", ~N[2021-02-03 19:39:53], ~N[2021-02-03 19:39:53]]
完美。在我們將類別整合到網頁層之前,我們需要讓我們的背景知識如何關聯產品和類別。首先,開啟 lib/hello/catalog/product.ex
並新增下列關聯
+ alias Hello.Catalog.Category
schema "products" do
field :description, :string
field :price, :decimal
field :title, :string
field :views, :integer
+ many_to_many :categories, Category, join_through: "product_categories", on_replace: :delete
timestamps()
end
我們使用了 Ecto.Schema
的 many_to_many
巨集讓 Ecto 知道如何透過 "product_categories"
加入表格將我們的產品關聯到多個類別。我們也使用了 on_replace: :delete
選項來宣告當我們變更我們的類別時,任何現有的加入記錄都應該刪除。
隨著我們架設了架構關聯,我們可以在我們的產品表單中實作類別的選取。為此,我們需要將前端的目錄 ID 使用者輸入轉譯到我們的多對多關聯。很幸運地,在我們的架構設定好後,Ecto 讓這變得輕而易舉。開啟您的目錄背景知識並進行下列變更
+ alias Hello.Catalog.Category
- def get_product!(id), do: Repo.get!(Product, id)
+ def get_product!(id) do
+ Product |> Repo.get!(id) |> Repo.preload(:categories)
+ end
def create_product(attrs \\ %{}) do
%Product{}
- |> Product.changeset(attrs)
+ |> change_product(attrs)
|> Repo.insert()
end
def update_product(%Product{} = product, attrs) do
product
- |> Product.changeset(attrs)
+ |> change_product(attrs)
|> Repo.update()
end
def change_product(%Product{} = product, attrs \\ %{}) do
- Product.changeset(product, attrs)
+ categories = list_categories_by_id(attrs["category_ids"])
+ product
+ |> Repo.preload(:categories)
+ |> Product.changeset(attrs)
+ |> Ecto.Changeset.put_assoc(:categories, categories)
end
+ def list_categories_by_id(nil), do: []
+ def list_categories_by_id(category_ids) do
+ Repo.all(from c in Category, where: c.id in ^category_ids)
+ end
首先,當我們擷取一個產品時,我們加入 Repo.preload
來預先載入我們的類別。這將允許我們在我們的控制器、範本,及任何我們想使用類別資訊的任何其他地方參照 product.categories
。接下來,我們修改我們的 create_product
和 update_product
函式呼叫成我們現有的 change_product
函式來產生一個變更集。在 change_product
裡,如果存在 "category_ids"
屬性,我們新增一個查詢來尋找所有類別。然後,我們預先載入類別並呼叫 Ecto.Changeset.put_assoc
來將擷取的類別置於變更集中。最後,我們實作了 list_categories_by_id/1
函式來查詢與類別 ID 相符的類別,或在不存在 "category_ids"
屬性時回傳一個空清單。現在我們的 create_product
和 update_product
函式在我們對我們的儲存庫嘗試插入或更新後,會收到一個變更集,其中類別關聯已準備就緒。
接著,讓我們將我們的類別輸入加入我們的產品表單中,以將我們的新功能公開到網頁。為了讓我們的表單範本保持條理清楚,讓我們撰寫一個新函式來概括為我們的產品呈現一個類別選擇輸入輸入的詳細資訊。在 lib/hello_web/controllers/product_html.ex
中開啟您的 ProductHTML
檢視並輸入這個
def category_opts(changeset) do
existing_ids =
changeset
|> Ecto.Changeset.get_change(:categories, [])
|> Enum.map(& &1.data.id)
for cat <- Hello.Catalog.list_categories(),
do: [key: cat.title, value: cat.id, selected: cat.id in existing_ids]
end
我們新增了一個 category_opts/1
函式,用來產生選擇器選項,以便在我們即將新增的多重選取標籤中使用。我們從變更集計算現有類別 ID,然後在產生輸入標籤的選取選項時使用這些值。我們藉由列舉所有類別,並回傳適當的 key
、value
和 selected
值來執行此動作。我們在變更集中找到類別 ID 時,會將某個選項標示為已選取。
由於我們的 category_opts
函式已經就緒,我們可以開啟 lib/hello_web/controllers/product_html/product_form.html.heex
,並新增
...
<.input field={f[:views]} type="number" label="Views" />
+ <.input field={f[:category_ids]} type="select" multiple={true} options={category_opts(@changeset)} />
<:actions>
<.button>Save Product</.button>
</:actions>
我們在儲存按鈕上方新增了一個 category_select
。現在讓我們來試用它。接下來,我們要在產品顯示範本中顯示產品的類別。將下列程式碼新增至 lib/hello_web/controllers/product_html/show.html.heex
中的清單
<.list>
...
+ <:item title="Categories">
+ <%= for cat <- @product.categories do %>
+ <%= cat.title %>
+ <br/>
+ <% end %>
+ </:item>
</.list>
現在,如果我們使用 mix phx.server
啟動伺服器,然後瀏覽 https://127.0.0.1:4000/products/new,我們將會看到新的類別多重選擇輸入。輸入一些有效的產品詳細資料,選取一或二個類別,然後按一下儲存。
Title: Elixir Flashcards
Description: Flash card set for the Elixir programming language
Price: 5.000000
Views: 0
Categories:
Education
Books
目前看起來沒什麼特別的,但它已經可以運作了!我們在上下文內新增了關聯,而且資料庫也強制執行資料完整性。還不錯。讓我們繼續建置!
跨上下文相依關係
現在,我們的產品目錄功能已經具備初步架構,讓我們開始建置應用程式的其他主要功能,即讓產品從目錄加入購物車。為了妥善追蹤已加入使用者購物車的產品,我們需要一個新的地方來持續保存這些資訊,以及產品資訊,例如在加入購物車時的價格。我們這麼做的目的是為了將來能偵測產品價格變更。我們知道自己需要建構什麼,但現在我們必須決定這個應用程式中購物車功能的位置。
如果我們退一步思考,並設想我們的應用程式隔離情形,會發現產品在我們目錄中的展示方式與管理使用者購物車的責任截然不同。產品目錄不應在意我們購物車系統的規則,反之亦然。這表示我們需要一個獨立的上下文來處理新的購物車責任。我們暫且稱它為 ShoppingCart
。
讓我們建立一個 ShoppingCart
上下文,來處理基本的購物車功能。在撰寫程式碼之前,讓我們假設我們有下列功能需求
- 從產品顯示頁面將產品加入使用者的購物車
- 在加入購物車時,儲存產品價格資訊
- 儲存並變更購物車中的數量
- 計算並顯示購物車價格總和
根據說明,很明顯我們需要一個 Cart
資源來儲存使用者的購物車,以及一個 CartItem
來追蹤購物車中的產品。隨著我們的計畫制定,讓我們開始吧。執行以下指令來產生新的內容情境
$ mix phx.gen.context ShoppingCart Cart carts user_uuid:uuid:unique
* creating lib/hello/shopping_cart/cart.ex
* creating priv/repo/migrations/20210205203128_create_carts.exs
* creating lib/hello/shopping_cart.ex
* injecting lib/hello/shopping_cart.ex
* creating test/hello/shopping_cart_test.exs
* injecting test/hello/shopping_cart_test.exs
* creating test/support/fixtures/shopping_cart_fixtures.ex
* injecting test/support/fixtures/shopping_cart_fixtures.ex
Some of the generated database columns are unique. Please provide
unique implementations for the following fixture function(s) in
test/support/fixtures/shopping_cart_fixtures.ex:
def unique_cart_user_uuid do
raise "implement the logic to generate a unique cart user_uuid"
end
Remember to update your repository by running migrations:
$ mix ecto.migrate
我們產生了新的內容情境 ShoppingCart
,同時新增 ShoppingCart.Cart
架構來將使用者與其購物車連結,而購物車則會保留購物車中的品項。我們目前還沒有真實的使用者,因此我們的購物車暫時會透過匿名使用者 UUID 追蹤,我們稍後會將它加入我們的 plug 會話。購物車就緒後,讓我們產生購物車品項
$ mix phx.gen.context ShoppingCart CartItem cart_items \
cart_id:references:carts product_id:references:products \
price_when_carted:decimal quantity:integer
You are generating into an existing context.
...
Would you like to proceed? [Yn] y
* creating lib/hello/shopping_cart/cart_item.ex
* creating priv/repo/migrations/20210205213410_create_cart_items.exs
* injecting lib/hello/shopping_cart.ex
* injecting test/hello/shopping_cart_test.exs
* injecting test/support/fixtures/shopping_cart_fixtures.ex
Remember to update your repository by running migrations:
$ mix ecto.migrate
我們在 ShoppingCart
內產生了一個名為 CartItem
的新資源。此架構和表格會保留購物車和產品的參考,以及我們將品項加入購物車時的價格,以及使用者希望購買的數量。讓我們觸碰 priv/repo/migrations/*_create_cart_items.ex
中產生的 migration 檔案
create table(:cart_items) do
- add :price_when_carted, :decimal
+ add :price_when_carted, :decimal, precision: 15, scale: 6, null: false
add :quantity, :integer
- add :cart_id, references(:carts, on_delete: :nothing)
+ add :cart_id, references(:carts, on_delete: :delete_all)
- add :product_id, references(:products, on_delete: :nothing)
+ add :product_id, references(:products, on_delete: :delete_all)
timestamps()
end
- create index(:cart_items, [:cart_id])
create index(:cart_items, [:product_id])
+ create unique_index(:cart_items, [:cart_id, :product_id])
我們再次使用 :delete_all
策略來強制資料完整性。如此一來,當從應用程式中刪除購物車或產品時,我們不用依賴 ShoppingCart
或 Catalog
內容情境,就能清理記錄了。這使得我們的應用程式碼解耦,而且資料完整性強制出現在它該在的地方,也就是資料庫。我們還加入了一個獨特約束,以確保不會有重複的產品加入購物車。與 product_categories
表格相同,使用多欄位索引讓我們移除最左邊欄位的獨立索引 (cart_id
)。資料庫表就緒後,我們現在就可以執行向上遷移了
$ mix ecto.migrate
16:59:51.941 [info] == Running 20210205203342 Hello.Repo.Migrations.CreateCarts.change/0 forward
16:59:51.945 [info] create table carts
16:59:51.949 [info] create index carts_user_uuid_index
16:59:51.952 [info] == Migrated 20210205203342 in 0.0s
16:59:51.988 [info] == Running 20210205213410 Hello.Repo.Migrations.CreateCartItems.change/0 forward
16:59:51.988 [info] create table cart_items
16:59:51.998 [info] create index cart_items_cart_id_index
16:59:52.000 [info] create index cart_items_product_id_index
16:59:52.001 [info] create index cart_items_cart_id_product_id_index
16:59:52.002 [info] == Migrated 20210205213410 in 0.0s
我們的資料庫已準備好使用新的 carts
和 cart_items
表格,但現在我們需要將它映射回應用程式碼。你可能想知道我們如何將資料庫外來鍵混合於不同的表格,以及這如何與分離的、分組功能的內容情境模式相關。讓我們深入探討這些方法以及它們的折衷方案。
跨內容情境資料
到目前為止,我們在彼此隔離應用程式的兩個主要內容情境上做得很好,但現在我們有必要處理依賴關係。
我們的 Catalog.Product
資源用於代表目錄中的產品,但最後讓商品存在於購物車中時,必須有來自目錄的產品。因此,我們的 ShoppingCart
上下文在 Catalog
上下文上會有資料相依性。基於這個前提,我們有兩個選項。一個是在 Catalog
上下文中建構 API,讓它可以有效率地擷取產品資料,供 ShoppingCart
系統使用,而我們會手動將它們拼湊起來。或者我們可以使用資料庫聯結來擷取相依資料。兩個都是有效的選項,取決於你的權衡和應用程式大小,但對於大多數應用程式來說,當你擁有硬資料相依性時,從資料庫中聯結資料就足夠了,這是我們在此採取的做法。
現在我們知道了資料相依性存在哪裡,讓我們新增架構關聯性,這樣就能夠將購物車商品連結到產品了。首先,讓我們對 lib/hello/shopping_cart/cart.ex
中的購物車架構做個快速的變更,將購物車連結到商品
schema "carts" do
field :user_uuid, Ecto.UUID
+ has_many :items, Hello.ShoppingCart.CartItem
timestamps()
end
現在我們的購物車已經連結到放入其中的商品,讓我們在 lib/hello/shopping_cart/cart_item.ex
中設定購物車商品的關聯性
schema "cart_items" do
field :price_when_carted, :decimal
field :quantity, :integer
- field :cart_id, :id
- field :product_id, :id
+ belongs_to :cart, Hello.ShoppingCart.Cart
+ belongs_to :product, Hello.Catalog.Product
timestamps()
end
@doc false
def changeset(cart_item, attrs) do
cart_item
|> cast(attrs, [:price_when_carted, :quantity])
|> validate_required([:price_when_carted, :quantity])
+ |> validate_number(:quantity, greater_than_or_equal_to: 0, less_than: 100)
end
首先,我們使用標準的 belongs_to
取代 cart_id
欄位,指向我們的 ShoppingCart.Cart
架構。接著,我們透過 belongs_to
新增第一個跨上下文的資料相依性,取代我們的 product_id
欄位以符合 Catalog.Product
架構。在這裡,我們故意將資料邊界連結起來,因為它提供了我們真正需要的事物:具有在系統中參考產品所需最低限度知識的獨立上下文 API。接著,我們為我們的 changeset 新增新的驗證。使用 validate_number/3
,我們確保使用者輸入提供的數量介於 0 到 100 之間。
建立好架構之後,我們可以開始將新的資料結構和 ShoppingCart
上下文 API 整合到我們的網路功能中。
新增購物車功能
就像我們之前提到的,上下文產生器只是應用程式的起點。我們可以且應該撰寫命名良好的專屬功能,來達成我們上下文的目標。我們有一些新的功能要實作。首先,我們需要確保每個使用我們應用程式的人都有購物車,如果還沒有的話。之後,我們允許使用者將商品新增到他們的購物車、更新商品數量,並計算購物車總計。讓我們開始吧!
我們不會著重於真正的使用者驗證系統,但當我們完成時,你可以自然地將它與我們在此撰寫的內容整合在一起。為了模擬目前的使用者階段,請開啟你的 lib/hello_web/router.ex
並輸入以下內容
pipeline :browser do
plug :accepts, ["html"]
plug :fetch_session
plug :fetch_live_flash
plug :put_root_layout, html: {HelloWeb.LayoutView, :root}
plug :protect_from_forgery
plug :put_secure_browser_headers
+ plug :fetch_current_user
+ plug :fetch_current_cart
end
+ defp fetch_current_user(conn, _) do
+ if user_uuid = get_session(conn, :current_uuid) do
+ assign(conn, :current_uuid, user_uuid)
+ else
+ new_uuid = Ecto.UUID.generate()
+
+ conn
+ |> assign(:current_uuid, new_uuid)
+ |> put_session(:current_uuid, new_uuid)
+ end
+ end
+ alias Hello.ShoppingCart
+
+ defp fetch_current_cart(conn, _opts) do
+ if cart = ShoppingCart.get_cart_by_user_uuid(conn.assigns.current_uuid) do
+ assign(conn, :cart, cart)
+ else
+ {:ok, new_cart} = ShoppingCart.create_cart(conn.assigns.current_uuid)
+ assign(conn, :cart, new_cart)
+ end
+ end
我們為瀏覽器管線新增一個新的 :fetch_current_user
及 :fetch_current_cart
外掛至所有基於瀏覽器的要求執行。接著,我們建置 fetch_current_user
外掛,它只在在工作階段尋找先前新增的使用者 UUID。如果我們發現一個,我們會新增一個 current_uuid
指配給連結,然後完成。如果我們至今仍未辨識此一位訪客,我們會使用 Ecto.UUID.generate()
產生一個唯一的 UUID,然後我們將該值放入 current_uuid
指配中,連同一個新的工作階段值,以便在未來的要求中辨識此一位訪客。一個隨機的唯一 ID 並不足以代表一位使用者,但它足以讓我們追蹤及辨識一位訪客跨不同的要求,這正符合我們目前的所需。稍後,當我們的應用程式更為完整時,你會準備好移轉至一個完整的使用者驗證方法。隨著目前的使用者獲得保證之後,我們進而建置 fetch_current_cart
外掛,找出使用者 UUID 的購物車,或為目前的使用者建立一個購物車,並在連線指派中指派結果。我們需要建置我們的 ShoppingCart.get_cart_by_user_uuid/1
並修改建立購物車功能以接受一個 UUID,但是讓我們先新增我們的路由。
我們需要建立一個購物車控制器來執行購物車操作,例如檢視購物車、更新數量並啟動結帳流程,以及一個購物車項目控制器用於將個別項目加入和移除購物車。在 lib/hello_web/router.ex
中將以下路由新增至你的路由器
scope "/", HelloWeb do
pipe_through :browser
get "/", PageController, :index
resources "/products", ProductController
+ resources "/cart_items", CartItemController, only: [:create, :delete]
+ get "/cart", CartController, :show
+ put "/cart", CartController, :update
end
我們新增一個 resources
宣告給 CartItemController
,它會替建立和刪除動作配置用於加入和移除個別購物車項目的路由。接著,我們新增兩個新的路由指向 CartController
。第一個路由,一個 GET 要求,將會描繪到我們的顯示動作,以顯示購物車內容。第二個路由,一個 PUT 要求,將會處理更新我們的購物車數量的表單提交。
有了我們的路由後,讓我們新增一個能夠從產品顯示頁面將項目加入購物車的功能。在 lib/hello_web/controllers/cart_item_controller.ex
中建立一個新檔案,並輸入
defmodule HelloWeb.CartItemController do
use HelloWeb, :controller
alias Hello.ShoppingCart
def create(conn, %{"product_id" => product_id}) do
case ShoppingCart.add_item_to_cart(conn.assigns.cart, product_id) do
{:ok, _item} ->
conn
|> put_flash(:info, "Item added to your cart")
|> redirect(to: ~p"/cart")
{:error, _changeset} ->
conn
|> put_flash(:error, "There was an error adding the item to your cart")
|> redirect(to: ~p"/cart")
end
end
def delete(conn, %{"id" => product_id}) do
{:ok, _cart} = ShoppingCart.remove_item_from_cart(conn.assigns.cart, product_id)
redirect(conn, to: ~p"/cart")
end
end
我們使用路由器中宣告的建立及刪除動作,定義新的 CartItemController
。對於 create
,我們呼叫 ShoppingCart.add_item_to_cart/2
函式,我們稍後實作。如果成功,我們顯示閃爍的成功訊息,並重新導向到購物車顯示頁面;否則,我們顯示閃爍錯誤訊息,並重新導向到購物車顯示頁面。對於 delete
,我們呼叫 remove_item_from_cart
函式,我們稍後在我們的 ShoppingCart
背景實作,然後再重新導向回購物車顯示頁面。我們還沒實作這兩個購物車函式,但請注意其名稱如何強調其用意:add_item_to_cart
和 remove_item_from_cart
使我們顯然了解我們在此執行什麼。它也讓我們能在不一次考慮所有實作細節的情況下,規範出我們的網路層及背景 API。
我們在 lib/hello/shopping_cart.ex
中實作 ShoppingCart
背景 API 的新介面
+ alias Hello.Catalog
- alias Hello.ShoppingCart.Cart
+ alias Hello.ShoppingCart.{Cart, CartItem}
+ def get_cart_by_user_uuid(user_uuid) do
+ Repo.one(
+ from(c in Cart,
+ where: c.user_uuid == ^user_uuid,
+ left_join: i in assoc(c, :items),
+ left_join: p in assoc(i, :product),
+ order_by: [asc: i.inserted_at],
+ preload: [items: {i, product: p}]
+ )
+ )
+ end
- def create_cart(attrs \\ %{}) do
- %Cart{}
- |> Cart.changeset(attrs)
+ def create_cart(user_uuid) do
+ %Cart{user_uuid: user_uuid}
+ |> Cart.changeset(%{})
|> Repo.insert()
+ |> case do
+ {:ok, cart} -> {:ok, reload_cart(cart)}
+ {:error, changeset} -> {:error, changeset}
+ end
end
+ defp reload_cart(%Cart{} = cart), do: get_cart_by_user_uuid(cart.user_uuid)
+
+ def add_item_to_cart(%Cart{} = cart, product_id) do
+ product = Catalog.get_product!(product_id)
+
+ %CartItem{quantity: 1, price_when_carted: product.price}
+ |> CartItem.changeset(%{})
+ |> Ecto.Changeset.put_assoc(:cart, cart)
+ |> Ecto.Changeset.put_assoc(:product, product)
+ |> Repo.insert(
+ on_conflict: [inc: [quantity: 1]],
+ conflict_target: [:cart_id, :product_id]
+ )
+ end
+
+ def remove_item_from_cart(%Cart{} = cart, product_id) do
+ {1, _} =
+ Repo.delete_all(
+ from(i in CartItem,
+ where: i.cart_id == ^cart.id,
+ where: i.product_id == ^product_id
+ )
+ )
+
+ {:ok, reload_cart(cart)}
+ end
我們從實作 get_cart_by_user_uuid/1
開始,它提取我們的購物車並加入購物車品項,以及其產品,以便我們將全部已預載資料填入完整購物車。接著,我們變更 create_cart
函式,以接受使用者 UUID 而非屬性,我們用它來填入 user_uuid
欄位。如果插入成功,我們透過呼叫私有 reload_cart/1
函式來重新載入購物車內容,此函式只呼叫 get_cart_by_user_uuid/1
來重新提取資料。
接著,我們撰寫新的 add_item_to_cart/2
函式,它接受購物車結構及產品 ID。我們繼續使用 Catalog.get_product!/1
提取產品,顯示背景如何能在需要時自然地呼叫其他背景。您也可以選擇將產品接收為參數,您會達成類似的結果。接著我們對我們的 repo 使用更新插入操作,以將新的購物車品項插入資料庫,或是在購物車中已存在時增加數量。此動作透過 on_conflict
與 conflict_target
選項達成,它們告訴我們的 repo 如何處理插入衝突。
最後,我們實作 remove_item_from_cart/2
,我們只發出 Repo.delete_all
呼叫,搭配查詢以刪除購物車中符合產品 ID 的購物車品項。最後,我們透過呼叫 reload_cart/1
來重新載入購物車內容。
在我們的新購物車函式就定位後,我們現在可以在產品目錄顯示頁面顯示「加入購物車」按鈕。在 lib/hello_web/controllers/product_html/show.html.heex
中開啟您的範本,並進行下列變更
...
<.link href={~p"/products/#{@product}/edit"}>
<.button>Edit product</.button>
</.link>
+ <.link href={~p"/cart_items?product_id=#{@product.id}"} method="post">
+ <.button>Add to cart</.button>
+ </.link>
...
來自 Phoenix.Component
中的 link
函數元件接受 :method
屬性來在按一下時發佈 HTTP 動詞,而不是預設的 GET 要求。有了這個連結,就能發佈「加入購物車」連結一個 POST 要求,此要求會與我們在 router 中定義的路由相符,該路由會派送至 CartItemController.create/2
函數。
讓我們試試看。使用 mix phx.server
啟動伺服器,並造訪產品頁面。如果我們嘗試按一下加入購物車連結,就會看到錯誤頁面,而主控台中有以下日誌
[info] POST /cart_items
[debug] Processing with HelloWeb.CartItemController.create/2
Parameters: %{"_method" => "post", "product_id" => "1", ...}
Pipelines: [:browser]
INSERT INTO "cart_items" ...
[info] Sent 302 in 24ms
[info] GET /cart
[debug] Processing with HelloWeb.CartController.show/2
Parameters: %{}
Pipelines: [:browser]
[debug] QUERY OK source="carts" db=1.9ms idle=1798.5ms
[error] #PID<0.856.0> running HelloWeb.Endpoint (connection #PID<0.841.0>, stream id 5) terminated
Server: localhost:4000 (http)
Request: GET /cart
** (exit) an exception was raised:
** (UndefinedFunctionError) function HelloWeb.CartController.init/1 is undefined
(module HelloWeb.CartController is not available)
...
奏效了!有點奏效。如果我們遵循日誌,就能看到我們 POST 到 /cart_items
路徑。接下來,我們可以看到我們的 ShoppingCart.add_item_to_cart
函數成功插入一行至 cart_items
表單,然後我們發布一則導向 /cart
的重新導向。在發生錯誤之前,我們還看到一則對 carts
表單的查詢,這表示我們正在擷取目前的使用者購物車。到目前為止都很順利。我們知道我們的 CartItem
控制器和新的 ShoppingCart
內文函數運作正常,但當路由嘗試派送至不存在的購物車控制器時,我們就會遭遇下一個未實作的功能。讓我們建立購物車控制器、檢視和範本來顯示和管理使用者的購物車。
在 lib/hello_web/controllers/cart_controller.ex
建立新檔案,並輸入此內容
defmodule HelloWeb.CartController do
use HelloWeb, :controller
alias Hello.ShoppingCart
def show(conn, _params) do
render(conn, :show, changeset: ShoppingCart.change_cart(conn.assigns.cart))
end
end
我們定義了一個新的購物車控制器來處理 get "/cart"
路由。為了顯示購物車,我們會呈現範本 "show.html"
,這會在稍後建立。我們知道需要允許變更品項數量來變更購物車品項,所以我們很快就會知道我們需要變更集。幸運的是,內文產生器包含一則 ShoppingCart.change_cart/1
函數,我們會使用它。我們傳入已在連結賦值中的購物車結構,這要歸功於我們在 router 中定義的 fetch_current_cart
插件。
接下來,我們可以實作檢視和範本。在 lib/hello_web/controllers/cart_html.ex
建立新的檢視檔案,其內容如下
defmodule HelloWeb.CartHTML do
use HelloWeb, :html
alias Hello.ShoppingCart
embed_templates "cart_html/*"
def currency_to_str(%Decimal{} = val), do: "$#{Decimal.round(val, 2)}"
end
我們建立一個檢視來呈現我們的 show.html
範本,並將我們的 ShoppingCart
內文別名化,以便將其套用至範本的範圍。我們必須顯示購物車價格,例如產品品項價格、購物車總計等,因此我們定義一則 currency_to_str/1
,它會取用我們的十進位結構,將其適當地四捨五入以顯示,並在前面加上美元符號。
接下來,我們可以在 lib/hello_web/controllers/cart_html/show.html.heex
建立範本
<%= if @cart.items == [] do %>
<.header>
My Cart
<:subtitle>Your cart is empty</:subtitle>
</.header>
<% else %>
<.header>
My Cart
</.header>
<.simple_form :let={f} for={@changeset} action={~p"/cart"}>
<.inputs_for :let={item_form} field={f[:items]}>
<% item = item_form.data %>
<.input field={item_form[:quantity]} type="number" label={item.product.title} />
<%= currency_to_str(ShoppingCart.total_item_price(item)) %>
</.inputs_for>
<:actions>
<.button>Update cart</.button>
</:actions>
</.simple_form>
<b>Total</b>: <%= currency_to_str(ShoppingCart.total_cart_price(@cart)) %>
<% end %>
<.back navigate={~p"/products"}>Back to products</.back>
我們從顯示空的購物車訊息開始,如果我們預先載入的 cart.items
是空的。如果我們有項目,我們使用我們的 HelloWeb.CoreComponents
提供的 simple_form
元件,來取得我們在 CartController.show/2
動作中指派給購物車的變更集,並建立一個表格,對應到我們的購物車控制器 update/2
動作。在表格中,我們使用 inputs_for
元件,來為巢狀的購物車項目呈現輸入。這會讓我們在提交表格時,將項目輸入映射回一起。下一步,我們顯示一個項目數量輸入,並標記它為產品標籤。我們用將項目價格轉換成字串來完成項目表格。我們還沒撰寫 ShoppingCart.total_item_price/1
函式,但我們再次採用了為我們的內容提供清晰、有描述性的公開介面的想法。在呈現所有購物車項目的輸入後,我們顯示「更新購物車」提交按鈕,連同整個購物車的總價。這是用另一項新的 ShoppingCart.total_cart_price/1
函式完成的,我們稍後會實作它。最後,我們新增了一個 back
元件,以返回到我們的產品頁面。
我們幾乎準備好嘗試我們的購物車頁面,但首先我們需要實作新的貨幣計算函式。在 lib/hello/shopping_cart.ex
開啟您的購物車內容,並新增這些新的函式
def total_item_price(%CartItem{} = item) do
Decimal.mult(item.product.price, item.quantity)
end
def total_cart_price(%Cart{} = cart) do
Enum.reduce(cart.items, 0, fn item, acc ->
item
|> total_item_price()
|> Decimal.add(acc)
end)
end
我們實作了 total_item_price/1
,它接收一個 %CartItem{}
結構。要計算總價,我們僅取預載的產品價格,並將它乘以項目的數量。我們使用 Decimal.mult/2
將我們的十進位貨幣結構乘以適當的精度。類似的,為了計算總購物車價,我們實作了一個 total_cart_price/1
函式,它接收購物車並將購物車中預載產品的價格加總。我們再次使用 Decimal
函式將我們的十進位結構加在一起。
現在我們可以計算價格總計,讓我們試試看!拜訪 https://127.0.0.1:4000/cart
,您應該已經看到購物車中的第一個項目。回到同一個產品,並按一下「加入購物車」,將會顯示我們的 upsert 動作。您的數量現在應該為兩。做得好!
我們的購物車頁面幾乎完成了,但提交表格會產生另一個錯誤。
Request: POST /cart
** (exit) an exception was raised:
** (UndefinedFunctionError) function HelloWeb.CartController.update/2 is undefined or private
讓我們回到我們的 CartController
,在 lib/hello_web/controllers/cart_controller.ex
,並實作更新動作
def update(conn, %{"cart" => cart_params}) do
case ShoppingCart.update_cart(conn.assigns.cart, cart_params) do
{:ok, _cart} ->
redirect(conn, to: ~p"/cart")
{:error, _changeset} ->
conn
|> put_flash(:error, "There was an error updating your cart")
|> redirect(to: ~p"/cart")
end
end
我們首先從提交表格中找出購物車參數。接下來,我們呼叫現有的 ShoppingCart.update_cart/2
函數,這是由內容產生器新增的。我們需要對此函數進行一些變更,但介面是良好的。如果更新成功,我們會重新導向回購物車頁面,否則會顯示一個閃現錯誤訊息並將使用者送回購物車頁面以修正任何錯誤。我們的 ShoppingCart.update_cart/2
函數一開始只專注於將購物車參數轉換為一個變更集,並根據我們的儲存庫更新它。針對我們的目的,我們現在需要它來處理巢狀購物車項目關聯,最重要的是處理數量更新的業務邏輯,例如移除數量為零的項目。
返回 lib/hello/shopping_cart.ex
中的購物車內容,並使用下列實作替換 update_cart/2
函數
def update_cart(%Cart{} = cart, attrs) do
changeset =
cart
|> Cart.changeset(attrs)
|> Ecto.Changeset.cast_assoc(:items, with: &CartItem.changeset/2)
Ecto.Multi.new()
|> Ecto.Multi.update(:cart, changeset)
|> Ecto.Multi.delete_all(:discarded_items, fn %{cart: cart} ->
from(i in CartItem, where: i.cart_id == ^cart.id and i.quantity == 0)
end)
|> Repo.transaction()
|> case do
{:ok, %{cart: cart}} -> {:ok, cart}
{:error, :cart, changeset, _changes_so_far} -> {:error, changeset}
end
end
我們開始的方式非常類似我們一開始的現成程式碼 — 我們使用購物車結構並將使用者輸入轉換為購物車變更集,但這次我們使用 Ecto.Changeset.cast_assoc/3
將巢狀項目資料轉換為 CartItem
變更集。還記得購物車表單範本中的 <.inputs_for />
呼叫嗎?那個隱藏的 ID 資料允許 Ecto 的 cast_assoc
將項目資料對應回購物車中的現有項目關聯。接下來,我們使用 Ecto.Multi.new/0
,你可能以前沒看過。Ecto 的 Multi
是一個允許延後定義一系列命名作業的特性,最後在資料庫交易中執行。多重鏈中的每個作業接收來自前一步驟的值,並執行直到遇到失敗的步驟為止。當一個作業失敗時,會回滾交易並傳回一個錯誤,否則則承諾交易。
對於我們的多重作業,我們開始發起購物車更新,我們將其命名為 :cart
。在發起購物車更新之後,我們執行一個多重 delete_all
作業,它會使用更新後的購物車並套用我們的數量為零邏輯。我們透過傳回一個尋找此購物車中所有數量為空的購物車項目的 Ecto 查詢,來移除購物車中任何數量為零的項目。使用我們的多重呼叫 Repo.transaction/1
會在新的交易中執行作業,我們會傳回成功或失敗的結果給呼叫者,就像原始函數一樣。
讓我們回到瀏覽器並試試看。將一些產品新增到您的購物車,更新數量,並觀察這些值隨著價格計算而改變。設定任何數量為 0 也會移除該項目。很不錯吧!
新增訂單的 context
有了我們的目錄
和購物車
context,我們親自體驗了我們的周全模組和函數名稱如何產生清晰且可維護的程式碼。我們最後要做的業務是允許使用者啟動結帳過程。我們不會深入到整合付款處理或訂單履行,但是我們會讓你開始朝那個方向前進。與之前一樣,我們需要決定完成訂單的程式碼應該放在哪裡。它是目錄的一部分嗎?顯然不是,但是購物車呢?購物車與訂單相關——畢竟,使用者必須新增物品才能購買任何產品——但訂單結帳流程是否應該在此分組?
如果我們停下來考量訂單流程,我們會看到訂單涉及與購物車內容相關但明顯不同的資料。此外,結帳流程的業務規則與購物車大不相同。舉例來說,我們可能允許使用者將缺貨商品新增到他們的購物車,但是我們不能允許沒有庫存的訂單完成。此外,當訂單完成時,我們需要擷取時間點產品資訊,例如物品的價格在付款交易時間。這一點很重要,因為產品價格將來可能會變動,但是訂單中的項目列必須始終記錄並顯示我們在購買當時收費的金額。基於這些原因,我們開始看到訂單可以根據它自己的資料考量和業務規則而獨立存在。
在命名方面,訂單
清楚地定義了我們 context 的範圍,因此,讓我們再次利用 context 產生器開始進行。在你的終端機執行下列指令碼
$ mix phx.gen.context Orders Order orders user_uuid:uuid total_price:decimal
* creating lib/hello/orders/order.ex
* creating priv/repo/migrations/20210209214612_create_orders.exs
* creating lib/hello/orders.ex
* injecting lib/hello/orders.ex
* creating test/hello/orders_test.exs
* injecting test/hello/orders_test.exs
* creating test/support/fixtures/orders_fixtures.ex
* injecting test/support/fixtures/orders_fixtures.ex
Remember to update your repository by running migrations:
$ mix ecto.migrate
我們產生了一個 訂單
context。我們新增了一個 user_uuid
欄位,將我們的 placeholder 目前使用者關聯至訂單,另外還有一個 total_price
欄位。有了我們的起點,讓我們在新建立的priv/repo/migrations/*_create_orders.exs
中的遷移中,做出下列變更
def change do
create table(:orders) do
add :user_uuid, :uuid
- add :total_price, :decimal
+ add :total_price, :decimal, precision: 15, scale: 6, null: false
timestamps()
end
end
如同先前所做,我們為十進位制欄位提供了合適的精確度和縮放選項,讓我們得以在不損失精確度的情況下儲存貨幣。我們還新增了非空限制,以強制所有訂單都有價格。
訂單表格本身不包含太多資訊,但我們知道我們需要儲存訂單中所有物品的時間點產品價格資訊。為此,我們將在這個 context 中再新增一個名為 LineItem
的結構。項目列將擷取產品的價格。請執行下列指令碼
$ mix phx.gen.context Orders LineItem order_line_items \
price:decimal quantity:integer \
order_id:references:orders product_id:references:products
You are generating into an existing context.
...
Would you like to proceed? [Yn] y
* creating lib/hello/orders/line_item.ex
* creating priv/repo/migrations/20210209215050_create_order_line_items.exs
* injecting lib/hello/orders.ex
* injecting test/hello/orders_test.exs
* injecting test/support/fixtures/orders_fixtures.ex
Remember to update your repository by running migrations:
$ mix ecto.migrate
我們使用了 phx.gen.context
指令來產生 LineItem
Ecto 架構,並將支援函式導入我們的 orders context。和之前一樣,讓我們修改 priv/repo/migrations/*_create_order_line_items.exs
中的遷移並進行下列小數欄位變更
def change do
create table(:order_line_items) do
- add :price, :decimal
+ add :price, :decimal, precision: 15, scale: 6, null: false
add :quantity, :integer
add :order_id, references(:orders, on_delete: :nothing)
add :product_id, references(:products, on_delete: :nothing)
timestamps()
end
create index(:order_line_items, [:order_id])
create index(:order_line_items, [:product_id])
end
遷移就緒後,讓我們在 lib/hello/orders/order.ex
中串聯我們的 orders 和 line item 相關性
schema "orders" do
field :total_price, :decimal
field :user_uuid, Ecto.UUID
+ has_many :line_items, Hello.Orders.LineItem
+ has_many :products, through: [:line_items, :product]
timestamps()
end
我們使用 has_many :line_items
來關聯 orders 和 line item,就像我們先前看過的一樣。接下來,我們使用了 has_many
的 :through
功能,它容許我們指示 ecto 如何 via 其他關係來關聯資源。在這種情況下,我們可以藉由尋找經由關聯的 line item 而取得的全部商品來關聯訂單裡的商品。接下來,讓我們在 lib/hello/orders/line_item.ex
中串聯其他方向的關聯
schema "order_line_items" do
field :price, :decimal
field :quantity, :integer
- field :order_id, :id
- field :product_id, :id
+ belongs_to :order, Hello.Orders.Order
+ belongs_to :product, Hello.Catalog.Product
timestamps()
end
我們使用 belongs_to
來關聯 line item 至 orders 和 products。藉由我們的關聯,我們可以開始將網路介面整合到訂單流程之中。開啟你的路由器 lib/hello_web/router.ex
並加上下列列
scope "/", HelloWeb do
pipe_through :browser
...
+ resources "/orders", OrderController, only: [:create, :show]
end
我們為我們產生的 OrderController
串聯了 create
和 show
路由,因為在目前來說這是我們需要的唯一動作。有了路由,我們現在可以進行向上遷移
$ mix ecto.migrate
17:14:37.715 [info] == Running 20210209214612 Hello.Repo.Migrations.CreateOrders.change/0 forward
17:14:37.720 [info] create table orders
17:14:37.755 [info] == Migrated 20210209214612 in 0.0s
17:14:37.784 [info] == Running 20210209215050 Hello.Repo.Migrations.CreateOrderLineItems.change/0 forward
17:14:37.785 [info] create table order_line_items
17:14:37.795 [info] create index order_line_items_order_id_index
17:14:37.796 [info] create index order_line_items_product_id_index
17:14:37.798 [info] == Migrated 20210209215050 in 0.0s
在我們呈現我們訂單的資訊之前,我們需要確保我們的訂單資料已完全填妥且目前的使用者可以查詢。開啟你位於 lib/hello/orders.ex
中的 orderts context 並用新的 get_order!/2
定義取代你的 get_order!/1
函式
def get_order!(user_uuid, id) do
Order
|> Repo.get_by!(id: id, user_uuid: user_uuid)
|> Repo.preload([line_items: [:product]])
end
我們重新撰寫函式以接受使用者 UUID 並向我們的儲存庫查詢與特定訂單 ID 對應使用者 ID 的訂單。然後我們透過預先載入我們的 line item 和 product 關聯性來填妥訂單。
為了完成訂單,我們的購物車頁面可以對 OrderController.create
動作執行 POST,但我們需要實作實際完成訂單的作業和邏輯。和之前一樣,我們會從網路介面開始。在 lib/hello_web/controllers/order_controller.ex
建立一個新檔案並鍵入
defmodule HelloWeb.OrderController do
use HelloWeb, :controller
alias Hello.Orders
def create(conn, _) do
case Orders.complete_order(conn.assigns.cart) do
{:ok, order} ->
conn
|> put_flash(:info, "Order created successfully.")
|> redirect(to: ~p"/orders/#{order}")
{:error, _reason} ->
conn
|> put_flash(:error, "There was an error processing your order")
|> redirect(to: ~p"/cart")
end
end
end
我們撰寫 create
動作以呼叫尚未實作的 Orders.complete_order/1
函式。我們的程式碼在技術上是「建立」一個訂單,但從長遠來看考量你的介面命名相當重要。完成 訂單的動作在我們的系統中非常重要。金錢會在交易中流動,實體商品可能自動運送,等等。這樣的作業需要一個更好、更明確的函式名稱,例如 complete_order
。如果訂單順利完成,我們即重新導向至顯示頁面,否則在重新導向回到購物車頁面時會顯示一個 flash 錯誤訊息。
這裡也是個好機會來說明,context 自然而然也能處理其他 context 定義的資料。這特別適用於在整個應用程式中都會用到的資料,例如這裡的購物車(但根據專案設定,也可以是目前的使用者或目前的專案,以此類推)。
現在,我們可以實作 Orders.complete_order/1
函式。要完成一筆訂單,我們的工作需要幾個步驟
- 必須保存一筆新的訂單紀錄,內容包含訂單的總價
- 購物車中的所有商品都必須轉換成新的訂單明細項目紀錄,內容包含商品數量和即時產品價格資訊
- 在成功插入訂單(並最終付款)後,商品必須從購物車中刪除
單從我們的需求中,我們就可以開始了解為何一般 create_order
函式無法滿足需求。讓我們在 lib/hello/orders.ex
中實作這個新函式
alias Hello.Orders.LineItem
alias Hello.ShoppingCart
def complete_order(%ShoppingCart.Cart{} = cart) do
line_items =
Enum.map(cart.items, fn item ->
%{product_id: item.product_id, price: item.product.price, quantity: item.quantity}
end)
order =
Ecto.Changeset.change(%Order{},
user_uuid: cart.user_uuid,
total_price: ShoppingCart.total_cart_price(cart),
line_items: line_items
)
Ecto.Multi.new()
|> Ecto.Multi.insert(:order, order)
|> Ecto.Multi.run(:prune_cart, fn _repo, _changes ->
ShoppingCart.prune_cart_items(cart)
end)
|> Repo.transaction()
|> case do
{:ok, %{order: order}} -> {:ok, order}
{:error, name, value, _changes_so_far} -> {:error, {name, value}}
end
end
我們從將購物車中的 %ShoppingCart.CartItem{}
對應到訂單明細項目結構的地圖開始做起。訂單明細項目紀錄的功能是記錄付款交易時產品的價格,因此我們在此參照產品的價格。接下來,我們使用 Ecto.Changeset.change/2
建立一個裸的訂單變更集,並關聯我們的使用者 UUID、設定總價計算,以及在變更集中放置我們的訂單明細項目。當已準備好要插入新的訂單變更集時,我們可以再次使用 Ecto.Multi
在資料庫交易中執行我們的操作。我們從插入訂單開始,接著執行 run
操作。Ecto.Multi.run/3
函式允許我們在函式中執行任何程式碼,該程式碼必須以 {:ok, result}
或錯誤成功,否則會停止交易並回滾交易。在此,我們只需呼叫我們的購物車 context,並請它修剪購物車中的所有商品。執行該交易會像之前一樣執行 multi,而且我們將結果傳回給呼叫者。
為了完成我們的訂單完成作業,我們需要在 lib/hello/shopping_cart.ex
中實作 ShoppingCart.prune_cart_items/1
函式
def prune_cart_items(%Cart{} = cart) do
{_, _} = Repo.delete_all(from(i in CartItem, where: i.cart_id == ^cart.id))
{:ok, reload_cart(cart)}
end
我們的函式接受購物車結構,並發出 Repo.delete_all
,其接受執行已提供購物車的所有商品查詢。我們僅透過將修剪後的購物車重新載入到呼叫者中,傳回成功結果。當我們的 context 完成後,現在我們需要向使用者顯示他們完成的訂單。回到你的訂單控制器,並新增 show/2
動作
def show(conn, %{"id" => id}) do
order = Orders.get_order!(conn.assigns.current_uuid, id)
render(conn, :show, order: order)
end
我們加入 show 動作,將我們的 conn.assigns.current_uuid
傳遞給 get_order!
,授權訂單只能由訂單擁有者觀看。接下來,我們可以實作檢視和範本。在 lib/hello_web/controllers/order_html.ex
產生一個新的檢視檔案,內容如下
defmodule HelloWeb.OrderHTML do
use HelloWeb, :html
embed_templates "order_html/*"
end
接下來,我們可以在 lib/hello_web/controllers/order_html/show.html.heex
建立範本
<.header>
Thank you for your order!
<:subtitle>
<strong>User uuid: </strong><%= @order.user_uuid %>
</:subtitle>
</.header>
<.table id="items" rows={@order.line_items}>
<:col :let={item} label="Title"><%= item.product.title %></:col>
<:col :let={item} label="Quantity"><%= item.quantity %></:col>
<:col :let={item} label="Price">
<%= HelloWeb.CartHTML.currency_to_str(item.price) %>
</:col>
</.table>
<strong>Total price:</strong>
<%= HelloWeb.CartHTML.currency_to_str(@order.total_price) %>
<.back navigate={~p"/products"}>Back to products</.back>
為了顯示我們已完成的訂單,我們顯示了訂單的使用者,然後是產品標題、數量和我們在完成訂單時「交易」的價格,以及總價的清單項目。
最後,我們將在購物車頁面新增「完成訂單」按鈕,以允許完成訂單。將以下按鈕新增到 lib/hello_web/controllers/cart_html/show.html.heex
中購物車顯示範本的 <.header>
<.header>
My Cart
+ <:actions>
+ <.link href={~p"/orders"} method="post">
+ <.button>Complete order</.button>
+ </.link>
+ </:actions>
</.header>
我們使用 method="post"
新增超連結,以將 POST 要求傳送給我們的 OrderController.create
動作。如果我們回到 https://127.0.0.1:4000/cart
的購物車頁面,並完成訂單,我們就會看到所呈現的範本
Thank you for your order!
User uuid: 08964c7c-908c-4a55-bcd3-9811ad8b0b9d
Title Quantity Price
Metaprogramming Elixir 2 $15.00
Total price: $30.00
做得好!我們尚未加入付款功能,但我們已經可以看到我們的 ShoppingCart
和 Orders
的脈絡切分如何讓我們邁向可維護的解決方案。購物車項目從訂單項目分離後,我們在未來可以充裕地新增付款交易、購物車價格偵測等功能。
做得很好!
常問問題
何時使用程式碼產生器?
在本指南中,我們已對架構、脈絡、控制器等使用程式碼產生器。如果你樂於使用 Phoenix 預設值,歡迎依賴產生器來建立應用程式的各個主要部分。使用 Phoenix 產生器時,你需要回答的主要問題是:這個新功能(包括其架構、表格和欄位)是否屬於現有某個脈絡或是一個新的脈絡?
透過此方式,Phoenix 產生器引導你使用脈絡將相關功能分組,而不是讓數十個架構散落四周,毫無結構。此外:如果你在想出脈絡名稱時遇到困難,你可以直接使用你正在建立的資源的複數形式。
我如何組織脈絡內的程式碼?
你可能想知道如何組織脈絡內的程式碼。例如,你是否應為變更組 (例如 ProductChangesets) 定義模組,並為查詢 (例如 ProductQueries) 定義另一個模組?
脈絡的其中一個重要優點是這個決策並不重要。脈絡是你的公開 API,其他模組是私有的。脈絡將這些模組分離成小型群組,因此應用程式的表面區域是脈絡,而不是你的所有程式碼。
因此,儘管你和你的團隊可以為這些私人模組建立組織模式,但我們也認為讓它們不同也是完全適當的。重點應放在如何定義脈絡,以及它們如何彼此互動(以及如何與你的網頁應用程式互動)。
將其視為一個整理良好的鄰里。你的脈絡就是房屋,你希望它們保存得當且連接良好,等等。在房屋內,它們可能都稍微不同,而這沒關係。
從脈絡 API 傳回 Ecto 結構
當我們探索脈絡 API 時,你可能曾想過
如果我們脈絡的其中一個目標是封裝 Ecto Repo 存取,為什麼當我們無法建立使用者時,
create_user/1
會傳回一個Ecto.Changeset
結構?
儘管 Changeset 是 Ecto 的一部分,它們並未繫結到資料庫,且可從任何來源匯入並輸出資料,這讓它成為一個一般且有用的資料結構,用於追蹤欄位變更、執行驗證,以及產生錯誤訊息。
因此,%Ecto.Changeset{}
是建模你的脈絡和網頁層之間的資料變更的理想選擇,無論你是否與 API 或資料庫溝通。
最後,請注意你的控制器和檢視也不會硬式編碼為只使用 Ecto。相反地,Phoenix 定義通訊協定,例如 Phoenix.Param
和 Phoenix.HTML.FormData
,它們允許任何函式庫擴充 Phoenix 產生 URL 參數或呈現表單的方式。對我們來說方便的是,phoenix_ecto
專案實作了那些通訊協定,但你也可以帶來你自己的資料結構並自己實作它們。