v1.7.0リリースノート: PVカウント

この記事はNext.js版の内容です。現在はAstroで構築し直したため、情報が古い可能性があります。 当時のリポジトリは こちらあるので参考にしてください。

今回のマイナーアップデートではPV(ページビュー)カウント機能を追加した。 表示場所は記事上部のタイトルの右下。リロードするとPVが更新されると思う。

本当であればこういった機能はリリース時点で出さないといけない。 なぜならリリースした時点から現在までのPVに計測漏れが出てしまうから。

ちょっとした文字列を追加しただけのシンプルな機能追加であるが、少し実験的な実装をしようと思ったのでリリースまでに時間がかかってしまった。

どのデータベースを使うべきか

まず考えなければならないのはデータベースである。

自前でサーバを用意してRDBMSをセットアップしてというのは管理コストがかかるため、メンテナンス不要で手軽に環境構築できるサーバレスで利用したい。

そこで前々から評判の良かったMySQL互換データベースの PlanetScale使おうとセットアップしていた。 セットアップが終わる頃にダッシュボードを確認するとなんと、Hobbyプラン(無料)は2024年4月8日に撤廃されるということだったソース)。

がっくりした。まあ、サービス提供側は慈善事業でしている訳でもないし、今までタダで使わせてくれてありがとうという気持ちだ。 今のところ一番使いやすいデータベースプラットフォームであるのは間違いないと思うのでお金さえあれば利用したい。

さて、PlanetScaleの代替を探すことにしたが、正直なところ特別な機能は必要ない。 ただ日本にリージョンがあることとPostgreSQLかMySQLがそのまま使えれば良い。

そうなると消去法から Supabase導かれた。 意外と日本リージョンを設置してあるサービスは少なく、まだまだ実際のプロダクトで使うには時期尚早だなと改めて思った。

早速、Supabaseを使ってマイグレーションからデータ更新まで一通り実装した。 しかし、ここで新たな落とし穴にハマってしまった。 Next.jsのEdge Runtimeを使ってSupabaseに接続しようとするとうまくいかないケースがあったおそらくこの事象)。

どうしてもSupabaseを使いたいという訳でもなかったので最終的にEdge Runtimeでも問題なく使えた Neon使うことにした。 リージョンがシンガポールなのはデメリットだが、新規リージョンを検討中 らしく東京リージョン(ap-northeast-1)が追加されるのは時間の問題な気がしている。

どのORMを使うか

TypeScriptのORMとしてデファクトスタンダートなのは Prisma だが、Edge Runtimeではそのまま使用できないという欠点がある 現在はプレビュー機能でサポートされているらしい参考)。 積極的にEdge Runtimeを使っていきたいのでPrismaではなく、Drizzle選択した。

Prismaの制約を差し置いてもDrizzleを選択するメリットは他にもある。 Prismaだとスキーマファイルは独自言語で定義しなければならない。これがあまり好きではない。 Drizzleだと完全にTypeScriptで定義できるため型安全に開発を進められる。

また、公式ドキュメントに “If you know SQL — you know Drizzle” と書いてあるとおり、SQLの文法に倣った形でクエリを発行できる。 SQLさえ知っておけばTypeScriptでも直感的に書けるのは大きい。

例えば、以下のコード。

country.ts
const findById = async ({ id }: { id: number }) =>
await db.select().from(countries).leftJoin(cities, eq(cities.countryId, countries.id)).where(eq(countries.id, id));
const country = findById({ id: 10 });

country.sql
SELECT *
FROM countries
LEFT JOIN cities ON cities.countryId = countries.id
WHERE countries.id = 10;

ほぼSQLを書いているような感覚でコードを書ける1

SQLは覚えておけば言語やフレームワークが違っても必ず役に立つので言語学習といった目的のためにも選択するのはありだと思う(もちろん個人プロジェクトで)。

tRPCを使ったAPIリクエスト

データ取得・更新を行う際には tRPC使ったAPIリクエストを行っている。 tRPCを使うことでバックエンドとフロントエンドで型情報を共有できる。そのため、バックエンドを実装後にフロントエンド用の型定義ファイルを作成する手間が省ける。

回初めてtRPCを使ってみたが、型補完がハマっていく様が気持ちよく、コードを書いてて楽しかった。 型定義ファイルを出力したりする手間が省けるので時間短縮になるのと何より開発体験が良くなるのを実感した。

例えば、PVを取得したりPVをインクリメントするAPIエンドポイントを定義すると以下のようなコードになる。

post.ts
export const postRouter = {
bySlug: publicProcedure.input(z.object({ slug: z.string() })).query(({ ctx, input }) => {
return ctx.db.query.posts.findFirst({
where: eq(schema.posts.slug, input.slug),
});
}),
incrementViews: publicProcedure.input(z.object({ slug: z.string() })).mutation(({ ctx, input }) => {
return ctx.db
.insert(schema.posts)
.values({ slug: input.slug, views: 1 })
.onConflictDoUpdate({
target: [schema.posts.slug],
set: { views: increment(schema.posts.views), updatedAt: new Date() },
});
}),
} satisfies TRPCRouterRecord;

それぞれが異なるデータベース操作を実行していることがわかると思うが、返り値には型がしっかりと付けられている(以下の型はイメージ)。

