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

Решению одной из таких задач будет посвящен обзор. В какой-то момент появилась необходимость проанализировать на основе открытых данных “Единого реестра субъектов малого и среднего предпринимательства” Федеральной налоговой службы (далее Реестр МСП) динамику по месяцам количества организаций определенного вида деятельности, а именно, сельхозпредприятий. Подходы, которые использовались при ее решении, надеюсь будут полезны тем, кто ищет варианты обработки больших структурированных массивов данных 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)


  1. firedragon
    15.05.2018 17:37
    +2

    Почему не Perl? И почему не загнать это в базу данных, создать OLAP куб и отдать аналитикам в бухгалтерию?


    1. domix32
      16.05.2018 12:25

      Perl не сильно приятней bash. Я бы предложил Python


      1. Cheater
        16.05.2018 12:57
        -1

        Эм, вообще-то сильно приятнее. SAX2 парсер из XML::Xerces, в комбинации с возможностями Perl по манипуляции plain text, в разы бы сократил объём кода, читаемость и скорость.


        1. domix32
          16.05.2018 14:24

          Синтаксис perl подразумевает огромное количество спец. символов ($@#), которые сами по себе трудно обрабатывать в голове. Конечно, в нем меньше ограничений специфичных в сравнении с bash (поди напиши валидное мультиусловие с первого раза), но вцелом все ещё слишком перегружено. Особенно если код пишет адепт однострочников.
          Python при сходных же реализациях и производительности выглядит на порядок чище и читаемее. Сопровождать и расширять такой код проще.


          1. firedragon
            16.05.2018 14:36
            +1

            1. Есть везде
            2. Модули есть под все
            3. Правильно работает с уникодом

            И это специализированный инструмент для обработки текста, и только во 2-ю очередь, ЯП общего назначения


          1. Cheater
            16.05.2018 14:40
            +1

            Уж сколько раз твердили миру, что читаемость зависит от скилла разработчика, а не от языка. Специальные символы для обозначения типа переменной есть в куче языков (сам не далее как вчера создавал на c++ параметр *&int). Если «код пишет адепт однострочников», то он и на питоне будет писать кашу.


            1. domix32
              16.05.2018 20:59

              Именно что от скилла. Вы весь такой со скилами и прокачанный, написали чудо-скрипт, а потом поменяли работу, а скрипт дали доработать свежеприбывшему програмисту рангом поменьше вас. И вот он начинает ломать глаза и мизинчик об это все.
              P.S. Как-то по причине нелюбви к $varname синтаксису так не стал PHPшником


            1. babylon
              17.05.2018 13:30

              Читаемость кода не всегда преследуется в качестве цели. Если цель не учебная.


  1. poxvuibr
    15.05.2018 17:59
    +8

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


  1. SDKiller
    15.05.2018 19:11
    +2

    А еще наши государственные opendata как будто специально задуманы, чтобы с ними мучались.
    Переписывался как-то с ответственной начальницей в госкомстате по поводу файла какого-то классификатора на 50 Гб в ворде (!).
    Вкратце — "нас обязали, мы выложили, а насколько это вам удобно — уже не наше дело".


    1. domix32
      16.05.2018 12:27

      Тут где-то даже была статься про работу с этими данными. А там порой то полей нет, то xml невалидный


  1. gibson_dev
    15.05.2018 19:59
    +1

    я на php 5.6 парсил 18гб xml файл — по времени 20 минут примерно со скачиванием и распаковкой, на современных версиях у думаю побыстрее будет


    1. xRay
      15.05.2018 22:03
      +1

      А чем xml обрабатывал? SimpleXMLelement?


      1. POPSuL
        16.05.2018 04:13

        Думаю что такие объемы необходимо обрабатывать с помощью XMLReader.


      1. gibson_dev
        16.05.2018 06:54

        XMLReader умеет потоковое чтение, им выбирал блоки и их уже для удобства парсил SimpleXMLelement


    1. peresada
      16.05.2018 08:04

      это ж какая у вас скорость скачивания была?


  1. igor_suhorukov
    16.05.2018 00:09

    IMHO bash скрипты тяжело отлаживать и тяжело расширять через пару месяцев… Есть море XQuery парсеров для задачи которую вы описали, а еще если нужна тяжелая артиллерия — с подобными задачами работает Apache Drill из коробки, но в этом есть смысл если одни и те же данные вы будете многократно обрабатывать, не производя предварительно ETL.


    1. gecube
      16.05.2018 22:45

      Самое страшное в баше — это отсутствие человеческой обработки ошибок. В результате надёжный код получается обмазан кучей проверок кодов возврата и/или output от программ и это реально становится тяжело отлаживать и расширять. Плюсы тоже очевидны: bash и wget практически самые стандартные утилиты, которые есть практически в каждом дистрибутиве linux. И порог входа в них достаточно низок.


  1. Cheater
    16.05.2018 13:02

    Совет один — загляните в код xmlstarlet и проверьте, использует он libxml2 как потоковый парсер (sax) или же строит полное дерево. Для XML таких объёмов строго рекомендуется использовать потоковый парсер. XQuery здесь имхо излишен — структура входных XML-ей довольно простая. Можно вообще при желании обойтись без XML парсера и обрабатывать тупо plain text (sed/awk) регулярками — скорость сильно возрастёт.


  1. gecube
    16.05.2018 22:39

    Вопрос из зала: почему нет многопотока? Почти наверняка можно было запустить по 8 (или любое число) одновременных процессов скачивания и преобразования файлов. Ну, если, конечно, не стояло задачи делать все на самом слабом и дешёвом сервере....