按着Phoenix Framework文档练个手,感受一下Elixir语言。

介绍

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/
http://localhost:4000/
不错,这说明我们成功了!那我们继续下面的操作吧!

增加Catalog的Context

Phoenix 包括mix phx.gen.htmlmix phx.gen.jsonmix phx.gen.livemix phx.gen.context生成器

我们将使用mix phx.gen.html它创建一个语境模块。
minx 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 查询,我们的控制器就不需要更改。同样,我们可以从应用程序中的任何其他接口重用我们的代码。无论是channelmix 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_productsupdate_product,但它们仅作为扩展业务逻辑和应用程序的基础。让我们通过跟踪产品页面查看次数来添加catalog的基本功能之一。

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

直观地说,您会假设以下事件:

  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)

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.Schemamany_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_productupdate_product函数来调用我们现有的change_product函数来生成一个变更集。

我们在其中change_product添加了查找以查找所有类别(如果"category_ids"存在该属性)。然后我们预加载类别并调用Ecto.

Changeset.put_assoc以将获取的类别放入变更集中。

最后,我们实现了list_categories_by_id/1查询与类别 ID 匹配的类别的功能,如果没有"category_ids"属性,则返回一个空列表。

现在我们create_productupdate_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上下文来处理基本的购物车职责。

在我们编写代码之前,让我们假设我们有以下功能需求:

  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/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再次使用该策略来强制执行数据完整性。

这样,当从应用程序中删除购物车或产品时,我们不必依赖ShoppingCartCatalog上下文中的应用程序代码来担心清理记录。

这使我们的应用程序代码保持解耦,并在它所属的位置(在数据库中)执行数据完整性。

我们还添加了一个独特的约束,以确保不允许将重复的产品添加到购物车中。

有了我们的数据库表,我们现在可以进行迁移:

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

我们的数据库已准备好使用新表cartscart_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_cartremove_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_conflictconflict_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并访问产品页面。如果我们尝试单击添加到购物车链接,我们将看到一个错误页面。

Table of Contents 第⼀部分:基础 Introduction 基础集合Enum 模块 模式匹配 控制语句 函数管道操作符 模块(Module) Mix 魔符(Sigil) ⽂档模块 测试推导字符串 ⽇期和时间 ⾃定义Mix任务 IEx辅助函数 第⼆部分:⾼级 1 2.1 2.2 2.3 2.4 2.5 2.6 2.7 2.8 2.9 2.10 2.11 2.12 2.13 2.14 3.1 3.2 3.3 3.4 4.1 4.2 4.3 和Erlang互操作 错误处理 可执⾏⽂件 并发OTP并发 OTP Supervisors OTP 分布式 元编程 Umbrella Projects Specifications and types ⾏为GenStage 协议Nerves 第三部分:ECTO Basics Changesets 关联关系 查询第四部分:专题 Plug 嵌⼊的 Elixir(EEx) Erlang 项式存储(ETS) 2 4.4 4.5 5.1 5.2 5.3 5.4 5.5 5.6 Mnesia 数据库 调试第五部分:程序库 Guardian(基础) Poolboy Benchee Bypass Distillery(基础) StreamData 3 Introduction 绪⾔第⼀部分:基础 基础集合Enum 模块 模式匹配 控制语句 函数管道操作符 模块(Module) Mix 魔符(Sigil) ⽂档模块 测试推导字符串 ⽇期和时间 ⾃定义Mix任务 IEx辅助函数 第⼆部分:⾼级 和Erlang互操作 错误处理 可执⾏⽂件 并发OTP并发 OTP Supervisors 4 Introduction OTP 分布式 元编程 Umbrella Projects Specifications and types ⾏为GenStage 协议Nerves 第三部分:ECTO Basics Changesets 关联关系 查询 第四部分:专题 Plug 嵌⼊的 Elixir(EEx) Erlang 项式存储(ETS) Mnesia 数据库 调试 第五部分:程序库 Guardian(基础) Poolboy Benchee Bypass Distillery(基础) StreamData
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

Msln1995

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值