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