减小状态引发的代码复杂度

要解决的问题是什么?

A problem well-stated is Half-solvedjavascript

"No Silver Bullet - Essence and Accident in Software Engineering"前端

以及另一篇著名的 "Out of the Tar Pit" 都把 State 形成的复杂度放到了首要的位置。java

其实要解决问题一直都是房间里的那头大象,Imperative Programming 的方式去管理 State 太复杂了。mysql

Imperative Programming 的问题是什么?

咱们并非没有办法去更新这些 State,Imperative Programming 的方式很是直观,就是把一堆读写状态的指令给CPU,CPU就会去一五一十地执行。咱们能够把软件地执行过程画成这样地一棵树:react

img

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

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

也就是最直观的,最easy的作法,并不能是最优的解法。即便咱们抽了不少很好的函数,也就是这些蓝色的圈圈。虽然可让代码看起来规整,可是仍是冗长仍是琐碎。我去年写了两篇关于代码可读性的文章,其实就是在讲这些问题:https://zhuanlan.zhihu.com/p/46435063https://zhuanlan.zhihu.com/p/34982747 。如今看来有点太啰嗦了。并且 readable 是一个偏主观的概念。Rich Hickey 有一个演讲 "Simple Made Easy" 讲得很好,他说 simple 是一个客观的指标。我把 Simple 具体为如下四个能够客观度量的属性sql

  • 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_de...

相关文章
相关标签/搜索