springboot+Selenium 实现html转图片(内含驱动包)

近期在业务中遇到一个问题,即需要将一个html的文本转换成一个图片,并且需要保留图片的像素大小,上面涉及到的字体样式等等,最终通过了以下方案来实现。在 Java Spring Boot 框架中,结合 Selenium 实现 HTML 富文本转图片的方案,核心思路是利用 Selenium 控制浏览器渲染 HTML 内容,再通过截图功能将其转换为图片。

一、添加依赖

java 复制代码
<!-- Selenium核心 -->
<dependency>
    <groupId>org.seleniumhq.selenium</groupId>
    <artifactId>selenium-java</artifactId>
    <version>4.15.0</version>
</dependency>
<!-- ChromeDriver(需与本地Chrome版本匹配) -->
<dependency>
    <groupId>org.seleniumhq.selenium</groupId>
    <artifactId>selenium-chrome-driver</artifactId>
    <version>4.15.0</version>
</dependency>

二、实现步骤

1、下载符合当前环境的驱动包

一般都是需要本地windows和linux双环境的驱动包,会涉及到本地的调试功能(后续代码中会涉及到)

注:我当前linux环境容器的版本较低(而且没有完整的驱动包,需要在dockerfile中启动的时候就部署,但是会有一个rpm文件,文件我会和其他版本的一些下载链接附着在文章末尾),所以我个人使用的驱动包是比较老的版本但是不影响功能,各位在使用的时候可以按照环境下载比较新的版本的驱动,兼容性会更好一些

2、代码实现

1、先创建驱动实例

java 复制代码
@Slf4j
@Configuration
public class SeleniumPoolConfig {

    // 配置参数(可根据服务器性能调整)
    private static final int POOL_SIZE = 2; // 浏览器实例数量
    private static final long TIMEOUT_SECONDS = 30; // 获取实例超时时间
    private final String driverPath;
    private final String chromePath;
    private final String osName;

    // 维护WebDriver实例及状态(true=空闲,false=占用)
    private final ConcurrentHashMap<WebDriver, Boolean> driverPool = new ConcurrentHashMap<>();
    // 用于等待空闲实例的信号量
    private final Semaphore semaphore = new Semaphore(POOL_SIZE);

    // 初始化驱动路径(构造器执行,早于@Bean)
    public SeleniumPoolConfig() {
        this.osName = System.getProperty("os.name").toLowerCase();
        log.info("操作系统: {}", osName);

        // 初始化驱动路径
        if (osName.contains("windows")) {
            String path = Objects.requireNonNull(getClass().getClassLoader().getResource("chromeDriver/chromedriver.exe")).getPath();
            this.driverPath = path.replaceFirst("file:/", "").replace("%20", " ");
            this.chromePath = null;
        } else {
            this.driverPath = "/opt/chromedriver/chromedriver";
            this.chromePath = "/opt/google/chrome/chrome";
        }
        System.setProperty("webdriver.chrome.driver", driverPath);
        log.info("浏览器驱动路径: {}", driverPath);
        log.info("浏览器可执行文件路径: {}", chromePath);
    }

    /**
     * 初始化WebDriver池(应用启动时执行)
     */
    @PostConstruct
    public void initDriverPool() {
        // 先清理残留进程
        if (osName.contains("linux")) {
            cleanUpLinuxChromeProcesses();
        }

        // 预热浏览器实例
        for (int i = 0; i < POOL_SIZE; i++) {
            try {
                WebDriver driver = createDriver();
                driverPool.put(driver, true); // 标记为空闲
                log.info("初始化浏览器池实例 {} 成功", i + 1);
            } catch (Exception e) {
                log.error("初始化浏览器池实例 {} 失败", i + 1, e);
                throw new RuntimeException("浏览器池初始化失败", e);
            }
        }
    }

