Objective-C 即时通信中的 Protobuf:从协议定义到 Socket 收发

在即时通信系统里,Socket 解决的是“字节如何在两端传输”,Protobuf 解决的是“这些字节代表什么”。两者经常一起出现,但职责完全不同。

本文以一个匿名化的 Objective-C 即时通信场景为例,完整说明以下问题:

  • 为什么选择 Protobuf,而不是直接传 JSON;
  • .protoprotoc 和 Objective-C Runtime 各自负责什么;
  • 如何使用 protoc 26.1 生成 .pbobjc.h.pbobjc.m
  • Protobuf Payload 如何放入自定义 Socket Packet;
  • 接收端如何处理 TCP 拆包、粘包,再解析 Protobuf;
  • ACK、重试和幂等如何与 message_id 配合;
  • 协议升级时,哪些改动兼容,哪些改动会造成线上事故。

本文所有类名、字段名、命令字和目录都是教学用的虚构示例,不对应任何具体项目。


一、先说结论:Protobuf 到底做什么

Protobuf(Protocol Buffers)是一套结构化数据序列化方案。

发送端把对象编码为二进制:

Objective-C 对象 -> Protobuf 编码 -> NSData

接收端把二进制还原为对象:

NSData -> Protobuf 解码 -> Objective-C 对象

它主要解决三个问题:

  1. 通信双方使用同一份结构定义;
  2. 以较紧凑的二进制格式传输数据;
  3. 协议可以在遵守规则的前提下逐步演进。

但要特别注意,Protobuf 不负责

  • 建立或维护 Socket 连接;
  • 处理 TCP 拆包、粘包;
  • 消息重试、ACK、去重;
  • 数据库事务;
  • 数据加密。

Protobuf 是编码方案,不是加密方案。抓到二进制数据后,只要拿到协议定义,内容仍然可以被解析。敏感数据应由 TLS 或业务加密层保护。


二、为什么 IM 更适合使用 Protobuf

1. 数据通常比 JSON 更紧凑

JSON 会重复传输字段名:

{
  "sender_id": 1001,
  "receiver_id": 2001,
  "message_id": "m-001"
}

Protobuf 在二进制中主要传输“字段号 + 类型 + 值”,不会反复传输 sender_id 这样的完整字符串。对于消息量大、长连接常驻的 IM 场景,能节省带宽和编解码开销。

2. 协议约束比字典更明确

使用 NSDictionary 或 JSON 时,字段类型往往只存在于文档和开发者约定中。Protobuf 把结构直接写进 .proto

int64 sender_id = 1;
string message_id = 3;
bytes content = 5;

代码生成后,Objective-C 层可以直接使用明确的属性,而不是到处写字符串 Key 和类型转换。

3. 更适合多端协作

同一份 .proto 可以生成 Objective-C、Swift、Java、Kotlin、Go、C++ 等语言的代码。客户端和服务端共享的是协议定义,而不是某一端手写的模型。

4. 支持向前、向后兼容

旧客户端收到新字段时会忽略未知字段;新客户端读取旧数据时,缺少的字段会使用默认值。这个能力很适合客户端版本无法同时升级的场景。

兼容性不是自动获得的。字段号、字段类型和删除策略仍然必须遵守规则,后文会详细说明。


三、三个版本概念不要混在一起

本文示例使用的协议编译器版本是:

protoc 26.1

工程中通常还会通过 CocoaPods 引入 Objective-C Runtime,例如:

pod 'Protobuf'

这两者不是同一个东西:

名称 作用 典型位置
.proto 协议源文件,是数据结构的唯一事实来源 Protocols/
protoc 26.1 读取 .proto,生成 Objective-C 代码 tools/protoc/bin/protoc
Objective-C Runtime 提供 GPBMessage、解析、序列化等运行能力 CocoaPods Protobuf
.pbobjc.h/.m 由生成器产生的业务协议类 Generated/

生成代码中通常包含生成器与 Runtime 的版本检查。升级任一方时,应重新生成并实际编译验证,不能仅因为“版本号更大”就断定兼容。

本文只给出 protoc 26.1 的命令示例,不要求也不建议在不了解工程状态时直接覆盖现有生成文件。


四、定义一份可演进的聊天协议

下面是一份完整但匿名化的示例:

syntax = "proto3";

package chat.protocol;

option objc_class_prefix = "CB";
option optimize_for = LITE_RUNTIME;

enum ResultCode {
  RESULT_CODE_UNSPECIFIED = 0;
  RESULT_CODE_OK = 1;
  RESULT_CODE_FAILED = 2;
}

message ChatMessage {
  int64 sender_id = 1;
  int64 receiver_id = 2;
  string message_id = 3;
  uint64 client_time = 4;
  bytes content = 5;
}

message ChatAck {
  string message_id = 1;
  uint64 server_time = 2;
  ResultCode result = 3;
}

逐项理解

syntax = "proto3" 表示使用 proto3 语法。

package chat.protocol 用于协议命名空间管理。它不等于 Objective-C 的模块名,也不要把真实公司域名或内部服务名写进公开文章。

objc_class_prefix = "CB" 给生成的 Objective-C 类增加前缀。例如 ChatMessage 会生成 CBChatMessage,可减少全局类名冲突。

optimize_for = LITE_RUNTIME 倾向于缩减生成代码依赖和体积,是否使用应由客户端、服务端和现有工程策略共同决定。已有协议不要为了“看起来更轻”而随意改动。

字段声明:

string message_id = 3;

可以拆成三部分:

  • string:字段类型;
  • message_id:协议中的字段名;
  • 3:字段号,也叫 tag。

字段号才是线上二进制协议真正识别字段的关键,字段名主要服务于代码可读性。


五、常用字段类型怎么选

Protobuf 类型 Objective-C 常见映射 适用场景
string NSString * 文本、消息 ID
bytes NSData * 二进制内容、嵌套加密结果
bool BOOL 开关状态
int32 / int64 int32_t / int64_t 普通整数
uint32 / uint64 无符号整数 时间戳、非负计数
sint32 / sint64 有符号整数 经常出现负数的数值
enum 生成的枚举 有限状态集合
repeated 生成的数组容器 同类型列表
map 生成的字典容器 键值表
oneof 互斥字段组 多种消息体只允许出现一种

int32sint32 的差异

Protobuf 的 int32/int64 使用 Varint。负数用普通 int32/int64 编码时通常不够紧凑;sint32/sint64 使用 ZigZag 编码,更适合正负数频繁出现的字段。

时间戳、长度、计数这类天然非负的数据,可以使用 uint64。不要只为了省几个字节就忽视业务范围和跨语言兼容。

bytes 不代表适合塞大文件

bytes 可以装任意二进制,但图片、视频、语音大文件不应直接塞进一条 Protobuf 消息。常见做法是:

  1. 文件通过对象存储或专门上传接口传输;
  2. Protobuf 只携带文件 URL、摘要、尺寸、时长等元数据;
  3. IM 消息仍保持小而可控。

这样可以避免 Socket 写缓冲区、内存峰值和重试成本被大文件拖垮。


六、使用 protoc 26.1 生成 Objective-C 文件

假设目录结构如下:

Demo/
├── Protocols/
│   └── chat_message.proto
├── Generated/
└── tools/
    └── protoc/
        └── bin/
            └── protoc

生成单个协议文件:

tools/protoc/bin/protoc \
  --proto_path=Protocols \
  --objc_out=Generated \
  Protocols/chat_message.proto

这里使用的是相对路径,不依赖任何人的电脑目录。

参数含义:

  • --proto_path=Protocols:指定 .proto 文件和 import 的搜索根目录;
  • --objc_out=Generated:指定 Objective-C 生成文件的输出目录;
  • 最后一项:本次要编译的协议文件。

执行后通常得到:

Generated/
├── ChatMessage.pbobjc.h
└── ChatMessage.pbobjc.m

批量生成多个协议文件可以使用:

find Protocols -name '*.proto' -print0 |
  xargs -0 tools/protoc/bin/protoc \
    --proto_path=Protocols \
    --objc_out=Generated

这段命令只应在确认以下事项后手动执行:

  1. 当前使用的确实是 protoc 26.1
  2. .proto 的 import 根目录正确;
  3. 输出目录不会误覆盖其他分支的生成结果;
  4. 生成代码对应的 Objective-C Runtime 已确认兼容;
  5. 已通过 Git 观察生成前后的差异。

有 import 时怎么处理

例如:

import "common/result.proto";

那么 --proto_path 指向的目录下必须能找到:

