`
deyimsf
  • 浏览: 66703 次
  • 性别: Icon_minigender_1
  • 来自: 北京
社区版块
存档分类
最新评论

nginx中的脚本(理论篇)

 
阅读更多

按照常规的打法或者按照常规的思路,针对变量的开发,也就是我们在ngx变量实现原理中介绍的那样:定义指令、设计结构体保存指令值、设计方法支持变量插入、利用自带的方法创建注册变量、利用自带的方法获取变量值等等,利用这些基本知识或功能点来开发变量完全没有任何问题。

 

但当你带着这些知识点去看nginx源码的时候,你会发现,变量的实际实现方式与我们前面介绍的变量实现方式很难一一对应上,代码里面会多出一些带"script"字样的结构体和方法,无论是变量的解析还是变量的获取,似乎都跟带"script"字样的方法和结构体脱离不了关系。

 

最初我知道nginx本身支持一些语言的特性,所以当我看到"script"字样的时候,就想到了脚本语言,因此我猜测nginx的语言特性就是按照脚本语言实现的。而脚本语言在我的观念里一般都是像lua、python、javascript这样的语言,像C、Java、Go这样的计算机语言都不属于脚本语言。但是这两种形式的语言是如何界定的?为什么有这样的界定?而nginx又真的是按照我猜测的那样,用所谓的脚本语言形式来实现语言特性的吗?nginx具体又是如何实现这些语言特性的?通过阅读完本篇内容读者应该就会有一个较清晰的认识。

 

1脚本和传统语言的区别

    

我们通常所说的脚本,实际上指的就是脚本语言,它在维基百科是这么定义的:脚本语言是为了缩短传统的"编写、编译、链接、运行"过程而创建的计算机编程语言,一个脚本通常是解释运行而非编译。

 

按照维基百科的解释,本质上它也是一个计算机语言,但是它又不同于传统的像C、Go、Java这样的编译型语言,它是解释运行的,比如像javascript、python、lua这样的语言。

 

一个比较"傻一点"的识别脚本语言的方式是,看它是否支持在命令行直接输入;语句并执行。比如像lua这样的,如果你本机上有安装lua,你可以直接在命令行中编写并运行程序语句,例如:

 

$ lua

>print "hello world"

hello world

>print(3+5)

8

 

大部分脚本语言都支持这种命令行式的程序执行,当然这并不是一个绝对的判断方式。

 

另一个判断方式是:脚本通常是解释运行而非编译。基本上所有传统编程语言都会经历"编译"这个阶段,而脚本语言一般会简化掉这个步骤。下图是一个程序语言从编写到运行的简要流程:

 

可以看到从"抽象语法树"开始,向下有两个分支,而我们一般意义上所说的脚本语言走的是左边的分支,而编译型语言则走的是右边的分支。大部分情况下,用这种方式来识别脚本语言,比我们上面用的"傻一点"方式会更严谨一点,但是就目前计算机语言的发展趋势来看,这两者界线是越来越较模糊了。比如luajit,它会动态的把lua程序编译成机器语言后再执行;而python语言也会产生中间代码(所谓的字节码),然后再通过中间代码执行。

 

为了后续能够更好的理解nginx中的script实现,再简单介绍一下上图中几个主要模块的作用:

 

词法分析:作用是解析出源码中有效的"单词",比如有如下源码:

 

abc = 3+5  // 这是一个加法表达式

             

执行完词法分析后,该源码会被解析成5个"单词",分别是'abc'、'='、'3'、'+','5',双斜杠及其后面的文字属于注释,会被忽略掉。嗯,这就是听起来高大上的词法分析做的事。

 

语法分析:该模块用来接收词法分析中的单词流,用来检查输入的单词流是否符合语法定义(比如加法运算符两边不能有非数字参与等),这里说的语法类似于我们自然语言中的语法(比如主语、谓语、问句、祈使句之类的),但它比自然语言要严格很多,一般会用一种称作BNF范式(巴斯克范式)的形式来描述语言的文法(语法)结构。如果读者之前没有接触过这些概念,那么可以把这种文法结构简单的理解成是正则表达式,而语法分析的过程就可以理解成正则匹配的过程。

 

抽象语法树:这是一个非常关键的步骤,它会把合法的单词流用合适的文法转变成一颗语法树,比如下面两个表达式:

 

3+5*8

(3+5)*8

 

可以用如下两颗语法树来表示:

      

为了便于理解,我简化了这两个语法树,只画出了关键部分,它们分别对应上面的两个表达式。从图中可以看到,抽象语法树还可以区分出运算符的优先级。而对于这两颗语法树,我们通过后序遍历就可以计算出这两个表达式的值。 比如对第一个抽象语法树进行后续遍历应该是这样:

  1. 遍历到"+"号节点后先计算它左边的值,记过为3

  2. 然后计算"+"号右边的值,右边是一个"*"运算符

  3. 看到"*"运算符后,先计算它左边的值5,然后再计算右边值8

  4. 退回到"*"运算符,把左右两个值相乘后作为"+"号节点的右值40

  5. 最后退回到"+"号节点后,再把算好的左右值相加,最后得结果43

 

解释执行:解释执行的过程就是遍历抽象语法树的过程,遍历结束执行也就结束了。

 

翻译成中间代码或机器语言:把语法树转换成中间代码,比如java中的字节码指令;或者直接转换成机器语言,比如C语言中的机器指令。字节码用JVM(java虚拟机)来执行,机器指令直接用cpu执行。

 

以上是所谓脚本语言和传统语言从编写到运行的一个简化概述, 用来对比nginx中script完全够用了,觉得意犹未尽的同学可以去看一些编译原理方面的资料。

 

2nginx如何实现"script"

    

nginx使用script来表示它本身对语言的实现,但是吧。。。又没办法完全跟上一小节介绍的两个概念完全对应上,因此在nginx中我们把“script(脚本)”看成是一个简化语言(或微语言)的代名词,这样在看后面内容的时候会稍微好理解一点。

 

虽然nginx有语言的特性,但它毕竟不是一个完整的语言,所以对比传统语言的实现也就更为简单,但一些基本概念还是存在的,比如字节码指令、指令指针、指令执行过程给予栈或给予寄存器、执行指令的cpu或模拟器(比如jvm)等,结合这些基本概念,再看nginx的脚本实现会更容易一些。下面我们就来看一看nginx是如何对应这几个基本概念的。

 

2.1 关于指令

 

前面在讲到变量和变量实现的时候多次提到过"指令"这个词,不过此时的"指令"和之前的"指令"是完全不同的概念,之前的"指令"专指在nginx配置文件中用来设置nginx的工作行为的配置项,而我们此时提到的"指令"是专指计算机语言中的"指令"。在本篇内容中,我们用"配置指令"来表示nginx配置文件中的指令,还请读者留意。

  

在传统的构架上,指令用一个操作码(opcode)来指定该指令要做的动作,你可以把它理解成是编程语言中的方法或函数;操作码后面一般会跟着零个或多个操作数(operand),这个可以理解成是方法或函数的入参。

 

比如下面两个基于寄存器的汇编指令:

 

 

 movl    -20(%rbp), %edi

 addl    -24(%rbp), %edi

 

第一条指令表示将-20(%rbp)地址处的数据放到%edi这个寄存器中;第二条表示将-20(%rbp)地址处的数据和寄存器%edi中的数据相加,然后在放到%edi中。

 

再比如java中基于栈的字节码指令:

 

iconst_2

iadd

   

第一条表示将数字2压入栈;第二条表示从栈中弹出两个数字并相加,并将结果再压入栈。其中栈是这种指令集隐含的一个数据结构,它充当了给予寄存器指令架构中寄存器的角色。

 

在nginx中,它用C语言的结构体来模拟指令,用结构体中的字段来携带方法和入参,nginx中的指令一般都遵从如下形式:

 

 

typedef struct {

    ngx_http_scritp_code_pt  code;

    uintptr_t                 index;

    // 其它字段

} ngx_http_script_var_code_t;

 

   

以这个结构体作为一个整体,你可以把它理解成一条指令。其中的字段code指向的是一个方法,表示该指令可以完成的动作,字段index则可以理解成指令的操作数,当然如果有需要的话可以有多个操作数。

 

我们虽然把字段index和其它字段比喻成指令的操作数(入参),把code方法看成该指令可以完成某个行为的具体实现,但code方法的入参并不是这里的操作数,而是一个脚本引擎对象(ngx_http_script_engine_t)。实际上利用这个引擎,可以在code方法内部拿到整个指令结构体(比如ngx_http_script_var_code_t指令结构体),这样就可以保证在code方法内有足够的指令信息可以用。关于这部分知识的具体内容,在后续讲到脚本引擎的时候会做详细描述,这里不再赘述。

  

除了上面介绍的,nginx中的指令还有一些其它的基本约束和特征,了解这些规则后再阅读相关内容,对初学者来说会相对轻松很多。好,闲话少说,下面我们就来看一下这些基本规则是什么:

 

  • 所有指令(指令结构体)都以code_t结尾,通过这个规则你可以很容易的辨别出那些结构体是用来模拟指令的。

  • 代表指令动作的字段code必须在指令结构体的第一个位置,算是一个潜规则,可以方便脚本引擎快速识别指令方法,也可以简化代码,等后续介绍完脚本引擎后读者会有一个更清晰的认识。

  • 代表指令动作的code方法一般都以code结尾,并且以ngx_http_script_engine_t(脚本引擎)为入参。比如ngx_http_script_copy_len_code()方法,主要用来计算文本值长度。

  • nginx中的所有指令相关的信息都放在ngx_http_script.h和ngx_http_script.c这两个文件中。

     

以上是一些比较宽泛的规则和特征,为了避免过早的陷入细节,其它一些更细的或者特殊的规则等遇到的时候再详细介绍。

 

2.2关于栈、指令指针和栈大小

常见的指令执行有两种方式,一种是给予栈的(比如java中的字节码指令),另一种基于寄存器(比如C最后编译成的机器指令),这里我们重点介绍一下基于栈的指令运行方式,因为nginx的指令运行也是给予栈的。

 

2.2.1一个例子

这里我们先使用前面提到的java字节码来举个例子,看一下给予栈的指令是如何工作的,通过这个例子来引出本小节要介绍的一些基本概念。

 

假设现在有如下指令序列(其中冒号左边是指令索引,右边是指令本身):

 

 

12: iconst_2

13: iadd

 

假设当前栈的状态为如下(top值为2):

    

  

目前,除了上面介绍的指令序列和栈之外,如果想运行这段指令序列,还需要引入一个变量,用来标识下面应该执行那条指令了,这个变量一般被称作指令指针(Instruction Pointer简称Ip),存放的是下一次要执行的指令地址,在我们这个例子里,该变量值为12,也就是说下一个要执行的指令是iconst_2。

 

现在我们就来看看在运行的过程中,栈和指令指针是如何变化的:

1.因为此时ip值为12,所以把整数2压入栈顶(这是iconst_2指令要做的事)后结果如下:

    

2.Ip指向下一条指令地址,也就是iadd指令所在的位置13

3.从栈中弹出两个整数值相加,然后将结果再压入栈(iadd指令要做的事)

    

4.Ip指向下一条指令地址

 

好,例子就到这里,下面我们就来看看从这个例子里面引出的一些概念。

 

2.2.2nginx中的栈

从上面的例子可以看到,栈就是一个先进后出的队列。具体到nginx,它使用一个ngx_http_variable_value_t类型的数组来模拟栈结构,形式如下:

 

ngx_http_variable_value_t  *sp;    

 

很简单,就是一个指针类型的数组,通过增减指针来模拟入栈出栈,而它的运作方式也基本就是例子里面展示的那样。

 

另外从nginx对栈的实现方式来可以看到,nginx中的栈只存一种数据类型,即我们之前提到过的ngx_http_variable_value_t结构体。这个相比其它语言来说,是非常非常简化了。

 

2.2.3nginx中的指令指针

在上面的例子中已经提到,指令指针是用来存放下一个指令地址的,在我们的例子中,这个"地址"指的是指令的索引值,而在nginx中它算得上是名副其实的地址。

 

nginx使用一个u_char类型指针来存储指令地址,具体定义如下:

 

u_char  *ip

 

在使用的过程中,ip变量实际存放的是每个指令结构体的起始地址(当然实际指令并不是u_char类型的,只是为了后续方便移动指令地址),指令在执行完毕后,我们把这个ip变量移动到下一个指令地址就可以了。

 

但是怎么移动指针呢?我们假设nginx中所有指令大小是都是固定的,比如每个指令占用10个字节,此时移动到下一个指令只需要把ip变量加10(因为ip是u_char类型指针)就可以了,或者干脆更改一下ip的变量类型,比如:

 

instruction *ip

 

其中instruction假设正好占10个字节,此时再移动到下一个指令时,ip变量直接加一就可以了。

 

遗憾的是nginx中指令指针是占一个字节的u_char类型,并且用来模拟指令的结构体的大小也是不一样的,所以每次移动到下一个指令时,需要实时计算出当前指令所占字节个数,然后用它跟当前ip值相加。因为只有指令自己最清楚自己占多少字节,所以具体操作由当前指令结构体中的code方法完成。

2.2.3栈大小

另外一个例子中没有提到的概念是"栈深度"或者"栈大小",用来表示栈最多可以容纳多少个元素。

 

大部分情况下,当你写完代码的那刻起,每个方法使用的栈大小就已经确定下来了,它并不是一个运行时动态值,而是一个在编译(或者代码解析)时计算出的值。假设我们有一个编译好的java字节码文件Demo.class,可以使用如下命令来观察这个类中每个方法的栈大小:

 

javap -verbose Demo

   

命令成功执行后,你会看到在每个方法下面都有一个"stack=xx",其中xx就是执行这个方法所需要用的栈大小。

 

nginx脚本中也有这样一个对应的概念,只不过它的实现方式就更简单粗暴了,就一个固定值,在ngx_http_rewrite_merge_loc_conf()方法中设置。它不像其它计算机语言(比如java)那样,每个指令序列(比如java中的某个方法)都有一个不同栈大小,nginx中所有指令序列都使用同一个固定值10来作为栈大小,并用它来申请内存空间。你不需要担心栈溢出,因为就目前nginx的实现来看,10个也是绰绰有余了。当然,如果将来你自定义的指令需要更灵活的控制栈大小,也是可以自己定制的。

 

2.3模拟执行指令的cpu

真正的机器指令是需要物理cpu来执行的,这种指令有很多种叫法,比如"机器语言指令"、"机器码"、"本地码"等,是物理cpu可以直接识别和解读的数据。

 

每个编译好的程序,最终落到磁盘上后都是一段有序的物理指令集,程序的运行过程就是cpu对这些指令读取和执行的过程,在这个过程中,cpu相对于指令来说就是一个"机器",用来解读它们的机器。目前有些高级语言并不直接产生机器码,而是生成一种中间码(比如java中的字节码),然后再有一个虚拟机(VM)来解读这个中间码,这个虚拟机对于中间码来说就是"机器(cpu)",相当于在模拟物理cpu。

 

像其它高级语言用vm来模拟cpu执行自己的中间码一样,nginx也需要模拟出一个cpu来执行自己的指令结构体,不过因为nginx并没有支持太多的语言特性,所以它的模拟就简陋很多,它仅仅用了一个脚本引擎结构体(ngx_http_script_engin_t)和一个while循环,它的执行过程也是非常简单的:

 

1.每一段指令序列在开始执行之前,需要先创建一个脚本引擎结构体:

e = ngx_pcalloc(r->pool, 

    sizeof(ngx_http_script_engine_t));

其中e是一个脚本引擎结构体的指针变量,ngx_pcalloc()是nginx内部用来分配内存空间的方法;r->pool是一个内存池,表示引擎所需内存空间从该池中分配。

 

2.引擎创建好之后就是栈空间的分配:

 

e->sp = ngx_pcalloc(r->pool, 

    rlcf->stack_size*sizeof(ngx_http_variable_value_t));

   

其中e->sp用来存放栈,rlcf->stack_size就是我们之前说的栈大小(可存变量的个数)。

 

3.把要执行的指令序列的首地址赋值给指针变量:

 

e->ip = rlcf->codes->elts;

   

其中rlcf->codes->elts中存放的就是这次要执行的整个指令序列。

 

4.粮草和兵马都准备好之后就可以开始作战了,具体作战方式如下:

 

while (*(uintptr_t *) e->ip) {

  code = *(ngx_http_script_code_pt *) e->ip;

  code(e);

}

   

其中code就是一个ngx_http_script_code_pt类型的函数指针,另外因为nginx的潜规则,每个指令结构体的第一个字段一定是ngx_http_script_code_pt类型,所以这里拿到e->ip后直接强转就行,然后再由code()方法来完成对应指令的动作,并更改指令指针值(e->ip),直到e->ip指向为空。

 

3中场总结

    

本篇内容主要从理论上陈述了nginx脚本的实现方式,先是介绍传统语言的实现方式,然后是nginx对这种方式的一个简化实现。下一篇实战篇我们会通过实际的nginx配置指令,来介绍nginx是如何编译并运行指令的,这些配置指令主要集中在rewrite标准模块中,我会挑几个重要的配置指令进行详细剖析。

 

 

关于nginx系列文章,感兴趣的同学可以关注这个目录:

     http://deyimsf.iteye.com/blog/2419833

我会持续对它更新,同时也欢迎其它同学提供好的写作案例和素材。

  • 大小: 40 KB
  • 大小: 19.9 KB
  • 大小: 5 KB
  • 大小: 6.3 KB
  • 大小: 5.1 KB
分享到:
评论

相关推荐

Global site tag (gtag.js) - Google Analytics