造轮子是最好的一种学习方式,本文尝试从0开始造个Python Web框架的轮子,我称它为ToyWebF。
本文操作环境为:MacOS,文中涉及的命令,请根据自己的系统进行替换。
ToyWebF的简单特性:
- 1.支持多种不同形式的路由注册方式
- 2.支持静态HTML、CSS、JavaScript
- 3.支持自定义错误
- 4.支持中间件
下面我们来实现这些特性。
最简单的web服务
首先,我们需要安装gunicorn,回忆一下Flask框架,该框架有内置的Web服务器,但不稳定,所以上线时通常会替换成uWSGI或gunicorn,这里不搞这个内置Web服务,直接使用gunicorn。
我们创建新的目录与Python虚拟环境,在该虚拟环境中安装gunicorn
mkdir ToyWebF python3 -m venv venv # 创建虚拟环境 source venv/bin/activate #激活虚拟环境 pip install gunicorn 复制代码
在啥都没有的情况下,构建最简单的Web服务,在ToyWebF目录下,创建a与a文件,写入下面代码。
# a 文件 class API: def __call__(self, environ, start_response): response_body = b"Hello, World!" status = "200 OK" start_response(status, headers=[]) return iter([response_body]) # a 文件 from api import API app = API() 复制代码
运行gunicorn app:app访问,可以看见Hello, World!,但现在请求体中的参数在environ变量中,难以解析,我们返回的response也是bytes形式。
我们可以使用webob库,将environ中的数据转为Request对象,将需要返回的数据转为Response对象,处理起来更加直观方便,直接通过pip安装一下。
pip install webob 复制代码
然后修改一下API类的__call__方法,代码如下。
from webob import Request, Response class API(object): def wsgi_app(self, environ, start_response): """通过 webob 将请求的环境信息转为request对象""" request = Request(environ) response = (request) return response(environ, start_response) def __call__(self, environ, start_response): (environ, start_response) 复制代码
上述代码中,通过webob库的Request类将environ对象(请求的环境信息)转为容易处理的request,随后调用handle_request方法对request进行处理,处理的结果,通过response对象返回。
handle_request方法在ToyWebF中非常重要,它会匹配出某个路由对应的处理方法,然后调用该方法处理请求并将处理的结果返回,在解析handle_request前,需要先讨论路由注册实现,代码如下。
class API(object): def __init__(self): # url路由 = {} def route(self, path): # 添加路由的装饰器 def wrapper(handler): (path, handler) return handler return wrapper def add_route(self, path, handler): # 相同路径不可重复添加 assert path not in , "Such route already exists" [path] = handler 复制代码
其实就是将路由和方法存到字典中,可以通过route装饰器的形式将路由和方法关联,也可以通过add_route方法关联,在a中使用一下。
app = API() # 通过装饰器关联路由和方法 @a("/home") def home(request, response): re = "This is Home" # 路由中可以有变量,对应的方法也需要有对应的参数 @a("/hello/{name}") def hello(requst, response, name): re = f"Hello, {name}" # 可以装饰类 @a("/book") class BooksResource(object): def get(self, req, resp): re = "Books Page" def handler1(req, resp): re = "handler1" # 可以直接通过add_route方法添加 a("/handler1", handler1) 复制代码
因为url中可以存在变量,如@a("/hello/{name}"),所以在匹配时,需要进行解析,可以使用正则匹配的方式进行匹配,parse这个第三方库已经帮我们实现了相应的正则匹配逻辑,pip安装使用一下则可。
# pip install parse In [1]: from parse import parse # 匹配 In [2]: res = parse("/hello/{name}", "/hello/二两") In [3]: res.named Out[3]: {'name': '二两'} 复制代码
这里定义find_handler方法来实现对的遍历。
class API(object): def find_handler(self, request_path): # 遍历路由 for path, handler in .items(): # 正则匹配路由 parse_result = parse(path, request_path) if parse_result is not None: # 返回路由对应的方法和路由本身 return handler, return None, None 复制代码
了解了路由与方法关联的原理后,就可以实现handle_request方法,该方法主要的路径就是根据路由调度对应的方法,代码如下。
import inspect class API(object): def handle_request(self, request): """请求调度""" response = Response() handler, kwargs = ) try: if handler is not None: if in(handler): # 如果是类,则获取其中的方法 handler = getattr(handler(), reque(), None) if handler is None: # 类中该方法不存在,则该类不支持该请求类型 raise AttributeError("Method now allowed", reque) handler(request, response, **kwargs) else: # 返回默认错误 (response) except Exception as e: raise e return response 复制代码
在该方法中,首先实例化webob库的Response对象,然后通过方法获取此次请求路由对应的方法和对应的参数,比如。
@a("/hello/{name}") def hello(requst, response, name): re = f"Hello, {name}" 复制代码
它将返回hello方法对象和name参数,如果是/hello/二两,那么name就是二两。
因为route装饰器可能装饰器的类对象,比如。
# 可以装饰类 @a("/book") class BooksResource(object): def get(self, req, resp): re = "Books Page" 复制代码
此时方法返回的hanler就是个类,但我们希望调用的是类中的get、post、delete等方法,所以需要一个简单的判断逻辑,通过in方法判断handler如果是类对象,那么就通过getattr方法获取类对象实例的中对应的请求方法。
# 获取请求方法, reque() 可为 get、post、delete handler = getattr(handler(), reque(), None) 复制代码
如果类对象中没有该方法属性,则抛出该请求类型不被允许的错误,如果不是类对象或类对象中存在该方法属性,则直接调用则可。
此外,如果方法的路由并没有注册到中,即404的情况,定义了defalut_response方法返回其中内容,代码如下。
class API(object): def defalut_response(self, response): re = 404 re = "Not Found" 复制代码
如果handle_request方法中调度的过程出现问题,则直接raise将错误抛出。
至此,一个最简单的web服务就编写完成了。
支持静态文件
回顾Flask,Flask可以支持HTML、CSS、JavaScript等静态文件,利用模板语言,可以构建出简单但美观的Web应用,我们让TopWebF也支持这一功能,最终实现图中的网站,完美兼容静态文件。
Flask使用了jinja2作为其html模板引擎,ToyWebF同样使用jinja2,jinja2其实实现一种简单的DSL(领域内语言),让我们可以在HTML中通过特殊的语法改变HTML的结构,该项目非常值得研究学习。
首先pip install jinja2,然后就可以使用它了,在ToyWebF项目目录中创建templates目录,以该目录作为默认的HTML文件根目录,代码如下。
from jinja2 import Environment, FileSystemLoader class API(object): def __init__(self, templates_dir="templates"): # html文件夹 = Environment(loader=FileSystemLoader))) def template(self, template_name, context=None): """返回模板内容""" if context is None: context = {} return .get_template(template_name).render(**context) 复制代码
首先利用jinja2的FileSystemLoader类将file system中的某个文件夹作为loader,然后初始化Environment。
在使用的过程中(即调用template方法),通过get_template方法获得具体的某个模板并通过render方法将对应的内容传递给模板中的变量。
这里我们不写前端代码,直接去互联网中下载模板,这里下载了Bootstrap提供的免费模板,可以自行去下载,下载完后,你会获得index.html以及对应的css、jss、img等文件,将index.html移动到ToyWebF/templates中并简单修改了一下,添加一些变量。
<!-- Masthead Heading--> <h1 class="masthead-heading text-uppercase mb-0">{{ title }}</h1> <!-- Masthead Subheading--> <p class="masthead-subheading font-weight-light mb-0">你好呀-{{ name }}</p> 复制代码
然后在a文件中为index.html定义路由以及需要的参数。
@a("/index") def index(req, resp): template = a("index.html", context={"name": "二两", "title": "ToyWebF"}) # re需要bytes,template方法返回的是unicode string,所以需要编码 re = () 复制代码
至此html文件的支持就完成了,但此时的html无法正常载入css和js,导致页面布局非常丑陋且交互无法使用。
接着就让ToyWebF支持css、js,首先在ToyWebF目录下创建static文件夹用于存放css、js或img等静态文件,随后直接将前面下载的模板,其中的静态文件复制到static中则可。
通过whitenoise第三方库,可以通过简单的几行代码让web框架支持css和js,不需要依赖nginx等服务,首先pip install whitenoise,随后修改API类的__init__方法,代码如下。
class API(object): def __init__(self, templates_dir="templates", static_dir="static"): # html文件夹 = Environment(loader=FileSystemLoader))) # css、JavaScript文件夹 = WhiteNoise(, root=static_dir) 复制代码
其实就是通过WhiteNoise将方法包裹起来,在调用API的__call__方法时,直接调用。
class API(object): def __call__(self, environ, start_response): return (environ, start_response) 复制代码
此时,如果请求web服务获取css、js等静态资源,WhiteNoise会获取其内容并返回给client,它在背后会匹配静态资源在系统中对应的文件并将其读取返回。
至此,一开始的网页效果就实现好了。
自定义错误
web服务如果出现500时,默认会返回internal server error,这显得比较丑,为了让框架使用者可以自定义500时返回的错误,需要添加一些代码。
首先API初始化时,初始对象并定义对应的方法添加自定义的错误
class API(object): def __init__(self, templates_dir="templates", static_dir="static"): # 自定义错误 = None def add_exception_handler(self, exception_handler): # 添加自定义error handler = exception_handler 复制代码
在handler_request方法进行请求调度时,调度的方法执行逻辑时报500,此时不再默认将错误抛出,而是先判断是否有自定义错误处理。
class API(object): def handle_request(self, request): """请求调度""" try: # ...省略 except Exception as e: # 为空,才返回internal server error if is None: raise e else: # 自定义错误返回形式 (request, response, e) return response 复制代码
在a中,自定义错误返回方法,如下。
def custom_exception_handler(request, response, exception_cls): re = "Oops! Something went wrong." # 自定义错误 a(custom_exception_handler) 复制代码
custom_exception_handler方法只返回自定义的一段话,你完全可以替换成美观的template。
我们可以实验性定义一个路由来看效果。
@a("/error") def exception_throwing_handler(request, response): raise AssertionError("This handler should not be user") 复制代码
支持中间件
Web服务的中间件也可以理解成钩子,即在请求前可以对请求做一些处理或者返回Response前对Response做一下处理。
为了支持中间件,在TopWebF目录下创建middleware.py文件,在编写代码前,思考一下如何实现?
回顾一下现在请求的调度逻辑。
1.通过routes装饰器关联路由和方法 2.通过API.whitenoise处理 3.如果是请求API接口,那么会将参数传递给API.wsgi_app 4.API.wsgi_app最终会调用API.handle_request方法获取路由对应的方法并调用该方法执行相应的逻辑
如果希望在request前以及response后做相应的操作,那么其实就需要让逻辑在API.handle_request前后执行,看一下代码。
from webob import Request class Middleware(object): def __init__(self, app): = app # API类实例 def add(self, middleware_cls): # 实例化Middleware对象,包裹 = middleware_cls() def process_request(self, req): # request前要做的处理 pass def process_response(self, req, resp): # response后要做的处理 pass def handle_request(self, request): (request) response = .handle_request(request) (request, response) return response def __call__(self, environ, start_response): request = Request(environ) response = .handle_request(request) return response(environ, start_response) 复制代码
其中add方法会实例化Middleware对象,该对象会将当前的API类实例包裹起来。
Middleware.handle_request方法其实就是在.handle_request前调用方法处理request前的数据以及调用处理response后的数据,而核心的调度逻辑,依旧交由API.handle_request方法进行处理。
这里的代码可能会让人感到疑惑,__call__方法和handle_request方法中都有.handle_request(request),但其调用对象似乎不同?这个问题暂时放一下,先继续完善代码,然后再回来解释。
接着在a中为API创建middleware属性以及添加新中间件的方法。
class API(object): def __init__(self, templates_dir="templates", static_dir="static"): # 请求中间件,将api对象传入 = Middleware(self) def add_middleware(self, middleware_cls): # 添加中间件 .add(middleware_cls) 复制代码
随后,在a中,自定义一个简单的中间件,然后调用add_middleware方法将其添加。
class SimpleCustomMiddleware(Middleware): def process_request(self, req): print("处理request", req.url) def process_response(self, req, resp): print("处理response", req.url) a(SimpleCustomMiddleware) 复制代码
定义好中间件后,在请求调度时,就需要使用中间件,为了兼容静态文件的情况,需要对css、js、ing文件的请求路径做一下兼容,在其路径中加上/static前缀
<!--修改index.html中导入静态文件的路径,只需要简单的加上/static前缀则可。--> <!--修改css导入路径,加/static前缀,img与js的导入路径同样需要--> <link href="/static/c; rel="stylesheet" /> <img class="masthead-avatar mb-5" src="/static/assets/img; alt="" /> <script src="/static/j;></script> 复制代码
紧接着,修改API的__call__,兼容中间件和静态文件,代码如下。
class API(object): def __call__(self, environ, start_response): path_info = environ["PATH_INFO"] static = "/" + # 以 /static 开头 或 中间件为空 if (static) or not : # "/static; -> 只取 , /static开头只是用于判断 environ["PATH_INFO"] = path_info[len(static):] return (environ, start_response) return (environ, start_response) 复制代码
至此,中间件的逻辑就完成了。
但代码中依旧有疑惑,Middleware类中的__call__方法和handle_request方法其调用的到底是谁?
为了方便理解,这里一步步拆解。
如果没有添加新的中间件,那么请求的调度逻辑如下。
# 属性映射关系 API.middleware = Middleware API.middleware.app = API # 调度逻辑 API.__call__ -> middleware.__call__ -> .handle_request -> API.handle_request() 复制代码
在没有添加中间件的情况下,其实就是API本身,所以middleware.__call__中的.handle_request就是调用API.handle_request。
如果添加了新的中间件,如上述代码中添加了名为SimpleCustomMiddleware的中间件,此时的请求调度逻辑如下。
# 属性映射关系 API.middleware = Middleware API.middleware.app = API API.middleware.add(SimpleCustomMiddleware) API.middleware.app = SimpleCustomMiddleware API.middleware.a = api 相当于 API.middleware.Sim = api # 调度逻辑 API.__call__ -> middleware.__call__ -> .handle_request -> Sim() -> .handle_request -> API.handle_request() 复制代码
因为注册中间件时,Middleware.add方法替换了原始Middleware实例中的app对象,将其替换成了SimpleCustomMiddleware,而SimpleCustomMiddleware也有app对象,SimpleCustomMiddleware中的app对象,才是API类实例。
在请求调度的过程中,就会触发Middleware类的handle_request方法,该方法就会执行中间件相应的逻辑去处理request和response中的数据。
当然,你可以通过Middleware.add方法添加多个中间件,这就会构成栈式调用的效果,代码如下。
class SimpleCustomMiddleware(Middleware): def process_request(self, req): print("处理request", req.url) def process_response(self, req, resp): print("处理response", req.url) class SimpleCustomMiddleware2(Middleware): def process_request(self, req): print("处理request2", req.url) def process_response(self, req, resp): print("处理response2", req.url) a(SimpleCustomMiddleware) a(SimpleCustomMiddleware2) 复制代码
启动web服务后,其执行效果如下。
最后多说一句,小编是一名python开发工程师,这里有我自己整理了一套最新的python系统学习教程,包括从基础的python脚本到web开发、爬虫、数据分析、数据可视化、机器学习等。想要这些资料的可以关注小编,并在后台私信小编:“01”即可领取。