ぱろっと・すたじお

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

async/awaitを投入したら便利だった件(あるいはAWS Lambdaで画像変換しようとしたら罠にはまった件)

今回は完全にメモ書きだし、たいしたことは書いてないのですが、
私自身がこれを書き残さないと後でまたはまりそうなので、
特に読まなくてもOKですΣ(・ω・ノ)ノ

API Gateway + Lambdaの画像変換ではまった話

本当に今さらなんですが、社内インフラで画像変換させるのが重すぎて、
他のサービスにも影響しそうだし、それ用のサーバ立てるのもめんどいってことで、
AWSに流しちゃえって話になったわけです

今だと「Lambda@Edge」の話ばかり出てくるのですが、
これは「CDNからLambdaをキック(して画像を変換する)」って仕組みなので、
社内と連携するなら「API Gateway+Lambda」という旧来の構成がいいかなと

構築したのは、API GatewayAPIとしてパラメータを受け取り、
Lambdaにeventとしてパラメータを流し、レスポンスをAPIとして返し、
エラーだったら500エラーで返す・・・みたいな標準的なやつです

これ自体はググれば山ほどやり方が出てくるので、
今回はどうでもいいのですが、
問題はLambda環境のimagemagickです

サンプルコードからしてJSというかES2016(=ES7)なので、
当然のようにES2016でコードを書いていて、手元の環境で動くことも確認したのに、
なぜかLambdaにデプロイすると動かないという・・・Σ(・ω・ノ)ノ

変換の手前までは動くことはログで確認できたので、
どうもimagemagick自体が動かないんじゃないか・・・と思ったら、
デフォルトのNode.js10系環境にはimagemagickが入ってなかったようで*1

LambdaのNode.jsを8.xにしたらあっさり動いたのですが、
これに気づくまで半日かかったのです
今さらこの仕組みを構築するって方は気をつけていただきたいщ(゚Д゚щ)


なお、API+Lambdaの環境だと、APIタイムアウトが29秒なので、
Lambdaの処理が29秒を超えると、応答がタイムアウトしてしまいます

現状だとAPI Gateway側のタイムアウトをこれ以上伸ばせないので、
結局、S3にアップロードされたことをトリガーにする仕組みも書いたのですが、
こちらはLambda側にテンプレートが用意されているのです

で、その環境のNode.jsが8.x指定なんですよね・・・
検証手順が逆ならば時間を無駄にしなかったのに・・・(´-ω-)

async/awaitが便利って話

ある意味ここまで前置きですΣ(・ω・ノ)ノ

ES6自体は以前からccptsで使っているわけですが・・・

github.com

ccpts.parrot-studio.com

・・・ES2016(かつてES7と呼ばれていたもの)から導入された、
「async/await」を使う機会がありませんでした

「イベントを数珠つなぎに処理する」のではなく、
「各componentがBusからイベントを勝手に受け取って動く」という疎結合な作りなので、
「連続した非同期処理を書く」ということが、あまりなかったのです*2

しかし、今回の画像変換は以下の3つの非同期処理をつなぐ必要があります

  • S3から画像をダウンロードする
  • 画像を加工する
  • 画像をS3にアップロードする

そもそも、Lambdaのテンプレートに、ご丁寧にも「async」が宣言されており、
これはもう使うしかないなと(`・ω・´)

ということで、「async/await」について細かく調べてみたわけですが、
新しいパラダイムを持ち込んだ難しい話ではなく、
「Promiseを使った非同期処理をいい感じに書ける仕様」でしかありません

もちろん、「いい感じに書けること」が最大のポイントでして、
そもそもPromise自体、「callback地獄をいい感じにしたい」から導入されたものですよね

qiita.com

ぶっちゃけ、これを読んでもらえれば、
もう私が書くことはないくらいなのですが、
Promiseの仕組み自体はjQuery全盛の時代から実装されています

  • 非同期処理でちゃんと正常系と異常系を処理したい
  • 非同期処理を連鎖させたい
  • 連鎖の途中でエラーになった場合に特別な制御をしたい

この要件を満たそうと、functionにfunctionを複数渡すことをネストさせた結果、
ぱっと見てわけがわからないコードができあがりました(lll゚Д゚)

それを改善したのがPromiseではあったのですが、
「つーかPromiseってなに? resolveとかrejectとかワカンネ」という、
概念としてやや複雑なものではありました

しかも、結局のところthenの中で別なfunctionを呼んでつなぐ構造は変わっておらず、
それぞれの処理を独立して「認識」できるかというと、結構面倒だったわけです

それが、「await」を使うだけでここまで単純化されます

try{
  // S3からダウンロード
  const image = await download(bucket, origin);
  // 画像変換
  const resized = await resize(image, ext, width, height);
  // S3に書き戻す
  const result = await upload(bucket, resized, image.ContentType, output);
} catch(e) {
  // エラーレスポンスを返す
}

一見、同期的なコードの羅列に見えますが、
裏ではちゃんと非同期的なcallbackで動いてブロックしないようになっており、
まさに「いい感じに書ける」仕様でございます( ゚д゚)o彡゚

要は「Promiseを返すと、resolveかrejectされるまで待って、
resolveした値を返し、rejectされたら例外をあげる」というだけの仕組みです
なので、function側はPromiseを返す仕組みにさえしておけば「いい感じ」になります

しかしですね・・・これだけだと
「そもそもPromiseがわかんないんだよぉぉぉぉっ」って問題を解決できません
そこで出てくるのが「async」です

「async」を宣言したfunctionは、暗黙的にPromiseに変換される・・・と書くと面倒そうですが、
要するにasyncを宣言して「普通に」書けばawaitできる、というだけです

// asyncをつけると、Promiseにラップしてくれる
async function resize(image) {

  // なんかresizeする処理

  if (err) {
    throw err; // 例外はrejectされる
  } else {
    return resizedImage; // 通常のreturnはresolveされる
  }
}

// resizeが完了するまで待って、終わったら結果がresizedに代入され、エラーなら例外
const resized = await resize(image);

Primiseの概念がわからなかったとしても、
「asyncつきでなんか非同期処理を書けば、いい感じにawaitできる」と覚えておけば、
それだけで直感的なコードになるわけです(`・ω・´) b

「直感的である」「わかりやすい」ということは、
「間違いを見つけやすくなる(ので品質が上がる)」ということでもあります
なので、積極的に採用していきたいところです

強いていえば、ES2016が動く環境か、
トランスパイラ経由のデプロイが整備された環境でしか使えないので、
サーバサイドエンジニアとしては、そこをどうにか頑張っていきたいところですね(`・ω・´)

おまけ:API GatewayとS3、どちらがトリガーとして最適か?

S3の方がトリガーとしてシンプルだし、Lambda@Edgeもその延長線なのだから、
そっちが本命・・・というのは事実だと思います

ただ、「画像をアップロードした」がトリガーだと、
「どうやって」とか「どこに」という変換の付帯情報が渡せないわけです(´-ω-)

変換処理ごとにLambdaを立てるってのが、
マイクロアーキテクチャ的には適切なのですが、
細かくたくさんの環境を作ると、管理できなくなると思うわけです

その点、APIであれば細かなパラメータを外部からコントロールでき、
Lambdaに情報を引き渡すことができるので、
汎用的に使うのには楽だと思います(`・ω・´)

一方で、わざわざhttpで通信するオーバーヘッドはあるし、
なにより(現時点では)タイムアウトの問題もあるので、
「汎用的な画像変換API」自体が適切か・・・というと、難しいところですね

結局のところ、どういうフローの中でどう使うか・・・なので、
「Lambdaを使えば画像変換が簡単」といっても、
考えることはたくさんありますね(´-ω-)

*1: サンプルコードにあるgmモジュールを、わざわざ手動でuploadしないといけないから、おかしいな・・・と思ってはいたのですが・・・(´-ω-)

*2: 詳しくはFRPについて書いた記事にて(´・ω・)っ https://parrot.hatenadiary.jp/entry/2015/11/29/175113

KotlinでいつものBrainF**kインタプリタを書いてみた件(+学んだ本が良かった件)

以前から話には聞いていたものの、
最近「ことりんはいいぞ・・・!」という話を聞く機会があったので、
試しに本を読んでみたわけです

Kotlinプログラミング

Kotlinプログラミング

この本がKotlin抜きに非常に良い本でして、
Kotlin自体もいい感じだとわかったので、
例によってBrainF**kインタプリタを書いてみました(´・ω・)っ

gist.github.com

これで「現場からは以上です」って感じなのですが、
以下は自分用のメモであり蛇足です...φ(・ω・`)


「今風の言語」とは何か?

1. 「人間」にとって都合の良い言語

仕様を読んでみて、Kotlinは非常に「今風の言語」だと思ったのですが、
何をもって「今風」なのか・・・と考えてみたわけです

そもそも、一番CPUに近い言語は「アセンブラ」であり、
それをもうちょっと人が書きやすくしたのが「C」、
そこにオブジェクト指向パラダイムを付加したのが「C++」や「Java」でした

「プログラムとは、本質的にメモリを操作する仕組みに過ぎない」というのは、
こちらの本にも書かれている本質的な話でして・・・

・・・Javaも極論を言えば
mallocとポインタを言いかえただけ」と見なすことが可能ですΣ(・ω・ノ)ノ

そういったCPUというかアセンブラから派生した言語とはまた別に、
「人にとって都合の良いモデル・処理系」を提唱したのが「Lisp」でして、
その影響を受けたJavaScript*1Rubyは非常に柔軟です

しかし、「人にとって都合がいい」は「CPUにとっては重い」ってことなので、
Rubyも長いこと「遅くて使えない」と言われていたのが、
CPUの高速化によって価値が再認識されたわけです*2

「わかりやすく言いかえる」というのが「人間」にとっては重要で、
オブジェクト指向」も本来的には「現実世界をモデル化する概念」ですし、
「関数型」も「数学の概念のモデル化」にすぎません

まあ、当時の関数型言語ブームは少々アカデミックに走りすぎて、
「なんかよくわからないけど難しい」になってしまっていたのはありますが(´-ω-)

今ではオブジェクト指向とか関数型の概念は、
もはやそのような単語が言語の文脈に出てこない程度には、
「当たり前のもの」として扱われるようになっております

2. 「型」の取り扱いと「設計思想」

さて、RubyJavaも「オブジェクト指向パラダイムを含んだ言語」ですが、
決定的に違うのが「型」の扱い方です

Javaは変数に型を要求するのが当たり前*3であり、
Rubyは「そのメソッドが呼べればOK」というゆるい縛りしかありません
(いわゆる「ダックタイピング」)

Rubyはその表現力ゆえに「Rails」という「実装された設計思想」を生み出し、
現在のWebフレームワークはほぼRailsに影響を受けているわけです

一方で、Webが肥大化するにつれ、
モノリシックなフレームワークで抱えられる規模を超えると、
「自由度が高いこと」は必ずしも利点ではなくなってきます

もちろん、先日書いたようにアーキテクチャ設計でカバーできる場合もありますが・・・

parrot.hatenadiary.jp

・・・ここまでコストを割けるのはかなり限られたケースでしょう

そもそも、どんな「できるエンジニア」であっても、
「人が認識できる規模」にはどうしても限界があり、
それを超えれば確実にミスを生みます

だからこそ、「設計上の制約」を「コードレベルで表現する」のが必要で、
その手段の一つが「型」と言えます

「じゃあJavaにすればいい」というのはよくわかるし、
実際に業務系システムではまだ圧倒的なシェアがあるのですが、
どうしても他の言語に比べて「お堅いので書きづらい」のはあります(´-ω-)

結局は「いちいち型を書く」のが面倒なのであり、もっといえば
「人が見ればわかることをいちいち書かないといけないのが冗長」ということでしょう

String hello = "Hello World!";

要は、「helloに文字列を代入してるんだから、
helloがStringなのわかるやん? わからんの(´・ω・)?」ということで、
いわゆる「型推論」ができないのか・・・ってことです

「人間にわかること」と「CPUにわかること」には乖離がありますが、
CPUの進化により、「現実的な時間」で推論が可能になったため、
最近の言語はだいたい型推論を備えています

最近のJavaにも一応備わっているらしいですが、
「関数型っぽい型=高階関数」までは扱えない・・・か、
扱いづらいか、対応がまだ不十分かのようです(´・ω・`)

(Java8で改善されたことは知っているものの、触れる機会がなかったので、
 このあたりの私の理解はあいまいです)

そこで、「JVMで動く関数型言語」として「Scala」が出てきたし、
私も一時期は興味があったわけですが・・・

やはり、先ほども書いたように、HaskellScalaのような言語は、
「純粋な関数型言語」を「アカデミックな思想」で目指しており、
「普通の人には難しい(と見える)」のが問題なのではないかと

