logo

アルパカログ

コードから見るeasy-notion-blogのキャッシュの仕組み

先日、Notionアンバサダー @___35d さんが easy-notion-blog の実装から着想を得て、自身の Notion Blog のビルド時間を大幅に短縮されたということを聞きました。

easy-notion-blog のユーザーはプログラミング学習中の方の比率が高いので、プロのエンジニアから参考になったと言ってもらえたのは、自分にとっては多少舞い上がっても仕方ないくらい嬉しいことです。

そこでこの記事では、お褒めに預かった easy-notion-blog のキャッシュの仕組みを、簡単なコードリーディングを交えながら紹介します。

💁‍♂️
キャッシュ以外の特長も紹介したかったのですが、書いてみたら結構な分量になったのでまたいずれ…
キャッシュの仕組み

easy-notion-blog では、冒頭でも触れたようにビルド時間短縮のために工夫しています。

easy-notion-blog のベースとなっている Next.js では、ISR という高速なページ表示の恩恵を最大限に受けるために、ビルド時に各ページの HTML を静的(static)に生成します。

「静的とは?」となってしまう方もいると思うので簡単に説明しておきましょう。

ブログやニュースサイトのように、ページを訪れたユーザーに関係なく内容が同じ Web サイトは、誰に対しても一律に同じ HTML を返すことができます。

例えるなら、観光地でもらえる紙のパンフレットのようなイメージでしょうか。

パンフレットを見ている人が誰か?今どこにいるか?にかかわらず同じ内容です。

つまり、紙のパンフレットのようにユーザーが誰かということを気にすることなく、事前に内容を固定して生成しておこうというのが静的生成の考え方です。

一方、静的の対義語として動的(dynamic)があります。

例えば、SNS はログインしているユーザーによってタイムラインが異なります。

SNS ではブログのように誰に対しても同じ内容を表示するわけにはいきません。

ユーザーによって内容を変える必要があります。

例えるなら地図アプリでしょうか。

地図アプリはユーザーの現在位置を認識した上で周辺の地図を表示してくれますよね。

つまり、ユーザーによって違う情報を出し分けているということです。

SNS のようなユーザーごとに異なる表示をする Web サイトでは、ユーザーがページを訪れたのを起点に、オンデマンドでページを生成します。これが動的生成です。

さて、話を戻しましょう。

Notion Blog で各ページの HTML を静的生成するということは、そのコンテンツとなるデータを Notion から取得する必要があります。

そしてデータを取得する回数は最低でもコンテンツと同数、つまりブログ記事の数と同じだけ必要になります。

Notion からデータを取得するには Notion API を使いますが、Notion に限らず一般的に Web API には時間あたりの使用制限が設けられています。

これは、過度な使用によってサービスに負荷をかけないようにするためです。

開発者にとってもユーザーにとっても、サービスそのものがダウンしてしまうのは避けたいですよね。

また、仮に API に使用制限が無かったとしても、通信は比較的時間のかかる部類の処理なので、記事の数だけ通信が発生するとそれだけビルド全体の時間は長くなってしまいます。

冒頭で紹介した @___35d さんのケースですと、記事の数は300以上とのことですから、ビルドに60分かかってしまうのも頷けます。

easy-notion-blog では API 使用回数の制限、API 通信に時間がかかるという2つの問題を解決するために、キャッシュ(cache)という仕組みを利用しています。

キャッシュとは、一度取得したデータを身近な場所に保存し再利用することで、データの取得にかかる時間を短縮する仕組みです。

例えるなら、何度聞いても忘れてしまうことがある場合に、毎回人に聞きに行くよりも、1度聞いたこともメモしておけば2度目からはメモを見れば済みますよね。

easy-notion-blog では、ビルドの最初に一度だけ Notion API から全ての記事データを取得し、ファイルにメモしています。

そして各記事の静的生成では、ファイルにメモしたデータを使います。

こうすることで Notion API の使用回数と通信回数を減らし、API の使用回数制限に引っかかるリスクを減らしつつビルド時間の大幅な短縮を実現しています。

これが easy-notion-blog におけるキャッシュです。

コードリーディング

実際にコードのどこでキャッシュを生成して利用しているのかも見ておきましょう。

package.json を見てみると、easy-notion-blog ではビルドコマンドをカスタムで定義していることがわかります。

"build": "node scripts/set-blog-index-cache.js && next build && node scripts/expire-blog-index-cache.js",
package.json に定義されたビルドコマンド

scripts/set-blog-index-cache.js というファイルが next build の前に実行されており、名前から見てもここでキャッシュをセットしてそうです。

中身を追っていくと src/lib/notion/blog-index-cache.jsset() メソッドに行き着きます。

処理を追っていくと Notion から全記事を取得してファイルに書き出しているのがわかります。

BLOG_INDEX_CACHE という定数がファイルパスを表しています。

fs.writeFileSync(BLOG_INDEX_CACHE, JSON.stringify(results))

次に、このキャッシュを利用している側も見てみましょう。

Notion API との通信を担っている src/lib/notion/client.ts を見てみましょう。

全ての記事を取得する getAllPosts() メソッドでは、最初の if 文で blogIndexCache.exists() の結果によって処理が分岐しています。

export async function getAllPosts() {
  let results = []

  if (blogIndexCache.exists()) {
    results = blogIndexCache.get()
    console.log('Found cached posts.')
  } else {

このメソッドはその名の通り、キャッシュファイルの有無を確認するものです。

キャッシュファイルの生成はビルド時にしか行わないため、キャッシュファイルがあればビルド時であると判断し、Notion API と通信する代わりにキャッシュからデータを取得します。

ℹ️
プログラムの処理をビルド時かそうでないかといった環境によって切り替えるには @___35d さんの実装のように環境変数を用いるのが一般的です。easy-notion-blog は非エンジニアにとって難しい環境変数の設定を最小限にするためファイルの存在有無によって処理を分岐する実装にしています。

記事取得に関する他のメソッドはどうなっているかも見ておきましょう。

例えば、指定した記事を個別に取得する getPostBySlug() でもやはり最初の if 文でキャッシュファイルの有無によって処理が分岐しています。

export async function getPostBySlug(slug: string) {
  if (blogIndexCache.exists()) {
    const allPosts = await getAllPosts()
    return allPosts.find(post => post.Slug === slug)
  }

違いは、キャッシュから取得した記事全件を返すのではなく、与えられた slug 引数にマッチする記事を選んで返しているところです。

これによってこのメソッドは Notion API と通信していないのに、あたかも通信してデータを取得したかのような振る舞いになります。

このメソッドを使用する開発者は、データをキャッシュから取得したのか、それとも API から取得したのか、特に意識する必要なくこのメソッドを使うことができます。

興味のある方はぜひ他のメソッドもコードリーディングしてみてください。

メソッドが欲しいデータに合わせてキャッシュから取得したデータをフィルタしていることが理解いただけると思います。

おわりに

この記事では easy-notion-blog のキャッシュ戦略について、コードリーディングを交えながら紹介しました。

私が働いていた MIXI は、古くは SNS mixi で、最近ではモンストなどのソーシャルゲームにおいて、大きな負荷を捌くためのキャッシュ技術に強みを持つ会社です。

インターネットを検索してみると高度なキャッシュ技術の記事がたくさん出てくるので、興味のある方はぜひ検索してみてください。

キャッシュの一面だけでも、MIXI に在籍できたのはラッキーだったなと思います。