Problem/Motivation

While replacing the form builder service in NodeThemeeHooks with a service closure in #3543386: Use service closure for form builder in node module hook classes, @berdir noted that on Standard install, the form builder still gets instantiated on every request because the search block is instantiated:

On standard install profile, this doesn't make a difference yet, because it's already initialized by \Drupal\search\Plugin\Block\SearchBlock.

This issue is to address how to inject the form builder and search page repository services lazily via service closures into the SearchBlock plugin class.

Steps to reproduce

Per #3543386-8: Use service closure for form builder in node module hook classes:

We'd want to do the same as we do here. we can't not instantiate block plugins.

plugins self-inject, so we need to define our own closure, ugly, but not not rocket science. Based on some unit tests I had to update, this should work:

public function __construct(array $configuration, $plugin_id, $plugin_definition, \Closure $form_builder, \Closure $search_page_repository) {
    parent::__construct($configuration, $plugin_id, $plugin_definition);
    $this->formBuilder = $form_builder;
    $this->searchPageRepository = $search_page_repository;
  }

  /**
   * {@inheritdoc}
   */
  public static function create(ContainerInterface $container, array $configuration, $plugin_id, $plugin_definition) {
    return new static($configuration, $plugin_id, $plugin_definition,
      fn() => $container->get('form_builder'),
      fn() => $container->get('search.search_page_repository')
    );
  }

So we just define the closure by hand.

Definitely a new issue, because for existing code, we have to think about BC. While working on #3541705: Lazy inject database into varous services, I was thinking we could do something like \Drupal\Core\DependencyInjection\DeprecatedServicePropertyTrait, but for closures by following a standard naming pattern.

So we'd change protected FormBuilderInterface $formBuilder to protected \Closure $formBuilderClosure. And then the trait, if a subclasses access $this->formBuilder which no longer exists, just checks with a magic __get() if $this->{$name}Closure exists, is a closure and calls it. But we can discuss that further in that or a separate new issue.

Proposed resolution

Remaining tasks

User interface changes

Introduced terminology

API changes

Data model changes

Release notes snippet

Issue fork drupal-3544405

Command icon Show commands

Start within a Git clone of the project using the version control instructions.

Or, if you do not have SSH keys set up on git.drupalcode.org:

Comments

godotislate created an issue. See original summary.

godotislate’s picture

Issue summary: View changes

godotislate’s picture

Status: Active » Needs review
Issue tags: +Needs change record

Put up a draft MR to see if tests pass, and they do.

Tagging for Needs CR for the constructor argument deprecations, will add that later. I know there was discussion in the original issue about typing, so we can bring that over here. For now, I added a return type declaration to the closures.

godotislate’s picture

One thought that doesn't apply to SearchBlock, but if we continue to use closures for services like this in plugins (and similar), will probably need to be careful with any classes that can be serialized and make sure to override __sleep() and __wakeup().

berdir’s picture

> One thought that doesn't apply to SearchBlock

Good thought and I'm not even sure it doesn't apply to that. There's a reason that \Drupal\Core\DependencyInjection\DependencySerializationTrait is used all over the place. I think it would be fairly easy to extend that trait and support closures? It's just an extra elseif to check if something is a closure and it's return value is a service? do we need reflection to only call closures without arguments?

godotislate’s picture

Good thought and I'm not even sure it doesn't apply to that. There's a reason that \Drupal\Core\DependencyInjection\DependencySerializationTrait is used all over the place. I think it would be fairly easy to extend that trait and support closures?

I missed that PluginBase does use DependencySerializationTrait, so SearchBlock could be serialized.

As for serialization/unserialization, I think you're right. It could look something like this (untested):

  protected $_serviceClosureIds = [];

  public function __sleep(): array {
    $vars = get_object_vars($this);
    try {
      $container = \Drupal::getContainer();
      $reverse_container = $container->get(ReverseContainer::class);
      foreach ($vars as $key => $value) {
        if (!is_object($value) || $value instanceof TranslatableMarkup) {
          // Ignore properties that cannot be services.
          continue;
        }
        if ($value instanceof EntityStorageInterface) {
          // If a class member is an entity storage, only store the entity type
          // ID the storage is for, so it can be used to get a fresh object on
          // unserialization. By doing this we prevent possible memory leaks
          // when the storage is serialized and it contains a static cache of
          // entity objects. Additionally we ensure that we'll not have multiple
          // storage objects for the same entity type and therefore prevent
          // returning different references for the same entity.
          $this->_entityStorages[$key] = $value->getEntityTypeId();
          unset($vars[$key]);
        }
        elseif ($value instanceof \Closure && ($service_id = $reverse_container->getId(($value)()))) {
          $this->_serviceClosureIds[$key] = $service_id;
          unset($vars[$key]);
        }
        elseif ($service_id = $reverse_container->getId($value)) {
          // If a class member was instantiated by the dependency injection
          // container, only store its ID so it can be used to get a fresh
          // object on unserialization.
          $this->_serviceIds[$key] = $service_id;
          unset($vars[$key]);
        }
      }
    }
    catch (ContainerNotInitializedException) {
      // No container, no problem.
    }

    return array_keys($vars);
  }

  public function __wakeup(): void {
    // Avoid trying to wakeup if there's nothing to do.
    if (empty($this->_serviceIds) && empty($this->_entityStorages)) {
      return;
    }
    $container = \Drupal::getContainer();
    foreach ($this->_serviceIds as $key => $service_id) {
      $this->$key = $container->get($service_id);
    }
    $this->_serviceIds = [];

    foreach ($this->_serviceClosureIds as $key => $service_closure_id) {
      $this->$key = static fn() => $container->get($service_closure_id);
    }

    if ($this->_entityStorages) {
      /** @var \Drupal\Core\Entity\EntityTypeManagerInterface $entity_type_manager */
      $entity_type_manager = $container->get('entity_type.manager');
      foreach ($this->_entityStorages as $key => $entity_type_id) {
        $this->$key = $entity_type_manager->getStorage($entity_type_id);
      }
    }
    $this->_entityStorages = [];
  }

