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