1<?php
2/**
3 * Zend Framework (http://framework.zend.com/)
4 *
5 * @link      http://github.com/zendframework/zf2 for the canonical source repository
6 * @copyright Copyright (c) 2005-2015 Zend Technologies USA Inc. (http://www.zend.com)
7 * @license   http://framework.zend.com/license/new-bsd New BSD License
8 */
9
10namespace Zend\Cache\Storage\Adapter;
11
12use ArrayAccess;
13use Memcache as MemcacheResource;
14use Traversable;
15use Zend\Cache\Exception;
16use Zend\Stdlib\ArrayUtils;
17
18/**
19 * This is a resource manager for memcache
20 */
21class MemcacheResourceManager
22{
23    /**
24     * Registered resources
25     *
26     * @var array
27     */
28    protected $resources = array();
29
30    /**
31     * Default server values per resource
32     *
33     * @var array
34     */
35    protected $serverDefaults = array();
36
37    /**
38     * Failure callback per resource
39     *
40     * @var callable[]
41     */
42    protected $failureCallbacks = array();
43
44    /**
45     * Check if a resource exists
46     *
47     * @param string $id
48     * @return bool
49     */
50    public function hasResource($id)
51    {
52        return isset($this->resources[$id]);
53    }
54
55    /**
56     * Gets a memcache resource
57     *
58     * @param string $id
59     * @return MemcacheResource
60     * @throws Exception\RuntimeException
61     */
62    public function getResource($id)
63    {
64        if (!$this->hasResource($id)) {
65            throw new Exception\RuntimeException("No resource with id '{$id}'");
66        }
67
68        $resource = $this->resources[$id];
69        if ($resource instanceof MemcacheResource) {
70            return $resource;
71        }
72
73        $memc = new MemcacheResource();
74        $this->setResourceAutoCompressThreshold(
75            $memc,
76            $resource['auto_compress_threshold'],
77            $resource['auto_compress_min_savings']
78        );
79        foreach ($resource['servers'] as $server) {
80            $this->addServerToResource(
81                $memc,
82                $server,
83                $this->serverDefaults[$id],
84                $this->failureCallbacks[$id]
85            );
86        }
87
88        // buffer and return
89        $this->resources[$id] = $memc;
90        return $memc;
91    }
92
93    /**
94     * Set a resource
95     *
96     * @param string $id
97     * @param array|Traversable|MemcacheResource $resource
98     * @param callable $failureCallback
99     * @param array|Traversable $serverDefaults
100     * @return MemcacheResourceManager
101     */
102    public function setResource($id, $resource, $failureCallback = null, $serverDefaults = array())
103    {
104        $id = (string) $id;
105
106        if ($serverDefaults instanceof Traversable) {
107            $serverDefaults = ArrayUtils::iteratorToArray($serverDefaults);
108        } elseif (!is_array($serverDefaults)) {
109            throw new Exception\InvalidArgumentException(
110                'ServerDefaults must be an instance Traversable or an array'
111            );
112        }
113
114        if (!($resource instanceof MemcacheResource)) {
115            if ($resource instanceof Traversable) {
116                $resource = ArrayUtils::iteratorToArray($resource);
117            } elseif (!is_array($resource)) {
118                throw new Exception\InvalidArgumentException(
119                    'Resource must be an instance of Memcache or an array or Traversable'
120                );
121            }
122
123            if (isset($resource['server_defaults'])) {
124                $serverDefaults = array_merge($serverDefaults, $resource['server_defaults']);
125                unset($resource['server_defaults']);
126            }
127
128            $resourceOptions = array(
129                'servers' => array(),
130                'auto_compress_threshold'   => null,
131                'auto_compress_min_savings' => null,
132            );
133            $resource = array_merge($resourceOptions, $resource);
134
135            // normalize and validate params
136            $this->normalizeAutoCompressThreshold(
137                $resource['auto_compress_threshold'],
138                $resource['auto_compress_min_savings']
139            );
140            $this->normalizeServers($resource['servers']);
141        }
142
143        $this->normalizeServerDefaults($serverDefaults);
144
145        $this->resources[$id] = $resource;
146        $this->failureCallbacks[$id] = $failureCallback;
147        $this->serverDefaults[$id] = $serverDefaults;
148
149        return $this;
150    }
151
152    /**
153     * Remove a resource
154     *
155     * @param string $id
156     * @return MemcacheResourceManager
157     */
158    public function removeResource($id)
159    {
160        unset($this->resources[$id]);
161        return $this;
162    }
163
164    /**
165     * Normalize compress threshold options
166     *
167     * @param int|string|array|ArrayAccess $threshold
168     * @param float|string                 $minSavings
169     */
170    protected function normalizeAutoCompressThreshold(& $threshold, & $minSavings)
171    {
172        if (is_array($threshold) || ($threshold instanceof ArrayAccess)) {
173            $tmpThreshold = (isset($threshold['threshold'])) ? $threshold['threshold'] : null;
174            $minSavings = (isset($threshold['min_savings'])) ? $threshold['min_savings'] : $minSavings;
175            $threshold = $tmpThreshold;
176        }
177        if (isset($threshold)) {
178            $threshold = (int) $threshold;
179        }
180        if (isset($minSavings)) {
181            $minSavings = (float) $minSavings;
182        }
183    }
184
185    /**
186     * Set compress threshold on a Memcache resource
187     *
188     * @param MemcacheResource $resource
189     * @param int $threshold
190     * @param float $minSavings
191     */
192    protected function setResourceAutoCompressThreshold(MemcacheResource $resource, $threshold, $minSavings)
193    {
194        if (!isset($threshold)) {
195            return;
196        }
197        if (isset($minSavings)) {
198            $resource->setCompressThreshold($threshold, $minSavings);
199        } else {
200            $resource->setCompressThreshold($threshold);
201        }
202    }
203
204    /**
205     * Get compress threshold
206     *
207     * @param  string $id
208     * @return int|null
209     * @throws \Zend\Cache\Exception\RuntimeException
210     */
211    public function getAutoCompressThreshold($id)
212    {
213        if (!$this->hasResource($id)) {
214            throw new Exception\RuntimeException("No resource with id '{$id}'");
215        }
216
217        $resource = & $this->resources[$id];
218        if ($resource instanceof MemcacheResource) {
219            // Cannot get options from Memcache resource once created
220            throw new Exception\RuntimeException("Cannot get compress threshold once resource is created");
221        }
222        return $resource['auto_compress_threshold'];
223    }
224
225    /**
226     * Set compress threshold
227     *
228     * @param string                            $id
229     * @param int|string|array|ArrayAccess|null $threshold
230     * @param float|string|bool                 $minSavings
231     * @return MemcacheResourceManager
232     */
233    public function setAutoCompressThreshold($id, $threshold, $minSavings = false)
234    {
235        if (!$this->hasResource($id)) {
236            return $this->setResource($id, array(
237                'auto_compress_threshold' => $threshold,
238            ));
239        }
240
241        $this->normalizeAutoCompressThreshold($threshold, $minSavings);
242
243        $resource = & $this->resources[$id];
244        if ($resource instanceof MemcacheResource) {
245            $this->setResourceAutoCompressThreshold($resource, $threshold, $minSavings);
246        } else {
247            $resource['auto_compress_threshold'] = $threshold;
248            if ($minSavings !== false) {
249                $resource['auto_compress_min_savings'] = $minSavings;
250            }
251        }
252        return $this;
253    }
254
255    /**
256     * Get compress min savings
257     *
258     * @param  string $id
259     * @return float|null
260     * @throws Exception\RuntimeException
261     */
262    public function getAutoCompressMinSavings($id)
263    {
264        if (!$this->hasResource($id)) {
265            throw new Exception\RuntimeException("No resource with id '{$id}'");
266        }
267
268        $resource = & $this->resources[$id];
269        if ($resource instanceof MemcacheResource) {
270            // Cannot get options from Memcache resource once created
271            throw new Exception\RuntimeException("Cannot get compress min savings once resource is created");
272        }
273        return $resource['auto_compress_min_savings'];
274    }
275
276    /**
277     * Set compress min savings
278     *
279     * @param  string            $id
280     * @param  float|string|null $minSavings
281     * @return MemcacheResourceManager
282     * @throws \Zend\Cache\Exception\RuntimeException
283     */
284    public function setAutoCompressMinSavings($id, $minSavings)
285    {
286        if (!$this->hasResource($id)) {
287            return $this->setResource($id, array(
288                'auto_compress_min_savings' => $minSavings,
289            ));
290        }
291
292        $minSavings = (float) $minSavings;
293
294        $resource = & $this->resources[$id];
295        if ($resource instanceof MemcacheResource) {
296            throw new Exception\RuntimeException(
297                "Cannot set compress min savings without a threshold value once a resource is created"
298            );
299        } else {
300            $resource['auto_compress_min_savings'] = $minSavings;
301        }
302        return $this;
303    }
304
305    /**
306     * Set default server values
307     * array(
308     *   'persistent' => <persistent>, 'weight' => <weight>,
309     *   'timeout' => <timeout>, 'retry_interval' => <retryInterval>,
310     * )
311     * @param string $id
312     * @param array  $serverDefaults
313     * @return MemcacheResourceManager
314     */
315    public function setServerDefaults($id, array $serverDefaults)
316    {
317        if (!$this->hasResource($id)) {
318            return $this->setResource($id, array(
319                'server_defaults' => $serverDefaults
320            ));
321        }
322
323        $this->normalizeServerDefaults($serverDefaults);
324        $this->serverDefaults[$id] = $serverDefaults;
325
326        return $this;
327    }
328
329    /**
330     * Get default server values
331     *
332     * @param string $id
333     * @return array
334     * @throws Exception\RuntimeException
335     */
336    public function getServerDefaults($id)
337    {
338        if (!isset($this->serverDefaults[$id])) {
339            throw new Exception\RuntimeException("No resource with id '{$id}'");
340        }
341        return $this->serverDefaults[$id];
342    }
343
344    /**
345     * @param array $serverDefaults
346     * @throws Exception\InvalidArgumentException
347     */
348    protected function normalizeServerDefaults(& $serverDefaults)
349    {
350        if (!is_array($serverDefaults) && !($serverDefaults instanceof Traversable)) {
351            throw new Exception\InvalidArgumentException(
352                "Server defaults must be an array or an instance of Traversable"
353            );
354        }
355
356        // Defaults
357        $result = array(
358            'persistent' => true,
359            'weight' => 1,
360            'timeout' => 1, // seconds
361            'retry_interval' => 15, // seconds
362        );
363
364        foreach ($serverDefaults as $key => $value) {
365            switch ($key) {
366                case 'persistent':
367                    $value = (bool) $value;
368                    break;
369                case 'weight':
370                case 'timeout':
371                case 'retry_interval':
372                    $value = (int) $value;
373                    break;
374            }
375            $result[$key] = $value;
376        }
377
378        $serverDefaults = $result;
379    }
380
381    /**
382     * Set callback for server connection failures
383     *
384     * @param string $id
385     * @param callable|null $failureCallback
386     * @return MemcacheResourceManager
387     */
388    public function setFailureCallback($id, $failureCallback)
389    {
390        if (!$this->hasResource($id)) {
391            return $this->setResource($id, array(), $failureCallback);
392        }
393
394        $this->failureCallbacks[$id] = $failureCallback;
395        return $this;
396    }
397
398    /**
399     * Get callback for server connection failures
400     *
401     * @param string $id
402     * @return callable
403     * @throws Exception\RuntimeException
404     */
405    public function getFailureCallback($id)
406    {
407        if (!isset($this->failureCallbacks[$id])) {
408            throw new Exception\RuntimeException("No resource with id '{$id}'");
409        }
410        return $this->failureCallbacks[$id];
411    }
412
413    /**
414     * Get servers
415     *
416     * @param string $id
417     * @throws Exception\RuntimeException
418     * @return array array('host' => <host>, 'port' => <port>, 'weight' => <weight>)
419     */
420    public function getServers($id)
421    {
422        if (!$this->hasResource($id)) {
423            throw new Exception\RuntimeException("No resource with id '{$id}'");
424        }
425
426        $resource = & $this->resources[$id];
427        if ($resource instanceof MemcacheResource) {
428            throw new Exception\RuntimeException("Cannot get server list once resource is created");
429        }
430        return $resource['servers'];
431    }
432
433    /**
434     * Add servers
435     *
436     * @param string       $id
437     * @param string|array $servers
438     * @return MemcacheResourceManager
439     */
440    public function addServers($id, $servers)
441    {
442        if (!$this->hasResource($id)) {
443            return $this->setResource($id, array(
444                'servers' => $servers
445            ));
446        }
447
448        $this->normalizeServers($servers);
449
450        $resource = & $this->resources[$id];
451        if ($resource instanceof MemcacheResource) {
452            foreach ($servers as $server) {
453                $this->addServerToResource(
454                    $resource,
455                    $server,
456                    $this->serverDefaults[$id],
457                    $this->failureCallbacks[$id]
458                );
459            }
460        } else {
461            // don't add servers twice
462            $resource['servers'] = array_merge(
463                $resource['servers'],
464                array_udiff($servers, $resource['servers'], array($this, 'compareServers'))
465            );
466        }
467
468        return $this;
469    }
470
471    /**
472     * Add one server
473     *
474     * @param string       $id
475     * @param string|array $server
476     * @return MemcacheResourceManager
477     */
478    public function addServer($id, $server)
479    {
480        return $this->addServers($id, array($server));
481    }
482
483    /**
484     * @param MemcacheResource $resource
485     * @param array $server
486     * @param array $serverDefaults
487     * @param callable|null $failureCallback
488     */
489    protected function addServerToResource(
490        MemcacheResource $resource,
491        array $server,
492        array $serverDefaults,
493        $failureCallback
494    ) {
495        // Apply server defaults
496        $server = array_merge($serverDefaults, $server);
497
498        // Reorder parameters
499        $params = array(
500            $server['host'],
501            $server['port'],
502            $server['persistent'],
503            $server['weight'],
504            $server['timeout'],
505            $server['retry_interval'],
506            $server['status'],
507        );
508        if (isset($failureCallback)) {
509            $params[] = $failureCallback;
510        }
511        call_user_func_array(array($resource, 'addServer'), $params);
512    }
513
514    /**
515     * Normalize a list of servers into the following format:
516     * array(array('host' => <host>, 'port' => <port>, 'weight' => <weight>)[, ...])
517     *
518     * @param string|array $servers
519     */
520    protected function normalizeServers(& $servers)
521    {
522        if (is_string($servers)) {
523            // Convert string into a list of servers
524            $servers = explode(',', $servers);
525        }
526
527        $result = array();
528        foreach ($servers as $server) {
529            $this->normalizeServer($server);
530            $result[$server['host'] . ':' . $server['port']] = $server;
531        }
532
533        $servers = array_values($result);
534    }
535
536    /**
537     * Normalize one server into the following format:
538     * array(
539     *   'host' => <host>, 'port' => <port>, 'weight' => <weight>,
540     *   'status' => <status>, 'persistent' => <persistent>,
541     *   'timeout' => <timeout>, 'retry_interval' => <retryInterval>,
542     * )
543     *
544     * @param string|array $server
545     * @throws Exception\InvalidArgumentException
546     */
547    protected function normalizeServer(& $server)
548    {
549        // WARNING: The order of this array is important.
550        // Used for converting an ordered array to a keyed array.
551        // Append new options, do not insert or you will break BC.
552        $sTmp = array(
553            'host'           => null,
554            'port'           => 11211,
555            'weight'         => null,
556            'status'         => true,
557            'persistent'     => null,
558            'timeout'        => null,
559            'retry_interval' => null,
560        );
561
562        // convert a single server into an array
563        if ($server instanceof Traversable) {
564            $server = ArrayUtils::iteratorToArray($server);
565        }
566
567        if (is_array($server)) {
568            if (isset($server[0])) {
569                // Convert ordered array to keyed array
570                // array(<host>[, <port>[, <weight>[, <status>[, <persistent>[, <timeout>[, <retryInterval>]]]]]])
571                $server = array_combine(
572                    array_slice(array_keys($sTmp), 0, count($server)),
573                    $server
574                );
575            }
576            $sTmp = array_merge($sTmp, $server);
577        } elseif (is_string($server)) {
578            // parse server from URI host{:?port}{?weight}
579            $server = trim($server);
580            if (strpos($server, '://') === false) {
581                $server = 'tcp://' . $server;
582            }
583
584            $urlParts = parse_url($server);
585            if (!$urlParts) {
586                throw new Exception\InvalidArgumentException("Invalid server given");
587            }
588
589            $sTmp = array_merge($sTmp, array_intersect_key($urlParts, $sTmp));
590            if (isset($urlParts['query'])) {
591                $query = null;
592                parse_str($urlParts['query'], $query);
593                $sTmp = array_merge($sTmp, array_intersect_key($query, $sTmp));
594            }
595        }
596
597        if (!$sTmp['host']) {
598            throw new Exception\InvalidArgumentException('Missing required server host');
599        }
600
601        // Filter values
602        foreach ($sTmp as $key => $value) {
603            if (isset($value)) {
604                switch ($key) {
605                    case 'host':
606                        $value = (string) $value;
607                        break;
608                    case 'status':
609                    case 'persistent':
610                        $value = (bool) $value;
611                        break;
612                    case 'port':
613                    case 'weight':
614                    case 'timeout':
615                    case 'retry_interval':
616                        $value = (int) $value;
617                        break;
618                }
619            }
620            $sTmp[$key] = $value;
621        }
622        $sTmp = array_filter(
623            $sTmp,
624            function ($val) {
625                return isset($val);
626            }
627        );
628
629        $server = $sTmp;
630    }
631
632    /**
633     * Compare 2 normalized server arrays
634     * (Compares only the host and the port)
635     *
636     * @param array $serverA
637     * @param array $serverB
638     * @return int
639     */
640    protected function compareServers(array $serverA, array $serverB)
641    {
642        $keyA = $serverA['host'] . ':' . $serverA['port'];
643        $keyB = $serverB['host'] . ':' . $serverB['port'];
644        if ($keyA === $keyB) {
645            return 0;
646        }
647        return $keyA > $keyB ? 1 : -1;
648    }
649}
650