有点唐突,咋不更新古希腊语了?
动机
前些天做了一个简单的查单词网站: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
,专门给我们的项目使用,同时在它下面创建一个 build
和 vendor
文件夹,和 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 的读者,通过 curl
和 tar
这两个命令就可以下载并解压缩代吗啦:
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_palloc
和 ngx_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
函数,从请求的结构体里获得。
HTTP Cookie
获得 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
再用 make
和 make 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 是相当大的一个工程,还有许多需要学习的地方。
也许之后有时间了,我还会再回到这个话题。