Honoで始めるエッジコンピューティング:Cloudflare WorkersとD1で作るミニブログ

1. はじめに

こちらの記事は、アソビュー! Advent Calendar 2024の5日目(裏面)です。

みなさんこんにちは、アソビューでエンジニアをしています竹村です。

以前からユーザーに近いエッジサーバーに分散してリクエストを処理するエッジコンピューティングの仕組みに興味を持っており、高負荷時や障害発生時にも重要な機能を低レイテンシかつ安定して提供する仕組みを作れるのではないかと考えていたのですが、なかなか触る機会もなく今日まできていました。

そこで今回は、Cloudflare Workers向けのシンプルで軽量なHonoフレームワークを使用し、記事の投稿、一覧表示、詳細表示機能を持ったシンプルなミニブログを作成してみました。

この記事では、Cloudflare WorkersとD1を使ったミニブログの構築過程を順を追って説明していきます。

2. アプリケーション設計

要件定義

このミニブログは、基本的なCRUD操作を実現する機能を揃えます。

  1. 投稿: 新規投稿を作成する機能
  2. 一覧表示: 投稿内容を一覧で表示する機能
  3. 詳細表示: 個別の投稿詳細情報を表示

アーキテクチャ概要

このミニブログは、下記のアーキテクチャに基づいて構築します。

Cloudflare Workers: APIサーバー

  • エッジサーバー上で動作するJavaScript実行環境であるCloudflare Workersを使用します。
  • Honoフレームワークを使用してエッジサーバー上で動作する低レイテンシのAPIサーバーを実装します。

ルートの一例:

  • GET /posts … 投稿一覧を取得
  • POST /posts … 新規投稿を作成
  • GET /posts/:id … 個別の投稿詳細を取得

Cloudflare D1: データストア

  • Cloudflare Workersから呼び出せるSQLiteベースのDBであるCloudflare D1を使用します。

データ構造:

  • postsテーブル
    • id (主キー)
    • title (タイトル)
    • content (内容)
    • created_at (作成日)
    • updated_at (更新日)

フロントエンド: 静的HTML/CSS/JavaScript

  • HTMLとCSSを使用したシンプルなUIを構築します。
  • JavaScriptを使用して、Workers APIと連携します。

これらの設計をもとにエッジサーバー上で動作するミニブログを開発していきます。

3. 環境のセットアップ

ここでは、開発に必要なツールのセットアップや初期設定を行います。

必要なツール

このミニブログを構築するために準備する必要のあるツールは以下の通りです。

  • Wrangler CLI:

    • Cloudflare Workersを構築し、デプロイを簡単に行えるコマンドラインツール。
    • インストール方法: Wranglerドキュメント
  • Node.js:

    • Honoフレームワークを構築するためのJavaScript実行環境。
    • インストール方法: Node.js公式サイト
  • Hono:

    • Cloudflare Workersに特化したシンプルで高速なルーティングライブラリ。
    • インストール方法: npm create hono@latest <project-name>

初期設定

Wranglerプロジェクトの作成

  1. cloudflare-workers テンプレートを選んでhonoプロジェクトを作成します。
    npm create hono@latest mini-blog
  2. Wranglerのログインを行います。
    wrangler login
  3. ディレクトリに移動します。
    cd mini-blog

D1データベースの作成とスキーマ設定

1. D1データベースを新規作成します。

wrangler d1 create mini_blog_db

2. WranglerコンフィグにD1データベースを追加します。

wrangler d1 list
# 表示された名前をコンフィグファイルに追加

3. データベーススキーマを設定します。

以下のDDLファイルを作成し、schema.sqlとして保存します。

CREATE TABLE posts (
    id INTEGER PRIMARY KEY AUTOINCREMENT,
    title TEXT NOT NULL,
    content TEXT NOT NULL,
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
    updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);

4. データベースにスキーマを適用します。

wrangler d1 execute mini_blog_db --file=schema.sql

これで初期設定は完了です。次はHonoフレームワークを使ったAPIの実装を行います。

4. APIの実装(Honoフレームワークを使用)

