灵雀云前端: Angular 响应式表单


Kubernetes 对象实战
简介
Kubernetes 集群的使用者日常工作中经常需要与 Deployment 等 Kubernetes 对象接触。熟悉 Kubernetes 的朋友都会知道,Kubernetes 的对象结构虽然重视可移植性,不同的对象有着相似的设计理念,但就算是最熟练的系统运维或者开发工程师也不一定能将 Kubernetes 的对象玩转。
为了使用户更方便的操作 Kubernetes 对象,灵雀云的 Kubernetes 发行版前端界面提供了对用户友好的 UI 表单解决方案。用户可以通过 UI 表单更容易的编辑 Kubernetes 对象,同时还提供了 YAML 格式与 UI 表单实时互转的功能。

读者可以先从这个Deployment表单demo里面感受一下实时互转的效果(注:这个 demo 是为这篇文章单独开发,与灵雀云产品无关):

pic1.png



难点
YAML 是 Kubernetes 对象最常见的展现和修改形式。对于前端来说,假如我们需要同时支持表单和 YAML 的方式编辑 Kubernetes 资源,那么最终我们都得落回到编辑 YAML 上。YAML 与表单数据的互转,类似于序列化与反序列化的过程,而 YAML 就是我们需要关注的序列化后的数据。

实践过程中,表单数据与 YAML 互转有不少问题需要攻克。比如:
UI 表单状态与 K8S 对象状态不一定是完全一致的,比方说:
我们不会或者不需要通过 UI 编辑 K8S 的所有字段,非 UI 可编辑字段在互转的时候可以得到正确保留
UI 展现的形式并非与 YAML 严格对应,比如 metadata 的 label 字段在 YAML 中表现为一个 StringMap,但在 UI 上表现为数组
针对实际业务场景,有时候会为 YAML 进行隐式的修改或者填充
表单字段嵌套层次深,同时表单字段之间可能有关联性
局部表单复用。 比如 Workload/Pod 相关的资源都可以编辑 PodSpec 或者 Container
实时同步表单与 YAML 内容,保证两种数据表现形式在任何时间点都是一致的
考虑到正确性与可维护性,这个功能点驱使我们的表单实现方案必须往单项数据流方向靠拢。
思路推导
模板驱动表单

每个接触 Angular 表单的开发者应该都接触过这两个不同的表单实现思路:模板驱动表单与响应式表单。有些开发者可能会把响应式表单与动态表单混淆,实际上这两个概念没有什么联系。不熟悉的同学可以看看 Angular 官网这篇关于表单的介绍。官网给出了对于两者的比较:
一般来说:

响应式表单更健壮:它们的可扩展性、可复用性和可测试性更强。 如果表单是应用中的关键部分,或者你已经准备使用响应式编程模式来构建应用,请使用响应式表单。

模板驱动表单在往应用中添加简单的表单时非常有用,比如邮件列表的登记表单。它们很容易添加到应用中,但是不像响应式表单那么容易扩展。如果你有非常基本的表单需求和简单到能用模板管理的逻辑,请使用模板驱动表单。
用模板驱动表单写前端表单确实很容易:给定任意一个数据对象,将需要的字段与模板的表单控件通过[(ngModel)]进行数据绑定;根据实际需要,再绑定一下诸如required的表单验证指令就完事了。鹅妹子嘤!

不过一旦这么做,用户就将数据的“权威”就交给了模板,脱离了数据的实际控制权,也就只能被动的接受来自于模板的数据更新、表单状态与生命周期、数据验证等事件。对于复杂表单的业务逻辑,你很难通过这种模式扩展到大规模而复杂的表单数据逻辑处理之中。
响应式表单与受控组件
使用 Angular 响应式表单对于初学者来说有些麻烦:为了维护表单的状态,我们需要显式地创建一套完整的表单控制器对象层级结构,并将此对象通过 FormGroup / FormControl 之类的指令绑定到模板上的表单控件上。初看 Angular 的响应式表单的思想,似乎有点违背如今 MV* 的设计模式,因为它把一些本来可以通过框架隐式管理的工作暴露给了开发者自己,额外的增加了不少工作量。

熟悉 React 的表单控件实现的人应该了解,React 有受控组件和非受控组件的概念。通过受控组件,用户可以通过单项数据流的思路,掌握表单控件数据的实际控制权。不过对于实际的完整表单应用场景,用户还需要处理表单的提交状态、表单验证逻辑等信息。Angular 的响应式表单控件就提供了一套完整的解决方案,帮助开发者更可控的管理表单的状态。
Angular 表单控件的根基:ControlValueAccessor
Angular 的表单控件的魔法与 React 的受控组件的思路十分类似,是典型的单项数据流的处理模式。假如一个组件提供了 NG_VALUE_ACCESSOR 令牌注入到模板的 DI 上下文,并实现了 ControlValueAccessor 接口,那么这个组件就可以绑定任意 Angular 的表单指令。

