Найти в Дзене

Пишем асинхронный веб скрапер

Оглавление

День добрый) Ещё при устройстве на работу прикидывал время на привыкание и получалось полгода-год на то, чтобы обвыкнуть на новом месте и появлялись ресурсы думать о чем-то другом в принципе) Примерно так и получилось. Блог на это время выпал из деятельности, зато определился, что хочется и дальше развиваться в этой сфере и накопил материала на несколько постов минимум) Скорее всего одним из ближайших постов напишу сравнение рабочего процесса в маркетинге и отделе разработки)

А сейчас уже 4 и заключительная часть перевода статьи-туториала по асинхронному коду в Python.

Предыдущая часть.

________________________________________________________________________________________

Программа: асинхронные запросы

Раз уж вы зашли так далеко, настало время самой веселой и влекательной части. В этой секции мы с вами построим веб скрапер areq.py, с использованием очень быстрого асинхронного фреймворка aiohttp (нам понадобится только его клиентская часть). С такими инструментами мы можем построить связь между кластером сайтов и представить её в виде графа, например.

Заметка: вы можете быть удивлены тем, что библиотека requests не совместима с async IO. Суть в том, что она построена на основе urllib3, которая в свою очередь использует модули Python http и socket.

По умолчанию, операции на сокетах - блокирующие. А значит Python не сможет дождаться await requests.get(url), потому что к методу .get() нельзя применить await. В aiohttp же практически все реализовано с помощью корутин.

На верхнем уровне, структура программы будет выглядеть так:

  • Читаем последовательность ссылок из локального файла url.txt.
  • Отправляем GET запрос и декодируем полученный контент. Если запрос проваливается, игнорируем эту ссылку.
  • Ищем в HTML коде, полученном по ссылке, теги с параметром href.
  • Пишем результат в foundurls.txt.
  • Делаем это асинхронно и параллельно, насколько это возможно. (используем aiohttp для запросов и aiofiles для работы с файлами. Это 2 главных примера работы с вводом-выводом данных, которые хорошо подходят для иллюстрирования сильных сторон асинхронного подхода).

Файл urls.txt не очень большого объема и содержит в основном самые посещаемые сайты:

Вторая ссылка из списка должна вернуть код 404, который так же необходимо обработать нашей программой. Если у вас расширенная версия этой программы, возможно вы столкнетесь с более серьезными проблемами вроде обрыва связи с сервером и цепочки бесконечных перенаправлений.

Все запросы должны быть сделаны в одной сессии, чтобы получить выгоду от внутреннего пула подключений.

Сейчас посмотрим на полный код программы, мы разберем каждую её часть дальше в материале:

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

Константа HREF_RE - это регулярное выражение, которое будет извлекать блоки с атрибутом href в HTML:

-3

Корутина fetch_html() - обертка вокруг GET запроса. Она делает запрос, дожидается ответа и декодирует HTML страницу, если статус был положительный или возвращает исключения, в случае не 200 статуса.

-4

Если все прошло по плану, то fetch_html() возвращает HTML код(строку). В этой функции нет обработки исключений. Логика построена таким образом, чтобы вернуть исключение вызывающей стороне и там его и обработать:

-5

Мы используем await с session.request() и resp.test() потому что это корутины. В ином случае, цикл запрос∕ответ будет занимать большую часть времени действия приложения. С помощью корутин мы позволяем другим операциям (парсинг и запись) работать, пока подготавливаются новые обработанные ссылки.

Следующее звено цепочки корутин - parse(), которое ждет от fetch_html() обработанную ссылку и извлекает все элементы с href атрибутом из HTML кода страницы, проверяя их валидность и преобразовывая в абсолютный путь.

Стоит признать, что вторая часть функции parse() - блокирующая, но она содержит быструю проверку регулярного выражения и обеспечивает преобразование в абсолютный путь.

В этом конкретном случае, синхронный код должен быть быстрым и незаметным. Но помните, что любая строка этого кода будет блокировать остальные корутины, если не используется yield, await или return. Если вам потребуется более сложный парсинг, возможно вам захочется запускать его с использованием отдельного процесса.

Следующая корутина write() берет экземпляр файла и одну из ссылок, дожидается отработки команды parse(), возвращающей набор отработанных ссылок, и асинхронно записывает их в файл, используя aiofiles, библиотеку для асинхронной работы с файлами.

И наконец bulk_crawl_and_write() - служит главной входной точкой в цепь корутин. Задачи, созданные для каждого адреса в файле urls.txt и будут выполняться в рамках одной сессии.

Несколько важных пунктов, которые заслуживают упоминания:

  • по умолчанию, в ClientSession встроен адаптер с максимальным числом подключений = 100. Отредактировать это можно в ClientSession в asyncio.connector.TCPConnector. Лимиты можно настраивать отдельно для каждого хоста.
  • Вы можете настраивать максимальное время ожидания как для сессии в целом, так и для отдельных запросов
  • скрипт так же использует асинхронный менеджер контекста. В нашей статье нет отдельной секции для объяснения этого концепта, т.к. общий переход мне показался достаточно простым. Названия методов поменялись с __exit__(), __enter__() на __aexit__(), __aenter__(). Как вы могли заметить, асинхронность может быть использована только внутри корутины, объявленной с async def.

Если вы хотите продолжить исследование, то можете посмотреть туториал на гитхабе.

И вот, итоги исполнения нашего скрипта - получили, распарсили и сохранили результаты с 9 адресов менее чем за секунду:

В оригинале есть бегунок, на скрин не вошла часть текста. Но основной посыл должен быть понятен.
В оригинале есть бегунок, на скрин не вошла часть текста. Но основной посыл должен быть понятен.

Не так уж и плохо. В качестве проверки можно посмотреть сколько строк у файла на выходе. В моем случае - 626, но в вашем результат может отличаться.

-7

Асинхронный код в общем контексте.

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

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

Это не продолжение рассуждений о разнице между асинхронностью, тредами и мультипроцессингом. Однако полезно иметь представление, когда асинхронность будет лучшим решением из перечисленных.

На самом деле, битвы асинхронности и мультипроцессности нет. Они вполне могут сосуществовать в согласии. Если у вас есть множество задач, требующих большое количество ресурсов CPU, то мультипроцессинг - очевидный выбор.

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

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

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

Асинхронный код начинает просто сиять, когда у вас есть большое количество задач ввода-вывода, которые в другом случае просто бы блокировали основное выполнение скрипта, например:

  • Работа с сетью и клиент-серверными приложениями.
  • Безсерверный дизайн, одноранговые многопользовательские сети (вроде групповых чатов).
  • Операции чтения∕записи, в которых вы хотите использовать "выстрелил и забыл" стиль, но боитесь забыть блокировку том, что вы читаете или куда пишете данные.

Одна из главных причин не использовать await в том, что далеко не все библиотеки и системы подключения к базам данных поддерживают этот функционал. Если вы хотите добавить асинхронное чтение для какой-то системы управления базой данных, то нужно найти не просто Python обертку, а еще и именно ту, которая поддерживает асинхронные операции. Корутины, которые содержат синхронные вызовы, блокируют другие корутины и операции.

Небольшой список библиотек, поддерживающих async∕await будет в конце этой статьи.

Асинхронный подход, какую библиотеку взять?

Наш материал сфокусирован на асинхронном коде с синтаксисом, содержащим async∕await конструкции и использующем asyncio для управления общим пулом корутин. Но asyncio это не единственная библиотека. Лучше скажет цитата Натаниэля Дж. Смита:

В ближайшие несколько лет, asyncio может оказаться одной из библиотек, избегаемых разработчиками, вроде urllib2.
...
Asyncio стала жертвой своего успеха. Во время её разработки использовались лучшие подходы, но с тех пор появились проекты вдохновленные asyncio. Добавление async∕await изменило ситуацию и мы поняли, что можем сделать лучше. Но asyncio оказалась сдерживаема собственной архитектурой. (источник)

