はじめに
前回はRuby on RailsのScaffold機能で作ったテーブルに対してデータのCRUD(作成・読み出し・更新・削除)ができるWebアプリを作りました。今回は下の画面のようなCurlを使ったリッチクライアント版のCRUDアプリを作成してみましょう。
また、先日Curl Ver.7がリリースされたましたので今回からVer.7を使います。実行環境(RTE)、IDEをダウンロード・インストールしてください。
前回の記事
Ruby on Rails とリッチクライアントのインターフェース
前回のScaffoldの生成したコードの説明でも少し触れたように、Ruby on Railsはたいへんリッチクライアントと相性の良い設計になっています。
データフォーマット
Ruby on Railsは通常のHTML以外にXMLフォーマットのデータを出力できます。またXMLだけではなく、以下のように1行コードを追加するだけでJSONフォーマットのデータも簡単に出力できます。
def index @players = Player.all respond_to do |format| format.html # index.html.erb format.xml { render :xml => @players } format.json { render :json => @players } # ← この行を追加 end end
さらに出力だけなくデータの受け取りも、通常使われるapplication/x-www-form-urlencodedやmultipart/form-dataだけでなく、XML、JSONフォーマットのデータもプログラムを変更する事なく使用できます。
REST
Ruby on RailsのコントローラーはREST(Representational State Transfer)原則に従っています。RESTではテーブル等のリソースをURLで一意に指定し、そのデータをHTTPメッソドのPOST、GET、 PUT、DELETEを使って、作成/読み出し/更新/削除できるなっています。
URL | メソッド | 操作 |
---|---|---|
http://localhost:3000/players | GET | playesテーブルの一覧 |
http://localhost:3000/players/123 | GET | id=123データの読み出し |
http://localhost:3000/players | POST | 新規データの作成 |
http://localhost:3000/players/123 | PUT | id=123データの更新 |
http://localhost:3000/players/123 | DELETE | id=123データの削除 |
また、JSONフォーマットで情報を操作する場合は次のようなURLになります。
URL | メソッド | 操作 |
---|---|---|
http://localhost:3000/players.json | GET | playesテーブルの一覧 |
http://localhost:3000/players/123.json | GET | id=123データの読み出し |
http://localhost:3000/players.json | POST | 新規データの作成 |
http://localhost:3000/players/123.json | PUT | id=123データの更新 |
http://localhost:3000/players/123.json | DELETE | id=123データの削除 |
このようなシンプルな考え方により、リッチクライアント側からのサーバーアクセスを統一的に操作でき、クライアント側のソフトをより簡潔にしています。
JSONフォーマット出力機能の追加
JSONフォーマットでの出力ができるようにコントローラーを改造してみましょう。respond_toブロックのformat.xmlの行をコピーし、xmlをjsonに書き換えるだけです。
app/controllers/players_controller.rb
class PlayersController < ApplicationController def chart @players = Player.all render :layout => false, :content_type => 'text/vnd.curl' end def index @players = Player.all respond_to do |format| format.html # index.html.erb format.xml { render :xml => @players } format.json { render :json => @players } # 追加 end end def show @player = Player.find(params[:id]) respond_to do |format| format.html # show.html.erb format.xml { render :xml => @player } format.json { render :json => @player } # 追加 end end def new @player = Player.new respond_to do |format| format.html # new.html.erb format.xml { render :xml => @player } format.json { render :json => @player } # 追加 end end def edit @player = Player.find(params[:id]) respond_to do |format| format.html # edit.html.erb format.xml { render :xml => @player } format.json { render :json => @player } # 追加 end end def create @player = Player.new(params[:player]) respond_to do |format| if @player.save flash[:notice] = 'Player was successfully created.' format.html { redirect_to(@player) } format.xml { render :xml => @player, :status => :created, :location => @player } format.json { render :json => @player, :status => :created, :location => @player } # 追加 else format.html { render :action => "new" } format.xml { render :xml => @player.errors, :status => :unprocessable_entity } format.json { render :json => @player.errors, :status => :unprocessable_entity } # 追加 end end end def update @player = Player.find(params[:id]) respond_to do |format| if @player.update_attributes(params[:player]) flash[:notice] = 'Player was successfully updated.' format.html { redirect_to(@player) } format.xml { head :ok } format.json { head :ok } else format.html { render :action => "edit" } format.xml { render :xml => @player.errors, :status => :unprocessable_entity } format.json { render :json => @player.errors, :status => :unprocessable_entity } # 追加 end end end def destroy @player = Player.find(params[:id]) @player.destroy respond_to do |format| format.html { redirect_to(players_url) } format.xml { head :ok } format.json { head :ok } # 追加 end end end
さて、出力されるJSONですが、http://localhost:3000/players/957451028.jsonの結果は次のように、クラス名 : 値のハッシュになっています。このうちクラス名については、あると便利な場合もありますが、今回は扱うクラスはplayerだけなので余分なデータとなってしまっています。
{"player": {"name": "RADUNSKE,Brock", "no": 25, "goal": 29, "created_at": "2009-05-06T02:27:14Z", "updated_at": "2009-05-06T02:27:14Z", "id": 957451028, "team": "AHL", "assist": 28}}
そこで、config/environment.rbファイルに以下の行を追加し、サーバを再起動してみましょう。
ActiveRecord::Base.include_root_in_json = false
今度は、http://localhost:3000/players/957451028.jsonの結果は以下のように値のハッシュのみになりました。
{"name": "RADUNSKE,Brock", "no": 25, "goal": 29, "created_at": "2009-05-06T02:27:14Z", "updated_at": "2009-05-06T02:27:14Z", "id": 957451028, "team": "AHL", "assist": 28}
app/controllers/application_controller.rb
なお、Ruby on RailsはデフォルトでCSRF(Cross Site Request Forgeries)対策用機能が動いていますが、リッチクライアントで使う場合は思わぬところでエラーになってしまう場合があります。そのため、今回は CSRF対策用機能を無効にしています(本当のアプリを作成する場合はむやみに無効にしないでくださいね)。
class ApplicationController < ActionController::Base helper :all # include all helpers, all the time # 以下の行をコメントアウト #protect_from_forgery # See ActionController::RequestForgeryProtection for details ・・・ 以下省略 ・・・
データ一覧の表示
それでは、playersデータの一覧をCurlの高機能なRecordGridを使って表示してみましょう。Curl側のプログラムはJava開発者のためのCurl入門-クライアントサイドCurlとサーバサイドJavaの通信のコードを少し変更したものを使います。
Curlコード用ページ
Curlのリッチクライアントアプリの場合、ページの制御はすべてクライアント側のプログラムが行いますので、最初にCurlのプログラムの書かれているページを表示する必要があります。
app/view/players/start.html.erb
まず、このCurlコード用ページを作りましょう。テンプレートは app/view/players/start.html.erb にします。ここにCurl側の一覧表示プログラムを書きます。
{curl 7.0 applet} {curl-file-attributes character-encoding = "utf8"} {import * from CURL.IO.JSON} {let players: RecordSet = {RecordSet {RecordFields {RecordField "id", caption = "id", domain = int} , {RecordField "name", caption = "name", domain = String} , {RecordField "team", caption = "team", domain = String} , {RecordField "no", caption = "no", domain = int} , {RecordField "goal", caption = "goal", domain = int} , {RecordField "assist", caption = "assist", domain = int} } } } {let server_url = "http://localhost:3000/"} {define-proc public {add-rec id: int, name: String, team: String, no: int, goal: int, assist: int }: void let new-rec: Record = {players.new-record} {new-rec.set "id", id} {new-rec.set "name", name} {new-rec.set "team", team} {new-rec.set "no", no} {new-rec.set "goal", goal} {new-rec.set "assist", assist} {players.append new-rec} } {define-proc public {get-plyaers-list}:void {let playersJsonValue: JsonValue = {JsonValue-parse {url server_url & "players.json"} } } {players.delete-all} {for player: JsonObject in playersJsonValue do let found?: bool = false let id: int = 0 let name: String = {String} let team: String = {String} let no: int = 0 let goal: int = 0 let assist: int = 0 set (id, found?) = {player.get-if-exists "id"} set (name, found?) = {player.get-if-exists "name"} set (team, found?) = {player.get-if-exists "team"} set (no, found?) = {player.get-if-exists "no"} set (goal, found?) = {player.get-if-exists "goal"} set (assist, found?) = {player.get-if-exists "assist"} {if found? then {add-rec id, name, team, no, goal, assist} } } } {let data_list: RecordGrid = {RecordGrid width = 16cm, height = 6cm, record-source = players, {RecordGridColumn "id", width = 0}, {RecordGridColumn "name", width = 5cm}, {RecordGridColumn "team", width = 2cm}, {RecordGridColumn "no", width = 2cm}, {RecordGridColumn "goal", width = 3cm, halign = "right"}, {RecordGridColumn "assist", width = 3cm, halign = "right"} } } {get-plyaers-list} {value {spaced-vbox data_list } }
app/controllers/players_controller.rb
このテンプレートを表示するためのメソッドをコントロラーに追加します。Curlのコードを表示するので、前回のchart表示メソッドと似たコードになります。
class PlayersController < ApplicationController def start render :layout => false, :content_type => 'text/vnd.curl' end ・・・ 以下省略 ・・・
config/route.rb
前回同様にURLとControllerの対応を記述したroute.rbの定義にstartメソッドを追加します。
ActionController::Routing::Routes.draw do |map| map.resources :players, :collection => { :chart => :get, :start => :get } ・・・ 以下省略 ・・・
ここでサーバを再起動し、http://localhost:3000/players/startをアクセスすると以下のようなデータ一覧が表示されます。
トップページ
Ruby on Railsではトップページ(http://localhost:3000 /)がアクセスされた場合、public/index.htmlファイルが表示ますが、これもconfig/route.rbファイルで設定できます、今回は「http://localhost:3000/players/start」をトップページにしましょう。
このとき、public/index.htmlファイルを削除する必要があります。
ActionController::Routing::Routes.draw do |map| map.resources :players, :collection => { :chart => :get, :start => :get } map.root :controller => "players", :action => "start" # ← 追加 ・・・ 以下省略 ・・・
データの変更
データ一覧(RecordGrid)上で選択したレコードの内容を変更するプログラムを書いていきましょう。これからはCurlのプログラミングになります。
Curl言語の文法やAPI仕様は開発者ガイドやCurl IDEのヘルプが役にたちます。また画面の設計はCurl IDE使うと、Visual Basicのように簡単に作る事ができます。
変更画面
今回は、1ページ目の画像のように、一覧の下にボタンを置き、変更ボタンが押された場合、データ一覧の選択されたレコードをボタンの下の編集フォームに表示します。
このフォームにあるDoneボタンを押すとフォームのデータがサーバに送られデータが変更されます。データをサーバに送った後でデータ一覧は再読込しています。
画面のレイアウトは以下のように、データ一覧、ボタン、編集フォームが並びます。ただし、編集フォームは最初は表示されないように visible? = false に設定しています。
{let data_form = {spaced-vbox visible? = false, data_http_form } } {value {spaced-vbox data_list, command_buttons, data_form } }
変更ボタン
変更(edit)ボタンが押されると、データ一覧が選択されているかチェックし、選択されていない場合はメッセージを表示します。
選択されている場合は複数選択された場合にそなえ、最初に選択されたデータの内容をset-update-form関数に渡して編集フォームを表示します。set-update-form関数については後述します。
{let command_buttons = {spaced-hbox {CommandButton label = "edit", {on Action do {if data_list.selection.record-count == 0 then {popup-message "No player selected"} else {for i:int in data_list.selection.records do {set-update-form data_list.records[i]} {break} } } } } } }
編集フォーム
編集フォームはサーバ側にデータを渡す必要があるので、 HttpFormを使って作っています。HttpForm上にあるTextField等の値はサーバ側に送る事ができます。HTMLのformと似ていますね。
HttpForm上には選手名、チーム名などの入力フィールドが並び、1番下に完了(Done)ボタンがあります。完了ボタンが押された場合、submit-openメソッドでTextField等の入力値をサーバ側にPOSTし、結果を受け取ってresponse変数に代入します。
submit-openの処理をwith-open-streamsマクロでかこむことで通信エラーなどがあってもサーバとの通信を確実に閉じるようにしています。
{let form_text_method:TextField = {TextField name = "_method", height = 0cm, width = 0cm}} {let form_text_name:TextField = {TextField name = "player[name]", width = 5cm}} {let form_text_team:TextField = {TextField name = "player[team]", width = 5cm}} {let form_text_no:TextField = {TextField name = "player[no]", width = 5cm}} {let form_text_goal:TextField = {TextField name = "player[goal]", width = 5cm}} {let form_text_assist:TextField = {TextField name = "player[assist]", width = 5cm}} {let data_http_form:HttpForm = {HttpForm {url server_url}, method = HttpRequestMethod.post, encoding = HttpFormData.urlencoded-mime-type, default-character-encoding = "utf8", {spaced-vbox margin = 5pt, form_text_method, {HBox {TextFlowBox "Name", width = 1.5cm}, form_text_name}, {HBox {TextFlowBox "Team", width = 1.5cm}, form_text_team}, {HBox {TextFlowBox "No", width = 1.5cm}, form_text_no}, {HBox {TextFlowBox "Goal", width = 1.5cm}, form_text_goal}, {HBox {TextFlowBox "Asssit", width = 1.5cm}, form_text_assist}, {CommandButton label = "Done", {on Action do {try {with-open-streams response:#TextInputStream = {data_http_form.submit-open character-encoding={get-character-encoding-by-name "utf8"}} do {if-non-null response then {set data_form.visible? = false} } } catch e:Exception do {popup-message "Error :" & e.message} } {get-plyaers-list} } } } } }
変更ボタンから呼び出されるset-update-form関数ですが、引数で与えられたレコード値を編集フォームの値に代入し、編集フォームの表示を有効にしています。
またデータの更新用にメソッドを put に URL を http://localhost:3000/players/957451028.json のように変更するデータを表すURLを設定しています。
{define-proc public {set-update-form rec: Record }:void {set form_text_method.value = "put"} {set form_text_name.value = rec["name"]} {set form_text_team.value = rec["team"]} {set form_text_no.value = "" & rec["no"]} {set form_text_goal.value = "" & rec["goal"]} {set form_text_assist.value = "" & rec["assist"]} {set data_http_form.form-action = {url server_url & "players/" & rec["id"] & ".json"}} {set data_form.visible? = true} }
データの新規作成
データの新規作成は、空の編集フォームを表示し、完了ボタンを押した際にはサーバへデータをポストしています。
{CommandButton label = "new", {on Action do {set-create-form} } },
新規作成フォーム設定関数set-create-formは、入力欄の値を空や0に設定し、メソッドをpostに、URLを http://localhost:3000/players.jsonのように設定しています。
{define-proc public {set-create-form }:void {set form_text_method.value = "post"} {set form_text_name.value = ""} {set form_text_team.value = ""} {set form_text_no.value = "1"} {set form_text_goal.value = "0"} {set form_text_assist.value = "0"} {set data_http_form.form-action = {url server_url & "players.json"}} {set data_form.visible? = true} }
データの削除
データの削除は、データの選択を確認しHttpFormを通じデータ削除を呼び出します。
{CommandButton label = "delete", {on Action do {if data_list.selection.record-count == 0 then {popup-message "No player selected"} else {if {popup-question "Are you sure?"} == Dialog.yes then {for i:int in data_list.selection.records do {set-destory-form data_list.records[i]} {break} } } } } }
データ削除関数set-destory-formは、メソッドをdeleteに、URLには http://localhost:3000/players/957451028.jsonのように削除するデータのURLを設定し、submit-openメソッドで削除命令をサーバに送っています。
{define-proc public {set-destory-form rec: Record }:void {set form_text_method.value = "delete"} {set data_http_form.form-action = {url server_url & "players/" & rec["id"] & ".json"}} {try {with-open-streams response:#TextInputStream = {data_http_form.submit-open character-encoding={get-character-encoding-by-name "utf8"}} do } catch e:Exception do {popup-message "Error :" & e.message} } {get-plyaers-list} }
まとめ
今回は、Scaffoldの機能を持つリッチクライアントをCurlで作成してみました。 サーバ側はほんの少しの改造を行ったのみで、Ruby on Railsがいかにリッチクライアント向きかが感じて頂けたかと思います。
Curlのコード全体は以下のようになります。
{curl 7.0 applet} {curl-file-attributes character-encoding = "utf8"} {import * from CURL.IO.JSON} {let players: RecordSet = {RecordSet {RecordFields {RecordField "id", caption = "id", domain = int} , {RecordField "name", caption = "name", domain = String} , {RecordField "team", caption = "team", domain = String} , {RecordField "no", caption = "no", domain = int} , {RecordField "goal", caption = "goal", domain = int} , {RecordField "assist", caption = "assist", domain = int} } } } {let server_url = "http://localhost:3000/"} {define-proc public {add-rec id: int, name: String, team: String, no: int, goal: int, assist: int }: void let new-rec: Record = {players.new-record} {new-rec.set "id", id} {new-rec.set "name", name} {new-rec.set "team", team} {new-rec.set "no", no} {new-rec.set "goal", goal} {new-rec.set "assist", assist} {players.append new-rec} } {define-proc public {get-plyaers-list}:void {let playersJsonValue: JsonValue = {JsonValue-parse {url server_url & "players.json"} } } {players.delete-all} {for player: JsonObject in playersJsonValue do let found?: bool = false let id: int = 0 let name: String = {String} let team: String = {String} let no: int = 0 let goal: int = 0 let assist: int = 0 set (id, found?) = {player.get-if-exists "id"} set (name, found?) = {player.get-if-exists "name"} set (team, found?) = {player.get-if-exists "team"} set (no, found?) = {player.get-if-exists "no"} set (goal, found?) = {player.get-if-exists "goal"} set (assist, found?) = {player.get-if-exists "assist"} {if found? then {add-rec id, name, team, no, goal, assist} } } } {define-proc public {set-update-form rec: Record }:void {set form_text_method.value = "put"} {set form_text_name.value = rec["name"]} {set form_text_team.value = rec["team"]} {set form_text_no.value = "" & rec["no"]} {set form_text_goal.value = "" & rec["goal"]} {set form_text_assist.value = "" & rec["assist"]} {set data_http_form.form-action = {url server_url & "players/" & rec["id"] & ".json"}} {set data_form.visible? = true} } {define-proc public {set-create-form }:void {set form_text_method.value = "post"} {set form_text_name.value = ""} {set form_text_team.value = ""} {set form_text_no.value = "1"} {set form_text_goal.value = "0"} {set form_text_assist.value = "0"} {set data_http_form.form-action = {url server_url & "players.json"}} {set data_form.visible? = true} } {define-proc public {set-destory-form rec: Record }:void {set form_text_method.value = "delete"} {set data_http_form.form-action = {url server_url & "players/" & rec["id"] & ".json"}} {try {with-open-streams response:#TextInputStream = {data_http_form.submit-open character-encoding={get-character-encoding-by-name "utf8"}} do } catch e:Exception do {popup-message "Error :" & e.message} } {get-plyaers-list} } {let data_list: RecordGrid = {RecordGrid width = 16cm, height = 6cm, record-source = players, {RecordGridColumn "id", width = 0}, {RecordGridColumn "name", width = 5cm}, {RecordGridColumn "team", width = 2cm}, {RecordGridColumn "no", width = 2cm}, {RecordGridColumn "goal", width = 3cm, halign = "right"}, {RecordGridColumn "assist", width = 3cm, halign = "right"} } } {let form_text_method:TextField = {TextField name = "_method", height = 0cm, width = 0cm}} {let form_text_name:TextField = {TextField name = "player[name]", width = 5cm}} {let form_text_team:TextField = {TextField name = "player[team]", width = 5cm}} {let form_text_no:TextField = {TextField name = "player[no]", width = 5cm}} {let form_text_goal:TextField = {TextField name = "player[goal]", width = 5cm}} {let form_text_assist:TextField = {TextField name = "player[assist]", width = 5cm}} {let data_http_form:HttpForm = {HttpForm {url server_url}, method = HttpRequestMethod.post, encoding = HttpFormData.urlencoded-mime-type, default-character-encoding = "utf8", {spaced-vbox margin = 5pt, form_text_method, {HBox {TextFlowBox "Name", width = 1.5cm}, form_text_name}, {HBox {TextFlowBox "Team", width = 1.5cm}, form_text_team}, {HBox {TextFlowBox "No", width = 1.5cm}, form_text_no}, {HBox {TextFlowBox "Goal", width = 1.5cm}, form_text_goal}, {HBox {TextFlowBox "Asssit", width = 1.5cm}, form_text_assist}, {CommandButton label = "Done", {on Action do {try {with-open-streams response:#TextInputStream = {data_http_form.submit-open character-encoding={get-character-encoding-by-name "utf8"}} do {if-non-null response then {set data_form.visible? = false} } } catch e:Exception do {popup-message "Error :" & e.message} } {get-plyaers-list} } } } } } {let data_form = {spaced-vbox visible? = false, data_http_form } } {let command_buttons = {spaced-hbox {CommandButton label = "new", {on Action do {set-create-form} } }, {CommandButton label = "edit", {on Action do {if data_list.selection.record-count == 0 then {popup-message "No player selected"} else {for i:int in data_list.selection.records do {set-update-form data_list.records[i]} {break} } } } }, {CommandButton label = "delete", {on Action do {if data_list.selection.record-count == 0 then {popup-message "No player selected"} else {if {popup-question "Are you sure?"} == Dialog.yes then {for i:int in data_list.selection.records do {set-destory-form data_list.records[i]} {break} } } } } } } } {get-plyaers-list} {value {spaced-vbox data_list, command_buttons, data_form } }