我们是如何解决abp身上的几个痛点
大家好,我是张飞洪,感谢您的阅读,我会不定期和你分享学习心得,希望我的文章能成为你成长路上的垫脚石,让我们一起精进。
abp框架在.net社区是spring一样的存在,用的人也非常多,毫无疑问,它确实是一个不错的框架,不然社区的star也不会那么多。我也是因为它的模块化,ddd,微服务兼容等特点做的选型。但是随着你使用的项目越多,你会发现它也有自己的不足,所谓没有十全十美的框架。
一、abp的痛点
通过使用商业版和社区版的经验,有几个痛点:
1.前端不支持VUE:
angular前端不好招聘,小公司可能个别会ng,但是架不住离职的风险。我曾经见过一个开发团队,后端用abp,前端用ng,前后端不分离,这个时候后端人员就硬着头皮去修改前人的angular,问他为什么改得这么慢,对方说:我比较熟悉vue,老板只有暗暗着急,却无可奈何。显然技术选型出问题,对团队和交付的影响是非常大的;
2.社区版界面比较丑陋:
是不是我的理解有偏差,感觉老外大部分都不怎么重视脸面问题。商业版的界面稍微就好看一些,但是味道感觉还是差了那么一点点;
3.售后服务非常慢:
这个可能是最坑的地方,对新人比较不友好,如果项目紧急,随时可能要面对的是漫长的等待,记得我刚入坑到时候,碰到一个https配置问题,问了邮件许久都没有回复;
4.功能不好用:
社区版的功能要么水土不服,要么功能太浅。看起来好些功能挺多的,但是用起来,还是要改得满头大汗;
5.价格比较昂贵:
带源码商业版要4万3左右,企业版更贵,大概要7.3万。
二、我们如何处理?
我不是说abp商业版不好,它挺不错的,只是不适合自己的业务需求,于是,我们用VUE3+ABP重做了一下(传送门),请大家指教,目前有几个增强点:
1.功能更深入。
比如:
- 【增强】文件管理:支持多种SSO存储渠道配置和切换;支持图片、文档、视频、音频的存储;
- 【新增】任务调度:支持单体和集群,支持可视化配置和日志记录;
- 【重写】认证授权:支持用户池,用户同步,单点登录,目前正在开发中;
- 【增强】用户和角色扩展;
- 【复刻】关联账户;
- 【复刻】委托登录;
- 【新增】菜单管理;
- 【新增】行政单位;
- 【新增】计量单位;
- 【新增】校验规则;
至于多语言,审计日志, opendidct,多租户等自带的功能,我们用vue3把他们优化和重写了,功能基本没有变化,没有什么可以分享的。
下面是认证授权的同步中心,用来解决多个系统用户集中管理和单点登录问题。
2.细节更深入。
功能层面
比如:菜单支持标星收藏和归类;前后端分离我们基于permission表扩展了菜单表,这样既能支持系统默认菜单,也能支持自定义菜单。
返回格式
又比如,abp的接口返回格式比较没有规则,考虑前端的体验,我们给它包了一个壳,做了格式统一:
- abp的返回格式:
- 我们的返回格式:
考虑前端的体验,我们给它包了一个壳
{
"code": null,
"data": {},
"isSuccess": true,
"msg": null,
"extras": null,
"timestamp": 1739241669
}
抛出异常状态码
这里面的状态码也很有讲究,我们观察发现abp也不是很规范,后台抛异常各自情况都有,我们统计了一下,abp的异常种类很多,多到感觉抓不到规律:
Application层:
throw new AbpException($"No policy defined to get/set permissions for the provider '{providerName}'. Use {nameof(PermissionManagementOptions)} to map the policy.");
throw new ApplicationException($"The permission named '{permission.Name}' has not compatible with the provider named '{providerName}'");
throw new AbpValidationException();
throw new FileNotFoundException($"Signing Certificate couldn't found: {file}");
throw new BadHttpRequestException("");
throw new NotImplementedException($"{nameof(GetUserInfoAsync)} is not implemented default. It should be overriden and implemented by the deriving class!");
throw new EntityNotFoundException(typeof(IdentityRole), id);
throw new UserResponseAlreadyExistException();
throw new EmailAddressRequiredException();
Domain层:
throw new BusinessException(code: IdentityErrorCodes.UserSelfDeletion);
throw new BusinessException(EventHubErrorCodes.CantChangeEventTiming)
.WithData("MaxTimingChangeLimit", EventConsts.MaxTimingChangeCountForUser);
throw new BusinessException(EventHubErrorCodes.TrackNameAlreadyExist)
.WithData("Name", name);
throw new ArgumentException("identityResult.Errors should not be null.");
throw new ArgumentNullException(nameof(childCode), "childCode can not be null or empty.");
throw new UserFriendlyException(L["CountryAndCityRequiredForUpdateInPersonEvent"]);
throw new AbpAuthorizationException(
L["EventHub:NotAuthorizedToUpdateEvent", @event.Title],
EventHubErrorCodes.NotAuthorizedToUpdateEvent
);
public static class IdentityErrorCodes
{
public const string UserSelfDeletion = "Volo.Abp.Identity:010001";
public const string MaxAllowedOuMembership = "Volo.Abp.Identity:010002";
public const string ExternalUserPasswordChange = "Volo.Abp.Identity:010003";
public const string DuplicateOrganizationUnitDisplayName = "Volo.Abp.Identity:010004";
public const string StaticRoleRenaming = "Volo.Abp.Identity:010005";
public const string StaticRoleDeletion = "Volo.Abp.Identity:010006";
public const string UsersCanNotChangeTwoFactor = "Volo.Abp.Identity:010007";
public const string CanNotChangeTwoFactor = "Volo.Abp.Identity:010008";
public const string YouCannotDelegateYourself = "Volo.Abp.Identity:010009";
public const string ClaimNameExist = "Volo.Abp.Identity:010021";
}
因为不同的种类异常返回的状态码是有差异的,归类起来有如下业务状态:
- 请求成功
- 请求成功,返回空
- 非法参数
- 未授权,需提供身份验证
- 已授权,但服务器拒绝请求
- 服务器内部错误
到底我们应该如何返回才能减轻前端判断的复杂度,如何让abp的异常和这些码对应起来,如何拦截abp底层异常,进行重写返回码,这是一项挺复杂的任务,后续有机会再做深入分享。
DTO规范
除此之外,abp内部本身的规范也存在一定问题,比如dto的命名,除了以dto为后缀,也会出现以input为后缀的dto类。我们内部统一以input和output作为后缀,因为关于命名我们也有一套规范,这里不展开。dto重要吗,有没有必要拿出来讲,这是一个问题。我觉得dto有很多门道,值得写一篇长文,下面简单点一下
dto门道:
- dto要不要包含业务逻辑?
- 未使用的DTO要不要删掉
- 输入DTO要不要共享
- 输出DTO要不要共享
- DTO命名规约(CURD/GET/SAVE/CHECK/LIST/TREE/SELECT/DETAIL等等)
- DTO如何验证,如何做多语言
不再展开了,敬请期待《DTO应该如何规范比较好?》。
其他
其他的细节还有很多,后续会做一个系列文章《我用abp做企业数字化应用》,这里就不一一罗列,有兴趣的朋友可以通过(传送门)了解学习。
3.基于Ant Design的VUE3框架。
选vue主要考虑vue开发者比较充沛,人员离职交接比较方便;另外颜值这块目前vue生态发展得很给力,应该会更加符合国人口味,更换主体也是标配。好不好看,东哥说我有“眼盲”,不知道奶茶好不好看:
主题萝卜青菜各有所爱,通过比对,不敢也没有对abp不敬,更不是为了输赢,就是想追求品位多一点,不知道这个主题你满意吗?
4.重写abp源码。
框架本身扩展性很不错,但是架不住各自需求不断冲击。
比如:
- 如何基于用户表,扩展原生的字段?
- 国内外第三方有几十个,我应该如何集成?
- 如何在授权中心托管一个VUE前端登录页面?
- 想给CurrentUser增加一个IsAdmin字段要如何增加?
- 如何隐藏API接口的显示?
- 测试阶段,如何取消全部权限Authorize?
- 如何使用纯净版的abp进行项目开发?
- 为什么返回的CurrentUser为空?
- 如何增加用户池Id,模仿IsDelete做数据过滤?
- 有了控制器层,为什么还要有httpapi这一层?
- 如何自定义Claim?
- 为什么老是报实体对象没有注入的错?
- abp太臃肿,如何给它瘦身?
- 如何去除abp数据库的表前缀?
- 微服务底下认证授权如何使用?
- 如何抛弃abp的identity基础模块,重写opendidct?
- 如何结合abp,jenkins,docker,k8s做devops?
- abp的store和领域服务有什么区别?
- ……
以上可以继续罗列很多,只要你一直用abp开发,你遇到的坑会越多,你就会越想重写abp,但是当你越过了这些障碍,你就越了解它,知道它的优点和缺点,知道如何避免这些坑位。
下面是我们在做IDaaS这个项目过程中,重写的一个功能,我们给授权中心托管了一个登录组件,支持验证码(手机/邮箱)和密码(手机/邮箱/用户名)登录,这样不管未来你有多少个系统,你都不需要重写登录了。你可以在认证授权中心进行注册应用,选择许可模式,配置scope,你就可以愉快得进行单点登录了。oauth2.0和openid这两个协议想要用好其实挺不容易的:
- 客户端有很多种类,比如app,web,spa,api等;
- 许可模式有授权码,隐式,密码和客户端凭证或者自定义模式;
- 什么类型的客户端要选择什么用的模式,如何自定义模式?
这些东西还是有点复杂的,如果您刚好也在做这类开发,欢迎您留言和我探讨。
5.追求性能极致。
abp在权限列表、权限保存和特征配置列表的性能非常拉胯,比如你定义的功能按钮在100以上,你会发现原生接口的权限渲染和保存会非常卡,主要原因就是在循环里面进行读写,我们花了大力气进行重构,下面是权限设置重构后的代码:
权限列表接口优化
下面是源码内容,里面有四个循环(晕):
public virtual async Task<GetPermissionListResultDto> GetAsync(string providerName, string providerKey)
{
await CheckProviderPolicy(providerName);
var result = new GetPermissionListResultDto
{
EntityDisplayName = providerKey,
Groups = new List<PermissionGroupDto>()
};
var multiTenancySide = CurrentTenant.GetMultiTenancySide();
foreach (var group in await PermissionDefinitionManager.GetGroupsAsync())
{
var groupDto = CreatePermissionGroupDto(group);
var neededCheckPermissions = new List<PermissionDefinition>();
var permissions = group.GetPermissionsWithChildren()
.Where(x => x.IsEnabled)
.Where(x => !x.Providers.Any() || x.Providers.Contains(providerName))
.Where(x => x.MultiTenancySide.HasFlag(multiTenancySide));
foreach (var permission in permissions)
{
if(permission.Parent != null && !neededCheckPermissions.Contains(permission.Parent))
{
continue;
}
if (await SimpleStateCheckerManager.IsEnabledAsync(permission))
{
neededCheckPermissions.Add(permission);
}
}
if (!neededCheckPermissions.Any())
{
continue;
}
var grantInfoDtos = neededCheckPermissions
.Select(CreatePermissionGrantInfoDto)
.ToList();
var multipleGrantInfo = await PermissionManager.GetAsync(neededCheckPermissions.Select(x => x.Name).ToArray(), providerName, providerKey);
foreach (var grantInfo in multipleGrantInfo.Result)
{
var grantInfoDto = grantInfoDtos.First(x => x.Name == grantInfo.Name);
grantInfoDto.IsGranted = grantInfo.IsGranted;
foreach (var provider in grantInfo.Providers)
{
grantInfoDto.GrantedProviders.Add(new ProviderInfoDto
{
ProviderName = provider.Name,
ProviderKey = provider.Key,
});
}
groupDto.Permissions.Add(grantInfoDto);
}
if (groupDto.Permissions.Any())
{
result.Groups.Add(groupDto);
}
}
return result;
}
下面是我们优化后的内容:
public override async Task<GetPermissionListResultDto> GetAsync(string providerName, string providerKey)
{
await CheckProviderPolicy(providerName);
var result = new GetPermissionListResultDto
{
EntityDisplayName = providerKey,
Groups = new List<PermissionGroupDto>()
};
var multiTenancySide = CurrentTenant.GetMultiTenancySide();
// 预先获取所有权限组及其子权限
var groups = await _permissionDefinitionManager.GetGroupsAsync();
// 提前检查所有需要的特性
var featureNames = groups
.Select(group => group.Properties.FirstOrDefault(it => it.Key == "FeatureName").Value?.ToString())
.Where(featureName => !string.IsNullOrEmpty(featureName))
.Distinct()
.ToList();
var enabledFeatures = new HashSet<string>();
foreach (var featureName in featureNames)
{
if (await _featureChecker.IsEnabledAsync(featureName))
{
enabledFeatures.Add(featureName);
}
}
// 批量查询权限状态
var allPermissions = groups.SelectMany(group => group.GetPermissionsWithChildren())
.Where(permission => permission.IsEnabled)
.Where(permission => !permission.Providers.Any() || permission.Providers.Contains(providerName))
.Where(permission => permission.MultiTenancySide.HasFlag(multiTenancySide))
.ToList();
var permissionNames = allPermissions.Select(permission => permission.Name).ToArray();
var multipleGrantInfo = await _permissionManager.GetAsync(permissionNames, providerName, providerKey);
var multipleGrantResult = multipleGrantInfo.Result.ToDictionary(x => x.Name);
// 缓存状态检查结果
var simpleStateCheckerCache = new Dictionary<string, bool>();
foreach (var permission in allPermissions)
{
if (!simpleStateCheckerCache.ContainsKey(permission.Name))
{
simpleStateCheckerCache[permission.Name] = await _simpleStateCheckerManager.IsEnabledAsync(permission);
}
}
}
重构后,耗时不足1s,如下图所示:
权限保存接口优化
针对权限保存的性能问题,我们去除循环操作,一次性读取到内存,在缓冲器进行操作,重构代码如下:
/// <summary>
/// 保存权限
/// </summary>
/// <param name="providerName"></param>
/// <param name="providerKey"></param>
/// <param name="input"></param>
/// <returns></returns>
public async override Task UpdateAsync(string providerName, string providerKey, UpdatePermissionsDto input)
{
await CheckProviderPolicy(providerName);
// 将 Permissions 根据 IsGranted 分为两个列表
var grantedPermissions = input.Permissions
.Where(p => p.IsGranted)
.Select(p => p.Name)
.ToList();
var deniedPermissions = input.Permissions
.Where(p => !p.IsGranted)
.Select(p => p.Name)
.ToList();
// 批量设置 Granted 为 true 的权限
if (grantedPermissions.Any())
{
await _permissionExtendManager.SetBatchAsync(grantedPermissions, providerName, providerKey, isGranted: true);
}
// 批量设置 Granted 为 false 的权限
if (deniedPermissions.Any())
{
await _permissionExtendManager.SetBatchAsync(deniedPermissions, providerName, providerKey, isGranted: false);
}
}
6.死磕代码规范。
一般开源项目不怎么注重代码可读性,abp的模块化设计思想,如果不考虑隔离的代价,其实还是挺不错的,为了项目长期可持续发展,我们还是拟定了适合自己内部的代码规范。
开发规范手册
因为规范是一个团队的标准,老板一般都比较重视,为此我们写了一本开发规范手册,希望对你有所启发。
以上是大的方面,从细节上,我们从DDD的逻辑分层结构入手,每个层、每个类、方法、控制器、api、验证格式、dto、常量等都一一审视一遍。
现摘录一部分:
HttpApi规约
接口数量最佳实践,我们约定:
- 一个接口能完成的工作,尽量不要用多个接口。
比如:租户有功能接口,租户版本有功能接口,可以考虑接口进行合并。 - 多个接口分开做的事情,尽量不要一个接口。
比如:列表和详情的接口,一般是分开编写的,确保接口的职责单一。 - 接口分开合并的粒度要适中
接口什么时候分开,什么时候合并,有个度的问题,比较难把握,如果自己不能确定,需要向上扩大讨论和取舍。坚持质量优先,进度可以适当放慢,不要为了赶进度,不兼顾代码质量,否正代码越多,错误越多,因为代码是需要长期维护的。
接口格式,我们在每个功能开发之前的设计范示:
Domain.Shared规约:
- 实体文件夹以s结尾;
- 错误代码以静态类+常量定义在该层,并以ErrorCodes结尾;
- 全局设置定义在该层,并固定以Settings命名;
- 多语言的json文件定义在该层;
- 枚举类和数据库字段长度和默认名称定义在该层。
Domain规约:
- 实体文件夹约定以s结尾;
- 表结构字段约定以静态类+常量定义在该层,并以Consts结尾(如果有定义);
- 全局设置约定定义在该层,并固定以Settings命名(如果有定义);
- 种子数据约定定义在该层;
EF Core规约:
- 实体文件夹采用s结尾;
- 内部包含DbContext,DbContextFactory,ModelCreatingExtensions三个核心类
规范虽好,但是它也是一把双刃剑,开发过程要进行balance,否则会影响团队的效率。比如可以前期起好头,评审频繁一些,后期团队上道了,可以每周审查一次,最好要有审查的标准,而且审核后要督促修改。
再次感谢您的阅读,希望我的文章能成为你成长路上的垫脚石。
(^_^)打个赏喝个咖啡(^_^)
![微信支付](https://images.cnblogs.com/cnblogs_com/jackyfei/1334006/o_wx.png)
【推荐】编程新体验,更懂你的AI,立即体验豆包MarsCode编程助手
【推荐】凌霞软件回馈社区,博客园 & 1Panel & Halo 联合会员上线
【推荐】抖音旗下AI助手豆包,你的智能百科全书,全免费不限次数
【推荐】轻量又高性能的 SSH 工具 IShell:AI 加持,快人一步
· DeepSeek-R1本地部署如何选择适合你的版本?看这里
· 开源的 DeepSeek-R1「GitHub 热点速览」
· 传国玉玺易主,ai.com竟然跳转到国产AI
· 揭秘 Sdcb Chats 如何解析 DeepSeek-R1 思维链
· 自己如何在本地电脑从零搭建DeepSeek!手把手教学,快来看看! (建议收藏)