· 15分で読了

Next.jsでブログ全体をリライトした話

この記事は中国語から自動翻訳されたものです。翻訳によりニュアンスが失われている場合があります。

はじめに

大体2015年頃から断続的にブログを書いていて、Pixnet(痞客邦)やLogdownから始まって、Hexoでスタイルを自作・調整したり、Mediumに移ったり、2019年には当時流行っていたGatsbyを触ってみたりした。そうしてGatsbyに落ち着いてから、もう3年以上が経つ。

改めて強調しておきたいが、もしブログ記事を書き始めたいなら、使い勝手の良い手頃なものを適当に選べば十分で、一番大切なのは「記事を書くこと」そのものだ。これまであまりにも多くのソフトウェアエンジニアが「〇〇で自作ブログを構築した」という記事を書いたきり、二度と更新しなくなったり、多大な時間をかけてサイトを構築したのに中身は「hello world」や「テスト」だけだったりするのを見てきた。僕自身、当時から記事を書いて共有する習慣を続けてきて本当によかったと思っている。アクセス数はそこまで多くないものの、一定数の読者も増えてきた。

なぜGatsbyをやめたのか?

本題に戻ると、僕が面倒だと感じていた点はいくつかある:

  • 静的生成:記事を1本書き上げるたびに毎回フルビルドが必要。(CIで回しているとはいえ、やはり面倒だ)
  • カテゴリ分類:これまではメタデータを単純にマッチングさせていただけだったので、時間が経つと忘れやすく探すのも億劫になり、カテゴリが散らかり放題になっていた
  • 記事数:数年積み重ねて記事数も140本を超えたので、そろそろ別の方法で保存や修正を管理するべきだと考えた
  • 面白そう:Next.jsはこの数年でかなり進化し、多くの機能がサポートされている。その上、Vercelへ完全にノーストレスでデプロイできるため、サーバー構築が面倒な僕にとっては非常に使い勝手が良い

Gatsbyの機能は豊富だが、最終的に生成されるのは静的ファイルであり、すべての記事をローカルで管理する必要がある。Gatsbyもファイルシステム以外のコンテンツソースをサポートしてはいるが、設定がやや面倒で、Gatsbyの定めたフォーマットに合わせる必要がある。カスタマイズしたい機能があれば、パッケージをひたすら探すか自作しなければならず、こうしたコストが積み重なって小さくないオーバーヘッドになっていた。

以上の理由から、最終的にカスタマイズ性のより高いNext.jsを選んだ。

要件

僕のブログに対する要件を整理すると、以下の通りだ:

  • MarkdownからHTMLへの変換:これは僕にとって必須だ。すべての記事がMarkdown形式であり、さらに数式や脚注(footnote)なども使うため。
  • コードのシンタックスハイライト
  • データベース:記事の保存と管理が容易で、テーブル作成やINDEX作成などを自分で行える粒度が望ましい
  • RSSサポート:RSSサポートは非常に重視している。ブログ自体はシンプルでもいいが、RSSは絶対に必要だ。多くの読者がRSS経由で新着記事の通知を受け取っている。
  • 画像・動画のアップロード:記事内の画像は現在CDNに置いており、アップロード機能を備えた簡易エディタが欲しい。
  • 多言語対応(i18n):海外コミュニティと交流したい記事については翻訳を追加する可能性がある。
  • ページを自由にカスタマイズできること

技術選定

  • フロントエンドとバックエンド:Next.js。実装の詳細は後述する
  • 多言語対応:Next.jsのi18n機能を利用して実装。値の取得方法は後述する
  • Markdownとコードハイライトの変換:remarkshikiで実現
  • データベース:PostgreSQL。Google Cloud SQLを利用して構築(データベースは想像以上に高かった😱)
  • RSS Feed:Cloud FunctionsとCloud Schedulerを用いて定期的に生成
  • 静的ファイルストレージ:S3、CloudFrontでCDN化
  • デプロイ:VercelとGitHubを連携し、新しいコミットをpushすると直接デプロイされる構成

