Google App Engine + Silverlight でタスク管理アプリケーション開発(3)

前回までの内容

Google App Engine(以下 GAE) + Silverlight でアプリを作ってやろうという本連載。前回でひとまず、GAE/Python でのサービス開発は終了。なんちゃって RESTful な Web API に仕上がりました。

今回から、そのサービスを呼び出す Silverlight クライアントを実装していきます。ここから長いです。

Silverlight プロジェクトを作成

まず Silverlight プロジェクトを作成します。名前は SilverTask。

プロジェクトフォルダは GAE/Python で作成した silvertask-web フォルダと同じディレクトリに配置します。

下図のように、SilverTask ソリューションフォルダ内に、Silverlight プロジェクトフォルダと、GAE/Python プロジェクトフォルダを配置する形になります。
f:id:griefworker:20100820203917j:image

モデルを作成

GAE/Python のモデルから、C# のクラスを生成します。以前作った Kay Framework の管理スクリプトの出番です。

スクリプトの使い方は上の記事を読んでもらうとして、生成したコードがこちら。

namespace Core.Models
{
    [System.Runtime.Serialization.DataContract]
    public partial class Task
    {
        public Task() { OnCreated(); }
        [System.Runtime.Serialization.DataMember(Name="key")]
        public string Key { get;set; }
        [System.Runtime.Serialization.DataMember(Name="created")]
        public System.DateTime Created { get;set; }
        [System.Runtime.Serialization.DataMember(Name="done")]
        public bool Done { get;set; }
        [System.Runtime.Serialization.DataMember(Name="name")]
        public string Name { get;set; }
        [System.Runtime.Serialization.DataMember(Name="user")]
        public string User { get;set; }
        partial void OnCreated();
    }

    public static partial class TaskMeta
    {
        public static string Key { get { return "key"; } }
        public static string Created { get { return "created"; } }
        public static string Done { get { return "done"; } }
        public static string Name { get { return "name"; } }
        public static string User { get { return "user"; } }
    }
}

メタクラスは Web API を叩いて取得した JSON からデータを取り出すときに使います。DataContractJsonSerializer を使うかもしれないので、データコントラクトにしています。使わないかもしれないけど。

JSON からモデルにデータを詰めるメソッドや、モデルを JSON にするメソッドを追加します。これらは Web API を呼び出すクラスで利用します。

using System;
using System.Json;

namespace Core.Models
{
    /// <summary>
    /// タスクを表します。
    /// </summary>
    public partial class Task
    {
        partial void OnCreated()
        {
            Created = DateTime.Now;
        }

        /// <summary>
        /// <see cref="Task"/> クラスの新しいインスタンスを初期化します。
        /// </summary>
        /// <param name="json">初期化に使う JSON オブジェクト。</param>
        public Task(JsonValue json)
            : this()
        {
            this.Key = json[TaskMeta.Key];
            this.User = json[TaskMeta.User];
            this.Name = json[TaskMeta.Name];
            this.Done = json[TaskMeta.Done];
            DateTime created;
            if (DateTime.TryParse(json[TaskMeta.Done].ToString(), out created))
            {
                this.Created = created;
            }
        }

        /// <summary>
        /// 現在のインスタンスのクローンを作成します。
        /// </summary>
        /// <returns>現在のインスタンスのクローン。</returns>
        public Task Clone()
        {
            return new Task()
            {
                Key = this.Key,
                Name = this.Name,
                Done = this.Done,
                Created = this.Created,
                User = this.User,
            };
        }

        /// <summary>
        /// JSON 文字列に変換します。
        /// </summary>
        /// <returns>JSON 文字列。</returns>
        public string ToJsonString()
        {
            var obj = new JsonObject(DateTimeOffset.Now);
            obj.Add(TaskMeta.Name, (JsonValue)Name);
            obj.Add(TaskMeta.Done, (JsonValue)Done);
            return obj.ToString();
        }
    }
}

JsonValue は暗黙的な型変換を提供しているので、C# の基本的なクラスだったら、たいてい、そのまま代入できます。コンバートする必要なし。

Web API を呼び出すクラスを作成

GAE/Python で実装した Web API を、タスクが保存されているリポジトリとみなします。リポジトリパターンってやつです。

テストがしやすくなるように、インタフェースと実装を分けます。こうしておけば、ダミーの実装を使ってビューやビューモデルのテストができるので。

インタフェースの定義がこちら。

using System;
using Core.Models;

namespace SilverTask.Models
{
    public interface ITaskRepository
    {
        /// <summary>
        /// タスクを取得します。
        /// </summary>
        /// <param name="completed">完了済みを取得するとき true。未完了を取得するとき false。</param>
        /// <param name="callback">コールバック。</param>
        void ReadTask(bool completed, Action<TaskResult> callback);

        /// <summary>
        /// タスクを作成します。
        /// </summary>
        /// <param name="task">作成するタスク。</param>
        /// <param name="callback">コールバック。</param>
        void CreateTask(Task task, Action<TaskResult> callback);

        /// <summary>
        /// タスクを削除します。
        /// </summary>
        /// <param name="tasks">削除するタスク。</param>
        /// <param name="callback">コールバック。</param>
        void DeleteTask(Task tasks, Action<TaskResult> callback);

        /// <summary>
        /// タスクを更新します。
        /// </summary>
        /// <param name="tasks">更新するタスク。</param>
        /// <param name="callback">コールバック。</param>
        void UpdateTask(Task tasks, Action<TaskResult> callback);
    }

