今天在微博上说了React对于面向对象编程里两个启示:组件模型的接口设计,和生命周期管理;说的比较抽象,这里给一个例子,讨论一些细节。node
这个例子是我正在写的一个项目,有一个功能是在一个connection上,multiplex/demultiplex多个stream出来;考虑到使用方便,提供给用户的应该是node的stream.Readable/stream.Writable这样的类实例。算法
connection就是node里的一个stream对象,实际项目里多是tcp/tls,或者任何duck-type象duplex stream的东西。编程
每一个connection对应了通信的另外一方,但除了connection还有其余状态须要维护,因此首先有个叫Peer
的类,它包含一个connection,大概这样:并发
class Peer extends EventEmitter { constructor (conn) { super() this.conn = conn ... } }
从conn里demux一个Writable;咱们假定每一个Writable
有惟一id;Peer
能够有Array或Map来维护全部的Writable
。异步
Writable在被用户写入数据时,它得可以封包把这个数据发出去,因此须要一个能调用到peer.conn
上的write
方法的办法,或者封装一个peer.write
方法;tcp
在用户写完结束的时候会调用Writable
的end
,Writable
会emit finish
,这彷佛是一个从Peer
里移除Writable
的好地方;函数
用户也可能出于某种缘由提早停止这个stream,它应该调用destroy
方法,这是node的stream的设计,开发者应该遵循这个约定,但能够重载方法。一个比较友好的实现是destroy
时向对方发送一个错误包,告知对方流被异常取消。性能
若是Writable
内部还有一些逻辑,好比encoding,它本身也可能出错;按照node的设计习惯,对象都是disposable的,一旦错误就抛弃,不考虑修复。测试
还有反方向来的几种错误逻辑:this
Peer.end()
被调用,此时所有Writable都要被清理,能够抛出异常;常见的设计方式是:
class Writable extends stream.Writable { constructor(id, peer) { super() this.peer = peer } _write (chunk, encoding, callback) { this.peer.write(chunk, encoding, callback) } _final (callback) { this.peer.write('bye bye my deer', callback) } _destroy (err, callback) { this.peer.write('I am destroyed') callback() } }
不熟悉node的stream的朋友可能须要理解一下;node容许本身继承实现一个stream;这个继承的stream,象这里,提供的_
开头的函数都是被内部调用的,分别对应write
, end
, destroy
这三个公开方法;这样实现的stream最大的收益是,node保证这些方法是被顺序调用的,好比一个_write
的实现里调用callback
参数以前,这个方法,或者其余方法,不会被调用,这给开发者带来很大的方便,不用本身处理并发和排队的问题。
对于熟悉node stream的代码的朋友来讲这个代码算是司空见惯。没有任何须要商榷的。
那么Writable的生命周期维护者Peer,在何时会把这个对象从本身的队列中移除呢?
成功结束(end
)的时候,它能够侦听finish
;若是是错误,它能够侦听error
;可是这个destroy
有点儿使人恼火,它什么事件都没抛;固然这个虽然打破了美感但不构成任何实际困难,能够直接去操做peer的数据把本身移除。
而后咱们来仔细考虑一下错误处理:
若是错误来自上层终止了整个connection,或者对方挂断了整个connection,或者对方决定abort,这里直接trigger这个Writable emit一个error便可;
若是Writable本身有内部的错误,也能够直接抛;
在Writable的构造函数结束以前,能够挂一个error handler给本身,这样各类错误均可以直接处理。只是须要区分一下,抛出错误时是否connection还可用,若是可用向另外一方发一个包告知。
若是实际代码写成这样,我实际上是没以为有任何问题的,错误处理覆盖的也足够全了,不用要求更多;即便在粗略的考虑上有模糊的细节,代码和测试写出来均可以澄清;我相信这样写出的代码在任何公司都不会被批评或解雇的。
可是让咱们来吹毛求疵一下:
第一,咱们在说React;React在任何状况下不传递组件引用,只传递props,包括Bound Function;因此Writable的this.peer
引用是不大好的;Peer
的功能多了去了,所有暴露给Writable是扩大范围;实际上在这里咱们只看到有两个地方是必要的。
第一是peer.write
须要被调用,第二是destroy的时候要移除本身。
对于第一个来讲,peer能够传入一个bound function;第二个,一样能够有一个这样的function,封装一下移除Writable的过程,可是应该叫什么名字呢?咱们姑且叫它deleteMe
。
而后咱们再想一想这个代码还哪里有问题?
我能想出来这么几个:
第一,这个Writable的维护者(Peer),和Writable之间,关于生命周期维护的协议约定是否是有点儿多?
Peer须要知道Writable有一个finish
事件,还须要知道它有个叫error
的事件是等价于finish
的;最后还得提供给他deleteMe
这样一个东西?为何Peer要理解这么多?若是下次不是Writable了,改称Readable了,finish
事件换了名字,换成end
了,Peer也须要知道吗?
第二,EventEmitter.emit自己是一个同步的过程,若是Peer在收到connection的error的时候,直接调用:
writable.emit(error)
若是清理writable的代码直接hook在error
事件上,固然这极可能是能够的;可是若是你须要异步呢?
哦,彷佛能够是不要直接调用writable.emit(err),能够有一个handleError之类的方法缓冲一下,清理完了再抛error;但这样作也有一个问题,假如这个stream有个用户有很是耗资源的过程,例如准备要写入的数据,它应该尽早获得error对吗?
你仔细想一想就会明白,Writable的全部接口方法和事件,都是给用户用的,不是给它的生命周期维护者用的。它的生命周期维护者能够用更简单和秘密的协议与它合做完成生命周期维护这个任务。
finish
或者error
表明另外一种finish
这些都不是Peer须要理解的,Peer只须要Writable在本身结束的时候告诉它,"I am terminated.",就够了,我相信你会赞成它须要一个更好和更准确的名字:
onStreamTerminated(id)
你看,这样当你的下一个任务是实现demux一个Readable Stream的时候,不须要作任何改动,right?
这很React对吗?
这是Peer传递给Writable的一个Prop,是一个bound function,当它被调用时控制权回到Peer的手上,它移除这个writable(或者将来的readable),至关于setState以后re-render,只不过此次是同步的。
至于Writable提供的一组methods和events,或者下一次改为了readable出现了另外一组methods和events,他们就像你在一个容器里嵌入了另外一个组件,这些methods和events是这个组件和用户,或者其余什么你看不到的组件,之间的protocol/interface;
组件设计思想里最重要的部分,就是你要知道哪一个是『你』的interface,不要由于有个引用在手上,就哪一个都去用用。
下一个问题:Peer能够调用Writable的destroy方法吗?
个人答案是No。
Peer是Writable的生命周期维护者,但Peer并不是Writable的用户。
Peer只要和Writable之间有两个bound founction的约定就能够工做了,一个是write,一个是onStreamTerminated,Peer为何还要知道Writable有其余的什么状态和行为细节呢?
我相信在不少相似的场景中,开发者会写出这样的错误处理代码,就是在Peer里自做主张处理了错误,把Writable(或Readable)直接destroy了。
Is this OK? Possible.
若是你把Destroy看做一个生命周期方法,它的owner是它的生命周期维护者;就像React有ComponentDidMount之类的hook同样,当生命周期维护变得复杂的时候,组件须要提供不少生命周期方法供维护者使用;可是!若是destroy这个方法是生命周期方法,就要禁止用户再使用它了。至少在node里这不是一个好办法,它break了接口的语义习惯(semantic convention),是开发的大忌。
最后:
你会给Writable装上一个handleError方法吗?把各类错误从这里塞进去?
做为接口设计我不认为这是一个好的作法,两个缘由。
第一,功能接口应该追求literal,而不是abstraction,这不是在搞算法,handleError这种名字是没语义的,并且十之八九要在里面搞if/then/else,那为何不这样设计接口呢:
peerAbort(err) // 对方放弃 connectionFinished() // node习惯,指己方结束 connectionEnded() // node习惯,指对方结束
这不是一目了然吗?
第二,你会发现上面写的这些分开的函数,分别位于不一样的event handler里,彼此之间没有干扰,逻辑清晰。
我借用一个硬件术语,叫cross-talk,来指那种使用handleError函数混合这些错误处理路径的作法,cross-talk的意思就是信号靠得太近了,有干扰。
并且分开干扰有性能收益,由于JavaScript的inline和inline caching能够更好的工做。
以上,是以React的设计思惟来理解的一个例子,你不从React来理解也没问题;可是:
我相信这些是普适的价值;
React的参考意义在于
它事实上赋予了一个组件的生命周期维护者特殊身份,维护者和被维护者之间不该经过用户接口通信,他们之间须要有“秘密通道”做为双方的工做协议,不污染用户接口;
例如传递onStreamTerminated
,是优于streame.emit('terminated')
的作法的。也许你还想顽抗一下说,那stream增长这个terminated
事件,也许不仅peer使用啊,也许还有其余用户也须要这个finish || error
的逻辑;我对此的回答是:是让Peer装上一个onStreamTerminated
方法全部stream均可用仍是它的全部stream都要有一个terminated
事件呢?
以上,我的意见,供参考。
写代码的一大乐趣是,你总能更fussy。