零基础开发 nginx 模块

语言: CN / TW / HK

推荐学习资料:

本文大纲:

  • 简要介绍 Nginx 动态模块 。
  • 快速搭建简单 开发环境 ,拉取源码并编译 nginx 。
  • 简要介绍 nginx 模块 源码配置与目录结构 ,建立工程框架。
  • 简要介绍 nginx HTTP 模块结构,建立 一个 HTTP 空模块 框架代码。
  • 编写一个简单配置文件,支持以普通用户 测试运行 nginx ,方便后续开发测试。
  • 通过一个 hello world 示例简要介绍 Nginx 配置指令 。
  • 简要介绍 Nginx HTTP 请求处理器 。
  • 简要介绍 Nginx 热更新 (reload) 高级功能。
  • 吐槽与闲聊 。

Nginx 动态模块

早期版本的 nginx 如果要扩展功能,新增代码必须和 nginx 主体代码一起编译成一个二进制文件,这显然非常不方便。2016 年 nginx 1.9.11 终于开始支持动态模块 (Linux 下动态模块即 so 文件),nginx 1.11.5 起支持单独编译动态模块 (而不必同时编译 nginx 自身),同时引入支持开源版本 nginx 与 nginx plus 的二进制兼容性。下图清晰展示了这种结构。

image

nginx 使用 C 语言开发,C/C++ 构建工具众多,如手写 Makefile, GNU Autoconf, cmake 等,一些项目甚至专门为自己开发了构建工具,如 boost 库等。nginx 使用哪种构建工具呢?很遗憾,最后一种,自己开发。nginx 使用 shell 脚本维护了一套自动生成 Makefile 的构建脚本,类似简化定制版的 Autoconf 。构建脚本位于代码库 auto/ 目录下,C 源码则位于 src/ 目录下。

nginx 构建脚本同时也用来编译附加模块。

显然,在 nginx 模块中可以自由使用 nginx 主体代码提供的 API 。需要注意的是, 构建时的 nginx 版本必须与运行时的 nginx 版本精确匹配 ,否则 nginx 将拒绝加载。这大概是 nginx 作者懒得精心维护 API 二进制兼容性。不过 模块源码通常是兼容的 ,与不同版本 nginx 源码一起编译即可得到对应版本的动态模块 so 文件。

开发环境

nginx 所需开发环境非常简单,我使用 Ubuntu 18.04 ,使用下列命令即可安装所需最小依赖。

sudo apt-get update
sudo apt-get install build-essential libpcre3-dev zlib1g-dev -y

接下来确定目标 nginx 版本,可使用 nginx -v 查看 nginx 版本,如 Ubuntu 18.04 自带 nginx 版本为 1.14.0 。

$ nginx -v
nginx version: nginx/1.14.0 (Ubuntu)

获取目标 nginx 版本源码,可从 github 拉取。使用 -b 指定拉取版本,--depth 1 表示仅拉取 1 个提交,不要提交历史,这样可以快速完成拉取。

git clone -b release-1.14.0 --depth 1 https://github.com/nginx/nginx.git

在 nginx 代码仓库目录下执行如下命令即可构建生成 nginx 可执行文件。

auto/configure && make
  • auto/configure 脚本检查开发环境和所需依赖,生成 Makefile 脚本,如果有报错按提示修复后重试即可。
  • make 命令使用 Makefile 构建生成 nginx 可执行文件。
  • 默认在代码仓库目录下新建一个名为 objs/ 的目录作为构建目录,构建脚本自动生成的相关文件和最终编译生成的 nginx 可执行文件也在该目录下。

测试运行刚刚生成的 objs/nginx 可执行文件,结果如下。

$ objs/nginx -v
nginx version: nginx/1.14.0

至此,最简 nginx 开发环境准备就绪。

注意: 此 nginx 版本仅用最小依赖和最简配置构建,仅供开发测试动态模块时使用,不可替代生产环境的 nginx 版本。

源码配置与目录结构

