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.js とbin/www をimport文を利用したES6形式のapp.mjs 、approotdir.mjs 、appsuport.mjs へごっそり再構成してapp.js とbin/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
をキーとして、title
とbody
とメモ更新時の時刻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>
メモ編集更新時には新規作成時と同じでtitle
、message
項目をPOST処理で送信するのだが、送り先は
notes/update
としており、かつ、input type = “hidden"でid
とcreatetime
は元のままの値を一緒にPOST送信している。
updatetimeは編集更新時間をサーバー側でチェックして更新している。
今後の改良点
以上の見てきた流れで簡単な掲示板アプリを作ることができた。
より実用的にするにはログイン機能を追加してユーザーを区別して、自分の書いたメモのみ編集許可を与えるとか、あるいはメモの編集更新があったらSocket.ioでリスト表示しているホーム画面を動的に変更するなどの拡張を考えていくのが良いだろう。