上下文模式 #

一、Context概述 #

1.1 什么是Context #

Context是Phoenix中组织业务逻辑的设计模式,它将相关的功能组织在一起,提供清晰的API边界。

1.2 Context结构 #

text
lib/hello/
├── accounts/           # Accounts Context
│   ├── accounts.ex     # 公共API
│   ├── user.ex         # User Schema
│   └── user_token.ex   # UserToken Schema
├── blog/               # Blog Context
│   ├── blog.ex         # 公共API
│   ├── post.ex         # Post Schema
│   └── comment.ex      # Comment Schema
└── shop/               # Shop Context
    ├── shop.ex         # 公共API
    ├── product.ex      # Product Schema
    └── order.ex        # Order Schema

二、定义Context #

2.1 Context模块 #

elixir
defmodule Hello.Accounts do
  @moduledoc """
  The Accounts context.
  """

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

  def list_users, do: Repo.all(User)

  def get_user!(id), do: Repo.get!(User, id)

  def get_user(id), do: Repo.get(User, id)

  def get_user_by_email(email) do
    Repo.get_by(User, email: email)
  end

  def create_user(attrs \\ %{}) do
    %User{}
    |> User.changeset(attrs)
    |> Repo.insert()
  end

  def update_user(%User{} = user, attrs) do
    user
    |> User.changeset(attrs)
    |> Repo.update()
  end

  def delete_user(%User{} = user) do
    Repo.delete(user)
  end

  def change_user(%User{} = user, attrs \\ %{}) do
    User.changeset(user, attrs)
  end
end

2.2 Schema模块 #

elixir
defmodule Hello.Accounts.User do
  use Ecto.Schema
  import Ecto.Changeset

  schema "users" do
    field :email, :string
    field :name, :string
    field :password, :string, virtual: true
    field :hashed_password, :string

    timestamps()
  end

  def changeset(user, attrs) do
    user
    |> cast(attrs, [:email, :name])
    |> validate_required([:email, :name])
    |> unique_constraint(:email)
  end

  def registration_changeset(user, attrs) do
    user
    |> changeset(attrs)
    |> cast(attrs, [:password])
    |> validate_required([:password])
    |> put_hashed_password()
  end

  defp put_hashed_password(changeset) do
    case get_change(changeset, :password) do
      nil -> changeset
      password -> put_change(changeset, :hashed_password, hash(password))
    end
  end
end

三、Context设计原则 #

3.1 单一职责 #

elixir
defmodule Hello.Accounts do
  alias Hello.Accounts.User
  alias Hello.Accounts.UserToken

  def list_users, do: Repo.all(User)
  def get_user!(id), do: Repo.get!(User, id)
  def create_user(attrs), do: ...
  def update_user(user, attrs), do: ...
  def delete_user(user), do: ...

  def generate_user_token(user), do: ...
  def verify_user_token(token), do: ...
end

3.2 清晰的API边界 #

elixir
defmodule Hello.Blog do
  alias Hello.Blog.Post
  alias Hello.Blog.Comment

  def list_posts(opts \\ []), do: ...
  def get_post!(id), do: ...
  def create_post(attrs), do: ...
  def update_post(post, attrs), do: ...
  def delete_post(post), do: ...

  def list_comments(post_id), do: ...
  def create_comment(post_id, attrs), do: ...
  def delete_comment(comment), do: ...
end

3.3 避免跨Context直接访问 #

elixir
defmodule Hello.Blog do
  alias Hello.Accounts

  def create_post(user_id, attrs) do
    user = Accounts.get_user!(user_id)

    %Post{author_id: user.id}
    |> Post.changeset(attrs)
    |> Repo.insert()
  end
end

四、Context关联 #

4.1 跨Context数据访问 #

elixir
defmodule Hello.Blog do
  alias Hello.Accounts

  def list_posts_by_user(user_id) do
    from(p in Post, where: p.author_id == ^user_id)
    |> Repo.all()
  end

  def get_post_with_author!(id) do
    post = Repo.get!(Post, id)
    author = Accounts.get_user!(post.author_id)
    %{post | author: author}
  end
end

4.2 跨Context事务 #

