第7章 实战项目:从零构建 HTTP 服务器

第7章 实战项目:从零构建 HTTP 服务器

虽然 Zig 标准库提供了现成的 HTTP 服务器实现 (std.http.Server),但为了真正理解网络编程的底层原理,本章我们将从零开始,仅使用底层的 TCP Socket API 来构建一个简易的 HTTP 服务器。

这将是一次激动人心的旅程,我们将亲手处理连接、解析协议文本,并构造响应报文。

HTTP 服务器的本质

剥去华丽的外衣,HTTP 服务器本质上就是一个在那儿死循环等待的 TCP 服务器

  1. 监听 (Listen):像酒店前台一样,在特定端口(Port)等待。
  2. 连接 (Accept):当客户端(如浏览器)发起连接时,建立 TCP 连接。
  3. 请求 (Request):读取客户端发送过来的文本数据(即 HTTP 请求)。
  4. 响应 (Response):解析请求,处理业务,然后回送一段特定格式的文本数据(即 HTTP 响应)。
  5. 关闭 (Close):结束对话,断开连接。

我们的目标就是用 Zig 代码实现上述 5 个步骤。

步骤 1:创建并配置 Socket

首先,我们需要创建一个 Socket(套接字),并将其绑定到本地地址(localhost)和端口(例如 3490)。

为了保持代码整洁,我们新建一个 config.zig 文件来封装 Socket 初始化逻辑。

// config.zig
const std = @import("std");
const net = std.net;

pub const Socket = struct {
    address: net.Address,

    pub fn init() !Socket {
        // 127.0.0.1 代表本机 (localhost)
        const host = [4]u8{ 127, 0, 0, 1 };
        const port = 3490;
        
        // 创建 IPv4 地址对象
        const addr = net.Address.initIp4(host, port);
        
        return Socket{ .address = addr };
    }
};

注意:Zig 标准库的 std.net 模块已经对底层的系统调用(如 socket, bind)进行了非常好的封装,我们直接使用 net.Address 即可。

步骤 2:启动服务器与接受连接

main.zig 中,我们将使用刚才定义的配置来启动服务器。

核心流程是:init -> listen -> accept

// main.zig
const std = @import("std");
const SocketConf = @import("config.zig");

pub fn main() !void {
    const stdout = std.io.getStdOut().writer();

    // 1. 初始化地址
    const socket_conf = try SocketConf.Socket.init();
    
    // 2. 开始监听
    // listen() 返回一个 Server 对象
    var server = try socket_conf.address.listen(.{
        .reuse_address = true, // 允许端口复用,方便调试
    });
    
    try stdout.print("Listening on {}\n", .{socket_conf.address});

    // 3. 等待连接
    // accept() 会阻塞程序,直到有客户端连接进来
    const connection = try server.accept();
    defer connection.stream.close(); // 确保连接最终被关闭

    try stdout.print("Client connected!\n", .{});
    
    // ... 后续处理逻辑
}

如果你现在运行 zig run main.zig,程序会打印 “Listening on…” 然后卡住。这是正常的!它在等待连接。你可以打开浏览器访问 http://127.0.0.1:3490,你会看到终端打印出 “Client connected!”,然后程序退出(因为我们还没写循环)。

步骤 3:读取 HTTP 请求

客户端连接后,会发送 HTTP 请求报文。我们需要读取这部分数据。

新建 request.zig 来处理请求读取逻辑。

// request.zig
const std = @import("std");
const Connection = std.net.Server.Connection;

// 从连接中读取数据到缓冲区
pub fn read_request(conn: Connection, buffer: []u8) !usize {
    // 获取 reader
    const reader = conn.stream.reader();
    
    // 尝试读取数据
    // 注意:在真实场景中,HTTP 请求可能很大,需要多次读取或处理粘包
    // 这里简化处理,假设一次就能读到头部
    const bytes_read = try reader.read(buffer);
    return bytes_read;
}

main.zig 中调用它:

// main.zig
const Request = @import("request.zig");

// ... inside main ...
    const connection = try server.accept();
    defer connection.stream.close();

    // 准备一个缓冲区
    var buffer: [4096]u8 = undefined; // 4KB buffer
    const bytes_read = try Request.read_request(connection, &buffer);
    
    const request_text = buffer[0..bytes_read];
    try stdout.print("Received Request:\n{s}\n", .{request_text});

现在再次运行并用浏览器访问,你将看到浏览器发送的原始 HTTP 请求头,长得像这样:

GET / HTTP/1.1
Host: 127.0.0.1:3490
User-Agent: Mozilla/5.0 ...
Accept: ...

步骤 4:解析请求 (Parsing)

我们需要从这堆文本中提取关键信息:Method (方法,如 GET)、URI (路径,如 /) 和 Version

我们来完善 request.zig。首先定义 HTTP 方法的枚举:

// request.zig

pub const Method = enum {
    GET,
    POST, // 暂时只支持 GET,但预留 POST
    UNKNOWN,

    pub fn fromString(s: []const u8) Method {
        if (std.mem.eql(u8, s, "GET")) return .GET;
        if (std.mem.eql(u8, s, "POST")) return .POST;
        return .UNKNOWN;
    }
};

pub const Request = struct {
    method: Method,
    uri: []const u8,
    version: []const u8,
};

// 简单的解析器:只解析第一行 "GET / HTTP/1.1"
pub fn parse_request(text: []const u8) !Request {
    // 1. 找到第一行结束的位置
    const line_end = std.mem.indexOfScalar(u8, text, '\n') orelse text.len;
    const first_line = text[0..line_end];

    // 2. 用空格分割第一行
    var iterator = std.mem.splitScalar(u8, first_line, ' ');

    // 3. 提取各部分
    const method_str = iterator.next() orelse return error.InvalidRequest;
    const uri = iterator.next() orelse return error.InvalidRequest;
    const version = iterator.next() orelse return error.InvalidRequest;

    return Request{
        .method = Method.fromString(method_str),
        .uri = uri,
        // trim 去掉可能的 \r (Windows换行符)
        .version = std.mem.trimRight(u8, version, "\r"), 
    };
}

步骤 5:发送响应 (Response)

最后一步是给浏览器回话。如果不回话,浏览器会一直转圈圈直到超时。

HTTP 响应的格式必须严格遵守标准:

HTTP/1.1 200 OK
Content-Length: 12
Content-Type: text/html
Connection: close

Hello World!

新建 response.zig

// response.zig
const std = @import("std");
const Connection = std.net.Server.Connection;

pub fn send_200(conn: Connection) !void {
    const body = "<html><body><h1>Hello from Zig!</h1></body></html>";
    const header = 
        "HTTP/1.1 200 OK\r\n" ++
        "Content-Type: text/html\r\n" ++
        "Connection: close\r\n" ++
        "\r\n"; // 头部和 Body 之间必须有一个空行

    // 发送头部
    try conn.stream.writeAll(header);
    // 发送内容
    try conn.stream.writeAll(body);
}

pub fn send_404(conn: Connection) !void {
    const msg = "HTTP/1.1 404 Not Found\r\nConnection: close\r\n\r\nNot Found";
    try conn.stream.writeAll(msg);
}

最终整合:Main Loop

现在的服务器只能处理一个请求就退出。为了让它真正可用,我们需要把它放入一个 while 循环中。

// main.zig
const std = @import("std");
const SocketConf = @import("config.zig");
const Request = @import("request.zig");
const Response = @import("response.zig");

pub fn main() !void {
    const stdout = std.io.getStdOut().writer();
    
    // 1. 启动监听
    const socket = try SocketConf.Socket.init();
    var server = try socket.address.listen(.{ .reuse_address = true });
    try stdout.print("Server running on http://127.0.0.1:3490\n", .{});

    // 2. 主循环:无限接受连接
    while (true) {
        // accept 会阻塞,直到有新连接
        const connection = try server.accept();
        // 确保连接关闭。注意:在单线程模型中,这会在处理完请求后立即执行
        defer connection.stream.close();

        // 3. 准备缓冲区并读取
        var buffer: [4096]u8 = undefined;
        const bytes_read = Request.read_request(connection, &buffer) catch |err| {
            try stdout.print("Read error: {}\n", .{err});
            continue;
        };
        
        if (bytes_read == 0) continue;

        // 4. 解析请求
        const request = Request.parse_request(buffer[0..bytes_read]) catch |err| {
            try stdout.print("Parse error: {}\n", .{err});
            continue;
        };
        
        try stdout.print("{s} {s}\n", .{ @tagName(request.method), request.uri });

        // 5. 路由与响应
        if (request.method == .GET) {
            if (std.mem.eql(u8, request.uri, "/")) {
                try Response.send_200(connection);
            } else {
                try Response.send_404(connection);
            }
        } else {
            // 暂时不支持其他方法
            try Response.send_404(connection);
        }
    }
}

现在,你拥有了一个能在浏览器中正常访问、能区分 404 页面的简易 HTTP 服务器!

虽然它是单线程、阻塞式的(一次只能处理一个请求),但这正是所有高性能服务器(如 Nginx、Apache)的雏形。通过这个项目,你掌握了 Socket 编程的核心流程,这比单纯使用现成的 HTTP 库要有价值得多。


下章预告:你已经写了不少代码了,但如何确保它们是正确的?下一章我们将深入探讨 Zig 强大的内置测试框架——单元测试 (Unit Tests)


原文出处 : https://pedropark99.github.io/zig-book/