
我最早强烈意识到需要Any这个类型是在做一个统一告警平台的 message 定义时。不同的告警源有的上报 CPU 使用率有的是磁盘 IO还有的是自定义的业务事件结构完全不一样。如果为每种告警单独建一个 message网关层就要为每种类型写一套透传逻辑接口签名也会膨胀到没法看。如果把它们都塞进bytes消费端又失去了类型信息只能靠额外字段约定 这里面是什么解析前还得先查表。就是这种既要结构化、又要保持开放的矛盾让google.protobuf.Any成了 ProtoBuf 生态里一个绕不开的类型。这篇就围绕它的语法、原理、实战和坑位展开聊的都是我在项目里验证过的东西。1. 为什么需要 Any静态类型世界的万能口袋1.1 静态类型 protobuf 的天然困境ProtoBuf 本质上是强类型、编译期的序列化协议这也是它的优点双方约定好 schema生成的代码能在编译期检查字段序列化/反序列化速度快、体积小。但强类型的另一面就是不够开放。我举个例子。你有一个MessageEnvelope想在里面放任意负载最直觉的方案是加一个bytes payload 1;。可这样一来payload里面到底是 HTTP 响应还是日志事件接收方完全不知道。你可能会说再加一个string type 2;标记类型不就行了行但这实际上是伪类型——消费者拿到type之后还是要自己维护映射表去 switch 分支再手动解析 bytes。如果新加一种 payload 类型发布新版本时旧消费者就完全没法读这种新增内容兼容性也不好做。另外还有一种常见想法是把所有可能出现的结构都枚举出来做成一个大 union例如message Envelope { oneof payload { CpuReport cpu 1; DiskReport disk 2; Heartbeat hb 3; } }oneof 更适合同一语义下有限可选的情况比如id或者name二选一。可一旦可能的结构不是固定的、需要不断新增扩展每次改动都要改共享的.proto文件对跨团队、跨版本协作来说非常痛苦。1.2 现有替代方案的核心局限我把当时调研过的几种伪 Any方案整理一下bytes 自维护类型枚举需要每个人自觉遵循映射表消费端解析前先查表。一旦某个服务没有同步到这个表它读到的就是一堆没法解释的二进制排障成本极高。mapstring, bytes本质上是上面方案的升级版类型信息变成了 key但 key 的约定依然是散装的没有编译期约束不同服务可能叫order也可能叫order_info。oneof结构上最严谨但要求所有分支在同一个 proto 文件中定义这等于把开放系统写死成封闭系统无法支持跨仓库扩展。等价的 JSON/动态格式把payload序列化成 JSON 字符串塞进bytes可读性确实好了但既然用了 ProtoBuf往往就是为了性能和强类型校验退回 JSON 属于倒退。这些方案的共同问题是没有把类型是什么和数据是什么这两件事放在同一个结构化框架中去传输。而Any的设计目标恰恰就是解决把类型标识和数据内容绑定在一起传输这个需求。1.3 Any 的定位与核心价值Any的核心价值一句话就能说清楚它允许你在不知道具体类型的情况下传输一条完整消息并且把这条消息的类型标识和字节内容打包在一起。接收端可以根据类型标识要么直接解包成已知类型要么原样转发给能处理它的下游。这相当于给 Protobuf 世界引入了一种受控的动态类型能力同时又不破坏静态类型的骨架。打个比方普通 message 是一个固定格式的表格每个字段都是提前印好的格子而Any是一个信封信封上写着里面装的是哪种表格信封里装着真正的那张表。你可以不拆开信封就把整个东西转发走到了有能力拆开它的人手里才拆。这样一来通用网关、事件总线、任务调度这类只负责搬运、不负责理解的中间层就不必绑定所有业务 schema 了。2. Any 的语法定义与核心使用套路2.1 proto 层面的声明想用Any第一步是在.proto文件里导入any.protosyntax proto3; import google/protobuf/any.proto; message Wrapper { string request_id 1; google.protobuf.Any payload 2; }看起来就是普通字段声明对不对但它背后其实是一条内建 message在 protobuf 的源码里它的定义是message Any { string type_url 1; bytes value 2; }所以你在传输层看到的二进制里它就是一个type_url字符串加一个value字节串非常朴素。生成的代码也不会让你直接操作这两个字段而是封装成了更高层的 API比如PackFrom()、UnpackTo()、IsT()。需要特别注意的是google/protobuf/any.proto在各类语言的 runtime 库里都已经自带不需要额外维护但你 import 的时候路径要写对google/protobuf/any.proto中间是斜杠不是点。2.2 type_url 的格式约定type_url是 Any 的灵魂它不是一个随便写的字符串。protobuf 对它有格式约定必须是[type.googleapis.com]/package.name.MessageName其中type.googleapis.com是默认的 server 前缀可以省略但斜杠/不能少。比如你在package order.v1;下定义了OrderCreated那对应的 type_url 就是type.googleapis.com/order.v1.OrderCreated很多人第一次写 Any 时手滑把前缀写成了type.googleapis.com/order.v1.OrderCreated之外的自定义字符串比如my.company.com/OrderCreated这在多数语言的UnpackTo里也能处理——只要前缀和斜杠后的部分能匹配消息的全限定名。但一旦服务端约定统一用type.googleapis.com自定义前缀可能会带来互操作问题。我习惯一律用标准前缀避免后续接第三方系统时出幺蛾子。2.3 最常用的打包解包 API下面是最常用的一段 C 代码我直接贴出来包含 include 的头文件路径方便对照#include google/protobuf/any.h #include wrapper.pb.h order::v1::OrderCreated order; order.set_order_id(10086); order.set_amount(299); Wrapper wrapper; wrapper.set_request_id(req-1); wrapper.mutable_payload()-PackFrom(order); // 另一端 Wrapper received; received.ParseFromString(bytes); if (received.payload().Isorder::v1::OrderCreated()) { order::v1::OrderCreated parsed; received.payload().UnpackTo(parsed); // 使用 parsed }Python 端更直白因为 Python 的反射模型比较方便from google.protobuf import any_pb2 import order_v1_pb2 wrapper order_v1_pb2.Wrapper() wrapper.request_id req-1 wrapper.payload.Pack(order_v1_pb2.OrderCreated(order_id10086, amount299)) # 另一端 if wrapper.payload.Unpack(wrapper_pb2.Wrapper.payload) or wrapper.payload.Is(order_v1_pb2.OrderCreated.DESCRIPTOR): ...本质上Pack就是把类型信息填充到type_url把序列化后的消息填到valueUnpack则反过来。在很多语言的实现里UnpackTo是会检查 type_url 是否匹配的如果不匹配会返回 false 或者抛异常——这也是为什么 type_url 的纪律如此重要。2.4 Any 与 message 嵌套的使用细节Any 字段可以嵌套出现在任意 message 内部包括 map 的 value、repeated 列表等等比如这样message Record { mapstring, google.protobuf.Any attributes 1; repeated google.protobuf.Any events 2; }这在业务上很有用attributes里可以用不同的 key 携带不同类型的数据events则可以保存一组不同结构的事件。但要注意嵌套使用会让到底谁负责解包这个问题更复杂——外层消息自己不管内层 Any 的类型只有真正要消费内层数据的服务才需要关心。这本身就是 Any 的设计意图把理解推迟到最后一站。3. Any 在实战中的典型应用场景3.1 gRPC 泛化调用与动态请求转发这是我最早落地 Any 的地方。我们内部有一个统一的 RPC 网关它接收外部的请求然后把请求体转发给后端的多个业务服务。如果用强类型定义网关必须知道所有服务的请求结构等于把几十个 proto 仓库全部依赖一遍这显然是不现实的。于是我们把网关和业务服务之间的请求体定义为google.protobuf.Anymessage RpcRequest { string target_service 1; string method_name 2; google.protobuf.Any request_body 3; mapstring, string metadata 4; }网关只需要解析三层信息目标服务、方法名和request_body中的 type_url。它甚至可以做一个动态路由读取type_url里的 message full name然后根据后缀比如OrderCreated、PaymentCompleted直接把整个Any原样转发给对应的 handler。真正生成 request 并处理 response 的业务模块才需要知道内部消息长什么样。这里有个关键收益当后端某个服务新增了一个请求结构时网关代码一行都不用改因为它的 schema 里永远只出现Any这一个字段类型。3.2 事件总线里的载荷建模如果你在做事件驱动架构比如用 Kafka 或 Pulsar 做事件总线你大概率会遇到这个问题Topic 是通用的比如user-events但事件的具体内容五花八门有UserRegistered、有UserLoggedIn、有PasswordReset。把每种事件的字段都写进同一个UserEventmessage会形成一个几百行的巨型 message而且随着需求迭代不断膨胀。更优雅的是外层只定义通用的元信息和Anymessage Envelope { string event_id 1; int64 timestamp 2; string source 3; google.protobuf.Any event_detail 4; }消费者订阅Envelope后先看event_detail.type_url再按需解包成自己关心的具体事件。不关心的事件它完全可以跳过解析这种选择性理解在消息量大的场景下能节省不少 CPU。3.3 配置中心与运行时特征扩展配置下发是 Any 的另一个舒适区。不同业务的配置结构天然不同数据库连接池的配置、限流策略的配置、推荐算法的参数配置共享一个 ConfigItem 是反人类的。用 Any 的话你可以这样设计message ConfigItem { string config_name 1; string version 2; google.protobuf.Any spec 3; }配置中心只负责存储、校验版本号和下发具体 spec 里的内容由各业务线自己的解析模块去UnpackTo。这样配置中心就不会被业务 schema 绑架新增一个配置类型也不需要重启配置中心。3.4 跨服务 A/B 实验与灰度参数携带做 A/B 实验时往往希望请求带上实验分组信息同时该分组可能需要附加不同的参数。这些参数的类型取决于实验本身可能是字符串、数字也可能是复杂的结构化对象。用一个mapstring, google.protobuf.Any experiment_params就可以解决key 是实验名value 是任意实验参数。框架层完全不用知道每个实验具体在改什么。这一类场景的共同特点是框架统一、业务封闭。Any 把框架和业务解耦得非常好框架代码只依赖any.proto业务代码自己负责自己的解析。4. 揭开 Any 内部工作原理type_url 是灵魂4.1 Any 的二进制布局要真正会用 Any还是得知道它底层在传输上长什么样。Any的两个字段type_url会被编码成 field 1wire type 2即 length-delimitedvalue会被编码成 field 2wire type 2。所以整个 Any 的二进制就是两个 LEN 类型的字段field 1: type_url - type.googleapis.com/order.v1.OrderCreated field 2: value - OrderCreated 序列化后的 bytes在 Wrapper 里这个 Any 字段又作为 field 2 的 LEN 字段被编码进去。所以整个 Wrapper 的 wire format 是层层嵌套的 LEN任何标准的 protobuf decoder 都能通过字段号解析出这个嵌套结构。4.2 type_url 的生成规则全限定名是关键PackFrom的时候各家语言的实现都会调用消息的 descriptor 获取 full name例如order.v1.OrderCreated然后拼上默认前缀type.googleapis.com/。这个 full name 必须和消息定义时的package message 名完全一致。有一种情况容易踩坑嵌套 message 的 full name。比如package order.v1; message Outter { message Inner { string id 1; } Inner inner 1; }这个Inner的 full name 是order.v1.Outter.Inner注意中间是点号type_url 会是type.googleapis.com/order.v1.Outter.Inner。很多人以为它是order.v1.Inner,这就是解包失败的常见原因。4.3 解包时的动态类型查找机制UnpackTo的实现原理我以 C 为例简单说一下。你需要看一下MessageFactory和DescriptorPool的关系。UnpackTo会先从type_url中提取出 message 的 full name然后在一个DescriptorPool中查找对应的Descriptor消息描述符。找到之后MessageFactory会根据 descriptor 创建一条空的动态消息实例再把Any.value里的 bytes 反序列化进去最后把内容拷贝到你传入的目标消息指针里。这里有一个很容易忽视的点如果某个 message 类型没有被当前程序的编译单元注册到默认 pool 里那么UnpackTo是找不到对应类型的。举个实际例子你的二进制用了动态加载.so的方式注册额外消息或者用了运行时生成的 descriptor 来解析某种请求这些消息可能不在默认 pool。这时候UnpackTo就会失败即使 type_url 看起来完全正确。这个现象在 Java 和 Go 里也存在只是表现形式略有差异。4.4 性能隐藏成本反射和动态分配很多人以为 Any 只是加了一个字符串成本可以忽略。实际上UnpackTo的内部机制涉及 descriptor 查找、动态消息分配、bytes 反序列化最后还要拷贝到目标对象这中间的反射开销和内存分配比直接解析静态类型字段多不少。如果一条消息嵌入多层 AnyAny 里面套 Any甚至套两层解包成本是叠加的。基准测试里解包一次 Any 字段大概比直接解析同体积静态字段要慢一个数量级。这不是 Any 的问题是动态分派必然的代价——任何语言的 dynamic_cast 或反射都要付这个成本。所以我的建议是高频主链路尽量用静态类型别为了看起来通用滥用 AnyAny 用在外围、低频、扩展点如果实在跑在热路径上考虑在应用层做type_url - 静态解析函数的缓存跳过每次反射。4.5 Any 与 proto3 的 JSON 序列化联动这里也顺带提一个很容易遇到的现象把包含 Any 的 message 转成 JSON 时Any 会变成两层结构外层是type这个特殊字段值为 type_url内层才是具体消息的 JSON 字段。比如{ request_id: req-1, payload: { type: type.googleapis.com/order.v1.OrderCreated, order_id: 10086, amount: 299 } }很多通用的 JSON 解析器不认识type所以如果你走 JSON 网关需要提前约定如何处理这个字段。protobuf 官方 json_pb2 能处理第三方库不一定。5. 使用 Any 最容易踩的坑5.1 import 缺失导致编译报错这是在项目规范化初期最常见的问题。google.protobuf.Any并不是默认就能用的类型它来自google/protobuf/any.proto。如果你忘记 import编译的时候会报类似google.protobuf.Any is not defined的错误。修复很简单import google/protobuf/any.proto;不同语言的编译检查通过后生成的代码里会自动带上 Any 的依赖比如 C 会生成google/protobuf/any.pb.h的 include。5.2 解包失败的类型不匹配问题我统计了一下自己项目里解包失败的案例几乎都是以下几个原因type_url 里的 full name 写错比如包名漏了或者 message 名拼错。Full name 是大小写敏感的order.v1.ordcreated就不可能匹配。嵌套 message 的 full name 记错前面说的Outter.Inner问题很隐蔽。消费者编译的程序没有注册对应类型这在动态加载场景最常见解决方案是显式地在程序启动时调用一次对应生成的.proto的注册函数比如 C 里的AddDescriptors()。字符串在传输过程中被截断或篡改任何反射查找都是严格匹配多一个空格都不行。排查办法其实很简单打印一下收到消息里payload.type_url()的值比对一下你期望的字符串。80% 的问题用这一招就能定位。5.3 Any 嵌套 Any 带来的爆炸性迷茫这是我在设计统一事件时踩过的深坑。设想你有一个Envelope里面是Any类型的event_detail。正常人会想event_detail里先 Pack 一个EventMeta这个EventMeta又定义了一个google.protobuf.Any content。这样做的结果是Envelope └── event_detail (Any) └── EventMeta └── content (Any) └── OrderCreated解包OrderCreated时你得先UnpackTo(EventMeta)再取里面的content再UnpackTo(OrderCreated)。任何一环没实现或者 type_url 不对整个链路就断了。而且这种嵌套没有任何编译器能帮你检查出了问题只能靠运行日志一层层找。我的建议是Any 嵌套最多一层。外层统一用Any内层直接是具体业务 message不要再套。如果确实需要消息里既有公共字段又有具体载荷把公共字段放在外层比如 request_id 放 Envelope而不是放进被 Pack 的 message 里。5.4 版本兼容type_url 不变、schema 演化Any并不是版本兼容的万能药。它的type_url指向的是消息的 full name而不是版本号。如果你有一个order.v1.OrderCreatedv2 改成了order.v2.OrderCreated这俩的 type_url 完全不同消费者也需要同时注册两种类型。如果你只升级 schema 不升级 package 名比如还在order.v1包下但字段从amount改成了total_amount那 type_url 不变但老消费者解析新消息时会忽略未知字段新消费者解析老消息时可能缺少必填字段proto3 下不存在必填字段但语义上可能出错。所以如果你的系统对消息的兼容性要求很高我建议在 message name 或 package 名上显式体现版本例如order.v2.*。这对 Any 场景尤其重要因为编译器没法帮你做类型检查版本全靠命名约定来保证。5.5 过度使用 Any 对可读性和工具链的影响这一点不算 bug但比 bug 更致命。当你到处使用 Any 时比如把一个请求的所有字段都塞进 Any你的 proto 文件看起来高度抽象但具体数据结构完全不可见。代码审查者没法一眼看出请求里有什么字段IDE 自动补全也失效了API 文档也丧失了意义。protobuf 的静态类型优势被完全放弃。工具链方面很多反射工具、字段校验工具、mock 工具都是基于具体 descriptor 的遇到 Any 只能显示type_url无法自动展开。像grpcurl这种命令行工具在反射 gRPC 服务时如果你用 Any 打包请求它没法对你做出正确的参数提示。这些都会显著降低开发效率。^\d$所以在设计接口时我给自己定了一个原则能用 oneof 表达的就不要上 Any。只有当类型集合真正开放、不可枚举时才轮到 Any 上场。这和能静态就别动态是一个道理。^\d$注意最后这个符号是我误打的忽略。6. 什么时候不推荐用 Any动态方案的选型边界6.1 Any 与 oneof 的对比我在文章开头提到过 oneof这里展开讲一下选型决策。维度oneofAny类型范围编译期固定写入同一个 proto任意消息类型运行时确定类型安全编译器保证type_url 字符串约定运行时才能发现错误可扩展性改 proto 文件重新部署新类型无需改外层 schema反射/文档完全支持工具链支持度参差不齐性能静态解析开销低反射解包开销高如果一个接口的可选类型在可见的未来内是有限的、可枚举的比如请求必须从三种登录方式中选一种那我一定写 oneof。只有当类型集合不可预知时比如我希望第三方开发者往我的系统里注入自定义类型的消息才考虑 Any。网上经常有人讨论std::variant和std::any的区别其实和这里 oneof vs Any 的取舍逻辑几乎一样编译期封闭好还是运行期开放好没有绝对答案取决于你是有限集合还是无限集合。6.2 Any 与 mapstring, bytes 的对比map 方案虽然有伪类型的问题但它有一个优点你可以为每个 key 动态加后缀来表达语义比如order_v1、order_v2。这让老版本消费者可以跳过不认识的 key新版本消费者也可以按需读取多个 key。Any 则只有一个 type_url想表达这个字段既可能是 v1 的订单也可能是 v2 的订单就得做成 repeated Any 或者用字段名区分。所以如果需要同一 key 多版本并存这类复杂映射语义map 反而更灵活。6.3 Any 与泛型/动态类型的跨语言对比如果你是 C 背景会天然觉得Any类似std::any但千万别把两者混同。std::any存的是内存对象只能单机内使用而 protobuf 的Any是跨语言的线缆格式它能跨服务、跨语言、跨版本传输。如果你需要的是进程内的万能容器那std::variant、std::any是更好的选择如果是消息传输那才轮到 proto 的Any。这种场景差异也解释了为什么我不是很推荐用 Any 模拟动态类型然后又把解析逻辑做成动态分发的做法系统一旦这么做接口的契约就从结构清晰的 protobuf退化成所有字段藏在一个黑盒里测试和排障的成本会指数上升。6.4 一个可复用的选型检查清单我给自己总结了一个清单每次设计新接口都拿它来判断要不要引入 Any接口的消费方是否真的不知道未来会出现哪些结构知道就别用这些结构是否都适合用 protobuf 来定义有些是外部 JSON可以考虑 string是否会出现在极端热点链路上是慎用考虑加缓存或静态映射是否需要一个中间层原样转发不解析内部内容是这是 Any 最理想的环境是否对 IDE、文档、工具链有很高要求有就要承受 Any 带来的可读性下降如果这五个问下来回答几乎全是是那 Any 就是合理的。否则优先考虑其他方案。7. 一个完整的动态消息分发 Demo光讲理论不够我放一个完整的示例覆盖从 proto 定义到生产端、消费端全流程。这个 Demo 模拟的是事件总线分发中心的最简化版本。7.1 定义 proto 文件syntax proto3; package demo.v1; import google/protobuf/any.proto; // 事件封装 message EventEnvelope { string event_id 1; int64 timestamp 2; google.protobuf.Any payload 3; } // 用户注册事件 message UserRegistered { string user_id 1; string email 2; } // 订单创建事件 message OrderCreated { string order_id 1; double amount 2; }编译命令Linux/macOS 下protoc --cpp_out. --python_out. demo.proto7.2 生产端把具体消息打包进 AnyPython 演示import time import demo_pb2 from google.protobuf import any_pb2 env demo_pb2.EventEnvelope() env.event_id evt-001 env.timestamp int(time.time()) order demo_pb2.OrderCreated(order_idA1001, amount99.9) env.payload.Pack(order) # 发到总线前查看一下 type_url 和二进制大小 print(type_url:, env.payload.type_url) print(value bytes len:, len(env.payload.value)) # 关键点发送端只关心 payload 是否打包成功不关心消费端如何解析 binary_data env.SerializeToString()打包后你会看到 type_url 是type.googleapis.com/demo.v1.OrderCreatedvalue 就是OrderCreated的序列化字节。发送端把binary_data发到 Kafka 或 gRPC 流里工作就结束了。7.3 消费端识别并解包C 演示#include iostream #include demo.pb.h void HandleEvent(const std::string raw) { demo::v1::EventEnvelope env; if (!env.ParseFromString(raw)) { std::cerr parse envelope failed std::endl; return; } std::cout event_id env.event_id() , type_url env.payload().type_url() std::endl; if (env.payload().Isdemo::v1::UserRegistered()) { demo::v1::UserRegistered user; if (env.payload().UnpackTo(user)) { std::cout user registered: user.user_id() , email user.email() std::endl; } } else if (env.payload().Isdemo::v1::OrderCreated()) { demo::v1::OrderCreated order; if (env.payload().UnpackTo(order)) { std::cout order created: order.order_id() , amount order.amount() std::endl; } } else { // 未知类型可以原样转发给下游或记录日志跳过 std::cout unknown type, skip payload std::endl; } }这个分支结构本质上是把动态类型重新收敛回静态处理我们通过IsT()判断 type_url匹配上了才静态解析。这也是 Any 项目里最常见的消费模式。7.4 运行验证与实测反馈我实际跑过这个 Demo在 Python 端打包C 端接收跨语言完全没有障碍。验证过程中有一个小插曲我第一次在 Python 端调用Pack()时没有 import 对应的demo_pb2模块结果 type_url 看起来是对的但value是空的。这是因为Pack()依赖目标对象的序列化能力模块没加载时即便 descriptor 能找到序列化结果也可能为空。这个现象在不同语言里表现不一样但排查思路一致先打印 type_url 和 value 的长度再来判断问题出在打包环节还是解包环节。我也顺手测了一下性能在循环里 Pack/Unpack 10 万次Python 和 C 各自的耗时大概比纯静态字段 message 多 2 到 5 倍。如果你在一个高吞吐的网关里频繁解包 Any请务必提前压测别等上了生产再被性能问题打脸。结尾说点我个人的体会。Any 这个类型看起来只是两个字段的语法糖但它真正改变的是系统之间接口边界的设计思路。我经手过的项目里凡是能把 Any 用对地方的都是外层框架非常稳定、内部细节千变万化的系统凡是把 Any 滥用成一坨黑盒的最后都栽在了可读性和排查效率上。最后分享一个小技巧如果你实在不想让消费端直接看到零散的 type_url 乱七八糟的字符串可以在业务层约定一个辅助函数统一根据 type_url 的后半段message full name做一次映射把已知的类型列表固化成常量。这样既能享受 Any 的开放性又能让代码里的分支逻辑清晰可控。Any 是个趁手的工具但它永远替代不了清晰的 schema 设计和良好的工程纪律。