
Vault UI 前端 Ember Models 建模实践指南属性字段、校验、Capabilities 与装饰器用法解析【免费下载链接】vaultA tool for secrets management, encryption as a service, and privileged access management项目地址: https://gitcode.com/GitHub_Trending/va/vaultVault 的前端控制台位于仓库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;DRAttribute 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 GroupsVault UI 使用Model 上声明的属性来决定输入相关关注点label、输入类型、帮助文本使用字段组来决定属性数据在表单与详情页上的排列顺序。属性通常定义在 Model 上字段组则定义在使用它们的组件或utils/model-helpers/*文件中。在讲解如何消费这些信息之前需要先了解withExpandedAttributes装饰器为 Model 注入的两样东西其实现见 model-expanded-attributes.jsallByKey一个 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 ComponentArgs { 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值必须存在基于isPresentlength支持{ 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 存进 storeCapabilities 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、defaultValueOpenAPI 水合的模型自然获得同步校验放 Model用withModelValidations(validations)注入validate()并在提交动作里先清错再校验、有错即 return且不禁用提交按钮需要软提示时使用level: warnCapabilities 检查放使用点路由 model 或组件多路径时优先走 capabilities service 的fetch一次请求拿不到能力时默认展示getter 中显式判断! falsenamespace 前缀只进 API 载荷、不进 Record 的 path ID字段平铺/分组用withFormFields(formFieldAttrs, formGroupObjects)或withExpandedAttributes_expandGroupsdefault组之外的字段会落入以 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),仅供参考