Fork me on GitHub

Gatsby Starter Qiita

フロントエンド初心者がMEANスタック(MongoDB+Express+Angular+Node.js)でアプリを作ってみて躓いたこと

2017/12/12

Takumon
Takumon
SIer

FUJITSU Advent Calendar 2017 12日目の記事です。

リッチでイマドキなデザインのアプリが作りたくて、
ここ4ヶ月ほどMEANスタック(MongoDB+Express+Angular+Node.js)でブログアプリを作っています。
知識ゼロからのスタートでしたが、多くの方々がブログやStackOverFlowに情報を載せてくれているので、躓きながらもなんとかアプリ開発を進めることができました。

この記事では、フロントエンド初心者の自分がMEANスタックでアプリを作る時に躓いたことや、こういう機能を実現するにはどうすればいいか?などをまとめています。
これからをAngularを学ぼうとしている方、ExpressやMongoDBなどサーバーサイドJavaScriptを学ぼうとしている方の参考になればうれしいです。

アプリの紹介

本線から脱線しますが、イントラでの使用を想定したQiitaのようなブログアプリです。デモ環境もあるのでよければ触ってみてください(モバイルには未対応ですが。。。)

アプリキャプチャ その1 (記事詳細)
appdemo_detail.png

アプリキャプチャ その2 (記事一覧)
アプリ_スクリーンキャプチャ_記事一覧.png

アプリキャプチャ その3 (プロフィール)
アプリ_スクリーンキャプチャ_ ユーザ画面.png

1. フロント側Angularまわり

Angularについて調べる時に古い情報を除外したい

1系はAngularJS、2系以降はAngularと呼ばれており、1系と2系以降では大きく仕様が異なります。
そのため検索する時はAngualr2などバージョンを指定したり、1系を除外するため--AngularJSをつけたりすると検索しやすいです。

HTMLのDOM要素を、別のDOM要素またはComponentから扱いたい

要素に#xxxxxのように#始まりの名前をつけると、別のDOM要素から参照できます

HTML
<input #phone placeholder="電話番号"/>
<!--  他のDOM要素からphonという変数名でDOM要素を参照できるようになる -->
<button >(click)="callPhone(phone.value)">

Componentから参照する場合は@ViewChildを使います

Component
  // ViewChildの引数に名前を文字列で指定します
  @ViewChild('phone') phoneElement: phoneElement;

  showPhoneValue() {
    console.log(this.phoneElement.value);
    )
  }

参考サイト

Routing時の認証を非同期で行いたい

URLごとの認証はCanActivateインターフェースを実装すればできますが、
サーバから認証情報を取得して非同期で認証したい場合もあると思います。
そのような時は、CanActivate#canActivatebooleanの代わりにObservableを戻り値に指定することで実現できます。

SampleAuthGuard
import { Injectable } from '@angular/core';
import { Router, CanActivate, ActivatedRouteSnapshot, RouterStateSnapshot } from '@angular/router';
import { Observable } from 'rxjs/Rx';

import { AuthenticationService } from './authentication.service';

@Injectable()
export class SampleAuthGuard implements CanActivate {

  constructor(
    private auth: AuthenticationService,
  ) { }

  // booleanではなくObservable<boolean>を戻り値で返す
  canActivate(route: ActivatedRouteSnapshot, state: RouterStateSnapshot): Observable<boolean> {
    return this.auth.checkState()
      .map(res => true)
      .catch(err => Observable.of(false))
  }
}

参考サイト

FormArrayの値を初期化したい

FormControlはpatchValueで初期値を設定できますが、
FormArrayの場合patchValueで配列の値を設定しようとしても設定できません。
こういう場合は、配列1つ1つの値をもとにFormControlを生成してFormArrayにpushします。

ダメな例
ngOnInit() {
  // Form生成
  this.form = this.formBuilder.group({
    schoolName: '',
    students: this.formBuilder.array([])
  });

  // Formに初期値を設定
  this.form.pathValue({
    schooleName: 'SampleSchoolName' // FormControlの値の初期化はpatchValueで可能
    students: ['taro', 'jiro', 'saburo']; // FormArrayに要素を追加する場合patchValueでは不可能
  });
}
良い例
ngOnInit() {
  // Formを生成
  this.form = this.formBuilder.group({
    schoolName: '',
    students: this.formBuilder.array([])
  });

  // Formに初期値を設定
  this.form.pathValue({
    schooleName: 'SampleSchoolName'
  });

  // データからFormControlを生成し1件1件FormArrayにpushする
  ['taro', 'jiro', 'saburo'].forEach(student -> {
    this.form.controls['students'].push(new FormControll(student));
  });
}

参考サイト

「ERROR Error: No provider for TemplateRef!」というエラー

最初このエラーが出た時は、何が原因なのかわからずに困りました。。。
大抵の場合は、*ngIf*ngForm*が抜けてることが原因です(要するにただのタイポです。。。)
*が抜けると、AngularはngIfをディレクティブとして解釈しようとしますが、
そんなディレクティブは存在しないのでNo provider for TemplateRef!と言われてしまうそうです。

参考サイト

textareaにおいてTabキーでインデントしたい

文書を入力するようなテキストエリアの場合に、Tabキーでのインデントしたい場合は、
kyedownイベント発生時にテキストエリアの値とキャレットの位置を操作することで実現可能です。

HTML
<textarea #sampletextarea
  (keydown)="indent($event, sampletextarea)" ></textarea>
Component
  indent($event, sampleTextAreaElement) {
    // Tabキー押下時
    if ($event.keyCode === 9) {
      // 次の要素にフォーカスが移らないようにする
      $event.preventDefault();

      // 現在のキャレット位置を取得
      const caretStart = textareaElement.selectionStart;
      const caretEnd = textareaElement.selectionEnd;

      // テキストエリアの値を取得し、キャレット位置にTabを挿入
      const TAB = '¥t';
      sampleTextAreaElement.value = sampleTextAreaElement.value.substring(0, caretStart)
                     + TAB + sampleTextAreaElement.value.substring(caretStart, value.length);

      // キャレット位置をTab分ずらす
      sampleTextAreaElement.focus();
      sampleTextAreaElement.setSelectionRange(caretStart + TAB.length, caretEnd + TAB.length);

      return;
    }
  }

Markdownプレビューを表示したい、ソースコードはシンタックスハイライトさせたい

markedhighlight.jsを組み合わせて使います。
markedのReadmeを見ればなんとなくわかりますが、Angularの仕組みに乗せる必要があるのでPipeやらServiceやらを作ります。

markdown-parse.service.ts
import { Injectable } from '@angular/core';
import marked from 'marked';
import hljs from 'highlight.js';


@Injectable()
export class MarkdownParseService {

  constructor() {
    marked.setOptions({
      highlight: function (code) {
        return hljs.highlightAuto(code).value;
      }
    });
  }

  parse(rawText: string) {
    return marked(rawText);
  }
}
markdown.pipe.ts
import marked from 'marked';
import { Pipe, PipeTransform } from '@angular/core';
import { MarkdownParseService } from './markdown-parse.service';

@Pipe({ name: 'toMarkdown' })
export class MarkdownParsePipe implements PipeTransform {
  constructor(markdownParseService: MarkdownParseService) {}

  transform(value: string): any {
    return this.markdownParseService.parse(value);
  }
}

HTMLで下記のように指定します。{{}}だとサニタイズされてしまうのでinnerHTML属性を指定します。

<div [innerHTML]="md | toMarkdown"></div>

参考サイト

絞り込み条件付きリストにおいて、リストの要素が変更、追加、削除された時に絞り込み結果をリフレッシュしたい

リストの絞り込みはPipeで実現しますが、通常Pipeはリストの要素が変更されても再び絞り込みが実施されることはありません。
このような場合はPipeアノテーションにてpureオプションをfalseに設定ましょう。

HTML
<input type="text" #searchUserName>
<ul>
  <li *ngFor="let user of (userList | searchUserFilter: searchUserName.value);" >{{user.name}}</li>
<ul>
search-user.pipe.ts
import { Pipe, PipeTransform } from '@angular/core';
import { User } from './user';

@Pipe({
  name: 'searchUserFilter',
  pure: false // pureをfalseにすることでユーザが変更、追加、削除された時にリフレッシュできる
})
export class SearchFilterPipe implements PipeTransform {
  transform(items: Array<User>, searchUserName: string): any[] {
    if (!searchUserName) return items;

    searchUserName = searchUserName.toLowerCase();
    return items.filter( item => item.user.userId.toLowerCase().includes(searchUserName));
  }
}

参考サイト

グローバル定数を定義したい

いろんなクラスで使う定数を共通化する時は、単純にクラスを作ってstaticなメンバとして定数を定義します。

app-settings.ts
export class AppSettings {
   public static API_ENDPOINT='http://127.0.0.1:6666/api/';
}
SampleService
import {Injectable} from 'angular2/core';
import {AppSettings} from './app-settings';

@Injectable()
export class SampleService {
    sampleMethod() {
      console.log(AppSettings.API_ENDPOINT);
    }
}

参考

画像が多い画面の初期表示を早くしたい

ng-lazyload-imageを使えば画像の遅延ロードを実現できます。
使い方もとても簡単でimgタグにディレクティブを指定するだけです。

HTML
 <img
  [defaultImage]="https://images.sample.com/photo/defaultimage" 
  [lazyLoad]="https://images.sample.com/photo/sampleimage"
  [offset]="30"
 >

defaultImage
 即時ロードされる画像のURL、遅延ロードする画像を読み込む間、表示される
lazyLoad
 遅延ロードする画像のURL
offset
 スクロールが発生する場合に画面下部の何ピクセル下に来た時にロードを開始するか
errorImage
 遅延ロード失敗時に表示する画像URL

参考サイト

Angular Cliのng serveコマンドでdistフォルダを一旦削除したくない

ng serveコマンドはdistフォルダを削除してからtsファイルをトランスコンパイルします。
それを防ぐためには、delete-output-pathオプションをfalseに指定します。

package.json ビルドスクリプト
"script": {
  "build": "cp ./resource/* dist && ng serve --delete-output-path=false"
}

参考サイト

AOTコンパイルが遅いのでなんとかしたい

なんとかできませんでした。。。(もしかしたら方法があるのかもしれません。誰か教えてください!!!)
AOTコンパイルはJITコンパイルが検出してくれないHTMLのエラーを警告してくれますが、その反面遅いです。特にAngular Materialを使う場合は顕著です。
そのため、自分の場合は基本的にJITビルドを使い、issueのプルリクをする前など区切りのいいタイミングでAOTビルドしてエラーがないか確認するというように使い分けてました。

2. フロント側Angularでのテスト周り

CI環境などでテストが終わらずにタイムアウトしてしまう

CirleCiなどでテストを実行する場合ng testコマンドだとプロセスが終了しないためタイムアウトで失敗してしまいます。
このような場合はwatchオプションをfalseに設定します。

ng test --wtach=false

参考サイト
Github isssue

テスト時に「Error: Timeout - Async callback was not invoked within timeout specified by jasmine.DEFAULT_TIMEOUT_INTERVAL.」や「Failed to execute 'send' on 'XMLHttpRequest'」のエラー

このような場合は、一時的にng testコマンドのオプションに-sm=falseを追加してテストし直すと根本原因エラーメッセージで出力されるようになります。
大抵の場合は自作したモッククラスに必要なメソッドがないことが原因です。

参考サイト

テスト用に子コンポーネントをモック化したい

意外と簡単で、TestBed#configureTestingModuleで
declarationsに自作したモックの子コンポーネントを追加するだけです。
input,outputがあれば必要に応じてメンバ定義します。

// ※import文は省略

// モックの子コンポーネントを定義
@Component({
  selector: 'app-child', // 子コンポーネントと同じものを定義
  template: '<p>Mock Child Component</p>'
})
class MockClildComponent {
  @Input() childInput: string;
  @Output() childOutput = new EventEmitter();
}

// ...

beforeEach(() => {
  TestBed.configureTestingModule({
    declarations: [
      // テスト時のdeclarationsにモックの定義を追加
      MockProductSettingsComponent,
      // ...
    ],
    // ...
  });
  // ...
});

参考サイト

テスト結果にAngular Materilaのスタイルが反映されない

UIフレームワークでAngular Materialを使っている場合、
karma.config.jsでAngular Materilaのcssを直接読み込んであげる必要あります。

karma.conf.js
files: [
      ・・・
      // Angular Materialのスタイルをテスト開始時に読み込んでおく
      {pattern: './node_modules/@angular/material/prebuilt-themes/indigo-pink.css', included: true, watched: false},
],

参考サイト

テスト結果にstyles.scss(アプリ共通のスタイル定義)のスタイルが反映されない

アプリ共通スタイルをSASS形式にしている場合
開発用ライブライにkarma-scss-preprocessornode-sassを追加してkarma.conf.jsを下記のように設定します。

karma.conf.js
plugins: [
  ・・・
  // プラグインに`karma-scss-preprocessor`を追加
  require('karma-scss-preprocessor')
],


files: [
  ・・・
  // filesにアプリ共通スタイルを追加
  { pattern: './src/styles.scss', watched: false,  included: true, served: true }
],

// preprocessorsを追加
preprocessors: {
 './src/test.ts': ['@angular/cli'],
 './src/styles.scss': ['scss']
},

参考サイト:

3. バックエンド側 Express、MongoDB周り

※MongDBをNode.jsで扱う場合はmongooseという便利なライブラリがあるのでそれを使う前提のお話です。

mongooseのvirtualメソッドを使う

例えば記事,コメント,リプライなどのモデルを定義する場合、
3つのモデルのライフサイクルは、記事追加 => 記事に対するコメント追加 => コメントに対するリプライ追加 のようになります。
このような場合は、コメントが記事の参照を持ち、リプライがコメントの参照を持つモデル構造が望ましいです。コメントやリプライ追加時に1つのモデルの更新だけですむからです。
db構造_良.png

ただ記事の検索は少し工夫が必要で、mongooseのvirtualを使います。これによって記事からコメントへの参照、コメントからリプライへの参照を擬似的に定義できるため、
検索時に擬似要素をpopulateするだけで、記事に紐付くコメントとリプライを取得できるようになります。
mongoose virtualのイメージ.png

具体的なソースコードを示します。

article.model.ts
import * as mongoose from 'mongoose';

const ArticleSchema = new mongoose.Schema({
  content: String
}, { toJSON: { virtuals: true } });


// 記事に紐付くコメントモデルの配列をcommentsという擬似要素で定義する
ArticleSchema.virtual('comments', {
  ref: 'Comment',
  localField: '_id',
  foreignField: 'articleId',
  justOne: false,
});

const Article = mongoose.model('Article', ArticleSchema);

export { Article };
comment.model.ts
import * as mongoose from 'mongoose';

const CommentSchema = new mongoose.Schema({
  articleId: {
    type: mongoose.Schema.Types.ObjectId,
    ref: 'Article',
  },
  comment: String;
}, { toJSON: { virtuals: true } });

// コメントに紐付くリプライモデルの配列をrepliesという擬似要素で定義する
CommentSchema.virtual('replies', {
  ref: 'Reply',
  localField: '_id',
  foreignField: 'commentId',
  justOne: false,
});

const Comment = mongoose.model('Comment', CommentSchema);

export { Comment };
reply.model.ts
import * as mongoose from 'mongoose';

const ReplySchema = new mongoose.Schema({
  commentId: {
    type: mongoose.Schema.Types.ObjectId,
    ref: 'Comment'
  },
  reply: String;
});

const Reply = mongoose.model('Reply', ReplySchema);

export { Reply };
検索処理
Article.find()
.populate({
  path: 'comments', // Articleモデルで定義した擬似要素commentsをpopulateする
  populate: [{
    path: 'replies', // Commentモデルで定義した擬似要素repliesをpopulateする
  }],
});

リクエスト、レスポンスのログ出力したい

Expressのuseにて実現します。
レスポンスオブジェクトのfinishイベントを監視することで、レスポンス時にログを出力しています。

リクエストとレスポンスのログ出力 ※ここでは簡単のためコンソールに出力しています
const express = express();
express.use(function accessLogHandler (req, res, next) {
  const start = new Date();
  // リクエスト時のログ 
  console.log([
    'start',
    req.headers['x-forwarded-for'] || req.connection.remoteAddress,
    req.method,
    req.url,
    '-',
    req.headers.referer || '-',
    req.headers['user-agent'] || '-',
    '--ms--'
  ].join(',\t'));

  res.once('finish', function() {
   // レスポンス時のログ
    accessLogger.info([
      'end',
      req.headers['x-forwarded-for'] || req.connection.remoteAddress,
      req.method,
      req.url,
      res.statusCode,
      req.headers.referer || '-',
      req.headers['user-agent'] || '-',
      '--' + (new Date().getMilliseconds() - start.getMilliseconds()) + 'ms--'
    ].join(',\t'));
  });

  next();
});

実際のログはこんな感じで出力されます。

start,  ::ffff:127.0.0.1, GET, /api/authenticate/check-state,   -, http://localhost:4200/, Mozilla/5.0 (Macintosh; Intel Mac OS X 10_12_6) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/62.0.3202.94 Safari/537.36, --ms--
end,    ::ffff:127.0.0.1, GET, /check-state,                  403, http://localhost:4200/, Mozilla/5.0 (Macintosh; Intel Mac OS X 10_12_6) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/62.0.3202.94 Safari/537.36,  --47ms--

ログ出力でオブジェクトの全プロパティを出力したい

オブジェクトの中身をログで確認する時はNode.jsのutil.inspect()を使います。

const util = require('util');
const myObject = {
   "a":"a",
   "b":{
      "c":"c",
      "d":{
         "e":"e",
         "f":{
            "g":"g",
            "h":{
               "i":"i"
            }
         }
      }
   }
}; 

console.log(util.inspect(myObject, false, null));

このようなログが出力されます

{ a: 'a', b: { c: 'c', d: { e: 'e', f: [Object] } } }

参考サイト

環境変数で上書きできる定数を定義したい

例えばサーバのポートなど環境個別に設定したくなるようなものは
環境変数で上書きできる定数にしておくと便利です。
Node.jsではprocess.env.変数名で環境変数が参照できるので下記のようにします。

定数定義
export const SERVER_PORT: string = process.env.SERVER_PORT || '3000'; // 環境変数SERVER_PORTが未指定の場合は3000となる
export const SERVER_HOST: string = process.env.SERVER_HOST || 'localhost'; // 環境変数SERVER_HOSTが未指定の場合はlocalhostとなる

DB初回アクセスに失敗した場合にリトライされない

mongooseを使っていると、DB初回接続時にエラーが起きた時に、なぜか再接続処理してくれません。
Dockerでアプリを配布する場合、アプリとMongoDBをdocker-composeで同時に立ち上げることがあると思いますが、まだMongoDBが起動しきってない状態でアプリからDBに接続しようとすると、アプリが異常終了してしまいます。これを防ぐには自力で再接続処理を実装する必要があります。

function createConnection (dbURL, options) {
    var db = mongoose.createConnection(dbURL, options);

    db.on('error', function (err) {
        if (err.message && err.message.match(/failed to connect to server .* on first connect/)) {
            console.log(new Date(), String(err));

            setTimeout(function () {
                console.log("Retrying first connect...");
                db.openUri(dbURL).catch(() => {});
            // 20秒後に再接続する
            }, 20 * 1000);
        } else {
            console.error(new Date(), String(err));
        }
    });

    db.once('open', function () {
        console.log("Connection to db established.");
    });

    return db;
}

参考サイト