Elixir Phoenix 指南 - Contexts - 翻译

410 阅读28分钟

到目前为止,我们已经构建了几个页面,通过我们的 router 连接了 controller 的 action,并了解了 Ecto 如何验证和持久化数据。现在,是时候通过编写与更大的 Elixir 应用程序交互的 Web 功能,来把这些结合起来,。

在构建 Phoenix 项目时,我们首先构建的是一个 Elixir 应用程序。Phoenix 的工作是为我们的 Elixir 应用提供一个 web 接口。自然地,我们使用模块和函数来组成我们的应用程序,但我们经常为某些模块分配特定的职责,并给它们命名:比如控制器、路由器和实时视图。

跟其他类似,Phoenix中的上下文是模块,但具有明确的职责来划定边界和分组功能。换句话说,它们允许我们推理和讨论应用程序设计。

思考

Contexts 是专门用于暴露和分组相关联功能的模块。 例如,当你调用 Elixir 的标准库时,无论是 Logger.info/1 还是 Stream.map/2,您都在访问不同的上下文。 实际上,Elixir 的 logger 由多个模块组成,但我们从不直接与这些模块交互。 我们之所以将 Logger 模块称为上下文,正是因为它暴露并分组了所有 logging 功能。

通过将暴露和分组相关功能的模块称为 contexts,我们可以帮助开发人员识别这些模式并进行讨论。归根结底,上下文就像控制器、视图等一样,都是模块。

在 Phoenix 框架中,上下文通常封装数据访问和数据验证逻辑。它们经常与数据库或 API 进行交互。总而言之,可以将它们视为解耦和隔离应用程序部分的边界。

让我们利用这些概念来构建我们的 Web 应用程序。我们的目标是构建一个电子商务系统,用户可以在其中展示产品、将商品添加到购物车并完成订单。

增加一个目录上下文

电子商务平台在其代码库中存在广泛的耦合,因此编写定义明确的模块非常重要。考虑到这一点,我们的目标是构建一个产品目录 API,用于处理系统中可用产品的创建、更新和删除。我们将从展示我们的产品的基本功能开始,稍后添加购物车功能。我们将看到,从一个坚实的、具有隔离边界的基础开始,可以让我们在添加功能时自然地扩展我们的应用程序。

Phoenix 提供了几个生成器,可以帮助我们将应用程序的功能封装隔离到不同的上下文中,这些生成器分别是:

  • mix phx.gen.html:用于生成 HTML 资源的控制器和上下文模块。
  • mix phx.gen.json:用于生成 JSON 资源的控制器和上下文模块。
  • mix phx.gen.live:用于生成 LiveView 组件、模板和上下文模块。
  • mix phx.gen.context:用于生成一个基本的上下文模块。

这些生成器是一个快速上手的好方法,Phoenix 会引导你朝着正确的方向发展你的应用程序。让我们利用这些工具来构建我们的新产品目录上下文。

为了运行上下文生成器,我们需要想出一个模块名称来对我们正在构建的相关功能进行分组。在 Ecto 指南中,我们看到了如何使用 Changesets 和 Repos 来验证和持久化用户 schema,但我们没有将其与我们的应用程序集成在一起。事实上,我们根本没有考虑我们的应用程序中的“用户”应该放在哪里。

让我们退一步考虑系统中的不同部分。我们知道,我们将有产品在页面上出售,并附有描述、定价等。除了销售产品,我们知道我们还需要支持购物车、订单结账等。虽然所购买的产品与购物车和结账流程相关,但展示产品和管理产品的展示与跟踪用户放在购物车中的内容或订单的放置方式明显不同。一个 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/controllers/product_html/edit.html.heex
* creating lib/hello_web/controllers/product_html/product_form.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.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/ 中按预期生成了 Web 文件。 我们还可以看到我们的上下文文件位于 lib/hello/catalog.ex ,我们的产品 schema 在同名目录中。 注意 lib/hellolib/hello_web 之间的区别。 我们有一个 Catalog 模块作为产品目录功能的公共 API,还有一个 Catalog.Product 结构,它是一个用于转换和验证产品数据的 Ecto schema。 Phoenix 还为我们提供了 web 和上下文测试,它还包括用于通过 Hello.Catalog 上下文创建实体的测试 helper,我们将在稍后介绍。 现在,让我们按照说明并根据控制台说明添加路由,在 lib/hello_web/router.ex 中:

  scope "/", HelloWeb do
    pipe_through :browser

    get "/", PageController, :index
