🤌 几分钟搞清 Django 静态文件处理

date
Nov 13, 2023
slug
django-static-quick-glance
status
Published
tags
python
django
summary
几分钟就能拿捏
type
Post
Django 处理静态文件有一套 Static 机制,它是简单的,但有时又是烦人的。
首先,啥是静态文件?不需要服务端计算,直接页面上请求拉取的文件:图片、视频、html、JS、CSS 等等。理论上来说,既然不需要计算,那实际上和 Python 也没啥必然联系了,所以 Django 在这里的职责就是“帮忙托管一下”。
说是帮忙,是因为托管静态文件这件事本来就有更专业的组件负责,比如 Nginx、CDN 等等。

DEBUG 一时爽

如果你还在本地开发阶段,可能觉得下面的内容都挺奇怪,明明不用额外配置就能访问所有静态文件。是的,当 DEBUG=True 的时候,Django 基本都帮你包办了,托管给 WhiteNoise 也会自动刷新静态文件。但如果放到生产,Django 就不再帮忙了,WhiteNoise 也只在程序启动时加载时,以下的内容就需要你自己梳理清楚了。

Django 怎么代管

所谓的帮忙代管,总结起来就是三个步骤:
  1. 你要告诉 Django:来自各个不同来源的静态文件分别放在什么位置,比如 Django 模版、前后端分离时的前端独立工程、项目外的视频图片等等。(对应 Django 配置 STATICFILES_DIRS
  1. 你要告诉 Django:最终你想把这些静态文件放到什么位置,这个位置可以就是专业组件能找到的位置。(对应 Django 配置 STATIC_ROOT
  1. 执行 Django 命令: python manage.py collectstatic ,Django 会忠实地按照你提供的信息把分散的静态文件收集放到同一个位置。
这三步搞定,Django 静态文件就处理完了,这些文件怎么被访问到,那就是专业组件的事情了。好奇宝宝们肯定有很多问题想问 Django:
  • 静态文件来源五花八门,怎么处理?
    • STATICFILES_FINDERS 实现自定义FINDER 逻辑
  • 静态文件怎么直接放云上 CDN 里去?或者远端 Nginx ?
    • STATICFILES_STORAGE 实现自定义 STORAGE 逻辑
回答完这些问题,Django 双手一摊:“静态文件没我的事了吧” 🤷‍♂️。
等等,你这个机灵鬼,我还没说完呢。

让 Django 多管点“闲事”

对于请求量大、地域来源广的服务,将站点的静态文件放到专业组件托管肯定是更好的选择。但有时候我们也需要部署一些体量不大应用——例如一些在公司内部服务的中型 SaaS,这时候把静态文件分离管理反而会增加流程和架构的复杂性(除非有非常完善的基建支持),仅仅是负责静态文件的分发是远远不够的,所以使用 Django 代理静态文件的请求也是很多生产环境的选择。
那么谁来做这部份工作呢?专业组件的工作 Django 看了直摆手,这时,【白噪声同志】应声而来。
在安装 WhiteNoise 之后,它将在你的服务内部帮忙 serve 静态文件,代理这些文件的请求以及相关的事务处理(压缩、缓存等等)。相关配置直接看官方文档就好,这里就不赘述了。
这么看起来,问题好像就直接解决了?等等,烦人的东西才刚来。

烦人的子路径问题

刚才通过 WhiteNoise 模块,已经将静态文件的访问入口收归到 Django 服务中了,理想状态下,直接访问 https://amazing-fake-app/static/ (通过 Django STATIC_URL 确定访问路径)就能寻找到收集好的静态文件了。
但是很多时候我们的服务并不是直接暴露在域名下的,往往会有“访问路径”作为前缀,例如服务本身访问:https://amazing-fake-app/foo/ ,这时如果保持原来的 STATIC_URL='/static' 很有可能就有问题。看起来这应该是 Django 应该处理的东西,为什么会有问题呢?
首先, /foo/ 通常是由外部接入层(例如 Nginx )转发提供的,Django 理论上是可以感知到它的存在:
def get_script_name(environ):
    """
    Return the equivalent of the HTTP request's SCRIPT_NAME environment
    variable. If Apache mod_rewrite is used, return what would have been
    the script name prior to any rewriting (so it's the script name as seen
    from the client's perspective), unless the FORCE_SCRIPT_NAME setting is
    set (to anything).
    """
    if settings.FORCE_SCRIPT_NAME is not None:
        return settings.FORCE_SCRIPT_NAME

    # If Apache's mod_rewrite had a whack at the URL, Apache set either
    # SCRIPT_URL or REDIRECT_URL to the full resource URL before applying any
    # rewrites. Unfortunately not every Web server (lighttpd!) passes this
    # information through all the time, so FORCE_SCRIPT_NAME, above, is still
    # needed.
    script_url = get_bytes_from_wsgi(environ, 'SCRIPT_URL', '') or get_bytes_from_wsgi(environ, 'REDIRECT_URL', '')

    if script_url:
        if b'//' in script_url:
            # mod_wsgi squashes multiple successive slashes in PATH_INFO,
            # do the same with script_url before manipulating paths (#17133).
            script_url = _slashes_re.sub(b'/', script_url)
        path_info = get_bytes_from_wsgi(environ, 'PATH_INFO', '')
        script_name = script_url[:-len(path_info)] if path_info else script_url
    else:
        script_name = get_bytes_from_wsgi(environ, 'SCRIPT_NAME', '')

    return script_name.decode()
可以看到 Django 一共可以在三个地方感知到子路径的存在:
  • 用户直接通过 FORCE_SCRIPT_NAME 手动设置
  • 从 wsgi 信息中获取 SCRIPT_URLREDIRECT_URL ,再转换成 SCRIPT_NAME
  • 从 wsgi 信息中获取 SCRIPT_NAME
第一个手动设置的我们先不讨论;第二个在代码和注释中说的很清楚了:特定的接入层(例如 Apache )会稳定提供,但不一定能保证;第三个看起来是缺省的获取方法,理论上它应该能覆盖大部份场景才对。
然而,假如你的接入层是广泛使用的 Nginx,缺省方法就有问题了:默认地,Nginx 并不支持在 HTTP Header 中使用包含下划线的变量,也就说 SCRIPT_NAME 默认不合法。
  • 如果你可以修改接入层的配置:可以手动调整 underscores_in_headers 的配置,但不幸的是,Nginx 还有个默认的行为,给 Header 添加 HTTP_ ,也就是只能通过变量 HTTP_SCRIPT_NAME 获取,Django 再次 Miss。你可以进一步地修改 Nginx 配置,直接手动添加SCRIPT_NAME :`uwsgi_param SCRIPT_NAME $http_script_name;
  • 如果你不能手动修改接入层的配置:刚才略过的手动设置 FORCE_SCRIPT_NAME 就派上用场了,手动添加上 FORCE_SCRIPT_NAME=/foo/
当然你可以在 Django wsgi 加载时做一个动态的配置,确保接入层的变更能及时更新到配置中:
class DynamicScriptNameWSGIHandler(WSGIHandler):
    def __call__(self, environ, start_response):
        # HTTP_X_SCRIPT_NAME(from Nginx) -> SCRIPT_NAME(Django wsgi)
        script_name = environ.get('HTTP_X_SCRIPT_NAME')
        if script_name is not None:
            environ['SCRIPT_NAME'] = script_name
            settings.FORCE_SCRIPT_NAME = settings.SITE_URL = '%s/' % script_name

        return super().__call__(environ, start_response)
至此,静态文件处理的流程就很清楚了,挺简单的一件事,就几分钟搞定,打完收工。

© bluesyu 2019 - 2024

powered by nobelium