我們目前做的網站提供上傳以及下載的功能,另外想要提供一個 offline CLI Tool 可以讓使用者更方便的上傳或下載檔案,也有可能可以整合這個 CLI tool 到他們自己的 CI\CD 流程當中。因為是獨立的 tool,想要用 Golang 來完成,順便可以了解一下這個語言的特性。
先簡單說一下我們 server 的環境配置:
- 利用 Json Web Token (JWT) 來作為認證的方式
- GraphQL 來當作 api 的唯一接口
因為功能不多,所以這篇文章就專注在如何使用 Golang 來發送 GraphQL 的 query 以及 mutation 指令到指定的 server。這個簡單的小工具,流程是這樣的:
- 檢查
config.toml
當中有沒有 JWT 的欄位,有的話就讀出來,沒有的話回報錯誤 - 從使用者輸入查看 action 是 import 還是 export,以及指令的 ProjectID,執行不同的函式
- 發送 GraphQL 指令
- 整理 GraphQL response
讀取 config 檔
每個語言或是作業系統都有不同對應的設定檔格式,像 Windows 鍾愛 .ini
,有些語言又可以支援多種的格式,像 Python 支援 .ini
以及 .json
。在 Golang 當中官方推薦的格式是 .toml
,這個格式很像 .ini
,不過又更彈性。在 Golang 當中要讀取 toml 的檔案相當簡單,直接看下面程式碼:
type Config struct {
Token string
}
func readConfig() (config Config) {
// 讀取執行檔所在路徑
exeFile, err := os.Executable()
if err != nil {
log.Fatal(err)
}
// 把資料夾路徑和 config.toml Join 在一起,方便之後讀檔
configFilePath := filepath.Join(filepath.Dir(exeFile), "config.toml")
_, err = os.Stat(configFilePath)
if err != nil {
log.Fatal("Config file is missing: ", configFilePath)
}
// 利用這行就可以直接把 config.toml 檔案的內容直接轉換到上面定義好的 Config Struct 中
_, err = toml.DecodeFile(configFilePath, &config)
if err != nil {
log.Fatal(err)
}
// 回傳一個 config Config 的結構
return config
}
要簡單的利用 toml 的格式,我們必須安裝另外一個第三方套件 https://github.com/BurntSushi/toml,利用下面指令可以直接安裝
go get github.com/BurntSushi/toml
# 如果被 Proxy 擋住了
https_proxy=PROXY_URL:PORT go get github.com/BurntSushi/toml
建立一個簡單的 Help Message並支持使用者輸入
我覺得 Golang 在這塊沒有像 Python 這麼的完善,介面相對簡單許多,也沒有提供 required 的功能,需要我們自己來實踐,直接看程式碼:
// 定義變數
var action string
var projectID string
var dirFilePath string
// StringVar 參數解釋: 對應變數, 參數名稱, 預設值, 說明
flag.StringVar(&action, "action", "", "Action should be import / export")
flag.StringVar(&projectID, "pid", "", "The target project ID")
flag.StringVar(&dirFilePath, "path", "", "The directory or file path. Always using / on Windows")
flag.Parse()
// 簡單做一下 required,如果有欄位缺失就跳錯誤
if action == "" || projectID == "" || dirFilePath == "" {
log.Fatal(errors.New("Input is not complete, please check again"))
}
上面這段編譯之後執行 ./PROJECT.EXE -h
可以看到下面的 help 訊息
Usage of C:\\Users\\USER_NAME\\go\\src\\PROJECT\\PROJECT.exe:
-action string
Action should be import / export
-path string
The directory or file path. Always using / on Windows
-pid string
The target project ID
使用者藉由輸入
./PROJECT.EXE -action import -path PATH_TO_DIR_FILE -pid PROJECT_ID
即可進入程式
發送 GraphQL Query/Mutation 指令以及 form-data 操作
發現 Golang 相當推薦使用 Struct 的方式來處理結構複雜的變數,所以結果都會轉換成 Struct 再回傳。這邊先定義的回傳 Struct。
type Import struct {
lineCount float64
}
type Export struct {
filePath string
fileName string
}
共用函式
因為最後的 request 都會走到同一個步驟,所以把發送 request 的部分抽出來,這樣才不會有大量重複的程式碼。
func graphqlQuery(request *http.Request) (statusCode string, response map[string]map[string]map[string]interface{}, err error) {
// 增加其他 headers
request.Header.Add("Authorization", fmt.Sprintf("Bearer %s", gJwtToken))
request.Header.Add("Cache-Control", "no-cache")
// 發送 request
resp, err := http.DefaultClient.Do(request)
if err != nil {
return statusCode, response, err
}
defer resp.Body.Close()
statusCode = resp.Status
// 讀取 response.body
body, _ := ioutil.ReadAll(resp.Body)
// 轉換成 json 格式
err = json.Unmarshal(body, &response)
return statusCode, response, err
}
Query / Mutation
Query 和 Mutation 操作方式都是一樣的,只差在指令當中把 query 或是 mutation。
func graphqlExport(projectID string) (finalRes Export, prettyRes string, err error) {
// GraphQL query 指令
query := `mutation {
export(projectId:"%s") {
filepath
filename
}
}`
// 把 query 變數轉成要發送的 Graphql 格式
querySrting := `{"query":` + strconv.QuoteToASCII(fmt.Sprintf(query, projectID)) + `}`
// 建立新的 request,以及增加特定的 header
request, _ := http.NewRequest("POST", gGraphqlURL, strings.NewReader(querySrting))
request.Header.Add("Content-Type", "application/json")
// 呼叫 共同函式發 query
_, response, err := graphqlQuery(request)
if err != nil {
return finalRes, prettyRes, err
}
// 轉換 response 成 struct 格式
finalRes.fileName = response["data"]["export"]["filename"].(string)
finalRes.filePath = response["data"]["export"]["filepath"].(string)
// Pretty print response 格式
tmpPrettyRes, err := json.MarshalIndent(response, "", " ")
prettyRes = string(tmpPrettyRes)
return finalRes, prettyRes, nil
}
Mutation Form-data Multipart
這邊比較特別一點,因為我們要上傳檔案至 server,利用了 form-data 的功能,所以這邊其實是把所有的 key-value 都包成 payload,再一次傳出去。總共有三個 key:
- operations: Graphql 的指令
- map: 檔案對應的變數
- file: 檔案的相關資訊
這三個的格式是因為 server 上的 Apollo-server-upload module 的教學文件這樣寫,我們就這樣採用了,並沒有特殊原因。
func graphqlImport(filePath, projectID string) (finalRes Import, prettyRes string, err error) {
// 開啟要上傳的檔案
file, err := os.Open(filePath)
if err != nil {
return finalRes, prettyRes, errors.New("Could not open the file, file format incorrect")
}
defer file.Close()
queryBody := &bytes.Buffer{}
// 建立 multipart writer instance
writer := multipart.NewWriter(queryBody)
boundary := writer.Boundary()
query := `mutation ($projectId:ID! $file:Upload!) {
import(projectId:$projectId file:$file) {
lineCount
}
}`
variables := `{
"projectId" : "%s",
"file" : null
}`
// 把 query 變數以及 variables 變數串一起
operationsValue := `{"query":` + strconv.QuoteToASCII(query) + `,"variables":` + fmt.Sprintf(variables, projectID) + `}`
mapValue := `{"file" : ["variables.file"]}`
// 寫入 multipart 中
_ = writer.WriteField("operations", operationsValue)
_ = writer.WriteField("map", mapValue)
// 讀取檔案相關資訊
part, err := writer.CreateFormFile("file", filepath.Base(filePath))
if err != nil {
return finalRes, prettyRes, errors.New("Could not open the file, file format incorrect")
}
// 寫入 multipart 中
_, err = io.Copy(part, file)
err = writer.Close()
if err != nil {
return finalRes, prettyRes, err
}
// 建立新的 request,以及特定的 header
request, _ := http.NewRequest("POST", gGraphqlURL, queryBody)
request.Header.Add("Content-Type", fmt.Sprintf("multipart/form-data; boundary=%s", boundary))
// 呼叫 共同函式發 query
_, response, err := graphqlQuery(request)
if err != nil {
return finalRes, prettyRes, err
}
// 轉換 response 成 struct 格式
finalRes.currentRows = response["data"]["import"]["lineCount"].(float64)
// Pretty print response 格式
tmpPrettyRes, err := json.MarshalIndent(response, "", " ")
prettyRes = string(tmpPrettyRes)
return finalRes, prettyRes, nil
}
下載某個 URL 的檔案
要下載某個 URL 的檔案或是讀取某一個 URL 的內容是後端常見的使用情境,要怎做呢?這邊可以注意一下程式碼中 URL 的串接方式,雖然可以用簡單的方式 fmt.Sprintf("%s/%s", DOMAIN, PATH)
,不過看到 stackoverflow 這篇文章之後發現這種作法應該比較正確。
func downloadFromURL(dstDirPath string, finalRes Export) error {
gBaseDomain := "http://DOMAIN.COM"
// Parse URL domain
base, err := url.Parse(gBaseDomain)
if err != nil {
return err
}
// Parse URL Path
downloadPath, err := url.Parse(finalRes.filePath)
if err != nil {
return err
}
// Concat Domain and Path to one URL
downloadURL := base.ResolveReference(downloadPath).String()
// Concat 檔案路徑
dstFilePath := filepath.Join(dstDirPath, finalRes.fileName)
log.Printf("Download %v -> %v", downloadURL, dstFilePath)
// 開啟目標檔案
file, err := os.Create(dstFilePath)
if err != nil {
return err
}
defer file.Close()
// 讀取 URL 內容
resp, err := http.Get(downloadURL)
if err != nil {
return err
}
defer resp.Body.Close()
// 複製 URL 內容至檔案
_, err = io.Copy(file, resp.Body)
if err != nil {
return err
}
return nil
}
檢查檔案是否存在或是輸入是否為資料夾
Golang 裡面這種對於資料夾的驗證比較麻煩一點,或是並沒有提供,查一查發現許多人都會自己寫並且包成一個 function 方便未來使用,如下:
// 檢查檔案是否存在
func fileExists(path string) error {
_, err := os.Stat(path)
if err == nil {
return nil
}
if os.IsNotExist(err) {
return errors.New("File is not exists")
}
return err
}
// 檢查路徑是否為資料夾
func isDirectory(path string) error {
pathStat, err := os.Stat(path)
if err != nil {
return errors.New("Path is not exists")
}
if pathStat.IsDir() {
return nil
}
return errors.New("Path is not directory")
}
寫了這個簡單的小工具,開始理解 Golang 的邏輯,一開始有點不習慣,不過寫道之後其實蠻上手的,因為 Golang 的嚴格規範,每一個人寫出來的程式碼都會符合某種風格,這也讓專案越來越大的時候更方便管理,這點是很值得讚揚的。雖然只是一個小小的工具,不過已經有點喜歡用 Golang 了,之後一些 Backend 的城市應該都會嘗試用 Golang 來寫,若有問題也請留言,有看到都會回。