Angular2で高速SPA開発(2)

はじめに

この記事はAngular2で高速SPA開発(1)の続きとなります。

前回はAngular2 + @angular/cli でアプリを作ってハイ終わり!でしたが、今回はバックエンド、データベースとの連携まで行きたいと思います。

大雑把に言うと、今回のテーマはMEANのAをAngular2で置き換える!ということになります。

MEANとは

MEAN(MEANスタックとも呼ばれる)とはWebアプリケーションの実行環境のことです。Webアプリの現場でよく使われている(と思われる)Linux + Apache + MySQL (MariaDB) + PHP/Perl/PythonであるところのLAMP環境と雰囲気的にはよく似ていて、MongoDB(データベース) + Express.js(バックエンドフレームワーク) + AngularJS(フロントエンドフレームワーク) + Node.js(サーバサイド実行環境)で構成された環境のことです。

アプリケーションの大部分をJavaScriptおよびJSONで記述することができること、サーバを担うNode.jsが省メモリ・高速で動作することからサーバへの不可が小さいこと、などの特徴を持ちます。

各構成要素の詳細については割愛しましょう。

今回のテーマにもあるMEANスタックのAとはもちろんAngularJSを指します。

やりたいこと

ところでこのAngularJSですが、(前回の記事でも述べましたが)現在ではより新しいバージョンのAngular(旧称Angular2。以下単にAngularといいます。)が登場しています。しかしながらこのAngular、AngularJSとの互換性がなく、アーキテクチャも一新されてしまっています。おまけにJavaScriptではなく、いわゆるaltJSのひとつであるTypeScriptが使われます。というような事情もあり、MEANで構成、というとAngularJSが使われている、ような気がしますよね。しかしJavaScriptはちょっとなぁ……というわたしのような人間も(多数)おられるでしょう。

というわけで、こんかいは、MEANのAをAngularに挿げ替え(ついでにサーバサイドもTypeScriptのコードに置き換え)ていきたいと思います!

……と思ったのですが、すでにいい記事がかかれています。

MEANスタック入門(1) MEANスタックとは | tadajam’s blog

この記事でも大いに参考にさせていただきました。

大まかにはこれで問題ないと思いますが、一部ハマったので備忘録も兼ねて書いておきます。

今回の環境

  • MacBook Pro (Retina, 13-inch, Early 2015)
    • macOS Sierra 10.12.5
    • プロセッサ: 3.1 GHz Intel Core i7
    • メモリ: 16 GB 1867 MHz DDR3
  • Node.js v7.5.0
  • npm 5.0.3
  • @angular/core 4.2.4
  • @angular/cli 1.1.3(その他のライブラリは省略)

といういつもどおりのものです。

ではさっそくはじめましょう!

フロントエンド

はい。フロントエンドですが、前回と同じくAngular + @angular/cli を使います。コマンド一発で終わりです。今回はmeanapp という名前で作りました。

$ ng new meanapp --routing=true

–routing=true を指定すると、生成時にルーティングの設定ファイルをapp-routing.module.ts として切り出してくれます。小規模な開発ならデフォルトのままでもよいのですが、どうせあとから切りたくなりますのではじめからそうしておきましょう。

データベース

データベースはMEANのMMongoDBを使います。MongoDBはいわゆるNoSQLタイプのデータベースで、中でもドキュメント指向と言われる種類のものです。すごく簡単に言うと、データとしてJSONをそのまま放り込むことができます。スキーマがきっちり決まっていなくてもそれほど問題にならないので、とくに開発段階では楽でよいですよね。

MongoDBのインストール、制御に関しては割愛します。macOSで作業しているなら下記の記事を参考にどうぞ。とはいえ、インストールはともかく、起動などはスクリプトで自動化しちゃうので気にしなくてもいいです。

Mac に MongoDBをインストール | Qiita

インストールされたDBにデータを投入しておきます。MongoDBにアクセスし

