はらへり日記

腹に弾丸

ISUCON9予選でフロントエンド周りの実装を担当した話

ISUCON9に参加した方、本戦までお疲れさまでした!

今年のISUCON9では運営として予選の作問に携わり、フロントエンド周りを担当しました。

その際に得た知見や実装なりの話を雑に書こうと思います。

話で出てくるソースコードは全てここにあります。

github.com

Excuse

  • 個人としての雑記です
  • 本業フロントエンドエンジニアではないので詰めが甘い部分がかなり多いです
    • 本業フロントエンドエンジニアからのマサカリ待ってます
  • ISUCON運営として作るフロントエンド、という視点で書いていきます
    • 願わくば未来のISUCONフロントエンド担当者に届け

あとは講評や解説は公式Blogで出してるのでそのあたりをぜひ読んでください!

isucon.net

あとはBackendやベンチマーカーの実装に関しては@catatsuyさんの記事とGitHubのコードを合わせて読むことをオススメします。

medium.com

私とISUCON

私自身のISUCONとの関わりはこんな感じです。

  • ISUCON5から毎年参加している
  • 毎年予選落ちで地団駄を踏む
  • 去年は覚悟を持って望んだものの予選落ち、ビール禁止を決意する

といった感じでした。

今年ももちろん再挑戦、悲願の予選突破を目指そうと思っていたのですが自分の会社に作問が回ってきたのでこんなチャンスなかなか無いだろうと思い運営として参加しました。

技術スタック

アプリケーション自体、どれくらいフロントエンドを作り込むかの点に関しては後述する理由でSingle Page Application(SPA)を採用しました。

私自身、以前にSPAを作ってからかなり年月が経ってしまっていたのと、今回は多少小回りが効かないとしても動くことが最優先だったのでcreate-react-appを利用しました。

丁寧にテストを書く余裕はないだろうと想像できていたのでなるべくエンバグしないようTypeScriptも導入しています。

create-react-app.dev

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で議論しました。興味があれば覗いてみてください。

github.com

壊れたときに原因がわかりやすい

参加したことがある方はよくわかると思いますが、ISUCONでは初見のアプリケーションを壊さずにドラスティックに書き換えていく必要があります。

その過程で起こる問題として意図しないエンバグやその原因調査です。

フロントエンドではこのあたりにすぐに気付けるようにし、原因調査のヒントとなるようにすべきです。

それにあたり以下のような方針で実装しました。

現実のアプリケーションでは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;

github.com

タイムラインのバグ修正 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でも正常に動作するようにできます。

github.com

Material UIのtheme styleがうまく反映されない

Material UIにはBase Colorやfont-size等をglobalで統一して管理したいときに便利なTheme機能があります。

v4-3-3.material-ui.com

今回も色の調整等でこの機能を利用していたのですが、特定のページの一部コンポーネントにスタイルが適用されないバグがありました。

github.com

色々調べていくと同じバグに遭遇してる人は何人か見つけたものの、どの方法を試してもうまく修正できなかったのでバグが再現するコンポーネントにのみnesting themeを同じテーマを使って行うというハックでバグを回避しました。

github.com

f:id:sota1235:20191005121120p:plain
勝利宣言

Special thanks

今回、きちんとSPAを作り込むにあたり主にデータローディング周りの設計に結構悩まされたのですが、私のggり方が悪いのか体系的に解説したいいドキュメントや記事を見つけられませんでした。

自分で車輪を再発明するしかないかと諦めかけましたが、藁にもすがる気持ちで@hiroppyソースコードを読みにいったら抱えてた疑問や迷いに対する答えが全てありました。

github.com

最後に

ISUCON9予選に参加して競技を楽しんでくれた皆様、メルカリの出題チームのメンバー、そして運営の皆様、本当にありがとうございました!

来年は(きっと)参加者として予選参加して本戦に行きたいと思います。

f:id:sota1235:20191006002923j:plain

ISUCON8に出て予選に落ちた #isucon

ISUCON8に出場して予選で散りました。

isucon.net

@kazeburoさんと@masartzさんとそたぶろるっつという名前のチームで一緒に出場しました。ありがとうございました!

結果

後半、少し追い上げるも予選突破ラインに遠く及ばず…。

fail地獄がつらかった。

やったこと

私は主にアプリケーションを見た。つらつらと。言語はNode.jsを選択しました。

  • 環境整備
    • lintツール導入(TypeScriptだったのでtslint入れた)
    • ts-nodeでのアプリ起動をやめてcompile後のjsを実行する
    • Node.jsのバージョンをv10.10.0へ変更
  • サーバ構成を変更
    • リクエストを受けるサーバにはh2oとMySQL、h2oはリクエストを残り2台にbalancingする
    • アプリサーバではpm2使ってCPUコア数分、Node.jsのクラスタを立てる
  • 無駄にsheetsテーブルを見てるところをハードコード
    • 1000件程度のデータだったがranknumカラムがわかればsheet_idがわかるし、ranksheet_idがわかればnumがわかるデータ構造になってたのでそこを参照したりJOINしてるところをつぶした
    • reportのところのJOINをつぶすのだけ時間無くてできなかった
      • SQL呼び出しが減るわけでなかったので後回しにしてしまった
  • getEventsでアプリでpublic_flgのfilterをかけてるものをflag見てSQLを変えるよう分岐
  • いくつかのgetEvent呼び出し部分を単一のSQLに変更
    • getEvent内はいろいろやっているが読んでみるとシンプルなSELECT文で置き換えられる場所がいくつかあった
  • ORDER BY RAND()をやめる
    • 予約されてない席でなく、予約してる席を全件取得するようにした(最大でも1000record)
      • SELECT sheet_id FROM reservations WHERE event_id = ? AND canceled_at IS NULL
      • これだと全ランク混ざるがrankがわかればsheet_idのrangeがわかるのでその範囲だけでループを回す
    • 予約してる席が分かれば予約されてない席がわかるのでそこからrandomで引っ張って処理した
      • 最初、randomじゃなくても行けないかなと思って試したけどだめだった
    • あとから「固定シーケンシャル番号を作って擬似ランダムにする」というアイディアが出て、頭を壁に打ち付けた

その他、チームメンバーには

  • INDEXの追加
  • getEventsのN+1撲滅
  • 残り座席数のtable化
  • SQL調整
  • その他環境整備やアプリレイヤー以外のチューニング

をひたすらやってもらってた

反省

  • 去年に引き続き凡ミスが多かった
    • ローカルでアプリ環境を作るという選択をしなかったので本番デバッグすることが多く、効率が悪かった
    • 他チームが「30分で作れなかったらやらない」という基準で取り組んで、作れたと言ってて時間で区切るのは賢いと思った
    • TypeScript実装だったので実装が堅牢だったかというと必要最低限の型付けしかなかったので思い切って最初20分くらい、コールドリーディング&型付けに投資もありかとおもった
      • 特にSQLの返り値周りはなんの値がどう加工されてるのか読み解くのに苦労した
    • やろうと思ってやらなかったがInspector立ち上げてデバッグするのもありだった
      • pm2立ち上げた後から設定入れるのが面倒でやらなかったがやるべきだった。判断ミス
  • 後半、ほとんど何もできなかった
    • 予約/キャンセルエンドポイントとずっとにらめっこしてたがどうすれば改善できるのかわからなかった
    • わからないならわからないで割り切って、きな臭いSQLを丁寧にチューニングしていくとかできることはあったはず
    • 正直、failの絶望感に打ちひしがれてる感が強かった(本戦出場チームもこのあたりは同じだったらしい)
  • pm2の使い方はもっと調べておくべきだった
    • inspector付きで立ち上げるとかloggingとか
    • このあたりは仕事で触る機会もある可能性あるしISUCON問題の復習で丁寧に触っておく

まとめ

仮にもハイトラフィックなアプリのBackendを1年半やっての敗北なので強い気持ちで1年間修行します。

espower-typescriptでローカルの.d.tsファイルを読み込ませる方法

最近、仕事でTypeScriptに入門中ひよっこです。

ユニットテストをmocha + power-assert + TypeScriptで書く際、espower-typescriptを導入したのだけど、その際にハマったのでメモ。

前提

  • TypeScript 3.0.1
  • power-assert 1.6.0
  • espower-typescript 9.0.0

起きたこと

mochaでテストを実行するとローカルの型定義ファイル(.d.ts)を読み込まず、エラーになった。

エラーメッセージ

https://i.gyazo.com/9671d41744f80e39fddf8dbdcfd1a1fe.png

原因

いろいろ調べるとespower-typescriptが依存しているts-nodeが原因っぽい。

このts-nodeのversionsが7.0.0からデフォルトの挙動でローカルのtsconfig.jsonの読み込みをスキップするようになった。

github.com

github.com

これを有効にするにはCLI実行時であれば--filesオプションを渡すだけなのだが、espower-typescriptにラップされてる上にコードを読んだ感じoptionで渡す方法も無いのでPRを出してoptionを渡せる or --filesオプションが有効になるよう書き換えるしかない。

解決策

と、面倒だなぁと悶々としていたら環境変数を指定することでオプション指定ができることに気づいた。

github.com

--files Load files from tsconfig.json on startup (TS_NODE_FILES, default: false)

なので以下のようにコマンド実行すればespower-typescript内部のts-nodeの--filesオプションが有効になった状態でテストを実行できる。

TS_NODE_FILES=true mocha -r espower-typescript/guess src/**/*.test.ts

後半のコマンドはよしなに、大事なのはTS_NODE_FILES=trueをつけることです。

そもそもTypeScriptでユニットテスト書くときのデファクトわからないしハマること多いけど引き続き頑張ります。

腰椎椎間板ヘルニアで手術のために入院したので感想を述べる

但し書き

  • ※この記事を自身の健康状態を判断するための材料として捉えないでください
    • 「あなたはこうだったのに私はこうだった!」とか「実際、全然違った」と言われても責任取れません
    • 私はド素人です。少しでも不安な方は即病院へ行ってプロのお医者さんに相談してください
  • じゃあ何のために残すかと言うと「ヘルニアで手術って何」とか「手術で入院って何」って不安になってる人がもしいたとき、その人にとっての一例として気休めに使ってくれ、程度の気持ちで書いてます
  • 病院紹介してほしい、とかなるならそれはそれで声かけてください

タイムライン

  • 2017年9月
    • もともと腰が悪い(ひどいときは靴下履く姿勢がしんどい)状態で重いものを持つ
    • 翌日、目覚めたときに「あ、あかん」という腰の痛みを覚える
  • 2017年10月~
    • 腰は痛くないのだが右足だけ痺れだす
    • ひどいときは5分歩くだけで痛くなって休むこともあった
    • 右足を引きずって歩いてるような状態になる
  • 2018年3月(※みんなは症状出たら即病院行こうな!!!)
    • 地元の整形外科病院に行く
    • とりあえず様子見しようとなる
      • 痛み止めの処方とコルセットの注文
    • MRIの予約取る
  • 同月
    • MRIにより腰椎椎間板ヘルニアと診断
    • ひとまずできることはないとのことで引き続き保存療法(痛み止め + コルセット)
  • 2018年6月
    • 一向に症状がよくならない、同じ病院に行っても保存療法以外案内されないので別の大きめの病院へ行く
    • もう一度MRIを撮る
    • 同じく腰椎椎間板ヘルニアの診断、保存療法で引き続き頑張るか手術ですぐに治すかの二択を案内される
    • 手術の強制はされなかった
      • 手術が必須になるのは生理機能に障害が出たり生活できないほど重度のヘルニアのとき
      • 私の場合、足に影響は出つつも一通りの日常生活は送れていた
  • とはいえこれがいつ改善されるかもわからない状態は耐えられなかったので手術を決意
  • 2018年8月
    • 手術と入院
  • 退院←イマココ

意思決定のポイント

お医者さんと話してていくつか大事な要素があった。

ヘルニアは原則、自然治癒する

ヘルニアになっても保存療法を行えば半年で7, 8割は自然治癒するらしい。

また、手術も基本的には発症してから1ヶ月間は様子を見ましょう、というのが業界のガイドライン(要出典)で定められており基本は保存療法から入る。

あと具体的に言われたのは手術をしてもしなくても10年後の健康状態は変わらない、つまり手術をしても早く治る可能性があるというだけで根本的に一生治るというわけではない。

様々なリスクがある

まずはそもそも治らないかもしれない、というリスク。

私のように足に痛みが出たのは「結果」であって、この結果に対して直接的に治療をするわけではない。

この結果の原因であろうヘルニアを取る手術をするので、ヘルニアが無くなると同時に痛みが100%無くなるとは言い切れない。

特に私の例だと、そもそもヘルニアが2つあってそのうちどちらが足に来てるかを特定できない(特定する方法はあるのだが、神経に針を刺すというものなので断念した)ので様々な検査で特にひどいと思われる方の対処をした。つまり今回処置したほうが原因でないなら症状は改善しないことになる、ということ。

どうせ切るのだから両方取ればいいじゃないかという話もあるが痛みが出てないヘルニアを下手にいじって別の症状が出るリスクの方が大きい、という説明をされたので素直に医師を信頼し症状がひどい方のみ、手を入れることにした。

※余談だが、病院内の記事にヘルニアには痛みがたまたま出てないだけでヘルニアになってるパターンとそうでないパターンが有るらしい。MRI取ったら実はヘルニアだった、なんてことは誰でもありえる

次に再発のリスク。

術後にもし症状が改善されてもまたヘルニアになって全く同じ症状に陥る、もしくは違う症状が出る可能性もあるしその可能性は生きてる限りついて回る。

なぜ手術に踏み切ったか

1つ目は1年間、保存療法で様子を見ても症状が全く改善されなかったこと。

2つ目にこの痛みにあと何年耐えればいいのかわからない、という状況が嫌でリスクを取ってでも可能性に治る可能性にかけたいと思ったこと。

基本的な日常生活を送ることはできていたがずっとコルセットを巻きっぱなしは不便だし、何時間でも散歩したいし、ハチャメチャに痛いわけではないのに右足を引きずって歩いてることを時たま指摘されたりしてて、ただでさえ短い貴重な人生をこの状態で過ごすのは素直に嫌だった。

実際の入院生活

流れはこんな感じ

  • 手術前日
    • 朝から入院
    • 点滴刺したり体調管理されたり
  • 手術日
    • この日は絶対安静
  • 手術翌日
    • 歩行許可が出る
    • 点滴やら血抜きの管やらいろいろ体から生えてる
  • 手術翌々日~退院日
    • リハビリする
    • 管が徐々に抜ける
    • 血液検査とかCTとかして異常が無いか経過観察

総じて、特に不自由はなかった、と思う。(入院したことないので普通はもっと苦しい、とかもっと楽、とか言えない)

体を動かせるようになってからは腰に負担をかけない起き方とか姿勢を教わって、ずっと同じ姿勢だと負担がかかるので1時間ごとに寝っ転がったり座ったり歩いたりして過ごしてた。

看護師さんも言ってたけどメインイベントは手術なのでそれ以外の時間は本当にすることがない。のでひたすら本を読んでた。

おかげで技術書2冊と小説2冊消化してしまった。あとの時間はコード書いたり寝てたりしてた。

手術前の時間はさすがに手術失敗とか植物人間とか不要な妄想が頭を駆け巡りそうになったのではたらく細胞を見て「頼んだお前ら」と祈ってた。

入院した結果

何回でもexcuseしておくと私の場合はこうだった、というだけで他の人が全く同じ経路をたどって同じ結果になるかはその人の症状次第だしそのあたりは本当、プロのお医者さんに相談してください。

私の場合、手術後にもともとあった右足の痛み、痺れは無くなりました。

ただ、まだ完全になくなったと言い切るのは早くて

  • 1年間のヘルニアを経てそもそも右足の筋力がかなり落ちてることにリハビリで気づいた
  • なので歩き方はまだ安定しないし、ストレッチとかしたときの筋肉のハリが左右で違う
  • このハリが筋力が原因なのか、痺れが残ってるからなのか感覚的に判断できない

という感じです。

ただ、術前よりも確実によくなってるので今後はまず再発防止のために当分は徹底的に腰に負担をかけない生活をおくることとストレッチや筋トレによるリハビリをする生活になりそうです。

総じて感想

  • 健康は大事
    • 運良く症状改善したけどそもそもならないのがベスト
  • 生きてるって素晴らしい
  • 塩分って大事
    • 入院中の健康という単語をそのまま定食にしたような食事は塩の大事さを感じさせる味わいだった
    • でもそれも3日で慣れてしまったので人間すごい
  • まだヘルニアじゃない人、特にエンジニアには腰まわりを気をつけて生活することをおすすめしたい
    • 筋トレと姿勢改善で予防できる
    • なってからじゃ遅いし人生棒に振る
  • 手術は正直、二度としたくない
    • めっちゃ痛かった、とかでなく麻酔から覚めた後のうつらうつらした感じとか管がつながってるディストピア感とか、体験するのは1回でいいかなと思う
    • まだ20代だからいいけどこれが30代、40代になると指数関数的にキツそう

今後