共享已保存的对象
编辑共享已保存的对象
编辑本指南描述了“共享已保存的对象”的工作,以及插件开发者需要注意的计划在 Kibana 8.0 版本中发布的重大更改。它还描述了开发者如何利用此功能。
从 8.7.0 版本开始,为了实现零停机升级,插件不再允许将现有的单空间已保存对象类型更新为可共享。请注意,新的已保存对象类型仍然可以定义为 'multiple'
或 'multiple-isolated'
。
概述
编辑已保存对象(以下简称“对象”)用于在 Kibana 中存储各种内容,从仪表盘到索引模式再到机器学习作业。使对象可共享的工作可以用一张图来概括:
每个插件都可以注册不同的对象类型以在 Kibana 中使用。在历史上,对象可以是隔离的(存在于单个空间中)或全局的(存在于所有空间中),没有中间状态。从 7.12 版本开始,Kibana 现在支持另外两种类型的对象:
存在于 |
对象 ID |
注册为 |
|
全局 |
所有空间 |
全局唯一 |
|
隔离 |
1 个空间 |
在每个空间中唯一 |
|
(新)可共享的 |
1 个空间 |
全局唯一 |
|
(新)可共享 |
1 个或多个空间 |
全局唯一 |
|
理想情况下,Kibana 中的大多数对象类型最终将是可共享的;但是,我们还引入了可共享的对象,作为插件开发者完全支持此功能的垫脚石。
实现可共享的已保存对象类型分两个阶段完成:
- 阶段 1:将现有的隔离对象类型转换为可共享的对象类型。继续阅读!
- 阶段 2:将现有的可共享对象类型切换为可共享的对象类型,或创建新的可共享对象类型。跳转到阶段 2 开发者流程图!
重大更改
编辑为了实现此功能,我们必须对对象如何序列化为原始 Elasticsearch 文档进行关键更改。因此,需要更改一些现有的对象 ID,这将导致消费者(插件开发者)与对象交互的方式发生一些重大更改。我们已实施缓解措施,以便如果消费者执行以下必要步骤,这些更改将不会影响最终用户。
现有的隔离对象类型需要在将 Kibana 升级到 8.0 版本后,通过特殊的转换过程才能变为可共享的。对象转换后,可以很容易地在将来的任何版本中切换为完全可共享的。此转换将更改不在默认空间中的任何现有对象的 ID。更改对象 ID 本身会产生一些连锁反应:
为了完全清楚:当且仅当您遵循以下步骤时,这些影响都将得到缓解!
外部插件也可以转换其对象,但是它们不必在 8.0 版本之前执行此操作。
阶段 1 开发者流程图
编辑如果您仍在阅读此页面,则您可能正在开发注册对象类型的 Kibana 插件,并且您想知道需要采取哪些步骤来为 8.0 版本做好准备并缓解任何重大更改!根据您使用已保存对象的方式,您可能需要采取多达 5 个步骤,这些步骤在下面的单独部分中详细介绍。请参考此流程图:
有一个概念验证 (POC) 拉取请求来演示这些更改。它首先添加一个简单的测试插件,允许用户创建和查看笔记。然后,它会逐步执行流程图中的步骤,将隔离的“笔记”对象转换为可共享的对象。在阅读本指南时,您可以在 POC 中跟随操作,以确切了解如何采取这些步骤。
步骤 1
编辑⚠️ 此步骤必须在 7.16 版本之前完成。 ⚠️
确保所有对象链接都使用根级别的
references
字段
如果您对问题 1的回答为“是”,则需要确保您的对象链接仅存储在根级别的 references
字段中。当给定对象的 ID 更改时,此字段将相应地为其他对象更新。
下图显示了从“案例”对象到“操作”对象的对象链接的两个不同示例。顶部显示了链接到另一个对象的错误方式,底部显示了正确的方式。
如果您的对象没有使用根级别的 references
字段,则需要在8.0 版本之前添加迁移来修复该问题。这是上述示例的迁移函数:
function migrateCaseToV716( doc: SavedObjectUnsanitizedDoc<{ connector: { type: string; id: string } }> ): SavedObjectSanitizedDoc<unknown> { const { connector: { type: connectorType, id: connectorId, ...otherConnectorAttrs }, } = doc.attributes; const { references = [] } = doc; return { ...doc, attributes: { ...doc.attributes, connector: otherConnectorAttrs, }, references: [...references, { type: connectorType, id: connectorId, name: 'connector' }], }; } ... // Use this migration function where the "case" object type is registered migrations: { '7.16.0': migrateCaseToV716, },
提醒一下,不要忘记添加单元测试和集成测试!
问题 2
编辑是否存在任何指向这些对象的“深层链接”?
深层链接是指向显示特定对象的页面的 URL。最终用户可能会将这些 URL 添加到书签或使用它们安排报表,因此确保这些 URL 继续工作至关重要。下图显示了指向 Canvas 工作台对象的深层链接示例:
请注意,某些 URL 可能包含指向多个对象的深层链接,例如,仪表板和索引模式的过滤器。
步骤 2
编辑⚠️ 此步骤最好在 7.16 版本中完成;它必须在 8.0 版本之前完成。⚠️
更新您的代码以使用新的 SavedObjectsClient
resolve()
方法,而不是get()
如果您对问题 2的回答为“是”,则需要确保在使用 SavedObjectsClient 使用其 ID 获取对象时,使用不同的 API 来执行此操作。现有的 get()
函数将仅使用其当前 ID 查找对象。为了确保您现有的深层链接 URL 不会中断,您应该使用新的 resolve()
函数;这将尝试使用其旧 ID和当前 ID 查找对象。
简而言之,如果您的深层链接页面之前有类似这样的内容:
const savedObject = savedObjectsClient.get(objType, objId);
则需要将其更改为以下内容:
const resolveResult = savedObjectsClient.resolve(objType, objId); const savedObject = resolveResult.saved_object;
在 POC 的步骤 2 中查看此示例!
SavedObjectsResolveResponse 接口具有四个字段,汇总如下:
-
saved_object
- 找到的已保存对象。 -
outcome
- 以下值之一:'exactMatch' | 'aliasMatch' | 'conflict'
-
alias_target_id
- 如果结果为'aliasMatch'
或'conflict'
,则会定义此项。这意味着具有此 ID 的旧 URL 别名指向具有不同 ID 的对象。 -
alias_purpose
- 如果结果为'aliasMatch'
或'conflict'
,则会定义此项。它描述了创建旧 URL 别名的原因。
SavedObjectsClient 在服务器端和客户端都可用。您可以通过自定义 HTTP 路由在服务器端获取对象,也可以直接在客户端获取对象。无论哪种方式,都需要将 outcome
和 alias_target_id
字段传递给您的客户端代码,并且您应该在下一步中相应地更新您的 UI。
您不必在所有地方都使用 resolve()
,您只应该将其用于深层链接!
步骤 3
编辑⚠️ 此步骤最好在 7.16 版本中完成;它必须在 8.0 版本之前完成。⚠️
更新您的客户端代码以正确处理三个不同的
resolve()
结果
空间插件 API 公开了您应该使用的 React 组件和函数,以便以一致的方式为最终用户呈现您的 UI。您的 UI 将需要使用 Core HTTP 服务和空间插件 API 来执行此操作。
您的页面应根据结果进行更改:
在 POC 的步骤 3 中查看此示例!
-
更新您插件的
kibana.json
以添加对空间插件的依赖关系... "optionalPlugins": ["spaces"]
-
更新您插件的
tsconfig.json
以添加对空间插件的类型定义的依赖关系... "references": [ ... { "path": "../spaces/tsconfig.json" }, ]
-
更新您的 Plugin 类实现以依赖于空间插件 API
interface PluginStartDeps { spaces?: SpacesPluginStart; } export class MyPlugin implements Plugin<{}, {}, {}, PluginStartDeps> { public setup(core: CoreSetup<PluginStartDeps>) { core.application.register({ ... async mount(appMountParams: AppMountParameters) { const [, pluginStartDeps] = await core.getStartServices(); const { spaces: spacesApi } = pluginStartDeps; ... // pass `spacesApi` to your app when you render it }, }); ... } }
-
在您的深层链接页面中,为
'aliasMatch'
结果添加检查if (spacesApi && resolveResult.outcome === 'aliasMatch') { // We found this object by a legacy URL alias from its old ID; redirect the user to the page with its new ID, preserving any URL hash const newObjectId = resolveResult.alias_target_id!; // This is always defined if outcome === 'aliasMatch' const newPath = `/this/page/${newObjectId}${window.location.hash}`; // Use the *local* path within this app (do not include the "/app/appId" prefix) await spacesApi.ui.redirectLegacyUrl({ path: newPath, aliasPurpose: resolveResult.alias_purpose, objectNoun: OBJECT_NOUN }); return; }
-
最后,在您的深度链接页面中,添加一个函数,以便在出现
'conflict'
结果时创建一个标注。const getLegacyUrlConflictCallout = () => { // This function returns a callout component *if* we have encountered a "legacy URL conflict" scenario if (spacesApi && resolveResult.outcome === 'conflict') { // We have resolved to one object, but another object has a legacy URL alias associated with this ID/page. We should display a // callout with a warning for the user, and provide a way for them to navigate to the other object. const currentObjectId = savedObject.id; const otherObjectId = resolveResult.alias_target_id!; // This is always defined if outcome === 'conflict' const otherObjectPath = `/this/page/${otherObjectId}${window.location.hash}`; // Use the *local* path within this app (do not include the "/app/appId" prefix) return ( <> {spacesApi.ui.components.getLegacyUrlConflict({ objectNoun: OBJECT_NOUN, currentObjectId, otherObjectId, otherObjectPath, })} <EuiSpacer /> </> ); } return null; }; ... return ( <EuiPage> <EuiPageBody> <EuiPageSection> {/* If we have a legacy URL conflict callout to display, show it at the top of the page */} {getLegacyUrlConflictCallout()} <EuiPageHeader> ... );
- 生成暂存数据并测试您的页面在不同结果下的行为。
提醒一下,不要忘记添加单元测试和功能测试!
步骤 4
编辑⚠️ 此步骤必须在 8.0 版本中完成(不早于也不晚于)。⚠️
更新您的服务器端代码,将这些对象转换为“可共享”的对象。
在完成步骤 3之后,您可以添加代码来转换您的对象。
之前的步骤可以反向移植到 7.x 分支,但是此步骤 — 转换本身 — 只能在 8.0 中进行!您应该为此使用单独的拉取请求。
在注册您的对象时,您需要更改 namespaceType
,并添加 convertToMultiNamespaceTypeVersion
字段。当用户安装 Kibana 8.0 版本时,此特殊字段将触发在核心迁移升级过程中发生的实际转换。
在 POC 的步骤 4 中查看示例!
提醒一下,不要忘记添加集成测试!
步骤 5
编辑⚠️ 此步骤必须在 8.0 版本中完成(不早于也不晚于)。⚠️
更新您的服务器端代码,为这些对象添加加密保存的对象 (ESO) 迁移
如果您对问题 3的回答为“是”,则需要采取其他步骤以确保您的对象在转换过程后仍然可以解密。加密的保存对象使用某些字段作为“额外身份验证数据” (AAD) 的一部分,以防御不同类型的加密攻击。对象 ID 是此 AAD 的一部分,因此,在更改对象的 ID 后,该对象将无法使用标准流程进行解密。
为了缓解这个问题,您需要添加一个“无操作”的 ESO 迁移,该迁移将在 8.0 升级过程中对象转换后立即应用。这将使用其旧 ID 解密该对象,然后使用其新 ID 重新加密该对象。
提醒一下,不要忘记添加单元测试和集成测试!
阶段 2 开发人员流程图
编辑本节介绍如何将可共享的对象类型切换为可共享的对象类型,或创建新的可共享的保存对象类型。请参阅此流程图
步骤 7
编辑更新保存的对象删除 API 的用法以处理多个空间
如果一个对象被共享到多个空间,则必须使用 force
删除选项才能删除。您应该始终注意保存的对象是否存在于多个空间中,并且在这种情况下应警告用户。
如果您的 UI 允许用户删除您的对象,则可以定义如下警告消息
const { namespaces, id } = savedObject; const warningMessage = namespaces.length > 1 || namespaces.includes('*') ? ( <FormattedMessage id="myPlugin.deleteObjectWarning" defaultMessage="When you delete this object, you remove it from every space it is shared in. You can't undo this action." /> ) : null;
堆栈管理中的数据视图页面使用类似的方法在其删除确认模态框中显示警告。
步骤 8
编辑允许用户查看和更改对象的分配空间
用户将需要一种方法来查看您的对象当前分配给哪些空间,并将它们共享到其他空间。您可以通过两种方式实现此目的,并且许多使用者希望同时实现这两种方式
-
(强烈推荐)将可重用的组件添加到您的应用程序中,使其“空间感知”。与空间相关的组件由空间插件导出,您可以在自己的应用程序中使用它们。
首先,请确保您的页面内容包裹在一个空间上下文提供程序中
const ContextWrapper = useMemo( () => spacesApi ? spacesApi.ui.components.getSpacesContextProvider : getEmptyFunctionComponent, [spacesApi] ); ... return ( <ContextWrapper feature='my-feature-id'> <!-- your page contents here --> </ContextWrapper> );
其次,显示一个对象的空间列表,第三,显示一个浮出层,供用户编辑对象分配的空间。您可能需要遵循数据视图页面的示例,并将它们合并为一个组件,以便可以单击空间列表来显示浮出层
const [showFlyout, setShowFlyout] = useState(false); const LazySpaceList = useCallback(spacesApi.ui.components.getSpaceList, [spacesApi]); const LazyShareToSpaceFlyout = useCallback(spacesApi.ui.components.getShareToSpaceFlyout, [spacesApi]); const shareToSpaceFlyoutProps: ShareToSpaceFlyoutProps = { savedObjectTarget: { type: myObject.type, namespaces: myObject.namespaces, id: myObject.id, icon: 'beaker', title: myObject.attributes.title, noun: OBJECT_NOUN, }, onUpdate: () => { /* callback when the object is updated */ }, onClose: () => setShowFlyout(false), }; const canAssignSpaces = !capabilities || !!capabilities.savedObjectsManagement.shareIntoSpace; const clickProperties = canAssignSpaces ? { cursorStyle: 'pointer', listOnClick: () => setShowFlyout(true) } : { cursorStyle: 'not-allowed' }; return ( <> <LazySpaceList namespaces={spaceIds} displayLimit={8} behaviorContext="outside-space" {...clickProperties} /> {showFlyout && <LazyShareToSpaceFlyout {...shareToSpaceFlyoutProps} />} </> );
icon
字段是可选的。它指定一个EUI 图标类型,该类型将显示在浮出层标题中。title
字段是可选的。它指定对象的易读标识符,该标识符将显示在浮出层标题中。noun
字段是可选的。它只是将浮出层中的 “object” 更改为您指定的任何内容 — 您可能希望浮出层显示 “dashboard” 或 “data view” 而不是 “object”。behaviorContext
字段是可选的。它控制空间列表的显示方式。当使用"outside-space"
行为上下文时,空间列表将在任何特定空间之外呈现,因此活动空间将包含在列表中。另一方面,当使用"within-space"
行为上下文时,空间列表将在活动空间内呈现,因此活动空间将从列表中排除。 -
允许用户在堆栈管理中的保存的对象管理页面中访问您的对象。您可以通过确保您的对象在您的保存的对象类型注册中标记为可导入和可导出来实现。
name: 'my-object-type', management: { isImportableAndExportable: true, }, ...
如果您这样做,那么您的对象将在保存的对象管理页面中可见,用户可以在其中将它们分配到多个空间。
常见问题解答 (FAQ)
编辑1. 为什么既有 “可共享” 对象类型,又有 “可共享” 对象类型?
编辑我们实现了“可共享”对象类型,作为当前具有隔离对象但尚未准备好支持完全可共享对象的用户的中间步骤。这主要是因为我们要确保所有对象类型在 8.0 版本中同时转换,以最大程度地减少最终用户体验的混乱和中断。
我们意识到,对于某些 Kibana 团队来说,转换过程及其所需要的一切都可能是一项不小的工作,需要在 8.0 版本之前做好准备。只要对象是可共享的,就可以确保其 ID 是全局唯一的,因此在适当的时候稍后使该对象可共享将是微不足道的。
开发人员可以轻松地翻转开关,将可共享的对象变成可共享的对象,因为它们的序列化方式相同。但是,我们认为,每个使用者都需要制定自己的计划,并在使对象可共享时进行其他 UI 更改。例如,某些用户可能无权访问“保存的对象管理”页面,但我们仍然希望这些用户能够查看他们的对象存在于哪些空间中并将它们共享到其他空间。每个应用程序都应添加相应的 UI 控件来处理此问题。
2. 为什么需要更改对象 ID?
编辑这是因为隔离对象是如何序列化为原始 Elasticsearch 文档的。当今,每个原始文档 ID 都包含其空间 ID (命名空间) 作为前缀。当对象被复制或导入到其他空间时,它们会保留相同的对象 ID,只是在序列化为 Elasticsearch 时,它们具有不同的前缀。这导致了许多 Kibana 安装在不同空间中具有相同对象 ID 的保存对象的情况。
一旦对象被转换,我们需要删除此前缀。由于迁移过程的限制,我们无法主动检查这是否会导致冲突。因此,我们决定为非默认空间中的每个对象预先重新生成对象 ID,以确保每个对象 ID 成为全局唯一的。
3. 如果一个页面有多个对象的深度链接怎么办?
编辑如问题 2中所述,某些 URL 可能包含多个对象 ID,实际上是深度链接到多个对象。这些应根据具体情况由插件所有者自行决定处理。一个好的经验法则是
- 页面上的“主要”对象应始终按照步骤 3中所述处理三个
resolve()
结果。 -
页面上的任何“次要”对象都可以不同地处理结果。如果次要对象 ID 不重要(例如,它仅充当页面锚点),则忽略不同的结果可能更有意义。如果次要对象确实重要,但它没有直接在 UI 中表示,则在遇到
'conflict'
结果时抛出描述性错误可能更有意义。-
可嵌入组件应使用
spacesApi.ui.components.getEmbeddableLegacyUrlConflict
来呈现冲突错误。查看详情会向用户展示如何使用 _disable_legacy_url_aliases API 禁用别名并解决问题。
- 如果辅助对象由外部服务(例如索引模式服务)解析,则该服务应简单地将完整的结果提供给使用者。
-
理想情况下,如果深层链接页面上的辅助对象解析为 'aliasMatch'
结果,则使用者应将用户重定向到具有新 ID 的 URL 并显示 Toast 消息。这样做的原因是,我们不希望用户过度依赖旧版 URL 别名。但是,对于 8.0 版本,不认为对辅助对象进行此类处理至关重要。
4. 什么是“旧版 URL 别名”?
编辑如上所述,当对象转换为可共享时,如果它存在于非默认空间中,则其 ID 将被更改。为了保留其旧 ID,我们还创建了一个名为旧版 URL 别名(简称“别名”)的特殊对象;此别名保留目标对象的旧 ID (sourceId),并且包含指向目标对象新 ID (targetId) 的指针。
别名设计为对最终用户大多不可见。没有直接管理它们的 UI。我们的愿景是,别名将被用作帮助我们完成 8.0 升级过程的临时措施,但我们将引导用户远离对别名的依赖,以便我们最终可以弃用并删除它们。
5. 为什么有三种不同的解析结果?
编辑resolve()
函数会检查具有给定 ID 的对象是否存在,以及是否有一个对象具有给定 ID 的别名。
- 如果只有前者为真,则结果为
'exactMatch'
— 我们找到了我们要查找的确切对象。 - 如果只有后者为真,则结果为
'aliasMatch'
— 我们找到了具有此 ID 的别名,该别名将我们指向具有不同 ID 的对象。 - 最后,如果两个条件都为真,则结果为
'conflict'
— 我们使用此 ID 找到了两个对象。为了可用性,我们决定不在此情况下返回错误,而是返回最正确的匹配项,即完全匹配项。通过告知使用者这是一个冲突,使用者可以向最终用户呈现适当的 UI,以解释这可能不是他们实际要查找的对象。
结果 1
当您使用对象的当前 ID 解析对象时,结果为 'exactMatch'
这可能发生在默认空间和非默认空间中。
结果 2
当您使用对象的旧 ID(其别名的 ID)解析对象时,结果为 'aliasMatch'
此结果只能发生在非默认空间中。
结果 3
第三个结果是一个边缘情况,是其他结果的组合。如果您解析一个对象 ID 并找到两个对象 - 一个作为完全匹配项,另一个作为别名匹配项 - 则结果为 'conflict'
我们实际上有控制措施来防止在您共享、导入或复制对象时发生这种情况。但是,如果以某种方式创建对象或用户篡改对象的原始 ES 文档,则此情况仍然可能会发生在几种不同的情况下。由于我们无法 100% 排除这种情况,因此我们必须优雅地处理它,但我们预计这种情况很少发生。
重要的是要注意,当发生 'conflict'
时,返回的对象是“最正确”的匹配项 - ID 与其完全匹配的对象。
6. 我是否应该始终使用 resolve 而不是 get?
编辑阅读本指南,您可能会认为在任何地方使用 resolve()
而不是 get()
更安全或更好。实际上,我们做出了明确的设计决策,添加了一个单独的 resolve()
函数,因为我们希望限制旧版 URL 别名的影响和依赖。为此,我们会根据 resolve()
的使用次数和遇到的不同结果收集匿名使用数据。如果 resolve()
的使用次数多于必要次数,则该使用数据将不太有用。
最终,resolve()
只应用于涉及用户控制的对象深层链接的数据流。没有理由更改任何其他数据流以使用 resolve()
。
7. 外部插件呢?
编辑外部插件(那些未随 Kibana 提供的插件)可以使用本指南将任何隔离对象转换为可共享或完全可共享!如果您是外部插件开发人员,则步骤相同,但是您无需担心在特定版本之前完成任何操作。您唯一需要知道的是,您的插件在 8.0 版本之前无法转换您的对象。