この記事は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
というモデルを定義しています。
name
とpassword
というフィールドを持っています。フィールドの型はデフォルトで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#login
やUser#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)
というのがUserPrivilege
をPrivilegeService
に登録して、どのユーザーの権限でアクセスするかを設定しています。
まとめ
アドベントカレンダーということで今日中にポストしようと思い駆け足で説明してきました。
この投稿だけでは伝えきれないものがたくさんあります。1時間のトークでも説明しきれませんでした(英語だったというのもあります。)
まだはじめたばかりのプロジェクトですが、RubyでIsomorphic programmingというのは魅力的な方法論だと思います。
この投稿でその一端でも感じてもらえたなら、なんらかのリアクションをいただけるとうれしいです。