silktide/syringe

Silktide Syringe, configuration utility for Pimple

3.6.0 2021-12-15 12:13 UTC

README

Syringe allows a Pimple DI container to be created and populated with services defined in configuration files, in the same fashion as Symfony's DI module.

Installation

composer require silktide/syringe

Changes with Version 3.0

BC's

Version 3 requires all parameters to be set before we can compile the container, as it resolves all of the parameters up front.

For example, this would work in version 2, but not in 3:

parameters:
    foo: "%bar%"
$container = Syringe::build([
    "paths" => [__DIR__]
    "files" => ["file.yml"]
]);
$container["bar"] = "chicken";

This would work in version 3:

parameters:
    foo: "%bar%"
Syringe::build([
    "paths" => [__DIR__]
    "files" => ["file.yml"],
    "parameters" => [
        "bar" => "chicken"
    ]
]);

Changes with Version 2.0

Improvements

  1. We can now cache the built container, saving lots of time on startup
  2. Tokens can now be escaped by repeating them (50% could be written as "50%%" as a parameter)
  3. Environment variables can be referenced using a $ token
  4. There's unit tests :D
  5. Multibyte support. I haven't done thorough testing, but we now use multibyte safe functions throughout
  6. Multiple files can share the same namespace like so: {"my-namespace":["service1.json", "service2.json"]}

BC's

Syringe 2.0 is pretty much an 100% rewrite, the functionality should remain more or less the same but the code behind it is vastly vastly different. As such, there are quite a few BC's as I feel it's better to BC once and hard rather than repeatedly

  1. Now requires PHP 7.1
  2. Aliases are now denoted through :: rather than .. This makes verifying whether something is aliased so much more clean
  3. TagCollection has been reworked to implement an iterator. This means that when we inject a tag in like so: '#collection', it will now return an iterable object instead of an array. This means that we will only build services if and when they are needed. We can still get information about the serviceNames on a TagCollection using ->getServiceNames
  4. The container is now originally updated using Syringe::build([]). Instead of chaining several slightly non-intuitive internal classes as the end user, we now provide a static method that takes an array of configuration options
  5. Containers are now always generated as part of Syringe rather than exposing populating an existing container.
  6. Files that inherit from each other will now throw exceptions if they overwrite each others services. A new parameter override has been added to services. If you are adding a service into the container and are well aware of the fact that it will overwrite an existing service you can set the override flag and it will not throw an error.
  7. Private services have been removed, in practice they added nothing useful but complicated affairs.
  8. Environment variables are no longer injected through prefixing parameters with SYRINGE__FOO as this was a bit clunky and the wrong way around to do it. A new token of $ means we can inject environment variables as parameters like so $foo$
  9. IniLoader has been removed, the format doesn't suit DI particularly nicely.
  10. LoaderInterface updated, now requires typehints
  11. We now support escaping of special tokens (environment, parameter, constant) by character repeating. e.g. a parameter value of 50% would be written as '50%%')
  12. As we've added a token for environment variables, any uses of $ in parameters will need to be escaped (like so: "I paid $$50 for this shirt")

Getting Started

The simplest method to create and set up a new Container is to use the Silktide\Syringe\Syringe class. It requires the path to the application directory and a list of filepaths that are relative to that directory

use Silktide\Syringe\Syringe;

$container = \Silktide\Syringe\Syringe::build([
	"files" => ["config/syringe.yml"]
]);

Flow

The code is hopefully relatively self explanatory with this version, but a basic rundown of the flow of the library for anyone trying to do future maintenance on it.

Syringe::build is the entrypoint in the library which deals with the configuration aspect.

MasterConfigBuilder takes the list of files and paths that we'll want to load from and recursively parses the files to build up a in-order list of FileConfig's, which represent each config file we've been asked to load either through the initial config, imports or inherits.

These are then merged into a MasterConfig.php, which handles merging any services together based on their weight, a property decided based on whether the key was loaded from an namespaced File.

The MasterConfig is then passed into the CompiledConfigBuilder which handles aliasing, the building of tags and the merging of abstract configurations.

Finally the CompiledConfig is passed into a ContainerBuilder which handles populating the Pimple\\Container

Key Production Configuration

When used in production, you should pass a PSR-16 cache interface (preferably as a cache parameter), like so:

