Backstage 如何编写自定义 Entity Provider 从外部系统摄取实体?
2026/9/12 17:31:08 网站建设 项目流程

Backstage 如何编写自定义 Entity Provider 从外部系统摄取实体?

【免费下载链接】backstageBackstage is an open framework for building developer portals项目地址: https://gitcode.com/GitHub_Trending/ba/backstage

当你的数据存放在 Backstage 没有现成 provider 模块覆盖的外部系统里(比如 HR 系统、内部 API、任意能列出实体的服务),而你又希望这些数据进入软件目录时,就需要编写一个自定义 Entity Provider。Entity Provider 位于 catalog 的最外层,是实体处理树的根来源——内置的 dynamic location store API 和你在app-config.yaml里声明的静态 locations 就属于两种内置 provider。本文基于 Backstage 官方文档,给出从 CLI 脚手架生成 provider 模块、实现读取逻辑、注册与配置调度,到验证摄取结果的完整路径。前提是你的 backend 使用新版 backend system,并已注册 catalog 后端插件(backend.add(import('@backstage/plugin-catalog-backend')))。

Entity Provider 的几条决定实现方式的特点

写代码前先理解 provider 的运行模型,这些特性直接决定了你要怎么实现:

  • 你在 backend 代码里实例化 provider,并通过 catalog builder 注册;通常每个远程系统对应一个 provider 实例。
  • 你可能需要主动驱动它运行:有的 provider 按周期触发,有的响应 webhook 或 pub/sub 事件。
  • 它的时机与处理循环(processing loops)相互独立——一个 provider 可以每 30 秒跑一次,另一个则随每次 webhook 调用运行。
  • 它对自己的实体集做更新时,既可以整体替换,也可以逐条增删。
  • 它的输出是一组 unprocessed entities,之后还要经过处理循环,才能成为最终的、stitched 的实体。
  • 当它删除一个实体时,该根节点下由 processor 生成的整个子树也会被删除。

用 CLI 脚手架生成 provider 模块

官方文档给出的最快起点是 Backstage CLI:

yarn new --select catalog-provider-module

它会脚手架出一个完整的 backend module,包含 provider 类、配置解析、调度和测试。CLI 会提示输入 module ID(例如frobs),生成后在plugins文件夹下得到如下结构:

plugins/catalog-backend-module-frobs-provider/ ├── config.d.ts ├── package.json ├── src/ │ ├── index.ts │ ├── module.ts │ └── provider/ │ ├── FrobsProvider.ts │ ├── FrobsProvider.test.ts │ └── readProviderConfigs.ts

在 read 方法里实现实体读取逻辑

生成的 provider 类实现了EntityProvider接口,处理调度、连接管理和 mutation。以下是以 module IDfrobs为例的关键结构(来自官方文档):

