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
咱們並非沒有辦法去更新這些 State,Imperative Programming 的方式很是直觀,就是把一堆讀寫狀態的指令給CPU,CPU就會去一五一十地執行。咱們能夠把軟件地執行過程畫成這樣地一棵樹:react
軟件的外在行爲,就是按照時間順序,產生一系列的狀態更新。也也就是有邏輯地按順序產生這些黃顏色的節點。可是問題是:git
若是一五一十地,按時間順序描述每個狀態更新的編程風格,產生出來的代碼冗長並且瑣碎。github
也就是最直觀的,最easy的作法,並不能是最優的解法。即便咱們抽了不少很好的函數,也就是這些藍色的圈圈。雖然可讓代碼看起來規整,可是仍是冗長仍是瑣碎。我去年寫了兩篇關於代碼可讀性的文章,其實就是在講這些問題:https://zhuanlan.zhihu.com/p/46435063 和 https://zhuanlan.zhihu.com/p/34982747 。如今看來有點太囉嗦了。並且 readable 是一個偏主觀的概念。Rich Hickey 有一個演講 "Simple Made Easy" 講得很好,他說 simple 是一個客觀的指標。我把 Simple 具體爲如下四個能夠客觀度量的屬性sql
與這四個屬性相反的是數據庫
Imperative Programming 表明的是這個真實世界。真實世界就是 Quantity large,無時無刻不 parallel,處處都是 long range causality,並且 entangled 的。Simplicity 是表明了人們假想的伊甸園,是咱們對肉腦薄弱的感知和計算能力的遷就。Simplicity is hard,when simplicity is not the reality。編程
因此,咱們能夠把要解決的問題,分解成這兩個問題:
DDD 能夠認爲是這麼三步
其核心就是能夠聚合根對狀態的黑盒封裝。這種所謂的黑盒封裝有兩個問題
爲何說沒有本質區別:
綜上面向對象不是那顆銀彈,DDD也不是。
Talk is cheap, show me the code
首先要解決的問題是儘量減小 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; }
咱們經過如下手段,把狀態要麼省掉,要麼從一個須要手工管理的狀態變成一個衍生狀態:
在兌現了一個 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。
假設要把 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 的。每一個組件管好本身的事情,綁好本身的數據,不用管其餘人都在幹什麼。
咱們來看最後一個屬性,Continuous。前面提到了兩個問題
咱們的解決方案就是提供一種 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...