基于webapi的websocket聊天室(四)

webapi,websocket · 浏览次数 : 0

小编点评

代码主要分为以下几个部分: **1. 定义类** * `BroadcastData`类用于存储广播数据,包含类型、数据等信息。 * `RCP`类提供静态方法,用于创建和解析 `BroadcastData` 对象。 * `Image`类提供静态方法,用于创建和解析图像数据。 * `File`类提供静态方法,用于创建和解析文件数据。 * `Link`类提供静态方法,用于创建和解析链接数据。 **2. 工具函数** * `#generateUUID()` 用于生成 32 位的随机 UUID。 * `#asciiToUint8Array()` 用于将 ASCII 字符串转换为 Uint8Array。 * `#uint8ArrayToAscii()` 用于将 Uint8Array 中的字节数据转换为 ASCII 字符串。 **3. 类方法** * `RCP`类拥有静态方法,用于解析 `BroadcastData` 对象。 * `Image`类拥有静态方法,用于解析图像数据。 * `File`类拥有静态方法,用于解析文件数据。 * `Link`类拥有静态方法,用于解析链接数据。 **4. 构造方法** * `BinaryReader` 和 `BinaryWriter` 类都有一个构造方法,用于初始化数据。 **5. 写数据方法** * `RCP`类提供方法,用于向 `BroadcastData` 对象写入数据。 * `Image`类提供方法,用于向 `BroadcastData` 对象写入图像数据。 * `File`类提供方法,用于向 `BroadcastData` 对象写入文件数据。 * `Link`类提供方法,用于向 `BroadcastData` 对象写入链接数据。 **6. 获取数据方法** * `RCP`类提供方法,用于从 `BroadcastData` 对象中获取类型、数据等信息。 * `Image`类提供方法,用于从 `BroadcastData` 对象中获取图像数据。 * `File`类提供方法,用于从 `BroadcastData` 对象中获取文件数据。 * `Link`类提供方法,用于从 `BroadcastData` 对象中获取链接数据。 **7. 示例** 代码中包含一个示例,展示如何使用 `RCP` 类解析 `BroadcastData` 对象。

正文

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

效果

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的消息

//RCP.cs

// 聊天室文本广播格式
public struct BroadcastData
{
    // 发布者
    public string visitor { get; set; }
    // 广播文本类型
    public BroadcastType type { get; set; }
    // 数据
    public object data { get; set; }
}
// 广播文本类型
public enum BroadcastType:byte
{
    // 发言
    message,
    // 文件
    file,
    // 链接
    link,
    // 图片
    image
}

对应实体传输方法

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

//RCP.cs

// 聊天室文本广播格式
public struct BroadcastData
{
    //...属性
	
    // 序列化对象
    public static byte[] Serialize(BroadcastData cascade){}
    // 反序列化对象
    public static BroadcastData Deserialize(ArraySegment<byte> data){}
}

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

对应处理方法

//RCP.cs

public class RCP
{
    // 创建消息的RCP传输对象
    public static BroadcastData Message(string visitor, string message){}
    // 解析RCP传输对象的消息
    public static string MessageResolve(BroadcastData broadcastData){}

    // 创建文件的RCP传输对象
    public static BroadcastData File((string fileName,string id, byte[] fileBuffer) file){}
    // 解析RCP传输对象中的文件
    public static (string fileName, string extension,string id, byte[] buffer) FileResolve(BroadcastData broadcastData){}

    // 创建链接的RCP传输对象
    public static BroadcastData Link(string visitor, (string fileName, int fileSize, string id) file){}
    // 解析RCP传输对象中的链接
    public static (string fileName,string id, int fileSize) LinkResolve(BroadcastData broadcastData){}

    // 创建图片的RCP传输对象
    public static BroadcastData Image(string visitor, string imageName, byte[] image){}
    // 解析RCP传输对象中的图片
    public static (string imageName, byte[] buffer) ImageResolve(BroadcastData broadcastData){}
}


聊天室改造

  • 首先需要改造一下类型
//WebSocketChatRoom.cs

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

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

/// <summary>
/// 处理二进制数据
/// </summary>
/// <param name="result"></param>
/// <param name="visitor"></param>
/// <returns></returns>
public async Task handleBytes((ArraySegment<byte> bytes, WebSocketMessageType MessageType) result,RoomVisitor visitor)
{
    BroadcastData recivedData = BroadcastData.Deserialize(result.bytes);
    BroadcastData data;
    switch (recivedData.type)
    {
        case BroadcastType.message://广播消息
            await Broadcast(visitor, recivedData);
            break;
        case BroadcastType.file://文件解析,暂存,广播链接
            (string fileName, string extension,string id, byte[] buffer) resoved = RCP.FileResolve(recivedData);
            await AcceptFile(resoved);
            data = RCP.Link(visitor.Name, ($"{resoved.fileName}.{resoved.extension}", resoved.buffer.Length, resoved.id));
            await Broadcast(visitor, data);
            break;
        case BroadcastType.link://文件下载
            (string fileName, string id, int fileSize) resolved = RCP.LinkResolve(recivedData);
            (string fileName,string id, byte[] fileBuffer) linkFile =await ReadLinkFile(resolved);
            data = RCP.File(linkFile);
            await Unicast(visitor, data);
            break;
        case BroadcastType.image://图片转发
            await Broadcast(visitor, recivedData);
            break;
        default:
            await Broadcast(visitor, new BroadcastData() { type = BroadcastType.message, data = "暂时不支持此消息类型" });
            break;
    }
}

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

  • 需要用到的其他方法
//WebSocketChatRoom.cs

