博客

有点唐突,咋不更新古希腊语了?

动机

前些天做了一个简单的查单词网站:blurdy.com. 做的过程中呢,为了让网站运行于 HTTPS (TLS) 协议之上,我设置了一个 Nginx 程序,作为浏览器和服务器之间的代理:它与浏览器用加密的 TLS/SSL 信道 (HTTPS),而与本地的服务器用明文的 HTTP 通信。

另一件事儿是,最近在公司辅导实习生,他的网站需要一个单点登录 / single sign-on (SSO) 的功能,我顺带看了些使用 Nginx 模块的实现方式。而我又很久没有写 C++ / C 还是忍不住想自己试试。

总之呢,古希腊语教程先推迟更新,这篇文章就分享一下我学习 Nginx 的过程。

 

本文的范围

在学习过程的中呢,我先使用了一个网络教程,随后又在亚马逊网站买了陶辉先生的书。我觉得书和网络教程各有各的优点,就不多说了,我将尽量提取双方讲得好的地方,在文章里分享给读者。

需要提前说的是,Nginx 是一个较大的工程,难以在短篇幅里详尽地描述。这篇文章致力于提供一个简单的入门:我们将制作一个 Nginx 模块,并将它同整个 Nginx 项目一同编译,最终运行。

这个模块将做一件简单的事:检查 HTTP 请求中的 cookie 里的密码,然后做相应的跳转。

 

基础知识

这篇文章的讲解将基于 OSX,它也基本适用于 Linux,但不太适用于 Windows。

当然啦,如果读者本身有足够的编译知识,又对 Windows 的程序开发环境比较熟练,那么使用任何主流系统都是没问题的。

同时呢,我们将主要使用 C 语言开发 Nginx 模块,用 Shell 脚本作为辅助工具,所以也希望读者对C语言和 Linux 命令行工具有基本的掌握。

 

工具和库

在开发过程中,我们需要编译 Nginx 代码和我们的模块,而编译和链接的过程需要一些额外的代码库,所以使用 Linux 的读者们,可以先安装一下这些库和工具。

$ sudo apt-get install build-essential libpcre3-dev zlib1g-dev libcurl4-openssl-dev redis-server libhiredis-dev libhiredis0.10

之后我们会用到 redis 作为缓存,所以这里也包含了 redis 服务器程序本身和它的 C 语言工具库 hiredis-dev

在 OSX 系统上,我发现大部分库在系统中已经有了,所以只额外装了 redis 的库

brew install hiredis

每个人的系统配置不同,我安装完后,库的位置是这儿,你可以用 brew list 来查看

$ brew list hiredis
/usr/local/Cellar/hiredis/1.0.0/include

 

起步

在这个阶段,我们需要先把项目的基本文件都安放好,同时确保 Nginx 能够在本地编译和运行。

 

项目目录与源码

首先需要创建一个空文件夹 nginx_mod,专门给我们的项目使用,同时在它下面创建一个 buildvendor 文件夹,和 build 里的 nginx 文件夹。随后我们将把源代码 (nginx_1.18.0) 下载到 vendor 目录中,而 build/nginx 目录将用来保存项目的编译成果。

nginx_mod
├── build
    └── nginx
└── vendor
    ├── nginx-1.18.0

代码是在官方网站 nginx.org 公开的,我们可以从这里手动下载: http://nginx.org/download/nginx-1.18.0.tar.gz

使用 Linux 的读者,通过 curltar 这两个命令就可以下载并解压缩代吗啦:

cd vendor
NGINX_VERSION=1.18.0
curl -s -L -O "http://nginx.org/download/nginx-$NGINX_VERSION.tar.gz"
tar xzf "nginx-$NGINX_VERSION.tar.gz"

在写这篇文章时 (2021年7月),最新的 Nginx 版本是 1.18.0 所以我们就用这个版本。

 

运行配置

在 Nginx 运行时,它会阅读一个配置文件,来知道自己到底该做啥。

读者也可以先看看源代码包里自带的配置文件 nginx-1.18.0/conf/nginx.conf

作为起步,咱写一个极简的 Nginx 配置文件就好,放在项目根目录 nginx_mod 下,命名为 nginx.conf

http {
  server {
    listen 8888;
    location / {
      proxy_pass http://google.com;
    }
  }
}

简单来说,这个配置表示让 Nginx 在 8888 端口运行一个 HTTP 服务器,同时代理谷歌网站:把所有的 HTTP 请求转发给谷歌,再把结果返回给客户端。

