Jackyfei

体验 ABP 的功能和服务

大家好,我是张飞洪,感谢您的阅读,我会不定期和你分享学习心得,希望我的文章能成为你成长路上的垫脚石,让我们一起精进。

在前面三章中,我们探讨了ABP框架提供的基本服务、数据访问基础设施和横切关注点问题。
在第2部分的最后一章中,我们将继续介绍经常使用的一些ABP功能:

  • 获取当前用户
  • 使用数据过滤
  • 控制审计日志
  • 缓存数据
  • 本地化用户界面(UI)

一、获取当前用户

如果应用需要对某些功能进行身份验证,通常需要获取有关当前用户的信息。ABP提供ICurrentUser服务,以获取当前登录用户的详细信息。对于Web应用程序,ICurrentUser的实现与ASP.NET Core完全集成,因此您可以轻松获取当前用户的Claims(声明)。

有关ICurrentUser服务的简单用法,请参阅以下代码块:

using System; 
using Volo.Abp.DependencyInjection; 
using Volo.Abp.Users; 
namespace DemoApp 
{     
    public class MyService : ITransientDependency 
    {         
        private readonly ICurrentUser _currentUser;         
        public MyService(ICurrentUser currentUser)         
        { 
            _currentUser = currentUser;
        }         
        public void Demo() 
        { 
            Guid? userId = _currentUser.Id;             
            string userName = _currentUser.UserName;             
            string email = _currentUser.Email;         
        }     
    }
}

在本例中,MyService构造函数注入ICurrentUser服务,然后获取当前用户的唯一IdUsernameEmail

ICurrentUser接口属性

以下是ICurrentUser接口的相关属性:

  • IsAuthenticated (bool):如果当前用户已登录(已验证),则返回 true
  • Id (Guid?):当前用户唯一标识符(UID)。如果尚未登录,则返回 null
  • UserName (string):当前用户的用户名。如果尚未登录,则返回 null
  • TenantId (Guid?):当前用户的租户ID。它可用于多租户应用程序。如果当前用户与租户无关,则返回null
  • Email (string):当前用户的电子邮件地址。如果未登录或未设置电子邮件地址,则返回null
  • EmailVerified (bool):如果当前用户的邮件已被验证,则返回true
  • PhoneNumber (string):当前用户的电话号码。如果当前用户未登录或未设置电话号码,则返回null
  • PhoneNumberVerified (bool):如果当前用户的电话号码已被验证,则返回true
    Roles (string[]):当前用户的所有角色(字符串数组)。

注入ICurrentUser服务

ICurrentUser是一种广泛使用的服务。因此,一些基本ABP类(如ApplicationService和AbpController)提供了预注入。在这些类中,您可以直接使用CurrentUser属性,而不是手动注入此服务。

ICurrentUser服务注入

  • ICurrentUser是一种广泛使用的服务。因此,一些ABP基类(如ApplicationServiceAbpController)提供了预注入。在这些类中,可以直接使用 CurrentUser属性,而无需手动注入此服务。

ABP可以与任何身份验证提供商合作,可以与ASP.NET Core提供的当前声明合作(声明[Claims]是用户登录时存储在身份验证票据中的键值对)。如果您使用的是基于cookie的身份验证,它们将存储在cookie中,并在每个请求中发送到服务器。如果您使用的是基于令牌的身份验证,则客户端会在每个请求中发送它们,通常在HTTP头中。

ICurrentUser服务从当前声明中获取所有信息。如果想要直接查询当前声明,可以使用FindClaimFindClaimsGetAllClaims方法。如果您创建了自定义声明,这些方法尤其有用:

定义声明

ABP提供了一种将自定义声明添加到身份验证票据的简单方法,以便在同一用户的下一个请求中安全地获取这些自定义值。您可以实现IAbpClaimsPrincipalContributor接口,将自定义声明添加到身份验证票据中。

在下面的示例中,我们将添加社会安全号码信息,这是身份验证票据的自定义声明:

public class SocialSecurityNumberClaimsPrincipalContributor : IAbpClaimsPrincipalContributor, ITransientDependency 
{     
    public async Task ContributeAsync(AbpClaimsPrincipalContributorContext context) 
    {  
        ClaimsIdentity identity = context.ClaimsPrincipal.Identities.FirstOrDefault();        
        var userId = identity?.FindUserId();         
        if (userId.HasValue) 
        {             
            var userService = context.ServiceProvider.GetRequiredService();                         
            var socialSecurityNumber = await userService.GetSocialSecurityNumberAsync(userId.Value);             
            if (socialSecurityNumber != null)  
            {                 
                identity.AddClaim(new Claim("SocialSecurityNumber",socialSecurityNumber));             
            }         
        }     
    } 
}

在本例中,我们首先获取ClaimsIdentity并查找当前用户的ID。然后,我们从IUserService获取社会保险号,这是自行开发的服务。您可以从ServiceProvider解析任何服务来查询所需的数据。最后,我们为identity添加了一个新的Claim(声明)。 每当用户登录时,都会执行SocialSecurityNumberClaimsPrincipalContributor

您可以为当前用户使用自定义声明,用于授权特定的业务需求、过滤数据或仅在UI上做显示。

[success] 请注意,除非使身份验证票据失效并重新登录,否则无法更改身份验证票据声明,因此不要在声明中存储频繁更改的数据。如果你想将用户数据存储在以后可以快速访问的位置,则可以使用缓存系统。

ICurrentUser是系统经常使用的核心服务。下一节将介绍大部分时间都能无缝工作的数据过滤系统。

二、使用数据过滤

过滤查询中的数据在数据库操作中非常常见。如果您使用的是结构化查询语言(SQL),那么可以使用WHERE子句。如果您使用的是语言集成查询(LINQ),则使用C#中的Where扩展方法。虽然大多数过滤条件在查询中有所不同,但如果实现的是软删除和多租户,则某些表达式在所有查询中是一致的。

