はじめての Silverlight

なんとなく、最近 WPF/MVVM の話とか、Windows Azure の話とかをしていて、Silverlight に手をだしてみようかという気になったので、ちょっと手をだしてみた。
Windows Azure の Web Role では、長時間のコネクションがロードバランサによって切断されるため動画のような大きなファイルを直接アップロードできないという問題がある。この対応としては、Blob Storage の SAS という機能を利用して、指定した URL に一時的にアップロードできる権限を与えて Blob Storage へのアップロードを行うという方法がとられる。というわけで、その処理を行うコンポーネントを作成してみようかな?と。

  • パラメータで指定した URI へ PUT によるアップロードを行う
  • Blob Storage のブロック分割に対応する
  • INPUT タグと似たような見た目にして、他の HTML 要素に溶け込むようにする
  • でも、アップロード進捗やエラー表示などは、HTML じゃできないこともやろう
  • 背景を透明して background-image 等が見えるように
  • MVVM っぽい構成で作ってみる
  • Azure 以外へもアップロードできるように、サーバ側も作りたいな

みたいなかんじで、テキトーに作ってみました。
http://twitpic.com/4j9fp1/full

全体的に、XAML の記述がいまいちわかってないんだな、と実感しましたね。あとは、概ねすらすらと進んだ気はします。

MVVM の練習……は、いまいちだったきがします。というのも

  1. ViewModel を作成するための ViewModelBase クラスを作成*1
  2. ICommand の実装として RelayCommand の作成
  3. ViewModel クラスの作成
  4. ViewModel XAML の作成
  5. View の作成

といったかんじで作業をしていて、モデル部分が作成されなかったので、名前だけ ViewModel とついた Model に対して View を直結した構成というオチに。こういう小さな部品に対して行き当たりばったりで作るからそんなことなっちゃうんだよーってかんじです。

いまいち見せられるところがないものになってしまったので、再利用できそうな2クラスだけだらーっと載せておくことにする。

まずは、ファイル名の横にファイルサイズを表示するのに使ってる値コンバータ。

<SIUnitConverter x:Key=UnitConv BaseUnit="Bytes" DivBy1024="True" Precision="5" Scale="2" />
  :
<TextBlock Text="{Binding FileSize, Converter={xxResource UnitConv}}">

