Next.jsを使用してブログ全体を書き直す

作成者:カランカラン
💡

質問やフィードバックがありましたら、フォームからお願いします

本文は台湾華語で、ChatGPT で翻訳している記事なので、不確かな部分や間違いがあるかもしれません。ご了承ください

前言

2015年からブログを書き続けていて、最初はPixnet、Logdownを経て、自分でHexoをカスタマイズし、Mediumに移りました。2019年にはGatsbyが流行していたので、その流れに乗って遊んでみました。こうして、Gatsbyに定住してから3年以上が経ちました。

改めて言いたいのは、ブログを始める際には、自分に合った使いやすいツールを見つけることが大切で、最も重要なのは「記事を書く」ということです。多くのソフトウェアエンジニアのブログ記事が「xxxを使って自分でブログを立ち上げた」と書いてあるのに、その後は更新がないというのを見てきました。また、時間をかけてサイトを構築しても、記事が「hello world」や「テスト」だけということもよくあります。私が当初から記事を書く習慣を貫いてきたことを幸運に思っています。流量は多くないけれど、一定数の読者が蓄積されました。

なぜGatsbyを使わなくなったのか?

本題に戻りますが、Gatsbyの煩わしさにはいくつかの点があります:

  • 静的生成:記事を書いた後、毎回全てを再ビルドしなければならない。(CIが動いていますが、やはり面倒です)
  • カテゴリ:現在はメタデータに単純にマッチさせるだけで、時間が経つと忘れやすく、探すのが面倒になり、カテゴリが混乱します。
  • 数量:数年の蓄積で記事数が140を超え、他の方法で保存・修正する必要が出てきました。
  • 楽しさ:ここ数年でNext.jsがかなり進化し、多くの機能がサポートされています。また、完全に無痛でVercelにデプロイできるので、サーバーを立てるのが面倒な私にとって非常に便利です。

Gatsbyの機能は豊富ですが、最終的に生成されるのは静的ファイルであり、全ての記事をローカルで管理しなければなりません。Gatsbyはファイルシステム以外のコンテンツソースをサポートしていますが、設定が少し面倒で、Gatsbyの規定フォーマットに合わないと使用できません。カスタマイズ機能を必要とする場合、パッケージを探し続けなければなりませんし、自分で書かなければならないこともあります。これらのコストが蓄積されて、かなりのオーバーヘッドになります。

以上の理由から、よりカスタマイズ性の高いNext.jsを選択しました。

需求

自分のブログに対するニーズを整理した結果、以下の点が挙げられます:

  • MarkdownからHTMLを生成:これは私にとって必須です。全ての記事はMarkdown形式であり、数学記法や脚注なども使用します。
  • コードシンタックススタイル
  • データベース:記事の保存と管理が便利で、できれば自分でテーブルやINDEXを作成できる粒度が望ましい
  • RSSサポート:私はRSSのサポートを非常に重視しており、ブログは簡素でもRSSは必須です。多くの読者はRSSを通じて新しい記事の通知を受け取っています。
  • 画像や動画のアップロード:記事の画像は現在CDNに置いているため、簡単なエディタでアップロード機能をサポートしてほしいです。
  • 多言語サポート(i18n):特定の記事を外国のコミュニティと交流するために翻訳を追加することがあります。
  • ページのカスタマイズが可能

技術選択

  • フロントエンドとバックエンド:Next.js、実装部分については後で触れます。
  • 多言語サポート:Next.jsのi18n機能を使って実装し、取得部分は後の章で述べます。
  • Markdownとコードシンタックススタイルの変換:remarkshikiを使用して実現。
  • データベース:PostgreSQLをGoogle Cloud SQLで構築。(データベースは思ったより高い😱)
  • RSSフィード:Cloud FunctionとCloud Schedulerを使用して定期的に生成。
  • 静的ファイルの保存:S3を使用し、Cloud Frontを介してCDNを実現。
  • デプロイ:VercelとGitHubを統合し、新しいコミットをpushすると直接デプロイ。

ここまで読んでいると、どうして静的な保存がAmazonにあって、他のクラウドサービスはGoogle Cloudなのかと思う方もいるでしょう。

自分でRDSとLambdaを使った感想としては、Googleのインターフェースと設定が比較的親しみやすいと感じたため、Google Cloudに移行しました。ただし、ブログを始めた時から静的ファイルはすべてS3に置いていたため、そのまま使うことにしました。今後、時間があれば静的ファイルの大移動を行うかもしれません。

実装

記事をデータベース(Cloud SQL)に移動

過去の記事は単一のMarkdownファイルで書かれていたため、最初のステップはファイルの内容をすべてデータベースに入れることです。私の設計では、postsとcategoriesの2つのテーブルを作成しました。i18nをサポートするために、タイトルや概要などのフィールドにはJSON型を使用しました。検索は少し面倒ですが、他の言語を追加する際には非常に便利です。次に、SQLを書いて記事を対応するフィールドに入れていきます。注目すべき点は、全てのデータベースのテーブル作成操作などは別途.sqlに書いて保存しており、問題が発生した時にロールバックしたり、再構築したりするのに便利です。