import { Config } from '@backstage/config'; import { DeferredEntity, EntityProvider, EntityProviderConnection, } from '@backstage/plugin-catalog-node'; import { randomUUID } from 'node:crypto'; import { readProviderConfigs } from './readProviderConfigs'; import { LoggerService, SchedulerService, SchedulerServiceTaskRunner, } from '@backstage/backend-plugin-api'; export class FrobsProvider implements EntityProvider { static fromConfig( configRoot: Config, options: { logger: LoggerService; scheduler: SchedulerService }, ): FrobsProvider[] { return readProviderConfigs(configRoot).map(providerConfig => { return new FrobsProvider({ id: providerConfig.id, target: providerConfig.target, logger: options.logger, taskRunner: options.scheduler.createScheduledTaskRunner( providerConfig.schedule, ), }); }); } readonly #id: string; readonly #target: string; readonly #logger: LoggerService; readonly #taskRunner: SchedulerServiceTaskRunner; constructor(options: { id: string; target: string; logger: LoggerService; taskRunner: SchedulerServiceTaskRunner; }) { this.#id = options.id; this.#target = options.target; this.#logger = options.logger; this.#taskRunner = options.taskRunner; } getProviderName() { return `FrobsProvider:${this.#id}`; } async connect(connection: EntityProviderConnection) { const id = `${this.getProviderName()}:refresh`; await this.#taskRunner.run({ id, fn: async () => { const logger = this.#logger.child({ taskId: id, taskInstanceId: randomUUID(), }); try { const entities = await this.read({ logger }); logger.info(`Read ${entities.length} entities`); await connection.applyMutation({ type: 'full', entities, }); } catch (error) { logger.error(`Refresh failed`, error); } }, }); } async read(options: { logger: LoggerService }): Promise<DeferredEntity[]> { const { logger } = options; logger.info(`Reading entities from ${this.#target}`); // Replace this with your actual>metadata: { annotations: { [ANNOTATION_LOCATION]: `hr-user:${this.getStaffUrl}`, [ANNOTATION_ORIGIN_LOCATION]: `hr-user:${this.getStaffUrl}`, }, links, name: kebabCase(user.displayName), title: user.displayName, },

两个注解的取值规则(何时相同、何时因 location 委托而不同)见 Well-known Annotations 文档。

把 provider 模块注册进 catalog

生成的module.ts通过 backend module 系统把 provider 接入 catalog:

import { coreServices, createBackendModule, } from '@backstage/backend-plugin-api'; import { catalogProcessingExtensionPoint } from '@backstage/plugin-catalog-node'; import { FrobsProvider } from './provider/FrobsProvider'; export const catalogModuleFrobs = createBackendModule({ moduleId: 'frobs-provider', pluginId: 'catalog', register({ registerInit }) { registerInit({ deps: { logger: coreServices.logger, config: coreServices.rootConfig, scheduler: coreServices.scheduler, processing: catalogProcessingExtensionPoint, }, async init({ logger, scheduler, config, processing }) { processing.addEntityProvider( FrobsProvider.fromConfig(config, { logger, scheduler, }), ); }, }); }, });

CLI 模板同时生成了在 backend 中注册该 module 的代码,即packages/backend/src/index.ts中的:

const backend = createBackend(); backend.add(import('@backstage/plugin-catalog-backend')); /* highlight-add-next-line */ backend.add(import('./plugins/catalog-backend-module-frobs-provider')); backend.start();

注册路径是processing.addEntityProvider(...),它通过catalogProcessingExtensionPoint扩展点把fromConfig创建出的 provider 数组交给 catalog 处理系统。

配置 target 与调度

生成的readProviderConfigs.tsapp-config.yaml解析配置,同时支持单实例和多个命名实例两种写法。

单实例:

catalog: providers: frobsProvider: target: https://frobs.example.com/api/v2 schedule: frequency: { minutes: 30 } timeout: { minutes: 3 }

多实例(例如指向不同环境):

catalog: providers: frobsProvider: production: target: https://frobs.example.com/api/v2 schedule: frequency: { minutes: 30 } timeout: { minutes: 3 } staging: target: https://frobs-staging.example.com/api/v2 schedule: frequency: { hours: 1 } timeout: { minutes: 3 }

不指定schedule时,provider 默认每 30 分钟运行一次、超时 3 分钟——这与模板readProviderConfigs.ts中的DEFAULT_SCHEDULE一致。此外,你可以用脚手架生成的config.d.ts文件为配置添加 schema(其中类型引用了SchedulerServiceTaskScheduleDefinitionConfig),配置 schema 的写法参见配置文件定义文档。

选择 full 还是 delta mutation

每个 provider 实例拥有自己的实体 "bucket",由getProviderName返回的稳定名字标识。每次 provider 发出 mutation,改变的都是这个 bucket 的内容,bucket 之外不可访问。两种 mutation:

Full mutation——替换整个 bucket 的内容。catalog 内部把它实现为高效的 delta(因为相邻两次运行之间的差异通常很小)。这是生成模板的默认策略,适合能从远程源批量拉取全部实体的场景:

await connection.applyMutation({ type: 'full', entities: entities.map(entity => ({ entity, locationKey: `frobs-provider:${this.#id}`, })), });

Delta mutation——对 bucket 内的特定实体做 upsert 或删除,更适合事件驱动的 provider(收到的是单条变更通知而非全量快照):

await connection.applyMutation({ type: 'delta', added: newEntities.map(entity => ({ entity, locationKey: `frobs-provider:${this.#id}`, })), removed: removedEntities.map(entity => ({ entity, locationKey: `frobs-provider:${this.#id}`, })), });

无论哪种方式,catalog 都会把这些实体当作 unprocessed 处理;落库后,已注册的 processors 再把它们转成最终的、stitched 的实体。

Location key 的冲突规则

provider 发出的每个实体都可以带locationKey,它是一个冲突解决键:一个不透明字符串,对每个实体可能出现的来源位置应唯一。建议设置为能明确标识 provider 及其实例属性的字符串。当两个实体定义共享同一个 entity reference(kind、namespace、name)时发生冲突,location key 按以下规则裁决:

  • 已有实体没有 location key 时,新实体胜出。
  • 已有实体有 location key 时,只有 location key 匹配,新实体才胜出。
  • 实体尚不存在时,catalog 用提供的 location key 插入它。

这套规则防止 "rogue" provider 抢占属于其他 provider 的实体。

验证摄取结果

验证依据是生成代码自带的日志行为和官方文档给出的失败现象:

  • 每次调度刷新成功后,日志会打印Read ${entities.length} entities(示例值Read 3 entities,具体数量取决于你read返回的实体数),可以据此确认每次运行是否正常读到了数据。
  • 读取或 mutation 抛错时,日志打印Refresh failed并附带错误对象——出现该日志说明本次刷新失败,需要检查外部系统连接和read实现。
  • 如果你发现实体没有出现在 catalog 中,先检查read返回的DeferredEntity是否包含backstage.io/managed-by-locationbackstage.io/managed-by-origin-location两个注解。文档明确说明:缺少注解的实体不会出现在 catalog 中,并会生成 warning 日志——出现这类 warning 即是注解缺失的信号。

实体经 provider 写入后,还要经过处理循环才会成为最终的 stitched 实体;这也是 "已写入 provider bucket" 与 "在目录中可见" 之间的区别。

参考资料

  • Custom entity providers:本文对应的官方文档,包含完整的UserEntityProvider示例(从 HR 系统同步用户实体并附加 Slack 链接)
  • Well-known Annotations:backstage.io/managed-by-location等注解的语义说明
  • CLI 模板源码:本文展示代码的原始生成模板
  • Defining config schemas:用生成的config.d.tsapp-config.yaml增加 schema

【免费下载链接】backstageBackstage is an open framework for building developer portals项目地址: https://gitcode.com/GitHub_Trending/ba/backstage

创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询