
Zig Workshop #3: แยกวิเคราะห์ log โดยไม่ขอหน่วยความจำแม้แต่ไบต์เดียว
บรรทัดหนึ่งของ 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
Navigation
- ก่อนหน้า: Workshop #2: อ่านไฟล์ทีละบรรทัด
- ถัดไป: Workshop #4: allocator ในงานจริง
- โค้ดบทนี้: step-03-parse
- ดูทั้งชุด: บทความแท็ก zig