
Zig Workshop #5: ให้ comptime สร้างตัวเลือกของ CLI จาก struct
ตอนนี้ zlog top --by=... รองรับแค่ path กับ ip เพราะเราเขียน switch ไว้เอง
ถ้าจะเพิ่ม --by=status ต้องแก้สี่ที่: enum ของฟิลด์, switch ที่ดึงค่า, ข้อความ --help และตัวตรวจ argument ลืมที่ใดที่หนึ่งแล้วบั๊กเงียบ ๆ — เพิ่มใน enum แต่ลืมใน help คนก็ไม่รู้ว่าใช้ได้
บทนี้เราจะทำให้เหลือที่เดียว: เพิ่มฟิลด์ใน LogLine แล้วที่เหลือรองรับให้เอง
โค้ดบทนี้อยู่ที่ step-05-comptime
อ่านโครงสร้าง struct ตอนคอมไพล์
const std = @import("std");
pub const LogLine = struct {
ip: []const u8,
method: []const u8,
path: []const u8,
status: u16,
bytes: u64,
};
/// รายชื่อฟิลด์ที่ --by รับได้ สร้างจาก struct เองตอนคอมไพล์
pub const groupable = std.meta.fieldNames(LogLine);
test "รายชื่อฟิลด์ถูกสร้างจาก struct เอง" {
try std.testing.expectEqual(@as(usize, 5), groupable.len);
try std.testing.expectEqualStrings("ip", groupable[0]);
try std.testing.expectEqualStrings("status", groupable[3]);
}
groupable เป็นค่าคงที่ที่คำนวณเสร็จตั้งแต่คอมไพล์ — ตอนรันมันคือข้อมูลนิ่ง ๆ ใน binary ไม่มีการวิเคราะห์อะไรอีก
ใช้ std.meta.fieldNames เป็นสะพานข้ามรุ่น: 0.16 ยังเก็บข้อมูลเป็น fields ส่วน 0.17 แยกเป็น field_names/field_types แต่ตัวช่วยนี้คืนชื่อฟิลด์ให้เราเหมือนเดิม
ดึงค่าตามชื่อฟิลด์ที่รู้ตอนรัน
ปัญหาที่น่าสนใจกว่า: ผู้ใช้พิมพ์ --by=status มาเป็น ข้อความตอนรัน แต่การเข้าถึงฟิลด์ของ struct ต้องรู้ชื่อ ตอนคอมไพล์
inline for คือสะพานเชื่อมสองโลกนี้ — มันคลี่ลูปออกตอนคอมไพล์ ได้เป็นชุด if เรียงกันที่เทียบข้อความตอนรัน โดยที่ตัวเข้าถึงฟิลด์ข้างในแต่ละอันเป็นแบบรู้ชื่อตั้งแต่คอมไพล์
const std = @import("std");
pub const LogLine = struct {
ip: []const u8,
method: []const u8,
path: []const u8,
status: u16,
bytes: u64,
};
pub const GroupError = error{UnknownField};
/// ฟิลด์ข้อความคืน slice ที่ยืมมา ส่วนฟิลด์ตัวเลขเขียนลง buf แล้วคืน slice ของ buf
pub fn fieldAsKey(line: LogLine, field: []const u8, buf: []u8) GroupError![]const u8 {
inline for (comptime std.meta.fieldNames(LogLine)) |field_name| {
if (std.mem.eql(u8, field_name, field)) {
const v = @field(line, field_name);
return switch (@typeInfo(@FieldType(LogLine, field_name))) {
.pointer => v, // []const u8
else => std.fmt.bufPrint(buf, "{d}", .{v}) catch unreachable,
};
}
}
return error.UnknownField;
}
test "fieldAsKey ใช้ได้ทั้งฟิลด์ข้อความและตัวเลข" {
const line: LogLine = .{ .ip = "10.0.0.1", .method = "GET", .path = "/a", .status = 404, .bytes = 12 };
var buf: [32]u8 = undefined;
try std.testing.expectEqualStrings("10.0.0.1", try fieldAsKey(line, "ip", &buf));
try std.testing.expectEqualStrings("/a", try fieldAsKey(line, "path", &buf));
try std.testing.expectEqualStrings("404", try fieldAsKey(line, "status", &buf));
try std.testing.expectEqualStrings("12", try fieldAsKey(line, "bytes", &buf));
try std.testing.expectError(error.UnknownField, fieldAsKey(line, "nope", &buf));
}
สังเกต switch (@typeInfo(f.type)) — ฟิลด์ข้อความกับฟิลด์ตัวเลขต้องจัดการคนละแบบ และการตัดสินใจนี้เกิด ตอนคอมไพล์ สำหรับแต่ละฟิลด์ ไม่ใช่ตอนรัน
catch unreachable ตรงนั้นถูกต้องเพราะเรารู้ว่า u64 ที่ยาวที่สุดคือ 20 หลัก และ buffer ที่ส่งเข้ามาคือ 32 ไบต์ — เป็นสัญญาที่เราคุมเองได้ทั้งสองฝั่ง
ต้นทุนของท่านี้: inline for คลี่เป็นการเทียบข้อความห้ารอบ ซึ่งเกิดต่อหนึ่งบรรทัด log ถ้าอยากเร็วกว่านี้ให้แปลงชื่อฟิลด์เป็น index ครั้งเดียวก่อนเข้าลูป — แต่ในบทเรื่อง optimization เราจะวัดกันก่อนว่าจุดนี้สำคัญจริงไหม แทนที่จะเดา
--help ที่อัปเดตตัวเอง
โค้ดบางส่วนของ usage() — รายการฟิลด์ไม่ได้พิมพ์เอง แต่มาจาก struct
// โค้ดบางส่วนของ usage() — รายการนี้มาจาก struct ไม่ได้พิมพ์เอง
try w.writeAll("FIELD ที่ใช้ได้:");
for (zlog.groupable) |name| try w.print(" {s}", .{name});
try w.writeAll("\n");
ผลจริงตอนรัน:
zlog 0.1.0 — วิเคราะห์ nginx access log
zlog count <file...> [--strict]
zlog top <file...> [--by=FIELD] [-n=N]
zlog --version
FIELD ที่ใช้ได้: ip method path status bytes
เพิ่มฟิลด์ user_agent ลงใน LogLine แล้วสั่ง zig build run ใหม่ — มันจะโผล่ในรายการนี้เอง และ --by=user_agent จะใช้ได้ทันที โดยไม่ต้องแก้ไฟล์ main.zig เลย
นี่คือสิ่งที่ Java/C# ต้องใช้ reflection ตอนรัน (จ่ายทุกครั้งที่เรียก) หรือ Rust ต้องใช้ procedural macro (ต้องเรียนระบบ macro แยกอีกชุด) ส่วน Zig ใช้ลูป for ธรรมดา
ตรวจชื่อฟิลด์ผิดตั้งแต่คอมไพล์
สำหรับจุดที่โค้ดเรารู้ชื่อฟิลด์เองอยู่แล้ว เราดักความผิดพลาดตั้งแต่คอมไพล์ได้เลย
const std = @import("std");
const LogLine = struct { ip: []const u8, path: []const u8, status: u16 };
const groupable = std.meta.fieldNames(LogLine);
fn checkedField(comptime field: []const u8) []const u8 {
comptime {
for (groupable) |name| {
if (std.mem.eql(u8, name, field)) return field;
}
@compileError("zlog: ไม่มีฟิลด์ชื่อ " ++ field ++ " ใน LogLine");
}
}
test "checkedField ตรวจตั้งแต่คอมไพล์" {
try std.testing.expectEqualStrings("path", comptime checkedField("path"));
}
ถ้ามีใครเขียน checkedField("statuz") จะได้ข้อความ zlog: ไม่มีฟิลด์ชื่อ statuz ใน LogLine ตอนคอมไพล์ พร้อมชี้บรรทัดที่เรียก ไม่ใช่ error ตอนรันที่อาจไม่มีใครเจอจนกว่าจะขึ้น production
ตรวจ argument ตอนรัน
ชื่อฟิลด์ที่มาจากผู้ใช้ต้องตรวจตอนรัน และตอนนี้เรามีรายการที่เชื่อถือได้อยู่แล้ว (โค้ดบางส่วน)
// โค้ดบางส่วนของการอ่าน argument
if (std.mem.cutPrefix(u8, arg, "--by=")) |v| {
var known = false;
for (zlog.groupable) |name| {
if (std.mem.eql(u8, name, v)) known = true;
}
if (!known) {
try w.print("zlog: ไม่รู้จักฟิลด์ {s}\n", .{v});
try usage(w); // แสดงรายการที่ใช้ได้ให้เลย
return 2;
}
opts.by = v;
}
std.mem.cutPrefix เป็นของใหม่ใน 0.16 — คืนส่วนที่เหลือหลังตัดคำนำหน้า หรือ null ถ้าไม่ตรง อ่านง่ายกว่า startsWith แล้วมา slice เองสองบรรทัด
ผู้ใช้พิมพ์ผิดจะได้รหัสจบ 2 พร้อมรายการที่ใช้ได้ ไม่ใช่ข้อความ error ห้วน ๆ
ลองใช้
zig build run -- top access.log --by=status -n=5
zig build run -- top access.log --by=ip -n=3
zig build run -- top access.log --by=nope # → บอกว่าใช้ฟิลด์ไหนได้บ้าง แล้วจบด้วยรหัส 2
สรุปบทนี้
std.meta.fieldNames(T)ให้รายชื่อฟิลด์ตอนคอมไพล์และเป็นสะพานที่ใช้ได้ทั้ง 0.16/0.17 — ใช้สร้างทั้งตัวเลือกของ CLI และข้อความ--helpinline forเชื่อมชื่อฟิลด์ที่รู้ตอนรัน เข้ากับการเข้าถึงฟิลด์ที่ต้องรู้ตอนคอมไพล์switch (@typeInfo(@FieldType(T, name)))แยกวิธีจัดการฟิลด์ข้อความกับตัวเลข โดยตัดสินตั้งแต่คอมไพล์@compileErrorดักชื่อฟิลด์ผิดในโค้ดของเราเองตั้งแต่ยังไม่ได้รัน- ไม่มีค่าใช้จ่ายของ reflection ตอนรันแบบ Java/C# และไม่ต้องเรียนระบบ macro แยกแบบ Rust
- เพิ่มฟิลด์ใน struct หนึ่งบรรทัด CLI รองรับเองทั้งหมด
บทสุดท้ายของ workshop — ทำให้เร็วขึ้นด้วยการจัดวางข้อมูลใหม่ เพิ่ม percentile แล้วส่ง binary ขึ้นทุกแพลตฟอร์มพร้อมตัวเลข benchmark ที่วัดเอง
Navigation
- ก่อนหน้า: Workshop #4: allocator ในงานจริง
- ถัดไป: Workshop #6: ทำให้เร็วและส่งขึ้นทุกแพลตฟอร์ม
- โค้ดบทนี้: step-05-comptime
- ดูทั้งชุด: บทความแท็ก zig