1. 程式人生 > >【限時免費】AppBoxCore - 細粒度許可權管理框架(EFCore+RazorPages+async/await)!

【限時免費】AppBoxCore - 細粒度許可權管理框架(EFCore+RazorPages+async/await)!

目錄

  1. 前言
  2. 全新AppBoxCore
    1. RazorPages 和 TagHelpers 技術架構
    2. 頁面處理器和資料庫操作的非同步呼叫
    3. Authorize特性和自定義許可權驗證過濾器
      1. Authorize登入授權
      2. 自定義CheckPower許可權過濾器
      3. CheckPower特性控制頁面的瀏覽許可權
      4. 表格行連結圖示的許可權控制
      5. 表格行刪除按鈕的後臺許可權控制
    4. 實體類模型定義的多對多聯接表
      1. 為什麼 EF Core 不支援隱式聯接表
      2. 定義聯接表模型類
      3. 配置多對多關係
      4. 聯接表相關程式碼更新
      5. 新增 IKey2ID 介面
    5. 表單和表格的快速模型初始化
      1. 表單控制元件的快速模型初始化
      2. 表格控制元件的快速模型初始化
    6. 對比 Dapper 和 EFCore 的實現細節
      1. 角色列表頁面
      2. 向角色中新增使用者列表
      3. 編輯使用者
      4. Menu模型類的ViewPowerName屬性
        1. 編輯頁面(獲取初始資料)
        2. 列表頁面
        3. 編輯頁面
        4. 編輯頁面後臺   
  3. 截圖賞析
    1. 深色主題(Dark Hive)
    2. 淺色主題(Pure Purple)  
  4. 原始碼下載

 

 

一、前言

AppBox的歷史可以追溯到 2009 年,第一個版本的 AppBox 是基於 FineUI(開源版)的通用許可權管理系統,包括使用者管理、職稱管理、部門管理、角色管理、角色許可權管理等模組。

 

AppBox提供一種通用的細粒度的許可權控制結構,可以對頁面上任意元素(按鈕,文字框,表格行中的連結)的啟用禁用顯示隱藏進行單獨的控制。

AppBox中的許可權管理涉及幾個概念:角色、使用者、許可權、頁面

  1. 角色:用來對使用者進行分組,許可權實際上是和角色對應的
  2. 使用者:一個使用者可以屬於多個角色
  3. 許可權:頂級許可權列表,比如“CoreDeptView”的意思是部門瀏覽許可權,為了方便許可權管理,我們還給許可權一個簡單的分組
  4. 頁面:使用者操作的載體,一個頁面可以擁有多個許可權,這個控制是在頁面程式碼中進行的,主動權在頁面

 這也是我們在 AppBox v3.0 中率先提出的【扁平化的許可權設計】理念,用一張圖來概括:

 

一路走來,我們累計了好多篇文章,這裡一併彙總出來:

2020年:

....

2018年:

【續】【AppBox】5年後,我們為什麼要從 Entity Framework 轉到 Dapper 工具?  

【AppBox】5年後,我們為什麼要從 Entity Framework 轉到 Dapper 工具?

【視訊教程】一步步將AppBox升級到Pro版

2016年:

AppBox v6.0中實現子頁面和父頁面的複雜互動  

2014年:

AppBoxPro - 細粒度通用許可權管理框架(可控制表格行內按鈕)原始碼提供下載  

【6年開源路】FineUI家族今日全部更新(FineUI + FineUI3to4 + FineUI.Design + AppBox)! 

2013年:

AppBox_v2.0完整版免費下載,暨AppBox_v3.0正式釋出!

AppBox升級進行時 - 擁抱Entity Framework的Code First開發模式

AppBox升級進行時 - 扁平化的許可權設計

AppBox升級進行時 - Entity Framework的增刪改查

AppBox升級進行時 - 如何向OrderBy傳遞字串引數(Entity Framework)

AppBox升級進行時 - 關聯表查詢與更新(Entity Framework)

AppBox升級進行時 - Attach陷阱(Entity Framework)

AppBox升級進行時 - Any與All的用法(Entity Framework)

AppBox - From Subsonic to EntityFramework 

2012年:

AppBox v1.0 釋出了 

AppBox v2.0 釋出了! 

2010年:

AppBox - 企業綜合管理系統框架最新進展

2009年:

ExtAspNet應用技巧(二十四) - AppBox之Grid資料庫分頁排序與批量刪除

 

二、全新AppBoxCore 

為什麼稱為全新 AppBoxCore?因為這次的升級我們採用了微軟最新的跨平臺 .Net Core 3.1 版本,所有技術架構都是引領潮流的存在:

  • 基於最新的 FineUICore 控制元件庫
  • 基於  ASP.NET Core 的 RazorPages 和 TagHelpers 技術架構
  • 使用 Entity Framework Core 進行資料庫檢索和更新
  • 頁面處理器(GET/POST)和資料庫操作全部改為非同步呼叫(async/await)。
  • 基於頁面模型的Authorize特性和自定義許可權驗證過濾器CheckPowerAttribute
  • 實體類模型定義的多對多聯接表(RoleUser)
  • 使用依賴注入新增資料庫連線例項

 

2.1 RazorPages 和 TagHelpers 技術架構

Razor Pages 和 Tag Helpers 是微軟在 ASP.NET Core 中的創新,使得傳統的 MVC 架構在檔案組織和頁面標籤上更像傳統的 ASP.NET WebForms,並且使用更加簡單。

我曾在 2009年寫過一篇文章,介紹引用這兩個特性的 FineUICore 看起來和之前的WebForms版本有多類似,可以參考一下:

【FineUICore】全新ASP.NET Core,比WebForms還簡單!  

下面就以這篇文章中的一張經典對比截圖看下兩者有多類似:

 

在官網的更新記錄(FineUICore v5.5.0),我們給出了這樣的文字描述:

+支援ASP.NET Core的新特性Razor Pages和Tag Helpers。
    -重寫了全部線上示例(包含750多個頁面),訪問網址:https://pages.fineui.com/
    +Razor Pages相比之前的Model-View-Controller,有如下優點:
        -Razor Pages是建立ASP.NET Core 2.0+網站應用程式的推薦方法。
        -Razor Pages基於資料夾的組織結構,無需複雜的路由配置和額外引入的Areas概念。
        -Razor Pages將MVC的Controller,Action和ViewModel合併為一個PageModel,更加輕量級。
        -Razor Pages的Page和PageModel在一個資料夾下,而MVC的Controller和View分別在不同的資料夾下,並且View還是二級目錄。
        -Razor Pages中Page和PageModel一一對應,避免MVC下可能出現的巨大Controller現象(一個Controller對應多個檢視)。
        -Razor Pages預設設定更安全,無需為每一個控制器方法指定ValidateAntiForgeryToken特性。
    +Tag Helpers相比之前的Html Helpers,有如下優點:
        -Tag Helpers是建立Razor Views和Razor Pages的推薦方法。
        -Tag Helpers更像是標準的HTML,熟悉HTML的前端設計師,無需學習C# Razor語法即可編輯檢視或頁面。
        -Tag Helpers可以更好地配合VS的智慧感知,在你輸入標籤的第一個字元開始就提供強大的程式碼輔助完成功能。
        -Tag Helpers更容易被WebForms開發人員所接受,可以直接從WebForms專案中拷貝頁面標籤到ASP.NET Core檢視中。
        -Tag Helpers可以更好地配合VS的文件格式化工具(Ctrl+K, D),而Html Helpers在VS中格式化會有無限縮排的問題。

 

毫無疑問,如果你還在從事 ASP.NET WebForms 的相關開發,並希望學習微軟的最新 ASP.NET Core 技術的話,這次的 AppBoxCore 將是最佳的學習案例!

 

下面給出 AppBoxCore 中的登入頁面標籤,是不是似曾相識:

<f:Window ID="Window1" IsModal="true" Hidden="false" EnableClose="false" EnableMaximize="false" WindowPosition="GoldenSection" Icon="Key" Title="@Model.Window1Title" Layout="HBox" BoxConfigAlign="Stretch" BoxConfigPosition="Start" Width="500">
    <Items>
        <f:Image ID="imageLogin" ImageUrl="~/res/images/login/login_2.png" CssClass="login-image">
        </f:Image>
        <f:SimpleForm ID="SimpleForm1" LabelAlign="Top" BoxFlex="1" BodyPadding="30 20" ShowBorder="false" ShowHeader="false">
            <Items>
                <f:TextBox ID="tbxUserName" FocusOnPageLoad="true" Label="帳號" Required="true" ShowRedStar="true" Text="">
                </f:TextBox>
                <f:TextBox ID="tbxPassword" TextMode="Password" Required="true" ShowRedStar="true" Label="密碼" Text="">
                </f:TextBox>
            </Items>
        </f:SimpleForm>
    </Items>
    <Toolbars>
        <f:Toolbar Position="Bottom">
            <Items>
                <f:ToolbarText Text="管理員賬號: admin/admin"></f:ToolbarText>
                <f:ToolbarFill></f:ToolbarFill>
                <f:Button ID="btnSubmit" Icon="LockOpen" Type="Submit" ValidateForms="SimpleForm1" OnClickFields="SimpleForm1" OnClick="@Url.Handler("btnSubmit_Click")" Text="登陸"></f:Button>
            </Items>
        </f:Toolbar>
    </Toolbars>
</f:Window>

 

2.2 頁面處理器和資料庫操作的非同步呼叫

伺服器的可用執行緒是有限的,在高負載情況下的可能所有執行緒都被佔用,此時伺服器就無法處理新的請求,直到有執行緒被釋放。

  • 使用同步程式碼時,可能會出現多個執行緒被佔用而不能執行任何操作的情況,因為它們正在等待 I/O 完成。
  • 使用非同步程式碼時,當執行緒正在等待 I/O 完成時,伺服器可以將其執行緒釋放用於處理其他請求。

下面就以角色編輯頁面,非同步程式碼呼叫如下:

[BindProperty]
public Role Role { get; set; }

public async Task<IActionResult> OnGetAsync(int id)
{
    Role = await DB.Roles
        .Where(m => m.ID == id).AsNoTracking().FirstOrDefaultAsync();


    if (Role == null)
    {
        return Content("無效引數!");
    }

    return Page();
}

public async Task<IActionResult> OnPostRoleEdit_btnSaveClose_ClickAsync()
{
    if (ModelState.IsValid)
    {
        DB.Entry(Role).State = EntityState.Modified;
        await DB.SaveChangesAsync();

        // 關閉本窗體(觸發窗體的關閉事件)
        ActiveWindow.HidePostBack();
    }

    return UIHelper.Result();
}

這裡 async Task 表示一個非同步函式,在 EFCore查詢中,通過 await 關鍵字表明一個非同步呼叫。

這段程式碼的同步形式:

[BindProperty]
public Role Role { get; set; }

public IActionResult OnGet(int id)
{
    Role = DB.Roles
        .Where(m => m.ID == id).AsNoTracking().FirstOrDefault();


    if (Role == null)
    {
        return Content("無效引數!");
    }

    return Page();
}

public IActionResult OnPostRoleEdit_btnSaveClose_Click()
{
    if (ModelState.IsValid)
    {
        DB.Entry(Role).State = EntityState.Modified;
        DB.SaveChanges();

        // 關閉本窗體(觸發窗體的關閉事件)
        ActiveWindow.HidePostBack();
    }

    return UIHelper.Result();
}

除了 async Task await 等幾個關鍵詞,以及函式名的Async 字尾之外,其他地方和非同步程式碼一模一樣。

是不是很簡單,C#提供瞭如此優雅的程式碼來實現非同步程式設計,讓新手簡單看一眼就明白了,這也沒誰了。

 

2.3 Authorize特性和自定義許可權驗證過濾器

2.3.1 Authorize登入授權

登入之後,我們把 [Authorize] 特性新增到 BaseAdminModel 基類上,這樣所有的 /Admin 目錄下的頁面都受到了登入保護。

每個 Pages/Admin/ 目錄中的頁面都繼承自 BaseAdminModel 類,比如角色編輯頁面:

public class DeptEditModel : BaseAdminModel

當然這裡僅僅是登入授權保護!

 

 2.3.2 自定義CheckPower許可權過濾器

那麼該如何判斷登入使用者是否有訪問某個頁面的許可權呢?

我們自定義了一個許可權驗證過濾器:

public class CheckPowerAttribute : ResultFilterAttribute
{
    /// <summary>
    /// 許可權名稱
    /// </summary>
    public string Name { get; set; }

    public override void OnResultExecuting(ResultExecutingContext filterContext)
    {
        HttpContext context = filterContext.HttpContext;
        
        if (!String.IsNullOrEmpty(Name) && !BaseModel.CheckPower(context, Name))
        {
            if (context.Request.Method == "GET")
            {
                BaseModel.CheckPowerFailWithPage(context);
                filterContext.Result = new EmptyResult();
            }
            else if (context.Request.Method == "POST")
            {
                BaseModel.CheckPowerFailWithAlert();
                filterContext.Result = UIHelper.Result();
            }
        }

    }
}

這個過濾器接受一個名為Name的字串引數,用來表示一個許可權名稱:

而一個使用者是否擁有這個許可權,就看這個使用者所屬的角色是否擁有這個許可權:

 

這個許可權可以對頁面,以及頁面上的控制元件進行細粒度的控制。

2.3.3 CheckPower特性控制頁面的瀏覽許可權

比如角色編輯頁面的瀏覽許可權:

[CheckPower(Name = "CoreRoleEdit")]
public class RoleEditModel : BaseAdminModel
{
    // ....
}

 

2.3.4 表格行連結圖示的許可權控制

既然不能編輯角色,那麼在角色管理中,就應該禁用表格行中的編輯連結按鈕,如下圖所示:

 

這個怎麼做到的呢?

首先,獲取當前使用者是否有編輯角色的許可權:

public class RoleModel : BaseAdminModel
{
    public bool PowerCoreRoleEdit { get; set; }
        
    public async Task OnGetAsync()
    {
        PowerCoreRoleEdit = CheckPower("CoreRoleEdit");
        // ...
    }
    
    // ...
}

然後,在檢視標籤中:

<f:Grid ID="Grid1" ...>
    <Columns>
        <f:RenderField EnableColumnHide="false" EnableHeaderMenu="false" Width="50" RendererFunction="renderActionEdit"></f:RenderField>
        <f:RenderField EnableColumnHide="false" EnableHeaderMenu="false" Width="50" RendererFunction="renderActionDelete"></f:RenderField>
    </Columns>
</f:Grid>

注意,其中renderActionEdit 用來渲染編輯列,這是一個JS函式:

<script>

    var coreRoleEdit = @Convert.ToString(Model.PowerCoreRoleEdit).ToLower();

    function renderActionEdit(value, params) {
        var imageUrl = '@Url.Content("~/res/icon/pencil.png")';
        var disabledCls = coreRoleEdit ? '' : ' f-state-disabled';
        return '<a class="action-btn edit'+ disabledCls +'" href="javascript:;"><img class="f-grid-cell-icon" src="' + imageUrl + '"></a>';
    }
    
</script>

這就從UI上阻止使用者訪問角色編輯頁面,如果使用者一意孤行,想通過URL直接訪問,就會觸發自定義CheckPower過濾器:

2.3.5 表格行刪除按鈕的後臺許可權控制

上面表格的行刪除按鈕可以做類似的許可權控制。但是實際的刪除操作是一個POST請求到頁面模型的處理器方法(Handler),而不是一個新的頁面(比如角色編輯頁面)。

既然使用者可以直接通過URL訪問角色編輯頁面,使用者通過可以偽造POST請求來執行刪除操作,這就需要對刪除的後臺處理器方法進行保護!

public async Task<IActionResult> OnPostRole_DoPostBackAsync(...)
{
    if (actionType == "delete")
    {
        // 在操作之前進行許可權檢查
        if (!CheckPower("CoreRoleDelete"))
        {
            CheckPowerFailWithAlert();
            return UIHelper.Result();
        }

        // ....
    }

    return UIHelper.Result();
}

上述的 CheckPower 方法是定義在基類中的一個公共方法:

public static bool CheckPower(HttpContext context, string powerName)
{
    // 當前登陸使用者的許可權列表
    List<string> rolePowerNames = GetRolePowerNames(context);
    if (rolePowerNames.Contains(powerName))
    {
        return true;
    }

    return false;
}

 

新手往往忽略了這個保護操作,覺得頁面上不可點選就萬事大吉,這是馬虎不得的。要記著這句話:客戶端的請求資料都是可以偽造的!

 

2.4 實體類模型定義的多對多聯接表

2.4.1 為什麼 EF Core 不支援隱式聯接表

EF Core不支援沒有實體類來表示聯接表的多對多關係。 這一點剛開始讓人很是意外,畢竟都發展這麼多年了,之前 EF 支援的東西 EF Core居然還不支援。

https://docs.microsoft.com/en-us/ef/core/modeling/relationships

Many-to-many relationships without an entity class to represent the join table are not yet supported. However, you can represent a many-to-many relationship by including an entity class for the join table and mapping two separate one-to-many relationships.

不過看到微軟這個文件中描述的細節,我覺得微軟是不打算支援多對多關係的隱式聯接表了:

https://docs.microsoft.com/en-us/aspnet/core/data/ef-rp/complex-data-model?view=aspnetcore-3.1&tabs=visual-studio#many-to-many-relationships

Data models start out simple and grow. Join tables without payload (PJTs) frequently evolve to include payload. By starting with a descriptive entity name, the name doesn't need to change when the join table changes. Ideally, the join entity would have its own natural (possibly single word) name in the business domain.

簡單翻一下是這樣的:資料模型開始時很簡單,隨著內容的增加,純聯接表 (PJT) 通常會發展為有效負載的聯接表。

也就是微軟認為,隱式的聯接表隨著業務的增加很可能不適用,很可能會向聯接表中新增新的欄位,這樣你還是需要建立顯式的聯接表。

 

既然如此!還不如不支援隱式的聯接表了。

 

好吧,看來 EF 中的隱式的聯接表是找不回來了。下面就來看下怎麼在 EF Core 中使用顯式的聯接表吧。

 

2.4.2 定義聯接表模型類

聯接表模型定義很簡單,我們就以使用者角色關係表為例:

public class RoleUser
{
    public int RoleID { get; set; }
    public Role Role { get; set; }

    public int UserID { get; set; }
    public User User { get; set; }
    
}

 

在使用者表和角色表中,我們要分別新增導航屬性,來表示一對多的關係,在角色表中:

public class Role
{
    [Key]
    public int ID { get; set; }

    [Display(Name="名稱")]
    [StringLength(50)]
    [Required]
    public string Name { get; set; }

    [Display(Name = "備註")]
    [StringLength(500)]
    public string Remark { get; set; }


    public List<RoleUser> RoleUsers { get; set; }
}

注意,這裡的導航屬性是 List<RoleUser> 。

 

作為對比,我們看下在 EF 版本中,這裡的導航是:

public List<User> Users { get; set; }

 

這個區別很重要。也就是說,在EFCore中,使用者表的導航屬性是聯接表RoleUser集合,這將導致一系列的程式碼更新,在隨後的一小節會有對比示例。

 

2.4.3 配置多對多關係

在EF版本中,可以方便的配置隱式聯接表的多對多關係,類似如下程式碼:

modelBuilder.Entity<Role>()
    .HasMany(r => r.Users)
    .WithMany(u => u.Roles)
    .Map(x => x.ToTable("RoleUsers")
        .MapLeftKey("RoleID")
        .MapRightKey("UserID"));

 

而在 EF Core 版本中,實際上是不存在多對多的關係的,而是通過兩個一對多關係來表示,相應的程式碼如下所示:

protected override void OnModelCreating(ModelBuilder modelBuilder)
{
    base.OnModelCreating(modelBuilder);

    // https://docs.microsoft.com/en-us/ef/core/modeling/relationships
    modelBuilder.Entity<RoleUser>()
        .ToTable("RoleUsers")
        .HasKey(t => new { t.RoleID, t.UserID });
    modelBuilder.Entity<RoleUser>()
        .HasOne(u => u.User)
        .WithMany(u => u.RoleUsers)
        .HasForeignKey(u => u.UserID);
    modelBuilder.Entity<RoleUser>()
       .HasOne(u => u.Role)
       .WithMany(u => u.RoleUsers)
       .HasForeignKey(u => u.RoleID);
       
}

程式碼稍顯複雜,但結構還是比較清晰的:

  • 定義聯接表名為RoleUsers,並指定組合鍵RoleID和UserID
  • HasOne + WithMany 組合,來定義一對多關係
  • 使用兩個一對多關係,來迂迴表示多對多關係

 

2.4.4 聯接表相關程式碼更新

由於實體聯接表的引入,我們需要對多處程式碼進行重構。下面給出幾個示例。

 

1. BaseModel中的GetRolePowerNames方法,之前 EF 版程式碼:

db.Roles.Include(r => r.Powers).Where(r => roleIDs.Contains(r.ID)).ToList();

更新為 EF Core 版程式碼:

db.Roles.Include(r => r.RolePowers).ThenInclude(rp => rp.Power).Where(r => roleIDs.Contains(r.ID)).ToList();

 

2. 使用者編輯頁面初始化程式碼,之前 EF 版程式碼:

DB.Users.Include(u => u.Roles).Where(m => m.ID == id).FirstOrDefault();

//...

String.Join(",", CurrentUser.Roles.Select(r => r.Name).ToArray());

更新為 EF Core 版程式碼:

await DB.Users.Include(u => u.RoleUsers).ThenInclude(ru => ru.Role).Where(m => m.ID == id).FirstOrDefaultAsync();

// ...

String.Join(",", CurrentUser.RoleUsers.Select(ru => ru.Role.Name).ToArray());

 

3. 職稱列表頁面刪除行程式碼,之前 EF 版程式碼:

DB.Users.Where(u => u.Titles.Any(r => r.ID == deletedRowID)).Count();

更新為 EF Core 版程式碼:

await DB.Users.Where(u => u.TitleUsers.Any(r => r.TitleID == deletedRowID)).CountAsync();

 

2.4.5 新增 IKey2ID 介面

在使用者編輯頁面,我們需要對使用者所屬的角色進行整體替換,類似的處理還有很多,我們把類似的操作都列出來:

  • 替換使用者所屬的角色列表
  • 替換使用者所屬的職稱列表
  • 替換角色的許可權列表
  • 向角色中新增使用者列表
  • 向職稱中新增使用者列表
  • 新增使用者時,新增角色列表
  • 新增使用者時,新增職稱列表

其實所有這些操作都是對多對多聯接表的操作,為了避免在多處出現類似的重複程式碼,我們新增了一個 IKey2ID 介面,表示有組合主鍵的聯接表:

public interface IKey2ID
{
    int ID1 { get; set; }

    int ID2 { get; set; }

}

使用者角色聯接表是實現這個介面的:

public class RoleUser : IKey2ID
{
    public int RoleID { get; set; }
    public Role Role { get; set; }

    public int UserID { get; set; }
    public User User { get; set; }


    [NotMapped]
    public int ID1
    {
        get
        {
            return RoleID;
        }
        set
        {
            RoleID = value;
        }
    }
    [NotMapped]
    public int ID2
    {
        get
        {
            return UserID;
        }
        set
        {
            UserID = value;
        }
    }

}

看似簡單的程式碼,卻蘊藏著我們的深入思考。為了在後期程式碼中用到大量的 Lambda 表示式,我們就需要固定的屬性名 ID1 和 ID2。

