Skip to content

WebSocket、GraphQL 与 Webhook:三种不同的问题,三条不同的链路 ​

聊天界面需要双方随时发消息,页面想一次取齐几个关联对象,支付平台要通知你订单状态变化。这三件事都常出现在 Web 系统里,但它们不是三种可以互换的“更高级 HTTP”。

WebSocket 改变连接上的通信方式,GraphQL 定义查询与执行模型,Webhook 约定系统间如何投递事件。先按问题选机制,再处理各自的失败边界。本篇在 Go 1.27.2、Linux/amd64 上实际运行 WebSocket 回显与心跳、GraphQL 字段执行和缓存、Webhook 签名与去重;完整代码与固定依赖放在文末。

先看数据向哪里走 ​

机制谁主动发起主要解决什么不自动保证什么
WebSocket客户端先建立连接,之后双方发消息持续双向交互重连恢复、应用确认、离线消息
GraphQL客户端提交有类型的查询/操作按字段选择关联数据查询便宜、对象有权访问、自动解决 N+1
Webhook事件提供方调用接收方 HTTP 地址系统间异步事件通知只投一次、按顺序、永不丢失

正在绘制图示…

可切换适合窗口或原始尺寸
查看图源代码
flowchart LR
    B[浏览器] <-->|WebSocket 双向消息| W[实时服务]
    P[页面客户端] -->|GraphQL 查询| G[查询执行器]
    G --> R[多个 resolver 与数据源]
    E[外部事件提供方] -->|Webhook POST| I[签名校验与持久化收件箱]
    I --> J[异步业务处理]
放大图示

原始尺寸下可横向或纵向滚动;按 Escape 关闭。

正在绘制图示…

查看图源代码
flowchart LR
    B[浏览器] <-->|WebSocket 双向消息| W[实时服务]
    P[页面客户端] -->|GraphQL 查询| G[查询执行器]
    G --> R[多个 resolver 与数据源]
    E[外部事件提供方] -->|Webhook POST| I[签名校验与持久化收件箱]
    I --> J[异步业务处理]

它们也能组合:GraphQL subscription 可以借助某种 WebSocket 子协议传输;Webhook 处理完后,可以通过 SSE 或 WebSocket 更新用户界面。组合不意味着其中一层会替另一层做重试与授权。

WebSocket:从 HTTP 握手进入消息协议 ​

经典 HTTP/1.1 WebSocket 先通过 Upgrade 握手,成功得到 101,随后连接上交换的是 WebSocket 帧。应用通常使用库的消息 API,不必自己拼帧;一条消息也可以由多个帧组成。客户端到服务端的 masking 是协议要求,不是加密,公网保密性要依靠 wss 的 TLS。RFC 6455 §4–5、§10

本例固定用 Gorilla WebSocket v1.5.3。它要求应用遵守“一条并发读取路径、一条并发写入路径”的约束;不要让多个 goroutine 随意同时 WriteMessage。控制消息有文档列出的并发例外,仍应按库约定使用。固定版本文档

握手前完成鉴权和 Origin 策略 ​

测试的 Upgrader 只接受一个 Origin:

go
up := websocket.Upgrader{
    CheckOrigin: func(r *http.Request) bool {
        return r.Header.Get("Origin") == "https://app.example"
    },
}

来自 https://evil.example 的握手实测得到:

text
rejected origin: 403

这是 Origin 策略测试,不是用户身份验证。服务端仍需检查用户凭据和目标频道权限,且不能因为请求来自非浏览器就跳过鉴权。浏览器可能携带 Cookie 发起 WebSocket 握手,Origin 检查有助于避免跨站滥用;普通 HTTP CORS 的响应 Header 不能代替 WebSocket 握手策略。测试中没有实现真实登录。

协议升级后,普通 HTTP 中间件不能再像短请求那样随时返回 JSON 错误。日志、连接追踪和关闭逻辑要转到 WebSocket 生命周期。

心跳需要一直有人读 ​

WebSocket 有 Ping、Pong、Close 控制帧。Gorilla 的默认 Ping 处理器会回复 Pong,但控制消息处理发生在读消息的过程中。如果只写不读,即使没有业务消息需要接收,也可能无法及时处理心跳和关闭。Gorilla 控制消息说明

本次服务端发送 Ping,客户端 ReadMessage 处理 Ping 并回复,服务端读循环触发 PongHandler;同时回显 hello:

go
c.SetPongHandler(func(string) error {
    pong <- struct{}{} // 测试观察点
    return nil
})
err := c.WriteControl(websocket.PingMessage, []byte("ping"), time.Now().Add(time.Second))

实际结果:

text
echo: hello; ping/pong processed

生产实现通常在收到 Pong 后延长读 deadline,并独立周期发送 Ping。时间间隔要和代理空闲超时、移动网络以及断线发现需求一起确定;过于频繁会浪费资源。浏览器 JavaScript 的标准 WebSocket API 不直接暴露 Ping/Pong,需要应用层心跳时可以另定义消息。WebSocket 标准 Ping/Pong

长连接要同时限制消息、队列和等待时间 ​

测试给服务端设置 SetReadLimit(64),发送 65 字节消息,客户端观察到:

text
oversized message: close 1009

这验证了消息大小限制,没有验证所有碎片化或压缩输入。SetReadDeadline、SetWriteDeadline 还负责限制等待。生产每个连接的待发队列也必须有界;慢客户端写不动时,需要决定断开、丢弃可替代的旧状态,还是让生产者等待。无限队列会把网络背压变成内存故障。

故障应用需要的机制
短暂断网退避加抖动重连,防止所有客户端同时冲回
重连期间漏消息序列号、最后确认位置、保留窗口与补发
命令发出后连接断开操作 ID、幂等处理、结果查询
服务准备退出停止新握手,通知关闭,等待或强制关闭已登记连接

TCP 保证一条连接上的字节顺序,却不提供跨重连的应用消息去重。Go Server.Shutdown 也不会自动管理已经被劫持的 WebSocket 连接;应用应登记它们,发 Close 帧并在预算内等待。http.Server.Shutdown

GraphQL:客户端选字段,服务端决定如何取得它们 ​

GraphQL Schema 定义类型与字段,客户端只能提交 Schema 允许的选择。以帖子和作者为例:

graphql
{ posts { id author { name } } }

执行器先解析、验证,再调用对应字段的 resolver 取值,最后按查询形状组织数据。Resolver 可以查数据库、缓存或远程服务;GraphQL 本身没有规定数据存在哪。固定规范 October 2021:执行

操作类型含义边界
query查询数据不应靠隐藏副作用实现业务写入
mutation执行修改顶层 mutation 字段按序执行,不等于自动数据库事务
subscription订阅后续结果传输、重连和保留策略还要另定

变量用独立参数传递,避免通过字符串拼接构造操作文本。字段的参数、对象 ID 即使通过类型验证,也仍需授权;登录用户不一定能读取查询中任意作者的私密字段。

为什么一次查询可能变成 N+1 次访问 ​

拿 20 个帖子需要一次查询。若每个 author resolver 都单独查作者,就再访问 20 次数据源,共 21 次。GraphQL 把它们装进一次客户端请求,没有自动合并后端读取。

本次内存实验只有两条帖子,共享作者 ID 7。resolver 的核心是:

go
key := p.Source.(map[string]any)["author_id"].(string)
if cache != nil {
    if v, ok := cache.values[key]; ok { return v, nil }
}
loads++ // 代表一次访问作者数据源
v := map[string]any{"name": "Gopher"}
if cache != nil { cache.values[key] = v }
return v, nil

文末在访问请求内缓存时加锁,并提供完整 Schema。关闭、打开请求内缓存分别运行同一查询,输出中的关键字段是:

text
cache=false author loads=2
cache=true author loads=1

两次的 data 都是:

json
{"posts":[{"author":{"name":"Gopher"},"id":"1"},{"author":{"name":"Gopher"},"id":"2"}]}

这只是同一作者的请求内去重,并非完整的批量 DataLoader。若 20 个帖子有 20 个不同作者,缓存本身仍要查 20 次。批量加载器把这一批 key 收集起来,一次查询 WHERE id IN (...),再按原 key 顺序返回结果。请求作用域缓存还可以避免把某用户的授权结果复用给另一用户。GraphQL DataLoader 原项目

同时存在 data 和 errors 是正常的协议情况 ​

本次给作者增加可空 private 字段,其 resolver 返回受控错误。执行查询时,两个帖子都碰到该错误,其余字段仍能返回:

text
field errors=2; partial data present=true

GraphQL 客户端不能只判断 HTTP 请求成功,也不能只判断 data != nil。应检查 errors、路径和字段可空性。若报错字段是 Non-Null,null 会向最近可空的父节点传播,严重时整个 data 为 null;类型设计会直接影响部分成功的形状。规范:Errors and Non-Nullability

