查看源代码 上下文

要求:本指南假设您已阅读过入门指南并已启动并运行 Phoenix 应用程序 启动并运行

要求:本指南假设您已阅读过请求生命周期指南

要求:本指南假设您已阅读过Ecto 指南

到目前为止,我们已经构建了页面,通过路由器将控制器操作连接起来,并了解了 Ecto 如何验证和持久化数据。现在是时候将所有这些整合在一起,编写与我们的更大 Elixir 应用程序交互的面向 Web 的功能。

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

与其他一切一样,Phoenix 中的上下文是模块,但它们具有不同的职责,即划定边界和分组功能。换句话说,它们使我们能够推断和讨论应用程序设计。

思考上下文

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

通过将暴露和分组相关功能的模块命名为 上下文,我们帮助开发人员识别这些模式并进行讨论。最终,上下文只是模块,就像您的控制器、视图等一样。

在 Phoenix 中,上下文通常封装数据访问和数据验证。它们通常与数据库或 API 交互。总的来说,将它们视为应用程序各部分解耦和隔离的边界。让我们利用这些想法来构建我们的 Web 应用程序。我们的目标是构建一个电子商务系统,我们可以在其中展示产品,允许用户将产品添加到他们的购物车,并完成他们的订单。

如何阅读本指南:使用上下文生成器是初学者和中级 Elixir 程序员快速入门和编写应用程序时的绝佳方式。本指南侧重于这些读者。

添加目录上下文

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

Phoenix 包含 mix phx.gen.htmlmix phx.gen.jsonmix phx.gen.livemix phx.gen.context 生成器,这些生成器将应用程序中隔离功能的想法应用到上下文。这些生成器是快速入门的绝佳方式,同时 Phoenix 会引导您朝着正确方向发展您的应用程序。让我们将这些工具用于我们的新产品目录上下文。

为了运行上下文生成器,我们需要想出一个模块名称,将我们正在构建的相关功能分组在一起。在Ecto 指南中,我们看到了如何使用 Changesets 和 Repos 来验证和持久化用户模式,但我们没有将其与我们的应用程序整合在一起。事实上,我们根本没有考虑在应用程序中“用户”应该放在哪里。让我们退一步,思考一下我们系统的不同部分。我们知道我们将有产品在页面上出售,以及描述、定价等。除了销售产品,我们还知道我们需要支持购物车、订单结账等等。虽然购买的产品与购物车和结账流程相关,但展示产品和管理产品的展示与跟踪用户将其放入购物车的商品或订单是如何下达的明显不同。一个Catalog 上下文是我们管理产品详细信息和展示我们出售产品的自然场所。

命名很困难

如果您在尝试想出一个上下文名称时卡住了,而系统中分组的功能尚不清楚,您可以简单地使用您正在创建的资源的复数形式。例如,一个用于管理产品的Products 上下文。随着您应用程序的增长以及系统各部分变得清晰,您可以简单地将上下文重命名为更精炼的名称。

为了快速启动我们的目录上下文,我们将使用 mix phx.gen.html,它创建一个上下文模块,将 Ecto 访问封装起来,用于创建、更新和删除产品,以及用于 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/index.html.heex
* creating lib/hello_web/controllers/product_html/new.html.heex
* creating lib/hello_web/controllers/product_html/show.html.heex
* creating lib/hello_web/controllers/product_html/product_form.html.heex
* creating lib/hello_web/controllers/product_html.ex
* creating test/hello_web/controllers/product_controller_test.exs
* creating lib/hello/catalog/product.ex
* creating priv/repo/migrations/20210201185747_create_products.exs
* creating lib/hello/catalog.ex
* injecting lib/hello/catalog.ex
* creating test/hello/catalog_test.exs
* injecting test/hello/catalog_test.exs
* creating test/support/fixtures/catalog_fixtures.ex
* injecting test/support/fixtures/catalog_fixtures.ex

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

    resources "/products", ProductController


Remember to update your repository by running migrations:

    $ mix ecto.migrate

Phoenix 按预期在 lib/hello_web/ 中生成了 Web 文件。我们还可以看到我们的上下文文件在 lib/hello/catalog.ex 文件中生成,我们的产品模式在同名目录中生成。请注意 lib/hellolib/hello_web 之间的区别。我们有一个 Catalog 模块充当产品目录功能的公共 API,还有一个 Catalog.Product 结构体,这是一个用于强制转换和验证产品数据的 Ecto 模式。Phoenix 还为我们提供了 Web 和上下文测试,它还包括用于通过 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,以及非空约束。这确保了我们以适当的精度存储货币,以便进行任何可能执行的数学运算。接下来,我们在 views 计数中添加了默认值和非空约束。在进行了更改后,我们准备将数据库迁移到最新版本。现在让我们执行操作

$ 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。让我们按照“新产品”链接,然后单击“保存”按钮,而不提供任何输入。我们应该看到以下输出

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

如果我们按照“返回”链接操作,我们将看到所有产品的列表,其中应该包含我们刚刚创建的产品。同样,我们可以更新此记录或将其删除。现在我们已经看到了它在浏览器中的工作方式,是时候查看生成的代码了。

从生成器开始

这个小小的 mix phx.gen.html 命令包含了令人惊讶的功能。我们开箱即用地获得了大量功能,用于在我们的目录中创建、更新和删除产品。这远远不是一个功能齐全的应用程序,但请记住,生成器首先是学习工具,也是您开始构建真实功能的起点。代码生成无法解决所有问题,但它将教会您 Phoenix 的来龙去脉,并引导您在设计应用程序时朝着正确的思维方式前进。

