Как стать автором
Обновить

Многопоточное скачивание в cURL на PHP

Время на прочтение 7 мин
Количество просмотров 34K
В данном топике представлена на мой взгляд удобная и функциональная реализация многопоточного скачивания на cURL для PHP. Возможно кому-то она будет полезна, а мне принесёт инвайт ;)

Скачиванием через cURL не пользовался пусть даже из интереса только ленивый. Будь-то из консоли, либо реализуя код на каком-либо ЯП. Решения блокирующего скачивания одной ссылки валяются на каждом углу сети, к примеру на php.net. Однако, если рассматривать реализации на PHP, то такой подход подчас не подходит ввиду высоких временных затрат на вспомогательные операции ( dns lookup, request waiting и подобные ). Для скачивания большого числа страниц последовательный вариант не приемлем. Если устраивает — дальше можно не читать :)

В Perl, к примеру, можно применять fork() либо нити (use threads) для распараллеливания однопоточных скачиваний. Это не считая богатых возможностей библиотек данного языка. Я лично применял нити и LWP. Однако, речь идёт о PHP, и тут с распараллеливанием большие проблемы ввиду отсутствия данной возможности в принципе. Если кто знает, как создавать нити, сообщите, но я не нашел пока достойных решений. Да, в cURL есть функции curl_multi_*, но вот примеры реализаций на их основе меня не устроили. И, в итоге, решил собрать свой велосипед.

Первоначально отсылаю к простейшему примеру из офф. справочника. Позволю себе привести его тут :)
<?php
    
// create both cURL resources
    
$ch1 curl_init();
    
$ch2 curl_init();
    
    
// set URL and other appropriate options

    
curl_setopt($ch1CURLOPT_URL«www.example.com»);
    
curl_setopt($ch1CURLOPT_HEADER0);
    
curl_setopt($ch2CURLOPT_URL«www.php.net»);

    
curl_setopt($ch2CURLOPT_HEADER0);
    
    
//create the multiple cURL handle
    
$mh curl_multi_init();

    
    
//add the two handles
    
curl_multi_add_handle($mh,$ch1);
    
curl_multi_add_handle($mh,$ch2);

    
    
$running=null;
    
//execute the handles
    
do {
        
curl_multi_exec($mh,$running);
    } while (
$running 0);

    
    
//close the handles
    
curl_multi_remove_handle($mh$ch1);
    
curl_multi_remove_handle($mh$ch2);
    
curl_multi_close($mh);

?>

Код отличается от однопоточного подхода более сложной организацией взаимодействия прикладного кода с библиотекой:
1) Для каждого соединения выполняется свой curl_init() и задаются параметры через curl_setopt(). Тут всё стандартно, привожу без объяснений.
2) Для общего управления скачиванием вызовом curl_multi_init() создается отдельный дескриптор, через который и будет производиться вся дальнейшая работа.
3) К данному дескриптору вызовом curl_multi_add_handle() цепляются созданные в начале отдельный соединения.
Подготовительный этап завершен, теперь непосредственно скачивание:
4) Скачивание библиотекой выполняется автоматически, явного вызова как было с curl_exec() теперь нет. Его заменяет многократный вызов curl_multi_exec(). Несмотря на схожее название, данная функция выполняет несколько другую роль — она блокирующе информирует об изменении числа активных потоков (ну и возникших ошибок). Второй параметр при вызове — ссылка на числовую переменную, в которую сохраняется число активных в данный момент соединений. Количество изменилось — значит какой-то поток завершил работу. Вот по этой причине цикл скачивания и реализован через
do {
curl_multi_exec($mh,$running);
} while (
$running 0);

5) Ну и наконец после скачивания выполняется освобождение ресурсов. Важно! Хоть соединения, созданные curl_init() и «цепляются» к основному дескриптору, он их автоматически не закрывает, это нужно делать вручную вызовом curl_multi_remove_handle() в добавление к curl_close().

Кому-то может хватить и такой реализации, и они могут дальше не читать. Я же пойду дальше.
Что в данной реализации плохого? Пара наиболее явных моментов:
  1. жёсткое ограничение на скачивание 2х ссылок, заданное прямо в коде
  2. получаемые страницы выводятся прямо в STDOUT

Это лишь часть, остальное обсуждается далее.

Исправляю указанные недостатки и получаю, к примеру, следующее:
<?php
    $urls 
= array( «www.example.com»«www.php.net» );
    
    
$mh curl_multi_init();
    
    
$chs = array();

    foreach ( 
$urls as $url ) {
        
$chs[] = ( $ch curl_init() );
        
curl_setopt$chCURLOPT_URL$url );

        
curl_setopt$chCURLOPT_HEADER);
        
// CURLOPT_RETURNTRANSFER - возвращать значение как результат функции, а не выводить в stdout

        
curl_setopt$chCURLOPT_RETURNTRANSFER);
        
curl_multi_add_handle$mh$ch );
    }

    
$prev_running $running null;

    do {
        
curl_multi_exec$mh$running );
        
        if ( 
$running != $prev_running ) {
            
// получаю информацию о текущих соединениях

            
$info curl_multi_info_read$mh );
            
            if ( 
is_array$info ) && ( $ch $info['handle'] ) ) {

                
// получаю содержимое загруженной страницы
                
$content curl_multi_getcontent$ch );
                
                
// тут какая-то обработка текста страницы

                // пока пусть будет как и в оригинале - вывод в STDOUT
                
echo $content;
            }
            

            
// обновляю кешируемое число текущих активных соединений
            
$prev_running $running;
        }
        
    } while ( 
$running );

    
    foreach ( 
$chs as $ch ) {
        
curl_multi_remove_handle$mh$ch );
        
curl_close$ch );

    }
    
curl_multi_close($mh);
?>


Далее, вряд ли в большинстве случаев будет достаточно просто выводить страницы в STDOUT. Тем более это происходит в произвольном порядке в зависимости от порядка реального скачивания (а не задания вызовами curl_multi_add_handle() ). Также, если скачивается большой объем, то нет смысла дожидаться получения всех страниц — можно уже начинать обрабатывать их по мере получения. Но и вариант с получением всех скопом также не стоит снимать со счетов.
Для этого: 1) реализую всё в виде функции, 2) введу параметр, задающий callback-функцию, которая будет вызываться для каждого полученного файла. Если callback не задан — применяется вариант с получением всех страниц сразу. Вот пример:
<?php
    
// пример простейшего callback'а. практически dummy-func.
    
function my_callback$url$content$curl_status$ch ) {

        echo 
«Скачивание страницы [$url] »;
        if ( !
$curl_status ) {
            echo 
«было успешным. текст страницы:\n$content\n»;

        }
        else {
            echo 
«выполнилось с ошибкой #$curl_status: ».curl_error$ch )."\n";
        }

    }
    
    function 
http_load$urls$callback false ) {
        
$mh curl_multi_init();
        

        
$chs = array();
        foreach ( 
$urls as $url ) {
            
$chs[] = ( $ch curl_init() );

            
curl_setopt$chCURLOPT_URL$url );
            
curl_setopt$chCURLOPT_HEADER);
            
// CURLOPT_RETURNTRANSFER - возвращать значение как результат функции, а не выводить в stdout

            
curl_setopt$chCURLOPT_RETURNTRANSFER);
            
curl_multi_add_handle$mh$ch );
        }
        
        
// если $callback задан как false, то функция должна не вызывать $callback, а выдать страницы как результат работы

        
if ( $callback === false ) {
            
$results = array();
        }
        
        
$prev_running $running null;

        do {
            
curl_multi_exec$mh$running );
            
            if ( 
$running != $prev_running ) {
                
// получаю информацию о текущих соединениях

                
$info curl_multi_info_read$ghandler );
                
                if ( 
is_array$info ) && ( $ch $info['handle'] ) ) {

                    
// получаю содержимое загруженной страницы
                    
$content curl_multi_getcontent$ch );
                    
                    
// скаченная ссылка
                    
$url curl_getinfo$chCURLINFO_EFFECTIVE_URL );

                    
                    if ( 
$callback !== false ) {
                        
// вызов callback-обработчика
                        
$callback$url$content$info['result'], $ch );

                    }
                    else {
                        
// добавление в хеш результатов
                        
$results$url ] = array( 'content' => $content'status' => $info['result'], 'status_text' => curl_error$ch ) );

                    }
                    
                }
                
                
// обновляю кешируемое число текущих активных соединений
                
$prev_running $running;
            }
            

        } while ( 
$running );
        
        foreach ( 
$chs as $ch ) {
            
curl_multi_remove_handle$mh$ch );

            
curl_close$ch );
        }
        
curl_multi_close$mh );
        
        
// результаты
        
return ( $callback !== false ) ? true $results;

    }
    
    
$urls = array( «www.example.com»«www.php.net» );
    
    
// вариант простой выдачи
    
print_rhttp_load$urls ) );

    
    
// вариант с callback
    
var_exporthttp_load$urlsmy_callback ) );
    
?>

Вот уже гораздо интереснее. Важный момент: при callback 4ый параметр — дескриптор соединения $ch, а при выдаче результатов хешем — просто строковое описание возникшей ошибки (ну или пустая строка, если всё нормально). Почему? Потому что curl_error() требует передачи дескриптора, который закрывается в конце работы функции. Так что в callback он еще существует и мы можем его использовать, а вот в хеше он уже ничего ценного дать не может. Как вариант, строковые описания кодов ошибок можно взять тут.

Итак, идём дальше. Хочется вызывать функцию не только для массива ссылок, но и иметь возможность скачать ей единственную страницу. Для этого нужно добавить всего-то одну строчку:
<?php function http_load$urls$callback false ) {

        
// даже если передан единственный параметр - считаю его элементом массива

        // это аналог: $urls = is_array( $urls ) ? $urls : array( $urls );
        
$urls = (array)$urls;

.... 
?>

Вот теперь можно качать ссылки по одной: http_load( 'google.com' ). Этакий возврат к истокам.

Потом мне потребовалось задавать много больше передаваемых заголовков для соединений. Указывать их по одному через curl_setopt() не практично. Лучше воспользоваться функцией curl_setopt_array. Переделываю и получаю (часть кода):
<?php
    
// общие для всех соединений заголовки
        
$ext_headers = array(
                            
'Expect:',
                            
'Accept: text/html,application/xhtml+xml,application/xml;q=0.9',

                            
'Accept-Language: ru,en-us;q=0.7,en;q=0.7',
                            
//'Accept-Encoding: gzip,deflate', // нужно потом распаковывать. ну его пока…
                            
'Accept-Charset: utf-8,windows-1251;q=0.7,*;q=0.5',
        );
        
$curl_options = array(

                                
CURLOPT_PORT            => 80,
                                
CURLOPT_RETURNTRANSFER    => 1// возвращать значение как результат функции, а не выводить в stdout

                                
CURLOPT_BINARYTRANSFER    => 1// передавать в binary-safe
                                
CURLOPT_CONNECTTIMEOUT    => 10// таймаут соединения ( lookup + connect )

                                
CURLOPT_TIMEOUT            => 30// таймаут на получение данных
                                
CURLOPT_USERAGENT        => 'Mozilla/5.0 (X11; U; Linux x86_64; en-US; rv:1.9.1.1) Gecko/20090716 Ubuntu/9.04 (jaunty) Shiretoko/3.5.1',

                                
CURLOPT_VERBOSE            => 2// уровень информирования
                                
CURLOPT_HEADER            => 0// заголовок не получается
                                
CURLOPT_FOLLOWLOCATION    => 1// следовать редиректам

                                
CURLOPT_MAXREDIRS        => 7// максимальное число редиректов
                                
CURLOPT_AUTOREFERER        => 1// при редиректе подставлять в «Referer:» значение из «Location:»

                                // CURLOPT_FRESH_CONNECT    => 0, // каждый раз использовать новое соединение
                                
CURLOPT_HTTPHEADER        => $ext_headers,
                            );
    }
    

    function 
http_load$urls$callback false ) {
        global 
$curl_options;
        
        
$mh curl_multi_init();

        if ( 
$mh === false ) return false;
        
        
$urls = (array)$urls;
        
        
$chs = array();

        foreach ( 
$urls as $url ) {
            
$chs[] = ( $ch curl_init() );
            

            
curl_setopt_array$ch$curl_options ); // задаю заголовки скопом
            
curl_setopt$chCURLOPT_URL$url );

            
            
curl_multi_add_handle$mh$ch );
        }

?>


Прикидываемся Огнелисом. Заголовки я прокомментировал. За подробным объяснением отправляю сюда.
И в догонку к этим заголовкам добавляется третий параметр в функцию:
<?php function http_load( $urls, $callback = false, $urls_params = array() ) {} ?>
в котором можно указывать свои заголовки, которые будут дополнительно преданы в соединения при их инициализации. Таким образом можно успешно отправлять POST запросы с параметрами либо указывать свои рефералы и формат передаваемых данных (при компрессии к примеру).
<?php

        foreach ( 
$urls as $ind => $url ) {
            
$chs[] = ( $ch curl_init() );

            
            
curl_setopt_array$ch$curl_options ); // задаю заголовки скопом
            
curl_setopt$chCURLOPT_URL$url );

            
            
// есть дополнительные параметры для инициализации данного соединения?
            
if ( isset( $urls_params$ind ] ) && is_array$urls_params$ind ] ) ) {

                
curl_setopt_array$ch$urls_params$ind ] );
            }
            
            
curl_multi_add_handle$mh$ch );

        }

?>


Вот такая функция. Еще можно было бы написать про работу с куками и POST-запросами, но это уж если получу инвайт. И так понаписал много, многие ли осилили? ;)
Теги:
Хабы:
+17
Комментарии 21
Комментарии Комментарии 21

Публикации

Истории

Работа

PHP программист
175 вакансий

Ближайшие события

Московский туристический хакатон
Дата 23 марта – 7 апреля
Место
Москва Онлайн
Геймтон «DatsEdenSpace» от DatsTeam
Дата 5 – 6 апреля
Время 17:00 – 20:00
Место
Онлайн