Хабрахабр

[recovery mode] LAppS сервер приложений для микросервисной архитектуры

image

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

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

Что это

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

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

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

Детали

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

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

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

, "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 — карта настройки сервисов

Имя сервиса, это по умолчанию и путь поиска Lua-модулей приложений. В выше приведённом примере сконфигурировано 2 демо-сервиса: echo и echo_lapps. По сути приложения в 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 — уникальный идентификатор соединения.

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

Простейшее приложение-скелет имплементирующее 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 базируется на гугловской спецификации JSON-RPC со следующими ключевыми отпличиями:

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

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

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

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

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

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

Кратко:

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

Более того, таблицы Lua конвертируются в nljson userdata с помощью простого присваивания. Работа с модулем, мало чем отличается от работы с нативными таблицами Lua. Например Однако 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.

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

демо-код клиента

<!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>

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

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

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

Показать больше

Похожие публикации

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *

Кнопка «Наверх»