首先让我们检查一下 ProductController,它是在 lib/hello_web/controllers/product_controller.ex 中生成的

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 获取产品列表,以及产品是如何使用 Catalog.create_product/1create 操作中持久化的。我们还没有查看目录上下文,因此我们还不知道产品获取和创建在后台是如何发生的——但这就是重点。我们的 Phoenix 控制器是更大应用程序的 Web 接口。它不应该关心产品是如何从数据库中获取或持久化到存储中的。我们只关心告诉我们的应用程序为我们执行一些工作。这很好,因为我们的业务逻辑和存储细节与应用程序的 Web 层解耦。如果我们稍后将存储引擎改为全文存储引擎以获取产品而不是使用 SQL 查询,我们的控制器不需要更改。同样,我们可以从应用程序中的任何其他接口(无论是通道、mix 任务还是长时间运行的进程导入 CSV 数据)重复使用我们的上下文代码。

在我们的 create 操作的情况下,当我们成功创建了一个产品时,我们使用 Phoenix.Controller.put_flash/3 来显示成功消息,然后我们重定向到路由器的产品展示页面。相反,如果 Catalog.create_product/1 失败,我们会渲染我们的 "new.html" 模板,并将 Ecto changeset 传递给模板,以便模板从其中提取错误消息。

接下来,让我们深入研究一下我们的 Catalog 上下文,它位于 lib/hello/catalog.ex

defmodule Hello.Catalog do
  @moduledoc """
  The Catalog context.
  """

  import Ecto.Query, warn: false
  alias Hello.Repo

  alias Hello.Catalog.Product

  @doc """
  Returns the list of products.

  ## Examples

      iex> list_products()
      [%Product{}, ...]

  """
  def list_products do
    Repo.all(Product)
  end
  ...
end

本模块将作为系统中所有产品目录功能的公共 API。例如,除了产品详情管理之外,我们还可以处理产品类别分类和产品变体,例如可选尺寸、装饰等。如果我们看一下 list_products/0 函数,我们可以看到产品获取的私有细节。它非常简单。我们调用了 Repo.all(Product)。我们在 Ecto 指南 中了解了 Ecto repo 查询的工作原理,因此这个调用应该看起来很熟悉。我们的 list_products 函数是一个泛化的函数名,指定了代码的 *意图* —— 主要是列出产品。我们使用 Repo 从 PostgreSQL 数据库中获取产品的意图细节对调用者隐藏。这是我们将在使用 Phoenix 生成器时反复看到的一个常见主题。Phoenix 将推动我们思考应用程序中不同职责的位置,然后将这些不同区域包装在命名良好的模块和函数后面,这些模块和函数使代码的意图清晰,同时封装了细节。

现在我们知道数据是如何获取的,但产品是如何持久化的呢?让我们看看 Catalog.create_product/1 函数

  @doc """
  Creates a product.

  ## Examples

      iex> create_product(%{field: value})
      {:ok, %Product{}}

      iex> create_product(%{field: bad_value})
      {:error, %Ecto.Changeset{}}

  """
  def create_product(attrs \\ %{}) do
    %Product{}
    |> Product.changeset(attrs)
    |> Repo.insert()
  end

这里有比代码更多的文档,但有两件事很重要。首先,我们再次看到,Ecto Repo 在后台用于数据库访问。您可能还注意到了对 Product.changeset/2 的调用。我们之前讨论过 changeset,现在我们在上下文中看到它们在起作用。

如果我们在 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 的一部分。构建 changeset 的调用者通过上下文 API 进行操作。例如,Catalog.create_product/1 调用我们的 Product.changeset/2 从用户输入构建 changeset。调用者(如我们的控制器操作)不直接访问 Product.changeset/2。所有与产品 changeset 的交互都通过公共 Catalog 上下文完成。

添加 Catalog 函数

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

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

直觉上,你会假设以下事件

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

而在实践中,会发生这种情况

  1. 用户 1 加载产品页面,计数为 13
  2. 用户 2 加载产品页面,计数为 13
  3. 用户 1 保存产品页面,计数为 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,让我们来探索如何在上下文中处理关系。

上下文中的关系

我们的基本目录功能很好,但让我们更上一层楼,对产品进行分类。许多电子商务解决方案允许对产品进行不同的分类,例如,产品可以被标记为时尚、电动工具等。从产品和类别之间的一对一关系开始,如果我们以后需要支持多个类别,会导致重大的代码更改。让我们设置一个类别关联,它将允许我们从跟踪每个产品一个类别开始,但随着功能的增长,可以轻松地支持更多类别。

现在,类别将只包含文本信息。我们首先需要确定类别在应用程序中的位置。我们有 Catalog 上下文,它管理着我们产品的展示。产品分类在这里很自然。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 中有了一个新的 Category 模式,以及我们的产品模式,Phoenix 告诉我们它在我们的现有 Catalog 上下文中为类别功能 *注入* 了新函数。注入的函数将与我们的产品函数非常相似,具有新的函数,如 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 模式和一个用于关联产品和类别的连接表,我们几乎可以开始连接我们的新功能。在我们深入研究之前,我们首先需要在 Web UI 中选择真实的类别。让我们在应用程序中快速播种一些新类别。将以下代码添加到您在 priv/repo/seeds.exs 中的种子文件中

for title <- ["Home Improvement", "Power Tools", "Gardening", "Books", "Education"] do
  {:ok, _} = Hello.Catalog.create_category(%{title: title})
end

我们简单地遍历一个类别标题列表,并使用我们生成的 create_category/1 函数将新的记录持久化到 Catalog 上下文中。我们可以使用 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.Schemamany_to_many 宏让 Ecto 知道如何通过 "product_categories" 连接表将我们的产品与多个类别关联起来。我们还使用了 on_replace: :delete 选项来声明,当我们更改类别时,任何现有的连接记录都应该被删除。

在设置完架构关联后,我们可以在产品表单中实现类别选择。为此,我们需要将前端的目录 ID 用户输入转换为我们的多对多关联。幸运的是,Ecto 使这变得轻而易举,现在我们的架构已经设置好了。打开你的目录上下文并进行以下更改

+ alias Hello.Catalog.Category

- def get_product!(id), do: Repo.get!(Product, id)
+ def get_product!(id) do
+   Product |> Repo.get!(id) |> Repo.preload(:categories)
+ end

  def create_product(attrs \\ %{}) do
    %Product{}
-   |> Product.changeset(attrs)
+   |> change_product(attrs)
    |> Repo.insert()
  end

  def update_product(%Product{} = product, attrs) do
    product
-   |> Product.changeset(attrs)
+   |> change_product(attrs)
    |> Repo.update()
  end

  def change_product(%Product{} = product, attrs \\ %{}) do
-   Product.changeset(product, attrs)
+   categories = list_categories_by_id(attrs["category_ids"])

+   product
+   |> Repo.preload(:categories)
+   |> Product.changeset(attrs)
+   |> Ecto.Changeset.put_assoc(:categories, categories)
  end

+ def list_categories_by_id(nil), do: []
+ def list_categories_by_id(category_ids) do
+   Repo.all(from c in Category, where: c.id in ^category_ids)
+ end

首先,我们添加了 Repo.preload 来在获取产品时预加载我们的类别。这将允许我们在控制器、模板以及任何其他我们想要使用类别信息的地方引用 product.categories。接下来,我们修改了我们的 create_productupdate_product 函数,以调用我们现有的 change_product 函数来生成一个 changeset。在 change_product 中,我们添加了一个查找,如果存在 "category_ids" 属性,则查找所有类别。然后,我们预加载类别并调用 Ecto.Changeset.put_assoc 将获取到的类别放入 changeset 中。最后,我们实现了 list_categories_by_id/1 函数来查询与类别 ID 匹配的类别,或者如果不存在 "category_ids" 属性,则返回一个空列表。现在,我们的 create_productupdate_product 函数在尝试对我们的 repo 进行插入或更新时,会收到一个包含类别关联的 changeset,这些关联已经准备就绪。

接下来,让我们通过将类别输入添加到产品表单中来将新功能公开到网络。为了保持表单模板整洁,让我们编写一个新函数来包装渲染产品类别选择输入的详细信息。打开你的 ProductHTML 视图,位于 lib/hello_web/controllers/product_html.ex 中,并键入以下内容

  def category_opts(changeset) do
    existing_ids =
      changeset
      |> Ecto.Changeset.get_change(:categories, [])
      |> Enum.map(& &1.data.id)

    for cat <- Hello.Catalog.list_categories(),
        do: [key: cat.title, value: cat.id, selected: cat.id in existing_ids]
  end

我们添加了一个新的 category_opts/1 函数,该函数为我们将很快添加的多选标签生成选择选项。我们从 changeset 中计算了现有的类别 ID,然后在生成输入标签的选择选项时使用了这些值。我们通过枚举所有类别并返回适当的 keyvalueselected 值来实现这一点。如果在 changeset 中的这些类别 ID 中找到了类别 ID,则我们将选项标记为选中。

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

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

+ <.input field={f[:category_ids]} type="select" multiple={true} options={category_opts(@changeset)} />

  <:actions>
    <.button>Save Product</.button>
  </:actions>

我们在保存按钮上方添加了一个 category_select。现在让我们试一试。接下来,让我们在产品展示模板中显示产品的类别。将以下代码添加到 lib/hello_web/controllers/product_html/show.html.heex 中的列表中

<.list>
  ...
+ <:item title="Categories">
+   <%= for cat <- @product.categories do %>
+     <%= cat.title %>
+     <br/>
+   <% end %>
+ </:item>
</.list>

现在,如果我们使用 mix phx.server 启动服务器并访问 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 架构,将用户与其包含购物车项目的购物车绑定在一起。我们还没有真正的用户,所以现在我们的购物车将由一个匿名用户 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 策略来强制执行数据完整性。这样,当从应用程序中删除购物车或产品时,我们就不必依赖 ShoppingCartCatalog 上下文中的应用程序代码来担心清理记录。这使我们的应用程序代码保持解耦,并将数据完整性强制执行放置在它应该在的地方——数据库中。我们还添加了一个唯一约束,以确保不允许将重复产品添加到购物车。与 product_categories 表一样,使用多列索引可以让我们删除最左侧字段 (cart_id) 的单独索引。有了数据库表,我们现在可以进行迁移

$ mix ecto.migrate

16:59:51.941 [info] == Running 20210205203342 Hello.Repo.Migrations.CreateCarts.change/0 forward

16:59:51.945 [info] create table carts

16:59:51.949 [info] create index carts_user_uuid_index

16:59:51.952 [info] == Migrated 20210205203342 in 0.0s

16:59:51.988 [info] == Running 20210205213410 Hello.Repo.Migrations.CreateCartItems.change/0 forward

16:59:51.988 [info] create table cart_items

16:59:51.998 [info] create index cart_items_cart_id_index

16:59:52.000 [info] create index cart_items_product_id_index

16:59:52.001 [info] create index cart_items_cart_id_product_id_index

16:59:52.002 [info] == Migrated 20210205213410 in 0.0s

我们的数据库已经准备就绪,其中包含新的 cartscart_items 表,但现在我们需要将其映射回应用程序代码。你可能想知道如何混合跨不同表的数据库外键,以及它与隔离的、分组的功能的上下文模式的关系。让我们直接进入并讨论这些方法及其权衡。

