对于Nginx的整体框架, 尚且只能了解个大概, 并不能弄清除整个流程. 索性先放着, 先了解其他组件.
事件处理框架所要解决的问题是 如何收集, 管理, 分发事件. 且事件类型主要为网络事件和定时器事件.
既然需要支持跨平台, 那么就肯定要封装不同平台的事件驱动机制. 当然, 我只能看懂select, poll, epoll这几个... 那么Nginx是如何选择的呢?
1 . 之前在对框架作了解时, 知道了整个Nginx框架为了不那么复杂, 只定义配置模块和核心模块. 而核心模块中又有其他模块的代言模块.
对于事件模块而言, 其在核心模块中的代言模块就是ngx_events_module模块. 所以可以这样理解, ngx_events_module不是事件模块而是核心模块, 但它定义了事件模块.
在Nginx启动过程中调用的ngx_cycle_init方法解析配置项时, 一旦在配置文件中找到ngx_events_module感兴趣的"event{}"配置项, 该核心模块就开始工作了. 此模块定义了事件类型的模块, 它的全部工作就是为所有事件模块解析"event{}"中的配置项, 同时管理这些事件模块存储配置项的结构体.
2 . 只是, 除了ngx_events_module对于事件模块很重要外, 还有一个事件模块 ngx_event_core_module也十分重要. 这个模块会决定使用哪种事件驱动机制, 以及如何管理事件
3 . 当然, 其他就是一系列不同系统不同内核版本的事件驱动模块的封装而成的模块了.
下面开始分析Nginx的事件模块:
关于事件模块中的事件
在Nginx中, 每个事件都由ngx_event_t结构体来表示. 所以以此作为切入点.
以上并没有完整的列出该结构体中的每个元素, 是为了更容易专注当前分析的部分.
对于每个事件, 其最核心的部分就是handler回调方法, 所有的Nginx模块只要处理事件就必然要设置handler回调方法.
既然handler决定如何消费事件, 那么如何触发这个消费事件呢?
这时候就是将事件放到事件驱动机制中去(epoll..), 等待被触发之后调用handler函数. 那么如何将事件添加到epoll中呢?
按照没有被封装过的epoll, 我们立即能想到调用epoll_ctl函数. 所以肯定会有结构体封装这类操作函数:
下面结构体定义了每个事件模块都要实现的接口(ngx_event_core_module和几个事件驱动模块):
所以, 如果想要往epoll中添加事件的话, 可以调用封装好的add函数. 只是因为平台不同, Nginx提供了两个简单的方法用于在事件驱动模块中添加删除事件
下面是这两个函数的原型:
其第二个参数可以是0或NGX_CLOSE_EVENT(但NGX_CLOSE_EVENT只在epoll的LT模式有效, 但epoll在Nginx中默认使用ET模式, 所以这个标志一般不用)
第二个函数将写事件添加到事件驱动模块中.
第二个参数lowat表示只有当连接对应的套接字缓冲区中必须有lowat大小的可用空间时, 事件收集器才处理这个可写事件(lowat为0表示不考虑可写缓冲区大小)
关于事件中的连接事件
对于web服务器 , 每个用户请求至少对应一个TCP连接; 为了处理这个事件, 每个连接至少需要一个读事件和一个写事件, 这样才能根据触发事件调度相应模块读取请求或是发送响应.
因此, Nginx定义了结构体 ngx_connection_t来表示连接, 表示被动接收的连接. 要注意的是, 这种连接不能随意创建, 必须从连接池(数组)中获取.
谈到连接池, 那么我们可以想象, 某个连接创建某个连接释放都是不固定的, 所以每次想要向连接池申请连接时, 都需要遍历一遍连接池寻找空闲连接. 这种效率自然很差, 所以Nginx定义了空闲连接池用以提供空闲连接. 空闲连接池的实现是利用了ngx_connection_t中的某个暂时不用的指针来作为连接下一个空闲连接的枢纽的.
不同的事件驱动机制需要使用的发送接收的方法也多是不同的
关于连接池, 上面谈到一点, 这里再做补充.
从下图可以看出, 在ngx_cycle_t中的connections和free_connections两个成员构成了一个连接池. 其中connections指向连接池首部, free_connections则指向第一个空闲连接.
所有空闲连接都以data成员作为next指针串联成一个单链表. 一旦有申请连接, 就返回free_connections指向的空闲连接, free_connections继续指向下一个空闲连接.
归还连接时则只需把连接插入到free_connections链表的表头即可.(有点静态链表的感觉)
除了分配连接, Nginx还认为每个连接至少需要一个读事件和写事件, 于是在ngx_cycle_t结构体中可以发现read_events和write_events这两个数组. 其数组大小和连接池大小相同. 所以根据下标就能将一个连接与两种事件相联系起来
Nginx也定义了两个用于申请回收连接的函数:
文章一开始也提到了, Nginx框架定义了配置模块与核心模块, 某些核心模块又定义了事件模块, HTTP模块等.
ngx_events_module就是定义了事件模块的核心模块. 此模块定义了上面涉及的事件类型, 每个事件模块都要实现的ngx_events_module_t接口, 管理这些事件模块生成的配置项结构体, 并解析事件类型配置项, 同时, 会调用其在ngx_commands_t数组中定义的回调方法
既然ngx_events_module定义了事件模块, 且有了以上结构体的认识, 那么就从ngx_events_module模块开始对事件模块进行整体的认识
作为核心模块, ngx_events_module还要实现核心模块的共同接口:
除了上面两个结构体, 每个模块都必须实现的模块接口:
但是, 在关注此函数之前, 我们需要理清思路, 每个事件模块都有为其自己创建的存储配置项的结构体(就是利用ngx_events_module_t中的craete_conf函数). 事件模块只需要在被调用时为其结构体分配内存即可, 那么这么多事件模块的配置项结构体的指针是如何被ngx_events_module管理/存储的呢?
每一个事件模块产生的配置项结构体都会被存放到ngx_events_module模块创建的 指针数组中, 既然如此, 该指针数组又是被存放在了哪里呢?
该指针数组被存放在ngx_cycle_t结构体的conf_ctx成员中. 这个成员是四级指针, 即为存放((指针数组)的地址)的数组, 粗略的看就是指针数组 (或者说, 它首先指向一个存放指针的数组, 这个数组中的指针成员同时又指向另外一个存放指针的数组). 这个指针数组就依次存放着所有Nginx模块关于配置项方面的指针. 顺序在ngx_modules.c文件中已经被定义好了. 可以看到, ngx_events_module被放在第4个位置, 那么所有进程的conf_ctx数组的第4个指针就保存着ngx_events_module产生的指针数组.
如下, 是Nginx为了方便获取某个事件模块结构体而定义的宏:
也可以发现, 没有给该核心模块创建存储配置项的结构体, 此时就被用来存放所有事件模块的结构体了.
如果我们想要获得某事件模块的结构体, 那么只要传入ngx_cycle_t的conf_ctx成员, 与对应模块的名称就可以了.
接下来就回到ngx_events_block函数.
不过, 对于events{...}块来说, 解析的配置项可能就是worker_connection指令设置每个worker的连接数, 可能是accept_mutex指令就是指定是否使用accept锁等等...
http://blog.csdn.net/chosen0ne/article/details/7741482
事件处理框架所要解决的问题是 如何收集, 管理, 分发事件. 且事件类型主要为网络事件和定时器事件.
既然需要支持跨平台, 那么就肯定要封装不同平台的事件驱动机制. 当然, 我只能看懂select, poll, epoll这几个... 那么Nginx是如何选择的呢?
1 . 之前在对框架作了解时, 知道了整个Nginx框架为了不那么复杂, 只定义配置模块和核心模块. 而核心模块中又有其他模块的代言模块.
对于事件模块而言, 其在核心模块中的代言模块就是ngx_events_module模块. 所以可以这样理解, ngx_events_module不是事件模块而是核心模块, 但它定义了事件模块.
在Nginx启动过程中调用的ngx_cycle_init方法解析配置项时, 一旦在配置文件中找到ngx_events_module感兴趣的"event{}"配置项, 该核心模块就开始工作了. 此模块定义了事件类型的模块, 它的全部工作就是为所有事件模块解析"event{}"中的配置项, 同时管理这些事件模块存储配置项的结构体.
2 . 只是, 除了ngx_events_module对于事件模块很重要外, 还有一个事件模块 ngx_event_core_module也十分重要. 这个模块会决定使用哪种事件驱动机制, 以及如何管理事件
3 . 当然, 其他就是一系列不同系统不同内核版本的事件驱动模块的封装而成的模块了.
下面开始分析Nginx的事件模块:
关于事件模块中的事件
在Nginx中, 每个事件都由ngx_event_t结构体来表示. 所以以此作为切入点.
struct ngx_event_s{
//事件相关对象, 通常data指向ngx_connection_t连接对象. (开启异步IO后可能指向ngx_event_aio_t结构体)
void *data;
...
//为1时表示可以建立连接. 通常在ngx_cycle_t中的listening动态数组中, 每一个监听对象ngx_listening_t对应的读事件中的accept才会是1
unsigned accept;
//这个标志位用于区分当前事件是否过期. 它仅仅是给事件驱动模块使用的(epoll等模块), 无关事件消费模块(http等模块)
//当需要处理一批事件时, 处理这批事件前面的事件可能导致关闭后面的连接, 而这些被关闭的连接可能影响到这批事件中还未处理的事件.
//这时, 可以通过此标志位来处理来避免处理后面已经过期的事件
//看不懂很正常, 下面会细谈.
unsigned instance;
...
//标志, 为1表示延迟建立TCP连接, 即经过三次握手后并不建立连接, 而是正真数据包来后才建立TCP连接
unsigned deferred_accept;
...
//在epoll事件驱动机制下表示一次尽可能多的建立TCP连接, 与配置文件中的配置项"multi_accept"对应
unsigned available;
//这个事件发生时的处理方法, 每个事件消费模块都会重新实现它, 以决定这个事件最终如何消费
ngx_event_handler_pt handler;
//定时器节点, 用于定时器红黑树中
ngx_rbtree_node_t timer;
...
//post事件将构成一个队列再统一处理, 队列以next和prev作为链表指针, 以此构成一个简易的双向链表
ngx_event_t *next;
ngx_event_t **prev;
};
以上并没有完整的列出该结构体中的每个元素, 是为了更容易专注当前分析的部分.
对于每个事件, 其最核心的部分就是handler回调方法, 所有的Nginx模块只要处理事件就必然要设置handler回调方法.
既然handler决定如何消费事件, 那么如何触发这个消费事件呢?
这时候就是将事件放到事件驱动机制中去(epoll..), 等待被触发之后调用handler函数. 那么如何将事件添加到epoll中呢?
按照没有被封装过的epoll, 我们立即能想到调用epoll_ctl函数. 所以肯定会有结构体封装这类操作函数:
下面结构体定义了每个事件模块都要实现的接口(ngx_event_core_module和几个事件驱动模块):
typedef struct {
ngx_str_t *name; //事件模块的名称
void *(*create_conf)(ngx_cycle_t *cycle); //创建存储配置项的结构体
char *(*init_conf)(ngx_cycle_t *cycle, void *conf); //解析完配置项后, 此函数用于综合处理当前模块感兴趣的全部配置项
ngx_event_actions_t actions; //每个事件驱动机制都要实现的10个方法. 比如添加事件, 删除事件, 初始化等等
} ngx_events_module_t;
从ngx_event_actions_t这个结构体就可以看出, 这就是封装操作函数的结构体:
typedef struct {
//添加和删除事件
ngx_int_t (*add)(ngx_event_t *ev, ngx_int_t event, ngx_uint_t flags);
ngx_int_t (*del)(ngx_event_t *ev, ngx_int_t event, ngx_uint_t flags);
...
//向事件驱动机制中添加或删除一个连接的读写事件
ngx_int_t (*add_conn)(ngx_connection_t *c);
ngx_int_t (*del_conn)(ngx_connection_t *c, ngx_uint_t flags);
...
//在正常的工作循环中, 将通过调用此方法来处理事件, 此方法是处理, 分发事件的核心
ngx_int_t (*process_events)(ngx_cycle_t *cycle, ngx_msec_t timer,
ngx_uint_t flags);
//初始化事件驱动模块
ngx_int_t (*init)(ngx_cycle_t *cycle, ngx_msec_t timer);
//退出事件驱动模块前调用的方法
void (*done)(ngx_cycle_t *cycle);
} ngx_event_actions_t;
所以, 如果想要往epoll中添加事件的话, 可以调用封装好的add函数. 只是因为平台不同, Nginx提供了两个简单的方法用于在事件驱动模块中添加删除事件
下面是这两个函数的原型:
ngx_int_t ngx_handle_read_event(ngx_event_t *rev, ngx_uint_t flags);
ngx_int_t ngx_handle_write_event(ngx_event_t *wev, size_t lowat);
第一个函数用于将读事件添加到事件驱动模块中, 这样该事件对应的TCP连接一旦出现可读事件, 就会回调该事件的handler方法.
其第二个参数可以是0或NGX_CLOSE_EVENT(但NGX_CLOSE_EVENT只在epoll的LT模式有效, 但epoll在Nginx中默认使用ET模式, 所以这个标志一般不用)
第二个函数将写事件添加到事件驱动模块中.
第二个参数lowat表示只有当连接对应的套接字缓冲区中必须有lowat大小的可用空间时, 事件收集器才处理这个可写事件(lowat为0表示不考虑可写缓冲区大小)
关于事件中的连接事件
对于web服务器 , 每个用户请求至少对应一个TCP连接; 为了处理这个事件, 每个连接至少需要一个读事件和一个写事件, 这样才能根据触发事件调度相应模块读取请求或是发送响应.
因此, Nginx定义了结构体 ngx_connection_t来表示连接, 表示被动接收的连接. 要注意的是, 这种连接不能随意创建, 必须从连接池(数组)中获取.
谈到连接池, 那么我们可以想象, 某个连接创建某个连接释放都是不固定的, 所以每次想要向连接池申请连接时, 都需要遍历一遍连接池寻找空闲连接. 这种效率自然很差, 所以Nginx定义了空闲连接池用以提供空闲连接. 空闲连接池的实现是利用了ngx_connection_t中的某个暂时不用的指针来作为连接下一个空闲连接的枢纽的.
struct ngx_connection_s {
//因为Nginx采用了事先创建好连接池的策略, 所以当连接未使用时, data成员用于充当连接池中空闲连接链表中的next指针.
//当连接被使用时, data的意义由使用的模块而定. 比如在HTTP模块中, data指向ngx_http_request_t请求
void *data;
//对应的读写事件
ngx_event_t *read;
ngx_event_t *write;
//套接字句柄
ngx_socket_t fd;
//直接接收/发送网络字节流的方法
ngx_recv_pt recv;
ngx_send_pt send;
//以ngx_chain_t链表为参数来接收/发送网络字符流的方法
ngx_recv_chain_pt recv_chain;
ngx_send_chain_pt send_chain;
//这个连接对应的ngx_listening_t对象, 此连接由listening监听端口的事件建立
ngx_listening_t *listening;
//这个连接已经发送出去的字节数
off_t sent;
ngx_log_t *log;
//内存池, 一般在accept一个新连接时, 会创建一个内存池, 而在连接结束时会销毁内存池.
//内存池大小将由listening监听对象中的pool_size来指定
ngx_pool_t *pool;
...
struct sockaddr *local_sockaddr;
socklen_t local_socklen;
//用于接收, 缓存来自客户发来的字节流. 每个事件消费模块可以自由决定从连接池中分配多大空间给buffer这个字段
//比如HTTP模块, 它的大小决定于client_header_buffer_size配置项
ngx_buf_t *buffer;
//该字段用来将当前连接以双向链表元素的形式添加到ngx_cycle_t核心结构体的reusable_connections_queue双向链表中
//表示可重用的连接
ngx_queue_t queue;
//连接使用次数
ngx_atomic_uint_t number;
//处理的请求次数
ngx_uint_t requests;
...
//表示TCP连接被销毁了, 此时此结构体仍可用, 但其套接字, 内存池已不可用了
unsigned destroyed:1;
unsigned idle:1;
unsigned reusable:1;
unsigned close:1;
unsigned sendfile:1;
//表示发送缓冲区的阀值
unsigned sndlowat:1;
unsigned tcp_nodelay:2; /* ngx_connection_tcp_nodelay_e */
unsigned tcp_nopush:2; /* ngx_connection_tcp_nopush_e */
...
};
这其中的4个关于接收, 发送网络字节流的方法以指针的形式出现, 说明每个连接都可以采用不同的接收方法, 每个事件消费模块都可以灵活决定其行为.
不同的事件驱动机制需要使用的发送接收的方法也多是不同的
关于连接池, 上面谈到一点, 这里再做补充.
从下图可以看出, 在ngx_cycle_t中的connections和free_connections两个成员构成了一个连接池. 其中connections指向连接池首部, free_connections则指向第一个空闲连接.
所有空闲连接都以data成员作为next指针串联成一个单链表. 一旦有申请连接, 就返回free_connections指向的空闲连接, free_connections继续指向下一个空闲连接.
归还连接时则只需把连接插入到free_connections链表的表头即可.(有点静态链表的感觉)
除了分配连接, Nginx还认为每个连接至少需要一个读事件和写事件, 于是在ngx_cycle_t结构体中可以发现read_events和write_events这两个数组. 其数组大小和连接池大小相同. 所以根据下标就能将一个连接与两种事件相联系起来
Nginx也定义了两个用于申请回收连接的函数:
ngx_connection_t *ngx_get_connection(ngx_socket_t s, ngx_log_t *log);
void ngx_free_connection(ngx_connection_t *c);
文章一开始也提到了, Nginx框架定义了配置模块与核心模块, 某些核心模块又定义了事件模块, HTTP模块等.
ngx_events_module就是定义了事件模块的核心模块. 此模块定义了上面涉及的事件类型, 每个事件模块都要实现的ngx_events_module_t接口, 管理这些事件模块生成的配置项结构体, 并解析事件类型配置项, 同时, 会调用其在ngx_commands_t数组中定义的回调方法
既然ngx_events_module定义了事件模块, 且有了以上结构体的认识, 那么就从ngx_events_module模块开始对事件模块进行整体的认识
static ngx_command_t ngx_events_commands[] = {
{ ngx_string("events"),
NGX_MAIN_CONF|NGX_CONF_BLOCK|NGX_CONF_NOARGS,
ngx_events_block,
0,
0,
NULL },
ngx_null_command
};
可见, 在配置文件中遇到"events{}"后, ngx_events_module就会调用ngx_events_block来处理.
作为核心模块, ngx_events_module还要实现核心模块的共同接口:
static ngx_core_module_t ngx_events_module_ctx = {
ngx_string("events"),
NULL,
ngx_event_init_conf
};
如果还有印象的话, 可以想起在Nginx框架中, 在解析配置文件前, 有这么一段代码:
for (i = 0; ngx_modules[i]; i++) {
if (ngx_modules[i]->type != NGX_CORE_MODULE) {
continue;
}
module = ngx_modules[i]->ctx;
if (module->create_conf) {
rv = module->create_conf(cycle);
if (rv == NULL) {
ngx_destroy_pool(pool);
return NULL;
}
cycle->conf_ctx[ngx_modules[i]->index] = rv;
}
}
不过, 在ngx_events_module中实现的核心模块却没有实现此函数, ngx_event_init_conf函数也只是简单进行正确性判断. 这是因为ngx_events_module模块并不会解析配置项的参数, 只是在出现events配置项后会调用各事件模块去解析events{...}块内的配置项, 所以也就不需要什么存储结构体了.
除了上面两个结构体, 每个模块都必须实现的模块接口:
ngx_module_t ngx_events_module = {
NGX_MODULE_V1,
&ngx_events_module_ctx, /* module context */
ngx_events_commands, /* module directives */
NGX_CORE_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
};
可见, 除了对events{...}内的配置项进行解析外, 此模块并没有做其他事情. 所以重点就关注ngx_events_block函数.
但是, 在关注此函数之前, 我们需要理清思路, 每个事件模块都有为其自己创建的存储配置项的结构体(就是利用ngx_events_module_t中的craete_conf函数). 事件模块只需要在被调用时为其结构体分配内存即可, 那么这么多事件模块的配置项结构体的指针是如何被ngx_events_module管理/存储的呢?
每一个事件模块产生的配置项结构体都会被存放到ngx_events_module模块创建的 指针数组中, 既然如此, 该指针数组又是被存放在了哪里呢?
该指针数组被存放在ngx_cycle_t结构体的conf_ctx成员中. 这个成员是四级指针, 即为存放((指针数组)的地址)的数组, 粗略的看就是指针数组 (或者说, 它首先指向一个存放指针的数组, 这个数组中的指针成员同时又指向另外一个存放指针的数组). 这个指针数组就依次存放着所有Nginx模块关于配置项方面的指针. 顺序在ngx_modules.c文件中已经被定义好了. 可以看到, ngx_events_module被放在第4个位置, 那么所有进程的conf_ctx数组的第4个指针就保存着ngx_events_module产生的指针数组.
如下, 是Nginx为了方便获取某个事件模块结构体而定义的宏:
#define ngx_event_get_conf(conf_ctx, module) \
(*(ngx_get_conf(conf_ctx, ngx_events_module))) [module.ctx_index];
#define ngx_get_conf(conf_ctx, module) conf_ctx[module.index]
看到这个, 我们就容易理解为什么在ngx_module_t中要定义index和ctx_index这两个索引了.
也可以发现, 没有给该核心模块创建存储配置项的结构体, 此时就被用来存放所有事件模块的结构体了.
如果我们想要获得某事件模块的结构体, 那么只要传入ngx_cycle_t的conf_ctx成员, 与对应模块的名称就可以了.
接下来就回到ngx_events_block函数.
static char *
ngx_events_block(ngx_conf_t *cf, ngx_command_t *cmd, void *conf)
{
char *rv;
void ***ctx;
ngx_uint_t i;
ngx_conf_t pcf;
ngx_events_module_t *m;
//虽然没能联系上下文, 但这里可以猜想就是ngx_cycle_t中的ctx_conf数组中的某一个元素(的指针? )
//如果已经出现过"events{...}"了, 就不会再重复处理第二次
if (*(void **) conf) {
return "is duplicate";
}
/* count the number of the event modules and set up their indices */
//为每个事件模块赋予一个ctx_index, 表示在事件模块中的顺序(index表示在所有模块中的顺序)
ngx_event_max_module = 0;
for (i = 0; ngx_modules[i]; i++) {
if (ngx_modules[i]->type != ngx_events_module) {
continue;
}
ngx_modules[i]->ctx_index = ngx_event_max_module++;
}
//以下几个步骤就是创建存储所有事件模块的存储配置项的结构体指针的数组
//而ctx就是一个指向指针数组的指针. (可以这样理解, 一个指向指针的指针就是二级指针, 那么指向指针数组的指针那自然是三级指针)
ctx = ngx_pcalloc(cf->pool, sizeof(void *));
if (ctx == NULL) {
return NGX_CONF_ERROR;
}
//分配指针数组所需空间
*ctx = ngx_pcalloc(cf->pool, ngx_event_max_module * sizeof(void *));
if (*ctx == NULL) {
return NGX_CONF_ERROR;
}
*(void **) conf = ctx;
//为每个事件模块创建其所需要的存储配置项的结构体
for (i = 0; ngx_modules[i]; i++) {
...
m = ngx_modules[i]->ctx;
if (m->create_conf) {
(*ctx)[ngx_modules[i]->ctx_index] = m->create_conf(cf->cycle);
...
}
}
pcf = *cf;
cf->ctx = ctx;
cf->module_type = ngx_events_module;
cf->cmd_type = NGX_EVENT_CONF;
//解析"events{...}"中的内容
rv = ngx_conf_parse(cf, NULL);
*cf = pcf;
if (rv != NGX_CONF_OK)
return rv;
//在解析完"events{...}“中的内容后, 为每个模块调用init函数, 最终处理配置项
for (i = 0; ngx_modules[i]; i++) {
...
m = ngx_modules[i]->ctx;
if (m->init_conf) {
rv = m->init_conf(cf->cycle, (*ctx)[ngx_modules[i]->ctx_index]);
...
}
}
return NGX_CONF_OK;
}
可以发现, 这里最主要的工作落在了ngx_conf_parse函数上, 只是我对配置模块的理解不够, 所以这里没有能力继续分析了...
不过, 对于events{...}块来说, 解析的配置项可能就是worker_connection指令设置每个worker的连接数, 可能是accept_mutex指令就是指定是否使用accept锁等等...
参考博客:
《深入理解NGINX》
http://blog.csdn.net/lengzijian/article/details/7598996http://blog.csdn.net/chosen0ne/article/details/7741482