Appearance
11 HTTP 与 gRPC:两种接口形态与它们共同的骨架
详细程度:深入 · 约 75 分钟 材料:TB09 W02-1 W03-1 W04-1 W04-2 W05-1 W05-2 W10-1 W13-2、C:go_basic/http C:go_basic/grpc C:go_basic/protoc、旧长版
notes/p10-grpc.mdnotes/p8-fast-track.md前置:04 接口与组合,10 分层与依赖注入
先看一个反直觉的现象
注册了 GET /books/{id} 和 POST /books 两条路由之后,POST /books/42 应该返回什么?直觉是 404:POST /books/42 这个"方法加路径"的组合我根本没注册过。Go 1.22 起的新路由器给出的却是 405。先跑一遍再往下读:
go
package main
import (
"fmt"
"io"
"net/http"
"net/http/httptest"
"strings"
)
func main() {
mux := http.NewServeMux()
mux.HandleFunc("GET /books/{id}", func(w http.ResponseWriter, r *http.Request) {
fmt.Fprintf(w, "book %s", r.PathValue("id"))
})
mux.HandleFunc("POST /books", func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusCreated)
})
srv := httptest.NewServer(mux)
defer srv.Close()
for _, req := range []struct{ method, path string }{
{"GET", "/books/42"},
{"POST", "/books/42"},
{"GET", "/nope"},
} {
r, _ := http.NewRequest(req.method, srv.URL+req.path, nil)
resp, err := http.DefaultClient.Do(r)
if err != nil {
panic(err)
}
body, _ := io.ReadAll(resp.Body)
resp.Body.Close()
fmt.Printf("%s %s -> %d %s\n", req.method, req.path, resp.StatusCode, strings.TrimSpace(string(body)))
}
}text
GET /books/42 -> 200 book 42
POST /books/42 -> 405 Method Not Allowed
GET /nope -> 404 404 page not found405 而不是 404 的原因:新路由器把"方法和路径"当成一段来匹配。/books/42 的路径段命中了 GET /books/{id} 里的 {id} 通配符,所以这个路径它是"认识"的,只是方法 POST 对不上,于是返回 405,并在响应头里写上 Allow: GET, HEAD 声明它支持哪些方法。而 /nope 连路径都不认识,才落到 404。
这个细节本身不是重点,它揭开的是整章的骨架:一个 router 把请求分给一个 handler,handler 外面再套一层层中间件。HTTP 和 gRPC 是这同一副骨架的两种面孔,区别只在签名。理解了骨架,换标准库、gin、gRPC 只是换签名。
和你已知语言的差异
| 问题 | Java / Kotlin | Rust | TS | Go |
|---|---|---|---|---|
| HTTP 处理函数 | Spring @GetMapping 方法 | axum async fn + extractor | Express (req, res) 回调 | 固定原型 func(w, r) |
| 横切逻辑怎么写 | Servlet Filter / HandlerInterceptor | tower Layer<Service> 洋葱 | Express middleware (req, res, next) | func(http.Handler) http.Handler |
| RPC 契约 | gRPC-java:.proto 生成 stub | tonic:.proto 生成 trait | @grpc/grpc-js 生成 | grpc:.proto 生成 interface |
| 路由能力放哪 | 框架注解 | 框架宏 | 框架路由表 | 1.22 起标准库自带 |
最陌生的是最后一行:Java 里写 Web 服务几乎默认配一个框架,Go 里标准库 net/http 自己就能把路由这件事做完整。这一节先讲标准库能干什么,再讲 gin 到底补了什么。
一、HTTP:标准库已经够用
最小服务只需要两个调用:http.HandleFunc 注册处理函数,http.ListenAndServe 启动监听。处理函数的原型是固定的 func(w http.ResponseWriter, r *http.Request):r 是进来的请求,w 用来写响应。所有基于标准库的 handler 都长这个样。〔TB09 p3-4〕
2021 年的课程还在为"方法加路径"的路由引入第三方包 gorilla/mux,靠 Methods("POST") 和 mux.Vars 才能做的事,现在标准库直接支持:〔TB09 p13-14〕〔C:go_basic/http/server/main.go:176-185〕
go
mux := http.NewServeMux()
mux.HandleFunc("GET /books/{id}", func(w http.ResponseWriter, r *http.Request) {
fmt.Fprintf(w, "book %s", r.PathValue("id"))
}){id} 是路径通配符,r.PathValue("id") 把它取出来;GET 前缀限制了方法。方法对不上自动 405,没匹配的路径自动 404,这些都不用自己写。
响应顺序是一条铁律
写响应头、状态码、响应体三件事有严格先后:先 w.Header().Add,再 w.WriteHeader(status),最后 w.Write(body)。〔C:go_basic/http/server/main.go:32-37〕
原因是状态码和 header 在 WriteHeader 被调用(或者 Write 隐式调用它)的那一刻就定下来了,之后再改 w.Header() 已经影响不到发出去的响应头。具体有三条推论:
WriteHeader只有第一次生效,第二次不起作用,标准库还会在日志里打一条superfluous response.WriteHeader call。Write之前没调WriteHeader,就隐式按 200 发。WriteHeader之后再w.Header().Add无效。〔实测〕Go 1.26 下先WriteHeader(201)再Add一个头,客户端读不到。
配套的另一半是客户端侧:用完必须关 resp.Body,否则这条连接不复用,每次请求都新建连接,协程和内存一起泄漏。便捷方法 http.Get 也一样要关。〔C:go_basic/http/client/main.go:25〕
超时:只设一个远远不够
长耗时处理函数要感知"服务端或客户端已经放弃",靠的是 r.Context()。用 http.TimeoutHandler 包一层,超时后处理函数的 context 会被取消:
go
package main
import (
"fmt"
"net/http"
"net/http/httptest"
"time"
)
func main() {
done := make(chan struct{})
mux := http.NewServeMux()
mux.HandleFunc("/slow", func(w http.ResponseWriter, r *http.Request) {
defer close(done)
select {
case <-time.After(300 * time.Millisecond):
w.Write([]byte("finished"))
case <-r.Context().Done():
fmt.Printf("handler sees: %v\n", r.Context().Err())
}
})
srv := httptest.NewServer(http.TimeoutHandler(mux, 100*time.Millisecond, "timeout"))
defer srv.Close()
resp, err := http.Get(srv.URL + "/slow")
if err != nil {
panic(err)
}
resp.Body.Close()
<-done
fmt.Printf("client gets: %d\n", resp.StatusCode)
}text
handler sees: context deadline exceeded
client gets: 503超时包装器 100ms 一到就向客户端回 503,同时把处理函数的 r.Context() 取消,处理函数里的 r.Context().Err() 变成 context deadline exceeded,它就可以停下。这里也暴露一个生产上的坑:http.Server 的 ReadHeaderTimeout 只管"读完请求头",慢速读 body、慢速写响应它都不管。只设一个超时是资源泄漏的入口,ReadTimeout、WriteTimeout、IdleTimeout 要按业务一起填上。
优雅退出
套路固定:signal.Notify 捕获 SIGINT 和 SIGTERM,收到后用带超时的 context 调 srv.Shutdown(ctx)。Shutdown 立即关闭所有 listener、关闭空闲连接、等活动连接处理完才返回。〔TB09 p9〕这个行为可以直接跑出来验证:
go
package main
import (
"context"
"fmt"
"net/http"
"os"
"os/signal"
"syscall"
"time"
)
func main() {
srv := &http.Server{Addr: "127.0.0.1:0", Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Write([]byte("ok"))
})}
errCh := make(chan error, 1)
go func() { errCh <- srv.ListenAndServe() }()
fmt.Println("serving")
sig := make(chan os.Signal, 1)
signal.Notify(sig, syscall.SIGINT, syscall.SIGTERM)
go func() {
time.Sleep(50 * time.Millisecond)
syscall.Kill(os.Getpid(), syscall.SIGTERM)
}()
<-sig
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
fmt.Println("shutdown:", srv.Shutdown(ctx))
fmt.Println("serve returned:", <-errCh)
}text
serving
shutdown: <nil>
serve returned: http: Server closed两行关键输出:Shutdown 返回 nil(正常退出),ListenAndServe 随后返回 http: Server closed。这个错误是 http.ErrServerClosed,是关闭成功的标志,不要当错误处理。ListenAndServe 会阻塞,所以必须放进独立 goroutine,再用 channel 把它的返回值传回主 goroutine。〔TB09 p16-17〕
二、中间件:一个处理函数加一串包装
前面已经看到 router 和 handler 的分工,现在看第三层:中间件。它的类型签名一句话说清:
go
func middleware(next http.Handler) http.Handler接受一个 handler,返回一个 handler。返回的那个在调用 next 之前做前置逻辑,之后做后置逻辑,于是任意多个中间件可以像洋葱一样套在业务 handler 外面。日志、鉴权、限流、跨域都挂在这一层,业务代码不动。〔TB09 p14-16〕下面同时演示洋葱顺序和响应头顺序:
go
package main
import (
"fmt"
"net/http"
"net/http/httptest"
)
func withLogger(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
fmt.Println("before:", r.URL.Path)
next.ServeHTTP(w, r)
fmt.Println("after:", r.URL.Path)
})
}
func main() {
mux := http.NewServeMux()
mux.HandleFunc("GET /hello", func(w http.ResponseWriter, r *http.Request) {
w.Header().Add("X-Note", "set-before-write")
w.WriteHeader(http.StatusOK)
w.Write([]byte("hi"))
})
srv := httptest.NewServer(withLogger(mux))
defer srv.Close()
resp, err := http.Get(srv.URL + "/hello")
if err != nil {
panic(err)
}
resp.Body.Close()
fmt.Println("client sees X-Note:", resp.Header.Get("X-Note"))
}text
before: /hello
after: /hello
client sees X-Note: set-before-writebefore 打印在 handler 之前、after 在之后,说明中间件确实"包"住了 handler;X-Note 在 WriteHeader 之前设置所以客户端能读到。这套"处理函数加包装"的骨架,是 gRPC 拦截器在 HTTP 里的对应物,第五部分会看到两边只是签名不同。
三、认证:从 Session 到长短 token
中间件是鉴权的挂载点,但鉴权本身教什么?这一节把登录态的完整生命周期讲完:怎么建立(Session/JWT)、怎么维持(过期与刷新)、怎么登出(无状态的代价)、第三方登录怎么接(OAuth2)。〔W03-1 p2〕
Session:状态存在服务端
登录成功后服务端建一张会话,会话 ID 放进 Cookie,会话数据(user_id 之类)存在服务端存储里。gin-contrib/sessions 的存储实现有 cookie、memstore、redis、gorm 等一排,选型一句话:单机随便,多实例必须选 redis,否则实例 0 签发的会话实例 1 认不得。sess_id 永远放 Cookie,Session 数据才是 store 里存的东西,别搞混。〔W03-1 p6-8〕
过期策略有四种:每次访问都刷(性能差)、快过期才刷、固定间隔刷、长短 token。前三种都能用「登录校验中间件顺手刷新」实现,第四种在下面讲。固定过期时间的问题很具体:设 10 分钟,9:59 还能用、10:01 就被踢,这对持续活跃的用户反而体验差。〔W03-1 p16-18〕
JWT:把状态签进 token
JWT 是三段式:header.payload.signature,前两段是 base64 的 JSON,第三段是对前两段的 HMAC 签名。它的哲学和 Session 正相反:状态不在服务端,由签名保证不可篡改。用标准库就能把签发与校验的全过程走一遍:〔W03-1 p22-23〕
go
package main
import (
"crypto/hmac"
"crypto/sha256"
"encoding/base64"
"encoding/json"
"fmt"
"strings"
"time"
)
func sign(parts []string, key []byte) string {
mac := hmac.New(sha256.New, key)
mac.Write([]byte(strings.Join(parts, ".")))
return base64.RawURLEncoding.EncodeToString(mac.Sum(nil))
}
func issue(userID int64, ttl time.Duration, key []byte) string {
header := base64.RawURLEncoding.EncodeToString([]byte(`{"alg":"HS256","typ":"JWT"}`))
payload, _ := json.Marshal(map[string]any{
"uid": userID,
"exp": time.Now().Add(ttl).Unix(),
})
p := base64.RawURLEncoding.EncodeToString(payload)
return header + "." + p + "." + sign([]string{header, p}, key)
}
// validate 返回 uid。任何一段被改过,HMAC 都对不上。
func validate(token string, key []byte) (int64, error) {
parts := strings.Split(token, ".")
if len(parts) != 3 {
return 0, fmt.Errorf("格式不对")
}
want := sign(parts[:2], key)
if !hmac.Equal([]byte(want), []byte(parts[2])) {
return 0, fmt.Errorf("签名不符")
}
raw, err := base64.RawURLEncoding.DecodeString(parts[1])
if err != nil {
return 0, err
}
var claims struct {
UID int64 `json:"uid"`
EXP int64 `json:"exp"`
}
if err := json.Unmarshal(raw, &claims); err != nil {
return 0, err
}
if time.Now().Unix() > claims.EXP {
return 0, fmt.Errorf("已过期")
}
return claims.UID, nil
}
func main() {
key := []byte("test-key-32-bytes-long-xxxxxxxx")
token := issue(42, time.Minute, key)
fmt.Println("段数:", len(strings.Split(token, ".")))
uid, err := validate(token, key)
fmt.Printf("正常校验: uid=%d err=%v\n", uid, err)
parts := strings.Split(token, ".")
ev, _ := base64.RawURLEncoding.DecodeString(parts[1])
forged := strings.Replace(string(ev), `"uid":42`, `"uid":100`, 1)
parts[1] = base64.RawURLEncoding.EncodeToString([]byte(forged))
_, err = validate(strings.Join(parts, "."), key)
fmt.Println("篡改后:", err)
_, err = validate(token, []byte("another-key-32-bytes-long-xxxxx"))
fmt.Println("换密钥:", err)
expired := issue(42, -time.Second, key)
_, err = validate(expired, key)
fmt.Println("过期:", err)
}text
段数: 3
正常校验: uid=42 err=<nil>
篡改后: 签名不符
换密钥: 签名不符
过期: 已过期四行输出就是 JWT 的全部性质:payload 只是 base64 不是加密(谁都能解开看),但改一个字节签名就废;换密钥等于全量登出;过期是 payload 里的一个时间戳比较。课程接 gin 时用 golang-jwt/jwt/v5 的原始 API 而不是插件,因为插件把签发校验包起来反而看不清机制;token 走 x-jwt-token 响应头下发、Authorization: Bearer 上送,CORS 要相应放行 AllowHeaders: Authorization 和 ExposeHeaders: X-Jwt-Token,否则浏览器读不到。〔W03-1 p24-30〕课程里的校验写法:GetHeader("Authorization") 后 strings.SplitN(authcode, " ", 2) 取第二段(最多切两段,token 里不会再有空格),再 jwt.ParseWithClaims(tokenStr, &uc, keyfunc)。前端配合是响应拦截器读 x-jwt-token 存 localStorage、请求拦截器统一 setAuthorization("Bearer "+token)、401 跳登录页。〔W03-1 p27-28〕
登出:无状态的代价
JWT 不可变,"退出登录"没法把已签发的 token 变无效:多实例下登出请求落实例 0、后续请求落实例 1,任何单机手段都拦不住。唯一根治是外部记录:Redis 里记一组不可用的 ssid(UUID,登录时同时签进长短两个 token),登出时 SET ssid 进去,登录校验和 refresh 都查它。记"不可用"而不是"可用",因为退出比登录低频得多,黑名单几百条,白名单上百万条。〔W05-2 p14-15〕〔W05-2 p2〕
黑名单只解决"主动登出",没解决"token 被盗"。方案是长短双 token:access token 短(几十分钟),高频携带易泄露;refresh token 长(7 天)且只在登录和刷新两个低频场景出现,不易泄露。前端拿到 401 就调 refresh 接口换新短 token,登录校验中间件里的自动续期逻辑删掉。refresh 的价值恰恰在于它低频;要是放在中间件里每次请求自动换发,那就和续期没区别,白做了。长 token 也泄露的缓解:把 User-Agent 等登录时信息签进去,或每次刷新轮换长 token,都只能缓解。〔W05-2 p3-8〕〔W05-2 p9〕〔W05-2 p10〕〔W05-2 p16-17〕
OAuth2 扫码:第三方登录是授权的特例
微信扫码登录本质是 OAuth2 授权码流程,三步:跳微信的授权页(后端拼 URL)、用户扫码确认、微信带着 code 回调你注册的 redirect_uri,后端再拿 code+appid+secret 换 access_token,最后 open_id 走 FindOrCreateByWechat 建账号。拼 URL 的五个参数:appid、redirect_uri(必须是注册时填的域名)、response_type=code、scope=snsapi_login、state(随机 UUID)。〔W05-1 p4-8〕〔W05-1 p9-12〕〔W05-1 p15-16〕〔W05-1 p23〕
两个细节最容易被糊弄过去。其一,URL 由后端拼而不是前端:state 要在后端校验,后端拼了改起来才方便。其二,state 防的是 CSRF 登录绑定攻击:攻击者先用自己的微信走一遍授权拿到 code,伪造页面诱导已登录用户点击,把攻击者的微信绑到受害者账号上;有 state(登录时发、回调时验)这条路就走不通。换回来的字段有两组:授权侧 access_token/expires_in/refresh_token,ID 侧 open_id/union_id。同一用户在 A、B 两个产品各有 open_id,union_id 全公司同一个,多产品账号打通靠它。你还能认出来:微信的 access/refresh 就是长短 token 的原型,自研双 token 方案就是从这里抄的作业。〔W05-1 p20-22〕〔W05-1 p24〕〔W05-1 p28-29〕
短信验证码登录:一次"检查并写入"的原子化
验证码登录是国内产品的默认登录方式,它的技术含量不在收发短信,在两条安全线和一次并发竞态。两条安全线:发码端控制频率(同号一分钟一条、验证码十分钟有效,外加系统限流兜底),校验端防暴力破解(验证通过即作废;失败三次作废;提示只说"不对",不告诉对方还剩几次机会)。〔W04-2 p26〕竞态在发码端:"检查这个号一分钟内发过没有"和"写入发送记录"是两步,两个请求同时检查都看到"没发过",就双发短信,多花的是真金白银。这是分布式业务并发,进程内的 sync.Mutex 管不了别的实例,只能靠 Redis 单线程执行 lua 脚本,把检查和写入封成原子操作。〔W04-2 p30-31〕〔W04-2 p2〕〔W04-2 p7〕
lua 脚本里 key 的剩余 TTL 就是状态机,三分支:不存在或冷却已过就写入验证码、过期 600 秒、计数键置 3;剩余 TTL 大于 9 分钟说明一分钟冷却还没过,拒绝;有 key 但没有过期时间属于异常状态,宁可拒绝服务也不猜。〔W04-2 p28〕〔W04-2 p32〕用锁模拟 Redis 的单线程语义跑一遍:
go
package main
import (
"errors"
"fmt"
"sync"
"time"
)
var (
ErrTooMany = errors.New("发送太频繁")
ErrKeyBroken = errors.New("key 异常,拒绝服务")
)
// store 模拟 Redis 执行 lua:检查与写入之间插不进别的命令。
type store struct {
mu sync.Mutex
codeTTL map[string]time.Duration
}
func (s *store) send(key string) error {
s.mu.Lock()
defer s.mu.Unlock()
ttl, ok := s.codeTTL[key]
switch {
case ok && ttl == -1: // 有 key 无过期时间:疑似误操作,拒绝
return ErrKeyBroken
case !ok || ttl <= 9*time.Minute: // 没发过,或冷却已过
s.codeTTL[key] = 10 * time.Minute
return nil
default: // 冷却期内
return ErrTooMany
}
}
func main() {
s := &store{codeTTL: map[string]time.Duration{}}
fmt.Println(s.send("login:138"))
fmt.Println(s.send("login:138"))
}text
<nil>
发送太频繁校验端有个反直觉的细节:验证成功后不能删 key,而是把计数键置成 -1 作废,key 还在但永远校验不过。如果直接删掉,"删除后立刻再发一条"的绕过路径就打开了。〔W04-2 p34-35〕另一个是并发注册:新手机号首次登录,FindOrCreate 两个请求同时发现"没有这个用户"就双建账号,靠手机号唯一索引兜底,第二个插入失败转查询。〔W04-2 p45-46〕最后是业务隔离:登录、改密码、危险操作二次验证都用验证码,key 里必须带 biz 维度(phone_code:$biz:$phone),各业务冷却与有效期独立;验证码服务和短信发送服务也值得拆开,短信供应商可能从腾讯换成阿里,验证码逻辑不该跟着改。〔W04-2 p10-11〕〔W04-2 p27-28〕登录 handler 本身只是编排:Verify 通过就 FindOrCreate 再 setJWTToken,ok=false 提示验证码错误,err 分系统异常与验证码错误两路。〔W04-2 p44〕
发码和登录两个接口都挂在 UserHandler 上(POST /login_sms/code/send 与 POST /login_sms),UserHandler 同时聚合 CodeService 和 UserService;严格做法是再封一个 UserAggrService,但课程认为还没复杂到那步。发码 handler:正则与非空校验手机号、调 CodeService.Send、按 err 分流(nil 成功、ErrCodeSendTooMany 提示太频繁、default 系统错误并打日志)。〔W04-2 p41-43〕
压测登录:先测再优化
登录优化最容易犯"上来就加缓存"。课程用 wrk 压注册/登录/Profile 三接口,定位到瓶颈是 bcrypt 的 CPU 耗时加数据库查询;给 Profile 读路径配了业务专属 UserCache(封装过期时间/key 结构/序列化协议),Repository 层组合 DAO+Cache 并做 Redis 故障降级;而登录本身是低频操作,结论是不值得缓存。先压测找到真瓶颈,再决定动哪里。优化顺序比优化手段重要。〔W04-1 p12〕〔W04-1 p14-15〕〔W04-1 p19〕〔W04-1 p2〕
四、gin 在标准库之上补了什么
标准库解决的是"路由器加 handler 模型",gin 补的是路由注册的便利和参数处理:参数绑定与校验(Bind 系列)、路由分组、中间件链、更顺手的响应方法。它的核心类型是 gin.Context,一个对象同时承担读请求和写响应,日常打交道最多的就是它。〔W02-1 p6〕〔W02-1 p10〕〔W02-1 p12〕gin.Default() 自带 Logger 和 Recovery 两个中间件,gin.New() 是裸引擎。每个 HTTP 方法一个注册快捷方法(GET/POST/DELETE/PATCH/PUT/OPTIONS/HEAD/Any/Match),参数都是路由规则加处理函数。
组织路由的惯例是每个业务一个 Handler 结构体,写一个 RegisterRoutes(server) 自注册,公共前缀用 server.Group 防手抖。集中注册(main 里全注册)打开文件就能看全所有路由但找起来费劲,分散注册(handler 自己注册)有条理但找路由难,两个都要认识。〔W02-1 p21-25〕gin 的 middleware 就是 func(*gin.Context) 的衍生类型,和标准库一样适合所有业务都关心的横切问题:CORS、日志、限流、可观测。〔W02-1 p44-45〕
一个典型的横切例子是跨域:浏览器先发一个 OPTIONS 预检请求试探,服务端在响应头里用 Access-Control-Allow-* 表态,所以 CORS 中间件必须能处理预检请求本身,不能让它落到鉴权之类的后续逻辑里。〔W02-1 p41-42〕(只有跨域的"非简单请求",比如带 JSON Content-Type 的 POST,浏览器才会先发预检。)不做处理时浏览器直接拦截并报 net::ERR_FAILED、No 'Access-Control-Allow-Origin' header,业务请求根本不会发出。〔W02-1 p40〕
标准库和 gin 怎么选:服务简单、想少依赖,1.22 以后的 ServeMux 够用;团队已经熟悉 gin 或者需要它的绑定校验时选 gin。两边的 handler 都能套进同样的"中间件等于包装函数"思路,没有谁更"正确"。
五、gRPC:契约先行
选型上,资料给的口诀是"遇事不决选 gRPC":利用 HTTP/2 的双向流,支持流控、压缩,跨语言,社区强。〔W10-1 p26〕(该页还写了"基于 QUIC",这是错的,gRPC 跑在 HTTP/2 上。)"契约先行"指先写 .proto(接口描述语言,平台无关),再用编译器生成各语言的客户端和服务端代码;序列化用 Protobuf,二进制格式,课程的说法是序列化速度和压缩效率在同类协议里名列前茅。〔W10-1 p27-29〕
四种流写在 service 定义里
〔C:go_basic/grpc/idl/service/student_service.proto:28-38〕
proto
service student{
// Unary RPC
rpc QueryStudent(QueryStudentRequest) returns (QueryStudentResponse);
rpc QueryStudents1(StudentIds) returns (QueryStudentResponse);
// Server streaming RPC
rpc QueryStudents2(StudentIds) returns (stream idl.model.student);
// Client streaming RPC
rpc QueryStudents3(stream StudentId) returns (QueryStudentResponse);
// Bidirectional streaming RPC
rpc QueryStudents4(stream StudentId) returns (stream idl.model.student);
}stream 写在请求前、响应前还是两边,决定四种形态:一元、服务端流、客户端流、双向流。读法:请求前写了 stream,客户端就是流式发送;响应前写了 stream,服务端就是流式返回;两边都写就是双向流。四种流的生命周期有一处不对称,最容易被忘:客户端创建的流最终要调 CloseSend(双向流)或 CloseAndRecv(客户端流),服务端不用关。课程里的类比是 HTTP 客户端要关 response body,而服务端不用关 request body。〔C:go_basic/grpc/client/main.go:119〕〔C:go_basic/grpc/client/main.go:155〕
拦截器链:签名变了,骨架没变
grpc.ChainUnaryInterceptor(a, b, c) 把多个拦截器串成链,单个拦截器的签名是 (ctx, req, info, handler),和 HTTP 中间件是同一个思路:调 handler(ctx, req) 之前是前置逻辑,之后是后置逻辑。〔C:go_basic/grpc/server/main.go:61〕客户端拦截器的签名里是 invoker 而不是 handler。
下面的例子不引真 gRPC(那需要生成代码,测试跑不了),只复刻拦截器链的形状,看它和 HTTP 中间件是同一副洋葱:
go
package main
import "fmt"
// 模拟 grpc.UnaryHandler 和一元拦截器的形状:
// 真实签名是 (ctx, req, info, handler),这里只留 req 和 handler。
type unaryHandler func(req string) string
type unaryInterceptor func(req string, next unaryHandler) string
func withLog(name string) unaryInterceptor {
return func(req string, next unaryHandler) string {
fmt.Println(name, "before")
out := next(req)
fmt.Println(name, "after")
return out
}
}
func main() {
handler := func(req string) string { return "resp:" + req }
chain := func(req string) string {
steps := []unaryInterceptor{withLog("a"), withLog("b")}
h := handler
for i := len(steps) - 1; i >= 0; i-- {
next := h
step := steps[i]
h = func(r string) string { return step(r, next) }
}
return h(req)
}
fmt.Println(chain("hi"))
}text
a before
b before
b after
a after
resp:hia 包在 b 外面:请求先经过 a 再经过 b,响应先经过 b 再经过 a。ChainUnaryInterceptor(a, b) 的执行顺序就是这个声明顺序。练习包 exercises/p10grpc 里的 LoggingUnaryInterceptor 是真实的最小例子,签名里多了 info *grpc.UnaryServerInfo,作用是在 handler 返回后打一条方法名加错误的日志。
几条实践
- 一个
*grpc.ClientConn可以被多个 client 共用(HTTP/2 多路复用),不要每次调用都新建。grpc.NewClient是惰性连接,调用级超时用context.WithTimeout。〔C:go_basic/grpc/client/main.go:43-69〕 - 负载均衡默认是
pick_first:课程的演示里永远只用 resolver 解析出的第一个节点(严格说是第一个连得上的地址,这一点我没在本机验证),等于没有均衡。要均衡必须在 service config 里指定round_robin等。〔W13-2 p4〕〔W13-2 p5〕 - 重试靠 service config 的
retryPolicy加负载均衡:调用不通就重试,重试时会再经过一遍负载均衡,同时要求负载均衡策略别再挑中已经不可用的节点;retryableStatusCodes通常只填UNAVAILABLE。〔W13-2 p29〕 - 生成代码的命令已经变了:课程里的
--go_out=plugins=grpc是旧写法,现行是独立的protoc-gen-go-grpc插件配--go-grpc_out。〔C:go_basic/grpc/idl/service/student_service.proto:46-47〕 go_package必须带.或/:分号前是输出路径,分号后是包名。〔W10-1 p34〕〔C:go_basic/grpc/idl/model/student.proto:5〕
六、HTTP 还是 gRPC
| 维度 | HTTP/JSON | gRPC |
|---|---|---|
| 运维与门槛 | 组件少,门槛低,兼容所有客户端 | 需要 proto 工具链,要求更高 |
| 异构与对外 | 浏览器、第三方直接用 | 浏览器不直接支持,需网关 |
| 性能与流 | 够用 | 二进制、HTTP/2、四种流 |
| 契约 | 靠文档或 OpenAPI | .proto 是契约本身 |
选型的出发点是一条概念上的澄清:REST 是一种风格(编程风格/架构风格),微服务是一种架构,两者没有必然关系。〔W10-1 p12〕落到实践上的常见分工是:对外和前端用 HTTP,内部服务之间用 gRPC,两边共用同一套"处理函数加中间件"的思路。
小结
- HTTP 的最小骨架是 router 加 handler,handler 原型固定为
func(w http.ResponseWriter, r *http.Request);1.22 起标准库路由器自带方法前缀和路径通配符。 - 响应顺序有铁律:先 Header,再
WriteHeader,最后Write;客户端用完必须关resp.Body。 - 中间件是
func(http.Handler) http.Handler,洋葱式包在 handler 外面;gin 的中间件是func(*gin.Context),同一个思路。 - gRPC 契约先行:
.proto声明四种流,生成客户端和服务端;拦截器是(ctx, req, info, handler)的链,和 HTTP 中间件同构。 - HTTP 与 gRPC 不是非此即彼,而是"对外用 HTTP、对内用 gRPC、中间件思路共用"。
常见坑
HTTP 侧的坑(超时只设一个、忘关 resp.Body、把 http.ErrServerClosed 当错误)已在上面铁律里讲过,这里只列 gRPC 侧:
- gRPC 客户端每次调用新建连接,丢掉 HTTP/2 的多路复用。
- 用旧的
plugins=grpc命令,新版本工具链不认。 - 在 proto 里改字段编号或复用已删除的编号,导致线上新旧版本互相读错数据。〔W10-1 p35〕
判断题与答案
POST /books/42没有注册,为什么返回 405 而不是 404?TimeoutHandler超时后,处理函数里怎么知道要停?- 客户端流和双向流的客户端各自要调哪个方法结束?
- 为什么
grpc.NewClient创建成功不代表服务可达? - 为什么"登录校验中间件里自动换发新短 token"等于没做长短 token?
答案
- 1.22 路由器按"方法加路径"一起匹配;
/books/42命中了GET /books/{id}的路径段,只是方法对不上,所以回 405 并在Allow头里声明允许的方法。/nope连路径都没匹配上才回 404。 TimeoutHandler超时后取消请求的r.Context(),处理函数里 selectr.Context().Done()会收到context deadline exceeded,据此停下不再白干。- 客户端流的客户端用
CloseAndRecv()发完并一次性收服务端的汇总响应;双向流的客户端用CloseSend()声明不再发,然后继续Recv收剩余消息。服务端两个都不需要主动关闭。 grpc.NewClient是惰性连接,创建时不拨号,第一次 RPC 才真正建立连接;所以创建成功只代表参数合法,服务端是否可达要到第一次调用才见分晓。- 因为 refresh 的安全价值就在低频:它只该在登录和 401 刷新两个场景出现。放进中间件每次请求都换发,攻击者盗到一个短 token 照样能一直续命,和自动续期没有区别,refresh token 反而多暴露了一次。
练习包:exercises/p10grpc 的 TestUnary、TestServerStreamWatch、TestClientStreamBulkSet、TestBidiChat(四种流,用内存里的 bufconn 跑真协议栈)、TestConcurrentUnary。〔实测〕go test -race -count=1 ./exercises/p10grpc/... 通过。
在项目里
dnsproxi-go/metrics.go的metricsServer返回一个带ReadHeaderTimeout的http.Server,是 HTTP 服务最小生产写法的例子(独立端口暴露/metrics)。- 这里没有现成的 gRPC 例子,dnsproxi 走的是 UDP 和 TCP 上的 DNS 协议(见 24 节)。gRPC 的真实使用见练习包
exercises/p10grpc和课程的微服务拆分(W10 系列)。
想深入:正文之外的干货
TB09 标准库 Web 服务的组装术
- ServeMux 的老规则是最长前缀匹配,模式
/能兜住所有路径;1.22 之前它分不清方法、取不了路径参数,这就是当年要引 gorilla/mux 的全部原因。〔TB09 p4〕〔TB09 p13-14〕 - 仿
database/sql的工厂注册:factory 包用map[string]store.Store加sync.RWMutex管实例类型,Register遇 nil 或重名 panic,New按名返回;internal/store 在 init 里自注册,main 只需空导入_ "bookstore/internal/store"。坑:忘了空导入,factory.New("mem")直接 panic unknown provider,因为注册从未发生。〔TB09 p10-12〕 - 惯用函数原型是"接受接口、返回具体类型"(
NewBookStoreServer(addr, s store.Store) *BookStoreServer),返回的具体类型组合传入接口的能力;ListenAndServe会阻塞,必须挪进独立 goroutine,用 errChan 把运行状态传回主 goroutine,再 select errChan 与time.After(1s)判断是否启动成功。〔TB09 p13-14〕〔TB09 p16-17〕 - middleware 用闭包包装
http.Handler而不是继承或配置表:Logging 记请求概要、Validating 校验Content-Type为 application/json,嵌套包裹后串到业务 handler 之前,横切逻辑与业务正交。〔TB09 p14-16〕 - handler 的共性收尾:
json.Decoder解码请求体,调 Store 接口方法,出错用http.Error带状态码;响应用json.Marshal加Content-Type: application/json。生产配置不硬编码,配置文件、命令行参数、环境变量是三种来源,标准库没有内置配置框架,社区常用 viper。〔TB09 p14-15〕〔TB09 p21〕
W02-1 gin 的路由、绑定与 CORS 细节
- gin 路由三态:静态
/hello完全匹配;参数路由/users/:name用Param("name")取,尾斜杠变体/users/daming/的参数值是daming/;通配符/views/*.html的匹配值含斜杠,且*不能单独出现(/users/*非法)。查询参数是另一回事,/order?id=123用Query("id")。〔W02-1 p13-16〕 - 两条初学者原则:查数据用 GET 参数放 query,提交数据用 POST 参数放 body。〔W02-1 p15-18〕
- 注册三步是所有写接口的模板:接收请求并校验、调业务逻辑、按结果返回响应;前端 axios.post("/users/signup", values) 用 JSON 传 email/password/confirmPassword 三个字段,后端第一个版本
ctx.String(200, "hello,你在注册")就能接上。先定前端页面再定后端接口的输入输出,是作者"从前端往后端"的顺序。〔W02-1 p26-29〕 - 校验三块与归属:邮箱格式、密码等于确认密码、密码强度(不少于 8 位含数字特殊字符);校验规则理论上由产品经理给,现在更强调二次验证而非密码强度。正则用
regexp包MatchString就够,不用专门学,要用时搜;复杂规则拆成多个表达式多次校验。〔W02-1 p32-33〕 - 预编译正则提速:pattern 用
regexp.MustCompile在NewUserHandler构造函数里编译好,存成 UserHandler 的emailRegexExp/passwordRegexExp字段,校验时直接调方法,不要在请求路径上重复编译。〔W02-1 p34〕 - Bind 按 Content-Type 决定反序列化方式,出错直接把错误响应写给前端(MustBind 语义),所以
if err := ctx.Bind(&req); err != nil { return }之后不用再处理;要自定义错误提示就用 ShouldBind,它只返回 error。〔W02-1 p31〕 - 注册请求的接数结构可以定义在 SignUp 方法内部:方法内部类除该方法外不可见,作者建议优先内部类。〔W02-1 p30〕
- Go 内置 regexp 是 RE2 语法,不支持
(?=...)前瞻断言,密码强度正则直接编译不了,要换 dlclark/regexp2;它是回溯引擎,MatchString返回(bool, error)双值,忘了判 err 会把系统错误当"密码不合格"。〔W02-1 p35〕 - 错误响应全用 200 加文案是教学简化,生产应区分 400(参数错)与 500(系统错);课程自己预告后面再讨论 error 处理与状态码。〔W02-1 p36〕
- CORS 三配置之外的坑:
AllowOriginFunc用strings.Contains(origin, "your_company.com")这种子串匹配有安全隐患(evil-your_company.com 也能过),生产用精确域名集合;MaxAge12h 是让浏览器缓存预检结果。〔W02-1 p43-47〕 - gin 启动日志的 trusted all proxies 警告是"未配置 SetTrustedProxies"的不安全默认,生产要设可信代理列表;端口占用(bind: address already in use)是启动失败最常见原因。〔W02-1 p7-8〕
- PPT 用的
github.com/gin-gonic/contribcors 已废弃,现行是github.com/gin-contrib/cors,API 同为cors.New(cors.Config{...})。
W03-1 Session 存储与限流的硬细节
- memstore.NewStore 要传两个 key:authentication key(建议 32 或 64 位)和 encryption key,cookie/memstore/redis 三个实现都要传。〔W03-1 p7-10〕
sess.Options的 MaxAge 有双重含义:既控 Cookie,又被 Redis 这类 store 用来控 Session 键值过期;只想"Cookie 过期"会漏掉数据侧过期。〔W03-1 p14-15〕- JWT vs Session 的账:JWT 免第三方存储、适合分布式、性能高(无 Redis 访问),缺点是 payload 可读、不能放敏感信息;混用方案是 token 只存 userId,敏感数据用 userId 组 key 去 Redis 取。〔W03-1 p30-31〕
- 刷新失败只打日志不拒绝请求:刷新是锦上添花,把刷新失败当 401 会让正常用户随机掉线。〔W03-1 p27〕
- 限流的动机是注册/登录这对天然漏洞:任何人可刷、且每个请求都查库,shell 脚本轻松构造每秒几千上万请求把 Web 和数据库同时打挂;防御是限流,限制"每个人发的请求数"或"系统处理的总量"。〔W03-1 p35-36〕
- 限流三问:对象用 IP(Web 端最好选择,MAC/设备号用不上,共享 IP 误伤靠阈值化解);阈值标准答案是压测得来,无压测凭经验每秒 100 足够(人手速撑死每秒 1 个,被限的直接拒绝"服务器繁忙");多实例必须 Redis 计数,负载均衡后请求不一定落同一台机器,本地计数会漏。〔W03-1 p38-42〕
- token 泄露防护分层:HTTPS 防传输窃取(防不了本机病毒)、二次验证最有效但重、初步手段是 User-Agent 比对(签发时带进 claims,校验时不一样就 401),代价是误伤换浏览器或升级浏览器的用户。〔W03-1 p45-48〕
W04-1 缓存放哪、怎么降级
- UserCache 屏蔽三件事:过期时间(15min 改一处)、key 结构(
user:info:%d)、序列化协议(json 与 []byte 互转);它属于"如何存储数据",放 Repository 层,Service 无感知。〔W04-1 p13-16〕 - Redis 故障时两种降级写法的对价:默认写法"cache.Get 有 error 就查 DAO 并回写",可用性优先,数据库承压但业务还在;FindByIdV1 用 switch err,只有 ErrKeyNotExist 才查 DAO,隔离优先,数据库保住但本业务不可用。课程推荐前者。〔W04-1 p17-18〕
- 回写缓存失败可忽略:缓存是优化不是数据源,回写失败下次再 miss;但完全忽略会掩盖 Redis 故障,生产要打点监控。〔W04-1 p17〕
- 压测前置三件事:登录态用 JWT(比 Session 好测)、有效期临时拉长到 30 分钟保证测试期间 token 不过期、去掉 ratelimit 限制,否则压出来的不是接口性能。〔W04-1 p5〕
- wrk 三个对象的脚本差异:signup 用不同邮箱模拟注册,login 的 body 要改成自己注册的账号,profile 要手动登录后把 Bearer token 与 User-Agent 复制进 headers,否则 401 会把压测变成"测鉴权"。〔W04-1 p5-8〕wrk 参数就四个:-t 线程、-d 时长、-c 并发连接、-s lua 脚本。〔W04-1 p3-6〕
W04-2 验证码登录的补遗
- 需求清单本身可当验收用例:手机号收码登录、新号直接建账号、验证码 10 分钟有效、一分钟一条、登录成功跳首页;非功能需求就一句,防攻击者恶意发短信(真金白银的损失)。〔W04-2 p7〕
- 腾讯实现坚持依赖注入:
NewService(c *sms.Client, appId, signName)外部传 client 而不是内部初始化;appId/signName 存指针(ekit.ToPtr)避免零值歧义;Send 里req.SetContext(ctx)把 ctx 继续下传,遍历 SendStatusSet 判 Code 不为 "Ok" 就带着 code 和原因报错。真发短信要环境变量加自己的 appId/signName/tplId,本地跑用 localsms 控制台打印验证码。〔W04-2 p21-22〕 - 需求分析三板斧:参考竞品、功能与非功能分开(中小公司排序是安全性>扩展性>性能)、正常与异常流程分开想(业务异常 vs 技术异常)。〔W04-2 p5〕
- 抽象由两个变化点决定:不同业务都要用验证码 + 可能换短信供应商;只按当下需求做简单抽象预留接口,别指望设计一个用几十年的接口。〔W04-2 p12〕〔W04-2 p17〕
- 腾讯短信 API 五要素:手机号、appId、签名(标记谁发的)、模板 ID、模板参数;appId 与签名固定,初始化注入,所以
Send(ctx, tplId, args, numbers...)里不用传。〔W04-2 p18-19〕 - 业务逻辑下沉 lua 是为原子性牺牲可移植性:换 memcache 要重写脚本,CodeService 留薄封装是为了把替换成本控住。〔W04-2 p37〕
- 错误信息模糊化是安全默认:三次失败后不提示"过于频繁失败",防攻击者探测重试上限;前端限流可被脚本绕过,服务端限流才是根。〔W04-2 p26〕〔W04-2 p54〕
W05-1 OAuth2 的边界细节
- 两个接口加一个新 Handler:
/oauth2/wechat/authURL构造跳转 URL(实现就是fmt.Sprintf(authURLPattern, appId, redirectURL, uuid.New())),/oauth2/wechat/callback处理回调;OAuth2WechatHandler 聚合 wechat.Service,与 UserHandler 并列。〔W05-1 p15-16〕 - state 攻击成立的链条要完整记住:攻击者先弄一个绑自己微信的临时授权码 A,伪造页面诱导已登录用户点击,请求带着用户的 Cookie/JWT 和攻击者的授权码 A 去绑定,此后攻击者扫码就能看到用户数据;核心是"让你用他的授权码绑定账号"。绕过它需要 Cookie 本身泄露(本机中毒级别),单纯跨域构造拿不到你的 state。〔W05-1 p28〕
- webook 登录场景(存在即登录、不存在即建号)天然免疫这个攻击,但通用方案要会:AuthURL 时生成 state 与本次会话绑定,回调时比对回调带回来的 state 是不是本会话发出的。〔W05-1 p29〕
- 回调路由用
Any方法注册:微信跳回可能是 GET,也防不同环境方法差异,比只注册 GET 稳。〔W05-1 p15〕 - state 放 JWT 存 Cookie 而非 Redis:免存储依赖、天然随 Cookie 过期;放 Cookie 而非 Header 是因为微信回调直达后端,不经过前端代码。verifyState 三错即敌情:取不到 cookie、不是合法 JWT、state 对不上,任一 error 都视为有人搞鬼,且不细分类别返回,防攻击者探测失败原因。〔W05-1 p30-32〕
- redirect_uri 域名必须预注册,微信侧硬校验,防任意回调劫持 code;本地开发要用内网穿透域名或测试公众号。〔W05-1 p9〕
- access_token 有效期短是故意的:泄露影响面小;refresh_token 长期保存,要更谨慎。union_id 是跨产品主键,只存 open_id 会让同公司多产品认不出同一个自然人。〔W05-1 p20-22〕
W05-2 ssid 方案的工程细节
- 长 token 用独立签名 key:短 token 的 key 泄露也伪造不了 refresh,两个 token 的权限域隔离。〔W05-2 p6〕
- ssid 方案的推导过程值得记:废 token 有两条路(只废长 token、长短都废),前者要"登录校验时随时拿得到长 token",后者两样都麻烦;干脆跳过 token 本身,用一个"这次登录"的标识 ssid(UUID)串起长短 token,登录校验、refresh、登出三处只认 ssid,它在 Redis 里一出现就算登录失效。〔W05-2 p16-17〕
- 登录签发收口在
SetLoginToken:uuid.New()生成 ssid,一次调用里先后发SetJWTToken(短)与SetRefreshToken(长),RefreshClaims 里带 User-Agent、uid、ssid;两个 token 签进同一个 ssid 是后面三处校验的前提。〔W05-2 p18〕 - 登出是两道闸:
ClearToken先把长短 token 响应头置空字符串(让正常用户快速 401),再把users:ssid:{ssid}SET 成 "n" 带 7 天过期(拦还在用旧 token 的客户端);缺任何一道都放过一类人。ssid 的 TTL 要覆盖长 token 的最长寿命,否则登出有失效窗口。〔W05-2 p21〕〔W05-2 p25〕 - Redis 崩溃降级方向是"跳过 ssid 校验"而不是拒绝服务:可用性优先于登出严格性;refresh 接口不降级,低频且安全敏感。〔W05-2 p19-20〕
- 不要用 user id 当登出标识:登录时要删 id、还要处理"一边登录一边退出"的并发;ssid 每次登录唯一,无此二难。〔W05-2 p29〕
- 坚持登出严格无效就得引入类 Session 机制:JWT 生成后"连它自己都不知道自己可不可用",Redis 黑名单不是 JWT 的补丁而是这个坚持的必然代价;反过来接受"登出只尽力"就能省掉这次 Redis 依赖。〔W05-2 p28〕
- 布隆过滤器是面试方案:N 个哈希函数置位,判"一定不存在"准、"存在"有假阳性,仍要 Redis 兜底且本身靠 Redis 实现,改进有限。〔W05-2 p30-31〕
- 重构收口:散在三个 handler 与 middleware 里的 JWT 逻辑内聚成 jwt 子包,定义 Handler 接口(ClearToken/SetLoginToken/SetJWTToken/CheckSession/ExtractTokenString)加 RedisHandler 实现;接口不直接经 ctx 写响应,返回值由调用方写前端。〔W05-2 p22-27〕
W10-1 微服务理论与 Protobuf 细节
- 分而治之的账:两系统合并的复杂度大于两者之和(交互成本),拆分的收益是总体复杂度下降加单模块可理解;单体与微服务的运维基本单位是实例,16G 的单体拆成 4G 的模块;微服务 API 向后兼容才能独立演进;服务边界贴近组织架构能减少交流损耗(康威定律)。〔W10-1 p5-7〕
- RPC 载体有 TCP(Dubbo 类大厂自研)、HTTP(gRPC)、UDP、MQ 封装四种,新协议多基于 HTTP;HTTP 微服务运维简单门槛低、适合异构,RPC 组件多要求高,外企与历史系统 HTTP 不少,微服务不等于 RPC。〔W10-1 p10-11〕
- DDD 词汇速记:实体有 ID(邮箱可改不算标识)、值对象无 ID(价格金额)、聚合体是聚合根加值对象、聚合根是修改唯一入口、聚合体间只能 ID 引用;实体必有表,表不一定都是实体;仓库是聚合体的增删改查并屏蔽缓存与 DB;领域事件多来自聚合体数据变化,发 MQ 的消息就是领域事件;跨聚合的逻辑才用 Domain Service,CRUD 项目里它几乎没代码。〔W10-1 p16-23〕〔W10-1 p15〕
- Protobuf 的账面性能:序列化 0.7ms/0.4MB 对 JSON 的 1.7-4.3ms/1.9-2.8MB,反序列化 0.8ms 对 2.6-6.5ms;
sint类型作者视为垃圾设计,oneof在 Go 里生成接口加类型断言(作者基本不用),enum 的 0 必须留给 Unknown(0 是 proto3 默认值,0 当业务值会混淆"未设置"与"合法值")。〔W10-1 p29-31〕〔W10-1 p36-39〕 - 工具链现状:protoc 加两个插件 protoc-gen-go 与 protoc-gen-go-grpc(GOPATH/bin 进 PATH),一条命令带两组 out/opt,go 与 go-grpc 都用 source_relative 相对输出;产物两个文件,纯结构体 pb.go 与含 gRPC 存根的
_grpc.pb.go。〔W10-1 p41-45〕 - 服务端生成接口强制组合
mustEmbedUnimplementedUserServiceServer,这是向后兼容的手段:新加方法时旧实现不至于编译失败;另有不推荐的 Unsafe 接口。客户端每方法生成(ctx, in, ...grpc.CallOption)。〔W10-1 p48-49〕 grpc.Dial惰性连接:Dial 不立即建连,第一次 RPC 才连,测试里 ctx 超时要覆盖调用全程;旧WithInsecure已废弃,用grpc.WithTransportCredentials(insecure.NewCredentials())。〔W10-1 p52〕
W13-2 负载均衡与重试的展开
- 默认 pick_first 并非完全无用:单节点加 K8s Service 场景下它就是正确行为;多实例才必须显式配 round_robin,指定方式是
WithDefaultServiceConfig传 JSON 串{"loadBalancingConfig":[{"round_robin":{}}]}。JSON 手写易错,写错一个引号配置静默失效退回 pick_first,建议配置文件加单测校验。〔W13-2 p4-5〕 - 官方加权轮询是客户端自算权重:按 QPS 与错误率实时算(QPS 高权重大、错误率高权重低),用 EDF 挑选,不需要注册中心传权重;要精确控制权重就自定义 Picker,权重经注册中心的 Metadata 传递(该字段已弃用,有的实现放 Address.Attributes,类型可能被 JSON 序列化成 string,必须确认)。〔W13-2 p9〕〔W13-2 p12-13〕
- 自定义负载均衡只碰两个接口:PickerBuilder 的 Build 出 Picker,Picker 的 Pick 返回 PickResult,一般不用顶层的 Balancer 接口(要管连接池状态);
PickResult.Done回调能拿到错误与延迟,是动态升降权的挂载点。算法经balancer.Register注册成进程级全局(匿名引入 weightedroundrobin 包触发 init 注册),不同服务要不同算法只能靠 service config 区分。〔W13-2 p11-14〕〔W13-2 p16〕 - failover 的两个齿轮缺一不可:只有 retry 是反复撞死节点,只有 LB 剔除是单次失败就丢请求;retryPolicy 字段有 name(去 proto 生成产物里找方法名)、maxAttempts、initialBackoff/maxBackoff/backoffMultiplier(指数退避)、retryableStatusCodes(通常 UNAVAILABLE)。〔W13-2 p29-30〕
- 两个框架参照:Kratos 默认平滑 WRR,负载均衡封装成 Selector(可传 filter 过滤节点),指定算法要 SetGlobalSelector;go-zero 默认写死 P2C(随机选 2 个节点挑负载低的),只能用原生 WithDefaultServiceConfig 覆盖。〔W13-2 p21-24〕
- 面向业务的 LB 选择:本地缓存场景用哈希 LB 保证同 key 必命中同节点,否则缓存节点来回漂移导致重复加载加内存翻倍;客户端发现服务端触发熔断或限流,一段时间内不选它(failover 变体)。〔W13-2 p34-35〕
C:go_basic/http、C:go_basic/grpc、C:go_basic/protoc 的补充点
- 服务端逐行 Flush 做流式响应:先设 Content-Length,断言
w.(http.Flusher)后每行 Write 加 Flush 强制写到 TCP;客户端提前 Close 断开,服务端 Write 会报错退出,必须检查 err 并 break,否则 goroutine 忙等。不声明长度则走 chunked。〔C:go_basic/http/server/main.go:93-115〕 - header key 写入自动正规化(tRAce-id 变 Trace-Id),大小写不敏感,读取用
http.CanonicalHeaderKey;同一 header 第二次SetCookie无效(header key 不可重复),客户端request.AddCookie可以加多个同名不同值,服务端解析出两个。〔C:go_basic/http/server/main.go:41-44〕〔C:go_basic/http/server/main.go:117-135〕 - 客户端便捷方法
http.Get/Post/PostForm/Head不能设 header,要设就用 NewRequest 加client.Do,超时挂在http.Client{Timeout};生产应复用 Client 实例(自带连接池)。〔C:go_basic/http/client/main.go:200-227〕 - Recv 循环里
io.EOF是正常结束标志,其他错误才需要处理,把 EOF 当错误会误报。〔C:go_basic/grpc/server/student.go:60-72〕 - gRPC 默认单条消息上限 Recv 4M(Send 默认 MaxInt32),传大对象要
MaxCallRecvMsgSize显式调大;TLS 一行开启:服务端grpc.Creds(credentials.NewServerTLSFromFile(...)),客户端NewClientTLSFromFile,即使 insecure 也必须设WithTransportCredentials。〔C:go_basic/grpc/server/main.go:52-68〕 - 序列化三种格式:
proto.Marshal二进制最快且不可读,protojson可读但性能最差分配最多,prototext调试用;实测 proto 约 511ns 对 json 1026ns,protojson 1979ns 加 56 次分配。〔C:go_basic/grpc/idl/model/student_test.go:23-89〕 - 多 proto 文件组织靠
--proto_path多路径解析 import,裸文件名在多路径下有同名歧义,标准做法是 import 全路径google/protobuf/timestamp.proto并把 proto_path 指向 include 目录;跨包引用字段要带 proto package 名(model.com.user);--go_opt=Mmodel/user.proto=a/b/c/model改写生成代码的 import,目标必须与 go.mod 的 module 名一致,否则编译报 package not in std。Timestamp 这类 well-known 类型生成*timestamppb.Timestamp。〔C:go_basic/protoc/dao/product.proto:8-12〕〔C:go_basic/protoc/model/user.proto:9-14〕〔C:go_basic/protoc/go.mod:1-2〕 - 生成代码头部有版本锚定:
EnforceVersion(20)与 MaxVersion 双向断言生成器与运行库兼容,SupportPackageIsVersion9 要求 grpc v1.64 以上;遇到版本不匹配先看这里。〔C:go_basic/protoc/model/user.pb.go:1-24〕
省略未吸收:TB09 的项目布局约定见 02 节,仿 database/sql 工厂已在本节 TB09 干货块展开;W04-2 的 sql.NullString 与 JsonColumn 已在 13 节验证码登录干货块展开,NULL 的三种处理方式见 12 节;W05-1 的 verifyState 实现细节与 W13-2 的 Kratos 作业属于框架与作业范畴;W02-1 的 gin 输入输出 API 全景与 ctx.Get 系列是 API 目录不是机制;W04-1 的 Redis 数据结构清单是选读内容,缓存机制见 13 节;W10-1 的 DDD 八概念正文仅留速记(p15 的概念总览已在速记条目里);C 单元的模板渲染、Cookie 解析 API 细节与 bufconn 练习未收。W05-1 p2、W10-1 p2 为目录页,无知识点可收。
想深入去哪查
- 标准库服务与中间件:TB09,
C:go_basic/http(响应顺序、流式响应、Cookie、三种路由)。 - gin:W02-1(路由、参数绑定、校验、CORS、middleware)。
- gRPC 与 protobuf:W10-1(选型、IDL、Protobuf 语法)、
C:go_basic/grpc(四种流、拦截器链、TLS、序列化性能)、C:go_basic/protoc(go_package、多proto_path)、W13-2(负载均衡与重试)。 - 认证:W03-1(Session 存储与刷新、JWT 结构与 CORS 配合)、W05-2(长短 token 与 ssid 登出)、W05-1(OAuth2 扫码与 state)、W04-1(wrk 压测登录与 UserCache 降级)。
- 旧长版:
notes/p10-grpc.md、notes/p8-fast-track.md。