1 điểm bởi GN⁺ 2023-10-17 | 1 bình luận | Chia sẻ qua WhatsApp
  • Việc cải tiến ServeMux của net/http trong Go 1.22 bổ sung khả năng khớp mẫu mạnh hơn cho bộ ghép kênh HTTP tiêu chuẩn, giúp giảm những trường hợp trước đây phải phụ thuộc vào router bên thứ ba chỉ vì khớp đường dẫn quá đơn giản
  • Mux mới cho phép dùng đồng thời phương thức HTTP và wildcard đường dẫn như /task/{id}/ trong mẫu, nên cùng một đường dẫn vẫn có thể nối tới các handler khác nhau cho GET, POST, DELETE
  • Có thể đọc giá trị đã khớp bằng req.PathValue("id"), và dùng {id}... cùng {$} để kiểm soát toàn bộ phần đường dẫn theo sau hoặc chính xác điểm kết thúc đường dẫn
  • Xung đột mẫu sẽ lộ ra dưới dạng panic tại thời điểm đăng ký, đồng thời cho biết đường dẫn nào khớp với cả hai bên và vì sao không bên nào cụ thể hơn bên nào
  • Chỉ với mux tiêu chuẩn, số lượng máy chủ có thể đáp ứng đủ nhu cầu sẽ tăng lên, nhưng các router·framework bên thứ ba như gorilla/mux hay Gin vẫn cung cấp phạm vi tính năng và công cụ rộng hơn

Thay đổi của ServeMux trong Go 1.22

  • Go 1.22 dự kiến sẽ đưa khả năng khớp mẫu nâng cao vào http.ServeMux, bộ ghép kênh phục vụ HTTP mặc định của net/http
  • http.ServeMux trước đây chủ yếu dựa vào khớp đường dẫn cơ bản, nên với định tuyến phức tạp hơn thì các thư viện bên thứ ba được dùng rất nhiều
  • Mux mới đưa khả năng khớp nâng cao vào trong thư viện chuẩn, thu hẹp khoảng cách tính năng với các gói bên thứ ba
  • Ví dụ so sánh sử dụng cách làm của thư viện chuẩn cũ và một máy chủ REST dùng gorilla/mux

Cách dùng cơ bản của mux mới

  • Với các lập trình viên từng dùng các gói router Go như gorilla/mux, cách dùng của mux chuẩn mới sẽ khá quen thuộc
  • Ví dụ tạo mux bằng http.NewServeMux() và đăng ký các mẫu phong phú hơn vào 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 đầu tiên bao gồm phương thức GET trong mẫu, nên chỉ hoạt động với các yêu cầu GET bắt đầu bằng /path/
  • Handler thứ hai dùng {id} trong /task/{id}/ để khớp một thành phần đường dẫn đơn, rồi đọc giá trị bằng r.PathValue("id") trong handler
  • Vì đây là ví dụ ở thời điểm Go 1.22 vẫn chưa phát hành, nên khuyến nghị chạy bằng gotip
  • Kết quả kiểm thử cho thấy ngay các quy tắc định tuyến
    • Yêu cầu /what/ trả về 404 page not found
    • Yêu cầu GET tới /path/ trả về got path
    • Yêu cầu POST tới /path/ trả về Method Not Allowed
    • Yêu cầu /task/f0cd2e/ được xử lý với id=f0cd2e

Tính năng mẫu bổ sung và xử lý xung đột

  • ServeMux mới cho phép kiểm soát khớp đường dẫn chi tiết hơn ngoài wildcard cơ bản
    • Dùng {id}... để khớp toàn bộ phần đường dẫn theo sau vào wildcard
    • Dùng {$} để khớp chặt chẽ điểm kết thúc đường dẫn
    • Xử lý quy tắc ưu tiên và xung đột giữa các mẫu
  • Khả năng xảy ra xung đột là phần được đặc biệt chú ý trong đề xuất
  • Ví dụ, hai mẫu sau đều khớp với yêu cầu /task/0/status/
mux.HandleFunc("/task/{id}/status/", ...)
mux.HandleFunc("/task/0/{action}/", ...)
  • Trong trường hợp này, ServeMux mới sẽ phát sinh panic ngay tại thời điểm đăng ký
  • Thông báo lỗi sẽ hiển thị cả một đường dẫn ví dụ mà hai mẫu cùng khớp, cùng với lý do vì sao không mẫu nào cụ thể hơn mẫu còn lại
  • Trong mã phức tạp nơi các mẫu được đăng ký từ nhiều vị trí, kiểu chẩn đoán xung đột chi tiết này đặc biệt hữu ích

