指令式Callback,函式式Promise:對node.js的一聲嘆息

海興發表於2013-07-28

原文:Callbacks are imperative, promises are functional: Node’s biggest missed opportunity

所謂promises,就是不會受不斷變化的情況影響。
-- Frank Underwood, ‘House of Cards’

人們常說Javascript是'函式式'程式語言。而這僅僅因為函式是它的一等值,可函數語言程式設計的很多其他特性,包括不可變資料,遞迴比迴圈更招人待見,代數型別系統,規避副作用等,它都不俱備。儘管把函式作為一等公民確實管用,也讓碼農可以根據自己的需要決定是否採用函式式的風格程式設計,但宣稱JS是函式式的往往會讓JS碼農們忽略函數語言程式設計的一個核心理念:用值程式設計。

'函數語言程式設計'是一個使用不當的詞,因為它會讓人們以為這是'用函式程式設計'的意思,把它跟用物件程式設計相對比。但如果物件導向程式設計是把一切都當作物件,那函數語言程式設計是把一切都當作值,不僅函式是值,而是一切都是值。這其中當然包括顯而易見的數值、字串、列表和其它資料,還包括我們這些OOP狗一般不會看成值的其它東西:IO操作和其它副作用,GUI事件流,null檢查,甚至是函式呼叫序列的概念。如果你曾聽說過'可程式設計的分號'1這個短語,你應該就能明白我在說什麼了。

1單子。 In functional programming, a monad is a structure that represents computations. A type with a monad structure defines what it means to chain operations of that type together. This allows the programmer to build pipelines that process data in steps, in which each action is decorated with additional processing rules provided by the monad. As such, monads have been described as "programmable semicolons"; a semicolon is the operator used to chain together individual statements in many imperative programming languages, thus the expression implies that extra code will be executed between the statements in the pipeline. Monads have been also explained with a physical metaphor as assembly lines, where a conveyor belt transports data between functional units that transform it one step at a time. http://en.wikipedia.org/wiki/Monad_(functional_programming)

最好的函數語言程式設計是宣告式的。在指令式程式設計中,我們編寫指令序列來告訴機器如何做我們想做的事情。在函數語言程式設計中,我們描述值之間的關係,告訴機器我們想計算什麼,然後由機器自己產生指令序列完成計算。

用過excel的人都做過函數語言程式設計:在其中通過建模把一個問題描繪成一個值圖(如何從一個值推匯出另一個)。當插入新值時,Excel負責找出它對圖會產生什麼影響,並幫你完成所有的更新,而無需你編寫指令序列指導它完成這項工作。

有了這個定義做依據,我要指出node.js一個最大的設計失誤,最起碼我是這樣認為的:在最初設計node.js時,在確定提供哪種方式的API式,它選擇了基於callback,而不是基於promise。

所有人都在用 [callbacks]。如果你釋出了一個返回promise的模組,沒人會注意到它。人們甚至不會去用那樣一個模組。

如果我要自己寫個小庫,用來跟Redis互動,並且這是它所做的最後一件事,我可以把傳給我的callback轉給Redis。而且當我們真地遇到callback hell之類的問題時,我會告訴你一個祕密:這裡還有協同hell和單子hell,並且對於你所建立的任何抽象工具,只要你用得足夠多,總會遇到某個hell。

在90%的情況下我們都有這種超級簡單的介面,所以當我們需要做某件事的時候,只要小小的縮排一下,就可以搞定了。而在遇到複雜的情況時,你可以像npm裡的其它827個模組一樣,裝上async。

--Mikeal Rogers, LXJS 2012

Node宣稱它的設計目標是讓碼農中的屌絲也能輕鬆寫出反應迅速的併發網路程式,但我認為這個美好的願望撞牆了。用Promise可以讓執行時確定控制流程,而不是讓碼農絞盡腦汁地明確寫出來,所以更容易構建出正確的、併發程度最高的程式。

編寫正確的併發程式歸根結底是要讓儘可能多的操作同步進行,但各操作的執行順序仍能正確無誤。儘管Javascript是單執行緒的,但由於非同步,我們仍然會遇到競態條件:所有涉及到I/O操作的操作在等待callback時都要把CPU時間讓給其他操作。多個併發操作都能訪問記憶體中的相同資料,對資料庫或DOM執行重疊的命令序列。藉助promise,我們可以像excel那樣用值之間的相互關係來描述問題,從而讓工具幫你找出最優的解決方案,而不是你親自去確定控制流。

