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

Zig Workshop #5: ให้ comptime สร้างตัวเลือกของ CLI จาก struct

พี่โชว์Engineering

ตอนนี้ 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 และข้อความ --help
  • inline for เชื่อมชื่อฟิลด์ที่รู้ตอนรัน เข้ากับการเข้าถึงฟิลด์ที่ต้องรู้ตอนคอมไพล์
  • switch (@typeInfo(@FieldType(T, name))) แยกวิธีจัดการฟิลด์ข้อความกับตัวเลข โดยตัดสินตั้งแต่คอมไพล์
  • @compileError ดักชื่อฟิลด์ผิดในโค้ดของเราเองตั้งแต่ยังไม่ได้รัน
  • ไม่มีค่าใช้จ่ายของ reflection ตอนรันแบบ Java/C# และไม่ต้องเรียนระบบ macro แยกแบบ Rust
  • เพิ่มฟิลด์ใน struct หนึ่งบรรทัด CLI รองรับเองทั้งหมด

บทสุดท้ายของ workshop — ทำให้เร็วขึ้นด้วยการจัดวางข้อมูลใหม่ เพิ่ม percentile แล้วส่ง binary ขึ้นทุกแพลตฟอร์มพร้อมตัวเลข benchmark ที่วัดเอง