Bläddra i källkod

Merge branch 'feat/PDF页面复用' of jyx/dcjxb.microservice into master

金逸霄 5 dagar sedan
förälder
incheckning
3f654c42d7

+ 18 - 0
abilities/exam-sprint/application/src/main/java/cn/yunzhixue/ability/center/examsprint/application/report/ExamSprintReportProperties.java

@@ -82,6 +82,8 @@ public class ExamSprintReportProperties {
         private Duration borrowTimeout = Duration.ofSeconds(30);
         private Duration launchTimeout = Duration.ofSeconds(30);
         private Duration renderTimeout = Duration.ofSeconds(30);
+        private boolean pageReuseEnabled = true;
+        private int pageMaxRenderCount = 200;
 
         public int getPoolSize() {
             return poolSize;
@@ -114,6 +116,22 @@ public class ExamSprintReportProperties {
         public void setRenderTimeout(Duration renderTimeout) {
             this.renderTimeout = renderTimeout;
         }
+
+        public boolean isPageReuseEnabled() {
+            return pageReuseEnabled;
+        }
+
+        public void setPageReuseEnabled(boolean pageReuseEnabled) {
+            this.pageReuseEnabled = pageReuseEnabled;
+        }
+
+        public int getPageMaxRenderCount() {
+            return pageMaxRenderCount;
+        }
+
+        public void setPageMaxRenderCount(int pageMaxRenderCount) {
+            this.pageMaxRenderCount = pageMaxRenderCount;
+        }
     }
 
     public static class Storage {

+ 131 - 1
abilities/exam-sprint/infrastructure/src/main/java/cn/yunzhixue/ability/center/examsprint/infrastructure/report/pdf/DefaultPlaywrightPdfWorker.java

@@ -21,10 +21,25 @@ final class DefaultPlaywrightPdfWorker implements PlaywrightPdfWorker {
     private final Playwright playwright;
     private final Browser browser;
     private final double renderTimeoutMillis;
+    private final boolean pageReuseEnabled;
+    private final int pageMaxRenderCount;
+    private BrowserContext reusableContext;
+    private Page reusablePage;
+    private int reusablePageRenderCount;
     private boolean closed;
 
     DefaultPlaywrightPdfWorker(double launchTimeoutMillis, double renderTimeoutMillis) {
+        this(launchTimeoutMillis, renderTimeoutMillis, true, 200);
+    }
+
+    DefaultPlaywrightPdfWorker(
+            double launchTimeoutMillis,
+            double renderTimeoutMillis,
+            boolean pageReuseEnabled,
+            int pageMaxRenderCount) {
         this.renderTimeoutMillis = renderTimeoutMillis;
+        this.pageReuseEnabled = pageReuseEnabled;
+        this.pageMaxRenderCount = validatePageMaxRenderCount(pageMaxRenderCount);
 
         Playwright initializedPlaywright = null;
         Browser initializedBrowser = null;
@@ -46,6 +61,14 @@ final class DefaultPlaywrightPdfWorker implements PlaywrightPdfWorker {
         Objects.requireNonNull(htmlContent, "htmlContent");
         ensureOpen();
 
+        if (pageReuseEnabled) {
+            return generateWithReusablePage(htmlContent);
+        }
+
+        return generateWithDedicatedContext(htmlContent);
+    }
+
+    private byte[] generateWithDedicatedContext(String htmlContent) {
         BrowserContext context = null;
         try {
             long stageStartedNanos = System.nanoTime();
@@ -93,6 +116,97 @@ final class DefaultPlaywrightPdfWorker implements PlaywrightPdfWorker {
         }
     }
 
+    private byte[] generateWithReusablePage(String htmlContent) {
+        try {
+            Page page = reusablePage();
+            long stageStartedNanos = System.nanoTime();
+            page.emulateMedia(new Page.EmulateMediaOptions().setMedia(Media.PRINT));
+            logStageCompleted("设置打印媒体", stageStartedNanos);
+
+            stageStartedNanos = System.nanoTime();
+            page.setContent(htmlContent, new Page.SetContentOptions()
+                    .setWaitUntil(WaitUntilState.LOAD)
+                    .setTimeout(renderTimeoutMillis));
+            logStageCompleted("设置HTML内容", stageStartedNanos);
+
+            stageStartedNanos = System.nanoTime();
+            page.evaluate("() => document.fonts ? document.fonts.ready.then(() => true) : true");
+            logStageCompleted("等待字体加载完成", stageStartedNanos);
+
+            stageStartedNanos = System.nanoTime();
+            byte[] pdfBytes = page.pdf(new Page.PdfOptions()
+                    .setFormat("A4")
+                    .setPrintBackground(true)
+                    .setPreferCSSPageSize(true)
+                    .setMargin(new Margin()
+                            .setTop("0")
+                            .setRight("0")
+                            .setBottom("0")
+                            .setLeft("0")));
+            logStageCompleted("生成PDF文件", stageStartedNanos);
+            reusablePageRenderCount++;
+            return pdfBytes;
+        } catch (Exception exception) {
+            rebuildReusablePage("failure");
+            throw new IllegalStateException("Failed to generate PDF", exception);
+        }
+    }
+
+    private Page reusablePage() {
+        if (reusablePage == null) {
+            createReusablePage("initial");
+        } else if (reusablePageRenderCount >= pageMaxRenderCount) {
+            rebuildReusablePage("max_render_count");
+        }
+        boolean pageReused = reusablePageRenderCount > 0;
+        log.info(
+                "PDF页面已准备 pageReuseEnabled={} pageReused={} pageRenderCount={} pageMaxRenderCount={}",
+                true,
+                pageReused,
+                reusablePageRenderCount,
+                pageMaxRenderCount);
+        return reusablePage;
+    }
+
+    private void createReusablePage(String reason) {
+        long stageStartedNanos = System.nanoTime();
+        reusableContext = browser.newContext(new Browser.NewContextOptions().setLocale("zh-CN"));
+        logStageCompleted("创建浏览器上下文", stageStartedNanos);
+        reusableContext.setDefaultTimeout(renderTimeoutMillis);
+        reusableContext.setDefaultNavigationTimeout(renderTimeoutMillis);
+
+        stageStartedNanos = System.nanoTime();
+        reusablePage = reusableContext.newPage();
+        reusablePageRenderCount = 0;
+        logStageCompleted("创建页面", stageStartedNanos);
+        log.info(
+                "PDF复用页面已创建 reason={} pageReuseEnabled={} pageRenderCount={} pageMaxRenderCount={}",
+                reason,
+                true,
+                reusablePageRenderCount,
+                pageMaxRenderCount);
+    }
+
+    private void rebuildReusablePage(String reason) {
+        closeReusablePage();
+        if (!closed) {
+            log.info(
+                    "PDF复用页面已重建 reason={} pageReuseEnabled={} pageRenderCount={} pageMaxRenderCount={}",
+                    reason,
+                    true,
+                    reusablePageRenderCount,
+                    pageMaxRenderCount);
+            createReusablePage(reason);
+        }
+    }
+
+    private void closeReusablePage() {
+        closeQuietly(reusableContext);
+        reusableContext = null;
+        reusablePage = null;
+        reusablePageRenderCount = 0;
+    }
+
     @Override
     public synchronized void close() {
         if (closed) {
@@ -101,10 +215,19 @@ final class DefaultPlaywrightPdfWorker implements PlaywrightPdfWorker {
 
         RuntimeException closeFailure = null;
         try {
-            browser.close();
+            closeReusablePage();
         } catch (RuntimeException exception) {
             closeFailure = exception;
         }
+        try {
+            browser.close();
+        } catch (RuntimeException exception) {
+            if (closeFailure == null) {
+                closeFailure = exception;
+            } else {
+                closeFailure.addSuppressed(exception);
+            }
+        }
         try {
             playwright.close();
         } catch (RuntimeException exception) {
@@ -132,6 +255,13 @@ final class DefaultPlaywrightPdfWorker implements PlaywrightPdfWorker {
         }
     }
 
+    private int validatePageMaxRenderCount(int pageMaxRenderCount) {
+        if (pageMaxRenderCount < 1) {
+            throw new IllegalArgumentException("pageMaxRenderCount must be at least 1");
+        }
+        return pageMaxRenderCount;
+    }
+
     private static void closeQuietly(AutoCloseable closeable) {
         if (closeable == null) {
             return;

+ 13 - 1
abilities/exam-sprint/infrastructure/src/main/java/cn/yunzhixue/ability/center/examsprint/infrastructure/report/pdf/DefaultPlaywrightPdfWorkerFactory.java

@@ -4,14 +4,26 @@ public final class DefaultPlaywrightPdfWorkerFactory implements PlaywrightPdfWor
 
     private final double launchTimeoutMillis;
     private final double renderTimeoutMillis;
+    private final boolean pageReuseEnabled;
+    private final int pageMaxRenderCount;
 
     public DefaultPlaywrightPdfWorkerFactory(double launchTimeoutMillis, double renderTimeoutMillis) {
+        this(launchTimeoutMillis, renderTimeoutMillis, true, 200);
+    }
+
+    public DefaultPlaywrightPdfWorkerFactory(
+            double launchTimeoutMillis,
+            double renderTimeoutMillis,
+            boolean pageReuseEnabled,
+            int pageMaxRenderCount) {
         this.launchTimeoutMillis = launchTimeoutMillis;
         this.renderTimeoutMillis = renderTimeoutMillis;
+        this.pageReuseEnabled = pageReuseEnabled;
+        this.pageMaxRenderCount = pageMaxRenderCount;
     }
 
     @Override
     public PlaywrightPdfWorker create() {
-        return new DefaultPlaywrightPdfWorker(launchTimeoutMillis, renderTimeoutMillis);
+        return new DefaultPlaywrightPdfWorker(launchTimeoutMillis, renderTimeoutMillis, pageReuseEnabled, pageMaxRenderCount);
     }
 }

+ 93 - 6
abilities/exam-sprint/infrastructure/src/test/java/cn/yunzhixue/ability/center/examsprint/infrastructure/report/pdf/PlaywrightExamSprintReportPdfGeneratorTest.java

@@ -119,12 +119,16 @@ class PlaywrightExamSprintReportPdfGeneratorTest {
         logger.addAppender(appender);
 
         try {
-            byte[] pdfBytes = pdfGenerator.generate("""
-                    <html>
-                    <head><meta charset=\"UTF-8\"/></head>
-                    <body><p>PDF 阶段计时日志</p></body>
-                    </html>
-                    """);
+            byte[] pdfBytes;
+            try (PlaywrightExamSprintReportPdfGenerator generator = new PlaywrightExamSprintReportPdfGenerator(
+                    new DefaultPlaywrightPdfWorkerFactory(30_000, 30_000, false, 200))) {
+                pdfBytes = generator.generate("""
+                        <html>
+                        <head><meta charset=\"UTF-8\"/></head>
+                        <body><p>PDF 阶段计时日志</p></body>
+                        </html>
+                        """);
+            }
 
             assertPdfHeader(pdfBytes);
         } finally {
@@ -167,6 +171,80 @@ class PlaywrightExamSprintReportPdfGeneratorTest {
                 .contains("durationMs="));
     }
 
+    @Test
+    void generateReusesPageWhenPageReuseIsEnabled() {
+        Logger logger = (Logger) LoggerFactory.getLogger(DefaultPlaywrightPdfWorker.class);
+        ListAppender<ILoggingEvent> appender = new ListAppender<>();
+        appender.start();
+        logger.addAppender(appender);
+
+        try (PlaywrightExamSprintReportPdfGenerator generator = new PlaywrightExamSprintReportPdfGenerator(
+                new DefaultPlaywrightPdfWorkerFactory(30_000, 30_000, true, 200))) {
+            assertPdfHeader(generator.generate(simpleHtml("第一页复用测试")));
+            assertPdfHeader(generator.generate(simpleHtml("第二页复用测试")));
+        } finally {
+            logger.detachAppender(appender);
+            appender.stop();
+        }
+
+        List<String> messages = appender.list.stream()
+                .map(ILoggingEvent::getFormattedMessage)
+                .toList();
+
+        assertThat(messages).anySatisfy(message -> assertThat(message)
+                .contains("PDF页面已准备")
+                .contains("pageReuseEnabled=true")
+                .contains("pageReused=false")
+                .contains("pageRenderCount=0"));
+        assertThat(messages).anySatisfy(message -> assertThat(message)
+                .contains("PDF页面已准备")
+                .contains("pageReuseEnabled=true")
+                .contains("pageReused=true")
+                .contains("pageRenderCount=1"));
+        assertThat(messages.stream()
+                        .filter(message -> message.contains("stage=创建浏览器上下文"))
+                        .count())
+                .isEqualTo(1);
+        assertThat(messages.stream()
+                        .filter(message -> message.contains("stage=创建页面"))
+                        .count())
+                .isEqualTo(1);
+    }
+
+    @Test
+    void generateRecreatesReusablePageAfterMaxRenderCount() {
+        Logger logger = (Logger) LoggerFactory.getLogger(DefaultPlaywrightPdfWorker.class);
+        ListAppender<ILoggingEvent> appender = new ListAppender<>();
+        appender.start();
+        logger.addAppender(appender);
+
+        try (PlaywrightExamSprintReportPdfGenerator generator = new PlaywrightExamSprintReportPdfGenerator(
+                new DefaultPlaywrightPdfWorkerFactory(30_000, 30_000, true, 1))) {
+            assertPdfHeader(generator.generate(simpleHtml("第一页达到阈值")));
+            assertPdfHeader(generator.generate(simpleHtml("第二页触发重建")));
+        } finally {
+            logger.detachAppender(appender);
+            appender.stop();
+        }
+
+        List<String> messages = appender.list.stream()
+                .map(ILoggingEvent::getFormattedMessage)
+                .toList();
+
+        assertThat(messages).anySatisfy(message -> assertThat(message)
+                .contains("PDF复用页面已重建")
+                .contains("reason=max_render_count")
+                .contains("pageMaxRenderCount=1"));
+        assertThat(messages.stream()
+                        .filter(message -> message.contains("stage=创建浏览器上下文"))
+                        .count())
+                .isEqualTo(2);
+        assertThat(messages.stream()
+                        .filter(message -> message.contains("stage=创建页面"))
+                        .count())
+                .isEqualTo(2);
+    }
+
     @Test
     void generateCreatesReadablePdfForOutlookReportTemplate() throws Exception {
         ClasspathOutlookExamSprintReportRenderer renderer = new ClasspathOutlookExamSprintReportRenderer(OBJECT_MAPPER);
@@ -222,6 +300,15 @@ class PlaywrightExamSprintReportPdfGeneratorTest {
         Files.write(previewPdfPath, pdfBytes);
     }
 
+    private String simpleHtml(String text) {
+        return """
+                <html>
+                <head><meta charset=\"UTF-8\"/></head>
+                <body><p>%s</p></body>
+                </html>
+                """.formatted(text);
+    }
+
     private String normalizePdfText(String text) {
         return Normalizer.normalize(text, Normalizer.Form.NFKC).replaceAll("\\s+", "");
     }

+ 5 - 1
ability-center-runtime/src/main/java/cn/yunzhixue/ability/center/examsprint/configuration/ExamSprintReportRuntimeConfiguration.java

@@ -51,6 +51,8 @@ public class ExamSprintReportRuntimeConfiguration {
         properties.getPdf().setBorrowTimeout(bound.getPdf().getBorrowTimeout());
         properties.getPdf().setLaunchTimeout(bound.getPdf().getLaunchTimeout());
         properties.getPdf().setRenderTimeout(bound.getPdf().getRenderTimeout());
+        properties.getPdf().setPageReuseEnabled(bound.getPdf().isPageReuseEnabled());
+        properties.getPdf().setPageMaxRenderCount(bound.getPdf().getPageMaxRenderCount());
         return properties;
     }
 
@@ -71,7 +73,9 @@ public class ExamSprintReportRuntimeConfiguration {
         ExamSprintReportProperties.Pdf pdf = properties.getPdf();
         return new DefaultPlaywrightPdfWorkerFactory(
                 pdf.getLaunchTimeout().toMillis(),
-                pdf.getRenderTimeout().toMillis());
+                pdf.getRenderTimeout().toMillis(),
+                pdf.isPageReuseEnabled(),
+                pdf.getPageMaxRenderCount());
     }
 
     @Bean

+ 2 - 0
ability-center-runtime/src/main/resources/application-prod.yml

@@ -13,6 +13,8 @@ ability:
         borrow-timeout: 60s
         launch-timeout: 45s
         render-timeout: 60s
+        page-reuse-enabled: ${ABILITY_EXAM_SPRINT_REPORT_PDF_PAGE_REUSE_ENABLED:true}
+        page-max-render-count: ${ABILITY_EXAM_SPRINT_REPORT_PDF_PAGE_MAX_RENDER_COUNT:200}
       storage:
         type: azure
         connection-string: "${AZURE_BLOB_CONNECTION_STRING:DefaultEndpointsProtocol=https;AccountName=dcjxb;AccountKey=+Bg9srieVxwemxcE2b+icL3t3hp8m04PuYanTl6fwB/Cx1SF49qimBpYvXQjcvatgDDDgxjqYDP/0DCFTSQcgg==;EndpointSuffix=core.chinacloudapi.cn;}"

+ 2 - 0
ability-center-runtime/src/main/resources/application-test.yml

@@ -13,6 +13,8 @@ ability:
         borrow-timeout: 60s
         launch-timeout: 45s
         render-timeout: 60s
+        page-reuse-enabled: true
+        page-max-render-count: 200
       storage:
         type: azure
         connection-string: "DefaultEndpointsProtocol=https;AccountName=dcjxbtest;AccountKey=CoOzFKq3/aecqY8JehnW+oV3XYe8dN8772NQbhT5VzYO5fdrx+Ps/LhmPqv9U/M28BtqSrgN13pjJqPvIRdI2w==;EndpointSuffix=core.chinacloudapi.cn"

+ 3 - 0
ability-center-runtime/src/main/resources/application.yml

@@ -4,6 +4,9 @@ ability:
       retention: 1d
       download-expiry: 15m
       cleanup-interval-ms: 600000
+      pdf:
+        page-reuse-enabled: true
+        page-max-render-count: 200
       storage:
         type: memory
         container-name: report

+ 24 - 1
ability-center-runtime/src/test/java/cn/yunzhixue/ability/center/examsprint/configuration/ExamSprintReportRuntimeConfigurationTest.java

@@ -13,10 +13,15 @@ import cn.yunzhixue.ability.center.examsprint.infrastructure.report.rendering.ou
 import com.fasterxml.jackson.databind.ObjectMapper;
 import org.junit.jupiter.api.Test;
 import org.springframework.boot.test.context.runner.ApplicationContextRunner;
+import org.springframework.boot.context.properties.bind.Binder;
+import org.springframework.boot.env.YamlPropertySourceLoader;
 import org.springframework.context.annotation.Bean;
 import org.springframework.context.annotation.Configuration;
 import org.springframework.context.annotation.Import;
+import org.springframework.core.env.StandardEnvironment;
+import org.springframework.core.io.ClassPathResource;
 
+import java.io.IOException;
 import java.time.Duration;
 import java.time.Instant;
 
@@ -46,6 +51,8 @@ class ExamSprintReportRuntimeConfigurationTest {
         bound.getPdf().setBorrowTimeout(Duration.ofSeconds(12));
         bound.getPdf().setLaunchTimeout(Duration.ofSeconds(13));
         bound.getPdf().setRenderTimeout(Duration.ofSeconds(14));
+        bound.getPdf().setPageReuseEnabled(false);
+        bound.getPdf().setPageMaxRenderCount(123);
 
         ExamSprintReportProperties properties = configuration.examSprintReportProperties(bound);
 
@@ -53,6 +60,23 @@ class ExamSprintReportRuntimeConfigurationTest {
         assertThat(properties.getPdf().getBorrowTimeout()).isEqualTo(Duration.ofSeconds(12));
         assertThat(properties.getPdf().getLaunchTimeout()).isEqualTo(Duration.ofSeconds(13));
         assertThat(properties.getPdf().getRenderTimeout()).isEqualTo(Duration.ofSeconds(14));
+        assertThat(properties.getPdf().isPageReuseEnabled()).isFalse();
+        assertThat(properties.getPdf().getPageMaxRenderCount()).isEqualTo(123);
+    }
+
+    @Test
+    void productionYamlExposesPdfPageReuseSettingsForOperationsOverride() throws IOException {
+        StandardEnvironment environment = new StandardEnvironment();
+        YamlPropertySourceLoader loader = new YamlPropertySourceLoader();
+        loader.load("application-prod", new ClassPathResource("application-prod.yml"))
+                .forEach(propertySource -> environment.getPropertySources().addLast(propertySource));
+
+        ExamSprintReportRuntimeConfiguration.BoundExamSprintReportProperties bound = Binder.get(environment)
+                .bind("ability.exam-sprint.report", ExamSprintReportRuntimeConfiguration.BoundExamSprintReportProperties.class)
+                .orElseThrow(IllegalStateException::new);
+
+        assertThat(bound.getPdf().isPageReuseEnabled()).isTrue();
+        assertThat(bound.getPdf().getPageMaxRenderCount()).isEqualTo(200);
     }
 
     @Test
@@ -92,7 +116,6 @@ class ExamSprintReportRuntimeConfigurationTest {
         new ApplicationContextRunner()
                 .withUserConfiguration(OutlookRendererConfiguration.class)
                 .run(context -> assertThat(context)
-                        .hasBean("classpathOutlookExamSprintReportRenderer")
                         .hasSingleBean(ClasspathOutlookExamSprintReportRenderer.class)
                         .hasSingleBean(ExamSprintReportRenderer.class));
     }