要解决的问题是什么?
A problem well-stated is Half-solved
“No Silver Bullet – Essence and Accident in Software Engineering”
以及另外一篇著名的 “Out of the Tar Pit” 都把 State 造成的复杂度放到了首要的位置。
其实要解决问题一直都是房间里的那头大象,Imperative Programming 的方式去管理 State 太复杂了。
Imperative Programming 的问题是什么?
我们并不是没有办法去更新这些 State,Imperative Programming 的方式非常直观,就是把一堆读写状态的指令给 CPU,CPU 就会去一五一十地执行。我们可以把软件地执行过程画成这样地一棵树:
软件的外在行为,就是按照时间顺序,产生一系列的状态更新。也也就是有逻辑地按顺序产生这些黄颜色的节点。但是问题是:
如果一五一十地,按时间顺序描述每一个状态更新的编程风格,产生出来的代码冗长而且琐碎。
也就是最直观的,最 easy 的做法,并不能是最优的解法。即使我们抽了很多很好的函数,也就是这些蓝色的圈圈。虽然可以让代码看起来规整,但是还是冗长还是琐碎。我去年写了两篇关于代码可读性的文章,其实就是在讲这些问题:https://zhuanlan.zhihu.com/p/46435063 和 https://zhuanlan.zhihu.com/p/34982747。现在看来有点太啰嗦了。而且 readable 是一个偏主观的概念。Rich Hickey 有一个演讲 “Simple Made Easy” 讲得很好,他说 simple 是一个客观的指标。我把 Simple 具体为以下四个可以客观度量的属性
- Quantity small:数量上少
- Sequential:串行的
- Continuous:上一行和下一行有必然的因果关系的必要。而有因果关系的逻辑,不应该相距太远
- Isolated:事情之间的相互影响小。能够 isolate,才意味着可以变成组件分解出来
与这四个属性相反的是
- Quantity large:数量上多
- Concurrent, parallel:并发是逻辑上的,并行是物理上的。无论是哪种,都比 sequential 更复杂。
- Long range causality:长距离的因果关系
- Entangled:剪不断理还乱
Imperative Programming 代表的是这个真实世界。真实世界就是 Quantity large,无时无刻不 parallel,到处都是 long range causality,而且 entangled 的。Simplicity 是代表了人们假想的伊甸园,是我们对肉脑薄弱的感知和计算能力的迁就。Simplicity is hard,when simplicity is not the reality。
所以,我们可以把要解决的问题,分解成这两个问题:
- 给我们的肉脑创造一个虚拟的伊甸园,在这里,Quantity small,Sequential,Continuous,Isolated。
- 和 Imperative Programming 不同,伊甸园的叙事方式和真实世界脱节了。所以当在残忍的真实世界里出了问题,没法在代码里找到直接对应。需要提供工具帮助人类理解实际发生的 Quantity large, concurrent / parallel,long range causality,entangled。
OOP/DDD 解决了上面的四个问题么?
DDD 可以认为是这么三步
- Application Service 加载 Domain Model
- 由 Aggregate Root 封装对状态的修改
- 副作用体现为 Domain Model 的更新,以及产生的 Domain Event
其核心就是可以聚合根对状态的黑盒封装。这种所谓的黑盒封装有两个问题
- 说到底,聚合根的 method,和 imperative programming 的 function,没有本质区别
- 对象之间的交互,特别是业务流程对多个对象的更新,没有自然的聚合根的归属。或者说,真正的聚合根应该是业务流程本身。但是流程并不是 Entity。
为什么说没有本质区别:
- Quantity small:在 OOP/DDD 里所有的状态仍然是按时间顺序去逐个更新的,一个没少
- Sequential:为了性能,仍然是要把代码写成多协程或者多线程的模式
- Continuous:一个完整的业务流程,还是被拆成了各个 API 的 controller 里。然而经常在一个 controller 里,处理着只是恰好同时发生,但是业务逻辑上没有彼此关联的代码。
- Isolated:ORM 给我们创造出了一个幻觉,然后 1 + N 查询的问题把我们拉回了现实。这种要求 Application Service 一次性把整个 Domain Model 加载到内存的做法,就一点都不 isolated。经常有一种,倒不如把代码都写在 Application Service 拉倒的感觉。
综上面向对象不是那颗银弹,DDD 也不是。
TypeScript 是如何解决这四个问题的?
Talk is cheap, show me the code
View 绑定到数据
首先要解决的问题是尽可能减少 State。比如说我们可以让 View 是“无状态”的,把所有的 View 绑定到数据上。例如为了实现这样的功能:
对应的 View 是 Html 的 DOM,这本身是一份状态。但是我们可以把它绑定到数据上:
<Button @onClick="onMinusClick">-</Button>
<span margin="8px">{{value}}</span>
<Button @onClick="onPlusClick">+</Button>
对应的数据
export class CounterDemo extends RootSectionModel {
value = 0;
onMinusClick() {this.value -= 1;}
onPlusClick() {this.value += 1;}
}
为什么这样算消除状态?在 this.value 被写入的时候,DOM 这份状态不是还是被更新了吗?比较这两种写法
设置绑定关系:<span margin="8px">{{value}}</span>
// 然后在流程内更新状态
this.value -= 1;
以及
// 然后在流程内更新两处状态
this.value -= 1;
this.updateView({msg: this.value})
this.value -= 1 触发的状态更新不算状态更新么?this.value -= 1 然后接着 this.updateView(this.value) 就不好呢?核心问题在于绑定的实质在于,绑定描述两个状态之间的 恒等关系。这个关系是在时间轴之外提前设置好的,而不是在时间轴内描述做为流程的一部分。这样当我们对时间进行叙事的时候,就可以忽略掉被绑定了的状态了。这个就是绑定可以减少状态带来的认知负担的核心原理。
前端状态绑定到数据库状态
我们可以来看一下,整个系统里都有哪些状态。
仅仅托管了界面状态是不够的。只是把问题转移了,不是还要管理前端状态么?各种 redux?所以还要进一步化简,对每一份状态,都要回答,有没有简化的可能?
比如我们希望直接把前端状态和数据库里主存储的状态来个绑定。
这是一个很常见的列表展示页的需求。我们当然可以封装一个后端的 domain object,然后再搞几个 url,封装一下 dto,然后再前端封装几个 view model,然后再展示出来。我们也可以这样:
<CreateReservation />
<Card title="预定列表" margin="16px">
<Form layout="inline">
<InputNumber :value="&from" label="座位数 from" />
<span margin="8px"> ~ </span>
<InputNumber :value="&to" label="to" />
</Form>
<span> 总数 {{totalCount}}</span>
<List :dataSource="filteredReservations" itemLayout="vertical" size="small">
<json #pagination>
{"pageSize": 10}
</json>
<slot #element="::element">
<ShowReservation :reservation="element.item">
</slot>
</List>
<Row justifyContent="flex-end" marginTop="8px">
<Button type="primary" icon="plus" @onClick="onNewReservationClick"> 预定 </Button>
</Row>
</Card>
然后对应绑定到的对象是这样写的:
export class ListDemo extends RootSectionModel {
public from: number = 1;
public to: number = 9;
public get filteredReservations() {return this.scene.query(Reservation_SeatInRange, { from: this.from, to: this.to});
}
public get totalCount() {return this.filteredReservations.length;}
public onNewReservationClick() {this.getSectionModel(CreateReservation).isOpen = true;
}
public viewCreateReservation() {return this.scene.add(CreateReservation);
}
}
我们可以看到,from 的值变了之后,filteredReservations 变了,totalCount 也跟着变了。如果数据源是一个数组,这个 demo 其实没啥。但是注意这里的数据源是 Mysql 数据库。但是我们使用的时候就像操作本地数组一样方便。
这里我们通过类似 GraphQL 的通用后端接口,把前端后端,中间 RPC 的状态都给合并成一个了。但是和 GraphQL 前端定义查询的做法不同,所能够查询的东西仍然是提前注册的,这样可以避免前端滥用无索引的查询的问题。这里做这个注册工作的就是 Reservation_SeatInRange,其定义是这样的
@sources.Mysql()
export class Reservation extends Entity {
public seatCount: number;
public phoneNumber: string;
}
@where('seatCount >= :from AND seatCount <= :to')
export class Reservation_SeatInRange {
public static SubsetOf = Reservation;
public from: number;
public to: number;
}
省掉前后端互相翻译添加的额外状态
前端和后端都是在处理同一个流程的同一个步骤,其上下文是高度一致的。我们可以认为实际上有两层 RPC
当这个 RPC 协议完全服务于对应的页面表单的前提下,这个 RPC 协议的 request 和 response 状态基本上等价于页面表单的状态。当然你可以说,RPC 协议可以是通用的,是可以复用的,和前端无关的。正是因为有这样的态度,所以才会多出来 BFF 这么额外的一层,不是么。创造新的问题。
假设要实现上面这个简单的表单。其视图是这样的
<Card title="餐厅座位预定" width="320px">
<Form>
{{message}}
<Input :value="&phoneNumber" label="手机号" />
<InputNumber :value="&seatCount" label="座位数" />
<Button @onClick="onReserveClick"> 预定 </Button>
</Form>
</Card>
然后我们把这个视图绑定到一个表单对象上,它同时兼任了前后端 RPC 交互协议的职责:
@sources.Scene
export class FormDemo extends RootSectionModel {@constraint.min(1)
public seatCount: number;
@constraint.required
public phoneNumber: string;
public message: string = '';
public onBegin() {this.reset();
}
public onReserveClick() {if (constraint.validate(this)) {return;}
this.saveReservation();
setTimeout(this.clearMessage.bind(this), 1000);
}
@command({runAt: 'server'})
private saveReservation() {if (constraint.validate(this)) {return;}
const reservation = this.scene.add(Reservation, this);
try {this.scene.commit();
} catch (e) {const existingReservations = this.scene.query(Reservation, { phoneNumber: this.phoneNumber});
if (existingReservations.length > 0) {this.scene.unload(reservation);
constraint.reportViolation(this, 'phoneNumber', {message: '同一个手机号只能有一个预定',});
return;
}
throw e;
}
this.reset();
this.message = '预定成功';
}
private reset() {
this.seatCount = 1;
this.phoneNumber = '';
}
private clearMessage() {this.message = '';}
}
实际存储在数据库里,不是这个表单,是另外一个:
@sources.Mysql()
export class Reservation extends Entity {
public seatCount: number;
public phoneNumber: string;
}
我们通过以下手段,把状态要么省掉,要么从一个需要手工管理的状态变成一个衍生状态:
- 转化为衍生的状态:计算属性,状态同步,视图表,物化视图表,缓存
- 让远端的状态就像在本地一样直接使用
- 减少因为网络传输引入的临时状态
Sequential 表达,Concurrent 执行
在兑现了一个 Quantity small 的目标之后,我们来看第二个目标,让代码 sequential。代码 sequential 其实很简单,就是串行写就好了。难题是,如果执行的时候也是 sequential,就会导致加载速度很慢。我们有两个可以参考学习的对象:
- CPU 的投机执行:虽然我们给 CPU 单核的是串行的指令流,但是 CPU 可以在没有数据依赖的情况下尽量投机并发执行。
- Facebook Haxl:https://wiki.haskell.org/wikiupload/c/cf/The_Haxl_Project_at_Facebook.pdf
假设有这样两张表:
CREATE TABLE `User` (`id` int(11) NOT NULL AUTO_INCREMENT,
`name` varchar(255) NOT NULL,
`inviterId` int(11) NULL,
PRIMARY KEY (`id`)
) ENGINE=InnoDB AUTO_INCREMENT=7 DEFAULT CHARSET=latin1;
CREATE TABLE `Post` (`id` int(11) NOT NULL AUTO_INCREMENT,
`title` varchar(255) NOT NULL,
`authorId` int(11) NOT NULL,
`editorId` int(11) NOT NULL,
PRIMARY KEY (`id`)
) ENGINE=InnoDB AUTO_INCREMENT=7 DEFAULT CHARSET=latin1;
对应的类定义:
@sources.Mysql()
export class User extends Entity {
public id: number;
public name: string;
public inviterId: number;
public get inviter(): User {return this.scene.load(User, { id: this.inviterId});
}
public get posts() {return this.scene.query(Post, { authorId: this.id});
}
}
@sources.Mysql()
export class Post extends Entity {
public id: number;
public title: string;
public authorId: number;
public get author(): User {return this.scene.load(User, { id: this.authorId});
}
public get editor(): User {return this.scene.load(User, { id: this.editorId});
}
public get authorName(): string {return this.author.name;}
public get inviterName(): string {
const inviter = this.author.inviter;
return inviter ? inviter.name : 'N/A';
}
}
那么去访问 author 和 editor 的时候,可以写成串行的:
const author = somePost.author
const editor = somePost.editor
return {author, editor}
但是因为中间没有实际访问过这两个对象,所以没有实际的数据依赖,这样的串行代码就会被并发执行。但是这样的访问
const author = somePost.author
const authorInviter = author.inviter
return {author, authorInviter}
因为 author.inviter 产生了数据依赖,这样就没法并发执行。所以这样就提供了一个用串行代码,利用数据的依赖关系来表达并发的方式。
Isolated,让组件只用管自己
然后我们来看第三个目标,Isolated。
假设要把 Post 渲染成上面这样的表格。我们知道“作者”和“邀请人”这两个字段都是外键关联的。所以如果没有任何优化,就是 Isolated 写,Isolated 执行,那么必然是会产生额外的 N + N 条子查询,这里 N 就是 4 行。
但是实际执行的时候只产生了 3 条查询,第一条是查询有多个 Post,第二条查询所有的作者,第三条查询所有的这些作者的邀请人。这里把多个 HTTP 请求合并成三条的 IO 合并是自动做的。
2019-07-19T11:25:04.136927Z 27 Query START TRANSACTION
2019-07-19T11:25:04.137426Z 27 Query SELECT id, title, authorId FROM Post
2019-07-19T11:25:04.138444Z 27 Query COMMIT
2019-07-19T11:25:04.772221Z 27 Query START TRANSACTION
2019-07-19T11:25:04.773019Z 27 Query SELECT id, name, inviterId FROM User WHERE id IN (10, 9, 11)
2019-07-19T11:25:04.774173Z 27 Query COMMIT
2019-07-19T11:25:04.928393Z 27 Query START TRANSACTION
2019-07-19T11:25:04.936851Z 27 Query SELECT id, name, inviterId FROM User WHERE id IN (8, 7, 9)
2019-07-19T11:25:04.937918Z 27 Query COMMIT
查询 mysql 的 general log,可以看到原来的 id = xxx 的查询编程了 id IN (xxx) 的查询了。所以不仅仅是合并成了两次 HTTP 请求,而且进一步合并成了两次 Mysql 查询。
这样就可以避免要求 Application Service 一次性拿一个大的 JOIN 查询把所有的领域层需要的数据全部加载进来这样的要求。可以让代码该 Isolated 的,就保持 Isolated 的。每个组件管好自己的事情,绑好自己的数据,不用管其他人都在干什么。
Continous 的业务流程
我们来看最后一个属性,Continuous。前面提到了两个问题
- 在 DDD 里,业务流程不知道归属给什么聚合根。
- Imperative Programming 会把连续的业务流程,切碎成小段来执行。前后逻辑通过全局状态(也就是数据库)来传递因果性。
我们的解决方案就是提供一种 Entity 叫 Process。它和其他的 Entity 一样,绑定了数据库表,就是数据的载体。同时它又代表了业务流程。也就是我们把一个业务流程函数,持久化成 Entity 了。也可以说我们把业务单据变成可执行的函数了。
假设需要实现上面所示的 Account 的生命周期。一开始账户是处于锁定状态,除非设置了密码。然后登录允许失败,但是最多失败三次。如果超过三次,则回到锁定状态。这个业务逻辑,用 Process 来写是这样的:
const MAX_RETRY_COUNT = 3;
@sources.Mysql()
export class Account extends Process {
public name: string;
// plain text, just a demo
public password: string;
public retryCount: number;
public reset: ProcessEndpoint<string, boolean>;
public login: ProcessEndpoint<string, boolean>;
public process() {
let password: string;
while (true) {locked: this.commit();
const resetCall = this.recv('reset');
password = resetCall.request;
if (this.isPasswordComplex(password)) {this.respond(resetCall, true);
break;
}
this.respond(resetCall, false);
}
let retryCount = MAX_RETRY_COUNT;
for (; retryCount > 0; retryCount -= 1) {normal: this.commit();
const loginAttempt = this.recv('login');
const success = loginAttempt.request === password;
this.respond(loginAttempt, success);
if (success) {
retryCount = MAX_RETRY_COUNT + 1;
continue;
}
}
__GOBACK__('locked');
}
private isPasswordComplex(password: string) {return password && password.length > 6;}
}
这个实体是持久化的,表结构是这样的:
CREATE TABLE `Account` (`id` int(11) NOT NULL AUTO_INCREMENT,
`name` varchar(255) NOT NULL UNIQUE,
`password` varchar(255) NOT NULL,
`status` varchar(255) NOT NULL,
`retryCount` int(11) NULL,
PRIMARY KEY (`id`)
) ENGINE=InnoDB AUTO_INCREMENT=7 DEFAULT CHARSET=latin1;
所以并不是什么把 javascript 协程持久化成不可读的二进制那样的技术,那个是上一代的持久化协程了。值得注意是有一个 status 字段,这个和代码中的 label statement 是对应的执行到了对应的行,status 就会被设置成对应的值。相比使用独立的 BPM 引擎,我们无须额外管理流程上下文,以及同步流程状态回业务的数据库。流程就是业务单据业务实体,业务单据就承载了流程。
这样我们就同时解决了 DDD 里流程逻辑不知道往哪里放的问题,就应该放到流程单据上。例如订单,报价单,这些代表了流程状态的单据表。同时我们也解决了 continous 的问题。但是这样的一个大 process() 函数怎么用呢?不能每次都从头执行吧。使用的代码长这个样子:
这是展示界面 AccountDemo.xml
<Form width="320px" margin="24px">
<Input label="用户名" :value="&name" />
<Input label="密码" :value="&password" />
<switch :value="status">
<slot #default><Button @onClick="onLoginClick"> 登录 </Button></slot>
<slot #locked><Button @onClick="onResetClick"> 重新设置密码 </Button></slot>
</switch>
{{notice}}
</Form>
界面是 reactive 的,流程驱动到了什么状态,就对应展示什么状态的交互。
这是界面对应的 AccountDemo.ts
@sources.Scene
export class AccountDemo extends RootSectionModel {
@constraint.required
public name: string;
@constraint.required
public password: string;
private justFailed: boolean;
private get account() {const accounts = this.scene.query(Account, { name: this.name});
return accounts.length === 0 ? undefined : accounts[0];
}
public get notice() {if (this.justFailed === undefined) {return '';}
if (this.justFailed === false) {return '登录成功';}
if (!this.account) {return '';}
if (this.account.status === 'locked') {return '账户已被锁定';}
return ` 还剩 ${this.account.retryCount} 次重试 `;
}
public get status() {if (!this.justFailed || !this.account) {return 'default';}
return this.account.status;
}
public onLoginClick() {if (constraint.validate(this)) {return;}
if (!this.account) {
constraint.reportViolation(this, 'password', {message: '用户名或者密码错误',});
return;
}
try {const success = this.scene.call(this.account.login, this.password);
if (!success) {throw new Error('failed');
}
this.justFailed = false;
} catch (e) {
this.justFailed = true;
constraint.reportViolation(this, 'password', {message: '用户名或者密码错误',});
return;
}
}
public onResetClick() {if (this.account) {this.scene.call(this.account.reset, 'p@55word');
}
}
}
通过 Process 暴露出来的 ProcessEndpoint,我们可以驱动这个流程。如果不需要返回值,用 ProcessEvent 单向通信也可以。
通过 Process,我们可以把一个流程的状态修改都封装到这个 Process 里,实现真正的封装。同时对于,流程内的分叉合并这些可以表达起来更自然。以及一个用户操作,需要同时驱动多个 Process 的情况,比如同时要处理营销流程,售卖流程,仓储库存流程之类的,可以很好的实现各自的独立闭环。而不用在一个大的 controller 里,把所有人的业务都做一点点。
所以,OOP/DDD 不够看的,得上 TypeScript。但是,你这里的 TypeScript 是 TypeScript 吗?
你们是谁?
我们的名字叫乘法云。我们在挑战的问题是
从业务想法到软件上线,速度如何提高 10x?
这里演示的 TypeScript 语法,可以完全通过 eslint/tslint 的检查,是纯正的 TypeScript。但是我们有自己的 aPaaS 平台,实现了以上所有的功能的运行时支持。官网和 IDE 正在紧张招人开发中。以下是广告时间,谢谢阅读。
求前端!求前端!求前端!
我们为顶尖工程师提供了与之相配的技术发挥空间、无后顾之忧的宽松工作环境以及有竞争力的薪酬福利。同时也为高潜质的行业新人提供充分的学习和成长机会。
这里,没有 996,崇尚高效。
这里,话语权不靠职级和任命,靠的是代码的说服力。
这里,不打鸡血,我们用理性和内驱力去征服各种挑战。
这里,也会有项目排期,但不怕 delay,我们有充足的时间,做到让自己更满意。
工作地点在北京西二旗,薪酬待遇见招聘链接:https://www.zhipin.com/job_de…