taowen
首发于taowen

面向对象不是银弹,DDD 也不是,TypeScript 才是

要解决的问题是什么?

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的做法,并非最优的解法。即使我们抽了很多很好的函数,也就是这些蓝色的圈圈。虽然可以让代码看起来规整,但是还是冗长还是琐碎。我去年写了两篇关于代码可读性的文章,其实就是在讲这些问题:zhuanlan.zhihu.com/p/46zhuanlan.zhihu.com/p/34 。现在看来有点太啰嗦了。而且 readable 是一个偏主观的概念。Rich Hickey 有一个演讲 "Simple Made Easy" 讲得很好,他说 simple 是一个客观的指标。我把 Simple 具体为以下四个可以客观度量的属性

  • Fewer States:数量上少
  • Sequential:串行的
  • Continuous:上一行和下一行有必然的因果关系的必要。而有因果关系的逻辑,不应该相距太远
  • Isolated:事情之间的相互影响小。能够 isolate,才意味着可以变成组件分解出来

与这四个属性相反的是

  • Many states:数量上多
  • Concurrent / Parallel:并发是逻辑上的,并行是物理上的。无论是哪种,都比 sequential 更复杂。
  • Long range causality:长距离的因果关系
  • Entangled:剪不断理还乱

并不是要消除所有的 state,数量能少一点是一点。剩下的部分就是必须要描述的按时间组织的业务流程,仍然是 Imperative Programming。TLA+ 进化成 PlusCal 之后,也看起来是命令式编程了。说明了这种表述方式,仍然是人类已知的描述时间顺序最直接的方式。但是对这部分希望是 sequential / continuous / isolated 的,来控制其复杂度。

OOP/DDD 解决了上面的四个问题么?

DDD 可以认为是这么三步

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

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

  1. 说到底,聚合根的method,和 imperative programming 的 function,没有本质区别
  2. 对象之间的交互,特别是业务流程对多个对象的更新,没有自然的聚合根的归属。或者说,真正的聚合根应该是业务流程本身。但是流程并不是 Entity。

为什么说没有本质区别:

  • Fewer States:在 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;
}

如果不是 TypeScript,要实现前后端共享模型定义还是挺麻烦的事情。当然你可能会说,怎么能这么干。后端就是后端,前端就是前端。世界上本来没有这么多规矩,还是要看收益。在后端的 Reservation,和前端的 Reservation 中间封装一层又一层的对象,真能有什么收益么?思考一下。

省掉前后端互相翻译添加的额外状态

前端和后端都是在处理同一个流程的同一个步骤,其上下文是高度一致的。我们可以认为实际上有两层 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,就会导致加载速度很慢。我们有两个可以参考学习的对象:

假设有这样两张表:

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 里,把所有人的业务都做一点点。

这里哪点是原创的?

几乎所有都是抄袭的。我们认为这是一件好事,因为熟悉,所以上手快。

  • TypeScript:一模一样的语法
  • 界面绑定:抄 vue/mobx,当然 vue 也是抄别人的
  • 数据库绑定:抄 PowerBuilder?
  • 前后端通信:抄 meteor?
  • 自动化并发,自动化IO合并:抄 Haxl
  • 序列化协程:抄 lua?抄 stackless python?
  • ORM:抄 Hibernate?
  • Snapshot:抄 immer.js?

我们最大的原创在于把这些独立的技术进行了整合。比如 stackless python 是可以序列化协程,但是你和把序列化的协程变成持久化在 Mysql 里的业务单据么?可以出报表么?可以界面绑定么?

Leaky Abstraction

这种人造的伊甸园创造的幻觉毕竟是幻觉。Every abstraction leaks。引擎不工作的时候,就是打开引擎盖子的时候。

Imperative Programming 代表的是这个真实世界。真实世界就是 Quantity large,无时无刻不 parallel,到处都是 long range causality,而且 entangled 的。Simplicity 是代表了人们假想的伊甸园,是我们对肉脑薄弱的感知和计算能力的迁就。Simplicity is hard,when simplicity is not the reality。

所以,我们解决一个问题

给我们的肉脑创造一个虚拟的伊甸园,在这里,Fewer states,Sequential,Continuous,Isolated。

又创造了一个新问题

和 Imperative Programming 不同,伊甸园的叙事方式和真实世界脱节了。所以当在残忍的真实世界里出了问题,没法在代码里找到直接对应。需要提供工具帮助人类理解实际 Excessively stateful, concurrent / parallel,long range causality,entangled。

你们是谁?

我们的名字叫乘法云。我们在挑战的问题是

从业务想法到软件上线,速度如何提高10x?

这里演示的 TypeScript 语法,可以完全通过 eslint/tslint 的检查,是纯正的 TypeScript。但是我们有自己的 aPaaS 平台,实现了以上所有的功能的运行时支持。官网和 IDE 正在紧张招人开发中。以下是广告时间,谢谢阅读。

求前端!求前端!求前端!

我们为顶尖工程师提供了与之相配的技术发挥空间、无后顾之忧的宽松工作环境以及有竞争力的薪酬福利。同时也为高潜质的行业新人提供充分的学习和成长机会。

这里,没有996,崇尚高效。
这里,话语权不靠职级和任命,靠的是代码的说服力。
这里,不打鸡血,我们用理性和内驱力去征服各种挑战。
这里,也会有项目排期,但不怕delay,我们有充足的时间,做到让自己更满意。

工作地点在北京西二旗,薪酬待遇见招聘链接:zhipin.com/job_detail/?

编辑于 2019-07-24

文章被以下专栏收录