模块源码在独立的文件夹下维护 (又称之为插件 addon)。模块源码目录下需提供一个名为 config 的 shell 配置脚本,提供模块信息。nginx 构建脚本将 ngx_addon_dir 变量设置为模块源码路径,并执行 config 脚本获取模块信息。

在 nginx 代码仓库旁边新建一个名为 nginx-hello-module 的模块文件夹,创建一个 config 脚本文件和一个 C 语言源码文件 hello_module.c,即得到一个最简单的模块示例,目录结构如下。

nginx/       # nginx 代码仓库
├── auto/    # nginx 构建脚本目录
└── src/     # nginx 源码目录, 其他文件夹暂未列出。
nginx-hello-module/    # 模块源码目录
├── config             # 模块配置脚本, shell 脚本
└── hello_module.c     # 模块源码文件

编写 config 配置脚本内容如下:

# vim: set ft=sh et:
ngx_addon_name=ngx_http_hello_module

ngx_module_type=HTTP
ngx_module_name="$ngx_addon_name"
ngx_module_srcs="$ngx_addon_dir/hello_module.c"

. auto/module
  • 插件名 ngx_addon_name 和模块名 ngx_module_name 设置为 ngx_http_hello_module 。
  • 模块类型 ngx_module_type 设置为 HTTP 。
  • 源码文件列表 ngx_module_srcs 设置为 $ngx_addon_dir/hello_module.c。注意: 源码路径必须添加 $ngx_addon_dir/ 前缀,构建脚本才能正确找到源码文件。
  • 语句 . auto/module 调用 nginx 提供的模块配置脚本,这条语句固定添加到 config 文件最后。

模块代码开发我们稍后再说,现在可以先建一个空源码文件 hello_module.c 。

在 nginx 代码仓库下执行如下命令,增加配置上述 nginx-hello-module 模块。

auto/configure --add-dynamic-module=../nginx-hello-module/

在 nginx 代码仓库下执行如下命令编译模块。

make modules

竟然编译成功了!得到动态模块文件 objs/ngx_http_hello_module.so 。但此时模块还不可用 (尝试加载此模块将报错),因为我们还没有写任何代码。

一个空模块

我们知道,一个 C 程序的入口是 main() 函数。而一个 nginx 动态模块的入口是一个 ngx_module_t 对象,其结构定义如下。

typedef struct ngx_module_s          ngx_module_t;

struct ngx_module_s {
    /* 私有字段 ... ... */

    void                 *ctx;
    ngx_command_t        *commands;
    ngx_uint_t            type;

    ngx_int_t           (*init_master)(ngx_log_t *log);

    ngx_int_t           (*init_module)(ngx_cycle_t *cycle);

    ngx_int_t           (*init_process)(ngx_cycle_t *cycle);
    ngx_int_t           (*init_thread)(ngx_cycle_t *cycle);
    void                (*exit_thread)(ngx_cycle_t *cycle);
    void                (*exit_process)(ngx_cycle_t *cycle);

    void                (*exit_master)(ngx_cycle_t *cycle);

    /* 扩展备用字段 ... ... */
};

除去私有字段和扩展备用字段,用户相关的字段可分为 3 个部分:

  • 模块类型 ngx_uint_t type 和模块类型特定的信息 void *ctx 。模块类型必须与 config 脚本配置的类型一致,本例即为 HTTP ,源码中用 NGX_HTTP_MODULE 表示。
  • 模块提供的指令列表 ngx_command_t *commands 。列表以 ngx_null_command 结尾,列表可以为空 (仅包含一个 ngx_null_command 结尾标记) 。
  • 其余为模块生命周期管理函数,可全部设置为 NULL 。

HTTP 模块对应的模块信息 (void *ctx 字段) 为 ngx_http_module_t 类型,可注册若干 HTTP 模块处理函数,可全部设置为 NULL 。

#define NGX_HTTP_MODULE           0x50545448   /* "HTTP" */

typedef struct {
    ngx_int_t   (*preconfiguration)(ngx_conf_t *cf);
    ngx_int_t   (*postconfiguration)(ngx_conf_t *cf);

    void       *(*create_main_conf)(ngx_conf_t *cf);
    char       *(*init_main_conf)(ngx_conf_t *cf, void *conf);

    void       *(*create_srv_conf)(ngx_conf_t *cf);
    char       *(*merge_srv_conf)(ngx_conf_t *cf, void *prev, void *conf);

    void       *(*create_loc_conf)(ngx_conf_t *cf);
    char       *(*merge_loc_conf)(ngx_conf_t *cf, void *prev, void *conf);
} ngx_http_module_t;

下面来编写 hello_module.c 源码,为简单起见,首先开发一个空模块吧。

首先引入 nginx 头文件,声明模块入口 ngx_module_t 对象,变量名必须为 config 脚本中配置的模块名,本例中即为

ngx_http_hello_module 。

#include <ngx_config.h>
#include <ngx_core.h>
#include <ngx_http.h>
extern ngx_module_t ngx_http_hello_module;

接下来设置 HTTP 模块信息 ngx_http_module_t ,相关处理函数全部设置为 NULL 。

static ngx_http_module_t ngx_http_hello_module_ctx = {
    NULL,       /* preconfiguration */
    NULL,       /* postconfiguration */

    NULL,       /* create main configuration */
    NULL,       /* init main configuration */

    NULL,       /* create server configuration */
    NULL,       /* merge server configuration */

    NULL,       /* create location configuration */
    NULL        /* merge location configuration */
};

指令列表 ngx_command_t[] 设置为一个空列表,仅包含 ngx_null_command 结尾标记。

static ngx_command_t ngx_http_hello_commands[] = {
    ngx_null_command
};

最后,定义模块入口对象 ngx_module_t 。开头私有字段使用 NGX_MODULE_V1 表示,结尾扩展备用字段使用 NGX_MODULE_V1_PADDING 表示。设置上述定义的 HTTP 模块信息 ngx_http_hello_module_ctx 和指令列表 ngx_http_hello_commands ,生命周期管理函数全部设置为 NULL 。

ngx_module_t ngx_http_hello_module = {
    NGX_MODULE_V1,
    &ngx_http_hello_module_ctx,            /* module context */
    ngx_http_hello_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
};

至此,一个空模块开发完成。这可以作为开发 HTTP 模块的初始模板,我们将在此基础上逐渐增加功能。

在 nginx 代码仓库目录下执行 make modules ,即可重新编译生成动态模块文件 objs/ngx_http_hello_module.so 。因为我们没有修改模块配置,没有添加或删除源码文件,所以不需要重新执行 auto/configure 配置脚本,直接执行 make modules 即可。

测试运行 nginx

在 nginx 代码仓库目录下新建一个测试配置文件 objs/nginx.conf ,内容如下:

# vim: set ft=nginx et:
daemon off;  # default on

pid objs/nginx.pid;
error_log stderr notice;

load_module objs/ngx_http_hello_module.so;

events {
}

http {

    access_log objs/access.log;

    server {
        listen 8080 default_server;
        return 200 "test\n";
    }

}
  • daemon off; 设置 nginx 进程不要后台化,保持前台运行,按 Ctrl+C 即可退出 nginx 。
  • error_log stderr notice; 错误日志直接输出到终端,方便测试运行时查看错误日志,设置日志级别为 notice 。
  • load_module objs/ngx_http_hello_module.so; 加载我们开发的动态模块 ngx_http_hello_module.so 。
  • listen 8080 default_server; HTTP 服务器监听 8080 端口,这样使用普通用户即可运行测试。
  • return 200 "testn"; HTTP 请求直接返回 "test" 字符串。

在 nginx 代码仓库目录下使用如下命令测试运行 nginx 。

objs/nginx -p "$PWD" -c objs/nginx.conf
  • -p "$PWD" 设置 nginx prefix 为当前目录。配置文件路径和配置文件中使用的相对路径使用相对于 prefix 的路径。
  • -c objs/nginx.conf 设置配置文件路径。

可看到 nginx 启动并打印日志,按 Ctrl+C 后 nginx 退出。此时我们的模块还是空模块,没有发挥任何作用。

Nginx 配置指令 - 世界你好

当我们学习一种新的开发技术时,第一个程序通常是 "hello world": 打印一条 "hello world" 语句,向世界问声好。第一次接触 nginx 开发时,我们不得不花时间做一些准备工作。现在,终于是时候张开双臂,说一声 "世界你好" 了。

我最早学习使用的是 Apache HTTP 服务器,其至今仍然是一款优秀强大的开源软件。一些团队因为特殊原因开始尝试新产品,俄罗斯程序员 Igor Sysoev 开发的 nginx 很快因其稳定性和高性能而声名鹊起。

最初学习使用 nginx 的感受是,nginx 的配置文件似乎比 apache 要简单友好一些 (在我对两者都不熟悉的情况下) 。nginx 的配置文件好像是一种脚本,所以 nginx 配置项被称作指令 (directive) 。没错,nginx 不只是一个 HTTP 服务器,还是一个被设计得简单小巧的脚本语言解释器,并支持开发添加新的指令。nginx 指令通常用于配置,我们称之为配置指令,换一种唬人的说法,叫做声明式指令。

现在我们设计一个 hello 指令输出 "hello world" 语句。

创建配置存储结构体

HTTP 配置分为 http/server/location 3 层结构。我们设计 hello 指令仅在最顶层 http {} 主区块 (block) 下使用和生效。HTTP 模块默认无配置存储空间,可设置 ngx_http_module_t::create_main_conf 函数创建主区块配置结构体。

我们设计本模块仅包含一个字符串参数,即要输出的语句。nginx 字符串类型为 ngx_str_t ,编写创建主配置结构体的函数 hello_create_main_conf() 如下:

static void*
hello_create_main_conf(ngx_conf_t *cf)
{
    ngx_str_t *conf;
    conf = ngx_pcalloc(cf->pool, sizeof(ngx_str_t));
    if (conf == NULL) {
        return NULL;
    }
    return conf;
}
  • 从配置内存池 cf->pool 分配一个字符串 ngx_str_t, 分配结构体将初始化为 0, 对 ngx_str_t 即空字符串。
  • 如果函数返回 NULL 则表示分配失败, nginx 将报错退出。

更新 ngx_http_module_t ngx_http_hello_module_ctx ,设置 create_main_conf 为 hello_create_main_conf() 函数。

static ngx_http_module_t ngx_http_hello_module_ctx = {
    NULL,                       /* preconfiguration */
    NULL,                       /* postconfiguration */

    hello_create_main_conf,     /* create main configuration */
    NULL,                       /* init main configuration */

    NULL,                       /* create server configuration */
    NULL,                       /* merge server configuration */

    NULL,                       /* create location configuration */
    NULL                        /* merge location configuration */
};

创建指令

一个指令用一个 ngx_command_t 类型的数据结构表示。

typedef struct ngx_command_s         ngx_command_t;

struct ngx_command_s {
    ngx_str_t             name;
    ngx_uint_t            type;
    char               *(*set)(ngx_conf_t *cf, ngx_command_t *cmd, void *conf);
    ngx_uint_t            conf;
    ngx_uint_t            offset;
    void                 *post;
};

#define ngx_null_command  { ngx_null_string, 0, NULL, 0, 0, NULL }
  • name 指定指令名,如 hello 。
  • type 是一个混合结构,包含指令类型、指令使用位置、指令参数个数等多种特性信息。使用 NGX_HTTP_MAIN_CONF 表示指令可在 http 主配置使用,NGX_CONF_TAKE1 表示指令接受 1 个参数。
  • set 为指令处理函数,即 nginx 配置设置函数。
  • conf 指示保存配置结构体的位置。使用 NGX_HTTP_MAIN_CONF_OFFSET 表示指令配置在 http 主配置下存储生效。
  • offset 指示指令配置字段的位置。通常一个模块的配置是一个结构体,而一个指令的配置是其中一个字段,set 函数通过 offset 访问字段,这样不需要知道结构体的类型 (结构),就可以读写配置字段。模块只有一个配置项时,设置为 0 即可。
  • post 对特定处理函数可增加后置处理函数,或增加传入参数。通常不使用,设为 NULL 。

声明指令处理函数 hello() :

static char* hello(ngx_conf_t *cf, ngx_command_t *cmd, void *conf);

创建 hello 指令如下:

static ngx_command_t ngx_http_hello_commands[] = {

    { ngx_string("hello"),
      NGX_HTTP_MAIN_CONF | NGX_CONF_TAKE1,
      hello,
      NGX_HTTP_MAIN_CONF_OFFSET,
      0,
      NULL },

    ngx_null_command
};

编写指令处理函数

指令执行处理:

  • nginx 根据指令 type 字段设置的特性自动校验指令位置,参数个数等信息,并将指令语句解析为字符串数组 (类似 shell 命令行) ,保存到 cf->args ,再调用指令处理函数。
  • 指令处理函数执行成功时返回 NGX_CONF_OK ,发生错误时返回错误消息。
  • 为了简化和统一指令处理, nginx 预定义了许多标准指令处理函数,如 ngx_conf_set_str_slot() 将一个字符串参数解析保存为一个 ngx_str_t 配置项。
  • hello 指令可复用 ngx_conf_set_str_slot() 函数获取参数值,再添加额外逻辑打印 hello 语句。

编写指令处理函数 hello() 如下:

static char*
hello(ngx_conf_t *cf, ngx_command_t *cmd, void *conf)
{
    ngx_str_t *str = conf;

    char *rv;

    rv = ngx_conf_set_str_slot(cf, cmd, str);
    if (rv != NGX_CONF_OK) {
        return rv;
    }

    ngx_log_error(NGX_LOG_NOTICE, cf->log, 0, "HELLO %V", str);

    return NGX_CONF_OK;
}
  • ngx_log_error() 是一个宏,最终将调用 ngx_log_error_core() 函数。
  • ngx_log_error() 第 3 个参数 err 表示系统错误码,无对应错误码时使用 0 。
  • nginx 未使用 C 标准库的 snprintf() 字符串格式化函数,而是自己实现了 ngx_snprintf() 函数,并自定义了类似的格式化字符串,其中 %V 表示输出 ngx_str_t * 指针指向的字符串。

至此,代码开发完成。在 nginx 代码仓库目录下执行 make modules 重新编译生成动态模块文件。

在配置文件 objs/nginx.conf http 配置下添加如下配置:

hello Nginx;

在 nginx 代码仓库目录下执行如下命令,nginx 日志将输出 "HELLO Nginx" 语句,按 Ctrl-C 退出 nginx 。

objs/nginx -p "$PWD" -c objs/nginx.conf

HTTP 请求处理器

nginx 定义了多个 HTTP 请求处理阶段 (phase) ,如读取完 HTTP 请求头后即进入 NGX_HTTP_POST_READ_PHASE 阶段。可在 HTTP 请求处理的各个阶段添加处理器函数,类似于 Java Servlet 中的 HTTP 过滤器 (Filter) 。

HTTP 处理器函数签名 (函数类型) 如下:

typedef ngx_int_t (*ngx_http_handler_pt)(ngx_http_request_t *r);
  • 参数 r 为 HTTP 请求结构体。
  • 返回值为 NGX_DECLINED 时,表示继续执行下一个处理器。
  • 发生错误时,返回 HTTP 错误码,如服务器错误 500 NGX_HTTP_INTERNAL_SERVER_ERROR ,nginx 将立即返回请求。

编写 HTTP 请求处理器 hello_handler() 如下,对每个 HTTP 请求打印一次 hello 语句,同时打印解析后的请求 uri 。使用 ngx_http_get_module_main_conf() 从 HTTP 请求对象获取 ngx_http_hello_module 模块关联的配置数据。