// 广播
public async Task Broadcast(RoomVisitor visitor,BroadcastData broadcastData){}
// 单播
public async Task Unicast(RoomVisitor visitor, BroadcastData broadcastData){}
// 多次接受消息
public async Task<(ArraySegment<byte> bytes, WebSocketMessageType MessageType)> GetBytes(WebSocket client, byte[] defaultBuffer){}
// 暂存在服务器,并返回
public async Task AcceptFile((string fileName, string extension,string id, byte[] buffer)  file){}
// 读取暂存在服务器的文件
 public async Task<(string fileName, string id, byte[] fileBuffer)> ReadLinkFile((string fileName, string id, int fileSize) link){}

完整代码

WebSocketChatRoom.cs
/// <summary>
/// 聊天室
/// </summary>
public class WebSocketChatRoom
{
    /// <summary>
    /// 成员
    /// </summary>
    public ConcurrentDictionary<string, RoomVisitor> clients=new ConcurrentDictionary<string, RoomVisitor>();

    private string _roomName;
    public string roomName { 
        get { return _roomName; } 
        set {
            _roomName = value;
            if (room != null)
            {
                room.Name = value;
            }
            else
            {
                room = new RoomVisitor() { Name = value,type=visitorType.room };
            }
        } 
    }

    public RoomVisitor room { get; set; }

    public WebSocketChatRoom()
    {
        
    }

    public async Task HandleContext(HttpContext context,WebSocket client)
    {
        //游客加入聊天室
        var visitor = new RoomVisitor() { Id= System.Guid.NewGuid().ToString("N"), Name = $"游客_{clients.Count + 1}", Web = client,type= visitorType.visitor };
        clients.TryAdd(visitor.Id, visitor);
        //广播游客加入聊天室
        await Broadcast(room, RCP.Message(room.Name, $"{visitor.Name}加入聊天室"));
        //消息缓冲区。每个连接分配400字节,100个汉字的内存
        var defaultBuffer = new byte[400];
        //消息循环
        while (!client.CloseStatus.HasValue)
        {
            try
            {
                var bytesResult = await GetBytes(client, defaultBuffer);
                if (bytesResult.MessageType == WebSocketMessageType.Text)
                {
                    //await Cascade(visitor,CascadeMeaasge(visitor,UTF8Encoding.UTF8.GetString(bytesResult.bytes.Array, 0, bytesResult.bytes.Count)));
                }
                else if (bytesResult.MessageType == WebSocketMessageType.Binary)
                {
                    await handleBytes(bytesResult, visitor);
                }
            }
            catch (Exception e)
            {

            }
        }
        //广播游客退出
        await Broadcast(room, RCP.Message(room.Name, $"{visitor.Name}退出聊天室"));
        await client.CloseAsync(
            client.CloseStatus!.Value,
            client.CloseStatusDescription,
            CancellationToken.None);
        clients.TryRemove(visitor.Id, out RoomVisitor v);
    }

    /// <summary>
    /// 广播
    /// </summary>
    /// <param name="visitor"></param>
    /// <param name="broadcastData"></param>
    /// <returns></returns>
    public async Task Broadcast(RoomVisitor visitor,BroadcastData broadcastData)
    {
        broadcastData.visitor = visitor.Name;
        foreach (var other in clients)
        {
            if (visitor != null)
            {
                if (other.Key == visitor.Id)
                {
                    continue;
                }
            }
            var buffer = BroadcastData.Serialize(broadcastData);
            if (other.Value.Web.State == WebSocketState.Open)
            {
                await other.Value.Web.SendAsync(buffer, WebSocketMessageType.Binary, true, CancellationToken.None);
            }
        }
    }

    /// <summary>
    /// 单播
    /// </summary>
    /// <param name="visitor"></param>
    /// <param name="broadcastData"></param>
    /// <returns></returns>
    public async Task Unicast(RoomVisitor visitor, BroadcastData broadcastData)
    {
        broadcastData.visitor = visitor.Name;
        var buffer = BroadcastData.Serialize(broadcastData);
        if (visitor.Web.State == WebSocketState.Open)
        {
            await visitor.Web.SendAsync(buffer, WebSocketMessageType.Binary, true, CancellationToken.None);
        }
    }


    /// <summary>
    /// 多次接受消息
    /// </summary>
    /// <param name="client"></param>
    /// <param name="defaultBuffer"></param>
    /// <returns></returns>
    public async Task<(ArraySegment<byte> bytes, WebSocketMessageType MessageType)> GetBytes(WebSocket client, byte[] defaultBuffer)
    {
        int totalBytesReceived = 0;
        int bufferSize = 1024 * 4;  // 可以设为更大,视实际情况而定
        byte[] buffer = new byte[bufferSize];
        WebSocketReceiveResult result;
        do
        {
            if (totalBytesReceived == buffer.Length)  // 如果缓冲区已满,扩展它
            {
                Array.Resize(ref buffer, buffer.Length + bufferSize);
            }

            var segment = new ArraySegment<byte>(buffer, totalBytesReceived, buffer.Length - totalBytesReceived);
            //!result.EndOfMessage时buffer不一定会被填满
            result = await client.ReceiveAsync(segment, CancellationToken.None);
            totalBytesReceived += result.Count;
        } while (!result.EndOfMessage);

        if (result.MessageType == WebSocketMessageType.Close)
        {
            return (new ArraySegment<byte>(buffer, 0, totalBytesReceived), WebSocketMessageType.Close);
        }

        return (new ArraySegment<byte>(buffer, 0, totalBytesReceived), result.MessageType);
    }


    /// <summary>
    /// 暂存在服务器,并返回
    /// </summary>
    /// <param name="buffer"></param>
    /// <returns></returns>
    public async Task AcceptFile((string fileName, string extension,string id, byte[] buffer)  file)
    {
        string fileName = $"{file.fileName}-{file.id}.{file.extension}";
        //每个聊天室一个文件夹
        string fullName = $@"C:\ChatRoom\{room.Name}\{fileName}";
        string directoryPath = Path.GetDirectoryName(fullName);
        if (!Directory.Exists(directoryPath))
        {
            Directory.CreateDirectory(directoryPath);
        }
         await File.WriteAllBytesAsync(fullName, file.buffer);
    }

    /// <summary>
    /// 读取暂存在服务器的文件
    /// </summary>
    /// <param name="link"></param>
    /// <returns></returns>
    public async Task<(string fileName, string id, byte[] fileBuffer)> ReadLinkFile((string fileName, string id, int fileSize) link)
    {
        string fullName = $@"C:\ChatRoom\{room.Name}\{link.fileName.Split('.')[0]}-{link.id}.{link.fileName.Split('.')[1]}";
        byte[] buffer = await File.ReadAllBytesAsync(fullName);
        return (link.fileName,link.id, fileBuffer:buffer);
    }

    /// <summary>
    /// 处理二进制数据
    /// </summary>
    /// <param name="result"></param>
    /// <param name="visitor"></param>
    /// <returns></returns>
    public async Task handleBytes((ArraySegment<byte> bytes, WebSocketMessageType MessageType) result,RoomVisitor visitor)
    {
        BroadcastData recivedData = BroadcastData.Deserialize(result.bytes);
        BroadcastData data;
        switch (recivedData.type)
        {
            case BroadcastType.message://广播消息
                await Broadcast(visitor, recivedData);
                break;
            case BroadcastType.file://文件解析,暂存,广播链接
                (string fileName, string extension,string id, byte[] buffer) resoved = RCP.FileResolve(recivedData);
                await AcceptFile(resoved);
                data = RCP.Link(visitor.Name, ($"{resoved.fileName}.{resoved.extension}", resoved.buffer.Length, resoved.id));
                await Broadcast(visitor, data);
                break;
            case BroadcastType.link://文件下载
                (string fileName, string id, int fileSize) resolved = RCP.LinkResolve(recivedData);
                (string fileName,string id, byte[] fileBuffer) linkFile =await ReadLinkFile(resolved);
                data = RCP.File(linkFile);
                await Unicast(visitor, data);
                break;
            case BroadcastType.image://图片转发
                await Broadcast(visitor, recivedData);
                break;
            default:
                await Broadcast(visitor, new BroadcastData() { type = BroadcastType.message, data = "暂时不支持此消息类型" });
                break;
        }
    }
}

/// <summary>
/// 游客
/// </summary>
public class RoomVisitor
{
    public WebSocket Web { get; set; }

    public string Name { get; set; }

    public string Id { get; set; }

    public visitorType type { get; set; }
}



/// <summary>
/// 游客类型
/// </summary>
public enum visitorType:byte
{
    /// <summary>
    /// 聊天室
    /// </summary>
    room,
    /// <summary>
    /// 游客
    /// </summary>
    visitor
}
RCP.cs
/// <summary>
/// RoomChatProtocal
/// 聊天室数据传输协议
/// </summary>
public class RCP
{
    /// <summary>
    /// 创建消息的RCP传输对象
    /// </summary>
    /// <param name="visitor"></param>
    /// <param name="message"></param>
    public static BroadcastData Message(string visitor, string message)
    {
        return new BroadcastData() { visitor = visitor, type = BroadcastType.message, data = message };
    }

    /// <summary>
    /// 解析RCP传输对象的消息
    /// </summary>
    /// <param name="broadcastData"></param>
    /// <returns></returns>
    public static string MessageResolve(BroadcastData broadcastData)
    {
        return broadcastData.data?.ToString()??"";
    }

    /// <summary>
    /// 创建文件的RCP传输对象
    /// </summary>
    /// <returns></returns>
    public static BroadcastData File((string fileName,string id, byte[] fileBuffer) file)
    {
        BroadcastData data = new BroadcastData();
        data.type = BroadcastType.file;
        int fileNameLength = UTF8Encoding.UTF8.GetByteCount(file.fileName);
        byte[] buffer = new byte[1 + fileNameLength + 4 + 32 + file.fileBuffer.Length];
        BinaryWriter writer = new BinaryWriter(new MemoryStream(buffer));
        writer.Write((byte)fileNameLength);
        writer.Write(UTF8Encoding.UTF8.GetBytes(file.fileName));
        writer.Write(file.fileBuffer.Length);
        writer.Write(ASCIIEncoding.ASCII.GetBytes(file.id));
        writer.Write(file.fileBuffer);
        data.data = buffer;
        return data;
    }

    /// <summary>
    /// 解析RCP传输对象中的文件
    /// </summary>
    /// <param name="broadcastData"></param>
    /// <returns></returns>
    /// <exception cref="NotImplementedException"></exception>
    public static (string fileName, string extension,string id, byte[] buffer) FileResolve(BroadcastData broadcastData)
    {
        BinaryReader reader = new BinaryReader(new MemoryStream((byte[])broadcastData.data));
        int fileNameLength = reader.ReadByte() & 0x000000FF;
        string fileExtensionName = UTF8Encoding.UTF8.GetString(reader.ReadBytes(fileNameLength));
        string fileName= fileExtensionName.Split('.')[0];
        string extension= fileExtensionName.Split(".")[1];
        int fileLength=reader.ReadInt32();
        string id = ASCIIEncoding.ASCII.GetString(reader.ReadBytes(32));
        byte[] buffer= reader.ReadBytes(fileLength);
        return (fileName, extension, id, buffer);
    }

    /// <summary>
    /// 创建链接的RCP传输对象
    /// </summary>
    public static BroadcastData Link(string visitor, (string fileName, int fileSize, string id) file)
    {
        int fileNameLength = UTF8Encoding.UTF8.GetByteCount(file.fileName);
        byte[] buffer = new byte[1 + fileNameLength + 32 + 4];
        BinaryWriter writer = new BinaryWriter(new MemoryStream(buffer));
        writer.Write((byte)fileNameLength);
        writer.Write(UTF8Encoding.UTF8.GetBytes(file.fileName));
        writer.Write(file.fileSize);
        writer.Write(ASCIIEncoding.ASCII.GetBytes(file.id));
        return new BroadcastData()
        {
            visitor = visitor,
            type = BroadcastType.link,
            data = buffer
        };
    }

