Nouvelle fonctionnalité
La dernière version de go (1.8) apporte une fonctionnalité que j’attendais depuis très longtemps, les plugins. Ceci dit, cette fonctionnalité a pour l’instant quelques limitations, présentes dans la documentation :
- Elle ne fonctionne que sous Linux (ce qui me convient très bien, vu ce que je veux faire)
- Un plugin n’est initialisé qu’une seule fois et ne peut pas être fermé.
- Il doit s’agir d’un paquet main.
Et j’ai aussi découvert par moi-même quelques petits désagréments :
- Ne fonctionne pas avec des noms de dossier bizarres ; j’avais un plugin dans
/plugins/v0.1/plugin.goet, à l’ouverture du plugin, apparaissait une erreur Symbol not found. - N’aime pas les interfaces comme variables exportées ; j’ai dû me résoudre à exporter le code souhaité sous forme de fonction plutôt que de variable, car cela ne fonctionnait pas avec une interface. Le symbole était bien exporté, mais je n’arrivais pas à le recaster vers l’interface.
Alors, pourquoi est-ce que j’aime autant les plugins ? Ils aident à la distribution de contenu et à la division des tâches. Comment ça ? Comme ceci :
Supposons que vous ayez un ensemble suffisamment grand de fonctionnalités assez indépendantes les unes des autres, chacune gérée par une équipe différente, avec des cycles de versions correctives différents.
La tendance actuelle est de gérer ce scénario sous forme de microservices, mais ce n’est pas toujours possible, ce qui nous amène au scénario proposé.
L’approche typique consiste à ce que chaque équipe travaille sur des fonctionnalités, éventuellement dans des branches de feature, sur le même dépôt et publie une version micro ou corrective à chaque correctif/changement.
Les plugins nous permettent d’aller un cran plus loin :
Avant de commencer
Tout le code de ces exemples se trouve dans un exemple fonctionnel ici.
Définissez vos types comme un contrat :
La seule chose qui doit être gravée dans le marbre entre les versions correctives, ce sont les types ; les modifier peut affecter des choses comme la persistance ou les formats de communication.
Dans le dépôt d’exemple, je n’ai pas choisi un très bon nom pour le paquet de types, j’ai utilisé contract. Dans ce cas, vous pouvez voir une interface très basique pour mon Plugin basique :
// Plugin represet a valid plugin.
type Plugin interface {
IsAcceptable() bool
Version() string
}Implémentez le type dans un dépôt séparé
Dans ce cas, j’ai utilisé le même dépôt, j’ai implémenté quelques plugins, un que je considérerai valide et l’autre invalide.
package main
import "github.com/perrito666/blogpost_goplogins/contract"
// ShowcaseElement is the variable used as the plugin entry
// point.
var ShowcaseElement contract.Plugin = &plugin{"0.1"}
// Showcase returns the current ShowcaseElement.
func Showcase() contract.Plugin { return ShowcaseElement }
type plugin struct {
version string
}
// Version returns this plugin instance version.
func (p *plugin) Version() string {
return p.version
}
// IsAcceptable returns true if this plugin is acceptable for our very
// demanding criteria.
func (p *plugin) IsAcceptable() bool {
return true
}
// This is just because this has to be a main package.
func main() {}Ceci dit, ce code est clairement complètement inutile et conçu pour illustrer un peu l’idée derrière les plugins.
Vous pouvez voir que Showcase n’est là que pour pouvoir exporter correctement ShowcaseElement, puisque je voulais que le type
plugin soit privé et n’exporter que ce que définit contract.Plugin. Tenter d’accéder à ShowcaseElement n’était pas possible
car on ne pouvait pas le caster correctement, alors j’ai créé une fonction d’accès ; le résultat final me convient bien.
Dans le dépôt, vous trouverez une version invalide qui n’a qu’une seule petite et bête variante : la méthode IsAcceptable renvoie false. L’idée derrière cela est que vous devriez écrire du code capable de diagnostiquer si une nouvelle version convient au code ; cela permet davantage de combinaisons entre le code appelant principal et les plugins, mais augmente aussi la possibilité que des combinaisons non testées tournent en production, ce qui n’est pas bon du tout.
Un très bon cas d’usage ici, ce sont par exemple les systèmes distribués sans surveillance où l’on peut avoir des facteurs comme des topologies de réseau différentes ou un matériel sous-jacent différent qui servent de critère de validation pour choisir un plugin.
La dernière pièce de ce puzzle est le chargeur (loader) : vous pouvez avoir un chemin défini pour les plugins et le surveiller pour détecter les changements, provoquant le redémarrage d’un processus en cours d’exécution afin de lui permettre de recharger les plugins. Vous pouvez facilement contrôler l’appelant (comme upstart ou systemd) en sortant avec un statut qui signalera au service de démonisation que cette sortie a été provoquée par de nouveaux plugins. Avoir la sortie contrôlée par votre propre processus, et non par des scripts externes, permet des arrêts plus propres.
Maintenant, pour compléter un exemple, voici un main très simple et basique qui chargera la dernière version convenable du plugin.
Pour cet exemple, le code des plugins est aussi présent dans le même dépôt sous plugin_sources et peut être compilé en appelant build_plugins.sh dans le répertoire des plugins.
func main() {
// findAvailableVersions returns a list of the .so files in version decreasing
// order, this assumes that you named the plugins with the versions, of course.
// The plugin code is quite brittle and based on naming.
pluginVersions, err := findAvailableVersions(pluginFolder)
if err != nil {
fmt.Println(fmt.Errorf("failed to list plugins: %v", err))
}
var acceptedPlugin contract.Plugin
var ok bool
// Iterate versions from newer to older.
for _, version := range pluginVersions {
// Try to open each but dont stop on failure since an older one might work.
p, err := plugin.Open(path.Join(pluginFolder, version))
if err != nil {
fmt.Println(fmt.Errorf("showcase plugin is not available: %v", err))
continue
}
// Lookup for the actual Accessor.
e, err := p.Lookup("Showcase")
if err != nil {
fmt.Println(fmt.Errorf("showcase element is not present: %v", err))
continue
}
// Basic Sanity check that the plugin has the right types (too basic though
// the types could still be wrong).
var pluginAccessor func() contract.Plugin
pluginAccessor, ok = e.(func() contract.Plugin)
if ok {
acceptedPlugin = pluginAccessor()
// Run our validation code.
if acceptedPlugin.IsAcceptable() {
break
}
}
}
// No suitable version found, this is a good moment to actually panic.
if !ok {
fmt.Println(fmt.Errorf("no suitable plugin version found"))
return
}
// If this was an actual daemon this would be a loop of sorts.
fmt.Println(fmt.Sprintf("Found newest valid plugin version: %q", acceptedPlugin.Version()))
}Ce qui précède constitue une belle démonstration de ce que nous pouvons faire : nous pouvons désormais avoir un runner principal et différentes équipes envoyant des plugins à déployer. La taille des binaires distribués sera plus petite, en supposant que vous ayez une segmentation et un isolement corrects dans votre base de code. La possibilité de revenir à des versions antérieures est aussi un grand avantage dans ce cas ; avec le code de résilience approprié en place, vous pouvez avoir un service très robuste qui ne tombe pas en panne à cause de mises à jour micro ratées.
Encore une fois, prenez cela avec des pincettes : il s’agit d’une preuve de concept d’une fonctionnalité très récente assemblée comme une simple démonstration ; je n’ai pas examiné l’implémentation sous-jacente, ni les compromis en termes de performance, de mémoire ou toute autre analyse approfondie.