werkzeug原始碼分析——從官網的示例程式碼開始

Mansk發表於2019-02-11

全文基於Python 2.7 macOS 10.12.2

werkzeug是Python實現的WSGI規範的使用函式庫。什麼是WSGI?如何理解CGI,WSGI 網上的說明很多,在文章的開始,我想要強調兩點

  • WSGI是一種伺服器和客戶端互動的介面規範
  • 理解web元件:client, server, and middleware.

正如werkzeug官網Werkzeug上所說,werkzeug使用起來非常簡單,但是卻非常強大。關於使用簡單的這個特性,官網給了一段示例程式碼。

from werkzeug.wrappers import Request, Response
@Request.application
def application(request):
    return Response('Hello World!')
if __name__ == '__main__':
    from werkzeug.serving import run_simple
    run_simple('localhost', 4000, application)
複製程式碼

執行起來以後,開啟我們的瀏覽器輸入127.0.0.1:4000就可以看到

werkzeug原始碼分析——從官網的示例程式碼開始

這篇文章我們就將從這段示例程式碼入手,從原始碼的角度分析一下背後究竟是如何實現的。


一個web應用的本質,實際就是: 瀏覽器(client)傳送一個請求(request) ——> 伺服器(server)接收到請求 ——> 伺服器處理請求 ——> 返回處理的結果(response) ——> 瀏覽器處理返回的結果,顯示出來。

再看這段程式碼,開始的func application(),非常的容易理解。函式在server端,接收了來自client的一個request,經過內部的處理以後返回了一個response。但是如果看過其他WSGI教程(比如wsgi介面)的朋友應該會感覺到奇怪,這個函式和別的地方舉例的不太一樣,因為WSGI要求web開發者必須實現的函式是這個樣子的

defapplication(environ, start_response):     start_response('200 OK', [('Content-Type','text/html')])     return 'Hello, web!'

我們的函式必須接受兩個引數environ,start_response。environ是一個儲存了請求的各項資訊的字典,而start_response是一個func,我們可以用它來給client端返回狀態碼和response headers。最後return我們真正想要返回的資料。但是werkzeug的這段示例程式碼卻簡化了很多,原因就在這個函式的裝飾器上 **@Request.application **

**@classmethod** def application(cls, f):     def application(*args):         request = cls(args[-2])         with request:             return f(*args[:-2] + (request,))(*args[-2:])     return update_wrapper(application, f)

原始碼可以看出,**@Request.application **攔截了func的倒數第二個引數(也就是environ),構建了request物件;然後把原引數移除了倒數兩個引數(environ和start_response)以後和request物件一起傳入了我們自己實現的func application();呼叫func application()返回的物件,傳入原引數的倒數兩個。把最後的執行結果返回。 為了便於理解,我寫了一個裝飾器,列印每個函式的引數。對比一下就很明瞭了。

@Request.application
原引數

如果你還是有一點疑惑,覺得話說的有點繞口。我們先保持疑問,在後面作者再給大家細細解釋。

看到這裡,這個func先放一邊。我們來看看func run_simple()。這是這個簡單web應用的入口函式。在~/werkzeug/serving.py檔案的開始,有這樣一段註釋

... There are many ways to serve a WSGI application.  While you're developing it you usually don't want a full blown webserver like Apache but a simple standalone one. ... For bigger applications you should consider using werkzeug.script instead of a simple start file.

