image


Предыстория


20-го декабря прошлого года я ушёл в отпуск, на целых 2 недели. Чем заняться в отпуске? Правильно, — кодом. Кодом, которым некогда заниматься в рабочее время. Последние несколько лет мне кодить приходилось очень мало. Руки стосковались. Какой код пишут в отпуске? Не знаю как вы, а я пишу велосипеды. Зачем? Причин может быть много, но основная, — мне интересно. Я люблю C++ и Lua. Я ещё и bash и awk люблю. Не закидывайте камнями, это личное, так получилось. JavaScript я не очень люблю (хотя последние 2 года если что-то и кодил то на JS), и это тоже личное.


Что в итоге получилось


Результатом скуки в отпуске стал LAppS — Lua Application Server. Этот отпускной кодинг растянулся на 6 месяцев (конечно после декабря я коду уделял не очень много времени, это видно по коммитам в github). Но в последние 2 недели удалось урвать достаточно много времени для того, чтобы получилось что-то рабочее.


Что это


Как уже ясно из названия, — LAppS сервер приложений Lua. Lua достаточно популярный язык, связка Nginx+Lua с библиотеками OpenResty активно используется по всму миру, один Cloudflare чего стоит. На момент начала разработки, уже существовали и вышеупомянутый Lua модуль для Nginx и Tarantool, luvit.io. Но ни один из них не поддерживал WebSockets. LAppS не поддерживает HTTP. Зато уже сейчас LAppS превосходит в производительности uWebSockets (ценой потребления больших вычислительных ресурсов).


Основная идея была в том, чтобы максимально сократить цикл разработки микросервисов. Lua имеет порог вхождения ниже чем JavaScript. Я больше чем уверен, даже школьники средних классов, вполне спокойно могут начать программировать на Lua. Но вот все доступные средства разработки web приложений для Lua, имеют уже не такой минимальный порог вхождения.


Поэтому основной упор в разработке делался на минималистичность API и простоту создания приложений, а также лёгкость конфигурируемости сервера приложений и сервисов.


Детали


Lua-приложения (сервисы) в LAppS, не блокируют ввод-вывод. Это наверное самое большое отличие от web сервера со скриптингом на Lua. LAppS использует 2 конфигурационных файла, для настройки поведения сервера WebSockets и для деплоймента приложений.


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


ws.json — Конфигурационный файл сервера WebSockets


{
  "listeners" : 1,
  "connection_weight": 0.7,
  "ip" : "0.0.0.0",
  "port" : 5083,
  "workers": {
    "workers" : 3,
    "max_connections" : 1000
  },
  "tls" : true,
  "tls_certificates" : {
     "ca" : "/opt/lapps/etc/ssl/cert.pem",
     "cert" : "/opt/lapps/conf/ssl/cert.pem", 
     "key" : "/opt/lapps/conf/ssl/key.pem"
  },
  "auto_fragment" : true,
  "max_inbound_message_size" : 300000
}

  • listeners — количество листенеров запускаемых параллельно, этот параметр влияет на то, как быстро LAppS принимает входящие соединения
  • connection_weight — параметр для внутреннего балансировщика сравнивающего глубину очереди ввода-вывода IOWorker-ов и кол-ва соединений. Чем больше одновременных соединений, тем меньше должен быть этот параметр, т.к. соединения могут использовать разные сервисы с разным профилем нагрузки. В такой ситуации лучше использовать менее нагруженный IOWorker для нового соединения, чем IOWorker с меньшим кол-вом соединений.
  • ip — IP-адрес интерфейса на котором сервер будет ожидать входящие соединения. По умолчанию — все интерфейсы.
  • port — порт. По умолчанию 5083.
  • workers.workers — Количество параллельно работающих IOWorker-ов. По умолчанию 3.
  • workers.max_connections — пока не используется. В дальнейшем будет устанавливать лимит активных соединений для 1-го IOWorker-a.
    • tls — Использовать TLS? По умолчанию "да". Вообще параметр бесполезный. Использовать-ли TLS диктуется параметром сборки. По умолчанию сервер собирается с использованием LibreSSL, и TLS 1.2.
  • tls_certificates.ca — Путь к сертификату идущему в поставке с LibreSSL.
  • tls_certificates.cert — Путь к сертификату (по умолчанию используется самоподписанный сертификат localhost)
  • tls_certificates.key — ключ сертификата
  • auto_fragment — использовать-ли авто-фрагментацию для исходящих сообщений (пока не эффективен)
  • max_inbound_message_size — лимит размера входящих сообщений (пока не эффективен)

