標籤:des blog http java 使用 os
Promises And Design Patterns
寫得好長好長好長長~
解決 Javascript 非同步事件的傳統方式是回呼函數;調用一個方法,然後給它一個函數引用,當這個方法完結的時候執行這個函數引用。
$.get(‘api/gizmo/42‘, function(gizmo) { console.log(gizmo); // or whatever});
看起來很不錯對不對,不過,也有缺點的;首先,合并或者連結多個非同步過程超複雜;要麼就是大量的模板代碼,要麼就是嗯哼你懂的回調地獄(一層套一層的回調):
$.get(‘api/gizmo/42‘, function(gizmo) { $.get(‘api/foobars/‘ + gizmo, function(foobar) { $.get(‘api/barbaz/‘ + foobar, function(bazbar) { doSomethingWith(gizmo, foobar, bazbar); }, errorCallback); }, errorCallback);}, errorCallback);
明白了吧。其實在 Javascript 中,有另外一種非同步處理模式:更屌,在 Javascript 裡面經常被叫做 Promises, CommonJS 標準委員會於是發布了一個規範,就把這個 API 叫做 Promises 了。
Promise 背後的概念非常簡單,有兩部分:
- Deferreds,定義工作單元,
- Promises,從 Deferreds 返回的資料。
基本上,你會用 Deferred 作為通訊對象,用來定義工作單元的開始,處理和結束三部分。
Promise 是 Deferred 響應資料的輸出;它有狀態 (等待,執行和拒絕),以及控制代碼,或叫做回呼函數,反正就是那些在 Promise 執行,拒絕或者提示進程中會被調用的方法。
Promise 不同於回調的很重要的一個點是,你可以在 Promise 狀態變成執行(resolved)之後追加處理控制代碼。這就允許你傳輸資料,而忽略它是否已經被應用擷取,然後緩衝它,等等之類的操作,因此你可以對資料執行操作,而不管它是否已經或者即將可用。
在之後的文章中,我們將會基於 AngularJS 來講解 Promises 。AngularJS 的整個程式碼程式庫很大程度上依賴於 Promise,包括架構以及你用它編寫的應用代碼。AngularJS 用的是它自己的 Promises 實現, $q 服務,又一個 Q 庫的輕量實現。
$q 實現了上面提到的所有 Deferred / Promise 方法,除此之外 $q 還有自己的實現: $q.defer(),用來建立一個新的 Deferred 對象; $q.all(),允許等待多 Promises 執行終了,還有方法 $q.when() 和 $q.reject(),具體我們之後會講到。
$q.defer() 返回一個 Deferred 對象,帶有方法 resolve(), reject(), 和 notify()。Deferred 還有一個 promise 屬性,這是一個 promise對象,可以用於應用內部傳遞。
promise 對象有另外三個方法: .then(),是唯一 Promise 規範要求的方法,用三個回調方法作為參數;一個成功回調,一個失敗回調,還有一個狀態變化回調。
$q 在 Promise 規範之上還添加了兩個方法: catch(),可以用於定義一個通用方法,它會在 promise 鏈中有某個 promise 處理失敗時被調用。還有 finally(),不管 promise 執行是成功或者失敗都會執行。注意,這些不應該和 Javascript 的異常處理混淆或者並用: 在 promise 內部拋出的異常,不會被 catch() 俘獲。(※貌似這裡我理解錯了)
Promise 簡單例子
下面是使用 $q ,Deferred,和 Promise 放一起的簡單例子。首先我要聲明,本文中所有例子的代碼都沒有經過測試;而且也沒有正確的引用 Angular 服務和依賴,之類的。不過我覺得對於啟發你怎麼玩,已經夠好了。
首先,我們先建立一個新的工作單元,通過 Deferred 對象,用 $q.defer():
var deferred = $q.defer();
然後,我們從 Deferred 拿到 promise,給它追加一些行為。
var promise = deferred.promise;promise.then(function success(data) { console.log(data);}, function error(msg) { console.error(msg);});
最後,我們假裝做點啥,然後告訴 deferred 我們已經完成了:
deferred.resolve(‘all done!‘);
當然,這不需要真的非同步,所以我們可以用 Angular 的 $timeout 服務(或者 Javascript 的 setTimeout,不過,在 Angular 應用中最好用 $timeout,這樣你可以 mock/test 它)來假裝一下。
$timeout(function() { deferred.resolve(‘All done... eventually‘);}, 1000);
好了,有趣的是:我們可以追加很多個 then() 到一個 promise 上,以及我們可以在 promise 被 resolved 之後追加 then():
var deferred = $q.defer();var promise = deferred.promise;// assign behavior before resolvingpromise.then(function (data) { console.log(‘before:‘, data);});deferred.resolve(‘Oh look we\‘re done already.‘)// assign behavior after resolvingpromise.then(function (data) { console.log(‘after:‘, data);});
那,要是發生異常怎麼辦?我們用 deferred.reject(),它會出發 then() 的第二個函數,就像回調一樣。
var deferred = $q.defer();var promise = deferred.promise;promise.then(function success(data) { console.log(‘Success!‘, data);}, function error(msg) { console.error(‘Failure!‘, msg);});deferred.reject(‘We failed :(‘);
不用 then() 的第二個參數,還有另外一種選擇,你可以用鏈式的 catch(),在 promise 鏈中發生異常的時候它會被調用(可能在很多鏈之後)。
promise .then(function success(data) { console.log(data); }) .catch(function error(msg) { console.error(msg); });
作為一個附加,對於長耗時的處理(比如上傳,長計算,批處理,等等),你可以用 deferred.notify() 作為 then() 第三個參數,給 promise 一個監聽來更新狀態。
var deferred = $q.defer();var promise = deferred.promise;promise .then(function success(data) { console.log(data); }, function error(error) { console.error(error); }, function notification(notification) { console.info(notification); })); var progress = 0; var interval = $interval(function() { if (progress >= 100) { $interval.cancel(interval); deferred.resolve(‘All done!‘); } progress += 10; deferred.notify(progress + ‘%...‘); }, 100) 鏈式 Promise
之前我們已經看過了,你可以給一個 promise 追加多個處理(then())。Promise API 好玩的地方在於允許鏈式處理:
promise .then(doSomething) .then(doSomethingElse) .then(doSomethingMore) .catch(logError);
舉個簡單的例子,這允許你把你的函數調用切分成單純的,單一目的方法,而不是一攬子麻團;還有另外一個好處是你可以在多 promise 任務中重用這些方法,就像你執行鏈式方法一樣(比如說工作清單之類的)。
如果你用前一個非同步執行結果出發下一個非同步處理,那就更牛X了。預設的,一個鏈式,像上面示範的那種,是會把前一個執行結果對象傳遞給下一個 then() 的。比如:
var deferred = $q.defer();var promise = deferred.promise;promise .then(function(val) { console.log(val); return ‘B‘; }) .then(function(val) { console.log(val); return ‘C‘ }) .then(function(val) { console.log(val); });deferred.resolve(‘A‘);
這會在控制台輸出以下結果:
ABC
雖然例子簡單,但是你有沒有體會到如果 then() 返回另一個 promise 那種強大。這種情況下,下一個 then() 會在 promise 完結的時候被執行。這種模式可以用到把 HTTP 要求串上面,比如說(當一個請求依賴於前一個請求的結果的時候):
var deferred = $q.defer();var promise = deferred.promise;// resolve it after a second$timeout(function() { deferred.resolve(‘foo‘);}, 1000);promise .then(function(one) { console.log(‘Promise one resolved with ‘, one); var anotherDeferred = $q.defer(); // resolve after another second $timeout(function() { anotherDeferred.resolve(‘bar‘); }, 1000); return anotherDeferred.promise; }) .then(function(two) { console.log(‘Promise two resolved with ‘, two); });
總結:
Promise 鏈會把上一個 then 的返回結果傳遞給調用鏈的下一個 then (如果沒有就是 undefined)
如果 then 回掉返回一個 promise 對象,下一個 then 只會在這個 promise 被處理結束的時候調用。
在鏈最後的 catch 為整個鏈式處理提供一個異常處理點
在鏈最後的 finally 總是會被執行,不管 promise 被處理或者被拒絕,起清理作用
Parallel Promises And ‘Promise-Ifying‘ Plain Values
我還提到了 $q.all(),允許你等待並行的 promise 處理,當所有的 promise 都被處理結束之後,調用共同的回調。在 Angular 中,這個方法有兩種調用方式: 以 Array 方式或 Object 方式。Array 方式接收多個 promise ,然後在調用 .then() 的時候使用一個資料結果對象,在結果對象裡麵包含了所有的 promise 結果,按照輸入數組的順序排列:
$q.all([promiseOne, promiseTwo, promiseThree]) .then(function(results) { console.log(results[0], results[1], results[2]); });
第二種方式是接收一個 promise 集合對象,允許你給每個 promise 一個別名,在回呼函數中可以使用它們(有更好的可讀性):
$q.all({ first: promiseOne, second: promiseTwo, third: promiseThree }) .then(function(results) { console.log(results.first, results.second, results.third); });
我建議使用數組標記法,如果你只是希望可以批處理結果,就是說,如果你把所有的結果都平等處理。而以對象方式來處理,則更適合需要自注釋代碼的時候。
另一個有用的方法是 $q.when(),如果你想通過一個普通變數建立一個 promise ,或者你不清楚你要處理的對象是不是 promise 時非常有用。
$q.when(‘foo‘) .then(function(bar) { console.log(bar); });$q.when(aPromise) .then(function(baz) { console.log(baz); });$q.when(valueOrPromise) .then(function(boz) { // well you get the idea. })
$q.when() 在諸如服務中的緩衝這種情況也很好用:
angular.module(‘myApp‘).service(‘MyService‘, function($q, MyResource) { var cachedSomething; this.getSomething = function() { if (cachedSomething) { return $q.when(cachedSomething); } // on first call, return the result of MyResource.get() // note that ‘then()‘ is chainable / returns a promise, // so we can return that instead of a separate promise object return MyResource.get().$promise .then(function(something) { cachedSomething = something }); };});
然後可以這樣調用它:
MyService.getSomething() .then(function(something) { console.log(something); }); AngularJS 中的實際應用
在 Angular 的 I/O 中,大多數會返回 promise 或者 promise-compatible(then-able)對象,但是,都挺奇怪的。$http 文檔 說,它會返回一個 HttpPromise 對象,嗯,確實是 promise,但是有兩個額外的(有用的)方法,應該不會嚇到 jQuery 使用者。它定義了 success() 和 error() ,用來分別對應 then() 的第一和第二個參數。
Angular 的 $resource 服務,用於 REST-endpoints 的 $http 封裝,同樣有點奇怪;通用方法(get(),save()之類的四個)接收第二和第三個參數作為 success 和 error 回調,同時它們還返回一個對象,當請求被處理之後,會往其中填充請求的資料。它不會直接返回 promise 對象;相反,通過 get() 方法返回的對象有一個屬性 $promise,用來暴露 promise 對象。
一方面,這和 $http 不符,並且 Angular 的所有東西都是/應該是 promise,不過另一方面,它允許開發人員簡單的把 $resource.get() 的結果指派給 $scope。原先,開發人員可以給 $scope 指定任何 promise,但是從 Angular 1.2 開始被定義為過時了:請看this commit where it was deprecated。
我個人來說,我更喜歡統一的 API,所以我把所有的 I/O 操作都封裝到了 Service 中,統一返回一個 promise 對象,不過調用 $resource 有點糙。下面是個例子:
angular.module(‘fooApp‘) .service(‘BarResource‘, function ($resource) { return $resource(‘api/bar/:id‘); }) .service(‘BarService‘, function (BarResource) { this.getBar = function (id) { return BarResource.get({ id: id }).$promise; } });
這個例子有點晦澀,因為傳遞 id 參數給 BarResource 看起來有點多餘,不過它也還是有道理的,比如你有一個複雜的對象,但只需要用它的 ID 屬性來調用一個服務。上面的好處還在於,在你的 controller 中,你知道從 Service 返回來的所有東西都是 promise 對象;你不需要擔心它到底是 promise 還是 resouce 或者是 HttpPromise,這能讓你的代碼更加一致,並且可預測 - 因為 Javascript 是弱類型,並且到目前為止,據我所知沒有任何一款 IDE 能告訴你方法傳回值的類型,它只能告訴你開發人員寫了什麼注釋,這點上面就非常重要了。
實際鏈式例子
我們的程式碼程式庫有一部分是依賴於前一個調用的結果來執行的。Promise 非常適用這種情況,並且允許你書寫易於閱讀的代碼,儘可能保持你的代碼整潔。考慮如下例子:
angular.module(‘WebShopApp‘) .controller(‘CheckoutCtrl‘, function($scope, $log, CustomerService, CartService, CheckoutService) { function calculateTotals(cart) { cart.total = cart.products.reduce(function(prev, current) { return prev.price + current.price; }; return cart; } CustomerService.getCustomer(currentCustomer) .then(CartService.getCart) // getCart() needs a customer object, returns a cart .then(calculateTotals) .then(CheckoutService.createCheckout) // createCheckout() needs a cart object, returns a checkout object .then(function(checkout) { $scope.checkout = checkout; }) .catch($log.error) });
聯合非同步擷取資料(customers, carts,建立 checkout)和處理同步資料(calculateTotals);這個實現不知道,甚至不需要知道這些服務是不是非同步,它會等到方法之行結束,不論非同步與否。在這個例子中,getCart()會從本機存放區中擷取資料, createCheckout() 會執行一個 HTTP 要求來確定產品的採購,諸如此類。不過從使用者的視角來看(執行這個調用的人),它不會關心這些;這個調用起作用了,並且它的狀態非常明了,你只要記住前一個調用會將結果返回傳遞到下一個 then() 。
當然,它就是自注釋代碼,並且很簡潔。
測試 Promise - 基於代碼
測試 Promise 非常簡單。你可以硬測,建立你的測試類比對象,然後暴露 then() 方法,這種直接測法。但是,為了讓事情簡單,我只用了 $q 來建立 promise - 這是一個非常快的庫。下面嘗試示範如何類比上面用到過的各種服務。注意,這非常冗長,不過,我還沒有找出一個方法來解決它,除了在 promise 之外弄一些通用的方法(指標看起來更短更簡潔,會比較受歡迎)。
describe(‘The Checkout controller‘, function() { beforeEach(module(‘WebShopApp‘)); it(‘should do something with promises‘, inject(function($controller, $q, $rootScope) { // create mocks; in this case I use jasmine, which has been good enough for me so far as a mocking library. var CustomerService = jasmine.createSpyObj(‘CustomerService‘, [‘getCustomer‘]); var CartService = jasmine.createSpyObj(‘CartService‘, [‘getCart‘]); var CheckoutService = jasmine.createSpyObj(‘CheckoutService‘, [‘createCheckout‘]); var $scope = $rootScope.$new(); var $log = jasmine.createSpyObj(‘$log‘, [‘error‘]); // Create deferreds for each of the (promise-based) services var customerServiceDeferred = $q.defer(); var cartServiceDeferred = $q.defer(); var checkoutServiceDeferred = $q.defer(); // Have the mocks return their respective deferred‘s promises CustomerService.getCustomer.andReturn(customerServiceDeferred.promise); CartService.getCart.andReturn(cartServiceDeferred.promise); CheckoutService.createCheckout.andReturn(checkoutServiceDeferred.promise); // Create the controller; this will trigger the first call (getCustomer) to be executed, // and it will hold until we start resolving promises. $controller("CheckoutCtrl", { $scope: $scope, CustomerService: CustomerService, CartService: CartService, CheckoutService: CheckoutService }); // Resolve the first customer. var firstCustomer = {id: "customer 1"}; customerServiceDeferred.resolve(firstCustomer); // ... However: this *will not* trigger the ‘then()‘ callback to be called yet; // we need to tell Angular to go and run a cycle first: $rootScope.$apply(); expect(CartService.getCart).toHaveBeenCalledWith(firstCustomer); // setup the next promise resolution var cart = {products: [ { price: 1 }, { price: 2 } ]} cartServiceDeferred.resolve(cart); // apply the next ‘then‘ $rootScope.$apply(); var expectedCart = angular.copy(cart); cart.total = 3; expect(CheckoutService.createCheckout).toHaveBeenCalledWith(expectedCart); // Resolve the checkout service var checkout = {total: 3}; // doesn‘t really matter checkoutServiceDeferred.resolve(checkout); // apply the next ‘then‘ $rootScope.$apply(); expect($scope.checkout).toEqual(checkout); expect($log.error).not.toHaveBeenCalled(); }));});
你看到咯,測試 promise 的代碼比它自己本身要長十倍;我不知道是否/或者有更簡單的代碼能達到同樣目的,不過,也許這裡應該還有我沒找到(或者發布)的庫。
要擷取完整的測試覆蓋,需要為三個部分都編寫測試代碼,從失敗到處理結束,一個接一個,確保異常被記錄。雖然代碼中沒有很清楚示範,但是代碼/處理實際上會有許多分支;每個 promise 到最後都會被解決或者拒絕;真或假,或者被建立分支。不過,測試的粒度到底是由你決定的。
我希望這篇文章給大家帶來一些理解 promise 的啟示,以及教會怎樣結合 Angular 來使用 promise。我覺得我只摸到了 一些皮毛,包括在這篇文章以及在到目前為止我所做過的 AngularJS 工程上;promise 能夠擁有如此簡單的 API,如此簡單的概念,並且對大多數 Javascript 應用來說,有如此強大的力量和影響有點難以置信。結合高水平的通用方法,程式碼程式庫,promise 可以讓你寫出更乾淨,易於維護和易於擴充的代碼;添加一個控制代碼,改變它,改變實現方式,所有這些東西都很容易,如果你對 promise 的概念已經理解了的話。
從這點考慮,NodeJS 在開發早期就拋棄了 promise 而採用現在這種回調方式,我覺得非常古怪;當然我還沒有完全深入理解它,但是看起來好像是因為效能問題,不符合 Node 的原本目標的緣故。如果你把 NodeJS 當成一個底層的庫來看的話,我覺得還是很有道理的;有大量的庫可以為 Node 添加進階的 promise API(比如之前提到的 Q).
還有一點請記住,這篇文章是以 AngularJS 為基礎的,但是,promises 和 類promise 編程方式已經在 Javascript 庫中存在好幾年了;jQuery ,Deferreds 早在 jQuery 1.5 (1月 2011) 就被添加進來。雖然看起來一樣,但不是所有外掛程式都能用。
同樣,Backbone.js 的 Model Api 也暴露了 promise 在它的方法中(save() 之類),但是,以我的理解,它貌似沒有沿著模型事件真正的起作用。也有可能我是錯的,因為已經有那麼一段時間了。
如果開發一個新的 webapp 的時候,我肯定會推薦基於 promise 的前端應用的,因為它讓代碼看起來非常整潔,特別是結合函數式編程範式。還有更多功能強勁的編程模式可以在 Reginald Braithwaite 的 Javascript Allongé book 中找到,你可以從 LeanPub 拿到免費的閱讀副本;還有另外一些比較有用的基於 promise 的代碼。