gen_server 模块中的异常处理最佳实践

Exception handling best practices inside gen_server module

我刚开始学习 Erlang,这是我的一个测试项目中的一个模块。我这样做是为了更好地理解监督树的工作原理,练习快速失败代码和一些编程最佳实践。

udp_listener 进程侦听 UDP 消息。它的作用是侦听来自网络中其他主机的通信请求,并使用 UDP 消息中定义的端口号通过 TCP 与它们联系。

每次套接字收到 UDP 消息时都会调用 handle_info(...) 函数,它会解码 UDP 消息并将其传递给 tcp_client 进程。

据我所知,我的代码中唯一的失败点是 decode_udp_message(Data)handle_info(...) 中的某个时间调用。

当这个函数失败时,整个udp_listener进程是否重新启动?我应该避免这种情况发生吗?

难道 handle_info(...) 函数不应该在不影响 udp_listener 进程的情况下默默地死掉吗?

我应该如何在 decode_udp_message(Data) 上记录异常?我想在主机的某个地方注册,但它失败了。

-module(udp_listener).
-behaviour(gen_server).
-export([init/1, handle_call/3, handle_cast/2, 
         handle_info/2, terminate/2, code_change/3]).

%% ====================================================================
%% API functions
%% ====================================================================

-export([start_link/1]).

start_link(Port) ->
    gen_server:start_link({local, ?MODULE}, ?MODULE, Port, []).

%% ====================================================================
%% Behavioural functions 
%% ====================================================================

%% init/1
%% ====================================================================
-spec init(Port :: non_neg_integer()) -> Result when
    Result :: {ok, Socket :: port()}
            | {stop, Reason :: term()}.
%% ====================================================================
init(Port) ->
    SocketTuple = gen_udp:open(Port, [binary, {active, true}]),
    case SocketTuple of
        {ok, Socket}        -> {ok, Socket};
        {error, eaddrinuse} -> {stop, udp_port_in_use};
        {error, Reason}     -> {stop, Reason}
    end.

% Handles "!" messages from the socket
handle_info({udp, Socket, Host, _Port, Data}, State) -> Socket = State,
    handle_ping(Host, Data),
    {noreply, Socket}.

terminate(_Reason, State) -> Socket = State,
    gen_udp:close(Socket).

handle_cast(_Request, State)        -> {noreply, State}.
handle_call(_Request, _From, State) -> {noreply, State}.
code_change(_OldVsn, State, _Extra) -> {ok, State}.

%% ====================================================================
%% Internal functions
%% ====================================================================

handle_ping(Host, Data) ->
    PortNumber = decode_udp_message(Data),
    contact_host(Host, PortNumber).

decode_udp_message(Data) when is_binary(Data) ->
    % First 16 bits == Port number
    <<PortNumber:16>> = Data,
    PortNumber.

contact_host(Host, PortNumber) ->
    tcp_client:connect(Host, PortNumber).

结果

我已根据您的回答更改了我的代码,decode_udp_message 已消失,因为 handle_ping 满足了我的需要。

handle_ping(Host, <<PortNumber:16>>) ->
    contact_host(Host, PortNumber);

handle_ping(Host, Data) ->
    %% Here I'll log the invalid datagrams but the process won't be restarted

我喜欢现在的方式,通过添加以下代码,我可以在未来处理协议更改而不会失去与旧服务器的向后兼容性:

handle_ping(Host, <<PortNumber:16, Foo:8, Bar:32>>) ->
    contact_host(Host, PortNumber, Foo, Bar);
handle_ping(Host, <<PortNumber:16>>) ->
    ...

@Samuel-Rivas

tcp_client 是另一个 gen_server,它有自己的主管,它将处理自己的故障。

-> Socket = State 现在只存在于 terminate 函数中。 gen_udp:close(Socket). 更护眼。

您的 decode_message 并不是唯一的失败点。 contact_host 也很可能会失败,但您要么忽略错误元组,要么在 tcp_client 实现中处理该失败。

除此之外,如果您的 udp_listener 是由具有正确策略的主管启动的,那么您的错误处理方法将起作用。如果 Data 不完全是 16 位,则匹配将失败并且进程将崩溃并出现 badmatch 异常。然后supervisor会开始新的

