Hono練習
実践Node.js入門
6章 ExpressによるREST APIサーバー/Webサーバー
を Bun + Honoで書き換える練習。
正確にはサンプルはJavaScriptコードなのでBEFOREコードには型定義が不足しているがそこはスキップ。
req, res -> c
の置き換えは機械的
// BEFORE
app.get(" ... ", (req, res) => { ... })
return res.status(200).send(" ... ")
// AFTER
app.get(" ... ", (c) => { ... })
c.status(200)
return c.text(" ... ")
// or return c.json({ ... })
6.4 データベース連携
redis(ioredis)は基本的にそのまま使いまわし可能
$ bun run src/index.ts
で既に port:3000 で立ち上がるおかげで app.listen(3000, () => { ... })
は不要に
const redis = new Redis({
port: 6379,
host: "localhost",
password: process.env.REDIS_PASSWORD,
offlineQueue: false,
})
const init = async () => {
await Promise.all([
redis.set("users:1", JSON.stringify({ id: 1, name: "alpha" })),
redis.set("users:2", JSON.stringify({ id: 2, name: "beta" })),
redis.set("users:3", JSON.stringify({ id: 3, name: "gamma" })),
redis.set("users:4", JSON.stringify({ id: 4, name: "delta" })),
])
}
// BEFORE
app.get("/user/:id", async (req, res) => {
try {
const key = `users:${req.param.id}`
const val = await redis.get(key)
const user = JSON.parse(val)
res.status(200).json(user)
} catch (err) {
console.error(err)
res.status(500).send('internal error')
}
})
// AFTER
app.get("/user/:id", async (c) => {
try {
const key = `users:${c.req.param("id")}`
const val = await redis.get(key)
const user = JSON.parse(val ?? "")
c.status(200)
return c.json(user)
} catch (err) {
console.error(err)
return c.text("Internal Server Error", 500)
}
})
6.5 ビューテンプレート
ejs苦手なのでjsxで
事前に
-
index.ts
->index.tsx
のrename - tsconfig.json の編集
が必要 同一ファイルで済むようになるの良い
(user.ejs / Users.tsx もほぼ変わらないので省略)
BEFORE
<!-- index.ejs -->
<!DOCTYPE html>
<html lang="ja">
<head>
...
...
app.get("/", (req, res) => {
res.render(path.join(__dirname, "views", "index.ejs"))
})
AFTER
const Top = () => {
return (
<html lang="ja">
<head>
...
</html>
)
}
app.get("/", (c) => {
return c.html(<Top />)
})
6.8 ファイル分割の実践
Hono 内の関数の分離では引数のお作法とかあるんだろうか
const func = (c: Context) => { ... }
OR
const func = (req: HonoRequest) => { ... }
OR
const func = (id: string) => { ... }
??
結局 scanStream
で進めるんか…?の件
getUsersロジック比較 by claude
下記はHonoで記述された3通りのユーザーリスト取得エンドポイントです。
これらのロジックについて、パフォーマンスや許容可能なリクエスト数、レスポンス量などの観点から比較してもらえますか?
app.get("/users", async (c) => {
const offset = Number(c.req.query("offset") ?? 0)
const usersList = await redis
.getClient()
.lrange("users:list", offset, offset + 1)
const users = usersList.map((user) => JSON.parse(user))
return c.json(users)
})
app.get("/users", async (c) => {
const stream = redis.getClient().scanStream({
match: "users:*",
count: 2,
})
const users = []
for await (const keys of stream) {
const usersList = await Promise.all(
keys.map(async (key) => {
const val = await redis.getClient().get(key)
return JSON.parse(val ?? "")
}),
)
users.push(...usersList)
}
return c.json(users)
})
app.get("/users", async (c) => {
const stream = redis.getClient().scanStream({
match: "users:*",
count: 2,
})
const users = []
for await (const keys of stream) {
for (const key of keys) {
const val = await redis.getClient().get(key)
users.push(JSON.parse(val ?? ""))
}
}
return c.json(users)
})
はい、これらの3つのエンドポイントについて、パフォーマンスや許容可能なリクエスト数、レスポンス量などの観点から比較してみましょう。
- 最初のエンドポイント(LRANGEを使用):
app.get("/users", async (c) => {
const offset = Number(c.req.query("offset") ?? 0)
const usersList = await redis
.getClient()
.lrange("users:list", offset, offset + 1)
const users = usersList.map((user) => JSON.parse(user))
return c.json(users)
})
パフォーマンス: 最も高速で効率的です。
許容可能なリクエスト数: 非常に多くのリクエストを処理できます。
レスポンス量: 一度に2ユーザーのみを返すため、レスポンスサイズは小さいです。
特徴:
- LRANGEコマンドは、指定された範囲のリスト要素を効率的に取得します。
- オフセットを使用して簡単にページネーションを実装できます。
- ユーザー数が増えても、パフォーマンスは一定です。
- 2番目のエンドポイント(SCANSTREAMを使用し、非同期でキーを処理):
app.get("/users", async (c) => {
const stream = redis.getClient().scanStream({
match: "users:*",
count: 2,
})
const users = []
for await (const keys of stream) {
const usersList = await Promise.all(
keys.map(async (key) => {
const val = await redis.getClient().get(key)
return JSON.parse(val ?? "")
}),
)
users.push(...usersList)
}
return c.json(users)
})
パフォーマンス: 中程度。SCANは効率的ですが、すべてのユーザーを取得します。
許容可能なリクエスト数: 中程度。ユーザー数が多い場合、処理に時間がかかります。
レスポンス量: すべてのユーザーを返すため、大きくなる可能性があります。
特徴:
- SCANSTREAMを使用してキーをイテレートします。
- 各バッチのキーを非同期で処理するため、一部の並列処理が可能です。
- ユーザー数が増えると、処理時間とメモリ使用量が増加します。
- 3番目のエンドポイント(SCANSTREAMを使用し、同期でキーを処理):
app.get("/users", async (c) => {
const stream = redis.getClient().scanStream({
match: "users:*",
count: 2,
})
const users = []
for await (const keys of stream) {
for (const key of keys) {
const val = await redis.getClient().get(key)
users.push(JSON.parse(val ?? ""))
}
}
return c.json(users)
})
パフォーマンス: 最も遅いです。各キーを順次処理します。
許容可能なリクエスト数: 最も少ないです。処理に時間がかかります。
レスポンス量: すべてのユーザーを返すため、大きくなる可能性があります。
特徴:
- SCANSTREAMを使用してキーをイテレートします。
- キーを1つずつ同期的に処理するため、最も遅いアプローチです。
- ユーザー数が増えると、処理時間が大幅に増加します。
総合的な比較:
- 最初のアプローチが最も効率的で、大規模なユーザーベースにも対応できます。ただし、リストの順序が重要な場合にのみ適しています。
- 2番目のアプローチは、すべてのユーザーを取得する必要がある場合に適していますが、ユーザー数が多い場合はパフォーマンスが低下する可能性があります。
- 3番目のアプローチは、最も遅く、大規模なデータセットには適していません。
実際の使用ケースに応じて、これらのアプローチを選択または組み合わせることをお勧めします。例えば、ページネーションを実装する場合は最初のアプローチが最適ですが、すべてのユーザーに対して一括処理を行う必要がある場合は、2番目のアプローチを使用し、適切なバッチサイズを設定することが考えられます。
6.9 ハンドラーのテスト
(○ンドラーの○ストみたい)
redis.scanStream({ ... })
ではなく redis.lrange({ ... })
を用いたパターンのテストを "bun:test" で書いてみる…インポート先を変えるだけでだいたい対応してた
ここではscanStream(=AsyncIterator)を使っていないため 6.10 はスキップ
(追記)ただリスト型でredisにデータ投入すると単一ユーザ取得が煩雑になりそうなので、7章では結局 scanStream に戻す
テスト対象コード
import * as redis from "../lib/redis"
const getUser = async (userId: string) => {
const key = `users:${userId}`
const val = await redis.getClient().get(key)
return JSON.parse(val ?? "")
}
const getUsers = async (offset?: string) => {
const argOffset = Number(offset) || 0
const usersList = await redis
.getClient()
.lrange("users:list", argOffset, argOffset + 1)
return usersList.map((user) => JSON.parse(user))
}
export { getUser, getUsers }
テストコード
// bunで置き換え
import { beforeEach, expect, jest, mock, test } from "bun:test"
import { getUser, getUsers } from "./users"
const mockRedisGet = jest.fn()
const mockRedisLrange = jest.fn()
mock.module("../lib/redis", () => {
return {
getClient: jest.fn().mockImplementation(() => {
return {
get: mockRedisGet,
lrange: mockRedisLrange,
}
}),
}
})
beforeEach(() => {
mockRedisGet.mockClear()
mockRedisLrange.mockClear()
})
test("test getUser", async () => {
mockRedisGet.mockResolvedValue(JSON.stringify({ id: 1, name: "alpha" }))
const res = await getUser("1")
// test return value
expect(res.id).toBe(1)
expect(res.name).toBe("alpha")
// test mock call count
expect(mockRedisGet).toHaveBeenCalledTimes(1)
// mock arg test
const [arg1] = mockRedisGet.mock.calls[0]
expect(arg1).toStrictEqual("users:1")
})
test("test getUsers return value", async () => {
// 1. scanStream -> lrange の置き換え
// 2. req -> c.req.XX の変数置き換え(HonoRequestではなくパラメータを引数に)
// した影響で諸々書き換え
const baseData = [
{ id: 1, name: "alpha" },
{ id: 2, name: "bravo" },
{ id: 3, name: "charlie" },
{ id: 4, name: "delta" },
]
mockRedisLrange.mockResolvedValue(baseData.map((d) => JSON.stringify(d)))
const users = await getUsers()
expect(mockRedisLrange).toHaveBeenCalledTimes(1)
expect(users.length).toStrictEqual(4)
expect(users).toStrictEqual(baseData)
})
// おまけ- 引数チェック
test("test getUsers arg", async () => {
const _ = await getUsers("5")
expect(mockRedisLrange).toHaveBeenCalledWith("users:list", 5, 6)
})
$ bun test src/handlers/users.test.ts
bun test v1.1.29 (6d43b366)
src/handlers/users.test.ts:
✓ test getUser
✓ test getUsers return value
✓ test getUsers arg
3 pass
0 fail
8 expect() calls
Ran 3 tests across 1 files. [65.00ms]
6.12 ハンドラー単位の分割とテストしやすさ
6.12.1 ハンドラー単位の分割とwrap関数
- Honoは
app.onError()
でグローバルエラーハンドラーが設定可能 - 非同期処理を直接使用可能
BEFORE
const wrapAPI = (fn) => {
return (req, res, next) => {
try {
fn(req)
.then((data) => res.status(200).json(data))
.catch((e) => next(e));
} catch (e) {
next(e);
}
};
};
const someHandler = async (req) => {
const error = new Error('some error');
error.status = 400;
throw error;
};
app.get('/user/:id', wrapAPI(someHandler));
app.use((err, req, res, next) => {
if (err.status) {
return res.status(err.status).send(err.message);
}
res.status(500).send('Internal Server Error');
console.error('[Internal Server Error]', err);
}
AFTER
app.onError((err, c) => {
if (err instanceof HTTPException) {
return c.json({ message: err.message }, err.status)
}
console.error('[Internal Server Error]', err)
return c.json({ message: 'Internal Server Error' }, 500)
})
const someHandler = async (c) => {
const error = new HTTPException(400, { message: 'some error' })
throw error
}
app.get('/user/:id', someHandler)
6.12.2 ハンドラー単位の分割とエラーハンドリング
エラークラスのインスタンスに応じた呼び分けについては Honoを使い倒したい2024 | エラーハンドリングの例 辺りがそのまま参考になりそう
7章 フロントエンド/バックエンドの開発
を Bun + Hono + Vite + Turborepoで書き換える練習
膨れたら記事か何かで書き直す想定
HonoXも択としてアリだけど章の趣旨からは外れる気がする
知らないの掛け算なのでまず読む
bun(pnpm) + monorepo
turborepo
biome + vscode + monorepo
一旦この方向性でやってみる
tsconfigの共通括り出しは却って煩雑になりそうだったのでスキップ、biome.json の括り出しも非対応のようなのでバージョンのみ管理
ここをスタート地点とする
$ tree -I "node_modules|dist|public"
.
├── apps
│ ├── backend
│ │ ├── package.json
│ │ ├── README.md
│ │ ├── src
│ │ │ ├── components
│ │ │ │ ├── Top.tsx
│ │ │ │ └── Users.tsx
│ │ │ ├── config.ts
│ │ │ ├── handlers
│ │ │ │ ├── users.test.ts
│ │ │ │ └── users.ts
│ │ │ ├── index.test.ts
│ │ │ ├── index.tsx
│ │ │ └── lib
│ │ │ └── redis.ts
│ │ └── tsconfig.json
│ └── frontend
│ ├── index.html
│ ├── package.json
│ ├── README.md
│ ├── src
│ │ ├── App.tsx
│ │ ├── main.tsx
│ │ └── vite-env.d.ts
│ ├── tsconfig.app.json
│ ├── tsconfig.app.tsbuildinfo
│ ├── tsconfig.json
│ ├── tsconfig.node.json
│ ├── tsconfig.node.tsbuildinfo
│ └── vite.config.ts
├── biome.json
├── bun.lockb
├── package.json
├── packages
│ ├── biome
│ │ └── package.json
│ └── typescript
│ └── package.json
├── README.md
└── turbo.json
biome.json
は使いまわし、 turbo.json
はドキュメントからそのまま拝借
{
"$schema": "https://biomejs.dev/schemas/1.9.2/schema.json",
"organizeImports": {
"enabled": true
},
"files": { "ignoreUnknown": true },
"linter": {
"enabled": true,
"ignore": ["node_modules"],
"rules": {
"recommended": true
}
},
"formatter": {
"enabled": true,
"ignore": ["node_modules"],
"indentStyle": "space"
},
"javascript": {
"parser": {
"unsafeParameterDecoratorsEnabled": true
},
"formatter": {
"semicolons": "asNeeded"
}
}
}
{
"$schema": "https://turbo.build/schema.json",
"tasks": {
"build": {
"outputs": ["dist/**"]
},
"check-types": {
"dependsOn": ["^check-types"]
},
"dev": {
"persistent": true,
"cache": false
}
}
}
前述の通り現状はmonorepo + vscode 環境で不安定そうだったので packages/biome
, packages/typescript
は設定ファイルの切り出しは行わず、とはいえ分離はしてみたかったので package.json
のみ切り出してみる
{
"name": "@packages/biome",
"private": true,
"version": "1.0.0",
"devDependencies": {
"@biomejs/biome": "^1.9.2"
}
}
{
"name": "@packages/typescript",
"private": true,
"version": "1.0.0",
"devDependencies": {
"typescript": "^5.5.3"
}
}
7.8 APIから取得した値を表示する
7.8.5 Proxyを導入してドメインを同じにする
上記設定を済ませたらここまで特に詰まることもなかった
proxyの設定は vite.config
で行う
// https://vitejs.dev/config/
export default defineConfig({
plugins: [react()],
+ server: {
+ proxy: {
+ "/api": {
+ target: "http://localhost:3000",
+ changeOrigin: true,
+ },
+ },
+ },
})
7.11 フロントエンドアプリケーションのデプロイ
Honoのデプロイ周りを全然知らない、というかデプロイ周りを全然知らない
Cloudflare WorkersだのPagesだのをこの機会に読む
書籍の意図を踏まえるとVite側でも server.js
ファイルを作成するべきなんだろうけど…この組み合わせだとだいぶ煩雑になってしまうかも
react deploy / vite deploy (ChatGPT 4o with canvas)
React単体(Create React Appを使ったものなど)とViteの構成での配信用サーバの選択肢を比較し、それぞれの違いをまとめてみます。
1. React単体とViteの違い
Reactのフロントエンド環境を構築する際に、Create React App(CRA)やViteを使うことが一般的ですが、それぞれに以下の違いがあります。
- Create React App(CRA): Reactの公式ツールチェーンで、プロジェクトの設定や構築を簡素化する目的で使われます。ただし、ビルドプロセスはやや遅い傾向があります。
- Vite: 新しいビルドツールで、超高速なビルドとホットリロードが特徴です。Viteは、軽量で開発時のユーザー体験が向上するため、多くのプロジェクトで採用が増えています。
2. 配信用サーバの選択肢と比較
以下、React単体(CRA)とViteの両方で配信用サーバを用いる際の選択肢について、それぞれの利点や特性を比較してまとめます。
配信用サーバ | React単体(CRA)での利用 | Viteでの利用 | 利点と特性の比較 |
---|---|---|---|
Express | - Reactのビルド成果物をexpress.static() で配信。- APIと統合し、統合デプロイに適している。 |
- Viteのビルド成果物をexpress.static() で配信。- 統合デプロイやプロキシ設定も可能。 |
- 統合デプロイに適しており、APIと静的ファイルを同じサーバで配信可能。 - シンプルな設定で配信可能。 |
Nginx | - Reactのビルド成果物をNginxで静的に配信。 - 本番環境に向いている。 |
- Viteで生成された成果物もNginxで配信可能。 | - 高パフォーマンスな静的配信に最適。 - Viteの軽量な成果物と相性が良い。 - 分離デプロイに向いている。 |
Apache | - ビルド成果物をApacheで配信。 - Nginxと同様、安定したサーバとして利用可能。 |
- Viteの成果物もApacheで配信可能。 | - 信頼性のあるサーバで、安定した配信を行うことができるが、設定がやや複雑。 - 高パフォーマンスではNginxが優れるが、Apacheも十分対応可能。 |
静的ホスティングサービス (Netlify, Vercel, GitHub Pages) |
- Reactのビルド成果物をアップロードするだけで簡単にデプロイ可能。 | - Viteの成果物も同様にホスティング可能。 | - 簡単で迅速なデプロイが可能。 - 分離デプロイに向いており、設定が最小限で済むため初心者にも使いやすい。 |
サーバーレスプラットフォーム (AWS S3 + CloudFront) |
- AWS S3にビルド成果物をアップロードし、CloudFrontでCDN配信。 | - Viteの成果物も同様に配信可能。 | - 世界中に高パフォーマンスで配信可能。 - コストパフォーマンスに優れており、大規模トラフィックにも対応。 |
Vite独自の開発サーバ | - 利用不可(CRAにはない)。 | - Viteの開発サーバで超高速なホットリロードを提供。 | - 主に開発時に使用され、本番環境には適さないが、開発体験を向上させる。 |
3. React単体(CRA)とViteにおけるサーバの選択
-
Expressの利用:
- React単体(CRA)でもViteでも、バックエンドと統合した形でデプロイしたい場合にExpressがよく使われます。APIと静的ファイルを同一サーバで提供するため、CORSの問題を回避しやすく、簡単に一元管理できるという利点があります。
-
NginxやApacheの利用:
- 両方のツールチェーンとも、NginxやApacheなどのWebサーバで本番環境に静的に配信することが可能です。Nginxは高パフォーマンスで軽量なため、特にViteでの成果物の配信に適しています。
-
静的ホスティングサービス:
- React単体でもViteでも、NetlifyやVercelなどの静的ホスティングサービスを使うことで、簡単に分離デプロイが可能です。この選択肢は、サーバの設定が不要で迅速なデプロイが求められる場合に特に有効です。
-
サーバーレスプラットフォーム(AWS S3 + CloudFront):
- 両方ともに、AWS S3に静的ファイルを配置し、CloudFrontでCDNを利用して世界中に配信する方法もあります。これは高可用性とスケーラビリティが求められる場合に適しています。
まとめ
- React単体(CRA)とViteの違いは主に開発体験とビルドの速さにありますが、配信用サーバの選択肢はどちらも基本的に同じです。
- Express は統合デプロイに適しており、APIと静的ファイルを同じサーバで配信したい場合に便利です。
- Nginx や Apache は高パフォーマンスな静的ファイル配信に適しており、特にViteの軽量な成果物の配信に相性が良いです。
- Netlify や Vercel などの静的ホスティングサービスは、シンプルで迅速なデプロイに優れており、初心者にも扱いやすいです。
- サーバーレスプラットフォーム(AWS S3 + CloudFrontなど)は、大規模トラフィックやグローバルな配信が求められる場合に最適です。
どちらを選ぶかは、プロジェクトの規模、必要なスケーラビリティ、そしてデプロイの簡単さのバランスを考慮して決めると良いでしょう。
react deploy / vite deploy (Claude 3.5 sonnet)
React単体でも「配信用サーバの立ち上げ」は可能です。ただし、通常は追加のツールやライブラリを使用します。
- create-react-appを使用している場合:
create-react-appには開発用サーバーが含まれており、npm start
コマンドで起動できます。これは開発環境用です。
本番環境では、npm run build
でビルドした後、以下のようなシンプルなサーバーを使用できます:
const express = require('express');
const path = require('path');
const app = express();
app.use(express.static(path.join(__dirname, 'build')));
app.get('*', function (req, res) {
res.sendFile(path.join(__dirname, 'build', 'index.html'));
});
const PORT = process.env.PORT || 3000;
app.listen(PORT, () => {
console.log(`Server is running on port ${PORT}`);
});
- カスタム設定の場合:
Reactアプリケーションを手動で設定している場合、以下のようなオプションがあります:
-
serve
パッケージ: npmのserve
パッケージを使用して静的ファイルを提供できます。npm install -g serve serve -s build
-
http-server
: 別の軽量な静的ファイルサーバーオプション。npm install -g http-server http-server ./build
- Viteを使用している場合:
Viteには開発サーバーと本番用のプレビューサーバーが含まれています。
- 開発:
npm run dev
- 本番プレビュー:
npm run build
後npm run preview
つまり、Expressを使用する必要は必ずしもありません。Expressを選択する主な理由は:
- カスタムサーバーロジックの追加(例:APIプロキシ)
- より細かな設定やミドルウェアの使用
- サーバーサイドレンダリング(SSR)の実装
- 既存のExpressベースのバックエンドとの統合
Honoを使用する場合、同様の機能を提供しつつ、より軽量で高速なサーバーを実現できます。例えば:
import { Hono } from 'hono'
import { serveStatic } from 'hono/serve-static'
const app = new Hono()
// 静的ファイルの提供
app.use('/*', serveStatic({ root: './build' }))
// クライアントサイドルーティングのサポート
app.get('*', (c) => c.html('./build/index.html'))
export default app
このように、Reactアプリケーションの配信には必ずしもExpressが必要というわけではありません。プロジェクトの要件や好みに応じて、適切なツールを選択できます。
7.12 フロントエンドのテスト
さすがに vitest 利用か
一般的なセットアップを参考にさせて頂きつつ、 vitest-setup.ts
ほどは触れない感じで
…と思ってたけど最低限の処理扱うために色々入れる必要あって結局膨れた
{
...
"devDependencies": {
"@packages/biome": "workspace:*",
"@packages/typescript": "workspace:*",
+ "@testing-library/jest-dom": "^6.5.0",
+ "@testing-library/react": "^16.0.1",
"@types/react": "^18.3.10",
"@types/react-dom": "^18.3.0",
"@vitejs/plugin-react-swc": "^3.5.0",
"globals": "^15.9.0",
+ "happy-dom": "^15.7.4",
"vite": "^5.4.8",
+ "vite-tsconfig-paths": "^5.0.1",
+ "vitest": "^2.1.2"
}
}
{
...
+ /* Testing */
+ "types": ["vitest/globals", "@testing-library/jest-dom"]
},
+ "include": [
+ "src",
+ "node_modules/vitest/global.d.ts",
+ "vite.config.ts",
+ "vite-setup.ts"
+ ]
}