Python廖雪峰實戰web開發(Day5-編寫web框架)
因為複雜的Web應用程式,光靠一個WSGI(Web Server Gateway Interface)函式來處理還是太底層了,我們需要在WSGI之上再抽象出Web框架(比如
Aiohttp
、Django
、Flask
等),從而進一步簡化Web開發。
在day1編寫web app
骨架因為要實現協程,所以運用的是aiohttp
web框架。那麼現在為何又要重新編寫一個新的web框架呢,這是因為從使用者的角度來說,aiohttp相對比較底層,想要使用框架時編寫更少的程式碼,就只能在aiohttp
框架上封裝一個更高階的框架。
Web框架的設計是完全從使用者出發,目的是讓框架使用者編寫儘可能少的程式碼。
因此我們希望框架使用者可以摒棄複雜的步驟,這次新建立的框架想要達到的預期效果是:只需編寫函式(不然就要建立async def handle_url_xxx(request): ...
這樣的一大推東西),透過新建的Web框架就可以實現相同的效果。同時,這樣編寫簡單的函式而非引入request
和web.Response
還有一個額外的好處,就是可以單獨測試,否則,需要模擬一個request
才能測試。
因為是以aiohttp
框架為基礎,要達到上述預期的效果,也是需要符合aiohttp
框架要求,因此就需要考慮如何在request
物件中,提取使用者編寫的函式中需要用到的引數資訊,以及如何將函式的返回值轉化web.response
1. 編寫URL處理函式
1.1 aiohttp編寫URL處理處理函式
day1的URL處理函式比較簡單,因為day1的的URL處理函式沒有真正意義上使用到request
引數,但總體上差不多。
使用aiohttp
框架,編寫一個URL處理函式大概需要幾步:
第一步,新增協程裝飾器
async def handle_url_xxx(request):
...
第二步,對request
引數進行操作,以獲取相應的引數
url_param = request.match_info['key']
query_params = parse_qs(request.query_string)
第三步,就是構造Response
物件並返回
text = render('template', data)
return web.Response(text.encode('utf-8'))
而新建立的web框架希望可以封裝以上一些步驟,在使用時,更加方便快捷。
1.2 新建web框架編寫URL處理函式
1.2.1 @get和@post
Http定義了與伺服器互動的不同方法,最基本的方法有4種,分別是GET,POST,PUT,DELETE。URL全稱是資源描述符,我們可以這樣認為:一個URL地址,它用於描述一個網路上的資源,而HTTP中的GET,POST,PUT,DELETE就對應著對這個資源的查,改,增,刪4個操作。
建議:
1、get方式的安全性較Post方式要差些,包含機密資訊的話,建議用Post資料提交方式;
2、在做資料查詢時,建議用Get方式;而在做資料新增、修改或刪除時,建議用Post方式;
把一個函式對映為一個URL處理函式,可以先構造一個裝飾器,用來儲存、附帶URL資訊,這裡使用了偏函式。
#這裡運用偏函式,一併建立URL處理函式的裝飾器,用來儲存GET、POST和URL路徑資訊
import functools
def Handler_decorator(path,*,method):
def decorator(func):
@functools.wraps(func)#更正函式簽名
def wrapper(*args,**kw):
return func(*args,**kw)
wrapper.__route__ = path #儲存路徑資訊,注意這裡屬性名叫route
wrapper.__method__ = method #儲存方法資訊
return wrapper
return decorator
get = functools.partial(Handler_decorator,method = 'GET')
post = functools.partial(Handler_decorator,method = 'POST')
1.2.3 定義RequestHandler
- 使用者編寫的URL處理函式不一定是一個coroutine,因此我們用RequestHandler()來封裝一個URL處理函式。
RequestHandler
是一個類,建立的時候定義了__call__()
方法,因此可以將其例項視為函式。RequestHandler
目的就是從URL函式中分析其需要接收的引數,從request中獲取必要的引數,呼叫URL函式。(要完全符合aiohttp
框架的要求,就需要把結果轉換為web.Response
物件,而這步驟並不是像教程所說在這階段實現,而是在後面建立middleware
的工廠函式時實現。)
import inspect,asyncio
from web_app.APIError import APIError
from aiohttp import web
from urllib import parse
#運用inspect模組,建立幾個函式用以獲取URL處理函式與request引數之間的關係
def get_required_kw_args(fn): #收集沒有預設值的命名關鍵字引數
args = []
params = inspect.signature(fn).parameters #inspect模組是用來分析模組,函式
for name, param in params.items():
if str(param.kind) == 'KEYWORD_ONLY' and param.default == inspect.Parameter.empty:
args.append(name)
return tuple(args)
def get_named_kw_args(fn): #獲取命名關鍵字引數
args = []
params = inspect.signature(fn).parameters
for name,param in params.items():
if str(param.kind) == 'KEYWORD_ONLY':
args.append(name)
return tuple(args)
def has_named_kw_arg(fn): #判斷有沒有命名關鍵字引數
params = inspect.signature(fn).parameters
for name,param in params.items():
if str(param.kind) == 'KEYWORD_ONLY':
return True
def has_var_kw_arg(fn): #判斷有沒有關鍵字引數
params = inspect.signature(fn).parameters
for name,param in params.items():
if str(param.kind) == 'VAR_KEYWORD':
return True
def has_request_arg(fn): #判斷是否含有名叫'request'引數,且該引數是否為最後一個引數
params = inspect.signature(fn).parameters
sig = inspect.signature(fn)
found = False
for name,param in params.items():
if name == 'request':
found = True
continue #跳出當前迴圈,進入下一個迴圈
if found and (str(param.kind) != 'VAR_POSITIONAL' and str(param.kind) != 'KEYWORD_ONLY' and str(param.kind != 'VAR_KEYWORD')):
raise ValueError('request parameter must be the last named parameter in function: %s%s'%(fn.__name__,str(sig)))
return found
#定義RequestHandler,正式向request引數獲取URL處理函式所需的引數
class RequestHandler(object):
def __init__(self,app,fn):#接受app引數
self._app = app
self._fn = fn
self._required_kw_args = get_required_kw_args(fn)
self._named_kw_args = get_named_kw_args(fn)
self._has_named_kw_arg = has_named_kw_arg(fn)
self._has_var_kw_arg = has_var_kw_arg(fn)
self._has_request_arg = has_request_arg(fn)
async def __call__(self,request): #__call__這裡要構造協程
kw = None
if self._has_named_kw_arg or self._has_var_kw_arg:
if request.method == 'POST': #判斷客戶端發來的方法是否為POST
if not request.content_type: #查詢有沒提交資料的格式(EncType)
return web.HTTPBadRequest(text='Missing Content_Type.')#這裡被廖大坑了,要有text
ct = request.content_type.lower() #小寫
if ct.startswith('application/json'): #startswith
params = await request.json() #Read request body decoded as json.
if not isinstance(params,dict):
return web.HTTPBadRequest(text='JSON body must be object.')
kw = params
elif ct.startswith('application/x-www-form-urlencoded') or ct.startswith('multipart/form-data'):
params = await request.post() # reads POST parameters from request body.If method is not POST, PUT, PATCH, TRACE or DELETE or content_type is not empty or application/x-www-form-urlencoded or multipart/form-data returns empty multidict.
kw = dict(**params)
else:
return web.HTTPBadRequest(text='Unsupported Content_Tpye: %s'%(request.content_type))
if request.method == 'GET':
qs = request.query_string #The query string in the URL
if qs:
kw = dict()
for k,v in parse.parse_qs(qs,True).items(): #Parse a query string given as a string argument.Data are returned as a dictionary. The dictionary keys are the unique query variable names and the values are lists of values for each name.
kw[k] = v[0]
if kw is None:
kw = dict(**request.match_info)
else:
if not self._has_var_kw_arg and self._named_kw_args: #當函式引數沒有關鍵字引數時,移去request除命名關鍵字引數所有的引數資訊
copy = dict()
for name in self._named_kw_args:
if name in kw:
copy[name] = kw[name]
kw = copy
for k,v in request.match_info.items(): #檢查命名關鍵引數
if k in kw:
logging.warning('Duplicate arg name in named arg and kw args: %s' % k)
kw[k] = v
if self._has_request_arg:
kw['request'] = request
if self._required_kw_args: #假如命名關鍵字引數(沒有附加預設值),request沒有提供相應的數值,報錯
for name in self._required_kw_args:
if name not in kw:
return web.HTTPBadRequest(text='Missing argument: %s'%(name))
logging.info('call with args: %s' % str(kw))
try:
r = await self._fn(**kw)
return r
except APIError as e: #APIError另外建立
return dict(error=e.error, data=e.data, message=e.message)
在上述RequestHandler
程式碼可以看出最後呼叫URL函式時,URL函式可能會返回一個名叫APIError
的錯誤,那這個APIError
又是什麼來的呢,其實它的作用是用來返回諸如賬號登入資訊的錯誤,這會在day10編寫使用者註冊API裡面講到,此時先按下面封裝一些APIError
吧:
class APIError(Exception):
'''
基礎的APIError,包含錯誤型別(必要),資料(可選),資訊(可選)
'''
def __init__(self,error,data = '',message = ''):
super(APIError,self).__init__(message)
self.error = error
self.data = data
self.message = message
class APIValueError(APIError):
'''
Indicate the input value has error or invalid. The data specifies the error field of input form.
表明輸入資料有問題,data說明輸入的錯誤欄位
'''
def __init__(self,field,message = ''):
super(APIValueError,self).__init__('Value: invalid',field,message)
class APIResourceNotfoundError(APIError):
'''
Indicate the resource was not found. The data specifies the resource name.
表明找不到資源,data說明資源名字
'''
def __init__(self,field,message = ''):
super(APIResourceNotFoundError,self).__init__('Value: Notfound',field,message)
class APIPermissionError(APIError):
'''
Indicate the api has no permission.
介面沒有許可權
'''
def __init__(self,message = ''):
super(APIPermissionError,self).__init__('Permission: forbidden','Permission',message)
2. 編寫add_route
函式以及add_static
函式
由於新建的web框架時基於aiohttp
框架,所以需要再編寫一個add_route函式,用來註冊一個URL處理函式,主要起驗證函式是否有包含URL的響應方法與路徑資訊,以及將函式變為協程。
以下是程式碼:
import inspect,asyncio
#編寫一個add_route函式,用來註冊一個URL處理函式
def add_route(app,fn):
method = getattr(fn,'__method__',None)
path = getattr(fn,'__route__',None)
if method is None or path is None:
return ValueError('@get or @post not defined in %s.'%str(fn))
if not asyncio.iscoroutinefunction(fn) and not inspect.isgeneratorfunction(fn): #判斷是否為協程且生成器,不是使用isinstance
fn = asyncio.coroutine(fn)
logging.info('add route %s %s => %s(%s)'%(method,path,fn.__name__,','.join(inspect.signature(fn).parameters.keys())))
app.router.add_route(method,path,RequestHandler(app,fn))#別忘了RequestHandler的引數有兩個
通常add_route()
註冊會呼叫很多次,而為了框架使用者更加方便,可以編寫了一個可以批量註冊的函式,預期效果是:只需向這個函式提供要批量註冊函式的檔案路徑,新編寫的函式就會篩選,註冊檔案內所有符合註冊條件的函式。
程式碼如下:
#直接匯入檔案,批量註冊一個URL處理函式
def add_routes(app,module_name):
n = module_name.rfind('.')
if n == -1:
mod = __import__(module_name,globals(),locals())
else:
name = module_name[n+1:]
mod = getattr(__import__(module_name[:n],globals(),locals(),[name],0),name)#第一個引數為檔案路徑引數,不能摻夾函式名,類名
for attr in dir(mod):
if attr.startswith('_'):
continue
fn = getattr(mod,attr)
if callable(fn):
method = getattr(fn,'__method__',None)
path = getattr(fn,'__route__',None)
if path and method: #這裡要查詢path以及method是否存在而不是等待add_route函式查詢,因為那裡錯誤就要報錯了
add_route(app,fn)
然後新增靜態資料夾的路徑:
import os,logging
#新增靜態資料夾的路徑
def add_static(add):
path = os.path.join(os.path.dirname(os.path.abspath(__file__)),'static')#輸出當前資料夾中'static'的路徑
app.router.add_static('/static/',path)#prefix (str) – URL path prefix for handled static files
logging.info('add static %s => %s'%('/static/',path))
新增完靜態檔案還需要初始化jinja2模板:
from jinja2 import Environment, FileSystemLoader
from datetime import datetime
import json, time
import logging
#初始化jinja2,以便其他函式使用jinja2模板
def init_jinja2(app, **kw):
logging.info('init jinja2...')
options = dict(
autoescape = kw.get('autoescape', True),
block_start_string = kw.get('block_start_string', '{%'),
block_end_string = kw.get('block_end_string', '%}'),
variable_start_string = kw.get('variable_start_string', '{{'),
variable_end_string = kw.get('variable_end_string', '}}'),
auto_reload = kw.get('auto_reload', True)
)
path = kw.get('path', None)
if path is None:
path = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'templates')
logging.info('set jinja2 template path: %s' % path)
env = Environment(loader=FileSystemLoader(path), **options)
filters = kw.get('filters', None)
if filters is not None:
for name, f in filters.items():
env.filters[name] = f
app['__templating__'] = env
def datetime_filter(t):
delta = int(time.time() - t)
if delta < 60:
return u'1分鐘前'
if delta < 3600:
return u'%s分鐘前' % (delta // 60)
if delta < 86400:
return u'%s小時前' % (delta // 3600)
if delta < 604800:
return u'%s天前' % (delta // 86400)
dt = datetime.fromtimestamp(t)
return u'%s年%s月%s日' % (dt.year, dt.month, dt.day)
以上的datetime_filter
函式實質是一個攔截器,具體作用在可以看Day8。
3. 編寫middleware
一輪過後,如何將函式返回值轉化為web.response
物件呢?
這裡引入aiohttp
框架的web.Application()
中的middleware
引數。
middleware
是一種攔截器,一個URL在被某個函式處理前,可以經過一系列的middleware
的處理。一個middleware
可以改變URL的輸入、輸出,甚至可以決定不繼續處理而直接返回。middleware的用處就在於把通用的功能從每個URL處理函式中拿出來,集中放到一個地方。
在我看來,middleware
的感覺有點像裝飾器,這與上面編寫的RequestHandler
有點類似。
有官方文件可以知道,當建立web.appliction
的時候,可以設定middleware
引數,而middleware
的設定是通過建立一些middleware factory
(協程函式)。這些middleware factory
接受一個app例項,一個handler
兩個引數,並返回一個新的handler
。
例如,一個記錄URL日誌的logger
可以作為middle factory
簡單定義如下:
import logging
async def logger_factory(app,handler):#協程,兩個引數
async def logger_middleware(request):#協程,request作為引數
logging.info('Request: %s %s'%(request.method,request.path))#日誌
return await handler(request)#返回
return logger_middleware
接下來就編寫轉化得到response
物件的middleware factory
。
from aiohttp import web
import logging
import json
#函式返回值轉化為`web.response`物件
async def response_factory(app,handler):
async def response_middleware(request):
logging.info('Response handler...')
r = await handler(request)
if isinstance(r, web.StreamResponse):
return r
if isinstance(r, bytes):
resp = web.Response(body=r)
resp.content_type = 'application/octet-stream'
return resp
if isinstance(r,str):
if r.startswith('redirect:'): #重定向
return web.HTTPFound(r[9:]) #轉入別的網站
resp = web.Response(body=r.encode('utf-8'))
resp.content_type = 'text/html;charsest=utf-8'
return resp
if isinstance(r,dict):
template = r.get('__template__')
if template is None: #序列化JSON那章,傳遞資料
resp = web.Response(body=json.dumps(r, ensure_ascii=False, default=lambda o: o.__dict__).encode('utf-8')) #https://docs.python.org/2/library/json.html#basic-usage
return resp
else: #jinja2模板
resp = web.Response(body=app['__templating__'].get_template(template).render(**r).encode('utf-8'))
resp.content_type = 'text/html;charset=utf-8'
return resp
if isinstance(r, int) and r >= 100 and r < 600:
return web.Response(r)
if isinstance(r, tuple) and len(r) == 2:
t, m = r
if isinstance(t, int) and t >= 100 and t < 600:
return web.Response(t, str(m))
# default,錯誤
resp = web.Response(body=str(r).encode('utf-8'))
resp.content_type = 'text/plain;charset=utf-8'
return resp
return response_middleware
值得注意得是在參考廖老師的原始碼時,意外的發現了一個名叫data_factory
的函式,其中思維是我目前遠遠不能達到的,如果使用其作為middleware
引數,那麼定義RequestHandler
時就不用那麼麻煩咯,但不知道老師教程不使用的原因是什麼,這裡貼一位大神,使用data_factory
作為middleware
引數編寫的關於frame,關於factory程式碼。
4. 測試執行
最後,當然就要測試一下看能不能跑得動了,一下是程式碼:
import asyncio
from web_app.webframe import get,post
#編寫用於測試的URL處理函式
@get('/')
async def handler_url_blog(request):
body='<h1>Awesome</h1>'
return body
@get('/greeting')
async def handler_url_greeting(*,name,request):
body='<h1>Awesome: /greeting %s</h1>'%name
return body
編寫以上程式碼另存名為webframe_test_handler
放在web_app
資料夾上。再編寫以下程式碼用於生成頁面進行此時:
from aiohttp import web
import asyncio
from web_app.webframe import add_routes,add_static
from web_app.middleware_factories import init_jinja2,datetime_filter,logger_factory,response_factory
import logging; logging.basicConfig(level=logging.INFO)
#編寫web框架測試
async def init(loop):
app = web.Application(loop=loop,middlewares=[logger_factory,response_factory])
init_jinja2(app,filters=dict(datetime=datetime_filter),path = r"E:\learningpython\web_app\templates")#初始化Jinja2,這裡值得注意是設定檔案路徑的path引數
add_routes(app,'web_app.webframe_test_handler')
add_static(app)
srv = await loop.create_server(app.make_handler(),'127.0.0.1',9000)
logging.info('Server started at http://127.0.0.1:9000...')
return srv
loop = asyncio.get_event_loop()
loop.run_until_complete(init(loop))
loop.run_forever()
最後訪問一下http://127.0.0.1:9000
網頁,awesome~