Go 04:实战 —— Web 服务(记账 REST API)
用 Go 重写 TypeScript 04 篇的记账 API,方便对比两种语言的风格。只用标准库(Go 1.22+ 的 net/http 已支持方法和路径参数路由),最后再介绍常用框架。
1. 项目结构(Go 社区常见布局)
go-accounting/
├── go.mod
├── cmd/
│ └── server/
│ └── main.go # 入口
└── internal/ # internal 下的包只能被本模块引用
├── model/record.go
├── store/
│ ├── store.go # 接口 + 内存实现
│ └── sqlite.go # 数据库实现
├── handler/record.go # HTTP 处理
└── middleware/middleware.go
mkdir go-accounting && cd go-accounting
go mod init example.com/accounting
2. 模型与校验
internal/model/record.go:
package model
import (
"errors"
"regexp"
"time"
)
type Record struct {
ID int64 `json:"id"`
Type string `json:"type"` // income | expense
Category string `json:"category"`
Amount float64 `json:"amount"`
Note string `json:"note,omitempty"`
Date string `json:"date"` // YYYY-MM-DD
CreatedAt time.Time `json:"createdAt"`
}
type RecordInput struct {
Type string `json:"type"`
Category string `json:"category"`
Amount float64 `json:"amount"`
Note string `json:"note"`
Date string `json:"date"`
}
var dateRe = regexp.MustCompile(`^\d{4}-\d{2}-\d{2}$`)
// Validate 返回所有字段错误,key 为字段名
func (in RecordInput) Validate() map[string]string {
errs := map[string]string{}
if in.Type != "income" && in.Type != "expense" {
errs["type"] = "must be income or expense"
}
if in.Category == "" || len(in.Category) > 30 {
errs["category"] = "length must be 1-30"
}
if in.Amount <= 0 {
errs["amount"] = "must be positive"
}
if !dateRe.MatchString(in.Date) {
errs["date"] = "must be YYYY-MM-DD"
}
return errs
}
type Summary struct {
Income float64 `json:"income"`
Expense float64 `json:"expense"`
Balance float64 `json:"balance"`
ByCategory map[string]float64 `json:"byCategory"`
}
var ErrNotFound = errors.New("record not found")
对比:TS 用 zod 一处定义类型+校验;Go 通常手写
Validate(),或使用go-playground/validator通过 struct tag 校验(validate:"required,gt=0")。
3. 存储层(接口 + 内存实现)
internal/store/store.go:
package store
import (
"context"
"strings"
"sync"
"time"
"example.com/accounting/internal/model"
)
type Filter struct {
Month string
Type string
}
type RecordStore interface {
List(ctx context.Context, f Filter) ([]model.Record, error)
Get(ctx context.Context, id int64) (model.Record, error)
Create(ctx context.Context, in model.RecordInput) (model.Record, error)
Delete(ctx context.Context, id int64) error
}
type MemoryStore struct {
mu sync.RWMutex // HTTP 服务是并发的,必须加锁
seq int64
items map[int64]model.Record
}
func NewMemoryStore() *MemoryStore {
return &MemoryStore{items: make(map[int64]model.Record)}
}
func (s *MemoryStore) List(_ context.Context, f Filter) ([]model.Record, error) {
s.mu.RLock()
defer s.mu.RUnlock()
result := []model.Record{} // 返回 [] 而不是 null
for _, r := range s.items {
if f.Month != "" && !strings.HasPrefix(r.Date, f.Month) {
continue
}
if f.Type != "" && r.Type != f.Type {
continue
}
result = append(result, r)
}
return result, nil
}
func (s *MemoryStore) Get(_ context.Context, id int64) (model.Record, error) {
s.mu.RLock()
defer s.mu.RUnlock()
r, ok := s.items[id]
if !ok {
return model.Record{}, model.ErrNotFound
}
return r, nil
}
func (s *MemoryStore) Create(_ context.Context, in model.RecordInput) (model.Record, error) {
s.mu.Lock()
defer s.mu.Unlock()
s.seq++
r := model.Record{
ID: s.seq, Type: in.Type, Category: in.Category, Amount: in.Amount,
Note: in.Note, Date: in.Date, CreatedAt: time.Now(),
}
s.items[r.ID] = r
return r, nil
}
func (s *MemoryStore) Delete(_ context.Context, id int64) error {
s.mu.Lock()
defer s.mu.Unlock()
if _, ok := s.items[id]; !ok {
return model.ErrNotFound
}
delete(s.items, id)
return nil
}
4. HTTP 处理层
internal/handler/record.go:
package handler
import (
"encoding/json"
"errors"
"net/http"
"strconv"
"example.com/accounting/internal/model"
"example.com/accounting/internal/store"
)
type RecordHandler struct {
store store.RecordStore
}
func NewRecordHandler(s store.RecordStore) *RecordHandler {
return &RecordHandler{store: s}
}
// Register 注册路由(Go 1.22+ 支持 "METHOD /path/{param}")
func (h *RecordHandler) Register(mux *http.ServeMux) {
mux.HandleFunc("GET /api/records", h.list)
mux.HandleFunc("POST /api/records", h.create)
mux.HandleFunc("GET /api/records/{id}", h.get)
mux.HandleFunc("DELETE /api/records/{id}", h.delete)
mux.HandleFunc("GET /api/records/summary/{month}", h.summary)
}
func (h *RecordHandler) list(w http.ResponseWriter, r *http.Request) {
q := r.URL.Query()
records, err := h.store.List(r.Context(), store.Filter{Month: q.Get("month"), Type: q.Get("type")})
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, records)
}
func (h *RecordHandler) create(w http.ResponseWriter, r *http.Request) {
var in model.RecordInput
if err := json.NewDecoder(r.Body).Decode(&in); err != nil {
writeError(w, http.StatusBadRequest, "invalid json")
return
}
if errs := in.Validate(); len(errs) > 0 {
writeJSON(w, http.StatusBadRequest, map[string]any{"error": "ValidationError", "details": errs})
return
}
rec, err := h.store.Create(r.Context(), in)
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusCreated, rec)
}
func (h *RecordHandler) get(w http.ResponseWriter, r *http.Request) {
id, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
if err != nil {
writeError(w, http.StatusBadRequest, "invalid id")
return
}
rec, err := h.store.Get(r.Context(), id)
if errors.Is(err, model.ErrNotFound) {
writeError(w, http.StatusNotFound, err.Error())
return
}
writeJSON(w, http.StatusOK, rec)
}
func (h *RecordHandler) delete(w http.ResponseWriter, r *http.Request) {
id, _ := strconv.ParseInt(r.PathValue("id"), 10, 64)
if err := h.store.Delete(r.Context(), id); errors.Is(err, model.ErrNotFound) {
writeError(w, http.StatusNotFound, err.Error())
return
}
w.WriteHeader(http.StatusNoContent)
}
func (h *RecordHandler) summary(w http.ResponseWriter, r *http.Request) {
records, _ := h.store.List(r.Context(), store.Filter{Month: r.PathValue("month")})
s := model.Summary{ByCategory: map[string]float64{}}
for _, rec := range records {
if rec.Type == "income" {
s.Income += rec.Amount
} else {
s.Expense += rec.Amount
s.ByCategory[rec.Category] += rec.Amount
}
}
s.Balance = s.Income - s.Expense
writeJSON(w, http.StatusOK, s)
}
func writeJSON(w http.ResponseWriter, status int, v any) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(v)
}
func writeError(w http.ResponseWriter, status int, msg string) {
writeJSON(w, status, map[string]string{"error": msg})
}
5. 中间件(函数包装函数 ≈ Python 装饰器 / Django middleware)
internal/middleware/middleware.go:
package middleware
import (
"log/slog"
"net/http"
"time"
)
type statusRecorder struct {
http.ResponseWriter
status int
}
func (r *statusRecorder) WriteHeader(code int) {
r.status = code
r.ResponseWriter.WriteHeader(code)
}
// Logging 记录每个请求的方法、路径、状态码和耗时
func Logging(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
start := time.Now()
rec := &statusRecorder{ResponseWriter: w, status: http.StatusOK}
next.ServeHTTP(rec, r)
slog.Info("request", "method", r.Method, "path", r.URL.Path,
"status", rec.status, "cost", time.Since(start))
})
}
// Recover 捕获 panic,避免单个请求导致整个服务崩溃
func Recover(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
defer func() {
if err := recover(); err != nil {
slog.Error("panic", "err", err)
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
}
}()
next.ServeHTTP(w, r)
})
}
// APIKey 简单鉴权
func APIKey(key string) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Header.Get("X-API-Key") != key {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
next.ServeHTTP(w, r)
})
}
}
6. 入口 + 优雅关闭
cmd/server/main.go:
package main
import (
"context"
"errors"
"log/slog"
"net/http"
"os"
"os/signal"
"syscall"
"time"
"example.com/accounting/internal/handler"
"example.com/accounting/internal/middleware"
"example.com/accounting/internal/store"
)
func main() {
addr := ":" + getenv("PORT", "8080")
mux := http.NewServeMux()
mux.HandleFunc("GET /health", func(w http.ResponseWriter, _ *http.Request) {
w.Write([]byte(`{"status":"ok"}`))
})
handler.NewRecordHandler(store.NewMemoryStore()).Register(mux)
srv := &http.Server{
Addr: addr,
Handler: middleware.Recover(middleware.Logging(mux)),
ReadTimeout: 10 * time.Second,
WriteTimeout: 10 * time.Second,
}
go func() {
slog.Info("listening", "addr", addr)
if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
slog.Error("server error", "err", err)
os.Exit(1)
}
}()
// 等待 Ctrl+C / kill 信号,然后在 5 秒内处理完正在进行的请求
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
<-ctx.Done()
shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
_ = srv.Shutdown(shutdownCtx)
slog.Info("server stopped")
}
func getenv(key, def string) string {
if v := os.Getenv(key); v != "" {
return v
}
return def
}
go run ./cmd/server
curl -X POST localhost:8080/api/records -d '{"type":"expense","category":"food","amount":35.5,"date":"2026-11-01"}'
curl localhost:8080/api/records/summary/2026-11
7. 接入数据库(database/sql + SQLite)
go get modernc.org/sqlite # 纯 Go 实现,无需 CGO,交叉编译友好
internal/store/sqlite.go:
package store
import (
"context"
"database/sql"
"errors"
"time"
"example.com/accounting/internal/model"
_ "modernc.org/sqlite" // 匿名导入,注册驱动
)
type SQLiteStore struct{ db *sql.DB }
func NewSQLiteStore(path string) (*SQLiteStore, error) {
db, err := sql.Open("sqlite", path)
if err != nil {
return nil, err
}
_, err = db.Exec(`CREATE TABLE IF NOT EXISTS records (
id INTEGER PRIMARY KEY AUTOINCREMENT,
type TEXT NOT NULL, category TEXT NOT NULL, amount REAL NOT NULL,
note TEXT, date TEXT NOT NULL, created_at DATETIME NOT NULL)`)
return &SQLiteStore{db: db}, err
}
func (s *SQLiteStore) List(ctx context.Context, f Filter) ([]model.Record, error) {
rows, err := s.db.QueryContext(ctx,
`SELECT id, type, category, amount, COALESCE(note, ''), date, created_at
FROM records
WHERE (? = '' OR date LIKE ? || '%') AND (? = '' OR type = ?)
ORDER BY date DESC`,
f.Month, f.Month, f.Type, f.Type) // 参数化查询,防 SQL 注入
if err != nil {
return nil, err
}
defer rows.Close()
result := []model.Record{}
for rows.Next() {
var r model.Record
if err := rows.Scan(&r.ID, &r.Type, &r.Category, &r.Amount, &r.Note, &r.Date, &r.CreatedAt); err != nil {
return nil, err
}
result = append(result, r)
}
return result, rows.Err()
}
func (s *SQLiteStore) Get(ctx context.Context, id int64) (model.Record, error) {
var r model.Record
err := s.db.QueryRowContext(ctx,
`SELECT id, type, category, amount, COALESCE(note, ''), date, created_at FROM records WHERE id = ?`, id).
Scan(&r.ID, &r.Type, &r.Category, &r.Amount, &r.Note, &r.Date, &r.CreatedAt)
if errors.Is(err, sql.ErrNoRows) {
return r, model.ErrNotFound
}
return r, err
}
func (s *SQLiteStore) Create(ctx context.Context, in model.RecordInput) (model.Record, error) {
now := time.Now()
res, err := s.db.ExecContext(ctx,
`INSERT INTO records (type, category, amount, note, date, created_at) VALUES (?, ?, ?, ?, ?, ?)`,
in.Type, in.Category, in.Amount, in.Note, in.Date, now)
if err != nil {
return model.Record{}, err
}
id, _ := res.LastInsertId()
return model.Record{ID: id, Type: in.Type, Category: in.Category, Amount: in.Amount,
Note: in.Note, Date: in.Date, CreatedAt: now}, nil
}
func (s *SQLiteStore) Delete(ctx context.Context, id int64) error {
res, err := s.db.ExecContext(ctx, `DELETE FROM records WHERE id = ?`, id)
if err != nil {
return err
}
if n, _ := res.RowsAffected(); n == 0 {
return model.ErrNotFound
}
return nil
}
在 main.go 中替换一行即可:
st, err := store.NewSQLiteStore("accounting.db")
if err != nil {
slog.Error("open db", "err", err)
os.Exit(1)
}
handler.NewRecordHandler(st).Register(mux)
这就是"面向接口"的价值:handler 完全不知道底层是内存还是数据库。
8. 常用生态
| 需求 | 选择 |
|---|---|
| Web 框架 | 标准库够用;gin(最流行)、echo、chi(轻量,兼容标准库) |
| ORM / SQL | sqlc(从 SQL 生成类型安全代码,推荐)、gorm(≈ Django ORM 风格)、sqlx |
| 配置 | 环境变量 +caarlos0/env,或 viper |
| 日志 | 标准库log/slog(结构化日志) |
| 校验 | go-playground/validator |
| API 文档 | swaggo/swag |
9. 练习
- 增加
PATCH /api/records/{id}。 - 给
/api/路由加上APIKey中间件,/health不需要鉴权。 - 增加分页参数
page、size,SQL 使用LIMIT ? OFFSET ?。 - 用
gin重写一遍路由层,对比代码量。
文章评论 (0)