1. 项目概述:为什么Unity项目需要自己处理Socket通讯?
在Unity里做网络功能,很多人的第一反应是直接用Unity自带的UNet(现在叫Netcode)或者找一些Asset Store上的插件,比如Photon、Mirror。这当然没问题,对于快速原型或者对网络底层不关心的项目来说,用现成方案是最省事的。但如果你遇到的是下面这些情况,自己动手实现Socket通讯就成了一个绕不开的坎:
- 对接非Unity生态的硬件或服务端:你的Unity客户端需要连接一个用C++、Java、Go甚至Python写的服务器,这个服务器可能是一个物联网网关、一个已有的游戏大厅、或者一个定制化的企业级后端。这时候,通用的、基于TCP/UDP的Socket协议就成了唯一的“通用语言”。
- 对网络性能和控制力有极致要求:你做的可能是一个实时竞技游戏,每一毫秒的延迟都至关重要;或者是一个需要处理海量并发连接的工具。使用底层Socket,你可以精细控制每一个数据包的发送时机、缓冲区大小、甚至自定义应用层协议,避免高级框架带来的额外开销和“黑盒”操作。
- 学习与深入理解:对于想成为技术专家的开发者而言,理解从传输层(TCP/UDP)到应用层的数据流是如何构建的,是必不可少的一课。自己实现一遍,你会对“粘包”、“心跳”、“重连”这些网络编程的核心概念有刻骨铭心的理解,以后无论用什么框架都能得心应手。
我这次分享的,就是一个在Unity中从零搭建TCP Socket客户端,并彻底解决令人头疼的“TCP粘包/拆包”问题的完整方案。整个过程我亲测有效,并且使用的都是.NET标准库里的类,完全免费,不需要任何第三方付费资产。无论你是想做一个联机小游戏demo,还是为你的AR/VR应用建立一个稳定的数据通道,这篇内容都能给你一套可以直接“抄作业”的代码和思路。
2. 核心原理与设计思路:TCP的可靠性与它的“副作用”
在动手写代码之前,我们必须先搞清楚我们要对付的“敌人”是谁。选择TCP协议,是因为它提供可靠的、有序的、基于字节流的连接。但正是这个“字节流”的特性,带来了粘包和拆包问题。
2.1 什么是TCP粘包与拆包?
你可以把TCP连接想象成一条源源不断的水管。客户端是水龙头,服务器是接水的水桶。你通过水管传递的不是一个一个包装好的盒子(数据包),而是连续的水流(字节流)。
- 粘包:你快速拧开又关上水龙头两次(发送了两个独立的消息),但水管里的水是连续流动的。结果,水桶里接到了一大股水,分不清哪部分是第一股,哪部分是第二股。在网络上,就是接收方一次
Receive操作收到了发送方多次Send操作的数据。 - 拆包:你想传递一个很大的水球(一个大的数据包)。但这个水球太大,一次挤不过水管狭窄处(MTU,最大传输单元),或者水桶(接收缓冲区)一次装不下。结果水球被拆成了好几股水流,分多次到达水桶。在网络上,就是发送方一次
Send操作的数据,被接收方用了多次Receive操作才收完。
2.2 为什么Unity里这个问题尤其需要注意?
因为Unity主线程的更新循环(Update)和网络接收的异步操作是不同步的。你可能会在Update里调用Receive,但网络数据到达的时机是随机的。如果你简单地在每一帧都去读Available的数据,你极有可能读到不完整的包(拆包),或者多个包混在一起(粘包)。如果不处理,你的游戏逻辑解析数据时就会错乱,导致角色位置瞬移、消息解析失败等诡异的Bug。
2.3 我们的解决方案设计:消息边界协议
解决这个问题的核心思想是:在字节流中,为每一条应用层消息明确标定边界。主流方法有四种:
- 固定长度:每个消息都一样长,不够的补空格。简单但浪费带宽。
- 分隔符:用特殊字符(如
\n)标记消息结束。但消息内容本身不能包含分隔符,需要转义。 - 长度前缀:在消息头部,先发送一个固定长度的字段,用来标明后面消息体的长度。这是最常用、最灵活的方式。
- 更复杂的协议:如HTTP的
Content-Length。
我们选择长度前缀法。它的工作流程如下:
- 发送端:先计算要发送的
消息体的字节长度,将这个长度值转换成一个固定格式(例如4字节的整数)的消息头。然后先发送消息头,再发送消息体。 - 接收端:建立一个数据缓存区。收到数据后,先存入缓存区。
- 检查缓存区数据是否大于等于
消息头的长度(如4字节)。 - 如果是,则从缓存区头部读出这4字节,解析出
消息体的预期长度N。 - 再检查缓存区剩余的数据是否大于等于
N字节。 - 如果是,则从缓存区中取出这
N字节,这就是一个完整的消息体,可以交给业务逻辑处理,并将这部分数据从缓存区移除。 - 重复此过程,直到缓存区数据不足以解析出一个完整的消息。
- 检查缓存区数据是否大于等于
这个方案清晰、高效,是我们接下来实现的基础。
3. 核心类实现:构建健壮的Unity TCP客户端
我们不使用Thread进行阻塞式接收,而是采用async/await异步模型,这样能更好地与Unity协同,避免主线程卡顿。整个核心将由三个类构成:TcpClient(连接管理)、MessageBuffer(粘包拆包处理)、和NetworkManager(Unity单例入口)。
3.1 MessageBuffer 类:数据缓存与消息解码器
这是解决粘包/拆包的核心。它内部维护一个List<byte>作为缓存区,并提供AddData和TryReadMessage方法。
using System; using System.Collections.Generic; using System.IO; public class MessageBuffer { // 消息头长度,我们约定为4字节,用于存储一个Int32类型的消息体长度 private const int HEADER_SIZE = sizeof(Int32); private List<byte> _buffer = new List<byte>(); private int _dataLength = 0; // 当前正在处理的消息体的长度 /// <summary> /// 将接收到的原始字节数据添加到缓存区 /// </summary> public void AddData(byte[] data, int offset, int count) { if (data == null || count <= 0) return; for (int i = 0; i < count; i++) { _buffer.Add(data[offset + i]); } } /// <summary> /// 尝试从缓存区中读取一条完整的消息。 /// 如果成功,返回true并将消息体数据放入`message`,否则返回false。 /// </summary> public bool TryReadMessage(out byte[] message) { message = null; // 1. 如果连消息头都读不出来,直接返回false if (_buffer.Count < HEADER_SIZE) { return false; } // 2. 如果是新消息的开始,先读取消息头,获取消息体长度 if (_dataLength == 0) { _dataLength = BitConverter.ToInt32(_buffer.ToArray(), 0); // 简单校验:消息体长度不能为负数,也不能过大(例如超过10MB) if (_dataLength < 0 || _dataLength > 10 * 1024 * 1024) { // 协议错误,清空缓存,避免后续解析全部失败 Reset(); throw new InvalidDataException($"Invalid message length received: {_dataLength}"); } } // 3. 检查缓存区数据是否足够组成一个完整的消息(头+体) if (_buffer.Count >= HEADER_SIZE + _dataLength) { // 4. 提取消息体(跳过前面的4字节消息头) int messageStartIndex = HEADER_SIZE; int messageSize = _dataLength; message = new byte[messageSize]; _buffer.CopyTo(messageStartIndex, message, 0, messageSize); // 5. 从缓存区中移除已处理的数据(消息头+消息体) int totalMessageSize = HEADER_SIZE + _dataLength; _buffer.RemoveRange(0, totalMessageSize); // 6. 重置状态,准备读取下一条消息 _dataLength = 0; return true; } // 数据还不够一条完整的消息 return false; } /// <summary> /// 重置缓存区,通常在连接断开或发生协议错误时调用 /// </summary> public void Reset() { _buffer.Clear(); _dataLength = 0; } }注意:这里使用
List<byte>是为了方便地使用RemoveRange。在实际高频场景下,为了性能可以考虑使用Circular Buffer(环形缓冲区)来避免频繁的内存移动,但对于大多数Unity项目,List<byte>已经足够。
3.2 TcpClient 类:连接、发送与异步接收
这个类封装了System.Net.Sockets.TcpClient,负责建立连接、发送数据,并启动一个后台任务持续接收数据。
using System; using System.Net.Sockets; using System.Threading; using System.Threading.Tasks; using UnityEngine; public class UnityTcpClient : IDisposable { private TcpClient _tcpClient; private NetworkStream _stream; private MessageBuffer _receiveBuffer; private CancellationTokenSource _cancellationTokenSource; private bool _isConnected = false; public event Action<byte[]> OnMessageReceived; public event Action OnConnected; public event Action<string> OnDisconnected; // 传递断开原因 public event Action<Exception> OnError; public bool IsConnected => _isConnected && _tcpClient?.Connected == true; public UnityTcpClient() { _receiveBuffer = new MessageBuffer(); } /// <summary> /// 异步连接到服务器 /// </summary> public async Task ConnectAsync(string host, int port, int timeoutMilliseconds = 5000) { if (IsConnected) { Debug.LogWarning("Client is already connected."); return; } try { _tcpClient = new TcpClient(); _cancellationTokenSource = new CancellationTokenSource(); var timeoutTokenSource = new CancellationTokenSource(timeoutMilliseconds); var linkedTokenSource = CancellationTokenSource.CreateLinkedTokenSource(_cancellationTokenSource.Token, timeoutTokenSource.Token); await _tcpClient.ConnectAsync(host, port).AsTask(linkedTokenSource.Token); _stream = _tcpClient.GetStream(); _isConnected = true; OnConnected?.Invoke(); Debug.Log($"Successfully connected to {host}:{port}"); // 启动接收任务 _ = ReceiveLoopAsync(_cancellationTokenSource.Token); // 使用 discard `_` 忽略Task,避免未等待的警告 } catch (OperationCanceledException) when (!_cancellationTokenSource.IsCancellationRequested) { // 连接超时 throw new TimeoutException($"Connection to {host}:{port} timed out after {timeoutMilliseconds}ms."); } catch (Exception e) { HandleError(e); throw; // 将异常向上抛出,让调用者知道连接失败 } } /// <summary> /// 发送一条消息(自动添加长度前缀) /// </summary> public void SendMessage(byte[] bodyData) { if (!IsConnected || bodyData == null) { Debug.LogWarning("Cannot send message: Client not connected or data is null."); return; } try { // 1. 构造带长度前缀的数据包 byte[] lengthPrefix = BitConverter.GetBytes(bodyData.Length); byte[] packet = new byte[lengthPrefix.Length + bodyData.Length]; Buffer.BlockCopy(lengthPrefix, 0, packet, 0, lengthPrefix.Length); Buffer.BlockCopy(bodyData, 0, packet, lengthPrefix.Length, bodyData.Length); // 2. 异步发送,不等待结果以避免阻塞 _stream.WriteAsync(packet, 0, packet.Length).ContinueWith(t => { if (t.IsFaulted) { HandleError(t.Exception?.InnerException ?? t.Exception); } }); } catch (Exception e) { HandleError(e); } } /// <summary> /// 接收数据的循环任务 /// </summary> private async Task ReceiveLoopAsync(CancellationToken cancellationToken) { byte[] readBuffer = new byte[4096]; // 4KB的读取缓冲区 while (!cancellationToken.IsCancellationRequested && IsConnected) { try { // 异步读取数据 int bytesRead = await _stream.ReadAsync(readBuffer, 0, readBuffer.Length, cancellationToken); if (bytesRead == 0) { // 对方优雅地关闭了连接 Debug.Log("Server closed the connection gracefully."); Disconnect("Server closed connection"); break; } // 将数据喂给MessageBuffer _receiveBuffer.AddData(readBuffer, 0, bytesRead); // 尝试从缓存中解析出完整的消息 while (_receiveBuffer.TryReadMessage(out byte[] completeMessage)) { // 在主线程上触发消息接收事件 // 这里使用`MainThreadDispatcher`是为了确保事件回调在Unity主线程执行,方便操作GameObject。 MainThreadDispatcher.Instance.Enqueue(() => OnMessageReceived?.Invoke(completeMessage)); } } catch (ObjectDisposedException) { // 连接已被释放,正常退出循环 break; } catch (Exception e) when (!(e is OperationCanceledException)) { // 其他网络异常 HandleError(e); Disconnect($"Receive error: {e.Message}"); break; } } } /// <summary> /// 断开连接 /// </summary> public void Disconnect(string reason = "Client request") { if (!_isConnected) return; _isConnected = false; _cancellationTokenSource?.Cancel(); try { _stream?.Close(); _tcpClient?.Close(); } catch (Exception e) { Debug.LogWarning($"Error while closing connection: {e.Message}"); } finally { _stream = null; _tcpClient = null; _receiveBuffer.Reset(); OnDisconnected?.Invoke(reason); Debug.Log($"Disconnected: {reason}"); } } private void HandleError(Exception e) { Debug.LogError($"Network error: {e}"); OnError?.Invoke(e); } public void Dispose() { Disconnect("Disposed"); _cancellationTokenSource?.Dispose(); } }3.3 NetworkManager 类:Unity单例与主线程调度
Unity的API(如Transform、UI操作)必须在主线程调用。我们的网络事件(如OnMessageReceived)是在后台线程触发的,因此需要一个桥梁将回调调度回主线程。
using System; using System.Collections.Generic; using UnityEngine; // 简易的主线程调度器 public class MainThreadDispatcher : MonoBehaviour { private static MainThreadDispatcher _instance; private readonly Queue<Action> _executionQueue = new Queue<Action>(); public static MainThreadDispatcher Instance { get { if (_instance == null) { GameObject go = new GameObject("MainThreadDispatcher"); _instance = go.AddComponent<MainThreadDispatcher>(); DontDestroyOnLoad(go); } return _instance; } } public void Enqueue(Action action) { lock (_executionQueue) { _executionQueue.Enqueue(action); } } void Update() { lock (_executionQueue) { while (_executionQueue.Count > 0) { _executionQueue.Dequeue()?.Invoke(); } } } } // 网络管理器,作为业务逻辑的入口 public class NetworkManager : MonoBehaviour { public static NetworkManager Instance { get; private set; } private UnityTcpClient _client; public string serverHost = "127.0.0.1"; public int serverPort = 8080; void Awake() { if (Instance != null && Instance != this) { Destroy(gameObject); return; } Instance = this; DontDestroyOnLoad(gameObject); // 确保主线程调度器存在 _ = MainThreadDispatcher.Instance; } void Start() { InitializeClient(); } void OnDestroy() { _client?.Disconnect("Manager destroyed"); _client?.Dispose(); } private void InitializeClient() { _client = new UnityTcpClient(); _client.OnConnected += HandleConnected; _client.OnDisconnected += HandleDisconnected; _client.OnMessageReceived += HandleMessageReceived; _client.OnError += HandleError; } // 提供给UI按钮调用的连接方法 public async void ConnectToServer() { if (_client.IsConnected) { Debug.Log("Already connected."); return; } try { await _client.ConnectAsync(serverHost, serverPort); } catch (Exception e) { Debug.LogError($"Connection failed: {e.Message}"); } } public void DisconnectFromServer() { _client?.Disconnect("User request"); } // 示例:发送一个字符串消息 public void SendChatMessage(string text) { if (!_client.IsConnected) { Debug.LogWarning("Not connected."); return; } // 将字符串编码为字节数组(这里使用UTF-8) byte[] data = System.Text.Encoding.UTF8.GetBytes(text); _client.SendMessage(data); } // 示例:发送一个自定义结构体(如玩家位置) public void SendPlayerPosition(Vector3 position) { // 将Vector3转换为字节数组 // 注意:这里需要定义好和服务端一致的序列化/反序列化规则 byte[] xBytes = BitConverter.GetBytes(position.x); byte[] yBytes = BitConverter.GetBytes(position.y); byte[] zBytes = BitConverter.GetBytes(position.z); byte[] data = new byte[12]; // 3个float * 4字节 Buffer.BlockCopy(xBytes, 0, data, 0, 4); Buffer.BlockCopy(yBytes, 0, data, 4, 4); Buffer.BlockCopy(zBytes, 0, data, 8, 4); _client.SendMessage(data); } // --- 事件处理器 --- private void HandleConnected() { Debug.Log("NetworkManager: Connected to server."); // 更新UI状态,比如将连接按钮置灰 } private void HandleDisconnected(string reason) { Debug.Log($"NetworkManager: Disconnected. Reason: {reason}"); // 更新UI状态,比如显示重连按钮 } private void HandleMessageReceived(byte[] messageData) { // 这里是主线程,可以安全操作Unity对象 // 示例:将接收到的字节数组解码为字符串 string text = System.Text.Encoding.UTF8.GetString(messageData); Debug.Log($"Received message: {text}"); // 或者,解析为自定义协议 // ParseGameProtocol(messageData); } private void HandleError(Exception error) { Debug.LogError($"NetworkManager error: {error.Message}"); } }4. 实战应用与协议设计:定义你的游戏通信语言
有了稳定的通信层,接下来就是定义应用层协议,即你和服务器之间“对话”的规则。一个简单的协议可以包含消息类型和消息体。
4.1 设计一个简单的二进制协议
我们可以定义消息结构为:[消息类型 (2字节)][消息体长度 (4字节)][消息体数据]。但为了简化,我们可以利用已有的“长度前缀”,在消息体内部再定义结构。这里展示一种更常见的混合方式:
- 消息ID(枚举):用一个
short或int表示这是什么消息(如1=登录,2=移动,3=聊天)。 - 序列化消息体:将具体的参数(如用户名、坐标、聊天内容)序列化为字节数组。
发送时,我们将消息ID和序列化后的消息体拼接起来,作为UnityTcpClient.SendMessage的bodyData参数。
// 定义消息类型枚举 public enum MessageType : short { LoginRequest = 1001, LoginResponse = 1002, PlayerMove = 2001, ChatMessage = 3001, } // 示例:登录请求消息体结构 public class LoginRequest { public string Username; public string Password; // 注意:实际项目中密码必须加密传输! } // 协议序列化辅助类 public static class ProtocolHelper { // 序列化登录请求 public static byte[] SerializeLoginRequest(LoginRequest request) { using (MemoryStream ms = new MemoryStream()) using (BinaryWriter writer = new BinaryWriter(ms)) { writer.Write(request.Username); writer.Write(request.Password); return ms.ToArray(); } } // 反序列化登录请求 public static LoginRequest DeserializeLoginRequest(byte[] data) { using (MemoryStream ms = new MemoryStream(data)) using (BinaryReader reader = new BinaryReader(ms)) { return new LoginRequest { Username = reader.ReadString(), Password = reader.ReadString() }; } } // 打包一个完整的网络消息 public static byte[] PackMessage(MessageType type, byte[] bodyData) { using (MemoryStream ms = new MemoryStream()) using (BinaryWriter writer = new BinaryWriter(ms)) { writer.Write((short)type); // 写入2字节的消息类型 if (bodyData != null) { writer.Write(bodyData); // 写入消息体 } return ms.ToArray(); } } // 解包一个完整的网络消息 public static bool UnpackMessage(byte[] data, out MessageType type, out byte[] bodyData) { type = 0; bodyData = null; if (data.Length < sizeof(short)) return false; using (MemoryStream ms = new MemoryStream(data)) using (BinaryReader reader = new BinaryReader(ms)) { type = (MessageType)reader.ReadInt16(); if (ms.Position < ms.Length) { bodyData = reader.ReadBytes((int)(ms.Length - ms.Position)); } return true; } } }在NetworkManager的HandleMessageReceived中,我们就可以进行协议解析:
private void HandleMessageReceived(byte[] messageData) { if (ProtocolHelper.UnpackMessage(messageData, out MessageType type, out byte[] body)) { switch (type) { case MessageType.LoginResponse: // 反序列化body,处理登录结果 break; case MessageType.PlayerMove: // 解析body,更新其他玩家位置 // Vector3 pos = ParsePosition(body); // UpdateRemotePlayerPosition(pos); break; case MessageType.ChatMessage: string chatText = System.Text.Encoding.UTF8.GetString(body); Debug.Log($"收到聊天: {chatText}"); break; default: Debug.LogWarning($"Unknown message type: {type}"); break; } } }4.2 发送消息的完整流程示例
// 在某个UI按钮点击事件中 public void OnLoginButtonClicked(string username, string password) { if (!_client.IsConnected) return; var loginReq = new LoginRequest { Username = username, Password = password }; byte[] bodyData = ProtocolHelper.SerializeLoginRequest(loginReq); byte[] networkPacket = ProtocolHelper.PackMessage(MessageType.LoginRequest, bodyData); _client.SendMessage(networkPacket); // 最终交给TcpClient发送 }5. 进阶优化与避坑指南
实现基础功能只是第一步,要让它在真实项目中稳定运行,还需要考虑很多细节。
5.1 心跳机制与断线重连
TCP连接在空闲时可能被中间路由器或防火墙断开。为了保持连接活跃,需要实现心跳机制。
public class HeartbeatService { private UnityTcpClient _client; private CancellationTokenSource _cts; private int _heartbeatIntervalMs = 30000; // 30秒一次 private int _timeoutMs = 10000; // 10秒无响应认为超时 private DateTime _lastReceiveTime; public void Start(UnityTcpClient client) { _client = client; _client.OnMessageReceived += OnAnyMessageReceived; // 任何消息都刷新接收时间 _lastReceiveTime = DateTime.Now; _cts = new CancellationTokenSource(); _ = HeartbeatLoop(_cts.Token); } private void OnAnyMessageReceived(byte[] _) { _lastReceiveTime = DateTime.Now; } private async Task HeartbeatLoop(CancellationToken token) { byte[] heartbeatPacket = ProtocolHelper.PackMessage(MessageType.Heartbeat, null); while (!token.IsCancellationRequested && _client.IsConnected) { await Task.Delay(_heartbeatIntervalMs, token); if ((DateTime.Now - _lastReceiveTime).TotalMilliseconds > _timeoutMs) { Debug.LogWarning("Heartbeat timeout, disconnecting."); _client.Disconnect("Heartbeat timeout"); break; } _client.SendMessage(heartbeatPacket); Debug.Log("Heartbeat sent."); } } public void Stop() { _cts?.Cancel(); if (_client != null) _client.OnMessageReceived -= OnAnyMessageReceived; } }5.2 发送队列与流量控制
在高频发送场景(如同步玩家位置),如果每帧都直接调用SendMessage,可能会导致大量小包发送,增加网络负担,甚至因发送缓冲区满而阻塞。一个优化是引入发送队列。
public class SendQueue { private Queue<byte[]> _queue = new Queue<byte[]>(); private UnityTcpClient _client; private object _lockObj = new object(); private bool _isSending = false; public void Enqueue(byte[] data) { lock (_lockObj) { _queue.Enqueue(data); if (!_isSending) { _isSending = true; // 可以立即开始发送,也可以等到下一帧或固定间隔再批量发送 Task.Run(() => SendFromQueue()); } } } private async Task SendFromQueue() { while (true) { byte[] dataToSend = null; lock (_lockObj) { if (_queue.Count == 0) { _isSending = false; break; } dataToSend = _queue.Dequeue(); } // 这里可以合并多个小包(如本帧所有位置更新)成一个大数据包再发送,进一步优化 if (_client.IsConnected) { try { await _client.SendMessageAsync(dataToSend); // 需要为TcpClient实现一个异步Send方法 } catch { /* 处理发送失败 */ } } // 可选:短暂延迟,控制发送速率 await Task.Delay(10); } } }5.3 常见问题排查与调试技巧
- “通常每个套接字地址只允许使用一次”:这个错误通常是因为端口被占用。确保服务器端口正确,并且没有重复启动客户端/服务器。在客户端,如果频繁连接断开,可以设置
TcpClient的LingerState属性或使用using语句确保资源释放。 - 连接失败:检查防火墙设置、服务器IP和端口是否正确、服务器程序是否在运行。使用
telnet [服务器IP] [端口]命令可以快速测试端口是否开放。 - 数据收不到或不全:首先检查
MessageBuffer的逻辑,用简单的字符串消息测试粘包拆包处理是否正确。在发送和接收两端打印日志,确认数据是否被正确拼接和解析。 - Unity编辑器下运行正常,打包后失败:检查打包平台的.NET API兼容性级别(推荐使用
.NET Standard 2.0或.NET Framework)。确保所有用到的System.Net.SocketsAPI在目标平台都可用。 - 性能问题:在Profiler中查看GC Alloc。频繁的
byte[]分配(如在ReceiveLoop中)会导致GC压力。可以考虑使用ArrayPool<byte>.Shared来租用和归还字节数组,大幅减少GC。
5.4 关于序列化的选择
上面的例子用了BinaryWriter/BinaryReader,它简单但跨语言兼容性差。对于复杂项目,可以考虑:
- Protobuf (Google.Protobuf):高效,二进制体积小,跨语言支持好,是工业级首选。
- MessagePack:同样高效,比JSON快,序列化后的数据也比JSON小。
- JSON (Newtonsoft.Json/System.Text.Json):可读性好,调试方便,但性能和体积是短板,适合对性能要求不高的配置信息传输。
选择哪一种,取决于你的团队技术栈和项目需求。