大多數情況下,我們並不需要一個大而全的webserver。比如,Apache。一個簡單而獨立的webserver就夠了。很顯然,我們今天所討論的run_simple()就是為此場景而服務的.

    def run_simple(hostname, port, application, use_reloader=False,
               use_debugger=False, use_evalex=True,
               extra_files=None, reloader_interval=1,
               reloader_type='auto', threaded=False,
               processes=1, request_handler=None, static_files=None,
               passthrough_errors=False, ssl_context=None):

    if use_debugger:
        from werkzeug.debug import DebuggedApplication
        application = DebuggedApplication(application, use_evalex)
    if static_files:
        from werkzeug.wsgi import SharedDataMiddleware
        application = SharedDataMiddleware(application, static_files)

    def log_startup(sock):
        display_hostname = hostname not in ('', '*') and hostname or 'localhost'
        if ':' in display_hostname:
            display_hostname = '[%s]' % display_hostname
        quit_msg = '(Press CTRL+C to quit)'
        port = sock.getsockname()[1]
        _log('info', ' * Running on %s://%s:%d/ %s',
             ssl_context is None and 'http' or 'https',
             display_hostname, port, quit_msg)

    def inner():
        try:
            fd = int(os.environ['WERKZEUG_SERVER_FD'])
        except (LookupError, ValueError):
            fd = None
        srv = make_server(hostname, port, application, threaded,
                          processes, request_handler,
                          passthrough_errors, ssl_context,
                          fd=fd)
        if fd is None:
            log_startup(srv.socket)
        srv.serve_forever()

    if use_reloader:
        # If we're not running already in the subprocess that is the
        # reloader we want to open up a socket early to make sure the
        # port is actually available.
        if os.environ.get('WERKZEUG_RUN_MAIN') != 'true':
            if port == 0 and not can_open_by_fd:
                raise ValueError('Cannot bind to a random port with enabled '
                                 'reloader if the Python interpreter does '
                                 'not support socket opening by fd.')

            # Create and destroy a socket so that any exceptions are
            # raised before we spawn a separate Python interpreter and
            # lose this ability.
            address_family = select_ip_version(hostname, port)
            s = socket.socket(address_family, socket.SOCK_STREAM)
            s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
            s.bind((hostname, port))
            if hasattr(s, 'set_inheritable'):
                s.set_inheritable(True)

            # If we can open the socket by file descriptor, then we can just
            # reuse this one and our socket will survive the restarts.
            if can_open_by_fd:
                os.environ['WERKZEUG_SERVER_FD'] = str(s.fileno())
                s.listen(LISTEN_QUEUE)
                log_startup(s)
            else:
                s.close()

        # Do not use relative imports, otherwise "python -m werkzeug.serving"
        # breaks.
        from werkzeug._reloader import run_with_reloader
        run_with_reloader(inner, extra_files, reloader_interval,
                          reloader_type)
    else:
        inner()
複製程式碼

開始研究原始碼,我們應該學會做減法,去掉一些分支看主幹,避免對整體理解的干擾。在示例程式碼中,我們用到的引數是hostname(本地執行預設127.0.0.1),port(預設是4000),application(就是我們傳入的func application())。其餘的引數全部按照預設設定來執行。原始碼中有很多的判斷,我們根據示例程式碼的引數,對原始碼進行處理,去掉不執行的部分。如下:

def run_simple(hostname, port, application, use_reloader=False,
               use_debugger=False, use_evalex=True,
               extra_files=None, reloader_interval=1,
               reloader_type='auto', threaded=False,
               processes=1, request_handler=None, static_files=None,
               passthrough_errors=False, ssl_context=None):
    def log_startup(sock):
        display_hostname = hostname not in ('', '*') and hostname or 'localhost'
        if ':' in display_hostname:
            display_hostname = '[%s]' % display_hostname
        quit_msg = '(Press CTRL+C to quit)'
        port = sock.getsockname()[1]
        _log('info', ' * Running on %s://%s:%d/ %s',
             ssl_context is None and 'http' or 'https',
             display_hostname, port, quit_msg)


    def inner():
        try:
            fd = int(os.environ['WERKZEUG_SERVER_FD'])
        except (LookupError, ValueError):
            fd = None
        srv = make_server(hostname, port, application, threaded,
                          processes, request_handler,
                          passthrough_errors, ssl_context,
                          fd=fd)
        if fd is None:
            log_startup(srv.socket)
        srv.serve_forever()

    inner()
複製程式碼

現在看來就簡單很多了。執行了一個inner()函式:首先從環境變數中獲取'WERKZEUG_SERVER_FD'的值,如果為空就執行log_startup()函式。執行make_server()函式並讓返回的物件執行server_forever()函式。

那我們再去make_server()裡看看到底做了什麼。

def make_server(host=None, port=None, app=None, threaded=False, processes=1,
                request_handler=None, passthrough_errors=False,
                ssl_context=None, fd=None):
    """Create a new server instance that is either threaded, or forks
    or just processes one request after another.
    """
    if threaded and processes > 1:
        raise ValueError("cannot have a multithreaded and "
                         "multi process server.")
    elif threaded:
        return ThreadedWSGIServer(host, port, app, request_handler,
                                  passthrough_errors, ssl_context, fd=fd)
    elif processes > 1:
        return ForkingWSGIServer(host, port, app, processes, request_handler,
                                 passthrough_errors, ssl_context, fd=fd)
    else:
        return BaseWSGIServer(host, port, app, request_handler,
                              passthrough_errors, ssl_context, fd=fd)
