2 คะแนน โดย GN⁺ 2024-02-10 | 1 ความคิดเห็น | แชร์ทาง WhatsApp
  • บริการ Go HTTP ที่ต้องดูแลระยะยาวจะบำรุงรักษาและตรวจสอบได้ง่ายกว่า เมื่อประกอบด้วย การส่งต่อ dependency แบบชัดเจน, การรวม route ไว้ที่เดียว, และฟังก์ชัน run ที่ทดสอบได้
  • ควรสร้าง handler เป็น ฟังก์ชันที่คืนค่า http.Handler โดยรับค่าที่จำเป็นผ่าน closure แทนการใช้เมธอดบน struct ของเซิร์ฟเวอร์ และประกอบ middleware ที่ใช้ร่วมกันในขั้นตอนสร้างเซิร์ฟเวอร์และลงทะเบียน route
  • ควรทำให้ func main() บางที่สุด และ inject context.Context, อาร์กิวเมนต์, การเข้าถึง environment, และ standard I/O เข้าไปใน run() เพื่อให้ การจัดการการปิดระบบและการควบคุมในการทดสอบ ง่ายขึ้น
  • helper สำหรับ encode/decode request/response, validation, middleware adapter และการทำ lazy initialization ด้วย sync.Once ช่วยลดโค้ดซ้ำ โดยยังคง flow มาตรฐานของ net/http ใน Go
  • สำหรับการทดสอบ ผู้เขียนชอบแนวทางแบบ end-to-end ที่ใกล้เคียงการเรียกใช้ API จริงมากกว่าการทดสอบ handler รายตัว โดยแต่ละเทสต์จะรันเซิร์ฟเวอร์ของตัวเองและตรวจสอบความพร้อมผ่าน /healthz หรือ /readyz

การสร้างเซิร์ฟเวอร์และจุดเริ่มต้นของบริการ

  • ให้ constructor NewServer เป็นฟังก์ชันที่สร้าง http.Handler หลักของบริการ
    • โดยทั่วไปแต่ละบริการจะมีหนึ่งตัว และ route ภายในจะกระจายคำขอไปยัง handler ต่าง ๆ
    • รับ dependency ทั้งหมดเป็นอาร์กิวเมนต์ เช่น logger, config, storage, external client
    • หากเป็นไปได้ให้คืนค่าเป็น http.Handler และถ้าซับซ้อนมากค่อยใช้ type เฉพาะ
    • สร้าง muxer ของตัวเอง แล้วส่งต่อไปยังฟังก์ชันลงทะเบียน route ใน routes.go
  • งานจัดการ HTTP ที่ต้องใช้ร่วมกันทุก endpoint ให้รวมไว้ใน NewServer
    • CORS
    • authentication middleware
    • logging
    • trace ID middleware
  • แม้รายการ dependency argument จะยาว ก็ยังควรเลือกใช้แบบ ฟังก์ชันอาร์กิวเมนต์ อย่างชัดเจน
    • ถ้าพลาด field ใน struct บางที compiler อาจไม่ช่วยจับ แต่ฟังก์ชันจะเรียกไม่ได้เลยถ้าไม่ส่งค่าที่ต้องใช้มา
    • ถ้าอาร์กิวเมนต์ยาว ให้จัด format แบบแนวตั้งเพื่อให้อ่านง่าย
    • dependency ที่บางเทสต์ไม่ได้ใช้สามารถส่ง nil เพื่อสื่อว่าไม่ได้ถูกใช้งาน

รวม API surface ไว้ใน routes.go

  • ให้ routes.go เป็นไฟล์ที่มองเห็น route ทั้งหมดของบริการได้ในที่เดียว
    • แต่ละโปรเจ็กต์จะมีจุดเดียวสำหรับไล่ดู API surface
    • เนื่องจาก NewServer มีรายการ dependency ขนาดใหญ่ addRoutes ก็อาจมีอาร์กิวเมนต์คล้ายกันตามมา
    • การตรวจ type ของ Go ช่วยจับอาร์กิวเมนต์ที่ขาดหายหรือเรียงลำดับผิดได้
  • พยายามทำให้ addRoutes เรียบง่ายและแบนราบ
    • งานที่อาจเกิดข้อผิดพลาดควรจัดการล่วงหน้าในฟังก์ชัน run
    • ในขั้นตอนลงทะเบียน handler ให้โฟกัสที่ routing เช่น mux.Handle, mux.HandleFunc, http.NotFoundHandler
    • ถ้าออกแบบให้ handler เองคืนค่า error ได้ addRoutes ก็อาจคืนค่า error ได้เช่นกัน

