Escriba su propio contenedor de inyección de dependencia

Escriba su propio contenedor de inyección de dependencia

Esta publicación se enfoca en el uso de un patrón de diseño para conectar los módulos de una base de código de una manera estructurada y comprobable.

Esta es una publicación de invitado de Nicolas Croad. Nicolás ha sido un desarrollador profesional principalmente en C++ durante la mayor parte de una carrera de 15 años. Actualmente trabajando en gráficos en tiempo real para el MetService de Nueva Zelanda.

Hoy demuestro una implementación armoniosa en C++ del patrón de diseño del localizador de servicios. Al igual que con la mayoría de las técnicas de programación, se hacen concesiones al implementar cualquier patrón.
Las ventajas de esta técnica son que,

  • Utiliza un enfoque consistente para la inyección de dependencia (facilitando la capacidad de prueba) que, por lo tanto, se puede aplicar en la medida requerida, en lugar de hacerlo por partes en el proyecto general.
  • Minimiza las dependencias de funciones que se exponen explícitamente como parte de la interfaz de funciones.
  • Hace que la duración de las dependencias funcione de una manera particularmente típica para C++, lo que a su vez facilita la gestión de posibles problemas de duración entre dependencias.

Antes de continuar, algunos de los detalles de implementación se han eliminado de los fragmentos de código que se presentan aquí. Algunos detalles adicionales y un ejemplo de trabajo están disponibles en Github.

¿De qué se trata la inyección de dependencia?

La inyección de dependencia (como se describe en Wikipedia o en el sitio web de Martin Fowler) es un patrón de diseño que se usa con frecuencia para respaldar la modularidad y la capacidad de prueba del código base. Como breve resumen, la inyección de dependencia es cuando un objeto o función proporciona las dependencias requeridas de otro objeto o función.

Hay 4 roles que cooperan para implementar la inyección de dependencia

  • El servicio objeto a inyectar.
  • El cliente objeto que depende de los servicios que se inyectan.
  • La interfaz a través del cual el objeto del cliente utiliza los servicios.
  • El inyector que inyecta los servicios en el cliente.

En algunos casos, la interfaz está separada del servicio; sin embargo, en muchos ejemplos que se describen aquí, la interfaz es la API pública del servicio.

Inyección de dependencia ingenua

Una forma sencilla de organizar esto puede ser pasar la dependencia como un argumento adicional a la función que se invoca.

void foo(int x, const Frobber& frobber = Frobber()) {
    double p = /* Complicated calculation of p given x */;
    frobber.frob(p);
}

El parámetro significa que cuando escribo casos de prueba para la función foo() Podré pasar por otros servicios en lugar de su frobber interfaz. Dependiendo de la funcionalidad que se esté probando, estos objetos pueden comprender cualquiera de los objetos stub, simulacros o falsos o ser los servicios habituales cuando se realiza algún tipo de prueba de integración. En el ejemplo anterior, las pruebas pueden verificar que el valor esperado de p se está pasando al frob() función (para valores de x ) instalando un frobber simulado servicio en pruebas.

Inyección de dependencia de parámetro único

A medida que toma forma un proyecto, las dependencias entre los módulos se desarrollarán y cambiarán, y el uso de la implementación ingenua de la inyección de dependencias (de pasar estas dependencias como parámetros individuales) requiere que muchas de estas firmas de funciones cambien. Además, la inyección de dependencias puede llevar a exponer todas las dependencias de la implementación como parte de la API pública de una función o tipo. Con frecuencia, las interfaces utilizadas por una función no son detalles pertinentes y presentarlas en la firma de funciones puede resultar perjudicial si cambian con regularidad.

Para mejorar esto, las dependencias se pueden agrupar en un tipo de contenedor de inyección de dependencia con el nombre abreviado DI. Casi exclusivamente paso esto como el primer parámetro, por lo que normalmente he escrito el equivalente a,

// The parameter name c is a terse and consistently used
// abbreviation for container.
void foo(const DI& c, int x) {
    double p = /* Complicated calculation of p given x */;
    c.getFrobber().frob(p);
}

