第 1 章:專案初始化 + 用 docker-compose 起資料庫

第 1 章:專案初始化 + 用 docker-compose 起資料庫

這一章在做什麼? 我們要正式建立 no-logic-trade 專案:用 go mod 初始化、規劃好整個專案的目錄結構、用 docker-compose 把 PostgreSQL 跑進容器(這樣你不用在本機安裝資料庫),然後寫一支最小的 Gin 伺服器、用本機 go run 跑起來。

為什麼這樣安排? 開發階段我們刻意只把資料庫放進容器,Go app 仍用本機 go run 執行 —— 因為 app 是你會一直改的程式碼,留在本機改完馬上重跑最快;而資料庫是「安裝麻煩、要保持版本一致」的東西,丟進容器最省事。等到最後一章(第 9 章)要做「一鍵啟動的完整專案」時,才會把 app 也容器化。


步驟 1:建立專案資料夾並初始化 module

  1. 建立專案資料夾並進入:
mkdir no-logic-trade
cd no-logic-trade

如果你現在就在我們要用的資料夾裡,這步可略過。

  1. 初始化 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 initnpm 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.gocmd/ 放「可執行程式的進入點」。我們的後端 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 getnpm installgo.sumpackage-lock.json(鎖定每個套件版本的雜湊值,確保每次裝的都是同一份)。 一樣,Go 沒有 node_modules 塞在你的專案裡 —— 套件下載到全域快取,go.mod 控制版本。


步驟 4:設定管理 —— .env.env.example.gitignore

後端有很多設定(資料庫帳密、JWT 密鑰、port)不該寫死在程式碼,也不該進版控。慣例是放進 .env

  1. 建立 .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
  1. 建立 .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
  1. 建立 .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 把它包成型別明確的 Config struct,誰用錯欄位名稱編譯就會擋下來。


步驟 6:用 docker-compose 把 PostgreSQL 跑進容器

接著我們不在本機裝 PostgreSQL,而是用 docker-compose 把它跑在容器裡。

需要先安裝 Docker Desktop(macOS / Windows)或 Docker Engine(Linux)。安裝後確認:docker --versiondocker 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 裡的帳號密碼只有在資料目錄是空的(第一次啟動)時才會生效。如果你之後改了 .envDB_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」最順?

這是這份教學刻意的安排,理由:

  1. 改 code 立即生效:改完一行 go run 重跑只要幾秒。如果 app 也在容器裡,每次改都得重新 build image、重啟容器,慢又煩。
  2. 資料庫最適合容器:DB 是「安裝麻煩、要顧版本一致、會累積狀態」的東西。丟進容器後,你不用在本機裝 PostgreSQL,團隊每個人的 DB 版本都一樣,想重來就 down -v 砍掉重建。
  3. app 是常動的程式碼:開發期它一直在變,留在本機最靈活(可直接用 debugger、看堆疊、熱重啟)。
  4. 連線很單純: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 四張核心表。