背景
ある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リポジトリを分けたかった
構成
前段に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
を加工
少し脚色していますが、だいたいこんなコードです。
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 を作る
const FuncA = () => {
return (
<p>
(erbの画面をそのまま再現する)
</p>
);
};
export default FuncA;
自分はBE担当なのであまり分からないのですが、
・既存アプリからHTML構造やCSSを輸入する必要がある
・メニューの選択など、状態系のコントロールをリプレイス後のコードで新たに考える必要がある
など、悩みポイントは多いようです。
Amplifyへデプロイする
AWS CLI から作成する手順もあるようですが、今回はAWSコンソール画面から作成します。
「Amplify>新しいアプリケーション>Webアプリケーションをホスト」を選択します。
Next.jsアプリケーションを管理しているgitリポジトリ・ブランチを選択します。
アプリケーション名を命名し、
プロビジョン〜検証が全て終わったら完了です。
完了後、CloudFrontを開くと、新規にdistributionが作成されていることが確認できます。
以上でmainブランチ相当の環境が立ち上がりました。
mainブランチにpushされるとデプロイが走るようになっています。
ステージング環境を立てる場合などは「ブランチの接続」から行います。
「staging」ブランチを選択し確定すると、このように複数環境立ち上がります。
(CloudFront distribution も新たに作成されます)
環境値は「環境変数」メニューから設定できます。
「すべてのブランチ」でデフォルト値を設定したあと、「アクション>変数の上書きを追加する」で特定ブランチのみに適用する値を指定できます。
(画像ではproductionをデフォルトにしていますが、何をデフォルトにすべきかは要検討です)
振り分け用CloudFront distribution 設置
新しい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切り替えで本番反映します。
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で振り分けた部分、以下機能が担えた可能性がありました。
具体的にどう組み込むかやパフォーマンス面などまだまだ理解していないので、今後調査したいと思います。