ここまで見て、「なんで静的ストレージだけAmazonで、残りのクラウドサービスはGoogle Cloudなんだ?」と思った人もいるだろう。

自分でRDSとLambdaを使ってみた感想として、GoogleのUIや設定のほうが扱いやすかったためGoogle Cloudに移行した。ただ、ブログを開設して以来ずっと静的ファイルはS3に置いていたので、そのまま使い続けている。もし後で時間ができたら、静的ファイルの大移行を行うかもしれない。

実装

記事をデータベース(Cloud SQL)へ移行する

過去の記事はすべて単一のMarkdownファイルとして書かれていたため、最初のステップはファイルの中身をすべてデータベースに投入することだった。設計としてはpostsとcategoriesという2つのテーブルを用意した。i18nに対応するため、タイトルや概要などのカラムにはjson型を採用した。検索は少し面倒になるが、こうすることで後から他の言語を追加するのが非常に楽になる。あとはSQLを書いて記事を対応するカラムに流し込むだけだ。特筆すべき点として、テーブル作成などのDB操作はすべて.sqlファイルとして別途保存しており、問題が起きたときのロールバックや再構築を容易にしている。

経験豊富なエンジニアにとっては常識だが、フレームワークの機能に頼り切っているかCLIで直接コマンドを叩いているだけで、いざ修正が必要になった時にどうすればいいかわからなくなる人を割と多く見かける。

Next.js

まずなぜNext.jsを採用したかについて話すと、各フロントエンドフレームワークにはそれぞれSSRフレームワークが存在するものの、僕の経験上、最も機能が豊富でエコシステムの統合が優れているのがNext.jsだった。ほとんど追加の設定なしで開発を始められる。開発の詳細をいくつか共有しよう:

SSR(Server Side Rendering)とISR(Incremental Static Regeneration)

Next.jsには3つのレンダリング方式が用意されている:

  • Static:ビルド時に純粋な静的ページを生成し、getStaticProps経由でpropsを渡してHTMLをレンダリングする
  • ISR:ビルド時に定義したパスの静的ページを生成し、指定したパスが存在しない場合はSSRを使ってページをレンダリングする
  • SSR:リクエストごとにページをレンダリングし、getServerSidePropsを実行してレンダリングされたHTMLを返したあと、フロントエンドでJavaScriptのロジック(イベントリスナーの登録など)を実行する

僕のブログの要件では、トップページや2〜3ページ目は頻繁にアクセスされ、記事の投稿に伴って更新される可能性があるため、ISRを採用した。記事本文もISRを採用しており、最新50件の記事はビルド時に直接静的ページを生成し、それより古い記事はリクエストがあってから生成するようにしている。/contactなどのページは完全な静的ページとして実装した。

実際に計測してみると、やはり静的生成は圧倒的に速い。サーバーサイドでデータベースに接続する必要があるため、SSRはどうしても時間がかかってしまう。

i18n

Next.jsにはルーティングレベルでi18n機能が組み込まれており、言語ごとに異なるドメインへリダイレクトしたり、異なるパスへリダイレクトしたりする設定ができる。例えば:

  • /zh/posts:localeをzhに設定
  • /en/posts:localeをenに設定

また、ヘッダーやnavigator.languagesを通じた言語の自動検出もサポートしている。サーバー側でもクライアント側でも、現在のlocaleの値を簡単に取得できる:

// Server 端
const getServerSideProps = ({ locale }) => {
  // current locale
} 

// Client 端
import { useRouter } from 'next/router'
const Component = () => {
  const { locale } = useRouter()
}

一般的なi18nの実装では、react-intlreact-i18nextのようなライブラリを使って処理を簡略化することが多い。しかしドキュメントを読んでいるうちに頭が痛くなってきた。僕の要件では外部サービスを使うわけでもなく、フォールバックの複雑なシナリオを想定する必要もなく、キーの数も追加ロードが必要なほど多くない。