跨上下文数据

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

我们的 Catalog.Product 资源用于保持表示目录中产品的职责,但最终要使项目存在于购物车中,目录中必须存在一个产品。鉴于此,我们的 ShoppingCart 上下文将依赖于 Catalog 上下文。考虑到这一点,我们有两个选择。一种是在 Catalog 上下文中公开 API,允许我们有效地获取产品数据以供 ShoppingCart 系统使用,我们可以手动将它们拼凑起来。或者,我们可以使用数据库联接来获取依赖数据。这两种方法都是有效的,取决于你的权衡和应用程序的大小,但当你存在硬性数据依赖时,从数据库联接数据对于一大类应用程序来说是可以的,我们将在本文中使用这种方法。

现在我们已经知道数据依赖项在哪里存在,让我们添加架构关联,以便我们可以将购物车项目绑定到产品。首先,让我们快速修改 lib/hello/shopping_cart/cart.ex 中的购物车架构,将购物车关联到其项目

  schema "carts" do
    field :user_uuid, Ecto.UUID

+   has_many :items, Hello.ShoppingCart.CartItem

    timestamps()
  end

现在我们的购物车已经与我们放入其中的项目相关联,让我们在 lib/hello/shopping_cart/cart_item.ex 中设置购物车项目关联

  schema "cart_items" do
    field :price_when_carted, :decimal
    field :quantity, :integer
-   field :cart_id, :id
-   field :product_id, :id

+   belongs_to :cart, Hello.ShoppingCart.Cart
+   belongs_to :product, Hello.Catalog.Product

    timestamps()
  end

  @doc false
  def changeset(cart_item, attrs) do
    cart_item
    |> cast(attrs, [:price_when_carted, :quantity])
    |> validate_required([:price_when_carted, :quantity])
+   |> validate_number(:quantity, greater_than_or_equal_to: 0, less_than: 100)
  end

首先,我们用一个指向我们的 ShoppingCart.Cart 架构的标准 belongs_to 替换了 cart_id 字段。接下来,我们通过添加第一个跨上下文数据依赖项(Catalog.Product 架构的 belongs_to)替换了我们的 product_id 字段。在这里,我们有意耦合数据边界,因为它提供了我们需要的:具有最小必要知识的隔离上下文 API,用于引用系统中的产品。接下来,我们在 changeset 中添加了一个新的验证。有了 validate_number/3,我们确保用户输入提供的任何数量都在 0 到 100 之间。

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

添加购物车功能

如前所述,上下文生成器只是应用程序的起点。我们可以而且应该编写名称良好、专为特定目的而设计的函数来完成上下文的目标。我们有几个新功能需要实现。首先,我们需要确保应用程序的每个用户都被授予一个购物车,如果还没有的话。从那里,我们就可以允许用户将物品添加到他们的购物车、更新物品数量和计算购物车总额。让我们开始吧!

我们现在不会专注于真正的用户身份验证系统,但当我们完成时,你将能够将一个完整的用户身份验证系统与我们在这里编写的代码自然地集成在一起。为了模拟当前用户会话,打开你的 lib/hello_web/router.ex 并键入以下内容

  pipeline :browser do
    plug :accepts, ["html"]
    plug :fetch_session
    plug :fetch_live_flash
    plug :put_root_layout, html: {HelloWeb.LayoutView, :root}
    plug :protect_from_forgery
    plug :put_secure_browser_headers
+   plug :fetch_current_user
+   plug :fetch_current_cart
  end

+ defp fetch_current_user(conn, _) do
+   if user_uuid = get_session(conn, :current_uuid) do
+     assign(conn, :current_uuid, user_uuid)
+   else
+     new_uuid = Ecto.UUID.generate()
+
+     conn
+     |> assign(:current_uuid, new_uuid)
+     |> put_session(:current_uuid, new_uuid)
+   end
+ end

+ alias Hello.ShoppingCart
+
+ defp fetch_current_cart(conn, _opts) do
+   if cart = ShoppingCart.get_cart_by_user_uuid(conn.assigns.current_uuid) do
+     assign(conn, :cart, cart)
+   else
+     {:ok, new_cart} = ShoppingCart.create_cart(conn.assigns.current_uuid)
+     assign(conn, :cart, new_cart)
+   end
+ end

我们在浏览器管道中添加了一个新的 :fetch_current_user:fetch_current_cart 插件,以便在所有基于浏览器的请求上运行。接下来,我们实现了 fetch_current_user 插件,它只是检查会话中是否存在之前添加的用户 UUID。如果我们找到了,我们将向连接添加一个 current_uuid 分配,然后我们就完成了。如果我们还没有识别出此访问者,我们将使用 Ecto.UUID.generate() 生成一个唯一的 UUID,然后我们将该值放置在 current_uuid 分配中,以及一个新的会话值,以便在未来的请求中识别此访问者。随机的、唯一的 ID 对于表示用户来说并不足以,但它足以让我们跟踪和识别跨请求的访问者,这正是我们现在需要的。随着应用程序的日益完善,你将准备好迁移到一个完整的用户身份验证解决方案。有了保证的当前用户,我们然后实现了 fetch_current_cart 插件,它要么找到用户 UUID 的购物车,要么为当前用户创建一个购物车,并在连接分配中分配结果。我们需要实现我们的 ShoppingCart.get_cart_by_user_uuid/1 并修改创建购物车函数以接受 UUID,但让我们先添加我们的路由。

