Plugable code

ruby 21 juin 2025

Besoin

On imagine un composant en charge de réaliser une action et  qui doit notifier via une chat (Slack,Mattermost,Teams) lors du traitement de celle-ci  :

Principe

Le but du document est de mettre en évidence le code qui permet de gérer de façon modulaire et externe (plugin) la notification et donc d'ajouter de nouveaux types de notifier

Etape 1

 soit le code de base :

module Application  
   class Component  
      def initialize  
           #code d'initialisation  
      end  
      def action              
           #code action       
           puts 'action'  
       end  
   end  
end  

Notre objectif concret est donc de faire la notification en fin d'éxecution de la méthode action

 Remarque : le code ne montrera pas le code de contrôle d'opération
def action              
    #code action       
     call_notifier notifier: @current_notifier, message: "test"  
end  

Cette méthode call_notifier devra donc être ajoutée à l'objet Component, on va le faire via mixin :

module Application  
    module Notifiers  
        DEFAULT = :mattermost  
        def call_notifier(notifier:  DEFAULT,message: )  
           #code de notification  
        end  
   end  
  
    class Component  
  
        include Application::Notifiers  
  
        def initialize  
            #code d'initialisation  
        end  
  
        def action  
            #code action  
            puts 'action'   
            call_notifier notifier: @current_notifier, message: "test"  
        end  
  
    end  
end  
  
  
my_component = Component::new  
my_component.action  

Si on exécute notre code, rien ne change, on obtient :

$ ruby test.rb  
action  
$  

Etape 2 

maintenant, on va implémenter le notifier par défaut : Mattermost, pour cela on va créer une classe Application::Notifiers::Mattermost

Celle-ci possèdera une méthode de classe notify 

module Application  
    module Notifiers  
  
        DEFAULT = :mattermost  
         
        def call_notifier(notifier:  DEFAULT,message: )  
            Object.const_get("Application::Notifiers::#{notifier.capitalize}").notify message  
        end   
  
        class Mattermost  
            def self.notify message  
                puts "via mattermost : #{message}"  
            end  
        end  
  
    end  
  
    class Component  
  
        include Application::Notifiers  
        attr_accessor :current_notifier  
        def initialize  
           # code d'initalisation  
        end  
  
        def action  
            #code action  
            puts 'action'  
            call_notifier notifier: @current_notifier, message: "execution de action"  
        end  
  
    end  
end  

 on créé donc une methode factory/wrapper call_notifier dans le mixin Application::Notifiers

 Remarque : on va donc par la suite proposer une structure conventionnelle simple pour les Notifier, basée sur le principe de Liskov

Convention :

  • le Notifier == Class
  • le Notifier namespace : Application::Notifiers
  • le Notifier implémente toujours la méthode de class publique notify qui reçoit en paramètre une chaine : message
Remarque : On observe l'avantage d'un langage interprété dynamique sur l'appel en indirection sur la méthode de classe self.notify basé sur le récupération dans l'object namespace de l'objet de type :

Application::Notifiers::Mattermost 

