经验首页 前端设计 程序设计 Java相关 移动开发 数据库/运维 软件/图像 大数据/云计算 其他经验
当前位置:技术经验 » 程序设计 » 编程经验 » 查看文章
基于webapi的websocket聊天室(四)
来源:cnblogs  作者:ggtc  时间:2024/5/21 8:50:34  对本文有异议

上一篇实现了多聊天室。这一片要继续改进的是实现收发文件,以及图片显示。

效果

image

问题

websocket本身就是二进制传输。文件刚好也是二进制存储的。
文件本身的传输问题不太,但是需要传输文件元数据,比如文件名和扩展名之类的。这很必要,如果我们想知道怎么展示这个文件的话。比如这个文件是图片还是word?或者是个exe?
有两种解决办法

  • 第一种是先发送文件元数据,在发送文件二进制数据。
  • 第二种则是在websocket上定义一个文件传输协议,将文件元数据和文件二进制数据打包在一个二进制消息中发送,在服务器解析这个二进制数据。

第一种方法很简单,只是服务器至少要接受两次消息,才能完成一个文件发送。第二种方法则能通过一次消息发送传输文件。
我采用第二种方法。

传输协议

在引入文件传输的要求后,我发现简单的文本传输也不能满足了,而是需要商定好的格式化的文本,比如json文本。
要不然客户端怎么知道是要显示一个文件下载链接而不是是普通消息文本?这就需要一个type指定。
由于图片是直接显示,文件是下载。客户端收到的又只是一个字节流,客户端怎么知道对应动作?
所以最好统一使用websocket二进制传输作为聊天室数据传输的方式。
这就需要一个简单的协议了。

  • 普通消息,消息类型用message
  • 发送图片,广播图片二进制,要和普通字节流区分,消息类型用image
  • 上传文件,然后广播文件链接,要和普通消息区分,消息类型用file
    比如下载文件,要和普通字节流区分,用文件传输协议。
    我们暂且称这个协议为roomChatProtocal,简称RCP

RCP

  • RCP对象格式
    发布者 类型 数据
    字段 visitor type data
    类型 string message,file,link,image object
  • RCP传输对象格式
    发布者长度 发布者 类型 数据长度 数据
    字节流 1 byte n byte 1 byte 4 byte m byte
  • 传输方法
    对象是程序中用的,字节流是传输时用的。
    在对象与字节流之间应该有两个转换方法 Serialize Deserialize

对应实体

在程序中需要一个对象承载RCP的消息

  1. //RCP.cs
  2. // 聊天室文本广播格式
  3. public struct BroadcastData
  4. {
  5. // 发布者
  6. public string visitor { get; set; }
  7. // 广播文本类型
  8. public BroadcastType type { get; set; }
  9. // 数据
  10. public object data { get; set; }
  11. }
  12. // 广播文本类型
  13. public enum BroadcastType:byte
  14. {
  15. // 发言
  16. message,
  17. // 文件
  18. file,
  19. // 链接
  20. link,
  21. // 图片
  22. image
  23. }

对应实体传输方法

在使用RCP时需要用特定的序列化和反序列化方法来解析RCP对象

  1. //RCP.cs
  2. // 聊天室文本广播格式
  3. public struct BroadcastData
  4. {
  5. //...属性
  6. // 序列化对象
  7. public static byte[] Serialize(BroadcastData cascade){}
  8. // 反序列化对象
  9. public static BroadcastData Deserialize(ArraySegment<byte> data){}
  10. }

type协议

type指示了接收端怎么处理消息。但接收端不仅要知道怎么处理消息,还需要获得正确的能够处理的消息。
所以,每种type还应该有一个对应的消息格式。data字段应遵循这种格式

  • message
    消息长度 消息
    4 byte n byte
  • file
    文件名长度 文件名 文件长度 文件链接 文件内容
    1 byte n byte 4 byte 32 byte m byte
    • 文件名长度
      最大支持256个字节,约60字
    • 文件名
      采用utf8进行编码
    • 文件长度
      最大支持4GB文件传输
    • 文件链接
      ASCll编码,32位UUID
    • 举例
      比如传输一张名为boom.png的图片,其大小为100KB
      那么要传输的二进制数据如下
      文件名长度 文件名 文件长度 文件链接 文件内容
      0x08 0x62 6f 6f 6d 2e 70 6e 67 0x00 01 90 00 32 byte 102400 byte
  • link
    文件名长度 文件名 文件大小 文件链接
    1 byte n byte 4 byte 32 byte
  • image
    图片名长度 图片名 图片长度 图片
    1 byte n byte 4 byte m byte

对应处理方法

  1. //RCP.cs
  2. public class RCP
  3. {
  4. // 创建消息的RCP传输对象
  5. public static BroadcastData Message(string visitor, string message){}
  6. // 解析RCP传输对象的消息
  7. public static string MessageResolve(BroadcastData broadcastData){}
  8. // 创建文件的RCP传输对象
  9. public static BroadcastData File((string fileName,string id, byte[] fileBuffer) file){}
  10. // 解析RCP传输对象中的文件
  11. public static (string fileName, string extension,string id, byte[] buffer) FileResolve(BroadcastData broadcastData){}
  12. // 创建链接的RCP传输对象
  13. public static BroadcastData Link(string visitor, (string fileName, int fileSize, string id) file){}
  14. // 解析RCP传输对象中的链接
  15. public static (string fileName,string id, int fileSize) LinkResolve(BroadcastData broadcastData){}
  16. // 创建图片的RCP传输对象
  17. public static BroadcastData Image(string visitor, string imageName, byte[] image){}
  18. // 解析RCP传输对象中的图片
  19. public static (string imageName, byte[] buffer) ImageResolve(BroadcastData broadcastData){}
  20. }

聊天室改造

  • 首先需要改造一下类型
  1. //WebSocketChatRoom.cs
  2. // 游客
  3. public class RoomVisitor
  4. {
  5. public WebSocket Web { get; set; }
  6. public string Name { get; set; }
  7. public string Id { get; set; }
  8. public visitorType type { get; set; }
  9. }
  10. // 游客类型
  11. public enum visitorType:byte
  12. {
  13. // 聊天室
  14. room,
  15. // 游客
  16. visitor
  17. }
  • 核心方法
    然后是我们的使用了协议后的核心方法,解析消息,然后根据消息类型执行相应分支。
    协议只规定了消息,没规定接受到消息后的动作。
    客户端和服务器段接收到同一类型的消息时,显然有不同动作。
    message file link image
    服务器端 广播 暂存,构造链接,广播链接 单播文件 广播
    客户端 显示 下载 构造下载链接 构造图片显示

所以在这个方法中我们来定义接收到不同类型消息时服务器端的动作

  1. /// <summary>
  2. /// 处理二进制数据
  3. /// </summary>
  4. /// <param name="result"></param>
  5. /// <param name="visitor"></param>
  6. /// <returns></returns>
  7. public async Task handleBytes((ArraySegment<byte> bytes, WebSocketMessageType MessageType) result,RoomVisitor visitor)
  8. {
  9. BroadcastData recivedData = BroadcastData.Deserialize(result.bytes);
  10. BroadcastData data;
  11. switch (recivedData.type)
  12. {
  13. case BroadcastType.message://广播消息
  14. await Broadcast(visitor, recivedData);
  15. break;
  16. case BroadcastType.file://文件解析,暂存,广播链接
  17. (string fileName, string extension,string id, byte[] buffer) resoved = RCP.FileResolve(recivedData);
  18. await AcceptFile(resoved);
  19. data = RCP.Link(visitor.Name, ($"{resoved.fileName}.{resoved.extension}", resoved.buffer.Length, resoved.id));
  20. await Broadcast(visitor, data);
  21. break;
  22. case BroadcastType.link://文件下载
  23. (string fileName, string id, int fileSize) resolved = RCP.LinkResolve(recivedData);
  24. (string fileName,string id, byte[] fileBuffer) linkFile =await ReadLinkFile(resolved);
  25. data = RCP.File(linkFile);
  26. await Unicast(visitor, data);
  27. break;
  28. case BroadcastType.image://图片转发
  29. await Broadcast(visitor, recivedData);
  30. break;
  31. default:
  32. await Broadcast(visitor, new BroadcastData() { type = BroadcastType.message, data = "暂时不支持此消息类型" });
  33. break;
  34. }
  35. }

主要就是进行了消息的解析,以及调用了RCPtype的的4组解析方法。

  • 需要用到的其他方法
  1. //WebSocketChatRoom.cs
  2. // 广播
  3. public async Task Broadcast(RoomVisitor visitor,BroadcastData broadcastData){}
  4. // 单播
  5. public async Task Unicast(RoomVisitor visitor, BroadcastData broadcastData){}
  6. // 多次接受消息
  7. public async Task<(ArraySegment<byte> bytes, WebSocketMessageType MessageType)> GetBytes(WebSocket client, byte[] defaultBuffer){}
  8. // 暂存在服务器,并返回
  9. public async Task AcceptFile((string fileName, string extension,string id, byte[] buffer) file){}
  10. // 读取暂存在服务器的文件
  11. public async Task<(string fileName, string id, byte[] fileBuffer)> ReadLinkFile((string fileName, string id, int fileSize) link){}

完整代码

