2 điểm bởi GN⁺ 2024-02-10 | 1 bình luận | Chia sẻ qua WhatsApp
  • Một dịch vụ HTTP Go được duy trì lâu dài sẽ dễ bảo trì và kiểm chứng hơn khi được tổ chức bằng truyền phụ thuộc tường minh, route tập trung tại một chỗ, và hàm run có thể kiểm thử
  • Thay vì biến handler thành method của struct server, hãy tạo chúng dưới dạng hàm trả về http.Handler nhận các giá trị cần thiết qua closure; middleware dùng chung được kết hợp ở bước tạo server và đăng ký route
  • Giữ func main() thật mỏng và tiêm context.Context, tham số, truy cập môi trường, cùng stdin/stdout/stderr vào run() sẽ giúp xử lý thoát và điều khiển kiểm thử trở nên đơn giản
  • Các phần mã hóa/giải mã request/response, kiểm tra hợp lệ, adapter middleware, và khởi tạo trì hoãn bằng sync.Once giúp giảm mã lặp mà vẫn giữ luồng chuẩn net/http của Go
  • Với kiểm thử, nên ưu tiên cách tiếp cận end-to-end gần với lời gọi API thực tế hơn là chỉ kiểm tra từng handler riêng lẻ; mỗi bài test tự khởi chạy server riêng và xác nhận trạng thái sẵn sàng qua /healthz hoặc /readyz

Tạo server và điểm vào của dịch vụ

  • Đặt constructor NewServer là hàm tạo http.Handler cốt lõi của dịch vụ
    • Thường mỗi dịch vụ có một hàm như vậy, còn route bên trong sẽ phân phối request đến từng handler
    • Nhận toàn bộ phụ thuộc như logger, cấu hình, kho dữ liệu, client bên ngoài dưới dạng tham số
    • Nếu có thể thì trả về http.Handler, còn khi phức tạp hơn có thể dùng kiểu riêng
    • Tự cấu hình muxer rồi chuyển vào hàm đăng ký route trong routes.go
  • Mọi xử lý HTTP dùng chung cho tất cả endpoint nên được gom trong NewServer
    • CORS
    • middleware xác thực
    • logging
    • middleware trace ID
  • Dù danh sách phụ thuộc dài, vẫn ưu tiên khai báo tường minh bằng tham số hàm
    • Nếu quên field trong struct, trình biên dịch có thể không chặn được; còn với tham số hàm, thiếu giá trị cần thiết thì không thể gọi hàm
    • Danh sách tham số dài sẽ dễ đọc nếu định dạng theo chiều dọc
    • Với phụ thuộc không dùng trong một bài test cụ thể, có thể truyền nil để thể hiện rằng nó không được sử dụng

Gom bề mặt API vào routes.go

  • Dùng routes.go làm nơi có thể xem toàn bộ route của dịch vụ tại một chỗ
    • Mỗi dự án sẽ có một vị trí duy nhất để lướt nhanh toàn bộ bề mặt API
    • Do NewServer có danh sách phụ thuộc lớn, addRoutes cũng có thể có danh sách tham số tương tự
    • Kiểm tra kiểu của Go sẽ bắt được tham số thiếu hoặc sai thứ tự
  • Giữ addRoutes đơn giản và phẳng nếu có thể
    • Các bước có thể phát sinh lỗi nên được xử lý trước trong hàm run
    • Ở bước đăng ký handler, chỉ tập trung vào routing như mux.Handle, mux.HandleFunc, http.NotFoundHandler
    • Nếu thiết kế yêu cầu chính handler phải trả về lỗi, thì addRoutes cũng có thể trả về lỗi

main chỉ gọi run

  • Giữ func main() là một hàm mỏng: gọi run(), nếu có lỗi thì ghi ra stderr và thoát bất thường
    • run nhận các thành phần cơ bản của hệ điều hành như context.Context, tham số, input/output, và hàm truy cập môi trường
    • run trả về lỗi, có thể xử lý lỗi như mã Go thông thường
  • Ví dụ các giá trị có thể truyền vào run
    • os.Args: dùng cho tham số chạy chương trình và phân tích cờ
    • os.Stdin: đọc đầu vào
    • os.Stdout: ghi đầu ra
    • os.Stderr: ghi log lỗi
    • os.Getenv: đọc biến môi trường
    • os.Getwd: lấy thư mục làm việc hiện tại
  • Thiết lập signal.NotifyContext bên trong run
    • Khi có tín hiệu thoát như Ctrl+C, context sẽ bị hủy
    • Nếu run trả về nil thì chương trình kết thúc bình thường
    • Nếu trả về lỗi, main sẽ in lỗi và thoát với mã khác 0
  • Tránh trạng thái toàn cục giúp nhiều bài test dùng được t.Parallel() hơn
    • Gọi run nhiều lần thì mỗi lần chạy không can thiệp lẫn nhau
    • Cờ được xử lý bằng flags.NewFlagSet trong run thay vì flag toàn cục
    • Biến môi trường được kiểm soát bằng cách tiêm getenv func(string) string thay vì sửa môi trường thật
    • Khác với t.SetEnv, cách này vẫn cho phép tiếp tục dùng test song song

Xử lý thoát và trạng thái sẵn sàng

  • Context cần được truyền xuyên suốt mọi tầng của dịch vụ
    • Khi có tín hiệu dừng, context sẽ bị hủy
    • Tác vụ dài hoặc lặp nên kiểm tra ctx.Err() hoặc ctx.Done() rồi dừng lại
    • Nếu khởi chạy goroutine khác, cũng cần dùng context để quyết định thời điểm dừng
  • Khi tắt HTTP server, gọi Shutdown để dừng một cách graceful
    • Ví dụ minh họa chờ ctx.Done() trong một goroutine riêng
    • Dùng timeout 10 * time.Second cho context tắt server
    • Nếu có lỗi khi shutdown thì ghi ra stderr
  • Để xác nhận server thực sự sẵn sàng trong test, nên có endpoint /healthz hoặc /readyz
    • Có thể tạo tín hiệu sẵn sàng bằng channel riêng, nhưng cách được ưu tiên là xác nhận bằng request HTTP thật
    • Vòng lặp kiểm tra readiness sẽ gọi request cho đến khi nhận 200 OK
    • Nếu context bị hủy hoặc hết thời gian chờ thì trả về lỗi
    • Vòng lặp ví dụ nghỉ 250ms giữa các lần request

Cách tổ chức handler

  • Thay vì hiện thực trực tiếp http.Handler hay http.HandlerFunc, hàm handler nên trả về chúng
    • Ví dụ: func handleSomething(logger *Logger) http.Handler
    • Cách này cho phép tạo môi trường closure riêng cho từng handler
    • Các giá trị đã khởi tạo có thể được dùng trong lúc xử lý request
  • Dữ liệu dùng chung nên chỉ được dùng ở chế độ chỉ đọc để an toàn
    • Nếu handler sửa đổi giá trị, cần cơ chế bảo vệ như mutex
    • Thông thường không khuyến nghị lưu trạng thái chương trình trong closure
  • Trong môi trường cloud, khó có thể giả định instance sẽ tồn tại lâu
    • Server có thể bị hạ xuống để tiết kiệm tài nguyên, hoặc crash vì lý do khác
    • Nhiều instance có thể chạy đồng thời và request có thể được phân phối theo cách khó đoán
    • Trạng thái bền vững của dự án thực tế nên nằm trong database hoặc API lưu trữ riêng

Mã hóa/giải mã request/response và kiểm tra hợp lệ

  • Vì mọi dịch vụ đều cần giải mã body request và mã hóa body response, nên hãy có helper encode / decode
    • Ví dụ sẽ đặt Content-Type là JSON, ghi mã trạng thái rồi gọi json.NewEncoder(w).Encode(v)
    • Phần giải mã bọc json.NewDecoder(r.Body).Decode(&v) và bổ sung ngữ cảnh cho lỗi
    • Dùng generics thì có thể suy luận kiểu như encode(w, r, http.StatusOK, obj)
    • decode là kiểu trả về nên phải chỉ rõ kiểu mong đợi như decode[CreateSomethingRequest](https://grafana.com/blog/2024/02/09/how-i-write-http-services-in-go-after-13-years/r)
  • Việc kiểm tra hợp lệ dùng interface một phương thức
    • Interface Validator có dạng Valid(ctx context.Context) map[string]string
    • Nếu không có vấn đề, trả về map có độ dài 0
    • Với trường có lỗi, key là tên field và value là mô tả để con người đọc được
  • Đối tượng kiểm tra phù hợp với các xác nhận trường nhanh
    • trường bắt buộc có trống hay không
    • chuỗi có đúng định dạng nhất định như email hay không
    • số có nằm trong khoảng cho phép hay không
  • Các kiểm tra phức tạp hơn như truy vấn database nên xử lý ở nơi khác
    • Những kiểm tra như vậy quá quan trọng để bị ẩn trong hàm validation nhanh
    • Phiên bản generic decodeValid[T Validator] ép kiểu T phải hiện thực Validator
    • Gọi len(problems) trên map nil vẫn trả về 0 nên không gây panic

Mẫu adapter middleware

  • Middleware nhận http.Handler và trả về một http.Handler mới
    • Có thể chạy mã trước và sau khi gọi handler gốc
    • Cũng có thể không gọi handler gốc tùy điều kiện
    • Ví dụ adminOnly sẽ trả HTTP 404 Not Found nếu không phải quản trị viên và không gọi handler gốc
  • Vị trí áp middleware thường là routes.go
    • Chỉ cần nhìn danh sách endpoint là biết route nào gắn middleware nào
    • Nếu danh sách middleware dài, có thể tách nhiều dòng cho dễ đọc
  • Middleware có nhiều phụ thuộc nên được bọc trong hàm trả về middleware
    • newMiddleware(logger, db, slackClient, rroll) trả về func(http.Handler) http.Handler
    • Trong mã đăng ký route có thể dùng gọn như middleware(handleSomething(...))
    • Cũng có thể định nghĩa type middleware func(h http.Handler) http.Handler, nhưng viết trực tiếp kiểu trả về sẽ rõ ràng hơn khi đọc mã

Thu hẹp phạm vi kiểu request/response

  • Các kiểu request/response chỉ dùng cho một endpoint cụ thể có thể được định nghĩa bên trong hàm handler
    • Giúp namespace toàn cục gọn hơn
    • Tránh việc handler khác phụ thuộc vào kiểu mà nó không cam kết ổn định
  • Khi mã test cũng cần cùng kiểu đó, có thể phát sinh chút vướng mắc
    • Trong trường hợp này, đưa kiểu ra ngoài cũng là lựa chọn hợp lý
    • Nếu kiểu request/response nằm trong handler, test có thể khai báo struct ẩn danh mới hoặc kiểu cục bộ
  • Kiểu cục bộ trong test giúp thể hiện ý đồ
    • Ví dụ nếu endpoint /greet chỉ cần field Name chứ không cần toàn bộ Person, thì struct input trong test chỉ nên có Name
    • Người đọc test sẽ thấy ngay endpoint đó quan tâm đến field nào

Trì hoãn khởi tạo bằng sync.Once

  • Những công việc tốn kém khi chuẩn bị handler có thể được hoãn tới request đầu tiên bằng sync.Once
    • Giúp giảm thời gian khởi động ứng dụng
    • Nếu handler không bao giờ được gọi thì công việc đắt đỏ đó cũng không chạy
  • Ví dụ minh họa parse file template chỉ một lần ở request đầu tiên
    • sync.Once đảm bảo đoạn mã chỉ chạy đúng một lần
    • Các request đồng thời khác sẽ chờ cho đến khi khởi tạo xong
    • Việc kiểm tra lỗi được thực hiện ngoài init.Do để lỗi tiếp tục được bộc lộ
  • Cách này chuyển thời gian khởi tạo từ lúc start sang thời điểm endpoint được truy cập lần đầu trong runtime
    • Trong môi trường dùng nhiều Google App Engine, cách này có thể phù hợp
    • Tùy môi trường triển khai mà quyết định dùng sync.Once ở đâu và khi nào

Chiến lược kiểm thử

  • Cấu trúc này coi khả năng kiểm thử là một mục tiêu rất quan trọng
    • Hàm run cho phép mã test chạy trực tiếp chương trình
    • Tiêu chí của test là: có dễ hiểu hành vi chương trình không, có giảm lo lắng khi thay đổi không, và sau khi test pass có tăng độ tin cậy khi triển khai production không
  • Vẫn có thể test riêng từng handler
    • Gọi hàm tạo handler và truyền các phụ thuộc cần thiết
    • Dùng httptest.NewRecorderhttp.NewRequest để tạo request/response
    • Kiểm tra mã trạng thái, body phản hồi, và header
    • Cách này bỏ qua middleware như xác thực và đi thẳng vào mã handler
  • Nhưng cách được ưu tiên hơn là gần với kiểm thử end-to-end
    • Gọi run để khởi động chương trình gần giống cách vận hành thực tế
    • Bao gồm phân tích tham số, nối phụ thuộc, migration database, và khởi động server
    • Khi test gọi API, toàn bộ các tầng và cả routes.go đều được kiểm chứng
    • Có thể tương tác với database thật
  • Cách này giúp giảm kiểm thử lặp lại
    • Nếu test riêng từng tầng, có thể xác nhận cùng một nội dung nhiều lần theo những cách hơi khác nhau
    • Kiểm thử end-to-end cung cấp bộ test trung tâm mô tả tương tác giữa người dùng và hệ thống
    • Các unit test đã có sẵn từ TDD hay lý do khác vẫn có thể giữ lại nếu còn phù hợp, nhưng nếu lặp lại đúng nội dung với end-to-end test thì có thể xóa
  • Mỗi bài test có thể chạy instance chương trình riêng
    • Mỗi test truyền tham số, cờ, stdin/stdout/stderr, và biến môi trường khác nhau
    • Tạo hàm hủy bằng context.WithCancel rồi đăng ký vào t.Cleanup(cancel)
    • Khi test kết thúc, context bị hủy và chương trình sẽ shutdown graceful
    • t.Cleanup của Go 1.14 được dùng như lựa chọn thay cho việc tự viết defer

Phạm vi áp dụng thực tế và bối cảnh tổ chức

  • Khi xây dựng API đơn giản, mẫu này hướng đến mã dễ đọc và dễ mở rộng
    • Dễ sao chép mẫu rồi mở rộng
    • Người mới dễ tham gia
    • Giảm cảm giác bất an khi thay đổi
    • Mọi thứ được cấu hình tường minh, không có hành vi kiểu ma thuật
  • Ngay cả khi dùng công cụ sinh mã, cách này vẫn có thể giữ nguyên
    • Ví dụ có thể dùng Oto package để sinh boilerplate dựa trên template
  • Trong dự án lớn hoặc tổ chức lớn, các lựa chọn kỹ thuật sẵn có có thể làm thay đổi quyết định
    • Ở tổ chức như Grafana Labs, một số công cụ và lớp trừu tượng cụ thể có thể đã được dùng rộng rãi
    • gRPC là một ví dụ như vậy
    • Khi đã có pattern và kinh nghiệm được thiết lập, việc đi theo luồng đó là lựa chọn thực dụng
  • Bài viết cũng đề cập đến bối cảnh của bộ sản phẩm Grafana IRM
    • Grafana IRM là bộ sản phẩm mà Grafana Labs đang xây dựng
    • Grafana Alerting gửi cảnh báo khi metric vượt ra ngoài ngưỡng cho phép
    • Grafana OnCall tự động hóa việc liên hệ đúng người bằng lịch trực và quy tắc escalation
    • Grafana Incident tạo phòng Zoom, kênh Slack riêng, dòng thời gian sự kiện và hỗ trợ ứng phó sự cố
    • Mục nào trong kênh Slack được thêm phản ứng emoji mặt robot sẽ được đưa vào dòng thời gian

1 bình luận

 
GN⁺ 2024-02-10
Ý kiến trên Hacker News
  • Tôi cũng từng thử cách tách riêng trình xác thực như phương thức Valid, nhưng sau khi đọc “Parse, Don’t Validate” [0] của Lexi Lambda, tôi cảm thấy tận dụng trình kiểm tra kiểu của Go giúp giảm lỗi đi rất nhiều
    Ví dụ, nếu muốn người dùng tuyệt đối không thể đặt tên người dùng không hợp lệ có chứa dấu ngoặc nhọn góc, thì với cách dùng trình xác thực, bạn phải gọi trình xác thực trên mọi đường đi của mã nơi tên người dùng đến từ đầu vào không đáng tin cậy
    Thay vào đó, nếu có kiểu Username và hàm khởi tạo NewUsername(username string) (Username, error), thì chỉ riêng việc một đối tượng Username tồn tại đã đảm bảo rằng nó đã vượt qua bước xác thực
    [0] https://lexi-lambda.github.io/blog/2019/11/05/parse-don-t-va...

    • Thật đáng ngạc nhiên khi việc dùng hệ thống kiểu đúng cách lại có thể cải thiện mã đến vậy. Đừng truyền mọi thứ dưới dạng string; hãy phân tích và gắn kiểu cho chúng
    • Đây là một mẫu thiết kế tốt, nhưng cần cẩn thận với xác thực quá sớm
      Mẫu này cho phép bạn xác thực sớm hoặc muộn tùy ý, nhưng không nói cho bạn biết nên làm vào lúc nào. Thông thường, nhiều khi tốt nhất là thực hiện nó như một phần của quá trình phân tích/xác thực một đối tượng lớn hơn
      Trong ngữ cảnh UI, ý tưởng xử lý dữ liệu chưa được xác thực có thể tham khảo “I is for Intent” [1] của Steven Witten
      [1] https://acko.net/blog/i-is-for-intent/
    • Về mặt khái niệm, điều này giống với kỹ thuật constructor privatefactory method từ lâu rồi
    • Các liên kết liên quan: Parse, don't validate (2019) - https://news.ycombinator.com/item?id=35053118 - tháng 3 năm 2023, Parse, Don't Validate (2019) - https://news.ycombinator.com/item?id=27639890 - tháng 6 năm 2021, Parse, Don’t Validate - https://news.ycombinator.com/item?id=21476261 - tháng 11 năm 2019, Parse, Don't Validate - https://news.ycombinator.com/item?id=21471753 - tháng 11 năm 2019
    • Trong Go, việc áp dụng mẫu này khá khó. Nếu Username được nhúng trong một struct và bạn quên đặt giá trị, thì zero value có thể vi phạm ràng buộc sẽ được đưa vào
  • Một trong những mẫu tôi ghét nhất là nhận một đối tượng Config biểu diễn toàn bộ cấu hình hệ thống rồi chuyền nó khắp nơi như một giá trị có thể thay đổi
    Làm vậy khiến mọi thứ bị ghép chặt với nhau thông qua đối tượng cấu hình. Trong một số hệ thống, có người còn ghi ngược giá trị vào đối tượng cấu hình đã được truyền vào, khiến các phần phải được cấu hình theo một thứ tự cụ thể thì hệ thống mới hoạt động bình thường
    Trong trường hợp khác, có subsystem ghi dữ liệu vào đối tượng cấu hình để subsystem khác đọc về sau, khiến không thể vô hiệu hóa một phần của hệ thống
    Mẫu “cấu hình là một giá trị mutable khổng lồ” là một mẫu khá phiền toái không chỉ trong Go mà cả ở các ngôn ngữ khác

    • Điểm mấu chốt không phải là đối tượng dữ liệu cấu hình nói chung mà là đối tượng cấu hình mutable
      Trong các dự án Python, tôi thường dùng dataclass cấu hình bất biến và truyền nó qua nhiều module; khi nhiều hàm phụ thuộc vào nhiều giá trị, thay vì truyền từng giá trị làm tham số hàm và định nghĩa kiểu riêng cho từng cái, việc có tất cả biến và định nghĩa kiểu trong một dataclass khá là một mẫu thiết kế tiện lợi
    • Cách tôi thích nhất để ngăn điều này là làm cho cấu hình thực sự bất biến, nhưng vẫn có thể cấu thành bằng các hàm Option
      options nội bộ chỉ được thay đổi trong lúc khởi tạo, và bên ngoài chỉ lộ ra Config cùng các accessor. Ví dụ có thể tạo bằng config.New(config.Name("Emanon")) và đọc bằng cfg.Name()
    • Tôi thường tạo struct Config cho từng package, còn configs.Config sẽ là dạng tập hợp các Config của từng package
      Có thể đây không phải thực hành tốt nhất trong Go, nhưng lúc khởi động bạn vẫn có thể tạo cấu hình toàn hệ thống như một thực thể duy nhất, trong khi với từng package chỉ cần truyền vào lượng phụ thuộc tối thiểu cần thiết
      Khi test cũng dễ hơn một chút vì không cần giả lập toàn bộ cấu hình chỉ để kiểm thử một package
    • Đồng ý. Trước đây tôi từng sửa đối tượng cấu hình cấp cao nhất và gây ra sự cố nghiêm trọng
      Tuyệt đối không nên sửa nó. Bạn không thể biết nó được dùng ở đâu và như thế nào, nên cái giá nhân công bị lãng phí là quá đắt; nếu cần thay đổi, tốt hơn là tạo ra một giá trị dẫn xuất từ bản gốc
      Điều buồn cười là về mặt thiết kế, đối tượng cấu hình đó phần nào đã là bất biến, và để sửa nó bạn còn phải dùng API WARNING_DO_NOT_USE, vậy mà người ta vẫn dùng chính nó để sửa đối tượng rồi gây sự cố
    • Tôi thấy đây là một phê bình hợp lý. Không biết theo bạn thì mẫu nào sẽ dễ dùng hơn
  • Tôi thực sự rất thích công việc của Mat Ryer, và đã áp dụng hầu hết các ý tưởng trong phiên bản năm 2018 của bài viết này vào mọi dự án Go kể từ đó
    Tuy vậy, tôi luôn thấy lấn cấn ở chỗ NewServer là một constructor lớn nhận mọi dependency làm tham số, còn trong test thì truyền nil cho những dependency không cần dùng như một tín hiệu rằng chúng không được sử dụng
    Kết quả là phần lớn mã có quá nhiều trạng thái dùng chung không cần thiết. Thực tế có nhiều HTTP handler chỉ cần kiểm tra xem người dùng gửi yêu cầu có quyền truy cập tài nguyên hay không rồi gọi đúng một hàm trong kho dữ liệu, nhưng cuối cùng lại trở thành một phần của một khối khổng lồ có thể truy cập toàn bộ object mà server cha nắm giữ và cả toàn bộ kho dữ liệu
    Ngay cả khi chỉ muốn mock hai method để test, cũng khó viết một bài test đơn giản; mẫu của Mat Ryer là tốt nhất tôi từng thấy cho đến nay, nhưng vẫn còn cảm giác chắc hẳn phải có cách tốt hơn

    • Tôi ngày càng nhạy cảm hơn với những điểm có độ kết hợp hướng vào cao như vậy trong các repository mình làm việc, và đặc biệt là khi đi sâu hơn vào thế giới Bazel thì tác động của việc quản lý dependency và thiết kế vật lý lên mã mình viết càng lớn
      Nếu có thể, plugin là một chiến lược ranh giới mã tốt. Kiến trúc plugin về cơ bản là opt-out, và chỉ lộ diện khi được chọn một cách tường minh, nên nó không ép mọi khả năng vào một khối mã nào đó
      Tôi gọi kiểu phần mềm có tính chất này là “gọi món riêng”. Nói chung, nên tránh tình huống “làm mọi thứ để có thể làm bất cứ thứ gì” càng nhiều càng tốt
    • Tôi có xu hướng viết phần lớn logic dưới dạng package. Ví dụ, nếu làm HN thì sẽ có package users hoặc package comments
      Các package này hoàn toàn không có giao diện HTTP, nhưng mỗi package đều có main riêng và một dạng giao diện CLI. Chú thích file //go:build ignore rất hữu ích cho việc này
    • Tôi chỉ dùng closure
      Thay vì định nghĩa handler kiểu func HandleX(w http.ResponseWriter, req *http.Request), tôi để nó nhận các dependency cần thiết theo dạng func HandleX(store *DataStore, dep1 Foo, dep2 Bar, commonDep Common) http.HandlerFunc rồi trả về http.HandlerFunc thực sự ở bên trong
      Và khởi tạo một lần ở điểm vào
    • Điều đó có nghĩa là object do NewServer tạo ra đang làm quá nhiều việc. Rất có thể có quá nhiều kiểu dữ liệu và hành vi bị gắn chặt với nhau
      Một ví dụ đơn giản là nếu thêm logger vào dependency của constructor, object đó sẽ làm nhiều việc hơn một chút so với triển khai đơn giản ban đầu. Bản thân điều đó không sao, nhưng sẽ đáng tiếc nếu không tìm ra cách thêm logging mà không phải sửa triển khai của phần đơn giản
      Các hàm bậc cao, ví dụ logger decorator, cho phép composition, dù chúng cũng có nhược điểm. Dù sao đó vẫn là một dạng cấu trúc có thể xử lý được, chứ không phải sai lầm
    • Tôi đã có cảm giác đó từ lâu, và giờ đã chuyển sang cách tiếp cận dùng struct cấu hình tùy chọn
      Cốt lõi là trong NewServer, ta kiểm tra các giá trị của struct cấu hình tùy chọn rồi sao chép chúng sang struct server. Nhờ vậy có thể chỉ mock ít dependency hơn, nên việc test dễ hơn rất nhiều
      Tôi cũng đã thử mẫu functional options rất nhiều như nhiều người khuyên, nhưng cuối cùng bỏ cuộc. Nó hơi khôn quá mức và khó đọc, và tôi thấy còn nhiều boilerplate hơn so với mẫu struct cấu hình + kiểm tra rồi sao chép
      [0] https://news.ycombinator.com/item?id=39320170
  • Tôi mong ý tưởng này được chấp nhận rộng rãi hơn trong HTTP service ở bất kỳ ngôn ngữ nào: nếu handler cần dependency thì nó phải yêu cầu trực tiếp bằng tham số, chứ không nên để dưới dạng method treo trên struct server khiến khi test xuất hiện các dependency bất ngờ
    Các handler trong HTTP service thường chứa khá nhiều business logic, và phần logic đó rất có thể có nhiều dependency. Thực tế tôi thường thấy một handler đơn lẻ dùng DB, cache, blob store, kiểm tra phân quyền đặc thù cho endpoint, bộ kiểm tra giấy phép, queue, logger chuyên biệt, client đo lường, v.v.
    Số tham số có thể lên tới hơn 9, và linter hay các kinh nghiệm thực hành thường cố ngăn điều đó, nhưng dependency đâu có biến mất; chúng chỉ bị giấu vào class/struct server, rồi dùng method signature ngắn để đánh lừa rằng dependency ít hơn thôi
    Càng về sau tôi càng thấy mã mà mọi dependency đều lộ ra trong function/method signature, kể cả có tới 20 cái, vẫn tốt hơn. Như vậy sẽ không che giấu việc độ phức tạp của mã đang tăng lên

    • Tôi luôn để handler là một struct riêng biệt có method xử lý route/request
      Ví dụ, trong struct CreateUser chỉ đặt những dependency cần cho tác vụ đó như store, cache, logger, pub, rồi triển khai ServeHTTP
      Trong main.go hoặc nơi thiết lập dependency, tôi tạo từng tác vụ và chỉ truyền vào các dependency cần thiết. Làm vậy rất hay vì có thể giữ các helper method của tác vụ/handler cụ thể đó làm private method của chính struct ấy
      Tuy nhiên, nếu một tác vụ cần dùng tác vụ khác thì sẽ bắt đầu phải chuyền qua lại giữa chúng, hoặc phải tách ra package/service riêng, nên có thể trở nên phiền phức
    • Không nhất thiết phải là hơn 9 tham số riêng biệt. Ở một số ngôn ngữ, có thể dùng một object context/env duy nhất chỉ chứa những gì handler cần
      Ví dụ viết như handleHello({ db, cache, blobStore, authz }, req, res), thì nếu hai handler dùng đúng cùng một context, có thể tái sử dụng, và cũng dễ khai báo context riêng cho từng handler tại chỗ gọi
  • Tôi đồng ý với nhiều phần của bài này, và muốn bổ sung thêm vài ý
    Nếu truyền WaitGroup vào struct service cùng với app context, thì tín hiệu interrupt có thể kích hoạt việc tắt ứng dụng qua context và goroutine chính có thể đợi WaitGroup trước khi thoát hẳn
    Với chương trình CLI, việc test stdout, stdin, stderr, args, env, v.v. là hữu ích, nhưng với HTTP server thì tôi nghĩ ít cần hơn. Tôi sẽ truyền cấu hình có cấu trúc vào hàm run để bài test tập trung hơn
    Tôi không đồng ý với cách parse template bằng sync.Once trong handler. Theo tôi, handler không nên parse template; việc đó phải diễn ra khi ứng dụng khởi động. Nếu không thể parse template thì ứng dụng không nên được coi là sẵn sàng nhận request và phải kết thúc với mã thoát khác 0

    • Điểm đầu tiên khá thú vị. Tôi tự hỏi liệu đây chẳng phải là vấn đề được giải quyết bằng truyền context và chờ server shutdown hay sao
  • Gần đây đang thử nghịch ogen: https://github.com/ogen-go/ogen
    Chỉ cần viết định nghĩa OpenAPI là nó sẽ lo routing, định nghĩa struct, kiểm chứng schema JSON, v.v. Việc của tôi chỉ là triển khai service
    Mấy thứ như kiểm tra phạm vi số nguyên trong query string quá nhàm chán, mà tự viết thì cũng rất dễ gõ nhầm
    Hiện vẫn chỉ đang ở giai đoạn thử nghiệm nên chưa phát hiện điểm dở nào

    • Vấn đề của cách tiếp cận này là quá trình tự tay viết OpenAPI ngay từ đầu cực kỳ tẻ nhạt
      Viết các IDL tương tự như Protobuf hay capnproto cho cảm giác năng suất hơn nhiều
    • Nếu thích hướng xuất đặc tả OpenAPI từ mã Go thì danielgtaylor/huma[1] và swaggest/rest[2] cũng ổn
      [1] https://github.com/danielgtaylor/huma
      [2] https://github.com/swaggest/rest
    • Tương tự, tôi đã bắt đầu với oapigen: github.com/deepmap/oapi-codegen
      Tôi tưởng việc viết đặc tả sẽ rất chán, nhưng hóa ra ổn hơn nhiều so với dự đoán, mà đằng nào cũng cần đặc tả nên tôi thấy viết trước vẫn tốt hơn
  • Tôi thấy fx(https://github.com/uber-go/fx) là một công cụ rất đơn giản nhưng cũng rất đa dụng để thiết kế ứng dụng
    Lời khuyên trong bài vẫn hữu ích, nhưng nó loại bỏ hoàn toàn phần “làm sao để đảm bảo X đã được khởi tạo khi Y cần đến”. Bài toán N*M giảm còn N, nên chỉ cần quan tâm cách khởi tạo từng mảnh, không cần lo đồng bộ hóa khởi tạo giữa các mảnh
    Tôi đã dùng khá nhiều thư viện dependency injection ở nhiều ngôn ngữ, cũng từng tự triển khai, nhưng đến giờ tôi vẫn thích sự đơn giản và tính tổng quát của fx nhất

    • Tôi thực sự ghét kiểu framework dependency injection này
      Trong một hệ thống được thiết kế tốt, chuyện này đáng ra phải rất nhỏ nhặt. Việc đảm bảo thứ gì đó đã được khởi tạo khi muốn dùng chỉ là chuyện nó đã sẵn sàng để truyền vào như tham số constructor hay chưa
      Có thể làm kiểu stockService := NewStockService(), orderService := NewOrderService(), orderProcessor := NewOrderProcessor(stockService, orderService)
      Đáng ra không cần cái gọi là “đồng bộ hóa” khởi tạo, và nếu làm sai thì sẽ không biên dịch được. Kể cả có thêm phụ thuộc vòng tròn thì cũng sẽ lộ rõ vì không thể lắp ghép theo đúng thứ tự
  • Đây là một bài viết rất hay với nhiều ý tưởng thú vị. Tôi không thể tin là mình lại không biết đến signal.NotifyContext
    Giờ thì tôi có thể nhớ cách xử lý signal mà không phải copy-paste cho từng dự án nữa

  • Tôi rất thích một số cách làm ở đây, nhưng cách test của tôi hơi khác một chút
    Trong newTestServer(), tôi khởi chạy một server có các dependency được thay bằng bản giả, và nếu muốn test lỗi dependency thì tôi đổi thuộc tính tương ứng sang một bản giả trả về lỗi
    Nhờ vậy có thể kiểm chứng cả đường đi lỗi, mục log, phát hành metric, timeout, lẫn graceful shutdown
    Sau khi server khởi động, tôi sẽ kiểm tra nó đã bind vào cổng nào. Vì giá trị mặc định là :0 nên phải chờ cổng thực sự được cấp phát
    Test “unit” có thể làm ở mức handler hoặc mức HTTP, và có thể kiểm thử mã theo đúng cách người dùng sẽ thấy, hoặc là cho đi qua toàn bộ middleware, hoặc là không đi qua middleware nào cả. Cũng có thể chạy song song bằng cách dựng N instance

  • Tôi không dùng Go, nhưng tôi thích những pattern kiểu này. Có cảm giác chúng áp dụng khá phổ quát cho mã dễ kiểm thử
    Tôi không muốn đọc lại mấy hướng dẫn khởi động nhanh, đặc biệt là của Python, nơi dependency bị xử lý theo kiểu ngầm định/tĩnh/không thể kiểm thử