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 项目启动流程
- 获取依赖 :运行
mix deps.get从 Hex 包管理器获取项目依赖。 - 编译项目 :运行
mix compile编译所有依赖和项目代码。 - 启动系统 :在
iex -S mix环境中启动系统。 - 启动 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 请求处理、优化数据库操作等。
Elixir项目:依赖、连接池与Web服务器搭建
超级会员免费看
33

被折叠的 条评论
为什么被折叠?



