MultiHttpClient   C
last analyzed

Complexity

Total Complexity 64

Size/Duplication

Total Lines 384
Duplicated Lines 0 %

Coupling/Cohesion

Components 1
Dependencies 0

Importance

Changes 0
Metric Value
dl 0
loc 384
rs 5.8364
c 0
b 0
f 0
wmc 64
lcom 1
cbo 0

6 Methods

Rating   Name   Duplication   Size   Complexity  
B __construct() 0 16 5
A run() 0 3 1
D runMulti() 0 125 28
F getCurlHandle() 0 136 26
A getCurlMulti() 0 9 2
A __destruct() 0 5 2

How to fix   Complexity   

Complex Class

Complex classes like MultiHttpClient often do a lot of different things. To break such a class down, we need to identify a cohesive component within that class. A common approach to find such a component is to look for fields/methods that share the same prefixes, or suffixes. You can also have a look at the cohesion graph to spot any un-connected, or weakly-connected components.

Once you have determined the fields that belong together, you can apply the Extract Class refactoring. If the component makes sense as a sub-class, Extract Subclass is also a candidate, and is often faster.

While breaking up the class, it is a good idea to analyze how other classes use MultiHttpClient, and based on these observations, apply Extract Interface, too.

1
<?php
2
/**
3
 * HTTP service client
4
 *
5
 * This program is free software; you can redistribute it and/or modify
6
 * it under the terms of the GNU General Public License as published by
7
 * the Free Software Foundation; either version 2 of the License, or
8
 * (at your option) any later version.
9
 *
10
 * This program is distributed in the hope that it will be useful,
11
 * but WITHOUT ANY WARRANTY; without even the implied warranty of
12
 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
13
 * GNU General Public License for more details.
14
 *
15
 * You should have received a copy of the GNU General Public License along
16
 * with this program; if not, write to the Free Software Foundation, Inc.,
17
 * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
18
 * http://www.gnu.org/copyleft/gpl.html
19
 *
20
 * @file
21
 */
22
23
/**
24
 * Class to handle concurrent HTTP requests
25
 *
26
 * HTTP request maps are arrays that use the following format:
27
 *   - method   : GET/HEAD/PUT/POST/DELETE
28
 *   - url      : HTTP/HTTPS URL
29
 *   - query    : <query parameter field/value associative array> (uses RFC 3986)
30
 *   - headers  : <header name/value associative array>
31
 *   - body     : source to get the HTTP request body from;
32
 *                this can simply be a string (always), a resource for
33
 *                PUT requests, and a field/value array for POST request;
34
 *                array bodies are encoded as multipart/form-data and strings
35
 *                use application/x-www-form-urlencoded (headers sent automatically)
36
 *   - stream   : resource to stream the HTTP response body to
37
 *   - proxy    : HTTP proxy to use
38
 *   - flags    : map of boolean flags which supports:
39
 *                  - relayResponseHeaders : write out header via header()
40
 * Request maps can use integer index 0 instead of 'method' and 1 instead of 'url'.
41
 *
42
 * @author Aaron Schulz
43
 * @since 1.23
44
 */
