Die Golumne

Einflach flexibler: Plug-ins mit Go

Frank Müller

© Reneé French (CC BY 3.0 DE) ©SuS Media

Ein Großteil der Projekte auf Basis von Go sind reine Open-Source-Anwendungen oder unternehmensinterne Anwendungen, die Open Source nutzen. In beiden Fällen sind modulare Erweiterungen auf Basis vordefinierter APIs kein Problem. Als Entwickler eines Systems definiere ich ein Interface, und der erweiternde Entwickler kann es nach seinen Vorstellungen implementieren. Anschließend wird vom neuen Typ eine Instanz erzeugt und an mein System übergeben. So oder in ähnlicher Form ein altbekanntes Muster.

Doch wie schaut es aus, wenn ich ein geschlossenes Binary ausliefern möchte, es jedoch durch den Nutzer erweiterbar sein soll? Oder ich plane, dass Nutzer meines mit Go erstellten Binarys dieses durch mitgelieferte Komponenten individualisieren können sollen? Auch hierfür bietet Go die Unterstützung, allerdings mit Einschränkungen. Dazu später mehr. Die Basis für die Erweiterungen sind die Plug-ins. Der Compiler erlaubt die Erzeugung von Shared Libraries in einer für Go nutzbaren Variante, das Package plugin erlaubt das dynamische Laden und die Nutzung der exportierten Komponenten. Hierbei handelt es sich um die globalen Variablen und Funktionen des jeweiligen Plug-ins.

Auf diesem Weg kann ich austauschbare Komponenten entwickeln, zum Beispiel für die Unterstützung unterschiedlicher Sprachen in meinem Programm. Ein anderer Anwendungsfall ist die Integration externer Systeme. Hier definiere ich Funktionen oder ein Interface, das ich von einem Plug-in erwarte, und lade es auf Basis meiner Konfiguration. Und natürlich kann ich auch mehrere Plug-ins mit gleicher Schnittstelle parallel laden und eine Laufzeitumgebung für ihre Zusammenarbeit bilden, zum Beispiel in einem System zum Event Processing.

Jedoch deuten die Shared Libraries schon eine Einschränkung an. Während eine Erweiterung über Quellcode in der Regel auf allen unterstützten Plattformen von Go zum Einsatz kommen kann, sind die Plug-ins derzeit auf die Betriebssysteme Linux und macOS eingeschränkt. Gleichzeitig müssen die Laufzeitsysteme des Hauptprogramms und der Bibliotheken natürlich zueinander passen. Doch wenn das zu den eigenen Anforderungen passt, spricht nichts gegen den Einsatz der Plug-ins.

Lesen Sie auch: Coden in Go: Für das Plus an Produktivität und Effizienz

Technisch gesehen ist die Implementierung eines Plug-ins trivial. Denn eigentlich handelt es sich nur um ein Package mit dem Namen main ohne die sonst übliche main()-Funktion. Vielmehr enthält das Package exportierte Funktionen und Variablen. Letztere können einfache Datentypen, aber auch Strukturen mit Methoden sein. Dies wird anschließend mit dem Befehl go build -buildmode=plugin übersetzt. Ergebnis ist die Library mit dem Names des Verzeichnisses, ganz entsprechend der Übersetzung von ausführbaren Programmen. Eine Datei foo/main.go wird also zur Datei foo/foo.so. Und wie auch bei Programmen, lässt sich hier mit dem Parameter -o ein alternativer Name setzen.

Plug-ins entwickeln

Ein kleines Szenario soll nun die praktische Umsetzung zeigen. Wir möchten eine kleine Serveranwendung ausliefern und hierin dem Nutzer ermöglich, sein Logging zu individualisieren. Wir selbst liefern ein Plug-in für das Standard-Logging von Go mit. Andere Nutzer möchten aber vielleicht das syslog nutzen oder den Server in ihren Stack aus Elasticsearch, Fluentd und Kibana (EFK) integrieren, um das Verhalten unterschiedlicher Services in ihrer Landschaft besser überwachen und auswerten zu können. Hierfür oder für andere Lösungen können dann beliebige Plug-ins als Treiber entwickelt werden.

Die zu erfüllende Schnittstelle eines Plug-ins soll an dieser Stelle eine Variable Logger sein. Ihr Typ erfüllt ein Interface mit den entsprechenden Methoden für die Konfiguration des Plug-ins – in diesem Fall generisch ein Byte-String mit der individuellen Konfiguration in JSON – und den Ausgabemethoden für die jeweiligen Loglevel (Listing 1).

 type API interface {
  SetConfig(data []byte) error

  Debug(msg string)
  Info(msg string)
  Warning(msg string)
  Error(msg string)
  Critical(msg string)
  Fatal(msg string)
}

var Logger API

Mehr erwarten wir nicht. Ein interner Wrapper, der die Plug-in-Instanz nutzt, kümmert sich um die weiteren Details. Nun wird es also Zeit für die Implementierung unseres Plug-ins für das Standard-Logging (Listing 2).

package main

import (
  "encoding/json"
  "log"
  "os"
  "time"
)

const timeFormat = "2006-01-02 15:04:05 Z07:00"

type config struct {
  Out    string `json:"out"`
  Prefix string `json:"prefix"`
  Flag   int    `json:"flag"`
}

type api struct {
  logger *log.Logger
}

func (a *api) SetConfig(data []byte) error {
  var cfg config
  err := json.Unmarshal(data, &cfg)
  if err != nil {
    return err
  }
  f, err := os.Open(cfg.Out)
  if err != nil {
    return err
  }
  a.logger = log.New(f, cfg.Prefix, cfg.Flag)
  return nil
}

func (a *api) Debug(msg string) {
  now := time.Now().Format(timeFormat)
  a.logger.Printf("%s [DEBUG] %s\n", now, msg)
}

...

Zusammen mit den weiteren Logging-Methoden erfüllt der Typ api das als Anforderung an die Plug-ins formulierte Interface. Nun müssen wir nur noch eine Instanz erzeugen und diese als globale Variable Logger exportieren, dann ist das Plug-in bereits fertig.

var Logger = &api{}

Natürlich lassen sich auch andere Varianten vorstellen. Zum Beispiel nur exportierte globale Funktionen, die einem logischen Interface entsprechen. Doch das wird dann beim Laden in der nutzenden Anwendung umständlicher. Eine andere Lösung wäre der Export einer Funktion, die ein Lazy Init auf einer privaten globalen Variable durchführt und den konfigurierten Typ zurückgibt (Listing 3). Doch das ist Geschmackssache.

var logger *api
var err error

func Logger(data []byte) (*api, error) {
  sync.Once(func() {
    logger = &api{}
    err = logger.setConfig(data)
    if err != nil {
      logger = nil
    }
  })
  return logger, err
}

Die Einfachheit der Nutzung eines Plug-ins zeigt nun unser Logger Package des Servers. Es enthält unter anderem eine Funktion zum Laden des Plug-ins und die Rückgabe der Logger-Instanz. Sie fasst alle notwendigen Schritte zusammen (Listing 4).

package logger

import (
  "errors"
  "plugin"
)

// loggerAPI für das Type Casting.
type loggerAPI interface {
  SetConfig(data []byte) error
  
  Debug(msg string)
  Info(msg string)
  Warning(msg string)
  Error(msg string)
  Critical(msg string)
  Fatal(msg string)
}

func loadLogger(
  filename string,
  data []byte,
) (loggerAPI, error) {
  // Plugin laden.
  p, err := plugin.Open(filename)
  if err != nil {
    return nil, err
  }
  // Variable "Logger" suchen.
  l, err := p.Lookup("Logger")
  if err != nil {
    return nil, err
  }
  // Typ Casting auf unsere API durchführen.
  la, ok := l.(loggerAPI)
  if !ok {
    return nil, errors.New("illegal logger API export")
  }
  // Konfiguration des Loggers.
  err = la.SetConfig(data)
  if err != nil {
    return nil, err
  }
  return la, nil
}

Mehr benötigen wir nicht, um die Logging-Plug-ins zu laden. Mit loadConfig("loggers/standard.so", config) laden und konfigurieren wir unseren Standard-Logger. Alternativ können mit loadConfig("myloggers/syslog.so", config) die syslog-Variante und mit einem loadConfig("myloggers/efk.so", config) die Variante für den EFK-Stack geladen werden.