我们需要实现一个购物车控制器,用于处理查看购物车、更新数量和启动结账流程等购物车操作,以及一个购物车商品控制器,用于将单个商品添加或移除到购物车中。在lib/hello_web/router.ex的路由器中添加以下路由。

  scope "/", HelloWeb do
    pipe_through :browser

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

+   resources "/cart_items", CartItemController, only: [:create, :delete]

+   get "/cart", CartController, :show
+   put "/cart", CartController, :update
  end

我们添加了一个resources声明,用于一个CartItemController,它将为添加和移除单个购物车商品的创建和删除操作连接路由。接下来,我们添加了两个指向CartController的新路由。第一个路由是一个 GET 请求,将映射到我们的 show 操作,用于显示购物车内容。第二个路由是一个 PUT 请求,将处理用于更新购物车数量的表单提交。

路由就位后,让我们从产品展示页面添加商品到购物车的功能。在lib/hello_web/controllers/cart_item_controller.ex创建一个新文件,并输入以下内容。

defmodule HelloWeb.CartItemController do
  use HelloWeb, :controller

  alias Hello.ShoppingCart

  def create(conn, %{"product_id" => product_id}) do
    case ShoppingCart.add_item_to_cart(conn.assigns.cart, product_id) do
      {:ok, _item} ->
        conn
        |> put_flash(:info, "Item added to your cart")
        |> redirect(to: ~p"/cart")

      {:error, _changeset} ->
        conn
        |> put_flash(:error, "There was an error adding the item to your cart")
        |> redirect(to: ~p"/cart")
    end
  end

  def delete(conn, %{"id" => product_id}) do
    {:ok, _cart} = ShoppingCart.remove_item_from_cart(conn.assigns.cart, product_id)
    redirect(conn, to: ~p"/cart")
  end
end

我们定义了一个新的CartItemController,其中包含我们在路由器中声明的创建和删除操作。对于create,我们调用一个ShoppingCart.add_item_to_cart/2函数,我们将在稍后实现它。如果成功,我们将显示一个闪现的成功消息,并重定向到购物车展示页面;否则,我们将显示一个闪现的错误消息,并重定向到购物车展示页面。对于delete,我们将调用一个remove_item_from_cart函数,我们将在我们的ShoppingCart上下文中实现它,然后重定向回购物车展示页面。我们还没有实现这两个购物车函数,但请注意,它们的名字清楚地表明了它们的意图:add_item_to_cartremove_item_from_cart使我们清楚地知道我们在这里完成了什么。它还允许我们指定我们的 Web 层和上下文 API,而无需考虑所有实现细节。

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

+  alias Hello.Catalog
-  alias Hello.ShoppingCart.Cart
+  alias Hello.ShoppingCart.{Cart, CartItem}

+  def get_cart_by_user_uuid(user_uuid) do
+    Repo.one(
+      from(c in Cart,
+        where: c.user_uuid == ^user_uuid,
+        left_join: i in assoc(c, :items),
+        left_join: p in assoc(i, :product),
+        order_by: [asc: i.inserted_at],
+        preload: [items: {i, product: p}]
+      )
+    )
+  end

- def create_cart(attrs \\ %{}) do
-   %Cart{}
-   |> Cart.changeset(attrs)
+ def create_cart(user_uuid) do
+   %Cart{user_uuid: user_uuid}
+   |> Cart.changeset(%{})
    |> Repo.insert()
+   |> case do
+     {:ok, cart} -> {:ok, reload_cart(cart)}
+     {:error, changeset} -> {:error, changeset}
+   end
  end

+  defp reload_cart(%Cart{} = cart), do: get_cart_by_user_uuid(cart.user_uuid)
+
+  def add_item_to_cart(%Cart{} = cart, product_id) do
+    product = Catalog.get_product!(product_id)
+
+    %CartItem{quantity: 1, price_when_carted: product.price}
+    |> CartItem.changeset(%{})
+    |> Ecto.Changeset.put_assoc(:cart, cart)
+    |> Ecto.Changeset.put_assoc(:product, product)
+    |> Repo.insert(
+      on_conflict: [inc: [quantity: 1]],
+      conflict_target: [:cart_id, :product_id]
+    )
+  end
+
+  def remove_item_from_cart(%Cart{} = cart, product_id) do
+    {1, _} =
+      Repo.delete_all(
+        from(i in CartItem,
+          where: i.cart_id == ^cart.id,
+          where: i.product_id == ^product_id
+        )
+      )
+
+    {:ok, reload_cart(cart)}
+  end

我们首先实现get_cart_by_user_uuid/1,它获取我们的购物车并连接购物车商品及其产品,以便我们拥有完整的购物车,其中包含所有预加载数据。接下来,我们修改了我们的create_cart函数,使其接受用户 UUID 而不是属性,我们用它来填充user_uuid字段。如果插入成功,我们将通过调用一个私有的reload_cart/1函数重新加载购物车内容,该函数只需调用get_cart_by_user_uuid/1来重新获取数据。

接下来,我们编写了新的add_item_to_cart/2函数,它接受一个购物车结构和一个产品 ID。我们继续使用Catalog.get_product!/1获取产品,展示了上下文如何在需要时自然地调用其他上下文。你也可以选择将产品作为参数接收,你会获得类似的结果。然后,我们对我们的仓库执行了一个 upsert 操作,要么将新的购物车商品插入数据库,要么如果它已经存在于购物车中,则将数量增加一。这是通过on_conflictconflict_target选项实现的,它告诉我们的仓库如何处理插入冲突。

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

有了新的购物车函数,我们现在可以将产品目录展示页面上的 "添加到购物车" 按钮公开。打开lib/hello_web/controllers/product_html/show.html.heex中的模板,并进行以下更改。

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

