使用 Promise.all() 解決多次的 API Callback

January 29, 2017

在 JavaScript 中蠻容易遇見像這樣 callback hell 的程式碼:

像這樣巢狀非同步的程式碼,對人腦來說真的很不友善,再後來學會了 Promise、Async / Await 來避免這樣寫出這樣過度巢狀化的程式碼。

Google Maps Service

這陣子正在寫一個 telegram bot,用到了 Google Maps 的服務,主要是為了用來計算兩點之間的距離,要使用這個服務,首先你需要到 Google API Console 去建立一個新專案:

Google Map API 1

接著點擊資料庫去選擇你需要的 API 服務:

Google Map API 2

完成後再點擊憑證的地方去複製你的 API 金鑰就可以使用 Google 提供的 API 服務了。

API 的 Callback

我們在 Node.js 中,使用 fs 來讀取檔案時,會看到如下的程式碼:

fs.readFile(file, [options], callback);

在 Node.js 的核心模組,通常在 callback 的部份我們都會優先處理錯誤:

fs.readFile('someFile.md', (err, data) => {
  if (err) throw err;
  console.log(data);
});

這次開發 telegram bot 因為使用了 Google Maps 服務因此選了一個 google-maps-service-js 的 library 來處理 Google Maps 相關的計算,因為要計算兩點的距離,根據官方的文件,我們必須使用 distanceMatrix 這個方法來計算:

client.distanceMatrix({origins, destinations}, (err, response) => {
  if (err) {
    console.log(err);
    return;
  };

  const { rows } = response.json;
  const { distance: { text } } = rows[0]['elements'][0];

  console.log(text);
});

這個部份我們可以得到兩點計算後的距離,然後把 response.json 解構賦值,我們可以得到兩點之間的距離到底是多少(text 是一個 String,ex: 5.0 km)。

這樣看起來好像順利可以取得距離了,但是像在 JavaScript 會有 scope 的問題,有些值你無法把他 return 出來時,就會用到 callback 了,上方的程式碼在 callback 的地方是一個 Anonymous Function,所以就算 return 了值,也無法取得,如果透過 callback 方式:

const client = require('@google/maps').createClient({
  key: 'your API key here'
});
const origins = { lat: 0123456, lng: 9987654 }; // `origins` and `position` just a example
const position = { lat: 123456, lng: 7890123 };

function callbackDistance(origins, position, callback) {
  client.distanceMatrix({origins, destinations}, (err, response) => {
    if (err) return callback(err);

    const { rows } = response.json;
    const { distance: { text } } = rows[0]['elements'][0];
    callback(null, text);
  });
}

callbackDistance(googleMapsClient, origins, position, (err, data) => {
  if (err) throw err;

  console.log(data);
});

這裡的 callback 遵循了 Node.js 的 callback function style 優先處理錯誤,但是後來我發現透過 callback 的方式來取得值的方式無法滿足我的需求,今天假設我對這個 distanceMatrix 做了多次的 request,然後得到多個值儲存成陣列再做計算的話呢?callback 好像讓事情變得更難處理呢!

嘗試使用 Promise

因為以上的問題,我後來嘗試使用了 Promise,這也讓程式看起來像是同步的感覺。

const fake = {
  origins: { lat: 0123456, lng: 9987654 },
  destinations: { lat: 123456, lng: 7890123 }
};

function promiseDistance(point) {
  return new Promise((resolve, reject) => {
    const { origins, destinations } = point;
    client.distanceMatrix({origins, destinations}, (err, response) => {
      if (err) return reject(err);

      const { rows } = response.json;
      const { distance: { text } } = rows[0]['elements'][0];
      resolve(text);
    });
  });
}

我們可以透過這樣的方式來取得距離:

promiseDistance(somePoint).then(res => console.log(res));

乾淨!相對於 callback 讓人感覺舒服多了,接下來我們來處理多筆資料:

const stores = [
  {
    "id": "4fe0da0f-fd90-4c17-83ae-2806d6daff28",
    "latitude": "22.66054200",
    "longitude": "120.51543900"
  },
  {
    "id": "7b2f0e7a-e138-47f7-b10f-9a04d4bd343a",
    "latitude": "22.00505500",
    "longitude": "120.74336830"
  },
  {
    "id": "81348d86-844b-47a2-b349-d492e56eee34",
    "latitude": "22.67599980",
    "longitude": "120.50244030"
  }
];

let point = {
  origins: { lat: 121.512386, lng: 25.051269 };
};

// newStores is => [...Promise { <pending> }]
const newStores = stores.map(store => {
  point.destinations = { lat: store['latitude'], lng: store['longitude'] };

  return promiseDistance(point)
    .then(distance => distance);
});

這時候我們有一個 stores 含有多筆經緯度的資料,而我們有個原點,把原點跟每個經緯度來做計算得到距離,在 stores.map 的部份可以看到我每次回傳的都是一個 Promise,promiseDistance(point) 把我們的點傳入進去,接著會得到 API 會傳回來的結果,這個地方是非同步,可以透過 console.log(newStores) 知道這個回傳陣列內的值都是一個 Promise {<pending>} 的狀態。

使用 Promise.all() 處理 […Promise { <pending> }]

在這個部份我本來也不知道該如何解決的,於是在 MDN 翻了 Promise 相關的文件找到了這個方法法 — Promise.all()

Promise.all(iterable) 傳入的值要是一個 iterable object,像是陣列就是一個 iterable object(更多 iterable 請參考 — MDN iterable),當你傳入的 iterable 內的 Promise 都為 resolved ,Promise.all 的 狀態就會轉為 resolved,反之假設其中一個為 rejected 的狀態,Promise.all 則會轉為 rejected 的狀態。

剛剛 nextStores 都是狀態在 pending 的 Promise,接著我們把它放入 Promsie.all(),來等待這些 pending 的狀態轉為 resolved or rejected,這樣我們就可以對 API 做多次的呼叫,Promise.all 會等待所有個 Promise 都完成狀態轉化後才會在繼續執行 .then.catch,感覺就像在寫同步的程式碼,感覺超棒的!

// newStores: https://gist.github.com/neighborhood999/d030dd4d3422681fc3266753a5e143fc

Promise.all(newStores)
  .then(results => {
    const nextStores = results.map(store => {
      // do something
    });

    return nextStores;
  .then(nextStores => {
    // do something
  })
  .catch(err => console.log(err);
});