HtmlToPdf/src/HtmlToPdfService.Core/Pool/BrowserPool.cs
code@server a919e23494 git
2026-04-05 12:22:56 +08:00

726 lines
25 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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;
/// <summary>
/// 增强版浏览器池实现
/// 支持自愈、健康检查、内存阈值重启
/// </summary>
public class BrowserPool : IBrowserPool
{
private readonly BrowserPoolOptions _options;
private readonly ILogger<BrowserPool> _logger;
private readonly ConcurrentBag<BrowserInstance> _availableBrowsers = new();
private readonly ConcurrentDictionary<string, BrowserInstance> _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<PdfServiceOptions> options, ILogger<BrowserPool> 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));
}
/// <inheritdoc />
public async Task<IBrowser> 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;
}
}
/// <inheritdoc />
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();
}
}
/// <inheritdoc />
public async Task WarmUpAsync(CancellationToken cancellationToken = default)
{
_logger.LogInformation("开始预热浏览器池,目标实例数: {Min}", _options.MinInstances);
var tasks = new List<Task>();
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);
}
/// <inheritdoc />
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
}
};
}
/// <inheritdoc />
public async Task<bool> 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;
}
}
/// <summary>
/// 执行健康检查
/// </summary>
private async Task PerformHealthCheckAsync()
{
if (_disposed) return;
try
{
var instancesToRestart = new List<BrowserInstance>();
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, "健康检查过程出错");
}
}
/// <summary>
/// 验证浏览器实例是否可用
/// </summary>
private async Task<bool> ValidateBrowserAsync(BrowserInstance instance)
{
try
{
if (!instance.Browser.IsConnected)
return false;
// 简单验证:尝试获取页面列表
var pages = await instance.Browser.PagesAsync();
return true;
}
catch
{
return false;
}
}
/// <summary>
/// 执行存活探测
/// </summary>
private async Task<bool> 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 { }
}
}
}
/// <summary>
/// 更新内存使用信息
/// </summary>
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
{
// 忽略进程信息获取失败
}
}
/// <summary>
/// 判断是否需要重启浏览器
/// </summary>
private bool ShouldRestartBrowser(BrowserInstance instance)
{
// 任务数超限
if (instance.TaskCount >= _options.MaxTasksPerBrowserInstance)
return true;
// 内存超限
if (instance.MemoryUsageMb > _options.BrowserRestartMemoryMb)
return true;
return false;
}
/// <summary>
/// 获取重启原因
/// </summary>
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 "未知";
}
/// <summary>
/// 重启浏览器实例
/// </summary>
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);
}
}
/// <summary>
/// 创建浏览器实例
/// </summary>
private async Task<BrowserInstance> 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();
}
}
/// <inheritdoc />
public async Task<string> EnsureChromiumReadyAsync(Action<string>? progress = null, CancellationToken cancellationToken = default)
{
return await EnsureChromiumDownloadedAsync(progress, cancellationToken);
}
/// <summary>
/// 确保 Chromium 已下载
/// </summary>
private async Task<string> EnsureChromiumDownloadedAsync(Action<string>? 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);
}
}
/// <summary>
/// 释放浏览器实例
/// </summary>
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);
}
}
/// <inheritdoc />
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);
}
}
}
/// <inheritdoc />
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("浏览器池清理完成");
}
/// <summary>
/// 强制终止浏览器进程
/// </summary>
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);
}
}
}
/// <summary>
/// 浏览器实例
/// </summary>
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; }
}