+   resources "/products", ProductController
  end

有了新路由,Phoenix 提醒我们通过运行 mix ecto.migrate 更新我们的 repo,但首先我们需要对 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 启动服务器并访问 http://localhost:4000/products。 让我们点击“New Product”链接并单击“Save”按钮而不提供任何输入。 我们应该看到以下输出:

Oops, something went wrong! Please check the errors below.

当我们提交表单时,我们可以看到所有与输入内联的验证错误。 好的! 开箱即用,上下文生成器在我们的表单模板中包含了 schema 字段,我们可以看到我们对所需输入的默认验证是有效的。 让我们输入一些示例产品数据并重新提交表单:

Product created successfully.

Title: Metaprogramming Elixir
Description: Write Less Code, Get More Done (and Have Fun!)
Price: 15.000000
Views: 0

如果我们点击“后退”链接,我们会得到所有产品的列表,其中应该包含我们刚刚创建的产品。 同样,我们可以更新或删除这条记录。 现在我们已经了解了它在浏览器中是如何工作的,是时候看看生成的代码了。

从生成器开始

那个小小的 mix phx.gen.html 命令包含了令人惊讶的功能。 我们获得了许多开箱即用的功能,用于我们的 catalog 中创建、更新和删除产品。 这远不是一个功能完整的应用程序,但请记住,生成器首先是学习工具,也是您开始构建真实功能的起点。 代码生成不能解决你所有的问题,但它会教你 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 来持久化。 我们还没有查看 Catalog 上下文,所以我们还不知道产品获取和创建是如何在幕后发生的——但这就是重点。 我们的 Phoenix 控制器是我们更大应用程序的 Web 界面。 它不应该关心产品如何从数据库中获取或存储到数据库中的细节。 我们只关心告诉我们的应用程序为我们执行一些工作。 这很棒,因为我们的业务逻辑和存储细节与应用程序的 Web 层分离。 如果我们稍后转移到全文存储引擎来获取产品而不是 SQL 查询,则不需要更改我们的控制器。 同样,我们可以在应用程序的任何其他接口中重用我们的上下文代码,无论是 channel 、mix 任务还是导入 CSV 数据的长时间运行的程序。(这里是分层的解释)

在我们的 create 操作中,当我们成功创建产品时,我们使用 Phoenix.Controller.put_flash/3 显示成功消息,然后我们重定向到路由的产品显示页面。 相反,如果 Catalog.create_product/1 失败,我们将呈现我们的“new.html”模板并传递模板的 Ecto changeset 以从中提取错误消息。

接下来,让我们深入挖掘并查看 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 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 schema,它看起来会很熟悉:

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。 与我们的产品变更集的所有交互都是通过公共目录上下文完成的。

添加目录上下文函数

正如我们所见,您的上下文模块是公开和分组相关功能的专用模块。 Phoenix 生成通用函数,例如 list_productsupdate_product,但它们仅作为您扩展业务逻辑和应用程序的基础。 让我们通过跟踪产品页面浏览量来添加目录的一项基本功能。

对于任何电子商务系统,跟踪产品页面被浏览次数的能力对于营销、建议、排名等都是必不可少的。虽然我们可以尝试使用现有的 Catalog.update_product 函数,但 Catalog.update_product( product, %{views: product.views + 1}), 这不仅容易出现竞争条件,而且还要求调用者对我们的 Catalog 系统了解太多。 要了解竞争条件存在的原因,让我们来看看可能执行的事件:

直觉上,您会假设以下事件:

  1. 用户 1 加载了计数为 13 的产品页面
  2. 用户 1 保存了 count 为 14 的产品页面
  3. 用户 2 加载了计数为 14 的产品页面
  4. 用户 2 保存了计数为 15 的产品页面

