Go Web 入门:只用标准库写一个小而清楚的 HTTP 服务

本文使用 Go 标准库 net/http 构建一个小型 JSON HTTP 服务,讲解路由、请求解析、响应编码、中间件雏形和错误处理。

为什么建议先用标准库写一次 Web 服务

很多人学 Go Web 的第一步是找框架。Gin、Echo、Fiber 都很好用,但如果完全跳过标准库,后面很容易只会照框架文档写代码,却不理解请求、响应、路由和中间件到底是什么。Go 的 net/http 标准库已经足够写一个小型 JSON 服务。先用它做一次完整项目,会让你对所有框架的理解都更扎实。

这篇文章会写一个简单的文章服务:支持健康检查、列出文章、按 ID 查询文章、创建文章。数据先存在内存里,不引入数据库。我们重点放在 HTTP 基础动作:如何启动服务,如何注册路由,如何解析 JSON 请求,如何写 JSON 响应,如何处理错误,以及如何加一个简单日志中间件。

项目虽小,但结构要清楚。Go Web 入门不应该从一堆魔法开始,而应该从一个能完整读懂的服务开始。

最小 HTTP 服务

最小服务代码:

package main

import (
	"fmt"
	"net/http"
)

func main() {
	http.HandleFunc("/healthz", func(w http.ResponseWriter, r *http.Request) {
		fmt.Fprintln(w, "ok")
	})

	if err := http.ListenAndServe(":8080", nil); err != nil {
		panic(err)
	}
}

运行:

go run .

访问:

curl http://localhost:8080/healthz

http.HandleFunc 注册路径和处理函数。处理函数接收两个参数:http.ResponseWriter 用来写响应,*http.Request 表示请求。http.ListenAndServe(":8080", nil) 在 8080 端口启动服务。第二个参数传 nil 表示使用默认路由器。

真实项目里不太建议长期使用默认路由器,因为它是全局状态,不利于测试和组合。我们马上改成显式 ServeMux

使用 ServeMux 显式注册路由

func main() {
	mux := http.NewServeMux()
	mux.HandleFunc("/healthz", healthHandler)

	server := &http.Server{
		Addr:    ":8080",
		Handler: mux,
	}

	if err := server.ListenAndServe(); err != nil {
		panic(err)
	}
}

func healthHandler(w http.ResponseWriter, r *http.Request) {
	w.WriteHeader(http.StatusOK)
	fmt.Fprintln(w, "ok")
}

这样路由器是局部对象,服务配置也更明确。后面加中间件、测试 handler 都更容易。

还应该检查请求方法:

func healthHandler(w http.ResponseWriter, r *http.Request) {
	if r.Method != http.MethodGet {
		http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
		return
	}
	w.WriteHeader(http.StatusOK)
	fmt.Fprintln(w, "ok")
}

HTTP handler 的常见结构是:检查方法,解析输入,调用业务逻辑,写响应。每一步失败都提前返回。

定义文章模型和内存存储

先定义结构体:

type Article struct {
	ID      int64  `json:"id"`
	Title   string `json:"title"`
	Content string `json:"content"`
}

内存存储:

type ArticleStore struct {
	mu       sync.Mutex
	nextID   int64
	articles map[int64]Article
}

func NewArticleStore() *ArticleStore {
	return &ArticleStore{
		nextID:   1,
		articles: make(map[int64]Article),
	}
}

为什么要 sync.Mutex?因为 HTTP 服务会并发处理请求。多个请求可能同时创建或读取文章,普通 Map 不能并发读写。这里用互斥锁保护共享状态。

添加方法:

func (s *ArticleStore) Create(title, content string) (Article, error) {
	title = strings.TrimSpace(title)
	if title == "" {
		return Article{}, fmt.Errorf("title is required")
	}

	s.mu.Lock()
	defer s.mu.Unlock()

	article := Article{
		ID:      s.nextID,
		Title:   title,
		Content: content,
	}
	s.articles[article.ID] = article
	s.nextID++

	return article, nil
}

列表方法:

func (s *ArticleStore) List() []Article {
	s.mu.Lock()
	defer s.mu.Unlock()

	items := make([]Article, 0, len(s.articles))
	for _, article := range s.articles {
		items = append(items, article)
	}
	sort.Slice(items, func(i, j int) bool {
		return items[i].ID < items[j].ID
	})
	return items
}

按 ID 查询:

var ErrArticleNotFound = errors.New("article not found")

func (s *ArticleStore) Get(id int64) (Article, error) {
	s.mu.Lock()
	defer s.mu.Unlock()

	article, ok := s.articles[id]
	if !ok {
		return Article{}, ErrArticleNotFound
	}
	return article, nil
}

这些方法还不是数据库层,但已经有清楚边界。Handler 不直接操作 Map,而是通过 store 方法完成业务动作。

写 JSON 响应

封装一个响应函数:

func writeJSON(w http.ResponseWriter, status int, value any) {
	w.Header().Set("Content-Type", "application/json; charset=utf-8")
	w.WriteHeader(status)
	if err := json.NewEncoder(w).Encode(value); err != nil {
		log.Printf("encode response: %v", err)
	}
}

错误响应:

type ErrorResponse struct {
	Error string `json:"error"`
}

func writeError(w http.ResponseWriter, status int, message string) {
	writeJSON(w, status, ErrorResponse{Error: message})
}

这比到处写 http.Error 更适合 JSON API。统一响应格式后,前端处理会简单很多。

列出文章

Handler 可以放进一个结构体,持有依赖:

type Handler struct {
	store *ArticleStore
}

func NewHandler(store *ArticleStore) *Handler {
	return &Handler{store: store}
}

列表接口:

func (h *Handler) listArticles(w http.ResponseWriter, r *http.Request) {
	if r.Method != http.MethodGet {
		writeError(w, http.StatusMethodNotAllowed, "method not allowed")
		return
	}

	articles := h.store.List()
	writeJSON(w, http.StatusOK, map[string]any{
		"items": articles,
	})
}

注册:

store := NewArticleStore()
handler := NewHandler(store)

mux := http.NewServeMux()
mux.HandleFunc("/healthz", healthHandler)
mux.HandleFunc("/articles", handler.listArticles)

访问:

curl http://localhost:8080/articles

会得到:

{"items":[]}

即使没有文章,也返回空数组,这比 null 更友好。

创建文章

请求结构体:

type CreateArticleRequest struct {
	Title   string `json:"title"`
	Content string `json:"content"`
}

Handler:

func (h *Handler) createArticle(w http.ResponseWriter, r *http.Request) {
	if r.Method != http.MethodPost {
		writeError(w, http.StatusMethodNotAllowed, "method not allowed")
		return
	}
	defer r.Body.Close()

	var req CreateArticleRequest
	if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
		writeError(w, http.StatusBadRequest, "invalid json body")
		return
	}

	article, err := h.store.Create(req.Title, req.Content)
	if err != nil {
		writeError(w, http.StatusBadRequest, err.Error())
		return
	}

	writeJSON(w, http.StatusCreated, article)
}

同一个路径 /articles 既要支持 GET 列表,又要支持 POST 创建,可以写一个分发函数:

func (h *Handler) articles(w http.ResponseWriter, r *http.Request) {
	switch r.Method {
	case http.MethodGet:
		h.listArticles(w, r)
	case http.MethodPost:
		h.createArticle(w, r)
	default:
		writeError(w, http.StatusMethodNotAllowed, "method not allowed")
	}
}

注册:

mux.HandleFunc("/articles", handler.articles)

测试:

curl -X POST http://localhost:8080/articles \
  -H 'Content-Type: application/json' \
  -d '{"title":"Go Web 入门","content":"先从 net/http 开始"}'

按 ID 查询文章

标准库路由器比较朴素。我们可以先用查询参数:

func (h *Handler) getArticle(w http.ResponseWriter, r *http.Request) {
	if r.Method != http.MethodGet {
		writeError(w, http.StatusMethodNotAllowed, "method not allowed")
		return
	}

	rawID := r.URL.Query().Get("id")
	id, err := strconv.ParseInt(rawID, 10, 64)
	if err != nil || id <= 0 {
		writeError(w, http.StatusBadRequest, "invalid article id")
		return
	}

	article, err := h.store.Get(id)
	if err != nil {
		if errors.Is(err, ErrArticleNotFound) {
			writeError(w, http.StatusNotFound, "article not found")
			return
		}
		log.Printf("get article: %v", err)
		writeError(w, http.StatusInternalServerError, "internal server error")
		return
	}

	writeJSON(w, http.StatusOK, article)
}

注册:

mux.HandleFunc("/article", handler.getArticle)

访问:

curl 'http://localhost:8080/article?id=1'

如果使用较新 Go 版本,标准库 ServeMux 也支持更丰富的模式,但入门阶段先用查询参数能少分散注意力。理解 handler 的输入输出比路由语法更重要。

一个简单日志中间件

中间件本质上是包装 handler:

func logging(next http.Handler) http.Handler {
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		start := time.Now()
		next.ServeHTTP(w, r)
		log.Printf("%s %s %s", r.Method, r.URL.Path, time.Since(start))
	})
}

使用:

server := &http.Server{
	Addr:    ":8080",
	Handler: logging(mux),
}

请求会先进入日志中间件,再进入真正路由。很多框架里的中间件也是这个思想:接收一个 handler,返回一个新的 handler。

真实日志还会记录状态码、请求 ID、客户端 IP 等。入门版本先记录方法、路径和耗时,足够理解结构。

完整 main 结构

整理后的入口大概是:

func main() {
	store := NewArticleStore()
	handler := NewHandler(store)

	mux := http.NewServeMux()
	mux.HandleFunc("/healthz", healthHandler)
	mux.HandleFunc("/articles", handler.articles)
	mux.HandleFunc("/article", handler.getArticle)

	server := &http.Server{
		Addr:    ":8080",
		Handler: logging(mux),
	}

	log.Println("server listening on :8080")
	if err := server.ListenAndServe(); err != nil {
		log.Fatal(err)
	}
}

这个服务仍然很小,但已经有了清楚的层次:main 负责组装,Handler 负责 HTTP,ArticleStore 负责数据,辅助函数负责 JSON 和错误响应。后续把内存存储换成数据库,handler 不需要大改。

小结

只用 Go 标准库就能写出一个清楚的 HTTP JSON 服务。你需要掌握的核心不是框架 API,而是 HTTP handler 的基本流程:检查方法,解析输入,调用业务对象,转换错误,写出响应。net/http 的模型很稳定,理解它之后再学任何 Go Web 框架都会更容易。

入门项目不必追求功能复杂。一个健康检查、一个列表接口、一个创建接口、一个查询接口,再加上统一 JSON 响应和日志中间件,已经足够覆盖 Web 服务的基本骨架。

下一步可以继续扩展:把内存 Map 换成数据库,把查询参数改成路径参数,增加请求 ID 和超时控制,写 handler 测试。只要基础边界保持清楚,项目就能自然长大。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页