-
Notifications
You must be signed in to change notification settings - Fork 6
Expand file tree
/
Copy pathInjector.php
More file actions
350 lines (329 loc) · 14.2 KB
/
Copy pathInjector.php
File metadata and controls
350 lines (329 loc) · 14.2 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
<?php
namespace Bigcommerce\Injector;
use Bigcommerce\Injector\Exception\InjectorInvocationException;
use Bigcommerce\Injector\Exception\MissingRequiredParameterException;
use Bigcommerce\Injector\Reflection\ClassInspectorInterface;
use InvalidArgumentException;
use Psr\Container\ContainerInterface;
use ReflectionException;
/**
* The Injector provides instantiation of objects (or invocation of methods) within the BC application and
* automatically injects dependencies from the IoC container. It behaves as a factory for any class wiring dependencies
* JIT which serves two primary purposes:
* - Binding of service definitions within the IoC container - allowing constructor signatures to define their
* dependencies and in most cases reducing the touch-points required for refactors.
* - Construction of objects with dependencies served by the IoC container during the post-bootstrap application
* lifecycle (such as factories building command objects dynamically) without passing around the IoC container
* to avoid Service Location/Implicit Dependencies
*
* NOTE: The second use case should ONLY apply when objects that depend on services need to be constructed dynamically.
* You should generally strive to construct your entire dependency object graph at construction rather than dynamically
* to ensure dependencies are clear.
*
* Return type hinting is provided for all constructed objects in IntelliJ/PHPStorm via the dynamicReturnTypes
* extension. Make sure you install it if you are using the injector to provide IDE hinting.
* @package \Bigcommerce\Injector
*/
class Injector implements InjectorInterface
{
/**
* Regular Expressions matching dependencies that can be automatically created using their class name, even if they
* are not defined in the IoC Container.
*
* @var string[]
*/
protected array $autoCreateWhiteList = [];
/**
* Cached results of canAutoCreate calls
*
* @var array<string, bool>
*/
private array $autoCreateCache = [];
/**
* Cached results of container->has() calls per type.
*
* The container is typically sealed at bootstrap, so whether a given FQCN
* is registered doesn't change during a request. Caching the boolean here
* eliminates a PSR-11 has() call on every subsequent injection of the same
* typed dependency.
*
* Stored as true|false so that false (type absent) is distinguishable from
* null (not yet looked up) via the null-coalescing operator.
*
* @var array<string, bool>
*/
private array $containerHasCache = [];
public function __construct(private readonly ContainerInterface $container, private readonly ClassInspectorInterface $classInspector)
{
}
/**
* Instantiate an object and attempt to inject the dependencies for the class by mapping constructor parameter \
* names to objects registered within the IoC container.
*
* The optional $parameters passed to this method accept and will inject values based on:
* - Type: [Cache::class => new RedisCache()] will inject RedisCache to each parameter typed Cache::class
* - Name: ["cache" => new RedisCache()] will inject RedisCache to the parameter named $cache
* - Index: [ 3 => new RedisCache()] will inject RedisCache to the 4th parameter (zero index)
*
* @param string $className The fully qualified class name for the object we're creating
* @param array $parameters An optional array of additional parameters to pass to the created objects constructor.
* @return object
* @throws InjectorInvocationException
* @throws InvalidArgumentException
* @throws ReflectionException
*/
public function create($className, $parameters = [])
{
$signature = $this->classInspector->getCallableConstructorSignature($className);
if ($signature === null) {
return new $className();
}
if ($signature === false) {
throw new InjectorInvocationException(
"Injector failed to create $className - constructor isn't public." .
" Do you need to use a static factory method instead?"
);
}
try {
// Fast path: skip the extra method call for the common autowiring case
if (empty($parameters)) {
return new $className(...$this->buildParameterArrayFromContainer($signature));
}
return new $className(...$this->buildParameterArray($signature, $parameters));
} catch (MissingRequiredParameterException $e) {
throw new InjectorInvocationException(
"Can't create $className " .
" - __construct() missing parameter '" . $e->getParameterString() . "'" .
" could not be found. Either register it as a service or pass it to create via parameters."
);
} catch (InjectorInvocationException $e) {
//Wrap the exception stack for recursive calls to aid debugging
throw new InjectorInvocationException(
$e->getMessage() .
PHP_EOL . " => (Called when creating $className)"
);
}
}
/**
* Call a method with auto dependency injection from the IoC container. This is functionally equivalent to
* call_user_func_array with auto-wiring against the service container.
* Note: Whilst this method is useful for dynamic dispatch i.e controller actions, generally you should be
* calling methods concretely. Use this wisely and ensure you always document return types.
*
* The optional $parameters passed to this method accept and will inject values based on:
* - Type: [Cache::class => new RedisCache()] will inject RedisCache to each parameter typed Cache::class
* - Name: ["cache" => new RedisCache()] will inject RedisCache to the parameter named $cache
* - Index: [ 3 => new RedisCache()] will inject RedisCache to the 4th parameter (zero index)
*
* @param object $instance
* @param string $methodName
* @param array $parameters
* @return mixed
* @throws InjectorInvocationException
* @throws InvalidArgumentException
*/
public function invoke($instance, $methodName, $parameters = [])
{
if (!is_object($instance)) {
throw new InvalidArgumentException(
"Attempted Injector::invoke on a non-object: " . gettype($instance) . "."
);
}
$className = get_class($instance);
try {
$parameters = $this->buildParameterArray(
$this->classInspector->getMethodSignature($className, $methodName),
$parameters
);
return $instance->{$methodName}(...$parameters);
} catch (MissingRequiredParameterException $e) {
throw new InjectorInvocationException(
"Can't invoke method $className::$methodName()" .
" - missing parameter '" . $e->getParameterString() . "'" .
" could not be found. Either register it as a service or pass it to invoke via parameters."
);
} catch (ReflectionException $e) {
throw new InjectorInvocationException(
"Failed to invoke $className::$methodName - method doesn't exist."
);
}
}
/**
* Add a regular expression to match classes that the Injector is permitted to construct as dependencies for other
* objects its creating, even if they haven't been defined in the service container.
*
* @param string $regex
* @return void
*/
public function addAutoCreate($regex)
{
$this->autoCreateWhiteList[] = "/^" . $regex . "$/ims";
// clear cache when new patterns are added
$this->autoCreateCache = [];
}
/**
* @return \string[]
*/
public function getAutoCreateWhiteList()
{
return $this->autoCreateWhiteList;
}
/**
* Check whether the Injector has been configured to allow automatic construction of the given FQCN as a dependency
*
* @param string $className
* @return bool
*/
public function canAutoCreate($className): bool
{
// values are true/false (never null), so null means "not cached"
$cached = $this->autoCreateCache[$className] ?? null;
if ($cached !== null) {
return $cached;
}
foreach ($this->autoCreateWhiteList as $regex) {
if (preg_match($regex, $className)) {
$this->autoCreateCache[$className] = true;
return true;
}
}
$this->autoCreateCache[$className] = false;
return false;
}
/**
* Construct the parameter array to be passed to a method call based on its parameter signature
*
* @param array $methodSignature
* @param array $providedParameters
* @return array
* @throws InjectorInvocationException
* @throws MissingRequiredParameterException
* @throws InvalidArgumentException
* @throws ReflectionException
*/
private function buildParameterArray($methodSignature, $providedParameters)
{
// when no parameters are explicitly provided (the common autowiring case), skip all the name/index/type lookups
// against $providedParameters entirely
if (empty($providedParameters)) {
return $this->buildParameterArrayFromContainer($methodSignature);
}
$parameters = [];
foreach ($methodSignature as $position => $parameterData) {
if (!isset($parameterData['variadic'])) {
$parameters[$position] = $this->resolveParameter($position, $parameterData, $providedParameters);
} else {
// variadic parameter must be the last one, so
// the rest of the provided paramters should be piped
// into it to mimic native php behaviour
foreach ($providedParameters as $variadicParameter) {
$parameters[] = $variadicParameter;
}
}
}
return $parameters;
}
/**
* Fast path for the common case: resolve all parameters from the container, auto-create, or defaults
* Skips the 3 array_key_exists lookups per parameter that resolveParameter does against $providedParameters
*
* @param array $methodSignature
* @return array
* @throws MissingRequiredParameterException
* @throws InjectorInvocationException
* @throws ReflectionException
*/
private function buildParameterArrayFromContainer($methodSignature)
{
$parameters = [];
foreach ($methodSignature as $parameterData) {
if (isset($parameterData['variadic'])) {
// variadic with no provided params = nothing to pipe
break;
}
$type = $parameterData['type'] ?? false;
if ($type) {
$inContainer = $this->containerHasCache[$type] ?? null;
if ($inContainer === null) {
$inContainer = $this->container->has($type);
$this->containerHasCache[$type] = $inContainer;
}
if ($inContainer) {
$parameters[] = $this->container->get($type);
continue;
}
if ($this->canAutoCreate($type)) {
$parameters[] = $this->create($type);
continue;
}
}
if (array_key_exists('default', $parameterData)) {
$parameters[] = $parameterData['default'];
continue;
}
$name = $parameterData['name'];
throw new MissingRequiredParameterException(
$name,
$type,
sprintf('Could not find required parameter "%s" for method', $name)
);
}
return $parameters;
}
/**
* This method will hunt for dependencies to satisfy the parameter requirements in the following order:
* - Key name in provided parameters (named parameters)
* - Index in provided parameters
* - FQCN in provided parameters
* - FQCN in container
* - Default value against method signature
* - Auto create white list of classes to recursively create
* @param int $position
* @param array $parameterData
* @param array $providedParameters
* @throws MissingRequiredParameterException
* @return mixed The resolved parameter value
*/
private function resolveParameter($position, $parameterData, &$providedParameters)
{
$name = $parameterData['name'];
$type = $parameterData['type'] ?? false;
if (array_key_exists($name, $providedParameters)) {
// Found the dependency by name in providedParameters
$result = $providedParameters[$name];
unset($providedParameters[$name]);
return $result;
}
if (array_key_exists($position, $providedParameters)) {
// Found the dependency index in providedParameters
$result = $providedParameters[$position];
unset($providedParameters[$position]);
return $result;
}
if ($type) {
if (array_key_exists($type, $providedParameters)) {
// Found the dependency by type in providedParameters
$result = $providedParameters[$type];
unset($providedParameters[$type]);
return $result;
}
if ($this->container->has($type)) {
// Found the dependency by type in the container
return $this->container->get($type);
}
if ($this->canAutoCreate($type)) {
// Auto create white list - recursion
return $this->create($type);
}
}
if (array_key_exists("default", $parameterData)) {
// Default value defined in signature
return $parameterData['default'];
}
throw new MissingRequiredParameterException(
$name,
$type,
sprintf('Could not find required parameter "%s" for method', $name)
);
}
}