The static is dead. Long live the static!

Opublikowane
2015-09-21 20:57
Written by

Civi v4.7 introduces some overhauls to the core CiviCRM development framework. Some of the planning discussions can be found in the forum, but now that it's merged and stablized a bit, I wanted to give a walk-through for other developers. A few highlights:

  • Core service objects (such as the settings manager, logger, and JS/CSS resource manager) should be accessed through a new facade, simply named Civi. A few examples of using this facade:
    Civi::log()->info('Hello, log!');
    Civi::settings()->get('versionCheck');
    Civi::service('civi_api_kernel')->getEntityNames(3);
    Civi::resources()->addScriptFile('org.example.mymodule', 'ex.js');
    Civi::$statics[__CLASS__]['tmpCacheData'] = ...;
    
  • All remaining settings from civicrm_domain.config_backend have been migrated to the settings framework. These may be modified, inspected, and overridden using the settings framework.
  • Path and URL settings may include path-variables, such as [civicrm.files] or [cms.root]. This simplifies migration/redeployment.
  • Hook functions are cached to avoid redundant scans.

The remainder of this article will explore the details in more depth.

In-depth: Statics, singletons, containers, and the Civi facade

Among developers, a lot of ink (and even more bytes, ad nauseum) have been spent discussing singletons, static functions/variables, global functions/variables, containers, and dependency-injection. I'm not looking to re-state that discussion here, but the Civi facade is a response to that debate.

The Civi facade provides access to the core services of the Civi framework. For example, to a write a log message, one might lookup the log service and call the error(), warning(), or info() function:

Civi::log()->info('Hello, log!');

Civi::log() is a simple, one-line function that locates and returns the log service -- and nearly every function in Civi:: follows that same pattern. If you've worked with Drupal 8, the Drupal facade is very similar.

Of course, syntactically, log() looks like a static function -- and static functions are bad, right? Sort of. A function like CRM_Core_Error::debug_log_message() is bad because:

  • (In)flexibility: It's impossible to swap out the log implementation (without either patching core or replacing the entire CRM/Core/Error.php).
  • (Un)testability: It's complicated to reset any hidden state (eg static variables) used by CRM_Core_Error.

In v4.5+, we have a dependency injection container, so we can skip these problems, right? We could use the container to inject the log as-needed. However, this comes with a problem: there are hundreds of thousands of lines of code which don't currently use the container. Moreover, third-party developers write custom scripts and integrations which aren't part of the container. The container cannot inject log in these cases. Instead, you have to lookup the service:

Civi\Core\Container::singleton()->get('log')

Unfortunately, that notation is also problematic. It's hard to remember and discover service names (log vs logs vs logger vs psr_log vs civi_system_log), and the returned item has no obvious class or interface -- so an IDE cannot provide typehints or drilldown. And it's pretty verbose.

The Civi facade balances these interests:

  • In older code and external code, one accesses the log via the Civi facade. The facade has typehints and documentation, and it's fairly pithy.
  • In newer container-aware code, one injects the log service.
  • The container and the facade provide exactly the same log service.
  • In testing, all the services and statics (whether used via facade or container) can be reset at once (by clearing Civi::$statics).

Of course, there are several services like log(). The following sections will drill-down on a few of the most important ones, comparing old code patterns (based on statics/singletons) and new patterns (based on the facade). The old patterns will still work - but should be viewed as deprecated.

Please note that, in terms of support and compatibility, the Civi facade should be regarded as an API -- we should be conscientious about adding, changing, removing, and documenting functions in the facade.

In-depth: Civi::log()

// Old pattern
CRM_Core_Error::debug_log_message("Egads!");
CRM_Core_Error::debug_var('something_bad_with_var', $var);
// New pattern
Civi::log()->error("Egads");
Civi::log()->error("There is something bad with {var}", array(
  'var' => $var,
));

Notes:

  • The old pattern logged all messages as errors. The new pattern admits different log levels (e.g. warning(), notice(), debug()).
  • The log service is based on the standardized PSR-3 Logger Interface.
  • The current implementation uses Civi's existing log file, but it could be swapped with any other PSR-3-compliant logger.
  • When putting in data, use the {var} notation instead of literally plugging in the $var variable. This pretty-prints the variable, and (in the future) it will allow the message to be translated/localized.

In-depth: Civi::settings()

