dely engineering blog

レシピ動画サービス「kurashiru」を運営するdelyのテックブログ

スモールスタートではじめるSSR

こんにちは。delyでフロントエンドを担当している@all__userです。

今回はkurashiruでSSR(Server Side Rendering)を導入した事例についてご紹介したいと思います。

目次

要約

  • SPAにしたい。SEOのことを考えるとSSRはしておきたい
  • でも全然リソース足りない
  • Railsを温存しつつスモールスタートでSSRできるようにした

という内容です。

経緯・背景

kurashiruはもともとRails単体のアプリケーションでしたが、フロントエンドにVue.jsを採用し、現在では多くのページがSPAへと移行しました。
SPAではSEOの観点からSSRが必要であるとよく言われます。
kurashiruでも調査・議論を重ねた結果、SSRを導入することになりました。

SSRの導入

フロントエンドをSPA(Single Page Application)に置き換えていくタイミングでSSRを導入し、結果としては懸念していたSEOへの悪影響も無く1、無事SPAへと移行することができました。

tech.dely.jp

SPAをやるにあたってとにかく一番頭をもたげたのはSSRどうするか問題でした。

SPAのSEO対策

正直に申しますと、個人的には「SSRはあったほうがいいけど、必須ではない」という主張の持ち主でした。
その内容は概ね以下のようなものです。

  • fetch as googleでレンダリングされていれば問題ない
  • 初期レンダリングコストの削減が目的なら導入・運用コストに見合わない

しかし、いくら調べてみても議論を重ねてみても、SEOに悪影響が無いとは言い切れませんでした。
kurashiruにとってSEOが悪化することはわずかな可能性でも避けたいことでしたが、それと同時に不要なものを導入して複雑化させるのも避けたいことでした。

そんな中、Google I/O '18の中でUser-Agentによってクローラーを識別し、クローラーに対してのみSSRした結果を返すダイナミックレンダリングという手法が紹介されていました。

www.youtube.com

www.suzukikenichi.com

このダイナミックレンダリングの説明の中で、GooglebotによるJavaScriptレンダリングは、現時点で完全ではないことが明示されています。
また、ダイナミックレンダリングがクローキングには当たらないことを示す内容でもありました。

developers.google.com

GooglebotがSPAを完全にレンダリングできない可能性がある以上、SPAへの移行はSSRを前提に考える必要がありました。

SSRのコスト

一般的なSSRの構成は、ユーザーからのリクエストをSSRサーバーで受け、APIサーバーに必要なデータを問い合わせ、ページをレンダリングし、その結果を返すというものです。

一般的なSSRの構成で考えた場合

この構成を採用する場合、以下のような懸念点がありました。

  • metaタグの仕組み(title、description、OGPなど)をRailsからSSRサーバーに移行する必要がある
  • 全ユーザーを対象とする場合はスケールやキャッシュの仕組みなどをNode.jsサーバー用に構成し直す必要がある
  • 非SPAのページと共存する場合、ALBなどの前段で振り分ける必要がある
  • Node.jsサーバーの面倒は誰が見るの問題

kurashiruは単体のRailsアプリケーションとしてすでに十分大きかったため、SSRサーバーにフロントの機能を移すだけで大きなコストがかかります。
社内にNode.jsやSSRの経験が豊富にあれば、この構成を採用する可能性もありましたが、私自身にNode.jsの実戦経験は無かったですし、他のメンバーの技術スタックやリソースを考えても、この構成への移行コストは高く、その時点では採用するメリットはありませんでした。
仮にリソースを割いて移行できたとしても、「もしダメでもやめればいい」という判断がしにくくなる可能性がありました。

Rendertronの採用は見送り

ダイナミックレンダリングの手法として紹介されているうちのひとつが、Rendertronを使った方法です。

github.com

話し合いの中で以下の懸念点が出てきました。

  • キャッシュ効率が高いことが前提となっている
  • クローラーが同じURLを再訪したときしかキャッシュヒットしない
  • 同じURLを再訪する頻度が少なそう
  • だとすると、マシンパワーが必要なためレスポンスタイムが心配
  • お金がかかりそう
  • あくまでクローラーに対してのレンダリング用で、ユーザーにも転用する未来が見えにくい

これらの懸念からRendertronの採用を見送りました。

kurashiruのSSR構成

大枠はRendertronによるダイナミックレンダリングの構成と同じです。
この構成にすることで、既存のRails資産を流用しつつ、部分的にSSRを導入できます。

