アクセスカウンターって昔流行りましたね。ただ、今はWordpress以外で、簡単にアクセスカウンターを実現する方法がなかなかないのも事実。(バックエンドがあると楽だけど)ということで、IaaSのSupabaseを使って作ってみました。

本記事のゴール

ブログにアクセスカウンターを追加する。その際に、Supabaseをバックエンドとして使用する。

要件定義

  同じユーザーがアクセスした場合は、カウントしない。

  同一日でなければ、再カウント可能とする

  1ページ1カウントとする。

そもそもSupabaseとは?

作業を進める前に「Supabase」とは何か?を説明します。SupabaseのTOPページによると、

SupabaseはFirebaseの代替となりうるサービスです。あなたの開発プロジェクトを、Postgres DB、認証機能、簡単に開発できるAPI、サーバレスの軽量ファンクション、購読機能(待機イベント)、ストレージ、オブジェクトベクトル化など さまざまな機能付きで始めましょう。

とのことでした。Firebaseの代替ということである程度本質ついていると思いますが、単なるデータベースではなく、付随する機能がさまざまついた (つまり拡張性が期待できる)IaaS(Infrastructure as a Service)ということをうたっていますね。

設計

完成したい「アクセスカウンター機能」の設計を簡単に行います。


DB設計・準備をSupabaseで

アカウント・プロジェクト作成後、

.env.環境名に保存します。以下はlocalの例を書きます。

.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カラムを追加

全てIs Nullableのチェックを外しておきます。(Nullが入ることは想定していないです。)

pageview_details

テーブル名をpageviews こちらも以下の3カラムを追加

同じくIs Nullableのチェックを外しておきます。(

※ 注意点

各テーブルの作成時にデフォルトでRLSがONになるので注意してください。 Supabaseでは下記のようなRLSを編集するツール(AWSでいうIAMのポリシーを編集するツール)が付属していますので こちらで適切に権限を設定してください。

rls-policy.png

後述しますが、このポリシー編集ツールがまだ完成度粗いな...と個人的には思ってしまいました。AWSだと名称から直感的に、「S3ReadOnlyAccess」みたいなRoleをくっつけられますよね。

あのような豊富な雛形があるといいですし、英語ネイティブでない日本人にとっては微妙な表現の読み違えで真逆の設定をしかねないと思うので、日本語対応を期待しています。

実装

ライブラリをNext.jsプロジェクトにインストール

プロジェクトのルートに移動し、yarnでインストールを実施します。

yarn add @supabase/supabase-js

layout.tsx / hooksの形で実装

ブログの詳細ページ共通で呼ばれるコンポーネントであればどこでも良いと思います。

私はhook形式にした上で、レイアウトコンポーネントで 呼ぶようにしました。_app.tsxからだと、利用するViewからは階層が遠く引き回す方法を考えないといけないからです。(Contextを使えばできるが、そこまでしなくてもいいかなと)

lib/components/layout.tsx
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. 既にテーブルにレコードがあれば+1
  2. レコードがなければ新規作成

という流れになります。

hooks/userPageCounter.tsx
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でプロジェクトを起動し、ブログページでカウンターの増加を確認します。

pv-increment.png

うん、ちゃんと増えてる。

使ってみて感じたSupabaseの長所・短所

私の方でSupabaseを使ったのは初めてでしたので、自分のため、が主目的とはなりますが今回長短所を軽く整理してみました。

数ヶ月〜かかるような中大規模プロジェクトへの導入を前提に検討していないため、粗があるかと思いますのでその点はお許しをいただけますと幸いです。

長所

短所

既存のRestAPI + RDBと比べると現時点(2023/09)では効率がまだまだかなと思うところがあるのも事実です。


まとめ・感想

技術選定にあたっては、「この技術に未来はあるのか?」 という観点で考えるわけですが、RDBとGUIを組み合わせたIaaSという発想は筋がいいと思います。「データが膨らんだ時にスケーラブルに利用継続できるのと差し替えに、NoSQL使うのはしんどい」とか「学習コストが高い」という話はずっと続いてたので。つまり、旧来の課題に対して適切に刺せているように感じられます。

一方で、利用者はエンジニアである以上最終的にはGUIはオマケにしかならないじゃないか という話もあるかと思います。つまり、Supabaseは「当初は無料で使える」「クラウドDBサービス」であることが価値の源泉であり、IaaSとしては厳密にはそこまで重視されていないのだと思います。

技術選定に一定の予算および工数がかけられる、大規模案件ならともかく。CUIよりも使いやすいGUIの登場を待っているわけには個人開発ではいかないので、supabaseコマンドを使ったり 前述のようにTableplusを使って対応するようになりそうですが、そうなるといよいよ更に簡便に使えるFirebaseAWS RDSなどの既存のRDBサービスでもいい気がしてきた次第でした。

結論、個人開発には気が向いたら使うかな、くらいでしょうかねえ。

数ヶ月・複数人のプロジェクトで会社として、試しに導入してみるのはいいと思いました! またEdge FunctionやAuthの使い勝手もどんなものかを、また稿を改めて書きたいと思っています。


この記事が何かのお役に立てれば幸いです。
最後までお読みいただきありがとうございました!