
前回の記事で、技術選定の理由と経緯について書きました。 今回は、実際の開発で遭遇した課題と、それをどう解決したかをまとめます。
開発で遭遇する課題は避けられないものですが、解決プロセスを記録しておくことで、同じような課題に遭遇した人への参考になればと思います。
この記事では、以下の6つの課題について、問題の概要、解決プロセス、解決方法を詳しく書いていきます。
Next.js 15のApp Routerを使い始めたとき、Server ComponentsとClient Componentsの使い分けで何度もハマりました。
最初は、すべてのコンポーネントをServer Componentsとして書いていました。 しかし、以下のような機能を実装しようとすると、エラーが発生しました。
localStorageやwindowオブジェクトなど、ブラウザ専用のAPIuseStateやuseEffectなどのReact Hooksエラーメッセージは「use clientディレクティブが必要です」というものでした。
最初は、このエラーの意味がよく分からず、どこに"use client"を追加すればいいのか迷いました。
Next.jsの公式ドキュメントを読んで、Server ComponentsとClient Componentsの違いを理解しました。
Server Components
Client Components
"use client"ディレクティブが必要解決方法として、コンポーネントを分割しました。
例えば、記事一覧ページでは
// Server Component(データ取得)
export default async function ArticlesPage() {
const articles = await getArticles();
return <ArticleList articles={articles} />;
}
// Client Component(インタラクティブなUI)
"use client";
export function ArticleList({ articles }) {
const [filter, setFilter] = useState("");
// ...
}
このように、データ取得はServer Componentで行い、インタラクティブな部分はClient Componentに分けました。
状態管理が必要な場合、以下の方針を取りました
useStateやuseEffectを使う場合は、Client ComponentにするNext.js 15のApp Routerでは、デフォルトでServer Componentsという点が重要です。
慣れてくると、Server ComponentsとClient Componentsを適切に使い分けることで、パフォーマンスと開発体験の両方を向上させられることが分かりました。
Cloudflare Workersは、Edge Runtimeで動作します。 Edge Runtimeは、Node.jsのランタイムとは異なり、一部のNode.js APIが使えません。
最初は、Node.jsで動くパッケージをそのまま使おうとして、エラーが発生しました。
特に、reading-timeパッケージを使おうとしたとき、Node.js専用のAPIを使っているため、Edge Runtimeで動作しませんでした。
エラーメッセージは「process is not defined」や「fs is not defined」といったものでした。これらは、Node.jsのグローバルオブジェクトですが、Edge Runtimeでは使えません。
エラーログを読むと、どのAPIが使えないかが分かりました。Cloudflare Workersのドキュメントを確認すると、Edge Runtimeで使えるAPIと使えないAPIのリストがありました。
Cloudflare Workersの公式ドキュメントを読んで、Edge Runtimeの制約を理解しました。
fetch、URL、TextEncoderなど)fs、path、processなど)reading-timeパッケージの代わりに、独自実装を作ることにしました。
読了時間の計算は、文字数を数えて、読書速度で割るだけなので、シンプルな実装で対応できます。
reading-timeパッケージの独自実装記事を表示するコンポーネントに、読了時間を計算するメソッドを追加しました。
static calculateReadingTime(content: string): number {
// マークダウン記号やHTMLタグを除去して文字数をカウント
const text = content
.replace(/```[\s\S]*?```/g, "") // コードブロックを除去
.replace(/`[^`]+`/g, "") // インラインコードを除去
.replace(/\[([^\]]+)\]\([^)]+\)/g, "$1") // リンクのテキストのみ抽出
.replace(/[#*_~`[\]()]/g, "") // マークダウン記号を除去
.replace(/<[^>]+>/g, "") // HTMLタグを除去
.replace(/\s+/g, " ") // 連続する空白を1つに
.trim();
const characterCount = text.length;
// 日本語の場合は200文字/分を基準とする
const minutes = Math.ceil(characterCount / 200);
return Math.max(1, minutes); // 最低1分
}
この実装は、Web標準のAPIのみを使っているため、Edge Runtimeで動作します。
Edge Runtimeでは、Web標準のAPIを使うことが重要です。
例えば、
String.prototype.replace()やString.prototype.trim()RegExpオブジェクトMath.ceil()やMath.max()これらは、すべてWeb標準のAPIなので、Edge Runtimeで問題なく動作します。
Edge Runtimeの制約は、最初は不便に感じましたが、Web標準のAPIを使うことで、よりポータブルなコードを書けることが分かりました。また、Node.js専用のパッケージに依存しないことで、コードの見通しも良くなりました。
記事一覧ページを実装したとき、記事の取得が遅いという問題が発生しました。 特に、記事数が増えてきたときに、ページの読み込み時間が長くなりました。
最初は、単純にSELECT * FROM articles ORDER BY updated_at DESCというクエリを実行していました。しかし、記事数が増えると、このクエリの実行時間が長くなりました。
まず、どのクエリが遅いのかを特定するため、パフォーマンス計測を行いました。D1では、クエリの実行時間をログで確認できます。
ボトルネックを特定すると、以下の問題が分かりました。
updated_atカラムにインデックスがなかったマイグレーションファイルに、以下のインデックスを追加しました。
CREATE INDEX idx_articles_updated_at ON articles(updated_at DESC);
CREATE INDEX idx_articles_draft ON articles(draft);
CREATE INDEX idx_articles_draft_updated ON articles(draft, updated_at DESC);
CREATE INDEX idx_articles_created_at ON articles(created_at DESC);
これにより、ORDER BY updated_at DESCのクエリが高速化されました。
クエリを見直して、必要なカラムのみを取得するようにしました。 また、ページネーションを実装して、一度に取得する記事数を制限しました。
ページネーションを実装することで、一度に取得する記事数を制限しました。これにより、クエリの実行時間が短縮されました。
インデックスを追加したことで、記事一覧の取得時間が大幅に短縮されました。
データベースのクエリ最適化では、インデックスの重要性を実感しました。適切なインデックスを設定することで、クエリの実行時間を大幅に短縮できます。また、ページネーションを実装することで、大量のデータを扱う場合でも、パフォーマンスを維持できます。
マークダウンをHTMLに変換する際、XSS(Cross-Site Scripting)攻撃のリスクがあります。マークダウンに悪意のあるHTMLやJavaScriptが含まれている場合、そのままHTMLに変換すると、セキュリティ上の問題が発生します。
最初は、マークダウンをそのままHTMLに変換していましたが、セキュリティの観点から、**サニタイズ(無害化)**が必要だと気づきました。
セキュリティのベストプラクティスを調査して、マークダウンのサニタイズ方法を調べました。一般的には、ホワイトリスト方式で、許可された要素や属性のみを残す方法が推奨されています。
サニタイズライブラリを比較して、rehype-sanitizeを選びました。理由は以下の通りです。
rehypeを使っていたため、統合しやすいシンタックスハイライトやコードコピーボタンなどの機能を維持するため、カスタムスキーマを設計しました。デフォルトのスキーマでは、これらの機能に必要な要素や属性が削除されてしまうためです。
rehype-sanitizeの導入markdown.tsに、rehype-sanitizeを追加しました。処理の順序が重要で、シンタックスハイライトやコードコピーボタンを追加した後にサニタイズする必要があります。
const result = await unified()
.use(remarkParse)
.use(remarkGfm)
.use(remarkRehype, { allowDangerousHtml: true })
.use(rehypeRaw)
.use(rehypeSlug)
.use(rehypeLinkIcon)
.use(rehypeHighlight) // シンタックスハイライト
.use(rehypeCodeCopy) // コードコピーボタン
.use(rehypeSanitize, sanitizeSchema) // サニタイズは最後
.use(rehypeStringify)
.process(markdown);
カスタムスキーマで、以下の要素や属性を許可しました:
hljs-*クラス、language-*クラスbutton要素、data-code属性svg要素、path要素id属性これにより、必要な機能を維持しながら、XSS攻撃を防ぐことができました。
セキュリティと機能の両立は、バランスが重要です。過度にサニタイズすると、必要な機能が動かなくなる可能性があります。一方で、サニタイズが不十分だと、セキュリティ上の問題が発生します。
カスタムスキーマを設計することで、必要な要素や属性のみを許可し、セキュリティと機能の両立を実現できました。
記事にアイキャッチ画像を設定する機能を実装する際、画像のアップロード方法で迷いました。
最初は、サーバーを経由してアップロードしようと考えましたが、以下の問題がありました。
Presigned URLという仕組みがあることを知りました。Presigned URLは、一時的なURLを生成して、クライアントから直接オブジェクトストレージにアップロードできる方式です。
Cloudflare R2は、AWS S3と互換のAPIを持っているため、S3のPresigned URLの仕組みがそのまま使えます。R2のドキュメントを確認して、Presigned URLの生成方法を調べました。
エラーケースを洗い出して、以下のケースに対応する必要があることが分かりました。
Presigned URLを生成するAPIエンドポイントを作成しました。@aws-sdk/s3-request-presignerを使って、Presigned URLを生成します。
import { S3Client, PutObjectCommand } from "@aws-sdk/client-s3";
import { getSignedUrl } from "@aws-sdk/s3-request-presigner";
const s3Client = new S3Client({
region: "auto",
endpoint: r2Endpoint,
credentials: {
accessKeyId: r2AccessKeyId,
secretAccessKey: r2SecretAccessKey,
},
});
const command = new PutObjectCommand({
Bucket: r2BucketName,
Key: imageKey,
ContentType: fileType,
});
const presignedUrl = await getSignedUrl(s3Client, command, {
expiresIn: 3600, // 1時間
});
画像を削除する際、データベースからURLを削除するだけでなく、R2からも実際のファイルを削除する必要があります。削除APIエンドポイントを作成して、R2からも削除する処理を実装しました。
Presigned URLを使うことで、サーバーの負荷を減らしながら、セキュアにアップロードできることが分かりました。また、S3互換のAPIを使うことで、既存の知識やライブラリを活かせる点も良かったです。
管理画面へのアクセスを制限するため、JWT認証を実装する必要がありました。
Cloudflare Accessを使うと、JWTトークンが自動的に発行されますが、Cloudflare Workers側でJWTトークンを検証する必要があります。
最初は、JWTの検証方法がよく分からず、どのライブラリを使えばいいのか迷いました。
JWTの仕様を理解して、トークンの構造や検証方法を学びました。JWTは、ヘッダー、ペイロード、署名の3つの部分から構成されています。
joseライブラリの調査JWT検証ライブラリを調査して、joseを選びました。理由は以下の通りです。
joseライブラリでの検証実装utils/auth.tsに、JWT検証の処理を実装しました。
import { jwtVerify, createRemoteJWKSet } from "jose";
const JWKS = createRemoteJWKSet(
new URL("https://your-team.cloudflareaccess.com/cdn-cgi/access/certs")
);
export async function verifyJWT(token: string) {
try {
const { payload } = await jwtVerify(token, JWKS, {
issuer: `https://your-team.cloudflareaccess.com`,
audience: "your-audience",
});
return payload;
} catch (error) {
return null;
}
}
JWT認証の実装では、JWKSを使った署名検証が重要だと分かりました。JWKSを使うことで、公開鍵を自動的に取得して検証できるため、鍵の管理が楽になります。
現時点で、以下の課題が残っています。
今後の改善予定として、以下の点を検討しています。
この記事では、開発で遭遇した課題と、その解決方法についてまとめました。開発で遭遇する課題は避けられないものですが、解決プロセスを記録しておくことで、同じような課題に遭遇した人への参考になればと思います。