Windows Azure Web サイトのローカル・ファイルシステムへのデータ保存

Windows Azure Web サイトのローカルストレージがデータの永続化に利用できるという話があって、この話を読むまでわたしもローカルストレージはデータの永続化には利用できないと思い込んでいました :mrgreen:

使えるということが分かったので、それじゃどういう風に使えるかな?という興味が湧いたので、ちょっと試してみました。

ファイルシステムを利用した I/O ということで、なんの工夫もせずにデータを一つのファイルにシリアル化すると、データ量が多くなるにつれて I/O がボトルネックになるよねぇ。。。ということで、KVS(キー・バリュー・ストア)的なアプローチをとってみます。

作成するアプリケーションはデータの保存、変更、削除、一件表示、月別一覧表示が行えるものとし、データ種別は「会社情報」と「数量記録」の二種類とします。

「会社情報」のデータ項目は「会社ID」「会社名」とし、キーは「会社ID」とします。
「数量記録」のデータ項目は「記録日」「会社ID」「属性ID」「数量」とし、キーは「記録日 + 会社ID」とします。属性は列挙型で「属性ID」と「属性名」を持つこととします。

データの保存形式は次の方式とします。
会社情報

  • App_Data フォルダにファイルを作成
  • ファイル名は company.pb

数量記録

  • App_Data フォルダ下に記録日の’年’でフォルダを作成
  • 記録日の’年月’で’年’のフォルダにファイルを作成
  • ファイル名は yyyyMM.pb (yyyyは西暦4桁、MMは月2桁)

データの永続化については、 protobuf-net を利用して、List 型のインスタンスをシリアル化し、ファイルに書き出します。

ファイル(= コレクションのインスタンス)が月単位になるので、月間の最大数量などは Linq to Object を用いて取得できますが、月を跨いでの検索などを行いたい場合には、何らかの工夫が必要になります(ココらへんの制約は KVS の一般的な制約と同様)。

動かしたときの画面は次のとおりです(動作実験版なので、装飾なしのシンプル画面です 😉 )。

/Home/Index の画面

/Home/Index
/Home/Index

初画面です。会社名のメンテナンスを行うためのリンクと数量記録の表示を行う年月の入力用のテキストボックスなどです。

/SampleData/Index の画面

/SampleData/Index
/SampleData/Index

数量記録の表示の Index 画面です。

/SampleData/Edit/ID の画面

/SampleData/Edit
/SampleData/Edit

数量記録の表示の Edit 画面です。

コードに入る前に、まずはプロジェクトの設定から。
まずは ASP.NET MVC の素のプロジェクトを作ります(コード例ではプロジェクト名は「WebSiteLocalStarage02」としています)(VS 2012 の場合はテンプレートで MVC 4 Web アプリケーションを選んで「OK」ボタンをクリックして、「新しい ASP.NET MVC プロジェクト」画面で「基本」を選択、VS 2013 の場合はテンプレートで MVC を選んでから「認証の変更」ボタンをクリックして「認証なし」を選択)。

次に、NuGet から protobuf-net をインストールします。「protobuf」で検索し、「protobuf-net」をインストールします。
また、UI でカレンダーから日付を選べるようにしたいので、jQuery UI Datepicker を利用します。VS 2012 では MVC を選択してプロジェクトを生成すると Scripts フォルダにインストールされた状態となりますが、VS 2013 では jQuery 本体のみの状態なので、自分でインストールする必要がありますが、NuGet に登録されている jQuery UI Core: Core(jQuery UI Widgets: Datepicker が依存しているモジュール)の依存関係が jQuery(>= 1.4.4 && < 1.6) となっているのに対して、VS 2013 の MVC プロジェクトにインストールされる jQuery のバージョンは 1.10.2 なので、この状態では jQuery UI 関連をインストールすることはできません(2013/11/4 現在)。ということで、現状、VS 2013 で jQuery UI を利用するには jQuery UI からダウンロードするしかなさそうです :mrgreen: インストールしたら、App_Start フォルダの BundleConfig.cs ファイルに手を入れて有効化させてください。
ちなみに、jQuery Validation はNuGet からのインストールが無事にできます 🙂

ということで環境が整ったので、次はコードです。
まずはモデルから。
データの操作状況(追加、削除など)を表現する DataState 列挙型です。

namespace WebSiteLocalStarage02.Models
{
    public enum DataState
    {
        Unchanged,
        Added,
        Deleted
    }
}

次に、データの操作状況(追加、削除など)を記録している State プロパティを持つインターフェイス IDataState インターフェイスです。

namespace WebSiteLocalStarage02.Models
{
    public interface IDataState
    {
        DataState State { get; set; }
    }
}

次に、会社情報を保持する CompanyData クラスです。

using ProtoBuf;

namespace WebSiteLocalStarage02.Models
{
    [ProtoContract]
    public class CompanyData : IDataState
    {
        [ProtoMember(1)]
        public int Id { get; set; }

        [ProtoMember(2)]
        public string Name { get; set; }

        public DataState State { get; set; }
    }
}

State プロパティは、データの操作(追加、削除など)を記録しておくものになります。

次に数量記録を保持する Sample クラスと会社分類の列挙型 CompanyClass です。

using System;
using System.ComponentModel.DataAnnotations;

