[Bread.Mvc] 開源一款自用 MVC 框架,支援 Native AOT

2023-08-31 18:03:27

Bread.Mvc

Bread.Mvc 是一款完全支援 Native AOT 的 MVC 框架,搭配同樣支援 AOT 的 Avalonia,讓你的開發事半功倍。專案開源在 Gitee,歡迎 Star

1. Ioc 容器

IoC容器是 MVC 框架的核心,為了支援AOT,Bread.Mvc 框架選擇使用 ZeroIoC 作為 IoC 容器。ZeroIoC 是一款摒棄了反射的 IoC 容器,具有極高的效能並且完全相容AOT。為了支援 .net 7, 我對 ZeroIoC 程式碼做了零星修改,重新發布在 Bread.ZeroIoC

1.1 服務註冊

由於不能使用反射,ZeroIoc 使用 SourceGenerator 技術在編譯期生成注入程式碼,這個機制依賴 ZeroIoCContainer 來觸發。ZeroIoCContainer 是部分類,並宣告了 Bootstrap 方法,使用者的注入註冊程式碼必須放在這個方法中才會被自動生成。您可以將服務註冊類放在專案的不同地方,或者放在不同的專案中。請參見以下程式碼實現自己的註冊類:

using Bread.Mvc;
using ZeroIoC;

namespace XDoc.Avalonia;

public partial class SessionContainer : ZeroIoCContainer
{
    protected override void Bootstrap(IZeroIoCContainerBootstrapper builder)
    {
        builder.AddSingleton<IAlertBox, AlertPacker>();
        builder.AddSingleton<IMessageBox, MessagePacker>();
        builder.AddSingleton<IUIDispatcher, MainThreadDispatcher>();

        builder.AddSingleton<Session>();
        builder.AddSingleton<SessionController>();
    }
}

1.2 IoC 容器初始化

需要使用 IoC.Init 方法初始化 IoC 容器,一般推薦在程式啟動之前完成服務註冊和 IoC 容器的初始化操作。請參見如下程式碼:

using Bread.Mvc;

IoC.Init(new XDocContainer(), new SessionContainer());

為了幫助理解,可以檢視 IoC.Init 函數的原始碼,就是將分佈在不同地方的多個註冊類合併為一個,大致如下所示:

public static void Init(params ZeroIoCContainer[] containers)
{
    foreach (var container in containers) {
        Resolver.Merge(container);
    }

    Resolver.End();
}

2. MVC 架構

2.1 Command

宣告:

使用者的輸入被抽象為Command,Command 連線使用者介面和 Controller。請參見如下程式碼宣告自己的 Command :

public static class AppCommands
{
    public static Command Load { get; } = new(nameof(AppCommands), nameof(Load));

    public static Command Save { get; } = new(nameof(AppCommands), nameof(Save));

    public static AsyncCommand<string, string> ImportAsync { get; } = new(nameof(AppCommands), nameof(ImportAsync));

    public static Command Delete { get; } = new(nameof(AppCommands), nameof(Delete));
}

有兩種型別的 Command, 普通 Command 和 AsyncCommand。如您所見, AsyncCommand 支援非同步操作。

使用:

一般我們我在 xaml 或 axaml 的字尾程式碼檔案中使用 Command,表示響應使用者的輸入。

private void UiListBox_SelectionChanged(object? sender, SelectionChangedEventArgs e)
{
    if (e.AddedItems == null || e.AddedItems.Count == 0) return;
    if (e.AddedItems[0] is not ImageItemViewModel img) return;
    if (img == _session.CurrentImage) return;

    SessionCommands.SwitchImage.Execution(img);
}

private void UiBtnRight_Click(object? sender, global::Avalonia.Interactivity.RoutedEventArgs e)
{
    SessionCommands.NextImage.Execution();
}

private void UiBtnLeft_Click(object? sender, global::Avalonia.Interactivity.RoutedEventArgs e)
{
    SessionCommands.PreviousImage.Execution();
}

2.2 Controller

Controller 是業務邏輯的入口,您將在這裡集中處理程式的各種邏輯。在上面 IoC 註冊的例子中,SessionController 就是一個我們自己定義的 Controller 類。
Controller 子類能自動注入已註冊過的服務(Model)。請儘可能使用組合模式以防止 Controller 程式碼體積膨脹。

public class SessionController : Controller, IDisposable
{
    readonly AppModel _app;
    readonly Session _session;
    readonly ProjectModel _prj;

    SerialTaskQueue<Doc?> _loadTask = new();

