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