7 小时以前 9bad721754fe8bbe2e5f459d0706e0fefac569f3
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
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) {
    }
 
}