ให้ main เรียกแค่ run

  • ทำ func main() ให้เป็นฟังก์ชันบาง ๆ ที่เรียก run() แล้วถ้ามี error ก็เขียนไปที่ stderr และจบโปรแกรมแบบผิดปกติ
    • run รับองค์ประกอบพื้นฐานจากระบบปฏิบัติการเป็นอาร์กิวเมนต์ เช่น context.Context, อาร์กิวเมนต์, I/O, และฟังก์ชันเข้าถึง environment
    • เพราะ run คืนค่า error จึงจัดการข้อผิดพลาดได้เหมือนโค้ด Go ทั่วไป
  • ตัวอย่างค่าที่ส่งเข้า run ได้แก่
    • os.Args: ใช้กับอาร์กิวเมนต์ตอนรันโปรแกรมและการ parse flag
    • os.Stdin: อ่านอินพุต
    • os.Stdout: เขียนเอาต์พุต
    • os.Stderr: เขียน log ข้อผิดพลาด
    • os.Getenv: อ่าน environment variable
    • os.Getwd: อ่าน working directory ปัจจุบัน
  • ตั้งค่า signal.NotifyContext ภายใน run
    • เมื่อมีสัญญาณปิด เช่น Ctrl+C context จะถูกยกเลิก
    • ถ้า run คืนค่า nil ให้จบแบบปกติ
    • ถ้าคืนค่า error main จะพิมพ์ error แล้วออกด้วยรหัสที่ไม่ใช่ 0
  • เมื่อหลีกเลี่ยง global state ได้ ก็ใช้ t.Parallel() ในเทสต์ได้มากขึ้น
    • ต่อให้เรียก run หลายครั้ง แต่ละรอบก็ไม่รบกวนกัน
    • จัดการ flag ด้วย flags.NewFlagSet ภายใน run แทน flag แบบ global
    • ควบคุม environment variable ด้วยการ inject getenv func(string) string แทนการแก้ environment จริง
    • วิธีนี้ต่างจาก t.SetEnv ตรงที่ยังใช้ parallel test ต่อได้

การปิดระบบและการจัดการสถานะพร้อมใช้งาน

  • ควรส่ง context ผ่านทุกชั้นของบริการ
    • เมื่อมีสัญญาณปิด context จะถูกยกเลิก
    • งานที่ใช้เวลานานหรือทำซ้ำควรเช็ก ctx.Err() หรือ ctx.Done() แล้วหยุด
    • หากเริ่ม goroutine อื่นไว้ ก็ควรใช้ context เป็นตัวตัดสินว่าต้องหยุดเมื่อใด
  • ตอนปิดเซิร์ฟเวอร์ HTTP ให้เรียก Shutdown เพื่อหยุดอย่างนุ่มนวล
    • ในตัวอย่างจะมี goroutine แยกที่รอ ctx.Done()
    • กำหนด timeout 10 * time.Second ให้กับ context สำหรับการปิดระบบ
    • หากมี error ระหว่างปิดระบบให้บันทึกลง stderr
  • หากต้องการยืนยันในเทสต์ว่าเซิร์ฟเวอร์พร้อมจริง ให้มี endpoint /healthz หรือ /readyz
    • จะใช้ channel แยกเพื่อส่งสัญญาณพร้อมก็ได้ แต่ผู้เขียนชอบตรวจด้วย HTTP request จริงมากกว่า
    • ลูปรอตรวจความพร้อมจะยิง request จนกว่าจะได้ 200 OK
    • ถ้า context ถูกยกเลิกหรือ timeout ก็ให้คืนค่า error
    • ลูปตัวอย่างพัก 250ms ระหว่าง request แต่ละครั้ง

รูปแบบการประกอบ handler

  • ฟังก์ชัน handler ควร คืนค่า http.Handler หรือ http.HandlerFunc มากกว่าการ implement ตรง ๆ
    • ตัวอย่าง: func handleSomething(logger *Logger) http.Handler
    • วิธีนี้ช่วยสร้าง closure environment แยกสำหรับแต่ละ handler
    • ค่าที่ initialize ไว้ล่วงหน้าสามารถนำมาใช้ตอนประมวลผล request ได้
  • การใช้ข้อมูลที่แชร์ร่วมกัน ควรใช้แบบอ่านอย่างเดียวจะปลอดภัยที่สุด
    • ถ้า handler แก้ไขค่าเหล่านั้น ต้องมีตัวป้องกันอย่าง mutex
    • โดยทั่วไปไม่แนะนำให้เก็บ state ของโปรแกรมไว้ใน closure
  • ในสภาพแวดล้อมแบบคลาวด์ ไม่ควรสมมติว่า instance จะอยู่ยาว
    • เซิร์ฟเวอร์อาจถูกปิดเพื่อลดการใช้ทรัพยากร หรือ crash ได้จากเหตุผลอื่น
    • หลาย instance อาจรันพร้อมกันและ request อาจถูกกระจายอย่างคาดเดาได้ยาก
    • state ที่ต้องคงอยู่จริงควรเก็บไว้ในฐานข้อมูลหรือ API สำหรับจัดเก็บโดยเฉพาะ

การ encode/decode request/response และการตรวจสอบความถูกต้อง

  • ทุกบริการต้องมีการ decode request body และ encode response body จึงควรมี helper encode / decode
    • ตัวอย่างจะตั้ง Content-Type เป็น JSON เขียน status code แล้วเรียก json.NewEncoder(w).Encode(v)
    • การ decode จะครอบ json.NewDecoder(r.Body).Decode(&v) แล้วเติมบริบทให้กับ error
    • ถ้าใช้ generics จะทำ type inference ได้ เช่น encode(w, r, http.StatusOK, obj)
    • เนื่องจาก decode คืนค่าเป็น type จึงต้องระบุ type ที่คาดหวัง เช่น decode[CreateSomethingRequest](r)
  • สำหรับ validation ให้ใช้อินเทอร์เฟซที่มีเมธอดเดียว
    • อินเทอร์เฟซ Validator มีรูปแบบ Valid(ctx context.Context) map[string]string
    • ถ้าไม่มีปัญหา จะคืนค่า map ที่มีความยาว 0
    • ถ้ามีปัญหา ให้ใช้ชื่อฟิลด์เป็น key และคำอธิบายที่คนอ่านเข้าใจได้เป็น value
  • สิ่งที่เหมาะกับ validation แบบนี้คือการตรวจฟิลด์อย่างรวดเร็ว
    • ฟิลด์บังคับต้องไม่ว่าง
    • รูปแบบสตริงเฉพาะ เช่น email ต้องถูกต้อง
    • ตัวเลขต้องอยู่ในช่วงที่อนุญาต
  • การตรวจที่ซับซ้อนกว่า เช่น query ฐานข้อมูล ควรแยกไปทำในตำแหน่งอื่น
    • เพราะเป็นการตรวจที่สำคัญเกินกว่าจะซ่อนไว้ในฟังก์ชัน validation แบบเร็ว
    • decodeValid[T Validator] เวอร์ชัน generic บังคับให้ type T ต้อง implement Validator
    • การเรียก len(problems) กับ nil map จะได้ 0 จึงไม่ทำให้ panic

