FastCGI 프로토콜의 PHP 구현에 대한 심층적인 이해

黄舟
풀어 주다: 2023-03-14 10:32:01
원래의
1225명이 탐색했습니다.

FastCGI를 논하기 전에, 전통적인 CGI의 작동 원리에 대해 이야기해야 합니다. 동시에 CGI 1.1 프로토콜에 대한 일반적인 이해가 있어야 합니다.

전통적인 CGI의 작동 원리 분석

클라이언트가 URL 주소는 GET/POST/PUT 등의 데이터를 통해 제출되며, HTTP 프로토콜을 통해 웹 서버에 요청을 보냅니다. 서버 측 HTTP 데몬은 HTTP 요청에 기술된 정보를 지정된 CGI 프로그램에 전달합니다. 표준 입력 stdin 및 환경 변수를 통해 홈페이지에 접속하고 애플리케이션을 시작합니다. 프로그램은 처리(데이터베이스 처리 포함)를 수행하고 처리 결과는 표준 출력 stdout을 통해 HTTP 데몬 데몬 프로세스로 반환된 후 HTTP로 반환됩니다. 데몬 프로세스는 HTTP 프로토콜을 통해 이를 클라이언트에 반환합니다.

위 문단은 아직 이해하기에는 상대적으로 추상적일 수 있습니다. GET 요청을 예로 들어 자세히 설명하겠습니다.

다음 코드는 그림에 설명된 기능을 구현하는 데 사용됩니다. 웹 서버는 소켓 수신 서비스를 시작한 다음 CGI 프로그램을 로컬에서 실행합니다. 나중에 더 자세한 코드 해석이 있습니다.

웹 서버 코드

#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <sys/types.h>
#include <sys/socket.h>
#include <arpa/inet.h>
#include <netinet/in.h>
#include <string.h>

#define SERV_PORT 9003
char* str_join(char *str1, char *str2);
char* html_response(char *res, char *buf);
int main(void)
{
    int lfd, cfd;
    struct sockaddr_in serv_addr,clin_addr;
    socklen_t clin_len;
    char buf[1024],web_result[1024];
    int len;
    FILE *cin;

    if((lfd = socket(AF_INET,SOCK_STREAM,0)) == -1){
        perror("create socket failed");
        exit(1);
    }

    memset(&serv_addr, 0, sizeof(serv_addr));
    serv_addr.sin_family = AF_INET;
    serv_addr.sin_addr.s_addr = htonl(INADDR_ANY);
    serv_addr.sin_port = htons(SERV_PORT);

    if(bind(lfd, (struct sockaddr *)&serv_addr, sizeof(serv_addr)) == -1)
    {
        perror("bind error");
        exit(1);
    }

    if(listen(lfd, 128) == -1)
    {
        perror("listen error");
        exit(1);
    }

    signal(SIGCLD,SIG_IGN);

    while(1)
    {
        clin_len = sizeof(clin_addr);
        if ((cfd = accept(lfd, (struct sockaddr *)&clin_addr, &clin_len)) == -1)
        {
            perror("接收错误\n");
            continue;
        }

        cin = fdopen(cfd, "r");
        setbuf(cin, (char *)0);
        fgets(buf,1024,cin); //读取第一行
        printf("\n%s", buf);

        //============================ cgi 环境变量设置演示 ============================

        // 例如 "GET /user.cgi?id=1 HTTP/1.1";

        char *delim = " ";
        char *p;
        char *method, *filename, *query_string;
        char *query_string_pre = "QUERY_STRING=";

        method = strtok(buf,delim);         // GET
        p = strtok(NULL,delim);             // /user.cgi?id=1 
        filename = strtok(p,"?");           // /user.cgi

        if (strcmp(filename,"/favicon.ico") == 0)
        {
            continue;
        }

        query_string = strtok(NULL,"?");    // id=1
        putenv(str_join(query_string_pre,query_string));

        //============================ cgi 环境变量设置演示 ============================

        int pid = fork();

        if (pid > 0)
        {
            close(cfd);
        }
        else if (pid == 0)
        {
            close(lfd);
            FILE *stream = popen(str_join(".",filename),"r");
            fread(buf,sizeof(char),sizeof(buf),stream);
            html_response(web_result,buf);
            write(cfd,web_result,sizeof(web_result));
            pclose(stream);
            close(cfd);
            exit(0);
        }
        else
        {
            perror("fork error");
            exit(1);
        }
    }

    close(lfd);

    return 0;
}

