V2EX = way to explore
V2EX 是一个关于分享和探索的地方
现在注册
已注册用户请  登录
taowen
V2EX  ›  TypeScript

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

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

    要解决的问题是什么?

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

    假设有这样两张表:

    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   ·   实用小工具   ·   1184 人在线   最高记录 6679   ·     Select Language
    创意工作者们的社区
    World is powered by solitude
    VERSION: 3.9.8.5 · 28ms · UTC 23:19 · PVG 07:19 · LAX 15:19 · JFK 18:19
    Developed with CodeLauncher
    ♥ Do have faith in what you're doing.