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 渲染用的浏览器持有者
|
* <p>
|
* 整个进程只启一个 {@link Playwright} 与一个 {@link Browser}:设计文档 §30 明文禁止
|
* 「每次请求 launch → 生成 → close」——Chromium 冷启动以秒计,每次重启会让出件慢到不可用,
|
* 也容易把内存吃光。启动是懒的:没有 PDF 需求就不该拉起一个浏览器进程。
|
* <p>
|
* 借出期间用 {@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 认的「别下载自带浏览器」开关。
|
* <p>
|
* 通过 {@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 <T> PageResult<T> withPage(Function<Page, T> 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}。
|
* <p>
|
* 启动参数优先级:{@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<String> 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 驱动。
|
* <p>
|
* <b>用机器自带的浏览器时,必须让它跳过「下载 Playwright 自带浏览器」这一步。</b>
|
* 否则 {@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}),这份自带浏览器就是多余的。
|
* <p>
|
* 反之,两者都没配(即明确要用自带 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>(T value, String browserStatus) {
|
}
|
|
}
|