Урок «Эрланг на практике. Способы обработки ошибок. Let It Crash.» Урок «Эрланг на практике. Способы обработки ошибок. L...» Эрланг на...

Defensive Programming vs Let It Crash

Когда вся программа выполняется в одном потоке, аварийное завершение этого потока означает аварийное завершение программы. И если это случилось в месте, где явно не предусмотрена обработка ошибок, то остается минимум информации для диагностики проблемы.

Поэтому программисты стараются предусмотреть обработку всех возможных ошибок во всех возможных местах. Такой стиль программирования называется Defensive Programming. И он нередко он приводит к тому, что в программа содержит больше кода для обработки ошибок, чем кода, выполняющего основную задачу. Конечно, это усложняет и написание кода, и поддержку.

Эрланг предлагает другой подход: реализовать только основную задачу (happy path) и не писать код для обработки ошибок. Благодаря многопоточности и разделению потоков на рабочие и супервизоры, любая ошибка всегда будет замечена и записана в лог. А система в целом продолжит работу. Этот подход называется Let It Crash.

Между тем, все инструменты для Defensive Programming в эрланг есть. И полностью от этого подхода никто не отказывается. На практике каждый разработчик ищет свой баланс между Defensive Programming и Let It Crash.

В этом уроке разберемся, как оба подхода применяются в эрланг. Но сперва рассмотрим средства языка для работы с ошибками.

Типы данных Maybe/Option и Either/Result

Когда на 5-м уроке мы рассматривали Key-Value типы данных, мы заметили, что некоторые из них имеют по два варианта функций, возвращающих или обновляющих значение по ключу.

Например, dict:fetch/2 бросает исключение, если нет ключа в словаре, а dict:find/2 возвращает атом error. Аналогично ведут себя maps:get/2 и maps:find/2.

В эрланг есть некоторый бардак в поведении разных функций: proplists:get_value/2 возвращает Value | undefined, dict:find/2 и maps:find/2 возвращают {ok, Value} | error, gb_trees:lookup/2 возвращает {value, Value} | none. Но общая закономерность видна: возвращается либо значение, обернутое в тегированый кортеж, либо некий атом, означающий отсутствие значения.

В других функциональных языка программирования это поведение стандартизировано.

Например, Haskell имеет тип Maybe:

Maybe = Just x | None

а OCaml имеет тип Option:

Option = Some x | None

Это псевдокод, а не правильный код на этих языках, но суть ясна.

Maybe/Option -- полезный тип во многих случаях. Но часто бывает нужно не просто сообщить об ошибке, но и указать тип ошибки. В эрланг для этого часто используются кортежи {ok, Value} | {error, Reason}.

А в Haskell есть тип Either:

Either = Right x | Left y

и в OCaml есть тип Result:

Result Ok x | Error y

Пример:

case find_user(UserId) of
    {ok, User} -> do something
    {error, not_found} -> do other thing
end

throw, try..catch

Как в и большинстве языков программирования, в эрланг есть исключения и способ их перехватить и обработать. Но картина несколько усложняется тем, что есть три типа исключений и три разных способа их генерировать.

throw(Reason) -- генерирует обычное исключение. Чаще всего именно эту функцию используют разработчики.

erlang:error(Reason) -- генерирует фатальную ошибку, восстановление после которой не подразумевается, и текущий поток должен упасть. Впрочем, это скорее соглашение, нежели техническое отличие. Перехватить и обработать это исключение все равно можно.

exit(Reason) -- генерирует системное сообщение. Мы это обсуждали в 11-м уроке "Обработка ошибок на низком уровне" и помним, что с помощью системных сообщений реализуются связи между потоками. Необходимость вызывать exit/1 и вмешиваться в этот механизм возникает очень редко, разве что в целях тестирования.

Аргумент Reason во всех этих функциях может быть любой структурой данных. Обычно это кортеж, либо одиночный атом, несущий какую-то информацию об ошибке.

Перехватить исключения можно конструкцией try..catch:

try
    some code here
catch
    TypeOfError:Reason1 -> some processing;
    TypeOfError:Reason2 -> some processing
