[譯] 用 Flask 輸出視訊流

周家未發表於2019-03-04

相信你已經知道,我和 O'Reilly Media 合作出版了 講解 Flask 的書籍和一些視訊。儘管這些書籍和視訊對 Flask 的講解已經足夠詳細了,但由於某些原因一小部分特性講的不夠多,因此我覺得把他們寫在這篇文章中是個好主意。

本文專注於,一個有意思的特性,它讓 Flask 應用能夠以分割成小塊的形式提供超大的響應,這可能要花一段較長的時間。為了闡明這個主題,你將會看到如何構建一個實時視訊流伺服器。

注意:現在有一篇關於本文的後續文章,Flask Video Streaming Revisited,我在後續文章中講了關於本文介紹的流伺服器的一些改進。

什麼是流?

流是一種讓伺服器在響應請求時將響應資料分塊的技術。我能想到好多可能很有用的理由:

  • 超級巨大的響應資料。對於超大的響應資料來說,先把響應資料裝載到記憶體中,再返回給客戶端是非常低效的。另一種方法是將響應資料寫入到磁碟中,然後用 flask.send_file() 將檔案返回給客戶端,但這樣將會增加 I/O 操作。如果響應資料較小,這就是個好得多的方法,因為資料能夠按塊進行儲存。
  • 實時資料。對於某些應用來說,也許需要向某個請求返回來自實時資料來源的資料。一個很貼切的例子是實時視訊或音訊傳送。很多安全攝像頭用該技術將視訊以流的形式傳送到伺服器。

用 Flask 實現流

Flask 通過使用 生成器(generator functions) 原生支援流式響應。生成器是一個特殊的函式,可以被中止或繼續執行。看看下面的函式:

def gen():
    yield 1
    yield 2
    yield 3
複製程式碼

這是一個分三步執行的函式,每一步都返回一個值。生成器的實現超出了本文的範圍,如果你對此很感興趣的話,下面的 shell session 會讓你知道怎麼使用生成器:

>>> x = gen()
>>> x
<generator object gen at 0x7f06f3059c30>
>>> x.next()
1
>>> x.next()
2
>>> x.next()
3
>>> x.next()
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
StopIteration
複製程式碼

可以看到,在這個簡單的例子中,一個生成器可以依次返回多個值。Flask 使用生成器的該特性實現了流。

下面的例子展示瞭如何在不把整個表都裝配到記憶體的情況下,使用流生成巨型資料表:

from flask import Response, render_template
from app.models import Stock

def generate_stock_table():
    yield render_template('stock_header.html')
    for stock in Stock.query.all():
        yield render_template('stock_row.html', stock=stock)
    yield render_template('stock_footer.html')

@app.route('/stock-table')
def stock_table():
    return Response(generate_stock_table())
複製程式碼

在這個例子中你可以看到 Flask 是如何使用生成器的。某個返回流式響應的路由需要返回一個入參為生成器的 Response 物件。Flask 將會負責呼叫生成器,並把所有部分的結果以塊的形式傳送給客戶端。

譯者注:python3 中,訪問 /stock-table 路由時,如果在 Debug 模式下看到 AttributeError: 'NoneType' object has no attribute 'app',則需要將 Response 的入參用 stream_with_context() 預處理。匯入該函式:from flask import stream_with_context,路由的返回值:return Response( stream_with_context( generate_stock_table() ) )

對於這個特殊的例子,假設 Stock.query.all() 返回的是可迭代的資料庫查詢結果,那麼你可以按每次一行的速度生成一個巨大的表,因此無論查詢結果中的元素數量有多少,該 Python 程式的記憶體佔用不會因為裝配巨大的響應字串而變得越來越大。

分部響應

上述的表格示例生成小部分傳統頁面,再把所有部分銜接成最終的文件。這是如何生成巨大響應的很好的示例,但更讓人興奮的事情是操作實時資料。

一種有趣的流的用法是讓每一個資料塊取代頁面中的前一塊,這樣流就能夠在瀏覽器視窗中進行“播放”或者動畫。使用該技術你能夠用圖片作為流的每一部分,這將帶來一個很酷的在瀏覽器中執行的視訊播放器。

實現原地更新的祕訣在於使用 multipart(分部) 響應。分部響應的內容是一個包含分部內容型別的頭部,後面的是用 boundary(分界線) 標記分割的部分,每一部分有各自的特定內容型別。

有若干個分部內容型別用於不同的用途。為了達到讓流中的每部分能夠替代前一部分的目的,內容型別必須用 multipart/x-mixed-replace。為了讓你知道它看上去是什麼樣的,這裡有個分部視訊流的結構:

HTTP/1.1 200 OK
Content-Type: multipart/x-mixed-replace; boundary=frame

--frame
Content-Type: image/jpeg

<jpeg data here>
--frame
Content-Type: image/jpeg

<jpeg data here>
...
複製程式碼

如你所見,結構很簡單。主要的 Content-Type 頭部設為 multipart/x-mixed-replace,還定義了邊界字串。然後是各個分部,邊界字串前面帶有兩個橫線,佔據一行。這部分有自己的 Content-Type 頭部,每個部分有可選的 Content-Length 頭部,表明該部分資料的位元組數長度,但至少對於圖片來說,瀏覽器不需要長度也能夠處理流資料。

構建一個實時視訊流伺服器

在本文中已經有了足夠的理論,現在是時候構建一個完整的能夠將直播視訊流式傳輸到瀏覽器的應用了。

有很多種流式傳輸視訊到瀏覽器的方式,每一種方法各有優劣。與 Flask 的流式特性結合得非常好的一種方法是流式輸出一系列單獨的 JPEG 圖片。這被稱為 移動的 JPEG(Motion JPEG),這種方法正被一些 IP 安全攝像頭使用。這種方法的延遲低,但是質量並不是最好,因為對於移動視訊來說,JPEG 的壓縮並不高效。

下面你將看到一個特別簡單但又十分完善的 web 應用,可以提供移動的 JPEG 流:

#!/usr/bin/env python
from flask import Flask, render_template, Response
from camera import Camera

app = Flask(__name__)

@app.route('/')
def index():
    return render_template('index.html')

def gen(camera):
    while True:
        frame = camera.get_frame()
        yield (b'--frame\r\n'
               b'Content-Type: image/jpeg\r\n\r\n' + frame + b'\r\n')

@app.route('/video_feed')
def video_feed():
    return Response(gen(Camera()),
                    mimetype='multipart/x-mixed-replace; boundary=frame')

if __name__ == '__main__':
    app.run(host='0.0.0.0', debug=True)
複製程式碼

這個應用匯入了 Camera 類,該類負責提供幀序列。當前情形將攝像頭控制部分放在單獨的模組中是很好的主意,這樣 web 應用就能保持程式碼的整潔、簡單和通用性。

該應用有兩個路由。路由 / 提供定義在 index.html 模版中的主頁面。你能從下面的程式碼中看到模版檔案的內容:

<html>
  <head>
    <title>Video Streaming Demonstration</title>
  </head>
  <body>
    <h1>Video Streaming Demonstration</h1>
    <img src="{{ url_for('video_feed') }}">
  </body>
</html>
複製程式碼

這是個簡單的 HTML 頁面,只有一個 heading 和一個圖片標籤。注意圖片標籤的 src 屬性指向的是該應用的第二個路由,而這正是奇妙的地方。

路由 /video_feed 返回的是流式響應。因為流返回的是可以顯示在網頁中的圖片,到該路由的 URL 就放在圖片標籤的 src 屬性中。瀏覽器會自動顯示流中的 JPEG 圖片,從而保持更新圖片元素,由於分部響應受大多數(甚至所有)瀏覽器的支援(如果你找到一款瀏覽器沒有這種功能,請務必告訴我)。

/video_feed 路由中用到的生成器函式叫做 gen(),它接收 Camera 類的例項作為引數。mimetype 引數的設定和上面一樣,是 multipart/x-mixed-replace 型別,邊界字串設定為 frame

gen() 函式進入迴圈,從而持續地將攝像頭中獲取的幀資料作為響應塊返回。該函式通過呼叫 camera.get_frame() 方法從攝像頭中獲取一幀資料,然後它將這一幀以內容型別為 image/jpeg 的響應塊形式產出(yield),如上所述。

從視訊攝像頭中獲取幀

現在剩下要做的只有實現 Camera 類了,它要能夠連線到攝像機硬體,並從硬體中下載實時視訊幀。將應用的硬體依賴部分封裝到類中的好處是,這個類可以針對不同人群有不同的實現,但應用的其它部分保持不變。你可以把這個類想象成裝置驅動,無論實際使用的是什麼硬體,它都能提供統一的實現。

Camera 類從應用中分離出來的另一個優勢是很容易讓應用誤以為相機是存在的,而實際情況是相機並不存在,因為相機類可以被實現成沒有真實硬體的模擬相機。實際上,在我製作這個應用的時候,對我來說最簡單的測試流的方式就是模擬相機,在我跑通其它部分前,不用考慮硬體問題。接下來你將看到我所使用的簡單模擬相機的實現:

from time import time

class Camera(object):
    def __init__(self):
        self.frames = [open(f + '.jpg', 'rb').read() for f in ['1', '2', '3']]

    def get_frame(self):
        return self.frames[int(time()) % 3]
複製程式碼

這種實現是從硬碟中讀取三張分別叫做 1.jpg2.jpg3.jpg 的圖片,然後以每秒一幀的速度迴圈返回它們。get_frame() 方法使用當前時間的秒數來決定當前應該返回三張圖片中的哪一張。非常簡單,不是嗎?

要執行這個模擬相機,我需要建立三個幀。我使用 gimp 做出了下面的圖片:

Frame 1
Frame 2
Frame 3

因為相機模擬出來了,這個應用可以執行在任何環境中,因此你可以立即執行它!我把這個應用的所有東西都準備好了,放在 GitHub 上。如果你熟悉 git,你可以用下面的命令克隆這個倉庫:

$ git clone https://github.com/miguelgrinberg/flask-video-streaming.git
複製程式碼

如果你要下載該應用,你可以從 這兒 獲取一個 zip 壓縮檔案。

裝好應用後,建立一個虛擬環境並安裝好 Flask。然後你可以執行命令:

$ python app.py
複製程式碼

當你開啟應用後,在瀏覽器中輸入 http://localhost:5000,你就能看到模擬的視訊流,不斷播放著圖片 1、2、3。是不是很酷?

當我做好了這些事情後,我用相機模組啟動了樹莓派,並實現了一個新的 Camera 類,這個類將樹莓派轉換成一個視訊流伺服器,使用 picamera 包來控制硬體。這裡不會涉及到相應的相機實現,但你可以在檔案 camera_pi.py 中找到相應的原始碼。

如果你有一個樹莓派和相機模組,你可以編輯 app.py 檔案,從這個模組中引入 Camera 類,然後你就可以流式直播樹莓派的相機,就像在下面的截圖中我所做的那樣:

Frame 1

如果你想讓這個流式應用和不同的相機一起使用,那麼你要做的就是改寫 Camera 類的實現。如果你實現了這樣的一個相機類,並將它貢獻到我的 GitHub 專案中,我將不勝感激。

流的侷限

Flask 應用在服務常規請求時,請求的週期短。web worker 接收到請求,呼叫處理函式,最終返回響應。一旦響應返回給了客戶端,worker 就處於空閒狀態,等待著接收下一次請求。

當接收到使用流的請求時,在流的持續時間內 worker 一直留存在客戶端中。在處理永不結束的、長的流時,比如從攝像機發來的一個視訊流,worker 將會對客戶端保持鎖定狀態,直到客戶端斷開連線。這也就意味著除非採用特殊的方法,否則有多少客戶端,應用就要為多少 web workers 提供服務。在 debug 模式下執行 Flask 應用意味著只有一個執行緒,因此你無法開啟另一個瀏覽器視窗,在兩個地方同時觀看流。

有很多方法可以解決這個關鍵的限制。我認為最好的方案是使用基於協程的 web 伺服器,比如 Flask 支援很好的 gevent。gevent 通過使用協程能夠在一個工作執行緒中處理多個客戶端,因為 gevent 修改了 Python I/O 函式,在必要時處理上下文的切換。

結論

如果你跳過了上面的內容,可以在 GitHub 倉庫上看到本文相應的程式碼:github.com/miguelgrinb…。你能從中找到不需要相機的視訊流通用實現,也可以看到樹莓派相機模組的實現。這篇 後續文章 講述了本文最開始釋出後我所做的一些改進。

我希望本文能夠為流這一話題帶來一些啟發。我專注於視訊流,因為我在這一領域中有些經驗,但流的應用不僅限於視訊。比如,這個技術可以用來保持伺服器與客戶端的連線長時間有效,允許伺服器在有資訊時傳送新資訊。最近 Web Socket 協議可以更高效的實現這個目的,但是 Web Socket 相當新穎,只能在現代瀏覽器中使用,而流卻能在非常多的瀏覽器中使用。

如果你有任何問題,請將它們寫在下方。我打算為不為大眾所知的 Flask 專題繼續撰寫文章,所以希望你能以某種方式聯絡我,以便知道更多文章釋出的時間,下篇文章中再見。

如果發現譯文存在錯誤或其他需要改進的地方,歡迎到 掘金翻譯計劃 對譯文進行修改並 PR,也可獲得相應獎勵積分。文章開頭的 本文永久連結 即為本文在 GitHub 上的 MarkDown 連結。


掘金翻譯計劃 是一個翻譯優質網際網路技術文章的社群,文章來源為 掘金 上的英文分享文章。內容覆蓋 AndroidiOS前端後端區塊鏈產品設計人工智慧等領域,想要檢視更多優質譯文請持續關注 掘金翻譯計劃官方微博知乎專欄

相關文章