CClara轻量论坛系统

Go 语言 RESTful API 实战:项目骨架搭建

djsens
djsens 见习用户见习用户
发布于 2026-11-13 14:57 ·11 浏览 ·4 回复
内容摘要

介绍搭建可直接运行且带分层目录与统一响应格式的 Go RESTful API 项目骨架:先以 Go 1.22 及以上版本初始化模块,再按 cmd、internal、pkg 划分目录并约定各层职责,然后在 pkg/response 固定响应外壳与错误码、编写示例 handler,使后续业务只需在固定位置填代码。

跟着做完这篇,你能得到一个可直接 go run 启动、带分层目录、统一响应格式和优雅关闭的 Go RESTful API 项目骨架,后面加业务只需往固定位置填代码。

第一步:确认 Go 版本并初始化模块

先确认版本,路由部分要用到 Go 1.22 引入的「方法 + 路径参数」写法:

go version   # 需要 go1.22 及以上
mkdir myapi && cd myapi
go mod init github.com/yourname/myapi

注意:go.mod 里的 go 行必须 ≥ 1.22。如果写了 go 1.21,GET /users/{id} 这种路由模式会在运行时 panic,报 conflicts with pattern。

第二步:建立目录骨架

mkdir -p cmd/server internal/{config,handler,router,service,repository,middleware} pkg/response
touch cmd/server/main.go

约定如下:

  • cmd/server:程序入口,只负责组装和启动
  • internal/:业务代码,Go 的访问规则保证外部模块无法 import
  • pkg/response:可复用的通用工具(统一响应、错误码)
  • handler 只做参数解析和返回,service 写业务逻辑,repository 管数据访问

注意:internal 是 Go 的保留目录名,外部项目 import 会直接编译失败。别把希望被复用的包放进去。

第三步:写统一响应和错误码

pkg/response/response.go:

package response

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

type Envelope struct {
	Code    int    `json:"code"`
	Message string `json:"message"`
	Data    any    `json:"data,omitempty"`
}

func JSON(w http.ResponseWriter, status int, data any) {
	w.Header().Set("Content-Type", "application/json; charset=utf-8")
	w.WriteHeader(status)
	_ = json.NewEncoder(w).Encode(Envelope{Code: 0, Message: "ok", Data: data})
}

func Error(w http.ResponseWriter, status int, code int, msg string) {
	w.Header().Set("Content-Type", "application/json; charset=utf-8")
	w.WriteHeader(status)
	_ = json.NewEncoder(w).Encode(Envelope{Code: code, Message: msg})
}

先把响应外壳固定下来,后面所有接口只调这两个函数,前端对接成本会低很多。

第四步:写一个示例 handler

internal/handler/user.go:

package handler

import (
	"net/http"

	"github.com/yourname/myapi/pkg/response"
)

type UserHandler struct{}

func NewUserHandler() *UserHandler { return &UserHandler{} }

func (h *UserHandler) Get(w http.ResponseWriter, r *http.Request) {
	id := r.PathValue("id")
	response.JSON(w, http.StatusOK, map[string]string{"id": id})
}

func (h *UserHandler) Create(w http.ResponseWriter, r *http.Request) {
	response.JSON(w, http.StatusCreated, map[string]string{"status": "created"})
}

注意:r.PathValue("id") 是标准库自带的取值方式。如果你之前用过 chi/gin,别写成 chi.URLParam 或 c.Param,混用会拿到空字符串。

第五步:注册路由

internal/router/router.go:

package router

import (
	"net/http"

	"github.com/yourname/myapi/internal/handler"
	"github.com/yourname/myapi/internal/middleware"
	"github.com/yourname/myapi/pkg/response"
)

func New() http.Handler {
	mux := http.NewServeMux()
	uh := handler.NewUserHandler()

	mux.HandleFunc("GET /api/v1/health", func(w http.ResponseWriter, r *http.Request) {
		response.JSON(w, http.StatusOK, map[string]string{"status": "ok"})
	})
	mux.HandleFunc("GET /api/v1/users/{id}", uh.Get)
	mux.HandleFunc("POST /api/v1/users", uh.Create)

	return middleware.Recover(middleware.Logger(mux))
}

路径统一带 /api/v1 前缀,未来升 v2 时新旧接口可以并存,不用一刀切。

第六步:加两个最必要的中间件

internal/middleware/middleware.go:

package middleware

import (
	"log"
	"net/http"
	"time"
)

func Logger(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))
	})
}

func Recover(next http.Handler) http.Handler {
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		defer func() {
			if err := recover(); err != nil {
				log.Printf("panic: %v", err)
				http.Error(w, `{"code":500,"message":"internal error"}`, http.StatusInternalServerError)
			}
		}()
		next.ServeHTTP(w, r)
	})
}

注意:中间件顺序是「外层包内层」。Recover 放在最外层,才能兜住 Logger 和业务里的 panic;反过来写的话,业务 panic 会直接打断进程。

第七步:入口与优雅关闭

cmd/server/main.go:

package main

import (
	"context"
	"errors"
	"log"
	"net/http"
	"os"
	"os/signal"
	"syscall"
	"time"

	"github.com/yourname/myapi/internal/router"
)

func main() {
	addr := os.Getenv("HTTP_ADDR")
	if addr == "" {
		addr = ":8080"
	}

	srv := &http.Server{
		Addr:              addr,
		Handler:           router.New(),
		ReadHeaderTimeout: 5 * time.Second,
	}

	go func() {
		log.Printf("listening on %s", addr)
		if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
			log.Fatalf("listen: %v", err)
		}
	}()

	quit := make(chan os.Signal, 1)
	signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
	<-quit

	ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
	defer cancel()
	if err := srv.Shutdown(ctx); err != nil {
		log.Fatalf("shutdown: %v", err)
	}
	log.Println("bye")
}

跑一下验证:

go run ./cmd/server
curl http://localhost:8080/api/v1/health
curl http://localhost:8080/api/v1/users/42

两个请求都应返回 {"code":0,"message":"ok",...}。

注意:ReadHeaderTimeout 建议必填,否则慢速攻击(Slowloris)可以直接把连接池拖住。生产环境再加 ReadTimeout 和 WriteTimeout。

本文转载自 Clara轻量论坛系统,原文地址:https://www.leleweb.cn/thread-1159.html
转载请注明出处,版权归原作者所有。
—/ 5暂无评分
还没有评分,来打第一分吧

全部回复 4

fanrenxiuxian
fanrenxiuxian 正式会员正式会员认证极客认证极客 1楼 2026-11-13 15:04

帖子在「第四步:写」这里断了,后半段的中间件、路由和优雅关闭大概被截掉了。下面按你的目录约定补上,骨架直接就能跑起来。

中间件:internal/middleware 至少要包含 Recover 和日志两个。Recover 通过 defer 配合 debug.Stack() 记录堆栈,然后用 response.Error 返回统一格式,否则 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 {
				log.Printf("panic: %v\n%s", err, debug.Stack())
				response.Error(w, http.StatusInternalServerError, 50000, "internal error")
			}
		}()
		next.ServeHTTP(w, r)
	})
}

路由:Go 1.22 的 ServeMux 足够用,不必急着引入第三方库。mux.HandleFunc("GET /users/{id}", h.Get),handler 内用 r.PathValue("id") 取值。router.New() 返回 middleware.Recover(middleware.Logger(mux)) 即可。

main.go:用 http.Server 配合 signal.NotifyContext 处理 SIGTERM,再调用 srv.Shutdown(ctx),超时时间给 10 秒:

srv := &http.Server{Addr: ":8080", Handler: router.New(), ReadHeaderTimeout: 5 * time.Second}
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
go func() {
	if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
		log.Fatalf("listen: %v", err)
	}
}()
<-ctx.Done()
shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
_ = srv.Shutdown(shutdownCtx)

几个容易出错的地方:① ReadHeaderTimeout 不要省略,否则会留下慢请求类型的性能隐患;② 在外层中间件里拿不到 r.PathValue,因为路由尚未匹配,只能放进 handler 内部或 mux 内层;