虽然在实践中会发生这种情况:

  1. 用户 1 加载了计数为 13 的产品页面
  2. 用户 2 加载了计数为 13 的产品页面
  3. 用户 1 保存了 count 为 14 的产品页面
  4. 用户 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 允许我们对数据库执行批量更新,并且非常适合原子更新值,例如增加我们的访问计数。 repo 操作的结果返回更新记录的数量,以及由 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 文件。 由于我们已经有了用于管理产品的控制器和模板,我们可以将新的类别功能集成到我们现有的 Web 表单和产品展示页面中。 我们可以看到我们现在在 lib/hello/catalog/category.ex 的产品 schema 旁边有一个新的 Category schema,Phoenix 告诉我们它正在为类别功能在我们现有的目录上下文中注入新功能。 注入的函数看起来与我们的产品函数非常相似,新函数如 create_categorylist_categories 等。 在我们向上迁移之前,我们需要进行第二次代码生成。 我们的类别架构非常适合表示系统中的单个类别,但我们需要支持产品和类别之间的多对多关系。 幸运的是,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 架构和一个用于关联产品和类别的连接表,我们几乎准备好开始连接我们的新功能。 在我们深入之前,我们首先需要在我们的网络用户界面中选择真正的类别。 让我们在应用程序中快速添加一些新类别。 将以下代码添加到 priv/repo/seeds.exs 中的种子文件中:

for title <- ["Home Improvement", "Power Tools", "Gardening", "Books"] 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]]

完美的。 在我们在 web 层集成类别之前,我们需要让我们的上下文知道如何关联产品和类别。 首先,打开 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 选项来声明在我们更改类别时应删除任何现有的连接记录。

通过我们的 schema 关联设置,我们可以在我们的产品表单中实现类别选择。 为此,我们需要将用户从前端输入的 catalog IDs 转换为我们的多对多关联。 幸运的是,既然我们的 schema 已经设置好了,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_productupdate_product 函数以调用我们现有的 change_product 函数来生成变更集。 在 change_product 中,如果存在“category_ids”属性,我们添加了一个查询以查找所有类别。 然后我们预加载类别并调用 Ecto.Changeset.put_assoc 将获取的类别放入变更集中。 最后,我们实现了 list_categories_by_id/1 函数来查询与类别 ID 匹配的类别,或者如果没有“category_ids”属性则返回一个空列表。 现在,我们的 create_productupdate_product 函数接收到一个变更集,其中包含类别关联,一旦我们尝试插入或更新我们的 repo 就可以使用了。

接下来,让我们通过将类别输入添加到我们的产品表单来向 Web 展示我们的新功能。 为了保持我们的表单模板整洁,让我们编写一个新函数来包装为我们的产品呈现类别选择输入的细节。 在 lib/hello_web/controllers/product_html.ex 中打开您的 ProductHTML 视图并将其键入:

defmodule HelloWeb.ProductHTML do
  use HelloWeb, :html

  import Phoenix.HTML.Form

  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.Formmultiple_select/3 来生成多选标签。 我们从变更集中计算出现有的类别 ID,然后在为输入标签生成选择选项时使用这些值。 我们通过枚举所有类别并返回适当的键、值和选定值来做到这一点。 如果在我们的变更集中的那些类别 ID 中找到类别 ID,我们将选项标记为已选中。

有了我们的 category_select 函数,我们可以打开 lib/hello_web/controllers/product_html/product_form.html.heex 并添加:

  ...
  <.input type="number" field={f[:views]} label="Views" />

+ <%= category_select f, @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 启动服务器并访问 http://localhost: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 上下文来处理基本的购物车职责。 在我们编写代码之前,让我们假设我们有以下功能需求:

  1. 从产品展示页面将产品添加到用户的购物车
  2. 在加入购物车时存储时间点产品价格信息
  3. 在购物车中存储和更新数量
  4. 计算并显示购物车价格总和

从描述中可以清楚地看出,我们需要一个 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 schema 将用户绑定到他们的购物车中,该购物车包含购物车商品。 我们还没有真正的用户,所以现在我们的购物车将由一个匿名用户 UUID 跟踪,我们稍后会将其添加到我们的 plug 会话中。 有了我们的购物车,让我们生成我们的购物车item:

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 的新资源。 该 schema 和表格将包含对购物车和产品的引用,以及我们将商品添加到购物车时的价格,以及用户希望购买的数量。 让我们修改 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 策略来强制数据完整性。 这样,当购物车或产品从应用程序中删除时,我们不必依赖 ShoppingCartCatalog 上下文中的应用程序代码来担心清理记录。 这使我们的应用程序代码保持解耦,并在其所属的位置(在数据库中)实施数据完整性。 我们还添加了一个唯一约束,以确保不允许将重复的产品添加到购物车。 有了我们的数据库表,我们现在可以执行迁移:

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

