简单web框架

1.WSGI

Python 的很多知名的Web框架(例如Flask, Django)实际上都是遵从了这个wsgi模型。


WSGI Server作用

  • 监听HTTP服务端口(TCPServer, 80端口)
  • 接收浏览器端HTTP请求并解析封装成environ环境数据
  • 负责调用应用程序,将environ和start_response方法传入
  • 将应用程序响应的正文封装成HTTP响应报文返回浏览器端

WSGI APP要求

  • 应用程序是一个可调用对象(函数、类都可以)
  • 这个可调用对象应该接收两个参数(environ, start_response)
  • 调用start_response
  • 最后必须返回一个可迭代对象

2.简单Web框架实现

首先说明这篇文章的目的是尽可能用最少的库代码去搭建简易的web框架,是笔者在探索web框架背后原理时自己实现的demo,有什么不对之处,欢迎指正。

让我们从一个简单的例子开始。这个例子是将environ里的key, value打印出来,后面在编写Request的时候,要知道这里面都有什么内容。这个例子比较典型了,很多地方都有实现,能帮助我们看看最简易模型。

# coding=utf-8

from werkzeug.serving import run_simple


def application(environ,start_response):
    from io import StringIO
    stdout = StringIO()
    print("Hello world!", file=stdout)
    print(file=stdout)
    for k, v in sorted(environ.items()):
        print(k, '=', repr(v), file=stdout)
    start_response("200 OK", [('Content-Type','text/plain; charset=utf-8')])
    return [stdout.getvalue().encode("utf-8")]


if __name__ == '__main__':
    run_simple("127.0.0.1", 8000, application)

让我们访问http://127.0.0.1:8000?user=admin&music=Rock看看返回什么。记住几个非常有用的字段,如REQUEST_METHOD,PATH_INFO,QUERY_STRING,解析这些字段可以知道用户请求的资源、路由是什么。

因此一个简单的Request类诞生了。

class Request:
    def __init__(self, environ):
        self.method = environ.get("REQUEST_METHOD", "GET")
        self.path = environ.get("PATH_INFO", "")
        self.query = environ.get("QUERY_STRING", "")
        self.args = self.parse(self.query)

    @staticmethod
    def parse(data: str, sep="&"):
        if not data:
            return {}
        return dict(map(lambda arg: arg.partition("=")[::2], [arg for arg in data.split(sep)]))

另外,让我们看看响应类该如何写,很简单,它只需要在返回内容前调用start_response, 第一个参数是状态字(包含状态码和状态消息),第二个参数是headers内容,headers是一个列表,列表里面是一个元组。返回的正文内容是一个列表,列表里的内容是bytes。

CODE_MAP = {
    200: "OK",
    404: "Not Found",
}

UNKNOWN = "Unknown"


class Response:
    default_charset = "utf-8"
    default_status = 200
    default_mimetype = "text/plain"

    def __init__(
            self,
            response=None,
            status=None,
            headers=None,
            mimetype=None,
            content_type=None,
    ):
        if status is None:
            status = self.default_status
        if isinstance(status, int):
            self.status = "{} {}".format(status, CODE_MAP.get(status, UNKNOWN))
        else:
            self.status = status
        if mimetype is None:
            mimetype = self.default_mimetype
        if content_type is None:
            content_type = ";".join([mimetype, "charset={}".format(self.default_charset)])
        if headers is not None:
            self.headers = headers
        else:
            self.headers = {}
        if "Content-Type" not in self.headers:
            self.headers["Content-Type"] = content_type

        if response is None:
            self.response = []
        elif isinstance(response, (bytes, bytearray, str)):
            self.response = self.set_data(response)
        else:
            self.response = self.set_data(str(response))

    def __call__(self, environ, start_response):
        start_response(self.status, self._headers)
        return self.response

    @property
    def _headers(self):
        return [(k, v) for k, v in self.headers.items()]

    def set_data(self, text):
        if isinstance(text, str):
            value = text.encode(self.default_charset)
        else:
            value = bytes(text)
        return [value]

