ほげにっき

hogedigoの日記

2013年06月11日のツイート

AngularJSチュートリアルやってメモメモ(step5 - XHR(Ajax)&DI)

ちょっと間があいてしまったが再開。。

目次


今回やるのはこちら↓
AngularJS: 5 - XHR & Dependency Injection


ここまでのstepではハードコードされた端末リストデータをつかってアプリを作成してきたが、このstepでは端末リストをangularの標準機能の$httpサービスを使ってサーバーから取得する様に修正する。さらにangularの依存性注入(DI)機能を使用してサービスをPhoneListCtrlコントローラーに渡す方法を学ぶ。


ワークスペースをstep5のにリセット。注:ローカルの修正は破棄される

git checkout -f step-5

ローカルサーバーをリフレッシュすると、20個の端末のリストが表示される。


step4からstep5への変更を説明。diffはこちら

データ

ファイルapp/phones/phones.jsonJSONフォーマットで記述された端末リストで、サーバーはリクエストを受けるとこのデータを返却する。

[
 {
  "age": 13,
  "id": "motorola-defy-with-motoblur",
  "name": "Motorola DEFY\u2122 with MOTOBLUR\u2122",
  "snippet": "Are you ready for everything life throws your way?"
  ...
 },
...
]

コントローラー

phones.jsonの端末リストデータを取得するためにangularの標準機能$httpサービスを使用している。$httpサービスはサーバーにHTTPリクエストを送信しデータをフェッチする。angularは$httpの他にも多くのWEBアプリ構築に使えるサービスを提供しており、それらのサービスは必要なところでangularによって受け渡される(DI)。


サービスはangularのDI(Dependency Injection:依存性注入)サブシステムによって管理されている。DIにより作成するアプリを粗結合(コンポーネント間の依存はコンポーネント自身が解決するのではなく、DIサブシステムが行う)に保つことができる。


app/js/controllers.js:

function PhoneListCtrl($scope, $http) {
  $http.get('phones/phones.json').success(function(data) {
    $scope.phones = data;
  });
 
  $scope.orderProp = 'age';
}
 
//PhoneListCtrl.$inject = ['$scope', '$http'];


$httpサービスはHTTPのGETリクエストを作成しwebサーバーに送信し、phone/phone.json(URLはindex.htmlからの相対パス)を取得している。サーバーはjsonファイルをレスポンスとして返却する(このサンプルでは固定のJSONテキストを返却しているが、もちろん動的に生成されたリストを返却することもできる)。


$httpサービスはsuccessメソッドを持つpromiseオブジェクトを返却する。非同期レスポンスを受け取って端末リストデータをコントローラの管理するscopeにphonesモデルとして保存する為に、successメソッドを呼び出す。正常なレスポンス(ステータス200ってことかな?)を受け取るとsuccessメソッドに設定したコールバック関数が呼び出される。Angularはjsonのレスポンスを検知した場合自動でパースしてjavascriptオブジェクトに変換し、コールバック引数に受け渡す。


angularでサービスを使用する為には、必要なものだけコントローラのコンストラクタ関数に名前を指定すればよい。

function PhoneListCtrl($scope, $http) {...}


AngularのDIは、コントローラーが構築されるときにサービスを受け渡す。そのサービスが他のサービスに依存する場合はその取得も自動で行う。
DIされる引数の名称は重大な意味をもつことに気をつけること。なぜならばDIは引数名から依存コンポーネントを見つけるので。

'$'プレフィックス命名規約

開発者は自分の独自サービスを作成することができる(step11で解説する)。命名規約では、Angularの標準サービスはプレフィックス'$'がついている。独自サービス(またはモデル等他のコンポーネント)を作成する際は名前の衝突を避ける為プレフィックス'$'をつけないこと。

Javascriptミニファイに関する注意

上述した通り、AngularのDIはコントローラーのコンストラクタ引数名から依存コンポーネントを解決する。もしJavascriptのコードをミニファイした場合全ての引数名はおそらく短縮化されるが、その場合AngularのDIは依存コンポーネントを見つけることが出来なくなる。
この問題は、サービス名を設定した配列をコントローラー関数の$injectプロパティに設定することで解決できる。

PhoneListCtrl.$inject = ['$scope', '$http'];

また、以下の様にコントローラー関数をラップする配列として記述する方法もある。配列には依存サービス名リストと、それに続くコントローラー関数を設定する。

var PhoneListCtrl = ['$scope', '$http', function($scope, $http) { /* constructor body */ }];

テスト

test/unit/controllersSpec.js:


今回からDIを使用しコントローラーが依存コンポーネントを持つ形となった為、テストは若干複雑になる。new演算子を使用してモック$http実装を作成しコンストラクタに渡してテストすることもできるが、オススメの(そしてより簡単な)方法は、テスト環境でも本番環境のAngularが行うのと同じ方法でコントローラーを作成することだ。

describe('PhoneCat controllers', function() {
 
  describe('PhoneListCtrl', function(){
    var scope, ctrl, $httpBackend;
 
    // The injector ignores leading and trailing underscores here (i.e. _$httpBackend_).
    // This allows us to inject a service but then attach it to a variable
    // with the same name as the service.
    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});
    }));


サンプルではJasmineとangular-mock.jsをロードしている為、moduleとinjectの2つのヘルパーメソッドを利用することができる。これらによりDIを操作し設定することが出来る。


下記の様にテスト環境でコントローラーを作成している。

  • injectヘルパーメソッドを利用し、$rootScope、$controller、$httpBackendサービスのインスタンスをJasmineのbeforeEach関数に受け渡している。これらのインスタンスは各単体テストでそれぞれ一から再構築されて渡される。それにより、各テストは他のテストの影響を受けない
  • $rootScope.$new()を呼び出すことでコントローラの為に新しいscopeを作成している
  • DIで受け渡された$controller関数を呼び出し、PhoneListCtrl関数とscopeをパラメータとして渡している


現在端末データを取得するのに$httpサービスを使用しているので、PhoneListCtrl用子scopeを作成する前に、テスト用モックにコントローラーからのリクエストを教えておく必要がある。この例では$httpBackendサービス(angularでXHRとJSONPリクエストを行う際に呼び出される基盤?)をモックして期待するリクエストURLに対して固定のレスポンスを返す様にしている。
※レスポンスは$httpBackend.flushが呼び出されるまで返されないので注意


次に、レスポンスを受け取る前にscopeに端末リストが保存されていないことをアサーションしている。

it('should create "phones" model with 2 phones fetched from xhr', function() {
  expect(scope.phones).toBeUndefined();
  $httpBackend.flush();
 
  expect(scope.phones).toEqual([{name: 'Nexus S'},
                               {name: 'Motorola DROID'}]);
});

レスポンスを返す為に$httpBackend.flushを呼ぶ必要がある。
そしてここで、(先ほどは保存されていなかった)端末リストがscopeに保存されていることをアサーションしている。


最後に、orderPropのデフォルト値が正しくセットされているかをアサーションしている。

it('should set the default value of orderProp model', function() {
  expect(scope.orderProp).toBe('age');
});
;


Karma画面に下記の用に表示されればテスト成功。

Chrome 22.0: Executed 2 of 2 SUCCESS (0.028 secs / 0.007 secs)

実験

  • index.htmlの最後に{{phones | json}}バインディングを追加し、端末リストをjson形式で表示してみよう
  • PhoneListCtrlコントローラーで、HTTPレスポンスを処理して端末リストの件数を先頭5件に制限してみよう。$httpコールバックの中で以下のコードを使用すれば実現できる
$scope.phones = data.splice(0, 5);


今日はここまで。次回は「step6 - リンクや画像のテンプレート化」。また少し間あくかも。。