Dựng Golang microservice boilerplate theo clean architecture
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
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.
- Note:
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
- Old way:
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ả ❤️❤️