基於 Socket 實現 MicroPython 的 HTTP 上傳文件(multipart/form-data)


起因

下述內容需要具備 HTTP 的基礎知識,如果不知道的可以過一遍 HTTP 協議詳解

繼上次在 K210 實現 HTTP Download 文件(https 也支持辣),現在就來說說直接基於 socket 的上傳文件實現吧。

首先准備一個 Server 文件服務器的 CPython 代碼,這個是再簡單不過了。

# coding=utf-8
from http.server import BaseHTTPRequestHandler
import cgi
import time

class PostHandler(BaseHTTPRequestHandler):
    def do_POST(self):
        print(self.headers)
        form = cgi.FieldStorage(
            fp=self.rfile,
            headers=self.headers,
            environ={'REQUEST_METHOD': 'POST',
                     'CONTENT_TYPE': self.headers['Content-Type'],
                     }
        )
        self.send_response(200)
        self.end_headers()
        self.wfile.write(('Client: %s\n' % str(self.client_address)).encode())
        self.wfile.write(('User-agent: %s\n' %
                          str(self.headers['user-agent'])).encode())
        self.wfile.write(('Path: %s\n' % self.path).encode())
        self.wfile.write(b'Form data:\n')
        print(form.keys())
        for field in form.keys():
            field_item = form[field]
            filename = field_item.filename
            filevalue = field_item.value
            filesize = len(filevalue)  # 文件大小(字節)
            print(filename, filesize)
            with open('%s-' % time.time() + filename, 'wb') as f:
                f.write(filevalue)
        return

def StartServer():
    from http.server import HTTPServer
    sever = HTTPServer(("0.0.0.0", 8080), PostHandler)
    sever.serve_forever()

if __name__ == '__main__':
    StartServer()

可以看到實現處理了一個 post 的請求,然后對這個請求依次解析出來,通常情況下找找代碼調用一下就成功了,比如下面的這份調用 request 的代碼:

#coding=utf-8
import requests
url = "http://127.0.0.1:8080"
path = "./hfshttp.zip"
print(path)
files = {'file': open(path, 'rb')}
r = requests.post(url, files=files)
print (r.url)
print (r.text)

'''
Host: 127.0.0.1:8080
Connection: keep-alive
Accept: */*
User-Agent: python-requests/2.22.0
Accept-Encoding: gzip, deflate
Content-Length: 859783
Content-Type: multipart/form-data; boundary=c42a6d00053f74d5edd8c8b00a8318ef
['file']
hfshttp.zip 859636
'''

上傳文件是不是很簡單就實現了,這也是隨處可見的代碼,但這代碼背后其實做了很多工作,你是無法直接在 MicroPython 上運行的。

沒有 requests 怎么辦?

不僅沒有 requests 也沒有 urllib ,那這可怎么辦呢?

留給我們的只有如下模塊。

from struct import pack
import socket
import uio as io

這個問題很早就年就有人在討論了,但隨着 CPython 的逐漸流行和成熟,基本上回復的都是趕緊換 requests ,也許誰也沒想到后來還有個 MicroPython 吧,那沒庫了我們就不行了嗎?

在這之前的文章,我已經在 K210 的 MicroPython 上基於 www.hc2.fr 的 MicroWebCli 實現了 HTTP 的基本操作,也就是我們常見的 GET 、POST 基礎功能已經具備了,也就是如何包裝一個上傳數據包的問題。

如何選擇上傳文件協議呢?我們有兩種請求的規范,前者是 PUT ,后者是 POST。

在 HTTP 1.1 之上有一個 WebDAV 協議,它和 FTP 一樣,都拓展出了 PUT 、GET 、 Delete 等文件操作,與 FTP 的區別在於端口不同,因為一個是 80 另一個是 21,接着 WebDAV 是網絡訪問和文件請求並存的一種拓展協議,也就是說,最簡單的文件上傳協議就是實現一個 PUT,要求服務器開發 WebDAV 的實現就可以了,當然,本文的重點不在講如何實現文件服務器的功能,只是提及一下 PUT 指令的內容,以此切入 POST 上傳文件的基礎知識鋪墊。

那么 PUT 實現都有哪些內容呢?只需要客戶端 socket 組裝下述內容發送給開放了 HTTP1.1 PUT 指令的服務器就可以被接收處理了,以往經常有人會拿這個命令來試探服務器有沒有漏洞 hah 所以現在的人都關了這個功能,或者上驗證了。

PUT /bacon.htm HTTP/1.1
Content-Length: 31
Accept: */*
Accept-Language: en-US
User-Agent: Mozilla/4.0 (compatible; MSIE 6.0; Win32)
Host: 218.94.36.38:9010
Content-Length: text/plain

a piece of data

可以看出 PUT 的規范很簡潔,下面提供一個 Python 的 Code 幫助你體會體會?

# File: httplib-example-2.py

import httplib

USER_AGENT = "httplib-example-2.py"

def post(host, path, data, type=None):

    http = httplib.HTTP(host)

    # write header
    http.putrequest("PUT", path)
    http.putheader("User-Agent", USER_AGENT)
    http.putheader("Host", host)
    if type:
        http.putheader("Content-Type", type)
    http.putheader("Content-Length", str(len(data)))
    http.endheaders()

    # write body
    http.send(data)

    # get response
    errcode, errmsg, headers = http.getreply()

    if errcode != 200:
        raise Error(errcode, errmsg, headers)

    file = http.getfile()
    return file.read()

if __name__ == "__main__":

    post("www.spam.egg", "/bacon.htm", "a piece of data", "text/plain")

實際上不難理解,想知道更多可以自行了解,可以看看這個 HTTP 協議入門 ,深入一點就要看標准協議辣,例如 Hypertext Transfer Protocol -- HTTP/1.1,示例 Code 來源在這里 The httplib module

注意,為什么我要提及 PUT 實際上是有原因的,早期它的實現很簡單,作為歷史的車輪自然會依次淘汰掉不可靠的一些指令,尤其是復雜的指令要求服務器有對應的實現,后來 PUT 也因為服務器漏洞問題而被大部分場合禁用了,所以實現它已經沒有什么意義了,那么接下來該怎么辦呢?

這時候我們就要引出第二個上傳協議 multipart/form-data 了。

multipart/form-data 是什么?如何實現?

multipart/form-data最初由 《RFC 1867: Form-based File Upload in HTML》文檔定義。

Since file-upload is a feature that will benefit many applications, this proposes an
extension to HTML to allow information providers to express file upload requests uniformly, and a MIME compatible representation for file upload responses.

文檔簡介中說明文件上傳作為一種常見的需求,在目前(1995年)的html中的form表單格式中還不支持,因此發明了一種兼容此需求的mime type。

The encoding type application/x-www-form-urlencoded is inefficient for sending large quantities of binary data or text containing non-ASCII characters. Thus, a new media type,multipart/form-data, is proposed as a way of efficiently sending the
values associated with a filled-out form from client to server.

文檔中也寫了為什么要新增一個類型,而不使用舊有的application/x-www-form-urlencoded:因為此類型不適合用於傳輸大型二進制數據或者包含非ASCII字符的數據。平常我們使用這個類型都是把表單數據使用url編碼后傳送給后端,二進制文件當然沒辦法一起編碼進去了。所以multipart/form-data就誕生了,專門用於有效的傳輸文件。

現在我們假設服務器是一個黑盒子,符合上傳文件的實現規范,而我們要做的就是在 MicroPython 上實現 HTTP 文件的上傳文件,而且不是 PUT 指令,而是通過 POST 的實現,這里我們就要來實現它了。

首先我們在本文的最初就已經通過 requests 進行了一個上傳文件的實踐,接下來留給我們的挑戰就是如何實現它,先看這篇文章 the-unfortunately-long-story-dealing-with ,可以不用細看,只是因為它提供了一份封裝數據包的邏輯實現,我們只需要通過它就可以實現基礎邏輯。

根據下圖的邏輯,第一個是確定 HTTP 的 header 屬於 POST 操作,同時提供了 Content-type 和 Content-length 文件信息。

import mimetools
import mimetypes
import io
import http
import json


form = MultiPartForm()
form.add_field("form_field", "my awesome data")

# Add a fake file
form.add_file(key, os.path.basename(filepath),
	fileHandle=codecs.open("/path/to/my/file.zip", "rb"))

# Build the request
url = "http://www.example.com/endpoint"
schema, netloc, url, params, query, fragments = urlparse.urlparse(url)

try:
	form_buffer =  form.get_binary().getvalue()
	http = httplib.HTTPConnection(netloc)
	http.connect()
	http.putrequest("POST", url)
	http.putheader('Content-type',form.get_content_type())
	http.putheader('Content-length', str(len(form_buffer)))
	http.endheaders()
	http.send(form_buffer)
except socket.error, e:
	raise SystemExit(1)

r = http.getresponse()
if r.status == 200:
	return json.loads(r.read())
else:
	print('Upload failed (%s): %s' % (r.status, r.reason))

於是這個請求的第一階段就結束了,此時發起的 HTTP 數據如下:

  • CPython 的 requsets
POST / HTTP/1.0
User-Agent: python-requests/2.22.0
Accept-Encoding: gzip, deflate
Connection: keep-alive
Accept: */*
Content-Length: 859783
Content-Type: multipart/form-data; boundary=c76ef433019742a27e38c33455206c52
  • MicroPython 的 MicroWebCli
POST / HTTP/1.0
Content-Type: multipart/form-data; boundary=1590160638.3663664
Host: 127.0.0.1
User-Agent: MicroWebCli by JC`zic
Content-Length: 859808

這都是封裝數據的過程,它只是發起了第一次的請求,接下來還有 form-data 的數據交互的請求,它格式滿足如下,但這要如何理解呢?實際上在第一次的請求中就已經告知本次的 form-data 內容和長度了,所以在第一次的請求結束后,就需要繼續發送這個 form-data 數據。

POST http://127.0.0.1:8080/
--1590161992.5543046
Content-Disposition: file; name="./"; filename="hfshttp.zip"
Content-Type: application/octet-stream

上述的 form-data 可以這樣理解,其中 --1590161992.5543046 是第一次請求中提出的分隔符boundary,這個分隔符可不能在后續的文件中出現,因為是用來分批文件上傳處理的,, form-data 標准格式是 name 和 value ,你也在這里面可以設計很多個協議內容,其他的就需要你看看服務器這一端的解析和實現了,例如下面這個是服務器的解析實現。

 def do_POST(self):
        form = cgi.FieldStorage(
            fp=self.rfile,
            headers=self.headers,
            environ={'REQUEST_METHOD': 'POST',
                     'CONTENT_TYPE': self.headers['Content-Type'],
                     }
        )
        self.send_response(200)
        self.end_headers()
        self.wfile.write(('Client: %s\n' % str(self.client_address)).encode())
        self.wfile.write(('User-agent: %s\n' %
                          str(self.headers['user-agent'])).encode())
        self.wfile.write(('Path: %s\n' % self.path).encode())
        self.wfile.write(b'Form data:\n')
        for field in form.keys():
            field_item = form[field]
            filename = field_item.filename
            filevalue = field_item.value
            filesize = len(filevalue)  # 文件大小(字節)
            print(filename, filesize)
            with open('%s-' % time.time() + filename, 'wb') as f:
                f.write(filevalue)
        return

當接收到路由(url)的 / 請求,先給個 200 應答表示接收到頭了,再來解析 form-data 中的數據,其中 field 和 form[field].filename 對應的就是上述 Content-Disposition: file; name="./"; filename="hfshttp.zip" 中的 name 和 filename ,這樣你就知道如何理解文件是如何被提取和下載的了。

所以 form-data 的格式是這樣的,參見 rfc1867 的 6. Examples 。

Content-type: multipart/form-data, boundary=AaB03x

--AaB03x
content-disposition: form-data; name="field1"

Joe Blow
--AaB03x
content-disposition: form-data; name="pics"; filename="file1.txt"
Content-Type: text/plain

... contents of file1.txt ...
--AaB03x--

實際上 field_item.value 對應的內容它就在 ... contents of file1.txt ... 省略的位置里填充的,所以你可以理解 form-data 實際上就是一個被封裝的多次 POST 請求,最后客戶端傳輸完成后,會等待服務器的一個請求,告知傳輸完成,而這個應答也是對應 Server 的操作邏輯的。

http://127.0.0.1:8080/
Client: ('127.0.0.1', 64589)
User-agent: python-requests/2.22.0
Path: /
Form data:

實際上就是上述 server 代碼寫的應答,現在你應該知道要如何發送一個 multipart/form-data 的請求了吧,這實際上是和語言無關的,只需要用你手頭的 socket 包裝一下就可以實現。

做一些拓展內容

而關於這個 form-data 的封裝過程,有很多優化要點和注意點。

先說 HTTP 數據包中的 boundary 和 mimetype 都是可以任意定義的,前者只要不和傳輸的內容有重合就行,所以我設置為 unix 時間字符串了,后者統一定義為 application/octet-stream 就可以了,因為這里不需要特別指定文件的解析邏輯操作,但如果你是寫服務器的操作的話,則需要注意這些定義的區別。

class MultiPartForm(object):
	"""Accumulate the data to be used when posting a form."""

	def __init__(self):
		self.form_fields = []
		self.files = []
		self.boundary = mimetools.choose_boundary()
		return

	def get_content_type(self):
		return 'multipart/form-data; boundary=%s' % self.boundary

	def add_field(self, name, value):
		"""Add a simple field to the form data."""
		self.form_fields.append((name, value))
		return

	def add_file(self, fieldname, filename, fileHandle, mimetype=None):
		"""Add a file to be uploaded."""
		body = fileHandle.read()
		if mimetype is None:
			mimetype = mimetypes.guess_type(filename)[0] or 'application/octet-stream'
		self.files.append((fieldname, filename, mimetype, body))
		return

	def get_binary(self):
		"""Return a binary buffer containing the form data, including attached files."""
		part_boundary = '--' + self.boundary

		binary = io.BytesIO()
		needsCLRF = False
		# Add the form fields
		for name, value in self.form_fields:
			if needsCLRF:
				binary.write('\r\n')
			needsCLRF = True

			block = [part_boundary,
			  'Content-Disposition: form-data; name="%s"' % name,
			  '',
			  value
			]
			binary.write('\r\n'.join(block))

		# Add the files to upload
		for field_name, filename, content_type, body in self.files:
			if needsCLRF:
				binary.write('\r\n')
			needsCLRF = True

			block = [part_boundary,
			  str('Content-Disposition: file; name="%s"; filename="%s"' % \
			  (field_name, filename)),
			  'Content-Type: %s' % content_type,
			  ''
			  ]
			binary.write('\r\n'.join(block))
			binary.write('\r\n')
			binary.write(body)


		# add closing boundary marker,
		binary.write('\r\n--' + self.boundary + '--\r\n')
		return binary

在 MultiPartForm 類設計的時候就是一個 StringIO 的緩沖區數據包裝,注意我前面說過的,每一次發起的 form-data 請求都要知道它的幀大小,包括傳輸的文件內容的長度,而 MultiPartForm 在 MicroPython 執行 form_buffer = form.get_binary().getvalue() 的時候就會內存不足 。

wCli = MicroWebCli('http://192.168.123.4:8080', 'POST')
#wCli = MicroWebCli('http://127.0.0.1:8080', 'POST')
print('POST %s' % wCli.URL)

form = MultiPartForm()

# Add a fake file
form.add_file(filepath, filename, fileHandle=open(filepath + filename, "rb"))

form_buffer =  form.get_binary().getvalue()

wCli.OpenRequest(None, form.get_content_type(), str(len(form_buffer)))

wCli.RequestWriteData(form_buffer)

因為 MultiPartForm 在設計的時候沒有考慮過內存不足的場合,它將預先載入所有文件的內容到 io.BytesIO() 對象中,然后獲取長度只需要 len(get_binary()) 即可,這個的前提是機器內存足夠大到放下這個 form-data ,但事實上在 MicroPython 中是不可能有這么多內存的,所以要改成邊讀邊上傳的模式,那么矛盾就出現了,我該如何在沒有加載所有文件的情況下獲取 form-data 的大小從而發起請求呢?

其實只需要改寫兩處地方,我們先看舊代碼。

	def add_file(self, fieldname, filename, fileHandle, mimetype=None):
		"""Add a file to be uploaded."""
		body = fileHandle.read()
		if mimetype is None:
			mimetype = mimetypes.guess_type(filename)[0] or 'application/octet-stream'
		self.files.append((fieldname, filename, mimetype, body))
		return

可以看到它此處是直接將整個文件都 read() 到 body 中,這在 MicroPython 中是不可能的,K210 通常只允申請 100K 的 BytesIO 對象,所以這里只需要提供文件的大小即可。

    def add_file(self, fieldname, filename, mimetype=None):
        """Add a file to be uploaded."""
        fileSize = os.stat(fieldname + filename)[6]
        if mimetype is None:
            mimetype = 'application/octet-stream'
        self.files.append((fieldname, filename, mimetype, fileSize))
        return

這是需要拆分兩次過程,先動態計算 form-data 的 binary 的長度,再來提供操作函數,傳遞 socket.write 給 MultiPartForm 類代理執行 form.put_binary(wCli._write) 即可,所以改成如下接口。

form = MultiPartForm()
# form.add_field("form_field", "my awesome data")

# Add a fake file
form.add_file(filepath, filename)

wCli.OpenRequest(None, form.get_content_type(), str(form.len_binary()))

form.put_binary(wCli._write)

附贈 MicroPython 實現

慣例了,需要就直接拿去吧,記得結合 MicroWebCli 使用,有錯誤也都是一些 依賴庫的錯誤,我都在 esp32 和 k210 的 micropython 上測試過辣。

class MultiPartForm(object):
    """Accumulate the data to be used when posting a form."""

    def __init__(self):
        self.form_fields = []
        self.files = []
        import time
        self.boundary = str(time.time()) # .encode()
        return

    def get_content_type(self):
        return 'multipart/form-data; boundary=%s' % self.boundary

    def add_file(self, fieldname, filename, mimetype=None):
        """Add a file to be uploaded."""
        fileSize = os.stat(fieldname + filename)[6]
        if mimetype is None:
            mimetype = 'application/octet-stream'
        self.files.append((fieldname, filename, mimetype, fileSize))
        return

    def len_binary(self):
        res = 0
        part_boundary = ('--' + self.boundary).encode()
        res += len(part_boundary)
        needsCLRF = False
        # Add the form fields
        for name, value in self.form_fields:
            if needsCLRF:
                res += len(b'\r\n')
            needsCLRF = True

            block = [part_boundary,
              ('Content-Disposition: form-data; name="%s"' % name).encode(),
              b'',
              value.encode()
            ]

            res += len(b'\r\n'.join(block))

        # Add the files to upload
        for fieldname, filename, content_type, fileSize in self.files:
            if needsCLRF:
                res += len(b'\r\n')
            needsCLRF = True

            block = [part_boundary,
              ('Content-Disposition: file; name="%s"; filename="%s"' % (fieldname, filename)).encode(),
              ('Content-Type: %s' % content_type).encode(),
              b'']

            res += len(b'\r\n'.join(block))
            res += len(b'\r\n')
            res += fileSize

        # add closing boundary marker,
        res += len(('\r\n--' + self.boundary + '--\r\n'))
        return res

    def put_binary(self, binary_write):
        """Return a binary buffer containing the form data, including attached files."""
        part_boundary = ('--' + self.boundary).encode()

        # binary = io.BytesIO()
        needsCLRF = False
        # Add the form fields
        for name, value in self.form_fields:
            if needsCLRF:
                binary_write('\r\n')
            needsCLRF = True

            block = [part_boundary,
              ('Content-Disposition: form-data; name="%s"' % name).encode(),
              b'',
              value.encode()
            ]

            binary_write(b'\r\n'.join(block))

        # Add the files to upload
        for fieldname, filename, content_type, fileSize in self.files:
            if needsCLRF:
                binary_write(b'\r\n')
            needsCLRF = True

            block = [part_boundary,
              ('Content-Disposition: file; name="%s"; filename="%s"' % (fieldname, filename)).encode(),
              ('Content-Type: %s' % content_type).encode(),
              b'']

            binary_write(b'\r\n'.join(block))
            binary_write(b'\r\n')
            #with open(fieldname + filename, 'rb') as fileObject:
                #binary_write(fileObject.read())
            with open(fieldname + filename, 'rb') as fileObject:
                # binary_write(fileObject.read())
                while True:
                    ch = fileObject.read(2048)
                    if not ch:
                        break
                    binary_write(ch)

        # add closing boundary marker,
        binary_write(('\r\n--' + self.boundary + '--\r\n').encode())

def test_http_upload():
    gc.collect()
    filename = 'F.zip'
    #filename = 'CH340.zip'
    filepath = '/sd/'
    fileObject = open(filepath + filename, 'rb')
    fileSize = os.stat(filepath + filename)[6]
    print(filepath, fileObject, fileSize)

    wCli = MicroWebCli('http://192.168.123.4:8080', 'POST')
    #wCli = MicroWebCli('http://127.0.0.1:8080', 'POST')
    print('POST %s' % wCli.URL)

    form = MultiPartForm()
    # form.add_field("form_field", "my awesome data")

    # Add a fake file
    form.add_file(filepath, filename)

    wCli.OpenRequest(None, form.get_content_type(), str(form.len_binary()))

    form.put_binary(wCli._write)

    # while True:
    #   data = fileObject.read(1024)
    #   if not data:
    #     break
    #   wCli.RequestWriteData(data)

    resp = wCli.GetResponse()
    if resp.IsSuccess() :
      o = resp.ReadContent()
      print('POST success', o)
    else :
      print('POST return %d code (%s)' % (resp.GetStatusCode(), resp.GetStatusMessage()))

#test_http_download()

#test_http_get()

while True:
    time.sleep(1)
    bak = time.ticks()
    try:
        test_http_upload()
    except Exception as E:
        print(E)
    print('total time ', (time.ticks() - bak) / 1000, ' s')

junhuanchen@qq.com 2020年5月23日留

注意:MicroWebCli 提供 application/x-www-form-urlencoded 的上傳文件方法,需要配合服務器食用。


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM