作者:阿Y(yzero) 项目背景:SwitchData 网络配置数据管理系统
在 .NET
项目中,数据库连接字符串往往分散在配置文件中,直接明文存储既不安全,也难以统一管理。SwitchData
项目里的 DbConnectionManager
把连接字符串的读取、解密、缓存、就绪检测打包到一个静态管理类中。本文结合源码,拆解它的设计思路与关键实现细节。
一、类职责与整体结构
DbConnectionManager 的核心职责可以概括为三点:
- 集中存储:以数据库名为 Key,把连接字符串缓存到内存;
- 安全解密:从配置文件读到的字符串默认是 AES 加密后的 Base64,使用前自动解密;
- 就绪检测:在应用启动或首次访问前,通过带重试的异步检测确认数据库可连。
整体结构如下(简化后):
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.ConnectionStrings。Get
方法在找不到连接字符串时,会触发一次回退加载:
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):
- 外层先判断
_initialized == 0 && !_fallbackInitialized,避免大多数情况都进锁; - 内层再判断一次
_fallbackInitialized,保证只有一个线程执行回退加载; _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透传:OpenAsync与Task.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
里,或者每次访问数据库都重新读取配置,不妨参考这套思路做一次集中管理。