В 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
rotor
В статье не хватает подсветки синтаксиса — так текст будет восприниматься легче.
Несколько замечаний по коду:
Не нужно заранее делать массив больше, но зарезервировать память действительно хорошая практика.
Для этого есть метод reserve:
Ещё есть статические массивы. Для них не нужны аллокации в кучи. Если размер массива известен на этапе компиляции, то использование такого массива может сделать программу эффективнее.
По коду, обратил внимание, что вы используете словари там, где достаточно было бы кортежей (tuple) или структур.
bobalus Автор
Спасибо за замечания.
На этапе компиляции пользовательские запросы неизвестны. Поэтому динамические массивы необходимы.
Под словарями вы имеете в виду ассоциативные массивы?
Полагаю, вместо
лучше делать следующим образом:
rotor
Ага. Ассоциативные массивы часто используют в динамически типизированных языках.
В D, как правило, можно обойтись более эффективным контейнером.
Структура вместо ассоциативного массива будет существенно эффективнее. Она аллоцируется на стеке. Кроме того, компилятор может делать проверки, которые не доступны при использовании словаря. Например в случае опечаток.
Ещё код можно сделать нагляднее и читабельнее, если используемые структуры данных заранее определить: