ภาพวาดโทนมิ้นต์สว่าง อิกัวนาตัวเล็กถือแว่นขยายส่องแถบกระดาษยาวที่วางแผ่บนโต๊ะ และทำเครื่องหมายวงเล็บสีอำพันคร่อมช่วงหนึ่งโดยไม่ตัดกระดาษ กรรไกรวางปิดอยู่ข้าง ๆ ไม่ได้ใช้

Zig Workshop #3: แยกวิเคราะห์ log โดยไม่ขอหน่วยความจำแม้แต่ไบต์เดียว

พี่โชว์Engineering

บรรทัดหนึ่งของ nginx combined log หน้าตาแบบนี้

203.0.113.7 - - [30/Aug/2026:12:00:00 +0700] "GET /index.html HTTP/1.1" 200 1024 "-" "curl/8.0"

เราต้องการห้าอย่างจากมัน: IP, method, path, status และจำนวนไบต์

วิธีที่คนส่วนใหญ่ทำ (และเป็นวิธีที่ Python, Java, C# สอน) คือ split แล้วได้ array ของ string ใหม่ — ซึ่งแปลว่า ขอหน่วยความจำใหม่และ copy ข้อมูลทุกบรรทัด ไฟล์ล้านบรรทัดก็ล้านครั้ง

Zig ทำได้ดีกว่านั้นมาก และบทนี้จะทำแบบที่ ไม่ขอหน่วยความจำเลยแม้แต่ไบต์เดียว

โค้ดบทนี้อยู่ที่ step-03-parse


parser ที่ทุกฟิลด์เป็นแค่ “มุมมอง” ของบรรทัดเดิม

const std = @import("std");

pub const ParseError = error{
    EmptyLine,
    MissingRequest,
    MissingStatus,
    BadStatus,
    BadBytes,
};

pub const LogLine = struct {
    ip: []const u8,
    method: []const u8,
    path: []const u8,
    status: u16,
    bytes: u64,
};

pub fn trimEol(line: []const u8) []const u8 {
    return std.mem.trimEnd(u8, line, "\r");
}

/// slice ที่คืนมาทั้งหมดชี้กลับเข้าไปใน `raw`
pub fn parseLine(raw: []const u8) ParseError!LogLine {
    const line = trimEol(raw);
    if (line.len == 0) return error.EmptyLine;

    const ip_rest = std.mem.cutScalar(u8, line, ' ') orelse return error.MissingRequest;
    const ip = ip_rest[0];

    // ส่วน request อยู่ในเครื่องหมายคำพูดคู่แรก
    const q1 = std.mem.findScalar(u8, line, '"') orelse return error.MissingRequest;
    const after_q1 = line[q1 + 1 ..];
    const q2 = std.mem.findScalar(u8, after_q1, '"') orelse return error.MissingRequest;
    const request = after_q1[0..q2];

    const method_rest = std.mem.cutScalar(u8, request, ' ') orelse return error.MissingRequest;
    const method = method_rest[0];
    const path = if (std.mem.cutScalar(u8, method_rest[1], ' ')) |c| c[0] else method_rest[1];

    // status กับ bytes ตามหลังเครื่องหมายคำพูดปิด
    const tail = std.mem.trimStart(u8, after_q1[q2 + 1 ..], " ");
    const status_rest = std.mem.cutScalar(u8, tail, ' ') orelse return error.MissingStatus;
    const status = std.fmt.parseInt(u16, status_rest[0], 10) catch return error.BadStatus;
    if (status < 100 or status > 599) return error.BadStatus;

    const bytes_text = if (std.mem.cutScalar(u8, status_rest[1], ' ')) |c| c[0] else status_rest[1];
    const bytes: u64 = if (std.mem.eql(u8, bytes_text, "-"))
        0 // nginx เขียน "-" เมื่อไม่มี body
    else
        std.fmt.parseInt(u64, bytes_text, 10) catch return error.BadBytes;

    return .{ .ip = ip, .method = method, .path = path, .status = status, .bytes = bytes };
}

test "parseLine ดึงทุกฟิลด์ออกมาโดยไม่ copy" {
    const line = "203.0.113.7 - - [30/Aug/2026:12:00:00 +0700] \"GET /index.html HTTP/1.1\" 200 1024 \"-\" \"curl/8.0\"";
    const parsed = try parseLine(line);

    try std.testing.expectEqualStrings("203.0.113.7", parsed.ip);
    try std.testing.expectEqualStrings("GET", parsed.method);
    try std.testing.expectEqualStrings("/index.html", parsed.path);
    try std.testing.expectEqual(@as(u16, 200), parsed.status);
    try std.testing.expectEqual(@as(u64, 1024), parsed.bytes);

    // พิสูจน์ว่าไม่ได้ copy: slice ชี้อยู่ในช่วงหน่วยความจำของบรรทัดต้นทาง
    const line_start = @intFromPtr(line.ptr);
    const path_start = @intFromPtr(parsed.path.ptr);
    try std.testing.expect(path_start >= line_start);
    try std.testing.expect(path_start < line_start + line.len);
}

test "รองรับบรรทัดแบบ Windows และ bytes ที่เป็นขีด" {
    const parsed = try parseLine("10.0.0.1 - - [x] \"POST /api HTTP/1.1\" 201 - \"-\" \"-\"\r");
    try std.testing.expectEqual(@as(u64, 0), parsed.bytes);
    try std.testing.expectEqual(@as(u16, 201), parsed.status);
    try std.testing.expectEqualStrings("/api", parsed.path);
}

test "error บอกได้ว่าพังเพราะอะไร" {
    try std.testing.expectError(error.EmptyLine, parseLine(""));
    try std.testing.expectError(error.MissingRequest, parseLine("no-quotes-here"));
    try std.testing.expectError(error.BadStatus, parseLine("1.1.1.1 - - [x] \"GET / HTTP/1.1\" 999 0 \"-\" \"-\""));
    try std.testing.expectError(error.BadStatus, parseLine("1.1.1.1 - - [x] \"GET / HTTP/1.1\" abc 0 \"-\" \"-\""));
}

ไม่มี gpa ในฟังก์ชันนี้เลย ซึ่งเป็นข้อพิสูจน์ที่ดีที่สุดว่ามันไม่ขอหน่วยความจำ — ใน Zig ฟังก์ชันที่ไม่มีพารามิเตอร์ allocator จะขอหน่วยความจำไม่ได้ (นอกจากจะไปแอบใช้ของ global ซึ่ง standard library ไม่มี)

เทสข้อแรกไม่ได้แค่เช็คค่า แต่พิสูจน์ด้วยที่อยู่หน่วยความจำว่า parsed.path ชี้อยู่ข้างใน line จริง ไม่ใช่ก้อนใหม่


ฟังก์ชันที่ใช้ และชื่อที่เปลี่ยนใน 0.16

ฟังก์ชันทำอะไรชื่อเดิมก่อน 0.16
std.mem.cutScalar(u8, s, ' ')ตัดที่ตัวแรกที่เจอ ได้สองท่อนไม่มี (ของใหม่)
std.mem.findScalar(u8, s, '"')หาตำแหน่งของตัวอักษรindexOfScalar
std.mem.trimStart / trimEndตัดหัว/ท้ายtrimLeft / trimRight

cutScalar คืน tuple สองช่อง — ท่อนหน้ากับท่อนหลัง — เป็น optional เพราะอาจไม่เจอตัวคั่นเลย ท่านี้อ่านง่ายกว่าการหา index แล้วมา slice เองมาก

หมายเหตุสำคัญเรื่องความเร็ว: std.mem.findScalar ใน 0.16 ถูกเขียนด้วย @Vector (SIMD) และคลี่ลูปสองเท่าไว้แล้ว โดยมีคอมเมนต์ในซอร์สบอกว่าการคลี่ลูปช่วยได้ราว 10% ฉะนั้น อย่าเสียเวลาเขียน SIMD เอง ของที่มีอยู่เร็วกว่าที่เราจะเขียนเองแทบแน่นอน (แต่ findScalarLast ไม่ได้ ถูก vectorize — ความไม่สมมาตรที่ควรรู้ไว้)


error set ที่บอกได้ว่าผิดตรงไหน

ParseError มีห้าค่า และแต่ละค่าบอกคนละเรื่อง

log จริงมีบรรทัดพังเสมอ — บรรทัดที่ถูกตัดกลางคัน, health check ที่ฟอร์แมตต่าง, บอทที่ยิง request แปลก ๆ โปรแกรมที่ตายเพราะเจอบรรทัดพังบรรทัดเดียวใช้งานจริงไม่ได้ แต่โปรแกรมที่กลืนทุกอย่างเงียบ ๆ ก็อันตรายเหมือนกัน เพราะคุณจะไม่รู้ว่าสถิติที่ได้ครอบคลุมข้อมูลจริงกี่เปอร์เซ็นต์

ทางออกคือให้ผู้ใช้เลือก และรายงานเสมอว่าข้ามไปกี่บรรทัด

const std = @import("std");
const Io = std.Io;

const LogLine = struct { path: []const u8, status: u16, bytes: u64 };

fn parseLine(raw: []const u8) !LogLine {
    const line = std.mem.trimEnd(u8, raw, "\r");
    const q1 = std.mem.findScalar(u8, line, '"') orelse return error.MissingRequest;
    const after = line[q1 + 1 ..];
    const q2 = std.mem.findScalar(u8, after, '"') orelse return error.MissingRequest;
    const req = after[0..q2];
    const m = std.mem.cutScalar(u8, req, ' ') orelse return error.MissingRequest;
    const path = if (std.mem.cutScalar(u8, m[1], ' ')) |c| c[0] else m[1];

    const tail = std.mem.trimStart(u8, after[q2 + 1 ..], " ");
    const st = std.mem.cutScalar(u8, tail, ' ') orelse return error.MissingStatus;
    const status = std.fmt.parseInt(u16, st[0], 10) catch return error.BadStatus;
    const bt = if (std.mem.cutScalar(u8, st[1], ' ')) |c| c[0] else st[1];
    const bytes = std.fmt.parseInt(u64, bt, 10) catch 0;

    return .{ .path = path, .status = status, .bytes = bytes };
}

const Summary = struct { lines: u64 = 0, skipped: u64 = 0, bytes: u64 = 0 };

fn consume(reader: *Io.Reader, strict: bool) !Summary {
    var s: Summary = .{};

    while (try reader.takeDelimiter('\n')) |raw| {
        const line = parseLine(raw) catch |err| {
            if (strict) return err; // โหมดเข้ม: หยุดทันที
            s.skipped += 1; // โหมดปกติ: ข้ามแต่นับไว้
            continue;
        };
        s.lines += 1;
        s.bytes += line.bytes;
    }
    return s;
}

test "ข้ามบรรทัดพังแต่ต้องนับไว้เสมอ" {
    const data =
        "1.1.1.1 - - [x] \"GET /a HTTP/1.1\" 200 100 \"-\" \"-\"\n" ++
        "พังแน่นอน\n" ++
        "1.1.1.2 - - [x] \"GET /b HTTP/1.1\" 404 10 \"-\" \"-\"\r\n";

    var r: Io.Reader = .fixed(data);
    const s = try consume(&r, false);

    try std.testing.expectEqual(@as(u64, 2), s.lines);
    try std.testing.expectEqual(@as(u64, 1), s.skipped);
    try std.testing.expectEqual(@as(u64, 110), s.bytes);
}

test "โหมด strict หยุดทันทีที่เจอบรรทัดพัง" {
    var r: Io.Reader = .fixed("พัง\n");
    try std.testing.expectError(error.MissingRequest, consume(&r, true));
}

catch |err| { if (strict) return err; ... continue; } คือรูปแบบที่ใช้ได้ทั่วไปกับงานประมวลผลข้อมูลเป็นชุด — จัดการตรงจุด ไม่ต้องมี flag global และคนอ่านโค้ดเห็นทั้งสองเส้นทางในสายตาเดียว


กับดักที่ต้องเห็นก่อนบทหน้า

parser ของเราเร็วเพราะไม่ copy แต่นั่นแปลว่า ข้อมูลที่ได้มีอายุเท่ากับ buffer ที่มันชี้อยู่

โค้ดข้างล่างนี้เป็นโค้ดบางส่วนที่ผิด และคอมไพล์ผ่านสบาย ๆ

// โค้ดบางส่วน — ตัวอย่างของบั๊กที่คอมไพเลอร์ไม่จับให้
var paths: std.ArrayList([]const u8) = .empty;
while (try reader.takeDelimiter('\n')) |raw| {
    const line = try parseLine(raw);
    try paths.append(gpa, line.path); // ❌ เก็บ slice ที่ยืมมา
}
// พอวนไปสักพัก reader เติมข้อมูลใหม่ทับ buffer
// paths ทุกตัวที่เก็บไว้ก่อนหน้าจะชี้ไปยังข้อมูลที่เปลี่ยนไปแล้ว

ใน Rust โค้ดแบบนี้คอมไพล์ไม่ผ่าน เพราะ borrow checker รู้ว่า line.path มีอายุผูกกับ buffer ใน Zig มันผ่านฉลุย แล้วไปพังตอนรัน หรือแย่กว่านั้นคือไม่พังแต่ให้ผลลัพธ์ผิด

เอกสาร Zig เขียนเรื่องนี้ไว้ตรง ๆ ว่าเป็นความรับผิดชอบของโปรแกรมเมอร์ที่จะไม่เข้าถึงข้อมูลที่หมดอายุแล้ว และย้ำว่า slice ก็คือ pointer รูปแบบหนึ่ง

บทหน้าเราจะเจอปัญหานี้ของจริง ตอนที่เก็บ path ลงตารางนับ พร้อมวิธีแก้ที่ถูกต้องและเทสที่พิสูจน์ว่าแก้ได้จริง


สรุปบทนี้

  • parser ที่ไม่มีพารามิเตอร์ allocator = ขอหน่วยความจำไม่ได้ เป็นการรับประกันที่อ่านจาก signature ได้เลย
  • cutScalar, findScalar, trimStart/trimEnd คือชุดเครื่องมือแยกข้อความของ 0.16 (ชื่อ indexOf* เปลี่ยนเป็น find* แล้ว)
  • findScalar ถูก vectorize มาแล้ว อย่าเขียน SIMD เอง
  • error set แยกกรณีให้ชัด แล้วให้ผู้ใช้เลือกว่าจะข้ามหรือหยุด — แต่ ต้องรายงานจำนวนที่ข้ามเสมอ
  • ของที่ยืมมามีอายุจำกัด และ Zig ไม่มีตัวดักให้ ต่างจาก Rust