《GraphQL 与 gRPC 客户端:类型安全 API 与代码生成》

系统梳理 Flutter 的类型安全 API 客户端:graphql_flutter 与 ferry 的查询、变更与订阅,build_runner 代码生成流程,grpc 与 protobuf 的客户端接入、流式调用与拦截器,以及缓存、重试、错误映射与常见踩坑清单。

开篇:接口字段改了,客户端却没人发现

后端把一个字段从 nickname 改名成 nick_name,REST 接口的响应里少了这个字段,Flutter 端直到用户反馈头像旁空白才发现。这类问题在字符串拼装的 REST 客户端里几乎无法避免:字段名只存在于字符串中,编译器完全帮不上忙。

GraphQL 与 gRPC 都能通过 schema 或 proto 文件在编译期生成类型安全的客户端代码,把"字段名写错"从运行时错误变成编译错误。代价是引入代码生成流程与构建复杂度。Flutter 侧的常见组合是:GraphQL 用 graphql_flutter 或 ferry,gRPC 用 grpc 加 protobuf,代码生成统一交给 build_runner。本文沿着选型、GraphQL 接入、代码生成、gRPC 接入、缓存与流式、性能踩坑这条链路展开。


一、API 客户端方案全景与选型

1.1 REST、GraphQL 与 gRPC 的取舍

维度RESTGraphQLgRPC
传输格式JSONJSONProtobuf 二进制
类型安全靠手写模型靠代码生成靠代码生成
请求粒度端点固定客户端声明字段方法固定
流式支持需 SSE 或 WebSocketSubscription原生四种流
浏览器友好好好需 gRPC-Web
典型场景通用接口聚合多源数据内部服务、实时

1.2 依赖与代码生成

# pubspec.yaml
dependencies:
  graphql_flutter: ^5.1.2
  ferry: ^0.16.1
  grpc: ^4.0.1
  protobuf: ^3.1.0
  fixnum: ^1.1.0

dev_dependencies:
  build_runner: ^2.4.12
  ferry_generator: ^0.12.0
  protoc_plugin: ^21.1.2

1.3 选型的核心问题

  • 后端是否已经提供 GraphQL 或 gRPC?没有的话,先评估改造成本。
  • 是否需要实时流?需要则 gRPC 的流式或 GraphQL 的 Subscription 更自然。
  • 团队能否接受代码生成进入构建流程?不能接受就退回手写 REST 客户端。

一句话总结:类型安全不是免费的,它的代价是 schema 与代码生成的流程约束,先确认团队愿意接受这份约束。


二、GraphQL 客户端接入

2.1 初始化客户端

final httpLink = HttpLink('https://api.example.com/graphql');

final authLink = AuthLink(
  getToken: () async => 'Bearer $accessToken',
);

final link = authLink.concat(httpLink);

final client = GraphQLClient(
  link: link,
  cache: GraphQLCache(store: InMemoryStore()),
);

在 Widget 树中通过 GraphQLProvider 注入客户端,下游用 Query、Mutation、Subscription 三个 Widget 消费。

GraphQLProvider(
  client: client,
  child: const MyApp(),
)

2.2 查询与变更

const fetchUser = gql(r'''
  query FetchUser($id: ID!) {
    user(id: $id) {
      id
      nickname
      avatarUrl
    }
  }
''');

Query(
  options: QueryOptions(
    document: fetchUser,
    variables: const {'id': '1024'},
  ),
  builder: (result, {fetchMore, refetch}) {
    if (result.isLoading) return const CircularProgressIndicator();
    if (result.hasException) return Text(result.exception.toString());
    final user = result.data?['user'];
    return Text(user?['nickname'] ?? '');
  },
)

2.3 订阅

const onMessage = gql(r'''
  subscription OnMessage($roomId: ID!) {
    messageAdded(roomId: $roomId) {
      id
      content
    }
  }
''');

Subscription(
  options: SubscriptionOptions(
    document: onMessage,
    variables: const {'roomId': 'r1'},
  ),
  builder: (result) {
    if (result.isLoading) return const Text('连接中');
    return Text(result.data?['messageAdded']?['content'] ?? '');
  },
)

2.4 手写字符串的隐患

上面的写法虽然能跑,但 result.data?['user']?['nickname'] 依然是字符串取值,类型安全没有真正落地。要获得编译期检查,必须引入代码生成。

一句话总结:graphql_flutter 解决了请求与缓存,但要真正类型安全,还得靠 ferry 这类带代码生成的客户端。


三、代码生成与类型安全

3.1 用 ferry 生成类型化客户端

ferry 从 .graphql 文件与 schema 生成 Dart 类,查询返回的是强类型对象而非 Map。

# lib/graphql/fetch_user.graphql
query FetchUser($id: ID!) {
  user(id: $id) {
    id
    nickname
  }
}
# build.yaml
targets:
  $default:
    builders:
      ferry_generator|graphql_builder:
        options:
          schema: myapp|lib/graphql/schema.graphql
      ferry_generator|serializer_builder:
        options:
          schema: myapp|lib/graphql/schema.graphql

3.2 运行代码生成

# 生成 GraphQL 客户端代码
dart run build_runner build --delete-conflicting-outputs

# 开发期监听文件变化持续生成
dart run build_runner watch --delete-conflicting-outputs

3.3 使用生成的客户端

final req = GFetchUserReq((b) => b..vars.id = '1024');
final response = await client.request(req).first;
final user = response.data?.user;
debugPrint(user?.nickname ?? '');

此时 user?.nickname 是编译期可检查的字段,后端改名会在重新生成后立刻报编译错误。

3.4 代码生成的工程约束

  • 生成的 .g.dart 文件要提交或统一在 CI 生成,团队内必须一致。
  • schema 变更后必须重新生成,建议在 CI 中加一步校验生成结果是否有未提交差异。
  • 生成的代码不要手工修改,任何改动都会在下次生成时被覆盖。

一句话总结:代码生成把接口契约变成了编译期约束,代价是 schema 与生成物必须纳入版本管理与 CI 校验。


四、gRPC 客户端接入

4.1 proto 定义与生成

// protos/user.proto
syntax = "proto3";

package user;

message UserRequest {
  string id = 1;
}

message UserReply {
  string id = 1;
  string nickname = 2;
}

service UserService {
  rpc GetUser(UserRequest) returns (UserReply);
  rpc WatchUsers(UserRequest) returns (stream UserReply);
}
# 生成 Dart 代码
protoc --dart_out=grpc:lib/src/generated \
  -Iprotos protos/user.proto

生成后会得到 user.pb.dart、user.pbgrpc.dart 等文件,包含消息类与客户端桩代码。

4.2 建立连接与调用

final channel = ClientChannel(
  'api.example.com',
  port: 443,
  options: const ChannelOptions(credentials: ChannelCredentials.secure()),
);

final stub = UserServiceClient(channel);

final reply = await stub.getUser(UserRequest(id: '1024'));
debugPrint(reply.nickname);

await channel.shutdown(); // 用完后关闭连接

4.3 流式调用

gRPC 支持四种调用模式,Flutter 端最常用的是服务端流(实时推送)与双向流(聊天)。

final stream = stub.watchUsers(UserRequest(id: '1024'));
await for (final reply in stream) {
  debugPrint('收到更新:${reply.nickname}');
}
模式请求响应典型场景
一元单个单个常规查询
服务端流单个多个实时推送
客户端流多个单个批量上传
双向流多个多个聊天、协作

4.4 元数据与拦截器

认证令牌通过 CallOptions 的 metadata 传递,重试、日志、超时则通过拦截器统一处理。

final options = CallOptions(
  metadata: {'authorization': 'Bearer $token'},
  timeout: const Duration(seconds: 10),
);

final reply = await stub.getUser(UserRequest(id: '1024'), options: options);

一句话总结:gRPC 的类型安全来自 proto 生成,实时能力来自流式调用,两者结合是内部服务通信的最优解。


五、缓存、重试与流式

5.1 GraphQL 缓存策略

GraphQL 的缓存分三层:内存归一化缓存(按对象 ID 存储)、网络缓存(HTTP 层)、持久化缓存(落盘)。归一化缓存能让"改了用户昵称,所有引用该用户的界面同步更新"。

缓存层作用范围失效时机
内存归一化单次会话应用重启
HTTP 缓存单次会话响应过期
持久化缓存跨启动手动清除

5.2 重试与退避

网络请求必须带重试,但要区分可重试与不可重试:连接超时、5xx 可重试;参数错误、401 不可重试。重试要使用指数退避并加抖动,避免雪崩。

Future<T> retry<T>(Future<T> Function() task, {int maxAttempts = 3}) async {
  var attempt = 0;
  while (true) {
    try {
      return await task();
    } catch (e) {
      attempt++;
      if (attempt >= maxAttempts) rethrow;
      final delay = Duration(milliseconds: 200 * (1 << attempt));
      await Future.delayed(delay);
    }
  }
}

5.3 连接生命周期与断线重连

gRPC 长连接会因网络切换、后台挂起而断开。应用回到前台时应重建 channel,流式订阅要能自动重连并补齐断线期间的数据。

5.4 错误映射

把传输层错误统一映射成业务可处理的类型,避免 UI 层到处判断 GrpcError 或 OperationException。

一句话总结:缓存决定"数据是否新鲜",重试决定"网络抖动是否致命",两者都要有明确的失效与退避规则。


六、性能与踩坑清单

6.1 常见踩坑清单

  • 每次请求都新建 GraphQLClient:缓存完全失效,应全局单例。
  • 每次调用都新建 gRPC channel:连接无法复用,开销巨大。
  • 忘记 channel.shutdown():连接泄漏,长时运行后耗尽资源。
  • 生成的代码手工修改:下次生成被覆盖,行为诡异。
  • schema 变更后未重新生成:编译期检查形同虚设。
  • 重试不区分错误类型:401 无限重试,触发风控。
  • 订阅未在页面销毁时取消:回调触发到已卸载的 Widget。
  • protobuf 生成代码未提交:CI 环境缺少依赖导致构建失败。

6.2 调试手段

# 校验 proto 是否能正常编译
protoc --descriptor_set_out=/dev/null -Iprotos protos/user.proto

# 查看 GraphQL schema 与本地 schema 是否一致
dart run build_runner build --delete-conflicting-outputs

6.3 构建体积与启动开销

代码生成会引入 protobuf、grpc 等依赖,包体积与启动时的类加载都会增加。如果只用到少量接口,可评估是否值得引入完整 gRPC 栈,或改用 gRPC-Web 或轻量 HTTP 加手写模型的折中方案。

一句话总结:类型安全客户端的性能问题集中在"连接与客户端是否复用"以及"生成物是否纳入构建流程"两点上。


FAQ

常见问题:GraphQL 客户端该选 graphql_flutter 还是 ferry?

答:需要快速上手、查询简单时用 graphql_flutter;需要真正的类型安全、复杂缓存策略、代码生成时用 ferry。两者可以共存,但建议统一,避免缓存与状态管理互相打架。

常见问题:代码生成的文件要提交到版本库吗?

答:两种做法都可行,但必须团队统一。提交的好处是 CI 不需要额外生成步骤、新成员拉代码即可编译;不提交则要在 CI 中固定生成步骤并校验无差异。混合做法最容易出问题。

常见问题:gRPC 在 Flutter Web 上能用吗?

答:浏览器不支持原生 gRPC,需要用 gRPC-Web 加代理。移动端可以直接使用原生 gRPC。若同时要支持 Web 与移动端,需评估是否统一改用 gRPC-Web。

常见问题:为什么 GraphQL 改了数据界面没刷新?

答:多半是归一化缓存命中导致的。缓存按对象 ID 存储,若变更后没有更新对应对象,界面仍读旧值。可通过 refetch、手动写缓存或调整缓存的 update 策略解决。

常见问题:gRPC 长连接经常断开怎么办?

答:网络切换、应用进入后台、服务端空闲回收都会断开长连接。应在应用回到前台时重建 channel,并为流式订阅实现自动重连与断线补偿,不要假设连接永远在线。

常见问题:重试会不会导致重复下单之类的副作用?

答:会。对非幂等的写操作重试必须谨慎,正确做法是服务端提供幂等键,客户端重试时携带同一幂等键,由服务端保证只生效一次。

常见问题:如何保证客户端与后端的接口契约同步?

答:把 schema 或 proto 文件作为唯一契约来源,纳入版本管理,并在 CI 中校验客户端生成结果与契约一致。契约变更时先改 schema,再重新生成客户端,让编译错误暴露所有受影响的位置。


相关阅读

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「Flutter」更多文章

  1. 《图表与数据可视化:fl_chart、自绘图表与实时数据展示》
  2. 《支付与内购:应用内购买、订阅与第三方支付》
  3. 《地图与定位服务:地图 SDK、定位与地理围栏》