    /**
     * 获取空闲的WebDriver实例(核心方法)
     */
    public WebDriver borrowDriver() throws Exception {
        // 获取信号量许可(超时则抛出异常)
        if (!semaphore.tryAcquire(TIMEOUT_SECONDS, TimeUnit.SECONDS)) {
            throw new RuntimeException("获取浏览器实例超时,当前所有实例均在使用中");
        }

        try {
            // 循环查找空闲实例
            while (true) {
                for (Map.Entry<WebDriver, Boolean> entry : driverPool.entrySet()) {
                    // 原子操作:如果是空闲状态,则标记为占用
                    if (entry.getValue() && driverPool.replace(entry.getKey(), true, false)) {
                        WebDriver driver = entry.getKey();
                        // 验证实例是否可用
                        if (isDriverValid(driver)) {
                            log.info("获取到空闲浏览器实例: {}", driver);
                            return driver;
                        } else {
                            // 实例无效,销毁并重建
                            log.info("浏览器实例 {} 已失效,重建中...", driver);
                            destroyDriver(driver);
                            WebDriver newDriver = createDriver();
                            driverPool.put(newDriver, false);
                            return newDriver;
                        }
                    }
                }
                // 暂时没有空闲实例,等待100ms重试
                Thread.sleep(100);
            }
        } catch (Exception e) {
            semaphore.release(); // 释放信号量
            throw e;
        }
    }

    /**
     * 归还WebDriver实例到池(使用后必须调用)
     */
    public void returnDriver(WebDriver driver) {
        if (driver == null) return;

        try {
            // 重置浏览器状态(清除缓存、Cookie等)
            driver.manage().deleteAllCookies();
            driver.navigate().to("about:blank"); // 跳转到空白页

            // 标记为空闲
            driverPool.replace(driver, false, true);
            log.info("浏览器实例 {} 已归还", driver);
        } catch (Exception e) {
            log.error("归还浏览器实例失败,将销毁该实例", e);
            destroyDriver(driver);
            try {
                // 重建实例补充到池
                WebDriver newDriver = createDriver();
                driverPool.put(newDriver, true);
            } catch (Exception ex) {
                log.error("重建浏览器实例失败", ex);
            }
        } finally {
            semaphore.release(); // 释放信号量
        }
    }

    /**
     * 创建新的WebDriver实例
     */
    private WebDriver createDriver() throws Exception {
        ChromeOptions options = new ChromeOptions();
        if (StringUtils.isNotBlank(chromePath)) {
            options.setBinary(chromePath);
        }

        List<String> args = new ArrayList<>();
        args.add("--memory-limit=1024000"); // 限制最大内存为 1024MB(单位:KB)  避免无限占用内存导致的内存溢出,影响主服务
        args.add("--headless=new");
        args.add("--disable-gpu");
        args.add("--window-size=1920,1080");
        args.add("--user-agent=Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/114.0.0.0 Safari/537.36");

        if (osName.contains("linux")) {
            args.add("--enable-features=VizDisplayCompositor");   // 强制走合成器
            args.add("--disable-software-rasterizer");            // 禁止退回 CPU 光栅
            args.add("--use-gl=swiftshader-webgl");
            args.add("--disable-dev-shm-usage");
            args.add("--no-sandbox");
            args.add("--no-user-data-dir");
            args.add("--single-process");
        }

        options.addArguments(args);

        // 多尝试机制
        int maxAttempts = 2;
        for (int i = 0; i < maxAttempts; i++) {
            try {
                return new ChromeDriver(options);
            } catch (Exception e) {
                log.info("第{}次创建浏览器实例失败,重试中...", i + 1, e);
                if (i == maxAttempts - 1) {
                    throw new RuntimeException("创建浏览器实例失败", e);
                }
                Thread.sleep(1000);
            }
        }
        throw new RuntimeException("创建浏览器实例失败");
    }

