减少状态引起的代码复杂度 - V2EX
V2EX = way to explore
V2EX 是一个关于分享和探索的地方
现在注册
已注册用户请  登录
taowen
V2EX    TypeScript

减少状态引起的代码复杂度

  •  
  •   taowen 2019-07-22 21:39:45 +08:00 4531 次点击
    这是一个创建于 2271 天前的主题,其中的信息可能已经有所发展或是发生改变。

    要解决的问题是什么?

    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 就会去一五一十地执行。我们可以把软件地执行过程画成这样地一棵树:

    img

    软件的外在行为,就是按照时间顺序,产生一系列的状态更新。也也就是有逻辑地按顺序产生这些黄颜色的节点。但是问题是:

    如果一五一十地,按时间顺序描述每一个状态更新的编程风格,产生出来的代码冗长而且琐碎。

    也就是最直观的,最 easy 的做法,并不能是最优的解法。即使我们抽了很多很好的函数,也就是这些蓝色的圈圈。虽然可以让代码看起来规整,但是还是冗长还是琐碎。我去年写了两篇关于代码可读性的文章,其实就是在讲这些问题:https://zhuanlan.zhihu.com/p/46435063https://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 可以认为是这么三步

    1. Application Service 加载 Domain Model
    2. 由 Aggregate Root 封装对状态的修改
    3. 副作用体现为 Domain Model 的更新,以及产生的 Domain Event

    其核心就是可以聚合根对状态的黑盒封装。这种所谓的黑盒封装有两个问题

    1. 说到底,聚合根的 method,和 imperative programming 的 function,没有本质区别
    2. 对象之间的交互,特别是业务流程对多个对象的更新,没有自然的聚合根的归属。或者说,真正的聚合根应该是业务流程本身。但是流程并不是 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 绑定到数据上。例如为了实现这样的功能:

    img

    对应的 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) 就不好呢?核心问题在于绑定的实质在于,绑定描述两个状态之间的恒等关系。这个关系是在时间轴之外提前设置好的,而不是在时间轴内描述做为流程的一部分。这样当我们对时间进行叙事的时候,就可以忽略掉被绑定了的状态了。这个就是绑定可以减少状态带来的认知负担的核心原理。

    前端状态绑定到数据库状态

    我们可以来看一下,整个系统里都有哪些状态。

    img

    仅仅托管了界面状态是不够的。只是把问题转移了,不是还要管理前端状态么?各种 redux ?所以还要进一步化简,对每一份状态,都要回答,有没有简化的可能?

    比如我们希望直接把前端状态和数据库里主存储的状态来个绑定。

    img

    这是一个很常见的列表展示页的需求。我们当然可以封装一个后端的 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

    img

    当这个 RPC 协议完全服务于对应的页面表单的前提下,这个 RPC 协议的 request 和 response 状态基本上等价于页面表单的状态。当然你可以说,RPC 协议可以是通用的,是可以复用的,和前端无关的。正是因为有这样的态度,所以才会多出来 BFF 这么额外的一层,不是么。创造新的问题。

    img

    假设要实现上面这个简单的表单。其视图是这样的

    <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 eservation = 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,就会导致加载速度很慢。我们有两个可以参考学习的对象:

    假设有这样两张表:

    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。

    img

    假设要把 Post 渲染成上面这样的表格。我们知道“作者”和“邀请人”这两个字段都是外键关联的。所以如果没有任何优化,就是 Isolated 写,Isolated 执行,那么必然是会产生额外的 N + N 条子查询,这里 N 就是 4 行。

    img

    但是实际执行的时候只产生了 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 了。也可以说我们把业务单据变成可执行的函数了。

    img

    假设需要实现上面所示的 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_detail/?query=%E4%B9%98%E6%B3%95%E4%BA%91&city=101010100&industry=&position=

    1 条回复    2019-07-23 22:50:56 +08:00
    关于     帮助文档     自助推广系统     博客     API     FAQ     Solana     1104 人在线   最高记录 6679       Select Language
    创意工作者们的社区
    World is powered by solitude
    VERSION: 3.9.8.5 28ms UTC 23:19 PVG 07:19 LAX 16:19 JFK 19:19
    Do have faith in what you're doing.
    ubao snddm index pchome yahoo rakuten mypaper meadowduck bidyahoo youbao zxmzxm asda bnvcg cvbfg dfscv mmhjk xxddc yybgb zznbn ccubao uaitu acv GXCV ET GDG YH FG BCVB FJFH CBRE CBC GDG ET54 WRWR RWER WREW WRWER RWER SDG EW SF DSFSF fbbs ubao fhd dfg ewr dg df ewwr ewwr et ruyut utut dfg fgd gdfgt etg dfgt dfgd ert4 gd fgg wr 235 wer3 we vsdf sdf gdf ert xcv sdf rwer hfd dfg cvb rwf afb dfh jgh bmn lgh rty gfds cxv xcv xcs vdas fdf fgd cv sdf tert sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf shasha9178 shasha9178 shasha9178 shasha9178 shasha9178 liflif2 liflif2 liflif2 liflif2 liflif2 liblib3 liblib3 liblib3 liblib3 liblib3 zhazha444 zhazha444 zhazha444 zhazha444 zhazha444 dende5 dende denden denden2 denden21 fenfen9 fenf619 fen619 fenfe9 fe619 sdf sdf sdf sdf sdf zhazh90 zhazh0 zhaa50 zha90 zh590 zho zhoz zhozh zhozho zhozho2 lislis lls95 lili95 lils5 liss9 sdf0ty987 sdft876 sdft9876 sdf09876 sd0t9876 sdf0ty98 sdf0976 sdf0ty986 sdf0ty96 sdf0t76 sdf0876 df0ty98 sf0t876 sd0ty76 sdy76 sdf76 sdf0t76 sdf0ty9 sdf0ty98 sdf0ty987 sdf0ty98 sdf6676 sdf876 sd876 sd876 sdf6 sdf6 sdf9876 sdf0t sdf06 sdf0ty9776 sdf0ty9776 sdf0ty76 sdf8876 sdf0t sd6 sdf06 s688876 sd688 sdf86