MultiCurlRunner   A
last analyzed

Complexity

Total Complexity 33

Size/Duplication

Total Lines 249
Duplicated Lines 0 %

Importance

Changes 3
Bugs 2 Features 1
Metric Value
eloc 111
c 3
b 2
f 1
dl 0
loc 249
rs 9.76
wmc 33

6 Methods

Rating   Name   Duplication   Size   Complexity  
B doWork() 0 59 8
A getResultData() 0 11 5
B run() 0 45 8
A getResult() 0 11 4
A __construct() 0 11 1
B parseResponse() 0 42 7
1
<?php
2
3
namespace Smoren\MultiCurl;
4
5
use CurlHandle;
6
use CurlMultiHandle;
7
use RuntimeException;
8
9
/**
10
 * Class to run MultiCurl requests and get responses
11
 * @author <[email protected]> Smoren
12
 */
13
class MultiCurlRunner
14
{
15
    /**
16
     * @var resource|CurlMultiHandle MultiCurl resource
17
     */
18
    protected $mh;
19
    /**
20
     * @var array<int, string> map [workerId => customRequestId]
21
     */
22
    protected $workersMap;
23
    /**
24
     * @var array<resource> unemployed workers stack
25
     */
26
    protected $unemployedWorkers;
27
    /**
28
     * @var int max parallel connections count
29
     */
30
    protected $maxConnections;
31
    /**
32
     * @var array<string, array<int, mixed>> map of CURL options including headers by custom request ID
33
     */
34
    protected $requestsConfigMap;
35
    /**
36
     * @var array<string, array<string, mixed>> responses mapped by custom request ID
37
     */
38
    protected $result;
39
40
    /**
41
     * MultiCurlRunner constructor
42
     * @param array<string, array<int, mixed>> $requestsConfigMap map of CURL options
43
     * including headers by custom request ID
44
     * @param int $maxConnections max parallel connections count
45
     */
46
    public function __construct(array $requestsConfigMap, int $maxConnections)
47
    {
48
        $this->requestsConfigMap = $requestsConfigMap;
49
        $this->maxConnections = min($maxConnections, count($requestsConfigMap));
50
51
        $mh = curl_multi_init();
52
53
        $this->mh = $mh;
0 ignored issues
show
Documentation Bug introduced by
It seems like $mh can also be of type true. However, the property $mh is declared as type CurlMultiHandle|resource. Maybe add an additional type check?

Our type inference engine has found a suspicous assignment of a value to a property. This check raises an issue when a value that can be of a mixed type is assigned to a property that is type hinted more strictly.

For example, imagine you have a variable $accountId that can either hold an Id object or false (if there is no account id yet). Your code now assigns that value to the id property of an instance of the Account class. This class holds a proper account, so the id value must no longer be false.

Either this assignment is in error or a type check should be added for that assignment.

class Id
{
    public $id;

    public function __construct($id)
    {
        $this->id = $id;
    }

}

class Account
{
    /** @var  Id $id */
    public $id;
}

$account_id = false;

if (starsAreRight()) {
    $account_id = new Id(42);
}

$account = new Account();
if ($account instanceof Id)
{
    $account->id = $account_id;
}
Loading history...
54
        $this->workersMap = [];
55
        $this->unemployedWorkers = [];
56
        $this->result = [];
57
    }
58
59
    /**
60
     * Makes requests and stores responses
61
     * @return self
62
     * @throws RuntimeException
63
     */
64
    public function run(): self
65
    {
66
        for($i=0; $i<$this->maxConnections; ++$i) {
67
            /** @var resource|false $unemployedWorker */
68
            $unemployedWorker = curl_init();
69
            if(!$unemployedWorker) {
70
                throw new RuntimeException("failed creating unemployed worker #{$i}");
71
            }
72
            $this->unemployedWorkers[] = $unemployedWorker;
73
        }
74
        unset($i, $this->unemployedWorker);
0 ignored issues
show
Bug introduced by
The property unemployedWorker does not exist on Smoren\MultiCurl\MultiCurlRunner. Did you mean unemployedWorkers?
Loading history...
75
76
        foreach($this->requestsConfigMap as $id => $options) {
77
            while(!count($this->unemployedWorkers)) {
78
                $this->doWork();
79
            }
80
81
            $options[CURLOPT_HEADER] = 1;
82
83
            $newWorker = array_pop($this->unemployedWorkers);
84
85
            // @phpstan-ignore-next-line
86
            if(!curl_setopt_array($newWorker, $options)) {
87
                $errNo = curl_errno($newWorker); // @phpstan-ignore-line
88
                $errMess = curl_error($newWorker); // @phpstan-ignore-line
89
                $errData = var_export($options, true);
90
                throw new RuntimeException("curl_setopt_array failed: {$errNo} {$errMess} {$errData}");
91
            }
92
93
            $this->workersMap[(int)$newWorker] = $id;
94
            curl_multi_add_handle($this->mh, $newWorker); // @phpstan-ignore-line
95
        }
96
        unset($options);
97
98
        while(count($this->workersMap) > 0) {
99
            $this->doWork();
100
        }
101
102
        foreach($this->unemployedWorkers as $unemployedWorker) {
103
            curl_close($unemployedWorker); // @phpstan-ignore-line
104
        }
105
106
        curl_multi_close($this->mh); // @phpstan-ignore-line
107
108
        return $this;
109
    }
110
111
    /**
112
     * Returns response:
113
     * [customRequestId => [code => statusCode, headers => [key => value, ...], body => responseBody], ...]
114
     * @param bool $okOnly if true: return only responses with (200 <= status code < 300)
115
     * @return array<string, array<string, mixed>> responses mapped by custom request IDs
116
     */
117
    public function getResult(bool $okOnly = false): array
118
    {
119
        $result = [];
120
121
        foreach($this->result as $key => $value) {
122
            if(!$okOnly || $value['code'] === 200) {
123
                $result[$key] = $value;
124
            }
125
        }
126
127
        return $result;
128
    }
129
130
    /**
131
     * Returns response bodies:
132
     * [customRequestId => responseBody, ...]
133
     * @param bool $okOnly if true: return only responses with (200 <= status code < 300)
134
     * @return array<string, mixed> responses mapped by custom request IDs
135
     */
136
    public function getResultData(bool $okOnly = false): array
137
    {
138
        $result = [];
139
140
        foreach($this->result as $key => $value) {
141
            if(!$okOnly || $value['code'] >= 200 && $value['code'] < 300) {
142
                $result[$key] = $value['body'];
143
            }
144
        }
145
146
        return $result;
147
    }
148
149
    /**
150
     * Manages workers during making the requests
151
     * @return void
152
     */
153
    protected function doWork(): void
154
    {
155
        assert(count($this->workersMap) > 0, "work() called with 0 workers!!");
156
        $stillRunning = null;
157
158
        while(true) {
159
            do {
160
                $err = curl_multi_exec($this->mh, $stillRunning); // @phpstan-ignore-line
161
            } while($err === CURLM_CALL_MULTI_PERFORM);
162
163
            if($err !== CURLM_OK) {
164
                $errInfo = [
165
                    "multi_exec_return" => $err,
166
                    "curl_multi_errno" => curl_multi_errno($this->mh), // @phpstan-ignore-line
167
                    "curl_multi_strerror" => curl_multi_strerror($err)
168
                ];
169
170
                $errData = str_replace(["\r", "\n"], "", var_export($errInfo, true));
171
                throw new RuntimeException("curl_multi_exec error: {$errData}");
172
            }
173
            if($stillRunning < count($this->workersMap)) {
174
                // some workers has finished downloading, process them
175
                // echo "processing!";
176
                break;
177
            } else {
178
                // no workers finished yet, sleep-wait for workers to finish downloading.
179
                curl_multi_select($this->mh, 1); // @phpstan-ignore-line
180
                // sleep(1);
181
            }
182
        }
183
        // @phpstan-ignore-next-line
184
        while(($info = curl_multi_info_read($this->mh)) !== false) {
185
            if($info['msg'] !== CURLMSG_DONE) {
186
                // no idea what this is, it's not the message we're looking for though, ignore it.
187
                continue;
188
            }
189
190
            if($info['result'] !== CURLM_OK) {
191
                $errInfo = [
192
                    "effective_url" => curl_getinfo($info['handle'], CURLINFO_EFFECTIVE_URL),
193
                    "curl_errno" => curl_errno($info['handle']),
194
                    "curl_error" => curl_error($info['handle']),
195
                    "curl_multi_errno" => curl_multi_errno($this->mh), // @phpstan-ignore-line
196
                    "curl_multi_strerror" => curl_multi_strerror(curl_multi_errno($this->mh)) // @phpstan-ignore-line
197
                ];
198
199
                $errData = str_replace(["\r", "\n"], "", var_export($errInfo, true));
200
                throw new RuntimeException("curl_multi worker error: {$errData}");
201
            }
202
203
            $ch = $info['handle'];
204
            $chIndex = (int)$ch;
205
206
            // @phpstan-ignore-next-line
207
            $this->result[$this->workersMap[$chIndex]] = $this->parseResponse(curl_multi_getcontent($ch));
208
209
            unset($this->workersMap[$chIndex]);
210
            curl_multi_remove_handle($this->mh, $ch); // @phpstan-ignore-line
211
            $this->unemployedWorkers[] = $ch;
212
        }
213
    }
214
215
    /**
216
     * Parses the response
217
     * @param string $response raw HTTP response
218
     * @return array<string, mixed> [code => statusCode, headers => [key => value, ...], body => responseBody]
219
     */
220
    protected function parseResponse(string $response): array
221
    {
222
        $arResponse = explode("\r\n\r\n", $response);
223
224
        $arHeaders = [];
225
        $statusCode = null;
226
        $body = null;
227
228
        while(count($arResponse)) {
229
            $respItem = array_shift($arResponse);
230
231
            $line = (string)strtok($respItem, "\r\n");
232
            $statusCodeLine = trim($line);
233
            if(preg_match('|HTTP/[\d.]+\s+(\d+)|', $statusCodeLine, $matches)) {
234
                $arHeaders = [];
235
236
                if(isset($matches[1])) {
237
                    $statusCode = (int)$matches[1];
238
                } else {
239
                    $statusCode = null;
240
                }
241
242
                // Parse the string, saving it into an array instead
243
                while(($line = strtok("\r\n")) !== false) {
244
                    if(($matches = explode(':', $line, 2)) !== false) {
245
                        $arHeaders[trim(mb_strtolower($matches[0]))] = trim(mb_strtolower($matches[1]));
246
                    }
247
                }
248
            } else {
249
                $contentType = $arHeaders['content-type'] ?? null;
250
                if($contentType === 'application/json') {
251
                    $body = json_decode($respItem, true);
252
                } else {
253
                    $body = $respItem;
254
                }
255
            }
256
        }
257
258
        return [
259
            'code' => $statusCode,
260
            'headers' => $arHeaders,
261
            'body' => $body,
262
        ];
263
    }
264
}
265