Nueva funcionalidad
La última versión de go (1.8) trae una funcionalidad que estuve esperando durante muchísimo tiempo, los plugins. Ahora bien, por ahora esta funcionalidad tiene algunas limitaciones, que figuran en la documentación:
- Solo funciona en Linux (lo cual a mí me viene bárbaro, dado lo que quiero hacer)
- Un plugin se inicializa una sola vez y no se puede cerrar.
- Tiene que ser un paquete main.
Y además descubrí un par de detalles molestos por mi cuenta:
- No funciona con nombres de carpeta raros; tenía un plugin en
/plugins/v0.1/plugin.goy al abrirlo aparecía un error Symbol not found. - No le gustan las interfaces como variables exportadas; tuve que recurrir a exportar el código que quería como una función en lugar de una variable porque no funcionaba con una interfaz. El símbolo se exportaba, pero no lograba castearlo de vuelta a la interfaz.
Ahora bien, ¿por qué me gustan tanto los plugins? Ayudan en la entrega de contenido y en la división de tareas. ¿Cómo? Así:
Supongamos que tenés un conjunto lo suficientemente grande de funcionalidades bastante independientes entre sí, cada una gestionada por un equipo distinto, con ciclos de versiones de parche diferentes.
La tendencia actual es manejar este escenario como microservicios, pero eso no siempre es posible, lo cual nos lleva al escenario que proponemos.
El enfoque típico para esto es que cada equipo trabaje en funcionalidades, quizás en ramas de feature, sobre el mismo repositorio y libere una versión micro o de parche con cada arreglo/cambio.
Los plugins nos permiten llevar esto un paso más allá:
Antes de empezar
Todo el código de estos ejemplos se puede encontrar en un ejemplo funcional acá.
Definí tus tipos como un contrato:
Lo único que tiene que quedar grabado en piedra entre versiones de parche son los tipos; cambiarlos puede afectar cosas como la persistencia o los formatos de comunicación.
En el repo de ejemplo no elegí un muy buen nombre para el paquete de tipos, usé contract. En este caso podés ver una interfaz muy básica para mi Plugin básico:
// Plugin represet a valid plugin.
type Plugin interface {
IsAcceptable() bool
Version() string
}Implementá el tipo en un repositorio aparte
En este caso usé el mismo repo, implementé un par de plugins, uno que voy a considerar válido y el otro inválido.
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() {}Ahora bien, este código es claramente inútil por completo y está pensado para mostrar un poco la idea detrás de los plugins.
Podés ver que Showcase está ahí solamente para poder exportar ShowcaseElement como corresponde, ya que quería que el tipo
plugin fuera privado y exportar únicamente lo que define contract.Plugin. Intentar acceder a ShowcaseElement no era posible
porque no se lo podía castear correctamente, así que creé una función de acceso; el resultado final me viene bien.
En el repo vas a encontrar una versión inválida que tiene una única variante muy pequeña y tonta: el método IsAcceptable devuelve false. La idea detrás de esto es que deberías escribir código capaz de diagnosticar si una nueva versión es apta para tu código; esto permite más combinaciones entre el código principal que llama y los plugins, pero también aumenta la posibilidad de que corran en producción combinaciones no probadas, lo cual no es para nada bueno.
Un caso de uso muy lindo acá es, por ejemplo, sistemas distribuidos desatendidos donde podés tener factores como distintas topologías de red o hardware subyacente que sean los que se usan como criterio de validación para elegir un plugin.
La parte final de este rompecabezas es el cargador (loader): podés tener una ruta definida para los plugins y vigilarla en busca de cambios que disparen el reinicio de un proceso en ejecución para permitirle recargar los plugins. Podés controlar fácilmente al invocador (como upstart o systemd) saliendo con un estado que le indique al servicio de daemonización que esta fue una salida provocada por nuevos plugins. Tener la salida controlada por tu propio proceso y no por scripts externos permite apagados más limpios.
Ahora, para completar un ejemplo, acá va un main muy simple y sencillo que carga la última versión apta del plugin.
Para este ejemplo, el código de los plugins también está presente bajo el mismo repo en plugin_sources y se puede compilar llamando a build_plugins.sh dentro del directorio de 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()))
}Lo de arriba es una linda muestra de lo que podemos hacer: ahora podemos tener un runner principal y distintos equipos enviando plugins para desplegar. El tamaño de los binarios distribuidos va a ser más chico, suponiendo que tengas una segmentación y aislamiento adecuados en tu base de código. La posibilidad de volver a versiones anteriores también es una gran ventaja en este caso; con el código de resiliencia apropiado podés tener un servicio muy robusto que no se cae ante actualizaciones micro fallidas.
De nuevo, tomá esto con pinzas: es una prueba de concepto de una funcionalidad muy nueva armada como una simple demostración; no analicé la implementación subyacente, ni los compromisos en términos de rendimiento o memoria ni ningún otro análisis en profundidad.