В предыдущей статье был рассмотрен подход к созданию csv из xml на базе данных, которые публикует ФИАС. В основу парсинга был положен DOM-парсер, загружающий в память весь файл целиком перед обработкой, что приводило к необходимости дробления файлов большого размера в виду ограниченного объема оперативной памяти. В этот раз предлагается посмотреть насколько хорош SAX-парсер и сравнить его скорость работы c DOM-парсером. В качестве подопытного будет использоваться наибольший из файлов базы ФИАС — houses, размером 27,5 ГБ.

Вступление


Вынуждены сразу огорчить почтеннейшую публику — сходу скормить SAX-парсеру файл БД ФИАС houses не удастся. Парсер вылетает с ошибкой «not well-formed (invalid token)». И первоначально были подозрения, что файл БД битый. Однако после нарезки БД на несколько мелких частей было установлено, что вылеты вызваны измененной кодировкой для номеров домов и/или строений. То есть в тегах STRUCNUM либо HOUSENUM попадались дома с буквой, записанной в странной кодировке (не UTF-8 и не ANSI, в которой сформирован сам документ):



При этом, если эту кодировку выправить, прогнав файл через функцию remove_non_ascii, запись принимала вид:



Такой файл также не поглощался парсером, из-за лишних знаков.

Пришлось вспоминать регулярные выражения и чистить файл перед загрузкой в парсер.
Вопрос: почему нельзя создать нормальную БД, которая выкладывается для работы приобретает оттенок риторического.

Чтобы выровнять стартовые возможности парсеров, очистим тестовый фрагмент от вышеуказанных нестыковок.

Код для очистки файла БД перед загрузкой в парсер:

Код
from datetime import datetime
import re
from unidecode import unidecode

start = datetime.now()

f= open('AS_HOUSE.462.xml', 'r',encoding='ANSI')
def remove_non_ascii(text):
        return unidecode(unidecode(text))

for line in f:    
        b=remove_non_ascii(line) 
        for c in re.finditer(r'\w{5}NUM="\d{1,}\"\w\"',b): 
                print(c[0])                      
                c1=c[0][:-3]+c[0][-2]
                print(c1) 
                b=b.replace(c[0],c1) # замена в строке        

                #сохраняем результат
                f1= open('out.xml', 'w',encoding='ANSI')
                f1.write(b)
                f1.close()

f.close()
print(datetime.now()- start)


Код переводит в xml-файле non_ascii символы в нормальные и затем удаляет лишние "" в наименованиях строений и домов.

SAX-парсер


Для старта возьмем небольшой xml файл (58,8 Мб), на выходе планируем получить txt или csv, удобный для дальнейшей обработки в pandas или excel.

Код
import xml.sax
import csv
from datetime import datetime

start = datetime.now()

class EventHandler(xml.sax.ContentHandler):
    def __init__(self,target):
        self.target = target
    def startElement(self,name,attrs):
        self.target.send(attrs._attrs.values())          
    def characters(self,text):
        self.target.send('')
    def endElement(self,name):
        self.target.send('')

def coroutine(func):
    def start(*args,**kwargs):
        cr = func(*args,**kwargs)
        cr.__next__()
        return cr
    return start

with open('out.csv', 'a') as f:
    # example use
    if __name__ == '__main__':
        @coroutine
        def printer():
            while True:
                event = (yield)                            
                print(event,file=f)
                

        xml.sax.parse("out.xml", EventHandler(printer()))

print(datetime.now()- start)


Выполнив программу получим значения словаря python:



Время выполнения: 5-6 сек.

DOM-парсер


Обработаем тот же файл, предварительно загрузив его целиком в память. Именно такой метод использует DOM-парсер.

Код
import codecs,os
import xml.etree.ElementTree as ET
import csv
from datetime import datetime

parser = ET.XMLParser(encoding="ANSI")
tree = ET.parse("out.xml",parser=parser)
root = tree.getroot()

Resident_data = open('AS_HOUSE.0001.csv', 'a',encoding='ANSI')
csvwriter = csv.writer(Resident_data)

