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

(WIP) civix#175 - Add support for mixins #19865

Closed
wants to merge 1 commit into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
civix#175 - Add support for mixins
Overview
--------

(NOTE: For this description, I reference the term "API" in the general sense of a programmatic interface -- such as
a hook or file-naming convention. It is not specifically about CRUD/DB APIs.)

The `civix` code-generator provides support for additional coding-conventions -- ones which are more amenable to
code-generation.  For example, it autoloads files from `xml/Menu/*.xml` and `**/*.mgd.php`.  The technique for
implementing this traditionally relies on generating a lot of boilerplate.

This patch introduces a new construct ("mixin") which allows boilerplate to be maintained more easily.  A mixin
inspects an extension programmatically, registering new hooks as needed.  A mixin may start out as a file in `civix`
(or even as a bespoke file in some module) - and then be migrated into `civicrm-core`. Each mixin has a name and
version, which means that (at runtime) it will only load the mixin once (ie the best-available version).

See: totten/civix#175

Before
------

The civix templates generate a few files, such as `mymod.php` and `mymod.civix.php`.
A typical example looks like this:

```php
// mymod.php - Implement hook_civicrm_xmlMenu
require_once 'mymod.civix.php';
function mymod_civicrm_xmlMenu(&$all, $the, $params) {
  _mymod_civix_civicrm_xmlMenu($all, $the, $params);
}
```

and

```php
// mymod.civix.php - Implement hook_civicrm_xmlMenu
function _mymod_civix_civicrm_xmlMenu(&$all, $the, $params) {
  foreach (_mosaico_civix_glob(__DIR__ . '/xml/Menu/*.xml') as $file) {
    $files[] = $file;
  }
}
```

These two files are managed differently: `mymod.php` is owned by the developer, and they may add/remove/manage the
hooks in this file.  `mymod.civix.php` is owned by `civix` and must be autogenerated.

This structure allows `civix` (and any `civix`-based extension) to take advantage of new coding-convention
immediately. However, it comes with a few pain-points:

* If you want to write a patch for `_mymod_civix_civicrm_xmlMenu`, the dev-test-loop requires several steps.
* If `civix` needs to add a new `hook_civicrm_foo`, then the author must manually create the stub
  function in `mymod.php`. `civix` has documentation (`UPGRADE.md`) which keeps a long list of stubs that must
  be manually added.
* If `civix` has an update for `_mymod_civix_civicrm_xmlMenu`, then the author must regenerate `mymod.civix.php`.
* If `mymod_civix_xmlMenu` needs a change, then the author must apply it manually.
* If `civix`'s spin on `hook_civicrm_xmlMenu` becomes widespread, then the `xmlMenu` boilerplate is duplicated
  across many extensions.

After
-----

An extension may enable a mixin in `info.xml`, eg:

```xml
<mixins>
  <mixin>civix-register-files@2.0</mixin>
</mixins>
```

Civi will look for a file `mixin/civicrm-register-files@2.0.0.mixin.php` (either in the extension or core). The file follows this pattern:

```php
return function(\CRM_Extension_MixInfo $mixInfo, \CRM_Extension_BootCache $bootCache) {
  // echo "This is " . $mixInfo->longName . "!\n";
  \Civi::dispatcher()->addListener("hook_civicrm_xmlMenu", function($e) use ($mixInfo) {
    ...
  });
}
```

The mixin file is a plain PHP file that can be debugged/copied/edited verbatim, and it can register for hooks on its
own.  The code is no longer a "template", and it doesn't need to be interwoven between `mymod.php` and
`mymod.civix.php`.

It is expected that a system may have multiple copies of a mixin.  It will choose the newest compatible copy.
Hypothetically, if there were a security update or internal API change, core might ship a newer version to supplant the
old copy in any extensions.

Technical Details
-----------------

Mixins may define internal classes/interfaces/functions. However, each major-version
must have a distinct prefix (e.g. `\V2\Mymixin\FooInterface`). Minor-versions may be
provide incremental revisions over the same symbol (but it's imperative for newer
increments to provide the backward-compatibility).
  • Loading branch information
totten committed Apr 6, 2021
commit efdb75c06be08f76af82d7abaf26c0b4dc16ed37
85 changes: 85 additions & 0 deletions CRM/Extension/BootCache.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
<?php
/*
+--------------------------------------------------------------------+
| Copyright CiviCRM LLC. All rights reserved. |
| |
| This work is published under the GNU AGPLv3 license with some |
| permitted exceptions and without any warranty. For full license |
| and copyright information, see https://civicrm.org/licensing |
+--------------------------------------------------------------------+
*/

/**
*/
class CRM_Extension_BootCache {

protected $locked = FALSE;

protected $data;

/**
* Define a persistent value in the extension's boot-cache.
*
* This value is retained as part of the boot-cache. It will be loaded
* very quickly (eg via php op-code caching). However, as a trade-off,
* you will not be able to change/reset at runtime - it will only
* reset in response to a system-wide flush or redeployment.
*
* Ex: $mix->define('initTime', function() { return time(); });
*
* @param string $key
* @param mixed $callback
* @return mixed
* The value of $callback (either cached or fresh)
*/
public function define($key, $callback) {
if (!isset($this->data[$key])) {
$this->set($key, $callback($this));
}
return $this->data[$key];
}

/**
* Determine if $key has been set.
*
* @param string $key
* @return bool
*/
public function has($key) {
return isset($this->data[$key]);
}

/**
* Get the value of $key.
*
* @param string $key
* @param mixed $default
* @return mixed
*/
public function get($key, $default = NULL) {
return $this->data[$key] ?? $default;
}

/**
* Set a value in the cache.
*
* This operation is only valid on the first page-load when a cache is built.
*
* @param string $key
* @param mixed $value
* @return static
* @throws \Exception
*/
public function set($key, $value) {
if ($this->locked) {
throw new \Exception("Cannot modify a locked boot-cache.");
}
$this->data[$key] = $value;
return $this;
}

public function lock() {
$this->locked = TRUE;
}

}
37 changes: 24 additions & 13 deletions CRM/Extension/ClassLoader.php
Original file line number Diff line number Diff line change
Expand Up @@ -63,22 +63,32 @@ public function __destruct() {
*/
public function register() {
// In pre-installation environments, don't bother with caching.
if (!defined('CIVICRM_DSN') || defined('CIVICRM_TEST') || \CRM_Utils_System::isInUpgradeMode()) {
return $this->buildClassLoader()->register();
}
$cacheFile = (defined('CIVICRM_DSN') && !defined('CIVICRM_TEST') && !\CRM_Utils_System::isInUpgradeMode())
? $this->getCacheFile() : NULL;

$file = $this->getCacheFile();
if (file_exists($file)) {
$loader = require $file;
if (file_exists($cacheFile)) {
[$classLoader, $mixinLoader, $bootCache] = require $cacheFile;
$cacheUpdate = NULL;
}
else {
$loader = $this->buildClassLoader();
$ser = serialize($loader);
file_put_contents($file,
sprintf("<?php\nreturn unserialize(%s);", var_export($ser, 1))
);
$classLoader = $this->buildClassLoader();
$mixinLoader = (new CRM_Extension_MixinScanner($this->mapper, $this->manager, $cacheFile !== NULL))->createLoader();
$bootCache = new CRM_Extension_BootCache();
// We don't own Composer\Autoload\ClassLoader, so we clone to prevent register() from potentially leaking data.
// We do own MixinLoader, and we want its state - like $bootCache - to be written.
$cacheUpdate = $cacheFile ? [clone $classLoader, clone $mixinLoader, $bootCache] : NULL;
}
return $loader->register();

$classLoader->register();
$mixinLoader->run($bootCache);

if ($cacheUpdate !== NULL) {
// Save cache after $mixinLoader has a chance to fill $bootCache.
$export = var_export(serialize($cacheUpdate), 1);
file_put_contents($cacheFile, sprintf("<?php\nreturn unserialize(%s);", $export));
}

return $classLoader;
}

/**
Expand Down Expand Up @@ -135,7 +145,8 @@ public function refresh() {
* @return string
*/
protected function getCacheFile() {
$envId = \CRM_Core_Config_Runtime::getId();
$formatRev = '_2';
$envId = \CRM_Core_Config_Runtime::getId() . $formatRev;
$file = \Civi::paths()->getPath("[civicrm.compile]/CachedExtLoader.{$envId}.php");
return $file;
}
Expand Down
13 changes: 13 additions & 0 deletions CRM/Extension/Info.php
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,13 @@ class CRM_Extension_Info {
*/
public $requires = [];

/**
* @var array
* List of expected mixins.
* Ex: ['civix@2.0.0']
*/
public $mixins = [];

/**
* @var array
* List of strings (tag-names).
Expand Down Expand Up @@ -189,6 +196,12 @@ public function parse($info) {
$this->tags[] = (string) $tag;
}
}
elseif ($attr === 'mixins') {
$this->mixins = [];
foreach ($val->mixin as $mixin) {
$this->mixins[] = (string) $mixin;
}
}
elseif ($attr === 'requires') {
$this->requires = $this->filterRequirements($val);
}
Expand Down
73 changes: 73 additions & 0 deletions CRM/Extension/MixInfo.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
<?php
/*
+--------------------------------------------------------------------+
| Copyright CiviCRM LLC. All rights reserved. |
| |
| This work is published under the GNU AGPLv3 license with some |
| permitted exceptions and without any warranty. For full license |
| and copyright information, see https://civicrm.org/licensing |
+--------------------------------------------------------------------+
*/

/**
* "Mixins" allow extensions to be initialized with small, reusable chunks of code.
*
* Example: A mixin might scan an extension for YAML files, aggregate them, add that
* to the boot-cache, and use the results to register event-listeners during initialization.
*
* Mixins have the following characteristics:
*
* - They are defined by standalone PHP files, e.g. `civix@1.0.2.mixin.php`
* - They are implicitly versioned via strict SemVer. (`1.1.0` can replace `1.0.0`; `2.0.0` and `1.0.0` are separate/parallel things).
* - They are activated via `info.xml` (`<mix>civix@1.0</mix>`).
* - They may be copied/reproduced in multiple extensions.
* - They are de-duped - such that a major-version (eg `civix@1` or `civix@2`) is only loaded once.
*
* The "MixInfo" record tracks the mixins needed by an extension. You may consider this an
* optimized subset of the 'info.xml'. (The mix-info is loaded on every page-view, so this
* record is serialized and stored in the MixinLoader cache.)
*/
class CRM_Extension_MixInfo {

/**
* @var string
*
* Ex: 'org.civicrm.flexmailer'
*/
public $longName;

/**
* @var string
*
* Ex: 'flexmailer'
*/
public $shortName;

/**
* @var string|null
*
* Ex: '/var/www/modules/civicrm/ext/flexmailer'.
*/
public $path;

/**
* @var array
* Ex: ['civix@2.0', 'menu@1.0']
*/
public $mixins;

/**
* Get a path relative to the target extension.
*
* @param string $relPath
* @return string
*/
public function getPath($relPath = NULL) {
return $relPath === NULL ? $this->path : $this->path . DIRECTORY_SEPARATOR . ltrim($relPath, '/');
}

public function isActive() {
return \CRM_Extension_System::singleton()->getMapper()->isActiveModule($this->shortName);
}

}
Loading