LoginSignup
1
0

【Elysia/Bun】先日作った、Elysia のチャットに SQLite の簡単なデータベースをつけた。

Last updated at Posted at 2023-12-21

更新履歴をページ下に移動しました。


先日作った、Elysia のチャット に SQLite の簡単なデータベースをつけた。まぁ、トランザクションなども付けてないけどまぁ、ログの消えないDB付きチャットにはなった。まぁ、簡易なチャットとしては充分かも。

今回の動作サンプル

まぁ、今回も作ったものを pm2 start "bun dev" してあげておきます。まぁ期間限定になるとは思いますが、参考までにどうぞ。ブラウザで複数開いて書き込むと動作が判ります。

Bun で pm2 登録する方法は下記をどうぞ。

参考: BunアプリをPM2で動かしてデーモン化する
https://qiita.com/toshirot/items/7a0555b46b3a64a4f643

自分が、Elysia の XSS やバリデーションなどの確認をまだちゃんとしていないので、一応、こつこつとスクリプトタグを除去してたりますが、そのうち勉強が進めばいろいろ改定していきたいと思ってはいます。

過去記事はこれです。

今回の環境

クラウド: Azure VM (これは何でも良い)
OS: Ubuntu 20.04.6 LTS (GNU/Linux 5.15.0-1050-azure x86_64)
Bun: v1.0.18
Elysia: v0.7.30
SQLite3: v3.31.1(Elysia を入れると自動で入る)

BunのインストールやElysia プロジェクト作成は、本家 などを参考にしてみてください。

今回はこんなディレクトリが作られます

SQLite 用の ディレクトリを ./db としました。同時書き込みが行われる状況でパフォーマンスを向上させる先行書き込みログ モード(WAL) を使っているので、DBファイルが3個になっています。

参考: SQLite > Write-Ahead Logging (WAS)

.ccchart/
├─ db/
│    ├─ mychat.sqlite // 通常の SQLiteファイル
│    ├─ mychat.sqlite-shm // ジャーナルモード用ファイル
│    └─ mychat.sqlite-wal // ジャーナルモード用ファイル
├─ src/
│    ├─ index.ts  // bun dev で起動するファイル
│    └─ utiles.ts // タイムゾーンの変更関数を置いた
│  
├─ README.md
├─ bun.lockb
├─ node_modules/
├─ package.json
└─ tsconfig.json

pacage.jsonのscriptは、今まで通り以下のようになっていますので、「$ bun dev」で実行できます。パラメータ「--watch 」は ホットリロード です。ファイルが変更された時に、自動的に再起動してくれます。

pacage.jsonのscript
  "scripts": {
    "test": "echo \"Error: no test specified\" && exit 1",
    "dev": "bun run --watch src/index.ts"
  },

結果

その結果、ブラウザにはこんなチャットが表示されて書き込めばDBへログが記録されていきます。

image.png

起動ファイルの中身

起動ファイルは src/index.ts です。
今回も、index.ts に全部入りで書きました。もちろん、実際に使う時は、適宜デイレクトリに小分けして使うようですね。

src/index.tsの中身
import { Elysia } from 'elysia';
import type { WS } from '@types/bun';
import { html } from '@elysiajs/html'
import { Database } from 'bun:sqlite';
import { adjustHours } from "./utils";

// チャット名
const chatName = 'myChat';
// バージョン
const version = '0.1.005';

type MsgType = 'msg' | 'info' | 'bc'; 

interface Head {
    type: MsgType;
}
interface Body {
    type: any;
}
interface MsgData {
    head: Head;
    body: Body;
}

//===========================================
// データベースの作成

// データベースファイルの名前を指定
const dbFileName = './db/'+chatName+'.sqlite';
// テーブルの名前を指定
const tableName = 'chat_logs';
// 新しいデータベースインスタンスを作成し、ファイルが存在しない場合はデータベースファイルを作成
const db = new Database(dbFileName, { create: true });
// 同時書き込みが行われる状況でパフォーマンスを大幅に向上させる先行書き込みログ モード(WAL) 
db.exec('PRAGMA journal_mode = WAL;');
// テーブルが存在しない場合はテーブルを作成
const sql_table_create =
    'CREATE TABLE IF NOT EXISTS ' 
    + tableName 
    + ' (id INTEGER PRIMARY KEY, name VARCHAR(255), msg VARCHAR(255), created_at TIMESTAMP);'
doQuery(db, sql_table_create);

// 出力するメッセ―ジ数
const limit = 20;

// クライアントを入れとく配列 ブロードキャスト用
let clients: WebSocket[] = [];

//===========================================
// サーバーを立てる HTTPとWebSocket
// 
const app = new Elysia()
    .use(html())
    .get('/', ({ cookie: { name } }) => { 
        // cookie から名前を取得
        (name.value)?name.value=name.value:name.value='通りすがりさん'
    return `
    <html lang='ja'>
        <head>
            <title>${chatName} updating</title>
            <script>${adjustHours} </script>
        </head>
        <body><Layout>
            <h1 style=margin-bottom:5px;>${chatName} v${version}</h1>
            
            <a href="https://qiita.com/toshirot/items/c5654156c8799ac28d83" target=qiita>
            →このチャットの作り方</a><br><br>

            名前: <input safe
                id="input_name"
                type="text"
                value="${name.value}"
                placeholder="名前を入れてください"><br>
            メッセージ: <input safe
                id="input_msg"
                type="text"
                onchange=""
                onkeyup="if(event.keyCode==13)btn_send.click();"}"
                placeholder="メッセージを入れてください"><br>
            <button id="btn_send">送信</button>
            
            <ul safe id=msgs></ul>

            <script>
                // 接続
                const socket = new WebSocket('ws://74.226.208.203:9011/ws');
                // 接続時イベント
                socket.onopen = function (event) {
                    socket.send(JSON.stringify({
                        head:{type: 'info'},
                        body:{
                            name: 'system',
                            msg: '誰かがサーバーへ接続しました。'
                        }
                    }))
                };
                // 着信時イベント
                socket.onmessage = function (event) {

                    // event.data is 
                    //  e.g. '{"head":{"type":"msg"},"body":[[52,"aa","aa","2023-12-20 14:39:04"]]}'
                    if(!event.data)return // 無ければ無視する
                    let data=JSON.parse(event.data) // object化する
                    if(!data.body)return // 無ければ無視する

                    // 下から上に向かって追記するので SELECT ASC で昇順取得したリストをafterbeginで追記する
                    data.body.reverse()
                    for(let i=0;i<${limit};i++){
                        // メッセージを出力する
                        if(!!data.body[i] && !!data.body[i]){
                            msgs.insertAdjacentHTML('afterbegin',
                              '<br><b>'
                              +data.body[i][0]+' '
                              +data.body[i][1]+': </b>'
                              +data.body[i][2]+' ('+adjustHours(data.body[i][3], +9)+')'
                            )
                        }
                    }

                };
                // 送信ボタンクリック時イベント
                btn_send.addEventListener('click', function () {
                    // 名前とメッセージがあれば送信する
                    if(!!input_name.value && !!input_msg.value) {
                        // 名前をcookieに保存する
                        document.cookie='name='+input_name.value;
                        // 送信する
                        socket.send(JSON.stringify({
                            head:{type: 'msg'},
                            body:{
                                name: input_name.value,
                                msg: input_msg.value
                            }
                        }));
                        // 送信したらメッセージ欄を空にする
                        input_msg.value='';
                    }
                });
            </script>
        </body>
    </html>
    `})
    // WebSocket サーバー
    .ws('/ws', {
        open(ws: WS): void {
            // クライアント配列を作る (ブロードキャスト等で利用する)。
            clients.push(ws)
            // DBからメッセージを 初期 limit件 降順で取り出してクライアントへ送信する
            let sql_select = 'SELECT * FROM ' + tableName + ' ORDER BY ID DESC LIMIT '+ limit +';'
            let res: any= doQuery(db, sql_select);
            // データ配列を接続してきたクライアントへ返す
            const data: MsgData = {
                head: {
                  type: "msg",
                },
                body: res,
            };
            // メッセージを送信する
            ws.send(JSON.stringify(data));
            
        },
        // メッセージが届いたら返すだけの簡単なエコーサーバー
        message(ws: WS, msgoj: MsgData): void {
            // メッセージの構文エラーははじく
            if (!msgoj || !msgoj.head || !msgoj.body) return;

            // Elysia の XSS やバリデーションの確認をまだしていないので、
            // 一応、msgoj を文字列化してスクリプトタグを除去し、オブジェクトへ戻す
            if(typeof msgoj=='object'){
                let msgstr=JSON.stringify(msgoj)
                msgstr=(''+msgstr).replace(/<script\b[^>]*>([\s\S]*?)<\/script>/gi, '');
                msgoj=JSON.parse(msgstr)
            } else return; // オブジェクト以外は無視する
  
            // タイプにより処理を分ける
            // type:msg はDBへ登録する
            if(msgoj.head.type==='msg'){
                console.log('msg',msgoj.body)
                let sql_ins = 
                        'INSERT OR IGNORE INTO ' 
                        + tableName 
                        + ' VALUES (null, "'+msgoj.body.name+'", "'+msgoj.body.msg+'", CURRENT_TIMESTAMP);'
                // insert する
                doQuery(db, sql_ins)
                // 最後の 1件だけ select する
                let sql_1 = 'SELECT * FROM ' + tableName + ' ORDER BY ID DESC LIMIT 1;'
                let res = doQuery(db, sql_1)//こんな配列で返ってくる。 [[0件目], [1件目], [2件目], [3件目]] 
                // sql_1 結果セットのメッセージ配列をブロードキャストする
                broadCast(ws, res)
            } else if(msgoj.head.type==='info'){
                // infoは DB へ保存しない。
                // 届いたinfoメッセージをブロードキャストする
                let time=new Date()
                broadCast(ws, [['', '--※info', msgoj.body.msg,
                  // UTCから日本時間への変換をクライアント側でしてるので、
                  // クライアントから届いた日本時間をマイナス9時間して 
                  // SQLite出力同様のUTCに揃える
                  adjustHours(new Date(), -9)
                ]]) 
            } else {}
        }
    })
    .listen(9011, (token: any) => {
        if (token) {
            console.log('Listening to port 9011');
        } else {
            console.error('Failed to listen to port 9011');
        }
    });


//===========================================
// データベースクエリを実行する関数
//  @param {String} sql - 実行するSQLクエリ
//  @returns {Array} - 結果の配列
type QueryResultType = Array<any>; 
function doQuery(db: Database, sql: string): QueryResultType {
    return db.query(sql).values();
}

//===========================================
// 全クライアントへブロードキャストする関数
//  @param {Object} ws - WebSocket インスタンス
//  @param {String} msg - 送信するメッセージ
//  @returns {void} - なし

// 全クライアントへブロードキャストする
function broadCast(ws: WS, msg: any): void {
    clients.forEach(function (socket, i) {
        const data: MsgData = {
            head: {
              type: "bc",// broadCast
            },
            body: msg,
        };
        console.log(data)
        socket.send(JSON.stringify(data) );
    })
}

あとから、SQLite のタイムゾーンがUTCだったと思い出して、2023/12/23 にタイムゾーンを変更する関数を src/utiles.ts に置きました。

一応、SQLite DBに保存する日付とサーバー側は UTC のままにして、表示側で日本時間に変換しています。

因みに、src/index.ts で import { adjustHours } from "./utils";と読込み、サーバー側でもクライアント側でも同じ関数を利用しています。

src/utiles.tsの中身
//===========================================
// タイムゾーンを変更する e.g. UTC -> JST 
//  @param {Object} date - Date インスタンス
//  @param {Number} adjustHour - 調整する時間数 e.g. -9 | +9
//  @returns {string} - 時間の文字列 e.g. '2023-12-22 09:59'
export function adjustHours(date: Date, adjustHour: number): string {
    if(date instanceof Date === false)date=new Date(date)
    date.setHours(date.getHours() + adjustHour)
    return date.toLocaleString(
        ["ja-JP"], {
        year: "numeric",
        month: "2-digit",
        day: "2-digit",
        hour: "2-digit",
        minute: "2-digit",
      })
}

更新履歴

Update:2023/12/24 v0.1.005:
名前欄をcookie保存する
送信したらメッセージ欄をクリアする
Update:2023/12/23 v0.1.004
メッセージ欄の改行送信機能を追加
Update:2023/12/23 v0.1.003 SQLite の CURRENT_TIMESTAMP がUTC なのを忘れてたので直した。src/utiles.ts を追加してそこにタイムゾーンの変更関数 adjustHours を置きました。

├─ src/
│    ├─ index.ts  // bun dev で起動するファイル
│    └─ utiles.ts // タイムゾーンの変更関数を置いた

Update:2023/12/22 v0.1.002 元記事の src/index.ts にtypescript 的な型指定やinterface定義などを追加しておきました。

一応、追加したい機能もいくつかあるので、継続的にアップデートしようかなと思っています。


最近 Qiita に書いた Bun 関連の記事10選

1
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
0