3
0

More than 1 year has passed since last update.

既存RailsアプリでNext.jsへの段階移行を始めた話

Last updated at Posted at 2022-09-30

背景

あるRailsアプリの新機能開発の様子

PM

「〜〜という新機能を追加します」

BE

「(routerに新しいリソース書いて)」
「(新しいモデルとDBマイグレーション書いて)」
「(コントローラとモデルにロジック書いて)」
「(viewテンプレートには仮でマークアップして)」

<仮のdiv>
  <% @items.each do |item| %>
     <仮のdiv>
       <%= item.name %>
       <%= button_tag :item_button %>
     </仮のdiv>
  <% end %>
</仮のdiv>

「マークアップお願いします」
「それとボタンクリックしたら〜〜するようにお願いします」
「>FE」

FE

「マークアップして」

<適切なタグ class="適切なクラス">
  <% @items.each do |item| %>
     <適切なタグ class="適切なクラス">
       <%= item.name %>
       <%= button_tag :item_button,
           id: "item-button-#{item.id}" %>
     </適切なタグ>
  <% end %>
</適切なタグ>

「JS,CSS書いて」

// 略

問題点

BE/FEが終わるまで双方待ちが発生する

「(viewテンプレートには仮でマークアップして)」

が終わるまでFEに引き渡せません。
その逆の、FE工程が終わらないとBEが始められないケースもしばしばあります。

BEの人にFEの知識が必要になる

<仮のdiv>

後続のFEの人に渡せる程度にはHTMLを仮組みできる必要があります。
JSやCSS事情の把握が必要なケースもたまにあります。

FEの人にBEの知識が必要になる

       <%= button_tag :item_button,
           id: "item-button-#{item.id}" %>

