Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
Total | |
96.67% |
87 / 90 |
|
83.33% |
15 / 18 |
CRAP | |
0.00% |
0 / 1 |
ServiceContainer | |
96.67% |
87 / 90 |
|
83.33% |
15 / 18 |
41 | |
0.00% |
0 / 1 |
__construct | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
destroy | |
100.00% |
6 / 6 |
|
100.00% |
1 / 1 |
3 | |||
loadWiringFiles | |
100.00% |
5 / 5 |
|
100.00% |
1 / 1 |
3 | |||
applyWiring | |
100.00% |
2 / 2 |
|
100.00% |
1 / 1 |
2 | |||
importWiring | |
100.00% |
19 / 19 |
|
100.00% |
1 / 1 |
3 | |||
hasService | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
has | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
peekService | |
100.00% |
3 / 3 |
|
100.00% |
1 / 1 |
2 | |||
getServiceNames | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
defineService | |
100.00% |
3 / 3 |
|
100.00% |
1 / 1 |
2 | |||
redefineService | |
100.00% |
6 / 6 |
|
100.00% |
1 / 1 |
3 | |||
addServiceManipulator | |
100.00% |
5 / 5 |
|
100.00% |
1 / 1 |
3 | |||
disableService | |
100.00% |
2 / 2 |
|
100.00% |
1 / 1 |
1 | |||
resetService | |
100.00% |
5 / 5 |
|
100.00% |
1 / 1 |
3 | |||
getService | |
100.00% |
7 / 7 |
|
100.00% |
1 / 1 |
4 | |||
get | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
createService | |
100.00% |
21 / 21 |
|
100.00% |
1 / 1 |
6 | |||
isServiceDisabled | |
0.00% |
0 / 1 |
|
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 | |
23 | namespace Wikimedia\Services; |
24 | |
25 | use LogicException; |
26 | use Psr\Container\ContainerInterface; |
27 | use RuntimeException; |
28 | use 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 | */ |
45 | class 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 | } |