Skip to content
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

Merged
merged 12 commits into from
Aug 11, 2018
Merged

New Bedrock Configuration Model #380

merged 12 commits into from
Aug 11, 2018

Conversation

austinpray
Copy link
Contributor

@austinpray austinpray commented Aug 5, 2018

  • tag roots/wp-config 1.0.0 before merge

Problem

The current Bedrock configuration system does not strictly adhere to one of the most important Roots core values:

All environments should match production as close as possible.

Stated another way and scoped specifically for Bedrock:

Environment configuration values should default to production values and only
deviate if absolutely necessary.

This is not how bedrock currently works. the current bedrock configuration process described by the docs in general works like so:

oldconfig

Which seems fine at first. However, upon further examination it falls apart.

The docs describe this intended usage:

The environment configs are required before the main application config so anything in an environment config takes precedence over application.

Note: You can't re-define constants in PHP. So if you have a base setting in application.php and want to override it in production.php for example, you have a few options:

  • Remove the base option and be sure to define it in every environment it's needed
  • Only define the constant in application.php if it isn't already defined.

At best the config process:

  1. is not DRY
  2. does not fail safe in situations of an unexpected WP_ENV value.
  3. will silently reject configuration values if a user is not clear on how define()ing constants in php works
  4. is not consistent with our environment parity recommendations

Not DRY

If you want all environments to match production you would have to copy paste the contents of environments/production.php to everywhere except environments/development.php. Obvious DRY violations aside, this is error prone. environments/staging.php can easily get out of sync with environments/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 unexpected WP_ENV variable (staging-experimental, productoin, or undefined to name a few): Bedrock will fail open and stuff like WP_DEBUG_DISPLAY will default to true (!!!). No web framework should behave this way when undefined values make their way into the system.

Silent Configuration Rejections

In ruby

irb(main):001:0> WP_DEBUG_DISPLAY = true
=> true
irb(main):002:0> WP_DEBUG_DISPLAY = false
(irb):2: warning: already initialized constant WP_DEBUG_DISPLAY
(irb):1: warning: previous definition of WP_DEBUG_DISPLAY was here
=> false

Clearly I am trying to do something bad and the logs will reflect this.

In PHP

php > define('WP_DEBUG_DISPLAY', true);
php > define('WP_DEBUG_DISPLAY', false);
php > var_dump(WP_DEBUG_DISPLAY);
bool(true)

Yikes.

Not Consistent With Roots Recommendations

If we recommend:

All environments should match production as close as possible.

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

  1. Collect all the configuration values in application.php as key value pairs in a map
  2. Collect all the configuration values in the desired config/environments/WP_ENV.php file and merge them with the key value pairs collected in application.php
  3. After all configuration values are collected we should apply them to the environment via define()

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.

Lines of Code (LOC)                              127
Comment Lines of Code (CLOC)                      56 (44.09%)
Non-Comment Lines of Code (NCLOC)                 71 (55.91%)

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:

bedrock-classes

Who's usage looks like:

bedrock-classes-usage

To produce:

newconfig

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 with Config::get(k) that doesn't exist in Config::$configMap. I do this because the control flow is nice and return null is not working because null is a viable configuration value. We don't use Config::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 using define(). 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!

@austinpray austinpray requested review from QWp6t, swalkinshaw and retlehs and removed request for QWp6t August 5, 2018 20:42
* This should be thrown when a user attempts to define() a constant that has already been defined
* @package Roots\Bedrock
*/
class ConstantAlreadyDefinedException extends \RuntimeException
Copy link
Contributor

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.

https://secure.php.net/manual/en/class.logicexception.php

Same goes to UndefinedConfigKeyException

Copy link
Contributor Author

@austinpray austinpray Aug 6, 2018

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?

Copy link
Contributor

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!!!

Copy link
Member

@swalkinshaw swalkinshaw left a 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).


/**
* Safe Debugging Settings
* Override these in config/{WP_ENV}.php
Copy link
Member

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.

@@ -1,5 +1,7 @@
<?php

use Roots\Bedrock\Config;

Copy link
Member

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.

Copy link
Contributor Author

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

{
if (!array_key_exists($key, self::$configMap)) {
$class = self::class;
throw new UndefinedConfigKeyException("'$key' has not been set by $class::define('$key', ...)");
Copy link
Member

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', ...)"

private static function defined($key)
{
if (defined($key)) {
$message = "Bedrock aborted trying to redefine constant '$key'";
Copy link
Member

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.

Copy link
Sponsor Member

@QWp6t QWp6t left a 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.

{
if (defined($key)) {
$message = "Bedrock aborted trying to redefine constant '$key'";
throw new ConstantAlreadyDefinedException($message);
Copy link
Sponsor Member

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.

Copy link
Contributor Author

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

/**
* @var array
*/
private static $configMap = [];
Copy link
Sponsor Member

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.

Copy link
Contributor Author

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

@austinpray
Copy link
Contributor Author

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"
Copy link
Contributor

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.

Copy link
Contributor Author

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"
Copy link
Contributor

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?

Copy link
Contributor Author

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

@austinpray
Copy link
Contributor Author

Did roots/wp-config#2

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.

@swalkinshaw
Copy link
Member

@austinpray not a bad idea. Let's consider/do that separately.

@swalkinshaw
Copy link
Member

Looks good to go once 1.0.0 is done and updated 🚀

@austinpray austinpray merged commit 0042307 into master Aug 11, 2018
@austinpray austinpray deleted the class-static branch August 11, 2018 03:47
@mike-source
Copy link

mike-source commented Sep 24, 2018

Has this change been reflected in the docs?

https://roots.io/bedrock/docs/folder-structure/
https://roots.io/bedrock/docs/configuration-files/

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!

@mike-source
Copy link

I'd offer to do it myself, but will confess I haven't got my head round the changes yet!

@tangrufus
Copy link
Contributor

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.

A simpler solution: Don't copy and paste the JSON; Use $ composer require instead.

@mike-source
Copy link

@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!

@austinpray
Copy link
Contributor Author

I can walk y'all boyos through the changes if you wanna contribute docs. Would be more fun that way

@MikeiLL
Copy link

MikeiLL commented Sep 24, 2018

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 $ composer require roots/wp-config?

@austinpray
Copy link
Contributor Author

@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

@austinpray
Copy link
Contributor Author

https://doodle.com/poll/mxa2ma2cmdef9aib If y’all want to vote on a time that works

@mike-source
Copy link

@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
Copy link
Contributor Author

@MikeiLL
Copy link

MikeiLL commented Sep 24, 2018

@austinpray yes see you then.

@austinpray
Copy link
Contributor Author

austinpray commented Sep 25, 2018

https://hangouts.google.com/call/4noypIUSvl1ljNNgs47mAAEE

Edit: RIP I totally messed up the recording

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
Development

Successfully merging this pull request may close these issues.

None yet

8 participants