ここでは、Honoを使ってCloudflare Workers上に動作するAPIを実装します。

基本ルーティングの設定

Honoはシンプルで高速なルーティング設定を実現することができます。

一覧と投稿作成のルートを設定する例を下記に示します。

コード例:

import { Hono } from 'hono';
import { Context } from 'hono/dist/context';

interface Env {
  DB: D1Database; // D1Database型を使用することでD1の型を明確に
}

const app = new Hono<{ Bindings: Env }>();

// 投稿一覧の取得
app.get('/posts', async (c: Context<{ Bindings: Env }>) => {
  const posts = await c.env.DB.prepare('SELECT * FROM posts').all();
  return c.json(posts);
});

// 新規投稿の作成
app.post('/posts', async (c: Context<{ Bindings: Env }>) => {
  const { title, content } = await c.req.json<{ title: string; content: string }>();
  
  if (!title || !content) {
    return c.json({ error: 'Title and content are required' }, 400);
  }

  await c.env.DB.prepare(
    'INSERT INTO posts (title, content) VALUES (?, ?)'
  ).bind(title, content).run();

  return c.text('Post created');
});

export default app;

D1との連携

Cloudflare D1を使用して、データベース操作を実現します。上記のコード中のc.env.DB はD1バインドを持っています。

コンフィグファイルで下記を設定しておく必要があります。

[[d1_databases]]
binding = "DB" # Honoから使用する名前
database_name = "mini_blog_db" # D1のデータベース名
database_id = "<your-database-id>" # Wranglerで発行されるID

D1の操作にはSQLiteと一致した構文を使用するため、学習コストも低いです。

エラーハンドリング

利用者のリクエストが不正な場合や、データ操作に問題が生じた場合の対策を入れることは必要です。

不正リクエストの例:

app.post('/posts', async (c) => {
  const { title, content } = await c.req.json();

  if (!title || !content) {
    return c.json({ error: 'Title and content are required' }, 400);
  }

  await c.env.DB.prepare(
    'INSERT INTO posts (title, content) VALUES (?, ?)'
  ).bind(title, content).run();

  return c.text('Post created');
});

データ操作エラーの例:

app.get('/posts/:id', async (c) => {
  try {
    const postId = c.req.param('id');
    const results = await c.env.DB.prepare(
      'SELECT * FROM posts WHERE id = ?'
    ).bind(postId).first();

    if (!results) {
      return c.json({ error: 'Post not found' }, 404);
    }

    return c.json(results);
  } catch (error) {
    return c.json({ error: 'Failed to retrieve post' }, 500);
  }
});

最終的なサンプルコード

ソースコード全体としては以下のとおりです。

index.ts

import { Hono } from 'hono';

type Bindings = {
  DB: D1Database;
};

const app = new Hono<{ Bindings: Bindings }>();

// 投稿一覧の取得
app.get('/posts', async (c) => {
  const posts = await c.env.DB.prepare('SELECT * FROM posts').all();
  return c.json(posts);
});

// 新規投稿の作成
app.post('/posts', async (c) => {
  const { title, content } = await c.req.json<{ title: string; content: string }>();
  
  if (!title || !content) {
    return c.json({ error: 'Title and content are required' }, 400);
  }

  await c.env.DB.prepare(
    'INSERT INTO posts (title, content) VALUES (?, ?)'
  ).bind(title, content).run();

  return c.text('Post created');
});

// 投稿の詳細取得
app.get('/posts/:id', async (c) => {
  try {
    const postId = c.req.param('id');
    const results = await c.env.DB.prepare(
      'SELECT * FROM posts WHERE id = ?'
    ).bind(postId).first();

    if (!results) {
      return c.json({ error: 'Post not found' }, 404);
    }

    return c.json(results);
  } catch (error) {
    return c.json({ error: 'Failed to retrieve post' }, 500);
  }
});

export default app;

wangler.toml

name = "mini-blog"
main = "src/index.ts"
compatibility_date = "2024-11-29"

