Go - gin 學習記錄

上次學了怎麼用 Go 和 SQL 交互,接著要利用 gin package 來寫 api!下面範例沒有用到資料庫,只是先用陣列存一些資料做存取與新增。真正用到資料庫的程式碼在文章最下面,是延續使用Go - mysql 學習記錄這篇架的資料庫。

初始化專案

mkdir example && cd example
go mod init example

開始寫程式!取得資料的 handler!

  1. 建立 main.go
     touch main.go
    
  2. main.go 貼上:
     package main
    
     import (
         "net/http"
    
         "github.com/gin-gonic/gin"
     )
    
  3. 建立資料的結構,在 main.go 貼上下面程式碼
     // album 結構代表會與之後從資料庫取回的專輯資料互相 match
     type album struct {
         ID     string  `json:"id"`
         Title  string  `json:"title"`
         Artist string  `json:"artist"`
         Price  float64 `json:"price"`
     }
    
     // 資料
     var albums = []album{
         {ID: "1", Title: "Blue Train", Artist: "John Coltrane", Price: 56.99},
         {ID: "2", Title: "Jeru", Artist: "Gerry Mulligan", Price: 17.99},
         {ID: "3", Title: "Sarah Vaughan and Clifford Brown", Artist: "Sarah Vaughan", Price: 39.99},
     }
    
  4. 貼上下面的 function,這是一個會取得 albums 訊息的 handler:
     func getAlbums(c *gin.Context) {
         c.IndentedJSON(http.StatusOK, albums)
     }
    
  5. 貼上下面程式碼,利用 gin 建立 Router,讓 getAlbums handler handle 到 /albums path 的 GET 請求:
     func main() {
         router := gin.Default()
         router.GET("/albums", getAlbums)
    
         router.Run("localhost:8080") // start the server
     }
    
  6. 在 terminal 執行 go mod tidygo get . 來取得需要的 dependency
  7. go run . 啟動剛剛寫好的 http server
  8. 在 terminal 用 curl 指令和 server 交互:
     curl http://localhost:8080/albums
    

添加資料的 handler!

  1. 把 POST method 的 request body 轉換成 album struct 後添加到 albums ,也就是我們的資料中:
    func postAlbums(c *gin.Context) {
        var newAlbum album
    
        // 把接收到的 json body 轉換成 struct
        if err := c.BindJSON(&newAlbum); err != nil {
            return
        }
    
        // 添加新專輯到 albums 陣列
        albums = append(albums, newAlbum)
        c.IndentedJSON(http.StatusCreated, newAlbum)
    }
    
  2. main.gorouter.GET("/albums", getAlbums) 下加上一行:
     router.POST("/albums", postAlbums)
    
  3. go run . 重新執行 server
  4. 測試看看吧!
     curl http://localhost:8080/albums \
     --include \
     --header "Content-Type: application/json" \
     --request "POST" \
     --data '{"id": "4","title": "The Modern Sound of Betty Carter","artist": "Betty Carter","price": 49.99}'
    
  5. 用前面的 GET method 確認資料真的被添加進去:
     curl http://localhost:8080/albums \
         --header "Content-Type: application/json" \
         --request "GET"
    

用 uri 指定取得特定資料!

  1. 貼上 handler function:
     func getAlbumByID(c *gin.Context) {
         id := c.Param("id") // 從 path 裡面取出 id parameter
    
         // 用迴圈來找這個 id 是對應哪張專輯
         for _, a := range albums {
             if a.ID == id {
                 c.IndentedJSON(http.StatusOK, a)
                 return
             }
         }
         c.IndentedJSON(http.StatusNotFound, gin.H{"message": "album not found"})
     }
    
  2. main.gorouter.GET("/albums", getAlbums) 下面貼上:
     router.GET("/albums/:id", getAlbumByID)
    
  3. 在 terminal 用 curl 打打看這個 api:
     curl http://localhost:8080/albums/2
    

把程式碼變成真的和資料庫交互!

之前建立的資料庫還在,裡面的資料也和前面的 album 一樣,如果 handler 與 path 都不變,將資料從陣列改成真正的資料庫資料的話,程式如下:

package main

