Drupal 8: Custom Cache Bins

14th September 2019

Drupal's cache system is robust, feature full and extensible. It can be used to cache small blocks of rendered output or the results of more complex calculations. The cache can be stored in a for the page request, in a database or in a different cache system.

I recently needed to store a decent amount of data that was pulled from an API. Getting the data from the API took a few seconds so it was important to cache this within the site for quick retrieval. Even just storing the cache in the database was many times quicker than the API so it made sense to cache the results within the site. Having the user sat there waiting for the page to load every time they do anything isn't great.

To this end I created a small Drupal service that would manage this cache for me. Here is the record from the services YAML file within the module.

  1. services:
  2.   my_module.cache:
  3.   class: Drupal\my_module\Cache\MyCache
  4.   arguments: ['@cache.default']

The class itself was basically a set and a get method for the specific cache bin with some static caching being used to prevent the same database request being made over and over again. The caches were time based so that they would expire in 24 hours and also tagged in order to allow them to be specifically cleared at a later time.

  1. <?php
  2.  
  3. namespace Drupal\my_module\Cache;
  4.  
  5. use Drupal\Core\Cache\Cache;
  6. use Drupal\Core\Cache\CacheBackendInterface;
  7.  
  8. /**
  9.  * Class MyCache.
  10.  *
  11.  * This class combines a static cache and an 'active' cache, loaded from a the
  12.  * default Drupal cache location.
  13.  *
  14.  * @package Drupal\my_module\Cache
  15.  */
  16. class MyCache {
  17.  
  18. /**
  19.   * The default cache bin.
  20.   *
  21.   * @var \Drupal\Core\Cache\CacheBackendInterface
  22.   */
  23. protected $cache;
  24.  
  25. /**
  26.   * MyCache constructor.
  27.   *
  28.   * @param \Drupal\Core\Cache\CacheBackendInterface $cache
  29.   * The default cache bin.
  30.   */
  31. public function __construct(CacheBackendInterface $cache) {
  32. $this->cache = $cache;
  33. }
  34.  
  35. /**
  36.   * Sets a cache with a specific id, data and type.
  37.   *
  38.   * @param string $id
  39.   * The cache id.
  40.   * @param mixed $data
  41.   * The data to be cached.
  42.   * @param array $type
  43.   * The type of data being cached. This is used to set up the cache tags.
  44.   */
  45. public function setCache($id, $data, $type): void {
  46. $cid = 'my_custom_cache:' . $type . ':' . $id;
  47.  
  48. $tags = [
  49. 'my_custom_cache:' . $type . ':' . $id,
  50. 'my_custom_cache:' . $type,
  51. 'my_custom_cache',
  52. ];
  53.  
  54. $tags = Cache::mergeTags($tags, [$cid]);
  55.  
  56. // Set the database cache.
  57. $this->cache->set($cid, $data, (new \DateTime('+1 day'))->getTimestamp(), $tags);
  58.  
  59. // Set the static cache.
  60. $staticCache = &drupal_static(__FUNCTION__ . $cid, NULL);
  61. $staticCache = $data;
  62. }
  63.  
  64. /**
  65.   * Get a specific cache.
  66.   *
  67.   * @param string $id
  68.   * The cache ID.
  69.   * @param string $type
  70.   * The cache type.
  71.   *
  72.   * @return mixed
  73.   * The cache or false if the cache was not found.
  74.   */
  75. public function getCache($id, $type) {
  76. $cid = 'my_custom_cache:' . $type . ':' . $id;
  77.  
  78. $staticCache = &drupal_static(__FUNCTION__ . $cid, NULL);
  79.  
  80. if ($staticCache) {
  81. // If the static cache exists, then return it.
  82. return $staticCache;
  83. }
  84.  
  85. // Get the cache out of the database and return the data component.
  86. $result = $this->cache->get($cid);
  87. return $result->data ?? NULL;
  88. }
  89.  
  90. }

The finished class in the project has a few methods for invalidating the cache when needed, which is why I'm adding in the cache tags. These are used to identify cache buckets that I needed to clear.

By default, this will add anything you cache to a cache_default table which contains a collection of other caches not connected to this data. In order to change this and use a different table we need to set up a custom cache bin. This is done using an entry in the services YAML file. This custom cache bin is then passed to the cache service set up earlier.

  1. services:
  2. # Custom cache bin.
  3.   cache.my_custom_cache:
  4.   class: Drupal\Core\Cache\CacheBackendInterface
  5.   tags:
  6.   - { name: cache.bin }
  7.   factory: cache_factory:get
  8.   arguments: [my_custom_cache]
  9. # Cache service.
  10.   my_module.cache:
  11.   class: Drupal\my_module\Cache\MyCache
  12.   arguments: ['@cache.my_custom_cache']

Now, because we have injected our custom cache bin into the MyCache class, any data we store in the cache is then stored in a table called cache_my_custom_cache.

What I needed to do was keep this cache entirely separate from the normal Drupal cache mechanisms so that if the caches were cleared then I didn't have to rebuild the caches again. This would allow things like the CSS/JavaScript cache to be flushed and not also flush our API cache. In order to do this I needed to allow a cache clear event to take place, without purging this custom cache from the system. This is possible, but a few things needed to be overridden in order for it to work correctly.