char* str_join(char *str1, char *str2)
{
    char *result = malloc(strlen(str1)+strlen(str2)+1);
    if (result == NULL) exit (1);
    strcpy(result, str1);
    strcat(result, str2);

    return result;
}

char* html_response(char *res, char *buf)
{
    char *html_response_template = "HTTP/1.1 200 OK\r\nContent-Type:text/html\r\nContent-Length: %d\r\nServer: mengkang\r\n\r\n%s";

    sprintf(res,html_response_template,strlen(buf),buf);

    return res;
}
로그인 후 복사

위 코드의 핵심 포인트:

  • 라인 66~81은 CGI 프로그램의 상대 경로를 찾습니다. (단순화를 위해 루트 디렉터리를 웹 프로그램의 현재 디렉터리로 직접 정의합니다. ), CGI 프로그램이 하위 프로세스에서 실행되도록 환경 변수도 CGI 프로그램이 실행 중일 때 쉽게 읽을 수 있도록 설정됩니다. 94~95행은 CGI 프로그램의 표준 출력 결과를 캐시에 기록합니다. 웹 서버 데몬

  • 97행은 래핑된 html 결과를 클라이언트 소켓 설명자에 기록하고 이를 웹 서버에 연결된 클라이언트에 반환합니다.

  • CGI 프로그램 (user.c)

    #include <stdio.h>
    #include <stdlib.h>
    // 通过获取的 id 查询用户的信息
    int main(void){
    
        //============================ 模拟数据库 ============================
        typedef struct 
        {
            int  id;
            char *username;
            int  age;
        } user;
    
        user users[] = {
            {},
            {
                1,
                "mengkang.zhou",
                18
            }
        };
        //============================ 模拟数据库 ============================
    
        char *query_string;
        int id;
    
        query_string = getenv("QUERY_STRING");
    
        if (query_string == NULL)
        {
            printf("没有输入数据");
        } else if (sscanf(query_string,"id=%d",&id) != 1)
        {
            printf("没有输入id");
        } else
        {
            printf("用户信息查询<br>学号: %d<br>姓名: %s<br>年龄: %d",id,users[id].username,users[id].age);
        }
    
        return 0;
    }
    로그인 후 복사
  • 위 CGI 프로그램을
로 컴파일하여 위 웹 프로그램과 같은 디렉토리에 위치시킵니다.

코드의 28번째 줄, 환경 변수에서 웹 서버 데몬에 이전에 설정된 환경 변수를 읽는 것이 우리 데모의 초점입니다.

gcc user.c -o user.cgiFastCGI의 작동 원리 분석

CGI/1.1 사양과 비교하여 웹 서버는 CGI 프로그램을 실행하기 위해 로컬에서 하위 프로세스를 포크하고 CGI 사전 정의된 환경 변수를 채우고 이를 시스템 환경 변수에 넣은 다음 자식 프로세스의 표준 입력 Pass를 통해 HTTP 본문의 내용을 전달하고, 처리가 완료된 후 표준 출력을 통해 웹 서버에 반환합니다. FastCGI의 핵심은 전통적인 포크 및 실행 방법을 제거하고, 각 시작의 막대한 오버헤드를 줄이고(나중에 PHP를 예로 들어 설명) 상주 방식으로 요청을 처리하는 것입니다.

FastCGI 워크플로는 다음과 같습니다.

FastCGI 프로세스 관리자는 자체적으로 초기화되고 여러 CGI 인터프리터 프로세스를 시작하며 웹 서버의 연결을 기다립니다.

  1. 웹 서버는 소켓을 통해 FastCGI 프로세스 관리자와 통신하고 CGI 환경 변수 및 표준 입력 데이터를 FastCGI 프로토콜을 통해 CGI 인터프리터 프로세스에 보냅니다.

  2. CGI 인터프리터 프로세스는 처리를 완료한 후 동일한 연결에서 표준 출력 및 오류 정보를 웹 서버로 반환합니다.

  3. CGI 인터프리터 프로세스는 웹 서버로부터의 다음 연결을 기다리고 처리합니다.

FastCGI와 기존 CGI 모드의 차이점 중 하나는 웹 서버가 CGI 프로그램을 직접 실행하지 않고 웹 서버에 필요한 소켓을 통해 FastCGI 응답자(FastCGI 프로세스 관리자)와 상호 작용한다는 것입니다. CGI 인터페이스 연결 데이터는 FastCGI 프로토콜을 따르는 패킷에 캡슐화되어 FastCGI 응답자 프로그램으로 전송됩니다. FastCGI 프로세스 관리자는 소켓 통신을 기반으로 하기 때문에 웹 서버와 CGI 응답자 서버도 별도로 배포됩니다.

한 가지 더, FastCGI는 CGI/1.1을 기반으로 하는 프로토콜입니다. CGI/1.1에서 전송되는 데이터는 FastCGI 프로토콜에서 정의한 순서와 형식으로 전송됩니다.

준비

아마도 위 내용은 아직 이해하기 매우 추상적일 수 있습니다. 이는 첫째, FastCGI 프로토콜에 대한 일반적인 이해가 없고, 둘째, 실제 코드 학습이 없기 때문입니다. 따라서 FastCGI 프로토콜의 내용을 미리 공부할 필요가 있으며, 반드시 완전히 이해할 필요는 없으며, 전반적인 이해를 마친 후 이 글을 읽고 학습과 이해를 결합하면 됩니다.

FastCGI 프로토콜 분석

다음은 특별한 설명 없이 PHP의 FastCGI 코드와 함께 분석한 것이며, 아래 코드는 모두 PHP 소스 코드에서 가져온 것입니다.

FastCGI 메시지 유형

FastCGI는 전송되는 메시지를 여러 유형으로 나누어 구조를 다음과 같이 정의합니다.

typedef enum _fcgi_request_type {
    FCGI_BEGIN_REQUEST      =  1, /* [in]                              */
    FCGI_ABORT_REQUEST      =  2, /* [in]  (not supported)             */
    FCGI_END_REQUEST        =  3, /* [out]                             */
    FCGI_PARAMS             =  4, /* [in]  environment variables       */
    FCGI_STDIN              =  5, /* [in]  post data                   */
    FCGI_STDOUT             =  6, /* [out] response                    */
    FCGI_STDERR             =  7, /* [out] errors                      */
    FCGI_DATA               =  8, /* [in]  filter data (not supported) */
    FCGI_GET_VALUES         =  9, /* [in]                              */
    FCGI_GET_VALUES_RESULT  = 10  /* [out]                             */
} fcgi_request_type;
로그인 후 복사

메시지 전송 순서

아래 그림은 간단한 메시지 전달 과정

最先发送的是FCGI_BEGIN_REQUEST,然后是FCGI_PARAMSFCGI_STDIN,由于每个消息头(下面将详细说明)里面能够承载的最大长度是65535,所以这两种类型的消息不一定只发送一次,有可能连续发送多次。

FastCGI 响应体处理完毕之后,将发送FCGI_STDOUTFCGI_STDERR,同理也可能多次连续发送。最后以FCGI_END_REQUEST表示请求的结束。

需要注意的一点,FCGI_BEGIN_REQUESTFCGI_END_REQUEST分别标识着请求的开始和结束,与整个协议息息相关,所以他们的消息体的内容也是协议的一部分,因此也会有相应的结构体与之对应(后面会详细说明)。而环境变量、标准输入、标准输出、错误输出,这些都是业务相关,与协议无关,所以他们的消息体的内容则无结构体对应。

由于整个消息是二进制连续传递的,所以必须定义一个统一的结构的消息头,这样以便读取每个消息的消息体,方便消息的切割。这在网络通讯中是非常常见的一种手段。

FastCGI 消息头

如上,FastCGI 消息分10种消息类型,有的是输入有的是输出。而所有的消息都以一个消息头开始。其结构体定义如下:

typedef struct _fcgi_header {
    unsigned char version;
    unsigned char type;
    unsigned char requestIdB1;
    unsigned char requestIdB0;
    unsigned char contentLengthB1;
    unsigned char contentLengthB0;
    unsigned char paddingLength;
    unsigned char reserved;
} fcgi_header;
로그인 후 복사

字段解释下:

  • version标识FastCGI协议版本。

  • type 标识FastCGI记录类型,也就是记录执行的一般职能。

  • requestId标识记录所属的FastCGI请求。

  • contentLength记录的contentData组件的字节数。

关于上面的xxB1xxB0的协议说明:当两个相邻的结构组件除了后缀“B1”和“B0”之外命名相同时,它表示这两个组件可视为估值为B1<<8 + B0的单个数字。该单个数字的名字是这些组件减去后缀的名字。这个约定归纳了一个由超过两个字节表示的数字的处理方式。

比如协议头中requestIdcontentLength表示的最大值就是65535

#include <stdio.h>
#include <stdlib.h>
#include <limits.h>

int main()
{
   unsigned char requestIdB1 = UCHAR_MAX;
   unsigned char requestIdB0 = UCHAR_MAX;
   printf("%d\n", (requestIdB1 << 8) + requestIdB0); // 65535
}
로그인 후 복사

你可能会想到如果一个消息体长度超过65535怎么办,则分割为多个相同类型的消息发送即可。

FCGI_BEGIN_REQUEST 的定义

typedef struct _fcgi_begin_request {
    unsigned char roleB1;
    unsigned char roleB0;
    unsigned char flags;
    unsigned char reserved[5];
} fcgi_begin_request;
로그인 후 복사

字段解释

role表示Web服务器期望应用扮演的角色。分为三个角色(而我们这里讨论的情况一般都是响应器角色)

typedef enum _fcgi_role {
    FCGI_RESPONDER    = 1,
    FCGI_AUTHORIZER    = 2,
    FCGI_FILTER        = 3
} fcgi_role;
로그인 후 복사

FCGI_BEGIN_REQUEST中的flags组件包含一个控制线路关闭的位:flags & FCGI_KEEP_CONN:如果为0,则应用在对本次请求响应后关闭线路。如果非0,应用在对本次请求响应后不会关闭线路;Web服务器为线路保持响应性。

FCGI_END_REQUEST 的定义

typedef struct _fcgi_end_request {
    unsigned char appStatusB3;
    unsigned char appStatusB2;
    unsigned char appStatusB1;
    unsigned char appStatusB0;
    unsigned char protocolStatus;
    unsigned char reserved[3];
} fcgi_end_request;
로그인 후 복사

字段解释

appStatus组件是应用级别的状态码。
protocolStatus组件是协议级别的状态码;protocolStatus的值可能是:

FCGI_REQUEST_COMPLETE:请求的正常结束。
FCGI_CANT_MPX_CONN:拒绝新请求。这发生在Web服务器通过一条线路向应用发送并发的请求时,后者被设计为每条线路每次处理一个请求。
FCGI_OVERLOADED:拒绝新请求。这发生在应用用完某些资源时,例如数据库连接。
FCGI_UNKNOWN_ROLE:拒绝新请求。这发生在Web服务器指定了一个应用不能识别的角色时。

protocolStatus在 PHP 中的定义如下

typedef enum _fcgi_protocol_status {
    FCGI_REQUEST_COMPLETE    = 0,
    FCGI_CANT_MPX_CONN        = 1,
    FCGI_OVERLOADED            = 2,
    FCGI_UNKNOWN_ROLE        = 3
} dcgi_protocol_status;
로그인 후 복사

需要注意dcgi_protocol_statusfcgi_role各个元素的值都是 FastCGI 协议里定义好的,而非 PHP 自定义的。

消息通讯样例

为了简单的表示,消息头只显示消息的类型和消息的 id,其他字段都不予以显示。下面的例子来自于官网

{FCGI_BEGIN_REQUEST,   1, {FCGI_RESPONDER, 0}}
{FCGI_PARAMS,          1, "\013\002SERVER_PORT80\013\016SERVER_ADDR199.170.183.42 ... "}
{FCGI_STDIN,           1, "quantity=100&item=3047936"}
{FCGI_STDOUT,          1, "Content-type: text/html\r\n\r\n<html>\n<head> ... "}
{FCGI_END_REQUEST,     1, {0, FCGI_REQUEST_COMPLETE}}
로그인 후 복사

配合上面各个结构体,则可以大致想到 FastCGI 响应器的解析和响应流程:

首先读取消息头,得到其类型为FCGI_BEGIN_REQUEST,然后解析其消息体,得知其需要的角色就是FCGI_RESPONDERflag为0,表示请求结束后关闭线路。然后解析第二段消息,得知其消息类型为FCGI_PARAMS,然后直接将消息体里的内容以回车符切割后存入环境变量。与之类似,处理完毕之后,则返回了FCGI_STDOUT消息体和FCGI_END_REQUEST消息体供 Web 服务器解析。

PHP 中的 FastCGI 的实现

下面对代码的解读笔记只是我个人知识的一个梳理提炼,如有勘误,请大家指出。对不熟悉该代码的同学来说可能是一个引导,初步认识,如果觉得很模糊不清晰,那么还是需要自己逐行去阅读。

php-src/sapi/cgi/cgi_main.c为例进行分析说明,假设开发环境为 unix 环境。main 函数中一些变量的定义,以及 sapi 的初始化,我们就不讨论在这里讨论了,只说明关于 FastCGI 相关的内容。

1.开启一个 socket 监听服务

fcgi_fd = fcgi_listen(bindpath, 128);
로그인 후 복사

从这里开始监听,而fcgi_listen函数里面则完成 socket 服务前三步socket,bind,listen

2.初始化请求对象

fcgi_request对象分配内存,绑定监听的 socket 套接字。

fcgi_init_request(&request, fcgi_fd);
로그인 후 복사

整个请求从输入到返回,都围绕着fcgi_request结构体对象在进行。

typedef struct _fcgi_request {
    int            listen_socket;
    int            fd;
    int            id;
    int            keep;
    int            closed;

    int            in_len;
    int            in_pad;

    fcgi_header   *out_hdr;
    unsigned char *out_pos;
    unsigned char  out_buf[1024*8];
    unsigned char  reserved[sizeof(fcgi_end_request_rec)];

    HashTable     *env;
} fcgi_request;
로그인 후 복사

3.创建多个 CGI 解析器子进程

这里子进程的个数默认是0,从配置文件中读取设置到环境变量,然后在程序中读取,然后创建指定数目的子进程来等待处理 Web 服务器的请求。

if (getenv("PHP_FCGI_CHILDREN")) {
    char * children_str = getenv("PHP_FCGI_CHILDREN");
    children = atoi(children_str);
    ...
}

do {
    pid = fork();
    switch (pid) {
    case 0:
        parent = 0; // 将子进程中的父进程标识改为0,防止循环 fork

        /* don&#39;t catch our signals */
        sigaction(SIGTERM, &old_term, 0);
        sigaction(SIGQUIT, &old_quit, 0);
        sigaction(SIGINT,  &old_int,  0);
        break;
    case -1:
        perror("php (pre-forking)");
        exit(1);
        break;
    default:
        /* Fine */
        running++;
        break;
    }
} while (parent && (running < children));
로그인 후 복사

4.在子进程中接收请求

到这里一切都还是 socket 的服务的套路。接受请求,然后调用了fcgi_read_request

fcgi_accept_request(&request)
로그인 후 복사
int fcgi_accept_request(fcgi_request *req)
{
    int listen_socket = req->listen_socket;
    sa_t sa;
    socklen_t len = sizeof(sa);
    req->fd = accept(listen_socket, (struct sockaddr *)&sa, &len);

    ...

    if (req->fd >= 0) {
        // 采用多路复用的机制
        struct pollfd fds;
        int ret;

        fds.fd = req->fd;
        fds.events = POLLIN;
        fds.revents = 0;
        do {
            errno = 0;
            ret = poll(&fds, 1, 5000);
        } while (ret < 0 && errno == EINTR);
        if (ret > 0 && (fds.revents & POLLIN)) {
            break;
        }
        // 仅仅是关闭 socket 连接,不清空 req->env
        fcgi_close(req, 1, 0);
    }

    ...

    if (fcgi_read_request(req)) {
        return req->fd;
    }
}
로그인 후 복사

并且把request放入全局变量sapi_globals.server_context,这点很重要,方便了在其他地方对请求的调用。

SG(server_context) = (void *) &request;
로그인 후 복사

5.读取数据

下面的代码删除一些异常情况的处理,只显示了正常情况下执行顺序。

fcgi_read_request中则完成我们在消息通讯样例中的消息读取,而其中很多的len = (hdr.contentLengthB1 << 8) | hdr.contentLengthB0;操作,已经在前面的FastCGI 消息头中解释过了。

这里是解析 FastCGI 协议的关键。