と、ファイルサイズ等を表示する場所に使用すると、指定した桁数に収まるように数値を調整した後にキロとかメガとかの文字と単位をつないだ文字列表記にしてくれる。上記の例だと、"120.50" "K" "Bytes" をつないだ "120.50KBytes" とか、1205.3MBytes みたいな表示になる。Scale の指定は Binding.Format と違って "1205.30MBytes" や "1.00 Bytes" とはならないように少し変則的になっています。
また、ファイルサイズ制限を "100MB" 等と指定できるように、逆変換もサポートしているので TextBox に TwoWay でバインドしたりもできます。 DivBy1024 が true なら 1K = 1024、DivBy1024 が false だと 1K = 1000 です。他のプロパティの説明は省略。

    /// <summary>
    /// 国際単位系による文字列表現と数値を変換する値コンバータ
    /// </summary>
    public class SIUnitConverter : IValueConverter
    {
        public SIUnitConverter() 
        {
            this.BaseUnit = "";
            this.DivBy1024 = false;
            this.Precision = 3;
            this.Scale = 3;
            this.MaxPrefixIndex = UnitChars.Length - 1;
        }

        /// <summary>接頭文字リスト</summary>
        private const string UnitChars = " KMGTPEZY";

        /// <summary>単位(接尾文字)</summary>
        public string BaseUnit { get; set; }

        /// <summary>単位が上がる値を 1024 倍毎に変更する場合、<c>true</c></summary>
        public bool DivBy1024 { get; set; }

        /// <summary>表示できる桁数</summary>
        public int Precision { get; set; }

        /// <summary>小数点以下に許容する桁数</summary>
        public int Scale { get; set; }

        /// <summary>桁区切り記号を使用するかどうかを設定します。</summary>
        public bool Cammaless { get; set; }

        /// <summary>使用できる最大の接頭文字</summary>
        public char MaxUnitPrefix
        {
            get { return UnitChars[this.MaxPrefixIndex]; }
            set
            {
                var index = UnitChars.IndexOf(value);
                if (index < 0)
                    throw new ArgumentOutOfRangeException("MaxUnitPrefix");

                this.MaxPrefixIndex = index;
            }
        }

        #region IValueConverter メンバー

        private int MaxPrefixIndex;

        /// <summary>
        /// UI 表示のためにターゲットに渡す前にソース データを変更します。
        /// </summary>
        /// <param name="value">ターゲットに渡すソース データ。</param>
        /// <param name="targetType">
        /// ターゲット依存関係プロパティで期待されるデータの <see cref="T:System.Type"/>
        /// </param>
        /// <param name="parameter">コンバーター ロジックで使用する省略可能なパラメーター。</param>
        /// <param name="culture">変換のカルチャ。</param>
        /// <returns>
        /// ターゲット依存関係プロパティに渡す値。
        /// </returns>
        public object Convert(object value, Type targetType, object parameter, CultureInfo culture)
        {
            try
            {
                var size = System.Convert.ToDouble(value, culture);
                var unit = this.DivBy1024 ? 1024d : 1000d;
                var idx = 0;
                var prec = Math.Pow(10, this.Precision);

                while ((size >= prec) && (idx < this.MaxPrefixIndex))
                {
                    idx++;
                    size = size / unit;
                }

                // 整数部の桁数を計算
                var scale = (idx > 0) ? (this.Precision - (int) Math.Ceiling(Math.Log10(size))).MinMax(0, this.Scale) : 0;

                return string.Concat(
                    size.ToString((this.Cammaless ? "F" : "N") + scale.ToString(), culture), 
                    UnitChars[idx], 
                    this.BaseUnit);
            }
            catch
            {
                return null;
            }
        }

        /// <summary>
        /// UI 表示のためにターゲットに渡す前にソース データを変更します。
        /// </summary>
        /// <typeparam name="T">目的の型</typeparam>
        /// <param name="value">ターゲットに渡すソース データ。</param>
        /// <param name="parameter">コンバーター ロジックで使用する省略可能なパラメーター。</param>
        /// <param name="culture">変換のカルチャ。</param>
        /// <returns>
        /// ターゲット依存関係プロパティに渡す値。
        /// </returns>
        public T Convert<T>(object value, object parameter, CultureInfo culture)
        {
            return (T) this.Convert(value, typeof(T), parameter, culture);
        }

        /// <summary>
        /// UI 表示のためにターゲットに渡す前にソース データを変更します。
        /// </summary>
        /// <typeparam name="T">目的の型</typeparam>
        /// <param name="value">ターゲットに渡すソース データ。</param>
        /// <returns>
        /// ターゲット依存関係プロパティに渡す値。
        /// </returns>
        public T Convert<T>(object value)
        {
            return this.Convert<T>(value, null, null);
        }

        /// <summary>
        /// ソース オブジェクトに渡す前にターゲット データを変更します。
        /// このメソッドが呼び出されるのは、<see cref="F:System.Windows.Data.BindingMode.TwoWay"/> バインディングの場合だけです。
        /// </summary>
        /// <param name="value">ソースに渡すターゲット データ。</param>
        /// <param name="targetType">
        /// ソース オブジェクトで期待されるデータの <see cref="T:System.Type"/>
        /// </param>
        /// <param name="parameter">コンバーター ロジックで使用する省略可能なパラメーター。</param>
        /// <param name="culture">変換のカルチャ。</param>
        /// <returns>
        /// ソース オブジェクトに渡す値。
        /// </returns>
        public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture)
        {
            if (value == null)
                return null;

            var s = value.ToString();
            if (string.IsNullOrEmpty(s))
                return null;

            var regex = new StringBuilder("^(?<value>[0-9", 32)
                                .Append(Regex.Escape(culture.NumberFormat.NumberDecimalSeparator))
                                .Append(Regex.Escape(culture.NumberFormat.NumberGroupSeparator))
                                .Append(Regex.Escape(culture.NumberFormat.NegativeSign))
                                .Append("]+)(?<scale>[")
                                .Append(Regex.Escape(UnitChars))
                                .Append("])?");

            if (string.IsNullOrWhiteSpace(this.BaseUnit))
                regex.Append("$");
            else
                regex.Append("(?:").Append(Regex.Escape(this.BaseUnit)).Append(")?$");

            var m = Regex.Match(s, regex.ToString(), RegexOptions.IgnoreCase);
            if (!m.Success)
                return null;

            decimal number;
            if (!decimal.TryParse(m.Groups["value"].Value, NumberStyles.Float | NumberStyles.AllowThousands, culture, out number))
                return null;

            var scale = m.Groups["scale"].Value;
            if (!string.IsNullOrWhiteSpace(scale))
            {
                var unit = this.DivBy1024 ? 1024m : 1000m;
                for (int index = UnitChars.IndexOf(scale[0]); index > 0; index--)
                    number *= unit;
            }

            return System.Convert.ChangeType(number, targetType, culture);
        }

        /// <summary>
        /// ソース オブジェクトに渡す前にターゲット データを変更します。
        /// </summary>
        /// <param name="value">ソースに渡すターゲット データ。</param>
        /// <param name="parameter">コンバーター ロジックで使用する省略可能なパラメーター。</param>
        /// <param name="culture">変換のカルチャ。</param>
        /// <returns>
        /// ソース オブジェクトに渡す値。
        /// </returns>
        public T ConvertBack<T>(object value, object parameter, CultureInfo culture)
        {
            return (T) this.ConvertBack(value, typeof(T), parameter, culture);
        }

        /// <summary>
        /// ソース オブジェクトに渡す前にターゲット データを変更します。
        /// </summary>
        /// <param name="value">ソースに渡すターゲット データ。</param>
        /// <returns>
        /// ソース オブジェクトに渡す値。
        /// </returns>
        public T ConvertBack<T>(object value)
        {
            return this.ConvertBack<T>(value, null, CultureInfo.InvariantCulture);
        }

        #endregion
    }

