跳到主要内容
版本:0.3.x(Latest)

基本介绍

LinaPro主框架通过capability.Services接口向插件暴露一组稳定的基础能力服务。这些服务覆盖了插件开发中最常见的横切关注点:认证与上下文、配置与资源、数据与存储、插件治理、通知、以及组织和租户等框架级能力。

源码插件通过pluginhost.Services获取完整的服务目录,它在capability.Services基础上扩展了TenantFilter(),为携带数据库查询构建器的能力提供了单独的入口。

这套服务架构遵循几个核心设计原则:

  • 显式契约,稳定边界。 每个服务都有明确的合约定义(contract包),插件只依赖稳定契约,不依赖宿主内部实现。
  • 插件作用域隔离。 配置、缓存、清单资源等服务自动绑定到当前插件ID,插件间不会互相干扰。
  • 能力可选,安全降级。 组织、租户等框架级能力在提供方不可用时自动降级,插件通过Available()检查可用性。
  • 只读消费,最小暴露。 普通插件获取的是只读消费接口,写操作和数据库查询构建器不通过capability.Services暴露。

能力速查

分类服务合约类型简介
认证与上下文APIDoc()contract.APIDocServiceAPI文档本地化,解析路由操作键和翻译文本
认证与上下文Auth()contract.AuthService租户Token签发、切换和模拟令牌管理
认证与上下文BizCtx()contract.BizCtxService读取当前请求的用户、租户、模拟状态等业务上下文快照
认证与上下文I18n()contract.I18nService运行时翻译、获取请求Locale、搜索翻译键
配置与资源Config()contract.ConfigService读取当前插件自己的静态配置
配置与资源HostConfig()contract.HostConfigService读取宿主公开的配置白名单键
配置与资源Manifest()contract.ManifestService读取当前插件manifest/下的原始资源文件
数据与存储Cache()contract.CacheService插件作用域的运行时缓存
数据与存储Session()contract.SessionService在线会话管理:分页查询和踢出会话
数据与存储Route()contract.RouteService获取当前动态路由的元数据
插件治理PluginLifecycle()contract.PluginLifecycleService插件生命周期编排:租户级禁用/删除的前置检查和通知
插件治理PluginState()contract.PluginStateService查询插件启用状态
通知Notify()contract.NotifyService发布通知到宿主收件箱
能力提供方Org()orgcap.Service组织能力消费:用户部门、岗位等只读投影
能力提供方Tenant()tenantcap.Service租户能力消费:当前租户、可见性校验、租户切换
数据与存储TenantFilter()contract.TenantFilterService为插件自有表注入tenant_id过滤条件

获取方式

源码插件

源码插件在路由注册、钩子回调和定时任务注册时,通过registrar.Services()获取完整的服务目录:

func registerRoutes(ctx context.Context, registrar pluginhost.HTTPRegistrar) error {
services := registrar.Services()

// 通过 services 访问各能力服务
config := services.Config()
tenantFilter := services.TenantFilter()
i18n := services.I18n()
// ...
return nil
}

registrar.Services()返回的pluginhost.Services嵌入了capability.Services的全部16个服务,并额外提供TenantFilter()。在钩子回调和定时任务注册中,同样通过payload.Services()registrar.Services()获取。

动态插件

动态插件通过pluginbridgeguestSDK访问主框架能力。所有服务调用都经过hostServices桥接,必须先在plugin.yaml中声明授权:

hostServices:
- service: config
methods: [get]
- service: hostConfig
methods: [get]
resources:
keys:
- workspace.basePath
- i18n.default
- service: manifest
methods: [get]
resources:
paths:
- profile.yaml
- service: cache
methods: [get, set, delete, incr, expire]
- service: i18n
methods: [translate, getLocale]
- service: notify
methods: [send]

guestSDK提供的便捷函数(Config.StringConfig.DurationI18n.TranslateManifest.Scan等)在运行时自动转化为对应的hostServices调用,不需要在plugin.yaml中把这些便捷函数名声明为methods。例如声明methods: [get]即可使用Config.StringConfig.BoolConfig.IntConfig.DurationConfig.Scan等所有读取函数。

WASM入口函数或控制器中,通过pluginbridgeguest侧能力获取各服务:

// 通过 guest 侧 SDK 读取配置
endpoint, err := guestsdk.Config.String(ctx, "sync.endpoint", "")

// 通过 guest 侧 SDK 读取宿主配置
workspaceBase, err := guestsdk.HostConfig.String(ctx, "workspace.basePath", "/admin")

// 通过 guest 侧 SDK 读取 manifest 资源
var profile struct {
Category string `yaml:"category"`
}
err := guestsdk.Manifest.Scan(ctx, "profile.yaml", "", &profile)

