From b57fdd5d709e6d78f1120691ee2519181ef6353e Mon Sep 17 00:00:00 2001 From: daniel Date: Sun, 20 May 2018 10:57:11 +0200 Subject: [PATCH] [MCR] Allow extensions to manipulate service instances Bug: T195212 Change-Id: I93c53328e3b4a5cecc934707dd4786a39bce6553 --- includes/services/ServiceContainer.php | 87 +++++++++++++++++- .../services/ServiceContainerTest.php | 88 ++++++++++++++++++- 2 files changed, 170 insertions(+), 5 deletions(-) diff --git a/includes/services/ServiceContainer.php b/includes/services/ServiceContainer.php index 5ea49eee19..30f82955c5 100644 --- a/includes/services/ServiceContainer.php +++ b/includes/services/ServiceContainer.php @@ -55,6 +55,11 @@ class ServiceContainer implements DestructibleService { */ private $serviceInstantiators = []; + /** + * @var callable[][] + */ + private $serviceManipulators = []; + /** * @var bool[] disabled status, per service name */ @@ -151,6 +156,22 @@ class ServiceContainer implements DestructibleService { $this->serviceInstantiators, $newInstantiators ); + + $newManipulators = array_diff( + array_keys( $container->serviceManipulators ), + $skip + ); + + foreach ( $newManipulators as $name ) { + if ( isset( $this->serviceManipulators[$name] ) ) { + $this->serviceManipulators[$name] = array_merge( + $this->serviceManipulators[$name], + $container->serviceManipulators[$name] + ); + } else { + $this->serviceManipulators[$name] = $container->serviceManipulators[$name]; + } + } } /** @@ -199,7 +220,7 @@ class ServiceContainer implements DestructibleService { * Define a new service. The service must not be known already. * * @see getService(). - * @see replaceService(). + * @see redefineService(). * * @param string $name The name of the service to register, for use with getService(). * @param callable $instantiator Callback that returns a service instance. @@ -224,7 +245,9 @@ class ServiceContainer implements DestructibleService { * * @see defineService(). * - * @note This causes any previously instantiated instance of the service to be discarded. + * @note This will fail if the service was already instantiated. If the service was previously + * disabled, it will be re-enabled by this call. Any manipulators registered for the service + * will remain in place. * * @param string $name The name of the service to register. * @param callable $instantiator Callback function that returns a service instance. @@ -233,7 +256,8 @@ class ServiceContainer implements DestructibleService { * Any extra instantiation parameters provided to the constructor will be * passed as subsequent parameters when invoking the instantiator. * - * @throws RuntimeException if $name is not a known service. + * @throws NoSuchServiceException if $name is not a known service. + * @throws CannotReplaceActiveServiceException if the service was already instantiated. */ public function redefineService( $name, callable $instantiator ) { Assert::parameterType( 'string', $name, '$name' ); @@ -250,6 +274,46 @@ class ServiceContainer implements DestructibleService { unset( $this->disabled[$name] ); } + /** + * Add a service manipulator callback for the given service. + * This method may be used by extensions that need to wrap, replace, or re-configure a + * service. It would typically be called from a MediaWikiServices hook handler. + * + * The manipulator callback is called just after the service is instantiated. + * It can call methods on the service to change configuration, or wrap or otherwise + * replace it. + * + * @see defineService(). + * @see redefineService(). + * + * @note This will fail if the service was already instantiated. + * + * @since 1.32 + * + * @param string $name The name of the service to manipulate. + * @param callable $manipulator Callback function that manipulates, wraps or replaces a + * service instance. The callback receives the new service instance and this the + * ServiceContainer as parameters, as well as any extra instantiation parameters specified + * when constructing this ServiceContainer. If the callback returns a value, that + * value replaces the original service instance. + * + * @throws NoSuchServiceException if $name is not a known service. + * @throws CannotReplaceActiveServiceException if the service was already instantiated. + */ + public function addServiceManipulator( $name, callable $manipulator ) { + Assert::parameterType( 'string', $name, '$name' ); + + if ( !$this->hasService( $name ) ) { + throw new NoSuchServiceException( $name ); + } + + if ( isset( $this->services[$name] ) ) { + throw new CannotReplaceActiveServiceException( $name ); + } + + $this->serviceManipulators[$name][] = $manipulator; + } + /** * Disables a service. * @@ -359,6 +423,23 @@ class ServiceContainer implements DestructibleService { $this, ...$this->extraInstantiationParams ); + + if ( isset( $this->serviceManipulators[$name] ) ) { + foreach ( $this->serviceManipulators[$name] as $callback ) { + $ret = call_user_func_array( + $callback, + array_merge( [ $service, $this ], $this->extraInstantiationParams ) + ); + + // If the manipulator callback returns an object, that object replaces + // the original service instance. This allows the manipulator to wrap + // or fully replace the service. + if ( $ret !== null ) { + $service = $ret; + } + } + } + // NOTE: when adding more wiring logic here, make sure importWiring() is kept in sync! } else { throw new NoSuchServiceException( $name ); diff --git a/tests/phpunit/includes/services/ServiceContainerTest.php b/tests/phpunit/includes/services/ServiceContainerTest.php index a760908f9e..aca88aa4b6 100644 --- a/tests/phpunit/includes/services/ServiceContainerTest.php +++ b/tests/phpunit/includes/services/ServiceContainerTest.php @@ -186,9 +186,26 @@ class ServiceContainerTest extends PHPUnit\Framework\TestCase { $services->applyWiring( $wiring ); + $services->addServiceManipulator( 'Foo', function ( $service ) { + return $service . '+X'; + } ); + + $services->addServiceManipulator( 'Car', function ( $service ) { + return $service . '+X'; + } ); + $newServices = $this->newServiceContainer(); - // define a service before importing, so we can later check that + // create a service with manipulator + $newServices->defineService( 'Foo', function () { + return 'Foo!'; + } ); + + $newServices->addServiceManipulator( 'Foo', function ( $service ) { + return $service . '+Y'; + } ); + + // create a service before importing, so we can later check that // existing service instances survive importWiring() $newServices->defineService( 'Car', function () { return 'Car!'; @@ -207,7 +224,7 @@ class ServiceContainerTest extends PHPUnit\Framework\TestCase { $newServices->importWiring( $services, [ 'Bar' ] ); $this->assertNotContains( 'Bar', $newServices->getServiceNames(), 'Skip `Bar` service' ); - $this->assertSame( 'Foo!', $newServices->getService( 'Foo' ) ); + $this->assertSame( 'Foo!+Y+X', $newServices->getService( 'Foo' ) ); // import all wiring, but preserve existing service instance $newServices->importWiring( $services ); @@ -326,6 +343,73 @@ class ServiceContainerTest extends PHPUnit\Framework\TestCase { } ); } + public function testAddServiceManipulator() { + $services = $this->newServiceContainer( [ 'Foo' ] ); + + $theService1 = new stdClass(); + $theService2 = new stdClass(); + $name = 'TestService92834576'; + + $services->defineService( + $name, + function ( $actualLocator, $extra ) use ( $services, $theService1 ) { + PHPUnit_Framework_Assert::assertSame( $services, $actualLocator ); + PHPUnit_Framework_Assert::assertSame( 'Foo', $extra ); + return $theService1; + } + ); + + $services->addServiceManipulator( + $name, + function ( + $theService, $actualLocator, $extra + ) use ( + $services, $theService1, $theService2 + ) { + PHPUnit_Framework_Assert::assertSame( $theService1, $theService ); + PHPUnit_Framework_Assert::assertSame( $services, $actualLocator ); + PHPUnit_Framework_Assert::assertSame( 'Foo', $extra ); + return $theService2; + } + ); + + // force instantiation, check result + $this->assertSame( $theService2, $services->getService( $name ) ); + } + + public function testAddServiceManipulator_fail_undefined() { + $services = $this->newServiceContainer(); + + $theService = new stdClass(); + $name = 'TestService92834576'; + + $this->setExpectedException( MediaWiki\Services\NoSuchServiceException::class ); + + $services->addServiceManipulator( $name, function () use ( $theService ) { + return $theService; + } ); + } + + public function testAddServiceManipulator_fail_in_use() { + $services = $this->newServiceContainer( [ 'Foo' ] ); + + $theService = new stdClass(); + $name = 'TestService92834576'; + + $services->defineService( $name, function () use ( $theService ) { + return $theService; + } ); + + // create the service, so it can no longer be redefined + $services->getService( $name ); + + $this->setExpectedException( MediaWiki\Services\CannotReplaceActiveServiceException::class ); + + $services->addServiceManipulator( $name, function () { + return 'Foo'; + } ); + } + public function testDisableService() { $services = $this->newServiceContainer( [ 'Foo' ] ); -- 2.20.1