先日作った、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 」は ホットリロード です。ファイルが変更された時に、自動的に再起動してくれます。
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1",
"dev": "bun run --watch src/index.ts"
},
結果
その結果、ブラウザにはこんなチャットが表示されて書き込めばDBへログが記録されていきます。
起動ファイルの中身
起動ファイルは src/index.ts です。
今回も、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";と読込み、サーバー側でもクライアント側でも同じ関数を利用しています。
//===========================================
// タイムゾーンを変更する 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選