    public SessionController(AppModel app, Session session, ProjectModel prj)
    {
        _app = app;
        _prj = prj;
        _session = session;

        SessionCommands.SwitchData.Event += SwitchData_Event;
        SessionCommands.SwitchDoc.Event += SwitchDoc_Event;
        SessionCommands.SwitchImage.Event += SwitchImage_Event;

        SessionCommands.NextImage.Event += NextImage_Event;
        SessionCommands.PreviousImage.Event += PreviousImage_Event;

        SessionCommands.SaveDoc.Event += SaveDoc_Event;
        SessionCommands.NextDoc.Event += NextDoc_Event;

        _loadTask.Start();

        _prj.Loaded += _prj_Loaded;
    }
}

有以下幾點需要特別注意:

  • 必須繼承自 Controller 類才會被 Ioc 初始化時自動範例化(避免沒有顯式獲取時 Command 的 Event 事件不被掛接);
  • 所有Controller都是單例模式,必須使用 AddSingleton 註冊,防止 Command 事件掛接後被多次觸發;
  • 建構函式中的引數 Model 類也必須在 ZeroIoCContainer 中註冊才會自動注入;
  • 相關 Command 的事件處理常式必須寫在建構函式中;
  • Command 可掛接在不同的 Controller 中,但是不保證執行順序;
  • SessionController 實現了 IDisposable 介面,但是無需我們顯式呼叫 Dispose 方法。請在應用程式結束時呼叫 IoC.Dispose() 清理。

2.3 Model

Model 連結業務邏輯和使用者介面。使用者輸入(滑鼠、鍵盤、觸屏動作等)通過 Command 觸發 Controller 中的業務流程,
在 Controller 中更新 Model 的屬性值,這些修改操作又立即觸發使用者介面的重新整理。
邏輯是閉環的:UI->Command->Controller->Model->UI。

定義:

原始碼中對 Model 的定義相當簡單,只是宣告必須要實現 INotifyPropertyChanged 介面。

public abstract class Model : INotifyPropertyChanged
{
    public bool IsDataChanged { get; set; }

    public event PropertyChangedEventHandler? PropertyChanged;

    protected virtual void OnPropertyChanged(string name)
    {
        IsDataChanged = true;
        this.PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(name));
    }
}

宣告:

一般我們將 Model 和相關的 Controller 宣告在一個類庫中,並用 internal set 修飾以防止不必要的外部修改。建議您也只在對應的 Controller 中修改 Model 的屬性。不加限制的修改 Model 物件的屬性,只會帶來更多的屎山程式碼。

public class ProjectModel : Model
{
    public int Volume { get; internal set; } = 3;

    public RangeList<Volume> Volumes { get; } = new();

    public string NewDocFolder { get; internal set; } = string.Empty;

    public RangeList<NewDoc> NewDocs { get; } = new();

    public ProjectModel()
    {
    }
}

推薦使用 PropertyChanged.Fody 自動實現 INotifyPropertyChanged 介面。
事實上因為實現了 INotifyPropertyChanged 介面, 您可以在xaml直接繫結 Model 中的屬性。

使用:

我們使用 Watch 函數監聽 Model 屬性的變化,Watch 和 UnWatch 函數的原型如下:

public static void Watch(this INotifyPropertyChanged publisher, string propertyName, Action callback);
public static void Watch(this INotifyPropertyChanged publisher, Action callback, params string[] propertyNames);
public static void UnWatch(this INotifyPropertyChanged publisher, string name, Action callback);
public static void UnWatch(this INotifyPropertyChanged publisher, Action callback, params string[] propertyNames);

通常我們在 Window 或者 UserControl 的 Load 程式碼中完成依賴注入和屬性監聽。
你可以一次監聽一個屬性,或同時監聽多個屬性並在一個 Action 中響應這些屬性的變化。

請記住,監聽的目的是為了響應業務變化以同步更新使用者介面。

private void ImageSlider_Loaded(object? sender, global::Avalonia.Interactivity.RoutedEventArgs e)
{
    if (Design.IsDesignMode) return;

    _session = IoC.Get<Session>();  // 從 IoC 容器中取出範例, Session 必須先註冊。
    _session.Watch(nameof(Session.CurrentImage), Session_CurrentImage_Changed); // 監聽 CurrentImage 屬性的變化

    uiListBox.ItemsSource = _session.Images; // UI元素直接繫結 Model 中的屬性
    uiListBox.SelectionChanged += UiListBox_SelectionChanged;
}

3. 其他基礎設施

3.1 Avalonia

當您的應用平臺是 Avalonia 時,Bread.Mvc.Avalonia 包含一些非常有用的擴充套件。

IUIDispatcher 介面 :UI執行緒注入

Bread.Mvc.Avalonia.MainThreadDispatcher 實現了 IUIDispatcher 介面。
因為當屬性被外部執行緒修改時,Watch 機制需要使用這個介面檢測當前執行緒是否在主執行緒中,並將變更 Invoke 給UI執行緒,所以您必須在Avalonia應用中註冊這個服務。

 builder.AddSingleton<IUIDispatcher, Bread.Mvc.Avalonia.MainThreadDispatcher>();

Reactive

為了簡化 Watch 操作,我們為常見的控制元件準備了更易用的繫結方法。


public interface IEnumDescriptioner<T> where T : Enum
{
    string GetDescription(T value);
}

public partial class SettingsPanel : UserControl
{
    SpotModel _spot = null!;

    public SettingsPanel()
    {
        InitializeComponent();

        if (Design.IsDesignMode) return;

        _spot = IoC.Get<SpotModel>();

        // combox initted by enum which LanguageHelper implements IEnumDescriptioner
        uiComboxLanguage.InitBy(new LanguageHelper(), Language.Chinese, 
            Language.English, Language.Japanese, Language.Japanese); 

        uiComboxLanguage.BindTo(_spot, m => m.Language); // ComboBox
       
        uiNUDAutoSave.BindTo(_app, x => x.AutoSave); // NumericUpDown
        uiTbRegCode.BindTo(_app, x => x.RegCode); // TextBox
        uiTbFilePath.BindTo(_app, x => x.FilePath); // TextBlock

        uiSlider.BindTo(_app, x => x.Progress); // Slider

        uiSwitchAutoSpot.BindTo(_spot, m => m.IsAutoSpot); // SwitchButton
        uiTbtnChannel.BindTo(_app, x => x.IsLeftChannel); // ToggleButton

        uiCheckSexual.BindTo(_app, x => x.IsMale); // CheckBox
    }
}

3.2 WPF

略,不想多說。

3.3 紀錄檔

Bread.Utility 中提供了一個簡單的紀錄檔類 Log。

public static class Log
{
    /// <summary>
    /// 開啟紀錄檔
    /// </summary>
    /// <param name="path">紀錄檔檔名稱</param>
    /// <param name="expire">紀錄檔檔案目錄下最多儲存天數。0表示不刪除多餘紀錄檔</param>
    /// <exception cref="ArgumentNullException"></exception>
    public static void Open(string path, int expire = 0);

    /// <summary>
    /// 關閉紀錄檔檔案
    /// </summary>
    public static void Close();

    public static void Info(string info, string? category = null,
        [CallerFilePath] string? className = null,
        [CallerMemberName] string? methondName = null,
        [CallerLineNumber] int lineNumber = 0);

    public static void Warn(string warn, string? category = null,
        [CallerFilePath] string? className = null,
        [CallerMemberName] string? methondName = null,
        [CallerLineNumber] int lineNumber = 0);

    public static void Error(string error, string? category = null,
        [CallerFilePath] string? className = null,
        [CallerMemberName] string? methondName = null,
        [CallerLineNumber] int lineNumber = 0);

    public static void Exception(Exception ex);
}

3.4 組態檔讀寫

內建 Config 類用於 ini 檔案讀寫。

public class CustomController : Controller
{
    Config _appConfig;
    readonly AppModel _app;
    readonly ProjectModel _prj;

    public AppController(AppModel app, ProjectModel prj)
    {
        _app = app;
        _prj = prj;
        
        _appConfig = new Config(Path.Combine(app.AppFolder, "app.data"));
    
        AppCommands.Load.Event += Load_Event;
        AppCommands.Save.Event += Save_Event;
    }

    private void Load_Event()
    {
        _appConfig.Load();
        _app.LoadFrom(_appConfig);
        _prj.LoadFrom(_appConfig);
    }

    private void Save_Event()
    {
        _app.SaveTo(_appConfig);
        _prj.SaveTo(_appConfig);
        _appConfig.Save();
    }
}
public class AppModel : Model
{
    public string Recorder { get; internal set; } = string.Empty;

    public ReadOnlyCollection<string> RecentList { get { return _recentList.AsReadOnly(); } }

    List<string> _recentList = new();

    public AppModel()
    {
    }

    public override void LoadFrom(Config config)
    {
        config.Load(nameof(AppModel), nameof(Recorder), (string value) => { Recorder = value; });

        var list = config.LoadList(nameof(RecentList));
        foreach (var item in list) {
            if (File.Exists(item)) {
                _recentList.Add(item);
            }
        }
        OnPropertyChanged(nameof(RecentList));
    }


    public override void SaveTo(Config config)
    {
        base.SaveTo(config);

        config[nameof(AppModel), nameof(Recorder)] = Recorder;
        config.SaveList(nameof(RecentList), _recentList);
    }
}

4. 限制

  • 只支援 .net 7 及之後的版本;
  • 不支援 asp.net core;