Trong bài này
Mình viết backend NowCheckin bằng NestJS, rồi vào Vinova và bắt đầu viết Go cho một nền tảng microservices. Đây là ghi chép sau hơn hai tháng, về thứ làm mình khó chịu, thứ mình đã quen và thứ mình vẫn nhớ ở NestJS.
Chào anh em.
Mình viết backend của NowCheckin bằng NestJS. Tháng 8 vừa rồi mình vào Vinova, được giao làm một nền tảng microservices, và ngôn ngữ ở đó là Go. Tính tới hôm nay là hơn hai tháng.
Nói trước cho khỏi cãi nhau: bài này không phải benchmark, và mình cũng không kết luận cái nào hơn cái nào. Hai tháng chưa đủ để mình dạy ai viết Go. Nó chỉ đủ để mình còn nhớ rất rõ cảm giác của một người mới chuyển nhà, và mình muốn ghi lại trước khi quen quá rồi quên mất.
Các ví dụ code trong bài là mình tự viết cho dễ hình dung, không phải code của dự án.
Tuần đầu, mình đi tìm những thứ không tồn tại
Ngày đầu tiên mở project Go, phản xạ của mình là đi tìm decorator. Không có. Rồi mình tìm cái container để inject service. Cũng không có. Tới lúc cần xử lý lỗi thì mình gõ try và ngồi nhìn editor gạch đỏ.
Lúc đó mình nghĩ Go thiếu thốn thật. Phải tới khoảng tuần thứ ba mình mới hiểu: nó không thiếu, nó cố tình không có. NestJS giấu giùm mình những thứ nhàm chán để mình viết cho nhanh. Go thì bắt mình viết hết ra, để sau này ai đọc cũng thấy.
Nghe thì giống một câu khẩu hiệu. Nhìn code sẽ rõ hơn.
Cùng một endpoint, viết hai kiểu
Lấy một hợp đồng theo id, không thấy thì trả 404.
NestJS
@Controller('policies')
export class PolicyController {
constructor(private readonly policies: PolicyService) {}
@Get(':id')
findOne(@Param('id') id: string) {
return this.policies.findOne(id);
}
}Go
func (h *PolicyHandler) FindOne(w http.ResponseWriter, r *http.Request) {
policy, err := h.policies.FindOne(r.Context(), r.PathValue("id"))
if errors.Is(err, ErrNotFound) {
http.Error(w, "policy not found", http.StatusNotFound)
return
}
if err != nil {
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
json.NewEncoder(w).Encode(policy)
}Bản NestJS ngắn bằng một nửa, và hồi viết NowCheckin mình rất thích điều đó. Nhưng thử hỏi một câu: nếu không tìm thấy hợp đồng thì client nhận về cái gì?
Với bản NestJS, mình không trả lời được nếu chỉ nhìn đoạn trên. Mình phải mở PolicyService ra xem nó có throw new NotFoundException() không. Nếu service ném một lỗi lạ nào đó mà không ai bắt, filter mặc định của NestJS sẽ trả về đúng thế này:
{
"statusCode": 500,
"message": "Internal server error"
}
Còn bản Go thì dài, nhìn hơi mệt, nhưng mọi đường đi của request nằm ngay trước mắt. Không tìm thấy thì 404. Lỗi khác thì 500. Hết.
Tiện nói luôn: cái r.PathValue("id") kia là hàng mới. Từ Go 1.22, router có sẵn trong thư viện chuẩn đã hiểu được method và tham số trên đường dẫn, nên nhiều service nhỏ không cần cài thêm framework nào.
if err != nil, lần thứ một trăm
Đây là thứ làm mình khó chịu nhất trong tháng đầu, và giờ lại là thứ mình thấy yên tâm nhất.
Trong NestJS, lỗi là exception. Nó bay lên qua từng tầng cho tới khi gặp thứ gì đó bắt nó. Tiện, nhưng cũng có nghĩa là khi đọc một hàm, mình không biết nó có thể ném ra cái gì.
Trong Go, lỗi là một giá trị bình thường mà hàm trả về. Ai gọi thì người đó phải quyết định ngay tại chỗ: xử lý, hay bọc thêm ngữ cảnh rồi trả tiếp lên trên.
policy, err := s.repo.FindByID(ctx, id)
if err != nil {
return nil, fmt.Errorf("find policy %s: %w", id, err)
}
Chữ %w giữ lại lỗi gốc bên trong, nên ở tầng trên mình vẫn dùng errors.Is để hỏi "đây có phải lỗi không tìm thấy không". Kết quả là khi có sự cố, dòng log đọc như một câu chuyện có đầu có đuôi, kiểu find policy 123: query: connection refused, thay vì một stack trace dài ba màn hình.
Mình không nói là viết if err != nil vui. Viết lần thứ một trăm vẫn chán y như lần đầu. Nhưng từ lúc chuyển sang, mình chưa gặp lại kiểu lỗi "server trả 500 mà không ai biết nó chui ra từ đâu".
Không có container thì ai nối dây?
Câu trả lời là hàm main.
func main() {
db := mustOpenDB(cfg.DatabaseURL)
policyRepo := postgres.NewPolicyRepo(db)
policyService := policy.NewService(policyRepo)
policyHandler := httpapi.NewPolicyHandler(policyService)
mux := http.NewServeMux()
mux.HandleFunc("GET /policies/{id}", policyHandler.FindOne)
log.Fatal(http.ListenAndServe(":8080", mux))
}
Lần đầu thấy cái này mình hơi sốc, vì nó giống hệt việc NestJS vẫn âm thầm làm giùm, chỉ khác là giờ mình tự gõ. Service nào cần cái gì thì truyền thẳng vào hàm khởi tạo.
Cái dở là project lớn lên thì main dài ra. Cái hay là khi muốn biết một thứ được tạo ở đâu, mình bấm "go to definition" là tới, không phải lần theo module nào import module nào, provider nào được export.
Goroutine, thứ mình tưởng đã hiểu
Gọi hai việc cùng lúc rồi chờ cả hai:
NestJS
const [customer, claims] = await Promise.all([
this.customers.find(id),
this.claims.listFor(id)
]);Go
g, ctx := errgroup.WithContext(ctx)
g.Go(func() (err error) {
customer, err = s.customers.Find(ctx, id)
return
})
g.Go(func() (err error) {
claims, err = s.claims.ListFor(ctx, id)
return
})
if err := g.Wait(); err != nil {
return nil, err
}Về độ gọn thì NestJS thắng, không cần bàn.
Nhưng có hai khác biệt mà phải dùng một thời gian mình mới thấm. Một là code JavaScript của mình chạy trên một luồng duy nhất. Promise.all giúp mình chờ nhiều việc I/O cùng lúc, chứ không làm hai phép tính nặng chạy song song được. Goroutine thì chạy thật sự song song trên nhiều nhân.
Hai là chuyện hủy. Với Promise.all, nếu việc thứ nhất lỗi thì việc thứ hai vẫn chạy tới cùng, chỉ là kết quả bị bỏ. Với errgroup, việc thứ nhất lỗi thì ctx bị hủy, và việc thứ hai biết đường dừng lại.
Bù lại, Go cho mình một loại bug mà trước giờ mình ít khi phải nghĩ tới: hai goroutine cùng ghi vào một biến. Mình đã dính một lần và mất cả buổi chiều. Bài học là cứ chạy test với cờ -race.
Những thứ mình nhớ ở NestJS
Nói cho công bằng, có mấy thứ mình nhớ thật.
- ValidationPipe và DTO. Khai báo một class, gắn vài decorator, thế là request sai định dạng bị chặn ngay từ cửa. Bên Go mình phải tự ghép thư viện và tự viết nhiều hơn.
- Swagger gần như tự có. Bên Go thì hoặc viết spec trước rồi sinh code, hoặc viết comment theo đúng cú pháp.
- Guard, interceptor, pipe. Có chỗ rõ ràng cho từng loại việc. Bên Go tất cả đều là middleware, tự mình đặt quy ước.
- CLI sinh sẵn module. Gõ một lệnh là có khung, khỏi nghĩ nên đặt file ở đâu.
Những thứ mình không muốn trả lại
- Build ra một file chạy duy nhất. Image nhỏ, khởi động gần như tức thì. Chạy trên Kubernetes thì cái này đáng tiền.
- Build và test rất nhanh. Nhanh tới mức mình bỏ luôn thói quen đứng dậy pha cà phê trong lúc chờ.
gofmt. Cả team không còn ai tranh luận về dấu chấm phẩy hay xuống dòng.- Đọc code người khác. Chỉ cần biết ngôn ngữ, không cần biết thêm một framework. Tuần đầu mình đã đọc hiểu được service của người khác, điều mà một người mới vào project NestJS khó làm được.
Tóm lại thành một bảng
| NestJS | Go | |
|---|---|---|
| Dựng một service mới | Rất nhanh, có sẵn khung | Chậm hơn, tự nối dây |
| Đọc code người khác | Phải biết framework | Chỉ cần biết ngôn ngữ |
| Lỗi | Exception, bắt ở filter | Giá trị trả về, xử lý tại chỗ |
| Validate và tài liệu API | Gần như có sẵn | Tự ghép |
| Việc nặng CPU | Chặn event loop | Goroutine chạy song song |
| Khởi động, bộ nhớ, image | Nặng hơn | Rất nhẹ |
| Bug đặc sản | undefined ở chỗ không ngờ | Con trỏ nil, data race |
Còn tốc độ thì sao?
Chắc sẽ có anh em hỏi. Mình không tự đo, nên mình không dám phán. Mình có đọc một bài đo NestJS với Prisma và Go với GORM trên cùng một bảng PostgreSQL: ở tải vừa phải, chênh lệch chỉ khoảng 10%. Tức là với một API CRUD bình thường, phần lớn thời gian nằm ở database chứ không nằm ở ngôn ngữ.
Theo mình, nếu lý do duy nhất để chuyển sang Go là "cho nhanh" thì chưa đủ.
Nếu làm lại NowCheckin, mình có chọn Go không?
Không. Mình vẫn chọn NestJS.
NowCheckin là kiểu sản phẩm mới, cần ra mắt sớm, rất nhiều CRUD và tích hợp với bên thứ ba. Ở đó, thứ quý nhất là tốc độ dựng tính năng, và NestJS cho mình đúng thứ đó.
Còn chỗ mình đang làm bây giờ thì khác: nhiều service nhỏ, nhiều người cùng đọc và sửa code của nhau, chạy trên Kubernetes. Ở đó Go hợp hơn hẳn. Nó chậm hơn ở ngày đầu tiên và nhanh hơn ở ngày thứ một trăm.
Mình mới ở ngày thứ sáu mươi mấy, nên nếu có chỗ nào mình hiểu chưa tới, anh em góp ý giúp mình nhé. Cảm ơn vì đã đọc.