Isomorphic web programming in Ruby

  • 0
    Like
  • 0
    Comment

    この記事は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というのは魅力的な方法論だと思います。
    この投稿でその一端でも感じてもらえたなら、なんらかのリアクションをいただけるとうれしいです。