Innerhalb der Funktion wird mit der Funktion plugin.Open() die oben als Plug-in übersetzte Shared Library geladen, also zum Beispiel die loggers/standard.so. Klappt das, ist die erste Hürde überwunden. Damit haben wir eine Instanz des Typs plugin.Plugin. Der Aufruf von Open() ist für den Einsatz in nebenläufigen Anwendungen sicher, und das Plug-in wird unter dem angegeben Pfad gespeichert. Es wird also nicht mehrfach geladen. Die ermittelte Instanz verwaltet alle exportierten Variablen und Funktionen, die sich anschließend mit der Methode Lookup() als plugin.Symbol zurückgeben lassen. Wird das Symbol gefunden, sind wir wieder einen Schritt weiter. Und auch dieser Zugriff ist in nebenläufigen Routinen sicher.

Ein Blick in die Dokumentation des Package plugin zeigt, dass Symbol nichts anderes als ein Empty Interface ist: type Symbol interface{}.

Für die Nutzung müssen wir nun also noch einen entsprechenden Type Cast durchführen. Doch dann ist es endlich soweit, wir haben unsere nutzbare Variable oder unsere Funktion, je nach Plug-in. Im Fall des Loggers ist es hier die Variable mit der Instanz eines Logging API. Praktisch ist dank der Interfaces, dass uns der konkrete Typ nicht interessiert. Wir sind nur daran interessiert, dass er unser lokal definiertes Interface erfüllt.

Nun können wir den eigentlichen Logger im Server implementieren. Dieser kapselt das geladene Plug-in und bietet öffentliche Komfortfunktionen. Das zu ladende Plug-in und dessen Konfiguration entnimmt der Logger der Systemkonfiguration (Listing 5).

package logger

import (
  "fmt"
  
  "../config"
)

type Logger struct {
  api loggerAPI
}

func New(cfg config.Config) (*Logger, error) {
  api, err := loadLogger(cfg.Logger.Filename, cfg.Logger.Config)
  if err != nil {
    return nil, err
  }
  return &Logger{
    api: api,
  }, nil
}

func (l *Logger) Debug(msg string) {
  l.api.Debug(msg)
}

func (l *Logger) Debugf(format string, a ...interface{}) {
  msg := fmt.Sprintf(format, a...)
  l.api.Debug(msg)
}

...

Nun verfügt unser Server über einen Logger, bei dem der Nutzer unsere mitgelieferten wie auch eigene Plug-ins für die Ziele integrieren kann.

Und mehr?

Doch manchmal passen die eigenen Anforderungen und die Einschränkungen durch die Plug-ins nicht zusammen. Ich möchte mehr Betriebssysteme unterstützen oder auch andere Sprachen. Und dies vielleicht auch über Systemgrenzen hinweg. In diesem Fall muss ein Umdenken eintreten.

Denn warum müssen Plug-ins Shared Libraries sein? Andere größere Systeme machen es vor. Sie stellen ein API zur Verfügung, mit dem externe Anwendungen kommunizieren können oder über das sie bidirektional mit einer externen Anwendung kommunizieren. Und dabei geht es nicht immer nur um eine Nutzung einer Anwendung wie die unsrige, sondern auch um ihre Erweiterung. Die Kubernetes Operators zeigen dies beispielhaft. Aus ihrer eigenen Sicht heraus sind es einfach nur Anwendungen, die ein API nutzen. Aus Sicht des Gesamtsystems sind es Plug-ins.

Für die Implementierung dieser Schnittstellen gibt es viele Möglichkeiten. JSON-RPC, ein RESTful API oder ein komplett eigenes Protokoll – das muss in Abhängigkeit von den eigenen Anforderungen und Ressourcen entschieden werden. Sicherheit und Verfügbarkeit haben in verteilten Umgebungen ein anderes Niveau. Doch der Blick über den Tellerrand zeigt, dass mit dem Package plugin von Go noch lange nicht Schluss ist und erweiterbare Anwendungssysteme durchaus möglich sind.

Verwandte Themen:

Geschrieben von
Frank Müller
Frank Müller
Der Oldenburger Frank Müller ist seit über dreißig Jahren in der IT zu Hause und im Netz vielfach als @themue anzutreffen. Das Interesse an Googles Sprache Go begann 2009 und führte inzwischen zu einem Buch sowie mehreren Artikeln und Vorträgen zum Thema.
Kommentare

Hinterlasse einen Kommentar

Hinterlasse den ersten Kommentar!

avatar
4000
  Subscribe  
Benachrichtige mich zu: