21

[ASP.NET Core 3框架揭秘] Options[6]: 扩展与定制

 4 years ago
source link: http://www.cnblogs.com/artech/p/inside-asp-net-core-06-06.html
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.

由于Options模型涉及的核心对象最终都注册为相应的服务,所以从原则上讲这些对象都是可以定制的,下面提供几个这样的实例。由于Options模型提供了针对配置系统的集成,所以可以采用配置文件的形式来提供原始的Options数据,可以直接采用反序列化的方式将配置文件的内容转换成Options对象。

一、使用JSON文件提供Options数据

在介绍IConfigureOptions扩展的实现之前,下面先演示如何在应用中使用它。首先在演示实例中定义一个Options类型。简单起见,我们沿用前面使用的包含两个成员的FoobarOptions类型,从而实现IEquatable<FoobarOptions>接口。最终绑定生成的是一个FakeOptions对象,为了演示针对复合类型、数组、集合和字典类型的绑定,可以为其定义相应的属性成员。

public class FakeOptions
{
    public FoobarOptions Foobar { get; set; }
    public FoobarOptions[] Array { get; set; }
    public IList<FoobarOptions> List { get; set; }
    public IDictionary<string, FoobarOptions> Dictionary { get; set; }
}

public class FoobarOptions : IEquatable<FoobarOptions>
{
    public int Foo { get; set; }
    public int Bar { get; set; }

    public FoobarOptions() { }
    public FoobarOptions(int foo, int bar)
    {
        Foo = foo;
        Bar = bar;
    }

    public override string ToString() => $"Foo:{Foo}, Bar:{Bar}";
    public bool Equals(FoobarOptions other) => this.Foo == other?.Foo && this.Bar == other?.Bar;
}

可以在项目根目录添加一个JSON文件(命名为fakeoptions.json),如下所示的代码片段表示该文件的内容,可以看出文件的格式与FakeOptions类型的数据成员是兼容的,也就是说,这个文件的内容能够被反序列化成一个FakeOptions对象。

{
    "Foobar": {
        "Foo": 1,
        "Bar": 1
    },
    "Array": [{
            "Foo": 1,
            "Bar": 1
        },
        {
            "Foo": 2,
            "Bar": 2
        },
        {
            "Foo": 3,
            "Bar": 3
        }],
    "List": [{
            "Foo": 1,
            "Bar": 1
        },
        {
            "Foo": 2,
            "Bar": 2
        },
        {
            "Foo": 3,
            "Bar": 3
        }],
    "Dictionary": {
        "1": {
            "Foo": 1,
            "Bar": 1
        },
        "2": {
            "Foo": 2,
            "Bar": 2
        },
        "3": {
            "Foo": 3,
            "Bar": 3
        }
    }
}

下面按照Options模式直接读取该配置文件,并将文件内容绑定为一个FakeOptions对象。如下面的代码片段所示,在调用IServiceCollection接口的AddOptions扩展方法之后,我们调用了另一个自定义的Configure<FakeOptions>扩展方法,该方法的参数表示承载原始Options数据的JSON文件的路径。这个演示程序提供的一系列调试断言表明:最终获取的FakeOptions对象与原始的JSON文件具有一致的内容。(S710)

class Program
{
    static void Main()
    {
        var foobar1 = new FoobarOptions(1, 1);
        var foobar2 = new FoobarOptions(2, 2);
        var foobar3 = new FoobarOptions(3, 3);

        var options = new ServiceCollection()
            .AddOptions()
            .Configure<FakeOptions>("fakeoptions.json")
            .BuildServiceProvider()
            .GetRequiredService<IOptions<FakeOptions>>()
            .Value;

        Debug.Assert(options.Foobar.Equals(foobar1));

        Debug.Assert(options.Array[0].Equals(foobar1));
        Debug.Assert(options.Array[1].Equals(foobar2));
        Debug.Assert(options.Array[2].Equals(foobar3));

        Debug.Assert(options.List[0].Equals(foobar1));
        Debug.Assert(options.List[1].Equals(foobar2));
        Debug.Assert(options.List[2].Equals(foobar3));

        Debug.Assert(options.Dictionary["1"].Equals(foobar1));
        Debug.Assert(options.Dictionary["2"].Equals(foobar2));
        Debug.Assert(options.Dictionary["3"].Equals(foobar3));
    }
}

二、JsonFileConfigureOptions<TOptions>

Options模型中针对Options对象的初始化是通过IConfigureOptions<TOptions>对象实现的,演示程序中调用的Configure<TOptions>方法实际上就是注册了这样一个服务。我们采用Newtonsoft.Json来完成针对JSON的序列化,并且使用基于物理文件系统的IFileProvider来读取文件。Configure<TOptions>方法注册的实际上就是如下这个JsonFileConfigureOptions<TOptions>类型。JsonFileConfigureOptions<TOptions>实现了IConfigureNamedOptions<TOptions>接口,在调用构造函数创建一个JsonFileConfigureOptions<TOptions>对象的时候,我们指定了Options名称、JSON文件的路径以及用于读取该文件的IFileProvider对象。

