Angular 表单在 K8S 资源对象上的实践

简介

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

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

图片

难点

YAML 是 Kubernetes 对象最常见的展现和修改形式。对于前端来说,假如我们需要同时支持表单和 YAML 的方式编辑 Kubernetes 资源,那么最终我们都得落回到编辑 YAML 上。 从数据表现格式上来看,表单的模型数据是 JSON 对象,而 YAML 为字符串。YAML 与表单数据的互转,类似于序列化与反序列化的过程,我们需要在转化的边界借助如js-yaml之类的库进行转换。 实际上数据形式的问题比较好解决,难点在于 YAML 所代表的 Kubernetes 资源对象模型与表单数据进行转换的问题。实践过程中,表单数据与 K8S 对象互转有不少问题需要攻克。比如:

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

      实现方案推导

      模板驱动表单

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

      一般来说:

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

      模板驱动表单在往应用中添加简单的表单时非常有用,比如邮件列表的登记表单。它们很容易添加到应用中,但是不像响应式表单那么容易扩展。如果你有非常基本的表单需求和简单到能用模板管理的逻辑,请使用模板驱动表单。

用模板驱动表单写前端表单确实很容易:给定任意一个数据对象,将需要的字段与模板的表单控件通过 [(ngModel)] 指令进行数据绑定;根据实际需要,再绑定一下诸如 required 的表单验证指令就完事了。鹅妹子嘤!

不过一旦这么做,用户就将数据的“权威”就交给了模板,脱离了数据的实际控制权,也就只能被动的接受来自于模板的数据更新、表单状态与生命周期、数据验证等事件。对于复杂表单的业务逻辑,你很难通过这种模式扩展到大规模而复杂的表单数据逻辑处理之中。

响应式表单与受控组件

使用 Angular 响应式表单对于初学者来说有些啰嗦和麻烦:为了维护表单的状态,我们需要显式地创建一套完整的表单控制器对象层级结构,并将此对象通过 FormGroup / FormControl 之类的指令绑定到模板上的表单控件上。初看 Angular 的响应式表单的思想,似乎有点违背如今 MV* 的设计模式,因为它把一些本来可以通过框架隐式管理的工作暴露给了开发者自己,额外的增加了不少工作量。

熟悉 React 的表单控件实现的人应该了解React 的受控组件和非受控组件概念。通过受控组件,用户可以通过单项数据流的思路,掌握表单控件数据的实际控制权。不过对于实际的完整表单应用场景,用户还需要处理表单的提交状态、表单验证逻辑等信息。Angular 框架内置了一套相对成熟稳定的响应式表单解决方案,帮助开发者更可控的管理表单的状态的同时进行表单相关业务的开发。

Angular 表单控件的根基:ControlValueAccessor

Angular 的表单控件的魔法与 React 的受控组件的思路十分类似,是典型的单项数据流的处理模式。另外,不管是模板驱动表单还是响应式表单,都是围绕着 ControlValueAccessor 接口设计实现的。假如一个组件提供了 NG_VALUE_ACCESSOR 令牌注入到模板的 DI 上下文,并实现了 ControlValueAccessor 接口,那么这个组件就可以绑定任意 Angular 的表单指令。

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

  • registerOnChange:初始化表单的过程中 Angular 会通过此接口,请求目标组件注册一个 onChange 回调。用户可以通过这回调,从内到外,将表单控件的数据更新事件发射到控件外部,更新表单控件对象的数据。
  • writeValue:Angular 的表单控件对象更新时会主动调用此函数。可以看成外部的数据状态流入表单内部。用户可以自定义这次数据更新的作用,绑定到组件内部模板的表单控件上。

    适配器模式

    聪明的朋友一定会注意到,ControlValueAccessor 接口并没有要求 onChange 与 writeValue 调用的时候表单数据格式与输入一致。因此我们可以在一个业务表单控件组件内实现局部的资源对象与UI表单数据转换的逻辑。比方说上面提到的,我们可以通过它实现一个键值对表单控件。

图片

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

假如我们以表单控件为界限,这个界限的两侧分别为 Host 上绑定表单控件 NgControl 与表单控件内部的响应式表单。借助 onChange 和 writeValue,我们可以围绕着 ControlValueAccessor 实现一个局部的适配器模式。

通过这个思路,我们可以继续引申:

由于有了基于适配器模式的数据转化思路, 对于每一个表单控件,我们可以通过 onChange 和 writeValue 这两个接口进行数据与表单的模型变化,实现UI的数据模型和实际对外暴露的数据模型的不一致需求。