来自Phoenix.Componentlink函数组件接受一个: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)
       ...

它正在工作!有点。如果我们跟踪日志,我们会看到我们对/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, changeset: ShoppingCart.change_cart(conn.assigns.cart))
  end
end

我们定义了一个新的购物车控制器来处理get "/cart"路由。为了显示购物车,我们渲染了一个"show.html"模板,我们将在稍后创建它。我们知道我们需要允许通过数量更新更改购物车商品,因此我们立刻知道我们需要一个购物车变更集。幸运的是,上下文生成器包含一个ShoppingCart.change_cart/1函数,我们将使用它。我们将它传递给我们的购物车结构,该结构已经在连接分配中,这要归功于我们在路由器中定义的fetch_current_cart插件。

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

defmodule HelloWeb.CartHTML do
  use HelloWeb, :html

  alias Hello.ShoppingCart

  embed_templates "cart_html/*"

  def currency_to_str(%Decimal{} = val), do: "$#{Decimal.round(val, 2)}"
end

我们创建了一个视图来渲染我们的show.html模板,并对我们的ShoppingCart上下文进行了别名,以便它在我们的模板范围内。我们需要显示购物车价格,例如商品价格、购物车总计等,因此我们定义了一个currency_to_str/1,它接收我们的十进制结构,以适当的方式进行舍入以供显示,并在前面加上一个美元符号。

接下来,我们可以在lib/hello_web/controllers/cart_html/show.html.heex创建模板。

<%= if @cart.items == [] do %>
  <.header>
    My Cart
    <:subtitle>Your cart is empty</:subtitle>
  </.header>
<% else %>
  <.header>
    My Cart
  </.header>

  <.simple_form :let={f} for={@changeset} action={~p"/cart"}>
    <.inputs_for :let={item_form} field={f[:items]}>
	<% item = item_form.data %>
      <.input field={item_form[:quantity]} type="number" label={item.product.title} />
      <%= currency_to_str(ShoppingCart.total_item_price(item)) %>
    </.inputs_for>
    <:actions>
      <.button>Update cart</.button>
    </:actions>
  </.simple_form>

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

<.back navigate={~p"/products"}>Back to products</.back>

我们首先在我们的预加载cart.items为空时显示空购物车消息。如果我们有商品,我们将使用我们的HelloWeb.CoreComponents提供的simple_form组件,使用我们在CartController.show/2操作中分配的购物车变更集,创建一个映射到我们的购物车控制器update/2操作的表单。在表单中,我们使用inputs_for组件来渲染嵌套购物车商品的输入。这将允许我们在提交表单时将商品输入映射在一起。接下来,我们显示商品数量的数字输入,并使用商品标题进行标记。我们使用商品价格的字符串形式完成商品表单。我们还没有编写ShoppingCart.total_item_price/1函数,但我们再次运用了为我们的上下文提供清晰、描述性的公共接口的想法。在渲染所有购物车商品的输入后,我们显示一个 "更新购物车" 提交按钮,以及整个购物车的总价格。这是通过另一个新的ShoppingCart.total_cart_price/1函数实现的,我们将在稍后实现它。最后,我们添加了一个back组件来返回到我们的产品页面。

我们几乎可以尝试我们的购物车页面了,但首先我们需要实现新的货币计算函数。打开lib/hello/shopping_cart.ex中的购物车上下文,并添加以下新函数。

  def total_item_price(%CartItem{} = item) do
    Decimal.mult(item.product.price, item.quantity)
  end

  def total_cart_price(%Cart{} = cart) do
    Enum.reduce(cart.items, 0, fn item, acc ->
      item
      |> total_item_price()
      |> Decimal.add(acc)
    end)
  end

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

现在我们能够计算价格总计了,让我们尝试一下!访问http://localhost:4000/cart,你应该已经看到你的第一个商品在购物车中。返回到同一个商品,并点击 "添加到购物车",将显示我们的 upsert 操作。你的数量现在应该是 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,并实现更新操作。

  def update(conn, %{"cart" => cart_params}) do
    case ShoppingCart.update_cart(conn.assigns.cart, cart_params) do
      {:ok, _cart} ->
        redirect(conn, to: ~p"/cart")

      {:error, _changeset} ->
        conn
        |> put_flash(:error, "There was an error updating your cart")
        |> redirect(to: ~p"/cart")
    end
  end

我们首先从表单提交中提取购物车参数。接下来,我们调用现有的ShoppingCart.update_cart/2函数,该函数是由上下文生成器添加的。我们需要对这个函数进行一些更改,但接口很好。如果更新成功,我们将重定向回购物车页面,否则我们将显示一个闪现的错误消息,并将用户发送回购物车页面以更正任何错误。开箱即用,我们的ShoppingCart.update_cart/2函数只关心将购物车参数转换为变更集并将其更新到我们的仓库。为了我们的目的,我们现在需要它处理嵌套的购物车商品关联,最重要的是,处理数量更新的业务逻辑,例如将零数量商品从购物车中移除。

返回到lib/hello/shopping_cart.ex中的购物车上下文,并将你的update_cart/2函数替换为以下实现。

  def update_cart(%Cart{} = cart, attrs) do
    changeset =
      cart
      |> Cart.changeset(attrs)
      |> Ecto.Changeset.cast_assoc(:items, with: &CartItem.changeset/2)

    Ecto.Multi.new()
    |> Ecto.Multi.update(:cart, changeset)
    |> Ecto.Multi.delete_all(:discarded_items, fn %{cart: cart} ->
      from(i in CartItem, where: i.cart_id == ^cart.id and i.quantity == 0)
    end)
    |> Repo.transaction()
    |> case do
      {:ok, %{cart: cart}} -> {:ok, cart}
      {:error, :cart, changeset, _changes_so_far} -> {:error, changeset}
    end
  end

