第7章 实战项目:从零构建 HTTP 服务器
第7章 实战项目:从零构建 HTTP 服务器
虽然 Zig 标准库提供了现成的 HTTP 服务器实现 (std.http.Server),但为了真正理解网络编程的底层原理,本章我们将从零开始,仅使用底层的 TCP Socket API 来构建一个简易的 HTTP 服务器。
这将是一次激动人心的旅程,我们将亲手处理连接、解析协议文本,并构造响应报文。
HTTP 服务器的本质
剥去华丽的外衣,HTTP 服务器本质上就是一个在那儿死循环等待的 TCP 服务器。
- 监听 (Listen):像酒店前台一样,在特定端口(Port)等待。
- 连接 (Accept):当客户端(如浏览器)发起连接时,建立 TCP 连接。
- 请求 (Request):读取客户端发送过来的文本数据(即 HTTP 请求)。
- 响应 (Response):解析请求,处理业务,然后回送一段特定格式的文本数据(即 HTTP 响应)。
- 关闭 (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)。