Решению одной из таких задач будет посвящен обзор. В какой-то момент появилась необходимость проанализировать на основе открытых данных “Единого реестра субъектов малого и среднего предпринимательства” Федеральной налоговой службы (далее Реестр МСП) динамику по месяцам количества организаций определенного вида деятельности, а именно, сельхозпредприятий. Подходы, которые использовались при ее решении, надеюсь будут полезны тем, кто ищет варианты обработки больших структурированных массивов данных XML, но распространенные средства обработки такие как SelectFromXML, он-лайн XML обработчики по каким-то причинам не подходят. Либо ограничен функционал, либо возникают проблемы при работе с кириллической кодировкой, либо не обеспечивается необходимая производительность, либо ограничены ресурсы «железа». Программисты и профессионалы надеюсь не буду слишком строги к стилю кодирования и выбору способов реализации, а критика и советы в комментариях приветствуются.
Итак задача:
На февраль 2018 года реестр МСП содержит 18 zip-архивов размером 3-4Gb. Каждый архив содержит около 5-6 тыс. файлов, содержащих сведения о примерно 6 миллионах организаций, общим объемом около 40Gb. Из этого массива требуется отобрать только те, которые относятся к сельхозпредприятиям и проанализировать динамику количества этих предприятия по месяцам.
Исходные файлы ФНС размещены по ссылке
Файлы описания организаций содержат следующую структуру:
<Файл ИдФайл="VO_RRMSPSV_0000_9965_20170110_01b07970-41d2-4d1e-bb80-0abee395d333" ВерсФорм="4.01" ТипИнф="РЕЕСТРМСП" КолДок="900">
<ИдОтпр>
<ФИООтв Фамилия="-" Имя="-"/>
</ИдОтпр>
<Документ ИдДок="4e28d9a9-c004-0f72-a27d-7d677620df81" ДатаСост="10.01.2017" ДатаВклМСП="01.08.2016" ВидСубМСП="2" КатСубМСП="1" ПризНовМСП="2">
<ИПВклМСП ИННФЛ="636204531704">
<ФИОИП Фамилия="МАРЫШЕВ" Имя="ВЯЧЕСЛАВ" Отчество="ВЛАДИМИРОВИЧ"/>
</ИПВклМСП>
<СведМН КодРегион="63">
<Регион Тип="ОБЛАСТЬ" Наим="САМАРСКАЯ"/>
<Район Тип="РАЙОН" Наим="БЕЗЕНЧУКСКИЙ"/>
<НаселПункт Тип="УЛИЦА" Наим="СОВЕТСКАЯ"/>
</СведМН>
<СвОКВЭД>
<СвОКВЭДОсн КодОКВЭД="42.21" НаимОКВЭД="Строительство инженерных коммуникаций для водоснабжения и водоотведения, газоснабжения" ВерсОКВЭД="2014"/>
<СвОКВЭДДоп КодОКВЭД="52.21.2" НаимОКВЭД="Деятельность вспомогательная, связанная с автомобильным транспортом" ВерсОКВЭД="2014"/>
<СвОКВЭДДоп КодОКВЭД="74.30" НаимОКВЭД="Деятельность по письменному и устному переводу" ВерсОКВЭД="2014"/>
<СвОКВЭДДоп КодОКВЭД="63.91" НаимОКВЭД="Деятельность информационных агентств" ВерсОКВЭД="2014"/>
<СвОКВЭДДоп КодОКВЭД="95.23" НаимОКВЭД="Ремонт обуви и прочих изделий из кожи" ВерсОКВЭД="2014"/>
<СвОКВЭДДоп КодОКВЭД="42.21" НаимОКВЭД="Строительство инженерных коммуникаций для водоснабжения и водоотведения, газоснабжения" ВерсОКВЭД="2014"/>
<СвОКВЭДДоп КодОКВЭД="62.09" НаимОКВЭД="Деятельность, связанная с использованием вычислительной техники и информационных технологий, прочая" ВерсОКВЭД="2014"/>
<СвОКВЭДДоп КодОКВЭД="25.72" НаимОКВЭД="Производство замков и петель" ВерсОКВЭД="2014"/>
<СвОКВЭДДоп КодОКВЭД="47.54" НаимОКВЭД="Торговля розничная бытовыми электротоварами в специализированных магазинах" ВерсОКВЭД="2014"/>
<СвОКВЭДДоп КодОКВЭД="42.22.1" НаимОКВЭД="Строительство междугородних линий электропередачи и связи" ВерсОКВЭД="2014"/>
<СвОКВЭДДоп КодОКВЭД="47.99" НаимОКВЭД="Торговля розничная прочая вне магазинов, палаток, рынков" ВерсОКВЭД="2014"/>
<СвОКВЭДДоп КодОКВЭД="82.19" НаимОКВЭД="Деятельность по фотокопированию и подготовке документов и прочая специализированная вспомогательная деятельность по обеспечению деятельности офиса" ВерсОКВЭД="2014"/>
<СвОКВЭДДоп КодОКВЭД="49.32" НаимОКВЭД="Деятельность такси" ВерсОКВЭД="2014"/>
<СвОКВЭДДоп КодОКВЭД="42.22.2" НаимОКВЭД="Строительство местных линий электропередачи и связи" ВерсОКВЭД="2014"/>
</СвОКВЭД>
</Документ>
<Документ ИдДок="7a14e521-68a3-9514-7540-04cb03799ac4" ДатаСост="10.01.2017" ДатаВклМСП="10.09.2016" ВидСубМСП="2" КатСубМСП="1" ПризНовМСП="1">
<ИПВклМСП ИННФЛ="636204538611">
<ФИОИП Фамилия="РУЧКАНОВА" Имя="ЛЮДМИЛА" Отчество="АЛЕКСЕЕВНА"/>
</ИПВклМСП>
<СведМН КодРегион="63">
<Регион Тип="ОБЛАСТЬ" Наим="САМАРСКАЯ"/>
<Район Тип="РАЙОН" Наим="БЕЗЕНЧУКСКИЙ"/>
<НаселПункт Тип="УЛИЦА" Наим="МОЛОДЕЖНАЯ"/>
</СведМН>
<СвОКВЭД>
<СвОКВЭДОсн КодОКВЭД="47.11" НаимОКВЭД="Торговля розничная преимущественно пищевыми продуктами, включая напитки, и табачными изделиями в неспециализированных магазинах" ВерсОКВЭД="2014"/>
<СвОКВЭДДоп КодОКВЭД="47.25.12" НаимОКВЭД="Торговля розничная пивом в специализированных магазинах" ВерсОКВЭД="2014"/>
</СвОКВЭД>
</Документ>
<Документ ИдДок="ad8636bb-78c3-763c-52d2-4fe5a93e9a8f" ДатаСост="10.01.2017" ДатаВклМСП="10.09.2016" ВидСубМСП="2" КатСубМСП="1" ПризНовМСП="1">
<ИПВклМСП ИННФЛ="636204540794">
<ФИОИП Фамилия="МИЧУРОВА" Имя="ТАТЬЯНА" Отчество="АЛЕКСАНДРОВНА"/>
</ИПВклМСП>
<СведМН КодРегион="63">
<Регион Тип="ОБЛАСТЬ" Наим="САМАРСКАЯ"/>
<Город Тип="ГОРОД" Наим="САМАРА"/>
<НаселПункт Тип="УЛИЦА" Наим="ВЛАДИМИРСКАЯ"/>
</СведМН>
<СвОКВЭД>
<СвОКВЭДОсн КодОКВЭД="47.41" НаимОКВЭД="Торговля розничная компьютерами, периферийными устройствами к ним и программным обеспечением в специализированных магазинах" ВерсОКВЭД="2014"/>
<СвОКВЭДДоп КодОКВЭД="49.20.9" НаимОКВЭД="Перевозка прочих грузов" ВерсОКВЭД="2014"/>
<СвОКВЭДДоп КодОКВЭД="47.78" НаимОКВЭД="Торговля розничная прочая в специализированных магазинах" ВерсОКВЭД="2014"/>
</СвОКВЭД>
</Документ>
Обработка будет выполняться в оболочке bash на виртуальной Linux машине с 2-я ядрами, 8 Gb оперативной памяти и 100Gb дискового пространства:
%Cpu0 : 6.1 us, 2.0 sy, 0.0 ni, 91.8 id, 0.0 wa, 0.0 hi, 0.0 si, 0.0 st
%Cpu1 : 54.1 us, 11.2 sy, 0.0 ni, 6.1 id, 28.6 wa, 0.0 hi, 0.0 si, 0.0 st
KiB Mem : 8258760 total, 64684 free, 5645284 used, 2548792 buff/cache
KiB Swap: 2129916 total, 1157076 free, 972840 used. 2271428 avail Mem
Скрипт должен обеспечить скачивание zip-архивов с сайта ФНС, переименование файлов для удобства последующей обработки, распаковку, обработку парсером (используется xmlstarlet) для поиска организаций, соответствующих заданных в скрипте критериям, очистку диска от временных файлов (в процессе обработки исходные файлы занимают десятки Gb), сохранение в формате, удобном для последующего использования в системах анализа данных и импорта в программы для работы с электронными таблицами (в нашем случае будет использоваться формат csv).
Скачивание и переименование выполним с использованием wget. Чтобы скрипт понимал, какие архивы с РМСП ему обрабатывать, создадим файл, под условным названием «полетное задание», где укажем, какие файлы обрабатывать и как именовать полученный результат.
Конфигурационный файл имеет следующую структуру:
Ссылка на файл, название результирующего файла, отметка о необходимости обработки '*' (для случаев, если возникает необходимость загрузить не весь набор файлов).
rmspfiles.txt
http://data.nalog.ru/opendata/7707329152-rsmp/data-08262016-structure-08012016.zip;20160826;*
http://data.nalog.ru/opendata/7707329152-rsmp/data-09102016-structure-08012016.zip;20160910;*
http://data.nalog.ru/opendata/7707329152-rsmp/data-10102016-structure-08012016.zip;20161010;*
http://data.nalog.ru/opendata/7707329152-rsmp/data-11252016-structure-08012016.zip;20161125;*
http://data.nalog.ru/opendata/7707329152-rsmp/data-12122016-structure-08012016.zip;20161212;*
http://data.nalog.ru/opendata/7707329152-rsmp/data-01112017-structure-08012016.zip;20170111;*
http://data.nalog.ru/opendata/7707329152-rsmp/data-02102017-structure-08012016.zip;20170212;*
http://data.nalog.ru/opendata/7707329152-rsmp/data-03102017-structure-08012016.zip;20170310;*
http://data.nalog.ru/opendata/7707329152-rsmp/data-04102017-structure-08012016.zip;20170410;*
http://data.nalog.ru/opendata/7707329152-rsmp/data-05102017-structure-08012016.zip;20170510
http://data.nalog.ru/opendata/7707329152-rsmp/data-11062017-structure-08012016.zip;20170611
http://data.nalog.ru/opendata/7707329152-rsmp/data-07102017-structure-08012016.zip;20170710
http://data.nalog.ru/opendata/7707329152-rsmp/data-08102017-structure-08012016.zip;20170810
http://data.nalog.ru/opendata/7707329152-rsmp/data-09112017-structure-08012016.zip;20170911
http://data.nalog.ru/opendata/7707329152-rsmp/data-10102017-structure-08012016.zip;20171010
http://data.nalog.ru/opendata/7707329152-rsmp/data-11102017-structure-08012016.zip;20171110
http://data.nalog.ru/opendata/7707329152-rsmp/data-12112017-structure-08012016.zip;20171211
http://data.nalog.ru/opendata/7707329152-rsmp/data-01112018-structure-08012016.zip;20180111
По завершению скачивания с переименованием запускается цикл по полученным архивам: unzip, поиск в XML нужных записей, запись результата в csv файлы. Перед обработкой следующего архива очищается место на диске от исходных файлов.
Не смотря на простоту задачи, скрипт пройдя этапы отладки и совершенствования получился достаточно замысловатым.
Итак, что получилось в итоге:
Загрузчик файлов:
#!/bin/bash
# **************** batch downloader from rmsp v 1.0. 2018-02-15 ***********************
start=`date +%s`
dt=`date`
logFn='output_wget.log'
printf "********************************************************************************************\n" | tee tmp_output.log
echo "* ${dt} wget *" | tee -a tmp_output.log
printf "*********************************************************************************************\n\n" | tee -a tmp_output.log
# download loop считываем файлы по ссылкам из “полетного задания”, переименовываем и сохраняем в папке zip2
IFS=';'
while read line; do
read -r -a array <<< "$line"
echo "${array[0]} | ${array[1]} "
# wget ${array[0]} -O ./zip2/${array[1]}.zip | tee -a tmp_output.log 2>&1
# get filesize of external - этот параметр пишется в лог для оценки производительности обработчика
FILESIZE=$(wget --spider ${array[0]} 2>&1 | awk '/Length/ {print $2}')
# - c - continue, 3>&1 - размер файла
wget -c ${array[0]} -O ./zip2/${array[1]}.zip 3>&1 | tee -a tmp_output.log
end=`date +%s`; runtime=$((end-start)); dt=`date '+%Y-%m-%d %H:%M:%S'`
printf "%s %4d sec %10d %s [ %s" ] ${dt} $runtime $FILESIZE ${array[0]} ${array[1]} | tee -a tmp_output.log
done < rmspfiles.txt
echo "" | tee -a tmp_output.log //записываем в файл для последующей отладки результаты работы
cat tmp_output.log $logFn > tmp_output2.log; mv tmp_output2.log $logFn
2. Парсер
#!/bin/bash
# 2018-02-16 Версия 1.1 Добавлены столбцы в итоговый файл
# 2018-02-19 Добавлены кавычки для предотвращение переноса строки в номерах лицензий в excell
# 2018-02-19 Добавлен sed для замены /n -> ; @@; -> \n
# удалены для лицензий кавычки
# задаем разделитель колонок для итоговых файлов (в нашем случае табуляция)
sp=' '
# Задаем параметры обработчика, пути для исходных и результирующих файлов, названия файлов для журналов обработки.
path_src="./src"
path_zip="./zip2"
path_res="./res"
t1="p1.log"
t2="p2.log"
t3="parsz.log"
fnExt=""$1
start=`date +%s`
dt=`date '+%Y-%m-%d %H:%M:%S'`
# Результат выводим в лог
echo "**** | parsz | ${dt} unzip from: $path_zip/$fnExt.zip to $path_src/$fnExt"
# | tee $t1
# -q quiet mode (-qq => quieter)
# -o overwrite files WITHOUT prompting
# -j junk paths. The archive's directory structure is not recreated; all files are deposited in the extraction directory (by default, the current one).
unzip -j -q -o $path_zip/$fnExt.zip -d $path_src/$fnExt/
end=`date +%s`
runtime=$((end-start))
MOREF1=`ls "$path_src/$fnExt/" | wc -l`
echo " ${dt}, $runtime sec [${MOREF1}] | files from: $path_src/$fnExt/ to $path_res/$fnExt.csv" | tee -a $t1
echo "ИНН$spНаименование МСП$spКатегория МСП$spВид МСП$spВид Деятельности (Основной ОКВЭД)$spРегионНаим$spРайонТип$spРайонНаим$spгородТип$spгородНаим$spНаселПунктТип$spНаселПунктНаим$spДатаСост$spДатаВключения$spНомерЛицензии$spФайлИмя@@" > $path_res/res-$fnExt.csv
/usr/bin/find $path_src/$fnExt/ -name "*.xml" | xargs -n1 xmlstarlet sel -T -f -t -m "//Документ/ОргВклМСП[contains(@НаимОрг,'СЕЛЬСКОХОЗЯЙСТВЕНН')]" -v "@ИННЮЛ" -o "$sp" -v "@НаимОрг" -o "$sp" --if "../@КатСубМСП=1" -o "Микро" --else --if "../@КатСубМСП=2" -o "Малые" --else -o "Средние" --break --break -o "$sp" --if "../@ВидСубМСП = 1" -o "Организация" --else -o "ИП" --break -o "$sp[" -v "../СвОКВЭД/СвОКВЭДОсн/@КодОКВЭД" -o "]" -v "../СвОКВЭД/СвОКВЭДОсн/@НаимОКВЭД" -o "$sp" -v "../СведМН/Регион/@Наим" -o "$sp" -v "../СведМН/Район/@Тип" -o "$sp" -v "../СведМН/Район/@Наим" -o "$sp" -v "../СведМН/Город/@Тип" -o "$sp" -v "../СведМН/Город/@Наим" -o "$sp" -v "../СведМН/НаселПункт/@Тип" -o "$sp" -v "../СведМН/НаселПункт/@Наим" -o "$sp" -v "../@ДатаСост" -o "$sp" -v "../@ДатаВклМСП" -o "$sp" -v "../СвЛиценз/@НомЛиценз" -o "$sp" \
-o "$fnExt@@" -n >> $path_res/res-$fnExt.csv
end=`date +%s`
runtime=$((end-start))
dt=`date '+%Y-%m-%d %H:%M:%S'`
echo " ${dt}, $runtime sec :parsing" | tee -a $t1
# Удаляем переносы строк в значениях за исключением последних в строках
sed -e ':a;N;$!ba;s/\n/;/g' $path_res/res-$fnExt.csv > $path_res/sed_tmp.csv
sed -e 's/@@;/\n/g' $path_res/sed_tmp.csv > $path_res/res-$fnExt.csv
end=`date +%s`
runtime=$((end-start))
dt=`date '+%Y-%m-%d %H:%M:%S'`
echo " ${dt}, $runtime sec :sed " | tee -a $t1
cat $t1 $t3 > $t2; mv $t2 $t3
# удаляем исходные XML файлы
rm -rf $path_src/$fnExt/*
echo "Удаляем исходные XML файлы rm -rf $path_src/$fnExt/*"
rm $t1
Весь массив данных из 18 файлов общим объемом в сотни Gb обрабатывается около 6 часов.
Процесс обработки записывается в файлы для последующей отладки и оптимизации скрипта.
После импорта в MS Excel получаем следующий результат:
Комментарии (20)
poxvuibr
15.05.2018 17:59+8Вот как приходится мучаться, когда нормального языка программирования под рукой нет.
SDKiller
15.05.2018 19:11+2А еще наши государственные opendata как будто специально задуманы, чтобы с ними мучались.
Переписывался как-то с ответственной начальницей в госкомстате по поводу файла какого-то классификатора на 50 Гб в ворде (!).
Вкратце — "нас обязали, мы выложили, а насколько это вам удобно — уже не наше дело".domix32
16.05.2018 12:27Тут где-то даже была статься про работу с этими данными. А там порой то полей нет, то xml невалидный
gibson_dev
15.05.2018 19:59+1я на php 5.6 парсил 18гб xml файл — по времени 20 минут примерно со скачиванием и распаковкой, на современных версиях у думаю побыстрее будет
xRay
15.05.2018 22:03+1А чем xml обрабатывал? SimpleXMLelement?
gibson_dev
16.05.2018 06:54XMLReader умеет потоковое чтение, им выбирал блоки и их уже для удобства парсил SimpleXMLelement
igor_suhorukov
16.05.2018 00:09IMHO bash скрипты тяжело отлаживать и тяжело расширять через пару месяцев… Есть море XQuery парсеров для задачи которую вы описали, а еще если нужна тяжелая артиллерия — с подобными задачами работает Apache Drill из коробки, но в этом есть смысл если одни и те же данные вы будете многократно обрабатывать, не производя предварительно ETL.
gecube
16.05.2018 22:45Самое страшное в баше — это отсутствие человеческой обработки ошибок. В результате надёжный код получается обмазан кучей проверок кодов возврата и/или output от программ и это реально становится тяжело отлаживать и расширять. Плюсы тоже очевидны: bash и wget практически самые стандартные утилиты, которые есть практически в каждом дистрибутиве linux. И порог входа в них достаточно низок.
Cheater
16.05.2018 13:02Совет один — загляните в код xmlstarlet и проверьте, использует он libxml2 как потоковый парсер (sax) или же строит полное дерево. Для XML таких объёмов строго рекомендуется использовать потоковый парсер. XQuery здесь имхо излишен — структура входных XML-ей довольно простая. Можно вообще при желании обойтись без XML парсера и обрабатывать тупо plain text (sed/awk) регулярками — скорость сильно возрастёт.
gecube
16.05.2018 22:39Вопрос из зала: почему нет многопотока? Почти наверняка можно было запустить по 8 (или любое число) одновременных процессов скачивания и преобразования файлов. Ну, если, конечно, не стояло задачи делать все на самом слабом и дешёвом сервере....
firedragon
Почему не Perl? И почему не загнать это в базу данных, создать OLAP куб и отдать аналитикам в бухгалтерию?
domix32
Perl не сильно приятней bash. Я бы предложил Python
Cheater
Эм, вообще-то сильно приятнее. SAX2 парсер из XML::Xerces, в комбинации с возможностями Perl по манипуляции plain text, в разы бы сократил объём кода, читаемость и скорость.
domix32
Синтаксис perl подразумевает огромное количество спец. символов ($@#), которые сами по себе трудно обрабатывать в голове. Конечно, в нем меньше ограничений специфичных в сравнении с bash (поди напиши валидное мультиусловие с первого раза), но вцелом все ещё слишком перегружено. Особенно если код пишет адепт однострочников.
Python при сходных же реализациях и производительности выглядит на порядок чище и читаемее. Сопровождать и расширять такой код проще.
firedragon
1. Есть везде
2. Модули есть под все
3. Правильно работает с уникодом
И это специализированный инструмент для обработки текста, и только во 2-ю очередь, ЯП общего назначения
Cheater
Уж сколько раз твердили миру, что читаемость зависит от скилла разработчика, а не от языка. Специальные символы для обозначения типа переменной есть в куче языков (сам не далее как вчера создавал на c++ параметр *&int). Если «код пишет адепт однострочников», то он и на питоне будет писать кашу.
domix32
Именно что от скилла. Вы весь такой со скилами и прокачанный, написали чудо-скрипт, а потом поменяли работу, а скрипт дали доработать свежеприбывшему програмисту рангом поменьше вас. И вот он начинает ломать глаза и мизинчик об это все.
P.S. Как-то по причине нелюбви к $varname синтаксису так не стал PHPшником
babylon
Читаемость кода не всегда преследуется в качестве цели. Если цель не учебная.