针对于一个复杂的资源对象表单,我们可以把问题拆解为多子表单控件组件,每一个子表单控件组件都去实现适配器模式。同时,每个子表单控件组件依然可以用同样方式进行拆解组合。这样,通过将资源对象不断往下递归拆解,借助表单的组合和嵌套,我们可以最终实现一个复杂的表单树。

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

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

由于上述实现思路过程有比较规范的思路,我们可以设计出来一个标准的开发 Kubernetes 资源对象表单的实现范式。这个范式可以大大降低开发人员对于开发、维护复杂表单实现的思维负担。以上的过程借助递归的思路,把一个问题拆解的简单又有效:

  • 不管任何模式的复杂表单,可以立刻开始着手开发
  • 强调开发体验的共识、抽象与封装
  • 避免开发出新的错误类型

    Kubernetes对象的响应式表单开发范式

    中心思想

    我总结了这个开发范式里几个关键点:

  • 神形合一:组件即是资源,也是表单控件
  • 分形:局部子对象表单组件处理与整体对象表单组件处理保持一致
  • 递归: 由于分形的特性,我们可以用递归的方式自上而下,用统一的方式处理表单组件
  • 问题隔离:一次只处理一个问题
  • 响应式表单:严格执行单向数据流,同步处理,以达到实时同步的目的

    流程

    为任意一个 Kubernetes 对象开发表单的过程可以总结如下:

    1. 学习目标 Kubernetes 对象的基本功能, 对它的 YAML Schema 有基本概念。
    1. 由于我们前端人员对于 YAML 字段的高透明度和充分的修改灵活度, 我们需要了解相关 k8s 对象的业务/特性.
      1. 书写目标 API 对象 TypeScript 的类型 ( interface / type 等)。
      2. 拆解 k8s 对象类型成一系列子对象,为每个可复用的子对象封装为单独的表单组件。
    2. 比如 PodSpec, Container, Env 等
      1. 为拆解出来的每个子对象表单组件实现表单到对象的互转。
      2. 组合子对象表单,最终组合成完整的 K8S 对象表单

稍后我们会以部署表单为例,详细说明流程细节。

用例分析: Deployment 表单

熟悉 Deployment 对象的结构

首先参考官网对于 Deployment 的 API 文档,输出一套 TypeScript 的接口,方便后续参照:

interface Deployment {
  apiVersion: string;
  kind: string;
  metadata?: ObjectMeta;
  spec?: DeploymentSpec;
}
export interface ObjectMeta {
  name?: string;
  namespace?: string;
}
export interface DeploymentSpec {
  replicas?: number;
  template?: PodTemplateSpec;
  // ...
}
export interface PodTemplateSpec {
  metadata?: ObjectMeta;
  spec?: PodSpec;
}
export interface PodSpec {
  containers: Container[];
  // ...
}
export interface Container {
  name?: string;
  image?: string;
}

部署表单拓扑

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

  • [DeploymentForm, PodSpecForm, ContainerForm] ``` +[DeploymentForm]———————-+ | | | metadata.name: input | | metadata.namespace: select | | spec.replicas: input | | | | +spec.template.spec: [PodSpecForm]-+ | | | | | | | containers[0]:+[ContainerForm]-+ | | | | | name: input | | | | | | image: input | | | | | +—————-+ | | | | containers[1]:+[ContainerForm]-+ | | | | | name: input | | | | | | image: input | | | | | +—————-+ | | | +———————————-+ | +————————————–+

art: http://asciiflow.com/

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

<deployment-form [(ngModel)]=”deployment”></deployment-form>

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

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

Name: Namespace
### K8S 资源对象表单控件组件 - 控制器
资源对象组件控制器(也就是 TS 部分)的职责如下:
* 对外暴露为一个单独的表单控件
  * Host 模板可以绑定表单相关指令到对象表单控件
* 对内表现为一个完整的表单组件
  * 根据视图创建出一个表单控件树
  * 协同各个表单控件,响应数据变化
* 使用单向数据流处理流入表单的数据
* 使用单向数据流处理流出表单的数据

组件初始化时,需要生成一个响应式表单控件树。根据实战,我总结如下经验:
* 有且只有一个根部 form 控件对象, 根据情况可能是 FormGroup 、FormArray、FormControl。但最终都要绑定到模板的 FormGroupDirective 指令上。
* FormGroup 对象结构一般与当前对象 schema 结构相似,这样可以
  * 通过 form.patchValue 来设置表单数据
  * 在控制器或者模板里更容易的与原始数据进行对照
* 在模板内可以组合使用 formGroupName, formControlName 等指令绑定于响应表单控件

比如对于部署表单,我们需要生成这样结构的表单控件:

const metadataForm = this.fb.group({ name: [’’, [Validators.required]], namespace: [’’, [Validators.required]], labels: [{}], annotations: [{}] }); const specForm = this.fb.group({ selector: this.fb.group({ matchLabels: [{}] }), template: this.fb.group({ spec: [{}] }) }); const deployForm = this.fb.group({ metadata: metadataForm, spec: specForm, });

控件需要对外暴露为一个普通的表单控件,同时将内部表单的错误向上传递到 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 资源表单辅助工具库
表单的单项数据流基本上可以用一张简单的图表示:

+——–+ |Resource|«<—–+ +—+—-+ | | | writeValue onChange | | adaptResource adaptForm | | setForm setResource | | | +——+ +——»>+ Form | +——+ ``` 由于控制器大多数情况下使用方式和行为高度相似,于是灵雀云前端将表单的这些功能和行为抽象、封装到了 BaseResourceFormComponent 基类内,并将 代码开源在此。

