首页
学习
活动
专区
工具
TVP
发布
精选内容/技术社群/优惠产品,尽在小程序
立即前往

应用程序分层:可扩展的Elixir应用程序设计模式

设计应用时往往会遇到一个问题:“这部分代码应该放在哪儿?”很多时候答案是不清楚的,结果代码被扔进了项目的垃圾桶(如 util 或 model)。这种事很多的话,代码库就会变乱,并且团队维护软件的能力会愈加低下。这并不是开发者经验不足或“命名困难”的迹象,而有可能是应用缺乏结构的征兆。本文希望帮助 Elixir 开发者学习如何构建可维护、适应并扩展的大型代码库,而不会陷入复杂的依赖项与技术累赘的陷阱中。

背景

Phoenix 上下文是迈向整洁应用的第一步,而且很适合小规模应用。但随着应用的不断发展,一种趋势是将所有上下文作为同级对象保留在单个层中,很难区分代码库中不同的抽象级别。当业务逻辑需要从多个上下文中组装数据时事情变得愈加复杂,逻辑也没有明确的落脚之处。

真正的问题在于应用有许多抽象级别,它们都分组到同一个上下文中。当众多抽象级别混在同一个模块中,或者没有放置首要业务逻辑的地方时,代码库就会变得混乱且难以推理。

另一种方法

有时,要想出另一种方法必须退后几步,从其他角度研究问题。

大多数库都是它们提供的功能的抽象层。开发者编写 API 客户端库是为了不用操心 API 客户端中的 HTTP 请求、解析响应、序列化数据等等内容。数据库适配器、Web 服务器、硬件驱动程序以及许多提供干净 API 来包装潜在复杂操作的库都是一样的道理。它们都隐藏了复杂性,使开发者可以专注于编写应用,不必担心底层问题(比如打开 Web 服务器的套接字)。

就连我们常用的库也将复杂性分配给了其他专注于较低抽象级别的库。比如说我们来看 ecto_sql 的依赖关系树。

代码语言:javascript
复制
ecto_sql ~> 3.0 (Hex package)
├── db_connection ~> 2.0 (Hex package)
│   └── connection ~> 1.0.2 (Hex package)
├── ecto ~> 3.1.0 (Hex package)
│   └── decimal ~> 1.6 (Hex package)
├── postgrex ~> 0.14.0 or ~> 0.15.0 (Hex package)
└── telemetry ~> 0.4.0 (Hex package)

ecto_sql 为应用提供了与数据库交互的 API。ecto_sql 内部的代码主要将应用数据写入数据库并从数据库中读出。但当涉及较低级别的问题(例如维护数据库连接、连接池、指标和特定于数据库的通信)时,它会调出其他关注这些较低抽象级别的库。

在 Elixir 生态系统中,让某些库专注于某些抽象级别的理念是非常常见的。库作者试图提供易于使用的 API,让其他应用可以在这些 API 的基础上构建,不会因为前者包装的抽象级别而困惑。这种模块化和关注点分离是让开发者构建更好,更可维护应用的关键因素,因为他们可以专注于业务逻辑并将较低级别的关注点委派给 Elixir 生态系统中的其他库。

将模块化依赖关系分层从而隔离抽象级别的模式很好用,为什么不把它扩展到我们的应用代码库中呢?我们可用一种模块化组件树将逻辑隔离到适当的抽象级别中。

模式

应用程序分层模式由两部分组成:

  1. 根据应用的各种抽象级别,将其分成几层树。
  2. 使每层的实现与替代实现可以轻松互换,以提高可测试性并增强对不断变化的业务需求的适应性。

有一个示例存储库就是用这种模式构建的:

https://github.com/aaronrenner/zone-meal-tracker

这个应用使用了后文中讨论的一些技术进行了重构,可以查阅提交历史记录来查看一些较早的步骤。

将应用分成多层

Elixir/Phoenix 社区中的开发者通常使用 Phoenix 上下文将项目分为单独的 Web 和业务逻辑应用。这最初是由 Lance Halvorsen 的演讲"Phoenix 不是你的应用"启发的,Phoenix 框架的代码生成器进一步发扬光大了这种思想。这种分离极大地简化了 Web 应用,使其专注于 Web 问题,并成为基础业务应用的瘦接口。这也使开发者可以单独关注其项目的核心逻辑——也就是解决业务需求的 Elixir 应用。

尽管将 Web 层与其余部分分离的好处颇多,但许多项目分离到这一步就算完了。较小的代码库可能没问题,但在较大的代码库中这可能导致应用变得很复杂。