    /**
     * 验证WebDriver实例是否有效
     */
    private boolean isDriverValid(WebDriver driver) {
        try {
            // 执行简单命令验证实例是否存活
            driver.getCurrentUrl();
            return true;
        } catch (Exception e) {
            log.error("浏览器实例已失效", e);
            return false;
        }
    }

    /**
     * 销毁WebDriver实例
     */
    private void destroyDriver(WebDriver driver) {
        if (driver == null) return;
        try {
            driver.quit(); // 优雅关闭
            log.info("浏览器实例 {} 已销毁", driver);
        } catch (Exception e) {
            log.info("销毁浏览器实例失败", e);
        } finally {
            driverPool.remove(driver);
        }
    }

    /**
     * 应用关闭时清理资源
     */
    @PreDestroy
    public void destroyAllDrivers() {
        log.info("开始清理所有浏览器实例...");
        driverPool.keySet().forEach(this::destroyDriver);
        driverPool.clear();
        log.info("所有浏览器实例清理完成");
    }

    private void cleanUpLinuxChromeProcesses() {
        try {
            log.info("开始清理Linux残留Chrome进程");
            Process process = Runtime.getRuntime().exec("pgrep -f 'chrome|chromedriver'");
            process.waitFor(2, TimeUnit.SECONDS);

            try (BufferedReader reader = new BufferedReader(
                    new InputStreamReader(process.getInputStream(), StandardCharsets.UTF_8))) { // 明确字符集
                String pidLine;
                while ((pidLine = reader.readLine()) != null) {
                    String pid = pidLine.trim();
                    if (pid.isEmpty()) {
                        log.warn("跳过空PID行");
                        continue;
                    }
                    // 1. 校验PID合法性(数字+范围)
                    if (!isValidPid(pid)) {
                        log.warn("发现无效PID: {}, 已跳过", pid);
                        continue;
                    }
                    // 2. 安全验证是否为目标进程
                    if (!isTargetProcess(pid)) {
                        log.warn("进程PID: {} 非目标进程,已跳过", pid);
                        continue;
                    }
                    // 3. 安全杀死进程
                    killProcess(pid);
                }
            } catch (IOException e) {
                log.error("读取进程输出失败", e);
            }
            log.info("Linux进程清理完成");
        } catch (Exception e) {
            log.info("Linux进程清理异常(非致命)", e);
        }
    }

    // 验证PID是否为纯数字且在合理范围(1到系统最大PID)
    private boolean isValidPid(String pid) {
        if (!pid.matches("^\\d+$")) {
            return false;
        }
        try {
            long pidNum = Long.parseLong(pid);
            // Linux系统PID通常从1开始,最大可通过/proc/sys/kernel/pid_max获取(默认32768)
            return pidNum >= 1 && pidNum <= 32768;
        } catch (NumberFormatException e) {
            return false;
        }
    }

    // 执行kill命令时增加异常处理和结果校验
    private void killProcess(String pid) {
        try {
            Process killProcess = new ProcessBuilder("kill", "-9", pid)
                    .redirectErrorStream(true)
                    .start();
            boolean exitCode = killProcess.waitFor(1, TimeUnit.SECONDS);
            if (!exitCode) {
                // 读取错误信息
                String error = new BufferedReader(
                        new InputStreamReader(killProcess.getInputStream(), StandardCharsets.UTF_8))
                        .lines().collect(Collectors.joining("\n"));
                log.error("杀死进程PID: {} 失败,退出码: {},错误: {}", pid, exitCode, error);
            } else {
                log.info("成功杀死进程PID: {}", pid);
            }
        } catch (Exception e) {
            log.error("杀死进程PID: {} 时发生异常", pid, e);
        }
    }


