基本概念
WebDriver
WebDriver 是 W3C 的一套規範,來源於 Selenium 這個自動化測試 Web 相關場景的項目。
https://w3c.github.io/webdriver/
它定義了一套 Resuful 風格的,針對瀏覽器的,可用於編程控制行為,獲取狀態的服務接口(自動化測試最初的訴求)。
Chrome 下的 WebDriver 實現
Chrome / Chromium 有單獨的 WebDriver 實現: https://sites.google.com/a/chromium.org/chromedriver/
對應已安裝的 Chrome 版本,下載 WebDriver 之後,是一個可執行文件。
這個可執行文件的作用,實際上是在本機實現了一套 HTTP 服務,默認在 9515 端口上,基本的列表在:https://chromium.googlesource.com/chromium/src/+/master/docs/chromedriver_status.md
在 POST 到 /session
創建新會話之後(參數是 json 形式,需要帶 sessionId
和 capabilities
),創建成功之後,你可以看到一個新的 Chrome 被啟動,通過對應的 sessionId
,你就可以使用其它服務對這個 Chrome 進行操作了,包括打開指定 URL ,獲取 DOM 狀態,截圖什麼的。
實際使用中,我們也可以直接使用 Selenium 模塊,它有對這個 WebDriver 服務的封裝。
Chrome Headless
Chrome 的“無頭瀏覽器”模式,是指在沒有 X 等圖形桌面的環境下,執行完整的 Chrome 功能,通過命令行啟動 Chrome 時,帶上參數 headless 可以以“無頭瀏覽器”模式啟動。一般,直接在命令行使用,我們只是配合 screenshot 完成截圖。
但加上前面介紹的 WebDriver ,我們可以在沒有 X 的服務器上,啟動並 Daemon 化一個 Chrome 瀏覽器,它可以做測試用,而這裡我要說的,是把它當成一個圖表渲染工具使用。
簡單使用 Chrome Headless
使用 Chrome Headless 做圖表渲染工具,簡單來說,就是運用像 ECharts 這類工具,在 html - css - js - canvas / svg 這套運行於瀏覽器環境的技術體系下,完成需要的圖表繪製。這在, Web 項目中,可能是一個平常的操作,但在服務端沒有瀏覽器環境的下,要畫出一個好看的,並且易於控制的圖表,在以往,其實並不是容易的事。可編程控制的圖表繪製,在 Python 中,一般方案是 matplotlib ,對於數據類圖表應用,它已經很出色了。但如果你進去想要控制一些細節的話,其實也挻難的。總的來說,這類場景,即使是 Python ,即使有 matplotlib ,同樣的數據類圖表應用,也很難與瀏覽器上的,一眾基於 canvas / svg 的方案相比(平衡投入與產出情況下)。
數據類圖表應用,需要產出的大多是位圖。利用 Chrome 的“無頭瀏覽器”模式,直接截圖就可以得到。
先完成一個 ECharts Demo 的頁面, demo.html
:
<!DOCTYPE html>
<html lang="zh-cmn-Hans">
<head>
<meta charset="utf-8" />
<title>Echarts</title>
<script crossorigin src="https://s.zys.me/js/echarts/echarts.min.js"></script>
</head>
<body>
<div id="app" style="width: 1000px; height: 800px;"></div>
<script type="text/javascript">
var option = {
animation: false,
...
};
var chart = echarts.init(document.getElementById('app'));
chart.setOption(option);
</script>
</body>
</html>
然後 python3 -m http.server
,讓它在 http://localhost:8080/demo.html
。
接著使用 Chrome :
google-chrome --headless --disable-gpu --screenshot --hide-scrollbars --window-size=1000x800 http://localhost:8000/demo.html
(其它命令行參數,可以在這裡找到: https://cs.chromium.org/chromium/src/headless/app/headless_shell_switches.cc)
這樣,就可以在當前目錄,得到一個 screenshot.png
的位圖圖表了。
WebDriver 控制 Chrome Headless
selenium 對 WebDriver 的封裝,具體文檔在: https://www.selenium.dev/selenium/docs/api/py/index.html
先實現像命令行一樣的功能:
# -*- coding: utf-8 -*-
from selenium import webdriver
from selenium.webdriver.remote.webdriver import WebDriver
def main():
options = webdriver.ChromeOptions()
options.binary_location = '/usr/bin/google-chrome-stable'
#options.add_argument('headless')
options.add_argument('hide-scrollbars')
options.add_argument('window-size=1000x800')
#options.add_experimental_option("detach", True)
driver = webdriver.Chrome('/home/zys/chromedriver', options=options)
#driver = WebDriver(command_executor='http://127.0.0.1:9515', desired_capabilities=options.to_capabilities())
driver.get('http://localhost:8000/demo.html')
driver.get_screenshot_as_file('/home/zys/selenium.png')
#buffer = driver.get_screenshot_as_png()
#print(buffer)
if __name__ == '__main__':
main()
這個代碼直接執行,不開 headless 選項情況下,執行完之後, Chrome 實例會銷燬。如果使用 Remote 的方式連到 9515 ,則實例不會銷燬,同時可以通過 session_id 屬性得到會話標識。
要整合進應用也很容易:
# -*- coding: utf-8 -*-
import os.path
from .base import BaseHandler
from .base_session import BaseSessionHandler
from app.config import CONF
ENV = CONF.get('general', 'env')
class HeadlessHandler(BaseHandler):
def get(self):
driver = getattr(self.application, 'web_driver', None)
if not driver:
self.write('no webdriver is running')
return
url = self.get_argument('url', None)
if not url:
self.write('need a url')
return
driver.start_session(self.application.capabilities)
width = self.get_argument('width', 1000)
height = self.get_argument('height', 800)
if width and height:
try:
width = int(width)
height = int(height)
driver.set_window_size(width, height)
except:
pass
driver.get(url)
buffer = driver.get_screenshot_as_png()
driver.close()
self.set_header('content-type', 'image/png')
self.write(buffer)
這整個應用中使用,需要稍加註意的,就是調度和部署的形式。
-
Handler
中使用,需要作 session 的隔離。 - 啟動一個 Chrome 實例,大概會佔 150M - 200M 的內存。
- 一臺機器上,可以只啟動一個 chrome_driver ,通過 --port 指定監聽的端口。
然後,因為所有的 Chrome 實例,都是在 chrome_driver 那邊管理的,所以,應用中啟動了實例,但是應用停止,或者重啟時,舊的實例,需要注意,記得手工清除掉,否則會一直佔用資源。
def exit(sign, frame):
if web_driver:
web_driver.quit()
server.stop()
ioloop = tornado.ioloop.IOLoop.current()
ioloop.add_callback(ioloop.stop)
signal.signal(signal.SIGTERM, exit)
signal.signal(signal.SIGINT, exit)
我是通過系統信號的方式,確保調用到實例的 quit()
方法。
截圖對比
上面的 WebDriver 方式直接對比命令行 Headless 方式:
class Headless2Handler(BaseHandler):
def get(self):
url = self.get_argument('url', None)
if not url:
self.write('need a url')
return
width = self.get_argument('width', 1000)
height = self.get_argument('height', 800)
tf = tempfile.mktemp()
image_file = tf + '.png'
cmd = 'google-chrome --headless --hide-scrollbars --window-size={},{} --screenshot="{}" --disable-gpu --no-sandbox {}'.format(
width, height, image_file, url)
subprocess.call(cmd, shell=True)
data = ''
with open(image_file, 'rb') as f:
data = f.read()
self.set_header('content-type', 'image/png')
self.write(data)
self.finish()
os.remove(image_file)
從效率上來說,結果兩者是差不多的。
複用實例
使用了 WebDriver ,理論上來說,一個 Headless 的 Chrome 實例,在整個應用的生命週期中,是可以只初始化啟動一次的。
前面 Headless 的例子,每次請求都重複初始化 sessionId ,是因為如果直接複用實例,直接 driver.get(url)
,則會報一個 sessionId 不合法的錯誤。懷疑是 selenium 或者 Chrome 自身的 BUG 。
後來,想到了另一種繞過去的辦法,就是實例中的頁面只加載一次,之後,通過 driver.execute_script(js_code)
來控制頁面內容,訪問指定頁面,可以使用:
driver.execute_script('location.href = "{}"'.format(url));
這樣,這個 driver 的狀態,就直接在每次請求之間被複用,省了初始化的巨大開銷。
在我的一臺捉襟見肘的服務器上,啟動 Chrome,或者初始化會話,大概需要 4 秒。而複用 driver 之後,同樣的邏輯,單個請求,就可以快 4 秒!
同理,對於圖表繪製場景,我們可以讓 driver 在初始化後,訪問 CDN 上的一個 HTML 頁面,這個頁面會加載 ECharts 之類的資源,同時在 window
上定義一個全局的 render(echarts_option)
函數。 Web 服務的請求,可以帶上完整的 ECharts 繪圖配置,通過 driver.execute_script()
執行,然後截圖。省去 Chrome 初始化的開銷,這個得到繪圖結果的過程可以極快,我的服務器上,瀏覽器裡請求-響應完整的耗時,可以在 400ms 以內。
真實的業務服務器上,估計可以 200ms 完成。這個水平應該可以 PK Nodejs 上直接的 canvas API 兼容性方案了(讓 ECharts 可以直接在 Nodejs 環境完成渲染),但是完整的瀏覽器頁面的能力,完全超越僅 canvas API 的能力(試試用 canvas 畫一個排版好的花哨的表格,有 <table>
+ CSS 能打?)。
200ms 的基準還要突破,一個思路,是在一個頁面同時完成多個圖表渲染,得到圖表之後,應用層再按規則切割。