Implementing Dynamic Behavior in Go with Scripting Interpreters

Dynamic Linking Fundamentals for Software Modules

In modern software development, enabling dynamic updates or module additions without redeploying the core application is a common requirement. This capability is foundational for features like hot-swapping and plugin architectures.

Platform-Specific Implementations

Two primary mechanisms exist: Dynamic Link Libraries (DLLs) on Windows and Dynamic Shared Objects (DSOs, or .so files) on Linux systems.

Windows Dynamic Link Libraries

  • Enable code and resource sharing across multiple processes.
  • Facilitate modular program design with separate component files.
  • Allow cross-language interoperability through standardized binary interfaces.
  • Simplify updates by replacing individual modules.
  • Provide groundwork for runtime module hot-swapping.
  • Expose programming APIs for external consumption.

Linux Dynamic Shared Objects

DSOs offer the same core advantages as DLLs while addressing platform-specific challenges. They often serve as compatibility layers, abstracting low-level system interfaces to provide consistent APIs for applications.

Interpreted Language Approach

For interpreted languages, dynamic behavior is inherent since source code is executed directly. Modifying the source implements updates without compilasion steps.

Go's Native Dynamic Capabilities

Go, as a compiled language, doesn't natively support dynamic source code loading. The standard library provides the plugin package, but with significant limitations.

go build -buildmode=plugin

Current constraints (as of Go 1.19) include:

  • Limited platform support (Linux, FreeBSD, macOS only)
  • No unload capability after loading
  • Requires reflection for accessing plugin symbols
  • Official development appears stagnant

Third-Party Interpreter: Yaegi

Yaegi (Yet Another Go Interpreter) enables Go source code execution at runtime. Originally developed for the Traefik proxy's plugin system, it supports three primary use cases:

Embedded Interpreter Pattern

package main

import (
    "github.com/traefik/yaegi/interp"
    "github.com/traefik/yaegi/stdlib"
)

func main() {
    engine := interp.New(interp.Options{})
    engine.Use(stdlib.Symbols)
    
    _, err := engine.Eval(`import "fmt"`)
    if err != nil {
        panic(err)
    }
    
    _, err = engine.Eval(`fmt.Println("Running Go dynamically")`)
    if err != nil {
        panic(err)
    }
}

Dynamic Extension Framework

package main

import "github.com/traefik/yaegi/interp"

const extensionCode = `package mathutil
func Double(n int) int { return n * 2 }`

func main() {
    vm := interp.New(interp.Options{})
    
    _, err := vm.Eval(extensionCode)
    if err != nil {
        panic(err)
    }
    
    funcSymbol, err := vm.Eval("mathutil.Double")
    if err != nil {
        panic(err)
    }
    
    doubleFunc := funcSymbol.Interface().(func(int) int)
    result := doubleFunc(21)
    println(result) // Output: 42
}

REPL Environment

Yaegi provides a read-eval-print-loop interface:

$ yaegi
> x := 10 + 32
> import "fmt"
> fmt.Printf("Result: %d\n", x)
Result: 42

Data Exchange Between Host and Interpreter

Input Mechanisms

Multiple approaches exist for passing data into the interpreter environment:

package main

import (
    "fmt"
    "github.com/traefik/yaegi/interp"
    "github.com/traefik/yaegi/stdlib"
)

func main() {
    // Method 1: Command-line arguments
    vm1 := interp.New(interp.Options{
        Args: []string{"test-value"},
    })
    vm1.Use(stdlib.Symbols)
    vm1.Eval(`import "os"`)
    vm1.Eval(`println("Argument:", os.Args[0])`)
    
    // Method 2: Environment variables
    vm2 := interp.New(interp.Options{
        Env: []string{"CONFIG_PATH=/etc/app"},
    })
    vm2.Use(stdlib.Symbols)
    vm2.Eval(`import "os"`)
    vm2.Eval(`println("Config:", os.Getenv("CONFIG_PATH"))`)
    
    // Method 3: Direct assignment
    vm3 := interp.New(interp.Options{})
    vm3.Use(stdlib.Symbols)
    vm3.Eval(`externalData := "injected"`)
    vm3.Eval(`println("Data:", externalData)`)
    
    // Method 4: Function injection
    vm4 := interp.New(interp.Options{})
    vm4.Use(stdlib.Symbols)
    vm4.Eval(`var sharedConfig map[string]interface{}`)
    vm4.Eval(`func LoadConfig(cfg map[string]interface{}) { 
        sharedConfig = cfg 
    }`)
    
    loader, _ := vm4.Eval("LoadConfig")
    configLoader := loader.Interface().(func(map[string]interface{}))
    configLoader(map[string]interface{}{
        "timeout": 30,
        "debug":   true,
    })
    vm4.Eval(`println("Timeout:", sharedConfig["timeout"])`)
}

Output Mechanisms

Retrieving data from the interpreter context:

package main

import (
    "fmt"
    "github.com/traefik/yaegi/interp"
    "github.com/traefik/yaegi/stdlib"
)

func main() {
    // Direct evaluation retrieval
    engine := interp.New(interp.Options{})
    engine.Use(stdlib.Symbols)
    engine.Eval(`computedValue := 100 * 2`)
    
    val, _ := engine.Eval("computedValue")
    numericResult := val.Interface().(int)
    fmt.Printf("Value: %d\n", numericResult)
    
    // Function-based retrieval
    engine.Eval(`
        func RetrieveResult() int {
            return computedValue * 2
        }
    `)
    
    retriever, _ := engine.Eval("RetrieveResult")
    resultFunc := retriever.Interface().(func() int)
    fmt.Printf("Doubled: %d\n", resultFunc())
    
    // Global symbol inspection
    engine.Eval(`appName := "demo"`)
    engine.Eval(`appVersion := 2`)
    
    for symbolName, symbolValue := range engine.Globals() {
        value := symbolValue.Interface()
        switch v := value.(type) {
        case string:
            fmt.Printf("%s -> %q\n", symbolName, v)
        case int:
            fmt.Printf("%s -> %d\n", symbolName, v)
        }
    }
}

Implementation Through Abstract Syntax Trees

Interpreters typical work by parsing source code into Abstract Syntax Trees (ASTs). Go's standard library provides robust AST manipulation capabilities through the go/ast package.

AST Representation Example

Consider the expression: A!=1 && (B>1 || (C<1 && A>2))

This compiles to a tree structure where each node represents a syntactic element:

  • Binary expressions (&&, ||, !=, >, <)
  • Identifiers (A, B, C)
  • Literal values (1, 2)
  • Parenthesized sub-expressions

AST Evaluation Implementation

package main

import (
    "fmt"
    "go/ast"
    "go/parser"
    "go/token"
    "strconv"
)

type EvaluationContext map[string]int

func main() {
    ctx := EvaluationContext{"A": 1, "B": 2, "C": 3}
    
    expressions := []string{
        `A == 1 && (B > 1 || C < 1)`,
        `A != 1 && (B > 1 || (C < 1 && A > 2))`,
    }
    
    for _, expr := range expressions {
        parsed, _ := parser.ParseExpr(expr)
        result := evaluateExpression(parsed, ctx)
        fmt.Printf("%s => %v\n", expr, result)
    }
}

func evaluateExpression(node ast.Expr, ctx EvaluationContext) interface{} {
    switch n := node.(type) {
    case *ast.BinaryExpr:
        left := evaluateExpression(n.X, ctx)
        right := evaluateExpression(n.Y, ctx)
        
        switch n.Op.String() {
        case "&&":
            return left.(bool) && right.(bool)
        case "||":
            return left.(bool) || right.(bool)
        case ">":
            return left.(int) > right.(int)
        case "<":
            return left.(int) < right.(int)
        case "==":
            return left.(int) == right.(int)
        case "!=":
            return left.(int) != right.(int)
        }
        
    case *ast.BasicLit:
        if n.Kind == token.INT {
            val, _ := strconv.Atoi(n.Value)
            return val
        }
        
    case *ast.Ident:
        return ctx[n.Name]
        
    case *ast.ParenExpr:
        return evaluateExpression(n.X, ctx)
    }
    
    return nil
}

Tags: Go Yaegi AST plugin Interpreter

Posted on Sat, 31 Oct 2026 16:05:31 +0000 by nutt318