MosslogMosslog
ブログサイト作ってみた(課題・解決編)

ブログサイト作ってみた(課題・解決編)

読了時間: 31

はじめに

前回の記事で、技術選定の理由と経緯について書きました。 今回は、実際の開発で遭遇した課題と、それをどう解決したかをまとめます。

開発で遭遇する課題は避けられないものですが、解決プロセスを記録しておくことで、同じような課題に遭遇した人への参考になればと思います。

この記事では、以下の6つの課題について、問題の概要、解決プロセス、解決方法を詳しく書いていきます。

  1. Next.js 15のApp Routerとの格闘: Server ComponentsとClient Componentsの使い分け
  2. Cloudflare WorkersでのEdge Runtime制約: Node.js APIの非互換性
  3. D1データベースのクエリ最適化: パフォーマンス問題の解決
  4. マークダウンのXSS対策: セキュリティと機能の両立
  5. 画像アップロードの実装: Presigned URLの活用
  6. JWT認証の実装: Cloudflare Accessとの統合

課題1: Next.js 15のApp Routerとの格闘

問題の概要

Next.js 15のApp Routerを使い始めたとき、Server ComponentsとClient Componentsの使い分けで何度もハマりました。

最初は、すべてのコンポーネントをServer Componentsとして書いていました。 しかし、以下のような機能を実装しようとすると、エラーが発生しました。

  • インタラクティブなUI: ボタンクリックやフォーム入力など、ユーザーインタラクションが必要な機能
  • ブラウザAPIの使用: localStoragewindowオブジェクトなど、ブラウザ専用のAPI
  • 状態管理: useStateuseEffectなどのReact Hooks

エラーメッセージは「use clientディレクティブが必要です」というものでした。 最初は、このエラーの意味がよく分からず、どこに"use client"を追加すればいいのか迷いました。

解決プロセス

ドキュメントの深掘り

Next.jsの公式ドキュメントを読んで、Server ComponentsとClient Componentsの違いを理解しました。

Server Components

  • サーバー側でレンダリングされる
  • データベースへのアクセスや、サーバー側の処理が可能
  • クライアント側のJavaScriptバンドルに含まれない(パフォーマンス向上)

Client Components

  • クライアント側でレンダリングされる
  • インタラクティブなUIや、ブラウザAPIが使える
  • "use client"ディレクティブが必要

解決方法

コンポーネントの分割戦略

解決方法として、コンポーネントを分割しました。

  • Server Components: データ取得や、静的なコンテンツの表示
  • Client Components: インタラクティブなUIや、状態管理が必要な部分

例えば、記事一覧ページでは

// 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に分けました。

状態管理の見直し

状態管理が必要な場合、以下の方針を取りました

  • サーバー側の状態: Server Componentsで取得したデータは、propsとしてClient Componentsに渡す
  • クライアント側の状態: useStateuseEffectを使う場合は、Client Componentにする

学んだこと

Next.js 15のApp Routerでは、デフォルトでServer Componentsという点が重要です。

慣れてくると、Server ComponentsとClient Componentsを適切に使い分けることで、パフォーマンスと開発体験の両方を向上させられることが分かりました。


課題2: Cloudflare WorkersでのEdge Runtime制約

問題の概要

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のドキュメント確認

Cloudflare Workersの公式ドキュメントを読んで、Edge Runtimeの制約を理解しました。

  • 使えるAPI: Web標準のAPI(fetchURLTextEncoderなど)
  • 使えないAPI: Node.js専用のAPI(fspathprocessなど)

代替実装の検討

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で動作します。

Web標準APIの活用

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専用のパッケージに依存しないことで、コードの見通しも良くなりました。


課題3: D1データベースのクエリ最適化

問題の概要

記事一覧ページを実装したとき、記事の取得が遅いという問題が発生しました。 特に、記事数が増えてきたときに、ページの読み込み時間が長くなりました。

最初は、単純に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のクエリが高速化されました。

クエリの見直し

クエリを見直して、必要なカラムのみを取得するようにしました。 また、ページネーションを実装して、一度に取得する記事数を制限しました。

ページネーションの実装

ページネーションを実装することで、一度に取得する記事数を制限しました。これにより、クエリの実行時間が短縮されました。

パフォーマンス改善の結果

インデックスを追加したことで、記事一覧の取得時間が大幅に短縮されました。

学んだこと

データベースのクエリ最適化では、インデックスの重要性を実感しました。適切なインデックスを設定することで、クエリの実行時間を大幅に短縮できます。また、ページネーションを実装することで、大量のデータを扱う場合でも、パフォーマンスを維持できます。


