Vault UI 前端 Ember Models 建模实践指南:属性字段、校验、Capabilities 与装饰器用法解析
【免费下载链接】vaultA tool for secrets management, encryption as a service, and privileged access management项目地址: https://gitcode.com/GitHub_Trending/va/vault
Vault 的前端控制台(位于仓库ui/目录)是一套基于 Ember 构建的界面,Models 是其表单、列表与详情展示页的核心数据层。本文以仓库中的 Models 设计文档 为主线,完整讲解 Vault UI 团队当前推荐的数据模型建模约定:字段(Attributes)与字段组(Field Groups)、表单校验(Validations)、权限能力(Capabilities)这三类"模型周边信息"分别应放在哪里、如何被装饰器(Decorator)注入,并结合ui/app内的实际实现与组件示例逐条佐证。读完本文,你将掌握如何在 Vault UI 中定义一个"瘦模型"、用withModelValidations组织表单校验、用 Capabilities 控制按钮显隐,以及如何通过withFormFields、withExpandedAttributes将模型元数据映射为表单与展示视图。
Models 的角色与"瘦模型"原则
Vault UI 使用 Models 主要作为表单(form)与列表/详情视图(list/show views)的底层数据层。随着 Ember-Data 不断演进,代码库中早期写下的用法已经过时,Models 设计文档 正是用来确立当前最佳实践的一份约定——代码库里的老示例并不总能反映当下的推荐写法。
Model(模型)可以被理解为一类数据实例(Record)的"形状"。最佳实践要求Model 尽量"瘦"(thin),只承载与该 Record 本身直接相关的数据。文档给出了一个判断基准:
- 以
user模型为例,它拥有firstName与lastName两个属性; - 在该 Model 上提供一个名为
fullName的 getter 是恰当的,因为该值可以直接由 Record 自身的属性计算得出,且与 Record 本身相关; - 但把"编辑表单上展示哪些字段"这类信息放进 Model 就是不恰当的,因为它与 Record 本身无关——字段展示属于视图关注点,而非记录取值。
围绕 Model 一共有四类"周边信息",每类该放哪里,文档给出了清晰的取舍与结论:
| 信息类别 | 含义 | 存放位置(TL;DR) |
|---|---|---|
| Attribute metadata(属性元数据) | 定义在模型属性上的 label、editType(编辑控件类型)、helpText 等信息,FormField组件据此渲染正确的标签、帮助文本与输入控件 | 由于 Vault UI 重度依赖 OpenAPI 同时填充属性和元数据,因此保留在 Model 的属性声明上 |
| Form and show fields(表单与展示字段) | 展示路由与创建/编辑表单中字段的分组与顺序 | 不放在 Model(旧模式),迁移期可借助装饰器与工具文件,最终落在组件或utils/model-helpers/*工具文件中 |
| Validations(校验) | 提交前对表单答案的合法性检查 | 保留在 Model 上,通过withModelValidations装饰器注入;因为校验状态与某个具体的 Record 强相关 |
| Capabilities(能力) | 通过按路径抓取权限计算出的"能否执行某操作" | 最佳实践是放在使用它的路由或组件中(而非 Model 上) |
属性(Attributes)与字段组(Field Groups)
Vault UI 使用Model 上声明的属性来决定"输入相关关注点"(label、输入类型、帮助文本),使用字段组来决定属性数据在表单与详情页上的排列顺序。属性通常定义在 Model 上;字段组则定义在使用它们的组件或utils/model-helpers/*文件中。
在讲解如何消费这些信息之前,需要先了解withExpandedAttributes装饰器为 Model 注入的两样东西(其实现见 model-expanded-attributes.js):
allByKey:一个 getter,把全部属性以"属性名 → 属性元数据"的对象形式返回;若该 Model 被列入 OpenAPI 支撑模型(OPENAPI_POWERED_MODELS),元数据中还包含 OpenAPI 回传的内容;_expandGroups:接收一组分组对象,并把属性 key 展开为其元数据。
一个完整的示例:simple-timer
下面定义了一个simple-timer模型,其中ttl属性带有较完整的元数据(编辑类型ttl、默认值3600s、标签与帮助文本),而restartable是仅企业版可见的属性:
// models/simple-timer.js @withExpandedAttributes() export default class SimpleTimer extends Model { @attr('string', { editType: 'ttl', defaultValue: '3600s', label: 'TTL', helpText: 'Here is some help text', }) ttl; @attr('string') name; @attr('boolean') restartable; // enterprise only }在使用该 Model 的 Record 的组件里,展示视图(show)需要扁平的属性数组,表单则需要分组后的字段——两者都基于allByKey与_expandGroups得到:
// components/simple-timer-display.ts export default class SimpleTimerDisplay extends Component<Args> { @service declare readonly version: VersionService; // 这些字段在 show 模式下平铺展示,被迭代后交给 InfoTableRow 使用 get showFields() { let fields = ['name', 'ttl']; if (this.version.isEnterprise) { fields.push('restartable'); } return fields.map((field) => this.args.model.allByKey[field]); } // 这些字段在 edit 模式下分组展示,输出格式可供 FormFieldGroups 之类组件消费 get fieldGroups() { let groups = [{ default: ['name', 'ttl'] }]; if (this.version.isEnterprise) { groups.push({ 'Custom options': ['restartable'] }); } return this.args.model._expandGroups(groups); } }说明:该示例为文档用于演示而虚构的模型(仓库中并无
simple-timer),但完整展示了"元数据放 Model、分组放组件"的分工。原文档中企业版追加分组一行缺少.push调用,属笔误,上例已修正。
此处的核心思路是:属性声明一次、元数据集中管理;至于哪些字段进详情页、哪些字段如何分组,则完全由消费端(组件)按需决定,因此可以按企业版与否、按不同使用场景自由组合。
表单校验(Validations)
校验用于表单提交前向用户反馈答案问题,从而避免把错误载荷发往 API。Vault UI 的校验最佳实践由以下规则构成:
- 使用
withModelValidations装饰器定义校验; - 在表单提交时触发装饰器注入的
validate()方法; - 若存在校验错误,则应:
- 在表单底部展示"表单有错误"的整体提示;
- 在数据有误的输入框旁增加行内告警(inline-alert);
- 提前退出表单的提交函数;
- 不要禁用提交按钮(允许用户点击并看到完整错误);
- 若校验通过,则按正常流程继续保存。
@withModelValidations()装饰器
装饰器的实现位于 model-validations.js。它提供:
- 在 Model 上注入
validate()方法,用于在发出 API 请求前检查各属性是否合法; - 支持自定义校验函数,也支持通过
type键引用 validators 工具集 中现成的校验方法; - 支持为校验项添加
level: 'warn',用于仅提醒用户注意输入而不阻断表单提交。
一个带两种校验的模型定义如下(其中password使用内置的presence校验器,keyName使用内联自定义校验函数):
import { withModelValidations } from 'vault/decorators/model-validations'; const validations = { // 对象键名即模型属性名 password: [{ type: 'presence', message: 'Password is required' }], keyName: [ { validator(model) { return model.keyName === 'default' ? false : true; }, message: `Key name cannot be the reserved value 'default'`, }, ], }; @withModelValidations(validations) export default class FooModel extends Model { @attr() password; @attr() keyName; }在表单组件中,提交动作按"先清错、再校验、有错即返回"的顺序组织:
// form-component.js export default class FormComponent extends Component { @tracked modelValidations = null; @tracked invalidFormAlert = ''; checkFormValidity() { interface Validity { // 仅当所有 state.isValid 都为 true 时整体 isValid 才为 true isValid: boolean; state: { // state 以属性名为 key [key: string]: { errors: string[]; warnings: string[]; isValid: boolean; } } invalidFormMessage: string; // eg "There are 2 errors with this form" } // 调用 validate() 返回 Validity const { isValid, state, invalidFormMessage } = this.args.model.validate(); this.modelValidations = state; this.invalidFormAlert = invalidFormMessage; return isValid; } @action submit() { // 先清除上一次的错误 this.modelValidations = null; this.invalidFormAlert = null; // 再检查合法性 const continueSave = this.checkFormValidity(); if (!continueSave) return; // 继续保存 ... } }可以据此看清validate()的返回结构:最外层是isValid(是否全部通过)与invalidFormMessage(如"There are 2 errors with this form"这类整体提示文案),state按属性名展开,每项包含errors、warnings与单项isValid。这正是"表单底部整体提示 + 输入框旁行内告警"两套 UI 的直接数据来源。
Vault UI 组件层有一个现成的参考实现:pki-generate-root 组件(PKI 密钥生成根表单),它结合了withModelValidations的校验与实际的表单提交流程。
内置校验器与level: 'warn'
装饰器文档中提到的"通过type引用的校验方法",实际定义在 ui/app/utils/forms/validators.js。从源码看,这些函数遵循"条件不满足即返回 false(表示不合法)"的约定,主要包括:
presence:值必须存在(基于isPresent);length:支持{ nullable, min, max }参数,校验字符串长度范围(值可能因默认值而为数字,内部先转字符串求长度);number:支持{ nullable, min, max },并专门处理了0是合法数字而!value会误判为真的问题;containsWhiteSpace/hasWhitespace:值不应包含空白(其中containsWhiteSpace是"不合法时返回 false"的模型校验器);endsInSlash:值不应以/结尾;isNonString:判断值是否能被解析为非字符串类型(对象、数组、数字、null、布尔等),提示用户改用 JSON 编辑器;isNot:值不等于给定比较值;WHITESPACE_WARNING、NON_STRING_WARNING:与工具名对应的现成提示文案。
需要提醒用户但不阻断提交时,可为校验项声明level: 'warn',告警会被收集到上面返回结构中的warnings数组,与硬性错误errors分开处理。
Capabilities:能力(权限)检查的最佳实践
Capabilities 用于回答"当前用户对某个 API 路径到底能不能执行某操作"。团队约定中有几条底层事实与原则:
- API 本身会拦截越权操作,因此 Capabilities 纯粹用于 UX 改进——把确定用户做不了的操作隐藏起来;
- 基于这一点,当无法确定某端点能力时,默认仍然展示对应操作(宁可让 API 拒绝,也不错误地隐藏可用功能);
- 能力的判定通过
capabilities-self端点获取,并以"路径作为 Record ID"的形式注册为一个 capabilities Model 存进 store; - Capabilities Record 的path ID 永远不要包含 namespace;但当应用运行在某个 namespace 内时,API 请求载荷中的路径必须补上 namespace 前缀,API 才会返回正确的权限(例如
adminnamespace 下的kv/data/foo,而不是 root 下的同名路径); - 针对某些路径拼接,必须实际测试能力判断是否符合预期——不要想当然认为 API 路径正确,多余的字符串插值容易带来隐蔽的拼写错误,进而让 getter 返回错误结果。
对于"在哪里检查能力",团队总体倾向于放在 Model 之外(路由 model 或组件内)。文档给出了从推荐到希望淘汰的三种模式。
模式一:组件内的单路径检查
在 clients/page-header 组件 中,用户在页头可以执行某个导出操作,因此组件在构造时即基于传入参数发起一次能力查询;由于该能力其实"不检查也可以"(拿不到就默认展示),这正是文档所说的"检查放组件、失败默认放行":
// clients/page-header.js constructor() { super(...arguments); this.getExportCapabilities(this.args.namespace); } async getExportCapabilities(ns = '') { try { const url = ns ? `${sanitizePath(ns)}/sys/internal/counters/activity/export` : 'sys/internal/counters/activity/export'; const cap = await this.store.findRecord('capabilities', url); this.canDownload = cap.canSudo; } catch (e) { // 若读取 capabilities 失败,则默认展示 this.canDownload = true; } }这里同时示范了两个要点:其一,路径中的 namespace 处理——在 namespace 内时通过sanitizePath拼出admin/sys/internal/...形式;其二,canSudo这类语义化字段名来自 capabilities 记录,配合try/catch把失败路径收敛为"放行"。
模式二:多路径一次请求(推荐的服务层方式)
当一次需要判断多个路径时,推荐使用 capabilities service 的fetch方法——它会把所有路径放进同一个 API 请求,而不是像其他方式那样每个路径各发一次capabilities-self请求。
kv secrets 引擎的 secret 路由 就是在路由的model()hook 中获取能力并返回一组can*值的典型实现:
async fetchCapabilities(backend, path) { const metadataPath = `${backend}/metadata/${path}`; const dataPath = `${backend}/data/${path}`; const subkeysPath = `${backend}/subkeys/${path}`; const perms = await this.capabilities.fetch([metadataPath, dataPath, subkeysPath]); // 返回值以路径为 key return { metadata: perms[metadataPath], data: perms[dataPath], subkeys: perms[subkeysPath], }; } async model() { const backend = this.secretMountPath.currentPath; const { name: path } = this.paramsFor('secret'); const capabilities = await this.fetchCapabilities(backend, path); return hash({ // ... canUpdateData: capabilities.data.canUpdate, canReadData: capabilities.data.canRead, canReadMetadata: capabilities.metadata.canRead, canDeleteMetadata: capabilities.metadata.canDelete, canUpdateMetadata: capabilities.metadata.canUpdate, }); }同一后端路径会映射成多条能力判定路径(metadata/、data/、subkeys/),对每条路径再取canRead、canUpdate、canDelete等能力字段,最终把路由 model 变成模板可直接消费的布尔集合。此方式的另一好处是:路径集合集中在fetchCapabilities一处,规避了分散字符串插值的拼写风险。
模式三:Model 上的lazyCapabilities(正在淘汰的模式)
第三种是曾经常见、但团队希望逐步放弃的模式——在 Model 上使用lazyCapabilities宏。该宏只有在对应属性被真正读取时才发起请求:例如下面canRead首次在模板上被渲染时,才会触发capabilities-self调用。宏的实现见 lazy-capabilities.js。
import lazyCapabilities, { apiPath } from 'vault/macros/lazy-capabilities'; export default class FooModel extends Model { @attr backend; @attr('string') fooId; // 对 API 路径中的动态部分使用字符串插值 // 第一个参数是 apiPath,其余参数是对应取值的模型属性路径 @lazyCapabilities(apiPath`${'backend'}/foo/${'fooId'}`, 'backend', 'fooId') fooPath; // 显式判断 !== false,因为默认行为是"展示"(能力尚未加载时为 undefined) get canRead() { return this.fooPath.get('canRead') !== false; } get canEdit() { return this.fooPath.get('canUpdate') !== false; } }注意两个细节:第一,apiPath标签模板配合插值生成动态 API 路径,而backend、fooId作为第二、三个参数把模型属性与占位对应起来;第二,getter 里必须显式判断!== false——因为能力尚未返回时取值为undefined,而约定"拿不到就默认展示",只有明确拿到false才应隐藏。
这种做法的缺陷文档也直言不讳:能力检查被绑定在 Record 上,同一路径的能力可能随页面切换(如先出现在列表下拉、又出现在详情页)而被重复请求。团队给出的未来优化方向是:在发起 API 请求前,先到 store 里按"匹配的路径/ID"查找是否已有缓存 capabilities 记录。这正是推荐在路由/组件层通过 service 统一检查的原因所在。
由 OpenAPI 水合的模型(Models hydrated by OpenAPI)
当某个 Model 的数据由后端 OpenAPI 描述驱动水合时,后端每次变更字段都会带来大量需要同步的模型改动。此时代码库提供的一个可用模式是combineFieldGroups方法——其实现位于 openapi-to-attrs.js。ui/docs/models.md中该小节正文尚未补全(以分隔线收尾),但从工具命名与上下文可以推断:其作用是把 OpenAPI 返回的字段分组与本地(组件或工具文件)定义的字段分组做合并,使后端驱动的字段变更无需在 Model 上逐一手工同步,相关属性的展开与分组仍可统一走前面介绍的_expandGroups/withFormFields通道。需要深入了解该函数行为时,可直接阅读上述工具文件的实现。
装饰器使用总览:@withFormFields()
@withFormFields()装饰器(实现见 model-form-fields.js)用于把一个模型快速扩展出可直接消费的字段与分组集合。它:
- 在模型类上设置
allFields、formFields与formFieldGroups属性; allFields恒包含模型的所有属性(无论传给装饰器的参数是什么);formFields与formFieldGroups仅当传入对应参数时才存在(未传入的不会生成,避免无效内存与 API 差异);- 其
type字段的取值需与 validators 工具集 中暴露的 key 保持一致,便于展示层按类型渲染正确的输入控件。
一个典型用法是同时传入"平铺字段列表"与"分组对象列表":
import { withFormFields } from 'vault/decorators/model-form-fields'; const formFieldAttrs = ['attrName', 'anotherAttr']; const formGroupObjects = [ // 在 form-field-groups.hbs 中,折叠组名由 key 名决定 // 'default' 组的属性字段会渲染在任何折叠组之前 // 其他组的属性字段渲染在各自折叠组内部 { default: ['someAttribute'] }, { 'Additional options': ['anotherAttr'] }, ]; @withFormFields(formFieldAttrs, formGroupObjects) export default class SomeModel extends Model { @attr('string', { ...options }) someAttribute; @attr('boolean', { ...options }) anotherAttr; }装饰器会为每个模型属性展开成如下对象结构:
{ name: 'someAttribute', type: 'string', options: { ...options }, }于是formFields只包含传给第一个参数的属性:
// 仅包含传入第一个参数的属性 model.formFields = [ { name: 'someAttribute', type: 'string', options: { ...options }, }, ];而formFieldGroups则把展开后的属性对象按分组 key 归类:
// 展开后的属性按 key 分组 model.formFieldGroups = [ { default: [ { name: 'someAttribute', type: 'string', options: { ...options }, }, ], }, { 'Additional options': [ { name: 'anotherAttr', type: 'boolean', options: { ...options }, }, ], }, ];可以看到,@withFormFields与前面@withExpandedAttributes的分工是互补的:前者面向"表单/展示字段的声明与分组"(对应文档中"放在组件或工具文件"的最佳实践,现以内聚的装饰器形式提供),后者面向"把已声明属性展开为带元数据、可按 key 索引的结构"。二者都致力于让 Model 保持单一声明源,而把字段的组织、校验与权限这些"周边关注点"以可复用的装饰器/工具方式与 Record 解耦。
小结:把约定落到代码时该记住什么
围绕 Models 文档 的约定,Vault UI 开发者在新增或改造模型时可以直接套用以下检查清单:
- Model 只放记录自身的值与可直接派生的 getter,展示分组交给组件或
utils/model-helpers/*; - 属性元数据留在 Model 的属性声明里(
editType、label、helpText、defaultValue),OpenAPI 水合的模型自然获得同步; - 校验放 Model,用
@withModelValidations(validations)注入validate(),并在提交动作里先清错再校验、有错即 return,且不禁用提交按钮;需要软提示时使用level: 'warn'; - Capabilities 检查放使用点(路由 model 或组件),多路径时优先走 capabilities service 的
fetch一次请求;拿不到能力时默认展示,getter 中显式判断!== false;namespace 前缀只进 API 载荷、不进 Record 的 path ID; - 字段平铺/分组用
@withFormFields(formFieldAttrs, formGroupObjects)或withExpandedAttributes+_expandGroups,default组之外的字段会落入以 key 命名的折叠组中。
这几条约定共同保证了:Model 保持单一职责与最小体积,字段、校验、权限等横切关注点各有其稳定归属,配合 OpenAPI 驱动的属性水合,使 Vault UI 在面对后端频繁的字段演进时依然能低成本地同步前端表单与详情页。
【免费下载链接】vaultA tool for secrets management, encryption as a service, and privileged access management项目地址: https://gitcode.com/GitHub_Trending/va/vault
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考