使用Django+channels+Python3.7時提交Form表單: 400 Bad Request問題

不太好升級的Python3.7之二

這個其實是我的鍋,不過我還是想"Blame"那個吞噬異常的程序員。

上次在自己的博客項目上嘗試了Python3.7的beta版之後,意識到Celery因爲慣性還是不能兼容3.7,所以不在做升級的打算。直到前不久開始弄一個簡單的內部社區,針對購買視頻的同學。這也是個人項目,所以激進點沒什麼關係。

既然是嚐鮮,那就順便也嚐嚐Django的channels,用它的Websocket來做桌面通知,也就是Chrome提供的:Notifications API 。

一開始的Python版本是3.6,開發部署都沒問題,功能也沒問題。在部署後想到,不如試試3.7。雖然channels的包聲明上還沒說能夠兼容3.7。

安裝3.7的過程也不順利,這篇暫且按下不表。

安裝好3.7之後,部署流程沒什麼差別,畢竟編寫好的fabric腳本,只是把創建虛擬環境的命令改爲了: python3.7 -m venv {project}

單說問題表現吧,或許你也可能遇到:通過Ajax發送的post請求,後端可以正常處理,但是通過Form表單提交的POST請求一律400 Bad Request

排查問題

首先需要確認的是請求有沒有打到後端upstream上,通過排查Openresty的日誌發現,是後端響應的400,那麼接下來就應該去排查應用了。

好戲纔剛剛開始。

按照往常的部署方式:Gunicorn + gthread + Django WSGI,要調試這樣的問題並不困難,因爲一直在用,所以偶爾會看下源碼。但問題是我使用了channels,所以部署的方式就變爲了:Daphne + Django ASGI了。(這裏說一下,有一個uvicorn的ASGI容器的實現,性能壓測表現也很棒,只是不能用supervisord來重啓,所以就使用channels推薦的Daphne了)

在現在的情況下要調試就不太容易了。爲啥呢?

channels依賴daphne,而daphne依賴twisted。對外的接口是異步的邏輯,所以調試起來沒那麼容易。

因爲是Django的項目,所以要確認是否有請求過來,首先要做的是在view里加日誌,沒有收到請求。接着在Middleware中增加日誌,還是沒有請求。

這意味着什麼?請求沒有進入Middleware的處理邏輯,也就是WSGI情況下對WSGIHandler的call的調用。

我對asgi的邏輯目前還不是特別清楚 ,單從代碼上看ASGI和WSGI也差不多。對於http的請求,它使用的是ASGIHandler來處理,依然是繼承自Django的core.handlers.base.BaseHandler(WSGIHandler也是繼承自它)。

不過在這裏調試依然沒有收穫,這說明請求的數據根本沒到達Handler的部分,那就應該是再往前一層的邏輯了,處理HTTP協議部分的邏輯。如果是Gunicorn + gthread的方式,直接去看對應的socket處理代碼就好。不過channels前面Daphne的Server,Daphne Server中用的是twisted.web.http下的HTTPFactory來封裝HTTP協議,而在HTTPFactory中,用的是twisted.web.http.Request來處理HTTP協議。

說到這,坑就來了。

不過我的具體定位的方法沒有那麼複雜,畢竟在熬夜的情況下要把代碼都讀一下也挺耗時間的。所以直接搜索400 Bad Request或者400關鍵字,在twisted和daphne的代碼中。最終也是定位到了twsited.web.http.Request中。

具體的代碼如下:

@implementer(interfaces.IConsumer,
             _IDeprecatedHTTPChannelToRequestInterface)
class Request:

    def requestReceived(self, command, path, version):
        """
        Called by channel when all data has been received.

        This method is not intended for users.

        @type command: C{bytes}
        @param command: The HTTP verb of this request.  This has the case
            supplied by the client (eg, it maybe "get" rather than "GET").

        @type path: C{bytes}
        @param path: The URI of this request.

        @type version: C{bytes}
        @param version: The HTTP version of this request.
        """
        self.content.seek(0,0)
        self.args = {}

        self.method, self.uri = command, path
        self.clientproto = version
        x = self.uri.split(b'?', 1)

        if len(x) == 1:
            self.path = self.uri
        else:
            self.path, argstring = x
            self.args = parse_qs(argstring, 1)

        # Argument processing
        args = self.args
        ctype = self.requestHeaders.getRawHeaders(b'content-type')
        if ctype is not None:
            ctype = ctype[0]

        if self.method == b"POST" and ctype:
            mfd = b'multipart/form-data'
            key, pdict = _parseHeader(ctype)
            if key == b'application/x-www-form-urlencoded':
                args.update(parse_qs(self.content.read(), 1))
            elif key == mfd:
                try:
                    # print(pdict)
                    # import pdb;pdb.set_trace()
                    cgiArgs = cgi.parse_multipart(self.content, pdict)

                    if _PY3:
                        # parse_multipart on Python 3 decodes the header bytes
                        # as iso-8859-1 and returns a str key -- we want bytes
                        # so encode it back
                        self.args.update({x.encode('iso-8859-1'): y
                                          for x, y in cgiArgs.items()})
                    else:
                        self.args.update(cgiArgs)
                except:  # 注意:坑在這
                    # It was a bad request.
                    self.channel._respondToBadRequestAndDisconnect()
                    return
            self.content.seek(0, 0)

        self.process()

上面的self.channel._respondToBadRequestAndDisconnect代碼如下,通過關鍵字搜索先找到這塊代碼。

def _respondToBadRequestAndDisconnect(self):
    """
    This is a quick and dirty way of responding to bad requests.

    As described by HTTP standard we should be patient and accept the
    whole request from the client before sending a polite bad request
    response, even in the case when clients send tons of data.

    @param transport: Transport handling connection to the client.
    @type transport: L{interfaces.ITransport}
    """
    self.transport.write(b"HTTP/1.1 400 Bad Request\r\n\r\n")
    self.loseConnection()

問題總結

到了具體代碼的位置,問題也就明瞭了。總結下原因。

在Python3.7的changelog裏面:https://docs.python.org/3.7/whatsnew/changelog.html#changelog

bpo-33497: Add errors param to cgi.parse_multipart and make an encoding in FieldStorage use the given errors (needed for Twisted). Patch by Amber Brown. bpo-29979: rewrite cgi.parse_multipart, reusing the FieldStorage class and making its results consistent with those of FieldStorage for multipart/form-data requests. Patch by Pierre Quentel.

去看對應的pull request會發現,cgi.parse_multipart被重寫了,強制需要CONTENT-LENGTH:

headers['Content-Length'] = pdict['CONTENT-LENGTH']

而我上面貼出來的代碼,其中調用cgi.parse_multipart方法的部分,外層有一個寬泛的異常處理,並且沒輸出任何日誌。當然也因爲傳進去的參數有問題。

知道了問題所以就去看了眼twisted在GitHub上的代碼,竟然已經處理了。(順便提一下,那個吞掉異常的代碼就是Amber Brown 2015年寫的,後來也是她解決的。看twisted的commit,很多她的提交。並且最近的一些Release都是她主導的。我只能說,誰年輕時還不寫幾個糟糕的代碼呢。不過新的代碼依然沒有輸出日誌啊, -.-| )

終極原因

上面說了一大堆內容,終極的原因其實是我用了一個老的twisted包(18.4.0),最新的是18.7.0。

總結

  • 寬泛的異常捕獲,並且不做任何輸出,簡直就是大坑。
  • 嚐鮮的情況下,最好都用新的版本,避免出現上面的問題。
  • channels跟Django結合的很好,用起來順手,調試起來麻煩。
  • 有空應該看看twisted,畢竟channels用到了它。

參考

  • https://reinout.vanrees.org/weblog/2015/11/06/twisted-and-django.html
  • https://labs.twistedmatrix.com/
  • https://github.com/twisted/twisted/blob/trunk/src/twisted/web/http.py
  • https://github.com/python/cpython/pull/991/files

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章