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
* 启动参数优先级:{@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
* 用机器自带的浏览器时,必须让它跳过「下载 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