很多站点做移动端适配时,第一反应是改后端代码:在 Controller 里读 User-Agent,判断是不是手机,再决定跳 m. 域名还是返回不同模板。这种写法能用,但把「流量分发」这件本该由接入层处理的事塞进了业务代码,结果是每个语言、每个服务都要重复实现一遍判断逻辑,正则写得还不一致;更麻烦的是灰度阶段想临时改规则,得重新发版。
Nginx 的 map 指令正好解决这个问题。它在 http 上下文里建立一张「变量 → 变量」的映射表,请求进来时一次性求值,后续 server、location、if、proxy_set_header 里都能直接引用。本文给出一套可直接落地的配置:根据 $http_user_agent 判断终端类型,把结果写入自定义变量,再通过 proxy_set_header 回写给上游,同时覆盖 PC 站与移动站分流、缓存 key 隔离、上游读取的完整链路,并列出实跑中会遇到的报错与排查方法。
为什么用 map 而不是 if
先说清楚选型理由,否则后面每个细节都容易被质疑。
- if 在 location 里属于「rewrite 阶段」指令,官方文档明确写了
if在 location 上下文中使用有诸多限制,比如if内不能再嵌套if,某些指令在if块里行为异常,容易踩坑。 - map 是编译期构建的哈希表,请求到达时按需惰性求值(只有被引用时才计算),正则匹配走的是预编译的 PCRE,比在每个
if里现写正则更稳。 - map 的结果是变量,可以跨
server、location复用,还能参与proxy_cache_key、log_format,这是if做不到的。
一个常被忽略的点:map 只能定义在 http 上下文,不能写在 server 或 location 里。写错了 Nginx 启动会直接报:
nginx: [emerg] "map" directive is not allowed here in /etc/nginx/conf.d/mobile.conf:12
解决办法就是把 map 块挪到 http { } 内部,通常放在 conf.d/ 下一个单独文件里,include 进去即可。
基础配置:从 User-Agent 到自定义变量
先写核心映射。要点有三个:变量名不能和内置变量冲突、默认值必须给、正则大小写不敏感要显式声明。
# /etc/nginx/conf.d/mobile-map.conf
# 必须在 http 上下文,通过 nginx.conf 的 include conf.d/*.conf 引入
map $http_user_agent $is_mobile {
default 0;
"" 0; # 空 UA,按 PC 处理
# 移动端系统与浏览器关键字
~*iphone 1;
~*ipod 1;
~*ipad 0; # iPad 默认走 PC 版,按需改 1
~*android 1;
~*windows\s+phone 1;
~*blackberry 1;
~*bb10 1;
~*opera\s+mini 1;
~*opera\s+mobi 1;
~*iemobile 1;
~*mobile 1;
~*webos 1;
~*kindle 1;
~*silk 1;
~*playbook 1;
# 爬虫单独标记,避免被当作手机分流
~*(bot|spider|crawler) 2;
}
几个容易写错的细节:
~*是大小写不敏感的正则匹配,~是敏感匹配,~*后面紧跟模式,中间不能有空格之外的修饰。写成~* iphone(多一个空格)虽然多数版本能容忍,但规范写法是不加空格的。- 顺序即优先级:
map按书写顺序匹配,命中第一条即返回。所以爬虫规则如果放在~*android后面,一个 Android 上的爬虫(UA 里既有 android 又有 bot)会先被~*android命中。要么把爬虫规则提前,要么像上面这样单独给一个值2,让业务侧自行决定。 - default 必须存在,否则未命中时变量值为空字符串,而
proxy_set_header传空值会导致上游拿不到该头。 - 变量名
$is_mobile必须是自定义的。若写成$mobile之类,注意别和已有模块的变量撞名。用nginx -V看不到变量列表,但启动时报variables hash相关错误基本就是撞名。
回写上游请求头:proxy_set_header 的正确用法
变量有了,接下来把它塞进发往上游的请求里。这里最常见的误解是以为设了变量上游就能拿到——不会,必须显式 proxy_set_header。
server {
listen 80;
server_name www.example.com;
location / {
proxy_pass http://backend_pool;
# 回写自定义头,值就是 map 的结果
proxy_set_header X-Device-Type $is_mobile;
proxy_set_header X-Original-UA $http_user_agent;
# 关键:Host 与真实 IP 的透传(否则上游日志全是 127.0.0.1)
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
注意 X-Device-Type 的值是 0 / 1 / 2,如果上游希望拿到语义化的字符串,可以在 map 里直接映射成字符串,也可以再套一层 map 做「值到值」的翻译:
map $is_mobile $device_label {
default desktop;
0 desktop;
1 mobile;
2 crawler;
}
# 在 location 中
proxy_set_header X-Device-Type $device_label;
map 的源变量可以是任意变量,包括另一个 map 的结果,这种链式写法在需要保持内部数值语义、又想让上游拿到可读值时很好用。
顺带处理:给上游的 Accept 头与缓存隔离
有些老后端会根据 Accept 头判断要不要返回 WAP 页面,如果你希望统一由 Nginx 决定,可以覆盖掉客户端的 Accept:
map $is_mobile $forward_accept {
default "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8";
1 "text/vnd.wap.wml,text/html;q=0.9,*/*;q=0.8";
}
proxy_set_header Accept $forward_accept;
原文写的是 default "text/html,...",1 "text/vnd.wap.wml,...",两个分支都要用引号包住,因为值里有逗号和分号,不加引号 Nginx 会把分号当指令结束符,报 nginx: [emerg] unexpected ";"。
更实用的是缓存隔离。如果前面挂了 proxy_cache,PC 和移动端共用同一个 key 会互相污染:
proxy_cache_key "$scheme://$host$request_uri|$device_label";
这样同一个 URL 会生成两份缓存,各自命中。代价是缓存体积翻倍,可接受。
移动站跳转:301 还是内部转发
分流有两种出口,配置完全不同,别混用。
方案 A:302 跳转到 m 域名
server {
listen 80;
server_name www.example.com;
# 只在移动端且不在 m 站时跳转,避免循环
if ($is_mobile = 1) {
return 302 https://m.example.com$request_uri;
}
location / {
proxy_pass http://backend_pool;
}
}
用 302 而不是 301:301 会被浏览器永久缓存,一旦判断规则调整(比如某机型不再算移动端),用户端无法刷新。用 302 保留调整空间。$request_uri 带原始 query string,不要用 $uri,否则参数全丢。
循环跳转是这套方案的头号事故:m 站如果也走同一套 Nginx 配置,UA 判断为移动端后会继续跳自己。规避方式是把判断条件补上 $host:
if ($is_mobile = 1) {
return 302 https://m.example.com$request_uri;
}
放在 server_name www.example.com 的 server 块里就不会触发 m 站,因为 m 站是另一个 server 块。
方案 B:内部转发到不同的上游
upstream pc_backend {
server 10.0.1.11:8080;
server 10.0.1.12:8080;
}
upstream m_backend {
server 10.0.2.11:8080;
}
map $is_mobile $backend_name {
default pc_backend;
0 pc_backend;
1 m_backend;
}
server {
listen 80;
server_name www.example.com;
location / {
proxy_pass http://$backend_name;
proxy_set_header X-Device-Type $device_label;
}
}
用变量做 proxy_pass 的目标时,不再支持 upstream 的负载均衡与健康检查(Nginx 无法在运行时解析变量为 upstream 组,除非用 resolver + 变量形式指向域名)。所以上面这种写法实际要求 $backend_name 的值能直接作为地址,或者你在 map 里映射到 127.0.0.1:8081 这样的字面量。若必须保留 upstream 能力,用 PC / m 两个 location 加 if 转发更稳,或者干脆走方案 A。
验证与调试
配置改完先别 reload,用 -t 检查语法:
nginx -t
# 期望输出
nginx: the configuration file /etc/nginx/nginx.conf syntax is ok
nginx: configuration file /etc/nginx/nginx.conf test is successful
验证变量取值最直接的办法是加一个调试 location,把变量回显出来(生产环境务必加 IP 白名单或删掉):
location = /debug-ua {
allow 10.0.0.0/8;
deny all;
add_header Content-Type text/plain;
return 200 "ua=$http_user_agent\nis_mobile=$is_mobile\ndevice=$device_label\n";
}
用不同 UA 请求:
# 模拟 iPhone
curl -s -H 'User-Agent: Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15' \
http://127.0.0.1/debug-ua
# 输出 is_mobile=1 device=mobile
# 模拟桌面 Chrome
curl -s -H 'User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 Chrome/120.0.0.0' \
http://127.0.0.1/debug-ua
# 输出 is_mobile=0 device=desktop
# 模拟 Googlebot
curl -s -H 'User-Agent: Mozilla/5.0 (compatible; Googlebot/2.1; +http://www.google.com/bot.html)' \
http://127.0.0.1/debug-ua
# 输出 is_mobile=2 device=crawler
要看上游到底收到了什么,在上游服务里打印请求头即可。以 Python 为例:
from http.server import BaseHTTPRequestHandler, HTTPServer
class H(BaseHTTPRequestHandler):
def do_GET(self):
body = (
f"X-Device-Type={self.headers.get('X-Device-Type')}\n"
f"X-Original-UA={self.headers.get('X-Original-UA')}\n"
).encode()
self.send_response(200)
self.send_header("Content-Type", "text/plain")
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
HTTPServer(("127.0.0.1", 9000), H).serve_forever()
把 proxy_pass 指向 http://127.0.0.1:9000,curl 一下就能看到 Nginx 实际写出的头。
踩坑清单:真实报错与处理
| 现象 / 报错 | 原因 | 解决 |
|---|---|---|
"map" directive is not allowed here |
把 map 写进了 server / location | 移到 http { } 内,通常独立 conf 文件 include |
unknown "is_mobile" variable |
map 文件没被 include,或变量名拼错 | 确认 nginx.conf 有 include /etc/nginx/conf.d/*.conf;,且文件顺序在引用它的 server 之前 |
| 上游收到的头为空 | map 未命中且没有 default,或 proxy_set_header 没写 |
补 default;确认 header 写在正确的 location 内,父级 location 的 header 会被子 location 覆盖 |
nginx: [emerg] unexpected ";" |
map 的值含分号/逗号未加引号 | 值用双引号包裹 |
| 302 跳转无限循环 | m 站与 PC 站共用同一 server 块或同一判断 | 按 server_name 拆开,只在 PC 站做跳转 |
| iPad 被识别为移动端 | UA 里含 iPad,被 ~*mobile 或泛规则命中 |
显式加 ~*ipad 0; 且放在泛匹配之前 |
| 改了配置只 reload 未生效 | reload 失败时旧配置继续服务 |
先 nginx -t,再 nginx -s reload,并 tail error.log 确认没有 worker 启动失败 |
一个完整的可用片段
把上面的内容拼成一份可直接放进 conf.d/ 的配置,PC 站跳 m 站、回写两个头、缓存隔离都包含:
# /etc/nginx/conf.d/mobile.conf
map $http_user_agent $is_mobile {
default 0;
"" 0;
~*(bot|spider) 2;
~*ipad 0;
~*iphone 1;
~*ipod 1;
~*android 1;
~*windows\s+phone 1;
~*blackberry 1;
~*iemobile 1;
~*mobile 1;
}
map $is_mobile $device_label {
default desktop;
0 desktop;
1 mobile;
2 crawler;
}
upstream pc_backend {
server 10.0.1.11:8080;
keepalive 32;
}
server {
listen 80;
server_name www.example.com;
if ($is_mobile = 1) {
return 302 https://m.example.com$request_uri;
}
location / {
proxy_pass http://pc_backend;
proxy_http_version 1.1;
proxy_set_header Connection "";
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Device-Type $device_label;
proxy_set_header X-Original-UA $http_user_agent;
proxy_cache my_cache;
proxy_cache_key "$scheme://$host$request_uri|$device_label";
proxy_cache_valid 200 10m;
add_header X-Cache-Device $device_label always;
}
}
注意 add_header 加了 always 参数,否则 4xx/5xx 响应不会带上这个头,排查时容易误以为配置没生效。
小结
map + proxy_set_header 这套组合的价值在于:把终端识别收敛到一处配置,业务代码只消费 X-Device-Type 这个稳定的契约头。规则调整只改 Nginx 并 reload,不涉及发版;同一个变量还能复用到缓存 key、日志格式、跳转逻辑里,避免了「五个地方各写一套 UA 判断」的维护灾难。落地时守住三条:map 必须在 http 上下文、default 必须给、正则顺序按从精确到宽泛排。
FAQ
map 里的正则能写多个条件(AND)吗?
不能。单个 map 条目只做一次匹配,没有逻辑与。需要组合判断(比如「Android 且不是微信」)时,用两层 map 串联:第一层算出 $is_mobile,第二层以 $is_mobile 和另一个变量为源再映射。Nginx 的 map 每条规则只接受一个源变量,链式 map 是官方支持的常见做法。
为什么上游读不到 X-Device-Type,但本地 curl 能看到?
先确认 proxy_set_header 写在最终命中的那个 location 里。Nginx 的 header 继承规则是:子 location 一旦自己写了任意一条 proxy_set_header,就完全不再继承父级的同类指令。很多「在 server 级写了一次,location 里又写了一次 Host」的场景会导致前者整组丢失。
UA 为空或伪造怎么办?
空 UA 已在 map 里归为 PC,不会误判为移动端。伪造 UA 无法从接入层彻底防住,这属于反爬范畴,需要配合 IP 频率限制(limit_req)和行为校验,不要指望 UA 判断承担安全职责,它只做分流。
map 会不会影响性能?
影响很小。map 建立的是哈希表 + 正则列表,且是惰性求值:只有当某个请求的上下文真正引用了该变量时才计算。一个请求里如果同时引用了 $is_mobile 和 $device_label,只计算一次 $is_mobile,$device_label 复用结果。真正有成本的是正则条数过多,几十条以内无需担心。
能否用 map 匹配请求头以外的条件,比如 Cookie?
可以。map 的源变量不限于 $http_user_agent,$cookie_xxx、$arg_xxx(query string 参数)、$http_xxx(任意请求头)都能用。强制跳移动站的场景里,常见做法是允许用户用 ?force_pc=1 覆盖自动判断,此时再叠一个 map 把 $arg_force_pc 映射为覆盖标志,最后用两层结果决定最终值即可。