アクセスカウンターって昔流行りましたね。ただ、今はWordpress以外で、簡単にアクセスカウンターを実現する方法がなかなかないのも事実。(バックエンドがあると楽だけど)ということで、IaaSのSupabaseを使って作ってみました。
本記事のゴール
ブログにアクセスカウンターを追加する。その際に、Supabaseをバックエンドとして使用する。
同じユーザーがアクセスした場合は、カウントしない。
同一日でなければ、再カウント可能とする
1ページ1カウントとする。
そもそもSupabaseとは?
作業を進める前に「Supabase」とは何か?を説明します。SupabaseのTOPページによると、
SupabaseはFirebaseの代替となりうるサービスです。あなたの開発プロジェクトを、Postgres DB、認証機能、簡単に開発できるAPI、サーバレスの軽量ファンクション、購読機能(待機イベント)、ストレージ、オブジェクトベクトル化など さまざまな機能付きで始めましょう。
とのことでした。Firebaseの代替ということである程度本質ついていると思いますが、単なるデータベースではなく、付随する機能がさまざまついた (つまり拡張性が期待できる)IaaS(Infrastructure as a Service)ということをうたっていますね。
設計
完成したい「アクセスカウンター機能」の設計を簡単に行います。
- 各ページマウント時に、useEffectを使い、現在のPV数を確認する。
- 各ページで、ローカルストレージに保存したランダム文字列をトリガーにSuparbaseへの参照系(select)リクエストを行い、データの有無を確認する。
- セキュリティ要件が厳しくないため、ローカルストレージで問題ない。(ローカルストレージでダメな場合は別途方法を検討する必要があるが今回は別の話)
- ランダム文字列が同一でも他の環境からリクエストはできるが、そこまでタイトな用件ではないため妥協する。
- 同一日かどうかは、dateカラムを作り判定を行う。
- 既にデータがある場合は何もしない。データがまだない時は、更新系(insert)リクエストを行い、DBに保存する。
DB設計・準備をSupabaseで
アカウント・プロジェクト作成後、
- APIキー
- プロジェクトURL(エンドポイント)
を.env.環境名
に保存します。以下はlocalの例を書きます。
NEXT_PUBLIC_SUPABASE_URL=https://xxxxxxxxxxxxxxx.supabase.co
NEXT_PUBLIC_SUPABASE_KEY=xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
NEXT_PUBLIC_
の接頭辞がキーにないと、Next.jsのフロントエンド側で使用したい場合は環境変数として適切に読まれないので注意してください。これらはプロジェクトページに移動後、「Settings」->「API」から取得できます。
pageviews/pageview_detailsテーブルを作成
ダッシュボードから、table -> 「Create New Table」で新たにテーブルを作成していきます。
pageviews
テーブル名をpageviews
キーとなるid
およびcreated_at
に加えて、以下の3カラムを追加
- slug varchar // 各ページのドメイン以下のURIをスラグとして入れます
- pageview int8
- updated_at (defaultはnow())
全てIs Nullableのチェックを外しておきます。(Nullが入ることは想定していないです。)
pageview_details
テーブル名をpageviews こちらも以下の3カラムを追加
- slug varchar // 各ページのドメイン以下のURIをスラグとして入れます
- random varchar // localStorageに設定するランダム文字列
- date date // 本日の日付のみ走査対象とします
同じくIs Nullableのチェックを外しておきます。(
※ 注意点
各テーブルの作成時にデフォルトでRLSがONになるので注意してください。 Supabaseでは下記のようなRLSを編集するツール(AWSでいうIAMのポリシーを編集するツール)が付属していますので こちらで適切に権限を設定してください。
後述しますが、このポリシー編集ツールがまだ完成度粗いな...と個人的には思ってしまいました。AWSだと名称から直感的に、「S3ReadOnlyAccess」みたいなRoleをくっつけられますよね。
あのような豊富な雛形があるといいですし、英語ネイティブでない日本人にとっては微妙な表現の読み違えで真逆の設定をしかねないと思うので、日本語対応を期待しています。
実装
ライブラリをNext.jsプロジェクトにインストール
プロジェクトのルートに移動し、yarnでインストールを実施します。
yarn add @supabase/supabase-js
layout.tsx / hooksの形で実装
ブログの詳細ページ共通で呼ばれるコンポーネントであればどこでも良いと思います。
私はhook形式にした上で、レイアウトコンポーネントで 呼ぶようにしました。_app.tsxからだと、利用するViewからは階層が遠く引き回す方法を考えないといけないからです。(Contextを使えばできるが、そこまでしなくてもいいかなと)
import React from 'react'
import "remixicon/fonts/remixicon.css"
import { useRouter } from "next/router"
import { usePageCounter } from 'hooks/usePageCounter'
type LayoutProps = {
children: React.ReactNode
}
const Layout: React.FC<LayoutProps> = ({
children
}) => {
const router = useRouter()
const currentUrl = typeof window !== 'undefined' ? window.location.href : '';
const isDetailPage = router.pathname.startsWith('/posts');
// hooksで、中身で全ての処理を完結させる
const [{ pageView }] = usePageCounter({
slug: router.asPath
})
// 以下はお手元のlayout.tsxの内容を移植してください。
return (
<>
{children}
</>
)
}
export default Layout
usePageCounter.tsx / randomコードをフックに全てを処理する
ページ描画時に、useEffect()
が呼ばれ、localStorageから保存済みのランダム文字列があれば呼ばれます。なければ新規に作っています。
これを条件にSupabaseのDBへ参照を行い、
- 既にテーブルにレコードがあれば+1
- レコードがなければ新規作成
という流れになります。
import { useEffect, useState } from "react"
import { createClient } from '@supabase/supabase-js'
import { getRandomString } from "lib/utils"
const supabaseUrl = process.env.SUPABASE_URL || ''
const supabaseKey = process.env.SUPABASE_KEY || ''
const supabase = createClient(supabaseUrl, supabaseKey)
export const usePageCounter = ({ slug }: {
slug: string
}) => {
const [pageView, setPageView] = useState(0)
const [random, setRandom] = useState("")
// ランダム文字列の発行および取得ロジック
useEffect(() => {
const storedRandom = localStorage.getItem('pageCounterRandom');
if (storedRandom) {
setRandom(storedRandom)
} else {
const newRandom = getRandomString()
localStorage.setItem('pageCounterRandom', String(newRandom));
setRandom(String(storedRandom))
}
}, []);
const fetchPageView = async (slug: string) => {
const { data, error } = await supabase
.from('pageviews')
.select()
.eq('slug', slug)
return { data, error }
}
const fetchPageViewDetail = async (slug: string, random: string) => {
const { data, error } = await supabase
.from('pageview_details')
.select()
.eq('slug', slug)
.eq('random', random)
.eq('date', new Date().toISOString())
return { data, error }
}
// NOTE: 本来はdataを型指定するべきだが、supabase loginなど作業が必要なため、一旦anyで判定
const upsertPageview = async (data: any, slug: string) => {
let id
let pageview = 1 // 初期値は0ではなく1
if (data.length > 0) {
id = data[0].id
pageview = data[0].pageview + 1 // increment
}
const { data: dataUpsert, error: errorUpsert } = await supabase
.from('pageviews')
.upsert({
id: id,
slug: slug,
pageview: pageview
})
.select()
return { dataUpsert, errorUpsert }
}
const insertPageViewDetail = async (slug: string, random: string) => {
const { error } = await supabase
.from('pageview_details')
.insert({
slug: slug,
random: random,
date: new Date().toISOString()
})
return { error }
}
useEffect(() => {
if (random === "") {
return
}
const executePageCounter = async () => {
let { data, error } = await fetchPageView(slug)
let { data: detailData, error: detailError } = await fetchPageViewDetail(slug, random)
if (error || data == undefined || detailError || detailData == undefined) {
// add error handling if needed
return
}
// 既に詳細データがある場合は、何もしないで終了する
if (data.length > 0 && detailData.length > 0) {
setPageView(data[0].pageview);
return
}
// データがない場合は新規作成する
await insertPageViewDetail(slug, random)
const { dataUpsert, errorUpsert } = await upsertPageview(data, slug)
if (errorUpsert || dataUpsert == undefined) {
// add error handling if needed
return
}
// 処理が終わってからpageviewを更新
setPageView(dataUpsert[0].pageview);
}
executePageCounter()
}, [random])
return [{ pageView }]
}
※ ランダム文字列の発行ロジック
一例ですがこちら
https://www.slingacademy.com/article/ways-to-generate-random-strings-in-javascript/
などを適宜実装してください。上記のusePageCounterで申しますとgetRandomString()
のところです。
私はこちらを使っていませんが、適宜作成し、util的なファイルに気軽に呼べるメソッドとして追加。あるいはhooks集などにあるやつを適宜採用ください。
動作確認(テスト)
ローカルでyarn run dev
でプロジェクトを起動し、ブログページでカウンターの増加を確認します。
うん、ちゃんと増えてる。
使ってみて感じたSupabaseの長所・短所
私の方でSupabaseを使ったのは初めてでしたので、自分のため、が主目的とはなりますが今回長短所を軽く整理してみました。
数ヶ月〜かかるような中大規模プロジェクトへの導入を前提に検討していないため、粗があるかと思いますのでその点はお許しをいただけますと幸いです。
長所
- バックエンドフレームワークのORMのような感覚でDBに繋げ、RDB経験が豊富な人には使いやすい。
- エラーハンドリングがSingleで実装でき、React経験豊富な人にもわかりやすい。
- 要するにどちらか片方の技術スタックを持っていればとっつきやすい。イコール、プロジェクトにおいて人材の確保などもしやすい。
- RDBなので、NoSQLに比べて複数人数での開発プロジェクトに適応しやすそう(この辺りのフィジビリはまだまだなので、稿を改めて)
- 意外に現時点でもフィルターはサンプルも豊富にあり充実していそうだった。(自分で試せてない)
- 中身Postgresqlなので、移植は普通にしやすい。supabaseコマンド経由でバックアップも取れるし、psqlでダンプもどうやら取れるらしい
短所
- Vercel/AWS/GCPなどと比べてGUIが過渡期でこなれていない。
- (個人的な感想となるが)GUIでのDB操作は効率面で悪く感じてしまう。
- と思ったら、TablePlusなどのDBクライアントで接続する方法を見つけた...のであまり短所にならないかも?
- コミュニティの日本語情報がまだ少ない。(gql系の技術などにも通じる話で、ナレッジが蓄積されたらどんどん便利になりそう。)
既存のRestAPI + RDBと比べると現時点(2023/09)では効率がまだまだかなと思うところがあるのも事実です。
まとめ・感想
技術選定にあたっては、「この技術に未来はあるのか?」 という観点で考えるわけですが、RDBとGUIを組み合わせたIaaSという発想は筋がいいと思います。「データが膨らんだ時にスケーラブルに利用継続できるのと差し替えに、NoSQL使うのはしんどい」とか「学習コストが高い」という話はずっと続いてたので。つまり、旧来の課題に対して適切に刺せているように感じられます。
一方で、利用者はエンジニアである以上最終的にはGUIはオマケにしかならないじゃないか という話もあるかと思います。つまり、Supabaseは「当初は無料で使える」「クラウドDBサービス」であることが価値の源泉であり、IaaSとしては厳密にはそこまで重視されていないのだと思います。
技術選定に一定の予算および工数がかけられる、大規模案件ならともかく。CUIよりも使いやすいGUIの登場を待っているわけには個人開発ではいかないので、supabase
コマンドを使ったり
前述のようにTableplusを使って対応するようになりそうですが、そうなるといよいよ更に簡便に使えるFirebaseやAWS RDSなどの既存のRDBサービスでもいい気がしてきた次第でした。
結論、個人開発には気が向いたら使うかな、くらいでしょうかねえ。
数ヶ月・複数人のプロジェクトで会社として、試しに導入してみるのはいいと思いました! またEdge FunctionやAuthの使い勝手もどんなものかを、また稿を改めて書きたいと思っています。
この記事が何かのお役に立てれば幸いです。
最後までお読みいただきありがとうございました!