[Javascript]非同步的救星Promise

Reading time ~12 minutes

Javascript Asynchronous Code

不論是撰寫前端 Javascript還是後端 NodeJS,異步操作都是無法避免的,像是向後端請求資料、處理瀏覽器事件、計時器等等都是常見的異步操作。 異步操作和我們熟悉的同步不同,當異步操作執行時,瀏覽器/NodeJS 不會等待當前操作的結果回傳後才執行下一行程式碼,他會繼續不回頭的執行下去,也就是我們常聽到的無阻塞特性 (non-blocking)。等遠端資料回傳後,瀏覽器或 NodeJS 才會通知 Javascript 引擎去呼叫使用者當初指定的 callback 做處理。

以下面程式碼為例。先定義一個函式 getRemoteData,其目的是用 Ajax 技術發出GET請求。我們運用 Javascript 中 function 可以當成變數使用的特性,將成功和失敗要執行的函式當成參數傳入 getRemoteData,並在裡面定義兩個函式的呼叫時機。這種撰寫方式稱為 callback pattern,被當成參數傳入的函式就叫callback,之後這個異步函式會在本文後面的例子不斷被使用到。

const getRemoteData = function (url, success, error) {
   let req = new XMLHttpRequest();
   req.responseType = 'json';
   req.open('GET', url);
   req.onload = function() {
      if(req.status == 200) {
         let data = JSON.parse(req.responseText);
         success(data);
      }
      else {
         req.onerror();
      } 
    }
   req.onerror = function () {
      if(error) {
         error(new Error(req.statusText));
      }
};
   req.send();
};

我們知道 Ajax 請求為異步操作,所以這裡程式不會等待 getRemoteData 完成後才繼續執行,而是會馬上接著跑 showStudents,此時 showStudents 的參數 students 仍為 null,故造成程式錯誤。

var students = null;
getRemoteData('/students', function(studentObjs) {
	 students = studentObjs; // 回傳資料
  },
  function (errorObj) {
     console.log(errorObj.message);
} );
showStudents(students); // null

要解決此問題,必須將 showStudents 放入 callback 函式,讓Ajax在資料回傳後執行 success callback 時,才執行 showStudents。

getRemoteData('/students', function(studentObjs) {
	 showStudents(studentObjs);
  },
  function (errorObj) {
     console.log(errorObj.message);
} );

這種寫法會讓熟悉多執行緒的 C#、JAVA 使用者一開始很不習慣,其中也有許多優點與缺點,本篇專注在討論缺點:callback hell,和如何利用 Promise 來解決 callback hell 的問題。

什麼是 callback hell? 如何做初步重構?

如果有異步動作需要有順序性的呼叫,在程式撰寫上常常需要一層包一層,如下面的例子,click 事件的 callback 包了 getRemoteData 這個異步操作,getRemoteData 的 callback 又註冊了 mouseover 事件,mouseover 的 callback 又包了 getRemoteData,這種寫法會導致程式的可讀性變差,我們稱這叫做callback hell。

var _selector = document.querySelector;
_selector('#search-button').addEventListener('click',
   function (event) {
    event.preventDefault();
    let ssn = _selector('#student-ssn').value;
    getRemoteData(`/students/${ssn}`, function (info) {
           _selector('#student-info').innerHTML = info;
           _selector('#student-info').addEventListener('mouseover',
              function() {
                  getRemoteData(`/students/${info.ssn}/grades`,
                      function (grades) {
                          // ... process list of grades for this student
		}); });
       })
       });
} });

為了讓程式更可讀,我們利用 javascript 的 function 是 first class citizen 的特性,將這些 callback 命名成一個個易解讀的變數並分離出來,這種做法叫做 continuation-passing style (CPS)。像上面的例子,可以被重構成如下。

var _selector = document.querySelector;
_selector('#search-button').addEventListener('click', getStudentSNN);

var getStudentSNN = function (event) {
    event.preventDefault();
    let ssn = _selector('#student-ssn').value;
    getRemoteData(`/students/${ssn}`, showInfoAndFindStudentGrade(info)).fail(showError);
} };

var showError = function() {
            console.log('Error occurred!');
};

var showInfoAndFindStudentGrade = function (info) {
           _selector('#student-info').innerHTML = info;
           _selector('#student-info').addEventListener('mouseover',
              function() {
                  getRemoteData(`/students/${info.ssn}/grades`,
                      function (grades) {
                          // ... process list of grades for this student
		}); });
};

在這裡要順道提醒,異步操作和同步操作混用時要多多注意 closure 的特性,他會導致一些超出預期的結果,以下面 loop 內的異步呼叫為例子。

var students = [{ ssn: '111111', country: 'US' },
                { ssn: '111112', country: 'US' },
                { ssn: '111113', country: 'US' }];
