オフトゥン大好き。

惰眠系プログラマの作業ログで( ˘ω˘ ) スヤァ…

ミクシィChallengeがひとつできるまで - Bug Shooting Challenge #1 を終えて

f:id:nukosuke:20181119182300j:plain

こちらの記事はミクシィグループ Advent Calendar 2018に寄稿しています。
さて、トップバッターのmatsubara0507に続き、2日目を担当します株式会社ミクシィ CRE*1id:nukosukeです。

少し前の話になってしまいますが、11月17日にBug Shooting Challenge (以下、BSC) というイベントを開催いたしました。これはミクシィが毎年数回、エンジニア学生を対象に開催している技術ワークショップ (通称Challengeシリーズ) のひとつです。BSCはサービスの不具合調査をテーマとしたイベントで、今回が記念すべき第1回開催でした。

この記事ではBSCの内容ではなくイベントを開催するまでの経緯や準備に焦点をあてて書きたいと思います。イベント当日の様子を知りたい方は、参加者の学生さんが参加レポートを書いてくださったのでこちらをぜひご覧ください。 (たくさん記事を執筆いただきありがとうございます!)

発端

弊社の人事は、もともとプロダクトの開発に携わっていたエンジニアが所属しているほどエンジニアフレンドリーな文化をもっています。特に新卒エンジニアは採用を通じて人事と仲良くなるためコミュニケーションをとる機会が多く、ChallengeシリーズのルーツともいうべきGit Challengeや、それに続くTDD Challengeもそのような企業風土の中で生まれてきました。

私はGit Challengeの運営に過去2回携わっていましたが、CREチームが'18新卒エンジニアに行った不具合調査研修の好評を受けて人事部からある構想について打診されました。

「この研修を学生向けのイベントとして開催すれば面白いんじゃないかな?」

業界全体にもっとCREを広めたいという想いを以前から抱いていた私にとってこの企画はまたとないチャンスでした。しかし、初めてのイベントをたった1人で形にするのは無理があり、XFLAGスタジオでSRE*2を務める@jtwp470と同スタジオでデータ分析を手がける解析チームのid:sh19910711に協力を仰ぎました。両名はGit Challengeの現役運営メンバーでもあったため半ば強引に引き抜くような形となりましたが、二つ返事でOKしてくれました。さらにプロジェクトマネジメントをCREのグループマネージャーである@otoyo0122に引き受けてもらい、6月中旬にひっそりとBSC運営チームが発足しました。

楽しんでもらうための工夫

研修プログラムを学生向けイベントにしたいといっても、そのまま研修の内容をなぞるだけでは面白みに欠けるのではないかと考えていました。研修というと堅苦しいイメージを持たれてしまう恐れがあったので、ゲーム感覚で参加できるような工夫が必要でした。

また、研修では弊社の人気タイトルのサーバコードに手を加えて教材として使用しましたが、外部の学生さんを招いてのイベントでプロダクトのコードを流用するわけにはいきません。

そこで、茨の道とは知りつつも運営チームで弊社タイトルのクローンを開発することにしました。

この擬似サービスの機能は、

  • ユーザ登録/ログイン
  • ゲーム内通貨の購入
  • ガチャ
  • キャラクター売却

だけのシンプルなものです。

擬似サービスにわざとバグを仕込んでおき、参加者にはあらかじめ用意したログをもとに該当箇所を発見して修正してもらう。

この流れがBSCの大枠として決定しました。

また、イベントの最中はCREになりきって取り組んでもらおうと、ちょっとリアリティのあるシナリオを用意したり、問題文をユーザからの問い合わせメールを模した文面にしたり工夫を凝らしました。

BSCで活きたGit Challengeの運営経験

前述のように、私を含め運営チーム3人にはGit Challengeの運営に携わった経験があったため、ある程度イベントに必要とされるリソースや起こりうる問題を把握していました。BSCではこの経験が活き、必要作業の洗い出しや工数の見積もりをスムーズに行うことができました。