我希望澄清大家對promise的誤解,它的作用不僅是給基於callback的非同步實現找一個語法更清晰的寫法。promise以一種全新的方式對問題建模;它要比語法層面的變化更深入,實際上是在語義層上改變了解決問題的方式。

我在兩年前曾寫過一篇文章,promises是非同步程式設計的單子。那篇文章的核心理念是單子是組建函式的工具,比如構建一個以上一個函式的輸出作為下一個函式輸入的管道。這是通過使用值之間的結構化關係來達成的,它的值和彼此之間的關係在這裡仍要發揮重要作用。

我仍將藉助Haskell的型別宣告來闡明問題。在Haskell中,宣告foo::bar表示“foo是型別為bar的值”。宣告foo :: Bar -> Qux 的意思是"foo是一個函式,以型別Bar的值為引數,返回一個型別為Qux的值"。如果輸入/輸出的確切型別無關緊要,可以用單個的小寫字母表示,foo :: a -> b。如果foo的引數不止一個,可以加上更多的箭頭,比如foo :: a -> b -> c表示foo有兩個型別分別為a和b的引數,返回型別為c的值。

我們來看一個Node函式,就以fs.readFile()為例吧。這個函式的引數是一個String型別的路徑名和一個callback函式,它沒有任何返回值。callback函式有兩個引數,Error(可能為null)和包含檔案內容的Buffer,也是沒有任何返回值。我們可以把readFile的型別表示為:

readFile :: String -> Callback -> ()

() 在 Haskell 中表示 null 型別。callback 本身是另一個函式,它的型別簽名是:

Callback :: Error -> Buffer -> ()

把這些都放到一起,則可以說readFile以一個String和一個帶著Buffer呼叫的函式為引數:

readFile :: String -> (Error -> Buffer -> ()) -> ()

好,現在請想象一下Node使用promises是什麼情況。對於readFile而言,就是簡單地接受一個String型別的值,並返回一個Buffer的promise值。

readFile :: String -> Promise Buffer

說得更概括一點,就是基於callback的函式接受一些輸入和一個callback,然後用它的輸出呼叫這個callback函式,而基於promise的函式接受輸入,返回輸出的promise值:

callback :: a -> (Error -> b -> ()) -> ()
promise :: a -> Promise b

基於callback的函式返回的那些null值就是基於callback程式設計之所以艱難的源頭:基於callback的函式什麼都不返回,所以難以把它們組裝到一起。沒有返回值的函式,執行它僅僅是因為它的副作用 -- 沒有返回值或副作用的函式就是個黑洞。所以用callback程式設計天生就是指令式的,是編寫以副作用為主的過程的執行順序,而不是像函式應用那樣把輸入對映到輸出。是手工編排控制流,而不是通過定義值之間的關係來解決問題。因此使編寫正確的併發程式變得艱難。

而基於promise的函式與之相反,你總能把函式的結果當作一個與時間無關的值。在呼叫基於callback的函式時,在你呼叫這個函式和它的callback被呼叫之間要經過一段時間,而在這段時間裡,程式中的任何地方都找不到表示結果的值。

fs.readFile('file1.txt',
  // 時光流逝...
  function(error, buffer) {
    // 現在,結果突然跌落在凡間
  }
);

從基於callback或事件的函式中得到結果基本上就意味著你“要在正確的時間正確的地點”出現。如果你是在事件已經被觸發之後才把事件監聽器繫結上去,或者把callback放錯了位置,那上帝也罩不了你,你只能看著結果從眼前溜走。這對於用Node寫HTTP伺服器的人來說就像瘟疫一樣。如果你搞錯了控制流,那你的程式就只能崩潰。

而Promises與之相反,它不關心時間或者順序。無論你在promise被resolve之前還是之後附上監聽器,都沒關係,你總能從中得到結果值。因此,返回promises的函式馬上就能給你一個表示結果的值,你可以把它當作一等資料來用,也可以把它傳給其它函式。不用等著callback,也不會錯過任何事件。只要你手中握有promise,你就能從中得到結果值。

var p1 = new Promise();
p1.then(console.log);
p1.resolve(42);

var p2 = new Promise();
p2.resolve(2013);
p2.then(console.log);

