GraphQL教程

graphql.cn

https://mp.weixin.qq.com/s/4n0Cl4xEMVoFCulTORPJWg

是什么

‌GraphQL是由‌Facebook开发的API查询语言,通过客户端定义数据结构提升数据传输效率,广泛应用于复杂数据交互场景。于2012年开发,2015年开源。

它是数据库无关的,而且可以在使用API的任何环境中有效使用,我们可以理解为GraphQL是基于API之上的一层封装,目的是为了更好,更灵活的适用于业务的需求变化。

为什么

传统开发(比如restful)的不足

REST作为一种现代网络应用非常流行的软件架构风格,自从Roy Fielding博士在2000年他的博士论文中提出来到现在已经有了20年的历史。它的简单易用性,可扩展性,伸缩性受到广大Web开发者的喜爱。

REST 的 API 配合JSON格式的数据交换,使得前后端分离、数据交互变得非常容易,而且也已经成为了目前Web领域最受欢迎的软件架构设计模式。

但随着REST API的流行和发展,它的缺点也暴露了出来:

  • 滥用REST接口,导致大量相似度很高(具有重复性)的API越来越冗余。
  • 对于前端而言:REST API粒度较粗,难以一次性符合前端的数据要求,前端需要分多次请求接口数据。增加了前端人员的工作量。而且多次http请求会增加服务器负担。
  • 对于后端而言:前端需要的数据往往在不同的地方具有相似性,但却又不同,比如针对同样的用户信息,有的地方只需要用户简要信息(比如头像、昵称),有些地方需要详细的信息,这就需要开发不同的接口来满足这些需求。当这样的相似但又不同的地方多的时候,就需要开发更多的接口来满足前端的需要。增加了后端开发人员的工作量和重复度。

那我们来分析一下,当前端需求变化,涉及到改动旧需求时,会有以下这些情况:

  • 「做加法:」产品需求增加,页面需要增加功能,数据也就相应的要增加显示,那么REST接口也需要做增加,这种无可厚非。
  • 「做减法:」产品需求减少,页面需要减少功能,或者减少某些信息显示,那么数据就要做减法。
  • 「同时做加减法:」既有加法,又有减法,其实这种就跟新需求没啥区别,前端需要重做页面,后端需要新写接口满足前端需要,但是旧接口还是不能轻举妄动(除非确定只有这一处调用才可以删除)。

针对做减法,一种通常懒惰的做法是,前端不与后端沟通,仅在前端对数据选择性显示。

因为后端接口能够满足数据需要,仅仅是在做显示的时候对数据进行了选择性显示,但接口的数据是存在冗余的,这种情况一个是存在数据泄露风险,另外就是数据量过大时造成网络流量过大,页面加载缓慢,用户流量费白白消耗,用户体验就会下降。

另外一种做法就是告知后端,要么开发新的接口,要么,修改旧接口,删掉冗余字段。

但一般来说,开发新接口往往是后端开发人员会选择的方案,因为这个方案对现有系统的影响最低,不会有额外的风险。

修改旧接口删除冗余数据的方案往往开发人员不会选择,这是为什么呢?

这就涉及到了系统的稳定性问题了,旧接口往往不止是一个地方在用,很有可能很多页面、设置不同客户端、不同服务都调用了这个接口获取数据,不做详细的调查,是不可能知道到底旧接口被调用了多少次,一旦改动旧接口,涉及范围可能非常大,往往会引起其他地方出现崩溃。改动旧接口成本太高,所以往往不会被采取。

针对做加法或者同时加减法:

往往这个时候,其实用到的数据大多都是来自于同一个DO或者DTO,不过是在REST接口组装数据时,用不同的VO来封装不同字段,或者,使用同样的VO,组装数据时做删减。

看到这些问题是不是觉得令人头大?

所以需求频繁改动是万恶之源,当产品小哥哥改动需求时,程序员小哥哥可能正提着铁锹赶来...

那么有没有一种方案或者框架,可以使得在用到同一个领域模型(DO或者DTO)的数据时,前端对于这个模型的数据字段需求的改动,后端可以根据前端的改动和需要,自动适配,自动组装需要的字段,返回给前端呢?如果能这样做的话,那么后端程序猿小哥可能要开心死了,前端妹子也不用那么苦口婆心地劝说后端小哥哥了。

所以GraphQL隆重出世了!那么问题来了,graphql怎么解决上面问题的呢?

GraphQL 实际上将多个 HTTP 请求聚合成了一个请求,将多个 restful 请求的资源变成了一个从根资源 POST 访问其他资源的 Comment 和 Author 的图,多个请求变成了一个请求的不同字段,从原有的分散式请求变成了集中式的请求,因此GraphQL又可以被看成是图数据库的形式。

REST 痛点

传统写法:/api/user/basic、/api/user/withProfile、/api/user/withOrder... 每换个页面就加路由,后端跟着写一堆重复代码。

GraphQL 做法

前端只发一次请求,把字段写进 query 就行:

go 复制代码
query { user(id:1){ name age avatar } }  # 个人中心只要 3 个字段
query { user(id:1){ name vipLevel orderList{price} } }  # 管理后台多拿订单

自动生成"解析函数"骨架,CRUD 只剩一行数据库调用

g generate dao 自动把表结构翻译成 CRUD 方法;

gqlgen generate 自动把 GraphQL 字段翻译成 Go 接口;

开发者要做的只是"把两者粘起来"------代码量比手写 REST 少一半以上。

文档、Mock、SDK 零成本自带

GraphQL 的 schema 就是活的接口文档,打开 Playground 就能点点查;

前端可以直接用 graphql-code-generator 一键生成 TypeScript SDK,再也不用等后端给 Swagger;

测试同学用 graphql-faker 基于 schema 就能造 Mock 数据,前后端并行开发。

聚合查询一次网络往返,性能反而更高

传统场景:打开订单页→先查用户→再查订单→再查商品,三次 HTTP。

GraphQL 场景:一次请求,后端用 GoFrame 的 Join 或 Scan 一次性把三张表捞回来,减少网络 RTT,前端白屏时间更短。

升级、换库、分库分表对前端透明

哪天把 MySQL 换成 TiDB,或者把字段下划线改成驼峰,只要改 resolver 里的那一行 Scan,schema 不变前端就无感;

如果走 REST,路由、DTO、Swagger、前端 SDK 全得同步改一遍。

统一网关/限流/监控,GoFrame 原生能力直接复用

鉴权、日志、限流、Prometheus 指标,全部用 GoFrame 的中间件写一次即可;

GraphQL 只有一个 /query 入口,nginx 网关配一条规则就能限流,比 REST 动辄几十条路由好维护得多。

简单示例,怎么用------以golang为例

下面以goframe v2框架举例

我们假设目录结构是这样,具体可以再调整

一、目录结构

复制代码
gframe-graphql/
├── api/                      # GraphQL 相关文件
│   ├── generated/            # gqlgen 自动生成
│   ├── model/                # 业务模型(与数据库表对应)
│   ├── resolver.go           # 把"GraphQL 语句"翻译成"GoFrame 数据库调用"
│   └── schema.graphqls       # 纯文本的 GraphQL 接口描述
├── internal/
│   ├── cmd/
│   │   └── main.go           # 项目入口,启动 HTTP + GraphQL
│   └── dao/                  # GoFrame dao(自动生成的表操作)
├── hack/                     # gqlgen 代码生成脚本
│   └── gqlgen.go
├── go.mod
└── config.yaml               # GoFrame 数据库配置

二、准备动作(一次性)

安装工具

复制代码
go install github.com/99designs/gqlgen@latest

拉依赖

复制代码
go get github.com/gogf/gf/v2
go get github.com/99designs/gqlgen

建数据库(MySQL 示例)

复制代码
CREATE TABLE `user` (
  `id   int          PRIMARY KEY AUTO_INCREMENT,
  `name` varchar(32) NOT NULL,
  `age  int          NOT NULL
);
INSERT INTO user(name,age) VALUES('Tom',18),('Jerry',19);

在config文件中写好数据库连接配置,然后使用自带的gf gen工具生成dao层

三、代码编写

1、api/model/user.go // 与表字段一一对应

go 复制代码
package model

// User 对应数据库表 user(字段名必须导出, gqlgen 才能用)
type User struct {
	ID   int    `json:"id"   orm:"id"  `
	Name string `json:"name" orm:"name"`
	Age  int    `json:"age"  orm:"age" `
}

2、api/schema.graphqls // 纯文本,描述"前端能查什么"

graphql 复制代码
type User {
  id  : Int!    # ! 表示非空
  name: String!
  age : Int!
}

type Query {
  userList: [User!]!   # 返回一批用户    userList对应后面4中的函数
}

3、hack/gqlgen.go // 生成代码的脚本

go 复制代码
//go:build ignore
// +build ignore

package main

import (
	"github.com/99designs/gqlgen/api"
	"github.com/99designs/gqlgen/codegen/config"
	"log"
)

func main() {
	cfg, err := config.LoadConfigFromDefaultLocations()
	if err != nil {
		log.Fatalln(err)
	}
	if err = api.Generate(cfg, api.AddPlugin()); err != nil {
		log.Fatalln(err)
	}
}

执行

go 复制代码
go run hack/gqlgen.go

会在 api/generated/ 里生成所有骨架,包括 resolver 接口。

4、api/resolver.go // 把"GraphQL 调用"翻译成"GoFrame 查库" 这个是关键 注意看好

go 复制代码
package api

import (
	"context"
	"gql-demo/api/generated"   // gqlgen 生成的接口包
	"gql-demo/api/model"
	"github.com/gogf/gf/v2/frame/g"
)

// Resolver 必须实现 generated.ResolverRoot 接口(生成器已帮你定义好)
type Resolver struct{}

// ------------------ Query 重点-------------------------
// UserList 对应 schema.graphqls 里的 userList 查询
func (r *Resolver) UserList(ctx context.Context) ([]*model.User, error) {
	var users []*model.User
	// 用 GoFrame 链式 ORM 查全表
	err := g.DB().Model("user").Scan(&users)
	if err != nil {
		return nil, err
	}
	return users, nil
}

