1<?php
2
3/*
4 * This file is part of the Symfony package.
5 *
6 * (c) Fabien Potencier <fabien@symfony.com>
7 *
8 * For the full copyright and license information, please view the LICENSE
9 * file that was distributed with this source code.
10 */
11
12namespace Symfony\Component\Cache\Traits;
13
14use Symfony\Component\Cache\Exception\CacheException;
15use Symfony\Component\Cache\Exception\InvalidArgumentException;
16
17/**
18 * @author Rob Frawley 2nd <rmf@src.run>
19 * @author Nicolas Grekas <p@tchwork.com>
20 *
21 * @internal
22 */
23trait MemcachedTrait
24{
25    private static $defaultClientOptions = [
26        'persistent_id' => null,
27        'username' => null,
28        'password' => null,
29        \Memcached::OPT_SERIALIZER => \Memcached::SERIALIZER_PHP,
30    ];
31
32    private $client;
33    private $lazyClient;
34
35    public static function isSupported()
36    {
37        return \extension_loaded('memcached') && version_compare(phpversion('memcached'), '2.2.0', '>=');
38    }
39
40    private function init(\Memcached $client, $namespace, $defaultLifetime)
41    {
42        if (!static::isSupported()) {
43            throw new CacheException('Memcached >= 2.2.0 is required.');
44        }
45        if ('Memcached' === \get_class($client)) {
46            $opt = $client->getOption(\Memcached::OPT_SERIALIZER);
47            if (\Memcached::SERIALIZER_PHP !== $opt && \Memcached::SERIALIZER_IGBINARY !== $opt) {
48                throw new CacheException('MemcachedAdapter: "serializer" option must be "php" or "igbinary".');
49            }
50            $this->maxIdLength -= \strlen($client->getOption(\Memcached::OPT_PREFIX_KEY));
51            $this->client = $client;
52        } else {
53            $this->lazyClient = $client;
54        }
55
56        parent::__construct($namespace, $defaultLifetime);
57        $this->enableVersioning();
58    }
59
60    /**
61     * Creates a Memcached instance.
62     *
63     * By default, the binary protocol, no block, and libketama compatible options are enabled.
64     *
65     * Examples for servers:
66     * - 'memcached://user:pass@localhost?weight=33'
67     * - [['localhost', 11211, 33]]
68     *
69     * @param array[]|string|string[] $servers An array of servers, a DSN, or an array of DSNs
70     * @param array                   $options An array of options
71     *
72     * @return \Memcached
73     *
74     * @throws \ErrorException When invalid options or servers are provided
75     */
76    public static function createConnection($servers, array $options = [])
77    {
78        if (\is_string($servers)) {
79            $servers = [$servers];
80        } elseif (!\is_array($servers)) {
81            throw new InvalidArgumentException(sprintf('MemcachedAdapter::createClient() expects array or string as first argument, "%s" given.', \gettype($servers)));
82        }
83        if (!static::isSupported()) {
84            throw new CacheException('Memcached >= 2.2.0 is required.');
85        }
86        set_error_handler(function ($type, $msg, $file, $line) { throw new \ErrorException($msg, 0, $type, $file, $line); });
87        try {
88            $options += static::$defaultClientOptions;
89            $client = new \Memcached($options['persistent_id']);
90            $username = $options['username'];
91            $password = $options['password'];
92
93            // parse any DSN in $servers
94            foreach ($servers as $i => $dsn) {
95                if (\is_array($dsn)) {
96                    continue;
97                }
98                if (0 !== strpos($dsn, 'memcached://')) {
99                    throw new InvalidArgumentException(sprintf('Invalid Memcached DSN: "%s" does not start with "memcached://".', $dsn));
100                }
101                $params = preg_replace_callback('#^memcached://(?:([^@]*+)@)?#', function ($m) use (&$username, &$password) {
102                    if (!empty($m[1])) {
103                        list($username, $password) = explode(':', $m[1], 2) + [1 => null];
104                    }
105
106                    return 'file://';
107                }, $dsn);
108                if (false === $params = parse_url($params)) {
109                    throw new InvalidArgumentException(sprintf('Invalid Memcached DSN: "%s".', $dsn));
110                }
111                if (!isset($params['host']) && !isset($params['path'])) {
112                    throw new InvalidArgumentException(sprintf('Invalid Memcached DSN: "%s".', $dsn));
113                }
114                if (isset($params['path']) && preg_match('#/(\d+)$#', $params['path'], $m)) {
115                    $params['weight'] = $m[1];
116                    $params['path'] = substr($params['path'], 0, -\strlen($m[0]));
117                }
118                $params += [
119                    'host' => isset($params['host']) ? $params['host'] : $params['path'],
120                    'port' => isset($params['host']) ? 11211 : null,
121                    'weight' => 0,
122                ];
123                if (isset($params['query'])) {
124                    parse_str($params['query'], $query);
125                    $params += $query;
126                    $options = $query + $options;
127                }
128
129                $servers[$i] = [$params['host'], $params['port'], $params['weight']];
130            }
131
132            // set client's options
133            unset($options['persistent_id'], $options['username'], $options['password'], $options['weight'], $options['lazy']);
134            $options = array_change_key_case($options, \CASE_UPPER);
135            $client->setOption(\Memcached::OPT_BINARY_PROTOCOL, true);
136            $client->setOption(\Memcached::OPT_NO_BLOCK, true);
137            $client->setOption(\Memcached::OPT_TCP_NODELAY, true);
138            if (!\array_key_exists('LIBKETAMA_COMPATIBLE', $options) && !\array_key_exists(\Memcached::OPT_LIBKETAMA_COMPATIBLE, $options)) {
139                $client->setOption(\Memcached::OPT_LIBKETAMA_COMPATIBLE, true);
140            }
141            foreach ($options as $name => $value) {
142                if (\is_int($name)) {
143                    continue;
144                }
145                if ('HASH' === $name || 'SERIALIZER' === $name || 'DISTRIBUTION' === $name) {
146                    $value = \constant('Memcached::'.$name.'_'.strtoupper($value));
147                }
148                $opt = \constant('Memcached::OPT_'.$name);
149
150                unset($options[$name]);
151                $options[$opt] = $value;
152            }
153            $client->setOptions($options);
154
155            // set client's servers, taking care of persistent connections
156            if (!$client->isPristine()) {
157                $oldServers = [];
158                foreach ($client->getServerList() as $server) {
159                    $oldServers[] = [$server['host'], $server['port']];
160                }
161
162                $newServers = [];
163                foreach ($servers as $server) {
164                    if (1 < \count($server)) {
165                        $server = array_values($server);
166                        unset($server[2]);
167                        $server[1] = (int) $server[1];
168                    }
169                    $newServers[] = $server;
170                }
171
172                if ($oldServers !== $newServers) {
173                    $client->resetServerList();
174                    $client->addServers($servers);
175                }
176            } else {
177                $client->addServers($servers);
178            }
179
180            if (null !== $username || null !== $password) {
181                if (!method_exists($client, 'setSaslAuthData')) {
182                    trigger_error('Missing SASL support: the memcached extension must be compiled with --enable-memcached-sasl.');
183                }
184                $client->setSaslAuthData($username, $password);
185            }
186
187            return $client;
188        } finally {
189            restore_error_handler();
190        }
191    }
192
193    /**
194     * {@inheritdoc}
195     */
196    protected function doSave(array $values, $lifetime)
197    {
198        if ($lifetime && $lifetime > 30 * 86400) {
199            $lifetime += time();
200        }
201
202        $encodedValues = [];
203        foreach ($values as $key => $value) {
204            $encodedValues[rawurlencode($key)] = $value;
205        }
206
207        return $this->checkResultCode($this->getClient()->setMulti($encodedValues, $lifetime));
208    }
209
210    /**
211     * {@inheritdoc}
212     */
213    protected function doFetch(array $ids)
214    {
215        $unserializeCallbackHandler = ini_set('unserialize_callback_func', __CLASS__.'::handleUnserializeCallback');
216        try {
217            $encodedIds = array_map('rawurlencode', $ids);
218
219            $encodedResult = $this->checkResultCode($this->getClient()->getMulti($encodedIds));
220
221            $result = [];
222            foreach ($encodedResult as $key => $value) {
223                $result[rawurldecode($key)] = $value;
224            }
225
226            return $result;
227        } catch (\Error $e) {
228            throw new \ErrorException($e->getMessage(), $e->getCode(), \E_ERROR, $e->getFile(), $e->getLine());
229        } finally {
230            ini_set('unserialize_callback_func', $unserializeCallbackHandler);
231        }
232    }
233
234    /**
235     * {@inheritdoc}
236     */
237    protected function doHave($id)
238    {
239        return false !== $this->getClient()->get(rawurlencode($id)) || $this->checkResultCode(\Memcached::RES_SUCCESS === $this->client->getResultCode());
240    }
241
242    /**
243     * {@inheritdoc}
244     */
245    protected function doDelete(array $ids)
246    {
247        $ok = true;
248        $encodedIds = array_map('rawurlencode', $ids);
249        foreach ($this->checkResultCode($this->getClient()->deleteMulti($encodedIds)) as $result) {
250            if (\Memcached::RES_SUCCESS !== $result && \Memcached::RES_NOTFOUND !== $result) {
251                $ok = false;
252                break;
253            }
254        }
255
256        return $ok;
257    }
258
259    /**
260     * {@inheritdoc}
261     */
262    protected function doClear($namespace)
263    {
264        return '' === $namespace && $this->getClient()->flush();
265    }
266
267    private function checkResultCode($result)
268    {
269        $code = $this->client->getResultCode();
270
271        if (\Memcached::RES_SUCCESS === $code || \Memcached::RES_NOTFOUND === $code) {
272            return $result;
273        }
274
275        throw new CacheException('MemcachedAdapter client error: '.strtolower($this->client->getResultMessage()));
276    }
277
278    /**
279     * @return \Memcached
280     */
281    private function getClient()
282    {
283        if ($this->client) {
284            return $this->client;
285        }
286
287        $opt = $this->lazyClient->getOption(\Memcached::OPT_SERIALIZER);
288        if (\Memcached::SERIALIZER_PHP !== $opt && \Memcached::SERIALIZER_IGBINARY !== $opt) {
289            throw new CacheException('MemcachedAdapter: "serializer" option must be "php" or "igbinary".');
290        }
291        if ('' !== $prefix = (string) $this->lazyClient->getOption(\Memcached::OPT_PREFIX_KEY)) {
292            throw new CacheException(sprintf('MemcachedAdapter: "prefix_key" option must be empty when using proxified connections, "%s" given.', $prefix));
293        }
294
295        return $this->client = $this->lazyClient;
296    }
297}
298