// prints:
// 42
// 2013

所以儘管then()這個方法的名字讓人覺得它跟某種順序化的操作有關,並且那確實是它所承擔的職責的副產品,但你真的可以把它當作unwrap來看待。promise是一個存放未知值的容器,而then的任務就是把這個值從promise中提取出來,把它交給另一個函式:從單子的角度來看就是bind函式。在上面的程式碼中,我們完全看不出來該值何時可用,或程式碼執行的順序是什麼,它只表達了某種依賴關係:要想在日誌中輸出某個值,那你必須先知道這個值是什麼。程式執行的順序是從這些依賴資訊中推匯出來的。兩者的區別其實相當微妙,但隨著我們討論的不斷深入,到文章末尾的lazy promises時,這個區別就會變得愈加明顯。

到目前為止,你看到的都是些無足輕重的東西;一些彼此之間幾乎沒什麼互動的小函式。為了讓你瞭解promises為什麼比callback更強大,我們來搞點更需要技巧性的把戲。假設我們要寫段程式碼,用fs.stat()取得一堆檔案的mtimes屬性。如果這是非同步的,我們只需要呼叫paths.map(fs.stat),但既然跟非同步函式對映難度較大,所以我們把async模組挖出來用一下。

var async = require('async'),
    fs    = require('fs');

var paths = ['file1.txt', 'file2.txt', 'file3.txt'];

async.map(paths, fs.stat, function(error, results) {
  // use the results
});

(哦,我知道fs的函式都有sync版本,但很多其它I/O操作都沒有這種待遇。所以,請淡定地坐下來看我把戲法變完。)

一切都很美好,但是,新需求來了,我們還需要得到file1的size。只要再stat就可以了:

var paths = ['file1.txt', 'file2.txt', 'file3.txt'];

async.map(paths, fs.stat, function(error, results) {
  // use the results
});

fs.stat(paths[0], function(error, stat) {
  // use stat.size
});

需求滿足了,但這個跟size有關的任務要等著前面整個列表中的檔案都處理完才會開始。如果前面那個檔案列表中的任何一項出錯了,很不幸,我們根本就不可能得到第一個檔案的size。這可就大大地壞了,所以,我們要試試別的辦法:把第一個檔案從檔案列表中拿出來單獨處理。

var paths = ['file1.txt', 'file2.txt', 'file3.txt'],
    file1 = paths.shift();

fs.stat(file1, function(error, stat) {
  // use stat.size
  async.map(paths, fs.stat, function(error, results) {
    results.unshift(stat);
    // use the results
  });
});

這樣也行,但現在我們已經不能把這個程式稱為並行化的了:它要用更長的時間,因為在處理完第一個檔案之前,檔案列表的請求處理得一直等著。之前它們還都是併發執行的。另外我們還不得不處理下陣列,以便可以把第一個檔案提出來做特別的處理。

Okay,最後的成功一擊。我們知道需要得到所有檔案的stats,每次命中一個檔案,如果成功,則在第一個檔案上做些工作,然後如果整個檔案列表都成功了,則要在那個列表上做些工作。帶著對問題中這些依賴關係的認識,用async把它表示出來。

var paths = ['file1.txt', 'file2.txt', 'file3.txt'],
    file1 = paths.shift();

async.parallel([
  function(callback) {
    fs.stat(file1, function(error, stat) {
      // use stat.size
      callback(error, stat);
    });
  },
  function(callback) {
    async.map(paths, fs.stat, callback);
  }
], function(error, results) {
  var stats = [results[0]].concat(results[1]);
  // use the stats
});

這就對了:每次一個檔案,所有工作都是並行的,第一個檔案的結果跟其他的沒關係,而相關任務可以儘早執行。Mission accomplished!

好吧,實際上並不盡然。這個太醜了,並且當問題變得更加複雜後,這個顯然不易於擴充套件。為了正確解決問題,要考慮很多東西,而且這個設計意圖也不顯眼,後期維護時很可能會把它破壞掉,後續任務跟如何完成所需工作的策略混雜在一起,而且我們不得不動用一些比較複雜的陣列分割操作來應對這個特殊狀況。啊哦!