我们以与开箱即用代码类似的方式开始——我们接收购物车结构并将用户输入转换为购物车变更集,除了这次我们使用Ecto.Changeset.cast_assoc/3将嵌套的商品数据转换为CartItem变更集。还记得我们购物车表单模板中的<.inputs_for />调用吗?隐藏的 ID 数据使 Ecto 的cast_assoc能够将商品数据映射回购物车中现有的商品关联。接下来,我们使用Ecto.Multi.new/0,你可能以前没有见过。Ecto 的Multi是一个功能,它允许在数据库事务中延迟定义一系列命名操作以最终执行。多链中的每个操作都接收来自先前步骤的值,并执行,直到遇到失败的步骤。当操作失败时,事务将回滚并返回错误,否则事务将提交。

对于我们的多操作,我们首先发出我们购物车的更新,我们将其命名为:cart。在购物车更新发出后,我们执行一个多delete_all操作,它接收更新后的购物车并应用我们的零数量逻辑。我们通过返回一个 ecto 查询来修剪购物车中任何数量为零的商品,该查询查找所有具有空数量的此购物车的购物车商品。使用我们的多项式调用Repo.transaction/1将在新的事务中执行这些操作,我们将返回成功或失败的结果给调用者,就像原始函数一样。

让我们返回浏览器并尝试一下。在你的购物车中添加一些商品,更新数量,并观察值以及价格计算的变化。将任何数量设置为 0 也会移除商品。非常棒!

添加订单上下文

通过我们的 CatalogShoppingCart 上下文,我们亲眼目睹了精心设计的模块和函数名如何生成清晰且易于维护的代码。我们最后要处理的任务是允许用户启动结账流程。我们不会深入集成支付处理或订单履行,但会让你朝着那个方向迈出第一步。像以前一样,我们需要决定完成订单的代码应该放在哪里。它是目录的一部分吗?显然不是,但购物车呢?购物车与订单相关 - 毕竟,用户必须添加商品才能购买任何产品 - 但结账流程应该在这里分组吗?

如果我们停下来考虑订单流程,我们会发现订单涉及与购物车内容相关但明显不同的数据。此外,结账流程的业务规则与购物车规则截然不同。例如,我们可能允许用户将预订商品添加到购物车,但我们不允许完成没有库存的订单。此外,我们需要在订单完成后捕获时间点产品信息,例如商品的 付款交易时的价格。这很重要,因为产品价格将来可能会发生变化,但我们订单中的行项目必须始终记录和显示我们在购买时收取的费用。出于这些原因,我们可以开始看到订购可以独立存在,拥有自己的数据问题和业务规则。

在命名方面,Orders 清楚地定义了我们上下文的范围,所以让我们再次利用上下文生成器开始。在你的控制台中运行以下命令

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

* creating lib/hello/orders/order.ex
* creating priv/repo/migrations/20210209214612_create_orders.exs
* creating lib/hello/orders.ex
* injecting lib/hello/orders.ex
* creating test/hello/orders_test.exs
* injecting test/hello/orders_test.exs
* creating test/support/fixtures/orders_fixtures.ex
* injecting test/support/fixtures/orders_fixtures.ex

Remember to update your repository by running migrations:

    $ mix ecto.migrate

我们生成了一个 Orders 上下文。我们添加了一个 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 架构,并将支持函数注入到我们的订单上下文中。像以前一样,让我们修改 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 连接了 createshow 路由,因为这些是我们目前需要的唯一操作。有了我们的路由,我们现在可以迁移了

$ 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 并查询我们的存储库,以查找与用户 ID 匹配的特定订单 ID 的订单。然后,我们通过预加载行项目和产品关联来填充订单。

为了完成订单,我们的购物车页面可以向 OrderController.create 操作发出 POST 请求,但我们需要实现操作和逻辑以真正完成订单。像以前一样,我们将从 Web 界面开始。在 lib/hello_web/controllers/order_controller.ex 中创建一个新文件,并输入以下内容

defmodule HelloWeb.OrderController do
  use HelloWeb, :controller

  alias Hello.Orders

  def create(conn, _) do
    case Orders.complete_order(conn.assigns.cart) do
      {:ok, order} ->
        conn
        |> put_flash(:info, "Order created successfully.")
        |> redirect(to: ~p"/orders/#{order}")

      {:error, _reason} ->
        conn
        |> put_flash(:error, "There was an error processing your order")
        |> redirect(to: ~p"/cart")
    end
  end
end

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

这也是一个强调上下文可以自然地使用其他上下文定义的数据的好机会。这在使用整个应用程序中使用的数据时尤其常见,例如这里的购物车(但也可能是当前用户或当前项目,等等,具体取决于你的项目)。

现在我们可以实现我们的 Orders.complete_order/1 函数。为了完成订单,我们的工作将需要几个操作

  1. 必须使用订单的总价持久化一个新的订单记录
  2. 购物车中的所有商品必须转换为具有数量和时间点产品价格信息的新订单行项目记录
  3. 订单成功插入(以及最终的付款)后,必须从购物车中修剪商品

