コンテキスト
前提: リクエストライフサイクルのガイドの内容を前提としています
前提: このガイドでは、Ectoガイドの内容を前提としています
これまでに、ページを構築し、ルーターを介してコントローラーのアクションを接続し、Ectoがどのようにしてデータをバリデートし、永続化するかを学んできました。今度は、より大きなElixirアプリケーションと相互作用するWeb向けの機能を書くことで、すべてを結びつける時が来ました。
Phoenixプロジェクトの構築する際に、何よりもまずElixirアプリケーションの構築を行います。Phoenixの仕事は、ElixirアプリケーションにWebインターフェイスを提供することです。当然のことながら、アプリケーションはモジュールと関数で構成されますが、単にモジュールにいくつかの関数を定義するだけでは、アプリケーションの設計には不十分です。モジュール間の境界や、機能のまとめ方を考える必要があります。つまり、コードを書くときには、アプリケーションの設計を考えることが重要なのです。
設計について考える
コンテキストは、関連する関数を公開したり、グループ化したりする専用モジュールです。たとえば、Logger.info/1
や Stream.map/2
など、Elixirの標準ライブラリを呼び出すときはいつでも、異なるコンテキストにアクセスしていることになります。内部的には、Elixirのロガーは複数のモジュールで構成されていますが、それらのモジュールと直接やりとりすることはありません。私たちは Logger
モジュールをコンテキストと呼んでいますが、これは正確にはすべてのロギング機能を公開し、グループ化しているからです。
関連する機能を公開したり、グループ化したりするモジュールに「コンテキスト」という名前をつけることで、開発者がこれらのパターンを識別し、それについて話し合えるようにしています。結局のところ、コンテキストは、コントローラーやビューなどと同様に、単なるモジュールに過ぎません。
Phoenixでは、コンテキストはデータアクセスやデータ検証をカプセル化することが多いです。コンテキストは、データベースやAPIに接続されます。全体的には、アプリケーションの一部を切り離して分離させるための境界線と考えてください。それでは、これらの考え方を参考にして、Webアプリケーションを構築してみましょう。私たちの目標は、商品を紹介し、ユーザーが商品をカートに入れ、注文を完了できるようなeコマースシステムを構築することです。
このガイドの読み方: コンテキストジェネレーターの使用は、Elixirプログラマーの初心者から中級者まで、アプリケーションを熟考して設計しながら素早く立ち上げることができる素晴らしい方法です。このガイドは、そのような読者に焦点を当てています。一方、経験豊富な開発者は、アプリケーションの設計に関する細かな議論からより多くの利益を得られるかもしれません。そのような方のために、ガイドの最後に「よくある質問(FAQ)」を掲載し、設計に関するさまざまな視点を紹介しています。初心者の方は、FAQセクションを読み飛ばしても問題ありませんが、より深く知りたい場合は、後ほどお読みください。
Catalogコンテキストを追加する
eコマースプラットフォームは、コードベース全体に広く結合しているため、明確なインターフェイスを書くことを前もって考えることが重要です。この点を考慮して、私たちの目標は、システムで利用可能な商品の作成、更新、および削除を処理する商品カタログAPIを構築することです。最初は商品を紹介するという基本的な機能から始めますが、後からカート機能を追加していきます。境界を分離した強固な基盤から始めることで、機能の追加に合わせてアプリケーションを自然に成長させることができることがわかります。
Phoenix には、mix phx.gen.html
、mix phx.gen.json
、mix phx.gen.live
、mix phx.gen.context
の各ジェネレーターが含まれており、アプリケーションの機能をコンテクストに分離するというアイデアを応用しています。これらのジェネレーターは、Phoenixがアプリケーションを成長させるために正しい方向に導いてくれるので、すぐに使い始めることができます。これらのツールを新しい商品カタログのコンテキストに使用してみましょう。
コンテキストジェネレーターを実行するには、構築する関連機能をグループ化したモジュール名を考える必要があります。Ectoガイド では、Changeset と Repos を使用してユーザー スキーマを検証して持続させる方法を見ましたが、これをアプリケーション全体に統合することはしませんでした。実際、私たちのアプリケーションにおける「ユーザー」の居場所については、まったく考えていませんでした。一歩下がって、システムのさまざまな部分について考えてみましょう。商品を販売するページでは、商品の説明や価格などを紹介します。商品の販売と同時に、カートや注文のチェックアウトなどもサポートする必要があることもわかっています。購入される商品はカートやチェックアウトのプロセスに関連していますが、商品を展示したり、商品の展示を管理することは、ユーザーがカートに入れたものを追跡したり、注文がどのように行われたかを追跡することとは明確に異なります。Catalog
というコンテキストは、商品の詳細を管理したり、販売中の商品を展示したりするのに適しています。
命名は難しいです。システムのグループ化された機能がまだ明確でなく、コンテキスト名を考え出すのに行き詰まった場合、単純に作成するリソースの複数形を使用できます。たとえば、商品を管理するための
Products
というコンテキストです。アプリケーションが成長し、システムの一部が明らかになってきたら、コンテキストの名前をより洗練された名前に変更できます。
catalogコンテキストを作成するために、mix phx.gen.html
を利用します。これは、商品の作成、更新、削除のためのEctoアクセスと、コントローラーやWebインターフェイス用のテンプレートなどのWebファイルを、コンテキストにまとめるコンテキストモジュールを作成するものです。プロジェクトのルートで以下のコマンドを実行してください。
$ 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/templates/product/edit.html.heex
* creating lib/hello_web/templates/product/form.html.heex
* creating lib/hello_web/templates/product/index.html.heex
* creating lib/hello_web/templates/product/new.html.heex
* creating lib/hello_web/templates/product/show.html.heex
* creating lib/hello_web/views/product_view.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
注意:ここでは、eコマースシステムをモデル化するための基本的な方法を説明しています。実際には、このようなシステムをモデル化すると、商品のバリエーション、オプション価格、複数の通貨など、より複雑な関係が生まれます。このガイドではシンプルに説明しますが、基礎を学ぶことで、このような完全なシステムを構築するための確かな出発点となるでしょう。
Phoenixは期待通りにWebファイルを lib/hello_web/
に生成してくれました。また、コンテキストファイルが lib/hello/catalog.ex
ファイルの中に生成され、商品スキーマが同名のディレクトリに生成されていることがわかります。lib/hello
と lib/hello_web
の違いに注目してください。商品カタログ機能のパブリックAPIとして機能する Catalog
モジュールと、商品データをキャストしてバリデーションするためのEctoスキーマである Catalog.Product
構造体があります。Phoenixはウェブとコンテキストのテストも提供し、Hello.Catalog
コンテキストでエンティティを作成するためのテストヘルパーも含まれていますが、これについてはあとで見てみましょう。ひとまず、コンソールの指示にしたがって、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
price列の精度を15、スケールを6とし、not-null制約を設けました。これにより、通貨を適切な精度で保存し、数学的な操作を行うことができます。次に、viewsにデフォルト値と not-null 制約を追加しました。変更が完了したので、データベースをマイグレートする準備ができました。今すぐ実行しましょう。
$ 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
でサーバーを起動し、http://localhost:4000/productsにアクセスしてみましょう。"New Product"のリンクをたどって、何も入力せずに"Save"ボタンをクリックしましょう。次のような出力が表示されるはずです。
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.html", products: products)
end
def new(conn, _params) do
changeset = Catalog.change_product(%Product{})
render(conn, "new.html", 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: Routes.product_path(conn, :show, product))
{:error, %Ecto.Changeset{} = changeset} ->
render(conn, "new.html", changeset: changeset)
end
end
def show(conn, %{"id" => id}) do
product = Catalog.get_product!(id)
render(conn, "show.html", product: product)
end
...
end
コントローラーがどのように動作するかは コントローラーガイドで説明しましたので、このコードはそれほど驚くべきものではありません。注目すべき点は、コントローラーがどのようにして Catalog
コンテキストを呼び出しているかということです。index
アクションでは、Catalog.list_products/0
で商品のリストを取得し、create
アクションでは、Catalog.create_product/1
で商品を永続化していることがわかります。まだカタログのコンテキストを見ていないので、商品の取得や作成がどのように行われているのかはわかりませんが、そこがポイントです。Phoenixのコントローラーは、大規模なアプリケーションのウェブインターフェイスです。商品がデータベースからどのように取得されるか、ストレージにどのように保存されるかなどの詳細は気にする必要はありません。私たちが気にしているのは、アプリケーションに何らかの作業を実行させることだけです。ビジネスロジックとストレージの詳細は、アプリケーションのウェブ層から切り離されているので、これは素晴らしいことです。後日、商品の取得にSQLクエリではなくフルテキストストレージエンジンを使用することになっても、コントローラーを変更する必要はありません。同様に、チャンネルやMixタスク、CSVデータを取り込む長時間のプロセスなど、アプリケーションの他のインターフェイスからコンテキストコードを再利用できます。
create
アクションの場合、商品の作成に成功すると、Phoenix.Controller.put_flash/3
を使って成功メッセージを表示し、ルーターの product_path
の詳細ページにリダイレクトします。逆に、Catalog.create_product/1
が失敗した場合は、"new.html"
テンプレートをレンダリングして、エラーメッセージを取り出すためにテンプレート用のEctoチェンジセットを渡します。
次に、より深く掘り下げて、lib/hello/catalog.ex
の Catalog
コンテキストをチェックしてみましょう。
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のクエリがどのように動作するかを見たので、この呼び出しは見覚えがあるはずです。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
といった一般的な関数を生成しますが、これらはビジネスロジックやアプリケーションを成長させるための基盤としての役割しかありません。ここでは、カタログの基本的な機能の1つである、商品ページの閲覧数のトラッキング機能を追加してみましょう。
どんなeコマースシステムでも、商品ページが何回閲覧されたかを追跡する機能は、マーケティング、サジェスト、ランキングなどのために不可欠です。既存の 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
ここでは、Repo.update_all
に渡すIDを使って、現在の商品を取得するクエリを作成しました。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.html", 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を拡張する方法がわかったところで、コンテキスト内のリレーションを扱う方法を探ってみましょう。
コンテキスト内のリレーションシップ
基本的なカタログ機能もいいのですが、さらに進化させるために、商品をカテゴリー別に分けてみましょう。多くのeコマースソリューションでは、商品をさまざまな方法で分類できます。たとえば、ある商品をファッション用、電動工具用などにマークできます。商品とカテゴリーを1対1の関係で始めてしまうと、あとで複数のカテゴリーをサポートする必要が出てきたときに、コードの大幅な変更が必要になります。最初は商品ごとに1つのカテゴリーを追跡し、後に機能が増えても簡単にサポートできるように、カテゴリーの関連付けを設定してみましょう。
今のところ、カテゴリーはテキスト情報のみを含みます。最初にやるべきことは、アプリケーションの中でカテゴリーをどこに置くかを決めることです。ここには商品の展示を管理する「カタログ」コンテキストがあります。商品のカテゴリー化はここに自然にフィットします。Phoenixは既存のコンテキストの中でコードを生成することもできるので、コンテキストに新しいリソースを追加するのも簡単です。プロジェクトのルートで次のコマンドを実行します。
2つのリソースが同じコンテキストに属しているかどうかを判断するのが難しい場合があります。そのような場合には、リソースごとに異なるコンテキストを用意し、必要に応じて後からリファクタリングを行います。そうしないと、緩やかに関連したエンティティの大きなコンテキストになってしまうことがあります。また、2つのリソースが関連しているからといって、必ずしも同じコンテキストに属しているとは限らないことも覚えておいてください。そうでなければ、アプリケーション内のリソースの大部分は互いに接続されているため、すぐに1つの大きなコンテキストになってしまいます。結論として、確信が持てない場合は、迷ったら別々のモジュール(コンテクスト)がいいということです。
$ 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
と同じですが、ウェブファイルを生成しないことが特徴です。商品を管理するためのコントローラーとテンプレートはすでに用意されているので、新しいカテゴリー機能を既存のウェブフォームと商品詳細ページに統合できます。lib/hello/catalog/category.ex
にある商品スキーマと一緒に、新しい Category
スキーマがあることがわかります。また、Phoenixはカテゴリー機能のために、既存のCatalogコンテキストに新しい関数を注入していることを教えてくれました。注入された関数は、product関数と非常によく似ていて、create_category
や list_categories
などの新しい関数があります。マイグレーションを実行する前に、2つめのコード生成を行う必要があります。カテゴリースキーマは、システム内の個々のカテゴリーを表現するには最適ですが、商品とカテゴリーの間の多対多の関係をサポートする必要があります。幸いなことに、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 index(:product_categories, [:category_id])
create unique_index(:product_categories, [:product_id, :category_id])
end
end
次に、:product_categories
テーブルを作成し、primary_key: false
オプションを使用しました。これは、中間テーブルに主キーが必要ないからです。次に、:product_id
と :category_id
の外部キーフィールドを定義し、on_delete: :delete_all
を渡して、リンクしている商品やカテゴリが削除された場合に、データベースが中間テーブルのレコードを確実に削除するようにしました。データベース制約を使用することで、その場しのぎでエラーが発生しやすいアプリケーションロジックに頼ることなく、データベースレベルでデータの整合性を確保しています。
次に、外部キーにインデックスを作成し、商品が重複したカテゴリーを持たないようにするためのユニークインデックスを作成しました。マイグレーションの準備が整ったら、次は実行しましょう。
$ 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.558 [info] create index product_categories_category_id_index
18:20:36.560 [info] create index product_categories_product_id_category_id_index
18:20:36.562 [info] == Migrated 20210222231930 in 0.0s
これで Catalog.Product
スキーマと、商品とカテゴリーを関連付ける中間テーブルができたので、新機能を繋ぎ合わせる準備がほぼ整いました。その前に、まずWebUIで選択できる実際のカテゴリーが必要です。早速、アプリケーションに新しいカテゴリーを追加してみましょう。以下のコードを、priv/repo/seeds.exs
にあるseedsファイルに追加してください。
for title <- ["Home Improvement", "Power Tools", "Gardening", "Books"] do
{:ok, _} = Hello.Catalog.create_category(%{title: title})
end
単純にカテゴリタイトルのリストを列挙して、カタログコンテキストの生成された create_category/1
関数を使って新しいレコードを永続化します。mix run
でseedを実行できます。
$ 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
マクロを使用して、"product_categories"
テーブルを通して、商品を複数のカテゴリーに関連付ける方法をEctoに知らせました。また、on_replace: :delete
オプションを使用して、カテゴリーを変更する際に既存の結合レコードを削除することを宣言しました。
スキーマの関連付けができたので、商品フォームにカテゴリーの選択を実装できます。そのためには、フロントエンドからのカタログIDのユーザー入力を、多対多の関連付けに変換する必要があります。幸いなことに、スキーマの設定を行なったので、この作業を簡単に行うことができます。カタログコンテキストを開き、次のように変更します。
- 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
を参照できるようになります。次に、既存の change_product
関数を呼び出してチェンジセットを生成するように、create_product
および update_product
関数を修正しました。change_product
の中で、"category_ids"
属性があれば、すべてのカテゴリーを検索する処理を追加しました。そして、カテゴリをプリロードし、Ecto.Changeset.put_assoc
を呼び出して、取得したカテゴリをチェンジセットに配置しました。最後に、list_categories_by_id/1
関数を実装しました。これはカテゴリIDにマッチするカテゴリを問い合わせ、"category_ids"
属性がない場合は空のリストを返します。これで、create_product
や update_product
関数が、カテゴリの関連付けがなされたチェンジセットを受け取るようになり、Repoに対して挿入や更新を試みる際に、すぐに利用できるようになりました。
次に、カテゴリー入力を商品フォームに追加して、新機能をウェブに公開しましょう。フォームのテンプレートを整頓するために、新しい関数を書いて、商品のカテゴリ選択入力のレンダリングの詳細をまとめましょう。lib/hello_web/views/product_view.ex
にある ProductView
を開いて、次のように入力します。
defmodule HelloWeb.ProductView do
use HelloWeb, :view
def category_select(f, changeset) do
existing_ids = changeset |> Ecto.Changeset.get_change(:categories, []) |> Enum.map(& &1.data.id)
category_opts =
for cat <- Hello.Catalog.list_categories(),
do: [key: cat.title, value: cat.id, selected: cat.id in existing_ids]
multiple_select(f, :category_ids, category_opts)
end
end
新しい category_select/2
関数を追加しました。この関数は Phoenix.HTML
の multiple_select/3
を使用して、複数の選択タグを生成します。チェンジセットから既存のカテゴリーIDを計算して、入力タグの選択オプションを生成するときにその値を使用しました。これは、すべてのカテゴリーを列挙し、適切な key
, value
, selected
の値を返すことで行いました。チェンジセットに含まれるカテゴリーIDの中にそのカテゴリーIDが含まれていれば、そのオプションは選択されたものとしてマークされます。
これで category_select
関数ができたので、lib/hello_web/templates/product/form.html.heex
を開いて追加します。
...
<%= label f, :views %>
<%= number_input f, :views %>
<%= error_tag f, :views %>
+ <%= category_select f, @changeset %>
<div>
<%= submit "Save" %>
</div>
保存ボタンの上に category_select
を追加しました。では、実際に試してみましょう。次に、商品のshowテンプレートで、商品のカテゴリーを表示してみましょう。以下のコードを lib/hello_web/templates/product/show.html.heex
に追加します。
...
+ <li>
+ <strong>Categories:</strong>
+ <%= for cat <- @product.categories do %>
+ <%= cat.title %>
+ <br>
+ <% end %>
+ </li>
</ul>
ここで、mix phx.server
でサーバーを起動し、http://localhost:4000/products/newにアクセスすると、新しいカテゴリの複数選択入力が表示されます。有効な商品情報を入力し、カテゴリーを1つまたは2つ選択して、「保存」をクリックします。
Title: Elixir Flashcards
Description: Flash card set for the Elixir programming language
Price: 5.000000
Views: 0
Categories:
Education
Books
まだまだ見栄えはしませんが、ちゃんと動いています。コンテキスト内にリレーションシップを追加し、データベースによってデータの整合性を確保しました。悪くないですね。引き続き構築していきましょう!
コンテキスト間の依存
商品カタログの機能ができたので、アプリケーションのもう1つの主な機能である、カタログからの商品のカートインに取り掛かりましょう。ユーザーがカートに入れた商品を適切に追跡するためには、カートに入れた時点での価格などの商品情報と一緒に、この情報を保存する新しい場所が必要です。これは、将来的に商品価格の変更を検出するために必要です。何を作るべきかはわかりましたが、次はカート機能をアプリケーションのどこに置くかを決めなければなりません。
一歩下がって、アプリケーションを切り離して考えてみると、カタログでの商品の展示は、ユーザーのカート管理の責任とは明らかに異なります。商品カタログは、ショッピングカートシステムのルールを気にするべきではありませんし、その逆もまた然りです。新しいカートの責任を処理するために、別のコンテキストが必要なのは明らかです。これを 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 でトラッキングされます。カートの準備ができたので、カートアイテムを生成してみましょう。
$ 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
を少し変更しましょう。
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
のコンテキストにあるアプリケーションコードに頼らずに、レコードのクリーンアップを行うことができます。これにより、アプリケーションコードは切り離され、データの整合性を保つためにはデータベースが必要となります。また、重複した商品がカートに追加されないように、ユニーク制約を追加しました。これでデータベースのテーブルができたので、あとはマイグレーションを実行するだけです。
$ 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
テーブルで準備が整いましたが、今度はそれをアプリケーションコードにマッピングする必要があります。データベースの外部キーを異なるテーブルに混在させるにはどうすればいいのか、それが孤立した機能とグループ化された機能というコンテキストパターンにどう関係するのか、疑問に思うかもしれません。ここでは、それぞれのアプローチとそのトレードオフについて説明します。
コンテキストをまたぐデータ
これまでのところ、私たちはアプリケーションの2つの主要なコンテキストを互いに分離する素晴らしい仕事をしてきましたが、今度は処理する必要のある依存関係が出てきました。
私たちの Catalog.Product
リソースは、カタログ内の製品を表現する責任を保持する役割を果たしますが、最終的にカートにアイテムが存在するためには、カタログから製品が存在する必要があります。このことから、私たちの ShoppingCart
コンテキストは Catalog
コンテキストにデータ依存を持つことになります。このことを念頭に置いて、私たちには2つのオプションがあります。1つは 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 :cart_id, :id
- field :product_id, :id
field :price_when_carted, :decimal
field :quantity, :integer
+ 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
まず、cart_id
フィールドを、ShoppingCart.Cart
スキーマを指す標準的な belongs_to
で置き換えました。次に、product_id
フィールドを置き換えて、Catalog.Product
スキーマを指す belongs_to
という最初のコンテキストをまたぐデータ依存関係を追加しました。ここではデータの境界を意図的に結合していますが、これはまさに私たちが必要としているものを提供しているからです。我々のシステムで商品を参照するために必要な最低限の知識を持つ独立したコンテキストAPIです。次に、チェンジセットに新しいバリデーションを追加しました。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, {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
+
+ def 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
を実装して、create cart関数を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
CartItemController
のための resources
宣言を追加しました。この宣言は、個々のカートアイテムを追加するためのcreateアクション、削除するためのdeleteアクションをルートに追加します。次に、CartController
を指す2つの新しいルートを追加しました。1つ目のルートはGETリクエストで、カートの中身を表示するshowアクションにマッピングされます。2つ目のルートはPUTリクエストで、カートの数量を更新するためのフォームの送信を処理します。
ルートができたところで、商品紹介ページから商品をカートに追加する機能を追加しましょう。新しいファイルを lib/hello_web/controllers/cart_item_controller.ex
に作成します。
defmodule HelloWeb.CartItemController do
use HelloWeb, :controller
alias Hello.{ShoppingCart, Catalog}
def create(conn, %{"product_id" => product_id}) do
product = Catalog.get_product!(product_id)
case ShoppingCart.add_item_to_cart(conn.assigns.cart, product) do
{:ok, _item} ->
conn
|> put_flash(:info, "Item added to your cart")
|> redirect(to: Routes.cart_path(conn, :show))
{:error, _changeset} ->
conn
|> put_flash(:error, "There was an error adding the item to your cart")
|> redirect(to: Routes.cart_path(conn, :show))
end
end
def delete(conn, %{"id" => product_id}) do
{:ok, _cart} = ShoppingCart.remove_item_from_cart(conn.assigns.cart, product_id)
redirect(conn, to: Routes.cart_path(conn, :show))
end
end
ルーターで宣言したcreateとdeleteのアクションを持つ新しい CartItemController
を定義しました。create
では、まずカタログ内の商品を Catalog.get_product!/1
で検索し、次に ShoppingCart.add_item_to_cart/2
関数を呼び出します。この関数はあとで実装します。成功した場合は成功した旨のフラッシュメッセージを表示して、カートの詳細ページにリダイレクトします。そうでない場合はフラッシュエラーメッセージを表示して、カートの詳細ページにリダイレクトします。delete
では、ShoppingCart
コンテキストに実装する remove_item_from_cart
関数を呼び出して、カートの詳細ページにリダイレクトします。これらの2つのショッピングカート関数はまだ実装していませんが、その名前がいかにその意図を表しているかに注目してください。add_item_to_cartと
remove_item_from_cart` で、ここで何をしようとしているのかが一目瞭然です。また、実装の詳細を一度に考えることなく、WebレイヤーやコンテキストAPIの仕様を決めることができます。
それでは、ShoppingCart
コンテキストAPIの新しいインターフェイスを lib/hello/shopping_cart.ex
に実装してみましょう。
alias Hello.Catalog
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(%{})
+ 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, %Catalog.Product{} = product) do
%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
関数を作成しました。この関数は、カタログから cart 構造体と product 構造体を受け取ります。この関数は、カタログからカート構造体と商品構造体を受け取ります。レポに対してアップサート操作を行い、新しいカートアイテムをデータベースに挿入するか、すでにカートに存在する場合は数量を1つ増やすようにしました。これは on_conflict
と conflict_target
オプションによって実現されており、挿入の競合をどのように処理するかをレポに伝えます。次に、remove_item_from_cart/2
を実装しました。ここでは、商品IDに一致するカートの商品を削除するためのクエリを含む Repo.delete_all
コールを単純に発行します。最後に、reload_cart/1
を呼び出してカートの中身を再読み込みします。
新しいカート機能ができたので、商品カタログの詳細ページで「カートに入れる」ボタンを表示できるようにしましょう。lib/hello_web/templates/product/show.html.heex
でテンプレートを開き、次のように変更します。
<h1>Show Product</h1>
+<%= link "Add to cart",
+ to: Routes.cart_item_path(@conn, :create, product_id: @product.id),
+ method: :post %>
...
Phoenix.HTML
の link
関数には :method
オプションがあり、クリックするとデフォルトのGETリクエストではなく、HTTP動詞を発行します。このリンクを設置すると、「Add to cart」リンクは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)
...
一応動いています!ログをたどると、/cart_items
パスにPOSTしたことがわかります。次に、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.html", changeset: ShoppingCart.change_cart(conn.assigns.cart))
end
end
新しいカートコントローラーを定義して、get "/cart"
ルートを処理するようにしました。カートを表示するために、あとで作成する "show.html"
テンプレートをレンダリングします。数量の更新によってカートのアイテムを変更できるようにする必要があるので、カートのチェンジセットが必要であることはすぐにわかります。幸いなことに、コンテキストジェネレーターには ShoppingChart.change_cart/1
関数が含まれているので、これを使用します。ルーターで定義した fetch_current_cart
プラグのおかげで、コネクションのアサインにすでに含まれているカート構造体を渡します。
次に、ビューとテンプレートを実装します。新しいビューファイルを lib/hello_web/views/cart_view.ex
に以下の内容で作成します。
defmodule HelloWeb.CartView do
use HelloWeb, :view
alias Hello.ShoppingCart
def currency_to_str(%Decimal{} = val), do: "$#{Decimal.round(val, 2)}"
end
show.html
テンプレートをレンダリングするビューを作成し、ShoppingCart
コンテキストをエイリアスして、テンプレートのスコープに入るようにしました。ビューでは商品の価格やカートの合計など、カートの金額を表示する必要があります。そこでDecimalの構造体を受け取り、表示のために適切に丸めて、米ドルのドル記号を前に付ける currency_to_str/1
を定義しました。
次に、テンプレートを lib/hello_web/templates/cart/show.html.heex
に作成します。
<h1>My Cart</h1>
<%= if @cart.items == [] do %>
Your cart is empty
<% else %>
<%= form_for @changeset, Routes.cart_path(@conn, :update), fn f -> %>
<ul>
<%= for item_form <- inputs_for(f, :items), item = item_form.data do %>
<li>
<%= hidden_inputs_for(item_form) %>
<%= item.product.title %>
<%= number_input item_form, :quantity %>
<%= currency_to_str(ShoppingCart.total_item_price(item)) %>
</li>
<% end %>
</ul>
<%= submit "update cart" %>
<% end %>
<b>Total</b>: <%= currency_to_str(ShoppingCart.total_cart_price(@cart)) %>
<% end %>
まず、プリロードされた cart.items
が空であれば、空のカートというメッセージを表示します。アイテムがある場合には、[form_for
] を使用して、CartController.show/2
アクションで割り当てたカートのチェンジセットを取得し、カートコントローラーの update/2
アクションに対応するフォームを作成します。フォームの中では、Phoenix.HTML.Form.inputs_for
を使用して、入れ子になったカートアイテムの入力をレンダリングします。各アイテムのフォームの入力に対しては、hidden_inputs_for/1
を使用して、アイテムのIDを隠れたinputタグとしてレンダリングします。これにより、フォームが送信されたときに、アイテムの入力を元に戻すことができるようになります。次に、カートに入っている商品の商品タイトルを表示し、続いて商品の数量を数字で入力します。最後に商品価格を文字列に変換してアイテムフォームを完成させます。ここではまだ ShoppingCart.total_item_price/1
関数を記述していませんが、ここでもコンテキストのための明確で説明的なパブリックインターフェイスの考え方を採用しています。すべてのカートアイテムの入力をレンダリングした後、"カートを更新"の送信ボタンを表示し、カート全体の合計金額を表示しています。これはもう1つの新しい関数 ShoppingCart.total_cart_price/1
で実現されていますが、これはすぐに実装します。
カートページを試してみる準備はほとんどできていますが、まずは通貨の計算関数を実装する必要があります。ショッピングカートのコンテキストを 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
私たちは %CartItem{}
構造体を受け取る total_item_price/1
を実装しました。総額を計算するには、プリロードされた商品の価格を取得し、それに商品の数量を乗じるだけです。Decimal.mult/2
を使用して、Decimalの通貨構造体を取得し、適切な精度で乗算しています。同様に、カートの合計価格を計算するために、total_cart_price/1
関数を実装しました。この関数は、カートを受け取り、カート内のアイテムのプリロードされた商品価格を合計します。ここでも、Decimal
関数を使用して、Decimalの構造体を加算します。
これで価格の合計が計算できるようになりましたので、実際に試してみましょう。http://localhost:4000/cart
にアクセスすると、すでにカートに最初の商品が入っているはずです。同じ商品に戻って「カートに入れる」をクリックすると、アップサートが実行されます。数量が2になっているはずです。うまく動いています!
カートページはほぼ完成していますが、フォームを送信するとまた別のエラーが発生します。
Request: POST /cart
** (exit) an exception was raised:
** (UndefinedFunctionError) function HelloWeb.CartController.update/2 is undefined or private
それでは、lib/hello_web/controllers/cart_controller.ex
にある CartController
に戻って、updateアクションを実装してみましょう。
def update(conn, %{"cart" => cart_params}) do
case ShoppingCart.update_cart(conn.assigns.cart, cart_params) do
{:ok, cart} ->
redirect(conn, to: Routes.cart_path(conn, :show))
{:error, _changeset} ->
conn
|> put_flash(:error, "There was an error updating your cart")
|> redirect(to: Routes.cart_path(conn, :show))
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
チェンジセットにキャストしています。カートフォームのテンプレートにある hidden_inputs_for/1
呼び出しを覚えていますか?この隠されたIDデータは、Ectoの cast_assoc
がアイテムデータをカート内の既存のアイテムの関連付けにマッピングすることを可能にします。次に、見たことがないかもしれませんが、Ecto.Multi.new/0
を使います。Ectoの Multi
は、データベースのレポトランザクション内で最終的に実行される名前付きのオペレーションのチェーンをレイジーに定義することができる機能です。マルチチェーンの各操作は、前のステップから値を受け取り、ステップが失敗するまで実行されます。操作が失敗すると、トランザクションはロールバックされてエラーが返され、そうでなければトランザクションはコミットされます。
今回のマルチオペレーションでは、まず、:cart
と名付けたカートの更新を発行します。カートの更新が発行された後、マルチの delete_all
オペレーションを実行します。これは更新されたカートを受け取り、数量がゼロの場合のロジックを適用します。カートの中の数量がゼロのアイテムは、数量が空のカートアイテムをすべて見つけるectoクエリを返すことで、すべて消去します。マルチで Repo.transaction/1
を呼び出すと、新しいトランザクションで操作が実行され、元の関数と同様に成功または失敗の結果を呼び出し元に返します。
それでは、ブラウザに戻って実際に試してみましょう。いくつかの商品をカートに入れ、数量を更新すると、価格の計算に伴って値が変化するのを確認できます。数量を0にすると、その商品は削除されます。なかなかいい感じですね。