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