用 Go 建立最小 HTTP Server: 專案結構與 Chi Router

DevOps 學習筆記

從零開始建立一個符合北美業界標準的最小 Go HTTP server, 包含專案結構說明與 chi router 使用

我的背景是 .NET Engineer, 因此筆記中會用 .NET 的對應概念來輔助釐清 Go 的設計

如果你也有 .NET 背景, 這些對比應該會有幫助

專案結構

整體配置

1
2
3
4
5
6
7
8
9
go-Api/
├── cmd/
│   └── server/
│       └── main.go
├── internal/
│   └── handler/
│       └── health.go
├── go.mod
└── go.sum

cmd/ 目錄

Go 規定每個可執行程式需要一個 package mainmain() 函數。cmd/ 是 Go 社群慣例, 用來放這個 repo 會產生幾個 binary 的入口

1
2
3
4
cmd/
├── server/    → HTTP server binary
├── worker/    → background job binary (future expansion)
└── migrate/   → DB migration binary (future expansion)

Binary 是什麼? Go 編譯後產生的單一可執行檔, 不需要任何 runtime 就能直接執行。對比 .NET 的 MyApp.dll (需要 runtime), Go binary 是完全獨立的。只有 package main 的檔案才產生 binary, internal/ 裡的程式碼是被編譯進去的, 不是獨立存在的

main.go 只負責讀設定、組裝元件、啟動 server, 不寫業務邏輯

對比 .NET:

Go .NET
cmd/server/main.go Program.cs

internal/ 目錄

Go 語言層級的封裝機制。internal/ 裡面的 package, 只有同一個 module 內的程式碼可以 import, 外部 module 完全無法使用

1
2
3
4
internal/
├── handler/      → equivalent to .NET Controller
├── service/      → equivalent to .NET Service (to be added)
└── repository/   → equivalent to .NET Repository (to be added)

internal/ 本身不是 Service 也不是 Repository, 它只是封裝邊界, 裡面自己決定怎麼分層

.NET 也有類似概念:

1
internal class UserService { }  // only accessible within the same assembly

Go 用目錄名稱來強制執行同樣的事, 編譯器層面直接阻擋外部 import

go.mod 與 go.sum

是什麼?

檔案 對比 .NET 用途
go.mod *.csproj 定義 module 名稱、Go 版本、依賴套件
go.sum packages.lock.json 每個依賴的 hash, 確保套件沒被竄改

go.sum 是自動產生的, 不需要手寫。兩個都要 commit 進 git, 這樣團隊每個人拿到的套件版本和內容完全一致

建立流程

1
2
3
4
5
6
7
8
9
mkdir my-project && cd my-project

go mod init github.com/yourname/my-project   # generates go.mod

# create directory structure, write code

go get github.com/go-chi/chi/v5              # add dependency, updates go.mod, generates go.sum

go mod tidy                                  # remove unused dependencies, add missing ones

Router 選擇

業界 Go backend 幾乎都會加一個輕量 router:

選項 特性
chi 輕量、完全相容 net/http、業界常用
gin 功能完整, 也很主流
標準庫 純練習可以, 業界幾乎都加 router

chinet/http 的薄包裝, 最接近 Go idiom, 又符合業界實際做法

實作

internal/handler/health.go

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
package handler

import (
    "encoding/json"
    "net/http"
)

func Health(w http.ResponseWriter, r *http.Request) {
    w.Header().Set("Content-Type", "application/json")
    w.WriteHeader(http.StatusOK)
    json.NewEncoder(w).Encode(map[string]string{"status": "ok"})
}

cmd/server/main.go

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
package main

import (
    "log"
    "net/http"

    "github.com/go-chi/chi/v5"
    "github.com/go-chi/chi/v5/middleware"
    "github.com/lou/go-api/internal/handler"
)

func main() {
    r := chi.NewRouter()

    r.Use(middleware.Logger)
    r.Use(middleware.Recoverer)

    r.Get("/healthz", handler.Health)

    log.Println("server starting on :8080")
    if err := http.ListenAndServe(":8080", r); err != nil {
        log.Fatalf("server failed: %v", err)
    }
}

/healthz 是什麼?

業界標配 endpoint, 用途:

  • Docker: HEALTHCHECK 指令定期打這個 endpoint, 確認 container 是否存活
  • Kubernetes: liveness probe / readiness probe
  • Load balancer: 確認 instance 可以接流量

Response 就是 200 OK + {"status": "ok"}

幾個 Go 特有的問題

為什麼 r 是 pointer (*http.Request)?

Go 傳參數預設是複製整份資料 (pass by value)。http.Request 是大 struct, 裡面含 headers、body、URL、context 等, 傳 pointer 只傳 8 bytes 的記憶體位址, 不複製資料

另外, middleware 可能會修改 request (例如加 context), 用 pointer 才能讓修改對所有後續 handler 可見

http.ResponseWriter 沒有 * 是因為它本身已經是 interface, interface 在 Go 內部就是 pointer, 不需要再加

為什麼 r 沒用到但不報錯?

Go 不允許宣告變數卻不用, 但函數參數例外r *http.Request 必須寫, 因為 chi 要求 handler 的函數簽名必須符合這個格式:

1
func(http.ResponseWriter, *http.Request)

這是一個契約, 類似 .NET 的 interface。chi 不管你用不用 r, 它只看函數簽名是否符合

map[string]string 是什麼?

Go 的 map 語法: map[KeyType]ValueType

1
2
3
4
5
// Go
map[string]string{"status": "ok"}

// C# equivalent
new Dictionary<string, string> { { "status", "ok" } }

轉成 JSON 就是 {"status": "ok"}

每個檔案都要 import? 不能集中嗎?

Go 沒有全域 import, 每個檔案必須自己宣告用到的東西。這是刻意的設計, 讓每個檔案的依賴一目瞭然

  • 標準庫寫短路徑: "net/http""encoding/json"
  • 第三方套件寫完整路徑: "github.com/go-chi/chi/v5"
  • 使用時只用最後一段的 package name: chi.NewRouter(), 不是 github.com/go-chi/chi/v5.NewRouter()

名稱衝突時可加別名:

1
2
3
4
import (
    chiMW "github.com/go-chi/chi/v5/middleware"
    myMW  "github.com/lou/go-api/internal/middleware"
)

Handler 對比 .NET Controller

1
2
3
4
5
6
// Go: handler is just a function
func Health(w http.ResponseWriter, r *http.Request) {
    w.Header().Set("Content-Type", "application/json")
    w.WriteHeader(http.StatusOK)
    json.NewEncoder(w).Encode(map[string]string{"status": "ok"})
}
1
2
3
4
5
6
7
8
9
// .NET: requires class + attribute
[ApiController]
[Route("api/[controller]")]
public class HealthController {
    [HttpGet]
    public IActionResult Get() {
        return Ok(new { status = "ok" });
    }
}

Go 沒有 class, handler 就是符合特定簽名的函數。Route 的設定在 main.go 集中管理, 不是用 attribute 分散在各個 controller 上

middleware 的作用

1
2
r.Use(middleware.Logger)
r.Use(middleware.Recoverer)
  • Logger: 每個 request 進來都自動 log, 對比 .NET 的 app.UseHttpLogging()
  • Recoverer: handler 如果 panic, 不讓整個 server 掛掉, 自動回 500, 對比 .NET 的 app.UseExceptionHandler()

本機測試

1
2
3
4
5
6
7
8
# start server
go run ./cmd/server/

# test in another terminal
curl http://localhost:8080/healthz

# expected response
{"status": "ok"}

References

0%