ついでに、初日(一週間前)に書いたシンプルな RelayCommand 実装も置いときます。

    /// <summary>
    /// アクションで表現された既存のメソッドへ実行を委譲するコマンド実装
    /// </summary>
    public class RelayCommand : ICommand
    {
        /// <summary>
        /// 新しい <see cref="RelayCommand"/> のインスタンスを生成します。
        /// </summary>
        /// <param name="execute">コマンドの機能として実行されるアクション</param>
        public RelayCommand(Action<object> execute) : this(execute, null) { }

        /// <summary>
        /// 新しい <see cref="RelayCommand"/> のインスタンスを生成します。
        /// </summary>
        /// <param name="execute">コマンドの機能として実行されるアクション</param>
        /// <param name="canExecute">コマンドの有効状態を判定するデリゲート</param>
        public RelayCommand(Action<object> execute, Func<object, bool> canExecute)
        {
            if (execute == null)
                throw new ArgumentNullException("execute");

            this.ExecuteHandler = execute;
            this.CanExecuteHandler = canExecute;
        }

        private readonly Action<object> ExecuteHandler;
        private readonly Func<object, bool> CanExecuteHandler;
        private bool? CanExecuteResult = null;

        /// <summary>
        /// 現在の状態でこのコマンドを実行できるかどうかを、設定または取得します。
        /// </summary>
        /// <remarks>
        /// <para>
        /// 有効状態の判定用にデリゲートを登録している場合、このプロパティの値を信頼しないでください。複数の UIElement から参
        /// 照されるコマンドの <see cref="CanExecute"/> プロパティは安全ではありません。コマンドの有効状態を確認するためには、
        /// <see cref="CanExecute"/> プロパティを利用せずに、コマンド自身を UIElement に割り当ててください。
        /// </para>
        /// <para>
        /// 有効状態の判定用にデリゲートを登録しており、まだ有効状態を判定されていないコマンドに対して <see cref="CanExecute" />
        /// プロパティが参照されると、有効状態の判定用にパラメータとして <c>null</c> を使用して有効状態を取得することを試みます。
        /// これは、パラメータに依存したコマンドに対する有効状態の判定に対して、予期せぬ結果を発生させる場合があります。この問題を
        /// 回避するために、パラメータに依存したコマンドは <see cref="CanExecute" /> プロパティを初期化するか、パラメータとして
        /// <c>null</c> を考慮した実装を行ってください。
        /// </para>
        /// </remarks>
        public bool CanExecute
        {
            get
            {
                if (!this.CanExecuteResult.HasValue)
                    this.CanExecuteResult = this.CanExecuteCommand(null);

                return this.CanExecuteResult.Value;
            }

            set
            {
                if (value != this.CanExecuteResult)
                {
                    this.CanExecuteResult = value;
                    this.OnCanExecuteChanged(EventArgs.Empty);
                }
            }
        }

        #region ICommand メンバー

        bool ICommand.CanExecute(object parameter)
        {
            var canExec = this.CanExecuteCommand(parameter);;

            this.CanExecuteResult = canExec;
            return canExec;
        }

        /// <summary>
        /// 現在の状態でこのコマンドを実行できるかどうかを判断するメソッドを定義します。
        /// </summary>
        /// <param name="parameter">
        /// コマンドで使用されたデータ。コマンドにデータを渡す必要がない場合は、このオブジェクトを null に設定できます。
        /// </param>
        /// <returns>
        /// このコマンドを実行できる場合は true。それ以外の場合は false。
        /// </returns>
        protected bool CanExecuteCommand(object parameter)
        {
            return (this.CanExecuteHandler == null) || this.CanExecuteHandler(parameter);
        }

        /// <summary>
        /// コマンドを実行するかどうかに影響するような変更があった場合に発生します。
        /// </summary>
        public event EventHandler CanExecuteChanged;

        /// <summary>
        /// <see cref="E:CanExecuteChanged"/> イベントを発行します。
        /// </summary>
        /// <param name="e">イベント データを格納している  <see cref="System.EventArgs"/></param>
        protected virtual void OnCanExecuteChanged(EventArgs e)
        {
            var handler = this.CanExecuteChanged;
            if (handler != null)
                handler(this, e);
        }

        /// <summary>
        /// コマンドの起動時に呼び出されるメソッドを定義します。
        /// </summary>
        /// <param name="parameter">
        /// コマンドで使用されたデータ。コマンドにデータを渡す必要がない場合は、このオブジェクトを null に設定できます。
        /// </param>
        public void Execute(object parameter)
        {
            this.ExecuteHandler(parameter);
        }

        #endregion
    }

*1:WPF/Silverlight 関連から頻繁にリンクされている MSDN マガジンの記事のコード、ほぼそのまま