attr_names = [
    'HOUSEID', 'HOUSEGUID', 'AOGUID', 'HOUSENUM', 'STRUCNUM', 
    'STRSTATUS', 'ESTSTATUS', 'STATSTATUS', 'IFNSFL', 'IFNSUL', 
    'TERRIFNSFL', 'TERRIFNSUL', 'OKATO', 'OKTMO', 'POSTALCODE', 
    'STARTDATE', 'ENDDATE', 'UPDATEDATE', 'COUNTER', 'NORMDOC', 
    'DIVTYPE', 'REGIONCODE'
]
start = datetime.now()
object = []
for member in root.findall('House'):    
    object = [member.attrib.get(attr_name, None) for attr_name in attr_names]
    csvwriter.writerow(object)    
Resident_data.close()
print(datetime.now()- start)


Время выолнения 2-3 сек.
Победа DOM-парсера?

Файлы побольше


Файлы небольшого размера не отражают действительности в полной мере. Возьмем файл побольше 353 Мб (предварительно почистив, как было указано выше).

Результаты погона:

SAX-парсер: 0:00:32.090836 — 32 сек
DOM-парсер: 0:00:16.630951 — 16 сек

Разница в 2 раза по скорости. Однако это не умаляет главного достоинства SAX-парсера — возможность обрабатывать файлы большого размера без предварительной загрузки в память.
Остается сожалеть, что данное достоинство не применимо к БД ФИАС, так как требуется предварительная работа с кодировками.

Файл для предварительной очистки кодировок:
— 353 Мб в архиве.

Очищенный файл БД для тестов парсеров:
— 353 Мб в архиве.

Комментарии (28)


  1. justhabrauser
    04.10.2019 13:08
    +1

    Вы немного не всё меряете.
    Не в скорости (точнее — не только в ней) дело.
    В конце DOM-парсера поставьте:

    import sys
    sys.getsizeof(tree)


    1. zoldaten Автор
      04.10.2019 15:22

      Допустим. С чем теперь сравнить размер дерева?


      1. justhabrauser
        04.10.2019 17:57

        Да ни с чем.
        С SAX точно нет смысла — там от размера XML размер парсера не зависит.
        Но можно сравнить с размером исходного XML и сделать для себя выводы.
        И прикинуть когда машина упадет в swap (а потом и в kernel panic) при обработке houses, ага.


  1. rSedoy
    04.10.2019 14:51

    Ну зачем выкладывать код такого качества, что вытекают глаза. Из-за этого пропадает желание разбираться всё ли правильно сделано, искать проблемные места и т.п.


    1. zoldaten Автор
      04.10.2019 15:07

      В каком месте у вас выпадают глаза?


      1. rSedoy
        04.10.2019 15:12

        куча закомментированных мест, несоблюдение pep8, портянка append и использование except не по назначению


        1. zoldaten Автор
          04.10.2019 15:17

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

          Кстати, почему except не по назначению?


          1. rSedoy
            04.10.2019 15:31

            вы опубликовали код который будет прочитан кучей людей, потратьте время на его причесывания.
            общий except зло, а в вашем случае, скорее всего должно быть что-то типа get для словарей


          1. WhiteBlackGoose
            05.10.2019 13:37

            А то есть остальное это ОК? То есть написать что-то типа


            words = ['IFNSFL', 'IFNSUL' ... ]
            for w in words:
                object.append(member.attrib[w] if w in member.attrib else None)

            Это куда хуже, чем такой замечательный код, как у вас?


            1. zoldaten Автор
              06.10.2019 22:38

              Так никто и не утверждает, что код идеален. Сравнение скорости работы парсеров это не туториал по курсу python.
              Приятно, что сообщество активно борется за лучший код, но тыкать exceptoм это из области «у вас ус отклеился».
              Конечно, тот, кто пишет про парсеры, не догадывается, что общий except это зло.


  1. GCU
    04.10.2019 15:09

    Можно кормить sax парсер через feed построчно непосредственно во время «очистки» файла БД, это будет быстрее, чем писать/читать новый обработанный файл. Более того — можно читать данные непосредственно из архива, распаковывая по мере обработки.

    P.S. Зачем заморачивались с coroutine я не понял :)


    1. zoldaten Автор
      04.10.2019 15:15

      1.Хорошая идея, построчно. Только вот база в одну бесконечную строку. Точнее в 2-е, 1-я xml заголовок.
      2.Очищенный файл для выравнивая стартовых условий парсеров.

      p.s. Корутин не работал, пришлось выяснять почему. Оказывается из питона 2.7 переехал в 3-й с удвоенными подчеркиваниями: cr.__next__()


      1. GCU
        04.10.2019 15:30

        Ну значит кормить блоками некоторой длины :)
        Просто ваш «код очистки» вроде написан построчно.

        Про условия — пожалуй нечестно сравнивать
        SAX: дергание coroutine+print на каждое событие
        DOM: csvwriter только тегов HOUSE.
        Тогда уж пусть и SAX просто пишет CSV в startElement, если тэг HOUSE


        1. zoldaten Автор
          04.10.2019 15:38

          Ну значит кормить блоками некоторой длины :)

          Не уверен, что это будет работать быстрее.
          Про условия — пожалуй нечестно сравнивать

          Тогда в защиту DOM добавим то, что он выполняет конкатенацию элементов перед записью.


          1. GCU
            04.10.2019 16:02

            Не уверен, что это будет работать быстрее

            DOM парсер тоже можно кормить по-частям, смысл в том, чтобы избавиться от чтения/записи дополнительного «очищенного» файла, особенно если он довольно большой.

            Фишка SAX тут скорее в другом — он может писать результаты ещё до того, как файл обработан целиком, в то время как с DOM можно работать только после загрузки целиком (ну и сожрёт много памяти).

            На современном железе чтение из архива с распаковкой может работать даже быстрее, чем чтение уже распакованного файла с диска. Судя по примеру — для обработки требуется всего один проход по файлу, так что в идеале программа будет ограничена лишь скоростью работы с диском.


  1. GCU
    04.10.2019 16:26

    Остается сожалеть, что данное достоинство не применимо к БД ФИАС, так как требуется предварительная работа с кодировками.

    Вполне применимо, как я уже писал в комментарии.
    Да, это не так красиво как просто дать имя файла. Придётся кормить парсер по-частям. Читать часть, чинить кодировку и кормить парсер :)

    Для простого фильтра по тегам для SAX 27,5 ГБ ни о чем.
    Тем более что парсер ничего кроме текущей «лексемы» не помнит.

    DOM парсер для этого тоже подходит, его так-же можно кормить по-частям, но следить за объёмом «еды», как только размер достигнут — скормить ему фиктивный «конец», обработать «документ» и начать парсить новый, скормив ему фиктивное «начало». Это практически то-же самое, что было описано с разбиением файлов на маленькие в предыдущей статье, только без самих файлов (их не надо писать на диск и потом читать).


    1. zoldaten Автор
      04.10.2019 17:09

      Это все так. Как говорится, осталось написать только код.
      1.Кроме того, кодировка поломана только 10-15 случаях в файле 350Мб. Считаете, что для этого надо дробить при кормлении? Лишние условия для проверки увеличат время работы.
      2. Следить за объемом еды для DOM. C какой целью, если заранее известен объем, при превышении которого программа вылетает. И как по частям кормить DOM, если он изначально заглатывает весь файл?


      1. GCU
        04.10.2019 18:32

        Считаете, что для этого надо дробить при кормлении? Лишние условия для проверки увеличат время работы.

        Требует проверки, но не думаю что значительно. Можно поэкспериментировать на скрипте для «очистки», обрабатывая блоки в 4Кб, 8Кб, 16Кб… и т.д. вместо одной «строки»

        И как по частям кормить DOM

        В примере вы используете xml.etree.ElementTree.parse
        Как это работает
        Если заглянуть внутрь, там вот это:
        def parse(source, parser=None):
            """Parse XML document into element tree.
        
            *source* is a filename or file object containing XML data,
            *parser* is an optional parser instance defaulting to XMLParser.
        
            Return an ElementTree instance.
        
            """
            tree = ElementTree()
            tree.parse(source, parser)
            return tree

        Что ведёт к методу ElementTree
            def parse(self, source, parser=None):
                """Load external XML document into element tree.
        
                *source* is a file name or file object, *parser* is an optional parser
                instance that defaults to XMLParser.
        
                ParseError is raised if the parser fails to parse the document.
        
                Returns the root element of the given source document.
        
                """
                close_source = False
                if not hasattr(source, "read"):
                    source = open(source, "rb")
                    close_source = True
                try:
                    if parser is None:
                        # If no parser was specified, create a default XMLParser
                        parser = XMLParser()
                        if hasattr(parser, '_parse_whole'):
                            # The default XMLParser, when it comes from an accelerator,
                            # can define an internal _parse_whole API for efficiency.
                            # It can be used to parse the whole source without feeding
                            # it with chunks.
                            self._root = parser._parse_whole(source)
                            return self._root
                    while True:
                        data = source.read(65536)
                        if not data:
                            break
                        parser.feed(data)
                    self._root = parser.close()
                    return self._root
                finally:
                    if close_source:
                        source.close()
        

        По сути это XMLParser, которого кормят по-частям кусками по 64Кб :)
        Как еда заканчивается, parser.close() вернёт root


        1. zoldaten Автор
          04.10.2019 20:31

          … переписать модуль python? По этой тропе недалеко и до разработки собственного языка.
          Тем не менее, хорошо, что вы глубоко ныряете.


  1. TIEugene
    04.10.2019 18:05

    Даже SAX-парсер можно значительно ускорить правильным его выбором.


    1. zoldaten Автор
      04.10.2019 20:21

      Читал предварительно вашу статью, впечатляет. Но у вас 10% материала отбраковано парсером…


  1. ShashkovS
    04.10.2019 18:13

    Простыню append'ов можно сделать так:

    attr_names = [
        'HOUSEID', 'HOUSEGUID', 'AOGUID', 'HOUSENUM', 'STRUCNUM', 
        'STRSTATUS', 'ESTSTATUS', 'STATSTATUS', 'IFNSFL', 'IFNSUL', 
        'TERRIFNSFL', 'TERRIFNSUL', 'OKATO', 'OKTMO', 'POSTALCODE', 
        'STARTDATE', 'ENDDATE', 'UPDATEDATE', 'COUNTER', 'NORMDOC', 
        'DIVTYPE', 'REGIONCODE'
    ]
    for attr_name in attr_names:
        object.append(member.attrib.get(attr_name, None))            
    


    1. soymiguel
      04.10.2019 23:57

      Append не нужен (а здесь даже и вреден).

      object = [member.attrib.get(attr_name, None) for attr_name in attr_names]



      1. zoldaten Автор
        07.10.2019 14:13

        Объединено с предыдущим комментом и добавлено в код. Выигрыш 20 сек!


  1. sshikov
    05.10.2019 11:12

    >То есть в тегах STRUCNUM либо HOUSENUM попадались дома с буквой, записанной в странной кодировке (не UTF-8 и не ANSI, в которой сформирован сам документ):
    Вообще-то, файлы ФИАС в utf-8, о чем написано в заголовке. Откуда тут про ANSI?

    Вы далеко не первый, кому понадобился ФИАС, и думаю даже не тысячный — тех кто эту процедуру проходил, их очень много. Даже у нас в компании я знаю минимум три случая импорта ФИАС независимо разными проектами ;) Но при этом я не помню, чтобы сталкивался с упоминаниями побитой кодировки. Может у вас предыдущие шаги процесса что-то портят?


    1. zoldaten Автор
      06.10.2019 22:25

      Как ни странно, БД обрабатывается и в utf-8 и ANSI, и обе спотыкаются на вышеуказанном.
      Проблема именно с HOUSES, с файлом адресов такого нет.

      Вы для каких целей ФИАС используете?


      1. sshikov
        06.10.2019 22:35

        >Как ни странно, БД обрабатывается и в utf-8 и ANSI
        Это странно. А можете показать точно то место, где есть проблемы с кодировкой? Ну скажем смещение от начала файла?

        >Вы для каких целей ФИАС используете?
        Ну если совсем просто — то для нормализации адресов. Чтобы разрешать неопределенности в написании адреса, нужно знать, какие адреса реально существуют. ФИАС в этом смысле не идеальный, но это лучшее, что у нас есть.


        1. zoldaten Автор
          07.10.2019 13:24

          Cмещение от начала файла:
          #line 2, column 656 — sax parser, использует «utf-8»
          #line 2, column 17232734 — dom parser, использует «ANSI»