Session 在一个 Web 应用中经常用到,虽然随着近年来 REST API 的不断兴起,session 被用得较少了,但是,还是有非常多的地方用到,即使是 RESTAPI,后台管理用的系统常常也是离不开 session 作为登陆认证使用。本文将讲解 Flask 中 session 的使用,以及底层实现,还将讲述如何自定义 session 管理接口。


Flask Session 使用

在 Flask 中,session 的操作如同操作 cookie 一般简单,因为 session 的实现机理,所以不用担心不同用户不同客户端 session 混淆的问题,也不用但心同一用户多客户统一的问题。

下面,就先以一个简单的控制用户登陆的示例,讲解一下如何使用 session:

from flask import Flask, session, redirect, url_for, escape, request

app = Flask(__name__)

@app.route('/')
def index():
    if 'username' in session:
        return 'Logged in as %s' % escape(session['username'])
    return 'You are not logged in'

@app.route('/login', methods=['GET', 'POST'])
def login():
    if request.method == 'POST':
        session['username'] = request.form['username']
        return redirect(url_for('index'))
    return '''
        <form action="" method="post">
            <p><input type=text name=username>
            <p><input type=submit value=Login>
        </form>
    '''

@app.route('/logout')
def logout():
    # remove the username from the session if it's there
    session.pop('username', None)
    return redirect(url_for('index'))

# set the secret key.  keep this really secret:
app.secret_key = 'A0Zr98j/3yX R~XHH!jmN]LWX/,?RT'


这是一个官方的例子,这个例子中有几个地方值得探讨,分别是:

app.securet_key

flask 规定如果想使用默认的 session,那么一定要设置 securet_key,而且最好是不容易猜出来的,官网有给出生成比较安全的 securet_key 的方式

How to generate good secret keys
The problem with random is that it's hard to judge what is truly random. And a secret key should be as random as possible. Your operating system has ways to generate pretty random stuff based on a cryptographic random generator which can be used to get such a key:


>>> import os
>>> os.urandom(24)
'\xfd{H\xe5<\x95\xf9\xe3\x96.5\xd1\x01O<!\xd5\xa2\xa0\x9fR"\xa1\xa8'


然后就是复制/黏贴为 securet_key 了。

from flask import session

和其他 request/current_app 一样,直接从 flask 中 import 进来就好了,后面会提到,其实这不是真实的 session 对象,这其实也是一个代理。

session['username']

这是一个典型的使用 session 的例子,使用 session 其实就跟使用 dict 一样,直接使用键就可以访问了,但是,前提是你必须在 session 中设置了值。

session['username'] = request.form['username']

这是一个典型的设置 session 值的例子,这里从请求中将 username 提取出来,然后设置给 session,这样,下次就可以直接从 session 中获取到键为 "username" 的值了。

session.pop('username', None)

这个例子是清除 session 的作用,这里将 "username" 从 session 中清除掉,这样以后就不能再使用这个 session 的键了。


上面就是一些简单使用 session 的例子,接着就讲一下如何替换 session 的实现。

在 Flask 中,默认的创建 session 和保存 session 的操作都是通过一个叫做 session_interface 的接口实现的,这个 session_interface 最起码需要实现两个方法,分别是:

所以,如果我们要将 session 保存在 redis,我们可以这样做:

创建 RedisSessionInterface

import pickle
from datetime import timedelta
from uuid import uuid4
from redis import Redis
from werkzeug.datastructures import CallbackDict
from flask.sessions import SessionInterface, SessionMixin


class RedisSession(CallbackDict, SessionMixin):

    def __init__(self, initial=None, sid=None, new=False):
        def on_update(self):
            self.modified = True
        CallbackDict.__init__(self, initial, on_update)
        self.sid = sid
        self.new = new
        self.modified = False


