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