Cloudflare Pages + D1 + Honoでプロフィールサイトを作ってみた
初めまして。
BtoBマーケの領域でプロダクト開発をしているエンジニアです。
仕事ではGo、React、Google Cloudあたりを使っています。
最近Cloudflareが楽しくて触っているのですが、Honoを使って前々から作りたかったプロフィールサイトとブログページを作成してみました。
完成したサイトはこちらです。
私の名前の姓をそのまま英語にしたドメインを取得しました。
(まぁ悪くないかなと思ってます)
作成したサイトは以下のレポジトリで公開しています。
簡単にどのような形で作ったかを綴っていきます。
技術スタック
以下の技術スタックで作成しました。
- Cloudflare Pages
- D1
- Hono
Hono
プロジェクトの立ち上げ
HonoにCloudflare Pages用のスターターが用意されているので、そちらを利用させて頂きました。
Honoのドキュメントは非常に分かりやすいので、基本的にHonoのドキュメント通りに実装していけばあまり詰まる部分はないかなと思っています。
npm create hono@latest bookbridge
あとはコンソールの指示に従えばテンプレートができあがあります。(めっちゃ簡単)
Cloudflare Workers+Honoだと、アイコンなどのasset配信がめんどくさいのですが(いちいちAPI書かないといけない)、Cloudflare Pages+Honoだとその煩わしさもないので最高です。
全体の構成
各ページ(役割)ごとにコンポーネントとAPIをディレクトリに分け、管理するようにしました。
- src
- api/(ページごとのAPIディレクトリ)
- components/(ページごとのコンポーネントディレクトリ)
- config/(bindingなどの設定系ファイルディレクトリ)
- css.ts(グローバルなcss)
- index.tsx(エントリーポイント)
- renderer.tsx(レイアウトエントリーポイント)
D1を作る
ブログサイトを作成するため、何かしらのデータベースが必要です。
今回は4月にGAにもなったD1を使っていきます。
(なんと5GBまで無料で使用可能)
D1の使い方はドキュメントに詳しく記載されているので、基本的にはそちらを参考に実装を進めました。
以下のコマンドでD1を作成します。
npx wrangler d1 create blog
成功すると、下のようなD1の情報が表示されるので、それをwrangler.toml
に記載します。
✅ Successfully created DB 'blog'
[[d1_databases]]
binding = "DB"
database_name = "blog"
database_id = "xxxxx"
wrangler.toml
[[d1_databases]]
binding = "DB"
database_name = "blog"
database_id = "xxxxx"
以上で基本的なセットアップは完了です。(なんて簡単)
なお、wrangler.toml
に記載したD1の情報はローカルPCでの開発に必要になるだけで、Cloudflare Pagesにデプロイする際には別途設定が必要になります。(後述します)
従って、セキュリティ面を考慮し、GitHubにあげる際は.gitignore
に追加すると良いのかなと思います。
tomlファイル内のシークレット値を外部から設定する処理は結構めんどくさそうなので、個人的にはかなり便利だなと感じているポイントです。
さて、最後にテーブルを作成して終わりです。
下のようなスキーマを適当なファイルに書いて、コマンドを実行するだけです。
schema.sql
CREATE TABLE IF NOT EXISTS articles (
id INTEGER PRIMARY KEY AUTOINCREMENT,
title TEXT,
body TEXT,
created_at TEXT DEFAULT CURRENT_TIMESTAMP,
updated_at TEXT DEFAULT CURRENT_TIMESTAMP
);
// ローカル環境へのマイグレーション
npx wrangler d1 execute blog --local --file=./schema.sql
// Cloudflare D1環境へのマイグレーション
npx wrangler d1 execute blog --remote --file=./schema.sql
なんて簡単!!
なお、D1はsqliteをベースにしたデータベースなので、sqliteの構文を使用してあげる必要があります。
(MySQLの型などは使えないので注意)
基本的なレイアウト
基本のレイアウトをrenderer.tsx
に書きます。
最終的に以下のようになりました。
import { jsxRenderer } from "hono/jsx-renderer";
import { header } from "./css";
import { Style } from "hono/css";
export const renderer = jsxRenderer(({ children }) => {
return (
<html>
<head>
<meta charset="UTF-8"></meta>
<meta
name="viewport"
content="width=device-width, initial-scale=1.0"
></meta>
<title>BookBridge</title>
<link rel="preconnect" href="https://fonts.googleapis.com"></link>
<link
rel="preconnect"
href="https://fonts.gstatic.com"
crossorigin="anonymous"
></link>
<link
href="https://fonts.googleapis.com/css2?family=Noto+Sans+JP:wght@200&display=swap"
rel="stylesheet"
></link>
<link href="/static/css/style.css" rel="stylesheet" />
<link href="/static/favicon.ico" rel="icon" />
<Style />
</head>
<body>
<header class={header}>
<h2>BookBridge Tech</h2>
<nav>
<a href="/">Home</a>
<a href="/profile">Profile</a>
<a href="/blog/articles">Blog</a>
<a href="/study">Study</a>
</nav>
</header>
{children}
</body>
</html>
);
});
Googleフォントを使用したかったので、それに関するlinkが多くなっています。
また、public/static
に配置したアセットもlinkで簡単に配信できるので非常に便利です。
更に今回は、cssの作成にHonoのヘルパーを使用させて頂きました!!
これめちゃくちゃ便利で、css書きたいけどcssのエコシステムまでは入れたくないなぁ、、というケースには最高です。(あと私のようなcss苦手勢にも凄く分かりやすいです)
使い方は簡単で、Style
をimportして、トップで定義するだけです。
あとはcssモジュールみたいな感覚で使用できます。
import { jsxRenderer } from "hono/jsx-renderer";
import { header } from "./css";
import { Style } from "hono/css";
export const renderer = jsxRenderer(({ children }) => {
return (
<html>
<head>
...
<Style />
</head>
<body>
<header class={header}>
...
</header>
{children}
</body>
</html>
);
});
ここまでで大まかなレイアウトは完成で、後はリンクを叩かれた時に実行されるAPIと、それに対応するコンポーネントを作成すれば完成です。
APIとコンポーネントを作る
リンクは色々作ったのですが、ブログページ以外は静的サイトなので、D1と連携する部分であるブログページだけここでは紹介します。他のサイトが気になる方は是非レポジトリを覗いてみてください。
まず、D1を使用できるようにするためBindingの設定をする必要があります。
config/bindings.tsx
export type Bindings = {
DB: D1Database;
}
これを、ブログAPIの中で型定義させます
api/blog.tsx
import { Hono } from "hono";
import { Bindings } from "../config/bindings";
const app = new Hono<{ Bindings: Bindings }>();
// ここに個別のAPIを記載していく
export default app;
これで事前準備はOKです。
なお、ここではHonoのベストプラクティスに従って、「個別のAPIを作成」 → 「トップモジュールでAPIを登録」という形で実装しています。
index.tsx
import { Hono } from "hono";
import { renderer } from "./renderer";
import blog from "./api/blog";
const app = new Hono();
app.use(renderer);
// ブログAPIを登録
app.route("/blog", blog);
export default app;
後は必要なAPIとそれに対応するコンポーネントを作成していけば良いです。
例えば、ブログIDで指定された特定のブログページを取得するようなAPIは下のような形で実装しました。
api/blog.tsx
import { Hono } from "hono";
import { Bindings } from "../config/bindings";
import { Article } from "../components/blog/article";
const app = new Hono<{ Bindings: Bindings }>();
app.get("/articles/:id{[1-9]+}", async (c) => {
const id = parseInt(c.req.param("id"), 10);
const res = await c.env.DB.prepare(
"SELECT title, body, strftime('%Y年%m月%d日 %H時%M分', created_at, '+9 hours') AS createdAt FROM articles WHERE id = ?"
)
.bind(id)
.first<{ title: string; body: string; createdAt: string }>();
if (!res) {
c.render(<div>Not found</div>);
return;
}
return c.render(
<Article title={res.title} body={res.body} createdAt={res.createdAt} />
);
});
export default app;
URLパス部分に正規表現が使えるのも便利です。
D1に対する記述方法はドキュメント通りですが、first
の部分に取得するデータの型を指定でき、こうすることでレスポンスに対して指定した型でアクセスできるようになります。
今回はオブジェクトでそのまま記述しましたが、DBのモデルを別ファイルで型定義してあげるなどするとより良さそうです。
また、ここでもsqliteに対するクエリを意識しないといけないことに注意が必要です。
次に作成系ですが、以下のように実装してみました。
import { Hono } from "hono";
import { Bindings } from "../config/bindings";
import { csrf } from "hono/csrf";
import { zValidator } from "@hono/zod-validator";
import { z } from "zod";
const app = new Hono<{ Bindings: Bindings }>();
const validater = zValidator(
"form",
z.object({ title: z.string(), body: z.string() })
);
app.post("/articles", csrf(), validater, async (c) => {
const { title, body } = c.req.valid("form");
const { success } = await c.env.DB.prepare(
"INSERT INTO articles (title, body) VALUES (?, ?)"
)
.bind(title, body)
.run();
if (!success) {
c.render(<div>Failed to create</div>);
return;
}
return c.redirect("/articles");
});
先ほどと大体同じですが、csrfプロテクターとバリデータを入れてみました。
これは @yusukebe さんの以下の記事を参考にしています。
こんな簡単にミドルウェアが導入できてしまうのは楽すぎますね。。
後は、作ったコンポーネントにform
を作ってPOSTするだけです。
なお、作成系、更新系、削除系のプライベートな処理系統に関しては、別のブランチ上で作成し、adminサイトとしてページごと分けています。(なのでこれらの処理系統はPrivateなブランチで管理しております)
このadminサイトは公開せず、何かしらの認証をかけたいところですが、HonoではBasic認証を簡単に設定することができるので、今回はそちらを使用させて頂きました。
import { Hono } from "hono";
import { renderer } from "./renderer";
import { basicAuth } from "hono/basic-auth";
import { Bindings } from "./config/bindings";
const app = new Hono<{ Bindings: Bindings }>();
app.use(renderer);
app.use("*", async (c, next) => {
const auth = basicAuth({
username: c.env.USERNAME,
password: c.env.PASSWORD,
});
return auth(c, next);
});
// APIの設定
export default app;
これだけで、全てのAPIに対してBasic認証をかけることができるので非常に楽です。
更に、Bindings
経由で環境変数を渡すことができるので、例えばローカル開発であれば.dev.vars
に、本番環境であればデプロイ時に環境変数にシークレット値を埋め込むようにすれば、簡単にシークレットにアクセスすることが可能です。
bindings.ts
export type Bindings = {
DB: D1Database;
USERNAME: string;
PASSWORD: string;
};
ここ辺りのドキュメントを参考にしています
色々割愛しておりますが、このような形でAPIとコンポーネントを作成しました。
最後にデプロイして終わりです。
デプロイ
デプロイと言ってもめちゃくちゃ簡単です。
Cloudflare Pageのアプリケーション作成ページから、GitHubレポジトリと連携させれば終わりです。
詳しくは下のドキュメントに記載いますが、設定が完了するとブランチにPushが走ると自動的にデプロイが走るようになるので、GitHub ActionsなどのCIを作る必要はありません。
また、デプロイ後に設定画面が見られるようになるので、必要な環境変数の設定や、D1に接続するための設定をする必要があります。(設定後は再度デプロイが必要です)
- 環境変数
- D1の設定
環境変数は設定時に暗号化させることも可能です。
また、D1データベース
の部分には、アクセスしたいデータベース名を記載してください。
以上で終了です。
Cloudflare Pageでデプロイが完了すると、ユニークなドメインが生成されますが、もしドメインを任意のドメインに変更したい場合は、CloudflareのDNSレコード設定でドメインを振り向けてあげる必要があります。
そちらに関しては割愛しますが、設定自体はめっちゃ簡単なので、ご興味のある方は調べてみてください。
まとめ
今回はHonoをがっつり使用してプロフィールサイトを作ってみました。
今後も色々と手を加えていこうかなぁと思いますが、HonoXも気になるところなので、どこかでHonoXで再構築とかもしてみたいなと考えています。
Cloudfalare、Hono最高!!
Discussion