如果是 ASP 或 PHP 類的技術,可以透過設定 response 的 timeout 時間,再加上 flush (或類似機制)達成。這樣的處理用在需要較長執行時間的 api (或頁面)的情境相當適合,但如果是 nextjs 前端搭配 golang 後端,就沒有類似的機制,需要改用其他方式。
Golang WebSocket
以 fiber framework 為例, golang api backend 會長得類似這樣
import {
"github.com/gofiber/contrib/websocket"
}
func main() {
app := fiber.New(fiber.Config{})
app.Get("/ws/resetfolder", websocket.New(handleResetFolder))
}
func handleResetFolder(c *websocket.Conn) {
if err := ResetFolder(c); err != nil {
log.Println("Error resetting folder:", err)
c.Close()
}
}
func ResetFolder(c *websocket.Conn) error {
// folder is defined in somewhere else
if err := os.RemoveAll(folder); err != nil {
return fmt.Errorf("error removing folder %s: %v", folder, err)
}
outputMessage("■", c)
outputMessage("<br/><br/>finished!", c)
c.Close()
return nil
}
nextjs 則需要建立 websocket connection
const [bWSClosed, setWSClosed] = useState(false);
const [bWSOpenedOnce, setWSOpened] = useState(false);
let wsHost = process.env.NEXT_PUBLIC_API_URL;
if (wsHost?.startsWith("http://")) {
wsHost = "ws" + wsHost?.substring(wsHost.indexOf(":"));
} else if (wsHost?.startsWith("https://")) {
wsHost = "wss" + wsHost?.substring(wsHost.indexOf(":"));
}
useEffect(() => {
const socket = new WebSocket(wsHost + 'ws/resetfolder');
socket.onopen = () => {
setWSClosed(false);
setWSOpened(true);
};
socket.onmessage = (event) => {
prevMessageRef.current = prevMessageRef.current + event.data;
setMessage(prevMessageRef.current);
};
socket.onclose = () => {
setWSClosed(true);
}
return () => {
socket.close();
};
}, []);
Golang Progressive Response
Go lang 裏頭,是使用 Stream 方式來做 progressive response,所以跟上面的 WebSocket 不同,這裡的 prResetFolder 這個 handler 的參數跟一般 api 呼叫一樣,都還是一個 *fiber.Ctx。
而且在 function 內可以看到 c.Context().SetBodyStreamWriter()
,直接用一個 streamwriter 來當作 body 內容輸出使用。
且裏頭還有設定一個 heart beat ticker,避免太久沒有 response 資料被browser client當作 idle 太久而斷線。
func main() {
app := fiber.New(fiber.Config{})
app.Get("/pr/resetfolder", prResetFolder)
}
func prResetFolder(c *fiber.Ctx) error {
c.Set("Content-Type", "text/plain")
c.Set("Cache-Control", "no-cache")
c.Set("Connection", "keep-alive")
c.Context().SetBodyStreamWriter(func(w *bufio.Writer) {
folder := filepath.Join(dirResult, strconv.Itoa(report_year))
ticker := time.NewTicker(1 * time.Minute) // Send heartbeat every 1 min
defer ticker.Stop()
done := make(chan struct{})
go func() {
for {
select {
case <-ticker.C:
fmt.Fprintf(w, ".") // Heartbeat to prevent idle
w.Flush()
case <-done:
return
}
}
}()
if err := os.RemoveAll(folder); err != nil {
fmt.Printf("error removing folder %s: %v\r\n", folder, err)
}
fmt.Fprintf(w, "■")
w.Flush()
close(done) // Stop ticker when work is done
fmt.Fprintf(w, "<br/><br/>finished!")
w.Flush()
})
return nil
}
NextJS 部分會變成
const [bWSClosed, setWSClosed] = useState(false);
const [bWSOpenedOnce, setWSOpened] = useState(false);
useEffect(() => {
const fetchProgress = async () => {
const response = await fetch(process.env.NEXT_PUBLIC_API_URL + 'pr/resetfolder');
const reader = response?.body?.getReader();
const decoder = new TextDecoder();
let progressData = '';
while (true) {
const result = await reader?.read();
if (!result) break;
const { done, value } = result;
if (done) {
// console.log("done");
setWSClosed(true);
setWSOpened(true);
break;
} else {
console.log("not done");
}
progressData += decoder.decode(value, { stream: true });
prevMessageRef.current = progressData;
setMessage(prevMessageRef.current);
}
};
fetchProgress();
}, []);
實際測試與心得
因為 websocket 建立的連線跟實際 tcp/ip socke不完全一樣,所以當這個backend api 執行時間需要很久的時候,在瀏覽器這邊確實會發生以為連線已經中斷的情況。解決方式就是要自己加上程式碼,讓 backend 這邊可以定時(或者每做完一些事情)輸出一些資料去給前端。
使用 stream writer 方式加上了 ticker ,理論上就可以避免上述情況發生。但我自己實際上使用時,有時還是會發生這樣的情況,但已經比用 websocket 好很多。