Jackyfei

我们是如何解决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,否则会影响团队的效率。比如可以前期起好头,评审频繁一些,后期团队上道了,可以每周审查一次,最好要有审查的标准,而且审核后要督促修改。

再次感谢您的阅读,希望我的文章能成为你成长路上的垫脚石。

+

(^_^)打个赏喝个咖啡(^_^)

微信支付
posted @ 2025-02-12 09:10  张飞洪[厦门]  阅读(891)  评论(11编辑  收藏  举报
相关博文:
阅读排行:
· DeepSeek-R1本地部署如何选择适合你的版本?看这里
· 开源的 DeepSeek-R1「GitHub 热点速览」
· 传国玉玺易主,ai.com竟然跳转到国产AI
· 揭秘 Sdcb Chats 如何解析 DeepSeek-R1 思维链
· 自己如何在本地电脑从零搭建DeepSeek!手把手教学,快来看看! (建议收藏)
点击右上角即可分享
微信分享提示