Chapter 13

コンテキスト(2/2)

koga1020
koga1020
2021.11.27に更新

コンテキスト

zennの仕様上、1ページでの文字数制限に達するため、contextドキュメントを分割しています。前半はこちら

Ordersコンテキストを追加する

CatalogShoppingCart のコンテキストでは、よく考えられたモジュールと関数名が、明快で保守性の高いコードを生み出していることを実際に確認できます。最後の仕事は、ユーザーがチェックアウトプロセスを開始できるようにすることです。ここでは、決済処理や注文処理の統合まではしませんが、その方向に向けてスタートします。前述のように、注文を完了するためのコードをどこに置くかを決めなければなりません。それはカタログの一部でしょうか?そうではなく、ショッピングカートはどうでしょうか?ショッピングカートは注文に関連しています。結局、ユーザーは商品を購入するためにアイテムを追加する必要がありますが、注文のチェックアウトプロセスはここにまとめるべきでしょうか?

注文プロセスをよく考えてみると、注文には関連性がありますが、カートの内容とは明確に異なるデータが含まれていることがわかります。また、チェックアウトプロセスに関するビジネスルールは、カートの場合とは大きく異なります。たとえば、ユーザーがバックオーダーした商品をカートに入れることはできても、在庫のない注文を完了させることはできません。さらに、注文が完了した時点で、決済トランザクション時点での商品の価格などの商品情報を取得する必要があります。商品の価格は将来的に変更される可能性がありますが、注文の品目には常に購入時の請求額が記録され、表示される必要があるからです。このような理由から、注文は独自のデータ関係とビジネスルールを持つ、合理的な独立したものであることがわかります。

名前の上では、Orders がコンテキストの範囲を明確に定義しています。コンテキストジェネレーターを利用して始めましょう。コンソールで次のコマンドを実行します。

$ mix phx.gen.html Orders Order orders user_uuid:uuid total_price:decimal

* creating lib/hello_web/controllers/order_controller.ex
* creating lib/hello_web/templates/order/edit.html.heex
* creating lib/hello_web/templates/order/form.html.heex
* creating lib/hello_web/templates/order/index.html.heex
* creating lib/hello_web/templates/order/new.html.heex
* creating lib/hello_web/templates/order/show.html.heex
* creating lib/hello_web/views/order_view.ex
* creating test/hello_web/controllers/order_controller_test.exs
* 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

Add the resource to your browser scope in lib/hello_web/router.ex:

    resources "/orders", OrderController


Remember to update your repository by running migrations:

    $ mix ecto.migrate

Orders コンテキストを生成し、HTMLコントローラーやビューなどを追加しました。の現在のユーザーを注文に関連付けるために、user_uuid フィールドと、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

以前に行ったように、decimal列に適切な精度とスケールのオプションを与え、精度を落とさずに通貨を格納できるようにしました。また、すべての注文が価格を持つことを強制するために、not-null制約を追加しました。

