在开发环境下使用nginx重写uri及代理功能

这篇文章整理了在前端开发中,在开发环境下使用nginx重写uri及代理功能的方法。

<!--more-->

参考

1. location匹配

参考

多个项目共用同一个域名时,往往需要根据url将请求转发到不同的项目上,此时需要配置location

location [ = | ~ | ~* | ^~ ] uri { ... }

在location指令和uri请求中间可以添加可选的修饰符,四种修饰符的含义分别如下

  • = 表示精确匹配。只有请求的url路径与后面的字符串完全相等时,才会命中。

  • ~ 表示该规则是使用正则定义的,区分大小写。

  • ~* 表示该规则是使用正则定义的,不区分大小写。

  • ^~ 表示如果该符号后面的字符是最佳匹配,采用该规则,不再进行后续的查找。

当不添加任何修饰符时,则使用请求资源路径与配置的uri进行前缀匹配:如果请求资源路径以配置的uri开头,则表示可命中该规则。

需要注意的是,不能同时存在相同的uri匹配规则,即

location /img/ {}
location ^~ /img/ {}

会提示错误

nginx: [emerg] duplicate location "/img/" in /usr/local/etc/nginx/servers/test.conf:61

注意uri末尾带斜杠与不带斜杠会被视作两条匹配规则,他们的处理也是不一样的,在下面的例子中也有提到(上面引用的文章里面关于这点描述貌似错了)。

location的具体的匹配过程为

  • 首先先检查无修饰符的规则,并进行前缀匹配,选择最长匹配的项并记录下来。
  • 然后检查是否存在精确匹配的location(使用了=修饰符),如果存在,则结束查找,使用它的配置。
  • 然后查找是否存在最优匹配,如果存在,则选择最优匹配结果最长的项,并使用它的配置
  • 然后按顺序查找使用正则修饰符定义的location,如果匹配,则停止查找,使用它定义的配置。
  • 最后,如果没有匹配的正则location,则使用前面记录的最长匹配前缀字符location。

从上面的匹配过程可以看出,匹配顺序是:

精准匹配 > 最优匹配 > 按顺序的正则匹配 > 最长的前缀匹配

接下来我们将编写一些测试来练习location,其大概形式如下

# uri表示location的需要匹配的规则
location uri {
      # config表示某个config配置
    [ config ]
}

为了验证"存在多个location时,到底是哪个location匹配规则生效"的问题,我们可以将请求转发到某个不存在的文件上,然后使用错误日志查看某个请求对应的location是啥

现在,让我们开始动手测试吧


server {
    listen 80;
    server_name test.com;
    index index.html;

    error_log  /usr/local/etc/nginx/logs/error.log error;

    location / {
        root /Users/Txm/A/;
    }
    location /img {
        root /Users/Txm/B/;
    }
    location /img/ {
        root /Users/Txm/C/;
    }
}

接下来准备了一些请求链接,通过访问并查看日志,就可以知道请求到底去了那个地方

请求url 匹配规则 备注
http://test.com/s/img/1.png A 只有/符合前缀匹配规则
http://test.com/img212/1.png B 只有/img符合前缀匹配,/img/不符合
http://test.com/img/1/1.png C /img/和/img末尾有无/是有区别的
http://test.com/img/1.png C /img/比/img的前缀匹配更长,更符合

接下来测试正则匹配,往上面的server模块中添加如下location配置

location ~* /im {
  root /Users/Txm/D/;
}
location ~* \.png {
  root /Users/Txm/E/;
}

此时再访问/img212/1.png/img/1/1.png/img/1.png这三个链接,都会命中规则D,如上面的匹配规则:

  • 正则匹配的优先级大于前缀匹配,因此不会匹配规则ABC
  • 正则匹配是按照定义的顺序进行匹配的,如果命中,则停止查找,因此虽然规则E也符合规则,但是没有被命中

我们继续来测试^~最优匹配

location ~ /bund {
  root /Users/Txm/F/;
}
location ~ /bundle/1 {
  root /Users/Txm/F1/;
}
location ^~ /bundl {
  root /Users/Txm/G/;
}
location ^~ /bundle/ {
  root /Users/Txm/G1/;
}
location ~ \.js$ {
  root /Users/Txm/H/;
}

我们用http://test.com/bundle/1.js这个链接来进行测试,理论上来说这个链接符合上述所有规则,实际上该请求命中规则G1,我的理解是:

  • 最优匹配的优先级高于正则匹配
  • 存在多个最优匹配的规则时,命中匹配长度最长的规则

因此此处命中了G1,如果删除G1,则根据优先级应该匹配G,继续删除G,此时状态回到了上面的正则匹配,根据正则按顺序匹配的规则,此时应该匹配F。

最后,让我们测试一下精准匹配,精准匹配表示请求的路径与配置的uri要完全一致才可以

location = /img {
    root /Users/Txm/I/;
}
请求url 匹配规则 备注
http://test.com/img I 精准匹配优先级最高
http://test.com/img/ D 不满足精准匹配和最优匹配,在顺序上满足正则匹配D

2. root 和alias

参考

上面整理了location的语法和匹配规则,但是location并不会改变请求的uri,实际上请求到的文件路径是由其他指令进行处理的。

root与alias都可以用来指定文件的路径,他们的主要区别在于nginx如何解释location后面的uri,这会使两者分别以不同的方式将请求映射到服务器文件上

  • root的处理方式:root路径+location路径
  • alias的处理方式:使用alias路径替换location路径

http://test.com/test/index.html请求为例,

server_name test.com;

location /test/ {
  # 当配置为root时,实际请求的Users/Txm/nginx_test/test/index.html
  # root可以放在 http、server、location、if等多个配置段下面
  # root /Users/Txm/nginx_test/; 

  # 当配置为alias时,实际请求的是/Users/Txm/nginx_test/index.html
  # alias只能放在location中
  # 注意alias末尾必须跟/
    alias /Users/Txm/nginx_test/; 
}

换句话说,alias是一个目录别名的定义,root则是最上层目录的定义。

结合location,使用root或alias就可以把请求的url映射到磁盘上对应的真实目录。但是在某些时候,仅仅有目录却没有真实文件也是不够的(最常见的场景大概是开发环境没有生成环境文件名的缓存hash值和.min.等后缀),此时可以通过rewrite重写请求uri路径。

3. rewrite

参考

rewrite模块允许使用正则修改请求的URI,发起内部跳转再匹配location,或者直接做30x重定向返回客户端。

rewrite regex replacement [flag]

其中的regex是PCRE风格的正则,rewrite的运行规则如下

  • 如果regex匹配当前请求的uri,则replacement 会被当作是新的uri参与后续处理。
    • 如果在server级别设置该选项,那么他们将在location之前生效。
    • 如果新URI字符中有关于协议的任何东西,比如http://或者https://等,则终止处理并直接响应302
  • 如果同一个上下文中(server、location、if)有多个能够匹配uri的rewrite正则,则会根据rewrite指令出现的先后顺序连续进行重写替换,并将替换后的replacement当作新的uri参与后续处理
  • 如果想要终止匹配,可以使用第三个参数flag,其取值如下
    • last表示停止处理任何rewrite相关的指令,并用替换后的uri开始下一轮的location匹配
    • break表示停止处理任何rewrite相关的指令,且直接使用该uri来处理请求,不再进行location匹配
    • redirect如果不包含协议,且是一个新的uri,则用新的uri匹配的location去处理请求,不会进行30x跳转;但是他也可以直接返回30x,让浏览器自己进行重定向请求
    • permanentredirect相同,区别在于它是直接返回301永久重定向

需要注意的是lastbreak的区别,如果在location中使用location,则会再次以新的URI重新发起重定向,并再次进行location匹配,如果新的uri和旧的uri都再次匹配到一个相同的location,就会发生死循环,当这种循环超过10次时,nginx就会返回500。因此牢记:在server上下文中使用last,而在location上下文中使用break

接下来让我们通过一些例子来验证rewrite的规则。

server {
    listen 80;
    server_name test2.com;
    index index.html;
    root  /Users/Txm/nginx_test/;

    access_log  /usr/local/etc/nginx/logs/test2.access.log;
    error_log  /usr/local/etc/nginx/logs/error2.log error;

      rewrite ^/baidu http://www.baidu.com;
    rewrite ^/bai http://image.baidu.com;

    return 403;
}
请求url 最终rewrite的uri 备注
http://test2.com/bai http://image.baidu.com
http://test2.com/baidu http://www.baidu.com 匹配到baidu,则直接返回
然后增加location
location /bundle/ {
    rewrite ^/bundle/(.*?)$ /dist/$1 break;
}

