項目需求
最近,項目接到了一個新需求,要求對指定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"));
}
}
截圖效果:

優勢:簡單易用,不需要任何第三方插件。
缺點:不能同時處理大量數據,技術含量過低,屬於應急型技巧。
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();
}
}
截圖效果:

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,放入兩行圖片,看一下效果:

所以,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");
}
}
截圖效果:

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,但由於沒有任何界面,當我們需要進行調試或復現問題時,就非常麻煩。
關於PhantomJS的具體使用,可以參考:Phantomjs實現后端將URL轉換為圖片
Headless Chrome
Headless Browser
意思是沒有頁面的瀏覽器,多用於測試web、截圖、圖像對比、測試前端代碼、爬蟲(雖然很慢)、監控網站性能等。其優點如下:
對於UI自動化測試,少了真實瀏覽器加載css,js以及渲染頁面的工作。無頭測試要比真實瀏覽器快的多。
可以在無界面的服務器或CI上運行測試,減少了外界的干擾,使自動化測試更穩定。
在一台機器上可以模擬運行多個無頭瀏覽器,方便進行並發測試。
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系統虛機,項目架構圖如下:

項目結構有5台Node節點,1台Hub,Hub與其中一台Node共享虛機。啟動模式采用standalone模式,啟動文件:selenium-server-standalone-2.42.0.jar。
Node節點安裝的Web服務均采用Chrome瀏覽器實現,瀏覽器版本71.0.3557.0(開發者內部版本)(64 位),瀏覽器驅動版本2.46。
Hub啟動命令:java -jar selenium-server-standalone-2.42.0.jar -role hub -maxSession 10 -port 4444
Node啟動命令:java -Dwebdriver.chrome.driver="/chromedriver_win32/chromedriver.exe" -Dbinary="/chrome-win/chrome.exe" -jar selenium-server-standalone-2.42.0.jar -role node -hub "http://ip(Hub的IP地址):4444/grid/register" -port 5555 -browser "browserName=chrome,version=71,platform=WINDOWS,maxInstances=10"
通過以上配置,完全可以滿足本次項目開發需求,實現了完整的功能特點!
文末
本文采用的Selenium + Chrome Headless
方式是瀏覽器操作的最佳方案,它完全兼容Chrome瀏覽器所有功能特點,是今后涉及瀏覽器開發工作中的必備手段!
關於Selenium
想了解的同學,可以參考官方文檔,這里不做具體介紹!
項目中盡量使用了案例代碼實測,僅供參考!有Bug之處,也歡迎留言指正!