// 下面三个函数用于把 Resolver 嵌套到 gqlgen 运行时
func (r *Resolver) Query() generated.QueryResolver { return &queryResolver{r} }

type queryResolver struct{ *Resolver }

5、internal/cmd/main.go // 入口:启动 HTTP + GraphQL Playground

go 复制代码
package main

import (
	"gql-demo/api"
	"gql-demo/api/generated"
	"github.com/99designs/gqlgen/graphql/handler"
	"github.com/99designs/gqlgen/graphql/playground"
	"github.com/gogf/gf/v2/frame/g"
	"github.com/gogf/gf/v2/net/ghttp"
)

func main() {
	// 1. 拿到 gqlgen 的解析器
	srv := handler.NewDefaultServer(generated.NewExecutableSchema(generated.Config{Resolvers: &api.Resolver{}}))

	// 2. 用 GoFrame 的 ghttp 注册路由
	s := g.Server()
	s.Group("/", func(group *ghttp.RouterGroup) {
		group.GET("/")                        // 首页提示
		group.GET("/graphql", playground.Handler("GraphQL playground", "/query")) // 可视化界面
		group.POST("/query", srv.ServeHTTP)   // 真正的 GraphQL 查询入口
	})

	// 3. 启动
	s.Run()
}

四、跑起来验证

go 复制代码
go mod tidy
go run internal/cmd/main.go

浏览器打开 http://localhost:8000/graphql,输入:

go 复制代码
query {
  userList {
    id
    name
    age
  }
}

立刻返回:

go 复制代码
{
  "data": {
    "userList": [
      { "id": 1, "name": "Tom",   "age": 18 },
      { "id": 2, "name": "Jerry", "age": 19 }
    ]
  }
}

至此,GoFrame v2 负责数据库 CRUD,gqlgen 负责 GraphQL 协议,两者无缝拼接完成。

五、更加具体一点的使用

加where条件

加参数过滤:

在schema.graphqls的query里面,修改为

go 复制代码
query { userList(age: Int): [User!]! }

resolver 里判断 age != nil.Where("age", age)

连表查询

一、数据库(MySQL)
go 复制代码
CREATE TABLE `user` (
  id   INT PRIMARY KEY AUTO_INCREMENT,
  name VARCHAR(32) NOT NULL
);

CREATE TABLE `product` (
  id   INT PRIMARY KEY AUTO_INCREMENT,
  title VARCHAR(64) NOT NULL,
  price DECIMAL(10,2) NOT NULL
);

CREATE TABLE `order` (
  id        INT PRIMARY KEY AUTO_INCREMENT,
  user_id   INT NOT NULL,
  product_id INT NOT NULL,
  num       INT NOT NULL,
  CONSTRAINT fk_user   FOREIGN KEY (user_id)   REFERENCES user(id),
  CONSTRAINT fk_product FOREIGN KEY (product_id) REFERENCES product(id)
);
go 复制代码
-- 造点数据
INSERT INTO user(name) VALUES ('Tom'),('Jerry');
INSERT INTO product(title,price) VALUES ('iPhone',5999),('MacBook',9999);
INSERT INTO `order`(user_id,product_id,num) VALUES (1,1,2),(2,2,1);
二、GraphQL schema(api/schema.graphqls)
go 复制代码
type User {
  id  : Int!
  name: String!
}

type Product {
  id   : Int!
  title: String!
  price: Float!
}

type Order {
  id     : Int!
  user   : User!      # 连表对象
  product: Product!   # 连表对象
  num    : Int!
}

type Query {
  orderList: [Order!]!   # 一次返回订单+用户+商品
}
三、Go 代码(只贴关键 diff,其余沿用上一篇骨架)

api/model/entity.go // 三张表的实体(GoFrame 命令 gf gen dao 会帮你生成,这里手写简版方便阅读)

go 复制代码
package model

type User struct {
	Id   int    `json:"id"   orm:"id"`
	Name string `json:"name" orm:"name"`
}

type Product struct {
	Id    int     `json:"id"    orm:"id"`
	Title string  `json:"title" orm:"title"`
	Price float64 `json:"price" orm:"price"`
}

type Order struct {
	Id        int      `json:"id"         orm:"id"`
	UserId    int      `json:"user_id"    orm:"user_id"`
	ProductId int      `json:"product_id" orm:"product_id"`
	Num       int      `json:"num"        orm:"num"`
	// 连表对象(数据库不存在,仅用于返回)
	User    *User    `json:"user"     orm:"with:uid=id"`    // 语义标签,非 ORM 约束
	Product *Product `json:"product"  orm:"with:pid=id"`
}

api/resolver.go // 核心:一条 SQL 连三表,一次性 Scan 进结构体

go 复制代码
package api

import (
	"context"
	"gql-demo/api/generated"
	"gql-demo/api/model"
	"github.com/gogf/gf/v2/frame/g"
)

type Resolver struct{}