[[d1_databases]]
binding = "DB" # Honoから使用する名前
database_name = "mini_blog_db" # D1のデータベース名
database_id = "<your-database-id>" # Wranglerで発行されるID

現時点のファイル構成

mini-blog/
├── src/                # Workers用のソースコード
│   └── index.ts        # Honoフレームワークを使ったAPI定義
├── wrangler.toml       # Wranglerの設定ファイル
└── schema.sql          # D1データベースのスキーマ定義

5. フロントエンド構築

ここでは、ミニブログのフロントエンドの構築を行います。投稿画面、一覧画面、詳細画面を分けて実装します。

UIの構成

それぞれの画面は以下の要素を持ちます。

  1. 投稿画面:
    • タイトルや内容を入力して投稿を作成するフォーム。
  2. 一覧画面:
    • 投稿のタイトルをリスト表示。
  3. 詳細画面:
    • 個別の投稿内容を表示する画面。

HTMLの実装

以下は各画面のHTML構造の例です。

投稿画面: new-post.html

<!DOCTYPE html>
<html lang="ja">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>新規投稿</title>
  <link href="https://cdn.jsdelivr.net/npm/[email protected]/dist/css/bootstrap.min.css" rel="stylesheet" integrity="sha384-9ndCyUaIbzAi2FUVXJi0CjmCapSmO7SnpJef0486qhLnuZ2cdeRhO02iuK6FUUVM" crossorigin="anonymous">
  <link rel="stylesheet" href="styles.css">
</head>
<body>
  <div class="container">
    <header class="row my-3">
      <div class="col">
        <h1>新規投稿</h1>
      </div>
    </header>
    <main class="row">
      <div class="row mb-3">
        <div class="col-12">
          <form id="post-form">
            <div class="mb-3">
              <label for="title" class="form-label">タイトル</label>
              <input type="text" id="title" placeholder="タイトル" class="form-control" required>
            </div>
            <div class="mb-3">
              <label for="content" class="form-label">内容</label>
              <textarea id="content" placeholder="内容" class="form-control" required></textarea>
            </div>
            <div class="mb-3">
              <button type="submit" class="btn btn-primary">投稿</button>
            </div>
          </form>
        </div>
      </div>
      <div class="row mb-3">
        <div class="col-12">
          <a href="index.html" class="btn btn-primary" role="button">投稿一覧に戻る</a>
        </div>
      </div>
    </main>
  </div>
  <script src="new-post.js"></script>
  <script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/js/bootstrap.bundle.min.js" integrity="sha384-geWF76RCwLtnZ8qwWowPQNguL3RmwHVBC9FhGdlKrxdiJJigb/j/68SIy3Te4Bkz" crossorigin="anonymous"></script>
</body>
</html>

一覧画面: index.html

<!DOCTYPE html>
<html lang="ja">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>投稿一覧</title>
  <link href="https://cdn.jsdelivr.net/npm/[email protected]/dist/css/bootstrap.min.css" rel="stylesheet" integrity="sha384-9ndCyUaIbzAi2FUVXJi0CjmCapSmO7SnpJef0486qhLnuZ2cdeRhO02iuK6FUUVM" crossorigin="anonymous">
  <link rel="stylesheet" href="styles.css">
</head>
<body>
  <div class="container">
    <header class="row my-3">
      <div class="col">
        <h1>投稿一覧</h1>
      </div>
    </header>
    <main class="row">
      <div class="row mb-3">
        <div class="col-12">
          <ul id="post-list" class="list-group"></ul>
        </div>
      </div>
      <div class="row">
        <div class="col-12">
          <a href="new-post.html" class="btn btn-primary" role="button">新規投稿</a>
        </div>
      </div>
    </main>
  </div>
  <script src="index.js"></script>
  <script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/js/bootstrap.bundle.min.js" integrity="sha384-geWF76RCwLtnZ8qwWowPQNguL3RmwHVBC9FhGdlKrxdiJJigb/j/68SIy3Te4Bkz" crossorigin="anonymous"></script>
</body>
</html>

詳細画面: post-detail.html