Cài đặt lại ví dụ máy chủ task

  • Máy chủ task/todo-list trong loạt bài REST Servers in Go trước đây được cài đặt lại bằng mux nâng cấp của Go 1.22
  • Toàn bộ mã nằm trong ví dụ stdlib-newmux
  • Các mẫu đăng ký dùng kết hợp phương thức HTTP và wildcard đường dẫn
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)
  • Giống như ví dụ gorilla/mux, cùng một đường dẫn vẫn có thể tách ra thành handler theo từng phương thức HTTP
  • Trong http.ServeMux trước đây, nhiều phương thức trên cùng một đường dẫn sẽ đi vào cùng một handler, và phải kiểm tra phương thức bên trong handler
  • Với mux mới, quyết định định tuyến được xử lý nhiều hơn ở bên ngoài handler nên việc tách handler trở nên dễ hơn

Dùng PathValue và các giới hạn

  • getTaskHandler đọc ID trích xuất từ đường dẫn bằng req.PathValue("id") và chuyển sang số nguyên bằng strconv.Atoi
  • Mẫu {id} của mux mới không cho phép chỉ định regex chỉ khớp số nguyên, nên vẫn cần xử lý lỗi từ strconv.Atoi
  • Nếu chuyển đổi ID thất bại thì phản hồi bằng http.StatusBadRequest, còn nếu không tìm thấy task trong kho lưu trữ thì phản hồi bằng http.StatusNotFound
  • Cách cài đặt cuối cùng rất giống với lời giải ở Part 2 dùng gorilla/mux
  • Khi cần, có thể dễ dàng giới hạn phần đường dẫn theo sau bằng wildcard {$} của http.ServeMux mới

Vị trí của mux chuẩn và router bên thứ ba

  • Với người mới học Go, câu hỏi “nên dùng gói router nào” là điều xuất hiện rất thường xuyên
  • Sau Go 1.22, nhiều người dùng có thể sẽ thấy chỉ riêng mux chuẩn mới đã đủ dùng mà không cần gói bên thứ ba
  • Các router như gorilla/mux vẫn cung cấp nhiều tính năng hơn thư viện chuẩn
  • Các framework nhẹ như Gin không chỉ có router mà còn cung cấp thêm công cụ để xây dựng web backend
  • Việc thư viện chuẩn trở nên mạnh hơn là thay đổi tích cực cho cả người dùng gói bên thứ ba lẫn người chỉ dùng thư viện chuẩn

1 bình luận

 
GN⁺ 2023-10-17
Ý kiến trên Hacker News
  • Việc gây panic khi hai route cùng khớp có vẻ trái với trực giác. Các web framework khác thường dùng route khớp được đăng ký trước, nên tôi tò mò liệu có lý do nào chỉ riêng Go mới như vậy không
    Trường hợp biên rằng “có thể đăng ký HTTP route ở nhiều nơi nên khó tìm các khớp trùng lặp” có vẻ có thể giải quyết bằng công cụ. Trong suốt sự nghiệp, tôi đã tận dụng rất nhiều hành vi “cái khớp trước thắng”, và cũng có nhiều yêu cầu nghiệp vụ cần để /foo/bar là một route riêng, còn /foo/{id} là route khác

    • Không thể bảo đảm thứ tự đăng ký luôn giống nhau, nên thực tế nó trở thành hành vi không xác định. ServeMux vốn được thiết kế và triển khai như vậy, và người ta cho rằng tiếp tục hỗ trợ hành vi đó là hữu ích
      Đề xuất thiết kế[1] nói rằng nếu khớp theo pattern có tính cụ thể cao hơn thì có thể giữ được tính độc lập với thứ tự của các pattern ServeMux ban đầu, nhưng có thể khó nhìn ra ngay pattern nào trong hai pattern là cụ thể hơn, hoặc vì sao chúng xung đột. Vì vậy thông báo panic khi đăng ký pattern xung đột sẽ hiển thị xung đột bằng một đường dẫn ví dụ
      Trong thảo luận nền tảng[2] cũng giải thích rằng ngữ nghĩa của mux không phụ thuộc vào thứ tự gọi Handle hay HandleFunc. Nhờ tính độc lập với thứ tự, thứ tự khởi tạo package không quan trọng và việc refactor code cũng dễ hơn. Vì thế, mục tiêu thiết kế cốt lõi vẫn là không dùng thứ tự đăng ký làm tiêu chí xử lý trường hợp hòa; đăng ký trùng lặp sẽ gây panic; và tránh các ngữ nghĩa phụ thuộc vào thứ tự đăng ký
      [1]: https://github.com/golang/go/issues/61410
      [2]: https://github.com/golang/go/discussions/60227
    • Lý do dùng một ngôn ngữ biên dịch và có kiểu như Go là để kéo lỗi sang trái sớm hơn. Lỗi bị bắt khi ứng dụng khởi động tốt hơn lỗi nổ ở một ca góc trong dịch vụ thực tế, và lỗi bị bắt lúc biên dịch còn tốt hơn lỗi bị bắt khi khởi động
      Gần đây trong một dự án cá nhân, khi dùng gorilla/mux, tôi gặp lỗi do vô tình tạo các route chồng lấn; nếu router gây panic thay vì chạy, nó đã buộc tôi refactor URL và tránh hoàn toàn lỗi đó
    • panic tự nó là một công cụ. Nó ngăn bạn viết code mơ hồ, khó suy luận, và giúp không phải phụ thuộc vào những yếu tố có thể thay đổi tùy ý như thứ tự khởi tạo. Ví dụ chỉ đổi tên file cũng có thể ảnh hưởng đến thứ tự các hàm init theo từng file trong Go
      Nếu cần hành vi như /foo/bar/foo/{id}, bạn có thể tạo một handler nhận đường dẫn cơ sở rồi rẽ nhánh tường minh sang các hàm handler khác bên trong đó. Nếu muốn hành vi không có trong thư viện chuẩn, cũng có rất nhiều thư viện khác nhiều tính năng hơn
      Thư viện chuẩn và ngôn ngữ Go trước các khả năng mơ hồ luôn có xu hướng thận trọng, và nhờ vậy ta dễ xây dựng trực giác mạnh và chính xác về cách thư viện chuẩn hoạt động. Khả năng đọc và hiểu code nhanh, chính xác có giá trị hơn nhiều so với tiết kiệm vài giây khi viết code
    • /foo/bar/foo/{id} có vẻ sẽ được cho phép. Vì cái trước cụ thể hơn nên có độ ưu tiên, có lẽ không vấn đề gì
      Tuy nhiên nếu có /foo/{id}/delete/foo/bar/{action} thì /foo/bar/delete sẽ khớp cả hai, và không bên nào cụ thể hơn, nên có lẽ sẽ panic. Cảm giác hợp lý, nhưng ưu tiên kiểu bên đăng ký trước thắng cũng có thể tốt hơn
    • Nhận được lỗi sẽ tốt hơn nhiều cho việc debug so với việc route thực sự được chạy lại là route khác và nhận dữ liệu sai. Tôi từng gặp chuyện này trong Django do một route thiếu regex $ ở cuối
  • Dự án gorilla/mux hơi kỳ lạ và gây bối rối. Năm ngoái các maintainer đã archive dự án, nên chúng tôi chuyển sang một multiplexer khác là gin-gonic
    Nhưng khi thấy bài viết nhắc đến nên tôi kiểm tra lại, thì nó không còn ở trạng thái archive nữa. Tôi không rõ điều này đặt dấu hỏi về độ ổn định của toàn bộ dự án, hay ngược lại là bằng chứng rằng open source thực sự ổn định vì ngay cả khi nhà phát triển rời đi, người khác vẫn có thể đứng ra tiếp tục bảo trì. Dù sao thì việc Go tự cung cấp tính năng này vẫn là điều tốt

  • Trước đây tôi cũng đã viết trong bình luận [1], nhưng tôi cho rằng cú pháp của đề xuất này là sai
    Để định nghĩa handler lại phải tạo ra một chuỗi ma thuật kỳ quặc. Không hiểu vì sao không biến nó thành đối số thực sự; làm vậy thì các hằng số hiện có cũng dùng dễ hơn
    [1] https://github.com/golang/go/issues/61410#issuecomment-16580...

    • Tôi nghĩ đó chẳng phải là vì bảo đảm tương thích ngược của Go sao
      Nếu thêm đối số mới vào interface Mux thì code hiện có sẽ hỏng, mà không thể đổi chữ ký method, nên kiểu chuỗi ma thuật này trông như một thỏa hiệp hợp lý. HTTP method cũng không sớm thay đổi, và cũng dễ kiểm chứng ở thời điểm đăng ký hoặc bằng phân tích tĩnh. Tôi không rõ dùng hằng số đem lại giá trị gì, nhất là nếu phải phá vỡ tương thích ngược hoặc phải duy trì song song mãi một API mux mới chỉ hơi khác API hiện có
    • Do phải parse {parameter} nên tôi nghĩ vốn đã là chuỗi ma thuật rồi
      Có thể xem nó theo thuật ngữ RFC2616 là Request-URI có thêm chút ma thuật tham số, và giờ thành [ Method SP ] Request-URI trông giống request line. Hoàn toàn tương thích ngược, và gần như nhìn là hiểu nó làm gì mà không cần đọc tài liệu
      Method hoặc là một trong vài hằng số định nghĩa sẵn, hoặc là extension-method = token, còn token loại trừ khoảng trắng, dấu slash và các loại ngoặc, nên kể cả method tùy biến rất lạ cũng có vẻ không có khả năng bị nhầm lẫn hay parse sai
    • Tôi đồng ý là hơi kỳ, nhưng lý do thì rõ ràng. Họ không muốn phá vỡ hay thay đổi interface công khai hiện có
    • Ban đầu tôi cũng có cảm giác như vậy, nhưng nói thật khi dùng Gin, các method như .Get(), .Post() chưa từng giải quyết được gì cho tôi. Một method đăng ký duy nhất nhét method vào trong chuỗi có lẽ cũng hoàn toàn ổn
    • Tôi không hiểu tự động đăng ký route HEAD thì có vấn đề gì
  • Tôi không thích cách này. Tò mò không biết có lý do gì để dùng tiền tố method được chuỗi hóa như vậy không
    Type safety của các method theo động từ như mux.Get, mux.Post tốt hơn chuỗi ma thuật được kiểm chứng ở runtime. Trình soạn thảo cũng có thể autocomplete hoặc IntelliSense

    • Cỡ này thì thêm cực kỳ dễ. Nếu thật sự bận tâm đến mức đó, chỉ cần làm kiểu func Get(mux, uri, handler) { mux.HandleFunc("GET " + uri, handler) } là được
      Tôi bỏ qua kiểu dữ liệu cho trông đơn giản
    • Tôi cũng không thích lắm. Tôi muốn chắc rằng routing hoạt động ở thời điểm biên dịch chứ không phải runtime
    • Nếu muốn type safety thì chọn một ngôn ngữ type-safe là được
    • Tôi không thích dùng chuỗi, nhưng công bằng mà nói, khi request đi vào thì HTTP method rốt cuộc cũng là chuỗi. Cũng có nét đẹp ở chỗ nó khớp với tiền tố của dòng đầu tiên trong gói HTTP
    • Nội dung này được đề cập khá rộng trong đề xuất
  • Thú vị. Giờ mux có thể match cả method, nên tôi đã thắc mắc khi route khớp nhưng method không khớp thì sẽ thế nào. Vấn đề là 404 hay 405
    Kiểm tra thử thì nó trả về 405 với header Allow được điền đúng
    https://cs.opensource.google/go/go/+/master:src/net/http/ser...
    Tôi biết nhiều người ghét interface dựa trên chuỗi, nhưng tôi nghĩ khả năng nhập đúng một thứ sai còn cao hơn là gõ nhầm tên HTTP method. Cá nhân tôi thấy phân tích tĩnh cảnh báo cú pháp sai là đủ
    Tuy nhiên nếu đã có yêu cầu nâng cao thì tôi nghĩ tốt nhất là không dùng serve mux mặc định. Có nhiều lựa chọn phù hợp hơn cho các use case khác nhau, và nếu bạn đã tạo route động rồi thì tự dùng router riêng có khi đỡ mất công hơn là cố nhét cấu trúc dữ liệu đang có vào router hiện tại

  • Với các đường dẫn chồng lấp, match theo thứ tự đã định nghĩa sẽ tốt hơn nhiều so với panic
    Trong ví dụ, nếu đã định nghĩa route /task/0/{action}/ trước route wildcard, tôi sẽ kỳ vọng nó được match trước. Như vậy mới dễ định nghĩa handler cho các trường hợp đặc biệt
    Tôi nghĩ cứ hoạt động đúng như tôi nói thì tốt hơn là thất bại theo cách “hữu ích”. Có mùi hơi lạ và không giống Go lắm

    • Ngữ nghĩa của ngôn ngữ rất quan trọng, và đây thực ra là một lựa chọn khá đậm chất Go
      ServeMux vốn được thiết kế để không tôn trọng thứ tự đăng ký, vì phụ thuộc vào thứ tự đăng ký là nguy hiểm. Một trong các mục tiêu thiết kế cốt lõi của Go là hỗ trợ “lập trình ở quy mô lớn”, và các tác dụng phụ kỳ lạ do sửa code ở nơi xa chính là kiểu việc muốn tránh
      Với ví dụ đơn giản, nơi mọi đăng ký nằm trong một hàm của một package duy nhất, ý đồ về thứ tự đăng ký là rõ ràng. Nhưng trong codebase đủ lớn, việc đăng ký rất có thể diễn ra ở nhiều nơi, hoặc là kết quả của file đầu vào hay code sinh ra. Người viết đặc tả sinh code có thể không biết các tinh vi của thứ tự thực thi và tác động của nó lên routing. Chỉ cần thứ tự sắp xếp từ điển của package import thay đổi cũng có thể khiến routing thay đổi ngoài dự kiến
      Tránh kết quả bất ngờ, giúp refactor dễ hơn và giúp suy luận về chương trình phức tạp dễ hơn rõ ràng là hướng đi đúng chất Go. Không nhất thiết lúc nào cũng đồng ý với lựa chọn đó, nhưng lý do thì nhất quán
  • Rất tốt vì bớt được một dependency bên ngoài. Nhân tiện, tổ hợp sqlc + Postgres + templ + htmx + Tailwind là một stack có năng suất phát triển rất cao. templ giống JSX cho Go

    • Đến giờ tôi có trải nghiệm khá tốt khi dùng sqlc
      Cũng nên thử xem htmx
    • Tôi tò mò ở đây xử lý Tailwind thế nào. Ý tưởng thêm Tailwind rất hay, nhưng nếu phải dùng npm thì tôi thà tự viết CSS hơn
    • Ban đầu tôi tưởng là gõ nhầm template, nhưng hóa ra thực sự có một package tên templ khác với thư viện chuẩn. Xem nhanh tài liệu thì trông khá gọn gàng
      Tôi từng hy vọng bud kèm biên dịch Svelte sẽ lấp chỗ trống này, nhưng templ có vẻ là lựa chọn thay thế tốt hơn
    • Giá mà templ có hỗ trợ IntelliJ thì tốt quá. Tôi thích ý tưởng này
  • Tôi thật sự không thích đề xuất này. Đưa HTTP request method vào URI, mà lại chỉ đôi khi mới làm như vậy, thậm chí có thể còn phải viết kiểu POST,PUT,PATCH /something nữa thì xin miễn
    Nếu nhất thiết phải làm, chỉ cần tạo một method chuyên dụng nhận tên HTTP request method là được

    • Không hiểu vì sao lại vậy. Thực ra cũng chẳng khác biệt lớn. Tôi không nghĩ có thể ghét cả đề xuất chỉ vì một vấn đề thuần túy về phong cách như thế
      Tôi tò mò method chuyên dụng nhận tên HTTP method giải quyết vấn đề gì so với việc đưa method vào chuỗi
    • Đồng ý. Thiết kế như vậy không hợp lý lắm
      Đáng ra có thể có một tham số riêng cho method hoặc danh sách tùy chọn
  • Go không phải công cụ chính của tôi, nhưng tôi đã dùng Gin, và nó kiểu như thế này:
    router.GET("/", func(context *gin.Context) { ... })
    Trông không khác nhiều so với nhiều ngôn ngữ và framework khác. Tôi tò mò liệu có thêm ví dụ nào dùng kiểu GET /path/ không

    • Có lẽ gần như không có, vì ngoài tương thích ngược ra thì không có lý do gì để làm như vậy
    • Thực tế tôi chưa từng thấy. Đơn giản là cách làm tệ
  • Việc đội ngũ ngôn ngữ chính thức dùng chuỗi có tiền tố thay cho enum trông như một lựa chọn thiết kế rất kỳ quặc
    Ví dụ, (Http.GET, "/path") có vẻ tốt hơn ("GET /path") hiện tại. Lạ thật

    • Đó là cách tuân thủ cam kết tương thích Go 1
    • Với tư cách lập trình viên Go, trong những trường hợp như thế này tôi ước có function overloading để phục vụ tương thích ngược. Cam kết tương thích là rất tuyệt, nhưng những điều kỳ quặc phát sinh từ đó thì không hay lắm
    • Đây là một sai lầm không thể sửa nếu không phá vỡ code hiện có hoặc giới thiệu hàm mới
      Dù vậy, tôi vẫn thích có nhiều hàm như mux.GET, mux.POST hơn cách kia