1. 程式人生 > >記一次"截圖"功能的前期調研過程!

記一次"截圖"功能的前期調研過程!

[TOC](目錄) # 專案需求 最近,專案接到了一個新需求,要求對指定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")); } } ``` 截圖效果: ![AWT截圖](https://img2020.cnblogs.com/blog/2050188/202009/2050188-20200923102937538-611750241.jpg) > 優勢:簡單易用,不需要任何第三方外掛。 > 缺點:不能同時處理大量資料,技術含量過低,屬於應急型技巧。 ## Swing 與`AWT`相比,`Swing`是基於awt的Java程式,包名`javax.swing`。它不僅提供了AWT的所有功能,還用純粹的Java程式碼對AWT的功能進行了大幅度的擴充。它是為解決AWT存在的問題而新開發的圖形介面包。 對Swing的測試,我們採用`DJNativeSwing-SWT`,它是java內嵌瀏覽器API,需要用到的依賴包有: ``` com.hynnet DJNativeSwing 1.0.0 com.hynnet
DJNativeSwing-SWT 1.0.0
org.eclipse.swt.org.eclipse.swt.win32.win32.x86_64.4.3.swt org.eclipse.swt.win32.win32.x86_64 4.3 ``` 測試程式碼: ``` 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截圖](https://img2020.cnblogs.com/blog/2050188/202009/2050188-20200923111433421-1834046027.png) 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轉圖片的領域使用率是不低的。引入依賴: ``` gui.ava
html2image 0.9
``` 測試程式碼: ``` import gui.ava.html.image.generator.HtmlImageGenerator; public class Html2Image { public static void main(String[] arg) throws Exception { String html = "
"; HtmlImageGenerator imageGenerator = new HtmlImageGenerator(); imageGenerator.loadHtml(html); Thread.sleep(5000); imageGenerator.saveAsImage("/data/test.png"); Thread.sleep(5000); } } ``` 測試程式碼比較簡單,手繪了一個Table,放入兩行圖片,看一下效果: ![Html2Image截圖](https://img2020.cnblogs.com/blog/2050188/202009/2050188-20200923115228911-1455385678.png) 所以,Html2Image的缺點也很明顯: - 當你的html頁面引入外部的CSS檔案以及JS檔案,生成的圖片是無法帶有這些動態效果的。也就是說,它不支援複雜的動態特性,只能支援寫在html程式碼裡的css效果。 - 當html程式碼裡帶有圖片時,生成的程式必須有一定的等待時間,否則生成的圖片會有空白,所以需要設法在程式碼生成圖片前讓程式等待一會,比如`Thread.sleep(8000)`。 - 除錯不易,很容易出現圖不清楚、有邊框、字型被洗白等等情況。 - ... ## PhantomJS `PhantomJS`是一個可程式設計的無頭瀏覽器。適用於頁面自動化,網頁監控,網路爬蟲等: - 頁面自動化測試:希望自動的登陸網站並做一些操作然後檢查結果是否正常。 - 網頁監控:希望定期開啟頁面,檢查網站是否能正常載入,載入結果是否符合預期。載入速度如何等。 - 網路爬蟲:獲取頁面中使用js來下載和渲染資訊,或者是獲取連結處使用js來跳轉後的真實地址。 PhantomJS官網下載地址:[http://phantomjs.org/](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截圖](https://img2020.cnblogs.com/blog/2050188/202009/2050188-20200923124740458-496295744.png) 關於PhantomJS的具體使用,可以參考:[Phantomjs實現後端將URL轉換為圖片](https://www.cnblogs.com/ason-wxs/p/13411271.html) 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、截圖、影象對比、測試前端程式碼、爬蟲(雖然很慢)、監控網站效能等。其優點如下: > 對於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下載連結](https://blog.csdn.net/BinGISer/article/details/88559532) Chrome瀏覽器下載地址:[Chrome Download](https://www.chromedownloads.net/chrome64linux/) # 實現方案 針對本期需求功能點做對比,以上幾種方案中,只有Chrome Headless模式可以滿足要求,既能保證截圖功能的實現,又適合以後專案的擴充套件! 針對Chrome Headless的選用方案,我們採用Selenium Server服務,服務端基於Selenium的Grid元件來搭建截圖功能。 Selenium Server目前採用2.42版本,圖片伺服器為5臺Windows系統虛機,專案架構圖如下: ![Selenium Grid](https://img2020.cnblogs.com/blog/2050188/202009/2050188-20200923133743070-1105490452.png) 專案結構有5臺Node節點,1臺Hub,Hub與其中一臺Node共享虛機。啟動模式採用standalone模式,啟動檔案:[selenium-server-standalone-2.42.0.jar](http://selenium-release.storage.googleapis.com/index.html)。 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:4444/grid/register" -port 5555 -browser "browserName=chrome,version=71,platform=WINDOWS,maxInstances=10" 通過以上配置,完全可以滿足本次專案開發需求,實現了完整的功能特點! # 文末 本文采用的`Selenium + Chrome Headless`方式是瀏覽器操作的最佳方案,它完全相容Chrome瀏覽器所有功能特點,是今後涉及瀏覽器開發工作中的必備手段! 關於[`Selenium`](https://www.selenium.dev/)想了解的同學,可以參考官方文件,這裡不做具體介紹! 專案中儘量使用了案例程式碼實測,僅供參考!**有Bug之處,也歡迎留言指正!**