应用分层背后的主要思想是将应用进一步分解为多个层次,以隔离应用的各种抽象级别。该概念最初由 1996 年的"面向模式的软件架构——第 1 卷"引入,称为层模式。这种模式的优点有:

  • 可理解性——由于各个层只关注单个抽象级别,因此代码更有条理。例如,业务逻辑层可以专注用户注册用户的过程(在数据库中创建用户,发送欢迎电子邮件等),而不会被 SQL 命令或电子邮件传递之类的低级细节困扰。
  • 可维护性——对于开发者而言,容易理解的代码也容易更新。将应用分成多个层时,可以更轻松地了解应该在哪里编写新逻辑。数据序列化的机制之类的事情只需 API 客户端这类底层操心即可。同样,支持新业务流程的逻辑应放在更高级别的业务逻辑层中。
  • 适应性——由于父层通过明确定义的 API 与子层通信,因此子层的实现可以用符合相同 API 的增强实现来代替。这样就可以在不涉及父层的前提下替换整个层的实现。

在“应用分层”中,我们采用"层模式"的严格变体(一个层只能耦合到它的直接子层);我们不创建应用程序范围的层,而是创建一个子层树,这些子层重点关注特定的抽象级别,各个级别负责解决手头的任务。

这种“层树”方法的重点是,每个实现都可以分解为逻辑子模块,并且必要时可以将每个子模块进一步分解为更多的子模块。这种分解自然会形成向较低层抽象移动的层。尽管业务逻辑层可能在协调几个复杂的较低层,但前者仍可以保持简单、可读和灵活性。这也使 API 客户端这样的较低层很容易提取到它们自己的独立库中,因为它们与较高层是分离的。

构建顶级 API

我们已经讨论了应用分层的高层结构,来看一下如何实现它。

为了明确我们的应用提供的功能,我们将应用的顶级模块视为其公共 API,也是外界与我们的代码交互的唯一通道。Elixir 的文档编写指南指出,文档中只应显示我们的公共 API,并且所有内部功能 / 模块都应使用 @moduledoc false 隐藏。这样一来,开发者就可以清楚地了解应用与外界的合约,而不会与其他涉及实现细节的内部模块混淆。

只让一个模块(及其相关结构)作为应用的公共 API 是非常重要的,原因如下:

  1. 如果将其他模块公开,则很难理解子模块是公共 API 的一部分还是仅在内部被调用的较低级别的实现细节。
  2. 当添加一个跨多个组件的新业务流程时(例如创建用户时注册用户和发送欢迎通知),可以用顶级公共 API 模块将这些组件绑定到一个业务流程中。如果没有统一的顶级业务逻辑去处,就会搞不清楚这些逻辑该放在哪里。

利用命名空间指示层

如果我们的顶级模块是公共 API,则子模块应该只是以下两种情况之一:

  1. 公共 API 引用的仅结构模块。
  2. 内部辅助模块,是公共 API 的实现细节,不能被公开调用。

如前所述,如果我们只把属于公共 API 的模块写在文档里,就很容易分辨出模块和函数是公共的还是内部的。

代码语言:javascript
复制
defmodule ZoneMealTracker do
  @moduledoc """
  公共 API for ZoneMealTracker application
  """
  alias ZoneMealTracker.User

  @doc """
  Registers a new user with email and password.
  """
  @spec register_user(String.t(), String.t()) ::
          {:ok, User.t()} | {:error, :email_already_registered}
  def register_user(email, password), do: #...
end
代码语言:javascript
复制
defmodule ZoneMealTracker.User do
  @moduledoc """
  User struct
  """
  # This is a struct module used by the public API

  defstruct [:id, :email]

  @type id :: String.t()
  @type t :: %__MODULE__{
    id: id,
    email: String.t()
  }
end
代码语言:javascript
复制
defmodule ZoneMealTracker.Notifications do
  @moduledoc false

  # This module is just an implementation detail of ZoneMealTracker
  # and not exposed on the public API. You can tell this by `@moduledoc false`
  # and it doesn't define a struct.
  #
  # No modules other than ZoneMealTracker should call this because it's
  # not part of the public API.

  @spec send_welcome_message(User.t) do
end

这种方法的妙处在于,我们通过公共 API 层(模块)向世界展示了我们的功能,而所有子辅助模块都是下面实现层的一部分,不会被公开调用。这使我们可以灵活地在实现层中组织和重组子模块,并可以安全地知道它们不应被它们的父模块(也就是公共 API)以外的其他任何模块调用。为了保持简单性,重点是将带有副作用的函数保留在仅结构化模块中。如果我们添加 ZoneMealTracker.User.fetch/1 以从数据库中检索用户,就会将公共 API 拆分为多个模块,从而引发多个问题,包括混淆公共 API,以及没有给总体业务逻辑提供明确的位置。相反,我们可以编写 ZoneMealTracker.fetch_user/1,它把查找逻辑直接放在函数上,或者委托给内部数据存储模块上的函数。

一直使用命名空间

之前我们使用命名空间来表示:

  1. 顶层模块是公共 API。
  2. 子模块是:公共 API 引用的仅结构模块;内部辅助模块,是公共 API 的实现细节,不能被公开调用。

这种模式的好处是我们可以在应用的深层继续使用它,并且仍然提供相同的结构和保证。

我们设定的模式其实是:

  1. 当前模块是 API。
  2. 子模块是当前模块的 API 引用的结构、API 的实现所使用的内部模块。
  3. 模块只能访问其直接子级。禁止访问同级、孙级和曾孙级等。如果你需要访问同级,则逻辑应放在下一个更高的命名空间中;如果你需要访问孙级,则子模块需要提供用于该功能的 API。

自然创建的层

以这种方式使用命名空间的好处在于,它会自然创建层。ZoneMealTracker.Notifications.NotificationPreferenceStore 仅专注于存储通知系统的用户首选项,而 ZoneMealTracker 则专注于应用的整体业务逻辑。对于开发者,这种分层结构提供了一些好处:

  1. 除非你关心模块的逻辑实现方式,否则无需关心模块的子级。例如,如果你要调用 ZoneMealTracker.register_user/2 函数,则应该能够相信该用户已正确注册。查看其代码或子模块的唯一理由是需要了解它如何注册用户。
  2. 使代码库易于理解。如果你的老板说“现在我们需要在注册用户时发送指标”,那么 ZoneMealTracker.register_user/2 是集成这个新功能的理想场所。如果这个顶级公共 API 不可用,则用户注册可能会放在 ZoneMealTracker.Accounts.register_user/1 之类的传统上下文中。但这会使 ZoneMealTracker.Accounts 模块更难以理解,因为其某些功能在业务逻辑层运行,而其他功能在持久层运行。现在,开发者必须记住哪些函数是高级的(业务逻辑),哪些是低级的(持久逻辑),并调用适合其当前工作级别的函数。如果模块的 API 仅在单个抽象级别上操作,事情会简单得多。

使实现可交换

现在应用已经通过命名空间分了层,并且每一层上都有定义明确的 API,我们可以进一步允许这些 API 背后的代码替换为不同的实现。

层模式在提及“可交换性”时暗示可以交换实现,Alistair Cockburn 在“六边形架构”一文中详细介绍了这一概念。六边形架构(又名端口和适配器)是说,为了保持应用的灵活性,业务逻辑应通过定义明确的接口与外界(数据库,HTTP API 等)通信。这为开发者提供了极大的灵活性,因为一旦创建了明确定义的接口,就可以将当前实现替换为也符合该明确定义的其他接口实现。在我们的例子中,应用的每一层都通过这些接口下面的层通信,这些较低层的实现可以很容易地交换而不会影响较高层的代码。

在六边形架构图中,我们不讨论高层和较低层,而是将图向左旋转 90 度并说:

  • 更高层通过“左侧端口”在六边形内部调用逻辑。
  • 六边形内部的逻辑通过“右侧端口”调用较低层。

六边形这个形状并不重要,只是易于绘制而已,其中每条边代表一个端口。

原始的 ZoneMealTracker 应用

用新实现替换的 ZoneMealTracker

在右侧端口上交换实现的能力使我们能够:- 轻松对业务逻辑进行单元测试,而无需调用外部服务。

  • 构建返回虚假数据的子层实现,以便在开发应用的较高层时其他团队继续构建较低层。
  • 无需实际编写任何下层实现代码即可巩固下层 API 的函数。这使我们可以推迟技术决策,例如使用哪个数据存储,如何构造数据库表等,直到我们对需要提供的接口有了更清晰的了解。
  • 轻松适应不断变化的业务需求。例如,当企业希望我们迁移到新的 API 提供方时,我们只需在该子层上换上新实现。只要新的实现可以提供与之前相同的代码级接口,替换时就不会影响上层。

在 Elixir 中替换子层的机制

尽管替换不同实现的能力听起来不错,但实际做起来可能很难。我尝试了以下几种方法:- 注入协作函数作为可选参数。

  • 在调用函数之前查找当前实现。
  • 在幕后调用一个 API 模块来委托给当前实现。

作为参考,下面是一个 register_user/2 函数示例,我们将使用不同的实现替换方法来修改它。

代码语言:javascript
复制
spec register_user(String.t(), String.t()) ::
        {:ok, User.t()} | {:error, :email_already_registered}
def register_user(email, password) do
  case AccountStore.create_user(email, password) do
    {:ok, %User{id: user_id} = user} ->
      :ok = Notifications.set_user_email(user_id, email)
      :ok = Notifications.send_welcome_message(user_id)

      {:ok, user}

    {:error, :email_not_unique} ->
      {:error, :email_already_registered}
  end
