Software Architecture

Au-delà des frameworks : Concevoir des stratégies d'injection de dépendances personnalisées pour les systèmes embarqués et edge

Dans le monde du développement desktop et côté serveur, les frameworks d'injection de dépendances (DI) comme Spring, Guice ou les conteneurs IoC sont une pratique standard. Ils abstractent la complexité de la construction du graphe d'objets, rendant le code modulaire et testable. Cependant, en entrant dans le domaine des systèmes embarqués et de l'informatique edge, ces solutions lourdes deviennent souvent des inconvénients. Les surcoûts liés à la réflexion, à l'allocation dynamique de la mémoire et aux séquences d'initialisation complexes peuvent être inacceptables dans des systèmes dotés d'une RAM limitée, de exigences de latence strictes ou sans système d'exploitation du tout.

Cet article explore comment concevoir des stratégies DI personnalisées et légères qui maintiennent la modularité sans le gonflement. En exploitant la résolution au moment de la compilation, la propriété explicite et une abstraction minimale, nous pouvons obtenir les avantages de la DI dans des environnements à ressources contraintes.

Les problèmes de la DI standard dans les contextes embarqués

Les conteneurs DI traditionnels s'appuient généralement sur deux mécanismes problématiques pour les cibles embarquées :

  • Allocation dynamique de la mémoire : La plupart des conteneurs allouent des objets sur le tas. Dans les systèmes embarqués, la fragmentation du tas et l'imprévisibilité sont des risques majeurs.
  • Réflexion et métadonnées : Le balayage des classes pour les annotations ou les interfaces nécessite une inspection au moment de l'exécution, ce qui est soit impossible en C++ bare-metal, soit extrêmement coûteux en termes de cycles d'instructions et de mémoire.

Pour les appareils edge, où chaque microseconde et chaque kilooctet comptent, nous avons besoin d'une stratégie DI qui soit statique, déterministe et explicite.

Stratégie 1 : Injection de dépendances au moment de la compilation

La solution la plus robuste pour les systèmes embarqués C++ est d'exploiter les gabarits (templates) et l'instantiation au moment de la compilation. Cette approche élimine complètement les surcoûts d'exécution. Les dépendances sont résolues au moment de la compilation, permettant au compilateur d'optimiser et de supprimer le code inutilisé et d'assurer une allocation dynamique nulle.

Considérez un simple `TelemetrySensor` qui a besoin d'un `DataLogger`. Au lieu d'injecter un pointeur au moment de l'exécution, nous lions la dépendance via un paramètre de gabarit :


template <typename Logger>
class TelemetrySensor {
public:
    void read() {
        double value = readHardware();
        logger.log(value);
    }
private:
    Logger logger;
    double readHardware() { /*...*/ }
};

class FileLogger {
public:
    void log(double val) { /*...*/ }
};

// L'instantiation est explicite et statique
TelemetrySensor<FileLogger> mySensor;

Ce motif garantit que le type `Logger` est connu au moment de la compilation. Le graphe d'objets est construit sur la pile ou en mémoire statique, éliminant l'utilisation du tas. L'inconvénient est une flexibilité réduite ; vous ne pouvez pas changer les implémentations au moment de l'exécution sans relier le binaire. Cependant, dans de nombreux scénarios embarqués, l'architecture est fixe après le développement, ce qui rend ce compromis acceptable.

Stratégie 2 : Le Service Locator avec registre statique

Lorsqu'un certain degré de flexibilité au moment de l'exécution est nécessaire (par exemple, pour les tests ou le remplacement à chaud de plugins), un Service Locator léger peut être implémenté sans réflexion. Au lieu de balayer les classes, nous utilisons un registre statique rempli au démarrage.


class ServiceRegistry {
public:
    template <typename T>
    static void registerService(T* instance) {
        services_[typeid(T).name()] = instance;
    }

    template <typename T>
    static T* getService() {
        auto it = services_.find(typeid(T).name());
        return it != services_.end() ? static_cast<T*>(it->second) : nullptr;
    }

private:
    static std::map<const char*, void*> services_;
};

// Utilisation
// 1. Créer des instances en mémoire globale/statique
static Logger myLogger;
static TelemetrySensor mySensor;

// 2. Enregistrer dans init()
void init() {
    ServiceRegistry::registerService(&myLogger);
    ServiceRegistry::registerService(&mySensor);
}

// 3. Accéder
Logger* log = ServiceRegistry::getService<Logger>();

Bien que cela utilise toujours un `std::map` (qui peut être remplacé par un tableau de taille fixe pour une efficacité encore plus grande), cela évite l'allocation dynamique des objets eux-mêmes. Le registre est un point de défaillance unique, il doit donc être utilisé avec parcimonie et uniquement pour des interfaces de base stables.

Stratégie 3 : Injection par constructeur avec motifs de fabrique

Pour des solutions intermédiaires, l'injection explicite par constructeur combinée à des fonctions de fabrique offre un chemin propre et lisible. Les dépendances sont passées explicitement, et les fabriques gèrent la logique de création, gardant les constructeurs propres et axés sur l'initialisation.


class DataProcessor {
public:
    DataProcessor(ILogger* logger, IConfig* config) 
        : logger_(logger), config_(config) {}
    
    void process() { /*...*/ }

private:
    ILogger* logger_;
    IConfig* config_;
};

// La fabrique crée le graphe de dépendances
std::unique_ptr<DataProcessor> createProcessor() {
    auto config = std::make_unique<FileConfig>();
    auto logger = std::make_unique<SyslogLogger>();
    
    // Passer des pointeurs bruts ou utiliser std::shared_ptr si la gestion de la durée de vie est complexe
    return std::make_unique<DataProcessor>(logger.get(), config.get());
}

Cette approche est très testable. Vous pouvez injecter des implémentations simulées (mock) de `ILogger` et `IConfig` dans les tests unitaires sans avoir besoin d'un démarrage complet du système. Elle rend également les dépendances explicites, ce qui aide à la lisibilité et à la maintenance du code.

Meilleures pratiques pour la DI embarquée

  1. Éviter l'allocation dynamique : Utilisez des tableaux `constexpr`, `std::array` ou un stockage statique pour les conteneurs DI lorsque c'est possible.
  2. Minimiser les fonctions virtuelles : Préférez la polymorphie basée sur les gabarits à la distribution virtuelle dans les chemins critiques pour les performances.
  3. Gestion explicite du cycle de vie : Définissez clairement qui possède et détruit les dépendances. Évitez les pointeurs pendants en utilisant `unique_ptr` ou des séquences d'arrêt explicites.
  4. Tester avec des faux (fakes) : Concevez des interfaces faciles à simuler. Une stratégie DI n'est bonne que si elle est testable.

Conclusion

L'injection de dépendances n'est pas une solution universelle. Dans les systèmes embarqués et edge, l'objectif passe de la commodité à la prévisibilité et à l'efficacité. En abandonnant les frameworks lourds et en adoptant la résolution au moment de la compilation, les registres statiques et l'injection explicite par constructeur, les développeurs peuvent construire des systèmes modulaires, testables et haute performance. La clé est d'adapter la stratégie DI aux contraintes de la plateforme cible, en s'assurant que l'architecture sert le matériel, et non l'inverse.

Share: