项目需求

最近,项目接到了一个新需求,要求对指定URL进行后端模拟前端请求,对页面进行截图,具体要求如下:

  • 纯后端模拟,不打开前端页面
  • 截全屏,也就是不管页面有多长,都要截取到一张图片上
  • 只要求截取浏览器DOM以内的部分,DOM以外不要截取
  • 保证页面不失真,页面渲染与实际一直
  • 确保图片清晰度
  • 能够支持多并发请求

功能调研

接到项目需求后,我就对Java实现的截图功能进行了一些前期调研,调研过程如下:

AWT

首先想到的是比较简单的Root,它应用简单,完全自动化,Java自带功能,包名java.awt。于是编写上手实验:

import javax.imageio.ImageIO;
import java.awt.*;
import java.awt.event.KeyEvent;
import java.awt.image.BufferedImage;
import java.io.File;
import java.net.URL;

public class AWTTest {

    public static void main(String[] args) throws Exception {
        // 此方法仅适用于JdK1.6及以上版本
        Desktop.getDesktop().browse(new URL("http://www.baidu.com/").toURI());
        Robot robot = new Robot();
        robot.delay(10000);
        Dimension d = new Dimension(Toolkit.getDefaultToolkit().getScreenSize());
        int width = (int) d.getWidth();
        int height = (int) d.getHeight();
        // 最大化浏览器
        robot.keyRelease(KeyEvent.VK_F11);
        robot.delay(2000);
        Image image = robot.createScreenCapture(new Rectangle(0, 0, width, height));
        BufferedImage bi = new BufferedImage(width, height, BufferedImage.TYPE_INT_RGB);
        Graphics g = bi.createGraphics();
        g.drawImage(image, 0, 0, width, height, null);
        // 保存图片
        ImageIO.write(bi, "jpg", new File("/data/test.jpg"));
    }
}

截图效果:

记一次"截图"功能的前期调研过程!-LMLPHP

Swing

AWT相比,Swing是基于awt的Java程序,包名javax.swing。它不仅提供了AWT的所有功能,还用纯粹的Java代码对AWT的功能进行了大幅度的扩充。它是为解决AWT存在的问题而新开发的图形界面包。

对Swing的测试,我们采用DJNativeSwing-SWT,它是java内嵌浏览器API,需要用到的依赖包有:

<dependency>
    <groupId>com.hynnet</groupId>
    <artifactId>DJNativeSwing</artifactId>
    <version>1.0.0</version>
</dependency>
<dependency>
    <groupId>com.hynnet</groupId>
    <artifactId>DJNativeSwing-SWT</artifactId>
    <version>1.0.0</version>
</dependency>
<dependency>
    <groupId>org.eclipse.swt.org.eclipse.swt.win32.win32.x86_64.4.3.swt</groupId>
    <artifactId>org.eclipse.swt.win32.win32.x86_64</artifactId>
    <version>4.3</version>
</dependency>

测试代码:

import chrriis.dj.nativeswing.swtimpl.NativeComponent;
import chrriis.dj.nativeswing.swtimpl.NativeInterface;
import chrriis.dj.nativeswing.swtimpl.components.JWebBrowser;
import chrriis.dj.nativeswing.swtimpl.components.WebBrowserAdapter;
import chrriis.dj.nativeswing.swtimpl.components.WebBrowserEvent;

import javax.imageio.ImageIO;
import javax.swing.*;
import java.awt.*;
import java.awt.image.BufferedImage;
import java.io.File;
import java.io.IOException;

public class SwingTest extends JPanel {
    private static final long serialVersionUID = 1L;

    // 行分隔符
    public final static String LS = System.getProperty("line.separator", "\n");
    // 文件分割符
    public final static String FS = System.getProperty("file.separator", "\\");
    // 以javascript脚本获得网页全屏后大小
    private final static StringBuffer JS_DIMENSION;

    static {
        JS_DIMENSION = new StringBuffer();
        JS_DIMENSION.append("var width = 0;").append(LS);
        JS_DIMENSION.append("var height = 0;").append(LS);
        JS_DIMENSION.append("if(document.documentElement) {").append(LS);
        JS_DIMENSION.append("  width = Math.max(width, document.documentElement.scrollWidth);").append(LS);
        JS_DIMENSION.append("  height = Math.max(height, document.documentElement.scrollHeight);").append(LS);
        JS_DIMENSION.append("}").append(LS);
        JS_DIMENSION.append("if(self.innerWidth) {").append(LS);
        JS_DIMENSION.append("  width = Math.max(width, self.innerWidth);").append(LS);
        JS_DIMENSION.append("  height = Math.max(height, self.innerHeight);").append(LS);
        JS_DIMENSION.append("}").append(LS);
        JS_DIMENSION.append("if(document.body.scrollWidth) {").append(LS);
        JS_DIMENSION.append("  width = Math.max(width, document.body.scrollWidth);").append(LS);
        JS_DIMENSION.append("  height = Math.max(height, document.body.scrollHeight);").append(LS);
        JS_DIMENSION.append("}").append(LS);
        JS_DIMENSION.append("return width + ':' + height;");
    }

    public SwingTest(final String url, final String fileName) {
        super(new BorderLayout());
        JPanel webBrowserPanel = new JPanel(new BorderLayout());
        final JWebBrowser webBrowser = new JWebBrowser(null);
        webBrowser.setBarsVisible(false);
        webBrowser.navigate(url);
        webBrowserPanel.add(webBrowser, BorderLayout.CENTER);
        add(webBrowserPanel, BorderLayout.CENTER);
        JPanel panel = new JPanel(new FlowLayout(FlowLayout.CENTER, 4, 4));
        webBrowser.addWebBrowserListener(new WebBrowserAdapter() {
            @Override
            public void loadingProgressChanged(WebBrowserEvent e) {
                // 当加载完毕时
                if (e.getWebBrowser().getLoadingProgress() == 100) {
                    String result = (String) webBrowser.executeJavascriptWithResult(JS_DIMENSION.toString());
                    int index = result == null ? -1 : result.indexOf(":");
                    NativeComponent nativeComponent = webBrowser.getNativeComponent();
                    Dimension originalSize = nativeComponent.getSize();
                    Dimension imageSize = new Dimension(Integer.parseInt(result.substring(0, index)), Integer.parseInt(result.substring(index + 1)));
                    imageSize.width = Math.max(originalSize.width, imageSize.width + 50);
                    imageSize.height = Math.max(originalSize.height, imageSize.height + 50);
                    nativeComponent.setSize(imageSize);
                    BufferedImage image = new BufferedImage(imageSize.width, imageSize.height, BufferedImage.TYPE_INT_RGB);
                    nativeComponent.paintComponent(image);
                    nativeComponent.setSize(originalSize);
                    try {
                        // 输出图像
                        ImageIO.write(image, "PNG", new File(fileName));
                    } catch (IOException ex) {
                        ex.printStackTrace();
                    }
                    // 退出操作
                    System.exit(0);
                }
            }
        });
        add(panel, BorderLayout.SOUTH);
    }

    public static void main(String[] args) {
        NativeInterface.open();
        SwingUtilities.invokeLater(() -> {
            // SWT组件转Swing组件,不初始化父窗体将无法启动webBrowser
            JFrame frame = new JFrame("以DJ组件保存指定网页截图");

            // 实际项目中传入URL参数,根据不同参数截取不同网页快照,保存地址也可以在构造器中多设置一个参数,保存到指定目录
            frame.getContentPane().add(new SwingTest("https://www.baidu.com/", "/data/test.png"), BorderLayout.CENTER);
            frame.setSize(1024, 1024);

            // 仅初始化,但不显示
            frame.invalidate();
            frame.pack();
            frame.setVisible(false);
        });
        NativeInterface.runEventPump();
    }
}

截图效果:

记一次&quot;截图&quot;功能的前期调研过程!-LMLPHP

Swing控件是改善为了AWT控件而发展出来的轻量级GUI控件,采用的是Composite设计模式,然而,由于没有清楚的分隔组件(Component)和容器(Container)的边界,就造成了Swing的几乎每个单独的组件都是一个容器,能够添加其他容器或者组件,其功能非常强大,但也存在以下一些的问题:

  • 与直觉不太一致:Swing的GUI上的各种组件如果添加的面板过多的话,就造成各个组件的层次很深,处理类似focus管理这样的问题就很麻烦,坐标的转换也很复杂,由于父子关系过多,您不看代码只看GUI,凭直觉难以区分组件的父子关系。
  • 布局上的困难:使用Swing开发界面的程序员会发现,即使Swing提供了这么多布局管理器,然而您想通过这些布局管理器做出很专业的界面却非常难,因为布局管理器非常依赖父容器和子组件的各种状态,尽管Swing最新的版本提供了类似组件和容器间隔的方法,然而还没有被大部分布局管理器采用,其实并不是布局管理器不够强大的问题,事实上,很多专业的界面需要从组件级别做出良好的定义,另外,不少Swing组件会根据容器的大小进行绘制,这也造成了很多不确定性,很多人喜欢使用NullLayout,可能就是这个原因,客户需要的是一个稳定的,可预知的界面,如果使用了布局管理器,会发现界面在不同的系统下展示的不同
  • 使用上的困扰:Swing组件本身由于不能分清是组件还是容器,很多容器方法比如setEnabled就没有效果,需要写代码遍历所有子组件,调用所 有的子组件相同的方法,而类似设置透明的方法也有这个问题,如果设置某个容器透明,也需要设置所有的子组件的透明属性,组件和容器的很多方法没有很好的定 义,这对了解Swing结构的人不是问题,但是对于熟悉别的GUI类库的人就产生了很大的困惑,因为不少容器上的方法调用后是没有效果的。

总得来说,对Composite设计模式应该慎用,如果一定要用,一定要良好的定义组件(Component)和容器(Container)的边界,避免很多功能陷入没有意义的父子遍历例程,增加了复杂性。

Html2Image

Html2Image是一个将html转成图片的工具,它在html转图片的领域使用率是不低的。引入依赖:

<dependency>
    <groupId>gui.ava</groupId>
    <artifactId>html2image</artifactId>
    <version>0.9</version>
</dependency>

测试代码:

import gui.ava.html.image.generator.HtmlImageGenerator;

public class Html2Image {

    public static void main(String[] arg) throws Exception {
        String html = "<table width='654' cellpadding='0' cellspacing='0' bordercolor='#FFFFFF'><tr><td><img src='https://images.cnblogs.com/cnblogs_com/ason-wxs/1814743/o_200727072807xiaoxiong.jpg'/></td><td><img src='https://images.cnblogs.com/cnblogs_com/ason-wxs/1814743/o_200727072807xiaoxiong.jpg'/></td></tr><tr><td><img src='https://images.cnblogs.com/cnblogs_com/ason-wxs/1814743/o_200727072807xiaoxiong.jpg'/></td><td><img src='https://images.cnblogs.com/cnblogs_com/ason-wxs/1814743/o_200727072807xiaoxiong.jpg'/></td></tr></table>";
        HtmlImageGenerator imageGenerator = new HtmlImageGenerator();
        imageGenerator.loadHtml(html);
        Thread.sleep(5000);
        imageGenerator.saveAsImage("/data/test.png");
        Thread.sleep(5000);
    }
}

测试代码比较简单,手绘了一个Table,放入两行图片,看一下效果:

记一次&quot;截图&quot;功能的前期调研过程!-LMLPHP

所以,Html2Image的缺点也很明显:

  • 当你的html页面引入外部的CSS文件以及JS文件,生成的图片是无法带有这些动态效果的。也就是说,它不支持复杂的动态特性,只能支持写在html代码里的css效果。
  • 当html代码里带有图片时,生成的程序必须有一定的等待时间,否则生成的图片会有空白,所以需要设法在代码生成图片前让程序等待一会,比如Thread.sleep(8000)
  • 调试不易,很容易出现图不清楚、有边框、字体被洗白等等情况。
  • ...

PhantomJS

PhantomJS是一个可编程的无头浏览器。适用于页面自动化,网页监控,网络爬虫等:

  • 页面自动化测试:希望自动的登陆网站并做一些操作然后检查结果是否正常。
  • 网页监控:希望定期打开页面,检查网站是否能正常加载,加载结果是否符合预期。加载速度如何等。
  • 网络爬虫:获取页面中使用js来下载和渲染信息,或者是获取链接处使用js来跳转后的真实地址。

PhantomJS官网下载地址:http://phantomjs.org/,下载后可以直接解压使用!

测试代码:

import lombok.extern.slf4j.Slf4j;
import org.apache.commons.io.FileUtils;
import org.apache.commons.lang3.StringUtils;

import java.io.File;
import java.io.IOException;

/**
 * Title 网页转图片处理类
 *
 * @author Ason(18078490)
 * @date 2020-08-03
 */
@Slf4j
public class PhantomTools {

    /**
     * 可执行文件phantomjs.exe路径
     */
    private final String phantomjsPath;
    /**
     * 快照图生成JS路径
     */
    private final String rasterizePath;
    /**
     * 截图间隔时间
     * 考虑报表渲染最大耗时,设置默认请求时间为90s
     */
    private String timeout = String.valueOf(90 * 1000);

    /**
     * 构造参数
     * 获取phantomjs路径
     */
    public PhantomTools() {
        String bootPath = new File(this.getClass().getResource("/").getPath()).getPath();
        phantomjsPath = String.join(File.separator, bootPath, "tool", "phantomjs", "phantomjs-2.1.1-windows", "bin", "phantomjs");
        rasterizePath = String.join(File.separator, bootPath, "tool", "phantomjs", "phantomjs-2.1.1-windows", "examples", "rasterize.js");
        String externalIntervalTime = "2000";
        if (StringUtils.isNotBlank(externalIntervalTime)) {
            timeout = externalIntervalTime;
        }
    }

    /**
     * 根据URL生成指定fileName的字节数组
     *
     * @param fileName 图片名称
     * @param url      请求URL
     * @param size     指定图片尺寸,例如:1000px*800px
     */
    public void screenshot(String fileName, String url, String size) {
        // 替换URL中特殊字符
        String parsedUrl = StringUtils.replace(url, "&", "\"&\"");
        try {
            // 执行快照命令
            String command = String.join(StringUtils.SPACE, phantomjsPath, rasterizePath, parsedUrl, fileName, timeout, size);
            log.info("[执行命令:{}]", command);
            // 执行命令操作
            Process process = Runtime.getRuntime().exec(command);
            // 一直挂起,直到子进程执行结束,返回值0表示正常退出
            if (process.waitFor() != 0 || process.exitValue() != 1) {
                log.error("[执行本地Command命令失败]");
                return;
            }
            // 返回图片
            FileUtils.getFile(fileName);
        } catch (IOException | InterruptedException e) {
            log.error("[图片生成失败]", e);
        }
    }

    /**
     * 测试方法
     *
     * @param arg 参数
     * @throws IOException 异常
     */
    public static void main(String[] arg) throws IOException {
        String url = "http://www.baidu.com";
        PhantomTools phantomTools = new PhantomTools();
        phantomTools.screenshot("/data/test.png", url, "1200px");
    }
}

截图效果:

记一次&quot;截图&quot;功能的前期调研过程!-LMLPHP

关于PhantomJS的具体使用,可以参考:Phantomjs实现后端将URL转换为图片

PhantomJS作为一款强大的命令行工具,可以胜任多种自动化测试、监控等工作,但很可惜,随着Google在Chrome 59版本放出了headless模式,Ariya Hidayat决定放弃对Phantom.js的维护,这也标示着Phantom.js 统治fully functional headless browser的时代将被chrome-headless代替了。

PhantomJS缺点:

  • 将近2k的issue,仍然需要人去修复。
  • Javascript天生单线程的弱点,需要用异步方式来模拟多线程,随之而来的callback地狱,对于新手而言非常痛苦,不过随着es6的广泛应用,我们可以用promise来解决多重嵌套回调函数的问题。
  • 虽然webdriver支持htmlunit与phantomjs,但由于没有任何界面,当我们需要进行调试或复现问题时,就非常麻烦。

Headless Chrome

Headless Browser意思是没有页面的浏览器,多用于测试web、截图、图像对比、测试前端代码、爬虫(虽然很慢)、监控网站性能等。其优点如下:

PhantomJS曾经就是一款优秀的Headless浏览器,但由于其多项缺点,在Headless Browser领域正在逐渐被chrome-headless取替。

Headless Chrome是Chrome浏览器的无界面形态,可以在不打开浏览器的前提下,使用所有Chrome支持的特性,在命令行中运行你的脚本。相比于其他浏览器,Headless Chrome能够更加便捷的运行web自动化测试、编写爬虫、截取图等功能。它的出现就是来代替PhantomJS的。

Headless Chrome优点:

  • 比phantomjs有更快更好的性能。
  • Headless Chrome要比现phantomjs更加快速的完成任务,且占用内存更少。
  • 有谷歌平台维护,不会出现2k的issue情况。
  • 支持ECMAScript 2017 (ES8),我们也可以使用最新的js语法来编写的脚本,例如async,await等。
  • 完全真实的浏览器操作,chrome headless支持所有chrome特性。
  • 调试便利。我们只需要在命令行中加入–remote-debugging-port=9222,再打开浏览器输入ip:9222就能进入调试界面。

我们采用selenium + headless chrome来做个代码测试:

import lombok.extern.slf4j.Slf4j;
import org.apache.commons.io.FileUtils;
import org.openqa.selenium.OutputType;
import org.openqa.selenium.TakesScreenshot;
import org.openqa.selenium.WebDriver;
import org.openqa.selenium.chrome.ChromeDriver;
import org.openqa.selenium.chrome.ChromeOptions;

import java.io.File;
import java.util.concurrent.TimeUnit;

/**
 * Title 截图工具
 *
 * @author Ason(18078490)
 * @date 2020-09-03
 */
@Slf4j
public class Test {

    static {
        System.setProperty("webdriver.chrome.driver", "/tigbs-assist/chromedriver_win32/chromedriver.exe");
    }

    public static void main(String[] arg) throws Exception {
        // 配置Chrome参数
        ChromeOptions options = new ChromeOptions();
        options.setBinary("/chrome-win/chrome.exe");
        options.addArguments("--headless");
        options.addArguments("--disable-gpu");
        options.addArguments("--no-sandbox");
        options.addArguments("--disable-dev-shm-usage");
        options.addArguments("--hide-scrollbars");
        WebDriver driver = null;
        try {
            driver = new ChromeDriver(options);
            driver.manage().timeouts().implicitlyWait(5, TimeUnit.MINUTES).setScriptTimeout(5, TimeUnit.MINUTES).pageLoadTimeout(5, TimeUnit.MINUTES);
            // 打开网页
            driver.get("http://www.baidu.com/");
            Thread.sleep(5000);
            // 图片写入到test.png
            File file = ((TakesScreenshot) driver).getScreenshotAs(OutputType.FILE);
            FileUtils.copyFile(file, new File("/data/test.png"));
        } finally {
            // 关闭浏览器驱动
            if (driver != null) {
                driver.quit();
            }
        }
    }
}

Chrome浏览器与驱动需要相对应,对应关系可以查找:ChromeDriver与Chrome版本对应参照表及ChromeDriver下载链接
Chrome浏览器下载地址:Chrome Download

实现方案

针对本期需求功能点做对比,以上几种方案中,只有Chrome Headless模式可以满足要求,既能保证截图功能的实现,又适合以后项目的扩展!

针对Chrome Headless的选用方案,我们采用Selenium Server服务,服务端基于Selenium的Grid组件来搭建截图功能。

Selenium Server目前采用2.42版本,图片服务器为5台Windows系统虚机,项目架构图如下:

记一次&quot;截图&quot;功能的前期调研过程!-LMLPHP

项目结构有5台Node节点,1台Hub,Hub与其中一台Node共享虚机。启动模式采用standalone模式,启动文件:selenium-server-standalone-2.42.0.jar

Node节点安装的Web服务均采用Chrome浏览器实现,浏览器版本71.0.3557.0(开发者内部版本)(64 位),浏览器驱动版本2.46。

通过以上配置,完全可以满足本次项目开发需求,实现了完整的功能特点!

文末

本文采用的Selenium + Chrome Headless方式是浏览器操作的最佳方案,它完全兼容Chrome浏览器所有功能特点,是今后涉及浏览器开发工作中的必备手段!

关于Selenium想了解的同学,可以参考官方文档,这里不做具体介绍!

项目中尽量使用了案例代码实测,仅供参考!有Bug之处,也欢迎留言指正!

09-23 20:16