Memcached   D
last analyzed

Complexity

Total Complexity 58

Size/Duplication

Total Lines 450
Duplicated Lines 0 %

Importance

Changes 13
Bugs 0 Features 0
Metric Value
eloc 131
c 13
b 0
f 0
dl 0
loc 450
rs 4.5599
wmc 58

21 Methods

Rating   Name   Duplication   Size   Complexity  
A decode() 0 9 1
A deleteIfExpired() 0 9 4
A throwExceptionOnClientCallFailure() 0 7 2
A encode() 0 12 2
A getCollection() 0 3 1
A setMultiNumericItemsForHHVM() 0 14 3
A set() 0 9 2
A setMulti() 0 24 5
A get() 0 23 2
A getMulti() 0 31 6
A delete() 0 5 1
A doIncrement() 0 20 6
A __construct() 0 3 1
A cas() 0 14 4
A decrement() 0 11 3
A deleteMulti() 0 46 5
A replace() 0 10 2
A increment() 0 13 3
A flush() 0 3 1
A touch() 0 20 2
A add() 0 10 2

How to fix   Complexity   

Complex Class

Complex classes like Memcached 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.

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 Memcached, and based on these observations, apply Extract Interface, too.

1
<?php
2
3
namespace MatthiasMullie\Scrapbook\Adapters;
4
5
use MatthiasMullie\Scrapbook\Adapters\Collections\Memcached as Collection;
6
use MatthiasMullie\Scrapbook\Exception\InvalidKey;
7
use MatthiasMullie\Scrapbook\Exception\OperationFailed;
8
use MatthiasMullie\Scrapbook\KeyValueStore;
9
10
/**
11
 * Memcached adapter. Basically just a wrapper over \Memcached, but in an
12
 * exchangeable (KeyValueStore) interface.
13
 *
14
 * @author Matthias Mullie <[email protected]>
15
 * @copyright Copyright (c) 2014, Matthias Mullie. All rights reserved
16
 * @license LICENSE MIT
17
 */
