Pull to refresh

Перенаправление данных из COM-порта в web. Доработка

Reading time 10 min
Views 23K
Недавно я опубликовал статью «Перенаправление данных из COM-порта в web», в которой описал прототип системы, транслирующей строки из последовательного порта компьютера в веб-браузер. В той статье я указал направления, в которых надо доработать прототип, чтобы приблизить его к продакшен-стадии:
— никакой дизайн веб-страницы
— в каждый момент времени данные получит только один веб-клиент
— очень ограниченный набор браузеров, с помощью которых можно получить доступ. Например, не работает ни в Internet Explorer 8, ни в браузере из Android 2.3.5
— требуется установка python

Через некоторое время я решил не оставлять его в таком виде и доработать. Под катом результат доработки и описание того, как я устранил все перечисленные недостатки.



Сразу покажу итоговый результат:

На этом видео видно, что строки из COM-порта отображаются сразу в трёх браузерах одновременно: в Firefox и IE 8 на том же компьютере, к которому подключена Arduino, и на смартфоне.
Сама Arduino передаёт строку «Температура: XXXXXX», а строку с датой-временем и номером строки — одна из частей бэкенда.

Теперь по порядку о том, как устранялись недостатки. В этот раз я постараюсь не быть очень нудным и не описывать каждую строчку кода, а показать только основные моменты. Если появятся вопросы, задавайте в комментариях.

Плохой дизайн веб-страницы



В предыдущей статье я писал, что в создании веб-фронтенда я разбираюсь от слова «никак», поэтому создание нормальной веб-страницы было для меня самым сложным. К счастью я почти сразу же обнаружил сайт w3schools.com, на котором открыл для себя Bootstrap и нашёл хорошие учебники по Ajax и jQuery. Для матёрых фронтенд-разработчиков представленные на этом сайте учебники, наверное, вызовут только улыбку, но для таких новичков, как я, это самое то, что надо.
Самое приятное в этих учебниках то, что они очень небольшие и очень по существу. Без размазывания каши по тарелке. В принципе, одного вечера на изучение достаточно, чтобы начать что-то делать.

Оказалось, что с помощью Bootstrap наваять более-менее приемлемый дизайн веб-страницы — это не так уж и сложно. Он, конечно, не сделает из вас Артемия Лебедева, но запрограммировать интерфейс с пользователем сделать на нём можно очень быстро.

Единственное, что я не смог понять, как с его помощью сделать нужное мне разбиение страницы на две части: большую, в которой текст отображается по середине в вертикальной плоскости, и маленькую, которая всё время «прижата» к нижнему краю окна браузера. Но тут на помощь пришла статья «Vertical Centering in CSS». В результате получилась вот такая заготовка под веб-страницу:

  <!-- Vertical aligment of text from "Vertical Centering in CSS" at 
  http://www.jakpsatweb.cz/css/css-vertical-center-solution.html -->
<!DOCTYPE HTML PUBLIC "-//W3C//DTD HTML 4.01//EN">
<html>
<head>
    <style type="text/css">
    html, body {
        height: 100%;
        margin: 0px;
    }
  </style>
</head>

<body>

  <div style="display: table; height: 90%; width: 100%; overflow: hidden;">
    <div style=" display: table-cell; vertical-align: middle;">
      <div style="text-align: center">
        data place<br>
        пока нет данных
      </div>
    </div>
  </div>

  <div style="display: table; height: 10%; width: 100%; overflow: hidden;">
    <div style=" display: table-cell; vertical-align: middle;">
        <div style="text-align: right;">
            buttons
        </div>
    </div>
  </div>
</body>
</html>


Практически весь остальной код страницы получился после чтения учебников по Bootstrap и JavaScript (раздел JS HTML DOM). Исключение — это код jQuery, который обновляет данные на странице. Но об этом чуть позже.

Ограниченный набор поддерживаемых браузеров



