👚

DROBE における Next.js の利用事例

こんにちは

DROBE のエンジニアの小黒です

直近のプロジェクトで Next.js を利用する機会があったので、DROBEでどのように利用したか簡単にご紹介したいと思います

Next.js の導入を検討している方の参考になれば幸いです

Next.js 導入の背景

DROBE では、AI とスタイリストがお客様一人一人の要望や好みに合わせて商品とコーディネートを提案させていただいているのですが、

こんな感じで提案された商品とスタイリングカルテが届きます
こんな感じで提案された商品とスタイリングカルテが届きます

ある時、マーケティングなどで利用するために、実際に取り扱っている商品やコーディネートの例を会員のお客様以外にも公開できる仕組みを作りたい、という話があがりました

PDM からもらった要件は下記の3つがあり

  • 認証などの機能なしで閲覧できること
  • SEO を考慮した Web ページになること
  • drobe.jp ドメインで配信できること(既存サービスと同じドメイン配下におけること)

エンジニア内で話し合ったところ、下記の観点を重視して、Next.js を使って SSG でページを生成し、静的ホスティングサービスを使ってページを公開する、という方針で開発することにしてみたのでした

  • 障害時などに既存サービスへの影響が少ない
  • サーバーの管理を行わなくても良い
  • DROBE 内の別開発でも使っている React.js を使って作られたコンポーネントを使いまわせる
  • 急な高トラフィックに強い
  • ページ読み込みが早く、 SEO に強い

Next.js について

Next.js とは

vercel という hosting サービスを提供している会社が提供している React ベースのフレームワークで、色々といい感じに最適化してくれながら SSR / SSG の機能を提供してくれます

開発用の hot reload 機能付きのサーバー が付いていたり、 sass や typescript を default でビルドに使えたりと、開発環境も充実しており最高の開発体験を味わえます

deploy 先は当然 vercel が最も相性が良いので、特別な理由がなければ vercel を選びましょう(DROBE はインフラが AWS 上に構築されていたので AWS 上への deploy になっています)

この後の利用事例ではチュートリアルに掲載されている Next.js の知識はある前提でご紹介しますので、Next.js ...? という方はぜひチュートリアルを読んでから利用事例の章に進んでください

Next.js のチュートリアルは非常に素晴らしく、短時間で概念を理解できるのでオススメです

DROBE での Next.js の利用事例(開発編)

DROBE での Next.js を使った開発に関して、簡単にご紹介します

主に利用ライブラリなどについて触れます

ディレクトリ構造

そこまで複雑なプロジェクトではなかったこともあり、ディレクトリ構造は特にトリッキーなことはしていません

構造は下記のようになっています

.
├── api
├── components
│   ├── atoms
│   ├── molecules
│   ├── organisms
│   ├── pages
│   ├── quantums
│   └── templates
├── const
├── pages
│   ├── _app.tsx
│   ├── _document.tsx
│   └── xxxx.tsx
├── records
├── styles
└── util

ディレクトリごとの役割

NameTags
api
API を呼ぶための関数が定義されている(後述)
components
コンポーネント群。他の開発でも用いられている atomic design を採用
const
定数が定義されている
pages
Next.js でビルドされるページ用の関数が定義
records
api response を parse するためのクラスが定義されている
styles
プロジェクト全体に適用される共通 css 置き場
util
便利関数群

特筆するべき点もないくらいにシンプルです

styled components

DROBE では styled components をよく用いているので、今回も用いることにしました

他のプロジェクトで用いられているコンポーネントを一部持ってくることを想定していたので導入は必須でした

styled components と Next.js では想定している動作環境が違うらしく babel.config.json を作って styled components がサーバーサイドでも動くように設定してあげる必要があるみたいです

{
  "presets": [
    "next/babel"
  ],
  "plugins": [
    [
      "styled-components",
      {
        "ssr": true,
        "displayName": true,
        "preprocess": false
      }
    ]
  ]
}

ただ、 styled components を用いたことによってページを開いたタイミングで、一瞬 css の適用がおそくなるという問題に遭遇しました

cssの適用遅れによるちらつき

どうやら styled components 側で css を描画するタイミング が微妙に遅れてしまうことによって発生しているようでした。

いわゆる FOUC( Flash of Unstyled Content ) という事象らしいです

さすがにこれは見栄えが悪すぎるとのことで調べたところドキュメントに下記の記載がありました

ご丁寧にサンプルまで用意してくれていました

どうやらビルド時に事前に css を作成しておき、Head にスタイルを注入しておいてくれることによってこの問題を解消できるようでした

サンプルの通り _document.tsx に下記のように記載することにしてみました

import Document from 'next/document';
import { ServerStyleSheet } from 'styled-components';