The cache_factory class being used to load the current cache backend will always load in the default Drupal cache systems, so we need to override that in the services to be a custom service.

  1. cache.my_custom_cache:
  2.   class: Drupal\Core\Cache\CacheBackendInterface
  3.   tags:
  4.   - { name: cache.bin }
  5.   factory: my_custom_cache.cache_factory:get
  6.   arguments: [my_custom_cache]

With that in place we need to set up the factory we just defined. This is first done by setting up another service called my_module.cache_factory.

  1. # Custom cache factory, used to stipulate the loading of cache.backend.database.my_module.
  2. my_module.cache_factory:
  3.   class: Drupal\my_module\Cache\CustomCacheFactory
  4.   arguments: ['@settings', '%cache_default_bin_backends%']
  5.   calls:
  6. - [setContainer, ['@service_container']]

With the service set up we need to create a concrete cache factory class. This class is pretty simple, all we are doing is returning a service called cache.backend.database.my_module, which doesn't exist (yet)

  1. <?php
  2.  
  3. namespace Drupal\my_module\Cache;
  4.  
  5. use Drupal\Core\Cache\CacheFactory;
  6.  
  7. class CustomCacheFactory extends CacheFactory {
  8.  
  9. public function get($bin) {
  10. $service_name = 'cache.backend.database.my_module';
  11. return $this->container->get($service_name)->get($bin);
  12. }
  13. }

To create this service we first need to define the service in the module services YAML file.

  1. # Custom cache backend database factory, used to load in the CacheDatabaseBackend object.
  2. cache.backend.database.my_module:
  3.   class: Drupal\my_module\Cache\DatabaseBackendFactory
  4.   arguments: ['@database', '@cache_tags.invalidator.checksum', '@settings']

Next we define the DatabaseBackendFactory class that we just defined in the services file above. This factory is used to return an object called CacheDatabaseBackend, which is a custom defined class. The core Drupal database caches also keep hold of a setting called DEFAULT_MAX_ROWS that is used to prevent the cache system becoming too large. I intend to increase this setting in the custom class, so I also included an override for the getMaxRowsForBin method that would allow this custom value to be used by Drupal's upstream garbage collection.

  1. <?php
  2.  
  3. namespace Drupal\my_module\Cache;
  4.  
  5. use Drupal\Core\Cache\DatabaseBackendFactory;
  6. use Drupal\my_module\Cache\CacheDatabaseBackend;
  7.  
  8. class DatabaseBackendFactory extends DatabaseBackendFactory {
  9.  
  10. /**
  11.   * {@inheritDoc}
  12.   */
  13. public function get($bin) {
  14. $max_rows = $this->getMaxRowsForBin($bin);
  15. return new CacheDatabaseBackend($this->connection, $this->checksumProvider, $bin, $max_rows);
  16. }
  17.  
  18. /**
  19.   * {@inheritDoc}
  20.   */
  21. protected function getMaxRowsForBin($bin) {
  22. return CacheDatabaseBackend::DEFAULT_MAX_ROWS;
  23. }
  24. }

Now we can finally create out custom cache backend and override the base cache clearing mechanism. This class extends the DatabaseBackend class but allows us to specially override the cache clearing mechanisms. Essentially, we set the deleteAll() method to do nothing, whilst also adding our own deleteCustomCaches() method that will delete anything in the cache using the same overridden deleteAll() method from the parent class.

  1. <?php
  2.  
  3. namespace Drupal\my_module\Cache;
  4.  
  5. use Drupal\Core\Cache\DatabaseBackend;
  6.  
  7. class CacheDatabaseBackend extends DatabaseBackend {
  8.  
  9. /**
  10.   * {@inheritdoc}
  11.   *
  12.   * We deliberately set this higher as we have a lot of data to store.
  13.   */
  14. const DEFAULT_MAX_ROWS = 50000;
  15.  
  16. /**
  17.   * {@inheritdoc}
  18.   */
  19. public function deleteAll() {
  20. // This method is called during a normal Drupal cache clear. We absolutely
  21. // do not want to flush the caches so we do nothing.
  22. }
  23.  
  24. /**
  25.   * This method will call the original deleteAll() method.
  26.   *
  27.   * Calling this method will permanently delete the caches for this cache bin.
  28.   *
  29.   * @throws \Exception
  30.   */
  31. public function deleteCustomCaches() {
  32. parent::deleteAll();
  33. }
  34. }

There are quite a few different pieces in play here, but we now have a custom cache bin that has been isolated from the main cache clearing mechanisms. This does mean that if we do need to clear the custom cache when we need to implement that functionality. For the project I was working on I created a form and a custom Drush command so that the cache could be cleared and rebuilt easily. That is beyond the scope of this post, but there really wasn't a lot of code written to do that. You just need to create a form where the submit handler runs the deleteCustomCaches() method in the CacheDatabaseBackend service.

There is also the issue that I have built all of this with a database cache system in mind. It's perfectly possible to create different implementations of cache bins (memcache for example) by adding some detection to the CustomCacheFactory get method and returning a different cache bin. This would, however, also mean writing a lot of code to override the different cache mechanisms. With the implementation above I am certain that this cache is being stored in the database, which is still a much quicker way of fetching the data I need and ultimately reduces the complexity of the problem. Overriding these classes to create custom interfaces is fine, but when I revisit the same code again in 6 months I need to remember how it all works. Documentation can only go so far.

Add new comment

The content of this field is kept private and will not be shown publicly.
CAPTCHA This question is for testing whether or not you are a human visitor and to prevent automated spam submissions.