Загрузка в несколько потоков с multi_curl и PHP

Когда нужно скачать сотню страниц, то можно обойтись моим компонентом Browser. Но недавно мне надо было скачать столько информации, что нужно было ждать неделю. Проблема в том, что компонент загружает страницы по очереди. Пришлось придумывать как заставить его качать в несколько потоков.

Оказалось, что самое простое решение - это multi_curl. Но его на форумах очень критикуют за то, что он ест слишком много памяти, глючит и вообще безобразничает. Есть ещё вариант - неблокирующие сокеты, но мне они показались более сложными. Ну, а самое правильное, решение - это pkg_delete php, pkg_add perl :) Потому что Perl для этого задуман, а PHP - нет. Но ради единоразовой операции, хоть и длительной, пересиливать свою необъяснимую нелюбовь к Perl я не стал. Тем более пишут, что если ставить немного потоков (до 100), то multi_curl будет нормально работать.

К счастью, я не сторонник выдумывания велосипедов, поэтому взял библиотеку Вадима Тимофеева для работы с multi_curl. Чтобы она работала с компонентом Browser, скачайте последнюю версию, разархивируйте файл MultiCurl.class.php в папку vendors вашего CakePHP-проекта и переименуйте его в multi_curl.php.

Папка vendors в CakePHP позволяет использовать сторонние разработки, которые не созданы специально для CakePHP. Эти файлы подключаются с помощью функции vendor()

Класс MultiCurl довольно интересно реализован. Он является абстрактным классом. Чтобы использовать его в своей программе, надо создать его наследника, в котором переопределить событие, которое происходит при загрузке. Вот что пришлось дописать в начало компонента Browser.

PHP:
  1. vendor('multi_curl');
  2. class BrowserComponentMultiCurl extends MultiCurl {
  3.     var $Browser = null;
  4.  
  5.     protected function onLoad($url, $content, $info) {
  6.         $s = (serialize($info) . "\r\n\r\n" . $content);
  7.         file_put_contents($this->Browser->_getCacheFilename($url), $s);
  8.     }
  9. }

Тут необходимо пояснить принцип работы multi_curl. При скачивании в несколько потоков multi_curl ставит в очередь закачки несколько страниц и возращает управление основной программе только после того как закачает все страницы. Но после закачки каждого файла выполняется callback-функция.

Я решил упростить себе задачу и сделал вместо метода getMulti($urls), метод cacheMulti($urls). Поэтому не забудьте созадать папку для кеширования - APP/tmp/cache/browser

PHP:
  1. /**
  2. * Downloads URLs in multiple threads
  3. *
  4. * @param array $urls a(url1, url2, ...)
  5. * @return boolean
  6. */
  7. function cacheMulti($urls) {
  8.     try {
  9.         $mc = new BrowserComponentMultiCurl();
  10.         $mc->Browser = $this;
  11.         $mc->setMaxSessions(5); // limit 5 parallel sessions (by default 10)
  12.         //$mc->setMaxSize(10240); // limit 10 Kb per session (by default 10 Mb)
  13.  
  14.         foreach ($urls as $url) {
  15.             if (!$this->_isCached($url)) {
  16.                 $mc->addUrl($url);
  17.             }
  18.         }
  19.         $mc->wait();
  20.     } catch (Exception $e) {
  21.         // dirty style, but good enough for my tasks
  22.         echo('<h3 style="color:red">'.$e->getMessage().'</h3>');
  23.         @flush();ob_flush();
  24.     }
  25.  
  26.     return true;
  27. }

Из основной программы этот метод я хотел вызвать сначала так:

PHP:
  1. $this->Browser->cacheMulti($urls);
  2. foreach ($urls as $url) {
  3.     $html = $this->Browser->get($url); // уже закешировано, поэтому выдаст сразу
  4.  
  5.     // process $html
  6. }

Но не тут то было. Так как URLов действительно очень много, то ни Firefox, ни Lynx (я сервисные скрипты запускаю часто через него) не могли дождаться ответа от сервера и писалось 408 Request Timeout. Тогда я добавил в httpd.conf настройку Timeout 300000. Ошибка 408 Request Timeout всё равно показывалась, но насколько я понял, Apache продолжал выполнять скрипт ещё 300000 секунд. Я не нашёл правильного решения этой проблемы, поэтому немного подкорректировал скрипт, разбив список URLов на части.

PHP:
  1. $queueLength = 30;
  2. for ($i=0; $i<count($urls); $i+=$queueLength) {
  3.     $queue = array_slice($urls, $i, $queueLength);
  4.     $this->Browser->cacheMulti($queue);
  5.  
  6.     foreach ($queue as $url) {
  7.         $html = $this->Browser->get($url); // уже закешировано, поэтому выдаст сразу
  8.  
  9.         // process $html
  10.     }
  11.        
  12.     echo(((!empty($i)?', ':'')) . $i); // показываем обработанный номер
  13.     @flush();ob_flush(); // принудительное отображение. по одной работать не хотят. собака в начале строки - неизбежное зло
  14.     usleep(500); // нечего пригружать сильно чужие сервера - забанят
  15. }

