介绍
Phoenix 是一个用 Elixir 编写的 Web 开发框架,它实现了服务器端模型视图控制器 (MVC) 模式。它的许多组件和概念对于我们这些具有其他 Web 框架(如 Ruby on Rails 或 Python 的 Django)经验的人来说似乎很熟悉。
Phoenix 提供了两全其美的优势 - 高开发人员生产力和高应用程序性能。它还具有一些有趣的新功能,例如用于实现实时功能的通道和用于超快速度的预编译模板。
说明
Phoenix 是用 Elixir 编写的,我们的应用程序代码也将使用 Elixir 编写。
这里我们假设你已经安装好了Elixir ,已经安装了 Hex,并已经把 Hex 升级到最新版本,安装 Phoenix 应用程序生成器了。
开始
现在我们就可以开始进行案例操作了。
这里我选择使用PostgreSQL的数据库,如果你想使用Mysql,可以看看我之前写的篇。
或者你创建项目的时候输入 --database mysql
创建一个项目
如果你使用PostgreSQL,那么直接可以使用以下命令:
mix phx.new hello
但如果你想使用MySQL,那么可以加一个参数:
mix phx.new hello --database mysql
生成结束以后的提示比较清晰了,我就不做多余描述:
We are almost there! The following steps are missing:
$ cd hello
Then configure your database in config/dev.exs and run:
$ mix ecto.create
Start your Phoenix app with:
$ mix phx.server
You can also run your app inside IEx (Interactive Elixir) as:
$ iex -S mix phx.server
按里面说的做就可以:
cd hello
但是数据库部分我们得去看看配置,不然直接运行mix ecto.create
可能会出现错误,如下:
10:15:30.297 [error] GenServer #PID<0.415.0> terminating
** (DBConnection.ConnectionError) tcp connect (localhost:5432): connection refused - :econnrefused
(db_connection 2.4.2) lib/db_connection/connection.ex:100: DBConnection.Connection.connect/2
(connection 1.1.0) lib/connection.ex:622: Connection.enter_connect/5
(stdlib 3.16.1) proc_lib.erl:226: :proc_lib.init_p_do_apply/3
Last message: nil
State: Postgrex.Protocol
10:15:32.342 [error] GenServer #PID<0.421.0> terminating
** (DBConnection.ConnectionError) tcp connect (localhost:5432): connection refused - :econnrefused
(db_connection 2.4.2) lib/db_connection/connection.ex:100: DBConnection.Connection.connect/2
(connection 1.1.0) lib/connection.ex:622: Connection.enter_connect/5
(stdlib 3.16.1) proc_lib.erl:226: :proc_lib.init_p_do_apply/3
Last message: nil
State: Postgrex.Protocol
** (Mix) The database for Hello.Repo couldn't be created: killed
设置数据库信息
找到config\dev.exs
文件和config\test.exs
文件,并将数据库信息按你的信息进行修改
config :hello, Hello.Repo,
username: "postgres",
password: "postgres",
hostname: "localhost",
database: "hello_dev",
stacktrace: true,
show_sensitive_data_on_connection_error: true,
pool_size: 10
# The MIX_TEST_PARTITION environment variable can be used
# to provide built-in test partitioning in CI environment.
# Run `mix help test` for more information.
config :hello, Hello.Repo,
username: "postgres",
password: "postgres",
hostname: "localhost",
database: "hello_test#{System.get_env("MIX_TEST_PARTITION")}",
pool: Ecto.Adapters.SQL.Sandbox,
设置完毕,继续运行mix ecto.create
mix ecto.create
可能会遇见的问题
运行时,你可能会遇见这样的问题:
10:35:48.475 [error] GenServer #PID<0.296.0> terminating
** (Postgrex.Error) FATAL 28000 (invalid_authorization_specification) no pg_hba.conf entry for host "192.168.0.25", user "hello", database "postgres", no encryption
(db_connection 2.4.2) lib/db_connection/connection.ex:100: DBConnection.Connection.connect/2
(connection 1.1.0) lib/connection.ex:622: Connection.enter_connect/5
(stdlib 3.16.1) proc_lib.erl:226: :proc_lib.init_p_do_apply/3
Last message: nil
State: Postgrex.Protocol
** (Mix) The database for Hello.Repo couldn't be created: killed
这个问题的原因:接数句库时 出现FATAL: password authentication failed for user “****”
解决办法:
修改:pgsql/data/pg_hba.conf
文件
再次运行mix ecto.create
就不会出错了。
运行我们创建的一个项目
使用 mix phx.server
命令启动项目:
mix phx.server
看见以下内容为启动成功:
[info] Running HelloWeb.Endpoint with cowboy 2.9.0 at 127.0.0.1:4000 (http)
[info] Access HelloWeb.Endpoint at http://localhost:4000
端口默认是4000,想修改端口的话,相信你在修改数据库信息的时候已经看到了修改地方config\dev.exs
打开浏览器看看 http://localhost:4000/
不错,这说明我们成功了!那我们继续下面的操作吧!
增加Catalog的Context
Phoenix 包括mix phx.gen.html
、mix phx.gen.json
、mix phx.gen.live
和mix phx.gen.context
生成器
我们将使用mix phx.gen.html
它创建一个语境模块。
该模块将用于创建、更新和删除产品的 Ecto 访问权限以及用于 Web 界面的控制器和模板等 Web 文件封装到我们的语境。
在项目根目录运行以下命令:
mix phx.gen.html Catalog Product products title:string description:string price:decimal views:integer
它将显示:
注:我们从电商的系统建模的基础开始。在实践中,对此类系统进行建模会产生更复杂的关系,例如产品变体、可选定价、多种货币等。我们将在本指南中保持简单,但基础将为您提供构建完整坚实的系统体系。
Phoenix 按预期在lib/hello_web/
中生成了文件.
我们还可以看到我们的Content
文件是在一个lib/hello/catalog.ex
文件中生成的,而我们的产品模式在同名目录中。
lib/hello
注意和之间的区别lib/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 提醒我们通过运行的 repo 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 ,以及非空约束。这确保我们为我们可能执行的任何数学运算以适当的精度存储货币。
接下来,我们为我们的视图计数添加了一个默认值和非空约束。
随着我们的更改到位,我们已准备好迁移我们的数据库。现在让我们这样执行:
mix ecto.migrate
在我们查看生成的代码之前,让我们启动服务器mix phx.server
并访问http://localhost:4000/products。
让我们点击“new products”链接,直接单击“保存”按钮而不进行任何输入。我们应该看到以下输出:
Oops, something went wrong! Please check the errors below.
当我们提交表单时,我们可以在输入中看到所有验证错误。不错啊!开箱即用,有木有?context
生成器在我们的表单模板中包含表结构字段,我们可以看到我们对必须要求输入的默认验证是有效的。
让我们输入一些产品数据并重新提交表单,返回界面会出现以下信息:
Product created successfully.
Show Product
Title: i330
Description: i330
Price: 40.000000
Views: 1
如果我们点击“back”链接,我们会看见所有产品的列表,其中应该包含我们刚刚创建的产品。同样,我们也可以更新或删除这条记录。
现在我们已经了解了它在浏览器中的工作方式,是时候看看生成的代码了。
从生成器开始
这个小小的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.html", products: products)
end
def new(conn, _params) do
changeset = Catalog.change_product(%Product{})
render(conn, "new.html", changeset: changeset)
end
def create(conn, %{"product" => product_params}) do
case Catalog.create_product(product_params) do
{:ok, product} ->
conn
|> put_flash(:info, "Product created successfully.")
|> redirect(to: Routes.product_path(conn, :show, product))
{:error, %Ecto.Changeset{} = changeset} ->
render(conn, "new.html", changeset: changeset)
end
end
def show(conn, %{"id" => id}) do
product = Catalog.get_product!(id)
render(conn, "show.html", product: product)
end
def edit(conn, %{"id" => id}) do
product = Catalog.get_product!(id)
changeset = Catalog.change_product(product)
render(conn, "edit.html", product: product, changeset: changeset)
end
def update(conn, %{"id" => id, "product" => product_params}) do
product = Catalog.get_product!(id)
case Catalog.update_product(product, product_params) do
{:ok, product} ->
conn
|> put_flash(:info, "Product updated successfully.")
|> redirect(to: Routes.product_path(conn, :show, product))
{:error, %Ecto.Changeset{} = changeset} ->
render(conn, "edit.html", product: product, changeset: changeset)
end
end
def delete(conn, %{"id" => id}) do
product = Catalog.get_product!(id)
{:ok, _product} = Catalog.delete_product(product)
conn
|> put_flash(:info, "Product deleted successfully.")
|> redirect(to: Routes.product_path(conn, :index))
end
end
我们已经在控制器指南中看到了控制器的工作原理,因此代码可能并不令人惊讶,值得注意的是我们的控制器如何调用Catalog的context
。
我们可以看到该index
方法是怎么调用Catalog.list_products/0
的,但是我们还没有看Catalog的context
,所以我们不知道这一切是怎么发生的。所以现在的重点就是createCatalog.create_product/1
。
我们的 Phoenix 控制器并不关心产品如何从数据库中获取或持久存储到存储中的细节,只关心并且告诉我们的应用程序为我们执行一些需要的工作。
这其实很棒,因为我这样让我们的业务逻辑、存储细节与应用程序的 Web 层分离,如果我们稍后转移到全文存储引擎来获取产品而不是 SQL 查询,我们的控制器就不需要更改。同样,我们可以从应用程序中的任何其他接口重用我们的代码。无论是channel
、mix tast
还是 long-running process
或是引用CSV数据。
以我们的create
方法为例,当我们成功创建产品时,我们使用Phoenix.Controller.put_flash/3
显示成功消息,然后我们重定向到路由的product_path
显示页面。相反,如果Catalog.create_product/1
失败,我们会渲染我们的"new.html"模板并传递模板的 Ecto 变更集以从中提取错误消息。
接下来,让我们深入挖掘并查看我们的Catalog
内容lib/hello/catalog.ex
:
(但是我这里就忍不住想吐槽一下,使用VSCode打开这个文件我眼睛都花了.不知道你们使用的什么编辑器或者主题/插件,有好方法记得给我说说…)
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
该模块将成为我们系统中所有产品Catalog 的公共 API。比如,除了产品细节的管理,我们还可以处理产品类别的分类和产品变体,比如可选尺寸、修饰等。如果我们看这个list_products/0
功能,我们可以看到产品获取的细节,而且超级简单!我们有一个调用的Repo.all(Product)
方法。
你可以通过Ecto 指南 来查询 Ecto repo是如何工作的,所以这个调用应该看起来很熟悉才对。
list_products
函数很明显的说出来我们的想法,我们使用通用函数名称命名我们的代码 - 即列出产品。
使用 Repo 从我们的 PostgreSQL 或其他数据库中获取产品的数据,这些细节对我们的调用者是隐藏的。这是很常见的事情,我们在使用 Phoenix 生成器还会再次看到它的特点。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.
我们之前讨论过changesets
,现在我们看到它们在我们的语境中起作用。
如果我们打开 catalog
中的lib/hello/catalog/product.ex,它会立即看起来很熟悉:
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
后得到的,除了这里我们在函数上方看到的 @doc false
以外,changeset/2
告诉我们,虽然这个函数是可公开调用的,但它不是公共上下文 API 的一部分。
构建变更集的调用者通过上下文 API 执行此操作。
例如,Catalog.create_product/1
调用我们Product.changeset/2
从用户输入构建changeset
。调用的时候例如我们的控制器操作,不是Product.changeset/2
直接访问。而是与我们的产品changeset
的所有交互都是通过公共Catalog上下文完成的。
添加Catalog
方法
正如我们所见,上下文模块是通过分组的公开相关功能的专用模块。
Phoenix 生成通用函数,例如list_products
和update_product
,但它们仅作为扩展业务逻辑和应用程序的基础。让我们通过跟踪产品页面查看次数来添加catalog的基本功能之一。
对于任何电子商务系统,跟踪产品页面浏览次数的能力对于营销、建议、排名等都是必不可少的。虽然我们可以尝试使用现有的Catalog.update_product
功能,但是Catalog.update_product(product, %{views: product.views + 1})
这不仅容出现竞争条件,但它也需要调用者对我们的目录系统了解太多。要了解为什么存在竞争条件,让我们来看看事件的可能执行情况:
直观地说,您会假设以下事件:
- 用户 1 加载计数为 13 的产品页面
- 用户 1 保存产品页面,计数为 14
- 用户 2 加载计数为 14 的产品页面
- 用户 2 保存产品页面,计数为 15
虽然在实践中会发生这种情况:
- 用户 1 加载计数为 13 的产品页面
- 用户 2 加载计数为 13 的产品页面
- 用户 1 保存产品页面,计数为 14
- 用户 2 保存计数为 14 的产品页面
由于多个调用者可能正在更新过时的视图数量,因此竞争条件将会使其成为更新现有表的不可靠方式,对此,我们其实有更好的方法。
我们考虑一个描述我们想要完成的功能。以下是我们希望如何使用它:
product = Catalog.inc_page_views(product)
emmm,看起来很棒。我们的调用者也不会对这个函数的作用感到困惑,我们可以将增量包装在原子操作中,以防止竞争条件。
但是该操作对MySQL的操作好像不支持。
打开catalog
上下文 ( 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选项指定的选定架构值。
当我们收到新的产品View时,我们使用put_in(product.views, views)
将新视图计数放置在产品结构中。
有了我们的上下文函数,让我们在我们的产品控制器中使用它。更新您的show操作lib/hello_web/controllers/product_controller.ex
以调用我们的新函数:
def show(conn, %{"id" => id}) do
product =
id
|> Catalog.get_product!()
|> Catalog.inc_page_views()
render(conn, "show.html", product: product)
end
我们修改了 show操作,将获取的产品通过管道传输到Catalog.inc_page_views/1
,这将返回更新后的产品信息。然后我们像以前一样渲染我们的模板。
让我们试试看。刷新您的产品页面几次并观察/查看次数增加。
我们同时还可以在 ecto 调试日志中看到我们的原子更新:
[debug] QUERY OK source="products" db=0.5ms idle=834.5ms
UPDATE "products" AS p0 SET "views" = p0."views" + $1 WHERE (p0."id" = $2) RETURNING p0."views" [1, 1]
干得好!
正如我们所见,使用上下文进行设计为您开发应用程序提供了坚实的基础。
使用离散的、定义明确的 API 来公开系统的意图,使您可以使用可重用的代码编写更多可维护的应用程序。
现在我们知道如何开始扩展我们的上下文 API,让我们探索处理上下文中的关系。
In-context Relationships
(机械翻译)
我们的基本目录功能很好,但让我们通过对产品进行分类来提高它的档次。
许多电子商务解决方案允许以不同的方式对产品进行分类,例如标记为时尚、电动工具等的产品。如果我们需要开始支持多个类别,那么从产品和类别之间的一对一关系开始将导致以后的主要代码更改。让我们建立一个类别关联,使我们能够开始跟踪每个产品的单个类别,但随着我们功能的进展,以后可以轻松地支持更多。
目前,类别将仅包含文本信息。我们的首要任务是确定类别在应用程序中的位置。我们有自己的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 表单和产品展示页面中。
我们可以看到我们现在Category在我们的产品模式旁边有一个新模块lib/hello/catalog/category.ex
,Phoenix 告诉我们它正在我们现有的目录上下文中为类别功能注入新功能。注入的函数对我们的产品函数来说看起来非常熟悉,新函数如create_category
,list_categories
, 等等。
在我们向上迁移之前,我们需要进行第二次代码生成。
我们的类别模式非常适合表示系统中的单个类别,但我们需要支持产品和类别之间的多对多关系。
幸运的是,ecto 允许我们简单地使用连接表来执行此操作,所以现在让我们使用以下ecto.gen.migration
命令生成它:
mix ecto.gen.migration create_product_categories
将会显示:
Generated hello app
* creating priv/repo/migrations/20210203192958_create_product_categories.exs
接下来,让我们打开新的迁移文件并将以下代码添加到change
函数中:
defmodule Hello.Repo.Migrations.CreateProductCategories do
use Ecto.Migration
def change do
create table(:product_categories, primary_key: false) do
add :product_id, references(:products, on_delete: :delete_all)
add :category_id, references(:categories, on_delete: :delete_all)
end
create index(:product_categories, [:product_id])
create index(:product_categories, [:category_id])
create unique_index(:product_categories, [:product_id, :category_id])
end
end
你知道吗?我们刚刚创建了一个product_categories
表并使用了该primary_key: false
选项,因为我们的连接表不需要主键。
接下来,我们定义了我们的:product_id
和:category_id
外键字段,并通过on_delete: :delete_all以确保如果删除链接的产品或类别,数据库会修剪我们的连接表记录。通过使用数据库约束,我们在数据库级别强制执行数据完整性,而不是依赖临时和容易出错的应用程序逻辑。
接下来,我们为外键创建索引,并创建一个唯一索引以确保产品不能有重复的类别。随着我们的迁移到位,我们可以向上迁移。
现在我们执行mix ecto.migrate
:
mix ecto.migrate
输出结果
15:38:15.868 [info] == Running 20220625070207 Hello.Repo.Migrations.CreateCategories.change/0 forward
15:38:15.876 [info] create table categories
15:38:16.622 [info] create index categories_title_index
15:38:16.935 [info] == Migrated 20220625070207 in 1.0s
15:38:18.070 [info] == Running 20220625072124 Hello.Repo.Migrations.CreateProductCategories.change/0 forward
15:38:18.070 [info] create table product_categories
15:38:18.544 [info] create index product_categories_product_id_index
15:38:18.839 [info] create index product_categories_category_id_index
15:38:19.145 [info] create index product_categories_product_id_category_id_index
15:38:20.974 [info] == Migrated 20220625072124 in 2.8s
现在我们已经有了一个Catalog.Product
模式和一个连接表来关联产品和类别,我们几乎可以开始连接我们的新功能了。
不过,在深入研究之前,我们首先需要在 Web UI 中选择真实的类别。让我们快速在应用程序中植入一些新类别。
priv/repo/seeds.exs
是个用于填充数据库的脚本。
将以下代码添加到您的文件中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
选项声明在更改类别时应删除任何现有的连接记录。
通过设置我们的模式关联,我们可以在我们的产品表单中实现类别的选择。
为此,我们需要将用户输入的目录 ID 从前端转换为多对多关联。幸运的是,现在我们的模式已经建立,Ecto 使这变得轻而易举。
打开您的catalog 上下文并进行以下更改,如果你认为麻烦 可以复制下面的代码:
- def get_product!(id), do: Repo.get!(Product, id)
+ def get_product!(id) do
+ Product |> Repo.get(id) |> Repo.preload(:categories)
+ end
def create_product(attrs \\ %{}) do
%Product{}
- |> Product.changeset(attrs)
+ |> change_product(attrs)
|> Repo.insert()
end
def update_product(%Product{} = product, attrs) do
product
- |> Product.changeset(attrs)
+ |> change_product(attrs)
|> Repo.update()
end
def change_product(%Product{} = product, attrs \\ %{}) do
- Product.changeset(product, attrs)
+ categories = list_categories_by_id(attrs["category_ids"])
+ product
+ |> Repo.preload(:categories)
+ |> Product.changeset(attrs)
+ |> Ecto.Changeset.put_assoc(:categories, categories)
end
+ def list_categories_by_id(nil), do: []
+ def list_categories_by_id(category_ids) do
+ Repo.all(from c in Category, where: c.id in ^category_ids)
+ end
首先,我们Repo.preload
在获取产品时添加了预加载类别。
这将允许我们product.categories
在我们的控制器、模板以及我们想要使用类别信息的任何其他地方进行引用。
接下来,我们修改了我们的create_product
和 update_product
函数来调用我们现有的change_product
函数来生成一个变更集。
我们在其中change_product
添加了查找以查找所有类别(如果"category_ids
"存在该属性)。然后我们预加载类别并调用Ecto.
Changeset.put_assoc
以将获取的类别放入变更集中。
最后,我们实现了list_categories_by_id/1
查询与类别 ID 匹配的类别的功能,如果没有"category_ids
"属性,则返回一个空列表。
现在我们create_product
和update_product
一旦我们尝试对我们的存储库进行插入或更新,函数就会收到一个带有类别关联的变更集。
接下来,让我们通过将类别输入添加到我们的产品表单来向 Web 公开我们的新功能。为了保持表单模板的整洁,让我们编写一个新函数来总结为我们的产品呈现类别选择输入的细节。打开你ProductView
的输入:
defmodule HelloWeb.ProductView do
use HelloWeb, :view
def category_select(f, changeset) do
existing_ids = changeset |> Ecto.Changeset.get_change(:categories, []) |> Enum.map(& &1.data.id)
category_opts =
for cat <- Hello.Catalog.list_categories(),
do: [key: cat.title, value: cat.id, selected: cat.id in existing_ids]
multiple_select(f, :category_ids, category_opts)
end
end
我们添加了一个新category_select/2
函数,它使用Phoenix的HTML'smultiple_select/3
生成多选标签。
我们从变更集中计算了现有的类别 ID,然后在为输入标签生成选择选项时使用这些值。
我们通过枚举所有类别并返回适当key的value、 和selected值来做到这一点。如果在变更集中的这些类别 ID 中找到类别 ID,我们将选项标记为选中。
有了我们的category_select
功能,我们可以打开lib/hello_web/templates/product/form.html.heex
并添加:
...
<%= label f, :views %>
<%= number_input f, :views %>
<%= error_tag f, :views %>
+ <%= category_select f, @changeset %>
<div>
<%= submit "Save" %>
</div>
我们在保存按钮上方添加了一个category_select
。
现在让我们试一试。
接下来,让我们在产品展示模板中展示产品的类别。
将以下代码添加到lib/hello_web/templates/product/show.html.heex
:
...
+ <li>
+ <strong>Categories:</strong>
+ <%= for cat <- @product.categories do %>
+ <%= cat.title %>
+ <br>
+ <% end %>
+ </li>
</ul>
现在,如果我们启动服务器mix phx.server
并访问http://localhost:4000/products/new,我们将看到新的类别多选输入。
输入一些有效的产品详细信息,选择一两个类别,然后单击保存。
我运行后网页效果如下
Product created successfully.
Show Product
Title: 测试
Description: test
Price: 10.000000
Views: 10
Categories: Gardening
它还没有太多可看的东西,但它确实有效!
我们在上下文中添加了关系,并由数据库强制执行数据完整性。
不错。让我们继续改进!
Cross-context dependencies
现在我们已经开始有了产品分类的功能,让我们开始处理我们应用程序的其他主要功能——购买产品。
为了正确跟踪已添加到用户购物车的产品,我们需要一个新位置来保存此信息,以及时间点产品信息,例如购物车时的价格。
这是必要的,因此我们可以在未来检测产品价格的变化。我们知道我们需要构建什么,但现在我们需要决定购物车功能在我们的应用程序中的位置。
如果我们退后一步考虑一下我们应用程序的隔离性,我们Catalog中产品的展示与管理用户购物车的职责明显不同。
产品目录不应该关心我们的购物车系统的规则,反之亦然。
这里显然需要一个单独的上下文来处理新的购物车职责。让我们称之为ShoppingCart
。
购物车
让我们创建一个ShoppingCart
上下文来处理基本的购物车职责。
在我们编写代码之前,让我们假设我们有以下功能需求:
- 从产品展示页面将产品添加到用户的购物车
- 在购物时存储时间点产品价格信息
- 在购物车中存储和更新数量
- 计算并显示购物车价格总和
从描述中,很明显我们需要一个Cart
资源来存储用户的购物车,以及CartItem
跟踪购物车中的产品。有了我们的计划,让我们开始工作。
运行以下命令来生成我们的新上下文:
mix phx.gen.context ShoppingCart Cart carts user_uuid:uuid:unique
运行后结果:
* creating lib/hello/shopping_cart/cart.ex
* creating priv/repo/migrations/20220625085059_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.
The Hello.ShoppingCart context currently has 6 functions and 1 file in its directory.
* It's OK to have multiple resources in the same context as long as they are closely related. But if a context grows too large, consider breaking it apart
* If they are not closely related, another context probably works better
The fact two entities are related in the database does not mean they belong to the same context.
If you are not sure, prefer creating a new context over adding to the existing one.
Would you like to proceed? [Yn] Y
* creating lib/hello/shopping_cart/cart_item.ex
* creating priv/repo/migrations/20220625085417_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
很棒,我们在ShoppingCartnamed
中生成了一个新资源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])
完整文件:
defmodule Hello.Repo.Migrations.CreateCartItems do
use Ecto.Migration
def change do
create table(:cart_items) do
add :price_when_carted, :decimal, precision: 15, scale: 6, null: false
add :quantity, :integer
add :cart_id, references(:carts, on_delete: :delete_all)
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])
end
end
我们:delete_all
再次使用该策略来强制执行数据完整性。
这样,当从应用程序中删除购物车或产品时,我们不必依赖ShoppingCart
或Catalog
上下文中的应用程序代码来担心清理记录。
这使我们的应用程序代码保持解耦,并在它所属的位置(在数据库中)执行数据完整性。
我们还添加了一个独特的约束,以确保不允许将重复的产品添加到购物车中。
有了我们的数据库表,我们现在可以进行迁移:
mix ecto.migrate
运行结果:
Compiling 3 files (.ex)
Generated hello app
16:59:26.753 [info] == Running 20220625085059 Hello.Repo.Migrations.CreateCarts.change/0 forward
16:59:26.763 [info] create table carts
17:00:00.289 [info] create index carts_user_uuid_index
17:00:23.138 [info] == Migrated 20220625085059 in 56.3s
17:00:27.633 [info] == Running 20220625085417 Hello.Repo.Migrations.CreateCartItems.change/0 forward
17:00:27.633 [info] create table cart_items
17:00:27.969 [info] create index cart_items_cart_id_index
17:00:28.598 [info] create index cart_items_product_id_index
17:00:28.921 [info] create index cart_items_cart_id_product_id_index
17:00:29.277 [info] == Migrated 20220625085417 in 1.6s
我们的数据库已准备好使用新表carts
和cart_items
表,但现在我们需要将其映射回应用程序代码。
您可能想知道我们如何在不同的表中混合数据库外键,以及这与隔离、分组功能的上下文模式有何关系。让我们开始讨论这些方法及其权衡。
Cross-context data
到目前为止,我们已经很好地将应用程序的两个主要上下文相互隔离,但现在我们有一个必要的依赖关系需要处理。
我们的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 :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
完整文件:
defmodule Hello.ShoppingCart.CartItem do
use Ecto.Schema
import Ecto.Changeset
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
end
首先,我们将cart_id
字段替换为belongs_to
指向我们ShoppingCart
,Cart模型的标准。
接下来,我们通过为模式product_id
添加第一个跨上下文数据依赖项来替换我们的字段。在这里,我们有意耦合数据边界,因为它提供了我们所需要的一个独立的上下文 API,具有在我们的系统中引用产品所需的最少知识。
接下来,我们向我们的变更集添加了一个新的验证。使用,我们确保用户输入提供的任何数量都在 0 到 100 之间。belongs_to
Catalog 的 Productvalidate_number/3
有了我们的模式,我们就可以开始将新的数据结构和ShoppingCart上下文 API 集成到我们面向 Web 的功能中。
添加购物车功能
正如我们之前提到的,上下文生成器只是我们应用程序的起点。我们可以而且应该编写命名良好、专门构建的函数来实现我们上下文的目标。我们有一些新功能要实现。首先,如果还没有购物车,我们需要确保我们应用程序的每个用户都获得了购物车。
然后,我们可以允许用户将商品添加到他们的购物车、更新商品数量并计算购物车总数。让我们开始吧!
在这一点上,我们不会专注于真正的用户身份验证系统,但是当我们完成时,您将能够自然地将其与我们在此处编写的内容集成。要模拟当前用户会话,请打开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
我们在浏览器pipe中添加了一个新插件,以在所有基于浏览器的请求上运行:fetch_current_user
。:fetch_current_cart
完成后接下来,我们实现了fetch_current_user
插件,它简单地检查会话中是否存在先前添加的用户 UUID。
如果我们找到一个,我们添加一个current_uuid
分配给连接,我们就完成了。如果我们还没有识别出这个访问者,我们用 生成一个唯一的 UUID Ecto. UUID.generate()
,然后我们把这个值放在current_uuid
分配,以及一个新的会话值,以在未来的请求中识别此访问者。一个随机的、唯一的 ID 并不能代表一个用户,但它足以让我们跨请求跟踪和识别访问者,这就是我们现在所需要的。
稍后随着我们的应用程序变得更加完整,您将准备好迁移到完整的用户身份验证解决方案。使用有保证的当前用户,然后我们实现了fetch_current_cart
插件,它要么为用户 UUID 找到购物车,要么为当前用户创建购物车并在连接分配中分配结果。我们需要实现ShoppingCart.get_cart_by_user_uuid/1
并修改 create cart
函数以接受 UUID,但让我们先添加我们的路由。
我们需要实现一个购物车控制器来处理购物车操作,例如查看购物车、更新数量和启动结帐流程,以及一个购物车项目控制器,用于在购物车中添加和删除单个商品。将以下路由添加到您的路由器lib/hello_web/router.ex
:
scope "/", HelloWeb do
pipe_through :browser
get "/", PageController, :index
resources "/products", ProductController
+ resources "/cart_items", CartItemController, only: [:create, :delete]
+ get "/cart", CartController, :show
+ put "/cart", CartController, :update
end
我们添加了一个resources
的 声明CartItemController,它将连接用于添加和删除单个购物车项目的创建和删除操作的路线。接下来,我们添加了两个指向新路由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
product = Catalog.get_product!(product_id)
case ShoppingCart.add_item_to_cart(conn.assigns.cart, product) do
{:ok, _item} ->
conn
|> put_flash(:info, "Item added to your cart")
|> redirect(to: Routes.cart_path(conn, :show))
{:error, _changeset} ->
conn
|> put_flash(:error, "There was an error adding the item to your cart")
|> redirect(to: Routes.cart_path(conn, :show))
end
end
def delete(conn, %{"id" => product_id}) do
{:ok, _cart} = ShoppingCart.remove_item_from_cart(conn.assigns.cart, product_id)
redirect(conn, to: Routes.cart_path(conn, :show))
end
end
我们在路由器中声明的CartItemController
定义了一个新的创建和删除操作。对于create,我们首先使用 查找目录中的产品Catalog.get_product!/1
,然后调用ShoppingCart.add_item_to_cart/2
我们稍后将实现的函数。如果成功,我们会显示一个 flash 成功消息并重定向到购物车显示页面;否则,我们会显示一个 Flash 错误消息并重定向到购物车显示页面。对于delete,我们将调用remove_item_from_cart
我们将在ShoppingCart
上下文中实现的函数,然后重定向回购物车显示页面。我们还没有实现这两个购物车功能,但请注意它们的名字如何表达它们的意图:add_item_to_cart
和remove_item_from_cart
让我们在这里完成的工作一目了然。它还允许我们指定 Web 层和上下文 API,而无需立即考虑所有实现细节。
让我们在以下位置实现ShoppingCart
上下文 API的新接口lib/hello/shopping_cart.ex
:
alias Hello.Catalog
alias Hello.ShoppingCart.{Cart, CartItem}
def get_cart_by_user_uuid(user_uuid) do
Repo.one(
from(c in Cart,
where: c.user_uuid == ^user_uuid,
left_join: i in assoc(c, :items),
left_join: p in assoc(i, :product),
order_by: [asc: i.inserted_at],
preload: [items: {i, product: p}]
)
)
end
- def create_cart(attrs \\ %{}) do
- %Cart{}
- |> Cart.changeset(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, %Catalog.Product{} = product) do
%CartItem{quantity: 1, price_when_carted: product.price}
|> CartItem.changeset(%{})
|> Ecto.Changeset.put_assoc(:cart, cart)
|> Ecto.Changeset.put_assoc(:product, product)
|> Repo.insert(
on_conflict: [inc: [quantity: 1]],
conflict_target: [:cart_id, :product_id]
)
end
def remove_item_from_cart(%Cart{} = cart, product_id) do
{1, _} =
Repo.delete_all(
from(i in CartItem,
where: i.cart_id == ^cart.id,
where: i.product_id == ^product_id
)
)
{:ok, reload_cart(cart)}
end
我们首先实现 get_cart_by_user_uuid/1
which 获取我们的购物车并加入购物车项目及其产品,以便我们拥有填充所有预加载数据的完整购物车。接下来,我们修改了create_cart
函数以接受用户 UUID 而不是我们用来填充user_uuid
字段的属性。如果插入成功,我们通过调用一个私有reload_cart/1
函数重新加载购物车内容,该函数只是调用get_cart_by_user_uuid/1
重新获取数据。接下来,我们编写了新add_item_to_cart/2
函数,它从目录中接受一个购物车结构和一个产品结构。我们对我们的 repo 使用了 upsert 操作,将新的购物车项目插入数据库,或者如果购物车中已经存在,则将数量增加一。这是通过on_conflict
和 conflict_targetoptions
,它告诉我们的 repo 如何处理插入冲突。接下来,我们实现remove_item_from_cart/2
了我们只需发出一个Repo delete_all
带有查询的调用来删除我们购物车中与产品 ID 匹配的购物车项目。最后,我们通过调用重新加载购物车内容reload_cart/1
。
有了新的购物车功能,我们现在可以在产品目录显示页面上显示“添加到购物车”按钮。打开您的模板lib/hello_web/templates/product/show.html.heex
并进行以下更改:
<h1>Show Product</h1>
+<%= link "Add to cart",
+ to: Routes.cart_item_path(@conn, :create, product_id: @product.id),
+ method: :post %>
...
来自的link函数·Phoenix.HTML·接受:method单击时发出 HTTP 动词的选项,而不是默认的 GET 请求。有了这个链接,“添加到购物车”链接将发出一个 POST 请求,该请求将与我们在路由器中定义的路由匹配,该路由将分派给该CartItemController.create/2
函数。
让我们试试看。启动您的服务器mix phx.server
并访问产品页面。如果我们尝试单击添加到购物车链接,我们将看到一个错误页面。