MVVMパターンを考えるとき、ViewはXAMLとかGUIだろうという固定観点があるが、実はそうではない。と思うのだがどうだろうか?いわゆる、ASP.NET MVC の View にWeb APIを割り当てると View ってのは XML や JSON になる。クライアントから見れば XMLやJSON はデータだが、MVCパターンで作られたWebアプリにしてみれば、XMLやJSONをViewに見立てることができる。実際、Web APIを作るとき複雑なXMLを返す場合は、Viewの機能を使ったほうが楽だったりする。
ClosedXMLをViewに見立てる
CloseXMLを使って、Excelに値を書き込もうとするとき Cell(row,column)あるはCell(“A1”)を使うわけだけど、ここの書き込み先をXAMLのバインドのように書けないだろうか?ってのを思いついた。
で、ClosedXML.Report https://github.com/ClosedXML/ClosedXML.Report はテンプレート用のExcelを作っておいて、あらかじめ名前を付けておく。
確かに、こんな風に Excel に名前を付けておいて、そこを目指して書き込むのもいいんんだが、なんらかのマークを書いておかなければいけないのが微妙な感じがする。実は、XAML も {Binding propName} という形で XAML 自身に記述するので似たようなことをやっているのだが。
これ、マークを View のほうに書いているけど、じゃあ ViewModel のほうに書くのはどうか?とおもってみたのがこれ。
テンプレートとなるExcelはふつうに記述しておく。
書き込んだ結果はこっち
Excelへの書き込み位置は、ViewModelに属性として記述する。
public class ViewModel : ObservableObject { private string _Name, _Address; private string _ModifiedDate; [ExcelBinding(Address: "B2", Property: "Value")] public string Name { get => _Name; set => SetProperty(ref _Name, value, nameof(Name)); } [ExcelBinding(Address: "B3", Property: "Value")] public string Address { get => _Address; set => SetProperty(ref _Address, value, nameof(Address)); } // 更新日時 [ExcelBinding(Address: "A4", Property: "Value")] public string ModifiedDate { get => _ModifiedDate; set => SetProperty(ref _ModifiedDate, value, nameof(ModifiedDate)); } }
ViewModelからClosedXMLを通してExcelに書き込むコードはこんな感じ。
ExcelBind _bind; ViewModel _vm; public void LoadExcel() { string tableName = "Personal"; string path = @"C:\Users\masuda\Documents\サンプルテンプレート.xlsx"; using (var wb = new XLWorkbook(path)) { var sh = wb.Worksheets.FirstOrDefault(t => t.Name == tableName); _bind = new ExcelBind(sh); _vm = new ViewModel(); _bind.DataContext = _vm; _vm.Name = "Tomoaki Masuda"; _vm.Address = "Itabash-ku Tokyo in Japan"; _vm.ModifiedDate = DateTime.Now.ToString(); // 更新日を表示 wb.SaveAs(@"C:\Users\masuda\Documents\sample_output.xlsx"); } }
当然のことながら、ClosedXML にはバインディング処理はないので、ClosedXMLのXLWorkbookとIXLWorksheetをラップするような橋渡しのExcelBindクラスを作る。ExcelBindクラスにはMVVMパターンのDataContextプロパティを持っているので、これにバインドする。
ViewModel の各種プロパティに設定をすると INotifyPropertyChanged インタフェースを使って、View に見立てられた ClosedXML に値を書き込むという仕組み。ClosedXML は入力インターフェースを持たないので、ClosedXML からの TextChenge などのイベント受信は不要(実は自動計算などがあるから、無いわけではないのだが)というわけだ。
ViewModel クラスは普通の MVVM パターンと同じようにプロパティに値を設定すればよい。
内部実装
ざっと内部的な実装を
目的のセルを設定するExcelBinding属性
“A1″や行列で指定する。将来的にはセルの名前を設定できるのもよいだろう。
[AttributeUsage(AttributeTargets.Property, AllowMultiple = false, Inherited = false)] public class ExcelBindingAttribute : Attribute { public string SheetName { get; } public string Address { get; } public int Row { get; } public int Column { get; } public string PropertyName { get; } public ExcelBindingAttribute(string Sheet, string Address, string Property ) { this.SheetName = Sheet; this.Address = Address; this.PropertyName = Property; } public ExcelBindingAttribute(string Sheet, int Row, int Column, string Property ) { this.SheetName = Sheet; this.Row = Row; this.Column = Column; this.PropertyName = Property; } public ExcelBindingAttribute(string Address, string Property = "Value") { this.SheetName = ""; this.Address = Address; this.PropertyName = Property; } public ExcelBindingAttribute(int Row, int Column, string Property = "Value") { this.SheetName = ""; this.Row = Row; this.Column = Column; this.PropertyName = Property; } }
橋渡しをする ExcelBind クラス
内部的にClosedXMLのXLWorkbookかIXLWorksheetを持たせる。DataContextプロパティに設定した ViewModel の PropertyChanged イベントをフックして、ClosedXML 経由で目的のセルの値を変更する。プロパティは、簡易のため Value のみに固定している。
GetDataContext メソッドは、逆向きで Excel シートから ViewModel を作るときに使う。
public class ExcelBind { private XLWorkbook _workbook; private IXLWorksheet _worksheet; public ExcelBind(XLWorkbook book) { _workbook = book; } public ExcelBind(IXLWorksheet sheet) { _worksheet = sheet; } private object _DataContext = null; public object DataContext { get => _DataContext; set { if (_DataContext != value) { _DataContext = value; if ( _DataContext is INotifyPropertyChanged ) (_DataContext as INotifyPropertyChanged).PropertyChanged += _DataContext_PropertyChanged; } } } private void _DataContext_PropertyChanged(object sender, PropertyChangedEventArgs e) { if (_DataContext == null) return; // 1.ViewModelのプロパティ名からリフレクションで値を取得 var pi = _DataContext.GetType().GetProperty(e.PropertyName); if (pi == null) return; // 2.属性から設定先のClosedXMLのプロパティを取得 var attr = Attribute.GetCustomAttribute(pi, typeof(ExcelBindingAttribute)) as ExcelBindingAttribute; // 3.設定先にリフレクションで値を代入 try { if (string.IsNullOrEmpty(attr.Address)) { // Row,Column 指定の場合 if (_worksheet != null) _worksheet.Cell(attr.Row, attr.Column).Value = pi.GetValue(_DataContext); if (_workbook != null && _workbook.Worksheet(attr.SheetName) != null) _workbook.Worksheet(attr.SheetName).Cell(attr.Row, attr.Column).Value = pi.GetValue(_DataContext); } else { // Address 指定の場合 if (_worksheet != null) _worksheet.Cell(attr.Address).Value = pi.GetValue(_DataContext); if (_workbook != null && _workbook.Worksheet(attr.SheetName) != null) _workbook.Worksheet(attr.SheetName).Cell(attr.Address).Value = pi.GetValue(_DataContext); } } catch { } // 例外は無視する } public void SetDataContext(object context) { _DataContext = context ; } public T GetDataContext<T>( T o = null ) where T : class, new() { if ( o == null ) o = new T(); // バインド先のデータを workbook/worksheet から取り出す // 1.プロパティ一覧を取得 var props = o.GetType().GetProperties(); foreach ( var pi in props ) { // 2.ExcelBindingAttribute属性がついているプロパティを取り出す var attr = Attribute.GetCustomAttribute(pi, typeof(ExcelBindingAttribute)) as ExcelBindingAttribute; if ( attr != null ) { IXLWorksheet sh = _worksheet; if ( sh == null ) { if (_workbook != null && _workbook.Worksheet(attr.SheetName) != null) sh = _workbook.Worksheet(attr.SheetName); } if ( sh == null ) { return o; } // 4.ViewModel のプロパティに値を設定する if (string.IsNullOrEmpty(attr.Address)) { pi.SetValue(o, sh.Cell(attr.Row, attr.Column).Value); } else { pi.SetValue(o, sh.Cell(attr.Address).Value.ToString()); } } } return o; } }
逆に Excel シートから ViewModel を構成する
ExcelBindのGetDataContextメソッドを使って、ViewModelを構成する例。これを使うと、Excel シートに記入してもらって、そこから値を取り出すのが楽にできるのでは?と考えたりする。
private void clickGetFromExcel(object sender, RoutedEventArgs e) { string tableName = "Personal"; string path = @"C:\Users\masuda\Documents\sample_output.xlsx"; using (var wb = new XLWorkbook(path)) { var sh = wb.Worksheets.FirstOrDefault(t => t.Name == tableName); _bind = new ExcelBind(sh); // ViewModel を再構成 var vm = _bind.GetDataContext<ViewModel>(); System.Diagnostics.Debug.WriteLine(vm.Name); System.Diagnostics.Debug.WriteLine(vm.Address); System.Diagnostics.Debug.WriteLine(vm.ModifiedDate); MessageBox.Show(vm.Name); } }
ViewModelからPropertyChangedを受ければViewになるのか?
直接 ClosedXMLに渡すわけではないが、ExcelBindクラスを媒介して値のやり取りができている。WPFやUWPのようなユーザーインタフェースはないが、ViewModelクラスのプロパティを使ってView(この場合はExcelシート)にアクセスできている。
この部分、構造が簡単な XML ならばデータとして扱うのだが、ExcelのOpenXML形式のように複雑怪奇なXMLの場合、ピンポイントで修正することを考えると「View」として扱うのがベターではないかと思っている。
ViewModelのプロパティは、別途DIなどでXMLやJSONから読み込むようにすれば、消えてしまった InfoPath のようにできるのではないな、と。
このあたりを少し整理して、NuGet に挙げられるようにする予定。