- การปรับปรุง ServeMux ของ
net/http ใน Go 1.22 เพิ่มการจับคู่แพตเทิร์นที่ทรงพลังขึ้นให้กับ HTTP multiplexer มาตรฐาน ช่วยลดพื้นที่ที่เคยต้องพึ่งพา router ภายนอกเพราะมีเพียงการจับคู่ path แบบง่าย
- mux ใหม่สามารถใช้ HTTP method และ wildcard ของ path อย่าง
/task/{id}/ ร่วมกันในแพตเทิร์นได้ ทำให้ path เดียวกันเชื่อมไปยัง handler คนละตัวตาม GET, POST, DELETE ได้
- ค่าที่จับคู่ได้อ่านได้ด้วย
req.PathValue("id") และสามารถควบคุม path ที่ตามมาทั้งหมดหรือจุดสิ้นสุด path แบบตรงตัวได้ด้วย {id}... และ {$}
- ความขัดแย้งของแพตเทิร์นจะแสดงเป็น panic ตอนลงทะเบียน พร้อมบอกว่า path ใดจับคู่ได้ทั้งสองฝั่ง และเหตุใดจึงไม่มีฝั่งใดเฉพาะเจาะจงกว่าอีกฝั่ง
- เซิร์ฟเวอร์ที่ใช้ mux มาตรฐานอย่างเดียวแล้วเพียงพออาจมีมากขึ้น แต่ router และ framework ภายนอกอย่าง
gorilla/mux หรือ Gin ยังคงมีฟีเจอร์และเครื่องมือที่กว้างกว่า
การเปลี่ยนแปลงของ ServeMux ใน Go 1.22
- Go 1.22 เตรียมเพิ่ม การจับคู่แพตเทิร์นที่ทรงพลังขึ้น ให้กับ
http.ServeMux ซึ่งเป็น HTTP serving multiplexer พื้นฐานของ net/http
http.ServeMux เดิมเน้นการจับคู่ path พื้นฐาน จึงมีการใช้ไลบรารีภายนอกจำนวนมากสำหรับ routing ที่ซับซ้อนกว่า
- mux ใหม่ดึงการจับคู่ขั้นสูงเข้ามาใน standard library เพื่อลดช่องว่างด้านฟีเจอร์กับแพ็กเกจภายนอก
- ตัวอย่างที่ใช้เปรียบเทียบคือแนวทางเดิมของ standard library และตัวอย่าง REST server ที่ใช้
gorilla/mux
วิธีใช้งานพื้นฐานของ mux ใหม่
- นักพัฒนาที่เคยใช้แพ็กเกจ router ของ Go อย่าง
gorilla/mux จะคุ้นเคยกับวิธีใช้งาน mux มาตรฐานใหม่
- ตัวอย่างสร้าง mux ด้วย
http.NewServeMux() แล้วลงทะเบียนแพตเทิร์นที่สมบูรณ์ขึ้นใน HandleFunc
mux.HandleFunc("GET /path/", func(w http.ResponseWriter, r *http.Request) {
fmt.Fprint(w, "got path\n")
})
mux.HandleFunc("/task/{id}/", func(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
fmt.Fprintf(w, "handling task with id=%v\n", id)
})
- handler ตัวแรกใส่ method
GET ไว้ในแพตเทิร์น จึงทำงานเฉพาะคำขอ GET ที่เริ่มด้วย /path/
- handler ตัวที่สองจับคู่ path component เดียวด้วย
{id} ใน /task/{id}/ แล้วอ่านค่าจาก handler ด้วย r.PathValue("id")
- เนื่องจากเป็นตัวอย่างในช่วงที่ Go 1.22 ยังไม่ออก release จึงแนะนำให้รันด้วย
gotip
- ผลการทดสอบช่วยยืนยันกฎ routing ได้ทันที
- คำขอ
/what/ ได้ 404 page not found
- คำขอ
GET ไปยัง /path/ ได้ got path
- คำขอ
POST ไปยัง /path/ ได้ Method Not Allowed
- คำขอ
/task/f0cd2e/ ถูกประมวลผลด้วย id=f0cd2e
ฟีเจอร์แพตเทิร์นเพิ่มเติมและการจัดการความขัดแย้ง
ServeMux ใหม่ควบคุมการจับคู่ path ได้ละเอียดขึ้น นอกเหนือจาก wildcard พื้นฐาน
- ใช้
{id}... เพื่อจับคู่ path ที่ตามมาทั้งหมด เข้า wildcard
- ใช้
{$} เพื่อจับคู่ จุดสิ้นสุดของ path อย่างเคร่งครัด
- จัดการลำดับความสำคัญและกฎความขัดแย้งระหว่างแพตเทิร์น
- ความเป็นไปได้ที่จะเกิดความขัดแย้งเป็นส่วนที่ proposal ให้ความสำคัญเป็นพิเศษ
- ตัวอย่างเช่น แพตเทิร์นสองรายการต่อไปนี้จะจับคู่คำขอ
/task/0/status/ ได้ทั้งคู่
mux.HandleFunc("/task/{id}/status/", ...)
mux.HandleFunc("/task/0/{action}/", ...)
- ในกรณีนี้
ServeMux ใหม่จะทำให้เกิด panic ตอนลงทะเบียน
- ข้อความ error จะแสดงทั้งตัวอย่าง path ที่แพตเทิร์นทั้งสองจับคู่ได้ และเหตุผลว่าทำไมไม่มีแพตเทิร์นใดเฉพาะเจาะจงกว่าอีกแพตเทิร์น
- ในโค้ดที่ซับซ้อนซึ่งลงทะเบียนแพตเทิร์นจากหลายตำแหน่ง การวินิจฉัยความขัดแย้งอย่างละเอียด แบบนี้มีประโยชน์เป็นพิเศษ
การทำตัวอย่าง task server ใหม่
- นำ task/todo-list server จากซีรีส์ REST Servers in Go เดิมมาทำใหม่ด้วย mux ที่ปรับปรุงใน Go 1.22
- โค้ดทั้งหมดอยู่ที่ ตัวอย่าง stdlib-newmux
- แพตเทิร์นที่ลงทะเบียนใช้ HTTP method ร่วมกับ wildcard ของ path
mux.HandleFunc("POST /task/", server.createTaskHandler)
mux.HandleFunc("GET /task/", server.getAllTasksHandler)
mux.HandleFunc("DELETE /task/", server.deleteAllTasksHandler)
mux.HandleFunc("GET /task/{id}/", server.getTaskHandler)
mux.HandleFunc("DELETE /task/{id}/", server.deleteTaskHandler)
mux.HandleFunc("GET /tag/{tag}/", server.tagHandler)
mux.HandleFunc("GET /due/{year}/{month}/{day}/", server.dueHandler)
- เช่นเดียวกับตัวอย่าง
gorilla/mux แม้เป็น path เดียวกันก็สามารถแยกเป็น handler ตาม HTTP method ได้
- ใน
http.ServeMux รุ่นก่อน หลาย method ของ path เดียวกันจะเข้า handler เดียวกัน และต้องตรวจ method ภายใน handler
- ใน mux ใหม่ การตัดสินใจ routing ถูกจัดการนอก handler มากขึ้น ทำให้แยก handler ได้ง่ายขึ้น
การใช้ PathValue และข้อจำกัด
getTaskHandler อ่าน ID ที่ดึงจาก path ด้วย req.PathValue("id") แล้วแปลงเป็นจำนวนเต็มด้วย strconv.Atoi
- แพตเทิร์น
{id} ของ mux ใหม่ไม่ระบุ regular expression ที่จับคู่เฉพาะจำนวนเต็ม จึงต้องจัดการ error จาก strconv.Atoi
- หากแปลง ID ไม่สำเร็จจะตอบด้วย
http.StatusBadRequest และหากไม่พบ task ใน storage จะตอบด้วย http.StatusNotFound
- implementation สุดท้ายคล้ายกับแนวทางใน Part 2 ที่ใช้
gorilla/mux มาก
- หากจำเป็น สามารถใช้ wildcard
{$} ของ http.ServeMux ใหม่เพื่อจำกัด path ที่ตามมาได้อย่างง่ายดาย
ตำแหน่งของ mux มาตรฐานและ router ภายนอก
- สำหรับผู้เริ่มต้น Go คำถามว่า “ควรใช้แพ็กเกจ router ใด” เป็นคำถามที่พบบ่อย
- หลัง Go 1.22 ผู้ใช้จำนวนมากอาจรู้สึกว่า mux มาตรฐานใหม่เพียงอย่างเดียวก็เพียงพอ โดยไม่ต้องใช้แพ็กเกจภายนอก
- router อย่าง
gorilla/mux ยังคงมีฟีเจอร์มากกว่า standard library
- framework น้ำหนักเบาอย่าง Gin มีไม่เพียง router แต่ยังมีเครื่องมือเพิ่มเติมสำหรับสร้าง backend เว็บด้วย
- การที่ standard library มีความสามารถมากขึ้นเป็นการเปลี่ยนแปลงเชิงบวกทั้งสำหรับผู้ใช้แพ็กเกจภายนอกและผู้ใช้ที่ใช้เฉพาะ standard library
1 ความคิดเห็น
ความคิดเห็นจาก Hacker News
การทำให้เกิด panic เมื่อสอง route match พร้อมกันดูจะขัดกับสัญชาตญาณ เฟรมเวิร์กเว็บอื่น ๆ มักใช้ route ที่ match ซึ่งถูกลงทะเบียนก่อน เลยสงสัยว่ามีเหตุผลเฉพาะของ Go หรือไม่
กรณีขอบที่ว่า “สามารถลงทะเบียน HTTP route ได้จากหลายที่ จึงหาการ match ซ้ำได้ยาก” ดูเหมือนแก้ได้ด้วยเครื่องมือ ตลอดอาชีพที่ผ่านมา ผมใช้พฤติกรรมแบบ “อันที่ match ก่อนชนะ” อยู่บ่อย ๆ และก็มีความต้องการทางงานไม่น้อยที่ต้องแยก
/foo/barเป็น route หนึ่ง และ/foo/{id}เป็นอีก route หนึ่งในข้อเสนอการออกแบบ[1] ระบุว่า หาก match ด้วย pattern ที่มีความเฉพาะเจาะจงสูง ก็จะรักษาคุณสมบัติไม่ขึ้นกับลำดับของ pattern ใน ServeMux เดิมไว้ได้ แต่ในบางกรณีอาจดูได้ไม่ชัดในทันทีว่า pattern ใดในสอง pattern มีความเฉพาะเจาะจงมากกว่า หรือทำไมจึงชนกัน ดังนั้นข้อความ panic ที่เกิดเมื่อมีการลงทะเบียน pattern ที่ขัดแย้งกันจึงถูกทำให้แสดงการชนกันด้วยตัวอย่าง path
ในการอภิปรายเบื้องหลัง[2] ก็อธิบายว่า semantics ของ mux ไม่ขึ้นกับลำดับการเรียก
HandleหรือHandleFuncความเป็นอิสระจากลำดับทำให้ลำดับการ initialize package ไม่สำคัญ และทำให้ refactor โค้ดง่ายขึ้น ดังนั้นจึงไม่ใช้ลำดับการลงทะเบียนเป็นเกณฑ์ตัดสินกรณีเสมอกัน การลงทะเบียนซ้ำจะทำให้เกิด panic และการหลีกเลี่ยง semantics ที่พึ่งพาลำดับการลงทะเบียนยังคงเป็นเป้าหมายหลักของการออกแบบ[1]: https://github.com/golang/go/issues/61410
[2]: https://github.com/golang/go/discussions/60227
เมื่อไม่นานมานี้ในโปรเจกต์ส่วนตัว ผมใช้ gorilla/mux แล้วเจอบั๊กที่เผลอสร้าง route ทับซ้อนกัน หาก router เกิด panic แทนที่จะรันต่อ ก็คงบังคับให้ผม refactor URL และหลีกเลี่ยงบั๊กนั้นได้หมด
initระดับไฟล์ของ Go ได้หากต้องการพฤติกรรมแบบ
/foo/barกับ/foo/{id}ก็สร้าง handler ที่รับ path พื้นฐาน แล้วแยก branch ไปยังฟังก์ชัน handler อื่นอย่างชัดเจนภายในนั้นได้ หากต้องการพฤติกรรมที่ไม่มีใน standard library ก็มีไลบรารีอื่นที่มีฟีเจอร์มากกว่าให้เลือกมากมายstandard library และภาษาของ Go มักเอนเอียงไปทางระมัดระวังเสมอเมื่อต้องเผชิญความกำกวมที่เป็นไปได้ และด้วยเหตุนี้จึงสร้างสัญชาตญาณที่หนักแน่นและแม่นยำได้ง่ายว่า standard library จะทำงานอย่างไร การอ่านและทำความเข้าใจได้อย่างรวดเร็วและถูกต้องมีค่ามากกว่าการประหยัดเวลาไม่กี่วินาทีตอนเขียนโค้ดมาก
/foo/barกับ/foo/{id}ดูเหมือนน่าจะอนุญาตได้ เพราะตัวแรกเฉพาะเจาะจงกว่า จึงมีลำดับความสำคัญมากกว่า น่าจะไม่เป็นไรแต่ถ้ามี
/foo/{id}/deleteกับ/foo/bar/{action}แล้ว/foo/bar/deleteจะ match กับทั้งคู่ และไม่มีฝ่ายใดเฉพาะเจาะจงกว่ากัน ก็น่าจะเกิด panic ฟังดูสมเหตุสมผล แต่ priority แบบให้ฝั่งที่ลงทะเบียนก่อนชนะก็อาจดีกว่า$ท้ายสุดโปรเจกต์ gorilla/mux ค่อนข้างแปลกและชวนสับสน ปีที่แล้ว maintainer archive โปรเจกต์ ผมเลยย้ายไปใช้ multiplexer อีกตัวชื่อ gin-gonic
แต่พอเห็นถูกพูดถึงในบทความจึงกลับไปตรวจดูอีกครั้ง พบว่าไม่อยู่ในสถานะ archive แล้ว ไม่แน่ใจว่านี่ทำให้ตั้งคำถามต่อเสถียรภาพของทั้งโปรเจกต์หรือไม่ หรือกลับกันคือเป็นหลักฐานว่าโอเพนซอร์สมีเสถียรภาพจริง เพราะแม้ developer จะออกไป คนอื่นก็ยังเข้ามารับช่วงดูแลต่อได้ ถึงอย่างนั้นก็ดีที่ฟีเจอร์นี้มีให้ใน Go เอง
[0] https://gorilla.github.io/blog/2023-07-17-project-status-upd...
[1] https://www.reddit.com/r/golang/comments/1528e25/gorilla_web...
[2] https://news.ycombinator.com/item?id=36935541
ก่อนหน้านี้ก็เคยเขียนไว้ในคอมเมนต์[1] แล้ว แต่ผมมองว่า ไวยากรณ์ของข้อเสนอนี้ผิด
ต้องสร้างสตริงเวทมนตร์แปลก ๆ เพื่อกำหนด handler ไม่เข้าใจว่าทำไมไม่ทำให้เป็นอาร์กิวเมนต์จริง ๆ ไปเลย แบบนั้นจะใช้ค่าคงที่เดิมได้ง่ายกว่าด้วย
[1] https://github.com/golang/go/issues/61410#issuecomment-16580...
ถ้าเพิ่มอาร์กิวเมนต์ใหม่ให้ interface ของ Mux โค้ดเดิมจะพัง และในเมื่อเปลี่ยน method signature ไม่ได้ สตริงเวทมนตร์แบบนี้ก็ดูเป็นทางประนีประนอมที่สมเหตุสมผล เมธอด HTTP ก็คงไม่ได้จะเปลี่ยนเร็ว ๆ นี้ และตรวจสอบตอนลงทะเบียนหรือด้วย static analysis ก็ง่าย ไม่ค่อยเห็นว่าการใช้ค่าคงที่จะให้คุณค่าอะไร โดยเฉพาะถ้าต้องทำลายความเข้ากันได้ย้อนหลัง หรือถ้าต้องคง mux API ใหม่ที่ต่างจาก API เดิมแค่นิดเดียวไปพร้อมกันตลอดกาล
{parameter}อยู่แล้ว เลยคิดว่าเป็น สตริงเวทมนตร์ อยู่แล้วไม่ใช่หรือถ้ามองตามศัพท์ของ RFC2616 ว่าเป็น
Request-URIที่มีเวทมนตร์ของพารามิเตอร์เพิ่มมานิดหน่อย แล้วตรงนี้กลายเป็น[ Method SP ] Request-URIที่ดูเหมือน request line ก็พอเข้าใจได้ เข้ากันได้ย้อนหลังเต็มที่ และแทบจะชัดเจนว่าทำอะไรโดยไม่ต้องดูเอกสารMethodเป็นหนึ่งในค่าคงที่ที่กำหนดไว้ล่วงหน้าหรือเป็นextension-method = tokenและtokenไม่รวมช่องว่าง สแลช และวงเล็บหลายชนิด ดังนั้นแม้จะเป็น custom method ที่แปลกมาก ๆ ก็ดูไม่น่าจะสับสนหรือถูก parse ผิด.Get(),.Post()ก็ไม่เคยช่วยแก้อะไรให้เป็นพิเศษ การมีเมธอดลงทะเบียนเดียวที่ใส่ method ไว้ในสตริงก็น่าจะโอเคโดยสมบูรณ์HEADอัตโนมัติมีปัญหาอะไรไม่ชอบวิธีนี้เลย สงสัยว่ามีเหตุผลอะไรถึงใช้ คำนำหน้า method ที่ทำเป็นสตริง
type safety ของเมธอดแยกตาม verb อย่าง
mux.Get,mux.Postดีกว่าสตริงเวทมนตร์ที่ตรวจสอบตอน runtime แถมยังใช้ autocomplete หรือ IntelliSense ใน editor ได้ด้วยfunc Get(mux, uri, handler) { mux.HandleFunc("GET " + uri, handler) }ได้เลยที่ละ type ไว้เพราะอยากให้ดูเรียบง่าย
น่าสนใจ ตอนนี้ mux match ได้ถึงระดับ method แล้ว เลยสงสัยว่าถ้า route ตรงแต่ method ไม่ตรงจะเกิดอะไรขึ้น เป็น 404 หรือ 405
ลองดูแล้วพบว่าส่งกลับเป็น 405 พร้อม header
Allowที่เติมไว้อย่างถูกต้องhttps://cs.opensource.google/go/go/+/master:src/net/http/ser...
รู้ว่าหลายคนไม่ชอบ interface แบบสตริง แต่ผมว่าความเป็นไปได้ที่จะพิมพ์ชื่อ HTTP method ผิดนั้นน้อยกว่าความเป็นไปได้ที่จะกรอกอะไรแปลก ๆ แต่ถูกไวยากรณ์เสียอีก ส่วนตัวคิดว่าแค่ static analysis เตือน syntax ที่ผิดได้ก็พอแล้ว
อย่างไรก็ตาม ถ้ามีความต้องการขั้นสูงอยู่แล้ว ผมว่าไม่ควรใช้ serve mux พื้นฐาน มีตัวเลือกอีกมากที่เหมาะกับ use case หลากหลายกว่า และถ้าสร้าง route แบบ dynamic อยู่แล้ว การเขียน router เองอาจยุ่งยากน้อยกว่าการฝืนยัดโครงสร้างข้อมูลที่มีให้เข้ากับ router เดิม
เส้นทางที่ทับซ้อนกันควร match ตามลำดับที่กำหนดไว้ มากกว่าจะ panic
ในตัวอย่าง ถ้ากำหนดเส้นทาง
/task/0/{action}/ก่อนเส้นทาง wildcard ก็คงคาดหวังว่ามันจะ match ก่อน แบบนี้ถึงจะกำหนด handler สำหรับกรณีพิเศษได้ง่ายผมว่าปล่อยให้มันทำงานตามที่ผมบอกไปเลยดีกว่าการล้มเหลวแบบ “ช่วยเหลือเกินไป” กลิ่นมันแปลก ๆ และไม่ค่อยเป็นสไตล์ Go
ServeMux เดิมถูกออกแบบมาไม่ให้เคารพลำดับการลงทะเบียน เพราะการพึ่งพาลำดับการลงทะเบียนนั้นอันตราย หนึ่งในเป้าหมายหลักของการออกแบบ Go คือการสนับสนุน “การเขียนโปรแกรมในระดับใหญ่” และ side effect แปลก ๆ ที่เกิดจากการแก้โค้ดที่อยู่ไกลกันก็เป็นสิ่งที่ต้องการหลีกเลี่ยงอย่างตรงจุด
ถ้าเป็นตัวอย่างง่าย ๆ ที่การลงทะเบียนทั้งหมดเกิดในฟังก์ชันเดียวของแพ็กเกจเดียว เจตนาของลำดับการลงทะเบียนก็ชัดเจน แต่ใน codebase ที่ใหญ่พอ การลงทะเบียนมีแนวโน้มเกิดหลายที่ และอาจเป็นผลจากไฟล์ input หรือโค้ดที่ generate มา คนที่เขียนสเปกสำหรับ generated code อาจไม่รู้ความละเอียดอ่อนของลำดับการรันและผลต่อ routing ก็ได้ แค่การเรียง import ของแพ็กเกจตามพจนานุกรมเปลี่ยนไป routing ก็อาจเปลี่ยนโดยไม่คาดคิด
การหลีกเลี่ยงผลลัพธ์ที่ไม่คาดคิด ทำให้ refactor ได้ง่าย และทำให้ให้เหตุผลกับโปรแกรมที่ซับซ้อนได้ง่ายขึ้น เป็นทิศทางที่สมกับ Go อย่างชัดเจน ไม่จำเป็นต้องเห็นด้วยกับทุกตัวเลือก แต่เหตุผลนั้นสอดคล้องกัน
ดีที่ลด dependency ภายนอกไปได้หนึ่งตัว นอกเรื่องนิดหนึ่ง ชุด sqlc + Postgres + templ + htmx + Tailwind เป็น stack ที่ productivity ในการพัฒนาสูงมาก templ ให้ความรู้สึกเหมือน JSX สำหรับ Go
คงต้องลองดู htmx ด้วย
เคยหวังว่า bud ที่ผูกการ compile ของ Svelte จะมาเติมช่องว่างนี้ แต่ templ ดูเป็นทางเลือกที่ดีกว่า
ไม่ชอบข้อเสนอนี้เลยจริง ๆ เอา HTTP request method ไปใส่ไว้ใน URI แถมทำแบบนั้นแค่บางครั้ง และอาจถึงขั้นให้เขียนแบบ
POST,PUT,PATCH /somethingด้วย ขอบายดีกว่าถ้าจำเป็นต้องทำจริง ๆ ก็ควรทำเมธอดเฉพาะสำหรับรับชื่อ HTTP request method ไปเลย
อยากรู้ว่าเมธอดเฉพาะที่รับชื่อ HTTP method แก้ปัญหาอะไรเมื่อเทียบกับการใส่ method ไว้ในสตริง
น่าจะมีอาร์กิวเมนต์แยกสำหรับรายการ method หรือ options ได้
Go ไม่ใช่เครื่องมือหลักของผม แต่เคยใช้ Gin และมันเป็นแบบนี้:
router.GET("/", func(context *gin.Context) { ... })ดูไม่ได้ต่างจากภาษาและเฟรมเวิร์กอื่น ๆ หลายตัวเท่าไร อยากรู้ว่ามีตัวอย่างอื่นที่ใช้รูปแบบ
GET /path/อีกไหมการที่ทีมภาษาทางการใช้ สตริงแบบมีคำนำหน้าแทน enum ดูเป็นการตัดสินใจออกแบบที่ประหลาดมาก
เช่น
(Http.GET, "/path")ดูดีกว่า("GET /path")ในปัจจุบัน แปลกดีถึงอย่างนั้นก็ยังชอบให้มีหลายฟังก์ชันอย่าง
mux.GET,mux.POSTมากกว่าวิธีนั้น