Code Coverage
 
Classes and Traits
Functions and Methods
Lines
Total
0.00% covered (danger)
0.00%
0 / 1
83.33% covered (warning)
83.33%
15 / 18
CRAP
96.81% covered (success)
96.81%
91 / 94
ServiceContainer
0.00% covered (danger)
0.00%
0 / 1
83.33% covered (warning)
83.33%
15 / 18
42
96.81% covered (success)
96.81%
91 / 94
 __construct
100.00% covered (success)
100.00%
1 / 1
1
100.00% covered (success)
100.00%
2 / 2
 destroy
100.00% covered (success)
100.00%
1 / 1
4
100.00% covered (success)
100.00%
8 / 8
 loadWiringFiles
100.00% covered (success)
100.00%
1 / 1
3
100.00% covered (success)
100.00%
6 / 6
 applyWiring
100.00% covered (success)
100.00%
1 / 1
2
100.00% covered (success)
100.00%
3 / 3
 importWiring
100.00% covered (success)
100.00%
1 / 1
3
100.00% covered (success)
100.00%
16 / 16
 hasService
100.00% covered (success)
100.00%
1 / 1
1
100.00% covered (success)
100.00%
1 / 1
 has
0.00% covered (danger)
0.00%
0 / 1
2
0.00% covered (danger)
0.00%
0 / 1
 peekService
100.00% covered (success)
100.00%
1 / 1
2
100.00% covered (success)
100.00%
3 / 3
 getServiceNames
100.00% covered (success)
100.00%
1 / 1
1
100.00% covered (success)
100.00%
1 / 1
 defineService
100.00% covered (success)
100.00%
1 / 1
2
100.00% covered (success)
100.00%
4 / 4
 redefineService
100.00% covered (success)
100.00%
1 / 1
3
100.00% covered (success)
100.00%
7 / 7
 addServiceManipulator
100.00% covered (success)
100.00%
1 / 1
3
100.00% covered (success)
100.00%
6 / 6
 disableService
100.00% covered (success)
100.00%
1 / 1
1
100.00% covered (success)
100.00%
3 / 3
 resetService
100.00% covered (success)
100.00%
1 / 1
3
100.00% covered (success)
100.00%
6 / 6
 getService
100.00% covered (success)
100.00%
1 / 1
4
100.00% covered (success)
100.00%
7 / 7
 get
0.00% covered (danger)
0.00%
0 / 1
2
0.00% covered (danger)
0.00%
0 / 1
 createService
100.00% covered (success)
100.00%
1 / 1
6
100.00% covered (success)
100.00%
18 / 18
 isServiceDisabled
0.00% covered (danger)
0.00%
0 / 1
2
0.00% covered (danger)
0.00%
0 / 1
<?php
/**
 * Generic service container.
 *
 * This program is free software; you can redistribute it and/or modify
 * it under the terms of the GNU General Public License as published by
 * the Free Software Foundation; either version 2 of the License, or
 * (at your option) any later version.
 *
 * This program is distributed in the hope that it will be useful,
 * but WITHOUT ANY WARRANTY; without even the implied warranty of
 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
 * GNU General Public License for more details.
 *
 * You should have received a copy of the GNU General Public License along
 * with this program; if not, write to the Free Software Foundation, Inc.,
 * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
 * http://www.gnu.org/copyleft/gpl.html
 *
 * @file
 */
namespace Wikimedia\Services;
use LogicException;
use Psr\Container\ContainerInterface;
use RuntimeException;
use Wikimedia\ScopedCallback;
/**
 * ServiceContainer provides a generic service to manage named services using
 * lazy instantiation based on instantiator callback functions.
 *
 * Services managed by an instance of ServiceContainer may or may not implement
 * a common interface.
 *
 * @note When using ServiceContainer to manage a set of services, consider
 * creating a wrapper or a subclass that provides access to the services via
 * getter methods with more meaningful names and more specific return type
 * declarations.
 *
 * @see MediaWiki core's docs/Injection.md for an overview of using dependency
 *      injection in that code base.
 */