Слабая поддержка браузерами предыдущего прототипа была обусловлена выбранной технологией доставки обновлений информации: Server-Sent Events. Поэтому в этот раз я решил воспользоваться старой проверенной временем технологией Ajax. Использование Ajax-а приводит к увеличению веб-трафика, но зато, по-моему, должно работать в максимальном количестве браузеров.
Ещё одним недостатком Ajax-а можно считать тот факт, что, если не предпринять никаких специальных мер, то возможны пропуски строк, которые передаются через COM-порт: если строки в последовательный порт будут поступать очень быстро, а Ajax-запросы приходить реже, что все строки между запросами будут не видны клиенту. Но для задачи отображения, например, текущей температуры — это совсем не страшно.

Можно было бы, наверное, воспользоваться технологией WebSockets, но, насколько я понял, в IE она поддерживается только с 10-й версии, а у меня IE 8, поэтому я даже не стал прорабатывать это направление.
Ещё буквально на днях в какой-то из статей на хабре или гиктаймс я наткнулся на упоминание библиотеки SockJS, которая, похоже, умеет обходить отсутствие WebSockets, но, во-первых, для неё требуется спец. поддержка на стороне сервера, а, во-вторых, у меня к этому моменту нормально работал Ajax, поэтому и она осталась без моего внимания.

Итак, Ajax. Когда-то давно я пытался изучить эту технологию. Но все бумажные учебники, которые мне попадались, были слишком нудными и я очень быстро бросал это дело. А вот на уже упомянутом w3schools.com учебник оказался очень хорошим. В результате достаточно быстро получился следующий код:

        function get_data()
        {
            var xmlhttp;
            xmlhttp=new XMLHttpRequest();
            xmlhttp.open("GET","/get_serial?r=" + Math.random(),true);
            xmlhttp.onreadystatechange=function()
              {
              if (xmlhttp.readyState==4 && xmlhttp.status==200)
                {
                document.getElementById("data").innerHTML=xmlhttp.responseText;
                get_data();
                }
              }
            xmlhttp.send();
        }

который вызывался при окончании загрузки страницы:
<body onload="get_data()">


В этом коде надо, наверное, обратить внимание на два момента. Во-первых, на строчку
            xmlhttp.open("GET","/get_serial?r=" + Math.random(),true);

Именно в ней происходит обращение к веб-серверу за очередной строкой из COM-порта. Добавка
r=" + Math.random()

нужна для того, чтобы Internet Explorer не кэшировал ответы. В противном случае он пошлёт только один Ajax-запрос, получит ответ и больше уже не будет обращаться к серверу. В интернете я видел решения проблемы кэширования путём посылки со стороны сервера специальных HTTP-заголовков
        response.headers['Last-Modified'] = datetime.now()
        response.headers['Cache-Control'] = 'no-store, no-cache, must-revalidate, post-check=0, pre-check=0, max-age=0'
        response.headers['Pragma'] = 'no-cache'
        response.headers['Expires'] = '-1'

но у меня это почему-то не заработало.
Альтернативный вариант — это использование POST вместо GET. Говорят, что такие запросы IE не кэширует, но я не проверял.
Кстати, jQuery, использует точно такой же способ борьбы с кэшированием — он к УРЛ-у тоже добавляет случайную строку. Только выглядит она несколько иначе.

Второй момент — это последовательность строк
            xmlhttp.open("GET","/get_serial?r=" + Math.random(),true);

и
            xmlhttp.onreadystatechange=function()
            ...


Изначально они стояли в другом порядке. И в Internet Explorer-е это приводило к странным проблемам — он выжирал всю доступную память. И только после этого обновлял данные. На чём и заканчивал свою работу.
Только на сайте XmlHttpRequest.ru я нашёл, что при повторном использовании объекта XMLHttpRequest рекомендуется сначала вызвать метод open() и только после этого менять свойство onreadystatechange.

Получив столько проблем с IE на ровном месте, я решил, что надо бы воспользоваться библиотекой, в которой подобные нюансы скорее всего уже учтены. Поскольку для Bootstrap-а на странице уже загружался jQuery
  <script src="https://ajax.googleapis.com/ajax/libs/jquery/1.11.3/jquery.min.js"></script>

