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

<!--
========== 背景 ==========
-->

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

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

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

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

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

#### FE

「マークアップして」

```erb
<適切なタグ 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の知識が必要になる

```erb
<仮のdiv>
```

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

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

```erb
       <%= 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](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2728467/877b6495-c3fc-7495-1f7b-9a3e9f5d5297.png)

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

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

<!--
========== Next.jsのリポジトリ作成 ==========
-->

## Next.jsのリポジトリ作成

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

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

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

```shell
yarn add @apollo/client graphql
```

### `pages/_app.tsx` を加工

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

```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

```tsx
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が払い出すトークンを入手する必要があります。
本構成では、ログイン状態確認のためのリクエスト

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

が成功したとき、

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

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

```tsx
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 を作る

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

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

<!--
========== Amplifyへデプロイする ==========
-->

## Amplifyへデプロイする

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

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

![スクリーンショット 2022-09-19 15.53.13.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2728467/d4b59b7c-3144-7af6-a8b9-70ef1622e493.png)

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

![スクリーンショット 2022-09-19 15.55.58.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2728467/fd7f8604-48f2-a82a-8e21-89b0c4a6b42a.png)

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

![スクリーンショット 2022-09-19 15.57.40.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2728467/e9e02e3b-f2f4-d622-4979-ae83eaeafeb8.png)

![スクリーンショット 2022-09-19 15.59.12.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2728467/bb2a2930-e312-9fb5-788d-c4883f0ebd73.png)

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

![スクリーンショット 2022-09-19 16.01.08.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2728467/ebdbbe76-d7f5-fe6f-e3b1-e3b19a5115ca.png)

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

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

<img width="400" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2728467/2c5753a8-8667-6acb-1bc6-c6d1d80ba06a.png">

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

![スクリーンショット 2022-09-19 16.06.17.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2728467/596e2628-cdbb-05b6-e7db-990cd8b887b2.png)

<!--
========== 振り分け用CloudFront distribution 設置 ==========
-->

## 振り分け用CloudFront distribution 設置

![スクリーンショット 2022-09-19 15.26.00.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2728467/3411473f-c2bd-3015-f45f-e09d7c5dc710.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切り替え）

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

![gairyaku1.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2728467/877b6495-c3fc-7495-1f7b-9a3e9f5d5297.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で振り分けた部分、以下機能が担えた可能性がありました。
具体的にどう組み込むかやパフォーマンス面などまだまだ理解していないので、今後調査したいと思います。

https://nextjs.org/docs/api-reference/next.config.js/rewrites