ぱろっと・すたじお

技術メモなどをまったりと / my site : http://parrot-studio.com/

React+jQuery+RailsのSPAをサーバサイドレンダリングに移行した件(その3:設計変更編)

2回に渡って書いてきたSSR化のお話も、今回でラストです(`・ω・´)


<前回>
parrot.hatenadiary.jp

parrot.hatenadiary.jp

<サイト>
ccpts.parrot-studio.com

<修正したコード>
github.com


正直、前回の話で「SSRの設計における一番の肝」は書いているのですが、
今日の件もそれはそれで重要なポイントではあるので、
頑張って書いていきます...φ(・ω・`)

前回の話が「SSRに関するわりと一般的な話」だったのに比べ、
今回の話は「ccptsの仕様やアーキテクチャに依存する話」が多いので、
一つの参考事例として捉えていただければ

もう一度段取りを復習しておくと、こんな感じでした

  1. ブラウザ系オブジェクトの排除(とりあえず実行時エラーを消す)
  2. 「仕様上の正しい動作」になるように修正
  3. インフラの調整

今回は残りのSTEP2とSTEP3のお話です

STEP2:SSRを前提にした設計の変更

STEP1の時点で「とりあえずの表示」はできているのですが、
当然、アプリの仕様として問題のある箇所が山ほどあります

仕様に依存する部分だったり、以前の手抜きの修正だったりするので、
あまり一般的ではないかもしれませんが、一つずつやったことを見ていきます

(1) Cookie周りの設計変更

前回は「parseした塊を丸ごと渡す」みたいな雑な実装をしましたが、
もちろんそれではダメ・・・ってことはないですが、
「最初の描画を全てSSR化する」という文脈ではよろしくありません

Cookieが管理しているデータをparseし、
それに対応するデータを取得するところまでサーバでやる必要があるからです


<今まで>

  1. クライアントの初期処理でCookieをparse
  2. 必要なデータをサーバAPIに問い合わせ
  3. 問い合わせのあったクエリに対するデータを取得
  4. APIの応答としてリストを返す
  5. クライアントの操作によりCookieを更新

<新しいやり方>

  1. サーバ側でCookieをparse
  2. Cookieに含まれる情報に対応するデータを取得
  3. Reactの初期値としてリストを渡す
  4. クライアントの操作によりCookieを更新


後から操作によって動的に変わる部分は今まで通りのやり方なのですが、
最初にレンダリングする情報だけ、サーバのデータ処理を先行した形です

ただまあ、ここに関しては、本質的に大きな設計変更で
「クライアントだけが知っていれば良かった情報を、サーバも知る必要がある」、
言いかえれば、「サーバ・クライアント間の線引きが崩れる」って話なんですよね・・・

そもそも、「サーバはAPIを提供し、クライアントで主要なロジックを組む」という設計で、
Cookieが管理する情報はまさに「クライアントだけが知っている情報」だったのです*1
(例:「お気に入り」「PT構成保存」等)

本当はうまく疎な関係にできればいいと思うし、
やり方はあると思うのですが、
今回の目的である「完全SSR化」を優先しました

(2) イベントの見直し(componentDidMountの排除)

jQuery.ready()」の時代からコードを積み足してきたのもあって、
componentの初期処理がcomponentDidMountに書かれていたケースが多数ありました
(例:componentDidMountの中で初期データをBacon.Busに流す)

これはcomponent同士を疎にするためには都合が良かったのですが、
サーバサイドではcomponentDidMountを初めとした「イベント」は動作しません
つまり、このままでは初期化処理が走りません(´-ω-)