WebSocketChatRoom.cs
  1. /// <summary>
  2. /// 聊天室
  3. /// </summary>
  4. public class WebSocketChatRoom
  5. {
  6. /// <summary>
  7. /// 成员
  8. /// </summary>
  9. public ConcurrentDictionary<string, RoomVisitor> clients=new ConcurrentDictionary<string, RoomVisitor>();
  10. private string _roomName;
  11. public string roomName {
  12. get { return _roomName; }
  13. set {
  14. _roomName = value;
  15. if (room != null)
  16. {
  17. room.Name = value;
  18. }
  19. else
  20. {
  21. room = new RoomVisitor() { Name = value,type=visitorType.room };
  22. }
  23. }
  24. }
  25. public RoomVisitor room { get; set; }
  26. public WebSocketChatRoom()
  27. {
  28. }
  29. public async Task HandleContext(HttpContext context,WebSocket client)
  30. {
  31. //游客加入聊天室
  32. var visitor = new RoomVisitor() { Id= System.Guid.NewGuid().ToString("N"), Name = $"游客_{clients.Count + 1}", Web = client,type= visitorType.visitor };
  33. clients.TryAdd(visitor.Id, visitor);
  34. //广播游客加入聊天室
  35. await Broadcast(room, RCP.Message(room.Name, $"{visitor.Name}加入聊天室"));
  36. //消息缓冲区。每个连接分配400字节,100个汉字的内存
  37. var defaultBuffer = new byte[400];
  38. //消息循环
  39. while (!client.CloseStatus.HasValue)
  40. {
  41. try
  42. {
  43. var bytesResult = await GetBytes(client, defaultBuffer);
  44. if (bytesResult.MessageType == WebSocketMessageType.Text)
  45. {
  46. //await Cascade(visitor,CascadeMeaasge(visitor,UTF8Encoding.UTF8.GetString(bytesResult.bytes.Array, 0, bytesResult.bytes.Count)));
  47. }
  48. else if (bytesResult.MessageType == WebSocketMessageType.Binary)
  49. {
  50. await handleBytes(bytesResult, visitor);
  51. }
  52. }
  53. catch (Exception e)
  54. {
  55. }
  56. }
  57. //广播游客退出
  58. await Broadcast(room, RCP.Message(room.Name, $"{visitor.Name}退出聊天室"));
  59. await client.CloseAsync(
  60. client.CloseStatus!.Value,
  61. client.CloseStatusDescription,
  62. CancellationToken.None);
  63. clients.TryRemove(visitor.Id, out RoomVisitor v);
  64. }
  65. /// <summary>
  66. /// 广播
  67. /// </summary>
  68. /// <param name="visitor"></param>
  69. /// <param name="broadcastData"></param>
  70. /// <returns></returns>
  71. public async Task Broadcast(RoomVisitor visitor,BroadcastData broadcastData)
  72. {
  73. broadcastData.visitor = visitor.Name;
  74. foreach (var other in clients)
  75. {
  76. if (visitor != null)
  77. {
  78. if (other.Key == visitor.Id)
  79. {
  80. continue;
  81. }
  82. }
  83. var buffer = BroadcastData.Serialize(broadcastData);
  84. if (other.Value.Web.State == WebSocketState.Open)
  85. {
  86. await other.Value.Web.SendAsync(buffer, WebSocketMessageType.Binary, true, CancellationToken.None);
  87. }
  88. }
  89. }
  90. /// <summary>
  91. /// 单播
  92. /// </summary>
  93. /// <param name="visitor"></param>
  94. /// <param name="broadcastData"></param>
  95. /// <returns></returns>
  96. public async Task Unicast(RoomVisitor visitor, BroadcastData broadcastData)
  97. {
  98. broadcastData.visitor = visitor.Name;
  99. var buffer = BroadcastData.Serialize(broadcastData);
  100. if (visitor.Web.State == WebSocketState.Open)
  101. {
  102. await visitor.Web.SendAsync(buffer, WebSocketMessageType.Binary, true, CancellationToken.None);
  103. }
  104. }
  105. /// <summary>
  106. /// 多次接受消息
  107. /// </summary>
  108. /// <param name="client"></param>
  109. /// <param name="defaultBuffer"></param>
  110. /// <returns></returns>
  111. public async Task<(ArraySegment<byte> bytes, WebSocketMessageType MessageType)> GetBytes(WebSocket client, byte[] defaultBuffer)
  112. {
  113. int totalBytesReceived = 0;
  114. int bufferSize = 1024 * 4; // 可以设为更大,视实际情况而定
  115. byte[] buffer = new byte[bufferSize];
  116. WebSocketReceiveResult result;
  117. do
  118. {
  119. if (totalBytesReceived == buffer.Length) // 如果缓冲区已满,扩展它
  120. {
  121. Array.Resize(ref buffer, buffer.Length + bufferSize);
  122. }
  123. var segment = new ArraySegment<byte>(buffer, totalBytesReceived, buffer.Length - totalBytesReceived);
  124. //!result.EndOfMessage时buffer不一定会被填满
  125. result = await client.ReceiveAsync(segment, CancellationToken.None);
  126. totalBytesReceived += result.Count;
  127. } while (!result.EndOfMessage);
  128. if (result.MessageType == WebSocketMessageType.Close)
  129. {
  130. return (new ArraySegment<byte>(buffer, 0, totalBytesReceived), WebSocketMessageType.Close);
  131. }
  132. return (new ArraySegment<byte>(buffer, 0, totalBytesReceived), result.MessageType);
  133. }
  134. /// <summary>
  135. /// 暂存在服务器,并返回
  136. /// </summary>
  137. /// <param name="buffer"></param>
  138. /// <returns></returns>
  139. public async Task AcceptFile((string fileName, string extension,string id, byte[] buffer) file)
  140. {
  141. string fileName = $"{file.fileName}-{file.id}.{file.extension}";
  142. //每个聊天室一个文件夹
  143. string fullName = $@"C:\ChatRoom\{room.Name}\{fileName}";
  144. string directoryPath = Path.GetDirectoryName(fullName);
  145. if (!Directory.Exists(directoryPath))
  146. {
  147. Directory.CreateDirectory(directoryPath);
  148. }
  149. await File.WriteAllBytesAsync(fullName, file.buffer);
  150. }
  151. /// <summary>
  152. /// 读取暂存在服务器的文件
  153. /// </summary>
  154. /// <param name="link"></param>
  155. /// <returns></returns>
  156. public async Task<(string fileName, string id, byte[] fileBuffer)> ReadLinkFile((string fileName, string id, int fileSize) link)
  157. {
  158. string fullName = $@"C:\ChatRoom\{room.Name}\{link.fileName.Split('.')[0]}-{link.id}.{link.fileName.Split('.')[1]}";
  159. byte[] buffer = await File.ReadAllBytesAsync(fullName);
  160. return (link.fileName,link.id, fileBuffer:buffer);
  161. }
  162. /// <summary>
  163. /// 处理二进制数据
  164. /// </summary>
  165. /// <param name="result"></param>
  166. /// <param name="visitor"></param>
  167. /// <returns></returns>
  168. public async Task handleBytes((ArraySegment<byte> bytes, WebSocketMessageType MessageType) result,RoomVisitor visitor)
  169. {
  170. BroadcastData recivedData = BroadcastData.Deserialize(result.bytes);
  171. BroadcastData data;
  172. switch (recivedData.type)
  173. {
  174. case BroadcastType.message://广播消息
  175. await Broadcast(visitor, recivedData);
  176. break;
  177. case BroadcastType.file://文件解析,暂存,广播链接
  178. (string fileName, string extension,string id, byte[] buffer) resoved = RCP.FileResolve(recivedData);
  179. await AcceptFile(resoved);
  180. data = RCP.Link(visitor.Name, ($"{resoved.fileName}.{resoved.extension}", resoved.buffer.Length, resoved.id));
  181. await Broadcast(visitor, data);
  182. break;
  183. case BroadcastType.link://文件下载
  184. (string fileName, string id, int fileSize) resolved = RCP.LinkResolve(recivedData);
  185. (string fileName,string id, byte[] fileBuffer) linkFile =await ReadLinkFile(resolved);
  186. data = RCP.File(linkFile);
  187. await Unicast(visitor, data);
  188. break;
  189. case BroadcastType.image://图片转发
  190. await Broadcast(visitor, recivedData);
  191. break;
  192. default:
  193. await Broadcast(visitor, new BroadcastData() { type = BroadcastType.message, data = "暂时不支持此消息类型" });
  194. break;
  195. }
  196. }
  197. }
  198. /// <summary>
  199. /// 游客
  200. /// </summary>
  201. public class RoomVisitor
  202. {
  203. public WebSocket Web { get; set; }
  204. public string Name { get; set; }
  205. public string Id { get; set; }
  206. public visitorType type { get; set; }
  207. }
  208. /// <summary>
  209. /// 游客类型
  210. /// </summary>
  211. public enum visitorType:byte
  212. {
  213. /// <summary>
  214. /// 聊天室
  215. /// </summary>
  216. room,
  217. /// <summary>
  218. /// 游客
  219. /// </summary>
  220. visitor
  221. }
