6

今天在微博上说了React对于面向对象编程里两个启示:组件模型的接口设计,和生命周期管理;说的比较抽象,这里给一个例子,讨论一些细节。

这个例子是我正在写的一个项目,有一个功能是在一个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方法;

在用户写完结束的时候会调用WritableendWritable会emit finish,这似乎是一个从Peer里移除Writable的好地方;

用户也可能出于某种原因提前中止这个stream,它应该调用destroy方法,这是node的stream的设计,开发者应该遵循这个约定,但可以重载方法。一个比较友好的实现是destroy时向对方发送一个错误包,告知对方流被异常取消。

如果Writable内部还有一些逻辑,比如encoding,它自己也可能出错;按照node的设计习惯,对象都是disposable的,一旦错误就抛弃,不考虑修复。

还有反方向来的几种错误逻辑:

  1. Peer被上层终止,例如Peer.end()被调用,此时全部Writable都要被清理,可以抛出异常;
  2. Peer的另一端决定放弃这个Writable,abort;
  3. Peer的connection断连了,也是类似的灾难情况,需要优雅处理。

常见的设计方式是:

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来理解也没问题;但是:

  • 最小接口,Writable并不拿到整个Peer引用为所欲为;
  • 最低耦合度,Peer并不理解Writable的行为,也不调用Writable的任何方法,仅提供Writable两个必要方法;
  • Be Literal,不要盲目混合代码处理路径,尤其是错误处理;

我相信这些是普适的价值;

React的参考意义在于

  • React Component之间无引用,props是组件之间唯一的接口协议,清晰无歧义,不扩大范围,是最佳耦合设计;
  • React有严格的top down的生命周期维护结构;

它事实上赋予了一个组件的生命周期维护者特殊身份,维护者和被维护者之间不应通过用户接口通讯,他们之间需要有“秘密通道”作为双方的工作协议,不污染用户接口;

例如传递onStreamTerminated,是优于streame.emit('terminated')的做法的。也许你还想顽抗一下说,那stream增加这个terminated事件,也许不只peer使用啊,也许还有其他用户也需要这个finish || error的逻辑;我对此的回答是:是让Peer装上一个onStreamTerminated方法所有stream都可用还是它的所有stream都要有一个terminated事件呢?


以上,个人意见,供参考。

写代码的一大乐趣是,你总能更fussy。


uglee
1.1k 声望1.2k 粉丝