そのため、最終的にはシンプルな実装を自前で書くことにした:

import React, { createContext, useCallback, useContext } from "react";
import { en } from "./en";
import { ja } from "./ja";
import { zh } from "./zh";
type I18nData = {
  _i18n: {
    locales: string[];
    data: {
      [key: string]: {
        [key: string]: string;
      };
    };
  };
};
export default function createI18n() {
  const datas = {
    en,
    ja,
    zh
  };
  return {
    _i18n: {
      locales: ["en", "zh", "ja"],
      data: datas
    }
  };
}

const I18nContext = createContext<{
  locale: string;
  _i18n: I18nData["_i18n"];
}>(null);

export const I18nProvider: React.FC<{
  _i18n: I18nData["_i18n"];
  locale: string;
  children: React.ReactElement;
}> = ({ _i18n, locale, children }) => {
  return (
    <I18nContext.Provider value={{ _i18n, locale }}>
      {children}
    </I18nContext.Provider>
  );
};

export const useTrans = () => {
  const { _i18n, locale } = useContext(I18nContext);
  const t = useCallback(
    (key: string) => {
      const data = _i18n.data[locale] || _i18n.data[locale];
      if (data) {
        return data[key] || key;
      }
      return key;
    },
    [_i18n.data, locale]
  );
  return { t };
};

素朴な作りだが十分機能する。どうせこのブログは高い確率で僕一人しか開発しないのだから、問題が出たらその都度直せばいい。JSに直接持たせることでバンドルサイズがほんの少し大きくなるが、現在の静的テキストファイルのサイズは小さく、トップページや主要ページはISRで実装している。必要になればCDNに移すのも大した手間ではないので、現段階ではこれで十分だ。

画像の処理

Next.jsには画像の読み込みと最適化を専門に扱うnext/imageが提供されている。特に対策をしないと、すべてのデバイス(スマホ、デスクトップ)で同じ画像がレンダリングされてしまう。しかし大画面のデバイスでは高解像度の画像を表示したい一方で、小型デバイスに過剰な高解像度画像を渡しても効果がわからないばかりか帯域の無駄遣いになり、ユーザー体験として好ましくない。

もう一つの問題として、画像にサイズが定義されていないと、読み込みが完了するまで高さが確保されず、読み込み完了時に突然高さが生じてLayout Shift(レイアウトのズレ)が発生する。画像が複数枚ある場合、ネットワークの遅延も合わさって非常にストレスになる。

そのため、Next.jsではnext/imageを使って画像を処理することが強く推奨されている。幅と高さを明示的に定義するか、アスペクト比を自分で定義する必要がある。また、next/imageを使用する場合、デフォルトでは画像はいったんサーバーを経由して処理されてから返却される。

例えばCDN上のURLがhttps://cdn.kalan.dev/images/avatar.jpegだった場合、実際のリクエストは/_next/image?url=${URL}&w=128&q=75のようになる。 これは、next/imageを経由したリクエストはすべて一度サーバーを経由し、サーバー側で最適化処理されてから返却されることを意味しており、直接元のURLをリクエストしているわけではない。

Next.js 処理圖片時的流程

この仕組みのメリットはデバイスの要件に応じて適切なフォーマットとサイズを返せる点だが、その反面サーバー側の負荷が高くなる。今回はVercelにデプロイしており、Vercelにはデフォルトで画像最適化の利用枠が用意されているためそこまで心配はいらないが、サーバーを自前で運用している場合は特に注意が必要だ。

そうであれば、他人の画像をこのAPIに投げて最適化させることもできてしまうのではないか?と思うかもしれない。Next.jsでは画像のdomainの設定が要求され、設定したdomainに合致しない画像へのリクエストは拒否されるようになっている。

画像最適化の処理を自前サーバーで走らせたくない場合は、別途loaderを設定してNext.jsがどのように画像を処理するか(例えばCDNに任せるなど)を定義できる。

