16、异步作业与测试:Oban与Elixir测试框架的实践

异步作业与测试:Oban与Elixir测试框架的实践

在开发应用程序时,异步作业和测试是两个至关重要的方面。异步作业可以提高应用程序的性能和响应能力,而测试则能确保应用程序的正确性和稳定性。本文将深入探讨Oban这个强大的异步作业处理库,并介绍Elixir内置的测试框架。

1. Oban vs. Sidekiq

在Ruby开发中,Sidekiq库已经存在了一段时间,开发者们对它非常熟悉。它在生产环境中极其稳定,每秒能够处理大量的作业。Sidekiq使用Redis来存储和处理作业,而Oban则基于Postgres数据库运行。尽管Redis和Postgres的操作方式有所不同,但大规模使用Oban并不困难。Elixir社区的实践证明,Oban是一个稳定的作业系统,能够处理大量的作业。此外,它还具备事务处理能力,并且无需运行Redis服务器,这些优势使其成为一个极具吸引力的作业系统。

1.1 实现Oban Worker

在我们的SMS应用中实现Oban Worker,需要以下步骤:

1.1.1 添加Oban到SMS应用
  • 步骤1 :在 mix.exs 中添加Oban依赖:
{:oban, "~> 2.16"},
  • 步骤2 :运行 mix deps.get 将其引入项目。
  • 步骤3 :创建新的迁移文件:
$ mix ecto.gen.migration add_oban_jobs_table
  • 步骤4 :在迁移文件中添加以下代码:
defmodule PhoneApp.Repo.Migrations.AddObanJobsTable do
  use Ecto.Migration

  def up do
    Oban.Migration.up(version: 11)
  end

  def down do
    Oban.Migration.down(version: 1)
  end
end
  • 步骤5 :运行 mix ecto.migrate 执行迁移。
  • 步骤6 :在 config.exs 中添加Oban队列配置:
config :phone_app, Oban,
  repo: PhoneApp.Repo,
  plugins: [
    # 1 hour
    {Oban.Plugins.Pruner, max_age: 60 * 60}
  ],
  queues: [default: 10]
  • 步骤7 :更新 test.exs 配置:
config :phone_app, Oban, testing: :manual
  • 步骤8 :在 lib/phone_app/application.ex start 函数中添加Oban Supervisor:
{Finch, name: PhoneApp.Finch},
{Oban, Application.fetch_env!(:phone_app, Oban)},
PhoneAppWeb.Endpoint
  • 步骤9 :验证配置是否正确:
$ iex -S mix
iex> Oban.config()
%Oban.Config{
  ...
}
1.1.2 编写Oban Worker

我们的SMS应用在发送消息时存在问题,消息不会立即送达,因此需要跟踪消息的状态。当发送消息时,我们将使用异步作业来检查消息的状态,直到其最终确定。以下是具体实现:

defmodule PhoneApp.Conversations.Worker.StatusWorker do
  use Oban.Worker
  alias PhoneApp.Conversations.Schema.SmsMessage

  def enqueue(%SmsMessage{} = message) do
    %{"id" => message.id}
    |> new()
    |> Oban.insert()
  end

  alias PhoneApp.Conversations.Query.SmsMessageStore
  def perform(%Oban.Job{args: %{"id" => message_id}}) do
    message = SmsMessageStore.get_sms_message!(message_id)
    %{body: resp} = PhoneApp.Twilio.get_sms_message!(message)
    case resp["status"] do
      "queued" ->
        {:error, "Message not ready"}
      status ->
        PhoneApp.Conversations.update_sms_message(
          message.message_sid,
          %{status: status}
        )
    end
  end
end

同时,需要添加 SmsMessageStore.get_sms_message!/1 函数:

def get_sms_message!(id) do
  Repo.get!(SmsMessage, id)
end
1.1.3 短信创建后入队作业

我们的作业应该在创建具有特定属性( status = queued direction = outgoing )的SMS消息时入队。在 lib/phone_app/conversations/conversations.ex 文件中,修改 create_sms_message/1 函数:

def create_sms_message(params) do
  PhoneApp.Repo.transaction(fn ->
    with {:ok, message} <- Query.SmsMessageStore.create_sms_message(params),
         {:ok, _} <- maybe_enqueue_status_worker(message) do
      message
    else
      {:error, cs} -> PhoneApp.Repo.rollback(cs)
    end
  end)
end

defp maybe_enqueue_status_worker(message) do
  case message do
    %{direction: :outgoing, status: "queued"} ->
      PhoneApp.Conversations.Worker.StatusWorker.enqueue(message)
    _ ->
      {:ok, :skipped}
  end
end

1.2 运行效果

启动 phone_app 应用和 mock_server 应用,访问 http://localhost:4004/messages 创建新的SMS消息,然后访问 http://localhost:4005/ 查看消息。最初,SMS应用会显示消息处于排队状态,短时间后刷新页面,会发现“queued”状态消失,系统正确识别消息已送达。

1.3 Oban的更多特性

Oban不仅易于上手,还提供了许多强大的功能,部分功能是开源的,还有付费版本提供更多高级功能。

1.3.1 遥测(开源)

可观测性是一个优秀作业系统的基本要求。Oban使用标准化的Telemetry库,让你可以选择要监控的事件。以下是一个示例:

defmodule Super.Application do
  use Application

  def start(_type, _args) do
    events = [
      [:oban, :job, :start],
      [:oban, :job, :stop],
      [:oban, :job, :exception]
    ]
    :telemetry.attach_many("oban-logger", events,
      &Super.ObanLogger.handle_event/4, [])
    # rest of application setup
  end
end

defmodule Super.ObanLogger do
  require Logger

  def handle_event([:oban, :job, :start], _measure, meta, _) do
    Logger.info("[Oban] started #{meta.worker}")
  end

  def handle_event([:oban, :job, event], measure, meta, _) do
    ms = ceil(measure.duration / 1_000_000)
    Logger.info("[Oban] #{event} #{meta.worker} ran in #{ms}ms")
  end
end
1.3.2 CRON作业(开源)

Oban提供了CRON插件,方便按重复计划运行作业。以下是CRON格式的示例:

config :my_app, Oban,
  plugins: [
    {Oban.Plugins.Cron,
      crontab: [
        {"* * * * *", MyApp.MinuteWorker},
        {"0 0 * * *", MyApp.DailyWorker, max_attempts: 1},
        {"0 12 * * MON", MyApp.MondayWorker, queue: :scheduled},
      ]}
  ]
1.3.3 恢复卡住的作业(开源)

作业只有在完全处理或失败足够多次后才会从系统中移除。但在应用程序重启或遇到外部问题时,作业可能会卡住。Oban的Lifeline插件可以恢复这些卡住的作业,这是免费版本的Sidekiq所不具备的优势。

1.3.4 网页界面(付费)

Oban提供付费的网页界面,类似于Sidekiq的专业版。该界面虽不开源,但费用支持Oban的开发,会定期添加新功能并保持界面的更新。

1.3.5 专业功能(付费)

付费版本的Oban提供了一系列强大的专业功能,如更强大的执行引擎、批量作业支持、严格有序的作业执行和扇入扇出工作流等。

1.4 总结

在开发应用程序时,异步作业是必不可少的。Elixir提供了构建高度并行系统的基础,但建议使用像Oban这样的现成库,它提供了受控并发、事务处理和审计等功能。Oban易于上手,同时提供了许多高级功能,能帮助你的应用程序不断发展。

以下是Oban配置和使用的流程图:

graph LR
    A[添加Oban依赖] --> B[创建迁移文件]
    B --> C[执行迁移]
    C --> D[配置Oban队列]
    D --> E[更新测试配置]
    E --> F[添加Oban Supervisor]
    F --> G[验证配置]
    G --> H[编写Oban Worker]
    H --> I[短信创建后入队作业]

1.5 测试Elixir

测试对于确保应用程序的正确性和可预测性至关重要。Elixir拥有优秀的内置测试框架ExUnit,以下是使用ExUnit进行测试的详细介绍。

1.5.1 创建第一个测试

