using System.Collections.Concurrent; using System.Diagnostics; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; using PuppeteerSharp; using HtmlToPdfService.Core.Options; namespace HtmlToPdfService.Core.Pool; /// /// 增强版浏览器池实现 /// 支持自愈、健康检查、内存阈值重启 /// public class BrowserPool : IBrowserPool { private readonly BrowserPoolOptions _options; private readonly ILogger _logger; private readonly ConcurrentBag _availableBrowsers = new(); private readonly ConcurrentDictionary _allBrowsers = new(); private readonly SemaphoreSlim _semaphore; private readonly SemaphoreSlim _createLock = new(1, 1); private readonly Timer _healthCheckTimer; private int _totalInstances = 0; private int _restartCount = 0; private bool _disposed = false; public BrowserPool(IOptions options, ILogger logger) { _options = options.Value.BrowserPool; _logger = logger; _semaphore = new SemaphoreSlim(_options.MaxConcurrent, _options.MaxConcurrent); // 启动健康检查定时器 _healthCheckTimer = new Timer( async _ => await PerformHealthCheckAsync(), null, TimeSpan.FromMilliseconds(_options.HealthCheckIntervalMs), TimeSpan.FromMilliseconds(_options.HealthCheckIntervalMs)); } /// public async Task AcquireAsync(CancellationToken cancellationToken = default) { if (_disposed) throw new ObjectDisposedException(nameof(BrowserPool)); // 等待信号量(控制并发数) var acquired = await _semaphore.WaitAsync(_options.AcquireTimeout, cancellationToken); if (!acquired) { _logger.LogWarning("获取浏览器实例超时,当前并发数已达上限"); throw new TimeoutException($"无法在 {_options.AcquireTimeout}ms 内获取浏览器实例"); } try { // 尝试从池中获取可用实例 while (_availableBrowsers.TryTake(out var instance)) { if (await ValidateBrowserAsync(instance)) { instance.LastUsedAt = DateTime.UtcNow; instance.TaskCount++; _logger.LogDebug("复用浏览器实例: {BrowserId}, 已处理任务数: {TaskCount}", instance.Id, instance.TaskCount); return instance.Browser; } else { // 实例已失效,清理 _logger.LogWarning("检测到无效的浏览器实例,正在清理: {BrowserId}", instance.Id); await DisposeBrowserAsync(instance); } } // 池中没有可用实例,创建新实例 if (_totalInstances < _options.MaxInstances) { var newInstance = await CreateBrowserAsync(cancellationToken); newInstance.TaskCount = 1; _logger.LogInformation("创建新浏览器实例: {BrowserId}, 当前总实例数: {Total}/{Max}", newInstance.Id, _totalInstances, _options.MaxInstances); return newInstance.Browser; } // 达到最大实例数 _logger.LogWarning("浏览器实例数已达上限 ({Max}),等待其他任务释放", _options.MaxInstances); throw new InvalidOperationException($"浏览器实例数已达上限: {_options.MaxInstances}"); } catch { _semaphore.Release(); throw; } } /// public void Release(IBrowser browser) { if (browser == null) throw new ArgumentNullException(nameof(browser)); try { var instance = _allBrowsers.Values.FirstOrDefault(b => b.Browser == browser); if (instance != null) { instance.LastUsedAt = DateTime.UtcNow; // 检查是否需要重启(任务数超限或内存超限) if (ShouldRestartBrowser(instance)) { _logger.LogInformation("浏览器实例需要重启: {BrowserId}, 原因: {Reason}", instance.Id, GetRestartReason(instance)); _ = RestartBrowserAsync(instance); } else if (browser.IsConnected && !_disposed) { _availableBrowsers.Add(instance); _logger.LogDebug("浏览器实例已归还到池中: {BrowserId}", instance.Id); } else { _logger.LogWarning("释放的浏览器实例已断开或池已销毁: {BrowserId}", instance.Id); _ = DisposeBrowserAsync(instance); } } } finally { _semaphore.Release(); } } /// public async Task WarmUpAsync(CancellationToken cancellationToken = default) { _logger.LogInformation("开始预热浏览器池,目标实例数: {Min}", _options.MinInstances); var tasks = new List(); for (int i = 0; i < _options.MinInstances; i++) { tasks.Add(Task.Run(async () => { try { var instance = await CreateBrowserAsync(cancellationToken); _availableBrowsers.Add(instance); _logger.LogDebug("预热:创建浏览器实例 {BrowserId}", instance.Id); } catch (Exception ex) { _logger.LogError(ex, "预热失败:创建浏览器实例时出错"); } }, cancellationToken)); } await Task.WhenAll(tasks); _logger.LogInformation("浏览器池预热完成,当前实例数: {Total}", _totalInstances); } /// public BrowserPoolStatus GetStatus() { var healthyCount = _allBrowsers.Values.Count(b => b.Browser.IsConnected); var totalMemory = _allBrowsers.Values.Sum(b => b.MemoryUsageMb); var availableIds = _availableBrowsers.Select(b => b.Id).ToHashSet(); var instances = _allBrowsers.Values.Select(b => new BrowserInstanceInfo { Id = b.Id, Status = availableIds.Contains(b.Id) ? "available" : "in-use", TaskCount = b.TaskCount, MemoryUsage = b.MemoryUsageMb * 1024 * 1024, // 转换为字节 CreatedAt = b.CreatedAt, LastUsedAt = b.LastUsedAt }).ToList(); return new BrowserPoolStatus { TotalInstances = _totalInstances, AvailableInstances = _availableBrowsers.Count, InUseInstances = _totalInstances - _availableBrowsers.Count, MaxInstances = _options.MaxInstances, HealthyInstances = healthyCount, RestartCount = _restartCount, TotalMemoryMb = totalMemory, Instances = instances, Config = new BrowserPoolConfigInfo { MaxInstances = _options.MaxInstances, MinInstances = _options.MinInstances, MaxConcurrent = _options.MaxConcurrent, MaxTasksPerInstance = _options.MaxTasksPerBrowserInstance, RestartMemoryMb = _options.BrowserRestartMemoryMb, HealthCheckIntervalMs = _options.HealthCheckIntervalMs } }; } /// public async Task HealthCheckAsync(CancellationToken cancellationToken = default) { try { var unhealthyCount = 0; foreach (var instance in _allBrowsers.Values.ToList()) { if (!await ValidateBrowserAsync(instance)) { unhealthyCount++; } } return unhealthyCount == 0; } catch (Exception ex) { _logger.LogError(ex, "健康检查失败"); return false; } } /// /// 执行健康检查 /// private async Task PerformHealthCheckAsync() { if (_disposed) return; try { var instancesToRestart = new List(); foreach (var instance in _allBrowsers.Values.ToList()) { // 检查连接状态 if (!instance.Browser.IsConnected) { _logger.LogWarning("检测到断开的浏览器实例: {BrowserId}", instance.Id); instancesToRestart.Add(instance); continue; } // 检查内存使用 await UpdateMemoryUsageAsync(instance); if (instance.MemoryUsageMb > _options.BrowserRestartMemoryMb) { _logger.LogWarning("浏览器实例内存过高: {BrowserId}, 内存: {Memory}MB", instance.Id, instance.MemoryUsageMb); instancesToRestart.Add(instance); continue; } // 执行简单的健康探测 if (!await PerformLivenessProbeAsync(instance)) { _logger.LogWarning("浏览器实例健康探测失败: {BrowserId}", instance.Id); instancesToRestart.Add(instance); } } // 重启不健康的实例 foreach (var instance in instancesToRestart) { await RestartBrowserAsync(instance); } // 补充实例到最小数量 while (_totalInstances < _options.MinInstances && !_disposed) { try { var newInstance = await CreateBrowserAsync(CancellationToken.None); _availableBrowsers.Add(newInstance); _logger.LogDebug("补充浏览器实例: {BrowserId}", newInstance.Id); } catch (Exception ex) { _logger.LogError(ex, "补充浏览器实例失败"); break; } } } catch (Exception ex) { _logger.LogError(ex, "健康检查过程出错"); } } /// /// 验证浏览器实例是否可用 /// private async Task ValidateBrowserAsync(BrowserInstance instance) { try { if (!instance.Browser.IsConnected) return false; // 简单验证:尝试获取页面列表 var pages = await instance.Browser.PagesAsync(); return true; } catch { return false; } } /// /// 执行存活探测 /// private async Task PerformLivenessProbeAsync(BrowserInstance instance) { IPage? page = null; try { page = await instance.Browser.NewPageAsync(); await page.GoToAsync("about:blank", new NavigationOptions { Timeout = 5000 }); return true; } catch { return false; } finally { if (page != null) { try { await page.CloseAsync(); } catch { } } } } /// /// 更新内存使用信息 /// private async Task UpdateMemoryUsageAsync(BrowserInstance instance) { try { var process = instance.Browser.Process; if (process != null && !process.HasExited) { process.Refresh(); instance.MemoryUsageMb = process.WorkingSet64 / 1024 / 1024; } } catch { // 忽略进程信息获取失败 } } /// /// 判断是否需要重启浏览器 /// private bool ShouldRestartBrowser(BrowserInstance instance) { // 任务数超限 if (instance.TaskCount >= _options.MaxTasksPerBrowserInstance) return true; // 内存超限 if (instance.MemoryUsageMb > _options.BrowserRestartMemoryMb) return true; return false; } /// /// 获取重启原因 /// private string GetRestartReason(BrowserInstance instance) { if (instance.TaskCount >= _options.MaxTasksPerBrowserInstance) return $"任务数超限 ({instance.TaskCount}/{_options.MaxTasksPerBrowserInstance})"; if (instance.MemoryUsageMb > _options.BrowserRestartMemoryMb) return $"内存超限 ({instance.MemoryUsageMb}MB/{_options.BrowserRestartMemoryMb}MB)"; return "未知"; } /// /// 重启浏览器实例 /// private async Task RestartBrowserAsync(BrowserInstance instance) { try { Interlocked.Increment(ref _restartCount); // 从池中移除 _allBrowsers.TryRemove(instance.Id, out _); Interlocked.Decrement(ref _totalInstances); // 关闭旧实例 try { await instance.Browser.CloseAsync(); } catch { } // 创建新实例 if (!_disposed) { var newInstance = await CreateBrowserAsync(CancellationToken.None); _availableBrowsers.Add(newInstance); _logger.LogInformation("浏览器实例已重启: {OldId} -> {NewId}", instance.Id, newInstance.Id); } } catch (Exception ex) { _logger.LogError(ex, "重启浏览器实例失败: {BrowserId}", instance.Id); } } /// /// 创建浏览器实例 /// private async Task CreateBrowserAsync(CancellationToken cancellationToken) { await _createLock.WaitAsync(cancellationToken); try { if (_totalInstances >= _options.MaxInstances) { throw new InvalidOperationException($"浏览器实例数已达上限: {_options.MaxInstances}"); } // 确保 Chromium 已下载 var chromiumPath = await EnsureChromiumDownloadedAsync(); var executablePath = string.IsNullOrWhiteSpace(_options.ExecutablePath) ? chromiumPath : _options.ExecutablePath; _logger.LogDebug("使用的 Chromium 路径: {Path}", executablePath); var launchOptions = new LaunchOptions { Headless = true, Args = _options.BrowserArgs, ExecutablePath = executablePath }; var browser = await Puppeteer.LaunchAsync(launchOptions); var instance = new BrowserInstance { Id = Guid.NewGuid().ToString(), Browser = browser, CreatedAt = DateTime.UtcNow, LastUsedAt = DateTime.UtcNow }; _allBrowsers.TryAdd(instance.Id, instance); Interlocked.Increment(ref _totalInstances); _logger.LogDebug("成功创建浏览器实例: {BrowserId}", instance.Id); return instance; } catch (Exception ex) { _logger.LogError(ex, "创建浏览器实例失败"); throw; } finally { _createLock.Release(); } } /// public async Task EnsureChromiumReadyAsync(Action? progress = null, CancellationToken cancellationToken = default) { return await EnsureChromiumDownloadedAsync(progress, cancellationToken); } /// /// 确保 Chromium 已下载 /// private async Task EnsureChromiumDownloadedAsync(Action? progress = null, CancellationToken cancellationToken = default) { try { var message = "检查 Chromium 是否已下载..."; _logger.LogInformation(message); progress?.Invoke(message); // 使用固定缓存目录,方便 Docker volume 映射持久化 var cacheDir = Environment.GetEnvironmentVariable("PUPPETEER_CACHE_DIR") ?? Path.Combine(AppContext.BaseDirectory, "chromium"); Directory.CreateDirectory(cacheDir); var fetcherOptions = new BrowserFetcherOptions { Path = cacheDir }; // 支持通过环境变量配置 Chromium 下载代理 var proxyUrl = Environment.GetEnvironmentVariable("CHROMIUM_DOWNLOAD_PROXY"); if (!string.IsNullOrWhiteSpace(proxyUrl)) { _logger.LogInformation("使用代理下载 Chromium: {Proxy}", proxyUrl); fetcherOptions.CustomFileDownload = async (url, destinationPath) => { var handler = new System.Net.Http.HttpClientHandler { Proxy = new System.Net.WebProxy(proxyUrl), UseProxy = true }; using var httpClient = new System.Net.Http.HttpClient(handler); httpClient.Timeout = TimeSpan.FromMinutes(10); using var response = await httpClient.GetAsync(url); response.EnsureSuccessStatusCode(); using var fileStream = new FileStream(destinationPath, FileMode.Create, FileAccess.Write, FileShare.None); await response.Content.CopyToAsync(fileStream); }; } var browserFetcher = new BrowserFetcher(fetcherOptions); var installedBrowsers = browserFetcher.GetInstalledBrowsers(); if (installedBrowsers.Any()) { var installedBrowser = installedBrowsers.First(); var executablePath = installedBrowser.GetExecutablePath(); message = $"Chromium 已存在,版本: {installedBrowser.BuildId}"; _logger.LogInformation("Chromium 已存在,版本: {Version},路径: {Path}", installedBrowser.BuildId, executablePath); progress?.Invoke(message); return executablePath; } message = "Chromium 未安装,开始下载(约 150MB,请耐心等待)..."; _logger.LogInformation(message); progress?.Invoke(message); // PuppeteerSharp 20.x 不再支持下载进度回调,直接下载 var installed = await browserFetcher.DownloadAsync(); var path = installed.GetExecutablePath(); message = "Chromium 下载完成!"; _logger.LogInformation("Chromium 下载完成,路径: {Path}", path); progress?.Invoke(message); return path; } catch (Exception ex) { _logger.LogError(ex, "下载 Chromium 失败"); throw new InvalidOperationException("无法下载 Chromium,请检查网络连接或手动下载", ex); } } /// /// 释放浏览器实例 /// private async Task DisposeBrowserAsync(BrowserInstance instance) { try { _allBrowsers.TryRemove(instance.Id, out _); Interlocked.Decrement(ref _totalInstances); await instance.Browser.CloseAsync(); _logger.LogDebug("浏览器实例已清理: {BrowserId}", instance.Id); } catch (Exception ex) { _logger.LogError(ex, "清理浏览器实例时出错: {BrowserId}", instance.Id); } } /// public async Task UpdateConfigurationAsync(int? maxInstances = null, int? minInstances = null, int? maxConcurrent = null) { if (_disposed) throw new ObjectDisposedException(nameof(BrowserPool)); var updated = false; var oldMax = _options.MaxInstances; var oldMin = _options.MinInstances; if (maxInstances.HasValue && maxInstances.Value != _options.MaxInstances) { _options.MaxInstances = maxInstances.Value; updated = true; _logger.LogInformation("浏览器池最大实例数已更新: {Old} -> {New}", oldMax, maxInstances.Value); } if (minInstances.HasValue && minInstances.Value != _options.MinInstances) { _options.MinInstances = minInstances.Value; updated = true; _logger.LogInformation("浏览器池最小实例数已更新: {Old} -> {New}", oldMin, minInstances.Value); } // 注意:MaxConcurrent 由信号量控制,运行时修改较复杂,这里只记录日志 if (maxConcurrent.HasValue && maxConcurrent.Value != _options.MaxConcurrent) { _logger.LogWarning("最大并发数修改需要重启服务才能生效: {Old} -> {New}", _options.MaxConcurrent, maxConcurrent.Value); } if (updated) { // 如果最小实例数增加,需要补充实例 if (_totalInstances < _options.MinInstances) { var toCreate = _options.MinInstances - _totalInstances; _logger.LogInformation("补充浏览器实例: {Count}", toCreate); for (int i = 0; i < toCreate && _totalInstances < _options.MaxInstances; i++) { try { var instance = await CreateBrowserAsync(CancellationToken.None); _availableBrowsers.Add(instance); } catch (Exception ex) { _logger.LogError(ex, "补充浏览器实例失败"); break; } } } // 如果最大实例数减少且当前实例数超过新上限,需要移除多余实例 // 这里采用惰性策略:不主动移除,等实例自然回收 if (_totalInstances > _options.MaxInstances) { _logger.LogInformation("当前实例数 {Current} 超过新的最大值 {Max},多余实例将在使用完毕后自然回收", _totalInstances, _options.MaxInstances); } } } /// public async ValueTask DisposeAsync() { if (_disposed) return; _disposed = true; _logger.LogInformation("开始清理浏览器池..."); // 停止健康检查 try { await _healthCheckTimer.DisposeAsync(); } catch { } // 清理所有浏览器实例 var disposeTasks = _allBrowsers.Values.Select(async instance => { try { // 先尝试优雅关闭 var closeTask = instance.Browser.CloseAsync(); var completed = await Task.WhenAny(closeTask, Task.Delay(3000)) == closeTask; if (!completed) { _logger.LogWarning("浏览器实例关闭超时,强制终止进程: {BrowserId}", instance.Id); } // 确保进程被终止 ForceKillBrowserProcess(instance); } catch (Exception ex) { _logger.LogError(ex, "清理浏览器实例时出错: {BrowserId}", instance.Id); // 即使 CloseAsync 失败,也尝试强制终止进程 ForceKillBrowserProcess(instance); } }); await Task.WhenAll(disposeTasks); _allBrowsers.Clear(); _availableBrowsers.Clear(); _totalInstances = 0; _semaphore.Dispose(); _createLock.Dispose(); _logger.LogInformation("浏览器池清理完成"); } /// /// 强制终止浏览器进程 /// private void ForceKillBrowserProcess(BrowserInstance instance) { try { var process = instance.Browser.Process; if (process != null && !process.HasExited) { _logger.LogDebug("强制终止浏览器进程: {BrowserId}, PID: {Pid}", instance.Id, process.Id); process.Kill(entireProcessTree: true); process.WaitForExit(2000); // 等待最多 2 秒 } } catch (Exception ex) { _logger.LogWarning(ex, "强制终止浏览器进程失败: {BrowserId}", instance.Id); } } } /// /// 浏览器实例 /// internal class BrowserInstance { public string Id { get; set; } = string.Empty; public IBrowser Browser { get; set; } = null!; public DateTime CreatedAt { get; set; } public DateTime LastUsedAt { get; set; } public int TaskCount { get; set; } public long MemoryUsageMb { get; set; } }