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();
    }
}
⚠⚠    以下内容为AI分析的结果,请根据实际情况进行判断。

MongoIndexInitializer.cs 分析报告

文件职责

MongoIndexInitializer.cs 是 MongoDB 索引的"代码优先(Code First)"同步器。它的职责是:

  1. 读取实体类型通过 IIndexedEntity<T> 静态抽象成员声明的索引定义(见 IIndexedEntity.csEntityIndexDefinition.cs);
  2. 与 MongoDB 集合上已存在的实际索引做比对;
  3. 缺失则创建、同名但定义漂移(Drift)则删除重建、完全一致则跳过;
  4. 通过 ConcurrentDictionary<Type, Task> 保证每个实体类型在进程生命周期内只同步一次,并在此之上实现失败重试。

它实现接口 IMongoIndexInitializer.cs,是入口契约的落地实现。

与相关文件的协作关系

相关文件协作方式
IIndexedEntity.cs定义了 static abstract IReadOnlyList<EntityIndexDefinition> GetIndexDefinitions(),实体实现它即可声明索引(例如 Article.csTimeline.csSitemapDocument.cs 中均有 ExpireAfter/TextWeights/DefaultLanguage 用法)
EntityIndexDefinition.cs索引定义结构体,含 NameFieldsUniqueTextWeightsDefaultLanguageExpireAfter,以及 GetResolvedName() 名称解析(未命名时按 MongoDB 风格生成 字段_方向 组合名)
EntityIndexField.csIndexKeyType.csIndexSortDirection.cs字段级定义:普通升/降序(1/-1)与文本索引的枚举与辅助工厂
MongodbAccess.csSynchronizeForTypeAsync 按运行时类型走反射泛型后,用连接串构造 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 写入 WeightsDefaultLanguage 直接透传。
  • 索引名统一使用定义解析名 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 中对"静态抽象成员模式"的设计说明)。
评论加载中...