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