Merge "Simplify HTMLTitleTextField::validate"
[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 // Break circular references due to the $this reference in closures, by
104 // erasing the instantiator array. This allows the ServiceContainer to
105 // be deleted when it goes out of scope.
106 $this->serviceInstantiators = [];
107 // Also remove the services themselves, to avoid confusion.
108 $this->services = [];
109 $this->destroyed = true;
110 }
111
112 /**
113 * @param array $wiringFiles A list of PHP files to load wiring information from.
114 * Each file is loaded using PHP's include mechanism. Each file is expected to
115 * return an associative array that maps service names to instantiator functions.
116 */
117 public function loadWiringFiles( array $wiringFiles ) {
118 foreach ( $wiringFiles as $file ) {
119 // the wiring file is required to return an array of instantiators.
120 $wiring = require $file;
121
122 Assert::postcondition(
123 is_array( $wiring ),
124 "Wiring file $file is expected to return an array!"
125 );
126
127 $this->applyWiring( $wiring );
128 }
129 }
130
131 /**
132 * Registers multiple services (aka a "wiring").
133 *
134 * @param array $serviceInstantiators An associative array mapping service names to
135 * instantiator functions.
136 */
137 public function applyWiring( array $serviceInstantiators ) {
138 Assert::parameterElementType( 'callable', $serviceInstantiators, '$serviceInstantiators' );
139
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, $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( $name ) {
192 return isset( $this->serviceInstantiators[$name] );
193 }
194
195 /**
196 * Returns the service instance for $name only if that service has already been instantiated.
197 * This is intended for situations where services get destroyed/cleaned up, so we can
198 * avoid creating a service just to destroy it again.
199 *
200 * @note This is intended for internal use and for test fixtures.
201 * Application logic should use getService() instead.
202 *
203 * @see getService().
204 *
205 * @param string $name
206 *
207 * @return object|null The service instance, or null if the service has not yet been instantiated.
208 * @throws RuntimeException if $name does not refer to a known service.
209 */
210 public function peekService( $name ) {
211 if ( !$this->hasService( $name ) ) {
212 throw new NoSuchServiceException( $name );
213 }
214
215 return $this->services[$name] ?? null;
216 }
217
218 /**
219 * @return string[]
220 */
221 public function getServiceNames() {
222 return array_keys( $this->serviceInstantiators );
223 }
224
225 /**
226 * Define a new service. The service must not be known already.
227 *
228 * @see getService().
229 * @see redefineService().
230 *
231 * @param string $name The name of the service to register, for use with getService().
232 * @param callable $instantiator Callback that returns a service instance.
233 * Will be called with this MediaWikiServices instance as the only parameter.
234 * Any extra instantiation parameters provided to the constructor will be
235 * passed as subsequent parameters when invoking the instantiator.
236 *
237 * @throws RuntimeException if there is already a service registered as $name.
238 */
239 public function defineService( $name, callable $instantiator ) {
240 Assert::parameterType( 'string', $name, '$name' );
241
242 if ( $this->hasService( $name ) ) {
243 throw new ServiceAlreadyDefinedException( $name );
244 }
245
246 $this->serviceInstantiators[$name] = $instantiator;
247 }
248
249 /**
250 * Replace an already defined service.
251 *
252 * @see defineService().
253 *
254 * @note This will fail if the service was already instantiated. If the service was previously
255 * disabled, it will be re-enabled by this call. Any manipulators registered for the service
256 * will remain in place.
257 *
258 * @param string $name The name of the service to register.
259 * @param callable $instantiator Callback function that returns a service instance.
260 * Will be called with this MediaWikiServices instance as the only parameter.
261 * The instantiator must return a service compatible with the originally defined service.
262 * Any extra instantiation parameters provided to the constructor will be
263 * passed as subsequent parameters when invoking the instantiator.
264 *
265 * @throws NoSuchServiceException if $name is not a known service.
266 * @throws CannotReplaceActiveServiceException if the service was already instantiated.
267 */
268 public function redefineService( $name, callable $instantiator ) {
269 Assert::parameterType( 'string', $name, '$name' );
270
271 if ( !$this->hasService( $name ) ) {
272 throw new NoSuchServiceException( $name );
273 }
274
275 if ( isset( $this->services[$name] ) ) {
276 throw new CannotReplaceActiveServiceException( $name );
277 }
278
279 $this->serviceInstantiators[$name] = $instantiator;
280 unset( $this->disabled[$name] );
281 }
282
283 /**
284 * Add a service manipulator callback for the given service.
285 * This method may be used by extensions that need to wrap, replace, or re-configure a
286 * service. It would typically be called from a MediaWikiServices hook handler.
287 *
288 * The manipulator callback is called just after the service is instantiated.
289 * It can call methods on the service to change configuration, or wrap or otherwise
290 * replace it.
291 *
292 * @see defineService().
293 * @see redefineService().
294 *
295 * @note This will fail if the service was already instantiated.
296 *
297 * @since 1.32
298 *
299 * @param string $name The name of the service to manipulate.
300 * @param callable $manipulator Callback function that manipulates, wraps or replaces a
301 * service instance. The callback receives the new service instance and this the
302 * ServiceContainer as parameters, as well as any extra instantiation parameters specified
303 * when constructing this ServiceContainer. If the callback returns a value, that
304 * value replaces the original service instance.
305 *
306 * @throws NoSuchServiceException if $name is not a known service.
307 * @throws CannotReplaceActiveServiceException if the service was already instantiated.
308 */
309 public function addServiceManipulator( $name, callable $manipulator ) {
310 Assert::parameterType( 'string', $name, '$name' );
311
312 if ( !$this->hasService( $name ) ) {
313 throw new NoSuchServiceException( $name );
314 }
315
316 if ( isset( $this->services[$name] ) ) {
317 throw new CannotReplaceActiveServiceException( $name );
318 }
319
320 $this->serviceManipulators[$name][] = $manipulator;
321 }
322
323 /**
324 * Disables a service.
325 *
326 * @note Attempts to call getService() for a disabled service will result
327 * in a DisabledServiceException. Calling peekService for a disabled service will
328 * return null. Disabled services are listed by getServiceNames(). A disabled service
329 * can be enabled again using redefineService().
330 *
331 * @note If the service was already active (that is, instantiated) when getting disabled,
332 * and the service instance implements DestructibleService, destroy() is called on the
333 * service instance.
334 *
335 * @see redefineService()
336 * @see resetService()
337 *
338 * @param string $name The name of the service to disable.
339 *
340 * @throws RuntimeException if $name is not a known service.
341 */
342 public function disableService( $name ) {
343 $this->resetService( $name );
344
345 $this->disabled[$name] = true;
346 }
347
348 /**
349 * Resets a service by dropping the service instance.
350 * If the service instances implements DestructibleService, destroy()
351 * is called on the service instance.
352 *
353 * @warning This is generally unsafe! Other services may still retain references
354 * to the stale service instance, leading to failures and inconsistencies. Subclasses
355 * may use this method to reset specific services under specific instances, but
356 * it should not be exposed to application logic.
357 *
358 * @note This is declared final so subclasses can not interfere with the expectations
359 * disableService() has when calling resetService().
360 *
361 * @see redefineService()
362 * @see disableService().
363 *
364 * @param string $name The name of the service to reset.
365 * @param bool $destroy Whether the service instance should be destroyed if it exists.
366 * When set to false, any existing service instance will effectively be detached
367 * from the container.
368 *
369 * @throws RuntimeException if $name is not a known service.
370 */
371 final protected function resetService( $name, $destroy = true ) {
372 Assert::parameterType( 'string', $name, '$name' );
373
374 $instance = $this->peekService( $name );
375
376 if ( $destroy && $instance instanceof DestructibleService ) {
377 $instance->destroy();
378 }
379
380 unset( $this->services[$name] );
381 unset( $this->disabled[$name] );
382 }
383
384 /**
385 * Returns a service object of the kind associated with $name.
386 * Services instances are instantiated lazily, on demand.
387 * This method may or may not return the same service instance
388 * when called multiple times with the same $name.
389 *
390 * @note Rather than calling this method directly, it is recommended to provide
391 * getters with more meaningful names and more specific return types, using
392 * a subclass or wrapper.
393 *
394 * @see redefineService().
395 *
396 * @param string $name The service name
397 *
398 * @throws NoSuchServiceException if $name is not a known service.
399 * @throws ContainerDisabledException if this container has already been destroyed.
400 * @throws ServiceDisabledException if the requested service has been disabled.
401 *
402 * @return object The service instance
403 */
404 public function getService( $name ) {
405 if ( $this->destroyed ) {
406 throw new ContainerDisabledException();
407 }
408
409 if ( isset( $this->disabled[$name] ) ) {
410 throw new ServiceDisabledException( $name );
411 }
412
413 if ( !isset( $this->services[$name] ) ) {
414 $this->services[$name] = $this->createService( $name );
415 }
416
417 return $this->services[$name];
418 }
419
420 /**
421 * @param string $name
422 *
423 * @throws InvalidArgumentException if $name is not a known service.
424 * @return object
425 */
426 private function createService( $name ) {
427 if ( isset( $this->serviceInstantiators[$name] ) ) {
428 $service = ( $this->serviceInstantiators[$name] )(
429 $this,
430 ...$this->extraInstantiationParams
431 );
432
433 if ( isset( $this->serviceManipulators[$name] ) ) {
434 foreach ( $this->serviceManipulators[$name] as $callback ) {
435 $ret = call_user_func_array(
436 $callback,
437 array_merge( [ $service, $this ], $this->extraInstantiationParams )
438 );
439
440 // If the manipulator callback returns an object, that object replaces
441 // the original service instance. This allows the manipulator to wrap
442 // or fully replace the service.
443 if ( $ret !== null ) {
444 $service = $ret;
445 }
446 }
447 }
448
449 // NOTE: when adding more wiring logic here, make sure importWiring() is kept in sync!
450 } else {
451 throw new NoSuchServiceException( $name );
452 }
453
454 return $service;
455 }
456
457 /**
458 * @param string $name
459 * @return bool Whether the service is disabled
460 * @since 1.28
461 */
462 public function isServiceDisabled( $name ) {
463 return isset( $this->disabled[$name] );
464 }
465 }