Go-Web从零开始的实战(六)

Go-Web从零开始的实战(六)

http是无状态的,Cookie是服务器“写给浏览器的纸条”。

无状态(Stateless)是http协议的核心设计,这意味着每次请求都是全新的、没有记忆的,即没有上下文。咋一看可能没感觉有啥问题,但是换个场景,你成功在网页上登录了你的账号,然后你想查看个人信息,但是服务器却不知道你是谁,因为你每次请求都是一个新的请求。

这可能听起来很荒唐,因为实际上我们大部分时候都很少遇到这种情况,因为服务器实际上通过多种方式(jwt自动续期、session等)避免了上述情况,Cookie也是一种方法。

我们再抽象下这个问题:http的无状态导致需要被记忆的信息无法被存储,那么解决方案最简单的就是每次请求都附带这些需要被记忆的信息就行。很明显这样确实可以解决问题,这里举个不恰当的例子:每次请求时都附带用户的用户名和密码,这样可以确保用户的信息,当然这很危险也很笨,你也不想每次都让服务器去数据库里面查询用户名和密码是否正确,但是这确实是一种办法。而且传输这些多余信息对网络带宽也是一种白白的占用。

Cookie的诞生

1994年,网景公司发明了Cookie,本质是服务器把“记忆”外包给浏览器,服务器设置了将数据存到返回的响应Cookie中,浏览器在接收到响应后发现有Cookie后会将其存到硬盘中。这还没完当访问该Url的同源Url时会在http的请求上附带这些Cookie信息,本质上来说跟上面的每次http请求时将需要传输的信息放到data中区别不大,只不过相当于官方封装(找了个专门的位置存储,并且制定了一套规范)

场景没有 Cookie有 Cookie
登录状态每页都要输入密码一次登录,全程有效
购物车添加商品 → 跳转页面 → 购物车空了关闭浏览器再打开,商品还在
用户偏好每次都要选"夜间模式"自动记住你的主题
广告追踪每次访问都是新用户能记录你的浏览历史(隐私问题)

为什么叫"外包"?

服务器说:“浏览器,我记性不好(HTTP 无状态),你帮我记点东西。”

浏览器说:“行,我记在小本本上(硬盘),但得按我的规矩来”:

  • 我给你个专门字段Cookie: header)
  • 我给你过期机制Expires
  • 我给你安全机制HttpOnlySecure
  • 我给你同源隔离(不同网站 Cookie 不串)

服务器:“成交!我只需要写 Set-Cookie,剩下的你包办。”

Cookie = 浏览器提供的"自动记忆"服务,它把"写/读/删/传"这四件事从开发者手里接管了,还附赠安全机制。

设置Cookie(服务器 → 浏览器)

