--- tags: [Go, 后端开发, 武科大, Week03, 错题精讲] create time: 2026-05-02 14:30 --- # 金山考试 - 武科大服务端 - 第三次课 · 错题精讲 ## 概述 本次测试涵盖 **HTTP 协议基础、Gin 框架核心机制、CORS 跨域、JWT 认证、Nginx 反向代理** 五大模块,共 30 道单选题。其中 **26 道答对,4 道错题**,集中在 HTTP 基础认知、Gin 中间件执行控制、以及 JWT 解析流程三个知识面上。本笔记逐一拆解错误原因,给出正解辨析和代码验证。 > [!tip] 使用建议 > 先看错题本身的「为什么错 → 为什么对」,再做对照表的自测检查,最后用「高频考点速览」串联记忆薄弱点。 --- ## 一、HTTP 协议基础:GET/POST 与默认端口 ### 单1:GET 和 POST 的主要区别 | 项目 | 内容 | |------|------| | ❌ 你的答案 | A(GET 请求数据,POST 只提交数据) | | ✅ 正确答案 | **B(GET 数据附加在 URL 之后,POST 数据放在请求体内)** | > [!error] 你的误区根源 > 你选了 **A**,描述的是一个"规范层面"的说法——从 RESTful 最佳实践来说 GET 应该用于获取资源、POST 用于创建资源。但题目问的是**主要区别**,而两者最根本的、决定性的差异在于**数据传输位置**。 #### 为什么 B 才是本质区别 ```go // GET — 参数拼在 URL 后面 ?key=value&name=xxx GET /api/users?page=1&limit=10 HTTP/1.1 Host: example.com // Body 为空 // POST — 参数放在 Body 中 POST /api/users HTTP/1.1 Host: example.com Content-Type: application/x-www-form-urlencoded page=1&limit=10 ``` | 维度 | GET | POST | |------|-----|------| | 数据位置 | URL 查询字符串(`?key=value`) | 请求体(Body) | | 数据长度 | 受 URL 长度限制(通常 ~2KB) | 理论上无限制 | | 缓存行为 | 可被浏览器缓存、收藏 | 不被缓存 | | 幂等性 | ✅ 幂等(多次请求结果一致) | ❌ 非幂等(可能重复创建资源) | > [!warning] ⭐ A 选项为什么不选 > "GET 只获取、POST 只提交"是 RESTful **设计规范**,不是 HTTP 协议的硬性约束。实际上你可以 POST 一个纯查询接口,也可以用 GET 传大数据(虽然不规范)。考试中考查的是协议层的事实差异,而非设计哲学。 #### 延伸对比 ```mermaid flowchart LR subgraph GET["GET 请求结构"] G1["Method: GET"] --> G2["URL: /api?q=hello"] G2 --> G3["Header: ..."] G3 --> G4["Body: (empty)"] end subgraph POST["POST 请求结构"] P1["Method: POST"] --> P2["URL: /api"] P2 --> P3["Header: Content-Type"] P3 --> P4["Body: key=value&foo=bar"] end style G4 fill:#f99 style P4 fill:#9f9 ``` ### 单5:HTTP 默认端口号 | 项目 | 内容 | |------|------| | ❌ 你的答案 | D(8080) | | ✅ 正确答案 | **B(80)** | > [!memory] 常用端口速记表 | 端口 | 协议 | 说明 | |------|------|------| | **80** | HTTP | 超文本传输协议标准端口 ✅ | | **443** | HTTPS | HTTP over TLS/SSL | | **21** | FTP | 文件传输协议 | | **22** | SSH | 安全远程登录 | | **3306** | MySQL | 数据库 | | **5432** | PostgreSQL | 数据库 | | **8080** | HTTP(替代) | 开发/代理常用端口,非标准 | | **8443** | HTTPS(替代) | HTTPS 的常见替代端口 | > [!question] 💡 思考 > 如果 Go 程序监听 80 端口报错 `permission denied`,怎么办? > > Linux/macOS 下 1024 以下的端口需要 root 权限。解决方法:① 用 `sudo` 运行;② 将 Nginx 设为反向代理转发到 8080;③ 使用 `authbind` 或 `setcap` 授予特定端口权限。这也是生产环境推荐 Nginx 前置的原因之一。 --- ## 二、Gin 中间件核心机制 ### 单13:Gin 中间件中继续执行后续逻辑的方法 | 项目 | 内容 | |------|------| | ❌ 你的答案 | B(`c.Abort()`) | | ✅ 正确答案 | **A(`c.Next()`)** | > [!error] 核心混淆点 > 你把 `c.Next()` 和 `c.Abort()` 的作用搞反了。这是 Gin 中间件最关键的两个方法,作用完全相反: ```go // ✅ c.Next() — 暂停当前中间件,去执行后续所有中间件 + handler // 执行完后回来继续运行 Next() 之后的代码 func loggingMiddleware(c *gin.Context) { start := time.Now() // ① 前置:记录开始时间 c.Next() // ② 去执行后面的中间件和 handler... // ③ 执行完回到这里 fmt.Println(time.Since(start)) // ④ 后置:输出耗时 } // ❌ c.Abort() — 立即终止整个中间件链,后续的 middleware + handler 全都不执行 func authMiddleware(c *gin.Context) { token := c.GetHeader("Authorization") if token == "" { c.Abort() // ← 后面的所有内容都不再执行 return // ⚠️ Abort() 后建议紧跟 return } c.Next() // 认证通过,才放行 } ``` > [!summary] c.Next() vs c.Abort() 对比 | 方法 | 行为 | 类比 | |------|------|------| | `c.Next()` | 把请求交给下一个 handler,执行完回来继续 | **"过安检,过了继续走"** | | `c.Abort()` | 直接拦截,不再往下传递 | **"拦下来,不能进"** | | `c.AbortWithStatus(401)` | 终止 + 返回指定状态码 | **"不让你进,还给你个理由"** | | `c.AbortWithError(500, err)` | 终止 + 返回状态码 + 附带错误信息 | **"不让你进,还告诉你出了什么错"** | > [!abstract] c.Next() 底层实现 > > ```go > // gin/context.go 简化版 > func (c *Context) Next() { > c.index++ // 移动到下一个 handler > for ; c.index < int8(len(c.handlers)); c.index++ { > if c.IsAborted() { // 如果前面有调用 Abort() > return // 循环终止 > } > c.handlers[c.index](c) // 执行当前 handler > } > } > ``` > > `c.Abort()` 只是设置了一个内部标志位 `isAborted = true`,当 `Next()` 检测到这个标志就停止遍历。
💡 深入理解:中间件的"洋葱模型" Gin 中间件执行可以用经典的"洋葱模型"来理解: ```mermaid flowchart TD A["M1 前置处理"] --> B["M1.Next()"] B --> C["M2 前置处理"] C --> D["M2.Next()"] D --> E["Handler 执行"] E --> F["M2 后置处理"] F --> G["M1 后置处理"] style E fill:#FFD700 style A fill:#90EE90 style B fill:#90EE90 style C fill:#90EE90 style D fill:#90EE90 style F fill:#FFB6C1 style G fill:#FFB6C1 ``` - **进入时**:M1 → M2 → Handler(按注册顺序) - **退出时**:Handler → M2 → M1(按逆序,栈的行为) - 这就是为什么日志中间件既能记录请求开始时间,也能记录响应耗时。
### 单12 & 单17:Gin 全局中间件注册与上下文共享 > [!note] 这两题你答对了 ✅,放在一起巩固: **单12:全局注册中间件** - 正确写法:`router.Use(middleware)` - Gin 没有 `RegisterMiddleware`、`AttachMiddleware`、`Middleware` 这些方法 **单17:中间件之间共享数据** - 正确方式:`c.Set(key, val)` / `c.Get(key)` 存入 Context 的 `Keys` map - 绝对不要用全局变量!每个请求的 Context 是隔离的 ```go // 中间件1:设置用户信息 func authMiddleware(c *gin.Context) { userID := parseToken(c.GetHeader("Authorization")) c.Set("userID", userID) // 存入 Context c.Set("role", getRole(userID)) c.Next() } // 中间件2/Handler:读取用户信息 func listUsers(c *gin.Context) { userID := c.GetString("userID") // 安全获取 role := c.MustGet("role").(string) // 断言获取(确保 key 存在) _ = role } ``` ### 单14 & 单15 & 单27 & 单28:Gin 参数解析与绑定 > [!note] 这四题你全部答对了 ✅,汇总关键 API: | 场景 | Gin API | 示例 | |------|---------|------| | JSON body → Struct | `c.ShouldBindJSON(&obj)` | `POST /api/user` 接收 JSON | | 路径参数 `:id` | `c.Param("id")` | `GET /user/:id` → `id="123"` | | 查询参数 `?key=` | `c.Query("key")` | `GET /search?q=hello` | | Form 表单 | `c.PostForm("field")` | `POST` x-www-form-urlencoded | | 动态路由定义 | `router.GET("/user/:id", h)` | Gin 用 `:` 前缀,不是 `{}` | | JSON 响应 | `c.JSON(200, gin.H{...})` | 返回标准 JSON | | 未认证状态码 | 401 Unauthorized | 400 是请求格式错,401 是未认证 | --- ## 三、JWT 认证:解析步骤与上下文传递 ### 单19:Golang 中解析 JWT 的正确步骤 | 项目 | 内容 | |------|------| | ❌ 你的答案 | C | | ✅ 正确答案 | **A(解码 → 验证签名 → 检查声明)** | > [!error] 你的误区 > 你可能凭直觉先"检查声明"(比如看看过期时间),但这样做是有安全隐患的:**在没有验证签名的前提下检查声明,等于信任了可能被篡改的数据。** #### 正确的 JWT 解析三步法 ```go import ( "github.com/golang-jwt/jwt/v5" ) func parseJWT(tokenString string) (*jwt.Claims, error) { // Step 1: 解码(Decoding)— 把 Base64Url 编码的三个部分拆出来 // 不验签名,只看格式是否合法 token, _, err := new(jwt.Parser).ParseUnverified(tokenString, jwt.MapClaims{}) if err != nil { return nil, fmt.Errorf("token format invalid: %w", err) } // Step 2: 验证签名(Signature Verification)— 用密钥重新计算 HMAC SHA256 // 确保 token 没有被第三方篡改 token, err = jwt.Parse(tokenString, func(token *jwt.Token) (interface{}, error) { return []byte("your-secret-key"), nil // 必须用同一个密钥 }) if err != nil || !token.Valid { return nil, fmt.Errorf("signature invalid: %w", err) } // Step 3: 检查声明(Claims Validation)— 此时才能相信数据 claims, ok := token.Claims.(jwt.MapClaims) if !ok { return nil, fmt.Errorf("invalid claims type") } // 检查过期时间 if time.Unix(int64(claims["exp"].(float64)), 0).Before(time.Now()) { return nil, fmt.Errorf("token expired") } return &claims, nil } ``` > [!warning] ⭐ 高频考点:为什么顺序不能乱? > > ``` > 错误的做法:先读 exp → 发现过期 → 告诉客户端"过期了" > ↑ 黑客可以伪造 exp = 未来时间来绕过检查 > > 正确的做法:先验签名 → 确认数据未被篡改 → 再读 exp → 确实是 2024-01-01 > ``` > > 签名验证是**信任锚**——只有签名正确,里面的所有声明才可信。 #### JWT 结构速记 ```mermaid flowchart LR A["JWT Token"] --> B["Header
Base64Url(JSON)"] A --> C["Payload / Claims
Base64Url(JSON)"] A --> D["Signature
HMACSHA256
base64UrlHeader.payload + secret"] B -->|"alg + typ"| C C -->|"签名算法对
Header+Payload
加上 Secret"| D style D fill:#9f9 ``` 三部分用 `.` 连接:`xxxxx.yyyyy.zzzzz` > [!memory] JWT 解析三步口诀 > **"先拆开看格式 → 再验身保真 → 最后读内容"** > > 解码 → 验签名 → 查声明 ### 单10 & 单11:JWT 解决的核心痛点与最佳实践 > [!note] 这两题你答对了 ✅,补充完整理解: **单10:JWT 解决的核心痛点** - 传统 Session:服务器内存/Redis 存储会话,集群环境下需要同步共享 - JWT:**无状态**,服务端无需存储会话记录即可验证有效性 - 注意:JWT 并没有加密用户密码,字符串也比 SessionID **更长**(多了 Header + Payload) **单11:JWT 验证后的最佳实践** - 中间件就像"查票员",查验通过后通过 `c.Set()` 把用户信息存入当前请求的 Context - 这样后续 Handler 能安全、隔离地拿到用户信息,不会互相干扰 ```go func JWTAuthMiddleware() gin.HandlerFunc { return func(c *gin.Context) { tokenString := c.GetHeader("Authorization") if tokenString == "" { c.AbortWithStatusJSON(401, gin.H{"error": "missing token"}) return } claims, err := parseJWT(tokenString) if err != nil { c.AbortWithStatusJSON(401, gin.H{"error": "invalid token"}) return } // ✅ 最佳实践:存入 Context,供下游使用 c.Set("userID", claims.UserID) c.Set("username", claims.Username) c.Next() } } ``` --- ## 四、Cookie 安全与 Session 管理 ### 单7:防止 XSS 攻击的 Cookie 防护 | 项目 | 内容 | |------|------| | ✅ 正确答案 | **C(httpOnly 设置为 true)** | > [!note] 此题你答对了 ✅,巩固关键知识点: ```go // Set-Cookie 的关键属性 http.SetCookie(w, &http.Cookie{ Name: "session_id", Value: sessionValue, Path: "/", MaxAge: 3600, // 有效期(秒),0 表示浏览器关闭即失效 HttpOnly: true, // ✅ 防 XSS —— JS 无法 document.cookie 读取 Secure: true, // ✅ 防窃听 —— 仅 HTTPS 传输 SameSite: http.SameSiteStrictMode, // ✅ 防 CSRF —— 严格同源策略 }) ``` | 属性 | 作用 | 防御目标 | |------|------|---------| | `HttpOnly` | 禁止 JavaScript 读取 | XSS 窃取 Cookie | | `Secure` | 仅 HTTPS 传输 | 中间人窃听 | | `SameSite` | 限制跨站携带 Cookie | CSRF 攻击 | | `Max-Age` | 控制有效期 | 延长攻击窗口 | ### 单9:Gin 与 Session | 项目 | 内容 | |------|------| | ✅ 正确答案 | **C(Session 本质是后端存状态,前端只保存 SessionID)** | > [!note] 此题你答对了 ✅。核心要点: > - Gin 官方库**不自带** Session,需第三方中间件(如 `gorilla/sessions`) > - Session **数据在服务端**,不在客户端(那是 Cookie 的做法) > - 关闭浏览器不等于删除服务端 Session(取决于 `Max-Age` 或服务端清理策略) ### 单21:Session ID 的存储方式 | 项目 | 内容 | |------|------| | ✅ 正确答案 | **B(Set-Cookie)** | > [!note] 此题你答对了 ✅。首次访问时服务端在 HTTP 响应的 Headers 里写入 `Set-Cookie: session_id=abc123`,浏览器自动保存,后续请求自动带上。 --- ## 五、CORS 跨域问题专题 ### 单22 ~ 单26:跨域全链路 > [!note] 这 5 道题你全部答对了 ✅,涵盖完整的跨域知识体系。整理如下: **单22:生产环境跨域的标准解决方案** - Nginx 反向代理统一域名 / CORS 中间件精确配置 **单23:为什么要用 Nginx 做反向代理** - 静态文件分发、隐藏后端 IP、负载均衡、限流 **单24:OPTIONS 预检请求的本质** - CORS Preflight:复杂请求前先问后端"我的自定义头你能接受吗?" **单25:Vite Proxy 的开发阶段原理** - 前端请求发给 Node.js 开发服务器 → 服务器转发给真实后端 → 原样返回 **单26:CORS 报错的根本原因** - 浏览器的同源策略(Same-Origin Policy):防止恶意网站窃取敏感数据 > [!abstract] 同源策略判定 ``` 同源 = 协议相同 && 域名相同 && 端口相同 http://localhost:3000 → http://localhost:8080 ❌ 端口不同,跨域! https://app.example.com → https://api.example.com ❌ 域名不同,跨域! ``` 详见 `[[DEV/跨域问题调试]]` 的详细排查流程。 --- ## 六、HTTP 状态码与语义 ### 单3、单6、单29:HTTP 基础概念 | 题号 | 考查点 | ✅ 答案 | 解析 | |------|--------|---------|------| | 单3 | "未修改"状态码 | **304** | 浏览器发 `If-Modified-Since` / `If-None-Match`,服务端返回 304 让浏览器用本地缓存 | | 单6 | 资源未被修改的状态码 | **304** | 和单3 考同一个知识点,换了一种问法 | | 单29 | HTTP "无状态"的含义 | **每次请求独立,服务器不记住之前请求** | 这意味着需要 Cookie/Session/JWT 来维护用户状态 | > [!memory] 与缓存相关的状态码 | 状态码 | 含义 | 触发条件 | |--------|------|---------| | **200** | 正常成功 | 首次请求 / 缓存失效 | | **304** | 未修改 | 缓存命中,服务端通知浏览器直接用本地副本 | | **206** | 部分内容 | Range 请求(视频断点续传等场景) | --- ## 七、其他知识点 ### 单30:Git 临时保存工作进度 | 项目 | 内容 | |------|------| | ✅ 正确答案 | **C(git stash)** | > [!note] 此题你答对了 ✅。 ```bash git stash # 保存当前工作区为"栈顶" git stash pop # 恢复最近一次 stash(同时删除它) git stash list # 查看所有 stash git stash apply # 恢复但不删除(留在栈里) ``` --- ## 错题自测表 > [!summary] 遮住右侧,自己检验是否真正理解了 | 题目 | 考查点 | ❌ 你的原答案 | ✅ 正确答案 | 关键概念 | |------|--------|-------------|------------|---------| | 单1 | GET vs POST 的本质区别 | A | **B** | URL 参数 vs Body,是事实差异而非设计规范 | | 单5 | HTTP 默认端口 | D (8080) | **B (80)** | 80 是标准端口,8080 是常见替代 | | 单13 | Gin 中间件继续执行的方法 | B (Abort) | **A (Next)** | Next() 继续执行,Abort() 中断链 | | 单19 | JWT 解析正确顺序 | C | **A** | 解码 → 验签名 → 查声明(签名是信任锚) | --- ## 高频考点速览 > [!tip] 考前快速过一遍 | 类别 | 考点 | 一句话口诀 | |------|------|-----------| | GET vs POST | 数据位置不同(URL vs Body) | **"GET 挂在 URL 上,POST 装在 Body 里"** | | HTTP 默认端口 | 80 | **"HTTP=80,HTTPS=443"** | | HTTP 无状态 | 每次请求独立 | **"喝醉就忘,下次重来"** | | 304 状态码 | 资源未修改,走缓存 | **"没变就用老的"** | | Gin c.Next() | 继续执行后续中间件 + handler | **"过桥费,交了才能走"** | | Gin c.Abort() | 中断中间件链 | **"栏杆放下,不让过"** | | Gin 全局中间件 | router.Use(middleware) | Use,不是 Register / Attach | | Gin 共享数据 | c.Set() / c.Get() | Context 里存取,别用全局变量 | | Gin 路径参数 | `/user/:id` | Gin 用 `:` 而不是 `{}` | | Gin JSON 绑定 | c.ShouldBindJSON(&obj) | 从请求体映射到 Struct | | 401 状态码 | 未认证/缺少凭证 | **"401 = 你是谁?"** | | Cookie httpOnly | 防 XSS 读取 | **"JS 看不见,只有浏览器带"** | | Session 本质 | 后端存状态,前端拿 SessionID | **"服务器记账,前端拿号码牌"** | | JWT 解析三步 | 解码 → 验签名 → 查声明 | **"先看皮相,再验身份证,最后读内容"** | | CORS 根因 | 浏览器同源策略 | **"不同源的请求,浏览器不放心"** | | OPTIONS 预检 | 复杂请求前的"询问" | **"我先问问你能不能干"** | | Git stash | 临时保存工作进度 | **"先收起来,干完别的再放回"** | | Nginx 前置 | 反向代理隐藏后端、负载均衡 | **"Nginx 挡在前面,后端躲在后面"** | --- ## 关联笔记 - [[hzh/EXAM]] - [[GIN/3-middleware]] — 中间件完整机制(含 c.Next() / c.Abort() 详解) - [[GIN/4-context-lifecycle]] — Context 生命周期与方法速查 - [[GIN/3-middleware/jwt-auth-qa]] — JWT 中间件安全性 - [[DEV/跨域问题调试]] — 跨域排查全流程