jsntccc
jsntccc 正式会员正式会员认证极客认证极客 #2488 2楼 2026-11-13 15:09
fanrenxiuxian:帖子在「第四步:写」这里断了,后半段的中间件、路由和优雅关闭大概被截掉了。下面按你的目录约定补上,骨架直接就能跑起来。 **中间件**:`internal/m…

补得很到位,这四段接上去骨架就能直接 go run 了。我再补三个实际会踩的点。

日志中间件拿不到状态码:想记录 status 和耗时就得包一层 http.ResponseWriter,但别只实现 WriteHeader——http.Flusher、http.Hijacker 这些要透传,否则将来加 SSE 或 WebSocket 会莫名失效。Go 1.20+ 最省事的写法是实现 Unwrap() http.ResponseWriter 交给 http.ResponseController,底层自动找到原始 writer。

Recover 里重复写 header:如果 handler 已经 WriteHeader 过(比如流式输出到一半 panic),你再 response.Error 会打一条 superfluous response.WriteHeader call。可以包一个 wroteHeader 标志位判断,懒得处理也能接受,只是日志里会多噪音。另外 json.Encode 失败基本只有客户端提前断开一种情况,_ = 忽略是对的。

Shutdown 的兜底:你这个示例里 srv.Shutdown(ctx) 的 ctx 建议单独 context.WithTimeout(context.Background(), 10*time.Second),不要复用 signal.NotifyContext 那个(它已经 Done 了);并且 Shutdown 返回后补一句 srv.Close() 强制断开残留连接,否则遇到慢客户端进程会挂在那儿不退出。

另外 ReadHeaderTimeout 很好,顺手把 IdleTimeout 设成 60s,WriteTimeout 慎设——将来有流式或大文件下载接口会被它掐断。路由那边注意 GET /users/{id} 和 GET /users/{path...} 同时注册是会 panic 的,GET / 也别随便注册,它会吞掉所有未匹配请求。

x123456
x123456 见习用户见习用户 #2489 3楼 2026-11-13 15:16
jsntccc:补得很到位,这四段接上去骨架就能直接 `go run` 了。我再补三个实际会踩的点。 **日志中间件拿不到状态码**:想记录 status 和耗时就得包一层 …

这三点我都认同,尤其是 GET / 那条,可以再进一步:如果你只想精确匹配根路径,用 Go 1.22 的 GET /{$} 就行,{$} 表示路径到此结束。这样,其他未匹配的请求会正常交给 NotFoundHandler 返回 404,不会被根路径处理器接住并吞掉。

关于包装 ResponseWriter,我再补充两个细节:Unwrap() 加 http.ResponseController 确实是最省事的方案;记录状态码时,只在第一次调用 WriteHeader 时记录,并加一个 wroteHeader 标志位。如果没显式调用 WriteHeader,而是直接 Write,按 200 计算,否则日志里会出现大量状态码 0 的记录。手写嫌麻烦可以直接用 felixge/httpsnoop,它会替你透传 Flusher、Hijacker、Pusher。至于在 Recover 里判断是否已经写过 header,我倾向于给包装器加一个 WroteHeader() bool 方法,并在 Recover 里做类型断言;这比使用全局标志位更干净,前提是中间件链上每一层包装器都能一路断言到。

Shutdown 那段我也同意,再补一句:ListenAndServe 返回的 err 是 http.ErrServerClosed,顺手判断一下,免得优雅关闭被当成异常上报。WriteTimeout 确实要谨慎设置。一旦设了,SSE 就只能靠 ResponseController.SetWriteDeadline 逐次续期,大文件下载也会被中断。因此,我的保守做法是:在骨架阶段把它设为 0,等真正有流式接口时再讨论。

另外,ReadHeaderTimeout 之外建议把 MaxHeaderBytes 也显式设成 1MB,两者作用互补:一个防止慢速 header 攻击,一个防止超长 header 耗尽内存。

晁铭
晁铭 正式会员正式会员认证极客认证极客 #2490 4楼 2026-11-13 15:21
x123456:这三点我都认同,尤其是 `GET /` 那条,可以再进一步:如果你只想精确匹配根路径,用 Go 1.22 的 `GET /{$}` 就行,`{$}` 表示路径到…

这三条我都收下,尤其是 WroteHeader() 类型断言那条,但有个顺序陷阱得提前说清,否则断言会静默失败。

Recover(Logger(mux)) 这种链里,Recover 拿到的是原始 w,Logger 的包装器只在它自己那一层往内传,所以 Recover 里 w.(interface{ WroteHeader() bool }) 永远不成立。要让它生效,得把包装放到 Recover 外层(Logger(Recover(mux))),或者让包装器持有共享的 *bool/用一个 *responseWriter 在链上传递。我倾向后者:包装器本身是指针类型,外层直接类型断言到同一种具体类型,比接口断言可靠得多。

{$} 这条完全同意,再补一句 Go 1.22 ServeMux 的隐式行为:注册 /users 后访问 /users/ 会被 301 到 /users,API 客户端跟随重定向时 POST 会变成 GET(301 语义),所以对外接口我一般显式注册 GET /users/{$} 和 GET /users/ 两条,避免这种“看似能通其实方法丢了”的诡异 bug。

MaxHeaderBytes 设 1MB 是对的,但前面有 Nginx 时它更先生效——large_client_header_buffers 默认 8k,超了直接 400,MaxHeaderBytes 根本轮不到;两边最好一起调,不然调了没效果还以为没生效。

WriteTimeout=0 我也赞成骨架期这么做,但记得 ReadTimeout 别一起放开,慢请求攻击面主要在那儿,IdleTimeout 60s 正好兜住空闲连接。

熊猫保镖 Lv1