2024年2月

应用--Program中的WebApplication

在6.0,微软团队对于NetCore做了很大的改变,其中有一个改变就是推出了新的托管模型--最小托管模型,使用该模型可以创建最小的web应用。(最小webapi请查看
官网

需要掌握:







构造流程

得到
WebApplicationBuilder构造器
-> 配置服务 ->
build()
方法得到
WebApplication对象
-> 配置中间件 -> 运行主机

// 得到应用构造器:WebApplicationBuilder
var builder = WebApplication.CreateBuilder(args);
// 配置日志
builder.Logging.AddLog4Net("ConfigFile/log4net.config");
// 得到应用:WebApplication
var app = builder.Build();
// 配置中间件
app.UseStaticFiles();
// 运行主机
app.Run();

你可能在疑惑,在3.0至5.0的版本都是直接调用
Host.CreateDefaultBuilder()
方法得到
HostBuilder构造器
,然后调用
ConfigureWebHostBuilder()
配置
WebHost
,然后在上面配置一些服务,构建然后运行。而6.0使用
WebApplication.CreateBuilder(args)
方法得到的是一个
WebApplicationBuilder
构造器,然后构建运行,他们有什么区别吗?

答:没什么区别,流程都是一样的,
WebApplication
对主机和服务做了一个更进一步的封装,使得更加方便配置和学习,而且额外暴露2个
Host属性

WebHost属性
用来配置(这2个属性也是方便之前的版本迁移到6.0的关键)。举个很简单的例子

区别 在3.0至5.0的版本中 6.0版本中
中间管道的配置 必须放在Startup.cs类中的Configure方法中,或者通过ConfigureWebHostDefaults中的webBuilder 来配置服务 通过调用app.UseXXXX来配置
路由中间件 使用app.UseRouting()之后才能app.UseEndpoints() 因为WebApplication继承了WebIEndpointRouteBuilder可以直接将路由,而无需显式调用
UseEndpoints

UseRouting

按照上方Program.cs流程顺序介绍相关类和方法

1. CreateBuilder(args) 方法:

使用默认值来生成构造一个
WebApplicationBuilder对象

该方法有3个重载:

  • WebApplication.CreateBuilder()
    :使用预配置的默认值来构造;
  • WebApplication.CreateBuilder(String [])
    :根据传入的命令行参数初始化;
  • WebApplication.CreateBuilder(WebApplicationOption)
    :根据传入的预配置来构造;
public class WebApplicationOptions
{
    public WebApplicationOptions(); 
    
    // 命令行参数
    public string[]? Args { get; init; }

    // 环境名称。
    public string? EnvironmentName { get; init; }
    
    // 应用程序名称。
    public string? ApplicationName { get; init; }
    
    // 内容根路径。
    public string? ContentRootPath { get; init; }

    // Web 根路径。
    public string? WebRootPath { get; init; }
}

WebApplicationBuilder类:

要创建一个
WebApplication对象
,需要一个
IHost对象

IHost对象
是通过
IHostBuilder创建
的,而
WebApplication
需要
WebApplicationBuilder
来构建,所以
WebApplicationBuilder
还需要一个IHostBuilder对象,我们针对
WebApplication
的一切配置,最终都会转移到这个对象上面才能生效,所以这就是为什么
WebApplicationBuilder
提供了这6个属性的原因。

构造函数

当通过调用
WebApplication.CreateBuilder()
方法的时候,根据命令行的参数传给
WebApplicationBuilder
的构造函数,而
WebApplicationBuilder
的构造函数内部会做:

  1. 创建
    HostBuilder _hostBuilder
    类。
  2. 创建
    BootstrapHostBuilder
    对象,调用拓展方法
    ConfigureWebHostBuilder()

    ConfigureDefaults()
    方法,将初始化的设置和服务收集起来,然后把收集到的服务和配置注入到
    Services成员属性

    Configure成员属性
    中。
  3. 然后会创建承载托管环境的
    IWebHostEnvironment
    ,对于
    Environment成员属性
    初始化。
  4. 调用
    Apply()
    方法得到
    HostBuilderContext上下文
  5. 使用
    HostBuilderContext

    WebHostBuilderContext
    ,创建
    ConfigureWebHostBuilde
    r和
    ConfigureHostBuilder
    并赋值给
    WebHost和Host属性
    。初始化
    Logging属性
  6. 得到一个
    Configure、Environment、WebHost、Host、Logging属性
    都被初始化的
    WebApplication对象

WebApplicationBuilder构造函数源码

public class WebApplicationBuilder
{
    private readonly HostBuilder _hostBuilder = new HostBuilder();
    private WebApplication _application;
    // 提供应用程序正在运行的Web托管环境的信息
    public IWebHostEnvironment Environment { get; }

    // 提供应用程序所需要的服务,即依赖注入容器
    public IServiceCollection Services { get; }

    // 提供应用程序所需要的配置
    public ConfigurationManager Configuration { get; }

    // 提供日志记录
    public ILoggingBuilder Logging { get; }

    // 配置WebHost服务器特定属性,实现IWebHostBuilder
    public ConfigureWebHostBuilder WebHost { get; }

    // 配置Host特定属性,实现IHostBuilder
    public ConfigureHostBuilder Host { get; }

    public WebApplicationBuilder(WebApplicationOptions options)
    {
        //创建BootstrapHostBuilder并利用它收集初始化过程中设置的配置、服务和针对依赖注入容器的设置
        var args = options.Args;
        var bootstrap = new BootstrapHostBuilder();
        bootstrap
            .ConfigureDefaults(null)
            // 此处用于中间件的注册
            .ConfigureWebHostDefaults(webHostBuilder => webHostBuilder.Configure(app              =>app.Run(_application.BuildRequestDelegate())))   
            .ConfigureHostConfiguration(config => {
                // 添加命令行配置源
                if (args?.Any() == true)
                {
                    config.AddCommandLine(args);
                }

                // 将WebApplicationOptions配置选项转移到配置中
                Dictionary<string, string>? settings = null;
                if (options.EnvironmentName is not null) {
                    (settings ??= new())[HostDefaults.EnvironmentKey] =  options.EnvironmentName;
                }
                if (options.ApplicationName is not null){
                    (settings ??= new())[HostDefaults.ApplicationKey] = options.ApplicationName;
                }
                if (options.ContentRootPath is not null){
                    (settings ??= new())[HostDefaults.ContentRootKey] = options.ContentRootPath;
                }
                if (options.WebRootPath is not null) {
                    (settings ??= new())[WebHostDefaults.WebRootKey] = options.EnvironmentName;
                }
                if (settings != null)
                {
                    config.AddInMemoryCollection(settings);
                }
            });

        // 将BootstrapHostBuilder收集到配置和服务转移到Configuration和Services上
        // 将应用到BootstrapHostBuilder上针对依赖注入容器的设置转移到_hostBuilder上
        // 得到BuilderContext上下文
        bootstrap.Apply(_hostBuilder, Configuration, Services, out var builderContext);

        // 如果提供了命令行参数,在Configuration上添加对应配置源
        if (options.Args?.Any() == true)
        {
            Configuration.AddCommandLine(options.Args);
        }
        // 构建WebHostBuilderContext上下文
        // 初始化Host、WebHost和Logging属性
        var webHostContext = (WebHostBuilderContext)builderContext.Properties[typeof(WebHostBuilderContext)];
        Environment = webHostContext.HostingEnvironment;
        Host = new ConfigureHostBuilder(builderContext, Configuration, Services);
        WebHost = new ConfigureWebHostBuilder(webHostContext, Configuration, Services);
        Logging = new LogginigBuilder(Services);
    }
   
}

简单来说WebApplicationBuilder的作用就是为了提供构建封装的一个HostBuilder对象

注意:记住这6个属性,NetCore的生态库基本上就是围绕这几个来构建的

接下来我们按照这几个属性来逐一分析其作用

IWebHostEnvironment Environment 属性

接口,继承
IHostEnvironment
,提供一些该应用程序的环境信息,比如根目录、环境变量、名称等,包含几个属性

  • WebRootPath
    ,用于设置和获取Web的根目录,默认是wwwroot的子文件夹(在5.0的时候。是通过Host.CreateDefaultBuilder()方法去设置的);
  • ApplicationName:
    应用名称;
  • ContentRootFileProvider:;
  • ContentRootPath:
  • EnvironmentName:
    环境名称;

比如我们经常用的判断是否是开发环境就是用的该类

demo

var builder = WebApplication.CreateBuilder(args); 
bool isDevelopment = builder.Environment.IsDevelopment();
IServiceCollection Services 属性

依赖注入容器
,可以注入服务,也可也获取服务实例,继承于
ICollection<ServiceDescriptor>泛型接口
,这个我们后续会在依赖注入章节详细描述。

通过往该属性添加系统服务支持,或者注入自己的服务

demo

var builder = WebApplication.CreateBuilder(args); 
// 注入Sql数据库支持
builder.Services.AddDbContext<SqlDbContext>();
// 依赖注入
builder.Services.AddSingleton<PersonService>();
ConfigurationManager Configuration 属性

配置管理,
是6.0版本新增的类,更见简单的用于获取和设置系统配置文件,替换掉了5.0版本中
IConfigurationBuilder接口

IConfigurationRoot接口
(6.0是把这2个接口整合在一起了,看源码可以发现
ConfigureManager
继承于这2个接口)

ConfigurationManager密封类源码

public sealed class ConfigurationManager : IConfigurationBuilder, IConfigurationRoot, IConfiguration, IDisposable
{
    public ConfigurationManager();
    public string this[string key] { get; set; }
    public void Dispose();
    public IEnumerable<IConfigurationSection> GetChildren();
    public IConfigurationSection GetSection(string key);
}

public interface IConfigurationBuilder
{ 
  IDictionary<string, object> Properties { get; }  
  IList<IConfigurationSource> Sources { get; }
  IConfigurationBuilder Add(IConfigurationSource source);
  IConfigurationRoot Build();
}

增加系统配置文件

当你调用
builder.Configuration.AddJsonFile("文件名称")拓展方法
的来增加自定义配置文件的时候(实际上是往调用的
IConfigurationBuilder.Add()
方法往
IList<IConfigurationSource> Sources { get; }属性
增加了一条数据),会将立即加载提供程序并更新配置,这样可以不用等到
Build()
方法,可以避免在部分生成方法多次加载配置源数据。

demo

var builder = WebApplication.CreateBuilder(args); 
builder.Configuration.AddJsonFile("servicesetting.json");

获取系统配置数据

demo

var builder = WebApplication.CreateBuilder(args); 
ConfigurationManager config = builder.Configuration;
string value1 = config["DBContextModel:SqlConnection"];
IConfigurationSection value2 = config.GetSection("DBContextModel");
ILoggingBuilder Logging

提供日志记录,包括控制台、调试、事件日志、TraceSource等组件,你是不是在疑惑为什么创建项目的时候appsetting.json配置文件有一个这样的配置?他就和日志记录息息相关

appstting.json文件节点

 "Logging": {
    "LogLevel": {
      "Default": "Information",
      "Microsoft": "Warning",
      "Microsoft.Hosting.Lifetime": "Information"
    }
  },

具体在日志章节描述

ConfigureWebHostBuilder WebHost 属性

继承于
IWebHostBuilder接口
,目的是复用接口,继承于
ISupportsStartUp接口
,由于6.0使用的是最小托管模型,所以传统的使用
Startup.cs文件
来配置服务和注册中间件已经不支持了,继承此接口的是原因是:

微软原话“但是我们希望用户在采用这种编程方式时得到显式的提醒,所以依然让它实现该接口,并在实现的方法中抛出NotImplementedException类型的异常。”

WebApplication
构造函数中,通过传进来的一些构造参数初始化一个实例赋值给该属性;

构造函数,通过
WebHostContext,Services成员属性、Configure成员属性
来初始化

ConfigureWebHostBuilder类源码

public class ConfigureWebHostBuilder : IWebHostBuilder, ISupportsStartup
{
    private readonly WebHostBuilderContext _builderContext;
    private readonly IServiceCollection _services;
    private readonly ConfigurationManager _configuration;

    public ConfigureWebHostBuilder(WebHostBuilderContext builderContext, ConfigurationManager configuration, IServiceCollection services)
    {
        _builderContext = builderContext;
        _services = services;
        _configuration = configuration;
    }
}
ConfigureHostBuilder Host 属性

继承于
IHostBuilder接口
,目的是复用接口,他更多的用来配置主机服务

WebApplication构造函数
中,通过
bootStrapBuilder
收集到的服务,传进来的一些构造参数初始化一个实例赋值给他;

构造函数,通过
HostBuilderContext ,Services成员属性、Configure成员属性
来初始化

  • Services成员属性会直接赋值ConfigureHostBuilder的_services属性

  • Configure成员属性相关Host的配置会被存放在ConfigureHostBuilder内部类的一个_configureActions字段暂时存起来

ConfigureHostBuilder类源码

public class ConfigureHostBuilder : IHostBuilder
{
    private readonly ConfigurationManager _configuration;
    private readonly IServiceCollection _services;
    private readonly HostBuilderContext _context;
    private readonly List<Action<IHostBuilder>> _configureActions = new();

    internal ConfigureHostBuilder(HostBuilderContext context, ConfigurationManager configuration, IServiceCollection services)
    {
        _configuration = configuration;
        _services = services;
        _context = context;
    }
}

BootstrapHostBuilder类:

BootstrapHostBuilder
继承于
IHostBuilder
,目的是为了构建和初始化
IHostBuilder
对象

这个它的作用是收集初始化
IHostBuilder
对象提供的设置并将它们分别应用到指定的
IServiceCollection、ConfigurationManager和IHostBuilder对象
上,在构造函数中会调用他的
Apply()
方法。

2. Build()方法:

简单来说就是将对于
WebApplicationBuilder
的一切配置转移到
IHostBuilder对象
上,然后得到一个
WebApplication对象

注意!!!!!

WebApplication
一旦创建,环境变量、配置都不允许再次改变(虽然我们也用不着,但是知道就好)

这个方法作用:


WebApplication

Configure成员属性

Services成员属性
转移到
HostBuil
der上面

WebApplication.Build()方法源码

 // 获取WebApplication对象,用于配置 HTTP 管道和路由的 Web 应用程序
    public WebApplication Build()
    {
        // 在此处连接主机配置。我们不会尝试保留配置,在此处获取本身,因为我们不支持在创建构建器后更改主机值。
         _hostBuilder.ConfigureHostConfiguration(builder =>
            {
                builder.AddInMemoryCollection(_hostConfigurationValues);
          });
        
        // 将ConfigurationManager的配置转移到_hostBuilder
        _hostBuilder.ConfigureAppConfiguration(builder =>
        {
            builder.AddConfiguration(Configuration);
            foreach (var kv in ((IConfigurationBuilder)Configuration).Properties)
            {
                builder.Properties[kv.Key] = kv.Value;
            }
        });

         var chainedConfigSource = new TrackingChainedConfigurationSource(Configuration);
        
        _hostBuilder.ConfigureServices((context, services) =>
            {
                // 简单来说就是把WeApplicationBuilder中的IServiceCollection属性添加到泛型主机中
                foreach (var s in _services)
                {                
                    services.Add(s);
                }

                // 把服务列表只能关于主机的服务添加到主机中
                // 确保添加的任何托管服务在初始托管服务集之后运行。也就是托管服务在web主机启动前运行
                foreach (var s in _services.HostedServices)
                {
                    services.Add(s);
                }

                // 清除主机托管服务列表
                _services.HostedServices.Clear();

                // 将任何服务添加到用户可见的服务集合中,
                _services.InnerCollection = services;

               // 保留主机中的配置
                var beforeChainedConfig = true;
                var hostBuilderProviders = ((IConfigurationRoot)context.Configuration).Providers;

                if (!hostBuilderProviders.Contains(chainedConfigSource.BuiltProvider))
                {                   
                    ((IConfigurationBuilder)Configuration).Sources.Clear();
                    beforeChainedConfig = false;
                }
                // 使配置管理器与最终_hostBuilder的配置匹配。
                foreach (var provider in hostBuilderProviders)
                {
                    if (ReferenceEquals(provider, chainedConfigSource.BuiltProvider))
                    {
                        beforeChainedConfig = false;
                    }
                    else
                    {
                        IConfigurationBuilder configBuilder = beforeChainedConfig ? _hostConfigurationManager : Configuration;
                        configBuilder.Add(new ConfigurationProviderSource(provider));
                    }
                }
            });

        // 在最终主机构建器上运行其他回调
        Host.RunDeferredCallbacks(_hostBuilder);

        // 构建应用
        _builtApplication = new WebApplication(_hostBuilder.Build());
        
        // 将服务集合标记为只读以防止将来修改
         _services.IsReadOnly = true;

        // 解析_hostBuilder的配置和构建器。 
        _ = _builtApplication.Services.GetService<IEnumerable<IConfiguration>>();
        return _builtApplication;
    }

WebApplication类:

应用类

继承4个接口

  • IHost接口
    :所以这就是上文当中说到为什么
    WebApplicationBuilder
    需要一个
    IConfigureHostBuilder
    属性的原因;
  • IApplicationBuilder
    :提供配置应用程序请求管道机制的类,所以我们的中间件可以直接注册到
    WebApplication
  • IEndpointRouteBuilder
    :定义应用程序中路由生成器的协定。 路由生成器指定应用程序的路由。所以我们无需显示调用
    UseEndpoint、UseRouting
    这2个中间件,这个在6.0的更新中也提到了;
  • IAsyncDisposable
    :提供异步释放的接口;

6个重要属性

  • IServiceProvider
    :应用程序的已配置服务。提供在程序运行期间解析的服务类型,简称依赖注入容器;
  • IConfiguration
    :应用程序的已配置,可以获取已经配置好的配置源;
  • IWebHostEnvironment
    : 托管环境信息;
  • IHostApplicationLifetime
    :允许通知使用者应用程序生存期事件;
  • ILogger
    :日志服务;
  • ICollection<string>
    :HTTP 服务器绑定到的 URL 列表。(IServerAddressesFeature:启动地址);

WebApplication类源码

public sealed class WebApplication : IHost, IApplicationBuilder, IEndpointRouteBuilder, IAsyncDisposable
{
   public IServiceProvider Services => _host.Services;   
   public IConfiguration Configuration => _host.Services.GetRequiredService<IConfiguration>();
   public IWebHostEnvironment Environment => _host.Services.GetRequiredService<IWebHostEnvironment>();
   public IHostApplicationLifetime Lifetime => _host.Services.GetRequiredService<IHostApplicationLifetime>();
   public ILogger Logger { get; } 
   public ICollection<string> Urls => ServerFeatures.Get<IServerAddressesFeature>()?.Addresses ??
            throw new InvalidOperationException($"{nameof(IServerAddressesFeature)} could not be found.");

}

拓展方法

就是各种系统定义好的中间件服务。

3. Run()方法 :

WebApplication.BuildRequestDelegate()方法

前面调用
ConfigureWebHostDefaults()
扩展方法提供的委托会将使用
BuildRequestDelegate()
方法注册的中间件管道,作为请求处理器,至此一个WebApplication对象完成。

## 4. 总结 :
首先,最小托管模型是6.0微软推出来的一个新的应用模板,为的是方便配置和学习,他只有3句代码,利用他可以生成最小webapi。
第一句是var bulider = WebAppliaction.CreateBuilder();
这句代码的作用是通过调用WebAppliaction的工厂方法CreateBuilder()得到WebApplicationBuilder对象,因为创建一个WebApplication对象需要一个Host,Host则必须由HostBuilder创建,所以WebApplicationBuilder对象的作用是提供一个封装好的HostBuilder对象用来构建IHost,
它含有6个属性以及一个构造函数,属性包括IServiceCollection依赖注入容器Services、ConfigureManage配置管理Configure、IWebHostEnvironment托管环境environment、ILoggingBuilder日志记录logging、ConfigureWebHostBuilder类型webhost、ConfigureHostBuilder类型host,需要这6个属性的目的就是用来提供HostBuilder的创建
构造函数的作用是根据传进来的命令行参数来初始化这些属性,首先他会初始化一个_hostBuilder对象,然后创建一个bootstrapBuilder对象用来调用他的拓展方法收集服务和配置,赋值给services和configure属性,接下里根据bootstrapBuilder对象的一些属性,初始化剩余属性,初始化WebHost和Host。

第二句代码是var app = bulider.bulider();
在这句代码之前,我们可以注入自己的一些服务和系统服务,通过调用AddScope()等依赖注入方法或者使用系统提供的服务方法、如AddController(),
这句代码的作用是,根据得到WebApplicationBuilder对象来创建WebApplication,这句代码最重要的就是,把services和configure属性赋值给HostBuilder,然后我们可以看到这个对象继承了4个接口,一个是IHost接口,这就解释了为什么WebApplicationBuilder需要有一个ConfigurHostBuilder对象,还有一个IApplicationBuilder接口,这个接口是构建中间件管道服务的接口,所以我们的中间件可以直接注册在WebApplication的原因,IEndpointRouteBuilder则是默认构造了路由,还有一个异步释放的接口。

第三句代码是app.Run();
在这句代码之前可以注入中间件服务,比如UseAuthorization()之类的,
这句代码的作用是,通过调用WebApplication内部的BuildRequestDelegation()方法把注册的中间件管道作为请求处理器,至此一个WebApplication对象完成
所以根据这几行代码我们不难看出,WebApplication其实就是对Host的再次封装,只是为了我们更加简单的去配置一些我们需要的服务和中间件


引言


大家好,我是你们的老伙计秀才!今天带来的是[深入浅出Java多线程]系列的第六篇内容:Java内存模型。大家觉得有用请点赞,喜欢请关注!秀才在此谢过大家了!!!

在并发编程中,有两个关键问题至关重要,它们是线程间通信机制和线程间同步控制。


线程间通信机制

线程间通信是指在一个多线程程序中,不同线程之间如何有效地交换信息。在Java内存模型(JMM)采用的共享内存并发模型中,线程间的通信主要是通过共享变量来实现的。每个线程可以读取或修改这些存储在堆内存中的共享变量,从而传递状态或数据给其他线程。例如:

class SharedData {
    public volatile int sharedValue;
}

public class ThreadCommunication {
    public static void main(String[] args) {
        SharedData data = new SharedData();
        Thread threadA = new Thread(() -> {
            data.sharedValue = 10// 线程A更新共享变量
        });

        Thread threadB = new Thread(() -> {
            while (data.sharedValue == 0) {} // 线程B等待共享变量被更新
            System.out.println("Thread B sees updated value: " + data.sharedValue);
        });

        threadA.start();
        threadB.start();

        try {
            threadA.join(); // 确保线程A完成更新
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    }
}

在这个案例中,
sharedValue
是一个共享变量,线程A对其进行了修改,而线程B则依赖于该变量的值进行后续操作。为了确保线程间通信的正确性,这里使用了
volatile
关键字来保证变量的可见性和禁止指令重排序。


线程间同步控制

线程间同步则是指控制不同线程间操作发生的相对顺序,以避免数据不一致和竞态条件等问题。在Java中,同步控制主要通过以下方式实现:

  1. synchronized关键字 :它可以修饰方法或代码块,确保同一时间只有一个线程能访问被保护的资源。如下所示:
class Counter {
    private int count = 0;

    public synchronized void increment() {
        count++;
    }

    public synchronized int getCount() {
        return count;
    }
}

在这个例子中,
increment()

getCount()
方法都被
synchronized
修饰,这样在同一时刻只能有一个线程执行这两个方法之一,防止了并发环境下计数器值的错误累加。

  1. Lock接口及其实现类 :除了内置的 synchronized 机制,Java还提供了更灵活的Lock接口如ReentrantLock,它允许更多的同步语义,比如尝试获取锁、可中断获取锁以及公平锁等。
  2. volatile关键字 :虽然主要用于提供内存可见性,但其也能间接起到一定的同步作用,即当一个线程修改了volatile变量时,其他线程能够立即看到这个新值。

综上所述,在Java并发编程中,线程间通信与同步控制相辅相成,共同构建了一个安全高效的并发环境。通过合理地利用Java内存模型提供的机制,开发者可以确保在多线程环境中,各个线程之间的数据交换有序且可靠。


并发模型对比


在并发编程领域,有两种主要的并发模型:消息传递并发模型和共享内存并发模型。Java多线程编程采用了共享内存并发模型,这一选择对理解Java内存模型(JMM)至关重要。

Untitled


消息传递并发模型

消息传递模型中,线程之间的通信和同步是通过发送和接收消息来实现的。每个线程拥有独立的本地状态,并通过将数据封装在消息中发送给其他线程来交换信息。在这种模型下,线程之间不直接共享数据,因此不存在竞争条件或同步问题。Erlang等语言中的Actor模型就是一个典型的消息传递并发模型实例。

-module(my_actor).
-export([start_link/0, ping/0]).

start_link() ->
    register(actor_name, spawn(fun() -> loop([]) end)).

ping() ->
    actor_name ! {self(), ping}.

loop(Msgs) ->
    receive
        {From, ping} ->
            From ! pong,
            loop(Msgs);
        _Other ->
            loop([Msg | Msgs])
    end.

在此Erlang示例中,
actor_name
是一个进程(即线程),它通过接收并响应消息来进行工作,而不是直接读写共享变量。


共享内存并发模型

而在Java中采用的共享内存模型,则允许线程访问相同的内存区域——堆区,其中包含的共享变量可以被多个线程同时读写。这种模型下,线程间通信是通过对共享变量进行读写操作间接完成的。然而,由于共享数据,这就带来了潜在的数据一致性问题,如竞态条件、死锁以及可见性问题。为了保证线程间的正确交互,Java内存模型定义了一套规则和机制。

public class SharedCounter {
    private volatile int count = 0;

    public void increment() {
        count++;
    }

    public int getCount() {
        return count;
    }
}

public class Main {
    public static void main(String[] args) throws InterruptedException {
        SharedCounter counter = new SharedCounter();
        Thread threadA = new Thread(() -> {
            for (int i = 0; i < 100000; i++) {
                counter.increment();
            }
        });

        Thread threadB = new Thread(() -> {
            while (true) {
                System.out.println(counter.getCount());
            }
        });

        threadA.start();
        threadB.start();

        threadA.join();
    }
}

在这个Java示例中,两个线程同时访问
SharedCounter
类的共享变量
count
,为了确保线程安全和可见性,我们使用了
volatile
关键字修饰该变量。Java内存模型通过主内存与各线程私有本地内存间的抽象关系以及内存屏障技术,保障了线程间共享变量的更新能够及时传播到所有线程。

Java选择共享内存并发模型的原因在于其简洁性和高效性,尤其是对于基于对象和引用透明性的程序设计而言。尽管存在潜在的并发问题,但通过提供诸如
synchronized

volatile
以及更高层次的并发工具如
java.util.concurrent
包下的各种锁机制和原子类等,Java提供了丰富的工具来管理和控制共享内存环境下的并发行为,使得开发者能够编写出高效的并发代码。


Java内存模型抽象结构解析



运行时数据区划分

Untitled

在Java虚拟机(JVM)的运行时环境中,内存被划分为多个区域以支持程序的执行。其中,线程私有的内存区域包括程序计数器、虚拟机栈以及本地方法栈,而堆和方法区则是所有线程共享的内存区域。

  • :每个线程都有自己的栈空间,用于存储局部变量、方法调用时的上下文信息(如返回地址、临时变量等)。由于栈是线程私有的,因此线程间不涉及共享和可见性问题。
public class StackExample {
    public void localVariableVisibility() {
        int localVar = 10// 局部变量存储在线程栈中,对其他线程不可见
    }
}

  • :堆内存是所有线程共享的区域,主要存储对象实例及数组。当创建一个类的对象时,其对象实例就分配在堆内存上,这些实例变量对所有能够访问到该对象的线程都是可见的。


堆内存中的内存不可见性原因

现代计算机系统为了提高性能,普遍采用了高速缓存技术,CPU有自己的缓存层级,包括L1、L2、L3等高速缓存。当线程A修改了堆内存中的共享变量时,这个更新可能只反映在了线程A所在的CPU缓存中,而不是立即同步到主内存或其他线程所在的CPU缓存中。这就是为什么即使是在共享内存区域——堆内存在多线程环境下也可能出现内存不可见性的问题。

public class CacheCoherenceIssue {
    private static volatile int sharedValue = 0;

    public static void main(String[] args) throws InterruptedException {
        Thread threadA = new Thread(() -> {
            for (int i = 0; i < 10000; i++) {
                sharedValue++;
            }
        });

        Thread threadB = new Thread(() -> {
            while (sharedValue == 0); // 如果没有volatile,可能会陷入循环无法退出
            System.out.println("Thread B observed: " + sharedValue);
        });

        threadA.start();
        threadB.start();

        threadA.join();
        threadB.join();
    }
}

上述代码中,如果没有使用
volatile
关键字修饰
sharedValue
,线程B可能无法观察到线程A对共享变量的更新,因为这种更新可能未及时传播至主内存或线程B的工作内存。


Java内存模型(JMM)详解

Java内存模型(JMM)是一种抽象概念,它定义了Java程序中各种变量的访问规则,尤其是针对堆内存中的共享变量。JMM确保了并发环境下的原子性、有序性和可见性:

  1. 主内存与本地内存的关系 :根据JMM的描述,所有共享变量都存储于主内存中,每个线程具有自己的本地内存,保存了该线程读写共享变量的副本。线程间的通信必须通过主内存进行,即线程先将本地内存中的共享变量刷新回主内存,然后其他线程从主内存读取最新值并更新到自己本地内存的过程。
  2. 内存操作的顺序保证 :JMM通过内存屏障来控制指令重排序,从而确保特定操作的顺序性,比如volatile变量的写后读操作不会被重排序。
  3. 内存可见性的实现 :JMM提供了一系列规则和机制来保证不同线程对共享变量修改的可见性,例如volatile变量的写会强制刷入主内存,并使其他线程对该变量的读失效,进而从主内存重新加载。

综上所述,Java内存模型在多线程编程中扮演着核心角色,通过规范和约束线程如何访问和更新共享变量,有效地解决了并发环境下的内存一致性问题。


Java内存模型与Java内存区域的关系



两者区别

Java内存模型(JMM)和Java运行时内存区域是两个不同的概念层次,它们在描述并发编程的内存行为时有着各自的侧重点:

  • Java内存模型(JMM) :从抽象层面定义了线程之间如何通过主内存进行交互以及如何保证数据的一致性和有序性。JMM关注的是对共享变量访问规则的规范,比如可见性、原子性和有序性,它是一组关于程序中所有变量访问操作的协议或约定。
  • Java运行时内存区域 :这是更为具体的概念,指Java虚拟机(JVM)在运行Java程序时实际划分的内存区域,包括堆、栈、方法区、程序计数器等。这些内存区域分别存储着对象实例、局部变量、类信息、线程上下文等不同类型的内存数据,并且各区域具有不同的生命周期和管理策略。


联系与映射

尽管JMM与Java运行时内存区域在概念上有所差异,但它们之间存在着密切的联系和映射关系:

  1. 主内存与共享数据区域 : 在JMM中,主内存对应于Java运行时内存区域中的堆和方法区。堆存放了Java对象实例,即多线程可以共享的对象数据;而方法区则包含了类的元数据和静态变量等,这些也是全局可访问的信息,因此它们都属于“主内存”的范畴。
  2. 本地内存与私有数据区域 : JMM中的本地内存实际上是一个抽象概念,涵盖了缓存、写缓冲区、寄存器等硬件设施,对应到Java运行时内存区域,可以理解为每个线程的私有工作空间,如程序计数器、虚拟机栈和本地方法栈。其中,虚拟机栈保存了方法调用的局部变量表,以及操作数栈等信息,这些都是严格线程私有的,符合本地内存的概念。

虽然无法直接以代码形式展示这种抽象的映射关系,但在实际编程中,我们可以观察到以下现象:

public class MemoryModelMapping {
    private static int sharedValue; // 存储在堆中,属于主内存区域
    private int threadLocalValue; // 存储在线程栈中,属于本地内存

    public void runInParallel() {
        Thread threadA = new Thread(() -> {
            sharedValue = 10// 修改共享变量
            threadLocalValue = 20// 修改线程局部变量
        });

        Thread threadB = new Thread(() -> {
            while (sharedValue == 0) {} // 等待共享变量更新
            System.out.println("Shared value: " + sharedValue);
        });

        threadA.start();
        threadB.start();

        try {
            threadA.join();
            threadB.join();
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    }
}

在这个示例中,
sharedValue
变量由于被多个线程共享,它的修改需要遵循JMM的同步和可见性规则,而
threadLocalValue
变量仅在线程内部使用,不受JMM的跨线程可见性约束,其生命周期完全受限于所在线程的虚拟机栈范围。这样,我们便能直观地感受到JMM与Java运行时内存区域之间的关联和作用机制。


Java语言特性与JMM实现


volatile关键字的作用

在Java并发编程中,volatile关键字是一个重要的工具,它用于修饰共享变量,确保了该变量在多线程环境下的可见性和禁止指令重排序。当一个线程修改了volatile变量的值时,其他线程能够立即看到这个更新后的值,这是因为volatile变量的读写操作都会与主内存直接交互,并且会在必要时插入内存屏障以保证数据的一致性。

public class VolatileExample {
    private volatile int sharedValue = 0;

    public void increment() {
        sharedValue++;
    }

    public int getSharedValue() {
        return sharedValue;
    }

    public static void main(String[] args) throws InterruptedException {
        VolatileExample example = new VolatileExample();
        Thread threadA = new Thread(example::increment);
        Thread threadB = new Thread(() -> System.out.println("Thread B sees: " + example.getSharedValue()));

        threadA.start();
        threadA.join(); // 确保线程A完成操作
        threadB.start();
    }
}

在这个例子中,
sharedValue
是一个被volatile修饰的变量,线程A对其进行了递增操作,而线程B可以立即获取到最新的值,体现了volatile对于共享状态同步的重要作用。


synchronized关键字的功能

synchronized
关键字提供了原子性和可见性保障,它可以应用于方法或代码块,使得在同一时间只有一个线程能访问被保护的资源,从而有效地解决了竞态条件和数据一致性问题。

public class SynchronizedExample {
    private int counter = 0;

    public synchronized void incrementCounter() {
        counter++;
    }

    public synchronized int getCount() {
        return counter;
    }

    public static void main(String[] args) {
        SynchronizedExample example = new SynchronizedExample();
        Thread threadA = new Thread(() -> {
            for (int i = 0; i < 1000; i++) {
                example.incrementCounter();
            }
        });

        Thread threadB = new Thread(() -> {
            while (true) {
                if (example.getCount() >= 1000) {
                    System.out.println("Counter reached 1000");
                    break;
                }
            }
        });

        threadA.start();
        threadB.start();

        try {
            threadA.join();
            threadB.join();
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    }
}

在此示例中,
synchronized
方法
incrementCounter()

getCount()
保证了计数器的增量操作是原子性的,同时多个线程对counter的读写操作不会出现竞态条件,即线程B总能看到线程A对counter修改的最新结果。


内存屏障与happens-before原则

为了更深入地理解并发控制机制,Java内存模型还引入了内存屏障(Memory Barrier)的概念,这是一种硬件级别的指令,用于确保特定内存操作顺序并刷新缓存。Java编译器会根据JMM规则,在适当的时机插入内存屏障,以实现对volatile变量和其他同步原语的正确支持。

另外,Java内存模型通过happens-before原则来简化程序员理解和推理程序行为。它定义了一系列先行发生关系,比如:程序次序规则、监视器锁规则等,这些规则明确了事件之间的执行顺序,如果A happens-before B,那么线程A对共享变量的修改对于线程B来说一定可见。

例如:

public class HappensBeforeExample {
    private static boolean flag = false;
    private static int data = 0;

    public static void main(String[] args) throws InterruptedException {
        Thread one = new Thread(() -> {
            data = 1// 修改数据
            flag = true// 设置标志位
        });

        one.start();
        one.join();

        // 根据happens-before原则,由于监视器锁规则
        // 当进入同步块时,线程将看到之前对flag的修改
        synchronized (HappensBeforeExample.class{
            if (flag) {
                System.out.println("Data seen in other thread: " + data); // 输出正确的值
            }
        }
    }
}

在这个例子中,因为
synchronized
关键字遵循happens-before原则中的监视器锁规则,因此主线程在进入同步块时,可以看到之前线程one对
flag
的修改,进而确定
data
变量是否已经被正确设置。

本文使用
markdown.com.cn
排版

本文介绍基于
C++
语言,遍历文件夹中的全部文件,并从中获取
指定类型
的文件的方法。

首先,我们来明确一下本文所需实现的需求。现在有一个文件夹,其中包含了很多文件,如下图所示;我们如果想获取其中所有类型为
.bmp
格式的文件的名称,如果文件数量比较多的话,手动筛选就会很麻烦。而借助
C++
代码就可以简单地实现这一需求。如果需要借助
Python
代码来实现同样的需求,可以参考文章
Python ArcPy批量掩膜、重采样大量遥感影像
,基于其中提到的
arcpy.ListRasters()
函数来实现。

首先需要说明的是,本文代码只能实现对
某一文件夹下的文件
进行遍历并筛选;如果是当前文件夹下的
子文件夹中
的文件,这一代码是没有办法遍历的。大家如果有相关需求的话,可以尝试在本文代码中加几个判断语句来实现;或者参考
HDF格式遥感影像批量转为TIFF格式:ArcPy实现

Python求取文件夹内的文件数量、子文件夹内的文件数量
这两篇文章,基于其中提到的方法用
Python
代码来实现。

本文分为两部分,第一部分为代码的分段讲解,第二部分为完整代码。

1 分段代码介绍

1.1 代码准备

这一部分主要是代码的头文件、命名空间与我们
自行撰写的自定义函数
get_need_file()
的声明;具体代码如下所示。

#include <iostream>
#include <vector>
#include <io.h>

using namespace std;

void get_need_file(string path, vector<string>& file, string ext);

其中,由于我们在接下来的代码中需要用到容器
vector
这一数据类型,因此首先需要添加
#include <vector>
;同时,我们在接下来的代码中需要用到头文件
io.h
中的部分函数(主要都是一些与计算机系统、文件管理相关的函数),因此需要添加
#include <io.h>

接下来,这里声明了一个自定义函数
get_need_file()
,具体我们在本文
1.3
部分介绍。

1.2 主函数

这一部分介绍代码的
main()
函数;具体代码如下所示。

int main() {
	string file_path = R"(E:\02_Project\02_ChlorophyllProduce\01_Data\00_Test)";
	vector<string> my_file;
	string need_extension = ".bmp";
	get_need_file(file_path, my_file, need_extension);
	for (int i = 0; i < my_file.size(); i++)
	{
		cout << "File " << i+1 << " is:" << endl;
		cout << my_file[i] << endl;
	}
	if (my_file.size() == 0)
	{
		cout << "No file can be found!" << endl;
	}
	else
	{
		cout << endl << "Find " << my_file.size() << " file(s)." << endl;
	}
	return 0;
}

首先,我们定义了几个后续代码需要用到的变量。其中,
file_path
是一个字符串
string
变量,表示我们需要进行文件遍历的文件夹路径;这里我们用
R"()"
取消其中路径转义字符的使用。
my_file
是一个容器
vector
变量,其中将会存储我们需要筛选出来的特定文件。
need_extension
是我们需要筛选出来的特定文件的格式后缀。这些变量是如何工作的,具体我们在本文
1.3
部分介绍。

随后,调用自定义函数
get_need_file()
;调用完毕后,
my_file
中就存储了我们需要筛选出来的特定文件(如果有的话)。

最后,
for
循环来输出我们找到的文件名称;
if
判断则是输出我们最终有没有筛选出指定格式的文件,如果筛选出来的话则会输出具体筛选出的文件数量。

主函数部分整体比较简单,这里就不再赘述。

1.3 自定义函数

这一部分介绍代码的自定义函数
get_need_file()
,也是本文最重要的部分;具体代码如下所示。

void get_need_file(string path, vector<string>& file, string ext)
{
	intptr_t file_handle = 0;
	struct _finddata_t file_info;
	string temp;
	if ((file_handle = _findfirst(temp.assign(path).append("/*" + ext).c_str(), &file_info)) != -1)
	{
		do
		{
			file.push_back(temp.assign(path).append("/").append(file_info.name));
		} while (_findnext(file_handle, &file_info) == 0);
		_findclose(file_handle);
	}
}

其中,自定义函数
get_need_file()
的三个参数,依次就是我们在主函数中定义的三个变量。

在自定义函数
get_need_file()
中,我们首先定义了
intptr_t
类型的变量
file_handle
,并对其赋值为
0
。首先,这里的
intptr_t
是一种与计算机系统有关的数据类型,专门用来存放
指针的地址
;相较于用标准的
int
格式、
long
格式存储
指针的地址
,其具有更高的安全性,因此在计算机系统中通常用其存储
指针的地址
。其次,这里的
file_handle
表示文件句柄;在计算机系统中,每一个文件都有一个唯一的编号(相当于我们每一个人都有一个唯一的身份证号码),不同的文件具有不同的句柄,依据这一个句柄计算机系统就能锁定其对应的那个唯一的文件。因为文件句柄就是一个
指向指针的指针
,亦即
指针的地址
,因此我们就将其设定为
intptr_t
类型。此外,为其赋值为
0
,就是相当于先暂时随便给它赋一个肯定不对的数值,之后程序会自动替换。

接下来,我们定义一个
_finddata_t
类型的变量
file_info
。首先,这里的
_finddata_t
其实是一个结构体,专门用来存储计算机系统中不同文件的各类信息;而
file_info
就是文件的不同信息。前面我们提到,
file_handle
相当于我们的身份证号码,那么这里
file_info
相当于就是存储了我们性别、家庭住址、爱好等信息的个人信息库。

随后,我们再定义一个字符串
string
类型的变量
temp
,其用来存储临时生成的文件路径。

接下来,进入
if
判断语句;这里我们将其拆开来看。首先,
temp.assign(path).append("/*" + ext)
其实就表示我们需要筛选的特定格式的文件,在本文中即
E:\02_Project\02_ChlorophyllProduce\01_Data\00_Test)/*.bmp
,并将其通过
.assign()
函数赋给字符串
temp
。随后,
.c_str()
函数将前面赋值好的字符串
temp
转为标准的
C
语言的格式(这是因为后面操作需要保证字符串为标准的
C
语言格式)。随后,将转换好的
C
语言格式字符串作为第一个参数,带入
_findfirst()
函数;其第二个参数则是
file_info

_findfirst()
函数的功能是在当前路径下,找到与第一个参数(在这里也就是转换好的
C
语言格式字符串)相匹配的第一个文件;如果能找到这个文件,那么其就返回该文件的句柄,并将该文件的信息放入
file_info
;如果找不到这个文件,那么该函数就返回
-1
。因此,这里的
if
判断语句表示,一旦在当前路径下找到我们需要的文件,就继续进行接下来的代码;如果找不到需要的文件,那么相当于当前文件夹下就没有符合我们要求的文件。

接下来,执行
do
语句内部的代码。其中,
temp.assign(path).append("/").append(file_info.name)
就表示当前找到的文件的路径及其名称,并通过
push_back()
函数将其附加至
vector
变量
file
的末尾。随后,进行
while
语句内部代码的判断——其中,
_findnext()
函数其实和前面的
_findfirst()
函数比较类似,它的作用是按照当前
_findfirst()
函数中所指定的文件筛选要求,进行继续筛选(
_findfirst()
函数相当于是找到了第一个符合我们筛选要求的文件,而
_findnext()
函数就是继续找,找到下一个符合要求的文件);如果其找到了,那么就将所找到的文件的句柄与信息返回到其两个参数中,且返回一个值
0
;如果没有找到的话就返回
-1
。因此,这里
while
语句相当于就是判断当前路径下还有没有我们需要的文件,如果有的话就再执行
do
语句内部的代码(即将文件的路径放入
vector
变量
file
的末尾);如果没有的话,那么就结束前面的循环。

最后,
_findclose()
表示将当前句柄所表示的文件加以关闭,并将对应的文件资源释放。

2 完整代码

本文所用到的全部代码如下。

#include <iostream>
#include <vector>
#include <io.h>

using namespace std;

void get_need_file(string path, vector<string>& file, string ext);

int main() {
	string file_path = R"(E:\02_Project\02_ChlorophyllProduce\01_Data\00_Test)";
	vector<string> my_file;
	string need_extension = ".bmp";
	get_need_file(file_path, my_file, need_extension);
	for (int i = 0; i < my_file.size(); i++)
	{
		cout << "File " << i + 1 << " is:" << endl;
		cout << my_file[i] << endl;
	}
	if (my_file.size() == 0)
	{
		cout << "No file can be found!" << endl;
	}
	else
	{
		cout << endl << "Find " << my_file.size() << " file(s)." << endl;
	}
	return 0;
}

void get_need_file(string path, vector<string>& file, string ext)
{
	intptr_t file_handle = 0;
	struct _finddata_t file_info;
	string temp;
	if ((file_handle = _findfirst(temp.assign(path).append("/*" + ext).c_str(), &file_info)) != -1)
	{
		do
		{
			file.push_back(temp.assign(path).append("/").append(file_info.name));
		} while (_findnext(file_handle, &file_info) == 0);
		_findclose(file_handle);
	}
}

运行上述代码后,将会得到所筛选出的文件各自的名称,以及其具体数量。

至此,大功告成。

大家好,我是张飞洪,感谢您的阅读,我会不定期和你分享学习心得,希望我的文章能成为你成长路上的垫脚石,让我们一起精进。
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
服务,然后获取当前用户的唯一
Id

Username

Email

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基类(如
    ApplicationService

    AbpController
    )提供了预注入。在这些类中,可以直接使用
    CurrentUser
    属性,而无需手动注入此服务。

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

ICurrentUser
服务从当前声明中获取所有信息。如果想要直接查询当前声明,可以使用
FindClaim

FindClaims

GetAllClaims
方法。如果您创建了自定义声明,这些方法尤其有用:

定义声明

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
    条件)。

数据过滤限制

数据过滤自动化仅在使用存储库或
DbContext

EF Core
)时有效。如果您使用的是手写的SQL
DELETE

SELECT
命令,您应该自己处理,因为在这种情况下,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
实体可以实现
ISoftDelete

IMultiTenant
接口。
如您所见,为实体实现数据过滤器非常简单,
只需实现与数据过滤器相关的接口即可
。默认情况下,所有数据过滤器均已启用,除非您明确禁用它们。

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
    。通常不需要此方法,因为在这两种情况下都会按预期
    Enable

    Disable

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

3 定义自定义数据过滤器

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

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

public interface Iarchivable { bool IsArchived { get; } }

IsArchived
属性将用于过滤实体。默认情况下,
IsArchived

true
的实体将被删除。一旦我们定义了这样一个接口,我们就可以实现它。请参见以下示例:

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
    ,因此它会删除
    IsArchived

    true
    的实体。
    我没有在过滤器实现中使用
    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
    :与此审计日志相关的注释/日志。

审计日志对象保存在关系数据库的多个表中:
AbpAuditLogs
,
AbpAuditLogActions
,
AbpEntityChanges
, 和
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不会保存HTTP
    GET
    请求的审计日志,因为
    GET
    请求不应该更改数据库。但是,将其设置为
    true
    后也会启用
    GET
    请求的审计日志记录。
  • IsEnabledForAnonymousUsers
    (
    bool
    ; default:
    true
    ):如果只想为经过身份验证的用户做审计,请将其设置为
    false
    。如果为匿名用户保存审计日志,这些用户的ID值将为
    null
  • AlwaysLogOnException
    (
    bool
    ; default:
    true
    ):如果应用程序出现异常,ABP将默认保存审计日志(
    IsEnabledForGetRequests

    IsEnabledForAnonymousUsers
    选项将不做考虑)。将此设置为
    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
    ,并等待
    true

    false
    。如果要为给定实体类型启用实体历史记录,则返回
    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; }
}

本示例将
Dto

CreditCardNumber
属性从审计日志中排除,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框架的其他功能兼容,例如多租户。

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

Memcached

建议使用分布式缓存系统,即使只有一个正在运行的实例。不要担心性能,因为分布式缓存的默认在内存中工作。这意味着它不是分布式的,除非您显式地配置一个真正的分布式缓存提供程序,比如
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.StackExchangeRedis
NuGet包,或者使用命令行在对应的项目中安装:

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.CurrentCulture

CultureInfo.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
之外,其他一些常见的基类,如
AbpController

AbpPageModel
,提供了与注入
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
(例如
username

ID
),并根据需要定义自定义声明。

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

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

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

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