- vừa được xem lúc

Dựng Golang microservice boilerplate theo clean architecture

0 0 30

Người đăng: Kiên Đinh

Theo Viblo Asia

Chuyện về việc học

Như các bạn đã biết, mình trước giờ chủ yếu code đột NÉT, nhưng lại có thêm vài mối tình ngoài em nó, chẳng hạn PHP, Nodejs... Rồi tình cờ mình nghe tới Golang. Xuất thế vào Obama đệ nhất nguyên niên (2009), là con út của gia tộc Google. Golang sở hữu nhiều thiên tư kinh hãi thế tục. Chẳng hạn như performance nhanh đến thần sầu, memory footprint chấp Nodejs 3 lần, quản lý bất đồng bộ xuất sắc với Goroutine, bên cạnh đó Go cũng rất dễ học và rất dễ tiếp cận.

Một ngày đẹp trời nọ mình học xong 1 course cơ bản về Go trên udemy, rồi cũng nghiên cứu cách để áp dụng vào thực tế, dựng luôn 1 app sử dụng em nó, tuy nhiên mình vẫn luôn đau đầu với việc suy nghĩ nên áp dụng cấu trúc nào để dễ bề code, maintain, tránh việc bị kẻ maintain chửi như làng Vũ Đại. Dĩ nhiên mình không thể bê tư duy MVC cũng như nhiều thứ từ dotNET qua Go được.

Mình không nghĩ sẽ áp dụng cấu trúc MVC như trong bài viết Tạo REST API với Nodejs và Typescript, vì Golang thường thường sẽ có thể phải áp dụng cả gRPC để giao tiếp giữa các service với nhau, rồi REST để giao tiếp với client, nếu áp gRPC song song REST, vậy mình sẽ sắp xếp cấu trúc Controller như thế nào ? Để business logic ở tầng nào mới được ?

Clean Architecture

Gần đây mình có bật trạng thái open to work trên Linkedin, và mình cũng có mong muốn là công việc mới sẽ được dùng Go. Thế là thầy của mình có comment rằng nếu làm Go thì em nghiên cứu clean architecture nhé, nó sinh ra là dành cho Go. Okay, thế mình bắt tay vào nghiên cứu clean architect luôn mà tạm thời gác lại những mặt hàng Hexagonal Architecture, Onion Architecture...

Sơ bộ về Clean architecture

Clean architecture là 1 triết lý (philosophy) trong thiết kế phần mềm được Robert C. Martin phát minh và đăng tải trên blog của ổng, blog Uncle Bob. Ông có viết luôn 1 cuốn sách về Clean Architecture, tuy nhiên trong bài viết này chỉ giới hạn những thứ mình tìm hiểu được trong thời gian vừa qua, còn cả 1 bầu trời kiến thức về em nó nữa mà cần chúng ta tìm hiểu tiếp.

Clean architecture được phát triển từ clean code, mà clean code lại follow SOLID principles, chắc hẳn các bạn đã nghe qua:

  • Single responsibility principle
  • Open/closed principle
  • Liskov substitution principle
  • Interface segregation principle
  • Dependency inversion principle

Clean architect:

Dựa vào hình trên chúng ta có thể thấy mô hình này được chia làm 4 layer chính (không bắt buộc chỉ có 4):

  • Entities layer
  • Use cases layer
  • Interface Adapters layer
  • Frameworks & Drivers layers

Theo thứ tự từ trong ra ngoài, tầng càng ở phía trong thì càng ít thay đổi và không bị phụ thuộc vào layer phía ngoài và ngược lại. Các layer bên ngoài dễ sửa đổi hơn và sẽ phụ thuộc vào các layer bên trong.

Triển khai clean architect với Golang

Bắt đầu triển khai 1 microsevice dùng để tạo các Todo item cho người dùng, hỗ trợ xác thực bằng JWT token.

Sử dụng các công nghệ sau:

  • Echo framework
  • Postgresql + GORM
  • Testify cho unit testing
  • Logrus để log, UUID...
  • Docker để build và support integration tests

Project structure:

Ở đây các tầng như Entities, Usecases, Interface Adapter đều phụ thuộc vào domain chính là Entities, nên mình chia ra thành các module theo Entity là Auth và Todos, nằm trong folder internal.

Thêm vào đó, những component như Usecase, Handler, Repository không tạo mỗi struct, mà nên implement theo Interface (D trong SOLID).

Entities Layer

internal/auth/models/user.go:

package models import "golang.org/x/crypto/bcrypt" type User struct { Id string `gorm:"primary_key"` Username string Password string Limit int
} func (u *User) HashPassword() error { hashedPassword, err := bcrypt.GenerateFromPassword([]byte(u.Password), bcrypt.DefaultCost) if err != nil { return err } u.Password = string(hashedPassword) return nil
} func (u *User) ComparePassword(password string) bool { if err := bcrypt.CompareHashAndPassword([]byte(u.Password), []byte(password)); err != nil { return false } return true
} 

internal/todos/models/todo.go:

package models import "time" type Todo struct { Id string `gorm:"primary_key"` Content string CreatedAt time.Time CreatedBy string User User `gorm:"foreignKey:CreatedBy"`
} 

Tầng entity chứa các enterprise logic nhất và ít thay đổi nhất, chẳng hạn như logic mã hoá password trong model user.

Usecases layer

auth/usecase.go

package auth import ( "context" "kienmatu/go-todos/internal/models"
) type UseCase interface { SignUp(ctx context.Context, username, password string, limit int) (*models.User, error) SignIn(ctx context.Context, username, password string) (string, error) ParseToken(ctx context.Context, accessToken string) (string, error)
} 

Implement trong internal/auth/usecase/usecase.go:

package usecase import ( "context" "fmt" "strings" "time" "kienmatu/go-todos/internal/auth" "kienmatu/go-todos/internal/models" "github.com/golang-jwt/jwt" "github.com/google/uuid"
) type AuthClaims struct { jwt.StandardClaims Username string `json:"username"` UserId string `json:"userId"`
} type authUseCase struct { userRepo auth.UserRepository hashSalt string signingKey []byte expireDuration time.Duration
} func NewAuthUseCase( userRepo auth.UserRepository, hashSalt string, signingKey []byte, tokenTTL int64) auth.UseCase { return &authUseCase{ userRepo: userRepo, hashSalt: hashSalt, signingKey: signingKey, expireDuration: time.Second * time.Duration(tokenTTL), }
} func (a *authUseCase) SignUp(ctx context.Context, username, password string, limit int) (*models.User, error) { fmtusername := strings.ToLower(username) euser, _ := a.userRepo.GetUserByUsername(ctx, fmtusername) if euser != nil { return nil, auth.ErrUserExisted } user := &models.User{ Id: uuid.New().String(), Username: fmtusername, Password: password, Limit: limit, } user.HashPassword() err := a.userRepo.CreateUser(ctx, user) if err != nil { return nil, err } return a.userRepo.GetUserByUsername(ctx, username)
} func (a *authUseCase) SignIn(ctx context.Context, username, password string) (string, error) { user, _ := a.userRepo.GetUserByUsername(ctx, username) if user == nil { return "", auth.ErrUserNotFound } if !user.ComparePassword(password) { return "", auth.ErrWrongPassword } claims := AuthClaims{ Username: user.Username, UserId: user.Id, StandardClaims: jwt.StandardClaims{ IssuedAt: time.Now().Unix(), Issuer: "go-todos", ExpiresAt: time.Now().Add(a.expireDuration).Unix(), }, } token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) return token.SignedString(a.signingKey)
} func (a *authUseCase) ParseToken(ctx context.Context, accessToken string) (string, error) { token, err := jwt.ParseWithClaims(accessToken, &AuthClaims{}, func(token *jwt.Token) (interface{}, error) { if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok { return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"]) } return a.signingKey, nil }) if err != nil { return "", err } if claims, ok := token.Claims.(*AuthClaims); ok && token.Valid { return claims.UserId, nil } return "", auth.ErrInvalidAccessToken
} 

Ở đây ta thấy được lợi thế của Dependency Inversion trong SOLID. Chúng ta có thể implement 1 usecase khác, sử dụng repository khác tuy nhiên vẫn implement usecase interface và không thay đổi logic chính của app.

  • <span style="color:#ffc107">Note</span>: Khi mình chuyển từ ngôn ngữ khác qua Go, mình đã tìm hiểu về naming convention standard làm sao cho không bị conflict giữa interface và struct, mình có thử theo cách thêm prefix I vào trước interface như C#, tuy nhiên trong Go làm như vậy hơi lạ lạ, khi chúng ta muốn gọi Interface thì phải gõ chữ I, và editor sẽ suggest hàng tá interface. Nên mình áp dụng 1 cách khác, tên của Interface và struct implement nó sẽ trùng nhau, tuy nhiên struct đó sẽ không được export ra ngoài (Non-Capitalized).
    • Old way:
      • type IUseCase interface
      • type UseCase struct
    • New way:
      • type UseCase interface
      • type useCase struct

Interface Adapters layer

Như diagram phía trên, app của chúng ta sẽ có handler tương tự như controller để expose api.

cmd/api/main.go:

package main import ( "log" "kienmatu/go-todos/config" "kienmatu/go-todos/db" "kienmatu/go-todos/internal/server" "github.com/sirupsen/logrus"
) func main() { log.Println("Starting api server") // Initialize config cfg := config.NewConfig() db := db.GetPostgresInstance(cfg, true) s := server.NewServer(cfg, db, logrus.New(), nil) if err := s.Run(); err != nil { log.Fatal(err) } }

config/config.go:

package main import ( "log" "kienmatu/go-todos/config" "kienmatu/go-todos/db" "kienmatu/go-todos/internal/server" "github.com/sirupsen/logrus"
) func main() { log.Println("Starting api server") // Initialize config cfg := config.NewConfig() db := db.GetPostgresInstance(cfg, true) s := server.NewServer(cfg, db, logrus.New(), nil) if err := s.Run(); err != nil { log.Fatal(err) } } 

Ngoài ra thì tầng này còn có phức tạp hơn: handler, middleware (bao gồm middleware cho từng module, middle dùng chung cho tất cả...) và presenter.

Handler nằm trong folder delivery/http chịu trách nhiệm xử lý HTTP request sử dụng Usecase để xử lý logic business. Okay quay trở lại vấn đề ban đầu, bây giờ nếu mình muốn sử dụng thêm gRPC để giao tiếp giữa các service, mình chỉ cần thêm component vào delivery/grpc

Presenter Ở đây sẽ chịu trách nhiệm cho việc dữ liệu được hiển thị ra ngoài từ response của handler, chẳng hạn như response của việc login/register sẽ không thể nào show ra password được, do đó ở presenter của module auth:

package presenter type SignUpInput struct { Username string `json:"username"` Password string `json:"password"` Limit int `json:"limit"`
} type SignUpResponse struct { Id string `json:"id"` Username string `json:"username"` Limit int `json:"limit"`
} 

Frameworks & DBs Layer

Theo bác Bob quý mến, thì layer này chính là layer ngoài cùng, và mọi chi tiết của app đều nằm tại đây: Chúng ta có interface Repository và struct repository sử dụng em nó.

Ví dụ như UserRepository:

package auth import ( "context" "kienmatu/go-todos/internal/models"
) const CtxUserKey = "userId" type UserRepository interface { CreateUser(ctx context.Context, user *models.User) error GetUserByUsername(ctx context.Context, username string) (*models.User, error) GetUserById(ctx context.Context, userId string) (*models.User, error)
}

Về phần framework, chúng ta sử dụng echo framework, là 1 framework được sử dụng phổ biến trong cộng đồng Go. Có 1 vài component được chia ra để dễ bề quản lý hơn như middleware manager, routes, server, handler (handler này để gom các handler và route lại cho server)...

Trong server:

package server import ( "context" "kienmatu/go-todos/config" "net/http" "os" "os/signal" "syscall" "time" "github.com/labstack/echo/v4" "github.com/sirupsen/logrus" "gorm.io/gorm"
) type Server struct { echo *echo.Echo cfg *config.Configuration db *gorm.DB logger *logrus.Logger ready chan bool
} func NewServer(cfg *config.Configuration, db *gorm.DB, logger *logrus.Logger, ready chan bool) *Server { return &Server{echo: echo.New(), cfg: cfg, db: db, logger: logger, ready: ready}
} func (s *Server) Run() error { server := &http.Server{ Addr: ":" + s.cfg.Port, WriteTimeout: 15 * time.Second, ReadTimeout: 15 * time.Second, } go func() { s.logger.Logf(logrus.InfoLevel, "Server is listening on PORT: %s", s.cfg.Port) if err := s.echo.StartServer(server); err != nil { s.logger.Fatalln("Error starting Server: ", err) } }() if err := s.MapHandlers(s.echo); err != nil { return err } if s.ready != nil { s.ready <- true } quit := make(chan os.Signal, 1) signal.Notify(quit, os.Interrupt, syscall.SIGTERM) <-quit ctx, shutdown := context.WithTimeout(context.Background(), 5*time.Second) defer shutdown() s.logger.Fatalln("Server Exited Properly") return s.echo.Server.Shutdown(ctx)
} 

Tổng kết

Các bạn có thể vào repo của mình Golang todo clean architecture và đọc readme để xem hướng dẫn chạy thử và xem.

Yeah mục đích cuối cùng của việc áp dụng các design principle cũng là hỗ trợ việc code và maintain thuận lợi hơn, các bạn có thể custom lại cho phù hợp nhất với dự án. Cấu trúc này hoàn toàn có thể áp dụng sang cho các ngôn ngữ lập trình và framework khác chứ không bị gói gọn trong Go.

Code của mình được tham khảo dựa trên repo này, đa tạ tác giả ❤️❤️

Bài viết gốc ở đây (devgiangho.github.io)

Bình luận

Bài viết tương tự

- vừa được xem lúc

gRPC - Nó là gì và có nên sử dụng hay không?

Nhân một ngày rảnh rỗi, mình ngồi đọc lại RPC cũng như gRPC viết lại để nhớ lâu hơn. Vấn đề là gì và tại sao cần nó .

0 0 124

- vừa được xem lúc

Embedded Template in Go

Getting Start. Part of developing a web application usually revolves around working with HTML as user interface.

0 0 51

- vừa được xem lúc

Tạo Resful API đơn giản với Echo framework và MySQL

1. Giới thiệu.

0 0 55

- vừa được xem lúc

Sử dụng goquery trong golang để crawler thông tin các website Việt Nam bị deface trên mirror-h.org

. Trong bài viết này, mình sẽ cùng mọi người khám phá một package thu thập dữ liệu có tên là goquery của golang. Mục tiêu chính của chương trình crawler này sẽ là lấy thông tin các website Việt Nam bị deface (là tấn công, phá hoại website, làm thay đổi giao diện hiển thị của một trang web, khi người

0 0 231

- vừa được xem lúc

Tạo ứng dụng craw dữ liệu bing với Golang, Mysql driver

Chào mọi người . Lâu lâu ta lại gặp nhau 1 lần, để tiếp tục series chia sẻ kiến thức về tech, hôm nay mình sẽ tìm hiểu và chia sẻ về 1 ngôn ngữ đang khá hot trong cộng đồng IT đó là Golang.

0 0 69

- vừa được xem lúc

Golang: Rest api and routing using MUX

Routing with MUX. Let's create a simple CRUD api for a blog site. # All . GET articles/ .

0 0 50