
これまで、Hono を用いてブログサイトの実装を行ってきましたが、基本的にはサーバサイドでの処理(いわゆる SSR)のみで実装をしてきました。
一方で、ブログサイトを公開するにあたっては、どのブログ記事が読まれているんだろう?などを知りたくなってきますし、単にアクセス分析するのではなく、読者がコメントしたり、いいねしたりといったリアクションをできるようにしたいと思いました。
いままで通りの実装スタイルでは、MPA(マルチページアプリケーション。ユーザ操作に応じて、ページ全体を読込直しする挙動のサイト)にならざるを得ず、「いいね」ボタンを押すだけで全体読み込み直しというのはなんともユーザ体験が低いように感じたため、クライアント側の処理実装を試みようと思います。
コメント機能を実装するにあたっては、他にもいろいろな技術要素を取り入れる必要がありますので、本記事では以下のような内容に触れます(大ボリュームです)。
- HonoX の islands
- React ライクな Hono/jsx
- データベース と ORM
- SQLite ベースの cloudflare D1
- drizzle
-
「いいね」ボタンなどを実装するにあたり、操作者を識別できるユーザ ID を発行する
- ログイン操作を要すような機密性を求める機能は実装しない。あくまで簡易的なユーザ ID とする
- 発行したユーザ ID をサーバ側では保持しない(ログイン操作までしないので)。ユーザ ID は クライアント側で cookie に保持する
- サイト訪問初回に表示する cookie 利用の同意ダイアログを実装する
- この辺のことについては、個人情報保護法制等を十分に学習する必要があります。
- (個人情報にあたらないまでも)個人データとしての取扱や、GCPR での取扱を踏まえたものとしています。
- 勝手に任意の文字列をユーザ ID として利用できないよう、cookie に保存する値はサーバ側で管理する秘密鍵を用いて暗号化した jwt とする
-
ユーザインタラクションが必要な部分のみを islands のクライアント側処理として実装する
no imagehttps://zenn.dev/yusukebe/articles/724940fa3f2450
-
クライアント上のいいねボタン押下時に、(全体を再読み込みせずに)非同期的に呼び出しする API は、Hono の Web サーバ上に実装する
- Hono の RPC を使うことで、クライアントからの API コール(サーバ処理の呼び出し)を型安全に行える
no image
https://zenn.dev/yusukebe/articles/a00721f8b3b92e
- Hono の RPC を使うことで、クライアントからの API コール(サーバ処理の呼び出し)を型安全に行える
-
お金のかからない DB を利用する(今のところ、個人の趣味の範囲なので)
- ブログサイト自体 Cloudflare Pages にデプロイしており、同じく Cloudflare の D1 を使うのが親和性高そうなので利用する
no image
https://zenn.dev/mizchi/articles/cloudflare-d1
- ブログサイト自体 Cloudflare Pages にデプロイしており、同じく Cloudflare の D1 を使うのが親和性高そうなので利用する
-
DB を Typescript から型安全に利用したい
- DB スキーマ(テーブル構造等の定義)をベースに、マイグレーション(DB テーブル等の生成)や Typescript プログラムから型安全に利用できる
Drizzleを使う - 現状、
prismaの方が普及しているようだが、スキーマの定義が Typescript ではなく、独特の記述?な感じがした・・・。使い方を見ているとDrizzleの方がしっくりくる。最近伸びているようだし。no imagehttps://zenn.dev/daichi2mori/articles/20240515-cf-d1-hono-drizzle
- DB スキーマ(テーブル構造等の定義)をベースに、マイグレーション(DB テーブル等の生成)や Typescript プログラムから型安全に利用できる
- D1 データベースの作成
npx wrangler d1 create honox-blog- D1 のデータベース ID(cloudflare の画面上に表示されます)を wrangler.toml に設定
name = "cf-pages-honox"
compatibility_date = "2024-04-01"
compatibility_flags = [ "nodejs_compat" ]
pages_build_output_dir = "./dist"
[[d1_databases]]
binding = "DB"
database_name = "honox-blog"
database_id = xxxxx-xxxxx-xxxxxxx-xxxxxx"・・・IDを設定
migrations_dir = "drizzle"・・・マイグレーション時に利用するsqlを格納するフォルダを用意して設定する- drizzle を導入
bun add drizzle-orm
bun add -D drizzle-kit- スキーマファイルを配置するディレクトリを作成(ここでは./db)し、SQL 生成やマイグレーション実行のスクリプトを package.json に登録しておく
"scripts": {
"drz:gen": "drizzle-kit generate", ・・・SQL生成
"drz:migl": "wrangler d1 migrations apply honox-blog --local",・・・マイグレーション(ローカル)
"drz:migr": "wrangler d1 migrations apply honox-blog --remote"・・・マイグレーション(cloudflare上)
},- スキーマファイルを作成したら、
npm run drz:genで SQL を生成した後、マイグレーションを実行します- "npm run drz:migl"でローカル(開発端末)上の SQLite にテーブル等が生成されます
- "npm run drz:migr"で Cloudflare D1 上の SQLite にテーブル等が生成されます
ここまで言っておきながらなんなのですが、コメント投稿フォームについては、MPA の構成で実装してみます。 (単に、そもそも私が MPA について何も分かっていないので・・・)
- コメント表示及び投稿フォームの画面コンポーネントを作成(いずれも islands ではなく、SSR のコンポーネントとして作成)
- コメント表示コンポーネントの処理では、DB からコメント一覧を取得して表示
- 投稿フォームコンポーネントでは、ボタン押下で Web サーバの コメント投稿 API(RPC) を呼び出す
- コメント投稿 API(RPC)では、クライアントから送信されたコメント情報を DB に保存する
- 応答として、元のブログ記事へリダイレクトさせる応答を返し、投稿されたコメントを反映したブログ記事ページを再表示させる
- 画面用コンポーネントを以下の用に実装しました。
- このブログ記事に表示できるよう、
./app/routes/posts/_renderer.tsx中から return するようにします。 - DB へのアクセスは getComments 内に実装しています(後述)
- このブログ記事に表示できるよう、
import { css } from "hono/css";
import { FC } from "hono/jsx";
import { getComments } from "@/app/lib/dbAccess";
import { useTheme } from "@/app/components/context/useTheme";
type CommentsProps = { postPath: string };
export const Comments: FC<CommentsProps> = async ({ postPath }) => {
const cssTheme = useTheme();
// ここで当該記事に関するコメントを取得
const results = await getComments(postPath);
const cssRootContainer = css`
padding-top: 0.5rem;
padding-bottom: 0.5rem;
padding-left: 16px;
padding-right: 16px;
margin: 5px;
background-color: ${cssTheme.contentBgColor};
`;
const cssCommentBase = css`
padding-top: 1rem;
padding-bottom: 1rem;
`;
const cssComment = css`
border-bottom: dotted 1px ${cssTheme.borderColor};
${cssCommentBase}
`;
const cssCommentLast = css`
${cssCommentBase}
`;
const cssCommentFormContainer = css`
display: grid;
justify-items: center;
padding: 1rem;
`;
const cssCommentForm = css`
display: flex;
flex-direction: column;
width: 100%;
`;
const cssCommentFormRow = css`
display: flex;
flex-direction: row;
justify-content: center;
`;
const cssInputLabel = css`
width: 20%;
`;
const cssInputName = css`
width: 80%;
color: ${cssTheme.fontColor};
border: solid 1px;
border-color: ${cssTheme.borderColor};
background-color: ${cssTheme.bodyBgColor};
margin: 1px;
`;
const cssInputMessage = css`
width: 80%;
color: ${cssTheme.fontColor};
border: solid 1px;
border-color: ${cssTheme.borderColor};
background-color: ${cssTheme.bodyBgColor};
margin: 1px;
height: 10em;
resize: none;
`;
const cssInputSubmit = css`
width: 20%;
margin: 1rem;
`;
return (
<div class={cssRootContainer}>
// コメント表示部分
<div>
<ol>
// 取得したコメントを表示
{results.map((result, idx, arr) => (
<>
<li class={idx === arr.length - 1 ? cssCommentLast : cssComment}>
<div>
{result.name} [ {result.createdAt} ]
</div>
<div>{result.message}</div>
</li>
</>
))}
</ol>
</div>
//コメント投稿フォーム部分
<div class={cssCommentFormContainer}>
//API`/form/comments`へpostアクセスする設定
<form class={cssCommentForm} method="post" action="/form/comments">
//APIからの応答でリダイレクトさせるため、表示中の記事のURLpathを隠し項目として保持(form送信時に渡す)
<input type="hidden" name="postPath" value={postPath} />
<div class={cssCommentFormRow}>
<label class={cssInputLabel}>名前</label>
<input
type="text"
name="name"
class={cssInputName}
required
></input>
</div>
<div class={cssCommentFormRow}>
<label class={cssInputLabel}>コメント</label>
<textarea
id="message"
class={cssInputMessage}
name="message"
required
></textarea>
</div>
<div class={cssCommentFormRow}>
<input type="submit" value="投稿" class={cssInputSubmit}></input>
</div>
</form>
</div>
</div>
);
};- コメント投稿 API ※コメント取得は SSR のみで実行のため、API の Route は作っていない
import { postComment } from "@/app/lib/dbAccess";
import { Hono } from "hono";
import { zCommentForm } from "@/app/types";
import { zValidator } from "@hono/zod-validator";
export const commentsRoute = new Hono().post(
"/",
/**
* validation
*/
zValidator("form", zCommentForm), ・・・ zodでバリデーションをしています
/**
* route logic
*/
async (c) => {
const commentForm = c.req.valid("form");
await postComment({
name: commentForm.name,
message: commentForm.message,
createdAt: new Date().toLocaleDateString(),
postPath: commentForm.postPath,
});
// 元のブログ記事へリダイレクト
return c.redirect(commentForm.postPath);
}
);import { z } from "zod";
export const zCommentForm = z.object({
name: z.string().min(1, {
message: "必須項目です。",
}),
message: z.string().min(1, {
message: "必須項目です。",
}),
createdAt: z.string().optional(),
postPath: z.string(),
});上記 route を、server.ts に登録
import { showRoutes } from "hono/dev";
import { createHono } from "honox/factory";
import { createApp } from "honox/server";
import { themeRoute } from "./routes/theme";
import { embedClientThemeHandler } from "./hono-middleware/embedClientThemeHandler";
import { embedMermaid } from "./hono-middleware/embedMermaid";
import { commentsRoute } from "./routes/form/comments";
// createApp前にベースとなるappを用意し、middlewareを設定する
const base = createHono();
base
.use("*", embedClientThemeHandler)
.use("/posts/*", embedMermaid)
.route("/theme", themeRoute)
.route("/form/comments", commentsRoute);
const app = createApp({ app: base });
showRoutes(app);
export default app;import { comments } from "@/db/schema";
import { and, count, eq } from "drizzle-orm";
import { drizzle } from "drizzle-orm/d1";
import { useRequestContext } from "hono/jsx-renderer";
import { InferInsertModel } from "drizzle-orm";
// Insert操作時に必要な型
type CommentModel = InferInsertModel<typeof comments>;
let db: ReturnType<typeof drizzle>;
const initDb = () => {
if (db) return;
const c = useRequestContext();
db = drizzle(c.env.DB); // D1データベースへのアクセス
return;
};
export const getComments = async (postPath: string) => {
initDb();
const result = await db
.select()
.from(comments)
.where(eq(comments.postPath, postPath));
return result;
};
export const postComment = async ({
name,
message,
createdAt,
postPath,
}: CommentModel) => {
initDb();
const result = await db.insert(comments).values({
name: name,
message: message,
createdAt: createdAt,
postPath: postPath,
});
return result;
};- DB スキーマ定義
- 予め SQL 生成、マイグレーションを実行し、DB テーブル等を用意しておきます
import { index, integer, sqliteTable, text } from "drizzle-orm/sqlite-core";
export const comments = sqliteTable("comments", {
id: integer("id", { mode: "number" }).primaryKey({ autoIncrement: true }),
postPath: text("post_path").notNull(),
createdAt: text("created_at").notNull(),
name: text("name").notNull(),
message: text("message").notNull(),
});
// インデックス。post_pathで検索する想定なので張っています
export const idxCommentPostPath = index("idx_comment_post_path").on(
comments.postPath
);ここからは islands での実装を行います。
islands として実装します。後に実装するいいねボタンコンポーネントと cookie 利用同意状態の state を共有したいため、上位のコンポーネントを用意して構成します。
islands 配置用親コンポーネント
- 同意したかどうかの state を定義
- state と更新関数を、下位のコンポーネントに渡して共有
- 後述するいいねボタンコンポーネントに対して、リクエスト時点のいいね数を返すため、上位の SSR のコンポーネントで DB へ照会した結果を受け取るように構成する(likesCount)
import { FC, useState } from "hono/jsx";
import { css } from "hono/css";
import LikeButton from "@/app/islands/components/LikeButton";
import AcceptCookieDialog from "@/app/islands/components/AcceptCookieDialog";
type PostIslandsContainerProps = {
likesCount: number;
currentAcceptCookie: boolean | undefined;
};
// ブログ記事ページのislands配置用コンテナ
export const PostIslandsContainer: FC<PostIslandsContainerProps> = ({
likesCount,
currentAcceptCookie,
}) => {
// cookie利用同意状態をstateとして定義し、コンポーネント間で共有
const [acceptCookie, setAcceptCookie] = useState(currentAcceptCookie);
const cssLayoutLike = css`
(略)
`;
const cssLayoutAcceptCookie = css`
(略)
`;
return (
<div>
<div class={cssLayoutAcceptCookie}>
{/* cookie同意ダイアログ */}
<AcceptCookieDialog
acceptCookie={acceptCookie}
setAcceptCookie={setAcceptCookie}
/>
</div>
<div class={cssLayoutLike}>
{/* いいねボタン */}
<LikeButton
acceptCookie={acceptCookie}
initialLikesCount={likesCount}
/>
</div>
</div>
);
};cookie 利用同意ダイアログコンポーネント
- 同意(または拒否)ボタン押下時に、ID 発行 RPC を呼び出し、同意状態や ID(を含む jwt)を保持する cookie を設定します
- 拒否した場合、sessionStorage に拒否したということを保存しています。この場合、別の記事へジャンプしたりしてもダイアログが再度表示されることはなくなりますが、ブラウザを開き直したり別タブで表示したときには、再度このダイアログが表示されるようにします。
import { FC, useState } from "hono/jsx";
import { hc } from "hono/client";
import { css } from "hono/css";
import { IdAppType } from "@/app/routes/api/id"; ・・・RPCで提供する型を参照する
import { AcceptCookie, SetAcceptCookie } from "@/app/types";
import { isRunningInBrowser } from "@/app/islands/lib/utils";・・・クライアントで実行している状態を判定するユーティリティ関数(後述)
type AcceptCookieDialogProps = {
acceptCookie: AcceptCookie;
setAcceptCookie: SetAcceptCookie;
};
const AcceptCookieDialog: FC<AcceptCookieDialogProps> = ({
acceptCookie,
setAcceptCookie,
}) => {
// RPC呼び出し用のクライアントを作成
const client = hc<IdAppType>(`https://xxxxxxxxxxxx/api/id`);
// cookie利用に同意していない場合に表示(一度拒否すると、セッション内では非表示にする)
const cookieDenied = isRunningInBrowser()
? sessionStorage.getItem("cookieDenied") === "true"
: false;
const [show, setShow] = useState(
!acceptCookie && !cookieDenied ? true : false
);
const cssRoot = css`
(略)
`;
const cssDescription = css`
(略)
`;
const accept = async () => {
await acceptOrDeny(true);
};
const deny = async () => {
await acceptOrDeny(false);
sessionStorage.setItem("cookieDenied", "true");
};
const acceptOrDeny = async (accept: boolean) => {
// ID発行APIを呼び出し
const result = await client.index.$post({ json: { accept: accept } });・・・RPCの呼び出し
setAcceptCookie(accept);
setShow(false);
};
// show ? : FOUCを抑制
return show ? (
<div class={cssRoot}>
<div class={cssDescription}>
<p>
このサイトではcookieを利用して、サイト訪問者の識別に利用します。
cookieの利用に同意いただくことで、サイト訪問者は記事のいいね機能等をご利用いただけます。
なお、サイト運営者はアクセス統計としてcookieの情報を利用する場合があります。
</p>
</div>
<button onClick={accept}>同意する</button>
<button onClick={deny}>拒否する</button>
</div>
) : (
// useEffectによる設定完了までは表示しない
<></>
);
};
export default AcceptCookieDialog;import { getTokenizeSecret } from "@/app/utils/env";
import { zValidator } from "@hono/zod-validator";
import { Hono } from "hono";
import { setCookie } from "hono/cookie";
import { sign } from "hono/jwt";
import { z } from "zod";
const zPostId = z.object({ accept: z.boolean() });
export const idRoute = new Hono().post(
"/",
/* validation */
zValidator("json", zPostId),・・・zodでのvalidationを実装
/* route logic */
async (c) => {
const postIdParam = c.req.valid("json");
if (postIdParam.accept) {
// ID to Token
const secret = getTokenizeSecret(); ・・・jwt作成用の秘密鍵を取得(ローカルではtoml内に定義、cloudflareでは管理画面から設定)
const token = await sign({ sub: crypto.randomUUID() }, secret);
setCookie(c, "accept-cookie", "true");
setCookie(c, "token", token);
return c.json({ message: "new token" });
} else {
setCookie(c, "accept-cookie", "false");
setCookie(c, "token", "");
return c.json({ message: "token cleared" });
}
}
);
export type IdAppType = typeof idRoute;上記 RPC の route をserver.tsに登録しておく
import { showRoutes } from "hono/dev";
import { createHono } from "honox/factory";
import { createApp } from "honox/server";
import { embedClientThemeHandler } from "./hono-middleware/embedClientThemeHandler";
import { embedMermaid } from "./hono-middleware/embedMermaid";
import { commentsRoute } from "./routes/form/comments";
import { idRoute } from "./routes/api/id";
// createApp前にベースとなるappを用意し、middlewareを設定する
const base = createHono();
base
.use("*", embedClientThemeHandler)
.use("/posts/*", embedMermaid)
.route("/form/comments", commentsRoute)
.route("/api/id", idRoute);
const app = createApp({ app: base });
showRoutes(app);
export default app;- cookie 同意済みの場合のみ活性化
- 最初にいいね数を(RPC より)取得して表示
- 閲覧ユーザが過去に当該記事で「いいね」していたかを取得
- いいね未押下の場合、押すと、いいね数を+1、RPC で DB に登録
- いいね押下済の場合、もう一度押すと、いいね数をー1、RPC で DB から削除
import { FC, useEffect, useState } from "hono/jsx";
import { hc } from "hono/client";
import { css } from "hono/css";
import { LikesAppType } from "@/app/routes/api/likes";
import { AcceptCookie } from "@/app/types";
const getPath = () => {
const url = new URL(document.URL);
return url.pathname;
};
type LikeButtonProps = {
acceptCookie: AcceptCookie;
initialLikesCount: number;
};
const LikeButton: FC<LikeButtonProps> = ({
acceptCookie,
initialLikesCount,
}) => {
const cssTheme = useTheme();
const envValue = useEnv();
const [likeCount, setLikeCount] = useState(initialLikesCount);
const [liked, setLiked] = useState(false);
const [btnDisabled, setBtnDisabled] = useState(acceptCookie ? true : false);
const client = hc<LikesAppType>(`https://xxxxxxxxxxxx/api/likes`);
const cssRoot = css`
(略)
`;
const cssLike = css`
(略)
`;
const cssLikeCount = css`
(略)
`;
useEffect(() => {
(async () => {
// like数を取得
const countRes = await client.count.$get({
query: { postPath: getPath() },
});
if (countRes.status === 200) {
const result = await countRes.json();
setLikeCount(result.count);
} else {
if (countRes.status === 401) {
const result = await countRes.json();
}
setLikeCount(0);
}
// ユーザが表示中の記事にlikeしているかどうかを取得
if (acceptCookie) {
const res = await client[":postPath"].$get({
param: {
postPath: encodeURIComponent(getPath()),
},
});
if (res.status === 200) {
const result = await res.json();
setLiked(result[0] ? true : false);
setBtnDisabled(false);
} else {
setLiked(false);
}
}
})();
}, []);
useEffect(() => {
setBtnDisabled(acceptCookie ? false : true);
}, [acceptCookie]);
const like = async () => {
setLikeCount(liked ? likeCount - 1 : likeCount + 1);
setLiked(!liked);
if (liked) {
await client[":postPath"].$delete({
param: {
postPath: encodeURIComponent(getPath()),
},
});
} else {
await client[":postPath"].$post({
param: {
postPath: encodeURIComponent(getPath()),
},
});
}
};
return (
<button class={cssRoot} disabled={btnDisabled} onClick={like}>
<div class={cssLike}>
<p class={cssLikeCount}>{likeCount}</p>
</div>
</button>
);
};
export default LikeButton;import { Hono } from "hono";
import { getCookie } from "hono/cookie";
import { verify } from "hono/jwt";
import { zValidator } from "@hono/zod-validator";
import {
deleteLike,
getLike,
getLikesCount,
postLike,
} from "@/app/lib/dbAccess";
import {
zDeleteLikeSchema,
zGetLikeSchema,
zGetLikesCountSchema,
zPostLikeSchema,
} from "@/app/types";
import { getTokenizeSecret } from "@/app/utils/env";
/**
* いいね機能API Route
*/
export const likesRoute = new Hono()
/**
* いいね数カウント取得API
*/
.get(
"/count",
/* validation */
zValidator("query", zGetLikesCountSchema),
/* route logic */
async (c) => {
const query = c.req.valid("query");
const likesCount = await getLikesCount(query.postPath);
return c.json({ count: likesCount });
}
)
/**
* ユーザのいいね実施有無取得API
*/
.get(
"/:postPath",
/* validation */
zValidator("param", zGetLikeSchema),
/* route logic */
async (c) => {
const param = c.req.valid("param");
const token = getCookie(c, "token");
if (!token) {
return c.json({ message: "unauthorized" }, 401);
}
// decode token
const secret = getTokenizeSecret();
const id = await verify(token, secret);
const result = await getLike({
postPath: param.postPath,
userId: id.sub as string,
});
return c.json(result, 200);
}
)
/**
* ユーザのいいね登録API
*/
.post(
"/:postPath",
/* validation */
zValidator("param", zPostLikeSchema),
/* route logic */
async (c) => {
const param = c.req.valid("param");
const token = getCookie(c, "token");
if (!token) {
return c.json({ message: "unauthorized" }, 401);
}
// decode token
const secret = getTokenizeSecret();
const id = await verify(token, secret);
await postLike({
userId: id.sub as string,
postPath: param.postPath,
createdAt: new Date().toLocaleDateString(),
});
return c.json({ message: "ok" });
}
)
/**
* ユーザのいいね削除API
*/
.delete(
"/:postPath",
/* validation */
zValidator("param", zDeleteLikeSchema),
/* route logic */
async (c) => {
const param = c.req.valid("param");
const token = getCookie(c, "token");
if (!token) {
return c.json({ message: "unauthorized" }, 401);
}
// decode token
const secret = getTokenizeSecret();
const id = await verify(token, secret);
await deleteLike({
postPath: param.postPath,
userId: id.sub as string,
});
return c.json({ message: "ok" });
}
);
export type LikesAppType = typeof likesRoute;useContext の利用 script タグの挿入
Hono のサーバサイドで扱う context と名称が同じため、ググラビリティが低い・・・
- islands 内でクライアント側でしか使えないオブジェクトへアクセスするとエラーとなる
- document など。
- islands コンポーネントも、サーバーサイドで一旦実行される仕様であるため、エラーとなってしまう
- 回避のため、islands の処理内でこれらのオブジェクトへアクセスする前に、サーバ側と判定される場合は処理をスキップする
// 判定用のユーティリティ関数 export const isRunningInBrowser = (): boolean => { return typeof window !== "undefined"; };
- Cloudflare D1 を使って、ユーザ情報やいいねボタン押下状態を保存できるブログサイトにしました。
- 初期表示する値は SSR で取得し、islands へ送る。クライアント側で全体を読み込み直さず、SPA 的に処理したい部分は islands として実装しました
- Drizzle は型安全なコーディングを実現可能であり、マイグレーションも含め一貫して Typescript での実装ができて、体験が良かったです。
以上です。
- コメント -
- testコメント [ 2024年11月16日 7:15 ]コメントです
- 関連記事 -
- 記事検索 -
このサイトではcookieを利用して、サイト訪問者の識別に利用します。 cookieの利用に同意いただくことで、サイト訪問者は記事のいいね機能等をご利用いただけます。 なお、サイト運営者はアクセス統計としてcookieの情報を利用する場合があります。