注意response设置成了可调用(定义了__call__函数),先调用start_response, 再返回响应正文。至此,application函数可以修改为:

def application(environ,start_response):
    request = Request(environ)
    msg = "I am {user}, I love {music} music".format(**request.args)
    return Response(msg)(environ, start_response)

请还是用http://127.0.0.1:8000?user=admin&music=Rock访问,不然会报错,效果如下。

Web框架一个很重要的功能就是路由,那我们该怎么去实现呢,我们知道flask框架所采用的是装饰器来实现路由,这里也想用装饰器来实现, 而且从外观上想和flask保持一致,其他功能暂不实现,务求简单。

NOT_FOUND = Response("Not Found, 404", 404)
NOT_AUTH = Response("No Auth, please connect to administrator", 403)


class WebFramework:
    default_host = "127.0.0.1"
    default_port = 8000

    def __init__(self, name=None):
        self.name = name if name else __name__
        self.map = {}

    def application(self, environ, start_response):
        request = Request(environ)
        method, path = request.method, request.path
        if not (method in self.map and path in self.map[method]):
            return NOT_FOUND(environ, start_response)
        response = self.map[method][path](request)
        if isinstance(response, Response):
            return response(environ, start_response)
        return Response(response)(environ, start_response)

    def run(self, host=None, port=None, debug=False):
        if host is None:
            host = self.default_host
        if port is None:
            port = self.default_port
        debug = bool(debug)
        run_simple(host, port, self.application, use_debugger=debug)

    def route(self, path, methods=("GET",)):
        def decorator(func):
            for method in methods:
                self.map.setdefault(method, {})[path] = func
            return func
        return decorator


app = WebFramework()


@app.route("/")
def index(request):
    return "Hello, I am {user}, I love {music} music".format(**request.args)


if __name__ == '__main__':
    app.run()

可以看到,这样就实现了路由功能。

在实际web应用中,常用的功能是登录,由于http是无状态的,而登录后跳转到其它页面,服务器还能知道我是哪个用户么,于是出现了会话管理,未完待续。。。

3.会话管理

  • HTTP 是无状态的协议

每个请求都是完全独立的,服务端无法确认当前访问者的身份信息,无法分辨上一次的请求发送者和这一次请求的发送者是不是同一个客户端。服务器与浏览器为了进行会话跟踪,就必须主动的去维护一个状态,这个状态用于告诉服务端前后两个请求是否来自同一个浏览器。这个状态的实现方式可以通过 cookie 、session或token来实现(请参考Cookie、Session、Token、JWT 傻傻分不清楚可不行)

  • Cookie
    先让我们从最简单的cookie开始,cookie的解析实际上是从HTTP_COOKIE字段获取而来, 为了实现登录功能,我们还特意实现了解析form表单方法。
class Request:
    def __init__(self, environ):
        self.method = environ.get("REQUEST_METHOD", "GET")
        self.path = environ.get("PATH_INFO", "")
        self.query = environ.get("QUERY_STRING", "")
        self.args = self.parse(self.query)
        self.form = self.parse_form(environ)
        self.cookie = self.parse(environ.get('HTTP_COOKIE'))

    @staticmethod
    def parse(data: str, sep="&"):
        if not data:
            return {}
        return dict(map(lambda arg: arg.partition("=")[::2], [arg for arg in data.split(sep)]))

    def parse_form(self, environ):
        buffer_size = int(environ.get("CONTENT_LENGTH", 0))
        data = environ['wsgi.input'].read(buffer_size).decode("utf-8") if buffer_size else ""
        return self.parse(data)

除了解析,就是生成cookie, 我们可以在Response里实现。

from werkzeug.http import dump_cookie

class Response:
    """省略之前代码"""
    def set_cookie(self, key, value, max_age=None, expires=None):
        self.headers["Set-Cookie"] = dump_cookie(key, value, max_age=max_age, expires=expires)

    def delete_cookie(self, key):
        self.set_cookie(key, "", max_age=0, expires=0)

另外让我们一起写写简单的视图页面,并为了验证登录,创建了一个假的数据库字典。主页视图需要验证cookie字段是否有username字段,如果没有,则会跳转到登录页面。登录页面判断请求方式,如果是POST请求,验证用户输入的用户名和密码,如果验证通过,保存Cookie并跳转到首页,首页再次访问的时候已经有了Cookie, 验证ok就可以显示欢迎页。登录页面如果是GET请求,则展示登录框页面,让用户输入用户名密码。

fake_db = {
    "users": {
        "admin": "admin",
        "huge": "huge",
    }
}

def redirect(location, code=302, response=None):
    if response is None:
        response = Response("redirect page", status=code)
        if not isinstance(location, str):
            raise ValueError
    response.headers["Location"] = location
    return response


@app.route("/")
def index(request):
    if request.cookie.get("username"):
        return show_index(request)
    return redirect("/login")


@app.route("/login", methods=["POST", "GET"])
def login(request):
    if request.method == "POST":
        return do_login(request)
    return show_login()


def do_login(request):
    username = request.form.get("username")
    password = request.form.get("password")
    if username in fake_db["users"] and password == fake_db["users"][username]:
        response = Response(status=302)
        response.set_cookie("username", username)
        return redirect("/", response=response)
    return NOT_AUTH


def show_login():
    return Response("""
    <!DOCTYPE html>
    <h1>登录</h1>
    <form method=post>
    <input type=text name=username>
    <input type=text name=password>
    <input type=submit value=提交>
    </form>
    """, mimetype="text/html")


@app.route("/logout")
def do_logout(request):
    response = Response("logout success")
    response.delete_cookie("username")
    return response


def show_index(request):
    username = request.cookie.get("username")
    return Response("""
    <!DOCTYPE html>
    <h1>主页</h1>
    <h1>你好,{user}</h1>
    <a href="http://127.0.0.1:8000/logout"><input type="button" value=退出></a>
    """.format(user=username), mimetype="text/html")

注意这里实现了一个简单的页面跳转函数,其实原理也很简单,就是将返回响应的headers的Location字段设置值为跳转路由,响应状态码设置为302。

让我们看看这个Cookie实现的效果,再进行简单的登录后,页面跳回首页欢迎页,重复刷新页面,已经不需要验证登录了。可是从这个实现方式,我们也看出了Cookie的弊病,那就是信息存储在浏览器端,数据透明,容易被伪造。

  • Session
    Session一般是配合Cookie一起使用,服务器响应浏览器端的请求,并返回唯一标识信息 sessionid 给浏览器,浏览器把返回的 sessionid 存储到 cookie 中,同时 cookie 记录 sessionid 属于哪个域名,当用户第二次访问服务器的时候,请求自动判断此域名下是否存在 cookie 信息,如果存在就将 cookie 信息发送给服务端,服务端会从 cookie 中获取 sessionid,再根据 sessionid 查找对应的 session 信息,如果找到 session 证明用户已经登录,可以执行后面操作,如果没有找到,说明用户没有登录或者失败。
最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 216,692评论 6 501
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 92,482评论 3 392
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 162,995评论 0 353
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 58,223评论 1 292
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 67,245评论 6 388
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 51,208评论 1 299
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 40,091评论 3 418
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 38,929评论 0 274
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 45,346评论 1 311
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 37,570评论 2 333
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 39,739评论 1 348
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 35,437评论 5 344
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 41,037评论 3 326
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 31,677评论 0 22
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 32,833评论 1 269
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 47,760评论 2 369
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 44,647评论 2 354

推荐阅读更多精彩内容