我們使用命名約定,將兩個主鍵分別對映到 ID1 和 ID2,在不同的聯接表中,含義是不同的:

  • RoleUser:ID1 => RoleID, ID2 => UserID
  • TitleUser:ID1 => TitleID, ID2 => UserID
  • RolePower:ID1 => RoleID, ID2 => PowerID

在基類(BaseModel)中,新增對聯接表的公共操作:

protected T Attach2<T>(int keyID1, int keyID2) where T : class, IKey2ID, new()
{
    T t = DB.Set<T>().Local.Where(x => x.ID1 == keyID1 && x.ID2 == keyID2).FirstOrDefault();
    if (t == null)
    {
        t = new T { ID1 = keyID1, ID2 = keyID2 };
        DB.Set<T>().Attach(t);
    }
    return t;
}

protected void AddEntities2<T>(int keyID1, int[] keyID2s) where T : class, IKey2ID, new()
{
    foreach (int id in keyID2s)
    {
        T t = Attach2<T>(keyID1, id);
        DB.Entry(t).State = EntityState.Added;
    }
}

protected void AddEntities2<T>(int[] keyID1s, int keyID2) where T : class, IKey2ID, new()
{
    foreach (int id in keyID1s)
    {
        T t = Attach2<T>(id, keyID2);
        DB.Entry(t).State = EntityState.Added;
    }
}

protected void RemoveEntities2<T>(List<T> existEntities, int[] keyID1s, int[] keyID2s) where T : class, IKey2ID, new()
{
    List<T> itemsTobeRemoved;
    if (keyID1s == null)
    {
        itemsTobeRemoved = existEntities.Where(x => keyID2s.Contains(x.ID2)).ToList();
    }
    else
    {
        itemsTobeRemoved = existEntities.Where(x => keyID1s.Contains(x.ID1)).ToList();
    }
    itemsTobeRemoved.ForEach(e => existEntities.Remove(e));
}

protected void ReplaceEntities2<T>(List<T> existEntities, int keyID1, int[] keyID2s) where T : class, IKey2ID, new()
{
    if (keyID2s.Length == 0)
    {
        existEntities.Clear();
    }
    else
    {
        int[] tobeAdded = keyID2s.Except(existEntities.Select(x => x.ID2)).ToArray();
        int[] tobeRemoved = existEntities.Select(x => x.ID2).Except(keyID2s).ToArray();

        AddEntities2<T>(keyID1, tobeAdded);
        RemoveEntities2<T>(existEntities, null, tobeRemoved);
    }
}

protected void ReplaceEntities2<T>(List<T> existEntities, int[] keyID1s, int keyID2) where T : class, IKey2ID, new()
{
    if (keyID1s.Length == 0)
    {
        existEntities.Clear();
    }
    else
    {
        int[] tobeAdded = keyID1s.Except(existEntities.Select(x => x.ID1)).ToArray();
        int[] tobeRemoved = existEntities.Select(x => x.ID1).Except(keyID1s).ToArray();

        AddEntities2<T>(tobeAdded, keyID2);
        RemoveEntities2<T>(existEntities, tobeRemoved, null);
    }
}

這裡的 AddEntities2 和 ReplaceEntities2 分別有兩個過載實現,對應於 ID1 和 ID2 兩個互換的不同情況。

 

這裡的實現其實非常巧妙,從優雅的呼叫就能看的出來,舉例如下:

  • 替換角色的許可權列表
ReplaceEntities2<RolePower>(role.RolePowers, selectedRoleID, selectedPowerIDs);
  • 向角色中新增使用者列表
AddEntities2<RoleUser>(roleID, selectedRowIDs);

 

2.5 表單和表格的快速模型初始化

 FineUICore的表單和表格控制元件都支援快速模型初始化繫結,通過一個簡單的 For 屬性,讓我們少些很多程式碼,下面通過 FineUICore 官網示例做個簡單的對比。

2.5.1 表單控制元件的快速模型初始化

手工設定表單欄位屬性的示例:

<f:SimpleForm ShowHeader="false" BodyPadding="10" ShowBorder="false" ID="SimpleForm1">
    <Items>
        <f:TextBox ShowRedStar="true" Required="true" Label="使用者名稱" MaxLength="20" ID="UserName"></f:TextBox>
        <f:TextBox ShowRedStar="true" Required="true" TextMode="Password" RequiredMessage="密碼不能為空!" EnableValidateTrim="false" Label="密碼" ID="Password"
                   MaxLength="9" MaxLengthMessage="密碼最大為 9 個字元!" MinLength="3" MinLengthMessage="密碼最小為 3 個字元!" Regex="^(?:[0-9]+[a-zA-Z]|[a-zA-Z]+[0-9])[a-zA-Z0-9]*$" RegexMessage="密碼至少包含一個字母和數字!"></f:TextBox>
    </Items>
</f:SimpleForm>

程式碼來自:https://pages.fineui.com/#/DataModel/Login

 

For屬性快速設定的示例:

<f:SimpleForm ShowHeader="false" BodyPadding="10" ShowBorder="false" ID="SimpleForm1">
    <Items>
        <f:TextBox For="CurrentUser.UserName"></f:TextBox>
        <f:TextBox For="CurrentUser.Password"></f:TextBox>
    </Items>
</f:SimpleForm>

程式碼來自:https://pages.fineui.com/#/DataModel/LoginModel

 

這兩個程式碼實現的功能是一模一樣的,只不過 For 屬性會從模型類中讀取欄位的註解值,並自動設定相應的屬性:

public class User
{
    [Required]
    [Display(Name = "使用者名稱")]
    [StringLength(20)]
    public string UserName { get; set; }
    

    [Required(ErrorMessage = "使用者密碼不能為空!", AllowEmptyStrings = true)]
    [Display(Name = "密碼")]
    [MaxLength(9, ErrorMessage = "密碼最大為 9 個字元!")]
    [MinLength(3, ErrorMessage = "密碼最小為 3 個字元!")]
    [DataType(DataType.Password)]
    [RegularExpression("^(?:[0-9]+[a-zA-Z]|[a-zA-Z]+[0-9])[a-zA-Z0-9]*$", ErrorMessage = "密碼至少包含一個字母和數字!")]
    public string Password { get; set; }

}

 

這樣做有兩個明顯的好處:

  • 簡化程式碼
  • 去除重複,減少人為的輸入錯誤,以及後期更新時可能存在不一致

 

頁面顯示效果:

 

AppBoxCore 中的所有表單都應用了 For 屬性快速設定,因此頁面程式碼非常簡潔,看一下相對比較簡單的角色編輯頁面:

<f:Panel ID="Panel1" ShowBorder="false" ShowHeader="false" AutoScroll="true" IsViewPort="true" Layout="VBox">
<Toolbars>
    <f:Toolbar ID="Toolbar1">
        <Items>
            <f:Button ID="btnClose" Icon="SystemClose" Text="關閉">
                <Listeners>
                    <f:Listener Event="click" Handler="F.activeWindow.hide();"></f:Listener>
                </Listeners>
            </f:Button>
            <f:ToolbarSeparator></f:ToolbarSeparator>
            <f:Button ID="btnSaveClose" ValidateForms="SimpleForm1" Icon="SystemSaveClose" OnClick="@Url.Handler("RoleEdit_btnSaveClose_Click")" OnClickFields="SimpleForm1" Text="儲存後關閉"></f:Button>
        </Items>
    </f:Toolbar>
</Toolbars>
<Items>
    <f:SimpleForm ID="SimpleForm1" ShowBorder="false" ShowHeader="false" BodyPadding="10">
        <Items>
            <f:HiddenField For="Role.ID"></f:HiddenField>
            <f:TextBox For="Role.Name">
            </f:TextBox>
            <f:TextArea For="Role.Remark"></f:TextArea>
        </Items>
    </f:SimpleForm>
</Items>
</f:Panel>

 

2.5.2 表格控制元件的快速模型初始化

手工設定表格列屬性的示例:

<f:Grid ID="Grid1" IsFluid="true" CssClass="blockpanel" ShowBorder="true" ShowHeader="true" Title="表格" DataIDField="Id" DataTextField="Name" DataSource="@DataSourceUtil.GetDataTable()">
    <Columns>
        <f:RowNumberField />
        <f:RenderField HeaderText="姓名" DataField="Name" Width="100" />
        <f:RenderField HeaderText="性別" DataField="Gender" FieldType="Int" RendererFunction="renderGender" Width="80" />
        <f:RenderField HeaderText="入學年份" DataField="EntranceYear" FieldType="Int" Width="100" />
        <f:RenderCheckField HeaderText="是否在校" DataField="AtSchool" RenderAsStaticField="true" Width="100" />
        <f:RenderField HeaderText="所學專業" DataField="Major" RendererFunction="renderMajor" ExpandUnusedSpace="true" MinWidth="150" />
        <f:RenderField HeaderText="分組" DataField="Group" RendererFunction="renderGroup" Width="80" />
        <f:RenderField HeaderText="註冊日期" DataField="LogTime" FieldType="Date" Renderer="Date" RendererArgument="yyyy-MM-dd" Width="100" />
    </Columns>
</f:Grid>

程式碼來自:https://pages.fineui.com/#/Grid/Grid

 

For屬性快速設定的示例:

<f:Grid ID="Grid1" IsFluid="true" CssClass="blockpanel" ShowBorder="true" ShowHeader="true" Title="表格" DataIDField="Id" DataTextField="Name" DataSource="@Model.Students">
        <Columns>
            <f:RowNumberField />
            <f:RenderField For="Students.First().Name" />
            <f:RenderField For="Students.First().Gender" RendererFunction="renderGender" Width="80" />
            <f:RenderField For="Students.First().EntranceYear" />
            <f:RenderCheckField For="Students.First().AtSchool" RenderAsStaticField="true" />
            <f:RenderField For="Students.First().Major" RendererFunction="renderMajor" ExpandUnusedSpace="true" MinWidth="150" />
            <f:RenderField For="Students.First().Group" RendererFunction="renderGroup" Width="80" />
            <f:RenderField For="Students.First().EntranceDate" />
        </Columns>
    </f:Grid>

程式碼來自:https://pages.fineui.com/#/DataModel/Grid

 

同樣,這兩個程式碼實現的功能是一模一樣的,只不過 For 屬性會從模型類中讀取欄位的註解值,並自動設定相應的屬性:

public class Student
{
    [Key]
    public int Id { get; set; }

    [Required]
    [Display(Name = "姓名")]
    [StringLength(20)]
    public string Name { get; set; }

    [Required]
    [Display(Name = "性別")]
    public int Gender { get; set; }

    [Required]
    [Display(Name = "入學年份")]
    public int EntranceYear { get; set; }

    [Required]
    [Display(Name = "是否在校")]
    public bool AtSchool { get; set; }

    [Required]
    [Display(Name = "所學專業")]
    [StringLength(200)]
    public string Major { get; set; }

    [Required]
    [Display(Name = "分組")]
    public int Group { get; set; }


    [Display(Name = "註冊日期")]
    [DisplayFormat(DataFormatString = "{0:yyyy-MM-dd}")]
    public DateTime? EntranceDate { get; set; }

}

 

頁面顯示效果:

 

同樣,AppBoxCore中所有的表格都使用了快速模型初始化。

 

2.6 對比 Dapper 和 EFCore 的實現細節 

除了 AppBoxCore 專案使用 EF Core 之外,我們還有另一個實現相同功能的專案:AppBoxCore.Dapper ,兩者的區別就在於訪問資料庫的方式:

  • AppBoxCore專案的技術架構:FineUICore + Razor Pages + Entity Framework Core
  • AppBoxCore專案的技術架構:FineUICore + Razor Pages + Dapper

下面會對幾處程式碼在 EF Core 和 Dapper 下的不同進行對比。

 

2.6.1 角色列表頁面

Dapper版:

private async Task<IEnumerable<Role>> Role_GetDataAsync(PagingInfoViewModel pagingInfo, string ttbSearchMessage)
{
    var builder = new WhereBuilder();

    string searchText = ttbSearchMessage?.Trim();
    if (!String.IsNullOrEmpty(searchText))
    {
        builder.AddWhere("roles.Name like @SearchText");
        builder.AddParameter("SearchText", "%" + searchText + "%");
    }

    // 獲取總記錄數(在新增條件之後,排序和分頁之前)
    pagingInfo.RecordCount = await CountAsync<Role>(builder);

    // 排列和資料庫分頁
    return await SortAndPageAsync<Role>(builder, pagingInfo);
}

 

EF Core版:

private async Task<IEnumerable<Role>> Role_GetDataAsync(PagingInfoViewModel pagingInfo, string ttbSearchMessage)
{
    IQueryable<Role> q = DB.Roles;

    string searchText = ttbSearchMessage?.Trim();
    if (!String.IsNullOrEmpty(searchText))
    {
        q = q.Where(p => p.Name.Contains(searchText));
    }

    // 獲取總記錄數(在新增條件之後,排序和分頁之前)
    pagingInfo.RecordCount = await q.CountAsync();

    // 排列和資料庫分頁
    q = SortAndPage<Role>(q, pagingInfo);

    return await q.ToListAsync();
}

 

2.6.2 向角色中新增使用者列表

Dapper版:

public async Task<IActionResult> OnPostRoleUserNew_btnSaveClose_ClickAsync(int roleID, int[] selectedRowIDs)
{
    await DB.ExecuteAsync("insert roleusers (UserID, RoleID) values (@UserID, @RoleID)", selectedRowIDs.Select(u => new { UserID = u, RoleID = roleID }).ToList());

    // 關閉本窗體(觸發窗體的關閉事件)
    ActiveWindow.HidePostBack();

    return UIHelper.Result();
}

 

EF Core版:

public async Task<IActionResult> OnPostRoleUserNew_btnSaveClose_ClickAsync(int roleID, int[] selectedRowIDs)
{
    AddEntities2<RoleUser>(roleID, selectedRowIDs);
    await DB.SaveChangesAsync();

    // 關閉本窗體(觸發窗體的關閉事件)
    ActiveWindow.HidePostBack();

    return UIHelper.Result();
}

 

2.6.3 編輯使用者

Dapper版:

var _user = await GetUserByIDAsync(CurrentUser.ID);

_user.ChineseName = CurrentUser.ChineseName;
_user.Gender = CurrentUser.Gender;
_user.Enabled = CurrentUser.Enabled;
_user.Email = CurrentUser.Email;
_user.CompanyEmail = CurrentUser.CompanyEmail;
_user.OfficePhone = CurrentUser.OfficePhone;
_user.OfficePhoneExt = CurrentUser.OfficePhoneExt;
_user.HomePhone = CurrentUser.HomePhone;
_user.CellPhone = CurrentUser.CellPhone;
_user.Remark = CurrentUser.Remark;

if (String.IsNullOrEmpty(hfSelectedDept))
{
    _user.DeptID = null;
}
else
{
    _user.DeptID = Convert.ToInt32(hfSelectedDept);
}

using (var transactionScope = new TransactionScope(TransactionScopeAsyncFlowOption.Enabled))
{
    // 更新使用者
    await ExecuteUpdateAsync<User>(DB, _user);

    // 更新使用者所屬角色
    int[] roleIDs = StringUtil.GetIntArrayFromString(hfSelectedRole);
    await DB.ExecuteAsync("delete from roleusers where UserID = @UserID", new { UserID = _user.ID });
    await DB.ExecuteAsync("insert roleusers (UserID, RoleID) values (@UserID, @RoleID)", roleIDs.Select(u => new { UserID = _user.ID, RoleID = u }).ToList());

    // 更新使用者所屬職務
    int[] titleIDs = StringUtil.GetIntArrayFromString(hfSelectedTitle);
    await DB.ExecuteAsync("delete from titleusers where UserID = @UserID", new { UserID = _user.ID });
    await DB.ExecuteAsync("insert titleusers (UserID, TitleID) values (@UserID, @TitleID)", titleIDs.Select(u => new { UserID = _user.ID, TitleID = u }).ToList());


    transactionScope.Complete();
}

注意:由於涉及多個表儲存,所以Dapper版藉助事務來完成多個表的資料更新操作。

 

EF Core版:

var _user = await DB.Users
    .Include(u => u.Dept)
    .Include(u => u.RoleUsers)
    .Include(u => u.TitleUsers)
    .Where(m => m.ID == CurrentUser.ID).FirstOrDefaultAsync();


_user.ChineseName = CurrentUser.ChineseName;
_user.Gender = CurrentUser.Gender;
_user.Enabled = CurrentUser.Enabled;
_user.Email = CurrentUser.Email;
_user.CompanyEmail = CurrentUser.CompanyEmail;
_user.OfficePhone = CurrentUser.OfficePhone;
_user.OfficePhoneExt = CurrentUser.OfficePhoneExt;
_user.HomePhone = CurrentUser.HomePhone;
_user.CellPhone = CurrentUser.CellPhone;
_user.Remark = CurrentUser.Remark;


int[] roleIDs = StringUtil.GetIntArrayFromString(hfSelectedRole);
ReplaceEntities2<RoleUser>(_user.RoleUsers, roleIDs, _user.ID);

int[] titleIDs = StringUtil.GetIntArrayFromString(hfSelectedTitle);
ReplaceEntities2<TitleUser>(_user.TitleUsers, titleIDs, _user.ID);

if (String.IsNullOrEmpty(hfSelectedDept))
{
    _user.DeptID = null;
}
else
{
    _user.DeptID = Convert.ToInt32(hfSelectedDept);
}

await DB.SaveChangesAsync();

 

2.6.4 Menu模型類的ViewPowerName屬性

在 Dapper 版,Menu的模型類中有 ViewPowerName 屬性:

public class Menu : ICustomTree, IKeyID, ICloneable
{
    [Key]
    public int ID { get; set; }

    [Display(Name = "選單名稱")]
    [StringLength(50)]
    [Required]
    public string Name { get; set; }

    // ...

    [Display(Name = "上級選單")]
    public int? ParentID { get; set; }

    [Display(Name = "瀏覽許可權")]
    public int? ViewPowerID { get; set; }


    [NotMapped]
    [Display(Name = "瀏覽許可權")]
    public string ViewPowerName { get; set; }
    
}

 

而 EF Core版中,Menu模型類中沒有 ViewPowerName 屬性,但是存在 ViewPower 導航屬性:

public class Menu : ICustomTree, IKeyID, ICloneable
{
    [Key]
    public int ID { get; set; }

    [Display(Name = "選單名稱")]
    [StringLength(50)]
    [Required]
    public string Name { get; set; }

    // ...

    [Display(Name = "上級選單")]
    public int? ParentID { get; set; }
    public Menu Parent { get; set; }


    [Display(Name = "瀏覽許可權")]
    public int? ViewPowerID { get; set; }
    public Power ViewPower { get; set; }
    
}

 

這個差異導致了多處程式碼不盡相同,不過總的來說還算清晰,我們一一列舉出來供大家參考。

1. 編輯頁面(獲取初始資料)
EFCore版:

public async Task<IActionResult> OnGetAsync(int id)
{
    Menu = await DB.Menus
        .Include(m => m.Parent)
        .Include(m => m.ViewPower)
        .Where(m => m.ID == id).FirstOrDefaultAsync();

    if (Menu == null)
    {
        return Content("無效引數!");
    }

    MenuEdit_LoadData(id);

    return Page();
}

 

Dapper版:

public async Task<IActionResult> OnGetAsync(int id)
{
    Menu = await DB.QuerySingleOrDefaultAsync<Models.Menu>("select menus.*, powers.Name ViewPowerName from menus left join powers on menus.ViewPowerID = powers.ID where menus.ID = @MenuID", new { MenuID = id });
    
    if (Menu == null)
    {
        return Content("無效引數!");
    }

    MenuEdit_LoadData(id);

    return Page();
}

 

2. 列表頁面
EFCore版:

<f:RenderField For="Menus.First().ViewPower.Name"></f:RenderField>

Dapper版:

<f:RenderField For="Menus.First().ViewPowerName"></f:RenderField>


3. 編輯頁面
EFCore版:

<f:TextBox For="Menu.ViewPowerID" Text="@(Model.Menu.ViewPower == null ? "" : Model.Menu.ViewPower.Name)" Name="ViewPowerName"></f:TextBox>


Dapper版:

<f:TextBox For="Menu.ViewPowerName"></f:TextBox>

 

4. 編輯頁面後臺
EFCore版:

OnPostMenuEdit_btnSaveClose_ClickAsync(string ViewPowerName)

 

Dapper版:

OnPostMenuEdit_btnSaveClose_ClickAsync()

 在程式碼中,可以通過 Menu.ViewPowerName 獲取使用者的輸入值。

 

 

三、截圖賞析

FineUICore 內建了幾十個主題,這裡就分別選取一個深色主題和淺色主題以饗讀者。

 

3.1 深色主題(Dark Hive) 

  

 

3.2 淺色主題(Pure Purple)

 

 

 

四、原始碼下載

FineUICore(基礎版)非免費軟體,你可以加入【三石和他的朋友們】知識星球下載 AppBoxCore 的完整專案原始碼:

https://fineui.com/fans/

FineUICore算是國內堅持在 ASP.NET Core 陣營僅有的控制元件庫了,前後歷經 12 年的時間持續不斷的更新,細節上追求精益求精,期待你的加入。

我們來回顧下從 FineUIPro 到 FineUIMvc,再到 FineUICore 關鍵時間點:

  • v1.0.0 於 2014-07-30 釋出,這也是我們 FineUIPro 產品線的第一個版本,實現了開源版(100多個版本)的全部功能。
  • v2.0.0 於 2014-12-10 釋出,半年的時間內我們快速迭代了 10 個小版本,併發布功能完善的 2.0 大版本。
  • v3.0.0 於 2016-03-16 釋出,在此期間我們不僅支援大資料表格,而且對手機、平板、桌面進行了全適配。
  • v4.0.0 於 2017-10-30 釋出,期間我們上線了新產品FineUIMvc 和純前端庫F.js,並且支援了CSS3動畫。
  • v5.0.0 於 2018-04-23 釋出,支援ASP.NET Core的全新產品FineUICore來了,並且創新了基於畫素的響應式佈局。
  • v6.0.0 於 2019-09-20 釋出,方便將WebForms快速遷移到FineUICore,並帶來一系列的功能和效能改善。
  • v6.2.0 於 2020-02-08 釋出,將 FineUICore 升級到最新的 .Net Core 3.1。

 

限時免費下載(3天)

今天恰逢【壯族三月三】(廣西法定節假日),家家戶戶都有做五色糯米飯的傳統,人們採來紅藍草、黃飯花、楓葉、紫蕃藤,用這些植物的汁浸泡糯米,做成紅、黃、黑、紫、白五色糯米飯。

其中以楓葉染成的黑色糯米飯最是香濃。

每個地方用的原料可能不大相同,比如這邊黃色糯米飯用的梔子染色的。

紫蕃藤又稱紫藍草,和紅藍草是同一個品種。紫藍草的葉片稍長,顏色稍深,煮出來的就是紫色,而紅藍草的葉片較圓,顏色較淺,煮出來的就是紅色。

這也正應了那句俗話【紅的發紫】,看來紅色和紫色本是一家。

 

重點來了,為了慶祝壯族三月三,我們將【AppBoxCore專案原始碼】限時免費下載3天,走過路過,不要錯過:

連結:https://pan.baidu.com/s/1eVEMJ3Mbs0ZzNUO7EdE-5A
提取碼:kkag

 

如果你希望下載 FineUI 的其他版本,或者 AppBoxCore.Dapper 專案原始碼,還請加入【三石和他的朋友們】知識星球:

https://fineui.com/fans/