static ngx_int_t
hello_handler(ngx_http_request_t *r)
{
    ngx_str_t * str = ngx_http_get_module_main_conf(r, ngx_http_hello_module);
    ngx_log_error(NGX_LOG_NOTICE, r->connection->log, 0, "HELLO %V, uri: %V", str, &r->uri);
    return NGX_DECLINED;
}

为 HTTP 模块编写一个 postconfiguration 函数 hello_init() ,将 HTTP 处理器 hello_handler() 注册到 NGX_HTTP_POST_READ_PHASE 阶段。nginx 将在完成配置解析 (执行完配置指令) 后执行 HTTP 模块的 postconfiguration 函数,以完成模块初始化。

static ngx_int_t
hello_init(ngx_conf_t *cf)
{
    ngx_http_handler_pt        *h;
    ngx_http_core_main_conf_t  *cmcf;

    cmcf = ngx_http_conf_get_module_main_conf(cf, ngx_http_core_module);

    h = ngx_array_push(&cmcf->phases[NGX_HTTP_POST_READ_PHASE].handlers);
    if (h == NULL) {
        return NGX_ERROR;
    }

    *h = hello_handler;

    return NGX_OK;
}

更新 ngx_http_module_t ngx_http_hello_module_ctx ,设置 postconfiguration 为 hello_init() 函数。

static ngx_http_module_t ngx_http_hello_module_ctx = {
    NULL,                       /* preconfiguration */
    hello_init,                 /* postconfiguration */

    hello_create_main_conf,     /* create main configuration */
    NULL,                       /* init main configuration */

    NULL,                       /* create server configuration */
    NULL,                       /* merge server configuration */

    NULL,                       /* create location configuration */
    NULL                        /* merge location configuration */
};

至此,开发完成。在 nginx 代码仓库目录下执行 make modules 重新编译生成动态模块文件,然后执行如下命令启动 nginx 。

objs/nginx -p "$PWD" -c objs/nginx.conf

使用浏览器或 curl 命令访问 http://localhost:8080/ ,每访问一次将看到 nginx 打印一次 hello 语句,及当前请求 uri 。类似如下输出:

2020/05/16 22:46:26 [notice] 7279#0: *1 HELLO Nginx, uri: /, client: 127.0.0.1, server: , request: "GET / HTTP/1.1", host: "localhost:8080"
2020/05/16 22:46:27 [notice] 7279#0: *1 HELLO Nginx, uri: /, client: 127.0.0.1, server: , request: "GET / HTTP/1.1", host: "localhost:8080"

热更新 (reload)

nginx 还支持热更新 (reload) ,这是一个很有用的高级特性。在不停止 nginx 的情况下将配置文件中的 hello 指令修改如下:

hello "阿泉";

在 nginx 代码仓库目录下执行如下 reload 命令:

objs/nginx -p "$PWD" -c objs/nginx.conf -s reload

reload 命令将看到如下输出:

2020/05/16 23:09:31 [notice] 9617#0: HELLO 阿泉
2020/05/16 23:09:31 [notice] 9617#0: signal process started

原 nginx 进程将看到如下输出。nginx 将重新进行配置初始化,创建新 worker 进程,并优雅退出旧 worker 进程。

2020/05/16 23:09:31 [notice] 9384#0: signal 1 (SIGHUP) received from 9617, reconfiguring
2020/05/16 23:09:31 [notice] 9384#0: reconfiguring
2020/05/16 23:09:31 [notice] 9384#0: HELLO 阿泉
# ... ...
2020/05/16 23:09:31 [notice] 9384#0: start worker process 9623
2020/05/16 23:09:31 [notice] 9385#0: gracefully shutting down

再次访问 http://localhost:8080/ 时,可看到 nginx 日志打印的 hello 语句也随之变成了新配置的 hello 语句。

2020/05/16 23:09:49 [notice] 9623#0: *3 HELLO 阿泉, uri: /, client: 127.0.0.1, server: , request: "GET / HTTP/1.1", host: "localhost:8080"

热更新 (reload) 功能非常有用,但在生产使用时一定要非常小心以避免故障。实际使用中可能用的并不多。

教程到此结束,下面扯些题外话。

吐槽与闲聊

Nginx 文档还算完善,代码还算优雅,阅读 Nginx 源码对提升开发水平颇有裨益,但其过程实在是烧脑和痛苦 (对我而言) 。Nginx 源码几乎攒齐了传统 C 语言编程的所有缺点。比如使用整数定义错误码和枚举类型,使用了迪杰斯特拉 (Dijkstra) 先生不建议使用的 goto 语句,一个整数字段 (如 cmd->type) 整合了多种枚举类型信息,许多地方使用了动态类型 void* 等。这些用法不受工具 (静态) 检查和约束 (原作者的脑中可能有一幅清晰的场景图表?),对不熟悉的开发者来说不仅难以理解,而且非常危险!但其背后往往又是出于性能 (和某种简洁性) 的考虑,大概是使用 C 语言的情况下所能做出的最大努力。换句话说,(很多时候) 这是 C 语言的局限性,而不是 Nginx 的问题。错误处理的正确解法应该是 Java 受检查的异常,但 C 语言缺少异常 (Exception) 等高级特性,合理使用 (无效业务值) 错误码和 goto 语句是优雅且高效的最佳实践之一。

语言之争

本段内容容易引起不适,建议跳过。

有时候想,Nginx 为什么不使用更高级的开发语言 (比如 C++) 编写,或者至少可以复用 Apache 基础库 APR 吧 ?其实又何止 Apache 基础库, Apache HTTP 服务器应该有很多组件都可以复用。但如果这样的话,Nginx 又怎么能叫 Nginx 呢 ? 大概只能是一个特殊版本的 Apache HTTP 服务器,影响力和竞争力都很难超越官方正版(就像许多 Nginx 修改版很难超越 Nginx 一样) 。不止是 3 方基础库,Nginx 连 C 语言标准库都试图避免直接使用,比如自己开发了 ngx_snprintf() (但 Nginx 也不是全都自己来,比如合理使用了 pcre, zlib, openssl 等 3 方库) 。很多 C 语言项目其实都在使用自己特殊定制版的 C 语言 (又一个典型缺点) 。这让我想起《黑客与画家》文集上提到的 迎难而生 的问题 (值得另外开贴讨论) ,如果一个问题太容易,谁都可以复制 (抄袭),那么它的核心竞争力在哪里?

Nginx 及其模块开发本身是有一定门槛的,甚至 Nginx 本身建议不要滥用模块开发 (而尽量用 nginx 配置或内置的 perl/njs 脚本) 。

有 nodejs 粉说用 nodejs 几条语句就可以写出一个高性能 HTTP 服务器,如果 nginx 这样写成,结果会怎样 ?在大家都在喊着 nodejs/python/php/golang/kotlin 天下第一的时候,老态龙钟的 C 语言荣获 TIOBE 编程语言排行榜 2019 年度语言,最近 (2020 年 5 月) 又重夺排行榜第一。我不是针对谁,我是说 javascript/php/golang 等都是垃圾语言 (python 和 kotlin 还算能用?)。我也不推荐 C 语言,C 语言显然有很多缺点 (过于底层),如果能够加上一些 C++ 特性 (特别是类和 RAII) 那肯定会好很多。但是 C++ 特性太多,简直是一团浆糊,所以许多团队和项目不得不精心控制一些边界,设计一个定制版的 C++ 语言 (与 C 语言类似)。这导致 C++ 语言分裂,是个不好的信号,也是这个原因导致许多声称解决这些问题的新语言不断出现。

结论: 贴近系统和硬件编程,C/C++ 是不错的选择,高级语言首选 Java ,其他一些快速粗糙 (quick and dirty) 的场景可适当选用其他语言。但一定要小心避免垃圾语言 (不再一一点名了) 和所谓的领域专用语言 (DSL) 。

代码风格

