From a9d469f980abf4fb797a0b2d26868ce3ad442b34 Mon Sep 17 00:00:00 2001 From: Tim Otten Date: Tue, 4 May 2021 13:36:48 -0700 Subject: [PATCH] Api4 Services - Lazy-load subscriber-objects This refines the way in which `Civi/Api4/Event/Subscriber/**.php` are loaded. This makes it safer (from a performance POV) to continue adding more subscribers/listeners without worrying that it will impact the quantity of files/classes/opcodes/SLOC loaded in a typical page-view. A good way to visualize this change is to skim `getDispatcherService()` (`[civicrm.compile]/CachedCiviContainer.*.php`) before and after the patch. (Examples included below.) Before ------ During every page-load, you need to register event-listeners. For subscriber-objects (like `Civi/Api4/Event/Subscriber/**.php`), you get the list of subscriptions by calling `getSubscribedEvents()`. Therefore, on every page-load, you must load/process the subscriber (regardless of whether it will actually be used) on the chance it that may be needed. In `CachedCiviContainer.*.php`, you will see snippets like: ```php protected function getDispatcherService() { ... $instance->addSubscriber(${($_ = isset($this->services['Civi_Api4_Event_Subscriber_ActivityPreCreationSubscriber']) ? $this->services['Civi_Api4_Event_Subscriber_ActivityPreCreationSubscriber'] : ($this->services['Civi_Api4_Event_Subscriber_ActivityPreCreationSubscriber'] = new \Civi\Api4\Event\Subscriber\ActivityPreCreationSubscriber())) && false ?: '_'}); $instance->addSubscriber(${($_ = isset($this->services['Civi_Api4_Event_Subscriber_ActivitySchemaMapSubscriber']) ? $this->services['Civi_Api4_Event_Subscriber_ActivitySchemaMapSubscriber'] : ($this->services['Civi_Api4_Event_Subscriber_ActivitySchemaMapSubscriber'] = new \Civi\Api4\Event\Subscriber\ActivitySchemaMapSubscriber())) && false ?: '_'}); ... ``` Observe that it instantiates `ActivitySchemaMapSubscriber` then passes the instance to `addSubscriber()`. After ----- You only need to instantiate service-objects if (a) you are building a fresh container or (b) actually running an event. This works by calling `getSubscribedEvents()` when building the container. The list of events is cached in the container. In `CachedCiviContainer.*.php`, you will see snippets like: ```php protected function getDispatcherService() { ... $instance->addSubscriberServiceMap('Civi_Api4_Event_Subscriber_ActivityPreCreationSubscriber', ['civi.api.prepare' => 'onApiPrepare']); $instance->addSubscriberServiceMap('Civi_Api4_Event_Subscriber_ActivitySchemaMapSubscriber', ['api.schema_map.build' => 'onSchemaBuild']); ... ``` Observe that it alludes to `ActivityPreCreationSubscriber` symbolically but does not need an actual instance. Comments ----------------- 1. To see that this is equivalent, I used `cv debug:event-dispatcher` before and after the patch. This requires an updated version of `cv`, and the formatting is a little a different, but it does show the same list of listeners. 2. There could be some concern like, "What happens if you're upgrading and have a cached list of subscription events?" Well, note that `CRM_Api4_Services::hook_container` already puts a cached list of subscribers in the container. It also registers the `FileResource`. Thus, if a file `Civi/Api4/Event/Subscriber/**.php` changes, it already makes the decision to recompile based on `filemtime()`. 3. There should already be a lot of test-coverage which hits code-paths for these listeners. (If this were generally non-functional, you'd see massive failures.) 4. For `r-run`, I picked an arbitary subscriber (`ActivitySchemaMapSubscriber`), then: * At the start of the file, add a statement to log whenever the file is read. ```php file_put_contents('/tmp/parselog.txt', sprintf("%s: %s: %s\n\n",date('Y-m-d H:i:s'), __FILE__, \CRM_Core_Error::formatBacktrace(debug_backtrace(DEBUG_BACKTRACE_IGNORE_ARGS, 15))), FILE_APPEND); ``` * In a separate window, do a `tail -f /tmp/parselog.txt`. * Edit the file to add/remove listeners (like `hook_civicrm_alterContent`) * Request some Civi page (`curl 'http://dmaster.127.0.0.1.nip.io:8001/civicrm/admin?reset=1'`). It's not important that it actually runs the full page... just that we boot up Civi to look for the page. * Alternately repeat the past few steps. Observe thta it only parses the file if there has been a change or if the relevant event(s) actually fire. --- CRM/Api4/Services.php | 5 +++-- Civi/Core/CiviEventDispatcher.php | 28 ++++++++++++++++++++++++++++ 2 files changed, 31 insertions(+), 2 deletions(-) diff --git a/CRM/Api4/Services.php b/CRM/Api4/Services.php index f7fc52befc..ed68e1a8ed 100644 --- a/CRM/Api4/Services.php +++ b/CRM/Api4/Services.php @@ -43,9 +43,10 @@ class CRM_Api4_Services { $subscribers = $container->findTaggedServiceIds('event_subscriber'); foreach (array_keys($subscribers) as $subscriber) { + $getSubscribedEvents = [$container->findDefinition($subscriber)->getClass(), 'getSubscribedEvents']; $dispatcher->addMethodCall( - 'addSubscriber', - [new Reference($subscriber)] + 'addSubscriberServiceMap', + [$subscriber, $getSubscribedEvents()] ); } diff --git a/Civi/Core/CiviEventDispatcher.php b/Civi/Core/CiviEventDispatcher.php index 195fc1ccfe..dd3bc3f7e5 100644 --- a/Civi/Core/CiviEventDispatcher.php +++ b/Civi/Core/CiviEventDispatcher.php @@ -61,6 +61,34 @@ class CiviEventDispatcher extends EventDispatcher { return (substr($eventName, 0, 5) === 'hook_') && (strpos($eventName, '::') === FALSE); } + /** + * Adds a series of event listeners from a subscriber object. + * + * This is particularly useful if you want to register the subscriber without + * materializing the subscriber object. + * + * @param string $subscriber + * Service ID of the subscriber. + * @param array $events + * List of events/methods/priorities. + * @see \Symfony\Component\EventDispatcher\EventSubscriberInterface::getSubscribedEvents() + */ + public function addSubscriberServiceMap(string $subscriber, array $events) { + foreach ($events as $eventName => $params) { + if (\is_string($params)) { + $this->addListenerService($eventName, [$subscriber, $params]); + } + elseif (\is_string($params[0])) { + $this->addListenerService($eventName, [$subscriber, $params[0]], isset($params[1]) ? $params[1] : 0); + } + else { + foreach ($params as $listener) { + $this->addListenerService($eventName, [$subscriber, $listener[0]], isset($listener[1]) ? $listener[1] : 0); + } + } + } + } + /** * Adds a service as event listener. * -- 2.25.1