ControlValueAccessor 最关键的有两点: registerOnChange 和 writeValue,这两个函数分别对应了单项数据流从表单内到外和从表单外到内两个方向的数据变化。

registerOnChange:初始化表单的过程中 Angular 会通过此接口请求目标组件注册一个 onChange 回调。用户可以通过这回调,从内到外,将表单控件的数据更新事件发射到控件外部,更新表单控件对象的数据。
writeValue:Angular 的表单控件对象更新时会主动调用此函数。可以看成外部的数据状态流入表单内部。用户可以自定义这次数据更新的作用,绑定到组件内部模板的表单控件上。
问题发散
聪明的朋友一定会注意到,ControlValueAccessor 接口并没有要求 onChange 与 writeValue 调用的时候表单数据格式需要与输入一致。因此们可以在一个业务表单控件组件内实现局部的资源对象与UI表单数据转换的逻辑。比方说上面提到的,我们可以通过它实现一个键值对表单控件。

pic2.jpg




它对外暴露为正常的键值对控件,值类型为 { [key: string]: string }。 数据由外到内时,可以通过 writeValue 将键值对通过 Object.entries 改变为 [string, string] 的数组,最后将绑定到表单内部的 FormArray 控件上;同时将内部状态改变时,在调用 onChange 之前将 [string, string][] 转化为外部的 { [key: string]: string } 对象类型。

具体实现细节详见:https://github.com/pengx17/k8s ... -form
通过这个思路,我们可以继续引申:
由于有了这样的数据转化思路, 对于每一个表单控件,我们可以通过 onChange 和 writeValue 这两个接口进行数据与表单的模型变化,实现UI的数据模型和实际对外暴露的数据模型的不一致需求。

通过递归,表单控件组件的内部模板的表单控件组件还可以是用同样方式进行实现。这样,我们就可以把问题拆解为一个个子表单。通过对于子表单的组合和嵌套,我们可以最终实现一个复杂的表单树。

内嵌表单的实现隔离了复杂表单的实现逻辑。每一个子表单控件虽然对外暴露是一个表单控件数据,但其内部是一个完整的表单。它的内部处理逻辑,比如表单的错误处理、数据转换等,父级(host)组件可以完全不了解。同时由于K8S的设计,一些子表单是可以在不同的资源里复用的,也就减轻了我们的开发成本。

表单控件本身在提供 K8S 数据的同时,也可以表现为一个独立的 K8S 对象资源。我们可以把局部相关的业务逻辑完整的封装在此表单控件组件内部,做到神行合一。这点很重要,通过这一点,我们可以更容易的划分出 K8S 资源的问题范围,更快做出代码结构的判断,减少开发的脑力负担和维护成本。坊间也有其他开发者倾向于将业务处理逻辑独立出来,不放到组件内部,这样组件就可以只负责薄薄一层视图渲染逻辑。我认为不是不可行,不过在复杂表单组件嵌套和复用角度,可能本文采用的方式更容易维护。

由于上述实现思路过程有比较规范的思路,我们可以设计出来一个标准的开发 Kubernetes 资源对象表单的实现范式。这个范式可以大大降低开发人员对于开发、维护复杂表单实现的思维负担。有好的范式的时候可以获得如下开发红利:

不管任何模式的复杂表单,可以立刻开始着手开发
强调开发体验的共识、抽象
避免开发出新的错误类型
Kubernetes对象的响应式表单开发范式
中心思想
我总结了这个开发范式里几个关键点:
神形合一:组件即是资源,也是表单控件
分形:局部子对象表单组件处理与整体对象表单组件处理保持一致
递归: 由于分形的特性,我们可以用递归的方式自上而下,用统一的方式处理表单组件
问题隔离:一次只处理一个问题
响应式表单:严格执行单向数据流,同步处理,以达到实时同步的目的
流程
为任意一个 Kubernetes 对象开发表单的过程可以总结如下:
学习目标 Kubernetes 对象的基本功能, 对它的 YAML Schema 有基本概念。
由于我们前端人员对于 YAML 字段的高透明度和充分的修改灵活度, 我们需要了解相关 k8s 对象的业务/特性.
书写目标 API 对象 TypeScript 的类型 ( interface / type 等)。
拆解 k8s 对象类型成一系列子对象,为每个可复用的子对象封装为单独的表单组件。
比如 PodSpec, Container, Env 等
为拆解出来的每个子对象表单组件实现表单到对象的互转。
组合子对象表单,最终组合成完整的 K8S 对象表单
稍后我们会以部署表单为例,详细说明流程细节。
用例分析: Deployment 表单
熟悉 Deployment 对象的结构
首先参考官网对于 Deployment 的 API 文档,输出一套 TypeScript 的接口,方便后续参照:

pic3.jpg


}
部署表单拓扑
对于部署表单,我们拆分为3个主要表单:

pic4.jpg


art: http://asciiflow.com/
K8S 资源对象表单控件组件 - 模板
最外层组件,对象的使用者可以依然使用模板驱动表单,将视图双向绑定到数据上:

<deployment-form [(ngModel)]="deployment"></deployment-form>
内部模板书写上比较容易:由普通表单控件 (如select, input等) 和其他子对象表单控件(如pod-spec-form)组成为一个单独的表单。

部署模板使用响应式表单:

pic5.jpg


K8S 资源对象表单控件组件 - 控制器
资源对象组件控制器(也就是 TS 部分)的职责如下:
对外暴露为一个单独的表单控件
Host 模板可以绑定表单相关指令到对象表单控件
对内表现为一个完整的表单组件
根据视图创建出一个表单控件树
协同各个表单控件,响应数据变化
使用单向数据流处理流入表单的数据
使用单向数据流处理流出表单的数据
组件初始化时,需要生成一个响应式表单控件树。根据实战,我总结如下经验:
有且只有一个根部 form 控件对象, 根据情况可能是 FormGroup 、FormArray、FormControl。但最终都要绑定到模板的 FormGroupDirective 指令上。
FormGroup 对象结构一般与当前对象 schema 结构相似,这样可以
通过 form.patchValue 来设置表单数据
在控制器或者模板里更容易的与原始数据进行对照
在模板内可以组合使用 formGroupName, formControlName 等指令绑定于响应表单控件
比如对于部署表单,我们需要生成这样结构的表单控件:

pic6.jpg


控件需要对外暴露为一个普通的表单控件,同时将内部表单的错误向上传递到 Host 上的 NgControl 指令上。最关键的就是要实现 ControlValueAccessor 接口:

writeValue: 由外部写入内部时,需要将资源对象适配为表单可用的模型结构。
大部分时候表单的 FormModel 与资源对象的 schema 一致。
假如业务需要,比如 k8s 的 metadata.labels 字段是 { [key: string]: string } 键值映射对象,但在视图中他的表单模型是键值对数组 [string, string][],可以在这个阶段进行数据适配。
onChange: 由内部写回外部时,需要将表单模型适配为资源对象模型,同时将 UI 不可见的字段写回资源对象模型中。
同时由于实现的原因,需要监听上层模板的 Form 指令,以得到提交嵌套模板的功能
setFormByResource 和 setResourceByForm
刚才提到,为表单设置资源对象数据时可以直接通过调用 form.patchValue(formModel) ,使得一个结构化的表单被能快速的填充。 有一个问题是,Angular 限制调用 patchValue 方法时 formModel 的 schema 必须是 form 结构的一个子集, 但通常来讲 form 的控制器结构有时候不需要覆盖完整的 schema (比如 status 字段等)。

我设计了 setFormByResource 函数解决这个问题,方法是通过遍历表单层级里面所有的控制器,以控制器所在的路径作为线索查找资源对象上的相应的值, 然后设置到表单控制器上;同时在 form 的某个控制器是 FormArray 的情况下,根据数据来源的大小进行伸缩。

而 setResourceByForm 函数与 setFormByResource 作用相反。 在表单数据写回资源对象时,利用它遍历表单层级控制器,将值设置到资源对象上。通过 setResourceByForm, 我们还可以做到从 UI 数据写回资源对象时,不去触碰 UI 表单没有的字段,避免了数据转化过程中数据可能会丢失的情况。
ng-resource-form-util 资源表单辅助工具库
开表单的单项数据流基本上可以用一张简单的图表示:

pic7.jpg


由于控制器大多数情况下使用方式和行为高度相似,于是灵雀云前端将表单的这些功能和行为抽象、封装到了 BaseResourceFormComponent 基类内,并将 代码开源在此。
上面的流程里还剩一些关键细节遗漏。比如说:
表单是如何处理表单验证,或者甚至是异步表单验证逻辑,并向上传递错误状态的?
本文表单与资源对象互转实际上解决的是表单数据与资源对象(JSON)之间数据流传递的过程。但在最外层,资源对象实际表现为 YAML 字符串,而不是 JSON 对象。
你可以通过 DEMO 和 DEMO 的源码继续了解一个比较完整的解决方案是怎样的。
写在最后
基于本文表单开发范式,灵雀云的前端开发可以非常快速的进行 K8S 相关资源对象表单的实现,并且得到 YAML 与资源对象互转的需求实现。

本文介绍了一种通用的基于 Angular 响应式表单编辑 Kubernetes 对象的实现思路与范式。实际上,这个思路并不只局限于 Angular 或者 Kubernetes 对象,读者甚至可以根据自己的需要,将此文章的思路使用 final form 带入到 React 或者 Vue 应用之中。

感谢阅读!

0 个评论

要回复文章请先登录注册