GenServer和Agent在Elixir中的真实用例
并发是Elixir OTP应用程序的面包和黄油。我们将看看Elixir是如何帮助实现并发性的,为几个函数调用提供平行服务的许多方法之一。
本博文中讨论的概念需要对Elixir/Erlang中的GenServer有一个非常基本的了解。
任何与需要OAuth2凭证的外部API对话的应用程序,都必须管理访问令牌,以及在令牌过期时刷新它的基本步骤。虽然有两种常见的方法来确保令牌被刷新,但我倾向于确保令牌在过期前被刷新的一方。这样一来,我就不必为了刷新令牌而等待令牌失效。
另一种方法是当我们收到401 ,就进行刷新。但是,等等!我不知道该怎么办。⚠️
在大多数情况下,区分过期的令牌和错误的令牌是非常困难的,因为这两种情况都会有一个来自服务器的
HTTP-401 Unauthorized响应。
我们的目标
我们希望在我们的Fancy 应用程序内有一个模块,给我们一个OAuth2访问令牌来调用https://funny-server.foo/videos/charlie-chaplin API。
一个理想的方法是,在我们的Fancy 应用程序中保持令牌的刷新和其他模块的访问,并将其缓存在某处。缓存可以是一个简单的内存对象,在Redis中,甚至是在一个文件中。但是对于存储像访问令牌这样的小字符串来说,Redis和文件都太花哨了🤷🏽♂️。
最好的选择是使用内存中的对象存储。Elixir/Erlang一般让我们用4种不同的方式来存储和分享数据。
1.GenServer
GenServer(或称General Server)是你在Elixir应用中最常见和最明显的OTP库。我们可以使用GenServer来实现BEAM进程的行为,它持有一个状态(我们的数据)。我们也可以通过calls 和casts 来查询相同的状态并对当前状态进行更改。
2.Agent
Agent是建立在GenServer之上的。它需要较少的代码行。说到实际使用,可以说它与GenServer类似。对于存储/共享状态(我们的数据)的实际用例,Agent所做的事情与裸露的GenServer完全相同,但通过抽象出GenServer的实现,以更方便开发者的方式。Agent暴露了4个主要函数:cast/2 、get/3 、update/3 和get_and_update/3 。我们将在后面的文章中详细介绍它们。
3.ETS(Erlang Term Storage)表
除非你有需要,而且背后有很强的理由,否则我建议不要在我们这样的用例中使用ETS。一个Erlang节点中的表的数量曾经是有限的,但现在已经不是这样了。即使如此,也要记住一件事,就是不能对表进行自动垃圾收集。这可能是一个问题,因为Erlang只有在它的所有者进程终止时才会删除这些表。长话短说,我们暂时不要使用这个选项。
4.Dedicated Storages
现代云服务提供了高速缓存存储和磁盘,这对某些用户来说是一个选择。然而,我们的情况不是这样的。文件和网络也可以是一种选择,但这些对我们来说并不可行。
好了,让我们来看看GenServer和Agent的细节。首先,我将展示GenServer版本的Fancy.Auth 的样子。然后,我们将看到Agent是如何帮助我们把一些代码简化为简洁易读的线条的:
defmodule Fancy.Auth do
use GenServer
@endpoint "https://funny-server.foo/oauth2/token"
# Client APIs
def start_link(_arg) do
GenServer.start(__MODULE__, %{}, name: __MODULE__)
end
def token do
GenServer.call(__MODULE__, :token)
end
# Server APIs
def init(state) do
# fetch token and save it as state in the GenServer process
{:ok, refresh_token()}
end
def handle_call(:token, _from, state) do
{token, new_state} = get_token(state)
{:reply, token, new_state}
end
defp refresh_token() do
# ... lines skipped for brevity ...
%{status_code: 200, body: body} =
resp = HTTPoison.post!(@endpoint, payload, headers, options)
# Successfully fetched access token
body = Jason.decode!(body, keys: :atoms)
Map.put(body, :expires_in, :os.system_time(:seconds) + body.expires_in)
end
defp get_token(%{expires_in: expires_in, access_token: token} = state) do
now = :os.system_time(:seconds)
# I am greedy ^_^
has_aged? = now + 1 > expires_in
if has_aged? do
# Refresh OAuth token as it has aged
auth = refresh_token()
{auth.access_token, auth}
else
# No need to refresh, send back the same token
{token, state}
end
end
end
这是一个非常简单明了的GenServer,它只暴露了一个API,即Fancy.Auth.token/1 ,从funny-server.foo ,如果它在下一秒就会过期,也会刷新。现在让我们看看如何用Agent实现同样的功能,而且代码行数更少但更容易阅读:
defmodule Fancy.Auth do
use Agent
@endpoint "https://funny-server.foo/oauth2/token"
def start_link(_arg) do
Agent.start_link(fn -> refresh_token() end, name: __MODULE__)
end
def token do
token = Agent.get_and_update(__MODULE__, fn state -> get_token(state) end)
token
end
# ... lines below are exactly the same as in the GenServer snippet ...
defp refresh_token() do
# ... lines skipped for brevity ...
%{status_code: 200, body: body} =
resp = HTTPoison.post!(@endpoint, payload, headers, options)
# Successfully fetched access token
body = Jason.decode!(body, keys: :atoms)
Map.put(body, :expires_in, :os.system_time(:seconds) + body.expires_in)
end
defp get_token(%{expires_in: expires_in, access_token: token} = state) do
now = :os.system_time(:seconds)
# I am greedy ^_^
has_aged? = now + 1 > expires_in
if has_aged? do
# Refresh OAuth token as it has aged
auth = refresh_token()
{auth.access_token, auth}
else
# No need to refresh, send back the same token
{token, state}
end
end
我在这里使用了Agent.get_and_update/3 ,但我们也可以通过在Auth.token/1 中使用Agent.get/3 ,在Auth.refresh_token/0 中使用Agent.update/3 ,分两步进行。
Agent.cast/2 可以用来使Auth.refresh_token/0 成为异步的。请记住,cast 是Fire-and-Forget,意味着刷新访问令牌的API调用的实际执行是异步进行的。根据你的使用情况,这可能是有利的,也可能是不利的。
通过使用Agent而不是GenServer,我们到底得到了什么?
我同意GenServer让我们的模块看起来更复杂的事实。实际上,这个模块唯一的核心功能是,获取一个访问令牌。对于这样一个简洁的用例,Agent更有意义。
在实现GenServer行为时,一个常见的模式是将客户端->服务器的功能从包的用户代码或应用程序的业务逻辑中抽象出来。这在我们的模块中引入了额外的代码行。虽然这对一个更复杂的服务器来说是很好的,但我们的服务器不需要用GenServerhandle_call 函数和其他东西来充实。代理人看起来更整洁,更薄
说了这么多,在这种情况下,使用GenServer而不是Agent并没有真正的性能提升。我不是在谈论基准,在那里你可能会看到GenServer的性能稍微好一点,这将是μs的数量级。然而,在更复杂的情况下,GenServer会更容易理解。将回调从公共API中分离出来可以让我更好地记录代码,也可以帮助我重复使用大部分的代码。
最后,这是你的代码,请决定吧
这篇文章并不打算偏向于某个OTP。我相信Agent和GenServer的选择更多的是个人对特定使用情况的偏好。
当模块本身是一个简单的键值访问机制时,用Agent来保持它的简单和愚蠢。当模块的功能要大得多,你需要对回调进行微调处理时,就用GenServer来分离关注点。
我希望你能发现这篇文章对实现类似的真实案例很有用。请继续关注我们的博客,以获得更多令人兴奋的话题!