另一个负例查询不存在的 missing 字段,验证阶段就失败,业务 resolver 不应执行。Schema 校验能防类型和字段错误,却不能估算所有真实执行代价。

查询成本要在执行前后都设边界 ​

深度限制只能阻止过深嵌套,阻止不了一层里大量别名、很大的分页数量或昂贵字段。可组合限制文档大小、字段数、分页上限、复杂度权重和总 deadline;高风险系统还可使用已登记的查询集合。复杂度估计只是一道保护,实际数据源超时与并发量仍需控制。

GraphQL 也不自动提供全局缓存。HTTP 缓存、客户端规范化缓存、resolver 数据缓存是不同层;mutation 后哪些数据失效,仍由业务定义。本次只执行进程内 Schema,没有搭 HTTP 传输层,也未实现查询成本分析器或 subscription 服务。GraphQL 官方安全建议

Webhook:先可靠接住,再异步处理 ​

Webhook 常是一份带事件 ID 的 HTTP POST。发送方可能因超时或 5xx 重试,同一事件可能到多次,也可能在后发生的事件之后才到。具体策略由提供方定义,不存在适用于所有 Webhook 的统一重试标准。例如 Stripe 明确要求处理重复、重试和非顺序投递。Stripe Webhook 文档

正在绘制图示…

可切换适合窗口或原始尺寸
查看图源代码
sequenceDiagram
    participant P as 事件提供方
    participant H as 接收端
    participant D as 持久化收件箱
    participant W as 后台处理器
    P->>H: 原始 Body、签名、事件 ID
    H->>H: 限大小,验证签名和重放窗口
    H->>D: 原子保存事件,按提供方与事件 ID 去重
    D-->>H: 已持久化或已经存在
    H-->>P: 2xx 确认接收
    W->>D: 读取待处理事件
    W->>W: 幂等执行业务,记录结果
放大图示

原始尺寸下可横向或纵向滚动;按 Escape 关闭。

正在绘制图示…

查看图源代码
sequenceDiagram
    participant P as 事件提供方
    participant H as 接收端
    participant D as 持久化收件箱
    participant W as 后台处理器
    P->>H: 原始 Body、签名、事件 ID
    H->>H: 限大小,验证签名和重放窗口
    H->>D: 原子保存事件,按提供方与事件 ID 去重
    D-->>H: 已持久化或已经存在
    H-->>P: 2xx 确认接收
    W->>D: 读取待处理事件
    W->>W: 幂等执行业务,记录结果

关键在于 2xx 的含义。如果表示“已可靠接收”,就应先完成持久化,而不是扔进一个进程内 channel 后马上确认;否则进程崩溃会丢失已确认的事件。若对方要求同步业务结果,则需遵守它的协议,不能擅自把确认含义换掉。

签名要对原始字节验证 ​

本篇自定义教学协议对 timestamp + "." + rawBody 做 HMAC-SHA256。它不是 GitHub 或 Stripe 的完整签名格式;生产必须按具体提供方文档实现,最好使用其官方验证函数。GitHub 签名验证

go
m := hmac.New(sha256.New, secret)
m.Write([]byte(stamp))
m.Write([]byte("."))
m.Write(body)
expected := m.Sum(nil)
if !hmac.Equal(received, expected) {
    // 拒绝请求
}

不要先反序列化再重新 Marshal 后验证。字段顺序、空白、转义可能变化,语义相同的 JSON 也不一定是同一份签名字节。本次用合法请求的签名,却在 Body 尾部多加一个空格,服务端返回 401;原文输入可以通过。签名比较使用 hmac.Equal,避免自己用提前退出的逐字节比较。Go hmac.Equal

签名验证也不是“允许任何事件做任何事”。验证成功后仍要检查事件类型、账户范围、结构和资源归属,并限制原始 Body 大小。示例只接受 created 类型和非空 ID。

时间窗口与事件去重各管一件事 ​

签名只能证明持有密钥的一方签过这份内容;被捕获的合法内容仍可重放。本例校验时间戳,允许最多 300 秒过去偏差与 30 秒未来偏差,并把时间戳也签进去。这是教学策略,不是通用标准值。测试中 301 秒以前的签名请求被拒绝。

时间窗口内仍可重复投递,所以还需要事件 ID 去重。文末用锁保护“查 seen、更新 applied”两步,20 个 goroutine 同时发同一合法事件,最后应用次数为 1。这个结果只证明进程内并发临界区正确:重启会丢 map,多实例也不共享它。可靠实现需要持久化唯一键、状态和业务写入的事务边界。

若写入外部系统无法与去重记录处于同一事务,简单地“先标记已处理”可能丢业务,“先处理后标记”可能重复副作用。应让下游支持幂等键,或采用可重试任务与明确补偿。Webhook 的至少一次投递和业务的只生效一次,需要分别设计。

乱序不能只靠事件 ID 解决 ​

事件 ID 判断是否重复,版本判断是否过时。若事件携带的是完整状态快照,可以比较同一资源的递增版本:

go
if incomingVersion <= currentVersion { return false }
currentVersion, state = incomingVersion, incomingState
return true

本次先应用 v2 的 closed,再收到 v1 的 open,得到:

text
apply v2=true; apply stale v1=false; state=closed

这个策略只适用于有可靠版本号的完整快照。若事件是“加 3”“减 2”这样的增量,直接丢弃旧版本可能丢掉必须执行的操作;需要有序日志、缺口检测或回查提供方的当前权威状态。时间戳也不天然是可靠全序,不能默认不同机器的时间精确一致。

接收端最终还要能观察积压、失败重试和死信,允许受控重放。发送端则应有重试预算、指数退避与抖动、投递日志,并保护可配置回调 URL,防止它被用来探测内部网络。这里的运维设计没有在本次单进程测试中模拟。

资料与实测范围 ​

本次 go mod verify、test、race、vet 全通过。测试范围是本地 TCP 上的 WebSocket、进程内 GraphQL 执行、httptest Webhook 接收器。未运行公网回调、真实支付平台、浏览器、持久化收件箱、跨节点消息恢复或 GraphQL subscription。依赖版本用于复现,不据此推断生产环境应采用同一版本。

完整复现代码 ​

使用 Go 1.27.2。下载时显式使用官方 Go 代理与校验服务;已有缓存后可离线运行:

sh
GOPROXY=https://proxy.golang.org GOSUMDB=sum.golang.org go mod download
go mod verify
GOPROXY=off GOSUMDB=off go test -v -count=1 ./...
GOPROXY=off GOSUMDB=off go test -race -count=1 ./...
GOPROXY=off GOSUMDB=off go vet ./...

go.mod ​

go.mod
module example.com/webapi/06_webhook

go 1.27.2

require (
	github.com/gorilla/websocket v1.5.3 // indirect
	github.com/graphql-go/graphql v0.8.1 // indirect
)

go.sum ​

text
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/graphql-go/graphql v0.8.1 h1:p7/Ou/WpmulocJeEx7wjQy611rtXGQaAcXGqanuMMgc=
github.com/graphql-go/graphql v0.8.1/go.mod h1:nKiHzRM0qopJEwCITUuIsxk9PlVlwIiiI8pnJEhordQ=

webhook_test.go ​

代码块 · 135 行 · 点击收起 / 展开
go
package webhookdemo

import (
	"crypto/hmac"
	"crypto/sha256"
	"encoding/hex"
	"encoding/json"
	"io"
	"net/http"
	"net/http/httptest"
	"strconv"
	"strings"
	"sync"
	"testing"
	"time"
)

type receiver struct {
	mu      sync.Mutex
	seen    map[string]bool
	applied int
	now     func() time.Time
	secret  []byte
}