Should that be done here? Or another issue?

berdir’s picture

Lets do another issue? We'll likely need to add a separate test for it anyway.

godotislate’s picture

do we need reflection to only call closures without arguments?

Interesting. It seems possible that a class could have a property whose value is a closure that takes at least 1 argument, so trying to call that closure without arguments during serialization would cause an error. I don't see a way to check for that other than using relfection on the closure.

godotislate’s picture

godotislate’s picture

Checked some other existing deprecation messages for constructor parameters, and some of them link to the issue and not a change record, so I decided just to do that. Removing "Needs change record" tag and moving MR out of draft.

smustgrave’s picture

Left a small comment but leaving in review for others.

dcam’s picture

This plugin is one of those having its create() function removed by #3552110: Remove manual create() method from plugins in favor of autowiring. How do these issues affect each other?

Is it possible to add the #[AutowireServiceClosure(...)] attribute and delete create() here? In which case I'd remove it from #3552110.

Or do the parameter deprecations and need to handle either the closures or original parameters negate the possibility of using autowiring?

Also, does the CR need before and after examples?

godotislate’s picture

Is it possible to add the #[AutowireServiceClosure(...)] attribute and delete create() here?

There needs to be a separate issue to support that. It was on my todo list to create one, but I haven't gotten around to it yet and thought it would be best to land this and #3544994: Make service closures serializable in classes using DependencySerializationTrait first.

Also, does the CR need before and after examples?

For the class's constructor arguments deprecation? I'm not sure how that would go.

dcam’s picture

There needs to be a separate issue to support that.

Ok, maybe I should go ahead and drop it from #3552110: Remove manual create() method from plugins so we don't have an inevitable merge conflict.

For the class's constructor arguments deprecation? I'm not sure how that would go.

Yeah, IDK either. Who constructs instances of plugins manually? But I've had to provide examples for such things in CRs like:

Before:

$block = new SearchBlock(
  $configuration,
  $plugin_id,
  $plugin_definition,
  \Drupal::service('form_builder'),
  \Drupal::service('search.search_page_repository'),
);

After:

$block = new SearchBlock(
  $configuration,
  $plugin_id,
  $plugin_definition,
  static fn(): FormBuilderInterface => \Drupal::service('form_builder'),
  static fn(): SearchPageRepositoryInterface => \Drupal::service('search.search_page_repository'),
);

You can use that if someone insists on it being in the CR.

dcam’s picture

Status: Needs review » Reviewed & tested by the community

Oh, the questions I had while reviewing were answered. So I'll go ahead and RTBC this. The code looks OK to me. This service closure pattern has me thinking about where it can be applied elsewhere.

berdir’s picture

I'm less concerned about the constructor and more about the properties which change type and behavior. I think there is an option on how we could make this work, the question is if we care enough (short: renamed properties + __get()). It's unlikely that someone has customized the constructor and we BC for that, but if someone would access the properties it would be a fatal error now.

dcam’s picture

Status: Reviewed & tested by the community » Needs review

@berdir I see your point. So would it be sufficient to do something like this?

public function __get(string $name) {
  if ($name == 'formBuilder') {
    @trigger_error(...);
    return ($this->formBuilder)();
  }
  elseif ($name == 'searchPageRepository') {
    @trigger_error(...);
    return ($this->searchPageRepository)();
  }
}
berdir’s picture

Yes, but we need to give the new properties a new name, for example formBuilderClosure, or __get() won't be triggered. If we'd adopt that naming convention, it could even be generic, similar to \Drupal\Core\DependencyInjection\DeprecatedServicePropertyTrait.

godotislate’s picture

Added the BC for the class properties. I genericized the __get(), but then didn't do a trait because the properties aren't really deprecated, since we'll be bringing them back via property hooks in D12 with minimum PHP 8.4 support in #3544903: [meta] Use property hooks to instantiate services from service closures. So the __get() will only be a thing for this short window of time.

godotislate’s picture

berdir’s picture

Component: plugin system » search.module
Status: Needs review » Reviewed & tested by the community

Thanks. I'm not sure how often we want to apply this pattern , at least not before the two related issues of which one is D12+ to deal with extra code and DX. But In this specific case, it results in a measurable improvement (maybe not in terms of actual time savings but in number of services instantiated) in a common scenario.

Added one optional suggestion to the MR, but I think this can go back to RTBC.

I think search module is a better component here as it's not (yet) a generic plugin system thing.

godotislate’s picture

Thanks for the reviews.
I wondering now if this should be postponed on #3544994: Make service closures serializable in classes using DependencySerializationTrait?

godotislate’s picture

Title: Replace SearchBlock service properties with service closures » [PP-1] Replace SearchBlock service properties with service closures
Status: Reviewed & tested by the community » Postponed

Version: 11.x-dev » main

Drupal core is now using the main branch as the primary development branch. New developments and disruptive changes should now be targeted to the main branch.

Read more in the announcement.