class RedisSessionInterface(SessionInterface):
    serializer = pickle
    session_class = RedisSession

    def __init__(self, redis=None, prefix='session:'):
        if redis is None:
            redis = Redis()
        self.redis = redis
        self.prefix = prefix

    def generate_sid(self):
        return str(uuid4())

    def get_redis_expiration_time(self, app, session):
        if session.permanent:
            return app.permanent_session_lifetime
        return timedelta(days=1)

    def open_session(self, app, request):
        sid = request.cookies.get(app.session_cookie_name)
        if not sid:
            sid = self.generate_sid()
            return self.session_class(sid=sid, new=True)
        val = self.redis.get(self.prefix + sid)
        if val is not None:
            data = self.serializer.loads(val)
            return self.session_class(data, sid=sid)
        return self.session_class(sid=sid, new=True)

    def save_session(self, app, session, response):
        domain = self.get_cookie_domain(app)
        if not session:
            self.redis.delete(self.prefix + session.sid)
            if session.modified:
                response.delete_cookie(app.session_cookie_name,
                                       domain=domain)
            return
        redis_exp = self.get_redis_expiration_time(app, session)
        cookie_exp = self.get_expiration_time(app, session)
        val = self.serializer.dumps(dict(session))
        self.redis.setex(self.prefix + session.sid, val,
                         int(redis_exp.total_seconds()))
        response.set_cookie(app.session_cookie_name, session.sid,
                            expires=cookie_exp, httponly=True,
                            domain=domain)


这里主要就是重写了 open_session 和 save_session 两个方法,其他方法和类都是辅助用的,当然, 既然 Session 的存放位置改变了,那么 Session Class 也需要重新定义一下,重要的一点:别忘了 Session Class 要继承 SessionMixin

接着替换掉 flask 的 session_interface 即可:

app = Flask(__name__)
app.session_interface = RedisSessionInterface()


就这样,当我们和平时一样操作 session 的时候,其实内部用的就是我们自定义的 session_interface 了。


替换 session 其实也就这么简单,接下来看看 Flask 是如何利用 session_interface 来操作 session 的。

首先看到 session 的定义,位置在:

flask/globals.py

line 0060: session = LocalProxy(partial(_lookup_req_object, 'session'))


可以看到 session 其实也是一个代理,其实是 partial 做了一层封装,那么转化一下就是:

session['name'] = _lookup_req_object('session')['name']


_lookup_req_object 这个函数我们已经见过好多次了,其实此时就是需要关注的还是老问题,就是 request

何时入栈

我们还是从代码的入口开始找起,flask 的路口一般是 wsgi_app,所以我们还是想找到:

flask/app.py

... ...

line 1916: def request_context(self, environ):
... ...
line 1944:     return RequestContext(self, environ)
... ... 
line 1958: def wsgi_app(self, environ, start_response):
... ...
line 1983:     ctx = self.request_context(environ)


首先是看 wsgi_app,这里有一个创建请求上下文的地方,然后调用的是 line: 1916 中的 request_context 方法,而 request_context 方法里面是直接创建了一个 RequestContext 变量,那我们就看下这个变量的创建过程:

flask/ctx.py

line 0207: class RequestContext(object):
... ...
line 0237:     def __init__(self, app, environ, request=None):
... ...
line 0244:         self.session = None
... ...
line 0297:     def push(self):
... ...
line 0332:         self.session = self.app.open_session(self.request)
line 0333:         if self.session is None:
line 0334:             self.session = self.app.make_null_session()


可以发现 session 在这里就被创建了,并且自然得放进了 请求上下文 中,这样的话,我们也就是可以通过访问 flask.session 来直接调用了。但是,我们可以发现 session 对象创建的方法却不是我们预想中的使用 session_interface,而是调用了 app 的方法,那我们就看一下 app 中的这些方法是怎么实现的:

line 0906: def open_session(self, request):
... ...
line 0914:     return self.session_interface.open_session(self, request)
... ...
line 0916: def save_session(self, session, response):
    ... ...
