【初心者向け】Express(Node.js)で"ioredis"モジュールを使ってRedisを操作する

Expressで"ioredis"モジュールをインストールして、Redisを使った投稿アプリを作成してみました。Redisの学習には丁度良く、個人的には面白いと思ったので、記事にして残しておきます。

投稿に対する複数のコメントを付けることもできますが、それならばテーブル間でリレーションを貼ることができるRDBを使う方が簡単でベストかと思い、今回はそこまではやっていません。

因みに、Redisの環境にはdockerコンテナを使っています。Redisの立ち上げ方や簡単なコマンド操作については【Redis入門】Ubuntu20.04でRedisの学習(docker)を参考にしてください。

検証環境

  • OS : Ubuntu 20.04
  • Redis : v7.4 / ioredis : v5.4.2

Redisデータベースの構成

RedisのHash型(連想配列型)を使って、データのCRUD処理をプログ投稿アプリで実装しました。Redisはスキーマレスですので、自由に書けるメリットはありますが、事前にどの様に書き込んでいくのか決めておいた方がやり易いので、データ構造を次のように定義しました。

id : ユニークID , # uuidを使っています
title  : 'タイトル文字',
body '本文の文字'

書き込むRedisコマンドは「hmset key field value [field value ...]」ですが、keyは一意に決まりかつpostデータ全件を抽出し易いように、
key = post:idの形とします。例えば、仮にidの値が「56ede78e-e8c2-4b39-8790-0f7844f9cefc」であったとすると、書き込むコマンドは以下の通りとなります。

hmset post: 56ede78e-e8c2-4b39-8790-0f7844f9cefc   title 'タイトル文字'  body   '本文の文字'

全件抽出する方法は、初めに関連するkeyをkeys post*で全て抽出した後に、各keyの値から1件づつhashデータを読み込むようにします。

関連コマンドの整理

概ねこんな感じでしょうか。詳細は公式サイトでご確認ください。

redis(v7.4)コマンドとioredis(v5.4.2)のメソッドとの比較

redisコマンドクラスメソッド
hset key field value field value ...redis.hset(`post:${post.id}`, post)
del key [key ...] redis.del(`post:${key}`)
hgetall keyredis.hgetall(`post:${key}`)
keys post:* redis.keys('post:*')

【注意1】Redis v7.4以降であれば、hmsetを使わなくてもhsetで複数のフィールドを一度に設定できるようになりました。これは使いやすさの改善によるものです。

【注意2】Redis v7.4以降に対応したioredisモジュールは、ioredis v5.x.x 以降となります。

ioredisの詳細は公式ドキュメントを参照してください。

コードの詳細説明

models/redisModel.js

const Redis = require('ioredis');

//  ****** model ********
const redis = new Redis({
	host: 'localhost',
	port: 6379,
	db: 0,
});

// ***** methods *******
/**
 * 1件削除
 */
async function delPost(key){
    await redis.del(`post:${key}`);
}

/**
 * 保存
 */
async function savePost(post) {
    await redis.hset(`post:${post.id}`, post);
}

/**
 * 関連するHash全て取得してposts[]に代入
 */
async function getallPosts() {
    const keys = await redis.keys('post:*');
    const posts = []; // ここに貯めて返す
    // keyを抽出
    for (const key of keys) {
      const post = await redis.hgetall(key);
      posts.push(post);
    }
    return posts;
}

/**
 * 1件のみ抽出
 */
async function getPost(key){
    const post = await redis.hgetall(`post:${key}`)
    return post;
}


// exports
module.exports = {savePost, getallPosts,  delPost , getPost}

routes/post.js

const express = require('express');
const router = express.Router();
const { v4: uuidv4 } = require('uuid');

const { savePost, getallPosts,  delPost , getPost} = require('../models/redisModel'); 
// ↓バリデーションチェックも入れています
const {check, validationResult } = require('express-validator');

let posts =[];
let comments = [];


/**
 *  1件削除する
 */
router.get('/:id/delete', async(req, res)=>{
    const id = req.params.id;
    await delPost(id);

    res.redirect('/');
});

/**
*  一覧表示
*  (全件抽出して'index'にredirect)
*/
router.get('/', async(req,res)=>{
    try{
        // 全ての投稿を読み込み
        posts = await getallPosts();
    }catch(err){
        console.log(err);
    }
    
    res.render('index' ,{posts:posts});
});

