В 2017м году я начал писать проект на nodejs — реализацию протокола ObjectServer от Weinzierl для доступа к значениям KNX. В процессе написания было изучено: работа с бинарными протоколами, представление данных, работа с сокетами(unix sockets в частности), работа с redis базой данных и pub/sub каналами.
Проект достиг стабильной версии. В это время я потихоньку ковыряю другие языки, в частности Dart и Flutter как его приложение. На полке пылится без действия купленный во времена студенчества справочник Г.Шилдта.
Настойчивая мысль переписать проект на C поселилась в голове. Рассматриваю варианты Go, Rust, отталкивающие иными синтаксическими конструкциями. Начать никак не получается, идея откладывается на время.
В мае этого года решил посмотреть язык D, почему-то уверенный в том, что буква D означает dynamic. Долго гадал откуда и почему эта мысль была в голове, так ответа не нашел. НО это уже не важно, поскольку переписыванием увлекся я на все лето.
Суть проекта
Модули KNX BAOS 830/832/838 подключены через UART к компьютеру, протокол ObjectServer обернут в FT1.2. Приложение устанавливает соединение с /dev/ttyXXX
, обрабатывает входящие данные, отправляет туда же конвертированные из JSON сообщения байты пользовательского запроса, приходящего на PUB/SUB канал, либо в очередь заданий на базе списков Redis-а (для nodejs очереди реализованы пакетом bee-queue).
queue.on("job", data => {
// предполагая валидное задание:
// конвертировать данные, отправить в серийный порт
// возвратить промис, который разрешится при входящем ответе
});
baos.on("data", data => {
// понять, что это: индикация или ответ
// если ответ, то разрешить промис из очереди
// если индикация - обработать и отправить в pub/sub
});
Динамичность
JSON в js — вещь нативная, как обработка происходит в статически типизированных языках я представления не имел. Как оказалось, разницы немного. Для примера взять метод get value
. В качестве аргументов он принимает либо число — номер датапоинта, либо массив номеров.
В js выполняются проверки:
if (Array.isArray(payload)) {
// получить значения для массива
return values;
}
if (typeof id === "number") {
// получить значения одного объекта
return value;
}
throw new Error("Неправильный id");
По сути то же самое на D:
if (payload.type() == JSONType.integer) {
// вернуть одно значение
} else if (payload.type() === JSONType.array) {
// вернуть массив значений
} else {
throw Errors.wrong_payload_type;
}
Почему-то на момент рассмотрения Rust-a меня затормозило именно отсутствие представления о работе с JSON. Другой момент, связанный с динамичностью: массивы. В js привыкаешь к тому, что достаточно вызвать метод push
для добавления элемента. На C динамичность реализуется ручным выделением памяти, а лезть туда не очень то и хотелось. Dlang, как оказалось, поддерживает динамические массивы.
ubyte[] res;
// хорошая практика - сначала сделать массив больше
res.length = 1000;
// а после заполнения изменить длину на нужную
res.length = count;
// чем менять каждый шаг длину массива на 1
Входящие UART данные в js конвертировались в Object
. Для этих целей в D отлично подходят структуры, перечисления со значениями и объединения.
enum OS_Services {
unknown,
GetServerItemReq = 0x01,
GetServerItemRes = 0x81,
SetServerItemReq = 0x02,
SetServerItemRes = 0x82,
// ...
}
// ...
struct OS_Message {
OS_Services service;
OS_MessageDirection direction;
bool success;
union {
// union of possible service returned structs
// DatapointDescriptions/DatapointValues/ServerItems/ParameterBytes
OS_DatapointDescription[] datapoint_descriptions;
OS_DatapointValue[] datapoint_values;
OS_ServerItem[] server_items;
Exception error;
};
}
При входящем сообщении:
ubyte mainService = data.read!ubyte();
ubyte subService = data.read!ubyte();
try {
if (mainService == OS_MainService) {
switch(subService) {
case OS_Services.GetServerItemRes:
result.direction = OS_MessageDirection.response;
result.service= OS_Services.GetServerItemRes;
result.success = true;
result.server_items = _processServerItemRes(data);
break;
case OS_Services.SetServerItemRes:
result.direction = OS_MessageDirection.response;
// ...
В js я байтовые значения хранил в массиве, при входящих данных делал поиск и возвращал строку с именем сервиса. Структуры, перечисления и объединения выглядят строже.
Работа с массивами байтовых данных
Node.js мне нравится абстракция Buffer
. Для примера: преобразования двух байтов в беззнаковое целое удобно выполнять методом readUInt16BE(offset)
, для записи — writeUInt16BE(value, offset)
, буфферы активно использовал для работы с бинарным протоколом. Для dlang я изначально начал шерстить репозиторий пакетов на что-либо похожее. Ответ нашелся в стандартной библиотеке std.bitmanip
. Для беззнаковых целых длиной 2 байта: ushort start = data.read!ushort()
, для записи: result.write!ushort(start, 2);
, где 2й аргумент — смещение.
EE, promises, async/await.
Самым тяжелым представлялось программирование без EventEmitter
. В node.js просто регистрируются функции слушатели, и при событии они вызываются. Таким образом, сильно думать не надо. В dlang пакетах tinyredis
и serialport
(зависимости моего приложения) есть неблокирующие методы для обработки сообщений. Решение простое: пока истина получать по очереди сообщения серийного порта и pub/sub канала. В случае входящего пользовательского запроса на pub/sub канал программа должна отправить сообщение в серийный порт, получить результат и отправить пользователю обратно в pub/sub. Методы для серийных запросов решено было сделать блокирующими.
while(!(_responseReceived || _resetInd || _interrupted)) {
try {
processIncomingData();
processIncomingInterrupts();
if (_resetInd || _interrupted) {
_response.success = false;
_response.service = OS_Services.unknown;
_response.error = Errors.interrupted;
_responseReceived = true;
_ackReceived = true;
}
// ...
// ...
return _response;
В цикле while данные опрашиваются неблокирующим методом processIncomingData()
. Так же предусмотрена вероятность того, что KNX модуль может быть перезагружен (отключен и подключен заново к шине KNX или программно). Также обработчик processIncomingInterrupts()
проверяет сервисный pub/sub канал на запрос reset
. Никаких промисов и асинхронных функций, в отличие от предыдущих реализаций на js. Пришлось подумать над структурой программы (а именно последовательности вызова функций), но, засчет отсутствия лишних абстракций, программировать стало проще. По сути, когда в js коде вызывается await someAsyncMethod
— асинхронная функция вызывается как блокирующая, проходя при этом через event loop. Сама возможность языка — это хорошо, но ведь можно обойтись и без нее.
Отличия
Очередь заданий. В node.js реализации для этой цели используется пакет bee-queue
. В реализации на D запросы отправляются только через pub/sub.
В остальном все практически идентично.
Оперативной памяти компилируемая версия потребляет в 10 раз меньше, что может быть важно для одноплатных компьютеров.
Компиляция
Компиляция проводилась при помощи ldc на платформе aarch64.
Для установки ldc:
curl -fsS https://dlang.org/install.sh | bash -s ldc
Была собрана плата, состоящая из трех основных компонентов: NanoPi Neo Core2 качестве компьютера, KNX BAOS module 830 для связи с шиной KNX и Silvertel Ag9205 для PoE питания, на которой и осуществлялось программирование.
Заключение
Не буду судить, какой язык лучше или хуже. Каждому свое: js отлично подходит для изучения, уровень абстракций(промисы, эмиттеры) позволяют достаточно легко и быстро строить структуру приложения. К реализации на dlang я подошел уже с ясным, заученным за полтора года, планом что делать. Когда знаешь какие данные необходимо обрабатывать и каким образом, статическая типизация не страшна. Неблокирующие методы позволяют организовать рабочий цикл. Это была первая моя работа на D, работа увлекательная и познавательная.
Насчет выхода из зоны комфорта (как указано в названии): в моем случае — у страха были глаза велики, что долго мешало мне попробовать что-то, помимо nodejs.
Исходные коды открыты и могут быть найдены на github.com/dobaos/dobaos