export default class MyDocument extends Document {
  static async getInitialProps(ctx) {
    const sheet = new ServerStyleSheet();
    const originalRenderPage = ctx.renderPage;

    try {
      ctx.renderPage = () =>
        originalRenderPage({
          enhanceApp: App => props => sheet.collectStyles(<App {...props} />)
        });

      const initialProps = await Document.getInitialProps(ctx);
      return {
        ...initialProps,
        styles: (
          <>
            {initialProps.styles}
            {sheet.getStyleElement()}
          </>
        )
      };
    } finally {
      sheet.seal();
    }
  }
}

その結果下記のように適用遅れがなくなりました

解消後(一応ページリロードは何度もおしてるのです..!)

MaterialUI なども同じような問題に遭遇するため、一律この対応が必要なようです

サイトマップ

SEO を考慮した Web ページとのことで サイトマップは必須です

DROBE では sitemap の生成は next-sitemap を用いることにしました

難しい設定は一切なく、シンプルで非常に使いやすかったです

module.exports = {
  siteUrl: process.env.SITE_URL,
  generateRobotsTxt: true,
}
設定ファイル例

大量にページを生成する予定なので、 sitemap の分割なども検討しなくてはならないのですが、このライブラリは全部よろしくやってくれるみたいで非常に便利でした

API call

静的なページといいつつ、絶対に最新じゃなければならない情報(価格など)に関してはどうしてもサーバーサイドからとってきたい、という話になり SSG なものの、一部 API を叩くことにしました

DROBE では普段から使い慣れている axios を使って動的に情報を取ってきています

~ api/item.ts ~
export const getItem = ({ sku }) => {
	return axios.get(`/items/${sku}`);
}

~ pages/item.tsx ~
export default function Page({ sku }) {
  const [item, setItem] = useState(null)

  useEffect(() => {
    const func = async () => {
      const response = await getItem({ sku })
      setItem(response.data)
    }
    func()
  }, [sku])

  return <ItemPage item={item} />
}

特別変わったことはしておらず、 hook を使って api call → 取得結果を反映、という処理を page に記載しています

next/Image コンポーネントについて

next/Image コンポーネントは Next.js が提供してくれているコンポーネントの1つで、クライアントの環境に合わせて画像の表示を自動でいい感じに調整してくれるという Next.js の神機能です

画像の WebP フォーマットへの変換、Lazy Load、レスポンシブ対応などを行なってくれます

ただし、この機能を適用した状態で next export を起動すると...

image

絶望です。どうやらデフォルトのローダーは使えず、自分で作ったローダーを設定しなくてはならないようです

デフォルトのローダーではリクエストが来たタイミングでいい感じにサーバーで処理を行う仕組みになっているようで、next export で出力された静的なファイルではその仕組みが使えないとのことです

こういった背景から今回は泣く泣く next/Image の利用をやめました

DROBE での Next.js の利用事例(デプロイ編)

DROBE ではざっくり下記のような構成で本番環境を構築しました

もともと AWS の上で運用しているサービスが多いので、今回のプロジェクトでも AWS を使って配信しています

全体構成概要
全体構成概要

1. ビルド周り

image

github action で s3 にアップロードしたソースコードをダウンロードしてきて、CodeBuild 内で next build を叩くようにしています

build が終わったら next export コマンドで生成したファイルを hosting 用の s3 のバケットにアップロード ( s3 sync ) しています

商品情報を管理している DROBE の基盤システムに対して CodeBuild からリクエストを投げて、生成する対象の商品 id や商品の詳細情報、コーディネートの情報などを取得して、ページを生成しています

各種情報は不定期で更新されることがあるため、CloudWatch Event で定期的に CodeBuild を起動してビルドし、最新の情報を取得するようにしています

2. 静的ファイルの配信周り

image

配信は Cloudfront と s3、そして間に Lambda Edge を使っています

通常の静的ファイルの hosting であれば Cloudfront の参照先を s3 に向けてあげるだけで良いのですが、Next.js だと問題が生じてしまいます

Next.js では、デフォルトのルーティングは pages 配下のファイルをそのまま URL に置き換えたものになります

つまり、 pages/hoge.tsx というファイルがあった場合には /hoge でアクセスされることを想定するようになるのです

pages
├── hoge.tsx
├── [fuga].tsx
..

URL は ↓ になる

/hoge
/{fuga}

そのためプロジェクト内で、 next/linknext/router を用いてリンクをしようとした際には全てのリンクは /hoge などのように拡張子を抜いた形で遷移先が設定されるようになります

しかし、 Next.js で export されたファイルは、 /hoge.html というファイル形式で出力されますので、 /hoge などのように拡張子を抜いた形でアクセスすることはできません

s3上に出力されたhtmlファイルの例
s3上に出力されたhtmlファイルの例

ここで活躍したのが lambda edge です

Lambda@Edge | AWS

Lambda@Edge は、 Amazon CloudFront の機能で、アプリケーションのユーザーに近いロケーションでコードを実行できるため、パフォーマンスが向上し、待ち時間が短縮されます。Lambda@Edge では、世界中の複数のロケーションでインフラストラクチャをプロビジョニングまたは管理する必要はありません。課金は実際に使用したコンピューティング時間に対してのみ発生し、コードが実行されていないときには料金も発生しません。 Lambda@Edge を使用すると、サーバー管理を何も行わなくても、ウェブアプリケーションをグローバルに分散させ、パフォーマンスを向上させることができます。Lambda@Edge は、Amazon CloudFront コンテンツ配信ネットワーク (CDN) によって生成されたイベントに対応してコードを実行します。コードを AWS Lambda にアップロードするだけで自動的にコードの実行やスケーリングが行われ、エンドユーザーに最も近い AWS ロケーションでの高可用性が実現します。 Lambda@Edge は、ユーザーに近い AWS のロケーションでコードをグローバルに実行するので、機能が充実した、高性能で低レイテンシーのカスタマイズされたコンテンツを配信できます。 オリジンサーバーを複数のロケーションでプロビジョニング、拡張、管理したり、ロードバランシングやドメインネームシステム (DNS) のルーティングサービスを設定しなくても、世界中の AWS でコードを自動的にスケールして実行することができます。オリジンで実行しているアプリケーションに何も変更を加えずに、新しい機能を追加することができます。最後に、Lambda@Edge と Amazon CloudFront を使用することで、従来の CDN より管理するオリジンインフラストラクチャが少なくなります。 Lambda@Edge を使用すると、Amazon CloudFront CDN を介して配信されるコンテンツをカスタマイズできることに加えて、アプリケーションのパフォーマンスに対するニーズに基づいて、コンピューティングリソースと実行時間をカスタマイズできます。 Lambda@Edge と Amazon CloudFront で何を構築できますか?

Lambda@Edge | AWS

Lambda Edge を設定しておけば、 Cloudfront へのアクセス時にソースコードを実行してくれるようで、今回の例でいうとアクセス時に拡張子が付いていない場合に .html をつけてアクセスしに行くようなコードを差し込みました

exports.handler = (event, context, callback) => {
    var request = event.Records[0].cf.request
    var olduri = request.uri
    var newuri = (path.extname(olduri) != "" || path.basename(olduri) == "") ? olduri : olduri.replace(/$/, '.html')
    request.uri = newuri
    return callback(null, request)
};

これで Next.js が期待する URL で s3 上の各 HTML ファイルにアクセスすることができるようになりました

※ trailing slash オプションをつけてビルドすると、全て /hoge/index.html のように フォルダの中に index.html を作るようになるため、 デフォルトのルートオブジェクトを index.html に設定すれば、 Lambda Edge なしでリンクなどが正常に動きます(ただし url の最後が / で終わってしまうため、見栄えが良くないなあという話になりDROBEでは採用しませんでした)

このままでも該当ページにアクセスできるようになるのですが、用意していないページにアクセスすると、s3でよく見る下記のエラーに遭遇してしまいます

image

Cloudfront では、エラー時のレスポンスも定義できるので、少し無理やりですが、↑のページで 403 がかえった場合には ステータスコードを 404 に切り替えて、 404 用のページのコンテンツを返すようにしています

image

3. drobe.jp 配下に置くための設定

image

DROBE では ALB を使って ECS 上で動作する既存サービスへリクエストを流しているのですが、特定の path のみ 今回作った Next.js のプロジェクトに向けるように構成を変更する必要がでてきました

Cloudfront を ALB の前に置くのが一般的な構成のようだったのですが、既存サービスへの影響を極力抑えたい、という背景から リクエスト → ALB → DROBE という流れを変更しないで行う、ということを検討しました

その結果だいぶトリッキーですが、 ALB の一部の path の転送先を Lambda に設定し、HTTP Request を投げて Cloudfront のコンテンツを取得してきて返すという処理を書くことにしました

Lambda に関しては下記のような関数を設定し、 Cloudfront 側へ 内部でリクエストを投げた結果を返すようにしています

const https = require('https')

exports.handler = async (event, context) => {
    return await new Promise(resolve => {
        const queryString = '?' + Object.entries(event.queryStringParameters).map((e) => `${e[0]}=${e[1]}`).join('=')
        const path = event.path + '' + (queryString == '?' ? '' : queryString)
        https.get(`https:\/\/${process.env.CLOUDFRONT_DOMAIN}${path}`, (res) => {
            let body = ''
            const headers = res.headers
            const statusCode = res.statusCode
            res.on('data', d => body += d )
            res.on('close', () => resolve({ statusCode, headers, body }))
        })
    })
}

ここの部分に関しては運用しながらベストプラクティスを模索していきたいと思っています

おわりに

DROBE での Next.js の利用事例をご紹介しました

ドキュメントが非常に丁寧なことと、使い方自体がとても簡単で多機能なことから、 Next.js は今後の開発においてもどんどん利用していきたいなと思えるフレームワークでした