/**
*  新規投稿
*/
router.post('/create',[
    check('title').notEmpty().withMessage('タイトルに文字が記入されてません'), 
    check('body').notEmpty().withMessage('本文に文字が記入されてません'),
    check('body').isLength({min:6}).withMessage('本文は6文字以上で入力してください')
], async(req, res)=>{
    try {
        validationResult(req).throw();
        // console.log(req.body);
        const {title, body} = req.body;
        const id = uuidv4();
        posts.push({id: id, title: title, body:body});
        await setPosts(posts);
        res.redirect('/');
        
      } catch (err) {
        const errors = [];
        const errdata = err.mapped();  // { title:{...},body:{...} }
       
        if('title' in errdata ){
            errors.push({msg : errdata.title.msg});
        }
        if('body' in errdata){
            errors.push({msg : errdata.body.msg});
        }
        
        res.status(400).render('validated', {errors : errors});
      }
      
});


/**
 * 詳細画面
 */
router.get('/:id/details', async(req, res)=>{
    const id = req.params.id;
    let post=[];
    try{
        post = await getPost(id);
    }catch(err){
        console.log(err);
    }
    
    res.render('detail', {post: post});
});

/**
 * 更新画面に遷移
 */
router.get('/:id/update', async(req, res)=>{
    const id = req.params.id;
    let data = await getPost(id);
 
    res.render('update', {post: data});
});

/**
*  更新処理
*/
router.post('/update', async(req, res)=>{
    const {id,title, body} = req.body;
    const data = [];
    data.push({id: id, title: title, body:body});

    await savePost(data[0]);
    res.redirect('/');
});


module.exports = router

views/

index.ejs

<!DOCTYPE html>
<html lang="ja">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>csrf test</title>
    <link rel="stylesheet" href="/css/style.css">
</head>
<body>
    <div class="container">

        <h2>投稿一覧 <small style="font-size: 0.7em; color: chocolate;"> -Express + CSRF + Redis(docker)-</small></h2>
        <ul >
            <% if (posts.length) { %>
                <% posts.forEach(post => { %>
                    <li>
                        <a href="/<%= post.id %>/details"><%= post.title %></a>
                        <span>
                            <a href="/<%= post.id %>/update" class="updatebtn"  >更新</a>
                            <button type="button"  class="delbtn" 
                                       data-id="<%= post.id %>" 
                                      data-title="<%= post.title %>">削除</button> 
                        </span>
                    </li>
                <% }); %>
            <% } else { %>
                <li>ありません</li>
            <% } %>
                 
        </ul>
        <form action="/create" method="post">
            <label for="title_id">タイトル</label><input type="text" name="title" id="title_id">
            <label for="body_id">本文</label><input type="text" name="body" id="body_id" autocomplete="off">
            <input type="hidden" name="_csrf" value="<%= csrftoken %>">
            <button type="submit" class="send_btn">新規投稿</button>
        </form>
    </div>

    <script>
        {
            'use strict';

            /**
             * 削除命令の有無を判断してリダイレクト先も決める
            */
            const delbtns = document.querySelectorAll(".delbtn");
            delbtns.forEach((btn) => {
                btn.addEventListener('click', ()=>{
                    const option= {
                        method : "GET",
                    }
                    const id = btn.dataset.id;
                    const url = `${id}/delete`;
                    // console.log(url);
                    const title = btn.dataset.title;
                    const ans = confirm(`[${title}]を削除しますか?`);
                    if (ans){
                        fetch(url,option)
                        .then(()=>{
                        });
                    }
                    document.location.href="/";
                });
            });
           
        }
    </script>
</body>
</html>

update.ejs

-- 省略
<body>
    <div class="container">
        <h2>更新画面</h2>
        <form action="/update" method="post">
            <input type="hidden" name="id" id="" value="<%= post.id %>">
            <input type="text" name="title" id="" value="<%= post.title %>">
            <input type="text" name="body" id="" value="<%= post.body %>" autocomplete="off">
            <input type="hidden" name="_csrf" value="<%= csrftoken %>">
            <br>
            <button type="submit" class="send_btn">更新する</button>
        </form>
        <a href="/">一覧に戻る</a>
    </div>

</body>
-- 省略

detail.ejs

-- 省略
<body>
    <div class="container">
        <h2><%= post.title %></h2>
        <p><%= post.body %></p>
        <a href="/">一覧に戻る</a>
    </div>
</body>
-- 省略

validated.ejs

<!DOCTYPE html>
<html lang="ja">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>検証結果</title>
    <link rel="stylesheet" href="../css/style.css">
</head>
<body>
    <div class="container">

        <h3 style="color:red;">投稿内容にエラーがあります。ご確認をお願いします。</h3>
        <ul>
            <% errors.forEach((err)=>{ %>
                <li><%= err.msg %></li>
            <% }); %>
        </ul>
        <a href="/">戻る</a>
    </div>
</body>
</html>

アプリの動作確認

最後こんな仕上がりになりました。

動画再生

Follow me!