複製程式碼

make_server()函式建立了一個新的server物件。參看一下原始碼,** ThreadedWSGIServer** 和 ** ForkingWSGIServer 都是 ** BaseWSGIServer的子類。這篇文章我們就不仔細分析區別,就以BaseWSGIServer入手。

BaseWSGIServer繼承自HTTPServer。在~/werkzeug/serving.py的開始

try:
    import SocketServer as socketserver
    from BaseHTTPServer import HTTPServer, BaseHTTPRequestHandler
except ImportError:
    import socketserver
    from http.server import HTTPServer, BaseHTTPRequestHandler
複製程式碼

werkzeug的HTTP服務是基於Pyhton的BaseHTTPServer來實現的。關於BaseHTTPServer的文件在這裡BaseHTTPServer。一般我們使用class HTTPServer來建立Server端,監聽指定埠,然後建立一個class BaseHTTPRequestHandler來處理我們捕獲到的request。在werkzeug中** WSGIRequestHandler** 繼承自** BaseHTTPRequestHandler**。

關於BaseHTTPServer並沒有什麼太多值得討論的東西,基本暴露出的介面都是呼叫父類的同名方法。我們要做的也就是傳入hostname,port等引數,然後執行serve_forever()來建立服務。(serve_forever()看著眼熟嗎?就是run_simple()中make_server()返回的srv物件執行的方法) 但是** WSGIRequestHandler **,作者還是想和各位嘮叨幾句。

根據Pyhton 2.7的官方文件所說,** BaseHTTPRequestHandler**這個類在Server端接收到請求以後會根據請求的方法來執行do_xx()函式。比如說我們發起的是一個GET請求,那麼就會執行do_GET()這個函式。但對應各種請求方法的do_xx()函式父類並沒有實現,需要使用者自行去定義。

文件中有一段示例程式碼,我們複製下來(如下)

import BaseHTTPServer

def run(server_class=BaseHTTPServer.HTTPServer,
        handler_class=BaseHTTPServer.BaseHTTPRequestHandler):
    server_address = ('', 5000)
    httpd = server_class(server_address, handler_class)
    httpd.serve_forever()


if __name__ == '__main__':
	
	run()

複製程式碼

執行會在本地的5000埠建立一個服務。但是當你使用瀏覽器前往127.0.0.1:5000時會收到一個501的錯誤。

Paste_Image.png
原因就在於我們並沒有實現do_GET()方法。現在我們稍稍修改程式碼,繼承BaseHTTPRequestHandler實現一個do_GET()方法,呼叫父類中send_response()返回client端一個200的狀態碼和一個'success message!'。

import BaseHTTPServer

class HTTPServerHandler(BaseHTTPServer.BaseHTTPRequestHandler):

	def do_GET(self):
		self.send_response(200,'success message!')
		
def run(server_class=BaseHTTPServer.HTTPServer,
        handler_class=HTTPServerHandler):
    server_address = ('', 5000)
    httpd = server_class(server_address, handler_class)
    httpd.serve_forever()

if __name__ == '__main__':
	
	run()
複製程式碼

再次請求。開啟瀏覽器的開發者工具,在network中可以看到返回的結果。

的

這樣。一個最簡單的Server端就實現了。當然,這個返回非常的簡陋,並沒有什麼實際的資訊返回。我們從這樣一個簡單的例子,應該要發現一點問題。

  • 每一種HTTP請求的方法都需要對應去實現一個do_xx()方法,非常的麻煩。
  • 所有的請求資訊都暴露給開發者,很多時候開發者作為上層的呼叫者,並不想關心很多底層的操作。
  • 當我們的application複雜起來,需要返回更多的內容。每次都要去操作這些基礎的HTTP請求相關的東西。非常的不友好。

接下來,我們看看werkzeug中是如何處理的。在官方文件中

handle() Calls handle_one_request() once (or, if persistent connections are enabled, multiple times) to handle incoming HTTP requests. You should never need to override it; instead, implement appropriate do_*()  methods.

handle_one_request() This method will parse and dispatch the request to the appropriate do_*()  method. You should never need to override it.

