跳转至

插件开发最佳实践

最后更新: 2026-08-04


1. 插件结构

✅ 推荐

// plugins/weather/weather.go
package weather

type Plugin struct {
    apiKey string
    cache  *cachePlugin.Plugin
}

func New() *plugin.Descriptor {
    p := &MyPlugin{}
    return &plugin.Descriptor{
        Name:    "weather",
        Version: "1.0.0",
        Deps:    []string{"cache"}, // 明确声明依赖
        Meta: &plugin.Metadata{
            Description: "天气查询",
            Category:    "工具",
        },
        Setup: func(ctx *plugin.SetupContext) (any, error) {
            // 依赖注入(ServiceProxy 在依赖热重载后仍有效)
            p.cache = plugin.Service[cachePlugin.Plugin](ctx, "cache")

            // 配置
            if ctx.Config != nil {
                p.apiKey = ctx.Config.GetString("api_key", "")
            }

            // 注册命令
            ctx.Reg.RegisterCommand(dto.GroupAtMessageCreate, "/weather").
                Handle(p.handle)

            return p, nil
        },
    }
}

❌ 避免

// ❌ 在 main.go 中内联所有插件逻辑
func main() {
    eng := engine.NewEngine()
    eng.OnCommand("/weather", func(ctx *eventctx.Context) error {
        // 大量业务逻辑…
    })
}

2. 依赖声明

✅ 推荐:在 Deps 中声明

&plugin.Descriptor{
    Name: "admin",
    Deps: []string{"permission", "storage"}, // 明确声明所有依赖
    Setup: func(ctx *plugin.SetupContext) (any, error) {
        perm    := plugin.Service[permission.Plugin](ctx, "permission")
        storage := plugin.Service[storage.Plugin](ctx, "storage")
        // ...
    },
}

✅ Smart 注册(自动推断依赖图)

// 无需手动排序:显式声明 Deps 或使用 WithInferDeps 由框架推断依赖关系
err := manager.RegisterBatch(ctx, []*plugin.Descriptor{
    admin.New(),
    storage.New(),
    permission.New(),
})

依赖推断仅对显式声明 DryRunSafe: true 的插件执行探测 Setup;第三方插件 在任何路径下 Setup 只执行一次。未声明 DryRunSafe 时以声明依赖参与拓扑排序。

❌ 避免

// ❌ 依赖已通过 Service[T] 使用,却未在 Deps 声明
// (应在 Deps 显式声明,或用 RegisterBatch 批量注册保证顺序)
Deps: []string{}, // 漏了 storage
Setup: func(ctx *plugin.SetupContext) (any, error) {
    s := plugin.Service[storage.Plugin](ctx, "storage") // 运行时报错
    // ...
},

3. 错误处理

✅ 推荐

Setup: func(ctx *plugin.SetupContext) (any, error) {
    apiKey := ""
    if ctx.Config != nil {
        apiKey = ctx.Config.GetString("api_key", "")
    }
    if apiKey == "" {
        return nil, fmt.Errorf("weather: api_key is required")
    }
    return &Plugin{apiKey: apiKey}, nil
},

Handler 中

func (p *Plugin) handle(ctx *eventctx.Context) error {
    result, err := p.fetch(city)
    if err != nil {
        // 记录详细错误,回复用户友好消息
        logger.WithError(err).Error("fetch failed")
        ctx.Reply("查询暂时不可用,请稍后再试")
        return nil
    }
    ctx.Reply(result)
    return nil
}

4. 后台 goroutine

始终使用 ctx.Spawn,不要裸起 goroutine

Setup: func(ctx *plugin.SetupContext) (any, error) {
    // ✅ 生命周期绑定,Teardown 自动停止
    ctx.Spawn(func(runCtx context.Context) {
        ticker := time.NewTicker(5 * time.Minute)
        defer ticker.Stop()
        for {
            select {
            case <-ticker.C:      p.refresh()
            case <-runCtx.Done(): return
            }
        }
    })

    // ❌ 避免:goroutine 泄漏,无法在 Teardown 时停止
    // go func() { for { time.Sleep(5*time.Minute); p.refresh() } }()

    return p, nil
},

5. 并发任务组(TaskGroup)

需要并发执行一批短任务并等待结果时,使用 ctx.NewTaskGroup(),不要用 ctx.Spawn