    /// <summary>
    /// リポジトリ操作の結果を格納します。
    /// </summary>
    public class TaskResult
    {
        /// <summary>
        /// タスクの一覧を取得します。
        /// </summary>
        public Task[] Tasks { get; private set; }

        /// <summary>
        /// キャンセルされたかどうか示す値を取得します。
        /// </summary>
        public bool Cancelled { get; private set; }

        /// <summary>
        /// 発生したエラーを取得します。
        /// </summary>
        public Exception Error { get; private set; }

        /// <summary>
        /// <see cref="TaskResult"/> クラスの新しいインスタンスを初期化します。
        /// </summary>
        /// <param name="tasks">タスク。</param>
        /// <param name="cancelled">キャンセルされたかどうか。</param>
        /// <param name="error">発生したエラー。</param>
        public TaskResult(Task[] tasks, bool cancelled = false, Exception error = null)
        {
            this.Tasks = tasks;
            this.Cancelled = cancelled;
            this.Error = error;
        }
    }
}

Silverlight の非同期サービス呼び出しは結果をイベントで受け取るのがほとんどですけど、今回はコールバックで受け取るようにしています。個人的に、コールバックの方が使いやすいので。

そして実装。

using System;
using System.Json;
using System.Linq;
using System.Net;
using System.Text;
using System.Windows.Browser;
using Core.Models;

namespace SilverTask.Models
{
    public partial class TaskRepository : ITaskRepository
    {
        /// <summary>
        /// サービスのベースアドレス。
        /// </summary>
        private static readonly string _BaseAddress;

        static TaskRepository()
        {
            _BaseAddress = HtmlPage.Document.DocumentUri.ToString();
        }

        void ITaskRepository.ReadTask(bool completed, Action<TaskResult> callback)
        {
            string query = _BaseAddress + string.Format(
                "tasks?{0}={1}",
                TaskMeta.Done,
                completed);

            var client = new WebClient();
            client.DownloadStringCompleted += (sender, e) =>
            {
                if (null != e.Error)
                {
                    callback(new TaskResult(new Task[0], e.Cancelled, e.Error));
                }
                else
                {
                    var tasks = from obj in (JsonArray)JsonArray.Parse(e.Result)
                                select new Task(obj);
                    callback(new TaskResult(tasks.ToArray()));
                }
            };
            client.DownloadStringAsync(new Uri(query));
        }

        void ITaskRepository.CreateTask(Task task, Action<TaskResult> callback)
        {
            string query = _BaseAddress + "tasks";
            string parameters = string.Format(
                "{0}={1}",
                TaskMeta.Name,
                HttpUtility.UrlEncode(task.Name));

            var client = new WebClient();
            client.UploadStringCompleted += (sender, e) =>
            {
                if (null != e.Error)
                {
                    callback(new TaskResult(new Task[0], e.Cancelled, e.Error));
                }
                else
                {
                    var result = new Task(JsonObject.Parse(e.Result));
                    callback(new TaskResult(new Task[] { result }));
                }
            };
            client.UploadStringAsync(new Uri(query), parameters);
        }

        void ITaskRepository.DeleteTask(Task task, Action<TaskResult> callback)
        {
            string uri = _BaseAddress + "tasks/" + task.Key;
            var request = WebRequest.CreateHttp(uri);
            request.Method = "DELETE";
            request.ContentType = "application/json";
            request.BeginGetResponse(result =>
            {
                var response = (HttpWebResponse)request.EndGetResponse(result);
                if (response.StatusCode != HttpStatusCode.OK)
                {
                    var error = new WebException(response.StatusDescription);
                    callback(new TaskResult(new Task[0], error: error));
                }
                else
                {
                    callback(new TaskResult(new Task[] { task.Clone() }));
                }
            }, null);
        }

        void ITaskRepository.UpdateTask(Task task, Action<TaskResult> callback)
        {
            string uri = _BaseAddress + "tasks/" + task.Key;
            byte[] bytes = Encoding.UTF8.GetBytes(task.ToJsonString());

            var request = WebRequest.CreateHttp(uri);
            request.Method = "PUT";
            request.ContentType = "application/json";
            request.ContentLength = bytes.Length;
            request.BeginGetRequestStream(result =>
            {
                using (var stream = request.EndGetRequestStream(result))
                {
                    stream.Write(bytes, 0, bytes.Length);
                }

                request.BeginGetResponse(result2 =>
                {
                    var response = (HttpWebResponse)request.EndGetResponse(result2);
                    if (response.StatusCode != HttpStatusCode.OK)
                    {
                        var error = new WebException(response.StatusDescription);
                        callback(new TaskResult(new Task[0], error: error));
                    }
                    else
                    {
                        callback(new TaskResult(new Task[] { task.Clone() }));
                    }
                }, null);
            }, null);
        }
    }
}

WebClient には PUT や DELETE に対応するメソッドが無いので、更新と削除は HttpWebRequest を使用しています。WebCliet に用意してくれればいいのに。

今回はここまで

変更内容の詳細は下記URLで確認できます。

今回のコードは、WebRequest や WebClient を使って RESTful なサービスの API を呼び出すサンプルにするといいです。PUT や DELETE を使った例はほどんど見かけないから、意外と貴重かも。

次は ViewModel を実装する予定です。