Можно было бы поставить обработку информации в onLoad, но мне так удобнее :)

Вот полная версия обновлённого компонента Browser

PHP:
  1. <?
  2.  
  3. vendor('multi_curl');
  4. class BrowserComponentMultiCurl extends MultiCurl {
  5.     var $Browser = null;
  6.  
  7.     protected function onLoad($url, $content, $info) {
  8.         $s = (serialize($info) . "\r\n\r\n" . $content);
  9.         file_put_contents($this->Browser->_getCacheFilename($url), $s);
  10.     }
  11. }
  12.  
  13. /**
  14. * Emulation of browser
  15. *
  16. * @version 1.3 (24 Oct 2007)
  17. * @author Vladimir Luchaninov - http://php.southpark.com.ua
  18. *
  19. */
  20. class BrowserComponent extends Object {
  21.     var $handle;
  22.     var $header;
  23.     var $body;
  24.  
  25.     /**
  26.      * Name of browser you want to emulate. If 'random' then it will select from the large list.
  27.      *
  28.      * @var string
  29.      */
  30.     var $userAgent = 'random';
  31.  
  32.     // if you need http auth
  33.     var $username = null;
  34.     var $password = null;
  35.  
  36.     var $proxy = ''; // 'ip:port'
  37.     var $referer = 'http://www.google.com/';
  38.     var $timeout = 30;
  39.  
  40.     /**
  41.      * if you want to cache your requests you need to create folder APP/tmp/cache/browser
  42.      *
  43.      * @var string
  44.      */
  45.     var $cacheFolder = null;
  46.  
  47.     var $symbolsNotFile = array( '~''!''@''#''http://', '/'"\\", ':''*''?''"''<''>''|');
  48.     var $symbolsFile = array('~~', '!!', '@@', '##', '#~',      '~!', '~@', '~#', '!~', '!@', '!#', '@~', '@!', '@#'); // still reserved '#!', '#@'
  49.  
  50.     /**
  51.      * Init handle for connection
  52.      *
  53.      * @param AppController $controller
  54.      */
  55.     function startup(&$controller) {
  56.         $cacheFolder = APP . 'tmp' . DS . 'cache' . DS . 'browser' . DS;
  57.         if (is_dir($cacheFolder)) {
  58.             $this->cacheFolder = $cacheFolder;
  59.         }
  60.  
  61.         $this->_initUserAgent();
  62.  
  63.         $this->handle = curl_init();
  64.     }
  65.  
  66.     /**
  67.      * Convert URL to the filename for caching
  68.      *
  69.      * @param string $url Like http://php.southpark.com.ua
  70.      * @return string Filename of the cache file (withour full path)
  71.      */
  72.     function urlToFilename($url) {
  73.         return r($this->symbolsNotFile, $this->symbolsFile, $url).'.txt';
  74.     }
  75.  
  76.     /**
  77.      * Convert filename from cache to URL
  78.      *
  79.      * @param string $filename Filename of cached file (without full path)
  80.      * @return string URL
  81.      */
  82.     function filenameToUrl($filename) {
  83.         return r($this->symbolsFile, $this->symbolsNotFile, substr($filename, 0, strlen($filename)-4));
  84.     }
  85.  
  86.     /**
  87.      * Extract header and body from response to $this->header and $this->body
  88.      *
  89.      * @param string $response
  90.      * @return string
  91.      */
  92.     function _setHeaderBody($response) {
  93.         // You should see responses from some strange web-services
  94.         // Check for \r\n\r\n is really not enough
  95.         $regex = '/(.*?)\n[\r\n]*?\n+(.*)/sm';
  96.  
  97.         $this->header = '';
  98.         if (!preg_match($regex, $response, $m)) {
  99.             $this->body = $response;
  100.         } else {
  101.             $this->header = $m[1];
  102.             $this->body = ltrim($m[2], "\r");
  103.  
  104.             // sometimes there are several headers
  105.             while (strpos($this->body, 'HTTP/')===0 && preg_match($regex, $this->body, $m)) {
  106.                 $this->header .= "\n\n" . $m[1];
  107.                 $this->body = ltrim($m[2], "\r");
  108.             }
  109.         }
  110.  
  111.         return true;
  112.     }
  113.  
  114.     /**
  115.      * Get cache file filename for $url if possible. Otherwise null
  116.      *
  117.      * @param string $url Like http://php.southpark.com.ua
  118.      * @return string Cache file filename with full path
  119.      */
  120.     function _getCacheFilename($url) {
  121.         if (!empty($this->cacheFolder) && empty($postvars)) {
  122.             return $this->cacheFolder . $this->urlToFilename($url);
  123.         } else {
  124.             return null;
  125.         }
  126.     }
  127.  
  128.     /**
  129.      * Check if $url is already downloaded and saved to cache file
  130.      *
  131.      * @param string $url Like http://php.southpark.com.ua
  132.      * @return boolean True if $url exist in cache
  133.      */
  134.     function _isCached($url) {
  135.         $cacheFile = $this->_getCacheFilename($url);
  136.  
  137.         return (!empty($cacheFile) && file_exists($cacheFile));
  138.     }
  139.  
  140.     /**
  141.      * List all cached URLs
  142.      *
  143.      * @return array a(url1, url2, ...)
  144.      */
  145.     function getCachedUrls() {
  146.         $folder = new Folder($this->cacheFolder);
  147.         $files = $folder->find('.*\.txt');
  148.  
  149.         $urls = array();
  150.         foreach ($files as $filename) {
  151.             $urls[] = $this->filenameToUrl($filename);
  152.         }
  153.  
  154.         return $urls;
  155.     }
  156.  
  157.     /**
  158.      * Main function
  159.      *
  160.      * @param string $url
  161.      * @param array $postvars
  162.      * @return string body
  163.      * after execution $this->header is accessible if needed
  164.      */
  165.     function get($url, $postvars=null) {
  166.         $cacheFile = $this->_getCacheFilename($url);
  167.  
  168.         if ($this->_isCached($url)) {
  169.             $response = file_get_contents($cacheFile);
  170.         } else {
  171.             $this->prepare($url, $postvars);
  172.             $response = curl_exec($this->handle);
  173.             if (!empty($cacheFile)) {
  174.                 file_put_contents($cacheFile, $response);
  175.             }
  176.         }
  177.         $this->referer = $url;
  178.  
  179.         $this->_setHeaderBody($response);
  180.  
  181.         return $this->body;
  182.     }
  183.  
  184.     /**
  185.      * Downloads URLs in multiple threads
  186.      *
  187.      * @param array $urls a(url1, url2, ...)
  188.      * @return boolean
  189.      */
  190.     function cacheMulti($urls) {
  191.         try {
  192.             $mc = new BrowserComponentMultiCurl();
  193.             $mc->Browser = $this;
  194.             $mc->setMaxSessions(5); // limit 5 parallel sessions (by default 10)
  195.             //$mc->setMaxSize(10240); // limit 10 Kb per session (by default 10 Mb)
  196.  
  197.             foreach ($urls as $url) {
  198.                 if (!$this->_isCached($url)) {
  199.                     $mc->addUrl($url);
  200.                 }
  201.             }
  202.             $mc->wait();
  203.         } catch (Exception $e) {
  204.             // dirty style, but good enough for my tasks
  205.             echo('<h3 style="color:red">'.$e->getMessage().'</h3>');
  206.             @flush();ob_flush();
  207.         }
  208.  
  209.         return true;
  210.     }
  211.  
  212.     /**
  213.      * Set default options of curl
  214.      *
  215.      * @param string $url
  216.      * @param array $postvars
  217.      */
  218.     function prepare($url, $postvars=false){
  219.         curl_setopt($this->handle, CURLOPT_PROXY, $this->proxy);
  220.         curl_setopt($this->handle, CURLOPT_REFERER, $this->referer);
  221.         curl_setopt($this->handle, CURLOPT_USERAGENT, $this->userAgent);
  222.         curl_setopt($this->handle, CURLOPT_URL, str_replace('&amp;','&',$url));
  223.         curl_setopt($this->handle, CURLOPT_HEADER, 1);
  224.         curl_setopt($this->handle, CURLOPT_FOLLOWLOCATION,1);
  225.         curl_setopt($this->handle, CURLOPT_RETURNTRANSFER, 1);
  226.         curl_setopt($this->handle, CURLOPT_TIMEOUT, $this->timeout);
  227.         curl_setopt($this->handle, CURLOPT_SSL_VERIFYPEER, false);
  228.         curl_setopt($this->handle, CURLOPT_SSL_VERIFYHOST,  2);
  229.  
  230.         curl_setopt($this->handle, CURLOPT_COOKIEJAR, APP.'tmp/cookie.txt');
  231.         curl_setopt($this->handle, CURLOPT_COOKIEFILE, APP.'tmp/cookie.txt');
  232.  
  233.         if (!empty($postvars)){
  234.             curl_setopt($this->handle, CURLOPT_POST, 1);
  235.             curl_setopt($this->handle, CURLOPT_POSTFIELDS, $postvars);
  236.         }
  237.  
  238.         if (!empty($this->username)) {
  239.             curl_setopt($this->handle, CURLOPT_HTTPAUTH, CURLAUTH_ANY);
  240.             curl_setopt($this->handle, CURLOPT_USERPWD, $this->username.':'.$this->password); // $auth should be [username]:[password]
  241.         }
  242.  
  243.         return true;
  244.     }
  245.  
  246.     /**