PluginProbe
404 Solution / trunk
404 Solution vtrunk
4.3.5 4.3.4 4.3.3 4.3.2 4.3.1 4.3.0 4.2.0 4.1.19 4.1.18 4.1.17 4.1.16 4.1.15 4.1.13 4.1.12 4.1.11 4.1.10 4.1.9 4.1.8 4.1.7 4.1.6 4.1.5 4.1.4 4.1.3 trunk 2.30.0 All 109 releases
404-solution / includes / core / ServiceContainer.php

ServiceContainer.php in 404 Solution trunk, at includes/core/ServiceContainer.php

407 lines 13.1 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3
4 if (!defined('ABSPATH')) {
5 exit;
6 }
7
8 /**
9 * Simple dependency injection container for managing service instances.
10 *
11 * This container provides a lightweight alternative to the singleton pattern,
12 * making dependencies explicit and enabling easier testing.
13 *
14 * Usage:
15 * $container = ABJ_404_Solution_ServiceContainer::getInstance();
16 * $service = $container->get('service_name');
17 *
18 * Or use the helper function:
19 * $service = abj_service('service_name');
20 *
21 * The global abj_service() helper is defined in
22 * includes/bootstrap/service-locator.php.
23 */
24 class ABJ_404_Solution_ServiceNotRegisteredException extends RuntimeException {
25
26 /** @var string */
27 private $serviceName;
28
29 /** @var string */
30 private $registrationClass;
31
32 /** @var string */
33 private $hint;
34
35 /**
36 * @param string $serviceName
37 * @param string $registrationClass
38 * @param \Throwable|null $previous
39 */
40 public function __construct($serviceName, $registrationClass, ?\Throwable $previous = null) {
41 $this->serviceName = (string)$serviceName;
42 $this->registrationClass = (string)$registrationClass;
43 $this->hint = 'Register service "' . $this->serviceName . '" in ' . $this->registrationClass
44 . ' or use abj_service_optional() when absence is an intentional bootstrap probe.';
45
46 parent::__construct($this->hint, 0, $previous);
47 }
48
49 /** @return string */
50 public function getServiceName() {
51 return $this->serviceName;
52 }
53
54 /** @return string */
55 public function getRegistrationClass() {
56 return $this->registrationClass;
57 }
58
59 /** @return string */
60 public function getHint() {
61 return $this->hint;
62 }
63 }
64
65 /**
66 * Thrown when a service factory transitively re-requests the service it is
67 * mid-construction of. Carries the resolution chain so the offending cycle is
68 * diagnosable from the message alone.
69 */
70 class ABJ_404_Solution_ServiceResolutionCycleException extends RuntimeException {
71
72 /** @var string */
73 private $serviceName;
74
75 /** @var string */
76 private $chain;
77
78 /**
79 * @param string $serviceName The service whose factory re-entered.
80 * @param string $chain Human-readable resolution chain (a -> b -> a).
81 */
82 public function __construct($serviceName, $chain) {
83 $this->serviceName = (string)$serviceName;
84 $this->chain = (string)$chain;
85 parent::__construct(
86 'Service resolution cycle detected while building "' . $this->serviceName
87 . '": ' . $this->chain
88 . '. A factory must not request the service it is constructing.'
89 );
90 }
91
92 /** @return string */
93 public function getServiceName() {
94 return $this->serviceName;
95 }
96
97 /** @return string */
98 public function getChain() {
99 return $this->chain;
100 }
101 }
102
103 class ABJ_404_Solution_ServiceContainer {
104
105 /**
106 * Singleton instance of the container itself.
107 * Note: The container is a singleton, but the services it manages can have any lifecycle.
108 */
109 /** @var self|null */
110 private static $instance = null;
111
112 /**
113 * Most recent Throwable suppressed by safeGet() / abj_service_optional(), or
114 * null when the last resolution succeeded. Recovered by diagnostic
115 * code via getLastSuppressedError().
116 *
117 * @var \Throwable|null
118 */
119 private static $lastSuppressedError = null;
120
121 /**
122 * Registered services and their factory functions.
123 * @var array<string, callable>
124 */
125 private $services = array();
126
127 /**
128 * Instantiated service instances (for singleton services).
129 * @var array<string, mixed>
130 */
131 private $instances = array();
132
133 /**
134 * Names whose factory is currently executing, in resolution order. Used to
135 * detect a service-resolution cycle (a factory that transitively re-requests
136 * itself) before it recurses into a stack overflow. Empty except mid-get().
137 * @var array<string, true>
138 */
139 private $resolving = array();
140
141 /**
142 * When true, registration fills gaps without replacing factories that a
143 * caller already installed before lazy bootstrap. Direct registration
144 * calls outside this guarded mode still replace services.
145 *
146 * @var bool
147 */
148 private $preserveExistingRegistrations = false;
149
150 /**
151 * Private constructor to enforce singleton pattern.
152 */
153 private function __construct() {
154 // Private constructor
155 }
156
157 /**
158 * Get the singleton instance of the container.
159 *
160 * @return ABJ_404_Solution_ServiceContainer
161 */
162 public static function getInstance() {
163 if (self::$instance === null) {
164 self::$instance = new self();
165 }
166 return self::$instance;
167 }
168
169 /**
170 * Register a service with a factory function.
171 *
172 * The factory function receives the container as its first parameter,
173 * allowing it to resolve dependencies.
174 *
175 * @param string $name Service identifier
176 * @param callable $factory Factory function that creates the service
177 * @return void
178 */
179 public function set($name, $factory) {
180 if (!is_callable($factory)) {
181 throw new InvalidArgumentException("Factory for service '$name' must be callable");
182 }
183 if ($this->preserveExistingRegistrations && isset($this->services[$name])) {
184 return;
185 }
186 $this->services[$name] = $factory;
187 // Clear any existing instance when re-registering
188 unset($this->instances[$name]);
189 }
190
191 /** @return void */
192 public function beginPreservingExistingRegistrations(): void {
193 $this->preserveExistingRegistrations = true;
194 }
195
196 /** @return void */
197 public function endPreservingExistingRegistrations(): void {
198 $this->preserveExistingRegistrations = false;
199 }
200
201 /**
202 * Get a service instance.
203 *
204 * Services are lazy-loaded - the factory function is only called
205 * the first time the service is requested.
206 *
207 * @param string $name Service identifier
208 * @return mixed The service instance
209 * @throws Exception if service is not registered
210 */
211 public function get($name) {
212 // Return existing instance if already created
213 if (isset($this->instances[$name])) {
214 return $this->instances[$name];
215 }
216
217 // Check if service is registered
218 if (!isset($this->services[$name])) {
219 throw new Exception("Service '$name' is not registered in the container"); // allow-raw-error: programmer assertion, callers either expect the throw or use safeGet()/abj_service_optional() which catch it
220 }
221
222 // Detect a resolution cycle before it recurses into a stack overflow.
223 // The instance is cached only AFTER its factory returns, so a factory
224 // that transitively re-requests the same service would otherwise re-run
225 // forever (a structural sibling of the 4.3.0 logging<->options OOM).
226 if (isset($this->resolving[$name])) {
227 $chain = implode(' -> ', array_keys($this->resolving)) . ' -> ' . $name;
228 throw new ABJ_404_Solution_ServiceResolutionCycleException($name, $chain);
229 }
230
231 // Create the instance using the factory
232 $factory = $this->services[$name];
233 $this->resolving[$name] = true;
234 try {
235 $instance = $factory($this);
236 } finally {
237 unset($this->resolving[$name]);
238 }
239
240 // Store the instance for future requests (singleton behavior)
241 $this->instances[$name] = $instance;
242
243 return $instance;
244 }
245
246 /**
247 * Check if a service is registered.
248 *
249 * @param string $name Service identifier
250 * @return bool
251 */
252 public function has($name) {
253 return isset($this->services[$name]);
254 }
255
256 /**
257 * Clear all services and instances.
258 * Useful for testing.
259 *
260 * @return void
261 */
262 public function clear() {
263 $this->services = array();
264 $this->instances = array();
265 }
266
267 /**
268 * Reset the container singleton instance.
269 * Useful for testing.
270 *
271 * @return void
272 */
273 public static function reset() {
274 self::$instance = null;
275 }
276
277 /**
278 * Non-throwing existence check. Returns true iff the container has a
279 * registered factory for the named service. Bootstraps the container
280 * singleton on demand so callers don't have to.
281 *
282 * @param string $name Service identifier
283 * @return bool
284 */
285 public static function safeHas($name) {
286 $c = self::getInstance();
287 return $c->has($name);
288 }
289
290 /**
291 * Non-throwing service resolution. Returns the resolved instance, or
292 * null if the service isn't registered or the factory raises any
293 * Throwable. Replaces the legacy `try { ServiceContainer::get(...) }
294 * catch { fall back } ` pattern at call sites - the swallow lives
295 * here, in one place.
296 *
297 * On failure the underlying Throwable is preserved two ways:
298 * 1. Full context (class, code, file:line, message) is written to
299 * the PHP error log so production sysadmins see it.
300 * 2. The Throwable instance is captured in self::$lastSuppressedError
301 * so diagnostic surfaces (admin notices, integration tests, the
302 * design-audit M401 fix in c367/368/369) can recover the full
303 * exception chain by calling self::getLastSuppressedError().
304 *
305 * @param string $name Service identifier
306 * @return mixed The service instance, or null on any failure
307 */
308 public static function safeGet($name) {
309 $c = self::getInstance();
310 if (!$c->has($name)) {
311 return null;
312 }
313 try {
314 $result = $c->get($name);
315 self::$lastSuppressedError = null;
316 return $result;
317 } catch (\Throwable $e) {
318 self::recordSuppressedError('ServiceContainer::safeGet(' . $name . ')', $e);
319 return null;
320 }
321 }
322
323 /**
324 * Returns the most recent Throwable that was suppressed by safeGet() or
325 * by the abj_service_optional() helper, or null if the last resolution succeeded.
326 *
327 * Diagnostic code should call this immediately after a null-return from
328 * safeGet()/abj_service_optional() to recover the full exception chain. The wrappers
329 * intentionally return null instead of throwing, but the underlying error
330 * is preserved here for inspection.
331 *
332 * @return \Throwable|null
333 */
334 public static function getLastSuppressedError() {
335 return self::$lastSuppressedError;
336 }
337
338 /**
339 * Reset the suppressed-error capture. Useful between tests and after a
340 * caller has handled a prior failure.
341 *
342 * @return void
343 */
344 public static function clearLastSuppressedError() {
345 self::$lastSuppressedError = null;
346 }
347
348 /**
349 * Internal: capture a suppressed Throwable for getLastSuppressedError()
350 * and emit a fully-contextualised PHP error-log line.
351 *
352 * Centralising the swallow-and-log behaviour here ensures every catch in
353 * this file records the exception class, file:line, code, and message,
354 * not just the message, so the exception chain is recoverable from
355 * production logs alone.
356 *
357 * @param string $context Short identifier for the call site
358 * (e.g. 'ServiceContainer::safeGet(foo)').
359 * @param \Throwable $e The suppressed exception.
360 * @return void
361 */
362 private static function recordSuppressedError($context, \Throwable $e) {
363 self::$lastSuppressedError = $e;
364 abj404_logPhpFallback('service-resolution-fallback', sprintf(
365 '%s suppressed %s (code %s) at %s:%d: %s',
366 $context,
367 get_class($e),
368 (string) $e->getCode(),
369 $e->getFile(),
370 $e->getLine(),
371 $e->getMessage()
372 ));
373 }
374
375 /**
376 * Public seam for the abj_service_optional() helper.
377 * Code outside the class cannot reach private statics, so this delegates
378 * to recordSuppressedError() and is otherwise identical. Not intended
379 * for call sites elsewhere in the codebase: use safeGet() instead.
380 *
381 * @param string $context
382 * @param \Throwable $e
383 * @return void
384 */
385 public static function recordSuppressedErrorPublic($context, \Throwable $e) {
386 self::recordSuppressedError($context, $e);
387 }
388
389 /**
390 * Returns true iff the container singleton has been instantiated AND
391 * at least one service factory has been registered. False during
392 * very early boot (autoload-only) or after `reset()` in tests.
393 *
394 * @return bool
395 */
396 public static function isInitialized() {
397 return self::$instance !== null && self::$instance->services !== array();
398 }
399 }
400
401 // Preserve the historical contract that loading core/ServiceContainer.php
402 // makes the global abj_service() helper available. Many tests require the
403 // container file directly to get that helper without also booting the
404 // production bootstrap.php. Production callers reach the same require via
405 // bootstrap.php; require_once makes either path safe.
406 require_once __DIR__ . '/../bootstrap/service-locator.php';
407