using ProtoBuf;

namespace WebSiteLocalStarage02.Models
{
    [ProtoContract]
    public class Sample : IDataState
    {
        [ProtoMember(1)]
        public string Id { get; set; }
        [ProtoMember(2)]
        public int CompanyId { get; set; }
        [ProtoMember(3)]
        public CompanyClass CompanyClassId { get; set; }
        [ProtoMember(4)]
        public int Quantity { get; set; }
        [ProtoMember(5)]
        public DateTime RecordDate { get; set; }

        public DataState State { get; set; }

        [Display(Name = "会社名")]
        public string CompanyName
        {
            get
            {
                var result = "";
                if (CompanyNameRep != null)
                    result = CompanyNameRep.GetCompanyName(CompanyId);

                return result;
            }
        }

        [Display(Name = "分類")]
        public string CompanyClassName
        {
            get { return CompanyClassId.ToString(); }
        }

        public string RecordDateString
        {
            get { return string.Format("{0:yyyy/MM/dd}", RecordDate); }
            set
            {
                DateTime tempDate;
                if (DateTime.TryParse(value, out tempDate))
                {
                    RecordDate = tempDate;
                }
                else
                {
                    throw new ValidationException("日付の形式が不正です。");
                }
            }
        }

        public IComapnyName CompanyNameRep { get; set; }

        public override bool Equals(object obj)
        {
            if ((obj as Sample) == null) return false;

            return (Id == (obj as Sample).Id);
        }

        public override int GetHashCode()
        {
            return Id.GetHashCode();
        }
    }

    public enum CompanyClass : int
    {
        分類1 = 1,
        分類2,
        分類3,
        分類4,
        分類5,
        分類6,
        分類7,
        分類8,
        分類9,
        分類10,
        分類11,
    }
}

Sample クラスで記録する会社情報は CompanyId のみなので、会社名の表示にはインターフェイス IComapnyName 型のプロパティ CompanyNameRep を経由して IComapnyName を実装するリポジトリの GetCompanyName メソッドを呼び出すようにしています(リポジトリへの参照がセットされていなければ空文字を返します)。
また、State プロパティは、データの操作(追加、削除など)を記録しておくものになります。
オーバーライドしている Equals メソッドは、データ操作後のファイルへの保存時に、削除印のついたデータと読み込んだ最新データを比較するときに利用します。

次に IComapnyName インターフェイスです。

namespace WebSiteLocalStarage02.Models
{
    public interface IComapnyName
    {
        int GetCompanyId(string name);
        string GetCompanyName(int id);
    }
}

次に 会社分類の列挙型 CompanyClass からリストボックス用の SelectList を作成する際に利用する CompanyClassIdName クラスです。

namespace WebSiteLocalStarage02.Models
{
    public class CompanyClassIdName
    {
        public int Id { get; set; }
        public string Name { get; set; }
    }
}

モデルの最後は /Home/Index で表示する年月の選択に利用する InputDate クラスです。

using System;
using System.ComponentModel.DataAnnotations;

namespace WebSiteLocalStarage02.Models
{
    public class InputDate
    {
        public DateTime SelectDate { get; set; }

        [Required]
        [Display(Name = "表示する年月")]
        public string SelectDateString
        {
            get { return string.Format("{0:yyyy/MM/dd}", SelectDate); }
            set
            {
                DateTime tempDate;
                if (DateTime.TryParse(value, out tempDate))
                    SelectDate = tempDate;
                else
                    throw new ValidationException("日付の形式が不正です。");
            }
        }
    }
}

次にリポジトリです。プロジェクトに DAL フォルダを作成します。
ISampleRepository インターフェイスです。

using System.Collections.Generic;

using WebSiteLocalStarage02.Models;

namespace WebSiteLocalStarage02.DAL
{
    public interface ISampleRepository
    {
        IEnumerable<Sample> FindMonthSamples(string dataPath, string yyyymm);
        Sample GetSample(string dataPath, string id);
        void AddSample(string dataPath, Sample sample);
        void UpdateSample(string dataPath, Sample sample);
        void DeleteSample(string dataPath, string id);

        IEnumerable<CompanyData> FindComapnies(string dataPath);
        CompanyData GetCompany(string dataPath, int id);
        void AddCompany(string dataPath, CompanyData company);

        void Save();
    }
}

動作実験版なので、会社情報のハンドリング系は、一覧取得、詳細取得、追加だけです 😉

次に SampleRepository クラスです。

using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Text.RegularExpressions;

using ProtoBuf;

using WebSiteLocalStarage02.Models;

namespace WebSiteLocalStarage02.DAL
{
    public class SampleRepository : ISampleRepository, IComapnyName
    {
        private string _dataPath;   // 処理対象が格納されているファイルのパス
        private List<Sample> _datas;    // 処理対象のコレクション
        private string _oldDataPath;    // 更新前データが格納されていたファイルのパス(記録年月が変わった場合)
        private List<Sample> _oldDatas; // 更新前データが格納されていたコレクション
        private List<CompanyData> _companies;
        private Random _random = new Random();
        private const int MIN_WAIT = 10;
        private const int MAX_WAIT = 200;
        private const string COMPANY_FILE = "company.pb";
        private const int RETRY_LIMIT = 5;

        private Regex IsYYYYMM = new Regex(@"^(199[0-9]|2[01][0-9]{2})(0[1-9]|1[0-2])$");

        #region ISampleRepository

        public IEnumerable<Sample> FindMonthSamples(string dataPath, string yyyymm)
        {
            checkPath(dataPath);
            if (!IsYYYYMM.IsMatch(yyyymm))
                throw new ArgumentException("日付の値が不正です。", "yyyymm");

            var fileName = getFilePath(dataPath, yyyymm);
            checkCompanies(dataPath);

            return readFile<Sample>(fileName);
        }

        public Sample GetSample(string dataPath, string id)
        {
            checkPath(dataPath);
            var fileName = getFilePath(dataPath, id);
            var samples = readFile<Sample>(fileName);
            checkCompanies(dataPath);

            return samples.Find(m => m.Id == id);
        }

        public void AddSample(string dataPath, Sample sample)
        {
            sample.Id = string.Format("{0:yyyyMMdd}{1}", sample.RecordDate, (int)sample.CompanyId);

            var fileName = getFilePath(dataPath, sample.Id);
            List<Sample> samples;
            if (_oldDataPath != null && fileName == _oldDataPath)
            {   // 現在の対象と _oldDataPath は同じもの
                samples = _oldDatas;
                _oldDataPath = null;
                _oldDatas = null;
            }
            else
            {
                samples = readFile<Sample>(fileName);
            }
            if (samples.Find(m => m.Id == sample.Id) != null &&
                (samples.Find(m => m.Id == sample.Id)).State != DataState.Deleted)
                throw new InvalidOperationException("登録しようとしたデータは既に登録されています。");

            sample.State = DataState.Added;
            samples.Add(sample);
            _datas = samples;
            _dataPath = fileName;
        }

        public void UpdateSample(string dataPath, Sample sample)
        {
            var fileName = getFilePath(dataPath, sample.Id);
            var samples = readFile<Sample>(fileName);
            var target = samples.Find(m => m.Id == sample.Id);
            if (target != null)
                target.State = DataState.Deleted;
            else
                throw new FileNotFoundException("更新しようとするデータが存在しません。");

            _oldDatas = samples;
            _oldDataPath = fileName;

            AddSample(dataPath, sample);
        }

        public void DeleteSample(string dataPath, string id)
        {
            var fileName = getFilePath(dataPath, id);
            var samples = readFile<Sample>(fileName);
            var target = samples.Find(m => m.Id == id);
            if (target != null)
               target.State = DataState.Deleted;
            _datas = samples;
            _dataPath = fileName;
        }

        public IEnumerable<CompanyData> FindComapnies(string dataPath)
        {
            checkCompanies(dataPath);
            return _companies;
        }

        public CompanyData GetCompany(string dataPath, int id)
        {
            checkCompanies(dataPath);
            return _companies.Find(m => m.Id == id);
        }

