Coming from #2260187: [PP-1] Deprecate drupal_static() and drupal_static_reset()
Note: The term "cache" is ambiguous. It can mean caching in storage between requests (database, memcache, etc), or caching in a variable in the current request / PHP process. I personally prefer the term "buffer" for caching in a variable. So "cache" only refers to storage. This is the term I will use below.
Problem
The scenario:
- We want data that could be obtained through a discovery process, but this is too slow to do it more than once.
- Data should be buffered in a variable.
- This may be in places where we don't have a container available, only static and global stuff.
- We need a way to reset some or all of this buffered data.
Current solution:
Cache data with drupal_static().
Problem: This sucks for testing.
Proposed solution
This is not actually a complete solution, but rather a way to organize things.
Goal: Make the services themselves independent from the static / global state stuff.
Solution, part I
1. Introduce an interface ResettableInterface, somewhere in Drupal\Component\.
namespace Drupal\Component\..;
interface ResettableInterface {
/**
* Resets buffered data.
*/
public function reset();
}
2. Implement whichever discovery process we are interested in as a "data provider", with or without parameters.
interface FooProviderInterface {
public function getFoo($x, $y);
}
/**
* Actually scans the filesystem, or whatever else is needed.
*/
class FooProvider_Discovery implements FooProviderInterface {
..
}
3. Implement a buffer decorator, which also implements ResettableInterface.
class FooProvider_Buffer implements FooProviderInterface, ResettableInterface {
private $decorated;
private $buffer = [];
public function __construct(FooProviderInterface $decorated) {
$this->decorated = $decorated;
}
public function reset() {
$this->buffer = [];
}
public function getFoo($x, $y) {
return isset($this->buffer[$x][$y])
? $this->buffer[$x][$y]
: $this->buffer[$x][$y] = $this->decorated->getFoo();
}
}
Solution, part II
Create tools that allow us to subscribe resettables.
interface ResettableSubscriberInterface {
/**
* Subscribes a resettable for reset events.
*
* @param ResettableInterface $resettable
*/
public function subscribeResettable(ResettableInterface $resettable);
}
(Optionally, the subscribeResettable() could take a machine name as a second parameter.)
This thing can be passed around to object factories, e.g. like this:
class FooProviderUtil {
public static function createFooProviderWithBuffer(ResettableSubscriberInterface $resettableSubscriber) {
$provider = new FooProvider_Discovery();
$provider = new FooProvider_Buffer($provider);
$resettableSubscriber->subscribeResettable($provider);
return $provider;
}
}
Somewhere we have a component that can actually reset all or some of the subscribed resettables.
This can be live in the container, or in a static variable.
The main point is:
The actual data provider classes (FooProvider, and the buffer decorator) don't need to know about it.
And in tests, we can instantiate these data providers without any static stuff.
FooProvider "singleton".
Singletons are bad for many reasons.
Here we are simply being honest. This is for cases where we need static access, e.g. when no container is available. So let us not create something which looks like a regular object from outside, but uses ugly static variables on the inside.
Instead we use a singleton, which is technically equivalent to what we were already doing, but more honest.
And we are using the least bad version of singleton possible:
- The class does NOT manage its own instance.
- The instance constructor is not made private, so we can create as many instances as we want for testing.
This said: I am totally not sure about this part.
There are probably other options. It depends on the details of "we don't have a container yet".
And here it is:
class FooProviderSingleton {
private static $instance;
public static function getInstance() {
return self::$instance !== NULL
? self::$instance
: self::$instance = self::createInstance();
}
private static function createInstance() {
$subscriber = ResettableSubscriberSingleton::getInstance();
return FooProviderUtil::createFooProviderWithBuffer($subscriber);
}
/**
* Optional static reset method.
*/
public static function reset() {
if (self::$instance !== NULL && self::$instance instanceof ResettableInterface) {
self::$instance->reset();
}
}
}
}
I don't care too much about the details of this singleton stuff.
The main point is:
- The code is still functionally equivalent with a function using drupal_static(). So, getting here won't break anything.
- The actual data provider implementation can be SOLID and nicely testable, and does not know about static instances of anything.
- This code can be refactored to something completely container-driven, without touching the data provider implementation. This is likely a BC break of some sort.
Comments
Comment #2
donquixote commentedComment #3
dawehnerA total different solution: Provide a container tag, which has a method which should be called, when a service needs to be reset. This then would allow you to be independent from any interface.
Comment #4
donquixote commentedI like this!
Maybe a class is from a 3rd party and currently does not implement our Drupal-specific reset interface. With the container tag it would still work.
And it is simpler. And more flexible, because maybe there are different reset triggers on the same object.
It does depend on the container, though. So it can not be used to immediately replace drupal_static().
And it means the object that needs the reset has to be a service.
If we are working with decorators, typically only the toplevel one will be a service. So either the toplevel decorator is the buffer we want to reset, or every decorator needs to call $this->decorated->reset() to let the event travel down.
When I was playing around splitting up ExtensionDiscovery, I did this exact pattern. And then noticed I had to add a reset() method to almost every interface, just because there might be a buffer decorator somewhere. Also, maybe you want to reset only one thing and not the other? And really the reset() should travel up, not down, right?
So the alternative idea was to let the reset event travel horizontally instead of vertically. Have a chain of decorators somewhere, but call the reset method on individual items of this chain from outside.
I think both ideas have potential. And we don't really need a one-size-fits-all, rather a toolbox. It is not mutually exclusive.
Comment #5
dawehnerWell, you need some way of registry no matter, having that in the container seems a reasonable limitation. You could even register the extension discovery but someone make it clear that this class is not meant to be actually initialized.
Note: we could also register classes with static reset methods in there:
Well having some way of resetAll for tests would be nice.
Comment #6
donquixote commentedThe singleton(s) would fill this purpose.
Of course I prefer the container.
The idea with the singleton was for a case where we want to refactor existing code with static variables or drupal_static(), but where the container is not an option at the time being, or would be too big of a change.
If we can go straight to container, this is obviously better.
This is why I proposed (elsewhere) to write a replacement for ExtensionDiscovery which is actually suitable as a service...