Introducing Annotated Container - Part 2
Over the last year I've been working off and on with a new project that I'm really excited about. It's a dependency injection framework for creating a feature rich, autowire-able, PSR-11 Container using PHP 8 Attributes. As the title suggests, I call it Annotated Container. This is the second in a three-part series introducing the framework. This article dives into more advanced features and how to resolve common problems with dependency injection. Part 1 provides an introduction. If you haven't checked that out you should probably start there first, I'm going to reference the code example shown in the Quick Start. Part 3 talks about some things I have learned while working on the project and its future.
Noteworthy Features
While I get pretty excited about the example in Part 1, an autowired Container these days isn't exactly special. This is basic functionality. It also doesn't help address common problems when working with a dependency injection framework. In this article I'm going to look at features of Annotated Container that help resolve three different issues:
- Resolving multiple aliases
- Injecting non-object values
- Incorporating third-party services that can't be annotated
Annotated Container has answers for a lot more problems that come up with dependency injection. Those features are discussed in the repo's documentation and future blog articles will go into them in-depth. Including a rundown of all the features in Annotated Container would truly turn this into a marathon post!
Quick Start Code Example
Just for a refresher here's the code example from Part 1. I won't get into the details here. If you're curious about anything that's done below I suggest you check out the previous article!
First, here was the example codebase we annotated.
<?php declare(strict_types=1);
namespace Acme\AnnotatedContainerDemo;
use Cspray\AnnotatedContainer\Attribute\Service;
#[Service]
interface BlobStorage {
public function store(string $key, string $contents) : void;
public function retrieve(string $key) : ?string;
}
#[Service]
class FilesystemBlobStorage implements BlobStorage {
public function store(string $key, string $contents) : void {
file_put_contents($key, $contents);
}
public function retrieve(string $key) : ?string {
return @file_get_contents($key) ?? null;
}
}
#[Service]
class BlobStorageConsumer {
public function __construct(public readonly BlobStorage $storage) {}
// Some API methods that would interact with the BlobStorage instance
}
Second, here was our bootstrapping code that creates a Container based on our Attributes.
<?php declare(strict_types=1);
namespace Acme\AnnotatedContainerDemo;
use Cspray\AnnotatedContainer\ContainerDefinitionCompileOptionsBuilder as CompileOptionsBuilder;
use function Cspray\AnnotatedContainer\compiler;
use function Cspray\AnnotatedContainer\containerFactory;
// The compiler() will statically analyze the given directories for annotated code
// $containerDef is a ContainerDefinition that details how to make your Container
$containerDef = compiler()->compile(
CompileOptionsBuilder::scanDirectories(__DIR__ . '/src')->build()
);
// Will create PSR-11 Container based off of analyzed source code
$container = containerFactory()->createContainer($containerDef);
Now let's look at some of the more advanced features with Annotated Container!
Resolving Aliases with Profiles
Before we get into solving ambiguous aliases I wanted to discuss how integral profiles are to Annotated Container. All services are assigned 1 or more profiles; either explicitly by you or implicitly. If you don't assign one then the service will be given the 'default' profile. When your Container is created a set of active profiles are provided. If you don't provide a list of active profiles we'll implicitly create one, ['default']
. Only services with profiles included in the active list are included in the Container. Now, let's look at how profiles might be useful.
All autowired Containers have to deal with the problem of ambiguous aliases, an abstract service having multiple concrete implementations. This is intractable from the perspective of a dependency injection framework. If there were 2 implementations of BlobStorage
from the example which one should be used? In Annotated Container one of the preferred ways to deal with this is the use of profiles.
Let's imagine that we've been tasked to improve upon the code from the Quick Start. Specifically, the requirements state that our production app will need to run on AWS and Google Cloud. The current implementation works well with the server in the datacenter, but it wouldn't work with the ephemeral filesystems present on a cloud server. Let's take a look at how our code might change.
<?php declare(strict_types=1);
namespace Acme\AnnotatedContainerDemo;
use Cspray\AnnotatedContainer\Attribute\Service;
#[Service(profiles: ['local'])]
class FilesystemBlobStorage implements BlobStorage {
// This has all of the same implementation as before
}
#[Service(profiles: ['aws'])]
class S3BlobStorage implements BlobStorage {
public function store(string $key, string $contents) : void {
// code would be implemented here to populate this in the appropriate bucket
}
public function retrieve(string $key) : ?string {
// code would be implemented here to return the $key value from the appropriate bucket
}
}
#[Service(profiles: ['google-cloud'])]
class GoogleCloudBlobStorage implements BlobStorage {
public function store(string $key, string $contents) : void {
// code would be implemented here to populate this in the appropriate bucket
}
public function retrieve(string $key) : ?string {
// code would be implemented here to return the $key value from the appropriate bucket
}
}
In our example above, two implementations have been added; one that's used when running on AWS and one that's used when running on Google. Note for all implementations an argument was added to the #[Service]
Attribute. The profiles
were set to ['aws']
and ['google-cloud']
, respectively. The existing FilesystemStorage
also had its profiles set to ['local']
. Now, the bootstrapping should explicitly declare a set of active profiles when compiling your ContainerDefinition
.
<?php declare(strict_types=1);
namespace Acme\AnnotatedContainerDemo;
use Cspray\AnnotatedContainer\ContainerDefinitionCompileOptionsBuilder as CompileOptionsBuilder;
use Cspray\AnnotatedContainer\ContainerFactoryOptionsBuilder as FactoryOptionsBuilder;
use Cspray\AnnotatedContainer\ActiveProfilesBuilder;
use function Cspray\AnnotatedContainer\compiler;
use function Cspray\AnnotatedContainer\containerFactory;
$containerDef = compiler()->compile(
CompileOptionsBuilder::scanDirectories(__DIR__ . '/src')->build()
);
// $profiles is an array of strings, you can manually create this array
// I recommend using ActiveProfilesBuilder to make sure the 'default' profile
// is handled properly. You could also take a look at ActiveProfilesParser for
// converting an arbitrary string into an array of active profiles.
//
// Depending on the conditions provided to addIf it is expected that $profiles would be
// one of the following possible sets:
// ['default', 'aws'] ['default', 'google-cloud'] ['default', 'local']
$profiles = ActiveProfilesBuilder::hasDefault()
->addIf('aws', function() {
// some code here to return true or false whether 'aws' is an active profile
})
->addIf('google-cloud', function() {
// some code here to return true or false whether 'google-cloud' is an active profile
})
->addIf('local', function() {
// some code here to return true or false whether 'local' is an active profile
})
->build();
// This is a PSR-11 container. The implementation is determined on which library you install
$container = containerFactory()->createContainer(
$containerDef,
FactoryOptionsBuilder::forActiveProfiles(...$profiles)->build()
);
You'll notice that multiple active profiles are included, one of them is the default
profile, like we discussed above. Now that more details have been provided Annotated Container can distinguish which concrete implementation to use when you type-hint BlobStorage
. When you're running with the aws
profile we'll use S3BlobStorage
, when you're running with google-cloud
we'll use GoogleCloudBlobStorage
, and when you're running with local
we'll continue to use FilesystemBlobStorage
.
If you include factory options with explicit active profiles, and you're not using the ActiveProfilesBuilder, be sure to include default
or your Container probably won't work right!
Profiles !== Environments
It is common for environments to be given a name like 'dev', 'test', 'staging', or 'prod'. For our example this isn't sufficient to disambiguate multiple aliases. After all, whether you're using AWS, Google Cloud, or local storage they could all be running in 'prod', perhaps 'staging', or even 'dev' in more sophisticated setups. Using a single environment name wouldn't be enough to distinguish which service to use. In many ways profiles can be more beneficial than a single environment name. As well, the typical environment names can, and probably should, be used as profiles! Just keep in mind that they are not 1-to-1 mappings. If it helps, you can think of profiles as tags that describe some aspect of the running environment.
Injecting Non-Service Values
Another common concern when dealing with dependency injection is how to provide non-object values. No matter how well you type your system at some point you'll have to inject a string
or int
. Like multiple aliases for the same abstract service, this is an intractable problem for Annotated Container. You'll have to provide some more information to handle creating those services correctly.
In this example we've been tasked with creating an SDK for interacting with a RESTful API. As is common with this kind of work our user-facing API requires a string representing an API key and some other non-service information. Let's take a look!
<?php declare(strict_types=1);
namespace Acme\AnnotatedContainerDemo;
use Cspray\AnnotatedContainer\Attribute\Service;
use Cspray\AnnotatedContainer\Attribute\Inject;
#[Service]
class ApiClient {
public function __construct(
#[Inject('API_KEY', from: 'env', profiles: ['prod', 'dev', 'staging'])]
#[Inject('known-key', profiles: ['test'])]
private readonly string $key,
#[Inject('API_SECRET', from: 'env', profiles: ['prod', 'dev', 'staging'])]
#[Inject('known-secret', profiles: ['test'])]
private readonly string $secret,
#[Inject(false, profiles: ['prod'])]
#[Inject(true, profiles: ['dev', 'test', 'staging'])]
private readonly bool $testMode
) {}
// API methods would go here
}
Following a pattern common with RESTful APIs, our ApiClient
requires a key, a secret, and whether you're in test mode. For the key and secret when active profiles includes 'prod', 'dev', or 'staging' we'll include a value from the environment. Note the from: 'env'
in each of these annotations. When this value is declared on #[Inject]
we fetch the value from the corresponding ParameterStore
. I'll get into that functionality below. For the key and secret with 'test' profiles a known value will be injected. Additionally, we'll ensure that the test mode is only set to false in 'prod' profiles and other environments run with test mode enabled.
Customizing Values with ParameterStore
As we talked about above we allow you to inject values that are stored in your environment. We support this functionality through an interface named ParameterStore
and an implementation named EnvironmentParameterStore
. It is expected that retrieving values from your environment is such a common use case that this functionality is provided out-of-the-box. You're also able to implement your own ParameterStore
implementations and have complete programmatic control of what values get injected at runtime. This offers up a wealth of possibilities for how to handle otherwise tricky problems.
While storing sensitive values in the environment is common, and makes for an easy demo, I don't believe it is ideal in real-life practice. Storing sensitive credentials in your environment isn't exactly great. Every piece of your application, and all your Composer dependencies, have access to it through $_ENV
or getenv()
. A supply-chain attack that compromised Composer, or just a bad-actor maintainer, might consider siphoning off those values as low-hanging fruit. Additionally, you have the operational challenge of having to propagate those sensitive values out to potentially n machines.
The better way to handle these type of values is with a secrets manager, like Hashicorp Vault or AWS Secrets Manager. Even 1Password has secrets automation that might be better than your environment. Not only do these services store your sensitive values securely, but they also offer up the canonical place for these values to exist with strict access control.
Let's take a look at rewriting the example above while using our own ParameterStore
implementation. First, let's update our ApiClient
service to fetch the data from a new store, it'll be called secrets
.
<?php declare(strict_types=1);
namespace Acme\AnnotatedContainerDemo;
use Cspray\AnnotatedContainer\Attribute\Service;
use Cspray\AnnotatedContainer\Attribute\Inject;
#[Service]
class ApiClient {
public function __construct(
#[Inject('API_KEY', from: 'secrets', profiles: ['prod', 'dev', 'staging'])]
#[Inject('known-key', profiles: ['test'])]
private readonly string $key,
#[Inject('API_SECRET', from: 'secrets', profiles: ['prod', 'dev', 'staging'])]
#[Inject('known-secret', profiles: ['test'])]
private readonly string $secret,
#[Inject(false, profiles: ['prod'])]
#[Inject(true, profiles: ['dev', 'test', 'staging'])]
private readonly bool $testMode
) {}
// API methods would go here
}
That seems simple enough. The only thing we did is change the from
value to 'secrets'. Now, we need to implement our custom secrets
store and let the ContainerFactory
know about it.
<?php declare(strict_types=1);
namespace Acme\AnnotatedContainerDemo;
use Cspray\AnnotatedContainer\ParameterStore;
class SecretsParameterStore implements ParameterStore {
public function getName() : string {
return 'secrets';
}
public function fetch(Type|TypeUnion|TypeIntersect $type, string $key) : mixed {
// implement whatever you need to do to get the right value
}
}
$containerDef = compiler()->compile(
CompileOptionsBuilder::scanDirectories(__DIR__ . '/src')->build()
);
$profiles = ['default']; // populate this with whether you're running in dev, test, staging, or prod
$containerFactory = containerFactory();
// Here's the important part! Make sure you added the SecretsParameterStore to the $containerFactory
$containerFactory->addParameterStore(new SecretsParameterStore());
$containerFactory->createContainer(
$containerDef,
FactoryOptionsBuilder::forActiveProfiles(...$profiles)->build()
);
A savvy reader will point out that surely the SecretsParameterStore
has its own set of configuration it needs. You'd likely be right! This isn't a problem Annotated Container can solve, unfortunately. Ultimately, this boils down to an operational concern with how your app is run. If you're targeting AWS SecretsManager and your app is running in ECS or EC2 then the credentials are likely automatically provided for you by AWS. Other cloud providers might offer similar functionality, or you may need to do something else depending on your app.
Integrate with 3rd-party Libraries
Considering how new PHP 8 Attributes are, and even newer Annotated Container is, you're gonna have some services in third-party libraries that aren't annotated. I anticipate you running into this problem and provided a functional API to allow doing everything that you do with Attributes. In this example we're gonna take a look at how to integrate the psr/log
package into our Container.
Integrating third-party services means providing definitions to the ContainerDefinitionBuilder
that is used during compilation to, well, build your ContainerDefinition
. All builders, like nearly all objects in Annotated Container, are immutable. To ease your burden of having to keep up with this immutability we provide functions for easily adding these definitions.
This example also introduces 2 new concepts we won't be able to show Attributes for; creating a service with your own factory and automatically invoking a method on a service post-construct. While the functions we use have Attribute equivalents for the sake of this example we'll wire the Container completely with the functional API and won't use any Attributes.
The first step is going to be to create a regular ol' callable
with a single argument, a ContainerDefinitionBuilderContext
. Inside that callable we can start using functions like Cspray\AnnotatedContainer\service
or Cspray\AnnotatedContainer\serviceDelegate
. Let's wire up our Container to share a Logger, use a factory to create it, and properly handle services that implement the LoggerAwareInterface
. After that we'll need to integrate it into our compiler which we'll do by making use of the ContainerDefinitionCompileOptionsBuilder
<?php declare(strict_types=1);
namespace Acme\AnnotatedContainerDemo;
use Cspray\AnnotatedContainer\ContainerDefinitionBuilderContext;
use Cspray\AnnotatedContainer\CallableContainerDefinitionBuilderContextConsumer;
use Psr\Log\LoggerInterface;
use Psr\Log\LoggerAwareInterface;
use function Cspray\AnnotatedContainer\service;
use function Cspray\AnnotatedContainer\serviceDelegate;
use function Cspray\AnnotatedContainer\servicePrepare;
use function Cspray\Typiphy\objectType;
class LoggerFactory {
// If your Factory needs any services provided by the Container simply define them as dependencies
// in the __construct method OR createLogger. Both will have dependencies autowired by the
// Container upon service creation
public function createLogger() : LoggerInterface {
// do whatever you need to do to create a Logger implementation
}
}
$containerBuilderCallable = static function(ContainerDefinitionBuilderContext $context): void {
$loggerType = objectType(LoggerInterface::class);
// Let the Container know the LoggerInterface is a shared service
service($context, $loggerType);
// Let the ContainerFactory know that the LoggerInterface should be created by instantiating a
// LoggerFactory and invoking createLogger.
serviceDelegate($context, $loggerType, objectType(LoggerFactory::class), 'createLogger');
// Tell the ContainerFactory automatically invoke the setLogger method on any service that
// implements the LoggerAwareInterface when it is instantiated. Since we wired up a logger
// to be shared whatever instance is returned from the LoggerFactory will be injected into
// all services that implement LoggerAwareInterface
servicePrepare($context, objectType(LoggerAwareInterface::class), 'setLogger');
};
$contextConsumer = new CallableContainerDefinitionBuilderContextConsumer($containerBuilderCallable);
$containerDef = compiler()->compile(
CompileOptionsBuilder::scanDirectories(__DIR__ . '/src')
->withContainerDefinitionBuilderContextConsumer($contextConsumer)
->build()
);
// Will create PSR-11 Container based off of analyzed source code
$container = containerFactory()->createContainer($containerDef);
Now, when we compile our ContainerDefinition
, after all the code in our source directory has been added to the ContainerDefinitionBuilder
, we will run the $contextConsumer
you passed in with the options. It will run our code adding in the psr/log
services and have our app ready to start logging!
Alternative: Get the Backing Container!
The Container returned from ContainerFactory
isn't just a ContainerInterface
. It also includes the Cspray\AnnotatedContainer\HasBackingContainer
interface. It exposes a method HasBackingContainer::getBackingContainer() : object
that returns the underlying container, based on which library you've installed. If what you need to do is too nuanced or isn't provided by Annotated Container this gives you the flexibilty to do everything with your Container that you'd normally do with it.
If at all possible, this isn't the recommended way to do things. The ContainerDefinition
is constructed in a way to be serializable. If you add definitions with the provided functional API you can ensure that your changes are serialized as well. If you add them to the backing container directly this wouldn't be serializable by Annotated Container. Additionally, if you ever need to change your backing container library the functional API will continue to work.
More Functionality Than Fits in 1 Post
I wanted to cover a lot more features in this article, but it is already getting pretty beefy, and we've only really covered three subjects! In addition to covering Attribute use of serviceDelegate()
and servicePrepare()
, we also have the following topics that could be covered:
- Immutable, type-safe configurations for values that are commonly passed as strings.
- Caching ContainerDefinition to avoid parsing your codebase every request.
- Disambiguating aliases using a primary Service.
- Creating arbitrary objects and autowiring their dependencies.
- Invoking callables and autowiring any parameters.
- Catching logical constraints with how your codebase has been annotated.
What I Learned - Part 3
Part 3 of introducing Annotated Container talks about what I've learned while working on the project. I'm a self-taught developer so writing stuff like this is primarily a way for me to learn something, and this project is no different. Along with things I've learned I also discuss the future of this project; unlike some of my other projects I don't expect this one to just be a learning experience.