MACH

Walking Machine

У нас в банке нужно ходить по сервисам. Некоторые сервисы вполне шаблонны: сначала нужно сделать первый запрос куда-то, потом в другое место метнуться прочитать статус; в некоторых случает достаточно 1 хопа, а в некоторых случаях даже могут ветвления быть и цепочка из четырех вызовов. Вообщем хоть бери и графами все это описывай и BPE для этого используй. Но тащить BPE для того, чтобы стуктурировать пайплайн внешних сервисов не очень, BPE больше для структурирования внутренней бизнес локиги, к тому же BPE предлагает контекст и историю. А для обхода всех эндпойнтов нам не нужен прямо бизнес контекст, нам хочется чтобы все без контекста было только с аккумулятором ответов. К тому же внутри BPE только Эрланг термами мы общаемся а тут эта муть с богомерзкими JSON и XML, вообщем надо что-то более легковесное и заточенное на форматтеры. N2O умеет форматтеры, но N2O еще тяжелее BPE, поэтому пришлось писать суперлегковеский Сервис Уолкер.

  • — Полная абстракция от функций формматирования
  • — Желание полной декларативности
  • — Простая но удобная система моков и их тестов
  • — По коду определить количество необходимых моков
  • — Построение кода по примерам сообщений
  • — Исключить падения из протокола через try из-за аккумулятивности
  • — Возвращать не только статус но и все шаги
  • — Транзакционность и Персистентность контекста опроса
  • — Настройка retry запросов, типов запросов и HTTP методов
  • — Двухуровневая модульность по портам подключения и точкам подключения
  • — Программирование последовательностей запросов в пайпах для построения не только линейных, но и ветвящихся алгоритмов обхода сетевых сервисов.

За счет того, что каждый вызов необходимо разорвать сразу перед вызовом нижележащего HTTP клиента, получилась система из трех типов и трех функций:

  • — Форматтер (распаковка)
  • — Протокол (вызов)
  • — Эрланг Терм (запаковка)

Запаковка и распаковка это типа return или pure, штуки которые втягивают какой-то объект в обертку какого-то типа. Например у нас Data а мы над Data конструируем такой тип:

Pipe = {ok,Data,Acc}
     | {warning,Data,Acc}
     | {error,Data,Acc}
     | {refusal,Data,Acc}
     | {repeat,Acc}

Вот этот тип и есть тот тип-протокол, благодаря которому наши функции Упаковки и Распаковки будут звучать. Т.е. имея этот тип мы делаем наши функции упаковки и распаковки композабельными. Теперь как мы будем выстраивать в цепочку нашу монадку. Для начала опишем интерфейс функций форматтирования:

req1([Id],Acc) ->
    Req = jsone:encode({[{id,Id}]}),
    {ok, Req, [{sid,Id}|Acc]}.

ans1(AnswerJSON,Acc) ->
    {ok,{Resp},_} = jsone:try_decode(unipre:characters_to_binary(AnswerJSON)),
    {Data} = proplists:get_value(<<"data">>,Resp),
    {ok, #account{ iban     = proplists:get_value(<<"iban">>,Data),
                   phone    = proplists:get_value(<<"phone">>,Data),
                   surnames = proplists:get_value(<<"surnames">>,Data),
                   names    = proplists:get_value(<<"names">>,Data)}, Acc}.

Тут вы не смотрите, что JSON не автоиматически преобразует типы по пайплайну а нужно вручную в поля заглядывать, это сделано для того чтобы программисты которые на этом будут писать, чувствовали немного анархии, впрочем мы им не запрещаем самим конвертировать JSON в Proplists с помощью нашего synrc/rest. Как видите Ans берет Erlang терм и пакует его в наш протокольный тип Pipe, который дальше уже пойдет на вызов сервиса, а после вызова нам нужно будет распарсать ответ от сервиса и опять запаковать его в наш протокольный тип, которые вместе с функциями упаковки и распаковки образуем монаду. Сама программа обхода выглядит следующим образом:

withdraw() -> {{?MODULE,mach:host(?MODULE)},
              [{json,post,withdraw,{req7,ans7}, 2, "/withdraw"},
               {json,get, withdraw,{req8,ans8}, 3, "/withdrawCheck"},
               {xml,post, withdraw,{req9,ans9}, 3, "/notify"}]}.

Тут это просто развернутая запись на Erlang композиции функций форматтирования и вызовов внешних сервисов:

req7 . call . ans7 . call . req8 . call . ans8 . req9 . call . ans9