    /// <summary>
    /// 解析RCP传输对象中的链接
    /// </summary>
    /// <param name="broadcastData"></param>
    /// <returns></returns>
    public static (string fileName,string id, int fileSize) LinkResolve(BroadcastData broadcastData)
    {
        BinaryReader reader = new BinaryReader(new MemoryStream((byte[])broadcastData.data));
        int fileNameLength=reader.ReadByte() & 0x000000FF;
        string fileName= UTF8Encoding.UTF8.GetString(reader.ReadBytes(fileNameLength));
        int fileLength=reader.ReadInt32();
        string id=ASCIIEncoding.ASCII.GetString(reader.ReadBytes(32));
        return (fileName, id, fileLength);
    }

    /// <summary>
    /// 创建图片的RCP传输对象
    /// </summary>
    /// <param name="visitor"></param>
    /// <param name="imageName"></param>
    /// <param name="image"></param>
    /// <returns></returns>
    public static BroadcastData Image(string visitor, string imageName, byte[] image)
    {
        BroadcastData data = new BroadcastData();
        data.visitor = visitor;
        data.type = BroadcastType.image;
        int fileNameLength = UTF8Encoding.UTF8.GetByteCount(imageName);
        byte[] buffer = new byte[1 + fileNameLength + 4 + 32 + image.Length];
        BinaryWriter writer = new BinaryWriter(new MemoryStream(buffer));
        writer.Write((byte)fileNameLength);
        writer.Write(UTF8Encoding.UTF8.GetBytes(imageName));
        writer.Write(image.Length);
        writer.Write(image);
        data.data = buffer;
        return data;
    }

    /// <summary>
    /// 解析RCP传输对象中的图片
    /// </summary>
    /// <param name="broadcastData"></param>
    /// <returns></returns>
    public static (string imageName, byte[] buffer) ImageResolve(BroadcastData broadcastData)
    {
        BinaryReader reader = new BinaryReader(new MemoryStream((byte[])broadcastData.data));
        int imageNameLength = reader.ReadByte() & 0x000000FF;
        string imageExtensionName = UTF8Encoding.UTF8.GetString(reader.ReadBytes(imageNameLength));
        int imageLength = reader.ReadInt32();
        byte[] buffer = reader.ReadBytes(imageLength);
        return (imageExtensionName, buffer);
    }
}
/// <summary>
/// RCP传输对象
/// </summary>
public struct BroadcastData
{
    /// <summary>
    /// 发布者
    /// </summary>
    public string visitor { get; set; }

    /// <summary>
    /// 广播文本类型
    /// </summary>
    public BroadcastType type { get; set; }

    /// <summary>
    /// 数据
    /// </summary>
    public object data { get; set; }

    /// <summary>
    /// 序列化对象
    /// </summary>
    /// <param name="broadcast"></param>
    /// <returns></returns>
    /// <exception cref="Exception"></exception>
    public static byte[] Serialize(BroadcastData broadcast)
    {
        using (MemoryStream memoryStream = new MemoryStream())
        {
            //utf8编码字符串
            using (BinaryWriter writer = new BinaryWriter(memoryStream))
            {
                //visitor长度,1字节
                writer.Write((byte)UTF8Encoding.UTF8.GetByteCount(broadcast.visitor));
                //visitor,n字节
                writer.Write(UTF8Encoding.UTF8.GetBytes(broadcast.visitor));
                //type,一字节
                writer.Write((byte)broadcast.type);
                //data,要么是字符串,要么是数组
                if (broadcast.data is string stringData)
                {
                    //int长度,4字节
                    writer.Write((UTF8Encoding.UTF8.GetByteCount(stringData)));
                    //data内容,m字节
                    writer.Write(UTF8Encoding.UTF8.GetBytes(stringData));
                }
                else if (broadcast.data is ArraySegment<byte> ArraySegmentData)
                {
                    //int长度,4字节
                    writer.Write(ArraySegmentData.Count);
                    //data内容,m字节
                    writer.Write(ArraySegmentData);
                }
                else if (broadcast.data is byte[] bytesData)
                {
                    //int长度,4字节
                    writer.Write(bytesData.Length);
                    //data内容,m字节
                    writer.Write(bytesData);
                }
                else
                {
                    throw new Exception("不支持的data类型,只能是string或ArraySegment<byte>");
                }
            }
            return memoryStream.ToArray();
        }
    }

    /// <summary>
    /// 反序列化对象
    /// </summary>
    /// <param name="data"></param>
    /// <returns></returns>
    public static BroadcastData Deserialize(ArraySegment<byte> data)
    {
        BroadcastData broadcastData = new BroadcastData();
        BinaryReader br = new BinaryReader(new MemoryStream(data.Array!));
        int visitorLength = br.ReadByte() & 0x000000FF;
        broadcastData.visitor = UTF8Encoding.UTF8.GetString(br.ReadBytes(visitorLength));
        broadcastData.type = (BroadcastType)br.ReadByte();
        int dataLength = br.ReadInt32();
        broadcastData.data = br.ReadBytes(dataLength);
        return broadcastData;
    }
}

/// <summary>
/// 消息类型
/// </summary>
public enum BroadcastType : byte
{
    /// <summary>
    /// 发言
    /// </summary>
    message,
    /// <summary>
    /// 文件传输
    /// </summary>
    file,
    /// <summary>
    /// 文件下载链接
    /// </summary>
    link,
    /// <summary>
    /// 图片查看
    /// </summary>
    image
}

web客户端

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