上面的流程里还剩一些关键细节遗漏。我简要提一下思路,整理为如下Q&A: Q: 表单是如何处理表单验证,或者甚至是异步表单验证逻辑,并向上传递表单验证状态的? A: 将内部表单的错误处理封装为验证器,绑定到 DI 中获取到 NgControl 的 Validator 上 Q: 内部表单的提交逻辑如何处理? A: 从 DI 中获取到 FormGroupDirective,监听表单提交事件 Q: 有的表单内部有些数据不属于当前 Kubernetes 对象。这种情况下如何处理? A: 借助 ng-content 、TemplateRef 或是 Portal,将资源无关模板映射到组件内。

你可以通过 DEMO 和 DEMO 的源码继续了解一个比较完整的解决方案是怎样的。

写在最后

基于本文表单开发范式,灵雀云的前端开发可以非常快速的进行 K8S 相关资源对象表单的实现,并且得到 YAML 与资源对象互转的需求实现。

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

感谢阅读!

使用 monaco-editor 验证 YAML 数据

Link: Angular 响应式表单 Kubernetes 对象实战

[译文] Angular 应用中的状态管理

原文: Managing State in Angular Applications

已经过作者Victor Savkin授权翻译

译者注:

Angular 开发组最近将 SemVer 版本命名方式引入了 Angular 2 中,参见这篇文章。这篇文章中出现的 Angular 指的是 Angular 2 框架。

本文原作者 Victor Savkin 为前谷歌 Angular 项目的核心成员,负责了 Angular 的依赖注入、状态监测、列表和路由模块的实现。

译文将根据作者的原意,以中文文法稍加润色。

Angular 应用中的状态管理

img

状态管理是个大学问。我们总是需要在不同的地方以并发的形式更新状态,比如协同多个后端服务、Web Worker 脚本、或是 UI 组件。借助像是 Redux 这样已有的解决方案,我们可以显式的去实现状态的协同更新,但这种模式还不能算是完全让我们高枕无忧。实际上,状态管理的需求还要广的多。

应选择把什么东西储存在内存还是 URL 中;如何处理本地的 UI 组件状态;如何在持久化的数据、URL 还有后端服务之间同步状态:以上这些状态相关的问题,都需要我们在应用设计中去解答。

状态类型的种类

一个典型的网页应用有以下六种状态类型:

  • 后端服务状态
  • (储存在客户端的)持久化状态
  • URL 和路由状态
  • 客户端状态
  • 瞬时客户端状态
  • 本地 UI 状态

接下来我们来深入探讨每种类型。

显然,后端服务状态是存储在后端服务上的状态,我们可以通过比如访问 REST 端点这样的方式去取值。持久化状态是存储在客户端内存中的服务器状态的一个子集。 简单来说,我们可以把持久化状态看做后端服务状态的一个缓存副本。不过在实际的应用中,我们通常会优化缓存的更新过程,以增强用户的体验。

客户端状态不会储存在服务器上。举一个典型的例子:我们会根据用户提供的过滤条件,创建并展示给用户被过滤的项目列表;项目本身会被存储在服务端的某个数据库上,但被过滤的值不会被存储在服务端。

建议:把客户端状态和持久化状态同时反映在 URL 中是个不错的实践。

应用的状态通常被存储在客户端上,但不会在 URL 中被展示出来。*比如,YouTube 会记住我在哪里停止观看的视频,并在下次观看时从上次停止观看的位置开始播放。由于这个信息没有存储在 URL 中,如果我把视频连接发给另外某个人,他就只能从头开始观看这个视频。这种场景中的状态就是瞬时状态。

