deploy
webpack
YARN
React
アドベントカレンダー2018

【React.js】SPAでのリビジョンアップ対策

11月からエイチームライフスタイルにJoinした奥山です。

本稿ではReactでWEBサービスなどを運用されている方で、同様の問題で苦労されていることの解決に役に立ってもらえればと思います。

ReactはWEBに新しいUXをもたらし開発するのも非常に楽しいものですが、同時に従来のWEB運用では想定し得なかった問題も出てきます。今後はこういった現場のノウハウ的なものを定期的にアップしていけたらと思います。


ReactなどでSPAを構築するメリットは高速動作する優れたUIがあると思います。

SPAの実態であるJavascriptファイルを最初にブラウザ側に読み込むことで、後はAPI通信のみとなるためHTMLページを都度読み込む従来のWEBサイトと比べ、不要な情報の読み込みや再レンダリングのオーバーヘッドが軽減され、高速なUIを実現できるようになります。

react_spa_revision01.jpg


リビジョンアップ時の問題

WEBサービスをフルのSPAで作成した場合、最初に読み込まれたJavascriptファイル上でWEBサービスが展開されるため、意図的にリロードしない限りブラウザ側に保持されたJavascriptファイルは読み込まれた時点のままとなります。

その場合、以下のようなケースが発生した場合に問題となります。


  • SPA側で表示項目の更新

  • APIサーバー側DBで更新項目のマイグレーション

  • REST-API側で受け渡しする項目更新

上記の変更があった場合、APIサーバー側とSPA側の同時更新を行わないと最悪SPA側のクラッシュにつながります。(SPA側の作りが良ければクラッシュまでは行かないが、データ不整合につながる可能性も考えられます。)

react_spa_revision03.jpg


解決方法を考える


①SPA側のルーティングが変わる度にリロード

この方法だと以下のような問題がある。


  • 都度の再読込に時間がかかりSPAの意味がなくなる

  • ルーティングが変わらない、ページ内部でのAPI通信時に対応できない

→ この方法はメリットも無ければ、根本解決していないため却下


②APIサーバー側でSPAのリビジョンを管理

APIサーバー側のDBなどでリビジョン情報を管理し、API経由で都度リビジョン確認を行う方法。

API通信時に必ずリビジョンチェックを走るようにすればSPA側を自動で更新をかけることが可能。

ただ、この方法だと以下の問題がある。


  • SPAの更新をAPIサーバー側でも管理しなければならない(二重管理)

  • リビジョン管理の仕組みが冗長的で複雑

  • 都度APIサーバーにリビジョン管理を行うため、APIサーバー側に負荷がかかる

→ この方法で一応解決できそうだが、上記の問題がある。


③WEBサーバー側でリビジョンを管理

WEBサーバーにリビジョン管理用のJSONファイルを配置して、そのファイルをAPI通信前に都度読み込みリビジョン確認を行う方法。

この方法だと、


  • APIサーバーに負荷をかけない

  • WEBサーバーのJSONファイルを読み込むだけなのでオーバーヘッドが少ない

  • 将来CDN対応した場合、さらに高速でリビジョン確認が行える

などのメリットがあります。

(結論)③の方法を採用とします。


実装方法を考える

以下のようなロジックで実装を行っていきます。


  • デプロイ時にHTMLファイル内にJavascriptコードでリビジョン情報のJSONを埋め込みます。

  • 同じくデプロイ時にrevision.jsonファイルに同じリビジョン情報のJSONを埋め込みます。

  • ブラウザ側に読み込まれたHTMLにはこのリビジョン情報が受け渡されるようになります。

  • SPAではAPI処理が走る前に毎回WEBサーバーのrevison.jsonファイルを確認を行いリビジョン情報を読み込みます。

  • 読み込んだリビジョン情報とHTMLに埋め込まれているリビジョン情報の付け合せを行います。

  • リビジョンに差異がある場合は強制リロード(キャッシュ問題もあるのでスーパーリロードにする)します。
    react_spa_revision02.jpg

-- JavascriptファイルではなくHTMLファイル側にリビジョン情報を持たせた理由

リビジョン情報をJavascript側に埋め込むことも考えたのですが、CIなどでデプロイすることを想定するとシェル側でリビジョン管理したほうがJavascript側のビルドと分離できてシンプルになるので、あえてHTMLファイル側に埋め込むようにしました。


リビジョン管理実装


HTMLテンプレートへリビジョン番号を埋め込み

SPAの埋め込み先であるテンプレートHTMLにリビジョン番号の埋め込みを行うため、12~16行目にscriptを埋め込んでおきます。※テンプレートのリビジョンNOは"0"とします。

※ スタイルにはBootstrapを使用しています。

ソースファイル:./src/index.html

<!DOCTYPE html>

<head>
<meta charset="UTF-8">
<title>SPA サンプル</title>
<meta name="description" content="">
<meta name="keywords" content="">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" />
<meta name="format-detection" content="telephone=no">
<meta http-equiv="X-UA-Compatible" content="IE=edge,chrome=1">
<link rel="stylesheet" href="https://maxcdn.bootstrapcdn.com/bootstrap/latest/css/bootstrap.min.css">
<link rel="stylesheet" href="https://maxcdn.bootstrapcdn.com/bootstrap/latest/css/bootstrap-theme.min.css">
<!-- リビジョン管理用JSON変数埋め込み -->
<script type="text/javascript">
var g_revision="0"; // リビジョンNO管理 ※必ず"0"を指定
</script>
<!-- /リビジョン管理用JSON変数埋め込み -->
</head>
<body>
<!-- SPA埋め込みノード -->
<div id="app"></div>
</body>
</html>


リビジョンをチェックする関数を追加

ローカルとサーバー側のリビジョンをチェックし、相違があった場合にリロードする関数を追加します。

※ 無限リロードになる可能性があるため、ローカルストレージを利用してフラグ管理を行っています。

※ API通信にはaxiosライブラリを利用していますが、別のライブラリ利用でも大丈夫です。

ソースファイル:./src/revision_check.js

import axios from 'axios'

/**
* リビジョン管理用のローカルストレージ保存関数
* @param obj
*/

export function setLocalStorageRevision(obj) {
localStorage.setItem('REVISION', JSON.stringify(Object.assign({}, JSON.parse(localStorage.getItem('REVISION')), obj)))
}

/**
* リビジョン管理用のローカルストレージ取得関数
* @param key
* @returns {*}
*/

export function getLocalStorageRevision(key) {
if (!JSON.parse(localStorage.getItem('REVISION')) || !JSON.parse(localStorage.getItem('REVISION'))[ key ]) {
return false
}
return JSON.parse(localStorage.getItem('REVISION'))[ key ]
}

/**
* リビジョンチェック関数
* @param -
* @returns Promiseオブジェクト
*/

export function checkRevision() {
return new Promise( (resolve, reject) => {
// Revisonチェック開始(キャッシュ無効にするためハッシュを付けて確認する)
let hash = new Date().getTime()
axios.get('/revision.json?'+hash).then(response => {
// 現在のリビジョンと取得したリビジョンが異なる場合はリロード
if (window.g_revision !== response.data.revision) {
// 無限リロードしないように更新中フラグで管理する
let revisionChangingFl = getLocalStorageRevision('revision_changing_fl')
setLocalStorageRevision({ revision_changing_fl: true})
if(!revisionChangingFl) {
// 完了メッセージ表示用フラグをON
setLocalStorageRevision({ revision_complete_fl: true})
console.log("リビジョンアップ!!")
// リビジョン更新開始
location.reload(true)
}
} else {
// リビジョンが一致したのでLocal Storageのリビジョン更新フラグをfalseに
setLocalStorageRevision({ revision_changing_fl: false})
}
resolve("ok")
}).catch(error => {
console.log(error)
reject(error)
})
})
}


リビジョンアップした場合にメッセージを表示するJSXを追加

以下のような、リビジョンアップ時にアラートメッセージで表示させるコンポーネントを作成します。

sample_revision.gif

ソースファイル:./revision_up_message.jsx

import React from 'react'

import {render} from 'react-dom'
import {setLocalStorageRevision, getLocalStorageRevision} from './revision_check'

class RevisionUpMessage extends React.Component {
// コンストラクタ
constructor(props) {
super(props)
this.state = {
revision_complete_fl: false
}
}

// 描画前
static getDerivedStateFromProps(nextProps, prevState) {
return ({
revision_complete_fl: getLocalStorageRevision('revision_complete_fl')
})
}

// 描画後
componentDidMount(){
// リビジョンアップ完了フラグを戻します。
setTimeout(() => {
setLocalStorageRevision({ revision_complete_fl: false })
setLocalStorageRevision({ revision_changing_fl: false })
this.setState({
revision_complete_fl: false
})
}, 5000)
}

// レンダリング
render () {
if(this.state.revision_complete_fl){
return (
<div className="alert alert-primary alert-dismissible fade show" role="alert">
最新バージョンへ更新完了しました。
</div>
)
} else {
return(null)
}
}
}
export default RevisionUpMessage


最後にリビジョンチェックとメッセージを組み込みます

統括するようなメインのJSXに先程作成したリビジョンチェック関数と、メッセージコンポーネントを組み込みます。


  • リビジョンをチェックするcheckRevision()はサーバーと通信する直前に呼ぶようにします。


  • checkRevision()は非同期で通信するため、Promiseで返し、処理終了後の.thenでその後のAPI処理を行うようにします。

  • リビジョンアップされた場合にアラート表示を行うためコンポーネントの先頭に<RevisionUpMessage />を埋め込みます。

ソース:./index.jsx

import React from 'react'

import {render} from 'react-dom'
import {checkRevision} from './revision_check' // <-- 追加
import RevisionUpMessage from './revision_up_message' // <-- 追加

class App extends React.Component {

// ~~{中略}~~

// 何らかのイベント処理
handleAdd(e) {
// リビジョンの確認 ※Promiseで返される
checkRevision()
.then( result => {
// 何らかのAPI通信
// ~~~~~
},
err => {
console.log("リビジョン確認失敗")
})
}

// ~~{中略}~~

// レンダリング
render () {
return (
<div>
<RevisionUpMessage />

<!-- ~~{中略}~~ -->

</div>
)
}
}
render(<App/>, document.getElementById('app'))

以上でリビジョンの変化があった場合に自動アップデートする仕組みが完成しました。


リビジョン管理を含んだデプロイを自動化する

このリビジョン管理方法だとデプロイの度に、


  • リビジョン番号を生成する

  • HTMLファイルにリビジョン番号を埋め込む

  • revision.jsonファイルを作成しリビジョン番号を埋め込む

が必要になりますが、手動で行うと手間がかかりミスや設定忘れなどの事故が起こる可能性があります。

そこで、シェルスクリプトで自動化をしていきます。(CIで利用する場合もそのまま利用できるようになります。)

ソースファイル:./deploy.sh

#!/bin/sh

# npmインストール
yarn install

# 本番向けビルド(./dist フォルダ以下に公開ファイルが生成される)
yarn run build

# リビジョンNOを生成
revision=`date +"%Y%m%d%I%M%S"`

# index.htmlのjavascriptグローバル変数`g_revision`にリビジョンNOをセット
sed -i -e "s/g_revision=\"0\"/g_revision=\"$revision\"/g" ./dist/index.html

# revision.jsonファイルを作成しリビジョンNOをセット
echo "{\"revision\": \"$revision\"}" > ./dist/revision.json

# ビルドしたソース一式をWEBサーバーへ配置
scp ./dist/* {WEBサーバーホスト名}:{ドキュメントルートパス} -i ~/.ssh/id_rsa -r

作成したdeploy.shは実行できるようにパーミッション変更を行います。

$ chmod 755 ./deploy.sh

その後deploy.shを実行するだけで自動でリビジョン管理を含んだデプロイができるようになります。

$ ./deploy.sh


サンプルソース

最後に、上記のサンプルソースをアップしていますので参考してもらえればと思います。

https://github.com/okkuyama/react_sample_revision_up

./deploy.shの最後の行を自身のWEBサーバーへアップロードするように設定変更してお試しください。


最後に

自分がSPAを作成したときに、この辺の管理を行えるライブラリの存在がなかったため上記のような自作を行いましたが、「現時点ですでに便利がライブラリがあるよ」のような意見がありましたら教えていただければさいわいです。


自分はまだジョインして1ヶ月程ですが、エイチームでは新しい技術に対して積極的にチャレンジしていく環境なので、エンジニアにとって非常にやりがいがある環境だと思います。

エイチームグループでは、一緒に働けるチャレンジ精神旺盛な仲間を募集していますので、興味を持たれた方はぜひエイチームグループ採用サイトを御覧ください。

https://www.a-tm.co.jp/recruit/