// OrderList 是实现 schema.graphqls 里 orderList 查询的函数
func (r *Resolver) OrderList(ctx context.Context) ([]*model.Order, error) {
	// 写一条连表 SQL,用别名让结果和 Go 结构体字段一一对应
	sql := `
	SELECT 
	    o.id         AS id,
	    o.num        AS num,
	    u.id         AS uid,
	    u.name       AS uname,
	    p.id         AS pid,
	    p.title      AS ptitle,
	    p.price      AS pprice
	FROM ` + "`order` o " + `
	JOIN user   u ON o.user_id    = u.id
	JOIN product p ON o.product_id = p.id
	`
	var list []*model.Order
	// ScanList 自动把多行结果映射到切片
	err := g.DB().Ctx(ctx).Raw(sql).ScanList(&list, "Order")
	if err != nil {
		return nil, err
	}
	// 二次循环把平铺结果挂到嵌套对象,方便 gqlgen 直接返回
	for _, o := range list {
		o.User = &model.User{
			Id:   o.UserId,
			Name: o.User.Name, // 这里已从 SQL 别名 uname 映射好
		}
		o.Product = &model.Product{
			Id:    o.ProductId,
			Title: o.Product.Title,
			Price: o.Product.Price,
		}
	}
	return list, nil
}

// 下面三行把 Resolver 注册到 gqlgen
func (r *Resolver) Query() generated.QueryResolver { return &queryResolver{r} }

type queryResolver struct{ *Resolver }

其余文件(main.go、schema、初始化)沿用上一篇即可,无需改动。

跑起来验证

go 复制代码
go run internal/cmd/main.go

浏览器打开 http://localhost:8000/graphql,输入:

bash 复制代码
query {
  orderList {
    id
    num
    user { id name }
    product { id title price }
  }
}

返回:

bash 复制代码
{
  "data": {
    "orderList": [
      {
        "id": 1,
        "num": 2,
        "user": { "id": 1, "name": "Tom" },
        "product": { "id": 1, "title": "iPhone", "price": 5999 }
      },
      {
        "id": 2,
        "num": 1,
        "user": { "id": 2, "name": "Jerry" },
        "product": { "id": 2, "title": "MacBook", "price": 9999 }
      }
    ]
  }
}

复杂聚合查询(max、count等)与分页

在 GraphQL 里用 COUNT、SUM、MAX 等聚合函数 一样能玩,思路只有两步:

在 schema 里把"聚合结果"当成一个普通对象暴露出去;

在 resolver 里写 GoFrame 链式 SQL(或 Raw),把 SELECT COUNT(*) ... GROUP BY ... 的结果 Scan 进结构体,再返回。

下面给你一个「完整可跑」的例子:

需求:前端要看到 每个商品被下单多少次、卖出去多少件、总销售额。

一、数据库(沿用之前 order/user/product)

go 复制代码
-- 已有数据
INSERT INTO `order`(user_id,product_id,num) VALUES (1,1,2),(2,1,3),(2,2,1);
-- 需要算:
-- 商品1  2+3=5件  销售额 5*5999
-- 商品2  1件      销售额 1*9999

二、GraphQL schema(api/schema.graphqls)

graphql

复制

go 复制代码
type ProductStat {
  productId   : Int!      # 商品主键
  title       : String!   # 商品名称(连表取)
  orderCount  : Int!      # 订单笔数  COUNT(*)
  totalNum    : Int!      # 卖出件数  SUM(num)
  totalAmount : Float!    # 总销售额  SUM(num*price)
}

type Query {
  productStatList: [ProductStat!]!   # 聚合列表
}

三、Go 代码(关键 resolver)

模型(api/model/stat.go)

go 复制代码
package model

type ProductStat struct {
	ProductId   int     `json:"productId"`   // 商品主键
	Title       string  `json:"title"`       // 商品标题
	OrderCount  int     `json:"orderCount"`  // 订单笔数
	TotalNum    int     `json:"totalNum"`    // 卖出件数
	TotalAmount float64 `json:"totalAmount"` // 总销售额
}

resolver(api/resolver.go)

go 复制代码
func (r *Resolver) ProductStatList(ctx context.Context) ([]*model.ProductStat, error) {
	// 写法1:GoFrame 链式 + Raw,直接一条 SQL 解决
	sql := `
	SELECT
	    p.id          AS product_id,
	    p.title,
	    COUNT(*)      AS order_count,
	    SUM(o.num)    AS total_num,
	    SUM(o.num * p.price) AS total_amount
	FROM product p
	JOIN ` + "`order` o " + `ON o.product_id = p.id
	GROUP BY p.id, p.title
	`
	var list []*model.ProductStat
	if err := g.DB().Ctx(ctx).Raw(sql).Scan(&list); err != nil {
		return nil, err
	}
	return list, nil
}
go 复制代码
// 注册到 gqlgen
func (r *Resolver) Query() generated.QueryResolver { return &queryResolver{r} }
type queryResolver struct{ *Resolver }

四、跑起来

go 复制代码
go run internal/cmd/main.go