lapps.json — Файл конфигурации сервисов


{
  "directories" :  {
     "applications" : "apps",
     "app_conf_dir" : "etc",
     "tmp": "tmp",
     "workdir": "workdir"
  },
  "services" : {
     "echo" : {
       "internal" : false,
       "request_target" : "/echo",
       "protocol" : "raw",
       "instances" : 3
     },
     "echo_lapps" : {
       "internal" : false,
       "request_target" : "/echo_lapps",
       "protocol" : "LAppS",
       "instances" : 3       
     }
  }
}

  • directories.applications — путь к директории с приложениями, относительный переменной окружения LAPPS_HOME (по умолчанию /opt/lapps), — по умолчанию apps
  • directories.app_conf_dir — путь к конфигурационным файлам сервисов (пока не используется)
  • *directories.{tmp,workdir} — пока тоже не используются
  • services — карта настройки сервисов

В выше приведённом примере сконфигурировано 2 демо-сервиса: echo и echo_lapps. Имя сервиса, это по умолчанию и путь поиска Lua-модулей приложений. По сути приложения в LAppS, это модули следующие определённому интерфейсу.


Кроме параметра internal, все остальные параметры настройки сервиса обязательны.


  • services.{name} — в примере services.echo и services.echo_lapps, имя сервиса.
  • services.{name}.request_target — цель в URL WebSockets, пример wss://127.0.0.1:5083/echowss://127.0.0.1:5083/echo или wss://127.0.0.1:5083/echo_lapps. IOWorker-ы после handshake ассоциируют сокеты с конкретным приложением на базе этой цели. Если в карте сервисов нет запрашиваемой клиентом цели, то handshake разрывается с кодом 403.
  • services.{name}.protocol — тип протокола приложения. Сейчас используются только 2 типа протоколов: raw и LAppS (о них ниже)
  • services.{name}.instances — кол-во параллельно работающих экземпляров приложения. Каждое соединение закреплено за своим экземпляром приложения.

Сборка, и установка


Инструкции по сборке и установке можно прочитать на wiki странице проекта


Можно воспользоваться подготовленным deb пакетом для установки в ubuntu-xenial


Приложения/Сервисы


Приложения на самом деле являются Lua-модулями, которые должны иметь несколько предопределённых методов с предопределённым поведением:


  • onStart — метод onStart, исполняется однажды перед стартом приложения. Здесь можно проводить начальную конфигурацию, выполнить какой-либо важный для инициализации приложения код. Этот метод не должен блокировать виртуальную машину Lua бесконечно долго. Т.е. выполнение цикла while(1), в этом методе приведёт к тому, что сервис не будет реагировать на поступающие запросы.
  • onShutdown -аналогично onStart, метод выполняется однажды, при остановке приложения. Также не должен бесконечно блокировать поток выполненя Lua машины. В противном случае сервис не остановится сам и не даст остановиться серверу приложений.
  • onMessage — имплементируется по разному для протоколов raw и LAppS. В зависимости от параметра указанного в конфигурации, данный метод будет получать данные разного типа.
    • raw: bool onMessage(handler,opcode,message) — где handler это уникальный идентификатор соединения; opcode — WebSockets OpCode, который принимает всего два значения 1 (TEXT) или 2 (BINARY), все фреймы с другими опкодами обрабатываются сервером и в приложение не передаются; message — строковое значение Lua содержащее бинарное или текстовое сообщение (в зависимости от опкода с которым был отправлен фрейм).
    • LAppS: bool onMessage(handler,msg_type,message)handler это уникальный идентификатор соединения; message — собственно сообщение соответствующее спецификации LAppS 1.0, это userdata объект типа nljson; msg_type — вспомогательный параметр, тип сообщения, принимает четыре значения от 1-го до 4-х:
      • 1 — Client Notification (CN)
      • 2 — CN с дополнительными параметрами в массиве params
      • 3 — запрос исполнения метода без параметров
      • 4 — запрос исполнения метода с параметрами в массиве params
  • onDisconnect(handler) — метод вызываемый при разрыве (сбросе или корректном закрытии в соответствии с RFC 6455) соединения клиентом. handler — уникальный идентификатор соединения.

Примечание: метод onMessage обязан возвращать булево значение. При возврате значения ложно, соединение для которого был вызван метод разрывается (close code 1000).


Простейшее приложение-скелет имплементирующее echo-server (протокол raw).


myapp = {}

myapp.__index = myapp;

myapp["onStart"]=function()
  -- do something on start
end

myapp["onDisconnect"]=function(handler)
  -- handler - is a unique client identifier
  -- react on client disconnect
end

myapp["onShutdown"]=function()
  -- do something on shutdown
end

myapp["onMessage"]=function(handler,opcode, message)
  -- it is an echo, - we return back the same message 
  local result, errmsg=ws:send(handler,opcode,message);
  if(not result)
  then
    print("myapp::OnMessage(): "..(errmsg or "none"));
  end
  return result;
end

return myapp;

Конфигурация для данного сервиса:


  "myapp" : {
     "internal" : false,
     "request_target" : "/myapp",
    "instances" : 1,
    "protocol": "raw"
  }

Протокол LAppS


Спецификация протокола LAppS базируется на гугловской спецификации JSON-RPC со следующими ключевыми отпличиями:


  • обмен сообщениями бинарный в формате CBOR
  • протокол специфицирует "Out of Order Notifications" (OON), тип сообщений инициируемый сервером.
  • протокол специфицирует каналы сообщений. Учитывая необходимость запрос-ответ сообщений, а также необходимость нотификаций со стороны сервера, каналы позволяют клиентскому приложению иметь на одном соединении параллельный поток данных на разных каналах. Канал 0 (CCH) зарезервирован для запрос-ответ сообщений, все остальные каналы согласуются приложением.

Со спецификацией можно ознакомиться на github


Приложение с поддержкой протокола LAppS


Не буду приводить полный код приложения, приведу лишь имплементацию метода onMessage. Детально с демо приложением можно ознакомиться в исходниках на github.


echo_lapps["onMessage"]=function(handler,msg_type, message)
  -- функция для реакции на тип сообщения
  local switch={
    [1] = function() -- Клиентские нотификации без параметров не принимаются сервером
                           --  (это не ограничение это деталь реализации приложения)

            -- сообщение об ошибке
            local err_msg=nljson.decode([[{
              "status" : 0,
              "error" : {
                "code" : -32600,
                "message": "This server does not accept Client Notifications without params"
              },
              "cid" : 0 
            }]]);
            -- отправка сообщения об ошибке
            ws:send(handler,err_msg);
            -- закрываем WebSocket с кодом 1003 - "не понимаю"
            ws:close(handler,1003);
          end,
    [2] = function() -- CN с параметрами. обрабатываем.
            local method=methods._cn_w_params_method[message.method] or echo_lapps.method_not_found;
            method(handler,message.params);
          end,
    [3] = function() -- не поддерживаем запросы без параметров
            local method=echo_lapps.method_not_found;
            method(handler);
          end,
    [4] = function() -- поддерживаем запросы с параметрами
            local method=methods._request_w_params_method[message.method] or echo_lapps.method_not_found;
            method(handler,message.params);
          end
  }

  -- выполняем селектор
  switch[msg_type]();

  return true;
end

Авто-подгружаемые модули


LAppS подгружает несколько модулей, перед стартом сервиса: nljson, ws, bcast. детально со спецификацией модулей можно ознакомиться на wiki проекта.


Кратко:


  • nljson — модуль для работы с JSON (ecode/decode/cbor).

