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

相关推荐
en-route2 小时前
Redis 作为消息队列的三种使用方式与 Spring Boot 实践
数据库·spring boot·redis
小坏讲微服务2 小时前
Spring Boot 4.0 + MyBatis-Plus 实战响应式编程的能力实战
java·spring boot·后端·mybatis
张较瘦_2 小时前
Springboot3 | JUnit 5 使用详解
spring boot·junit
李慕婉学姐2 小时前
Springboot遇见宠物生活馆系统设计与实现n6ea5118(程序+源码+数据库+调试部署+开发环境)带论文文档1万字以上,文末可获取,系统界面在最后面。
数据库·spring boot·宠物
BAStriver2 小时前
关于Flowable的使用小结
java·spring boot·spring·flowable
十一.3663 小时前
106-110 操作内联样式,获取元素的样式,其他样式相关的属性
前端·html
Dolphin_Home3 小时前
Java Stream 实战:订单商品ID过滤技巧(由浅入深)
java·开发语言·spring boot
白宇横流学长5 小时前
基于SpringBoot实现的垃圾分类管理系统
java·spring boot·后端
tang&6 小时前
【Python自动化测试】Selenium常用函数详解
开发语言·python·selenium