SproutCore で JAX-RS(Jersey) と連携させる方法

SproutCore の ToDo チュートリアルを元に、JAX-RS(Jersey 使用)と連携させてみた。
ちなみに SproutCoreJavaScript 用のフレームワークRuby 上で実行やビルド(HTML や JavaScript を生成)を実施し、Rails と同様の感覚で JavaScript の Web アプリケーションを作成できる点が特徴。

使用した環境は以下。

なお、実際は SproutCore の sc-build コマンドで生成した HTML や JavaScript を Jersey のリソースクラス等と共に WAR ファイル化してデプロイするような流れになると思うが、今回はそこまではやらず、連携方法を確認するまでに止めている。

インストールとプロジェクトの作成

SproutCoreRubyGems でインストールする。

>gem install sproutcore

インストールを行うと sc-xxx コマンドが使えるようになるので、
sc-init コマンドでプロジェクトを新規作成。

プロジェクト作成例
>sc-init test_app

作成したプロジェクトのディレクトリに移動し、sc-server コマンドを実行すると 簡易Web サーバー(WEBrick)が起動するので、http://localhost:4020/[プロジェクト名] に Web ブラウザでアクセスすれば、デフォルトのトップページが表示される。

サーバー起動例

以下のコマンドを実行して http://localhost:4020/test_app にアクセスすると Welcome to SproutCore! のページが表示される。

>cd test_app
>sc-server

Model の作成

それでは、中身を作りこんでいく。
まず、Model を sc-gen model を使って作成。

Model は View に表示したり、バックエンド(今回は Jersey)との送受信に使用される。

>sc-gen model TestApp.Task

apps/[プロジェクト名]/models/[モデル名].js ファイルが作成されるので、このファイルにプロパティを定義していく。

今回は、title という String 型のプロパティを 1つ定義した。

apps/test_app/models/task.js
TestApp.Task = SC.Record.extend(
/** @scope TestApp.Task.prototype */ {
 title: SC.Record.attr(String)
}) ;

Controller の作成

次に、Controller を sc-gen controller を使って作成する。

今回は、ListView に表示するために ArrayController を拡張した TasksController を作成し、ToDo タスク追加時(Add Task ボタン押下時)の処理を実装した。

なお、チュートリアルのサンプルのように自動的に編集モードには移行していない

>sc-gen controller TestApp.TasksController SC.ArrayController
apps/test_app/controllers/tasks.js
TestApp.tasksController = SC.ArrayController.create(
/** @scope TestApp.tasksController.prototype */ {
    //タスク追加時の処理
    addTask: function() {
        //'New Task' という title の Task を作成し追加する
        var task = TestApp.store.createRecord(TestApp.Task, {"title": "New Task"});
        //作成した Task を選択
        this.selectObject(task);
        return YES;
    }
}) ;

View の作成

次に、View を作成する。

今回はメインページの UI を変更する事にするので、新たな View の生成は行わず、既存の apps/[プロジェクト名]/resources/main_page.js ファイル(プロジェクト作成時に生成される)を変更して、タスク追加ボタンとタスクのリスト表示を配置した。

実装内容は以下の通り。

  • ボタン押下時に tasksController の addTask メソッドを実行するため、SC.ButtonView.design の target と action に設定
  • ListView にタスク一覧を表示するため、SC.ListView.design の contentBinding に表示対象のコンテンツ、selectionBinding に選択時のコンテンツのバインディングを設定
  • SC.ListView.design の contentValueKey に表示対象のプロパティ(Task Model の title)を設定
  • SC.ListView.design の canEditContent に YES を設定し編集を有効化

なお、tasksController の arrangedObjects や selection は SC.ArrayController で定義されているので、別途実装する必要は無い。

apps/test_app/resources/main_page.js
TestApp.mainPage = SC.Page.design({
  mainPane: SC.MainPane.design({
    childViews: 'topView listView'.w(),
    //トップメニュー
    topView: SC.ToolbarView.design({
        layout: {top: 0, left: 0, right: 0, height: 36},
        anchorLocation: SC.ANCHOR_TOP,
        childViews: 'labelView addButton'.w(),

        labelView: SC.LabelView.design({
            layout: { centerX: 0, centerY: 0, width: 200, height: 24 },
            textAlign: SC.ALIGN_CENTER,
            tagName: "h1", 
            value: "ToDo Sample"
        }),
        addButton: SC.ButtonView.design({
            layout: {centerY: 0, width: 100, height: 24, right: 60},
            title: "Add Task",
            //ボタン押下時の実行対象 Controller の設定
            target: "TestApp.tasksController",
            //ボタン押下時の実行メソッドの設定
            action: "addTask"
        })
    }),
    //メインボディ
    listView: SC.ScrollView.design({
        layout: {top: 36, bottom: 10, left:0, right: 0},
        backgroundColor: 'white',
        //リスト表示
        contentView: SC.ListView.design({
            //リスト表示対象のバインディング設定
            contentBinding: 'TestApp.tasksController.arrangedObjects',
            //選択箇所のバインディング
            selectionBinding: 'TestApp.tasksController.selection',
            //表示データのプロパティ名
            contentValueKey: 'title',
            //編集を有効化
            canEditContent: YES
        })
    })
  })
});

DataSource の作成

Jersey と連携させるために、sc-gen data_source を使って DataSource を作成する。
作成された DataSource にバックエンドとの CRUD 処理を実装していく事になる。(DataSource を切り替えれば、バックエンドが切り替わるようになっている)

>sc-gen data_source TestApp.JerseyDataSource

今回は、とりあえず fetch(一覧取得)、createRecord(作成)、updateRecord(更新) を実装する事にした。

なお、チュートリアルのサンプルでは、notify に渡すメソッドを別途定義していたが、簡単に実装するためにクロージャーを使った。(不要な引数も省いているので注意)

処理の概要は以下の通り。

  • /tasks への GET でタスクの一覧を JSON データで取得
  • /tasks への JSON データ POST でタスクを新規追加
  • /tasks/[番号] への JSON データ PUT でタスクの更新

なお、新規作成に関してはバックエンド(Jersey)側の処理で HTTP Status が 204 で Location ヘッダーが返ってくるように実装する必要がある。

apps/test_app/data_sources/jersey.js
TestApp.JerseyDataSource = SC.DataSource.extend(
/** @scope TestApp2.JerseyDataSource.prototype */ {
  //一覧取得
  fetch: function(store, query) {
    SC.Request.getUrl("/tasks").json().notify(this, function(res) {
        if (SC.ok(res)) {
            //JSON データを取得するには res.get('body') を使う
            store.loadRecords(TestApp.Task, res.get('body').task);
            store.dataSourceDidFetchQuery(query);
        }
        else {
            store.dataSourceDidErrorQuery(query, res);
        }
    }).send();

    return YES;
  },
  retrieveRecord: function(store, storeKey) {
    return NO ; // return YES if you handled the storeKey
  },
  //新規作成
  createRecord: function(store, storeKey) {
    var obj = store.readDataHash(storeKey);

    SC.Request.postUrl("/tasks").json().notify(this, function(res) {
        if (SC.ok(res)) {
            var url = res.header('Location');
            var paths = url.split("/");

            //Location の内容(FQDN)から新規作成したデータの
            //インデックス番号を取りだして設定
            store.dataSourceDidComplete(storeKey, null, paths[paths.length - 1]);
        }
        else {
            store.dataSourceDidError(storeKey, res);
        }
    }).send(obj);

    return YES;
  },
  //更新
  updateRecord: function(store, storeKey) {
    var obj = store.readDataHash(storeKey);

    SC.Request.putUrl("/tasks/" + store.idFor(storeKey)).json().notify(this, function(res) {
        if (SC.ok(res)) {
            store.dataSourceDidComplete(storeKey);
        }
        else {
            store.dataSourceDidError(storeKey);
        }
    }).send(obj);

    return YES;
  },
  destroyRecord: function(store, storeKey) {
    return NO ; // return YES if you handled the storeKey
  }
}) ;

アプリケーション設定の変更

SproutCore 側での作業の最後にアプリケーションの設定を変更する。

まず、core.js に作成した DataSource(TestApp.JerseyDataSource)を使うための設定(store の値を変更)を行う。
commitRecordsAutomatically を YES にすることで、編集確定後などに自動的にバックエンドにリクエストを送信するようになる。

apps/test_app/core.js
TestApp = SC.Application.create(
  /** @scope TestApp2.prototype */ {
  NAMESPACE: 'TestApp',
  VERSION: '0.1.0',
  //JerseyDataSource への切り替え
  store: SC.Store.create({ 
    commitRecordsAutomatically: YES
  }).from('TestApp.JerseyDataSource')
}) ;

次に、main.js に tasksController へ初期コンテンツを設定する処理を追加する。

apps/test_app/main.js
TestApp.main = function main() {
  TestApp.getPath('mainPage.mainPane').append() ;
  //リスト表示するデータの設定
  TestApp.tasksController.set('content', TestApp.store.find(TestApp.Task));
} ;
function main() { TestApp.main(); }

最後に、バックエンド側にリクエストが投げられるようにするため Buildfile に proxy 設定を追加する。
今回は /tasks へのアクセスが localhost:8082 に投げられるように設定を変更した。

Buildfile
config :all, :required => :sproutcore
proxy "/tasks", :to => "localhost:8082"

バックエンド(Jersey)の作成

Jersey を使ったバックエンドを作成する。

今回は、Scala で実装している。(特に深い理由は無し)
タスクの作成時には Response、更新時には Task を返しているところがポイント。
なお、Buildfile の proxy に設定した通り、http://localhost:8082/tasks でアクセスできるように定義している。

task_test.scala
import scala.reflect.BeanProperty
import scala.collection.mutable.ListBuffer

import javax.ws.rs.{GET, POST, PUT, Path, PathParam, Consumes, Produces}
import javax.ws.rs.core.{Response, UriBuilder}

import javax.xml.bind.annotation.XmlRootElement

import com.sun.jersey.api.core.ClassNamesResourceConfig
import com.sun.jersey.api.container.httpserver.HttpServerFactory
import com.sun.jersey.api.container.ContainerFactory
import com.sun.jersey.spi.resource.Singleton

@XmlRootElement
class Task(@BeanProperty var guid: String, @BeanProperty var title: String) {
    def this() = this("", "")
}

@Path("tasks")
@Singleton
class TaskResource {
    val taskList = new ListBuffer[Task]

    //タスク作成
    @POST
    @Consumes(Array("application/json"))
    def createTask(task: Task): Response = {
        task.guid = taskList.length.toString
        taskList += task

        val uri = UriBuilder.fromPath("{index}").build(task.guid)
        return Response.created(uri).build()
    }

    //タスク更新
    @PUT
    @Path("{index}")
    @Produces(Array("application/json"))
    def updateTask(@PathParam("index") index: Int, task: Task): Task = {
        val t = taskList(index)
        t.title = task.title
        t
    }

    //全タスク取得
    @GET
    @Produces(Array("application/json"))
    def getTaskList(): Array[Task] = {
        taskList.toArray
    }
}

object TaskTest extends Application {
    val server = HttpServerFactory.create("http://localhost:8082/", new ClassNamesResourceConfig(classOf[TaskResource]))

    server.start()

    println("server start ...")
}

動作確認

まず、バックエンドを実行しておく。(Scala での Jersey 実行は id:fits:20091116 参照)

バックエンドの実行
>scala TaskTest
2009/11/26 21:59:55 com.sun.jersey.server.impl.application.WebApplicationImpl initiate
情報: Initiating Jersey application, version 'Jersey: 1.1.4 11/10/2009 05:36 PM'・・・

次に sc-server で SproutCore を起動し、Web ブラウザで http://localhost:4020/test_app にアクセス。
Add Task ボタンでタスク追加、タスク編集(ダブルクリックで編集状態になる)を実施し、リクエストがバックエンドの Jersey アプリケーションに送信されることを確認する。

SproutCore アプリケーション起動
>cd test_app
>sc-server
・・・