注意噢,这里我们用了 http 而非 https, 因为后者需要配置 TLS / SSL 模块用于加密。

随着项目的演化,我们之后会再往这里添加新的配置。当然啦,熟悉 Nginx 的读者也可以自定义配置文件。

这里我说的是 Nginx 运行时所用的配置文件,接下来要讲的是 Nginx 编译时所用的配置。

 

编译配置

源代码的压缩包是自带一个 configure 的脚本,熟悉老旧 C++ / C 项目的读者可能比较了解:人们通常先运行 configure 脚本来检查系统的一些配置,设置编译项目所需要的环境变量,自定义编译方式等等。

configure 脚本支持许多参数 / parameters,咱也可以啥都不用,直接运行

cd vendor/nginx/nginx-1.18.0 && ./configure

但这里我们还是设置几个实用的参数:

--with-debug:运行程序时,将输出更具体的程序状态信息,通常它们将帮助我们 debug / 查错。

--prefix 相当于为所有与路径相关的参数增加一个前缀,比如这里我们用 prefix=build/nginx , 之后设置 --error-log-path=logs/error.log 的时候,就相当于告诉 Nginx 把错误日志发到 build/nginx/logs/error.log

项目编译后将生成一个的可运行文件,这个参数也间接决定了它的位置。

--conf-path 我们平时运行 Nginx 时都必须指定一个配置文件,这个参数指定了默认的配置文件的位置。

--error-log-path--http-log-path 两个参数设置了程序运行时日志文件的位置。

每次编译之前我们就先运行一次 configure 脚本:

cd ./vendor/nginx/nginx-1.18.0;
./configure \
  --with-debug \
  --prefix=$(pwd)/../../build/nginx \
  --conf-path=conf/nginx.conf       \
  --error-log-path=logs/error.log   \
  --http-log-path=logs/access.log

运行完 configure 脚本呢,它会自动生成一个 objs 目录,编译所需的所有东西(源代码与配置文件)都在这儿了。

 

编译和安装

配置脚本 configure 生成了 objs 目录下的所有文件,而咱要用的呢,就是其中的 Makefile 文件。

它可以被用来编译 Nginx 项目和把编译好的 Nginx 程序安装到指定位置。

使用 Makefile 的方式很简单,就是运用 make 命令:

make; make install

因为我们之前指定了 Nginx 配置文件的位置 conf/nginx.conf 所以我们也需要在这个位置创建并提供一个 nginx.conf 文件。

咱把刚创建的配置文件复制过去就好:

cp ./nginx.conf ./build/nginx/conf/nginx.conf

也有的朋友可能喜欢用软链接,也可以这样:

ln -sf ./nginx.conf ./build/nginx/conf/nginx.conf

 

运行

在编译和安装 Nginx 之后,可运行的 nginx 程序应该位于 build/nginx/sbin/ 目录下,咱这就运行一下:

$ ./build/nginx/sbin/nginx

如果没有输出(错误),那么 Nginx 已经在运行了,我们可以用 curl 命令发一个 HTTP 请求验证一下

$ curl -I localhost:8888
# 运行 curl 的结果
HTTP/1.1 302 Moved Temporarily
Content-Type: text/html
Content-Length: 160
Connection: keep-alive
Location: localhost

为什么是端口 8888 呢,因为源码包里自带的配置文件指定了 Nginx 将监听 8888 端口的 HTTP 请求。

同时呢,读者也可以通过在浏览器的地址栏里输入 http://localhost:8888 来查看 Nginx 页面。

 

模块定义

既然已经能够在本地编译运行 Nginx 了,这就开始写咱自己的模块!

每一个 Nginx 模块都有两个基本组成:配置文件 + 源代码。

 

配置文件

在配置文件里,我们会写明模块的名称,源代码的位置,和模块将怎样被使用。

所以咱的配置文件就是这么三行。我觉得可以把它看作一个定义了环境变量的 shell 脚本。

ngx_addon_name=ngx_http_auth_module
HTTP_MODULES="$HTTP_MODULES ngx_http_auth_module"
NGX_ADDON_SRCS="$NGX_ADDON_SRCS $ngx_addon_dir/ngx_http_auth_module.c"

变量 ngx_addon_name 定义了模块的名称,咱给它起名 ngx_http_auth_module