type PostRouter = {
bySlug: QueryProcedure<{
input: { slug: string };
output: { slug: string; views: number; createdAt: Date; updatedAt: Date } | undefined;
}>;
incrementViews: MutationProcedure<{ input: { slug: string }; output: NeonHttpQueryResult<never> }>;
};

Auth.jsを使って保護されたプロシージャを定義する

tRPCでは認証・認可によって保護されたプロシージャを定義できる参考)。 そうすることでサインインしているユーザのみAPIリクエストを送信可能にできたり、権限によってはエラーを返すようにできたりと便利だ。

色々試してみたかったので Auth.js使って最低限の認証機能を追加した。 @auth/drizzle-adapter使ってNeon側でユーザの認証情報を保存している。

ただ、ユーザの個人情報をデータベースに保存するのは何かあった時に怖いのでできるだけ持ちたくはない。 そう遠くないうちにIDaaSに移行する予定。個人的に Clerk気になっている。

さて、サインイン状態を作り出せたので認証が完了していないと送信できない名前付きプロシージャ(Base Procedures)を作成してみた。

trpc.ts
export const protectedProcedure = t.procedure.use(({ ctx, next }) => {
if (!ctx.session?.user) {
throw new TRPCError({ code: 'UNAUTHORIZED' });
}
return next({
ctx: {
session: { ...ctx.session, user: ctx.session.user },
},
});
});

上記の場合だとセッションにユーザ情報がある = サインイン状態として扱い、もしユーザ情報がなければ401エラーを返す。 他にもユーザ情報にFirebaseでいうところのカスタムクレームを持たせればその値によって認可を行える(管理者権限を持たせたりなど)。

使い方は簡単でAPIエンドポイントを作成する際に先ほど作成した名前付きプロシージャを挟むだけである。 以下の場合だとpublicProcedure誰でもアクセス可能だが、protectedProcedureサインイン状態でないとエラーが返される。

auth.ts
export const authRouter = createTRPCRouter({
getSession: publicProcedure.query(({ ctx }) => {
return ctx.session;
}),
getSecretMessage: protectedProcedure.query(() => {
return 'you can see this secret message!';
}),
});

コンポーネント側の処理

APIエンドポイントの作成は完了したので最後にコンポーネントを作っていく。 このコンポーネントはPVの取得とPVのインクリメント、スタイリングされたJSXをカプセル化したものだ。

Next.jsはRSC(React Server Components)をサポートしているため、用途に合わせて適切な法を選択する必要がある。 Server Componentを使えばJSファイルをブラウザに送信しないため、その分パフォーマンスを高められる。

当初はServer Componentを使ってPVの表示・更新を行っていたが、以下の問題が気になったので最終的にはClient Componentとして実装した。

  • 戻るボタン等のリロードが発生しないページ遷移ではPVがインクリメントしない
  • 別タブから戻ってきた時に最新のPVに更新されない

Client Componentにすることでインタラクティブにデータを扱える。

以下が今回追加したコンポーネントである。

view-counter.tsx
export const ViewCounter = ({ slug }: { slug: string }) => {
const utils = api.useUtils();
const { mutate } = api.post.incrementViews.useMutation({
onSuccess: async () => await utils.post.bySlug.invalidate({ slug }),
});
const { data } = api.post.bySlug.useQuery({ slug });
const views = data?.views;
React.useEffect(() => mutate({ slug }), [mutate, slug]);
if (!views) return <ViewCounterSkeleton />;
return <p className='font-sans text-sm text-muted-foreground'>{views.toLocaleString()} views</p>;
};
export const ViewCounterSkeleton = () => <Skeleton className='h-4 w-14' />;

tRPCには React Queryラッパーが存在するため、楽々とデータの取得・更新を行える。 ページにアクセスした段階でuseEffectからmutate関数が呼び出させてDB上のPVをインクリメントする。更新に成功した場合はPVの取得を行い、表示に反映させている。

こういったデータ更新ではOptimistic Update(楽観的更新)するとUIへのフィードバックが高速になるためよく用いられるが実装例)、このコンポーネントは初期表示でしかミューテーションされない前提なのでメリットが特にないため使用していない。

データが返ってくるまではスケルトンスクリーンを表示させている。 PV程度なら一瞬しか表示されないか全く表示されないかだろうと思っていたが、Neonからのレスポンスが案外遅く、意外と表示されている。 やっぱり地理的な要因は大きいのだな(シンガポールからのレスポンスなので)。しくはNext.jsの Full Route Cache優秀でTTFBが早過ぎるのか。

もう一つの選択肢

PVの実装方法は以上だが、もう1つ面白そうな実装方法があって最後まで悩んだ。

UpstashRedisを使っても同様の実装が可能。 しかも、面倒なIPアドレスを使った重複排除も簡単に行える今回は見送った機能実装した)。

ただし、Upstashは1日あたり10,000件のリクエストまでしか無料でないためそこが少し気になった。 とはいえ、オーバーしても従量課金制で大した金額にはならないので、Upstash使ってみたいし気が向いたときに移行しようかな。

Redisのライセンスが変更になったためUpstashにも影響があるかと思ったが、まさかの自前で実装していたらしくこれからも問題なく使えるらしい。


  1. ただし、後述するtRPCではまた違った書き方となる。