Назовем пару альтернатив, которые используют другое АПИ и другие подходы - curio и trio. Однако на мой взгляд, если вы делаете среднего размера простую программу, проще будет использовать asincio и не добавлять огромную зависимость в свой проект.

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

Полезные мелочи.

В следующих нескольких секциях я раскрою некоторые части asyncio и async∕await, не затронутые ранее, но важные для понимания построения программ.

Другие асинхронные функции высшего уровня.

В дополнение к asyncio.run() вы уже видели функции вроде syncio.create_task() и asyncio.gather().

Вы можете использовать сreate_task(), чтобы запланировать выполнение корутин для asycnio.run():

-8

В примере есть тонкость. Если мы не будет использовать аwait с функцией t в функции main(), то она может отработать до того, как сам main() сообщит о завершении t. Это происходит потому что asyncio.run(main()) вызывает цикл до завершения функции main(). Подразумевается, что если main() отработала, то все корутины внутри неё так же отработали и их можно завершить. Чтобы получить текущие задачи, вы можете использовать asyncio.Task.all_tasks()

Заметка: asyncio.create_task() появилось только в Python 3.7. В Python 3.6 или ниже используйте asyncio.ensure_future() вместо create_task().

Отдельно есть asyncio.gather(). Эта команда позволяет аккуратно разместить корутины в одном future-объекте. Как результат, она возвращает future-объект, в котором вы можете указать, какие именно корутины хотите дождаться. В чем то это похоже на работу с очередями и queue.join() из ранних примеров. Результатом gather() будет список результатов корутин:

-9

Вы скорее всего заметили, что gather() ждет общего результата всех корутин, которые вы запустили. Другой способ - проитерироваться через asyncio.as_completed() чтобы получить задачи, которые уже завершены в порядке завершения. Эта функция вернет генератор, возвращающий завершившиеся задачи. Далее вы увидите результат coro([3, 2, 1]), который будет будет доступен раньше coro([10, 5, 0]), а не одновременно, как в случае с gather():

-10

Приоритет ожидания.

Несмотря на похожесть у команды await более высокий приоритет чем у yield. Поэтому в некоторых случаях вам могут понадобиться скобки. Для более подробной информации смотрите примеры из PEP 492.

Заключение.

Теперь вы достаточно подкованы, чтобы использовать async∕await и библиотеки построенные на них. Краткий перечень освещенных тем:

  • Асинхронный подход - не зависящая от языка модель, и способ наладить одновременное исполнение программ, с помощью запуска корутин, косвенно взаимодействующих с другим.
  • Специфичные для Python инструменты - async∕await. Используйте их для определения корутин
  • asyncio - Python библиотека, которая предоставляет АПИ для запуска и управления корутинами.

__________________________________________________________________________________________

Ееееи, мой долг по переводам наконец-то доделан)

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

Список библиотек, о котором говорилось в материале.

Libraries That Work With async/await

From aio-libs:

  • aiohttp: Asynchronous HTTP client/server framework
  • aioredis: Async IO Redis support
  • aiopg: Async IO PostgreSQL support
  • aiomcache: Async IO memcached client
  • aiokafka: Async IO Kafka client
  • aiozmq: Async IO ZeroMQ support
  • aiojobs: Jobs scheduler for managing background tasks
  • async_lru: Simple LRU cache for async IO

From magicstack:

  • uvloop: Ultra fast async IO event loop
  • asyncpg: (Also very fast) async IO PostgreSQL support

From other hosts:

  • trio: Friendlier asyncio intended to showcase a radically simpler design
  • aiofiles: Async file IO
  • asks: Async requests-like http library
  • asyncio-redis: Async IO Redis support
  • aioprocessing: Integrates multiprocessing module with asyncio
  • umongo: Async IO MongoDB client
  • unsync: Unsynchronize asyncio
  • aiostream: Like itertools, but async