Rubyが広まったのはその柔軟性もありますが、
やはり「プログラムを楽しく書きたい」という「思想」の面が強いわけです
良くも悪くも、「現実的な妥協」が入っている

Kotlinは「純粋な関数型言語」ではありませんが、
高階関数」や「再代入不可」のような、
Rubyと同程度以上の関数型言語的なパラダイムを備えています

その上でKotlinの「思想」は「よりベターなJava」であり、
非常に「Javaとの連携」を強く意識しています

Javaのclassとの暗黙的相互運用な可能な点は、
まさに「現場が望んだのだからそうあるべき」という思想を感じます(`・ω・´) b

結局のところ、「今風の言語」の個人的な定義はこうなるのかなと

  • 「思想」が感じられ、「現実的なバランス」に落とし込んでいる
  • 関数型のパラダイムを「現実的に便利な部分だけ」引き継いでいる
  • 過去に存在した言語との相互運用性を意識している

その意味で、JavaScriptに対する「TypeScript」と、
Javaに対する「Kotlin」は同じなんだろうな・・・と*4

考えてみると、TypeScriptもKotlinも(ついでに C# *5 も)「企業」が開発した言語です
それだけ、「現場の声」が反映されていたり、
「現場で使われるIDEとの連携」を意識している・・・ということですね

3. Rubyエンジニアから見たKotlin

Rubyのエンジニアからすると、
Kotlinは「Rubyっぽくて書きやすいJava」ですが、
明確な違いがあります

それは「変更に対してオープンかクローズか」という違いです

Rubyにおいて、あらゆるものは上書き可能であり、
それゆえに「黒魔術」とも呼ばれる柔軟なメタプログラミングが可能です
これを利用してgemにモンキーパッチをあてる・・・というのもよくあります

言いかえると、「誰かが勝手にまずいことをやってしまうことを防げない」ので、
ある人が良かれと思って変えたクラスの変更が、
他に波及して壊れる・・・ということもあります(´-ω-)

モンキーパッチにしても、当時はわかっていてやったとしても、
あとになると忘れてしまい、gemが更新できず、
右往左往する・・・ってことがよくあります
(ゆえに、私はgemやフレームワーク自体にパッチをあてるのは否定的です)

それはプログラマの質とかチームの問題である・・・という「思想」なのですが、
大規模なシステムでそういった「やってほしくないこと」を防ぐのは、
「現実的に考えると」難しいです

その点、Kotlinは「クローズ」がデフォルトで、
「必要な時に明示する」という「思想」です

クラスだけでなく、メソッド単位で宣言が必要なのは「面倒」ですが、
「面倒だから面倒ではない手段を探す」ように誘導しているのはあります
(これはRubyの設計思想も同じ)

また、「良くないことをしている場所」をあとから探すのが容易になるのも利点です
Javaでもアノテーションとかありますが、
「言語仕様で強制」はできませんからね(´-ω-)

だからRubyよりKotlinが優れている・・・ということではなく、
あくまで「適用範囲を良く考えよう」ということです

一般的なWebの文脈においてはRubyが楽だけど、
業務系システムですでにJVMの実績があればKotlinだよね・・・みたいな...φ(・ω・`)

冒頭の本は何がいいのか?

先ほどもちょっと挙げたこちらの本を、私は以前から絶賛しております

parrot.hatenadiary.jp

www.slideshare.net

この本のすばらしい点は、「とにかく実装を進めていって、
必要になったらあたらしい概念を導入する」というのを徹底している点です
なにしろ、「変数」が出てくるのが後半ってレベルです

同様に、先ほどのKotlinの本も、
「Kotlinの機能を必要になったら紹介する(という体で書かれている)」のが重要で、
「この機能はどういう時に使うと適切なのか?」が明確なのです(`・ω・´) b

Kotlinプログラミング

Kotlinプログラミング

なにしろ、「クラス」が出てくるのが後半ってレベルでして、
高階関数をどのタイミングで使えばいいのか?」もわかるようになってます

この本でも一貫して貫かれていたのが「ベターJavaとしてのKotlin」であり、
その意味ではJavaを知らない人には少々難しくなりそうにも思えます

その点、題材を「ゲーム」にすることで敷居を下げており、
最後まで興味を持って読みやすく工夫されてます

正直、この本がなければ、今回の記事は書かなかったとというか、
インタプリタだけ書いて満足したと思います

訳本ではありますが、特に読みづらいところもなく、
コードの間違いが気になる以外は、全体としてよくできた本だと思います
Kotlinに興味がある方だけでなく、プログラム初心者にもお勧めです(´・ω・)っ

*1: 「ブラウザで動くLisp」を実装するつもりが、マーケティングの都合でJavaっぽくなってしまった・・・というのは有名な話ですね(´-ω-) でも、「関数を値として扱える」という本質を譲らなかったおかげで、柔軟性を備えているわけです(`・ω・´) b

*2: JavaScriptだって、初期には原始的なDOMの操作にしか使われてなかったのが、ブラウザの高速化により、今ではゲームすら動かせるわけですよね

*3: 最近は緩和されたようにも思いますが、それは裏に隠したってことであり、基本的には「型があるべき」という「設計思想」だと思います

*4: Rubyも今では独立した言語ですが、かつては「ベターなPerl」を意識していた時代があり、その名残が仕様の随所に見られます・・・が、だいぶ減りましたね

*5: C++が辛いから、もうちょっと書きやすくしつつ、今風のパラダイムを積極的に取り込んだ言語・・・ですよね?

これからの中規模分割型アーキテクチャを考えよう

さんざん言及されている資料であり、
これさえ読んでもらえれば、これ以降の文章は不要ですΣ(・ω・ノ)ノ

speakerdeck.com

個人的に、モノリシックアーキテクチャと、マイクロアーキテクチャも、
どちらも「それだけでは辛い」ものであり、
どこかでバランスを取らないといけない・・・と、考えていました

それに対する明確な回答がこの資料であり、
少なくとも現時点においては最適解だと思っているので、
これ以降はただの自分用メモです...φ(・ω・`)


Case1:業務系CMSAPI

私が最初に「API状のもの」を作った時*1アーキテクチャがこちら*2です(´・ω・)っ

f:id:parrot_studio:20190403111453j:plain


当時、私は関数型言語に興味があったので、
アーキテクチャの設計そのものにも関数型的概念を突っ込んでいました

www.slideshare.net

API層が「副作用がある(Store)」と「副作用がない(Logic)」に明確に分かれており、
Perlで表現されたViewがLogicのAPIのみを触るという、
「ちゃんと分離されている感のある」のが非常に良かったと思います

問題は、Logic層がViewに強く依存しているところです

そもそも、Perlで書く量を限界まで減らそうとした結果、
Logicに業務ロジックのほとんどが移動した・・・というのが真相なので、
Logicにエラー文言のようなViewに関わる情報まで含んでいるのが問題です(´-ω-)

とはいえ、Store層はそこそこリソース単位のAPIとして成立しており、
Logic層も今回の機能に特化したAPI群である・・・と考えると、
(Viewの情報さえ分離できれば)構造そのものは不適切でもなかったと思います

Case2:スマホゲーのAPI

急にシステムが飛びますが、次に設計したのがスマホゲーのサーバですΣ(・ω・ノ)ノ

スマホゲーももちろんWebシステムではあるのですが、
通常のWebシステムに比べて、とがった部分が多々あります

  • セキュリティレベルを相当高くする必要がある(例:通信データ自体を暗号化)
  • 応答を少しでも早くしないといけない
  • ユーザーデータが莫大で、単一スキーマに存在することが必須(意味で分離できない)
  • 業務ロジック(=ゲームルール)が複雑で、多数のリソースを同一トランザクションで処理する必要がある
  • 通信のリトライを雑にすることが許されない
  • etc...

「ま、ここはいっか」って妥協が許されないのがゲームサーバです(lll゚Д゚)
(ユーザーが不正を働くモチベーションが非常に高いため)

にもかかわらず、「ゲームの売り上げ」と「サーバの精度」はあまり相関がなく、
結局のところ別な要因で決まるのも辛いところで、だからこそサーバエンジニアは、
企画やクライアントエンジニアが動きやすい環境を作るのが仕事になります

そんな当時のアーキテクチャは、こんな感じでした(´・ω・)っ

f:id:parrot_studio:20190403111503j:plain


莫大なユーザーデータが単一のスキーマに存在するのが必須なのですが、
その代わりユーザー(のID)で水平分割はしやすいです
(その代わり、「ユーザーをまたいだ処理」*3で地獄を見ます)

また、ゲームロジックが複雑で、処理が多岐に渡るため、
そのロジックをどこに置くかが問題で、当時はmodelに置いていました
その結果、modelが複雑で面倒な構造になってました(lll゚Д゚)

また、管理画面も同一のソースで管理していたのですが、
これは管理画面からの操作でも、ユーザーデータのバリデーション*4などは同一のため、
開発効率を考えると仕方なかった部分はあります

今考えるともうちょっとやり方はあったと思いますが、
小人数で面倒なシステム開発を進めるためにも、
ある程度モノリシックなシステムになってしまうのも仕方ないのかな・・・と

あと、これは当時の会社の方針ですが、
どうしても「アーキテクチャの設計」ができるエンジニアは限られるため、
ある程度プロジェクトが軌道に乗った段階で、別の新規プロジェクトに回されました*5

そのため、「最初から最後まで」見ていたシステムはなくて、
こはちょっと残念です・・・が、
私のようなタイプが「運用フェーズ」に向いてないのも事実なので、合理的です

Case3:モノリシックなAPIとView

そして現状いじっている業務システムです
Case1とCase3の中間を取ったようなアーキテクチャになってます

f:id:parrot_studio:20190403111514j:plain

以前に比べるとAPIRailsに関する理解も深まったので、
かなりリソース志向のAPIになっており、
さまざまなViewでそれを使う設計になってます

いわば、リソースAPIがいろいろなシステムのデータ管理層になっており、
View層がロジカルな部分を担当し、画面を作る・・・という形です

最初はReadがメインだったので問題なかったのですが、
Writeが発生したあたりでだんだん粗が見えてきて、
Viewがどんどん膨らんでしまいました(lll゚Д゚)

また、API側にも業務ロジックが一部入り込んでしまったりと、
Case1のLogicにあたる「業務ロジックを管理する層」を明確にしなかったのが失敗です

今にして思えば、私はCase1を「いまいち」だと思っていたのですが、
その理由を明確に分析してなかったのかな・・・と
実はCase1を整理したバージョンが正解に近かったのですが・・・(´・ω・`)

