GORM:從建置到 CRUD

進行後端開發時,少不了要使用資料庫,由於資料庫的設計與使用方式偏向函數思維,跟主流的物件導向習慣不同,因此很多語言都有對應的 ORM 套件,用來將資料庫轉為物件,方便開發者使用。

Golang 同樣有許多第三方的 ORM 套件,例如 lib/pq、go-pg、gorm、pgx,其中 gorm 是 GitHub 上星星數較多的專案,而且能對應到不同類型的資料庫,在 Golang 中常被使用。本文會講解如何使用 gorm 來操作 Postgres,順帶走一次後端資料庫開發的基本流程。

Establish Database

既然要使用 ORM 來開發資料庫,當然要先有資料庫啦。Postgres 是常用到的關聯式資料,它是 web based,支援高併發,相對傳統的 SQLite 等 file based 的資料庫,更適合網路應用。

要安裝 Postgres,可以用

choco install postgresql -y

安裝包會安裝 Postgres 的程式本體,還有一些命令行工具,使用這些工具可以不用進到 Postgres 的互動介面,就能操作資料庫。

接著,在專案目錄下新增資料庫資料夾,存放資料以及相關檔案

project
├── cmd
├── data
│   └── postgres
├── pkg
├── scripts
├── go.mod
└── README.md

使用命令行工具 initdb 來初始化資料庫

initdb.exe -D ./data/postgres -U postgres

-D 是指定資料夾;-U 是指定 user。初始化後,可以看到 data/postgres 資料夾下產生出許多新檔案。

有資料庫後,要將這個資料庫的服務連上網路,讓使用者使用。可以用命令行工具 pg_ctl 來執行

pg_ctl.exe -D ./data/postgres -l logfile start

-l 是 log 的位置。

執行後可以看到

waiting for server to start.... done
server started

伺服器啟動成功。

在開始使用資料庫前,需要先制定資料庫的表格結構(schema),告訴使用者資料庫有哪些欄位,資料格式是什麼,這些資料定義語言通常會用 sql 描述。

為做到這件事,在 scripts 中建立一個 sql 檔案

project
├── cmd
├── data
│   └── postgres
├── pkg
├── scripts
│   └── postgres
│       └── initialize.sql
├── go.mod
└── README.md

內容是

CREATE TABLE players (
    id SERIAL PRIMARY KEY,
    age SMALLINT NOT NULL,
    username VARCHAR(50) NOT NULL,
    budget INTEGER
);

意思是要創建一張名為 players 的表格,用來記錄比賽選手的資料。表格中有 id、age、username、budget 四個欄位。其中 id 是會自行增加的整數,也是主 key,用來當索引;age 是整數,不得為空;username 是字串,長度最多 50,不得為空;budget 是整數。

接著用 psql 來操作 sql

psql -U postgres -d postgres -f scripts/postgres/initialize.sql

執行後,剛剛的 sql 生效,資料庫初始化完成。

Connect with pgadmin

雖然說資料庫初始化完成,但看不到實體,還是沒有實感。這時可以先用互動式的命令行界面來看

psql -U postgres

登入後,輸入搜尋語句

postgres=# select * from players;
id | age | username | budget
----+-----+----------+--------
(0 rows)

如搜尋結果,表格格式跟剛剛創建的相同。

但如果每次查詢都要用命令行,需要記憶大量指令,操作起來不直覺,好在 Postgres 也有提供 GUI 讓使用者操作,安裝可以用

choco install pgadmin4 -y

安裝好後啟動,可以再右下角看到 pgAdmin4 的圖案

由於 pgAdmin4 是 web based 的客戶端,啟動後會開啟瀏覽器網頁。可以用 Create Server 將剛剛創建好的資料庫加入

輸入帳號密碼後連線

在左邊的專案樹中出現連線到的 Server,底下能看到創建好的表格

選擇 All Rows 後,就能瀏覽現在有的資料

當然,現在什麼都還沒有。

因為 web based 的設計,pgAdmin4 跟瀏覽器綁在一起,雖然適合分散式應用,但響應速度較慢、操作較麻煩,對開發初期或個人專案來講不是很方便。有些人會選擇 pgAdmin3 或是 dbeaver 這類 GUI,這邊就見仁見智,建議先都試試看,再選擇符合自己習慣的來使用。

Connect to Database

