Nest 使用笔记
第十六章:GraphQL 分析看板——code-first、多数据源聚合
打开 learnhub 的 AnalyticsResolver,在真实代码里讲透 GraphQL 的 code-first schema、Apollo 驱动配置、Resolver/ObjectType 装饰器,以及如何用一个 GraphQL 查询聚合 MySQL 计数与 Mongo DAU。每段代码都能在 learnhub 里指到对应文件。
- Nest
- GraphQL
- 数据聚合
前面十五章里 learnhub 的 REST 接口已经搭起来了——帖子、评论、权限、上传都齐。这一章加一个新口味:用 GraphQL 给运营做一个分析看板,DAU(日活)+ 站点总览一次取回。和「写个 hello resolver 跑通查询」不同,这一章直接打开 learnhub 已经在用的 AnalyticsResolver,在真实代码里讲透四件事:code-first schema 怎么生成、Resolver/ObjectType 怎么写、多数据源在一个查询里怎么聚合、同一个 JWT Guard 怎么同时盖住 HTTP 和 GraphQL。
顺带说一句:这一章原本计划讲 Nginx 反向代理,后来课程结构调整了——Nginx 网关配置挪到第二十三章 wrap-up 用 Docker Compose 编排整套服务时一起讲。所以这个文件的 URL 还是 /docs/nest/nginx/,但内容讲的是 GraphQL,找 Nginx 那部分的同学直接跳第二十三章。
先搞懂:GraphQL 是什么 / 为什么需要 / 企业级怎么用
GraphQL 是什么:Facebook 2012 内部用、2015 开源的查询语言 + 运行时。客户端自己写查询语句说要哪些字段,服务端按需返回,全套服务对外只有一个 endpoint。一份 schema 描述「服务能回答什么」,客户端照着 schema 写 query,服务端解析执行。
为什么需要它:REST 的痛点很真实——over-fetch(详情接口返回了一大堆列表里用不到的字段)、under-fetch(首页要拼帖子+作者+评论,得调 3 次再前端组装)、版本地狱(v1/v2/v3 一改就破老前端)。GraphQL 一次性解决:客户端按需取字段、一次查询拿多源数据、schema 即合约。但前提是你要懂它的复杂度——N+1、查询深度限制、缓存策略都会悄悄咬人。
企业级怎么用(下面每一条,这一章都会在 learnhub 里真做一遍,不是空头支票):
- REST 和 GraphQL 共存而非二选一——learnhub 的帖子/认证/权限继续走 REST(简单增删改查够用),只给运营看板这种「需要灵活聚合多源」的场景上 GraphQL;
- code-first:写 TS resolver 类 + 装饰器,schema 自动生成,TS 类型是唯一真相源——本章第三步落地;
- 鉴权复用 HTTP 那套——同一个 JwtAuthGuard 同时盖住 REST 和 GraphQL,不重写——本章第七步落地;
- 警惕 N+1:列表查询返回的关联字段,每条都触发一次解析 → 上 dataloader 批量。
这一章你会做出什么
- 打开 learnhub 的
src/modules/analytics/,逐文件讲清楚它为什么这么写。 - 在真实代码里吃透:code-first schema 生成(
autoSchemaFile)、Resolver + ObjectType 装饰器、多数据源聚合(一个summary查询里同时查 MySQL 计数 + Mongo DAU)、GraphQL 鉴权(GqlExecutionContext)。 - 在浏览器 Apollo Sandbox 里写出查询语句,看到 JSON 结果。
前置:learnhub 跑着(MySQL 必起,Mongo 起了才能看 DAU),种子数据里有 admin / admin123456,仓库代码已拉到本地能打开看。
第一步:先想清楚——GraphQL vs REST,什么时候用哪个
新手接触 GraphQL 经常被带着走「REST 过时了」——这是错觉。两种范式各有适用面,learnhub 是混着用的。
REST 在 learnhub 里的职责:帖子 CRUD(/api/v1/posts)、登录(/api/v1/auth/login)、文件上传、权限管理——这些是资源导向的简单增删改查,每个接口返回结构稳定、字段固定,REST 的「URL 是资源、HTTP 动词是动作」模型非常贴。
GraphQL 在 learnhub 里的职责:只有运营分析看板一个场景。为什么是它?因为看板有几个特征:
- 多源:DAU 来自 Mongo(行为日志聚合)、站点总览来自 MySQL(用户/帖子/评论计数),一个画面要同时显示。
- 频繁演进:今天看 DAU,明天加留存,后天加分渠道口径——REST 写死 JSON 字段每次都要改后端,GraphQL 让客户端自己挑字段。
- 多端复用:Web 看板、内部 BI 工具、报表导出,都从同一个 graph 取数据,各取所需。
思考:分析看板本来可以做成 GET /api/v1/analytics/summary 返回固定 JSON——为什么这里值得上 GraphQL?因为看板是个频繁变视图的场景:产品每周加新指标、改口径,REST 要么做成一个大而全的接口每次都 over-fetch、要么每加一个指标发一版后端,GraphQL 让客户端自己拼字段、后端只负责把「能回答的问题」用 schema 暴露。这才是 GraphQL 真正的甜区——前端需求不确定、聚合多源、视图演进频繁。反过来,简单的 posts/:id 详情接口上 GraphQL 就是给自己找麻烦。
注意:GraphQL 不是 REST 的替代品,是补充。整个项目全切 GraphQL 会让你为「取一个帖子」这种简单需求也写一堆 query string + 解析逻辑、加 N+1 监控、维护 schema 演进——复杂度收益不对等。learnhub 只在分析看板上用它,是经过取舍的:简单 REST、灵活 GraphQL,各司其职。
第二步:code-first vs schema-first——为什么选 code-first
GraphQL 的 schema(.graphql 文件)描述了类型、查询、字段——是一份契约。写 schema 有两条路:
- schema-first:先手写
schema.graphql(type Query { summary: Summary! }这种),再用代码生成器生成 TS 类型,最后写 resolver 实现它。schema 是真相源。 - code-first:直接写 TS resolver 类 + 装饰器(
@ObjectType() @Field()),Nest 启动时自动从这些装饰器生成 schema。TS 代码是真相源。
learnhub 选 code-first(autoSchemaFile: true),原因:
- TS 类型已经是真相源了——entity、DTO、service 返回值都靠 TS 类型驱动。再手写一份
.graphqlschema 等于维护两份类型定义,改个字段两处改、还容易对不齐。 - 装饰器和实体写在一起,IDE 跳转、重构、类型检查都顺,写起来和写 controller 几乎一样。
- schema 启动时自动生成到内存(
autoSchemaFile: true),不用把生成的.graphql文件 commit 进 git,没有「生成物 vs 手写」的冲突。
代价:schema 长什么样不能直接看文件(要么把 autoSchemaFile 设成路径落盘看、要么去 Apollo Sandbox 浏览),新人入门要多认几个装饰器。learnhub 觉得这个代价值得——下面会看到,code-first 下写 GraphQL 和写 REST controller 几乎是同一件事。
第三步:Apollo 驱动的接入——forRootAsync
Nest 的 @nestjs/graphql 是个抽象层,下面要接一个具体的 GraphQL 服务器实现。learnhub 用 Apollo(@nestjs/apollo + ApolloDriver),是 Nest 生态最成熟的选择。接进 app.module.ts:
// learnhub/src/app.module.ts
import { GraphQLModule } from '@nestjs/graphql';
import { ApolloDriver, ApolloDriverConfig } from '@nestjs/apollo';
GraphQLModule.forRootAsync<ApolloDriverConfig>({
driver: ApolloDriver,
useFactory: () => ({
autoSchemaFile: true, // code-first:从装饰器自动生成 schema(存内存)
path: 'graphql',
useGlobalPrefix: true, // 配合 main.ts 的 setGlobalPrefix('api') → /api/graphql
sortSchema: true, // 生成的 schema 字段按字母排序,diff 干净
// GET /api/graphql 默认进 Apollo Sandbox(浏览器里的在线测试 UI)
}),
}),
几个要点:
driver: ApolloDriver显式指定用 Apollo——Nest 也支持 Mercurius(Fastify 下更轻)等其他驱动,写法会有差异,认准一个。autoSchemaFile: true是 code-first 的开关:值为true表示 schema 只存内存、不落盘;值也可以是一个文件路径(join(process.cwd(), 'schema.graphql')),那样会把生成的 schema 写进文件、方便 review。learnhub 选内存。path: 'graphql'+useGlobalPrefix: true:因为main.ts里设了全局前缀api,组合起来 GraphQL 端点是/api/graphql。这两个必须配套——只设path不开useGlobalPrefix,端点会是/graphql,前缀不一致会让前端代理和鉴权逻辑乱掉。sortSchema: true让生成的 schema 字段稳定排序,团队协作时 commit diff 才不会因为字段顺序随机变而脏掉。forRootAsync而不是forRoot:和 TypeORM 一样,留异步工厂口子方便未来从ConfigService注入配置(虽然这里useFactory没注入依赖,但和其他模块风格统一)。
注意:Apollo Sandbox 是 Apollo Server v3+ 默认的浏览器测试 UI——访问 GET /api/graphql(注意是 GET,POST 才是真正发查询)会出来一个网页,左边写 query、右边看结果、还能看自动生成的 schema。它是 GraphQL 版的 Swagger——第十四章 Swagger 已经讲过这类「在线试接口」的思路,这里同理:浏览器里直接试,不用 curl。
第四步:定义 ObjectType——返回给客户端的对象类型
GraphQL 的一切都靠 schema 里的类型。code-first 下,类型是 TS 类 + 装饰器。learnhub 看板要返回两种数据:DAU 的点(日期 + 活跃数)、站点总览(用户/帖子/评论/浏览量计数)。各定义一个 @ObjectType:
// learnhub/src/modules/analytics/dto/analytics.dto.ts
import { Field, Int, ObjectType } from '@nestjs/graphql';
@ObjectType()
export class DauPoint {
@Field(() => String, { description: '日期 YYYY-MM-DD' })
date: string;
@Field(() => Int, { description: '当日活跃用户数(distinct userId)' })
activeUsers: number;
}
@ObjectType({ description: '站点总览统计' })
export class Summary {
@Field(() => Int) userCount: number;
@Field(() => Int) postCount: number;
@Field(() => Int) commentCount: number;
@Field(() => Int, { description: '全部帖子浏览量之和' }) totalViews: number;
}
@ObjectType() 把类标记成「GraphQL 的对象类型」,@Field() 把每个属性标记成「这个类型的一个字段」。@Field(() => Int) 显式声明标量类型——TS 的 number 在 GraphQL 里歧义(可能是 Int 也可能是 Float),所以必须显式指明。description 写进 schema 文档,Apollo Sandbox 里能看到,作用等同 Swagger 的 @ApiProperty 描述。
注意 DauPoint 在 resolver 里返回的是 [DauPoint](列表),因为 DAU 要返回近 N 天的曲线,每天一个点;Summary 是单对象。
第五步:写 Resolver——Query 怎么映射到查询
ObjectType 定义了「形状」,Resolver 定义了「怎么取」。GraphQL 的查询分三类:Query(读)、Mutation(写)、Subscription(订阅、WebSocket 推)。learnhub 看板只读,所以只写 @Query:
// learnhub/src/modules/analytics/analytics.resolver.ts
import { Args, Int, Query, Resolver } from '@nestjs/graphql';
import { AnalyticsService } from './analytics.service';
import { DauPoint, Summary } from './dto/analytics.dto';
@Resolver()
export class AnalyticsResolver {
constructor(private readonly analytics: AnalyticsService) {}
@Query(() => [DauPoint], { description: '近 N 天日活' })
async dau(@Args('days', { type: () => Int, defaultValue: 7 }) days: number): Promise<DauPoint[]> {
return this.analytics.dau(days);
}
@Query(() => Summary, { description: '站点总览:用户/帖子/评论/浏览量' })
async summary(): Promise<Summary> {
return this.analytics.summary();
}
}
几个要点:
@Resolver()标记这是个 resolver 类,Nest 启动时扫到、注册进 GraphQL schema 生成。空括号表示「不绑定到某个具体 ObjectType 的字段解析」(那种用法叫字段 resolver,给某个对象类型的字段写自定义解析逻辑,learnhub 这里用不上)。@Query(() => [DauPoint])声明一个 Query:返回类型是DauPoint的列表(方括号是 GraphQL 的列表类型)。和@Field一样,返回类型必须显式传函数——Nest 靠反射拿不到运行时泛型信息(TS 编译后类型擦除),必须告诉它这个 query 返回什么。@Args('days', { type: () => Int, defaultValue: 7 })声明一个查询参数:客户端传dau(days: 30),没传就用默认 7 天。type: () => Int同样要显式。- resolver 自己不写业务逻辑——只负责把 GraphQL 查询翻译成对 service 的调用,业务全在
AnalyticsService。这和 REST 那边 controller/service 分层完全一致:Resolver 就是 GraphQL 版的 Controller。
对应到客户端的查询语句长这样:
query {
summary {
userCount
postCount
totalViews # 客户端可以只挑要的字段,没要 commentCount 就不会返回
}
dau(days: 14) {
date
activeUsers
}
}
这条查询里,客户端一次请求同时拿了 summary 和 dau——REST 要么调两次接口、要么后端专门写一个聚合接口。这是 GraphQL 的核心收益之一:一个查询拿多源数据,客户端决定要哪些字段。
第六步:多数据源聚合——一个查询背后查 MySQL + Mongo
这是这一章最有价值的一节。summary 和 dau 看似两个简单查询,背后却跨了 MySQL 和 Mongo 两个数据源。看 AnalyticsService:
// learnhub/src/modules/analytics/analytics.service.ts
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { BehaviorLogService } from '../behavior-log/behavior-log.service';
@Injectable()
export class AnalyticsService {
constructor(
private readonly behaviorLog: BehaviorLogService,
@InjectRepository(User) private readonly userRepo: Repository<User>,
@InjectRepository(Post) private readonly postRepo: Repository<Post>,
@InjectRepository(Comment) private readonly commentRepo: Repository<Comment>,
) {}
async dau(days: number): Promise<DauPoint[]> {
return this.behaviorLog.dau(days);
}
async summary(): Promise<Summary> {
const [userCount, postCount, commentCount, viewRow] = await Promise.all([
this.userRepo.count(),
this.postRepo.count(),
this.commentRepo.count(),
this.postRepo.createQueryBuilder('p').select('COALESCE(SUM(p.view_count),0)', 's').getRawOne(),
]);
return {
userCount,
postCount,
commentCount,
totalViews: Number(viewRow?.s ?? 0),
};
}
}
两个看点:
看点一:多数据源在一个 service 里组合。 AnalyticsService 同时注入了 TypeORM 的 Repository<User/Post/Comment>(走 MySQL)和 BehaviorLogService(内部走 Mongo)。summary 的四个字段都来自 MySQL:用户/帖子/评论计数各一个 repo.count(),浏览量求和走 QueryBuilder 的 SUM;dau 则完全委托给 BehaviorLogService——它内部查的是 Mongo 的行为日志集合(第十八章细讲)。客户端看到的 Summary 对象字段是统一的、来源对它透明——这就是多源持久化(polyglot persistence)在一个 API 后面统一暴露的收益,GraphQL 在这里扮演 facade(外观)的角色。
看点二:Promise.all 并发执行。 四个 MySQL 查询彼此独立,用 Promise.all 并发而不是 await 一个接一个——总耗时约等于最慢的那个,而不是四个之和。看板类接口这种「拼装多个独立计数」的场景,并发差别很明显:串行 4 条各 20ms 是 80ms,并发就是 ~20ms。
至于 DAU 那条,看 BehaviorLogService.dau:
// learnhub/src/modules/behavior-log/behavior-log.service.ts
async dau(days = 7): Promise<{ date: string; activeUsers: number }[]> {
const since = new Date(Date.now() - days * 24 * 3600 * 1000);
const rows = await this.model.aggregate<{
_id: string;
activeUsers: number;
}>([
{ $match: { createdAt: { $gte: since }, userId: { $ne: null } } },
{
$group: {
_id: { $dateToString: { format: '%Y-%m-%d', date: '$createdAt' } },
users: { $addToSet: '$userId' },
},
},
{ $project: { _id: 1, activeUsers: { $size: '$users' } } },
{ $sort: { _id: 1 } },
]);
return rows.map((r) => ({ date: r._id, activeUsers: r.activeUsers }));
}
这是 Mongo 的聚合管道:$match 过滤时间范围 → $group 按天分组并用 $addToSet 收集每天出现过的 userId → $project 用 $size 算集合大小(去重后的活跃数)→ $sort 按日期升序。这套管道的细节(schema、索引、为什么用 Mongo 存行为日志)是第十八章的内容,这里只要知道一个事实:MySQL 的「按天去重计数」在大表上很贵,Mongo 的聚合管道专为这类文档聚合设计。GraphQL 在前面把两个引擎的能力拼成一个统一 API。
思考:为什么 DAU 不直接在 MySQL 算?因为行为日志是「写入极频繁、字段可能变、按时间范围扫」的典型文档场景——每次请求都 append 一条,MySQL 行锁 + 索引开销大,去重聚合在大表上跑 COUNT(DISTINCT) 会很慢。Mongo 无 schema、写优化、原生聚合管道,正好对路。这是「为不同数据形态选不同存储」的实践——learnhub 把交易型数据(用户、帖子、评论)给 MySQL,把日志型数据(行为流)给 Mongo。GraphQL 在前面把它们统一暴露给客户端,客户端完全感知不到底下是两个引擎。
模块装配把这一切串起来:
// learnhub/src/modules/analytics/analytics.module.ts
@Module({
imports: [TypeOrmModule.forFeature([User, Post, Comment]), BehaviorLogModule],
providers: [AnalyticsService, AnalyticsResolver],
})
export class AnalyticsModule {}
TypeOrmModule.forFeature([...]) 注册 MySQL 三个实体的 Repository,BehaviorLogModule 是 Mongo 那边的服务(exports: [BehaviorLogService, ...],第十八章细讲)。providers 把 Resolver 和 Service 都注册——Resolver 也算 provider,Nest 才会把它扫进 GraphQL schema 生成。
第七步:鉴权——同一个 JwtAuthGuard 盖住 GraphQL
GraphQL 请求也走 HTTP(POST /api/graphql,body 里是 query string),所以理论上 HTTP 的中间件、守卫、拦截器都能作用到 GraphQL 上——但有一个坑:GraphQL 的 ExecutionContext 和 HTTP 的不一样,直接 context.switchToHttp().getRequest() 在 GraphQL 请求里拿不到东西。learnhub 的 JwtAuthGuard 做了个分支处理:
// learnhub/src/core/guards/jwt-auth.guard.ts
import { GqlExecutionContext } from '@nestjs/graphql';
private getRequest(context: ExecutionContext): Request & { user?: RequestUser } {
if (context.getType() === 'http') {
return context.switchToHttp().getRequest();
}
const gqlCtx = GqlExecutionContext.create(context);
return gqlCtx.getContext().req;
}
GqlExecutionContext.create(context).getContext().req 把 GraphQL 的执行上下文转回底层的 Express Request——Apollo 驱动默认把 express 的 req 挂在 GraphQL context 上,所以拿得到。这样 canActivate 里取 token、验签、挂 req.user 的逻辑一行不用改,REST 和 GraphQL 共用一套鉴权(第十一章细讲 JwtAuthGuard 本体,这里只看它怎么兼容 GraphQL)。
客户端怎么带 token?GraphQL 请求的 HTTP header 里加 Authorization: Bearer xxx,和 REST 一模一样。Apollo Sandbox 顶部有 Headers 输入框,加一条 {"Authorization": "Bearer eyJ..."} 就能带着鉴权发查询。
注意:第十一章(JWT)讲的 @Public() 白名单在 GraphQL 上同样生效——因为 JwtAuthGuard 是 APP_GUARD 全局注册的,无论请求走 REST 还是 GraphQL 都会被拦一道。如果某个 query 想公开(比如首页的公开统计),在 resolver 方法上加 @Public() 即可。learnhub 的 analytics 查询默认需要登录,因为运营看板不对外开放。
第八步:跑起来,在 Apollo Sandbox 里写查询
cd learnhub
docker compose -f docker/docker-compose.yml up -d mysql redis mongo # 起 MySQL/Redis/Mongo
npm install
npm run migration:run # 建表 + 灌种子
npm run start:dev # 起服务
服务起来后,浏览器打开 http://localhost:3000/api/graphql(GET),进 Apollo Sandbox。先登录取 token(用第十一章的 /api/v1/auth/login,账号 admin / admin123456),然后在 Sandbox 顶部 Headers 加:
{ "Authorization": "Bearer 把token粘这里" }
写一条查询:
query {
summary {
userCount
postCount
commentCount
totalViews
}
dau(days: 7) {
date
activeUsers
}
}
点运行,拿到(数据取决于你库里的内容):
{
"data": {
"summary": { "userCount": 1, "postCount": 5, "commentCount": 3, "totalViews": 142 },
"dau": [
{ "date": "2026-07-13", "activeUsers": 1 },
{ "date": "2026-07-14", "activeUsers": 1 }
]
}
}
试着只挑要的字段——把 summary 里的 commentCount 删掉再跑,返回的 JSON 里就没那个字段;把 dau(days: 7) 改成 dau(days: 30),曲线立刻变成 30 天。这就是 GraphQL「客户端决定字段、客户端决定参数」最直观的体验:schema 没变、resolver 没变,客户端改 query 就能改返回结构。
这一章的成果
- 接好
@nestjs/graphql+ Apollo 驱动,autoSchemaFile: true走 code-first,schema 从 TS 装饰器自动生成,端点/api/graphql走全局前缀。 - 写了
AnalyticsResolver+DauPoint/Summary两个@ObjectType,理解了@Query/@Args/@Field装饰器和 schema 的对应关系。 - 在
AnalyticsService里把 MySQL 计数(TypeORM Repository)和 Mongo DAU(BehaviorLogService聚合管道)聚合成一个 GraphQL 查询,吃透了「多源持久化在一个 API 后面统一暴露」。 - 复用同一个
JwtAuthGuard盖住 REST 和 GraphQL——GqlExecutionContext把 GraphQL 上下文转回 express req,鉴权逻辑零改动。 - 在 Apollo Sandbox 里写了真实查询,体会到「客户端挑字段、一个查询拿多源」的收益,也清楚了 GraphQL 不是 REST 的替代品而是补充——learnhub 只在看板这种聚合多源、视图演进的场景上用它。
常见问题
- 启动报 “Cannot find GraphQL module”:装依赖。
npm i @nestjs/graphql @nestjs/apollo graphql @apollo/server,再在app.module.ts里driver: ApolloDriver。learnhub 的package.json已经封好。 - Apollo Sandbox 打不开:要
GET /api/graphql才是 Sandbox UI,POST /api/graphql才是真发查询。生产环境建议关掉 Sandbox——通过introspection: false关掉 schema 自省,防止外人扫接口。 - N+1:列表型 Query(返回
[Post]然后每条都带author字段)每条都会触发一次 author 解析 → 100 条帖子 = 100 次查 user。解法是 dataloader——按批 + 缓存。learnhub 的 analytics 没踩这个坑(返回的是聚合后的数字,不是关联列表),但写复杂 resolver 时要警惕。 - code-first 生成的 schema 改了不生效:schema 在每次启动时重新生成,不要手改任何
schema.graphql文件(learnhub 用autoSchemaFile: true压根没落盘)。改字段去改 TS 装饰器,重启服务。 - 鉴权拿不到 token:GraphQL 请求的 token 走 HTTP header(
Authorization: Bearer xxx),不是 query 参数。Apollo Sandbox 在顶部 Headers 配。如果守卫抛 401,先看JwtAuthGuard.getRequest是不是走了 GraphQL 分支(context.getType()在 GraphQL 请求里返回的不是'http',得用GqlExecutionContext)。 - GraphQL 适合所有接口吗:不适合。简单资源 CRUD(帖子增删改查)上 GraphQL 是自找麻烦——query string 解析、schema 学习成本、N+1 监控都白加。learnhub 的原则:简单 REST、灵活 GraphQL。
- 找 Nginx 反代内容去哪了:这章原来是 Nginx,已经按课程结构调整换成了 GraphQL。Nginx 网关 + Docker Compose 的完整反代配置在第二十三章 wrap-up。
下一章是实战串烧——把前面学的 JWT 登录 + TypeORM CRUD + 参数校验串成一个完整小项目:注册登录后能增删改查自己的文章,把前十六章的零件装成一辆能跑的车。