为什么建议先用标准库写一次 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 测试。只要基础边界保持清楚,项目就能自然长大。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。