Fork me on GitHub

Gatsby Starter Qiita

Angular4 + Express4 + MongoDB3 + TypeScript2 の最小構成プロジェクトをAngular CLIベースで構築する。(ビルド、テスト、Dockerデプロイまで) その1. ビルド編

2017/07/19

Takumon
Takumon
SIer

やりたいこと

  • Angular CLI使って、MEANスタック(MongoDB + Express + Angular + NodeJS)のアプリを作りたい。どうせならサーバ側もTypeScriptで作りたい。
  • フロント側とサーバ側の両方をwebpack、gulpなどは使わずにnpm scriptsだけでビルド、テストできるようにしたい。
  • Dockerを使ってアプリを簡単に配布したい。

これらを達成するための最小構成プロジェクトの作り方を3回に分けて紹介します。

その1. ビルド編

Angular CLIで作成したプロジェクトをベースに、
MongoDBに登録したメッセージを画面に一覧で表示するアプリを作成していきます。
メッセージを登録すると一覧に追加されていくようなアプリです。

アプリ概要.png

プロジェクト構成

今回のチュートリアル終了すると下記のようなプロジェクトの構成になります。
リポジトリも用意しているので詳細はそちらを参照してください。

プロジェクトの構成(完成イメージ)
.
├── dist                ・・・(1) コンパイル資産出力先
│   └── server              ・・・(1-1) コンパイルされたサーバ資産
│       ├── app.js
│       ├── app.js.map
│       ├── bin
│       │   ├── www.js
│       │   └── www.js.map
│       ├── config.js
│       ├── config.js.map
│       ├── models
│       │   ├── message.js
│       │   └── message.js.map
│       ├── public      ・・・(1-2) コンパイルされたクライアント資産
│       │   ├── favicon.ico
│       │   ├── index.html
│       │   ├── inline.bundle.js
│       │   ├── inline.bundle.js.map
│       │   ├── main.bundle.js
│       │   ├── main.bundle.js.map
│       │   ├── polyfills.bundle.js
│       │   ├── polyfills.bundle.js.map
│       │   ├── styles.bundle.js
│       │   ├── styles.bundle.js.map
│       │   ├── vendor.bundle.js
│       │   └── vendor.bundle.js.map
│       └── routes
│           ├── message.js
│           └── message.js.map
├── node_modules
│   ├── ...
│   ...
│
├── e2e
│   ├── app.e2e-spec.ts
│   ├── app.po.ts
│   └── tsconfig.e2e.json
├── server               ・・・(2) サーバ資産
│   ├── app.ts
│   ├── bin
│   │   └── www.ts
│   ├── config.ts
│   ├── models
│   │   └── message.ts
│   ├── routes
│   │   └── message.ts
│   └── tsconfig.server.json
├── src                   ・・・(3) クライアント資産
│   ├── app
│   │   ├── app.component.css
│   │   ├── app.component.html
│   │   ├── app.component.spec.ts
│   │   ├── app.component.ts
│   │   ├── app.module.ts
│   │   └── message
│   │       └── message.service.ts
│   ├── assets
│   ├── environments
│   │   ├── environment.prod.ts
│   │   └── environment.ts
│   ├── favicon.ico
│   ├── index.html
│   ├── main.ts
│   ├── polyfills.ts
│   ├── styles.css
│   ├── test.ts
│   ├── tsconfig.app.json
│   ├── tsconfig.spec.json
│   └── typings.d.ts
├── karma.conf.js
├── package-lock.json
├── package.json
├── protractor.conf.js
├── proxy.conf.json    ・・・(4) 
├── tsconfig.json
├── tslint.json
└── README.md

各資産について

(1) dist

コンパイルした資産の出力先フォルダ

(1-1) dist/server

ここにサーバ側のコンパイルされたjsファイルが出力されます。
serverフォルダを設けているのは本資産とテスト資産を分離したかったからです。
その2. テスト編で説明しますが、サーバ側テスト用jsファイルはdist配下のserver_testフォルダに出力されるようにしています。

(1-2) dist/server/public

コンパイルされたクライアント資産。
サーバ側アプリの資産の一部としてコンパイルされるようにしています。
Expressのアプリでは静的資産をpublicフォルダに置くのが一般的なのでこうしました。

(2) server

サーバ資産を格納するためのディレクトリ。
いろいろ悩みましたが、TypeScript資産をコンパイルすることとテストすることを考慮してこのような構成にしました。

(3) src

フロントの実行資産とテスト資産を格納するためのディレクリ。
Angular CLIでプロジェクトを作成するとデフォルトで作成されます。
ビルドやアプリ起動はngコマンドで実施します。

(4) proxy.conf.json

npm startでフロント側とサーバ側を同時に起動した時に、クライアント側からサーバ側へのリクエストを送れるようにするためのプロキシ設定です。

構築手順

1. プロジェクト作成

  • Angular CLIをインストール

    $ npm install -g @angular/cli
    
  • プロジェクトを生成、Angular CLIであらかじめ定義している依存ライブラリをインストール

    $ ng new sample
    $ cd sample
    $ npm install
    
  • Angular CLIであらかじめ定義している依存ライブラリの他に必要なものををインストール


    express
    Webアプリケーションフレームワーク
    body-parser
    リクエストボディのパーサー
    mongoose
    MongoDBへのアクセスを簡単にしてくれるAPI
    nodemon
    node実行時にソースの変更を自動反映してくれるツール
    npm-run-all
    npm-scripts の連結実行を管理するためのパッケージ
    $ npm install --save express body-parser mongoose
    $ npm install --save-dev @types/mongoose nodemon npm-run-all
    

2. サーバ側を作成

Angular CLIで作ったプロジェクトの直下にserverフォルダを作って、その中にサーバ側の処理を書いていきます。

server/models/message.ts

MongoDBにアクセスするためのモデルを定義します。
DBアクセスにはmangoosを使います。

message.ts
import * as mongoose from 'mongoose';

const Message = mongoose.model('messages', new mongoose.Schema({
  message: {type: String}
}));

export { Message };

server/routes/message.ts

エンドポイントごとの処理を記述するルータを定義します。
メッセージの取得と登録にはserver/models/message.tsを使います。

message.ts
import * as http from 'http';
import { Router, Response } from 'express';
import { Message } from '../models/message';

const messageRouter: Router = Router();

// 全てのメッセージを取得する
messageRouter.get('/', (req, res, next) => {
  Message.find(function(err, doc) {
    if (err) {
      return res.status(500).json({
          title: 'エラーが発生しました。',
          error: err.message
      });
    }

    return res.status(200).json({messages: doc});
  });
});

// メッセージを登録する
messageRouter.post('/', (req, res, next) => {
  const message = new Message({
    message: req.body.message
  });

  message.save((err, result) => {
    if (err) {
      return res.status(500).json({
          title: 'エラーが発生しました。',
          error: err.message
      });
    }

    return res.status(200).json({
      message: 'メッセージを登録しました。',
      obj: result
    });
  });
});

export { messageRouter };

server/app.ts

Expressで使用するルータと依存モジュールを定義するためのファイルを作成します。
メッセージAPIのエンドポイントは/api/messagesに設定し、
mongooseを使ってMongoDBへの接続設定をしています。
クライアント資産はビルドするとpublicフォルダ配下に出力されるようにしているので、
静的資産へのルーティングはpublicフォルダを指定しています。

app.ts
import * as express from 'express';
import * as path from 'path';
import * as bodyParser from 'body-parser';
import * as mongoose from 'mongoose';

import { messageRouter } from './routes/message';
import { MONGO_URL } from './config';

class App {
  public express: express.Application;

  constructor() {
    this.express = express();
    this.middleware();
    this.routes();
  }

  private middleware(): void {
    this.express.use(bodyParser.json());
    this.express.use(bodyParser.urlencoded({ extended: false }));
    // 接続する MongoDB の設定
    mongoose.Promise = global.Promise;
    mongoose.connect(process.env.MONGO_URL || MONGO_URL, {
      useMongoClient: true,
    });
    process.on('SIGINT', function() { mongoose.disconnect(); });
  }

  private routes(): void {
    // 静的資産へのルーティング
    this.express.use(express.static(path.join(__dirname, 'public')));

    this.express.use('/api/messages', messageRouter);

    // その他のリクエストはindexファイルにルーティング
    this.express.get('*', (req, res) => {
      res.sendFile(path.join(__dirname, 'public/index.html'));
    });
  }
}

export default new App().express;

server/bin/www.ts

Node.js でサーバを起動するための設定ファイルを作成します。

www.ts
import * as http from 'http';
import { SERVER_PORT } from '../config';
import app from '../app';



// ポートの設定.
const port = normalizePort(process.env.PORT || SERVER_PORT);
app.set('port', port);


// HTTPサーバ生成.
const server = http.createServer(app);
server.listen(port, () => console.log(`API running on localhost:${port}`));
server.on('error', onError);
server.on('listening', onListening);

// ポートを正規化.
function normalizePort(val): number|string|boolean  {

  const normalizedPort: number = (typeof val === 'string')
    ? parseInt(val, 10)
    : val;

  if (isNaN(normalizedPort)) {
    return val;
  }

  if (normalizedPort >= 0) {
    return normalizedPort;
  }

  return false;
}

// エラーハンドラー.
function onError(error): void {
  if (error.syscall !== 'listen') {
    throw error;
  }

  const bind = typeof port === 'string'
    ? 'Pipe ' + port
    : 'Port ' + port;

  switch (error.code) {
    case 'EACCES':
      console.error(bind + ' requires elevated privileges');
      process.exit(1);
      break;
    case 'EADDRINUSE':
      console.error(bind + ' is already in use');
      process.exit(1);
      break;
    default:
      throw error;
  }
}

// サーバ起動時のリスナー.
function onListening(): void {
  const addr = server.address();
  const bind = (typeof addr === 'string')
    ? `pipe ${addr}`
    : `port ${addr.port}`;
}

server/config.ts

サーバ側の設定ファイルを作成します。
ポートとMongoDBのURLを定義しています。
今回MongoDBはローカルにポート27017で立てる想定です。

config.ts
export const SERVER_PORT = 3000;
export const MONGO_URL = 'mongodb://localhost:27017/test';

3. クライアント側を作成

Angular CLIでプロジェクトを作成すると最低限のクライアント資産が生成されるので、
ここでは修正が必要なファイル、新規作成するファイルのみ紹介します。

src/app/message/message.service.ts

サーバ側からメッセージを取得するためのサービスを新規作成します。

message.service.ts
import { Injectable } from '@angular/core';
import { Http, Response } from '@angular/http';
import { Observable } from 'rxjs/Rx';
import 'rxjs/Rx';

@Injectable()
export class MessageService {

  constructor(private http: Http) {}

  getAll(): Observable<any> {
    return this.http
        .get('/api/messages')
        .map((response: Response) => {
            const result = response.json();
            return result;
        })
        .catch((error: Response) => Observable.throw(error.json()));
  }

  regist(message: string): Observable<any> {
    return this.http
      .post('/api/messages', {message: message})
      .map((response: Response) => {
            const result = response.json();
            return result;
      })
      .catch((error: Response) => Observable.throw(error.json()));
  }
}

src/app/app.component.ts

既存のファイルを修正して、messagesを保持するようにします。
MessageServiceを使ってメッセージを取得します。

app.component.ts
import { Component, OnInit } from '@angular/core';
import { MessageService } from './message/message.service';

@Component({
  selector: 'app-root',
  templateUrl: './app.component.html',
  styleUrls: ['./app.component.css'],
  providers: [ MessageService ]
})
export class AppComponent {
  messages: Array<any>;
  message: string;

  constructor(private messageService: MessageService) {
    this.getMessages();
  }

  getMessages(): void {
    this.messageService
      .getAll()
      .subscribe((res: any) => {
        this.messages = res.messages;
      });
  }

  registerMessage(): void {
    if (!this.message) {
      return;
    }

    this.messageService
      .register(this.message)
      .subscribe((res: any) => {
        this.message = '';
        this.getMessages();
      });
  }
}

src/app/app.component.html

既存のファイルの修正して、メッセージ一覧と登録のUIに書き換えます。

app.component.html
<div>
  <div>
    <h1>メッセージ一覧</h1>
    <button id="getMessagesButton" (click)="getMessages()">メッセージ一覧を最新化</button>
    <ul id="messageList">
      <li *ngFor="let item of messages">
        {{item.message}}
      </li>
    </ul>
  </div>
  <div>
    <h1>メッセージ登録</h1>
    <input type="text" id="registerMessage" [(ngModel)]="message" placeholder="登録するメッセージを入力してください。">
    <button type="submit" id="registerMessageButton" (click)='registerMessage()'>登録</button>
  </div>
</div>

src/app/app.module.ts

HttpModule、FormsModule、MessageServiceを追加します。

app.module.ts
import { BrowserModule } from '@angular/platform-browser';
import { NgModule } from '@angular/core';
import { HttpModule } from '@angular/http';
import { FormsModule } from '@angular/forms';

import { AppComponent } from './app.component';
import { MessageService } from './message/message.service';

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


4. ビルドまわり環境を整備

pakcage.json

スクリプトを下記のように修正します。
(npm scriptsは便利ですが、コメントが記述できないのが残念だなーと思いました。)

package.json

  "scripts": {
    ...
    "start": "npm-run-all -s build:server -p start:*",
    "start:client": "ng serve --aot=true --progress=false --proxy-config proxy.conf.json",
    "start:server": "run-p watch:server boot:server",
    "watch:server": "tsc -w -p ./server/tsconfig.server.json",
    "boot:server": "nodemon ./dist/server/bin/www.js",
    "build": "run-s build:server build:client",
    "build:client": "ng build --output-path=./dist/server/public",
    "build:server": "tsc -p ./server/tsconfig.server.json",
    "buildRun": "run-s build boot:server",
    ...
  },
  • startでクライアント資産とサーバ資産の両方を起動します。
  • start:clientでクライアント資産をコンパイルして起動します。Angular CLIのngコマンドにお任せしています。なおstartではクライアント資産とサーバ資産で二つのサーバを起動するので、クライアントからサーバへ(リクエストを送れるようにプロキシ設定を行っています。プロキシ設定ファイルについては下で触れます。
  • start:serverでサーバ資産をコンパイルしてExpressを起動します。
  • watch:serverでサーバ側のTypeScriptをウォッチして変更があればコンパイルするようにします。
  • boot:serverでコンパイルしたサーバ側資産を起動します。nodeではなくnodemonを使うことでコンパイルしたサーバ資産に更新があった場合でも即座に更新を反映するようにしています。
  • buildクライアント資産とサーバ資産の両方をコンパイルします。
  • build:serverでサーバ資産をコンパイルしています。コンパイル時の設定は下で触れる/server/tsconfig.server.jsonを使います。
  • build:clientでクライアント資産をコンパイルしています。出力先はサーバ側資産の静的ファイル格納フォルダ(dist/server/public)を指定しています。
  • buildRunでクライアント資産とサーバ資産の両方をコンパイルしサーバ資産を起動します。とりあえずデプロイするアプリを起動したい時の便利コマンドです。

server/tsconfig.server.json

サーバ資産コンパイルときの設定ファイルを作成します。
outDirで出力先をdist/serverに指定しています。

tsconfig.server.json
{
  "extends": "../tsconfig.json",
  "compilerOptions": {
    "preserveConstEnums": true,
    "outDir": "../dist/server",
    "mapRoot": "../dist/server",
    "module": "commonjs"
  }
}

proxy.conf.json

startでクライアントとサーバの2つを起動した時に、クラ
イアントからサーバへのリクエストを送れるようにするためのプロキシ設定ファイルを作成します。
/api始まるリクエストをサーバへのリクエストとみなしてプロキシ設定を行います。

proxy.conf.json
{
  "/api": {
    "target": "http://localhost:3000",
    "secure": false
  }
}

MongoDBをローカルで立ち上げる

具体的な方法について触れませんが、Dockerでもなんでもいいのでローカルにポート27017でMongoDBを立ち上げておいてください。DB、テーブルの作成などは不要です。

5. 試してみる

アプリを起動してみる

  • プロジェクト直下で下記コマンドを実行するとアプリが起動します。

    $ npm start 
    
  • 起動したらhttp://localhost:4200にアクセスしてみます。すると下記のようにメッセージ一覧画面が表示され、メッセージを登録すると適宜一覧に追加されていきます。

アプリ概要.png

  • 試しにクライアント資産かサーバ資産を修正してみると、コンンパイルされてアプリに変更がリアルタイムに反映されることがわかります。

アプリをビルドしてみる

  • プロジェクト直下で下記コマンドを実行するとアプリがビルドされdistフォルダ配下に出力されます。

    $ npm run build
    

アプリをビルドして起動してみる

  • プロジェクト直下で下記コマンドを実行するとアプリがビルドされdistフォルダ配下に出力された後に起動されます。 ビルドしたアプリはhttp://localhost:3000でアクセスできます。 $ npm run buildRun

終わりに

今回はAngular CLIベースのプロジェクトをベースにしてMEANスタックの最小構成プロジェクトを構築する方法を紹介しました。プロジェクトを起動、ビルドすることはできるようになったので、次回「その2. テスト編」ではテストコードの作成とテスト実施環境の構築について紹介しようと思います。