end.

После catch мы видим сопоставление с образцом, но не совсем обычное. TypeOfError -- это тип исключения (throw, error или exit), а Reason1 -- это шаблон, который должен совпасть с аргументом Reason соответствующих функций.

Вот более конкретный пример, как могут вылядеть эти шаблоны:

try
    some code here
catch
    throw:my_exception -> some processing;
    throw:{error, some_reason} -> some processing;
    throw:{error, {some, [complex, data]}} -> some processing;
    error:{some_error, details} -> log details and die;
    exit:{signal, details} -> log details and die;
end.

При обработке исключения нас обычно интересует стек вызовов функций. Получить его можно вызовом erlang:get_stacktrace().

Чтобы понять, как этот стек выглядит, лучше всего увидеть его на практике.

Возьмем такой простой модуль из 3-х функций:

-module(test).
-export([run/0]).
run() ->
    try
        1 + some_fun()
    catch
        throw:my_exception ->
            StackTrace = erlang:get_stacktrace(),
            io:format("~p", [StackTrace])
    end.
some_fun() ->
    2 + other_fun().
other_fun() ->
    throw(my_exception).

и запустим его:

3> test:run().
[{test,other_fun,0,[{file,"test.erl"},{line,20}]},
 {test,some_fun,0,[{file,"test.erl"},{line,16}]},
 {test,run,0,[{file,"test.erl"},{line,7}]},
 {erl_eval,do_apply,6,[{file,"erl_eval.erl"},{line,661}]},
 {shell,exprs,7,[{file,"shell.erl"},{line,684}]},
 {shell,eval_exprs,7,[{file,"shell.erl"},{line,639}]},
 {shell,eval_loop,3,[{file,"shell.erl"},{line,624}]}]ok

Стек представляет собой список кортежей, где каждый кортеж указывает функцию и строку в исходном коде. Мы видим, что исключение возникло в модуле test, в функции other_fun с арностью 0, которая определена в файле test.erl, в 20-й строке кода. И дальше мы видим цепочку вызовов функций, которые привели к этому месту.

Выбор способа обработки ошибок

Итак, у разработчика есть несколько вариантов.

Внутри функции можно сообщить об ошибке с помощью исключения, либо возвратом специального значения (Option или Result). А снаружи, при вызове функции, можно либо обработать ошибку (Defensive Programming) либо проигнорировать (Let It Crash).

В эрланг, и вообще в функциональном программировании, предпочитают использовать специальные значения, а исключениями применяют редко. Можно обойтись и вообще без исключений, что подтверждает язык Go. Но это не всегда удобно.

Например, сервер обрабатывает HTTP запрос со сложными входными данными. Эти данные нужно валидировать по многим условиям, и при несоответствии данных любому из этих условий, сервер отказывается их принимать.

Вариант без исключений может выглядеть примерно так:

case check1(Data) of
    ok -> case check2(Data) of
              ok -> case check3(Data) of
                        ok -> process_data(Data);
                        {error, Reason3} -> {error, Reason3}
              {error, Reason2} -> {error, Reason2}
    {error, Reason1} -> {error, Reason1}
end.

Причем, таких проверок может быть десяток и больше. Проблему можно решать по-разному. Например, применив монады (библиотека erlando реализует для эрланг некоторые монады из Haskell). Это даст лаконичный, но более сложный для понимания код.

А с помощью исключений можно сделать простое и понятное решение:

try
    check1(Data),
    check2(Data),
    check3(Data)
catch
    throw:Reason1 -> {error, Reason1};
    throw:Reason2 -> {error, Reason2};
    throw:Reason3 -> {error, Reason3}
end.

В функциональном программировании считается правильным иметь только одну точку выхода из функции. Но на практике иногда удобно иметь много точек выхода. И в этой ситуации помогают исключения.

Я могу дать такую рекомендацию: в большинстве случаев использовать специальные типы Option и Result. Исключения использовать редко, только если они дают более простой и понятный код, чем код со специальными типами.

Итак, мы сообщили об ошибке. Следующий вопрос: нужно ли обрабатывать эту ошибку, или лучше игнорировать ее? Тут нужно четко понимать, что происходит, если ошибка проигнорирована.

А происходит следующее: текущий поток падает и перезапускается супервизором из некоего известного стабильного состояния. Информация, хранящаяся в памяти потока (стек и куча), теряется. В лог пишется сообщение об ошибке.

Часто нас такое поведение устраивает. И тогда лучше не усложнять код и игнорировать ошибку.

Но есть случаи, когда это поведение не подходит:

  • ошибка может быть не ошибкой, а штатной ситуацией, для которой мы знаем, что нужно делать;
  • нужно сохранить данные из памяти потока;
  • информации в логе недостаточно, чтобы понять, что происходит.

И тогда нужно явно обработать ошибки.

catch all шаблоны

Отдельная, но близкая к обработке ошибок тема -- использование catch all шаблонов при сопоставлении с образцом.

Рассмотрим такой код:

case some(Arg) of
    {tag1, Result1} -> do_something;
    {tag2, Reslut2} -> do_other;
end

Это Let It Crash подход. Если some(Arg) вернет какой-то результат, для которого нет шаблона, то поток упадет.

case some(Arg) of
    {tag1, Result1} -> do_something;
    {tag2, Reslut2} -> do_other;
    Any -> process_unknown_data(Any)
end

Это Defensive Programming. Результат, для которого нет шаблона мы явно обрабатываем каким-то образом.

Я рекомендую использовать catch all шаблоны для обработчиков handle_call, handle_cast, handle_info в gen_server. Это позволяет четко логировать запросы к gen_server, для которых не реализована обработка.

handle_call({some, Data}, _From, State) ->
    ...
handle_call({other, Data}, _From, State) ->
    ...
handle_call(Any, _From, State) ->
    lager:error("unknown call ~p in ~p ~n", [Any, ?MODULE]),
    {noreply, State}.

Информацию об ошибке мы получим в любом случае.

Если есть catch all шаблон:

1> gen_server:call(some_worker, blablabla).
16:53:06.529 [error] unknown call blablabla in some_worker

и если нету:

1> gen_server:call(some_worker, blablabla).
 ** exception exit: {{function_clause,[{some_worker,handle_call,
                                                   [blablabla,{<0.42.0>,#Ref<0.0.0.984>},no_state],
                                                   [{file,"src/some_worker.erl"},{line,25}]},
                                      {gen_server,try_handle_call,4,
                                                  [{file,"gen_server.erl"},{line,607}]},
                                      {gen_server,handle_msg,5,
                                                  [{file,"gen_server.erl"},{line,639}]},
                                      {proc_lib,init_p_do_apply,3,
                                                [{file,"proc_lib.erl"},{line,237}]}]},
                    {gen_server,call,[some_worker,blablabla]}}
     in function  gen_server:call/2 (gen_server.erl, line 182)
2> 16:53:53.353 [error] gen_server some_worker terminated with reason: ...
16:53:53.353 [error] CRASH REPORT Process some_worker with 0 neighbours exited with reason: ...

Но в первом случае это будет просто аккуратная запись в error.log. А во втором случае gen_server упадет и потеряет свое состояние.

Supervisor и распределенность

Супервизоры мы уже рассматривали в 11-м и 12-м уроках. Повторяться не буду. Добавлю только, что если считать try..catch первым уровнем обработки ошибок, то супервизоры будут вторым уровнем.

Для кода в стиле Let It Crash супервизоры -- главное средство. Но важно, чтобы каждый рабочий поток был запущен под супервизором.

Распределенность обеспечивает третий уровень обработки ошибок. Правильно построенный кластер продолжает обслуживать клиентов при выходе из строя одного или даже нескольких узлов. Здесь работают похожие механизмы. Узлы в кластере мониторят друг друга подобно тому, как мониторят друг друга потоки в рамках одного узла.

В итоге эрланг позволяет построить очень надежные системы. Но надежность не возникнет сама собой, это должен сделать разработчик.