第 1 章:專案初始化 + 用 docker-compose 起資料庫
這一章在做什麼? 我們要正式建立 no-logic-trade 專案:用 go mod 初始化、規劃好整個專案的目錄結構、用 docker-compose 把 PostgreSQL 跑進容器(這樣你不用在本機安裝資料庫),然後寫一支最小的 Gin 伺服器、用本機 go run 跑起來。
為什麼這樣安排? 開發階段我們刻意只把資料庫放進容器,Go app 仍用本機 go run 執行 —— 因為 app 是你會一直改的程式碼,留在本機改完馬上重跑最快;而資料庫是「安裝麻煩、要保持版本一致」的東西,丟進容器最省事。等到最後一章(第 9 章)要做「一鍵啟動的完整專案」時,才會把 app 也容器化。
步驟 1:建立專案資料夾並初始化 module
- 建立專案資料夾並進入:
mkdir no-logic-trade
cd no-logic-trade
如果你現在就在我們要用的資料夾裡,這步可略過。
- 初始化 Go module:
go mod init no-logic-trade
執行後會產生 go.mod:
// go.mod
module no-logic-trade
go 1.22
module no-logic-trade:這個 module 的名字,同時也是所有 import 路徑的前綴。等等你會看到import "no-logic-trade/internal/config"這種寫法 —— 前面的no-logic-trade就是這裡來的。
🔁 JS 對照
go mod init≈npm init。差別是 Go 的 module 名稱會變成 import 路徑前綴。 正式要發佈、給別人go get的專案,慣例用網址形式(例如github.com/你的帳號/no-logic-trade)。我們這份教學是本機練習,用簡短的no-logic-trade就好,import 比較好讀。
步驟 2:規劃完整專案目錄結構
這是我們整個專案完成後的樣子。現在不用一次建好,每一章會帶你建到當下需要的檔案。先看一眼全貌,你會比較有方向感:
no-logic-trade/
├── cmd/
│ └── api/
│ └── main.go # ⭐進入點:把所有零件組裝起來、啟動 server
│
├── internal/ # 本專案私有程式碼(外部無法 import,見下方說明)
│ ├── config/
│ │ └── config.go # 讀 .env / 環境變數 (本章)
│ ├── database/
│ │ ├── database.go # 建立 sqlx 連線 (第 2 章)
│ │ └── migrate.go # 自寫的 migrator (第 2 章)
│ ├── model/
│ │ └── model.go # User/Account/Holding/Order 等 struct (第 2 章起)
│ ├── repository/ # 只負責「碰資料庫」的一層 (第 2 章起)
│ │ ├── user_repository.go
│ │ ├── account_repository.go
│ │ ├── holding_repository.go
│ │ └── order_repository.go
│ ├── service/ # 業務邏輯層 (第 3 章起)
│ │ ├── auth_service.go
│ │ ├── account_service.go
│ │ ├── quote_service.go
│ │ ├── order_service.go
│ │ └── portfolio_service.go
│ ├── handler/ # 接 HTTP 請求/回應的一層 (第 3 章起)
│ │ ├── handler.go # 共用:統一回應格式、錯誤輔助
│ │ ├── auth_handler.go
│ │ ├── account_handler.go
│ │ ├── quote_handler.go
│ │ ├── order_handler.go
│ │ └── portfolio_handler.go
│ └── middleware/
│ └── auth.go # JWT 驗證中介層 (第 3 章)
│
├── migrations/ # 建表用的 .sql (第 2 章起)
│ ├── 001_create_users.sql
│ ├── 002_create_accounts.sql
│ ├── 003_create_holdings.sql
│ └── 004_create_orders.sql
│
├── .env # 真正的環境變數(含密碼,不進版控)(本章)
├── .env.example # 範例環境變數(進版控,給別人參考) (本章)
├── .gitignore (本章)
├── docker-compose.yml # 開發期只跑 PostgreSQL (本章)
├── Dockerfile # 把 app 容器化 (第 9 章)
├── go.mod
└── go.sum # 套件雜湊鎖定(裝套件後自動產生)
為什麼這樣分?cmd/ 和 internal/ 是什麼?
這是 Go 社群很常見的專案佈局,兩個關鍵慣例:
cmd/api/main.go:cmd/放「可執行程式的進入點」。我們的後端 API 叫api,所以是cmd/api/main.go。如果哪天要多一個指令(例如離線跑帳的小工具),就再開cmd/worker/main.go。internal/:這是 Go 語言內建、會強制執行的規則 —— 放在internal/底下的 package,只有本專案內的程式碼可以 import,外部專案 import 會直接編譯失敗。等於幫你把「實作細節」鎖在專案內,只有你自己用。
三層架構:handler → service → repository
我們的業務程式碼會分三層,這是後端常見的職責分離:
| 層 | 負責什麼 | 類比 |
|---|---|---|
| handler | 接 HTTP 請求、解析輸入、呼叫 service、回 JSON。不寫業務邏輯。 | Express 的 route handler (req,res)=>{} |
| service | 真正的業務邏輯(檢查餘額、計算持股均價、決定能不能下單)。 | 你 Node 專案裡的 services/ 或 controller 的核心邏輯 |
| repository | 只跟資料庫對話(SQL 查詢、寫入)。不寫業務判斷。 | Node 的 DAO / models/ 查詢層 |
資料流向是:HTTP 請求 → handler → service → repository → 資料庫,回應再原路返回。這樣分的好處:業務邏輯不被 HTTP 細節或 SQL 綁住,好讀、好測、好改。
🔁 JS 對照 很多 Node/Express 專案會有
routes/、controllers/、services/、models/的分層,概念一模一樣。Go 只是換個名字(handler / service / repository)並用資料夾 = package 來切。
步驟 3:安裝這一章需要的套件
這一章會用到兩個套件:Gin(Web 框架)和 godotenv(讀 .env)。
go get github.com/gin-gonic/gin
go get github.com/joho/godotenv
執行後,go.mod 會多出 require 區塊,並產生 go.sum。
🔁 JS 對照
go get≈npm install。go.sum≈package-lock.json(鎖定每個套件版本的雜湊值,確保每次裝的都是同一份)。 一樣,Go 沒有node_modules塞在你的專案裡 —— 套件下載到全域快取,go.mod控制版本。
步驟 4:設定管理 —— .env、.env.example、.gitignore
後端有很多設定(資料庫帳密、JWT 密鑰、port)不該寫死在程式碼,也不該進版控。慣例是放進 .env。
- 建立
.env(這份含密碼,等等會用.gitignore排除,不進版控):
# .env
# ── 應用程式 ──────────────────────────────
APP_PORT=8080
# ── PostgreSQL ───────────────────────────
DB_HOST=localhost
DB_PORT=5432
DB_USER=nolologic
DB_PASSWORD=nolologic_secret
DB_NAME=nolologic
DB_SSLMODE=disable
# ── JWT(第 3 章會用到)──────────────────
JWT_SECRET=dev-secret-change-me
- 建立
.env.example(這份不含真密碼、會進版控,讓接手的人知道有哪些設定要填):
# .env.example
APP_PORT=8080
DB_HOST=localhost
DB_PORT=5432
DB_USER=your_db_user
DB_PASSWORD=your_db_password
DB_NAME=your_db_name
DB_SSLMODE=disable
JWT_SECRET=please-change-me
- 建立
.gitignore:
# .gitignore
# 環境變數(含密碼,絕對不要進版控)
.env
# 編譯產物
/bin/
/api
*.exe
# 作業系統雜物
.DS_Store
🔁 JS 對照 這完全對應 Node 的
dotenv用法:.env放設定、用.gitignore排除、附一份.env.example當範本。唯一差別是等等我們在 Go 裡要主動呼叫 godotenv 載入.env(Node 通常是require("dotenv").config(),一樣是主動載入)。
💡 一個小巧思:docker-compose 會自動讀取同目錄的
.env做變數替換。所以我們等下的docker-compose.yml裡寫${DB_USER},它會自動從這份.env取值。也就是說 —— 同一份.env同時餵給了 Go app 和 docker-compose,不用維護兩份設定。
步驟 5:寫 config 套件,把設定讀進來
建立 internal/config/config.go:
// internal/config/config.go
package config
import (
"log"
"os"
"github.com/joho/godotenv"
)
// Config 把所有設定集中成一個 struct,整個專案共用
type Config struct {
AppPort string
DBHost string
DBPort string
DBUser string
DBPassword string
DBName string
DBSSLMode string
JWTSecret string
}
// Load 讀取 .env(若有)與系統環境變數,組成 Config
func Load() *Config {
// 嘗試載入 .env。找不到檔案不算錯(正式環境常常直接用系統環境變數),
// 所以這裡只印個提示,不中斷程式。
if err := godotenv.Load(); err != nil {
log.Println("提示:找不到 .env 檔,改用系統環境變數")
}
return &Config{
AppPort: getEnv("APP_PORT", "8080"),
DBHost: getEnv("DB_HOST", "localhost"),
DBPort: getEnv("DB_PORT", "5432"),
DBUser: getEnv("DB_USER", "nolologic"),
DBPassword: getEnv("DB_PASSWORD", "nolologic_secret"),
DBName: getEnv("DB_NAME", "nolologic"),
DBSSLMode: getEnv("DB_SSLMODE", "disable"),
JWTSecret: getEnv("JWT_SECRET", "dev-secret-change-me"),
}
}
// getEnv 讀一個環境變數,沒設定就回傳 fallback 預設值
func getEnv(key, fallback string) string {
if value, ok := os.LookupEnv(key); ok {
return value
}
return fallback
}
逐段解釋:
type Config struct { ... }:把所有設定收進一個 struct(回顧第 0 章步驟 5)。之後任何地方需要設定,就拿這個*Config,不用到處讀環境變數。func Load() *Config:回傳一個指標*Config(第 0 章步驟 9 的概念)。設定只需要一份,傳指標可避免到處複製整包設定。godotenv.Load():把.env的內容塞進「環境變數」。注意我們不把找不到.env當成錯誤,因為部署到正式環境時通常沒有.env、而是直接設系統環境變數 —— 這是很實務的寫法。getEnv(key, fallback):自己寫的小工具,環境變數有就用、沒有就用預設值。os.LookupEnv回傳兩個值(值, 是否存在),這又是 Go「多回傳值」的典型用法。
🔁 JS 對照
require("dotenv").config(); const config = { appPort: process.env.APP_PORT || "8080", dbHost: process.env.DB_HOST || "localhost", // ... };
getEnv(key, fallback)就是 Go 版的process.env.X || "預設值"。差別是 Go 把它包成型別明確的Configstruct,誰用錯欄位名稱編譯就會擋下來。
步驟 6:用 docker-compose 把 PostgreSQL 跑進容器
接著我們不在本機裝 PostgreSQL,而是用 docker-compose 把它跑在容器裡。
需要先安裝 Docker Desktop(macOS / Windows)或 Docker Engine(Linux)。安裝後確認:
docker --version與docker compose version都有輸出即可。
建立 docker-compose.yml:
# docker-compose.yml
services:
db:
image: postgres:16-alpine
container_name: nlt-postgres
restart: unless-stopped
environment:
POSTGRES_USER: ${DB_USER}
POSTGRES_PASSWORD: ${DB_PASSWORD}
POSTGRES_DB: ${DB_NAME}
ports:
- "${DB_PORT}:5432"
volumes:
- pgdata:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${DB_USER} -d ${DB_NAME}"]
interval: 5s
timeout: 5s
retries: 5
volumes:
pgdata:
逐行說明(第一次接觸 Docker,每一行都解釋):
services::一份 compose 檔可以定義多個「服務(容器)」。現在只有一個叫db。db::我們替這個服務取的名字,叫db。(第 9 章 app 容器要連它時,主機名就是這個db。)image: postgres:16-alpine:用官方的 PostgreSQL 16 映像檔。alpine是一個超精簡的 Linux 基底,image 比較小、下載快。container_name: nlt-postgres:給容器一個固定好記的名字(不設的話 Docker 會自動取一個亂亂的名字)。restart: unless-stopped:容器掛掉會自動重啟,除非你手動停它。開發時很方便。environment::設定容器內的環境變數。PostgreSQL 官方映像檔第一次啟動時,會用這三個變數自動建立資料庫與帳號:POSTGRES_USER: ${DB_USER}→ 建立的使用者(${DB_USER}自動從.env取,這裡是nolologic)。POSTGRES_PASSWORD: ${DB_PASSWORD}→ 該使用者的密碼。POSTGRES_DB: ${DB_NAME}→ 自動建立一個同名資料庫。
ports: - "${DB_PORT}:5432":埠對應,格式是主機端口:容器端口。PostgreSQL 在容器內聽5432,我們把它對應到主機的5432(${DB_PORT})。正因為有這行,你本機用go run跑的 app 才連得到容器裡的 DB(連localhost:5432)。volumes: - pgdata:/var/lib/postgresql/data:把資料庫實際存資料的目錄/var/lib/postgresql/data,掛到一個叫pgdata的具名磁碟區。有了它,容器刪掉重建資料也不會不見(資料持久化)。healthcheck::定期跑pg_isready檢查資料庫是否已經可以接受連線。docker compose ps會顯示 healthy/unhealthy,第 9 章 app 容器也會靠它「等 DB 準備好再啟動」。- 最底下的
volumes: pgdata::宣告我們上面用到的具名磁碟區。
⚠️ 重要小提醒:
environment裡的帳號密碼只有在資料目錄是空的(第一次啟動)時才會生效。如果你之後改了.env的DB_USER/DB_PASSWORD卻發現連不上,是因為舊資料還在pgdata裡。要整個重來,用docker compose down -v(-v會連同pgdata一起刪掉),再up一次。
現在啟動資料庫:
docker compose up -d
up:依docker-compose.yml建立並啟動服務。-d:detached(背景執行),不會佔住你的終端機。
第一次會下載 image,需要一點時間。完成後確認狀態:
docker compose ps
預期看到 nlt-postgres 狀態為 Up 且 (healthy)(healthy 可能要等幾秒)。
為什麼開發階段「只有 DB 進容器、app 用本機 go run」最順?
這是這份教學刻意的安排,理由:
- 改 code 立即生效:改完一行
go run重跑只要幾秒。如果 app 也在容器裡,每次改都得重新 build image、重啟容器,慢又煩。 - 資料庫最適合容器:DB 是「安裝麻煩、要顧版本一致、會累積狀態」的東西。丟進容器後,你不用在本機裝 PostgreSQL,團隊每個人的 DB 版本都一樣,想重來就
down -v砍掉重建。 - app 是常動的程式碼:開發期它一直在變,留在本機最靈活(可直接用 debugger、看堆疊、熱重啟)。
- 連線很單純:app 在本機、DB 在容器、靠
localhost:5432對接,不用處理容器之間的網路。
等到第 9 章,code 穩定、要交付「一鍵啟動的完整專案」時,build image 的成本才划算 —— 那時我們才把 app 也容器化。
步驟 7:寫最小的 Gin 伺服器(/health)
建立進入點 cmd/api/main.go:
// cmd/api/main.go
package main
import (
"log"
"net/http"
"github.com/gin-gonic/gin"
"no-logic-trade/internal/config"
)
func main() {
// 1) 載入設定
cfg := config.Load()
// 2) 建立 Gin 引擎(內建 logger + panic recovery)
r := gin.Default()
// 3) 健康檢查路由:用來確認服務有活著
r.GET("/health", func(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{
"status": "ok",
})
})
// 4) 啟動服務,監聽設定檔指定的 port
addr := ":" + cfg.AppPort
log.Printf("🚀 server 啟動於 http://localhost%s", addr)
if err := r.Run(addr); err != nil {
// r.Run 失敗(例如 port 被占用)就讓程式以非 0 結束
log.Fatalf("server 啟動失敗: %v", err)
}
}
逐段解釋:
package main+func main():這是可執行程式的進入點(第 0 章步驟 3)。import "no-logic-trade/internal/config":import 我們自己寫的 config 套件。注意路徑前綴no-logic-trade正是go.mod裡的 module 名。import 後用config.Load()呼叫(config是該套件的 package 名)。cfg := config.Load():把設定載進來。gin.Default():建立 Gin 引擎(等同express())。r.GET("/health", ...):註冊一條健康檢查路由。/health是後端慣例,給負載平衡器、監控、或你自己確認「服務還活著嗎」用。addr := ":" + cfg.AppPort:組出監聽位址,例如:8080。Go 的字串用+串接。log.Fatalf:印出錯誤訊息並讓程式結束(exit code 非 0)。r.Run只有在出錯時才會回傳(正常情況它會一直擋住、持續服務)。
🔁 JS 對照
import { config } from "./internal/config"; // 我們自己的模組 const cfg = config.load(); const app = express(); app.get("/health", (req, res) => res.json({ status: "ok" })); app.listen(cfg.appPort, () => console.log("server up"));結構幾乎一致。Go 多了「import 路徑要帶 module 前綴」、「錯誤要自己判斷」這兩個習慣。
✅ 如何執行 / 驗證這一章成功
確保資料庫還在跑(步驟 6 的 docker compose up -d),然後啟動 Go app:
go run ./cmd/api
go run ./cmd/api:跑cmd/api這個**資料夾(package)**底下的程式。用./cmd/api而不是cmd/api/main.go,是因為它會自動納入該資料夾內所有.go檔(之後檔案變多也不用改指令)。
你應該看到 Gin 的啟動訊息,最後一行類似:
🚀 server 啟動於 http://localhost:8080
[GIN-debug] Listening and serving HTTP on :8080
驗證 1:打 health 端點
另開一個終端機:
curl http://localhost:8080/health
預期回應:
{"status":"ok"}
驗證 2:確認資料庫真的活著
直接連進容器裡的 PostgreSQL:
docker compose exec db psql -U nolologic -d nolologic -c "\conninfo"
docker compose exec db ...:在名為db的執行中容器裡執行指令。psql -U nolologic -d nolologic:用nolologic帳號連到nolologic資料庫。-c "\conninfo":執行一個指令後就退出,印出連線資訊。
預期看到類似:
You are connected to database "nolologic" as user "nolologic" ...
再看看目前有哪些表(現在應該是空的,下一章才建表):
docker compose exec db psql -U nolologic -d nolologic -c "\dt"
預期回應:
Did not find any relations.
看到這三個結果,代表:專案骨架立好了、設定能讀進來、Gin server 跑得起來、容器裡的 PostgreSQL 也活著並可連線。🎉
常用 Docker 指令小抄
docker compose up -d # 背景啟動 DB
docker compose ps # 看服務狀態
docker compose logs -f db # 持續看 DB log(Ctrl+C 離開)
docker compose stop # 停止(保留資料)
docker compose down # 停止並移除容器(保留 pgdata 資料)
docker compose down -v # 停止並移除容器 + 刪掉 pgdata(資料全清,重來用)
停掉 Go app:回到它執行的終端機按
Ctrl + C。DB 容器會繼續在背景跑,不影響。
這一章你完成了什麼
- 用
go mod init建立專案、規劃好cmd/+internal/的目錄結構。 - 用
.env+ godotenv 做設定管理,並讓 docker-compose 共用同一份.env。 - 用 docker-compose 把 PostgreSQL 跑進容器,理解了「為什麼開發期只有 DB 進容器、app 用本機
go run」。 - 寫出最小 Gin server,
/health驗證通過。
下一步 → 第 2 章:連線資料庫與 Migration(建表)
我們會用 sqlx 連上容器裡的 PostgreSQL,並自己寫一支小型 migrator 讀取 migrations/*.sql 依序建表,建立 users / accounts / holdings / orders 四張核心表。