Redis Sessions for NBA.com and Service Decorators in Drupal 8
Goal: Getting PHP Sessions into Redis
One of several performance-related goals for NBA.com was to get the production database to a read-only state. This included moving cache, the Dependency Injection container, and the key-value database table to Redis. 99% of all sessions were for logged-in users, which use a separate, internal instance of Drupal; but there are edge cases where anonymous users can still trigger a PHP session that gets saved to the database.
For all it’s cool integrations with Symfony and its attempts at making everything pluggable or extendable, PHP session handling in Drupal 8 is still somewhat lacking. Drupal 8 core extends Symfony’s session handling in a way that makes a lot of assumptions, including one that developers won’t want to use any other native session handler, such as the file system or a key/value store like Redis or Memcached.
Session Handlers in Symfony and Drupal
PHP has some native session handling that’s baked in, and for basic PHP applications in simple environments, this is fine. Generally speaking, it works by storing session data in files in a temporary location on the host machine and setting a cookie header so that subsequent HTTP requests can reference the same session. However, since the default behavior doesn’t scale for everyone or meet every project’s needs, PHP offers the ability to easily swap out native session handlers. One can even create a user-defined session handler, thanks to PHP 5’s SessionHandler class.
The SessionHandler class defines some basic methods to allow a developer to create, destroy, and write session data. This class can be extended, and then ini_set('session.save_handler', TYPE)
(where “TYPE” can be any of the known save handers, such as “file” or “pdo”) and ini_set('session.save_handler', PATH)
(where “PATH” can be any writeable file system path or stream) can be used to tell PHP to use this extended class for handling sessions. In essence, this is what Symfony does, by extending this into a collection of NativeSessionHandler classes. This allows Symfony developers to easily choose PDO, file, or even memcached session storage by defining session handler methods for each storage mechanism.
Symfony-based applications can normally just choose which PHP session handling is desired through simple configuration. This is well-documented at Symfony Session Management and Configuring Sessions and Save Handlers. It’s even possible to create custom session handlers by extending the NativeSessionHandler, and using ini_set() inside the class’ constructor. There is no default Redis session handler in Symfony, but there are plenty of examples out there on the Internet, such as http://athlan.pl/symfony2-redis-session-handler/
Drupal 8 extends this even further with its own SessionManager class. This SessionManager class is a custom NativeSessionHandler (PHP allows “user” as one of the session.save_handler types). As part of the SessionManager class, several optimizations have been carried over from Drupal 7, including session migration and a few other things to prevent anonymous users from saving an empty session to the database. Because of these optimizations, however, we don’t want to simply ignore this class; however, the NativeSessionHandler service has the database connection injected into it as a dependency. This means future attempts to simply extend Drupal’s NativeSessionHandler service class will result in vestigial dependency injection.
Implementation
Now that we understand a little more about the underpinnings of session handling in PHP, Symfony, and Drupal 8, I needed to determine how to tell Drupal to use Redis for full session management. Several important goals included:
-
Keep all of Drupal 7 and 8’s optimizations made to session handling (which originated in Pressflow).
-
Don’t patch anything; leave Drupal core as intact as possible, but not rely on the core behavior of using the database for session storage.
-
Leverage the Redis module for connection configuration and API.
Just Override the Core Session Service?
One option that was considered was to simply override the Drupal core service. In core.services.yml the session_manager service is defined as using the Drupal\Core\Session\SessionManager class. In theory, a simple way to change Drupal’s database-oriented session handling would be to just replace the class. In this way, we would simply pretend the SessionManager class didn’t exist, and we would be able to use our CustomRedisSessionManager class, which we would write from scratch.
However, there are a few flaws in this plan:
-
We would have to reimplement all session handler methods, even if nothing differed from Drupal’s core class methods, such as session_destroy().
-
If Drupal core changed to include new or modified session handling, we would likely have to reimplement these changes in our custom code. Being off of the upgrade path or not being included in any future security fixes would be a Bad Thing™.
For more information about the proper way to override a code service in Drupal 8, see https://www.drupal.org/node/2306083
Enter: Service Decoration
For the purpose of this blog post, I will briefly introduce service decorators; but for a more general, in-depth look, a good resource to learn about Service Decorators is Mike Potter’s blog post, Using Symfony Service Decorators in Drupal 8. This is what I used as the basis for my decision to decorate the existing core session_handler service rather than overriding it or extending it.
What is a Service Decorator?
Service decoration is a common pattern in OOP that lets developers separate the modification of a service or class from the thing they’re modifying. In a simple way, we can think of a service decorator as a diff to an existing class. It’s a way to say, “hey, still use that other service, but filter my changes on top of it.”
Decorating the Session Manager Service
Symfony paves the way for services in Drupal 8, and carries with it several other design patterns, including service decorators. To decorate an existing service, you simply define a new service, and use the `decorates` key in your `MODULE.service.yml` file.
For the Redis Sessions module, here is `redis_sessions.service.yml`:
- services:
- # Decorate the core session_manager service to use our extended class.
- redis_sessions.session_manager:
- class: Drupal\redis_sessions\RedisSessionsSessionManager
- decorates: session_manager
- decoration_priority: -10
- arguments: ['@redis_sessions.session_manager.inner',
- '@request_stack', '@database', '@session_manager.metadata_bag',
- '@session_configuration', '@session_handler']
The `decorates` key tells Symfony and Drupal that we don’t want use this as a separate service; instead, continue to use the core session_manager service, and decorate it with our own class. The `decoration_priority` simply adds weight (or negative weight, in this case) to tell Drupal to use our service above other services that also might try and decorate or override the session_manager class.
The `arguments` key injects the same dependencies as well as the original session_manager service as a sort of top-level argument. In this way, we can still use the session_manager as the service that handles PHP sessions, and it will have all of its necessary dependencies injected into it directly by our service class. This will also inject that service into our class in case we need to reference any session_manager methods, and treat them as a _parent class method.
For the same module, here is the `RedisSessionsSessionManager.php` class constructor:
- public function __construct(SessionManager $session_manager,
- RequestStack $request_stack, Connection $connection, MetadataBag
- $metadata_bag, SessionConfigurationInterface $session_configuration,
- $handler = NULL) {
- $this->innerService = $session_manager;
- parent::__construct($request_stack, $connection, $metadata_bag,
- $session_configuration, $handler);
- $save_path = $this->getSavePath();
- if (ClientFactory::hasClient()) {
- if (!empty($save_path)) {
- ini_set('session.save_path', $save_path);
- ini_set('session.save_handler', 'redis');
- $this->redis = ClientFactory::getClient();
- }
- else {
- throw new \Exception("Redis Sessions has not been configured. See
- 'CONFIGURATION' in README.md in the redis_sessions module for instructions.");
- }
- }
- else {
- throw new \Exception("Redis client is not found. Is Redis module
- enabled and configured?");
- }
- }
In RedisSessionsSessionManager.php, we define the `RedisSessionsSessionManager` class, which will decoration Drupal core’s `SessionManager` class. Two things to note in our constructor is that:
-
We set
$this->innerService = $session_manager;
to be able to reference the core session_manager service as an inner service. -
We check that the module has the necessary connection configuration to a Redis instance, and if so, we’ll use ini_set to tell PHP to use our Redis-based `session.save_path` and `session.save_handler` settings.
Everything Else is Simple
In our RedisSessionsSessionManager class, there’s just a few things we want to change from the core SessionManager class. Namely, these will be some Drupal-specific optimizations to keep anonymous users from creating PHP sessions that will be written to Redis (originally, the database), and session migration for users that have successfully logged in (and may have some valuable session data worth keeping).
We also have to some extra things to make using Redis as a session handler easier. There are a few new methods that Redis Sessions will use to make looking up session data easier. Since Redis is essentially just a memory-based key-value store, we can’t easily look up session data by a Drupal user’s ID. Well, we can, but it’s an expensive operation, and that would negate the performance benefits of storing session data in Redis instead of the database.
With these custom methods aside, everything else just relies on PHP’s native session handling. We’ve told PHP to use the base Redis PHP class as the handler, which is just part of having Redis support compiled in PHP. We’ve told PHP where to save the session data; in this case, a TCP stream to our redis instance configured for the Redis module.
Bonus
As of the writing of this blog post, I’ve begun the process of releasing Redis Sessions as a submodule of the Redis module. This can help serve as both a practical example of creating a service decorator as well as helping high-traffic sites that also wish to serve content from a read-only database. For those that would like to help test the module, here is the patch to add Redis Sessions submodule to the Redis module.
Want to read more about Drupal 8 architecture solutions and how to evaluate each solution based on your business objectives? Download our whitepaper here.