Playground 里输入:

go 复制代码
query {
  productStatList {
    productId
    title
    orderCount
    totalNum
    totalAmount
  }
}

返回:

go 复制代码
{
  "data": {
    "productStatList": [
      {
        "productId": 1,
        "title": "iPhone",
        "orderCount": 2,
        "totalNum": 5,
        "totalAmount": 29995
      },
      {
        "productId": 2,
        "title": "MacBook",
        "orderCount": 1,
        "totalNum": 1,
        "totalAmount": 9999
      }
    ]
  }
}

五、如果你更喜欢"纯链式 ORM"而不写 Raw

GoFrame 也支持链式调法:

go

复制

go 复制代码
db := g.DB().Model("order o").
    Fields("o.product_id", "p.title", "COUNT(*) order_count", "SUM(o.num) total_num", "SUM(o.num*p.price) total_amount").
    LeftJoin("product p", "p.id=o.product_id").
    Group("o.product_id, p.title")
var list []*model.ProductStat
err := db.Scan(&list)

等价效果,不想手写 SQL 也能拼出来。

分页

把 schema 改成:

go 复制代码
type ProductStatPage {
  list       : [ProductStat!]!
  totalCount : Int!     # 总商品数
}

type Query {
  productStatPage(page: Int = 1, size: Int = 10): ProductStatPage!
}

resolver 里用 GoFrame 分页语法:

go 复制代码
page, size := g.RequestFromCtx(ctx).GetPage(size, page)
db := g.DB().Model(...).Page(page, size)
total, _ := db.Count()
list := make([]*model.ProductStat, 0)
db.Scan(&list)
return &model.ProductStatPage{List: list, TotalCount: total}, nil

前端拿到 totalCount 可直接渲染分页条。

结论

GraphQL 不排斥聚合函数,把 COUNT/SUM 的结果当成普通对象返回即可;

写一条带 GROUP BY 的 SQL(或链式 ORM),Scan 进结构体,resolver 原样抛出;

再套个 Page() 就能支持分页,复杂度跟写 REST 完全一样,却保留了"前端要多少字段拿多少"的灵活性。

增删改

加 Mutation:在 schema.graphqls 里写

go 复制代码
mutation { createUser(name:String!,age:Int!):User! }

然后实现 func (r *mutationResolver) CreateUser(...),内部用
g.DB().Model("user").Data(g.Map{...}).Insert() 即可。

如果需要更新与删除

go 复制代码
type Mutation {
  # 1. 插入
  createOrder(userId: Int!, productId: Int!, num: Int!): Order!

  # 2. 更新
  updateOrderNum(id: Int!, num: Int!): Order!

  # 3. 删除
  deleteOrder(id: Int!): Boolean!   # 返回 true 表示成功
}

使用graphql一定要做好数据隔离

比如 举个例子 本来用户只能查自己的订单 但是上面的方式 用户能够直接调用代码 查到别人的订单 通过订单id号为别人

后面会讲怎么处理这种情况

简单验权

用户 A 发一条 query { order(id: 1234) { user { name } } }

而订单 1234 其实是用户 B 的 → 直接越权暴露隐私。

下面把「为什么会出现」「怎么修」「代码怎么改」一步步拆开给你看,并给出可直接落地的 最小权限样板。

一、复现越权场景(用我们前面的例子)

resolver 里现在是这样:

go 复制代码
func (r *Resolver) Order(ctx context.Context, id int) (*model.Order, error) {
	var o model.Order
	// 没有任何身份判断,直接按主键查
	err := g.DB().Model("order").Where("id", id).Scan(&o)
	return &o, err
}

请求:

go 复制代码
query { order(id: 1) { id user { name } } }

只要知道订单号就能拿到别人的订单 → 水平权限漏洞(IDOR)。

二、修复思路:在 resolver 里加「数据归属」校验

登录时把用户 ID 写进 JWT(或 Session)。

每次查询都拿"当前用户 ID"和"要查数据的所属用户 ID"比对,不等就直接返回错误。

不要信任前端传的"用户 ID",只认后端从 token 里解析出来的。

三、代码改造(最小侵入版)

登录成功时签发 JWT(只贴关键片段)

go 复制代码
// internal/logic/user.go
import "github.com/golang-jwt/jwt/v4"
func Login(ctx context.Context, name string) (string, error) {
	var u model.User
	if err := g.DB().Model("user").Where("name", name).Scan(&u); err != nil {
		return "", err
	}
	token := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{
		"uid": u.Id,           // 把用户主键写进去
		"exp": time.Now().Add(24 * time.Hour).Unix(),
	})
	return token.SignedString([]byte("your-secret"))
}

写一个中间件,把 uid 塞进 context

go

复制

go 复制代码
// internal/middleware/auth.go
func Auth(r *ghttp.Request) {
	tokenStr := r.Header.Get("Authorization")
	claims := jwt.MapClaims{}
	if _, err := jwt.ParseWithClaims(tokenStr, &claims, func(t *jwt.Token) (interface{}, error) {
		return []byte("your-secret"), nil
	}); err != nil {
		r.Response.WriteStatusExit(401, "Unauthorized")
	}
	uid := int(claims["uid"].(float64))
	// 把当前用户 ID 塞进后续能拿到的 context
	ctx := context.WithValue(r.Context(), "uid", uid)
	r.SetCtx(ctx)
	r.Middleware.Next()
}

