用 c 写一个最简单的 web server

新挖一个坑 : Do It Your Self 自己动手做系列
这是第一篇 《用 c 写一个最简单的 web server》
后续希望我能坚持持续更新吧...

核心代码

不多费话,直接上代码 Show Me The Code / SMTC

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

// 定义 webserver 监听的端口
#define PORT 8080

void main() {
    /**
     * socket 函数原型
     * man socket
     * 依赖 `#include <sys/socket.h>`
     * int socket(int domain, int type, int protocol);
     */
    // step 01. 创建一个 socket
    int s = socket(AF_INET, SOCK_STREAM, 0);

    /**
     * sockaddr_in 结构体定义
     * man 7 ip # 然后查找 sockaddr_in
     * 依赖 #include <netinet/in.h>
     * struct sockaddr_in {
     *     sa_family_t    sin_family; // address family: AF_INET
     *     in_port_t      sin_port;   // port in network byte order
     *     struct in_addr sin_addr;   // internet address
     * };
     * 
     * htons 函数原型
     * man htons
     * 依赖 #include <arpa/inet.h>
     * int16_t htons(uint16_t hostshort);
     */
    // step 02. 定义一个 sockaddr_in 结构
    // 注意,这里的 sin_port 是使用网络字节顺序 即大端字节序
    struct sockaddr_in addr = {
        AF_INET,        //
        htons(PORT),    // hex(8080) -> 0x1f90
        0               // 绑定本机所有网络接口
    };

    /**
     * 绑定端口
     * man bind
     * 依赖 #include <sys/socket.h>
     * int bind(int sockfd, const struct sockaddr *addr,
     *          socklen_t addrlen);
     */
    // step 03. 将套接字(socket)绑定到网络端口上
    int ret = bind(s, (struct sockaddr *) &addr, sizeof(addr));
    if ( ret < 0 ) {
        printf("ERR: cant bind address port\n");
    }

    /**
     * 调用监听
     * man listen
     * 依赖 #include <sys/socket.h>
     * int listen(int sockfd, int backlog);
     * - backlog 为拒绝新连接之前可以同时等待的连接数
     */
    // step 04. 开启监听
    printf("INFO: server listen port on %d\n", PORT);
    listen(s, 10);

    int total_request = 0;
    int succ_request = 0;
    while(1) {
        /**
         * 接受连接请求
         * man listen
         * 依赖 #include <sys/socket.h>
         * int accept(int sockfd, struct sockaddr *addr, socklen_t *addrlen);
         */
        // step 05. 接受请求
        int client_fd = accept(s, 0, 0);

        total_request += 1;   // 请求总数计数

        // 定义数据缓冲区
        char buffer[1024] = {0};

        /**
         * 接受连接请求
         * man recv
         * 依赖 #include <sys/socket.h>
         * ssize_t recv(int sockfd, void *buf, size_t len, int flags);
         */
        // step 06. 接收客户端请求数据
        recv(client_fd, buffer, 1024, 0);

        /**
         * 分析 : http GET 请求格式如下
         * GET /file.html .....
         * 这里,只需要对应的 file.html 文件名即可
         */
        // step 07. 分析请求中的文件名称
        // 1. 去掉请求内容中 `GET /` 这 5 个字符
        char* f = buffer + 5;
        /**
         * 依赖 #include <string.h>
         * char *strchr(const char *s, int c);
         */
        // 2. 截断请求中 ` ` 之后的内容
        // 比如 `file.html .....` 中的 ` .....`
        *strchr(f, ' ') = 0;
        printf("LOG: requests file : %s\n", f);
        /**
         * 打开文件
         * man 2 open
         * 依赖 #include <fcntl.h>
         * int open(const char *pathname, int flags);
         */
        // step 08. 打开对应文件
        int opened_fd = open(f, O_RDONLY);
        if ( opened_fd < 0 ) {
            close(opened_fd);   // 关闭文件描述符
            close(client_fd);   // 关闭客户端连接
            printf("ERR: request file not exists or premission not allow\n");
            continue;
        }

        /**
         * 给客户端传输文件内容
         * man 2 sendfile
         * 依赖 #include <sys/sendfile.h>
         * ssize_t sendfile(int out_fd, int in_fd, off_t *offset, size_t count);
         * 
         * write
         * man 2 write
         * 依赖 #include <unistd.h>
         * ssize_t write(int fd, const void *buf, size_t count);
         */
        const char *http_response = "HTTP/1.1 200 OK\r\nContent-Type: text/html\r\n\r\n";
        int n = write(client_fd, http_response, strlen(http_response));
        // step 09. 传输文件到客户端
        sendfile(client_fd, opened_fd, 0, 1024);

        /**
         * 关闭使用到的资源
         * man 2 close
         * 依赖 #include <unistd.h>
         * int close(int fd);
         */
        // 关闭打开的文件
        close(opened_fd);
        // 请求结束,并闭客户端连接
        close(client_fd);

        succ_request += 1;    // 请求成功计数
        printf("LOG: request stat : %d / %d\n", succ_request, total_request);
    }

    // 关闭服务器套接字
    close(s);
}

环境说明

以上代码在 Ubuntu 22.04.4 LTS x86_64 上编译通过,完成测试。

编译前,请确认是否已经安装开发编译工具。

使用方法

这里假定服务器程序、源代码和相关的静态文件存储位置为 ~/diy/c 路径下。

准备可以访问的静态文件

在服务端可以准备几个测试请求的 html 文件。比如

cd ~/diy/c
echo "<h1>这是 Index.html 文件</h1>" > index.html
echo "<h2>Hello, this is test file</h2>" > test.html

编译服务端程序并执行

将上面代码保存到 ~/diy/c/webserver.c 中。进入到 ~/diy/c/ 目录。

gcc -o webserver webserver.c
./webserver

即可运行服务端。

模拟客户端请求

# 假设运行 webserver 的服务器 ip 为 192.168.1.101
# 在同局域网中其它机器上(同一网段内)
curl 192.168.1.101:8080/index.html
# 或在运行 webserver 的服务器上其它终端中
curl 127.0.0.1:8080/index.html # 可以正常访问
curl 127.0.0.1:8080/abc.html # 因为没有文件,无法正常访问的

程序流程解释说明

核心流程描述

一个 HTTP 网络服务的基本流程如下

定义并创建服务端套按字监听端口

  1. 服务端程序中,创建一个 socket 套接字
  2. 定义服务端网络端口 比如 0.0.0.0:8080
  3. 绑定已定义的服务端网络端口到已创建的 socket 套接字上
  4. 调用监听方法,在服务器上开端口

循环处理客户请求

  1. 通过 accept 阻塞式等待客户端连接请求 (单进程单线程)

    服务器每次只处理一个客户端连接,并且在处理完一个连接后关闭它,然后再次调用 accept 等待下一个连接。这种方式称为“单线程单进程”服务器,它不能同时处理多个客户端。

  2. 这个代码只实现了假定请求方式为 GET 的方法,其它请求方法没有实现,可以进一步扩展
  3. 分析请求文件名称
  4. 判断文件是否存在,不存在的情况直接忽略请求
  5. 向客户端描述符中输出响应头,这个代码中均以 200text/html 响应
  6. 通过 sendfile 向客户端描述符中追加被请求的文件内容
  7. 请求结束后,关闭文件描述符和客户端连接

中断或严重异常后,退出程序

  • 关闭服务端 socket 连接资源

accept 方法描述

accept 函数是在服务器的主循环中调用的,用于接受客户端的连接请求。下面是 accept 函数的相关信息和行为解释:

函数原型:

int accept(int sockfd, struct sockaddr *addr, socklen_t *addrlen);
  • sockfd:是服务器套接字的文件描述符,已经通过 bind 和 listen 准备好接受连接。
  • addr:是一个指向 sockaddr 结构的指针,它将包含连接客户端的地址信息。
  • addrlen:是一个指向 socklen_t 变量的指针,指示 addr 结构的大小。在调用 accept 之前,这个值应该被初始化为 addr 结构的大小,调用后,它将被设置为实际的地址结构大小。

阻塞行为:

如果没有客户端连接请求,accept 调用将阻塞,直到一个客户端发起连接。这意味着服务器将等待直到一个客户端尝试连接到服务器的监听套接字。

处理多个请求:

当 accept 接受了一个连接请求后,它返回一个新的套接字文件描述符,该文件描述符与发起连接的客户端通信。服务器可以使用这个新的套接字来与客户端进行数据交换。

对于多个客户端请求,服务器需要为每个连接创建一个新套接字。在这个示例中,服务器每次只处理一个客户端连接,并且在处理完一个连接后关闭它,然后再次调用 accept 等待下一个连接。这种方式称为“单线程单进程”服务器,它不能同时处理多个客户端。

并发处理:

为了同时处理多个客户端请求,服务器可以实现多线程或使用多进程。在每个 accept 调用后,服务器可以创建一个新的线程或进程来处理连接,而主线程继续监听新的连接请求。这种方式称为“多线程”或“多进程”服务器。

另一种方法是使用 I/O 多路复用技术,如 selectpoll 或 epoll(在Linux上),这允许服务器在单个线程中同时监控多个套接字的读写事件。


日志记录

2024-06-11

  1. 初步描述记录一个极简的 WebServer 的整个实现流程
  2. 代码目录只能在 Linux 中编译通过,在 OSX 中,未能正常编译

记在最后

挖个坑,后续找时间,安排上多路复用的代码

赞赏