use Silktide\Syringe\Syringe;

$container = \Silktide\Syringe\Syringe::build([
	"cache" => new FileCache(sys_get_temp_dir())
]);

The most computationally expensive part of Syringe (certainly when using many syringe based libraries) is: 1. the namespacing of the different parameters and 2. the validating of the classes/methods inside the configuration files

By passing in the cache parameter we cache the generated CompiledConfig and use that instead. This leads to much, much faster code (takes about 7% of the time)

Configuration Files

By default, Syringe allows config files to be in JSON, YAML or PHP format. Each file can define parameters, services and tags to inject into the container, and these entities can be referenced in other areas of configuration.

Parameters

A Parameter is a named, static value, that can be accessed directly from the Container, or injected into other parameters or services. For a config file to define a parameter, it uses the parameters key and then states the parameters name and value.

parameters:
    myParam: "value"

Once defined, a parameter can be referenced inside a string value by surrounding its name with the % symbol and the parameters value will the be inserted when the the string value is resolved. This can be done in service arguments or in other parameters, like so:

parameters:
    firstName: "Joe"
    lastName: "Bloggs"
    fullName: "%firstName% %lastName%"
    array: ["foo", "bar"]
    object: {"foo":"salad", "bar":"fish"}

Parameters can have any scalar or array value.

Constants

Quite often, a value set in a PHP constant is required to be injected. Hard coding these value directly into DI config is brittle and requires maintenance to keep in sync, which should be avoided where possible. Syringe solves this problem by allowing PHP constants to be referenced directly in config, by surrounding the constant name with ^ characters:

parameters:
    maxIntValue: "^PHP_INT_MAX^"
    custom: "^MY_CUSTOM_CONSTANT^"
    classConstant: "^MyModule\\MyService::CLASS_CONSTANT^"

Where class constants are used, you are required to provide the fully qualified class name. As this has to be enclosed inside a string, all forward slashes must be escaped, as in the example.

Services

Services are instances of a class that can have other services, parameters or values injected into them. A config file defines services inside the services key and gives each entry a class key, containing the fully qualified class name to instantiate. For classes which have constructor arguments, these can be specified by setting the arguments key to a list of values, parameters or other services, as required by the constructor

services:
    myService:
        class: MyModule\MyService
        arguments:
            - "first constructor argument"
            - 12345
            - false
	    - {"foo":"salad", "bar":"fish"}

Service injection

Services can have parameters or other services injected into them as method arguments, by referencing a service name prefixed with the @ character. This is done in one of two ways:

Constructor injection

Injection can be done when a service is instantiated, by setting references in arguments key of a service definition. This is typically done for dependencies which are required.

services:
    injectable:
        class: MyModule\MyDependency

    myService:
        class: MyModule\MyService
        arguments:
            - "@injectable"
            - "%myParam%"

Setter injection

Services can also be injected by calling a method after the service has been instantiated, passing the dependant service in as an argument. This form is useful for optional dependencies.

services:
    injectable:
        class: MyModule\MyDependency

    myService:
        class: MyModule\MyService
        calls:
            -
                method: "setInjectable"
                arguments:
                    - "@injectable"

The calls key can be used to run any method on a service, not necessarily one to inject a dependency. They are executed in the order they are defined.

services:
    myService:
        class: MyModule\MyService
        calls:
            - method: "warmCache"
            - method: "setTimeout"
              arguments: ["%myTimeout%"]
            - method: "setLogger"
              arguments: ["@myLogger"]

Tags

In some cases, you may want to inject all the services of a given type as a method argument. This can be done manually, by building a list of service references in config, but maintaining such a list is cumbersome and time consuming.

The solution is tags; allowing you to tag a service as being part of a collection and then to inject the whole collection of services in one reference.

A tag is referenced by prefixing its name with the # character.

services:
    logHandler1:
        ...
        tags:
            - "logHandlers"
            
    logHandler2:
        ...
        tags:
            - "logHandlers"
            
    loggerService:
        ...
        arguments:
            - "#logHandlers"

When the tag is resolved, the collection is passed through as TagCollection, which can be used as an iterator. This should be typehinted against iterator, not TagCollection unless you're certain you know what you're doing.

Factories

