Edited at
OpalDay 2

Isomorphic web programming in Ruby

More than 1 year has passed since last update.

この記事はOpal Advent Calendar 2016の2日目の投稿として書いています。

今日はRubyConf Taiwan 2016で同じタイトルのトークをしてきました。台湾でのトークは英語のトークということでなかなかひどいものになってしまいました。

リベンジの意味もこめてその内容ここに書きとめておこうと思います。

基本的にはライブコーディングによるデモだったのでデモプログラムを https://github.com/youchan/menilite-todo-app に置いておきます。


Isomorphic web programming in Ruby

Isomorphic programmingといえばNode.jsという感じですが、RubyでもOpalを使うとIsomorphic programmingができますよという話です。

昨日の話と繋がりますが、Rubyでサーバーサイドを書けるので、OpalでIsomorphic programmingというのはとても魅力的な話です。もちろん、他のAltJSでは真似ができません。(Scala.jsとか他の言語からのトランスパイラについてはその限りではありませんが)


Menilite

そういうわけで、RubyでIsomorphic programmingするフレームワークMeniliteを開発しました。

MeniliteはModelをサーバーサイドとクライアントサイドで共有します。

サーバーサイドのModelはクライアントサイドから透過的に扱うことができるようになっています。

たとえば、クライアントサイドでsaveすればサーバーサイドでデータベースに保存されるという感じです。

RubyConf Taiwanのトークもそうでしたが、この稿ではMeniliteによるIsomorphic programmingについて書きます。


Demo

ライブコーディングにつかったデモアプリについて少し説明しておきます。

作ったのはTodoアプリですが、メインはTodoの管理ではなくユーザー管理のところでした。

Todoを選んだのは単に簡単で動的なものを表現できるからです。

ユーザー管理にフォーカスしたのは、モデルを透過的に扱えるようにするということからアクセス制御が必要になることを強調したかったからです。またサインアップやログインなどのフォームも実装するというところがデモとしてよいと思いました。


モデルの定義

モデルは以下のように定義します。

class User < Menilite::Model

field :name
field :password

action :signup, save: true do |password|
self.password = BCrypt::Password.create(password)
end

if_server do
def auth(password)
BCrypt::Password.new(self.password) == password
end
end
end

Menilite::Modelを継承してUserというモデルを定義しています。

namepasswordというフィールドを持っています。フィールドの型はデフォルトでstring型になります。

もちろん型を指定することもできます。

actionはサーバーサイドで実行されるロジックで、クライアントサイドではUserクラスのsignupという名前のインスタンスメソッドとして呼び出すことができます。ちょうどRPCのような感じです。

saveオプションはこのアクションを実行した後にモデルが保存されることを表わしていて、この場合、selfはUserモデルのインスタンスを表しています。

if_serverはサーバーサイドでのみ実行されるブロックを定義していて、この場合、authメソッドはサーバーサイドだけに定義されることを表しています。


アクション

先ほど定義した、signupアクションはクライアントサイドからはメソッドとして見えます。

このメソッドはサーバーサイドのアクションを呼びだしたあと、コールバックとして引数で渡されたブロックを実行します。

def signup

user = User.new(name: @refs[:name].value)
user.signup(@refs[:password].value) do |status, res|
if status == :success
$window.location.assign('/')
end
end

クライアントサイドからはこのように使います。statusには:success:failure,:validation_errorなどの結果が返ってきます。

この例では成功した場合だけルートパスにリダイレクトしています。


コントローラー

サインアップができたら、次はログインの機能です。

ログインの情報はセッションに保存することにします。

セッションのようなリクエストに紐付くコンテキストをモデルに持たせるのはあまり良い方法ではありません。

また、複数のモデルに横断する処理はモデルのアクションに定義するのはむずかしいでしょう。

そこで、コントローラーという機能を用意しました。

MeniliteはMVCフレームワークではありませんが、Viewに当るものがクライアントサイドのViewでModelがクライアントサイドから透過的に扱えるというところから、残るControllerに当るものが必要になると感じました。

さて、コントローラーの例を見せましょう。

class ApplicationController < Menilite::Controller

action :login do |username, password|
user = User.find(name: username)
if user && user.auth(password)
session[:user_id] = user.id
else
raise Menilite::Unauthorized.new
end
end
end

コントローラーにもモデルのようにアクションを定義することができます。モデルと違うのはセッションがあつかえる点です。

セッションのほかにもリクエストのコンテキストに紐付くものを扱うことができます。(クエリパラメータなど)

Railsのアクションに似ていますね。Railsと違うのは、クライアントからはメソッドとして透過的に見えることです。

このアクションの呼びだしは次の例になります。

def login

name = @refs[:name].value
password = @refs[:password].value
ApplicationController.login(name, password) do |status, res|
if status == :success
$window.location.assign('/')
end
end
end

先ほどのサインアップの例とよく似ていますね。違うのはインスタンスを作らずにクラスメソッドとして呼びだしているところです。


認証

認証には先ほど定義した。User#authメソッドを使います。このメソッドはサーバーサイドだけで呼びだせます。

APIの認証はMeniliteの大部分のアクションの前に挟み込む必要があります。

そこで、コントローラーにbefore_actionという機能を用意しました。

class ApplicationController < Menilite::Controller

before_action(exclude: ['ApplicationController#login', 'User#signup']) do
user = User[session[:user_id]]
if user
Menilite::PrivilegeService.current.privileges << UserPrivilege.new(user)
else
raise Menilite::Unauthorized.new
end
end
...

excludeパラメータでApplicationController#loginUser#signupを除外しています。

それ以外のすべてのアクションに対してこのbefore_actionは実行されます。

認証が成功したときの処理についてはこのあとに説明しましょう。


アクセスコントロール

すでに説明しているとおり、


モデルを透過的に扱えるようにするということからアクセス制御が必要になる


ということが言えます。つまり、すべてのユーザーがすべてのデータにアクセスできたら困るということです。

そこでデータにアクセス制限を加える方法について説明します。

まず、モデルについてです。デモのTodoアプリにはEntryというモデルがあります。次のような感じです。

class Entry < Menilite::Model

field :description
field :done, :boolean
field :user, :reference

permit :user_privilege
end

このなかに、userというフィールドがあります。これはActiveRecordのAssociationのようなものです。

ユーザーで制限するためにはユーザーに紐付けることが必要ということです。

permitというのはこのデータへのアクセスに対して特別な権限を与えるということです。

user_privilegeは次のように定義されます。

class UserPrivilege < Menilite::Privilege

def key
:user_privilege
end

def initialize(user)
@user = user
end

def filter
{ user_id: @user.id }
end

def fields
{ user_id: @user.id }
end
end

keyはさきほどのpermitの引数に渡してこのPrivilegeを識別するために使います。

filterはgetするときのフィルターとして働きます。

fieldsはpostするときの追加フィールドとして働きます。

つまり、postするときは持ち主であるユーザーの情報を付加して、getするときにはそのユーザーのものだけにフィルターします。

そして、認証のところで出てきた、

Menilite::PrivilegeService.current.privileges << UserPrivilege.new(user)

というのがUserPrivilegePrivilegeServiceに登録して、どのユーザーの権限でアクセスするかを設定しています。


まとめ

アドベントカレンダーということで今日中にポストしようと思い駆け足で説明してきました。

この投稿だけでは伝えきれないものがたくさんあります。1時間のトークでも説明しきれませんでした(英語だったというのもあります。)

まだはじめたばかりのプロジェクトですが、RubyでIsomorphic programmingというのは魅力的な方法論だと思います。

この投稿でその一端でも感じてもらえたなら、なんらかのリアクションをいただけるとうれしいです。