package cn.iocoder.yudao.module.qcreport.service.render; import cn.iocoder.yudao.framework.common.exception.ServiceException; import cn.iocoder.yudao.module.qcreport.config.QcReportPdfProperties; import com.microsoft.playwright.Browser; import com.microsoft.playwright.BrowserType; import com.microsoft.playwright.Page; import com.microsoft.playwright.Playwright; import com.microsoft.playwright.PlaywrightException; import jakarta.annotation.PostConstruct; import jakarta.annotation.PreDestroy; import jakarta.annotation.Resource; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Component; import org.springframework.util.StringUtils; import java.nio.file.Path; import java.util.List; import java.util.Map; import java.util.concurrent.Semaphore; import java.util.concurrent.TimeUnit; import java.util.function.Function; import static cn.iocoder.yudao.module.qcreport.enums.ErrorCodeConstants.RENDER_PDF_BROWSER_LAUNCH_FAILED; import static cn.iocoder.yudao.module.qcreport.enums.ErrorCodeConstants.RENDER_PDF_BUSY; /** * PDF 渲染用的浏览器持有者 *

* 整个进程只启一个 {@link Playwright} 与一个 {@link Browser}:设计文档 §30 明文禁止 * 「每次请求 launch → 生成 → close」——Chromium 冷启动以秒计,每次重启会让出件慢到不可用, * 也容易把内存吃光。启动是懒的:没有 PDF 需求就不该拉起一个浏览器进程。 *

* 借出期间用 {@link Semaphore} 设闸(§30/§39 要求控制 Playwright 并发), * 排队超过 {@code timeoutMs} 直接报忙,不无限堆积;每次借出前检查 {@link Browser#isConnected()}, * 断连(浏览器崩溃/被外部杀掉)就重启一个,不让一次崩溃把后续所有出件都拖死。 */ @Slf4j @Component public class BrowserManager { /** 本次复用了已在运行的浏览器实例 */ public static final String STATUS_READY = "READY"; /** 本次发现浏览器不可用(还没启过,或已断开),重新启动了一个 */ public static final String STATUS_RESTARTED = "RESTARTED"; /** * Playwright 认的「别下载自带浏览器」开关。 *

* 通过 {@link Playwright.CreateOptions#setEnv} 传给驱动进程,效果等同于在机器上设这个环境变量, * 但不需要运维去改启动脚本 —— 项目在哪台机器上跑都一致。 */ private static final String SKIP_BROWSER_DOWNLOAD_ENV = "PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD"; @Resource private QcReportPdfProperties properties; /** 并发名额。借出期间占一个,用完归还 */ private volatile Semaphore slots; /** 保护 {@link #playwright} / {@link #browser} 的启动、重启与关闭 */ private final Object browserLock = new Object(); private Playwright playwright; private Browser browser; @PostConstruct public void init() { Integer concurrency = properties.getConcurrency(); this.slots = new Semaphore(concurrency == null || concurrency < 1 ? 1 : concurrency); } /** * 借一个页面执行 {@code action}:负责并发设闸、浏览器保活、页面关闭与名额归还。 * * @return 动作返回值 + 本次浏览器状态({@link #STATUS_READY} / {@link #STATUS_RESTARTED}),后者供渲染记录留痕 */ public PageResult withPage(Function action) { if (!tryAcquire()) { throw new ServiceException(RENDER_PDF_BUSY.getCode(), RENDER_PDF_BUSY.getMsg() + "(当前并发上限 " + properties.getConcurrency() + ",请等前一个任务结束后重试)"); } try { boolean restarted = false; Browser target; synchronized (browserLock) { if (browser != null && browser.isConnected()) { target = browser; } else { target = launchBrowser(); restarted = true; } } Page page = target.newPage(); try { return new PageResult<>(action.apply(page), restarted ? STATUS_RESTARTED : STATUS_READY); } finally { closePage(page); } } finally { slots.release(); } } @PreDestroy public void destroy() { synchronized (browserLock) { closeBrowser(); if (playwright != null) { try { playwright.close(); } catch (Exception e) { log.warn("[destroy][关闭 Playwright 失败]", e); } playwright = null; } } } private boolean tryAcquire() { Long configured = properties.getTimeoutMs(); long timeoutMs = configured == null || configured < 1 ? 30000L : configured; try { return slots.tryAcquire(timeoutMs, TimeUnit.MILLISECONDS); } catch (InterruptedException e) { Thread.currentThread().interrupt(); return false; } } /** * 启动浏览器。调用方必须持有 {@link #browserLock}。 *

* 启动参数优先级:{@code executable-path} > {@code channel} > Playwright 自带的 Chromium。 */ private Browser launchBrowser() { closeBrowser(); BrowserType.LaunchOptions options = new BrowserType.LaunchOptions().setHeadless(true); String executablePath = text(properties.getExecutablePath()); String channel = text(properties.getChannel()); if (executablePath != null) { options.setExecutablePath(Path.of(executablePath)); } else if (channel != null) { options.setChannel(channel); } List args = properties.getBrowserArgs(); if (args != null && !args.isEmpty()) { options.setArgs(args); } try { if (playwright == null) { playwright = createPlaywright(channel, executablePath); } Browser launched = playwright.chromium().launch(options); this.browser = launched; log.info("[launchBrowser][PDF 渲染浏览器已启动,来源({}),版本({})]", describeSource(channel, executablePath), launched.version()); return launched; } catch (PlaywrightException e) { closeBrowser(); throw new ServiceException(RENDER_PDF_BROWSER_LAUNCH_FAILED.getCode(), RENDER_PDF_BROWSER_LAUNCH_FAILED.getMsg() + ":浏览器来源为" + describeSource(channel, executablePath) + ",实际原因是「" + e.getMessage() + "」。请确认该浏览器已安装在该机器上并在 yudao.qcreport.pdf.executable-path 中指向其可执行文件;" + "或把 channel 留空、执行 playwright install chromium 后使用 Playwright 自带的 Chromium。"); } } /** * 启动 Playwright 驱动。 *

* 用机器自带的浏览器时,必须让它跳过「下载 Playwright 自带浏览器」这一步。 * 否则 {@code Playwright.create()} 会同步执行一次 {@code playwright install}(chromium + firefox + webkit, * 几百 MB);外网不通的环境下它会先卡满 10 分钟再抛 * {@code Timed out waiting for browsers to install} —— 首次导出直接变成一次十分钟的挂起。 * 我们既然已经有 Chrome 可打({@code executable-path} 或 {@code channel}),这份自带浏览器就是多余的。 *

* 反之,两者都没配(即明确要用自带 Chromium)时不加这个变量:那时确实需要 Playwright 自己去装, * 装不上会在 {@link com.microsoft.playwright.BrowserType#launch} 处报明确错误。 */ private Playwright createPlaywright(String channel, String executablePath) { Playwright.CreateOptions options = new Playwright.CreateOptions(); if (channel != null || executablePath != null) { options.setEnv(Map.of(SKIP_BROWSER_DOWNLOAD_ENV, "1")); } return Playwright.create(options); } private String describeSource(String channel, String executablePath) { if (executablePath != null) { return "executable-path=" + executablePath; } if (channel != null) { return "channel=" + channel; } return "Playwright 自带 Chromium"; } private static String text(String value) { return StringUtils.hasText(value) ? value.trim() : null; } private void closeBrowser() { if (browser != null) { try { browser.close(); } catch (Exception e) { log.warn("[closeBrowser][关闭浏览器失败,忽略并重建]", e); } browser = null; } } private void closePage(Page page) { try { page.close(); } catch (Exception e) { log.warn("[closePage][关闭页面失败,忽略]", e); } } /** * 借出结果:动作返回值 + 本次浏览器状态。 */ public record PageResult(T value, String browserStatus) { } }