ภาพวาดโทนมิ้นต์สว่าง อิกัวนาตัวเล็กใช้ฉากไม้วัดมุมของกรอบไม้เปล่าที่เพิ่งประกอบเสร็จบนโต๊ะ มีสลักเล็กสีอำพันอยู่ที่มุมข้อต่อ ชิ้นส่วนที่ยังไม่ได้ใช้วางเรียงข้าง ๆ

Zig Workshop #1: วางโครง CLI ที่รันได้จริงด้วย build.zig ของ 0.16

พี่โชว์Engineering

สิบบทแรกเราเรียนภาษา ตั้งแต่บทนี้เราจะสร้างของจริง

สิ่งที่จะได้ตอนจบ workshop คือ zlog — เครื่องมือบรรทัดคำสั่งที่อ่าน access log ของ nginx แล้วสรุปให้ว่า path ไหนถูกเรียกบ่อยสุด อัตราความผิดพลาดเท่าไหร่ ขนาด response ที่เปอร์เซ็นไทล์ 95 เป็นเท่าไหร่ ทำงานได้ทั้ง macOS, Linux และ Windows และเร็วพอที่จะเอาไปใช้จริง

ทำไมเลือกโจทย์นี้: มันบังคับให้ใช้ของที่เป็นจุดแข็งของ Zig ครบทุกอย่าง — อ่านข้อมูลแบบ streaming, แยกข้อความโดยไม่ copy, จัดการหน่วยความจำเอง, comptime และการคอมไพล์ข้ามแพลตฟอร์ม โดยไม่ต้องพึ่ง API ที่ยังไม่นิ่งเลยสักตัว

โค้ดทั้งหมดอยู่ที่ kangana1024/showkhun-zig-workshop แต่ละบทมี branch ของตัวเอง บทนี้คือ step-01-skeleton


เป้าหมายของบทนี้

zig build run -- --version
# zlog 0.1.0 (zig 0.16.0)

เล็กมาก แต่มันบังคับให้เราตอบคำถามพื้นฐานทุกข้อของโปรเจกต์ Zig ให้ครบ


เริ่มจาก zig init

mkdir zlog
cd zlog
zig init

ผลจริงที่ได้ใน 0.16:

info: created build.zig
info: created build.zig.zon
info: created src/main.zig
info: created src/root.zig
info: see `zig build --help` for a menu of options

zig init สร้างมาให้ทั้งไฟล์ library (src/root.zig) และไฟล์โปรแกรม (src/main.zig) ซึ่งเป็นโครงที่ดี — โค้ดตรรกะอยู่ฝั่ง library เพื่อให้เทสได้ ส่วน main.zig ทำหน้าที่แค่รับ argument กับพิมพ์ผล


build.zig.zon: จุดที่โปรเจกต์เก่าพัง

ไฟล์นี้คือข้อมูลประจำตัวโปรเจกต์ ของที่ zig init สร้างให้มีคอมเมนต์ยาวมาก บล็อกข้างล่างเป็นโค้ดบางส่วนที่ตัดคอมเมนต์ออกให้เหลือแก่น

.{
    .name = .zlog,
    .version = "0.1.0",
    .fingerprint = 0x55b82e33491c94fc, // Changing this has security and trust implications.
    .minimum_zig_version = "0.16.0",
    .dependencies = .{},
    .paths = .{ "build.zig", "build.zig.zon", "src", "LICENSE", "README.md" },
}

มีสองจุดที่ 0.16 เข้มขึ้นและทำให้โปรเจกต์เก่า build ไม่ผ่าน

หนึ่ง — .name ต้องเป็น enum literal เขียน .name = .zlog ไม่ใช่ .name = "zlog" แบบเดิม

สอง — ต้องมี .fingerprint เป็นรหัสประจำตัวโปรเจกต์ที่ zig init สุ่มให้ครั้งเดียวแล้ว ห้ามแก้อีก คอมเมนต์ที่ Zig ใส่ไว้บอกเหตุผลตรง ๆ ว่าการแก้ค่านี้มีนัยด้านความปลอดภัยและความน่าเชื่อถือ เพราะมันคือสิ่งที่ทำให้ระบบแยกออกว่า package ไหนเป็นเวอร์ชันใหม่ของ package ไหน

.minimum_zig_version = "0.16.0" ไม่บังคับแต่ควรใส่ เพราะชุดบทความนี้ยืนยันแล้วว่าโค้ดแบบนี้ใช้กับ 0.15 ไม่ได้


build.zig: สคริปต์ build ที่เป็นภาษา Zig

บล็อกข้างล่างเป็นโค้ดบางส่วน ตัดคอมเมนต์ยาว ๆ ที่ zig init ใส่มาออก ไฟล์เต็มอยู่ใน repo

const std = @import("std");

pub fn build(b: *std.Build) void {
    const target = b.standardTargetOptions(.{});
    const optimize = b.standardOptimizeOption(.{});

    // module = ชุดไฟล์ + ตัวเลือกการคอมไพล์ ต้องสร้างก่อนเสมอใน 0.16
    const exe_mod = b.createModule(.{
        .root_source_file = b.path("src/main.zig"),
        .target = target,
        .optimize = optimize,
    });

    const exe = b.addExecutable(.{ .name = "zlog", .root_module = exe_mod });
    b.installArtifact(exe);

    const run_cmd = b.addRunArtifact(exe);
    run_cmd.step.dependOn(b.getInstallStep());
    if (b.args) |args| run_cmd.addArgs(args); // ส่งต่อสิ่งที่อยู่หลัง --
    const run_step = b.step("run", "รัน zlog");
    run_step.dependOn(&run_cmd.step);

    const exe_tests = b.addTest(.{ .root_module = exe_mod });
    const test_step = b.step("test", "รันเทสทั้งหมด");
    test_step.dependOn(&b.addRunArtifact(exe_tests).step);
}

จุดที่ทำให้ตัวอย่างเก่าทั้งหมดพัง: ใน 0.14 และก่อนหน้า คนเขียนแบบนี้ (โค้ดบางส่วน)

// โค้ดบางส่วนแบบเก่า — ใช้กับ 0.16 ไม่ได้แล้ว
const exe = b.addExecutable(.{
    .name = "zlog",
    .root_source_file = b.path("src/main.zig"),
    .target = target,
    .optimize = optimize,
});

ExecutableOptions ของ 0.16 ไม่มีฟิลด์ root_source_file, target หรือ optimize แล้ว ทุกอย่างย้ายไปอยู่ใน module ที่ต้องสร้างก่อน

b.standardTargetOptions และ b.standardOptimizeOption คือสิ่งที่ทำให้เราสั่ง zig build -Dtarget=... และ -Doptimize=... ได้จากบรรทัดคำสั่ง — เดี๋ยวบทสุดท้ายจะได้ใช้หนัก


main.zig: โครงของ CLI

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

pub const version = "0.1.0";

const Command = enum { count, top, help, version_only };

fn commandOf(arg: []const u8) Command {
    if (std.mem.eql(u8, arg, "--version")) return .version_only;
    return std.meta.stringToEnum(Command, arg) orelse .help;
}

fn usage(w: *Io.Writer) !void {
    try w.print(
        \\zlog {s} — วิเคราะห์ nginx access log
        \\
        \\  zlog count <file...>
        \\  zlog top   <file...>
        \\  zlog --version
        \\
    , .{version});
}

pub fn main(init: std.process.Init) !u8 {
    const io = init.io;

    var out_buf: [4096]u8 = undefined;
    var fw: Io.File.Writer = .init(.stdout(), io, &out_buf);
    const w = &fw.interface;
    defer w.flush() catch {}; // กัน output หายตอนออกทางไหนก็ตาม

    const args = try init.minimal.args.toSlice(init.arena.allocator());
    const command: Command = if (args.len > 1) commandOf(args[1]) else .help;

    switch (command) {
        .version_only => try w.print("zlog {s} (zig {s})\n", .{ version, @import("builtin").zig_version_string }),
        .help => try usage(w),
        .count, .top => try w.print("ยังไม่ได้ทำ: {t}\n", .{command}),
    }
    return 0;
}

test "commandOf อ่านคำสั่งจาก argument" {
    try std.testing.expectEqual(Command.count, commandOf("count"));
    try std.testing.expectEqual(Command.top, commandOf("top"));
    try std.testing.expectEqual(Command.version_only, commandOf("--version"));
    try std.testing.expectEqual(Command.help, commandOf("อะไรก็ไม่รู้"));
}

มีห้าเรื่องที่ตั้งใจใส่ไว้ตั้งแต่บรรทัดแรก

1. main(init: std.process.Init) !u8 — รับ init เพื่อเข้าถึง io, allocator และ argument และคืน u8 เพื่อคุมรหัสจบโปรแกรมเอง ซึ่งจำเป็นสำหรับเครื่องมือที่คนจะเอาไปต่อใน shell script (0 = สำเร็จ, 1 = ทำงานผิดพลาด, 2 = ใช้คำสั่งผิด)

2. defer w.flush() catch {} — วางไว้ทันทีหลังสร้าง writer ไม่ว่าโปรแกรมจะออกทางไหน output จะถูกส่งออกครบ นี่คือบั๊กที่ทุกคนเจอในการเขียน Zig 0.16 ครั้งแรก ถ้าลืมบรรทัดนี้ โปรแกรมจะเงียบสนิททั้งที่โค้ด print ทำงานแล้ว

3. init.arena.allocator() สำหรับ argument — argument อยู่จนจบโปรแกรม ไม่ต้องคืนทีละชิ้น arena ที่ Zig เตรียมให้เหมาะพอดี ไม่ต้องเขียนโค้ด free เลย

4. std.meta.stringToEnum — แปลงข้อความเป็น enum ให้เลย ไม่ต้องเขียน if เทียบทีละอัน และเมื่อเพิ่มคำสั่งใหม่ใน enum มันรองรับให้ทันที

5. {t} — ตัวระบุรูปแบบของ 0.16 ที่พิมพ์ชื่อ tag ของ enum ออกมา สั้นกว่า @tagName


รันดู

zig build test              # เทสผ่านทั้งหมด
zig build run -- --version  # zlog 0.1.0 (zig 0.16.0)
zig build run               # ไม่มีคำสั่ง → แสดงวิธีใช้
zig build                   # ได้ไฟล์ที่ zig-out/bin/zlog

-- ในคำสั่ง zig build run -- --version สำคัญ: ทุกอย่างหลัง -- คือ argument ของโปรแกรมเรา ส่วนก่อนหน้าเป็นของ zig build


สรุปบทนี้

  • zig init ของ 0.16 สร้างทั้ง main.zig และ root.zig — วางตรรกะไว้ฝั่ง library เพื่อให้เทสได้
  • build.zig.zon บังคับ .name เป็น enum literal และต้องมี .fingerprint ที่ห้ามแก้
  • build.zig ของ 0.16 ต้องสร้าง module ก่อน แล้วค่อยเอาไปทำ executable
  • main(init: std.process.Init) !u8 ให้ทั้ง io, allocator, argument และคุมรหัสจบโปรแกรมได้
  • defer w.flush() ตั้งแต่บรรทัดที่สร้าง writer

บทหน้าเราจะอ่านไฟล์จริง — ทีละบรรทัดแบบ streaming ที่ไฟล์ 10 กิกะไบต์ก็ไม่ทำให้แรมเต็ม พร้อมจัดการเรื่องบรรทัดแบบ Windows ที่คนลืมกันประจำ