在 resolver 里做"订单归属"判断

go 复制代码
func (r *Resolver) Order(ctx context.Context, id int) (*model.Order, error) {
	// 1. 取当前登录用户
	uid, ok := ctx.Value("uid").(int)
	if !ok {
		return nil, fmt.Errorf("未登录")
	}
	// 2. 先查订单
	var o model.Order
	if err := g.DB().Model("order").Where("id", id).Scan(&o); err != nil {
		return nil, err
	}
	// 3. 关键:订单不属于当前用户 → 直接拒绝
	if o.UserId != uid {
		return nil, fmt.Errorf("无权查看他人订单")
	}
	// 4. 再补连用户、商品(同之前)
	_ = g.DB().Model("user").Where("id", o.UserId).Scan(&o.User)
	_ = g.DB().Model("product").Where("id", o.ProductId).Scan(&o.Product)
	return &o, nil
}

路由层挂中间件(main.go)

go 复制代码
s := g.Server()
s.Group("/query", func(g *ghttp.RouterGroup) {
	g.Middleware(middleware.Auth) // 所有 GraphQL 请求先过鉴权
	g.ALL("", graphqlHandler)
})

四、效果验证

用户 A 的 uid=1,JWT 里也是 1

请求 order(id:1) → 订单.user_id=1 → 正常返回

用户 A 改 id 为 2

订单.user_id=2 ≠ 1 → 直接报错

{"errors":[{"message":"无权查看他人订单"}]}

五、再上层保险(可选)

数据库层加 row-level security(MySQL 8.0 支持 RLS,Pg 更成熟),SQL 里自带 WHERE user_id = current_uid(),即使代码漏判也查不到。

把"用户 ID"作为 强制参数 放进 GraphQL 查询,例如:

graphql

复制

query { myOrderList { id product { title } } } # 后端永远只返回当前用户

不提供按订单号单查的入口,彻底杜绝越权。

网关层统一做 字段级授权(graphql-shield 规则),resolver 只写业务,不用重复写 if uid!=xxx。

结论

GraphQL 不会自动帮你做"只能看自己的"这种业务级隔离;

"谁能看哪条数据"必须自己在 resolver(或服务层)里显式判断,判断依据只能来自后端可信的登录态(JWT/session),绝不能信任前端传的任何"用户 ID"。

只要加上"数据归属"这一行 if,就能把前面说的"订单 ID 遍历"漏洞彻底堵死。

直接拖库

会------但只要按规范部署,GraphQL 并不比 REST 更"危险"。

把"整个库被拉走"通常不是 GraphQL 本身的问题,而是缺了必要的安全治理。下面把常见风险点、攻击场景和对应的"加固措施"逐条列给你,所有结论都基于 2024-2025 年社区与厂商的公开复盘 。

一、GraphQL 独有的"可被直接拖库"风险场景

无限嵌套 + 缺少深度限制

典型 payload:

graphql

复制

query { user(id: 1) {

friends { # 第 1 层

friends { # 第 2 层

friends { # ...

friends { id name email }

}

}

}

} }

如果好友关系双向,一条查询就能指数级膨胀,把全站用户刷出来 。

→ 缓解:给 gqlgen 加 FixedComplexityLimit 或 depthLimit 中间件,推荐深度 ≤ 8。

缺少分页 / 数量限制

graphql

复制

query { users { id name email phone } } # 一次 SELECT * FROM user

十万用户直接打满内存。

→ 缓解:

全部列表型字段强制加 first/last/max=50 参数;

在数据库层用 游标分页(WHERE id > cursor LIMIT 50),而不是 OFFSET。

内省(Introspection)把整张"数据地图"暴露给陌生人

默认 /graphql?query={__schema{types{name fields{name}}}} 就能拿到所有字段、关系、枚举值 。

→ 生产环境务必关闭内省(gqlgen 的 introspection: false),或者只在调试白名单 IP 开启。

未做字段级授权------"查得到对象就能看到所有列"

例如 User 类型里把 email、idCard、salary 全部暴露,普通用户也能顺手带走别人的敏感列。

→ 缓解:

在 resolver 里做 列白名单(row-level + column-level);

或者使用 GraphQL 网关(Apollo Router / graphql-shield)统一加权限注解。

二、"老熟人"漏洞在 GraphQL 里一样会出现

SQL 注入

直接把参数拼进 SQL:

go

复制

db.Raw("SELECT * FROM user WHERE name = '" + name + "'") // ❌

社区案例里被 sqlmap 一把拖走 。

→ 始终用参数化查询 / GoFrame ORM 链式条件,不要手写拼接。

CSRF / Clickjacking

如果 GraphQL 端点接受 application/x-www-form-urlencoded,攻击者可以构造隐藏表单把恶意 mutation 塞进去 。

→ 只接受 application/json;启用 CSRF Token 或 SameSite=Strict。

批量查询(Batching)轰炸

HTTP/2 下一次发 1000 条 {users{...}} 请求体,服务器照样会执行 1000 次 SQL。

→ 在网关层限制 单 HTTP 请求只含单一 GraphQL 查询;或者给 Batching 总量设上限(Apollo 默认 10)。

三、业界落地的"最小安全基线"清单

表格

复制

防护措施 推荐值 / 工具 作用

查询深度限制 ≤ 8 层 防无限嵌套

查询复杂度上限 5000 点(gqlgen FixedComplexityLimit) 防 DoS

分页大小上限 50 条 / 次 防一次性拖全表

关闭内省 introspection: false 隐藏数据地图

字段级授权 graphql-shield / 手写 if 防越权

参数化 SQL GoFrame .Where("id", uid) 防 SQL 注入

关闭 GraphiQL 生产环境不挂载 playground 减少调试入口

速率限制 100 req/min / IP 防爆破

结论

GraphQL 确实比 REST 多一个"可被任意字段查询"的入口,但只要在网关 / resolver 里把"深度、数量、权限、速率"四件事管住,它就不会比传统接口更容易被拖库。相反,schema 驱动的强类型反而让"字段级审计"更容易自动化。

一句话:安全不是 GraphQL 的锅,缺安全策略才是。

知识讲解

GraphQL的核心概念:图表模式(Schema)

要想要设计GraphQL的数据模型,用来描述你的业务数据,那么就必须要有一套Schema语法来做支撑。

数据类型

想要描述数据,就必须离不开数据类型的定义。所以GraphQL设计了一套Schema模式(可以理解为语法),其中最重要的就是数据类型的定义和支持。

那么类型(Type)就是模式(Schema)最核心的东西了。

什么是类型?

对于数据模型的抽象是通过类型(Type)来描述的,每一个类型有若干字段(Field)组成,每个字段又分别指向某个类型(Type)。这很像Java、C#中的类(Class)。

GraphQL的Type简单可以分为两种,一种叫做Scalar Type(标量类型),另一种叫做Object Type(对象类型)。

那么就分别来介绍下两种类型。

标量类型(Scalar Type)

标量是GraphQL类型系统中最小的颗粒。类似于Java、C#中的基本类型。

其中内建标量主要有:

String

Int

Float

Boolean

Enum

ID

上面的类型仅仅是GraphQL默认内置的类型,当然,为了保证最大的灵活性,GraphQL还可以很灵活的自行创建标量类型。

对象类型(Object Type)

仅有标量类型是不能满足复杂抽象数据模型的需要,这时候我们可以使用对象类型。

通过对象模型来构建GraphQL中关于一个数据模型的形状,同时还可以声明各个模型之间的内在关联(一对多、一对一或多对多)。

对象类型的定义可以参考下图:

是不是很方便呢?我们可以像设计类图一样来设计GraphQL的对象模型。

类型修饰符(Type Modifier)

那么,类型系统仅仅只有类型定义是不够的,我们还需要对类型进行更广泛性的描述。

类型修饰符就是用来修饰类型,以达到额外的数据类型要求控制。

比如:

列表:[Type]

非空:Type!

列表非空:[Type]!

非空列表,列表内容类型非空:[Type!]!

在描述数据模型(模式Schema)时,就可以对字段施加限制条件。

例如定义了一个名为User的对象类型,并对其字段进行定义和施加限制条件:

那么,返回数据时,像下面这种情况就是不允许的:

Graphql会根据Schema Type来自动返回正确的数据:

其他类型

除了上面的,Graphql还有一些其他类型来更好的引入面向对象的设计思想:

接口类型(Interfaces):

其他对象类型实现接口必须包含接口所有的字段,并具有相同的类型修饰符,才算实现接口。

比如定义了一个接口类型:

那么就可以实现该接口:

联合类型(Union Types):

联合类型和接口十分相似,但是它并不指定类型之间的任何共同字段。几个对象类型共用一个联合类型。

输入类型(Input Types):

更新数据时有用,与常规对象只有关键字修饰不一样,常规对象时 type 修饰,输入类型是 input 修饰。

比如定义了一个输入类型:

前端发送变更请求时就可以使用(通过参数来指定输入的类型):

所以,这样面向对象的设计方式,真的对后端开发人员特别友好!而且前端MVVM框架流行以来,面向对象的设计思想也越来越流行,前端使用Graphql也会得心应手。

Graphql 技术接入架构

那么,该怎么设计来接入我们现有的系统中呢?

将Graphql服务直连数据库的方式:最简洁的配置,直接操作数据库能减少中间环节的性能消耗。

集成现有服务的GraphQL层:这种配置适合于旧服务的改造,尤其是在涉及第三方服务时、依然可以通过原有接口进行交互。

直连数据库和集成服务的混合模式:前两种方式的混合。

这种模式 其实更像graphql实现了传统api定义层或者controller层需要的东西,自定义传哪些字段

