Flask 基于子域名的蓝图管理

版权声明:所有博客文章除特殊声明外均为原创,允许转载,但要求注明出处。

在 Flask 中,蓝图(Blueprint)通常是基于路径进行分派的,因此我们看到典型的注册代码一般类似这样:

app.register_blueprint(home_bp, url_prefix='...')

相对少见的另一种用法是,Blueprint 也可以通过子域名来分派,这涉及到程序结构上会有一些改变,同时也会带来一些新的问题(当然都是可以解决的)。使用子域名是大型网站的常规做法,同时也使得 URL 路径更有针对性,比如提供一个 https://api.mydomain.com/... 比起所有页面都堆到 https://mydomain.com/ 下面,看上去也显得更专业一些。我自己也在尝试通过这种方式重构自己的网站,最开始尝试的是每个域名使用一个单独的 app 去管理,但很快发现如果一些比较小的功能也做成独立的网站,会带来比较多额外的管理负担。因此,把这些功能合并到一个app,对外又能通过子域名公开,是不错的做法。因此,我对这种实现做了一些尝试,并对遇到的问题和解决办法做一个记录,以供自己和其他朋友参考。

域名管理

首先需要注意的是,由于需要区分不同的域名,以前那种在开发环境下统一用 localhost 做域名的做法现在行不通了。大型的网站可能会考虑用自定义域名解析的方式实现统一管理,我们现在的场景没那么复杂,简单在 hosts 里面加几条记录也就足够了。

127.0.0.1              yuhao.space
127.0.0.1              www.yuhao.space
127.0.0.1              blog.yuhao.space

基本应用

添加域名记录以后,我们来写一个简单的测试程序,检查一下域名分派在开发环境下是否正常。这里需要注意的几个点:

  • app.config['SERVER_NAME'] 需要指向基本域名,包括端口(Flask 默认为5000);
  • 创建 Blueprint 需要添加 subdomain 参数指向子域名(除主域名外)。

我们模拟两个域名来测试:一个主域名和一个博客域名(blog)。

from flask import Flask, Blueprint

home_bp = Blueprint('home', __name__)
blog_bp = Blueprint('blog', __name__)

@home_bp.route('/')
def home_index():
    return 'home index'

@blog_bp.route('/')
def blog_index():
    return 'blog index'


def create_app():

    def register_blueprints(app):
        app.register_blueprint(home_bp)
        app.register_blueprint(blog_bp, subdomain='blog')

    app = Flask(__name__)
    app.config['SERVER_NAME'] = 'yuhao.space:5000'
    register_blueprints(app)
    return app

app = create_app()

if __name__ == '__main__':
    app.run(debug=True)

运行程序,然后打开浏览器,分别浏览如下地址:

  • http://yuhao.space:5000/
  • http://blog.yuhao.space:5000/

很好,一切正常。接下来看看在生产环境下表现如何。

使用 Nginx 作为反向代理

在生产环境下,我们基本上不会把 Flask 应用直接暴露在公网上,而是使用类似 Nginx 这样的服务器作为前端代理。这种部署模式会带来一些额外的复杂性,而且容易出错(特别是配置方面),需要仔细验证。同时,正式环境一般也要在公网上启用 HTTPS,这又要求我们有一个有效的证书。因为我已经用 Let's Encrypt 申请过证书,所以这里就偷懒直接拿来用了,想自行验证的读者请在本地生成一个测试证书,不想搞太麻烦的同学就接着往下看吧。

Nginx 的配置不太相关的部分就略过了,其实和普通网站基本没有太大区别:

server {
    listen 443 ssl;
    server_name         yuhao.space *.yuhao.space;
    ssl_certificate     ...;
    ssl_certificate_key ...;

    location / {
        proxy_set_header    Host            $http_host;
        proxy_set_header    X-Real-IP       $remote_addr;
        proxy_set_header    X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header    X-Scheme        $scheme;
        proxy_pass          http://localhost:5000;
    }
}

这里我们让所有域名都通过相同的服务端口,为了识别到域名,别忘了设置 HOST。

设置完毕并重新加载 Nginx,然后用生产模式运行应用:

FLASK_ENV=production FLASK_APP=app flask run

然后打开浏览器访问...404?怎么回事?回想一下代码能猜到,通过生产环境访问时,浏览器发来的 Host 已经不带 5000 端口号了,因此我们这里要对应修改一下。为了同时保证开发环境仍然正常工作,需要判断一下环境:

    app = Flask(__name__)
    server_name = 'yuhao.space'
    env = os.getenv('FLASK_ENV', 'development')
    if env != 'production':
        server_name += ':5000'
    app.config['SERVER_NAME'] = server_name

当然,这不是足够灵活的生产代码,但我在这里希望作为示例尽量简单明了。

再次运行程序,这次访问应该正常了。

检查反向地址

地址访问正常只是成功的一半。另一半是从客户请求中生成可访问的地址————也就是我们熟悉的 url_for(),同样应该检查它们是否工作正常。为此,我们把视图函数稍稍修改一下,让它们返回模板内容:

@home_bp.route('/')
def home_index():
    return render_template('home.html')

然后写一个简单的模板:

<a href="{{ url_for('home.home_index') }}">Home Index</a>
<a href="{{ url_for('blog.blog_index') }}">Blog Index</a>
<br/>

<a href="{{ url_for('home.home_index', _external=True) }}">Home Index</a>
<a href="{{ url_for('blog.blog_index', _external=True) }}">Blog Index</a>

好消息是,内部地址是正确的; 坏消息则是外部地址(使用 _external 参数)路径虽然没错,但却返回了 http:// 的地址,这可不是我们想要的结果。

一个简单粗暴的处理办法是, 为 url_for 强制指定协议:

<a href="{{ url_for('home.home_index', _external=True, _scheme='https') }}">Home Index</a>

但可想而知,如果网站有很多链接的话,这样会增加不小的工作量。

从官方文档和代码中的备注来看,应用程序配置中有一项 PREFERED_URL_SCHEME 似乎应当与此有关,但文档对此解释不太明确,我尝试过添加该配置也不起作用。Stackoverflow 和 Github 上也有人提过类似的问题:PREFERRED_URL_SCHEME doesn't seem to work in Jinja2 Flask Template。 官方对此也没有明确的答复。

既然没有正规途径,只要自己解决了。所幸 url_for 已经有参数可以利用,所以这也并不难。我们只要定义一个模板函数来强制指定 scheme 就好了:

    def external_url_for(endpoint, **values):
        values.setdefault('_external', True)
        values.setdefault('_scheme', 'https')
        return url_for(endpoint, **values)

    ...
    app.add_template_global(external_url_for, 'external_url_for')

然后在模板中用 external_url_for 代替 url_for 调用即可。

结语

本文讨论了基于子域名的 Flask Blueprint 开发实践和相关问题的处理。要处理多个域名,另一种做法是通过 WSGI 接口分配多个应用,这种做法在 官方文档 Application Dispatching 部分 有所涉及。但我觉个这种方法略“重”,因此还是走了 Blueprint 的路子。如果子应用的规模很大,那么单独分配 app 或许是更灵活的做法。从实践中我们也可以体会到,尽管 Flask 在个别地方功能略显不足,但还是给我们提供了很多灵活性,值得好好去挖掘。