Otros enfoques de inyección de dependencia

En la sección más abajo, Tiempos de vida del servicio, presento un mecanismo basado en la pila de programas para administrar el tiempo de vida de los objetos en el contenedor DI. De lo contrario, existe una amplia gama de enfoques para la inyección de dependencia que se utiliza con el patrón de diseño.

Estos incluyen la inyección de constructor (donde las dependencias se inyectan durante una llamada de constructor) y la inyección de setter (donde las dependencias se conectan al cliente usando setters después de la construcción). Ambos enfoques asumen que la vida útil del objeto de servicio abarcará la vida útil del objeto del cliente que lo usa.

Esta suposición se adapta mucho mejor a un entorno de programación que usa un recolector de basura que la estrategia de administración de memoria que se usa en C++. En la práctica de usar el patrón de diseño del contenedor DI, es importante comprender que, cuando los tipos de programa retienen referencias (o punteros) al contenedor DI o a cualquiera de sus miembros, se vuelven a presentar problemas similares de duración del objeto.

Similitudes con el patrón del localizador de servicios

Hasta ahora, esta es solo una descripción de la inyección de dependencia con un nivel de direccionamiento indirecto agregado. Agregar este nivel de direccionamiento indirecto hace que el enfoque se vea muy similar al patrón de diseño del localizador de servicios. En ese patrón, la resolución de dependencia se produce a través de una API de localización de servicios que proporciona una referencia al servicio que requiere el cliente.

De hecho, si todos los accesos al contenedor DI se hicieran a través del método estático (introducido en Interfaces de función fuera de control), esa sería la descripción más apropiada de este diseño.

Mi preferencia personal sería mantener la práctica de pasar el contenedor DI como un parámetro explícito en los casos en que esto sea posible. Esto debería dejar claro a los lectores,

  • Que la vida útil de los objetos en el contenedor esté limitada por la pila del programa.
  • Lo que hace el parámetro del contenedor DI para la función a la que se transfiere.

Vida útil del servicio

Otra técnica bastante común para la inyección de dependencia es crear algún tipo de API de localización de servicios con plantilla donde estén disponibles los servicios registrados o predeterminados. El mayor problema con esta técnica se relaciona con la vida útil de los servicios que esa API instala o resuelve bajo demanda.

Por lo general, esto todavía conduce a un código de prueba relativamente complicado en el que es necesario configurar y eliminar una serie de dependencias que se inyectarán alrededor de las pruebas y la falla en el mantenimiento de esto conduce con frecuencia a que el orden de ejecución de las pruebas se vuelva rígido (por ejemplo:el las pruebas solo pasan cuando se ejecutan en un orden específico). Además, dependiendo de cómo se implemente su API, esto también puede conducir a problemas conocidos de inicialización estática y/o orden de destrucción entre servicios.

El enfoque del contenedor DI, por otro lado, utiliza la pila de programas para definir la vida útil de los servicios en el contenedor. Para lograr esto, se utiliza una plantilla de clase:

// The name is an abbreviation for Dependency Injected Lifetime.
// This version works with C++17 compilers and allocates
// installed services on the program stack.
template <typename I, typename T>
class DILifetime {
   public:
      template <typename... Args>
      DILifetime(I*& member, Args&&... args)
      : item_(std::forward<Args>(args)...),
        member_(&member)
      {
          *member_ = &item_;
      }
      DILifetime(const DILifetime& other) = delete;
      DILifetime& operator=(const DILifetime& other) = delete;
      // Deleting these methods is problematic before C++17
      // This is because C++17 adds Guaranteed Copy Elision
      DILifetime(const DILifetime&& other) = delete;
      DILifetime& operator=(const DILifetime&& other) = delete;
      ~DILifetime() {
         if (member_)
            *member_ = nullptr;
      }
      const T& getComponent() const { return item_; }
      T& getComponent() { return item_; }
   private:
      T item_;
      I** member_ = nullptr;
};

