Porting Google Apps Script to Go: A Case Study
I ran a stock trading web console in Google Apps Script (GAS) for years. Free tier, "publish as web app" in one click—hard to beat as a start. But as the bot matured, I hit walls. Eventually, I ported the whole thing to Go. Here's what I learned.
Apps Script Architecture and Its Limits
A GAS web app has exactly two entry points:
function doGet(e) { /* GET request → HTML response */ }
function doPost(e) { /* POST request → JSON handling */ }
Simple, but for trading automation this simplicity becomes a cage:
- 6-minute execution limit — any function call over 6 minutes (shorter for triggers) terminates forcibly. You can't run an infinite trading loop.
- No state persistence — memory dies between calls. State must be read from and written to
PropertiesServiceor Sheets each time. - No concurrency control — overlapping triggers easily cause duplicate executions of the same logic.
- Debugging and versioning friction — local toolchains, git, and testing aren't natural.
Migration Strategy — Routing as the Bridge
Map GAS's two functions directly to Go's net/http routing:
func main() {
http.HandleFunc("/", handleHome) // doGet → HTML
http.HandleFunc("/api", handleAPI) // doPost → JSON
http.Handle("/static/", http.StripPrefix("/static/",
http.FileServer(http.Dir("./static/"))))
log.Fatal(http.ListenAndServe(":9329", nil))
}
Code that branched on e.parameter.action in GAS now decodes the request body and switches in Go. Keep the action names identical; the frontend doesn't change:
type APIRequest struct {
Action string `json:"action"`
Payload interface{} `json:"payload"`
}
func handleAPI(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
respondWithError(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
var req APIRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
respondWithError(w, "Invalid JSON format", http.StatusBadRequest)
return
}
switch req.Action {
case "getParameters":
handleGetParameters(w, r)
case "saveParameters":
handleSaveParameters(w, req.Payload)
case "startBot":
handleStartBot(w)
case "stopBot":
handleStopBot(w)
default:
respondWithError(w, "Unknown action", http.StatusBadRequest)
}
}
Match the response format too. Keep { status, data, ok, error } structure; the existing JS client just works:
Reclaiming State — A Bot That Actually Runs
The biggest win: the code now owns long-running state directly. In GAS, impossible. In Go, "start the bot and let it run in the background" is natural:
func handleStartBot(w http.ResponseWriter) {
botInstance.mu.Lock()
defer botInstance.mu.Unlock()
ctx, cancel := context.WithCancel(context.Background())
botInstance.cancelFunc = cancel
botInstance.IsRunning = true
go botInstance.TradingEngine.Run(ctx) // background trading loop
respondWithJSON(w, APIResponse{Status: "started"}, http.StatusOK)
}
Call context.Cancel() and a "stop" button actually halts the loop. Not fake Sheets-polling; a real long-running process. Config lives in local files (or SQLite), not spreadsheets, so queries are instant.
What You Gain
- 6-minute limit vanishes — infinite trading loops possible
- In-memory state — no round-trip to external storage per call
- Single binary —
go buildoutput runs anywhere; no external runtime - Standard toolchain — git, local debugging, tests, type safety
The Tradeoff
Nothing's free. GAS's "click publish" deployment is gone. You now own the server infrastructure (or a PC that stays on). You wire authentication yourself; Google's identity layer disappears. For small, infrequent tasks, GAS remains sensible. But for a bot that holds state and runs long, the migration cost pays for itself.
This covers architecture migration patterns and does not guarantee investment returns.
댓글
댓글 쓰기