ABP自动化了数据过滤过程,帮助您避免在应用代码中到处重复相同的过滤逻辑。

在本节中,我们将首先看到ABP框架的预构建数据过滤器,然后学习如何在需要时禁用过滤器。最后,我们将看到如何实现自定义数据过滤器

我们通常使用简单的接口来实现对实体的过滤。ABP定义了两个预定义的数据过滤器,以实现软删除和多租户。

1 预构建数据过滤器

1.1 软删除过滤器

如果对一个实体使用软删除,则不会从物理上删除数据库中的实体。而是将其标记为已删除。

ABP定义了ISoftDelete接口,以将实体标记为软删除。可以为实体实现该接口,如下代码块所示

public class Order : AggregateRoot, ISoftDelete 
{     
    public bool IsDeleted { get; set; }     
    //...other properties 
}

在本例中,Order实体具有由ISoftDelete接口定义的IsDeleted属性。一旦实现了该接口,ABP将为您自动执行以下任务:

  • 删除订单时,ABP会标识Order实体实现软删除,并将IsDeleted设置为 true,订单不会在数据库中被物理删除。
  • 查询订单时,ABP会自动过滤掉被删除的实体(通过向查询中添加IsDeleted == false条件)。

数据过滤限制

数据过滤自动化仅在使用存储库或DbContextEF Core)时有效。如果您使用的是手写的SQL DELETESELECT命令,您应该自己处理,因为在这种情况下,ABP无法拦截您的操作。

1.2 多租户过滤器

在SaaS解决方案中,多租户是租户之间共享资源的一种广泛使用的模式。在多租户应用程序中,隔离不同租户之间的数据至关重要。一个租户无法读取或写入另一个租户的数据,即使它们位于同一个物理数据库中。

ABP有一个完整的多租户系统,将在第16章“实现多租户”中详细介绍。这里仅仅提到的是它的过滤器,因为它与数据过滤系统有关。
ABP定义了IMultiTenant接口,用于为实体启用多租户数据过滤器。我们可以为实体实现该接口,如以下代码块所示:

public class Order : AggregateRoot, IMultiTenant {
    public Guid? TenantId { get; set; }     
    //...other properties 
}

IMultiTenant接口定义了TenantId属性,如本例所示,使用的是Guid类型。

一旦我们实现了IMultiTenant接口,ABP就会使用当前租户的ID自动过滤订单实体的所有查询。当前租户的ID是从ICurrentTenant服务获得的。

使用多个数据过滤器

可以为同一实体启用多个数据筛选器。例如,本节中定义的Order实体可以实现ISoftDeleteIMultiTenant接口。
如您所见,为实体实现数据过滤器非常简单,只需实现与数据过滤器相关的接口即可。默认情况下,所有数据过滤器均已启用,除非您明确禁用它们。

2 禁用数据过滤器

在某些情况下,可能需要禁用自动筛选器。例如,您可能希望从数据库中读取已删除的实体,您可能希望查询所有租户的数据。为此,ABP提供了一种简单而安全的方法来禁用数据过滤器。

以下示例显示了如何通过使用IDataFilter服务禁用ISoftDelete数据过滤器,从数据库获取所有订单,包括已删除的订单:

public class OrderService : ITransientDependency {     
    private readonly IRepository _orderRepository;     
    private readonly IdataFilter _dataFilter;     
    public OrderService(Irepository orderRepository,IdataFilter dataFilter) 
    {
        _orderRepository = orderRepository;         
        _dataFilter = dataFilter;     
    }     
    public async Task> GetAllOrders()
    {         
        using (_dataFilter.Disable())
        {             
            return await _orderRepository.GetListAsync();         
        }     
    } 
}

在本例中,OrderService注入Order存储库和IdataFilter服务。然后使用_dataFilter.Disable<IsoftDelete>()表达式以禁用软删除筛选器。在using语句中,过滤器被禁用,我们可以查询已删除的订单。

始终使用using语句

Disable方法返回一个一次性对象,以便我们可以在using语句中使用它。一旦using块结束,过滤器会自动返回到启用状态。该方式允许我们安全地禁用过滤器,而不会影响调用GetAllOrders方法的任何逻辑。建议在using语句中禁用筛选器。

IdataFilter服务还提供了两种方法:

  • Enable<Tfilter>:启用数据过滤器。如果过滤器已启用,则该选项无效。建议在using语句中启用筛选器,和禁用方法一样。
  • IsEnabled<Tfilter>:如果给定的筛选器当前已启用,则返回 true。通常不需要此方法,因为在这两种情况下都会按预期 EnableDisable

我们已经学习了如何禁用和启用数据过滤器。下面继续展示如何创建自定义数据过滤器。

3 定义自定义数据过滤器

就像预构建的数据过滤器一样,您可能需要定义自己的过滤器。数据过滤器由接口表示,因此第一步是为过滤器定义接口。

假设您希望归档实体,并自动过滤归档数据。为此,我们可以定义这样的接口(您可以在领域层中定义),如下所示:

public interface Iarchivable { bool IsArchived { get; } }

IsArchived属性将用于过滤实体。默认情况下,IsArchivedtrue的实体将被删除。一旦我们定义了这样一个接口,我们就可以实现它。请参见以下示例:

public class Order : AggregateRoot, Iarchivable 
{     
    public bool IsArchived { get; set; }     
    //...other properties 
}

在本例中,Order实体实现了Iarchivable接口,这使得在该实体上应用数据过滤器成为可能。

请注意,Iarchivable接口没有为Iarchivable定义setter属性,但Order实体定义了setter属性。这是因为我们不需要在接口上设置Iarchivable,但需要在实体上设置它。

由于数据过滤是在数据库提供程序级别完成的,因此自定义过滤器的实现也取决于数据库提供程序。本节将展示如何为EF Core实现IsArchived过滤器。如果您使用的是MongoDB,请参阅ABP的文档

ABP使用EF Core的全局查询过滤器系统对EF Core中的数据进行过滤。可以在DbContext中为数据过滤器实现过滤逻辑。

**第1步:**在DbContext中定义属性(将在过滤器表达式中使用),如下所示:

protected bool IsArchiveFilterEnabled => DataFilter?.IsEnabled() ?? false;

该属性使用IdataFilter服务获取过滤器状态。DataFilter属性来自基类AbpDbContext,如果没有从依赖项注入(DI)系统解析DbContext出实例,则该属性可以为null。这就是为什么我使用null检查。

**第2步:**重写ShouldFilterEntity方法,以决定是否应过滤给定的实体类型:

protected override bool ShouldFilterEntity(ImutableEntityType entityType) 
{     
    If (typeof(IArchivable).IsAssignableFrom(typeof(TEntity)))
    { 
        return true;     
    }          
    return base.ShouldFilterEntity(entityType); 
}

ABP框架为DbContext中的每个实体调用此方法(仅在应用启动后,首次使用DbContext类时调用一次)。如果此方法返回true,则为该实体启用EF Core全局过滤器。在这里,我只是检查给定的实体是否实现了IArchivable接口,并在这种情况下返回true。否则,调用base方法,以便检查其他数据过滤器。

**第3步:**ShouldFilterEntity仅决定是否启用过滤,实际的过滤逻辑应该通过重写CreateFilterExpression方法来实现:

protected override Expression<Func<TEntity, bool>> CreateFilterExpression<TEntity>() {     
    var expression = base.CreateFilterExpression<TEntity>();     
    if (typeof(Iarchivable).IsAssignableFrom(typeof(TEntity)))   
    {         
        Expression<Func<TEntity, bool>> archiveFilter = e => !IsArchiveFilterEnabled || !EF.Property<bool>(e, "IsArchived"); 
        expression = expression == null ? archiveFilter : CombineExpressions(expression,archiveFilter);     
    }     
    return expression; 
}

实现似乎有点复杂,因为它创建并组合了表达式。最重要的是如何定义archiveFilter表达式。

  • !IsArchiveFilterEnabled检查过滤器是否已禁用。如果过滤器被禁用,则不计算其他条件,并且检索所有实体时不进行过滤!
  • !EF.Property<bool>(e, "IsArchived")检查该实体的IsArchived值是否为false,因此它会删除IsArchivedtrue的实体。
    我没有在过滤器实现中使用Order实体,这意味着实现是通用的,可以与任何实体类型一起工作。您只需要为要应用过滤器的实体实现IArchivable接口。

总之,ABP允许我们轻松地创建和控制全局查询过滤器,它还使用过滤系统实现了两种流行的模式:软删除和多租户。下一节将介绍非常常见的日志审计系统。

三、控制审计日志

ABP的审计日志系统会跟踪所有请求和实体更改,并将它们写入数据库。最后,你会得到一份报告,描述了我们的系统做了什么,什么时候做的,是谁做的。

从启动模板创建新解决方案时,审计日志已经预先安装并配置完毕。大多数情况下,无需进行任何配置。但是,ABP允许我们控制、自定义和扩展审计日志系统。

首先,让我们了解一下什么是审计日志对象。

审计日志对象(Audit log object)

审计日志对象是在特定作用域内对方法操作实体变更的日志记录,比如执行HTTP请求记录。下面是ABP审计实体关系图:

让我们从根对象开始解释:

  • AuditLogInfo:在每个作用域(通常是web请求)中,都会创建一个AuditLogInfo对象,其中包含当前用户、当前租户、HTTP请求、客户端和浏览器详细信息,以及操作的执行时间和持续时间等信息。
  • AuditLogActionInfo:每个审计日志中,操作通常是controller action调用、page handler调用或application service方法调用。包括该调用中的类名、方法名和方法参数。
  • EntityChangeInfo:审计日志对象可能包含数据库实体更改。包含实体更改类型(创建、更新或删除)、实体类型和更改实体的ID。
  • EntityPropertyChangeInfo:对于每个实体更改,它都会在属性(数据库字段)上记录更改。此对象包含属性的名称、类型、旧值和新值。
  • Exception:在此审计日志范围内发生的异常列表。
  • Comment:与此审计日志相关的注释/日志。

审计日志对象保存在关系数据库的多个表中:AbpAuditLogsAbpAuditLogActionsAbpEntityChanges, 和AbpEntityPropertyChanges。您可以打开数据库表,详细了解审计日志对象的基本属性,或查看AuditLogInfo对象的详细信息。

MongoDB限制

ABP使用EF Core的更改跟踪系统来获取实体更改信息,但是MongoDB不会记录实体更改,因为MongoDB驱动程序没有此类更改跟踪系统。