location /dist/ {
    rewrite ^/dist/(.*?)$ /src/$1 break;
}
请求url 最终rewrite的uri 备注
http://test2.com/bundle/1.js /Users/Txm/nginx_test/dist/1.js break不再进行location匹配
http://test2.com/dist/1.js /Users/Txm/nginx_test/src/1.js

然后将/bundle/里面的标识符break修改为last

location /bundle/ {
  rewrite ^/bundle/(.*?)$ /dist/$1 break;
}

则可以看见最终的uri跟/dist/一样,重写成了/Users/Txm/nginx_test/src/1.js,因此牢记在location中使用break的警告。

通过rewrite,我们可以重写路径,将一些原本不存在的文件修改为实际存在磁盘上的文件,下面是一个去掉.min后缀和-hash后缀的重写规则,可以将历史项目中使用grunt打包的静态资源映射到src对应源文件去

rewrite ^(.*?)((\.min)?\-.*?)(\..*?)$ $1$4 last;

4. nginx代理的一些用法

反向代理是为服务端服务的,反向代理可以帮助服务器接收来自客户端的请求,帮助服务器做请求转发,负载均衡等。

反向代理对服务端是透明的,对我们是非透明的,即我们并不知道自己访问的是代理服务器,而服务器知道反向代理在为他服务。

正向代理是为我们服务的,即为客户端服务的,客户端可以根据正向代理访问到它本身无法访问到的服务器资源,一种应用场景是:假设公司的局域网不允许访问外网,则局域网中的客户端要访问Internet,则需要通过代理服务器来访问。

正向代理对我们是透明的,对服务端是非透明的,即服务端并不知道自己收到的是来自代理的访问还是来自真实客户端的访问。

下面是在前端开发中,可以使用nginx代理实现的一些功能场景

4.1. 在本地开发环境模拟线上请求场景

代码运行环境可以分为本地开发环境、测试环境和线上生产环境。以现有开发流程中某个web项目为例:

  • 开发时是本地启动的3015端口号
  • 测试时在提测平台根据工单拉取相关服务,通过k8s部署在容器并运行服务,最终输入一系列的host列表
  • 工单可上线时,通过发布平台合并代码到develop及master,然后按命令步骤部署到生产服务器上

假设访问服务的链接是http://m.xxx.com/h5/test为了达到三个环境相同的访问场景,一般来说需要做下面处理:

需要访问生产环境时,直接在浏览器输入当前链接即可,线上的域名一般会预先解析到对应的服务器上ip上,默认情况下输入域名访问到的就是生产环境的服务。

可以把测试环境理解成生产环境的镜像,应用也是部署在一台服务器上,需要访问测试环境时,我们就需要将域名指向测试服务器的ip地址

在本地开发时,如果需要通过域名访问本地开发环境,则可以通过修改host,然后将域名请求域名代理到本地node服务

127.0.0.1 xxx.com

然后修改nginx配置,通过nginx将xxx.com域名的请求代理到本地的服务端口号上面

server {
    listen 80;
    server_name m.xxx.com;

    location / {
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forward-For $proxy_add_x_forwarded_for;
        proxy_set_header Host $http_host;
        proxy_pass http://127.0.0.1:3015;
    }
}

这里推荐一个超级好用的host修改工具:SwitchHosts,可以很方便地在本地、测试环境、生成环境进行域名切换。

4.2. 将线上请求资源文件映射到本地

由于生产环境的静态资源如样式表、JavaScript文件一般是经过压缩和打包的,为了缓存控制甚至为文件名添加了hash后缀,如果在某些时候需要对线上代码进行调试,一般由两种方式

第一种方式是通过charles等抓包工具,将请求的文件通过Map Local的方式映射到本地磁盘上,此时请求资源实际返回的是本地文件,然后可以通过修改本地文件来达到调试的目的。这种方式适合未经过代码合并打包处理的文件,在维护一些使用requirejs、seajs等模块管理工具加载文件的老项目比较有用。

第二种将静态资源域名配置到本地,然后通过nginx的locationaliasrewrite实现静态资源文件代理

server  {
    listen 80;
    server_name cnd.shymean.com;

    location /wargame/ {
        alias /Users/Txm/github/wargame/dist/;
            # 移除请求如jquery.min-a2dfg.js链接上的hash值a2dfg
            # http://cnd.shymean.com/wargame/jquery.min-a2dfg.js 实际返回文件为 /Users/Txm/github/wargame/dist/jquery.min.js
        rewrite ^(.+)/(.+)[-.]\w+\.(\w+)$ $1/$2.$3 last;
    }

    location /blog/ {
            # 直接映射到本地目录
        alias /Users/Txm/blog/public/;
    }
}