<!DOCTYPE html>
<html lang="ja">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>投稿詳細</title>
  <link href="https://cdn.jsdelivr.net/npm/[email protected]/dist/css/bootstrap.min.css" rel="stylesheet" integrity="sha384-9ndCyUaIbzAi2FUVXJi0CjmCapSmO7SnpJef0486qhLnuZ2cdeRhO02iuK6FUUVM" crossorigin="anonymous">
  <link rel="stylesheet" href="styles.css">
</head>
<body>
  <div class="container">
    <header class="row my-3">
      <div class="col">
        <h1>投稿詳細</h1>
      </div>
    </header>
    <main class="row mb-3">
      <div class="row mb-3">
        <div id="post-detail" class="col-12"></div>
      </div>
      <div class="row mb-3">
        <div class="col-12">
          <a href="index.html" class="btn btn-primary" role="button">投稿一覧に戻る</a>
        </div>
      </div>
    </main>
  </div>
  <script src="post-detail.js"></script>
  <script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/js/bootstrap.bundle.min.js" integrity="sha384-geWF76RCwLtnZ8qwWowPQNguL3RmwHVBC9FhGdlKrxdiJJigb/j/68SIy3Te4Bkz" crossorigin="anonymous"></script>
</body>
</html>

JavaScriptの実装

APIと連携して投稿の作成、一覧表示、詳細表示を実現します。

投稿画面: new-post.js

const API_URL = 'https://<your-workers-url>'; // APIエンドポイント

async function createPost(event) {
  event.preventDefault();

  const title = document.getElementById('title').value;
  const content = document.getElementById('content').value;

  await fetch(`${API_URL}/posts`, {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ title, content }),
  });

  window.location.href = 'index.html';
}

document.getElementById('post-form').addEventListener('submit', createPost);

一覧画面: index.js

const API_URL = 'https://<your-workers-url>'; // APIエンドポイント

async function fetchPosts() {
  const response = await fetch(`${API_URL}/posts`);
  const posts = await response.json();

  console.log(posts)

  const postList = document.getElementById('post-list');
  postList.innerHTML = '';

  posts.results.forEach(post => {
    const li = document.createElement('li');
    li.classList.add('list-group-item');
    const link = document.createElement('a');
    link.href = `post-detail.html?id=${post.id}`;
    link.textContent = post.title;
    li.appendChild(link);
    postList.appendChild(li);
  });
}

fetchPosts();

詳細画面: post-detail.js

const API_URL = 'https://<your-workers-url>'; // APIエンドポイント

async function fetchPostDetail() {
  const params = new URLSearchParams(window.location.search);
  const id = params.get('id');

  const response = await fetch(`${API_URL}/posts/${id}`);
  const post = await response.json();

  const postDetail = document.getElementById('post-detail');
  postDetail.innerHTML = `<h2 class="h4">${post.title}</h2><pre style="white-space: pre-wrap">${post.content}</pre>`;
}

fetchPostDetail();

最終的なファイル構成

mini-blog/
├── public/                 # 静的ファイルを格納するフォルダ
│   ├── index.html          # 一覧画面
│   ├── new-post.html       # 投稿画面
│   ├── post-detail.html    # 詳細画面
│   ├── index.js            # 一覧画面のJavaScript
│   ├── new-post.js         # 投稿画面のJavaScript
│   ├── post-detail.js      # 詳細画面のJavaScript
│   └── assets/             # 必要に応じて画像や追加リソースを格納
├── workers/                # Workers用のソースコード
│   ├── app.js              # Honoフレームワークを使ったAPI定義
│   └── wrangler.toml       # Wranglerの設定ファイル
└── schema.sql              # D1データベースのスキーマ定義

これで投稿画面、一覧画面、詳細画面の実装は完了です。次はデプロイと動作確認を行います。

6. デプロイと動作確認

ミニブログの開発が完了したら、Cloudflare Workersへのデプロイと動作確認を行います。

デプロイ手順

Cloudflare Workersにプロジェクトをデプロイするには、以下の手順を実施します。

1. Wranglerの準備

デプロイを開始する前に、wrangler.tomlが正しく設定されていることを確認します。