$ mongo

データベースとデータを作成します。ここではtestdb という名前のデータベースにしました。また> はMongoDBのプロンプトを表しています。

> use testdb;
> for (let i = 0; i < 20; i++) {
>  db.users.save({id:i, address: "address" + i});
> }

JavaScriptがかけるので、データの用意も簡単ですね。念のため確認しておきましょう。

> db.users.find();
{ "_id" : ObjectId("59535f311db9be1c9ed3be14"), "id" : 0, "address" : "address_0@example.com" }
{ "_id" : ObjectId("59535f311db9be1c9ed3be15"), "id" : 1, "address" : "address_1@example.com" }
{ "_id" : ObjectId("59535f311db9be1c9ed3be16"), "id" : 2, "address" : "address_2@example.com" }
{ "_id" : ObjectId("59535f311db9be1c9ed3be17"), "id" : 3, "address" : "address_3@example.com" }
{ "_id" : ObjectId("59535f311db9be1c9ed3be18"), "id" : 4, "address" : "address_4@example.com" }
# ... 省略

よさそうですね。

バックエンド

バックエンドはMEANのEExpress.jsを使います。わたしはJavaScriptを書きたくないので、先に述べたとおりTypeScriptで書き、それをトランスパイルして実行しようと思います。

まずはトランスパイラのインストールですね。

$ npm install typescript --save-dev

とするとインストールされます。

つぎは本体です。リクエストを処理するためのライブラリも一緒にインストールします。

$ npm install express body-parser --save

MongoDBのドライバとしてmongooseを使いますのであわせてインストールします。

$ npm install --save mongoose

それから、このような構造になるようにディレクトリを作ります。

meanapp
└── server
    ├── bin
    ├── models
    └── routes

server/config.ts にサーバの設定を書きます。

export const serverPort: number = 4300; // サーバのポート
export const mongoUri: string = "mongodb://localhost/testdb"; // DBの接続先

そしてサーバサイドのエントリポイント、server/bin/www.ts を作成します。

import * as http from 'http';
import { app } from '../app';
import { serverPort, mongoUri } from '../config';
import * as mongoose from 'mongoose';

const port = process.env.PORT || serverPort;
app.set('port', port);

const server = http.createServer(app);
server.listen(port, () => {
  mongoose.connect(mongoUri);
});

待受開始時にmongooseへ接続しています。

このファイルで参照しているapp.ts ですが、ここにはルーティングの設定を書きます。といってもここでは振り分けをしているだけで、実際の処理はserver/routes 以下のファイルが受け持ちます。

import * as express from 'express';
import * as path from 'path';
import { json, urlencoded } from 'body-parser';
import * as compression from 'compression';

import { usersRouter } from './routes/users';

const app: express.Application = express();
app.disable('x-powered-by');

app.use(json());
app.use(compression());
app.use(urlencoded({ extended: true }));

app.use(express.static(path.join(__dirname, '../client')));

app.use('/api/users', usersRouter);

app.get('/', (req, res) => {
  res.sendFile(path.join(__dirname, '../client/index.html'));
});

export { app };

サーバルート/ へのアクセスはclient/index.html に、/api/users へのアクセスはusersRouter に振り分けるようにしています。

さて、バックエンドはAPIサーバとして動作することになりますが、そのAPIの本体とも言える処理をroutes/users.tsが受け持ちます。

import { Request, Response, Router } from 'express';
import { usersModel } from '../models/users.model';

const usersRouter: Router = Router();

usersRouter.get('/', (request: Request, response: Response) => {
  usersModel.find({}, (err, users) => {
    if (err) throw err;
    response.json({users: users});
  });
});

export { usersRouter };

/api/users にアクセスがあったとき、DBからユーザ情報をfind してレスポンスとして返していることがわかりますね(モデルmodels/users.model.ts は省略します)。

こうしてできあがったサーバサイドのコードをトランスパイルするための設定ファイルをserver/tsconfig.jsonに書いていきます。