4.3. nginx配置跨域

正向代理一个经典的场景是使用nginx绕开浏览器的跨域限制,在前后端分离的开发调试过程中,本地起的前端功能可能是localhost:port域名形式,一般情况下会使用本地mock数据进行开发,如果需要直接访问远程接口进行联调,则会遇见跨域问题。

这种场景主要是在开发时页面域名(localhost)和接口域名(api.xxx.com)不一致导致的,通过nginx的location和proxy_pass,将指定请求代理到对应服务商,从浏览器的角度来看,请求的都是同一个域名,也就不存在跨域限制了

server {
    listen 80;
      server_name shymean.com;
    location /api/ {
          # 局域网中后台开发的本地服务,用于联调
        proxy_pass http://192.168.132.253:7654;
    }
}

另外一种场景是:出于性能优化的目的,一些静态资源等往往使用单独的cdn域名,当业务需要使用跨域资源(如在canvas上绘制网络图片最后需要调用canvas.toDataURL),此时也会存在跨域限制,通过nginx的add_header功能可以非常简单地实现CORS。

# 静态资源
map $http_origin $imgCorsHost {
    default 0;
    "~http://shymean.com" http://shymean.com;
    "~https://shymean.com" https://shymean.com;
}

server {
    listen 80;
    listen 443;
    server_name cdn.shymean.com;

    root /Users/Txm/Desktop/blog/static;

    add_header Access-Control-Allow-Origin $imgCorsHost;
    add_header Access-Control-Allow-Methods 'GET, POST, OPTIONS';
    add_header Access-Control-Allow-Headers 'DNT,X-Mx-ReqToken,Keep-Alive,User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type,Authorization';

    if ($request_method = 'OPTIONS') {
        return 204;
    }

}

从上面的例子中可以看到,如果需要指定Access-Control-Allow-Origin为多个域名,可以使用nginx的map结构。

4.4. 通过nginx修改响应的内容

有时会有一些只在开发环境下生效的逻辑,如引入mock代码、向移动端页面增加eruda调试工具等。

在本地开发时,可以通过运行时指定环境变量为development来判断是否为生产环境,从而修改响应内容

<% if(!app.isProduction()) { %>
    <script src="https://cdnjs.cloudflare.com/ajax/libs/Mock.js/1.0.1-beta3/mock-min.js"></script>
    <%- IncludeAssets('start:statics/h5/act/js/_mock.js') %>
    <script>
        window.isMockReuquest = true
    </script>
<% } %>

上面这种方式,在代码中增加额外的环境判断代码,然后注入mock代码,通过nginx通过拦截并修改响应内容,可以同样实现这个功能。在最开始实现这个需求的时候,花了大量时间来查阅相关的实现方法,发现最简单的方式应该是通过openresty来实现。参考:

location ~* 1.js$ {
    body_filter_by_lua_file /usr/local/etc/openresty/lua/hello.lua;
}

然后新增hello.lua脚本,编写相关逻辑

-- body_filter_by_lua, body filter模块,ngx.arg[1]代表输入的chunk,ngx.arg[2]代表当前chunk是否为last
local chunk, eof = ngx.arg[1], ngx.arg[2]
local buffered = ngx.ctx.buffered
if not buffered then
   buffered = {}  -- XXX we can use table.new here 
   ngx.ctx.buffered = buffered
end
if chunk ~= "" then
   buffered[#buffered + 1] = chunk
   ngx.arg[1] = nil
end
if eof then
   local whole = table.concat(buffered)
   ngx.ctx.buffered = nil
     whole = string.gsub(whole, "console.log",  "console.warn")

   ngx.arg[1] = whole
end

需要注意的是,修改后的whole内容长度,不能超过之前原本的内容长度,否则后面的数据会被截取掉,估计跟content-length头部有关。关于openrestylua,之前接触的也不是很多,后面会继续深入学习一下。

5. 小结

本文总结了几个与nginx匹配uri相关的一些指令,包括

  • 使用location匹配uri
  • 使用rootalias指定请求资源目录
  • 使用rewrite重写uri及后续匹配规则

结合uri和代理,我们就可以把请求导向自己需要的资源上去,从而满足开发环境的多种开发需求。