声明式(declarative)和强制式(imperative,也常被翻译成”命令式“)是两种不同的编程方式。关于这个主题网上有很多介绍文章。总的来说,声明式程序描述想要的结果,而强制式程序描述为了达到某个结果所需要采取的每一个准确步骤。两种方式都有各自的优缺点和最适用的场景。声明式编程的一个常见用例是为一些定义明确的工作流设置参数。比如说,在咖啡馆点单,你可以只告诉服务员“我要一杯加糖和奶的咖啡”。你不需要给出怎么做咖啡的一步一步的指示,包括什么时候加糖什么时候加奶,因为你确定店员知道做咖啡的详细流程。
通过 HTTP 提供和代理内容服务也是一个被技术协议明确定义的工作流。因此,nginx 的配置绝大部分是声明式的,你不需要关心每一条指令是怎么被执行的,以及什么时候被执行的。例如,这条指令 add_header X-My-Data abc always;
只是保证 X-My-Data: abc
出现在 HTTP 响应报文的头部里;proxy_pass https://www.my-upstream.com;
告诉服务器从指定的上游把内容取下来。这些指令看起来很直接,但是当我们需要基于不同的条件对工作流进行不同配置的时候,事情就变得有趣了。
让我们回到点咖啡的例子,你告诉服务员“如果你店里的牛奶不是M 牌的就别加”。一位有经验的服务员会了解店里牛奶的库存,然后根据是否有M 牌牛奶向后台的店员下发”标准”的指示:“做一杯加糖加奶的咖啡”,或者“做一杯只加糖的咖啡”。如果咖啡店为不同的做咖啡流程预先编了码,指示可能会更简单,比如“做 1 号”或“做 2 号”。
Nginx支持由 location
和 if
指令为配置设定条件。CDN Pro 还引入了 elseif
和 else
指令来实现更为灵活的配置。这些指令后面的一对花括号定义了一个“上下文”,而且它们可以多重嵌套。每个上下文里的声明型指令可以和上层上下文里的指令合并以生成一个“标准”的配置。当 nginx在加载和解析配置文件时会建立一个查找表,其中包括所有上下文和相应的标准配置。比如下面的配置:
server {
CONFIG_0
location /a { # 上下文 1
CONFIG_1
}
location / { # 上下文 2
CONFIG_2
if ($http_x_my_hdr) { # 上下文 3
CONFIG_3
}
location /b { # 上下文 4
CONFIG_4
}
}
}
生成的查找表大概是这个样子:
上下文 | 合并后的 "标准" 配置 |
---|---|
1 | 合并(CONFIG_0, CONFIG_1) |
2 | 合并(CONFIG_0, CONFIG_2) |
3 | 合并(CONFIG_0, CONFIG_2, CONFIG_3) |
4 | 合并(CONFIG_0, CONFIG_2, CONFIG_4) |
收到客户请求后,Nginx 首先会确定处理这个请求的上下文,然后把对应的标准配置应用于接下来的处理流程,这就像服务员把你带有条件的订单转化为一个标准的订单。如果请求匹配多个相同级别的上下文,下面的规则保证只有一个被选中:
if
指令块中, 选最后一个。Nginx 不会动态合并配置,所以其它 if
指令块里面的声明式指令被忽略;location
指令块里, 根据规则选择优先级最高的;location
指令块比 if
块的优先级更高。上面的规则 1 可能是 Nginx 行为里新用户最难理解的,因为它和绝大部分编程语言都不一样。因此我们强烈建议用户在可能的情况下避免在 if
块里使用声明式指令,而使用此网页描述的替代方案。
上面提到的 if
指令是由 rewrite模块提供的。这个模块也支持其它一些重要的功能,比如 URL 改写,变量创建和赋值。我们在开源版本的基础上做了一些重要的改进,引入了一些新的指令。下面是此模块中可以用于 CDN Pro的指令列表:if
, else
, elseif
, break
, return
, rewrite
, set
, 和 eval_func
。Rewrite 模块最重要的特点是它的指令是顺序执行的,按照它们在代码里面出现的顺序执行,就像强制式语言一样。然而,它们是在请求处理流程的早期执行的,早于除了 location
之外的所有指令。我们会在下一节讨论由此引起的一些问题。
set
和 eval_func
指令可以用来为变量赋值。它们可以和 if
指令一起,基于不同的条件赋于变量不同的值。由于大部分声明式指令在参数里支持变量,这就提供了一个基于条件来改变服务器行为的好方法。
原则上,用户不需要关心每一条声明式指令是什么时候执行的。但是了解一下执行时间的相关知识有助于避免一些常见错误。事实上,绝大部分指令的执行时间点都不难通过它们在请求处理流程上的功能分析出来。在下面的示意图里,我们将这个流程分为7个阶段。
例如,指令 add_header
是在第 7 个阶段,为响应添加头部的时候执行的,proxy_set_header
是在第 5 个阶段,为发给上游的请求添加头部的时候执行的。所有的访问控制指令都是在第 3 阶段执行的,rewrite 模块的指令是在第 2 阶段执行的。
熟悉强制式编程的人常常会忘记,声明式指令在配置里面的位置并不影响它的执行时间点。来看下面的配置:
allow 1.2.3.4;
deny all;
location /hello {
return 200 'Hello World!';
}
location / {
origin_pass my_origin;
}
尽管访问控制指令 allow
和 deny
被放在 server 层,location
指令块之前,它们仍然会在请求完成匹配 location 之后执行,也在所有的 rewrite 模块指令(包括 return
)执行之后。因此,任何对 "/hello" 的请求都会收到状态码 200 并且访问控制指令不会执行。
新用户常犯的一个错误是试图把 $upstream_*
变量放进 if
指令的条件里,希望根据源站的响应来改变 Nginx 的行为。然而, if
指令是在请求处理流程的早期执行的,此时上游请求还没有被送往源站,所以只有从请求里面提取的变量有值,所有的 $upstream_*
变量都是空的。想根据源站的响应来控制 Nginx 的行为,正确的做法是使用我们为很多指令新增的 if()
参数。这个参数里面的条件是在指令即将被执行的时候才判定的。如果指令的执行时间点是在收到响应之后(流程图里面阶段 6 和 7),$upstream_*
变量就可以用在条件里。比如下面这个配置,当状态码是 404时,强制移除 "Cache-Control" 头并且缓存1 个小时:
origin_header_modify Cache-Control '' policy=overwrite if($upstream_status = 404);
proxy_cache_valid 404 1h;
Nginx 的一个强大的功能是对变量的支持。一些变量在请求处理的流程中持续地被更新,从而每个指令得到的变量值取决于这条指令执行的时间点。最典型的例子是变量 $request_time
,它返回从请求收到那一刻起到当前的时间差。考虑下面的这段代码:
set $req_time $request_time;
add_header X-req-time-1 $req_time;
add_header X-req-time-2 $request_time;
新用户可能会惊讶地发现两个响应头里面的值不相同。这是因为 set
是在流程的早期执行的,所以它得到的是 $request_time
的一个早期的“快照”并将其存在变量 $req_time
中。而 add_header
是在构造给客户端的响应头部的时候执行的,要晚一些,所以 X-req-time-2
得到的值会更大。但是这个值仍然不是处理请求的全部时间,因为随后传输响应正文会再花一定的时间。当响应很大时,用这种方式在头部里记录的时间值可能比整个处理时间小得多。