客户端设置变动与业务服务配置的动态桥接方案

1. 方案概述

在客户端应用(如 Avalonia/MAUI)开发中,本方案旨在解决 “UI 层配置变更”“业务层服务消费” 之间的解耦问题。

  • 现状与约束:业务层 Service 采用 .NET 标准的 IOptionsMonitor<ServiceOpts> 机制消费配置,且不应直接依赖 UI 层的具体设置类(以保持单向依赖和高内聚)。
  • 解决方案:引入一个自定义的 ServiceConfigurationProvider 作为桥接介质。当 UI 层的设置类发生属性变更(如触发 INotifyPropertyChanged)时,由该 Provider 捕获并更新标准配置系统(IConfiguration)的内存数据,同时触发重载通知(Reload)。
  • 最终效果:业务层 Service 无需感知 UI 层的存在,即可通过官方的 IOptionsMonitor 机制秒级、无感地获取并应用最新参数值

2. 核心架构与数据流

        [ 用户的 UI 界面 ]
               │
               ▼ (用户修改了 IP 地址等参数)
┌──────────────────────────────┐
│     UserSettings             │  <-- 抛出 PropertyChanged 事件
└──────────────┬───────────────┘
               │ 
               ▼ (桥接中介:过滤并只同步关心的属性)
┌──────────────────────────────┐
│ ServiceConfigurationProvider │  <-- 写入配置 Data,并执行 OnReload()
└──────────────┬───────────────┘
               │
               ▼ (官方 .NET 配置系统)
┌──────────────────────────────┐
│     IConfiguration           │  <-- 顺着配置管道往下游分发
└──────────────┬───────────────┘
               │
               ▼ (强类型选项)
┌──────────────────────────────┐
│ IOptionsMonitor<ServiceOpts> │  <-- 自动刷新并触发变更通知
└──────────────┬───────────────┘
               │
               ▼ (业务层服务)
┌──────────────────────────────┐
│     BusinessService          │  <-- 执行 OnChange 回调,无感更新
└──────────────────────────────┘

3. 标准方案实现:基于 ConfigurationProvider 桥接

3.1 UI 设置对象(示例)

public class UserSettings : INotifyPropertyChanged
{
    public event PropertyChangedEventHandler? PropertyChanged;

    // 业务关心的属性
    public string ServiceAddress { get; set; } = "http://127.0.0.1";
    public int ServicePort       { get; set; } = 50325;
    public string ServiceToken { get; set; } = string.Empty;

    // 大量与后台服务无关的其它 UI 属性(语言、主题、窗口大小……)
    public string Language { get; set; } = "zh-CN";
    public string Theme    { get; set; } = "System";

    protected void OnPropertyChanged([CallerMemberName] string? name = null)
        => PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(name));
}

3.2 桥接 Provider 与 Source

/// <summary>
/// 把设置对象中"与某业务相关"的属性,桥接到 .NET 配置系统。
/// </summary>
public class ServiceConfigurationProvider : ConfigurationProvider, IDisposable
{
    private readonly string _section;
    private UserSettings? _settings;

    public ServiceConfigurationProvider(string section = "MyService")
    {
        _section = section;
        // 初始化默认值,防止一开始没数据报错
        Data[$"{_section}:BaseAddress"] = "http://127.0.0.1:50325";
        Data[$"{_section}:Token"]       = string.Empty;
    }

    /// <summary>容器构建完成后调用:同步初始值并订阅变更</summary>
    public void Initialize(UserSettings settings)
    {
        _settings = settings;
        SyncFromSettings();
        settings.PropertyChanged += OnPropertyChanged;
    }

    public void Dispose()
    {
        if (_settings != null)
            _settings.PropertyChanged -= OnPropertyChanged; // 避免内存泄漏
    }

    private void OnPropertyChanged(object? sender, PropertyChangedEventArgs e)
    {
        // 关键:只响应与本业务相关的属性,避免无关属性变更触发无谓的广播
        if (IsRelevantProperty(e.PropertyName))
            SyncFromSettings();
    }

    private void SyncFromSettings()
    {
        if (_settings == null) return;

        Data[$"{_section}:BaseAddress"] = $"{_settings.ServiceAddress}:{_settings.ServicePort}";
        Data[$"{_section}:Token"]       = _settings.ServiceToken;

        OnReload(); // 吹哨:通知所有 IOptionsMonitor 订阅者更新
    }

    private static bool IsRelevantProperty(string? name) => name switch
    {
        nameof(UserSettings.ServiceAddress) => true,
        nameof(UserSettings.ServicePort)    => true,
        nameof(UserSettings.ServiceToken)   => true,
        _ => false,
    };
}

public class ServiceConfigurationSource : IConfigurationSource
{
    private readonly ServiceConfigurationProvider _provider;
    public ServiceConfigurationSource(ServiceConfigurationProvider provider) => _provider = provider;
    public IConfigurationProvider Build(IConfigurationBuilder builder) => _provider;
}

3.3 业务服务消费(无依赖)

public class BusinessService
{
    private readonly IOptionsMonitor<MyServiceOptions> _options;

    public BusinessService(IOptionsMonitor<MyServiceOptions> options)
    {
        _options = options;
        // 监听变更回调
        _options.OnChange(opts => Reconfigure(opts));
    }

    public void DoWork()
    {
        // 随时拿到的都是最新值
        var baseAddress = _options.CurrentValue.BaseAddress;
    }

    private void Reconfigure(MyServiceOptions opt) { /* 重新初始化连接 / 鉴权 */ }
}

public class MyServiceOptions
{
    public string BaseAddress { get; set; } = string.Empty;
    public string Token       { get; set; } = string.Empty;
}

3.4 DI 容器接线(两阶段)

// —— 阶段一:构建配置管线时挂上 Provider ——
var provider = new ServiceConfigurationProvider("MyService");
var source   = new ServiceConfigurationSource(provider);

configurationBuilder.Add(source); // 1. 让配置系统认领该源
services.AddSingleton(provider);  // 2. 极其重要:必须注入同一个 provider 实例

// 注册业务服务
services.AddXxxServices(configuration, "MyService");

// —— 阶段二:容器构建完成后初始化桥接 ——
var sp       = services.BuildServiceProvider();
var settings = sp.GetRequiredService<UserSettings>();

// 注入刚才的同一个 Provider 并启动监听
sp.GetRequiredService<ServiceConfigurationProvider>().Initialize(settings);

4. 替代技术方案权衡

除了上述标准的 ConfigurationProvider 桥接模式之外,根据项目规模、开发敏捷度和框架约束的不同,还可以选择以下几种替代方案:

方案 A:强刷缓存方案 (IOptionsMonitorCache)

适用场景:既想完全保留官方 IOptionsMonitor 的下游消费习惯,又嫌编写 Provider/Source 的样板代码太啰嗦。

  • 实现原理:在 UI 层变动时,直接从 DI 容器中取出 Option 缓存(IOptionsMonitorCache<T>),强制清空并覆写新对象。这会强制触发 IOptionsMonitor 抛出 OnChange 通知。
  • 代码示例
// 在 UI 层发生变更时直接执行
var cache = serviceProvider.GetRequiredService<IOptionsMonitorCache<MyServiceOptions>>();
var newOptions = new MyServiceOptions 
{ 
    BaseAddress = $"{_settings.ServiceAddress}:{_settings.ServicePort}",
    Token = _settings.ServiceToken
};

cache.Clear(); // 冲刷旧缓存
cache.TryAdd(Microsoft.Extensions.Options.Options.DefaultName, newOptions); // 写入新值

方案 B:消息总线/事件聚合器(弱耦合方案)

适用场景:轻量级桌面客户端。不想让臃肿的 Microsoft 配置体系渗透进核心业务。

  • 实现原理:UI 层在配置发生修改后,发布一个轻量级的 Record 消息;业务服务订阅该消息,并在收到后更新自己的内部状态。
  • 代码示例(以 CommunityToolkit.Mvvm 的消息总线为例):
// 1. 定义事件
public record ServiceSettingsChangedMessage(string BaseAddress, string Token);

// 2. UI层发布
WeakReferenceMessenger.Default.Send(new ServiceSettingsChangedMessage(newAddress, newToken));

// 3. 业务层监听并消费
public class BusinessService : IRecipient<ServiceSettingsChangedMessage>
{
    private string _baseAddress;
    public BusinessService() => WeakReferenceMessenger.Default.Register(this);

    public void Receive(ServiceSettingsChangedMessage message)
    {
        _baseAddress = message.BaseAddress; // 响应变更
    }
}

方案 C:经典的接口隔离(最纯粹的 OOP 方案)

适用场景:后台业务服务完全由自己控制,且不强绑定微软的 Options 体系。

  • 实现原理:在业务层定义一个配置接口(如 IServiceConfig),让 UI 层的 UserSettings 实现该接口。业务服务直接依赖接口,并通过接口上的事件感知更新。
  • 代码示例
public interface IServiceConfig
{
    string BaseAddress { get; }
    event Action? ConfigChanged;
}

// UI 层的 UserSettings 直接实现此接口
public class UserSettings : IServiceConfig, INotifyPropertyChanged { ... }

// 业务层直接注入并使用该接口
public class BusinessService
{
    public BusinessService(IServiceConfig config)
    {
        config.ConfigChanged += () => { /* 重新连接 */ };
    }
}

5. 方案综合对比

维度 / 方案 标准 ConfigurationProvider (推介) 方案 A (OptionsCache 强刷) 方案 B (消息总线) 方案 C (接口隔离)
代码样板量 较多(需写 Source/Provider) 极少 极少 极少
解耦彻底性 ⭐⭐⭐⭐⭐ (完美解耦) ⭐⭐⭐⭐⭐ (完美解耦) ⭐⭐⭐⭐ (依赖公共 Message) ⭐⭐⭐ (依赖自定义接口)
符合微软规范 符合官方标准配置管线 属于底层 Hack 行动 完全跳出 Options 框架 完全跳出 Options 框架
第三方兼容性 完美兼容所有第三方依赖 Options 的 SDK 完美兼容所有第三方依赖 Options 的 SDK 无法兼容第三方只认 Options 的 SDK 无法兼容第三方只认 Options 的 SDK

6. 避坑与易错清单

  1. 生命周期对齐(致命)AddSingleton(provider) 必须和配置管线里使用的是同一个实例。如果 new 了两次,UI 层的写入和 DI 的读取就会彻底脱节,导致更新失效。
  2. 过滤不可省略:在 PropertyChanged 回调中,必须对属性名进行过滤。由于 UserSettings 往往有几十个无关属性(如语言、窗口坐标等),不加过滤会导致用户拖动窗口或切换主题时,后台 Service 发生无谓的重载和频繁重连。
  3. **不要注入 IOptions<T>**:业务服务构造函数中必须注入 IOptionsMonitor<T>。若注入了静态的 IOptions<T> 或只读一次的 IOptionsSnapshot<T>(在 Singleton 中它也是静态的),将永远无法拿到运行时的动态更新。
上一篇