Rails x CoffeeScript x KnockoutJSでWebサービス作った
今年の10月からあるスタートアップの立ち上げに参加していて、先日ベータローンチした。
自分はプログラミング初心者というわけでは全くないが、大学の専攻は農学部だし、それまで一人でサービスを一から作ってリリースしたこともなかったので、完全に独力でサービスを作りあげリリースするというのはかなりタフで、学ぶところが多かった。
この記事はKnockoutJS Advent Calendarの中の一記事として作成しているが、KnockoutJS単体というよりもRails x KnockoutJSでWebアプリを作った過程の記録としたい。
何を作ったのか
今回立ち上げた会社は、投資銀行出身の外国人(アラン)が金融機関向けのツールを作りたいということから始まったBtoBである。 彼が、East Venturesの衛藤バタラさん(mixi作った人)に話を持ちかけたところから始まった。で、ちょうどいい所にいた自分に話がきた。
アランが言うには、投資銀行という場所はコミュニケーションの点で未だにイノベーションが進んでおらず、そこに最新の(?)Webアプリケーションを作れば必ずいける、という話だ。 アプリケーションとしてはそこまで高度なこと(大量のトラフィックとかなんとか処理とか)が要求されているわけではなく、だからこそ自分でも挑戦してみたいという気持ちになった。自分には一からサービスを作ってみたいという気持ちがとても強かったこともあり、新卒で入った会社を飛び出して参加させてもらった。
アプリケーションの機能としては、ディールの一覧を取得し、それをインタラクティブに投資家<=>投資銀行間でやりとりしたり、チャットしたりとコミュニケーションを円滑化する、というのが中心である。 Webアプリとしては一般的だと思う。
友達に使ってもらうようなものではないが。
なぜこの構成にしたのか
今回のサービス作りではAPIおよびHTMLのテンプレートを返すためにRails, フロントエンドのなんやかんやをやりくりするためにKnockoutJSを選択した。
Railsはとりあえず普通のWebアプリを作るためには安易すぎるくらいの選択で特に述べることはないが、KnockoutJSを選ぶというのはあまり多くないと思う。
最近はだいぶディスられてるみたいだけど、JavaScriptフレームワークとしてはちょっと前にAngularJSがもてはやされてて、自分も前職でそれに触れ、純粋に面白いと思い、今回もできれば使いたいなと思っていたが、考えた結果としてAngularJSは却下した。
その他にもBackboneやらEmberJSも(ちょっとだけ)検討したが、最終的にチュートリアル触った感じからKnockoutJSを選ぶことにした。自分が選んだ理由ははっきりしていて、
① Internet Explorer 8などの古めのブラウザにも対応している
② 機能が少なくて学習コストが低い
③ Railsアプリとの相性がよい
この3つに集約される。
①については、金融機関向けのサービスということで、大変残念なことにメインのターゲットブラウザがIE8と9 ということになってしまった。 エンジニアとしてはちっとも面白くないしなんだかなーと思っているうえに、AngularJSがIE8のサポート次から切るよ、と言っててぐぬぬと思っていたところにKnockoutJSは「IE6から全部サポート!」などというソ○トバンクばりの大言壮語を吐くので、「これは心強い」と素直に感じてしまった。
②は後述するが、KnockoutJSは代表的なJavaScriptフレームワークの中では圧倒的に学習コストが低いと思う。Angularにちょっと触れた自分としては逆にそれが魅力的だった。data-bind=とやってほげほげすればたいていのことは間に合う。ドキュメントは充実してる気はあんまりしないが、チュートリアルはわりと親切で、これを組み合わせればなんとかなりそうだ、という実感がもてた。
③が結局は決定的だったが、AngularJSはRailsとの相性があまりよろしくないと思う。Rebuild.fmでも言われていたが、RailsとAngularは機能的にかぶっている部分がありすぎる。Gruntでビルドするとか、ルーティングとかうまく使いこなせれば強いのだろうが、それだったらRails使う意味があんまりないかなと思っていた。その一方でKnockoutJSは薄いフレームワークであるがゆえに、Ruby on Railsのapp/assets/javascripts以下にきれいに収まってくれる。
この透明感というか「全体を把握できている感」が一からサービスを作っていく上で安心できた。
全体の構成
Railsの役割としては大きく二つで、HTMLをほぼリソースとは関係なくレンダリングする部分と、リソースをAPIサーバとして適宜返す、という部分を/apiでネームスペースで区切って共存させることになった。 レンダリングしたHTMLやJavaScriptの中には当然ながらKnockoutJSで書いたコードが入っていて、その中でKnockoutJSがAPIを叩いてテンプレートをよろしく動かすという感じだ。
KnockoutJSはすべてCoffeeScriptで書いた。CoffeeScriptはクラスを定義できるので、そこでリソースが持っているデータ構造をほぼ完全に再現して、それをKnockoutJSでバインドして動かした。
フロントエンドの構成
ここからもうちょっと具体的な話を書きたい。Railsに導入するための参考になれば。
ディレクトリ構成は次のような感じで、
├── application.js ├── common │ ├── bindingHandlers.js.coffee │ ├── class.js.coffee │ └── utils.js.coffee ├── calendar.js.coffee ├── deal_room.js.coffee ├── sessions.js.coffee └── users.js.coffee
なんとなくこういうの載せるってどうなのかなーと思って本物とは少し変えてあるのだが、要はapplication.jsの下に、共通して使うメソッドとかクラスを定義するcommonというディレクトリがあって、それ以外が各テンプレートと対応するCoffeeScriptになっていて、それぞれがサーバーとデータをやりとりする、という感じ。
当然ながらチャットとかノーティフィケーション系のどこでも呼ばれないといけないデータはcommon/class.js.coffee内に入っていて、その中に共通機能をいれたViewModelを定義して、各テンプレートではそれを継承したViewModelをつくる、という感じだ。全く伝わる気がしない。
bindingHandlersには、要素をドラッグしたり、エフェクトをつけたりといったDOM操作系の機能が入っている。だから、DOM操作は末端のCoffeeScriptには書いてなくて、データとかロジックだけをうまく切りだせていると思う。
よく使ったコードパターン
自分が書いたコードを振り返ってみると、アプリケーションの機能としては分割されているものの、違うリソースに対して同じようなふるまいを提供している部分が多い。
class @BaseViewModel constructor: -> self = this self.currentUser = ko.observable() self.notifications = ko.observableArray([]) self.message_status = ko.observable '' self.message = ko.observable '' self.socket $.ajax({ type: 'GET', url: '/current_user', success: (result) -> self.currentUser(new User(result.current_user)) self.socket = io.connect(':[PORT]') self.socket.on('connected', -> self.socket.json.emit('init', { 'room': [room id] }) ) self.socket.on('notification', (data) -> self.addChat(data.from_id, false ) ) }) self.loadNotifications = -> $.getJSON('/api/notifications', (result) -> self.notifications($.map(result.notifications, (item) -> return new Notification(item))) ) return self.loadNotifications()
上がログイン下でのすべてのテンプレートのViewModelが共通して継承するクラス(の省略版)で、ログインしているユーザーの情報をAjaxでとってきたり、別に立ててあるSocketIOのサーバにつなぎに言ったりしている。
よく使ったのがself.loadNotifications()みたいにしてデータをロードして、$.map使って定義したクラスにデータを入れ籠む、という形。これも伝わる気がしない。
そして、各データを構成するクラスは以下のような感じ。
class @User constructor: (data) -> self = this self.id = data.id self.type = data.type self.email = ko.observable(data.email) self.first_name = ko.observable(data.first_name) self.surname = ko.observable(data.surname) self.full_name = data.first_name + ' ' + data.surname self.company_name = ko.observable(data.company_name) self.image_url = if data.image_url then data.image_url else 'default-user-image.jpg' self.attemptedEmail = ko.observable(data.email) self.attemptedFirstName = ko.observable(data.first_name) self.attemptedSurname = ko.observable(data.surname) self.editing = ko.observable(false) self.edit = -> self.editing(true) self.save = -> $.ajax({ type: 'PUT', url: '/api/users/' + self.id, contentType: 'application/json', data: ko.toJSON({ user: { email: self.attemptedEmail(), first_name: self.attemptedFirstName(), surname: self.attemptedSurname(), } }), success: (result) -> if result.status == 'success' self.update(result.user) self.editing(false) }) return true self.cancel = -> self.editing(false) update: (data) -> self = this self.email(data.email) self.attemptedEmail(data.email) self.first_name(data.first_name) self.attemptedFirstName(data.first_name) self.surname(data.surname) self.attemptedSurname(data.surname)
上はユーザークラスで、プロフィール画面にバインドさせて編集したり更新するのに使った。idとかfirst_nameとか一通りの情報は持たせた上で、saveみたいに情報をPOSTするためのメソッドを個別に持たせた。ここではPUTか。
情報を更新したら、サーバーから更新後のデータをそのまま返させて、それを使ってupdateメソッドを呼ぶことで自分自身を更新する。こうすることでデータをまるごとリロードしたりせずともSPA的な機能を表現できた。
リソースの更新にはこのように各オブジェクトが自分自身を更新できる場合もあってきれいな感じだが、実際にはその親クラスにメソッドを持たせる必要がある場合が多かった。
例えばそのオブジェクトがObservableArrayに入っている場合。
observableArrayは配列を監視して、データが更新されたらテンプレートも一緒に更新する、というデータバインディングの基本機能を実現するものだが、ただ自分を変えるだけじゃなく、削除したり、他のobservableArrayに移動したりという場合にはどうしても親から操作する必要があった。
まとめ
今回はRailsとKnockoutJSでそこそこの規模のアプリを作るのに使った大枠をざっくりと記録した。
以上の内容をまとめると、以下のようになる。
① Railsにはテンプレートの描画とAPIの返し(?)を担当させ、両者を綺麗に分割する
② リソースをそのまま反映したクラスをフロントエンド側で用意しておき、ajaxで取り込んでself.list($.map(data, (item) -> new Class(item)))の形で一覧を取得
③ 各アイテムの更新の際には、一覧の数が関係なければオブジェクト自身に更新させ、数が変わるときには親のビューモデルからAPIを叩いて更新する
来週も書くつもりだが、次回はもっと具体的なTips的なのを書けたらと思う。