func sign(secret []byte, stamp string, body []byte) string {
	m := hmac.New(sha256.New, secret)
	m.Write([]byte(stamp))
	m.Write([]byte("."))
	m.Write(body)
	return hex.EncodeToString(m.Sum(nil))
}
func (s *receiver) ServeHTTP(w http.ResponseWriter, r *http.Request) {
	if r.Method != "POST" {
		w.Header().Set("Allow", "POST")
		http.Error(w, "method", 405)
		return
	}
	r.Body = http.MaxBytesReader(w, r.Body, 1024)
	defer r.Body.Close()
	body, err := io.ReadAll(r.Body)
	if err != nil {
		http.Error(w, "body rejected", 413)
		return
	}
	stamp := r.Header.Get("X-Timestamp")
	sec, err := strconv.ParseInt(stamp, 10, 64)
	now := s.now().Unix()
	if err != nil || sec < now-300 || sec > now+30 {
		http.Error(w, "stale timestamp", 401)
		return
	}
	got, err := hex.DecodeString(r.Header.Get("X-Signature"))
	if err != nil {
		http.Error(w, "signature", 401)
		return
	}
	want, _ := hex.DecodeString(sign(s.secret, stamp, body))
	if !hmac.Equal(got, want) {
		http.Error(w, "signature", 401)
		return
	}
	var event struct {
		ID   string `json:"id"`
		Type string `json:"type"`
	}
	if err = json.Unmarshal(body, &event); err != nil || event.ID == "" || event.Type != "created" {
		http.Error(w, "event", 400)
		return
	}
	s.mu.Lock()
	if !s.seen[event.ID] {
		s.seen[event.ID] = true
		s.applied++
	}
	s.mu.Unlock()
	w.WriteHeader(204)
}
func TestSignatureAndDuplicate(t *testing.T) {
	now := time.Unix(1900000000, 0)
	s := &receiver{seen: map[string]bool{}, now: func() time.Time { return now }, secret: []byte("public-test-secret")}
	body := `{"id":"evt-1","type":"created"}`
	stamp := strconv.FormatInt(now.Unix(), 10)
	call := func(payload, ts, sig string) int {
		r := httptest.NewRequest("POST", "/", strings.NewReader(payload))
		r.Header.Set("X-Timestamp", ts)
		r.Header.Set("X-Signature", sig)
		w := httptest.NewRecorder()
		s.ServeHTTP(w, r)
		return w.Code
	}
	signature := sign(s.secret, stamp, []byte(body))
	if got := call(body+" ", stamp, signature); got != 401 {
		t.Fatal(got)
	}
	old := strconv.FormatInt(now.Unix()-301, 10)
	if got := call(body, old, sign(s.secret, old, []byte(body))); got != 401 {
		t.Fatal(got)
	}
	if got := call(strings.Repeat("x", 1025), stamp, signature); got != 413 {
		t.Fatal(got)
	}
	var wg sync.WaitGroup
	for i := 0; i < 20; i++ {
		wg.Add(1)
		go func() {
			defer wg.Done()
			if got := call(body, stamp, signature); got != 204 {
				t.Errorf("%d", got)
			}
		}()
	}
	wg.Wait()
	if s.applied != 1 {
		t.Fatalf("applied=%d", s.applied)
	}
	t.Log("raw-body tampering, timestamp window, body limit, and concurrent duplicate suppression verified")
}

func TestOutOfOrderSnapshot(t *testing.T) {
	// This works for complete versioned snapshots, not for increment/decrement events.
	version, state := 0, ""
	apply := func(v int, next string) bool {
		if v <= version {
			return false
		}
		version, state = v, next
		return true
	}
	newer, older := apply(2, "closed"), apply(1, "open")
	if !newer || older || state != "closed" {
		t.Fatal(version, state)
	}
	t.Logf("apply v2=%v; apply stale v1=%v; state=%s", newer, older, state)
}

protocols_test.go ​

代码块 · 158 行 · 点击收起 / 展开
go
package webhookdemo

import (
	"context"
	"encoding/json"
	"errors"
	"net/http"
	"net/http/httptest"
	"strings"
	"sync"
	"testing"
	"time"

	"github.com/gorilla/websocket"
	"github.com/graphql-go/graphql"
)

