diff --git a/README.md b/README.md index 657ec63..b2b5f74 100644 --- a/README.md +++ b/README.md @@ -1,9 +1,20 @@ -# Redis Object Cache for WordPress +# Redis Cache for WordPress -> A persistent object cache backend powered by Redis. Supports [Predis](https://github.com/nrk/predis/), [PhpRedis (PECL)](https://github.com/phpredis/phpredis), replication, clustering and [WP-CLI](http://wp-cli.org/). +> A persistent cache backend powered by Redis. [![Build Status](https://travis-ci.com/LeoColomb/wp-redis.svg?branch=master)](https://travis-ci.com/LeoColomb/wp-redis) +## Features + +* Enable the two cache wrappers for WordPress + * Object cache + * Page cache +* Adds handy [WP-CLI](http://wp-cli.org/) commands +* Supports major PHP Redis drivers + * [Predis](https://github.com/nrk/predis/) + * [PhpRedis (PECL)](https://github.com/phpredis/phpredis) +* Supports replication and clustering + ## Installation @@ -13,7 +24,8 @@ "extra": { "dropin-paths": { "web/app/": [ - "package:leocolomb/wp-redis:dropins/object-cache.php" + "package:leocolomb/wp-redis:dropins/object-cache.php", + "package:leocolomb/wp-redis:dropins/page-cache.php" ] } } @@ -43,7 +55,6 @@ Constant name|Default value|Description `WP_REDIS_DATABASE`|`0`|Accepts a numeric value that is used to automatically select a logical database with the `SELECT` command. `WP_REDIS_PASSWORD`|_not set_|Accepts a value used to authenticate with a Redis server protected by password with the `AUTH` command. - ### Parameters Constant name|Default value|Description @@ -54,6 +65,21 @@ Constant name|Default value|Description `WP_REDIS_IGNORED_GROUPS`|`['counts', 'plugins']`|Set the cache groups that should not be cached in Redis. `WP_REDIS_IGBINARY`|_not set_|Set to `true` to enable the [igbinary](https://github.com/igbinary/igbinary) serializer. +### Page cache + +Constant name|Default value|Description +--|--|-- +`WP_CACHE`|`false`|Set to `true` to enable advanced page caching. If not set, the Redis page cache will not be used. +`WP_REDIS_TIMES`|`2`|Only cache a page after it is accessed this many times. +`WP_REDIS_SECONDS`|`120`|Only cache a page if it is accessed `$times` in this many seconds. Set to zero to ignore this and use cache immediately. +`WP_REDIS_MAXAGE`|`300`|Expire cache items aged this many seconds. Set to zero to disable cache. +`WP_REDIS_GROUP`|`'redis-cache'`|Name of object cache group used for page cache. +`WP_REDIS_UNIQUE`|`[]`|If you conditionally serve different content, put the variable values here using the `add_variant()` method. +`WP_REDIS_HEADERS`|`[]`|Add headers here as `name => value` or `name => [values]`. These will be sent with every response from the cache. +`WP_REDIS_UNCACHED_HEADERS`|`['transfer-encoding']`|These headers will never be cached. (Use lower case only!) +`WP_REDIS_CACHE_CONTROL`|`true`|Set to `false` to disable `Last-Modified` and `Cache-Control` headers. +`WP_REDIS_USE_STALE`|`true`|Is it ok to return stale cached response when updating the cache? +`WP_REDIS_NOSKIP_COOKIES`|`['wordpress_test_cookie']`|Names of cookies - if they exist and the cache would normally be bypassed, don't bypass it. ## Replication & Clustering diff --git a/composer.json b/composer.json index 8e5db2a..5d2527e 100644 --- a/composer.json +++ b/composer.json @@ -1,7 +1,7 @@ { "name": "leocolomb/wp-redis", - "description": "A persistent object cache backend for WordPress powered by Redis.", - "type": "wordpress-dropin-bundle", + "description": "A persistent cache backend for WordPress powered by Redis.", + "type": "wordpress-dropins", "homepage": "https://github.com/LeoColomb/wp-redis", "license": "GPL-3.0+", "authors": [ @@ -18,6 +18,8 @@ "wordpress-dropin", "wordpress-cache", "cache-object", + "cache-page", + "cache-advanced", "redis", "wordpress" ], diff --git a/dropins/advanced-cache.php b/dropins/advanced-cache.php new file mode 100644 index 0000000..f5c3e7b --- /dev/null +++ b/dropins/advanced-cache.php @@ -0,0 +1,210 @@ +cache_status_header($redis_cache::CACHE_STATUS_MISS); + + // Don't cache interactive scripts or API endpoints + if (in_array(basename($_SERVER['SCRIPT_FILENAME']), [ + 'wp-cron.php', + 'xmlrpc.php', + ])) { + $redis_cache->cache_status_header($redis_cache::CACHE_STATUS_BYPASS); + + return; + } + + // Don't cache javascript generators + if (strpos($_SERVER['SCRIPT_FILENAME'], 'wp-includes/js') !== false) { + $redis_cache->cache_status_header($redis_cache::CACHE_STATUS_BYPASS); + + return; + } + + // Only cache HEAD and GET requests + if (isset($_SERVER['REQUEST_METHOD']) && ! in_array($_SERVER['REQUEST_METHOD'], ['GET', 'HEAD'])) { + $redis_cache->cache_status_header($redis_cache::CACHE_STATUS_BYPASS); + + return; + } + + // Don't cache when cookies indicate a cache-exempt visitor + if (is_array($_COOKIE) && ! empty($_COOKIE)) { + foreach (array_keys($_COOKIE) as $cookie) { + if (in_array($cookie, $redis_cache->noskip_cookies)) { + continue; + } + + if (strpos($cookie, 'wp') === 0 || + strpos($cookie, 'wordpress') === 0 || + strpos($cookie, 'comment_author') === 0 + ) { + $redis_cache->cache_status_header($redis_cache::CACHE_STATUS_BYPASS); + + return; + } + } + } + + if (! defined('WP_CONTENT_DIR')) { + $redis_cache->cache_status_header($redis_cache::CACHE_STATUS_DOWN); + + return; + } + + if (! require_once WP_CONTENT_DIR . '/object-cache.php') { + $redis_cache->cache_status_header($redis_cache::CACHE_STATUS_DOWN); + + return; + } + + wp_cache_init(); + + if (! ($wp_object_cache instanceof WP_Redis_Object_Cache)) { + $redis_cache->cache_status_header($redis_cache::CACHE_STATUS_DOWN); + + return; + } + + // Cache is disabled + if ($redis_cache->max_age < 1) { + $redis_cache->cache_status_header($redis_cache::CACHE_STATUS_BYPASS); + + return; + } + + // Necessary to prevent clients using cached version after login cookies set + if (defined('WP_REDIS_VARY_COOKIE') && WP_REDIS_VARY_COOKIE) { + header('Vary: Cookie', false); + } + + if (function_exists('wp_cache_add_global_groups')) { + wp_cache_add_global_groups([$redis_cache->group]); + } + + $redis_cache->setup_request(); + $redis_cache->do_variants(); + $redis_cache->generate_keys(); + + $genlock = false; + $do_cache = false; + $serve_cache = false; + $cache = wp_cache_get($redis_cache->key, $redis_cache->group); + + if (isset($cache['version']) && $cache['version'] !== $redis_cache->url_version) { + // Refresh the cache if a newer version is available + $redis_cache->cache_status_header($redis_cache::CACHE_STATUS_EXPIRED); + $do_cache = true; + } else if ($redis_cache->seconds < 1 || $redis_cache->times < 2) { + if (is_array($cache) && time() < $cache['time'] + $cache['max_age']) { + $do_cache = false; + $serve_cache = true; + } else if (is_array($cache) && $redis_cache->use_stale) { + $do_cache = true; + $serve_cache = true; + } else { + $do_cache = true; + } + } else if (! is_array($cache) || time() >= $cache['time'] + $redis_cache->max_age - $redis_cache->seconds) { + // No cache item found, or ready to sample traffic again at the end of the cache life + + wp_cache_add($redis_cache->req_key, 0, $redis_cache->group); + $requests = wp_cache_incr($redis_cache->req_key, 1, $redis_cache->group); + + if ($requests >= $redis_cache->times) { + if (is_array($cache) && time() >= $cache['time'] + $cache['max_age']) { + $redis_cache->cache_status_header($redis_cache::CACHE_STATUS_EXPIRED); + } + + wp_cache_delete($redis_cache->req_key, $redis_cache->group); + $do_cache = true; + } else { + $redis_cache->cache_status_header($redis_cache::CACHE_STATUS_IGNORED); + $do_cache = false; + } + } + + // Obtain cache generation lock + if ($do_cache) { + $genlock = wp_cache_add("{$redis_cache->url_key}_genlock", 1, $redis_cache->group, 10); + } + + if ($serve_cache && + isset($cache['time'], $cache['max_age']) && + time() < $cache['time'] + $cache['max_age'] + ) { + // Respect ETags + $three04 = false; + + if (isset($_SERVER['HTTP_IF_NONE_MATCH'], $cache['headers']['ETag'][0]) && + $_SERVER['HTTP_IF_NONE_MATCH'] == $cache['headers']['ETag'][0] + ) { + $three04 = true; + } else if ($redis_cache->cache_control && isset($_SERVER['HTTP_IF_MODIFIED_SINCE'])) { + $client_time = strtotime($_SERVER['HTTP_IF_MODIFIED_SINCE']); + + if (isset($cache['headers']['Last-Modified'][0])) { + $cache_time = strtotime($cache['headers']['Last-Modified'][0]); + } else { + $cache_time = $cache['time']; + } + + if ($client_time >= $cache_time) { + $three04 = true; + } + } + + // Use the cache save time for `Last-Modified` so we can issue "304 Not Modified", + // but don't clobber a cached `Last-Modified` header. + if ($redis_cache->cache_control && ! isset($cache['headers']['Last-Modified'][0])) { + header('Last-Modified: ' . gmdate('D, d M Y H:i:s', $cache['time']) . ' GMT', true); + header('Cache-Control: max-age=' . ($cache['max_age'] - time() + $cache['time']) . ', must-revalidate', true); + } + + $redis_cache->do_headers($redis_cache->headers, $cache['headers']); + + if ($three04) { + $protocol = $_SERVER['SERVER_PROTOCOL']; + + if (! preg_match('/^HTTP\/[0-9]{1}.[0-9]{1}$/', $protocol)) { + $protocol = 'HTTP/1.0'; + } + + header("{$protocol} 304 Not Modified", true, 304); + $redis_cache->cache_status_header($redis_cache::CACHE_STATUS_HIT); + exit; + } + + if (! empty($cache['status_header'])) { + header($cache['status_header'], true); + } + + $redis_cache->cache_status_header($redis_cache::CACHE_STATUS_HIT); + + if ($do_cache && function_exists('fastcgi_finish_request')) { + echo $cache['output']; + fastcgi_finish_request(); + } else { + echo $cache['output']; + exit; + } + } + + if (! $do_cache || ! $genlock) { + return; + } + + $wp_filter['status_header'][10]['redis_cache'] = [ + 'function' => [&$redis_cache, 'status_header'], + 'accepted_args' => 2 + ]; + + ob_start([&$redis_cache, 'output_callback']); +endif; diff --git a/src/class-wp-redis-object-cache-cli-commands.php b/src/class-wp-redis-cli-commands.php similarity index 93% rename from src/class-wp-redis-object-cache-cli-commands.php rename to src/class-wp-redis-cli-commands.php index 9728c5e..0c28a5f 100644 --- a/src/class-wp-redis-object-cache-cli-commands.php +++ b/src/class-wp-redis-cli-commands.php @@ -1,6 +1,6 @@ 'tcp', 'host' => '127.0.0.1', - 'port' => 6379 + 'port' => 6379, + 'client' => 'pecl' ]; - foreach (['scheme', 'host', 'port', 'path', 'password', 'database'] as $setting) { + array_map(function ($setting) { $constant = sprintf('WP_REDIS_%s', strtoupper($setting)); if (defined($constant)) { $parameters[$setting] = constant($constant); } - } + }, [ + 'scheme', + 'host', + 'port', + 'path', + 'password', + 'database', + 'client' + ]); if (defined('WP_REDIS_GLOBAL_GROUPS') && is_array(WP_REDIS_GLOBAL_GROUPS)) { $this->global_groups = WP_REDIS_GLOBAL_GROUPS; @@ -152,16 +161,8 @@ public function __construct($fail_gracefully = true) $this->ignored_groups = WP_REDIS_IGNORED_GROUPS; } - $client = defined('WP_REDIS_CLIENT') ? WP_REDIS_CLIENT : null; - - if (class_exists('Redis') && strcasecmp('predis', $client) !== 0) { - $client = 'pecl'; - } else { - $client = 'predis'; - } - try { - if (strcasecmp('pecl', $client) === 0) { + if (class_exists('Redis') && strcasecmp('pecl', $parameters['client']) === 0) { $this->redis_client = sprintf('PhpRedis (v%s)', phpversion('redis')); if (defined('WP_REDIS_SHARDS')) { @@ -187,12 +188,12 @@ public function __construct($fail_gracefully = true) } } - if (strcasecmp('predis', $client) === 0) { + if (strcasecmp('predis', $parameters['client']) === 0) { $this->redis_client = 'Predis'; // Require PHP 5.6 or greater if (version_compare(PHP_VERSION, '5.6.0', '<')) { - throw new Exception; + throw new Exception(); } // Load bundled Predis library @@ -205,13 +206,13 @@ public function __construct($fail_gracefully = true) if (defined('WP_REDIS_SHARDS')) { $parameters = WP_REDIS_SHARDS; - } elseif (defined('WP_REDIS_SENTINEL')) { - $parameters = WP_REDIS_SERVERS; - $options['replication'] = 'sentinel'; - $options['service'] = WP_REDIS_SENTINEL; } elseif (defined('WP_REDIS_SERVERS')) { $parameters = WP_REDIS_SERVERS; $options['replication'] = true; + if (defined('WP_REDIS_SENTINEL')) { + $options['replication'] = 'sentinel'; + $options['service'] = WP_REDIS_SENTINEL; + } } elseif (defined('WP_REDIS_CLUSTER')) { $parameters = WP_REDIS_CLUSTER; $options['cluster'] = 'redis'; diff --git a/src/class-wp-redis-page-cache.php b/src/class-wp-redis-page-cache.php new file mode 100644 index 0000000..f653a32 --- /dev/null +++ b/src/class-wp-redis-page-cache.php @@ -0,0 +1,359 @@ + value` or `name => [values]`. + * These will be sent with every response from the cache. + * + * @var array + */ + protected $headers = []; + + /** + * These headers will never be cached. (Use lower case only!) + * + * @var array + */ + protected $uncached_headers = [ + 'transfer-encoding' + ]; + + /** + * Set to `false` to disable `Last-Modified` and `Cache-Control` headers. + * + * @var boolean + */ + protected $cache_control = true; + + /** + * Set to `true` to disable the output buffer. + * + * @var boolean + */ + protected $cancel = false; + + /** + * Is it ok to return stale cached response when updating the cache? + * + * @var boolean + */ + protected $use_stale = true; + + /** + * Names of cookies - if they exist and the cache would normally be bypassed, don't bypass it. + * + * @var array + */ + protected $noskip_cookies = [ + 'wordpress_test_cookie' + ]; + + protected $keys = []; + protected $url_key; + protected $url_version; + protected $key; + protected $req_key; + protected $status_header; + protected $status_code; + + public function __construct() + { + array_map(function ($setting) { + $constant = sprintf('WP_REDIS_%s', strtoupper($setting)); + if (defined($constant)) { + $this->$setting = constant($constant); + } + }, [ + 'times', + 'seconds', + 'max_age', + 'group', + 'unique', + 'headers', + 'uncached_headers', + 'cache_control', + 'use_stale', + 'noskip_cookies' + ]); + } + + public function __get($name) + { + return $this->$name; + } + + public function setup_request() + { + if (isset($_SERVER['HTTP_HOST'])) { + $this->keys['host'] = $_SERVER['HTTP_HOST']; + } + + if (isset($_SERVER['REQUEST_METHOD'])) { + $this->keys['method'] = $_SERVER['REQUEST_METHOD']; + } + + if (isset($_SERVER['QUERY_STRING'])) { + parse_str($_SERVER['QUERY_STRING'], $query_string); + $this->keys['query'] = $query_string; + } + + if (isset($_SERVER['REQUEST_URI'])) { + if (($pos = strpos($_SERVER['REQUEST_URI'], '?')) !== false) { + $this->keys['path'] = substr($_SERVER['REQUEST_URI'], 0, $pos); + } else { + $this->keys['path'] = $_SERVER['REQUEST_URI']; + } + } + + $this->keys['ssl'] = $this->is_secure(); + + $this->keys['extra'] = $this->unique; + + $this->url_key = md5(sprintf( + '%s://%s%s', + $this->keys['ssl'] ? 'http' : 'https', + $this->keys['host'], + $this->keys['path'] + )); + + $this->url_version = (int) wp_cache_get("{$this->url_key}_version", $this->group); + } + + public function is_secure() + { + if (isset($_SERVER['HTTPS']) && (strtolower($_SERVER['HTTPS']) === 'on' || $_SERVER['HTTPS'] == '1')) { + return true; + } + + if (isset($_SERVER['SERVER_PORT']) && ($_SERVER['SERVER_PORT'] == '443')) { + return true; + } + + if (isset($_SERVER['HTTP_X_FORWARDED_PROTO']) && $_SERVER['HTTP_X_FORWARDED_PROTO'] === 'https') { + return true; + } + + return false; + } + + protected function add_variant($function) + { + $this->vary[md5($function)] = $function; + } + + /** + * This function is called without arguments early in the page load, + * then with arguments during the output buffer handler. + * + * @param boolean $dimensions + */ + public function do_variants($dimensions = false) + { + if ($dimensions === false) { + $dimensions = wp_cache_get("{$this->url_key}_vary", $this->group); + } else { + wp_cache_set("{$this->url_key}_vary", $dimensions, $this->group, $this->max_age + 10); + } + + if (is_array($dimensions)) { + ksort($dimensions); + + foreach ($dimensions as $key => $function) { + $value = $function(); + $this->keys[$key] = $value; + } + } + } + + public function generate_keys() + { + $this->key = md5(serialize($this->keys)); + $this->req_key = "{$this->key}_reqs"; + } + + protected function status_header($status_header, $status_code) + { + $this->status_header = $status_header; + $this->status_code = $status_code; + + return $status_header; + } + + public function cache_status_header($cache_status) + { + header(self::CACHE_STATUS_HEADER_NAME.": $cache_status"); + } + + /** + * Merge the arrays of headers into one and send them. + * + * @param array $headers1 + * @param array $headers2 + */ + public function do_headers($headers1, $headers2 = []) + { + $headers = []; + $keys = array_unique(array_merge(array_keys($headers1), array_keys($headers2))); + + foreach ($keys as $k) { + $headers[$k] = []; + + if (isset($headers1[$k]) && isset($headers2[$k])) { + $headers[$k] = array_merge((array) $headers2[$k], (array) $headers1[$k]); + } else if (isset($headers2[$k])) { + $headers[$k] = (array) $headers2[$k]; + } else { + $headers[$k] = (array) $headers1[$k]; + } + + $headers[$k] = array_unique($headers[$k]); + } + + // These headers take precedence over any previously sent with the same names + foreach ($headers as $k => $values) { + $clobber = true; + + foreach ($values as $v) { + header("$k: $v", $clobber); + $clobber = false; + } + } + } + + protected function output_callback($output) + { + $output = trim($output); + + if ($this->cancel !== false) { + wp_cache_delete("{$this->url_key}_genlock", $this->group); + header('X-Redis-Cache-Status: BYPASS', true); + + return $output; + } + + // Do not cache 5xx responses + if (isset($this->status_code) && intval($this->status_code / 100) === 5) { + wp_cache_delete("{$this->url_key}_genlock", $this->group); + header('X-Redis-Cache-Status: BYPASS', true); + + return $output; + } + + $this->do_variants($this->vary); + $this->generate_keys(); + + $cache = [ + 'version' => $this->url_version, + 'time' => isset($_SERVER['REQUEST_TIME']) ? $_SERVER['REQUEST_TIME'] : time(), + 'status_header' => $this->status_header, + 'headers' => [], + 'output' => $output, + ]; + + foreach (headers_list() as $header) { + list($k, $v) = array_map('trim', explode(':', $header, 2)); + $cache['headers'][$k][] = $v; + } + + if (! empty($cache['headers']) && ! empty($this->uncached_headers)) { + foreach ($this->uncached_headers as $header) { + unset($cache['headers'][$header]); + } + } + + foreach ($cache['headers'] as $header => $values) { + // Don't cache if cookies were set + if (strtolower($header) === 'set-cookie') { + wp_cache_delete("{$this->url_key}_genlock", $this->group); + header('X-Redis-Cache-Status: BYPASS', true); + + return $output; + } + + foreach ((array) $values as $value) { + if (preg_match('/^Cache-Control:.*max-?age=(\d+)/i', "{$header}: {$value}", $matches)) { + $this->max_age = intval($matches[1]); + } + } + } + + $cache['max_age'] = $this->max_age; + + wp_cache_set($this->key, $cache, $this->group, $this->max_age + $this->seconds + 30); + + wp_cache_delete("{$this->url_key}_genlock", $this->group); + + if ($this->cache_control) { + // Don't clobber `Last-Modified` header if already set + if (! isset($cache['headers']['Last-Modified'])) { + header('Last-Modified: ' . gmdate('D, d M Y H:i:s', $cache['time']) . ' GMT', true); + } + + if (! isset($cache['headers']['Cache-Control'])) { + header("Cache-Control: max-age={$this->max_age}, must-revalidate", false); + } + } + + $this->do_headers($this->headers); + + return $cache['output']; + } +}