kurashiruで採用したSSRの構成

  1. Webサーバーがリクエストを受けると、User-Agentからクローラーかどうかを判別し、クローラーの場合はSSRに必要な情報(リクエストパスなど)をJSONにまとめてSSRサーバー(Node.js)にPOSTします。
  2. SSRサーバーがPOSTリクエストを受け取ると、ページのレンダリングに必要なデータをAPIから取得します。
  3. SSRサーバーでデータの取得が完了すると、vue-server-rendererを使用してクライアントサイドと同じコードを実行し、body要素直下にマウントされるルートコンポーネントをレンダリングします。
  4. SSRサーバーでレンダリングが完了すると、レンダリング済みのHTML文字列をJSONにまとめ、Webサーバーに返します。
  5. Webサーバーがレンダリング済みのHTMLを受け取ると、body直下にそのHTMLを埋め込み、完成したHTMLをブラウザに返します。

ルートメタフィールドを利用したデータ取得の仕組み

クライアントサイドと同じように、レンダリングの過程でAPIサーバーから必要なデータをフェッチしますが、クライアントサイドと異なる点として、レンダリングを1ライフサイクル(ルートコンポーネントのbeforeRouteEnter, beforeCreate, created)内で同期的に行う必要があります。
そのため、レンダリングを始める前にすべてのデータのフェッチが完了している必要があります。

この問題を解決するために、例えばNuxt.jsではasyncDataというメソッドを使用することで、非同期のデータ取得をあらかじめ完了させておくことができます。

ja.nuxtjs.org

kurashiruではNuxt.jsを導入していなかったため、同様の仕組みを用意する必要がありました。
コンポーネントのメソッドを利用する代わりに、ルートメタフィールドを利用しています。

router.vuejs.org

簡略化したコードを以下に示します。

// ComponentA.vue
export const generateSsrFetcher = ({ app }: { app: Vue }) => {
  return {
    fetchEndpointA() {
      return app.$api.fetchEndPointA({ id: app.$route.params.id });
    },
  };
};

export default Vue.extend({ /* ... */ });

// routes.ts
const routes = [
  {
    path: '/component_a/:id',
    name: 'component_a',
    component: () => import('./path/to/ComponentA.vue'),
    meta: {
      async getSsrMetadata() {
        const { generateSsrFetcher } = await import('./path/to/ComponentA.vue');
        return { generateSsrFetcher };
      },
    },
  },
];

// renderApp.ts
async () => {
  // ...
  const app = createApp();
  app.$router.push({ path: '/component_a/123' });
  const metadata = await app.$route.meta.getSsrMetadata();
  const ssrFetcher = metadata.generateSsrFetcher({ app });
  const data = await ssrFetcher.fetchEndPointA();
  // ...
}

Nuxt.jsのasyncDataではthisによるVueインスタンスの参照ができないという制約があります。
レンダリング前に実行されるメソッドということを考えると自然なことですが、this.$route.paramsを利用できないなどの不便な点もあります。
これらを緩和するためにAPIからデータを取得する際、ルートインスタンスを参照できるようにしています。2

消極的SSRから積極的SSRへ

SSRの導入に関しては、SEO対策だけでなく、初期レンダリングコスト削減による体感速度の向上など、UX改善という側面もあります。
kurashiruではまだこのようなモチベーションでSSRに取り組めていないので、消極的SSRと呼んでいたりします。

Node.jsを運用する知見が少しづつ溜まってきたこともあり、全てのリクエストに対してSSRするなど、より積極的な導入も考えています。
このあたりは今後の課題です。

まとめ

SPAへの移行を機にSSRを導入した事例についてご紹介しました。
SPA+SSRを同時に導入するということは、大きな変更を2つ同時に行わなければならず、単純明快なメリットが無いと特に採用ハードルが高くなると思います。
この記事で紹介したように、既存資産を流用しながら検証を行うことができれば、SPA+SSRは最良の選択になるかもしれません。

最後までお読みいただきありがとうございました。
SPA化に関しては下記の記事でも紹介していますので、ぜひご覧ください!

tech.dely.jp


  1. 「SPAはSEOに良い影響があった」という意味ではないので注意が必要です。

  2. Vue.js 2.6で入ったserverPrefetchでは、thisによるコンポーネント自身のインスタンス参照が可能なので、置き換えを検討しています。