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; }
}