class ServiceContainer implements ContainerInterface, DestructibleService {
    /**
     * @var mixed[]
     */
    private $services = [];
    /**
     * @var callable[]
     */
    private $serviceInstantiators = [];
    /**
     * @var callable[][]
     */
    private $serviceManipulators = [];
    /**
     * @var true[] Set of services that got disabled via {@see disableService}
     */
    private $disabled = [];
    /**
     * @var array
     */
    private $extraInstantiationParams;
    /**
     * @var bool
     */
    private $destroyed = false;
    /**
     * @var true[] Set of services currently being created, to detect loops
     */
    private $servicesBeingCreated = [];
    /**
     * @param array $extraInstantiationParams Any additional parameters to be passed to the
     * instantiator function when creating a service. This is typically used to provide
     * access to additional ServiceContainers or Config objects.
     */
    public function __construct( array $extraInstantiationParams = [] ) {
        $this->extraInstantiationParams = $extraInstantiationParams;
    }
    /**
     * Destroys all contained service instances that implement the DestructibleService
     * interface. This will render all services obtained from this ServiceContainer
     * instance unusable. In particular, this will disable access to the storage backend
     * via any of these services. Any future call to getService() will throw an exception.
     *
     * @see MediaWikiServices::resetGlobalInstance()
     */
    public function destroy() {
        foreach ( $this->getServiceNames() as $name ) {
            $service = $this->peekService( $name );
            if ( $service !== null && $service instanceof DestructibleService ) {
                $service->destroy();
            }
        }
        // Break circular references due to the $this reference in closures, by
        // erasing the instantiator array. This allows the ServiceContainer to
        // be deleted when it goes out of scope.
        $this->serviceInstantiators = [];
        // Also remove the services themselves, to avoid confusion.
        $this->services = [];
        $this->destroyed = true;
    }
    /**
     * @param string[] $wiringFiles A list of PHP files to load wiring information from.
     * Each file is loaded using PHP's include mechanism. Each file is expected to
     * return an associative array that maps service names to instantiator functions.
     */
    public function loadWiringFiles( array $wiringFiles ) {
        foreach ( $wiringFiles as $file ) {
            // the wiring file is required to return an array of instantiators.
            $wiring = require $file;
            if ( !is_array( $wiring ) ) {
                throw new LogicException( "Wiring file $file must return an array" );
            }
            $this->applyWiring( $wiring );
        }
    }
    /**
     * Registers multiple services (aka a "wiring").
     *
     * @param callable[] $serviceInstantiators An associative array mapping service names to
     *        instantiator functions.
     */
    public function applyWiring( array $serviceInstantiators ) {
        foreach ( $serviceInstantiators as $name => $instantiator ) {
            $this->defineService( $name, $instantiator );
        }
    }
    /**
     * Imports all wiring defined in $container. Wiring defined in $container
     * will override any wiring already defined locally. However, already
     * existing service instances will be preserved.
     *
     * @since 1.28
     *
     * @param ServiceContainer $container
     * @param string[] $skip A list of service names to skip during import
     */
    public function importWiring( ServiceContainer $container, array $skip = [] ) {
        $newInstantiators = array_diff_key(
            $container->serviceInstantiators,
            array_flip( $skip )
        );
        $this->serviceInstantiators = array_merge(
            $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];
            }
        }
    }
    /**
     * Returns true if a service is defined for $name, that is, if a call to getService( $name )
     * would return a service instance.
     *
     * @param string $name
     *
     * @return bool
     */
    public function hasService( string $name ): bool {
        return isset( $this->serviceInstantiators[$name] );
    }
    /** @inheritDoc */
    public function has( string $name ): bool {
        return $this->hasService( $name );
    }
    /**
     * Returns the service instance for $name only if that service has already been instantiated.
     * This is intended for situations where services get destroyed/cleaned up, so we can
     * avoid creating a service just to destroy it again.
     *
     * @note This is intended for internal use and for test fixtures.
     * Application logic should use getService() instead.
     *
     * @see getService().
     *
     * @param string $name
     *
     * @return mixed|null The service instance, or null if the service has not yet been instantiated.
     * @throws RuntimeException if $name does not refer to a known service.
     */
    public function peekService( string $name ) {
        if ( !$this->hasService( $name ) ) {
            throw new NoSuchServiceException( $name );
        }
        return $this->services[$name] ?? null;
    }
    /**
     * @return string[]
     */
    public function getServiceNames(): array {
        return array_keys( $this->serviceInstantiators );
    }
    /**
     * Define a new service. The service must not be known already.
     *
     * @see getService().
     * @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.
     *        Will be called with this ServiceContainer instance as the only parameter.
     *        Any extra instantiation parameters provided to the constructor will be
     *        passed as subsequent parameters when invoking the instantiator.
     *
     * @throws RuntimeException if there is already a service registered as $name.
     */
    public function defineService( string $name, callable $instantiator ) {
        if ( $this->hasService( $name ) ) {
            throw new ServiceAlreadyDefinedException( $name );
        }
        $this->serviceInstantiators[$name] = $instantiator;
    }
    /**
     * Replace an already defined service.
     *
     * @see defineService().
     *
     * @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.
     *        Will be called with this ServiceContainer instance as the only parameter.
     *        The instantiator must return a service compatible with the originally defined service.
     *        Any extra instantiation parameters provided to the constructor will be
     *        passed as subsequent parameters when invoking the instantiator.
     *
     * @throws NoSuchServiceException if $name is not a known service.
     * @throws CannotReplaceActiveServiceException if the service was already instantiated.
     */
    public function redefineService( string $name, callable $instantiator ) {
        if ( !$this->hasService( $name ) ) {
            throw new NoSuchServiceException( $name );
        }
        if ( isset( $this->services[$name] ) ) {
            throw new CannotReplaceActiveServiceException( $name );
        }
        $this->serviceInstantiators[$name] = $instantiator;
        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
     * 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( string $name, callable $manipulator ) {
        if ( !$this->hasService( $name ) ) {
            throw new NoSuchServiceException( $name );
        }
        if ( isset( $this->services[$name] ) ) {
            throw new CannotReplaceActiveServiceException( $name );
        }
        $this->serviceManipulators[$name][] = $manipulator;
    }
    /**
     * Disables a service.
     *
     * @note Attempts to call getService() for a disabled service will result
     * in a DisabledServiceException. Calling peekService for a disabled service will
     * return null. Disabled services are listed by getServiceNames(). A disabled service
     * can be enabled again using redefineService().
     *
     * @note If the service was already active (that is, instantiated) when getting disabled,
     * and the service instance implements DestructibleService, destroy() is called on the
     * service instance.
     *
     * @see redefineService()
     * @see resetService()
     *
     * @param string $name The name of the service to disable.
     *
     * @throws RuntimeException if $name is not a known service.
     */
    public function disableService( string $name ) {
        $this->resetService( $name );
        $this->disabled[$name] = true;
    }
    /**
     * Resets a service by dropping the service instance.
     * If the service instances implements DestructibleService, destroy()
     * is called on the service instance.
     *
     * @warning This is generally unsafe! Other services may still retain references
     * to the stale service instance, leading to failures and inconsistencies. Subclasses
     * may use this method to reset specific services under specific instances, but
     * it should not be exposed to application logic.
     *
     * @note This is declared final so subclasses can not interfere with the expectations
     * disableService() has when calling resetService().
     *
     * @see redefineService()
     * @see disableService().
     *
     * @param string $name The name of the service to reset.
     * @param bool $destroy Whether the service instance should be destroyed if it exists.
     *        When set to false, any existing service instance will effectively be detached
     *        from the container.
     *
     * @throws RuntimeException if $name is not a known service.
     */
    final protected function resetService( string $name, bool $destroy = true ) {
        $instance = $this->peekService( $name );
        if ( $destroy && $instance instanceof DestructibleService ) {
            $instance->destroy();
        }
        unset( $this->services[$name] );
        unset( $this->disabled[$name] );
    }
    /**
     * Returns a service of the kind associated with $name.
     * Services instances are instantiated lazily, on demand.
     * This method may or may not return the same service instance
     * when called multiple times with the same $name.
     *
     * @note Rather than calling this method directly, it is recommended to provide
     * getters with more meaningful names and more specific return types, using
     * a subclass or wrapper.
     *
     * @see redefineService().
     *
     * @param string $name The service name
     *
     * @throws NoSuchServiceException if $name is not a known service.
     * @throws ContainerDisabledException if this container has already been destroyed.
     * @throws ServiceDisabledException if the requested service has been disabled.
     * @return mixed The service instance
     */
    public function getService( string $name ) {
        if ( $this->destroyed ) {
            throw new ContainerDisabledException();
        }
        if ( isset( $this->disabled[$name] ) ) {
            throw new ServiceDisabledException( $name );
        }
        if ( !isset( $this->services[$name] ) ) {
            $this->services[$name] = $this->createService( $name );
        }
        return $this->services[$name];
    }
    /** @inheritDoc */
    public function get( string $name ) {
        return $this->getService( $name );
    }
    /**
     * @param string $name
     *
     * @throws NoSuchServiceException if $name is not a known service.
     * @throws RecursiveServiceDependencyException if a circular dependency is detected.
     * @return mixed
     */
    private function createService( string $name ) {
        if ( !isset( $this->serviceInstantiators[$name] ) ) {
            throw new NoSuchServiceException( $name );
        }
        if ( isset( $this->servicesBeingCreated[$name] ) ) {
            throw new RecursiveServiceDependencyException(
                "Circular dependency when creating service! " .
                implode( ' -> ', array_keys( $this->servicesBeingCreated ) ) . " -> $name" );
        }
        $this->servicesBeingCreated[$name] = true;
        $removeFromStack = new ScopedCallback( function () use ( $name ) {
            unset( $this->servicesBeingCreated[$name] );
        } );
        $service = ( $this->serviceInstantiators[$name] )(
            $this,
            ...$this->extraInstantiationParams
        );
        if ( isset( $this->serviceManipulators[$name] ) ) {
            foreach ( $this->serviceManipulators[$name] as $manipulator ) {
                $ret = $manipulator( $service, $this, ...$this->extraInstantiationParams );
                // If the manipulator callback returns a value, that replaces the original service.
                // This allows the manipulator to wrap or fully replace the service.
                if ( $ret !== null ) {
                    $service = $ret;
                }
            }
        }
        $removeFromStack->consume();
        // NOTE: when adding more wiring logic here, make sure importWiring() is kept in sync!
        return $service;
    }
    /**
     * @param string $name
     * @return bool Whether the service is disabled
     * @since 1.28
     */
    public function isServiceDisabled( string $name ): bool {
        return isset( $this->disabled[$name] );
    }
}