Plugable code
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 publiquenotify
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 classeApplication::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 leComponent
et son notifier : on ne peut peut plus utiliser leComponent
sans leNotifier
!
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 deextended
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 Component
, on 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 callbackextended
(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 leComponent
et leNotifier
, 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
(leComponent
est subsituable par un autreObject
) - on utilise
method_missing
pour le default forward qui wrappe le méthodeexecute
- 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 deComponent
etProxyNotifier::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 !