課題4: マークダウンのXSS対策

問題の概要

マークダウンをHTMLに変換する際、XSS(Cross-Site Scripting)攻撃のリスクがあります。マークダウンに悪意のあるHTMLやJavaScriptが含まれている場合、そのままHTMLに変換すると、セキュリティ上の問題が発生します。

最初は、マークダウンをそのままHTMLに変換していましたが、セキュリティの観点から、**サニタイズ(無害化)**が必要だと気づきました。

解決プロセス

セキュリティベストプラクティスの調査

セキュリティのベストプラクティスを調査して、マークダウンのサニタイズ方法を調べました。一般的には、ホワイトリスト方式で、許可された要素や属性のみを残す方法が推奨されています。

サニタイズライブラリの比較

サニタイズライブラリを比較して、rehype-sanitizeを選びました。理由は以下の通りです。

  • rehypeエコシステム: 既に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攻撃を防ぐことができました。

学んだこと

セキュリティと機能の両立は、バランスが重要です。過度にサニタイズすると、必要な機能が動かなくなる可能性があります。一方で、サニタイズが不十分だと、セキュリティ上の問題が発生します。

カスタムスキーマを設計することで、必要な要素や属性のみを許可し、セキュリティと機能の両立を実現できました。


課題5: 画像アップロードの実装

問題の概要

記事にアイキャッチ画像を設定する機能を実装する際、画像のアップロード方法で迷いました。

最初は、サーバーを経由してアップロードしようと考えましたが、以下の問題がありました。

  • パフォーマンス: サーバーを経由すると、アップロードが遅くなる
  • サーバーの負荷: 大きな画像ファイルをサーバーで処理すると、負荷がかかる
  • スケーラビリティ: トラフィックが増えると、サーバーのリソースが不足する可能性がある

解決プロセス

AWS S3のドキュメント参照

Presigned URLという仕組みがあることを知りました。Presigned URLは、一時的なURLを生成して、クライアントから直接オブジェクトストレージにアップロードできる方式です。

R2のAPIドキュメント確認

Cloudflare R2は、AWS S3と互換のAPIを持っているため、S3のPresigned URLの仕組みがそのまま使えます。R2のドキュメントを確認して、Presigned URLの生成方法を調べました。

エラーケースの洗い出し

エラーケースを洗い出して、以下のケースに対応する必要があることが分かりました。

  • ファイルタイプの検証: 画像ファイル以外をアップロードさせない
  • ファイルサイズの制限: 大きすぎるファイルをアップロードさせない
  • アップロード失敗時の処理: ネットワークエラーなどの場合の処理

解決方法

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を使うことで、既存の知識やライブラリを活かせる点も良かったです。


課題6: JWT認証の実装

問題の概要

管理画面へのアクセスを制限するため、JWT認証を実装する必要がありました。

Cloudflare Accessを使うと、JWTトークンが自動的に発行されますが、Cloudflare Workers側でJWTトークンを検証する必要があります。

最初は、JWTの検証方法がよく分からず、どのライブラリを使えばいいのか迷いました。

解決プロセス

JWT仕様の理解

JWTの仕様を理解して、トークンの構造や検証方法を学びました。JWTは、ヘッダー、ペイロード、署名の3つの部分から構成されています。

joseライブラリの調査

JWT検証ライブラリを調査して、joseを選びました。理由は以下の通りです。

  • Edge Runtime対応: Edge Runtimeで動作する
  • JWKS対応: JWKS(JSON Web Key Set)を使って署名検証ができる
  • TypeScript対応: TypeScriptの型定義が充実している

解決方法

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を使うことで、公開鍵を自動的に取得して検証できるため、鍵の管理が楽になります。


今後の課題

未解決の課題

現時点で、以下の課題が残っています。

  • 関連記事の表示: データ構造は準備済みだが、表示機能は未実装
  • 全文検索(FTS): 現時点では、タイトルとタグでの検索のみ
  • 画像最適化: R2 Image Transformationの活用を検討中

今後の改善予定

今後の改善予定として、以下の点を検討しています。

  • パフォーマンス最適化: 記事数が増えたときのパフォーマンス改善
  • 機能追加: コメント機能や、いいね機能などの追加
  • UI/UX改善: ユーザー体験の向上

終わりに

この記事では、開発で遭遇した課題と、その解決方法についてまとめました。開発で遭遇する課題は避けられないものですが、解決プロセスを記録しておくことで、同じような課題に遭遇した人への参考になればと思います。