WEB编程基础

谈论WEB编程的时候常说天天在写CGI,那么CGI是什么呢?可能很多时候并不会去深究这些基础概念,再比如除了CGI还有FastCGI, wsgi, uwsgi等,那这些又有什么区别呢?为了总结这些这些WEB编程基础知识,于是写了此文,如有错误,恳请指正,示例代码见 web-basis

1 CGI

1.1 CGI原理

在说明CGI是什么之前,我们先来说说CGI不是什么。

  • CGI不是一门编程语言。它的实现对编程语言没有限定,你可以用python,php,perl,shell,C语言等。
  • CGI不是一个编程模式。你可以使用任何你熟悉的方式实现它。
  • CGI也不复杂,不需要你是一个编程老鸟,菜鸟一样可以愉快的写自己的CGI。

那么CGI到底是什么?CGI全称是Common Gateway Interface,即通用网关接口。我们可能对API(Application Programming Interface)会很熟悉,CGI就是WEB服务器的API。WEB服务器顾名思义,就是发送网页给浏览器的软件,浏览器称之为web client,WEB服务器是web server。浏览器作为客户端,它做的工作就是向WEB服务器请求文件(比如HTML文档,图片,样式文件以及任何其他文件等),一般的WEB服务器的功能就是发送存储在服务器上的静态文件给发送请求的客户端。

那么问题来了,有些时候,我们需要发送动态的数据给客户端,这就需要我们写程序来动态生成数据并返回,这就是CGI用处所在。需要强调的是,WEB服务器和客户端之间是不能交互的,CGI程序不能要求用户输入一些参数,处理并返回输出,然后要求用户继续输入,这也是CGI能够保持简单的原因之一。CGI程序每次只能最多获取一次用户输入,然后处理并返回一次输出。那么CGI如何获取用户输入呢?

CGI程序获取用户输入依赖浏览器发送请求的方式。一般来说,浏览器的HTTP请求会以GET或者POST的方式发送。浏览器使用HTML表单获取用户输入,HTML表单可以指定浏览器发送请求的方法是GET还是POST,它们不同在于GET方法会将用户输入参数作为URL一部分,而POST的优势在于:

  • 你可以发送更多的数据(URL长度是有限制的)
  • 发送数据不会在URL中被记录(例如你要发送密码放到URL中是不太安全的),也不会出现在浏览器的地址栏中。

那么CGI程序如何知道客户端请求是哪种方法呢?在WEB服务器加载你的CGI程序前,会设置一些环境变量让CGI程序知道去哪里获取用户输入数据以及数据大小。比如 REQUEST_METHOD这个环境变量会设置为客户端的请求方法如GET/POST/HEAD等。而CONTENT_LENGTH环境变量会告诉你应该从stdin中读取多少字节数据。CONTENT_TYPE则是告诉你客户端数据类型,是来自表单还是其他来源。

当CGI程序读取到了用户输入数据后,可以处理数据并将响应发送到stdout。CGI程序可以返回HTML数据或者其他类型数据如GIF图片等。这也是为什么你在返回数据前要先在第一行说明你返回数据的类型,如Content-type: text/html,然后加两个CRLF后(HTTP协议的规定),再返回真正的输出数据。

1.2 CGI实现

在现实应用中,WEB服务器常用的有nginx和apache。apache提供了很多模块,可以直接加载CGI程序,和上一章提到的方式基本一致。而nginx是不能加载CGI程序的,必须另外单独运行一个CGI程序处理器来处理CGI请求,先来看下CGI实现,WEB服务器代码cgi.c。编译并运行:

$ gcc -o cgi cgi.c
$ ./cgi

CGI程序如下,可以为C语言编写,如 cgi_hello.c,也可以是shell,python等其他语言,如 cgi_hello.sh。编译cgi_hello.c,放到cgi.c同一个目录下面。

$ gcc -o cgi_hello cgi_hello.c

使用C实现一个cgi服务器,其实就是WEB服务器并附带调用cgi程序功能。根据URL中的路径获取cgi程序名,并执行该cgi程序获取返回结果并返回给客户端。注意,是在WEB服务器程序中设置的环境变量,通过execl执行cgi程序,cgi程序因为是fork+exec执行的,子进程是会复制父进程环境变量表到自己的进程空间的,所以可以读取环境变量QUERY_STRING。在浏览器输入 http://192.168.56.18:6006/cgi_hello?name=ssj(测试机ip为192.168.56.18)
可以看到返回 Hello: ssj

2 FastCGI协议

2.1 FastCGI原理

如前面提到的,nginx是不能直接加载CGI程序的,由此需要一个专门的CGI程序管理器,nginx通过unix-socket或tcp-socket与CGI程序管理器通信。如php常用php-fpm,python常用uWSGI等,不过它们的协议不同,php-fpm用的是fastcgi协议,而uWSGI用的是uwsgi协议。nginx对这两种协议都支持,nginx配置文件/etc/nginx/fastcgi_params/etc/nginx/uwsgi_params就是分别针对这两种协议的。

先来看看FastCGI协议。顾名思义,FastCGI协议不过是CGI协议的变种,不同之处仅仅在于WEB服务器和CGI程序的交互方式。CGI协议中WEB服务器和CGI程序是通过环境变量来传递信息,WEB服务器fork+exec来执行CGI程序,CGI程序将输出打印到标准输出,执行完成后即退出。而FastCGI做的事情几乎和CGI一样,不同点在于FastCGI是通过进程间通信来传递信息,比如unix socket或tcp socket。那么,如果只是这么小的不同,FastCGI协议的意义何在呢?FastCGI的意义在于可以让WEB应用程序架构完全变化,CGI协议下,应用程序的生命周期是一次http请求,而在FastCGI协议里面,应用程序可以一直存在,处理多个http请求再退出,大幅提升了WEB应用程序性能。

FastCGI协议是一个交互协议,尽管底层传输机制是面向连接的,但是它本身不是面向连接的。WEB服务器和CGI程序管理器之间通过FastCGI的消息通信,消息由header和body两部分组成。其中header包含的字段如下:

Version: FastCGI协议版本号,目前一般是1.
Type: 标识消息类型。后面会有提到。
Request ID: 标识消息数据包所属的请求。
Content Length: 该数据包中body长度

FastCGI主要的消息类型如下:

  • BEGIN_REQUEST:WEB服务器 => 应用程序,请求开始时发送。
  • ABORT_REQUEST:WEB服务器 => 应用程序,准备终止正在运行的请求时发送。常见情况是用户点击了浏览器的停止按钮。
  • END_REQUEST:应用程序 => WEB服务器,请求处理完成后发送。这种消息的body会包含一个return code,标识请求成功还是失败。
  • PARAMS:WEB服务器 => 应用程序,称之为“stream packet”,一个请求里面可能发送多个PARAMS类型的消息。最后一个body长度为0的消息标识这类消息结束。PARAMS类型消息里面包含的数据正是CGI里面设置到环境变量里面的那些变量。
  • STDIN: WEB服务器 => 应用程序,这也是一个“stream packet”,POST相关数据会在STDIN消息中发送。在发送完POST数据后,会发送一个空的STDIN消息以标识STDIN类型消息结束。
  • STDOUT: 应用程序 => WEB服务器,这也是一个“stream packet”,是应用程序发送给WEB服务器的包含用户请求对应的响应数据。响应数据发送完成后,也会发送一个空的STDOUT消息以标识STDOUT类型消息结束。

WEB服务器和FastCGI应用程序之间交互流程通常是这样的:

  • WEB服务器接收到一个需要FastCGI应用程序处理的客户端请求。因此,WEB服务器通过unix-socket或者TCP-socket连接到FastCGI程序。
  • FastCGI程序看到了到来的连接,它可以选择拒绝或者接收该连接。若接收连接,则FastCGI程序开始从连接的数据流中读取数据包。
  • 如果FastCGI程序没有在预期时间内接收连接,则请求失败。否则,WEB服务器会发送一个 BEGIN_REQUEST 的消息给FastCGI程序,该消息有一个唯一的请求ID。接下来的消息都用这个在header中声明的同样的ID。接着,WEB服务器会发送一定数目的PARAMS消息给FastCGI程序,当变量都发送完成时,WEB服务器再发送一个空的PARAMS消息关闭PARAMS数据流。而且,WEB服务器会将收到的来自客户端的POST数据通过STDIN消息传给FastCGI程序,当所有POST数据传输完成,一样也会发送一个空的STDIN类型的消息以标识结束。
  • 同时,当FastCGI程序接收到BEGIN_REQUEST包后,它可以回复一个END_REQUEST包拒绝该请求,也可以接收并处理该请求。如果接收请求,则它会等到PARAMSSTDIN包都接收完成再一起处理,响应结果会通过STDOUT包发送回WEB服务器,最终会发送END_REQUEST包给WEB服务器让其知道请求是成功还是失败了。

有人可能会有点奇怪,为什么消息头中需要一个Request ID,如果一个请求一个连接,那这个字段是多余的。也许你猜到了,一个连接可能包含多个请求,这样就需要标识消息数据包是属于哪个请求,这也是FastCGI为什么要采用面向数据包的协议,而不是面向数据流的协议。一个连接中可能混合多个请求,在软件工程里面也称之为多路传输。由于每个数据包都有一个请求ID,所以WEB服务器可以在一个连接中同时传输任意个数据包给FastCGI应用程序。而且,FastCGI程序可以同时接收大量的连接,每个连接可以同时包含多个请求。

此外,上面描述的通信流程并不是顺序的。也就是说,WEB服务器可以先发送20个BEGIN_REQUEST包,然后再发送一些PARAMS包,接着发送一些STDIN包,然后又发送一些PARAMS包等等。

2.2 FastCGI实例分析

测试环境配置和抓包

FastCGI实现方式很多,如PHP的php-fpm,或者比较简单的fcgiwrap,在这里,我用fcgiwrap这个比较简单的实现来分析FastCGI协议,验证上一节说的原理。

