AngularJSチュートリアルやってメモメモ(step11 - RESTとカスタムサービス)
ついにチュートリアル最後のステップ。
このステップではデータのフェッチをさらに洗練させる。
今回やるのはこちら↓
AngularJS: 11 - REST and Custom Services
ワークスペースをstep11のにリセット。注:ローカルの修正は破棄される
git checkout -f step-11
ローカルサーバーをリフレッシュ(またはAngularサーバーで確認可)。
チュートリアルサンプルアプリ最後の機能追加としてRESTfulクライアントの役割を持つカスタムサービスを定義する。このクライアントを使用して、データ取得の為のXHRリクエストを低レベルな$http API&HTTPメソッド&URLを用いた手法に比べてより簡単に作成することが出来る。
step10からstep11への変更を説明。diffはこちら。
テンプレート
カスタムサービスはapp/js/services.jsで定義されているので、このファイルをレイアウトテンプレートにインクルードする必要がある。加えてangular-resource.jsをロードする必要がある。このファイルはこの後で使用するngResourceモジュールとさらにその中の$resourceサービスを含んでいる。
app/index.html:
... <script src="js/services.js"></script> <script src="lib/angular/angular-resource.js"></script> ...
サービス
app/js/services.js:
angular.module('phonecatServices', ['ngResource']). factory('Phone', function($resource){ return $resource('phones/:phoneId.json', {}, { query: {method:'GET', params:{phoneId:'phones'}, isArray:true} }); });
まずモジュールAPIとfactory関数を使用してカスタムサービスを登録している。factory関数にサービス名'Phone'とファクトリ関数オブジェクトを渡す。ファクトリ関数は引数に依存コンポーネントを宣言出来る点でコントローラーのコンストラクタと似ている。Phoneサービスは$resourceサービスへの依存を宣言している。
$resourceサービスはわずか数行のコードでRESTfulクライアントの作成を容易にする。このクライアントはアプリケーションの中で低レベル$httpサービスの代わりとして使用できる。
app/js/app.js:
... angular.module('phonecat', ['phonecatFilters', 'phonecatServices']). ...
phonecatServicesモジュールをphonecatアプリケーションのrequires配列引数に渡す必要がある。
コントローラー
低レベル$httpサービスを整理して新しいPhoneサービスに置き換え、サブコントローラー(PhoneListCtrlとPhoneDetailCtrl)をシンプルにする。Angularの$resourceサービスはRESTfulリソースとして公開されているデータソースを$httpよりも簡単に扱うことが出来る。またコントローラーのコードが読みやすくなるというメリットも。
app/js/controllers.js:
... function PhoneListCtrl($scope, Phone) { $scope.phones = Phone.query(); $scope.orderProp = 'age'; } //PhoneListCtrl.$inject = ['$scope', 'Phone']; function PhoneDetailCtrl($scope, $routeParams, Phone) { $scope.phone = Phone.get({phoneId: $routeParams.phoneId}, function(phone) { $scope.mainImageUrl = phone.images[0]; }); $scope.setImage = function(imageUrl) { $scope.mainImageUrl = imageUrl; } } //PhoneDetailCtrl.$inject = ['$scope', '$routeParams', 'Phone'];
PhoneListCtrlの中をどの様に修正したかに注目。
$http.get('phones/phones.json').success(function(data) { $scope.phones = data; });
↓
$scope.phones = Phone.query();
上記は全ての端末を取得する為のシンプルな文。
このコード中の重要な点は、Phoneサービスを呼ぶ際にcallback関数を一つも渡していないことだ。まるで結果が同期的に返却されているかの様に見えるが実際はそうではない。同期的に返却されているのは"future"オブジェクトで、これはXHRレスポンスが返却されたタイミングでデータを格納する。Angularのデータバインディングはfutureをテンプレートにバインドすることが出来るので、データが到着したタイミングでビューは自動で更新される。
futureオブジェクトとデータバインディングだけに頼ることだけでは要件を満たすのに不十分なこともあるかもしれないが、そういったケースではレスポンスを処理するコールバック関数を渡すことも出来る。PhoneDetailCtrlコントローラーではコールバックを利用してmainImageUrlを設定している。
テスト
ユニットテストを修正して今回作成した新しいサービスがHTTPリクエストを発行しそれらを正しく処理しているかを検証する。またコントローラーがそのサービスと正しく協調動作するかをチェックする。
$resourceサービスはレスポンスオブジェクトにリソースの更新や削除を行うメソッドを自動追加する為、もし標準的なtoEqualマッチャーを使用した場合、テストする値はレスポンスと正しくマッチしなくなりテストは失敗するだろう。この問題を解決する為に新しく定義したtoEqualDataというJasmineのマッチャーを使用する。toEqualDataマッチャーで2つのオブジェクトを比較すると、オブジェクトのプロパティのみが比較され、メソッドは無視される。
test/unit/controllersSpec.js:
describe('PhoneCat controllers', function() { beforeEach(function(){ this.addMatchers({ toEqualData: function(expected) { return angular.equals(this.actual, expected); } }); }); beforeEach(module('phonecatServices')); describe('PhoneListCtrl', function(){ var scope, ctrl, $httpBackend; beforeEach(inject(function(_$httpBackend_, $rootScope, $controller) { $httpBackend = _$httpBackend_; $httpBackend.expectGET('phones/phones.json'). respond([{name: 'Nexus S'}, {name: 'Motorola DROID'}]); scope = $rootScope.$new(); ctrl = $controller(PhoneListCtrl, {$scope: scope}); })); it('should create "phones" model with 2 phones fetched from xhr', function() { expect(scope.phones).toEqual([]); $httpBackend.flush(); expect(scope.phones).toEqualData( [{name: 'Nexus S'}, {name: 'Motorola DROID'}]); }); it('should set the default value of orderProp model', function() { expect(scope.orderProp).toBe('age'); }); }); describe('PhoneDetailCtrl', function(){ var scope, $httpBackend, ctrl, xyzPhoneData = function() { return { name: 'phone xyz', images: ['image/url1.png', 'image/url2.png'] } }; beforeEach(inject(function(_$httpBackend_, $rootScope, $routeParams, $controller) { $httpBackend = _$httpBackend_; $httpBackend.expectGET('phones/xyz.json').respond(xyzPhoneData()); $routeParams.phoneId = 'xyz'; scope = $rootScope.$new(); ctrl = $controller(PhoneDetailCtrl, {$scope: scope}); })); it('should fetch phone detail', function() { expect(scope.phone).toEqualData({}); $httpBackend.flush(); expect(scope.phone).toEqualData(xyzPhoneData()); }); }); });
ブラウザのKarmaタブでテストが成功することを確認。
Chrome 22.0: Executed 4 of 4 SUCCESS (0.038 secs / 0.01 secs)