// 通过 guest 侧 SDK 读取翻译
message := guestsdk.I18n.Translate(ctx, "plugin.record.created", "Record created")

// 通过 hostServices 操作缓存
err := guestsdk.Cache.Set(ctx, "stats", "visit-count", "100", 0)

// 从 BridgeRequestEnvelopeV1 请求包中提取上下文
userID := requestEnvelope.UserID
tenantID := requestEnvelope.TenantID

动态插件的BizCtx上下文信息从BridgeRequestEnvelopeV1请求包中提取,而不是通过services.BizCtx()获取。请求包中的UserIDTenantID等字段由主框架在进入WASM沙箱前注入,语义与源码插件的BizCtxService.Current()一致。

代码示例

以下示例展示源码插件和动态插件使用各基础能力的常见模式。

读取配置

源码插件

源码插件直接调用services.Config()services.HostConfig()

func registerRoutes(ctx context.Context, registrar pluginhost.HTTPRegistrar) error {
services := registrar.Services()

syncEndpoint, err := services.Config().String(ctx, "sync.endpoint", "")
if err != nil {
return err
}

timeout, err := services.Config().Duration(ctx, "sync.timeout", 30*time.Second)
if err != nil {
return err
}

workspaceBase, err := services.HostConfig().String(ctx, "workspace.basePath", "/admin")
if err != nil {
return err
}

_ = syncEndpoint
_ = timeout
_ = workspaceBase
return nil
}

动态插件

动态插件通过guestSDK读取,需要在plugin.yaml中声明confighostConfig授权:

syncEndpoint, err := guestsdk.Config.String(ctx, "sync.endpoint", "")
if err != nil {
return err
}

timeout, err := guestsdk.Config.Duration(ctx, "sync.timeout", 30*time.Second)
if err != nil {
return err
}

workspaceBase, err := guestsdk.HostConfig.String(ctx, "workspace.basePath", "/admin")
if err != nil {
return err
}

操作缓存

源码插件

源码插件通过services.Cache()操作缓存:

func (s *recordService) GetVisitCount(ctx context.Context, recordID int) (int, error) {
services := s.services
val, err := services.Cache().Get(ctx, "visit", fmt.Sprintf("record:%d", recordID))
if err != nil {
return 0, err
}
count, _ := strconv.Atoi(val)
return count, nil
}

func (s *recordService) IncrVisitCount(ctx context.Context, recordID int) error {
services := s.services
_, err := services.Cache().Incr(ctx, "visit", fmt.Sprintf("record:%d", recordID))
return err
}

func (s *recordService) CacheSummary(ctx context.Context, recordID int, summary string) error {
services := s.services
return services.Cache().Set(ctx, "summary", fmt.Sprintf("record:%d", recordID), summary, 10*time.Minute)
}

动态插件

动态插件通过hostServicescache授权操作缓存,需要在plugin.yaml中声明service: cache

func GetVisitCount(ctx context.Context, recordID int) (int, error) {
val, err := guestsdk.Cache.Get(ctx, "visit", fmt.Sprintf("record:%d", recordID))
if err != nil {
return 0, err
}
count, _ := strconv.Atoi(val)
return count, nil
}

func IncrVisitCount(ctx context.Context, recordID int) error {
_, err := guestsdk.Cache.Incr(ctx, "visit", fmt.Sprintf("record:%d", recordID))
return err
}

国际化翻译

源码插件

源码插件通过services.I18n()翻译文本:

func (s *recordService) CreateRecord(ctx context.Context, req *CreateRecordReq) error {
services := s.services

// 插件语言包 manifest/i18n/zh-CN/plugin.json 中定义 "plugin.record.created"
msg := services.I18n().Translate(ctx, "plugin.record.created", "记录创建成功")

// 发布通知时使用翻译后的文本
return services.Notify().SendNoticePublication(ctx, &contract.NoticePublication{
SourceType: "plugin",
Title: msg,
Content: fmt.Sprintf("记录ID: %d", req.ID),
})
}

动态插件

动态插件通过guestSDK翻译,需要在plugin.yaml中声明service: i18n

func CreateRecord(ctx context.Context, req *CreateRecordReq) error {
msg := guestsdk.I18n.Translate(ctx, "plugin.record.created", "记录创建成功")

// 发布通知
return guestsdk.Notify.SendNoticePublication(ctx, &notify.Publication{
SourceType: "plugin",
Title: msg,
Content: fmt.Sprintf("记录ID: %d", req.ID),
})
}

发布通知

源码插件

源码插件通过services.Notify()发布通知:

func (s *recordService) NotifyRecordCreated(ctx context.Context, record *Record) error {
services := s.services

title := services.I18n().Translate(ctx, "plugin.record.created.title", "新记录")
content := services.I18n().Translate(
ctx,
"plugin.record.created.content",
fmt.Sprintf("记录 \"%s\" 已创建", record.Title),
)

return services.Notify().SendNoticePublication(ctx, &contract.NoticePublication{
SourceType: "plugin",
CategoryCode: "record_created",
Title: title,
Content: content,
})
}