变量NGX_ADDON_SRCS 定义了所有模块的源代码的位置,用空格分开,这里加上了咱的源代码文件 ngx_http_auth_module.c

  • 而变量 $ngx_addon_dir 将指向我们的模块的根目录,它是 Nginx 在编译时会帮我们设置的,咱直接用就好。

最后呢,我们把自己的模块名加到 Nginx 的 HTTP 模块列表里,因为在入门阶段,HTTP 模块相对容易开发。

  • Nginx 还有几个主要的模块列表:事件驱动,HTTP 过滤等,它们也允许我们添加自定义模块。

这个配置文件,咱必须给它命名为 config,放在项目的根目录下。

刚入门的朋友可以把它看作 Nginx 的一个潜规则 / convention, 配置文件名称和位置是固定的,咱不能瞎改。

而熟悉源代码的读者(也很感谢你阅读我的文章)可以通过更改 configure 或者 Makefile 自定义配置文件的名称和位置。

 

源代码

害折腾半天,总算开始写代码了,咱这就创建一个文件 ngx_http_auth_module.c

这个文件名必须与模块配置里写的一致,不然编译时找不着。

首先呢,我们要加载几个 Nginx 的头文件,因为模块一定会用到某些 Nginx 代码库里的函数和结构。

然后呢,我们声明一下自己的模块,在 Nginx 项目中,所有模块 (在C语言中) 的类型都是 ngx_module_t

#include <nginx.h>
#include <ngx_config.h>
#include <ngx_core.h>
#include <ngx_http.h>

ngx_module_t ngx_http_auth_module;

注意噢,在这里我们只是声明了一个全局变量 ngx_http_auth_module 以方便我们在代码中使用,我们会在之后定义 / 初始化它。

熟悉 C++ / C 编程的朋友知道,正式项目里的头文件 (header) 和源代码 (source) 文件分开 - 大致相当于接口与实现的分离。

但作为一个新手起步的项目,咱把所有的模块代码都放到同一个源代码文件里,方便改动也方便讲解。

 

定义模块

具体来说呢,所有的模块都是像这样被定义的:创建一个 ngx_module_t 结构体,再往里填上模块的组成部分。

这里我把几个主要的成分定义了,其他的我们可以用 NULL 来忽略。

ngx_module_t ngx_http_auth_module = {
    NGX_MODULE_V1,
    &ngx_http_auth_module_ctx, // module context
    ngx_http_auth_commands,    // module directives
    NGX_HTTP_MODULE,           // module type
    NULL, // init master
    NULL, // init module
    NULL, // init process
    NULL, // init thread
    NULL, // exit thread
    NULL, // exit process
    NULL, // exit master
    NGX_MODULE_V1_PADDING
};

NGX_MODULE_V1: Nginx 代码库提供的一个宏 / macro,它定义了具体的一系列模块参数,咱用默认的 V1 / 第一版就好。

#define NGX_MODULE_V1 \
    NGX_MODULE_UNSET_INDEX, NGX_MODULE_UNSET_INDEX, \
    NULL, 0, 0, nginx_version, NGX_MODULE_SIGNATURE

&ngx_http_auth_module_ctx: 这是一个指针,指向了模块的环境 / 上下文 / Context 它也是一个结构体,随后会讲。

  • 在上下文 / 环境结构体定义了:在 Nginx 运行时,模块如何被配置和初始化等等。

ngx_http_auth_commands : 这是一个数组,它将告诉 Nginx 这个模块有哪些配置项 / directive, 如何解读配置文件,随后会讲。

比如在咱的配置文件中,listen 8888; 可以看作是 server 模块的一个配置项, proxy_pass http://google.com; 可以看作是 location 模块的一个配置项

NGX_HTTP_MODULE :指模块的类别,咱这个模块属于 HTTP 模块。还记得吗,Nginx 有多个模块类别。

随后的一系列结构体变量,它们允许我们定义一些函数,但 Nginx 程序处于运行和关闭的不同阶段时调用,我们先用 NULL 表示咱不需要。

  • 比如啊,我们希望 Nginx 的工作进程运行时执行一段代码,咱可以把代码放到一个函数中,然后把函数指针在第三个 NULL 的位置 / init process.

最后呢,因为 ngx_module_t 结构体还有许多变量,咱 / 很多人平时都用不上,咱就用一个宏 NGX_MODULE_V1_PADDING 来取代它们。

如果去看源代码,这个宏 / macro 是这样的

#define NGX_MODULE_V1_PADDING  0, 0, 0, 0, 0, 0, 0, 0

相当于把 ngx_module_t 中的其他变量都设为 0 或者 NULL。在 C语言中 NULL 和 0 是等价的 #define NULL ((void*)0)

 

配置列表

先讲模块定义里的 ngx_http_auth_commands 因为它比较简单,在入门阶段,我们先创建一个专门用来保存模块配置 / 参数得结构:

在命名的时候,我们采用了与 Nginx 一致的方式,在末尾加上 _t 表示这是一个自定义的结构

typedef struct {
    ngx_str_t cookie_name;
} auth_main_conf_t;

这里只有一个参数 cookie_name 我们之后可以继续加。需要注意的是,我们尽量用 Nginx 自带的数据结构,比如 ngx_str_t 来表示字符串,而非 const char*

  • 因为在不同操作系统 / CPU 架构上,所需要用的数据结构 / 类型可能不同,所以在代码里我们就用 Nginx 提供的壳儿类型。
  • 这样呢,当编译发生时,这些壳儿类型会(根据操作系统 / CPU架构)被替换成具体的结构 / 类型。

然后呢,我们就可以定义模块得配置列表啦,它是一个 ngx_command_t 数组,所以每个元素又是一个结构体,代表一行具体的配置。

static ngx_command_t ngx_http_auth_commands[] = {
  {
        ngx_string("auth_cookie_name"),
        NGX_HTTP_MAIN_CONF | NGX_CONF_TAKE1,
        ngx_conf_set_str_slot,
        NGX_HTTP_MAIN_CONF_OFFSET,
        offsetof(auth_main_conf_t, cookie_name),
        NULL // 一个指针,指向创建配置过程中可能用到的其他东西,当我们不需要,就设为 NULL
    },
  ngx_null_command
};

结构体 ngx_command_t 的第一个变量是配置项的名称,用一个字符串表示,咱给它起名 auth_cookie_name.

第二个变量表示:这个配置项能出现在配置文件 nginx.conf 的什么位置和它最多有几个数值

  • 我们这里用 NGX_HTTP_MAIN_CONF 表示它应该在 http {} 里面
  • 而宏 NGX_CONF_TAKE1 表示这个配置只会有一个参数数值
  • 我们用比特操作 | (并集 / AND) 来合并它俩

接下来,第三个变量是一个函数,用来设置配置项的数值,因为我们的配置项类型只是一个字符串,用 Nginx 提供的 ngx_conf_set_str_slot

  • 如果是整数,就用 ngx_conf_set_num_slot 其他类型等需要 / 用到时再说。

读者可以这么想象,在 Nginx 程序中有一个HTTP配置结构体,它里面有三个具体的结构体:main, server 和 location,分别存储配置文件的不同部分:

http {
 # 我们打算把模块的配置 / 参数放在这个块儿里 (而不是server或者location中)
 server {
 }
 location / {
 }
}

ngx_command_t 的第四个变量上,我们用 NGX_HTTP_MAIN_CONF_OFFSET

它表示:如果使用者想在 nginx.conf 中为我们的模块做一些自定义的配置,配置项应该加在 http 这个块儿里。

下一个 offsetof(auth_main_conf_t, cookie_name) 表示 Nginx 在解析完这个配置的参数后,应该把数值存在配置结构体的哪个位置

  • 而在这里,位置是用一个内存偏移量来表达的,所以我们用 C语言的 offsetof 这个宏。

使用宏 offsetof 是因为针对不同操作系统 / CPU架构,结构体的内存分布有可能不同,它的定义是这样的:

#define offsetof(t, d) __builtin_offsetof(t, d)

__builtin_offsetof 将由编译器来定义,在编译过程中,这个宏会被替换成具体的实现方式 / 函数。

结构体 ngx_command_t 的最后一个变量是一个指针,指向创建配置过程中可能用到的其他东西,我们不需要,就设为 NULL.

 

定义整个 ngx_command_t 列表时,必须用一个空结构体来标注结尾,就像定义字符串时用 '\0' 作为结尾那样。

因为每个开发者都需要做这个事,Nginx 代码库提供了宏 ngx_null_command 在源代码里,它定义了一个空的 ngx_command_t 结构体:

#define ngx_null_command  { ngx_null_string, 0, NULL, 0, 0, NULL }

 

上下文

还记得吗,除了配置列表外,在 ngx_http_auth_module 结构中的另一个重要变量是上下文结构指针:它指向我们自定义模块的上下文结构。

上下文 / Context 结构体定义了 Nginx 运行时,模块如何被配置和初始化等等。

那么我们的上下文变量 ngx_http_auth_module_ctx 是怎么定义的呢,其实它也就包含了一系列的函数指针:

static ngx_http_module_t ngx_http_auth_module_ctx = {
    NULL,               // 解析所有配置之前调用它
    ngx_http_auth_init, // 所有配置解析完之后调用它

    ngx_http_auth_create_main_conf, // 用于创建模块的配置
    NULL, // 用于初始化模块的配置
  
    NULL, // 用于创建 server 配置
    NULL, // 用于合并 server 配置
  
    NULL, // 用于创建 location 配置
    NULL  // 用于合并 location 配置
};

因为我们要做的是一个 HTTP 模块,所以用HTTP类型的上下文的结构体,也就是 ngx_http_module_t

这个结构体里的大部分变量咱还不需要用,设置为 NULL 就好。

有俩变量咱是需要的:用于初始化模块的 ngx_http_auth_init 和用于配置模块的 ngx_http_auth_create_main_conf两个函数。

咱接下来就讲讲它俩怎么写。

 

模块函数

之前的几个小节讲了怎么定义一个模块,读者可能也注意到,这个过程基本是在把不同的函数注册到不同的地方。

注册意味着:我们把一个函数指针传给 Nginx (框架) 这样它在加载我们的模块时,知道在 1) 哪个阶段该运行 2) 哪个函数来做 3) 某件事。

而在开发 Nginx 模块时,绝大部分时间会花在具体的模块函数上:这些函数实现一个模块的具体功能。

 

配置创建函数

先前在上下文结构中,我们注册了俩函数,先讲配置创建函数,因为它比较简单。

它将在 Nginx 启动阶段解析 nginx.conf 时被调用,来生成一个结构体,之后可以被用来存储模块的配置。

与 Nginx 注册的函数,必须满足对应的类型 / 签名要求。对于这个函数呢,它的要求就是:

1 - 接受一个 ngx_conf_t * 参数 - 它提供了当前模块所在语境的配置,我们的模块属于 HTTP 模块,所以这个参数将指向一个 ngx_http_conf_ctx_t 结构体。

2 - 返回 void 类型指针,指向模块的配置结构体。

static void* 
ngx_http_auth_create_main_conf(ngx_conf_t * cf) {
    auth_main_conf_t * conf = ngx_pcalloc(
      cf->pool, sizeof(auth_main_conf_t)
    );
    if (conf == NULL) {
        return NULL;
    }
    // 给我们的配置参数初始化一个默认的字符串值,叫 "auth_token"
    conf->cookie_name = ngx_string("auth_token");
    return conf;
}

这里我们创建了一个配置结构体,并把它的指针返回(给 Nginx)

通常呢,一个模块会有许多的配置,开发者会在这个函数里给它们设置默认值。咱的模块比较简单,就一个参数。

在这个阶段,许多模块还会读取全局配置 ngx_conf_t 来调整自身模块的一些配置参数。

值得一说的是配置结构体是如何被创建的。

 

内存池

之前我们看到,几乎所有的基础数据类型 / primitive types 都有对应的 Nginx 壳儿,我们应该在模块里使用它们,而不是 C语言自带的。

如果想在运行时分配内存,我们并不用 C语言自带的 malloc 或者 calloc, 而是用 Nginx 提供的壳函数 ngx_pallocngx_pcalloc

调用这个壳函数,有一个需求,就是要提供一个 Nginx 内存池结构体 (的指针),这里我们通过参数 ngx_conf_t 间接获得。

对于模块而言,动态内存的来源并不重要,不论是来自内存池,还是来自系统实时分配。

为啥要用这个呢,用这个有啥好处呢,因为啊 :

1)Nginx 启动时会向系统申请大块的内存 - 也就是多个内存池。程序在运行时若需要,可以直接从池子里拿,而不需要每次都调用系统内核的 API 去获得,这样做效率更高。

2)模块代码不需要自己管理(申请和释放)堆内存,Nginx 会在合适的阶段释放池里的内存。

 

初始化函数

在上下文结构体中注册的另一个函数就是这个 ngx_http_auth_init ,Nginx 在初始化我们的模块时将调用它。

它的函数签名 / 类型也必须遵照 Nginx 的规定:

1 - 接受一个 ngx_conf_t * 参数,它将指向一个 ngx_http_conf_ctx_t 结构体。

2 - 返回一个 ngx_int_t 整数,表示模块初始化的结果

函数名倒无所谓,当我们按照常规 / 习俗用前缀 ngx_ + 模块名 http_auth + 后缀 _init 表示函数的功能是初始化。

 

请求阶段

在一个模块的初始化函数中该做啥呢?其实也就是把具体的请求处理函数给注册到 Nginx 上。下一节我们再讲具体的请求处理函数。

处理一个 HTTP 请求的过程被 Nginx 划分成11个阶段 .. 取决于功能,一个模块可能被用在不同的阶段。

在每一个阶段,Nginx 都会运行对应的一系列函数,它们被存在一个列表中。这个列表就在程序初始化时(根据编译选项和模块配置)被创建。

因为咱写的模块将用来判断一个用户是否已经通过登录认证,所以我们选择把它注册到 NGX_HTTP_ACCESS_PHASE 这个检查访问权限的阶段。

这也是为什么我们之前,在定义模块结构体时,把类型设置为 NGX_HTTP_MODULE 这个类型,在定义配置参数时,我们也用了 NGX_HTTP_MAIN_CONF 来声明:这个模块的参数应该出现在配置文件的 http {} 块儿里边。

 

处理函数列表

那么怎么把函数注册到具体的 HTTP 请求处理阶段呢?我们需要先获得那个阶段的函数列表,

而为了获得这个列表,我们需要先获得 HTTP 的配置结构体 ngx_http_core_main_conf_t

所以第一行代码使用了 Nginx 库函数 ngx_http_conf_get_module_main_conf,其实它也是一个宏

#define ngx_http_conf_get_module_main_conf(cf, module) \
    ((ngx_http_conf_ctx_t *) cf->ctx)->main_conf[module.ctx_index]

这个配置结构体有一个数组变量叫 phases ,通过它我们能获得 NGX_HTTP_ACCESS_PHASE 阶段的所有函数。

随后呢,我们往这个阶段的函数列表里添加一个空位,之后再把咱的请求处理函数放进去:

static ngx_int_t
ngx_http_auth_init(ngx_conf_t *cf) {
    // 从全局配置中获得 HTTP 相关的配置
    ngx_http_core_main_conf_t *cmcf = ngx_http_conf_get_module_main_conf(
      cf, ngx_http_core_module
    );
    // 在 “HTTP 访问处理函数” 列表的末尾,创建一个空位 - 返回空位的内存地址 / 指向它的指针
	  ngx_http_handler_pt *h = ngx_array_push(
      &cmcf->phases[NGX_HTTP_ACCESS_PHASE].handlers
    );
    if (h == NULL) {
        return NGX_ERROR;
    }
    // 把咱的函数放到那个空位上
    *h = ngx_http_auth_handler;
    return NGX_OK;
}

 

Nginx 数组

像其他的数据类型那样,Nginx 库中也有一个数组结构体,叫 ngx_array_t. 为了往里添加元素 / 项目,我们必须用对应的辅助函数。

这里我们是往数组末尾添加东西,所以用 ngx_array_push,它会在数组的末尾创建一个空位,并返回它的内存地址 / 指针。

在进行错误检查后,我们通过指针把咱的函数放到那个空位上: *h = ngx_http_auth_handler;

 

Nginx 返回值

从汇编语言开始,人们就采用了返回值的方式,来表示一个函数的运行结果。Nginx 也采用了这个做法,用宏来定义了一系列的返回值。

如果操作成功呢,就返回 NGX_OK 失败的话就是 NGX_ERROR

当然啦,还有其他的返回值,比如 NGX_AGAIN 或者 NGX_DONE 等等,它们代表其他的含义,在特定的情境下是有用的。

 

请求处理函数

刚刚在初始化函数里,我们把一个叫 ngx_http_auth_handler 的函数加到了 HTTP 处理阶段的函数列表里。

  • 有的模块可能会注册多个函数,把不同的函数加到不同阶段的函数列表里。

这才是一个模块真正处理 HTTP 请求的地方。像之前一样,这个函数也有固定的类型 ngx_http_handler_pt,它的定义是这样:

typedef ngx_int_t (*ngx_http_handler_pt)(ngx_http_request_t *r);

也就是说呢,它需要接受 ngx_http_request_t HTTP 请求的结构体 (的指针) 然后返回一个数字,表示处理结果或者 HTTP 状态码。

static ngx_int_t
ngx_http_auth_handler(ngx_http_request_t *r) {
    // Nginx 有可能调用统一个处理函数多次,
    // 我们通过这个 flag 来确保我们的函数只运行一次。
    if (r->main->internal) {
        return NGX_DECLINED;
    }
    r->main->internal = 1;

    auth_main_conf_t * conf = ngx_http_get_module_main_conf(
      r, ngx_http_auth_module
    );
    ngx_str_t redirect_location = ngx_string("http://google.com");
    ngx_str_t cookie_name = conf->cookie_name;
    ngx_str_t auth_token;
    ngx_int_t lookup = ngx_http_parse_multi_header_lines(
      &r->headers_in.cookies, &cookie_name, &auth_token
    );
    // cookie 没找着儿
    if (lookup == NGX_DECLINED) {
				return redirect(r, &redirect_location);
    }
    // 在这个例子中,我们把密码明文放在代码里,实际不建议这么做
    ngx_str_t valid_token = ngx_string("valid");
    if (0 == ngx_rstrncmp(auth_token.data, valid_token.data, ngx_strlen(valid_token.data))) {
        return NGX_DECLINED;
    }
    return redirect(r, &redirect_location);
}

在这个函数中呢,我们主要做几件事:

1 - 确保当前的函数只运行一次

2 - 读取配置,获得将要检查的 cookie 的名称

3 - 从请求的头部中获得 cookie 并将它的数值跟期待值比较

4 - 如果请求并没有提供正确的 cookie,就跳转到其他页面 / 网站,我们额外写了个 redirect 函数做这事儿。

 

多次调用

在处理一个请求的不同阶段,Nginx 是有可能多次调用同一个处理函数的。

当我们的函数,只是检查请求 cookie 然后跳转,只需要运行一次就好了,所以借助请求结构体里的一个标记 / flag 变量。

if (r->main->internal) {
  return NGX_DECLINED;
}
r->main->internal = 1;

在 Nginx 中,返回值常量 NGX_DECLINED 并不是字面含义那样,把请求给拒绝了。

它表示当前模块把能做的都做了,请继续用其他模块处理当前请求

 

使用配置

还记得吗,我们的模块是可以自定义配置参数的,那么怎样在处理请求时使用配置呢?

通过 ngx_http_get_module_main_conf 函数,从请求的结构体里获得。

 

获得 cookie 的方式,也是通过请求结构体:r->headers_in.cookies, 熟悉 HTTP 的读者知道,cookie 也是一个头部段。

而这样获得的 cookies 的值呢,是一个 Nginx 数组 ngx_array_t,这意味着我们需要在里面查找一下我们感兴趣的 cookie.

好在 Nginx 提供了 ngx_http_parse_multi_header_lines 这个工具函数:

ngx_int_t lookup = ngx_http_parse_multi_header_lines(
  &r->headers_in.cookies, &cookie_name, &auth_token
);

这个函数不仅能解析,还能查找指定项的数值,所以在第二个参数传入了 cookie 的名称,第三项则是一个地址,用来存结果。

它的返回值表示是所找到的项在数组中的位置,如果没找到呢,它会返回 NGX_DECLINED

所以紧接着,我们就检查是否找到模块所期待的 cookie,没找到的话就跳转。

 

跳转

在 HTTP 协议中,在回应一个请求时,我们是可以通过特定头部段 / header 和状态码 (status code) 来表达 (请求 / 页面) 跳转的。

具体来说,我们需要把跳转页面的 URL 放到 Location 这个头部段上,然后设置状态码为 302.

static ngx_int_t
redirect(ngx_http_request_t *r, ngx_str_t *location) {
  ngx_table_elt_t *h = ngx_list_push(
    &r->headers_out.headers
  );
  ngx_str_set(&h->key, "Location");
  h->hash = 1;
  h->value = *location;
  return NGX_HTTP_MOVED_TEMPORARILY;
}

Nginx 已经定义好了所有的 HTTP 状态码,我们用对应的常量 / 宏就好,比如这里的 NGX_HTTP_MOVED_TEMPORARILY 代表 302.

怎么设置 HTTP 头部段呢?

