格)
# GraphQL with Go:另一種API風(fēng)格摘要: 本篇從GraphQL的核心概念講起用gqlgen搭建一個完整的GraphQL服務(wù)演示Schema定義、Resolver實現(xiàn)和Mutation操作分享N1查詢問題及DataLoader解決方案的踩坑經(jīng)歷對比REST與GraphQL兩種API風(fēng)格的取舍。開篇故事去年我們有個移動端App首頁要展示用戶信息和最近訂單。REST的做法是先調(diào)/users/1拿用戶信息再調(diào)/users/1/orders拿訂單列表兩個請求串行發(fā)。移動端網(wǎng)絡(luò)差的時候兩個請求加起來要等兩三秒用戶看到白屏就想退出。后來產(chǎn)品又要在訂單列表里展示每個訂單關(guān)聯(lián)的商品名稱。REST這邊只能再循環(huán)調(diào)/orders/:id/itemsN個訂單N次請求。移動端同事快瘋了說你們后端能不能一次把數(shù)據(jù)給全了。我當(dāng)時想直接加個聚合接口/users/:id/home把用戶和訂單和商品全查出來打包返回。但這接口只適合首頁換個場景又要寫新接口。后來越來越多場景要聚合聚合接口膨脹到了二十多個。后來我研究了GraphQL發(fā)現(xiàn)它天然解決這個問題??蛻舳诵枰裁醋侄尉筒槭裁醋侄我淮握埱竽萌嘘P(guān)聯(lián)數(shù)據(jù)。今天就聊聊在Go里怎么用GraphQL。一、GraphQL核心概念GraphQL用Schema定義數(shù)據(jù)類型和查詢?nèi)肟?。客戶端發(fā)一個查詢語句服務(wù)端解析后只返回客戶端請求的字段。# 客戶端只需要用戶名, 就只返回用戶名 query { user(id: 1) { name } } # 需要用戶名和訂單, 一個請求搞定 query { user(id: 1) { name orders { id amount items { name } } } }同一個接口不同客戶端按需取字段。移動端取精簡數(shù)據(jù)管理后臺取完整數(shù)據(jù)后端只維護(hù)一個Resolver。二、用gqlgen搭建GraphQL服務(wù)Go生態(tài)里gqlgen是最主流的GraphQL庫基于Schema先行的理念先寫Schema再生成代碼骨架。# 安裝gqlgengoinstallgithub.com/99designs/gqlgenlatest# 初始化項目go mod init myapp gqlgen init先寫Schema定義數(shù)據(jù)類型和查詢。# schema.graphql 定義類型和查詢?nèi)肟?# 用戶類型 type User { id: ID! name: String! email: String! orders: [Order!]! # 用戶的訂單列表 } # 訂單類型 type Order { id: ID! amount: Float! items: [Item!]! # 訂單里的商品 } # 商品類型 type Item { id: ID! name: String! price: Float! } # 查詢?nèi)肟?type Query { user(id: ID!): User users: [User!]! } # 變更入口 type Mutation { createUser(name: String!, email: String!): User! }執(zhí)行g(shù)qlgen generate后它會根據(jù)Schema生成Go的struct定義和Resolver接口。你只需要實現(xiàn)接口方法。三、實現(xiàn)Resolvergqlgen生成的Resolver接口是你要實現(xiàn)的核心。每個Schema里的字段都有一個對應(yīng)的Resolver方法。packagegraphimport(contextmyapp/graph/model)// Resolver 根Resolver, 持有依賴typeResolverstruct{userStore*UserStore// 用戶數(shù)據(jù)源orderStore*OrderStore// 訂單數(shù)據(jù)源}// User 返回UserResolver, 處理User類型的字段解析func(r*Resolver)User()UserResolver{returnuserResolver{r}}// Query Query入口的Resolverfunc(r*Resolver)Query()QueryResolver{returnqueryResolver{r}}// Mutation Mutation入口的Resolverfunc(r*Resolver)Mutation()MutationResolver{returnmutationResolver{r}}typequeryResolverstruct{*Resolver}// User 根據(jù)ID查用戶// ctx是請求上下文, id是參數(shù)func(q*queryResolver)User(ctx context.Context,idstring)(*model.User,error){// 從數(shù)據(jù)源查用戶user,err:q.userStore.GetByID(ctx,id)// 查數(shù)據(jù)庫iferr!nil{returnnil,err// 返回錯誤給GraphQL}returnuser,nil// 返回用戶對象}typeuserResolverstruct{*Resolver}// Orders 解析User的orders字段// 只有客戶端請求了orders字段時才會調(diào)用這個方法func(u*userResolver)Orders(ctx context.Context,obj*model.User)([]*model.Order,error){// 根據(jù)用戶ID查訂單列表orders,err:u.orderStore.GetByUserID(ctx,obj.ID)// 按用戶ID查iferr!nil{returnnil,err}returnorders,nil// 返回訂單列表}typemutationResolverstruct{*Resolver}// CreateUser 創(chuàng)建用戶func(m*mutationResolver)CreateUser(ctx context.Context,namestring,emailstring)(*model.User,error){user:model.User{Name:name,Email:email}// 構(gòu)造用戶對象iferr:m.userStore.Create(ctx,user);err!nil{// 寫入數(shù)據(jù)庫returnnil,err}returnuser,nil// 返回新用戶}gqlgen的核心設(shè)計是按字段懶加載??蛻舳苏埱髐ser(id:1){name}時只調(diào)用Query.User方法不會調(diào)用userResolver.Orders??蛻舳苏埱罅薿rders字段時才查訂單查了訂單又請求了items才查商品。這種設(shè)計避免了多余的數(shù)據(jù)查詢。四、獨家踩坑:N1查詢問題說一個GraphQL最經(jīng)典的坑。上面那個Resolver看起來沒問題但壓測時數(shù)據(jù)庫連接被打滿了。問題是這樣的。有個查詢要獲取10個用戶每個用戶再查訂單。query { users { name orders { amount } } }。執(zhí)行的SQL是這樣的。SELECT * FROM users; -- 1次查詢拿10個用戶 SELECT * FROM orders WHERE user_id 1; -- 每個用戶1次查詢 SELECT * FROM orders WHERE user_id 2; ...總共10個用戶就是10次查詢 -- 1 10 11次SQL, 這就是N1問題數(shù)據(jù)量大了數(shù)據(jù)庫扛不住。REST接口也有這個問題但GraphQL的嵌套查詢讓N1更容易發(fā)生因為每層嵌套都可能觸發(fā)新的查詢。解決方案是用DataLoader。它把同層級的查詢合并成批量查詢。importgithub.com/graph-gophers/dataloader/v7// OrderLoader 批量加載訂單的DataLoadertypeOrderLoaderstruct{store*OrderStore// 訂單數(shù)據(jù)源}// BatchGetOrders 批量查詢, 一次SQL查多個用戶的訂單func(l*OrderLoader)BatchGetOrders(ctx context.Context,userIDs[]string)[]*dataloader.Result[[]*model.Order]{// 一次SQL查出所有用戶的訂單orders,_:l.store.GetByUserIDs(ctx,userIDs)// 批量查詢// 按userID分組返回結(jié)果results:make([]*dataloader.Result[[]*model.Order],len(userIDs))// 結(jié)果切片fori,uid:rangeuserIDs{results[i]dataloader.Result[[]*model.Order]{Data:orders[uid]}// 按用戶分組}returnresults// 返回每個用戶對應(yīng)的訂單}// 在Resolver中使用DataLoaderfunc(u*userResolver)Orders(ctx context.Context,obj*model.User)([]*model.Order,error){// 從context取出DataLoader實例loader:ctx.Value(orderLoader).(*dataloader.Loader[string,[]*model.Order])// Load會自動批量合并同層級的查詢thunk:loader.Load(ctx,obj.ID)// 延遲加載orders,err:thunk()// 實際執(zhí)行批量查詢iferr!nil{returnnil,err// 錯誤處理}returnorders,nil// 返回訂單}DataLoader在同一個事件循環(huán)tick內(nèi)收集所有Load調(diào)用然后用一個批量查詢一次性取回所有數(shù)據(jù)。10個用戶的訂單從11次SQL降到2次一次查用戶一次批量查訂單。這個坑我踩了挺久才定位。癥狀是QPS上不去數(shù)據(jù)庫CPU飆高。開了GORM的Debug日志看SQL發(fā)現(xiàn)同一個請求里同一條SELECT語句被反復(fù)執(zhí)行。接上DataLoader后SQL數(shù)量大幅下降QPS直接翻倍。五、對比分析與總結(jié)維度RESTGraphQL取數(shù)據(jù)固定字段多次請求按需取字段一次請求過取數(shù)據(jù)常見接口返回固定字段少見客戶端指定字段欠取數(shù)據(jù)常見要發(fā)多次請求少見一次查全緩存HTTP緩存天然支持緩存復(fù)雜靠客戶端緩存學(xué)習(xí)成本低CRUD直覺高Schema和ResolverN1問題可控需要DataLoader兜底GraphQL在數(shù)據(jù)聚合場景下優(yōu)勢明顯一個請求拿全所有關(guān)聯(lián)數(shù)據(jù)移動端友好。但它的復(fù)雜度也高Schema設(shè)計、N1優(yōu)化、緩存策略都是要趟的坑。REST在簡單CRUD場景下仍然是最直接的方案。我的建議是看場景選。移動端App、多端復(fù)用API、數(shù)據(jù)聚合多的項目GraphQL值得上。內(nèi)部管理系統(tǒng)、簡單CRUD服務(wù)REST夠用。兩者不沖突可以共存GraphQL做聚合層REST做底層服務(wù)。模塊三的API開發(fā)篇到這里就結(jié)束了。從Gin路由到中間件從版本控制到文檔生成從驗證錯誤處理到CORS安全最后到GraphQL你已經(jīng)具備搭建生產(chǎn)級API服務(wù)的完整能力。后續(xù)模塊會進(jìn)入數(shù)據(jù)庫和緩存的話題。