dhii/services

A collection of useful DI service implementations.

v0.1.1-alpha3 2023-01-31 23:49 UTC

README

Continuous Integration Latest Stable Version Latest Unstable Version

This package provides a collection of service factory and extension definition implementations that can be used with PSR-11 containers, as well as the experimental service provider spec, to replace the anonymous functions that are typically used for definitions.

Requirements

  • PHP >= 7.0 < PHP 8

Installation

With Composer:

composer require dhii/services

Without Composer:

  1. Go here.
  2. Install it.
  3. See "With Composer"

Classes

All implementations in this package inherit from Service; an invocable object with a getDependencies method that returns an array of keys.

Factory

A simple implementation that uses a callback to construct its service.

Unlike a normal anonymous function, the callback given to the Factory does not get a ContainerInterface argument, but rather the services that match the given dependency keys. This allows omitting a lot of trivial service retrieval code, and most importantly type-hinting the services:

new Factory(['dep1', 'dep2'], function(int $dep1, SomeInterface $dep2) {
  // ...
});

Roughly equivalent to:

function (ContainerInterface $c) {
  $dep1 = $c->get('dep1');
  if (!is_int($dep1)) {
    throw new TypeError(sprintf('Parameter $dep1 must be of type int; %1$s given', is_object($dep1) ? get_class($dep1) : gettype($dep1));
  }

  $dep2 = $c->get('dep2');
  if (!($dep2 instanceof SomeInterface)) {
    throw new TypeError(sprintf('Parameter $dep2 must be of type int; %1$s given', is_object($dep2) ? get_class($dep2) : gettype($dep2));
  }
  // ...
}

This is true for all implementations that use a similar mechanic of passing already resolved services to other definitions. Therefore, type-checking code will be henceforth omitted for brevity.

Extension

Very similar to Factory, but the callback also receives the service instance from the original factory or previous extension as the first argument.

new Extension(['dep1', 'dep2'], function($prev, $dep1, $dep2) {
  // ...
});

Equivalent to:

function (ContainerInterface $c, $prev) {
  $dep1 = $c->get('dep1');
  $dep2 = $c->get('dep2');
  // ...
}

Constructor

A variant of Factory that invokes a constructor, rather than a callback function. Very useful in cases where a class is only constructed using other services.

new Constructor(MyClass::class, ['dep1', 'dep2']);

Equivalent to:

function (ContainerInterface $c) {
  $dep1 = $c->get('dep1');
  $dep2 = $c->get('dep2');

  return new MyClass($dep1, $dep2);
}

Consequently, it also works without any dependencies, which is useful when the constructor is parameterless:

new Constructor(MyClass::class);

Equivalent to:

function (ContainerInterface $c) {
  return new MyClass();
}

ServiceList

Creates an array that contains the services indicated by its dependencies. Very useful for managing registration of instances when coupled with ArrayExtension.

new ServiceList(['service1', 'service2']);

Equivalent to:

function (ContainerInterface $c) {
  return [
    $c->get('service1'),
    $c->get('service2'),
  ];
}

ArrayExtension

An extension implementation that adds its dependencies to the previous value. Very useful for registering new instances to a list.

new ArrayExtension(['dep1', 'dep2'])

Equivalent to:

function (ContainerInterface $c, array $prev) {
  return array_merge($prev, [
    $c->get('dep1'),
    $c->get('dep2'),
  ]);
}

FuncService

A variant of Factory, but it returns the callback rather than invoking it. Invocation arguments will be passed before the injected dependencies. Very useful for declaring callback services.

new FuncService(['dep1', 'dep2'], function($arg1, $arg2, $dep1, $dep2) {
  // ...
});

Equivalent to:

function (ContainerInterface $c) {
  $dep1 = $c->get('dep1');
  $dep2 = $c->get('dep2');

  return function ($arg1, $arg2) use ($dep1, $dep2) {
    // ...
  };
}

Others

  • StringService - For services that return strings that are interpolated with other services.
  • Value - For services that always return a static value.
  • Alias - An alias for another service, with defaulting capabilities for when the original does not exist.
  • GlobalVar - For services that return global variables.

Mutation

The withDependencies() method allows all service instances to be copied with different dependencies, while leaving the original instances unaffected.

$service = new Factory(['database'], function ($database) {
  // ...
});

$service2 = $service->withDependencies(['db']);

This makes it possible to modify service dependencies at run-time, or even during a build process, which can be especially useful when dealing with 3rd party service providers that need to be rewired.

Multi-boxing

One of the benefits of being able to derive new services with different dependencies is the ability to use the same provider multiple times. Let's look at an example.

Consider a service provider for a logger that writes to a file.

class LoggerProvider implements ServiceProviderInterface
{
  public function getFactories()
  {
    return [
      'logger' => new Constructor(FileLogger::class, ['file_path']),
      'file_path' => new Value(sys_get_tmp_dir() . '/log.txt')
    ];
  }
  
  public function getExtensions ()
  {
    return [];
  }
}

Our application needs to keep 2 different log files: one for errors and one for debugging.

Simply using the above service provider twice won't work; we'd be re-declaring the logger and file_path services.

Prefixing the factories would allow us to have two instances of the service provider, but that would break the dependencies. If we prefixed the factories from one logger such that they become debug_logger and debug_file_path, the debug_logger factory would still be depending on file_path, which would no longer exist after prefixing.

This where mutation of dependencies comes in. We can write a PrefixingProvider decorator that not only prefixes all services in a provider, but also prefixes any dependencies.

(The below class is incomplete for the sake of brevity. Assume that a constructor exists and that it initializes its $prefix and $provider properties).

class PrefixingProvider implements ServiceProviderInterface {
  public function getFactories() {
    $factories = [];

    foreach ($this->provider->getFactories() as $key => $factory) {
      $deps = $factory->getDependencies();
      $newDeps = array_map(fn($dep) => $this->prefix . $dep, $deps);

      $factories[$this->prefix . $key] = $factory->withDependencies($newDeps);
    }
    
    return $factories;
  }
}

We can now create two different versions of the same service provider:

$debugLogProvider = new PrefixingProvider('debug_', new LoggerProvider);
$errorLogProvider = new PrefixingProvider('error_', new LoggerProvider);

The first one will provide debug_logger and debug_file_path, while the second will provide error_logger and error_file_path.

Static Analysis

By having all services declare their dependencies, we open up the possibility to create an inspection tool that statically analyzes a list of services to build a dependency graph. This graph can help uncover various potential problems without needing to run the code. These insights can reveal:

  • Circular dependency
  • Dependencies that do not exist
  • Factories that override each other, rather than using extensions
  • Dependency chains that are too deep
  • Unused services

No such tool exists at the time of writing, but I do plan on taking on this task.