多くの経験豊富なエンジニアにとっては常識ですが、実際にはフレームワークが提供する機能に依存している人が多く、CLIでコマンドを打つだけで、結果として変更が必要になった時に困惑することが少なくありません。

Next.js

なぜNext.jsを使うことにしたのかについてお話ししましょう。主要なフロントエンドフレームワークにはそれぞれ対応するSSRフレームワークがありますが、私の経験では機能が最も豊富で統合が優れているのはNext.jsです。ほとんど設定なしで開発を始めることができます。開発の詳細をいくつか共有します:

SSR(サーバーサイドレンダリング)とISR(インクリメンタル静的再生成)

Next.jsは三つのページレンダリング方式を提供しています:

  • 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値を簡単に取得できます:

// サーバー側
const getServerSideProps = ({ locale }) => {
  // 現在のlocale
} 

// クライアント側
import { useRouter } from 'next/router'
const Component = () => {
  const { locale } = useRouter()
}

一般的にi18nを行う際には、react-intlreact-i18nextなどのライブラリを使って操作を簡略化しますが、ドキュメントを見ていると頭が痛くなり、私のニーズにおいてはサードパーティサービスを使わず、失敗シナリオを仮定する必要もありません。Keyが多くないため、追加でロードする必要もありません。

そのため、最終的に自分で簡単な実装を書きました:

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が提供されています。特別な処理をしない場合、すべてのデバイス(スマートフォン、デスクトップ)で同じ画像がレンダリングされることになります。しかし、これはあまり良い体験ではありません。なぜなら、大きなサイズのデバイスはより高解像度の画像を望むことがある一方で、小さなサイズのデバイスに高解像度の画像を提供すると、効果が見えないだけでなく、帯域幅を無駄にすることになるからです。

もう一つの問題は、画像のサイズが定義されていない場合、読み込まれる前は高さを占有しませんが、読み込みが完了した後に突然高さが発生し、レイアウトシフトを引き起こすことです。複数の画像がある場合、ネットワーク遅延が加わると非常に煩わしいことになります。

そのため、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のリクエストがサーバーを経由して処理され、返されることを示しています。

Next.jsでの画像処理の流れ

このようにすることで、デバイスのニーズに応じた適切なフォーマットとサイズを返すことができますが、これはサーバーの負担も増すことを意味します。私はVercelを使用してデプロイしているため、Vercelは画像最適化の制限を設定してくれているので、あまり心配する必要はありませんが、独自のサーバーを立てている場合は特に注意が必要です。

では、他の人の画像もこのAPIを通して最適化できるのかというと、Next.jsでは画像のドメインを設定する必要があります。ドメインが一致しない場合、リクエストは拒否されます。

画像の最適化処理を自分のサーバーで実行したくない場合は、loaderを追加設定して、Next.jsがどのように画像を処理するかを定義することができます(例えばCDNに渡すなど)。

remarkとshiki

MarkdownをHTMLに変換するために、現在非常に人気のあるremarkを使って実装します。まず、unifiedを使用してMarkdownを構文ツリーに変換し、さまざまなパッケージを通じてHTMLに変換します。また、コードシンタックススタイルの部分ではshikiを使用しており、彼のテーマ定義と構文定義が非常に気に入っています。

実際の開発中に問題が発生したのは、shikiが実行時にテーマとコードシンタックスの設定ファイルをロードすることです。しかし、Vercelにデプロイすると、サーバーレス関数になるため、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と組み合わせて使用することができます。追加の設定は必要ありません。

一般的なサーバーレス関数とは異なり、VercelのEdge Functionsは独立したV8エンジン環境で実行されます。実行環境が同じであるため、起動速度が非常に速く、グローバルノードにデプロイできるため、ユーザーの所在地に基づいて近いノードを決定し、APIの応答時間を短縮します。

この小さなランタイムを活用することで、Edge Functionsはサーバーレス関数よりも早いコールドブートと高いスケーラビリティを実現できます。

この機能は本当に素晴らしいです。公式が提供するサンプルを参考に、タイトルに基づいてOGイメージを動的に生成するAPIをデプロイしました。スタイルはHTML構文で定義できます。記事にOGイメージがない場合、このAPIにフォールバックします。ただし、使用にはいくつかの制限があり、VM環境で実行されるため、直接DB操作を行えないか、HTTPリクエストを実行できるかは不明です。

サーバーレス関数

デフォルトでは、Next.jsをVercelにデプロイすると、非静的ページでSSRやISRを採用している場合、Vercelは自動的にサーバーレス関数を生成します。

つまり、Next.jsアプリケーションがVercelにデプロイされると、単一のマシンではなく、複数のサーバーレス関数に分割されます。これにより、コールドスタートの遅延が発生します。

