node.jsで Isomorphic フレームワーク作ってみようとしたら超辛かった

現状どんな実装があるかはここみてください Isomorphic JavaScript - The future of web app development

主張

  • Node.jsは現状フロントエンドユーティリティに最も適している
  • Node.jsは一般にサーバーサイドでの運用は難しいし、自分もそう思う
  • どうせやるなら、Node.jsにしかできないことをやるべきだ
  • Node.jsのキラーアプリRailsクローンではなく単体ソースからクライアント・サーバーのコードを同時に生成するIsomorphicフレームワークであるべきだ。
    • これだけは他のフレームワークには絶対に真似できないので、一応可能性は検証すべきだ
    • 自分で実装することで、MeteorとかDerbyはなぜうまくいってないのかも考えたい

というわけで Isomorphicなフレームワークを自分で作ってみたいと思っていた。 今ならせめてConceptualな実装ぐらいならいけるのではないか、という着想があった。

着想

  • React、renderComponent以外はサーバーでも動くし、データドリブンな設計すればギリギリまでサーバーで動いてそのレイヤーでテスタブルにできるんじゃないだろうか
  • renderComponentの部分を専用のラッパで呼んでやれば完璧なラッパーでnodeで走らせてテスト出来て最高なのでは?
  • クライアントではできないStorageが抽象化APIがあると嬉しいよな
  • Storage抽象、Meteorぐらいしかみたことないし作ってみるか

API

まずはDBを扱うStorageクラスだけ実装しようとしてみた。というかここがキモになる(と、最初は思っていたら最難関だった)。

class Todo extends ism.Storage
  endpoint: '/todos'
  schema:
    title: String
    user_id: String
    
  validate: ({title, user_id}) ->
    title.length > 1 and user_id
todo = new Todo title: 'hoge'
todo.save()

Todo.fetch().then (data) ->
  console.log data

クライアント・サーバーで両方共こんなAPIが動いてほしい。クライアントでバリデーションできるし、 サーバーでもバリデーションできると嬉しい。サーバーに投げて404の返事が来る前に弾かれるんならクライアントで叩きたい。

実装

  • インターフェースだけ定義したクラス作って、クラサバで別実装を差しなおせばいいのでは
  • クライアント側はcommon.jsでビルドして意識しないようにさせよう
  • ライブラリはとりあえずKoa, Mongoose(mongo)で決め打ちする

大雑把に共通インターフェースを用意する。(以下適当にコード書くけどすごい雑なので真に受けないでください)

class ism.Storage
  endpoint: null
  schema: {}

  @extend: (obj) ->
    class extends ism.Storage
      _.extend @::, obj

  @fetch: ->
    throw 'should implement fetch'

  save: ->
    throw 'should implement save'

サーバー側(抜粋)

  • ルーティング適当に決める
  • DB操作の実装を用意する
ism.Storage.fetch = (query = {}) -> new Promise (done) =>
  @model.find query, (err, content) -> done(content)

ism.Storage::save = -> Promise (done) =>
  params = {}
  for own k, v of @ then params[k] = v
    
  model = new @constructor.model params
  model.save (err, data) -> done(data)
  
# Storage定義からAPIを定義する(koaのコード)
ism.Storage.mount = (Storage) ->
  Model = mongoose.model Storage.name, Storage::schema
  Storage.model = Model

  # fetch
  route.get Storage::endpoint, ->
    query = @request.query ? {}
    @body = yield (cb) ->
      Storage.fetch(query).then (content) -> cb null, content

  # save
  route.post Storage::endpoint, ->
    params = @request.body
    @body = yield (cb) ->
      model = new Model params
      model.save (err, data) ->
        cb(null, {error: err})

クライアント側(抜粋)

  • サーバーで用意したAPIを殴ってPromiseで待つ
ism.Storage.fetch = (params) -> new Promise (done, reject) =>
  $.get(host+'/api'+@::endpoint).done (data) -> done data

ism.Storage::save = -> new Promise (done, reject) =>
  params = {}
  for own k, v of @ then params[k] = v
  $.post(host+'/api'+@endpoint, params).done (data) -> done(data)

これでサンプルコードがだましだまし動いた…んだけど

結果

考えが全然甘かった

  • とにかくAPIデザインが難しい
    • 下手なクラサバ抽象化は結果として使いにくくなる
    • 一行書くにつれてもう片方で出来ないことが増えて、泥沼に入り込んでいく感じがある
    • 同じ名前のAPIで別物、っていうのが思った以上に気を使うし実装する過程で発狂しそうになる
  • セキュリティサンドボックスを厳密にするために、クライアントとサーバーの細かい部分をどう折り合いをつけるか難しい
    • 大雑把なAPI提供してクライアントからやり放題になるの辛い
    • とはいえ、なんか適当に作ったデフォルト実装で実際の使用時に満足するわけない
  • 結局ブラウザのcommonjsとサーバー側でrequireできるライブラリが違うので意識しないのは無理

あとkoaだとyield必須なんだけどクライアントで動かないのでそこを気をつけたりしないとならなかった(これはライブラリ選択の問題で自分のせいだけど)

とにかく考えることが多くて、実際のユースケースを消化した上でよっぽどAPIセンスが良くないと設計できない。Meteorちょっと触った程度の自分はちょっと限界を感じて、諦めた。

仮コードはここにある https://github.com/mizchi-sandbox/ism

example以下がこうやりたい的なAPIなんだけどここまでたどり着ける気がしないので一旦pending

meteorの苦労がわかりました。