ISUCON9予選でフロントエンド周りの実装を担当した話
ISUCON9に参加した方、本戦までお疲れさまでした!
今年のISUCON9では運営として予選の作問に携わり、フロントエンド周りを担当しました。
その際に得た知見や実装なりの話を雑に書こうと思います。
話で出てくるソースコードは全てここにあります。
Excuse
- 個人としての雑記です
- 本業フロントエンドエンジニアではないので詰めが甘い部分がかなり多いです
- 本業フロントエンドエンジニアからのマサカリ待ってます
- ISUCON運営として作るフロントエンド、という視点で書いていきます
- 願わくば未来のISUCONフロントエンド担当者に届け
あとは講評や解説は公式Blogで出してるのでそのあたりをぜひ読んでください!
あとはBackendやベンチマーカーの実装に関しては@catatsuyさんの記事とGitHubのコードを合わせて読むことをオススメします。
私とISUCON
私自身のISUCONとの関わりはこんな感じです。
- ISUCON5から毎年参加している
- 毎年予選落ちで地団駄を踏む
- 去年は覚悟を持って望んだものの予選落ち、ビール禁止を決意する
といった感じでした。
今年ももちろん再挑戦、悲願の予選突破を目指そうと思っていたのですが自分の会社に作問が回ってきたのでこんなチャンスなかなか無いだろうと思い運営として参加しました。
技術スタック
アプリケーション自体、どれくらいフロントエンドを作り込むかの点に関しては後述する理由でSingle Page Application(SPA)を採用しました。
私自身、以前にSPAを作ってからかなり年月が経ってしまっていたのと、今回は多少小回りが効かないとしても動くことが最優先だったのでcreate-react-appを利用しました。
丁寧にテストを書く余裕はないだろうと想像できていたのでなるべくエンバグしないようTypeScriptも導入しています。
create-react-appを最後に触ったのは2年ほど前だったのですが、その時よりもかなり使いやすくなっていて最高でした。
no config(厳密には違うのかな)でhot reload、transpile等々動くのは楽だしソースコードと設定周りが強く依存し合うこともないので個人的に重視している捨てやすさも十二分にあるなという印象です。
これに合わせてState管理周りはRedux、UI周りはMaterial UIを導入しています。Material UIマジで最高。
他に使ってるものは興味あればpackage.jsonを覗いてください。
フロントエンドの方針
以下の点を方針として立てました。
- Backendのテンプレート移植の負担をなるべく無くす
- 競技者がアプリケーションを理解しやすいフロントエンドを作る
- 機能が理解しやすい
- 壊れたときに原因がわかりやすい
- フロントエンドでデバッグしたいときにやりやすい
Backendのテンプレート移植の負担をなるべく無くす
ISUCONでは例年、多くの参加者が楽しめるよう複数の言語実装を提供しています。言い換えるとブラウザで開くと全く同じに見えるアプリケーションを複数種類の言語で実装するということです。
その際、コストとして大きいのがHTMLレンダリング周りの移植です。
Backend側での実装移植はよほど言語に依存した実装にしていなければ難しくないですが、テンプレートエンジンは言語やライブラリによって機能の差異が多く結構大変です(※過去の運営をやったわけではないですが、一緒に運営をした人の体験談から想像するのは難くなかった)。
なので基本、Backend側はHTTPのAPIを提供するのみ、HTMLのレンダリングはブラウザ上でする、という方針になりました。ゆえのSPA。
競技者が機能を理解しやすい
今回のアプリケーションは一言で言うならば椅子のフリマアプリでした。
近年は様々なフリマアプリが使われるように身近に感じられるようになりましたが、とはいえ競技者全員が直感的に機能を理解できるとは限りません。
特に今回は配送機能や決済機能周りはAPIのI/Fや仕様書のみから全てを読み解くのは容易ではなかったので、フロントエンドの画面を触ることでそれぞれのAPIの存在意義や機能を理解できるよう心がけました。
このあたりは以下のissueで議論しました。興味があれば覗いてみてください。
壊れたときに原因がわかりやすい
参加したことがある方はよくわかると思いますが、ISUCONでは初見のアプリケーションを壊さずにドラスティックに書き換えていく必要があります。
その過程で起こる問題として意図しないエンバグやその原因調査です。
フロントエンドではこのあたりにすぐに気付けるようにし、原因調査のヒントとなるようにすべきです。
それにあたり以下のような方針で実装しました。
- ページレンダリング時にcriticalなエラーがAPIから返ってきたらAPIのエラーメッセージを500ページにそのまま表示
- フォーム等でエラーが返ってきたらMaterial UIのSnackbar Componentを利用してAPIのエラーをそのまま表示
現実のアプリケーションではAPIのエラーをそのまま表示したり、criticalなエラーを全て500として扱うことは望ましくないのですが今回は競技者にいち早くエラーに気づいてもらう&エラー文をDeveloper Tool等を見ずに知ってもらうためにこのような構成にしています。
BUMP機能のフィードバックUI追加 by sota1235 · Pull Request #411 · isucon/isucon9-qualify · GitHub
フロントエンドエラー周り修正 by sota1235 · Pull Request #299 · isucon/isucon9-qualify · GitHub
ログイン/新規登録時のエラー表示方法を変更 by sota1235 · Pull Request #417 · isucon/isucon9-qualify · GitHub
その他工夫したこと/ハマったこと
React Routerで同ページ間の遷移もActionが発火する
今回はReact RouterをReduxにつなぎこむためにsupasate/connected-react-routerを利用しました。
このconnected-react-router
はReact Routerでページ遷移が発生した際に@@router/LOCATION_CHANGE
というActionを発火させます。
このActionを見ることで例えば「商品ページから別ページへ遷移したら商品ページのstateを初期状態に戻す」といったことが可能になります。
私のState管理の実装は以下のようになっていました。
- Page Componentのレンダリング時(
constructor
)にデータをfetchするためのActionを発火- データが来るまではloading componentを表示しておく
- React Routerのページ遷移アクションが発生したらデータとローディングステータスのstateをリセット
しかし、このActionは同じページ間を遷移する場合にも発火してしまいます。
結果として
- ユーザからはページ遷移していない(同じページにとどまっているだけ)のにデータリセットのが走る
- Page Componentはレンダリングされない(既にされている)のでデータfetchは走らない
というバグが発生しました。
対応策として、@@router/LOCATION_CHANGE
が発火したら遷移前のpathと遷移後のpathを比較し、違う場合のみカスタムActionを発火するmiddlewareを実装。それをもとにState制御することで解決しました。
import { Dispatch, Middleware, MiddlewareAPI } from 'redux'; import { AppState } from '../index'; import { LOCATION_CHANGE } from 'connected-react-router'; import { pathNameChangeAction } from '../actions/locationChangeAction'; import { ActionTypes } from '../actions/actionTypes'; // react-routerのページ遷移発火時、pathnameが変わった場合は独自のactionを発火する const checkLocationChange: Middleware = <S extends AppState>( store: MiddlewareAPI<Dispatch, S>, ) => (next: Dispatch<ActionTypes>) => (action: ActionTypes): any => { const { getState, dispatch } = store; if (action.type !== LOCATION_CHANGE) { return next(action); } const { router } = getState(); const currentPath = router.location.pathname; const nextPath = action.payload.location.pathname; // 遷移前と遷移後が同一pathなら何もしない if (currentPath === nextPath) { return next(action); } dispatch(pathNameChangeAction()); return next(action); }; export default checkLocationChange;
タイムラインのバグ修正 by sota1235 · Pull Request #285 · isucon/isucon9-qualify · GitHub
同一Routeで異なるpathへの遷移時のState管理がうまくいかない
先程の実装で十分かと思いきや、まだカバーできないパターンがあります。
例えば/items/:item_id
というRouteがある際に、/items/8
から/items/9
へ遷移したパターンです。
このパターンの際に起きるバグとして
/items/8
へアクセス- Page Componentがレンダリング、constructor内でfetch Actionが発火
- ページレンダリング完了
/items/9
へ遷移- ページ遷移のカスタムアクションが発火
- stateがリセットされる
- しかしPage Componentは再レンダリングされないため、fetch Actionが発火しない
これを解決するためにReact ComponentのgetDerivedStateFromProps
メソッドを利用しました。
static getDerivedStateFromProps(nextProps: Props, prevState: State) { const nextLoading = nextProps.loading; const nextPageUserId = Number(nextProps.match.params.user_id); // ページ遷移を確認した場合はデータ取得を行う if (nextPageUserId !== prevState.currentPageUserId) { nextProps.load(nextPageUserId, nextIsMyPage); return { ...prevState, loading: true, currentPageUserId: nextPageUserId, }; } return { ...prevState, loading: nextLoading, currentPageUserId: nextPageUserId, }; }
上記のnextProps.match.params.user_id
には例えば/users/:user_id
というRouteにアクセスした際に:user_id
に入る任意の値が入ります。
これにより同じRoute間の遷移、かつ違うpathでも正常に動作するようにできます。
Material UIのtheme styleがうまく反映されない
Material UIにはBase Colorやfont-size等をglobalで統一して管理したいときに便利なTheme機能があります。
今回も色の調整等でこの機能を利用していたのですが、特定のページの一部コンポーネントにスタイルが適用されないバグがありました。
色々調べていくと同じバグに遭遇してる人は何人か見つけたものの、どの方法を試してもうまく修正できなかったのでバグが再現するコンポーネントにのみnesting themeを同じテーマを使って行うというハックでバグを回避しました。
Special thanks
今回、きちんとSPAを作り込むにあたり主にデータローディング周りの設計に結構悩まされたのですが、私のggり方が悪いのか体系的に解説したいいドキュメントや記事を見つけられませんでした。
自分で車輪を再発明するしかないかと諦めかけましたが、藁にもすがる気持ちで@hiroppyのソースコードを読みにいったら抱えてた疑問や迷いに対する答えが全てありました。
いろいろ調べた挙げ句 @about_hiroppy の書いたコード読みに行ったら一発で解決。ありがとう僕たちの @about_hiroppy
— きりん (@sota1235) August 3, 2019
最後に
ISUCON9予選に参加して競技を楽しんでくれた皆様、メルカリの出題チームのメンバー、そして運営の皆様、本当にありがとうございました!
来年は(きっと)参加者として予選参加して本戦に行きたいと思います。