func setCookieHandler(w http.ResponseWriter, r *http.Request) { // 创建一个 Cookie cookie := &http.Cookie{ Name: "username", Value: "yjj", Path: "/", // 生效路径 MaxAge: 3600, // 秒(1小时) HttpOnly: true, // 禁止 JS 读取(防 XSS) Secure: false, // true=仅限 HTTPS SameSite: http.SameSiteLaxMode, // 防 CSRF } // 写入响应头 http.SetCookie(w, cookie) fmt.Fprintln(w, "Cookie 已设置") } 
Set-Cookie: username=yjj; Path=/; Max-Age=3600; HttpOnly; SameSite=Lax 

读取Cookie(浏览器 → 服务器)

func getCookieHandler(w http.ResponseWriter, r *http.Request) { // 方式一:获取指定 Cookie cookie, err := r.Cookie("username") if err != nil { http.Error(w, "Cookie 不存在", http.StatusBadRequest) return } fmt.Fprintf(w, "username=%s\n", cookie.Value) // 方式二:获取所有 Cookie for _, c := range r.Cookies() { fmt.Fprintf(w, "%s=%s\n", c.Name, c.Value) } } 

删除Cookie

func deleteCookieHandler(w http.ResponseWriter, r *http.Request) { // 设置 MaxAge=-1 或 Expires 为过去时间 cookie := &http.Cookie{ Name: "username", MaxAge: -1, // 立即过期 } http.SetCookie(w, cookie) fmt.Fprintln(w, "Cookie 已删除") } 

Session:服务器存储

上面在Cookie中我们提到了,为什么需要Cookie。Cookie解决了一部分问题,但是如果数据过大该怎么办?且不论Cookie携带的数据上限4KB,就是每次都携带这么多信息也会导致的网络带宽浪费。那么有没有以一种方法可以避免呢?

Cookie大部分用于鉴权,存储用户信息,那么是不是可以通过在服务器上构建一个字典(哈希Map)来存储这些信息,在将唯一的Key作为Cookie存储到浏览器本地,这样就解决了需要携带大量信息的问题,而且还一定程度上保护了数据信息的安全。

也就是服务器存数据,浏览器存钥匙。

在发明 Cookie 后,开发者们很快发现刚才说的痛点,于是 Session 模式诞生了。

当然上述只是理想模型,还有很多实际问题:

  • 服务器重启怎么办?(持久化问题)当服务器重启就意味着内存情况,所有信息丢失,这个时候哈希Map就无法保证可靠性,必须是安全可靠的数据存储系统(数据库)。
  • 如果使用了负载均衡技术怎么办?(同步问题)用户请求的每台机器都需要Session,如果前后提供服务的机器不一致时会导致Session丢失,从而导致业务出现问题。当然你使用一台内存数据库来提供所有机器的Session存储功能,当需要Session都去访问内存数据库,这可以解决问题。也可以改变轮训策略,按照Session分配固定提供服务的机器(但是这个还是治标不治本,如果是提供服务的机器宕机,还是需要重新产生Session)
  • Key何时过期?(过期问题)如果一直不过期会导致内存数据库内存占用过高,这明显不可取,固定时间过期,可能会产生用户正在使用,但是Session突然过期。正确的解决方案是下面的结合
    • 用户主动退出 → Session 应该立即失效(否则别人还能用)
    • 用户 30 分钟没操作 → 应该自动过期(防忘记登出被利用)

Key怎样才算安全?(安全问题)随机数或者自增,这些都不算安全,真正解决方法是加密级随机数

import "crypto/rand" func generateSessionID() string { b := make([]byte, 32) // 256 位 rand.Read(b) // 密码学安全的随机 return base64.URLEncoding.EncodeToString(b) // 结果: "xY7zA9kP2qR8sT4vN6mL5wQ3eO1uI0oK" } 

Go的标准库中未提供Session,因为Session是“业务层”的概念,而不是“协议层”。

net/httpHTTP协议库,它只关心:

  • ✅ 解析请求行、请求头
  • ✅ 处理 Cookie(HTTP 标准的一部分)
  • ✅ 写响应、设置状态码

Session 是应用层的抽象:

  • ❌ 怎么存?内存?Redis?MySQL?
  • ❌ 存多久?30 分钟?1 天?滑动过期?
  • ❌ 分布式怎么办?服务器重启怎么办?
  • ❌ 如何防 Session 劫持?如何续期?

这些没有标准答案,取决于你的业务场景(电商?博客?银行系统?)。

虽然没提供但是根据上面的逻辑,我们可以简单实现一个Session

package main import ( "crypto/rand" _ "embed" "encoding/base64" "fmt" "net/http" "sync" "time" ) //go:embed static/index.html var indexHtml []byte type SessionData map[string]interface{} type SessionStore struct { mu sync.RWMutex sessions map[string]SessionData } var store = &SessionStore{sessions: make(map[string]SessionData)} func generateSessionID() string { b := make([]byte, 32) // 256 位 rand.Read(b) // 密码学安全的随机 return base64.URLEncoding.EncodeToString(b) } func getSession(r *http.Request) (string, SessionData) { // 从 Cookie 读 SessionID cookie, err := r.Cookie("sid") if err == nil { store.mu.RLock() if data, exists := store.sessions[cookie.Value]; exists { store.mu.RUnlock() return cookie.Value, data } store.mu.RUnlock() } // 不存在则创建 sessionID := generateSessionID() data := make(SessionData) store.mu.Lock() store.sessions[sessionID] = data store.mu.Unlock() return sessionID, data } // 保存 SessionID 到 Cookie func saveSession(w http.ResponseWriter, sessionID string) { http.SetCookie(w, &http.Cookie{ Name: "sid", Value: sessionID, Path: "/", MaxAge: 1800, // 30分钟 HttpOnly: true, // JS 无法读取(防 XSS) SameSite: http.SameSiteLaxMode, }) } // 删除 Session func deleteSession(w http.ResponseWriter, r *http.Request) { cookie, err := r.Cookie("sid") if err == nil { store.mu.Lock() delete(store.sessions, cookie.Value) // 从内存删除 store.mu.Unlock() // 删除浏览器 Cookie http.SetCookie(w, &http.Cookie{ Name: "sid", Value: "", Path: "/", MaxAge: -1, // 立即过期 }) } } // 个人中心(需要登录) func profileHandler(w http.ResponseWriter, r *http.Request) { _, sessionData := getSession(r) // 检查是否登录 if sessionData["is_login"] != true { http.Redirect(w, r, "/", http.StatusFound) return } w.Header().Set("Content-Type", "text/html; charset=utf-8") h := fmt.Sprintf(` <h2>欢迎, %s!</h2> <p>登录时间: %s</p> <a href="/logout">退出登录</a>`, sessionData["username"], sessionData["login_time"]) w.Write([]byte(h)) } // 退出登录 func logoutHandler(w http.ResponseWriter, r *http.Request) { deleteSession(w, r) http.Redirect(w, r, "/", http.StatusFound) } func main() { // 启动定时清理过期 Session(每 30 分钟) go func() { for { time.Sleep(30 * time.Minute) store.mu.Lock() for id, data := range store.sessions { // 删除 30 分钟未活动的 Session if time.Since(data["login_time"].(time.Time)) > 30*time.Minute { delete(store.sessions, id) } } store.mu.Unlock() } }() mux := http.NewServeMux() mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { w.Write(indexHtml) }) mux.HandleFunc("/login", func(w http.ResponseWriter, r *http.Request) { username := r.FormValue("username") password := r.FormValue("password") // 简单验证(真实业务需要进行数据库验证) if username == "yjj" && password == "123" { sessionId, sessionData := getSession(r) sessionData["username"] = "yjj" sessionData["is_login"] = true sessionData["login_time"] = time.Now() saveSession(w, sessionId) http.Redirect(w, r, "/profile", http.StatusSeeOther) return } fmt.Fprintln(w, "用户名或密码错误") return }) mux.HandleFunc("/profile", profileHandler) mux.HandleFunc("/logout", logoutHandler) http.ListenAndServe(":8080", mux) } 
<!DOCTYPE html> <html lang="en"> <head> <meta charset="UTF-8"> <title>File System</title> </head> <body> <h2>登录</h2> <form action="/login" method="post"> <label> 用户名: <input type="text" name="username"> </label> <label> 密码: <input type="password" name="password"> </label> <button type="submit">登录</button> </form> <p>测试账号:yjj</p> <p>测试密码:123</p> </body> </html> 

未完待续

Could not load content