ServiceContainer.php 16 KB

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