Работа с модулем, мало чем отличается от работы с нативными таблицами Lua. Более того, таблицы Lua конвертируются в nljson userdata с помощью простого присваивания. Однако Lua не делает различия между объектами (ключ-значение) и массивами, поэтому например для пустых Lua-таблиц их конвертирование nljson представляет некое препятствие. Например


 local object=nljson.decode({}) 

Приведёт к созданию JSON-Array с именем object. Поэтому лучше пользоваться такой инициализацией:


 local object=nljson.decode('{}') 

Это определение однозначно создаст JSON-Object.


Далее этим объектом можно пользоваться как нативной луа таблицей:


 object["test"]="значение";
 print(object.test)
 object["map"]={
    ["key1"] = "value",
    ["key2"] = 33
  }
  print(object)

Скорость работы с nljson объектами мало отличается от нативных таблиц Lua.


  • ws — модуль для отправки WebSocket сообщений, имеет всего 2 метода: send, close.
  • bcast — модуль широковещательных сообщений, доступные методы: subscribe, unsubscribe, create, send.

Клиентские приложения


Тут всё проще пареной репы, — благо WebSockets API для браузеров продуман и прост.
Обязательная библиотека cbor.js. Webix используется для отображения bar-chart.
Не пинайте за пароль в тексте кода. Это-же просто демо.


демо-код клиента
<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="utf-8">
  </head>
  <body> 
    <link rel="stylesheet" href="http://cdn.webix.com/edge/webix.css" type="text/css">
    <script src="http://cdn.webix.com/edge/webix.js" type="text/javascript"></script>
    <script src="cbor.js" type="text/javascript"></script>

    <div id="chart" style="width:100%;height:300px;margin:3px"></div>
    <div id="stime" style="width:100%;height:300px;margin:3px"></div>

    <script>

      // globals

      window["secs_since_start"]=0;
      window["roundtrips"]=0;
      window["subscribed"]=false;
      window["lapps"]={
        authkey : 0
      };

      // initial data set for the chart

      var dataset = [
        { id:1, rps:0, second:0 }
      ]

      // the chart
      webix.ui({
        id:"barChart",
        container:"chart",
        view:"chart",
        type:"bar",
        value:"#rps#",
        label:"#rps#",
        radius:0,
        gradient:"rising",
        barWidth:40,
        tooltip:{
            template:"#rps#"
        },
        xAxis:{
            title:"Ticking RPS",
            template:"#second#",
            lines: false
        },
        padding:{
            left:10,
            right:10,
            top:50
        },
        data: dataset
      });

      // might be a dialog instead. never do this in production.
      var login = {
        lapps : 1,
        method: "login",
        params: [
          {
            user : "admin",
            password : "admin"
          }
        ]
      };

      // echo request
      var echo= {
        lapps : 1,
        method: "echo",
        params: [
          { authkey : 0 },
          [1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25]
        ]
      };

      // create a websocket
      var websocket = new WebSocket("wss://127.0.0.1:5083/echo_lapps");
      websocket.binaryType = "arraybuffer";

      // on response
      websocket.onmessage = function(event)
      {
        window.roundtrips=window.roundtrips+1;

        // CBOR to native JavaScript object
        var message = CBOR.decode(event.data);

        // Verifying the channel
        if(message.cid === 0)
        {
          if(message.status === 1)
          {
            if(window.lapps.authkey === 0)
            {
              if(typeof message.result[0].authkey !== "undefined") // authkey is arrived
              {
                window.lapps.authkey=message.result[0].authkey;
                echo.params[0].authkey = window.lapps.authkey;
                websocket.send(CBOR.encode(echo));
              }
              else
              {
                console.log("No authkey: "+JSON.stringify(message));
              }
            }
            else
            {
              websocket.send(CBOR.encode(echo));

              // already authenticaed, may subscribe to OONs

              if(!window.subscribed)
              {
                var subscribe={
                  lapps : 1,
                  method: "subscribe",
                  params: [
                    { authkey: window.lapps.authkey }
                  ],
                  cid: 5
                };
                websocket.send(CBOR.encode(subscribe));
                window.subscribed=true;
              }
            }
          }
          else
          {   
            console.log("ERROR: "+JSON.stringify(message));
          }
        }
        else if(message.cid === 5) // server time OON
        {
          console.log("OON is received");
          webix.message({
            text : message.message[0],
            type: "info",
            expire: 999
          });

          window.secs_since_start++;
          $$("barChart").add({rps: window.roundtrips, second: window.secs_since_start});
          window.roundtrips=0;
          if(window.secs_since_start > 30 )
          {
            $$("barChart").remove($$("barChart").getFirstId());
          }
        }
        else //  other OONs are just printed to console
        {
          console.log("OON: "+JSON.stringify(message));
        }
      };

      // login on connection
      websocket.onopen=function() 
      {
        console.log('is open');
        window.teststart=Date.now()/1000;
        websocket.send(CBOR.encode(login));        
      }

      // close connection if peer sent close frame
      websocket.onclose=function()
      {
        console.log("is closed");
      }
    </script>

  </body>