最后,每个独立的 UI 组件都可能含有决定各自行为的内部状态。比方说,要不要把一个可折叠项目展开,或者这个按钮的颜色是什么。这就是本地 UI 状态。

建议:在鉴定一个状态的种类时,可以问一下自己两个问题:这个状态可否被共享?它的生命周期是什么?

状态同步

持久化状态和服务器务状态存储着相同的信息;对于客户端状态和 URL 也需要存储相同的信息。因此,我们必须去解决状态同步的问题。选择状态的同步策略通常也就成为了设计应用的状态管理最重要的决定之一。

我们是否可以选择将某些同步做成严格同步的?哪些状态是可以做成异步的?或者用分布式系统的术语描述: 我们应该选择使用严格(strict)一致性还是最终(eventual)一致性?

这篇文章,我们将要去研究以上所提到的各个问题。

img


案例

我们先从一个看起来构建的还算合理的系统看起。这个应用展示了一个演讲列表,用户可以过滤列表,或是选择观看、评价某个演讲。

img

img

这个应用中有两大块路由:一个用来展示演讲列表,另外一个用来显示一个演讲的详细信息。

// routes.ts
RouterModule.forRoot([
  { path: 'talks',  component: TalksAndFiltersCmp },
  { path: 'talk/:id', component: TalkDetailsCmp }
])

下面是应用的简略架构图:

img

下面是应用的数据模型信息:

// model.ts
export interface Talk {
  id: number;
  title: string;
  speaker: string;
  description: string;
  yourRating: number;
  rating: number;
}

export interface Filters {
  speaker: string;
  title: string;
  minRating: number;
}

以及两个主要组件:

// talks-and-filters.ts
@Component({
  selector: 'app-cmp',
  templateUrl: './talks-and-filters.html',
  styleUrls: ['./talks-and-filters.css']
})
export class TalksAndFiltersCmp {
  constructor(public backend: Backend) {}

  handleFiltersChange(filters: Filters): void {
    this.backend.changeFilters(filters);
  }
}
// talk-detail.ts
@Component({
  selector: 'talk-details-cmp',
  templateUrl: './talk-details.html',
  styleUrls: ['./talk-details.css']
})
export class TalkDetailsCmp {
  talk: Talk;

  constructor(private backend: Backend, 
              public watchService: WatchService, 
              private route: ActivatedRoute) {
    route.params
      .mergeMap(p => this.backend.findTalk(+p['id']))
      .subscribe(t => this.talk = t);
  }

  handleRate(newRating: number): void {
    this.backend.rateTalk(this.talk.id, newRating);
  }

  handleWatch(): void {
    this.watchService.watch(this.talk);
  }
}

两个组件本身不会处理任何实际的业务逻辑。这一部分处理被委托给了 Backend 和 WatchService 服务。

// backend.ts
@Injectable()
export class Backend {
  _talks: {[id:number]: Talk} = {};
  _list: number[] = [];

  filters: Filters = {speaker: null, title: null, minRating: 0};

  constructor(private http: Http) {}

  get talks(): Talk[] {
    return this._list.map(n => this._talks[n]);
  }

  findTalk(id: number): Observable<Talk> {
    return of(this._talks[id]);
  }

  rateTalk(id: number, rating: number): void {
    const talk = this._talks[id];
    talk.yourRating = rating;
    this.http.post(`/rate`, {id: talk.id, yourRating: rating}).forEach(() => {});
  }

  changeFilters(filters: Filters): void {
    this.filters = filters;
    this.refetch();
  }

  private refetch(): void {
    const params = new URLSearchParams();
    params.set("speaker", this.filters.speaker);
    params.set("title", this.filters.title);
    params.set("minRating", this.filters.minRating.toString());
    this.http.get(`/talks`, {search: params}).forEach((r) => {
      const data = r.json();
      this._talks = data.talks;
      this._list = data.list;
    });
  }
}

每当过滤条件改变时,Backend服务将会重新获取演讲数组。因此每当用户浏览某项单独的演讲时,Backend将会从内存中获取所需的信息。译者注:也就是说查看某个演讲的详情时不会重新获取数据。

WatchService的实现十分简单:

// watch-service.ts
export class WatchService {
  watched: {[k:number]:boolean} = {};

  watch(talk: Talk): void {
    console.log("watch", talk.id);
    this.watched[talk.id] = true;
  }

  isWatched(talk: Talk): boolean {
    return this.watched[talk.id];
  }
}

源码

你可以在这里获取以上应用的源码。

案例中的状态类型

