目录
本系列中的所有BlazorForms文章:
- BlazorForms低代码开源框架——第1部分:介绍和种子项目
- BlazorForms低代码开源框架——第2部分:CrmLight项目
- BlazorForms低代码开源框架——第3部分:CrmLight Lead Board
我们在这里部署了一个工作解决方案:
请记住,它没有数据库,它将所有数据保存在内存中,供所有用户共享,并且在一段时间不活动后,容器会关闭丢失所有数据。
我们部署到具有最低CPU和内存要求的Linux docker容器,但它运行速度非常快。
介绍
这篇文章延续了由PRO CODERS PTY LTD开发的关于BlazorForms框架的系列文章,并作为具有MIT许可证的开源项目共享。
在上一篇文章“BlazorForms低代码开源框架介绍和种子项目”中,我介绍了这个框架来简化Blazor UI开发,并允许创建简单且可维护的C#代码。
该框架的主要思想是提供一种模式,将逻辑与UI隔离开来,并强制开发人员将逻辑保留在Flow和规则中。窗体仅包含模型和UI控件之间的绑定。
无需直接的UI操作,除非您希望对其进行高度自定义。这意味着Flow和规则中的逻辑不依赖于UI,并且是100%可单元测试的。
为了尽量减少最初的工作量,我们创建了以下种子项目,这些项目可在GitHub上找到,并将CrmLight项目版本0.7.0复制到我的博客存储库中。
从GitHub下载此博客文章代码:
GitHub上的BlazorForms项目:
CrmLight项目
CrmLightDemoApp创建项目是为了演示如何实现比基本种子项目中提出的更复杂的方案。CrmLight Flow使用具有某些扩展的完全实现CRUD操作的存储库。
数据和存储库
该应用程序使用多个实体和关系:
- Company
- Person
- PersonCompanyLink
- PersonCompanyLinkType
它们之间的关系可以在图中显示:
图1
为了实现数据访问,我们使用了经典的存储库模式,这意味着对于每个实体,我们都有一个专门的存储库。但是,多次实现相同的CRUD操作是没有意义的,因此我们使用了泛型。
如果您查看解决方案资源管理器,您将看到简化的Onion体系结构文件夹结构:
图2
其中 IRepository.cs 为所有存储库定义通用接口:
namespace CrmLightDemoApp.Onion.Domain.Repositories
{
public interface IRepository<T>
where T : class
{
Task<List<T>> GetAllAsync();
IQueryable<T> GetAllQuery();
Task<List<T>> RunQueryAsync(IQueryable<T> query);
Task<T> GetByIdAsync(int id);
Task<int> CreateAsync(T data);
Task UpdateAsync(T data);
Task DeleteAsync(int id);
Task SoftDeleteAsync(int id);
Task<List<T>> GetListByIdsAsync(IEnumerable<int> ids);
}
}
和 LocalCacheRepository.cs 实现此接口:
using BlazorForms.Shared;
using CrmLightDemoApp.Onion.Domain;
using CrmLightDemoApp.Onion.Domain.Repositories;
namespace CrmLightDemoApp.Onion.Infrastructure
{
// this is repository emulator that stores all data in memory
// it stores and retrieves object copies, like a real database
public class LocalCacheRepository<T> : IRepository<T>
where T : class, IEntity
{
protected int _id = 0;
protected readonly List<T> _localCache = new List<T>();
public async Task<int> CreateAsync(T data)
{
_id++;
data.Id = _id;
_localCache.Add(data.GetCopy());
return _id;
}
public async Task DeleteAsync(int id)
{
_localCache.Remove(_localCache.Single(x => x.Id == id));
}
public async Task<T> GetByIdAsync(int id)
{
return _localCache.Single(x => x.Id == id).GetCopy();
}
public async Task<List<T>> GetAllAsync()
{
return _localCache.Where
(x => !x.Deleted).Select(x => x.GetCopy()).ToList();
}
public async Task UpdateAsync(T data)
{
await DeleteAsync(data.Id);
_localCache.Add(data.GetCopy());
}
public async Task SoftDeleteAsync(int id)
{
_localCache.Single(x => x.Id == id).Deleted = true;
}
public async Task<List<T>> GetListByIdsAsync(IEnumerable<int> ids)
{
return _localCache.Where(x => ids.Contains(x.Id)).Select
(x => x.GetCopy()).ToList();
}
public IQueryable<T> GetAllQuery()
{
return _localCache.AsQueryable();
}
public async Task<List<T>> RunQueryAsync(IQueryable<T> query)
{
return query.ToList();
}
}
}
如您所见,我们在项目中不使用任何数据库,将所有数据保存在内存模拟器中。这应该简化演示运行体验,同时确保开发的存储库可用于单元测试。
为了简化代码,我们使用了来自BlazorForms.Shared的GetCopy扩展方法,该方法使用反射来创建新实例并将所有public属性复制到其中。
专用存储库预填充了一些数据,您可以在运行应用程序时看到这些数据:
using CrmLightDemoApp.Onion.Domain;
using CrmLightDemoApp.Onion.Domain.Repositories;
namespace CrmLightDemoApp.Onion.Infrastructure
{
public class CompanyRepository : LocalCacheRepository<Company>, ICompanyRepository
{
public CompanyRepository()
{
// pre fill some data
_localCache.Add(new Company { Id = 1, Name = "Mizeratti Pty Ltd",
RegistrationNumber = "99899632221",
EstablishedDate = new DateTime(1908, 1, 17) });
_localCache.Add(new Company { Id = 2, Name = "Alpha Pajero",
RegistrationNumber = "89963222172",
EstablishedDate = new DateTime(1956, 5, 14) });
_localCache.Add(new Company { Id = 3, Name = "Zeppelin Ltd Inc",
RegistrationNumber = "63222172899",
EstablishedDate = new DateTime(2019, 11, 4) });
_localCache.Add(new Company { Id = 4, Name = "Perpetuum Automotives Inc",
RegistrationNumber = "22217289963",
EstablishedDate = new DateTime(2010, 1, 7) });
_id = 10;
}
}
}
PersonCompanyRepository.cs 具有连接多个实体并返回PersonCompanyLinkDetails组合对象的方法:
public async Task<List<PersonCompanyLinkDetails>> GetByCompanyIdAsync(int companyId)
{
var list = _localCache.Where(x => !x.Deleted &&
x.CompanyId == companyId).Select(x =>
{
var item = new PersonCompanyLinkDetails();
x.ReflectionCopyTo(item);
return item;
}).ToList();
var company = await _companyRepository.GetByIdAsync(companyId);
var personIds = list.Select(x => x.PersonId).Distinct().ToList();
var persons = (await _personRepository.GetListByIdsAsync
(personIds)).ToDictionary(x => x.Id, x => x);
var linkIds = list.Select(x => x.LinkTypeId).Distinct().ToList();
var links = (await _personCompanyLinkTypeRepository.
GetListByIdsAsync(linkIds)).ToDictionary(x => x.Id, x => x);
foreach (var item in list)
{
item.LinkTypeName = links[item.LinkTypeId].Name;
item.PersonFullName = $"{persons[item.PersonId].FirstName}
{persons[item.PersonId].LastName}";
item.PersonFirstName = persons[item.PersonId].FirstName;
item.PersonLastName = persons[item.PersonId].LastName;
item.CompanyName = company.Name;
}
return list;
}
业务逻辑
服务文件夹包含与BlazorForms相关的代码——应用程序业务逻辑:
图3
Flow模型
在我们开始研究Flow之前,我想提一下,我们不使用域实体作为模型,而是使用可能与领域实体具有相同属性的业务模型类。这样做,我们可以使用对Flow处理逻辑有用的额外属性来扩展Flow模型。例如,CompanyModel从公司实体继承所有属性,同时还具有我们将在Flow逻辑中使用的特殊属性:
public class CompanyModel : Company, IFlowModel
{
public virtual List<PersonCompanyLinkDetailsModel>
PersonCompanyLinks { get; set; } = new List<PersonCompanyLinkDetailsModel>();
public virtual List<PersonCompanyLinkDetailsModel>
PersonCompanyLinksDeleted { get; set; } = new List<PersonCompanyLinkDetailsModel>();
public virtual List<PersonCompanyLinkType> AllLinkTypes { get; set; }
public virtual List<PersonModel> AllPersons { get; set; }
}
列表Flow
列表Flow是一种简化的Flow,用于以UI表形式显示记录列表。它没有定义正文,但它有检索记录数据的LoadDataAsync方法:
public class CompanyListFlow : ListFlowBase<CompanyListModel, FormCompanyList>
{
private readonly ICompanyRepository _companyRepository;
public CompanyListFlow(ICompanyRepository companyRepository)
{
_companyRepository = companyRepository;
}
public override async Task<CompanyListModel>
LoadDataAsync(QueryOptions queryOptions)
{
var q = _companyRepository.GetAllQuery();
if (!string.IsNullOrWhiteSpace(queryOptions.SearchString))
{
q = q.Where(x => x.Name.Contains
(queryOptions.SearchString, StringComparison.OrdinalIgnoreCase)
|| (x.RegistrationNumber != null &&
x.RegistrationNumber.Contains(queryOptions.SearchString,
StringComparison.OrdinalIgnoreCase)) );
}
if (queryOptions.AllowSort && !string.IsNullOrWhiteSpace
(queryOptions.SortColumn) && queryOptions.SortDirection != SortDirection.None)
{
q = q.QueryOrderByDirection(queryOptions.SortDirection,
queryOptions.SortColumn);
}
var list = (await _companyRepository.RunQueryAsync(q)).Select(x =>
{
var item = new CompanyModel();
x.ReflectionCopyTo(item);
return item;
}).ToList();
var result = new CompanyListModel { Data = list };
return result;
}
}
如您所见,CompanyListFlow通过依赖注入在构造函数中接收ICompanyRepository并使用它来检索数据。
QueryOptions参数可能包含我们用于组装查询添加Where和OrderBy子句的搜索模式和/或排序信息——这要归功于我们的存储库具有GetAllQuery和RunQueryAsync方法的灵活性。
运行查询后,我们遍历返回的记录,并使用扩展方法ReflectionCopyTo将返回的Company实体的所有属性复制到从Company继承的CompanyModel业务对象(顺便说一句,您可以使用AutoMapper,但我个人认为它的语法过于复杂)。
列表Form
定义Company UI表的最后一部分是定义列和导航的列表窗体:
public class FormCompanyList : FormListBase<CompanyListModel>
{
protected override void Define(FormListBuilder<CompanyListModel> builder)
{
builder.List(p => p.Data, e =>
{
e.DisplayName = "Companies";
e.Property(p => p.Id).IsPrimaryKey();
e.Property(p => p.Name);
e.Property(p => p.RegistrationNumber).Label("Reg. No.");
e.Property(p => p.EstablishedDate).Label("Established date").
Format("dd/MM/yyyy");
e.ContextButton("Details", "company-edit/{0}");
e.NavigationButton("Add", "company-edit/0");
});
}
}
IsPrimaryKey()标记包含记录主键的列,该主键将作为ContextButton导航链接格式字符串“company-edit/{0}”的参数提供。
我们还为DateTime列提供列标签和日期格式。
为了呈现表单,我们在 Pages 文件夹中添加了FlowListForm控件到CompanyList.razor中:
@page "/company-list"
<FlowListForm FlowType="@typeof(CrmLightDemoApp.Onion.
Services.Flow.CompanyListFlow).FullName" Options="GlobalSettings.ListFormOptions" />
@code {
}
您可以运行应用程序并搜索和排序公司:
图4
如果单击该行,您将导航到提供记录主键作为参数的公司编辑页面,但如果单击“添加”按钮,则“公司编辑”页面将获得零作为主键参数,这意味着——添加新记录。
CompanyEdit.razor接受参数Pk并将其提供给FlowEditForm:
@page "/company-edit/{pk}"
<FlowEditForm FlowName="@typeof(CrmLightDemoApp.Onion.Services.
Flow.CompanyEditFlow).FullName" Pk="@Pk"
Options="GlobalSettings.EditFormOptions"
NavigationSuccess="/company-list" />
@code {
[Parameter]
public string Pk { get; set; }
}
编辑Flow
CompanyEditFlow类定义了两种主要情况——对于非零ItemKey(提供Pk)LoadData方法应该执行并且FormCompanyView应该向用户显示。FormCompanyView有删除按钮,如果按下它,那么DeleteData方法将被执行。
第二种情况——在FormCompanyView中按下零ItemKey或编辑按钮——在这种情况下,该LoadRelatedData方法将被执行并且FormCompanyEdit向用户显示:
public override void Define()
{
this
.If(() => _flowContext.Params.ItemKeyAboveZero)
.Begin(LoadData)
.NextForm(typeof(FormCompanyView))
.EndIf()
.If(() => _flowContext.ExecutionResult.FormLastAction ==
ModelBinding.DeleteButtonBinding)
.Next(DeleteData)
.Else()
.If(() => _flowContext.ExecutionResult.FormLastAction ==
ModelBinding.SubmitButtonBinding || !_flowContext.Params.ItemKeyAboveZero)
.Next(LoadRelatedData)
.NextForm(typeof(FormCompanyEdit))
.Next(SaveData)
.EndIf()
.EndIf()
.End();
}
LoadData方法按包含PersonCompanyLinks的公司详细信息填充Flow模型,包括Company和Person实体之间的引用:
public async Task LoadData()
{
if (_flowContext.Params.ItemKeyAboveZero)
{
var item = await _companyRepository.GetByIdAsync(_flowContext.Params.ItemKey);
// item and Model have different types - we use reflection
// to copy similar properties
item.ReflectionCopyTo(Model);
Model.PersonCompanyLinks =
(await _personCompanyRepository.GetByCompanyIdAsync(Model.Id))
.Select(x =>
{
var item = new PersonCompanyLinkDetailsModel();
x.ReflectionCopyTo(item);
return item;
}).ToList();
}
}
DeleteData方法只是使用存储库方法SoftDeleteAsync删除Company,这会将实体Deleted标志更改为true。
LoadRelatedData方法填充AllLinkTypes和AllPersons集合,我们将用于Dropdown和DropdownSearch控件。
如果用户在FormCompanyEdit上按下“提交”按钮,则执行SaveData方法,如果ID大于零,则更新Company记录,或者如果ID为零,则插入记录(添加新Company案例)。该方法还遍历PersonCompanyLinksDeleted和PersonCompanyLinks集合,并删除用户删除的记录,插入和更新用户添加和更改的记录:
public async Task SaveData()
{
if (_flowContext.Params.ItemKeyAboveZero)
{
await _companyRepository.UpdateAsync(Model);
}
else
{
Model.Id = await _companyRepository.CreateAsync(Model);
}
foreach (var item in Model.PersonCompanyLinksDeleted)
{
if (item.Id != 0)
{
await _personCompanyRepository.SoftDeleteAsync(item.Id);
}
}
foreach (var item in Model.PersonCompanyLinks)
{
if (item.Id == 0)
{
item.CompanyId = Model.Id;
await _personCompanyRepository.CreateAsync(item);
}
else if (item.Changed)
{
await _personCompanyRepository.UpdateAsync(item);
}
}
}
编辑Form
FormCompanyView定义以表格格式显示所有Company属性和PersonCompanyLinks的Company只读表示形式。我还为“删除”按钮添加了一条确认消息:
public class FormCompanyView : FormEditBase<CompanyModel>
{
protected override void Define(FormEntityTypeBuilder<CompanyModel> f)
{
f.DisplayName = "Company View";
f.Property(p => p.Name).Label("Name").IsReadOnly();
f.Property(p => p.RegistrationNumber).Label("Reg. No.").IsReadOnly();
f.Property(p => p.EstablishedDate).Label("Established date").IsReadOnly();
f.Table(p => p.PersonCompanyLinks, e =>
{
e.DisplayName = "Associations";
e.Property(p => p.LinkTypeName).Label("Type");
e.Property(p => p.PersonFullName).Label("Person");
});
f.Button(ButtonActionTypes.Close, "Close");
f.Button(ButtonActionTypes.Delete, "Delete")
.Confirm(ConfirmType.Continue, "Delete this Company?", ConfirmButtons.YesNo);
f.Button(ButtonActionTypes.Submit, "Edit");
}
}
图5
FormCompanyEdit包含用于编辑Company属性和PersonCompanyLinks的输入控件,这些控件定义在Repeater控件中,Repeater控件呈现一个可编辑的网格,可以在其中添加、更改或删除记录。
public class FormCompanyEdit : FormEditBase<CompanyModel>
{
protected override void Define(FormEntityTypeBuilder<CompanyModel> f)
{
f.DisplayName = "Company Edit";
f.Confirm(ConfirmType.ChangesWillBeLost,
"If you leave before saving, your changes will be lost.",
ConfirmButtons.OkCancel);
f.Property(p => p.Name).Label("Name").IsRequired();
f.Property(p => p.RegistrationNumber).Label("Reg. No.").IsRequired();
f.Property(p => p.EstablishedDate).Label("Established date").IsRequired();
f.Repeater(p => p.PersonCompanyLinks, e =>
{
e.DisplayName = "Associations";
e.Property(p => p.Id).IsReadOnly().Rule(typeof
(FormCompanyEdit_ItemDeletingRule), FormRuleTriggers.ItemDeleting);
e.PropertyRoot(p => p.LinkTypeId).Dropdown
(p => p.AllLinkTypes, m => m.Id,
m => m.Name).IsRequired().Label("Type")
.Rule(typeof(FormCompanyEdit_ItemChangedRule),
FormRuleTriggers.ItemChanged);
e.PropertyRoot(p => p.PersonId).DropdownSearch
(e => e.AllPersons, m => m.Id, m =>
m.FullName).IsRequired().Label("Person")
.Rule(typeof(FormCompanyEdit_ItemChangedRule),
FormRuleTriggers.ItemChanged);
}).Confirm(ConfirmType.DeleteItem, "Delete this association?",
ConfirmButtons.YesNo);
f.Button(ButtonActionTypes.Cancel, "Cancel");
f.Button(ButtonActionTypes.Submit, "Save");
}
}
定义确认消息用于离开表单而不保存数据;另一条消息要求删除中继器中的PersonCompanyLinks记录。
PropertyRoot()函数与Property()函数相同,但当您需要引用根Model类中的集合时,可以在Repeater内部使用。
该表单还具有两个规则。
FormCompanyEdit_ItemDeletingRule在用户删除Repeater记录时触发,该Rule将删除的记录存储在一个特殊的Model集合中,我们将在SaveData方法中使用它:
public class FormCompanyEdit_ItemDeletingRule : FlowRuleBase<CompanyModel>
{
public override string RuleCode => "CMP-1";
public override void Execute(CompanyModel model)
{
// preserve all deleted items
model.PersonCompanyLinksDeleted.Add
(model.PersonCompanyLinks[RunParams.RowIndex]);
}
}
FormCompanyEdit_ItemChangedRule当用户在Repeater中更改LinkType或Person属性并且此规则更新Changed标志时触发。这也将在Flow SaveData方法中使用:
public class FormCompanyEdit_ItemChangedRule : FlowRuleBase<CompanyModel>
{
public override string RuleCode => "CMP-2";
public override void Execute(CompanyModel model)
{
model.PersonCompanyLinks[RunParams.RowIndex].Changed = true;
}
}
当用户单击“编辑”按钮时,他们将看到:
图6
我应该再次提到,表单不包含任何业务逻辑,它们只定义模型如何绑定到控件和规则。表单也无法加载或保存任何数据。当您需要保存/加载数据时,应使用Flow。当您需要执行复杂的验证或标记更改/重新加载某些数据的记录时,应使用规则。遵循此建议,您的代码将易于理解和维护,没有任何问题。
Person和PersonCompanyType Flow和窗体遵循相同的方法,您可以在从GitHub下载的解决方案中看到其代码。
添加SQL数据库
为了完成这篇文章,我想用SqlRepository代替LocalCacheRepository, SqlRepository在SQL数据库中存储数据。结果解决方案我添加到文件夹CrmLightDemoApp.Sql\。
为了开始使用SQL,我添加了Packages Microsoft.EntityFrameworkCore.Tools和Microsoft.EntityFrameworkCore.SqlServer,然后将CrmContext.cs添加到Onion\Infrastructure\文件夹中:
using CrmLightDemoApp.Onion.Domain;
using Microsoft.EntityFrameworkCore;
namespace CrmLightDemoApp.Onion.Infrastructure
{
public class CrmContext : DbContext
{
public DbSet<Company> Company { get; set; }
public DbSet<Person> Person { get; set; }
public DbSet<PersonCompanyLink> PersonCompanyLink { get; set; }
public DbSet<PersonCompanyLinkType> PersonCompanyLinkType { get; set; }
protected override void OnConfiguring(DbContextOptionsBuilder options)
=> options.UseSqlServer("Server =
(localdb)\\mssqllocaldb; Database=CrmLightDb1;
Trusted_Connection=True;MultipleActiveResultSets=true");
}
}
这是SQL Express mssqllocaldb,它应该在任何Windows计算机上工作,但是如果需要,可以在连接字符串中指定真正的SQL Server数据库。
然后,我需要修改我的Company,Person和PersonCompanyLinkType实体以包含对PersonCompanyLink表的引用,这是EF模型优先方法所必需的,其中数据库架构是从实体生成的。
然后,我在包管理器控制台中创建了运行以下命令的EF迁移:
Add-Migration InitialCreate
接下来,如果要运行应用程序,则必须在程序包管理器控制台上运行以下命令:
Update-Database
此命令将在目标数据库中创建表和关系。
更改存储库
创建 SqlRepository.cs 而不是LocalCacheRepository。它使用CrmContext来执行SQL数据库中的查询:
using CrmLightDemoApp.Onion.Domain;
using CrmLightDemoApp.Onion.Domain.Repositories;
using Microsoft.EntityFrameworkCore;
namespace CrmLightDemoApp.Onion.Infrastructure
{
public class SqlRepository<T> : IRepository<T>
where T : class, IEntity, new()
{
public async Task<int> CreateAsync(T data)
{
using var db = new CrmContext();
db.Set<T>().Add(data);
await db.SaveChangesAsync();
return data.Id;
}
public async Task DeleteAsync(int id)
{
using var db = new CrmContext();
var table = db.Set<T>();
var entity = new T { Id = id };
table.Attach(entity);
table.Remove(entity);
await db.SaveChangesAsync();
}
public async Task<T> GetByIdAsync(int id)
{
using var db = new CrmContext();
return await db.Set<T>().SingleAsync(x => x.Id == id);
}
public async Task<List<T>> GetAllAsync()
{
using var db = new CrmContext();
return await db.Set<T>().Where(x => !x.Deleted).ToListAsync();
}
public async Task UpdateAsync(T data)
{
using var db = new CrmContext();
db.Set<T>().Update(data);
await db.SaveChangesAsync();
}
public async Task SoftDeleteAsync(int id)
{
using var db = new CrmContext();
var record = await db.Set<T>().SingleAsync(x => x.Id == id);
record.Deleted = true;
await db.SaveChangesAsync();
}
public async Task<List<T>> GetListByIdsAsync(IEnumerable<int> ids)
{
using var db = new CrmContext();
return await db.Set<T>().Where(x => ids.Contains(x.Id)).ToListAsync();
}
public ContextQuery<T> GetContextQuery()
{
var db = new CrmContext();
return new ContextQuery<T>(db, db.Set<T>().Where(x => !x.Deleted));
}
public async Task<List<T>> RunContextQueryAsync(ContextQuery<T> query)
{
return await query.Query.ToListAsync();
}
}
}
不幸的是,我需要更改IRepository<>接口,因为我的初始设计不允许在存储库之外组装搜索和排序查询,现在使用了GetContextQuery和RunContextQueryAsync方法,它们适用于disposable类ContextQuery<>。
现在,如果运行应用程序,则初始数据库将为空,并且需要使用UI填充Person、Company和PersonCompanyLink表。
总结
在这篇文章中,我介绍了来自BlazorForms开源框架的CrmLight种子项目。它展示了如何将数据库存储库、应用程序业务逻辑和用户界面连接在一起的简单方法。最后,我将解决方案从使用内存模拟存储库转换为真正的SQL数据库,以便生成的解决方案可以成为某些实际项目的良好开端。
在我以后的文章中,我将重点介绍更复杂的场景和其他类型的Flow,并将介绍更多可以使用BlazorForms实现的用例。
您可以在我的GitHub文件夹Story-09-BlazorForms-CrmLight上找到完整的解决方案代码:
https://www.codeproject.com/Articles/5351772/BlazorForms-Low-Code-Open-Source-Framework-Part-2