elixir
defmodule Hello.Shop do
  alias Hello.Accounts
  alias Hello.Shop.Order

  def create_order(user_id, attrs) do
    Ecto.Multi.new()
    |> Ecto.Multi.run(:user, fn _repo, _changes ->
      case Accounts.get_user(user_id) do
        nil -> {:error, :user_not_found}
        user -> {:ok, user}
      end
    end)
    |> Ecto.Multi.insert(:order, fn %{user: user} ->
      Order.changeset(%Order{user_id: user.id}, attrs)
    end)
    |> Repo.transaction()
  end
end

五、Context测试 #

5.1 测试Context #

elixir
defmodule Hello.AccountsTest do
  use Hello.DataCase

  alias Hello.Accounts

  describe "users" do
    alias Hello.Accounts.User

    test "list_users/0 returns all users" do
      user = user_fixture()
      assert Accounts.list_users() == [user]
    end

    test "get_user!/1 returns the user with given id" do
      user = user_fixture()
      assert Accounts.get_user!(user.id) == user
    end

    test "create_user/1 with valid data creates a user" do
      attrs = %{email: "test@example.com", name: "Test User"}

      assert {:ok, %User{} = user} = Accounts.create_user(attrs)
      assert user.email == "test@example.com"
      assert user.name == "Test User"
    end

    test "create_user/1 with invalid data returns error changeset" do
      attrs = %{email: nil, name: nil}
      assert {:error, %Ecto.Changeset{}} = Accounts.create_user(attrs)
    end
  end

  defp user_fixture(attrs \\ %{}) do
    attrs = Enum.into(attrs, %{email: "test@example.com", name: "Test User"})
    {:ok, user} = Accounts.create_user(attrs)
    user
  end
end

5.2 测试辅助 #

elixir
defmodule Hello.DataCase do
  use ExUnit.CaseTemplate

  using do
    quote do
      alias Hello.Repo

      import Ecto
      import Ecto.Changeset
      import Ecto.Query
      import Hello.DataCase
    end
  end

  setup tags do
    Hello.DataCase.setup_sandbox(tags)
    :ok
  end

  def setup_sandbox(tags) do
    pid = Ecto.Adapters.SQL.Sandbox.start_owner!(Hello.Repo, shared: not tags[:async])
    on_exit(fn -> Ecto.Adapters.SQL.Sandbox.stop_owner(pid) end)
  end
end

六、Context生成器 #

6.1 使用生成器 #

bash
mix phx.gen.context Accounts User users email:string name:string
mix phx.gen.context Blog Post posts title:string body:text
mix phx.gen.context Shop Product products name:string price:decimal

6.2 生成器输出 #

text
* creating lib/hello/accounts/user.ex
* creating priv/repo/migrations/20240101000000_create_users.exs
* creating lib/hello/accounts.ex
* creating test/hello/accounts_test.exs

七、Context最佳实践 #

7.1 命名约定 #

elixir
defmodule Hello.Accounts do
  def list_users, do: ...
  def get_user!(id), do: ...
  def get_user(id), do: ...
  def create_user(attrs), do: ...
  def update_user(user, attrs), do: ...
  def delete_user(user), do: ...
  def change_user(user, attrs), do: ...
end

7.2 文档注释 #

elixir
defmodule Hello.Accounts do
  @moduledoc """
  The Accounts context handles user management.

  ## Examples

      iex> Accounts.create_user(%{email: "test@example.com"})
      {:ok, %User{}}

      iex> Accounts.create_user(%{email: nil})
      {:error, %Ecto.Changeset{}}
  """

  @doc """
  Creates a new user.

  ## Examples

      iex> create_user(%{email: "test@example.com", name: "Test"})
      {:ok, %User{}}

      iex> create_user(%{email: nil})
      {:error, %Ecto.Changeset{}}
  """
  def create_user(attrs), do: ...
end

7.3 错误处理 #

elixir
defmodule Hello.Accounts do
  def authenticate_user(email, password) do
    with %User{} = user <- get_user_by_email(email),
         true <- verify_password(user, password) do
      {:ok, user}
    else
      nil -> {:error, :not_found}
      false -> {:error, :invalid_password}
    end
  end
end

八、总结 #

8.1 核心概念 #

概念 说明
Context 业务逻辑模块
Schema 数据模型
公共API Context暴露的函数
私有函数 内部实现

8.2 设计原则 #

  • 单一职责
  • 清晰的API边界
  • 避免跨Context直接访问
  • 使用Ecto.Multi处理跨Context事务

8.3 下一步 #

现在你已经了解了上下文模式,接下来让我们学习 认证系统,深入了解用户认证!

最后更新:2026-03-28