В прошедший понедельник на московской конференции DevConf я презентовал в виде мастер-класса питоновую библиотеку для потоковой генерации XML. Как и надеялся, получил хороший фидбек, и вот сегодня формально представляю проект и первый его релиз.

Описание

Название обыгрывает "ElementTree" в том смысле, что в отличие от неё elementflow не генерит собственно дерево.

Текущие известные библиотеки, умеющие генерировать XML (ElementTree, lxml), строят в памяти XML-дерево целиком, а затем его сериализуют. Это может быть неэффективно при генерации более-менее объёмных XML-документов (например в контексте контент-ориентированного веб-сервиса, выдающего большое количество XML-данных). Существующий же потоковый генератор xml.sax.saxutils.XMLGenerator очень низкоуровневый, там надо руками закрывать элементы.

А кроме того, большинство XML-библиотек, откровенно говоря, отвратительно работают с namespace'ами.

Использование

Базовый пример:

import elementflow
file = open('text.xml', 'w') # can be any  object with .write() method

with elementflow.xml(file, u'root') as xml:
    xml.element(u'item', attrs={u'key': u'value'}, text=u'text')
    with xml.container(u'container', attrs={u'key': u'значение'}):
        xml.text(u'текст')
        xml.element(u'subelement', text=u'subelement text')

Использовать with обязательно, чтобы правильно закрывались элементы-контейнеры. Библиотека ожидает на вход юникодные строки и выдаёт на выход байтовую строку в utf-8. Библиотека правильно escape'ит текстовый строки и значения атрибутов, но не будет заморачиваться с проверкой корректности имён тегов и атрибутов.

Про работу с namespace'ами, форматирование вывода и превращение генерирующего кода в итератор читайте в README.

Производительность

Все тесты проводились вот на таком XML:

<contacts>
  <person id="0">
    <name>John &amp; Smith</name>
    <email>john.smith@megacorp.com</email>
    <phones>
      <phone type="work">123456</phone>
      <phone type="home">123456</phone>
    </phones>
  </person>

  <!-- повторить <person> .. </person> несколько тысяч раз -->

</contacts>

Записи генерируются простым циклом, то есть времени на выборку данных не тратится, это чистое время работы генератора и вывода в /dev/null. Спецификация машины неважна, это некая средняя десктопная машина.

Вот результаты одиночного теста на 40000 записей:

ElementTree10,7 сек
cElementTree6,2 сек
lxml.etree0,9 сек
elementflow2,3 сек

Низкая скорость ElementTree меня сильно озадачила, не знаю, почему он такой медленный, даже в C-версии. Высокая скорость lxml, напротив, вполне ожидаема. Это, впрочем, не очень полезный тест, потому что гораздо интересней то, как будет вести себя веб-сервис, генерирующий XML для нескольких одновременных запросов.

Для этого я на мастер-классе провёл два разных теста: под Tornado и под Django. В них сравнивались две модели генерации XML:

В обоих случаях использовалась elementflow. Веб-сервис нагружался тулзой siege смесью URL'ов: 80% маленьких и быстрых ("Hello, World!") и 20% — генерирующих большой XML. Использовались 10 одновременных запросов на Tornado и 20 — на Django.

Код обоих тестовых сервисов можно посмотреть в истории Bazaar'а.

Tornado

При генерации в памяти сразу заметен вот такой неприятный эффект:

HTTP/1.1 200  16.18 secs: 3548949 bytes ==> /memory?count=20000
HTTP/1.1 200  16.21 secs: 3548949 bytes ==> /memory?count=20000
HTTP/1.1 200  10.34 secs:      13 bytes ==> /
HTTP/1.1 200  16.25 secs: 3548949 bytes ==> /memory?count=20000
HTTP/1.1 200   9.32 secs:      13 bytes ==> /
HTTP/1.1 200   8.64 secs:      13 bytes ==> /
HTTP/1.1 200   9.42 secs:      13 bytes ==> /
HTTP/1.1 200   0.11 secs:      13 bytes ==> /
HTTP/1.1 200   0.18 secs:      13 bytes ==> /
HTTP/1.1 200   0.00 secs:      13 bytes ==> /

Те "hello-world" ответы, которые запрашиваются одновременно с долгими XML-ответами, вынуждены их ждать, в результате чего отдаются очень долго. В обычной же ситуации должны отдаваться мгновенно.

При генерации потоком эффект пропадает:

HTTP/1.1 200   3.69 secs: 1086426 bytes ==> /stream?count=20000&bufsize=4096
HTTP/1.1 200   3.69 secs: 1086426 bytes ==> /stream?count=20000&bufsize=4096
HTTP/1.1 200   0.02 secs:      13 bytes ==> /
HTTP/1.1 200   3.33 secs: 1086426 bytes ==> /stream?count=20000&bufsize=4096
HTTP/1.1 200   3.33 secs: 1086426 bytes ==> /stream?count=20000&bufsize=4096
HTTP/1.1 200   3.33 secs: 1086426 bytes ==> /stream?count=20000&bufsize=4096
HTTP/1.1 200   3.33 secs: 1086426 bytes ==> /stream?count=20000&bufsize=4096
HTTP/1.1 200   0.02 secs:      13 bytes ==> /
HTTP/1.1 200   0.02 secs:      13 bytes ==> /

Кроме того, и большие ответы генерируются быстрее.

Django

Под Django такого же эффекта, как в однопоточном Tornado, проявляться не может, там запросы друг другу не мешают. Там происходит другой эффект: при генерации в памяти создаётся очень много отфорканных процессов, которые создают на машине большой LA, отчего она работает существенно медленней. При этом я подбирал такие цифры нагрузок, чтобы физически памяти ещё хватало.

Вот картинка при генерации в памяти:

HTTP/1.1 200   9.05 secs:    5620 bytes ==> /memory?count=20000
HTTP/1.1 200   9.39 secs:    5620 bytes ==> /memory?count=20000
HTTP/1.1 200   0.00 secs:      13 bytes ==> /
HTTP/1.1 200   0.00 secs:      13 bytes ==> /
HTTP/1.1 200   0.29 secs:      13 bytes ==> /
HTTP/1.1 200   0.00 secs:      13 bytes ==> /
HTTP/1.1 200  10.51 secs:    5620 bytes ==> /memory?count=20000
HTTP/1.1 200   0.00 secs:      13 bytes ==> /
HTTP/1.1 200   0.01 secs:      13 bytes ==> /

LA: 5,9; средняя скорость: 6,81 rps

С потоковой генерацией всё лучше:

HTTP/1.1 200   3.83 secs: 1086426 bytes ==> /stream?count=20000&bufsize=4096
HTTP/1.1 200   3.84 secs: 1086426 bytes ==> /stream?count=20000&bufsize=4096
HTTP/1.1 200   3.86 secs: 1086426 bytes ==> /stream?count=20000&bufsize=4096
HTTP/1.1 200   3.86 secs: 1086426 bytes ==> /stream?count=20000&bufsize=4096
HTTP/1.1 200   3.98 secs: 1086426 bytes ==> /stream?count=20000&bufsize=4096
HTTP/1.1 200   0.48 secs:      13 bytes ==> /
HTTP/1.1 200   0.00 secs:      13 bytes ==> /
HTTP/1.1 200   0.00 secs:      13 bytes ==> /
HTTP/1.1 200   0.00 secs:      13 bytes ==> /

LA: 2,7; средняя скорость: 13,18 rps

Заключение

Хоть библиотека и не такая быстрая, как пресловутый lxml, мне кажется, что она довольно полезна в определённых ситуациях. В ближайшем будущем я планирую сделать ещё бранч для Python 3 и, конечно, собрать как можно больше фидбека и контрибуций.

Комментарии: 5

  1. Powerman

    А почему размер ответов разный? Особенно смущают 5K в варианте Django/memory по сравнению с 1M в Django/stream.

  2. Ivan Sagalaev

    Это меня тоже удивило. Я подозреваю, что это что-то у siege то ли считается не так, то ли не забирается до конца. Потому что я проверял, спрашивая те же запросы curl'ом — там всё отдаётся до конца, и файлы получаются одинаковые.

  3. Алекс

    Хорошая статья. Плохо разбираюсь в Питоне, но даже мне было все ясно

  4. http://seriyps.ru/

    Думаю буду использовать..

    Интересно было бы посмотреть на эти же siege тесты, но с lxml.

    Кстати, мне кажется с XML-ками с большой вложенностью тоже будет грузить прилично, а если много похожих элементов на одном уровне, то да, будет летать.

    И еще вопрос - есть ли смысл использовать эту библиотеку для генерации xml под XSLT шаблонизацию?

  5. Ivan Sagalaev

    Интересно было бы посмотреть на эти же siege тесты, но с lxml

    До этого руки не дошли :-).

    Кстати, мне кажется с XML-ками с большой вложенностью тоже будет грузить прилично

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

    есть ли смысл использовать эту библиотеку для генерации xml под XSLT шаблонизацию

    По идее, её для всего можно использовать. Почему нет? :-)

Добавить комментарий