注文テーブルだけではあまり情報がありませんが、注文に含まれるすべてのアイテムの商品価格情報を特定時点で保存する必要があることがわかっています。そのために、このコンテキストに 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 スキーマを生成し、サポートする関数を注文コンテキストに注入しました。前回同様、priv/repo/migrations/*_create_order_line_items.exs のマイグレーションを修正し、次のようにdecimalのフィールドを変更してみましょう。

  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 で注文とラインアイテムの関連付けを行いましょう。

  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 を使って注文とラインアイテムを関連付けました。次に、has_many:through 機能を使いました。これは、別のリレーションシップにまたがってリソースを関連付ける方法をectoに指示できます。この場合、関連するラインアイテムを通してすべての商品を見つけることで、注文の商品を関連付けることができます。次に、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 を使って、ラインアイテムを注文や商品に関連付けました。関連付けが完了したら、ウェブインターフェイスを注文プロセスに統合する作業を始めましょう。ルーター lib/hello_web/router.ex を開き、以下の行を追加します。

  scope "/", HelloWeb do
    pipe_through :browser

    ...
+   resources "/orders", OrderController, only: [:create, :show]
  end

ここでは、生成した OrderControllercreateshow のルートを設定しました。今回必要なアクションはこれだけです。ルートが完成したので、次はマイグレーションを行います。

$ 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 のordersコンテキストを開き、get_order!/1 関数を新しい get_order!/2 定義で置き換えます。

  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に一致する注文をレポに問い合わせるように、この関数を書き換えました。そして、ラインアイテムと商品の関連付けをプリロードするように入力しました。

注文を完了させるには、カートページから OrderController.create アクションにPOSTを発行しますが、実際に注文を完了させるための操作やロジックを実装する必要があります。先ほどと同様に、Webインターフェイスから始めて、lib/hello_web/controllers/order_controller.ex のcreate関数を書き換えてみましょう。

  def create(conn, _) do
    case Orders.complete_order(conn.assigns.cart) do
      {:ok, order} ->
        conn
        |> put_flash(:info, "Order created successfully.")
        |> redirect(to: Routes.order_path(conn, :show, order))

      {:error, _reason} ->
        conn
        |> put_flash(:error, "There was an error processing your order")
        |> redirect(to: Routes.cart_path(conn, :show))
    end
  end

まだ実装されていない Orders.complete_order/1 関数を呼び出すように create アクションを書き換えました。phoenixが生成したコードでは、一般的な Orders.create_order/1 を呼び出していました。私たちのコードは技術的には注文を「作成」していますが、一歩下がってインターフェイスのネーミングを検討することが重要です。私たちのシステムでは、注文を「完了」させることが非常に重要です。トランザクションでお金がやり取りされたり、物理的な商品が自動的に出荷されたりします。このような操作には、complete_order のような、より良い、よりわかりやすい関数名がふさわしいです。注文が正常に完了した場合は詳細ページにリダイレクトし、そうでない場合はフラッシュエラーを表示してカートページに戻ります。

それでは、Orders.complete_order/1 関数を実装してみましょう。注文を完了させるためには、いくつかの操作が必要になります。

  1. 注文の合計金額を記載した新規注文レコードを作成します。
  2. カートに入っているすべての商品は、数量とその時点での商品価格の情報を含む新しい注文品目レコードへ変換します
  3. 注文の挿入(および最終的な支払い)が成功した後、アイテムをカートから削除する必要があります

今回の要件だけでも、汎用の create_order 関数ではダメだということがわかります。それでは、この新しい関数を lib/hello/orders.ex に実装してみましょう。

  alias Hello.ShoppingCart
  alias Hello.Orders.LineItem

  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{} を、LineItem構造体のマップにマッピングしました。注文品目レコードの仕事は、支払い処理時の商品の価格をキャプチャすることなので、ここで商品の価格を参照します。次に、Ecto.Changeset.change/2 で素のOrderチェンジセットを作成し、ユーザーUUIDを関連付け、合計金額の計算を設定し、注文品目をチェンジセット内に配置します。新しい注文のチェンジセットを挿入する準備ができたので、再び Ecto.Multi を利用して、データベーストランザクション内で操作を実行できます。まず、注文の挿入を行い、続いて run オペレーションを実行します。Ecto.Multi.run/3 関数では、{:ok, result} で成功するか、エラーになってトランザクションを停止してロールバックする必要のある、関数内の任意のコードを実行できます。ここでは単純に、ショッピングカートのコンテキストを呼び出して、カート内のすべてのアイテムを削除するように依頼できます。トランザクションを実行すると、マルチを実行し、結果を呼び出し元に返します。

注文を完了するためには、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 を発行します。成功した場合は、アイテムが削除されたカートを呼び出し元にリロードするだけです。コンテキストが完成したので、次はユーザーに完成した注文を表示する必要があります。注文コントローラーに戻って、show/2 アクションを修正します。

  def show(conn, %{"id" => id}) do
-   order = Orders.get_order!(id)
+   order = Orders.get_order!(conn.assigns.current_uuid, id)
    render(conn, "show.html", order: order)
  end

showアクションを微調整して、conn.assigns.current_uuidget_order! に渡すことで、注文の所有者のみが注文を閲覧できるようにしました。次に、lib/hello_web/templates/order/show.html.heex にある注文の詳細テンプレートを置き換えます。

<h1>Thank you for your order!</h1>

<ul>
  <li>
    <strong>User uuid:</strong>
    <%= @order.user_uuid %>
  </li>

  <%= for item <- @order.line_items do %>
    <li>
      <%= item.product.title %>
      (<%= item.quantity %>) - <%= HelloWeb.CartView.currency_to_str(item.price) %>
    </li>
  <% end %>

  <li>
    <strong>Total price:</strong>
    <%= HelloWeb.CartView.currency_to_str(@order.total_price) %>
  </li>

</ul>

<span><%= link "Back", to: Routes.cart_path(@conn, :show) %></span>

注文が完了したことを示すために、注文のユーザーを表示し、続いて、商品名、数量、注文完了時に「取引された」価格、および合計金額を含む注文品目のリストを表示しました。

最後に、カートページに「注文完了」ボタンを追加して、注文を完了できるようにします。以下のボタンを lib/hello_web/templates/cart/show.html.heex にあるカートの詳細テンプレートの下部に追加します。

  <b>Total</b>: <%= currency_to_str(ShoppingCart.total_cart_price(@cart)) %>

+ <%= link "complete order", to: Routes.order_path(@conn, :create), method: :post %>
<% end %>

OrderController.create アクションにPOSTリクエストを送信するために、method: :post のリンクを追加しました。http://localhost:4000/cart のカートページに戻って注文を完了すると、レンダリングされたテンプレートが表示されます。

Thank you for your order!

User uuid: 08964c7c-908c-4a55-bcd3-9811ad8b0b9d
Metaprogramming Elixir (2) - $15.00
Total price: $30.00

お疲れ様でした!まだ支払い機能は追加していませんが、ShoppingCartOrders のコンテキストを分割することで、メンテナンス性の高いソリューションを目指していることがよくわかります。カートのアイテムが注文のラインアイテムから分離されたことで、将来的には支払いトランザクションやカート価格の検出などを追加するための準備が整いました。

素晴らしいですね!

FAQ

コンテキストAPIからEcto構造体を返す

コンテキストAPIを調べていくうちに、疑問に思ったことがあるかもしれません。

コンテキストの目的の1つがEcto Repoへのアクセスをカプセル化することであるならば、ユーザーの作成に失敗したときに create_user/1Ecto.Changeset 構造体を返すのはなぜでしょうか?

その答えは、アプリケーションの中で %Ecto.Changeset{} をパブリックな データ構造 として公開することにしたからです。チェンジセットを使うことで、フィールドの変更を追跡したり、検証を行ったり、エラーメッセージを生成したりすることができることは以前に説明しました。ここでの使用は、プライベートなRepoへのアクセスやEctoのチェンジセットAPIの内部から切り離されています。私たちは、フィールドエラーのような豊富な情報を含む、呼び出し側が理解できるデータ構造を公開しています。便利なことに、phoenix_ecto プロジェクトは、必要な Phoenix.ParamPhoenix.HTML.FormData プロトコルを実装しており、フォーム生成やエラーメッセージなどのために %Ecto.Changeset{} をどのように処理するかを知っています。また、同じ目的のために独自の %Accounts.Changes{} 構造体を定義し、Web層の統合のためにPhoenixプロトコルを実装したように考えることもできます。