HtmlToPdf/mvp/HtmlToPdfService.Core/Pool/BrowserPool.cs
2025-12-11 23:35:52 +08:00

354 lines
12 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 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<IBrowser> _availableBrowsers = new();
private readonly ConcurrentDictionary<string, IBrowser> _allBrowsers = new();
private readonly SemaphoreSlim _semaphore;
private readonly SemaphoreSlim _createLock = new(1, 1);
private int _totalInstances = 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);
}
/// <summary>
/// 获取浏览器实例
/// </summary>
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 browser))
{
if (browser.IsConnected)
{
_logger.LogDebug("复用浏览器实例,当前总实例数: {Total}", _totalInstances);
return browser;
}
else
{
// 实例已失效,清理
_logger.LogWarning("检测到断开的浏览器实例,正在清理");
await DisposeBrowserAsync(browser);
}
}
// 池中没有可用实例,创建新实例
if (_totalInstances < _options.MaxInstances)
{
var newBrowser = await CreateBrowserAsync(cancellationToken);
_logger.LogInformation("创建新浏览器实例,当前总实例数: {Total}/{Max}",
_totalInstances, _options.MaxInstances);
return newBrowser;
}
// 达到最大实例数,等待其他任务释放
_logger.LogWarning("浏览器实例数已达上限 ({Max}),等待其他任务释放", _options.MaxInstances);
throw new InvalidOperationException($"浏览器实例数已达上限: {_options.MaxInstances}");
}
catch
{
_semaphore.Release();
throw;
}
}
/// <summary>
/// 释放浏览器实例
/// </summary>
public void Release(IBrowser browser)
{
if (browser == null)
throw new ArgumentNullException(nameof(browser));
try
{
if (browser.IsConnected && !_disposed)
{
_availableBrowsers.Add(browser);
_logger.LogDebug("浏览器实例已归还到池中");
}
else
{
_logger.LogWarning("释放的浏览器实例已断开或池已销毁");
_ = DisposeBrowserAsync(browser); // 异步清理,不等待
}
}
finally
{
_semaphore.Release();
}
}
/// <summary>
/// 预热浏览器池
/// </summary>
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 browser = await CreateBrowserAsync(cancellationToken);
_availableBrowsers.Add(browser);
_logger.LogDebug("预热:创建浏览器实例 {Current}/{Target}", i + 1, _options.MinInstances);
}
catch (Exception ex)
{
_logger.LogError(ex, "预热失败:创建浏览器实例时出错");
}
}, cancellationToken));
}
await Task.WhenAll(tasks);
_logger.LogInformation("浏览器池预热完成,当前实例数: {Total}", _totalInstances);
}
/// <summary>
/// 获取池状态
/// </summary>
public BrowserPoolStatus GetStatus()
{
return new BrowserPoolStatus
{
TotalInstances = _totalInstances,
AvailableInstances = _availableBrowsers.Count,
MaxInstances = _options.MaxInstances,
InUseInstances = _totalInstances - _availableBrowsers.Count
};
}
/// <summary>
/// 创建浏览器实例
/// </summary>
private async Task<IBrowser> 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
};
_logger.LogDebug("启动参数: Headless={Headless}, Args={Args}",
launchOptions.Headless, string.Join(" ", launchOptions.Args ?? Array.Empty<string>()));
var browser = await Puppeteer.LaunchAsync(launchOptions);
var browserId = Guid.NewGuid().ToString();
_allBrowsers.TryAdd(browserId, browser);
Interlocked.Increment(ref _totalInstances);
_logger.LogDebug("成功创建浏览器实例: {BrowserId}", browserId);
return browser;
}
catch (Exception ex)
{
_logger.LogError(ex, "创建浏览器实例失败");
throw;
}
finally
{
_createLock.Release();
}
}
/// <summary>
/// 清理浏览器实例
/// </summary>
private async Task DisposeBrowserAsync(IBrowser browser)
{
try
{
await browser.DisposeAsync();
Interlocked.Decrement(ref _totalInstances);
// 从字典中移除
var key = _allBrowsers.FirstOrDefault(x => x.Value == browser).Key;
if (key != null)
{
_allBrowsers.TryRemove(key, out _);
}
_logger.LogDebug("浏览器实例已清理,当前总实例数: {Total}", _totalInstances);
}
catch (Exception ex)
{
_logger.LogError(ex, "清理浏览器实例时出错");
}
}
/// <summary>
/// 确保 Chromium 已下载,并返回可执行文件路径
/// </summary>
private async Task<string> EnsureChromiumDownloadedAsync()
{
try
{
_logger.LogInformation("检查 Chromium 是否已下载...");
var browserFetcher = new BrowserFetcher();
// 检查是否已经下载
var installedBrowsers = browserFetcher.GetInstalledBrowsers();
if (installedBrowsers.Any())
{
var installedBrowser = installedBrowsers.First();
var executablePath = installedBrowser.GetExecutablePath();
_logger.LogInformation("Chromium 已存在,版本: {Revision},路径: {Path}",
installedBrowser.BuildId, executablePath);
return executablePath;
}
// 下载 Chromium启动进度提示
_logger.LogInformation("========================================");
_logger.LogInformation("🔽 开始下载 Chromium约 300MB");
_logger.LogInformation("⏱️ 预计需要 2-10 分钟,取决于网络速度");
_logger.LogInformation("========================================");
// 启动一个后台任务来显示"下载中"的提示
var cts = new CancellationTokenSource();
var progressTask = Task.Run(async () =>
{
int dots = 0;
var messages = new[]
{
"📥 正在下载 Chromium请稍候",
"⏳ 继续下载中,请耐心等待",
"🚀 下载进行中,马上就好",
"💪 坚持住,快完成了"
};
int messageIndex = 0;
while (!cts.Token.IsCancellationRequested)
{
await Task.Delay(5000, cts.Token);
if (!cts.Token.IsCancellationRequested)
{
_logger.LogInformation("{Message}{Dots}",
messages[messageIndex % messages.Length],
new string('.', (dots % 3) + 1));
dots++;
if (dots % 3 == 0) messageIndex++;
}
}
}, cts.Token);
try
{
var installedBrowser = await browserFetcher.DownloadAsync();
cts.Cancel();
var executablePath = installedBrowser.GetExecutablePath();
_logger.LogInformation("========================================");
_logger.LogInformation("✅ Chromium 下载完成!");
_logger.LogInformation("📁 路径: {Path}", executablePath);
_logger.LogInformation("🏷️ 版本: {Revision}", installedBrowser.BuildId);
_logger.LogInformation("========================================");
return executablePath;
}
catch
{
cts.Cancel();
throw;
}
}
catch (Exception ex)
{
_logger.LogError(ex, "下载 Chromium 失败");
throw new InvalidOperationException("无法下载 Chromium。请检查网络连接或手动下载。", ex);
}
}
/// <summary>
/// 释放资源
/// </summary>
public async ValueTask DisposeAsync()
{
if (_disposed)
return;
_disposed = true;
_logger.LogInformation("开始清理浏览器池...");
// 清理所有浏览器实例
var disposeTasks = _allBrowsers.Values.Select(async browser =>
{
try
{
await browser.DisposeAsync();
}
catch (Exception ex)
{
_logger.LogError(ex, "清理浏览器实例时出错");
}
});
await Task.WhenAll(disposeTasks);
_allBrowsers.Clear();
_availableBrowsers.Clear();
_totalInstances = 0;
_semaphore.Dispose();
_createLock.Dispose();
_logger.LogInformation("浏览器池清理完成");
}
}