通过请求结构体 ngx_http_request_t,我们能获得回应里的头部 r->headers_out.headers 它是一个 Nginx 定义的链表结构。

 

Nginx 链表

像之前的 Nginx 数组一样,链表结构也有对应的一组函数,比如 ngx_list_push 用来添加元素。

使用它的方式也相似,它会返回一个链表空位的内存地址 / 指针,随后我们再通过指针来设置具体数值。

这个链表结构的每一项都是一个 ngx_table_elt_t 结构体的指针,以后在用 Nginx 提供的散列表时还会用到它。

  • 我不知道为什么 Nginx 不额外定义一个给链表元素的结构体。

这个结构体被使用时(表示链表当前项有一个值),必须把它的 hash 变量设置一下。

然后我们把头部段的名称放在 key 上,再把具体的字符串值放到 value 变量上。

h->hash = 1;
ngx_str_set(&h->key, "Location");
h->value = *location;	

熟悉散列表数据结构的读者应该已经想到,这样的 ngx_table_elt_t 结构显然是给 linear probing 类型的散列表用的。

 

编译&测试

模块写好啦,现在咱可以试着编译一下,当我们也得把配置文件更新一下:

1 - 我们希望 HTTP 请求里有一个叫 auth_token 的 cookie, 里面存着用户的密码,我们的模块可以检查它的数值。

2 - 如果验证失败,我们的模块会把请求跳转到 google.com. 所以这里做一点改动:如果通过验证,请求跳转到 bing.com

http {
  auth_cookie_name "auth_token";
  server {
    listen 8888;
    location / {
      proxy_pass http://bing.com;
    }
  }
}

更新完我们把它复制过去:cp nginx.conf build/conf/nginx.conf

然后就可以编译啦,像之前那样,我们先运行 configure 再用 makemake install.

相比之前,这里我们多加了一个参数 --add-module=${mod_root} 告诉 Nginx 我们希望把这个目录下的模块也一起编译了。

mod_root=$(pwd)
./vendor/nginx-1.18.0/configure \
    --with-debug \
    --prefix=build/nginx \
    --conf-path=conf/nginx.conf       \
    --error-log-path=logs/error.log   \
    --http-log-path=logs/access.log   \
    --add-module=${mod_root}
make
make install

编译成功后,我么可以试着发一个 HTTP 请求,不带 cookie 的,咱可以直接用浏览器访问 localhost:8888 就好。

然后会看到页面跳转到谷歌主页。如果失败的话,可以看看日志文件 cat ./build/nginx/logs/error.log

然后我们再试一个携带密码 cookie 的请求:

$ curl -I  --cookie "auth_token=valid" localhost:8888
HTTP/1.1 301 Moved Permanently
Server: nginx/1.18.0
Content-Length: 0
Connection: keep-alive
Location: http://www.bing.com/

 

Redis

在一开始的时候,我们还特地安装了 redis 库,因为我想把 HTTP 请求的正确密码放在 redis 缓存里。

  • 然后在 Nginx 模块运行时,通过 redis 来获得密码,而不是把密码明文放在代码里。

写到这儿,我发现文章的篇幅已经比较大,而学习 Nginx 模块编程其实跟 redis 并没有什么关联。

这里就补上一段从 redis 获得数据的代码,读者也可以尝试一下把它与已有代码整合起来。

#include "hiredis/hiredis.h"

static ngx_int_t
get_expected_token(ngx_str_t* auth_token_name, ngx_str_t *auth_token) {
  const char *redis_ip = "localhost";
  const int redis_port = 6379;

  redisContext *context = redisConnect(redis_ip, redis_port);
  redisReply *reply = redisCommand(context, "GET %s", auth_token_name->data);
  if (reply->type == REDIS_REPLY_NIL) {
    return NGX_DECLINED;
  }
  ngx_str_set(auth_token, reply->str);
  return NGX_OK;
}

如果已经安装好 redis 程序的话,通过这两个命令可以启动 / 关闭 redis 服务器,它默认运行在 6379 端口。

redis-server start
redis-server stop

然后可以用 redis-cli 来添加缓存项。

 

回顾

啊,总算把所有的细节都写好了,写完发现编程的文章也挺不容易,比古希腊语教程还复杂一点。

我把代码和配置文件也打包上传了,读者可以从这里下载:nginx-example.zip

同时呢,Nginx 是相当大的一个工程,还有许多需要学习的地方。

也许之后有时间了,我还会再回到这个话题。