18
class Memcached implements KeyValueStore
19
{
20
    /**
21
     * @var \Memcached
22
     */
23
    protected $client;
24
25
    public function __construct(\Memcached $client)
26
    {
27
        $this->client = $client;
28
    }
29
30
    /**
31
     * {@inheritdoc}
32
     */
33
    public function get($key, &$token = null)
34
    {
35
        /**
36
         * Wouldn't it be awesome if I just used the obvious method?
37
         *
38
         * I'm going to use getMulti() instead of get() because the latter is
39
         * flawed in earlier versions, where it was known to mess up some
40
         * operations that are followed by it (increment/decrement have been
41
         * reported, also seen it make CAS return result unreliable)
42
         *
43
         * @see https://github.com/php-memcached-dev/php-memcached/issues/21
44
         */
45
        $values = $this->getMulti(array($key), $tokens);
46
47
        if (!isset($values[$key])) {
48
            $token = null;
49
50
            return false;
51
        }
52
53
        $token = $tokens[$key];
54
55
        return $values[$key];
56
    }
57
58
    /**
59
     * {@inheritdoc}
60
     */
61
    public function getMulti(array $keys, array &$tokens = null)
62
    {
63
        $tokens = array();
64
        if (empty($keys)) {
65
            return array();
66
        }
67
68
        $keys = array_map(array($this, 'encode'), $keys);
69
70
        if (defined('\Memcached::GET_EXTENDED')) {
71
            $return = $this->client->getMulti($keys, \Memcached::GET_EXTENDED);
72
            $this->throwExceptionOnClientCallFailure($return);
73
            foreach ($return as $key => $value) {
74
                // once PHP<5.5 support is dropped, just use array_column
75
                $tokens[$key] = $value['cas'];
76
                $return[$key] = $value['value'];
77
            }
78
        } else {
79
            $return = $this->client->getMulti($keys, $tokens);
0 ignored issues
show
Bug introduced by
$tokens of type array is incompatible with the type integer expected by parameter $flags of Memcached::getMulti(). ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

79
            $return = $this->client->getMulti($keys, /** @scrutinizer ignore-type */ $tokens);
Loading history...
80
            $this->throwExceptionOnClientCallFailure($return);
81
        }
82
83
        $keys = array_map(array($this, 'decode'), array_keys($return));
84
        $return = array_combine($keys, $return);
85
86
        // HHVMs getMulti() returns null instead of empty array for no results,
87
        // so normalize that
88
        $tokens = $tokens ?: array();
89
        $tokens = array_combine($keys, $tokens);
90
91
        return $return ?: array();
92
    }
93
94
    /**
95
     * {@inheritdoc}
96
     */
97
    public function set($key, $value, $expire = 0)
98
    {
99
        if ($this->deleteIfExpired($key, $expire)) {
100
            return true;
101
        }
102
103
        $key = $this->encode($key);
104
105
        return $this->client->set($key, $value, $expire);
106
    }
107
108
    /**
109
     * {@inheritdoc}
110
     */
111
    public function setMulti(array $items, $expire = 0)
112
    {
113
        if (empty($items)) {
114
            return array();
115
        }
116
117
        $keys = array_keys($items);
118
        if ($this->deleteIfExpired($keys, $expire)) {
119
            return array_fill_keys($keys, true);
120
        }
121
122
        if (defined('HHVM_VERSION')) {
123
            $nums = array_filter(array_keys($items), 'is_numeric');
124
            if (!empty($nums)) {
125
                return $this->setMultiNumericItemsForHHVM($items, $nums, $expire);
126
            }
127
        }
128
129
        $keys = array_map(array($this, 'encode'), array_keys($items));
130
        $items = array_combine($keys, $items);
131
        $success = $this->client->setMulti($items, $expire);
132
        $keys = array_map(array($this, 'decode'), array_keys($items));
133
134
        return array_fill_keys($keys, $success);
135
    }
136
137
    /**
138
     * {@inheritdoc}
139
     */
140
    public function delete($key)
141
    {
142
        $key = $this->encode($key);
143
144
        return $this->client->delete($key);
145
    }
146
147
    /**
148
     * {@inheritdoc}
149
     */
150
    public function deleteMulti(array $keys)
151
    {
152
        if (empty($keys)) {
153
            return array();
154
        }
155
156
        if (!method_exists($this->client, 'deleteMulti')) {
157
            /**
158
             * HHVM didn't always support deleteMulti, so I'll hack around it by
159
             * setting all items expired.
160
             * I could also delete() all items one by one, but that would
161
             * probably take more network requests (this version always takes 2).
162
             *
163
             * @see http://docs.hhvm.com/manual/en/memcached.deletemulti.php
164
             */
165
            $values = $this->getMulti($keys);
166
167
            $keys = array_map(array($this, 'encode'), array_keys($values));
168
            $this->client->setMulti(array_fill_keys($keys, ''), time() - 1);
169
170
            $return = array();
171
            foreach ($keys as $key) {
172
                $key = $this->decode($key);
173
                $return[$key] = array_key_exists($key, $values);
174
            }
175
176
            return $return;
177
        }
178
179
        $keys = array_map(array($this, 'encode'), $keys);
180
        $result = (array) $this->client->deleteMulti($keys);
181
        $keys = array_map(array($this, 'decode'), array_keys($result));
182
        $result = array_combine($keys, $result);
183
184
        /*
185
         * Contrary to docs (http://php.net/manual/en/memcached.deletemulti.php)
186
         * deleteMulti returns an array of [key => true] (for successfully
187
         * deleted values) and [key => error code] (for failures)
188
         * Pretty good because I want an array of true/false, so I'll just have
189
         * to replace the error codes by falses.
190
         */
191
        foreach ($result as $key => $status) {
192
            $result[$key] = true === $status;
193
        }
194
195
        return $result;
196
    }
197
198
    /**
199
     * {@inheritdoc}
200
     */
201
    public function add($key, $value, $expire = 0)
202
    {
203
        $key = $this->encode($key);
204
205
        $success = $this->client->add($key, $value, $expire);
206
        if ($success) {
207
            $this->deleteIfExpired($key, $expire);
208
        }
209
210
        return $success;
211
    }
212
213
    /**
214
     * {@inheritdoc}
215
     */
216
    public function replace($key, $value, $expire = 0)
217
    {
218
        $key = $this->encode($key);
219
220
        $success = $this->client->replace($key, $value, $expire);
221
        if ($success) {
222
            $this->deleteIfExpired($key, $expire);
223
        }
224
225
        return $success;
226
    }
227
228
    /**
229
     * {@inheritdoc}
230
     */
231
    public function cas($token, $key, $value, $expire = 0)
232
    {
233
        if (!is_float($token) && !is_int($token)) {
234
            return false;
235
        }
236
237
        $key = $this->encode($key);
238
239
        $success = $this->client->cas($token, $key, $value, $expire);
240
        if ($success) {
241
            $this->deleteIfExpired($key, $expire);
242
        }
243
244
        return $success;
245
    }
246
247
    /**
248
     * {@inheritdoc}
249
     */
250
    public function increment($key, $offset = 1, $initial = 0, $expire = 0)
251
    {
252
        if ($offset <= 0 || $initial < 0) {
253
            return false;
254
        }
255
256
        /*
257
         * Not doing \Memcached::increment because that one:
258
         * * needs \Memcached::OPT_BINARY_PROTOCOL == true
259
         * * is prone to errors after a flush ("merges" with pruned data) in at
260
         *   least some particular versions of Memcached
261
         */
262
        return $this->doIncrement($key, $offset, $initial, $expire);
263
    }
264
265
    /**
266
     * {@inheritdoc}
267
     */
268
    public function decrement($key, $offset = 1, $initial = 0, $expire = 0)
269
    {
270
        if ($offset <= 0 || $initial < 0) {
271
            return false;
272
        }
273
274
        /*
275
         * Not doing \Memcached::decrement for the reasons described in:
276
         * @see increment()
277
         */
278
        return $this->doIncrement($key, -$offset, $initial, $expire);
279
    }
280
281
    /**
282
     * {@inheritdoc}
283
     */
284
    public function touch($key, $expire)
285
    {
286
        if ($this->deleteIfExpired($key, $expire)) {
287
            return true;
288
        }
289
290
        /**
291
         * HHVM doesn't support touch.
292
         *
293
         * @see http://docs.hhvm.com/manual/en/memcached.touch.php
294
         *
295
         * PHP does, but only with \Memcached::OPT_BINARY_PROTOCOL == true,
296
         * and even then, it appears to be buggy on particular versions of
297
         * Memcached.
298
         *
299
         * I'll just work around it!
300
         */
301
        $value = $this->get($key, $token);
302
303
        return $this->cas($token, $key, $value, $expire);
304
    }
305
306
    /**
307
     * {@inheritdoc}
308
     */
309
    public function flush()
310
    {
311
        return $this->client->flush();
312
    }
313
314
    /**
315
     * {@inheritdoc}
316
     */
317
    public function getCollection($name)
318
    {
319
        return new Collection($this, $name);
320
    }
321
322
    /**
323
     * Shared between increment/decrement: both have mostly the same logic
324
     * (decrement just increments a negative value), but need their validation
325
     * split up (increment won't accept negative values).
326
     *
327
     * @param string $key
328
     * @param int    $offset
329
     * @param int    $initial
330
     * @param int    $expire
331
     *
332
     * @return int|bool
333
     */
334
    protected function doIncrement($key, $offset, $initial, $expire)
335
    {
336
        $value = $this->get($key, $token);
337
        if (false === $value) {
338
            $success = $this->add($key, $initial, $expire);
339
340
            return $success ? $initial : false;
341
        }
342
343
        if (!is_numeric($value) || $value < 0) {
344
            return false;
345
        }
346
347
        $value += $offset;
348
        // value can never be lower than 0
349
        $value = max(0, $value);
350
        $key = $this->encode($key);
351
        $success = $this->client->cas($token, $key, $value, $expire);
352
353
        return $success ? $value : false;
354
    }
355
356
    /**
357
     * Encode a key for use on the wire inside the memcached protocol.
358
     *
359
     * We encode spaces and line breaks to avoid protocol errors. We encode
360
     * the other control characters for compatibility with libmemcached
361
     * verify_key. We leave other punctuation alone, to maximise backwards
362
     * compatibility.
363
     *
364
     * @see https://github.com/wikimedia/mediawiki/commit/be76d869#diff-75b7c03970b5e43de95ff95f5faa6ef1R100
365
     * @see https://github.com/wikimedia/mediawiki/blob/master/includes/libs/objectcache/MemcachedBagOStuff.php#L116
366
     *
367
     * @param string $key
368
     *
369
     * @return string
370
     *
371
     * @throws InvalidKey
372
     */
373
    protected function encode($key)
374
    {
375
        $regex = '/[^\x21\x22\x24\x26-\x39\x3b-\x7e]+/';
376
        $key = preg_replace_callback($regex, function ($match) {
377
            return rawurlencode($match[0]);
378
        }, $key);
379
380
        if (strlen($key) > 255) {
381
            throw new InvalidKey("Invalid key: $key. Encoded Memcached keys can not exceed 255 chars.");
382
        }
383
384
        return $key;
385
    }
386
387
    /**
388
     * Decode a key encoded with encode().
389
     *
390
     * @param string $key
391
     *
392
     * @return string
393
     */
394
    protected function decode($key)
395
    {
396
        // matches %20, %7F, ... but not %21, %22, ...
397
        // (=the encoded versions for those encoded in encode)
398
        $regex = '/%(?!2[1246789]|3[0-9]|3[B-F]|[4-6][0-9A-F]|5[0-9A-E])[0-9A-Z]{2}/i';
399
400
        return preg_replace_callback($regex, function ($match) {
401
            return rawurldecode($match[0]);
402
        }, $key);
403
    }
404
405
    /**
406
     * Memcached seems to not timely purge items the way it should when
407
     * storing it with an expired timestamp, so we'll detect that and
408
     * delete it (instead of performing the already expired operation).
409
     *
410
     * @param string|string[] $key
411
     * @param int             $expire
412
     *
413
     * @return bool True if expired
414
     */
415
    protected function deleteIfExpired($key, $expire)
416
    {
417
        if ($expire < 0 || ($expire > 2592000 && $expire < time())) {
418
            $this->deleteMulti((array) $key);
419
420
            return true;
421
        }
422
423
        return false;
424
    }
425
426
    /**
427
     * Numerical strings turn into integers when used as array keys, and
428
     * HHVM (used to) reject(s) such cache keys.
429
     *
430
     * @see https://github.com/facebook/hhvm/pull/7654
431
     *
432
     * @param int $expire
433
     *
434
     * @return array
435
     */
436
    protected function setMultiNumericItemsForHHVM(array $items, array $nums, $expire = 0)
437
    {
438
        $success = array();
439
        $nums = array_intersect_key($items, array_fill_keys($nums, null));
440
        foreach ($nums as $k => $v) {
441
            $success[$k] = $this->set((string) $k, $v, $expire);
442
        }
443
444
        $remaining = array_diff_key($items, $nums);
445
        if ($remaining) {
0 ignored issues
show
Bug Best Practice introduced by
The expression $remaining of type array is implicitly converted to a boolean; are you sure this is intended? If so, consider using ! empty($expr) instead to make it clear that you intend to check for an array without elements.

This check marks implicit conversions of arrays to boolean values in a comparison. While in PHP an empty array is considered to be equal (but not identical) to false, this is not always apparent.

Consider making the comparison explicit by using empty(..) or ! empty(...) instead.

Loading history...
446
            $success += $this->setMulti($remaining, $expire);
447
        }
448
449
        return $success;
450
    }
451
452
    /**
453
     * Will throw an exception if the returned result from a Memcached call
454
     * indicates a failure in the operation.
455
     * The exception will contain debug information about the failure.
456
     *
457
     * @param mixed $result
458
     *
459
     * @throws OperationFailed
460
     */
461
    protected function throwExceptionOnClientCallFailure($result)
462
    {
463
        if (false !== $result) {
464
            return;
465
        }
466
467
        throw new OperationFailed($this->client->getResultMessage(), $this->client->getResultCode());
468
    }
469
}
470