วิธีเขียนบริการ HTTP ด้วย Go หลังผ่านไป 13 ปี
(grafana.com)- บริการ Go HTTP ที่ต้องดูแลระยะยาวจะบำรุงรักษาและตรวจสอบได้ง่ายกว่า เมื่อประกอบด้วย การส่งต่อ dependency แบบชัดเจน, การรวม route ไว้ที่เดียว, และฟังก์ชัน
runที่ทดสอบได้ - ควรสร้าง handler เป็น ฟังก์ชันที่คืนค่า
http.Handlerโดยรับค่าที่จำเป็นผ่าน closure แทนการใช้เมธอดบน struct ของเซิร์ฟเวอร์ และประกอบ middleware ที่ใช้ร่วมกันในขั้นตอนสร้างเซิร์ฟเวอร์และลงทะเบียน route - ควรทำให้
func main()บางที่สุด และ injectcontext.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 flagos.Stdin: อ่านอินพุตos.Stdout: เขียนเอาต์พุตos.Stderr: เขียน log ข้อผิดพลาดos.Getenv: อ่าน environment variableos.Getwd: อ่าน working directory ปัจจุบัน
- ตั้งค่า
signal.NotifyContextภายในrun- เมื่อมีสัญญาณปิด เช่น
Ctrl+Ccontext จะถูกยกเลิก - ถ้า
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
- ในตัวอย่างจะมี goroutine แยกที่รอ
- หากต้องการยืนยันในเทสต์ว่าเซิร์ฟเวอร์พร้อมจริง ให้มี 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 บังคับให้ typeTต้อง implementValidator- การเรียก
len(problems)กับnilmap จะได้ 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 นั้นสนใจฟิลด์ไหน
- เช่น ถ้า 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 ความคิดเห็น
ความคิดเห็นบน Hacker News
เคยลองใช้วิธีแยก validator ออกมาเหมือนเมธอด
Validแล้ว แต่หลังจากอ่าน “Parse, Don’t Validate” [0] ของ Lexi Lambda ก็รู้สึกว่าการใช้ type checker ของ Go ทำให้เกิดข้อผิดพลาดน้อยกว่ามากเช่น ถ้าต้องการไม่ให้ผู้ใช้ระบุชื่อผู้ใช้ที่ผิดกฎหมายซึ่งมีวงเล็บมุมได้โดยเด็ดขาด ถ้าใช้แนวทาง validator ก็ต้องเรียก validator ในทุกเส้นทางโค้ดที่ชื่อผู้ใช้อาจมาจากอินพุตที่ไม่น่าเชื่อถือ
แต่ถ้ามี type
Usernameกับ constructorNewUsername(username string) (Username, error)แค่ข้อเท็จจริงที่ว่ามีอ็อบเจ็กต์Usernameอยู่ ก็รับประกันได้แล้วว่าผ่านการตรวจสอบมาแล้ว[0] https://lexi-lambda.github.io/blog/2019/11/05/parse-don-t-va...
stringแต่ควร parse แล้วกำกับ type ให้มันpattern นี้ทำให้สามารถตรวจสอบได้ทั้งเร็วขึ้นหรือช้าลงเมื่อใดก็ได้ แต่ไม่ได้บอกว่าควรทำเมื่อไร โดยทั่วไปมักดีที่สุดเมื่อทำเป็นส่วนหนึ่งของกระบวนการ parse/validate อ็อบเจ็กต์ที่ใหญ่กว่า
ในบริบทของ UI แนวคิดเรื่องการจัดการข้อมูลที่ยังไม่ได้ตรวจสอบ บทความ “I is for Intent” [1] ของ Steven Witten น่าใช้อ้างอิง
[1] https://acko.net/blog/i-is-for-intent/
Usernameถูกฝังอยู่ใน struct แล้วลืมตั้งค่า ก็จะได้ zero value ที่อาจละเมิดข้อจำกัดหนึ่งใน pattern ที่ไม่ชอบที่สุดคือการรับ Config object ที่แทนการตั้งค่าทั้งระบบ แล้วส่งต่อแบบ mutable ไปทั่ว
ทำแบบนั้นแล้วทุกอย่างจะ coupling กันผ่านอ็อบเจ็กต์ตั้งค่า ในบางระบบ มีคนเขียนค่ากลับลงไปในอ็อบเจ็กต์ตั้งค่าที่ได้รับมา ทำให้แต่ละส่วนต้องถูกตั้งค่าตามลำดับเฉพาะ ระบบถึงจะทำงานถูกต้อง
อีกกรณีหนึ่ง มี subsystem บางตัวเขียนข้อมูลที่ subsystem อื่นจะอ่านภายหลังลงในอ็อบเจ็กต์ตั้งค่า ทำให้ปิดใช้งานบางส่วนของระบบไม่ได้
pattern ที่ “การตั้งค่าเป็นค่าขนาดใหญ่แบบ mutable ค่าเดียว” เป็น pattern ที่ค่อนข้างน่ารำคาญ ไม่ใช่แค่ใน Go แต่ในภาษาอื่นด้วย
ในโปรเจกต์ Python มักใช้
dataclassการตั้งค่าแบบ immutable และส่งให้หลายโมดูล เมื่อหลายฟังก์ชันพึ่งพาหลายค่า แทนที่จะส่งแต่ละค่าเป็นอาร์กิวเมนต์ของฟังก์ชันและนิยาม type แยกกัน การมีตัวแปรและนิยาม type ทั้งหมดอยู่ในdataclassที่เดียวก็กลายเป็น design pattern ที่ค่อนข้างสะดวกOptionoptionsภายในจะเปลี่ยนได้เฉพาะระหว่างการสร้าง และภายนอกเปิดเผยแค่Configกับ accessor เท่านั้น เช่น สร้างเป็นconfig.New(config.Name("Emanon"))แล้วอ่านด้วยcfg.Name()Configสำหรับแต่ละ package และให้configs.Configเป็นรูปแบบที่รวบรวมConfigของแต่ละ package ไว้อาจไม่ใช่ best practice ของ Go แต่ตอนเริ่มต้นสามารถทำให้การตั้งค่าทั้งระบบเป็น entity เดียวได้ และส่งเฉพาะ dependency ขั้นต่ำที่จำเป็นให้แต่ละ package ได้ จึงดี
ตอนทดสอบก็ง่ายขึ้นเล็กน้อย เพราะไม่ต้องสร้างการตั้งค่าทั้งหมดแบบปลอม ๆ เพียงเพื่อทดสอบ package เดียว
ห้ามแก้ไขเด็ดขาด เพราะไม่รู้ว่าถูกใช้ที่ไหนอย่างไร ทำให้เสียดายค่าแรงที่สูญเปล่า และถ้าจำเป็นต้องเปลี่ยน ควรสร้างค่าที่ derive มาจากต้นฉบับจะดีกว่า
ที่ตลกคือในเชิงการออกแบบ อ็อบเจ็กต์ตั้งค่านั้นเป็น immutable อยู่ระดับหนึ่ง และถ้าจะแก้ไขต้องใช้ API
WARNING_DO_NOT_USEแต่ก็ยังใช้มันไปเปลี่ยนอ็อบเจ็กต์จนทำให้ระบบล่มชอบงานของ Mat Ryer มาก และได้นำแนวคิดส่วนใหญ่จากบทความเวอร์ชันปี 2018 นี้ไปใช้กับโปรเจกต์ Go ทุกโปรเจกต์ตั้งแต่นั้นมา
แต่ส่วนที่ทำให้รู้สึกไม่สบายใจมาตลอดคือ
NewServerเป็นคอนสตรักเตอร์ขนาดใหญ่ที่รับ dependency ทั้งหมดเป็นอาร์กิวเมนต์ และในการทดสอบก็ส่งnilเพื่อเป็นสัญญาณว่า dependency ที่ไม่จำเป็นจะไม่ถูกใช้ผลคือโค้ดส่วนใหญ่มี shared state ที่ไม่จำเป็นจำนวนมาก ในความเป็นจริงมี HTTP handler จำนวนมากที่แค่ต้องตรวจสอบว่าผู้ใช้ที่ส่งคำขอมีสิทธิ์เข้าถึง resource หรือไม่ แล้วเรียกฟังก์ชันเดียวของ data store แต่กลับกลายเป็นส่วนหนึ่งของก้อนขนาดใหญ่ที่เข้าถึง object ทั้งหมดที่ parent server มี และเข้าถึง data store ทั้งหมดได้
แม้อยากทดสอบโดย mock แค่สองเมธอด ก็เขียนเทสต์ง่าย ๆ ได้ยาก และแม้แพตเทิร์นของ Mat Ryer จะดีที่สุดเท่าที่เคยเห็นมา ก็ยังรู้สึกว่าน่าจะมีทางออกที่ดีกว่านี้
ถ้าเป็นไปได้ plugin เป็นกลยุทธ์แบ่งขอบเขตโค้ดที่ดี สถาปัตยกรรมแบบ plugin โดยพื้นฐานอยู่ในสถานะ opt-out และจะปรากฏก็ต่อเมื่อเลือกอย่างชัดเจน จึงไม่บังคับความเป็นไปได้ทั้งหมดให้กับก้อนโค้ดใดก้อนหนึ่ง
ผมเรียกซอฟต์แวร์ที่มีลักษณะนี้ว่า “แบบสั่งเป็นจาน ๆ” โดยทั่วไปควรหลีกเลี่ยงสถานการณ์ “ทำทุกอย่างเพื่อจะทำอะไรก็ได้”
usersหรือcommentspackage เหล่านี้ไม่มี HTTP interface เลย แต่แต่ละ package มี
mainของตัวเองและ interface แบบ CLI บางอย่าง คอมเมนต์ไฟล์//go:build ignoreมีประโยชน์ในจุดนี้แทนที่จะนิยาม 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
NewServerสร้างกำลังทำงานมากเกินไป มีความเป็นไปได้สูงว่ามี data type และพฤติกรรมมากเกินไปถูกผูกเข้าด้วยกันตัวอย่างง่าย ๆ ถ้าเพิ่ม logger เข้าไปเป็น dependency ของคอนสตรักเตอร์ object ก็จะทำงานมากกว่าการ implement แบบเรียบง่ายตอนแรกเล็กน้อย ตัวมันเองไม่เป็นไร แต่ถ้าหาวิธีทำ logging โดยไม่แก้ implementation ของสิ่งที่เรียบง่ายนั้นไม่ได้ ก็เป็นเรื่องน่าเสียดาย
higher-order function เช่น logger decorator ช่วยให้ทำ composition ได้ แต่ก็มีข้อเสียอยู่ดี ถึงอย่างนั้นก็เป็นรูปแบบหนึ่งของโครงสร้างที่จัดการได้ ไม่ใช่ความผิดพลาด
แก่นคือภายใน
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 ของฟังก์ชัน/เมธอดนั้นดีกว่า เพราะจะได้ไม่หลอกตัวเองว่าความซับซ้อนของโค้ดไม่ได้เพิ่มขึ้น
เช่น ใส่เฉพาะ dependency ที่งานนั้นต้องใช้ เช่น
store,cache,logger,pubไว้ใน structCreateUserแล้ว implementServeHTTPใน
main.goหรือจุดที่ตั้งค่า dependency ก็สร้างแต่ละงานขึ้นมาและส่งเฉพาะ dependency ที่จำเป็น วิธีนี้ดีตรงที่สามารถเก็บ helper method ของงาน/handler เฉพาะนั้นไว้เป็น private method ของ struct นั้นได้แต่ถ้างานหนึ่งต้องการอีกงานหนึ่ง ก็อาจเริ่มต้องส่งต่อกันไปมา หรือดึงออกเป็น package/service แยก ซึ่งอาจยุ่งยากได้
เช่นเขียนเป็น
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ช่วงนี้กำลังลองเล่น ogen อยู่: https://github.com/ogen-go/ogen
ถ้าเขียนนิยาม OpenAPI ไว้ มันจะจัดการ routing, นิยาม struct, การตรวจสอบ JSON schema ฯลฯ ให้ สิ่งที่ผมต้องทำมีแค่ implement service เท่านั้น
งานอย่างการตรวจสอบช่วงของจำนวนเต็มใน query string นั้นน่าเบื่อมาก และถ้าเขียนเองก็พิมพ์ผิดได้ง่ายเหลือเกิน
ตอนนี้ยังอยู่ในขั้นลองเล่น เลยยังไม่เจอส่วนที่แย่
รู้สึกว่าการเขียน IDL คล้าย ๆ กันอย่าง Protobuf หรือ capnproto ให้ประสิทธิภาพการทำงานสูงกว่ามาก
[1] https://github.com/danielgtaylor/huma
[2] https://github.com/swaggest/rest
ตอนแรกคิดว่าการเขียนสเปกคงน่าเบื่อ แต่จริง ๆ แล้วดีกว่าที่คิดมาก และยังไงก็ต้องมีสเปกอยู่แล้ว เลยมองว่าเขียนไว้ล่วงหน้าดีกว่า
รู้สึกว่า fx(https://github.com/uber-go/fx) เป็นเครื่องมือที่เรียบง่ายมากแต่ก็ใช้งานได้หลากหลายสำหรับการออกแบบแอปพลิเคชัน
คำแนะนำในบทความยังมีประโยชน์อยู่ แต่ช่วยตัดประเด็นที่ว่า “จะรับประกันได้อย่างไรว่า X ถูก initialize แล้วเมื่อ Y ต้องใช้” ออกไปได้ทั้งหมด ปัญหา N*M ลดเหลือปัญหา N คือแค่ต้องสนใจว่าจะ initialize แต่ละชิ้นอย่างไร และไม่ต้องกังวลเรื่องการ synchronize การ initialize ระหว่างชิ้นส่วนต่าง ๆ
เคยใช้ไลบรารี dependency injection ในหลายภาษา และเคย implement เองด้วย แต่ความเรียบง่ายกับความอเนกประสงค์ของ fx ถูกใจที่สุดเท่าที่เคยเจอมา
ในระบบที่ออกแบบมาดี ปัญหานี้ควรเป็นเรื่องเล็กน้อย การรับประกันว่าบางอย่างถูก 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/ทดสอบไม่ได้อีกแล้ว