はじめに
Webアプリでクライアントに動的な情報を提供する際、そのデータをどこに保持するかにはいくつかの選択肢があります。本記事では、Node.jsを用いた数値カウンターのシンプルなWebアプリを例に、データの持ち方によってどのような違いが出てくるのかを比較してみました。
前提条件
Node.jsのインストールが必要です。
本記事では以下のバージョンを使いました。
$ node --version
v20.19.2
事前準備
今回、プログラミング言語はTypeScript、WebアプリのフレームワークはExpress.jsを使います。これらを使えるようにするために、必要なパッケージをインストールします。
筆者がNode.jsとTypeScriptの初心者なので、備忘用メモとして折りたたんで書いています。使い慣れている方は読み飛ばしていただいて結構です。確認したい場合は、折りたたみ(▶と書かれている部分)を展開してください。
Node.js プロジェクトの作成
手順はここをクリック
空のディレクトリを作成して、移動します。
mkdir /path/to/work-dir/
cd /path/to/work-dir/
以下のコマンドで、ディレクトリをNode.jsのプロジェクトとして使えるようにします。
npm init -y
すると、package.json
というファイルが作成されます。
$ tree .
.
└── package.json
0 directories, 1 file
$ cat package.json
{
"name": "work-dir",
"version": "1.0.0",
"main": "index.js",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1"
},
"keywords": [],
"author": "",
"license": "ISC",
"description": ""
}
Express.jsとTypeScriptのインストール
手順はここをクリック
以下のコマンドでそれぞれインストールします。
npm install express
npm install --save-dev typescript ts-node @types/node @types/express
続いて、TypeScriptを使用できるように、tsconfig.json
を追加します。
設定ファイルの各パラメータの意味については説明を割愛します。
{
"compilerOptions": {
"target": "ES2020",
"module": "CommonJS",
"moduleResolution": "node",
"esModuleInterop": true,
"strict": true,
"outDir": "dist"
},
"include": ["src"]
}
これで準備は完了です。
カウンターWebアプリの実装
準備ができたので、カウンターWebアプリを幾つかのパターンで作っていきます。主に確認したいポイントは以下です。
- カウンターの更新がサーバーで行われる「サーバーサイド」なのか、クライアントで行われる「クライアントサイド」なのか?
- 各ケースで、異なるクライアントやブラウザからWebアプリにアクセスした場合に、カウンターの値がどのように変化するのか?
尚、本記事で紹介したソースは以下のGitHubにも格納しています。
サーバーサイドアプリケーション
(ケース1) カウンターの値をサーバーに保持
まずは、カウンターをサーバ側で保持して更新する例です。次のような振る舞いをするWebアプリを作ってみましょう。
-
/count
に GET でアクセス:count
の現在の値を取得 -
/increment
に POST でアクセス:count
の現在の値に1を足して、足した後の結果を返す
これを実現するソースコードを、src/
ディレクトリに以下のように作成します。
import express, { Request, Response } from 'express';
const app = express();
const port = 3000;
let count = 0;
// 現在のカウントを取得
app.get('/count', (_req: Request, res: Response) => {
res.json({ count });
});
// カウントを1増やす
app.post('/increment', (_req: Request, res: Response) => {
count++;
res.json({ count });
});
app.listen(port, '0.0.0.0', () => {
console.log(`Server is running at http://localhost:${port}`);
});
上記のソースコードをもとに、サービスを起動します。
npx ts-node src/server.ts
仮に現在のマシンのIPアドレスを192.168.1.4
とします。この時、あるクライアント(client-A
とする)から curl
でアクセスすると、次のような振る舞いを確認できるはずです。
(client-A) $ curl -X GET http://192.168.1.4:3000/count
{"count":0}
(client-A) $ curl -X POST http://192.168.1.4:3000/increment
{"count":1}
(client-A) $ curl -X POST http://192.168.1.4:3000/increment
{"count":2}
続いて、別のクライアント(client-B
とする)からアクセスすると、client-A
で更新したcountの値 2
が出力されます。
(client-B) $ curl -X GET http://192.168.1.4:3000/count
{"count":2}
count
の値はサーバーのメモリ上に保持されているため、アクセス元のクライアントが変わっても初期値0から始まることはありません。
ちなみにこのWebアプリを複数のワーカープロセスで起動した場合、count
の値は各ワーカーのメモリに格納されます。そのためクライアントから見えるcount
の値は、リクエストが処理されるワーカーによって変わる可能性があります。
例えばcount
の値を参照するリクエストを何回か実行すると、以下のように異なる値が返されるケースを確認できます。
(client-A) $ curl -X GET http://192.168.1.4:3000/count
{"count":0}
(client-A) $ curl -X GET http://192.168.1.4:3000/count
{"count":1}
この状態を図にしてみました。Webアプリでワーカープロセスが複数動いており、count
の値が各ワーカーに格納されていることがわかります。
折りたたみですが、以下に複数のワーカーを起動するソースを記載します。
複数のワーカーでWebアプリ起動するソース(src/server.ts)
import cluster from 'cluster';
import express, { Request, Response } from 'express';
const numOfWorkers = 2;
const port = 3000;
if (cluster.isPrimary) {
console.log(`Primary process ${process.pid} is running`);
// 指定したワーカー数ぶんフォーク
for (let i = 0; i < numOfWorkers; i++) {
cluster.fork();
}
} else {
const app = express();
let count = 0;
app.get('/count', (_req: Request, res: Response) => {
res.json({ count });
});
app.post('/increment', (_req: Request, res: Response) => {
count++;
res.json({ count });
});
app.listen(port, '0.0.0.0', () => {
console.log(`Worker ${process.pid} running on http://localhost:${port}`);
});
}
(ケース2) カウンターの値をCookieに保存
Cookieとは「HTTP通信においてクライアントとサーバー間で状態を持たせるための仕組み」です。例えばログイン状態を保持する場合に使いますが、今回はCookieにカウンターの値を格納してみます。
実現したいことを図にしてみましょう。
クライアントに発行されるCookieに count
を格納し、Webアプリはリクエストのpathやメソッドに応じてCookieの count
に以下の処理をします。
-
/count
に GET でアクセス: Cookieのcount
の現在の値を取得 -
/increment
に POST でアクセス: Cookieのcount
の現在の値に1を足して、足した後の結果を返す
実装
この処理を実現するWebアプリを実装します。
まずは、WebアプリでCookieによるセッション情報を扱うために、以下の express-session のパッケージを追加でインストールします。
npm install express-session
npm install --save-dev @types/express-session
以下がソースコードです。
※ 筆者がTypeScript初心者なので、備忘のためにコメントを沢山残しています。
import express, { Request, Response } from 'express';
import session from 'express-session';
const app = express();
const port = 3000;
// セッションミドルウェアの設定
// セッションIDのCookieを安全に利用するためのシークレットキーを設定
// 本番環境では環境変数などから取得し、より複雑な文字列にすべし
app.use(session({
secret: 'my-super-secret-key-for-session', // セッションIDを暗号化するための秘密鍵
resave: false, // セッションに変更がない場合でもセッションストアに保存し直すか (通常はfalse)
saveUninitialized: false, // 未初期化のセッションを保存するか (通常はfalse)
cookie: {
maxAge: 1000 * 60 * 60 * 24, // クッキーの有効期限 (例: 24時間)
// secure: true, // HTTPS環境でのみクッキーを送信する場合はtrue (本番環境で推奨)
// httpOnly: true, // クライアントサイドのJavaScriptからクッキーにアクセスできないようにする (推奨)
}
}));
// Expressの型定義を拡張して、req.session にアクセス可能にする
declare module 'express-session' {
interface SessionData {
count?: number; // countプロパティを追加(?がつくのでオプショナルプロパティ。このプロパティはあってもなくてもいい)
}
}
// 現在のカウントを取得
app.get('/count', (req: Request, res: Response) => {
// req.session からクライアントごとの 'count' を取得
// 初めてアクセスするクライアントの場合、req.session.count は undefined なので 0 を初期値とする
const currentCount = req.session.count || 0;
res.json({ count: currentCount });
});
// カウントを1増やす
app.post('/increment', (req: Request, res: Response) => {
// req.session.count をインクリメント
// 初めてアクセスするクライアントの場合、初期値は 0 として扱う
req.session.count = (req.session.count || 0) + 1;
res.json({ count: req.session.count });
});
app.listen(port, '0.0.0.0', () => {
console.log(`Server is running at http://localhost:${port}`);
});
動作確認
以下のコマンドでサービスを起動します。
npx ts-node src/server.ts
先ほどと同様にcurl
を使ってWebアプリにアクセスすると、count
の値が増えていきません。
$ curl -X GET http://localhost:3000/count
{"count":0}
$ curl -X POST http://localhost:3000/increment
{"count":1}
# countがインクリメントされない
$ curl -X POST http://localhost:3000/increment
{"count":1}
# countが初期値に戻っている
$ curl -X GET http://localhost:3000/count
{"count":0}
これは、WebアプリがCookieを返しているのに、クライアントはそれを使わずに毎回リクエストしているためです。
そこで、以下のようにCookieをcookie.txt
に保存して呼び出すオプションを追加すると、count
が増えていくことを確認できます。(-c
はCookieを保存、-b
は保存したCookieを送るオプションです)
$ curl -X GET http://localhost:3000/count -c cookie.txt
{"count":0}
$ curl -X POST http://localhost:3000/increment -c cookie.txt -b cookie.txt
{"count":1}
$ curl -X POST http://localhost:3000/increment -c cookie.txt -b cookie.txt
{"count":2}
$ curl -X POST http://localhost:3000/increment -c cookie.txt -b cookie.txt
{"count":3}
$ curl -X GET http://localhost:3000/count -c cookie.txt -b cookie.txt
{"count":3}
ここで、Cookieを保存するファイル名をcookie2.txt
に変えてみます。するとcount
の値が 0
にリセットされ、Cookie毎に保存されているのがわかります。
$ curl -X GET http://localhost:3000/count -c cookie2.txt
{"count":0}
$ curl -X POST http://localhost:3000/increment -c cookie2.txt -b cookie2.txt
{"count":1}
$ curl -X POST http://localhost:3000/increment -c cookie2.txt -b cookie2.txt
{"count":2}
再びcookie.txt
を使うと、これまでのcount
の値が保持されているのを確認できます。
$ curl -X GET http://localhost:3000/count -c cookie.txt -b cookie.txt
{"count":3}
$ curl -X POST http://localhost:3000/increment -c cookie.txt -b cookie.txt cookie.txt
{"count":4}
今回はcurl
コマンドでCookieにカウント値を格納した際の挙動を確認しましたが、WebブラウザでCookieを使ってリクエストを送信する場合も同じ仕組みで動作します。つまり、クライアントが異なる場合はそれぞれ異なるCookieが発行され、各クライアントのCookieに個別のカウント値が保持されます。
クライアントサイドアプリケーション
続いて、クライアントサイドのカウンターアプリを紹介します。
いきなりですが、以下のHTMLファイルを用意すればとりあえずは完成です。
<!DOCTYPE html>
<html>
<head>
<title>カウントアプリ</title>
</head>
<body>
<p id="count-display">count: 0</p>
<button id="counter">increment</button>
<script type="module">
const button = document.getElementById('counter');
const display = document.getElementById('count-display');
let count = 0;
button.addEventListener('click', () => {
count++;
display.textContent = `count: ${count}`;
});
</script>
</body>
</html>
このHTMLファイルをWebブラウザにドラッグ&ドロップすると、次のようなカウンターWebアプリが表示されます。
上の状態で、「increment」ボタンを何回かクリックすると、countの値が1つずつ増えていきます。
サーバーサイドアプリケーションだとNode.jsやExpress.jsをインストールするなどの準備が必要ですが、それと比べるとクライアントサイドアプリケーションは簡単に動かせてしまい、拍子抜けしそうですね。そもそもWebサーバーが要らない...。なので「クライアントサイドアプリケーション」なのです!
このHTMLファイルをサーバーから提供する場合は、今までと同じようにNode.js等での実装が必要です。以下に実装方法をご紹介します。
まず、次のようなディレクトリ構成でファイルを用意します。
.
├── package.json # 「サーバーサイドアプリケーション」で使ったものをコピペする
├── public
│ └── index.html # 既に載せたHTMLファイルを格納
├── src
│ └── server.ts
└── tsconfig.json # 「サーバーサイドアプリケーション」で使ったものをコピペする
src/server.ts
は次のような内容にします。
import express, { Request, Response } from 'express';
import path from 'path';
const app = express();
const port = 3000;
// '../public' に格納されている静的ファイルを公開
app.use(express.static(path.join(__dirname, '../public')));
app.listen(port, '0.0.0.0', () => {
console.log(`Server is running at http://localhost:${port}`);
});
以下のコマンドで、必要なパッケージをインストールします。package.json
に既に必要パッケージが書かれているので、npm install
の後にパッケージの指定は不要です
npm install
サーバーを起動します。
npx ts-node src/server.ts
Webブラウザで http://localhost:3000/ にアクセスすると、カウンターWebアプリが表示されます。
今の状態を図にしてみました。
サーバーサイドアプリケーションでは、カウンターの更新処理をサーバーで実施していました。一方、クライアントサイドアプリケーションでは、サーバーは「カウンターの更新方法を書いたHTMLファイル」をクライアントに送信し、クライアントはその内容に従ってカウンターを更新します。
具体的にどんな処理をするのかは、index.html
の script
タグに次のようにJavaScriptで記載されています。
...
<script type="module">
const button = document.getElementById('counter');
const display = document.getElementById('count-display');
let count = 0;
button.addEventListener('click', () => {
count++;
display.textContent = `count: ${count}`;
});
</script>
...
上記の内容に基づきクライアントがカウンターを更新するので、サーバーは各クライアントのカウント値を知ることはできません。(もちろん実装によってはクライアントで更新したデータをサーバーに送ることもできると思いますが)
また、先に示した
のような表示は、「WebブラウザがHTMLテキストを読み取り、JavaScriptの処理をしてカウント値を更新および保持しつつ画面上に出している」から為せる業です。そのため、例えばサーバーサイドアプリケーションで確認したのと同じやり方で、端末から curl -X GET http://localhost:3000
のコマンドを実行しても、毎回同じ内容のHTMLファイルが表示されるだけでカウンターの更新を確認できない点にご注意ください。
ちなみにWebブラウザ(Chrome)でクライアントサイドアプリケーションのHTMLファイルを表示すると、カウンターの値が次のように変化することを確認しました。
- Webブラウザを最読み込みすると、値が
0
に戻る - 異なるタブで開くと値が
0
で表示され、タブ毎に独立してカウントされる
おわりに
サーバーサイドアプリケーションとクライアントサイドアプリケーションの違いを、簡単なWebアプリを作って確認してみました。Web開発に慣れている方にとっては当たり前の内容かもしれませんが、この種の経験に乏しい私も実装して記事に書いてみて、少し理解が深まったのは良かったです。