许多在线风格指南都会宣传这种风格。我认为他们错了。尽管立即失败正是您想要的,但这并不意味着您不能提供比 badmatch 更好的理由。所以我会在那里写一些更好的错误处理。通常,我会抛出一个信息丰富的元组,但对于生成服务器来说这很棘手,因为它们将每个调用都包装在一个 catch 中,这会将抛出转换为有效值。不幸的是,这是其他长篇解释的主题,因此出于实际目的,我会在这里抛出错误。第三种选择是使用错误元组 ({ok, Blah} | {error, Reason}),但这很快就会变得复杂。使用哪个选项也是一个很长的话题explanation/debate,所以现在我将继续我自己的方法。

回到你的代码,如果你想要适当的和信息丰富的错误管理,我会在这行中用 decode_udp_message 函数做一些事情(保留你当前的语义,参见这个响应的末尾,因为我认为它们不是您想要的):

decode_udp_message(<<PortNumber:16>>) ->
    PortNumber;
decode_udp_message(Ohter) ->
    %% You could log here if you want or live with the crash message if that is good enough for you
    erlang:error({invalid_udp_message, {length, byte_size(Other)}}).

如您所说,这将占用整个 UDP 连接。如果该进程被主管重新启动,那么它将重新连接(这可能会导致问题,除非您使用 reuseaddr sockopt)。除非您计划每秒失败多次并且打开连接成为负担,否则那会很好。如果是这种情况,您有多种选择。

  • 假设您可以控制所有故障点并在那里处理错误而不会崩溃。例如,在这种情况下,您可以忽略格式错误的消息。这在像这样的简单场景中可能没问题,但不安全,因为它很容易忽略故障点。
  • 分离你想要保持容错的关注点。在这种情况下,我将有一个进程来保持连接,另一个进程来解码消息。对于后者,您可以使用 "decoding server" 或根据您的偏好和您期望的负载为每条消息生成一个。

总结:

  • 一旦您的代码发现正常行为之外的东西就失败是个好主意,但请记住使用主管来恢复功能
  • 就让它崩溃,根据我的经验,这是一种不好的做法,您应该努力找出明确的错误原因,这将使您的系统增长时更轻松
  • 进程是您隔离故障恢复范围的工具,如果您不希望一个系统受到 failures/restarts 的影响,只需派生进程来处理您想要隔离的复杂性
  • 有时性能会妨碍您,您需要妥协并就地处理错误,而不是让进程崩溃,但像往常一样,在这个意义上避免过早优化

关于您的代码的一些与错误处理无关的注释:

  • 您在 decode_udp_message 中的评论似乎暗示您要先解析 16 位,但实际上您是在强制 Data正好 16 位。
  • 在你的一些调用中你做了类似 -> Socket = State 的事情,缩进可能是不好的风格,而且变量的重命名在某种程度上是不必要的。您可以在函数头中将 State 更改为 Socket,或者,如果您想明确表示您的状态是套接字,请将您的函数头写成 ..., Socket = State) ->

我认为 "let it crash" 经常被误解为 "do not handle errors"(一个更强烈和更奇怪的建议)。你的问题 ("should I handle errors or not") 的答案是 "it depends".

错误处理的一个问题是用户体验。你永远不会想要向你的用户抛出堆栈跟踪和监督树。正如 Samuel Rivas 指出的那样,另一个问题是仅从崩溃的进程进行调试可能会很痛苦(尤其是对于初学者而言)。

Erlang 的设计有利于具有非本地客户端的服务器。在此架构中,客户端必须能够处理服务器突然变得不可用(当您单击 S.O 上的 "post" 按钮时,您的 wifi 连接断开 just。 ,并且服务器必须能够处理客户端的突然退出。在这种情况下,我会将 "let it crash" 翻译为 "since all parties can handle the server vanishing and coming back, why not use that as the error handler? Instead of writing tons of lines of code to recover from all the edge-cases (and then still missing some), just drop all the connections and return to a known-good state."

"it depends" 进来了。也许知道是谁发送了错误的数据报对您来说真的很重要(因为您也在编写客户端)。也许客户总是想要回复(希望不是 UDP)。

就个人而言,我首先写 "success path",其中既包括成功的成功,也包括我想向客户展示的错误。所有我没有想到或客户不需要知道的事情都由进程重新启动来处理。