You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

416 lines
17 KiB
C#

// ============================================================================
// 【文件说明】HwSearchSchemaService.cs - 搜索表结构初始化服务
// ============================================================================
// 这个服务负责自动创建搜索索引表和索引。
//
// 【为什么需要自动建表?】
// 1. 开发环境:快速启动,不需要手动执行 SQL 脚本
// 2. 测试环境:每次测试前可以重建干净的表
// 3. CI/CD自动化部署时自动创建表结构
//
// 【设计模式 - 单例初始化】
// 这个服务使用"延迟初始化 + 双重检查锁"模式:
// 1. 第一次调用时才创建表
// 2. 使用锁保证线程安全
// 3. 使用 volatile 标记避免指令重排
//
// 【与 Java Spring Boot 的对比】
// Java 通常用 Flyway 或 Liquibase 做数据库迁移:
// @Bean
// public Flyway flyway() {
// return Flyway.configure()
// .locations("db/migration")
// .dataSource(dataSource)
// .load();
// }
//
// C# 这里用代码直接建表,更简单但不适合复杂迁移场景。
// ============================================================================
using System.Data;
using System.Data.Common;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Options;
namespace Admin.NET.Plugin.HwPortal;
/// <summary>
/// 搜索表结构初始化服务。
/// <para>
/// 【服务职责】
/// 1. 检查搜索表是否存在
/// 2. 创建搜索表(如果不存在)
/// 3. 创建索引(唯一索引、普通索引、全文索引)
/// </para>
/// <para>
/// 【C# 语法知识点 - sealed 密封类 + ITransient】
/// sealed + ITransient 的组合表示:
/// - 这是一个瞬态服务(每次请求创建新实例)
/// - 不能被继承(不需要扩展)
///
/// 对比 Java
/// Java 通常用 @Service + @Scope("prototype") 实现类似效果。
/// </para>
/// </summary>
public sealed class HwSearchSchemaService : IHwSearchSchemaService, ITransient
{
/// <summary>
/// 表名常量。
/// <para>
/// 【C# 语法知识点 - const 常量】
/// const 是编译期常量,值在编译时就确定了。
/// 编译器会把常量值直接嵌入调用处。
/// </para>
/// </summary>
private const string TableName = "hw_portal_search_doc";
/// <summary>
/// 初始化锁。
/// <para>
/// 【C# 语法知识点 - SemaphoreSlim 信号量】
/// SemaphoreSlim 是轻量级信号量,用于异步场景的并发控制。
///
/// SemaphoreSlim(1, 1) 表示:
/// - 初始计数1允许 1 个线程进入)
/// - 最大计数1最多允许 1 个线程同时进入)
///
/// 这是一个"互斥锁"Mutex保证同一时间只有一个线程能执行初始化。
///
/// 对比 Java
/// Java 用 ReentrantLock 或 synchronized
/// private static final ReentrantLock lock = new ReentrantLock();
/// lock.lock();
/// try { ... } finally { lock.unlock(); }
///
/// C# 的 SemaphoreSlim 支持 async/await更适合异步编程。
/// </para>
/// <para>
/// 【为什么用 static readonly
/// - static所有实例共享同一个锁
/// - readonly只能在声明时或构造函数中赋值
///
/// 这样保证整个应用只有一个锁,所有线程都竞争同一个锁。
/// </para>
/// </summary>
private static readonly SemaphoreSlim InitLock = new(1, 1);
/// <summary>
/// 初始化完成标记。
/// <para>
/// 【C# 语法知识点 - volatile 关键字】
/// volatile 表示"易变字段",告诉编译器:
/// 1. 不要优化这个字段的访问(如缓存到寄存器)
/// 2. 每次读写都直接操作内存
/// 3. 避免指令重排
///
/// 为什么需要 volatile
/// 在多线程环境下,一个线程修改了 _initialized其他线程要能立即看到。
///
/// 对比 Java
/// Java 也有 volatile 关键字,含义完全一样:
/// private static volatile boolean initialized = false;
/// </para>
/// </summary>
private static volatile bool _initialized;
/// <summary>
/// 数据库上下文。
/// </summary>
private readonly HwPortalSearchDbContext _db;
/// <summary>
/// 搜索配置选项。
/// <para>
/// 【C# 语法知识点 - IOptions&lt;T&gt; 模式】
/// IOptions&lt;T&gt; 是 ASP.NET Core 的配置模式:
/// 1. 在 Startup 中注册配置
/// 2. 通过依赖注入获取 IOptions&lt;T&gt;
/// 3. .Value 获取配置实例
///
/// 对比 Java Spring Boot
/// Java 通常直接注入配置类:
/// @Autowired
/// private HwPortalSearchProperties properties;
///
/// C# 的 IOptions 模式更灵活,支持配置热更新。
/// </para>
/// </summary>
private readonly HwPortalSearchOptions _options;
/// <summary>
/// 构造函数(依赖注入)。
/// </summary>
/// <param name="db">数据库上下文</param>
/// <param name="options">配置选项</param>
public HwSearchSchemaService(HwPortalSearchDbContext db, IOptions<HwPortalSearchOptions> options)
{
_db = db;
_options = options.Value;
}
/// <summary>
/// 确保搜索表已创建。
/// <para>
/// 【双重检查锁模式】
/// 这是经典的线程安全单例初始化模式:
///
/// 1. 第一次检查无锁if (!_options.AutoInitSchema || _initialized)
/// - 快速路径:如果已初始化,直接返回,不获取锁
/// - 性能优化:避免每次都获取锁
///
/// 2. 获取锁await InitLock.WaitAsync(cancellationToken)
/// - 保证同一时间只有一个线程能执行初始化
///
/// 3. 第二次检查有锁if (_initialized)
/// - 防止重复初始化
/// - 场景:线程 A 和 B 同时通过第一次检查A 先获取锁并初始化完成,
/// B 获取锁后要再次检查是否已初始化
///
/// 对比 Java
/// Java 的双重检查锁写法类似:
/// if (!initialized) {
/// synchronized (lock) {
/// if (!initialized) {
/// // 初始化
/// initialized = true;
/// }
/// }
/// }
///
/// C# 的异步版本需要用 SemaphoreSlim 替代 lock。
/// </para>
/// </summary>
/// <param name="cancellationToken">取消令牌</param>
public async Task EnsureCreatedAsync(CancellationToken cancellationToken = default)
{
// 【第一次检查 - 快速路径】
// 如果配置关闭了自动初始化,或者已经初始化完成,直接返回。
if (!_options.AutoInitSchema || _initialized)
{
return;
}
// 【获取锁】
// WaitAsync 会阻塞当前线程,直到获取锁。
// cancellationToken 支持取消等待。
await InitLock.WaitAsync(cancellationToken);
try
{
// 【第二次检查 - 防止重复初始化】
if (_initialized)
{
return;
}
// 【执行建表 SQL】
// ExecuteSqlRawAsync 执行原生 SQL 语句。
// """...""" 是 C# 11 的"原始字符串字面量"语法:
// - 三个双引号开始和结束
// - 内部可以包含换行、引号等特殊字符
// - 不需要转义
//
// 对比 Java
// Java 需要用字符串拼接或文本块Java 15+
// String sql = """
// CREATE TABLE IF NOT EXISTS ...
// """;
//
// C# 的原始字符串更灵活,可以控制缩进。
await _db.Database.ExecuteSqlRawAsync(
"""
CREATE TABLE IF NOT EXISTS `hw_portal_search_doc` (
`id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '',
`doc_id` VARCHAR(128) NOT NULL COMMENT '',
`source_type` VARCHAR(32) NOT NULL COMMENT '',
`biz_id` VARCHAR(64) NULL COMMENT '',
`title` VARCHAR(500) NULL COMMENT '',
`content` LONGTEXT NULL COMMENT '',
`web_code` VARCHAR(64) NULL COMMENT '',
`type_id` VARCHAR(64) NULL COMMENT 'ID',
`device_id` VARCHAR(64) NULL COMMENT 'ID',
`menu_id` VARCHAR(64) NULL COMMENT 'ID',
`document_id` VARCHAR(64) NULL COMMENT 'ID',
`base_score` INT NOT NULL DEFAULT 0 COMMENT '',
`route` VARCHAR(255) NULL COMMENT '',
`route_query_json` JSON NULL COMMENT '',
`edit_route` VARCHAR(255) NULL COMMENT '',
`is_delete` CHAR(1) NOT NULL DEFAULT '0' COMMENT '',
`updated_at` DATETIME NULL COMMENT '',
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '',
`modified_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '',
PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='hw-portal ';
""",
cancellationToken);
// 【创建索引】
// 索引可以加速查询,但需要单独创建。
// EnsureIndexAsync 方法会检查索引是否存在,不存在才创建。
await EnsureIndexAsync("uk_hw_portal_search_doc_doc_id",
"ALTER TABLE `hw_portal_search_doc` ADD UNIQUE KEY `uk_hw_portal_search_doc_doc_id` (`doc_id`);",
cancellationToken);
await EnsureIndexAsync("idx_hw_portal_search_doc_source_type",
"ALTER TABLE `hw_portal_search_doc` ADD KEY `idx_hw_portal_search_doc_source_type` (`source_type`);",
cancellationToken);
await EnsureIndexAsync("idx_hw_portal_search_doc_updated_at",
"ALTER TABLE `hw_portal_search_doc` ADD KEY `idx_hw_portal_search_doc_updated_at` (`updated_at`);",
cancellationToken);
// 【全文索引】
// FULLTEXT 索引用于全文搜索,支持 LIKE '%keyword%' 查询优化。
// 只有 MyISAM 和 InnoDBMySQL 5.6+)引擎支持全文索引。
await EnsureIndexAsync("ft_hw_portal_search_doc_title_content",
"ALTER TABLE `hw_portal_search_doc` ADD FULLTEXT KEY `ft_hw_portal_search_doc_title_content` (`title`, `content`);",
cancellationToken);
// 【标记初始化完成】
// 必须在所有操作完成后才设置,否则其他线程可能提前返回。
_initialized = true;
}
finally
{
// 【释放锁】
// finally 保证无论成功还是异常,锁都会被释放。
// 如果不释放,后续所有请求都会被阻塞。
InitLock.Release();
}
}
/// <summary>
/// 确保索引存在(不存在则创建)。
/// </summary>
/// <param name="indexName">索引名称</param>
/// <param name="ddl">创建索引的 DDL 语句</param>
/// <param name="cancellationToken">取消令牌</param>
private async Task EnsureIndexAsync(string indexName, string ddl, CancellationToken cancellationToken)
{
// 先检查索引是否存在。
long count = await GetIndexCountAsync(indexName, cancellationToken);
if (count > 0)
{
// 索引已存在,跳过创建。
return;
}
// 索引不存在,执行创建语句。
await _db.Database.ExecuteSqlRawAsync(ddl, cancellationToken);
}
/// <summary>
/// 查询索引是否存在。
/// <para>
/// 【查询方式】
/// 通过查询 information_schema.statistics 表判断索引是否存在:
/// - information_schema 是 MySQL 的元数据库
/// - statistics 表存储了所有表和索引的信息
///
/// 对比 Java JDBC
/// Java 通常用 DatabaseMetaData 获取索引信息:
/// DatabaseMetaData metaData = connection.getMetaData();
/// ResultSet indexes = metaData.getIndexInfo(null, null, "hw_portal_search_doc", false, false);
/// </para>
/// </summary>
/// <param name="indexName">索引名称</param>
/// <param name="cancellationToken">取消令牌</param>
/// <returns>索引数量0 表示不存在)</returns>
private async Task<long> GetIndexCountAsync(string indexName, CancellationToken cancellationToken)
{
// 【获取底层 ADO.NET 连接】
// _db.Database.GetDbConnection() 获取 EF Core 包装的底层 DbConnection。
// 有时需要直接使用 ADO.NET API如执行原生 SQL、获取元数据等。
DbConnection connection = _db.Database.GetDbConnection();
// 【连接状态检查】
// 如果连接未打开,需要手动打开。
// EF Core 通常会自动管理连接,但这里我们需要确保连接可用。
bool shouldClose = connection.State != ConnectionState.Open;
if (shouldClose)
{
await connection.OpenAsync(cancellationToken);
}
try
{
// 【创建命令】
// await using 是 C# 8 的"异步 using"语法。
// 会自动调用 DisposeAsync适合实现了 IAsyncDisposable 的对象。
//
// 对比 Java
// Java 用 try-with-resources
// try (PreparedStatement stmt = connection.prepareStatement(sql)) {
// ...
// }
await using DbCommand command = connection.CreateCommand();
command.CommandText =
"""
SELECT COUNT(1)
FROM information_schema.statistics
WHERE table_schema = DATABASE()
AND table_name = @tableName
AND index_name = @indexName;
""";
// 【添加参数】
// 使用参数化查询,防止 SQL 注入。
AddParameter(command, "@tableName", TableName);
AddParameter(command, "@indexName", indexName);
// 【执行查询】
// ExecuteScalarAsync 返回结果集的第一行第一列。
// 这里返回的是 COUNT(1) 的结果,即索引数量。
object result = await command.ExecuteScalarAsync(cancellationToken);
// 【结果转换】
// result 可能是 null 或 DBNull理论上不应该但防御性编程
// Convert.ToInt64 可以处理多种数值类型int, long, decimal 等)。
return result == null || result == DBNull.Value ? 0 : Convert.ToInt64(result);
}
finally
{
// 【关闭连接】
// 如果是我们打开的连接,用完后要关闭。
// 如果连接本来就是打开的,不要关闭(可能是事务中的连接)。
if (shouldClose)
{
await connection.CloseAsync();
}
}
}
/// <summary>
/// 添加命令参数。
/// <para>
/// 【C# 语法知识点 - static 静态方法】
/// private static void AddParameter(...)
///
/// 静态方法不依赖实例状态,可以直接调用。
/// 这个方法是工具方法,不需要访问实例成员,所以声明为 static。
///
/// 对比 Java
/// Java 的静态方法语法完全一样。
/// </para>
/// </summary>
/// <param name="command">数据库命令</param>
/// <param name="name">参数名</param>
/// <param name="value">参数值</param>
private static void AddParameter(DbCommand command, string name, object value)
{
// 【创建参数】
// CreateParameter() 创建一个参数对象。
// 不同数据库提供者的参数类型不同,但都继承自 DbParameter。
DbParameter parameter = command.CreateParameter();
parameter.ParameterName = name;
// 【空值处理】
// value ?? DBNull.Value 表示:
// - 如果 value 不为 null使用 value
// - 如果 value 为 null使用 DBNull.Value
//
// 数据库不能直接存储 C# 的 null需要用 DBNull.Value 表示数据库的 NULL。
parameter.Value = value ?? DBNull.Value;
// 【添加参数到命令】
command.Parameters.Add(parameter);
}
}