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

31次阅读

共计 12550 个字符,预计需要花费 32 分钟才能阅读完成。

要解决的问题是什么?

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 可以认为是这么三步

  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 绑定到数据上。例如为了实现这样的功能:

对应的 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…

正文完
 0