提到了,當我們捕獲到request的時候呼叫的是這兩個func()。並且,它給我們的提示是**You should never need to override it. **(滑稽)。但實際在werkzeug中這兩個方法是被override了的。要想不顧官方的阻攔一意孤行,首先我們還是應該瞭解這兩個方法究竟是怎麼實現的。

def handle_one_request(self):
        """Handle a single HTTP request.

        You normally don't need to override this method; see the class
        __doc__ string for information on how to handle specific HTTP
        commands such as GET and POST.

        """
        try:
            self.raw_requestline = self.rfile.readline(65537)
            if len(self.raw_requestline) > 65536:
                self.requestline = ''
                self.request_version = ''
                self.command = ''
                self.send_error(414)
                return
            if not self.raw_requestline:
                self.close_connection = 1
                return
            if not self.parse_request():
                # An error code has been sent, just exit
                return
            mname = 'do_' + self.command
            if not hasattr(self, mname):
                self.send_error(501, "Unsupported method (%r)" % self.command)
                return
            method = getattr(self, mname)
            method()
            self.wfile.flush() #actually send the response if not already done.
        except socket.timeout, e:
            #a read or a write timed out.  Discard this connection
            self.log_error("Request timed out: %r", e)
            self.close_connection = 1
            return

    def handle(self):
        """Handle multiple requests if necessary."""
        self.close_connection = 1

        self.handle_one_request()
        while not self.close_connection:
            self.handle_one_request()
複製程式碼

捕獲了request之後執行的是func handle()。其中self.close_connection 是一個關閉連線的標誌位。只有在request header中包含keep-alive且協議版本號大於HTTP/1.1的時候設0,其餘情況下置1。捕獲request的具體實現還是在handle_one_request()中。

1.首先判斷接收的位元組數。是否大於65536。大於返回414錯誤。(IP首部中標識長度的有16bit,所以長度限制不可以大於2^16) 2.判斷讀取的位元組。為空關閉連線 3.呼叫parse_request()解析request。解析錯誤返回狀態嗎,成功繼續。 4.根據請求的方法尋找對應的函式。如果子類沒有實現對應方法,返回501錯誤。找到對應方法,執行並將返回內容寫入資料。 5.如果timeout,打個log記錄一下。

這就是BaseHTTPServer中的處理。在瞭解了它的原理之後,讓我們回到werkzeug,看看它是如何處理的。

def handle(self):
        """Handles a request ignoring dropped connections."""
        rv = None
        try:
            rv = BaseHTTPRequestHandler.handle(self)
        except (socket.error, socket.timeout) as e:
            self.connection_dropped(e)
        except Exception:
            if self.server.ssl_context is None or not is_ssl_error():
                raise
        if self.server.shutdown_signal:
            self.initiate_shutdown()
        return rv

def handle_one_request(self):
        """Handle a single HTTP request."""
        self.raw_requestline = self.rfile.readline()
        if not self.raw_requestline:
            self.close_connection = 1
        elif self.parse_request():
            return self.run_wsgi()
複製程式碼

BaseHTTPServer並不支援SSL。在werkzeug中新增了對SSL的支援。所以在werkzeug的handle()中,它在執行了父類的func handle()後,除了對socket.timeout的處理,還考慮了SSL的可能。在func handle_one_request()中,減少了對位元組流長度的判斷。和BaseHTTPServer不同的是,werkzeug中把所有請求方法的處理,都放到了func run_wsgi()中,而不是根據請求方法區分成不同的方法並且要求子類來實現。

