boxmoe_header_banner_img

Hello! 欢迎来到我的博客!

加载中

文章导读

Go语言原生Web开发指南


avatar
xiaoifei 2026年5月20日 21

目录

前言

此教程面向其他语言转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-Typecontent-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 的风险。真实项目中如果涉及登录态,还需要配合 SecureSameSite 等属性一起使用。

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.PostFormr.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 表单字段容器ParseFormParseMultipartForm保存请求体中的普通字段,不包含 URL 查询参数
保存合并字段r.Form解析后的综合字段容器ParseFormParseMultipartForm保存普通表单字段 + 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,
    })
}

统一响应结构的好处是前端可以固定判断 codemessagedata,不用每个接口都写不同的解析逻辑。

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 里面拿着同一个 userServiceuserService 里面拿着同一个 userRepouserRepo 里面拿着同一个 db

请求 A ─┐
请求 B ─┼─> 同一个 userHandler
请求 C ─┘

        同一个 userService

        同一个 userRepo

        同一个 *sql.DB 连接池

因为 userHandleruserServiceuserRepo 会被多个请求共享,因此必须是无状态的,即内部不应该保存某个具体请求的数据

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 使用的是 QueryRowContextExecContextQueryContext 这一类方法,数据库操作就能收到取消信号。

常见数据库调用建议如下:

不带 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,例如 emailpasswordpageSize,这些应该作为函数参数。
  • 不要把 context.Background() 随便写在业务链路中,否则会切断请求原本的取消信号。
  • 不要长期保存 r.Context(),它只应该在当前请求生命周期内使用。
  • context.WithValue 适合放请求级元信息,例如当前用户、trace id、request id。
  • Service 和 Repository 方法建议把 ctx context.Context 放在第一个参数,这是 Go 里常见的约定。

为什么不利用Context解决依赖注入

依赖注入解决的是“对象从哪里来”,Context 解决的是“这一次请求的信息如何往下传”
userHandleruserServiceuserRepo 这些对象是程序启动时创建并复用的;而 ctx 是每个请求进来时产生并一路向下传递的。两者不要混在一起。

实战案例

这边打算单独开一期博客,等待更新完毕



评论(0)

查看评论列表

暂无评论


发表评论

表情 颜文字
插入代码