Go 語言在 Linux IoT 開發板上處理大檔案上傳的解決方法
Toby
Toby

近年來不少本來用於物聯網開發的主板已經差不多具備 10 年前電腦主機的 IO 速度和處理水平了。在網上不難看到具備 1000Mbps 網絡接口並同時使用多核心的處理器的單片電腦(SBC),價格上也不太貴(約 100 – 120HKD 一塊),可玩性還是很高的。

然後,我想用它來組網絡儲存系統

對,理論上你是可以用這種 SBC 來做網絡儲存器(NAS),我們一個一個來看:

  1. ✔️ CPU: H3 / H5 處理器 ( 1.3Ghz ,一般作為文件伺服器完全沒問題)
  2. ✔️ 1000Mbps Ethernet / 網絡接口
  3. ✔️ USB2.0 接口(480Mbps,對於家中只有 100Mbps 上下載的我來說完足夠)
  4. ✔️ 40 x 42 cm,可以直接黏在硬碟盒後面
  5. ✔️ 運行時只需要 5V 0.2 – 0.3A,超級省電

可是問題就是它只有 512MB 的 RAM 啊!!!

現在這年代連最便宜的 Synology DS118 都要比它多 512MB RAM 啊!

可是,要這麼多 RAM 到底用來幹麼?

Golang 網頁伺服器的文件上載的運作原理

一般來說使用 Golang 處理文件上載的邏輯都長這樣


r.ParseMultipartForm(10 << 20)

 file, handler, err := r.FormFile("myFile")
 if err != nil {
    fmt.Println("Error Retrieving the File")
    fmt.Println(err)
    return
}
defer file.Close()

//然後用 io.Copy 把暫存檔案複制到目的地,這裡我就不仔細寫了

在一般電腦上運行這段代碼是完全 OK 的,在上載檔案時 Golang 會把 multipart form data 中的檔案緩存到 RAM,然後在 io.Copy 之後,defer 的 file.Close() 會把 Reader 關掉,之後交由 GC 處理刪除。

這情況只能適用於上載的檔案比系統能用的 RAM 還要細的情況,對於像 ZeroPi / Orange Pi 這種 SBC 要上載大型檔案,就不能用這傳統的方法,而需要特別處理了。不然就會出現以下情況

就是所謂的「爆 RAM」

那讓我們先來了解一下 parseMultipartForm 的原理吧,簡單來說是

  1. 你的 browser 把檔案塞到了一個 HTTP FORM 裡面
  2. browser 把 FORM 的資料連同你要的檔案在同一個(或多個) Request 裡面送到伺服器
  3. 因為檔案太大,沒有辦法使用同一個 HTTP REQUEST 處理,所以到達的時候檔案可能被切成幾個 「區塊」,而這個就是 Multipart Form
  4. Golang 把這些「 區塊 」寫到 RAM 裡面(在 Debian 的情況下就是寫到 /tmp 裡面)
  5. 然後如果檔案大小比RAM 大,/tmp 就會被塞爆,出現「no space left on device」錯誤
  6. 如果 Golang 繼續把東西塞進去,它就會被作業系統殺掉(Killed)

要解決這個問題,我們可以使用兩個方法,首先是不要使用 Golang 內置的 Request library,自己重新寫一個。但是那樣實在太麻煩了,所以我決定直接在前端作更改

WebSocket 是個好東西

說到 WebSocket 很多人會想起類似 Chatroom 或是 Web 的 online game 之類,但是 WebSocket 除了能發 plain text 之外還能發 binary 喔!

所以,新的做法就很簡單:

我們把檔案切成好幾十分小的區塊傳到伺服器端再組合起來不就行了嗎?

對,而且這也很簡單,只要把上載的 file object 根據大小切成小區塊就可以了。我這裡側是根據 Hadoop Distributed File System 的大檔案預設切成 4MB 一個區塊,然後上載到伺服器 / Golang 的 endpoint

let socket = new WebSocket("/api/upload");
let currentSendingIndex = 0;
let chunks = Math.ceil(file.size/uploadFileChunkSize,uploadFileChunkSize);

//發送區塊的功能
function sendChunk(id){
	var offsetStart = id*uploadFileChunkSize;
	var offsetEnd = id*uploadFileChunkSize + uploadFileChunkSize;
	var thisblob = file.slice(offsetStart,offsetEnd);
	socket.send(thisblob);

	//Update progress to first percentage
	var progress = id / (chunks-1) * 100.0;
	if (progress > 100){
		progress = 100;
	}
	console.log("Progress (%): ", progress)
	
}

好了,之後就到安排發送了。然而,其中一個重要的問題是:

socket.Send() 原來是 async 的功能欸!?

所以,為了解決用 for loop 來進行 sendChunk 會讓太多個區塊同時發送的問題,我們要先等待伺服器回傳一個 keyword 我們再發下一個區塊,過程大約這樣:

  1. 等待 socket connection 開啟
  2. 開啟完成,發送 0 號區塊
  3. 等待伺服器回傳 “next” 字符
  4. 收到 “next” 字符,發送 1號區塊
  5. 重複 2 直到所有區塊已經發送完畢
  6. 發送 “done”,告訴伺服器可以開始把檔案區塊組合
  7. 等待伺服器回傳 “ok” 作為「完成」信號
  8. 關閉 socket connection

客戶端的 JavaScript 大概長這樣

 //Start sending
socket.onopen = function(e) {
	//Send the first chunk
	sendChunk(0);
	currentSendingIndex++;
};

//On Server -> Client message
socket.onmessage = function(event) {
	//Append to the send index
	var incomingValue = event.data;

	if (incomingValue == "next"){
		if (currentSendingIndex == chunks + 1){
			//Already finished
			socket.send("done");
		}else{
			//Send next chunk
			sendChunk(currentSendingIndex);
			currentSendingIndex++;
		}
	  
	}else if (incomingValue == "OK"){
		console.log("Upload Completed!");
	}
	
}

而在 Golang 端,我是使用 gorilla/websocket 進行開發的。詳情見 https://github.com/gorilla/websocket

這裡我只把重要的部分顯示出來:

//Define upload task paramters
uploadFolder := "./upload/" + taskUUID
chunkName := []string{}

//Start websocket connection
var upgrader = websocket.Upgrader{}
c, err := upgrader.Upgrade(w, r, nil)
defer c.Close()

//Listen for incoming blob / string
for {
	mt, message, err := c.ReadMessage()
	if err != nil {
		//Connection closed by client. Clear the tmp folder and exit
		log.Println("Upload terminated by client. Cleaning tmp folder.")
		//Clear the tmp folder
		time.Sleep(1 * time.Second)
		os.RemoveAll(uploadFolder)
		return
	}
	//The mt should be 2 = binary for file upload and 1 for control syntax
	if mt == 1 {
		msg := strings.TrimSpace(string(message))
		if msg == "done" {
			//Start the merging process
			log.Println(userinfo.Username + " uploaded a file: " + targetUploadLocation)
			break
		} else {
			//Unknown operations

		}
	} else if mt == 2 {
		//File block. Save it to tmp folder
		chunkName = append(chunkName, filepath.Join(uploadFolder, "upld_"+strconv.Itoa(blockCounter)))
		ioutil.WriteFile(filepath.Join(uploadFolder, "upld_"+strconv.Itoa(blockCounter)), message, 0700)
		blockCounter++

		//Update the last upload chunk time
		lastChunkArrivalTime = time.Now().Unix()

		//Request client to send the next chunk
		c.WriteMessage(1, []byte("next"))
	}
	//log.Println("recv:", len(message), "type", mt)
}

//Merge the file, please handle the error yourself!
out, _ := os.OpenFile(targetUploadLocation, os.O_CREATE|os.O_WRONLY, 0755)
defer out.Close()
for _, filesrc := range chunkName {
	srcChunkReader, err := os.Open(filesrc)
	if err != nil {
		log.Println("Failed to open Source Chunk", filesrc, " with error ", err.Error())
		c.WriteMessage(1, []byte(`{\"error\":\"Failed to open Source Chunk\"}`))
		return
	}
	io.Copy(out, srcChunkReader)
	srcChunkReader.Close()
}

//Return complete signal
c.WriteMessage(1, []byte("OK"))
	
//Clear the tmp folder
os.RemoveAll(uploadFolder)

//Close WebSocket connection after finished
c.WriteControl(8, []byte{}, time.Now().Add(time.Second))
c.Close()
	

就是這樣,我們就能成功的在 512MB 的開發板上上載超過 RAM 容量的檔案了(測試過 1 – 2 GB 的檔案也完全沒問題!)

在 512MB 的 ArozOS 中使用此方法上傳一個 1.05GB 的檔案

而上載速度嘛… 由於沒有了 RAM 的緩存,所以就看你的 SD 卡速度囉