我们来研究一下有哪些东西管理着应用的各项状态。

  • Backend管理持久化状态(演讲数组)和客户端状态(过滤条件)
  • 路由管理 URL 和路由状态
  • WatchService管理瞬时状态(已经观看过的演讲)
  • 每个组件管理自己的本地 UI 状态

问题

乍看之下,这个应用的实现是合理的:应用逻辑由依赖注入的服务处理,每个方法短小精悍,代码风格不错。但只要往细里研究,我们就能在其中发现不少问题。

客户端持久化状态与服务器状态的同步

首先,当载入一个演讲的详情时,我们会调用 Backend.findTalk 函数,把储存在内存中的集合数据中取出相应数据。当用户从列表页开始浏览然后访问不同页面时,这样的做法倒还行得通;但如果用户直接从演讲的详情 URL 载入应用时,Backend中的数据集合还是空的,应用因此就无法正确的进行工作。我们可以在此做个变通,在获取演讲详情前先检查一下演讲集中是否有正确的演讲数据,如果没有就从服务器端把数据拉回来。

// backend.ts
export class Backend {
  //...
  findTalk(id: number): Observable<Talk> {
    if (this._talks[id]) return of(this._talks[id]);

    const params = new URLSearchParams();
    params.set("id", id.toString());
    return this.http.get(`${this.url}/talk/`, {search: params})
                    .map(r => this._talks[id] = r.json()['talk']);
  }
  //...
}

其次,为了增强用户体验,演讲的评分方法的实现过于乐观的直接修改了传入的演讲对象。问题在于我们没有处理好这里可能出现的错误:如果服务端更新演讲数据失败,客户端将会显示错误的信息(指的是前端显示的数据与后端不一致)。

我们可以这样修复上面的问题,当出现数据更新异常时,把评价值重设为null。

// backend.ts
export class Backend {
  //...
  rateTalk(talk: Talk, rating: number): void {
    talk.yourRating = rating;
    this.http.post(`${this.url}/rate`, {id: talk.id, yourRating: rating}).catch((e:any) => {
      talk.yourRating = null;
      throw e;
    }).forEach(() => {});
  }
  //...
}

经过以上修改,持久化状态就可以与服务端数据正确的进行同步了。

URL 和客户端状态的同步

更改过滤条件时,我们也会发现 URL 并没有同步得到更新。我们可以用手动同步的方式解决这个问题。

img

// talks-and-filters.ts
Import {paramsToFilters, filtersToParams} from './utils';

@Component({
  selector: 'app-cmp',
  templateUrl: './talks-and-filters.html',
  styleUrls: ['./talks-and-filters.css']
})
export class TalksAndFiltersCmp {
  constructor(public backend: Backend, 
              private router: Router, 
              private route: ActivatedRoute) {
    route.params.subscribe((p:any) => {
      // the url changed => update the backend
      this.backend.changeFilters(paramsToFilters(filters));
    });
  }

  handleFiltersChange(filters: Filters): void {
    this.backend.changeFilters(filters);
    // the backend chagned => update the URL
    this.router.navigate(["/talks", filtersToParams(filters)]);
  }
}

技术上讲,这段代码是可以解决问题的(URL 和客户端状态做到了同步),但这个解决方案本身也是有问题的:

  • Backend.refetch被调用了两次。过滤条件改变时将会调用一次refetch,但它也会触发一次导航,而每次导航的触发也会最终调用一次refetch。
  • 路由和Backend状态的同步是以异步的方式实现的。这就意味着,路由信息将无法从Backend中获取到任何可靠的信息;反之,Backend也无法从路由状态或是 URL 中获取到可靠的信息 - 这些信息也许还没在那里。
  • 我们还没处理过路由守卫被阻挡时的情况(注:Angular 原生路由的功能,在路由触发前可以先检查是否当前状态可否跳转到新的状态)。这样就会导致,无论客户端状态如何进行更新,导航的变化就像是永远都会成功一样。
  • 我们的解决方案只能处理一个特定路由和客户端状态的同步的问题。但如果我们需要新加入一个路由的话,这块逻辑就得重新实现一遍。
  • 最后,我们的模型是可变的。这就意味着,我们可以在不去更新 URL 的情况下去更新模型数据。这正是导致错误产生的普遍原因之一。

错在哪里?

对于这样一个小应用我们就能找到好多潜在的问题。为什么状态管理这么难解决?我们在解决问题的过程中都犯了哪些错误呢?

  • 没有把状态管理从业务逻辑和后端交互服务中抽离出来。 Backend 在与服务器进行交互的同时,也在维护着状态。这对 WatchService来说也有同样的问题。
  • 客户端持久化状态和后端服务器状态之间的同步策略不够清晰。就算我们之后修复了这个问题,但解决方案只能针对某个路由起作用,没有全局角度考虑复用性。
  • 客户端状态和 URL 之间的同步策略不够清晰。由于目前没有设计路由守卫,同时refetch的实现是幂等性的,我们的方案暂时避开了某些问题,但它不是一个能长久发展的解决方案。
  • 数据模型是可变的,这就意味着保持应用状态的可依赖性变得困难。

重构步骤一: 分离状态管理

img

我们需要解决的最大也是首先需要去解决的问题就是,如何把状态管理逻辑从应用的其他部分抽离出来。管理状态的难度令人生畏,因此需要把它跟“与服务器交互”、“正在观看视频”或是其他任何复杂逻辑混在一起时,将使得我们痛不欲生。这里,我们来通过引入 “Redux 式” 的状态管理策略到我们的应用中。

规则一:将后端交互和业务逻辑从状态管理中抽离出来

Redux 介绍参考链接

考虑到目前在网上已经有很多关于 Redux 的介绍了,在这篇文章中我就略过不表。你可以去参考以下文章了解更多信息:

首先,我们从定义应用可以执行的每种动作开始:

// model.ts
export type ShowDetail = { type: 'SHOW_DETAIL', talkId: number };
export type Filter = { type: 'FILTER', filters: Filters };
export type Watch = { type: 'WATCH', talkId: number };
export type Rate = { type: 'RATE', talkId: number, rating: number };
export type Action = Filter | ShowDetail | Watch | Rate | Unrate;

然后是状态:

// model.ts

// all non-local state of the application
export type State = { 
  talks: { [id: number]: Talk }, 
  list: number[], 
  filters: Filters, 
  watched: { [id: number]: boolean } 
};

// init state
export const initState: State = {
  talks: {}, 
  list: [], 
  filters: {speaker: null, title: null, minRating: 0}, 
  watched: {}
};

最后是 reducer:

// model.ts

// a factory to create reducer
export function reducer(backend: Backend, watch: WatchService) {
  return (store: Store<State, Action>, state: State, action: Action) => {
    switch (action.type) {
      case 'FILTER':
        return backend.findTalks(action.filters).
            map(r => ({...state, ...r, filters: action.filters}));

      case 'SHOW_DETAIL':
        if (state.talks[action.talkId]) return state;
        return backend.findTalk(action.talkId).
            map(t => ({...state, talks: {...state.talks, [t.id]: t}}));

      //...
      default:
        return state;
    }
  }
}

操作非本地状态的的任务有且只有如上 reducer 进行处理。这样Backend和WatchService就可以变得无状态。

// watch-service.ts
export class WatchService {
  watch(talk: Talk): void {
    console.log("watch", talk.id);
  }
}

理想状态的更新

之前,我们有个临时的理想状态的更新策略。我们还能做的更好。

我们引入另外一个叫做UNRATE的动作,用以处理服务器拒绝更新的情况。

// model.ts
export function reducer(backend: Backend, watch: WatchService) {
  return (store: Store<State, Action>, state: State, action: Action) => {
    switch (action.type) {
      //...
      case 'RATE':
        backend.rateTalk(action.talkId, action.rating).catch(e =>
          store.dispatch({type: 'UNRATE', talkId: action.talkId, error: e})
        ).forEach(() => {});

        const talkToRate = state.talks[action.talkId];
        const ratedTalk = {...talkToRate, yourRating: action.rating};
        const updatedTalks = {...state.talks, [action.talkId]: ratedTalk};
        return {...state, talks: updatedTalks};

      case 'UNRATE':
        const talkToUnrate = state.talks[action.talkId];
        const unratedTalk = {...talkToUnrate, yourRating: null};
        const updatedTalksAfterUnrating = {...state.talks, [action.talkId]: unratedTalk };
        return {...state, talks: updatedTalksAfterUnrating};

      default:
        return state;
    }
  }
}

这个更新很有必要。这将保证所有操作将按顺序进行执行、避免了交错的可能性。

规则二: 理想的状态更新过程需要引入额外的动作来处理错误。

不可变数据

最后,我们把数据模型改为不可变的类型。这将会带来很多有益的后果,稍后我会聊一聊这个话题。

规则三:为持久性状态和客户端状态使用不可变数据

更新后的组件

重构后,我们的组件变得简单了。现在他们就只需负责状态的查询和动作的派送。