45
class MultiHttpClient {
46
	/** @var resource */
47
	protected $multiHandle = null; // curl_multi handle
48
	/** @var string|null SSL certificates path  */
49
	protected $caBundlePath;
50
	/** @var integer */
51
	protected $connTimeout = 10;
52
	/** @var integer */
53
	protected $reqTimeout = 300;
54
	/** @var bool */
55
	protected $usePipelining = false;
56
	/** @var integer */
57
	protected $maxConnsPerHost = 50;
58
	/** @var string|null proxy */
59
	protected $proxy;
60
	/** @var string */
61
	protected $userAgent = 'wikimedia/multi-http-client v1.0';
62
63
	/**
64
	 * @param array $options
65
	 *   - connTimeout     : default connection timeout (seconds)
66
	 *   - reqTimeout      : default request timeout (seconds)
67
	 *   - proxy           : HTTP proxy to use
68
	 *   - usePipelining   : whether to use HTTP pipelining if possible (for all hosts)
69
	 *   - maxConnsPerHost : maximum number of concurrent connections (per host)
70
	 *   - userAgent       : The User-Agent header value to send
71
	 * @throws Exception
72
	 */
73
	public function __construct( array $options ) {
74
		if ( isset( $options['caBundlePath'] ) ) {
75
			$this->caBundlePath = $options['caBundlePath'];
76
			if ( !file_exists( $this->caBundlePath ) ) {
77
				throw new Exception( "Cannot find CA bundle: " . $this->caBundlePath );
78
			}
79
		}
80
		static $opts = [
81
			'connTimeout', 'reqTimeout', 'usePipelining', 'maxConnsPerHost', 'proxy', 'userAgent'
82
		];
83
		foreach ( $opts as $key ) {
84
			if ( isset( $options[$key] ) ) {
85
				$this->$key = $options[$key];
86
			}
87
		}
88
	}
89
90
	/**
91
	 * Execute an HTTP(S) request
92
	 *
93
	 * This method returns a response map of:
94
	 *   - code    : HTTP response code or 0 if there was a serious cURL error
95
	 *   - reason  : HTTP response reason (empty if there was a serious cURL error)
96
	 *   - headers : <header name/value associative array>
97
	 *   - body    : HTTP response body or resource (if "stream" was set)
98
	 *   - error     : Any cURL error string
99
	 * The map also stores integer-indexed copies of these values. This lets callers do:
100
	 * @code
101
	 *		list( $rcode, $rdesc, $rhdrs, $rbody, $rerr ) = $http->run( $req );
102
	 * @endcode
103
	 * @param array $req HTTP request array
104
	 * @param array $opts
105
	 *   - connTimeout    : connection timeout per request (seconds)
106
	 *   - reqTimeout     : post-connection timeout per request (seconds)
107
	 * @return array Response array for request
108
	 */
109
	public function run( array $req, array $opts = [] ) {
110
		return $this->runMulti( [ $req ], $opts )[0]['response'];
111
	}
112
113
	/**
114
	 * Execute a set of HTTP(S) requests concurrently
115
	 *
116
	 * The maps are returned by this method with the 'response' field set to a map of:
117
	 *   - code    : HTTP response code or 0 if there was a serious cURL error
118
	 *   - reason  : HTTP response reason (empty if there was a serious cURL error)
119
	 *   - headers : <header name/value associative array>
120
	 *   - body    : HTTP response body or resource (if "stream" was set)
121
	 *   - error   : Any cURL error string
122
	 * The map also stores integer-indexed copies of these values. This lets callers do:
123
	 * @code
124
	 *        list( $rcode, $rdesc, $rhdrs, $rbody, $rerr ) = $req['response'];
125
	 * @endcode
126
	 * All headers in the 'headers' field are normalized to use lower case names.
127
	 * This is true for the request headers and the response headers. Integer-indexed
128
	 * method/URL entries will also be changed to use the corresponding string keys.
129
	 *
130
	 * @param array $reqs Map of HTTP request arrays
131
	 * @param array $opts
132
	 *   - connTimeout     : connection timeout per request (seconds)
133
	 *   - reqTimeout      : post-connection timeout per request (seconds)
134
	 *   - usePipelining   : whether to use HTTP pipelining if possible
135
	 *   - maxConnsPerHost : maximum number of concurrent connections (per host)
136
	 * @return array $reqs With response array populated for each
137
	 * @throws Exception
138
	 */
139
	public function runMulti( array $reqs, array $opts = [] ) {
140
		$chm = $this->getCurlMulti();
141
142
		// Normalize $reqs and add all of the required cURL handles...
143
		$handles = [];
144
		foreach ( $reqs as $index => &$req ) {
145
			$req['response'] = [
146
				'code'     => 0,
147
				'reason'   => '',
148
				'headers'  => [],
149
				'body'     => '',
150
				'error'    => ''
151
			];
152
			if ( isset( $req[0] ) ) {
153
				$req['method'] = $req[0]; // short-form
154
				unset( $req[0] );
155
			}
156
			if ( isset( $req[1] ) ) {
157
				$req['url'] = $req[1]; // short-form
158
				unset( $req[1] );
159
			}
160
			if ( !isset( $req['method'] ) ) {
161
				throw new Exception( "Request has no 'method' field set." );
162
			} elseif ( !isset( $req['url'] ) ) {
163
				throw new Exception( "Request has no 'url' field set." );
164
			}
165
			$req['query'] = isset( $req['query'] ) ? $req['query'] : [];
166
			$headers = []; // normalized headers
167
			if ( isset( $req['headers'] ) ) {
168
				foreach ( $req['headers'] as $name => $value ) {
169
					$headers[strtolower( $name )] = $value;
170
				}
171
			}
172
			$req['headers'] = $headers;
173
			if ( !isset( $req['body'] ) ) {
174
				$req['body'] = '';
175
				$req['headers']['content-length'] = 0;
176
			}
177
			$req['flags'] = isset( $req['flags'] ) ? $req['flags'] : [];
178
			$handles[$index] = $this->getCurlHandle( $req, $opts );
179
			if ( count( $reqs ) > 1 ) {
180
				// https://github.com/guzzle/guzzle/issues/349
181
				curl_setopt( $handles[$index], CURLOPT_FORBID_REUSE, true );
182
			}
183
		}
184
		unset( $req ); // don't assign over this by accident
185
186
		$indexes = array_keys( $reqs );
187
		if ( isset( $opts['usePipelining'] ) ) {
188
			curl_multi_setopt( $chm, CURLMOPT_PIPELINING, (int)$opts['usePipelining'] );
189
		}
190
		if ( isset( $opts['maxConnsPerHost'] ) ) {
191
			// Keep these sockets around as they may be needed later in the request
192
			curl_multi_setopt( $chm, CURLMOPT_MAXCONNECTS, (int)$opts['maxConnsPerHost'] );
193
		}
194
195
		// @TODO: use a per-host rolling handle window (e.g. CURLMOPT_MAX_HOST_CONNECTIONS)
196
		$batches = array_chunk( $indexes, $this->maxConnsPerHost );
197
		$infos = [];
198
199
		foreach ( $batches as $batch ) {
200
			// Attach all cURL handles for this batch
201
			foreach ( $batch as $index ) {
202
				curl_multi_add_handle( $chm, $handles[$index] );
203
			}
204
			// Execute the cURL handles concurrently...
205
			$active = null; // handles still being processed
206
			do {
207
				// Do any available work...
208
				do {
209
					$mrc = curl_multi_exec( $chm, $active );
210
					$info = curl_multi_info_read( $chm );
211
					if ( $info !== false ) {
212
						$infos[(int)$info['handle']] = $info;
213
					}
214
				} while ( $mrc == CURLM_CALL_MULTI_PERFORM );
215
				// Wait (if possible) for available work...
216
				if ( $active > 0 && $mrc == CURLM_OK ) {
217
					if ( curl_multi_select( $chm, 10 ) == -1 ) {
218
						// PHP bug 63411; https://curl.haxx.se/libcurl/c/curl_multi_fdset.html
219
						usleep( 5000 ); // 5ms
220
					}
221
				}
222
			} while ( $active > 0 && $mrc == CURLM_OK );
223
		}
224
225
		// Remove all of the added cURL handles and check for errors...
226
		foreach ( $reqs as $index => &$req ) {
227
			$ch = $handles[$index];
228
			curl_multi_remove_handle( $chm, $ch );
229
230
			if ( isset( $infos[(int)$ch] ) ) {
231
				$info = $infos[(int)$ch];
232
				$errno = $info['result'];
233
				if ( $errno !== 0 ) {
234
					$req['response']['error'] = "(curl error: $errno)";
235
					if ( function_exists( 'curl_strerror' ) ) {
236
						$req['response']['error'] .= " " . curl_strerror( $errno );
237
					}
238
				}
239
			} else {
240
				$req['response']['error'] = "(curl error: no status set)";
241
			}
242
243
			// For convenience with the list() operator
244
			$req['response'][0] = $req['response']['code'];
245
			$req['response'][1] = $req['response']['reason'];
246
			$req['response'][2] = $req['response']['headers'];
247
			$req['response'][3] = $req['response']['body'];
248
			$req['response'][4] = $req['response']['error'];
249
			curl_close( $ch );
250
			// Close any string wrapper file handles
251
			if ( isset( $req['_closeHandle'] ) ) {
252
				fclose( $req['_closeHandle'] );
253
				unset( $req['_closeHandle'] );
254
			}
255
		}
256
		unset( $req ); // don't assign over this by accident
257
258
		// Restore the default settings
259
		curl_multi_setopt( $chm, CURLMOPT_PIPELINING, (int)$this->usePipelining );
260
		curl_multi_setopt( $chm, CURLMOPT_MAXCONNECTS, (int)$this->maxConnsPerHost );
261
262
		return $reqs;
263
	}
264
265
	/**
266
	 * @param array $req HTTP request map
267
	 * @param array $opts
268
	 *   - connTimeout    : default connection timeout
269
	 *   - reqTimeout     : default request timeout
270
	 * @return resource
271
	 * @throws Exception
272
	 */
273
	protected function getCurlHandle( array &$req, array $opts = [] ) {
274
		$ch = curl_init();
275
276
		curl_setopt( $ch, CURLOPT_CONNECTTIMEOUT,
277
			isset( $opts['connTimeout'] ) ? $opts['connTimeout'] : $this->connTimeout );
278
		curl_setopt( $ch, CURLOPT_PROXY, isset( $req['proxy'] ) ? $req['proxy'] : $this->proxy );
279
		curl_setopt( $ch, CURLOPT_TIMEOUT,
280
			isset( $opts['reqTimeout'] ) ? $opts['reqTimeout'] : $this->reqTimeout );
281
		curl_setopt( $ch, CURLOPT_FOLLOWLOCATION, 1 );
282
		curl_setopt( $ch, CURLOPT_MAXREDIRS, 4 );
283
		curl_setopt( $ch, CURLOPT_HEADER, 0 );
284
		if ( !is_null( $this->caBundlePath ) ) {
285
			curl_setopt( $ch, CURLOPT_SSL_VERIFYPEER, true );
286
			curl_setopt( $ch, CURLOPT_CAINFO, $this->caBundlePath );
287
		}
288
		curl_setopt( $ch, CURLOPT_RETURNTRANSFER, 1 );
289
290
		$url = $req['url'];
291
		$query = http_build_query( $req['query'], '', '&', PHP_QUERY_RFC3986 );
292
		if ( $query != '' ) {
293
			$url .= strpos( $req['url'], '?' ) === false ? "?$query" : "&$query";
294
		}
295
		curl_setopt( $ch, CURLOPT_URL, $url );
296
297
		curl_setopt( $ch, CURLOPT_CUSTOMREQUEST, $req['method'] );
298
		if ( $req['method'] === 'HEAD' ) {
299
			curl_setopt( $ch, CURLOPT_NOBODY, 1 );
300
		}
301
302
		if ( $req['method'] === 'PUT' ) {
303
			curl_setopt( $ch, CURLOPT_PUT, 1 );
304
			if ( is_resource( $req['body'] ) ) {
305
				curl_setopt( $ch, CURLOPT_INFILE, $req['body'] );
306
				if ( isset( $req['headers']['content-length'] ) ) {
307
					curl_setopt( $ch, CURLOPT_INFILESIZE, $req['headers']['content-length'] );
308
				} elseif ( isset( $req['headers']['transfer-encoding'] ) &&
309
					$req['headers']['transfer-encoding'] === 'chunks'
310
				) {
311
					curl_setopt( $ch, CURLOPT_UPLOAD, true );
312
				} else {
313
					throw new Exception( "Missing 'Content-Length' or 'Transfer-Encoding' header." );
314
				}
315
			} elseif ( $req['body'] !== '' ) {
316
				$fp = fopen( "php://temp", "wb+" );
317
				fwrite( $fp, $req['body'], strlen( $req['body'] ) );
318
				rewind( $fp );
319
				curl_setopt( $ch, CURLOPT_INFILE, $fp );
320
				curl_setopt( $ch, CURLOPT_INFILESIZE, strlen( $req['body'] ) );
321
				$req['_closeHandle'] = $fp; // remember to close this later
322
			} else {
323
				curl_setopt( $ch, CURLOPT_INFILESIZE, 0 );
324
			}
325
			curl_setopt( $ch, CURLOPT_READFUNCTION,
326
				function ( $ch, $fd, $length ) {
327
					$data = fread( $fd, $length );
328
					$len = strlen( $data );
0 ignored issues
show
Unused Code introduced by
$len is not used, you could remove the assignment.

This check looks for variable assignements that are either overwritten by other assignments or where the variable is not used subsequently.

$myVar = 'Value';
$higher = false;

if (rand(1, 6) > 3) {
    $higher = true;
} else {
    $higher = false;
}

Both the $myVar assignment in line 1 and the $higher assignment in line 2 are dead. The first because $myVar is never used and the second because $higher is always overwritten for every possible time line.

Loading history...
329
					return $data;
330
				}
331
			);
332
		} elseif ( $req['method'] === 'POST' ) {
333
			curl_setopt( $ch, CURLOPT_POST, 1 );
334
			// Don't interpret POST parameters starting with '@' as file uploads, because this
335
			// makes it impossible to POST plain values starting with '@' (and causes security
336
			// issues potentially exposing the contents of local files).
337
			// The PHP manual says this option was introduced in PHP 5.5 defaults to true in PHP 5.6,
338
			// but we support lower versions, and the option doesn't exist in HHVM 5.6.99.
339
			if ( defined( 'CURLOPT_SAFE_UPLOAD' ) ) {
340
				curl_setopt( $ch, CURLOPT_SAFE_UPLOAD, true );
341
			} elseif ( is_array( $req['body'] ) ) {
342
				// In PHP 5.2 and later, '@' is interpreted as a file upload if POSTFIELDS
343
				// is an array, but not if it's a string. So convert $req['body'] to a string
344
				// for safety.
345
				$req['body'] = http_build_query( $req['body'] );
346
			}
347
			curl_setopt( $ch, CURLOPT_POSTFIELDS, $req['body'] );
348
		} else {
349
			if ( is_resource( $req['body'] ) || $req['body'] !== '' ) {
350
				throw new Exception( "HTTP body specified for a non PUT/POST request." );
351
			}
352
			$req['headers']['content-length'] = 0;
353
		}
354
355
		if ( !isset( $req['headers']['user-agent'] ) ) {
356
			$req['headers']['user-agent'] = $this->userAgent;
357
		}
358
359
		$headers = [];
360
		foreach ( $req['headers'] as $name => $value ) {
361
			if ( strpos( $name, ': ' ) ) {
362
				throw new Exception( "Headers cannot have ':' in the name." );
363
			}
364
			$headers[] = $name . ': ' . trim( $value );
365
		}
366
		curl_setopt( $ch, CURLOPT_HTTPHEADER, $headers );
367
368
		curl_setopt( $ch, CURLOPT_HEADERFUNCTION,
369
			function ( $ch, $header ) use ( &$req ) {
370
				if ( !empty( $req['flags']['relayResponseHeaders'] ) ) {
371
					header( $header );
372
				}
373
				$length = strlen( $header );
374
				$matches = [];
375
				if ( preg_match( "/^(HTTP\/1\.[01]) (\d{3}) (.*)/", $header, $matches ) ) {
376
					$req['response']['code'] = (int)$matches[2];
377
					$req['response']['reason'] = trim( $matches[3] );
378
					return $length;
379
				}
380
				if ( strpos( $header, ":" ) === false ) {
381
					return $length;
382
				}
383
				list( $name, $value ) = explode( ":", $header, 2 );
384
				$req['response']['headers'][strtolower( $name )] = trim( $value );
385
				return $length;
386
			}
387
		);
388
389
		if ( isset( $req['stream'] ) ) {
390
			// Don't just use CURLOPT_FILE as that might give:
391
			// curl_setopt(): cannot represent a stream of type Output as a STDIO FILE*
392
			// The callback here handles both normal files and php://temp handles.
393
			curl_setopt( $ch, CURLOPT_WRITEFUNCTION,
394
				function ( $ch, $data ) use ( &$req ) {
395
					return fwrite( $req['stream'], $data );
396
				}
397
			);
398
		} else {
399
			curl_setopt( $ch, CURLOPT_WRITEFUNCTION,
400
				function ( $ch, $data ) use ( &$req ) {
401
					$req['response']['body'] .= $data;
402
					return strlen( $data );
403
				}
404
			);
405
		}
406
407
		return $ch;
408
	}
409
410
	/**
411
	 * @return resource
412
	 */
413
	protected function getCurlMulti() {
414
		if ( !$this->multiHandle ) {
415
			$cmh = curl_multi_init();
416
			curl_multi_setopt( $cmh, CURLMOPT_PIPELINING, (int)$this->usePipelining );
417
			curl_multi_setopt( $cmh, CURLMOPT_MAXCONNECTS, (int)$this->maxConnsPerHost );
418
			$this->multiHandle = $cmh;
419
		}
420
		return $this->multiHandle;
421
	}
422
423
	function __destruct() {
424
		if ( $this->multiHandle ) {
425
			curl_multi_close( $this->multiHandle );
426
		}
427
	}
428
}
429