Котрая происходит при вызове сервиса. Первым параметром мы ложим программу обходов сервиса, т.е. композицию функции. Вторым параметром мы ложим Erlang объект который хотим залифтить монадично по цепочке, дальше таймаут дефалный и начальное положение акумулятора. Покажем как это работает на примере:

 > mach:services(withdraw(),[#customer{}], 1000, []).

    {ok,20,
     [{ans2,20},
      {call,<<"{\"Response\":{\"bank\":\"mach\",\"state\":\"r\"...>>},
      {req2,<<"{\"Request\":{\"bank\":\"mach\"}}">>},
      {ans1,[undefined,undefined,1,<<"00000001">>,site]},
      {call,<<"{\"Response\":{\"bank\":\"mach\",\"state\":\"r\",\"list\":{\"cont"...>>},
      {req1,<<"{\"Request\":{\"ref\":\"2015100803502313492\",\"channel\":\"S"...>>},
      {req,<<"{\"Request\":{\"ref\":\"2015100803502313492\",\"channel"...>>},
      {id,<<"00000001">>},
      {sid,1},
      {channel,site}]}

Не забываем про декларативное описание все-го что нам нужно для доступа к сервису, половина уже описана в самих шагах как то METHOD, MIME, количество Retries и точка монтирования порта. Сам порт залается функуией host/1, а кастомные заголовки для сервиса header/1 в которых вы можете свободно обращаться к акумулятору за полями которые вы можете положить в акумулятор на любом шаге выполнения этой композиции.

headers(Acc) -> [{proplists:get_value(method,Acc), "HTTP/1.1"},
                 {"content-type",   mach:type(proplists:get_value(type,Acc))},
                 {"accept",         mach:type(proplists:get_value(type,Acc))},
                 {"connection",     "keep-alive"},
                 {"content-Length", size(proplists:get_value(req,Acc))},
                 {"sid",          proplists:get_value(sid,Acc)}].

host(_)    -> "http://bank.com:8888/";

Вообщем в модуле больше ничего не нужно, кроме имплементации обязательной функции test/0 и вместе с ней моками которые имитируют ответы от каждого вебсервиса, естественно моки должны быть в тех же модулях что и функции упаковки и распаковки.

test() ->
  lists:flatten([[ begin erlang:put(mock,Mock),
      mach:service(?MODULE:Fun(),Args,1000,Acc) end || Mock <- Mocks ]
          || {Fun,Mocks,Args,Acc} <-
          [{withdraw, [7,8], [#account{}],  []},
           {odb,      [5],   [#customer{}], []},
           {debt,   [1,2,3], [#customer{}}, []] ]).

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

mock(9,[Method,Address,Headers,Type,Data]) ->
    Request = [{i, [{'ibank',   [#xmlText{value="10"}] },
                    {'account', [#xmlText{value="20"}]},
                    {'names',   [#xmlText{value="mach"}]},
                    {'phone',   [#xmlText{value="r"}]}]} ],
    {ok,{{[],200,"OK"},[], mach:xml(Request) }}.

В сигнатура функции mock/2 второй параметр это список всего что нужно передать в httpc клиент. А вот собственно три функции Упаковки, Вызова и рапаковки:

pack(_,{{P,RA},[{Type,Method,M,{F,G},Retry,Addr}|T]}=Stack,A,Time,Acc) ->
       try case M:F(A,Acc) of
               {ok,Ret,NewAcc} -> call(Retry,Stack,Ret,Time,[{F,A}|NewAcc]);
       {warning,Reason,NewAcc} -> {warning,Reason,NewAcc};
         {error,Reason,NewAcc} -> {error,Reason,NewAcc} end
       catch E:R -> fatal(E,R) end.

call(Retry,{{_,RA},[{Type,Method,M,{F,G},A,Addr}|T]}=Stack,Req,Time,Acc)
       when Retry < 1 -> {error,{attempts,A},Acc};
call(Retry,{{P,RA},[{Type,Method,M,{F,G},_,Addr}|T]}=Stack,Req,Time,Acc) ->
       try case req_ret(request(P,M,[ Method,
                                addr(RA,Addr,Acc),
                                P:headers([{req,Req},
                                           {method,string:to_upper(
                                                   wf:to_list(Method))}]),
                                type(Type),
                                Req])) of
                   {ok,Answer} -> unpack(Retry,Stack,Answer,
                                         Time,[{call,Req}|Acc]);
                {error,Reason} -> {error,Reason,Acc} end
    catch E:R -> fatal(E,R) end.

unpack(Retry,{{P,RA},[{_,_,M,{F,G},_,_}|T]}=Stack,A,Time,Acc) ->
    try case M:G(A,Acc) of
               {ok,Ret,NewAcc} -> pipe(Retry,{{P,RA},T},Ret,Time,[{G,A}|NewAcc]);
       {warning,Reason,NewAcc} -> {warning,Reason,NewAcc};
         {error,Reason,NewAcc} -> {error,Reason,NewAcc};
       {refusal,Reason,NewAcc} -> {refusal,Reason,NewAcc};
               {repeat,NewAcc} ->
                       timer:sleep(Time),
                       wf:info(?MODULE, "Retry ~p: ~p~n",[Retry, hd(Acc)]),
                       call(Retry-1,Stack,element(2,hd(Acc)),
                            Time,tl(Acc)) end
    catch E:R -> fatal(E,R) end.

Где мокабельный http_request выгляит так:

http_request(P,M,true,Args) -> M:mock(mock(get(mock)),Args);
http_request(P,M,false,[Method,Address,Headers,Type,Data]) ->
     httpc:request(Method,{Address, Headers, Type, Data},[],[]).

Получилось жутко удобно. Публикую здесь краткое описание для всех, кто собирается использовать этот продукт. Что касается моков, то их количество зависит от количества case в методах распаковки. Проверить что все моки работают можно скопом:

> mach:test().
   [{passed,26},{failed,0}].

Вот так может выглядеть например модуль сервиса:

  -module(service_edit).
  -compile(export_all).

  req(List,Acc)   -> {ok, mach:xml([{}]), Acc}.
  ans(Binary,Acc) -> {ok, "OK", Acc}.

  mock(1, [Method,Address,Headers,Type,Data]) ->
         {ok,{{[],200,"OK"},[],jsone:encode({[{<<"Response">>,
                                            {[{'bank','mach'}]} }]}) }}.

Что дал переход на такую систему сервисов? Я смогу за 2 дня переписать 15 сервисов убрав все лишнее, что не касается форматтироврания, в результате у меня получился общий код который единобразно работает со всеми сервисами, дает трейсы выполнения любых программ обхода сервисов, и методику закатывания любых сервисов не думая особо над кодом: моки создаются по коду, а код пишется по мокам.

Цена вопроса 100 LOC, в среднем получается 2-5КБ на сервис. Аналоги в других мирах: Apache Camel, WCF.