        public void AddCompany(string dataPath, CompanyData company)
        {
            checkCompanies(dataPath);

            if (_companies.Find(m => m.Name == company.Name) != null)
                throw new ArgumentException("登録しようとした名称は既に登録されています。");

            var maxId = _companies.Count != 0 ? _companies.Max(s => s.Id) : 0;
            company.Id = ++maxId;

            company.State = DataState.Added;
            _companies.Add(company);

            var times = 0;
            while (times < RETRY_LIMIT)
            {
                FileStream fs = null;
                var isBusy = false;
                try
                {
                    fs = File.Open(dataPath + @"\" + COMPANY_FILE, FileMode.OpenOrCreate, FileAccess.ReadWrite, FileShare.None);
                    save(fs, _companies);
                    times = int.MaxValue;
                }
                catch (Exception e)
                {
                    if (e.GetType() == typeof(IOException) && e.HResult == -2147024864) // ERROR_SHARING_VIOLATION 0x80070020
                    {
                        ++times;
                        if (times >= RETRY_LIMIT)
                            throw new IOException("ファイルが使用中のため開くことができませんでした。");
                        isBusy = true;
                    }
                    else
                        throw;
                }
                finally
                {
                    if (fs != null)
                        fs.Dispose();
                    if (isBusy)
                        System.Threading.Thread.Sleep(_random.Next(MIN_WAIT, MAX_WAIT));
                }
            }
        }

        public void Save()
        {
            var times = 0;
            var oldStatus = saveStatus.NotEmpty;
            var newStatus = saveStatus.NotEmpty;
            while (times < RETRY_LIMIT)
            {
                FileStream oldFs = null;
                FileStream newFs = null;
                var isBusy = false;
                try
                {
                    // 更新前後(更新前は記録月が変わった場合)のファイルをロックしてからファイルへの保存を行う。
                    if (_oldDataPath != null)
                        oldFs = File.Open(_oldDataPath, FileMode.OpenOrCreate, FileAccess.ReadWrite, FileShare.None);

                    var folder = Path.GetDirectoryName(_dataPath);
                    if (!Directory.Exists(folder))
                        Directory.CreateDirectory(folder);

                    newFs = File.Open(_dataPath, FileMode.OpenOrCreate, FileAccess.ReadWrite, FileShare.None);

                    if (oldFs != null)
                    {
                        oldStatus = save(oldFs, _oldDatas);
                    }

                    newStatus = save(newFs, _datas);
                    times = int.MaxValue;
                }
                catch (Exception e)
                {
                    if (e.GetType() == typeof(IOException) && e.HResult == -2147024864) // ERROR_SHARING_VIOLATION 0x80070020
                    {
                        ++times;
                        if (times >= RETRY_LIMIT)
                            throw new IOException("ファイルが使用中のため開くことができませんでした。");
                        isBusy = true;
                    }
                    else
                        throw;
                }
                finally
                {
                    if (oldFs != null)
                        oldFs.Dispose();
                    if (newFs != null)
                        newFs.Dispose();
                    if (isBusy)
                        System.Threading.Thread.Sleep(_random.Next(MIN_WAIT, MAX_WAIT));
                }
            }

            if (oldStatus == saveStatus.Empty)
                deleteFile(_oldDataPath);
            _oldDataPath = null;
            _oldDatas = null;
            if (newStatus == saveStatus.Empty)
                deleteFile(_dataPath);
            _dataPath = null;
            _datas = null;
        }

        #endregion ISampleRepository

        #region IComapnyName

        public int GetCompanyId(string name)
        {
            return _companies.Find(m => m.Name == name).Id;
        }

        public string GetCompanyName(int id)
        {
            return _companies.Find(m => m.Id == id).Name;
        }

        #endregion IComapnyName

        private saveStatus save<T>(FileStream fs, List<T> datas)
        {
            var status = saveStatus.NotEmpty;
            // ロックが掛かった状態で最新データを取得して更新する
            var list = readFile<T>(fs);  
            datas.ForEach(n =>
            {
                switch ((n as IDataState).State)
                {
                    case DataState.Added:
                        list.Add(n);
                        break;
                    case DataState.Deleted:
                        var target = list.Find(m => m.Equals(n));
                        list.Remove(target);
                        break;
                }
            });

            fs.SetLength(0);    // ファイルストリームの長さを 0 にする(既存データを削除)
            Serializer.Serialize(fs, list); // コレクションのインスタンスをシリアライズして書き出す
            if (list.Count == 0)
                status = saveStatus.Empty;

            return status;
        }

        private void deleteFile(string dataPath)
        {
            File.Delete(dataPath);
            var folder = Path.GetDirectoryName(dataPath);
            if (Directory.GetFileSystemEntries(folder).Count() == 0)
                Directory.Delete(folder);
        }

        private void checkPath(string dataPath)
        {
            if (!Directory.Exists(dataPath))
            {
                throw new ArgumentException("存在しないフォルダが指定されました。", "dataPath");
            }
        }

        private string getFilePath(string dataPath, string id)
        {
            if (id.Length < 6)
                throw new ArgumentException("ID の形式が不正です。");

            var fileName = dataPath + @"\" + id.Substring(0, 4);
            fileName += @"\" + id.Substring(0, 6) + ".pb";

            return fileName;
        }

        private List<T> readFile<T>(string path)
        {
            List<T> result = new List<T>();

            if (File.Exists(path))
            {
                var times = 0;
                while (times < RETRY_LIMIT)
                {
                    try
                    {
                        using (var readFs = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.Read))
                        {
                            result = readFile<T>(readFs);
                            times = int.MaxValue;
                        }
                    }
                    catch (Exception e)
                    {
                        if (e.GetType() == typeof(IOException) && e.HResult == -2147024864) // ERROR_SHARING_VIOLATION 0x80070020
                        {
                            ++times;
                            if (times >= RETRY_LIMIT)
                                throw new IOException("ファイルが使用中のため開くことができませんでした。");
                            System.Threading.Thread.Sleep(_random.Next(MIN_WAIT, MAX_WAIT));
                        }
                        else
                            throw;
                    }
                }
            }

            return result;
        }

        private List<T> readFile<T>(FileStream fs)
        {
            var result = Serializer.Deserialize<List<T>>(fs);
            result.ForEach(n =>
            {
                if ((n as IDataState) != null)
                    (n as IDataState).State = DataState.Unchanged;
                // Sample クラスの ICompanyName インターフェイス型のプロパティ CompanyNameRepに
                // このインスタンスへの参照をセットする
                if ((n as Sample) != null)
                    (n as Sample).CompanyNameRep = this;
            });

            return result;
        }

        private void checkCompanies(string dataPath)
        {
            if (_companies == null)
            {
                _companies = readFile<CompanyData>(dataPath + @"\" + COMPANY_FILE);
            }
        }

        private enum saveStatus
        {
            NotEmpty,
            Empty
        }
    }
}

数量記録の更新は、既存のデータの削除 -> 更新後データの追加 で行っています。また、記録日の変更により記録するファイルが変わることも想定して、_oldDatas と _oldDataPath に更新前データを保持していたコレクションとデータファイル名を退避させています(Add メソッドと Save メソッドで利用します)。
データ操作は、コレクション中のデータに「追加」「削除」の印をつけておいて、Save メソッドで _OldDataPath のファイルを含めてファイルをロックした上で最新データを取得し、追加・削除を行なって書き戻した後にロックを解除する方式としています(更新前データの整合性チェックまでは行っていません :mrgreen: )。
プライベートメソッドの save と readFile は、会社情報の CompanyData と 数量記録の Sample を扱うことからジェネリックス・メソッドにしています。また、readFile メソッドでは、扱っているモデルが Sample であれば、CompanyNameRep プロパティへ自身のインスタンスへの参照をセットしています(Sample クラスで説明した会社名の取得のため)。
また、ファイルのオープン時に共用エラーが発生した場合、5回のリトライを行うようにしています。

次はコントローラーです。
まずは HomeController です。

using System;
using System.Web.Mvc;

using WebSiteLocalStarage02.DAL;
using WebSiteLocalStarage02.Models;

namespace WebSiteLocalStarage02.Controllers
{
    public class HomeController : Controller
    {
        private ISampleRepository _repository;