Next.jsがVercelにデプロイされた後のイメージ

そのため、実装においていくつかの詳細に注意する必要があります:

  • 一貫性を受け入れない限り、全体の変数を使用してインメモリのキャッシュを避けるべきです。他のページに共有できない可能性があります。
  • VercelがResponseとRequestに関する制限を理解する必要があります。
  • 私のアプリケーションではデータベース接続を確立するため、同時に多くのサーバーレス関数が実行されて接続数が増加しないように、アイドルタイムアウトを短く設定して、データベース接続があまり長く占有しないようにし、接続数を少し増やしてエラーが発生しないようにしました。

Cloud FunctionsとCloud Scheduler

RSSフィードは定期的に生成されます。このためにCloud FunctionsとCloud Schedulerを使用しました。Cloud FunctionsとCloud Runの関係を理解するのにかなりの時間を費やしました。Cloud FunctionはGoogleがサポートするプログラミング言語でのみ書かれていますが、Cloud RunはDocker上で実行されるため、イメージさえあれば使用できます。Cloud Functionは現在第2世代があり、バックエンドはCloud Runに変更されたようです。

RSSフィードのような、スクリプトを書くだけで済むが少し時間がかかる作業には、Cloud FunctionsとSchedulerの組み合わせが非常に適しています。唯一注意が必要なのは、より多くのメモリを占有する場合はCloud Functionのメモリサイズを調整することです。

ユーザーはさまざまなイベントを通じてCloud Functionsをトリガーできます。例えば、Pub/Sub経由やHTTP呼び出しによってです。ここでは、Pub/Subを使用して実装し、generate-rssイベントを受信したときに実行されます。

RSSフィード生成のイメージ

Cloud Schedulerには1つのジョブしかないため無料で、Cloud Functionsは外部からアクセスされないため、トラフィックも無料枠内で処理されます。個人プロジェクトには非常に便利です。

AWS S3とCDN設定

AWS S3はブログを始めて以来使用しているサービスで、CloudFrontと組み合わせて使用しています。最初はデフォルトのドメイン名を使用していましたが、調べてみるとカスタムドメインを設定でき、SSL証明書をAWS Certificateを通じて簡単に取得できることが分かりました。CNAMEを設定するだけで、残りはAmazonが自動で処理してくれます。

また、外部に静的リソースをCDN経由で提供する場合は、S3のアクセス権をオフにし、すべてのトラフィックがCloudFrontから来ることを確実にすることをお勧めします。一方で安全性が高まり、DDoS攻撃を受けた場合もCloudFrontが一層の保護を提供し、Web ACLを通じてIPアドレスをブロックできます。

設定プロセスは以下の通りです:

  • CloudFrontのインターフェースからディストリビューションを作成

CloudFrontのコントロールインターフェースイメージ

  • ソースがAWS S3の場合、「オリジンアクセス設定」を選択するとストレージポリシーが追加されます。その後、S3のコントロールインターフェースでパブリックアクセスをブロックし、ACLを強制的に無効にしました。

CloudFrontコントロールインターフェースの「オリジンアクセス設定」

  • AWS Certificate ManagerからSSL証明書をリクエストします。DNS検証を通じて証明書を取得すると、CNAME名とCNAME値が与えられ、それをDNSレコードに入力して、実際にそのドメインを所有していることを証明します。

AWS Certificate Managerのコントロールインターフェース

  • CloudFrontの設定にカスタムドメインを追加すれば、大成功です!AWS Certificate自体は無料で提供されます。これからは静的リソースにhttps://cdn.kalan.devを通じてアクセスでき、見た目も良くなりました。

Slate Editor

(開発中)

自分のニーズに合ったMarkdownエディタを作りたいと思っており、カスタマイズ性の高いパッケージを探していたため、最終的にSlateを選びました。Slateは柔軟性が高い設計ですが、ほとんど全ての機能を自分で実装する必要があり、その背後にあるノード構造を理解する必要があるため、自分の理想の形にするには時間がかかります。

現在、実装したかった機能のいくつかを実現しました:

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

この部分は、完全な成果が得られた際に皆さんと共有します。

機能と最適化

ここまで来て、実用的なブログシステムがようやく完成しました。今後修正や最適化したい点がいくつかあります:

  • テストとE2Eテストを追加。現在、記事の内容が多すぎて、一つ一つ確認するのが面倒です。
  • DBクエリを減らし、記事内容をキャッシュしてCDNに置く。
  • JavaScriptバンドルサイズを減少。
  • フルテキスト検索。
  • アップロードしたファイルを直接データベースに挿入。
  • 記事のエクスポートとインポート。
  • 続く。

この記事が役に立ったと思ったら、下のリンクからコーヒーを奢ってくれると嬉しいです ☕ 私の普通の一日が輝かしいものになります ✨

Buy me a coffee