for (let i = 0; i < students.length; i++) {
   let student = students[i];
   if (student.country === 'US') {
      getRemoteData(`/students/${student.ssn}/grades`,
         function (grades) {
            showStudents(student.ssn, average(grades));
         },
         function (error) {
           console.log(error.message);
         });
    } 
}

本程式的結果不是大家預期的:

111111 --> 80
111112 --> 78
111113 --> 90

而是

111113 --> 80
111113 --> 78
111113 --> 90

我們會發現,雖然後面的平均成績對了,但其學號都是 students 陣列中最後一位學生的學號。 這個原因是因為 showStudents 並不是馬上執行,而是等到 Ajax 請求的 grades 回傳後才被呼叫,此時的 student 變數已經不是當時請求送出時的 student了,而是被最後一次給值的 student,也就是學號為 111113 的 student。所以我們必須要用 curry 寫法來避免這種問題產生,如下。

var students = [{ ssn: '111111', country: 'US' },
                { ssn: '111112', country: 'US' },
                { ssn: '111113', country: 'US' }];
for (let i = 0; i < students.length; i++) {
   let student = students[i];
   let showGrade = function(student){
        return function (grades) {
            showStudents(student.ssn, average(grades));
        };
    }();
   if (student.country === 'US') {
      getRemoteData(`/students/${student.ssn}/grades`,
         showGrade(grades),
         function (error) {
           console.log(error.message);
      });
   }
}

利用 closure 的特性將 student 變數鎖在 showGrade 的 closure 之中,這樣呼叫 callback 時就會使用到當時指定的 student 變數,並達到我們原本預期的結果。

111111 --> 80
111112 --> 78
111113 --> 90

雖然 CPS 有改善程式的可讀性,但仍遠不夠應付更加複雜的應用程式操作,這時就來到本篇的重點,Promise!

初解Promise

我們常把 Promise 用來包裝異步函式,因為它能做到以下幾點:

  1. 用 pipeline 的方式解決 callback hell 問題
  2. 好的 Error handling 方式

Promise 擁有三種狀態: Pending, Fulfilled, Rejected,Pending 代表此 Promise 內的程式還未完成;Fulfilled 代表程式已執行成功,到達此狀態時 Promise 會呼叫 resolve 函式;Rejected 代表程式執行失敗,到達此狀態時 Promise 會呼叫 reject 函式。

FP

我們可以親自建構 Promise 要何時到達 Pending, Fulfilled, Rejected,如下面是我們用 ES6 Promise 做的展示,ES6 Promise 採用的是 Promises/A+ 的標準。

我們設定 Promise 中異步請求的結果如果回傳成功,就到達 Fulfilled 狀態並執行 resolve(result);請求結果如果回傳失敗,到達 Rejected 狀態並執行 reject 函式。

var fetchData = new Promise(function (resolve, reject) {
    // fetch data async or run long-running computation
    $.get(url, function(result){
        if (<success>) {
            resolve(result); // successful calllback
	    } else {
            reject(new Error('Error performing this operation!'));
        }
    });
});

根據上面的範例,resolve 和 reject 函式都是”非同步操作完成”後才執行,所以在執行到 resolve 或 reject 之前,此 Promise 會一直保持在 Pending 狀態等待遠端的回傳結果,由此達成”異步操作完成後才會繼續執行”的效果。

之後,我們便可以使用 Promise 的 then 指定 resolve 函式,catch 指定 reject 函式,如下:

fetchData.then(function(v){ console.log('Successful-' + v); })
         .catch(function(message){ console.log(message); });

resolve 和 reject 的回傳值會被包裝成新的Promise回傳,之後我們就能利用新的 Promise 繼續做串接,達到 pipeline coding 的效果,讓程式可讀性獲得提升。

fetchData.then(...).then(...).then(...)  // pipeline

Promise串接的圖解可以參考如下,每個操作都是接續同步執行的!

FP

Promise 在 Function Programming 裡類似於 Monad,關於 Monad 的圖解說明可以看這裡。簡單來說,Monad是一個值或是函式的 Wrapper,這個 Wrapper 擁有兩點定義:

  1. unit :: a -> Monad a (將 a 值用 Monad 包裝)
  2. flatMap :: Monad a -> (a -> b) -> Monad b (取出 Monad 的 a 做計算後,生成 b,將結果 b 重新包裝成新的 Monad)

在我們撰寫的 Promise 範例中,function (resolve, reject){ … } 相等於上面定義的 a 值。我們將這個函式用 Promise 包裝,之後用 then 指定 resolve 函式;用 catch 指定 reject 函式。Promise 會在執行 resolve 或 reject 時,將函式執行完的回傳結果包裝成新的 Promise。這樣的流程符合上面定義的 Monad 特性,所以我們可以說 Promise 其實是 Monad。

使用Promise

