ぼくたちがフロントエンドエンジニアと呼ばれるまで

Angular1によるSPA開発 – 設計編

こんにちは、エンジニアの尾形です。

前回のブログではAngular1によるSPA開発の基本部分についてお伝えしました。
今回はAngular1の設計の勘所についてご紹介します。
(なぜAngular1を採用しているかについては前回記事をご参照ください。)

コンポーネント指向でアプリケーションを開発しよう

まず最初にAngular1ではなくAngular2の話になりますが、Angular2はコンポーネント指向のフレームワークです。画面の要素を部品化してコンポーネントとして定義することで次の恩恵を受けることができます。

・HTMLテンプレートと処理をセットで記述できるためプログラムの見通しが良くなる
・再利用しやすい
・メンテナンスしやすい
・複数人で開発しやすい

Angular1もバージョン1.5からコンポーネントが利用可能となり、Angular2に近い構造でアプリケーションを開発することができるようになりました。将来的にAngular2に移行することを視野に入れ、Angular1のコンポーネント指向でアプリケーションを開発しています。

Angular1のコンポーネントの簡単なサンプルをご紹介します。サンプルでよく見られるToDoリストを例にします。
実際のプログラムをご紹介する前に、ToDoリストを表示するときの処理の流れ(シーケンス図)を掲載しておきます。プログラムの理解の一助になればと思います。
(画像をクリックで拡大)

それでは、実際のプログラムについてご紹介していきます。

<html ng-app="app">
<script type="text/javascript" src="/js/app.js"></script>
<todo-list-component></todo-list-component>
</html>

↑ToDoリストのメインのHTMLです。
app.jsがAngular1のアプリケーションです。
todo-list-componentはToDoのリストを表すコンポーネントです。

todo-list-componentは次のようになります。

import { Todo } from '../models/todo';

export class TodoListComponent {
    public static factory(): ng.IComponentOptions {
        return {
            controller: TodoListController,
            template: `
<ul>
    <todo-item-component todo="todo" ng-repeat="todo in $ctrl.todoList"></todo-item-component>
</ul>
`
        };
    }
}

class TodoListController {
    private todoList = new Array<Todo>();

    constructor() {
    }

    $onInit() {
        this.todoList.push({ id: 1, name: 'ToDoその1' });
        this.todoList.push({ id: 2, name: 'ToDoその2' });
        this.todoList.push({ id: 3, name: 'ToDoその3' });
    }
}

↑TodoListComponentでcontroller(処理)とtemplate(HTMLテンプレート)を定義しています。
HTMLテンプレート内に、別のコンポーネントであるtodo-item-componentが登場しています。todo-item-componentはひとつのToDoを表すコンポーネントで、ng-repeatによりToDoの数だけ出力されます。
TodoListControllerの$onInit()はデータバインディングが済んだあとに呼び出されるメソッドです。そこで疑似的にToDoを3件追加しています。(実際にはAPIでToDoのリストを取得したりするでしょう)

todo-item-componentは次のようになります。

import { Todo } from '../models/todo';

export class TodoItemComponent {
    public static factory(): ng.IComponentOptions {
        return {
            controller: TodoItemController,
            template: `
<li>
    <p ng-bind="$ctrl.todo.name"></p>
</li>
`,
            bindings: { 'todo': '=' }
        };
    }
}

class TodoItemController {
    private todo: Todo;

    constructor() {
    }
}

↑TodoItemComponentではcontrollerとtemplateに加え、bindingsも定義しています。bindingsはその名の通りデータバインディングの定義で、todo-item-componentが生成されるときにデータ(ToDoのモデル)がバインドされます。
なお、TodoItemControllerの処理は割愛していますが、ToDoの編集や削除などの責務を持つことになります。

export interface Todo {
    id: number;
    name: string;
}

↑TodoはひとつのToDoを表すモデルです。ここではIDと名前のみ定義しています。
このモデルはtodo-list-componentとtodo-item-componentで使用されます。

メインのプログラムファイルは次のようになります。

import angular = require('angular');

import { TodoListComponent } from './components/toto-list-component';
import { TodoItemComponent } from './components/toto-item-component';

var appModule = angular.module('app', []);
appModule.component('todoListComponent', TodoListComponent.factory());
appModule.component('todoItemComponent', TodoItemComponent.factory());

↑Angular1にコンポーネント群を登録しています。

以上がコンポーネントのサンプルです。コンポーネント指向のイメージは掴めたでしょうか。
(クラス図を後述しますのでそちらも合わせてご確認ください)

ライブラリーの機能はラップして使うべし

Angular1に限った話ではありませんが、ライブラリーの機能はそのまま使うよりもラップして使うことが多いですよね。その理由には次のようなものがあります。

・ライブラリーの機能自体には手を加えずに挙動を追加、変更できる
・ライブラリーを利用する側が使いやすいインターフェースを変更できる
・必要な機能のみを公開し、不要な機能を隠ぺいすることで分かりやすくできる

例えば、Angular1にはHTTP通信を行なうための$httpサービスがあり、サーバーのAPIを呼び出したりする場合に使用します。各々のコンポーネントで$httpサービスをそのまま使っても良いのですが、HTTP通信を行う際には、ローディング中の表示をしたり、HTTPヘッダーに拡張ヘッダーを追加したり、認証に関する処理やエラー処理、リトライ処理を行なったりと、考慮しなくてはならないことがいろいろあります。
そういった面倒なことは共通化して利用する側では楽をしたいですよね。そこで、アプリケーション独自のHTTPサービスを作成してAngularの$httpサービスをラップしてしまいます。

import { ApiTokenService } from './api-token-service';
import { Configs } from '../config/config';
import { DialogService } from './dialog-service';
import { StorageService } from './storage-service';

export class ApiService {
    constructor(
        private $http: ng.IHttpService,
        private $timeout: ng.ITimeoutService,
        private apiTokenService: ApiTokenService,
        private configs: Configs,
        private dialogService: DialogService,
        private storageService: StorageService) {
        "ngInject";
    }
}

↑API処理に必要なサービス群をDI(Dependency Injection)し、ApiServiceとしてAPI処理を実装します。$http, $timeoutはAngular1の標準サービス、それ以外はアプリケーションの独自サービスです。各サービスの説明は省略しますが、名前からなんとなく想像できると思います。

appModule.service('apiService', ApiService);

↑コンポーネントと同様に、Angular1にサービスを登録しています。

このサービスを各コンポーネントにDIすることにより、各コンポーネントでApiServiceを利用できるようになります。

ここまでに登場したクラス群を整理するためにクラス図を描いてみました。
(画像をクリックで拡大)

前述したTodoListControllerのサンプルではApiServiceを使用していませんでしたが、TodoListControllerにてAPIを呼び出してToDoリストを取得することを想定して、TodoListControllerにApiServiceをDIした例にしています。

ビジネスロジックや共通的な処理は、コンポーネントから切り離してサービスとして実装すると、クラスの責務が明確になり、クラスの関連も疎結合になるので、アプリケーションの構造がすっきりします。なお、Angular1のサービス同士で相互依存にしてしまうと、実行時に循環参照となりエラーとなってしまいますので、片方向の依存にする必要があります。

なお、上の図はVisual Studio CodeのUMLプラグインで描いています。興味がある方は以下の外部サイトを参照してみてください。

Visual Studio Codeで自由自在にUMLを描こう

環境固有の設定を切り替えるには

アプリケーションの開発をしていると、開発環境や本番環境で設定を切り替えたいケースがあります。サーバー側のアプリケーションであれば、環境変数によって設定を切り替える等の対応ができますが、クライアント側のアプリケーションの場合はそうはいきません。

今回のSPA開発では、gulp-ng-constantというGulpのプラグインを使用して、TypeScriptのコンパイル時に設定を切り替える手法を取りました。

まず最初に、環境ごとの設定を記述したJSONファイルを用意します。

config/config.json

{
    "development": {
        "logging": "true",
        "sampleUrl": "http://development.example.com"
    },
    "staging": {
        "logging": "true",
        "sampleUrl": "http://staging.example.com"
    },
    "production": {
        "logging": "false",
        "sampleUrl": "https://production.example.com"
    }
}

次に、環境ごとの設定を保持するクラスのテンプレート(EJSテンプレートエンジンの形式)を定義します。

config/config.tpl.ejs

export interface Configs {<% constants.forEach(function(constant) {%>
    <%= constant.name.replace(/"/g, "'") %>: string;<% }) %>
};

export class ConfigsImpl {
    static get Default(): Configs {
        return {<% constants.forEach(function(constant) {%>
            <%= constant.name.replace(/"/g, "'") %>: <%= constant.value.replace(/"/g, "'")%>,<% }) %>
        };
    }
};

最後に、環境ごとの設定を出力するタスクをGulpのタスクとして作成します。

// プラグインを読み込む
var gulp = require('gulp');
var ngConstant = require('gulp-ng-constant');
var del = require('del');
var rename = require('gulp-rename');

// 環境ごとの設定を出力するタスク
gulp.task('config', function() {

    var paths = {
        config: './src/frontend/typescript/config/config.json',
        tsTemplate: './src/frontend/typescript/config/config.tpl.ejs',
        tsDestDir: './src/frontend/typescript/config/',
        tsDestFile: 'config.ts'
    };

    var config = require(paths.config);
    var env = process.env.APP_ENV || 'development';
    var envConfig = config[env];

    // 旧ファイル削除
    del.sync([paths.tsDestDir + paths.tsDestFile]);

    return ngConstant({
            constants: envConfig,
            templatePath: paths.tsTemplate,
            stream: true
        })
        .pipe(rename(paths.tsDestFile))
        .pipe(gulp.dest(paths.tsDestDir));
});

以上で準備は完了です。
本番環境のconfigを作るときは次のように実行します。

APP_ENV=production gulp config

これにより次のようなファイルが出力されます。
config/config.ts

export interface Configs {
    logging: string;
    imageServerUrl: string;
};

export class ConfigsImpl {
    static get Default(): Configs {
        return {
            logging: 'false',
            sampleUrl: 'https://production.example.com'
        };
    }
};

上で出力されたConfigsを、Angular1に定数サービスとして登録します。

import { ConfigsImpl } from './config/config';

var appModule = angular.module('app', []).constant('configs', ConfigsImpl.Default);

あとは、通常のサービスと同様に、利用したい箇所で’configs’をDIすることにより、環境に応じた設定を参照できるようになります。

まとめと予告

今回はAngular1の設計の勘所について書かせていただきました。
次回はAntular1のTipsについてご紹介させていただきます。

第6回
Angular1によるSPA開発 – Tips編
– URLによる二重クリック対策(サンプル紹介)
– ネットワークオフライン検知について(サンプル紹介)
– $scope.$watch() や $scope.$on() でイベントのリスナーを登録する際の注意事項(監視が不要になった時点でリスナーを解除する必要がある)
– jQueryによるDOMの変更をAngular側へ通知する(trigger(‘change’)のイベントを使用すること。trigger(‘input’)はIEで動作しない)
– ng-repeat でユニークキー重複エラー(Angularが生成するhash keyが重複するケースがある)

フロントエンドエンジニアへの道

1- HTML/CSSコーディングはじめるぞ〜HTML/CSSコーディングを行なうにあたっての事前準備
2- チーム作業をするための最適なCSS規約、Sassについて考えた
3- Sassコーディングを行なう上で必要となる各種自動化の話
4- Angular1によるSPA開発 – 基本編
5- Angular1によるSPA開発 – 設計編
6- Angular1によるSPA開発 – Tips編

この記事を書いた人・プロフィール
オガティー
ニックネーム: オガティー

2014年3月から現職。当社のベンチャービジネスに心惹かれて入社。
それ以前はソフトハウス(2社)に在籍。
エンジニア歴はもう少しで20年。通信キャリアーのシステム開発、サーバーサイドプログラミングの経験が長い。
エンジニアとしてのベースはJava屋。Androidアプリ開発も。サーバーサイドはPHP, Node.jsが多い。
フロントエンドエンジニアとしては駆け出し。
趣味:ギター、ドラム、フットサル、お酒