Chapter 32Project Http Json Client

项目

概述

本项目章节将31中的网络原语扩展为自包含客户端,它轮询服务、解析JSON并打印健康报告。而前一章侧重于原始套接字握手和最小HTTP示例,本章结合std.http.Client.fetchstd.json.parseFromSlice和格式化终端输出来构建面向用户的工作流(参见Client.zigstatic.zig)。

示例有意在同一进程内启动本地服务器,以便客户端可以脱机运行和测试。该工具使得在请求帧和解析逻辑上进行迭代变得容易,同时使用Zig 0.15.2中引入的更安全的Reader和Writer API(参见v0.15.2)。

学习目标

  • 使用std.net.Address.listen启动轻量级HTTP工具,并使用std.Thread.ResetEvent协调就绪状态。
  • 通过在线表示层叠在std.json.parseFromSlice之上,将JSON有效载荷捕获和解码为类型化Zig结构和标记联合。
  • 使用现代Writer API显式管理缓冲区并在表格中呈现结果,并突出显示受影响的服务。

每个目标都直接构建在前一章介绍的客户端原语和Zig标准库提供的HTTP组件上(参见31Server.zig)。

项目架构

我们将程序结构化为三个部分:公开状态端点的本地HTTP服务器、将响应建模为类型数据的解码层,以及打印简洁摘要的表示层。这镜像了内容计划中提到的"获取→解析→报告"工作流,同时将整个项目保持在单个Zig可执行文件中。link

本地服务工具

工具线程绑定到127.0.0.1,接受单个客户端,并用罐装JSON文档回答GET /api/status。它重用前一章的std.http.Server适配器,因此所有TCP细节都保留在标准库内,程序的其余部分可以将服务视为在别处运行(参见net.zig)。

类型化解码策略

JSON文档使用可选字段来描述不同的事件类型,因此程序首先将其解析为镜像这些可选字段的"线"结构,然后基于kind属性将数据提升为Zig union(enum)。这种模式使std.json解析保持简单,同时仍为下游逻辑产生符合人体工程学的域模型(参见meta.zig)。

获取、解码和呈现

下面的完整程序将工具、解码器和渲染器连接在一起。它可以直接使用zig run运行,并打印服务表和任何活动事件。

Zig
const std = @import("std");

// Mock JSON response containing service health data for multiple regions.
// In a real application, this would come from an actual API endpoint.
const summary_payload =
    "{\n" ++ "  \"regions\": [\n" ++ "    {\n" ++ "      \"name\": \"us-east\",\n" ++ "      \"uptime\": 0.99983,\n" ++ "      \"services\": [\n" ++ "        {\"name\":\"auth\",\"state\":\"up\",\"latency_ms\":2.7},\n" ++ "        {\"name\":\"billing\",\"state\":\"degraded\",\"latency_ms\":184.0},\n" ++ "        {\"name\":\"search\",\"state\":\"up\",\"latency_ms\":5.1}\n" ++ "      ],\n" ++ "      \"incidents\": [\n" ++ "        {\"kind\":\"maintenance\",\"window_start\":\"2025-11-06T01:00Z\",\"expected_minutes\":45}\n" ++ "      ]\n" ++ "    },\n" ++ "    {\n" ++ "      \"name\": \"eu-central\",\n" ++ "      \"uptime\": 0.99841,\n" ++ "      \"services\": [\n" ++ "        {\"name\":\"auth\",\"state\":\"up\",\"latency_ms\":3.1},\n" ++ "        {\"name\":\"billing\",\"state\":\"outage\",\"latency_ms\":0.0}\n" ++ "      ],\n" ++ "      \"incidents\": [\n" ++ "        {\"kind\":\"outage\",\"started\":\"2025-11-05T08:12Z\",\"severity\":\"critical\"}\n" ++ "      ]\n" ++ "    }\n" ++ "  ]\n" ++ "}\n";

// Coordination structure for passing server state between threads.
// The ResetEvent enables the main thread to wait until the server is ready to accept connections.
const ServerTask = struct {
    server: *std.net.Server,
    ready: *std.Thread.ResetEvent,
};