я решил, что негоже не воспользоваться поддержкой Ajax, встроенной в эту библиотеку. Поэтому код обновления данных преобразовался в такой:
        function on_load(responseTxt, statusTxt, xhr){
            if( statusTxt == "success" ){
                $("#data").load("/get_serial?r=" + Math.random(), on_load )
            } else {
                $("#data").text( "Error: " + xhr.status + ": " + xhr.statusText );
            }
        }
        $(document).ready(function(){
                on_load("", "success", 3);
            });


Один клиент в каждый момент времени



В предыдущей статье я уже писал про возможный путь решение этой проблемы: разбиение бэкенда на две части, использование ZMQ для их связи + многопользовательский http-сервер в качестве второй части. В качестве такого сервера я выбрал Flask. Я не проводил никаких сравнений альтернатив, он просто попался мне первым. Выяснилось, что его можно запустить в режиме параллельной обработки http-запросов и этого оказалось достаточным. Для запуска такого режима достаточно передать ему параметр
threaded = True (см. stackoverflow.com/questions/14672753/handling-multiple-requests-in-flask):

    app.run(host='0.0.0.0', debug=False, threaded=True)


Помимо многопоточности Flask предоставляет ещё очень удобный механизм маршрутизации, т.е. соответствия отдельных процедур определённым http-запросам. Так что для создания простеньких веб-приложений Flask — очень милое дело.

Но больше всего мне хотелось бы рассказать про библиотеку ZMQ — продемонстрировать её потрясающие возможности.
Вот код демонстрационного ZMQ-сервера:
import zmq
import time

context = zmq.Context.instance()

pub_sock = context.socket(zmq.PUB)
pub_sock.bind( 'tcp://*:12345' )

count = 0
def get_full_line_from_serial():
    global count
    
    count += 1
    return time.strftime( '%Y.%m.%d %H:%M:%S' ) + ': string N %d' % count


while True:
    line = get_full_line_from_serial()
    print line
    pub_sock.send( line )
    time.sleep(1)

который имитирует чтение из COM-порта. На самом деле он каждую секунду генерирует новую строку, «публикует» её (т. е. отсылает всем подписавшимся клиентам) и для отладки выводит её на печать.

А вод код клиента:
import zmq

context = zmq.Context.instance()
zmq_sub_sock = context.socket(zmq.SUB)
zmq_sub_sock.setsockopt(zmq.SUBSCRIBE, '')
zmq_sub_sock.connect( 'tcp://localhost:12345' )

poller = zmq.Poller()
poller.register( zmq_sub_sock, zmq.POLLIN )

while True:

    socks = dict(poller.poll(timeout=5000))
    if zmq_sub_sock in socks:
        print zmq_sub_sock.recv()
    else:
        print 'No data within 5 sec'

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

А вот демонстрация их совместной работы:

На что хочется обратить внимание. Во-первых, клиента можно запустить до запуска сервера. И это никак не скажется на его работоспособности. Во-вторых, даже падение сервера не приведёт к падению клиентов — как только сервер перезапустится, клиенты снова начнут получать сообщения от него. Ну не фантастика ли? И это при полном отсутствии каких-либо специальных инструкций в исходных кодах клиента и сервера. Сколько кода пришлось бы написать самостоятельно для реализации подобного функционала при использовании обычных TCP/IP сокетов?
И это только маленькая толика того, что умеет библиотека ZMQ. В очередной раз крайне настоятельно рекомендую присмотреться к этой библиотеке.

Standalone приложение



Я уже, вроде бы, упоминал, что python слабо приспособлен к созданию standalone приложений. Это интерпретируемый язык, для обычной работы которого требуется программа-интерпретатор. К сожалению для него не существует компилятора, который бы умел генерировать нативный бинарный код. Мне известны две программы, позволяющие создать из скрипта некое подобие самостоятельного приложения: py2exe и pyInstaller. Сам я чаще всего пользуюсь вторым, поэтому и в данном проекте решил использовать его.