在开始测试之前,我们先比较一下Ruby和Elixir的测试理念。Ruby的测试历史上主要由RSpec主导,但近年来Minitest逐渐复兴。Minitest提供最少的断言,使测试保持简单;而RSpec则提供更全面的断言库和多种数据设置选项。Elixir的测试框架ExUnit随语言一起提供,无需额外安装。它类似于Minitest,使用简单的断言和最少的测试用例设置选项,这种简约的方法被证明非常强大,易于学习,并且能让你编写简洁、富有表现力的测试。

1.5.2 测试NewMessage Changeset

我们的第一个测试是针对Ecto changeset的。这个测试不涉及数据库或网络请求,只需要调用一个函数并检查其输出。

  • 步骤1 :验证测试环境是否正确设置,运行 mix test 确保现有测试通过:
$ mix test
.....
Finished in 0.08 seconds (0.04s async, 0.04s sync)
5 tests, 0 failures

如果遇到错误,可能是数据库设置问题,可运行 MIX_ENV=test mix do ecto.drop, ecto.create, ecto.migrate 重置测试数据库。

  • 步骤2 :创建测试文件 test/phone_app/conversations/schema/new_message_test.exs 并添加以下代码:
defmodule PhoneApp.Conversations.Schema.NewMessageTest do
  use ExUnit.Case, async: true
  alias PhoneApp.Conversations.Schema.NewMessage
end

注意文件名必须使用 .exs 扩展名。

  • 步骤3 :添加测试用例,测试必填字段、自动添加国家代码和电话号码验证:
describe "changeset/1" do
  test "fields are required" do
    cs = NewMessage.changeset(%{})
    assert [
      to: {"can't be blank", _}, body: {"can't be blank", _}
    ] = cs.errors
  end

  test "the country code is added if not present" do
    assert %{
      errors: [],
      changes: %{to: "+1 5005550006"}
    } = NewMessage.changeset(%{"body" => "test", "to" => "5005550006"})
  end

  test "the phone number is validated" do
    assert %{
      errors: [to: {"is an invalid phone number", _}]
    } = NewMessage.changeset(%{"body" => "test", "to" => "+1 111-222-3333"})
  end
end

运行 mix test 确保新测试通过。

1.5.3 深入了解变更集测试

我们使用 describe/2 宏对测试进行分组,使用 test/2 宏定义单个测试用例。在测试用例中,我们可以直接访问应用程序的模块和函数,使用 assert/1 函数进行断言。 assert/1 函数既可以进行简单的相等断言,也可以使用模式匹配。模式匹配断言的强大之处在于,当模式不匹配时,ExUnit会以易读的、彩色编码的格式显示差异,让你清楚地知道左右两侧的不同之处。

以下是测试流程的表格总结:
| 步骤 | 操作 | 命令/代码 |
| ---- | ---- | ---- |
| 1 | 验证测试环境 | mix test |
| 2 | 创建测试文件 | test/phone_app/conversations/schema/new_message_test.exs |
| 3 | 添加测试用例 | 见上述代码 |
| 4 | 运行测试 | mix test |

1.6 测试原则

并非所有内容都需要测试,需要在测试价值、工作量和业务价值之间找到平衡。通常,不建议测试框架本身,而是测试应用程序实现的功能,如必填字段和验证逻辑。当不确定是否需要测试时,最好进行测试,但不要让测试成为完成功能的障碍。

通过以上内容,我们了解了Oban的使用和Elixir的测试方法,这些知识将帮助你构建更稳定、可靠的应用程序。

2. 测试不同场景

2.1 测试Ecto查询

在应用中,Ecto查询是常见的操作。为了测试Ecto查询,我们需要创建一个测试环境,模拟数据库操作。以下是一个简单的示例,假设我们有一个 User 模块,并且想要测试一个查询用户的函数。

首先,创建测试文件 test/phone_app/users/query/user_query_test.exs

defmodule PhoneApp.Users.Query.UserQueryTest do
  use ExUnit.Case, async: true
  alias PhoneApp.Users.Query.UserQuery
  alias PhoneApp.Repo

  setup do
    :ok = Ecto.Adapters.SQL.Sandbox.checkout(Repo)
    Ecto.Adapters.SQL.Sandbox.mode(Repo, {:shared, self()})
    :ok
  end

  test "get user by id" do
    user = Repo.insert!(%PhoneApp.Users.Schema.User{name: "Test User", email: "test@example.com"})
    result = UserQuery.get_user_by_id(user.id)
    assert result.id == user.id
  end
end

在这个测试中,我们使用了 Ecto.Adapters.SQL.Sandbox 来隔离测试环境,确保每个测试用例都有独立的数据库状态。 setup 函数会在每个测试用例执行前运行,用于设置数据库沙箱。

2.2 测试外部API请求

当应用需要与外部API进行交互时,我们需要模拟这些请求,以确保测试的独立性和可重复性。可以使用 Mox 库来模拟外部API客户端。

首先,添加 Mox 依赖到 mix.exs

{:mox, "~> 1.0", only: :test}

然后运行 mix deps.get 安装依赖。

创建一个模拟模块 test/phone_app/mocks/twilio_mock.exs

defmodule PhoneApp.Mocks.TwilioMock do
  use Mox

  def expect_get_sms_message!(message) do
    expect(:get_sms_message!, fn ^message ->
      %{body: %{"status" => "delivered"}}
    end)
  end
end

在测试文件中使用模拟模块:

defmodule PhoneApp.Conversations.Worker.StatusWorkerTest do
  use ExUnit.Case, async: true
  import Mox

  alias PhoneApp.Conversations.Worker.StatusWorker
  alias PhoneApp.Conversations.Schema.SmsMessage

  setup :verify_on_exit!

  test "perform job when message is delivered" do
    message = %SmsMessage{id: 1, message_sid: "123"}
    PhoneApp.Mocks.TwilioMock.expect_get_sms_message!(message)
    result = StatusWorker.perform(%Oban.Job{args: %{"id" => message.id}})
    assert {:ok, _} = result
  end
end

在这个测试中,我们使用 Mox 来模拟 PhoneApp.Twilio.get_sms_message! 函数的返回值,从而避免实际的API请求。

2.3 测试Phoenix请求

Phoenix是一个流行的Elixir Web框架,测试Phoenix请求可以确保路由和控制器的正确性。

创建测试文件 test/phone_app_web/controllers/user_controller_test.exs

defmodule PhoneAppWeb.UserControllerTest do
  use PhoneAppWeb.ConnCase

  test "GET /users", %{conn: conn} do
    conn = get(conn, Routes.user_path(conn, :index))
    assert html_response(conn, 200) =~ "Users"
  end
end

在这个测试中,我们使用 PhoneAppWeb.ConnCase 来创建一个模拟的HTTP连接,然后发送一个GET请求到 /users 路由,并验证响应状态码和响应内容。

3. 测试策略总结

3.1 测试类型总结
测试类型 描述 示例
单元测试 测试单个函数或模块的功能 测试Ecto changeset、Ecto查询
集成测试 测试多个模块之间的交互 测试Phoenix请求、外部API请求
端到端测试 模拟用户在应用中的实际操作 使用工具如Wallaby进行浏览器自动化测试
3.2 测试流程总结
graph LR
    A[编写测试用例] --> B[设置测试环境]
    B --> C[执行测试]
    C --> D[验证结果]
    D --> E{测试是否通过}
    E -- 是 --> F[完成测试]
    E -- 否 --> G[调试修复]
    G --> A

4. 结论

在开发过程中,异步作业和测试都是不可或缺的环节。Oban为我们提供了一个强大而稳定的异步作业处理方案,它基于Postgres数据库,支持事务处理,并且提供了丰富的开源和付费功能。同时,Elixir的内置测试框架ExUnit让我们能够方便地编写各种类型的测试,确保应用程序的正确性和可维护性。

通过合理运用Oban和ExUnit,我们可以构建出高效、稳定、可靠的应用程序。在实际开发中,我们应该根据应用的需求和复杂度,选择合适的测试策略和工具,不断优化测试流程,提高开发效率和软件质量。

希望本文介绍的内容能够帮助你更好地理解和应用Oban和ExUnit,让你的Elixir开发之旅更加顺利。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值