開發與維運

WebDriver 和 Chrome Headless

基本概念

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 形式,需要帶 sessionIdcapabilities),創建成功之後,你可以看到一個新的 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 的基準還要突破,一個思路,是在一個頁面同時完成多個圖表渲染,得到圖表之後,應用層再按規則切割。

Leave a Reply

Your email address will not be published. Required fields are marked *