        public HomeController() : this(new SampleRepository()) { }
        public HomeController(ISampleRepository repository)
        {
            _repository = repository;
        }

        //
        // GET: /Home/

        public ActionResult Index()
        {
            var selectDate = new InputDate { SelectDate = DateTime.Today };

            return View(selectDate);
        }

        //
        // POST: /Home/

        [HttpPost]
        [ValidateAntiForgeryToken]
        public ActionResult Index(InputDate inputDate)
        {
            try
            {
                if (ModelState.IsValid)
                {
                    var seleceYearMonth = string.Format("{0:yyyyMM}", inputDate.SelectDate);
                    return RedirectToAction("Index", "SampleData", new { id = seleceYearMonth });
                }

                return View(inputDate);
            }
            catch
            {
                return View(inputDate);
            }
        }

    }
}

次に CompanyController です。

using System;
using System.Web.Mvc;

using WebSiteLocalStarage02.DAL;
using WebSiteLocalStarage02.Models;

namespace WebSiteLocalStarage02.Controllers
{
    public class CompanyController : Controller
    {
        private ISampleRepository _repository;

        public CompanyController() : this(new SampleRepository()) { }
        public CompanyController(ISampleRepository repository)
        {
            _repository = repository;
        }

        //
        // GET: /Company/

        public ActionResult Index()
        {
            var path = Server.MapPath("~/App_Data");

            return View(_repository.FindComapnies(path));
        }

        //
        // GET: /Company/Create/

        public ActionResult Create()
        {
            return View();
        }

        //
        // POST: /Company/Create/

        [HttpPost]
        [ValidateAntiForgeryToken]
        public ActionResult Create([Bind(Include = "Name")]CompanyData company)
        {
            var path = Server.MapPath("~/App_Data");

            try
            {
                if (ModelState.IsValid)
                {
                    _repository.AddCompany(path, company);
                    return RedirectToAction("Index");
                }
            }
            catch (Exception e)
            {
                ModelState.AddModelError("Err", e.Message);
            }

            return View(company);
        }
    }
}

次に SampleDataController です。

using System;
using System.Collections.Generic;
using System.Linq;
using System.Web.Mvc;

using WebSiteLocalStarage02.DAL;
using WebSiteLocalStarage02.Models;

namespace WebSiteLocalStarage02.Controllers
{
    public class SampleDataController : Controller
    {
        private readonly ISampleRepository _repository;

        public SampleDataController() : this(new SampleRepository()) { }
        public SampleDataController(ISampleRepository repository)
        {
            _repository = repository;
        }

        //
        // GET: /SampleData/

        public ActionResult Index(string id)
        {
            try
            {
                var yearMonthString = id ?? string.Format("{0:yyyyMM}", DateTime.Today);
                var path = Server.MapPath("~/App_Data");

                // Create へ渡す Index へのリンク用のデータ
                ViewBag.yyyyMM = yearMonthString;

                return View(_repository.FindMonthSamples(path, yearMonthString).OrderBy(k => k.RecordDate));
            }
            catch
            {
                return HttpNotFound();
            }
        }

        //
        // GET /SampleData/Details/5

        public ActionResult Details(string id)
        {
            var path = Server.MapPath("~/App_Data");

            var target = _repository.GetSample(path, id);
            if (target == null) return HttpNotFound();

            return View(target);
        }

        //
        // GET /SampleData/Create/

        public ActionResult Create(string yyyyMM)
        {
            // Index へのリンク用のデータ
            ViewBag.yyyyMM = yyyyMM;

            var path = Server.MapPath("~/App_Data");

            // ドロップダウンリスト用のデータ
            ViewData["CompanyId"] = new SelectList(_repository.FindComapnies(path), "Id", "Name");
            ViewData["CompanyClassId"] = new SelectList(getCompanyClasses(), "Id", "Name");

            return View();
        }

        //
        // POST: /SampleData/Create/

        [HttpPost]
        [ValidateAntiForgeryToken]
        public ActionResult Create([Bind(Include = "CompanyId,CompanyClassId,Quantity,RecordDateString")] Sample sample)
        {
            var path = Server.MapPath("~/App_Data");

            try
            {
                if (ModelState.IsValid)
                {
                    _repository.AddSample(path, sample);
                    _repository.Save();
                    var yyyymm = string.Format("{0:yyyyMM}", sample.RecordDate);
                    return RedirectToAction("Index", new { id = yyyymm });
                }
            }
            catch (Exception e)
            {
                ModelState.AddModelError("Err", e.Message);
            }

            // Index へのリンク用のデータ
            ViewBag.yyyyMM = sample.Id.Substring(0, 6);

            // ドロップダウンリスト用のデータ
            ViewData["CompanyId"] = new SelectList(_repository.FindComapnies(path), "Id", "Name", sample.CompanyId);
            ViewData["CompanyClassId"] = new SelectList(getCompanyClasses(), "Id", "Name", (int)sample.CompanyClassId);

            return View(sample);
        }