Protocols/common/result.proto

import 的查找依据是 --proto_path,不是当前终端目录,也不是 Xcode Group 的显示层级。这是生成失败最常见的原因之一。


七、生成文件如何接入 Objective-C 工程

生成后的 .pbobjc.h.pbobjc.m 应作为一组加入工程:

  1. 把文件放到稳定的协议生成目录;
  2. 在 Xcode 中加入对应 Target;
  3. 确认 .pbobjc.m 出现在 Build Phases -> Compile Sources;
  4. 通过 CocoaPods 或其他方式链接匹配的 Protobuf Runtime;
  5. 检查是否存在重复加入、重复生成或同名旧文件。

常见错误包括:

  • 只加入 .h,没有编译 .m,最终出现链接错误;
  • 同一个 .pbobjc.m 被两个路径重复加入,出现 duplicate symbol;
  • .proto 已更新,但工程仍引用旧生成文件;
  • import 路径与文件实际目录不一致;
  • 生成器与 Runtime 不兼容,触发生成代码中的版本检查。

不要修改 .pbobjc.h/.m

生成文件不是业务代码。正确的变更流程是:

修改 .proto -> 重新生成 -> 检查 diff -> 编译验证

手改生成文件,下次执行 protoc 就会全部丢失。需要转换或业务辅助逻辑时,应写 Category、Mapper 或独立适配层。

-fno-objc-arc 不是固定要求

一些历史工程会给生成的 .pbobjc.m 单独配置 -fno-objc-arc。这通常与当时的生成器和 Runtime 有关,并不是 protoc 26.1 的通用固定配置。

是否需要该参数,应以当前生成代码、Runtime 官方要求和实际编译结果为准。不要从旧工程机械复制。


八、Objective-C 中如何创建和序列化消息

引入生成头文件:

#import "ChatMessage.pbobjc.h"

创建消息对象:

CBChatMessage *message = [CBChatMessage message];
message.senderId = 1001;
message.receiverId = 2001;
message.messageId = @"m-20260924-001";
message.clientTime = 1780000000000;
message.content = [@"hello" dataUsingEncoding:NSUTF8StringEncoding];

.proto 中的下划线命名会映射为 Objective-C 驼峰属性,例如:

sender_id   -> senderId
message_id  -> messageId
client_time -> clientTime

序列化为 NSData

NSData *payloadData = message.data;

此时的 payloadData 只是 Protobuf Payload,还不是一个可以直接解决 TCP 拆包问题的完整网络帧。


九、Protobuf 如何进入 Socket Packet

一个常见的长连接协议会在 Protobuf 外面再包一层固定格式 Header:

+-------------------- 16-byte Header -------------------+
| totalLength | flags | appId | command | bodyLen | seq |
+-------------------------------------------------------+
|               Protobuf Payload                        |
+-------------------------------------------------------+

Header 中各字段的职责通常是:

字段 用途
totalLength 整个 Packet 的长度
flags 压缩、加密、版本等标志
appId 应用或协议域标识
command 决定 Payload 应使用哪种 Protobuf 类型解析
bodyLen Payload 长度
seq 请求序号、追踪或应答关联

这些 Header 字段属于自定义传输协议,不属于 Protobuf。

发送代码可以抽象成:

CBChatMessage *message = [CBChatMessage message];
message.messageId = model.messageId;
message.senderId = model.senderId;
message.receiverId = model.receiverId;
message.content = model.contentData;

NSData *payloadData = message.data;
SocketPacket *packet = [SocketPacket packetWithCommand:SocketCommandChat
                                               payload:payloadData];
[socketClient sendPacket:packet];

这里有三层对象:

业务模型 Model
    -> Protobuf Message
        -> Socket Packet(Header + Payload)

这样分层的价值是:

  • 业务模型不需要知道二进制协议细节;
  • Protobuf 类不需要知道 Socket 的连接状态;
  • Packet 层统一处理长度、命令字和序号;
  • 更换存储模型时不会直接污染协议层。

十、TCP 拆包、粘包与 Protobuf 的关系

TCP 是字节流,不保留应用层消息边界。

一次 write 的数据,接收端可能分多次收到:

发送:[完整 Packet A]
接收:[A 的前半段] [A 的后半段]

这就是常说的“拆包”。

多次 write 的数据,也可能一次收到:

发送:[Packet A] [Packet B]
接收:[Packet A + Packet B]

这就是常说的“粘包”。

Protobuf 无法判断一段 TCP 字节流从哪里开始、在哪里结束。因此接收端必须先通过 Header 中的长度字段切出完整 Packet,再把 Payload 交给 Protobuf。

典型接收流程:

1. Socket 收到新数据
2. 追加到接收缓冲区
3. 缓冲区不足 16 字节:继续等待
4. 读取 Header 和 bodyLen
5. 校验长度是否合理
6. 数据不足一个完整 Packet:继续等待
7. 截取完整 Packet
8. 根据 command 选择 Protobuf 类型
9. parseFromData:error:
10. 缓冲区还有数据:继续解析下一帧

伪代码示例:

- (void)appendReceivedData:(NSData *)receivedData {
    [self.receiveBuffer appendData:receivedData];

    while (self.receiveBuffer.length >= SocketHeaderLength) {
        SocketHeader header = [self parseHeaderFromData:self.receiveBuffer];

        if (header.bodyLength > SocketMaxPayloadLength) {
            [self closeForInvalidPacket];
            return;
        }

        NSUInteger packetLength = SocketHeaderLength + header.bodyLength;
        if (self.receiveBuffer.length < packetLength) {
            return;
        }

        NSRange payloadRange = NSMakeRange(SocketHeaderLength,
                                           header.bodyLength);
        NSData *payloadData = [self.receiveBuffer subdataWithRange:payloadRange];

        [self.receiveBuffer replaceBytesInRange:NSMakeRange(0, packetLength)
                                      withBytes:NULL
                                         length:0];

        [self handlePayload:payloadData command:header.command];
    }
}

真正实现时还需要注意:

  • Header 的字节序必须统一,网络协议通常采用大端序;
  • 加法前要防止整数溢出;
  • bodyLength 必须设置上限,不能完全信任网络数据;
  • Header 本身也要校验版本、命令字和合法范围;
  • 解析失败不能继续把错误数据交给业务层;
  • 如果启用了压缩或加密,应按 flags 规定的顺序处理。

十一、接收端如何解析 Protobuf

Packet 层已经切出完整 payloadData 后,再根据 command 选择具体消息类型:

- (void)handlePayload:(NSData *)payloadData
               command:(SocketCommand)command {
    switch (command) {
        case SocketCommandChat:
            [self handleChatPayload:payloadData];
            break;

        case SocketCommandAck:
            [self handleAckPayload:payloadData];
            break;

        default:
            [self reportUnsupportedCommand:command];
            break;
    }
}

解析聊天消息:

- (void)handleChatPayload:(NSData *)payloadData {
    NSError *parseError = nil;
    CBChatMessage *message = [CBChatMessage parseFromData:payloadData
                                                   error:&parseError];
    if (parseError != nil || message == nil) {
        [self reportParseError:parseError];
        return;
    }

    [self.messageService receiveMessage:message];
}

不要忽略 NSError。网络数据可能被截断、版本不匹配、解密失败,也可能根本不是预期的 Protobuf 类型。

command 为什么重要

同一段二进制数据不会自动告诉 Runtime “我是 ChatMessage 还是 ChatAck”。外层 Header 的 command 相当于路由键:

command = Chat -> CBChatMessage
command = Ack  -> CBChatAck

因此 Command 映射表本身也是协议的一部分,必须由两端共同维护,不能随意复用旧命令字。


十二、ACK、重试与幂等怎么结合

Protobuf 只负责把 message_id 编码进数据。可靠消息能力来自上层状态机。

常见流程:

客户端生成 message_id
        |
        v
本地落库,状态 = sending
        |
        v
Protobuf 编码 + Socket 发送
        |
        v
服务端按 message_id 去重
        |
        v
服务端返回 ChatAck
        |
        v
客户端更新状态 = sent

如果超时没有收到 ACK,客户端可能重发同一条消息。重发时必须继续使用原来的 message_id,不能每次生成新 ID。

服务端或接收端以 message_id 做唯一性判断:

  • 第一次收到:写入并处理;
  • 再次收到相同 ID:不重复创建消息,只返回已有处理结果。

这就是幂等:同一请求执行一次和执行多次,最终业务结果一致。

为什么仅有 sequence 不够