El trabajo de esta plantilla de clase es una tarea similar a RAII bastante típica. Se aferra a un miembro inicializado del contenedor DI. Siguiendo la construcción de item_ un puntero  member_ en el contenedor DI se apunta a él, y justo antes de la destrucción, el puntero vuelve a ser nulo. Por lo tanto, los objetos en el contenedor DI tienen su tiempo de vida administrado por el compilador de C++.

En el caso de que se requiera una inspección o inicialización adicional del objeto de servicio mantenido vivo por esta plantilla de clase, esto está disponible usando el getComponent() métodos.

Antes de la elisión de copia garantizada

Esta implementación anterior del DILifetime La plantilla funciona cuando el compilador admite la elisión de copia garantizada. Sin embargo, muchos proyectos aún no utilizarán exclusivamente compiladores de C++17.

Sin embargo, la interfaz de clase idéntica es posible utilizando estándares de idioma anteriores siempre que esté dispuesto a asignar los servicios instalados en el montón. Una de las características principales de la plantilla de clase es que debe admitir la instalación de servicios que no tienen funciones de copiar o mover.

Usando estándares anteriores, una interfaz sintácticamente equivalente es compatible con la siguiente plantilla de clase.

// C++11 compatible version.
// This one allocates services on the heap.

template <typename I, typename S>
class DILifetime {
   public:
      template <typename... Args>
      DILifetime( I*& member, Args&&... args )
      : item_( new S( std::forward<Args>( args )... ) ),
      member_( &member )
      {
         *member_ = item_.get();
      }
      DILifetime( const DILifetime& other ) = delete;
      DILifetime& operator=( const DILifetime& other ) = delete;
      DILifetime( DILifetime&& other )
      : item_( std::move( other.item_ ) ),
        member_( other.member_ )
      {
         other.member_ = nullptr;
      }
      DILifetime& operator=( DILifetime&& other ) {
         item_ = std::move( other.item_ );
         member_ = other.member_;
         other.member_ = nullptr;
         return *this;
      }
      ~DILifetime() {
         if( member_ )
            *member_ = nullptr;
      }
      const S& getComponent() const { return *item_; }
      S& getComponent()       { return *item_; }
   private:
      std::unique_ptr<S> item_;
      I** member_ = nullptr;
};

La Cuestión de Dios (Clases)

Con solo este pequeño marco, estamos listos para implementar la clase de contenedor DI en sí. La reutilización y el intercambio de código de biblioteca entre proyectos a menudo se describen de manera positiva y existen beneficios obvios, sin embargo, en el caso del contenedor DI en sí, los contenidos son manifiestamente tipos y tal vez un reflejo de la arquitectura del proyecto que usa el contenedor. Debido a esto, mi sugerencia sería que esta clase se implemente específicamente para los requisitos de cada proyecto.

La primera preocupación de implementación es que su contenedor DI debería poder incluirse solo con los nombres de todos las interfaces que resuelve. La razón principal por la que es importante que este contenedor funcione solo con una declaración directa es un principio arquitectónico.

A medida que esta técnica prolifera a través de su proyecto, el contenedor DI brinda acceso a más componentes. Esto puede conducir al diseño generalmente no intencional conocido como la clase dios, por lo que esta clase está restringida a proporcionar acceso a una colección de tipos sin especificar sus API. En términos específicos de C++, el tipo de contenedor DI es una clase de solo encabezado y todos los métodos que se describen a continuación se pueden escribir en línea.

Para cada tipo contenido en el contenedor DI hay dos métodos y un campo agregado al contenedor.

// Acronym is short for Dependency-Injection (Container).
// The name is intentionally kept short as this will be
// a common function parameter.
class DI {
   private:
      class Factory* factory_ = nullptr;
   public:
      Factory& getFactory() const {
         assert(factory_ && “No Factory has been installed”);
         return *factory_;
      }
      template <typename T, typename... Args>
      DILifetime<Factory, T> installFactory(Args&&... args) {
         assert(!factory_ && “A Factory has previously been installed”);
         return DILifetime<Factory, T>(factory_, std::forward<Args>(args)...);
      }
      // This repeats for other types as they become provided via the container.
};

Los métodos devuelven intencionalmente una referencia no constante en el descriptor de acceso constante. Inyectar el contenedor consistentemente como un const DI& parámetro y haciendo el installXXX() Los métodos non-const usan el compilador para hacer cumplir que la inicialización ocurre solo en un área del programa (como se describe en Inicialización del contenedor).

El acceso a una interfaz que no se ha instalado previamente en el contenedor o la sustitución de los servicios en el contenedor por otros no son compatibles y activan inmediatamente una aserción. Esto evita cualquier tipo de relación oculta entre los componentes del contenedor (como el orden de las dependencias de ejecución entre las pruebas).

A medida que se agregan más tipos al contenedor, se puede agregar una gran cantidad de código similar a sí mismo a la clase DI. Para abordar esto, el campo y las funciones getXXX() y installXXX() podría escribirse como una macro de función (no trivial) que hace la declaración/definición de la clase DI en una lista de los miembros del contenedor.

#define DECLARE_INTERFACE(InterfaceType, interfaceName)      \
private:                                                     \
class InterfaceType* interfaceName = nullptr;                \
public:                                                      \
// The rest of this macro is provided in the example ...

class DI {
   DECLARE_INTERFACE(Factory, factory_);
   DECLARE_INTERFACE(/*Another kind of interface*/);
   // This repeats for other types as they become provided via the container.
};

#undef DECLARE_INTERFACE

Podría decirse que hay mayores beneficios al escribir cada miembro del contenedor a mano y así permitir el uso de los puntos de personalización que se describen a continuación para resaltar el uso previsto. La implementación de este tipo también representa un buen lugar para documentar la arquitectura de los proyectos.

Para el macrófobo un tercer ejemplo se encuentra entre la esencia que lo acompaña, que usa la herencia múltiple en lugar de la macro anterior.

Puntos de personalización de contenedores

El getFactory() y installFactory() Las funciones habilitan una serie de puntos de personalización dependiendo de cómo se comporten los servicios en el contenedor DI.

  • Para cualquier interfaz disponible que tenga una API completamente constante, el getXXX() la función puede devolver una referencia constante al servicio.
  • Cuando, como suele ser el caso, los servicios instalados con installXXX() no requiere parámetros de constructor, entonces el parámetro args de esta función se puede eliminar.
  • El parámetro de plantilla T de installXXX() puede tener un argumento predeterminado. Esto permite que los componentes se instalen sin un argumento de plantilla explícito en el sitio de la llamada.
  • En el raro caso de una interfaz opcional, el getXXX() La función devuelve un puntero a cualquier servicio instalado en lugar de una referencia.

Estos puntos de personalización deben usarse para resaltar el uso previsto de las interfaces disponibles desde el contenedor DI.

Interfaces de función fuera de control

En algunos casos, la API de algunas de las funciones que se implementan en un proyecto no será modificable. En estos casos, dichas funciones aún pueden requerir acceso al contenedor DI, pero no podrán aceptarlo como un parámetro.

Para facilitar este caso de uso, el contenedor DI puede estar disponible de forma estática con bastante facilidad. La expectativa para el uso de contenedores es que solo habrá un contenedor DI en cualquier programa o programa de prueba en cualquier momento, o en algunas instancias de subprocesos múltiples, podría ser uno por subproceso.

Para facilitar esto, el contenedor DI se puede actualizar de la siguiente manera,

class DI {
public:
    DI() {
        assert(!activeContainer_);
        activeContainer_ = this;
    }
    ~DI() {
        activeContainer_ = nullptr;
    }
    DI(const DI& other) = delete;
    DI& operator=(const DI& other) = delete;
    DI(DI&& other) = delete;
    DI& operator=(DI&& other) = delete;
    static const DI& getDI() {
        assert(activeContainer_);
        return *activeContainer_;
    }
private:
    // This will otherwise need to be declared in a single source file.
    static DI* activeContainer_;
};

Esto, a su vez, permite que las funciones que requieren acceso al contenedor DI accedan a él con una llamada a DI::getDI() siempre que se haya creado un contenedor anteriormente en el programa.

Inicialización del contenedor

En algunos casos, un proyecto complejo implementará múltiples ejecutables, sin embargo, incluso en tales casos, es posible que prefiramos tener una rutina de inicialización de contenedor.

Para habilitar esto, el contenedor se puede inicializar en una función y luego pasar a una llamada de función de tipo borrado (que permite pasar una lambda en el sitio de la llamada).

void initializeAndRun(std::function<void(const DI&)> func) {
    DI container;
    #if defined(_WIN32) || defined(_WIN64)
        auto factory = container.installFactory< WindowsFactory >();
    #else
        auto factory = container.installFactory< PosixFactory >();
    #endif // _WIN32 || _WIN64</i>
    auto doThingPipeline &nbsp;&nbsp;&nbsp;&nbsp;&nbsp;= container.installDoThingPipeline();
    // ... more of the same follows here
    func(container);
}

Dondequiera que se defina esta función, deberá ubicarse en una capa bastante alta de un proyecto, ya que deberá incluir muchos de los servicios específicos del proyecto.

¿Qué aspecto tiene el código resultante?

El código de implementación termina haciendo uso del contenedor DI como se muestra aquí.

Thing makeSpecificThing( const DI& c )
{
   ThingConfig config;
   // ...
   return c.getDoThingPipeline().completeDoingThing( config );
}

Otros casos de prueba para este ejemplo podrían escribirse más o menos de la siguiente manera (usando Catch2 de Phil Nash)

class FakeDoThingPipeline : public DoThingPipeline {
   public:
      Thing completeDoingThing( const ThingConfig& thingConfig ) override
      {
         completeDoingThingCalls++;
         return Thing();
      }
   public:
      // Yes, this is a very simplistic mocking technique
      int completeDoingThingCalls = 0;
};
TEST_CASE("Make specific thing does the thing") {
   DI container;
   auto doThingPipeline = container.installDoThingPipeline< FakeDoThingPipeline >();
   Thing thing = makeSpecificThing( container );
   REQUIRE( 1 == doThingPipeline.getComponent().completeDoingThingCalls );
}

Algunas variaciones

Otra razón para implementar el tipo de contenedor DI a medida es que puede haber algunas características específicas del proyecto en torno a la inyección de dependencia. A continuación, describiré un par de variaciones obvias que demuestran que las adaptaciones a menudo se pueden implementar sin aumentar significativamente la complejidad del enfoque.

Rendimiento Específicamente sobrecarga de llamada de función virtual

El desafío instintivo para una gran cantidad de código inyectado de dependencia es cuánto afecta esto al tiempo de ejecución de un programa.

Al implementar esta técnica, un enfoque común es hacer que su interfaz sea abstracta y luego implementarla exactamente para un servicio que siempre se usa en el programa real. Luego, la interfaz abstracta proporciona un punto de inyección para los tipos stub, simulacros o falsos que se inyectan con frecuencia en el código de prueba.

El resultado de esto es que, en lugar de realizar llamadas a funciones, el código que proporciona esta capacidad de prueba a menudo termina realizando llamadas a funciones virtuales.

Sin embargo, al utilizar la técnica del contenedor DI, existe una técnica razonablemente conveniente que puede compensar la cantidad de objetos que se están construyendo para desvirtualizar dichas llamadas. Luego, dicho servicio se agrega al contenedor DI y hace posible compilar la unidad con las funciones virtuales cuando se crea el código de prueba, o sin las funciones virtuales cuando se construye el código de versión.

#if defined(TEST_APIS)
#define TESTABLE virtual
#else
#define TESTABLE
#endif
class DoThingPipeline {
   public:
      TESTABLE ~DoThingPipeline() = default;
      TESTABLE Thing completeDoingThing ( const ThingConfig& thingConfig );
};

Aunque en la mayoría de los casos esta técnica es probablemente una optimización prematura, es bastante sencillo aplicarla a clases que implementan principalmente comportamiento sin implementar estado.

Además, cuando el rendimiento no es una preocupación, la técnica de proporcionar el código de implementación real como una llamada de función virtual aún se puede usar para facilitar la sustitución de llamadas reales por stub, falsas o simuladas durante la prueba.

Programas con varios subprocesos

En un programa de subprocesos múltiples, muchos clientes pueden resolver interfaces sin tener necesariamente una API segura para subprocesos para estos servicios. Para habilitar esto, el propio contenedor DI se puede colocar en el almacenamiento local de subprocesos y los objetos de servicio se pueden agregar durante la inicialización del contenedor específico para cada subproceso.

class DI {
   public:
      DI() {
         assert(!activeContainer_);
         activeContainer_ = this;
      }
      ~DI() {
         activeContainer_ = nullptr;
      }

      // The rest of this also looks a lot like the previous example
   private:
      // Each thread now uses a separate DI container object, which ought
      // to be initialized soon after the thread has been started.
      thread_local static DI* activeContainer_;
};

Además de esto, las funciones de inicialización para el contenedor no necesitan ser las mismas ni proporcionar un conjunto coincidente de objetos de servicio.

void initializeAndRun(std::function<void(const DI&)> func) {
   DI container;
   auto threadPool = container.installThreadPool();
   // ... other main thread services are initialized here.
   func(container);
}
void initializeAndRunPerThread(std::function<void(const DI&)> func) {
   DI container;
   auto requestHandler = container.installRequestHandler();
   // ... other per thread services are initialized here.
   func(container);
}

Conclusión

En toda una gran base de código, fomentar el código expresivo puede ser una solución ampliamente aplicada que encaje en muchas partes del programa. Las compensaciones involucradas con esta implementación de inyección de dependencia parecen bastante ergonómicas y naturales.

Cuando se necesite una solución que requiera inyección de dependencia, esta implementación debería ser aplicable de forma rutinaria. La consistencia que esto fomenta, a su vez, hace que sea fácil reconocer la solución familiar que se está aplicando nuevamente, en lugar de una solución menos familiar de la amplia cartera de mecanismos de inyección de dependencia disponibles.

El esquema general surgió de una idea más trillada, agrupar una serie de parámetros de función inyectados en una sola estructura y así reducir el recuento total de parámetros. Esto también tuvo los beneficios de volver a encapsular estas dependencias en la implementación y solo exponer el hecho de que la función estaba usando la inyección de dependencia en la declaración de la función. Incluso esto se vuelve innecesario siempre que esté dispuesto a proporcionar acceso estático al contenedor DI relevante, aunque creo que los casos de prueba parecen leerse más claramente con un parámetro de contenedor DI explícito.

Una de las compensaciones clave en juego aquí parece ser la elección entre forzar la especificación explícita de servicios o, alternativamente, admitir la configuración implícita de los objetos de servicio especificando una implementación predeterminada.

La provisión de una implementación predeterminada que luego se devuelve cuando no se ha instalado ningún servicio explícito es típica de muchos mecanismos similares de inyección de dependencia, especialmente aquellos que implican acceso estático a las interfaces (por ejemplo, a menudo un patrón singleton). Creo que la alternativa aquí de requerir una configuración y desmontaje explícitos de los servicios en el contenedor DI y un lugar claro designado para la inicialización real del contenedor hace que la vida útil del objeto sea comparativamente fácil de observar. También es muy bueno tener una gran parte de esto implementado y administrado automáticamente por el compilador de C++.

En resumen, creo que este patrón podría usarse para satisfacer la mayoría de las necesidades de inyección de dependencia en casi cualquier base de código C++ y, al hacerlo, con frecuencia haría que la base de código fuera más fácil de comprender, flexible y comprobable.