この記事何?
エンジニア歴1年半。業務で利用しているHonoが大好きだが、TSもプログラミング知識も弱々すぎて上手く使いこなせない。速いはずが多分生かせてない。
ちゃんと理解するためにソースコードリーディングしたい。
でも目的なくソースコード眺めても意味ないし……ねむい……
そうだ!PRを全部読めば、変遷やなぜ変更されているかストーリー的に分かるのでは。
と思ったのでチャレンジしてみる。PR2000以上あるので、全部できるかは知らん。
https://github.com/honojs/hono
#116 refactor: compose
compose関数のリファクタリング
変更前
export const compose = <T>(middleware: Function[], onError?: Function) => {
const errors: Error[] = [] // 複数エラーを配列で管理(なぜ?)
return function (context: T, next?: Function) {
let index = -1
return dispatch(0)
async function dispatch(i: number): Promise<object | void> { // 戻り値が曖昧
// ...
try {
return Promise.resolve(fn(context, dispatch.bind(null, i + 1))).catch((e) => {
errors.push(e) // ← エラーを蓄積
if (onError && context instanceof Context) {
context.res = onError(errors[0], context) // 最初のエラーだけ使う
} else {
throw errors[0]
}
})
} catch (err) {
return Promise.reject(err)
}
}
}
}
- errors 配列を作るが、結局最初のエラーしか使わない
- 戻り値の型が
Promise<object | void>で曖昧 - try-catchと.catch()が混在して複雑
変更後: シンプルに
export const compose = <T>(middleware: Function[], onError?: Function) => {
return function (context: T, next?: Function) {
let index = -1
return dispatch(0)
async function dispatch(i: number): Promise<T> { // 戻り値が明確
if (i <= index) return Promise.reject(new Error('next() called multiple times'))
index = i
let fn = middleware[i]
if (i === middleware.length) fn = next
if (!fn) return context // contextを返す
// .then()/.catch() で統一
return Promise.resolve(fn(context, dispatch.bind(null, i + 1)))
.then(() => {
return context // 常にcontextを返す
})
.catch((err) => {
if (onError && context instanceof Context) {
context.res = onError(err, context) // 直接errを使う
return context
} else {
throw err
}
})
}
}
}
#117 feat: app.notFound() and app.onError()
BREAKING CHANGES
app.notFound() と app.onError() のAPI改善
変更前: プロパティに直接代入
app.onError = (err, c) => {
return c.text('Error', 500)
}
app.notFound = (c) => {
return c.text('Not Found', 404)
}
問題点:
- メソッドチェーンができない
変更後: メソッドとして呼び出す
app.notFound((c) => {
return c.text('Custom 404', 404)
})
app.onError((err, c) => {
console.error(err)
return c.text('Custom Error', 500)
})
// メソッドチェーンも可能
const app = new Hono()
.notFound((c) => c.text('Not Found', 404))
.onError((err, c) => c.text('Error', 500))
これよくわかんなかった。なんで変更したんだろう。
書き方を揃えるってことかな。
#118 refactor: create utils/crypto.ts
utils/crypto.ts の作成
変更前のファイル構造:
src/utils/
├── buffer.ts ← ここに色々詰め込んでいた
│ ├── sha256()
│ ├── sha1()
│ ├── decodeBase64()
│ ├── timingSafeEqual()
│ └── equal()
└── ...
変更後のファイル構造:
src/utils/
├── buffer.ts ← バッファ関連のみ
│ ├── timingSafeEqual()
│ └── equal()
├── crypto.ts ← 暗号関連を分離
│ ├── sha256()
│ ├── sha1()
│ ├── createHash() ← 新規追加(汎用化)
│ └── decodeBase64()
└── ...
#119 feat: etag middleware
ETagミドルウェアの追加
ETagとは?
ETag (またはエンティティタグ)は HTTP のレスポンスヘッダーで、リソースの特定バージョンの識別子です。ウェブサーバーは、コンテンツが変更されていない場合はレスポンス全体を再送する必要がないので、キャッシュがより効率的になり通信帯域を節約することができます。加えて、 ETag はリソースが同時に更新されて互いを上書きすること (「空中衝突」) を防ぐのに役立ちます。
ブラウザキャッシュを利用する仕組み。
それを実現するためのHTTPレスポンスHeader。
HTTPキャッシュの仕組み:
1回目のリクエスト:
Client → GET /api/users → Server
Client ← { users: [...] } ← ETag: "abc123" (ハッシュ値)
└── クライアントは保存
2回目のリクエスト:
Client → GET /api/users
If-None-Match: "abc123" ← 前回のETagを送る
Server: 「データ変わってないな...」
Client ← 304 Not Modified ← ボディなしなので軽い
└── クライアントは保存したデータを使う
メリット:
- 帯域幅の節約(変更なければボディを送らない)
- サーバー負荷軽減
- レスポンス時間短縮
実装コード
import { sha1 } from '../../utils/crypto'
type ETagOptions = {
weak: boolean // 弱いETag(W/プレフィックス)を使うか
}
export const etag = (options: ETagOptions = { weak: false }) => {
return async (c: Context, next: Function) => {
// クライアントが送ってきたETagを取得
const ifNoneMatch = c.req.header('If-None-Match')
await next() // 次のハンドラーを実行(レスポンスが作られる)
// レスポンスボディからハッシュを計算
const clone = c.res.clone()
const body = await parseBody(c.res)
const hash = await sha1(body)
// ETag値を作成(弱いETagは W/ プレフィックス付き)
const etag = options.weak ? `W/"${hash}"` : `"${hash}"`
// クライアントのETagと一致したら304を返す
if (ifNoneMatch && ifNoneMatch === etag) {
c.res = new Response(null, {
status: 304,
statusText: 'Not Modified',
})
c.res.headers.delete('Content-Length')
} else {
// 一致しなければETagヘッダーを付けて返す
c.res = new Response(clone.body, clone)
c.res.headers.append('ETag', etag)
}
}
}
使い方
import { Hono } from 'hono'
import { etag } from 'hono/etag'
const app = new Hono()
// 全ルートにETag適用
app.use('*', etag())
// または弱いETag
app.use('*', etag({ weak: true }))
app.get('/api/users', (c) => {
return c.json({ users: [...] })
})
弱いETagってなんだ、かわいいな。
ディレクティブ
W/ 省略可
'W/' (大文字) は弱いバリデーターを使用することを示します。弱い ETag は生成が簡単ですが、比較にはあまり役立ちません。強力なバリデーターは比較には理想的ですが、効率的に生成するのはとても困難です。同じリソースを表現する 2 つの弱い ETag の値があった場合、意味的には同等ですが、バイト単位では同じではない可能性があります。すなわち、弱い ETag はバイト範囲指定のリクエストが行われたときにキャッシュされませんが、強い ETag は範囲指定のリクエストもキャッシュします。
強いETag(デフォルト):
"abc123"
→ バイト単位で完全一致が必要
弱いETag:
W/"abc123"
→ 意味的に同等なら一致とみなす
→ 圧縮前後で同じ扱いにしたい時など
#120 chore: update benchmark script
ベンチマークスクリプトの更新