Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
100.00% covered (success)
100.00%
87 / 87
100.00% covered (success)
100.00%
18 / 18
CRAP
100.00% covered (success)
100.00%
1 / 1
ServiceContainer
100.00% covered (success)
100.00%
87 / 87
100.00% covered (success)
100.00%
18 / 18
41
100.00% covered (success)
100.00%
1 / 1
 __construct
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 destroy
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
3
 loadWiringFiles
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
3
 applyWiring
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
2
 importWiring
100.00% covered (success)
100.00%
19 / 19
100.00% covered (success)
100.00%
1 / 1
3
 hasService
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 has
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 peekService
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 getServiceNames
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 defineService
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 redefineService
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
3
 addServiceManipulator
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
3
 disableService
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 resetService
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
3
 getService
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
4
 get
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 createService
100.00% covered (success)
100.00%
18 / 18
100.00% covered (success)
100.00%
1 / 1
6
 isServiceDisabled
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
1<?php
2declare( strict_types = 1 );
3
4/**
5 * Generic service container.
6 *
7 * @license GPL-2.0-or-later
8 * @file
9 */
10
11namespace Wikimedia\Services;
12
13use LogicException;
14use Psr\Container\ContainerInterface;
15
16/**
17 * ServiceContainer provides a generic service to manage named services using
18 * lazy instantiation based on instantiator callback functions.
19 *
20 * Services managed by an instance of ServiceContainer may or may not implement
21 * a common interface.
22 *
23 * @note When using ServiceContainer to manage a set of services, consider
24 * creating a wrapper or a subclass that provides access to the services via
25 * getter methods with more meaningful names and more specific return type
26 * declarations.
27 *
28 * @see MediaWiki core's docs/Injection.md for an overview of using dependency
29 *      injection in that code base.
30 */
31class ServiceContainer implements ContainerInterface, DestructibleService {
32
33    /**
34     * @var array<string,mixed>
35     */
36    private $services = [];
37
38    /**
39     * @var array<string,callable>
40     */
41    private $serviceInstantiators = [];
42
43    /**
44     * @var array<string,callable[]>
45     */
46    private $serviceManipulators = [];
47
48    /**
49     * @var array<string,true> Set of services that got disabled via {@see disableService}
50     */
51    private $disabled = [];
52
53    /**
54     * @var array
55     */
56    private $extraInstantiationParams;
57
58    /**
59     * @var bool
60     */
61    private $destroyed = false;
62
63    /**
64     * @var array<string,true> Set of services currently being created, to detect loops
65     */
66    private $servicesBeingCreated = [];
67
68    /**
69     * @param array $extraInstantiationParams Any additional parameters to be passed to the
70     * instantiator function when creating a service. This is typically used to provide
71     * access to additional ServiceContainers or Config objects.
72     */
73    public function __construct( array $extraInstantiationParams = [] ) {
74        $this->extraInstantiationParams = $extraInstantiationParams;
75    }
76
77    /**
78     * Destroys all contained service instances that implement the DestructibleService
79     * interface. This will render all services obtained from this ServiceContainer
80     * instance unusable. In particular, this will disable access to the storage backend
81     * via any of these services. Any future call to getService() will throw an exception.
82     *
83     * @see MediaWikiServices::resetGlobalInstance()
84     */
85    public function destroy() {
86        foreach ( $this->services as $service ) {
87            if ( $service instanceof DestructibleService ) {
88                $service->destroy();
89            }
90        }
91
92        // Break circular references due to the $this reference in closures, by
93        // erasing the instantiator array. This allows the ServiceContainer to
94        // be deleted when it goes out of scope.
95        $this->serviceInstantiators = [];
96        // Also remove the services themselves, to avoid confusion.
97        $this->services = [];
98        $this->destroyed = true;
99    }
100
101    /**
102     * @param string[] $wiringFiles A list of PHP files to load wiring information from.
103     * Each file is loaded using PHP's include mechanism. Each file is expected to
104     * return an associative array that maps service names to instantiator functions.
105     */
106    public function loadWiringFiles( array $wiringFiles ) {
107        foreach ( $wiringFiles as $file ) {
108            // the wiring file is required to return an array of instantiators.
109            $wiring = require $file;
110
111            if ( !is_array( $wiring ) ) {
112                throw new LogicException( "Wiring file $file must return an array" );
113            }
114
115            $this->applyWiring( $wiring );
116        }
117    }
118
119    /**
120     * Registers multiple services (aka a "wiring").
121     *
122     * @param array<string,callable> $serviceInstantiators An associative array mapping service names to
123     *        instantiator functions.
124     */
125    public function applyWiring( array $serviceInstantiators ) {
126        foreach ( $serviceInstantiators as $name => $instantiator ) {
127            $this->defineService( (string)$name, $instantiator );
128        }
129    }
130
131    /**
132     * Imports all wiring defined in $container. Wiring defined in $container
133     * will override any wiring already defined locally. However, already
134     * existing service instances will be preserved.
135     *
136     * @since 1.28
137     *
138     * @param ServiceContainer $container
139     * @param string[] $skip A list of service names to skip during import
140     */
141    public function importWiring( ServiceContainer $container, array $skip = [] ) {
142        $newInstantiators = array_diff_key(
143            $container->serviceInstantiators,
144            array_flip( $skip )
145        );
146
147        $this->serviceInstantiators = array_merge(
148            $this->serviceInstantiators,
149            $newInstantiators
150        );
151
152        $newManipulators = array_diff(
153            array_keys( $container->serviceManipulators ),
154            $skip
155        );
156
157        foreach ( $newManipulators as $name ) {
158            if ( isset( $this->serviceManipulators[$name] ) ) {
159                $this->serviceManipulators[$name] = array_merge(
160                    $this->serviceManipulators[$name],
161                    $container->serviceManipulators[$name]
162                );
163            } else {
164                $this->serviceManipulators[$name] = $container->serviceManipulators[$name];
165            }
166        }
167    }
168
169    /**
170     * Returns true if a service is defined for $name, that is, if a call to getService( $name )
171     * would return a service instance.
172     *
173     * @param string $name
174     *
175     * @return bool
176     */
177    public function hasService( string $name ): bool {
178        return isset( $this->serviceInstantiators[$name] );
179    }
180
181    /** @inheritDoc */
182    public function has( string $name ): bool {
183        return $this->hasService( $name );
184    }
185
186    /**
187     * Returns the service instance for $name only if that service has already been instantiated.
188     * This is intended for situations where services get destroyed/cleaned up, so we can
189     * avoid creating a service just to destroy it again.
190     *
191     * @note This is intended for internal use and for test fixtures.
192     * Application logic should use getService() instead.
193     *
194     * @see getService().
195     *
196     * @param string $name
197     *
198     * @return mixed|null The service instance, or null if the service has not yet been instantiated.
199     * @throws NoSuchServiceException if $name does not refer to a known service.
200     */
201    public function peekService( string $name ) {
202        if ( !$this->hasService( $name ) ) {
203            throw new NoSuchServiceException( $name );
204        }
205
206        return $this->services[$name] ?? null;
207    }
208
209    /**
210     * @return string[]
211     */
212    public function getServiceNames(): array {
213        return array_map( strval( ... ), array_keys( $this->serviceInstantiators ) );
214    }
215
216    /**
217     * Define a new service. The service must not be known already.
218     *
219     * @see getService().
220     * @see redefineService().
221     *
222     * @param string $name The name of the service to register, for use with getService().
223     * @param callable $instantiator Callback that returns a service instance.
224     *        Will be called with this ServiceContainer instance as the only parameter.
225     *        Any extra instantiation parameters provided to the constructor will be
226     *        passed as subsequent parameters when invoking the instantiator.
227     *
228     * @throws ServiceAlreadyDefinedException if there is already a service registered as $name.
229     */
230    public function defineService( string $name, callable $instantiator ) {
231        if ( $this->hasService( $name ) ) {
232            throw new ServiceAlreadyDefinedException( $name );
233        }
234
235        $this->serviceInstantiators[$name] = $instantiator;
236    }
237
238    /**
239     * Replace an already defined service.
240     *
241     * @see defineService().
242     *
243     * @note This will fail if the service was already instantiated. If the service was previously
244     * disabled, it will be re-enabled by this call. Any manipulators registered for the service
245     * will remain in place.
246     *
247     * @param string $name The name of the service to register.
248     * @param callable $instantiator Callback function that returns a service instance.
249     *        Will be called with this ServiceContainer instance as the only parameter.
250     *        The instantiator must return a service compatible with the originally defined service.
251     *        Any extra instantiation parameters provided to the constructor will be
252     *        passed as subsequent parameters when invoking the instantiator.
253     *
254     * @throws NoSuchServiceException if $name is not a known service.
255     * @throws CannotReplaceActiveServiceException if the service was already instantiated.
256     */
257    public function redefineService( string $name, callable $instantiator ) {
258        if ( !$this->hasService( $name ) ) {
259            throw new NoSuchServiceException( $name );
260        }
261
262        if ( isset( $this->services[$name] ) ) {
263            throw new CannotReplaceActiveServiceException( $name );
264        }
265
266        $this->serviceInstantiators[$name] = $instantiator;
267        unset( $this->disabled[$name] );
268    }
269
270    /**
271     * Add a service manipulator callback for the given service.
272     * This method may be used by extensions that need to wrap, replace, or re-configure a
273     * service. It would typically be called from a MediaWikiServices hook handler.
274     *
275     * The manipulator callback is called just after the service is instantiated.
276     * It can call methods on the service to change configuration, or wrap or otherwise
277     * replace it.
278     *
279     * @see defineService().
280     * @see redefineService().
281     *
282     * @note This will fail if the service was already instantiated.
283     *
284     * @since 1.32
285     *
286     * @param string $name The name of the service to manipulate.
287     * @param callable $manipulator Callback function that manipulates, wraps or replaces a
288     * service instance. The callback receives the new service instance and this
289     * ServiceContainer as parameters, as well as any extra instantiation parameters specified
290     * when constructing this ServiceContainer. If the callback returns a value, that
291     * value replaces the original service instance.
292     *
293     * @throws NoSuchServiceException if $name is not a known service.
294     * @throws CannotReplaceActiveServiceException if the service was already instantiated.
295     */
296    public function addServiceManipulator( string $name, callable $manipulator ) {
297        if ( !$this->hasService( $name ) ) {
298            throw new NoSuchServiceException( $name );
299        }
300
301        if ( isset( $this->services[$name] ) ) {
302            throw new CannotReplaceActiveServiceException( $name );
303        }
304
305        $this->serviceManipulators[$name][] = $manipulator;
306    }
307
308    /**
309     * Disables a service.
310     *
311     * @note Attempts to call getService() for a disabled service will result
312     * in a DisabledServiceException. Calling peekService for a disabled service will
313     * return null. Disabled services are listed by getServiceNames(). A disabled service
314     * can be enabled again using redefineService().
315     *
316     * @note If the service was already active (that is, instantiated) when getting disabled,
317     * and the service instance implements DestructibleService, destroy() is called on the
318     * service instance.
319     *
320     * @see redefineService()
321     * @see resetService()
322     *
323     * @param string $name The name of the service to disable.
324     */
325    public function disableService( string $name ) {
326        $this->resetService( $name );
327
328        $this->disabled[$name] = true;
329    }
330
331    /**
332     * Resets a service by dropping the service instance.
333     * If the service instance implements DestructibleService, destroy()
334     * is called on the service instance.
335     *
336     * @warning This is generally unsafe! Other services may still retain references
337     * to the stale service instance, leading to failures and inconsistencies. Subclasses
338     * may use this method to reset specific services under specific instances, but
339     * it should not be exposed to application logic.
340     *
341     * @note This is declared final so subclasses can not interfere with the expectations
342     * disableService() has when calling resetService().
343     *
344     * @see redefineService()
345     * @see disableService().
346     *
347     * @param string $name The name of the service to reset.
348     * @param bool $destroy Whether the service instance should be destroyed if it exists.
349     *        When set to false, any existing service instance will effectively be detached
350     *        from the container.
351     */
352    final protected function resetService( string $name, bool $destroy = true ) {
353        $instance = $this->services[$name] ?? null;
354
355        if ( $destroy && $instance instanceof DestructibleService ) {
356            $instance->destroy();
357        }
358
359        unset( $this->services[$name] );
360        unset( $this->disabled[$name] );
361    }
362
363    /**
364     * Returns a service of the kind associated with $name.
365     * Services instances are instantiated lazily, on demand.
366     * This method may or may not return the same service instance
367     * when called multiple times with the same $name.
368     *
369     * @note Rather than calling this method directly, it is recommended to provide
370     * getters with more meaningful names and more specific return types, using
371     * a subclass or wrapper.
372     *
373     * @see redefineService().
374     *
375     * @param string $name The service name
376     *
377     * @throws NoSuchServiceException if $name is not a known service.
378     * @throws ContainerDisabledException if this container has already been destroyed.
379     * @throws ServiceDisabledException if the requested service has been disabled.
380     * @return mixed The service instance
381     */
382    public function getService( string $name ) {
383        if ( $this->destroyed ) {
384            throw new ContainerDisabledException();
385        }
386
387        if ( isset( $this->disabled[$name] ) ) {
388            throw new ServiceDisabledException( $name );
389        }
390
391        if ( !isset( $this->services[$name] ) ) {
392            $this->services[$name] = $this->createService( $name );
393        }
394
395        return $this->services[$name];
396    }
397
398    /** @inheritDoc */
399    public function get( string $name ) {
400        return $this->getService( $name );
401    }
402
403    /**
404     * @param string $name
405     *
406     * @throws NoSuchServiceException if $name is not a known service.
407     * @throws RecursiveServiceDependencyException if a circular dependency is detected.
408     * @return mixed
409     */
410    private function createService( string $name ) {
411        if ( !isset( $this->serviceInstantiators[$name] ) ) {
412            throw new NoSuchServiceException( $name );
413        }
414
415        if ( isset( $this->servicesBeingCreated[$name] ) ) {
416            throw new RecursiveServiceDependencyException(
417                "Circular dependency when creating service! " .
418                implode( ' -> ', array_keys( $this->servicesBeingCreated ) ) . " -> $name" );
419        }
420
421        $this->servicesBeingCreated[$name] = true;
422        try {
423            $service = ( $this->serviceInstantiators[$name] )(
424                $this,
425                ...$this->extraInstantiationParams
426            );
427            if ( isset( $this->serviceManipulators[$name] ) ) {
428                foreach ( $this->serviceManipulators[$name] as $manipulator ) {
429                    $ret = $manipulator( $service, $this, ...$this->extraInstantiationParams );
430
431                    // If the manipulator callback returns a value, that replaces the original service.
432                    // This allows the manipulator to wrap or fully replace the service.
433                    if ( $ret !== null ) {
434                        $service = $ret;
435                    }
436                }
437            }
438        } finally {
439            unset( $this->servicesBeingCreated[$name] );
440        }
441
442        // NOTE: when adding more wiring logic here, make sure importWiring() is kept in sync!
443
444        // @phan-suppress-next-line PhanPossiblyUndeclaredVariable Bug https://github.com/phan/phan/issues/4419
445        return $service;
446    }
447
448    /**
449     * @param string $name
450     * @return bool Whether the service is disabled
451     * @since 1.28
452     */
453    public function isServiceDisabled( string $name ): bool {
454        return isset( $this->disabled[$name] );
455    }
456}