Всё познаётся в сравнении, или реализация одной простенькой задачи на python и tcl

В силу исторических причин, у нас в конторе, используется старенькая АТС Panasonic TDA200. И, как известно, журнал звонков она выводит в последовательный порт, для чтения данных из которого, на сервере использовалась одна программулька. У этого ПО есть ряд ограничений, делающий его использование неудобным (размер лог-файла, размер БД) и дабы побороть эти недостатки и в силу природной лени (чтобы избежать постоянной очистки лога и БД вручную) было решено набыдлокодить что-то своё. А так как, уже давно, на глаза попадается слово «python» да и пытливый ум периодически просыпается, то решено было данную задачу реализовать на этом языке и попутно на, хорошо мне знакомом, tcl. Ну а результатами решил поделиться с обществом. Да, сразу замечу, что задача решена и сервис доведён до «промышленной» эксплуатации. Для хранения данных используется СУБД MariaDB (оно уже было), в качестве хост-системы CentOS 7.

И ещё одно замечание — питонист из меня ещё тот, поэтому за качество кода пинать можно но не сильно. Примеры кода будут приводиться в таком порядке: сперва питон потом тикль, описание процедур и команд будет идти в порядке необходимом для понимания работы скриптов, а не так как они записаны в коде.

Python

import pymysql
import sys, os
import re
import datetime
# параметры соединения с СУБД
db_host = 'host'
db_user = 'dbuser'
db_pass = 'dbpass'
out_dir = '/var/log/ats'

Tcl

package require mysqltcl
# параметры соединения с СУБД
#set db(host) "host"
#set db(user) "user"
#set db(pass) "password"
#set db(dbname) "ats_test"
#set out_dir "~/tmp/ats"

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

config.tcl
# параметры соединения с СУБД
set db(host) "host"
set db(user) "user"
set db(pass) "password"
set db(dbname) "ats_test"
set out_dir "~/tmp/ats"


Скрипт может обрабатывать данные из текстового файла и напрямую из последовательного порта, для этого добавлены ключи для запуска, соответсвенно -port — чтение из порта, -file — чтение из файла:


if __name__ == "__main__":
    if len(sys.argv) > 2:
        if sys.argv[1] == '-port':
            #action = 'read_port'
            port_name = sys.argv[2]
            port_data_read(port_name)
        if sys.argv[1] == '-file':
            #action = 'read_file'
            log_file_name = sys.argv[2]
            log = open(log_file_name)
            for line in log:
                parce_string(line)
            log.close()
    else:
        print ("\nФормат вызова:\n- для обработки файла\
        \n # python data_reader.py -file TDA20013082015_12052016.lg\
        \n- для чтения данных напрямую с com-порта АТС\
        \n # python data_reader.py -port /dev/ttyUSB0\n")
        sys.exit(1)

В tcl-скрипте добавлена еще одна опция -conf, тестирование кода проводилось на рабочем сервере, и корячить туда помимо питона ещё и тикль, было уже черезчур. И по сему, пришлось собирать «экзешник» по принципу «всё включено» а чтобы обеспечить гибкость настроек и была добавлена эта опция (да и так правильнее).

# Обработка ключей командной сроки
if {[llength $argv] >= 2} {
    if {[lindex $argv 0] == "-conf"} {
        source [lindex $argv 1]
    } else {
	puts "Не указан конфигурационный файл"
    }
    if {[lindex $argv 2] == "-port"} {
        set port_name [lindex $argv 3]
        PortDataRead $port_name
    }
    if {[lindex $argv 2] == "-file"} {
        set log_file_name [lindex $argv 3]
        set log [open $log_file_name "r"]
        # проверям наличие каталога и если его нет то создаём
        if {[file isdirectory $out_dir] == 0} {
            file mkdir $out_dir
        }
        # читаем файл построчно
        while {[gets $log line] >= 0} {
            ParceString $line
        }
        close $log
    }
} else {
    puts "\nФормат вызова:\n- для обработки файла\
    \n # -conf config.tcl
    \n # tclsh logger.tcl -conf config.tcl -file TDA20013082015_12052016.lg\
    \n- для чтения данных напрямую с com-порта АТС\
    \n # tclsh logger.tcl -conf config.tcl -port /dev/ttyUSB0\n"
    exit
}

Идём дальше. Функции работы с последовательным портом:

def port_data_read(port_name):
    global out_dir
    """Чтение данных из последовательного порта"""
    import serial
    ser = serial.Serial(port_name)
    ser.baudrate = 9600
    while True:
        # читаем строку из порта
        line = ser.readline()
        # декодируем строку в текстовый формат
        line = line.decode()
        # обрезаем лишние пробельные символы
        line = line.rstrip()
        # отдаём процедуре обработки строки
        parce_string(line)

В tcl операции работы с файлами или портами используются одни и те же. Т.е. сперва создаётся так называемый pipe (труба или канал) командой open потом уже из этой «трубы» читаются данные или записываются туда, будь то файл или последовательный порт.


# читаем данные из порта
proc PortDataRead {portName} {
    global fh
    # открываем порт в режииме "только чтение"
    set fh [open $portName RDONLY]
    # настраиваем канал в неблокирующем режиме и соответсвующими параметрами порта
    fconfigure $fh -blocking 0 -buffering line -mode 9600,n,8,1 -translation crlf -eofchar {}
   # "навешиваем" событие
    fileevent $fh readable Read
    vwait forever
}
# читаем строку из порта и отправляем на обработку
proc Read {} {
    global fh
    if {[gets $fh line] >= 0} {
	ParceString $line
    }
}

Команда

 fileevent $fh readable Read

позволяет навешивать на канал событие, точнее реакцию на событие, в данном случае мы указали, что при появлении каких-то данных в канале выполнять процедуру Read.

Вот и подошли к ключевому моменту — разбору строки. АТС «выбрасывает» данные ввиде строки где поля разделены пробелами, точнее, для каждого поля задан размер в символах и отсутствующие данные добиваются пробелами:

30/09/16 10:44     501 01 <I>                       0'00 00:00'13            D0

Код функции на питоне:

def parce_string(line):
    """Получает на вход строку и раскидывает её в нужном виде и пишет в файл"""
    # тут проверяется строка на ненужный хлам
    if line[:3] == "---" or line == "" or line[3:7] == "Date":
        print(line)
        return
    print(line)
    # Создаём текстовые файлы на всякий случай, для дублирования информации
    now = datetime.datetime.now()
    out_log_name = os.path.join(out_dir, '{}_{}'.format(now.month, now.year))
    out_log = open(out_log_name,"a+")
    out_log.write(line + '\n')
    out_log.close()
    # Разбор строки
    # Преобразуем дату к виду "ДД/ММ/ГГГГ" (с годом решил не мудрствовать а решить в лоб)
    call_date = "20{}/{}/{}".format(line[6:8],line[3:5],line[:2])
    # выдёргиваем данные и обрезаем лишние пробелы
    call_time = line[9:14].strip()
    int_number = line[19:22].strip()
    ext_co_line = line[23:25].strip()
    dial_number = line[26:51].strip()
    ring = line[52:56].strip()
    call_duration = re.sub("'", ":", line[57:65].strip())
    acc_code = line[66:77].strip()
    call_code = line[77:81].strip()
    # Проверяем признак входящщего звонка
    if dial_number == "<I>":
        call_direct = "Входящий"
        dial_number = ""
    elif dial_number[:3] == "EXT":
        call_direct = "Внутренний"
        dial_number = dial_number[3:]
    else: call_direct = "Исходящий"
    # отправлем в процедуру добавление в БД
    insert(call_date=call_date,
           call_time=call_time,
           int_number=int_number,
           ext_co_line=ext_co_line,
           dial_number=dial_number,
           ring=ring,
           call_duration=call_duration,
           acc_code=acc_code,
           call_code=call_code,
           call_direct=call_direct)

В строковых функциях между питом и тиклем есть некоторые различия, к примеру line[9:14] вернёт содержимое строки начиная с 9 и кончая 13 символом включительно, т.е. в качестве правой границы указывается следующий за значимым символ. В tcl, для этой цели, используется команда [string range $line 9 13].

proc ParceString {line} {
    global out_dir arrVariables
    # Получает на вход строку и раскидывает её в нужном виде и пишет в файл
    if {[string range $line 0 2] == "---" || $line == "" || [string range $line 3 6] == "Date"} {
        #puts $line
        return
    }
    # Создаём текстовые файлы на всякий случай, для дублирования информации
    # получим имя файла ввиде ММ_ГГГГ используя цепь вложенных команд clock
    set fName [clock format [clock scan "now" -base [clock seconds]] -format %m_%Y]
    set out_log_name [file join $out_dir $fName]
    set out_log [open $out_log_name "a+"]
    puts $out_log "$line"
    close $out_log
    # Разбор строки
    # все данные сохраняются в именованом массиве переменных
    # Преобразуем дату к виду "ДД/ММ/ГГГГ"
    set arrVariables(call_date) "20[string range $line 6 7]\/[string range $line 3 4]\/[string range $line 0 1]"
    set arrVariables(call_time) [string range $line 9 13]
    set arrVariables(int_number) [string range $line 19 21]
    set arrVariables(ext_co_line) [string range $line 23 24]
    set arrVariables(dial_number) [string range $line 26 50]
    set arrVariables(ring) [string range $line 52 55]
    set arrVariables(call_duration) [string range $line 57 66]
    set arrVariables(acc_code) [string range $line 66 76]
    set arrVariables(call_code) [string range $line 77 81]
    # Проверяем признак входящщего звонка
    if {$arrVariables(dial_number) == "<I>"} {
        set arrVariables(call_direct) "In"
        set arrVariables(dial_number) ""
    } elseif {[string range $arrVariables(dial_number) 0 3] == "EXT"} {
        set arrVariables(call_direct) "Ext"
        set arrVariables(dial_number) [string range $arrVariables(dial_number) 3 end]
    } else {
        set arrVariables(call_direct) "Out"
    }
    InsertData

В тикле есть такая замечательная штука, как массив переменных, в данном случае это arrVariables(), в который сохраняются все данные в соответсвующих переменных, определяемых ключом, к примеру arrVariables(call_time) — это время звонка. Можно было это всё сохранить ввиде списка списков «ключ — значение», на примере предыдущей переменной, это выглядело-бы следющим образом:

lappend lstVar [list call_time [string range $line 9 13]]
т.е. в список lstVar (точнее переменную содержащую список) добавляем список из двух значений call_time и содержимое строки $line между 9 и 13 символами включительно.

А теперь добавим строку в БД, структура которой описана ниже:

Структура таблицы
CREATE TABLE `cdr` (
`id` int(11) NOT NULL AUTO_INCREMENT,
`call_date` date DEFAULT NULL,
`call_time` time DEFAULT NULL,
`int_number` varchar(11) DEFAULT NULL,
`ext_co_line` char(2) DEFAULT NULL,
`dial_number` varchar(30) DEFAULT NULL,
`ring` varchar(5) DEFAULT NULL,
`call_duration` time DEFAULT NULL,
`acc_code` varchar(20) DEFAULT NULL,
`call_code` char(2) DEFAULT NULL,
`call_direct` varchar(45) DEFAULT NULL,
PRIMARY KEY (`id`)
) ENGINE=InnoDB AUTO_INCREMENT=2775655 DEFAULT CHARSET=utf8 COMMENT='Call Data Records';

Запрос к БД строится динамически на основе параметров переданных в функцию. Из кода, в принципе, всё понятно — форматируем строки в соответствии с требованиями к SQL-запросу, в нужных местах вставляем запятые или скобки и т.д. И так как запрос строится динамически, то в некоторых местах добавляется лишняя запятая и пробел, которые приходится удалять командой rstrip(', ') (можно, конечно, считать количество полей и добавлять нужное число запятых, но накладных расходов это не уменьшит и потому сделано так). Так как данные сыпятся ни часто, то на каждую строку данных (один запрос) выполняется одна транзакция, т.е. подключились, выполнили запрос, отключились.
И собсвенно код функции:

def insert(**kwargs):
    """Вставка данных в БД. В качестве параметров список полей и значений"""
    qwery = 'INSERT INTO `cdr` ('
    for key in kwargs.keys():
        qwery = "{} `{}`, ".format(qwery,key)
    qwery = qwery.rstrip(', ') + ') VALUES('
    for key in kwargs.keys():
        #qwery = qwery + '"' + kwargs.get(key) +'", '
        qwery = "{} \"{}\",".format(qwery,kwargs.get(key))

А теперь то же самое на тикле:

proc InsertData {} {
    global arrVariables db
    set qwery "INSERT INTO `cdr` ("
    # читаем тот  самый массив с параметрами и генерим запрос
    foreach key [array names arrVariables] {
        set qwery "$qwery `$key`, "
    }
    set qwery "[string trimright  $qwery ", "]\) VALUES\("
    foreach key [array names arrVariables] {
        set qwery "$qwery \"[string trim $arrVariables($key)]\","
    }
    set qwery "[string trimright $qwery ", "]\);"
    puts $qwery
    # подключаемся к БД выполняем запрос и отключаемся
    set conn [mysql::connect -host $db(host) -db $db(dbname) -user $db(user) -password $db(pass) -encoding utf-8]
    mysql::exec $conn $qwery
    mysql::commit $conn
    mysql::close $conn
}

Вот на этом можно и завершить повествование. На мой взгляд никаких приемуществ, в данном конкретном случае, ни у того ни у другого языка нет (лукавлю слегка, по мне тикль красивше, но это в силу привычки). Сложностей с питоном также не возникает. Исходники тестировались в Centos и Fedore последних версий и Windows 10. Проектик (в части сбора данных) доведён до логического завершения и запущен в работу, есть еще простенькая вэб мордочка со справочником телефонов и отчетами по собранным данным, но это тема другой статьи.

Исходники доступны тут: Git репозитарий
Метки:
  • +15
  • 5,2k
  • 4
Поделиться публикацией
Комментарии 4
  • 0

    Чуток конструктивной критики:


    Обработка ключей командной сроки

    Я не знаю как в Tcl, но в Python для этого есть модуль getopt: https://docs.python.org/2/library/getopt.html


    def parce_string(line):

    Разбор записей фиксированного формата проще и быстрее всего делается через unpack: https://docs.python.org/2/library/struct.html


    def insert(**kwargs):

    Склеивать строку запроса из ключей вместе со значениями не очень хорошая идея. В вашем случае SQL инъекции можно не бояться, но если вдруг на входе прилетит лишняя запятая или кавычка, то запрос сломается и вы об этом даже не узнаете.


    Лучше использовать параметры SQL запроса:


    conn.execute("INSERT INTO `foo` (foo, bar) VALUES (?, ?)", (foo_value, bar_value))

    А чтобы сформировать сам запрос, циклы тоже использовать не обязательно. Можно оперировать списками:


    q_keys = kwargs.keys()
    q_values = kwargs.values()
    
    q_key_list = ", ".join([str(k) for k in q_keys]
    q_placeholder_list = ", ".join(['?' for k in q_keys])
    
    query = "INSERT INTO `cdr` (%s) VALUES (%s)" % (q_key_list, q_placeholder_list)
    connection.execute(query, q_values)

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


    Кроме собственно кода, я бы ещё добавил как минимум индексы к таблице, ротацию логов и хотя бы рудиментарную обработку ошибок. Поскольку у вас CentOS 7, можно просто добавить systemd unit и получить две последние плюшки практически безвозмездно. :)

    • 0
      Я не знаю как в Tcl, но в Python для этого есть модуль getopt: docs.python.org/2/library/getopt.html

      Да, я про getopt в курсе, и для тикля есть подобные, просто в данном случае для 2-3 опций использование дополнительного пакета счёл не целесообразным :)
      Склеивать строку запроса из ключей вместе со значениями не очень хорошая идея. В вашем случае SQL инъекции можно не бояться, но если вдруг на входе прилетит лишняя запятая или кавычка, то запрос сломается и вы об этом даже не узнаете.

      Тут набор данных строго регламентирован, лишнего не прилетит, если только не ошибки с порта.
      Лучше использовать параметры SQL запроса:

      В моём случае подойдёт вариант со списками, спасибо за подсказку. Как я понял данный код:
      q_key_list = ", ".join([str(k) for k in q_keys])

      Мы перебираем список «for k in q_keys» и из его элементов преобразованных в строку «str(k)» лепим опять же строку с разделителем ", ".

      Поскольку у вас CentOS 7, можно просто добавить systemd unit и получить две последние плюшки практически безвозмездно

      Так и сделано, сервис запускается через systemd.

      Спасибо за комментарий!
      • +1

        Есть еще argparse с дополнительными плюшками вроде документации, дефолтных значений, кастов к типам и прочим

      • 0
        Да, я про getopt в курсе, и для тикля есть подобные, просто в данном случае для 2-3 опций использование дополнительного пакета счёл не целесообразным :)

        Лучше сразу использовать готовые модули, это хорошая привычка. Потом всё равно придётся переписывать, когда надо будет добавить ещё один параметр. :)


        Тут набор данных строго регламентирован, лишнего не прилетит, если только не ошибки с порта.

        а) ошибки из порта, б) ошибки в коде, ц) раз в год и незаряженное ружьё стреляет. Ошибки в коде легко могут возникнуть, если надо будет поменять формат CDR или вообще кто-нибудь решит приспособить ваш скрипт для другой модели АТС. Да вы же сами и решите, потом когда-нибудь.


        Если же взять за правило всегда использовать параметры SQL, то можно не думать о многих проблемах потом. Ещё одна хорошая привычка. :)


        Мы перебираем список «for k in q_keys» и из его элементов преобразованных в строку «str(k)» лепим опять же строку с разделителем ", ".

        Всё правильно, но обратите внимание — всё это используется для того, чтобы построить список полей для вставки и список позиционных параметров. Сами значения передаются в execute(), что и обеспечивает отсутствие проблем с некорректными значениями.


        Вообще насколько я понимаю, в вашем случае приводить тип через str() не обязательно, поскольку ключи и так строки, но Python странный язык и допускает ключи других типов, числовые например. Поэтому я перестраховался, чтобы потом не ломать голову над странными ошибками. :)

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