zzxworld

Sanic 学习(一) - 开发一个 Session 扩展

Sanic 是 Python 编程语言环境下的一个高性能异步 Web 开发框架,也是我一直想要学习和掌握的 Web 开发框架。只是待在 Laravel 的舒适圈久了,面对这种需要自己完善补充各种基础功能的「微框架」,缺乏用起来的行动力。今天忽然心血来潮想要再次体验一下 Sanic,趁着这股热乎劲儿,我决定写一个 Session 扩展练练手。

是的,你没看错。作为一个 Web 开发框架,Sanic 连 Session 功能都没提供,就是如此「简洁」。它有一个第三方的 Session 库,但这个库已经两年多没有更新,估计作者已经放弃了维护。这是选择用户热度不高的框架所要承担的代价,很多在其他框架上看起来本就该有的功能,到了这些冷门框架上就不得不自己来「造轮子」。所幸我目前只是抱着学习的目的,没有进度要求,所以压力不大。

在开始写这个扩展前,需要先了解一下 Sanic 框架提供的相关基础功能和 Session 处理的相关流程。有 Web 开发基础的朋友应该都了解,Session 无非就是用来识别并隔离当前访问用户的信息。这个识别的基础离不开 Cookie。所以从流程上来看,主要就是以下步骤:

  1. 浏览器携带 Cookie 发起请求。
  2. 框架接收到请求后从请求的 Cookie 中寻找设定的 Session ID。
  3. 如果找到了 Session ID,就从服务器存储的 Session 中找对应的数据,并取出来运行相应的程序逻辑。
  4. 如果没找到,就生成一个新的 Session ID,并运行相应的程序逻辑。最后返回 Cookie 信息给浏览器。

根据以上步骤,开始写代码。

from sanic import Sanic, redirect
from sanic.response import html

app = Sanic('zzxworld')

@app.get('/')
async def home(request):
    count = request.ctx.session.get('count')
    count = 0 if count is None else count + 1

    request.ctx.session.set('count', count)

    return html('<h1>Session Demo</h1>'
                '<div><code>Count: '+str(count)+'</code></div>'
                '<p><a href="/reset">reset</a></p>')

@app.get('/reset')
async def logout(request):
    request.ctx.session.remove()
    return redirect('/')

使用 sanic app 命令运行项目,然后在浏览器中访问 http://localhost:8000,不出意外的话应该会出现一个错误页面。因为上面的代码目前还只是「伪代码」,request.ctx 后的 session 对象目前还不存在,也是接下来要完成的。不过从以上代码可以看出我最终要实现的目的:

  1. 首次访问这个程序时,页面的 Count 后会显示 0。
  2. 每刷新一次页面,Count 后的存储在 Session 中的数字会加 1。
  3. 点击 reset 连接会跳转到 /reset 页面,此页面负责清空 Session,然后再返回到主页面。此时 Count 后的数字再次归 0。

有了目标后,正式开始扩展开发。通过查阅 Sanic 的自定义扩展文档,了解到可以通过继承 sanic_ext 包中的 Extension 来添加扩展对象。其中 name 属性和 startup 方法是必须的。照猫画虎写一个:

from sanic_ext import Extension
from time import time
from datetime import datetime
import uuid

class Session(Extension):
    """zzxworld 的 Session 扩展"""
    name = 'zzxSession'

    _cookieName = 'sessid'  # Cookie 中的 Session ID 命名
    _store = None  # Session 存储对象

    def startup(self, bootstrap):
        """扩展入口"""
        self._store = MemorySessionStore()

        # 注册请求时的 session 绑定操作
        self.app.request_middleware.appendleft(self.startSession)
        # 注册请求结束后的 session 保存操作
        self.app.response_middleware.append(self.saveSession)

    def startSession(self, request):
        """给每个请求附加 session 操作对象"""
        request.ctx.session = SessionItem(
                self._store,
                request.cookies.get(self._cookieName))

    def saveSession(self, request, response):
        """在每个请求结束时保存 session 内容"""
        lifeDatetime = None  # Session 和相关 Cookie 的生命周期

        # 获取 Cookie 中的 Session ID
        sessionId = request.cookies.get(self._cookieName)
        if sessionId is None:
            # 没有找到 Session ID 时初始化新的 Cookie
            sessionId = uuid.uuid4().hex  # 随机生成一串唯一字符作为 Session ID
            lifeSeconds = 3600  # 默认 1 小时有效期
            lifeDatetime = datetime.fromtimestamp(time()+lifeSeconds)
            response.add_cookie(self._cookieName, sessionId,
                                max_age = lifeSeconds,
                                expires = lifeDatetime,
                                secure = False)

        # 保存当前请求中的 Session 数据
        self._store.save(sessionId, request.ctx.session.get(), lifeDatetime)

以上代码关键部分都有注释,就不再赘述逻辑了。最后需要使用 sanic_ext 中的 Extend 注册扩展:

from sanic_ext import Extend

Extend.register(Session)

目前的 Session 扩展,注册了依然还是无法使用。注意看代码就会发现,其中还有两个对象没有完成,一个是 MemorySessionStore,这个用来作为全局的 session 内容存储器。根据存储方式的区别,可以分别创建不同的存储器。比如想要把 session 存储在 Redis 中,就可以创建一个 RedisSessionStore 对象。另外一个需要完成的对象是 SessionItem,它用来保存每次请求时的 session 操作,并提供一些 session 的操作接口。让我们先来完成 MemorySessionStore 对象:

class MemorySessionStore():
    """使用内存的 Session 存储器"""
    _data = {}

    def get(self, sessionId):
        """获取指定 Session ID 的内容"""
        if sessionId in self._data:
            return self._data[sessionId]['value']
        return {}

    def save(self, sessionId, sessionData, lifeDatetime=None):
        """保存指定 Session ID 的内容"""
        if sessionId not in self._data:
            self._data[sessionId] = {}

        self._data[sessionId]['value'] = sessionData

        if lifeDatetime is not None:
            self._data[sessionId]['life_timestamp'] = lifeDatetime.timestamp()

最后是 SessionItem 对象:

class SessionItem():
    """基于每个请求的 Session 操作对象"""
    _data = {}

    def __init__(self, store, sessionId):
        """初始化 session 数据"""
        self._data = store.get(sessionId)

    def get(self, name = None):
        """获取指定名称或所有 session"""
        if name:
            return self._data[name] if name in self._data else None
        else:
            return self._data

    def set(self, name, value):
        """设置指定名称的 sesion"""
        self._data[name] = value

    def remove(self, name = None):
        """删除指定名称或清空 session"""
        if name:
            del self._data[name]
        else:
            self._data = {}

把以上代码都组织到同一个文件里,然后运行 sanic 命令,打开浏览器测试一下效果。不出意外的话,每次刷新页面都会看到 Count 后的数字会加 1。同时打开一个新的浏览器试试,两边的结果应该互不干扰。

这个用于 Sanic 的 session 扩展至此算是基本满足了需求。如果要在实际项目中使用还需要进一步完善。比如放在 MemorySessionStore 中的 session 还需要一个清除过期数据的策略。更好的方式是用专业的 K/V 数据库来管理,比如 Redis。另外在设置 Cookie 的部分,参数都是「写死」的,最好能通过外部配置的方式来调整相关参数。不过本文的目的只是尝试体验 Sanic 的扩展开发流程,就先止步于此吧。