sequence 常用于一次连接内的请求关联,而 message_id 通常要求跨重连、跨重试保持稳定。连接断开后 sequence 可能重新计数,所以业务幂等应优先依赖全局稳定的消息 ID。


十三、协议演进的硬规则

1. 已发布字段号不能修改

错误示例:

// 旧版本
string message_id = 3;

// 错误:把同一字段改成 8
string message_id = 8;

对线上协议来说,3 才是这个字段的身份。改字段名通常不影响二进制兼容,改字段号会让新旧两端把它当成不同字段。

2. 删除字段后使用 reserved

message ChatMessage {
  reserved 6;
  reserved "legacy_text";

  int64 sender_id = 1;
  int64 receiver_id = 2;
  string message_id = 3;
}

reserved 防止后来的人误用旧字段号或旧字段名。字段号一旦发布,就不应分配给新的语义。

3. 新字段使用新字段号

message ChatMessage {
  int64 sender_id = 1;
  int64 receiver_id = 2;
  string message_id = 3;
  uint64 client_time = 4;
  bytes content = 5;
  string trace_id = 7;
}

旧客户端一般会忽略 trace_id,新客户端读取旧消息时则得到默认值。

4. 不要随意修改字段类型

即使部分类型在线格式上看似兼容,也可能产生截断、符号变化或跨语言差异。最稳妥的做法是新增字段,完成灰度迁移后再保留旧字段为废弃状态。

5. enum 的 0 值表示未知状态

enum ResultCode {
  RESULT_CODE_UNSPECIFIED = 0;
  RESULT_CODE_OK = 1;
  RESULT_CODE_FAILED = 2;
}

proto3 会把枚举的第一个值作为默认值,所以 0 最好表示“未知/未设置”,不要让它直接表示“成功”。否则字段缺失也会被误判为成功。

6. 优先使用 oneof 表达互斥关系

message MessageContent {
  oneof value {
    string text = 1;
    ImageMeta image = 2;
    AudioMeta audio = 3;
  }
}

这比同时定义多个可选字段、再靠业务约定“只能设置一个”更可靠。


十四、协议设计中值得重点学习的能力

对于有 Objective-C 经验的开发者,学习重点不应停留在“怎么调用 message.data”,而应放在以下工程能力上。

1. Wire Format

理解 Varint、ZigZag、Length-delimited、字段号与 wire type,才能判断类型选择对体积和兼容性的真实影响。

2. Schema 演进

掌握新增字段、废弃字段、reserved、enum 扩展、oneof 演进,以及多版本客户端共存策略。

3. 代码生成链路

.proto 视为源代码,把 .pbobjc.* 视为构建产物。团队应统一生成器版本、命令和输出路径。

4. Socket 分帧

掌握固定 Header、长度字段、接收缓冲区、循环解帧、字节序、最大帧限制和异常连接处理。Protobuf 不能替代这一层。

5. 可靠消息状态机

理解本地落库、Outbox、发送、ACK、超时重试、幂等和最终状态收敛。Protobuf 只是状态机中的编码工具。

6. 安全边界

任何来自网络的长度和枚举值都不可信。先限制 Packet 大小,再解密/解压,最后解析 Protobuf,并控制单次处理的内存峰值。

7. 可观测性

日志应记录 command、sequence、message ID、Payload 长度、解析耗时和错误类型,但不要输出消息正文、令牌或完整二进制数据。


十五、推荐的工程目录和自动化策略

ProtocolModule/
├── Protocols/          # .proto 源文件
├── Generated/          # .pbobjc.h/.m
├── Scripts/            # 固定版本的生成脚本
└── Tests/              # 编解码与兼容性测试

推荐把生成文件提交到仓库,原因是:

  • iOS 开发者拉取代码后不必先安装生成器;
  • Code Review 能直接看到生成代码变化;
  • 发布分支可以准确复现当时的协议产物。

同时在 CI 中执行一致性检查:

固定 protoc 版本重新生成
        -> 与仓库中的 Generated 比较
        -> 有差异则构建失败

这样可以发现“改了 .proto 忘记重新生成”和“开发者本机生成器版本不一致”两类问题。

注意:CI 的生成环境和命令应由团队维护。不要让脚本依赖某位开发者的绝对路径。


十六、建议补上的测试

1. 编解码回环测试

- (void)testChatMessageRoundTrip {
    CBChatMessage *sourceMessage = [CBChatMessage message];
    sourceMessage.senderId = 1001;
    sourceMessage.messageId = @"test-message-id";
    sourceMessage.content = [@"hello" dataUsingEncoding:NSUTF8StringEncoding];

    NSError *parseError = nil;
    CBChatMessage *decodedMessage =
        [CBChatMessage parseFromData:sourceMessage.data
                               error:&parseError];

    XCTAssertNil(parseError);
    XCTAssertEqual(decodedMessage.senderId, sourceMessage.senderId);
    XCTAssertEqualObjects(decodedMessage.messageId, sourceMessage.messageId);
    XCTAssertEqualObjects(decodedMessage.content, sourceMessage.content);
}

2. 新旧协议兼容测试

保留一份旧版本编码得到的二进制 Fixture,让新代码持续解析;也可以让旧协议类读取新数据,验证新增字段不会破坏旧端。

3. Packet 边界测试

至少覆盖:

  • Header 不完整;
  • Payload 不完整;
  • 一次收到两个 Packet;
  • 一个 Packet 被拆成多次收到;
  • bodyLength 为 0;
  • bodyLength 超过上限;
  • 非法 command;
  • Protobuf 数据损坏。

4. 幂等测试

连续提交两次相同 message_id,验证数据库最终只有一条业务消息,并且两次都能得到确定的 ACK 结果。


十七、常见误区

误区一:用了 Protobuf 就没有粘包

错误。粘包是 TCP 字节流的边界问题,必须由 Packet Header 的长度字段和接收缓冲区解决。

误区二:二进制格式就是加密

错误。Protobuf 只是人眼不方便直接阅读,不能提供机密性和身份认证。

误区三:生成文件可以直接改

错误。任何修改都应回到 .proto,然后重新生成。

误区四:删除字段后可以复用字段号

错误。旧数据或旧客户端仍可能使用这个字段号。应使用 reserved 永久保留。

误区五:解析成功就代表消息可信

错误。解析成功只说明字节符合某种结构。权限、签名、发送方、会话归属和业务状态仍需独立校验。

误区六:protoc 版本等于 Runtime 版本

错误。一个负责生成代码,一个负责运行生成代码。必须按生成代码中的兼容检查和官方版本规则配套验证。


十八、一套完整的发送与接收链路

把前面的内容串起来,发送链路是:

1. UI 触发发送
2. Service 生成稳定 message_id
3. 消息与 Outbox 在同一数据库事务中落库
4. Mapper 把业务模型转换为 Protobuf Message
5. message.data 生成 Payload
6. Packet 层写入 Header、command、length、sequence
7. Socket 层发送完整 Packet
8. 收到 ACK 后更新本地消息状态
9. 超时则使用同一 message_id 重试

接收链路是:

1. Socket 收到任意长度的 NSData
2. 追加进接收缓冲区
3. Packet 层按 Header.length 循环切帧
4. 校验最大 Payload 长度
5. 根据 command 选择生成的 Protobuf 类型
6. parseFromData:error: 解码
7. 校验会话和业务字段
8. 使用 message_id 去重
9. 写数据库并通知界面刷新

这套分层中,Protobuf 只出现在第 4~6 步附近。把它放回完整链路中理解,就不会再把“序列化”“分包”“可靠投递”混成一个问题。


结语

Protobuf 的 API 很简单,真正有价值的部分是协议治理。

在 Objective-C IM 工程中,稳定的实践应当是:以 .proto 作为协议源文件,用固定的 protoc 26.1 生成 Objective-C 类型,通过匹配的 Runtime 完成编解码;外层 Packet 负责 TCP 消息边界,消息状态机负责 ACK、重试和幂等,数据库负责可靠落盘。

只要把这几层职责分清,再严格遵守字段号不可复用、生成文件不可手改、网络长度必须校验这些规则,Protobuf 就不只是一个“把对象转成 NSData”的工具,而会成为长期可维护的通信协议基础。

©著作权归作者所有,转载或内容合作请联系作者
【社区内容提示】社区部分内容疑似由AI辅助生成,浏览时请结合常识与多方信息审慎甄别。
平台声明:文章内容(如有图片或视频亦包括在内)由作者上传并发布,文章内容仅代表作者本人观点,简书系信息发布平台,仅提供信息存储服务。

友情链接更多精彩内容