首选吐槽一下。Nginx 只使用 C 风格的注释 / / (不使用 C++ 的双斜杠 // 注释) 。使用 4 个空格缩进 (而不是 tab) 。变量名常常太短 (导致含义不直观) 。单行源码不超过 80 个字符 (可能也是导致变量名过短的原因)。这几点个人不太喜欢。

听说 nginx 作者有代码洁癖,要求字段名 (变量名) 排版对齐。我也有代码洁癖,我反对这种对齐,表面上视觉整齐了,实际上维护跟踪很麻烦 (特别是没有工具支持的情况下) 。再看 nginx 代码,不仅要求对齐,而且是抛开修饰符后的单词对齐 (嗯,奇怪的排版) 。如 struct ngx_command_s 定义如下。

struct ngx_command_s {
    ngx_str_t             name;
    ngx_uint_t            type;
    char               *(*set)(ngx_conf_t *cf, ngx_command_t *cmd, void *conf);
    ngx_uint_t            conf;
    ngx_uint_t            offset;
    void                 *post;
};

nginx 代码是很吝惜注释的,但并非没有注释,恰当的时候会有注释,而更多的时候让代码自己说话。如 ngx_http_core_generic_phase() 函数的这段代码,结合注释可知这里已经考虑枚举了 rc 的所有可能取值。这点我是比较赞赏的,不过个人建议可以适当添加更多注释 (特别是逻辑复杂的地方) 。

    if (rc == NGX_DECLINED) {
        r->phase_handler++;
        return NGX_AGAIN;
    }

    if (rc == NGX_AGAIN || rc == NGX_DONE) {
        return NGX_OK;
    }

    /* rc == NGX_ERROR || rc == NGX_HTTP_...  */

    ngx_http_finalize_request(r, rc);

    return NGX_OK;

另外,nginx 代码鼓励用空行分割语义块 (哪怕只有一行) ,如 ngx_conf_handler() 函数包含如下代码块:

            /* set up the directive's configuration context */

            conf = NULL;

            if (cmd->type & NGX_DIRECT_CONF) {
                conf = ((void **) cf->ctx)[cf->cycle->modules[i]->index];

            } else if (cmd->type & NGX_MAIN_CONF) {
                conf = &(((void **) cf->ctx)[cf->cycle->modules[i]->index]);

            } else if (cf->ctx) {
                confp = *(void **) ((char *) cf->ctx + cmd->conf);

                if (confp) {
                    conf = confp[cf->cycle->modules[i]->ctx_index];
                }
            }

if 子句和 else 子句执行不同的逻辑,用一个空行分开,结构更加清晰,这一点值得学习。顺便说句,这段代码较难读懂,也许可以再适当添加部分注释。

最后,很多人可能听过类似 "单个函数不要超过 100 行" (更有严格的说 50 行, 20 行) 这样的最佳实践。但如果我们看许多优秀开源项目的代码,大佬们写起代码来根本停不下来,洋洋洒洒几百行的核心函数纯属正常。尽量保持函数功能单一和简短当然是最近实践,但是 不用死守规则 。规则往往是由强者制定来约束弱者,黑客从来不应该受任何具体规则的束缚,唯一的规则就是正确、简短、健壮,然后越快越好。别给我说那些婆婆妈妈的编程规范。

我的代码又快又稳定,然后你跑来说我排版不好看 (是的我说了) ?滚一边去!

后浪

初次接触一种开发技术,好像来到一座花园,想要到某个目的地取采摘一朵花 (开发需求)。陌生的花园犹如迷宫,一开始我们跌跌撞撞,可能被荆棘扎手,可能走错方向,但最终来到玫瑰花栏,摘下一朵花。于是我沿途做下记号,小心避开荆棘和弯路,就成了这篇文章。

所有本文更适合作为简单的快速参考 (沿路记号),而读者可能会充满 “这里为什么要这样?” 的疑问。许多疑问都可以在 Nginx 官方 开发指南 和 源码 里找到答案,那才是真正的藏宝图。只有我们亲自摸索熟悉了这座花园,才会发现许许多多的宝藏,你也许会发现,旁边花栏有更美丽的郁金香和清香的茉莉花。 先读代码,后浪。

分享到: