2022.07.11

REST API với Golang, Gin, MinIO và Docker

Trong một lần tham gia dự án, mình phải gặp nhiều hạn chế của chương trình lập trình ngôn ngữ mà mình đang sử dụng, khi tìm hiểu về giải pháp khắc phục, mình tìm thấy Golang như một vị cứu tinh ở thời điểm đó. Sau một thời gian ngắn tìm hiểu về Go, từ góc nhìn của một người mới tiếp cận, mình “nhìn thấy” một số ưu điểm khiến Golang là ngôn ngữ lập trình tiếp theo mà mình sẽ tìm hiểu và sử dụng.

Trong bài viết này mình sẽ hướng dẫn từng bước thực hiện REST API TODO App với Golang Gin, MinIO (S3 compatible object storage) và Docker.

Golang, Gin

Golang (Go)

a. Golang là gì?

Go được Google phát triển vào năm 2007 cho các API và ứng dụng web. Go gần đây đã trở thành một trong những ngôn ngữ lập trình phát triển nhanh nhất do tính đơn giản cũng như khả năng xử lý các multi-core system, network và large codebase.

Go hay còn gọi là Golang ra đời nhằm đáp ứng nhu cầu của các thành viên lập trình trong các dự án lớn. Nó đã trở nên phổ biến trong nhiều công ty CNTT lớn nhờ đơn giản cấu trúc, hiện đại và thuộc tính quen thuộc về cú pháp. Các công ty sử dụng Go làm ngôn ngữ lập trình của họ bao gồm Google, Uber, Twitch, Dropbox,…. Go cũng trở nên phổ biến trong giới khoa học dữ liệu vì sự nhanh nhẹn và hiệu suất của nó.

b. Tại sao mình chọn Golang?

Tuy hiện tại Go không được phổ biến như Java hoặc Python, nhưng càng ngày Go càng được nhiều người biết đến hơn. Nó được đánh giá là một ngôn ngữ tối giản (minimalist), dễ học, mã minh bạch, tương thích và nhanh.

Hiện nay Go đang là top 3 ngôn ngữ lập trình nên học nhất và mức lương trung bình cho một Go Developer là 141,654$/năm theo simplilearn.com. (Ngày 21/06/2022)

Vì vậy, mình quyết định lựa chọn tìm hiểu về Golang.

Gin

a. Gin là gì?

Gin (https://github.com/gin-gonic/gin) là một framework nhỏ với hiệu năng cao (high-performance micro-framework) có thể được sử dụng để xây dựng các website và microservices. Sử dụng Gin giúp cho việc xây dựng các luồng xử lý request từ các module có thể tái sử dụng trở nên đơn giản hơn rất nhiều. Gin thực hiện điều này bằng cách cho phép bạn viết middleware có thể được đưa vào một hoặc nhiều request handling hoặc nhóm request handling.

b. Tại sao mình chọn Gin?

Gin là một trong những framework hàng đầu trong hệ sinh thái xoay quanh Go (Gin là web framework top 1 của ngôn ngữ Go (Cập nhập lần cuối 2021-10-03)). Đây được coi là một minimalist framework vì nó chỉ bao gồm các thư viện và tính năng cần thiết nhất. Điều này giúp Gin là lựa chọn lý tưởng cho người mới bắt đầu như mình.

MinIO

MinIO là mã nguồn mở phục vụ việc lưu trữ các object (Object Storage), tương thích với Amazon S3, Kubernetes. MinIO được thiết kế để phục vụ lưu trữ dữ liệu dạng object như hình ảnh, video, file,…

Trong bài viết này mình sẽ sử dụng MinIO thông qua Docker và kết nối với Gin như một cơ sở dữ liệu với tập tin học tập.

Docker

Nói ngắn gọn Docker là một nền tảng để giúp phát triển, triển khai và chạy ứng dụng dễ dàng hơn bằng cách sử dụng các thùng chứa (dựa trên nền tảng ảo hóa).

Ở bài viết này mình sẽ không đi sâu vào Docker. Bạn có thể tham khảo thêm về docker trong bài viết Docker là gì? .

Hiện Thực

Viết chương trình Hello World sử dụng Go

Đầu tiên, như là thường lệ, chúng ta sẽ viết một đoạn mã nhỏ bằng chữ Hello World. Bạn cài đặt Golang theo hướng dẫn từ trang chủ của Go ( https://go.dev/doc/install ). Sau đó chúng ta sẽ đi qua các bước:

 Bước 1: Tạo thư mục để chứa mã nguồn của dự án Todo App

mkdir go-rest-api

Bước 2: Khởi tạo Go Modules

go mod init TodoApp
go get -u github.com/gin-gonic/gin

Bước 3: Tạo tệp main.go và viết đầu tiên chương trình Hello World

package main
  import (
    "fmt"
  )
func main() {
  fmt.Println("Hello World!")

Bước 4: Chạy file main.go sử dụng câu lệnh

go run main.go

Bạn sẽ nhìn thấy output như sau:

Hello World!

Như vậy là chúng ta đã chạy được chương trình đầu tiên được sử dụng Go. Tiếp theo mình sẽ sử dụng Docker và Docker Compose để thiết lập môi trường phát triển. Như vậy, những người khác hoặc chính bạn sau này đều có thể xây dựng môi trường chạy dự án chỉ bằng một câu lệnh “docker-compile up” mà không cần quan tâm là ở máy chúng ta đã cài đặt Go hay chưa. Chúng ta cùng bắt đầu nhé.

Thiết lập Docker

Đầu tiên chúng ta hãy nhìn tổng quan về dự án kiến ​​trúc mà chúng ta sẽ thực hiện. Kiến trúc sẽ như sau:

  • Sử dụng Nginx làm proxy ngược: khi có yêu cầu gọi vào API, yêu cầu sẽ chuyển vào Nginx, sau đó được chuyển tiếp sang phần phụ trợ
  • Backend sử dụng khung Gin, tương tác với Cơ sở dữ liệu sử dụng MinIO và trả lời phản hồi về cho Nginx
  • Nginx gửi phản hồi trả về cho người dùng

Từ kiến trúc này, chúng ta sẽ cấu trúc folder như sau:

Bước 1: Tạo dockerfile cho backend trong TodoApp thư mục / .docker / backend /

Từ dockerfile này, sau quá trình build chúng ta sẽ có được docker image cho backend. Chạy docker image này chúng ta sẽ có container backend sẵn sàng xử lý các request được gửi đến.

FROM golang:1.18.1-alpine3.15

WORKDIR /usr/src/app
ENV MINIO_SERVER_ACCESS_KEY=minioadmin
ENV MINIO_SERVER_SECRET_KEY=minioadmin
CMD go run main.go

Bước 2: Tạo tệp nginx.conf trong TodoApp thư mục / .docker / nginx /

Ở file nginx.conf này, chúng ta sẽ cấu hình Nginx trở thành một reverse proxy.

# Determine the formatting of the log that will be print to the access.log file
log_format testlog '$remote_addr - $remote_user [$time_local] '
               '"$request" $status $bytes_sent '
               '"$http_referer" $http_user_agent $request_body $gzip_ratio '
               '"$request_time $upstream_connect_time $upstream_header_time $upstream_response_time ';

# Write the reverse proxy
server {
    # Determine where to output the log
    access_log /var/log/nginx/access.log;
    # expose port 80
    listen 80;

    # if the root route get access it will return the default nginx html page
    location /api {
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header Host $http_host;
        proxy_set_header X-Forwarded-Proto $scheme;
        # do not forget to include the scheme which is http
        proxy_pass http://backend:8080;
    }
}

Bước 3: Tạo tập tin docker – compos.yml trong TodoApp /

Sử dụng Docker Compose, chúng sẽ sẽ kết nối các service lại theo kiến trúc như đã chia sẻ ở trên. Từ đó, mỗi khi cần chạy dự án, chúng ta chỉ cần sử dụng lệnh “docker-compose up” là được. 

version: '3.7'
services:
    db:
        image: 'bitnami/minio:2022.4.16-debian-10-r9'
        ports:
            - '9002:9000'
            - '9001:9001'
        environment:
            - MINIO_ROOT_USER=minioadmin
            - MINIO_ROOT_PASSWORD=minioadmin
        networks:
            - TodoApp
    backend:
        build:
            context: .
            dockerfile: ./.docker/backend/dockerfile
        volumes:
            - ./backend:/usr/src/app
        depends_on:
            - db
        ports:
            - 8080:8080
        networks:
            - TodoApp
    nginx:
        image: nginx:1.21.6-alpine
        ports:
            - 80:80
        volumes:
            - ./.docker/nginx/nginx.conf:/etc/nginx/conf.d/default.conf
        depends_on:
            - backend
        networks:
            - TodoApp
networks:
    TodoApp:
        driver: bridge

Bước 4: Chuyển main.go, go.mod, go.sum vào thư mực TodoApp/backend/

Bước 5: Chạy dự án sử dụng câu lệnh

docker-compose up

Bước 6: Bạn sẽ thấy output như sau

Hello World!

    Thiết lập Route

    a. Route là gì

    Route tạm dịch là một tuyến đường. Nó sẽ thực hiện nhiệm vụ kết nối giữa client và server.

    b. Tại sao phải dùng Route?

    Route luôn là một phần quan trọng của hệ thống website. Tất cả các request khi qua Route đều được kiểm tra và xử lý. Sử dụng hệ thống định tuyến cho phép chúng ta cấu trúc ứng dụng của mình theo cách tốt hơn và tường minh hơn.

    c. Tạo router

    Cách mặc định để tạo một Route trong Gin như sau:

    router := gin.Default()

    router.GET(“/api/todos”, func(c *gin.Context) {

    c.JSON(http.StatusOK, gin.H{

    “message”: “Hello World!”,

    })

    File main.go lúc này sẽ như sau:

    package main
    
    import (
        "net/http"
        "time"
        "github.com/gin-gonic/gin"
    )
    
    func main() {
        time.Sleep(3 * time.Second)
        router := gin.Default()
        router.GET("/api/todos", func(c *gin.Context) {
                c.JSON(http.StatusOK, gin.H{
                    "message": "Hello World!",
            })
        })
        router.Run(":8080")
    }
    

    Chạy lệnh

    Truy cập url: localhost:8080/api/todos/

    Kết quả sẽ giống như sau:

    Vậy là chúng ta đã vừa tạo thành công một route để xử lý request sử dụng Gin. Hiện tại, mỗi khi người dùng gọi vào API /api/todos thì sẽ nhận được response là { “message”: “Hello World” }. Ở phần tiếp theo chúng ta sẽ cùng nhau hoàn thiện một API hoàn chỉnh cho việc quản lý các Todo.

    Todo Model

    a. Đầu tiên chúng ta cần mô hình hóa một Todo.

    Thuộc tính của một todo gồm:

    +Id string

    +Name string

    (Note: Tên thuộc tính phải viết hoa chữ cái đầu. Nếu không sẽ bị lỗi)

    b. Tạo file JSON

    Mình sẽ sử dụng MinIO như một Database nên những trao đổi dữ liệu giữa ServerDatabase sẽ là file json. 

    func CreateJson(id, name string) (jsonData []byte, todo Todo){
        todo = Todo{
            Id: id, Name: name,
        }
        jsonData, err :=json.Marshal(todo)
        if err != nil {
            log.Fatal(err)
        }
        return jsonData, todo
    }
    

     c. Lấy tất cả dữ liệu Todo

    Vì chúng ta giao tiếp với database bằng dữ liệu json. Nên để nhận được dữ liệu theo cấu trúc Todo. Chúng ta phải chuyển đổi dữ liệu.

    func GetAllTodos(jsonFiles []io.Reader) (temp TodoList){
        for i:=0; i < len(jsonFiles); i++{
            data := StreamToByte(jsonFiles[i])
            var todo Todo
            json.Unmarshal(data, &todo)
            temp.TodoList = append(temp.TodoList, todo)
        }
        return temp
    }
    

    Lúc này ta sẽ có file Todo.go ở thư mục TodoApp/backend/Models như sau:

    package models
    
    import (
        "bytes"
        "encoding/json"
        "io"
        "log"
    )
    type TodoList struct{
     	TodoList []Todo `json:"TodoList"`
    }
    type Todo struct {
        Id string `json:"Id"`
        Name string `json:"Name"`"
    }
    
    func CreateJson(id, name string) (jsonData []byte, todo Todo){
        todo = Todo{
            Id: id, Name: name,
        }
        jsonData, err :=json.Marshal(todo)
        if err != nil {
            log.Fatal(err)
        }
        return jsonData, todo
    }
    
    func StreamToByte(stream io.Reader) []byte {
        buf := new(bytes.Buffer)
          buf.ReadFrom(stream)
          return buf.Bytes()
      }
    func GetATodo(jsonFile io.Reader) (temp Todo){
        data := StreamToByte(jsonFile)
        json.Unmarshal(data, &temp)
        return temp
    }
    func GetAllTodos(jsonFiles []io.Reader) (temp TodoList){
        for i:=0; i < len(jsonFiles); i++{
            data := StreamToByte(jsonFiles[i])
            var todo Todo
            json.Unmarshal(data, &todo)
            temp.TodoList = append(temp.TodoList, todo)
        }
        return temp
    }
    

    Như vậy là chúng ta đã có một Model cơ bản. Tiếp theo chúng ta cần một cầu nối giữa MinIO và Backend của chúng ta. Thông thường, nếu như sử dụng các framework như PHP Laravel với hệ cơ sở dữ liệu là MySQL, Postgre,… framework sẽ cung cấp sẵn các connector cho chúng ta config và sử dụng. Ở đây mình sẽ viết một connector đơn giản để giao tiếp với MinIO.  

    Database connector

    Vì đây là một connector đơn giản nên mình sẽ hard-coded accessKeyID, secretAccessKey,... Thông thường những thông tin này nên sử dụng biến môi trường để lưu trữ và code của chúng ta sẽ đọc từ biến môi trường để config. File MinIODB.go trong thư mục TodoApp/backend/Database/ sẽ như sau:

    package Config
    
    import (
        "context"
        "fmt"
        "io"
        "log"
        "github.com/minio/minio-go/v7"
        "github.com/minio/minio-go/v7/pkg/credentials"
        "github.com/sirupsen/logrus"
    )
    const endpoint = "db:9000"
    const accessKeyID = "minioadmin"
    const secretAccessKey = "minioadmin"
    //Create connect
    func ConnectDB()(c *minio.Client,err error){
        useSSL := false
        minioClient, err := minio.New(endpoint, &minio.Options{
            Creds:  credentials.NewStaticV4(accessKeyID, secretAccessKey, ""),
            Secure: useSSL,
        })
        if err != nil {
            log.Fatalln(err)
        }
        return minioClient, err
    }
    //Set permission
    func SetPermission(client *minio.Client, bucketName string) error{
        policy := `{"Version": "2012-10-17","Statement": [{"Action": ["s3:GetObject"],"Effect": "Allow","Principal": {"AWS": ["*"]},"Resource": ["arn:aws:s3:::`+ bucketName +`/*"],"Sid": ""}]}`
    
        err := client.SetBucketPolicy(context.Background(), bucketName, policy)
        if err != nil {
            fmt.Println(err)
            return err
        }
        return err
    }
    //Create bucket
    func CreateBucket(client *minio.Client, bucketName string) error{
        ctx := context.Background()
        err := client.MakeBucket(ctx, bucketName, minio.MakeBucketOptions{Region: "ap-northeast-1"})
        if err != nil{
            exists, errBucketExists :=client.BucketExists(ctx, bucketName)
            if errBucketExists != nil {
                logrus.Errorf("[UploadImage] check bucket exists error: %s", err)
                return err
            }
            if !exists {
                logrus.Errorf("[UploadImage] make bucket error: %s", err)
                return err
            }
        }
        return err
    }
    //Upload data to MinIO
    func UploadData(client *minio.Client, bucketName, objectName string, data io.Reader) error {
        _, err := client.GetBucketPolicy(context.Background(), bucketName)
        if err != nil {
            log.Fatalln(err)
        }
        n, err := client.PutObject(context.Background(), bucketName, objectName, data, -1, minio.PutObjectOptions{ContentType: "application/octet-stream"})
        if err != nil {
            fmt.Println(err)
            return err
        }
        fmt.Println("Successfully uploaded bytes: ", n)
        return err
    }
    //Get a data from MinIO
    func GetDataTodo(client *minio.Client, bucketName, objectName string) (file io.Reader) {
        _, err := client.GetBucketPolicy(context.Background(), bucketName)
        if err != nil {
            log.Fatalln(err)
        }
        file, err = client.GetObject(context.Background(), bucketName, objectName, minio.GetObjectOptions{})
        if err != nil {
            fmt.Println(err)
            return 
        }
        return file
    }
    //Get all data from MinIO
    func GetDataTodoList(client *minio.Client, bucketName string) (file []io.Reader) {
        _, err := client.GetBucketPolicy(context.Background(), bucketName)
        if err != nil {
            log.Fatalln(err)
        }
        objectCh := client.ListObjects(context.Background(), bucketName, minio.ListObjectsOptions{
            Recursive: true,
     	})
         for object := range objectCh {
            file = append(file, GetDataTodo(client, bucketName, object.Key))
        }
        return file
    }
    

    Controllers

    Cuối cùng chúng ta sẽ tạo ra file controller thực hiện nhiệm vụ liên kết giữa server được xây dựng bằng Go và database MinIO.

    File TodoController.go trong thư mục TodoApp/backend/http/Controllers/ sẽ như sau:

    package Controllers
    
    import (
        "TodoApp/Database"
        "TodoApp/models"
        "bytes"
        "github.com/minio/minio-go/v7"
    )
    
    func GetAllTodos(Client *minio.Client, bucketName string) (res models.TodoList) {
        todoList := Config.GetDataTodoList(Client, bucketName)
        res = models.GetAllTodos(todoList)
        return res
    }
    
    func AddTodo(Client *minio.Client, bucketName, objectName, id, name string)(res models.Todo){
        jsonData,res := models.CreateJson(id, name)
        data := bytes.NewReader(jsonData)
        err := Config.UploadData(Client, bucketName,objectName, data)
        if err !=nil{
            panic(err)
        }
        return res
    }
    
    func UploadJson(Client *minio.Client, bucketName, objectName, id, name string){
        jsonFile,_:=models.CreateJson(id, name)
        data := bytes.NewReader(jsonFile)
        err := Config.UploadData(Client, bucketName,objectName, data)
        if err !=nil{
            panic(err)
        }
    }
    

      Update file main.go

      Dữ liệu example sẽ được upload lên MinIO để trực quan hơn.

      File main.go lúc này sẽ như sau:

      package main
      
      import (
          "TodoApp/Database"
          "TodoApp/http/Controllers"
          "TodoApp/models"
          "log"
          "time"
          "github.com/gin-gonic/gin"
      )
      
      func main() {
          time.Sleep(3 * time.Second)
          Client, err := Config.ConnectDB()
          if err != nil {
              log.Println(err)
          }
          err = Config.CreateBucket(Client, "todolist")
          if err != nil {
              log.Println(err)
          }
          //example data
          Controllers.UploadJson(Client, "todolist","todo1.json","1","go to school")
          Controllers.UploadJson(Client, "todolist","todo2.json","2","go to canteen")
          Controllers.UploadJson(Client, "todolist","todo3.json","3","come back home")
          //route
          router := gin.Default()
          router.GET("/api/todos", func(c *gin.Context) {
              ab := Controllers.GetAllTodos(Client, "todolist")
              c.JSON(200, ab)
              })
          router.POST("/api/todos/:uid/:id", func(c *gin.Context) {
              var todo models.Todo
              err := c.BindJSON(&todo)
              if err != nil {
                  log.Fatal(err)
              }
              res := Controllers.AddTodo(Client, "todolist", c.Param("id")+".json", c.Param("id"), todo.Name)
              c.JSON(200, res)
          })
          router.Run(":8080")
      }
      

      a. Cấu trúc thư mục:

      b. Chạy lệnh

      docker-compose up

      Truy cập url: localhost:8080/api/todos/

      Kết quả sẽ như sau:


      Thêm một Todo bằng phương thức POST qua URL:
      localhost:8080/api/todos/ +id

      Mình dùng extension: Thunder Client trên Visual Studio để test.

      Kết quả:

       

      Truy cập lại url: localhost:8080/api/todos/, danh sách Todolist đã có thêm Todo mà chúng ta vừa tạo bằng POST request.

        c. Truy cập localhost:9001 để xem dữ liệu trong MinIO

        Đăng nhập với UsernamePassword: minioadmin

        Truy cập vào bucket todolist chúng ta sẽ sẽ nhìn thấy như thế này:

        Kết luận

        Vậy là mình đã cùng các bạn thực hiện tạo REST API TodoApp bằng Golang Gin, MinIO và Docker. Qua bài viết này, các bạn đã có một bước khởi đầu để tự mình bước tiếp trên con đường trở thành Go Developer. 

        Hy vọng bài viết sẽ hữu ích cho những bạn mới tiếp cận với Golang như mình. Nhìn chung, mình thấy khá hứng thú khi đến với Golang. Và không hề hối hận với thời gian mình bỏ ra, vì nó rất nhanh và dễ tiếp cận. Cám ơn vì đã dành thời gian xem bài viết của mình.

        0 Comments
        Inline Feedbacks
        View all comments