CodeZine(コードジン)

特集ページ一覧

Ruby on Rails + Curl
リッチクライアントCRUDアプリを作成する

第2回

  • ブックマーク
  • LINEで送る
  • このエントリーをはてなブックマークに追加
2009/06/30 15:00

 Ruby on Railsをサーバーサイドに使い、ビューにCurlを使ったRIAアプリケーションの開発です。今回はリッチクライアント版のCRUDアプリを作成します。

はじめに

 前回は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
    }
}
  • ブックマーク
  • LINEで送る
  • このエントリーをはてなブックマークに追加

著者プロフィール

  • 吉田裕美(ヨシダユウミ)

    有限会社 EY-Office 取締役 CADのベンチャー企業でCADのコア部分や図面管理システムなどの開発に従事した後、独立しJava,Ruby,PerlでWebアプリを中心に開発してきた。現在は殆どの開発はRuby on Rails。 ここ数年はソフトウェアエンジニアの教育に興味をもち、従来の...

All contents copyright © 2005-2020 Shoeisha Co., Ltd. All rights reserved. ver.1.5