-
-
Notifications
You must be signed in to change notification settings - Fork 1.2k
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
New Bedrock Configuration Model #380
Conversation
* This should be thrown when a user attempts to define() a constant that has already been defined | ||
* @package Roots\Bedrock | ||
*/ | ||
class ConstantAlreadyDefinedException extends \RuntimeException |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Should we use LogicException
instead?
Because LogicException
is
Exception that represents error in the program logic. This kind of exception should lead directly to a fix in your code.
Same goes to UndefinedConfigKeyException
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Hey! Thank you for taking a look at this.
I understand where you are coming from when suggesting \LogicException. However, I am still going to advocate for \RuntimeException.
In general when trying to determine what exception class to extend I always look at what other exceptions subclass it. Let's look at the exception hierarchy with some cherry-picked examples:
- LogicException (extends Exception): Exception that represents error in the program logic. This kind of exception should lead directly to a fix in your code.
- BadFunctionCallException:
- BadMethodCallException
- DomainException
- InvalidArgumentException
- LengthException
- OutOfRangeException: Exception thrown when an illegal index was requested. This represents errors that should be detected at compile time.
- RuntimeException (extends Exception): Exception thrown if an error which can only be found on runtime occurs.
- OutOfBoundsException: Exception thrown if a value is not a valid key. This represents errors that cannot be detected at compile time.
- OverflowException
- RangeException
- UnderflowException
- UnexpectedValueException
The fact that the runtime has been polluted with unexpected constants is something that cannot be detected at compile time. Especially since we dynamically build this configuration based on environment variables.
Take a look at the get method of \DS\Map: https://secure.php.net/manual/en/ds-map.get.php. This throws an OutOfBoundsException (extends RuntimeException) if the key could not be found and a default value was not provided.
Lastly consider the following completely valid use case:
JankyHomebrewDBClient.php
if (!defined('DB_PORT')) {
define('DB_PORT', 3306)
}
class JankyHomebrewDBClient {
// ...
}
config/application.php
// ...
Config::define('DB_PORT', env('DB_PORT'));
// ...
Config::apply();
one-off-db-migration-script.php
// I just want to connect to the DB I don't need WordPress
require_once __DIR__ . '/vendor/autoload.php';
require_once __DIR__ . '/config/application.php';
$client = new JankyHomebrewDBClient(/* ... */);
This will throw a ConstantAlreadyDefinedException
at runtime. I don't necessarily consider this a logical error, since JankyHomebrewDBClient can never tell if it is going to define DB_PORT at compile time.
What do you think?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Awesome! This reply should go into textbooks as "best code review reply example".
Thanks!!!
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This is great 🎉. Couple minor copy questions/suggestions.
What about turning this into a Composer package? It's a bit more overheard, but I might prefer that over including these files in a Bedrock install. It might encourage people to modify the code or put other stuff in lib
(which may or may be good).
config/application.php
Outdated
|
||
/** | ||
* Safe Debugging Settings | ||
* Override these in config/{WP_ENV}.php |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Why are these "safe" and why do only these have a comment about overriding? Every setting can be overridden technically.
config/application.php
Outdated
@@ -1,5 +1,7 @@ | |||
<?php | |||
|
|||
use Roots\Bedrock\Config; | |||
|
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
We didn't have it before either, but I think a comment block explaining the config a little bit would be useful. I know we have docs, but I think it's important in here as well.
Mostly around explaining how the application config defaults to production settings and the env specific files should only be used when necessary to change those.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yep this needs to be in here
lib/Bedrock/Config.php
Outdated
{ | ||
if (!array_key_exists($key, self::$configMap)) { | ||
$class = self::class; | ||
throw new UndefinedConfigKeyException("'$key' has not been set by $class::define('$key', ...)"); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Maybe: "'$key' has not been defined. Use $class::define('$key', ...)"
lib/Bedrock/Config.php
Outdated
private static function defined($key) | ||
{ | ||
if (defined($key)) { | ||
$message = "Bedrock aborted trying to redefine constant '$key'"; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Hmm I think we can be a little more clear about what's going on here. Something like:
Config aborted trying to
define
'$key' . The constant has already been defined.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I do like how the problem has been identified, but I'm not sure if this is the best solution to address it for our purposes. There a few issues that I have with the class; some are minor and some are more significant.
The most significant issue that I have is that it feels overengineered. We're basically just providing a way for developers to set a key-value pair and then override them later and ultimately pass them all to define()
. There are already plenty of ways for doing this in PHP, and I'm not sold on why we wouldn't just go with one of those.
An example would be using a simple $config
array and array_merge($config, include __DIR__ . '/' . WP_ENV . '.php');
or some such to override values and then at the end just loop over the array and pass the pairs to define()
. Developers can work with the array just like they would any other array. This is the simplest solution and should be taken under consideration.
I'm not a fan of the class full of static
members. You can just instantiate the class and accomplish the same thing using $config->define()
and $config->apply()
. I usually only see classes like this take the form of utility classes (such as Illuminate\Support\Str
), but that's not what we're doing here. This has all the markings of a regular ol' class instance, but it's unnecessarily being made static instead.
lib/Bedrock/Config.php
Outdated
{ | ||
if (defined($key)) { | ||
$message = "Bedrock aborted trying to redefine constant '$key'"; | ||
throw new ConstantAlreadyDefinedException($message); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
If we're trying to replicate Ruby's behavior, then you can trigger a warning with
trigger_error($message, E_USER_WARNING);
It might be helpful since it would give developers a full list of constants that they're attempting to redefine instead of failing one at a time.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The user should be able to recover from the exception without incurring a warning though. Lemme think about this
lib/Bedrock/Config.php
Outdated
/** | ||
* @var array | ||
*/ | ||
private static $configMap = []; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
In general, I'm not a fan of private
properties or methods. I'd suggest using protected
instead.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
👍 changed over at https://github.com/roots/wp-config
Moving this over to https://github.com/roots/wp-config |
composer.json
Outdated
@@ -32,13 +32,19 @@ | |||
"url": "https://wpackagist.org" | |||
} | |||
], | |||
"autoload": { | |||
"psr-4": { | |||
"Roots\\Bedrock\\": "lib/Bedrock" |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This shouldn't be necessary anymore now that Config has been moved to a package.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
whoops! I'm sloppy haha
composer.json
Outdated
"require": { | ||
"php": ">=5.6", | ||
"composer/installers": "^1.4", | ||
"vlucas/phpdotenv": "^2.0.1", | ||
"johnpbloch/wordpress": "4.9.8", | ||
"oscarotero/env": "^1.1.0", | ||
"roots/wp-password-bcrypt": "1.0.0" | ||
"roots/wp-password-bcrypt": "1.0.0", | ||
"roots/wp-config": "dev-master" |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
+1 for extracting this as a package!
How about ^1.0
instead of dev-master
?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yep! I am going tag 1.0.0 before this is merged
and 1e6ff6c Anyone have any comments about my comments? lol. Removing config/environments/production.php makes a lot of sense to me. It makes so sense to do all this and then still provide a place by default to put production specific config. Nothing is stopping people from creating that file but by default it shouldn't be there. I was debating removing staging.php as well but I will defer to you guys. |
@austinpray not a bad idea. Let's consider/do that separately. |
Looks good to go once 1.0.0 is done and updated 🚀 |
Has this change been reflected in the docs? https://roots.io/bedrock/docs/folder-structure/ Not sure if this is the best place to mention it, and probably bad practice on my part but I was copying in an old composer.json (from before August 2018, with my preferred packages) and missed the changes. Just posting here in case anyone else is doing the same (I suspect quite a few people do this with bedrock + composer) and got confused. Was pulling my hair out for a couple hours! |
I'd offer to do it myself, but will confess I haven't got my head round the changes yet! |
A simpler solution: Don't copy and paste the JSON; Use |
@tangrufus hmmm maybe I could write myself a CLI script in future, I needed to add 8+ plugins to my composer.json at once and also add a couple of custom repositories... ACF pro is incredibly annoying to use via composer. I'll probably just remember to check Bedrock latest changes! |
I can walk y'all boyos through the changes if you wanna contribute docs. Would be more fun that way |
I'd like to contribute, but am also a little glassy-eyed about where this codebase goes. Is it a plugin that would potentially be installed with composer? Would that be |
@MikeiLL (and anyone else reading this) wanna hop on a google hangout later today and I can walk ya through it in like 10 mins? Can record and then share here for posterity |
https://doodle.com/poll/mxa2ma2cmdef9aib If y’all want to vote on a time that works |
@austinpray I'm UK based so those times are a bit tricky (been a long day!) If something happens definitely share it though I'll take a look. Cheers. |
@austinpray yes see you then. |
https://hangouts.google.com/call/4noypIUSvl1ljNNgs47mAAEE Edit: RIP I totally messed up the recording |
Problem
The current Bedrock configuration system does not strictly adhere to one of the most important Roots core values:
Stated another way and scoped specifically for Bedrock:
This is not how bedrock currently works. the current bedrock configuration process described by the docs in general works like so:
Which seems fine at first. However, upon further examination it falls apart.
The docs describe this intended usage:
At best the config process:
WP_ENV
value.define()
ing constants in php worksNot DRY
If you want all environments to match production you would have to copy paste the contents of
environments/production.php
to everywhere exceptenvironments/development.php
. Obvious DRY violations aside, this is error prone.environments/staging.php
can easily get out of sync withenvironments/production.php
and congratulations your staging environment no longer reflects production or vice versa.Does Not Fail Safe
You could perhaps
require_once __dir__ . '/production.php';
in every file except development.php. However now the second Bedrock encounters an unexpectedWP_ENV
variable (staging-experimental
,productoin
, orundefined
to name a few): Bedrock will fail open and stuff likeWP_DEBUG_DISPLAY
will default totrue
(!!!). No web framework should behave this way when undefined values make their way into the system.Silent Configuration Rejections
In ruby
Clearly I am trying to do something bad and the logs will reflect this.
In PHP
Yikes.
Not Consistent With Roots Recommendations
If we recommend:
Then why does a user have to take explicit action to make this happen for every environment they define? Why doesn't it just default to this behavior? In the current model production configuration is "opt-in" instead of "opt-out". This is perpendicular to what we recommend.
Solution
"Production" configuration should be specified in its entirety in config/application.php and files in
config/environments/*.php
should only be used to override production behavior. For instance in development.php you would want to show error messages and define debug flags and stuff.To do this we should
config/environments/WP_ENV.php
file and merge them with the key value pairs collected in application.phpdefine()
At every point in every step above we need to protect the users from redefining constants.
Implementation
NOTE: Before your eyes glaze over looking at crazy UML diagrams: go read the code. What we are doing here is very simple and takes less than 100 lines of code.
The reason I am going so overboard with explaining the implementation is because I need a second pair of eyes to sign off on the UX I am proposing here. I want to validation on if throwing these exceptions in these places makes sense etc. Also in writing this explanation I have already fixed a couple edgecases.
I have defined these classes:
Who's usage looks like:
To produce:
Exceptions
I throw a
ConstantAlreadyDefinedException
when a user attempts to enter a situation described in the "Problem" section where PHP would silently reject the configuration value.I throw a
UndefinedConfigKeyException
when a user attempts to fetch a value withConfig::get(k)
that doesn't exist inConfig::$configMap
. I do this because the control flow is nice andreturn null
is not working because null is a viable configuration value. We don't useConfig::get(k)
anywhere but it is a part of the API because I can see users needing it.Backwards Compatibility
All of this
Config
class madness is opt-in. Nothing stops a user from just going back to usingdefine()
.Config::define()
is just an alternative API that behaves in a safe and sane way with regards to overriding environment configurations.Conclusion
I am out of time for today so can't elaborate much further. Interested to discuss this with you guys!