审计日志作用域(Audit log scope

如本节开头所述,在每个审计日志作用域都会创建一个审计日志对象。

审计日志作用域使用环境上下文模式(Ambient Context Pattern)。创建审计日志作用域时,在此域中执行的所有操作和更改都将保存为单个审计日志对象。

有几种方法可以建立审计日志作用域:

1 审计日志中间件

第一种是最常见的方法,在ASP.NET Core管道中配置审计日志中间件

app.UseAuditing();

这通常放在app.UseEndpoints()app.UseConfiguredEndpoints()配置之前。使用该中间件时,每个HTTP请求都会写入一个单独的审计日志记录(默认已在启动模板中配置完毕)。

2 审计日志拦截器

如果您不使用审计日志中间件,或者您的应用程序不是请求/回复式的ASP.NET Core应用(例如,是桌面或Blazor Server应用),ABP会根据每个应用服务方法创建一个新的审计日志作用域。

3 手动创建审计作用域

非必要不这么做,但如果要手动创建审计作用域,可以使用IAuditingManager服务,如以下代码所示:

public class MyServiceWithAuditing : ITransientDependency {     
    //...inject IAuditingManager _auditingManager;     
    public async Task DoItAsync() 
    {         
        using (var auditingScope = _auditingManager.BeginScope())
        {             
            try{                 
                //TODO: call other services...             
            } catch (Exception ex) {
                _auditingManager.Current.Log.Exceptions.Add(ex);
                throw;
            }           
            finally
            {
                await auditingScope.SaveAsync();
            }        
        }     
    }
}

一旦注入IAuditingManager服务后,可以使用BeginScope方法创建新的作用域,然后,创建一个try-catch块来保存审计日志,包括异常情况。在try内,可以执行逻辑,调用任何其他服务。所有这些操作及更改最终都会在finally中被保存为单个审计日志对象。

在审计日志作用域内,_auditingManager.Current.Log可用于获取当前审计日志对象,用于审查或其他操作(例如,添加注释或其他信息)。超出审计日志作用域,_auditingManager.Current将返回null,因此如果不确定是否存在审计作用域,请务必检查null值。

接下来,我们看看审计日志系统的配置选项options

审计选项

AbpAuditingOptions用于配置审计默认选项,如下例所示:

Configure(options => { options.IsEnabled = false; });

您可以在模块的ConfigureServices方法内配置options。有关审计系统的主要选项,请参见以下列表:

  • IsEnabled (bool; default: true):禁用审计。
  • IsEnabledForGetRequests (bool; default: false):默认情况下,ABP不会保存HTTPGET请求的审计日志,因为GET请求不应该更改数据库。但是,将其设置为true后也会启用GET请求的审计日志记录。
  • IsEnabledForAnonymousUsers (bool; default: true):如果只想为经过身份验证的用户做审计,请将其设置为false。如果为匿名用户保存审计日志,这些用户的ID值将为null
  • AlwaysLogOnException (bool; default: true):如果应用程序出现异常,ABP将默认保存审计日志(IsEnabledForGetRequestsIsEnabledForAnonymousUsers选项将不做考虑)。将此设置为false可禁用该行为。
  • hideErrors (bool; default: true):将审计日志对象保存到数据库时忽略异常。将其设置为false以抛出异常。
  • ApplicationName (string; default: null):如果多个应用使用同一数据库保存审计日志,则可以在每个应用中设置此选项,以便根据应用名称筛选日志。
  • IgnoredTypes (List<Type>):忽略审计日志中的某些特定类型,包括实体类型。

除了这些全局选项外,还可以启用/禁用实体的更改跟踪。

启用实体历史记录

审计日志对象包含实体/属性更改的详细信息,默认情况下,它对所有实体都是禁用的,因为全部开启会将太多日志写入数据库,从而迅速增加数据库容量。

[warning] 所以,建议只对要跟踪的实体进行启用。

有两种方法可以为实体启用历史记录,如下所述:

  • [Auditing]属性,用于为单个实体启用它,后续将详细介绍。
  • EntityHistorySelectors选项,用于为多个实体启用它。
    在以下示例中,我为所有实体启用EntityHistorySelectors选项:
Configure(options => { options.EntityHistorySelectors.AddAllEntities(); });

AddAllEntities方法是一种快捷方式。EntityHistorySelectors是命名选择器的列表,您可以添加lambda表达式来选择所需的实体。以下代码相当于前面的配置代码:

Configure(options => { options.EntityHistorySelectors.Add(new NamedTypeSelector("MySelectorName", type => true)); });
  • NamedTypeSelector的第一个参数是选择器名称MySelectorName。选择器名称是任意的,用于从选择器列表中查找或删除选择器。
  • NamedTypeSelector的第二个参数采用一个表达式。它为您提供一个实体type,并等待truefalse。如果要为给定实体类型启用实体历史记录,则返回true。因此,可以传递一个表达式(比如type => type.Namespace.StartsWith("MyRootNamespace"),选择具有名称空间的所有实体)。您可以根据需要添加任意多个选择器。如果其中一个返回true,则该实体用于记录属性更改。

除了这些全局选项和选择器之外,还可以根据类、方法和属性级别来启用/禁用审计日志记录。

禁用/启用审计日志详细记录

使用审计日志时,通常需要记录每次访问。但是,在某些情况下,我们可能希望禁用某些特定操作或实体的审计日志。比如:

  • 操作参数写入日志可能很危险(例如,它可能包含用户的密码);
  • 操作调用或实体更改可能超出用户的控制,不值得记录;
  • 操作可能是一个批量操作,写入太多审计日志会降低性能。

ABP定义了[DisableAuditing][Audited]属性,以声明方式控制记录的对象。审计日志记录有两个目标可以控制:服务调用和实体历史记录。

1 服务调用

默认情况下,审计日志中包括应用服务方法、Razor页面处理程序(Razor Page handlers)和模型视图控制器(MVC)控制器操作(controller actions)。要禁用它们,可以在类或方法级别使用[DisableAuditing]属性。

以下示例在应用服务类上使用[DisableAuditing]属性:

[DisableAuditing] 
public class OrderAppService : ApplicationService, IOrderAppService 
{     
    public async Task CreateAsync(CreateOrderDto input){ }     
    public async Task DeleteAsync(Guid id){ } 
}

以上方法,ABP将排除服务OrderAppService所有方法的审计记录。如果只想禁用其中一种方法,比如CreateAsync,可以在方法级别使用它:

public class OrderAppService : ApplicationService, IOrderAppService 
{     
    [DisableAuditing]     
    public async Task CreateAsync(CreateOrderDto input) { }     
    public async Task DeleteAsync(Guid id) { } 
}

在这种情况下,CreateAsync方法的调用将不做审计,而对DeleteAsync方法的调用被写入审计日志对象中。使用以下代码可以实现相同的行为:

[DisableAuditing] 
public class OrderAppService : ApplicationService, IOrderAppService {     
    public async Task CreateAsync(CreateOrderDto input)     {     }     
    [Audited]     
    public async Task DeleteAsync(Guid id)     {     } 
}

其中,除DeleteAsync方法之外,所有方法的审计日志都被禁用,因为DeleteAsync方法声明了[Audited]属性。

[success] [Audited][DisableAuditing]属性可用于任何类,任何方法,以便在该类或方法上启用/禁用审计日志记录。

审计日志对象包含方法调用信息,它还包括已执行方法的所有参数。这对了解系统进行了哪些更改非常有用;但是,在某些情况下,可能需要排除输入的某些属性。比如,你不想将用户的信用卡信息包含在审计日志中。在这种情况下,可以对输入对象的属性使用[DisableAuditing]

public class CreateOrderDto 
{     
    public Guid CustomerId { get; set; }     
    public string DeliveryAddress { get; set; }     
    [DisableAuditing]     
    public string CreditCardNumber { get; set; }
}

本示例将DtoCreditCardNumber属性从审计日志中排除,ABP不会将它写入审计日志。

禁用方法审计日志不会影响实体历史记录。如果某个实体发生了更改,仍会记录更改。下面将介绍如何控制实体历史记录的审计日志。

2 实体历史记录

在启用实体历史记录部分,我们介绍了如何通过定义选择器为一个或多个实体启用实体历史记录。但是,如果要为单个实体启用记录,有一种更简单的替代方法:只需在实体类上方添加[Audited]属性:

[Audited] 
public class Order : AggregateRoot { }

在本例,我向订单实体添加了[Audited]属性,从而为该实体启用实体历史记录。

假设您已使用选择器为所有实体启用记录,但希望为特定实体禁用它们。在这种情况下,可以对该实体类使用[DisableAuditing]属性。
[DisableAuditing]属性也可以用于实体的属性,以将该属性从审计日志中排除,如下例所示:

[Audited] 
public class Order : AggregateRoot {     
    public Guid CustomerId { get; set; }     
    [DisableAuditing]     
    public string CreditCardNumber { get; set; } 
}

如上,ABP不会将CreditCardNumber值写入审计日志。

3 存储审计日志

ABP框架设计之时,对需要接触数据源的任何地方引入抽象,从而不用担心具体的存储问题,审计日志系统也不例外。它定义了IAuditingStore接口来抽象保存审计日志对象的位置。该接口只有一个方法:

Task SaveAsync(AuditLogInfo auditInfo);

您可以实现此接口,以便在需要的地方保存审计日志。如果您使用ABP的启动模板创建解决方案,审计日志被默认保存到主库。

以上介绍了控制和定制审计日志系统的不同方法。它是系统跟踪和记录更改的基础设施。下一节将介绍缓存系统,这是Web应用的另一个基础功能。

四、缓存数据

缓存是提高应用性能和可伸缩性的基础设施之一。ABP扩展了ASP.NET Core的分布式缓存系统,使其与ABP框架的其他功能兼容,例如多租户。

如果您运行应用的多个实例或拥有分布式系统(如微服务),则分布式缓存是必不可少的。它提供了不同应用之间数据的一致性,并允许共享缓存的值。分布式缓存通常是一个外部的独立应用程序,如RedisMemcached

建议使用分布式缓存系统,即使只有一个正在运行的实例。不要担心性能,因为分布式缓存的默认在内存中工作。这意味着它不是分布式的,除非您显式地配置一个真正的分布式缓存提供程序,比如Redis

ASP Core中的分布式缓存

本节主要介绍ABP的缓存功能,并不涵盖所有ASP.NET Core的分布式缓存系统。如果你想了解更多,您可以参考Microsoft的文档

在本节,我将展示如何使用IDistributedCache<T>接口、配置选项,以及错误处理和批处理操作。我们还将学习如何使用Redis作为分布式缓存提供程序。最后,我将讨论缓存过期。

使用IDistributedCache接口

ASP.NET Core定义了IDistributedCache接口,但它不是类型安全的。它支持的存储和读取类型是字节数组,而不是对象。而ABP的IDistributedCache<T>接口被设计为具有类型安全的泛型接口(T代表存储在缓存中的类型)。它在内部使用标准的IDistributedCache接口,与ASP.NET Core 100%兼容。ABP的IDistributedCache<T>接口有两个优点,如下所示:

  • 自动将对象序列化/反序列化为JSON,然后是字节数组byte。所以,您无需处理序列化和反序列化。
  • 它会自动为缓存的Key添加前缀,以允许对不同类型的缓存对象使用相同的Key

使用IDistributedCache<T>接口的第一步是:定义一个类来表示缓存中的项,如:

public class UserCacheItem {     
    public Guid Id { get; set; }     
    public string UserName { get; set; }     
    public string EmailAddress { get; set; } 
}

这是一个普通的C#类,唯一的限制是它应该是可序列化的,因为它在保存到缓存时被序列化为JSON,而从缓存读出时被反序列化(保持简单,不要添加无关的引用)。

定义了缓存类后,第二步:我们可以注入IDistributedCache<T>接口,如以下代码块所示:

public class MyUserService : ITransientDependency 
{     
    private readonly IDistributedCache<UserCacheItem > _userCache;     
    public MyUserService(IDistributedCache<UserCacheItem > userCache)     
    { 
        _userCache = userCache;     
    }
}

我注入IDistributedCache<UserCacheItem>服务来处理UserCacheItem对象的分布式缓存。下面展示如何获取缓存值,获取不到则查询数据库:

public async Task GetUserInfoAsync(Guid userId)
{     
    return await _userCache.GetOrAddAsync(userId.ToString(), async () => await GetUserFromDatabaseAsync(userId), () => new DistributedCacheEntryOptions         
    { 
        AbsoluteExpiration = DateTimeOffset.Now.AddHours(1) }); 
    }

我向GetOrAddAsync方法传递了三个参数:

  • 第一个参数是缓存键,它是一个字符串值。
  • 第二个参数是一个工厂方法GetUserFromDatabaseAsync,如果在缓存中找不到,则执行该方法查询数据库。
  • 最后一个参数是返回DistributedCacheEntryOptions对象的工厂方法。它是可选的,用于配置缓存项的过期时间。仅当GetOrAddAsync方法Add操作时,才会执行。

默认情况下,缓存键是string数据类型。另外,ABP还定义了另一个接口IDistributedCache<TCacheItem, TCacheKey>,允许您指定缓存Key,这样就不需要手动将Key转换为字符串类型。我们可以注入IDistributedCache<UserCacheItem,Guid>服务,并在本例的第一个参数中删除ToString()用法。

DistributedCacheEntryOptions选项用于控制缓存的生命周期:

  • AbsoluteExpiration:设置绝对过期时间,如上示例。
  • AbsoluteExpirationRelativeToNow:设置绝对过期时间的另一种方法。我们可以重写一下上面的选项为 AbsoluteExpirationRelativeToNow = TimeSpan.FromHours(1),结果是一样的。
  • SlidingExpiration:设置缓存滑动过期时间,这意味着,如果继续访问缓存项,过期时间将自动延长。

如果未传递过期时间参数,则使用默认值。或者使用AbpDistributedCacheOptions全局选项(后续介绍)。在此之前,让我们看看IDistributedCache<UserCacheItem>服务的其他方法,如下所示:

  • GetAsync 使用Key从缓存中读取数据;
  • SetAsync 将项目保存到缓存中(覆写);
  • RefreshAsync 重置滑动过期时间;
  • RemoveAsync 从缓存中删除项目。

关于同步缓存方法

所有方法都有同步版本,比如GetAsync方法的GET方法。但是,建议尽可能使用异步版本。
以上方法是ASP.NET Core的标准方法。ABP为每个方法添加了批处理的方法,例如GetManyAsync 之于GetAsync。如果有很多项要读或写,那么使用批处理方法可以显著提高性能。ABP 框架还定义了 GetOrAddAsync方法(见以上示例),用于安全地读取缓存值,并在方法调用中设置缓存值。

配置缓存选项

AbpDistributedCacheOptions是配置缓存的主要选项类。您可以在模块类的ConfigureServices方法中对其进行配置(在领域或应用层中配置),如下所示:

Configure(options => { 
    options.GlobalCacheEntryOptions.AbsoluteExpirationRelativeToNow = TimeSpan.FromHours(2); 
});

这里配置了GlobalCacheEntryOptions属性,将默认缓存过期时间配置为2小时。
AbpDistributedCacheOptions还有一些其他属性,如下所述:

  • KeyPrefix (string; default: null):添加到所有缓存键开头的前缀。当使用多个应用共享的分布式缓存时,此选项可用于隔离应用程序的缓存项。
  • hideErrors (bool; default: true):用于控制缓存服务方法上错误处理的默认值。

正如您在前面的示例中所看到的,可以通过将参数传递给IDistributedCache服务的方法来覆盖这些选项。

错误处理

当我们使用外部进程(如Redis)进行分布式缓存时,读写缓存时可能会出现问题,比如:缓存服务器可能掉线,或者出现网络问题。这些临时问题在大多数情况下都可以忽略,尤其是试图从缓存中读取数据时。如果缓存服务目前不可用,您可以尝试从原始数据源读取数据,它可能比较慢,但比总比抛出异常要好。

所有IDistributedCache<T>方法都会获得一个可选的hideErrors参数来控制异常处理。如果传递false,则抛出所有异常。如果传递true,则ABP将隐藏与缓存相关的错误。如果未指定值,将使用默认值。

在多租户中使用缓存

如果应用程序是多租户,ABP会自动将当前租户的ID添加到缓存Key中,以区分不同租户的缓存值。通过这种方式,它在租户之间提供了隔离。
如果要创建租户之间共享缓存,可以在缓存类上使用[IgnoreMultiTenancy]属性,如以下代码块所示:

[IgnoreMultiTenancy] 
public class MyCacheItem { /* ... */ }

在本例中,不同租户都可以访问MyCacheItem值。

使用Redis作为分布式缓存

Redis是一种流行的工具,用作分布式缓存。ASP.NET Core为Redis提供了一个缓存集成包。您可以参照Microsoft的文档
ABP也提供了一个Redis集成包,它扩展了Microsoft的集成,以支持批处理操作(例如GetManyAsync,在使用IDistributedCache<T>接口一节中提到)。因此,建议使用ABP的Volo.Abp.Caching.StackExchangeRedisNuGet包,或者使用命令行在对应的项目中安装:

abp add-package Volo.Abp.Caching.StackExchangeRedis

安装完成后,只需在``appsettings.json配置Redis`服务器的连接字符串和端口,如下所示:

"Redis": { "Configuration": "127.0.0.1" }

有关配置的详细信息,请参阅Microsoft文档

缓存失效

有一种流行的说法是,缓存失效是计算机科学中的两个难题之一(另一个是命名问题)。缓存值通常是数据的副本(需要频繁读取或者计算的值)。用于提高性能和可伸缩性,但当原始数据发生变更,缓存值过时时,问题就来了。我们应该仔细观察这些变化,对缓存中的值进行及时地删除或刷新,这就是所谓的缓存失效。

缓存失效在很大程度上取决于缓存的数据和应用程序逻辑。但在某些特定情况下,ABP可以帮助您使缓存的数据失效。

  • 一种特殊情况是,当实体发生更改(更新或删除)时,我们可能希望缓存数据失效。对于这种情况,我们可以注册ABP发布的事件。当相关用户实体发生更改时,以下代码将使用户缓存失效:
public class MyUserService : ILocalEventHandler<EntityChangedEventData<IdentityUser>>,  ITransientDependency 
{     
    private readonly IDistributedCache<UserCacheItem> _userCache;     
    private readonly IRepository<IdentityUser, Guid> _userRepository;     
    //...omitted other code parts     
    public async Task HandleEventAsync(EntityChangedEventData<IdentityUser> data)
    { 
        await _userCache.RemoveAsync(data.Entity.Id.ToString());     
    }
}

MyUserService注册了EntityChangedEventData<IdentityUser>本地事件。当创建一个新的IdentityUser实体或更新/删除现有IdentityUser实体时,会触发此事件。HandleEventAsync方法用于将用户从缓存中移除。

因为本地事件只在当前的过程中起作用,这意味着处理类(此处为MyUserService)应该与实体更改处于相同的进程中。

关于事件总线系统

本地和分布式事件是ABP框架的特性,本书中没有包括这些特性。如果您想了解更多信息,请参阅ABP文档

五、本地化用户界面

在做产品需求设计时,您可能希望本地化当前的UI。ASP.NET Core提供了一个本地化系统。ABP扩展的新功能和约定,使本地化更加容易和灵活。

本节介绍如何定义语言,为语言创建并读取文本。您将了解本地化资源的概念和嵌入式本地化资源文件。

我们从定义语言开始:

定义语言

本地化的第一个问题是:您希望在UI上支持哪些语言?ABP提供了一个定义语言的选项AbpLocalizationOptions,如以下代码块所示:

Configure(options => {
    options.Languages.Add(new LanguageInfo("en", "en", "English"));     
    options.Languages.Add(new LanguageInfo("tr", "tr", "Türkçe"));
    options.Languages.Add(new LanguageInfo("es", "es","Español")); 
});

以上代码写在模块类的ConfigureServices方法中。如果您是使用ABP启动模板创建的解决方案,该配置已经完成。您只需根据需要编辑即可。

LanguageInfo构造函数接受几个参数:

  • cultureName: 语言的区域性名称(代码),运行时会设置为CultureInfo.CurrentCulture
  • uiCultureName: 语言的UI区域性名称(代码),运行时会设置为CultureInfo.CurrentUICulture
  • displayName: 显示给用户的语言的名称。建议用原语写下这个名字;
  • flagIcon: 显示语言所属的国旗的字符串值。

ABP根据当前HTTP请求确定选中的语言。

选中语言

ABP使用AbpRequestLocalizationMiddleware确定当前语言。这是一个添加到ASP.NET Core请求管道的中间件:

app.UseAbpRequestLocalization();

当请求通过此中间件时,将确定一种语言并将其设置为CultureInfo.CurrentCultureCultureInfo.CurrentUICulture。这些是NET的标准做法。

当前语言是根据HTTP请求参数,按给定优先级顺序确定的:
1.如果设置了culture查询字符串参数,则由该参数确定当前语言。例如http://localhost:5000/?culture=en-US

2.如果设置了.AspNetCore.Culture的cookie值,将由该值确定当前语言。

3.如果设置了Accept-Language HTTP头,将由该头确定当前语言。
默认情况下,浏览器通常会发送最后一个。

关于ASP.NET Core的本地化系统

以上介绍的行为是默认行为,然而ASP.NET Core的语言确定更灵活,更可定制。有关更多信息,请参阅Microsoft文档

在定义了要支持的语言之后,接下来就是定义本地化资源。

定义本地化资源

ABP与ASP.NET Core的本地化系统完全兼容。所以,你可以使用.resx文件作为本地化资源(参看Microsoft文档),然而,ABP提供了一种轻量级、灵活且可扩展的方法:使用简单的JSON文件定义本地化文本。

当我们使用ABP启动模板创建解决方案时,Domain.Shared项目已经包含了本地化资源和本地化JSON文件:

在本例,DemoAppResource表示本地化资源。一个应用程序可以有多个本地化资源,每个资源定义自己的JSON文件。您可以将本地化资源视为一组本地化文本。它有助于构建模块化系统,每个模块都有自己的本地化资源。

本地化资源类是空类,如下代码所示:

[LocalizationResourceName("DemoApp")] 
public class DemoAppResource { }

当你想使用本地化资源中的文本时,这个类指定相关的资源。LocalizationResourceName属性为资源设置字符串名称。每个本地化资源都有一个唯一的名称,在客户端也可以引用该资源进行本地化。

默认本地化资源

在创建ABP解决方案时,通常有一个(默认)本地化资源,默认本地化资源类的名称以项目名称开头,例如ProductManagementResource

一旦我们有了本地化资源,我们就可以为我们支持的每种语言创建一个JSON文件。

使用本地化JSON文件

本地化文件是一个简单的JSON格式的文件,如以下代码块所示

{
    "culture":"en",
    "texts": {
        "Home":"Home",
        "WelcomeMessage":"Welcome to the application."
    } 
}

该文件中有两个根元素,如下所述:

  • culture:语言文化代码。它与定义语言部分中引入的区域性代码相匹配。
  • texts:包含本地化文本的键值对。键用于访问本地化文本,值是当前区域性(语言)的本地化文本。

定义完语言的本地化文本之后,我们在运行时请求本地化文本。

读取本地化文本

ASP.NET Core定义了一个IStringLocalizer<T>接口,以获取当前文化中的本地化文本,其中T代表本地化资源类。您可以将该接口注入到类中,如以下代码块所示:

public class LocalizationDemoService : ITransientDependency {     
    private readonly IStringLocalizer<DemoAppResource> _localizer;     
    public LocalizationDemoService(IStringLocalizer<DemoAppResource> localizer)     
    {
        _localizer = localizer; 
    }     
    public string GetWelcomeMessage()    
    {         
        return _localizer["WelcomeMessage"];     
    } 
}

其中LocalizationDemoService注入IStringLocalizer<DemoAppResource>服务,用于访问DemoAppResource的本地化文本。在GetWelcomeMessage方法中,我们获取WelcomeMessage键的本地化文本。如果当前语言为英语,则返回Welcome to the application,正如上一节的JSON文件中定义的那样。

我们可以在本地化文本时传递参数。

参数化文本

本地化文本可以包含参数,如下例所示:

"WelcomeMessageWithName": "Welcome {0} to the application."

参数可以传递到定位器,如以下代码块所示:

public string GetWelcomeMessage(string name)
{
    return _localizer["WelcomeMessageWithName", name]; 
}

给定名称将替换{0}占位符

回退逻辑

当在当前区域性的JSON文件中找不到请求的文本时,本地化系统会回退到父区域性或默认区域性的文本。

例如,假设您请求获取WelcomeMessage文本,而当前区域性(CultureInfo.CurrentUICulture)为de-DE(德语),会出现以下的其中一种情况:

  • 如果在JSON文件中没有定义"culture": "de-DE",或者JSON文件中不包含WelcomeMessage键,那么它会返回到父区域性("de"),尝试在该区域性中查找给定的键。
  • 如果在父区域性中找不到它,它将返回到本地化资源的默认区域性。
  • 如果在默认区域性中找不到,则返回给定的键(例如WelcomeMessage)作为响应。

配置本地化资源

在使用本地化资源之前,应将其添加到AbpLocalizationOptions中。此配置已在启动模板中完成,代码如下:

Configure<AbpVirtualFileSystemOptions>(options => {
    options.FileSets.AddEmbedded<DemoAppDomainSharedModule>();      
}); 
Configure<AbpLocalizationOptions>(options => {
    options.Resources.Add<DemoAppResource>("en").AddBaseTypes(typeof(AbpValidationResource)).AddVirtualJson("Localization/DemoApp");
    options.DefaultResourceType = typeof(DemoAppResource); 
});

本地化JSON文件通常被定义为嵌入式资源。我们使用AbpVirtualFileSystemOptions配置ABP的虚拟文件系统,以便将该程序集中的所有嵌入文件添加到虚拟文件系统中(当然也包括本地化文件)。

然后,我们将DemoAppResource添加到Resources字典中,以便ABP识别它。这里,"en"参数设置本地化资源的默认区域。

ABP的本地化系统相当高级,它允许您通过继承本地化资源来重用本地化资源的文本。在本例中,我们继承了AbpValidationResource,它在ABP框架中定义(包含标准的验证错误消息)。
AddVirtualJson方法设置与该资源相关的JSON文件(使用虚拟文件系统)。

最后,DefaultResourceType设置默认本地化资源。在某些不指定本地化资源的地方,可以使用默认资源。

在特殊服务中本地化

在重复性注入IStringLocalizer<T>都会很乏味。ABP将它预注入到一些特殊的基类中。从这些类继承后,可以直接使用L快捷方式。

以下示例显示了如何在应用服务方法中使用本地化文本:

public class MyAppService : ApplicationService {
    public async Task FooAsync()
    {         
        var str = L["WelcomeMessage"];     
    } 
}

在本例中,L属性由ApplicationService基类定义,因此不需要手动注入IStringLocalizer<T>服务。你可能会想,因为我们还没有指定本地化资源,这里使用的是哪一个?答案是DefaultResourceType选项,这在上面已经解释过。

如果要为特定应用服务指定另一个本地化资源,请在服务的构造函数中设置LocalizationResource属性:

public class MyAppService : ApplicationService 
{     
    public MyAppService()
    {         
       LocalizationResource = typeof(AnotherResource);     
    } 
    //... 
}

除了ApplicationService之外,其他一些常见的基类,如AbpControllerAbpPageModel,提供了与注入IStringLocalizer<T>服务相同的L快捷属性。

在客户端使用本地化

ABP的所有本地化资源都可以直接在客户端上使用。
例如,在ASP.NET的MVC/Razor Pages中,通过JavaScript的WelcomeMessage键本地化:

var str = abp.localization.localize('WelcomeMessage', 'DemoApp');

DemoApp是本地化资源名称,WelcomeMessage是此处的本地化键(第4部分的“用户界面和API开发”将详细介绍客户端本地化)。

总结

在本章中,我们了解了几乎所有Web应用都需要的一些基本功能。

ICurrentUser服务用于读取应用的当前用户的信息。您可以使用标准claims(例如usernameID),并根据需要定义自定义声明。

我们探索了数据过滤系统,在从数据库查询时自动过滤数据。通过这种方式,我们可以很容易地实现一些软删除和多租户。我们还学习了如何定义自定义数据过滤器,并在必要时禁用过滤器。

我们还了解了审计日志系统如何跟踪和保存用户的所有操作。我们可以通过属性和选项控制审计日志系统。

缓存数据是提高系统性能和可伸缩性的另一个基本概念。我们已经了解了ABP的IDistributedCache<T>服务,它提供了一种类型安全的方式,并自动执行序列化和异常处理。

最后,我们探讨了ASP.NET Core 和ABP的本地化基础设施。

posted @ 2024-02-02 11:45  张飞洪[厦门]  阅读(2383)  评论(9编辑  收藏  举报