ctx.Spawn / SpawnNamed ctx.NewTaskGroup
适用场景 长驻后台 daemon(定时清理、自动保存、监听循环) 短生命周期并发任务(并发网络请求、批量计算)
生命周期 跟随插件,Teardown 时自动 cancel + wait 调用者主动 Wait(),Teardown 时也会 cancel
返回值 无(fire-and-forget) Wait() 返回聚合 error
任务签名 func(context.Context) func(context.Context) error
Setup: func(ctx *plugin.SetupContext) (any, error) {
    results := make([]Result, 0, len(urls))
    var mu sync.Mutex

    g := ctx.NewTaskGroup()
    for _, url := range urls {
        url := url
        g.Go(func(taskCtx context.Context) error {
            data, err := fetchWithCtx(taskCtx, url)
            if err != nil {
                return err
            }
            mu.Lock()
            results = append(results, data)
            mu.Unlock()
            return nil
        })
    }
    if err := g.Wait(); err != nil {
        ctx.Log.Warnf("部分请求失败: %v", err)
    }
    // 处理全部 results ...

    return p, nil
},

如果任务集合已知且无需处理中间结果,可用 Batch 快捷方式:

err := ctx.Batch(task1, task2, task3).Wait()

6. DryRun 保护(Smart 注册时)

Smart 模式会多次执行 Setup(DryRun 阶段)推断依赖关系。 ctx.Regctx.EventBusctx.Spawn 已自动 no-op,大多数插件无需处理

仅当 Setup 中有以下副作用时才需要判断: - 调用外部 HTTP/DB 请求 - 写入进程级全局变量(如 Prometheus 注册)

Setup: func(ctx *plugin.SetupContext) (any, error) {
    if !ctx.DryRun {
        // 仅在真实注册时执行
        p.metrics = initPrometheusMetrics()
    }
    // 正常注册逻辑
    ctx.Reg.RegisterCommand(...)
    return p, nil
},

6. 管理类插件(Privileged)

需要调用 Reload/Disable/Enable 等写操作的插件, 必须声明 Privileged: true,并通过 ctx.Admin 访问:

&plugin.Descriptor{
    Name:       "admin",
    Privileged: true,  // ← 显式声明,代码审查可见
    Setup: func(ctx *plugin.SetupContext) (any, error) {
        // 只读查询用 ctx.Info
        all := ctx.Info.List()
        // 写操作用 ctx.Admin
        _ = ctx.Admin.Reload("weather")
        return &Admin{info: ctx.Info, admin: ctx.Admin}, nil
    },
}

不要ctx.Admin 存储到可被非特权代码访问的位置。


7. 测试

使用 plugintest 包测试插件的 Setup 逻辑:

import "github.com/KomeiDiSanXian/remilia/plugin/plugintest"

func TestMyPlugin(t *testing.T) {
    // 创建测试上下文(含依赖容器)
    deps := map[string]any{"cache": cachePlugin}
    ctx := plugintest.NewSetupContextWithDeps("myplugin", deps, nil)
    defer plugintest.StopSetupContext(ctx)

    // 运行 Setup
    api, err := myDescriptor.Setup(ctx)
    require.NoError(t, err)
    assert.NotNil(t, api)
}

需要测试完整生命周期(Register → Setup → Teardown)时,直接使用 plugin.Manager

func TestFullLifecycle(t *testing.T) {
    pm := plugin.NewManager(nil)
    require.NoError(t, pm.Register(myplugin.New()))
    require.NoError(t, pm.Unregister(context.Background(), "myplugin"))
}

8. ctx.Set / ctx.Delete 注意事项

从 Context 中删除 key 须使用 ctx.Deletectx.Set(key, nil)no-op

// ✅ 正确删除
ctx.Delete("session")

// ❌ 不会删除,nil 值被静默忽略
ctx.Set("session", nil)

9. 不要在 Setup 中做重型初始化

重型初始化(DB 连接、预加载大量数据)应放在 goroutine 中异步完成, 或用 sync.Once + 懒加载方式在第一次调用时初始化:

Setup: func(ctx *plugin.SetupContext) (any, error) {
    p := &Plugin{}
    ctx.Spawn(func(runCtx context.Context) {
        if err := p.initDB(runCtx); err != nil {
            ctx.Log.WithError(err).Error("db init failed")
        }
    })
    ctx.Reg.RegisterCommand(...)
    return p, nil
},