func TestWebSocket(t *testing.T) {
	var workers sync.WaitGroup
	pong := make(chan struct{}, 4)
	up := websocket.Upgrader{CheckOrigin: func(r *http.Request) bool { return r.Header.Get("Origin") == "https://app.example" }}
	s := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		c, err := up.Upgrade(w, r, nil)
		if err != nil {
			return
		}
		workers.Add(1)
		defer workers.Done()
		defer c.Close()
		c.SetReadLimit(64)
		_ = c.SetReadDeadline(time.Now().Add(3 * time.Second))
		c.SetPongHandler(func(string) error { pong <- struct{}{}; return nil })
		if err := c.WriteControl(websocket.PingMessage, []byte("ping"), time.Now().Add(time.Second)); err != nil {
			return
		}
		for {
			kind, b, err := c.ReadMessage()
			if err != nil {
				return
			}
			_ = c.SetWriteDeadline(time.Now().Add(time.Second))
			if err := c.WriteMessage(kind, b); err != nil {
				return
			}
		}
	}))
	defer s.Close()
	url := "ws" + strings.TrimPrefix(s.URL, "http")
	dialer := websocket.Dialer{HandshakeTimeout: time.Second}
	_, resp, err := dialer.Dial(url, http.Header{"Origin": []string{"https://evil.example"}})
	if err == nil || resp.StatusCode != 403 {
		t.Fatalf("%v %v", resp, err)
	}
	resp.Body.Close()
	t.Log("rejected origin: 403")
	c, _, err := dialer.Dial(url, http.Header{"Origin": []string{"https://app.example"}})
	if err != nil {
		t.Fatal(err)
	}
	defer c.Close()
	_ = c.SetReadDeadline(time.Now().Add(2 * time.Second))
	if err := c.WriteMessage(websocket.TextMessage, []byte("hello")); err != nil {
		t.Fatal(err)
	}
	_, body, err := c.ReadMessage()
	if err != nil || string(body) != "hello" {
		t.Fatalf("%q %v", body, err)
	}
	select {
	case <-pong:
	case <-time.After(time.Second):
		t.Fatal("pong not read")
	}
	t.Log("echo: hello; ping/pong processed")
	if err := c.WriteMessage(websocket.TextMessage, []byte(strings.Repeat("x", 65))); err != nil {
		t.Fatal(err)
	}
	_, _, err = c.ReadMessage()
	if !websocket.IsCloseError(err, websocket.CloseMessageTooBig) {
		t.Fatal(err)
	}
	t.Log("oversized message: close 1009")
	c.Close()
	workers.Wait()
}

type cacheKey struct{}
type requestCache struct {
	mu     sync.Mutex
	values map[string]any
}

func TestGraphQLSelectionAndErrors(t *testing.T) {
	var loads int
	user := graphql.NewObject(graphql.ObjectConfig{Name: "User", Fields: graphql.Fields{
		"name":    &graphql.Field{Type: graphql.String},
		"private": &graphql.Field{Type: graphql.String, Resolve: func(graphql.ResolveParams) (any, error) { return nil, errors.New("forbidden") }},
	}})
	post := graphql.NewObject(graphql.ObjectConfig{Name: "Post", Fields: graphql.Fields{
		"id": &graphql.Field{Type: graphql.ID},
		"author": &graphql.Field{Type: user, Resolve: func(p graphql.ResolveParams) (any, error) {
			key := p.Source.(map[string]any)["author_id"].(string)
			cache, _ := p.Context.Value(cacheKey{}).(*requestCache)
			if cache != nil {
				cache.mu.Lock()
				defer cache.mu.Unlock()
				if v, ok := cache.values[key]; ok {
					return v, nil
				}
			}
			loads++
			v := map[string]any{"name": "Gopher"}
			if cache != nil {
				cache.values[key] = v
			}
			return v, nil
		}},
	}})
	schema, err := graphql.NewSchema(graphql.SchemaConfig{Query: graphql.NewObject(graphql.ObjectConfig{Name: "Query", Fields: graphql.Fields{
		"posts": &graphql.Field{Type: graphql.NewList(post), Resolve: func(graphql.ResolveParams) (any, error) {
			return []map[string]any{{"id": "1", "author_id": "7"}, {"id": "2", "author_id": "7"}}, nil
		}},
	}})})
	if err != nil {
		t.Fatal(err)
	}
	query := `{ posts { id author { name } } }`
	for _, cached := range []bool{false, true} {
		loads = 0
		ctx := context.Background()
		if cached {
			ctx = context.WithValue(ctx, cacheKey{}, &requestCache{values: map[string]any{}})
		}
		result := graphql.Do(graphql.Params{Schema: schema, RequestString: query, Context: ctx})
		if len(result.Errors) != 0 {
			t.Fatal(result.Errors)
		}
		want := 2
		if cached {
			want = 1
		}
		if loads != want {
			t.Fatal(loads)
		}
		b, _ := json.Marshal(result.Data)
		t.Logf("cache=%v author loads=%d data=%s", cached, loads, b)
	}
	result := graphql.Do(graphql.Params{Schema: schema, RequestString: `{ posts { id author { name private } } }`, Context: context.Background()})
	if len(result.Errors) != 2 || result.Data == nil {
		t.Fatal(result)
	}
	t.Logf("field errors=%d; partial data present=%v", len(result.Errors), result.Data != nil)
	result = graphql.Do(graphql.Params{Schema: schema, RequestString: `{ posts { missing } }`, Context: context.Background()})
	if len(result.Errors) == 0 {
		t.Fatal("invalid query accepted")
	}
	t.Log("unknown field rejected before execution")
}

保留各书来源与许可 · 示例运行条件以正文为准