    /**
     * 安全验证进程是否为目标进程
     * @param pid 已校验为纯数字的进程ID
     * @return 是否为目标进程
     */
    private boolean isTargetProcess(String pid) {
        try {
            // 1. 用数组传参避免命令注入,查询进程详情
            Process process = new ProcessBuilder("ps", "-p", pid, "-o", "comm=") // 只输出进程名
                    .redirectErrorStream(true) // 合并错误流到输入流
                    .start();

            // 2. 读取进程名并校验(假设目标进程名为"target-process")
            try (BufferedReader reader = new BufferedReader(
                    new InputStreamReader(process.getInputStream(), StandardCharsets.UTF_8))) {
                String comm = reader.readLine();
                // 3. 确保进程存在且名称匹配(根据实际场景调整匹配逻辑)
                return comm != null && "target-process".equals(comm.trim());
            } finally {
                process.waitFor(1, TimeUnit.SECONDS); // 等待进程结束,避免资源泄漏
            }
        } catch (Exception e) {
            log.warn("验证进程PID: {} 失败", pid, e);
            return false; // 验证失败则视为非目标进程
        }
    }


    /**
     * 每天凌晨1点重新初始化所有浏览器实例(清理长期运行的实例)
     * 浏览器会持续占用内存,可每天重新初始化实例释放内存
     * cron表达式:秒 分 时 日 月 周(0 0 1 * * ? 表示每天1点0分0秒)
     */
    @XxlJob("reinitializeDriversAtMidnight")
    public void reinitializeDriversAtMidnight() {
        log.info("开始执行凌晨1点实例重建任务...");

        // 1. 等待所有实例归还(避免中断正在执行的任务)
        try {
            // 尝试获取所有信号量许可(若有实例在使用,会阻塞直到归还)
            // 超时时间设为10分钟(根据业务最大任务耗时调整)
            boolean allReleased = semaphore.tryAcquire(POOL_SIZE, 10, TimeUnit.MINUTES);
            if (!allReleased) {
                log.warn("等待10分钟后仍有实例未归还,强制重建(可能影响部分任务)");
            }
        } catch (InterruptedException e) {
            log.error("等待实例归还时被中断", e);
            Thread.currentThread().interrupt();
            return;
        }

        try {
            // 2. 销毁所有旧实例
            log.info("销毁所有旧实例...");
            driverPool.keySet().forEach(this::destroyDriver);
            driverPool.clear();

            // 3. 重建新实例
            log.info("开始重建新实例...");
            for (int i = 0; i < POOL_SIZE; i++) {
                WebDriver newDriver = createDriver();
                driverPool.put(newDriver, true);
                log.info("重建实例 {} 成功", i + 1);
            }
            log.info("凌晨1点实例重建任务完成");
        } catch (Exception e) {
            log.error("实例重建失败", e);
            // 若重建失败,尝试恢复至少1个实例保证基本可用
            try {
                WebDriver fallbackDriver = createDriver();
                driverPool.put(fallbackDriver, true);
                log.info("重建失败后,恢复1个备用实例");
            } catch (Exception ex) {
                log.error("备用实例恢复失败", ex);
            }
        } finally {
            // 4. 释放所有信号量许可(重置为初始状态)
            int permitsToRelease = POOL_SIZE - semaphore.availablePermits();
            if (permitsToRelease > 0) {
                semaphore.release(permitsToRelease);
            }
        }
    }
}

2、转图片方法

java 复制代码
@Slf4j
public class Test {

    @Autowired
    private SeleniumPoolConfig driverPool;