Etape 3

 On va maintenant créer l'interface de plugin à part entière, pour cela il nous faut gérer une méthode d'enregistrement de plugin register_notifiers, elle aussi conventionnelle :

  • Un plugin doit se trouver dans le répertoire relatif "./plugins" (on pourrait choisir n'importe quel chemin et aussi faire du cumulative path merging pour élargir la recherche à plusieurs chemins potentiels)
  • Un plugin est un fichier *.rb
  • Le nom des fichier doit correspondre au downcase du nom de la classe du Plugin Notifier, ex : fichier slack.rb pour classe Application::Notifiers::Slack
 Remarque : on va encore une fois ici bénéficier d'un des avantages d'un langage interprété dynamique comme Ruby  : la réimplémentation dynamique

Un module peu être réimplémenté

on va créer aussi une gestion de notifier par défaut, modifiable via un accesseur R/W sur l'attribut @current_notifier et initialisé par la méthode init_notifiers aussi en charge d'appeler la méthode d'enregistrement

module Application  
    module Notifiers  
  
        DEFAULT = :mattermost  
        def init_notifiers  
            @notifiers  = [:mattermost]  
            register_notifiers  
            return @notifiers.first  
        end  
  
        attr_reader :notifiers  
  
        def call_notifier(notifier:  DEFAULT,message: )  
            Object.const_get("Application::Notifiers::#{notifier.capitalize}").notify message  
        end   
  
        private  
        def register_notifiers  
            Dir[File.dirname(__FILE__) + '/plugins/*.rb'].each {|file|   
                plugin = File::basename(file, '.rb')  
                @notifiers.push plugin.to_sym;   
                require  file  
            }  
        end  
  
        class Mattermost  
            def self.notify message  
                puts "via mattermost : #{message}"  
            end  
        end  
  
    end  
  
    class Component  
  
        include Application::Notifiers  
        attr_accessor :current_notifier  
  
        def initialize  
            @current_notifier = init_notifiers  
            # code d'initialisation  
        end  
  
        def action  
            # code action  
            puts 'action'  
            call_notifier notifier: @current_notifier, message: "execution de action"  
        end  
  
    end  
end  
  
my_component = Component::new  
my_component.action  

 si on exécute ce code on obtient :

$ ruby test.rb  
action  
via mattermost : execution de action  
$  

Si on créé un plugin dans ./plugins/teams.rb du type :

module Application  
    module Notifiers  
  
         class Teams  
            def self.notify message  
                puts "via teams: #{message}"  
            end  
        end  
  
    end  
end  

 On va :

  • vérifier que le plugins est enregistré
  • l'utiliser en changeant le @current_notifier et afficher la liste des @notifiers via les accesseurs
[reste du code ]  
my_component = Component::new  
p my_component.notifiers  
my_component.action  
my_component.current_notifier = :teams  
p my_component.current_notifier  
my_component.action  

 

Si on éxecute ce code, on obtient :

$ ruby test.rb  
[:mattermost,:teams]  
action  
via mattermost : execution de action  
:teams  
action  
via teams : execution de action  
$  

 CQFD, oui mais non !

 

 Problème :  l'empreinte dans le code du composant est trop forte et on vient de créer un couplage fort entre le Component et son notifier : on ne peut peut plus utiliser le Component sans le Notifier !

Etape 4

On va donc faire une première modification transitoire et passer d'un mixin par include sur la classe à un extend sur l'instance :

 

module Application  
    module Notifiers  
  
        DEFAULT = :mattermost  
        def self.init_notifiers  
            @@notifiers  = [:mattermost]  
            self.register_notifiers  
            return @@notifiers.first  
        end  
  
        def notifiers; @@notifiers; end  
        def current_notifier; @@current_notifier; end  
        def current_notifier=(val); @@current_notifier = val; end  
  
  
        def call_notifier(notifier:  DEFAULT,message: )  
  
            Object.const_get("Application::Notifiers::#{notifier.capitalize}").notify message  
  
        end   
  
        def self.extended(mod)  
            @@current_notifier = self.init_notifiers  
          end  
  
  
        private  
        def self.register_notifiers  
            Dir[File.dirname(__FILE__) + '/plugins/*.rb'].each {|file|   
                plugin = File::basename(file, '.rb')  
                @@notifiers.push plugin.to_sym;   
                require  file  
            }  
        end  
  
        class Mattermost  
  
            def self.notify message  
                puts "via mattermost : #{message}"  
            end  
        end  
    end  
  
    class Component  
  
        include Application::Notifiers  
        def initialize  
            # code d'initialisation  
        end  
  
        def action  
            #code action  
            puts 'action'  
            call_notifier notifier: @@current_notifier, message: "test"  
        end  
  
    end  
end  
  
test = Application::Component::new  
test.extend Application::Notifiers  
p test.notifiers  
test.action  
  
test.current_notifier = :teams  
test.action  

On voit ligne 61 l'extend, pour que le code continue de fonctionner, on va devoir :

  • utiliser un callback Ruby exented qui va s'éxecuter au moment de l'include, hélas celui-ci, s'éxecute sur le contexte du module et non de la classse
  • passer les attributs  @notifiers et @current_notifier en attributs de classe car il vont être set lors de l'éxecution de extended
 Remarque : Ligne 11,12 et 13 on voit aussi le besoin de faire de accesseurs sur attribut de classe, l'usage des macros attr_* n'est plus possible, mais on les remonte tous dans le module Notifiers

Si on exécute ce code, tout marche comme avant :

$ ruby test.rb  
[:mattermost,:teams]  
action  
via mattermost : execution de action  
action  
via teams : execution de action  
$  
 Remarque : dans cette phase transitoire pour le ligne 54 puisse fonctionner on doit garde l'include dans le Component, il ne s'agit donc pas d'un code valide

Etape 5

Notre objectif ici est de ne plus avoir besoin de l'include, de ne plus initialiser les Notifiers depuis le Component et de limiter l'usage des attributs de classe.

Le but est de faire disparaitre tout le code de Notification dans le Componenton va donc modifier le code tel que :

module Application  
  
    module Notifiers  
  
        DEFAULT = :mattermost  
        def self.init_notifiers  
            @@notifiers  = [:mattermost]  
            self.register_notifiers  
            return @@notifiers.first  
        end  
  
        def notifiers;  @@notifiers; end   
        attr_accessor :current_notifier  
  
        def call_notifier(message: )  
            Object.const_get("Application::Notifiers::#{@current_notifier.capitalize}").notify message  
        end   
  
        def execute(method,*args, &block)  
          self.send method, *args, &block  
          call_notifier  message: "execute #{method}"  
        end  
  
        def self.extended(mod)  
            mod.instance_variable_set "@current_notifier", self.init_notifiers  
        end  
  
        private  
        def self.register_notifiers  
            Dir[File.dirname(__FILE__) + '/plugins/*.rb'].each {|file|   
                plugin = File::basename(file, '.rb')  
                @@notifiers.push plugin.to_sym;   
                require  file                 
            }  
        end  
  
        class Mattermost  
            def self.notify message  
                puts "via mattermost : #{message}"  
            end  
        end  
  
    end  
  
    class Component  
  
        def initialize  
            # code d'initialisation  
        end  
  
        def action  
            #code action            
        end  
  
    end  
end  
  
test = Application::Component::new  
test.extend Application::Notifiers  
p test.notifiers  
test.execute :action  
test.current_notifier = :teams  
test.execute :action  

On introduit donc une méthode execute wrapper qui va servir à notifier et forwarder l'execution d'une méthode de Component on peut donc d'ores et déjà remettre @current_notifier en attribut

 Remarque : on a bien réussit à supprimer toutes les dépendances aux Notifiers dans le component et supprimé le couplage fort, mais par contre on vient de provoquer une modification de prototype d'appel :

test.action

devient

test.execute action

 

dans la pratique cela pourrait engager de grosses refactorisation de code

Remarque 2 : il nous reste malheureusement encore un attribut de classe, du à notre séparation en méthode (appelé lors du callback en scope module extended

Etape 6

 

Refactorisation du code d'initialisation et de registering des plugins et suppression du dernier attribut de classe @@notifiers:

module Application  
  
  
    class Component  
        def initialize  
            # code d'initialisation  
        end  
        def action  
            #code action  
        end  
    end  
  
    module Notifiers  
  
        DEFAULT = :mattermost  
  
        attr_reader :notifiers  
        attr_accessor :current_notifier  
  
        def call_notifier(message: )  
            Object.const_get("Application::Notifiers::#{@current_notifier.capitalize}").notify message  
        end   
  
        def execute(method,*args, &block)  
          self.send method, *args, &block  
          call_notifier  message: "execute #{method}"  
        end  
  
        def self.extended(mod)  
            plugins = [DEFAULT]  
            Dir[File.dirname(__FILE__) + '/plugins/*.rb'].each {|file|   
                plugin = File::basename(file, '.rb')  
                plugins.push plugin.to_sym;   
                require  file  
            }  
            mod.instance_variable_set "@notifiers", plugins  
            mod.instance_variable_set "@current_notifier", plugins.first  
        end  
  
        class Mattermost  
            def self.notify message  
                puts "via mattermost : #{message}"  
            end  
        end  
  
    end  
end  
  
test = Application::Component::new  
test.extend Application::Notifiers  
p test.notifiers  
test.execute :action  
test.current_notifier = :teams  
test.execute :action  

On peut donc constater:

  • la suppression/intégration du code d'init et registering dans le callback extended
  • l'utilisation de instance_variable_set sur le scope instance de la référence extended sur le callback extended (pour set les variables d'instances)
  • l'usage exhaustif de macros attr_* sur des attributs d'instance
 Remarque : on vient de faire une inversion de contrôle entre le Component et le Notifier, ne reste donc plus que le pbm de prototype

Etape 7

 Pour en finir avec notre dernier pbm de changement de prototype, la solution un ProxyForward

d'ou le code suivant

module Application  
  
    class Component  
  
        def initialize  
            # code d'initialisation  
        end  
  
        def action  
            #code action  
        end  
  
        def action2(status: true)  
            # action status true/false  
            return status  
        end   
  
    end  
  
    module Notifiers  
  
        DEFAULT = :mattermost  
  
        attr_reader :notifiers  
        attr_accessor :current_notifier  
  
        def call_notifier(message: )  
            Object.const_get("Application::Notifiers::#{@current_notifier.capitalize}").notify message  
        end   
  
        def execute(method,*args, &block)  
          self.send method, *args, &block  
          call_notifier  message: "execute #{method}"  
        end  
  
        def self.extended(mod)  
            plugins = [DEFAULT]  
            Dir[File.dirname(__FILE__) + '/plugins/*.rb'].each {|file|   
                plugin = File::basename(file, '.rb')  
                plugins.push plugin.to_sym;   
                require  file  
            }  
            mod.instance_variable_set "@notifiers", plugins  
            mod.instance_variable_set "@current_notifier", plugins.first  
        end  
  
        class Mattermost  
            def self.notify message  
                puts "via mattermost : #{message}"  
            end  
        end  
    end  
  
  
class ProxyNotifier  
  
    def initialize(notifier: true, type: nil, object: Application::Component::new )         
        @component = object   
        if notifier then   
          @component.extend Application::Notifiers  
          @component.current_notifier = type if type   
        end  
    end  
  
  
    def method_missing(m, *args, &block)  
        @component.execute m, *args, &block unless Application::Notifiers.method_defined? m  
    end  
  
    def notifiers; @component.notifiers; end      
    def current_notifier; @component.current_notifier; end  
    def current_notifier=(val); @component.current_notifier=(val); end  
   
end  
  
end  
  
test2  = Application::ProxyNotifier::new  
p test2.notifiers  
test2.action  
test2.current_notifier = :teams  
test2.action  

On a donc ajouté un ProxyNotifier qui va se substituer et instancier le Component :

  • il a la charge de construire le Component, de l'extend du notifier ou pas et de définir le current_notifier (le Component est subsituable par un autre Object)
  • on utilise method_missing pour le default forward qui wrappe le méthode execute
  • on ajoute des directs forwards pour les méthodes mixed par l'extend du Module Notifiers

  On a donc de nouveau un prototype d'appel du type :

test2.action

 Remarque : à condition de faire l'unique refacto des Construction de Component et ProxyNotifier::new object: Application::Component::new

Etape Finale

 Les directs forward peuvent se faire avec le module Stdlib de Ruby : Forwardable

 on obtient donc le code final

require 'forwardable'  
module Application  
  
    class Component  
  
        def initialize  
            # code d'initialisation  
        end  
  
        def action  
            #code action  
        end  
  
    end  
  
    module Notifiers  
  
        DEFAULT = :mattermost  
  
        attr_reader :notifiers  
        attr_accessor :current_notifier  
  
        def call_notifier(message: )  
            Object.const_get("Application::Notifiers::#{@current_notifier.capitalize}").notify message  
        end   
  
        def execute(method,*args, &block)  
          self.send method, *args, &block  
          call_notifier  message: "execute #{method}"  
        end  
  
        def self.extended(mod)  
            plugins = [DEFAULT]  
            Dir[File.dirname(__FILE__) + '/plugins/*.rb'].each {|file|   
                plugin = File::basename(file, '.rb')  
                plugins.push plugin.to_sym;   
                require  file  
            }  
            mod.instance_variable_set "@notifiers", plugins  
            mod.instance_variable_set "@current_notifier", plugins.first  
        end  
  
        class Mattermost  
            def self.notify message  
                puts "via mattermost : #{message}"  
            end  
        end  
  
    end  
  
   class ProxyNotifier  
  
      extend Forwardable  
  
      def initialize(notifier: true, type: nil, object: Application::Component::new )  
          @component = object   
          if notifier then   
            @component.extend Application::Notifiers  
            @component.current_notifier = type if type   
          end  
      end  
  
      def method_missing(m, *args, &block)  
        @component.execute m, *args, &block unless Application::Notifiers.method_defined? m  
      end  
  
      def_delegators :@component, :notifiers, :current_notifier, :current_notifier=  
     
  end  
  
end  
  
  
test2  = Application::ProxyNotifier::new  
p test2.notifiers  
test2.action  
test2.current_notifier = :teams  
test2.action

Cette fois CQFD !

Mots clés

Romain GEORGES

Open Source evangelist & Ruby enthousiast