static inline ssize_t safe_read(fcgi_request *req, const void *buf, size_t count)
{
    int    ret;
    size_t n = 0;

    do {
        errno = 0;
        ret = read(req->fd, ((char*)buf)+n, count-n);
        n += ret;
    } while (n != count);
    return n;
}
로그인 후 복사
static int fcgi_read_request(fcgi_request *req)
{
    ...

    if (safe_read(req, &hdr, sizeof(fcgi_header)) != sizeof(fcgi_header) || hdr.version < FCGI_VERSION_1) {
        return 0;
    }

    len = (hdr.contentLengthB1 << 8) | hdr.contentLengthB0;
    padding = hdr.paddingLength;

    req->id = (hdr.requestIdB1 << 8) + hdr.requestIdB0;

    if (hdr.type == FCGI_BEGIN_REQUEST && len == sizeof(fcgi_begin_request)) {
        char *val;

        if (safe_read(req, buf, len+padding) != len+padding) {
            return 0;
        }

        req->keep = (((fcgi_begin_request*)buf)->flags & FCGI_KEEP_CONN);

        switch ((((fcgi_begin_request*)buf)->roleB1 << 8) + ((fcgi_begin_request*)buf)->roleB0) {
            case FCGI_RESPONDER:
                val = estrdup("RESPONDER");
                zend_hash_update(req->env, "FCGI_ROLE", sizeof("FCGI_ROLE"), &val, sizeof(char*), NULL);
                break;
            ...
            default:
                return 0;
        }

        if (safe_read(req, &hdr, sizeof(fcgi_header)) != sizeof(fcgi_header) || hdr.version < FCGI_VERSION_1) {
            return 0;
        }

        len = (hdr.contentLengthB1 << 8) | hdr.contentLengthB0;
        padding = hdr.paddingLength;

        while (hdr.type == FCGI_PARAMS && len > 0) {
            if (safe_read(req, &hdr, sizeof(fcgi_header)) != sizeof(fcgi_header) || hdr.version < FCGI_VERSION_1) {
                req->keep = 0;
                return 0;
            }
            len = (hdr.contentLengthB1 << 8) | hdr.contentLengthB0;
            padding = hdr.paddingLength;
        }

        ...
    }
}
로그인 후 복사

6.执行脚本

假设此次请求为PHP_MODE_STANDARD则会调用php_execute_script执行PHP文件。这里就不展开了。

7.结束请求

fcgi_finish_request(&request, 1);
로그인 후 복사
int fcgi_finish_request(fcgi_request *req, int force_close)
{
    int ret = 1;

    if (req->fd >= 0) {
        if (!req->closed) {
            ret = fcgi_flush(req, 1);
            req->closed = 1;
        }
        fcgi_close(req, force_close, 1);
    }
    return ret;
}
로그인 후 복사

fcgi_finish_request中调用fcgi_flushfcgi_flush中封装一个FCGI_END_REQUEST消息体,再通过safe_write写入 socket 连接的客户端描述符。

8.标准输入标准输出的处理

标准输入和标准输出在上面没有一起讨论,实际在cgi_sapi_module结构体中有定义,但是cgi_sapi_module这个sapi_module_struct结构体与其他代码耦合太多,我自己也没深入的理解,这里简单做下比较,希望其他网友予以指点、补充。

cgi_sapi_module中定义了sapi_cgi_read_post来处理POST数据的读取.

while (read_bytes < count_bytes) {
    fcgi_request *request = (fcgi_request*) SG(server_context);
    tmp_read_bytes = fcgi_read(request, buffer + read_bytes, count_bytes - read_bytes);
    read_bytes += tmp_read_bytes;
}
로그인 후 복사

fcgi_read中则对FCGI_STDIN的数据进行读取。
同时cgi_sapi_module中定义了sapi_cgibin_ub_write来接管输出处理,而其中又调用了sapi_cgibin_single_write,最后实现了FCGI_STDOUT FastCGI 数据包的封装.

fcgi_write(request, FCGI_STDOUT, str, str_length);
로그인 후 복사

위 내용은 FastCGI 프로토콜의 PHP 구현에 대한 심층적인 이해의 상세 내용입니다. 자세한 내용은 PHP 중국어 웹사이트의 기타 관련 기사를 참조하세요!

관련 라벨:
원천:php.cn
본 웹사이트의 성명
본 글의 내용은 네티즌들의 자발적인 기여로 작성되었으며, 저작권은 원저작자에게 있습니다. 본 사이트는 이에 상응하는 법적 책임을 지지 않습니다. 표절이나 침해가 의심되는 콘텐츠를 발견한 경우 admin@php.cn으로 문의하세요.
인기 튜토리얼
더>
최신 다운로드
더>
웹 효과
웹사이트 소스 코드
웹사이트 자료
프론트엔드 템플릿