{
  "compilerOptions": {
    "declaration": false,
    "emitDecoratorMetadata": true,
    "experimentalDecorators": true,
    "lib": ["es6", "dom"],
    "module": "commonjs",
    "moduleResolution": "node",
    "outDir": "../dist/server",
    "target": "es6",
    "typeRoots": [
      "../node_modules/@types"
    ]
  }
}

トランスパイルの方法は、プロジェクトのルートディレクトリで

$ tsc -p ./server

とするだけです(tsc へのパスは適宜調整してください。グローバルへのインストールであればそのままtsc 、プロジェクトローカルであればnode_modules/.bin/tsc です。ここへPATH を通すのもよいかもしれません)。

その後、

$ node dist/server/bin/www.js

とすることでサーバが起動します。ただし、この状態ではdist/client 以下にファイルが存在しないので/ へのアクセスは404エラーとなります(ng serve などとして簡易サーバを使う場合、実際のファイルは出力されずオンメモリで動作します)。

APIへのアクセスは正しく動作することを確認しておきましょう。localhost:4300/api/users へとアクセスします。

APIから取得したデータ
APIから取得したデータ

よさそうですね。

AngularとExpress

それではいよいよAngularとExpressを連携させたいと思います。

meanapp
└── src
    └── app
        ├── components
        └── services

となるようにディレクトリを作成します。

AngularではAPIへのアクセスはServicが担当することになっていますので、まずはusers APIを扱うServiceを作ります。

$ cd src/app/services
$ ng g service CallExpressApi

としてCallExpressApiService を生成します(gはgenerateの略。部品を生成してくれます)。call-express-api.service.ts を次のように編集します。

import { Injectable } from '@angular/core';
import { Http } from '@angular/http';
import 'rxjs/add/operator/map';

@Injectable()
export class CallExpressApiService {

  constructor(
    private http: Http
  ) { }

  getUsers() {
    return this.http.get("api/users").map(response => response.json());
  }
}

このクラスはgetUsers() によりAPIからデータを取得します。

続いてこのServiceを利用するComponentを作成します。同じくng g を使って、

$ cd src/app/components
$ ng g component CallExpressApi

としてCallExpressApiComponent を生成し、call-express-api.component.ts を以下のように編集します(テンプレートは省略)。

import { Component, OnInit } from '@angular/core';
import { CallExpressApiService } from '../../services/call-express-api.service';

@Component({
  selector: 'app-call-express-api',
  templateUrl: './call-express-api.component.html',
  styleUrls: ['./call-express-api.component.css'],
  providers: [CallExpressApiService]
})
export class CallExpressApiComponent implements OnInit {

  users: any;

  constructor(
    private callExpressApiService: CallExpressApiService
  ) { }

  ngOnInit() {
    this.callExpressApiService.getUsers().subscribe(u => {
      this.users = u.users;
    })
  }

}

そしてこのコンポーネントへのアクセスはapp-routing.modules.ts にroutes として記述します。

import { NgModule } from '@angular/core';
import { Routes, RouterModule } from '@angular/router';

import { CallExpressApiComponent } from './components/call-express-api/call-express-api.component';

const routes: Routes = [
  {path: 'users', component: CallExpressApiComponent},
];

@NgModule({
  imports: [RouterModule.forRoot(routes)],
  exports: [RouterModule]
})
export class AppRoutingModule { }

ただし、これをng serve で動かす場合、クライアントサイドはlocalhost:4200 、サーバサイドはlocalhost:4300 で動作することになり、APIサーバへアクセスすることができません(ポートを指定してもオリジンが異なるのでクロスオリジンとなりエラー)。ここではng の実行時にプロキシを通し、/api 以下へのアクセスをサーバサイドに流しましょう。

プロジェクトルートにproxy.conf.json を用意し、

{
  "/api": {
    "target": "http://localhost:4300",
    "secure": false
  }
}
$ ng serve --proxy-config proxy.conf.json

とします。

これでlocalhost:4200/users へアクセスすると……

クライアント側からAPIにアクセス
クライアント側からAPIにアクセス

はい!できました!!(実際はこのままではうまくいかないと思います。このハマりポイントは後ほど。)

自動化

通常、このようなプロジェクトの開発ではgulpgruntのようなタスクランナを使うことがほとんだと思います。しかし、よくわからないのでここではpackage.json にnpm-script を書いて対応します。

まずは必要なライブラリをインストールしましょう。

$ npm install --save-dev npm-run-all
$ npm install --save-dev nodemon

その後、package.json を

"scripts": {
    "start": "run-s tsc start:p",
    "tsc": "tsc -p ./server",
    "start:p": "run-p start:mongo watch:*",
    "start:mongo": "mongod --dbpath mongo/data",
    "watch:ng": "ng serve --proxy-config proxy.conf.json",
    "watch:tsc": "tsc -w -p ./server",
    "watch:nodemon": "nodemon dist/server/bin/www.js"
}

のように編集します。この状態で

$ npm run start

すると、サーバ側のスクリプトがビルド(トランスパイル)され、dist/server 以下に配置され、MongoDBが起動し(なおDBはmongo/data 以下に配置することにしました)、ng により簡易サーバがプロキシ付きで起動し、そしてサーバ側がnodemon により待受状態になります。ng serve およびtsc -w によりサーバサイド、クライアントサイドともにファイルに変更があった場合は自動でトランスパイルが走ります

本番環境向けにビルドしたい!という場合には

"scripts": {
    "build": "run-p tsc build:ng",
    "build:ng": "ng build --prod"
}

のようなスクリプトを追加し、

$ npm run build

とすればよいです。dist/server 以下にサーバサイドのスクリプトが、dist/client 以下にクライアントサイドのスクリプトが生成されます。

なお、ビルド後は(ng serve 等しない限りは)サーバ側からもdist/client 以下のファイルが見えるようになりますので

$ node dist/server/bin/www.js

とすることでlocalhost:4300 にアクセスしてアプリを使うことができます。ng serve すると、dist/client 以下はすべて削除され、やはりオンメモリでの動作になるのでサーバ側からはdist/client/index.html 等が参照できません。

 

はい。というわけで、MEANスタックのAをAngularに置き換えてWebアプリが作成できました!!思ったよりも長くなってしまいましたが、コードの記述量はそれほど多くなく、かなり楽なんだなぁという印象ですね。

ここから、わたしがハマったところを解決策付きで解説していきます!

ハマりポイント

HttpModule

トップページから/users にアクセスしてもAPIへのアクセスがされていないように見えます……というかそもそもブラウザのURLを見てもなにも変化がありません。DBもサーバも動いているのになぜでしょうか。

これはモジュールが足りないことによる問題のようです。まずCallExpressApiComponent はたしかにCallExpressApiService を通してAPIを叩くのですが、HttpModule をインポートしておかないとServiceで利用しているHTTPクライアントが動作しない(?)ようです。結果として404エラーとなります。ここでng serve の余計なお世話親切な機能により404は/ へとリダイレクトされます(この挙動自体はクライアントサイドアプリではスタンダードなようですが)。そのためこのようにURLに変化もなく、正しく表示されない……ということになります。

ですので、app.module.ts に

import { HttpModule } from '@angular/http';

を追加し、さらに

@NgModule({
  declarations: [
    AppComponent,
    CallExpressApiComponent
  ],
  imports: [
    BrowserModule,
    AppRoutingModule,
+   HttpModule
  ],
  providers: [],
  bootstrap: [AppComponent]
})
export class AppModule { }

としてモジュールをインポートすればOKです。

MongoDBの罠

現在、MongoDBへの接続はこのようになっていると思います。

const server = http.createServer(app);
server.listen(port, () => {
  mongoose.connect(mongoUri);
});

が、このまま実行すると、

(node:18302) DeprecationWarning: `open()` is deprecated in mongoose >= 4.11.0, use `openUri()` instead, or set the `useMongoClient` option if using `connect()` or `createConnection()`

という警告が!!

このメッセージの言うとおりにmongoose.connect(mongoUri, { useMongoClient: true }); としてみると、確かに警告は消えるのですが、なんとモデルデータが取得できなくなってしまいました……(なぜかリストの だけは表示されてますが)

データが取得できない
データが取得できない

気持ちは悪いですが元のコードに戻しましょう。

どうやらこれはバグのようなので、警告が出るのはあきらめる、バージョンを下げる、修正されるのを待つ、あるいは、パッチを書いてPR送るなどしましょう。

[#5399] DeprecationWarning: `open()` is deprecated in mongoose >= 4.11.0, use `openUri()` instead … | Automattic/mongoose | Github

Pug使いたいんですけど

HTMLを書く代わりにPugで生成させたくなってきましたよね?

しかしここまで記事のとおりに来ていると……

$ ng eject
Your package.json scripts must not contain a start script as it will be overwritten.

まずng eject が通りません。

メッセージにあるように、package.jsonstart スクリプトを書いていることが原因です。eject 時にng ではなくwebpack が直接使われることになる関係上、ここを変更しているとダメなようです……ここでは単純に

  "scripts": {
    "ng": "ng",
    "build": "ng build",
    "test": "ng test",
    "lint": "ng lint",
    "e2e": "ng e2e",
-   "start": "run-s tsc start:p"
    "tsc": "tsc -p ./server",
    "start:p": "run-p start:mongo watch:*",
    "start:mongo": "mongod --dbpath mongo/data",
    "watch:ng": "ng serve --proxy-config proxy.conf.json",
    "watch:tsc": "tsc -w -p ./server",
    "watch:nodemon": "nodemon dist/server/bin/www.js"
  },

とでもしてしまいましょう。

ここであらためてeject ng すると、無事webpack.config.json が生成されます。

また、package.json が変更されるので編集しておきます。

  "scripts": {
    "ng": "ng",
    "build": "webpack",
    "test": "karma start ./karma.conf.js",
    "lint": "ng lint",
    "e2e": "protractor ./protractor.conf.js",
    "tsc": "tsc -p ./server",
    "start:p": "run-p start:mongo watch:*",
    "start:mongo": "mongod --dbpath mongo/data",
-   "watch:ng": "ng serve --proxy-config proxy.conf.json",
    "watch:tsc": "tsc -w -p ./server",
    "watch:nodemon": "nodemon dist/server/bin/www.js",
-   "start": "webpack-dev-server --port=4200",
+   "start": "run-s tsc start:p"
+   "watch:wp": "webpack-dev-server --port=4200",
    "pree2e": "webdriver-manager update --standalone false --gecko false --quiet"
  },

あとはAngular2で高速SPA開発(1)で紹介したようにテンプレートを書き換えるとちゃんと動作しますね!

Webpackでもプロキシ

上に書かれているとおりにng eject し、Webpackでも動作させられるようにはなりました。しかし、今度はプロキシの問題が……Webpackでプロキシを使うには、webpack.config.js に設定を書く必要があります。

"devServer": {
  "historyApiFallback": true,
+ "proxy": {
+   "/api": {
+     "target": "http://localhost:4300",
+     "secure": false
+   }
+ }
}

中身はproxy.config.json と同じものです。

これでWebpack経由でもプロキシを通してAPIにアクセスできます。

おわりに

とりあえずフロント、バック、DBの連携はできましたね。あとはもうちょっとこう、アプリケーションぽくなにかできたらいいかなと思いますそしてやっぱりSPAには触れられていません

次回は(あれば)そのあたりも考えてなにか作ってみたいと思います。

お楽しみに。では。