我们的数据库已准备好使用新的 cartscart_items 表,但现在我们需要将其映射回应用程序代码。 您可能想知道我们如何在不同的表中混合使用数据库外键,以及这与隔离、分组功能的上下文模式有何关系。 让我们开始讨论这些方法及其权衡。

跨上下文数据

到目前为止,我们已经很好地将应用程序的两个主要上下文相互隔离,但现在我们需要处理一个必要的依赖项。

我们的 Catalog.Product 资源用于在目录中表示产品,但最终要使商品存在于购物车中,目录中的产品必须存在。 鉴于此,我们的 ShoppingCart 上下文将对 Catalog 上下文具有数据依赖性。 考虑到这一点,我们有两个选择。 一种是在 Catalog 上下文中暴露 API,使我们能够高效地获取产品数据以用于 ShoppingCart 系统,我们将手动将这些数据拼接在一起。 或者我们可以使用数据库连接来获取相关数据。 考虑到您的权衡和应用程序大小,这两个都是有效的选项,但是当您具有硬数据依赖性时从数据库连接数据对于大型应用程序来说是很好的,这也是我们将在此处采用的方法。

现在我们知道我们的数据依赖关系存在于何处,让我们添加我们的schema关联,以便我们可以将购物车项目与产品联系起来。 首先,让我们快速更改 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

首先,我们用指向我们的 ShoppingCart.Cart schame的标准 belongs_to 替换了 cart_id 字段。 接下来,我们通过为 Catalog.Product 模式添加我们的第一个跨上下文数据依赖项来替换我们的 product_id 字段。 在这里,我们有意耦合数据边界,因为它提供了我们所需要的。 一个独立的上下文 API,具有在我们的系统中引用产品所需的最低限度的知识。 接下来,我们向变更集添加了一个新的验证。 使用 validate_number/3,我们确保用户输入提供的任何数量都在 0 到 100 之间。

有了我们的 schema,我们就可以开始将新的数据结构和 ShoppingCart 上下文 API 集成到我们面向 Web 的功能中。

Adding Shopping Cart functions

正如我们之前提到的,上下文生成器只是我们应用程序的起点。 我们可以而且应该编写命名良好的、专门构建的函数来实现我们上下文的目标。 我们有一些新功能要实现。 首先,我们需要确保我们的应用程序的每个用户都被授予购物车(如果购物车尚不存在)。 从那里,我们可以允许用户将商品添加到他们的购物车、更新商品数量并计算购物车总数。 让我们开始吧!

此时我们不会专注于真正的用户身份验证系统,但是当我们完成时,您将能够自然地将一个系统与我们在这里编写的内容集成。 要模拟当前用户会话,请打开您的 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

我们向 browser 管道添加了一个新的 :fetch_current_user:fetch_current_cart plug ,以在所有基于 browser 的请求上运行。 接下来,我们实现了 fetch_current_user plug ,它只检查先前添加的用户 UUID 的会话。 如果我们找到一个,我们添加一个 current_uuid 分配给连接,我们就完成了。 如果我们还没有识别出这个访问者,我们会用 Ecto.UUID.generate() 生成一个唯一的 UUID,然后我们将该值放在 current_uuid assign中,连同一个新的会话值以在未来的请求中识别这个访问者。 一个随机的、唯一的 ID 并不能代表一个用户,但足以让我们跨请求跟踪和识别访问者,这就是我们现在所需要的。 稍后随着我们的应用程序变得更加完整,您将准备好迁移到完整的用户身份验证解决方案。 在保证当前用户的情况下,我们随后实现了 fetch_current_cart plug,它可以为用户 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