// Runs a minimal HTTP server fixture on a background thread.
// Responds to /api/status with the canned JSON payload above,
// and returns 404 for all other paths.
fn serveStatus(task: ServerTask) void {
    // Signal to the main thread that the server is listening and ready.
    task.ready.set();

    const connection = task.server.accept() catch |err| {
        std.log.err("accept failed: {s}", .{@errorName(err)});
        return;
    };
    defer connection.stream.close();

    // Allocate fixed buffers for HTTP protocol I/O.
    // The Reader and Writer interfaces wrap these buffers to manage state.
    var recv_buf: [4096]u8 = undefined;
    var send_buf: [4096]u8 = undefined;
    var reader = connection.stream.reader(&recv_buf);
    var writer = connection.stream.writer(&send_buf);
    var server = std.http.Server.init(reader.interface(), &writer.interface);

    // Handle incoming requests until the connection closes.
    while (server.reader.state == .ready) {
        var request = server.receiveHead() catch |err| switch (err) {
            error.HttpConnectionClosing => return,
            else => {
                std.log.err("receive head failed: {s}", .{@errorName(err)});
                return;
            },
        };

        // Route based on request target (path).
        if (std.mem.eql(u8, request.head.target, "/api/status")) {
            request.respond(summary_payload, .{
                .extra_headers = &.{
                    .{ .name = "content-type", .value = "application/json" },
                },
            }) catch |err| {
                std.log.err("respond failed: {s}", .{@errorName(err)});
                return;
            };
        } else {
            request.respond("not found\n", .{
                .status = .not_found,
                .extra_headers = &.{
                    .{ .name = "content-type", .value = "text/plain" },
                },
            }) catch |err| {
                std.log.err("respond failed: {s}", .{@errorName(err)});
                return;
            };
        }
    }
}

// Domain model representing the final, typed structure of the service health data.
// All slices are owned by an arena allocator tied to the request lifetime.
const Summary = struct {
    regions: []Region,
};

const Region = struct {
    name: []const u8,
    uptime: f64,
    services: []Service,
    incidents: []Incident,
};

const Service = struct {
    name: []const u8,
    state: ServiceState,
    latency_ms: f64,
};

const ServiceState = enum { up, degraded, outage };

// Tagged union modeling the two kinds of incidents.
// Each variant carries its own payload structure.
const Incident = union(enum) {
    maintenance: Maintenance,
    outage: Outage,
};

const Maintenance = struct {
    window_start: []const u8,
    expected_minutes: u32,
};

const Outage = struct {
    started: []const u8,
    severity: Severity,
};

const Severity = enum { info, warning, critical };

// Wire format structures mirror the JSON shape exactly.
// All fields are optional to match the loose JSON schema;
// we promote them to the typed domain model after validation.
const SummaryWire = struct {
    regions: []RegionWire,
};

const RegionWire = struct {
    name: []const u8,
    uptime: f64,
    services: []ServiceWire,
    incidents: []IncidentWire,
};

const ServiceWire = struct {
    name: []const u8,
    state: []const u8,
    latency_ms: f64,
};

// All incident fields are optional because different incident kinds use different fields.
const IncidentWire = struct {
    kind: []const u8,
    window_start: ?[]const u8 = null,
    expected_minutes: ?u32 = null,
    started: ?[]const u8 = null,
    severity: ?[]const u8 = null,
};

// Custom error set for decoding and validation failures.
const DecodeError = error{
    UnknownServiceState,
    UnknownIncidentKind,
    UnknownSeverity,
    MissingField,
};

// Allocates a copy of the input slice in the target allocator.
// Used to transfer ownership of JSON strings from the parser's temporary buffers
// into the arena allocator so they remain valid after parsing completes.
fn dupeSlice(allocator: std.mem.Allocator, bytes: []const u8) ![]const u8 {
    const copy = try allocator.alloc(u8, bytes.len);
    @memcpy(copy, bytes);
    return copy;
}

// Maps a service state string to the corresponding enum variant.
// Case-insensitive to handle variations in JSON formatting.
fn parseServiceState(text: []const u8) DecodeError!ServiceState {
    if (std.ascii.eqlIgnoreCase(text, "up")) return .up;
    if (std.ascii.eqlIgnoreCase(text, "degraded")) return .degraded;
    if (std.ascii.eqlIgnoreCase(text, "outage")) return .outage;
    return error.UnknownServiceState;
}

// Parses severity strings into the Severity enum.
fn parseSeverity(text: []const u8) DecodeError!Severity {
    if (std.ascii.eqlIgnoreCase(text, "info")) return .info;
    if (std.ascii.eqlIgnoreCase(text, "warning")) return .warning;
    if (std.ascii.eqlIgnoreCase(text, "critical")) return .critical;
    return error.UnknownSeverity;
}

// Promotes wire format data into the typed domain model.
// Validates required fields, parses enums, and copies strings into the arena.
// All allocations use the arena so cleanup is automatic when the arena is freed.
fn buildSummary(
    arena: std.mem.Allocator,
    parsed: SummaryWire,
) (DecodeError || std.mem.Allocator.Error)!Summary {
    const regions = try arena.alloc(Region, parsed.regions.len);
    for (parsed.regions, regions) |wire, *region| {
        region.name = try dupeSlice(arena, wire.name);
        region.uptime = wire.uptime;

        // Convert each service from wire format to typed model.
        region.services = try arena.alloc(Service, wire.services.len);
        for (wire.services, region.services) |service_wire, *service| {
            service.name = try dupeSlice(arena, service_wire.name);
            service.state = try parseServiceState(service_wire.state);
            service.latency_ms = service_wire.latency_ms;
        }

        // Promote incidents into the tagged union based on the `kind` field.
        region.incidents = try arena.alloc(Incident, wire.incidents.len);
        for (wire.incidents, region.incidents) |incident_wire, *incident| {
            if (std.ascii.eqlIgnoreCase(incident_wire.kind, "maintenance")) {
                const window_start = incident_wire.window_start orelse return error.MissingField;
                const expected = incident_wire.expected_minutes orelse return error.MissingField;
                incident.* = .{ .maintenance = .{
                    .window_start = try dupeSlice(arena, window_start),
                    .expected_minutes = expected,
                } };
            } else if (std.ascii.eqlIgnoreCase(incident_wire.kind, "outage")) {
                const started = incident_wire.started orelse return error.MissingField;
                const severity_text = incident_wire.severity orelse return error.MissingField;
                const severity = try parseSeverity(severity_text);
                incident.* = .{ .outage = .{
                    .started = try dupeSlice(arena, started),
                    .severity = severity,
                } };
            } else {
                return error.UnknownIncidentKind;
            }
        }
    }

    return .{ .regions = regions };
}

// Fetches the status endpoint via HTTP and decodes the JSON response into a Summary.
// Uses a fixed buffer for the HTTP response; for larger payloads, switch to a streaming approach.
fn fetchSummary(arena: std.mem.Allocator, client: *std.http.Client, url: []const u8) !Summary {
    var response_buffer: [4096]u8 = undefined;
    var response_writer = std.Io.Writer.fixed(response_buffer[0..]);

    // Perform the HTTP fetch with a custom User-Agent header.
    const result = try client.fetch(.{
        .location = .{ .url = url },
        .response_writer = &response_writer,
        .headers = .{
            .user_agent = .{ .override = "zigbook-http-json-client/0.1" },
        },
    });
    _ = result;

    // Extract the response body from the fixed writer's buffer.
    const body = response_writer.buffer[0..response_writer.end];
    
    // Parse JSON into the wire format structures.
    var parsed = try std.json.parseFromSlice(SummaryWire, arena, body, .{});
    defer parsed.deinit();

    // Promote wire format to typed domain model.
    return buildSummary(arena, parsed.value);
}

// Renders the service summary as a formatted table followed by an incident list.
// Uses a buffered writer for efficient output to stdout.
fn renderSummary(summary: Summary) !void {
    var stdout_buffer: [1024]u8 = undefined;
    var stdout_writer = std.fs.File.stdout().writer(&stdout_buffer);
    const out = &stdout_writer.interface;

    // Print service table header.
    try out.writeAll("SERVICE SUMMARY\n");
    try out.writeAll("Region        Service        State       Latency (ms)\n");
    try out.writeAll("-----------------------------------------------------\n");
    
    // Print each service, grouped by region.
    for (summary.regions) |region| {
        for (region.services) |service| {
            try out.print("{s:<13}{s:<14}{s:<12}{d:7.1}\n", .{
                region.name,
                service.name,
                @tagName(service.state),
                service.latency_ms,
            });
        }
    }

    // Print incident section header.
    try out.writeAll("\nACTIVE INCIDENTS\n");
    var incident_count: usize = 0;
    
    // Iterate all incidents across all regions and format based on kind.
    for (summary.regions) |region| {
        for (region.incidents) |incident| {
            incident_count += 1;
            switch (incident) {
                .maintenance => |m| try out.print("- {s}: maintenance window starts {s}, {d} min\n", .{
                    region.name,
                    m.window_start,
                    m.expected_minutes,
                }),
                .outage => |o| try out.print("- {s}: outage since {s} (severity: {s})\n", .{
                    region.name,
                    o.started,
                    @tagName(o.severity),
                }),
            }
        }
    }

    if (incident_count == 0) {
        try out.writeAll("- No active incidents reported.\n");
    }

    try out.writeAll("\n");
    try out.flush();
}