未来の話:これからの理想的アーキテクチャ

冒頭のスライドの内容を抜粋すると、こうなります

  • Railsはリソース志向に特化したフレームワークである
    • 結果的に小規模のリソース群に対しての開発効率がダントツである
  • しかし、業務ロジック層を無理矢理入れようとした結果、システムが複雑化する
    • 先の例ならmodelがファットになる
    • 最近の例だとService層を導入する
  • Railsの良さを生かしつつ、全体のアーキテクチャでカバーできないのか?

これに加えて、最近のマイクロアーキテクチャが、
「細かすぎてわからなくなってしまう」という問題を抱えているのもあります

「巨大なシステム」も「数が多すぎるシステム」も、
結局のところ「人間の認識限界を超える」のが問題なのです
設計の肝は「人間の認識できる規模にインターフェースを整理すること」ですからね

うまく多層化されたシステムや、うまく構造化されたコードは、
ある処理を考える時に、「その先」のことを考えず、
「今見ている処理」を小さく考えることができます

関数型言語(的な設計)の肝はそこにあるのですが、
本質的な話ばかりが先行した結果、
非常に扱いづらい印象になってしまったのがとても残念です(´-ω-)

先のスライドをふまえつつ、私が理解した範疇で、
もし「次」を考えるとすると、こうなるのかなと

f:id:parrot_studio:20190403111531j:plain

  • View-Logic(Service)-Resource-Dataに層を分ける
    • View層は「表現」で分割
    • Logic(Service)層は「業務」で分割
    • Data層は「トランザクション」で分割
    • Resource層はData層と1対1で、Logic層に対するAPIを提供

Data-Resouceの層を組むのにRailsは便利で、
その範疇であればRailsが破綻しないのであれば、
こういうやり方になるのかな・・・と...φ(・ω・`)

本来、全てのデータ処理に対してトランザクションを張りたいところですが、
それでは(Case3のような)巨大なスキーマができあがり、
管理できなくなるので、ある程度整合性をあきらめて分割する部分も出てくるのかなと*6

まとめ

個人的に「モノリシックなアーキテクチャ」も、「マイクロアーキテクチャ」も、
どちらもどこかで破綻すると思っていて、
「ちょうどいい落としどころ」がどこかにあるだろうな・・・と思ってました

その指針を明確に示しているのが冒頭のスライドであり、
非常に価値があると思います
ぜひ皆さんも読んでくださいね(`・ω・´)