我们为 CartItemController 添加了一个 resources 声明,它将连接用于添加和删除单个购物车项目的创建和删除操作的路由。 接下来,我们添加了两条指向 CartController 的新路由。 第一条路由,一个 GET 请求,将映射到我们的 show 动作,以显示购物车内容。 第二条路由是 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
    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。 对于创建,我们调用一个 ShoppingCart.add_item_to_cart/2 函数,我们稍后会实现它。 如果成功,我们会显示一条成功消息并重定向到购物车显示页面; 否则,我们会显示一条错误消息并重定向到购物车显示页面。 对于删除,我们将调用我们将在我们的 ShoppingCart 上下文中实现的 remove_item_from_cart 函数,然后重定向回购物车显示页面。 我们还没有实现这两个购物车函数,但请注意它们的名字是如何表达它们的意图的:add_item_to_cartremove_item_from_cart 使我们在这里完成的工作一目了然。 它还允许我们指定我们的 web 层和上下文 API,而无需立即考虑所有实现细节。

让我们在 lib/hello/shopping_cart.ex 中实现 ShoppingCart 上下文 API 的新接口:

  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(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 使用 upsert 操作将一个新的购物车项目插入数据库,或者如果购物车中已经存在,则将数量增加一个。 这是通过 on_conflictconflict_target 选项完成的,它们告诉我们的 repo 如何处理插入冲突。

最后,我们实现了 remove_item_from_cart/2 ,我们只需发出 Repo.delete_all 调用并查询删除购物车中与产品 ID 匹配的购物车商品。 最后,我们通过调用 reload_cart/1 重新加载购物车内容。

有了新的购物车功能,我们现在可以在产品目录显示页面上显示“添加到购物车”按钮。 在 lib/hello_web/controllers/product_html/show.html.heex 中打开您的模板并进行以下更改:

<h1>Show Product</h1>

+<.link href={~p"/cart_items?product_id=#{@product.id}"} method="post">Add to cart</.link>
...

Phoenix.Component 中的 link 函数组件接受一个 :method 属性以在单击时发出 HTTP 动词,而不是默认的 GET 请求。 有了这个链接,“添加到购物车”链接将发出一个 POST 请求,该请求将与我们在路由器中定义的路由匹配,该路由分派给 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。 在我们的错误之前,我们还看到了对购物车表的查询,这意味着我们正在获取当前用户的购物车。 到目前为止,一切都很好。 我们知道我们的 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” 模板,我们稍后将创建该模板。 我们知道我们需要允许通过数量更新来更改购物车项目,所以我们马上就知道我们需要一个购物车变更集。 幸运的是,上下文生成器包含我们将使用的 ShoppingChart.change_cart/1 函数。 由于我们在路由器中定义的 fetch_current_cart 插件,我们将我们的购物车结构传递给它,该结构已经在连接分配中。

接下来,我们可以实现视图和模板。 使用以下内容在 lib/hello_web/controllers/cart_html.ex 中创建一个新的视图文件:

defmodule HelloWeb.CartHTML do
  use HelloWeb, :html

  alias Hello.ShoppingCart

  import Phoenix.HTML.Form

  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 创建模板:

<h1>My Cart</h1>

<%= if @cart.items == [] do %>
  Your cart is empty
<% else %>
  <.form :let={f} for={@changeset} action={~p"/cart"}>
    <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" %>
  </.form>

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

如果我们预加载的 cart.items 是空的,我们首先显示空购物车消息。 如果我们有物品,我们使用 (Phoenix.Component) 提供的 [form] 组件来获取我们在 CartController.show/2 操作中分配的购物车变更集,并创建一个映射到我们的购物车控制器 update/2 操作的表单。 在表单中,我们使用 Phoenix.HTML.Form.inputs_for/2 来呈现嵌套购物车项目的输入。 对于每个项目表单输入,我们使用 hidden_inputs_for/1 将项目 ID 呈现为隐藏的输入标签。 这将允许我们在提交表单时将项目输入映射回一起。 接下来,我们显示购物车中商品的产品标题,然后是商品数量的输入数字。 我们通过将商品价格转换为字符串来完成商品表单。 我们还没有编写 ShoppingCart.total_item_price/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

我们实现了 total_item_price/1 它接受一个 %CartItem{} 结构。 要计算总价,我们只需将预加载产品的价格乘以商品数量即可。 我们使用 Decimal.mult/2 来获取我们的十进制货币结构并以适当的精度将其相乘。 类似地,为了计算总购物车价格,我们实现了一个 total_cart_price/1 函数,它接受购物车并对购物车中商品的预加载产品价格求和。 我们再次使用 Decimal 函数将十进制结构相加。

现在我们可以计算总价了,让我们试试吧! 访问 http://localhost:4000/cart ,您应该已经在购物车中看到了您的第一件商品。 返回到相同的产品并单击“添加到购物车”将显示我们的 upsert 正在运行。 你的数量现在应该是两个。 干得好!

我们的购物车页面几乎完成了,但是提交表单会产生另一个错误。

Request: POST /cart
** (exit) an exception was raised:
    ** (UndefinedFunctionError) function HelloWeb.CartController.update/2 is undefined or private

让我们回到位于 lib/hello_web/controllers/cart_controller.exCartController 并实施更新操作:

  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 函数只关心将购物车参数转换为变更集并根据我们的 repo 更新它。 出于我们的目的,我们现在需要它来处理嵌套的购物车项目关联,最重要的是,如何处理数量更新的业务逻辑,例如从购物车中删除零数量的项目。

返回到 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 查询来删除购物车中数量为零的所有商品,该查询查找该购物车的所有购物车商品数量为空。 用我们的 multi 调用 Repo.transaction/1 将执行新事务中的操作,我们将成功或失败结果返回给调用者,就像原始函数一样。

让我们回到浏览器并尝试一下。 将一些产品添加到您的购物车,更新数量,并观察价值随着价格计算而变化。 将任何数量设置为 0 也会删除该项目。 很简约!

添加订单上下文

通过我们的 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/controllers/order_html/edit.html.heex
* creating lib/hello_web/controllers/order_html/order_form.html.heex
* creating lib/hello_web/controllers/order_html/index.html.heex
* creating lib/hello_web/controllers/order_html/new.html.heex
* creating lib/hello_web/controllers/order_html/show.html.heex
* creating lib/hello_web/controllers/order_html.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

就像我们之前所做的那样,我们为小数列提供了适当的精度和比例选项,这将使我们能够在不损失精度的情况下存储货币。 我们还添加了一个非空约束来强制所有订单都有价格。

单独的订单表并没有包含太多信息,但我们知道我们需要存储订单中所有项目的时间点产品价格信息。 为此,我们将为此上下文添加一个名为 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 schema并将支持函数注入到我们的订单上下文中。 像以前一样,让我们修改 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 中连接我们的订单和订单项关联:

  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 将行项目关联到订单和产品。 有了我们的关联,我们就可以开始将 Web 界面集成到我们的订购流程中了。 打开您的路由器 lib/hello_web/router.ex 并添加以下行:

  scope "/", HelloWeb do
    pipe_through :browser

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

我们为生成的 OrderController 连接了创建和显示路由,因为这些是我们目前唯一需要的操作。 有了我们的路线,我们现在可以向上迁移:

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 中打开您的订单上下文,并用新的 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 并查询我们的 repo 以查找与给定订单 ID 的用户 ID 匹配的订单。 然后我们通过预加载我们的订单项和产品关联来填充订单。

要完成订单,我们的购物车页面可以向 OrderController.create 操作发出 POST,但我们需要实施操作和逻辑以实际完成订单。 和以前一样,我们将从 Web 界面开始,重写 lib/hello_web/controllers/order_controller.ex 中的创建函数:

  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

我们重写了创建操作以调用尚未实现的 Orders.complete_order/1 函数。 phoenix 生成的代码有一个通用的 Orders.create_order/1 调用。 我们的代码在技术上是在“创建”订单,但重要的是退后一步并考虑接口的命名。 完成订单的行为在我们的系统中极为重要。 金钱在交易中易手,实物商品可以自动发货等。这样的操作应该有一个更好、更明显的函数名称,例如 complete_order。 如果订单成功完成,我们将重定向到显示页面,否则在我们重定向回购物车页面时会显示一个 flash 错误。

这也是一个很好的机会来强调上下文也可以自然地与其他上下文定义的数据一起工作。 这对于在整个应用程序中使用的数据特别常见,例如这里的购物车(但它也可以是当前用户或当前项目,等等,具体取决于您的项目)。

现在我们可以实现我们的 Orders.complete_order/1 函数。 要完成订单,我们的工作将需要一些操作:

新的订单记录必须与订单的总价保持一致 购物车中的所有商品都必须转换为包含数量和时间点产品价格信息的新订单行项目记录 成功插入订单(并最终付款)后,必须从购物车中删除商品 仅从我们的需求来看,我们就可以开始明白为什么通用的 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{} 映射到订单行项目结构的映射中。 订单行项目记录的工作是在付款交易时捕获产品的价格,因此我们在这里引用产品的价格。 接下来,我们使用 Ecto.Changeset.change/2 创建一个裸订单变更集并关联我们的用户 UUID,设置我们的总价计算,并将我们的订单行项目放入变更集中。 准备好插入新的订单变更集后,我们可以再次使用 Ecto.Multi 在数据库事务中执行我们的操作。 我们首先插入订单,然后是运行操作。 Ecto.Multi.run/3 函数允许我们运行函数中的任何代码,这些代码必须成功并返回 {:ok, result} 或错误,这会停止并回滚事务。 在这里,我们可以简单地调用我们的购物车上下文并要求它修剪购物车中的所有商品。 运行事务将像以前一样执行 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 接受所提供购物车的所有项目的查询。 我们通过简单地将修剪后的购物车重新加载给调用者来返回成功结果。 上下文完成后,我们现在需要向用户显示他们完成的订单。 回到您的订单控制器并修改 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, order: order)
  end

