這是 Go 語言從零到 Web 應用系列的第七篇。上一篇我們建立了第一個 API Server,但所有程式碼都在一個檔案裡。這篇要來學如何組織一個可維護的專案結構。
專案目錄結構
Go 社群雖然沒有強制的專案結構,但有一個被廣泛採用的慣例。以下是我們的 Web 應用將使用的結構:
bookapi/
├── cmd/
│ └── server/
│ └── main.go # 程式進入點
├── internal/
│ ├── handler/
│ │ └── book.go # HTTP handler
│ ├── middleware/
│ │ ├── logging.go # 日誌中間件
│ │ └── recovery.go # Panic recovery 中間件
│ ├── model/
│ │ └── book.go # 資料模型
│ └── store/
│ └── book.go # 資料存取層
├── go.mod
├── go.sum
└── .env # 環境變數(不進版控)
目錄說明
cmd/:可執行程式的進入點。每個子目錄對應一個執行檔internal/:私有程式碼,Go 編譯器會阻止外部套件引用這個目錄下的程式碼handler/:HTTP 請求處理器middleware/:中間件model/:資料模型定義store/:資料存取邏輯
初始化專案
mkdir bookapi && cd bookapi
go mod init bookapi
定義資料模型
internal/model/book.go
package model
import "time"
type Book struct {
ID int `json:"id"`
Title string `json:"title"`
Author string `json:"author"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
type CreateBookRequest struct {
Title string `json:"title"`
Author string `json:"author"`
}
func (r CreateBookRequest) Validate() map[string]string {
errors := make(map[string]string)
if r.Title == "" {
errors["title"] = "title is required"
}
if r.Author == "" {
errors["author"] = "author is required"
}
return errors
}
type UpdateBookRequest struct {
Title *string `json:"title"`
Author *string `json:"author"`
}
使用獨立的 Request 型別而非直接用 Book,可以明確區分「客戶端送來的資料」和「系統內部的資料」。UpdateBookRequest 中的指標欄位讓我們能區分「沒有提供」和「設為空字串」。
資料存取層
internal/store/book.go
package store
import (
"bookapi/internal/model"
"errors"
"sync"
"time"
)
var ErrNotFound = errors.New("not found")
type BookStore struct {
mu sync.RWMutex
books []model.Book
nextID int
}
func NewBookStore() *BookStore {
return &BookStore{nextID: 1}
}
func (s *BookStore) List() []model.Book {
s.mu.RLock()
defer s.mu.RUnlock()
result := make([]model.Book, len(s.books))
copy(result, s.books)
return result
}
func (s *BookStore) GetByID(id int) (model.Book, error) {
s.mu.RLock()
defer s.mu.RUnlock()
for _, b := range s.books {
if b.ID == id {
return b, nil
}
}
return model.Book{}, ErrNotFound
}
func (s *BookStore) Create(req model.CreateBookRequest) model.Book {
s.mu.Lock()
defer s.mu.Unlock()
now := time.Now()
book := model.Book{
ID: s.nextID,
Title: req.Title,
Author: req.Author,
CreatedAt: now,
UpdatedAt: now,
}
s.nextID++
s.books = append(s.books, book)
return book
}
func (s *BookStore) Update(id int, req model.UpdateBookRequest) (model.Book, error) {
s.mu.Lock()
defer s.mu.Unlock()
for i := range s.books {
if s.books[i].ID == id {
if req.Title != nil {
s.books[i].Title = *req.Title
}
if req.Author != nil {
s.books[i].Author = *req.Author
}
s.books[i].UpdatedAt = time.Now()
return s.books[i], nil
}
}
return model.Book{}, ErrNotFound
}
func (s *BookStore) Delete(id int) error {
s.mu.Lock()
defer s.mu.Unlock()
for i := range s.books {
if s.books[i].ID == id {
s.books = append(s.books[:i], s.books[i+1:]...)
return nil
}
}
return ErrNotFound
}
注意:
- 使用
sync.RWMutex而非sync.Mutex,讀取操作可以並行 ErrNotFound是一個 sentinel error,讓 handler 層能判斷是「找不到」還是「其他錯誤」- 目前用記憶體儲存,下一篇會換成 SQLite
中間件(Middleware)
中間件是在 Handler 前後執行的邏輯,用來處理跨 handler 的共通功能。
中間件的概念
Request → [Logging] → [Recovery] → [Handler] → Response
在 Go 中,中間件就是一個接受 http.Handler 並回傳 http.Handler 的函式:
type Middleware func(http.Handler) http.Handler
internal/middleware/logging.go
package middleware
import (
"log"
"net/http"
"time"
)
type wrappedWriter struct {
http.ResponseWriter
statusCode int
}
func (w *wrappedWriter) WriteHeader(statusCode int) {
w.statusCode = statusCode
w.ResponseWriter.WriteHeader(statusCode)
}
func Logging(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
start := time.Now()
wrapped := &wrappedWriter{ResponseWriter: w, statusCode: http.StatusOK}
next.ServeHTTP(wrapped, r)
log.Printf(
"%s %s %d %v",
r.Method,
r.URL.Path,
wrapped.statusCode,
time.Since(start),
)
})
}
wrappedWriter 包裝了原始的 ResponseWriter,讓我們能捕獲 handler 設定的狀態碼。
internal/middleware/recovery.go
package middleware
import (
"log"
"net/http"
)
func Recovery(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
defer func() {
if err := recover(); err != nil {
log.Printf("panic recovered: %v", err)
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
}
}()
next.ServeHTTP(wrapped, r)
})
}
Recovery 中間件防止單一請求的 panic 導致整個伺服器崩潰。
串接中間件
// 手動串接
handler := middleware.Logging(middleware.Recovery(mux))
// 或建立一個 Chain 輔助函式
func Chain(handler http.Handler, middlewares ...func(http.Handler) http.Handler) http.Handler {
for i := len(middlewares) - 1; i >= 0; i-- {
handler = middlewares[i](handler)
}
return handler
}
// 使用
handler := Chain(mux, middleware.Logging, middleware.Recovery)
HTTP Handler
internal/handler/book.go
package handler
import (
"bookapi/internal/model"
"bookapi/internal/store"
"encoding/json"
"errors"
"net/http"
"strconv"
)
type BookHandler struct {
store *store.BookStore
}
func NewBookHandler(s *store.BookStore) *BookHandler {
return &BookHandler{store: s}
}
func (h *BookHandler) RegisterRoutes(mux *http.ServeMux) {
mux.HandleFunc("GET /books", h.List)
mux.HandleFunc("POST /books", h.Create)
mux.HandleFunc("GET /books/{id}", h.Get)
mux.HandleFunc("PUT /books/{id}", h.Update)
mux.HandleFunc("DELETE /books/{id}", h.Delete)
}
func (h *BookHandler) List(w http.ResponseWriter, r *http.Request) {
books := h.store.List()
writeJSON(w, http.StatusOK, books)
}
func (h *BookHandler) Get(w http.ResponseWriter, r *http.Request) {
id, err := strconv.Atoi(r.PathValue("id"))
if err != nil {
writeError(w, http.StatusBadRequest, "Invalid book ID")
return
}
book, err := h.store.GetByID(id)
if err != nil {
if errors.Is(err, store.ErrNotFound) {
writeError(w, http.StatusNotFound, "Book not found")
return
}
writeError(w, http.StatusInternalServerError, "Internal error")
return
}
writeJSON(w, http.StatusOK, book)
}
func (h *BookHandler) Create(w http.ResponseWriter, r *http.Request) {
var req model.CreateBookRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "Invalid JSON")
return
}
if errs := req.Validate(); len(errs) > 0 {
writeJSON(w, http.StatusBadRequest, map[string]any{
"error": "Validation failed",
"fields": errs,
})
return
}
book := h.store.Create(req)
writeJSON(w, http.StatusCreated, book)
}
func (h *BookHandler) Update(w http.ResponseWriter, r *http.Request) {
id, err := strconv.Atoi(r.PathValue("id"))
if err != nil {
writeError(w, http.StatusBadRequest, "Invalid book ID")
return
}
var req model.UpdateBookRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "Invalid JSON")
return
}
book, err := h.store.Update(id, req)
if err != nil {
if errors.Is(err, store.ErrNotFound) {
writeError(w, http.StatusNotFound, "Book not found")
return
}
writeError(w, http.StatusInternalServerError, "Internal error")
return
}
writeJSON(w, http.StatusOK, book)
}
func (h *BookHandler) Delete(w http.ResponseWriter, r *http.Request) {
id, err := strconv.Atoi(r.PathValue("id"))
if err != nil {
writeError(w, http.StatusBadRequest, "Invalid book ID")
return
}
if err := h.store.Delete(id); err != nil {
if errors.Is(err, store.ErrNotFound) {
writeError(w, http.StatusNotFound, "Book not found")
return
}
writeError(w, http.StatusInternalServerError, "Internal error")
return
}
w.WriteHeader(http.StatusNoContent)
}
// --- JSON 輔助函式 ---
func writeJSON(w http.ResponseWriter, status int, data any) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
json.NewEncoder(w).Encode(data)
}
func writeError(w http.ResponseWriter, status int, message string) {
writeJSON(w, status, map[string]string{"error": message})
}
路由註冊模式
每個 handler 群組負責註冊自己的路由(RegisterRoutes),而非在 main 中集中管理。這樣當 handler 增加時,main.go 不會越來越肥。
環境變數管理
使用 os 套件
import "os"
port := os.Getenv("PORT")
if port == "" {
port = "8080"
}
建立 Config 結構
type Config struct {
Port string
Env string
}
func LoadConfig() Config {
port := os.Getenv("PORT")
if port == "" {
port = "8080"
}
env := os.Getenv("APP_ENV")
if env == "" {
env = "development"
}
return Config{
Port: port,
Env: env,
}
}
.env 檔案
開發時可以建立 .env 檔案(記得加到 .gitignore):
PORT=8080
APP_ENV=development
使用第三方套件 github.com/joho/godotenv 來載入:
go get github.com/joho/godotenv
import "github.com/joho/godotenv"
func init() {
godotenv.Load() // 載入 .env 檔案
}
程式進入點
cmd/server/main.go
package main
import (
"bookapi/internal/handler"
"bookapi/internal/middleware"
"bookapi/internal/model"
"bookapi/internal/store"
"fmt"
"log"
"net/http"
"os"
"time"
)
func main() {
// 設定
port := os.Getenv("PORT")
if port == "" {
port = "8080"
}
// 初始化 store
bookStore := store.NewBookStore()
// 加入範例資料
bookStore.Create(model.CreateBookRequest{
Title: "The Go Programming Language",
Author: "Donovan & Kernighan",
})
// 建立路由
mux := http.NewServeMux()
// 註冊 handler
bookHandler := handler.NewBookHandler(bookStore)
bookHandler.RegisterRoutes(mux)
// 首頁
mux.HandleFunc("GET /", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
fmt.Fprintf(w, `{"message":"Book API","version":"1.0"}`)
})
// 套用中間件
var h http.Handler = mux
h = middleware.Logging(h)
h = middleware.Recovery(h)
// 建立伺服器
server := &http.Server{
Addr: ":" + port,
Handler: h,
ReadTimeout: 15 * time.Second,
WriteTimeout: 15 * time.Second,
IdleTimeout: 60 * time.Second,
}
log.Printf("Server starting on :%s", port)
log.Fatal(server.ListenAndServe())
}
執行
go run ./cmd/server
請求參數解析
Query Parameters
// GET /books?page=2&limit=10&sort=title
func (h *BookHandler) List(w http.ResponseWriter, r *http.Request) {
query := r.URL.Query()
page, _ := strconv.Atoi(query.Get("page"))
if page < 1 {
page = 1
}
limit, _ := strconv.Atoi(query.Get("limit"))
if limit < 1 || limit > 100 {
limit = 10
}
sort := query.Get("sort")
// ...
}
Path Parameters(Go 1.22+)
// GET /books/{id}
id := r.PathValue("id")
// GET /users/{userID}/books/{bookID}
userID := r.PathValue("userID")
bookID := r.PathValue("bookID")
Form Data
func formHandler(w http.ResponseWriter, r *http.Request) {
// 解析表單資料
r.ParseForm()
name := r.FormValue("name")
email := r.FormValue("email")
// ...
}
下一步
現在我們有了一個結構清晰的 Web 專案,但資料存在記憶體中,伺服器重啟就消失了。下一篇我們將整合 SQLite 資料庫,讓資料能持久化儲存。