這些問題的根源都在於我們用控制流作為解決辦法的主體,如果用資料間的依賴關係,就不會這樣了。我們的思路不是“要執行這個任務,我需要這個資料”,沒有把找出最優路徑的工作交給執行時,而是明確地向執行時指出哪些應該並行,哪些應該順行,所以我們得到了一個特別脆弱的解決方案。

那promises怎麼幫你脫離困境?嗯,首先要有能返回promises的檔案系統函式,用callback做引數的那套東西不行。但在這裡我們不要手工打造一套檔案系統函式,通過超程式設計作個能轉換一切函式的東西就行。比如,它應該接受型別為:

String -> (Error -> Stat -> ()) -> ()

的函式,並返回:

String -> Promise Stat

下面就是這樣一個函式:

// promisify :: (a -> (Error -> b -> ()) -> ()) -> (a -> Promise b)
var promisify = function(fn, receiver) {
  return function() {
    var slice   = Array.prototype.slice,
        args    = slice.call(arguments, 0, fn.length - 1),
        promise = new Promise();

    args.push(function() {
      var results = slice.call(arguments),
          error   = results.shift();

      if (error) promise.reject(error);
      else promise.resolve.apply(promise, results);
    });

    fn.apply(receiver, args);
    return promise;
  };
};

(這不是特別通用,但對我們來說夠了.)

現在我們可以對問題重新建模。我們需要做的全部工作基本就是將一個路徑列表對映到一個stats的promises列表上:

var fs_stat = promisify(fs.stat);

var paths = ['file1.txt', 'file2.txt', 'file3.txt'];

// [String] -> [Promise Stat]
var statsPromises = paths.map(fs_stat);

這已經是付利息了:在用 async.map()時,在整個列表處理完之前你拿不到任何資料,而用上promises的列表之後,你可以徑直挑出第一個檔案的stat做些處理:

statsPromises[0].then(function(stat) { /* use stat.size */ });

所以在用上promise值後,我們已經解決了大部分問題:所有檔案的stat都是併發進行的,並且訪問所有檔案的stat都和其他的無關,可以從陣列中直接挑我們想要的任何一個,不止是第一個了。在前面那個方案中,我們必須在程式碼裡明確寫明要處理第一個檔案,想換檔案時改起來不是那麼容易,但用promises列表就容易多了。

謎底還沒有完全揭曉,在得到所有的stat結果之後,我們該做什麼?在之前的程式中,我們最終得到的是一個Stat物件的列表,而現在我們得到的是一個Promise Stat 物件的列表。我們想等著所有這些promises都被兌現(resolve),然後生出一個包含所有stats的列表。換句話說,我們想把一個promises列表變成一個列表的promise。

閒言少敘,我們現在就給這個列表加上promise方法,那這個包含promises的列表就會變成一個promise,當它所包含的所有元素都兌現後,它也就兌現了。

// list :: [Promise a] -> Promise [a]
var list = function(promises) {
  var listPromise = new Promise();
  for (var k in listPromise) promises[k] = listPromise[k];

  var results = [], done = 0;

  promises.forEach(function(promise, i) {
    promise.then(function(result) {
      results[i] = result;
      done += 1;
      if (done === promises.length) promises.resolve(results);
    }, function(error) {
      promises.reject(error);
    });
  });

  if (promises.length === 0) promises.resolve(results);
  return promises;
};

(這個函式跟 jQuery.when() 類似, 以一個promises列表為引數,返回一個新的promise,當引數中的所有promises都兌現後,這個新的promise就兌現了.)

只需把陣列打包在promise裡,我們就可以等著所有結果出來了:

list(statsPromises).then(function(stats) { /* use the stats */ });

我們最終的解決方案就被削減成了下面這樣:

var fs_stat = promisify(fs.stat);

var paths = ['file1.txt', 'file2.txt', 'file3.txt'],
    statsPromises = list(paths.map(fs_stat));

statsPromises[0].then(function(stat) {
  // use stat.size
});

statsPromises.then(function(stats) {
  // use the stats
});

該方案的這種表示方式看起來要清楚得多了。藉助一點通用的粘合劑(我們的promise輔助函式),以及已有的陣列方法,我們就能用正確、有效、修改起來非常容易的辦法解決這個問題。不需要async模組特製的集合方法,只是讓promises和陣列兩者的思想各自保持獨立,然後以非常強大的方式把它們整合到一起。

特別要注意這個程式是如何避免了跟並行或順序相關的字眼出現。它只是說我們想做什麼,然後說明任務之間的依賴關係是什麼樣的,其他的事情就交給promise類庫去做了。

實際上,async集合模組中的很多東西都可以用promises列表上的操作輕鬆代替。前面已經看到map的例子了:

async.map(inputs, fn, function(error, results) {});

相當於:

list(inputs.map(promisify(fn))).then(
    function(results) {},
    function(error) {}
);

async.each()async.map() 實質上是一樣的,只不過each()只是要執行效果,不關心返回值。完全可以用map()代替。

async.mapSeries() (如前所述,包括 async.eachSeries()) 相當於在promises列表上呼叫 reduce()。也就是說,你拿到輸入列表,並用reduce產生一個promise,每個操作都依賴於之前的操作是否成功。我們來看一個例子:基於fs.rmdir()實現 rm -rf 。程式碼如下:

var dirs = ['a/b/c', 'a/b', 'a'];
async.mapSeries(dirs, fs.rmdir, function(error) {});

相當於:

var dirs     = ['a/b/c', 'a/b', 'a'],
    fs_rmdir = promisify(fs.rmdir);

var rm_rf = dirs.reduce(function(promise, path) {
  return promise.then(function() { return fs_rmdir(path) });
}, unit());

rm_rf.then(
    function() {},
    function(error) {}
);

其中的 unit()只是為了產生一個已解決的promise已啟動操作鏈(如果你知道monads,這就是給promises的return 函式):

// unit :: a -> Promise a
var unit = function(a) {
  var promise = new Promise();
  promise.resolve(a);
  return promise;
};

reduce()只是取出路徑列表中的每對目錄,用promise.then()根據上一步操作是否成功來執行路徑刪除操作。這樣可以處理非空目錄:如果上一個promise由於某種錯誤被rejecte了,操作鏈就會終止。用值之間的依賴關係限定執行順序是函式式語言藉助monads處理副作用的核心思想。

最後這個例子的程式碼比async版本繁瑣得多,但不要被它騙了。關鍵是領會精神,要將彼此不相干的promise值和list操作結合起來組裝程式,而不是依賴定製的流程控制庫。如您所見,前一種方式寫出來的程式更容易理解。

準確地講,它之所以容易理解,是因為我們把一部分思考的過程交給機器了。如果用async模組,我們的思考過程是這樣的:

  • A.程式中這些任務間的依賴關係是這樣的
  • B.因此各操作的順序必須是這樣
  • C.然後我們把B所表達的意思寫成程式碼吧

用promises依賴圖可以跳過步驟B。程式碼只要表達任務之間的依賴關係,然後讓電腦去設定控制流。換種說法,callback用顯式的控制流把很多細小的值粘到一起,而promises用顯式的值間關係把很多細小的控制流粘到一起。Callback是指令式的,promises是函式式的。

如果最終沒有一個完整的promises應用,並且是體現函數語言程式設計核心思想 laziness的應用,我們對這個話題的討論就不算完整。Haskell是一門懶語言,也就是說它不會把程式當成從頭執行到尾的指令碼,而是從定義程式輸出的表示式開始,向stdio、資料庫中寫了什麼等等,以此向後推導。它尋找最終表示式的輸入所依賴的那些表示式,按圖反向探索,直到計算出程式產生輸出所需的一切。只有程式為完成任務而需要計算的東西才會計算。

解決電腦科學問題的最佳解決方案通常都是找到可以對其建模的準確資料結構。Javascript有一個與之非常相似的問題:模組載入。我們只想載入程式真正需要的模組,而且想盡可能高效地完成這個任務。

在 CommonJS 和 AMD出現之前,我們確實就已經有依賴的概念了,指令碼載入庫有一大把。大多數的工作方式都跟前面的例子差不多,明確告訴指令碼載入器哪些檔案可以並行下載,哪些必須按順序來。基本上都必須寫出下載策略,要想做到正確高效,那是相當困難,跟簡單描述指令碼間的依賴關係,讓載入器自己決定順序比起來簡直太坑人了。

接下來開始介紹LazyPromise的概念。這是一個promise物件,其中會包含一個可能做非同步工作的函式。這個函式只在呼叫promise的then()時才會被呼叫一次:即只在需要它的結果時才開始計算它。這是通過重寫then()實現的,如果工作還沒開始,就啟動它。

var Promise = require('rsvp').Promise,
    util    = require('util');

var LazyPromise = function(factory) {
  this._factory = factory;
  this._started = false;
};
util.inherits(LazyPromise, Promise);

LazyPromise.prototype.then = function() {
  if (!this._started) {
    this._started = true;
    var self = this;

    this._factory(function(error, result) {
      if (error) self.reject(error);
      else self.resolve(result);
    });
  }
  return Promise.prototype.then.apply(this, arguments);
};

比如下面這個程式,它什麼也不做:因為我們根本沒要過promise的結果,所以不用幹活:

var delayed = new LazyPromise(function(callback) {
  console.log('Started');
  setTimeout(function() {
    console.log('Done');
    callback(null, 42);
  }, 1000);
});

但如果加上下面這行,程式就會輸出Started,過了一秒後,在輸出Done和42:

delayed.then(console.log);

但既然這個工作只做一次,呼叫then()會多次輸出結構,但並不會每次都執行任務:

delayed.then(console.log);
delayed.then(console.log);
delayed.then(console.log);

// prints:
// Started
// -- 1 second delay --
// Done
// 42
// 42
// 42

用這個非常簡單的通用抽象,我們可以隨時搭建一個優化模組系統。假定我們要像下面這樣建立一堆模組:每個模組都有一個名字,一個依賴模組列表,以及一個傳入依賴項,返回模組API的工廠函式。跟AMD的工作方式非常像。

var A = new Module('A', [], function() {
  return {
    logBase: function(x, y) {
      return Math.log(x) / Math.log(y);
    }
  };
});

var B = new Module('B', [A], function(a) {
  return {
    doMath: function(x, y) {
      return 'B result is: ' + a.logBase(x, y);
    }
  };
});

var C = new Module('C', [A], function(a) {
  return {
    doMath: function(x, y) {
      return 'C result is: ' + a.logBase(y, x);
    }
  };
});

var D = new Module('D', [B, C], function(b, c) {
  return {
    run: function(x, y) {
      console.log(b.doMath(x, y));
      console.log(c.doMath(x, y));
    }
  };
});

這裡出了一個鑽石的形狀:D依賴於B和C,而它們每個都依賴於A。也就是說我們可以載入A,然後並行載入B和C,兩個都到位後載入D。但是,我們希望工具能自己找出這個順序,而不是由我們自己寫出來。

這很容易實現,我們把模組當作LazyPromise的子型別來建模。它的工廠只要用我們前面那個list promise輔助函式得到依賴項的值,然後再經過一段模擬的載入時間後用那些依賴項構建模組。

var DELAY = 1000;

var Module = function(name, deps, factory) {
  this._factory = function(callback) {
    list(deps).then(function(apis) {
      console.log('-- module LOAD: ' + name);
      setTimeout(function() {
        console.log('-- module done: ' + name);
        var api = factory.apply(this, apis);
        callback(null, api);
      }, DELAY);
    });
  };
};
util.inherits(Module, LazyPromise);

因為 Module 是 LazyPromise, 只是像上面那樣定義模組不會載入。我們只在需要用這些模組的時候載入它們:

D.then(function(d) { d.run(1000, 2) });

// prints:
// 
// -- module LOAD: A
// -- module done: A
// -- module LOAD: B
// -- module LOAD: C
// -- module done: B
// -- module done: C
// -- module LOAD: D
// -- module done: D
// B result is: 9.965784284662087
// C result is: 0.10034333188799373

如上所示,最先載入的是A,完成後同時開始下載B和C,在兩個都完成後載入D,跟我們想的一樣。如果呼叫C.then(function() {}),那就只會載入A和C;不在依賴關係圖中的模組不會載入。

所以我們幾乎沒怎麼寫程式碼就建立了一個正確的優化模組載入器,只要用lazy promises的圖就行了。我們用函數語言程式設計中值間關係的方式代替了顯式宣告控制流的方式,比我們自己寫控制流容易得多。對於任何一個非迴圈得依賴關係圖,這個庫都能用來替你優化控制流。

這就是promises真正強大的地方。它不僅能在語法層面上規避縮排金字塔,還能讓你在更高層次上對問題建模,而把底層工作交給工具完成。真的,那應該是我們所有碼農對我們的軟體提出的要求。如果Node真的想讓併發程式設計更容易,他們應該再好好看看promises。

相關文章