It can happen that you call Settings::getSingleton() or just the procedural wrapper, settings(), before it is initialized.
The components which do that do not properly check if the returned result is not NULL.
Most prominent example is index.php,
try {
drupal_handle_request();
}
catch (Exception $e) {
$message = 'If you have just changed code (for example deployed a new module or moved an existing one) read <a href="http://drupal.org/documentation/rebuild">http://drupal.org/documentation/rebuild</a>';
if (settings()->get('rebuild_access', FALSE)) {
Ouch.
There are some options to get out of this:
- Let getSingleton() throw an exception if not initialized.
- Split in two accessor methods:
Settings::getSingleton() -> may return NULL, which needs to be properly checked for.
Settings::requireSingleton() -> will never return NULL, but will throw an exception if not initialized. - Rethink the entire singleton design. (*)
--------------
(*) There are many things wrong about the singleton "pattern", which is nicely summarized here,
http://stackoverflow.com/questions/137975/what-is-so-bad-about-singleton...
Points 1., 3. and 4 refer to the smell of global state, which I think we can excuse by being surrounded by (legacy) code which depends on global state.
But point 2 is interesting,
They violate the Single Responsibility Principle: by virtue of the fact that they control their own creation and lifecycle.
In fact, our Drupal 7 style procedural static cache pattern did not have this flaw (depending how it was implemented, of course): The static cache variable lived in a different place than the class whose instance was being cached. Whether to call that a singleton or not, I dunno.
But in the particular implementation we use in the Settings class, the flaw mentioned above is clearly present:
function __construct(array $settings) {
$this->storage = $settings;
self::$instance = $this;
}
Problems:
- Why is it the constructor's job to set the static instance?
A constructor should have no side effects, this one has. And it could be so easily avoided. - Why do the instantiated class be the same class as the one that has the static accessor method and static instance variable?
Both of these violate the single responsibility principle.
The second issue is kinda acceptable, because while the static stuff and the non-static stuff live in a class with the same name, they do not have to share anything. Except the potential leak of non-static private variables into the static context - but the class is small enough to see that it is not the case.
And yes, this might be a textbook implementation of the singleton pattern. But then you should get a new textbook.
And see this, in bootstrap.inc:
function drupal_settings_initialize() {
[..]
// Initialize Settings.
new Settings($settings);
}
Calling a constructor here is semantically wrong. Instead, this should be a call to a static method.
Patch will follow.
| Comment | File | Size | Author |
|---|---|---|---|
| #46 | interdiff-44-46.txt | 863 bytes | mitrpaka |
| #46 | settings_getinstance-2210521-46.patch | 1.97 KB | mitrpaka |
| #44 | interdiff-41-44.txt | 696 bytes | mitrpaka |
| #44 | settings_getinstance-2210521-44.patch | 1.93 KB | mitrpaka |
| #41 | settings_getinstance-2210521-41.patch | 1.81 KB | mitrpaka |
Comments
Comment #1
damien tournoud commentedSettings has zero conceptual reason for being a singleton:
(1) Settings depend on the website (via the configuration path), which depends on the request.
(2) Symfony supports executing multiple requests in the same PHP environment.
(3) As a consequence, there could be different settings in the same PHP environment.
It is only a singleton by laziness, because we still haven't come up with a bootstrap process that makes sense.
Comment #2
donquixote commentedWe can and should design a new bootstrap process that eliminates the need for a singleton or static access.
But we can also make this stuff suck less, for the time until a real solution lands. We should not invest too much into it, but I think some basic improvement as suggested above could still be a good thing.
Comment #3
donquixote commentedDuh.. I think I first want to see stuff like this to get in: #2210565: Docblock and code style improvements in core/includes/bootstrap.inc
This will allow to really find all occurances of "new Settings" or "Settings::getSingleton()", so we can replace the latter with requireInstance().
(btw, about singletons.. #1757536-139: Move settings.php to /settings directory, fold sites.php into settings.php)
Comment #4
donquixote commentedIntroducing Settings::requireSingleton().
Comment #5
sunI agree with the (main) problem statement of this issue:
Settings::get() should blow up with an exception upon too early access.
But the proposed patch doesn't really make sense to me.
In any case, I disagree with @Damien in #1, and here is why:
Settingsindeed depends on the site directory, which depends on the [master] request.Settingsis to be a read-only reincarnation of$settingsvariables from settings.php.The site directory also defines the list of available extensions. It is not possible to change that list mid-request. Doing so would require to boot a new kernel with a completely new module list.
In turn, a
HttpKernelwould have to reboot a new instance of its parentKernel. I am very confident we do not want to get there; the currentDrupalKernelreboot situation upon module installation is painful enough already.As a consequence, there can only be one
Settingsinstance in the entire lifetime of a PHP request, regardless of how many sub-requests may be issued. TheSettingssingleton represents the settings.php of the master request. Only the request changes, no other parts of Drupal's kernel are rebooted.Comment #6
andypostComment #7
marvil07 commentedI do not see why we need a wrapper, instead of doing it directly on the method.
In any case
\Drupal\Component\Utility\Settingsis now\Drupal\Core\Site\Settings.Proposed resolution: do it directly on the method (BTW
getSingleton()is nowgetInstance())Comment #8
vlad.n commentedAdded exception directly in the methods. Also removed global variables that were not used.
Comment #9
vlad.n commentedComment #11
vlad.n commentedUsing exception only on getInstance.
Comment #12
vlad.n commentedComment #13
dawehner+1 for doing that, though we should also have a test
This improves developer experience a bit
Comment #14
dawehnerNeeds work as test are needed still
Comment #15
mitrpaka commentedSimple unit test cases added.
Comment #16
lauriiiThanks for your work!
@thows should be after @return. There should be also additional line break between them.
Should we also test the scenario where the exception is being thrown?
Comment #17
mitrpaka commentedUpdated patch.
1) @thows should be after @return. There should be also additional line break between them.
Changed.
2) Should we also test the scenario where the exception is being thrown?
Actually, testGetInstanceWithMissingInstance() test case is testing exception thrown.
Comment #18
lauriiiSorry I was blind and didn't see that :) RTBC for me!
Comment #19
dawehnerIMHO we should extend the existing test in \Drupal\Tests\Core\Site\SettingsTest
Comment #20
mitrpaka commented@dawehner Settings class is singleton and settings are initialized in SettingsTest::SetUp() - How would you test exception thrown then?
Comment #21
dawehnerYou could unset it first, can't you, at least using reflection :)
Comment #22
Ema.jar commentedI'm working on this issue for the SpringWeekend2016 in Milan with fusillicode!!
Comment #23
Ema.jar commentedComment #24
fusillicode commentedWorking on it with Ema.jar directly from Milan SprintWeekend2016 ;)
Comment #25
Ema.jar commentedWe've created a test based on reflection, as dawehner said, and added it in \Drupal\Tests\Core\Site\SettingsTest
Comment #26
imiksuCleaning up drupalcampfi tags.
Comment #27
dawehnerGreat DX improvement in situations like unit tests
Comment #28
alexpott8.1.x has entered the RC phase and 8.0.x has entered it's last RC. As silly as it might seem something mught be relying on the NULL behaviour - however there is a DX improvement here. Therefore we should move this to 8.2.x. It is also only a task,
Comment #29
dawehnerThe alternative would be to trigger and error and maybe return a non singleton instance?
Comment #30
alexpottAn @throws should document under what circumstances the exception is thrown.
So this will change the singleton for all other tests - doesn't break anything atm and if it did the other tests would be written wrong.
Comment #32
klausi@throws does not need to have a comment if the exception is that obvious, see https://www.drupal.org/docs/develop/coding-standards/api-documentation-a... .
Testing best practices say that the exception annotation should not be used and we should call $this->setExpectedException() right before the last line in the test instead.
this should use the Settings::class instead of the class name in a string.
$singleton is never used and can be removed.
Comment #33
mitrpaka commentedUpdated patch based on review comments by @klausi. Thanks for the review and guidance.
Comment #34
mitrpaka commentedCorrect patch file attached.
Comment #36
dawehnerWhat about telling people what they should do instead? So probably call new
SettingsorSettings::initialize?Comment #37
klausiAgreed, so the message could be 'Settings::$instance is not initialized yet. Call Settings::initialize() first.'
this should use \BadMethodCallException::class and not as string.
Comment #38
mitrpaka commentedThanks for the reviews and comments. Patch updated.
Comment #39
donquixote commentedIs this good advice? Do we want people / contrib modules to call Settings::initialize(), or should this be the job of some dedicated snippet in core?
If a contrib / custom developer runs into this problem, likely this developer did something too early in the request.
So, "Whatever you are trying to do, it might be too early for that. You could call Settings::initialize(), but it is probably better to wait until it is called in the regular way. Also check for recursions.".
Maybe we could help with the recursion check?
Comment #40
klausiWell, if people run into this problem then they are trying to access settings too early, probably outside of the usual Drupal request flow. The only solution to that is to initialize the settings if they really need settings. They probably cannot wait for later?
That sentence is a bit broken, did you mean "Thrown when the settings instance has not been initialized yet."?
That method does not exist on the Settings class. Should be "Tests that an excpetion is thrown when settings are not initialized yet."
Comment #41
mitrpaka commentedUpdated patch.
Comment #42
klausiLooks good!
Comment #43
alexpottI think that rather than telling them what to do an explanation of why this occurs might be more useful. I'm sympathetic to @donquixote's point that just telling a developer to call initialize() without also making them aware of why might lead to blindly calling Settings::initialize() without considering your code's architecture.
Comment #44
mitrpaka commentedPatch updated.
Comment #45
klausiI think we should keep the first sentence "Settings::$instance is not initialized yet." and then add the rest. We should mention the reason why we have to throw the BadMethodCallException.
Comment #46
mitrpaka commentedComment #47
klausiLooks good, thanks!
Comment #49
catchTried to think of a better comment because the current one seems a bit cryptic, but that's because hitting the error in the first place is strange and couldn't improve on it.
Committed/pushed to 8.3.x, thanks!