建置完資料庫後,開始來開發應用程式,建立 cmd 並在 configs 跟 pkg 下加入需要的檔案

project
├── cmd
│   └── postgres
│       └── main.go
├── configs
│   └── postgres
│       └── config.json
├── data
│   └── postgres
├── pkg
│   └── postgres
│       ├── client.go
│       └── config.go
├── scripts
│   └── postgres
│       └── initialize.sql
├── go.mod
└── README.md

pkg/client 用來建立資料庫的客戶端;pkg/config 用來設定資料庫。

引入 gorm

go get -u github.com/jinzhu/gorm

configs/config.json 的內容是

{
    "Addr": "127.0.0.1",
    "Port": 5432,
    "Username": "postgres",
    "Name": "postgres",
    "Password": "postgres"
}

表明連線的位置、端口、使用者帳密等。這些資訊可以用 Golang 在 pkg/config 讀出,內容是

package postgres

import (
    "encoding/json"
    "io/ioutil"
)

type dbConfig struct {
    Addr     string
    Port     int
    Username string
    Name     string
    Password string
}

func getDbConfig() *dbConfig {
    config := dbConfig{}
    file := "./configs/postgres/config.json"
    data, err := ioutil.ReadFile(file)
    err = json.Unmarshal(data, &config)
    if err != nil {
        panic(err)
    }
    return &config
}

有設定檔的資料後,Golang 可以在 pkg/client 根據設定來建立連線,內容是

package postgres

import (
    "github.com/jinzhu/gorm"
    // for postgres
    _ "github.com/jinzhu/gorm/dialects/postgres"
    "fmt"
)

type DBClient struct {
    client *gorm.DB
}

func (m *DBClient) Connect() {
    config := getDbConfig()
    client, err := gorm.Open(
        "postgres",
        fmt.Sprintf(
            "host=%s port=%d user=%s dbname=%s password=%s sslmode=disable",
            config.Addr,
            config.Port,
            config.Username,
            config.Name,
            config.Password,
        ),
    )
    if err != nil {
        panic(err)
    }
    m.client = client
}

func (m *DBClient) Disconnect() {
    m.client.Close()
}

主要的程式碼是

client, err := gorm.Open(
    "postgres",
    fmt.Sprintf(
        "host=%s port=%d user=%s dbname=%s password=%s sslmode=disable",
        config.Addr,
        config.Port,
        config.Username,
        config.Name,
        config.Password,
    ),
)

gorm 的設定很單純,只要指定連線的資料庫類型;再輸入對應的參數,就能進行連線。連線後會回傳 client 供後續操作。這邊將 client 保存到 m.client 中。

如果要中斷連線,則可以用

m.client.Close()

有了 client 後,就能在 cmd/main.go 中使用它

package main

import (
    "example/pkg/postgres"
    "fmt"
)

func main() {
    client := postgres.DBClient{}
    client.Connect()
    client.Disconnect()
}

CRUD

CRUD 是資料庫的四種操作,分別是 Create/Read/Update/Delete。前面已經連上資料庫了,接著把 CRUD 給補齊

先來建模,新增 pkg/player.go

project
├── cmd
│   └── postgres
│       └── main.go
├── configs
│   └── postgres
│       └── config.json
├── data
│   └── postgres
├── pkg
│   └── postgres
│       ├── client.go
│       ├── player.go
│       └── config.go
├── scripts
│   └── postgres
│       └── initialize.sql
├── go.mod
└── README.md

內容跟前面建好的 schema 相同

package postgres

// Player is player
type Player struct {
    ID int
    Age int
    Username string
    Budget int
}

修改 client 的操作,加入 Create

func (m *DBClient) Insert(player Player) error {
    res := m.client.Create(&player)
    if res.Error != nil {
        return res.Error
    }
    return nil
}

簽名很直觀,應用端會傳入資料,client 將資料傳給資料庫,如果有錯誤就回報。

加入 Read

func (m *DBClient) Get() ([]Player, error) {
    players := []Player{}
    res := m.client.Order("id").Find(&players)
    if res.Error != nil {
        return nil, res.Error
    }
    return players, nil
}

這個稍微複雜點,client 用 Find 查詢資料,並將得到的資料 assign 給 struct,由於資料可能有多筆,要使用 slice。前面的 Order 是排序用,確保得到的資料會依照 id 來排。得到資料後,將資料回傳給應用端。

加入 Update

func (m *DBClient) Update(player Player) error {
    res := m.client.Save(&player)
    if res.Error != nil {
        return res.Error
    }
    return nil
}

這邊改用 Save 來保存資料;gorm 的 Save 是當資料不存在時,會創建資料,資料存在時,會更新資料。用來判斷存不存在的依據是 id,應用端傳下來的值應該要帶 id,才能有更新的效果。

加入 Delete

func (m *DBClient) Delete(player Player) error {
    res := m.client.Delete(&player)
    if res.Error != nil {
        return res.Error
    }
    return nil
}

很直觀,就是刪除,值得注意的是 gorm 有特別說明

Delete delete value match given conditions, if the value has primary key, then will including the primary key as condition
WARNING If model has DeletedAt field, GORM will only set field DeletedAt’s value to current time

記得要帶主 key,以免找不到條件而刪除整張表格;有時在資料庫應用中,因為有稽核與回滾的需求,不希望刪除資料,取而代之是使用標記的方式來表示該資料已經不被使用了。如果要標記刪除,可以用 DeletedAt 這個欄位。

建好 CRUD 後,在應用端也加入對應的使用

func main() {
    client := postgres.DBClient{}
    client.Connect()
    player := postgres.Player{
        Age:      18,
        Username: "ken",
        Budget:   1000,
    }
    client.Insert(player)
    players, err := client.Get()
    if err != nil {
        fmt.Println(err)
    }
    if len(players) > 1 {
        players[1].Budget = 2000
        client.Update(players[1])
    }
    client.Disconnect()
}

編譯並執行程式碼,可以在 pgAdmin 上看到結果

執行多次,可以看到每次執行都會增加一筆,同時 id = 2 的資料 budget 被改成 2000

Join

有時候會需要同時取得兩張表格的資料,並合成一張大表格,在資料庫的操作中稱為 Join。來看一個情境,假設現在新增一張 games 的表格,用來表示比賽的地點、勝利選手、獎金。可以修改 sql 為

DROP TABLE IF EXISTS players;
DROP TABLE IF EXISTS games;

CREATE TABLE players (
    id SERIAL PRIMARY KEY,
    age SMALLINT NOT NULL,
    username VARCHAR(50) NOT NULL,
    budget INTEGER
);

CREATE TABLE games (
    id SERIAL PRIMARY KEY,
    winner_id SMALLINT NOT NULL,
    location VARCHAR(50) NOT NULL,
    prize INTEGER
);

並在 pkg 下建立新模型 game.go

project
├── cmd
│   └── postgres
│       └── main.go
├── configs
│   └── postgres
│       └── config.json
├── data
│   └── postgres
├── pkg
│   └── postgres
│       ├── client.go
│       ├── player.go
│       ├── game.go
│       └── config.go
├── scripts
│   └── postgres
│       └── initialize.sql
├── go.mod
└── README.md

內容是

package postgres

// Game is game
type Game struct {
    ID       int
    WinnerID int
    Location string
    Prize    int
}

現在有個需求,希望查詢選手時,能一併查詢出該選手贏得的比賽,這時就可以透過 Join 來完成。

在原本的 game.go 中新增一個擴增表格用的結構

// JoinResult is join result
type JoinResult struct {
    Player
    Game
}

因為 Golang 組合的特性,直接用兩個子結構合併即可。

在 client 的使用中新增 Join

func (m *DBClient) GetPlayerJoinGame() ([]JoinResult, error) {
    results := []JoinResult{}
    res := m.client.Table("players").Select("*").Joins("inner join games on players.id = games.winner_id").Scan(&results)
    if res.Error != nil {
        return nil, res.Error
    }
    return results, nil
}

Join 的條件是 player 的 id 等於 game 的 winner_id,就將兩者表示為同一列。得到的結果用 Scan 裝進 results 結構中。

用 pgAdmin 手動新增一筆 game 的資料

在應用端加入查詢,並印出

results, _ := client.GetPlayerJoinGame()
fmt.Println(results[0])
client.Disconnect()

編譯並執行,得到

{{2 18 ken 2000} {1 2 New York 3000}}

前一個 {} 是選手的資料;後一個 {} 是比賽的資料。透過單次查詢,得到兩個表格的資料並放入同一個結構中。

小結

跑完 CRUD 後,覺得 gorm 的用法比想像中直覺,反而是前面建置資料庫的步驟比較麻煩。Golang 以組合來替代繼承的哲學,的確讓它在操作資料庫上顯得更輕巧。但錯誤處理的方式還是有些繁瑣,某程度上,會有種 Golang 希望開發者對於第三方套件保持警戒的味道在。

很有意思的是,如果使用 Wireshark 來看,gorm 的查詢會是

如果熟悉 Postgres 的通訊協定的話,也許可以成為效能優化的參考。

Reference

Read more

收拾行李搬家去:從 Medium 到 Ghost

收拾行李搬家去:從 Medium 到 Ghost

想搬家想很久,連身邊的朋友都搬完了,我還沒動工。 原因是我懶,我討厭麻煩,每次有人問我吃什麼,我都回答麥當勞。搬家是一件麻煩事,我已經有一份很讚的工作了,全副精神都放在工作上,偶爾才會想起來,反正家什麼時候都能搬,一點也不急,有什麼好急的呢對吧。這樣一拖,就拖到現在。 繼續用 Medium 不好嗎? 跟男女朋友分手一樣,通常被問到:「對方不好嗎?」得到回答是:「也沒有不好啦,只是……(以下開放填空)。」 從優點開始講吧!Medium 的編輯器很棒,它是 WYSIWYG(所見即所得)類型的編輯器,能讓創作者快速發佈內容,也因為它讓內容發佈更容易了,它開始吸引一批優秀的創作者,這批創作者持續創作內容,又吸引來更多讀者,更多讀者激勵創作者產出內容,內容又再吸引讀者……這形成一個增強迴圈。Medium 還能支援多人協作,拜它時尚簡約的風格所賜,科技公司會使用 Medium 來打造品牌形象,例如我前公司的 Tech Blog

By Ken Chen
OpenTelemetry 的可觀察性工程:以 Sentry 為例

OpenTelemetry 的可觀察性工程:以 Sentry 為例

點進 OpenTelemetry 的官方文件,它最先映入眼中的句子是「什麼是 OpenTelemetry」。例如,它是套可觀察性框架,用於檢測、蒐集與導出遙測數據;它是開源且供應商中立,能搭配其他的開源工具,像 Jaeger 或 Prometheus;它能將應用程式與系統儀表化,無關是用 Go 還是 .NET 開發,也無關部署在 AWS 還是 GCP 上。 但是身為一名開發者,當下我們想的是:「公司常開發一些沒人要用的功能,聽說 OpenTelemetry 可以提高可觀察性,也許我們應該放棄開發功能,轉頭建立更好的開發環境。」「AWS 常常要不到需要的數據,也許我們應該改用另一套工具,像是 OpenTelemetry,來解決這件事。」我們想像 OpenTelemetry 「應該」要能解決目前面臨到的一些問題,就像在技術的鏡像中尋找願望一樣。 如果已經有在用 Sentry,還需要導入 OpenTelemetry

By Ken Chen
標準化之路:Go 1.23 中的迭代器

標準化之路:Go 1.23 中的迭代器

Ian Lance Taylor 在 "Range Over Function Types" 這篇文章聊到 iterator 誕生的原因。如果我們有兩個容器,稱為集合(Set),想要取得這兩個集合中的不重複元素,加到新的集合中形成聯集,我們可以寫個 Union 函式來執行 // Set holds a set of elements. type Set[E comparable] struct { m map[E]struct{} } // Union returns the union of two sets. func Union[E comparable](s1, s2 *Set[

By Ken Chen
OAuth 2.0 的身份認證:OpenID Connect

OAuth 2.0 的身份認證:OpenID Connect

OAuth 2 讓網路服務可以存取第三方的受保護資源,因此,有些開發者會進一步利用 OAuth 2 來進行使用者認證。但這中間存在著一些語義落差,因為 OAuth 2 當初設計目的是「授權」而不是「認證」,兩者關注的焦點會有些不同。OpenID Connect 是基於 OAuth 2 的一套身份認證協定,讓開發者可以在 OAuth 2 授權的基礎上,再加入標準的認證流程。在這篇文章中,我會說明授權跟認證的場景有何差異,並講解 OpenID Connect 如何滿足認證需求。 因為 OpenID Connect 是建構在 OAuth 2 的基礎上,我會假設這篇文章的讀者已經知道 OAuth 2 的組件與流程,如果你不熟悉,可以先閱讀另外兩篇文章 * OAuth 2.0:

By Ken Chen