编程语言

Go教程(四)实战 —— Web 服务(记账 REST API)

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. 练习

  1. 增加 PATCH /api/records/{id}。
  2. 给 /api/ 路由加上 APIKey 中间件,/health 不需要鉴权。
  3. 增加分页参数 page、size,SQL 使用 LIMIT ? OFFSET ?。
  4. 用 gin 重写一遍路由层,对比代码量。

文章评论 (0)