    /**
     * 将HTML富文本转换为图片入口
     * @param htmlContent HTML富文本内容
     * @return 图片路径
     */
    public void convertHtmlToImage(String htmlContent) {
        WebDriver webDriver = null;
        File tempHtmlFile = null;
        // 总耗时起点
        long totalStartTime = System.currentTimeMillis();
        log.info("===== HTML转图片处理开始 =====");
        try {
            // 从池里获取浏览器实例
            long borrowDriverStartTime = System.currentTimeMillis();
            webDriver = driverPool.borrowDriver();
            log.info("获取浏览器实例耗时: {}ms", System.currentTimeMillis() - borrowDriverStartTime);
            // 1. 创建临时HTML文件并加载
            tempHtmlFile = createTempHtmlFile(htmlContent);
            webDriver.get("file:///" + tempHtmlFile.getAbsolutePath().replace("\\", "/"));

            // 1. 等字体加载完成
            long waitFontsReadyStartTime = System.currentTimeMillis();
            ((JavascriptExecutor) webDriver).executeScript("return document.fonts.ready");
            // 2. 等一次合成帧
            ((JavascriptExecutor) webDriver).executeScript("return new Promise(r => requestAnimationFrame(r))");

            // 2. 等待页面完全渲染(针对动态内容,如JS加载的元素)
            Thread.sleep(200);
            log.info("等字体加载完成耗时: {}ms", System.currentTimeMillis() - waitFontsReadyStartTime);
            
            // 4. 调整浏览器窗口大小以适配内容(加100避免边缘截断)
            webDriver.manage().window().setSize(new Dimension(3840, 2160));

            // 5. 再次等待窗口调整完成
            Thread.sleep(100);

            // 6. 截取完整页面截图
            File targetFile = ((TakesScreenshot) webDriver).getScreenshotAs(OutputType.FILE);

            // 7. 这里可以对文件进行处理,例如保存到文件服务器等等
        }catch (Exception e){
            log.error("图片生成失败, 异常信息: {}", e.getMessage(), e);
            throw new BusinessException("图片生成失败");
        }finally {
            //清理临时文件
            if (tempHtmlFile != null) {
                tempHtmlFile.delete();
            }
            // 归还实例到池(必须执行)
            if (webDriver != null) {
                driverPool.returnDriver(webDriver);
            }
        }
    }

    /**
     * 创建临时HTML文件,用于承载富文本内容
     */
    private File createTempHtmlFile(String htmlContent) throws IOException {
        // 创建临时文件
        File tempFile = File.createTempFile("temp_html_", ".html");
        // 设置JVM退出时自动删除临时文件
        tempFile.deleteOnExit();

        // 写入HTML内容
        FileUtils.writeStringToFile(tempFile, htmlContent, StandardCharsets.UTF_8);
        return tempFile;
    }

}

三、部署

在服务部署的时候,可根据实际情况来处理,目前我的环境是采用dockerfile来部署的,所以需要先建立一个dockerfile文件,用于启动前的包部署和需要的字体等等一些的初始化,各位可依据自身环境来处理即可(任何问题可留言讨论。。。。)

四、驱动下载

附(整理了一些版本比较齐全的可以下载驱动包的路径):

1、linux版本下载地址 : https://wojc.cn/archives/1195.html

2、https://pan.quark.cn/s/ccf4fe478b04#/list/share/5da77429824943e4ac1e5507d8858801

3、https://blog.csdn.net/weixin_43873210/article/details/145723166

相关推荐
用户83071968408210 小时前
Spring Boot 集成 RabbitMQ :8 个最佳实践,杜绝消息丢失与队列阻塞
spring boot·后端·rabbitmq
Java水解11 小时前
Spring Boot 视图层与模板引擎
spring boot·后端
Java水解11 小时前
一文搞懂 Spring Boot 默认数据库连接池 HikariCP
spring boot·后端
洋洋技术笔记15 小时前
Spring Boot Web MVC配置详解
spring boot·后端
初次攀爬者1 天前
Kafka 基础介绍
spring boot·kafka·消息队列
用户8307196840821 天前
spring ai alibaba + nacos +mcp 实现mcp服务负载均衡调用实战
spring boot·spring·mcp
Java水解1 天前
SpringBoot3全栈开发实战:从入门到精通的完整指南
spring boot·后端
willow2 天前
html5基础整理
html
初次攀爬者2 天前
RocketMQ在Spring Boot上的基础使用
java·spring boot·rocketmq
花花无缺2 天前
搞懂@Autowired 与@Resuorce
java·spring boot·后端