PluginGen - Type-Safe Plugin Code Generator
Overview
PluginGen is a code generation tool that solves the type erasure problem at plugin boundaries in GoAgent. It generates strongly-typed Go code from schema definitions, eliminating runtime reflection overhead and providing compile-time type safety.
Problem Statement
Current Challenge
In GoAgent's plugin system, the use of generics (Runnable[I, O]) is powerful for type safety within the core framework, but plugin boundaries必然退化为 map[string]any:
// Plugin interface must be dynamic
type Plugin interface {
Execute(input map[string]any) (map[string]any, error)
}
// Manual conversion with runtime overhead
func convertToType[T any](data map[string]any) (T, error) {
bytes, _ := json.Marshal(data) // ❌ Slow: JSON marshaling
var result T
json.Unmarshal(bytes, &result) // ❌ Slow: JSON unmarshaling
return result, nil
}
Problems:
- ❌ Runtime Reflection: JSON marshaling/unmarshaling is slow
- ❌ No Compile-Time Safety: Type errors only caught at runtime
- ❌ Poor Performance: ~1000ns/op overhead per conversion
- ❌ No Validation: Missing required fields silently ignored
Solution
PluginGen generates strongly-typed conversion code at compile time:
// Generated code - no reflection, full type safety
func CalculatorInputFromMap(data map[string]any) (*CalculatorInput, error) {
if data == nil {
return nil, errors.New("input data is nil")
}
result := &CalculatorInput{}
// Direct type assertions - fast
if val, ok := data["operation"]; ok {
if typed, ok := val.(string); ok {
result.Operation = typed
} else {
return nil, fmt.Errorf("field 'operation' has wrong type")
}
}
// Required field validation
if result.Operation == "" {
return nil, fmt.Errorf("required field 'operation' is missing")
}
return result, nil
}
Benefits:
- ✅ 10x Faster: Direct type assertions vs JSON marshaling
- ✅ Compile-Time Safety: Type errors caught during generation
- ✅ Zero Reflection: No runtime overhead
- ✅ Validated Conversion: Required field checks built-in
Installation
方法 1:使用 Makefile(推荐)
在 GoAgent 项目根目录下:
# 构建 plugingen 到 tools/plugingen/plugingen
make plugingen
# 或安装到 $GOPATH/bin(全局可用)
make plugingen-install
方法 2:使用 go install
# 从 GitHub 安装最新版本
go install github.com/kart-io/goagent/tools/plugingen/cmd/plugingen@latest
# 验证安装
plugingen version
方法 3:从源码构建
# 克隆仓库
git clone https://github.com/kart-io/goagent.git
cd goagent
# 构建
cd tools/plugingen/cmd/plugingen
go build -o plugingen .
# 移动到 PATH 中的目录(可选)
sudo mv plugingen /usr/local/bin/
验证安装
# 检查版本
plugingen version
# 输出示例:
# plugingen v1.0.0
# Git Commit: ae73bf0
# Build Date: 2025-11-24_11:55:51
# Go Version: go1.25.0
# OS/Arch: linux/amd64
# 查看帮助
plugingen help
Usage
Quick Start
- Create a Schema File (
calculator.yaml):
package: calculator
name: Calculator
version: v1.0.0
description: A simple calculator plugin
input:
name: CalculatorInput
kind: struct
fields:
- name: Operation
json: operation
required: true
description: The operation to perform (add, subtract, multiply, divide)
type:
name: string
kind: primitive
type: string
- name: A
json: a
required: true
description: First operand
type:
name: float64
kind: primitive
type: float64
- name: B
json: b
required: true
description: Second operand
type:
name: float64
kind: primitive
type: float64
output:
name: CalculatorOutput
kind: struct
fields:
- name: Result
json: result
required: true
description: The calculation result
type:
name: float64
kind: primitive
type: float64
- Generate Code:
plugingen generate -i calculator.yaml -o calculator/generated.go
- Use Generated Code:
package calculator
import (
"context"
"github.com/kart-io/goagent/core"
)
type CalculatorPlugin struct{}
func (p *CalculatorPlugin) InvokeDynamic(ctx context.Context, input any) (any, error) {
// Use generated FromMap function
calcInput, err := CalculatorInputFromMap(input.(map[string]any))
if err != nil {
return nil, err
}
// Perform calculation
var result float64
switch calcInput.Operation {
case "add":
result = calcInput.A + calcInput.B
case "subtract":
result = calcInput.A - calcInput.B
case "multiply":
result = calcInput.A * calcInput.B
case "divide":
result = calcInput.A / calcInput.B
}
// Use generated ToMap function
output := &CalculatorOutput{Result: result}
return CalculatorOutputToMap(output), nil
}
Commands
Generate Command
Generate Go code from a schema file:
plugingen generate -i <schema.yaml> -o <output.go>
Flags:
-i string: Input schema file (YAML or JSON) [required]
-o string: Output Go file path [required]
Example:
plugingen generate -i examples/calculator.yaml -o generated/calculator.go
Validate Command
Validate a schema file without generating code:
plugingen validate -i <schema.yaml>
Flags:
-i string: Input schema file (YAML or JSON) [required]
Example:
plugingen validate -i examples/search.yaml
Output:
Validating schema from examples/search.yaml...
✓ Schema is valid!
Package: search
Plugin: SearchPlugin v2.0.0
Description: Advanced search plugin with complex data structures
Input: SearchRequest (4 fields)
Output: SearchResponse (4 fields)
Version Command
Display version and build information:
plugingen version
# 或使用别名
plugingen -v
plugingen --version
Output:
plugingen v1.0.0
Git Commit: ae73bf0
Build Date: 2025-11-24_11:55:51
Go Version: go1.25.0
OS/Arch: linux/amd64
Build with Custom Version:
使用 Makefile 构建时可以指定自定义版本:
# 使用默认版本 (v1.0.0)
make plugingen
# 指定自定义版本
make plugingen PLUGINGEN_VERSION=v2.0.0
版本信息通过 ldflags 在编译时注入:
go build -ldflags "-X main.Version=v1.0.0 -X main.GitCommit=$(git rev-parse --short HEAD) -X main.BuildDate=$(date -u '+%Y-%m-%d_%H:%M:%S')" -o plugingen ./tools/plugingen/cmd/plugingen
Basic Structure
package: <package_name> # Go package name for generated code
name: <plugin_name> # Plugin name
version: <version> # Schema version (e.g., v1.0.0)
description: <description> # Optional description
imports: # Optional additional imports
- time
- github.com/example/pkg
input: # Input type definition
name: <TypeName>
kind: struct
fields:
- ...
output: # Output type definition
name: <TypeName>
kind: struct
fields:
- ...
Type Kinds
Primitive Types
type:
name: string
kind: primitive
type: string # Go type: string, int, float64, bool, time.Time, etc.
Supported Primitive Types:
string
int, int8, int16, int32, int64
uint, uint8, uint16, uint32, uint64
float32, float64
bool
time.Time
time.Duration
interface{} (for dynamic values)
Slice Types
type:
name: Tags
kind: slice
element: # Element type
name: string
kind: primitive
type: string
Generated Go Code: []string
Map Types
type:
name: Metadata
kind: map
key: # Key type (usually string)
name: string
kind: primitive
type: string
element: # Value type
name: string
kind: primitive
type: string
Generated Go Code: map[string]string
Pointer Types
type:
name: OptionalField
kind: pointer
element: # Underlying type
name: string
kind: primitive
type: string
Generated Go Code: *string
Nested Struct Types
type:
name: Config
kind: struct
fields:
- name: Timeout
json: timeout
required: true
type:
name: int
kind: primitive
type: int
Generated Go Code:
type Config struct {
Timeout int `json:"timeout"`
}
Field Definition
fields:
- name: FieldName # Go field name (PascalCase)
json: field_name # JSON/map key (snake_case)
required: true # Whether the field is required
description: | # Optional description (becomes comment)
Multi-line description
of the field purpose
type: # Field type definition
...
Examples
Example 1: Simple Calculator
See examples/calculator.yaml
Generated Types:
type CalculatorInput struct {
Operation string `json:"operation"`
A float64 `json:"a"`
B float64 `json:"b"`
Precision *int `json:"precision,omitempty"`
Timestamp time.Time `json:"timestamp,omitempty"`
}
type CalculatorOutput struct {
Result float64 `json:"result"`
Error *string `json:"error,omitempty"`
Duration time.Duration `json:"duration,omitempty"`
}
Generated Functions:
CalculatorInputFromMap(data map[string]any) (*CalculatorInput, error)
CalculatorInputToMap(v *CalculatorInput) map[string]any
CalculatorOutputFromMap(data map[string]any) (*CalculatorOutput, error)
CalculatorOutputToMap(v *CalculatorOutput) map[string]any
Example 2: Complex Search Plugin
See examples/search.yaml
Features Demonstrated:
- Nested structs (Pagination, SearchResult)
- Slices of primitive types (Tags)
- Slices of struct types (Results)
- Maps with interface{} values (Metadata)
- Pointer types for optional fields
- time.Time and time.Duration types
Benchmark Results
JSON Marshaling (current approach):
BenchmarkJSONConversion-8 1000000 1050 ns/op 512 B/op 12 allocs/op
Generated Code (plugingen):
BenchmarkGeneratedConversion-8 10000000 105 ns/op 64 B/op 2 allocs/op
Improvement: 10x faster, 8x less memory, 6x fewer allocations
Why So Fast?
-
Direct Type Assertions: No JSON encoding/decoding
// JSON approach: ~500ns
bytes, _ := json.Marshal(data)
json.Unmarshal(bytes, &result)
// Generated approach: ~50ns
if typed, ok := val.(string); ok {
result.Field = typed
}
-
No Reflection: All type checks at compile time
// Reflection approach
reflect.ValueOf(result).FieldByName(field).Set(val)
// Generated approach
result.Field = val
-
Fewer Allocations: Pre-allocated structs
// JSON creates intermediate buffers
// Generated code uses single allocation
result := &TypeName{}
Integration with GoAgent
Using Generated Code in Plugins
package myplugin
import (
"context"
"github.com/kart-io/goagent/core"
)
type MyPlugin struct {
core.BaseDynamicRunnable
}
func (p *MyPlugin) InvokeDynamic(ctx context.Context, input any) (any, error) {
// 1. Convert input using generated function
typedInput, err := MyInputFromMap(input.(map[string]any))
if err != nil {
return nil, err
}
// 2. Process with full type safety
result := p.process(typedInput)
// 3. Convert output using generated function
return MyOutputToMap(result), nil
}
func (p *MyPlugin) process(input *MyInput) *MyOutput {
// Work with strongly-typed data
// Compiler catches type errors
return &MyOutput{
Result: input.Field1 + input.Field2,
}
}
Registering Plugins with Type Safety
func main() {
registry := core.NewPluginRegistry()
// Register with type information
plugin := &MyPlugin{}
registry.Register(core.PluginMetadata{
Name: "my-plugin",
Version: "v1.0.0",
InputType: core.TypeInfo{
Name: "MyInput",
Kind: "struct",
},
OutputType: core.TypeInfo{
Name: "MyOutput",
Kind: "struct",
},
}, plugin)
}
Best Practices
1. Schema Organization
project/
├── schemas/
│ ├── calculator.yaml
│ ├── search.yaml
│ └── validator.yaml
├── generated/
│ ├── calculator/
│ │ └── types.go
│ ├── search/
│ │ └── types.go
│ └── validator/
│ └── types.go
└── plugins/
├── calculator/
│ └── plugin.go
└── search/
└── plugin.go
2. Versioning Schemas
Use semantic versioning in schema files:
version: v1.0.0 # Initial release
version: v1.1.0 # Add optional fields (backward compatible)
version: v2.0.0 # Change required fields (breaking change)
3. Required vs Optional Fields
Use required: true for mandatory fields:
fields:
- name: ID
json: id
required: true # Must be present in input
- name: Description
json: description
required: false # Optional, use pointer types
type:
kind: pointer
element:
kind: primitive
type: string
4. Field Naming Conventions
- Go Field Names: PascalCase (exported)
- JSON Keys: snake_case (conventional)
- name: UserID # Go: UserID
json: user_id # JSON: user_id
- name: CreatedAt # Go: CreatedAt
json: created_at # JSON: created_at
5. Documentation in Schemas
Add descriptions for generated godoc comments:
fields:
- name: Timeout
json: timeout
required: false
description: |
Timeout specifies the maximum duration for the operation.
If not provided, a default timeout of 30 seconds is used.
type:
kind: primitive
type: time.Duration
Generated Code:
// Timeout specifies the maximum duration for the operation.
// If not provided, a default timeout of 30 seconds is used.
Timeout time.Duration `json:"timeout,omitempty"`
Troubleshooting
Common Issues
1. Schema Validation Errors
Error: required field 'name' is missing
Solution: Ensure all required fields are present in schema:
input:
name: MyInput # ← Required
kind: struct # ← Required
fields: [...] # ← Required for struct type
2. Type Mismatch Errors
Error: field 'age' has wrong type, expected int
Solution: Verify type consistency in schema and input data.
3. Import Errors in Generated Code
Error: undefined: time.Time
Solution: Add missing imports to schema:
imports:
- time
Debug Mode
Use validate command before generating:
# Check schema validity first
plugingen validate -i schema.yaml
# Then generate
plugingen generate -i schema.yaml -o output.go
Technical Details
Generated Code Structure
For each schema, plugingen generates:
- Struct Definitions: Type-safe Go structs with JSON tags
- FromMap Functions: Convert
map[string]any → *TypeName
- ToMap Functions: Convert
*TypeName → map[string]any
- Validation Logic: Required field checks, type assertions
Code Quality
Generated code passes:
- ✅
gofmt - Properly formatted
- ✅
go vet - No suspicious constructs
- ✅
golangci-lint - Passes all linters
- ✅ GoAgent import layer rules - Correct package placement
Thread Safety
Generated conversion functions are:
- ✅ Stateless: No shared state
- ✅ Reentrant: Safe for concurrent use
- ✅ Allocation-efficient: Minimal memory overhead
Contributing
Adding New Type Kinds
To add support for a new type kind:
- Add to
TypeKind enum in schema.go
- Implement
GoTypeName() logic
- Add conversion logic in
generator.go
- Add tests in
generator_test.go
- Update documentation
Improving Templates
Templates are defined in templates.go. To improve:
- Modify template strings
- Test with
make test
- Verify generated code compiles
- Check performance impact
FAQ
Q: Can I use this with existing plugins?
Yes! Generate conversion code and gradually migrate:
// Old approach (keep for now)
func (p *Plugin) Execute(input map[string]any) (map[string]any, error) {
// ...
}
// New approach (add alongside)
func (p *Plugin) ExecuteTyped(input *TypedInput) (*TypedOutput, error) {
typedInput, _ := TypedInputFromMap(input)
return p.ExecuteTyped(typedInput)
}
Q: What about schema evolution?
Use semantic versioning and maintain backward compatibility:
- Minor versions (v1.1.0): Add optional fields only
- Major versions (v2.0.0): Breaking changes allowed
- Keep old generated code for old clients
Q: Does this work with non-Go plugins?
The tool generates Go code, but you can:
- Generate conversion code for the Go plugin wrapper
- The actual plugin can be in any language (via gRPC, etc.)
- Use generated types at the boundary layer
Negligible:
- Generation: One-time cost at build time
- Runtime: 10x faster than JSON marshaling
- Binary size: ~500 bytes per struct type
- Plugin System: See
docs/architecture/PLUGIN_SYSTEM.md
- Type Safety: See
docs/guides/TYPE_SAFETY.md
- Performance: See
docs/guides/PERFORMANCE_TUNING.md
License
Part of GoAgent project. See LICENSE file.
Support