line 0926:     return self.session_interface.save_session(self, session, response)
... ...
line 0928: def make_null_session(self):
... ...
line 0934:     return self.session_interface.make_null_session(self)


可以发现,其实这里就是简单得调用了我们 session_interface 的 open_session 和 save_session 的方法。

何时出栈

出栈的问题还是和 request 一致的,就是看 request 何时出栈的,这个在之前已经解析过了,这里就不累赘了。


整个 Flask 的 Session 的流程就是这样的,那么下面我们就来关注一下 Flask 默认的 Session 的实现是怎样的。正如前面提到的,我们主要关注两个函数: open_session 和 save_session,但是,我们上面看代码的时候还发现了一个 make_null_session,那就顺便看一下 make_null_session ,总共是三个。

flask/sessions.py

line 0290: class SecureCookieSessionInterface(SessionInterface):
line 0319:     def open_session(self, app, request):
line 0320:         s = self.get_signing_serializer(app)
line 0321:         if s is None:
line 0322:             return None
line 0323:         val = request.cookies.get(app.session_cookie_name)
line 0324:         if not val:
line 0325:             return self.session_class()
line 0326:         max_age = total_seconds(app.permanent_session_lifetime)
line 0327:         try:
line 0328:             data = s.loads(val, max_age=max_age)
line 0329:             return self.session_class(data)
line 0330:         except BadSignature:
line 0331:             return self.session_class()


首先,这里用到了一个 signing_serializer,这个是用的 itsdanguagers 的一个 URLSafeTimedSerializer 类,这个类可以签名系列化一个对象,并且反系列化回来,而这个系列化依赖于 flask 的 secret_key,这也就是为什么要 session 支持需要设置 securet_key 的原因。

line 323 是从 cookie 中将加密后的 session 对象取出来,然后在 line 328 进行解密,得到数据,然后用这个数据构建 session 对象,然后就放进 request 中使用了。

接着概要得开一下 save_session 的内容:

line 0333: def save_session(self, app, session, response):
line 0334:     domain = self.get_cookie_domain(app)
line 0335:     path = self.get_cookie_path(app)
... ...
line 0340:     if not session:
line 0341:         if session.modified:
line 0342:             response.delete_cookie(app.session_cookie_name,
line 0343:                                    domain=domain, path=path)
line 0344:         return
... ...
line 0353:     if not self.should_set_cookie(app, session):
line 0354:         return
line 0355: 
line 0356:     httponly = self.get_cookie_httponly(app)
line 0357:     secure = self.get_cookie_secure(app)
line 0358:     expires = self.get_expiration_time(app, session)
line 0359:     val = self.get_signing_serializer(app).dumps(dict(session))
line 0360:     response.set_cookie(app.session_cookie_name, val,
line 0361:                         expires=expires, httponly=httponly,
line 0362:                         domain=domain, path=path, secure=secure)


save_session 的工作就更简单了,前面都做了一个变量的获取操作,关键的在于 line 359,这里对session 的内容进行一个加密,然后在 line 360 将加密后的 session 保存到cookie 中,返回给客户端,等待客户端下次请求传递回来。


扩展思路

如何实现多子域名下多应用的 session 同步

对于一个企业级应用,我们可能会有好多个系统,每个系统都有一个独立的子域名,例如一个电子商务系统,可能我们的商品系统、购物车、订单系统都是不同的子域名,假设是这样:

商品系统:item.mdpress.com
购物车:  cart.mdpress.com
订单系统:order.mdpress.com


那我们为了给用户一个良好的体验,肯定是希望用户如果在一个子系统中登陆了,在其他子系统也能共享登陆状态的。那么基于这个需求,该如何实现呢?

这个问题有一点点复杂,但是,总的思路来说使用默认的 session 应该是实现不了的,但是,如果使用我们第二段中提到的 RedisSessionInterface 是很容易实现的,这个就不细说了,如果有兴趣,可以留言参与讨论。