・ruby(erb)の文法
・タグヘルパー(button_tag)など、Railsが提供するものの動き
・BEから値をどうやって入手するか( #{item.id}
などの知識が必要になります。 

どうしたかったか

・FEをReactで書きたかった
・BEはAPIに専念したかった
・FE/BEでgitリポジトリを分けたかった

構成

gairyaku1.png

前段にCloudFront distribution を設置し、 1~数画面ずつ移行できるようにする を実現しています。
狙った画面(/func_a)だけNext.jsへ、それ以外は従来のRailsサーバへ送るようにしています。

Next.jsはAmplifyにデプロイしています。
前述のCloudFront distribution でリプレイス対象に選択したパスをAmplifyで受け付けます。

Next.jsのリポジトリ作成

リプレイス後のFE環境となるNext.jsアプリケーションを新規作成します。
create-next-appで新規に作成します。

npx create-next-app --example with-typescript example_app

BEとのデータ交換はGraphQLで行うので、必要なライブラリを入れておきます。

yarn add @apollo/client graphql

pages/_app.tsx を加工

少し脚色していますが、だいたいこんなコードです。

pages/_app.tsx
import { useState, useEffect } from 'react';
import { ApolloClient, InMemoryCache, createHttpLink, ApolloProvider } from '@apollo/client';
import { setContext } from '@apollo/client/link/context';
import { AppProps } from 'next/dist/shared/lib/router/router';

const httpLink = createHttpLink({
  uri: 'https://example.com/api/graphql',
  credentials: 'same-origin' // リクエストにCookieを付与
})
const authLink = setContext((_, { headers }) => {
  return {
    headers: {
      ...headers,
      'x-csrf-token': sessionStorage.getItem('x-csrf-token') // リクエストにCSRFトークンを付与
    }
  }
})
const client = new ApolloClient({
  cache: new InMemoryCache(),
  link: authLink.concat(httpLink)
})

function MyApp({ Component, pageProps, router }: AppProps) {
  const [isLoggedIn, setIsLoggedIn] = useState(false);

  useEffect(() => {
    // ここでapolloを呼ぶ方法が分からなかったので、fetchAPIを使っています
    // もしかしたらFEの人が直してくれているかも
    fetch(`https://example.com/api/current_user`, { credentials: 'include' })
      .then(response => {
        if (!response.ok) return router.push('/login'); // 未ログインの場合はログインページへ

        setIsLoggedIn(true);
        sessionStorage.setItem('x-csrf-token', response.headers.get('x-csrf-token')) // レスポンスヘッダからCSRFトークンを取得
      })
      .catch(error => {
        router.push('/login'); // サーバエラーはログインページへ
      })
  },[router.pathname])

  if(isLoggedIn) {
    return (
      <ApolloProvider client={client}>
        <Component {...pageProps} />
      </ApolloProvider>
    )
  }
}

export default MyApp;

注意すべきは
・Cookie
・CSRFトークン
の2点で、それぞれ解説を書きます。

Cookie

const httpLink = createHttpLink({
  uri: 'https://example.com/api/graphql',
  credentials: 'same-origin' // リクエストにCookieを付与
})

トークン認証でなくCookieで認証します。 

なお、example.com のようにAppサーバとAPIサーバが同じ前提で書いています。

もしAPIサーバがapi.example.com などでサブドメインが異なる場合は、credentials: 'include' を使います。
(Rails側のsession_store設定にも関わります)

CSRFトークン

フルスタックWebフレームワークには大体CSRF対策としてこの仕組みがあると思います。
これを付与せずにPOST(/PUT/DELETE/...)を送るとブロックしてくれる仕組みです。

POST(/PUT/DELETE/...)を送るためには、Railsが払い出すトークンを入手する必要があります。
本構成では、ログイン状態確認のためのリクエスト

fetch(`https://example.com/api/current_user`, { credentials: 'include' })

が成功したとき、

sessionStorage.setItem('x-csrf-token', response.headers.get('x-csrf-token')) // レスポンスヘッダからCSRFトークンを取得

のようにCSRFトークンを取得して sessionStorage に保管します。
その後、

const authLink = setContext((_, { headers }) => {
  return {
    headers: {
      ...headers,
      'x-csrf-token': sessionStorage.getItem('x-csrf-token') // リクエストにCSRFトークンを付与
    }
  }
})
const client = new ApolloClient({
  cache: new InMemoryCache(),
  link: authLink.concat(httpLink)
})

のようにしてapolloが送るリクエストに付与しています。

pages/func_a.tsx を作る

pages/func_a.tsx
const FuncA = () => {
  return (
    <p>
      erbの画面をそのまま再現する
    </p>
  );
};
export default FuncA;

自分はBE担当なのであまり分からないのですが、
・既存アプリからHTML構造やCSSを輸入する必要がある
・メニューの選択など、状態系のコントロールをリプレイス後のコードで新たに考える必要がある
など、悩みポイントは多いようです。

Amplifyへデプロイする

AWS CLI から作成する手順もあるようですが、今回はAWSコンソール画面から作成します。

「Amplify>新しいアプリケーション>Webアプリケーションをホスト」を選択します。

スクリーンショット 2022-09-19 15.53.13.png

Next.jsアプリケーションを管理しているgitリポジトリ・ブランチを選択します。

スクリーンショット 2022-09-19 15.55.58.png

アプリケーション名を命名し、

スクリーンショット 2022-09-19 15.57.40.png

スクリーンショット 2022-09-19 15.59.12.png

プロビジョン〜検証が全て終わったら完了です。
完了後、CloudFrontを開くと、新規にdistributionが作成されていることが確認できます。

スクリーンショット 2022-09-19 16.01.08.png

以上でmainブランチ相当の環境が立ち上がりました。
mainブランチにpushされるとデプロイが走るようになっています。

ステージング環境を立てる場合などは「ブランチの接続」から行います。
「staging」ブランチを選択し確定すると、このように複数環境立ち上がります。
(CloudFront distribution も新たに作成されます)

環境値は「環境変数」メニューから設定できます。
「すべてのブランチ」でデフォルト値を設定したあと、「アクション>変数の上書きを追加する」で特定ブランチのみに適用する値を指定できます。
(画像ではproductionをデフォルトにしていますが、何をデフォルトにすべきかは要検討です)

スクリーンショット 2022-09-19 16.06.17.png

振り分け用CloudFront distribution 設置

スクリーンショット 2022-09-19 15.26.00.png

新しいdistributionを設置し、このように設定しました。

パスパターン オリジン ビューワープロトコルポリシー キャッシュポリシー名 オリジンリクエストポリシー名
/_next/* amplify HTTP を HTTPS にリダイレクト Managed-CachingOptimized -
/func_a amplify HTTP を HTTPS にリダイレクト Managed-CachingDisabled -
デフォルト (*) alb HTTP を HTTPS にリダイレクト Managed-CachingDisabled Managed-AllViewer

オリジン
/_next/* amplifyが自動生成するCloudFront distribution へ送ります。
/func_a amplifyが自動生成するCloudFront distribution へ送ります。
デフォルト (*) Railsが動いているALBへ送ります。

キャッシュ
/_next/* JS,CSS類なので、基本設定である Managed-CachingOptimized を使います。
/func_a 動的ページなので Managed-CachingDisabled を使います。
デフォルト (*) 動的ページなので Managed-CachingDisabled を使います。

オリジンリクエストポリシー名
/_next/* -
/func_a -
デフォルト (*) リクエストヘッダを従来通り渡したいため Managed-AllViewer を使います。

本番反映(DNS切り替え)

あとはDNS切り替えで本番反映します。

gairyaku1.png

Route53で「example.com」のAレコードをALBからCloudFront distribution へ向けます。
Route53の場合はALIASで指定できます。
(他DNSサーバの場合はALIASの代わりにCNAMEで指定しますが、CNAMEはサブドメインしか指定できない仕様があるので「example.com」はNGかもしれません)

TTLが切れて切り替わったら、
https://example.com/func_a
https://example.com/func_b
を見比べて反映を確認します。

最後に

CloudFrontで振り分けた部分、以下機能が担えた可能性がありました。
具体的にどう組み込むかやパフォーマンス面などまだまだ理解していないので、今後調査したいと思います。

3
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
3
0