前言
很多场景【单体+模块化】比微服务更合适,开发难度低、代码可复用性强、可扩展性强。模块化开发有些难点,模块启动与卸载、模块之间的依赖和通讯。asp.net core abp为我们提供了模块化开发能力及其它基础功能。基于abp(一代6.3)结合DDD已基本开发好一个【工单管理模块】,本篇做个基本介绍并说明如何集成此模块,后续会详细说明思路。
资源
线上demo:http://web1.cqyuzuji.com:9000/ 账号:admin 密码:123qwe
后端源码:https://gitee.com/bxjg1987/abp
前端源码:https://gitee.com/bxjg1987/front
必备知识
熟悉asp.net core和abp(注意是老版本,非vNext,但也很容易迁移到vNext上)
术语
下文会提供到一些概念,理解这个黑重要。
abp模块:这个不解释了,是abp基础,请参考官方文档
通用模块:这个是使用abp模块开发方式做的一些通用的,与具体业务无关的模块,比如:数据字典模块
业务模块:工单管理、广告管理、电商模块等为了实现具体业务的模块。
业务场景
客户是做复印机出租的,它希望做一套系统管理整个业务,其中工单是一个比较重要的模块,大致流程如下:
- 客户通过小程序上报工单,说明什么设备出了什么问题
- 系统后台管理员查看下大致问题后审核
- 后台管理员将已审核的工单分配给指定维修人员,或维修人员通过app自己领取已审核的工单
- 当维修人员到达客户处,通过app将工单设置为已执行状态
- 当维修人员处理完任务后通过app将工单设置为已完成状态,同时可能需要录入完成情况说明
以上是主体流程,还有些边角的以后文章会详细说明,比如:从审核状态跳跃到已完成状态;从已完成状态回退到待审核状态;状态变化时的事件等。
工单类型不同:有些工单可能并不是客户提交的,比如当采购的二手设备入库时要做检修,也会产生工单,这种情况工单不会与客户关联,而时与入库单关联;再比如让某员工开车去托快递回来这种情况工单会与物流信息关联
工单创建方式不同:客户通过小程序提交、后台管理员手动建立、当发生某些事件时自动创建(比如采购入库时自动创建)
其实工单管理模块是个通用的业务,在很多系统可能都需要,因此考虑做成独立的业务模块,方便复用。
目标
可复用
工单模块以nuget包发布,你可以安装后简单配置后就可以使用。
易升级
上面说了,以nuget包形式发布的,将来模块更新后发布新版本的nuget包,各系统更新下,引用新版本包就ok啦
独立性
工单模块只依赖些通用的、非业务型的模块。工单模块需要用到“员工”概念,在系统中往往体现为一个用户,工单模块本身不提供“员工管理”的功能,因为你的系统可能有自己的“员工管理”功能;或你直接拿abp原始的 AbpUser作为员工也行。试想如果“工单模块”本身提供了员工管理模块,你引用过去,发现自己系统中已有实现了的员工管理,是不是很麻烦?
所以你的项目引用工单模块时需要做个适配,为工单模块提供需要用到的员工相关功能,主要是几个查询。
说明:
abp vNext使用契约层来实现模块独立化,个人认为不完整,比如你的项目中有个”员工管理“模块,你在定义契约接口和DTO时只能定义通用的,为了尽量通用,接口中的方法会尽量多,或分开多定义几个接口,DTO中的属性也会尽量多,因为你不知道将来哪各模块引用你,所以你无法定义准确的、刚好够用的接口和DTO。
现在有各”工资管理“模块,引用你的”员工管理模块“,它会拿到DTO中很多不必要的属性,也会在引入接口时拿到很多不需要的方法。
再比如我的”工单模块“如果直接引用你的契约,将来我发布工单模块,其它系统引用后,它必须去实现”员工契约“中的接口,它会很迷茫,我要实现这个契约中所有的接口吗?DTO所有的属性我都需要赋值吗?其实某些契约中的接口方法工单模块可能根本不需要,同理契约中的DTO也不一定都需要赋值。
还有更多问题,这些问题不影响使用,但挺别扭。出现这样的原因,是独立的业务模块应该在契约中定义自己能向外提供什么数据之外,还应该定义自己需要什么,而不是让别的模块的契约来指定。
我们在开发工单模块时,会从这两个方向来定义契约,即:工单模块需要什么数据?工单模块能向外提供什么数据?
可扩展
abp本身提供了很强的扩展能力,你可以
- 通过“动态属性系统”来扩展实体类
- 通过工单CRUD、工单状态变化等事件来添加自己的业务逻辑
- 通过集成并替换工单模块提供领域服务、应用服务来重写现有业务逻辑
- 默认的UI只是结合我自己的项目用easyui实现的,你可以实现自己的UI
- 通过集成抽象工单实体、抽象工单的领域服务、抽象的工单应用服务来实现更多的工单类型
使用DDD开发方式
实践下DDD
核心业务逻辑在工单实体类中,它定义了相应的业务方法,内部会改变工单实体自身的一些状态,必要时触发相应事件,以此来确保工单始终能处于正确的状态,比如:某个已完成的工单无关联的员工或没有开始和完成时间;再比如某个已拒绝的工单,没有拒绝说明。如果实体的属性都是public get; set; 很容易出现这种问题,因为协作开发时别人很可能胡乱调用你的实体,随意设置值。
领域服务有少量代码,也触发相应的领域事件。
应用服务来接收前端调用,协调领域实体和服务来实现业务逻辑。
关于DDD下篇详细说明设计思路时再细说
集成
可扩展性中提到工单是抽象化的,但默认提供了一个”普通工单“的实现,因此安装并配置模块后此功能立即可用。另外也可用提供几个子类实现一个自定义类型的工单。
线上demo:http://web1.cqyuzuji.com:9000/ 账号:admin 密码:123qwe
先在abp官方下载一个干净的abp项目,写此文章时用的abp6.3 .net 5。或者你可用在你目前的项目引入并测试。按以下步骤进行配置。
安装nuget包
相关nuget包都是以:BXJG.WorkOrder为前缀的。
先确保:
在解决方案上右键 > 管理解决方案的包 > 更新 -> Castle.Windsor.MsDependencyInjection 升级到3.4.0
在解决方案上右键 > 管理解决方案的包 > 更新 -> Microsoft.EntityFrameworkCore 更新到5.0.4
XXX.Core层中
Install-Package BXJG.WorkOrder.EFCore -Version 1.0.0-rc
XXX.EntityFrameworkCore层中
Install-Package BXJG.WorkOrder.EFCore -Version 1.0.0-rc
XXX.Application层中
Install-Package BXJG.WorkOrder.Application -Version 1.0.0-rc
Install-Package BXJG.WorkOrder.EmployeeApplication -Version 1.0.0-rc
工单模块中,后台管理工单和员工端对工单的操作是分开两个应用层项目定义的,根据你的情况决定是否分开,若分开则上面的包需要分开安装。
配置
在DbContext中注册相关实体
由于工单模块没有使用独立DbContext的方式,因此需要在你的主程序的DbContext中注册并配置”普通工单“和“工单分类”的实体。在XXX.EntityFrameworkCore层中找到你的DbContext,做如下配置:
1 public virtual DbSet<BXJG.WorkOrder.WorkOrderCategory.CategoryEntity> BXJGWorkOrderCategory { get; set; } 2 public virtual DbSet<BXJG.WorkOrder.WorkOrder.OrderEntity> BXJGWorkOrder { get; set; } 3 4 protected override void OnModelCreating(ModelBuilder modelBuilder) 5 { 6 base.OnModelCreating(modelBuilder); 7 modelBuilder.ApplyConfigurationBXJGWorkOrder();//别忘了这里的映射配置 8 }
注册权限和菜单
普通工单后台管理和员工端相关权限已定义为扩展方法,可以直接在主程序中调用,将其注册到主程序的权限树中。在XXX.Core/Authorization/XXXAuthorizationProvider中注册【普通工单】和【工单分类】的权限,为了演示将权限注册在了租户权限下面。
1 //注意这里的admin是指你已经存在的权限节点 2 admin.AddBXJGWorkOrderPermission(); 3 admin.AddBXJGEmployeeWorkOrderPermission();
同理在BXJG.Web.Mvc/Startup/XXXNavigationProvider中注册【普通工单】和【工单分类】的菜单
context.Manager.MainMenu.AddBXJGWorkOrderNavigation();
注册动态api
由于开发模块时不确定你会如何使用工单模块的应用层,因此默认并未自动注册为动态web api,如果需要你可以自己配置,目前是手动,将来可能提供扩展方法一次性注册。在XXX.Web.Core/XXXWebCoreModule的PreInitialize()中配置启用工单模块中普通工单和工单分类的相关动态web api
1 //注册后台管理工单的动态api
Configuration.Modules.AbpAspNetCore().CreateControllersForAppServices(typeof(BXJG.WorkOrder.ApplicationModule).Assembly,"bxjgworkorder"); 2 //注册后台和员工端管理工单的动态api
Configuration.Modules.AbpAspNetCore().CreateControllersForAppServices(typeof(BXJG.WorkOrder.BXJGCommonApplicationModule).Assembly, "bxjgworkorder"); 3 //注册员工端管理工单的动态api
Configuration.Modules.AbpAspNetCore().CreateControllersForAppServices(typeof(BXJG.WorkOrder.BXJGWorkOrderEmployeeApplicationModule).Assembly, "bxjgworkorder");
添加模块依赖
虽然已添加了模块相关包引用,但此时这些包对于主程序来说仅仅是普通的dll,必须按abp的方式,让主程序的模块依赖工单模块,这样工单模块中的dll才会以Abp模块方式启动。由于工单模块只提供到应用程序级别,因此在主程序的Application层中的Module类中添加依赖是最合适的。在XXX.Application/XXXApplicationModule中添加模块依赖
[DependsOn(略... typeof(BXJG.WorkOrder.ApplicationModule),
typeof(BXJG.WorkOrder.BXJGWorkOrderEmployeeApplicationModule))] public class XXXApplicationModule : AbpModule {
添加模块适配代码
如前所述,工单模块不提供员工管理功能,但依赖员工信息,因此你需要提供一个适配,这也是完全独立的工单模块的关键。在XXXApplication中新增WorkOrder文件夹,然后定义如代码
1 public class EmployeeAppService : IEmployeeAppService 2 { 3 private readonly IRepository<User, long> userRepository; 4 public IAsyncQueryableExecuter AsyncQueryableExecuter { get; set; } = NullAsyncQueryableExecuter.Instance; 5 public EmployeeAppService(IRepository<User, long> userRepository) 6 { 7 this.userRepository = userRepository; 8 } 9 10 public async Task<IEnumerable<EmployeeDto>> GetByIdsAsync(params string[] ids) 11 { 12 var query = userRepository.GetAll() 13 .Where(c => ids.Contains(c.Id.ToString())) 14 .Select(c => new EmployeeDto 15 { 16 Id = c.Id.ToString(), 17 Name = c.Name, 18 Phone = c.PhoneNumber 19 }); 20 return await AsyncQueryableExecuter.ToListAsync(query); 21 } 22 23 public async Task<IEnumerable<string>> GetIdsByKeywordAsync(string keyword) 24 { 25 var query = userRepository.GetAll() 26 .WhereIf(!keyword.IsNullOrEmpty(), c => c.Name.Contains(keyword) || c.PhoneNumber.Contains(keyword)) 27 .Select(c => c.Id.ToString()); 28 return await AsyncQueryableExecuter.ToListAsync(query); 29 } 30 31 public async Task<IEnumerable<EmployeeDto>> GetAllAsync(string keyword) 32 { 33 var query = userRepository.GetAll() 34 .WhereIf(!keyword.IsNullOrEmpty(), c => c.Name.Contains(keyword) || c.PhoneNumber.Contains(keyword)) 35 .Select(c => new EmployeeDto 36 { 37 Id = c.Id.ToString(), 38 Name = c.Name, 39 Phone = c.PhoneNumber 40 }); 41 return await AsyncQueryableExecuter.ToListAsync(query); 42 } 43 } 44 public class EmployeeSession : IEmployeeSession 45 { 46 private readonly IAbpSession abpSession; 47 48 public EmployeeSession(IAbpSession abpSession) 49 { 50 this.abpSession = abpSession; 51 } 52 53 public string CurrentEmployeeId => abpSession.UserId?.ToString(); 54 }
当然还需要在XXXApplication中的Model文件的Initialize()中配置依赖注入
IocManager.Register<IEmployeeAppService, WorkOrder.EmployeeAppService>(DependencyLifeStyle.Transient);
IocManager.Register<IEmployeeSession, EmployeeSession>(DependencyLifeStyle.Transient);
数据库迁移
这个是按abp的套路,这不再详述。注意abp默认下载来的项目连接字符串是连接到localhost的,而vs2019的localdb稍有不同,我是改成如下形式的,你看着办
"Default": "Server=(localDB)\\mssqllocaldb; Database=BXJGDB; Trusted_Connection=True;"
运行
不出意外的话接口就可以访问了
- WorkOrder:后台管理员对工单进行管理的接口,其中ChangeStatus是将工单跳跃或回退到指定状态,这个操作不是一步到位的,比如从”待审核“状态 跳跃到 ”已完成“中间会经历:确认、分配、执行、完成等步骤,操作员必须有这些步骤的权限,切工单状态必须正确(打个比方,分配时会判断工单是否已关联的处理人,只是假设,目前没做这个限制),这部分逻辑大多在工单实体中。
- WorkOrderCategory:后台管理员对工单分类进行维护的接口
- WorkOrderCommon:员工端或后台管理端都可以调用的接口,用来获取工单状态列表、紧急程度列表等
- WorkOrderEmployee:员工端对工单进行操作的接口,获取待分配的工单、执行、完成工单等。
后续
- 目前已实现真正的独立的业务模块
- 配置还需要进一步简化
- 目前只是基本能跑通工单流程,未作详细测试。
- 上面说明了模块的基本集成,以及模块内默认实现的“普通工单”的功能,如何扩展后续会说明,比如:实现自定义类型的工单、如何通过继承、事件等方式来扩展工单模块等。
- 目前模块只提供到应用层级别,也就是只提供后端接口,前端我使用的easyui,虽然可以使用abp的虚拟文件系统来实现UI模块化,但目前没有这样做,你可以使用自己喜欢的框架来完成UI