React啓示錄

今天在微博上說了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

在用戶寫完結束的時候會調用WritableendWritable會emit finish,這彷佛是一個從Peer裏移除Writable的好地方;函數

用戶也可能出於某種緣由提早停止這個stream,它應該調用destroy方法,這是node的stream的設計,開發者應該遵循這個約定,但能夠重載方法。一個比較友好的實現是destroy時向對方發送一個錯誤包,告知對方流被異常取消。性能

若是Writable內部還有一些邏輯,好比encoding,它本身也可能出錯;按照node的設計習慣,對象都是disposable的,一旦錯誤就拋棄,不考慮修復。測試

還有反方向來的幾種錯誤邏輯:this

  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。

相關文章
相關標籤/搜索