chatRoomClient.html
<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>聊天室</title>
</head>
<style>
    html{
        height: calc(100% - 16px);
        margin: 8px;
    }
    body{
        height: 100%;
        margin: 0;
    }
</style>
<body>
    <div style="height: 100%;display: grid;grid-template: auto 1fr 100px/1fr;row-gap: 8px;">
        <div style="grid-area: 1/1/2/2;">
            <div style="display: grid;grid: 1fr/1fr 100px;column-gap: 8px;">
                <div style="grid-area: 1/1/1/2;display: flex;justify-content: end;">
                    <label>房间</label>
                    <input style="width: 300px;" value="ws://localhost:5234/chat/房间号" name="room" oninput="changeroom(event)"/>
                </div>
                <button style="grid-area: 1/2/1/3;" onclick="connectRoom()" id="open">打开连接</button>
            </div>
        </div>
        <div style="grid-area: 2/1/3/2;background-color: #eeeeee;overflow-y: auto;" id="chatMessages"></div>
        <div style="grid-area: 3/1/4/2;position: relative;">
            <div class="toolbar">
                <button onclick="sendimage()">图片</button>
                <button onclick="sendFile()">文件</button>
            </div>
            <textarea style="width: calc(100% - 20px);padding: 5px 10px;height: calc(100% - 33px);font-size: 16px;" id="msg"></textarea>
            <button style="position: absolute;right: 10px;bottom: 5px;" onclick="sendmsg()">发送</button>
        </div>
    </div>

    <script>
        var socket;
        var isopen=false;    

        function changeroom(e){
            document.title=`聊天室-${e.srcElement.value.split('/').reverse()[0]}`;
        }
        function sendmsg(){
            var msg=document.getElementById('msg').value;
            if(msg=='')return
            if(!isopen)return
            if(isopen){
                var broadcastData=RCP.Message(msg);
                var buffer=BroadcastData.Serialize(broadcastData);
                socket.send(buffer);
                broadcastData.visitor='我';
                broadcastData.data=RCP.MessageResolve(broadcastData);
                appendMsg(broadcastData,'right');
                document.getElementById('msg').value='';
            }
        }
        function sendimage(){
            if(!isopen)return;
            var input=document.createElement('input');
            input.type='file';
            input.accept='image/jpeg,image/png'
            input.click();
            input.onchange=e=>{
                if(e.srcElement.files.length==0)return;
                var image=e.srcElement.files[0];
                var fileReader=new FileReader();
                fileReader.onload=()=>{
                    var broadcastData= RCP.Image(image.name,fileReader.result);
                    var buffer=BroadcastData.Serialize(broadcastData);
                    socket.send(buffer);
                    broadcastData.visitor='我';
                    var resolvedImage=RCP.ImageResolve(broadcastData);
                    var extension=resolvedImage.imageName.split('.')[resolvedImage.imageName.split('.').length-1];
                    resolvedImage.buffer=createDataURL(extension,resolvedImage.buffer);
                    broadcastData.data=resolvedImage.buffer;
                    appendImage(broadcastData,'right');
                }
                fileReader.readAsArrayBuffer(image);
            }
        }
        function sendFile(){
            if(!isopen)return;
            var input=document.createElement('input');
            input.type='file';
            input.click();
            input.onchange=e=>{
                if(e.srcElement.files.length==0)return;
                var file=e.srcElement.files[0];
                var fileReader=new FileReader();
                fileReader.onload=()=>{
                    var broadcastData= RCP.File(file.name,fileReader.result);
                    var buffer=BroadcastData.Serialize(broadcastData);
                    socket.send(buffer);
                    broadcastData.visitor='我';
                    var resolve=RCP.FileResolve(broadcastData);
                    broadcastData.data={fileName:`${resolve.fileName}.${resolve.extension}`,id:resolve.id,fileSize:resolve.buffer.length};
                    appendLink(broadcastData,'right');
                }
                fileReader.readAsArrayBuffer(file);
            }
        }
        function downloadLink(fileName,id,fileSize){
            var broadcastData= RCP.Link(fileName,id,fileSize);
            var buffer=BroadcastData.Serialize(broadcastData);
            socket.send(buffer);
        }
        function downloadFile(fileInfo){
            const url=createDataURL(fileInfo.extension,fileInfo.buffer);
            var download=document.createElement('a');
            download.href=url;
            download.download=`${fileInfo.fileName}.${fileInfo.extension}`;
            download.click();
        }
        function connectRoom(){
            if (isopen==true) {
                socket.close();
                return;
            }
            var route=document.getElementsByName('room')[0].value;
            try {                
                socket=new WebSocket(route);   
            } catch (error) {
                console.log(error);
                isopen=false;
                document.getElementById('open').innerText='打开连接';
                return
            }            
            socket.addEventListener('open', (event) => {
                isopen=true;
                document.getElementById('open').innerText='关闭连接'
            });
            socket.addEventListener('message', (event) => {
                // 处理接收到的消息
                console.log('Received:', event.data);
                var fileReader = new FileReader();
                fileReader.onload=function(event){
                    arrayBufferNew = event.target.result;
                    // uint8ArrayNew = new Uint8Array(arrayBufferNew);
                    handleBytes(arrayBufferNew);
                }
                fileReader.readAsArrayBuffer(event.data);
            });
            socket.addEventListener('close',event=>{
                isopen=false;
                document.getElementById('open').innerText='打开连接';
            })
        }
        function handleBytes(arrayBufferNew){
            var broadcastData=BroadcastData.Deserialize(arrayBufferNew);
            switch (broadcastData.type) {
                case BroadcastType.message:
                    var msg=RCP.MessageResolve(broadcastData);
                    broadcastData.data=msg;
                    appendMsg(broadcastData);
                    break;
                case BroadcastType.image:
                    var image=RCP.ImageResolve(broadcastData);
                    var extension=image.imageName.split('.')[image.imageName.split('.').length-1];
                    image.buffer=createDataURL(extension,image.buffer);
                    broadcastData.data=image.buffer;
                    appendImage(broadcastData);
                    break;
                case BroadcastType.link:
                    var linkInfo=RCP.LinkResolve(broadcastData);
                    broadcastData.data=linkInfo;
                    appendLink(broadcastData);
                    break;
                case BroadcastType.file:
                    var fileInfo=RCP.FileResolve(broadcastData);
                    downloadFile(fileInfo);
                    break;
                default:
                    break;
            }
        }

        function appendMsg(broadcastData,dock){
            var chatMessages = document.getElementById('chatMessages');
            if(dock!='right'){
                chatMessages.innerHTML+=`
                <div style="padding:10px;">
                    <div>${broadcastData.visitor}</div>
                    <div style="padding:0 50px;">                    
                        <div style="display:inline-block;padding:5px;border-radius:5px;background-color:#ffffff;">${broadcastData.data}</div>
                    </div>
                </div>`;
            }
            else{
                chatMessages.innerHTML+=`
                <div style="padding:10px;display:flex;flex-direction: column;align-items: flex-end;">
                    <div>${broadcastData.visitor}</div>
                    <div style="padding:0 50px;">                    
                        <div style="display:inline-block;padding:5px;border-radius:5px;background-color:#ffffff;">${broadcastData.data}</div>
                    </div>
                </div>`;
            }
            // 使用 scrollIntoView 方法将底部元素滚动到可见区域
            chatMessages.lastChild.scrollIntoView({ behavior: 'smooth', block: 'end', inline: 'nearest' });
        }
        function appendImage(broadcastData,dock){
            var chatMessages = document.getElementById('chatMessages');
            if(dock!='right'){
                chatMessages.innerHTML+=`
                <div style="padding:10px;">
                    <div>${broadcastData.visitor}</div>
                    <div style="padding:0 50px;">                    
                        <div style="display:inline-block;padding:5px;border-radius:5px;background-color:#ffffff;"><img style="height:100px;" src="${broadcastData.data}"></img></div>
                    </div>
                </div>`;
            }
            else{
                chatMessages.innerHTML+=`
                <div style="padding:10px;display:flex;flex-direction: column;align-items: flex-end;">
                    <div>${broadcastData.visitor}</div>
                    <div style="padding:0 50px;">                    
                        <div style="display:inline-block;padding:5px;border-radius:5px;background-color:#ffffff;"><img style="height:100px;" src="${broadcastData.data}"></img></div>
                    </div>
                </div>`;
            }
            // 使用 scrollIntoView 方法将底部元素滚动到可见区域
            chatMessages.lastChild.scrollIntoView({ behavior: 'smooth', block: 'end', inline: 'nearest' });
        }
        function appendLink(broadcastData,dock){
            var chatMessages = document.getElementById('chatMessages');
            if(dock!='right'){
                chatMessages.innerHTML+=`
                <div style="padding:10px;">
                    <div>${broadcastData.visitor}</div>
                    <div style="padding:0 50px;">                    
                        <div style="display:inline-block;padding:5px;border-radius:5px;background-color:#ffffff;">
                            <div style="display:grid;grid-template:2fr 1fr/1fr/auto;row-gap:5px;">
                                <div style="grid-area:1/1/2/2;font-size:18px;max-width:300px;padding:0 5px;">${broadcastData.data.fileName}</div>    
                                <div style="grid-area:2/1/3/2;font-size:12px;padding:0 5px;">${broadcastData.data.fileSize}字节</div>   
                                <div style="grid-area:1/2/3/3;display:flex;align-items:center;padding:0 5px;background-color:lightblue;cursor:pointer;">
                                    <div style="display:inline-block;" onclick="downloadLink('${broadcastData.data.fileName}','${broadcastData.data.id}',${broadcastData.data.fileSize})">下载⬇</div>
                                </div>   
                            </div>
                        </div>
                    </div>
                </div>`;
            }
            else{
                chatMessages.innerHTML+=`
                <div style="padding:10px;display:flex;flex-direction: column;align-items: flex-end;">
                    <div>${broadcastData.visitor}</div>
                    <div style="padding:0 50px;">                    
                        <div style="display:inline-block;padding:5px;border-radius:5px;background-color:#ffffff;">
                            <div style="display:grid;grid-template:2fr 1fr/1fr/auto;row-gap:5px;">
                                <div style="grid-area:1/1/2/2;font-size:18px;max-width:300px;padding:0 5px;">${broadcastData.data.fileName}</div>    
                                <div style="grid-area:2/1/3/2;font-size:12px;padding:0 5px;">${broadcastData.data.fileSize}字节</div>   
                                <div style="grid-area:1/2/3/3;display:flex;align-items:center;padding:0 5px;background-color:lightgreen;">
                                    <div style="display:inline-block;">上传</div>
                                </div> 
                            </div>
                        </div>
                    </div>
                </div>`;
            }
            // 使用 scrollIntoView 方法将底部元素滚动到可见区域
            chatMessages.lastChild.scrollIntoView({ behavior: 'smooth', block: 'end', inline: 'nearest' });
        }

        function getMIME(params) {
                switch (params) {
                    case 'jpg':
                        return 'image/jpeg';
                    case 'jpeg':
                        return 'image/jpeg';
                    case 'png':
                        return 'image/png';
                    default:
                        break;
                }
            }
        function createDataURL(extension,buffer){
            // 将 ArrayBuffer 包装成 Blob 对象
            var MIME = getMIME(extension)
            const blob = new Blob([buffer], { type: MIME });
            // 使用 URL.createObjectURL() 创建 Blob 对象的 URL
            const url = URL.createObjectURL(blob);
            return url;
        }

    </script>

    <script>
        class BroadcastType{
            static message=new Uint8Array([0])[0]
            static file=new Uint8Array([1])[0]
            static link=new Uint8Array([2])[0]
            static image=new Uint8Array([3])[0]
        }
        class BroadcastData{
            visitor;
            type;
            data;
            static Serialize(broadcast){
                var writer=new BinaryWriter();
                writer.write(new Uint8Array([0]));
                writer.write(new Uint8Array([broadcast.type]));                
                writer.writeInt32(broadcast.data.byteLength);
                writer.write(new Uint8Array(broadcast.data));
                return writer.toArray();
            }
            static Deserialize(buffer){
                var broadcastData=new BroadcastData();
                var reader=new BinaryReader(buffer);
                var visitorLength=reader.readByte();
                var visitorBytes = reader.readBytes(visitorLength);
                broadcastData.visitor = new TextDecoder().decode(visitorBytes);
                broadcastData.type=reader.readByte();
                var dataLength=reader.readInt32(4);
                broadcastData.data = reader.readBytes(dataLength);
                return broadcastData;
            }
        }
        class RCP{
            static Message(message){
                var broadcastData=new BroadcastData();
                var coder=new TextEncoder();
                broadcastData.type=BroadcastType.message;
                var data=coder.encode(message);
                broadcastData.data=data;
                return broadcastData;
            }
            static MessageResolve(broadcastData){
                return new TextDecoder().decode(broadcastData.data);
            }
            static Image(imageName,imageBuffer){
                var data = new BroadcastData();
                data.type=BroadcastType.image;
                var imageNameLength=new TextEncoder().encode(imageName).length;
                var writer=new BinaryWriter();
                writer.write(new Uint8Array([imageNameLength]));
                writer.write(new TextEncoder().encode(imageName));
                writer.writeInt32(imageBuffer.byteLength);
                writer.write(new Uint8Array(imageBuffer));
                data.data = writer.toArray();
                return data;
            }
            static ImageResolve(broadcastData){
                var data=broadcastData.data
                if(broadcastData.data instanceof Uint8Array)
                    data=data.buffer.slice(data.byteOffset, data.byteOffset + data.byteLength);
                var reader=new BinaryReader(data);
                var imageNameLength=reader.readByte();
                var coder=new TextDecoder();
                var imageExtensionName=coder.decode(reader.readBytes(imageNameLength));
                var imageLength=reader.readInt32();
                var buffer=reader.readBytes(imageLength);
                return {imageName:imageExtensionName,buffer:buffer};
            }
            static File(fileName,fileBuffer){
                var data = new BroadcastData();
                data.type=BroadcastType.file;
                var fileNameLength=new TextEncoder().encode(fileName).length;
                var writer=new BinaryWriter();
                writer.write(new Uint8Array([fileNameLength]));
                writer.write(new TextEncoder().encode(fileName));
                writer.writeInt32(fileBuffer.byteLength);
                var uuid=this.#generateUUID();
                var uint8uuid=this.#asciiToUint8Array(uuid);
                writer.write(uint8uuid);
                writer.write(new Uint8Array(fileBuffer));
                data.data = writer.toArray();
                return data;
            }
            static FileResolve(broadcastData){
                var data=broadcastData.data
                if(broadcastData.data instanceof Uint8Array)
                    data=data.buffer.slice(data.byteOffset, data.byteOffset + data.byteLength);
                var reader=new BinaryReader(data);
                var fileNameLength=reader.readByte();
                var coder=new TextDecoder();
                var fileExtensionName=coder.decode(reader.readBytes(fileNameLength));
                var extension=fileExtensionName.split('.')[fileExtensionName.split('.').length-1];
                var fileLength=reader.readInt32();
                var linkbyte=reader.readBytes(32);
                var link=this.#uint8ArrayToAscii(linkbyte);
                var buffer=reader.readBytes(fileLength);
                return {fileName:fileExtensionName.replace(`.${extension}`,''),extension:extension,id:link,buffer:buffer}
            }
            static Link(fileName,id,fileSize){
                var data = new BroadcastData();
                data.type=BroadcastType.link;
                var fileNameLength=new TextEncoder().encode(fileName).length;
                var writer=new BinaryWriter();
                writer.write(new Uint8Array([fileNameLength]));
                writer.write(new TextEncoder().encode(fileName));
                writer.writeInt32(fileSize);
                var uint8uuid=this.#asciiToUint8Array(id);
                writer.write(uint8uuid);
                data.data = writer.toArray();
                return data;
            }
            static LinkResolve(broadcastData){
                var data=broadcastData.data
                if(broadcastData.data instanceof Uint8Array)
                    data=data.buffer.slice(data.byteOffset, data.byteOffset + data.byteLength);
                var reader=new BinaryReader(data);
                var fileNameLength=reader.readByte();
                var coder=new TextDecoder();
                var fileExtensionName=coder.decode(reader.readBytes(fileNameLength));
                var fileLength=reader.readInt32();
                var linkbyte=reader.readBytes(32);
                var link=this.#uint8ArrayToAscii(linkbyte);
                return {fileName:fileExtensionName,id:link,fileSize:fileLength};
            }

            //工具函数
            static #generateUUID() {
                // 生成随机的 UUID
                const uuid = 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function(c) {
                    const r = Math.random() * 16 | 0;
                    const v = c === 'x' ? r : (r & 0x3 | 0x8);
                    return v.toString(16);
                });
                return uuid.replace(/-/g, ''); // 移除横线,得到 32 位的 UUID
            }
            static #asciiToUint8Array(str) {
                const uint8Array = new Uint8Array(str.length);
                for (let i = 0; i < str.length; i++) {
                    uint8Array[i] = str.charCodeAt(i);
                }
                return uint8Array;
            }
            static #uint8ArrayToAscii(uint8Array) {
                let asciiString = '';
                for (let i = 0; i < uint8Array.length; i++) {
                    asciiString += String.fromCharCode(uint8Array[i]);
                }
                return asciiString;
            }

        }
        class BinaryReader {
            #position;
            #buffer;
            #dataView;
            constructor(arrayBuffer) {
                this.#buffer = arrayBuffer;
                this.#position = 0;
                this.#dataView=new DataView(arrayBuffer);
            }


            readByte() {
                var value=this.#dataView.getInt8(this.#position,true);
                this.#position+=1;
                return value;
            }
            readBytes(length) {
                var bytes = new Uint8Array(this.#buffer, this.#position, length);
                this.#position += length;
                return bytes;
            }
            readInt32(){
                var value=this.#dataView.getInt32(this.#position,true);
                this.#position+=4;
                return value;
            }
        }
        class BinaryWriter {
            #data;
            constructor() {
                this.#data = [];
            }
            // 向流中添加数据
            write(chunk) {
                for (let i = 0; i < chunk.byteLength; i++) {
                    this.#data.push(chunk[i]);
                }
            }
            // 将收集到的数据转换为 ArrayBuffer
            toArray() {
                const buffer = new ArrayBuffer(this.#data.length);
                const view = new Uint8Array(buffer);
                for (let i = 0; i < this.#data.length; i++) {
                    view[i] = this.#data[i];
                }
                return buffer;
            }
            writeInt32(number){
                // 创建一个 ArrayBuffer,大小为 4 字节
                const buffer = new ArrayBuffer(4);
                // 创建一个 DataView,用于操作 ArrayBuffer
                const dataView = new DataView(buffer);
                // 将一个数值写入到 DataView 中
                dataView.setInt32(0, number, true); // 第二个参数表示字节偏移量,第三个参数表示是否使用小端序(true 表示使用)
                // 创建一个 Uint8Array,从 ArrayBuffer 中获取数据
                const uint8Array = new Uint8Array(buffer);
                this.write(uint8Array);
        }
    }

    </script>
</body>
</html>

与基于webapi的websocket聊天室(四)相似的内容:

基于webapi的websocket聊天室(四)

上一篇实现了多聊天室。这一片要继续改进的是实现收发文件,以及图片显示。 效果 问题 websocket本身就是二进制传输。文件刚好也是二进制存储的。 文件本身的传输问题不太,但是需要传输文件元数据,比如文件名和扩展名之类的。这很必要,如果我们想知道怎么展示这个文件的话。比如这个文件是图片还是word

基于webapi的websocket聊天室(番外二)

我比较好奇的是webapi服务器怎么处理http请求和websocket请求。有了上一篇番外的研究,这里就可以试着自己写个非常简易的webapi服务器来接收这两种请求。 效果 http请求 消息打印 响应解析 websocket请求 消息打印 使用聊天室测试 其实两种请求差不多,就只是一些头部字段有

基于webapi的websocket聊天室(番外一)

上一篇我已经实现了聊天室,并且在协议中实现了4种类型的消息传输。其实还可以添加video,audio,live等等类型。 不过假如把目前的协议看作RCP1.0版的话,这个版本就只支持有限的4种消息。精力有限啊。也许RCP2.0就可以把video,audio类型加进去? 这不是这篇番外考虑的。而是我在

利用云服务提供商的免费证书,在服务器上发布https前端应用和WebAPI的应用

我们如果要在服务器上发布https前端应用和WebAPI的应用,那么我们就需要用到https证书了。我们一般发布的应用的云服务器上,都会提供一定量的相关的免费证书(一般为20个)供我们使用,每个一年期限,到期再续即可,一般情况下基本上满足要求了,本篇随笔介绍如何基于云服务提供商的免费证书,在服务器上发布Nginx的前端应用和基于IIS的Web API接口的https应用处理。

基于 ActionFilters 的限流库DotNetRateLimiter使用

前言 在构建API项目时,有时出于安全考虑,防止访问用户恶意攻击,希望限制此用户ip地址的请求次数,减轻拒绝服务攻击可能性,也称作限流。接下来,我们就来学习开源库DotNetRateLimiter 如何轻松实现限流。 项目使用配置 安装Nuget包 在新建立的WebAPI项目中,通过Nuget包管理

基于.NetCore开发博客项目 StarBlog - (22) 开发博客文章相关接口

## 前言 本文介绍博客文章相关接口的开发,作为接口开发介绍的第一篇,会写得比较详细,以抛砖引玉,后面的其他接口就粗略带过了,着重于WebApi开发的周边设施。 涉及到的接口:文章CRUD、置顶文章、推荐文章等。 开始前先介绍下AspNetCore框架的基础概念,MVC模式(前后端不分离)、WebA

EDP .Net开发框架--WebApi

EDP是一套集组织架构,权限框架【功能权限,操作权限,数据访问权限,WebApi权限】,自动化日志,动态Interface,WebApi管理等基础功能于一体的,基于.net的企业应用开发框架。通过友好的编码方式实现数据行、列权限的管控。

EDP .Net开发框架--组织架构

EDP是一套集组织架构,权限框架【功能权限,操作权限,数据访问权限,WebApi权限】,自动化日志,动态Interface,WebApi管理等基础功能于一体的,基于.net的企业应用开发框架。通过友好的编码方式实现数据行、列权限的管控。

EDP .Net开发框架--自动化日志

EDP是一套集组织架构,权限框架【功能权限,操作权限,数据访问权限,WebApi权限】,自动化日志,动态Interface,WebApi管理等基础功能于一体的,基于.net的企业应用开发框架。通过友好的编码方式实现数据行、列权限的管控。

EDP .Net开发框架--权限

EDP是一套集组织架构,权限框架【功能权限,操作权限,数据访问权限,WebApi权限】,自动化日志,动态Interface,WebApi管理等基础功能于一体的,基于.net的企业应用开发框架。通过友好的编码方式实现数据行、列权限的管控。