Skip to content

Commit

Permalink
Allow loading service providers from autoload.files
Browse files Browse the repository at this point in the history
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.
  • Loading branch information
Nevay committed Jun 30, 2024
1 parent 7872231 commit 34ea960
Show file tree
Hide file tree
Showing 3 changed files with 102 additions and 13 deletions.
23 changes: 21 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
67 changes: 57 additions & 10 deletions src/Composer/Plugin.php
Original file line number Diff line number Diff line change
Expand Up @@ -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 {

Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -146,7 +153,7 @@ private static function providerRuntimeValidatedRequirements(string $provider):

$condition = var_export(true, true);
/** @var ReflectionAttribute<ServiceProviderRequirementRuntimeValidated> $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 = '';
Expand Down Expand Up @@ -179,20 +186,60 @@ private function vendorDir(Composer $composer, Filesystem $filesystem): string {
/**
* @return array<class-string, array<class-string, string>>
*/
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);
}
}
}
25 changes: 24 additions & 1 deletion src/ServiceLoader.php
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
<?php declare(strict_types=1);
namespace Nevay\SPI;

use Closure;
use Iterator;
use IteratorAggregate;
use ReflectionAttribute;
Expand All @@ -19,6 +20,7 @@ final class ServiceLoader implements IteratorAggregate {

/** @var array<class-string, list<class-string>> */
private static array $mappings = [];
private static bool $skipChecks = false;

/** @var class-string<S> */
private readonly string $service;
Expand Down Expand Up @@ -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;
}

Expand Down Expand Up @@ -139,4 +141,25 @@ public static function providerAvailable(string $provider, bool $skipRuntimeVali

return true;
}

/**
* @return array<class-string, list<class-string>>
*
* @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;
}
}
}

0 comments on commit 34ea960

Please sign in to comment.