はじめに
こんにちは。DELISH KITCHEN 開発部の村上です。 この記事は every Tech Blog Advent Calendar 2023 の 22 日目です。いよいよ長く続いたアドカレも終盤になりました。これまで投稿された他の記事もリスト化されているのでぜひ見てみてください!!
さて今回はエブリーで運営しているキャンペーンの LP 開発においてヘッドレス CMS の microCMS と Next.js を用いた効率化に取り組んでいるのでその取り組みや知見を紹介させていただきます。
ヘッドレス CMS 導入の背景
エブリーではクライアントが協賛する形でいくつかのプレゼントキャンペーンが実施されており、それぞれのキャンペーンは独自の LP ページを持ちます。元々は Google フォームで行っていたものを LP ページ作成での CV 向上を計るという方針にあたり、取り組み当初はうまくいくか不透明な中で仕組みを作りすぎず、最速で PoC を行うためにエンジニアが自らマークアップを行い、Next.js で SSG したコンテンツを S3 + CloudFront で配信する最小構成を選択しました。実際にこの構成である程度のスピード感を持って施策の検証をすることができたのですが、PoC を終えて拡大フェーズに事業が入っていく中で管理するキャンペーンも増え、以下のような課題も浮き彫りになってきました。
- 同時並行で複数キャンペーンが開始したい時にエンジニアの工数がボトルネックになる
- 開催中のキャンペーン情報の細かい変更や改善でもエンジニアへの依頼が必要
- キャンペーン担当者との実装確認依頼もありコミュニケーションコストが高く、ケアレスミスも発生しやすい
このようにその当時は最適と思われた意思決定も事業の成長スピードに対して開発体制やシステムが次第に追いつかなくなり、開発がボトルネックになることが目立ってきていたような状況でした。そこでこうした変化にシステム側も対応するためにエンジニアが作業をすることなく、キャンペーンの作成や更新をできるような状態を目指し、ヘッドレス CMS の導入を検討しました。既存の配信構成をほぼ変える事なく、コンテンツ管理も内製せずに改善ができるのはヘッドレス CMS の大きなメリットでした。
ヘッドレス CMS で有名なところだとContentfulなどもありますが、実際に使う運用者を考えるとわかりやすい UI や日本語サポートが充実していて、日本での採用事例の多くあるmicroCMSという日本製の CMS を最終的には採用しました。エンジニアとしては開発ロードマップが公開されて日々アップデートがされていたり、Next.js での新しい機能でのmicroCMS の利用をいち早く公式として発信したりとその開発体制にも好感を持てました。
microCMS を用いたシステムの全体像
microCMS の導入後のシステム全体像は以下のようになりました。
キャンペーンページの配信までは大きく以下のような流れをたどります。
- microCMS 内でコンテンツの入稿または更新
- 変更を検知して、Github Actions が発火
- Next.js で SSG してビルド、 S3 にデプロイ
- Cloudfront 経由でキャンペーンページを配信
ここからはこの仕組みを導入していくまでの工程の詳細を説明していきます。
microCMS の API 作成
まずはコンテンツ入稿やシステム連携の土台となる API の作成から行っていきます。API スキーマはフィールドという単位で要素を追加しながら定義していきます。選択できるフィールドの種類はたくさんあるのでこちらの公式ドキュメントを参考にしていただきたいのですが、その中でも私たちが LP のコンテンツ作成において一番活用しているカスタムフィールドと繰り返しフィールドを紹介します。
カスタムフィールド
カスタムフィールドは microCMS 内において複数のフィールドを組み合わせて固有のフィールドを作ることができる機能です。例えば、LP においてボタンを表示したいとしても自由にカスタマイズできるようにしようと思うと、ボタンのリンクだけではなく、表示するテキストや独自スタイルごとのボタン種別、分析用のイベント識別子などを追加したくなります。そういった場合はカスタムフィールドを用いて以下のようなフィールドを作成することで実現可能です。
実際の API はレスポンス内でネストされたオブジェクトの形をとります。
{ "id": "test-id", "createdAt": "2023-12-20T08:30:38.460Z", "updatedAt": "2023-12-20T08:30:38.460Z", "publishedAt": "2023-12-20T08:30:38.460Z", "revisedAt": "2023-12-20T08:30:38.460Z", "button": { "fieldId": "itemButton", "text": "ボタン", "type": ["form"], "link": "https://example.com", "eventLabel": "test" } }
繰り返しフィールド
カスタムフィールドと活用することで API スキーマの柔軟性が格段に向上するのが繰り返しフィールドです。繰り返しフィールドはカスタムフィールドを複数選択し、選択したカスタムフィールドを好きな順序で繰り返し入れることができるものです。例えば、先ほどのボタンに加えて、シンプルにタイトルを表すカスタムフィールドを追加して繰り返しフィールドを追加すると以下のような形で入稿することができます。
実際の API レスポンスは配列の中に各カスタムフィールドのオブジェクトが入っているので識別子でそのオブジェクトの型を判別して実装します。
{ "id": "test-id", "createdAt": "2023-12-20T08:30:38.460Z", "updatedAt": "2023-12-20T08:33:39.574Z", "publishedAt": "2023-12-20T08:30:38.460Z", "revisedAt": "2023-12-20T08:33:39.574Z", "items": [ { "fieldId": "itemTitle", "title": "タイトルA" }, { "fieldId": "itemButton", "text": "ボタンA", "type": ["form"], "link": "https://example.com", "eventLabel": "test_a" }, { "fieldId": "itemTitle", "title": "タイトルB" } ] }
今回 CMS 導入に至った LP のコンテンツ制作においてはテキストや画像、ボタンなど各要素の配置をそれぞれのキャンペーンに最適化して作るため、自由にコンテンツを入れ替えて構築することができることは必須要件でそれを実現できるこういった機能はとても便利でした。
Next.js の build 時に microCMS の API を利用してページを生成
API が作成できたら次はその API を利用して実際にページを生成する部分を作っていきます。既存のシステムでは Next.js の SSG 機能を使って build し S3 から CloudFront 経由で配信をしているので build 時に microCMS の API を呼び出す形で連携していきます。microCMS では Javascript SDK を npm で配布しており、 microcms-js-sdkを使うことで簡単に連携をすることができます。
実際にこれらを利用することで以下のような実装をすることができます。
// libs/client.js import { createClient } from "microcms-js-sdk"; export const client = createClient({ serviceDomain: "domain", apiKey: process.env.API_KEY, }); // pages/campaign/[id].jsx import { client } from "libs/client"; export default function Campaign({ campaign }) { return ( <main> <h1>{campaign.title}</h1> <p>{campaign.content}</p> </main> ); } export const getStaticPaths = async () => { const data = await client.get({ endpoint: "campaigns", }); const paths = data.contents.map((c) => `/campaign/${c.id}`); return { paths, fallback: false }; }; export const getStaticProps = async ({ params }) => { const data = await client.get({ endpoint: "campaigns", contentId: params.id, }); return { props: { campaign: data, }, }; };
getStaticPaths と getStaticProps で microCMS から返却されたデータを使ってページ生成することができました。これでデプロイ時に自動で CMS のコンテンツを取得してサイトに反映することが可能になりました。
変更を検知して Github Actions を起動
デプロイ時に自動でコンテンツ反映ができるようになりましたが、CMS での運用を考えると管理画面内の変更が即時に反映できる形が望ましいです。これを実現するために microCMS 内での変更を検知して Github Actions を起動するようにします。microCMS では Github Actions への webhook 通知ができるようになっており、変更を検知すると dispatch イベントが発火し、起動するようになります。
API 設定 > Webhook からトリガーイベントや通知タイミングの設定を行えます。
Github Actions は既存の CI/CD ですでに組み込んでいたので一部のビルド、デプロイ処理を切り出すことで特別このためにやることなく設定することが可能です。
本番運用する中での工夫
以上で最低限、microCMS と連携して自動でコンテンツ反映ができるようになると思います。ただ、実際に本番運用するとなるといくつか注意したいポイントがあったのでそちらについても触れていきたいと思います。
1. CMS で入稿された画像を S3 に同期して別で配信を行う
microCMS では画像管理、配信も行える基盤が整備されており、裏側は imgix と連携しています。したがって、imgixAPI で行えることが microCMS でも行うことができ、動的なサイズやフォーマット変更など画像を配信する上で強力な機能をたくさん備えています。ただ、この画像配信は無料で制限なく使えるものではなく、実際には各料金プランで確保された月のデータ転送量外で増えた転送量はその分従量課金されていく形になります。今回の場合では LP として画像配信が多く、トラフィックを試算したところ自前で画像配信基盤を構築した方が低コストに運用ができそうだったので移行に踏み出しました。
先ほどのコンテンツ管理での Github Actions 連携同様に microCMS でのメディア管理では Webhook の機能があります。今回はこの機能を利用して、API Gateway + lambda で S3 に画像を同期するような設定を行っています。
body には以下の内容が入ってきます
{ "service": "test", "type": "edit", // new または update または delete "old": { "url": "https://image.microcms-assets.io/xxxxxx", "width": 100, "height": 100 }, "new": { "url": "https://image.microcms-assets.io/xxxxxx", "width": 100, "height": 100 } }
今回はドメインの置換のみで参照先を切り替えられるようにしたいので、lambda 側では microCMS でのパスを引き継いだ状態で S3 に格納します。弊社で動いている python のコードは以下のような実装になっています。
from http import HTTPStatus from urllib.request import urlopen import os import boto3 s3 = boto3.resource('s3') bucket = s3.Bucket(os.environ.get('S3_BUCKET', 'test.bucket')) replace_url = os.environ.get('REPLACE_URL', 'https://images.microcms-assets.io/') def sync_media_cms_to_s3(event, _): try: if event['type'] == 'new': uploadS3(event['new']['url']) elif event['type'] == 'edit': deleteS3(event['old']['url']) uploadS3(event['new']['url']) elif event['type'] == 'delete': deleteS3(event['old']['url']) except Exception as e: body = f"failed to sync media. err: {e}" print(f"ERROR: {body}") return { "statusCode": HTTPStatus.INTERNAL_SERVER_ERROR.value, "body": body, } return { "statusCode": 200, } def uploadS3(url): upload_s3_path = url.replace(replace_url, '') bucket.upload_fileobj(urlopen(url), upload_s3_path) def deleteS3(url): delete_s3_path = url.replace(replace_url, '') bucket.Object(delete_s3_path).delete()
次に独自の画像ドメインに参照を変える必要もあります。こちらはシンプルな対応になりますが、API のレスポンス内にある microCMS の画像ドメイン(images.microcms-assets.io)を独自ドメインに置換して build することで参照を変えることができます。
あくまで今回上げさせてもらったこの取り組みは弊社の基盤での最適解であり、確保された月のデータ転送量の範囲内で運用できる場合や超えたとしても自前で基盤を作るコストと天秤にかけて問題がない場合には積極的に画像配信の機能も使った方がいいと思うので、参考程度に見ていただけると嬉しいです。
2. microCMS の API 制限の回避
先ほど Next.js を用いた microCMS との連携とページ生成について、その手法を紹介させていただきましたが、こちらには一つ問題があります。それはある程度コンテンツ数が増えてくるとその数だけ getStaticProps 内での API 呼び出しが行われ、GET API のレートリミットである 60 回/秒を超えてしまう可能性が考えられることです。
これは microCMS に限らず、Next.js 内の SSG での課題として議論されていたり、レスポンスを再利用するような手法も紹介されていたりします。実際に私たちも紹介された手法を用いて、getStaticPaths で fetch した API のレスポンスをファイルとしてキャッシュして getStaticProps で再利用するようなやり方で API の呼び出し回数を抑えています。
詳細な実装は紹介されているサイトを見ていただきたいですが、ページ生成部分の実装は以下のように変わります。
import { client } from "libs/client"; export default function Campaign({ campaign }) { return ( <main> <h1>{campaign.title}</h1> <p>{campaign.content}</p> </main> ); } export const getStaticPaths = async () => { const data = await client.get({ endpoint: "campaigns", }); // データをキャッシュする await client.cache.set(data.contents); const paths = data.contents.map((c) => `/campaign/${c.id}`); return { paths, fallback: false }; }; export const getStaticProps = async ({ params }) => { // キャッシュからデータを取得 const data = await client.cache.get(params.id); return { props: { campaign: data, }, }; };
3. プレビュー機能を活用する
実際に運用していくとおそらく変更前にその見た目の確認をできれば本番と同じような環境で行いたくなってくると思います。microCMS ではプレビュー画面を表示する機能が備わっており、設定する URL の中に {CONTENT_ID}
と {DRAFT_KEY}
という文字列を埋めることによって、プレビュー画面利用時に自動で置換され、URL が構築されるようになっています。
この機能を使って、特定のパスに preview ページを作って、受け取った CONTENT_ID と DRAFT_KEY から動的に画面を生成することができ、自前で実装せずとも簡単にプレビュー画面を作れるようになっています。
おわりに
現在では徐々に microCMS でのコンテンツ連携に各キャンペーン LP が移行している状態で、すでにエンジニア側、担当者双方で工数が減り、改善スピードが上がったことを実感しています。元々こういった改善案もエンジニア側で事業の成長スピードに合わせて、議論されて挑戦してみた結果生まれており、エンジニアがその時々の事業状況からその都度最適なシステムを考えるオーナシップがあるからこそ実現できたと思います。エブリーではこれからも技術的挑戦を行いながら、エンジニア側から技術で事業の成長を牽引していきたいと思っているので、ぜひ興味を持った方はカジュアルにお話させていただきたいです!