354 lines
12 KiB
C#
354 lines
12 KiB
C#
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("浏览器池清理完成");
|
||
}
|
||
}
|
||
|