From 34ea96023e5b47c383269467e150cf17076aa3d0 Mon Sep 17 00:00:00 2001 From: Tobias Bachert Date: Fri, 28 Jun 2024 23:00:48 +0200 Subject: [PATCH] Allow loading service providers from `autoload.files` Improves interoperability w/ disabled SPI plugin; removes the need to define service definitions in both `extra.spi` and an autoloaded file. Adds two configuration options: - `extra.spi-config.autoload-files` creates a static map from `ServiceLoader::register()` calls within `autoload.files` - `extra.spi-config.prune-autoload-files` removes no longer needed files from `autoload.files` Resolves #1. --- README.md | 23 ++++++++++++-- src/Composer/Plugin.php | 67 +++++++++++++++++++++++++++++++++++------ src/ServiceLoader.php | 25 ++++++++++++++- 3 files changed, 102 insertions(+), 13 deletions(-) diff --git a/README.md b/README.md index 7df654f..5d9b2d0 100644 --- a/README.md +++ b/README.md @@ -17,15 +17,34 @@ Service provider implementations must provide a public zero-arguments constructo #### Registering via composer.json `extra.spi` ```shell -composer config --json --merge extra.spi.Namespace\\Service '["Namespace\\Implementation"]' +composer config --json --merge extra.spi.Example\\Service '["Example\\Implementation"]' ``` #### Registering via php ```php -ServiceLoader::register('Namespace\Service', 'Namespace\Implementation'); +ServiceLoader::register(Example\Service::class, Example\Implementation::class); ``` +###### Converting `ServiceLoader::register()` calls to precompiled map + +`ServiceLoader::register()` calls can be converted to a precompiled map by setting `extra.spi-config.autoload-files` to +- `true` to process all `autoload.files` (should be used iff `autoload.files` is used exclusively for service +provider registration), +- or a list of files that register service providers. + +```shell +composer config --json extra.spi-config.autoload-files true +``` + +###### Removing obsolete entries from `autoload.files` + +By default, `extra.spi-config.autoload-files` files that register service providers are removed from +`autoload.files`. This behavior can be configured by setting `extra.spi-config.prune-autoload-files` to +- `true` to remove all `exra.spi-config.autoload-files` files from `autoload.files`, +- `false` to keep all `autoload.files` entries, +- or a list of files that should be removed from `autoload.files`. + ### Application authors Make sure to allow the composer plugin to be able to load service providers. diff --git a/src/Composer/Plugin.php b/src/Composer/Plugin.php index f03447a..f7cc697 100644 --- a/src/Composer/Plugin.php +++ b/src/Composer/Plugin.php @@ -4,22 +4,29 @@ use Composer\Composer; use Composer\EventDispatcher\EventSubscriberInterface; use Composer\IO\IOInterface; +use Composer\Package\Package; +use Composer\Package\PackageInterface; use Composer\Plugin\PluginInterface; use Composer\Script\Event; use Composer\Script\ScriptEvents; use Composer\Util\Filesystem; +use Composer\Util\Platform; use Nevay\SPI\ServiceLoader; use Nevay\SPI\ServiceProviderRequirementRuntimeValidated; use ReflectionAttribute; use ReflectionClass; +use function array_diff; use function array_fill_keys; use function array_unique; use function class_exists; use function implode; use function is_string; +use function json_encode; use function preg_match; use function sprintf; use function var_export; +use const JSON_UNESCAPED_SLASHES; +use const JSON_UNESCAPED_UNICODE; final class Plugin implements PluginInterface, EventSubscriberInterface { @@ -62,7 +69,7 @@ public function preAutoloadDump(Event $event): void { private function dumpGeneratedServiceProviderData(Event $event): void { $match = ''; - foreach ($this->serviceProviders($event->getComposer()) as $service => $providers) { + foreach ($this->serviceProviders($event->getComposer(), $event->getIO()) as $service => $providers) { if (!preg_match(self::FQCN_REGEX, $service)) { $event->getIO()->warning(sprintf('Invalid extra.spi configuration, expected class name, got "%s" (%s)', $service, implode(', ', array_unique($providers)))); continue; @@ -146,7 +153,7 @@ private static function providerRuntimeValidatedRequirements(string $provider): $condition = var_export(true, true); /** @var ReflectionAttribute $attribute */ - foreach ((new ReflectionClass($provider))->getAttributes(ServiceProviderRequirementRuntimeValidated::class, \ReflectionAttribute::IS_INSTANCEOF) as $attribute) { + foreach ((new ReflectionClass($provider))->getAttributes(ServiceProviderRequirementRuntimeValidated::class, ReflectionAttribute::IS_INSTANCEOF) as $attribute) { $requirement = $attribute->newInstance(); $class = '\\' . $requirement::class; $args = ''; @@ -179,20 +186,60 @@ private function vendorDir(Composer $composer, Filesystem $filesystem): string { /** * @return array> */ - private function serviceProviders(Composer $composer): array { + private function serviceProviders(Composer $composer, IOInterface $io): array { $mappings = []; - foreach ($composer->getPackage()->getExtra()['spi'] ?? [] as $service => $providers) { + $this->serviceProvidersFromExtraSpi($composer->getPackage(), $mappings); + $this->serviceProvidersFromAutoloadFiles($composer->getPackage(), $mappings, Platform::getCwd(), $io); + foreach ($composer->getRepositoryManager()->getLocalRepository()->getPackages() as $package) { + $this->serviceProvidersFromExtraSpi($package, $mappings); + $this->serviceProvidersFromAutoloadFiles($package, $mappings, $composer->getInstallationManager()->getInstallPath($package), $io); + } + + return $mappings; + } + + private function serviceProvidersFromExtraSpi(PackageInterface $package, array &$mappings): void { + foreach ($package->getExtra()['spi'] ?? [] as $service => $providers) { + $providers = (array) $providers; $mappings[$service] ??= []; - $mappings[$service] += array_fill_keys($providers, $composer->getPackage()->getPrettyString()); + $mappings[$service] += array_fill_keys($providers, $package->getPrettyString() . ' (extra.spi)'); } - foreach ($composer->getRepositoryManager()->getLocalRepository()->getPackages() as $package) { - foreach ($package->getExtra()['spi'] ?? [] as $service => $providers) { - $providers = (array) $providers; + } + + private function serviceProvidersFromAutoloadFiles(PackageInterface $package, array &$mappings, string $installPath, IOInterface $io): void { + $autoloadFiles = $package->getAutoload()['files'] ?? []; + $spiAutoloadFiles = $package->getExtra()['spi-config']['autoload-files'] ?? false ?: []; + $spiPruneAutoloadFiles = $package->getExtra()['spi-config']['prune-autoload-files'] ?? null; + + if ($spiAutoloadFiles === true) { + $spiAutoloadFiles = $autoloadFiles; + } + if ($spiPruneAutoloadFiles === true) { + $spiPruneAutoloadFiles = $spiAutoloadFiles; + } + + $includeFile = (static fn(string $file) => require $file)->bindTo(null, null); + foreach ($spiAutoloadFiles as $index => $file) { + $io->debug(sprintf('Loading service providers from "%s" (%s)', $file, $package->getPrettyString())); + + if (!$includedProviders = ServiceLoader::collectProviders($includeFile, $installPath . '/' . $file)) { + unset($spiAutoloadFiles[$index]); + continue; + } + + foreach ($includedProviders as $service => $providers) { $mappings[$service] ??= []; - $mappings[$service] += array_fill_keys($providers, $package->getPrettyString()); + $mappings[$service] += array_fill_keys($providers, $package->getPrettyString() . ' (' . json_encode($file, flags: JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) . ')'); } } - return $mappings; + $spiPruneAutoloadFiles ??= $spiAutoloadFiles; + if ($spiPruneAutoloadFiles && $autoloadFiles && $package instanceof Package) { + $io->debug(sprintf('Pruning autoload.files (%s): %s', $package->getPrettyString(), implode(', ', $spiPruneAutoloadFiles))); + + $autoload = $package->getAutoload(); + $autoload['files'] = array_diff($autoloadFiles, $spiPruneAutoloadFiles); + $package->setAutoload($autoload); + } } } diff --git a/src/ServiceLoader.php b/src/ServiceLoader.php index fb59d5c..b3a58a7 100644 --- a/src/ServiceLoader.php +++ b/src/ServiceLoader.php @@ -1,6 +1,7 @@ > */ private static array $mappings = []; + private static bool $skipChecks = false; /** @var class-string */ private readonly string $service; @@ -61,7 +63,7 @@ public static function register(string $service, string $provider): bool { if (in_array($provider, self::providers($service), true)) { return true; } - if (!self::serviceAvailable($service) || !self::providerAvailable($provider)) { + if (!self::$skipChecks && (!self::serviceAvailable($service) || !self::providerAvailable($provider))) { return false; } @@ -139,4 +141,25 @@ public static function providerAvailable(string $provider, bool $skipRuntimeVali return true; } + + /** + * @return array> + * + * @internal + */ + public static function collectProviders(Closure $closure, mixed ...$args): array { + $skipChecks = self::$skipChecks; + $mappings = self::$mappings; + self::$skipChecks = true; + self::$mappings = []; + + try { + $closure(...$args); + + return self::$mappings; + } finally { + self::$skipChecks = $skipChecks; + self::$mappings = $mappings; + } + } }