remarkとshiki

MarkdownをHTMLに変換するために、現在人気のあるremarkを使って実装した。まずunifiedを使ってMarkdownを構文木(AST)に変換し、各種プラグインを介してHTMLへと変換していく。また、コードのシンタックスハイライトにはshikiを採用した。テーマ定義や文法定義がとても好みなのが主な選定理由だ。

実際の開発中に遭遇した問題として、shikiは実行時にテーマやコード文法の定義ファイルをロードするが、VercelにデプロイするとServerless Functionになるため、fs.readFileを使った方法ではnode_modules内のファイルを正常に読み込めないという現象があった。解決策として、テーマファイルと文法設定ファイルをプロジェクト内に手動で配置することにした:

const languagesPath = path.join(
  process.cwd(),
  'src',
  'utils',
  'shiki',
  'languages'
)

const langs = shiki.BUNDLED_LANGUAGES.map((lang) => ({
  ...lang,
  path: path.join(languagesPath, lang.path || "")
}));

export default async function convertMdToHTML() {
  const nordTheme = shiki.toShikiTheme(theme as any);
  const highlighter = await shiki.getHighlighter({ theme: nordTheme, langs });
  ...
}

Vercel Edge Functions

Next.js 13で実験的機能としてEdge Functionsが導入され、VercelのEdge Functionsと追加設定なしでシームレスに組み合わせて使えるようになった。

通常のServerless Functionsと異なり、VercelのEdge Functionsは独立したV8 Engine環境上で動作する。実行環境が共通化されているため起動速度が格段に速く、世界中のエッジノードにデプロイしてユーザーの所在地に近いノードで処理できるため、APIの応答時間を短縮できる。

By taking advantage of this small runtime, Edge Functions can have faster cold boots and higher scalability than Serverless Functions.

この機能は本当にクールで、公式が提供しているサンプルに沿って、タイトルから動的にOG画像を生成するAPIをデプロイした。デザインもHTML構文で定義できる。記事にOG画像が設定されていない場合、このAPIにフォールバックされる。ただし利用には制約もあり、VM環境で実行されるため、DBの直接操作はできず、HTTPリクエストが実行できるかも定かではない。

Serverless Functions

デフォルトでは、Next.jsをVercelにデプロイした際、静的ページ以外でSSRまたはISRを採用している場合、Vercelは自動的にServerless Functionsを生成する。

つまり、Next.jsアプリケーションはVercelにデプロイされた後、1台のサーバー上で動くのではなく、複数のServerless Functionsへと分割され、コールドスタートによるレイテンシが発生する可能性があるということだ。

Next.js 部署到 Vercel 之後的示意圖

そのため、実装時にはいくつか注意すべき詳細がある:

  • 不整合を許容できる場合を除き、グローバル変数を使ったインメモリカッシュは避けるべきだ。他のページインスタンスと共有できない可能性があるためだ。
  • Vercelのレスポンスやリクエストに関する制限(Limits)を理解しておく必要がある。
  • 僕のアプリケーションではDB接続を確立するため、大量のServerless Functionが同時に実行されて接続数が激増するのを防ぐ目的で、DB接続が長く占有されないようidle timeoutを短めに設定したほか、エラーが出ないよう接続数も少し増やしておいた。

Cloud FunctionsとCloud Scheduler

RSS Feedは定期的に生成される仕組みにしており、ここではCloud FunctionsとCloud Schedulerを組み合わせて実装した。Cloud FunctionsとCloud Runの関係性を整理するのに随分と時間を費やした。Cloud FunctionsはGoogleがサポートしているプログラミング言語でしか書けないが、Cloud RunはバックエンドでDockerが動いているため、コンテナイメージさえあれば何でも動かせる。なおCloud Functionsは現在第2世代(2nd gen)が出ており、裏側はCloud Runベースに移行しているようだ。