要は、「全てをSSR化する」ためには、
「最初に表示したいデータ」は全てcomponentのpropsに渡すしかないわけです
(個人的には、ベストではないけど、目的のためにはベターくらいの感じで)

  constructor(props: AppViewProps) {
    super(props)

    // ...

    // constructorでサーバから受け取ったqueryStringをmodelに変換
    // queryStringもブラウザの概念なので、サーバから明示的にクライアントに渡す必要あり
    this.query = Query.parse(this.props.queryString)

    // ...
  }

  private renderConditionView(): JSX.Element | null {
    if (!this.state.showConditionArea) {
      return null
    }

    return (
      // クエリmodelをフォームに渡して描画
      // 以前だと Bacon.Bus を経由してデータを送っていたので、propsで渡す必要がなかった
      <ConditionView
        originTitle={this.props.originTitle}
        query={this.query}
        switchMainMode={this.switchMainMode.bind(this)}
      />
    )
  }

まあ、初期表示以降は今まで通り、
Bacon.Bus経由でストリームとしてデータを流しているので、
純粋すぎた部分と現実的な部分で落としどころを見つけられたのかな・・・としておきましょう

(3) 状態管理の手抜きを改善

このあたりまでくると、SSRに関係ない話なのですが・・・

jQuery.ready()」の時代からコードを積み足してきたのもあって、
あらかじめ全ての構造を描画しておいて、
初期処理や何らかのイベントでhide/showする・・・という設計がかなり残ってました

後からの制御はともかく、
初期処理(=componentDidMount)で表示を管理するとSSRでおかしくなるので、
厳密にprops/stateに依存する管理に全て移行させました

これはもう、React化した時のやり残しというか手抜きのフォローに近いのですが、
雑とはいえ問題なく動いていたものであっても、
SSR化する際には厳密に書かないとダメってことです(´-ω-)

結果的に、componentDidMount等に残ったコードは、
(前回の)Browserに依存するコードだけになりました(見た目とか、イベントハンドラのセットとか)
つまり、ブラウザでしか動作しなくても問題ない・・・ということです

export default abstract class ArcanaRenderer<T> extends React.Component<T> {

  protected div: HTMLDivElement | null = null

  public componentDidMount(): void {
    if (this.div) {
      Browser.hide(this.div)
      Browser.fadeIn(this.div)
    }
  }

  public componentWillUpdate(): void {
    // 更新される時はいったん消す
    if (this.div) {
      Browser.hide(this.div)
    }
  }

  public componentDidUpdate(): void {
    // 再マウントされる時にフェードイン
    if (this.div) {
      Browser.fadeIn(this.div)
    }
  }

// ...

}

一応フォローしておくと、STEP1の時の判断と同じで、
以前は「今回の目的はとにかくReactで動作するようにすることで、
細かい設計の粗は気にしない」というポリシーだったのです

「厳密にやる」ってのは相応のコストがかかる話なので、
「目的に合わせて現実的なコストに落とし込む」ってのも、
「運用」していくという観点では必要なのかなと

(4) 完全なレスポンシブ化

これもある意味手抜きを直しただけなのですが・・・

今まで、初期処理としてwindowサイズを計算し、
一定ラインを超えたら「携帯モード」で表示する、ってのをやってました
windowsサイズを元にフラグで管理)

当然、これはwindowオブジェクトに触れないSSRでは通用しないのですが、
とりあえずPC/タブレット用に描画して、
ブラウザ側の初期処理で修正すれば・・・と思ったら、うまくいかずΣ(゚Д゚)ガーン

SSRで吐き出したDOMと、後から読み込んだReact(on Rails)が処理するDOMで、
差分があるとwarningが出るのですが、携帯サイズだと大量のwarningが出るし、
そもそも挙動もおかしくなるのです

結局、正しい形でBootStrapのレスポンシブ機能を適用しました

  private renderMember(): JSX.Element {
    const m = this.props.member

    // 両方出力して、bootstrapのhiddenクラスに制御を任せる
    if (!m) {
      return (
        <div>
          <div className="none hidden-sm hidden-md hidden-lg summary-size arcana" />
          <div className="none hidden-xs full-size arcana" />
        </div>
      )
    }

  // ...
  }

もっと力技のところもありますが、基本的な方針はこうなってます

継ぎ足されたコードの弊害といってみればそれまでですが、
「ブラウザという環境」を、いかに暗黙的に、無意識に、
前提に置いていたか・・・ということでもあります(´-ω-)

STEP3:Webサーバのチューニング

ここまででコードレベルではだいたいリリース水準に達したので、
問題ないことを確認するため、staging環境にデプロイしたのですが、
やはりいろいろ問題が出ましたΣ(゚Д゚)ガーン

(1) Nginxのキャッシュ

一回目の表示は問題ないのに、二回目以降にエラーが出る問題が発生し、
エラーメッセージでググったところ、こんな話が

osa.hatenablog.com

SSRで巨大なHTMLをproxy先が吐き出したため、
キャッシュが使われるようになったが、
パーミッションに問題があったということです

つまり、staging環境で今までディスクキャッシュは使ってなかったのに、
SSR化してHTMLが大きくなったら、キャッシュに逃がす必要が出た、ということです

本番のパーミッションは特に問題なかったとはいえ、
そもそもストレージへの書き込みが発生する時点でまずいので、
キャッシュを大きめに設定しなおしました

(2) CPUの負荷(未解決・先送り)

前項の問題がもう出ないことを確認するのと、
ちょうど業務でパフォーマンス的な問題が出たりってのがあったので、
ついでにstaging環境でベンチマークをとってみました

すると、RubyのプロセスのCPU使用量がすごい勢いで上がっていき、
何度か実行していると、レスポンスが大幅に遅延したり、
詰まってエラーになってしまうことも(lll゚Д゚)

かなり無茶な叩き方をしたことは事実ですが、
思ったより「Rubyプロセスの中でexecjsの処理を実行する」のが重いようです

こればっかりは仕組みの問題であり、
そもそも最初からRubyでviewを作れば問題ないわけですが、
今回はSSR化自体が目的で、しかもサイトの訪問は皆無なので、気にしないことに

ある程度アクセスが多いようならば、
viewレベルのキャッシュを入れていく必要があると思いますが、
最終的にexecjsにHTML出力処理を投げる関係で、Railsのキャッシュが生かしづらい構造です

まさに「作って運用しようとしたからわかる問題」であり、
それがわかっただけでも収穫なのですが、
やっぱりSSRはいろいろ難しいですね・・・(´-ω-)

まあ、現実にはそこまで大きなアクセスがないサイトですし、
今回は特に何もしませんでした

(3) 通信量が大きすぎる

問題が解決した(あるいは先送りした)ので、今度は本番にデプロイして、
Googleのモバイルスピードテストサイトで確認したところ、
めっちゃ遅いという判定にΣ(゚Д゚)ガーン

developers.google.com

SSRで速くしたはずなのに、なんでや・・・と思ったら、
どうも通信量が大きすぎるという指摘でした

単純にNginxがgzip圧縮することで解決しましたが、
もっと早くやっておけって話ではありますよ(´・ω・)(・ω・`)ネー

あとはまだ、packs/ccpts.jsがminifyされてないって問題があるのですが、
いまいちうまくいかないので、いったん保留しております

まとめ

ということで、長々と書いてきましたが、最後にまとめです...φ(・ω・`)

  • SSRの大前提を忘れてはいけない
    • SPAがあってのSSRである
    • 最初からサーバサイドでviewを作る設計との比較が必要
  • 暗黙的に「ブラウザ」に依存している部分の排除が大変
    • JavaScriptで書かれたコードではあるが、「サーバサイドで動かす」のを意識しないとダメ
    • ccpts程度の規模でもこれだけ大変なので、業務レベルのアプリではかなり面倒
  • (個人的に)「うまい設計」がまだ見えない
    • 全部サーバサイドはなんか違和感があるので、うまくクライアントと切り離したい

今までがそうであったように、今回の修正もこれが完成ってわけではないので、
また何か新しい技術を使って整理できるといいかなと思います

*1: とはいえ、Cookieが扱うのは「データ」であって「ロジック」ではないので、制御のコードはまだクライアントにあり、その意味では設計が維持されています