pub fn main() !void {
    // Set up a general-purpose allocator for long-lived allocations (client, server).
    var gpa = std.heap.GeneralPurposeAllocator(.{}){};
    defer _ = gpa.deinit();
    const allocator = gpa.allocator();

    // Bind to localhost on an OS-assigned port (port 0 → automatic selection).
    const address = try std.net.Address.parseIp("127.0.0.1", 0);
    var server = try address.listen(.{ .reuse_address = true });
    defer server.deinit();

    // Spin up the server fixture on a background thread.
    var ready = std.Thread.ResetEvent{};
    const server_thread = try std.Thread.spawn(.{}, serveStatus, .{ServerTask{
        .server = &server,
        .ready = &ready,
    }});
    defer server_thread.join();

    // Wait for the server thread to signal that it's ready to accept connections.
    ready.wait();

    // Retrieve the actual port chosen by the OS.
    const port = server.listen_address.in.getPort();

    // Initialize the HTTP client with the main allocator.
    var client = std.http.Client{ .allocator = allocator };
    defer client.deinit();

    // Create an arena allocator for all parsed data.
    // The arena owns all slices in the Summary; they're freed when the arena is destroyed.
    var arena_inst = std.heap.ArenaAllocator.init(allocator);
    defer arena_inst.deinit();
    const arena = arena_inst.allocator();

    // Set up buffered stdout for logging.
    var stdout_buffer: [256]u8 = undefined;
    var stdout_writer = std.fs.File.stdout().writer(&stdout_buffer);
    const log_out = &stdout_writer.interface;

    // Construct the full URL with the dynamically assigned port.
    var url_buffer: [128]u8 = undefined;
    const url = try std.fmt.bufPrint(&url_buffer, "http://127.0.0.1:{d}/api/status", .{port});
    try log_out.print("Fetching {s}...\n", .{url});

    // Fetch and decode the status endpoint.
    const summary = try fetchSummary(arena, &client, url);
    try log_out.print("Parsed {d} regions.\n\n", .{summary.regions.len});
    try log_out.flush();

    // Render the final report to stdout.
    try renderSummary(summary);
}

此程序依赖于Zig 0.15.2中引入的现代Reader/Writer API和HTTP客户端组件(参见Writer.zig)。

运行
Shell
$ zig run main.zig
输出
Shell
Fetching http://127.0.0.1:46211/api/status...
Parsed 2 regions.

SERVICE SUMMARY
Region        Service        State       Latency (ms)
-----------------------------------------------------
us-east      auth          up              2.7
us-east      billing       degraded      184.0
us-east      search        up              5.1
eu-central   auth          up              3.1
eu-central   billing       outage          0.0

ACTIVE INCIDENTS
- us-east: maintenance window starts 2025-11-06T01:00Z, 45 min
- eu-central: outage since 2025-11-05T08:12Z (severity: critical)

你的端口号每次运行都会更改,因为服务器监听0并让OS选择空闲套接字。客户端从server.listen_address.in.getPort()动态构造URL。

演练

  1. 服务器引导。serveStatus在接受的TCP流上启动std.http.Server,比较请求目标,并响应JSON或404。摘要有效载荷驻留在多行字符串中,但你也可以通过std.json.Stringify轻松发出它。

  2. 线解码和提升。获取后,客户端将其解析为SummaryWire,这是一个反映JSON形状的切片和可选结构。buildSummary然后在arena内分配类型化切片,并将事件kind字符串映射到联合变体。arena和固定写入器都利用后Writer门禁I/O API来显式控制分配。

  3. 渲染。renderSummary通过Writer.print打印服务表并迭代事件,为每个区域显示严重性和调度详细信息。

注意事项与限制

  • std.http.Client.fetch将整个响应缓冲到固定写入器中;对于更大的有效载荷,交换为支持arena的构建器或使用std.json.Scanner流式传输标记(参见Scanner.zig)。
  • 解码逻辑假设事件对象包含其kind所需的字段。验证失败作为error.MissingField冒泡;如果期望部分填充的数据,调整错误处理以降级或记录。
  • arena分配器在报告的整个生命周期内保持所有解码切片存活。如果你需要长寿命所有权,请将arena替换为寿命更长的分配器,并在报告过期时手动释放切片。arena_allocator.zig

练习

  • 添加--region标志,将打印的表格过滤到特定区域。重用网络章节之前的早期CLI章节中的参数解析模式(参见05)。
  • 使用历史延迟百分位数扩展JSON有效载荷,并绘制文本火花线或最小/中位数/最大摘要。咨询std.fmt获取格式化辅助函数(参见fmt.zig)。
  • 将罐装数据替换为你选择的实时端点,但用超时包装它并回退到工具以保持测试确定性。

限制、替代方案和边缘案例

  • 如果响应增长超过response_buffer大小,client.fetch报告error.WriteFailed。通过使用支持堆的写入器重试或通过将主体流式传输到磁盘来处理这种情况。
  • 对于联合提升,考虑将原始SummaryWire与你的类型化数据一起存储,这样你就可以在诊断中公开原始JSON字段而无需重新解析。
  • 在生产代码中,你可能希望在多次获取中重用单个std.http.Client;此示例在一次请求后丢弃它,但API公开了准备重用的连接池。

Help make this chapter better.

Found a typo, rough edge, or missing explanation? Open an issue or propose a small improvement on GitHub.