def run_wsgi(self):
        if self.headers.get('Expect', '').lower().strip() == '100-continue':
            self.wfile.write(b'HTTP/1.1 100 Continue\r\n\r\n')

        self.environ = environ = self.make_environ()
        headers_set = []
        headers_sent = []

        def write(data):
            assert headers_set, 'write() before start_response'
            if not headers_sent:
                status, response_headers = headers_sent[:] = headers_set
                try:
                    code, msg = status.split(None, 1)
                except ValueError:
                    code, msg = status, ""
                self.send_response(int(code), msg)
                header_keys = set()
                for key, value in response_headers:
                    self.send_header(key, value)
                    key = key.lower()
                    header_keys.add(key)
                if 'content-length' not in header_keys:
                    self.close_connection = True
                    self.send_header('Connection', 'close')
                if 'server' not in header_keys:
                    self.send_header('Server', self.version_string())
                if 'date' not in header_keys:
                    self.send_header('Date', self.date_time_string())
                self.end_headers()

            assert isinstance(data, bytes), 'applications must write bytes'
            self.wfile.write(data)
            self.wfile.flush()

        def start_response(status, response_headers, exc_info=None):
            if exc_info:
                try:
                    if headers_sent:
                        reraise(*exc_info)
                finally:
                    exc_info = None
            elif headers_set:
                raise AssertionError('Headers already set')
            headers_set[:] = [status, response_headers]
            return write

        def execute(app):
            application_iter = app(environ, start_response)
            try:
                for data in application_iter:
                    write(data)
                if not headers_sent:
                    write(b'')
            finally:
                if hasattr(application_iter, 'close'):
                    application_iter.close()
                application_iter = None

        try:
            execute(self.server.app)
        except (socket.error, socket.timeout) as e:
            self.connection_dropped(e, environ)
        except Exception:
            if self.server.passthrough_errors:
                raise
            from werkzeug.debug.tbtools import get_current_traceback
            traceback = get_current_traceback(ignore_system_exceptions=True)
            try:
                # if we haven't yet sent the headers but they are set
                # we roll back to be able to set them again.
                if not headers_sent:
                    del headers_set[:]
                execute(InternalServerError())
            except Exception:
                pass
            self.server.log('error', 'Error on request:\n%s',
                            traceback.plaintext)
複製程式碼

func run_wsgi()首先對request的headers進行了一個判斷。有關HTTP協議的東西不在我們這篇文章的討論範圍內,RFC的文件在8.2.3 Use of the 100 (Continue) Status這裡。有興趣的朋友參考文件一下,這裡我們可以先忽略了這個判斷。

之後是獲取環境變數environ。func make_environ()實質作用就是打包各類資訊成一個字典。當你去檢視這個函式原始碼時,你會發現這個字典的很多key值你非常的眼熟。沒錯,就是文章開始在解釋func application()時,列印傳入引數時列印的那個environ引數。func make_environ()獲取了各項資訊以後會在之後傳給我們自己實現的application。這裡我們先簡單略過,後面遇到再討論。

之後宣告瞭兩個陣列和三個func,我們直接略過一路看到底。看到結尾處的try-catch,這才是run_wsgi()的入口位置。首先函式呼叫了func execute(),傳入繫結在HTTPServer上的application。這個application就是func run_simple()我們傳入的自定義的那個func application(),make_server()建立HTTPServer時將application繫結在上面。

application_iter = app(environ, start_response)

這樣簡單的一句乍看會讓人比較的懵。因為我們知道,func application()返回的是一個Response物件,而這裡application_iter很明顯是一個可以迭代的物件。其中的祕密就在文章開始我們所說的那個deractor**@Request.application**。

我們已經解釋過了,這個deractor攔截倒數第二個引數,對比這裡就是environ,建立一個request物件,然後和倒數第二之前的引數(也就是隻傳入了request物件)一起傳入我們的func application(),return了一個Response物件。return f(*args[:-2] + (request,))(*args[-2:]),這個deractor在return的時候又把這個response當做函式來處理了一把。在這個例子中最後的結果類似這樣response(environ,start_response)。這樣列出來就很明確了,我們去~/werkzeug/wrapper.py中看看class BaseResponse的__call__方法。

def __call__(self, environ, start_response):
        """Process this response as WSGI application.

        :param environ: the WSGI environment.
        :param start_response: the response callable provided by the WSGI
                               server.
        :return: an application iterator
        """
        app_iter, status, headers = self.get_wsgi_response(environ)
        start_response(status, headers)
        return app_iter
複製程式碼

關於class Requestclass Response其實有很多值得討論的地方。這裡我不展開討論func get_wsgi_response()方法的實現。我們只要明確,返回了三個物件,app_iter(包含了需要返回的各項資料))status(狀態碼)headers(response headers)。其中app_iter就是我們上面剛剛討論的application_iter。函式裡呼叫傳入的func start_response來寫入status和headers。

def start_response(status, response_headers, exc_info=None):
            if exc_info:
                try:
                    if headers_sent:
                        reraise(*exc_info)
                finally:
                    exc_info = None
            elif headers_set:
                raise AssertionError('Headers already set')
            headers_set[:] = [status, response_headers]
            return write
