
目录
前言
此教程面向其他语言转Go的开发者,对于已经建立Web开发知识体系的,希望它帮助你快速上手
而对于Web开发的初学者,希望它能够带领你建立该知识体系
在开发前我们可以下一个第三方包:air
它能够热重载代码,就像我们在Idea开发Java一样一键HotWrap,不过因为Go是静态编译的语言,每次更改代码都需要重新编译运行,而这个包简化了这一步骤
知识点
1. 启动服务器
下面是一段示例代码
package main
import (
"fmt"
"net/http"
)
func main() {
http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
// r 代表客户端请求
// w 用来返回响应
fmt.Fprintf(w, "Hello, you've requested: %s\n", r.URL.Path)
})
http.HandleFunc("/name", func(w http.ResponseWriter, r *http.Request) {
fmt.Fprintf(w, "My name is xiaoifei!")
})
http.ListenAndServe(":80", nil) // 监听端口,默认Mux路由
}
可以看到使用ListenAndServe方法我们一键启动了服务器,第二个参数的nil代表使用DefaultServeMux进行处理
配合http.HandleFunc处理访问路径的匹配与具体的相应动作
对于请求的处理我们可以用 HandleFunc
func(w http.ResponseWriter, r *http.Request)
这个匿名函数中可以看到响应和接收请求的对象w是服务端给客户端返回 HTTP 响应 的对象
type ResponseWriter interface {
Header() Header
Write([]byte) (int, error)
WriteHeader(statusCode int)
}
因为Fprintf要求传入一个实现了io.writer的接口,在Fprintf内部会将其传换成[]byte
并调用w.Write([]byte(...)),而因为ResponseWriter也实现了io.writer
于是响应内容就很优雅地写入到了响应Buffer中,等待handler return后最终发回给客户端
精细化配置
对于 ListenAndServe 方法,我们可以自定义mux
func ListenAndServe(addr string, handler Handler) error
我们通过 NewServeMux 创建的mux对象就包含ServeHTTP,即实现了 handler 接口
func main() {
mux := http.NewServeMux() // mux全称multiplexer,即请求分发器
mux.HandleFunc("GET /users", getUsers)
mux.HandleFunc("POST /users", createUser)
http.ListenAndServe(":8080", mux)
}
我们可以创建Server对象做精细化配置
func main() {
mux := http.NewServeMux()
mux.HandleFunc("GET /hello", func(w http.ResponseWriter, r *http.Request) {
w.Write([]byte("hello"))
})
server := &http.Server{
Addr: ":8080",
Handler: mux,
ReadTimeout: 10 * time.Second,
WriteTimeout: 10 * time.Second,
}
err := server.ListenAndServe()
if err != nil {
log.Fatal(err)
}
}
2. 数据接收
关于数据的接收,我们需要将关注点放到协议本身
对于HTTP协议,其由请求行,请求头,请求体;响应行,响应头,响应体构成
2.1 请求行处理
2.1.1 请求方法识别
http.HandleFunc("/hello", func(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case http.MethodGet:
w.Write([]byte("GET"))
case http.MethodPost:
w.Write([]byte("POST"))
default:
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
}
})
语法糖:
在Go 1.22后官方支持使用方法匹配语法
在请求地址前加上方法名进行限制
mux.HandleFunc("GET /hello", func(w http.ResponseWriter, r *http.Request) {
w.Write([]byte("GET hello"))
})
2.1.2 查询传参(Query Param)
对于客户端发送的请求GET /users?name=tom&page=2
Go可以用如下格式接收:
http.HandleFunc("GET /users", func(w http.ResponseWriter, r *http.Request) {
name := r.URL.Query().Get("name")
page := r.URL.Query().Get("page")
})
2.1.3 路径传参(Path Param)
Go对于路径传参的处理比较鸡肋,因为他需要开发者手动进行字符串截取
但随着第三方框架的支持,其内部都实现了自己的一套参数提取规则
而官方意识到每个框架都在造轮子,决定自己造一个轮子,一统天下
Go 1.22 以后,原生 ServeMux 支持路径通配符。
对于客户端发送的请求GET /users/123
mux := http.NewServeMux()
mux.HandleFunc("GET /users/{id}", func(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
w.Write([]byte("user id = " + id))
})
PathValue 是 Go 1.22 加入的,用来取匹配到的路径通配符。
单段 wildcard 和多段 wildcard:
| 写法 | 匹配内容 | |
|---|---|---|
{xxx} | 一个路径段 | |
{xxx...} | 后面所有路径 |
当客户端请求/group/101/profile/001
mux.HandleFunc("GET /group/{groupId}/user/{userId}", func(w http.ResponseWriter, r *http.Request) {
groupId := r.PathValue("groupId")
userId := r.PathValue("userId")
})
mux.HandleFunc("/group/{respath...}", func(w http.ResponseWriter, r *http.Request) {
r.PathValue("respath") // 101/user/001
})
这里有人可能会疑惑,为什么PathValue不作为Url的子方法
URL/URI规范中只负责传输和格式,并没有指定业务语义
也就是说 /group/101/profile/001 是一段资源路径,101和001对其是无意义的一段路径
他们的具体含义是由Web开发的RESTful API标准决定的,由后端进行业务上的路由匹配
2.2 请求头:Header、Cookie 和上下文信息
除了请求行和请求体之外,请求头中也会携带大量元信息,例如认证信息、客户端类型、内容格式、缓存控制等。
其中常用的就是Authorization,Cookie,Content-Type等字段
func headerHandler(w http.ResponseWriter, r *http.Request) {
token := r.Header.Get("Authorization")
userAgent := r.UserAgent()
contentType := r.Header.Get("Content-Type")
fmt.Println(token)
fmt.Println(userAgent)
fmt.Println(contentType)
w.Write([]byte("header received"))
}
Header.Get 会自动处理大小写问题,因此 Content-Type、content-type 在语义上是一样的。
Cookie 本质上也是一种特殊的请求头,浏览器会在后续请求中自动携带服务端写入的 Cookie。
func cookieHandler(w http.ResponseWriter, r *http.Request) {
cookie, err := r.Cookie("token")
if err != nil {
http.SetCookie(w, &http.Cookie{
Name: "token",
Value: "abc123",
Path: "/",
MaxAge: 3600,
HttpOnly: true,
})
w.Write([]byte("set cookie"))
return
}
w.Write([]byte("cookie value = " + cookie.Value))
}
HttpOnly 表示前端 JavaScript 不能直接读取这个 Cookie,能够降低 XSS 攻击窃取 Cookie 的风险。真实项目中如果涉及登录态,还需要配合 Secure、SameSite 等属性一起使用。
2.3 请求体处理:HTTP请求中的Content-Type
参考文档:HTTP Content-Type
Content-Type是请求头的字段,决定服务器用什么方式解析请求体
HTTP协议规定:信息的传输方式不同,选择的Content-Type也不同,常用的传输格式如下
- application/json
- application/x-www-form-urlencoded
- application/octet-stream
- multipart/form-data
2.3.1 JSON解析
对于Json格式的解析,Go标准库提供了解析器
type CreateUserRequest struct {
Name string `json:"name"` // 反射字段声明
Age int `json:"age"`
}
func createUser(w http.ResponseWriter, r *http.Request) {
var req CreateUserRequest
err := json.NewDecoder(r.Body).Decode(&req)
if err != nil {
http.Error(w, "invalid json", http.StatusBadRequest)
return
}
w.Write([]byte(req.Name))
}
将 r.Body 解析到 req 中
这里要注意很重要的点,r.body 在方法中使用过一次了就不能在被使用,因为他的本质是数据流
读取之后就从管道另外一侧流走了。
我们可以通过 body, _ := io.ReadAll(r.Body) 将其缓存到body中
对于较小的数据可以这么做,例如一些Json信息字段。但是对于较大的文件,一次性保存在内存中并不明智
所以我们通常会使用流式写入,对于较大文件或者规定文件在多少大小的情况下保存在内存
2.3.2 表单解析
对于表单类型的Content-Type有两种
一种是处理文本的,而一种是处理文件的
- application/x-www-form-urlencoded:适合提交普通文本字段,比如用户名、密码、邮箱。
- multipart/form-data:适合提交文件,也可以同时提交普通文本字段,比如头像文件 + 昵称 + 用户 ID。
纯文本字段上传:application/x-www-form-urlencoded
application/x-www-form-urlencoded这个传输类型会将字符串进行url编码,例如“你好”会变成“%E4%BD%A0%E5%A5%BD”
下面是客户端发送与服务器接收的例子
案例1:
// js
fetch("/login", {
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded"
},
body: "email=test@example.com&password=123456"
})
// go
err := r.ParseForm()
if err != nil { ... }
email := r.FormValue("email") // FormValue内部会自动调用ParseForm不过无法处理错误
password := r.FormValue("password")
也可以使用ParseForm+PostForm.Get进行处理,ParseForm 会解析 URL query,也会在 POST / PUT / PATCH 时解析表单请求体;表单 body 参数会放进 r.PostForm 和 r.Form
案例2:
// Content-Type: application/x-www-form-urlencoded
// name=tom&age=18
func formHandler(w http.ResponseWriter, r *http.Request) {
err := r.ParseForm()
if err != nil {
http.Error(w, "invalid form", http.StatusBadRequest)
return
}
name := r.PostForm.Get("name")
age := r.PostForm.Get("age")
w.Write([]byte(name + " " + age))
}
文本和文件混合上传:multipart/form-data
浏览器处理请求体,用boundary将数据拆分成多个part
http内部的请求体格式如下:
------boundary
Content-Disposition: form-data; name="title"
我的录音
------boundary
Content-Disposition: form-data; name="file"; filename="audio.mp3"
Content-Type: audio/mpeg
这里是文件的二进制内容
------boundary--
下面是客户端发送与服务器接收的例子
案例1:
// js
const formData = new FormData()
formData.append("title", "我的录音")
formData.append("file", audioFile)
fetch("/v1/audio/upload", { // 注意这里一般不要手动设置 Content-Type
method: "POST",
// Content-Type: multipart/form-data; boundary=----WebKitFormBoundaryxxxx // 浏览器自动补全(包括boundary字段)
body: formData
})
// go
err := r.ParseMultipartForm(10 << 20)
file, header, err := r.FormFile("file")
title := r.FormValue("title")
案例2:
func uploadHandler(w http.ResponseWriter, r *http.Request) {
// 解析 multipart form
err := r.ParseMultipartForm(10 << 20) // 左移20位,代表10 * 2 ^ 20,约为2MB。参数表示最多用 10MB 内存解析 multipart
if err != nil {
http.Error(w, "parse error", http.StatusBadRequest)
return
}
// 取普通字段
name := r.FormValue("name")
age := r.FormValue("age")
// 取文件
file, header, err := r.FormFile("avatar")
if err != nil {
http.Error(w, "file error", http.StatusBadRequest)
return
}
defer file.Close()
fmt.Println(name)
fmt.Println(age)
fmt.Println(header.Filename)
w.Write([]byte("upload success"))
}
在代码err := r.ParseMultipartForm(10 << 20)中处理文件我们通常传入一个最大大小(单位为Byte)
最多用 10MB 内存解析 multipart/form-data,超过部分自动写磁盘临时文件
下面对Form的解析流程以及API进行表格汇总:
| 阶段 | Go 中对应内容 | 它是什么 | 什么时候有值 | 主要作用 |
|---|---|---|---|---|
| 接收请求 | r.Body | 原始请求体数据流 | 请求刚到后就有 | 保存前端传来的原始 body,包括 multipart 内容 |
| 判断类型 | r.Header.Get("Content-Type") | 请求体类型说明 | 请求刚到后就有 | 判断是 JSON、普通表单,还是文件上传表单 |
| 解析普通表单 | r.ParseForm() | 解析动作 | 手动调用后执行 | 解析 application/x-www-form-urlencoded 表单和 URL 查询参数 |
| 解析文件表单 | r.ParseMultipartForm(10 << 20) | 解析动作 | 手动调用后执行 | 解析 multipart/form-data,包括普通字段和文件字段 |
| 保存普通字段 | r.PostForm | 解析后的 POST 表单字段容器 | ParseForm 或 ParseMultipartForm 后 | 保存请求体中的普通字段,不包含 URL 查询参数 |
| 保存合并字段 | r.Form | 解析后的综合字段容器 | ParseForm 或 ParseMultipartForm 后 | 保存普通表单字段 + URL 查询参数 |
| 保存上传文件 | r.MultipartForm.File | 解析后的文件字段容器 | ParseMultipartForm 后 | 保存上传文件的文件头信息 |
| 读取普通字段 | r.Form.Get("title") | 从 r.Form 中取值 | 通常在解析后使用 | 读取请求体字段,也可能读取到 URL 查询参数 |
| 读取 POST 字段 | r.PostForm.Get("title") | 从 r.PostForm 中取值 | 通常在解析后使用 | 只读取请求体里的普通字段 |
| 读取文件字段 | r.FormFile("file") | 获取上传文件 | multipart/form-data 场景使用 | 返回文件流和文件信息 |
3. 数据响应
服务端接收请求之后,最终一定要给客户端返回响应。响应由状态码、响应头、响应体组成。
3.1 返回普通文本
最简单的返回方式就是直接调用 w.Write。会将字符编码为字节流直接写入响应体中。
func textHandler(w http.ResponseWriter, r *http.Request) {
w.Write([]byte("hello world"))
}
如果没有显式设置状态码,Go 会默认返回 200 OK。
3.2 返回状态码
func statusHandler(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusCreated)
w.Write([]byte("created"))
}
需要注意:WriteHeader 只能生效一次。只要调用过 Write,Go 就会默认先写入 200 状态码,因此如果要自定义状态码,必须先调用 WriteHeader,再调用 Write。
常用状态码如下:
| 状态码 | 含义 |
|---|---|
200 | 请求成功 |
201 | 创建成功 |
204 | 成功但没有响应体 |
400 | 客户端请求参数错误 |
401 | 未认证 |
403 | 无权限 |
404 | 资源不存在 |
405 | 请求方法不允许 |
500 | 服务端内部错误 |
3.3 返回 JSON
后端接口开发最常见的响应格式就是 JSON。
type User struct {
ID int `json:"id"`
Name string `json:"name"`
Age int `json:"age"`
}
func jsonHandler(w http.ResponseWriter, r *http.Request) {
user := User{
ID: 1,
Name: "tom",
Age: 18,
}
w.Header().Set("Content-Type", "application/json; charset=utf-8")
err := json.NewEncoder(w).Encode(user)
if err != nil {
http.Error(w, "encode json failed", http.StatusInternalServerError)
return
}
}
这里不需要先把结构体转成字符串,json.NewEncoder(w).Encode(user) 会直接把 JSON 写入响应体。
3.4 统一响应结构
实际开发中通常不会直接返回业务对象,而是统一包装一层响应结构。
type Response struct {
Code int `json:"code"`
Message string `json:"message"`
Data interface{} `json:"data,omitempty"`
}
func writeJSON(w http.ResponseWriter, status int, resp Response) {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.WriteHeader(status)
if err := json.NewEncoder(w).Encode(resp); err != nil {
http.Error(w, "encode response failed", http.StatusInternalServerError)
}
}
之后业务代码就可以这样写:
func userHandler(w http.ResponseWriter, r *http.Request) {
user := User{ID: 1, Name: "tom", Age: 18}
writeJSON(w, http.StatusOK, Response{
Code: 0,
Message: "success",
Data: user,
})
}
统一响应结构的好处是前端可以固定判断 code、message、data,不用每个接口都写不同的解析逻辑。
4. 路由管理
前面的示例直接使用了 http.HandleFunc,它内部操作的是默认路由器 DefaultServeMux。
http.HandleFunc("/hello", helloHandler)
http.ListenAndServe(":8080", nil)
第二个参数传入 nil 时,Go 会默认使用 http.DefaultServeMux。
更推荐的写法是自己创建一个 ServeMux,这样路由边界更加清晰,同时还能自定义全局中间件。
func main() {
mux := http.NewServeMux()
mux.HandleFunc("GET /hello", helloHandler)
mux.HandleFunc("POST /users", createUser)
http.ListenAndServe(":8080", mux)
}
当项目变大后,可以按模块拆分路由注册函数。
func registerUserRoutes(mux *http.ServeMux) {
mux.HandleFunc("GET /users/{id}", getUser)
mux.HandleFunc("POST /users", createUser)
mux.HandleFunc("PUT /users/{id}", updateUser)
mux.HandleFunc("DELETE /users/{id}", deleteUser)
}
func main() {
mux := http.NewServeMux()
registerUserRoutes(mux)
http.ListenAndServe(":8080", mux)
}
4.1 路由拆分
func main() {
mux := http.NewServeMux()
mux.Handle("/user/", http.StripPrefix("/user", NewUserRouter()))
mux.Handle("/audio/", http.StripPrefix("/audio", NewAudioRouter()))
http.ListenAndServe(":8080", mux)
}
// internal/router/router.go
func NewRouter() http.Handler {
mux := http.NewServeMux()
mux.Handle("/user/", http.StripPrefix("/user", NewUserRouter()))
adminRouter := NewAdminRouter()
mux.Handle("/admin/", http.StripPrefix("/admin", AdminAuthMiddleware(adminRouter))) // 需要登录,那可以单独处理
return mux
}
func NewUserRouter() http.Handler {
mux := http.NewServeMux()
mux.HandleFunc("POST /register", func(w http.ResponseWriter, r *http.Request) {
w.Write([]byte("user register"))
})
mux.HandleFunc("POST /login", func(w http.ResponseWriter, r *http.Request) {
w.Write([]byte("user login"))
})
mux.HandleFunc("GET /profile", func(w http.ResponseWriter, r *http.Request) {
w.Write([]byte("user profile"))
})
return mux
}
func NewAudioRouter() http.Handler {
mux := http.NewServeMux()
mux.HandleFunc("POST /upload", func(w http.ResponseWriter, r *http.Request) {
w.Write([]byte("audio upload"))
})
mux.HandleFunc("GET /list", func(w http.ResponseWriter, r *http.Request) {
w.Write([]byte("audio list"))
})
return mux
}
5. 静态资源服务
如果要提供图片、CSS、JavaScript 等静态资源,可以使用 http.FileServer。
假设项目目录如下:
project
├── main.go
└── static
├── app.js
└── style.css
注册静态资源路由:
func main() {
mux := http.NewServeMux()
fs := http.FileServer(http.Dir("./static"))
mux.Handle("/static/", http.StripPrefix("/static/", fs))
http.ListenAndServe(":8080", mux)
}
当访问 http://localhost:8080/static/style.css 时,Go 会读取本地的 ./static/style.css 文件并返回给客户端。
这里的 http.StripPrefix 用来去掉 URL 前缀。否则请求 /static/style.css 时,FileServer 会去本地寻找 ./static/static/style.css,路径就重复了。
6. 中间件
中间件可以理解成:夹在“请求进入”和“真正业务处理函数”之间的一层处理逻辑,本质上就是对 http.Handler 的包装
浏览器请求来了,不是马上进入真正的接口函数,而是先经过中间件。中间件可以检查、记录、修改请求,甚至提前拒绝请求;处理函数返回响应后,中间件也可以继续做一些收尾工作。
常见的中间件
- 日志记录:记录请求路径、请求方法、耗时
- 权限验证:检查用户有没有登录
- 错误恢复:防止程序 panic 后整个服务崩掉
- 统一响应头:给所有响应加公共 Header
- 请求耗时统计
- 限流
6.1 用户身份鉴权中间件
// middleware
// func AuthMiddleware(sessionStore *session.SessionStore, next func(http.ResponseWriter, *http.Request)) func(http.ResponseWriter, *http.Request) {
func AuthMiddleware(sessionStore *session.SessionStore, next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
cookie, err := r.Cookie("session_id")
if errors.Is(err, http.ErrNoCookie) {
writeJson(w, http.StatusUnauthorized, Fail(CodeAuthFail))
return
}
if err != nil {
log.Println("Read Cookie Error:", err)
writeJson(w, http.StatusInternalServerError, Fail(CodeInternalError))
return
}
log.Println(err)
user, ok := sessionStore.Get(cookie.Value)
if !ok {
writeJson(w, http.StatusUnauthorized, Fail(CodeAuthFail, "session expired"))
return
}
ctx := context.WithValue(r.Context(), "user", user)
next(w, r.WithContext(ctx))
}
}
// 使用
http.HandleFunc("POST /v1/audio/upload", AuthMiddleware(sessionStore, func(w http.ResponseWriter, r *http.Request){
user := r.Context().Value("user").(User) // 拿到用户身份
...
}))
上面代码中 HandleFunc 函数接收两个参数,一个是路径匹配字符串,一个是func(ResponseWriter, *Request)类型的对象,也就是我们传入的匿名函数
但是你会发现next对象(也就是我们传入的函数)他的类型是 http.HandlerFunc
通过源码可以发现 http 包内部将 func(ResponseWriter, *Request) 单独作为一个新的类型 http.HandlerFunc 使用,并且在使用的时候,会将 func(http.ResponseWriter, *http.Request) 强制转换成 http.HandlerFunc 类型使用
// server.go 源码
type Handler interface {
ServeHTTP(ResponseWriter, *Request)
}
type HandlerFunc func(ResponseWriter, *Request)
func (f HandlerFunc) ServeHTTP(w ResponseWriter, r *Request) {
f(w, r)
}
func (mux *ServeMux) HandleFunc(pattern string, handler func(ResponseWriter, *Request)) { // 我们使用的方法
if use121 {
mux.mux121.handleFunc(pattern, handler)
} else {
mux.register(pattern, HandlerFunc(handler)) // 函数被转换成HandlerFunc适配器类型了
}
}
func (mux *ServeMux) Handle(pattern string, handler Handler) {
if use121 {
mux.mux121.handle(pattern, handler)
} else {
mux.register(pattern, handler)
}
}
除此之外还为 http.HandlerFunc 类型声明了新的对象方法 ServeHTTP 使其变成了接口 http.Handler 的实现类
这里可能有人想问,既然我中间件返回的是handlerFunc,但是我HandleFunc第二个参数接收的要是一个func(ResponseWriter,*Request),为什么不报错
var a MyInt = 10
var b int = a // 不可以
因为Go规定
如果两个类型的底层类型相同,并且其中至少有一个不是具名类型,那么可以直接赋值。
func(ResponseWriter,*Request) 是一个匿名类型,因此不会报错
一般来说,标准中间件的声明会采用返回 http.Handler 的形式,而非 http.HandlerFunc
下面是采用Handler形式,此外路由的注册就需要使用Handle而非HandlerFunc
此时路由的处理就需要从http.HandleFunc变成http.Handle,第二个参数用于接收实现了http.Handler接口的对象
type contextKey string
const userContextKey contextKey = "user"
func AuthMiddleware(sessionStore *session.SessionStore) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
cookie, err := r.Cookie("session_id")
if errors.Is(err, http.ErrNoCookie) {
writeJson(w, http.StatusUnauthorized, Fail(CodeAuthFail))
return
}
if err != nil {
log.Println("Read Cookie Error:", err)
writeJson(w, http.StatusInternalServerError, Fail(CodeInternalError))
return
}
user, ok := sessionStore.Get(cookie.Value)
if !ok {
writeJson(w, http.StatusUnauthorized, Fail(CodeAuthFail, "session expired"))
return
}
ctx := context.WithValue(r.Context(), userContextKey, user)
next.ServeHTTP(w, r.WithContext(ctx))
})
}
}
// 使用
uploadHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { // 将函数作为值转换为(HandlerFunc类型,且实现了Handler)赋值给uploadHandler
user := r.Context().Value(userContextKey).(User)
// ...
})
auth := AuthMiddleware(sessionStore) // auth是一个函数,接收一个Handler对象,返回一个Handler对象
http.Handle("POST /v1/audio/upload", auth(uploadHandler)) // auth(uploadHandler)返回一个Handler对象,正好满足Handle传参
不仅如此,中间件还能对全局Mux进行包裹
因为路由本身就实现了http.Handler
mux 本身实现了 http.Handler 接口,所以可以把整个 mux 传入中间件函数中进行包裹。这样所有进入该 mux 的请求都会先经过中间件逻辑,再由 mux 分发到具体路由处理函数。从效果上看,这类似于 Java Web 中的 Filter,可以用于鉴权、日志、跨域、限流等公共逻辑。不过严格来说,这不是修改了 mux 本身,而是创建了一个包裹 mux 的新 Handler,服务器最终监听的是这个新 Handler。
如下所示:
mux := http.NewServeMux()
mux.HandleFunc("POST /v1/audio/upload", func(w http.ResponseWriter, r *http.Request) {
user := r.Context().Value(userContextKey).(User)
// ...
})
mux.HandleFunc("GET /v1/user/profile", func(w http.ResponseWriter, r *http.Request) {
user := r.Context().Value(userContextKey).(User)
// ...
})
protectedMux := AuthMiddleware(sessionStore)(mux)
http.ListenAndServe(":8080", protectedMux)
6.2 日志耗时中间件
func LoggingMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
start := time.Now()
next.ServeHTTP(w, r)
fmt.Printf("%s %s %s\n", r.Method, r.URL.Path, time.Since(start))
})
}
使用方式:
func main() {
mux := http.NewServeMux()
mux.HandleFunc("GET /hello", helloHandler)
handler := logging(mux)
http.ListenAndServe(":8080", handler)
}
执行链路如下:
请求进入
-> logging middleware
-> mux 路由匹配
-> 具体 handler
-> logging middleware 收尾
响应返回
6.3 panic 恢复中间件
如果 handler 中发生 panic,默认情况下可能会导致当前连接异常。实际项目中通常会通过中间件统一恢复。
func recovery(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
defer func() {
if err := recover(); err != nil {
fmt.Println("panic:", err)
http.Error(w, "internal server error", http.StatusInternalServerError)
}
}()
next.ServeHTTP(w, r)
})
}
多个中间件可以嵌套组合:
handler := recovery(LoggingMiddleware(mux))
请求会先进入 recovery,再进入 logging,最后进入真实业务 handler。
7. 数据库
这里使用Mysql的驱动进行讲解
import "database/sql"
import _ "github.com/go-sql-driver/mysql"
再程序中我们可以直接使用下面的语句对数据库进行连接,基础的执行语法可参考:mysql-database
db, err := sql.Open("mysql", "username:password@(127.0.0.1:3306)/dbname?parseTime=true")
那么如何将其融入到Web开发流程中呢?
数据库连接对象通常在程序启动时创建一次,然后在所有请求 handler 里复用,在 Go 的标准库 database/sql 里,只要你用的是 *sql.DB,它本身就带连接池能力。
func initDB() *sql.DB {
db, err := sql.Open("mysql", "root:22222@(127.0.0.1:3306)/serverlearn?parseTime=true")
if err != nil {
panic(err)
}
db.SetMaxOpenConns(1)
db.SetMaxIdleConns(1)
db.SetConnMaxLifetime(time.Hour)
if err := db.Ping(); err != nil {
panic(err)
}
return db
}
func main() {
db := initDB() // 初始化DB连接器对象
defer db.Close()
mux := http.NewServeMux()
mux.HandleFunc("POST /v1/register", registerHandler(db))
http.ListenAndServe(":8080", mux)
}
func registerHandler(db *sql.DB) http.HandlerFunc { // 闭包包装
return func(w http.ResponseWriter, r *http.Request) { // 由于HandleFunc必须接收 http.handler 对象
var req RegisterRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeJson(w, http.StatusBadRequest, Response{
Code: 10001,
Msg: "参数错误",
})
return
}
_, err := db.Exec(
"INSERT INTO users(email, nickname, password) VALUES (?, ?, ?)",
req.Email,
req.Nickname,
req.Password,
)
if err != nil {
writeJson(w, http.StatusInternalServerError, Response{
Code: 50000,
Msg: "数据库错误",
})
return
}
writeJson(w, http.StatusOK, Response{
Code: 0,
Msg: "注册成功",
})
}
}
通过闭包,我们可以传入db的上下文
如果还想给某个接口加上Logging中间件,我们可以这样做:
mux.Handle("POST /v1/register", loggingMiddleware(registerHandler(db)))
8. 优雅关闭服务
前面的示例直接使用 http.ListenAndServe,当服务器程序被强制终止时,正在处理的请求可能会被直接中断。
更完整的写法是创建 http.Server,并监听系统信号,在程序退出前给请求一点处理时间。
func main() {
mux := http.NewServeMux()
mux.HandleFunc("GET /hello", helloHandler)
server := &http.Server{
Addr: ":8080",
Handler: mux,
ReadTimeout: 5 * time.Second,
WriteTimeout: 10 * time.Second,
IdleTimeout: 60 * time.Second,
}
go func() {
fmt.Println("server listening on :8080")
if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed {
panic(err)
}
}()
quit := make(chan os.Signal, 1)
signal.Notify(quit, os.Interrupt, syscall.SIGTERM)
<-quit
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
if err := server.Shutdown(ctx); err != nil {
panic(err)
}
fmt.Println("server stopped")
}
这里有几个重点:
ReadTimeout限制读取请求的最大时间WriteTimeout限制写响应的最大时间IdleTimeout限制空闲连接保留时间Shutdown会停止接收新请求,并等待正在处理的请求结束
在生产环境中,不建议裸用没有超时配置的http.ListenAndServe,因为慢请求可能会长期占用连接资源。
9. 项目标准结构
在看完上面的知识点,你会发现,当前所有的操作都是在main.go中进行的,代码冗长且不容易维护
因此我们需要拆分逻辑,将代码按照职责划分
可参考下面目录结构:
my-web-app/
├── cmd/
│ └── server/
│ └── main.go
│
├── internal/
│ ├── config/
│ │ └── config.go
│ │
│ ├── handler/
│ │ ├── user_handler.go
│ │ └── auth_handler.go
│ │
│ ├── service/
│ │ ├── user_service.go
│ │ └── auth_service.go
│ │
│ ├── repository/
│ │ └── user_repository.go
│ │
│ ├── model/
│ │ └── user.go
│ │
│ ├── dto/
│ │ ├── request.go
│ │ └── response.go
│ │
│ ├── middleware/
│ │ └── cors.go
│ │
│ ├── router/
│ │ └── router.go
│ │
│ └── database/
│ └── mysql.go
│
├── pkg/
│ └── utils/
│ └── password.go
│
├── migrations/
│ └── 001_create_users.sql
│
├── configs/
│ └── config.yaml
├── .env
├── go.mod
├── go.sum
└── README.md
9.1 依赖注入
Go的设计哲学偏向将依赖关系写出来,而不是藏起来
因此我们需要手动注入依赖
userRepo := repository.NewUserRepository(db)
userService := service.NewUserService(userRepo)
userHandler := handler.NewUserHandler(userService)
之后每个请求进来时,Gin 调用的是同一个 userHandler 的方法,而这个 userHandler 里面拿着同一个 userService,userService 里面拿着同一个 userRepo,userRepo 里面拿着同一个 db
请求 A ─┐
请求 B ─┼─> 同一个 userHandler
请求 C ─┘
↓
同一个 userService
↓
同一个 userRepo
↓
同一个 *sql.DB 连接池
因为 userHandler、userService、userRepo 会被多个请求共享,因此必须是无状态的,即内部不应该保存某个具体请求的数据
Java Spring 默认的
@Service、@Controller、@Repository通常也是单例,也是如此
9.2 Context 上下文传递
在 Web 开发中,请求不是只在 Handler 中被处理,它通常会经过:
middleware -> handler -> service -> repository -> database
如果每一层都需要知道“当前请求是谁发起的”“请求是否已经取消”“数据库查询是否应该超时”,就需要一个贯穿整条调用链的对象。
在 Go 里,这个对象就是 context.Context。
它主要做三件事:
- 传递请求级别的数据,例如当前登录用户、trace id、request id。
- 传递取消信号,例如客户端断开连接后,后面的数据库查询也应该尽快停止。
- 传递超时时间,例如一个请求最多处理 3 秒,超过就自动取消。
r.Context()之所以能在客户端断开时被取消,底层与 HTTP 连接状态有关;而 HTTP/1.1 通常跑在 TCP 上,所以 TCP 连接的关闭、读写失败、协议层检测,最终会被 Go 的net/http包装成 request context 的取消信号。
需要注意,Context 不是用来代替函数参数的。普通业务参数,例如 email、password、userID,仍然应该显式写在函数参数中。Context 更适合传递“和这次请求相关,但不是业务主体”的信息。
9.2.1 从请求中拿到 Context
每个 *http.Request 自带一个 Context:
func handler(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
// 后续可以把 ctx 继续传给 service / repository
_ = ctx
}
当客户端断开连接、请求超时、或者服务端主动取消时,这个 ctx 会收到取消信号。
因此在多层架构中,Handler 通常会把 r.Context() 继续往下传:
func (h *AuthHandler) Login(w http.ResponseWriter, r *http.Request) {
var req LoginRequest
json.NewDecoder(r.Body).Decode(&req)
user, err := h.authService.Login(r.Context(), req.Email, req.Password)
if err != nil {
// ...
}
writeJSON(w, http.StatusOK, Success(user))
}
Service 再继续传给 Repository:
func (s *AuthService) Login(ctx context.Context, email, password string) (User, error) {
user, err := s.userRepo.FindByEmail(ctx, email)
if err != nil {
return User{}, err
}
// 校验密码等业务逻辑
return user, nil
}
Repository 最终把 ctx 传给数据库操作:
func (r *UserRepository) FindByEmail(ctx context.Context, email string) (User, error) {
var user User
err := r.db.QueryRowContext(
ctx,
"SELECT user_id, email, nickname, password_hash FROM users WHERE email = ?",
email,
).Scan(&user.UserID, &user.Email, &user.Nickname, &user.PasswordHash)
return user, err
}
这里使用的是 QueryRowContext,而不是 QueryRow。区别在于:前者能感知 ctx 的取消和超时。
9.2.2 在中间件中写入上下文数据
鉴权中间件通常会先通过 Cookie / Session / Token 找到当前用户,然后把用户信息放入请求上下文中,传给后续 Handler。
type contextKey string
const userContextKey contextKey = "user"
func AuthMiddleware(sessionStore *SessionStore) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
cookie, err := r.Cookie("session_id")
if err != nil {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
user, ok := sessionStore.Get(cookie.Value)
if !ok {
http.Error(w, "session expired", http.StatusUnauthorized)
return
}
ctx := context.WithValue(r.Context(), userContextKey, user)
next.ServeHTTP(w, r.WithContext(ctx))
})
}
}
这段代码里有两个关键点:
context.WithValue(r.Context(), userContextKey, user)会基于原来的 Context 创建一个新的 Context。r.WithContext(ctx)会创建一个带有新 Context 的 Request,并传给下一个 Handler。
注意,Request 本身没有被原地修改。Go 标准库的做法是创建一个新的 Request 对象:
next.ServeHTTP(w, r.WithContext(ctx))
后续 Handler 就可以这样读取用户信息:
func uploadHandler(w http.ResponseWriter, r *http.Request) {
user, ok := r.Context().Value(userContextKey).(User)
if !ok {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
fmt.Println(user.UserID)
}
9.2.3 上下文Key不建议直接用字符串
很多示例会这样写:
ctx := context.WithValue(r.Context(), "user", user)
这能运行,但不推荐。因为字符串 key 很容易和其他包里的 key 冲突。
更推荐的写法是定义一个当前包私有的 key 类型:
type contextKey string
const userContextKey contextKey = "user"
这样即使别的包也用了 "user" 这个字符串,也不会和你的 key 冲突,因为它们的类型不同。
如果项目拆分成 internal/middleware 包,也可以提供一个辅助函数,避免业务层到处写类型断言:
func UserFromContext(ctx context.Context) (User, bool) {
user, ok := ctx.Value(userContextKey).(User)
return user, ok
}
Handler 中使用:
func uploadHandler(w http.ResponseWriter, r *http.Request) {
user, ok := UserFromContext(r.Context())
if !ok {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
fmt.Println(user.Email)
}
9.2.4 Context 超时控制
除了传值,Context 更重要的能力是超时和取消。
例如某个接口最多允许处理 3 秒:
func timeoutHandler(w http.ResponseWriter, r *http.Request) {
ctx, cancel := context.WithTimeout(r.Context(), 3*time.Second)
defer cancel()
user, err := userRepo.FindByEmail(ctx, "test@example.com")
if err != nil {
if errors.Is(ctx.Err(), context.DeadlineExceeded) {
http.Error(w, "request timeout", http.StatusGatewayTimeout)
return
}
http.Error(w, "server error", http.StatusInternalServerError)
return
}
writeJSON(w, http.StatusOK, Success(user))
}
如果 3 秒内数据库还没有返回,ctx 会被取消。只要 Repository 使用的是 QueryRowContext、ExecContext、QueryContext 这一类方法,数据库操作就能收到取消信号。
常见数据库调用建议如下:
| 不带 Context | 带 Context,推荐 |
|---|---|
db.Query(...) | db.QueryContext(ctx, ...) |
db.QueryRow(...) | db.QueryRowContext(ctx, ...) |
db.Exec(...) | db.ExecContext(ctx, ...) |
9.2.5 三层结构中的 Context 传递方式
在标准三层结构中,Context 的传递方向应该非常明确:
HTTP Request
-> Handler: r.Context()
-> Service: Login(ctx, ...)
-> Repository: FindByEmail(ctx, ...)
-> database/sql: QueryRowContext(ctx, ...)
对应代码结构可以是:
// handler
func (h *AuthHandler) Login(w http.ResponseWriter, r *http.Request) {
var req LoginRequest
json.NewDecoder(r.Body).Decode(&req)
user, err := h.authService.Login(r.Context(), req.Email, req.Password)
// ...
}
// service
func (s *AuthService) Login(ctx context.Context, email, password string) (User, error) {
user, err := s.userRepo.FindByEmail(ctx, email)
// ...
return user, nil
}
// repository
func (r *UserRepository) FindByEmail(ctx context.Context, email string) (User, error) {
var user User
err := r.db.QueryRowContext(ctx, "...", email).Scan(...)
return user, err
}
这样做的好处是:请求一旦取消,整条调用链都能感知到,而不是 Handler 已经结束了,数据库还在后台继续查询。
Context 很有用,但也不能滥用:
- 不要把普通业务参数放进 Context,例如
email、password、pageSize,这些应该作为函数参数。 - 不要把
context.Background()随便写在业务链路中,否则会切断请求原本的取消信号。 - 不要长期保存
r.Context(),它只应该在当前请求生命周期内使用。 context.WithValue适合放请求级元信息,例如当前用户、trace id、request id。- Service 和 Repository 方法建议把
ctx context.Context放在第一个参数,这是 Go 里常见的约定。
为什么不利用Context解决依赖注入
依赖注入解决的是“对象从哪里来”,Context 解决的是“这一次请求的信息如何往下传”
userHandler、userService、userRepo这些对象是程序启动时创建并复用的;而ctx是每个请求进来时产生并一路向下传递的。两者不要混在一起。
实战案例
这边打算单独开一期博客,等待更新完毕

评论(0)
暂无评论