引言
本系列结合我最近基于 flask 写的 myarxiv 项目,讨论一些网站开发宏观和微观问题。宏观的就是对网站架构设计的理解和我现在践行的一套可能和主流略有出入的方法论。微观的就是 flask 周边,从消息队列管理的 celery 到数据库 orm 的 sqlalchemy 等等大大小小的坑及相应解决方案的简单总结。
对于 flask 这种“轻量级”的框架(官方都称自己是 microframework),自然有人喜欢有人觉得一般1。很多人喜欢 django 那种开箱即用自带电池的全家桶,然而我是用连 web framework 都没有的老版 aiohttp 写过网站的,所以对于我来说 flask 已经是太好用了,该有的功能一个不少,插件之间的互相结合和魔改也比较顺手(虽然也偶尔有坑)。相反,我不喜欢所有功能和实现都限制的很死的框架,写起来虽然省心,但自由度太小了。需要有一个比较奇葩的问题的解决方案时,重量级框架由于更强的耦合,也往往比轻量级的框架解决起来更丑,更痛苦。因此,通过这些天的开发经验,感觉 flask 各方面算是恰到好处,都比较合我的心意。从学习的角度讲,用 flask 这种框架,需要不断的关联各部分和组合插件,对于网站搭建和 app 运行时的行为认识更加底层,也更考验开发者的架构和连接能力。flask 唯一的缺点,可能是生产环境初始开发和搭建起整个框架需要一定的时间成本,不过诸如 cookiecutter-flask 这种模版化快速生成网站项目原型的项目,可以弥补这一不足。
本篇内容并不涉及 flask 及相关库的基础教学,如果想要系统学习 flask,建议直接阅读 flask 的官方文档。flask 文档质量在开源社区项目中可以说很优秀了。如果想进一步了解使用 flask 实现基本功能的实践,可以阅读 Miguel 的系列教程博客2, 这一系列教程讲的非常好,除了设计的网站架构是传统的形式而非典型的前后端分离以外。
抽象元素的结构组织
最流行的应用开发架构无疑是 MVC,包括现在演化出的所谓的前后端分离,从以前的后端写路由传对象渲染模版,到现在的后端只写 API,看前端表演。结合 MVC 和这种前后端分离的实践,我对于网站架构的一些思考总结成的概念图如下。(请注意,这些思考中部分和现行的共识略有出入,但我觉得自己的这套想法开发和维护起来更舒服)。
以上概念图明显脱胎于最原始的 MVC 架构图。图中的 router 就对应了原始的 controller。图中各实体之间的箭头,表示了可以主动发起通讯的方向,而没有显示箭头的指向,是不允许主动通讯的。图中的虚线标记了网站前端和后端开发的分界线,而各抽象实体间的距离表明了其间的关联程度。
更具体的讲,View 部分就对应了前端的 html 和 css 部分,负责最终内容的显示,这一部分在项目中可以通过 Vue component 的形式组织和实现。ViewModel 则来自 Model View ViewModel 的概念,在项目中对应了 javascript 部分定义的 Vue 对象。该对象通过 v-text, v-bind
等形式直接控制了 View 部分内容的显示,这对应了从 ViewModel 到 View 的箭头。另一方面 Vue 也可以通过 v-model
的方式(本质上是 v-bind
加事件截获实现的双向绑定),来实现从 View 到 ViewModel 的主动数据通讯。这一部分对应的情景主要是用户进行的 View 界面 form 输入,这也是用户和整个 Web app 互动的唯一入口了。考虑到 v-model
主要用于 form 且几乎是双向绑定的唯一选择,因此这条信道选择了较细的箭头来表示。
如果用户直接在浏览器地址栏输入 url,则通过后端(其实也可以放在前端实现)的 Router 部分,来实现导流。这一部分项目中可以通过 flask 的 router 实现。需要强调的是,在这种架构中,router 薄的令人发指,几乎只有一句 return render_template()
就结束了,其真的唯一的作用就是路由,甚至几乎不传入模板任何数据。这一点从 router 和 model 完全没有任何通讯也可以看出。因此当用户输入 url 时,router 差不多只负责简单的返回对应的空网页。网页中内容的渲染,则有 ViewModel 负责。ViewModel 在页面加载后,通过 created
的 hook,向后端发送 ajax 请求,获取对应的数据,Vue 实例更新这些数据后,View 的网页被重新渲染,从而显示出信息。
ajax 请求背后对应的就是所谓的 API,其实这一部分也是用 flask 的 router 实现的,只不过这部分的业务逻辑重一些,每个函数行数多一些。要实现从请求内容合法性检查,到数据库增删改查,到返回结果的全过程。而在 API 对应的设计上,我的理解和传统的 RESTFUL API 的规范略有出入。因为 http 协议原生的,无论是 GET 对应的 param,还是 POST 对应的 form body,实在是太弱了,完全无法表现结构化数据,比如字典数组等的嵌套等。对于复杂的 API 请求,使用这些办法来通讯,还需要二次造轮子对 param 或 form 的语义进行再创作。与其这样,我选择所有的 API 请求通讯全部利用 json 格式,完美兼容结构化数据。这对应的结果就是 API 的动词语义规范,我并不去严格遵循。除了最简单的查询操作使用 GET 之外,包括复杂查询操作在内,我一律使用 POST。其他方法的支持情况可想而知,坑不一定潜伏在哪,还不如绝大多数一律用 POST,省心省事。在我看来,对应 API 请求 url 最后加上 /verb
,也没有多不清真。这样做的好处是可以做出很复杂的结构化查询和操作,而不再使用弱鸡的 POST 的 form 格式 (application/x-www-form-urlencoded
)。另一方面,form 格式的数据是字符串形式,后端的 request.form
并不自动解析,而 json 格式的数据,后端的 request.json
会直接还原成 Python 的原生格式。比如,iscorrect: true
,以 form 的形式 POST 到后端拿到的是 "true"
这一字符串,而 json 格式则拿到的是 True
这一 Python 关键字。 为了实现上面的 API 请求效果,ViewModel 会自动拦截所有的表格提交,按照 v-model 的内容,以 json 格式直接进行后端 API 请求。
后端的 Model 部分实现了 ORM,可以利用 sqlalchemy 来实现。一般的实践中,甚至还会在 ORM 之上在封装一层抽象,把增删改查操作作为 MixIn 直接传递给所有的 Model,从而使得 API 中对数据 Model 的操作更加自然,彻底屏蔽掉数据库的存在。当然所有的抽象都是 leaky abstraction3,很多时候连 ORM 都很难万能,还得临时裸写 sql query 实现复杂的数据查找要求,简单的增删改查封装,也只是大多数情况省事一点的 shortcut 而已。数据查询情况一复杂,还得直接利用 sqlalchemy 的 query
甚至 execute
来实现。好在 sqlalchemy 的好处就在于,其不绑架你必须使用 ORM,即使定义了 model,你也总可以用更低层的 execute
来直接操作数据库来实现复杂要求。因此 sqlalchemy 算是一个很好的平衡点,平衡了“每个不用 orm 的网站开发最后都造出了自己的 orm 轮子,每个用了 orm 的网站开发最后都开始手写 sql” 这一悖论。
具象服务的结构
以上讨论了,这些抽象的概念的组织和通讯,本部分则关心具体服务之间的互动和组织。比如一个典型的网站,可能还包括了 wsgi 服务器,数据库,消息队列等多种服务相协同,才能合理的工作,这些服务之间的架构可以参考下面的概念图。
这一图中包括了部署和生产时常用的工具。靠上的工具是部署时使用的,靠下的工具多是生产环境使用的。虽然 APP 本身内置了 wsgi 的服务器,不过太弱了,不支持并发。因此首先在最前边堆一个 nginx,并通过 server config 中的 proxy pass
将外来的合法请求转发到 localhost 的相应端口,而这一端口由 gunicorn 监听。图中双线的模块,代表很容易并行分布式开多个实例。(当然不是其他的分布式不行,只是诸如数据库之类的有状态服务分布式不能无脑直接多开实例,需要考虑的问题较多。)对于多个节点都有 app 实例的网站,可以在其中一个配置 nginx 负载均衡,并将 upstream 设为不同节点上的 gunicorn。gunicorn 的作用则是 master slaver 模型,主进程可以根据请求数 fork 出多个 app 进程(事实上, gunicorn 同时支持多线程和多进程模式,更多细节可以参考这个讨论4),提高并发。进一步的,通过 eventlet 等工具,还可以使 gunicorn 的任务调配,同时支持多进程和异步的并发。gunicorn 后边则是最原始的 app。另一个调优的方面,就是把 app 中的 static 文件夹内的 js 和 css 文件及 assets 资源文件,直接由 nginx 服务器负责分流,从而减轻 app 本身的路由压力。更进一步,还可以设置网站的 CDN,使得这些静态文件缓存在更靠近用户的一侧,不过网站外围的配置,就超出此文的讨论范围了。
App 后边则有 mysql 这种关系型的数据库支持。与此同时,为了提高性能,还有基于 redis 或 memcache 的缓存支持。如果用 redis,建议直接连个 redis 对象,每次缓存手操也不麻烦。相反抽象出个 cache 层大大限制了 redis 功能的发挥。对于比较耗时的任务,app 可以将任务加入消息队列,RabbitMQ,或者 Redis,本示意图使用 redis。Celery 进程会读取消息队列(celery 称其为 broker)中的任务,并异步的在后台,甚至其它节点来完成。flask 对于 celery 有原生支持。Celery 实例本身对于分布式支持非常好,甚至还可以设置 celery 的路由,给不同节点的不同实例安排不同类型的任务。Celery Beat 则是一个类似 crontab 的用来定时调用相应 celery 任务的进程,常见的应用场景包括网站的定时爬虫和邮件发送等。而对于 app,celery worker 和 celery beat,为了使其在后台稳定的运行,需要使用 supervisord 进行监视和管理,一旦某个进程退出将会自动重启。对于 celery 计算的任务结果,都会存储在 backend 中,这里我们 celery 的 backend 也使用了 reids。因此,虽然图上只有一个 redis,但却是三重角色:app 的缓存,celery 的 broker,celery 的 backend。同时 celery 的任务过程中还很可能涉及向 mysql 数据库直接写入数据,这一部分如果使用 flask-sqlalchemy 的话,由于数据库连接要求 app context 会有点麻烦,有时间在详解这个坑。
对于部署工具,主要是利用 fabric 写出自动的部署脚本,从而完成一键部署新版本的 app,和调用 alembic 对数据库进行版本管理和表格升级等。
图中没有提到的是 docker,对于图中几乎所有服务,都可以打包成 docker,并通过 docker-compose 连接起来,共同一键启动。比较常见的实践是 mysql 和 redis 用独立的 docker。而 app 和 gunicorn 用一个 docker。至于 celery 可以使用独立 docker,单独处理任务,也可以放在 app 的docker 中。但一个 docker 只能有一个进程入口,想要一个 docker 同时运行 gunicorn 和 celery,就只能写一个 supervisord 管理这几个服务,然后入口改为 supervisord。注意这里 supervisord 没有了守护进程的意义,因为 docker 自己就有这种功能。在 docker 中使用 supervisord 的唯一作用就是作为一个 docker 运行多个进程(不推荐这种实践)的 workaround 而已。
本文总结了 web 开发时,抽象概念和具象服务两个层面实体的架构组织和连接。写完才发现,这些实践和 flask 关系也不大,算是适用于不同语言不同框架的一些普适内容。