        //
        // GET /SampleData/Edit/5

        public ActionResult Edit(string id)
        {
            var path = Server.MapPath("~/App_Data");

            var target = _repository.GetSample(path, id);
            if (target == null) return HttpNotFound();

            // ドロップダウンリスト用のデータ
            ViewData["CompanyId"] = new SelectList(_repository.FindComapnies(path), "Id", "Name", target.CompanyId);
            ViewData["CompanyClassId"] = new SelectList(getCompanyClasses(), "Id", "Name", (int)target.CompanyClassId);

            return View(target);
        }

        //
        // POST /SampleData/Edit/

        [HttpPost]
        [ValidateAntiForgeryToken]
        public ActionResult Edit([Bind(Include = "Id,CompanyId,CompanyClassId,Quantity,RecordDate,RecordDateString")] Sample sample)
        {
            var path = Server.MapPath("~/App_Data");

            try
            {
                if (ModelState.IsValid)
                {
                    _repository.UpdateSample(path, sample);
                    _repository.Save();
                    var yyyymm = string.Format("{0:yyyyMM}", sample.RecordDate);
                    return RedirectToAction("Index", new { id = yyyymm });
                }
            }
            catch (Exception e)
            {
                ModelState.AddModelError("Err", e.Message);
            }

            // ドロップダウンリスト用のデータ
            ViewData["CompanyId"] = new SelectList(_repository.FindComapnies(path), "Id", "Name", sample.CompanyId);
            ViewData["CompanyClassId"] = new SelectList(getCompanyClasses(), "Id", "Name", (int)sample.CompanyClassId);

            return View(sample);
        }

        //
        // GET /SampleData/Delete/5

        public ActionResult Delete(string id)
        {
            var path = Server.MapPath("~/App_Data");

            var target = _repository.GetSample(path, id);
            if (target == null) return HttpNotFound();

            return View(target);
        }

        //
        // POST /SampleData/Delete/

        [HttpPost, ActionName("Delete")]
        [ValidateAntiForgeryToken]
        public ActionResult DeleteConfirmed(string id)
        {
            var path = Server.MapPath("~/App_Data");

            _repository.DeleteSample(path, id);
            _repository.Save();
            return RedirectToAction("Index", new { id = id.Substring(0, 6) });
        }

        // 会社分類の列挙型(CompanyClass)から List<CompanyClassIdName> 型のオブジェクトを返す。
        private List<CompanyClassIdName> getCompanyClasses()
        {
            var companyClasses = new List<CompanyClassIdName>();
            foreach (var n in Enum.GetNames(typeof(CompanyClass)))
            {
                companyClasses.Add(new CompanyClassIdName { Id = (int)Enum.Parse(typeof(CompanyClass), n), Name = n });
            }

            return companyClasses;
        }

        protected override void Dispose(bool disposing)
        {
            base.Dispose(disposing);
        }
    }
}

最後にビューです。
最初に Shared の _Layout.cshtml


<!DOCTYPE html>
<html>
<head>
    <meta charset="utf-8" />
    <meta name="viewport" content="width=device-width" />
    <title>@ViewBag.Title</title>
    @Styles.Render("~/Content/css")
    @Styles.Render("~/Content/themes/base/css")
    @Scripts.Render("~/bundles/modernizr")
</head>
<body>
    @RenderBody()