*1: 厳密にはその手前があるのですが、ここに残すような精度のものではないし、結局リリースされていない=運用されてないので、価値はありませんΣ(・ω・ノ)ノ

*2: どこかで似たような図を書いて発表した記憶があるのですが、プライベートな勉強会で資料をUPしなかった気がするので、作り直しました(´-ω-)

*3: 例:フレンド申請・レイド戦・マルチプレイ

*4: しかもめっちゃ複雑(lll゚Д゚) 「お金を増やす」だけでも多数のバリデーションが必要ですからね・・・

*5: 別な人に開発を引き継ぐのが前提だったため、「できるだけわかりやすい、Railsの標準的な設計」を心がけたので、ここでRailsに対する習熟度が上がったと思ってます

*6: もちろん、Case2のようなゲームサーバの場合、ユーザーデータは単一トランザクションでないとダメなので、このような設計は採りづらいと思います。業務系寄りの話ですね

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が扱うのは「データ」であって「ロジック」ではないので、制御のコードはまだクライアントにあり、その意味では設計が維持されています

React+jQuery+RailsのSPAをサーバサイドレンダリングに移行した件(その2:ブラウザ依存排除編)

というわけで、前回の続きです...φ(・ω・`)


<前回>
parrot.hatenadiary.jp

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

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


前回は「サーバサイドレンダリングSSR)」の概念的な話と、
そこから導かれる設計の概要、そしてSSRに移行するための段取りについて書きました

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

今回はこの一番最初、「ブラウザ系オブジェクトの排除」からやっていきます

STEP1:実行時エラーの消去

とにかくまずは「エラーが出ない」「何か画面が表示される」を目指します
前回、windowオブジェクトを排除すればエラーにならないのでは・・・という推測しましたが、
これを立証するのが最初のステップになります

(1) 二つの領域のjsと、それをつなぐProxy

具体的なRailsの話であり、前回書いた話になりますが、
Railsが扱うjsはざっくり2種類あります

Railsのassetsに属する「application.js」と、
webpackでまとめられ、SSRにも使われる「Reactを主体にしたjs」
(以下、アプリ名にあわせて「ccpts.js」と表記)です

前者は「app/assets/javascripts」、後者は「app/javascript」に存在し、
それぞれ別なルートでまとめられて一つになります*1

「application.js」は(HTMLのlayoutを経由して)クライアントのみで読み込まれますが、
「ccpts.js」サーバとクライアント双方で実行されます
この「ccpts.js」にwindowオブジェクト等が含まれるとまずいことになります(´-ω-)

ならば、windowに触れるコードをapplication.jsに追い出してしまおう・・・ということです

f:id:parrot_studio:20190124110650p:plain

まず、windowとかjQueryとか、ブラウザ系のオブジェクトに触るコードを、
「Browser」というオブジェクトに閉じ込めてしまいます

// app/assets/javascripts/browser.js の抜粋
// webpackを通さないので、TypeScriptではない

// クラス的に振る舞うグローバルオブジェクトを定義
Browser = {};

// window.location
Browser.thisPage = function () {
  return window.location.href;
}

// window.confirm
Browser.confirm = function (mes) {
  return (window.confirm(mes));
}

// jQueryに依存する処理の例
Browser.addSwipeHandler = function (div, callbackLeft, callbackRight) {
  if (!div) {
    return;
  }

  $(div).swipe({
    swipeLeft: (function (e) {
      e.preventDefault();
      callbackLeft();
    }),
    swipeRight: (function (e) {
      e.preventDefault();
      callbackRight();
    })
  });
}

元々React化した際、jQueryなどのライブラリは「application.js」に追いやられているので、
これでwindowに触るコードとライブラリが全て「application.js」に分離されたことになります *2

しかし、このままではReact側で生成したDOMと、これらの処理が連携できません

そこで、「クライアントサイドではBrowserに処理を委譲し、
サーバサイドでは適当な値を返すオブジェクト(≒クラス)」として、
「BrowserProxy」を定義します

// BrowserProxyの抜粋

// 他のコードから追い出されたdeclare
// ここにしかdeclareがないので、他のコードに外部依存がないことを保証できる
declare var window
declare var Browser

export default class BrowserProxy {

  // windowオブジェクト(とBrowserオブジェクト)の有無を判定
  public static isWindowDefined: boolean = (() => {
    if (typeof window === "undefined") {
      return false
    }
    if (typeof Browser === "undefined") {
      return false
    }
    return true
  })()

  // windowがなければ特定の値を返す例
  public static thisPage(): string {
    if (BrowserProxy.isWindowDefined) {
      return Browser.thisPage()
    }
    return "/"
  }

  // windowがあるときだけhandlerをセットする例
  public static addSwipeHandler(div, callbackLeft, callbackRight): void {
    if (BrowserProxy.isWindowDefined) {
      Browser.addSwipeHandler(div, callbackLeft, callbackRight)
    }
  }

}

このように、windowやBrowserが存在する(≒クライアントサイド)では、
まんまBrowserに処理を委譲していますが、
Browserが存在しない(≒サーバサイド)環境では何もしないか、適当な値を返しています

すでにTypeScript化しているので、外部参照している箇所は
「declare $」とか「declare location」とか、declare宣言されているので、
そこをBrowserとBrowserProxyにどんどん切り出していきます*3


呼び出す側はこんな感じです

// 関連する箇所だけ抜粋

export default class DatabaseTableArea extends ResultView<ResultViewProps> {

  private arcanaTable: HTMLTableElement | null = null

  // componentをマウントする時にハンドラをセット(ただし、クライアントならば)
  public componentDidMount(): void {
    Browser.addSwipeHandler(
      this.arcanaTable,
      this.handleLeftSwipe.bind(this),
      this.handleRightSwipe.bind(this)
    )
  }

}

この段階では、SSRと無関係に、ただコードを整理しているだけですので、
処理を切り出したらSSRなしで動作が変わらないことを確認・・・という感じで進めます

(2) 暫定的Cookie対応

ここまでの作業が終わったところで、試しにSSRに切り替えたところ、
「window not found」ではないエラーに変わりました

これはこれで一歩前進ですが、エラーの内容を調べたところ、
Cookieを読んで初期化するところで落ちてました(´-ω-)

この時点ではサーバサイドJS実行系におけるCookieの扱いがわからなかったのですが・・・

  • Cookieに触れたこと自体はエラーの原因ではない(ただ存在しなかったという挙動)
  • Cookieへの書き込みでは落ちていないように見える(この時点では挙動不明)

・・・ということで、サーバサイドでCookieのオブジェクトをparseし、
Reactの初期値として構造を丸ごと渡す・・・という強引な手にΣ(・ω・ノ)ノ

これはあくまで一時的な処置で、次のSTEP2で適切に置き換えられるのですが、
とにかくこの段階では「エラーを消す」(そしてBrowserProxy方式の妥当性を検証する)のが
最優先なので、無理矢理な手段をとりました


この修正により、ついにSSRでHTMLが吐き出され、
それっぽい画面が表示されました・・・が、
それは新たな戦いの始まりでしかなかったのです・・・

今回のまとめと次回予告

今回は実際の例として、Railsの文脈での作業について書いてみましたが、
一般的なポイントは以下です...φ(・ω・`)

  • jsを2つに分離する
    • 「クライアントでだけ実行されるもの」と「サーバ・クライアント双方で実行されるもの」
  • クライアントでだけ実行されるjsに、ブラウザ系オブジェクトを参照するコードを集める
  • ブラウザ系オブジェクトの処理を代行するProxy層を用意する

これで「表示」まで進むことができ、
SSR自体がいけそうな気がするレベルにはなったのですが、
進めていくと、「クライアント」と「サーバ」を別な形で意識させられることになります


ということで、次回「STEP2:仕様変更編」へ続きます(`・ω・´)


parrot.hatenadiary.jp

*1: 前者はsprockets、後者はwebpacker

*2: どこかで書いたと思ったら、ちゃんと書いてなかった気がするけど、とりあえずこのあたり https://parrot.hatenadiary.jp/entry/2016/02/28/113310

*3: declareについては以前の記事参照 https://parrot.hatenadiary.jp/entry/2018/08/29/171654