先安装fcgiwrap,可以源码安装,如果是ubuntu/debian系统也可以直接apt-get安装。通过/etc/init.d/fcgiwrap start启动fcgiwrap默认会以unix-socket方式运行,如果要改成tcp-socket运行,可以fcgiwrap -f -s tcp:ip:port这样运行。

# sudo apt-get install fcgiwrap

在测试的nginx配置的server段里面添加一行

include /etc/nginx/fcgi.conf;

其中fcgi.conf文件内容见 fcgi.conf

测试用的cgi程序都放在 /usr/share/nginx/cgi-bin目录下面。测试cgi程序为 fcgi_hello.sh

在浏览器输入http://192.168.56.18/cgi-bin/fcgi_hello.sh?foo=bar可以看到返回结果。

为了避免其他干扰,我没用tcp-socket运行fcgiwrap,这样为了抓unix-socket的包,需要使用socat这个工具。为了抓包,需要简单改下nginx的配置,将 /etc/nginx/fcgi.conf中的fastcgi_pass这一行修改下,如下所示。

# fastcgi_pass  unix:/var/run/fcgiwrap.socket;
fastcgi_pass  unix:/var/run/fcgiwrap.socket.socat;

reload nginx并在命令行打开socat命令

socat -t100 -x -v UNIX-LISTEN:/var/run/fcgiwrap.socket.socat,mode=777,reuseaddr,fork UNIX-CONNECT:/var/run/fcgiwrap.socket

此时,在浏览器输入http://192.168.56.18/cgi-bin/fcgi_hello.sh?foo=bar可以看到socat命令会有输出如下:

> 2018/01/30 06:16:42.309659  length=960 from=0 to=959
01 01 00 01 00 08 00 00 00 01 00 00 00 00 00 00  ................
01 04 00 01 03 92 06 00 0c 07 51 55 45 52 59 5f  ..........QUERY_
53 54 52 49 4e 47 66 6f 6f 3d 62 61 72 0e 03 52  STRINGfoo=bar..R
45 51 55 45 53 54 5f 4d 45 54 48 4f 44 47 45 54  EQUEST_METHODGET
......
66 72 3b 71 3d 30 2e 36 00 00 00 00 00 00 01 04  fr;q=0.6........
00 01 00 00 00 00 01 05 00 01 00 00 00 00        ..............
--
< 2018/01/30 06:16:42.312909  length=136 from=0 to=135
01 06 00 01 00 61 07 00 53 74 61 74 75 73 3a 20  .....a..Status: 
32 30 30 0d 0a                                   200..
43 6f 6e 74 65 6e 74 2d 54 79 70 65 3a 20 74 65  Content-Type: te
78 74 2f 70 6c 61 69 6e 0d 0a                    xt/plain..
0d 0a                                            ..
52 45 51 55 45 53 54 20 4d 45 54 48 4f 44 3a 20  REQUEST METHOD: 
20 47 45 54 0a                                    GET.
50 41 54 48 5f 49 4e 46 4f 3a 20 0a              PATH_INFO: .
51 55 45 52 59 5f 53 54 52 49 4e 47 3a 20 20 66  QUERY_STRING:  f
6f 6f 3d 62 61 72 0a                             oo=bar.
00 00 00 00 00 00 00 01 06 00 01 00 00 00 00 01  ................
03 00 01 00 08 00 00 00 00 00 00 00 00 00 00     ...............

在ubuntu/debian上通过 sudo apt-get install libfcgi-dev后,可以在/usr/local/fastcgi.h中找到各个类型的消息的定义,接下来我们对照上一节说的FastCGI类型逐个分析下。

分析

WEB服务器和FastCGI之间通常的交互流程是这样的,下面会通过抓包详细分析。

{FCGI_BEGIN_REQUEST,   1, {FCGI_RESPONDER, 0}}
{FCGI_PARAMS,          1, "\013\007QUERY_STRINGfoo=bar"}
{FCGI_PARAMS,          1, ""}
{FCGI_STDIN,           1, "id=1&name=ssj"}
{FCGI_STDIN,           1, ""}

    {FCGI_STDOUT,      1, "Content-type: text/html\r\n\r\n<html>\n<head> ... "}
    {FCGI_STDOUT,      1, ""}
    {FCGI_END_REQUEST, 1, {0, FCGI_REQUEST_COMPLETE}}

WEB服务器发送给FastCGI程序的数据包:

  • 第一个消息是 BEGIN_REQUEST,可以看到第1个字节为01,也就是version为1,第2个字节为01,即消息类型是 BEGIN_REQUEST,接着3-4字节0001是requestId为1。再接着5-6字节0008是消息体长度为8。然后7-8字节0000是保留字段和填充字段。接着8个字节就是消息体了,9-10字节0001为role值,表示FCGI_RESPONDER,也就是这是一个需要响应的消息。11字节00为flag,表示应用在本次请求后关闭连接。然后12-16的5个字节0000000000为保留字段。

  • 第二个消息的第1个字节是01,也是version为1,第2个字节为04,表示消息类型为PARAMS。接着3-4字节为0001是requestId也是1。5-6字节0x0392消息体长度为914字节。后面7-8是0600位填充字段6字节。后面的为消息体内容,也就是QUERY_STRING, REQUEST_METHOD这些在CGI中设置到环境变量中的变量和值。接下来是PARAMS消息体。PARAMS消息用的是Name-Value对这种形式组织的数据结构,先是变量名称长度,然后是变量值长度,接着才是名字和值的具体数据。注意,名和值的长度如果超过1字节,则用4个字节来存储,具体是1字节还是4字节根据长度值的第一个字节的最高位来区分,如果为1则是4字节,如果为0则是1字节。如此可以分析PARAMS消息体了,头两个字节0c07表示名字长度为12,值长度为7,然后就是13个字节的变量名QUERY_STRING,7字节的值foo=bar,以此类推,接着的2个字节0e03就是名字长度为14,值长度为3,变量名是REQUEST_METHOD,值为GET...后续数据就是剩下的其他变量。最后面的6个字节000000000000是填充字节。

  • 第三个消息也是PARAMS,这是一个空的PARAMS消息。第1字节为01,第2字节为04表示PARAMS,3-4字节0001是requestId为1,5-6字节0000表示消息体长度为0,7-8字节0000表示填充和保留字节为0。

  • 第四个消息为STDIN,第1个字节01是version,第2个字节05表示类型为STDIN,接下来是3-4字节0001是requestId为1,5-6字节表示消息体长度为0,因为我们没有POST数据。后面7-8字节为0。(如果有POST数据,则STDIN这里消息体长度不为0,而它的消息体就是POST的数据,注意STDIN不是Name-Value对,它是直接将POST的数据字段连在一起的,如这样id=1&name=ssj)。到此,WEB服务器发送给FastCGI程序的数据包结束。

FastCGI程序发送给WEB服务器的数据包:

  • 第一个消息是 STDOUT 。第1个字节还是01为version,第2个字节06表示类型为STDOUT,接着3-4字节0001还是requestId,5-6字节0061为消息体长度97,7-8字节0700表示填充字段为7字节。接下来消息体就是返回的内容Status: 200\r\n...

  • 第二个消息还是 STDOUT,不过是空的STDOUT消息,用来标识STDOUT消息结束。

  • 第三个消息是 END_REQUEST。第1个字节01还是version,第2个字节03标识类型 END_REQUEST,3-4字节为requestId为1,5-6字节为消息体大小为8,7-8字节0000为填充字节长度。后面消息体内容为8个0字节。也就是说appStatus为0,protocolStatus也为0.其中protocalStatus是协议级的状态码,为0表示 REQUEST_COMPLETE,即请求正常完成。

// 消息类型定义
#define FCGI_BEGIN_REQUEST       1
#define FCGI_ABORT_REQUEST       2
#define FCGI_END_REQUEST         3
#define FCGI_PARAMS              4
#define FCGI_STDIN               5
#define FCGI_STDOUT              6
#define FCGI_STDERR              7
#define FCGI_DATA                8
#define FCGI_GET_VALUES          9
#define FCGI_GET_VALUES_RESULT  10
#define FCGI_UNKNOWN_TYPE       11
#define FCGI_MAXTYPE (FCGI_UNKNOWN_TYPE)

2.3 fcgiwrap分析

fcgiwrap用到了libfcgi库,libfcgi库提供了一些函数封装,以方便实现fastcgi管理器。fcgiwrap启动参数如下:

fcgiwrap -f -s unix:/var/run/fcgiwrap.socket -c 2

其中-s指定socket类型,若要用tcp-socket则用 -s tcp:ip:port。-c参数指定子进程数目,这里为2个。

fcgiwrap的核心代码如下,即先创建一个listen socket,然后将该socket通过dup2复制到文件描述符0,因为libfcgi库里面固定从fd 0来监听网络数据。prefork是创建参数指定数目的子进程数目,然后父进程通过pause()调用停止运行,接着每个子进程继续往下执行fcgiwrap_main()函数。

int main(int argc, char **argv) {
    fd = setup_socket(socket_url);
    prefork(nchildren);
    fcgiwrap_main();
}

fcgiwrap_main()核心代码如下,即不停的通过 FCGI_Accept()函数监听连接并处理请求。其中FCGI_Accept()函数是libfcgi库提供的,主要作用就是监听listen socket上的请求,然后根据fastcgi协议读取数据并解析为方便处理的结构,设置环境变量environ等,这样handle_fcgi_request()就能跟cgi程序一样通过读取环境变量还获取cgi文件名等内容。

static void fcgiwrap_main(void)
{
   ...... //略去了一些信号处理代码
   inherited_environ = environ;

    while (FCGI_Accept() >= 0 && !sigint_received) {
        handle_fcgi_request();
    }
}

handle_fcgi_request()就是处理请求的函数了,先是fork出子进程去执行CGI程序,将执行结果写入到管道中,而父进程则读取管道中的数据并返回给WEB服务器。这里有几点注意下:

  • 子进程中代码dup2(pipe_in[0], 0)执行后,子进程从pipe_in[0]作为标准输入,而父进程设置了 fc.fd_stdin = pipe_in[1],在函数fcgi_pass()中,会先调用子函数fcgi_pass_request()读取FCGI_stdin中的数据(也就是前一节提到的STDIN类型的消息,也就是POST中的表单数据)并写入fc.fd_stdin,也就是写入到了pipe_in管道中,则子进程此时就可以从标准输入中(因为前面的dup2)读取到数据。同理,子进程中代码dup2(pipe_out[1], 1)即说明子进程的标准输出会输出到管道pipe_out中,父进程在fcgi_pass()中同理可以通过管道读取到子进程的运行输出结果(这里fcgi_pass()使用了select()方式来轮询fd_stdout和fd_stderr文件描述符)。父进程读取到输出结果后,返回STDOUTFCGI_END_REQUEST消息给nginx服务器,完成本次请求。
static void handle_fcgi_request(void)
{
    int pipe_in[2];
    int pipe_out[2];
    int pipe_err[2];
    char *filename;
    char *last_slash;
    char *p;
    pid_t pid;

    struct fcgi_context fc;

    switch((pid = fork())) {
        case -1:
            goto err_fork;

        case 0: /* child */
            close(pipe_in[1]);
            close(pipe_out[0]);
            close(pipe_err[0]);

            dup2(pipe_in[0], 0);
            dup2(pipe_out[1], 1);
            dup2(pipe_err[1], 2);

            close(pipe_in[0]);
            close(pipe_out[1]);
            close(pipe_err[1]);

            close(FCGI_fileno(FCGI_stdout));

            signal(SIGCHLD, SIG_DFL);
            signal(SIGPIPE, SIG_DFL);

            filename = get_cgi_filename();
            inherit_environment();
            ...... //省略了检查文件是否存在和文件权限的代码

            execl(filename, filename, (void *)NULL);
            cgi_error("502 Bad Gateway", "Cannot execute script", filename);

        default: /* parent */
            close(pipe_in[0]);
            close(pipe_out[1]);
            close(pipe_err[1]);

            fc.fd_stdin = pipe_in[1];
            fc.fd_stdout = pipe_out[0];
            fc.fd_stderr = pipe_err[0];
            fc.reply_state = REPLY_STATE_INIT;
            fc.cgi_pid = pid;

            fcgi_pass(&fc);
    }
    return;

   ...... // 省略部分错误处理代码
    FCGI_puts("Status: 502 Bad Gateway\nContent-type: text/plain\n");
    FCGI_puts("System error");
}

实际应用中,像php-fpm(fpm是fastcgi process manager的意思)这种Fastcgi进程管理器,它会有master进程和worker进程,然后统一由master进程来分发请求管理worker,但是用的都是fastcgi协议,与本文分析的一致。

3 WSGI

3.1 WSGI规范

WSGI是Web服务器网关接口(Python Web Server Gateway Interface,缩写为WSGI)是为Python语言定义的WEB服务器和WEB应用程序或框架之间的一种简单而通用的接口,它与CGI类似,它不是一种框架,也不是模块,而是一种服务器(Web Server)和应用程序(Web Application)之间规范。WSGI协议实际上是定义了一种WEB服务器与WEB框架解耦的规范,开发者可以选择任意的WEB 服务器和WEB应用组合实现自己的web应用。例如常用的uWSGI和Gunicorn都是实现了WSGI Server协议的服务器(uWSGI还兼有进程管理器,监控,日志,插件,网关等功能),Flask是实现了WSGI Application协议的应用框架(当然Flask也自带有一个简单的WEB服务器,虽然我们通常是用nginx来处理静态文件),可以根据项目情况搭配使用。

WSGI分为两端:服务器/网关端 和 应用/框架端,服务器端调用应用端提供的可调用的对象。可调用对象可以是函数、方法、类或者实现了__call__方法的实例,这取决于服务器和应用选择哪种实现技术。除了纯正的服务器和应用,也可以使用中间件技术来实现该规范。

应用/框架

应用对象就是一个接受两个参数的可调用对象,它可以是函数,方法,类等。应用对象必须可以被多次调用。虽然我们称之为应用对象,但这并不意味着应用开发者要用WSGI作为WEB编程API。应用开发者可以继续使用已经存在的、高级框架服务去开发他们的应用。WSGI 是一个为框架开发者和服务器开发者准备的工具,应用开发者不需要直接使用 WSGI。

app.py是包含两个应用对象的示例,其中一个是用函数实现,另一个是用类实现。

服务器/网关

服务器/网关每次从 HTTP 客户端收到一个请求,就调用一次应用对象。为了便于说明,这里有个简单的CGI网关的例子 server.py,接收请求并调用应用对象app处理请求,实际负责处理请求的地方在handles.py中。

中间件:可以扮演两种角色

中间件是这样一种对象,它既可以作为服务器端跟应用端交互,也可以作为应用端跟服务器端交互。中间件组件通常具备下面几个功能:

  • 在重写了环境变量后,根据目标URL将请求路由到不同的应用对象。
  • 允许多个应用或框架在同一个进程中依次执行。
  • 通过转发请求和响应,支持负载均衡和远程处理。
  • 支持对内容进行后续处理。

中间件的存在对于接口的“服务器/网关”和“应用/框架”这两端是透明的,并不需要特别的支持。大多数情况下,中间件必须符合WSGI的服务器和应用程序端的限制和要求。

3.2 WSGI细节

规范细节

应用对象必须接受两个位置参数。为了便于说明,我们将参数命名为environ和start_response,当然你也可以用其他的名称。服务器/网关必须使用位置参数(非关键字参数)调用应用对象,如result = application(environ, start_response)

environ参数是一个字典对象,包含CGI风格的环境变量。这个对象必须是一个内置的Python字典(不是子类、UserDict等),并允许应用程序修改字典。字典还必须包含某些WSGI必需的变量(在后面的章节中介绍),还可能包含特定的服务器的扩展变量,按照约定方式进行命名。

start_response参数是一个可调用的对象,它接受两个必填的位置参数和一个可选参数。这三个参数通常命名为status,response_headers和exc_info。应用程序通常通过start_response(status,response_headers)方式调用它。

status参数是形式为“200 OK”这样的状态字符串,response_headers是描述HTTP响应头的(header_name,header_value)元组列表。可选的exc_info参数仅在应用程序捕获错误并尝试向浏览器显示错误消息时使用。start_response必须返回一个write(body_data)的可调用对象,它接受一个位置参数,该参数作为HTTP响应主体的一部分。

当被服务器调用时,应用对象必须返回一个产生零个或多个字节串的迭代,比如一个Python列表。如果应用程序返回的迭代对象具有close()方法,则服务器/网关在结束当前请求前必须调用该方法,无论请求是正常完成还是由于迭代期间的因为浏览器断开连接产生了应用程序错误而提前终止。调用close()方法是为了释放应用程序的资源。

环境变量

environ字典中必须包含CGI规范中定义的变量,包括下面这些:

  • REQUEST_METHOD
  • SCRIPT_NAME
  • PATH_INFO
  • QUERY_STRING
  • CONTENT_TYPE
  • CONTENT_LENGTH
  • SERVER_NAME, SERVER_PORT
  • SERVER_PROTOCOL
  • HTTP_Variables

除了CGI定义的环境变量之外,environ字典中还要包含下面几个变量:

  • wsgi.version:WSGI版本,元组(1,0)表示版本为1.0.
  • wsgi.url_scheme:URL模式,值通常为http或者https。
  • wsgi.input:可以读取HTTP请求体的输入流。(当被应用对象请求时,服务器/网关执行 read ,可以预读取请求体,缓存到内存或者磁盘中,或者用其他处理输入流的技术)
  • wsgi.errors:错误输出流。在许多服务器中,wsgi.errors通常是服务器的日志。
  • wsgi.multithread:应用对象如果支持多线程,则设置为true。
  • wsgi.multiprocess:应用对象如果支持多进程,则设置为true。
  • wsgi.run_once:如果服务器/网关希望应用对象在包含它的进程中仅执行一次这个请求,它的值为true。正常情况下,只有是基于CGI的网关才是true。

最后,environ字典也可能包含服务器定义的变量。这些变量只能使用小写字母,数字,点和下划线来命名,并且应该用该服务器/网关唯一的名称作为前缀。例如,mod_python可能会定义名称为mod_python.some_variable的变量。

输入流和错误流

服务器提供的输入流和错误流需提供如下方法:

方法
read(size) input
readline() input
readlines(hint) input
iter() input
flush() errors
write(str) errors
writelines(seq) errors

方法含义可以在标准库中查找,不过有几点要注意:

  • 服务器不能读取超过客户端指定的Content-Length的数据,而如果应用对象尝试读取超过Content-Length的内容,服务器应该模拟已经读到文件结束。服务器应该允许read()在没有参数的情况下被调用,并返回客户端输入流的其余部分。从一个空的或者已经读完的输入流读取时,服务器应该返回空字节串。

  • 服务器应该支持readline()的可选“size”参数,但是在WSGI 1.0中,服务器不支持该参数也是可以的。

  • 错误流一般不会重读,因此服务器/网关可以直接转发写入操作,而不需要缓冲。在这种情况下,flush()方法可能是没有操作的。但是,便携式应用程序不能假定输出是无缓冲的或者flush()是无操作的。如果他们需要确保输出已经被写入,他们必须调用flush(),而不管flush()具体做了什么。

  • 上表中列出的方法必须得到符合本规范的所有服务器的支持。符合本规范的应用程序不得使用输入流或错误流对象的任何其他方法或属性。特别是,应用程序不能尝试关闭这些流,即使它们拥有close()方法。

start_response

start_response是传递给应用对象的第二个参数是可调用对象(通常就是个函数),start_response(status,response_headers,exc_info = None)。 (与所有的WSGI可调用对象参数一样,这里必须是位置参数,不能用关键字参数)。start_response用于开始HTTP响应,它必须返回一个write(body_data)的可调用对象。

status参数就是"200 OK"或者"404 Not Found"这种状态字符串,由状态码和状态说明组成的字符串,由一个空格分开,没有周围的空格或其他字符(更多请参见RFC2616第6.1.1节)。字符串不能包含控制字符,也不能以回车、换行符或它们的组合结束。

response_headers参数是(header_name,header_value)元组的列表。它必须是一个Python列表类型,并且服务器可以任意改变其内容。每个header_name必须是一个有效的HTTP头部字段名称(由RFC2616,第4.2节定义)。header_nameheader_value不能包含任何控制字符(包括回车符或换行符)。服务器/网关负责确保向客户端发送正确的响应头部:如果应用对象省略了HTTP响应所需的头部,则服务器/网关必须添加它。例如,HTTP的Date和Server头部通常由服务器/网关提供。(注意:HTTP头部字段不区分大小写,因此在检查应用程序提供的头部时务必考虑这一点!)。禁止应用对象使用 HTTP 1.1的 hop-by-hop 特性或者头(如Keep-Alive),以及任何在 HTTP/1.0中等价的特性,或任何影响客户端到 web服务器端持久化连接的头部。

服务器应该在调用start_response的时候检查头文件中的错误,以便应用程序仍在运行时抛出错误。但是,start_response实际上并不传输响应头。相反,它必须将它们存储在服务器/网关上,以便仅在应用返回值时或者在应用首次调用write()时传输。响应头传输的这种延迟是为了确保缓冲和异步应用程序可以用错误输出来替换它们原来预期的输出,直到最后的可能时刻。例如,如果在应用程序缓冲区内生成正文时发生错误,则应用程序可能需要将status从“200 OK”更改为“500 Internal Error”。

exc_info参数(如果提供)必须是Python sys.exc_info()元组。只有在错误处理程序调用start_response的情况下,应用程序才能提供此参数。如果提供了exc_info,并且还没有发送HTTP头,start_response应该用新提供的头部替换当前存储的HTTP头部,从而允许应用程序在发生错误时“改变主意”。但是,如果此时已经发送了HTTP头部,则start_response必须再次抛出异常。

处理Content-Length头部

如果应用程序提供Content-Length头,则服务器不应该发送比Content-Length更多的字节,并且应该在发送完Content-Length字节后停止发送响应,如果应用程序此时还继续尝试写入,则应该抛出错误)。

如果应用程序不提供Content-Length头部,则服务器或网关可以选择几种方法之一来处理它。最简单的是在响应完成时关闭客户端连接。但是,在某些情况下,服务器/网关可以生成一个Content-Length头部来避免关闭客户端连接。注意:应用程序和中间件的输出中不能使用任何类型的Transfer-Encoding,例如chunking或gzip,这些传输编码是Web服务器/网关的职责

缓冲和流

一般来说,应用程序通过缓冲输出并一次发送全部数据来实现最佳吞吐量。在Zope等现有框架中,这是一种常见的方法:输出缓存在一个StringIO或类似的对象中,然后与响应头一起传输。

3.3 实现

总体上看来,WSGI服务器端就是接收请求,设置好环境变量,然后调用应用对象处理请求。而应用对象调用start_response函数设置头部(注意,此时还没有返回响应给客户端),然后应用对象返回一个可迭代对象(如Python的列表)给服务器端。服务端对应用对象返回的迭代数据进行输出,输出前会先调用send_headers()来发送响应头部。

完整的示例代码参见 web-basis-wsgi,代码基本来源于Python自带的wsgiref和http相关模块。

4 uWSGI和uwsgi协议

4.1 uWSGI安装配置

uWSGI是一个WEB服务器,它实现了WSGI协议、uwsgi协议、http协议等。这里要区分下:uWSGI是WEB服务器,而小写的uwsgi是协议。安装uWSGI的步骤比较简单,如下:

# sudo apt-get install build-essential python-dev
# sudo pip install uwsgi

然后我们可以编写一个简单的符合WSGI规范的python程序:

# foobar.py
def application(env, start_response):
    start_response('200 OK', [('Content-Type','text/html')])
    return [b"Hello World"]

运行:

# uwsgi --http :9090 --wsgi-file foobar.py

此时,我们就可以在浏览器输入 http://127.0.0.1:9090来访问了。指定参数--http则是以HTTP服务器方式运行,在实际项目中,通常会以socket的方式运行,nginx负责处理静态资源,动态请求则由nginx通过uwsgi协议与uWSGI服务器交互。

配置nginx如下:

# /etc/nginx/sites-enabled/uwsgi
server {
    listen 9090;
    location / {
        include uwsgi_params;
         uwsgi_pass 127.0.0.1:3031;
    }
}

以socket的方式运行uWSGI如下(加了进程和线程数配置):

uwsgi --socket 127.0.0.1:3031 --wsgi-file foobar.py --master --processes 4 --threads 2

为了方便,可以将启动参数放到配置文件 config.ini中,然后 uwsgi config.ini即可。

## config.ini示例
[uwsgi]
uid = nobody
gid = nogroup
socket = 127.0.0.1:3031
chdir = /home/vagrant/project/uwsgi
wsgi-file = foobar.py
processes = 4
threads = 2

如果nginx里面配置的是proxy_pass http://127.0.0.1:3031,则此时需要将uwsgi以 http-socket的方式运行,即

uwsgi --http-socket 127.0.0.1:3031 --wsgi-file foobar.py --master --processes 4 --threads 2 

4.2 uwsgi协议

前面我们分析过fastcgi和wsgi协议,而uwsgi是uWSGI独有的用于与WEB服务器通信的协议,它是一个字节协议,可以传输任意数据类型,nginx也已经支持uwsgi协议,如我们前面用到的uwsgi_pass。

uwsgi协议的包头如下:共32位,前面8位为标识,中间16位是数据包大小,最后8位也为标识。我这里分析的是modifier1为0的情况,即数据包为WSGI变量,datasize为WSGI块变量大小(不包括请求体)。其他选项详细含义可以参见 uwsgi-protocol.

struct uwsgi_packet_header {
    uint8_t modifier1;
    uint16_t datasize;
    uint8_t modifier2;
};

struct uwsgi_var {
    uint16_t key_size;
    uint8_t key[key_size];
    uint16_t val_size;
    uint8_t val[val_size];
}

实例分析:

以前面例子说明,我们使用tcpdump命令查看nginx和uWSGI之间的通信包

tcpdump -i lo port 3031 -n -X -vvvv

运行 curl -i http://127.0.0.1:9090
可以看到如下输出:

 127.0.0.1.36705 > 127.0.0.1.3031
    0x0000:  4500 0193 9511 4000 4006 a651 7f00 0001  E.....@.@..Q....
    0x0010:  7f00 0001 8f61 0bd7 441d 5d4e c7f6 50e7  .....a..D.]N..P.
    0x0020:  8018 02ab ff87 0000 0101 080a 0009 c842  ...............B
    0x0030:  0009 c842 005b 0100 0c00 5155 4552 595f  ...B.[....QUERY_
    0x0040:  5354 5249 4e47 0000 0e00 5245 5155 4553  STRING....REQUES
    0x0050:  545f 4d45 5448 4f44 0300 4745 540c 0043  T_METHOD..GET..C
    ...
    
127.0.0.1.3031 > 127.0.0.1.36705
   ...
    0x0030:  0009 c842 4854 5450 2f31 2e31 2032 3030  ...BHTTP/1.1.200
    0x0040:  204f 4b0d 0a43 6f6e 7465 6e74 2d54 7970  .OK..Content-Typ
    0x0050:  653a 2074 6578 742f 6874 6d6c 0d0a 0d0a  e:.text/html....
    0x0060:  4865 6c6c 6f20 576f 726c 64              Hello.World

除去前面ip和tcp包头,可以看到实际内容从 005b 0100开始,即uwsgi包的长度为 0x015b共347字节,后面则是实际内容,如QUERY_STRING这个变量key长度为12(0c00),后面紧跟key,而value长度为0(0000),没有内容。后面REQUEST_METHOD的key长度为14(0e00),value为GET,长度为3(0300),依此类推,跟fastcgi协议有点相似,这些变量来自/etc/nginx/uwsgi_params中定义和nginx添加的HTTP_USER_AGENT等HTTP变量。uWSGI发送给nginx的响应报文则是标准的HTTP响应。

4.3 uWSGI与应用框架组合使用

uWSGI可以与Flask,Django,web2py等框架组合使用,在我们的实际项目中,架构通常是nginx + uWSGI + Flask,即静态请求由nginx处理,动态请求转发到uWSGI,然后组合使用Flask框架来编写业务逻辑,当然里面通常还会用到gevent等。

uWSGI与Flask组合使用也很简单。因为Flask框架将WSGI的可调用应用对象暴露出来了,我们只要在启动参数中指明入口的app即可。先安装flask模块:

# sudo pip install flask

然后编写flask程序如下:

from flask import Flask
app = Flask(__name__)

@app.route('/')
def index():
    return "<span style='color:red'>I am app 1</span>"

然后启动uWSGI(比之前只是多一个 --callable 参数):

uwsgi --socket 127.0.0.1:3031 --wsgi-file flaskapp.py --callable app --processes 4 --threads 2

5 总结

从CGI,FastCGI,WSGI到uWSGI,涉及内容很基础也很繁杂,本文只是抛砖引玉,说出了我对这些概念的基本理解,如有错漏,恳请指正。实际项目中用到的架构如 nginx + uWSGI + Flask (配合gevent,mysql线程池等),后面有时间再做总结。

6 参考资料

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 211,884评论 6 492
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 90,347评论 3 385
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 157,435评论 0 348
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 56,509评论 1 284
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 65,611评论 6 386
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 49,837评论 1 290
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 38,987评论 3 408
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 37,730评论 0 267
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 44,194评论 1 303
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 36,525评论 2 327
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 38,664评论 1 340
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 34,334评论 4 330
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 39,944评论 3 313
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,764评论 0 21
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,997评论 1 266
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 46,389评论 2 360
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 43,554评论 2 349

推荐阅读更多精彩内容