动态插件

动态插件通过guestSDK发布通知,需要在plugin.yaml中声明service: notify

func NotifyRecordCreated(ctx context.Context, record *Record) error {
title := guestsdk.I18n.Translate(ctx, "plugin.record.created.title", "新记录")
content := guestsdk.I18n.Translate(
ctx,
"plugin.record.created.content",
fmt.Sprintf("记录 \"%s\" 已创建", record.Title),
)

return guestsdk.Notify.SendNoticePublication(ctx, &notify.Publication{
SourceType: "plugin",
CategoryCode: "record_created",
Title: title,
Content: content,
})
}

租户感知

源码插件

源码插件通过TenantFilterService实现租户感知的数据操作。该服务在pluginhost.Services中扩展提供,不在capability.Services中。DAO层由make dao生成,提供dao.Xxx单例和do.Xxxentity.Xxx模型类型。

服务层注入TenantFilterService

type serviceImpl struct {
tenantFilter plugincontract.TenantFilterService
}

func New(tenantFilter plugincontract.TenantFilterService) Service {
return &serviceImpl{tenantFilter: tenantFilter}
}

在插件注册入口中,通过registrar.Services()获取并注入:

services := registrar.Services()
recordSvc := recordsvc.New(services.TenantFilter())

查询列表——使用Apply()追加tenant_id条件:

func (s *serviceImpl) List(ctx context.Context, pageNum, pageSize int) ([]*entitymodel.Record, int, error) {
model := s.tenantFilter.Apply(ctx, dao.Record.Ctx(ctx), "")

total, err := model.Count()
if err != nil {
return nil, 0, err
}

var items []*entitymodel.Record
err = model.OrderDesc(dao.Record.Columns().UpdatedAt).
Page(pageNum, pageSize).
Scan(&items)
if err != nil {
return nil, 0, err
}

return items, total, nil
}

联合查询时使用qualifier参数为tenant_id列添加表限定符,避免列名歧义:

func (s *serviceImpl) ListWithAuthor(ctx context.Context) ([]*RecordWithAuthor, error) {
model := dao.Record.Ctx(ctx).
LeftJoin("sys_user u", "u.id = "+dao.Record.Columns().AuthorId)

// qualifier 为 "r",生成 r.tenant_id 条件
model = s.tenantFilter.Apply(ctx, model, "r")

var items []*RecordWithAuthor
err := model.Scan(&items)
return items, err
}

新增记录——通过Context()获取租户ID并显式写入:

func (s *serviceImpl) Create(ctx context.Context, in *CreateRecordInput) (int64, error) {
tenantID := s.tenantFilter.Context(ctx).TenantID

recordID, err := dao.Record.Ctx(ctx).Data(do.Record{
TenantId: tenantID,
Title: strings.TrimSpace(in.Title),
Content: in.Content,
}).InsertAndGetId()
if err != nil {
return 0, err
}

return recordID, nil
}

更新和删除——手动添加TenantFilterColumn条件,与主键一起限定范围:

func (s *serviceImpl) Update(ctx context.Context, in *UpdateRecordInput) error {
tenantID := s.tenantFilter.Context(ctx).TenantID

_, err := dao.Record.Ctx(ctx).
Where(plugincontract.TenantFilterColumn, tenantID).
Where(do.Record{Id: in.Id}).
Data(do.Record{
Title: strings.TrimSpace(in.Title),
Content: in.Content,
}).
Update()
return err
}

func (s *serviceImpl) Delete(ctx context.Context, id int64) error {
tenantID := s.tenantFilter.Context(ctx).TenantID

_, err := dao.Record.Ctx(ctx).
Where(plugincontract.TenantFilterColumn, tenantID).
Where(do.Record{Id: id}).
Delete()
return err
}

动态插件

动态插件有些不一样,由于设计沙箱的权限控制,动态插件需要通过hostServicesdata授权进行数据库查询,租户过滤由主框架在数据访问层自动注入:

# plugin.yaml
hostServices:
- service: data
methods: [list, get, create, update, delete]
resources:
tables:
- plugin_demo_dynamic_record

动态插件中的数据库操作示例:

type recordDAO struct{}

func (d *recordDAO) ListByTenant(ctx context.Context, page, pageSize int) ([]*Record, error) {
result, err := guestsdk.Data.List(ctx, &data.ListRequest{
Table: "plugin_demo_dynamic_record",
Page: page,
PageSize: pageSize,
})
if err != nil {
return nil, err
}

var records []*Record
if err := result.Scan(&records); err != nil {
return nil, err
}
return records, nil
}

相关内容