ClosedXMLとMVVMパターンを良好な関係にしてみる

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 に挙げられるようにする予定。

カテゴリー: 開発, C# パーマリンク