// Old pattern
CRM_Core_BAO_Setting::getItem(
  CRM_Core_BAO_Setting::SYSTEM_PREFERENCES_NAME,
  'versionCheck', NULL, 1)
// New pattern
Civi::settings()->get('versionCheck')

Notes:

  • The Civi::settings() helper provides access to all settings in the current domain.
  • The old function accepted group-name and component_id, but these were not consistently or reliably used, and the name effectively needed to be unique. The new function simplifies. If you must access the group-name/component_id, these are still available in the settings metadata.
  • Default values are loaded from the setting metadata.
  • To avoid the overhead of reloading/rescanning the full setting metadata on every page-request, the default values are retained in a cache.

In-depth: Civi::service($id)

// Old pattern
Civi\Core\Container::singleton()
  ->get('civi_api_kernel')
  ->getEntityNames(3);
// New pattern
Civi::service('civi_api_kernel')
  ->getEntityNames(3);

For core, documented, well-supported services like logs and settings, it's preferrable to use a static function (Civi::log() or Civi::settings()). However, if you need to lookup some alternative service (perhaps an esoteric, limited-purpose, or undocumented service), then you can find it by name.

In-depth Civi::resources()

// Old pattern
CRM_Core_Resources::singleton()->addScriptFile('org.example.mymodule', 'ex.js');
// New pattern
Civi::resources()->addScriptFile('org.example.mymodule', 'ex.js');

No functional change -- just a cleaner notation.

In-depth: Civi::$statics

// Old pattern
public static function foo() {
  static $mydata;
  if ($mydata === NULL) {
    $mydata = ...;
  }
}
// New pattern
public static function foo() {
  if (!isset(Civi::$statics[__CLASS__]['mydata'])) {
    Civi::$statics[__CLASS__]['mydata'] = ...;
  }
}

A common optimization is to temporarily cache data in a static variable. The static variable will be retained for the length of the current page-request -- and then resets automatically.

In unit-testing, one must explicitly reset static variables to ensure consistent/predictable results, but resetting static variables is either manual or slow when they're distributed helterskelter. Instead, use Civi::$statics. This is slightly more verbose, but it's faster and simpler in unit-testing.

In-depth: CRM_Core_Config as facade

In previous versions, the $config object was constructed by merging data from:

  • CRM_Core_Config, CRM_Core_Config_Variables, CRM_Core_Config_Defaults
  • define()d constants and server properties
  • civicrm_domain.config_backend
  • civicrm_setting
  • Various bits of filter/helper logic

The $config object is used quite pervasively and must be maintained for compatibility. However, to ensure that we properly migrated all data from civicrm_domain.config_backend to civicrm_setting, we must be able to definitively audit all the properties in $config.

In v4.7, this has been reworked. $config still mixes data from multiple sources, but there's a map of all properties which describes where each piece of data comes from: CRM_Core_Config_MagicMerge::getPropertyMap()

In-depth: Global $civicrm_setting

The global variable $civicrm_setting is used to override CiviCRM settings. v4.7 should be backward compatible, but if you manipulate $civicrm_setting in an extension or module, then check out the change log:

https://docs.civicrm.org/dev/en/latest/api/changes/#470-global-civicrm_setting-multiple-changes

In-depth: hook_civicrm_container

/**
 * @param \Symfony\Component\DependencyInjection\ContainerBuilder $container
 */
function example_civicrm_container($container) {
  $container->addResource(new \Symfony\Component\Config\Resource\FileResource(__FILE__));
  $container->findDefinition('dispatcher')->addMethodCall('addListener', 
    array(\Civi\Token\Events::TOKEN_REGISTER, 'example_register_tokens')
  ));
}

Civi v4.7 allows extensions and modules to add or modify services in the container. For more discussion about the concepts and functions in ContainerBuilder $container object, check the documentation for the Symfony DependencyInjection Component.

Note: I don't expect this interface to see a lot of usage right now -- we need more polished examples of working with $container (e.g. loading YAML files and annotations) and, for the moment, it only has a few specific use-cases (e.g. manipulating the API kernel and the schedule-reminder system). However, I expect we'll see more interesting things come from this in future versions.

Filed under

Comments

Anonymous (niezweryfikowany)
2015-09-23 - 08:56

Thanks for the insightful article, Tim. It's a very much welcome change to the core, thanks to everyone who put effort into this!

I think links to the new classes on Github would be quite useful, though.

I like this. Has a Joomla-esque feel.