複製程式碼

func start_response()將status和response_headers合併在func run_wsgi()開始建立的陣列裡,然後返回一個func write()。這些資訊只是被儲存下來,此時還沒有被寫入wfile。此時我們獲取了application_iter,開始迭代呼叫func write()將每一項資訊寫入wfile

def write(data):
            assert headers_set, 'write() before start_response'
            if not headers_sent:
                status, response_headers = headers_sent[:] = headers_set
                try:
                    code, msg = status.split(None, 1)
                except ValueError:
                    code, msg = status, ""
                self.send_response(int(code), msg)
                header_keys = set()
                for key, value in response_headers:
                    self.send_header(key, value)
                    key = key.lower()
                    header_keys.add(key)
                if 'content-length' not in header_keys:
                    self.close_connection = True
                    self.send_header('Connection', 'close')
                if 'server' not in header_keys:
                    self.send_header('Server', self.version_string())
                if 'date' not in header_keys:
                    self.send_header('Date', self.date_time_string())
                self.end_headers()

            assert isinstance(data, bytes), 'applications must write bytes'
            self.wfile.write(data)
            self.wfile.flush()
複製程式碼

func write()首先判斷了header_set是否為空,保證func start_response在func write之前執行。這是因為我們在寫入資料之前首先要寫入response_headers,再呼叫func self.end_headers()來將response_headers和應用資料區分開來。如果順序錯開就會發生錯誤。

再拿之前的例子用一下,這次我們加一點內容

import BaseHTTPServer


class HTTPServerHandler(BaseHTTPServer.BaseHTTPRequestHandler):

	def do_GET(self):
		self.send_response(200,'success message!')
		self.end_headers()

		self.wfile.write('hello world!')

		
def run(server_class=BaseHTTPServer.HTTPServer,
        handler_class=HTTPServerHandler):
    server_address = ('', 5000)
    httpd = server_class(server_address, handler_class)
    httpd.serve_forever()


if __name__ == '__main__':
	
	run()
複製程式碼

執行這樣一段程式碼之後開啟瀏覽器輸入127.0.0.1:5000,你可以看到瀏覽器顯示

瀏覽器顯示結果

但是如果你替換了一下寫入的順序

def do_GET(self):
		self.wfile.write('hello world!')
		
		self.send_response(200,'success message!')
		self.end_headers()
複製程式碼

再次訪問127.0.0.1:5000,這個時候就會報錯了。

訪問出現了錯誤

官網的文件解釋了end_headers()的作用。雖然簡單,但也還是要我們注意

end_headers() Sends a blank line, indicating the end of the HTTP headers in the response.

回到werkzeug的原始碼,之後我們判斷headers_sent是否為空。其實看這個命令我們也能猜出大概,這是儲存已經寫入過的response_headers的陣列。如果為空證明我們還沒有寫入,進入if判斷裡,從我們之前func start_response中儲存的headers_set中取出狀態碼和response_headers寫入返回資訊中。並且核查了幾個必要的response_headers是否存在,如果不存在就進行設定寫入。並且每一個寫入的response_headers都被儲存進headers_sent。這樣第二次呼叫func write就不再重複設定。

func write的最後是真正的資料寫入操作。

回到func run_wsgi的主幹上來。我們在拿到application_iter後開始逐個迭代呼叫func write寫入response。另外,假設func start_response沒有設定任何response_headers,application_iter也為空。為了保證func write一定被執行一次,response_headers預設值被寫入。我們會寫入一個*' '*的資料。

之後是一些對於異常的處理。包括超時或者不可預知的錯誤。會丟擲異常或者打log記錄。

至此。一個完整的web流程就走完了。


這篇文章作者嘗試把werkzeug這顆大樹的枝丫全部砍去,留下一根主幹來說明這個框架核心所在。當然,現實的web應用不可能如此簡單。比如

  • 所有的url的處理都導向一個func去處理。
  • 返回複雜的資料如何處理
  • 各種異常的處理
  • 對各種操作的容錯處理

...

後面作者會從主幹擴充開始,慢慢補回枝丫來解釋其他部分。著名的Flask框架就是基於werkzeug和Jinja 2實現的。在之後的文章,我不希望拆開werkzeug和Flask來分析。當做一個整體來看會更加和諧,也便於理解。

如有錯誤,歡迎指正

相關文章