利用 Golang 與 GraphQL Server 做檔案操作

我們目前做的網站提供上傳以及下載的功能,另外想要提供一個 offline CLI Tool 可以讓使用者更方便的上傳或下載檔案,也有可能可以整合這個 CLI tool 到他們自己的 CI\CD 流程當中。因為是獨立的 tool,想要用 Golang 來完成,順便可以了解一下這個語言的特性。

先簡單說一下我們 server 的環境配置:

  • 利用 Json Web Token (JWT) 來作為認證的方式
  • GraphQL 來當作 api 的唯一接口

因為功能不多,所以這篇文章就專注在如何使用 Golang 來發送 GraphQL 的 query 以及 mutation 指令到指定的 server。這個簡單的小工具,流程是這樣的:

  1. 檢查 config.toml 當中有沒有 JWT 的欄位,有的話就讀出來,沒有的話回報錯誤
  2. 從使用者輸入查看 action 是 import 還是 export,以及指令的 ProjectID,執行不同的函式
  3. 發送 GraphQL 指令
  4. 整理 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 來寫,若有問題也請留言,有看到都會回。


comments powered by Disqus