上周三下午,某开发者把一段 Django 接口联调代码丢进群里,附了一张终端截图,满屏红字里最扎眼的一行是ModuleNotFoundError: No module named 'corsheaders'。他的原话是“我明明 pip install 过了”。这个场景我见过太多次,前后端分离的项目一到联调阶段,浏览器控制台开始刷 CORS 报错,后端第一反应是找django-cors-headers,装完、加进INSTALLED_APPS、重启服务,然后一头撞在这个 ImportError 上。问题本身不复杂,但它牵扯出来的东西不少:虚拟环境、解释器路径、包名与模块名的错位、中间件顺序、3.x 到 4.x 的配置项改名、白名单策略、预检请求。这篇文章就把这条链路从头到尾拆一遍,从“为什么这一行报错会出现在这里”讲到“配好之后怎么保证它真的在干活”。适合刚接手中台接口的新手,也适合那些被跨域问题反复折腾过、想一次性把配置逻辑理顺的老手。
1. 先把报错读明白:缺的不是跨域功能,是一个名字对不上的包
1.1 三种报错文本,同一个病根
corsheaders这个报错在不同 Python 版本下会长得不一样,很多人被这个差异带偏了方向。Python 2 时代统一抛ImportError: No module named 'corsheaders';Python 3.6 之后ModuleNotFoundError从ImportError里派生出来,专门表示“模块找不到”,所以新版本会看到ModuleNotFoundError: No module named 'corsheaders'。两者是父子类关系,except ImportError都能接住,本质完全一致:解释器在sys.path列出的所有目录里翻了一遍,没找到名为corsheaders的包目录。
需要特别指出的是,这个报错出现的时机很有信息量。如果是启动 Django 时立刻崩,报错栈顶大概率是django.core.management或者django.utils.module_loading,说明问题出在INSTALLED_APPS里写了'corsheaders'但包没装到当前解释器;如果是请求进来才报,栈顶出现在某个视图文件里,那多半是你自己写了from corsheaders.xxx import ...,属于代码里显式引用。前者是配置问题,后者是代码问题,排查路径不一样。
还有一种容易混淆的情况:报错信息里写的是No module named 'corsheaders.middleware'或者No module named 'corsheaders.middleware.CorsMiddleware'。这种说明corsheaders包本身找到了,但包里没有middleware这个子模块。原因通常是装的不是官方那个包,而是某个历史上存在过的同名包,或者装了一个版本极老、目录结构已经和文档对不上的包。这两种错误的处理方式完全不同,读报错时盯住“点号后面还有没有东西”这一步很关键。
1.2 为什么安装名和导入名故意不一样
这是新手最容易卡住的地方,也是这条报错最核心的病因。pip install django-cors-headers,但代码里写的是import corsheaders。安装名带django-前缀、用连字符,导入名不带前缀、全小写无分隔符。这个约定在 Django 生态里到处都是:django-rest-framework装完导入rest_framework,django-filter导入django_filters,django-debug-toolbar导入debug_toolbar。原因在于 PyPI 的包名规则和 Python 的标识符规则不是一回事,PyPI 允许连字符、允许前缀做命名空间区分(同一个作者可能同时维护 Django 版和 Flask 版),而 Python 的模块名必须是合法标识符,下划线可以有,连字符绝对不行。
理解了这一层,你就能明白为什么“我装了 django-cors-headers,为什么导入 corsheaders 报错”这个因果链在逻辑上其实是不成立的——除非装的不是那个包。反过来,有人试图pip install corsheaders(不带前缀),PyPI 上确实存在过这个名字的包,但它和官方维护的django-cors-headers不是同一个东西,装上去要么报更奇怪的错,要么装了个空壳。所以正确的动作永远是:安装用django-cors-headers,导入和配置用corsheaders,这两者之间的转换由 pip 的包元数据完成,不需要你手动干预。
顺带提一句INSTALLED_APPS里的写法。这里必须写'corsheaders',不能写'django-cors-headers',也不能写'corsheaders.apps.CorsHeadersConfig'——虽然理论上 Django 支持 AppConfig 路径写法,但这个包并没有提供独立的 AppConfig 类供你指定(部分老版本有,新版本已经简化)。写错这一处,报错信息会变成ModuleNotFoundError: No module named 'django-cors-headers',连字符出现在模块名里,一眼就能认出来是配置写错了。
2. 按顺序排查:五个检查点,基本能锁定九成情况
2.1 第一检查点:解释器到底是谁
绝大多数“我明明装了”的案子,最后都归结到这一条上。你的机器上可能同时存在系统 Python、pyenv 管理的某个版本、conda 环境、虚拟环境.venv、IDE 自带的解释器。pip install走的是某一个,python manage.py runserver走的是另一个,两边井水不犯河水。
确认方式很直接。先看当前 shell 里python指向哪里:
which python which pip python -c "import sys; print(sys.executable)" python -m pip --version前两条输出应该在同一个目录下(比如都在~/.virtualenvs/myproj/bin/),如果which pip指向/usr/local/bin/pip而which python指向虚拟环境,那基本可以确诊。python -m pip --version这条更重要,它强制用当前解释器去调 pip,输出的路径和sys.executable一致才说明两者匹配。
我个人的习惯是彻底抛弃裸pip命令,一律用python -m pip install xxx。多敲几个字符,省掉无数排查时间。虚拟环境激活之后也建议再跑一次python -c "import sys; print(sys.prefix)",确认sys.prefix指向的是虚拟环境根目录而不是/usr。有些 shell 配置会在激活虚拟环境后仍然保留旧的 PATH 顺序,导致pip被覆盖,这种情况在 Windows 的 Git Bash 里尤其常见。
注意:用 conda 的时候,
conda install和pip install装到的是同一个 site-packages,但两者的依赖解析器互相不知道对方做了什么。混用久了容易出现“pip 说已安装、import 却找不到”的诡异现象。Django 生态的包建议统一走 pip。
2.2 第二检查点:包有没有真的落到这个解释器里
路径对了之后,第二步是确认包是否存在于当前环境的 site-packages 中。三条命令配合着看:
python -m pip show django-cors-headers python -m pip list | grep -i cors python -c "import corsheaders; print(corsheaders.__file__)"第一条会输出包的版本、安装位置、依赖信息。如果输出WARNING: Package(s) not found,那就是真没装到这儿。第二条做模糊匹配,能顺带发现有没有装过corsheaders这个同名包(如果有两条输出,立刻卸掉非官方那个)。第三条是最终验证,能 import 成功并打印出.py文件路径,说明链路彻底通了;如果还报错,把sys.path打出来看看:
python -c "import sys; [print(p) for p in sys.path]"输出的列表里应该包含当前环境的site-packages目录。如果这个目录不在列表里,问题就比“包没装”更严重了——可能是PYTHONPATH被污染,或者虚拟环境本身损坏,重建一个环境比修更省事。
还有一个隐蔽情况:包确实装了,但装到了用户级目录~/.local/lib/python3.x/site-packages,而当前虚拟环境设置了include-system-site-packages = false(默认就是 false),所以看不见。pip install --user是罪魁祸首,在虚拟环境里用这个参数毫无意义,纯属自找麻烦。
2.3 第三检查点:拼写、下划线与单复数
这一条听起来很傻,但实际发生的频率超乎想象。常见的错误拼写包括:corsheader(少 s)、corsHeaders(大小写)、cors_headers(加下划线)、crossheaders(多一个 s)。Python 的模块名区分大小写,在 macOS 和 Windows 的默认文件系统上可能不区分(HFS+ 和 NTFS 默认大小写不敏感),导致本地能跑、部署到 Linux 就崩。这类问题最难查,因为本地完全复现不了。
我的做法是把INSTALLED_APPS里和跨域相关的这一行单独拎出来,用注释标上对应的 pip 包名,形成一种自我文档化:
INSTALLED_APPS = [ # ... 'corsheaders', # pip install django-cors-headers # ... ]看起来啰嗦,但接手项目的人(包括三个月后的自己)会感谢这一行注释。同样的注释方式也适用于MIDDLEWARE里的'corsheaders.middleware.CorsMiddleware'。
2.4 第四检查点:requirements 与镜像源带来的假安装
团队协作里常见的翻车现场:requirements.txt里写的是django-cors-headers,但某个成员本地用的是pip install -r requirements.txt,而他的 pip 配置了国内镜像源,镜像同步延迟导致拿到的版本偏旧;或者更糟,requirements.txt里写错了包名(比如写成django-cors-header少个 s),pip 报错但被|| true之类的脚本吞掉了,环境看起来装完了,实际缺这个包。
排查方式是先看requirements.txt原文,再比对pip freeze的输出:
grep -i cors requirements.txt python -m pip freeze | grep -i cors两边应该对得上。如果freeze里没有,说明构建过程某一步失败了。CI 环境里这种情况尤其要注意,Dockerfile 里多阶段构建时,依赖安装阶段和运行阶段如果不是同一个基础镜像、同一个 Python 版本,装好的包可能根本不在最终镜像的sys.path里。
2.5 第五检查点:同名包和旧缓存
PyPI 上曾存在过名为corsheaders的包,功能与官方包不同,装上之后import corsheaders可能成功但缺少middleware子模块。判断方法是看pip show输出的包名:官方的是django-cors-headers,作者信息、主页链接都能对上;野路子包的名字就叫corsheaders。发现装错了,先卸干净:
python -m pip uninstall corsheaders django-cors-headers -y python -m pip install "django-cors-headers>=4.0"另一个来源是 pip 的本地缓存。极少数情况下,缓存里的 wheel 文件损坏,安装过程“成功”但解压出来的目录不完整。清缓存重装:
python -m pip cache purge python -m pip install --no-cache-dir django-cors-headers--no-cache-dir这个参数在排查疑难杂症时非常好用,代价只是多花几秒下载时间。
3. 装对之后才刚开始:INSTALLED_APPS 与 MIDDLEWARE 的接线顺序
3.1 一份可以直接抄的最小配置
ImportError 解决掉之后,很多人会以为万事大吉,结果浏览器控制台照样刷红。因为包能导入只是第一步,它得被 Django 真正加载并挂到请求处理链上。最小可用配置分三块,缺一不可。
第一块是settings.py里的INSTALLED_APPS,加在列表靠前的位置即可,它本身不涉及加载顺序:
INSTALLED_APPS = [ 'django.contrib.admin', 'django.contrib.auth', 'django.contrib.contenttypes', 'django.contrib.sessions', 'django.contrib.messages', 'django.contrib.staticfiles', 'corsheaders', # 新增 'rest_framework', 'myapp', ]第二块是MIDDLEWARE,顺序在这里是有强约束的:
MIDDLEWARE = [ 'corsheaders.middleware.CorsMiddleware', # 尽量靠前 'django.middleware.security.SecurityMiddleware', 'django.contrib.sessions.middleware.SessionMiddleware', 'django.middleware.common.CommonMiddleware', 'django.middleware.csrf.CsrfViewMiddleware', 'django.contrib.auth.middleware.AuthenticationMiddleware', 'django.contrib.messages.middleware.MessageMiddleware', 'django.middleware.clickjacking.XFrameOptionsMiddleware', ]第三块是策略配置,开发阶段可以先放开,生产环境必须收紧,这块在第 5 节细讲。
3.2 中间件位置放错的两种典型症状
CorsMiddleware的位置规则在官方文档里写得很明确:放在CommonMiddleware之前,如果用了SessionMiddleware又需要读 session,放在它之后。原因在于 Django 中间件的执行模型——请求阶段从上往下走,响应阶段从下往上回。CorsMiddleware需要在请求阶段尽早判断来源是否合法,并给响应加上Access-Control-Allow-Origin等头;如果它排在CommonMiddleware后面,CommonMiddleware可能已经生成了 404 或者重定向响应,此时 CORS 头来不及加,浏览器端看到的就是“跨域被拦”,但服务端日志里一切正常。
症状一:简单 GET 请求正常,带自定义头的 POST 请求在浏览器里报跨域,但 curl 或者 Postman 直接请求完全正常。这通常是因为预检请求(OPTIONS)被前面的中间件拦截了,或者响应头没加上。排查方法是打开浏览器开发者工具的 Network 面板,找到那条 OPTIONS 请求,看响应头里有没有Access-Control-Allow-Origin。没有就是中间件没生效。
症状二:所有响应头里出现了两条Access-Control-Allow-Origin,内容一样但重复。浏览器对重复的 CORS 头会直接判定失败。这种情况往往是两边都加了头——比如 Nginx 配置里写了add_header Access-Control-Allow-Origin *;,Django 这边也加了。解决办法是只保留一层,推荐在 Django 里管,Nginx 那边去掉,因为白名单逻辑在应用层更好维护。
提示:中间件顺序错了不会抛异常,只会静默失效。改完
MIDDLEWARE之后一定要重启服务,Django 的自动重载在settings.py大幅改动时偶尔会漏掉。
4. 版本差异是第二颗雷:3.x 到 4.x 的配置项改名
4.1 配置项新旧对照
跨过 ImportError 这道坎,下一个坑往往在升级之后才踩到。django-cors-headers从 3.5 开始陆续重命名配置项,4.0 版本直接把旧名字移除。如果你从某个老项目里拷贝了配置过来,或者在搜索引擎里翻到一篇 2019 年的文章,照着写大概率不生效。
| 旧配置项(3.x 起废弃) | 新配置项(4.x 必须用) | 说明 |
|---|---|---|
CORS_ORIGIN_ALLOW_ALL | CORS_ALLOW_ALL_ORIGINS | 布尔值,放开所有来源 |
CORS_ORIGIN_WHITELIST | CORS_ALLOWED_ORIGINS | 明确列出的来源列表 |
CORS_ORIGIN_REGEX_WHITELIST | CORS_ALLOWED_ORIGIN_REGEXES | 正则匹配的来源 |
CORS_ALLOW_METHODS | 不变 | 允许的 HTTP 方法 |
CORS_ALLOW_HEADERS | 不变 | 允许的请求头 |
CORS_EXPOSE_HEADERS | 不变 | 暴露给前端 JS 的响应头 |
CORS_ALLOW_CREDENTIALS | 不变 | 是否允许携带 Cookie |
CORS_PREFLIGHT_MAX_AGE | 不变 | 预检结果缓存秒数 |
改名之后最直接的表现是:配置写了但完全不生效,跨域照旧被拦。因为新版本根本不认识这些 key,Django 的 settings 是普通模块属性,写错了不会有任何警告,静默忽略。我见过一个项目在 4.2 上跑着CORS_ORIGIN_WHITELIST,前端一直连不上,查了两天才发现是版本对不上。
4.2 升级顺序与兼容写法
如果项目还卡在老版本(比如 3.4),升级时要按这个顺序走:先升到 3.5 或 3.7 这个区间,此时新旧配置项都认,把settings.py里的旧 key 全部替换成新 key,跑一遍测试确认功能正常;再升到 4.x,把旧 key 的残留删干净。跳跃式升级容易出问题,因为中间版本的兼容期被跳过了。
兼容期可以写一段临时判断,让代码在新旧版本下都能跑:
import corsheaders CORS_VERSION = tuple(int(x) for x in corsheaders.__version__.split('.')[:2]) if CORS_VERSION >= (3, 5): CORS_ALLOWED_ORIGINS = ["https://frontend.example.internal"] CORS_ALLOW_ALL_ORIGINS = False else: CORS_ORIGIN_WHITELIST = ["https://frontend.example.internal"] CORS_ORIGIN_ALLOW_ALL = False这段代码不优雅,但迁移期能救命。迁完之后务必删掉,别让它长期留在代码库里。另外corsheaders.__version__这个属性在极老版本上可能不存在,用importlib.metadata.version("django-cors-headers")更稳妥。
版本升级还带出另一个变化:4.x 对CORS_ALLOWED_ORIGINS的校验更严格了。以前写example.com(不带协议)会被静默接受并导致匹配失败,现在某些版本会在启动时直接抛ImproperlyConfigured,提示来源格式不对。这个改动其实是好事,把配置错误从运行时前移到启动时,但如果你是从老版本升上来的,第一次启动就可能被这个异常拦住,心理上要有准备。
5. 跨域策略的取舍:白名单、凭据、预检请求
5.1 开发环境放开,生产环境收紧
开发阶段图省事,很多人直接CORS_ALLOW_ALL_ORIGINS = True。本地跑没问题,但如果这个值随着配置文件一起被带到生产环境,等于对所有站点开放了你的接口。浏览器层面看,任何网站都能用用户的 Cookie 调你的接口——虽然有credentials的限制兜底,但风险依然实打实存在。
比较稳妥的做法是用环境变量区分:
import os DEBUG = os.environ.get("DJANGO_DEBUG", "0") == "1" if DEBUG: CORS_ALLOW_ALL_ORIGINS = True else: CORS_ALLOW_ALL_ORIGINS = False CORS_ALLOWED_ORIGINS = [ "https://app.example.internal", "https://admin.example.internal", ] CORS_ALLOWED_ORIGIN_REGEXES = [ r"^https://preview-[a-z0-9]+\.example\.internal$", ]正则那条是给预览环境用的,每次提 PR 会生成一个带随机后缀的域名,硬列白名单维护不过来。不过正则要写得收敛一点,^https://preview-这种前缀匹配容易被绕过(攻击者注册preview-evil.example.internal的子域就能命中)。用example.internal这种内部域名能规避掉大部分这类问题。
5.2 带 Cookie 的请求为什么不能配成通配符
这是 CORS 规范里最容易被误解的一条。当请求携带凭据(Cookie、HTTP 认证信息、TLS 客户端证书)时,响应头里的Access-Control-Allow-Origin必须是具体的来源,不能是*,Access-Control-Allow-Credentials必须是true。这条规则由浏览器强制执行,服务端配错了浏览器就直接拒。
对应的 Django 配置是:
CORS_ALLOW_CREDENTIALS = True CORS_ALLOWED_ORIGINS = ["https://app.example.internal"] # 注意:此时 CORS_ALLOW_ALL_ORIGINS 必须为 False如果同时设了CORS_ALLOW_ALL_ORIGINS = True和CORS_ALLOW_CREDENTIALS = True,新版库会在启动时抛异常,老版本则会静默地把来源反射成请求的 Origin,看起来能用,实际上等于放开了所有来源带 Cookie 访问,极其危险。
前端这边也有对应要求,用 fetch 时必须显式写credentials: 'include',用 axios 要写withCredentials: true。少了这一项,浏览器不发 Cookie,后端拿不到 session,接口返回 401,然后一堆人开始怀疑是跨域配置没生效,其实配置是对的,只是前端没带凭据。
5.3 OPTIONS 预检 405 与自定义请求头
浏览器判断一个请求是否触发预检,取决于请求方法、Content-Type 和自定义头。application/json的 POST、带Authorization头的请求、PUT/DELETE 方法,全都会先发一个 OPTIONS 请求探路。这个 OPTIONS 请求不带业务数据,只带Access-Control-Request-Method和Access-Control-Request-Headers两个头,服务端需要在响应里明确告诉浏览器“允许用什么方法、允许带什么头”。
如果前端用了自定义头,比如X-Request-Id、X-Tenant-Key,而CORS_ALLOW_HEADERS里没列出来,预检响应里就不会包含它,浏览器判定失败。默认的CORS_ALLOW_HEADERS覆盖了一批常见头,但业务自定义的必须手动加:
from corsheaders.defaults import default_headers CORS_ALLOW_HEADERS = ( *default_headers, "x-request-id", "x-tenant-key", )default_headers是个元组,解包进去能保证基础头不丢。这个写法比手动罗列content-type、authorization这些更安全,因为库升级时默认头会跟着变。
OPTIONS 请求返回 405 是另一个高频问题。原因通常是路由层没有为这个路径注册 OPTIONS 方法,或者某个自定义中间件在CorsMiddleware之前就把 OPTIONS 请求拦掉了。DRF 的APIView会处理 OPTIONS,但如果用的是普通 Django 视图配@require_http_methods(["GET", "POST"]),OPTIONS 会直接被拒。解决方式是把CorsMiddleware往前提,让它在路由匹配之前就返回预检响应——这正是 3.2 节强调顺序的原因。
6. 排查速查表与几条踩出来的经验
6.1 症状到原因的对照表
把前面几节的内容压缩成一张表,遇到问题时按症状对号入座,能省掉大半时间。
| 现象 | 最可能的原因 | 首选动作 |
|---|---|---|
启动即报No module named 'corsheaders' | 包没装到当前解释器 | python -m pip show django-cors-headers |
| 装了但 import 仍失败 | 虚拟环境未激活或 IDE 解释器选错 | python -c "import sys; print(sys.executable)" |
报No module named 'corsheaders.middleware' | 装的是同名野包 | pip uninstall corsheaders后重装官方包 |
配置了CORS_ORIGIN_WHITELIST但不生效 | 4.x 已移除旧 key | 换成CORS_ALLOWED_ORIGINS |
| 简单请求正常,POST 预检失败 | 中间件顺序错,OPTIONS 被提前拦截 | 把CorsMiddleware前移到CommonMiddleware之前 |
响应头重复两条Access-Control-Allow-Origin | Nginx 与 Django 都加了头 | 去掉 Nginx 那侧的add_header |
| 前端带 Cookie 请求被拒 | 前端没设withCredentials或后端没开CORS_ALLOW_CREDENTIALS | 两边同时检查 |
| 自定义头触发跨域失败 | CORS_ALLOW_HEADERS未包含该头 | 用*default_headers解包后追加 |
| 预览环境跨域时好时坏 | 白名单硬编码,未覆盖动态域名 | 改用CORS_ALLOWED_ORIGIN_REGEXES |
| 本地正常,容器里报错 | 构建阶段与运行阶段镜像不一致 | 检查最终镜像的site-packages |
6.2 几条不太写在文档里的建议
第一条,把跨域配置单独抽一个模块。settings.py越写越长之后,CORS_*这些配置散落在几百行里很难找。我一般的做法是建settings/cors.py,里面只放跨域相关的开关和列表,主配置里from .cors import *。好处有两层:排查时一眼定位,团队里谁改了白名单 git diff 也清爽。
第二条,本地开发优先用前端代理,而不是放开后端跨域。Vite、webpack dev server 都支持把/api转发到后端,这样浏览器看到的是同源请求,压根不触发 CORS。CORS 配置只在测试环境和生产环境用真域名时才需要。这样做的好处是开发阶段可以完全绕开跨域问题,把配置错误的暴露点推迟到更接近真实部署的环节,反而更容易发现真问题。
第三条,白名单里别写localhost。有些人为了本地调试方便,把http://localhost:3000写进生产配置。这个域名在攻击者机器上同样存在,用户在自己电脑上访问恶意页面时,页面可以起一个本地服务监听 3000 端口,配合 DNS 重绑定做出一些意想不到的事。开发和生产用两套配置,靠环境变量切,别图省事合并。
第四条,跨域和 CSRF 是两回事,别混着调。CORS 是浏览器限制跨源读取响应,CSRF 是服务端防止跨站伪造请求。Django 的CsrfViewMiddleware会因为缺少 CSRF token 拒绝 POST,这在跨域场景下表现为 403 而不是 CORS 报错。如果前端是 SPA 且用 session 认证,要么在请求头里带X-CSRFToken,要么改用 token 认证(比如 JWT)绕开 CSRF。我见过不少项目把@csrf_exempt加到所有接口上“解决”跨域问题,这属于用错误的手段掩盖错误的症状,安全上留了窟窿。
第五条,加一个冒烟测试。在 CI 里跑一条 curl,检查 OPTIONS 请求的响应头:
curl -s -o /dev/null -D - -X OPTIONS \ -H "Origin: https://app.example.internal" \ -H "Access-Control-Request-Method: POST" \ https://api.example.internal/api/v1/orders/ \ | grep -i "access-control-allow-origin"有输出就说明链路是通的。这条检查只要几秒钟,但能在配置改动后第一时间拦住回归问题,比等到联调时被前端追着问要高效得多。
最后分享一个排查手法。当所有检查都做完还是不通时,把CorsMiddleware的日志级别临时调到 DEBUG,Django 会在日志里打印每个请求的来源判断结果和最终附加的响应头。看到实际计算出的值,比对着配置文档猜要快得多。调完记得改回去,DEBUG 级别的日志量在生产环境是灾难。