2. デプロイの実行

Wrangler CLIを使用してデプロイを行います。

以下のコマンドを実行してください。

wrangler publish

成功すると、デプロイ先のURLが表示されます。このURLを控えておきましょう。

3. 静的ファイルのアップロード

フロントエンドの静的ファイル(HTML、CSS、JavaScript)は、任意の静的ホスティングサービス(例: Cloudflare Pages、Vercel、Netlify)を使用してホストします。

Cloudflare Pagesを使用する場合:

  1. CloudflareダッシュボードからPagesプロジェクトを作成します。
  2. public/フォルダをリポジトリに追加し、Pagesにデプロイします。
  3. PagesのURLを控えておきます。

動作確認

デプロイ後、アプリケーションが正しく動作することを確認します。

1. 投稿の一覧表示

  1. 一覧画面(index.html)をブラウザで開きます。
  2. デプロイされたWorkers APIと正しく連携して投稿が取得されていることを確認します。

投稿一覧

2. 新規投稿の作成

  1. 投稿画面(new-post.html)でタイトルと内容を入力し、投稿ボタンを押します。
  2. API経由で投稿が作成され、一覧画面で確認できることを確認します。

新規投稿

3. 投稿詳細の表示

  1. 一覧画面から任意の投稿を選択し、詳細画面(post-detail.html)が表示されることを確認します。

投稿詳細

4. エラーハンドリングの確認

  1. 不正なデータ(空のタイトルや内容など)を送信した場合に適切なエラーメッセージが表示されることを確認します。
  2. 存在しない投稿IDを指定した場合に適切なエラー応答が返されることを確認します。

トラブルシューティング

1. デプロイ後にAPIが動作しない場合

  • wrangler.tomlの設定を再確認してください。
  • D1データベースのバインドが正しいか確認します。

2. フロントエンドがAPIと連携しない場合

  • APIエンドポイントURLが正しいか確認してください。
  • CORSエラーが発生している場合は、API側で適切なヘッダーを追加します。

これでデプロイと動作確認のセクションは完了です。次は、プロジェクト全体を振り返り、拡張案について触れていきます。

7. まとめと今後の展開

成果と学び

今回、HonoフレームワークとCloudflare Workersを利用してミニブログを構築したことで、エッジコンピューティングの魅力を実感することができました。Honoは非常にシンプルで軽量な設計になっており、コードの記述量を抑えつつ、直感的にルーティングやAPIの構築が可能でした。また、Cloudflare Workersを使うことで、ほとんどインフラを意識せずにアプリケーションの開発が進められた点も大きな利点でした。

D1をデータストアとして活用することで、SQLite互換のデータベースを手軽に扱えた一方で、トランザクションが使えないなどいくつか機能上の制約もあり、全てを置き換えるというのは難しそうです。しかし、負荷の波が大きい機能をCloudflare Workersに置きかえることで、コストの最適化やレイテンシの低減に大いに可能性を感じました。

今後の展開

今回のミニブログは、非常にシンプルな構成で構築しましたが、今後はさらに実践的な機能を追加していきたいと考えています。

  1. 認証機能の追加
    現在は誰でも利用可能な設計ですが、認証機能を追加することで、より現実的なWebアプリケーションに近づけたいと思っています。

  2. フロントエンドの強化
    フロントエンドについては、現在静的HTMLとJavaScriptで構成していますが、最近の開発では直接HTMLやJavaScriptを記述することはほぼないため、ReactやVue.jsなどのフレームワークを取り入れたいと思っています。

  3. 負荷テストとスケーラビリティの検証
    実際に負荷をかけるテストを実施し、WorkersとD1の組み合わせがどの程度のトラフィックに耐えられるかを検証したいと考えています。

この記事が、皆さんがエッジコンピューティングやHonoを活用する際の参考になれば幸いです。

最後に

アソビューではより良いプロダクトを世の中に届けられるよう共に挑戦していくエンジニアを募集しています。

カジュアル面談も実施していますので お気軽にエントリーしていただければと思います。

www.asoview.com