当日のタイムスケジュールはGit Challengeを参考にしており、BSCのために調整を加えていきました。異なるイベント間でも知見が共有され、いわばイベント開催のフレームワークが存在したことが大きな助けとなりました。

本気の環境

学生さんの中には遠方からイベントのために来られる方もいます。貴重な時間を割いてせっかく参加してもらうからには普段触れることのない技術スタックに触れて欲しいと思い、CREチームが実業務で使用しているhadoop + hive環境を使ってログ抽出をしてもらうことにしました。

しかし、そのためにはgrepで検索できるレベルのログでは全く物足りません。そこで、擬似サービスのAPIサーバが完成した後、自動プレイクライアントを作成し、これをEC2上で1週間以上マルチプロセス稼働させることで最終的に12GBのアプリケーションログと8.5GBのリクエストログを生成しました。このクライアントが稼働している傍、私が手動でエンドポイントにリクエストを送り、解答の手がかりになるログを差し込んでいきました。ログ生成の終了予定日に自動プレイクライアントを止め忘れてログファイルがディスクを埋め尽くすという事件が発生したりもしました。

実際のプロダクトではこの比ではないくらい膨大なログがあるのですが、それでもgrepをハングアップさせるには十分なサイズを用意できたため、学生さんにはログ解析における分散処理の必要性を実感してもらえたのではないかと感じています。 (EMRでログ全期間で検索を行うクエリを投げると完了まで1分ほどかかりました)

ログ周りの準備はid:sh19910711がS3バケットに配置したログデータをHadoopファイルシステム(HDFS)用にパーティショニングしたり、そのデータをAWSマネージド分散処理環境であるEMRから参照できるようにしたりして、ログの解析環境を本番さながらに整備してくれました。

当日作業の自動化

当日は打ち合わせや会場の準備などで何かと慌ただしくなることはあらかじめ想定していました。そのため当日行うEMRインスタンスの起動やGitHubリポジトリの作成作業は前日までに自動化しておくことにしました。このあたりの下準備を@jtwp470がTerraformを使って整えてくれたおかげで、当日はプルリクエストをマージするだけで環境を構築することができました。

彼がTerraformと奮闘した記録は後日記事にまとめてくれますので公開されましたらまたこちらで紹介したいと思います。

βテスト: 幻の第0回BSC

一通りの準備が完了すると我々運営チームは完全に油断していましたが、イベント開催日が近づくに連れて「何かやり残した作業はないか」「想定漏れはないか」など誰からとなく不安の声が上がりはじめました。

このような不備を洗い出すためには極力参加者のバックグラウンドに近い人に対してリハーサルを行うことが効果的だと思い、当日チューターとして協力してくれる入社1年目メンバーに対してβテストを行うことにしました。

すると、なんということでしょう・・・

もはや想定通りに進んだことの方が少ないという有様で、具体的には以下のような問題が起こりました。

  • Dockerで用意していたサーバコンテナの起動が失敗する
  • ローカルのHiveコンテナが激重
  • 問題文の説明不足・誤植

これらは不備というより不注意でしかないのですが、フィードバックの中で最も衝撃的だったのは予想以上に問題が難しかったということです。APIサーバやゲームクライアントを設計・開発した当人の「簡単」は全くあてにならず、チュートリアルですら難しすぎるというレベル感でした。

このβテストでは初めてコードに触れる人の立場から意見をもらえたため、その後資料の改善やタイムスケジュールの調整をする上で大いに役立ちました。忙しい中βテストに時間を割いてくれたチューターメンバーには本当に感謝しています。

BSCにかける想い

業務領域の異なる運営メンバー3人がイベントの核となるテーマを模索する中で、共通していたのは「開発者視点だけでなく、ユーザ視点を持ってサービスの運用を考えられるようになってほしい」という想いです。ですから、このイベントを通じて私たちはバグの発見・修正方法だけでなく、その後の対応についても言及しました。

