第 0 章:Go 給 JS 工程師的快速入門
這一章在做什麼? 在正式動手寫專案之前,先把 Go 跟 Node.js / JavaScript 的世界觀對齊一次。後面每一章都會用到這裡的觀念(package、靜態型別、struct、error 處理、interface、指標),先在這裡一次講清楚,之後就能專心在「做功能」上,不會一直被語法岔題打斷。
為什麼值得花這 30 分鐘? Go 跟 JS 最大的差別不是長相,而是思維:靜態型別、編譯式、用回傳值處理錯誤、預設傳值(複製)。先理解這幾件事,後面看 sqlx、JWT、下單交易的程式碼會非常順。
這一章是純語法暖身。我們會開一個獨立的
playground/練習資料夾,跟正式專案(第 1 章才建)完全分開。你可以放心亂玩、寫完就刪。
步驟 1:安裝 Go 並驗證
- 到 https://go.dev/dl/ 下載你作業系統的安裝檔,安裝(macOS 也可
brew install go)。 - 開一個新的終端機,確認版本:
go version
預期看到類似(版本 1.22 以上即可):
go version go1.22.5 darwin/arm64
Go 工具鏈 vs Node 工具鏈
Go 把很多工具「內建」在 go 這一個指令底下,不像 Node 要另外裝一堆 CLI:
| 你想做的事 | Node.js 世界 | Go 世界 |
|---|---|---|
| 跑一支程式 | node index.js |
go run main.go |
| 編譯成可執行檔 | (通常不用) | go build → 產生一個單一執行檔 |
| 安裝套件 | npm install xxx |
go get xxx |
| 初始化專案 | npm init |
go mod init |
| 格式化程式碼 | Prettier(要另裝) | go fmt(內建,全社群統一風格) |
| 跑測試 | Jest / Vitest(要另裝) | go test(內建) |
🔁 JS 對照 Node 專案靠
package.json+node_modules+ 一堆 devDependencies(Prettier、ESLint、ts-node…)。 Go 幾乎只靠一個go指令搞定編譯、格式化、測試、套件管理。格式化沒有選項可吵 — 全 Go 社群都用go fmt的同一種風格(縮排用 Tab、大括號位置固定)。所以你不會看到「我們團隊要不要分號」這種爭論。
步驟 2:建立第一個 module(對照 package.json)
Go 用 module 管理一個專案的相依套件,對應 Node 的 package.json。
mkdir playground
cd playground
go mod init playground
執行後會多出一個 go.mod 檔:
// playground/go.mod
module playground
go 1.22
逐行說明:
module playground:這個 module 的名字。正式專案通常會用網址形式(例如github.com/你的帳號/no-logic-trade),因為它同時是「別人 import 你的路徑」。練習階段叫playground就好。go 1.22:這個專案使用的 Go 版本。
🔁 JS 對照
go.mod≈package.json:宣告專案名稱、Go 版本、相依套件。 之後安裝套件時會自動產生go.sum(≈package-lock.json,鎖定每個套件的雜湊值確保安全)。 差別:Go 沒有node_modules。下載的套件存在全域快取,由go.mod控制版本,所以你的專案資料夾會乾淨很多。
步驟 3:Hello World(package 與進入點)
建立 playground/main.go:
// playground/main.go
package main
import "fmt"
func main() {
fmt.Println("Hello, Go!")
}
執行:
go run main.go
預期輸出:
Hello, Go!
逐段解釋:
package main:每個.go檔第一行都要宣告自己屬於哪個 package。名字叫main的 package 很特別 — 它是「可執行程式」的進入包。其他名字的 package 則是「函式庫」。func main():名為main的函式是程式的進入點(對應index.js最外層會被執行的那段)。一個可執行程式有且只有一個main()。import "fmt":引入標準函式庫fmt(format,負責印東西、格式化字串)。fmt.Println≈console.log。- 沒有分號:Go 不用在每行尾巴加
;(編譯器自動處理)。 - 大括號位置是固定的:
{一定跟在同一行尾,這不是風格,是規定(go fmt會強制)。
🔁 JS 對照 JS 一個檔案就是一個 module,靠
import/export串起來;Go 是一個資料夾 = 一個 package,同資料夾的多個.go檔共用同一個 package 名稱、彼此的函式直接互相看得到,不用 import。 也就是說:Go 的「import」是 import 別的資料夾 / 別的套件,不是 import 同資料夾的隔壁檔案。
步驟 4:靜態型別(Go 跟 JS 最根本的差異)
把 main.go 改成這樣再跑一次:
// playground/main.go
package main
import "fmt"
func main() {
var symbol string = "AAPL" // 明確寫出型別 string
price := 195.5 // := 讓 Go 自動推導型別(這裡是 float64)
var quantity int = 10 // int
fmt.Println(symbol, price, quantity)
// 下面這行如果取消註解會「編譯失敗」:不能把字串塞進一個 float64 變數
// price = "變貴了"
}
go run main.go 預期輸出:
AAPL 195.5 10
重點:
- 變數一旦決定型別,就不能再變成別的型別。
price是float64,你不能之後塞字串給它 — 而且這個錯誤在編譯時就被抓出來,不是等到執行才爆。這就是「靜態型別」的價值:很多 JS 要靠測試或 TypeScript 才能發現的錯誤,Go 在你按下執行前就擋掉了。 - 兩種宣告變數的寫法:
var symbol string = "AAPL":完整寫法,可指定型別。price := 195.5:短宣告,Go 從右邊的值自動推導型別。只能在函式內用。日常最常用這個。
- 零值(zero value):如果你只宣告不給值,Go 會給一個預設「零值」而不是
undefined:數字是0、字串是""、布林是false、指標/slice/map 是nil。Go 沒有undefined這種東西。
🔁 JS 對照
let symbol = "AAPL"; // 之後可以 symbol = 123,JS 不會抱怨 let price = 195.5;JS 變數可以隨時換型別,錯誤通常要到執行期才浮現。 Go 的
:=很像 TypeScript 的型別推導(const price = 195.5推導成number),但 Go 是真的靜態型別語言,型別檢查是語言本身的一部分,不是外掛。
步驟 5:struct(你最常用的資料結構)
struct 是一組欄位的集合,是 Go 用來描述「一筆資料長什麼樣」的主力。我們整個專案的 User、Account、Stock、Order 都會是 struct。
// playground/main.go
package main
import "fmt"
// 定義一個型別:一檔股票長這樣
type Stock struct {
Symbol string
Price float64
}
func main() {
// 建立一個 Stock 實體
apple := Stock{Symbol: "AAPL", Price: 195.5}
fmt.Println(apple.Symbol) // AAPL
fmt.Println(apple.Price) // 195.5
// 也可以印整個 struct(%+v 會連欄位名一起印,debug 很好用)
fmt.Printf("%+v\n", apple) // {Symbol:AAPL Price:195.5}
}
重點:
type Stock struct { ... }:定義一個叫Stock的新型別。- 欄位寫法是
欄位名 型別(名字在前、型別在後,這跟 JS / TS 相反)。 - 建立實體用
Stock{Symbol: "AAPL", Price: 195.5}。 - 用
.取欄位,跟 JS 物件一樣。
🔁 JS 對照 Go 的 struct 比較像 TypeScript 的
interface/type+ 一個符合它的物件,但兩者合一:interface Stock { symbol: string; price: number; } const apple: Stock = { symbol: "AAPL", price: 195.5 };關鍵差異:JS 物件可以隨時
apple.foo = 123加新欄位;Go 的 struct 欄位是固定的,加一個沒定義的欄位會編譯失敗。這正是我們要的安全感。
步驟 6:大小寫決定「公開或私有」(取代 export)
Go 沒有 export 關鍵字。它用一個超簡單的規則:識別字(變數、函式、struct 欄位…)的首字母大寫 = 對外公開(exported);小寫 = 只在自己 package 內看得到(private)。
type Stock struct {
Symbol string // 大寫開頭 → 公開,別的 package 可以存取
price float64 // 小寫開頭 → 私有,只有本 package 內看得到
}
func GetQuote(symbol string) Stock { /* ... */ } // 公開函式
func calculate(x int) int { /* ... */ } // 私有函式
🔁 JS 對照
export function getQuote() {} // 公開 function calculate() {} // 模組私有JS 靠
export關鍵字決定;Go 靠首字母大小寫決定。所以在 Go 裡,公開 API 都是大寫開頭(User、GetBalance),這也是為什麼你會看到fmt.Println而不是fmt.println—Println是fmt套件公開出來的。
步驟 7:錯誤處理 — Go 沒有 try/catch ⭐
這是 JS 工程師最不習慣、但最重要的一個差異。Go 沒有 exception、沒有 try/catch。取而代之:一個可能失敗的函式會回傳兩個值:結果 + error,呼叫端必須馬上檢查那個 error。
// playground/main.go
package main
import (
"errors"
"fmt"
)
// 回傳「兩個值」:商,以及一個 error
func divide(a, b float64) (float64, error) {
if b == 0 {
// 出錯:回傳零值 + 一個 error
return 0, errors.New("除數不能為零")
}
// 成功:回傳結果 + nil(代表沒有錯誤)
return a / b, nil
}
func main() {
result, err := divide(10, 2)
if err != nil {
fmt.Println("發生錯誤:", err)
return
}
fmt.Println("結果:", result) // 結果: 5
// 故意製造錯誤
_, err = divide(10, 0)
if err != nil {
fmt.Println("發生錯誤:", err) // 發生錯誤: 除數不能為零
return
}
}
go run main.go 預期輸出:
結果: 5
發生錯誤: 除數不能為零
重點:
- 函式可以回傳多個值:
(float64, error)。這在 JS 沒有,JS 要回傳多個值得包成陣列或物件。 - 慣例:最後一個回傳值是
error。成功時回nil(代表「沒有錯誤」,類似 JS 的null)。 if err != nil { ... }是你在 Go 程式裡會看到最多次的一段。它就是 Go 的錯誤處理。一開始覺得囉嗦,但它的好處是:每個可能失敗的點都被明確標示出來,不會有「某個深處 throw 出來、不知道誰該接」的問題。_是「我故意忽略這個回傳值」的寫法(這裡忽略商,只看 error)。Go 不允許宣告了卻沒用的變數,用_明確丟掉。
🔁 JS 對照
function divide(a, b) { if (b === 0) throw new Error("除數不能為零"); return a / b; } try { const result = divide(10, 0); } catch (err) { console.error("發生錯誤:", err.message); }JS:用
throw丟出、用try/catch接住,控制流會「跳走」。 Go:用回傳值傳遞錯誤,控制流是平的、一行一行往下走,你必須主動檢查。 心法轉換:「每呼叫一個可能失敗的函式,下一行幾乎都是if err != nil」。習慣之後會發現它讓錯誤路徑非常清楚。
步驟 8:interface — 隱式實作的鴨子型別
interface 定義「一組行為(方法)」。任何型別只要實作了這些方法,就自動算是符合這個 interface — 不需要像 Java/TS 那樣明寫 implements。我們第 5 章的報價服務、之後抽換資料來源都會用到它。
先記住一個關鍵字:接收者(receiver)。Go 沒有隱藏的 this;你得在方法名稱前面自己把它命名出來 —— 下面程式裡的那個 m 就是。
// playground/main.go
package main
import "fmt"
// 定義「行為」:任何能提供報價的東西,都要有 GetPrice 方法
type QuoteProvider interface {
GetPrice(symbol string) float64
}
// 一個具體實作:把假資料存在自己(struct)身上
type MemoryQuotes struct {
prices map[string]float64
}
// func 後面、方法名稱前面的 (m MemoryQuotes) 就是「接收者 receiver」。
// m 是一個 MemoryQuotes 實例,等同其他語言的 this / self,只是 Go 要你自己命名。
// 呼叫 mem.GetPrice("AAPL") 時,mem 本人會被塞進 m,
// 方法就能透過 m 讀到「自己身上」的資料(這裡是 m.prices)。
func (m MemoryQuotes) GetPrice(symbol string) float64 {
return m.prices[symbol] // 讀自己(m)身上的 prices;map 查不到 key 會回零值 0
}
// 這個函式只認 interface,不在乎背後是哪種實作
func printPrice(q QuoteProvider, symbol string) {
fmt.Printf("%s 現價: %.2f\n", symbol, q.GetPrice(symbol))
}
func main() {
// 建立實例、順便把假資料塞進去
mem := MemoryQuotes{prices: map[string]float64{
"AAPL": 195.5,
"TSLA": 250.0,
}}
var q QuoteProvider = mem // mem 有 GetPrice,就自動符合 QuoteProvider
printPrice(q, "AAPL") // AAPL 現價: 195.50
printPrice(q, "TSLA") // TSLA 現價: 250.00
}
逐段解釋:
type QuoteProvider interface { GetPrice(symbol string) float64 }:定義一個合約 — 「凡是有GetPrice(string) float64方法的型別,都是 QuoteProvider」。- 接收者
(m MemoryQuotes)是最容易卡住的地方,拆成三塊看:m是變數名稱,你隨便取都行(慣例用型別的第一個字母,MemoryQuotes→m)。它就是 Go 版的this/self。MemoryQuotes是型別,表示「這個GetPrice是掛在MemoryQuotes上的方法」。- 合起來就是:「幫
MemoryQuotes加一個GetPrice方法,方法內用m稱呼被呼叫的那個實例」。所以mem.GetPrice("AAPL")一執行,m就等於mem,m.prices就是mem.prices。 - 補充:如果方法根本用不到自己(例如空 struct),連名字都能省略成
func (MemoryQuotes) GetPrice(...)。這就是為什麼你可能看過沒有m的版本 —— 原本的例子就是這種、才會讓人覺得m很多餘。
MemoryQuotes沒有寫任何implements QuoteProvider,但因為它剛好有對得上的GetPrice方法,就自動是一個QuoteProvider。這叫隱式實作。prices map[string]float64是 struct 的一個欄位,型別是 map(字典)map[鍵型別]值型別;查不到 key 會回該值型別的零值(這裡是0)。
🔁 JS 對照 上面的 receiver
m就是你早就在用的this,Go 只是把它從「隱形」改成「明講」:class MemoryQuotes { constructor(private prices: Record<string, number>) {} // ↓ 這個 this,就等於 Go 的 receiver m getPrice(symbol: string): number { return this.prices[symbol] ?? 0; } }而「不用寫 implements 就自動符合」則是 TypeScript 的 structural typing(結構型別 / 鴨子型別):
interface QuoteProvider { getPrice(symbol: string): number; } // 任何有 getPrice(string):number 的物件都符合,不用宣告 implements差別在 JS 是 runtime「反正你呼叫就試試看」;Go 是編譯期就保證「你傳進來的東西一定有 GetPrice」。 interface 的實際用途:讓
printPrice這種高階邏輯不綁死某個實作。今天用記憶體假資料MemoryQuotes,明天換成「接真實 API 的實作」,只要它一樣有GetPrice,printPrice一行都不用改。這在第 5 章會真正用上。
步驟 9:指標(pointer)入門 — 傳值還是傳本體?
JS 物件永遠是「傳參考」,改了就是改本體。Go 預設相反:傳參數時是傳「複本」。如果你想讓函式真的改到原本的資料,要用指標。
// playground/main.go
package main
import "fmt"
type Account struct {
Balance int
}
// 傳值:a 是 acc 的「複本」,改它不影響外面
func depositByValue(a Account, amount int) {
a.Balance += amount
}
// 傳指標:a 指向「同一個 acc」,改它就是改本體
// *Account 表示「指向 Account 的指標」
func depositByPointer(a *Account, amount int) {
a.Balance += amount
}
func main() {
acc := Account{Balance: 100}
depositByValue(acc, 50)
fmt.Println("傳值之後:", acc.Balance) // 還是 100(改到的是複本)
depositByPointer(&acc, 50) // &acc 取得 acc 的「記憶體位址」
fmt.Println("傳指標之後:", acc.Balance) // 150(真的改到本體)
}
go run main.go 預期輸出:
傳值之後: 100
傳指標之後: 150
重點(先記得這幾個符號就好):
*Account:型別寫法,表示「一個指向 Account 的指標」。&acc:取得acc的記憶體位址(取址),把「指向它的指標」傳進去。- 在指標上取欄位 Go 很貼心,直接
a.Balance就好(不用像 C 寫(*a).Balance,Go 自動幫你解開)。
你現在只要記住兩個實務場景:
- 想讓函式改到原本的 struct → 用指標。
- struct 比較大、不想每次都複製一份 → 用指標(效率)。
之後你會常看到 func (s *userService) Register(...)、db.Get(&user, ...) 這種寫法,裡面的 * 和 & 就是這裡的概念。看到 &user 就想成「把 user 的位址交出去,讓對方能填東西進來」。
🔁 JS 對照
function deposit(account, amount) { account.balance += amount; } // 直接改到本體JS 物件是傳參考,這段一定改得到本體 — 你從來不用想「傳值還傳參考」。 Go 預設傳值(複製),所以「要不要用指標」變成一個你要做的決定。 小抄:基本型別(int、string…)通常傳值;要被修改的 struct、或大型 struct,用指標
*T,呼叫時傳&x。
步驟 10:裝 Gin,寫出第一支 Web 伺服器
最後,我們來感受一下這份教學的主角框架 Gin。它在 Go 後端的地位,就等於 Express 在 Node.js:負責路由(route)、處理請求/回應、串接 middleware。
- 在
playground/裡安裝 Gin:
go get github.com/gin-gonic/gin
這會把 Gin 加進 go.mod,並產生/更新 go.sum。
- 把
main.go換成這個最小 Web 伺服器:
// playground/main.go
package main
import (
"net/http"
"github.com/gin-gonic/gin"
)
func main() {
// 建立一個 Gin 引擎(已內建 logger 與 recovery middleware)
r := gin.Default()
// 註冊一條 GET 路由 /ping
r.GET("/ping", func(c *gin.Context) {
// 回一個 JSON:HTTP 200 + {"message":"pong"}
c.JSON(http.StatusOK, gin.H{
"message": "pong",
})
})
// 啟動伺服器,監聽 8080 port
r.Run(":8080")
}
- 執行:
go run main.go
你會看到 Gin 印出啟動訊息,包含 Listening and serving HTTP on :8080。
逐段解釋(左邊 Go、右邊它對應的 Express 概念):
r := gin.Default()→const app = express()。Default()還幫你掛好了「印出每個請求的 log」和「panic 不會讓整個服務掛掉」兩個 middleware。r.GET("/ping", handler)→app.get("/ping", handler)。func(c *gin.Context)→ Express 的(req, res) => {}。差別:Gin 把req和res合併成一個c(Context),你從c拿請求資料、也用c回應。c.JSON(http.StatusOK, gin.H{...})→res.status(200).json({...})。http.StatusOK就是常數200(用名字比用數字好讀)。gin.H{...}是 Gin 提供的「快速建立 JSON 物件」的捷徑,它其實就是map[string]interface{}的別名 — 你可以把它想成 JS 的物件字面值{}。r.Run(":8080")→app.listen(8080)。
🔁 JS 對照(Express)
const express = require("express"); const app = express(); app.get("/ping", (req, res) => { res.status(200).json({ message: "pong" }); }); app.listen(8080);幾乎一一對應。最大的差別就是 Gin 用單一個
c *gin.Context取代(req, res),以及型別是靜態的。如果你寫過 Express,Gin 會非常親切。
✅ 如何驗證這一章成功
伺服器(步驟 10)還開著的話,另開一個終端機:
curl http://localhost:8080/ping
預期回應:
{"message":"pong"}
如果你想看得更清楚(含 HTTP 狀態碼):
curl -i http://localhost:8080/ping
預期會看到開頭是:
HTTP/1.1 200 OK
Content-Type: application/json; charset=utf-8
...
{"message":"pong"}
看到 pong 就代表:你裝好了 Go、建好了 module、裝好了 Gin、也成功跑起一支 Web 伺服器。🎉
要停掉伺服器,回到它執行的那個終端機按 Ctrl + C。
這一章你學到的(之後每章都會用到)
| 觀念 | 一句話記住 | 之後用在哪 |
|---|---|---|
| package | 一個資料夾 = 一個 package;main 是進入包 |
整個專案的目錄結構 |
靜態型別 / := |
型別固定、編譯期就擋錯 | 所有變數 |
| struct | 描述「一筆資料長怎樣」 | User、Account、Order… |
| 大小寫 | 大寫=公開、小寫=私有 | 設計 API、跨 package 呼叫 |
error 回傳 |
沒有 try/catch,靠 if err != nil |
每一個 DB / 邏輯操作 |
| interface | 隱式實作的合約,解耦實作 | 報價服務、可抽換相依 |
指標 * / & |
要改本體或避免複製就用指標 | service / repository / sqlx |
下一步 → 第 1 章:專案初始化 + 用 docker-compose 起資料庫
我們會正式建立 no-logic-trade 專案、規劃完整目錄結構、用 docker-compose 把 PostgreSQL 跑進容器,並讓 Go app 用本機 go run 跑起來。
練習用的
playground/資料夾現在可以刪掉了,正式專案下一章重新建。