基於webapi的websocket聊天室(四)

ggtc發表於2024-05-19

上一篇實現了多聊天室。這一片要繼續改進的是實現收發檔案,以及圖片顯示。

效果

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>

相關文章