end

替换方法:将协作函数作为可选参数注入

这种方法会修改函数以接受新参数,以便开发者可以在测试期间替换实现。

代码语言:javascript
复制
@type register_user_opt ::
  {:create_user_fn, (String.t(), String.t() -> {:ok, User.t()} | {:error, Changeset.t}) |
  {:set_primary_notification_email_fn, (String.t(), String.t() -> :ok)} |
  {:send_welcome_message_fn, (String.t() -> :ok)}

@spec register_user(String.t(), String.t()) ::
        {:ok, User.t()} | {:error, :email_already_registered}
def register_user(email, password, opts \\ []) do
  create_user_fn = Keyword.get(opts, :create_user_fn, &AccountStore.create_user/2)
  set_primary_notification_email_fn =
    Keyword.get(opts, :set_primary_notification_email, &Notifications.set_user_email/2)
  send_welcome_message_fn =
    Keyword.get(opts, :send_welcome_message, &Notifications.send_welcome_message/1)

  case create_user_fn.(email, password) do
    {:ok, %User{id: user_id} user} ->

      :ok = set_primary_notification_email_fn.(user_id, email)
      :ok = send_welcome_message_fn.(user_id)

      {:ok, user}

    {:error, :email_not_unique} ->
      {:error, :email_already_registered}
  end
end

好处:

  • 门槛低。
  • 允许将协作函数与其他任何实现互换。不需要定义其他模块。
  • 可以在每个函数调用中与其他实现交换。应用环境中没有全局配置。

缺点:

  • 上手快意味着函数更复杂,更难推理。
  • 需要重大代码更改。
  • 替换的实现容易偏离预期。
  • 不支持透析器。

替换方法:在调用函数之前查找当前实现

这种方法从应用环境中查找包含当前实现的模块。过程发生在调用该函数的模块中。

代码语言:javascript
复制
@spec register_user(String.t(), String.t()) ::
        {:ok, User.t()} | {:error, :email_already_registered}
def register_user(email, password) do
  case account_store().create_user(email, password) do
    {:ok, %User{id: user_id} = user} ->
      :ok = notifications().set_user_email(user_id, email)
      :ok = notifications().send_welcome_message(user_id)

      {:ok, user}

    {:error, :email_not_unique} ->
      {:error, :email_already_registered}
  end
end

defp account_store do
  Application.get_env(:zone_meal_tracker, :account_store, AccountStore)
end

defp notifications do
  Application.get_env(:zone_meal_tracker, :notifications, Notifications)
end

好处:

  • 与 Mox 兼容。

缺点:

  • 调用函数时需要更改代码。
  • 不适用于透析器,因为模块名称是动态解析的。
  • 每个调用者模块必须自己查找当前的实现。这是很大的负担。
  • 当前的实现是通过应用环境全局控制的。所以难以并行运行多个实现。

替换方法:在幕后调用一个 API 模块来委托给当前实现

这种方法使客户端代码无需更改即可继续调用原始模块。原始模块被修改为将函数调用委托给当前实现。这种方法基本上将模块的公共 API 从其实现中分离出来。

在这种方法中,客户端代码保持不变。

代码语言:javascript
复制
@spec register_user(String.t(), String.t()) ::
        {:ok, User.t()} | {:error, :email_already_registered}
def register_user(email, password) do
  case AccountStore.create_user(email, password) do
    {:ok, %User{id: user_id} = user} ->
      :ok = Notifications.set_user_email(user_id, email)
      :ok = Notifications.send_welcome_message(user_id)

      {:ok, user}

    {:error, :email_not_unique} ->
      {:error, :email_already_registered}
  end
end

要调整的是协作模块。

代码语言:javascript
复制
defmodule ZoneMealTracker.AccountStore do
  @moduledoc false

  alias ZoneMealTracker.AccountStore.User

  @behaviour ZoneMealTracker.AccountStore.Impl

  @impl true
  @spec create_user(User.email(), User.password()) ::
          {:ok, User.t()} | {:error, :email_not_unique}
  def create_user(email, password) do
    impl().create_user(email, password)
  end

  defp impl do
    Application.get_env(:zone_meal_tracker, :account_store, __MODULE__.PostgresImpl)
  end
end

然后将实际的实现移到它自己的模块中。

代码语言:javascript
复制
defmodule ZoneMealTracker.AccountStore.PostgresImpl do
  @moduledoc false

  alias Ecto.Changeset
  alias ZoneMealTracker.AccountStore.PostgresImpl.InvalidDataError
  alias ZoneMealTracker.AccountStore.PostgresImpl.Repo
  alias ZoneMealTracker.AccountStore.User

  @behaviour ZoneMealTracker.AccountStore.Impl

  @impl true
  @spec create_user(User.email(), User.password()) ::
          {:ok, User.t()} | {:error, :email_not_unique}
  def create_user(email, password) when is_email(email) and is_password(password) do
    %User{}
    |> User.changeset(%{email: email, password: password})
    |> Repo.insert()
    |> case do
      {:ok, %User{} = user} ->
        {:ok, user}

      {:error, %Changeset{errors: errors}} ->
        if Enum.any?(errors, &match?({:email, {"is_already_registered", _}}, &1)) do
          {:error, :email_not_unique}
        else
          raise InvalidDataError, errors: errors
        end
    end
  end
end

最后,API 和实现与一个行为同步。

代码语言:javascript
复制
defmodule ZoneMealTracker.AccountStore.Impl do
  @moduledoc false

  alias ZoneMealTracker.AccountStore.User

  @callback create_user(User.email(), User.password()) ::
              {:ok, User.t()} | {:error, :email_not_unique}
end

好处:

  • 不需要更改客户端代码。
  • 支持透析器。
  • 在一处交换实现。
  • 与 Mox 兼容。
  • 额外的命名空间可以很容易分开保存多个实现。每个实现都有自己的命名空间,删除实现时只需删除其命名空间。

缺点:

  • 需要更多的层级架构。
  • 向命名空间层次结构添加额外的级别。
  • 当前的实现是通过应用环境全局控制的。所以很难并行运行多个实现。

我发现“在幕后调用一个 API 模块来委托给当前实现”是最有效的方法,后文就会使用这种方法。

单元测试

有了交换机制后,现在我们可以轻松地单独测试 ZoneMealTracker,而无需设置(甚至需要完整的实现)ZoneMealTracker.AccountStore 或 ZoneMealTracker.Notifications。

以下是 ZoneMealTracker 模块及其测试。

代码语言:javascript
复制
# lib/zone_meal_tracker.ex

defmodule ZoneMealTracker do
  @moduledoc """
  公共 API for ZoneMealTracker
  """

  alias ZoneMealTracker.AccountStore
  alias ZoneMealTracker.Notifications

  @spec register_user(String.t(), String.t()) ::
        {:ok, User.t()} | {:error, :email_already_registered}
  def register_user(email, password) do
    case AccountStore.create_user(email, password) do
      {:ok, %User{id: user_id} = user} ->
        :ok = Notifications.set_user_email(user_id, email)
        :ok = Notifications.send_welcome_message(user_id)

        {:ok, user}

      {:error, :email_not_unique} ->
        {:error, :email_already_registered}
    end
  end
end
代码语言:javascript
复制
# test/test_helper.exs

Mox.defmock(ZoneMealTracker.MockAccountStore,
  for: ZoneMealTracker.AccountStore.Impl
)

Application.put_env(
  :zone_meal_tracker,
  :account_store,
  ZoneMealTracker.MockAccountStore
)

Application.put_env(
  :zone_meal_tracker,
  :notifications_impl,
  ZoneMealTracker.MockNotifications
)

Mox.defmock(ZoneMealTracker.MockNotifications,
  for: ZoneMealTracker.Notifications.Impl
)
代码语言:javascript
复制
# test/zone_meal_tracker.exs

defmodule ZoneMealTrackerTest do
  use ExUnit.Case, async: true

  import Mox

  alias ZoneMealTracker
  alias ZoneMealTracker.MockAccountStore
  alias ZoneMealTracker.MockNotifications
  alias ZoneMealTracker.User

  setup [:set_mox_from_context, :verify_on_exit!]

  test "register_user/2 when email is unique" do
    email = "foo@bar.com"
    password = "password"
    user_id = "123"
    user = %User{id: user_id, email: email}

    expect(MockAccountStore, :create_user, fn ^email, ^password ->
      {:ok, user}
    end)

    MockNotifications
    |> expect(:set_user_email, fn ^user_id, ^email -> :ok end)
    |> expect(:send_welcome_message, fn ^user_id -> :ok end)

    assert {:ok, ^user} = ZoneMealTracker.register_user(email, password)
  end

  test "register_user/2 when email is already taken" do
    email = "foo@bar.com"
    password = "password"

    expect(MockAccountStore, :create_user, fn ^email, ^password ->
      {:error, :email_not_unique}
    end)

    assert {:error, :email_already_registered} = ZoneMealTracker.register_user(email, password)
  end
end

由于我们为每个依赖项都提供了清晰的 API,并且可以轻松交换模拟实现,因此很容易用编写代码时所用的抽象级别对业务逻辑进行单元测试。通过交换模拟实现,我们可以轻松地测试 AccountStore.create_user/2 返回{:error, :email_not_unique}时有没有通知发送。该模拟使我们不必先在数据库中使用邮件地址创建用户,因此我们可以确保第二个用户注册失败并且在失败时不会发送任何通知。这种方法使我们能够对各个层单独进行单元测试,并依赖模块之间的合约(API)——由透和 AccountStore.Impl 行为强制执行,由集成测试双重检查——以确保模块按预期运行。这种方法允许高层专注测试来自较低层的已知响应,而无需与底层实现的细节耦合。此外,如果要将帐户账户存储从基于 Postgres 的本地实现迁移到基于 riak 的高可用性实现,无需修改 ZoneMealTracker 中的业务逻辑测试以使其兼容新的 ZoneMealTracker.AccountStore 实现。

一路往下,每层都可做替换

尽管“六边形架构”只是说“六边形内部”的业务逻辑应通过定义良好的 API 与外部系统通信,但这种模式还可以在整个应用的多个层重复。如果我们在每一层都允许替换实现,就会得到一棵整齐的分层依赖关系树,所有依赖项都有可以在任何时候轻松替换的实现。这不仅对测试有很大帮助,而且还使每一层的测试专注于自己的抽象级别,而无需与基础实现耦合。例如,对 ZoneMealTracker.Notifications.send_welcome_message/1 的测试可确保为给定的用户 ID 注册了电子邮件地址,然后向该地址发送欢迎电子邮件。由于我们可以将模拟实现替换为 NotificationPreferenceStore 和 Emails 模块,因此我们的测试不会与通知首选项的存储方式或电子邮件的发送方式耦合。于是它们能够专注于在调用函数时将电子邮件发送给适当用户的业务逻辑。

文件结构反映了应用结构

这种模式的另一个优点是,它使公共 API 模块及其底层实现之间的分离更加清晰。

代码语言:javascript
复制
lib/zone_meal_tracker/
  account_store.ex <- Top level API module
  account_store/
    impl.ex <- Behaviour to be implemented by top level module and impls
    in_memory_impl.ex <- An in-memory implementation
    in_memory_impl/ <- modules used only by this specific implementation
      state.ex
    login.ex <- struct returned by public API
    postgres_impl.ex <- A postgres-backed implementation
    postgres_impl/ <- modules used only by this specific implementation
      domain_translator.ex
      exceptions.ex
      login.ex
      repo.ex
      supervisor.ex
      user.ex
    supervisor.ex <- Helper module used across all implementations (@moduledoc false)
    user.ex <- struct returned by public API

查看模块的文件夹时应注意以下几点:

  • 以 _impl 结尾的内容都是实现。<impl_name>_impl/ 文件夹中的内容都是仅用于该实现的辅助模块。
  • impl.ex 是使顶级模块和实现保持同步的行为。
  • 文件夹中的其他大多数模块(application.ex 或 supervisor.ex 这样的已知模块除外)都是 API 引用的仅结构模块。

这种结构和统一性使我们瞟一眼文件树就可以轻松理解每一层。此外,随着我们对需要提供的 API 的理解加深,我们可能从 InMemoryImpl 开始,然后扩展到 PostgresImpl。这种结构允许在仍使用 InMemoryImpl 的情况下单独开发 PostgresImpl。当需要从 InMemoryImpl 切换到 PostgresImpl 时,在顶层模块中更改一行代码,将默认实现设置为 PostgresImpl 即可(也可以通过应用环境更改以方便回滚)。迁移完成后,我们要删除旧的实现,只需删除 in_memory_impl.ex 和 in_memory_impl/。由于整个实现都包含在该文件和文件夹中,因此删除它非常简单。这个想法最初得到了 Johnny Winn 演讲的启发:“删除它即可”

集成测试

尽管每一层都经过了完整的单元测试,并且透析器正在各层之间进行类型检查,但是进行一些端到端测试以确保所有默认实现均按预期集成仍然很重要。这很简单,只需编写一个自动测试过一遍用户注册流程来确保一切正常。

由于我们的测试会操纵应用环境以在各个层替换模拟实现,一般来说最好在 umbrella 应用外部安装一个集成测试器,以在干净的环境中启动应用。

ZoneMealTracker 应用的 integration_tester 文件夹中有一个集成测试器示例。

如果你需要基于浏览器的集成测试,可以考虑 Wallaby 和 Hound 这两款出色的工具;它们可让你通过真正的 Web 浏览器来驱动应用。如果你要编写带有 API的应用,可以构建一个 API 客户端以简化集成测试(需要从另一个 Elixir 应用调用 API 时也非常有用)。

技巧和窍门

这种模式很管用,并且在维护长寿命应用时提供了灵活性;另外在面对这种结构的应用时需要记住一些事项。

仅在需要时添加交换机制

大家一开始会倾向在每个模块之间添加交换机制。但太多的交换机制只会带来不必要的痛苦和挫败感。只在以下内容之间插入交换机制:

  1. 业务逻辑层和延伸到外部服务(API,数据库等)的层。
  2. 不同的业务逻辑层。例如,开发者可以为 ZoneMealTracker.Notifications 插入交换机制,以便单独测试其父层 ZoneMealTracker。在测试 ZoneMealTracker 时,我们不在乎通知的发送方式,只是在 ZoneMealTracker.Notifications 上调用适当的函数。

很多时候还有像 ZoneMealTracker.Notifi-cations.Logger.Formatter 这样的辅助模块,它们仅包含从其父级提取的纯函数(ZoneMealTracker.Notifications.Logger)。这些模块不与任何外部服务或系统的任何部分交互,因此不需要交换逻辑。向这些模块添加交换逻辑只会使测试和整个应用结构复杂化。

将实现细节保留在 API 之外

不要让实现细节泄漏到应用的 API 中也很重要。如果开发者无视纪律,在 API 中返回了 Ecto 模式和变更集,则他们可能无法换出基于 ecto 的本地帐户存储,无法让新的实现通过 HTTP 扩展到帐户微服务。关键是要确保通过公共 API 接受或返回的结构与基础实现之间没有任何关联。这意味着任何 Ecto 模式或变更集都不应泄漏到公共 API 中,因为它们会阻止我们切换到非 ecto 数据存储上。

处理长命名空间

将应用分层到适当的抽象级别(完成交换逻辑)之后,有时我们的命名空间会变得很深。当嵌套 3 个或更多可交换层时,模块名称可能会变得很长且很麻烦。

代码语言:javascript
复制
defmodule ZoneMealTracker.DefaultImpl.Notifications.DefaultImpl
            .NotificationPreferenceStore.PostgresImpl.PrimaryEmail do

end

这时可以选择一个逻辑清晰的独立层,如 ZoneMealTracker.DefaultImpl.Notifications,并将其从 umbrella 提取到自己的应用中。为了表明它是内部应用,我在应用前加上了项目的首字母缩写,如 ZMTNotifications。这种提取不仅缩短了命名空间的长度,而且还带来了其他一些好处:

  • 每个内部应用都有自己的 mix.exs,可以轻松分辨出哪一层引入了依赖项。
  • 诸如 API 客户端之类的独立库可以轻松地从 umbrella 提取到自己的项目中,并发布到 Hex。
  • 即使在内部应用上,它的顶层模块也公开了系统其余部分可以使用的 API。由于此模块功能够多,值得提取,因此我还添加了有关该顶级内部 API 的文档。现在,当开发者为项目生成 ExDoc 时,他们不仅会看到主要的公共 API(ZoneMealTracker),还能看到他们可以使用的内部 API(如 ZMTNotifications)。

API 模块中没有逻辑

构建 API 模块时,其函数应仅是当前实现的传递。如果开发者向 API 模块添加逻辑,则当他们想从函数返回特定数据时也必须牢记这一逻辑。下面是一个例子:

代码语言:javascript
复制
# API Module that contains logic
defmodule ZMTNotifications do
  @spec fetch_registered_email(String.t()) | {:ok, String.t()} | {:error, :not_found}
  def fetch_registered_email(email) do
    current_impl().fetch_registered_email(
  end

  @spec email_registered?(String.t) :: boolean
  def email_registered?(email) do
    # This contains logic instead of a passthrough
    match?({:ok, _}, fetch_email_registered(email)
  end
end

# Module being tested
defmodule ZoneMealTracker do

  @spec email_registered?(String.t) :: boolean
  def email_registered?(email) do
    # Code under test
    ZMTNotifications.email_registered?(email)
  end
end

# Test Case
defmodule ZoneMealTrackerTest do
  use ExUnit.Case
  import Mox
  alias ZoneMealTracker.MockZMTNotifications

  test "email_registered?/1 returns true when email is registered" do
    # Although the code under test is calling
    # `ZMTNotifications.email_registered?/1`, we have to know to mock
    # `fetch_registered_email/1` because there is logic in
    # `ZMTNotifications.email_registered?/1` instead of being just a straight
    # passthrough.

    expect(MockZMTNotifications, :fetch_registered_email, fn email -> {:ok, email})

    assert ZoneMealTracker.email_registered?("foo@bar.com")
  end
end

从上面的示例中,我们不能只希望 MockZMTNotifications.email_registered?/1 返回我们想要的值,而是需要知道基础实现调用 MockZMTNotifications. fetch_registered_email/1。

为了使 MockZMTNotifications.email_register-ed?/1 返回 true,实际上我们必须使 MockZMTNotifications.fetch_registered_email/1 返回{:ok, email}。为了测试一个看似简单的函数,引入了许多不必要的耦合和复杂性。我将在 API 模块中放入的唯一逻辑是保护子句以确保将适当的数据类型传递给基础实现。当使用无效数据调用 API 模块上的函数时,这会使错误消息更易理解,并防止我们忘记保护实际实现中的子句。除此之外,API 模块上的函数应该是当前实现的完整传递。

与之前工作的关系

在软件设计中,大多数理念都是将某人的早期工作应用在稍有不同的环境中。Greg Young 的“破坏软件的艺术”是一次精彩的演讲,讨论了将大型系统分解为一组微型程序(组件),每个微型程序(组件)都可以在大约一周内删除和重写,从而使大型系统的各个部分小巧易懂。且容易适应不断变化的业务需求。

应用分层的思想与这一哲学相似。通过应用分层,我们将应用构建为可以随时替换的组件树。例如,如果我们想更改 ZMTNotifi-cations.DefaultImpl.NotificationPreferenceStore 以写入 riak 数据库而非 postgres 数据库,则可以编写新的 ZMTNotifications.Default-Impl.NotificationPreferenceStore.RiakImpl 模块,并在准备好时把它替换进去。从 postgres 到 riak 的转换完成后,我们可以删除 ZMTNotifications.DefaultImpl.NotificationPreferenceStore.PostgresImpl 及其所有子模块。只要将我们的数据库逻辑隔离成一个小组件,我们也能像 Greg 所说的那样,只需一周即可删除和重写一个组件。

更进一步,当业务需求变化时,我们可能需要完全更改通知系统的设计。有了这套交换基础结构,我们可以定义一个新的 ZMTNotifica-tions.EnhancedImpl 模块并开始开发新的通知系统。与 ZMTNotifications.DefaultImpl 实现相比,此实现可能需要单独的数据存储和服务,但它们都可以塞在 ZMTNotifications.Enhanced-Impl 命名空间下。只要它能满足 ZMTNotifications.Impl 行为定义的约定,我们就可以随意重写此组件树,而不会影响任何上游层。另外,由于此组件与系统的其余部分隔离,因此很多时候也只用一周即可重写它。

在 Greg 的演讲中,他提到了在一周内重写应用中微型程序(组件)的好处。在应用分层中,每个可交换的实现都类似那些微型程序。Greg 提到“出色代码和糟糕代码之间的区别在于程序(层)的大小。”如果每一层仅专注于单个抽象级别(业务逻辑,持久性等),就可以保持小巧的规模并能随需求变化轻松替换。这种结构使开发者摆脱了庞大而复杂的代码库的束缚,使他们可以在需要更改时只重写系统的一小部分。根据我的经验,这种应用写起来会非常舒心。

最后的建议

构建软件是一个过程——正确的软件设计会随着你的进展而逐渐清晰可见。我曾试图跳过一些步骤并提前创建层,结果发现自己走错了路,只得重写代码。所以我开始遵循以下流程:

  1. 应通过提取私有函数使公共函数更易阅读。
  2. 如果已提取了几个相关的私有函数,或者需要为这些私有函数编写单元测试,则将其提取到子模块中。
  3. 如果需要调用外部服务或业务逻辑的某个部分,从而难以编写针对业务逻辑的测试,那么就在模块中插入一个可交换层,作为业务逻辑与外部服务 / 独立域之间的边界。

编写一个模块时要问自己“这个模块的公共 API 是什么?”。这样就能更好地思考支持该系统的其余部分需要履行的合约,同时还可以灵活地尝试实现。面对较高层时,我将调用子层应有的函数,然后在巩固该子层的 API 之后再执行较低层的逻辑。这样就能确保子层 API 有意义。

尽管以这种方式设计系统需要动脑筋,还要守纪律,但它会让代码库非常灵活,且易于调整和维护。我鼓励开发复杂应用的开发者在应用中的某处尝试这种模式——可以是外部服务的边界(例如 API 客户端),也可以是应用的最高边界(在 Web 层和业务逻辑之间)。你会越来越熟练,越来越明白该如何构建系统的最佳结构。不出意外的话,最后你会获得一个灵活、可适应的代码库。此外不要忘记添加一些完整的堆栈集成测试,以确保你的应用在重构代码库时继续工作。

编程愉快!

资源

原文链接: https://aaronrenner.io/2019/09/18/-application-layering-a-pattern-for-extensible-elixir-application-design.html

  • 发表于:
  • 本文为 InfoQ 中文站特供稿件
  • 首发地址https://www.infoq.cn/article/ZmAONCLFca7qYQIg4y70
  • 如有侵权,请联系 cloudcommunity@tencent.com 删除。

扫码

添加站长 进交流群

领取专属 10元无门槛券

私享最新 技术干货

扫码加入开发者社群
领券