我们调整了 show 动作以将我们的 conn.assigns.current_uuid 传递给 get_order! 授权订单只能由订单所有者查看。 接下来,我们可以替换 lib/hello_web/controllers/order_html/show.html.heex 中的订单展示模板:

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

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

  <li :for={item <- @order.line_items}>
    <%= item.product.title %>
    (<%= item.quantity %>) - <%= HelloWeb.CartHTML.currency_to_str(item.price) %>
  </li>

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

</ul>

<span><.link href={~p"/cart"}>Back</.link></span>

为了显示我们已完成的订单,我们显示了订单的用户,然后是包含产品名称、数量和我们在完成订单时“交易”的价格以及总价的行项目列表。

我们最后添加的是将“完成订单”按钮添加到我们的购物车页面以允许完成订单。 将以下按钮添加到 lib/hello_web/controllers/cart_html/show.html.heex 中购物车显示模板的底部:

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

+ <.link href={~p"/orders"} method="post">complete order</.link>
<% end %>

我们添加了一个带有 method="post" 的链接,以向我们的 OrderController.create 操作发送 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

干得好! 我们还没有添加支付,但我们已经可以看到我们的 ShoppingCart 和 Orders 上下文拆分如何推动我们走向可维护的解决方案。 将我们的购物车项目与我们的订单行项目分开,我们有能力在未来添加支付交易、购物车价格检测等。

做得好!

FAQ

如果我们上下文的目标之一是封装 Ecto Repo 访问,为什么 create_user/1 在我们创建用户失败时返回一个 Ecto.Changeset 结构?

虽然 Changeset 是 Ecto 的一部分,但它们不依赖于数据库,它们可用于将数据从任何源映射到任何源,这使其成为跟踪字段更改、执行验证和生成错误消息的通用且有用的数据结构 .

出于这些原因,%Ecto.Changeset{} 是对上下文和 Web 层之间的数据更改进行建模的不错选择。 无论您是在与 API 还是数据库对话。

最后,请注意您的控制器和视图也没有硬编码为专门与 Ecto 一起工作。 相反,Phoenix 定义了 Phoenix.Param 和 Phoenix.HTML.FormData 等协议,它们允许任何库扩展 Phoenix 生成 URL 参数或呈现表单的方式。 对我们来说方便的是,phoenix_ecto 项目实现了这些协议,但您也可以使用自己的数据结构并自己实现它们。

这章是很重要的一章,需要仔仔细细精读; 虽然整篇都是机翻,不过前半段有一句一句的改,后半段就基本就是一扫而过了,等后面开发的时候再回来改一下;