pyInstaller берёт питоновский скрипт, анализирует все файлы, от которых он зависит (определяет все импортируемые модули и подключаемые динамические библиотеки) и далее либо собирает их все в отдельную папку, либо упаковывает в некий аналог самораспаковывающегося архива. Туда же добавляется код собственно интерпретатора, а также небольшая стартовая часть, которая при старте подготавливает среду исполнения и запускает интерпретацию целевого скрипта.

Поскольку ни создатели питона, ни создатели библиотек под него не задумывались о таком возможном использовании, всё это работает несколько «через пень-колоду». Основные проблемы заключаются в подготовке списка всех необходимых библиотек и модулей. Потому что многие авторы библиотек используют неявное импортирование, при котором нет явного указания на то, какой модуль будет импортирован в скрипт или другой модуль. Соответственно частенько после автоматического анализа скрипта pyInstaller-ом надо руками дописывать в настроечный файл (с расширением .spec) имена дополнительных модулей и/или путей, где их искать. Вместе с pyInstaller-ом идёт большой набор готовых функций для таких библиотек, но жизнь не стоит на месте. В частности так оказалось с текущей версией ZMQ — разработчики pyZMQ (биндинга ZMQ к python-у) изменили механизм импортирования вспомогательных библиотек, в результате чего собранное pyInstaller-ом приложение не запускается. Народ с этим делом уже разобрался и соответствующий патч для pyInstaller-а подготовили. Патч этот рабочий, но в официальный релиз пока не попал, поэтому пришлось руками патчить pyInstaller. Код патча смотри на github.com/pyinstaller/pyinstaller/pull/110/files

Но даже этого патчка оказалось мало в случае, если standalone приложение должно представлять из себя только один исполнимый файл, а не целую папку. Пришлось руками ещё корректировать .spec-файл, чтобы библиотека libsodium.pyd из дистрибутива ZMQ попала в нужное место при распаковке .exe-файла.

Вторая проблема заключалась в модуле multiprocessing. Бэкенд я разбил на две части, которые должны работать параллельно. Мне показалось неправильным, если для запуска бэкенда придётся запускать две отдельные программы. А использовать многопоточность (multithreading) мне показалось неправильным из-за наличия GIL (Global Interpretation Lock). Казалось бы, чего проще написать простенький скрипт, который при старте будет просто порождать два новых процесса: один для ZMQ-сервера, читающего данные из COM-порта, второй — для HTTP-сервера, отвечающего на запросы веб-клиентов. И действительно, при работе в обычном режиме (т. е. при «ручном» запуске интерпретатора) прекрасно работает следующий скрипт:

# -*- coding: utf-8 -*-

from multiprocessing import Process
import serial_to_zmq
import zmq2web_using_flask
import time


def main():
    args = get_command_line_params()
    
    p1 = Process( target=serial_to_zmq.work, args=(args.serial_port_name, args.serial_port_speed, args.zmq_pub_addr) )
    p1.start()
    
    p2 = Process(target=zmq2web_using_flask.work, args=(args.zmq_sub_addr,))
    p2.start()    
    
    print 'Press Ctrl+C to stop...',
    while True:
        time.sleep(10)
    
    
def get_command_line_params():
    ...
    
if __name__ == '__main__':
    main()


Но после обработки pyInstaller-ом полученный .exe нормально не работал. Происходил запуск нескольких ZMQ-серверов и Flask-приложений с соответствующими сообщениями о том, что «сокет уже занят» и ещё какие-то странности. Выяснилось, что при запуске Flask-приложения ему надо передавать параметр debug=False:

    app.run(host='0.0.0.0', debug=False, threaded=True)


а для модуля multiprocessing нужно вызывать специальную функцию freeze_support(), которая нужна в том режиме (froozen), который создаётся при интерпретации скрипта в случае standalone приложения, созданного pyInstaller-ом.

Вообщем, итог по данному пункту таков: создать standalone приложение из питоновского скрипта можно, но это не просто.

Все исходные тексты можно взять на гитхабе: github.com/alguryanow/serial2web-2

P.S. Ещё учебник по Bootstrap: www.tutorialrepublic.com/twitter-bootstrap-2.3.2-tutorial
Tags:
Hubs:
+10
Comments 13
Comments Comments 13

Articles