32、Elixir项目:依赖管理、连接池适配与Web服务器搭建

Elixir项目:依赖、连接池与Web服务器搭建

Elixir项目:依赖管理、连接池适配与Web服务器搭建

1. 依赖管理

在Elixir项目中,外部依赖是通过元组来指定的。以下是一个示例,展示了如何在 Todo.MixProject 模块中指定依赖:

defmodule Todo.MixProject do
  ...
  defp deps do
    [
      {:poolboy, "~> 1.5"}
    ]
  end
end

在这个元组中,第一个元素是一个原子,对应依赖的应用程序名称;第二个元素 "~> 1.5" 是版本要求,表示需要版本1.5或任何后续的1.x版本。关于版本语法的更多信息,可以查看官方文档: https://hexdocs.pm/elixir/Version.html#module-requirements

指定项目依赖于外部库后,需要获取这些依赖。可以通过在命令行运行 mix deps.get 来完成。依赖是从Elixir的外部包管理器Hex( https://hex.pm )获取的,其他可能的依赖源包括GitHub仓库、Git仓库或本地文件夹。更多信息可查看官方文档: https://hexdocs.pm/mix/Mix.Tasks.Deps.html

运行 mix deps.get 会递归地获取所有依赖,并将每个依赖的确切版本引用存储在 mix.lock 文件中。如果 mix.lock 文件已经存在于磁盘上,则会参考该文件来获取依赖的正确版本,以确保在不同机器上实现可重复的构建。因此,要确保将 mix.lock 文件包含在项目所在的源代码控制中。

获取所有依赖后,可以通过运行 mix compile 来构建整个系统,它会编译所有依赖和项目。值得注意的是,Poolboy是一个Erlang库,但 mix 仍然知道如何编译它。

2. 适配连接池

2.1 Poolboy工作流程

使用Poolboy需要启动一个名为池管理器的进程,该进程管理一组工作进程。启动池管理器时,需要传递所需的池大小(工作进程的数量)和为每个工作进程提供支持的模块。在启动过程中,池管理器会将工作进程作为其子进程启动。

其他进程可以向池管理器请求一个工作进程的PID,这个操作称为 checkout 。一旦进程获得工作进程的PID,就可以向该工作进程发出请求。当客户端进程不再需要工作进程时,会通知池管理器,这个操作称为 checkin

与简单的连接池相比,这个工作流程更加复杂。 checkout checkin 操作使池管理器能够跟踪哪些工作进程正在被使用。如果有可用的工作进程,客户端可以立即获取一个;否则,如果所有工作进程都已被签出,客户端将不得不等待。一旦有工作进程被返回池,等待的客户端将签出该工作进程。

Poolboy还依赖于监视器和链接来检测客户端的终止。如果客户端签出一个工作进程后崩溃,池管理器进程会检测到并将工作进程返回池。同样,如果工作进程崩溃,会启动一个新的工作进程。

2.2 启动池管理器

要在监督树中启动池管理器,可以调用 :poolboy.start_link ,但有一个更优雅的方法是调用 :poolboy.child_spec/3 ,它描述了如何启动Poolboy。以下是如何将数据库切换到由Poolboy驱动的连接池的示例:

defmodule Todo.Database do
  @db_folder "./persist"
  def child_spec(_) do
    File.mkdir_p!(@db_folder)
    :poolboy.child_spec(
      __MODULE__,
      [
        name: {:local, __MODULE__},
        worker_module: Todo.DatabaseWorker,
        size: 3
      ],
      [@db_folder]
    )
  end
  ...
end
  • 传递给 :poolboy.child_spec/3 的第一个参数是子进程的ID,这里使用模块名 Todo.Database 作为ID。
  • 第二个参数是池选项:
  • :name 选项表示池管理器进程应在本地注册,这样无需知道其PID就可以与其交互。
  • :worker_module 选项指定为每个工作进程提供支持的模块。
  • :size 选项指定池的大小。
  • 最后一个参数是一个参数列表,在启动每个工作进程时传递给其 start_link 函数。这里表示需要三个工作进程,每个工作进程由 Todo.DatabaseWorker 模块提供支持,池管理器将通过调用 Todo.DatabaseWorker.start_link(@db_folder) 来启动每个工作进程。

值得注意的是,进行此更改后,不再需要 Todo.Database.start_link ,因为新规范表明应通过调用 :poolboy.start_link 来启动数据库。

2.3 调整存储和获取函数

接下来,需要调整 Todo.Database store get 函数。以前,这些函数会选择一个工作进程ID,然后将该ID转发到 Todo.DatabaseWorker 模块中的相应函数。现在,这些函数需要从池中签出工作进程,向工作进程发出请求,并将工作进程返回池。所有这些操作都可以通过 :poolboy.transaction/2 函数轻松完成:

defmodule Todo.Database do
  ...
  def store(key, data) do
    :poolboy.transaction(
      __MODULE__,
      fn worker_pid ->
        Todo.DatabaseWorker.store(worker_pid, key, data)
      end
    )
  end
  def get(key) do
    :poolboy.transaction(
      __MODULE__,
      fn worker_pid ->
        Todo.DatabaseWorker.get(worker_pid, key)
      end
    )
  end
end

调用 :poolboy.transaction/2 时,传递池管理器的注册名称,这将发出一个 checkout 请求以获取一个工作进程。一旦有工作进程可用,就会调用提供的匿名函数。匿名函数执行完毕后, :poolboy.transaction/2 会将工作进程返回池。

在提供的匿名函数中,获取签出的工作进程的PID,并发出 Todo.DatabaseWorker 请求。这意味着需要稍微更改 Todo.DatabaseWorker 的实现。以前,该模块的函数接受一个工作进程ID,然后进行注册表查找以找到工作进程。在这个版本中,PID的发现由池完成,因此不再需要注册工作进程或进行任何查找来发现进程:

defmodule Todo.DatabaseWorker do
  use GenServer
  def start_link(db_folder) do
    GenServer.start_link(__MODULE__, db_folder)
  end
  def store(pid, key, data) do
    GenServer.cast(pid, {:store, key, data})
  end
  def get(pid, key) do
    GenServer.call(pid, {:get, key})
  end
  ...
end

通过这些更改,就完成了连接池实现的调整。客户端模块 Todo.System Todo.Server 的代码以及测试代码保持不变,这得益于将实现细节隐藏在接口函数后面。

2.4 可视化系统

拥有完整的OTP应用程序后,可以使用名为 observer 的工具来可视化它,该工具是标准Erlang/OTP发行版的一部分。

首先启动系统,然后创建两个待办事项服务器:

$ iex -S mix
iex(1)> Todo.Cache.server_process("Alice")
iex(2)> Todo.Cache.server_process("Bob")

然后启动 observer 工具:

iex(3)> :observer.start()

会出现一个GUI窗口,显示系统的一些基本信息。点击 Applications 选项卡,可以看到应用程序的监督树。例如,两个顶级进程(PID为 <0.280.0> <0.281.0> )是OTP用于管理应用程序的进程,第三个进程(PID为 <0.282.0> )是顶级 Todo.System 监督器,然后可以看到四个子进程:PID为 <0.283.0> 的进程、 Todo.Cache Todo.Database Todo.ProcessRegistry

如果可用, observer 会使用进程的注册名称;否则,会使用进程PID来显示进程。例如,PID为 <0.283.0> 的第一个进程是由 Todo.Metrics 模块提供支持的指标报告器。在缓存下面,可以看到两个子进程,它们是刚刚从shell启动的待办事项服务器。可以通过双击进程框并在新窗口中点击 State 按钮来轻松验证这一点,在最后一行将看到进程状态,其中包含待办事项列表的名称。

observer 工具对于可视化运行系统的行为很有用。例如,在 Processes 选项卡中,可以看到系统中运行的所有进程列表,并轻松找出哪些进程非常繁忙或使用大量内存,该选项卡常用于查找系统中的瓶颈。甚至可以使用 observer 来可视化生产环境中运行的系统。

3. 构建Web服务器

3.1 选择依赖

可以从头开始实现整个服务器,但这会工作量过大。因此,可以使用一些现有的库来简化工作。有多个适用于Elixir和Erlang的Web服务器框架和库。如果计划进行任何严肃的生产开发,应该考虑使用Phoenix框架( https://phoenixframework.org/ ),它功能多样且高度模块化,是为各种Web服务器提供支持的绝佳选择。但为了简单起见,这里选择两个较低级别的库。

第一个是名为Cowboy的Erlang库( https://github.com/extend/cowboy ),它是一个相当轻量级且高效的HTTP服务器库,在Erlang和Elixir生态系统中是HTTP服务器的流行选择。但不会直接使用Cowboy,而是通过Plug库( https://github.com/elixir-lang/plug )与其交互,Plug提供了一个统一的API,隐藏了实际HTTP服务器实现的细节。

可以通过一个依赖将这两个库添加到项目中:

defmodule Todo.Mixfile do
  ...
  defp deps do
    [
      {:poolboy, "~> 1.5"},
      {:plug_cowboy, "~> 2.6"}
    ]
  end
end

添加了一个名为 plug_cowboy 的新依赖,该库提供了Plug和Cowboy之间的粘合代码。在内部,该库依赖于Plug和Cowboy库,因此它们是项目的传递依赖,不需要显式列出, mix 工具可以自动解析。

3.2 启动服务器

配置好依赖后,可以运行 mix deps.get 并开始实现HTTP接口。如前所述,与Cowboy交互的主要接口是Plug。Plug是一个相当复杂的库,这里不会深入介绍。重点是让一个基本版本运行起来,并理解所有组件是如何协同工作的。

要启动由Plug和Cowboy驱动的服务器,可以使用 Plug.Cowboy.child_spec/1 函数,它返回一个子规范,描述如何启动负责系统HTTP服务器部分的进程。通常,最好将其包装在一个专用模块中:

defmodule Todo.Web do
  ...
  def child_spec(_arg) do
    Plug.Cowboy.child_spec(
      scheme: :http,
      options: [port: 5454],
      plug: __MODULE__
    )
  end
  ...
end

传递给 Plug.Cowboy.child_spec/1 的参数提供了服务器选项。这里指定要在端口5454上提供HTTP流量服务。最后一个选项 :plug 表示将调用该模块中的某个函数来处理请求。

当将 Todo.Web 包含在监督器中时,会启动几个新进程。至少会有一个进程监听给定端口并接受请求,然后每个不同的TCP连接将在一个单独的进程中处理,并且必须实现的回调将在这些特定于请求的进程中调用。但无需担心这些细节,由于 child_spec/1 的存在,Plug库会将这些内部细节隐藏起来。

需要注意的是,应用程序是单例的,在运行的BEAM实例中只能启动一个不同应用程序的实例。但这并不意味着系统中只能运行一个HTTP服务器。Plug和Cowboy应用程序可以被视为HTTP服务器的工厂。启动这些应用程序时,还没有启动任何HTTP服务器。可以使用 child_spec/1 将HTTP服务器注入监督树中,当然也可以在系统中运行多个HTTP服务器,例如可以添加另一个用于管理目的的HTTP服务器。

有了 child_spec/1 后,可以将HTTP服务器注入监督树,这在 Todo.System 监督器中完成:

defmodule Todo.System do
  def start_link do
    Supervisor.start_link(
      [
        Todo.Metrics,
        Todo.ProcessRegistry,
        Todo.Database,
        Todo.Cache,
        Todo.Web
      ],
      strategy: :one_for_one
    )
  end
end

由于进行了包装和适当的命名, Todo.System 模块清晰地描述了系统的组成:指标、进程注册表、数据库、缓存和Web服务器。

3.3 处理请求

3.3.1 为add_entry请求设置路由

现在可以开始处理一些请求了。以 add_entry 请求为例,这将是一个POST请求。为了简化实现,将通过URL传递所有参数,示例请求如下:

http://localhost:5454/add_entry?list=bob&date=2023-12-19&title=Dentist

首先为这个请求设置路由,示例代码如下:

defmodule Todo.Web do
  use Plug.Router
  plug :match
  plug :dispatch
  ...
  post "/add_entry" do
    ...
  end
  ...
end

这里有一些特殊的构造,它们是使用Plug的一部分。 use Plug.Router 会向模块中添加一些函数,类似于使用 use GenServer 。导入的函数大部分将由Plug内部使用。

plug :match plug :dispatch 值得特别提及,这些调用会执行一些额外的编译时工作,使你能够匹配不同的HTTP请求。这些表达式是Elixir宏调用的示例,它们将在编译时解析。结果是模块中会得到一些额外的函数(同样仅由Plug使用)。

最后,调用 post 宏来定义请求处理代码。 post 宏的工作方式类似于之前看到的 test 宏。在底层,这个宏会生成一个函数,该函数由调用 plug 宏和 use Plug.Router 生成的所有其他样板代码使用。生成的函数大致如下:

defp do_match(conn, "POST", ["add_entry"], _) do
  ...
end

生成的 do_match 函数的代码将包含传递给 plug 宏的代码。生成的 do_match 函数由模块中由于 use Plug.Router plug :match 表达式而存在的其他生成代码调用。

如果多次调用 post 宏, do_match 函数将有多个子句,每个子句对应一个正在处理的路由,最终会得到类似如下的代码:

defp do_match(conn, "POST", ["add_entry"], _), do: ...
defp do_match(conn, "POST", ["delete_entry"], _), do: ...
...
defp do_match(conn, _, _, _), do: ...

可以使用名为 decompile 的工具( https://github.com/michalmuskala/decompile )来验证这一点。按照README说明安装该工具,切换到待办事项项目的根文件夹,编译项目,然后运行 mix decompile Todo.Web --to expanded ,这将生成 Elixir.Todo.Web.ex 文件,其中包含所有宏展开后的Elixir代码。

简而言之,当HTTP POST请求的路径为 /add_entry 到达服务器时,会调用提供给 post "/add_entry" 的代码。

3.3.2 实现add_entry请求处理程序

以下是 add_entry 请求处理程序的实现:

defmodule Todo.Web do
  ...
  post "/add_entry" do
    conn = Plug.Conn.fetch_query_params(conn)
    list_name = Map.fetch!(conn.params, "list")
    title = Map.fetch!(conn.params, "title")
    date = Date.from_iso8601!(Map.fetch!(conn.params, "date"))
    list_name
    |> Todo.Cache.server_process()
    |> Todo.Server.add_entry(%{title: title, date: date})
    conn
    |> Plug.Conn.put_resp_content_type("text/plain")
    |> Plug.Conn.send_resp(200, "OK")
  end
  ...
end

在请求处理程序中,使用了 conn 变量,它是由 post 宏生成并绑定到正确值的。 conn 变量表示连接,它是 Plug.Conn 结构体的一个实例,包含一个TCP套接字以及正在处理的请求的状态信息。从处理程序代码中,必须返回修改后的连接,其中包含响应信息,如状态和主体。

请求处理程序的实现包括三个部分:
1. 解码输入参数 :调用 Plug.Conn.fetch_query_params/1 函数,它返回一个新的连接结构,其中 params 字段包含请求参数(以映射形式)。这本质上是一种缓存技术,Plug会在连接结构体中缓存 fetch_query_params 的结果,因此重复调用 fetch_query_params 不会导致过多的解析。
2. 执行系统操作 :在这个例子中,是添加一个新的待办事项条目。
3. 响应客户端 :设置响应的内容类型、状态和主体。

3.3.3 处理entries请求

处理 entries 请求的方法与 add_entry 请求类似:

defmodule Todo.Web do
  ...
  get "/entries" do
    conn = Plug.Conn.fetch_query_params(conn)
    list_name = Map.fetch!(conn.params, "list")
    date = Date.from_iso8601!(Map.fetch!(conn.params, "date"))
    entries =
      list_name
      |> Todo.Cache.server_process()
      |> Todo.Server.entries(date)
    formatted_entries = ...
    conn
    |> Plug.Conn.put_resp_content_type("text/plain")
    |> Plug.Conn.send_resp(200, formatted_entries)
  end
  ...
end

通过以上步骤,就完成了在待办事项系统中引入HTTP接口的基本实现,展示了如何使用OTP应用程序以及各个组件是如何连接在一起的。

3.3.4 请求处理流程总结

我们可以将请求处理的流程总结如下,这有助于更清晰地理解整个过程:
1. 路由匹配 :通过 use Plug.Router plug :match plug :dispatch 以及 post get 等宏来匹配不同的 HTTP 请求路径。
2. 参数解析 :使用 Plug.Conn.fetch_query_params/1 解析 URL 中的参数。
3. 业务逻辑处理 :根据解析后的参数调用相应的业务逻辑函数,如 Todo.Server.add_entry Todo.Server.entries
4. 响应返回 :设置响应的内容类型、状态码和响应体,使用 Plug.Conn.put_resp_content_type Plug.Conn.send_resp 完成响应。

以下是 add_entry 请求处理的 mermaid 流程图:

graph LR
    A[接收 POST /add_entry 请求] --> B[解析请求参数]
    B --> C[调用业务逻辑添加条目]
    C --> D[设置响应内容类型和状态码]
    D --> E[返回响应]

3.4 项目依赖与启动流程梳理

为了更好地理解整个项目,我们可以梳理一下项目的依赖关系和启动流程。

3.4.1 项目依赖关系
依赖名称 作用
poolboy 用于实现连接池管理,提高资源利用率
plug_cowboy 作为 Plug 和 Cowboy 之间的粘合代码,实现 HTTP 服务器

以下是依赖关系的 mermaid 图:

graph LR
    A[Todo 项目] --> B[poolboy]
    A --> C[plug_cowboy]
    C --> D[Plug]
    C --> E[Cowboy]
3.4.2 项目启动流程
  1. 获取依赖 :运行 mix deps.get 从 Hex 包管理器获取项目依赖。
  2. 编译项目 :运行 mix compile 编译所有依赖和项目代码。
  3. 启动系统 :在 iex -S mix 环境中启动系统。
  4. 启动 HTTP 服务器 :在 Todo.System 监督器中启动 Todo.Web 模块,从而启动由 Plug 和 Cowboy 驱动的 HTTP 服务器。

以下是启动流程的 mermaid 流程图:

graph LR
    A[开始] --> B[mix deps.get]
    B --> C[mix compile]
    C --> D[iex -S mix]
    D --> E[启动 Todo.System 监督器]
    E --> F[启动 Todo.Web 服务器]
    F --> G[监听端口,接收请求]

3.5 代码结构与模块职责

整个项目的代码结构和各个模块的职责如下:
- Todo.MixProject Todo.Mixfile :管理项目的依赖信息。
- Todo.Database :负责数据库相关操作,使用 Poolboy 实现连接池管理。
- Todo.DatabaseWorker :具体的数据库工作进程,处理数据的存储和获取。
- Todo.Cache :提供缓存功能,用于存储和获取待办事项服务器进程。
- Todo.Server :处理待办事项的具体业务逻辑,如添加条目、获取条目等。
- Todo.Web :实现 HTTP 服务器,处理 HTTP 请求和响应。
- Todo.System :作为顶级监督器,管理整个系统的启动和运行。

通过这样的代码结构和模块划分,各个模块的职责清晰,代码的可维护性和可扩展性得到了提高。

综上所述,我们通过引入外部依赖、适配连接池、构建 Web 服务器等步骤,实现了一个完整的待办事项系统。这个系统不仅具备基本的业务逻辑处理能力,还通过 HTTP 接口提供了对外服务的能力。同时,借助 observer 工具,我们可以对系统的运行状态进行可视化监控,方便进行调试和性能优化。在实际开发中,我们可以根据具体需求进一步扩展系统功能,如添加更多的 HTTP 请求处理、优化数据库操作等。

评论
成就一亿技术人!
拼手气红包6.0元
还能输入1000个字符  | 博主筛选后可见
 
红包 添加红包
表情包 插入表情
 条评论被折叠 查看
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值