แพตเทิร์น middleware adapter

  • middleware รับ http.Handler แล้วคืน http.Handler ตัวใหม่
    • สามารถรันโค้ดก่อนหรือหลังเรียก handler เดิมได้
    • และอาจไม่เรียก handler เดิมเลยก็ได้ตามเงื่อนไข
    • ในตัวอย่าง adminOnly จะคืน HTTP 404 Not Found หากไม่ใช่ผู้ดูแล และจะไม่เรียก handler เดิม
  • ตำแหน่งที่ใช้ middleware โดยทั่วไปคือ routes.go
    • แค่ดูรายการ endpoint ก็รู้ได้ว่ามี route ไหนติด middleware อะไรบ้าง
    • ถ้ารายการ middleware ยาว ให้ขึ้นหลายบรรทัดเพื่อให้อ่านง่าย
  • middleware ที่มี dependency จำนวนมาก ควรห่อเป็นฟังก์ชันที่คืน middleware
    • newMiddleware(logger, db, slackClient, rroll) จะคืน func(http.Handler) http.Handler
    • ในโค้ดลงทะเบียน route จะใช้สั้น ๆ แบบ middleware(handleSomething(...))
    • จะประกาศ type middleware func(h http.Handler) http.Handler แยกต่างหากก็ได้ แต่การเขียน return type ตรง ๆ ทำให้อ่านโค้ดชัดกว่า

ลดขอบเขตของ type สำหรับ request/response

  • type request/response ที่ใช้เฉพาะ endpoint เดียว สามารถประกาศ ภายในฟังก์ชัน handler ได้
    • ช่วยให้ global namespace สะอาดขึ้น
    • และป้องกันไม่ให้ handler อื่นพึ่งพา type ที่ไม่ได้รับประกันว่าเสถียร
  • หากโค้ดเทสต์ต้องใช้ type เดียวกัน อาจเกิดความฝืดบ้าง
    • กรณีนี้ย้าย type ออกมาข้างนอกก็ถือว่าสมเหตุสมผล
    • ถ้า type อยู่ใน handler เทสต์ก็ยังประกาศ anonymous struct หรือ local type ใหม่ได้
  • local type ในเทสต์ช่วยให้เจตนาชัดเจน
    • เช่น ถ้า endpoint /greet ต้องการแค่ฟิลด์ Name ไม่ใช่ Person ทั้งตัว เทสต์ก็ควรมี input struct ที่มีแค่ Name
    • คนอ่านเทสต์จะเห็นได้ทันทีว่า endpoint นั้นสนใจฟิลด์ไหน

เลื่อนการ initialize ด้วย sync.Once

  • งานที่มีต้นทุนสูงระหว่างเตรียม handler สามารถเลื่อนไปทำตอน request แรกด้วย sync.Once
    • ช่วยลดเวลาเริ่มต้นของแอปพลิเคชัน
    • หาก handler นั้นไม่ถูกเรียก งานต้นทุนสูงก็จะไม่เกิดขึ้นเลย
  • ตัวอย่างคือ parse ไฟล์ template เพียงครั้งเดียวใน request แรก
    • sync.Once รับประกันว่าโค้ดจะรันเพียงครั้งเดียว
    • request อื่นที่เข้ามาพร้อมกันจะรอจนการ initialize เสร็จ
    • การตรวจ error จะทำไว้นอก init.Do เพื่อให้ error ถูกเปิดเผยซ้ำได้เสมอ
  • วิธีนี้เป็นการย้ายเวลา initialize จากตอนเริ่มระบบไปเป็นตอนมีการเรียก endpoint ครั้งแรกใน runtime
    • ในสภาพแวดล้อมที่ใช้ Google App Engine มาก แนวทางนี้อาจเหมาะสม
    • ควรตัดสินใจตามสภาพแวดล้อม deployment ว่าจะใช้ sync.Once ที่ไหนและเมื่อไร

กลยุทธ์การทดสอบ

  • โครงสร้างนี้ให้ความสำคัญกับการทดสอบที่ทำได้ง่าย
    • ฟังก์ชัน run ทำให้โค้ดเทสต์สามารถรันโปรแกรมได้โดยตรง
    • การทดสอบควรตอบโจทย์ว่าเข้าใจพฤติกรรมโปรแกรมได้ง่ายไหม ลดความกังวลเวลามีการเปลี่ยนแปลงได้ไหม และหลังผ่านเทสต์แล้วช่วยเพิ่มความมั่นใจในการ deploy production ได้หรือไม่
  • จะทดสอบเฉพาะ handler แยกเดี่ยวก็ได้
    • เรียกฟังก์ชันสร้าง handler แล้วส่ง dependency ที่ต้องใช้เข้าไป
    • ใช้ httptest.NewRecorder และ http.NewRequest เพื่อสร้าง request/response
    • ตรวจสอบ status code, response body และ header
    • วิธีนี้จะข้าม middleware อย่าง authentication แล้วเข้าไปยังโค้ด handler โดยตรง
  • แต่วิธีที่ผู้เขียนชอบมากกว่าคือแนวทางที่ใกล้เคียง end-to-end test
    • เรียก run เพื่อยกโปรแกรมขึ้นมาในลักษณะใกล้เคียงการทำงานจริง
    • ครอบคลุมตั้งแต่การ parse argument, การต่อ dependency, database migration ไปจนถึงการเริ่มเซิร์ฟเวอร์
    • เมื่อเทสต์เรียก API ก็จะตรวจสอบทุกชั้นรวมถึง routes.go ไปพร้อมกัน
    • และสามารถโต้ตอบกับฐานข้อมูลจริงได้
  • วิธีนี้ช่วยลดการทดสอบซ้ำซ้อน
    • ถ้าทดสอบทุกชั้นแยกกัน อาจกลายเป็นตรวจเรื่องเดียวกันหลายรอบด้วยวิธีที่ต่างกันเล็กน้อย
    • end-to-end test ให้ชุดเทสต์หลักที่อธิบายปฏิสัมพันธ์ระหว่างผู้ใช้กับระบบ
    • unit test ที่มีอยู่แล้วจากแนวทางอย่าง TDD ยังเก็บไว้ได้ถ้าเหมาะสม แต่ถ้าซ้ำกับ end-to-end test ก็อาจลบออกได้
  • แต่ละเทสต์สามารถรัน instance ของโปรแกรมตัวเองได้
    • แต่ละเทสต์ส่งอาร์กิวเมนต์, flag, standard I/O และ environment variable ของตัวเองได้
    • สร้างฟังก์ชันยกเลิกด้วย context.WithCancel แล้วลงทะเบียนใน t.Cleanup(cancel)
    • เมื่อเทสต์จบ context จะถูกยกเลิกและโปรแกรมจะปิดอย่างนุ่มนวล
    • t.Cleanup ใน Go 1.14 ใช้แทนการเขียน defer ตรง ๆ ได้

ขอบเขตการใช้งานจริงและบริบทขององค์กร

  • เมื่อต้องสร้าง API แบบเรียบง่าย แพตเทิร์นนี้มุ่งไปที่โค้ดที่อ่านง่ายและขยายต่อได้ง่าย
    • คัดลอกแพตเทิร์นไปขยายต่อได้ง่าย
    • คนใหม่เข้ามาทำงานต่อได้ง่าย
    • เวลามีการเปลี่ยนแปลงจะกังวลน้อยลง
    • ทุกอย่างประกอบแบบชัดเจน ไม่มีพฤติกรรมแบบเวทมนตร์
  • แม้ใช้เครื่องมือ generate โค้ด แนวทางนี้ก็ยังคงใช้ได้
    • ตัวอย่างเช่น สามารถใช้ Oto package เพื่อ generate boilerplate จาก template ได้
  • ในโปรเจ็กต์ใหญ่หรือองค์กรใหญ่ การเลือกใช้เทคโนโลยีเดิมอาจเปลี่ยนการตัดสินใจ
    • ในองค์กรอย่าง Grafana Labs อาจมีเครื่องมือและ abstraction บางอย่างที่ถูกใช้อย่างแพร่หลายอยู่แล้ว
    • gRPC เป็นตัวอย่างหนึ่ง
    • เมื่อมีแพตเทิร์นและประสบการณ์ที่ตกผลึกแล้ว การไหลไปตามแนวนั้นก็เป็นทางเลือกที่ใช้งานได้จริง
  • ยังมีบริบทของชุดผลิตภัณฑ์ Grafana IRM ด้วย
    • Grafana IRM คือชุดผลิตภัณฑ์ที่ Grafana Labs กำลังพัฒนา
    • Grafana Alerting จะส่งการแจ้งเตือนเมื่อ metric ออกนอกช่วงที่ยอมรับได้
    • Grafana OnCall ทำกระบวนการติดต่อคนที่เหมาะสมให้เป็นอัตโนมัติด้วยตารางเวรและกฎ escalation
    • Grafana Incident ช่วยรับมือ incident โดยสร้างห้อง Zoom, ช่อง Slack เฉพาะ และ timeline ของเหตุการณ์
    • รายการที่มีการกด reaction อีโมจิหน้าหุ่นยนต์ในช่อง Slack จะถูกเพิ่มเข้า timeline

1 ความคิดเห็น

 
GN⁺ 2024-02-10
ความคิดเห็นบน Hacker News
  • เคยลองใช้วิธีแยก validator ออกมาเหมือนเมธอด Valid แล้ว แต่หลังจากอ่าน “Parse, Don’t Validate” [0] ของ Lexi Lambda ก็รู้สึกว่าการใช้ type checker ของ Go ทำให้เกิดข้อผิดพลาดน้อยกว่ามาก
    เช่น ถ้าต้องการไม่ให้ผู้ใช้ระบุชื่อผู้ใช้ที่ผิดกฎหมายซึ่งมีวงเล็บมุมได้โดยเด็ดขาด ถ้าใช้แนวทาง validator ก็ต้องเรียก validator ในทุกเส้นทางโค้ดที่ชื่อผู้ใช้อาจมาจากอินพุตที่ไม่น่าเชื่อถือ
    แต่ถ้ามี type Username กับ constructor NewUsername(username string) (Username, error) แค่ข้อเท็จจริงที่ว่ามีอ็อบเจ็กต์ Username อยู่ ก็รับประกันได้แล้วว่าผ่านการตรวจสอบมาแล้ว
    [0] https://lexi-lambda.github.io/blog/2019/11/05/parse-don-t-va...

    • น่าทึ่งอีกครั้งว่าถ้าใช้ type system ให้ถูกต้อง โค้ดจะดีขึ้น อย่าส่งทุกอย่างเป็น string แต่ควร parse แล้วกำกับ type ให้มัน
    • เป็น design pattern ที่ดี แต่ต้องระวัง การตรวจสอบที่เร็วเกินไป
      pattern นี้ทำให้สามารถตรวจสอบได้ทั้งเร็วขึ้นหรือช้าลงเมื่อใดก็ได้ แต่ไม่ได้บอกว่าควรทำเมื่อไร โดยทั่วไปมักดีที่สุดเมื่อทำเป็นส่วนหนึ่งของกระบวนการ parse/validate อ็อบเจ็กต์ที่ใหญ่กว่า
      ในบริบทของ UI แนวคิดเรื่องการจัดการข้อมูลที่ยังไม่ได้ตรวจสอบ บทความ “I is for Intent” [1] ของ Steven Witten น่าใช้อ้างอิง
      [1] https://acko.net/blog/i-is-for-intent/
    • ในเชิงแนวคิดก็เหมือนเทคนิค private constructor และ factory method แบบเก่า
    • ลิงก์ที่เกี่ยวข้อง: Parse, don't validate (2019) - https://news.ycombinator.com/item?id=35053118 - มีนาคม 2023, Parse, Don't Validate (2019) - https://news.ycombinator.com/item?id=27639890 - มิถุนายน 2021, Parse, Don’t Validate - https://news.ycombinator.com/item?id=21476261 - พฤศจิกายน 2019, Parse, Don't Validate - https://news.ycombinator.com/item?id=21471753 - พฤศจิกายน 2019
    • ใน Go การนำ pattern นี้ไปใช้ทำได้ยาก ถ้า Username ถูกฝังอยู่ใน struct แล้วลืมตั้งค่า ก็จะได้ zero value ที่อาจละเมิดข้อจำกัด
  • หนึ่งใน pattern ที่ไม่ชอบที่สุดคือการรับ Config object ที่แทนการตั้งค่าทั้งระบบ แล้วส่งต่อแบบ mutable ไปทั่ว
    ทำแบบนั้นแล้วทุกอย่างจะ coupling กันผ่านอ็อบเจ็กต์ตั้งค่า ในบางระบบ มีคนเขียนค่ากลับลงไปในอ็อบเจ็กต์ตั้งค่าที่ได้รับมา ทำให้แต่ละส่วนต้องถูกตั้งค่าตามลำดับเฉพาะ ระบบถึงจะทำงานถูกต้อง
    อีกกรณีหนึ่ง มี subsystem บางตัวเขียนข้อมูลที่ subsystem อื่นจะอ่านภายหลังลงในอ็อบเจ็กต์ตั้งค่า ทำให้ปิดใช้งานบางส่วนของระบบไม่ได้
    pattern ที่ “การตั้งค่าเป็นค่าขนาดใหญ่แบบ mutable ค่าเดียว” เป็น pattern ที่ค่อนข้างน่ารำคาญ ไม่ใช่แค่ใน Go แต่ในภาษาอื่นด้วย

    • ประเด็นหลักไม่ใช่อ็อบเจ็กต์ข้อมูลการตั้งค่าทั่วไป แต่คือ อ็อบเจ็กต์การตั้งค่าแบบ mutable
      ในโปรเจกต์ Python มักใช้ dataclass การตั้งค่าแบบ immutable และส่งให้หลายโมดูล เมื่อหลายฟังก์ชันพึ่งพาหลายค่า แทนที่จะส่งแต่ละค่าเป็นอาร์กิวเมนต์ของฟังก์ชันและนิยาม type แยกกัน การมีตัวแปรและนิยาม type ทั้งหมดอยู่ใน dataclass ที่เดียวก็กลายเป็น design pattern ที่ค่อนข้างสะดวก
    • วิธีที่ชอบที่สุดในการป้องกันเรื่องนี้คือทำให้ config เป็น immutable จริง ๆ แต่ยังประกอบได้ด้วยฟังก์ชัน Option
      options ภายในจะเปลี่ยนได้เฉพาะระหว่างการสร้าง และภายนอกเปิดเผยแค่ Config กับ accessor เท่านั้น เช่น สร้างเป็น config.New(config.Name("Emanon")) แล้วอ่านด้วย cfg.Name()
    • มักจะสร้าง struct Config สำหรับแต่ละ package และให้ configs.Config เป็นรูปแบบที่รวบรวม Config ของแต่ละ package ไว้
      อาจไม่ใช่ best practice ของ Go แต่ตอนเริ่มต้นสามารถทำให้การตั้งค่าทั้งระบบเป็น entity เดียวได้ และส่งเฉพาะ dependency ขั้นต่ำที่จำเป็นให้แต่ละ package ได้ จึงดี
      ตอนทดสอบก็ง่ายขึ้นเล็กน้อย เพราะไม่ต้องสร้างการตั้งค่าทั้งหมดแบบปลอม ๆ เพียงเพื่อทดสอบ package เดียว
    • เห็นด้วย เคยเปลี่ยนอ็อบเจ็กต์ตั้งค่าระดับบนสุดแล้วทำให้เกิด เหตุขัดข้องร้ายแรง มาก่อน
      ห้ามแก้ไขเด็ดขาด เพราะไม่รู้ว่าถูกใช้ที่ไหนอย่างไร ทำให้เสียดายค่าแรงที่สูญเปล่า และถ้าจำเป็นต้องเปลี่ยน ควรสร้างค่าที่ derive มาจากต้นฉบับจะดีกว่า
      ที่ตลกคือในเชิงการออกแบบ อ็อบเจ็กต์ตั้งค่านั้นเป็น immutable อยู่ระดับหนึ่ง และถ้าจะแก้ไขต้องใช้ API WARNING_DO_NOT_USE แต่ก็ยังใช้มันไปเปลี่ยนอ็อบเจ็กต์จนทำให้ระบบล่ม
    • มองว่าเป็นคำวิจารณ์ที่สมเหตุสมผล อยากรู้ว่าคิดว่า pattern ที่ใช้ง่ายกว่านี้คืออะไร
  • ชอบงานของ Mat Ryer มาก และได้นำแนวคิดส่วนใหญ่จากบทความเวอร์ชันปี 2018 นี้ไปใช้กับโปรเจกต์ Go ทุกโปรเจกต์ตั้งแต่นั้นมา
    แต่ส่วนที่ทำให้รู้สึกไม่สบายใจมาตลอดคือ NewServer เป็นคอนสตรักเตอร์ขนาดใหญ่ที่รับ dependency ทั้งหมดเป็นอาร์กิวเมนต์ และในการทดสอบก็ส่ง nil เพื่อเป็นสัญญาณว่า dependency ที่ไม่จำเป็นจะไม่ถูกใช้
    ผลคือโค้ดส่วนใหญ่มี shared state ที่ไม่จำเป็นจำนวนมาก ในความเป็นจริงมี HTTP handler จำนวนมากที่แค่ต้องตรวจสอบว่าผู้ใช้ที่ส่งคำขอมีสิทธิ์เข้าถึง resource หรือไม่ แล้วเรียกฟังก์ชันเดียวของ data store แต่กลับกลายเป็นส่วนหนึ่งของก้อนขนาดใหญ่ที่เข้าถึง object ทั้งหมดที่ parent server มี และเข้าถึง data store ทั้งหมดได้
    แม้อยากทดสอบโดย mock แค่สองเมธอด ก็เขียนเทสต์ง่าย ๆ ได้ยาก และแม้แพตเทิร์นของ Mat Ryer จะดีที่สุดเท่าที่เคยเห็นมา ก็ยังรู้สึกว่าน่าจะมีทางออกที่ดีกว่านี้

    • ใน repository ที่ทำงานอยู่ เริ่มไวต่อจุดที่มี afferent coupling สูง แบบนี้มากขึ้นเรื่อย ๆ โดยเฉพาะยิ่งเข้าไปลึกในโลกของ Bazel การจัดการ dependency และการออกแบบเชิงกายภาพก็ยิ่งส่งผลต่อโค้ดที่เขียนมากขึ้น
      ถ้าเป็นไปได้ plugin เป็นกลยุทธ์แบ่งขอบเขตโค้ดที่ดี สถาปัตยกรรมแบบ plugin โดยพื้นฐานอยู่ในสถานะ opt-out และจะปรากฏก็ต่อเมื่อเลือกอย่างชัดเจน จึงไม่บังคับความเป็นไปได้ทั้งหมดให้กับก้อนโค้ดใดก้อนหนึ่ง
      ผมเรียกซอฟต์แวร์ที่มีลักษณะนี้ว่า “แบบสั่งเป็นจาน ๆ” โดยทั่วไปควรหลีกเลี่ยงสถานการณ์ “ทำทุกอย่างเพื่อจะทำอะไรก็ได้”
    • มักเขียนตรรกะส่วนใหญ่เป็น package เช่นถ้าสร้าง HN ก็จะวางเป็น package อย่าง users หรือ comments
      package เหล่านี้ไม่มี HTTP interface เลย แต่แต่ละ package มี main ของตัวเองและ interface แบบ CLI บางอย่าง คอมเมนต์ไฟล์ //go:build ignore มีประโยชน์ในจุดนี้
    • ใช้ closure ไปเลย
      แทนที่จะนิยาม handler เป็น func HandleX(w http.ResponseWriter, req *http.Request) ก็ให้เป็นรูปแบบ func HandleX(store *DataStore, dep1 Foo, dep2 Bar, commonDep Common) http.HandlerFunc คือรับ dependency ที่จำเป็น แล้วคืน http.HandlerFunc จริงจากข้างใน
      จากนั้น initialize ครั้งเดียวที่ entry point
    • แปลว่า object ที่ NewServer สร้างกำลังทำงานมากเกินไป มีความเป็นไปได้สูงว่ามี data type และพฤติกรรมมากเกินไปถูกผูกเข้าด้วยกัน
      ตัวอย่างง่าย ๆ ถ้าเพิ่ม logger เข้าไปเป็น dependency ของคอนสตรักเตอร์ object ก็จะทำงานมากกว่าการ implement แบบเรียบง่ายตอนแรกเล็กน้อย ตัวมันเองไม่เป็นไร แต่ถ้าหาวิธีทำ logging โดยไม่แก้ implementation ของสิ่งที่เรียบง่ายนั้นไม่ได้ ก็เป็นเรื่องน่าเสียดาย
      higher-order function เช่น logger decorator ช่วยให้ทำ composition ได้ แต่ก็มีข้อเสียอยู่ดี ถึงอย่างนั้นก็เป็นรูปแบบหนึ่งของโครงสร้างที่จัดการได้ ไม่ใช่ความผิดพลาด
    • รู้สึกแบบเดียวกันมานาน และตอนนี้ย้ายมาใช้แนวทาง โครงสร้าง config แบบ optional แล้ว
      แก่นคือภายใน NewServer ให้ validate ค่าต่าง ๆ ของโครงสร้าง config แบบ optional แล้วคัดลอกไปยัง server struct วิธีนี้ทำให้ mock dependency น้อยลงได้ และเทสต์ง่ายขึ้นมาก
      เคยลอง functional options pattern มามากตามที่หลายคนแนะนำ แต่สุดท้ายก็เลิกใช้ รู้สึกว่ามันฉลาดเกินไปหน่อยจนอ่านยาก และมี boilerplate มากกว่าแพตเทิร์น config struct + validate แล้ว copy
      [0] https://news.ycombinator.com/item?id=39320170
  • อยากให้แนวคิดนี้ได้รับการยอมรับกว้างขึ้นใน HTTP service ของทุกภาษา: ถ้า handler ต้องการ dependency ก็ควรเรียกร้องโดยตรงผ่าน อาร์กิวเมนต์ ไม่ใช่ผูกไว้เป็นเมธอดบน server struct แล้วทำให้มี dependency โผล่มาแบบไม่คาดคิดตอนทดสอบ
    handler ของ HTTP service มักมี business logic อยู่มาก และ logic นั้นมีแนวโน้มจะมี dependency จำนวนมาก จริง ๆ แล้วเห็นบ่อยว่าหนึ่ง handler ใช้ DB, cache, blob storage, การตรวจสิทธิ์เฉพาะ endpoint, license checker, queue, logger เฉพาะทาง, metrics client ฯลฯ
    จำนวนพารามิเตอร์อาจกลายเป็น 9 ตัวขึ้นไป และ linter หรือกฎคร่าว ๆ จากประสบการณ์มักพยายามห้ามไว้ แต่ dependency ไม่ได้หายไปไหน แค่ถูกซ่อนไว้ใน class/struct server แล้วหลอกตัวเองว่า dependency น้อยเพราะ method signature สั้นเท่านั้น
    ยิ่งเวลาผ่านไป แม้จะเพิ่มเป็น 20 ตัว ก็รู้สึกว่าโค้ดที่ dependency ทั้งหมดปรากฏใน signature ของฟังก์ชัน/เมธอดนั้นดีกว่า เพราะจะได้ไม่หลอกตัวเองว่าความซับซ้อนของโค้ดไม่ได้เพิ่มขึ้น

    • ผมมักวาง handler เป็น struct แยกต่างหาก ที่มีเมธอดสำหรับจัดการ route/request
      เช่น ใส่เฉพาะ dependency ที่งานนั้นต้องใช้ เช่น store, cache, logger, pub ไว้ใน struct CreateUser แล้ว implement ServeHTTP
      ใน main.go หรือจุดที่ตั้งค่า dependency ก็สร้างแต่ละงานขึ้นมาและส่งเฉพาะ dependency ที่จำเป็น วิธีนี้ดีตรงที่สามารถเก็บ helper method ของงาน/handler เฉพาะนั้นไว้เป็น private method ของ struct นั้นได้
      แต่ถ้างานหนึ่งต้องการอีกงานหนึ่ง ก็อาจเริ่มต้องส่งต่อกันไปมา หรือดึงออกเป็น package/service แยก ซึ่งอาจยุ่งยากได้
    • ไม่จำเป็นต้องเป็นอาร์กิวเมนต์แยก 9 ตัวขึ้นไปเสมอไป ในบางภาษาอาจใช้ context/env object ตัวเดียวที่มีเฉพาะสิ่งที่ handler ต้องการ
      เช่นเขียนเป็น handleHello({ db, cache, blobStore, authz }, req, res) แล้วถ้า handler สองตัวใช้ context เหมือนกันเป๊ะก็ reuse ได้ และยังประกาศ context ราย handler ที่จุดเรียกได้ง่าย
  • เห็นด้วยกับหลายส่วนในบทความนี้ และอยากเสริมอีกสองสามข้อ
    ถ้าส่ง WaitGroup พร้อมกับ app context เข้าไปใน service struct เมื่อเกิด interrupt ก็ให้ trigger การปิดแอปผ่าน context และ goroutine หลักก็รอ WaitGroup ก่อนปิดจริงได้
    ถ้าเป็นโปรแกรม CLI การทดสอบ stdout, stdin, stderr, args, env ฯลฯ มีประโยชน์ แต่สำหรับ HTTP server ผมมองว่าน้อยกว่า ฟังก์ชัน run ควรรับ structured config เพื่อให้การทดสอบโฟกัสมากขึ้น
    ไม่เห็นด้วยกับการ parse template ใน handler ด้วย sync.Once มองว่า handler ไม่ควรทำการ parse template และควรทำตอนเริ่มแอป ถ้า parse template ไม่ได้ แอปก็ไม่ควรเข้าสถานะพร้อมรับ request และควรจบด้วย exit code ที่ไม่ใช่ 0

    • ประเด็นแรกน่าสนใจ สงสัยว่านี่ไม่ใช่ปัญหาที่แก้ได้ด้วย context propagation และการรอให้ server shutdown หรือ
  • ช่วงนี้กำลังลองเล่น ogen อยู่: https://github.com/ogen-go/ogen
    ถ้าเขียนนิยาม OpenAPI ไว้ มันจะจัดการ routing, นิยาม struct, การตรวจสอบ JSON schema ฯลฯ ให้ สิ่งที่ผมต้องทำมีแค่ implement service เท่านั้น
    งานอย่างการตรวจสอบช่วงของจำนวนเต็มใน query string นั้นน่าเบื่อมาก และถ้าเขียนเองก็พิมพ์ผิดได้ง่ายเหลือเกิน
    ตอนนี้ยังอยู่ในขั้นลองเล่น เลยยังไม่เจอส่วนที่แย่

    • ปัญหาของแนวทางนี้คือขั้นตอนการ เขียน OpenAPI ด้วยมือมาตั้งแต่ต้น นั้นน่าเบื่อสุด ๆ
      รู้สึกว่าการเขียน IDL คล้าย ๆ กันอย่าง Protobuf หรือ capnproto ให้ประสิทธิภาพการทำงานสูงกว่ามาก
    • ถ้าชอบแนวทาง export สเปก OpenAPI จากโค้ด Go มากกว่า danielgtaylor/huma[1] กับ swaggest/rest[2] ก็ใช้ได้ดี
      [1] https://github.com/danielgtaylor/huma
      [2] https://github.com/swaggest/rest
    • ผมก็เริ่มจาก oapigen คล้าย ๆ กัน: github.com/deepmap/oapi-codegen
      ตอนแรกคิดว่าการเขียนสเปกคงน่าเบื่อ แต่จริง ๆ แล้วดีกว่าที่คิดมาก และยังไงก็ต้องมีสเปกอยู่แล้ว เลยมองว่าเขียนไว้ล่วงหน้าดีกว่า
  • รู้สึกว่า fx(https://github.com/uber-go/fx) เป็นเครื่องมือที่เรียบง่ายมากแต่ก็ใช้งานได้หลากหลายสำหรับการออกแบบแอปพลิเคชัน
    คำแนะนำในบทความยังมีประโยชน์อยู่ แต่ช่วยตัดประเด็นที่ว่า “จะรับประกันได้อย่างไรว่า X ถูก initialize แล้วเมื่อ Y ต้องใช้” ออกไปได้ทั้งหมด ปัญหา N*M ลดเหลือปัญหา N คือแค่ต้องสนใจว่าจะ initialize แต่ละชิ้นอย่างไร และไม่ต้องกังวลเรื่องการ synchronize การ initialize ระหว่างชิ้นส่วนต่าง ๆ
    เคยใช้ไลบรารี dependency injection ในหลายภาษา และเคย implement เองด้วย แต่ความเรียบง่ายกับความอเนกประสงค์ของ fx ถูกใจที่สุดเท่าที่เคยเจอมา

    • ผมไม่ชอบ เฟรมเวิร์ก dependency injection แบบนี้จริง ๆ
      ในระบบที่ออกแบบมาดี ปัญหานี้ควรเป็นเรื่องเล็กน้อย การรับประกันว่าบางอย่างถูก initialize แล้วตอนอยากใช้ ก็คือเรื่องว่ามันพร้อมจะถูกส่งเข้าไปเป็น constructor argument หรือไม่
      สร้างแบบ stockService := NewStockService(), orderService := NewOrderService(), orderProcessor := NewOrderProcessor(stockService, orderService) ก็พอ
      ไม่ควรต้องมีอะไรอย่าง “synchronization” ของการ initialize และถ้าทำผิดก็จะ compile ไม่ผ่าน ต่อให้เพิ่ม dependency แบบวนลูปเข้ามา ก็จะประกอบตามลำดับที่ถูกต้องไม่ได้ จึงเห็นปัญหาได้ชัดเจน
  • เป็นบทความที่ยอดเยี่ยมและมีไอเดียน่าสนใจเยอะมาก ไม่น่าเชื่อว่าผมไม่รู้จัก signal.NotifyContext มาก่อน
    ต่อไปคงจำวิธี จัดการ signal ได้โดยไม่ต้อง copy-paste ในทุกโปรเจกต์แล้ว

  • ชอบแนวทางที่ทำในนี้มาก แต่การทดสอบของผมต่างออกไปเล็กน้อย
    ใน newTestServer() ผมจะเปิด server ที่ใส่ dependency ปลอมเข้าไป และถ้าอยากทดสอบข้อผิดพลาดของ dependency ก็เปลี่ยน property นั้นให้เป็นของปลอมที่คืน error
    ดังนั้นจึงตรวจสอบได้ทั้ง error path, log entry, การส่งออก metric, timeout ไปจนถึง graceful shutdown
    หลังจาก server เริ่มแล้ว จะตรวจดูว่าถูก bind กับพอร์ตไหน เพราะค่าเริ่มต้นคือ :0 จึงต้องรอพอร์ตที่ถูกจัดสรรจริง
    การทดสอบแบบ “unit” ทำได้ทั้งในระดับ handler หรือระดับ HTTP และสามารถทดสอบโค้ดได้เพียงพอในแบบที่ผู้ใช้จะเห็นจริง โดยจะให้ผ่าน middleware ทั้งหมดหรือไม่ให้ผ่านเลยก็ได้ รวมถึงเปิด N instance เพื่อทดสอบแบบขนานก็ได้

  • ผมไม่ได้ใช้ Go แต่ชอบ pattern แบบนี้ รู้สึกว่าใช้ได้ค่อนข้างทั่วไปกับ โค้ดที่ทดสอบได้
    ไม่อยากเห็น quick start guide โดยเฉพาะของ Python ที่จัดการ dependency แบบ implicit/static/ทดสอบไม่ได้อีกแล้ว