using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Diagnostics;
using System.Linq;
using System.Reflection;
using System.Threading;
using System.Threading.Tasks;
using Dpz.Core.Entity.Base;
using Dpz.Core.Entity.Base.Indexes;
using Microsoft.Extensions.Logging;
using MongoDB.Bson;
using MongoDB.Driver;
namespace Dpz.Core.MongodbAccess;
/// <summary>
/// MongoDB 索引初始化器,根据实体上的 <see cref="IIndexedEntity{T}"/> 定义自动创建索引。
/// 每个实体类型在应用生命周期内只执行一次索引检查。
/// </summary>
public sealed class MongoIndexInitializer(ILogger<MongoIndexInitializer> logger)
: IMongoIndexInitializer
{
private readonly ConcurrentDictionary<Type, Task> _initializedTypes = new();
/// <summary>
/// 同步实体对应的集合索引与代码定义(每个类型只执行一次)
/// </summary>
public Task SynchronizeAsync<T>(
IMongoCollection<T> collection,
CancellationToken cancellationToken = default
)
where T : IBaseEntity
{
return _initializedTypes.GetOrAdd(
typeof(T),
_ => SynchronizeCoreAsync(collection, cancellationToken)
);
}
/// <summary>
/// 根据运行时类型同步索引(用于启动时批量初始化)
/// </summary>
public Task SynchronizeForTypeAsync(
Type entityType,
string connectionString,
CancellationToken cancellationToken = default
)
{
return _initializedTypes.GetOrAdd(
entityType,
_ =>
{
var method = GetType()
.GetMethod(
nameof(SynchronizeForTypeCoreAsync),
BindingFlags.NonPublic | BindingFlags.Instance
)!
.MakeGenericMethod(entityType);
return (Task)method.Invoke(this, [connectionString, cancellationToken])!;
}
);
}
public Task ResetAsync(CancellationToken cancellationToken = default)
{
cancellationToken.ThrowIfCancellationRequested();
_initializedTypes.Clear();
logger.LogInformation("MongoDB 索引初始化状态已重置");
return Task.CompletedTask;
}
private Task SynchronizeForTypeCoreAsync<T>(
string connectionString,
CancellationToken cancellationToken
)
where T : IBaseEntity
{
var access = new MongodbAccess<T>(connectionString);
return SynchronizeCoreAsync(access.Collection, cancellationToken);
}
private async Task SynchronizeCoreAsync<T>(
IMongoCollection<T> collection,
CancellationToken cancellationToken
)
where T : IBaseEntity
{
try
{
var totalStopwatch = Stopwatch.StartNew();
var definitions = GetIndexDefinitions<T>();
if (definitions is not { Count: > 0 })
{
return;
}
var existingIndexes = await (
await collection.Indexes.ListAsync(cancellationToken)
).ToListAsync(cancellationToken);
var existingIndexMap = existingIndexes
.Where(static index => index.TryGetValue("name", out _))
.ToDictionary(
static index => index["name"].AsString,
static index => index,
StringComparer.OrdinalIgnoreCase
);
var createdCount = 0;
var droppedCount = 0;
var matchedCount = 0;
foreach (var def in definitions)
{
var resolvedName = def.GetResolvedName();
var model = BuildIndexModel<T>(def, resolvedName);
if (model == null)
{
continue;
}
if (existingIndexMap.TryGetValue(resolvedName, out var existing))
{
if (IsIndexMatched(existing, def))
{
matchedCount++;
continue;
}
// 同名索引定义发生漂移时,遵循代码优先,删除并重建。
var dropStopwatch = Stopwatch.StartNew();
await collection.Indexes.DropOneAsync(resolvedName, cancellationToken);
dropStopwatch.Stop();
droppedCount++;
logger.LogInformation(
"删除索引完成: {EntityType}.{IndexName}, 耗时 {ElapsedMs}ms",
typeof(T).Name,
resolvedName,
dropStopwatch.ElapsedMilliseconds
);
}
var createStopwatch = Stopwatch.StartNew();
await collection.Indexes.CreateOneAsync(
model,
cancellationToken: cancellationToken
);
createStopwatch.Stop();
createdCount++;
logger.LogInformation(
"创建索引完成: {EntityType}.{IndexName}, 耗时 {ElapsedMs}ms",
typeof(T).Name,
resolvedName,
createStopwatch.ElapsedMilliseconds
);
}
totalStopwatch.Stop();
logger.LogInformation(
"索引初始化完成: {EntityType}, 定义={DefinedCount}, 已匹配={MatchedCount}, 删除={DroppedCount}, 创建={CreatedCount}, 总耗时={ElapsedMs}ms",
typeof(T).Name,
definitions.Count,
matchedCount,
droppedCount,
createdCount,
totalStopwatch.ElapsedMilliseconds
);
}
catch (Exception e)
{
// 失败时移除已缓存的 Task,允许下次重试
_initializedTypes.TryRemove(typeof(T), out _);
logger.LogError(e, "索引同步失败: {EntityType}", typeof(T).Name);
}
}
private CreateIndexModel<T>? BuildIndexModel<T>(EntityIndexDefinition def, string resolvedName)
where T : IBaseEntity
{
IndexKeysDefinition<T>? keys = null;
foreach (var field in def.Fields)
{
IndexKeysDefinition<T> key = field.KeyType switch
{
IndexKeyType.Text => Builders<T>.IndexKeys.Text(field.FieldName),
_ => field.Direction == IndexSortDirection.Descending
? Builders<T>.IndexKeys.Descending(field.FieldName)
: Builders<T>.IndexKeys.Ascending(field.FieldName),
};
keys = keys == null ? key : Builders<T>.IndexKeys.Combine(keys, key);
}
if (keys == null)
{
return null;
}
var options = new CreateIndexOptions { Name = resolvedName, Unique = def.Unique };
// TTL 索引必须是单字段普通索引,且字段为 BSON Date 类型
if (def.ExpireAfter is { } expireAfter)
{
if (def.Fields.Count != 1 || def.Fields[0].KeyType != IndexKeyType.Standard)
{
logger.LogWarning(
"索引定义无效: {IndexName} 的 ExpireAfter 仅支持单字段普通索引",
resolvedName
);
return null;
}
options.ExpireAfter = expireAfter;
}
// 文本索引权重
if (def.TextWeights is { Count: > 0 })
{
var weightsDoc = new BsonDocument();
foreach (var (fieldName, weight) in def.TextWeights)
{
weightsDoc[fieldName] = weight;
}
options.Weights = weightsDoc;
}
if (!string.IsNullOrEmpty(def.DefaultLanguage))
{
options.DefaultLanguage = def.DefaultLanguage;
}
return new CreateIndexModel<T>(keys, options);
}
private static bool IsIndexMatched(BsonDocument existing, EntityIndexDefinition def)
{
var hasTextField = def.Fields.Any(static field => field.KeyType == IndexKeyType.Text);
if (!MatchesKeyDefinition(existing, def.Fields, hasTextField))
{
return false;
}
var existingUnique =
existing.TryGetValue("unique", out var uniqueValue) && uniqueValue.ToBoolean();
if (existingUnique != def.Unique)
{
return false;
}
var existingExpireAfterSeconds = existing.TryGetValue(
"expireAfterSeconds",
out var expireAfterValue
)
? expireAfterValue.ToInt64()
: (long?)null;
var expectedExpireAfterSeconds = def.ExpireAfter is { } expireAfter
? (long)expireAfter.TotalSeconds
: (long?)null;
if (existingExpireAfterSeconds != expectedExpireAfterSeconds)
{
return false;
}
if (!hasTextField)
{
return true;
}
if (def.TextWeights is { Count: > 0 } && !MatchesTextWeights(existing, def.TextWeights))
{
return false;
}
if (!string.IsNullOrWhiteSpace(def.DefaultLanguage))
{
if (!existing.TryGetValue("default_language", out var languageValue))
{
return false;
}
if (
!string.Equals(
languageValue.AsString,
def.DefaultLanguage,
StringComparison.Ordinal
)
)
{
return false;
}
}
return true;
}
private static bool MatchesKeyDefinition(
BsonDocument existing,
IReadOnlyList<EntityIndexField> fields,
bool hasTextField
)
{
if (!existing.TryGetValue("key", out var keyValue) || keyValue is not BsonDocument keyDoc)
{
return false;
}
if (hasTextField)
{
return keyDoc.TryGetValue("_fts", out var textType) && textType == "text";
}
if (keyDoc.ElementCount != fields.Count)
{
return false;
}
for (var index = 0; index < fields.Count; index++)
{
var expectedField = fields[index];
var element = keyDoc.GetElement(index);
if (!string.Equals(element.Name, expectedField.FieldName, StringComparison.Ordinal))
{
return false;
}
var expectedDirection = (int)expectedField.Direction;
if (!int.TryParse(element.Value.ToString(), out var existingDirection))
{
return false;
}
if (existingDirection != expectedDirection)
{
return false;
}
}
return true;
}
private static bool MatchesTextWeights(
BsonDocument existing,
IReadOnlyDictionary<string, int> expectedWeights
)
{
if (
!existing.TryGetValue("weights", out var weightsValue)
|| weightsValue is not BsonDocument weights
)
{
return false;
}
if (weights.ElementCount != expectedWeights.Count)
{
return false;
}
foreach (var (fieldName, expectedWeight) in expectedWeights)
{
if (!weights.TryGetValue(fieldName, out var weightValue))
{
return false;
}
if (!int.TryParse(weightValue.ToString(), out var actualWeight))
{
return false;
}
if (actualWeight != expectedWeight)
{
return false;
}
}
return true;
}
private static IReadOnlyList<EntityIndexDefinition>? GetIndexDefinitions<T>()
where T : IBaseEntity
{
if (!typeof(IIndexedEntity<T>).IsAssignableFrom(typeof(T)))
{
return null;
}
// 通过带完整约束的辅助方法调用 static abstract 接口方法
var method = typeof(MongoIndexInitializer)
.GetMethod(nameof(GetDefinitionsCore), BindingFlags.NonPublic | BindingFlags.Static)!
.MakeGenericMethod(typeof(T));
return (IReadOnlyList<EntityIndexDefinition>?)method.Invoke(null, null);
}
private static IReadOnlyList<EntityIndexDefinition> GetDefinitionsCore<T>()
where T : IBaseEntity, IIndexedEntity<T>
{
return T.GetIndexDefinitions();
}
}
MongoIndexInitializer.cs 分析报告
文件职责
MongoIndexInitializer.cs 是 MongoDB 索引的"代码优先(Code First)"同步器。它的职责是:
- 读取实体类型通过
IIndexedEntity<T>静态抽象成员声明的索引定义(见 IIndexedEntity.cs 与 EntityIndexDefinition.cs); - 与 MongoDB 集合上已存在的实际索引做比对;
- 缺失则创建、同名但定义漂移(Drift)则删除重建、完全一致则跳过;
- 通过
ConcurrentDictionary<Type, Task>保证每个实体类型在进程生命周期内只同步一次,并在此之上实现失败重试。
它实现接口 IMongoIndexInitializer.cs,是入口契约的落地实现。
与相关文件的协作关系
| 相关文件 | 协作方式 |
|---|---|
| IIndexedEntity.cs | 定义了 static abstract IReadOnlyList<EntityIndexDefinition> GetIndexDefinitions(),实体实现它即可声明索引(例如 Article.cs、Timeline.cs、SitemapDocument.cs 中均有 ExpireAfter/TextWeights/DefaultLanguage 用法) |
| EntityIndexDefinition.cs | 索引定义结构体,含 Name、Fields、Unique、TextWeights、DefaultLanguage、ExpireAfter,以及 GetResolvedName() 名称解析(未命名时按 MongoDB 风格生成 字段_方向 组合名) |
| EntityIndexField.cs、IndexKeyType.cs、IndexSortDirection.cs | 字段级定义:普通升/降序(1/-1)与文本索引的枚举与辅助工厂 |
| MongodbAccess.cs | SynchronizeForTypeAsync 按运行时类型走反射泛型后,用连接串构造 MongodbAccess<T> 拿到其 Collection 属性 |
| MongoIndexInitializerService.cs | 应用启动的后台托管服务,扫描 Dpz.Core.Public.Entity 中所有 IIndexedEntity<> 实现,逐类型调用 SynchronizeForTypeAsync;停止时调用 ResetAsync。其在 ServiceExtensions.cs 中被注册为单例 IMongoIndexInitializer |
| IMongoIndexInitializer.cs | 定义三个公开入口 |
说明:公开接口中的泛型方法 SynchronizeAsync<T>(接收调用方传入的 IMongoCollection<T>)在当前代码库中未检索到实际调用方,目前被调用的入口是托管服务使用的 SynchronizeForTypeAsync。
核心逻辑拆解
1. 每个类型只同步一次 + 失败可重试
两个公开方法都以 _initializedTypes.GetOrAdd(typeof(T), ...) 返回一个共享的 Task:并发调用同一类型只会真正执行一次同步,其余调用复用该 Task。关键细节:
SynchronizeCoreAsync内部有try/catch,失败不会让缓存的 Task 变成 Faulted(异常被吞掉只记日志),而是先把typeof(T)从字典移除——这样应用后续再次触发同类型同步时可以重试。- 注意:被缓存的 Task 捕获的是第一个调用方的
CancellationToken(值工厂闭包了首次传入的 token),后续调用方即使传不同 token 也无法取消已开始的那次同步。
2. 运行时类型 → 泛型的反射桥
SynchronizeForTypeAsync 接收的是 Type 而非泛型参数,因此通过 MakeGenericMethod 反射调用私有的 SynchronizeForTypeCoreAsync<T>,内部用连接串构造 MongodbAccess.cs 中的 MongodbAccess<T>(集合名约定为 typeof(T).Name,Mongo 客户端按连接串缓存复用)。
3. 调用 static abstract 接口成员的两段式反射
GetIndexDefinitions<T> 先用 typeof(IIndexedEntity<T>).IsAssignableFrom(typeof(T)) 判断实体是否实现了索引接口(不实现则返回 null,同步时直接跳过),再反射定位带完整约束 where T : IBaseEntity, IIndexedEntity<T> 的静态助手 GetDefinitionsCore<T>,由其调用静态抽象成员 T.GetIndexDefinitions()。这是为了绕过"只有约束完整才能调用 static abstract 成员"的编译期限制。
4. 同步/比对/漂移重建
SynchronizeCoreAsync<T> 的完整流程:
flowchart TD
A[开始同步类型 T] --> B[Stopwatch 启动]
B --> C{GetIndexDefinitions 有定义?}
C -- 否 --> Z[结束,无操作]
C -- 是 --> D[ListAsync 拉取服务器现有索引]
D --> E[按索引名构造 Map<br/>OrdinalIgnoreCase 忽略大小写]
E --> F{遍历每条代码定义}
F --> G{同名索引已存在?}
G -- 否 --> H[CreateOneAsync 创建索引<br/>createdCount++]
G -- 是 --> I{IsIndexMatched 一致?}
I -- 一致 --> J[matchedCount++ 跳过]
I -- 漂移 --> K[DropOneAsync 删除同名索引]
K --> H
H --> L[输出统计日志<br/>定义/匹配/删除/创建/总耗时]
J --> L
L --> Z
Z --> M{同步过程抛异常?}
M -- 是 --> N[TryRemove 缓存条目 允许重试<br/>LogError]
M -- 否 --> O[缓存保留 Task 不再执行]
5. 索引模型构建(BuildIndexModel<T>)
- 普通字段按
Direction生成Ascending/Descending;文本字段生成IndexKeys.Text;多字段用Combine。 - 空字段集返回
null(跳过)。 - TTL 限制:只有"单字段 + 普通类型"才允许设置
ExpireAfter,否则记录警告并放弃该条定义。 - 文本索引的
TextWeights转成BsonDocument写入Weights,DefaultLanguage直接透传。 - 索引名统一使用定义解析名
GetResolvedName()。
6. 一致性判定(IsIndexMatched 及配套方法)
- 文本索引只看 key 文档中
_fts == "text";普通/复合索引要求字段个数、字段名、每个方向数值(1/-1)完全一致(方向直接取枚举数值)。 - 比较
unique布尔值。 - 比较
expireAfterSeconds(代码里把TimeSpan换算成总秒数)。 - 仅当存在文本字段时,进一步比较
weights文档的字段与权重,以及default_language。
值得注意的设计点与潜在问题
- 错误可见性:异常在
SynchronizeCoreAsync内部被捕获,外部拿到的总是"正常完成"的 Task,调用方无法通过 await 感知失败,只能依赖日志与ResetAsync/重试触发机制。托管服务 MongoIndexInitializerService.cs 中的计数逻辑因此依赖日志而非异常传播(它自己还会包一层 try/catch)。 - "代码优先"策略:同一索引名下若定义漂移(如把升序改成降序、去掉 unique),会直接 Drop 后重建,可能造成瞬时无索引窗口;这是该实现有意的取舍,与接口注释"缺失则创建,漂移则重建"一致。
- 依赖了较新的 C# 语言特性:静态抽象接口成员、主构造函数、集合表达式,与仓库中 .NET 10 的目标框架一致(见 Dpz.Core.Entity.Base/README.md 中对"静态抽象成员模式"的设计说明)。