実際に、不具合がおきた時は修正だけで済むケースは少なく、その後に影響範囲を調べ、必要であればユーザに対して補填を行い、原因の説明責任を果たす必要があります。技術イベントだからと単に不具合の修正方法に終始するのではなく「自分だったらどうするだろう」と考えてもらい、マインド面でも何か気づきを得てもらえるように心がけました。

第1回開催を終えて - BSCの今後

まだまだ改善すべきところはたくさんありますが、今回参加してくださった学生さんから楽しかったと言う声が聞けて本当に嬉しい限りです。

BSCは早くも次回開催の話が上がっており、今回見つかった課題や学生さんからのフィードバックをもとにブラッシュアップしていきたいと思います。

Git Challengeの方は第10回を数えるまでの人気イベントとなりました。BSCもこれに続けるよう運営チームともども精進してまいりますので引き続きBug Shooting Challengeをよろしくお願いします。

PR

明日、3日目はcgetcがSpread Sheetを使ったボット開発について何か書いてくれるようです。それでは、引き続きミクシィアドベントカレンダーをお楽しみください!

qiita.com

関連記事

*1:Customer Reliability Engineer: 顧客信頼性エンジニア

*2:Site Reliability Engineer

簡単にできる!Goで書いたCLIツールを配布する方法

どうも、id:nukosukeです。

さっそくですがみなさん、パッとCLIツールを作るとなったらやっぱりGoですか?
Goですよね?

でも、「作ったツールを配布したい」となったときにちょっと困ったことがあります。

  • 開発マシンにGoの環境を作ってる人が少なかった (構築方法説明するのは面倒)
  • Go環境入れてもらったもののバージョンが違ってビルドで死ぬ
  • depやvgoのようなバージョンマネージャを使ってなくて、依存ライブラリがアップグレードしててビルドで死ぬ

とかとか。

前のEmacsの記事でも同じようなことを言いましたが、環境再現性ってやっぱり大事ですよね。この辺りの煩雑さは周辺ツールではなくGo本体のバージョンアップで今後対応が進むようですが、そもそもツールを使うだけの人にビルド環境を用意させるのはどうなんでしょう。バイナリで配布すれば面倒な環境構築も必要ないわけでして。

というわけで、今回の記事ではGoで書いたツールのテストと配布 (CI・CD*1 )環境をサクッと作ります。

要件

  • お金をかけずに誰でもダウンロードできる場所に置いておきたい
  • gitでバージョンタグを打ったら自動でビルドして配布用のアーカイブがアップロードされるようにしたい
  • OSやCPUアーキテクチャごとにクロスコンパイルしたバージョンを用意したい

使用するツール・サービス

GitHub

言わずと知れたGitホスティングサービスですね。今回はツールのバイナリ配布のためにreleaseという機能を使います。releaseでは特定のタグやリビジョンに対してソースコードアーカイブ(.zip, .tar.gz)を自動で作成してくれます。加えて、任意のファイルを追加することができますのでここにバイナリをzipに固めたものをアップロードしていきます。

TravisCI

OSS開発においてお金をかけずにCIを導入したいというときにTravisCIとCircleCIが選択肢に上がるかと思います。(他にもたくさんCIサービスがありますね。おすすめがあればぜひ教えてください)

CI ビルド環境 id:nukosukeの所感
TravisCI VM あんまり複雑なことを考えずサクッと導入したい人向け。TravisCIがサポートしている機能だけで要件を満たせるなら導入がすごくラクGitHub releaseを自動で作る機能など便利機能が満載。
CircleCI コンテナ(v2.0より) テスト環境をカスタマイズしてステップごとに色々やりたい人向け。Dockerイメージを使用してとことんカスタマイズできるため複雑な要件の場合はこちらを選択した方がいい。ステップごとにイメージの切り替えが可能。

今回はTravisCIの機能で要件を満たせるのでこちらを使ってスピーディーにCI環境を作ります。ちなみに今回は触れませんが、WindowsでテストしたいときはAppVeyorが便利です。

gox

