Пишем код для Node.js в стиле «машина состояний»

    Вы пишите сервер на Node.js, который принимает входящие TCP-соединения и ведёт с клиентами нетривиальный диалог по нестандартному протоколу? Возможно вам будет интересен пример, который я развиваю в своих проектах. Что я имею ввиду под нетривиальным диалогом?

    Давайте сравним.

    Удалённый датчик температуры, который стучится к серверу и после установления соединения пишет в сокет несколько байт своего идентификатора, а затем несколько байт текущей температуры — это примитивно.

    Тот же датчик, который может получать от сервера команды на изменение частоты измерений или периода усреднения измеряемой величины — уже посложнее. Добавьте к этому, например, функционал отправки датчику обновлений прошивки и код запросто может потерять лаконичность.

    В подобных случаях я организую свой код в стиле «машины состояний», не претендуя, впрочем, на соответствие канонам теории автоматов.

    Пример для этой статьи выложен здесь: github.com/kityan/fsmConnection. Далее поясню несколько ключевых моментов.

    Код основного приложения.

    Рассмотрим только серверную часть. Код очень простой:

    var net = require('net');
    var ClientConnection = require('./ClientConnection.js');
    var config = {"socketTimeout":3000, "port": 30000}
    net.createServer(function(socket) {var clientConnection = new ClientConnection(socket, config);})
    	.listen(config.port, function () {console.log('Listening on: ' + config.port);});
    

    Каждый раз после установления соединения сервер создаёт экземпляр ClientConnection, передавая ему сокет и конфигурационный объект.

    Фрагменты кода модуля ClientConnection.

    Инициализируем поля экземпляра:

    var ClientConnection = function (socket, config){...}
    

    В прототипе определяем метод ClientConnection.to, который будет осуществлять переключение машины.

    ClientConnection.prototype.to = function (newState) {
    	// есть onExitHandler?
    	if (this.currentState && this.states[this.currentState].onExitHandler && 
    		typeof this.states[this.currentState].onExitHandler == 'function') { 
    			this.states[this.currentState].onExitHandler.call(this); 
    	}
    
    	var prevState = this.currentState; 
    	this.currentState = newState;
    
    	// есть inputHandler?
    	if (this.currentState && this.states[this.currentState].inputHandler && 
    		typeof this.states[this.currentState].inputHandler == 'function') {
    			this.handleInput = this.states[this.currentState].inputHandler.bind(this);
    	} else { 
    		this.handleInput = this.noInputHandler 
    	}
    	
    	// есть onEnterHandler?
    	if (this.states[this.currentState].onEnterHandler && 
    		typeof this.states[this.currentState].onEnterHandler == 'function') { 
    			this.states[this.currentState].onEnterHandler.call(this, prevState); 
    	}
    
    	return this;
    }
    

    При переключении мы проверяем, имело ли предыдущее состояние метод onExitHandler и, если имело, вызываем его.
    Затем назначаем методу handleInput машины указатель на inputHandler нового состояния. И, наконец, проверяем, есть ли у нового состояния метод onEnterHandler. Если есть — вызываем его.

    Что же происходит дальше после вызова ClientConnection.to(newState)? Если в вызовах onExitHandler и onEnterHandler не произошло переключения в другое состояние, машина остаётся в этом. И далее уже всё зависит от данных сокета. Все прилетающие пакеты будут направляться в handleInput. Почему?

    Дело в том, что при создании экземпляра мы сразу же переключаемся в состояние инициализации, где вешаем обработчики на события сокета:

    ClientConnection.prototype.states  = {
    	'inital': {
    		'onEnterHandler': function(){
    
    			// socket events
    			this.socket.on('timeout', function() {this.to('socket-timeout');}.bind(this));
    			this.socket.on('end', function() {this.to("socket-end");}.bind(this));
    			this.socket.on('error', function (exc) {this.to("socket-error").handleInput(exc);}.bind(this));
    			this.socket.on('close', function () {this.to("socket-close");}.bind(this));
    			this.socket.on('data', function (data) {this.handleInput(data);}.bind(this)); 
    					
    			this.to("waitingForHelloFromClient");
    		}
    	}, 
    ...
    }
    


    И уже затем переключаемся в следующее состояние. В нашем случае это 'waitingForHelloFromClient'.

    Все состояния описываются в объекте ClientConnection.prototype.states. Допустимы состояния, у которых нет inputHandler. При переключении в такие состояния, мы отрабатываем какой-то алгоритм внутри их onEnterHandler и сразу же переключаемся в другое состояние. Останавливаемся мы в том, которое имеет inputHandler, чтобы следующая итерация Event Loop могла вызывать код для обработки данных сокета, если они появятся. Категорически не рекомендуется делать переключения в onExitHandler.

    Собственно всё. Если код покажется удобным — применяйте на здоровье. Критика приветствуется.

    Хочу отметить, что есть решения (например, Machina.JS), которые в общем случае могут оказаться более удобными.
    Поделиться публикацией
    Похожие публикации
    Комментарии 29
      0
      Аргументы в пользу того, что в конкретно вашем случае код становится проще, звучат неубедительно. Кажется, что это усложнение ради усложнения.
      КА имеет смысл применять там, где много состояний, много переходов, могут быть циклические цепочки переходов. Остальные случаи можно отнести к тривиальным и пользоваться соответствующими более простыми средствами.
        –1
        Разумеется, что пример упрощён и этот подход там не оправдан. В рабочих проектах код несколько сложнее.

        Кроме того, разбиение на блоки всё-таки помогает лучше воспринимать код, явно видеть законченные фрагменты алгоритма и понимать, в каких случаях ожидается обработка данных от второй стороны, в каких — нет. Кроме того, я отступаю от строгости КА и конечное состояние зависит не только от исходного и входящих данных, но и от других параметров. К примеру, при передаче удалённому клиенту больших порций данных с докачкой, я храню счётчик переданного (байтов, фрагментов...) в объекте ClientConnection и в состоянии, например, «передатьБлокБольшихДанных» зацикливаюсь, пока счётчик не укажет на последний фрагмент «больших данных».

        Также в некоторых случаях приходится вычитывать буфер данных с удалённого датчика, работая в двух состояниях «ожидаемНовыйФрагментБуфера» и «обрабатываемНовыйФрагментБуфера». И только получив в первом состоянии код об опустошении буфера переключаться дальше.
        0
        А, позвольте уточнить, почему не использовать генераторы (function* foo() {}) или копроцедуры (async function foo() {})? Они позволяют описывать конечные автоматы неявно, кратко, и не заботясь о вынесении состояния «за скобки».
          0
          Как минимум, потому что, к своему стыду, мне названные вами абстракции не были знакомы. Бегло посмотрев на генераторы, могу предположить, что его выполнение линейно, а мне в ряде случаев нужно оставаться в состоянии и обрабатывать некоторое количество событий сокета, а не переключаться сразу дальше. С асинхронными функциями сходу не понял, но, замечу, что использование машины состояний в моем коде не имеет явного отношения к асинхронности, промисам и проч.
            –1
            Выполнение генератора линейно, как и выполнение функции, но ничто не мешает вам оставаться в нужном состоянии сколько угодно долго, используя циклы:
            async function shell(password) {
                await write('What is the password?');
                while (true) {
                    const guess = await read();
                    if (guess === correctPassword) {
                        await write('Nice guess!');
                        break;
                    }
                    await write('Nope! ;)');
                }
                
                while (true) {
                    await write('Okay, now enter some secret command');
                    const command = await read();
                    switch (command) {
                        case 'secret': {
                            await write('Purrrfect!');
                            break;
                        }
                        case 'bye': {
                            await write('Okay, see you later');
                            return;
                        }
                    }
                }
            }
            


            Естественно, это все можно описать при помощи конечного автомата, потому что это и есть конечный автомат в конечном итоге — но такая запись, как мне кажется, немного человекочитаемее.
              +1
              Приведённый в комментарии код не про генератор же, нет?
              Всё таки мне непривычно, но не буду торопиться с выводами — надо пробовать.
                –1
                Ну, приведенный в комментарии код можно записать и как генератор — при помощи вот этого, например. В таком случае `await` будет `yield`, а больше ничего не изменится.
            +1
            Возможно, автор писал свое решение, когда генераторы не поддерживались нативно в nodejs, а транспилировать он не хотел.
              0
              Возможно, но что плохого в транспилировании? Речь ведь не о языках типа CoffeeScript или LiveScript, которые должны поддерживаться, и если поддержка прекратится, то все, приехали. Транспиляция идет из фактически будущей версии языка, которую рано или поздно будет поддерживать платформа, и от транспиляции этих функций можно будет отказаться.
                0
                Ну есть кое-какие неудобства, для кого-то незначительные, для кого-то show stopper.
                  0
                  А можно пример этих неудобств?
                    +1
                    Стек-трейс показывает невесть куда, ошибки могут быть невразумительные, сам процесс занимает время, прикручивать юнит-тесты — занятие не для слабонервных (особенно в karma). В общем, вы не поверите, но в мире до сих пор полно проектов, которые пишутся не на ES2015:)
                      –1
                      Стек-трейс показывает невесть куда
                      Так, первую проблему я вам решил.
                      ошибки могут быть невразумительные
                      Это в случае ошибок в самом транспайлере? Нельзя исключать, конечно. А при использовании транспайленного кода sourcemaps это обрабатывают.
                      сам процесс занимает время
                      Чего именно, внедрения транспайлера? Тот же babel подключается, например, следующим образом:
                      exec node --require "foobar/boot.js" "foobar/main.js";
                      А сам boot.js — это что-то типа такого. Надо будет повыбрасывать оттуда кучу трансформаций, т.к. их уже node давно поддерживает нативно, типа тех же классов. В таком случае, строку запуска можно поменять на что-то типа этого:
                      exec node \
                      	--es_staging \
                      	--expose_debug_as=V8Debug \
                      	--expose_gc_as=V8GC \
                      	--harmony \
                      	--harmony_array_includes \
                      	--harmony_arrow_functions \
                      	--harmony_atomics \
                      	--harmony_concat_spreadable \
                      	--harmony_default_parameters \
                      	--harmony_destructuring \
                      	--harmony_modules \
                      	--harmony_new_target \
                      	--harmony_object \
                      	--harmony_object_observe \
                      	--harmony_proxies \
                      	--harmony_reflect \
                      	--harmony_regexps \
                      	--harmony_rest_parameters \
                      	--harmony_sharedarraybuffer \
                      	--harmony_shipping \
                      	--harmony_simd \
                      	--harmony_sloppy \
                      	--harmony_spread_arrays \
                      	--harmony_spreadcalls \
                      	--harmony_tostring \
                      	--harmony_unicode_regexps \
                      	--strong_mode \
                      	--strong_this \
                      	--throw-deprecation \
                      	--require "foobar/boot.js" \
                      	"foobar/main.js";

                      прикручивать юнит-тесты
                      Признаться, я пользуюсь Mocha, и с ней никаких проблем не было — запуск тестов ничем не отличается от планового запуска кода, дополнительных телодвижений для этого не нужно. Для karma, как я погуглил, есть варианты:
                      1) использовать --require boot.js, как в примере выше, тогда все будет «просто работать»
                      2) использовать штуки типа github.com/babel/karma-babel-preprocessor
                      в мире до сих пор полно проектов
                      Есть уважительная причина — legacy. Для новых проектов нет причин, почему не использовать ES2015/ES2016.
                        –1
                        Есть уважительная причина — legacy. Для новых проектов нет причин, почему не использовать ES2015/ES2016.

                        Про это самое я и говорил в первом комментарии:) С чего вы взяли, что это новый проект? Вон, товарищ даже классы не использует.

                        Про дистанцию от «погуглил, есть варианты» до «емае, наконец-то это поделие работает» в случае с karma писать не буду, больная тема:(
                          –1
                          Ну, относительно новый, почти год. Но я не стараюсь писать код с использованием последних нововведений стандартов, поскольку в клиентской части внедрение сдерживается браузерами и менее подконтрольно, чем версия ноды на сервере.
                          О каких классах речь? TypeScript? Или нативные есть?
                            –1
                            в клиентской части внедрение сдерживается браузерами

                            Парадоксально, но на момент написания этого комментария поддержка ES2015 в Chrome, FF и Edge полнее, чем в последней версии node.js.

                            Или нативные есть?

                            Не берусь передать всю силу моих эмоций:) Да, есть.
                              +1
                              Нативные есть в es6. Для серверной части новые плюшки можно использовать спокойно. Часть из них нода поддерживает с флагами, часть уже стандартно.
                              Для клиентской части есть полифилы и библиотеки/бандлеры, которые прозрачно их вставляют.

                              В любом случае, есть babel, его можно настроить так, чтобы всё работало и на сервере и на клиенте (ну за исключением совсем уж древних браузеров).
                                +1
                                Оговорка — «совсем уж древних» — это таких, которые не поддерживают даже ES3. Т.е. стандарт, которому 17 лет.
                            +2
                            Для новых проектов нет причин, почему не использовать ES2015/ES2016.

                            Усложнение сборки проекта. Вернее кроме сборки добавление этапа компиляции.
                              0
                              Тот же babel предлагает require hook, для которого нет вообще никакого дополнительного этапа компиляции — только лишь единственный ключ при старте node, который я привел выше. Так как компиляция происходит единожды при импорте модуля, а затем он кешируется до изменения mtime, то его спокойно можно использовать в продакшене. Результат такой компиляции ничем не отличается от предварительной сборки.
                                0
                                Усложнение

                                только лишь единственный ключ

                                Этот единственный ключ может понадобиться ввести в 100500 местах синхронно у всех разработчиков, админов и т. д.
                                  0
                                  синхронно у всех разработчиков
                                  Какой кошмар, а зачем? Только не говорите, что ваше приложение запускается просто как «node app.js» с командной строки, без npm scripts или какого-нибудь Procfile.
                                    0
                                    А если forever?
                                      +1
                                      Это хороший вопрос, мне пришлось поставить forever и порыться в его исходниках, чтобы разобраться. Самое простое решение:
                                      {
                                          "uid": "foobar",
                                          "append": true,
                                          "watch": true,
                                          "script": "foobar.js",
                                          "sourceDir": "/path/to/app",
                                          "command": "node --harmony --require /path/to/app/boot.js"
                                      }
                                      Правда, оно не очень красивое, так как sourceDir дублируется в command, потому что авторы forever не предусмотрели экспортировать переменные окружения вида FOREVER_SOURCE_DIR, хотя тут это бы пригодилось.

                                      Решение получше — pull request в forever с экспортом переменных окружения, что я сейчас и сделаю.
                –1
                Возможно автору будет интересно как в свое время я писал Конечный автомат на bash
                Окончательное решение в конце статьи.
                Основная идея такова:

                Начало главного цикла
                Выполнить действие текущего состояния *
                Выполнить выход из текущего состояния **
                Конец главного цикла

                * Выполнение функции, которая связана с текущим состоянием (в моем случае состоянием считалось выполнение какого-то простого действия).
                ** Выполнение функции, которая жестко привязана к текущему состоянию. Она проверяет условия и выбирает следующее состояние.
                  0
                  Интересно! Но я точно не стал бы делать это на bash :) Синтаксис совсем непривычный, да linux пользуюсь не часто. А тот же js позволяет автоматизировать процессы нодой на линуксе и нодой или WSH под виндой.
                    –1
                    Я не о языке реализации (в моем случае надо было запускать различные утилиты под linux, потому bash), а об организации кода. Автомат выполняется в замкнутом цикле. Текущее состояние = ссылка на функцию, но можно сделать и на объект. В последнем случае удобно реализовать функцию перехода к следующему состоянию в виде метода.
                      0
                      Да, я понял. Есть еще один момент — на примере я не уловил, где обработка входных данных? Есть ли вообще? Или состояния используются только для группировки действий? В статьей сказано, что ожидаются результаты каких-то «сервисов», но не совсем понятно — как это происходит.
                        0
                        Состояния используются для выполнения внешних действий — запуска утилит.
                        После завершения действия выполняется анализ ее результата и других обстоятельств и на основании этого выбирается следующее состояние (действие).

                        Например:
                        Состояние — импорт списка стран из внешнего сервиса.
                        По завершению проверяем все ли страны наша система распознала.
                        Если нет, то следующее состояние — отправка просьбы о помощи оператору.
                        Если все страны распознаны, следующее состояние — импорт городов.

                Только полноправные пользователи могут оставлять комментарии. Войдите, пожалуйста.

                Самое читаемое