Kratos 下使用 Protobuf FieldMask 完全指南

当我们使用 gRPC 进行跨服务通讯时,调用方往往只需要响应中的部分字段 —— 冗余字段不仅会增加网络传输成本,更可能触发不必要的下游依赖调用(比如为了返回一个非核心字段,需要额外调用 2 个服务)。​

在微服务场景中,这种「无效计算 + 无效传输」的开销会被放大:一次 RPC 级联 3~5 个下游是常态,而响应体中 60% 以上的字段可能都是调用方不需要的。​

此时,我们需要一种「字段按需筛选」机制:

  • GraphQL 用「字段选择器」实现​
  • JSON:API 用「稀疏字段集」实现​
  • 而 gRPC 生态中,Protobuf FieldMask 是标准且高效的解决方案。

一、核心认知:FieldMask 是什么?为什么必要?​

1.1 定义与核心价值​

Protobuf 的 FieldMask(定义在 google.protobuf.FieldMask 中)是一种「字段选择器」,本质是一个字符串列表,用于明确指定「需要返回 / 更新的字段」。其核心价值体现在四方面:​

价值维度 具体收益
计算成本优化 避免非必要字段的计算(如关联查询、复杂序列化、加密解密)
网络传输优化 减少响应包体积,跨服务 / 跨地域调用场景下收益尤为明显
依赖链解耦 无需为冗余字段依赖下游服务(如 A 服务无需依赖 B 服务的非核心字段逻辑)
接口灵活性提升 调用方自主选择所需字段,服务端无需频繁变更接口(减少版本迭代成本)

1.2 语法规则(必记!避坑关键)​

  • 字段名必须与 Protobuf 定义一致(使用下划线命名法,而非驼峰)​
  • 嵌套字段用 . 分隔(如 user.profile.avatar,对应嵌套消息结构)​
  • 通配符 * 表示「所有直接子字段」(不含嵌套字段,如 user.* 仅包含 user 的一级字段)​
  • 示例:field_mask: [“id”, “product.price”, “order.items.*”]​

1.3 微服务场景的量化收益​

业务场景 无效字段占比 延迟优化效果 带宽优化效果 下游 QPS 优化
商品详情页(APP 首屏) 71% P99 延迟 -35% 18 KB → 4.8 KB(-73%) 下游 QPS -40%
订单列表页(PC 端) 68% P99 延迟 -28% 12 KB → 3.7 KB(-69%) 下游 QPS -35%
用户中心基础信息查询 82% P99 延迟 -42% 23 KB → 3.9 KB(-83%) 下游 QPS -50%

核心原因: 减少了无效的下游调用、序列化开销,同时提升了缓存命中率(字段粒度缓存更易命中)。​

二、IDL 设计:规范定义 FieldMask(遵循 AIP-161 标准)​

IDL 设计是 FieldMask 落地的基础,必须遵循「查询用 field_mask、更新用 update_mask」的规范(对齐 Google AIP-161 标准),确保接口一致性和可维护性。​

2.1 依赖引入​

syntax = "proto3";​
package product.v1;

import "google/protobuf/field_mask.proto";

2.2 规范定义请求字段​

2.2.1 查询场景(Get/List):用 field_mask 指定返回字段​

查询接口中,field_mask 作为可选字段,允许调用方自主选择返回字段(未指定时返回核心字段):​

// 商品查询请求(单条)
message GetProductRequest {
  string id = 1; // 资源唯一标识
  // 字段选择器:指定需要返回的字段(如 ["id", "name", "price"])
  google.protobuf.FieldMask field_mask = 2;
}

// 商品查询响应
message GetProductResponse {
  message Product {
    string id = 1;        // 核心字段
    string name = 2;      // 核心字段
    string description = 3; // 非核心字段(长文本)
    double price = 4;     // 核心字段
    message Inventory {   // 嵌套字段(库存信息)
      int32 stock = 1;
      string warehouse = 2;
    }
    Inventory inventory = 5; // 非核心字段(需调用库存服务)
    repeated string tags = 6; // 重复字段
  }
  Product product = 1;
}
2.2.2 更新场景(Update):用 update_mask 指定更新字段​
// 商品更新请求
message UpdateProductRequest {
  string id = 1; // 资源唯一标识(推荐单独透出,而非嵌套在 data 中)
  Product data = 2; // 待更新的字段数据(仅填充需要更新的内容)
  // 字段选择器:明确指定需要更新的字段(如 ["price", "inventory.stock"])
  google.protobuf.FieldMask update_mask = 3; // 必填字段
}

// 商品更新响应
message UpdateProductResponse {
  bool success = 1;
  Product updated_product = 2; // 返回更新后的完整数据(或按需求返回指定字段)
}

2.4 IDL 设计最佳实践​

  1. 字段命名规范: 查询用 field_mask,更新用 update_mask,避免混淆(如 mask 这种模糊命名)。​
  2. 核心字段默认返回: 未指定 field_mask 时,服务端返回核心字段(如 id、name),避免返回空数据。​
  3. 嵌套字段合理拆分: 将「高开销字段」(如需要跨服务查询的字段)拆分为嵌套消息,便于单独筛选(如 inventory 字段)。​
  4. 避免过度拆分: 字段粒度不宜过细(如将 user.name 拆分为 user.first_name+user.last_name 是合理的,但拆分为单个字符则无意义)。​

三、Kratos 集成落地

查询场景:从 SQL 到响应的全链路字段筛选

核心优化:数据层(ent)只查询 FieldMask 指定的字段,服务层只返回指定字段,避免「查询冗余字段 + 响应裁剪」的无效开销。​

在查询当中,主要就是注入到SQL语句的SELECT参数,我为ent封装了几个方法:

// NormalizeFieldMaskPaths normalizes the paths in the given FieldMask to snake_case
func NormalizeFieldMaskPaths(fm *fieldmaskpb.FieldMask) {
	if fm == nil || len(fm.GetPaths()) == 0 {
		return
	}

	fm.Normalize()

	fm.Paths = NormalizePaths(fm.Paths)
}

func NormalizePaths(paths []string) []string {
	if len(paths) == 0 {
		return paths
	}

	for i, field := range paths {
		if field == "id_" || field == "_id" {
			field = "id"
		}
		paths[i] = stringcase.ToSnakeCase(field)
	}

	return paths
}

// BuildFieldSelect 构建字段选择
func BuildFieldSelect(s *sql.Selector, fields []string) {
	if len(fields) > 0 {
		fields = NormalizePaths(fields)
		s.Select(fields...)
	}
}

// BuildFieldSelector 构建字段选择器
func BuildFieldSelector(fields []string) (error, func(s *sql.Selector)) {
	if len(fields) > 0 {
		return nil, func(s *sql.Selector) {
			BuildFieldSelect(s, fields)
		}
	} else {
		return nil, nil
	}
}

// ApplyFieldMaskSelect 将 fieldmask 转换为 snake_case 并通过 apply 回调传入。
// - apply: 接受归一化字段并调用,例如: func(ps ...string) { builder.Select(ps...) }
// - mask: 传入的 FieldMask,nil 或 空时不做任何操作
func ApplyFieldMaskSelect(apply func(...string), mask *fieldmaskpb.FieldMask) {
	if apply == nil || mask == nil || len(mask.GetPaths()) == 0 {
		return
	}

	NormalizeFieldMaskPaths(mask)

	if len(mask.GetPaths()) > 0 {
		apply(mask.GetPaths()...)
	}
}

// ApplyFieldMaskToBuilder 接受一个带 Select(...string) 方法的 builder 和 FieldMask,
// 将 paths 归一化为 snake_case(并将 id_/_id 归为 id),然后调用 builder.Select(paths...) 并返回 builder。
// - R 是 Select 方法的返回类型(例如 *ent.UserSelect)
// - B 是拥有 Select(...string) R 方法的类型(例如 *ent.UserQuery)
// 返回 (R, bool): bool 表示是否实际调用了 Select(即 mask 非空)。
func ApplyFieldMaskToBuilder[R any, B interface{ Select(fields ...string) R }](builder B, mask *fieldmaskpb.FieldMask) (R, bool) {
	var zero R
	if mask == nil || len(mask.GetPaths()) == 0 {
		return zero, false
	}

	NormalizeFieldMaskPaths(mask)

	if len(mask.GetPaths()) == 0 {
		return zero, false
	}

	return builder.Select(mask.GetPaths()...), true
}

如果是列表查询,我们可以调用一个更高层级的方法BuildQuerySelector

import entgo "github.com/tx7do/go-utils/entgo/query"

builder := r.data.db.Client().User.Query()

err, whereSelectors, querySelectors := entgo.BuildQuerySelector(
  req.GetQuery(), req.GetOrQuery(),
  req.GetPage(), req.GetPageSize(), req.GetNoPaging(),
  req.GetOrderBy(), user.FieldCreatedAt,
  req.GetFieldMask().GetPaths(),
)

if querySelectors != nil {
  builder.Modify(querySelectors...)
}

如果是查询单个数据,则我们可以这样调用:

import entgo "github.com/tx7do/go-utils/entgo/query"

builder := r.data.db.Client().User.Query()

entgo.ApplyFieldMaskToBuilder(builder, req.ViewMask)

更新场景:安全更新 + NULL 字段处理

核心需求:仅更新 FieldMask 指定的字段,支持将字段设为 NULL(如清空描述),避免全量覆盖。​

更新需要做两步:

  1. 把不需要更新的字段过滤掉;
  2. 把需要更新为NULL的字段的SQL添加上。

过滤字段,我这里有封装一个工具集:

go get github.com/tx7do/go-utils/fieldmaskutil

调用fieldmaskutil.FilterByFieldMask方法:

import "github.com/tx7do/go-utils/fieldmaskutil"

if err := fieldmaskutil.FilterByFieldMask(trans.Ptr(proto.Message(req.GetData())), req.UpdateMask); err != nil {
  r.log.Errorf("invalid field mask [%v], error: %s", req.UpdateMask, err.Error())
  return userV1.ErrorBadRequest("invalid field mask")
}

在这里我们拿ent作为一个示例,同样的,对于ent的一些常规操作,我也封装了一个工具集:

go get github.com/tx7do/go-utils/entgo

直接在builder.Exec之前调用方法:

import entgoUpdate "github.com/tx7do/go-utils/entgo/update"

entgoUpdate.ApplyNilFieldMask(proto.Message(req.GetData()), req.UpdateMask, builder)

项目代码

参考资料

Logo

有“AI”的1024 = 2048,欢迎大家加入2048 AI社区

更多推荐