public class JsonFileConfigureOptions<TOptions> : IConfigureNamedOptions<TOptions> where TOptions : class, new()
{
    private readonly IFileProvider _fileProvider;
    private readonly string _path;
    private readonly string _name;

    public JsonFileConfigureOptions(string name, string path, IFileProvider fileProvider)
    {
        _fileProvider = fileProvider;
        _path = path;
        _name = name;
    }

    public void Configure(string name, TOptions options)
    {
        if (name != null && _name != name)
        {
            return;
        }

        byte[] bytes;
        using (var stream = _fileProvider.GetFileInfo(_path).CreateReadStream())
        {
            bytes = new byte[stream.Length];
            stream.Read(bytes, 0, bytes.Length);
        }

        var contents = Encoding.Default.GetString(bytes);
        contents = contents.Substring(contents.IndexOf('{'));
        var newOptions = JsonConvert.DeserializeObject<TOptions>(contents);
        Bind(newOptions, options);
    }

    public void Configure(TOptions options) => Configure(Options.DefaultName, options);

    private void Bind(object from, object to)
    {
        var type = from.GetType();
        if (type.IsDictionary())
        {
            var dest = (IDictionary)to;
            var src = (IDictionary)from;
            foreach (var key in src.Keys)
            {
                dest.Add(key, src[key]);
            }
            return;
        }

        if (type.IsCollection())
        {
            var dest = (IList)to;
            var src = (IList)from;
            foreach (var item in src)
            {
                dest.Add(item);
            }
        }

        foreach (var property in type.GetProperties())
        {
            if (property.IsSpecialName || property.GetMethod == null ||
                property.Name == "Item" || property.DeclaringType != type)
            {
                continue;
            }

            var src = property.GetValue(from);
            var propertyType = src?.GetType() ?? property.PropertyType;

            if ((propertyType.IsValueType || src is string || src == null) && property.SetMethod != null)
            {
                property.SetValue(to, src);
                continue;
            }

            var dest = property.GetValue(to);
            if (null != dest && !propertyType.IsArray())
            {
                Bind(src, dest);
                continue;
            }

            if (property.SetMethod != null)
            {
                var destType = propertyType.IsDictionary()
                    ? typeof(Dictionary<,>).MakeGenericType(propertyType.GetGenericArguments())
                    : propertyType.IsArray()
                    ? typeof(List<>).MakeGenericType(propertyType.GetElementType())
                    : propertyType.IsCollection()
                    ? typeof(List<>).MakeGenericType(propertyType.GetGenericArguments())
                    : propertyType;

                dest = Activator.CreateInstance(destType);
                Bind(src, dest);

                if (propertyType.IsArray())
                {
                    IList list = (IList)dest;
                    dest = Array.CreateInstance(propertyType.GetElementType(), list.Count);
                    list.CopyTo((Array)dest, 0);
                }
                property.SetValue(to, src);
            }
        }
    }
}

internal static class Extensions
{
    public static bool IsDictionary(this Type type) => type.IsGenericType && typeof(IDictionary).IsAssignableFrom(type) && type.GetGenericArguments().Length == 2;
    public static bool IsCollection(this Type type) => typeof(IEnumerable).IsAssignableFrom(type) && type != typeof(string);
    public static bool IsArray(this Type type) => typeof(Array).IsAssignableFrom(type);
}

在实现的Configure方法中,JsonFileConfigureOptions<TOptions>利用提供的IFileProvider对象读取了指定JSON文件的内容,并将其反序列化成一个新的Options对象。由于Options模型最终提供的总是IOptionsFactory<TOptions>对象最初创建的那个Options对象,所以针对Options的初始化只能针对这个Options对象。因此,不能使用新的Options对象替换现有的Options对象,只能将新Options对象承载的数据绑定到现有的这个Options对象上,针对Options对象的绑定实现在上面提供的Bind方法中。如下所示的代码片段是注册JsonFileConfigureOptions<TOptions>对象的Configure<TOptions>扩展方法的定义。

public static class ServiceCollectionExtensions
{
    public static IServiceCollection Configure<TOptions>(this IServiceCollection services, string filePath, string basePath = null)  where TOptions : class, new()
        => services.Configure<TOptions>(Options.DefaultName, filePath, basePath);

    public static IServiceCollection Configure<TOptions>(this IServiceCollection services, string name, string filePath,  string basePath = null) where TOptions : class, new()
    {
        var fileProvider = string.IsNullOrEmpty(basePath)
            ? new PhysicalFileProvider(Directory.GetCurrentDirectory())
            : new PhysicalFileProvider(basePath);

        return services.AddSingleton<IConfigureOptions<TOptions>>( new JsonFileConfigureOptions<TOptions>(name, filePath, fileProvider));
    }
}

三、定时刷新Options数据

通过对IOptionsMonitor<Options>的介绍,可知它通过IOptionsChangeTokenSource<TOptions>对象来感知Options数据的变化。到目前为止,我们尚未涉及针对这个服务的注册,下面演示如何通过注册该服务来实现定时刷新Options数据。对于如何同步Options数据,最理想的场景是在数据源发生变化的时候及时将通知“推送”给应用程序。如果采用本地文件,采用这种方案是很容易实现的。但是在很多情况下,实时监控数据变化的成本很高,消息推送在技术上也不一定可行,此时需要退而求其次,使应用定时获取并更新Options数据。这样的应用场景可以通过注册一个自定义的IOptionsChangeTokenSource<TOptions>实现类型来完成。

在讲述自定义IOptionsChangeTokenSource<TOptions>类型的具体实现之前,先演示针对Options数据的定时刷新。我们依然沿用前面定义的FoobarOptions作为绑定的目标Options类型,而具体的演示程序则体现在如下所示的代码片段中。

class Program
{
    static void Main()
    {
        var random = new Random();
        var optionsMonitor = new ServiceCollection()
            .AddOptions()
            .Configure<FoobarOptions>(TimeSpan.FromSeconds(1))
            .Configure<FoobarOptions>(foobar =>
            {
                foobar.Foo = random.Next(10, 100);
                foobar.Bar = random.Next(10, 100);
            })
            .BuildServiceProvider()
            .GetRequiredService<IOptionsMonitor<FoobarOptions>>();

        optionsMonitor.OnChange(foobar  => Console.WriteLine($"[{DateTime.Now}]{foobar}"));
        Console.Read();
    }
}

如上面的代码片段所示,针对自定义IOptionsChangeTokenSource<TOptions>对象的注册实现在我们为IServiceCollection接口定义的Configure<FoobarOptions>扩展方法中,该方法具有一个TimeSpan类型的参数表示定时刷新Options数据的时间间隔。在演示程序中,我们将这个时间间隔设置为1秒。为了模拟数据的实时变化,可以调用Configure<FoobarOptions>扩展方法注册一个Action<FoobarOptions>对象来更新Options对象的两个属性值。

利用IServiceProvider对象得到IOptionsMonitor<FoobarOptions>对象,并调用其OnChange方法注册了一个Action<FoobarOptions>对象,从而将FoobarOptions承载的数据和当前时间打印出来。由于我们设置的自动刷新时间为1秒,所以程序会以这个频率定时将新的Options数据以下图所示的形式打印在控制台上。

ae2Mv2M.png!web

四、TimedRefreshTokenSource<TOptions>

前面演示程序中的Configure<TOptions>扩展方法注册了一个TimedRefreshTokenSource<TOptions>对象,下面的代码片段给出了该类型的完整定义。从给出的代码片段可以看出,实现的OptionsChangeToken方法返回的IChangeToken对象是通过字段_changeToken表示的OptionsChangeToken对象,它与第6章介绍的ConfigurationReloadToken类型具有完全一致的实现。

public class TimedRefreshTokenSource<TOptions> : IOptionsChangeTokenSource<TOptions>
{
    private OptionsChangeToken _changeToken;
    public string Name { get; }
    public TimedRefreshTokenSource(TimeSpan interval, string name)
    {
        this.Name = name ?? Options.DefaultName;
        _changeToken = new OptionsChangeToken();
        ChangeToken.OnChange(() => new CancellationChangeToken(new CancellationTokenSource(interval).Token),
            () =>
            {
                var previous = Interlocked.Exchange(ref _changeToken, new OptionsChangeToken());
                previous.OnChange();
            });
    }

    public IChangeToken GetChangeToken() => _changeToken;

    private class OptionsChangeToken : IChangeToken
    {
        private readonly CancellationTokenSource _tokenSource;

        public OptionsChangeToken() => _tokenSource = new CancellationTokenSource();
        public bool HasChanged => _tokenSource.Token.IsCancellationRequested;
        public bool ActiveChangeCallbacks => true;
        public IDisposable RegisterChangeCallback(Action<object> callback, object state) => _tokenSource.Token.Register(callback, state);
        public void OnChange() => _tokenSource.Cancel();
    }
}

通过调用构造函数创建一个TimedRefreshTokenSource<TOptions>对象时,除了需要指定Options的名称,还需要提供一个TimeSpan对象来控制Options自动刷新的时间间隔。在构造函数中,可以通过调用ChangeToken的OnChange方法以这个间隔定期地创建新的OptionsChangeToken对象并赋值给_changeToken。与此同时,我们通过调用前一个OptionsChange

Token对象的OnChange方法对外通知Options已经发生变化。

public static class ServiceCollectionExtensions
{
    public static IServiceCollection Configure<TOptions>( this IServiceCollection services, string name, TimeSpan refreshInterval)
        => services.AddSingleton<IOptionsChangeTokenSource<TOptions>>( new TimedRefreshTokenSource<TOptions>(refreshInterval, name));
    public static IServiceCollection Configure<TOptions>( this IServiceCollection services, TimeSpan refreshInterval)
        => services.Configure<TOptions>(Options.DefaultName, refreshInterval);
}

About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK