Audit Report Generation, testing.B & Zero-Alloc Verification
เปลี่ยน Core Parsing Engine จาก Day 14 ให้กลายเป็น CLI Financial Ledger Auditor ที่สมบูรณ์แบบ: สรุปผลตัวเลขทางการเงินแบบ Zero-Heap Allocation, เขียนชุดทดสอบสมรรถนะระดับไมโครวินาทีด้วยเครื่องมือมาตรฐาน testing.B, และพิสูจน์ผลลัพธ์ 0 B/op และ 0 allocs/op บนฮาร์ดแวร์จริง
Section 0: The "Why" & Core Vocabulary
ในโลกของ JVM เมื่อเราต้องการวัดประสิทธิภาพระดับไมโครวินาทีของคำสั่งหักเงินหรืออ่านไฟล์ เราจำเป็นต้องพึ่งพา Framework ภายนอกขนาดใหญ่อย่าง JMH (Java Microbenchmark Harness) ซึ่งต้องติดตั้ง Annotation Processor, รันกระบวนการ Warmup หลายรอบเพื่อรอให้ JIT Compiler ทำการ C2 Tiered Compilation, และมักเกิดปัญหาเรื่อง GC Pause แอบแฝงระหว่างรัน Benchmark
The JMH & JIT Trap We Are Leaving Behind
ใน JVM โค้ดที่รันเร็วใน JMH อาจไม่ได้รันเร็วใน Production เพราะ JIT Compiler ทำการ Dynamic De-optimization เมื่อพบ Branch ใหม่ หรือเมื่อ JVM Heap เริ่มเต็มจนต้องเกิด Major GC Collection
ในทางตรงกันข้าม ภาษา Go มาพร้อมกับเครื่องมือวัดผลสมรรถนะในตัว (testing.B) ที่คอมไพล์เป็น Static Machine Code โดยตรง จึงวัดค่า Latency, Memory Footprint, และอัตรา Throughput ได้ตรงตามพฤติกรรมฮาร์ดแวร์จริงแบบ 100% ปราศจากความผันผวนของ JIT
| คำศัพท์สถาปัตยกรรม (Go) | นิยามและพฤติกรรมระดับ Runtime / Hardware | แนวคิดเทียบเท่าใน Kotlin / JVM |
|---|---|---|
| testing.B | Struct ใน Standard Library สำหรับควบคุมการรัน Microbenchmark โดย Runtime จะปรับขนาด b.N แบบไดนามิกจนกว่าจะได้ผลลัพธ์ที่นิ่งทางสถิติ |
JMH @Benchmark และ Blackhole |
| b.ReportAllocs() | คำสั่งสั่งให้ Go Benchmark Harness บันทึกตัวเลข Heap Allocations ต่อรอบการทำงาน (B/op และ allocs/op) |
JMH Profiler -prof gc |
| Dead-Code Elimination | การที่คอมไพเลอร์ตัดคำสั่งที่ไม่ส่งผลต่อ Output ของฟังก์ชันทิ้งไป ต้องป้องกันด้วยการส่งผลลัพธ์เข้าสู่ Global Package Sink Variable | JMH Blackhole.consume() |
| Audit Aggregation | การคำนวณยอดเงินรวม (Net Sum), สถิติรายการที่ถูกต้อง/ผิดพลาด โดยใช้วิธี Accumulate ค่าบน Struct เดียวโดยไม่สร้าง Object บน Heap | Java Streams .reduce() / Collectors.summarizingLong() |
Benchmark Verification: Go Zero-Alloc vs Spring Batch Ingestion (10M Records)
Section 1: Go Syntax & Command Blueprint
ใน Go ฟังก์ชัน Benchmark จะถูกเขียนในไฟล์ที่ลงท้ายด้วย _test.go เสมอ และต้องขึ้นต้นชื่อฟังก์ชันด้วย Benchmark... โดยรับพารามิเตอร์เป็น b *testing.B
package ledger_test
import (
"bytes"
"testing"
"ledger-parser/internal/ledger"
)
// Global sink เพื่อป้องกันคอมไพเลอร์ทำ Dead-Code Elimination ตัดลูปทิ้ง!
var benchmarkSink ledger.TransactionRecord
func BenchmarkParseSingleLine(b *testing.B) {
line := []byte("TX-10001,1711920000,ACC-8801,ACC-9902,500000,THB")
// 1. สั่งเปิดการวัดผล Heap Allocations อัตโนมัติ
b.ReportAllocs()
// 2. ระบุขนาดไบต์ที่ประมวลผลต่อรอบเพื่อคำนวณ Throughput (MB/s)
b.SetBytes(int64(len(line)))
// 3. รีเซ็ตตัวจับเวลาเพื่อตัดเวลา Setup ด้านบนออกไป
b.ResetTimer()
// 4. ลูปทดสอบ: b.N จะถูก Go Runtime เพิ่มขึ้นเรื่อยๆ จนกระทั่งได้ค่าที่เสถียร
for i := 0; i < b.N; i++ {
benchmarkSink = ledger.ParseSingleLine(line)
}
}
คำสั่งรัน Benchmark และวิเคราะห์ผลลัพธ์ผ่าน Terminal:
42857142: จำนวนรอบที่รันได้ในเวลาประมาณ 1 วินาที (ยิ่งมากยิ่งดี)28.1 ns/op: เวลาที่ใช้ต่อ 1 Record (28.1 นาโนวินาที = ประมวลผลได้กว่า 35 ล้านแถวต่อวินาที!)0 B/op, 0 allocs/op: ตัวเลขศักดิ์สิทธิ์ที่ยืนยันว่าไม่มีการแตะต้อง Heap เลยแม้แต่ไบต์เดียว!
Section 2: Memory Architecture & Benchmark Mechanics
ทำไม testing.B ถึงวัดผลได้แม่นยำระดับฮาร์ดแวร์? มาผ่าโครงสร้างของตัวควบคุม Benchmark ภายใน Go Runtime
The Zero-Alloc Audit Aggregator Struct
ในการสรุปรายงานของ Deliverable นี้ เราจะไม่สร้าง Slice หรือ Map เพิ่มเติมระหว่างการสรุปผล แต่จะใช้ Struct Accumulator ตัวเดียวในการกวาดข้อมูล 48 ไบต์ของ TransactionRecord:
type AuditSummary struct {
TotalRecords int64 // 8B: จำนวนธุรกรรมทั้งหมด
ValidRecords int64 // 8B: จำนวนธุรกรรมที่ผ่านเกณฑ์
MalformedCount int64 // 8B: จำนวนแถวที่ฟิลด์ไม่ครบหรือผิดโครงสร้าง
InvalidAmtCount int64 // 8B: จำนวนรายการที่ยอดเงินไม่ถูกต้อง (<= 0)
TotalVolumeTHB int64 // 8B: ยอดเงินรวมในหน่วยสตางค์ (THB)
TotalVolumeUSD int64 // 8B: ยอดเงินรวมในหน่วยเซนต์ (USD)
} // ขนาดรวม = 48 ไบต์ (Aligned 8 ไบต์อย่างสมบูรณ์แบบ)
Section 3: Visual Workflow & Assembly/RAM Mechanics
มาดูลำดับขั้นตอนการทำงานจริงระดับ CPU Register และคำสั่ง Assembly ในลูปการคำนวณผลสรุปการเงิน
Section 4: Edge Cases, Hazards & Defensive Rules
Hazard #1: The Compiler Dead-Code Trap
หากคุณเขียนลูป Benchmark: for i := 0; i < b.N; i++ { Parse(line) } โดยไม่นำผลลัพธ์ไปกำหนดค่าให้ตัวแปรภายนอก คอมไพเลอร์ที่ฉลาดของ Go จะมองว่าผลลัพธ์ไม่มีใครใช้งาน และจะ ลบโค้ดทั้งฟังก์ชันทิ้งไป (Dead-code elimination) ทำให้ Benchmark รายงานความเร็วเป็น 0.2 ns/op แบบหลอกลวง!
วิธีแก้: กำหนดค่าลงในตัวแปร Global Package Sink เสมอ
Hazard #2: Forgetting b.ResetTimer()
หากมีการโหลดไฟล์จำลองขนาด 50MB ก่อนเริ่มลูป Benchmark แล้วลืมสั่ง b.ResetTimer() เวลาที่ใช้ในการเปิดไฟล์และอ่านดิสก์จะถูกนำไปหารเฉลี่ยรวมใน ns/op ด้วย ทำให้ผลการวัดความเร็วของฟังก์ชัน Parsing คลาดเคลื่อนอย่างรุนแรง
Section 5: Knowledge Verification Quiz
16 Comprehensive Questions — Phase 1 Mastery Evaluation
Section 6: Hands-on Engineering Labs
ประกอบชิ้นส่วนชุดทดสอบประสิทธิภาพและโมดูลสรุปบัญชีการเงินของ Deliverable ตรวจสอบความถูกต้องผ่าน Verification Engine
Lab 1: Write an Idiomatic Zero-Alloc Benchmark
โจทย์: เขียนฟังก์ชัน BenchmarkIngest(b *testing.B) ที่มีการเรียก b.ReportAllocs(), สั่ง b.ResetTimer() และวนลูป for i := 0; i < b.N; i++ เพื่อเรียก parseRecord(mockData) พร้อมส่งผลลัพธ์เข้าสู่ตัวแปร globalSink เพื่อป้องกัน Dead-Code Elimination
Lab 2: High-Speed In-Memory Ledger Accumulator
โจทย์: เขียนฟังก์ชัน AggregateRecords(records []TransactionRecord) (totalCount int64, validCount int64, totalTHB int64) โดยวนลูปอ่านแบบ Single-pass: หาก rec.Status == 0 ให้นับเป็น validCount และหาก bytes.Equal(rec.Currency[:], []byte("THB")) ให้นำ rec.AmountCents มาบวกสะสมใน totalTHB
Lab 3: Parameterized Sub-benchmarks with b.Run()
โจทย์: เขียนฟังก์ชัน BenchmarkSuite(b *testing.B) โดยใช้ b.Run("ValidLine", ...) และ b.Run("MalformedLine", ...) เพื่อแยกการวัดผลออกเป็น 2 Sub-benchmarks ย่อย