作者:阿Y(yzero) 项目背景:SwitchData 网络配置数据管理系统

在 .NET 项目中,数据库连接字符串往往分散在配置文件中,直接明文存储既不安全,也难以统一管理。SwitchData 项目里的 DbConnectionManager 把连接字符串的读取、解密、缓存、就绪检测打包到一个静态管理类中。本文结合源码,拆解它的设计思路与关键实现细节。

一、类职责与整体结构

DbConnectionManager 的核心职责可以概括为三点:

  1. 集中存储:以数据库名为 Key,把连接字符串缓存到内存;
  2. 安全解密:从配置文件读到的字符串默认是 AES 加密后的 Base64,使用前自动解密;
  3. 就绪检测:在应用启动或首次访问前,通过带重试的异步检测确认数据库可连。

整体结构如下(简化后):

public static class DbConnectionManager
{
    private static readonly ConcurrentDictionary<string, string> _connectionStrings = new(StringComparer.OrdinalIgnoreCase);
    private static int _initialized = 0;
    private static readonly object _fallbackLock = new();
    private static volatile bool _fallbackInitialized = false;

    public static void Init(IConfiguration configuration) { ... }
    public static string Get(string databaseName) { ... }
    public static async Task WaitForReadyAsync(...) { ... }
}

下面逐个拆解关键实现。

二、加密连接字符串:从配置文件到明文

配置文件里的连接字符串不宜直接放明文。SwitchData 在写入配置前会先用 SymmetricCryptographer.Encrypt 进行 AES 加密,再转 Base64。读取时,Add 方法负责反向解密:

private static string Add(string databaseName, string connectionString)
{
    ArgumentException.ThrowIfNullOrWhiteSpace(databaseName);
    ArgumentException.ThrowIfNullOrWhiteSpace(connectionString);

    var decrypted = SymmetricCryptographer.Decrypt(connectionString);
    _connectionStrings[databaseName] = decrypted;
    return decrypted;
}

这里有两个细节值得注意:

  • 解密后立即缓存明文:解密操作只执行一次,后续所有数据库访问都直接读缓存,避免重复加解密开销;
  • ConcurrentDictionary 的线程安全赋值indexer 赋值是原子操作,多个线程同时调用 Add 时,最终写入的是某个线程的解密结果,不会破坏字典内部结构。

三、并发安全的一次性初始化

Init 方法负责从 IConfiguration 批量加载连接字符串。如果项目里有多处调用 Init 的风险,需要用原子操作保证只执行一次:

public static void Init(IConfiguration configuration)
{
    if (Interlocked.Exchange(ref _initialized, 1) == 1)
    {
        return;
    }

    var section = configuration.GetSection("ConnectionStrings");
    foreach (var child in section.GetChildren())
    {
        if (!string.IsNullOrWhiteSpace(child.Key) && !string.IsNullOrWhiteSpace(child.Value))
        {
            Add(child.Key, child.Value);
        }
    }
}

Interlocked.Exchange(ref _initialized, 1) 会返回旧值。只有第一个调用者得到 0,后续调用者都得到 1,从而自然跳过初始化逻辑。相比 lock,这种方式更轻量,也避免了锁对象的生命周期管理。

四、兼容 WinForms / App.config 的回退加载

SwitchData 同时存在 ASP.NET Core 与 WinForms 两种宿主。前者使用 IConfiguration,后者仍然依赖 ConfigurationManager.ConnectionStringsGet 方法在找不到连接字符串时,会触发一次回退加载:

public static string Get(string databaseName)
{
    if (_initialized == 0 && !_fallbackInitialized)
    {
        lock (_fallbackLock)
        {
            if (!_fallbackInitialized)
            {
                LoadFromConfigurationManager();
                _fallbackInitialized = true;
            }
        }
    }

    if (_connectionStrings.TryGetValue(databaseName, out var value))
    {
        return value;
    }

    throw new KeyNotFoundException($"数据库 '{databaseName}' 连接字符串不存在");
}

这里采用了经典的双重检查锁定(DCL):

  1. 外层先判断 _initialized == 0 && !_fallbackInitialized,避免大多数情况都进锁;
  2. 内层再判断一次 _fallbackInitialized,保证只有一个线程执行回退加载;
  3. _fallbackInitialized 声明为 volatile,防止指令重排导致读到半初始化状态。

回退加载时还会过滤掉系统默认的 LocalSqlServer,防止在缺少该连接时抛出不必要的异常:

if (cs.Name.Equals("LocalSqlServer", StringComparison.OrdinalIgnoreCase))
{
    continue;
}

五、数据库就绪检测:带取消的重试机制

应用启动时,数据库可能尚未完全就绪。WaitForReadyAsync 提供了一个可配置的重试检测接口:

public static async Task WaitForReadyAsync(
    string databaseName,
    int retryCount = 10,
    int delaySeconds = 5,
    CancellationToken cancellationToken = default)
{
    ArgumentOutOfRangeException.ThrowIfLessThan(retryCount, 1);
    ArgumentOutOfRangeException.ThrowIfLessThan(delaySeconds, 1);

    var database = DatabaseFactory.CreateDatabase(databaseName);
    using var connection = database.CreateConnection();

    Exception lastException = null;
    for (int i = 0; i < retryCount; i++)
    {
        try
        {
            await connection.OpenAsync(cancellationToken);
            Logger.Info("数据库连接成功");
            return;
        }
        catch (Exception ex)
        {
            lastException = ex;
            Logger.Warn($"数据库未就绪,第 {i + 1}/{retryCount} 次重试:{ex.Message}");
            if (i < retryCount - 1)
            {
                await Task.Delay(TimeSpan.FromSeconds(delaySeconds), cancellationToken);
            }
        }
    }

    throw new Exception($"数据库连接超时(重试 {retryCount} 次,每次间隔 {delaySeconds} 秒)", lastException);
}

设计亮点:

  • using var connection:无论成功还是失败,连接都会被释放,避免连接泄漏;
  • CancellationToken 透传OpenAsyncTask.Delay 都接收 cancellationToken,应用关闭时可以立即终止等待;
  • 最后一次异常向上抛出:如果全部重试失败,把最后一次异常作为 InnerException,方便定位真实原因。

六、使用示例

在 ASP.NET Core 的 Program.cs 中:

var builder = WebApplication.CreateBuilder(args);
DbConnectionManager.Init(builder.Configuration);

// 启动前等待数据库就绪
await DbConnectionManager.WaitForReadyAsync("DefaultDB");

// 后续业务层通过名称获取连接字符串
var connStr = DbConnectionManager.Get("DefaultDB");

在需要支持 WinForms 的场景中,即使不调用 Init,首次 Get 也会自动从 App.config 加载,降低迁移成本。

七、总结

DbConnectionManager 不是一个复杂的类,但它把连接字符串管理中的几个常见问题处理得很干净:

  • ConcurrentDictionary + StringComparer.OrdinalIgnoreCase 实现线程安全、大小写不敏感的缓存;
  • Interlocked.Exchange 实现无锁的一次性初始化;
  • 用双重检查锁定 + volatile 兼容老版本宿主;
  • CancellationToken 透传实现可取消的重试就绪检测。

如果你的项目还在把连接字符串明文写在 appsettings.json 里,或者每次访问数据库都重新读取配置,不妨参考这套思路做一次集中管理。