Viết Server Golang và cấu trúc source code

I. Giới Thiệu

Bài viết gồm 2 phần chính : Đầu tiên là xây dựng server Golang đơn giản, sau đó cấu trúc lại source code. Điều này giúp tạo ra các ứng dụng linh hoạt, dễ bảo trì và mở rộng.

Nếu không có thời gian, bạn có thể xem source code ở đây. Giờ thì bắt đầu thôi.

II. Xây dựng server golang đơn giản

  • Mục tiêu viết server sẽ gồm 3 phần:

    • 1. Tạo ra một server chạy trên localhost:8000

    • 2. viết API để front-end có thể gọi tới

    • 3. Kết nối CSDL

  • Toàn bộ file server golang đơn giản ở đây.

1. Xây dựng HTTP cơ bản

Bước đầu tiên, mình xây dựng một server HTTP đơn giản bằng Golang. Sử dụng framework gin, chúng ta có thể dễ dàng tạo ra các endpoint và xử lý các yêu cầu đến từ người dùng.

package main

import (
    "github.com/gin-gonic/gin"
)

func main() {
    //------ Kết nối CSDL PostgreSQL
    // Khởi tạo route sử dụng Gin
    router := gin.Default()
    // Định nghĩa API GET "/hello" để trả về "Hello, World!"
    // Chạy server API trên http://localhost:8000
    router.Run("localhost:8000")
}

2. Xử lý yêu cầu

  • Viết API hello world đơn giản

                // Định nghĩa API GET "/hello" để trả về "Hello, World!"
                router.GET("/hello", func(c *gin.Context) {
                    c.JSON(200, gin.H{
                        "message": "Hello, World!",
                    })
                })
    

3. Giao tiếp cơ sở dữ liệu

Để giao tiếp xuống CSDL PostgreSQL, mình sử dụng thư viện gorm.

import (
    "gorm.io/driver/postgres"
    "gorm.io/gorm"
)    
    //------ Kết nối CSDL PostgreSQL    
    // Tạo chuỗi kết nối đến PostgreSQL
    dsn := fmt.Sprintf( 
        // để như này sẽ lộ hết thông tin, chúng ta sẽ fix nó ở phần sau
        "host=%s port=%s user=%s password=%s dbname=%s", 
        "localhost", "5432", "hieuhoccode", "pass", "erp_db_name")

    // Kết nối đến CSDL PostgreSQL
    db, err := gorm.Open(postgres.Open(dsn), &gorm.Config{
        Logger: logger.Default.LogMode(logger.Info),
    })
    if err != nil {
        log.Fatalf("Failed to connect to PostgreSQL: %v", err)
    }

    // ping db xem có hoạt động không
    sqlDB, err := db.DB()
    if err = sqlDB.Ping(); err != nil {
        log.Fatal("Failed to connect to the database")
    }

III. Cấu trúc lại source code

Từ một file main.go, mình sẽ tách thành các folder khác nhau. Điều này giúp duy trì tính tổ chức và dễ dàng thay đổi khi cần.

Dưới đây là hình mình họa và cách viết chi tiết của các file.

  • Bạn có thể theo dõi source code ở đây.

Tiếp theo mình sẽ đi vào chi tiết chức năng và cách mình viết các file:

1. main.go

  • File chạy chính của ứng dụng, điểm bắt đầu khi khởi chạy ứng dụng của mình.

  •               package main
                  import (
                      "refactor/service/config"
                      "refactor/service/route"
                  )
    
                  func main() {
                      config.SetEnv() // truyền biến môi trường vào chường trình để dùng
                      app := route.NewService() // Khởi tạo route sử dụng Gin
                      if err := app.Start(); err != nil { // chạy chương trình
                          panic(err)
                      }
                  }
    

7. config/

Chứa các file cấu hình. Trong đó:

  • env.go: Tăng cường bảo mật và linh hoạt cho ứng dụng Golang

  • Bảo mật dữ liệu quan trọng như tài khoản và mật khẩu CSDL luôn là ưu tiên hàng đầu. Nếu thông tin nhạy cảm bị tiết lộ, tất cả dữ liệu của bạn có thể gặp rủi ro nghiêm trọng. Đặc biệt, việc xóa hoặc sửa đổi dữ liệu CSDL có thể gây hậu quả nghiêm trọng.

  • Vì vậy, mình sẽ lưu trữ các dữ liệu nhạy cảm này trong biến môi trường thông qua editor Goland.

  • Mình tạo file env.go và sử dụng thư viện caarlos0 để lấy và sử dụng biến môi trường.

  •               package config
                  import (
                     "fmt"
                     "github.com/caarlos0/env"
                  )
    
                  type Config struct {
                     DBHost     string `env:"DB_HOST" envDefault:"DB_HOST"`
                     DBPort     string `env:"DB_PORT" envDefault:"DB_PORT"`
                     DBUser     string `env:"DB_USER" envDefault:"DB_USER"`
                     DBPassword string `env:"DB_PASSWORD" envDefault:"DB_PASSWORD"`
                     DBName     string `env:"DB_NAME" envDefault:"DB_NAME"`
                     Port       string `env:"PORT" envDefault:"PORT"`
                  }
    
                  var cfg Config
    
                  // Lấy dữ liệu từ biến môi trường, gán vào biến cfg
                  func SetEnv() {
                     if err := env.Parse(&cfg); err != nil {
                        fmt.Printf("Failed to read environment variables: %v", err)
                        return
                     }
                  }
    
                  // Sử dụng biến config
                  func GetEnv() Config {
                     return cfg
                  }
    
  • app.go: Chứa cấu hình cho ứng dụng, cài đặt HTTPS, v.v.

          package config
          import (
              "github.com/gin-gonic/gin"
          )
    
          type App struct {
              Router *gin.Engine
          }
    
          func NewApp() *App {
              return &App{
                  Router: gin.Default(),
              }
          }
    
          func (a *App) Start() error {
              return a.Router.Run(":" + cfg.Port)
          }
    
  • db.go: Chứa cấu hình kết nối với CSDL, bao gồm tên database, thông tin kết nối, v.v.

          package config
          import (
              "fmt"
              "gorm.io/driver/postgres"
              "gorm.io/gorm"
              "gorm.io/gorm/logger"
              "log"
          )
    
          var dbDefault *gorm.DB
    
          // sử dụng singleton pattern để tạo một connection duy nhất đến database
          // khi ứng dụng lớn hơn thì không nên sử dụng singleton pattern
          // thay vào đó nên sử dụng connection pool
          func (a *App) GetDB() *gorm.DB {
              if dbDefault == nil {
                  return a.initDB()
              }
              return dbDefault
          }
    
          func (a *App) initDB() *gorm.DB {
              // Tạo chuỗi kết nối đến PostgreSQL
              dsn := fmt.Sprintf(
              "host=%s user=%s password=%s dbname=%s port=%s",
               cfg.DBHost, cfg.DBUser, cfg.DBPassword, cfg.DBName, cfg.DBPort)
    
              // Kết nối đến CSDL PostgreSQL
              db, err := gorm.Open(postgres.Open(dsn), &gorm.Config{
                  Logger: logger.Default.LogMode(logger.Info),
              })
              if err != nil {
                  log.Fatalf("Failed to connect to PostgreSQL: %v", err)
              }
              return db
          }
    

6. repo/

  • repo.go định nghĩa repository và tạo interface để các package khác có thể gọi tới có thể sử dụng.

  •               package repo
                  import (
                      "refactor/service/model"
                      "gorm.io/gorm"
                  )
    
                  type Repo struct {
                      db *gorm.DB
                  }
    
                  func NewRepo(db *gorm.DB) *Repo {
                      return &Repo{
                          db: db,
                      }
                  }
    
                  type IRepo interface {
                      GetUsers(name string) ([]model.User, error)
                  }
    
  • Tiếp theo, mình tách riêng ra một file khác trong folder repo/ để viết logic tương tác với CSDL. Thao tác CRUD (Tạo, Đọc, Cập nhật, Xóa) dữ liệu được đặt ở đây.

      package repo
      import (
          "refactor/model"
          "context"
      )
    
      func (r *Repo) GetUsers(ctx context.Context) ([]model.User, error) {
          // Lấy từ database
          var users []model.User
          if err := r.db.Find(&users).Error; err != nil {
              return []model.User{}, err
          }
          return users, nil
      }
    

5. service/

  • Chứa logic chính của ứng dụng. Đây là nơi bạn triển khai các chức năng cụ thể mà ứng dụng cung cấp.

  •               package service
                  import (
                      "refactor/service/model"
                      "refactor/service/repo"
                      "context"
                  )
    
                  type User struct {
                      repo repo.IRepo
                  }
    
                  func NewUser(repo repo.IRepo) *User {
                      return &User{
                          repo: repo,
                      }
                  }
    
                  type IUser interface {
                      GetUsers(ctx context.Context) (users []model.User, err error)
                  }
    
                  func (s *User) GetUsers(ctx context.Context) (users []model.User, err error) {
                      // Gọi tới tầng repo
                      users, err = s.repo.GetUsers(ctx)
                      if err != nil {
                          return users, err
                      }
                      return users, nil
                  }
    

4. handler/

  • Xử lý các yêu cầu từ front-end và trả về các phản hồi. Tại đây, bạn sẽ xử lý logic do người dùng yêu cầu.

  •               package handler
                  import (
                      "refactor/service"
                      "github.com/gin-gonic/gin"
                  )
    
                  type User struct {
                      service service.IUser
                  }
    
                  func NewUser(service service.IUser) *User {
                      return &User{
                          service: service,
                      }
                  }
    
                  func (h *User) GetUsers(c *gin.Context) {
                      // call service
                      user, err := h.service.GetUsers(c)
                      if err != nil {
                          c.JSON(401, gin.H{
                              "message": err.Error(),
                          })
                          return
                      }
    
                      // Trả về thông tin của bản ghi
                      c.JSON(200, user)
                  }
    

2. route/

  • Chứa các API mà front-end của ứng dụng sẽ gọi.

  •               package route
                  import (
                      "refactor/service/config"
                      "refactor/service/handler"
                      "refactor/service/repo"
                      "refactor/service/service"
                  )
    
                  type Service struct {
                      *config.App
                  }
    
                  func NewService() *Service {
                      s := Service{
                          config.NewApp(),
                      }
    
                      db := s.GetDB()
                      repo := repo.NewRepo(db)
    
                      userService := service.NewUser(repo)
                      user := handler.NewUser(userService)
    
                      router := s.Router
                      v1 := router.Group("/v1")
    
                      // user
                      v1.POST("/login", middleware.CheckAdmin(), user.GetUsers)
                      return &s
                  }
    

8. middleware/

  • Chứa các hàm trung gian, xử lý yêu cầu trước khi nó đến tới handler. Ví dụ, xác thực, ghi log, xử lý lỗi panic, v.v.

  •               package middleware
                  import (
                      "refactor/service/util"
                      "github.com/gin-gonic/gin"
                  )
    
                  func CheckAdmin() gin.HandlerFunc {
                      return func(c *gin.Context) {
                          header := c.Request.Header
                          // kiểm tra người dùng có phải admin không
                          if header.Get("x-role-id") != util.ADMIN_ROLE {
                              c.AbortWithStatusJSON(403, gin.H{
                                  "message": "Forbidden",
                              })
                              return
                          }
                          c.Next()
                      }
                  }
    

9. model/

  • Lưu trữ các cấu trúc dữ liệu (struct) được sử dụng trong toàn bộ ứng dụng. Điều này giúp duy trì tính nhất quán trong dữ liệu.

  •               package model
                  // Tạo struct thông tin user
                  type User struct {
                      Id       int64  `json:"id"`
                      UserName string `json:"user_name"`
                  }
    
                  // vì từ khóa "user" đã được SQL định nghĩa 
                  // nên cta sẽ dùng một tên khác cho bảng này "users"
                  func (User) TableName() string {
                      return "users"
                  }
    

10. util/

  • Chứa các hàm tiện ích chung, ví dụ như:

    • constant.go: Định nghĩa các hằng số được sử dụng trong toàn bộ dự án.

    • error.go: Xử lý các lỗi thường gặp.

    • response.go: Định nghĩa cấu trúc phản hồi chung từ server.

IV. Tham khảo cách cấu trúc source code khác

https://200lab.io/blog/clean-architecture-uu-nhuoc-va-cach-dung-hop-ly/

https://200lab.io/blog/ung-dung-clean-architecture-service-golang-rest-api/
https://github.com/bxcodec/go-clean-arch?fbclid=IwAR08SHU9urwPAofeHDVzg5MwAn4uNl49uGog7SebXWiJHRV7Mq5_y5uiMWk