仅从我们的需求中,我们就可以开始了解为什么通用 create_order 函数不够用。让我们在 lib/hello/orders.ex 中实现这个新函数

  alias Hello.Orders.LineItem
  alias Hello.ShoppingCart

  def complete_order(%ShoppingCart.Cart{} = cart) do
    line_items =
      Enum.map(cart.items, fn item ->
        %{product_id: item.product_id, price: item.product.price, quantity: item.quantity}
      end)

    order =
      Ecto.Changeset.change(%Order{},
        user_uuid: cart.user_uuid,
        total_price: ShoppingCart.total_cart_price(cart),
        line_items: line_items
      )

    Ecto.Multi.new()
    |> Ecto.Multi.insert(:order, order)
    |> Ecto.Multi.run(:prune_cart, fn _repo, _changes ->
      ShoppingCart.prune_cart_items(cart)
    end)
    |> Repo.transaction()
    |> case do
      {:ok, %{order: order}} -> {:ok, order}
      {:error, name, value, _changes_so_far} -> {:error, {name, value}}
    end
  end

我们首先将我们购物车的 %ShoppingCart.CartItem{} 映射到订单行项目结构的映射。订单行项目记录的作用是捕获 付款交易时 产品的价格,因此我们在这里引用产品的价格。接下来,我们使用 Ecto.Changeset.change/2 创建一个空的订单 changeset 并关联我们的用户 UUID、设置我们的总价计算并将我们的订单行项目放在 changeset 中。有了可以插入的新鲜订单 changeset,我们再次可以使用 Ecto.Multi 在数据库事务中执行我们的操作。我们首先插入订单,然后是一个 run 操作。 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!(conn.assigns.current_uuid, id)
    render(conn, :show, order: order)
  end

我们添加了 show 操作以将我们的 conn.assigns.current_uuid 传递给 get_order!,该操作授权仅由订单所有者查看订单。接下来,我们可以实现视图和模板。在 lib/hello_web/controllers/order_html.ex 中创建一个新的视图文件,内容如下

defmodule HelloWeb.OrderHTML do
  use HelloWeb, :html

  embed_templates "order_html/*"
end

接下来,我们可以在 lib/hello_web/controllers/order_html/show.html.heex 中创建模板

<.header>
  Thank you for your order!
  <:subtitle>
     <strong>User uuid: </strong><%= @order.user_uuid %>
  </:subtitle>
</.header>

<.table id="items" rows={@order.line_items}>
  <:col :let={item} label="Title"><%= item.product.title %></:col>
  <:col :let={item} label="Quantity"><%= item.quantity %></:col>
  <:col :let={item} label="Price">
    <%= HelloWeb.CartHTML.currency_to_str(item.price) %>
  </:col>
</.table>

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

<.back navigate={~p"/products"}>Back to products</.back>

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

我们的最后一个补充是将“完成订单”按钮添加到我们的购物车页面,以允许完成订单。将以下按钮添加到 lib/hello_web/controllers/cart_html/show.html.heex 中的购物车显示模板的 <.header> 中

  <.header>
    My Cart
+    <:actions>
+      <.link href={~p"/orders"} method="post">
+        <.button>Complete order</.button>
+      </.link>
+    </:actions>
  </.header>

我们添加了一个使用 method="post" 的链接,以向我们的 OrderController.create 操作发送 POST 请求。如果我们回到 http://localhost:4000/cart 中的购物车页面并完成订单,我们会看到我们渲染的模板

Thank you for your order!

User uuid: 08964c7c-908c-4a55-bcd3-9811ad8b0b9d
Title                   Quantity Price
Metaprogramming Elixir  2        $15.00

Total price: $30.00

干得好!我们还没有添加支付,但我们已经看到我们的 ShoppingCartOrders 上下文分离是如何将我们引导到一个可维护的解决方案的。通过将购物车商品与订单行项目分开,我们有能力在将来添加支付交易、购物车价格检测等。

干得好!

常见问题解答

何时使用代码生成器?

在本指南中,我们使用了代码生成器来生成模式、上下文、控制器等等。如果你乐意继续使用 Phoenix 默认值,可以自由地依靠生成器来构建应用程序的很大一部分。在使用 Phoenix 生成器时,你需要回答的主要问题是:这个新功能(及其模式、表和字段)属于现有上下文中的一个还是一个新的上下文?

通过这种方式,Phoenix 生成器引导你使用上下文来对相关功能进行分组,而不是让几十个模式在没有任何结构的情况下四处散布。记住:如果你在想出一个上下文名称时遇到困难,你可以简单地使用你要创建的资源的复数形式。

如何在上下文中组织代码?

你可能想知道如何在上下文中组织代码。例如,是否应该为 changeset(例如 ProductChangesets)定义一个模块,为查询(例如 ProductQueries)定义另一个模块?

上下文的一个重要好处是,这个决定并不重要。上下文是你的公共 API,其他模块是私有的。上下文将这些模块隔离到一个小组中,因此你应用程序的表面积是上下文,而不是所有代码

因此,虽然你和你的团队可以建立组织这些私有模块的模式,但我们也认为它们完全不同是可以的。主要重点应该是如何定义上下文以及它们如何相互交互(以及与你的 Web 应用程序的交互)。

把它想象成一个维护良好的社区。你的上下文是房子,你想要保持它们的良好保存、良好连接等等。在房子里面,它们可能都有点不同,这没关系。

从上下文 API 返回 Ecto 结构

在探索 Context API 时,您可能会有疑问

如果我们的 Context 目标之一是封装 Ecto Repo 访问,为什么当我们无法创建用户时,create_user/1 会返回一个 Ecto.Changeset 结构体?

虽然 Changesets 是 Ecto 的一部分,但它们并没有绑定到数据库,它们可以用于从任何源映射数据,这使得它成为一个通用的有用数据结构,用于跟踪字段更改、执行验证和生成错误消息。

出于这些原因,%Ecto.Changeset{} 是一个很好的选择,可以用来模拟您的 Context 和 Web 层之间的数据更改 - 无论您是与 API 还是数据库通信。

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