RSS Feedの生成のような、ちょっとしたスクリプトを実行するだけで少し時間がかかるような処理は、Cloud FunctionsとSchedulerの組み合わせに非常に適している。唯一の注意点として、メモリを多く消費する場合はCloud Functionのメモリサイズを適切に調整しておく必要がある。

Cloud Functionsは様々なイベントをトリガーにできる。例えばPub/Sub経由や、直接HTTPで呼び出すことも可能だ。僕の場合はPub/Subで実装し、generate-rssイベントを受信した際に実行されるようにした。

生成 RSS Feed 的示意圖

Cloud Schedulerのジョブは1つだけなので無料枠に収まり、Cloud Functionsも外部からアクセスされないため、トラフィックも無料枠内に収まる。個人プロジェクトには非常にありがたい。

AWS S3とCDNの設定

AWS S3はブログ開設当初から使い続けているサービスで、CloudFrontと併用している。元々はデフォルトで割り振られるドメイン名を使っていたが、後から調べてみるとカスタムドメインを割り当てられることを知った。しかもSSL証明書もAWS Certificate Managerから直接発行でき、CNAMEを設定すればあとはAmazonが自動でよしなにやってくれる。

また、外部から静的リソースを取得するためにCDNを使う場合、S3への直接アクセス権は塞いでおき、すべてのトラフィックが必ずCloudFrontを経由するように設定することをおすすめする。セキュリティ上の理由もあるし、万が一DDoS攻撃を受けた場合でも、CloudFront側でWeb ACLを使ってIPアドレスをブロックするなどの保護レイヤーを挟むことができるためだ。

詳細な設定手順は以下の通り:

  • CloudFrontの管理画面からディストリビューションを作成する

CloudFront 的控制介面示意圖

  • オリジンがAWS S3の場合、「Origin access control settings(オリジンアクセス制御)」を選択するとバケットポリシーを自動で追加してくれる。その後S3の管理画面で「パブリックアクセスのブロック」を有効にし、ACLを強制的に無効化する。

CloudFront 控制介面的「原始存取控制設定」

  • AWS Certificate ManagerからSSL証明書をリクエストする。DNS検証を利用する場合、証明書作成後にCNAME名とCNAME値が発行されるので、それをDNSレコードに追加してドメインの所有権を証明する。

AWS Certificate Manager 的控制介面

  • CloudFrontの設定にカスタムドメインを追加すれば完了だ!AWS Certificate Managerのパブリック証明書自体は無料で使用できる。これで静的リソースはすべてhttps://cdn.kalan.dev経由でアクセスできるようになり、見た目もかなりすっきりした。

Slate Editor

(開発中)

自分のニーズに合ったMarkdownエディタを作りたいと考え、カスタマイズ性の高いライブラリを求めた結果、最終的にSlateを選んだ。Slateは設計が非常に柔軟だが、ほぼすべての機能を自分で実装しなければならず、内部のNode構造も理解する必要があるため、思い通りの形に仕上げるにはそれなりに時間がかかる。

現在、ずっと欲しかった以下のような機能を実装している:

  1. ドラッグ&ドロップやコピペでアップロードをトリガーし、画像をCDNにアップロードしてリンクを返す機能。
  2. 特定のショートカットキーを押すとTranslation APIを直接呼び出してテキストを他言語に翻訳する機能。これにより、単純な段落はそのまま自動翻訳し、複雑な段落だけ自力で翻訳できるようになる。

このあたりは、しっかり形になったらまた改めて共有したいと思う。

機能と最適化

ここまでで、実用に耐えるブログシステムがようやく完成した。今後修正や最適化を行いたい項目がいくつかある:

  • テストとE2Eテストの追加(現状は記事数が多すぎて1本ずつ確認するのが大変すぎる)
  • DBクエリの削減、記事内容などをキャッシュしてCDNに配置
  • JavaScriptのバンドルサイズ削減
  • 全文検索
  • ファイルアップロード後に直接データベースへ格納
  • 記事のエクスポートとインポート
  • つづく

関連記事

他のトピックを探索