RCP.cs
  1. /// <summary>
  2. /// RoomChatProtocal
  3. /// 聊天室数据传输协议
  4. /// </summary>
  5. public class RCP
  6. {
  7. /// <summary>
  8. /// 创建消息的RCP传输对象
  9. /// </summary>
  10. /// <param name="visitor"></param>
  11. /// <param name="message"></param>
  12. public static BroadcastData Message(string visitor, string message)
  13. {
  14. return new BroadcastData() { visitor = visitor, type = BroadcastType.message, data = message };
  15. }
  16. /// <summary>
  17. /// 解析RCP传输对象的消息
  18. /// </summary>
  19. /// <param name="broadcastData"></param>
  20. /// <returns></returns>
  21. public static string MessageResolve(BroadcastData broadcastData)
  22. {
  23. return broadcastData.data?.ToString()??"";
  24. }
  25. /// <summary>
  26. /// 创建文件的RCP传输对象
  27. /// </summary>
  28. /// <returns></returns>
  29. public static BroadcastData File((string fileName,string id, byte[] fileBuffer) file)
  30. {
  31. BroadcastData data = new BroadcastData();
  32. data.type = BroadcastType.file;
  33. int fileNameLength = UTF8Encoding.UTF8.GetByteCount(file.fileName);
  34. byte[] buffer = new byte[1 + fileNameLength + 4 + 32 + file.fileBuffer.Length];
  35. BinaryWriter writer = new BinaryWriter(new MemoryStream(buffer));
  36. writer.Write((byte)fileNameLength);
  37. writer.Write(UTF8Encoding.UTF8.GetBytes(file.fileName));
  38. writer.Write(file.fileBuffer.Length);
  39. writer.Write(ASCIIEncoding.ASCII.GetBytes(file.id));
  40. writer.Write(file.fileBuffer);
  41. data.data = buffer;
  42. return data;
  43. }
  44. /// <summary>
  45. /// 解析RCP传输对象中的文件
  46. /// </summary>
  47. /// <param name="broadcastData"></param>
  48. /// <returns></returns>
  49. /// <exception cref="NotImplementedException"></exception>
  50. public static (string fileName, string extension,string id, byte[] buffer) FileResolve(BroadcastData broadcastData)
  51. {
  52. BinaryReader reader = new BinaryReader(new MemoryStream((byte[])broadcastData.data));
  53. int fileNameLength = reader.ReadByte() & 0x000000FF;
  54. string fileExtensionName = UTF8Encoding.UTF8.GetString(reader.ReadBytes(fileNameLength));
  55. string fileName= fileExtensionName.Split('.')[0];
  56. string extension= fileExtensionName.Split(".")[1];
  57. int fileLength=reader.ReadInt32();
  58. string id = ASCIIEncoding.ASCII.GetString(reader.ReadBytes(32));
  59. byte[] buffer= reader.ReadBytes(fileLength);
  60. return (fileName, extension, id, buffer);
  61. }
  62. /// <summary>
  63. /// 创建链接的RCP传输对象
  64. /// </summary>
  65. public static BroadcastData Link(string visitor, (string fileName, int fileSize, string id) file)
  66. {
  67. int fileNameLength = UTF8Encoding.UTF8.GetByteCount(file.fileName);
  68. byte[] buffer = new byte[1 + fileNameLength + 32 + 4];
  69. BinaryWriter writer = new BinaryWriter(new MemoryStream(buffer));
  70. writer.Write((byte)fileNameLength);
  71. writer.Write(UTF8Encoding.UTF8.GetBytes(file.fileName));
  72. writer.Write(file.fileSize);
  73. writer.Write(ASCIIEncoding.ASCII.GetBytes(file.id));
  74. return new BroadcastData()
  75. {
  76. visitor = visitor,
  77. type = BroadcastType.link,
  78. data = buffer
  79. };
  80. }
  81. /// <summary>
  82. /// 解析RCP传输对象中的链接
  83. /// </summary>
  84. /// <param name="broadcastData"></param>
  85. /// <returns></returns>
  86. public static (string fileName,string id, int fileSize) LinkResolve(BroadcastData broadcastData)
  87. {
  88. BinaryReader reader = new BinaryReader(new MemoryStream((byte[])broadcastData.data));
  89. int fileNameLength=reader.ReadByte() & 0x000000FF;
  90. string fileName= UTF8Encoding.UTF8.GetString(reader.ReadBytes(fileNameLength));
  91. int fileLength=reader.ReadInt32();
  92. string id=ASCIIEncoding.ASCII.GetString(reader.ReadBytes(32));
  93. return (fileName, id, fileLength);
  94. }
  95. /// <summary>
  96. /// 创建图片的RCP传输对象
  97. /// </summary>
  98. /// <param name="visitor"></param>
  99. /// <param name="imageName"></param>
  100. /// <param name="image"></param>
  101. /// <returns></returns>
  102. public static BroadcastData Image(string visitor, string imageName, byte[] image)
  103. {
  104. BroadcastData data = new BroadcastData();
  105. data.visitor = visitor;
  106. data.type = BroadcastType.image;
  107. int fileNameLength = UTF8Encoding.UTF8.GetByteCount(imageName);
  108. byte[] buffer = new byte[1 + fileNameLength + 4 + 32 + image.Length];
  109. BinaryWriter writer = new BinaryWriter(new MemoryStream(buffer));
  110. writer.Write((byte)fileNameLength);
  111. writer.Write(UTF8Encoding.UTF8.GetBytes(imageName));
  112. writer.Write(image.Length);
  113. writer.Write(image);
  114. data.data = buffer;
  115. return data;
  116. }
  117. /// <summary>
  118. /// 解析RCP传输对象中的图片
  119. /// </summary>
  120. /// <param name="broadcastData"></param>
  121. /// <returns></returns>
  122. public static (string imageName, byte[] buffer) ImageResolve(BroadcastData broadcastData)
  123. {
  124. BinaryReader reader = new BinaryReader(new MemoryStream((byte[])broadcastData.data));
  125. int imageNameLength = reader.ReadByte() & 0x000000FF;
  126. string imageExtensionName = UTF8Encoding.UTF8.GetString(reader.ReadBytes(imageNameLength));
  127. int imageLength = reader.ReadInt32();
  128. byte[] buffer = reader.ReadBytes(imageLength);
  129. return (imageExtensionName, buffer);
  130. }
  131. }
  132. /// <summary>
  133. /// RCP传输对象
  134. /// </summary>
  135. public struct BroadcastData
  136. {
  137. /// <summary>
  138. /// 发布者
  139. /// </summary>
  140. public string visitor { get; set; }
  141. /// <summary>
  142. /// 广播文本类型
  143. /// </summary>
  144. public BroadcastType type { get; set; }
  145. /// <summary>
  146. /// 数据
  147. /// </summary>
  148. public object data { get; set; }
  149. /// <summary>
  150. /// 序列化对象
  151. /// </summary>
  152. /// <param name="broadcast"></param>
  153. /// <returns></returns>
  154. /// <exception cref="Exception"></exception>
  155. public static byte[] Serialize(BroadcastData broadcast)
  156. {
  157. using (MemoryStream memoryStream = new MemoryStream())
  158. {
  159. //utf8编码字符串
  160. using (BinaryWriter writer = new BinaryWriter(memoryStream))
  161. {
  162. //visitor长度,1字节
  163. writer.Write((byte)UTF8Encoding.UTF8.GetByteCount(broadcast.visitor));
  164. //visitor,n字节
  165. writer.Write(UTF8Encoding.UTF8.GetBytes(broadcast.visitor));
  166. //type,一字节
  167. writer.Write((byte)broadcast.type);
  168. //data,要么是字符串,要么是数组
  169. if (broadcast.data is string stringData)
  170. {
  171. //int长度,4字节
  172. writer.Write((UTF8Encoding.UTF8.GetByteCount(stringData)));
  173. //data内容,m字节
  174. writer.Write(UTF8Encoding.UTF8.GetBytes(stringData));
  175. }
  176. else if (broadcast.data is ArraySegment<byte> ArraySegmentData)
  177. {
  178. //int长度,4字节
  179. writer.Write(ArraySegmentData.Count);
  180. //data内容,m字节
  181. writer.Write(ArraySegmentData);
  182. }
  183. else if (broadcast.data is byte[] bytesData)
  184. {
  185. //int长度,4字节
  186. writer.Write(bytesData.Length);
  187. //data内容,m字节
  188. writer.Write(bytesData);
  189. }
  190. else
  191. {
  192. throw new Exception("不支持的data类型,只能是string或ArraySegment<byte>");
  193. }
  194. }
  195. return memoryStream.ToArray();
  196. }
  197. }
  198. /// <summary>
  199. /// 反序列化对象
  200. /// </summary>
  201. /// <param name="data"></param>
  202. /// <returns></returns>
  203. public static BroadcastData Deserialize(ArraySegment<byte> data)
  204. {
  205. BroadcastData broadcastData = new BroadcastData();
  206. BinaryReader br = new BinaryReader(new MemoryStream(data.Array!));
  207. int visitorLength = br.ReadByte() & 0x000000FF;
  208. broadcastData.visitor = UTF8Encoding.UTF8.GetString(br.ReadBytes(visitorLength));
  209. broadcastData.type = (BroadcastType)br.ReadByte();
  210. int dataLength = br.ReadInt32();
  211. broadcastData.data = br.ReadBytes(dataLength);
  212. return broadcastData;
  213. }
  214. }
  215. /// <summary>
  216. /// 消息类型
  217. /// </summary>
  218. public enum BroadcastType : byte
  219. {
  220. /// <summary>
  221. /// 发言
  222. /// </summary>
  223. message,
  224. /// <summary>
  225. /// 文件传输
  226. /// </summary>
  227. file,
  228. /// <summary>
  229. /// 文件下载链接
  230. /// </summary>
  231. link,
  232. /// <summary>
  233. /// 图片查看
  234. /// </summary>
  235. image
  236. }

web客户端

我简单写了个web客户端。也实现了RCP

chatRoomClient.html
  1. <!DOCTYPE html>
  2. <html lang="en">
  3. <head>
  4. <meta charset="UTF-8">
  5. <meta name="viewport" content="width=device-width, initial-scale=1.0">
  6. <title>聊天室</title>
  7. </head>
  8. <style>
  9. html{
  10. height: calc(100% - 16px);
  11. margin: 8px;
  12. }
  13. body{
  14. height: 100%;
  15. margin: 0;
  16. }
  17. </style>
  18. <body>
  19. <div style="height: 100%;display: grid;grid-template: auto 1fr 100px/1fr;row-gap: 8px;">
  20. <div style="grid-area: 1/1/2/2;">
  21. <div style="display: grid;grid: 1fr/1fr 100px;column-gap: 8px;">
  22. <div style="grid-area: 1/1/1/2;display: flex;justify-content: end;">
  23. <label>房间</label>
  24. <input style="width: 300px;" value="ws://localhost:5234/chat/房间号" name="room" oninput="changeroom(event)"/>
  25. </div>
  26. <button style="grid-area: 1/2/1/3;" onclick="connectRoom()" id="open">打开连接</button>
  27. </div>
  28. </div>
  29. <div style="grid-area: 2/1/3/2;background-color: #eeeeee;overflow-y: auto;" id="chatMessages"></div>
  30. <div style="grid-area: 3/1/4/2;position: relative;">
  31. <div class="toolbar">
  32. <button onclick="sendimage()">图片</button>
  33. <button onclick="sendFile()">文件</button>
  34. </div>
  35. <textarea style="width: calc(100% - 20px);padding: 5px 10px;height: calc(100% - 33px);font-size: 16px;" id="msg"></textarea>
  36. <button style="position: absolute;right: 10px;bottom: 5px;" onclick="sendmsg()">发送</button>
  37. </div>
  38. </div>
  39. <script>
  40. var socket;
  41. var isopen=false;
  42. function changeroom(e){
  43. document.title=`聊天室-${e.srcElement.value.split('/').reverse()[0]}`;
  44. }
  45. function sendmsg(){
  46. var msg=document.getElementById('msg').value;
  47. if(msg=='')return
  48. if(!isopen)return
  49. if(isopen){
  50. var broadcastData=RCP.Message(msg);
  51. var buffer=BroadcastData.Serialize(broadcastData);
  52. socket.send(buffer);
  53. broadcastData.visitor='我';
  54. broadcastData.data=RCP.MessageResolve(broadcastData);
  55. appendMsg(broadcastData,'right');
  56. document.getElementById('msg').value='';
  57. }
  58. }
  59. function sendimage(){
  60. if(!isopen)return;
  61. var input=document.createElement('input');
  62. input.type='file';
  63. input.accept='image/jpeg,image/png'
  64. input.click();
  65. input.onchange=e=>{
  66. if(e.srcElement.files.length==0)return;
  67. var image=e.srcElement.files[0];
  68. var fileReader=new FileReader();
  69. fileReader.onload=()=>{
  70. var broadcastData= RCP.Image(image.name,fileReader.result);
  71. var buffer=BroadcastData.Serialize(broadcastData);
  72. socket.send(buffer);
  73. broadcastData.visitor='我';
  74. var resolvedImage=RCP.ImageResolve(broadcastData);
  75. var extension=resolvedImage.imageName.split('.')[resolvedImage.imageName.split('.').length-1];
  76. resolvedImage.buffer=createDataURL(extension,resolvedImage.buffer);
  77. broadcastData.data=resolvedImage.buffer;
  78. appendImage(broadcastData,'right');
  79. }
  80. fileReader.readAsArrayBuffer(image);
  81. }
  82. }
  83. function sendFile(){
  84. if(!isopen)return;
  85. var input=document.createElement('input');
  86. input.type='file';
  87. input.click();
  88. input.onchange=e=>{
  89. if(e.srcElement.files.length==0)return;
  90. var file=e.srcElement.files[0];
  91. var fileReader=new FileReader();
  92. fileReader.onload=()=>{
  93. var broadcastData= RCP.File(file.name,fileReader.result);
  94. var buffer=BroadcastData.Serialize(broadcastData);
  95. socket.send(buffer);
  96. broadcastData.visitor='我';
  97. var resolve=RCP.FileResolve(broadcastData);
  98. broadcastData.data={fileName:`${resolve.fileName}.${resolve.extension}`,id:resolve.id,fileSize:resolve.buffer.length};
  99. appendLink(broadcastData,'right');
  100. }
  101. fileReader.readAsArrayBuffer(file);
  102. }
  103. }
  104. function downloadLink(fileName,id,fileSize){
  105. var broadcastData= RCP.Link(fileName,id,fileSize);
  106. var buffer=BroadcastData.Serialize(broadcastData);
  107. socket.send(buffer);
  108. }
  109. function downloadFile(fileInfo){
  110. const url=createDataURL(fileInfo.extension,fileInfo.buffer);
  111. var download=document.createElement('a');
  112. download.href=url;
  113. download.download=`${fileInfo.fileName}.${fileInfo.extension}`;
  114. download.click();
  115. }
  116. function connectRoom(){
  117. if (isopen==true) {
  118. socket.close();
  119. return;
  120. }
  121. var route=document.getElementsByName('room')[0].value;
  122. try {
  123. socket=new WebSocket(route);
  124. } catch (error) {
  125. console.log(error);
  126. isopen=false;
  127. document.getElementById('open').innerText='打开连接';
  128. return
  129. }
  130. socket.addEventListener('open', (event) => {
  131. isopen=true;
  132. document.getElementById('open').innerText='关闭连接'
  133. });
  134. socket.addEventListener('message', (event) => {
  135. // 处理接收到的消息
  136. console.log('Received:', event.data);
  137. var fileReader = new FileReader();
  138. fileReader.onload=function(event){
  139. arrayBufferNew = event.target.result;
  140. // uint8ArrayNew = new Uint8Array(arrayBufferNew);
  141. handleBytes(arrayBufferNew);
  142. }
  143. fileReader.readAsArrayBuffer(event.data);
  144. });
  145. socket.addEventListener('close',event=>{
  146. isopen=false;
  147. document.getElementById('open').innerText='打开连接';
  148. })
  149. }
  150. function handleBytes(arrayBufferNew){
  151. var broadcastData=BroadcastData.Deserialize(arrayBufferNew);
  152. switch (broadcastData.type) {
  153. case BroadcastType.message:
  154. var msg=RCP.MessageResolve(broadcastData);
  155. broadcastData.data=msg;
  156. appendMsg(broadcastData);
  157. break;
  158. case BroadcastType.image:
  159. var image=RCP.ImageResolve(broadcastData);
  160. var extension=image.imageName.split('.')[image.imageName.split('.').length-1];
  161. image.buffer=createDataURL(extension,image.buffer);
  162. broadcastData.data=image.buffer;
  163. appendImage(broadcastData);
  164. break;
  165. case BroadcastType.link:
  166. var linkInfo=RCP.LinkResolve(broadcastData);
  167. broadcastData.data=linkInfo;
  168. appendLink(broadcastData);
  169. break;
  170. case BroadcastType.file:
  171. var fileInfo=RCP.FileResolve(broadcastData);
  172. downloadFile(fileInfo);
  173. break;
  174. default:
  175. break;
  176. }
  177. }
  178. function appendMsg(broadcastData,dock){
  179. var chatMessages = document.getElementById('chatMessages');
  180. if(dock!='right'){
  181. chatMessages.innerHTML+=`
  182. <div style="padding:10px;">
  183. <div>${broadcastData.visitor}</div>
  184. <div style="padding:0 50px;">
  185. <div style="display:inline-block;padding:5px;border-radius:5px;background-color:#ffffff;">${broadcastData.data}</div>
  186. </div>
  187. </div>`;
  188. }
  189. else{
  190. chatMessages.innerHTML+=`
  191. <div style="padding:10px;display:flex;flex-direction: column;align-items: flex-end;">
  192. <div>${broadcastData.visitor}</div>
  193. <div style="padding:0 50px;">
  194. <div style="display:inline-block;padding:5px;border-radius:5px;background-color:#ffffff;">${broadcastData.data}</div>
  195. </div>
  196. </div>`;
  197. }
  198. // 使用 scrollIntoView 方法将底部元素滚动到可见区域
  199. chatMessages.lastChild.scrollIntoView({ behavior: 'smooth', block: 'end', inline: 'nearest' });
  200. }
  201. function appendImage(broadcastData,dock){
  202. var chatMessages = document.getElementById('chatMessages');
  203. if(dock!='right'){
  204. chatMessages.innerHTML+=`
  205. <div style="padding:10px;">
  206. <div>${broadcastData.visitor}</div>
  207. <div style="padding:0 50px;">
  208. <div style="display:inline-block;padding:5px;border-radius:5px;background-color:#ffffff;"><img style="height:100px;" src="${broadcastData.data}"></img></div>
  209. </div>
  210. </div>`;
  211. }
  212. else{
  213. chatMessages.innerHTML+=`
  214. <div style="padding:10px;display:flex;flex-direction: column;align-items: flex-end;">
  215. <div>${broadcastData.visitor}</div>
  216. <div style="padding:0 50px;">
  217. <div style="display:inline-block;padding:5px;border-radius:5px;background-color:#ffffff;"><img style="height:100px;" src="${broadcastData.data}"></img></div>
  218. </div>
  219. </div>`;
  220. }
  221. // 使用 scrollIntoView 方法将底部元素滚动到可见区域
  222. chatMessages.lastChild.scrollIntoView({ behavior: 'smooth', block: 'end', inline: 'nearest' });
  223. }
  224. function appendLink(broadcastData,dock){
  225. var chatMessages = document.getElementById('chatMessages');
  226. if(dock!='right'){
  227. chatMessages.innerHTML+=`
  228. <div style="padding:10px;">
  229. <div>${broadcastData.visitor}</div>
  230. <div style="padding:0 50px;">
  231. <div style="display:inline-block;padding:5px;border-radius:5px;background-color:#ffffff;">
  232. <div style="display:grid;grid-template:2fr 1fr/1fr/auto;row-gap:5px;">
  233. <div style="grid-area:1/1/2/2;font-size:18px;max-width:300px;padding:0 5px;">${broadcastData.data.fileName}</div>
  234. <div style="grid-area:2/1/3/2;font-size:12px;padding:0 5px;">${broadcastData.data.fileSize}字节</div>
  235. <div style="grid-area:1/2/3/3;display:flex;align-items:center;padding:0 5px;background-color:lightblue;cursor:pointer;">
  236. <div style="display:inline-block;" onclick="downloadLink('${broadcastData.data.fileName}','${broadcastData.data.id}',${broadcastData.data.fileSize})">下载?</div>
  237. </div>
  238. </div>
  239. </div>
  240. </div>
  241. </div>`;
  242. }
  243. else{
  244. chatMessages.innerHTML+=`
  245. <div style="padding:10px;display:flex;flex-direction: column;align-items: flex-end;">
  246. <div>${broadcastData.visitor}</div>
  247. <div style="padding:0 50px;">
  248. <div style="display:inline-block;padding:5px;border-radius:5px;background-color:#ffffff;">
  249. <div style="display:grid;grid-template:2fr 1fr/1fr/auto;row-gap:5px;">
  250. <div style="grid-area:1/1/2/2;font-size:18px;max-width:300px;padding:0 5px;">${broadcastData.data.fileName}</div>
  251. <div style="grid-area:2/1/3/2;font-size:12px;padding:0 5px;">${broadcastData.data.fileSize}字节</div>
  252. <div style="grid-area:1/2/3/3;display:flex;align-items:center;padding:0 5px;background-color:lightgreen;">
  253. <div style="display:inline-block;">上传</div>
  254. </div>
  255. </div>
  256. </div>
  257. </div>
  258. </div>`;
  259. }
  260. // 使用 scrollIntoView 方法将底部元素滚动到可见区域
  261. chatMessages.lastChild.scrollIntoView({ behavior: 'smooth', block: 'end', inline: 'nearest' });
  262. }
  263. function getMIME(params) {
  264. switch (params) {
  265. case 'jpg':
  266. return 'image/jpeg';
  267. case 'jpeg':
  268. return 'image/jpeg';
  269. case 'png':
  270. return 'image/png';
  271. default:
  272. break;
  273. }
  274. }
  275. function createDataURL(extension,buffer){
  276. // 将 ArrayBuffer 包装成 Blob 对象
  277. var MIME = getMIME(extension)
  278. const blob = new Blob([buffer], { type: MIME });
  279. // 使用 URL.createObjectURL() 创建 Blob 对象的 URL
  280. const url = URL.createObjectURL(blob);
  281. return url;
  282. }
  283. </script>
  284. <script>
  285. class BroadcastType{
  286. static message=new Uint8Array([0])[0]
  287. static file=new Uint8Array([1])[0]
  288. static link=new Uint8Array([2])[0]
  289. static image=new Uint8Array([3])[0]
  290. }
  291. class BroadcastData{
  292. visitor;
  293. type;
  294. data;
  295. static Serialize(broadcast){
  296. var writer=new BinaryWriter();
  297. writer.write(new Uint8Array([0]));
  298. writer.write(new Uint8Array([broadcast.type]));
  299. writer.writeInt32(broadcast.data.byteLength);
  300. writer.write(new Uint8Array(broadcast.data));
  301. return writer.toArray();
  302. }
  303. static Deserialize(buffer){
  304. var broadcastData=new BroadcastData();
  305. var reader=new BinaryReader(buffer);
  306. var visitorLength=reader.readByte();
  307. var visitorBytes = reader.readBytes(visitorLength);
  308. broadcastData.visitor = new TextDecoder().decode(visitorBytes);
  309. broadcastData.type=reader.readByte();
  310. var dataLength=reader.readInt32(4);
  311. broadcastData.data = reader.readBytes(dataLength);
  312. return broadcastData;
  313. }
  314. }
  315. class RCP{
  316. static Message(message){
  317. var broadcastData=new BroadcastData();
  318. var coder=new TextEncoder();
  319. broadcastData.type=BroadcastType.message;
  320. var data=coder.encode(message);
  321. broadcastData.data=data;
  322. return broadcastData;
  323. }
  324. static MessageResolve(broadcastData){
  325. return new TextDecoder().decode(broadcastData.data);
  326. }
  327. static Image(imageName,imageBuffer){
  328. var data = new BroadcastData();
  329. data.type=BroadcastType.image;
  330. var imageNameLength=new TextEncoder().encode(imageName).length;
  331. var writer=new BinaryWriter();
  332. writer.write(new Uint8Array([imageNameLength]));
  333. writer.write(new TextEncoder().encode(imageName));
  334. writer.writeInt32(imageBuffer.byteLength);
  335. writer.write(new Uint8Array(imageBuffer));
  336. data.data = writer.toArray();
  337. return data;
  338. }
  339. static ImageResolve(broadcastData){
  340. var data=broadcastData.data
  341. if(broadcastData.data instanceof Uint8Array)
  342. data=data.buffer.slice(data.byteOffset, data.byteOffset + data.byteLength);
  343. var reader=new BinaryReader(data);
  344. var imageNameLength=reader.readByte();
  345. var coder=new TextDecoder();
  346. var imageExtensionName=coder.decode(reader.readBytes(imageNameLength));
  347. var imageLength=reader.readInt32();
  348. var buffer=reader.readBytes(imageLength);
  349. return {imageName:imageExtensionName,buffer:buffer};
  350. }
  351. static File(fileName,fileBuffer){
  352. var data = new BroadcastData();
  353. data.type=BroadcastType.file;
  354. var fileNameLength=new TextEncoder().encode(fileName).length;
  355. var writer=new BinaryWriter();
  356. writer.write(new Uint8Array([fileNameLength]));
  357. writer.write(new TextEncoder().encode(fileName));
  358. writer.writeInt32(fileBuffer.byteLength);
  359. var uuid=this.#generateUUID();
  360. var uint8uuid=this.#asciiToUint8Array(uuid);
  361. writer.write(uint8uuid);
  362. writer.write(new Uint8Array(fileBuffer));
  363. data.data = writer.toArray();
  364. return data;
  365. }
  366. static FileResolve(broadcastData){
  367. var data=broadcastData.data
  368. if(broadcastData.data instanceof Uint8Array)
  369. data=data.buffer.slice(data.byteOffset, data.byteOffset + data.byteLength);
  370. var reader=new BinaryReader(data);
  371. var fileNameLength=reader.readByte();
  372. var coder=new TextDecoder();
  373. var fileExtensionName=coder.decode(reader.readBytes(fileNameLength));
  374. var extension=fileExtensionName.split('.')[fileExtensionName.split('.').length-1];
  375. var fileLength=reader.readInt32();
  376. var linkbyte=reader.readBytes(32);
  377. var link=this.#uint8ArrayToAscii(linkbyte);
  378. var buffer=reader.readBytes(fileLength);
  379. return {fileName:fileExtensionName.replace(`.${extension}`,''),extension:extension,id:link,buffer:buffer}
  380. }
  381. static Link(fileName,id,fileSize){
  382. var data = new BroadcastData();
  383. data.type=BroadcastType.link;
  384. var fileNameLength=new TextEncoder().encode(fileName).length;
  385. var writer=new BinaryWriter();
  386. writer.write(new Uint8Array([fileNameLength]));
  387. writer.write(new TextEncoder().encode(fileName));
  388. writer.writeInt32(fileSize);
  389. var uint8uuid=this.#asciiToUint8Array(id);
  390. writer.write(uint8uuid);
  391. data.data = writer.toArray();
  392. return data;
  393. }
  394. static LinkResolve(broadcastData){
  395. var data=broadcastData.data
  396. if(broadcastData.data instanceof Uint8Array)
  397. data=data.buffer.slice(data.byteOffset, data.byteOffset + data.byteLength);
  398. var reader=new BinaryReader(data);
  399. var fileNameLength=reader.readByte();
  400. var coder=new TextDecoder();
  401. var fileExtensionName=coder.decode(reader.readBytes(fileNameLength));
  402. var fileLength=reader.readInt32();
  403. var linkbyte=reader.readBytes(32);
  404. var link=this.#uint8ArrayToAscii(linkbyte);
  405. return {fileName:fileExtensionName,id:link,fileSize:fileLength};
  406. }
  407. //工具函数
  408. static #generateUUID() {
  409. // 生成随机的 UUID
  410. const uuid = 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function(c) {
  411. const r = Math.random() * 16 | 0;
  412. const v = c === 'x' ? r : (r & 0x3 | 0x8);
  413. return v.toString(16);
  414. });
  415. return uuid.replace(/-/g, ''); // 移除横线,得到 32 位的 UUID
  416. }
  417. static #asciiToUint8Array(str) {
  418. const uint8Array = new Uint8Array(str.length);
  419. for (let i = 0; i < str.length; i++) {
  420. uint8Array[i] = str.charCodeAt(i);
  421. }
  422. return uint8Array;
  423. }
  424. static #uint8ArrayToAscii(uint8Array) {
  425. let asciiString = '';
  426. for (let i = 0; i < uint8Array.length; i++) {
  427. asciiString += String.fromCharCode(uint8Array[i]);
  428. }
  429. return asciiString;
  430. }
  431. }
  432. class BinaryReader {
  433. #position;
  434. #buffer;
  435. #dataView;
  436. constructor(arrayBuffer) {
  437. this.#buffer = arrayBuffer;
  438. this.#position = 0;
  439. this.#dataView=new DataView(arrayBuffer);
  440. }
  441. readByte() {
  442. var value=this.#dataView.getInt8(this.#position,true);
  443. this.#position+=1;
  444. return value;
  445. }
  446. readBytes(length) {
  447. var bytes = new Uint8Array(this.#buffer, this.#position, length);
  448. this.#position += length;
  449. return bytes;
  450. }
  451. readInt32(){
  452. var value=this.#dataView.getInt32(this.#position,true);
  453. this.#position+=4;
  454. return value;
  455. }
  456. }
  457. class BinaryWriter {
  458. #data;
  459. constructor() {
  460. this.#data = [];
  461. }
  462. // 向流中添加数据
  463. write(chunk) {
  464. for (let i = 0; i < chunk.byteLength; i++) {
  465. this.#data.push(chunk[i]);
  466. }
  467. }
  468. // 将收集到的数据转换为 ArrayBuffer
  469. toArray() {
  470. const buffer = new ArrayBuffer(this.#data.length);
  471. const view = new Uint8Array(buffer);
  472. for (let i = 0; i < this.#data.length; i++) {
  473. view[i] = this.#data[i];
  474. }
  475. return buffer;
  476. }
  477. writeInt32(number){
  478. // 创建一个 ArrayBuffer,大小为 4 字节
  479. const buffer = new ArrayBuffer(4);
  480. // 创建一个 DataView,用于操作 ArrayBuffer
  481. const dataView = new DataView(buffer);
  482. // 将一个数值写入到 DataView 中
  483. dataView.setInt32(0, number, true); // 第二个参数表示字节偏移量,第三个参数表示是否使用小端序(true 表示使用)
  484. // 创建一个 Uint8Array,从 ArrayBuffer 中获取数据
  485. const uint8Array = new Uint8Array(buffer);
  486. this.write(uint8Array);
  487. }
  488. }
  489. </script>
  490. </body>
  491. </html>

原文链接:https://www.cnblogs.com/ggtc/p/18190128

 友情链接:直通硅谷  点职佳  北美留学生论坛

本站QQ群:前端 618073944 | Java 606181507 | Python 626812652 | C/C++ 612253063 | 微信 634508462 | 苹果 692586424 | C#/.net 182808419 | PHP 305140648 | 运维 608723728

W3xue 的所有内容仅供测试,对任何法律问题及风险不承担任何责任。通过使用本站内容随之而来的风险与本站无关。
关于我们  |  意见建议  |  捐助我们  |  报错有奖  |  广告合作、友情链接(目前9元/月)请联系QQ:27243702 沸活量
皖ICP备17017327号-2 皖公网安备34020702000426号