简单解决大型 Flask 蓝图的路由划分
Flask
框架提供了蓝图(Blueprint
)的概念,作为划分大型网站的主要工具。但对于地址较为复杂的网站,仅靠 Blueprint
仍然是不够的。以个人博客为例,如果我们把它规划为个人网站的一个子分区,那么很自然地会设置一个 url_prefix='/blog'
的蓝图。但 blog 下面可能不只是一个简单的平面结构,还会有更多的层次,类似这样:
- /blog/post/
- /blog/post/
/edit - /blog/post/
/comments - /blog/category
- /blog/category/
- ...
当然,我们仍然可以逐项在路由中添加必要的前缀,但是这样显然违反了 DIY 原则。有没有办法将 Blueprint
再进行细分呢?
遗憾的是,蓝图已经是最小的管理单位,Flask
官方对此并未提供什么解决办法。好在 Flask 是一个比较灵活的框架,俗话说自己动手丰衣足食,但在动手之前,我们不妨先看看其他框架是如何解决这个问题的。
Django
是经常拿来和 Flask
作比较的同类框架。不同于 Flask
的装饰器风格,Django
的设计倾向于在同一个地方(通常是每个 app 中的 urls.py
)统一声明所有的路由映射。Django
也支持用 include
的方式声明嵌套形式的地址,大致实现如下:
from django.urls import include, path
nested = [
path('/', nest_views.index, name='index'),
...
]
urlpatterns = [
path('/', home_views.index, name='index'),
path('nested/', include(nested, namespace='nested')),
]
Django
的做法可以给我们一些启发。Flask
虽然鼓励我们用装饰器的方式去声明路由,但同时也提供了 add_url_rule
这种更加灵活的方法,给了我们额外的发挥空间。
我们希望实现一个“缩微版”的 Blueprint
,简称为 Area
(这个名字是从 ASP.NET MVC
里面“偷”来的)。大致要求为:
- 在
Blueprint
下面可以挂接多个Area
。对于更复杂的 URL,Area
下面可以再次挂接Area
,理论上可以无限扩展; - 同一个
Area
下面的所有路由共享相同的 URL 前缀; - 注册路由的接口和
Flask App/Blueprint
保持一致。
了解了要求,实现就很简单了。
class BlueprintArea:
def __init__(self, owner, prefix):
self.owner = owner
self.prefix = prefix
def add_url_rule(self, rule, endpoint=None, view_func=None, **options):
full_rule = self.prefix + rule
endpoint = options.pop("endpoint", None)
self.owner.add_url_rule(full_rule, endpoint, view_func, **options)
def route(self, rule, **options):
def decorator(f):
self.add_url_rule(rule, view_func=f, **options)
return f
return decorator
这里,我们直接借鉴了 Blueprint
对 add_url_rule
的实现,只是加上了自己的前缀。
然后,我们写个简单的 Flask 应用来测试一下。为了证明 Area 是可以嵌套的,我们在 area1 下面再挂一个 area11。为简洁起见,完整的视图函数就不再列出了:
from flask import Flask, Blueprint
app = Flask(__name__)
@app.route('/')
def index():
return 'index'
bp = Blueprint('bp', __name__)
area1 = BlueprintArea(bp, '/area1')
area11 = BlueprintArea(area1, '/area11')
area2 = BlueprintArea(bp, '/area2')
@bp.route('/')
def bp_index():
return 'bp'
@area1.route('/')
def area1_index():
...
app.register_blueprint(bp, url_prefix='/bp')
这里有两点值得说明:
- 在声明所有路由之后再调用
register_blueprint()
。这是因为register_blueprint
会对所有已声明的路由进行注册,在这之后声明的路由将不起作用。如果将Flask
应用组织为多个模块的话,请注意模块之间的导入关系。 - 尽管 Area 可以(理论上)无穷嵌套,但仍然需要保证对应的视图函数不要重名,或者手工指定 endpoint,否则像
url_for
之类的功能可能会出现问题。
最后,我们写个测试来验证上述代码是否正常工作:
def run_tests():
cases = [
('/bp/', 'bp'),
('/bp/area1/', 'area1'),
('/bp/area1/123', 'area1 single(123)'),
('/bp/area1/area11/', 'area11'),
('/bp/area2/', 'area2'),
]
with app.test_client() as client:
for url, expected in cases:
actual = client.get(url).get_data(as_text=True)
assert expected == actual, 'url({}) actual response: {}'.format(url, actual)
if __name__ == '__main__':
app.run()
else:
run_tests()
OK,没有问题。
最后介绍一点相关资料。本文提出的并不是什么新鲜的观点,因为在 StackOverflow 曾经有人提过类似的想法:
甚至有人到 Flask
官方提出过类似的需求,但是被否决了:
在 StackOverflow 的回答中,Abhishek Gupta 的思路和本文的办法在原理上是相同的,只是他的实现没有考虑递归嵌套的问题。但我个人意见,不赞成将其称为 Nest Blueprint
,因为它实现的只是路由嵌套,而并不具备 Blueprint 所包含的其他大多数管理功能。这并不是什么问题:如果让每个 Area 都有类似过滤器、错误处理之类的功能,那就有点过于复杂了,实际上也无此必要,在 Blueprint
的层次上处理是一个很好的平衡。只是因为路由对大型网站而言数量会非常多,所以才会有额外的管理要求。
如果你的项目需要处理大型 Flask
应用和复杂的路由,可以考虑本文提供的方案。Flask
自身也提供了一些比较高级的管理方法,比如多重应用和扩展 WSGI
等。但对于本文的场景而言它们并不是特别合适。如果你想了解 Flask 本身的扩展方式,请参考 官方文档。