    @Scripts.Render("~/bundles/jquery")
    @RenderSection("scripts", required: false)
</body>
</html>

jQuery UI 系のスタイルをまとめている ~/Content/themes/base/css への参照を追加しています(VS 2012 で生成される BundleConfig.cs ファイルの内容に沿ったもの。VS 2013 の場合は手を加えた内容に見合ったものにしてください(~/Content/css に追加した場合は変更不要など))。

次に Home のもの。
Index.cshtml

@model WebSiteLocalStarage02.Models.InputDate

@{
    ViewBag.Title = "Index";
}

<h2>Index</h2>

@Html.ActionLink("会社名のメンテナンス", "Index", "Company")

@using (Html.BeginForm()) {
    @Html.AntiForgeryToken()
    @Html.ValidationSummary(true)

    <fieldset>
        <legend>表示する年月を選択</legend>

        <div class="editor-label">
            @Html.LabelFor(model => model.SelectDateString)
        </div>
        <div class="editor-field">
            @Html.TextBoxFor(model => model.SelectDateString, new { @class = "datepicker" })
            @Html.ValidationMessageFor(model => model.SelectDateString)
        </div>

        <p>
            <input type="submit" value="選択" />
        </p>
    </fieldset>
}

@section Scripts {
    @Scripts.Render("~/bundles/jqueryui")
    @Scripts.Render("~/bundles/jqueryval")
    <script type="text/javascript">
        $(function () {
            $('.datepicker').datepicker({ dateFormat: 'yy/mm/dd' });
        });
    </script>
}

@section Scripts の記述は、VS 2012 で生成される BundleConfig.cs ファイルの内容に沿ったものになっています。VS 2013 の場合は手を加えた内容に見合ったものにしてください(~/bundles/jquery に追加した場合は @Scripts.Render の行は不要など)。以下同様です。

次に Company のもの。
Create.cshtml

@model WebSiteLocalStarage02.Models.CompanyData

@{
    ViewBag.Title = "Create";
}

<h2>Create</h2>

@using (Html.BeginForm()) {
    @Html.AntiForgeryToken()
    @Html.ValidationSummary(false)

    <fieldset>
        <legend>CompanyData</legend>

        <div class="editor-label">
            @Html.LabelFor(model => model.Name)
        </div>
        <div class="editor-field">
            @Html.EditorFor(model => model.Name)
            @Html.ValidationMessageFor(model => model.Name)
        </div>

        <p>
            <input type="submit" value="Create" />
        </p>
    </fieldset>
}

<div>
    @Html.ActionLink("Back to List", "Index")
</div>

@section Scripts {
    @Scripts.Render("~/bundles/jqueryval")
}

Index.cshtml

@model IEnumerable<WebSiteLocalStarage02.Models.CompanyData>

@{
    ViewBag.Title = "Index";
}

<h2>Index</h2>

<p>
    @Html.ActionLink("Create New", "Create")
</p>
<table>
    <tr>
        <th>
            @Html.DisplayNameFor(model => model.Name)
        </th>
        <th></th>
    </tr>

@foreach (var item in Model) {
    <tr>
        <td>
            @Html.DisplayFor(modelItem => item.Name)
        </td>
    </tr>
}

</table>

<p>
    @Html.ActionLink("選択画面に戻る", "Index", "Home")
</p>

最後に SampleData のもの。
Create.cshtml

@model WebSiteLocalStarage02.Models.Sample

@{
    ViewBag.Title = "Create";
}

<h2>Create</h2>

@using (Html.BeginForm()) {
    @Html.AntiForgeryToken()
    @Html.ValidationSummary(false)

    <fieldset>
        <legend>Sample</legend>
        <div class="editor-label">
            @Html.LabelFor(model => model.CompanyId)
        </div>
        <div class="editor-field">
            @Html.DropDownListFor(model => model.CompanyId, null)
            @Html.ValidationMessageFor(model => model.CompanyId)
        </div>

        <div class="editor-label">
            @Html.LabelFor(model => model.CompanyClassId)
        </div>
        <div class="editor-field">
            @Html.DropDownListFor(model => model.CompanyClassId, null)
            @Html.ValidationMessageFor(model => model.CompanyClassId)
        </div>

        <div class="editor-label">
            @Html.LabelFor(model => model.Quantity)
        </div>
        <div class="editor-field">
            @Html.EditorFor(model => model.Quantity)
            @Html.ValidationMessageFor(model => model.Quantity)
        </div>

        <div class="editor-label">
            @Html.LabelFor(model => model.RecordDateString)
        </div>
        <div class="editor-field">
            @Html.TextBoxFor(model => model.RecordDateString, new { @class = "datepicker" })
            @Html.ValidationMessageFor(model => model.RecordDateString)
        </div>

        <p>
            <input type="submit" value="Create" />
        </p>
    </fieldset>
}

<div>
    @Html.ActionLink("Back to List", "Index", new { id = ViewBag.yyyyMM })
</div>

@section Scripts {
    @Scripts.Render("~/bundles/jqueryui")
    @Scripts.Render("~/bundles/jqueryval")
    <script type="text/javascript">
        $(function () {
            $('.datepicker').datepicker({ dateFormat: 'yy/mm/dd' });
        });
    </script>
}

Delete.cshtml

@model WebSiteLocalStarage02.Models.Sample

@{
    ViewBag.Title = "Delete";
}

<h2>Delete</h2>

<h3>Are you sure you want to delete this?</h3>
<fieldset>
    <legend>Sample</legend>

    <div class="display-label">
        @Html.DisplayNameFor(model => model.CompanyName)
    </div>
    <div class="display-field">
        @Html.DisplayFor(model => model.CompanyName)
    </div>

    <div class="display-label">
        @Html.DisplayNameFor(model => model.CompanyClassName)
    </div>
    <div class="display-field">
        @Html.DisplayFor(model => model.CompanyClassName)
    </div>

    <div class="display-label">
         @Html.DisplayNameFor(model => model.Quantity)
    </div>
    <div class="display-field">
        @Html.DisplayFor(model => model.Quantity)
    </div>

    <div class="display-label">
         @Html.DisplayNameFor(model => model.RecordDateString)
    </div>
    <div class="display-field">
        @Html.DisplayFor(model => model.RecordDateString)
    </div>
</fieldset>
@using (Html.BeginForm()) {
    @Html.AntiForgeryToken()
    <p>
        <input type="submit" value="Delete" /> |
        @Html.ActionLink("Back to List", "Index", new { id = string.Format("{0:yyyyMM}", Model.RecordDate) })
    </p>
}

Details.cshtml

@model WebSiteLocalStarage02.Models.Sample

@{
    ViewBag.Title = "Details";
}

<h2>Details</h2>

<fieldset>
    <legend>Sample</legend>

    <div class="display-label">
        @Html.DisplayNameFor(model => model.CompanyName)
    </div>
    <div class="display-field">
        @Html.DisplayFor(model => model.CompanyName)
    </div>

    <div class="display-label">
        @Html.DisplayNameFor(model => model.CompanyClassName)
    </div>
    <div class="display-field">
        @Html.DisplayFor(model => model.CompanyClassName)
    </div>

    <div class="display-label">
         @Html.DisplayNameFor(model => model.Quantity)
    </div>
    <div class="display-field">
        @Html.DisplayFor(model => model.Quantity)
    </div>

    <div class="display-label">
         @Html.DisplayNameFor(model => model.RecordDateString)
    </div>
    <div class="display-field">
        @Html.DisplayFor(model => model.RecordDateString)
    </div>
</fieldset>
<p>
    @Html.ActionLink("Edit", "Edit", new { id = Model.Id }) |
    @Html.ActionLink("Back to List", "Index", new { id = string.Format("{0:yyyyMM}", Model.RecordDate) })
</p>

Edit.cshtml

@model WebSiteLocalStarage02.Models.Sample

@{
    ViewBag.Title = "Edit";
}

<h2>Edit</h2>

@using (Html.BeginForm()) {
    @Html.AntiForgeryToken()
    @Html.ValidationSummary(false)

    <fieldset>
        <legend>Sample</legend>

        @Html.HiddenFor(model => model.Id)

        <div class="editor-label">
            @Html.LabelFor(model => model.CompanyId)
        </div>
        <div class="editor-field">
            @Html.DropDownListFor(model => model.CompanyId, null)
            @Html.ValidationMessageFor(model => model.CompanyId)
        </div>

        <div class="editor-label">
            @Html.LabelFor(model => model.CompanyClassId)
        </div>
        <div class="editor-field">
            @Html.DropDownListFor(model => model.CompanyClassId, null)
            @Html.ValidationMessageFor(model => model.CompanyClassId)
        </div>

        <div class="editor-label">
            @Html.LabelFor(model => model.Quantity)
        </div>
        <div class="editor-field">
            @Html.EditorFor(model => model.Quantity)
            @Html.ValidationMessageFor(model => model.Quantity)
        </div>

        <div class="editor-label">
            @Html.LabelFor(model => model.RecordDateString)
        </div>
        <div class="editor-field">
            @Html.TextBoxFor(model => model.RecordDateString, new { @class = "datepicker" })
            @Html.ValidationMessageFor(model => model.RecordDateString)
        </div>

        @Html.HiddenFor(model => model.RecordDate)

        <p>
            <input type="submit" value="Save" />
        </p>
    </fieldset>
}

<div>
    @Html.ActionLink("Back to List", "Index", new { id = string.Format("{0:yyyyMM}", Model.RecordDate) })
</div>

@section Scripts {
    @Scripts.Render("~/bundles/jqueryui")
    @Scripts.Render("~/bundles/jqueryval")
    <script type="text/javascript">
        $(function () {
            $('.datepicker').datepicker({ dateFormat: 'yy/mm/dd' });
        });
    </script>
}

最後に Index.cshtml です。

@model IEnumerable<WebSiteLocalStarage02.Models.Sample>

@{
    ViewBag.Title = "Index";
}

<h2>Index</h2>

<p>
    @Html.ActionLink("Create New", "Create", new { yyyyMM = ViewBag.yyyyMM })
</p>
<table>
    <tr>
        <th>
            @Html.DisplayNameFor(model => model.Id)
        </th>
        <th>
            @Html.DisplayNameFor(model => model.CompanyName)
        </th>
        <th>
            @Html.DisplayNameFor(model => model.CompanyClassName)
        </th>
        <th>
            @Html.DisplayNameFor(model => model.Quantity)
        </th>
        <th>
            @Html.DisplayNameFor(model => model.RecordDateString)
        </th>
        <th></th>
    </tr>

@foreach (var item in Model) {
    <tr>
        <td>
            @Html.DisplayFor(modelItem => item.Id)
        </td>
        <td>
            @Html.DisplayFor(modelItem => item.CompanyName)
        </td>
        <td>
            @Html.DisplayFor(modelItem => item.CompanyClassName)
        </td>
        <td>
            @Html.DisplayFor(modelItem => item.Quantity)
        </td>
        <td>
            @Html.DisplayFor(modelItem => item.RecordDateString)
        </td>
        <td>
            @Html.ActionLink("Edit", "Edit", new { id = item.Id }) |
            @Html.ActionLink("Details", "Details", new { id = item.Id }) |
            @Html.ActionLink("Delete", "Delete", new { id = item.Id })
        </td>
    </tr>
}

</table>

<p>
    @Html.ActionLink("表示年月選択画面へ戻る", "Index", "Home")
</p>

これでビルドすれば動きます(ただし、コントローラーでのエラーハンドリングは試用で問題ない程度しか行なっていません 😛 )。
Windows Azure Web サイトへの発行の方法は、マイクロソフトの情報などを参考にしてください(DB は利用しないので、DB 関係のセッティングは不要です)。

使ってみた感じでは、利用者認証が不要で、何らかのデータを取得・整理して保存 & 利用者に表示するような用途には有効な活用ができそうですね 🙂


コメントを残す

メールアドレスが公開されることはありません。 * が付いている欄は必須項目です