If you have a number of services to be available that use the same class or interface, it can be advantageous to abstract the creation of these services into a factory class, to aid maintenance and reusability. Syringe provides two methods of using factories in this way; via a call to a static method on the factory class, or by calling a method on a separate factory service.

services:
    newService1:
        class: MyModule\MyService
        factoryClass: MyModule\MyServiceFactory
        factoryMethod: "createdWithStatic"
        
    newService2:
        class: MyModule\MyService
        factoryService: "@myServiceFactory"
        factoryMethod: "createdWithService"
        
    myServiceFactory:
        class: MyModule\MyServiceFactory

If the factory methods require arguments, you can pass them through using the arguments key, in the same way you would for a normal service or a method call.

Service Aliases

Syringe allows you to alias a service name to point to another definition, using the aliasOf key. This is useful if you deal with other modules and need to use your own version of a service instead of the module's default one.

# [foo.yml]
services:
    default:
        class: MyModule\DefaultService
        ...

# [bar.yml]
services:
    default:
        aliasOf: "@custom"
        
    custom:
        class: MyModule\MyService
        ...

Abstract Services

Services can often have definitions that are very similar or contain portions that will always be the same. As a method to reduce duplicated config, a service's definition can "extend" a base definition. This has the effect of merging the two definitions together. Any key conflicts take the service's value rather than the one from the base, however the list of calls is merged rather than overwritten. There is no restriction on what keys you can define in the base definition. Base definitions have to be marked as abstract and cannot be used directly as a service. These abstract definitions can extend other definitions in the same way, similar to how inheritence works in OOP.

services:
    loggable:
        abstract: true
        calls:
            - method: "setLogger"
            - arguments: "@logger"

    myService:
        class: MyModule\MyService
        extends: "@loggable"            # this will import the "setLogger" call into this service definition
        
    factoriedService:
        abstract: true
        extends: "@loggable"
        factoryClass: MyModule\MyServiceFactory
        factoryMethod: "create"

    myFactoriedService:
        class: MyModule\MyService
        extends: "@factoriedService"    # imports both the factory config and the "setLogger" call

Lazy Services

Dependency injection can lead to excessive generation of objects that can have expensive initialisation logic but aren't actually used. As such, syringe offers native support for ProxyManager

services:
  	runner:
		class: MyRunner
		arguments:
			- "@expensiveService"
		
	expensiveService:
		class: MyExpensiveClass
		lazy: true

This will inject a proxy object for expensiveService that is indistinguishable to MyExpensiveClass to the code, but prevents it from being loaded un-necessarily

Lazy Skip Destructor

If a service has a __destruct, ProxyManager will create the service just to ensure that the __destruct logic is triggered as expected. This feels counter intuitive but it is the right default behaviour.

However, if you're just performing cleanup from your object, it isn't really desired to create an object just to have to perform the cleanup from doing so.

As such, we provide the lazySkipDestruct property to allow us to overcome this behaviour

services:
  	runner:
		class: MyRunner
		arguments:
			- "@expensiveService"
		
	expensiveService:
		class: MyExpensiveClass
		lazy: true
		lazySkipDestruct: true

Imports

When your object graph becomes large enough, it is often useful to split your configuration into separate files; keeping related parameters and services together. This can be done by using the imports key:

imports:
    - "loggers.yml"
    - "users.yml"
    - "report/orders.yml"
    - "report/products.yml"
    
services:
    ...

If any imported files contain duplicated keys, the file that is further down the list wins. As the parent file is always processed last, its services and parameters always take precedence over the imported config.

# [foo.yml]
parameters:
    baz: "from foo"

# [bar.yml]
imports: 
    - "foo.yml"
    
parameters:
    baz: "from bar"
    
# when bar.yml is loaded into Syringe, the "baz" parameter will have a value of "from bar"

Environment Variables

If required, Syringe allows you to set environment variables on the server that will be imported at runtime. This can be used to set different parameter values for local development machines and production servers, for example. They can be injected similar to parameters using the token of & like &myvar& so

Config Aliases and Namespacing

When dealing with a large object graph, conflicting service names can become an issue. To avoid this, Syringe allows you to set an namespace for a config file. Within the file, services can be referenced as normal, but files which use different namespaces or no namespace need to prefix the service name with the namespace. This allows you to compartmentalise your DI config for better organisation and to promote modular coding.

