01. 搭服务器:处理器、路由表、读请求参数(细讲)
🥰个人主页:会编程的土豆(欢迎来访)
💎作者简介:后端学习者
❄️个人专栏:数据结构与算法,数据库,leetcode
✨那些你一个人走过的夜路,终将化作照亮未来的光
对应课:搭建服务器、HTTP 协议、处理请求、给客户端响应、读参数(约 5.4 节重点)
代码示例来自bookstore0612,路径:C:\Users\zjl\Projects\goweb-bookstore
一、最小可运行服务器(HelloWorld 逐行)
1.1 完整代码
package main import ( "fmt" "net/http" ) func hello(w http.ResponseWriter, r *http.Request) { fmt.Fprintln(w, "Hello World") } func main() { http.HandleFunc("/hello", hello) http.ListenAndServe(":8080", nil) }保存为main.go,在项目根执行:
go run main.go浏览器访问http://localhost:8080/hello,页面显示Hello World(后面可能多一个换行,来自Fprintln)。
1.2 逐行解释
| 行 | 代码 | 含义 |
|---|---|---|
| 1 | package main | 可执行程序必须是main包 |
| 4~5 | import "net/http" | 标准库 HTTP 包,路由、服务器、Cookie 都在这 |
| 7~9 | func hello(w, r) | 处理器函数:处理一次请求;固定签名两个参数 |
| 8 | fmt.Fprintln(w, "Hello World") | 往响应体写文本;w实现了io.Writer |
| 12 | http.HandleFunc("/hello", hello) | 登记路由:路径 → 函数(注意传的是函数名,不是hello()) |
| 13 | http.ListenAndServe(":8080", nil) | 阻塞监听 8080;nil= 使用默认路由表 |
1.3 两个核心类型
http.ResponseWriter(常名w)— 代表「这次请求的响应」:
- 写状态码(默认 200)
- 写响应头(
w.Header().Set(...)) - 写响应体(
fmt.Fprintln(w,...)、w.Write([]byte(...)))
http.Request(常名r)— 代表「浏览器发来的这次请求」:
- URL、Method(GET/POST)
- 请求头、Cookie
- 请求体(POST 表单、JSON 等)
1.4 处理器函数 = 课里的「处理请求的函数」
书城项目里每一个controller.Login、controller.AddBook2Cart都是这种签名:
func Login(w http.ResponseWriter, r *http.Request) { ... }一次 HTTP 请求 → 调用一次处理器函数。
二、三种挂法:HandleFunc、Handle、ServeHTTP
2.1 方式一:HandleFunc —— 挂普通函数(书城 99% 用法)
http.HandleFunc("/login", controller.Login)- 第一个参数:URL 路径(精确匹配,课里阶段不做正则)
- 第二个参数:符合
func(w,r)签名的函数
内部原理(了解即可):HandleFunc会把你的函数包装成一个实现了ServeHTTP的对象,再注册到 Mux。
2.2 方式二:Handle —— 挂「对象」
type MyHandler struct{} func (m *MyHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { fmt.Fprintln(w, "正在通过处理器处理你的请求") } func main() { myHandler := MyHandler{} http.Handle("/", &myHandler) // 注册的是指针 &myHandler http.ListenAndServe(":8080", nil) }要点:
| 细节 | 说明 |
|---|---|
方法名必须叫ServeHTTP | 这是http.Handler接口规定的 |
注册时用&myHandler | 指针接收者,避免拷贝 |
Handle不是立刻调用 | 只是登记;请求来了才执行 |
2.3 方式三:ServeHTTP —— 接口本质
type Handler interface { ServeHTTP(ResponseWriter, *Request) }谁实现了这个接口,谁就可以被http.Handle(path, handler)注册。
对比表:
| API | 注册目标 | 典型场景 |
|---|---|---|
HandleFunc(path, fn) | 函数 | 书城所有业务处理器 |
Handle(path, obj) | 实现了Handler的对象 | http.FileServer、http.StripPrefix返回值 |
直接写ServeHTTP | 自定义类型 | 课里演示、标准库内部 |
2.4 书城 main.go 里的真实注册
// 静态资源:Handle + FileServer(FileServer 本身实现了 Handler) http.Handle("/static/", http.StripPrefix("/static/", http.FileServer(http.Dir("views/static")))) // 动态业务:HandleFunc + controller 函数 http.HandleFunc("/login", controller.Login) http.HandleFunc("/addBook2Cart", controller.AddBook2Cart)本质都是:URL → 某种处理逻辑。
三、路由表 ServeMux —— 用「电话总机」类比
3.1 是什么
路由表(ServeMux)就是一张URL → 处理器的对照表:
/login → controller.Login /regist → controller.Regist /main → controller.GetPageBooksByPrice /addBook2Cart → controller.AddBook2Cart ...类比:公司总机。客户拨分机号(URL),总机查表转接到对应部门(处理器)。
3.2 DefaultServeMux(默认总机)
http.HandleFunc("/login", controller.Login) http.ListenAndServe(":8080", nil) // 第二个参数 nil → 用 DefaultServeMux- Go 在
net/http包里预置了全局变量DefaultServeMux http.HandleFunc/http.Handle实际是往DefaultServeMux上注册- 一开始表是空的,所有业务路径都是你自己挂上去的
- 「默认」≠ 内置了
/login;只是「默认用这一张表」
3.3 NewServeMux(自己买一台总机)
mux := http.NewServeMux() mux.HandleFunc("/myMux", myHandler) http.ListenAndServe(":8080", mux) // 必须传入 mux,不能 nil| 写法 | 路由表 | 效果 |
|---|---|---|
ListenAndServe(":8080", nil) | DefaultServeMux | 用包级HandleFunc注册的都能命中 |
ListenAndServe(":8080", mux) | 自定义 mux | 只有mux.HandleFunc注册的能命中 |
HandleFunc+ListenAndServe(..., mux)混用 | 分裂 | 注册在 Default 上的路径在自定义 mux 上404 |
何时效果不同(易错):
http.HandleFunc("/a", handlerA) // 注册在 DefaultServeMux mux := http.NewServeMux() mux.HandleFunc("/b", handlerB) http.ListenAndServe(":8080", mux) // 只有 /b 有效,/a 会 404!书城课里为了讲清概念会写NewServeMux;入门项目用 DefaultServeMux + nil 足够,和自定义 mux 在「只用一个表」时效果几乎一样。
3.4 路径匹配规则(课里常用子集)
- 注册
/static/这种带尾部/的前缀,会匹配/static/css/style.css - 精确路径如
/login只匹配/login(不含 query:/login?x=1仍匹配) - 根路径
/在书城用匿名函数单独处理,避免抢走所有 404
四、http.Server 结构体(自定义服务器)
4.1 基本写法
import "time" server := http.Server{ Addr: ":8080", // 监听地址 Handler: nil, // nil 则内部用 DefaultServeMux ReadTimeout: 5 * time.Second, WriteTimeout: 10 * time.Second, IdleTimeout: 120 * time.Second, } server.ListenAndServe()4.2 常用字段
| 字段 | 作用 |
|---|---|
Addr | 如:8080、127.0.0.1:8080 |
Handler | 根处理器,通常是nil、*ServeMux或自定义Handler |
ReadTimeout | 读整个请求(含 body)的最长时间 |
WriteTimeout | 写响应的最长时间 |
IdleTimeout | HTTP/1.1 keep-alive 空闲连接超时 |
4.3 致命易错点
server := http.Server{ Addr: ":8080", Handler: myMux, ReadTimeout: 2 * time.Second, } // 错:这样 ListenAndServe 不会用到上面 server 的配置! http.ListenAndServe(":8080", nil) // 对:必须调实例方法 server.ListenAndServe()规则:配了http.Server{...}就必须server.ListenAndServe(),否则自定义 Handler、超时全部不生效。
书城完整版为简洁直接用:
http.ListenAndServe(":8080", nil)五、HTTP 报文解剖(带书城登录实例)
5.1 请求报文(浏览器 → 服务器)
以登录 POST 为例:
POST /login HTTP/1.1 ← 请求行:方法 + 路径 + 协议版本 Host: localhost:8080 ← 请求头开始 User-Agent: Mozilla/5.0 ... Accept: text/html,application/xhtml+xml,... Content-Type: application/x-www-form-urlencoded Content-Length: 32 Cookie: user=abc-def-ghi ← 若之前登录过,会带上 username=admin&password=123456 ← 空行后是请求体(POST 表单)| 部分 | 内容 | 在 Go 里怎么读 |
|---|---|---|
| 请求行 | POST /login HTTP/1.1 | r.Methodr.URL.Path |
| 请求头 | Host、Content-Type、Cookie… | r.Headerr.Cookie("user") |
| 请求体 | username=admin&password=123456 | ParseForm后PostForm/PostFormValue |
GET 示例(首页翻页):
GET /main?pageNo=2&min=10&max=50 HTTP/1.1 Host: localhost:8080参数在URL query,不在 body;用r.FormValue("pageNo")或r.URL.Query().Get("pageNo")。
5.2 响应报文(服务器 → 浏览器)
登录成功时大致如下:
HTTP/1.1 200 OK ← 状态行 Content-Type: text/html; charset=utf-8 Set-Cookie: user=c65d2a76-9447-44cc-...; Path=/; HttpOnly Date: ... <!DOCTYPE html> ← 响应体(HTML) <html>...</html>| 状态码 | 含义 | 书城用法 |
|---|---|---|
| 200 | OK | 正常返回 HTML / JSON |
| 302 | Found | http.Redirect(w,r,"/main",http.StatusFound) |
| 404 | Not Found | http.NotFound(w,r) |
| 401 | Unauthorized | 改购物车数量未登录时http.Error(..., 401) |
5.3 处理器里写响应的几种方式
// 1. 纯文本 fmt.Fprintln(w, "Hello") w.Write([]byte("请先登录!")) // 2. 跳转 http.Redirect(w, r, "/main", http.StatusFound) // 3. HTML 模板(书城主流) t := template.Must(template.ParseFiles("views/pages/user/login.html")) t.Execute(w, data) // 4. JSON(加购 Ajax) w.Header().Set("Content-Type", "application/json; charset=utf-8") json.NewEncoder(w).Encode(map[string]interface{}{"ok": true, "msg": "..."})注意:若手动WriteHeader(404),须在任何 body 写入之前调用。
六、读请求参数(课 5.4 重点 + hanzong/admin 实验)
6.1 实验 HTML(课内经典)
<form action="http://localhost:8080/getBody" method="POST"> 用户名:<input type="text" name="username" value="hanzong"><br/> 密码:<input type="password" name="password" value="666666"><br/> <input type="submit"> </form>若浏览器地址栏或 form 的 action 同时带 query:
http://localhost:8080/getBody?username=admin则同一次 POST里:
- URL 上有
username=admin - 表单 body 里有
username=hanzong&password=666666
6.2 四种容器 API
①r.Form— URL + 表单 body 都要
r.ParseForm() // 必须先解析! fmt.Fprintln(w, r.Form) // 输出类似:map[username:[hanzong admin] password:[666666]]顺序规则:同一 key 多个值时,表单在前,URL query 在后(slice 里hanzong在admin前面)。
内存示意:
r.Form["username"] → []string{"hanzong", "admin"} ↑ PostForm 来的 ↑ URL 来的②r.PostForm— 只要 POST body 里的表单
r.ParseForm() fmt.Fprintln(w, r.PostForm) // map[username:[hanzong] password:[666666]] ← 没有 admin③r.FormValue("username")— 快捷取「第一个值」
// 内部会自动 ParseForm v := r.FormValue("username") // "hanzong"(表单优先于 URL 的第一个)④r.PostFormValue("username")— 只认 body
v := r.PostFormValue("username") // "hanzong" v := r.PostFormValue("password") // "666666"书城登录(controller/userhandler.go):
username := r.PostFormValue("username") password := r.PostFormValue("password") user, _ := dao.CheckUserNameAndPassword(username, password)为什么登录用PostFormValue?密码只应从 POST body 来,避免有人构造GET /login?password=xxx混进参数。
6.3 对比总表
| API | 数据来源 | 是否自动 Parse | 返回值 | 书城场景 |
|---|---|---|---|---|
ParseForm() | 前提 | — | error | 用 Form/PostForm 前必调 |
r.Form | URL + body | 否 | map[string][]string | 调试、多值 |
r.PostForm | 仅 body | 否 | map[string][]string | 区分 URL/表单 |
FormValue(k) | URL + body | 是 | 第一个 string | 加购bookId、删书id |
PostFormValue(k) | 仅 body | 是 | string | 登录、注册 |
6.4 Content-Type 限制
上述Form/PostForm默认针对:
Content-Type: application/x-www-form-urlencoded上传文件时是multipart/form-data,需:
r.ParseMultipartForm(maxMemory) r.MultipartForm.File["myfile"]书城课阶段几乎都是 urlencoded,知道即可。
6.5 Ajax 与表单的一致性
注册页 Ajax 校验(/checkUserName):
$.post("/checkUserName", {"username": username}, function(data){ ... });后端:
username := r.PostFormValue("username")jQuery 的$.post会把对象编成POST body,和 form 的name="username"本质相同。
加购(views/index.html):
$.post("/addBook2Cart", {"bookId": bookId}, ...);后端:
bookID := r.FormValue("bookId")这里用FormValue也行,因为 Ajax 只有 body、没有冲突 URL 参数。
七、静态资源 FileServer(书城 main.go)
7.1 注册代码
http.Handle("/static/", http.StripPrefix("/static/", http.FileServer(http.Dir("views/static")))) http.Handle("/pages/", http.StripPrefix("/pages/", http.FileServer(http.Dir("views/pages"))))7.2 请求如何映射到磁盘
| 浏览器请求 | StripPrefix 后 | 磁盘路径 |
|---|---|---|
/static/css/style.css | css/style.css | views/static/css/style.css |
/pages/user/login.html | user/login.html | views/pages/user/login.html |
7.3 逐层理解
http.FileServer(http.Dir("views/static")) → 实现了 Handler,按 URL 路径读本地文件 http.StripPrefix("/static/", ...) → 去掉 URL 前缀,否则会把 /static/ 也当成目录名 http.Handle("/static/", ...) → 挂到 DefaultServeMux7.4 login.html 如何引用静态资源
<link rel="stylesheet" href="/static/css/style.css"> <script src="/static/script/jquery-1.7.2.js"></script>浏览器会再发 GET 请求拉 css/js,由 FileServer 处理,不经过controller。
7.5 易错点
| 问题 | 原因 |
|---|---|
| css 404 | go run工作目录不在项目根,views/static找不到 |
| 路径多/少一层 | StripPrefix与Handle前缀不一致 |
| 动态页也想走 FileServer | 带{{.}}的模板必须用template.Execute,不能当纯静态 |
八、action 如何连到 handler(前后端闭环)
8.1 三步对应关系
HTML form action="/login" ↕ 路径字符串必须一致 main.go http.HandleFunc("/login", controller.Login) ↕ 函数名 controller/userhandler.go func Login(w,r) { ... }8.2 登录完整闭环
前端(views/pages/user/login.html):
<form action="/login" method="POST"> <input name="username" ... /> <input name="password" type="password" ... /> <input type="submit" value="登录" /> </form>路由(main.go):
http.HandleFunc("/login", controller.Login)后端(controller/userhandler.go):
func Login(w http.ResponseWriter, r *http.Request) { username := r.PostFormValue("username") // 对应 name="username" password := r.PostFormValue("password") // 对应 name="password" // ... }8.3 对应关系表
| 前端 | 后端 |
|---|---|
action="/login" | HandleFunc("/login", ...) |
method="POST" | 参数在 body →PostFormValue |
name="username" | PostFormValue("username") |
| 点击 submit | 浏览器发 HTTP POST,服务器调一次Login |
关键概念:表单 HTML不是请求体;用户点提交后,浏览器根据action/method/name打包成 HTTP 请求体。
8.4 与 Ajax 路径一致
$.post("/addBook2Cart", {"bookId": bookId}, ...);http.HandleFunc("/addBook2Cart", controller.AddBook2Cart)/addBook2Cart字符串三处(JS、main、注释)必须一致,差一个字母就 404。
九、常见易错点
| # | 易错点 | 后果 | 正确做法 |
|---|---|---|---|
| 1 | HandleFunc("/login", Login())加了括号 | 编译错误或逻辑错乱 | 传Login不是Login() |
| 2 | 用 Form 却忘记ParseForm | 空 map | 或用FormValue自动 Parse |
| 3 | 登录用FormValue被 URL 参数污染 | 安全/逻辑问题 | 敏感字段用PostFormValue |
| 4 | ListenAndServe与http.Server混用 | 超时不生效 | 二选一,配 Server 就server.ListenAndServe() |
| 5 | DefaultServeMux 与自定义 mux 混注册 | 部分路由 404 | 只用一个表 |
| 6 | FileServer 忘记 StripPrefix | 404 | 前缀成对写 |
| 7 | action 写成/Login大小写 | 404 | Go 路径大小写敏感 |
| 8 | 在 Write 之后 SetHeader | 头无效 | 先 Header 再 Write |
十、Mini FAQ
Q1:HandleFunc和Handle选哪个?
A:业务逻辑用HandleFunc;标准库提供的FileServer等已是Handler,用Handle。
Q2:为什么 ListenAndServe 第二个参数是 nil?
A:nil 表示使用DefaultServeMux,与前面http.HandleFunc配套。
Q3:GET 和 POST 参数都用 FormValue 行吗?
A:可以,GET 的 query 和 POST 的 body 都能取;但登录密码请用PostFormValue。
Q4:r.Form["username"][0]和 FormValue 区别?
A:FormValue 取第一个;Form 暴露全部值,适合 debug。
Q5:处理器里能读几次 body 吗?
A:body 是流,一般只能读一次;ParseForm会读并缓存到 Form/PostForm。
Q6:端口被占用怎么办?
A:改:8080为:8081,或关闭占用 8080 的进程。