Goは GOOS, GOARCH 環境変数を指定することで様々なプラットフォーム向けにビルドすることができます。しかし、いちい環境変数を指定してプラットフォームごとにgo buildを繰り返すのは面倒です。一発で複数のプラットフォーム向けのバイナリを作りたいですよね。

はい、そんなときにgoxが便利です。デフォルトではサポートしているOSとアーキテクチャの全組み合わせでバイナリを出力します。ターゲットを絞りたい場合は、OSとアーキテクチャのリストをオプションに渡すとその分だけビルドすることができます。

使用方法の詳細については後ほど見ていきます。

手順

GitHub: Personal Tokenの発行

releaseページにバイナリをアップロードするのに使います。場所が少し分かりづらいのですが、SettingsDeveloper settingsPersonal access tokens から発行できます。

Generate new tokenで発行できたらTravisCIの環境変数GITHUB_TOKEN という名前で保存しておきましょう。

この際、Display value in build logがオフになっていることを確認しておきましょう。これがオンになっていると、テストのログに$GITHUB_TOKENの中身が表示されてしまうためトークンが漏洩してしまいます。

TravisCI: go testの実行

TravisCIでgo testを走らせる設定です。この記事の本旨とは関係ないですが、パッケージマネージャにdepを、テストカバレッジの計測にCoverallsを使っています。

# 言語を指定します
language: go

# テストを回すGoのバージョンをリストで指定します
go:
  - "1.11.x"
  - master

# 依存ライブラリのインストール処理前に走らせる処理を書きます
#   ここではdepパッケージマネージャとテストカバレッジ計測のためにgoverallsを入れています
before_install:
  - curl https://raw.githubusercontent.com/golang/dep/master/install.sh | sh
  - go get github.com/mattn/goveralls # for profiling coverage

# Gopkg.toml, Gopkg.lockをもとに依存ライブラリをインストールします
#   installを指定しないとデフォルトではgo getが使用されるため、
#   依存ライブラリのアップデートで突然壊れる可能性があります
install:
  - dep ensure

# テストを実行します
#  2つ目はカバレッジ計測のコマンドです
script:
  - go test -v ./...
  - goveralls -service=travis-ci

自動テスト(CI)だけが目的であればこれで完了ですが、今回は配布(CD)もやりたいので続いてその設定をやっていきましょう。

TravisCI: goxでクロスコンパイル

先ほどの.travis.ymlにビルドの設定を追記します。デフォルトではgoxはカレントディレクトリに {{.Dir}}_{{.OS}}_{{.Arch}} (Windowsの場合は.exeがつく) の形式の名前でバイナリを吐き出しますが、これだとLICENSEやREADMEなど同梱したいファイルがある場合に不便です。そこでディレクトリを切ってその中にバイナリを出力するように -output オプションを指定しています。ターゲットのOSやアーキテクチャはスペース区切りの文字列で渡します。 -osarch="linux/amd64" のように一緒に指定することもできます。

詳しくは gox -h で確認してみてください。

before_deploy:
  # goxをインストール
  - go get github.com/mitchellh/gox
  # Linux, macOS, Windowsに対して、
  # 386(32bit), amd64(64bit)CPU向けのバイナリをビルドします
  - gox -os="linux darwin windows" -arch="386 amd64" -output="{{.Dir}}-{{.OS}}-{{.Arch}}/{{.Dir}}"
  # バイナリを出力したディレクトリをそれぞれzipに固めます
  - |
      for os in linux darwin windows; do
        for arch in 386 amd64; do
          cp README.md LICENSE terraform-provider-zendesk-$os-$arch
          zip -r terraform-provider-zendesk-$os-$arch.zip terraform-provider-zendesk-$os-$arch
        done
      done

TravisCI: GitHub releaseページにバイナリをアップロード

さらに、GitHub releaseページにバイナリをアップロードする設定を追記します。

deploy:
  provider: releases
  api_key: $GITHUB_TOKEN
  file_glob: true
  file: terraform-provider-zendesk-*-*.zip
  skip_cleanup: true
  # tagが打たれたときのみGo 1.11でビルドしたバイナリをアップロードします
  on:
    tags: true
    go: "1.11.x"

ではさっそくgit tagでバージョンタグを打ってみます。しばらくして、releaseページに以下のように.zipがアップロードされていたら成功です。

それでは、楽しいGoライフを!

*1:CI = Continuous Integration, CD = Continuous Delivery

Emacsパッケージマネージャ決定版:これからのパッケージ管理はstraight.elで決まり!

ども、久々のブログ更新は最近導入したstraight.elというEmacsのパッケージマネージャについて書きます。

7年ほどEmacsを使い続けてきましたが、まだまだ初心者の域を抜け出せずにいる id:nukosuke です。

前置き

全世界76億人のEmacs使いの皆さんはどうやってelパッケージを管理していますでしょうか。
僕はこれまでpackage.elやCask、el-getなどのパッケージマネージャを使ってきましたが、どれも好きになれませんでした。

というのも、僕がパッケージマネージャに唯一求めるのはマシンを買い変えた時の環境再現性(reproducibility)であって、今まで触れてきたものはこれを担保するためにかなりの労力を費やす必要があったからです。

具体的には、

  • package.el : そもそもパッケージのバージョンをロックする機能がない・・・
  • Cask : python 2系に依存していたためにpython 3に切り替えた途端に動かなくなった・・・
  • el-get : リビジョンハッシュを指定しないと最新版のパッケージがインストールされてしまい、いつのまにかパッケージ同士の互換性がなくなっていた・・・

と、パッケージマネージャごとにいろいろな苦しみがありました。


追記 2018/4/11:
id:taraoさんからコメントでel-get-lockというパッケージを教えていただきました。
el-getではこれを使うことでロックファイルを生成することができるようです。


それでもEmacsを愛していたのでなんとかやってこれましたが、そろそろ限界。
そんなときに会社の同期にSpacemacsを勧められました。(straight.elじゃないのかよ)

こういった大きなディストリビューションにはあんまり興味がなかったので最初は乗り気ではなかったのですが、ちょっとだけ触ってみようと思ってインストールしてみたら、これがわりと良い。

設定要らずで必要な機能はほぼ揃うし、ファイル拡張子から必要なモードを自動的に判断してlayer(設定のまとまり)を勝手に設定ファイルに追記するという変態っぷり。layerはSpacemacsのリポジトリホスティングしているので突然設定が壊れることもありません。最高やないか。

そんなこんなで特に不満を抱くこともなく、気づけば1年弱使っていました。




でも、気づきました。

だんだんEmacsへの愛着が薄れている事に。





これだったらAtomでもVSCodeでもなんでもいいじゃないか!(あえてあいつの名前を出さない勇気)

そもそもEmacsを好きになったのは自分の好みを投影できるからであって、
なんというか・・・こう・・・大衆のイメージに染まっていない地下アイドルを応援するような気持ちです。

と、前置きが長くなりましたがそういうわけでもう一度、秘伝のタレ探求に乗り出したわけです。

straight.el

github.com

このパッケージマネージャを知ったのはかなり最近です。どういう経緯で行き着いたのか全く覚えてないのですが、気づいたらブックマークに入っていました。

Next-generation, purely functional package manager for the Emacs hacker.

なんともキャッチーじゃないですか?

READMEには他のパッケージマネージャとの比較が書いてあるので、ニーズに応じてどれを使うべきか判断する材料にもなります。

そして、原則にはしっかりと 100% reproducible と書いてありますし、説明を読めば読むほどこれが長年探し求めていたものだと思いました。

しかし、残念なことに以前管理に苦しんでいたときにはこのパッケージマネージャはまだ存在しなかったのです。最初のバージョンがリリースされたのは2017年1月。

つまり最先端なのです。

メリット

straight.elを使うメリットはたくさんあります。
このエントリでは、その中から僕がこのパッケージマネージャを気に入った主な理由を2つ挙げます。

use-packageマクロをstraight.elにフォールバックできる

use-packageはパッケージのrequireやら、load-pathの設定やら、キーバインドの設定やら、その他諸々をひとまとめにできる強力なマクロです。 るびきち先生のこちらの記事でも紹介されているように、use-packageを使うと設定ファイルの可読性が一気に上がります。

また、use-package自体にはパッケージをインストールする機能はないのですが、読み込み時にパッケージが存在しなかった場合に別途パッケージマネージャを使ってインストールする処理にフォールバックすることができます。

そして、use-packageのREADMEには「フォールバック先にはデフォルトでpackage.elを使用するが、use-package-ensure-function を上書きすることで別のパッケージマネージャを使うことができる*1」と記述があります。

いわく、現在*2この拡張に対応しているのはstraight.elだけとのことです。

これでuse-packageが無敵になりました。

lockファイルを生成できる

はっきり言って導入の決め手はこれです。Caskやel-getでもバージョンを固定することはできましたが、自分で指定する必要がありました。

でもstraight.elを使えば、もう温もりの手作業でリビジョンハッシュを設定ファイルに書き込む必要はないんです。

M-x straight-freeze-versions を実行すると ~/.emacs.d/straight/versions という名前のlockファイルが生成されるので、これをgitで管理しておくことで、設定を行ったときと同一のバージョンでパッケージ構成をいつでもどこでも再現できるのです。

設定例

ちゃんと仕様を知りたければもちろん公式のREADMEを読んだ方がいいです。
ここでは僕がstraight.elを使い始めるにあたって最初に行った設定を紹介します。

;; 何も考えず公式のREADMEからコピペすればいいコード
;; straight.el自身のインストールと初期設定を行ってくれる
(let ((bootstrap-file (concat user-emacs-directory "straight/repos/straight.el/bootstrap.el"))
      (bootstrap-version 3))
  (unless (file-exists-p bootstrap-file)
    (with-current-buffer
        (url-retrieve-synchronously
         "https://raw.githubusercontent.com/raxod502/straight.el/develop/install.el"
         'silent 'inhibit-cookies)
      (goto-char (point-max))
      (eval-print-last-sexp)))
  (load bootstrap-file nil 'nomessage))

;; use-packageをインストールする
(straight-use-package 'use-package)

;; オプションなしで自動的にuse-packageをstraight.elにフォールバックする
;; 本来は (use-package hoge :straight t) のように書く必要がある
(setq straight-use-package-by-default t)

;; init-loaderをインストール&読み込み
(use-package init-loader)

;; ~/.emacs.d/init/ 以下のファイルを全部読み込む
(init-loader-load "~/.emacs.d/init")

これであとは ~/.emacs.d/init/ いかにそれぞれのパッケージの設定ファイルを置けばいいだけになりました。

例えばcompanyの設定ファイルはこんな感じです。
インストール、読み込み、変数の設定からキーバインドまで一挙に行うことができます。

(use-package company
    :init
    (setq company-selection-wrap-around t)
    :bind
    (:map company-active-map
        ("M-n" . nil)
        ("M-p" . nil)
        ("C-n" . company-select-next)
        ("C-p" . company-select-previous)
        ("C-h" . nil))
    :config
    (global-company-mode))

さいごに

新しいということもあってか、これまでのパッケージマネージャに抱いていた不満がstraight.elでは全て解消されていました。

これを使って設定ファイルを書き直してからまだ一度も壊れていませんし、そもそも100% reproducibleの言葉どおり、毎回同じバージョンが降ってくるので壊れようがありません。プライベート用のPCで作った設定を編集することなく、仕事用PCで全く同じ環境を作ることにも成功しました。

僕のようにいろんなパッケージマネージャを渡り歩いてきた方、 特に環境移行の際に設定ファイルのメンテナンスコストに悩まされているという方はstraight.elを試してみてはいかがでしょうか。

Reactシングルページアプリケーションをサーバサイドレンダリングする

どうも、 @nukosuke です。

こちらはQiita Advent Calendar '17 React #1、3日目の記事です。

概要

この記事ではReactで構築したシングルページアプリケーション(SPA)をサーバサイドレンダリング(SSR)する過程をまとめました。

つかったもの

成果物

まずは出来上がったものから。こちらのリポジトリをcloneして以下のように起動できます。

$ npm run build:server # サーバjsをビルドします
$ npm run build:client # クライアントjsをビルドします
$ npm start            # localhost:3000で起動します

手順

ざっくりとした手順ですが、今回やったことを要約すると以下のようになります。

  1. KoaでReactコンポーネントをサーバサイドレンダリングする
  2. ReactRouterを使ってルーティング(URLに対して描画されるべきコンポーネントの定義)を行う
  3. Webpackでブラウザ側のJavaScriptをバンドルする
  4. バンドルしたJavaScriptをbodyの最後に差し込む

それでは順番に設定を見ていきます。

KoaでReactコンポーネントをサーバサイドレンダリングする

ディレクトリ構成は自由にできますが、僕はいつも以下のような構成にしています。参考程度に。

react-ssr-171203/
|- src/
    |- client.tsx # ブラウザJavaScriptのエントリポイント
    |- server.tsx # Nodeのエントリポイント
    |- components/ # Reactコンポーネントを入れておく場所
        |- Application.tsx # ReactRouterのルーティングを行うコンポーネント
|- build/ # トランスパイル済みのサーバサイドJavaScriptが入る場所。import(require)の都合上階層はsrc/の構造をそのまま移すようにしておく
|- public/javascripts/ # トランスパイル&webpack済みのブラウザJavaScriptが入る場所
|- webpack.config.js

まずはKoaを使ってReactコンポーネントレンダリングするHTTPサーバを立てます。KoaはExpressの後継として開発されているフレームワークです。

SSR用のモジュール react-dom/server から renderToString() APIを使って <Application/> コンポーネントをhtml文字列に変換し、それをbodyの中に埋め込んでいます。 <Application/> はこの時点では特に何も内容のないコンポーネントです。

import * as React from "react";

export default class Application extend React.Component<any, any> {
  render() {
    return (<div>SSRてすと</div>);
  }
}

ReactRouterを使ってルーティングを行う

次に、ReactRouterを使ってApplicationコンポーネントにルーティングを定義していきます。

デモということでちょっとサボりましたが、本来であれば <Route>component 属性に別のファイルからimportしたコンポーネントを渡します。

こうしてルーティングを定義したコンポーネント<StaticRouter> でラップします。Koaのコンテキスト(リクエスト情報など)からリクエストURLを渡すことで renderToString() で描画されるべき子コンポーネントを割り出し、よしなにレンダリングしてくれるようになるのです。 実際にReactRouterでラップしてSSRするサーバコードが次のようになります。

Webpackでブラウザ側のJavaScriptをバンドルする

さて、ここまでがサーバサイドでReactをレンダリングする手順でした。しかし、このままではただmuxとレンダリングエンジンがReactRouterとReactに置き換わっただけですのでここからはブラウザ側のJavaScriptを作っていきます。

まず、Webpackの設定を書きます。

"use strict";
const path = require("path");

module.exports = {
    entry: "./src/client.tsx",
    output: {
        filename: "application.js",
        path: path.join(__dirname, "public/javascripts")
    },
    resolve: {
        extensions: [".ts", ".tsx", ".js", ".jsx"]
    },
    module: {
        rules: [
            {
                test: /\.tsx?$/,
                use: {
                    loader: "ts-loader"
                }
            }
        ]
    }
};

client.tsx をエントリポイントとして、拡張子が ts or tsx のファイルをJavaScriptにトランスパイルし、 /public/javascripts 以下に application.js という名前で配置する設定です。

そして、 client.tsx を書いていきます。<Application/> はすでにさっきの手順で書いたので、今度はこれをブラウザ用の <BrowserRouter> コンポーネントでラップします。 ラップしたものを hydrate() APIに渡せば完了です。この際、SSRしたhtml文字列を囲んでいる <div> をhydrateに渡す必要があるので、idを設定しておいて document.getElementById() で取得します。

<!-- server.tsx -->
<div id="react-root">${markup}</div>

// client.tsx
document.getElementById("react-root")

ブラウザ側のコードは最終的にこんな風になります。

import * as React    from "react";
import * as ReactDOM from "react-dom";
import { BrowserRouter } from "react-router-dom";
import Application from "./components/Application";

ReactDOM.hydrate(
  <BrowserRouter>
    <Application/>
  </BrowserRouter>,
  document.getElementById("react-root") // コンポーネントを吊るす対象
);

バンドルしたJavaScriptをbodyの最後に差し込む

最後に、SSR後にwebpackしたJavaScriptを読み込むようにします。これで一番最初のSSR以降、レンダリングは全てブラウザ側に丸投げできるようになります。

koa-static というKoaのプラグインを使用してWebpackでバンドルしたJavaScriptをブラウザに配ります。Webpackの出力先である /public を静的ファイル置き場としてマウントし、<body> の最後に <script> を差し込みました。

最終的にサーバ側は次のようなコードになります。

動作確認

npm start でサーバを起動してブラウザから localhost:3000 にアクセスしてみます。

ブラウザ側ではサーバで構築されたDOMと hydrate() APIによってブラウザレンダリングされるDOMとの差分をチェックし、差分がある場合のみコンポーネントの再描画を行います。通常、SSRされたDOMはブラウザJavaScriptの読み込みが終わった時点では差分がないため再描画は発生しません。

一番最初に表示されるページはサーバサイドでレンダリングされたもので、リンクにより遷移したページはReactRouterによってサーバへのアクセスなしで描画されたものです。

今回やりたかったけどできなかったこと

  • Reduxの導入 (redux, react-redux, react-router-reduxとかめちゃくちゃパッケージが増えて正直しんどい)
  • テスト周り (Jestというテストライブラリが流行っている(?)らしいので時間があれば試してみたい)

react_on_railsでreduxを使う

前回の続きでreduxを導入しました。

必要なnpmを追加

redux-thunkは非同期アクションを実現するパッケージなのであまり関係ありませんが、後々入れることになると思うのでついでに入れておきます。

yarn add redux react-redux react-router-redux redux-thunk

コードにstoreを追加する

サーバサイドのルーター

ブラウザのルーター

コントローラにもstoreを追加する

  # application_controller.rb
  def render_for_react(props: {}, status: 200)
    if request.format.json?
      response.headers["Cache-Control"] = "no-cache, no-store"
      response.headers["Expires"] = "Fri, 01 Jan 1990 00:00:00 GMT"
      response.headers["Pragma"] = "no-cache"
      render(
        json: common_props.merge(props),
        status: status,
      )
    else
      redux_store(
        "store",
        props: common_props.merge(props).as_json,
      )

      render(
        html: view_context.react_component(
          "Router",
          prerender: true,
        ),
        layout: true,
        status: status,
      )
    end
  end

テンプレートに<%= redux_store_hydration_data %>を追加。

reducerを追加

propsとrailsContextをinitialStateにぶち込み、react-router-reduxの提供しているルーティングに関するreducerと後で作るその他のreducersを全部放り込んでcombineReducersでまとめます。

  const initialState = {
    ...props,
    railsContext,
  };

  const reducer = combineReducers({
    ...reducers,
    routing: routerReducer,
  });

この時点でrailsContextReducerがないとエラーが発生するので、こいつを作っていきます。とはいっても最初のロード以外でこいつが処理を行うことは基本的にありませんのでstateをそのまま返すだけのものです。

export default function railsContextReducer(state = {}) {
  return state;
}

propsを出力すると以下のようにroutingrailsContextが含まれていることがわかります(reduxのconnectstateをそのままpropsマッピングした場合の出力)。

https://gyazo.com/b3f404fd17c025ff780762a3c04a0e97

関連記事

nukosuke.hatenablog.jp