![98e2c14b2043c1a4991c2bfc32b838ba.png](https://i-blog.csdnimg.cn/blog_migrate/6cedafe0bf19818567bc25635806c620.jpeg)
我是一名后端程序员,我想了一个基于 model 的方式来抽象所有前后端状态的写法。是闲的蛋疼么?
状态管理问题总结
- 要管理的状态太多了:每一份状态都需要一份注意力,就需要额外的人手去维护他。而技术栈的分化,导致管理不同的状态要求的人员技能背景是完全不同的。这导致需要更多的专业化团队。每个专业化的团队关注的只是业务上的一个环节,而不是一个完整的端到端的业务。
- 层与层之间的沟通成本:包括开发成本和运行时成本。往往两份状态之间用很随意的方式进行联系,比如页面上的点击,到报表的记录,中间可能要经过一个很不起眼的日志文件。又比如前后端通信的 RESTful 接口,各种定义的方式都有。这会不仅仅导致一次性开发成本的增加,更加会引起长期跟踪业务问题的成本的上升。
- 每一层本身的专注性:React 组件都需要把后端的状态先复制一份到本地,把各种外部状态都归并为浏览器内存中的状态。类似做法在后端,在data pipeline的业务逻辑里也有,都要把自己的业务和外边的I/O做一层基于复制的隔离。相当于每个模块每个环节都要把周边的数据都要重新建模一份到自己这里,把自己和上下游隔离清楚。
理想的状态管理是让所有人都关注在业务逻辑上,而不是 I/O 上。所谓业务逻辑,就是数据与数据之间的规则关系。
- 减少需要手工管理的状态:把状态托管掉,实现自动化的同步,就像 React 理想中的 "UI = f(data)" 那样,当一份状态是另外一份状态的函数的时候,就可以认为这份状态不存在了。
- 标准化状态查询与修改方式:可以从界面 label 一直追溯到数据库字段。从报表的一行,追溯到界面上的按钮。每一种交互都有一种标准的写法。
- 专注在自己的业务上:消除各种状态管理技术上的差异性,实现互联互通。无论是在前端取数据,还是后端取数据都是同样的写法。每个环节都关注在自己要表达的业务逻辑上,而不是数据是如何搬家的细节上。
插播一个招聘:乘法云招聘前端基建工程师,主要的职责就是写我下面描述这套有点另类的 API
下面是大家喜闻乐见的上代码环节。
例子:父子表单以及后端校验
代码 https://github.com/mulcloud/state-management-demo/tree/master/src/Scenario2
这个例子实现了如下功能:
- 前端动态构建的父子表单
- 选择框状态与按钮联动
- 提交父子表单到后端,由后端完成数据校验,并把错误回显到界面
- 查询后端持久化的数据
父子表单就是组件的组合
父子表单不需要用特殊的语法来表单,也就是和组装普通组件一样进行组合。
<dynamic :expand="counters">
<Counter_ #element :="#element" />
</dynamic>
子表单的状态声明在父组件上,由父组件往下传递,这个传递的语法是 :="#element"
,代表“全绑定”
export class CounterList extends Biz.MarkupView {
public counters: CounterForm[] = [];
}
添加一个子表单只需要修改这个数组,添加一个元素就可以了:
public onAdd() {
this.counters.push(this.scene.add(CounterForm));
}
这种写法的核心收益是可以把一个父子组件的状态全部都收到一个地方来管理。相比 React 组件的默认状态管理,子组件的状态是子组件内部私有的,父组件很难获取得到。
在后端“修改”前端界面
用 React + Java 来写表单的后端校验,往往需要定义一个 DTO 来描述前端的表单结构。后端完成校验之后,要返回另外一个 DTO 来描述每个字段的错误。然后前端 React 还需要把这个 DTO 的结果更新到全局 store 里,最后更新每个组件的本地状态上。这个过程中需要定义重复的结构体,以及重复的数据复制。
在我们这个例子里,事情就要简单得多。CounterList 同时也承担了前后端之间数据交换协议的角色。在点 save 按钮的时候,onSave 方法是一个 rpc 接口,会自动往后端传当前的表单,也就是 CounterList。同时 onSave 函数处理完之后,CounterList 又会回传到界面上。
@Biz.command({ runAt: 'server' })
@Biz.published
public onSave() {
for (const form of this.counters) {
Constraint.clearValidationResults(form);
if (form.value > 5) {
Constraint.reportViolation(form, 'value', { message: 'too big'});
} else {
console.log(`save ${form.value}`);
this.scene.add(Counter, { value: form.value });
}
}
}
值得一提的是 Biz.command 和 Biz.Command 的区别。这两种都是写 command 的方式,小写的 command 是一个方法,而大写的 Command 是一个类,但是干的活是一样的。方法会把所在的对象 this 自动做为一个隐藏参数,在 rpc 调用的时候传递。一般来说,如果一个命令与界面的关系更近,我们会把它写成一个 MarkupView 上的方法。如果这个命令与数据库存储更接近,就会把它写成一个独立的类。因为上面添加了 runAt server,所以这个 command 虽然写在了 Ui 里,但其实是在服务端运行的。
数据校验的结果不仅仅是一个 error message,而是要具体标记到字段级别的。所以如果用传统的写法来做,validate 这个后端 rpc 接口,需要一个很复杂的数据结构来承担 request,又要额外定义一个带一堆 error 字段的 response 做为数据结构返回到前端。在 TSM 里,这些 request 和 response 就不需要重复定义了,我们直接把 Ui 表单当 rpc 接口协议来用。每个表单字段都可以用 Constraint.reportViolation 来标记这个字段遇到的错误。在例子中, value 这个字段就会有一个 value_ERROR 字段。
<FieldItem :value="&value">
{{ id }}
<Checkbox :checked="&checked"></Checkbox>{{ checked }}
<button @onClick="onMinus">-</button>{{ value }}<button @onClick="onPlus">+</button>
</FieldItem>
通过上面这个 Ui 组件,把 value_ERROR 的内容给回显到了界面上。
同时因为前后端都是同一个编程语言,甚至定义在了同一个 class 里。onSave 方法可以抽取 validate 方法出来,被前后端共用。这样后端做的数据校验可以在前端提前做一遍,让用户可以得到更及时的响应。
从前端“直接”查询ORM
状态管理当然包括数据库 ORM。TSM 提供了增删改查的完整封装。而且这个 ORM 还延申到了前端,可以在前端直接查询数据库里的数据。
虽然我们让 CounterList / CounterForm 承担了很多角色,但是 CounterForm 本身并不适合做为数据库的持久化模型。只有及其简单的业务下,持久化的状态和界面状态有一一对应关系,绝大多数情况下,这两份状态所需要的字段都是不一样的。对于高度重复的简单场合,后面在 PCP 里,我们有基于代码生成的解决方案来自动用 Counter 生成 CounterForm。
@Biz.source(new Biz.GlobalMemStore())
export class Counter extends Biz.ActiveRecord {
public value: number;
}
这里做为一个 demo,我们并没有使用 TSM 的官方存储,而是把内存当数据库来用了。
插入到数据库里必须在后端做,前端是没有权利不经过后端来添加数据的。this.scene.add(Counter) 可以看成 new Counter() 的另外一种写法。因为 Counter 定义了自己的 source,所以会自动被保存到 GlobalMemStore 里。
public onList() {
for (const counter of this.scene.query(Counter)) {
console.log(counter.value);
}
}
虽然前端不能插入数据,但是前端可以不经过后端开接口就直接查询持久化数据。this.scene.query 类似于 GraphQL,就是一个通用的数据查询接口。这样后端只需要专注于控制数据的写入,数据的读取以及展示都是前端自己可以掌控的。你可能会担心这样的开放接口会不会有权限问题。这个在后面会有专门的权限介绍。
在 onList 里,Counter 这个类还承担了 response 状态的角色。我们不需要给 RPC 接口单独定义返回用的数据结构。你可能会担心后端给这样通用的接口,会造成 I/O 上的性能问题。针对这个问题后面也会专门有介绍。
这个例子想要说明的是:
- 持久化层的状态和界面状态是严格分离的,虽然前后端一体,虽然都是 javascript,但是并不意味着需要前后端复用同一个 model 定义。本质上前后端的 model 职责是不同的。 前端负责的是读,model 体现的更多的是如何呈现。后端不应该把数据封闭起来,应该允许前端任意组合查询。
- 后端负责的是写,model 更多体现的是发生过什么事情,如何发生的。前端必须通过后端接口的业务校验才能写入更新。
总结
在 TSM 里,没有组件状态,表单状态,数据库状态的区别。所有的不同技术实现的状态,都暴露出统一的 API。可以让前后端之间用更低的成本进行配合,减少沟通上的翻译成本。
例子:界面绑定数据库
代码:https://github.com/mulcloud/state-management-demo/tree/master/src/Scenario3
这个例子实现了如下功能
- 把数据库“绑定”到界面上
- 界面上可以发新增,删除,以及修改的命令。这些命令的结果会触发受影响界面的刷新。
比 rxjs 更简洁的异步数据流绑定
React/Vue 等前端框架虽然支持数据绑定,但是都只能绑定到一个前端内的本地状态。TSM 把绑定的边界拓展了,支持直接写一个 RPC 请求,然后让界面与这个请求的结果进行绑定。当远程数据更新了之后,被绑定的界面也会自动刷新。
<dynamic :expand="counters">
<Counter_ #element :counter="#element" />
</dynamic>
上面绑定的 counters 就是一个执行 RPC 查询的 getter。注意到这个实际上一个异步请求,你不需要写 rxjs 这样的东西,也可以操纵异步数据流。
public get counters() {
return this.scene.query(Counter);
}
点 add 按钮的时候会触发命令,发给后端(因为 Counter 上标记了 @Biz.published,所以做为 api 直接在网络上暴露了)
public onAdd() {
this.scene.add(Counter);
}
add 之后,绑定的 counters 查询会重新执行一遍。之所以 add 之后可以做到自动刷新,就是因为前面写 counters 这个 getter 时建立的绑定关系。这样做的收益就是事实上前端表单“状态”是没有的,相当于直接数据库状态绑定到了DOM上。这样对于程序员来说就少一份需要管理的状态。
远程数据自动缓存
Angular 的 async pipe 可以达到类似的异步数据流绑定效果,但是 TSM 比 Angular 的实现要更好用一些。例如
<h3>
Found {{ (movies$ | async)?.total }} Results
</h3>
<app-movies [movies]="movies$ | async" />
如果这么用 async pipe,实际上会触发 movies$ 的两次计算。详情参见:https://blog.lacolaco.net/2020/03/angular-app-reactiveness-en/
而 TSM 则不会,所有的 getter 都是默认缓存的,只要求值了一次,依赖不变更就不会触发重新计算。
兄弟组件通信不再是麻烦的问题
当没有勾选的时候,delete按钮不出现。勾选了之后,delete 按钮才出现。这个需求本质上是要求两个状态保持一致。这个需求直接用 React 组件来实现之所以难做是因为涉及到兄弟组件的通信问题。经典的解决办法是把状态上提到公共的父组件上,由父组件重渲染来实现兄弟组件的联动。而 TSM 允许我们把这样的两个状态之间的关系直接用一个简单的 getter 来表达
public get hasSelected() {
for (const form of this.scene.query(CounterForm)) {
if (form.checked) {
return true;
}
}
return false;
}
其想法就是“Ui as Database”。所有组件的状态都是 scene 这个内存数据库里可以查询到的东西。CounterForm 既是 Ui 组件,同时又是一份可以被 query 的前端状态。然后我们把 delete 按钮是否可见的属性和这个查询进行绑定。一旦勾选,就会联动刷新。
public onDelete() {
const deleted = [];
for (const form of this.scene.query(CounterForm)) {
if (form.checked) {
deleted.push(form.counter);
}
}
this.call(BatchDeleteCounters, deleted);
}
提交表单的时候,也可以是用这个来实现仅删除选中的 counter。
总结
这个案例的主旨是绑定到查询。这个查询可以是跨网络边界的远程查询,也可以把Ui组件当成数据库来看待,绑定到兄弟组件的状态的查询上。
例子:批量操作型界面
代码:https://github.com/mulcloud/state-management-demo/tree/master/src/Scenario4
该例子实现了如下功能:
- 从数据库里恢复出之前的状态
- 在页面上支持了增删改查,但是不用每次操作都数据库落盘,体验更好
- 点击保存的时候,批量把界面上的改动提交落盘
子表单的前端状态
批量就是在前端持有状态。
export class CounterForm extends Biz.MarkupView {
public id: string;
public readonly counter?: Counter;
public value: number;
public checked: boolean;
public deleted: boolean;
public onBegin() {
this.value = this.counter?.value || 0;
}
// ...
}
在 onBegin 的时机(可以认为是 React 生命周期里的 onMount),把持久化的数据库状态复制一份变成可以在前端编辑的表单状态。
@Biz.command({ runAt: 'server' })
public save() {
if (this.counter) {
if (this.deleted) {
this.scene.delete(this.counter);
} else {
this.counter.value = this.value;
}
} else {
this.scene.add(Counter, { value: this.value })
}
}
在 save 按钮触发的时候,把表单状态写回到数据库。
父表单的前端状态
父表单持有的状态更复杂一些是一个 array
export class CounterList extends Biz.MarkupView {
public counters: CounterForm[] = [];
public onBegin() {
for (const counter of this.scene.query(Counter)) {
this.counters.push(this.create(CounterForm, { counter }));
}
}
// ...
}
类似的在 onBegin 的时机做了一份状态复制。因为有了这份副本
public onAdd() {
this.counters.push(this.scene.add(CounterForm));
}
添加按钮就不需要去调用后端接口了,而是直接修改前端的状态。
@Biz.command({ runAt: 'server' })
@Biz.published
public onSave() {
for (const form of this.counters) {
form.save();
}
}
在 save 的时候,把表单状态写回到数据库。按照这个模式,更复杂的批量界面也是照葫芦画瓢,无非是定义一个前端持有的对象图,在 onBegin 的时候做一次拷贝,然后在 save 的时候再拷贝回去。
不需要全局 store 也能持有复杂的前端状态
在 TSM 的解决方案里是没有区分组件本地状态和全局 store 的。当我们需要在前端持有批量操作中的状态的时候,可以直接把这个暂存的状态放在组件本地。这样的代码相比在全局 store 里同步一份组件的状态要更加好理解。某种程度上来说,有点类似于 jQuery 直接操作 DOM 的时候,直接查询 DOM 状态的感觉。
总结
批量操作的界面是更复杂的,因为多了一份表单状态需要管理。如果可能的话,优先说服产品经理不要设计这样的界面出来。 如果一定要做批量型界面,TSM 提供了 onBegin 的生命周期回调,让你可以选择绑定到前端状态,而不是只能绑定到数据库。
例子:无限滚动
代码:https://github.com/mulcloud/state-management-demo/tree/master/src/Scenario5
该例子实现了如下功能:
- 用上拉触发加载的方式实现的分页
- 加载过程中会在底部出现 loading... 字样
- 每条数据又会加载自己的关联数据,并把这些 I/O 查询实现合并后的批量加载
托管 loading 状态
传统的 React 写法是加一个 isLoading 的 flag。在开始加载之前需要设置为 true,加载完成之后需要设置为 false。而且要脱离界面把界面需要的数据先加载进来,包括界面下的子组件依赖的数据,要不然就没法确定什么时候 loading 完成了。
<Suspense>
<span #fallback>loading...</span>
<InfiniteScroll>
<CounterListPage #page :="#page" />
<div #loadingMore>正在加载...</div>
</InfiniteScroll>
</Suspense>
使用 TSM 就不需要手工去设置 isLoading 了。加载数据的过程就是页面渲染的过程,整个I/O加载是被完全托管的,从框架里可以直接拿到当前的 isLoading 状态。比如我们这里使用的 tsx 封装的组件 InfiniteScroll:
function LoadingMore({ model, loadingMore }: { model: ReturnType<typeof useInfiniteScrollModel>, loadingMore?: Slotlike }): React.ReactElement | null {
const [startOperation, isLoading] = useOperation('load more', { timeoutMs: 30000 });
React.useEffect(() => {
model.sentinelObserver = () => {
startOperation(() => {
stalk`load more`();
model.nextPage();
});
};
}, []);
if (!isLoading) {
return null;
}
if (loadingMore) {
return renderSlot(loadingMore);
}
return <div>loading more...</div>;
}
根据 useOperation 返回的 isLoading 决定是不是要出 loading indicator 就可以了。
自动合并 I/O 为批量查询
渲染界面一个常见的问题是渲染一个表格,每一行都需要额外加载其他数据。这样就会导致1条查询出这个表格,然后每一行又追加N条子查询。这种 N+1 查询的问题在 TSM 的底层 I/O 调度上上得到了解决。业务上直接在界面上说要什么数据就可以了
<dynamic :expand="counters">
<div #element>
{{ pageNumber }} / {{ #element.value }} --
<Name>{{ #element.countedBy.firstName }}</Name>
<Name>{{ #element.countedBy.lastName }}</Name>
</div>
</dynamic>
这里引用了 counter.countedBy 这个关联数据。其定义是
import * as Biz from '@triones/biz-kernel';
import { SystemUser } from '@app/Scenario5/Private/SystemUser';
@Biz.source(new Biz.GlobalMemStore())
export class Counter extends Biz.ActiveRecord {
public value: number;
@Biz.lookup
public countedBy: SystemUser;
}
当访问 countedBy 的时候,会触发一条查询去加载 SystemUser。但是在写的时候,完全不用关心运行时是如何把界面上多个组件发出的这些子查询合并成一条批量查询的问题。
总结
TSM 托管了加载过程,并且处理了数据加载的常见性能问题。