</html>

Клиентское приложение это echo-клиент для протокола LAppS. Серверная часть приложения броадкастит раз в секунду своё время, график обновляется по этому OON.


Примечание: Если в браузере запустить несколько клиентов, то и кол-во броадкастов увеличится, т.к. броадкасты отправляются из onMessage, раз в секунду.


Что-бы это исправить, необходима реализация самостоятельных приложений, коммуницирующих с остальным стэком LAppS. Эта часть сейчас в разработке.

Комментарии (3)


  1. apapacy
    04.05.2018 10:54
    +1

    Было бы очень интересно посмотреть на сравнение по Вашей методике с реализаципй на openresfy github.com/openresty/lua-resty-websocket


    1. thatsme Автор
      04.05.2018 11:24

      Эти вещи нельзя сравнивать, по нескольким причинам:

      1. github.com/openresty/lua-resty-websocket — это реализация WebSockets в виде библиотеки для работы под nginx или с дистрибутивом OpenResty. От пользователя ничего не скрывается и контроль за flow весь возлагается на приложение. Для lua-resty-websocket в открытых источниках нет данных о соответствии стандарту. Более того, именно это соответствие вам придётся реализовывать в своём приложении с использованием этой библиотеки.

      2. В LAppS я концентрируюсь на том, что-бы избавить пользователя (разработчика) от контроля за flow и обеспечить удобный механизм RPC с возможностью мултиплексирования каналов передачи сообщений в потоке WebSockets. Также я ориентировался с самого начала разработки на соответствие стандарту RFC 6455.

      3. Библиотеки из стэк-а OpenResty можно запускать под LAppS. Только наверное, весь код который не выполнен в виде модулей для lua и завязанный на API Nginx работать не будет.

      4. Производительность сравнивать сейчас тоже бесполезно, те результаты которые я получил хоть и значительны, но никакой оптимизации производительности в коде LAppS не производилось. Более того добавленный недели 2 назад стэк EventBus снизил производительность на ~30% (до 86к rps с балансером или 55к без балансера, до этого без балансера было ~78к rps). С ним легче рабтается в дебагере. Убрать этот стэк дело 1-го дня. Этот стэк я пока убирать не буду, он мне нужен для отладки функционирования приложений и для разработки «decoupled apps».


    1. thatsme Автор
      05.05.2018 01:28
      +1

      До меня не сразу дошло, что в скрипте lua под nginx соединение нужно удерживать с помощью примитивного while(true). Вобщем протестировать удалось. Использовалось 3 worker-а в nginx-e (так-же как и в LAppS). Неожиданно, но мой велосипед оказался быстрее. При тех-же 80-ти клиентах связка nginx-1.12.2-r1 (gentoo)+lua-resty-websocket выдала 58367.4 rps (best average within 2 mins run). Использованная секция location nginx.conf.

      Добавил результаты на страницу в github. Просто пухну от гордости. Однако всё равно остаюсь при своём мнении, что с nginx+lua-resty-websocket, LAppS сравнивать нелзя. nginx это в первую очередь web-server, очень хороший web-server, он изначально под WebSockets не затачивался.