需要对后台服务大改么?

使用了GraphQL就要完全抛弃REST了吗?

GraphQL需要直接对接数据库吗?

使用GraphQL需要对现有的后端服务进行大刀阔斧的修改吗?

答案是:NO!不需要!(即使不采用下面的部署模式也不用这么大的修改)

它完全可以以一种不侵入的方式来部署,将它作为前后端的中间服务,也就是,现在开始逐渐流行的 前端 ------ 中端 ------ 后端 的三层结构模式来部署!

那就来看一下这样的部署模式图:

GraphQL应用的基本架构

下图是一个 GraphQL 应用的基本架构,其中客户端只和 GraphQL 层进行 API 交互,而 GraphQL 层再往后接入各种数据源。这样一来,只要是数据源有的数据, GraphQL 层都可以让客户端按需获取,不必专门再去定接口了。

一个GraphQL服务仅暴露一个 GraphQL Endpoint,可以按照业务来进行区分,部署多个GraphQL服务,分管不同的业务数据,这样就可以避免单服务器压力过大的问题了。

Graphql的一些工具

graphiql (npm): 一个交互式的运行于浏览器中的 GraphQL IDE。

Graphql Language Service: 一个用于构建 IDE 的 GraphQL 语言服务(诊断、自动完成等) 的接口。

quicktype (github): 在 TypeScript、Swift、golang、C#、C++ 等语言中为 GraphQL 查 询生成类型。

想要获取更多关于Graphql的一些框架、工具,可以去awesome-graphql:一个神奇的社区,维护一系列库、资源等,地址是

https://github.com/chentsulin/awesome-graphql。

想要学习更多Graphql的知识,可以去GraphQL.cn

other

我能这样理解么 实际上就是 后端把所有数据都查出来,然后前端根据需要 自己选择需要展示哪些字段?

不是"后端把所有数据都查出来",而是:

后端只查前端"请求里要的那些字段"(GraphQL 的按需查询)。

举个例子:

schema 里即使暴露了 20 个字段,前端只写 3 个 → resolver 里只会 SELECT id, name, price(gqlgen 会自动帮你投影),不会把另外 17 个字段捞回来。

数据库层面也是按需的(不会 SELECT *)。

下面给你一段实测代码,同一接口,两次请求,SQL 语句字段数完全不同。

实测对比(gqlgen + GoFrame)

schema:

go 复制代码
type Product {
  id          : Int!
  title       : String!
  price       : Float!
  stock       : Int!
  description : String!
}

resolver 里打印实际 SQL:

go 复制代码
func (r *Resolver) Product(ctx context.Context, id int) (*model.Product, error) {
	// 这里故意不写具体字段,用 gqlgen 的自动投影
	var p model.Product
	err := g.DB().Model("product").Where("id", id).Scan(&p)
	g.Log().Debug(ctx, g.DB().GetLastSql()) // 打印 SQL
	return &p, err
}

① 前端只要标题:

go 复制代码
query { product(id:1){ title } }

实际 SQL(日志):

go 复制代码
SELECT `title` FROM `product` WHERE `id` = 1

② 前端多要两个字段:

go 复制代码
query { product(id:1){ title price stock } }

实际 SQL:

go 复制代码
SELECT `title`, `price`, `stock` FROM `product` WHERE `id` = 1

看到区别了吗?gqlgen 会根据 selection set 自动帮你拼接要查询的字段,不会 SELECT * 回来再过滤。

例外情况

你在 resolver 里手写 Raw SQL 并且写了 SELECT *,那就真的会全表拖;

→ 解决:用链式 ORM 或者手写字段列表,保持与前端 selection 一一对应。

嵌套子对象(friends{...})默认会再发一次 SQL;

→ 可用 DataLoader 或提前 JOIN 批量解决,但依旧只选要用的列。

一句话总结

GraphQL 的"按需"是全程按需:

前端只问 3 个字段 → resolver 只解析 3 个字段 → SQL 只查 3 个字段 → 网络只回 3 个字段。

不是"全查回来再挑",而是"没点的分支连数据库都不碰"。

相关推荐
席万里2 小时前
Go开源库gcurl实际生产级应用
开发语言·后端·golang
yuuki2332332 小时前
【数据结构&C语言】排序大汇总
c语言·数据结构·后端·排序算法
间彧2 小时前
Docker 数据持久化完全指南:四种挂载方式详解与实战
后端
IT_陈寒2 小时前
SpringBoot 3.2 性能优化全攻略:7个让你的应用提速50%的关键技巧
前端·人工智能·后端
❀͜͡傀儡师3 小时前
springboot集成mqtt服务,自主下发
java·spring boot·后端·mqtt·netty
火车叼位3 小时前
兼容命令行与 Android Studio 的 JDK 策略:从踩坑到方案
后端
IMPYLH3 小时前
Lua 的 pairs 函数
开发语言·笔记·后端·junit·单元测试·lua
用户345848285053 小时前
什么是 Java 内存模型(JMM)?
后端
南雨北斗3 小时前
kotlin中的继承和委托
后端