AppBoxCore - 细粒度权限管理框架(EFCore+RazorPages+async/await)!
目录
- 前言
- 全新AppBoxCore
- RazorPages 和 TagHelpers 技术架构
- 页面处理器和数据库操作的异步调用
- Authorize特性和自定义权限验证过滤器
- Authorize登录授权
- 自定义CheckPower权限过滤器
- CheckPower特性控制页面的浏览权限
- 表格行链接图标的权限控制
- 表格行删除按钮的后台权限控制
- 实体类模型定义的多对多联接表
- 为什么 EF Core 不支持隐式联接表
- 定义联接表模型类
- 配置多对多关系
- 联接表相关代码更新
- 新增 IKey2ID 接口
- 表单和表格的快速模型初始化
- 表单控件的快速模型初始化
- 表格控件的快速模型初始化
- 对比 Dapper 和 EFCore 的实现细节
- 角色列表页面
- 向角色中添加用户列表
- 编辑用户
- Menu模型类的ViewPowerName属性
- 编辑页面(获取初始数据)
- 列表页面
- 编辑页面
- 编辑页面后台
- 截图赏析
- 源代码下载
一、前言
AppBox的历史可以追溯到 2009 年,第一个版本的 AppBox 是基于 FineUI(开源版)的通用权限管理系统,包括用户管理、职称管理、部门管理、角色管理、角色权限管理等模块。
AppBox提供一种通用的细粒度的权限控制结构,可以对页面上任意元素(按钮,文本框,表格行中的链接)的启用禁用显示隐藏进行单独的控制。
AppBox中的权限管理涉及几个概念:角色、用户、权限、页面
- 角色:用来对用户进行分组,权限实际上是和角色对应的
- 用户:一个用户可以属于多个角色
- 权限:顶级权限列表,比如“CoreDeptView”的意思是部门浏览权限,为了方便权限管理,我们还给权限一个简单的分组
- 页面:用户操作的载体,一个页面可以拥有多个权限,这个控制是在页面代码中进行的,主动权在页面
这也是我们在 AppBox v3.0 中率先提出的【扁平化的权限设计】理念,用一张图来概括:
一路走来,我们累计了好多篇文章,这里一并汇总出来:
2020年:
....
2018年:
【续】【AppBox】5年后,我们为什么要从 Entity Framework 转到 Dapper 工具?
【AppBox】5年后,我们为什么要从 Entity Framework 转到 Dapper 工具?
2016年:
2014年:
AppBoxPro - 细粒度通用权限管理框架(可控制表格行内按钮)源码提供下载
【6年开源路】FineUI家族今日全部更新(FineUI + FineUI3to4 + FineUI.Design + AppBox)!
2013年:
AppBox_v2.0完整版免费下载,暨AppBox_v3.0正式发布!
AppBox升级进行时 - 拥抱Entity Framework的Code First开发模式
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年:
2010年:
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.
不过看到微软这个文档中描述的细节,我觉得微软是不打算支持多对多关系的隐式联接表了:
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 的完整项目源代码:
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。
AppBoxCore v6.2 更新记录:
+2020-03-31 v6.2 -升级到 FineUICore(基础版)v6.2.0。 -基于 ASP.NET Core 的 RazorPages 和 TagHelpers 技术架构。 -使用 EntityFramework Core 访问数据库。 -基于 .Net Core 3.1。 -部分代码参考网友【时不我待】的实现:https://t.zsxq.com/UBAqN3N +功能更新。 +页面处理器(GET/POST)和数据库操作全部改为异步调用(async/await)。 -服务器的可用线程是有限的,在高负载情况下的可能所有线程都被占用,此时服务器就无法处理新的请求,直到有线程被释放。 -使用同步代码时,可能会出现多个线程被占用而不能执行任何操作的情况,因为它们正在等待 I/O 完成。 -使用异步代码时,当线程正在等待 I/O 完成时,服务器可以将其线程释放用于处理其他请求。 -将基类的ExecuteUpdate、Sort、SortAndPage、Count、FindByID方法全部改为异步调用。 -增加页面模型基类BaseAdminModel,并设置[Authorize]特性以阻止未登陆用户访问管理页面。 -页面模型类中,将对ViewBag的调用改为类属性。 +EFCore不支持没有实体类来表示联接表的多对多关系。 -无有效负载的多对多联接表有时称为纯联接表 (PureJoinTable)。 -数据模型开始时很简单,随着内容的增加,纯联接表 (PJT) 通常会发展为有效负载的联接表。 -新增实体类:RolePower、RoleUser、TitleUser。 -更新AppBoxCoreContext中的OnModelCreating,包含多对多,一对多,单个导航等定义。 -更新模型类User,删除Roles和Titles导航属性,新增RoleUsers和TitleUsers导航属性。 -更新AppBoxCoreDatabaseInitializer,并在程序启用阶段调用(Program.cs)。 +使用依赖注入添加数据库连接实例。 -在Startup.cs的ConfigureServices中,通过AddDbContext来注册EFCore服务。 +在BaseModel.cs类中获取数据库连接实例。 -FineUICore.PageContext.GetRequestService<AppBoxCoreContext>(); -由于同时需要在静态函数和实例函数中调用,所以通过当前请求上下文获取服务对象。 -如果仅需要在实例函数中调用,可以通过类的构造函数注入。 +在页面初始化查询中,添加对AsNoTracking()的调用。 -如果返回的实体未在当前上下文中更新(未调用SaveChanges),AsNoTracking方法将会提升性能。 +由于现在需要用关联表表示多对多关系,所以需要对之前的代码进行重构。 +重构BaseModel中的GetRolePowerNames方法。 -db.Roles.Include(r => r.Powers).Where(r => roleIDs.Contains(r.ID)).ToList(); -改为: -db.Roles.Include(r => r.RolePowers).ThenInclude(rp => rp.Power).Where(r => roleIDs.Contains(r.ID)).ToList(); +重构用户编辑页面初始化代码。 -DB.Users.Include(u => u.Roles).Where(m => m.ID == id).FirstOrDefault(); -String.Join(",", CurrentUser.Roles.Select(r => r.Name).ToArray()); -改为: -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()); +重构职称列表页面删除行代码。 -DB.Users.Where(u => u.Titles.Any(r => r.ID == deletedRowID)).Count(); -改为: -await DB.Users.Where(u => u.TitleUsers.Any(r => r.TitleID == deletedRowID)).CountAsync(); +更新用户密码页面,设置HiddenField的Name=hfUserID属性,以便在后台通过函数参数获取值。 -可选实现:设置CurrentUser的[BindProperty]特性,然后通过CurrentUser.ID获取。 -可选实现:函数参数IFormCollection values,然后通过Convert.ToInt32(values["CurrentUser.ID"].ToString())获取。 -使用TextBox标签的For属性(For=Title.Name)时,无需设置Required=true和ShowRedStar=true,这两个属性会从Title模型的特性中读取并设置。 -用户列表页面的触发器输入框,由于设置了OnTrigger2ClickFields=Panel1,因此无需额外传入参数new Parameter("ttbSearchMessage","F.ui.ttbSearchMessage.getValue()")。 -菜单编辑页面,如果指定的浏览权限名称错误,则弹出框提示(浏览权限 XXX 不存在!)。 +新增IKey2ID接口。 -RolePower、RoleUser、TitleUser实现了IKey2ID接口,BaseModel新增AddEntities2、ReplaceEntities2方法。 +编辑用户页面。 -ReplaceEntities<Role>(_user.Roles, roleIDs); 改为:ReplaceEntities2<RoleUser>(_user.RoleUsers, roleIDs, _user.ID); -_user.Dept = Attach<Dept>(Convert.ToInt32(hfSelectedDept)); 改为:_user.DeptID = Convert.ToInt32(hfSelectedDept); +新增用户页面。 -AddEntities<Role>(user.Roles, roleIDs); 改为:AddEntities2<RoleUser>(roleIDs, CurrentUser.ID); +Menu模型类,Dapper版有ViewPowerName属性,而EFCore版没有ViewPowerName属性。 +列表页面: -EFCore版:<f:RenderField For="Menus.First().ViewPower.Name"></f:RenderField> -Dapper版:<f:RenderField For="Menus.First().ViewPowerName"></f:RenderField> +编辑页面: -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> +编辑页面后台: -EFCore版:OnPostMenuEdit_btnSaveClose_ClickAsync(string ViewPowerName) -Dapper版:OnPostMenuEdit_btnSaveClose_ClickAsync(),通过 Menu.ViewPowerName 获取用户的输入值。
今天恰逢【壮族三月三】(广西法定节假日),家家户户都有做五色糯米饭的传统,人们采来红蓝草、黄饭花、枫叶、紫蕃藤,用这些植物的汁浸泡糯米,做成红、黄、黑、紫、白五色糯米饭。
其中以枫叶染成的黑色糯米饭最是香浓。
每个地方用的原料可能不大相同,比如这边黄色糯米饭用的栀子染色的。
紫蕃藤又称紫蓝草,和红蓝草是同一个品种。紫蓝草的叶片稍长,颜色稍深,煮出来的就是紫色,而红蓝草的叶片较圆,颜色较浅,煮出来的就是红色。
这也正应了那句俗话【红的发紫】,看来红色和紫色本是一家。