简单解决大型 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

这里,我们直接借鉴了 Blueprintadd_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')

这里有两点值得说明:

  1. 在声明所有路由之后再调用 register_blueprint()。这是因为 register_blueprint 会对所有已声明的路由进行注册,在这之后声明的路由将不起作用。如果将 Flask 应用组织为多个模块的话,请注意模块之间的导入关系。
  2. 尽管 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 曾经有人提过类似的想法:

Nested Blueprints in Flask?

甚至有人到 Flask 官方提出过类似的需求,但是被否决了:

Nestable blueprints

在 StackOverflow 的回答中,Abhishek Gupta 的思路和本文的办法在原理上是相同的,只是他的实现没有考虑递归嵌套的问题。但我个人意见,不赞成将其称为 Nest Blueprint,因为它实现的只是路由嵌套,而并不具备 Blueprint 所包含的其他大多数管理功能。这并不是什么问题:如果让每个 Area 都有类似过滤器、错误处理之类的功能,那就有点过于复杂了,实际上也无此必要,在 Blueprint 的层次上处理是一个很好的平衡。只是因为路由对大型网站而言数量会非常多,所以才会有额外的管理要求。

如果你的项目需要处理大型 Flask 应用和复杂的路由,可以考虑本文提供的方案。Flask 自身也提供了一些比较高级的管理方法,比如多重应用和扩展 WSGI 等。但对于本文的场景而言它们并不是特别合适。如果你想了解 Flask 本身的扩展方式,请参考 官方文档