// ============================================================================ // 【文件说明】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; /// /// 搜索表结构初始化服务。 /// /// 【服务职责】 /// 1. 检查搜索表是否存在 /// 2. 创建搜索表(如果不存在) /// 3. 创建索引(唯一索引、普通索引、全文索引) /// /// /// 【C# 语法知识点 - sealed 密封类 + ITransient】 /// sealed + ITransient 的组合表示: /// - 这是一个瞬态服务(每次请求创建新实例) /// - 不能被继承(不需要扩展) /// /// 对比 Java: /// Java 通常用 @Service + @Scope("prototype") 实现类似效果。 /// /// public sealed class HwSearchSchemaService : IHwSearchSchemaService, ITransient { /// /// 表名常量。 /// /// 【C# 语法知识点 - const 常量】 /// const 是编译期常量,值在编译时就确定了。 /// 编译器会把常量值直接嵌入调用处。 /// /// private const string TableName = "hw_portal_search_doc"; /// /// 初始化锁。 /// /// 【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,更适合异步编程。 /// /// /// 【为什么用 static readonly?】 /// - static:所有实例共享同一个锁 /// - readonly:只能在声明时或构造函数中赋值 /// /// 这样保证整个应用只有一个锁,所有线程都竞争同一个锁。 /// /// private static readonly SemaphoreSlim InitLock = new(1, 1); /// /// 初始化完成标记。 /// /// 【C# 语法知识点 - volatile 关键字】 /// volatile 表示"易变字段",告诉编译器: /// 1. 不要优化这个字段的访问(如缓存到寄存器) /// 2. 每次读写都直接操作内存 /// 3. 避免指令重排 /// /// 为什么需要 volatile? /// 在多线程环境下,一个线程修改了 _initialized,其他线程要能立即看到。 /// /// 对比 Java: /// Java 也有 volatile 关键字,含义完全一样: /// private static volatile boolean initialized = false; /// /// private static volatile bool _initialized; /// /// 数据库上下文。 /// private readonly HwPortalSearchDbContext _db; /// /// 搜索配置选项。 /// /// 【C# 语法知识点 - IOptions<T> 模式】 /// IOptions<T> 是 ASP.NET Core 的配置模式: /// 1. 在 Startup 中注册配置 /// 2. 通过依赖注入获取 IOptions<T> /// 3. .Value 获取配置实例 /// /// 对比 Java Spring Boot: /// Java 通常直接注入配置类: /// @Autowired /// private HwPortalSearchProperties properties; /// /// C# 的 IOptions 模式更灵活,支持配置热更新。 /// /// private readonly HwPortalSearchOptions _options; /// /// 构造函数(依赖注入)。 /// /// 数据库上下文 /// 配置选项 public HwSearchSchemaService(HwPortalSearchDbContext db, IOptions options) { _db = db; _options = options.Value; } /// /// 确保搜索表已创建。 /// /// 【双重检查锁模式】 /// 这是经典的线程安全单例初始化模式: /// /// 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。 /// /// /// 取消令牌 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 和 InnoDB(MySQL 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(); } } /// /// 确保索引存在(不存在则创建)。 /// /// 索引名称 /// 创建索引的 DDL 语句 /// 取消令牌 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); } /// /// 查询索引是否存在。 /// /// 【查询方式】 /// 通过查询 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); /// /// /// 索引名称 /// 取消令牌 /// 索引数量(0 表示不存在) private async Task 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(); } } } /// /// 添加命令参数。 /// /// 【C# 语法知识点 - static 静态方法】 /// private static void AddParameter(...) /// /// 静态方法不依赖实例状态,可以直接调用。 /// 这个方法是工具方法,不需要访问实例成员,所以声明为 static。 /// /// 对比 Java: /// Java 的静态方法语法完全一样。 /// /// /// 数据库命令 /// 参数名 /// 参数值 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); } }