@Component({
  selector: 'talk-details-cmp',
  templateUrl: './talk-details.html',
  styleUrls: ['./talk-details.css']
})
export class TalkDetailsCmp {
  constructor(private store: Store<State, Action>, private route: ActivatedRoute) {
    route.params.forEach(p => {
      this.store.dispatch({
        type: 'SHOW_DETAIL',
        talkId: p['id']
      });
    });
  }

  get talk(): Talk {
    return this.store.state.talks[+this.route.snapshot.params['id']];
  }

  get watched(): boolean {
    return this.store.state.watched[+this.route.snapshot.params['id']];
  }

  handleRate(newRating: number): void {
    this.store.dispatch({
      type: 'RATE',
      talkId: this.talk.id,
      rating: newRating
    });
  }

  handleWatch(): void {
    this.store.dispatch({
      type: 'WATCH',
      talkId: this.talk.id
    });
  }
}

分析

  • 状态管理与逻辑处理/服务交互分离开来。使用 reducer 成为了我们去改变本地客户端状态的惟一方式。前后端交互、观看视频等,现在被无状态服务进行处理。
  • 不再为持久化状态和客户端状态使用可变对象数据类型。
  • 客户端持久化数据与服务器状态的同步有了新策略。现在我们有了UNRATE动作来处理错误,这使得我们可以按顺序去处理动作。

不过这里需要说明的是,我们的目的不是为了在系统中用上 Redux。就算使用了 Redux,我们仍然有可能出现将逻辑处理与状态管理混杂在一起、没有处理错误的情况下进行了乐观性更新、或是使用了可变状态的问题。Redux 可以帮助我们去解决以上问题,但他不是一种万能药,也不是解决问题的唯一途径。

规则四:使用 Redux ,是为了解决问题,而不是目的本身。

另外你可能会注意到,在重构过程中我们完全没有触碰任何本地 UI 状态。这是因为,本地 UI 的状态几乎从来都不会成为我们的问题。 组件可以有别人访问不到的可变属性,而这是我们不太需要关注的东西。

使用 GraphQL 和 Apollo

就算是做了以上重构,我们依然是以手动的方式管理客户端-服务端的状态同步,而这可能会导致我们犯错。我们有可能会忘了处理异常,或是将缓存失效化。

GraphQL 和 Apollo 在全局的高度解决了上面的问题,但这也意味着我们需要投入更多精力在后端的基础架构上。这两个库也可以做到和 Redux 一同协作,可以参考一下这个项目: Hongbo-Miao/apollo-chat。

如果你能平衡好研发成本,我很推荐你去调研一下 Apollo。

源码

经过这次重构后的代码可以在此处找到:点我

重构二:路由和数据仓库

img

剩下的问题

我们的设计还遗留了以下问题:

  • 路由无法可靠的从Backend获取信息
  • Backend也无法可靠的从路由或是 URL 获得信息
  • 如果路由守卫拒绝了导航行为,客户端状态依然以导航成功的情况进行更新
  • reducer 不能阻止导航行为
  • 路由和后端状态的同步是临时的。如果我们增加了个新的路由,我们还得把同步的逻辑在那里重新实现一遍。

img

将路由作为状态的事实来源(Source of Truth)

一种解决问题的方式是,构建个通用的数据和路由同步更新的库。这没法解决所有的问题,不过至少解决方案不再是临时的了。另外一种方式是,将导航行为作为更新状态的一部分。再或者,我们可以把状态更新作为导航变化的一部分。那么,我们应该选择哪种方式呢?

规则五:永远将路由器作为状态的事实来源

由于用户总是会通过 URL 跟应用进行交互,那么我们就应该把路由作为状态处理的事实来源和动作的发起者。换个角度说,应该是通过路由器调用 reducer,而不是 reducer 去掉用路由的更新。

img

在这种架构下,路由器首先解析 URL,创建新的路由器状态快照,再把快照交给 reducer 进行处理,在 reducer 在处理完后才进行真正的导航行为。

实现这种模式并不困难。可以引入RouterConnectedToStoreModule :

// app.ts
@NgModule({
  declarations: [
    //...
  ],
  imports: [
    //...
    RouterConnectedToStoreModule.forRoot(
      "reducer",
      [
        { path: '', pathMatch: 'full', redirectTo: 'talks' },
        { path: 'talks',  component: TalksAndFiltersCmp },
        { path: 'talk/:id', component: TalkDetailsCmp }
      ]
    )

  ],
  providers: [
    Backend,
    WatchService,
    { provide: "reducer", useFactory: reducer, deps: [Backend, WatchService]}
  ]
})
export class AppModule { }

RouterConnectedToStoreModule 会帮我们设置路由器:在 URL 解析完,生成新的路由状态后,路由器将会派发 ROUTER_NAVIGATION动作。

// reducers.ts
export function reducer(backend: Backend, watch: WatchService) {
  return (store: Store<State, Action>, state: State, action: Action) => {
    switch (action.type) {
      case 'ROUTER_NAVIGATION':
        const route = action.state.root.firstChild.firstChild;

        if (route.routeConfig.path === "talks") {
          const filters = createFilters(route.params);
          return backend.findTalks(filters).
            map(r => ({...state, ...r, filters}));

        } else if (route.routeConfig.path  === "talk/:id") {
          const id = +route.params['id'];
          if (state.talks[id]) return state;
          return backend.findTalk(id).
            map(t => ({...state, talks: {...state.talks, [t.id]: t}}));

        } else {
          return state;
        }

      //...
      default:
        return state;
    }
  }
}

就像你所看到的,这个 reducer 可能会返回 observable,在这种情况下会使得路由器等待 observable 完成后才进行页面跳转。如果 reducer 抛出异常的时候,路由将会取消掉这次导航变化。

借助以上方法我们就不需要 Filter 动作了。现在,我们可以通过路由的导航变化来触发正确的动作。

// talks-and-filters.ts
@Component({
  selector: 'app-cmp',
  templateUrl: './talks-and-filters.html',
  styleUrls: ['./talks-and-filters.css']
})
export class TalksAndFiltersCmp {
  constructor(private router: Router, private store: Store<State, Action>) {}

  get filters(): Filters {
    return this.store.state.filters;
  }

  get talks(): Talk[] {
    return this.store.state.list.map(n => this.store.state.talks[n]);
  }

  handleFiltersChange(filters: Filters): void {
    this.router.navigate(["/talks", filtersToParams(filters)]);
  }
}

分析

这次重构使得客户端状态与 URL 紧密相联。路由导航行为将会调用 reducer;一旦 reducer 执行完毕后,导航将会根据新的状态继续进行。

以下是我们重构带来的结果:

  • reducer 可靠的利用新的 URL 和新的路由状态进行状态计算
  • 路由守卫或是路由解析器(resolver,用以在跳转到某个新的路由之前准备惰性状态的解析)也可以可靠的利用 reducer 创建的新状态
  • reducer 可以中止导航行为
  • 没有任何状态是并发更新的。我们总是知道每项数据的来源是什么。
  • 这个解决方案也是全局性的。如果这个做好了,我们就不需要在加入新的路由的时候去操心如何同步两个状态(指的路由和 Backend)

也就是说,我们已经把之前列举的所有问题都解决了。

源码

经过这次重构后的代码可以在此处找到:点我

使用 @ngrx/store

在这篇文章中,我有意的没有使用任何已有的 Redux 库来构建我们的应用。我实现了自己的数据仓库,并把他连接到了路由器上(加起来也就是几百行的代码)。

这样做的目的是为了向你展示:认真地思考问题是非常重要的,而不是盲目使用最新的某个第三方库。

话虽如此,我认为 @ngrx/store 是个 Angular 的非常好的 Redux 实现,如果没有特别的理由,你应该使用它。如果你正在使用了,可以来试试 vsavkin/router-store,这个库为 @ngrx/store 实现了路由链接器。这个库应该很快就会成为 ngrx 的一部分。

总结

我们从一个简单应用开始。初看实现是合理的:函数短小精悍,代码优雅;随后我们深入研究后发现了很多的潜在问题。如果没有一双训练有素的眼睛,这些问题很可能就被忽视了。我们尝试用一些变通方案解决了问题,但仍然很多没有解决,并且解决方案都不能很好的拓展到全局。

这个应用有如此多的问题,是因为我们没有从头到尾仔细想清楚应用状态管理的策略。每种解决方案都是临时的。当我们需要去处理一个并发的分布式系统时,临时性的解决方案会迅速击垮整个系统。

之后我们开始着手重构系统,在应用引入了类似 Redux 的数据仓库和不可变数据类型。但这并不是我们的目的,而是我们实现目标的手段。为了解决剩余的问题,我们实现了将 reducer 连接到路由的策略。

在整个重构过程中,我们还随之总结了几个有用的规则。

这篇文章一个主要的目的是:你应该有意的思考如何进行状态管理,因为这是个很需要仔细琢磨的难题。不要相信任何人说“有一个简单的模式/第三方库”可以解决它,因为它根本不存在。

鉴定应用中的状态类型,管理好不同类型的状态,保证好状态的一致性,以用心的态度设计你的应用。