Как украсть миллиардАлександр Самойлович |
Аннотация:В статье описывается создание программы типа crawler, которая обходит интернет-сайт, находит и сохраняет на диске данные нужных типов. Программа будет написана на языке Erlang. Разработка программы будет вестись «сверху вниз». Цель статьи — показать, как свойства Erlang позволяют значительно ускорить разработку и выполнение программы по сравнению с использованием других языков, не требуя при этом от программиста практически никаких дополнительных усилий.
The article describes the creation of a spider software which crawls the web site in search of files of certain types, and saves them to disk. The program is written in Erlang using the «top-down» approach. This article aims to show how Erlang allows for significant acceleration of development process and minimization of the run time of the program compared to other programming languages with no extra effort on the part of the programmer.
Обсуждение статьи ведётся по адресу
http://community.livejournal.com/fprog/2735.html.
1 Введение
Эта программа появилась в результате решения прикладной задачи. Несколько лет назад мне понадобилось выкачать с некоторого сайта хранящиеся на нем картинки. Картинок было много, примерно несколько тысяч, может быть, десятки тысяч. Прихотливой рукой автора они были щедро разбросаны по всему сайту. Придумать простой алгоритм описания того, где они лежат, мне не удалось. Поэтому в конце концов была написана программа, которая обходила весь сайт, находила картинки и сохраняла их. Задача оказалась достаточно удобной для использования в целях самообразования. С тех пор, начиная изучать новый язык программирования, я пишу на нем очередную реализацию этой программы. Предпоследняя из них была составлена на Scheme. Программа работала и выдавала разумные результаты, но дождаться окончания её работы мне не удалось. Насколько я понимаю, единственным ограничением производительности была однопоточность. Программа была переписана на Erlang. Этот процесс и воспроизводится в статье.
Документация
Документации, статей и книг про Erlang сейчас существует намного меньше, чем, например, про Haskell. На английском языке выпущено две книги: Programming Erlang (автор Joe Armstrong) и Erlang Programming (авторы Francesco Cesarini и Simon Thompson). Описание языка, примеры и иные материалы можно найти на официальном сайте Erlang: www.erlang.org. Например, по адресу http://www.erlang.org/download/getting\_started-5.4.pdf можно найти введение в Erlang. Собрание немногочисленных «рецептов» (cookbook) лежит здесь: http://schemecookbook.org/Erlang/WebHome.
Алгоритм работы
Алгоритм работы нашей программы прост:
- получаем текст страницы;
- находим в ней все интересующие нас ссылки;
- в зависимости от типа ссылки либо сохраняем ссылку (не содержимое), либо переходим к пункту 1, либо игнорируем её;
- когда все интересующие нас ссылки найдены, сохраняем содержимое этих ссылок на диске.
Сохранение картинки разделено на два шага (1—3 и 4) специально. Сначала мы сохраняем только ссылку на картинку, а потом получаем ее содержимое. Так было сделано потому, что не все картинки были мне интересны, и хотелось иметь возможность редактировать список ссылок для сохранения вручную.
Архитектура
Одна из главных особенностей Erlang — легковесные процессы. В связи с этим проектирование программ на Erlang — это разбиение задач на независимые процессы и определение способов взаимодействия между ними. Процессы в Erlang создаются легко и стоят дешево, поэтому наличие тысяч или десятков тысяч процессов в одной задаче — вполне обычное дело. Процессы в Erlang можно рассматривать как аналоги классов в C++ или Java. Для каждой задачи (независимой активности) создаётся отдельный процесс. Процессы могут обмениваться между собой сообщениями. Посылка сообщений асинхронна. Послав сообщение, процесс продолжает свою работу. Сообщения, передаваемые между процессами, обслуживающимися одним и тем же экземпляром виртуальной машины Erlang, не теряются. Посланное сообщение попадает в очередь сообщений процесса независимо от того, исполняется он на той же машине или где-то в сети. Данные между процессами не разделяются, а копируются. Процессы не имеют доступа к внутренностям друг друга.
В реальных проектах часто создаётся иерархическая система рабочих и наблюдающих процессов, благодаря чему достигается высокая надежность приложения. Но это тема для отдельной большой статьи. А наше приложение — игрушечное, и нам потребуется лишь очень упрощенная система контроля за исполняющимися процессами.
В нашей задаче процессы будут использоваться для получения содержимого веб-страниц из интернета. Для каждой обрабатываемой единицы текста, будь то страница или строка, заведем отдельный процесс. После того, как страница будет проанализирована, и вся необходимая информация извлечена, найденные ссылки надо будет сохранить в файле. Файл один, а процессов много. Если все они станут писать одновременно — неприятностей не избежать, нужно будет решать задачу синхронизации. Это возможно, но есть более простой подход, решающий проблему автоматически: мы заведем специальный процесс, который будет писать в файл, а все остальные будут посылать ему сообщения о том, что они хотели бы записать.
Дочерние процессы могут посылать два вида сообщений: сообщения записывающему процессу о том, что нужно записать данные в файл, и сообщения родительскому процессу о завершении своей работы.
Процесс записи будет понимать два вида сообщений: сообщения от рабочих процессов с данными для записи и сообщение от родительского процесса о том, что данных для записи больше не будет, и можно приступать с сохранению их на диск.
Замечание о долгоживущих процессах
Как уже говорилось, типичная программа на Erlang состоит из большого количества процессов. В разных задачах эти процессы имеют схожее поведение. Они создаются, контролируют поведение друг друга, обмениваются сообщениями и т. д. Такие общие черты поведения называются шаблонами поведения. В реализацию Erlang входит библиотека OTP, в которой, помимо прочего, реализованы шаблоны поведения процессов, чтобы разработчику не было необходимости каждый раз заново это поведение программировать. Но так как мы хотим не только уметь пользоваться библиотечными абстракциями, но и знать, как они устроены, мы организуем долгоживущий сервисный процесс вручную, используя рекурсию, не прибегая к помощи модуля gen_server
из библиотеки OTP.
Организация кода, модули
Файлы с кодом программ на Erlang называются модулями, расширение файлов с исходным текстом должно быть .erl. Изнутри модуля его функции могут быть вызваны просто по имени, а снаружи модуля — по длинному имени, состоящему из названия модуля и названия функции. Наш модуль будет называться download
.
erl
, а функция, которая обходит сайт и сохраняет адреса файлов на диске — start
()
. Снаружи, например, из командной строки Erlang, эта функция будет вызываться как download
:
start
()
.
В начале каждого модуля обязательно идёт заголовок, содержащий имя этого модуля, которое должно совпадать с именем файла без расширения. После заголовка мы описываем, какие функции можно вызывать извне этого модуля. По умолчанию, все функции модуля доступны только изнутри, снаружи модуля они не видны. Для того, чтобы функцию можно было вызывать извне, она должна быть описана командой -
export
(...)
. К ней мы вернемся в конце статьи. А сейчас, для упрощения отладки, сделаем все функции модуля публичными:
Замечания об отладке
В жизни программа создавалась «сверху вниз», практически в той же последовательности, как это представлено в статье. Работоспособность каждой вновь написанной функции проверялась путем ее вызова в командной строке Erlang с соответствующими параметрами. Для того, чтобы скомпилировать функцию более высокого уровня без реализации всех функций более низкого уровня, нам необходимо создать функции-заглушки. Например, пусть нужно написать такую заглушку для функции f(P1, P2). Если возвращаемое значение нас не интересует, то заглушка может выглядеть как:
Символы подчеркивания перед аргументами — это сообщение компилятору о том, что в дальнейшем аргументы не используются, и их значения не важны. Если этого не сделать, компилятор выдаст предупреждение о неиспользованных переменных.
В Erlang есть настоящие отладчики, например, debugger, но для нашего приложения вполне достаточно диагностических сообщений в консоли.
Комментарии
Комментарии в Erlang однострочные. Комментарием считается все, начиная с символа % и до конца строки. Комментарии разных уровней принято выделять разным количеством %. Например, комментарии, относящиеся ко всему модулю — %%%, комментарии, описывающие функцию — %%, а комментарии к строке кода — %.
Константы
Во время работы нам потребуется несколько параметров, например, URL сайта, который мы будем обходить, и имя файла, куда будут записываться промежуточные результаты. Их можно задать, определив их константами, воспользовавшись механизмом макросов или передать аргументами в функцию. Для простоты, заведем несколько функций, которые будут возвращать нужные нам строки:
2 Разработка
Главная функция обхода
Функция start
()
будет вызываться из командной строки. В начале работы она произведет инициализацию библиотеки inets
, которая будет читать для нас данные из интернета. Затем мы создадим процесс Writer, записывающий на диск интересующие нас ссылки. Каждый процесс, который хочет что-то записать, должен будет послать процессу Writer сообщение об этом. Функция обработки веб-страниц process_page
()
сделает всю работу по разбору текстов страниц и определению типов найденных ссылок. И, наконец, мы информируем записывающий процесс, что все данные обработаны, и он может завершать свою работу.
Процессы создаются стандартной функцией spawn
()
. При запуске процесса ей передаётся функция процесса. Процесс завершится вместе с завершением этой функции. Сам же spawn
()
возвращает родителю идентификатор запущенного процесса-потомка, «pid». Полученный идентификатор можно использовать в качестве адреса при посылке процессу сообщений: Pid
!
AnyMessage
.
Мы хотим, чтобы наш процесс жил достаточно долго для того, чтобы обработать неизвестное заранее число сообщений, поэтому мы будем использовать рекурсию, которая позволит нам принять и обработать потенциально неограниченное количество сообщений одно за другим. Один вызов функции служит для обработки одного сообщения. Накапливать данные мы будем, передавая их аргументом рекурсивно вызываемой функции. Так как вначале работы список сохраненных адресов картинок пуст, аргументом для первого вызова будет пустой список []
.
У функции spawn
()
есть несколько вариантов вызова. В этой статье мы будем пользоваться только одним их них:
Здесь мы передали в spawn
()
безаргументную функцию write_proc
()
, которую определим ниже. Также начальная функция процесса, передаваемая в spawn
()
, может создаваться и при помощи конструкции вида:
Так порождается анонимная функция, аналог \
a
->
a
+ 42
в Haskell. Мы ещё воспользуемся этим способом.
Управлять процессом Writer мы будем при помощи двух сообщений — write
и stop
. Для их посылки используем две функции:
Функция stop_write
()
посылает сообщение, состоящее из одного атома stop
. Функция write
()
для посылки сообщения использует пару {
write
,
String
}
, состоящую из атома write
и произвольных данных String
. Цель введения таких однострочных функций для посылки сообщения — изоляция логики программы от деталей реализации.
Функция обработки цикла сообщений процесса Writer
Функция обработки цикла сообщений процесса Writer должна знать о существовании сообщений stop
и {
write
,
String
}
, описанных в предыдущем разделе. Для получения сообщений в Erlang в общем случае используется конструкция:
Ветвь будет выполнена, только если будет найдено соответствие PatternN, и при этом GuardN будет истинным. Guard — это выражение, которое принимает значение true
или false
. Часть when
Guard
не является обязательной. В этой статье она нам не понадобится, поэтому выражение receive
будет выглядеть так:
Дойдя до блока receive
, процесс остановится и будет ждать, пока в очереди сообщений что-то не появится. Если очередь не пуста, программа попытается найти соответствие между сообщением и образцами, описанными в разных ветвях receive
. Сопоставление будет удачным только в том случае, если удалось найти соответствие для всех величин, входящих в данные. Сейчас у нас таких образцов два — stop
и {
write
,
String
}
. Если соответствие найдено, программа выполнит правую часть выражения, если нет — программа будет ожидать в receive
бесконечно. Бесконечно? Но это же не то, что нам надо. Что будет, если по каким-то причинам сообщение вообще не дойдет? Тогда функция никогда не закончится, и процесс не завершится. Для обработки этого случая и используется ветвь after
Timeout
. Если в течение Timeout
миллисекунд ни одного соответствия между пришедшим сообщением и ветвью receive
не будет найдено, то выполнится ветвь after
:
В случае получения сообщения stop
Writer откроет файл и для каждого элемента списка Data
вызовет функцию записи в файл, после чего файл будет закрыт, и цикл обработки сообщений завершится.
Если придёт сообщение {
write
,
String
}
, то write_loop
/2
для каждого тысячного сообщения напечатает диагностику на консоль и вновь вызовет себя, уже с новыми аргументами. Рекурсия, а именно, концевая рекурсия — это обычный способ организации циклов в Erlang. Так как переменные в Erlang не изменяются, для сохранения данных между вызовами функций используются их аргументы. В нашем случае мы добавляем величину String, полученную из сообщения, к списку данных Data
, который был у нас на момент вызова функции write_loop
/2
. После этого рекурсивного вызова процесс Writer
окажется там же, где и до прихода сообщения — в ожидании сообщения в инструкции receive
.
В нашем случае ветвь after
служит упрощённым обработчиком ошибок. Его логика такова: если в течение некоторого времени мы не получили ни одного сообщения о том, что нужно сохранить какие-то данные, это значит, что все, что может быть найдено, уже записано. Если какие-либо процессы не смогли завершиться нормально к этому моменту, то скорее всего это обозначает, что они уже никогда и завершатся в связи с неизвестными нам ошибками. Величина 10 секунд выбрана произвольно. Она должна быть достаточно велика, чтобы превышать возможные задержки, возникающие при нормальной работе сайта.
Чтобы избежать дублирования кода, в ветви after
процесс посылает сообщение stop
себе же, а затем вызывает функцию write_loop
/2
, чтобы его обработать. Просто выйти из функции мы не хотим, так как реальная запись данных в файл происходит именно при обработке сообщения stop
. Посылка stop
из Timeout
позволит нам сохранить на диске те данные, которые уже накопились к этому моменту.
Одна из первых версий программы не содержала списка обработанных данных Data
. Она немедленно записывала их на диск при получении сообщения {
write
,
String
}
. Обработчик сообщения stop
только закрывал файл. К сожалению, получилось не очень хорошо. Файловые операции в Erlang, предоставляемые библиотечным модулем io
, довольно медленны, поэтому процесс записи на диск работал недостаточно быстро. Собрать как можно больше данных в памяти, а затем записать их в один прием — это один из примеров оптимизации скорости работы, рекомендованный Джо Армстронгом, одним из авторов языка.
Обработка одной страницы
Веб-страницу мы будем обрабатывать построчно, чтобы упростить нашу учебную задачу. Сначала прочитаем исходный текст страницы, вызвав функцию get_url_contents
()
, которую мы вскоре опишем. Если чтение закончилось с ошибкой, то есть возвращенное значение отличается от {
ok
,
Data
}
, немедленно закончим функцию. Если чтение было успешным, разобьем полученный текст на строки, и для каждой строки запустим процесс обработки строки. Функция обработки страницы не должна завершаться до тех пор, пока не закончатся все порожденные ею процессы обработки строк.
В этом коде наибольший интерес представляет функция collect
()
. Её назначение — ждать, пока все процессы обработки строк не завершатся:
Реализация этой функции состоит из двух ветвей. При вызове функции Erlang попытается найти соответствие между формальными и фактическими параметрами. Если collect
()
вызвана с нулевым аргументом, она вернет атом ok
, и на этом всё закончится. Если же она вызвана с положительным аргументом, она будет ждать в receive
до тех пор, пока не получит сообщение done
, после чего она вызовет себя же с уменьшенным аргументом и снова будет ждать сообщения. Это значит, что, будучи вызвана, функция collect
(
N
)
не завершится, пока не получит N сообщенийdone
. Так как мы вызвали её с аргументом, равным количеству запущенных процессов-обработчиков строк, она будет ждать до тех пор, пока количество полученных сообщений done
не сравняется с количеством строк в странице.
Получение содержимого URL адреса
Для чтения данных, расположенных по известному URL, мы будем пользоваться библиотечной функцией http
:
request
()
. В случае успеха чтения (коды возврата 200 и 201) мы вернем прочитанное содержимое. Если ошибка произошла по вине сервера (коды возврата 5XX), то мы подождем и вновь повторим попытку. В случае любой другой ошибки вернем атом failed
, что будет означать, что чтение не удалось. Если чтение не удалось по вине библиотеки http (ветвь {
error
,
Why
}
), мы тоже повторим попытку чтения после паузы.
Функция обработки одной строки
При обработке строки мы попытаемся найти в этой строке URL. Если он будет найден, мы его обработаем. При выходе из функции сообщим родительскому процессу, что функция обработки строки завершилась. Это приведет к уменьшению счетчика ожидания collect
()
на 1.
Функция done
()
посылает родительскому процессу сообщение, состоящее из одного атома done
.
Извлечение URL из строки
Извлечение URL из строки HTML произвольного вида — задача непростая. Мы будем решать её очень приблизительно, при помощи регулярного выражения. Используемый метод имеет множество ограничений: он не найдет URL, разбитый на несколько строк, или второй URL в строке. Но для наших учебных целей он вполне подойдет. В случае успеха мы вернём пару, состоящую из атома и найденной величины {
match
,
Value
}
, а в случае неуспеха — только атом failed
:
Обработка URL
Для определения типа входного URL вновь воспользуемся регулярными выражениями. Типов URL, которые мы умеем распознавать, три:
- image
- — ссылка на картинку,
- page
- — ссылка на другую страницу и
- strange
- — URL, не являющийся ни одним из двух предыдущих типов.
Найдя картинку, мы пошлем процессу Writer
сообщение о том, что этот адрес нужно сохранить. Если адрес соответствует типу page
, для него необходимо вызвать ту же функцию process_link
()
, с которой начались наши вычисления в функции start
()
. Для типа strange
мы просто напечатаем диагностику.
Результат работы функции start()
Мы закончили часть кода, которая обходит сайт и сохраняет в файле адреса картинок для последующей обработки. Никакая дополнительная сборка программы не требуется. В моем случае уже через несколько секунд работы в файле pictures.txt оказалось записано более 55 тысяч адресов.
Главная функция сохранения
Отредактировав файл с адресами картинок pictures.txt, приступим с получению самих картинок из сети и сохранению их на диске.
Вызов функции download
:
save
()
происходит независимо от вызова функции download
:
start
()
. Между двумя этими вызовами сессия Erlang не обязана сохраняться. После того, как start
()
отработает, мы вполне можем закрыть сессию, отредактировать файл со ссылками на картинки, а сохранять их через пару дней. Поэтому мы вновь проинициализируем модуль inets
, читающий для нас данные из интернета. Сначала прочитаем файл с адресами картинок. При этом функция file
:
read_file
()
вернёт данные в бинарном формате. Превратим бинарные данные в список (а строки в Erlang — это списки) и разобьем одну длинную строку на части, считая перевод строки разделителем. В конце вызовем функцию, осуществляющую цикл сохранения.
Ограничение количества процессов
Одна из первых версий программы запускала независимый процесс сохранения для каждого адреса. К сожалению, это не заработало. Я не смог получить ни одной картинки. Я не знаю истинной причины. Возможно, модуль http не захотел работать со слишком большим количеством одновременных соединений. Может быть, сайт оказался не готов к такой нагрузке. Мы воспользуемся решением, которое успешно обходит все возможные трудности одновременно. Ограничим количество одновременно читающих/пишущих процессов, например, двумя сотнями. Число 200 выбрано экспериментальным путем. Разницы в скорости сохранения при 20 и 200 процессах я не заметил, но совершенно точно, что 200 процессов работают быстрее, чем 2. С другой стороны, число процессов не должно быть очень большим, чтобы не натолкнуться на ограничение операционной системы на количество одновременно открытых файлов.
Логика ограничения количества процессов реализована в функции save_loop
()
. Она включает в себя четыре ветви.
Первая ветвь выполнится в том случае, если запущенных процессов уже не осталось, s и список входных URL для сохранения пуст. Эта ветвь прервет цикл сохранения.
Вторая ветвь выполнится в том случае, если список входных URL пуст, а количество запущенных процессов — любое. Цикл сохранения будет ждать в receive
до тех пор, пока какой-нибудь процесс не сообщит о своём завершении. Тогда цикл снова вызовет себя же, уменьшив счётчик исполняемых процессов на 1.
Третья ветвь вызовется при непустом входном списке и количестве исполняющихся процессов меньшем двухсот. Цикл запустит новый процесс сохранения и вновь вызовет себя же, увеличив счетчик исполняющихся процессов на 1.
Четвертая ветвь выполнится в том случае, если не найдено ни одного из предыдущих соответствий. Она отвечает случаю, когда количество запущенных процессов превышает максимально разрешённое. Эта ветвь будет ждать в receive
до тех пор, пока один из процессов не закончится, после чего снова вызовет save_loop
()
, уменьшив счётчик процессов на 1.
Сохранение одного URL
Сохраняя содержимое URL, мы должны по URL сформировать путь, по которому мы будем сохранять картинку, создать при необходимости все промежуточные директории, прочитать файл из интернета, сохранить его на диске и сообщить родительскому процессу о том, что функция сохранения файла завершилась.
Функция url_to_path
()
жульнически проста. Она откусывает от абсолютного URL протокол и имя сервера:
Функция sure_path
()
в качестве входных аргументов использует имя текущей директории и список, состоящий из всех промежуточных директорий и имени файла. Этот список получается в результате вызова filename
:
split
(
Path
)
:
Если входной список директорий состоит только из имени файла, функция ничего не делает, в противном случае она вычисляет имя очередной директории, создает её при помощи file
:
make_dir
(
DirName
)
и вновь вызывает себя с только что вычисленной текущей директорией и укороченным списком, лишенным головы.
Export
Наша программа закончена. Осталось только привести её в соответствие с правилами хорошего тона программирования на Erlang. Сейчас все функции, описанные в модуле download.erl, видны снаружи. Для запуска программы мы пользуемся только двумя из них — start
и save
. Сделаем видимыми только их. Для этого заменим:
на:
Величины /0 и /1 называются арностью и обозначают количество аргументов функции. Функции с одинаковыми именами, но разной арностью являются разными функциями.
3 Заключение
Как уже упоминалось, программа которая находит на сайте картинки, выкачивает их и сохраняет на диске, была сначала написана на Scheme, а потом на Erlang. При практически одинаковом размере кода и одинаковых временных затратах на разработку, программа на Erlang работает в сотни раз быстрее. Это обусловлено природой задачи, которая легко поддаётся распараллеливанию. Поддержка параллельности на Erlang не требует от программиста практически никаких дополнительных усилий. Трудности синхронизации процессов на Erlang не идут ни в какое сравнение с теми, с которыми приходится сталкиваться, программируя многопоточное приложение в C или C++. Модель процессов, принятая в Erlang, позволяет программисту самостоятельно определять удобные ему механизмы синхронизации.
Этот документ был получен из LATEX при помощи HEVEA