For example, the two config files, foo.yml and bar.yml can be given namespaces when setting up the config files to create a Container from:

$configFiles = [
  "foo_namespace" => "foo.yml",
  "bar_namespace" => "bar.yml"
];

foo.yml could defined a service, fooOne, which injected another service in the same file, fooTwo, as normal. However, if a service in bar.yml wanted to inject fooTwo, it would have to use its full service reference @foo_namespace.fooTwo. Likewise if fooOne wanted to inject barOne from bar.yml it would have to use @bar_namespace.barOne as the service reference.

Extensions

There can be times where you need to call setters on a dependent module's services, in order to inject your own dependent service as a replacement for the module's default one. In order to do this, you need to use the extensions key. This allows you to specify the service and provide a list of calls to make on it, essentially appending them to the service's own calls key

# [foo.yml, namespaced with "foo_namespace"]
services:
    myService:
        class: MyModule\MyService
        ...

# [bar.yml]
services:
    myCustomLogger:
        ...
        
extensions:
    foo_namespace.myService:
        - method: "addLogger"
          arguments: "@myCustomLogger"

Reference characters

In order to identify references, the following characters are used:

  • @ - Services
  • % - Parameters
  • # - Tags
  • ^ - Constants
  • & - Environment Variables

Conventions

Syringe does not enforce naming or style conventions, with one exception. A service's name can be any you like, as long as it does not start with one of the reference characters, but a config namespace is always seperated from a service name with a ::, e.g. myAlias::serviceName, as such, this should be avoided in any service/parameter names

parameters:
    database.host: "..."
    database.username: "..."
    database.password: "..."
    
services:
    database.client:
        ...

Advanced Usage

Base paths for config files

In order to use configuration in a particular file, its filepath must be passed to the ContainerBuilder, which will use the loading system to convert a file into a PHP array. Syringe uses absolute paths when loading files, but this is obviously not ideal when you're passing config filepaths.

In order to get around this, you can add additional paths to the configuration array. For example, for a config file with absolute path of /var/www/app/config/syringe.yml, you could set a base path of /var/www/app and use config/syringe.yml as the relative filepath.

$container = \Silktide\Syringe\Syringe::build([
   "paths" => ["/var/www/app"]
	"files" => ["config/syringe.yml"]
]);

If you use several base paths, Syringe will look for a config file in each base path in turn, so the order is important.

Application root directory

If you have services that deal with files, it can be very useful to have the base directory of the application as a parameter in DI config, so you can be sure any relative paths you use are correct.

$container = \Silktide\Syringe\Syringe::build([
   "appDir" => "my/application/directory", # Application Directory
   "appDirKey" => "myAppParameterKey"
]);

If no app directory key is passed, the default parameter name is app.dir.

Container class

Some projects that use Pimple, such a Silex, extend the Container class to add functionality to their API. Syringe can create custom containers in this way by allowing you to set the container class it instantiates:

$container = \Silktide\Syringe\Syringe::build([
   "containerClass" => "Silex\Application::class"
]);

Loaders

Syringe can support any data format that can be translated into a nested PHP array. Each config file is processed by the loader system, which is comprised of a series of Loader objects, each handling a single data format, that take a file's contents and decode it into an array of configuration.

By default the ContainerBuilder loads the PHPLoader, the YamlLoader and the JsonLoader

Custom loaders

By default Syringe supports YAML and JSON data formats for the configurations files, but it is possible to use any format that can be translated into a nested PHP array. The translation is done by a Loader; a class which takes a filepath, reads the file and decodes the data.

To create a Loader for your chosen data format, the class needs to implement the LoaderInterface and state what its name is and what file extensions it supports. For example, a hypothetical XML Loader would look something like this:

use Silktide\Syringe\Loader\LoaderInterface;

class XmlLoader implements LoaderInterface
{
    public function getName()
    {
        return "XML Loader";
    }
    
    public function supports($file)
    {
        return pathinfo($file, PATHINFO_EXTENSION) == "xml";
    }
    
    public function loadFile($file)
    {
        // load and decode the file, returning the configuration array
    }
}

Once created, such a loader can be used by overwriting $config["loaders"] = [new XmlLoader()]

Credits

Written by: