異步函數的兩個視角

我們來一起看一下兩個程序員之間的故事。

以下示例代碼是用Scala寫的,不過本文所講的話題并不僅限于Scala,任何有Future/Promise支持的語言都是適用的。

下面這個wiki頁面羅列了各個有Future/Promise支持的語言,已經涵蓋了大多數的常用語言。

Future與promise實現列表

我是異步函數的編寫者

我寫了兩個異步函數,來提供給其他程序員同事使用。

type CallBack = Try[String] => Unit

def pretendCallAPI(callBack: CallBack, okMsg: String, failedMsg: String) = {
  val task = new TimerTask {
    override def run() = {
      val percentage = Random.between(1, 100)

      if (percentage >= 50)
        callBack(Success(okMsg))
      else if (percentage <= 30)
        callBack(Failure(new Exception(failedMsg)))
      else
        callBack(Failure(new Exception("network problem")))
    }
  }

  new Timer().schedule(task, Random.between(200, 500))
}

val searchTB = pretendCallAPI(_, "product price found", "product not listed")
val buyFromTB = pretendCallAPI(_, "product bought", "can not buy, no money left")

這兩個異步函數: searchTB用來從淘寶搜索物品,另一個buyFromTB用來購買搜到的物品。

由于僅僅是為了演示而寫的,他們兩個都是基于一個叫做pretendCallAPI的函數實現的。

顧名思義,pretendCallAPI并不會真的去調用淘寶的API,而只是模擬API的行為。

這個pretendCallAPI函數有幾個行為特征:

  • 每次耗時200到500毫秒之間
  • 每次執行有50%的幾率成功
  • 20%的幾率遇到網絡故障
  • 另外30%的幾率雖然網絡沒問題但是服務器會給你一個非正常的結果

當然,由于我寫的是異步算法,需要避免block caller thread。

所以當你調用pretendCallAPI的時候,這個函數是瞬間立即返回的。

那么當然我就無法在函數返回的時候return什么有用的東西給你了。

如果你想知道執行的結果到底是啥,你需要傳給我一個CallBack,在我執行完后,通過CallBack來告知你執行的結果。

這個CallBack的完整簽名表達式展開是Try[String] => Unit

大家看searchTB和buyFromTB可能覺得他們長的有點奇怪,這是Scala里柯里化的寫法。

也就是通過把pretendCallAPI包一層來構造新的函數,鎖死兩個參數,剩下的一個參數(也就是CallBack)就變成了新構造出來的函數的唯一參數了。

也就是說searchTB和buyFromTB的簽名是(Try[String] => Unit) => Unit。

關于柯里化這個語言特性的更多信息:

https://cuipengfei.me/blog/2013/12/25/desugar-scala-6/

好了,現在這兩個函數可以提供給大家使用了。

我是異步函數的調用者

聽說異步函數已經寫好了,我終于可以用他們來實現剁手業務了。

聽函數作者講了一下,用起來應該不會很難,那我來實現一下吧。

def searchPriceThenBuy() = {
  searchTB {
    case Success(searchMsg) =>
      println(searchMsg)
      buyFromTB {
        case Success(buyMsg) => println(buyMsg)
        case Failure(err) => println(err.getMessage)
      }
    case Failure(err) => println(err.getMessage)
  }
}

使用searchTB和buyFromTB并不難. 他們兩個都是接受CallBack作為參數的函數。

CallBack本身是個函數,它的簽名是Try[String] => Unit。

而Try有兩種形式,分別是Success和Failure。

所以在調用searchTB和buyFromTB的時候,必須把兩個分支都給到(以免pattern match不到)。

這樣在異步函數有結果的時候(無論成敗)才能call back過來到我的代碼,以便我能夠在合適的時機做后續的處理(無論是基于成功做后續業務,還是做error handling)。

關于pattern match,可以參考這里:

https://cuipengfei.me/blog/2013/12/29/desugar-scala-8/

https://cuipengfei.me/blog/2015/06/16/visitor-pattern-pattern-match/

這段代碼跑一下的話,會有這么幾種結果:

  • 搜到了,也買到了
  • 搜到了,購買時遇到了網絡故障
  • 搜到了,由于支付寶錢不夠而沒買到
  • 沒搜到,購買行為未觸發
  • 搜索遇到網絡故障,購買行為未觸發

一共就這么幾種可能,因為pretendCallAPI是跑概率的,多跑幾次這些情況都能遇到。

雖然實現出來不難,執行結果也沒問題,但是總有點隱憂。

這里只有searchTB和buyFromTB兩個函數,如果其他場景下我需要把更多的異步函數組合起來使用呢?豈不是要縮進很多層?

當然,縮進只是個視覺審美問題,是個表象,不是特別要緊.關鍵是我的業務邏輯很容易被這樣的代碼給割裂的雞零狗碎,那就不好了。

我要給上游編寫異步函數的同事反饋一下,看是否有辦法解決這個問題。

鏡頭切回到異步函數編寫者

之前寫的兩個函數反饋不太好,主要是因為同事們認為使用CallBack不是最優的方式。

這個反饋確實很中肯,如果只有一個異步函數單獨使用,用CallBack也沒什么太大的問題,如果是很多個異步函數組合使用確實會形成多層嵌套的問題。

我作為上游程序員,確實需要更多地為下游調用者考慮。

既然如此,那我改版一下,免除掉讓下游使用CallBack的必要性。

type CallBackBasedFunction = (CallBack) => Unit

def futurize(f: CallBackBasedFunction) = () => {
  val promise = Promise[String]()

  f {
    case Success(msg) => promise.success(msg)
    case Failure(err) => promise.failure(err)
  }

  promise.future
}

val searchTBFutureVersion = futurize(searchTB)
val buyFromTBFutureVersion = futurize(buyFromTB)

先定義一個CallBackBasedFunction,它代表一個接受CallBack為參數的函數的簽名。

表達式展開后就是: (Try[String] => Unit) => Unit

這就符合了searchTB和buyFromTB兩個函數的簽名。

futurize算是個higher order function,它接受一個CallBackBasedFunction作為參數,返回一個() => Future[String]。

(Future是Scala標準庫的內容,可以認為和JS Promises/A+是類似的概念)

也就是說futurize可以把searchTB和buyFromTB改造成返回Future的函數。上面代碼最后兩行就是改造的結果。

這樣,原本接受CallBack做為參數且沒有返回值的函數,就變成了不接受參數且返回Future的函數。

再看futurize的具體實現,它使用了Scala的Promise,讓返回的Future在原版函數成功時成功,在原版函數失敗時失敗。

這樣,我就得到了searchTBFutureVersion和buyFromTBFutureVersion這兩個仍然是立即瞬間返回,不會block caller thread的函數。

關于Scala中Promise和Future的更多信息:

https://docs.scala-lang.org/overviews/core/futures.html

鏡頭再切到異步函數調用者

現在有了searchTBFutureVersion和buyFromTBFutureVersion,我來試著重新實現一次:

def searchPriceThenBuyFutureVersion() = {
  val eventualResult = for {
    searchResult <- searchTBFutureVersion().map(msg => println(msg))
    buyResult <- buyFromTBFutureVersion().map(msg => println(msg))
  } yield (searchResult, buyResult)

  eventualResult.onComplete {
    case Failure(err) => println(err.getMessage)
    case _ =>
  }
}

這里用到了Scala的for comprehension,編譯后會變成map,flatMap等等monadic operator。

而map,flatMap等操作符正是Scala中Future拿來做組合用的。

這樣,用for把兩個返回Future的異步函數組織起來,形成一個新的Future,然后在新的Future complete時統一處理異常。

關于for的更多信息:

https://cuipengfei.me/blog/2014/08/30/options-for/

這次實現的代碼與上次的行為是一致的,沒什么兩樣。

不過我的業務代碼從雞零狗碎變成了平鋪直敘平易近人。

(這種效果在這里表現的并不是特別突出,不過很容易想象如果需要組合使用的異步函數更多一些的話,這種效果的好處就顯露出來了)

當然了,讓業務代碼易讀易懂主要還是要靠個人奮斗,而有了Promise和Future這種歷史進程的推力,則更有增益作用。

小結

最近在看Scala Reactive的一些內容

想起了很久之前寫過一篇叫做自己動手實現Promises/A+規范的博客,用JS實現了一個簡版的Promise:

https://cuipengfei.me/blog/2016/05/15/promise/

我在當時的一段演示代碼里面寫了兩句注釋:

Promise的作用在于

  1. 給異步算法的編寫者和使用者之間提供一種統一的交流手段
  2. 給異步算法的使用者提供一種組織代碼的手段,以便于將一層又一層嵌套的業務主流程變成一次一次的對then的調用

不過當時的博客里只講了實現Promise規范的事情,并沒有詳細解釋過這兩句話。

既然又遇到了這個話題,于是寫點Scala來把當時沒展開寫到的內容補充了一下。

上文的四個鏡頭展現了兩個角色的思考過程,通過這個過程其實也就解釋了上面兩句注釋的含義。

1.給異步算法的編寫者和使用者之間提供一種統一的交流手段

所謂統一的交流手段,其實就是異步函數的簽名問題。

由于需要處理的業務五花八門,異步函數接受的參數列表沒法統一,但是返回值是可以統一的。

一個異步函數,接受了外界給的參數,立即瞬間返回一個Js的Promise或者Scala的Future(或者是任何語言中類似概念的叫法)。

然后在異步任務執行完的時候把Promise resolve/reject掉(讓Future success或者failure),借此來讓調用方的代碼知道該到了它跑后續處理的時候了。

這樣我們就獲得了一個sensible default,無需在每次設計異步函數的時候都去商議該返回什么東西,該怎么獲得異步執行的結果。

2.給異步算法的使用者提供一種組織代碼的手段,以便于將一層又一層嵌套的業務主流程變成一次一次的對then的調用
所謂組織代碼的手段,就是關于異步函數調用者的那兩個鏡頭的內容了。

一開始CallBack套著CallBack,異步的味道很重,這體現出了代碼的組織方式在向代碼的技術實現低頭。或者說是代碼的技術實現干擾了我行文的風格

后來變成了看起來很像是消費同步函數結果的寫法。從而讓我慣常的文風得以保持。


文/ThoughtWorks 崔鵬飛 更多洞見

?著作權歸作者所有,轉載或內容合作請聯系作者
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發布,文章內容僅代表作者本人觀點,簡書系信息發布平臺,僅提供信息存儲服務。

推薦閱讀更多精彩內容