這節將幫助大家了解如何實際使用Promise。請看下面例子,這段程式首先呼叫後端全部學生的資料,將回傳的學生們經過排名後,用 for 迴圈檢查哪些學生的住所在 US,呼叫每一位在 US 的學生成績後計算平均,最後顯示在頁面。

 
getRemoteData('/students',
    function (students) {
      students.sort(function(a, b){
            if(a.ssn < b.ssn) return -1;
            if(a.ssn > b.ssn) return 1;
            return 0;
      });
      for (let i = 0; i < students.length; i++) {
         let student = students[i];
         if (student.address.country === 'US') {
            getRemoteData(`/students/${student.ssn}/grades`,
              function (grades) {
                  showStudents(student, average(grades));
              },
              function (error) {
                  console.log(error.message);
              }); 
         }
      } 
    },
    function (error) {
        console.log(error.message);
    });

如果單純的使用 callback 撰寫,程式碼的可讀性會變得很差,重用性也不高,接下來我們就用 Promise 改寫。

(1) 將異步操作包成Promise (known as promisifying a function)

首先,讓我們將異步操作 getRemoteData 改寫成回傳 Promise 資料結構的 getPromiseWithRemoteData!

const getPromiseWithRemoteData = function (url) {
    return new Promise(function(resolve, reject) { // return promise
        let req = new XMLHttpRequest();
        req.responseType = 'json';
        req.open('GET', url);
        req.onload = function() {
            if(req.status == 200) {
                let data = JSON.parse(req.responseText);
                resolve(data); // being fulfilled
            }
            else {
                reject(new Error(req.statusText));
            }
        };
        req.onerror = function () {
        if(reject) {
            reject(new Error('IO Error'));
        } };
       req.send();
   });
};

(2) 將連續動作都用 then 的呼叫方式改寫,用 catch 方式補充例外處理

有了 getPromiseWithRemoteData 後,就能將上面的程式改寫成下面,程式碼一下子少了很多。

getPromiseWithRemoteData('/students')
    .then(R.sortBy(R.prop('ssn')))
    .then(R.filter(s => s.address.country == 'US'))
    .then((students) => { 
        return students.map((student) => {
            return getPromiseWithRemoteData(`/students/${student.ssn}/grades`)
                    .then((grades) => showStudents(student, average(grades)))
                    .catch((error) => console.log(error.message))
            })
        })
    .catch((error) => console.log(error.message))

補充 Promise.all()

根據上面的 students.map 程式碼,每個學生會透過 getPromiseWithRemoteData 取得所有分數後將平均顯示到頁面上,再回傳 Fulfilled 狀態的 Promise。但向遠端請求分數時,每個學生的分數回傳時間不一樣,會導致學生們的分數整合困難。舉例,如果我們希望得到住在 US 的學生的平均分數的再平均,根據之前的思路我們可能會撰寫如下。

getPromiseWithRemoteData('/students')
    .then(R.sortBy(R.prop('ssn')))
    .then(R.filter(s => s.address.country == 'US'))
    .then((students) => { 
        return students.map((student) => {
            return getPromiseWithRemoteData(`/students/${student.ssn}/grades`)
                    .then((grades) => average(grades))
                    .catch((error) => console.log(error.message))
            })
        })
    .then((averageGrades) => console.log(average(averageGrades)))  // wrong answer
    .catch((error) => console.log(error.message))

但這種需求用上面的寫法就會出錯,因為學生們的分數不會如我們想的那樣同時到達,而是會陸陸續續回傳,導致無法算出正確的平均,此時就需要使用 Promise.all()。 Promise.all()會將一個 Promise 陣列包裝成新的 Promise 物件,此 Promise 物件會等待陣列中的每個 Promise 都變成 Fulfilled 後,才執行 resolve 函式到達 Fulfilled 狀態,只要陣列中有一個 Promise 到 Rejected,Promise.all() 就會終止並到達 Rejected 狀態。我們可以將上面的程式改寫如下:

getPromiseWithRemoteData('/students')
    .then(R.sortBy(R.prop('ssn')))
    .then(R.filter(s => s.address.country == 'US'))
    .then((students) => { 
            return Promise.all(students)
                .then((student) => getPromiseWithRemoteData(`/students/${student.ssn}/grades`))
                .then((grades) => average(grades))
                .catch((error) => console.log(error.message))
            })
        })
    .then((averageGrades) => console.log(average(averageGrades)))    
    .catch((error) => console.log(error.message))

就算陣列元素本身不是 Promise,Promise.all() 也會先將陣列元素都轉換成 Promise 元素後,再用 Promise 包裝陣列。有了Promise.all(),我們也能做到”等待多個回傳資料”的功能。

後話

熟悉 ES6 Promise 後,可以搭配學習 Generator 和 Async/Await,觀察整個異步撰寫方法的演進。


範例參考與圖片來源

[DevOps]鳳凰計畫

鳳凰計畫:一個IT計畫的傳奇故事,用這本小說作為 DevOps 的入門實在適合不過了! Continue reading