2017/12/12
FUJITSU Advent Calendar 2017 12日目の記事です。
リッチでイマドキなデザインのアプリが作りたくて、
ここ4ヶ月ほどMEANスタック(MongoDB+Express+Angular+Node.js)でブログアプリを作っています。
知識ゼロからのスタートでしたが、多くの方々がブログやStackOverFlowに情報を載せてくれているので、躓きながらもなんとかアプリ開発を進めることができました。
この記事では、フロントエンド初心者の自分がMEANスタックでアプリを作る時に躓いたことや、こういう機能を実現するにはどうすればいいか?などをまとめています。
これからをAngularを学ぼうとしている方、ExpressやMongoDBなどサーバーサイドJavaScriptを学ぼうとしている方の参考になればうれしいです。
本線から脱線しますが、イントラでの使用を想定したQiitaのようなブログアプリです。デモ環境もあるのでよければ触ってみてください(モバイルには未対応ですが。。。)
1系はAngularJS
、2系以降はAngular
と呼ばれており、1系と2系以降では大きく仕様が異なります。
そのため検索する時はAngualr2
などバージョンを指定したり、1系を除外するため--AngularJS
をつけたりすると検索しやすいです。
要素に#xxxxx
のように#
始まりの名前をつけると、別のDOM要素から参照できます
<input #phone placeholder="電話番号"/>
<!-- 他のDOM要素からphonという変数名でDOM要素を参照できるようになる -->
<button >(click)="callPhone(phone.value)">
Componentから参照する場合は@ViewChild
を使います
// ViewChildの引数に名前を文字列で指定します
@ViewChild('phone') phoneElement: phoneElement;
showPhoneValue() {
console.log(this.phoneElement.value);
)
}
参考サイト
URLごとの認証はCanActivate
インターフェースを実装すればできますが、
サーバから認証情報を取得して非同期で認証したい場合もあると思います。
そのような時は、CanActivate#canActivate
でbooleanの代わりにObservableを戻り値に指定することで実現できます。
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))
}
}
参考サイト
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));
});
}
参考サイト
最初このエラーが出た時は、何が原因なのかわからずに困りました。。。
大抵の場合は、*ngIf
や*ngForm
の*
が抜けてることが原因です(要するにただのタイポです。。。)
*
が抜けると、AngularはngIf
をディレクティブとして解釈しようとしますが、
そんなディレクティブは存在しないのでNo provider for TemplateRef!
と言われてしまうそうです。
参考サイト
文書を入力するようなテキストエリアの場合に、Tabキーでのインデントしたい場合は、
kyedownイベント発生時にテキストエリアの値とキャレットの位置を操作することで実現可能です。
<textarea #sampletextarea
(keydown)="indent($event, sampletextarea)" ></textarea>
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;
}
}
markedとhighlight.jsを組み合わせて使います。
markedのReadmeを見ればなんとなくわかりますが、Angularの仕組みに乗せる必要があるのでPipeやらServiceやらを作ります。
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);
}
}
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に設定ましょう。
<input type="text" #searchUserName>
<ul>
<li *ngFor="let user of (userList | searchUserFilter: searchUserName.value);" >{{user.name}}</li>
<ul>
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なメンバとして定数を定義します。
export class AppSettings {
public static API_ENDPOINT='http://127.0.0.1:6666/api/';
}
import {Injectable} from 'angular2/core';
import {AppSettings} from './app-settings';
@Injectable()
export class SampleService {
sampleMethod() {
console.log(AppSettings.API_ENDPOINT);
}
}
参考
ng-lazyload-imageを使えば画像の遅延ロードを実現できます。
使い方もとても簡単でimgタグにディレクティブを指定するだけです。
<img
[defaultImage]="https://images.sample.com/photo/defaultimage"
[lazyLoad]="https://images.sample.com/photo/sampleimage"
[offset]="30"
>
defaultImage
即時ロードされる画像のURL、遅延ロードする画像を読み込む間、表示される
lazyLoad
遅延ロードする画像のURL
offset
スクロールが発生する場合に画面下部の何ピクセル下に来た時にロードを開始するか
errorImage
遅延ロード失敗時に表示する画像URL
参考サイト
ng serve
コマンドでdistフォルダを一旦削除したくないng serve
コマンドはdist
フォルダを削除してからtsファイルをトランスコンパイルします。
それを防ぐためには、delete-output-pathオプションをfalseに指定します。
"script": {
"build": "cp ./resource/* dist && ng serve --delete-output-path=false"
}
参考サイト
なんとかできませんでした。。。(もしかしたら方法があるのかもしれません。誰か教えてください!!!)
AOTコンパイルはJITコンパイルが検出してくれないHTMLのエラーを警告してくれますが、その反面遅いです。特にAngular Materialを使う場合は顕著です。
そのため、自分の場合は基本的にJITビルドを使い、issueのプルリクをする前など区切りのいいタイミングでAOTビルドしてエラーがないか確認するというように使い分けてました。
CirleCiなどでテストを実行する場合ng test
コマンドだとプロセスが終了しないためタイムアウトで失敗してしまいます。
このような場合はwatchオプションをfalseに設定します。
ng test --wtach=false
参考サイト
Github isssue
このような場合は、一時的に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,
// ...
],
// ...
});
// ...
});
参考サイト
UIフレームワークでAngular Materialを使っている場合、
karma.config.js
でAngular Materilaのcssを直接読み込んであげる必要あります。
files: [
・・・
// Angular Materialのスタイルをテスト開始時に読み込んでおく
{pattern: './node_modules/@angular/material/prebuilt-themes/indigo-pink.css', included: true, watched: false},
],
参考サイト
アプリ共通スタイルをSASS形式にしている場合
開発用ライブライにkarma-scss-preprocessor
とnode-sass
を追加して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']
},
参考サイト:
※MongDBをNode.jsで扱う場合はmongooseという便利なライブラリがあるのでそれを使う前提のお話です。
例えば記事
,コメント
,リプライ
などのモデルを定義する場合、
3つのモデルのライフサイクルは、記事追加 => 記事に対するコメント追加 => コメントに対するリプライ追加 のようになります。
このような場合は、コメントが記事の参照を持ち、リプライがコメントの参照を持つモデル構造が望ましいです。コメントやリプライ追加時に1つのモデルの更新だけですむからです。
ただ記事の検索は少し工夫が必要で、mongooseのvirtualを使います。これによって記事からコメントへの参照、コメントからリプライへの参照を擬似的に定義できるため、
検索時に擬似要素をpopulateするだけで、記事に紐付くコメントとリプライを取得できるようになります。
具体的なソースコードを示します。
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 };
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 };
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となる
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;
}
参考サイト