云枫的菜园子

带着一个流浪的心,慢慢沉淀。 https://github.com/CloudFeng

  博客园  :: 首页  :: 新随笔  :: 联系 :: 订阅 订阅  :: 管理

OpenResty在C语言模块遵守NGINX的编码风格,比如OpenResty本身的NGINX插件模块或者OpenResty的Lua库中C部分。然而,即便是NGINX自身的C语言核心代码风格并没有与基础代码保持一致。所以,本文拟定一份指南性的文档以便消除各种歧义。

提交给OpenResty核心项目的补丁需要遵守此指南,否则不会通过评审进程,更不用说归并在一起。OpenRest和NGINX社区都鼓励开发者在使用C语言开发自身模块或库时遵守此指南。

命名规范

对于NIGINX相关的C代码,源码文件名字(包括.c.h文件)、全局变量、全局函数、C语言中的结构/联合体/枚举的名字、编译单元作用域的静态变量和函数块,还有在头文件中公开定义的宏,以上这些都是全量命名。比如

ngx_http_core_module.c
ngx_http_finalize_request
和 NGX_HTTP_MAIN_CONF

这是十分重要的,是因为在C语言中并不像C++中有显示的名字空间。使用全量性名字可以避免名字冲突,而且有助于调试。在Lua库中的C组件,对于所有在相关C编译单元中顶层C标签中我们同样使用前缀,比如resty_blah_(如果库的名称是lua-resty-blah)。

在C函数中声明局部变量使用的短名字。在NGINX核心组件中广泛使用短变量名字是:clevpq 等等。这些变量名通常是生命周期比较短并且是有限的作用域。根据Huffman原则,在当前上下文中对于常用的地方我们使用短变量名可以避免行噪音。但短变量名需要遵守NGINX的风格。不要自我发明除非必要,但必须使用含义明确的名字。对于pq,它们经常用在处理字符串处理上下文中的字符串指针变量名。

一旦需要C结构体和联合体,全写形式命名之,除非成员的名字很长。比如,在NGINX中 struct ngx_http_request_s,有很长的成员变量名字: read_event_handlerupstream_states
request_body_in_persistent_file 等。

对于typedef指向的结构体的变量名以_t作为后缀,结构体名以_s作为后缀;对于typedef指向枚举的变量名以_e作为后缀。在函数作用域中定义的局部变量可以不必遵守此约定。下面是NGINX核心组件的代码:

typedef struct ngx_connection_s      ngx_connection_t;
typedef struct {
    WSAOVERLAPPED    ovlp;
    ngx_event_t     *event;
    int              error;
} ngx_event_ovlp_t;
struct ngx_chain_s {
    ngx_buf_t    *buf;
    ngx_chain_t  *next;
};
typedef enum {
    ngx_pop3_start = 0,
    ngx_pop3_user,
    ...
    ngx_pop3_auth_external
} ngx_pop3_state_e;

缩进

NGINX中使用空格作为缩进,而不是使用制表符。通常我们使用4个空格,除了某些地方有对齐要求或者在特定的类中需要(我们会在后面详细讨论这些示例)。总之,合理的缩进你的代码。

每行限定80个字符

所有的源码行必须控制在80个字符内(尽管在NGINX中有些地方保持是78个,但建议定死80个字符)。不同场景下,对于缩进在连续行中有不同的缩进规则。我我们将在下面详细讨论之。

每行结尾无空白符

代码行尾不应有任何空格、制表符,甚至空行。很多编辑器会在用户设置下对空白字符自动高亮或者截断。合理地配置你的编辑器或者IDE。

函数声明

将头文件或者.c文件头部的C函数声明(不是定义!)尽量放在一行。下面是来自NGINX中的示例:

ngx_int_t ngx_http_send_special(ngx_http_request_t *r, ngx_uint_t flags);

若一行太长,超过了80个字符,可以使用4个空格符将其分隔多行。比如:

ngx_int_t ngx_http_filter_finalize_request(ngx_http_request_t *r,
    ngx_module_t *m, ngx_int_t error);

若返回类型是指针类型,需要*之前有个空格而不是其后,如下所示:

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

注意函数定义与声明是不同的风格, 更多可以参考 函数定义部分 。

函数定义

C函数定义编码风格与声明是不同的(参看函数声明部分)。返回类型独占一行,第二行是函数的名称和参数列表;{独占第三行。下面是NGINX中的代码示例:

ngx_int_t
ngx_http_compile_complex_value(ngx_http_compile_complex_value_t *ccv)
{
    ...
}

注意一点的是:在 (前后都没有空格,并且在前三行无任何缩进。

如果参数列表很长,比如超过了80个字符,以不同的行分隔之,每一行前缩进4个空格。示例:

ngx_int_t
ngx_http_complex_value(ngx_http_request_t *r, ngx_http_complex_value_t *val,
    ngx_str_t *value)
{
    ...
}

若返回的类型是指针类型,在*之前有一个空格,如下所示:

static char *
ngx_http_core_pool_size(ngx_conf_t *cf, void *post, void *data)
{
    ...
}

局部变量

命名规范部分,要求局部变量使用短名字,比如evclcf等。通常它们放在每个函数定义个开始处,而不是在任何代码块的开始处,除非有助于天使或者其他特殊的需求。同样,函数内的标志符(除了*前缀),必须垂直对齐。比如:

    ngx_str_t               *value;
    ngx_uint_t               i;
    ngx_regex_elt_t         *re;
    ngx_regex_compile_t      rc;
    u_char                   errstr[NGX_MAX_CONF_ERRSTR];

注意标志符irercerrstr是如何垂直对齐,但*前缀不包括在内。

可能会出现超长的局部变量名,若是一同对齐,会导致代码超级难看。可以将长局部变量名与其他局部变量之间插入空行分隔开来。此时,两组标志符,没有必要垂直对齐。示例如下:

static char *
ngx_http_core_open_file_cache(ngx_conf_t *cf, ngx_command_t *cmd, void
     *conf)
{
    ngx_http_core_loc_conf_t *clcf = conf;

    time_t          inactive;
    ngx_str_t   *value, s;
    ngx_int_t     max;
    ngx_uint_t   i;
    ...
}

可以看出clcf定义与其他局部变量是分开的,其他的局部变量仍旧垂直对齐的。

在C函数中,局部变量后面与真正执行的代码之间使用空行分隔的。比如:

u_char * ngx_cdecl
ngx_sprintf(u_char *buf, const char *fmt, ...)
{
    u_char   *p;
    va_list   args;

    va_start(args, fmt);
    p = ngx_vslprintf(buf, (void *) -1, fmt, args);
    va_end(args);

    return p;
}

正好有一空行在局部变量定义之后。

空行的使用

连续的C函数定义、多行全局/静态变量定义和struct/union/enum定义必须使用2个空行分隔。下面是连续的C函数定义:

void
foo(void)
{
    /* ... */
}


int
bar(...)
{
    /* ... */
}

下面是多个连续的静态变量定义的例子:

static ngx_conf_bitmask_t  ngx_http_core_keepalive_disable[] = {
    ...
    { ngx_null_string, 0 }
};


static ngx_path_init_t  ngx_http_client_temp_path = {
    ngx_string(NGX_HTTP_CLIENT_TEMP_PATH), { 0, 0, 0 }
};

仅有一行变量定义必须组织在一起,比如:

static ngx_str_t  ngx_http_gzip_no_cache = ngx_string("no-cache");
static ngx_str_t  ngx_http_gzip_no_store = ngx_string("no-store");
static ngx_str_t  ngx_http_gzip_private = ngx_string("private");

下面是多个结构体的定义:

struct ngx_http_log_ctx_s {
    ngx_connection_t    *connection;
    ngx_http_request_t  *request;
    ngx_http_request_t  *current_request;
};


struct ngx_http_chunked_s {
    ngx_uint_t           state;
    off_t                size;
    off_t                length;
};


typedef struct {
    ngx_uint_t           http_version;
    ngx_uint_t           code;
    ngx_uint_t           count;
    u_char              *start;
    u_char              *end;
} ngx_http_status_t;

所有的都是以2个空行分隔的。

若不同类型的顶层对象定义也是需要用2行空行分隔,比如:

#if (NGX_HTTP_DEGRADATION)
ngx_uint_t  ngx_http_degraded(ngx_http_request_t *);
#endif


extern ngx_module_t  ngx_http_module;

在全局变量声明后面使用2行空行分隔静态函数声明。

多个C函数声明不比适用两行空行彼此分隔,如下所示:

ngx_int_t ngx_http_discard_request_body(ngx_http_request_t *r);
void ngx_http_discarded_request_body_handler(ngx_http_request_t *r);
void ngx_http_block_reading(ngx_http_request_t *r);
void ngx_http_test_reading(ngx_http_request_t *r);

甚至在跨越多行的情况下也是如此。比如:

char *ngx_http_merge_types(ngx_conf_t *cf, ngx_array_t **keys,
    ngx_hash_t *types_hash, ngx_array_t **prev_keys,
    ngx_hash_t *prev_types_hash, ngx_str_t *default_types);
ngx_int_t ngx_http_set_default_types(ngx_conf_t *cf, ngx_array_t **types,
    ngx_str_t *default_type);

有时候,我们需要根据语意将函数声明分组,然后使用2行空行分隔,这样有助于代码可读性,比如:

ngx_int_t ngx_http_send_header(ngx_http_request_t *r);
ngx_int_t ngx_http_special_response_handler(ngx_http_request_t *r,
    ngx_int_t error);
ngx_int_t ngx_http_filter_finalize_request(ngx_http_request_t *r,
    ngx_module_t *m, ngx_int_t error);
void ngx_http_clean_header(ngx_http_request_t *r);


ngx_int_t ngx_http_discard_request_body(ngx_http_request_t *r);
void ngx_http_discarded_request_body_handler(ngx_http_request_t *r);
void ngx_http_block_reading(ngx_http_request_t *r);
void ngx_http_test_reading(ngx_http_request_t *r);

前面一组基本上是响应头部的函数声明,后面一组是请求体相关的函数声明。

类型转换

在C语言中将void指针(void *)赋值给非void指针并没有要求显示转换。NGINX中的编码风格也是如此。比如:

char *
ngx_http_types_slot(ngx_conf_t *cf, ngx_command_t *cmd, void *conf)
{
    char  *p = conf;
    ...
}

示例中conf变量是void指针类型,将其赋值给一个char *局部变量,并没有使用任何显示类型转换。

确实需要显示类型转换的时候,确保在目标指针类型名字的第一个*前加一个空格
,同样在)后面加入一个空格。比如:

*types = (void *) -1;

示例中在*)之前有一个空格,同样在)后面也有一个。此规则同样适用于将一个值进行类型转换。比如:

if ((size_t) (last - buf) < len) {
    ...
}

或者多个连续的类型转换:

aio->aiocb.aio_data = (uint64_t) (uintptr_t) ev;

注意(uint64_t)(uintptr_t)之间的空格,还有在(uintptr_t)之后的空格。

If语句

NGINX 适用的if 语句与C语言中的编码风格是一样的。

首先,在 if后面必须有一个空格;再者(){之间有空格。如下:

if (a > 3) {
    ...
}

可以看出,在 if后面有一个空格;在 {之前有一个空格。然而在(右边和)左边是没有空格。此外,
{if关键字在同一行,除非此行超过了80个字符。若是则,我们需要将其分隔多行,{独立成行。
如下面的例子:

        if (ngx_http_set_default_types(cf, prev_keys, default_types)
            != NGX_OK)
        {
            return NGX_CONF_ERROR;
        }

注意 != OKif 语句的条件部分对齐(不包括 ()。

若逻辑操作符在很长的条件语句中,需要确保连接逻辑操作符在后续行开头处,并且缩进需体现出条件的嵌套结构。如下:

        if (file->use_event
            || (file->event == NULL
                && (of->uniq == 0 || of->uniq == file->uniq)
                && now - file->created < of->valid
#if (NGX_HAVE_OPENAT)
                && of->disable_symlinks == file->disable_symlinks
                && of->disable_symlinks_from == file->disable_symlinks_from
#endif
            ))
        {
            ...
        }

我们可以忽略中间的宏指令,它们并不是if 语句本身的编码风格。

若在if 语句块后有其他的语句,通常会在其后空一行。比如:

        if (rc != NGX_OK && (of->err == 0 || !of->errors)) {
            goto failed;
        }

        if (of->is_dir) {
            ...
        }

注意空行是如何分离if 语句块的,或者后面跟着其他的语句:

        if (file->is_dir) {

            /*
             * chances that directory became file are very small
             * so test_dir flag allows to use a single syscall
             * in ngx_file_info() instead of three syscalls
             */

            of->test_dir = 1;
        }

        of->fd = file->fd;
        of->uniq = file->uniq;

类似的,常常会在if 语句前有一空行。比如:

        rc = ngx_open_and_stat_file(name, of, pool->log);

        if (rc != NGX_OK && (of->err == 0 || !of->errors)) {
            goto failed;
        }

在代码块之间使用空白符,会让代码不那么臃肿。同样也适用于whilefor 等语句。

尽管只有单条件的If 语句,也需要使用 {},比如:

        if (file->is_dir || file->err) {
            goto update;
        }

在例子中绝对不能省略{},尽管标准C语言是允许这么做的。

else语句

if 语句有else分支的时,相关的语句也必须使用{}包裹。而且} else {之前有一空行。如下所示:

    if (of->disable_symlinks == NGX_DISABLE_SYMLINKS_NOTOWNER
        && !(create & (NGX_FILE_CREATE_OR_OPEN|NGX_FILE_TRUNCATE)))
    {
        fd = ngx_openat_file_owner(at_fd, p, mode, create, access, log);

    } else {
        fd = ngx_openat_file(at_fd, p, mode|NGX_FILE_NOFOLLOW, create, access);
    }

注意} else {在同一行,并且在其上方有一空行。

For语句

for 语句的编码风格在很多地方与If语句部分描述的if 语句相似。在for关键字之后和{之前有空格。另外,语句块需要用{}包裹。再者就是在for的条件部分中的;后面有空格。下面的例子展示上述的要求:

for (i = 0; i < size; i++) {
    ...
}

一个特殊情况是无限循环,在NGINX中通常是如下编码的:

    for ( ;; ) {
        ...
    }

或者for 语句条件部分包括,:

    for (i = 0, n = 2; n < cf->args->nelts; i++, n++) {
        ...
    }

或者单独忽略循环条件:

    for (p = pool, n = pool->d.next; /* void */; p = n, n = n->d.next) {
        ...
    }

While语句

while语句的编码风格在很多地方与If语句部分描述的if 语句相似。在while关键字之后和{之前有空格。另外,语句块需要用{}包裹。如下:

    while (log->next) {
        if (new_log->log_level > log->next->log_level) {
            new_log->next = log->next;
            log->next = new_log;
            return;
        }

        log = log->next;
    }

Do-while 语句与之类似:

    do {
        p = h2c->state.handler(h2c, p, end);

        if (p == NULL) {
            return;
        }

    } while (p != end);

注意在do{之间存在一个空格,在while的前后也是存在的。

Switch语句

switch语句的编码风格在很多地方与If语句部分描述的if 语句相似。在switch关键字之后和{之前有空格。另外,语句块需要用{}包裹。如下:

    switch (unit) {
    case 'K':
    case 'k':
        len--;
        max = NGX_MAX_SIZE_T_VALUE / 1024;
        scale = 1024;
        break;

    case 'M':
    case 'm':
        len--;
        max = NGX_MAX_SIZE_T_VALUE / (1024 * 1024);
        scale = 1024 * 1024;
        break;

    default:
        max = NGX_MAX_SIZE_T_VALUE;
        scale = 1;
    }

注意case标签与switch关键字是垂直对齐的。

有时,会在第一个case标签前加入一空行,如下:

    switch (c->log_error) {

    case NGX_ERROR_IGNORE_EINVAL:
    case NGX_ERROR_IGNORE_ECONNRESET:
    case NGX_ERROR_INFO:
        level = NGX_LOG_INFO;
        break;

    default:
        level = NGX_LOG_ERR;
    }

分配内存的错误处理

NGINX中有一个很好习惯就是检查动态内存申请的错误。任何地方都如下所示处理:

    sa = ngx_palloc(cf->pool, socklen);
    if (sa == NULL) {
        return NULL;
    }

上面的两条语句使用很频繁,通常不会在申请语句和if语句之间加入空行。

确保你从来不会遗漏检查类似动态申请内存。

函数调用

C函数调用不需要在参数列表的()前后加入空格,如下所示:

sa = ngx_palloc(cf->pool, socklen);

若函数调用超过了80个字符,需要将参数列表独立成行,子行中的参数需要与首行参数垂直对齐。如下:

        buf->pos = ngx_slprintf(buf->start, buf->end, "MEMLOG %uz %V:%ui%N",
                                size, &cf->conf_file->file.name,
                                cf->conf_file->line);

宏定义要求在#define后有一个空格,而在定义体前面需至少2个空格。如下:

#define F(x, y, z) ((z) ^ ((x) & ((y) ^ (z))))

有时候定义体前需要更多的空格,出于多个相关宏定义之间对齐。如下:

#define NGX_RESOLVE_A         1
#define NGX_RESOLVE_CNAME     5
#define NGX_RESOLVE_PTR       12
#define NGX_RESOLVE_MX        15
#define NGX_RESOLVE_TXT       16
#define NGX_RESOLVE_AAAA      28
#define NGX_RESOLVE_SRV       33
#define NGX_RESOLVE_DNAME     39
#define NGX_RESOLVE_FORMERR   1
#define NGX_RESOLVE_SERVFAIL  2

对于展开多行的宏定义,需要对齐续行符\。如下:

#define ngx_conf_init_value(conf, default)
\
    if (conf == NGX_CONF_UNSET) {                                            \
        conf = default;                                                      \
    }

我们建议将\放在第78列,尽管NGINX前后不一致。

全局或静态变量

对于局部变量、顶部的静态变量的定义和声明,需要在类型描述符与变量名之间加入至少两个空格(包括多个前导*修饰)

下面是一些示例:

ngx_uint_t   ngx_http_max_module;


ngx_http_output_header_filter_pt  ngx_http_top_header_filter;
ngx_http_output_body_filter_pt    ngx_http_top_body_filter;
ngx_http_request_body_filter_pt   ngx_http_top_request_body_filter;

同样地对于带有初始化的变量定义也是如此,如下:

ngx_str_t  ngx_http_html_default_types[] = {
    ngx_string("text/html"),
    ngx_null_string
};

操作符

二元操作符

很多二元操作符前后需要一个空格。比如,算术运算符、位运算符、关系运算符合逻辑运算符。如下:

yday = days - (365 * year + year / 4 - year / 100 + year / 400);

还有,

if (*p >= '0' && *p <= '9') {

对于结构体/联合体成员操作符 ->.,在其前后都没有空格。比如:

ls = cycle->listening.elts;

对于,操作符,在其后面需要有一个空格,而不是前面:

for (p = pool, n = pool->d.next; /* void */; p = n, n = n->d.next) {

NGINX中通常避免使用,操作符,除非在 for语句中声明多个同样类型的变量。在其他情况,最好将,表达式
分成多条语句。

一元操作符

通常C中得一元操作符前后都不会放入任何空格。如下:

for (p = salt; *p && *p != '$' && p < last; p++) { /* void */ }
#define SET(n)      (*(uint32_t *) &p[n * 4])

注意,在*&前后都没放入任何空白符(在第二例子中&的前面加入了空格,是因为使用了类型转换,更多地可以参考[类型转换][#类型转换]部分)。

对于后缀操作符同样适用:

for (value = 0; n--; line++) {

三元操作符

三元操作符要求在其前后使用空白符,如同二元操作符一样。比如:

node = (rc < 0) ? node->left : node->right;

从上面的例子可以看出,当三元操作中的条件部分是一个表达式时,可以加入(),尽管不是必须的。

结构体/联合体/枚举 定义

结构体、联合体和枚举定义的风格是相似的,都需要将标志符垂直对齐,与在局部变量中说明的局部变量对齐类似。我们列举来自
NGINX核心代码的例子来说明:

typedef struct {
    ngx_uint_t           http_version;
    ngx_uint_t           code;
    ngx_uint_t           count;
    u_char              *start;
    u_char              *end;
} ngx_http_status_t;

与局部变量的定义一样,我们同样需要使用空行分开组字段,如下所示:

struct ngx_http_request_s {
    uint32_t                          signature;         /* "HTTP" */

    ngx_connection_t                 *connection;

    void                            **ctx;
    void                            **main_conf;
    void                            **srv_conf;
    void                            **loc_conf;

    ngx_http_event_handler_pt         read_event_handler;
    ngx_http_event_handler_pt         write_event_handler;
    ...
};

在此例中,每一组成员变量都是垂直对齐的,但不要求所有的组都一同对齐(尽管我们可以这么做,如上述例子一般)。

联合体也是类似的:

typedef union epoll_data {
    void         *ptr;
    int           fd;
    uint32_t      u32;
    uint64_t      u64;
} epoll_data_t

下面是枚举类型:

typedef enum {
    NGX_HTTP_INITING_REQUEST_STATE = 0,
    NGX_HTTP_READING_REQUEST_STATE,
    NGX_HTTP_PROCESS_REQUEST_STATE,

    NGX_HTTP_CONNECT_UPSTREAM_STATE,
    NGX_HTTP_WRITING_UPSTREAM_STATE,
    NGX_HTTP_READING_UPSTREAM_STATE,

    NGX_HTTP_WRITING_REQUEST_STATE,
    NGX_HTTP_LINGERING_CLOSE_STATE,
    NGX_HTTP_KEEPALIVE_STATE
} ngx_http_state_e;

Typedef 定义

类似于typedef定义同样需要在定义体的前面至少有2个空格,(通常就2个空格)
typedef u_int aio_context_t;

在将typedef定义分组的时候可以使用多于2个空格将其垂直对齐,这样可以代码更为美观。

typedef struct ngx_module_s          ngx_module_t;
typedef struct ngx_conf_s            ngx_conf_t;
typedef struct ngx_cycle_s           ngx_cycle_t;
typedef struct ngx_pool_s            ngx_pool_t;
typedef struct ngx_chain_s           ngx_chain_t;
typedef struct ngx_log_s             ngx_log_t;
typedef struct ngx_open_file_s       ngx_open_file_t;

工具

OpenResty团队维护ngx-releng工具。它静态地扫描当前C文件,校验本文档所覆盖的风格,但不全部。`ngx-releng是OpenResty核心开发的必选工具,也有助于
NGINX模块开发者或者NGINX核心开发者。我们会持续增加更多地校验功能,同时我们也期待您的加入。

clang静态代码分析器对于获取代码隐匿问题是一个好帮手,你可以开启优化配置标志以编译一切。

很多编辑器提供了高亮后者自动截除行尾的空白符,也可以将制表符扩展成空格。比如,在vim中我们可以将下面的
配置放入到 ~/.vimrc,可以标亮任何行尾空白符:

highlight WhiteSpaceEOL ctermbg=darkgreen guibg=lightgreen
match WhiteSpaceEOL /\s$/
autocmd WinEnter * match WhiteSpaceEOL /\s$/

或者设置一组简便的属性:

set expandtab
set shiftwidth=4
set softtabstop=4
set tabstop=4

Goto语句和代码标签

NGINX中使用goto 语句进行错误处理是十分明智,这是对臭名昭著的goto语句使用比较好的场景。许多非资深的C语言程序员对于goto语句使用存在恐慌,这其实是不公平的。(PS:Dijkstra大神的Go To Statement Considered Harmful,然而在《代码大全》一书中:

90%情况下用goto都是错的,但少数情况goto确实有效(比如在错误处理中的多重判断,又如两个条件判断和一个else子句的情况),是解决问 题的合理办法。这时候用goto无妨,但应该加注释说明理由。

)。使用goto 语句往回跳转是十分糟糕的,其他是可以的,特别是错误处理。NGINX要求代码标签包裹在空行中,比如:

        p = ngx_pnalloc(pool, len);
        if (p == NULL) {
            goto failed;
        }

        ...

        i++;
    }

    freeaddrinfo(res);
    return NGX_OK;

failed:

    freeaddrinfo(res);
    return NGX_ERROR;

校验空指针

在NGINX中,常常使用 p == NULL,而不是 !p 检查一个指针的值是否为NULL。只要校验指针是否为NULL的地方就遵守此约定。同理,推荐使用 p != NULL而不是 p 检验指针的值是非NULL,但是有时候使用 p也是可以的。

下面是一些例子:

if (addrs != NULL) {
if (name == NULL) {

检验 NULL通常清楚的表明了检查值属性,并且提高代码的可读性。

作者

本指南由OpenResty的创建者章亦春撰写。

反馈与建议

有任何反馈或者建议都可以发送邮件章亦春

posted on 2019-02-26 21:35  CloudFeng  阅读(323)  评论(0编辑  收藏  举报