減小狀態引發的代碼複雜度

要解決的問題是什麼?

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

相關文章
相關標籤/搜索