Old Sunset Days

Node.js+Express+MongoDBで簡易掲示板を作る

日付 タグ node.js カテゴリ node.js

目次

Node.jsで簡易掲示板の構築

最近読んでいるNode.js勉強のために買った英語の技術本Kindle版

「Node.js Web Development: Server-side web development made easy with Node 14 using practical examples, 5th Edition」 David Herron

を参考にしつつ、Node.js+Express+MongoDBで簡易掲示板を作ってみた。 今回は上記の本を参考にしているため、前に見たようにES6形式のimport文でモジュールをインポートする形式で作っている。
( ==> Node.jsでES6形式のモジュールimportを利用する

最終的に作るのは以下のような簡易な掲示板。

このようなメモをどんどん書き足していく掲示板。

今回はログイン機能はなく、単にタイトルとメモをどんどん投稿できるだけの機能実装までである。

Expressの簡単な説明は以前の記事 ( ==> ExpressフレームワークでNode.jsでサーバー立ち上げ

MongoDBをNode.jsから扱うには以前の記事 ( ==> Node.jsからMongoDBを利用する

を参考にしてもらいたい。

テスト用ディレクトリ作成

$ mkdir my-board
$ cd my-board
$ npx express-generator@4.x --view=hbs --git
$ npm install
$ npm install mongodb --save

まずはテスト用のディレクトリを作成して必要なモジュールのインストール。
templateエンジンとしては、hbsオプションでHandlebarsを利用することにする。

https://handlebarsjs.com/guide/ - Handlebars

ES6形式のimport文に書き換え

./
├── app.mjs
├── approotdir.mjs
├── appsupport.mjs
├── package.json

上記の本でもそうなのだが、今後はES6形式のimport文の方が柔軟性が高そうなので、なるべくES6形式のモジュールimport文を利用していきたいのだが、なにぶん、express-generatorで生成したファイルはrequireを使用してモジュールをインポートしている。

そこで上記の英語本を参考にexpress-generatorで生成されたapp.jsbin/www をimport文を利用したES6形式のapp.mjsapprootdir.mjsappsuport.mjs へごっそり再構成してapp.jsbin/www は削除しておく。


approotdir.mjs

import * as path from 'path';
import * as url from 'url';

const __filename = url.fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
export const approotdir = __dirname;


appsupport.mjs

import { port, NotesStore as notes } from './app.mjs';
import * as util from 'util';

/**
 * Normalize a port into a number, string, or false.
 */
export function normalizePort(val) {
    const port = parseInt(val, 10);

    if (isNaN(port)) {
        // named pipe
        return val;
    }

    if (port >= 0) {
        // port number
        return port;
    }

    return false;
}

/**
 * Event listener for HTTP server "error" event.
 */
export function onError(error) {
    if (error.syscall !== 'listen') {
        throw error;
    }

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

    // handle specific listen errors with friendly messages
    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;
    }
}

/**
 * Event listener for HTTP server "listening" event.
 */
import { server } from './app.mjs';
export function onListening() {
    const addr = server.address();
    const bind = typeof addr === 'string'
        ? 'pipe ' + addr
        : 'port ' + addr.port;
    console.log(`Listening on ${bind}`);
}


export function handle404(req, res, next) {
    const err = new Error('Not Found');
    err.status = 404;
    next(err);
}

export function basicErrorHandler(err, req, res, next) {
    // http://expressjs.com/en/guide/error-handling.html
    if (res.headersSent) {
        return next(err)
    }
    // set locals, only providing error in development
    res.locals.message = err.message;
    res.locals.error = req.app.get('env') === 'development' ? err : {};

    // render the error page
    res.status(err.status || 500);
    res.render('error');
}

async function catchProcessDeath() {
    console.log('shutdown ...');
    await notes.close();
    await server.close();
    process.exit(0);
}

process.on('uncaughtException', function(err) {
    console.error("Crashed - "+ (err.stack || err));
});

process.on('unhandledRejection', (reason, p) => {
    console.error(`Unhandled Rejection at: ${util.inspect(p)} reason: ${reason}`);
});

process.on('SIGTERM', catchProcessDeath);
process.on('SIGINT', catchProcessDeath);
process.on('SIGHUP', catchProcessDeath);

process.on('exit', () => { console.log('exiting...'); })

(SIGTERMなどのアプリイベントが発生した時にMongoDBとの接続を切るようになっている)


app.mjs

import { default as express } from 'express';
import { default as hbs } from'hbs';
import * as path from'path';
import { default as logger } from'morgan';
import { default as cookieParser } from'cookie-parser';
import { default as bodyParser } from'body-parser';
import * as http from 'http';
import { approotdir } from './approotdir.mjs';
const __dirname = approotdir;
import {
    normalizePort, onError, onListening, handle404, basicErrorHandler
} from './appsupport.mjs';

// MongoDBのデータ
import { default as MongoDBStore } from './models/notes-mongodb.mjs';
export const NotesStore = new MongoDBStore();

import { router as indexRouter } from './routes/index.mjs';
import { router as notesRouter } from './routes/notes.mjs';

export const app = express();

// Viewセットアップ
app.set('views', path.join(__dirname, 'views'));
app.set('view engine', 'hbs');
hbs.registerPartials(path.join(__dirname, 'partials'));

app.use(logger('dev'));
app.use(bodyParser.json());
app.use(bodyParser.urlencoded({ extended: false }));
app.use(cookieParser());
app.use(express.static(path.join(__dirname, 'public')));

// ルーティング
app.use('/', indexRouter);
app.use('/notes', notesRouter);

app.use(handle404);
app.use(basicErrorHandler);

export const port = normalizePort(process.env.PORT || '3000');
app.set('port', port);

export const server = http.createServer(app);

server.listen(port);
server.on('error', onError);
server.on('listening', onListening);

また、express-generatorで作成されたbin/www は削除しているので、package.json を調整して


package.json

{
  "name": "my-board",
  "version": "0.0.0",
  "private": true,
  "scripts": {
    "start": "DEBUG=notes:* node ./app.mjs"
  },
  "dependencies": {
    "cookie-parser": "~1.4.4",
    "debug": "~2.6.9",
    "express": "~4.16.1",
    "hbs": "~4.0.4",
    "http-errors": "~1.6.3",
    "mongodb": "^3.6.3",
    "morgan": "~1.9.1"
  }
}

プログラムのメインの本体がapp.mjs になるため、 "start": "DEBUG=notes:* node ./app.mjs" としてあることに注意が必要である。

MongoDBへデータを格納する

models/
└── notes-mongodb.mjs

これは以前やったNode.jsでMongoDBヘアクセスする方法とほぼ同じであるが、 格納するデータはこんな感じで入っていることを想定する。

> db.notes.find()
{ "_id" : ObjectId("5fcb7668398a09eaa7ae09ef"), "title" : "朝ごはん", "body" : "トーストと卵", "createtime" : "1607169640792", "updatetime" : "1607169640792" }
{ "_id" : ObjectId("5fcb7673398a09eaa7ae09f0"), "title" : "昼ごはん", "body" : "生姜焼き定食", "createtime" : "1607169651908", "updatetime" : "1607169651908" }
{ "_id" : ObjectId("5fcb76c1398a09eaa7ae09f1"), "title" : "夜ごはん", "body" : "肉じゃが\r\nごはん\r\n味噌汁", "createtime" : "1607169729975", "updatetime" : "1607169729975" }
  • title
    • メモのタイトル
  • body
    • メモの本体
  • createtime
    • メモ作成時の時刻
  • updatetime
    • メモ更新時の時刻(作成時は作成時の時刻と同じ)

_idはMongoDBによって自動的に振られるObjectIdであり、ユーザーが指定するのはタイトルtitleとメモ本体bodyである。

createtimeとupdatetimeに関してはサーバーコード上でメモ新規作成時とメモ編集更新字にUnix timeを計算して格納する。

Node.jsでUnix timeの扱い方については前回の記事を参考に。
( ==> Node.jsでUnix timeデータを扱う

さて上記メモのデータをCRUD(Create, Read, Update, Delete)するモデルデータを扱うコードが以下となる。


models/notes-mongodb.mjs

import mongodb from 'mongodb';
const MongoClient = mongodb.MongoClient;
import DBG from 'debug';
const debug = DBG('notes:notes-mongodb');
const error = DBG('notes:error-mongodb');

var client;

const connectDB = async () => {
    if (!client) client = await MongoClient.connect('mongodb://localhost/');
}
const db = () => { return client.db('mongo_db'); };

export default class MongoDBStore {

    async close() {
        if (client) client.close();
        client = undefined;
    }

    async create(title, body, createtime, updatetime) {
        await connectDB();
        const collection = db().collection('notes');
        await collection.insertOne({
            title: title,
            body: body,
            createtime: createtime,
            updatetime: updatetime
        });
    }

    async update(id, title, body, createtime, updatetime) {
        await connectDB();
        const collection = db().collection('notes');
        const doc = await collection.findOne({ _id: new mongodb.ObjectID(id)});
        if (!doc) {
            throw new Error(`No data found for ${id}`);
        } else {
          await collection.updateOne(
            { _id: new mongodb.ObjectID(id) },
            { $set: { title: title, body: body, creatime: createtime, updatetime: updatetime } }
          );
        }
    }

    async read(id_string) {
        debug(`${id_string}`)
        await connectDB();
        const collection = db().collection('notes');
        const id = new mongodb.ObjectID(id_string);
        const doc = await collection.findOne({ _id: id});
        return {
          id: id_string,
          title: doc.title,
          body: doc.body,
          createtime: doc.createtime,
          updatetime: doc.updatetime
        }
    }

    async readall() {
        await connectDB();
        const collection = db().collection('notes');
        const alldata = await new Promise((resolve, reject) => {
            var alldata = [];
            collection.find().sort({createtime:-1}).forEach(
                note => { alldata.push({
                  id: note._id.toString(),
                  title: note.title,
                  body: note.body,
                  createtime: new Date(Number(note.createtime) + 1000*60*60*9).toISOString().replace(/T/, ' ').replace(/\..+/, ''),
                  updatetime: new Date(Number(note.updatetime) + 1000*60*60*9).toISOString().replace(/T/, ' ').replace(/\..+/, '')
                });
                },
                err  => {
                    if (err) reject(err);
                    else resolve(alldata);
                }
            );
        });
        return alldata;
    }

    async destroy(id_string) {
        debug(`${id_string}`)
        await connectDB();
        const collection = db().collection('notes');
        const id = new mongodb.ObjectID(id_string);
        const doc = await collection.findOne({ _id: id});
        if (!doc) {
            throw new Error(`No data found for ${id_string}`);
        } else {
            await collection.findOneAndDelete({ _id: id});
        }
    }

}

createは単に新規作成したドキュメント(データ)をDBに保存する。

updateでは_idをキーとして、titlebodyとメモ更新時の時刻updatetimeを更新する。
_id{ _id: new mongodb.ObjectID(id) } として一旦mongodb.ObjectIDにして検索キーにしていることに注意が必要である。

readは_idをキーにして検索してドキュメントを読み込む。

readallは全ドキュメントを読み込むのだが、主に一覧表示する時に使われるため、作成時刻と更新時刻のミリ秒の部分は余分なので、サーバー側で削除している。

destroyは_idをキーに検索かけて該当のドキュメントを削除している。

ホームとメモ作成編集のルーティングの定義

ルーティングとしてはホーム画面routes/index.mjs とメモ追加や編集画面のためのroutes/notes.mjs で構成される。

routes/
├── index.mjs
└── notes.mjs

ホーム画面ではメモの一覧を表示する。


routes/index.mjs

import * as util from 'util';
import { default as express } from 'express';
import { NotesStore as notes } from '../app.mjs';
export const router = express.Router();
import DBG from 'debug';
const debug = DBG('notes:home');
const error = DBG('notes:error-home');

// ホームページ
router.get('/', async (req, res, next) => {
  try {
    // 全メモリストを取得して表示
    const notelist = await notes.readall();
    res.render('index', { notelist: notelist });
  } catch (err) {error(err); next(err);}
});

一方、メモ追加したり編集するルーティングroutes/notes.mjs では、それぞれCRUDに対応するmodels/notes-mongodb.mjs のコードを呼び出して処理している。

date.getTime() で新規メモ作成時や編集更新時の時間をUnix timeに変換している。


routes/notes.mjs

import * as util from 'util';
import { default as express } from 'express';
import { NotesStore as notes } from '../app.mjs';
export const router = express.Router();
import DBG from 'debug';
const debug = DBG('notes:home');
const error = DBG('notes:error-home');

// メモ追加入力画面
router.get('/add', (req, res, next) => {
  debug('/notes/add called');
  res.render('noteadd');
});

// メモ編集画面
router.get('/edit', async (req, res, next) => {
  debug('/notes/edit called');
  try {
    const note = await notes.read(req.query.id);
    res.render('noteedit', { note: note });
  } catch (err) {error(err); next(err);}
});

// メモを新規セーブする
router.post('/save', async (req, res, next) => {
  debug('/notes/save called');
  debug(`req.body.title = ${req.body.title}`);
  debug(`req.body.message = ${req.body.message}`);
  const date = new Date();
  try {
    await notes.create(req.body.title, req.body.message, date.getTime().toString(), date.getTime().toString());
    res.redirect('/');
  } catch (err) { error(err); next(err); }
});

// メモを更新セーブする
router.post('/update', async (req, res, next) => {
  debug('/notes/update called');
  debug(`req.body.title = ${req.body.title}`);
  debug(`req.body.message = ${req.body.message}`);
  debug(`req.body.id = ${req.body.id}`);
  debug(`req.body.createtime = ${req.body.createtime}`);
  // just update
  const date = new Date();
  try {
    await notes.update(req.body.id, req.body.title, req.body.message, req.body.createtime, date.getTime().toString());
    res.redirect('/');
  } catch (err) { error(err); next(err); }
});

// メモをデリートする
router.get('/destroy', async (req, res, next) => {
  debug('/notes/destroy called');
  debug(`req.body.id = ${req.query.id}`);
  try {
    await notes.destroy(req.query.id);
    res.redirect('/');
  } catch (err) { error(err); next(err); }
});

ホームとメモ作成編集のビューの定義

views/
├── error.hbs
├── index.hbs
├── layout.hbs
├── noteadd.hbs
└── noteedit.hbs
partials/
└── header.hbs

ホーム画面にはMongoDBのDBにすでに保存されたメモ書きがcreatetimeの降順で一覧表示される。

ホーム画面のリンクからメモ作成や一覧表示されているメモ書きを再編集したり、削除したりできるようにしている。

新規メモ追加

メモ編集更新時(すでに存在するメモのタイトル、本体がデフォルト編集で入っており編集しなおし)

メモ削除時には本当に削除するかどうかのConfirmを念のために出すようになっている


partials/header.hbs

<header>
<h1><!-- ここにアプリ名を入れる --></h1>
<div class='navbar'>
<p><a href='/'>Home</a></a></p>
</div>
</header>

ホーム画面に戻るリンクを設置。


views/layout.hbs

<!DOCTYPE html>
<html>
  <head>
    <title>{{tile}}</title>
    <link rel='stylesheet' href='/stylesheets/style.css' />
  </head>
  <body
    {{> header }}
    {{{body}}}
  </body>
</html>

headerをpartial読み込み


views/error.hbs

<h1>{{message}}</h1>
<h2>{{error.status}}</h2>
<pre>{{error.stack}}</pre>

特にデフォルトで生成されたのから変えていない。


views/index.hbs

<script>
function del(id) {
  var result = window.confirm("id=" + id + "の記事を削除しますか?");
  if(result) {
    window.location = "/notes/destroy?id=" + id;
  }
}
</script>
<h1>掲示板</h1>
<h2>新規メモ追加:</h2>
<a href="/notes/add">メモ追加する</a>
<h2>メッセージ:</h2>
{{#each notelist}}
<ul>
  <li>Title: {{ title }}</li>
  <li><a href="/notes/edit?id={{id}}">Edit</a> | <a href='javascript:del("{{ id }}")'>Delete</a></li>
  <ul>
    <li>id: {{ id }}</li>
    <li>create at: {{ createtime }}</li>
    <li>update at: {{ updatetime }}</li>
    <li>memo:<pre>{{ body }}</pre></li>
  </ul>
</ul>
<hr>
{{/each}}

readallでMongoDBから読み込まれたものはcollection.find().sort({createtime:-1}) によって降順ソートされているので、 それをリスト表示する。

なお、新規追加は/notes/add 、編集更新は/notes/edit 、削除は/notes/destroy だが、削除に関してはスクリプトで確認ダイアログを出すようにしている。


views/noteadd.hbs

<form action="/notes/save" method="post">
    <div>
        <label>タイトル</label><br>
        <input type="text" id="title" name="title" placeholder="タイトル入力"/>
    </div>
    <div>
        <label for="message">メモ</label><br>
        <textarea name="message" id="message" cols="50" rows="5"></textarea>
    </div>
    <div>
        <input type="submit" value="メモ追加"/>
    </div>
</form>

メモ新規追加では入力フォームからtitle, messageで入力されたものを /notes/save にPOST処理することでサーバー側で保存されるようになっている。


views/noteeddit.hbs

<form action="/notes/update" method="post">
    <input type="hidden" id="id" name="id" value="{{ note.id }}" />
    <input type="hidden" id="createtime" name="createtime" value="{{ note.createtime }}"/>
    <div>
        <label>タイトル</label><br>
        <input type="text" id="title" name="title" value="{{ note.title }}"/>
    </div>
    <div>
        <label for="message">メモ</label><br>
        <textarea name="message" id="message" cols="50" rows="5"> {{ note.body }}</textarea>
    </div>
    <div>
        <input type="submit" value="メモ更新"/>
    </div>
</form>

メモ編集更新時には新規作成時と同じでtitlemessage項目をPOST処理で送信するのだが、送り先は notes/update としており、かつ、input type = “hidden"でidcreatetimeは元のままの値を一緒にPOST送信している。 updatetimeは編集更新時間をサーバー側でチェックして更新している。

今後の改良点

以上の見てきた流れで簡単な掲示板アプリを作ることができた。

より実用的にするにはログイン機能を追加してユーザーを区別して、自分の書いたメモのみ編集許可を与えるとか、あるいはメモの編集更新があったらSocket.ioでリスト表示しているホーム画面を動的に変更するなどの拡張を考えていくのが良いだろう。