import (
	"database/sql"
	"fmt"
	"log"
	"net/http"
	"os"
	"strconv"

	"github.com/go-sql-driver/mysql"

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

var db *sql.DB

// album represents data about a record album.
type Album struct {
	ID     string  `json:"id"`
	Title  string  `json:"title"`
	Artist string  `json:"artist"`
	Price  float64 `json:"price"`
}

func main() {
	// db config
	cfg := mysql.Config{
		User:   os.Getenv("DBUSER"), //export DBUSER=你的 MySQL 用戶名 
		Passwd: os.Getenv("DBPASS"), //export DBPASS=你的 MySQL password 
		Net:    "tcp",
		Addr:   "127.0.0.1:3306",
		DBName: "recordings",
	}
	// 連接資料庫
	var err error
	db, err = sql.Open("mysql", cfg.FormatDSN())
	if err != nil {
		log.Fatal(err)
	}

	pingErr := db.Ping() // 確認是否真的連接上資料庫
	if pingErr != nil {
		log.Fatal(pingErr)
	}
	fmt.Println("Connected!")

  // router 的部分
	router := gin.Default()
	router.GET("/albums", getAlbums)
	router.POST("/albums", postAlbums)
	router.GET("/albums/:id", getAlbumByID)

	router.Run("localhost:8080")
}

// 取得所有專輯資料
func getAlbums(c *gin.Context) {
	var albums []Album
	rows, err := db.Query("SELECT * FROM album")
	if err != nil {
		c.IndentedJSON(http.StatusNotFound, gin.H{"message": "getAlbums error"})
	}
	defer rows.Close()

	for rows.Next() {
		var alb Album
		if err := rows.Scan(&alb.ID, &alb.Title, &alb.Artist, &alb.Price); err != nil {
			c.IndentedJSON(http.StatusNotFound, gin.H{"message": "getAlbums error"})
		}
		albums = append(albums, alb)
	}
	if err := rows.Err(); err != nil {
		c.IndentedJSON(http.StatusNotFound, gin.H{"message": "getAlbums error"})
	}

	c.IndentedJSON(http.StatusOK, albums)
}

// 新增專輯
func postAlbums(c *gin.Context) {
	var newAlbum Album

	// 把接收到的 json body 轉換成 struct
	if err := c.BindJSON(&newAlbum); err != nil {
		c.IndentedJSON(http.StatusNotFound, gin.H{"message": "getAlbums error"})
	}

	result, err := db.Exec("INSERT INTO album (title, artist, price) VALUES (?, ?, ?)", newAlbum.Title, newAlbum.Artist, newAlbum.Price)
	if err != nil {
		c.IndentedJSON(http.StatusNotFound, gin.H{"message": "getAlbums error"})
	}
	id, err := result.LastInsertId()
	if err != nil {
		c.IndentedJSON(http.StatusNotFound, gin.H{"message": "getAlbums error"})
	}

	c.IndentedJSON(http.StatusCreated, "新專輯的 id 是:"+strconv.Itoa(int(id)))
}

// 使用 id 取得特定專輯
func getAlbumByID(c *gin.Context) {
	id := c.Param("id")

	var alb Album
	row := db.QueryRow("SELECT * FROM album WHERE id = ?", id)
	if err := row.Scan(&alb.ID, &alb.Title, &alb.Artist, &alb.Price); err != nil {
		if err != nil {
			c.IndentedJSON(http.StatusNotFound, gin.H{"message": err})
		}
	}

	c.IndentedJSON(http.StatusOK, alb)
}

錯誤處理的部分是亂寫的,統一使用 http.StatusNotFound 敷衍過去,但實際上應該有優雅又精確的處理辦法,之後研究完再來更新這篇!

Reference

Tutorial: Developing a RESTful API with Go and Gin Tutorial: Accessing a relational database

HTML - Open link with new tab

方法如下:

<a href="某個網址" target="_blank" rel="noreferrer noopenner"> 
  New link
</a>

根據 google chrome 官方說明:

  • rel="noopener" prevents the new page from being able to access the window.opener property and ensures it runs in a separate process.
  • rel="noreferrer" has the same effect but also prevents the Referer header from being sent to the new page. See Link type “noreferrer”.

簡而言之,後面的 rel="noreferrer noopenner" 是安全性考量,避免新開的頁面如果是 malicious page 會影響到原本的頁面。不過新版的瀏覽器目前都默認 target="_blank" link 使用 rel=noopener

Reference

Links to cross-origin destinations are unsafe target=”_blank” 的安全性風險

React - React router with customized id

在 Route 的過程中,發現一個問題,那就是如何在跳轉到 /user 這個 link 的同時,透過 /user/:userid 這種方式將想傳遞的內容傳遞到 component 內呢?找到的方法如下:

  1. 定義 Route,後面的 <User /> 是要路由過去的 component,也就是需要取得 userid 的頁面
     <Route path="user/:userid" element={<User />} />
    
  2. 頁面跳轉的 Link,userid 是某個 dynamic 的變數,像如果 userid 是 1 的話,等同 <Link href="/user/1" />
     <Link href={"/user/"+ userid} />
    
  3. 在目標頁面(component),也就是 <User /> 內取得id
     import { useParams } from "react-router-dom";
        
     let { userid } = useParams();
    

Reference

React-Router v6 教学(附demo) React-Router doc

Go - Context

控制 Goroutine 的方式有 Waitgroup 和 Context,前面有講過 Waitgroup 簡單來說是可以指定一個數量,等到這個數量的 job 都執行完了之後再繼續執行主程式。

Context 的話,goroutine 內可能又有多個 goroutine,背景又有別的 goroutine,取消就會變得很麻煩,這就是 Context 常見的使用場景,在複雜的 goroutine 場景進行取消非常好用,又可以理解成:多個或有上下層關係的 goroutine 同步請求、處理請求、取消。

建立 Context 的方法:

  • context.Backgroud(), 較常使用
  • context.TODO()

With 函數(使用時需要從父 Context 衍生):

  • func WithCancel(parent Context) (ctx Context, cancel CancelFunc)
  • func WithDeadline(parent Context, deadline time.Time) (Context, CancelFunc)
  • func WithTimeout(parent Context, timeout time.Duration) (Context, CancelFunc)
  • func WithValue(parent Context, key, val interface{}) Context

WithTimeout,超時取消範例:

package main

import (
	"context"
	"fmt"
	"time"
)

func main() {
	HttpHandler()
}

func HttpHandler() {
	ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) 
        // cancel 其實沒被使用到 context 就結束了,但必須 defer cancel() 來避免 context leak
	defer cancel()
	deal(ctx)
}

func deal(ctx context.Context) {
	for i := 0; i < 10; i++ {
		time.Sleep(1 * time.Second)
		select {
		case <-ctx.Done(): // 三秒後context被關閉,執行此處
			fmt.Println(ctx.Err()) 
			return
		default: // 前三秒時
			fmt.Printf("deal time is %d\n", i)
		}
	}
}

WithCancel,取消範例:

package main

import (
	"context"
	"fmt"
	"time"
)

func main() {
	ctx, cancel := context.WithCancel(context.Background())

	go Talk(ctx)

	time.Sleep(10 * time.Second)
	cancel() // 十秒後閉嘴
	time.Sleep(1 * time.Second)
}

package main

import (
	"context"
	"fmt"
	"time"
)

func main() {
	ctx, cancel := context.WithCancel(context.Background())

	go Talk(ctx)

	time.Sleep(10 * time.Second)
	cancel()
	time.Sleep(1 * time.Second)
}

func Talk(ctx context.Context) {
	for range time.Tick(time.Second) {
		select {
		case <-ctx.Done():
			fmt.Println("我要閉嘴了")
			return
		default:
			fmt.Println("balabalabalabala")
		}
	}
}

Reference

【Go语言】小白也能看懂的context包详解:从入门到精通 用 10 分鐘了解 Go 語言 context package 使用場景及介紹

MySQL - Explicit Commit / Implicit Commit / Autocommit

資料庫提交數據的三種類型:

顯式提交 (Explicit Commit)

COMMIT 命令完成提交,例如:

select * from dept;
commit;

隱式提交 (Implicit Commit)

運行某些指令之後資料庫自動完成,不需要再做 COMMIT,例如:

CREATE TABLE Persons (
    PersonID int,
    LastName varchar(255),
    FirstName varchar(255),
    Address varchar(255),
    City varchar(255)
);

隱式提交的指令有:CREATE TABLEDROP TABLE 等,這些指令各自被視作單一的 transaction,因此執行完畢做 ROLLBACK 不會有用。更多可以參考 Statements That Cause an Implicit Commit

自動提交 (Autocommit)

如果 autocommit mode 開啟的話,每一個 SQL statement 都將被視作單一的 transaction,不需要 COMMIT,會自動完成提交。MySQL 默認 autocommit mode 開啟。

可以用 SELECT @@autocommit 指令確認 autocommit mode 是否開啟,是的話值為 1。

Reference

autocommit, Commit, and Rollback MYSQL的COMMIT和ROLLBACK使用讲解