Blazor를 이용하여~ 그래픽 웹챗을 구현하는 변종 실험입니다.


GIT : https://github.com/psmon/BlazorChatApp

작동버전 : https://sam.webnori.com/metaroom - git에서 추가커스텀


구현기능

  • 입장을 하면, 자신의 캐릭터(도형)가 랜덤생성됩니다.
  • 여러사용자가 입장가능하며, 자신의 캐릭터를 움직일수 있습니다.
  • 모든 사용자의 위치는 동기화가됩니다.


확장가능

기본 프로젝트를 활용, 캐릭터애니메이션등적용및 다양한 분야에 적용할수 있습니다.

패션문화의 거리 : 루나소프트 패션앱 쇼아입점 상품이 활용되었습니다.


상담메타 : 상담 CS기능 확장 ( 챗봇을 통해 택배 반품접수가 가능한 루나톡~)


추가 구현예정:

  • 인증및 채널관리 ( 인증없음~)
  • 웹소켓 메시지 사용자 타켓및 채널타켓 ( 브로드 캐스트 사용)
  • 기본 채팅기능
  • 맵디자인~


실행 샘플

meta-chat3.mp4


실행샘플2 - 캐틱터 애니메이션 적용

meta_comm.mp4




메시지 설계

웹소켓(브라우져)와 서버메시지(액터)의 정의를 C#오브젝트로 통합할수 있습니다. - 블레이즈 특성

namespace BlazorChatApp.Shared
{
    public class ChatData
    {
    }

    public class UserInfo
    {
        public string Id { get; set; }
        public string Name { get; set; }
 
        public string Color { get; set; }
    }

    public class RoomInfo
    {
        public string Id { get; set; }
        public string Name { get; set; }
    }

    public class UpdateUserPos :UserInfo
    { 
        public double PosX{get; set; }
        public double PosY{get; set; }
        
    }


    public class ChatMessage
    { 
        public UserInfo From { get; set; }
        public string Message { get; set; }
    }

    public class JoinRoom
    {
        public RoomInfo RoomInfo { get; set; }
        public UserInfo UserInfo { get; set; }
    }

    public class SyncRoom
    {
        public RoomInfo RoomInfo { get; set; }
        public UserInfo UserInfo { get; set; }
    }

    public class LeaveRoom
    {
        public RoomInfo RoomInfo { get; set; }
        public UserInfo UserInfo { get; set; }
    }


    public class BaseCmd
    {
        public string Command {get;set; }
    }


    public class RoomCmd : BaseCmd
    {        
        public UserInfo UserInfo{ get; set; }
        public object Data{get;set; }
    }

}


웹소켓 처리코드

시그널 R이 이용되었으며, 일부 서버에서 상태처리를 해야하는경우 액터를 연결하여 분산처리가능한 서버기능으로 확장할수 있습니다.

  • Cilent To Server : 브라우저에서 출발한 메시지를 서버에 전송합니다.
  • Server To Client : 서버에서 연산된 메시지를, 브라우저에게 전송합니다.
using System.Collections.Generic;
using System.Threading.Tasks;

using Akka.Actor;

using BlazorChatApp.Shared;

using Microsoft.AspNetCore.SignalR;

namespace BlazorChatApp.Server.Hubs
{
    public class ChatHub : Hub
    {
        private ActorSystem actorSystem;

        private ActorSelection roomActor;

        public ChatHub(ActorSystem _actorSystem)
        {
            actorSystem = _actorSystem;
            roomActor = actorSystem.ActorSelection("user/room1");
        }

        
        // Client To Server

        public async Task JoInRoom(JoinRoom joinRoom)
        {
            roomActor.Tell(joinRoom);
        }

        public async Task SyncRoom(SyncRoom syncRoom)
        {
            roomActor.Tell(syncRoom);
        }

        public async Task LeaveRoom(LeaveRoom leaveRoom)
        {
            roomActor.Tell(leaveRoom);
        }

        public async Task UpdateUserPos(UpdateUserPos updateUserPos)
        {
            roomActor.Tell(updateUserPos);
        }

        // Server To Client : RoomActor 의 Hub Context를 통해 전송을할수 있습니다.

       

    }
}


서버코드

웹소켓 이벤트가 주로 브라우저에서 발생하는 이벤트를 처리하면

서버내에서 처리해야하는 로직의 경우 액터를 활용하는것이 유연할수 있습니다.

여기서는 접속한 사용자의 좌표및 동기화를 관리합니다.

using System;
using System.Collections.Generic;
using System.Linq;
using System.Text.Json;
using System.Threading.Tasks;

using Akka.Actor;
using Akka.Event;

using BlazorChatApp.Shared;

using Microsoft.AspNetCore.SignalR;
using Microsoft.AspNetCore.SignalR.Client;
using Microsoft.Extensions.DependencyInjection;


namespace BlazorChatApp.Server.Hubs
{
    public class RoomActor : ReceiveActor
    {
        private readonly ILoggingAdapter log = Context.GetLogger();

        public Dictionary<string,UpdateUserPos> users = new Dictionary<string,UpdateUserPos>();

        private string roomName;

        private int userAutoNo = 0;

        private readonly IServiceScopeFactory scopeFactory;
        

        Random random= new Random();

        public RoomActor(string _roomName, IServiceScopeFactory _scopeFactory)
        {
            scopeFactory = _scopeFactory;            

            roomName = _roomName;

            log.Info($"Create Room{roomName}");

            Receive<RoomCmd>(cmd => {
                log.Info("Received String message: {0}", cmd);
                //Sender.Tell(message);                
            });

            Receive<JoinRoom>(async cmd => {
                userAutoNo++;
                string jsonString = JsonSerializer.Serialize(cmd);
                log.Info("Received JoinRoom message: {0}", jsonString);                
                string RandomColor =  string.Format("#{0:X6}", random.Next(0xFFFFFF));
                
                UserInfo userInfo = new UserInfo()
                { 
                    Id=cmd.UserInfo.Id,
                    Name=$"User-{userAutoNo}",
                    Color=RandomColor
                };

                UpdateUserPos updateUserPos= new UpdateUserPos()
                { 
                    Id=cmd.UserInfo.Id,
                    Name=$"User-{userAutoNo}",
                    PosX=random.NextDouble()*300+20,PosY=random.NextDouble()*300+20,
                    ConnectionId = cmd.ConnectionId
                };

                users[cmd.UserInfo.Id] = updateUserPos;

                await OnJoinRoom(cmd.RoomInfo, userInfo, updateUserPos);
            });

            Receive<SyncRoom>(async cmd => {           
                userAutoNo++;
                string jsonString = JsonSerializer.Serialize(cmd);
                log.Info("Received SyncRoom message: {0}", jsonString);

                List<UpdateUserPos> updateUserPosList = users.Values.ToList();                
                //await hubConnection.SendAsync("OnSyncRoom", cmd.UserInfo, updateUserPosList);
                string RandomColor =  string.Format("#{0:X6}", random.Next(0xFFFFFF));

                UserInfo userInfo = new UserInfo()
                { 
                    Id=cmd.UserInfo.Id,
                    Name=$"User-{userAutoNo}",
                    Color=RandomColor
                };

                await OnSyncRoom(userInfo, updateUserPosList);

            });

            Receive<ChatMessage>(async cmd => {           
                userAutoNo++;
                string jsonString = JsonSerializer.Serialize(cmd);
                log.Info("Received ChatMessage message: {0}", jsonString);

                ChatMessage chatMessage = new ChatMessage()
                { 
                    From = cmd.From,
                    Message = cmd.Message
                };

                await OnChatMessage(chatMessage);

            });

            Receive<UpdateUserPos>(async cmd => { 
                string jsonString = JsonSerializer.Serialize(cmd);
                log.Info("Received UpdateUserPos message: {0}", jsonString);

                double AbsPosX = users[cmd.Id].PosX+cmd.PosX;
                double AbsPosY= users[cmd.Id].PosY+cmd.PosY;                  

                if(users.ContainsKey(cmd.Id))
                {
                    users[cmd.Id].PosX=AbsPosX;
                    users[cmd.Id].PosY=AbsPosY;
                }

                log.Info($"UpdateUser : X=>{AbsPosX} Y=>{AbsPosY}");

                UpdateUserPos updateUserPos = new UpdateUserPos()
                {
                    Id = cmd.Id,
                    Name = cmd.Name,
                    PosX = cmd.PosX,
                    PosY = cmd.PosY,
                    AbsPosX = AbsPosX,
                    AbsPosY = AbsPosY
                };

                await OnUpdateUserPos(updateUserPos);

            });            

            Receive<Disconnect>(async cmd => {                
                string jsonString = JsonSerializer.Serialize(cmd);
                log.Info("Received Disconnect message: {0}", jsonString);
                var disconnectUser = users.Values.Where(e=> e.ConnectionId == cmd.ConnectionId).FirstOrDefault();

                if(disconnectUser != null)
                {
                    if(users.ContainsKey(disconnectUser.Id))
                    {
                        users.Remove(disconnectUser.Id);

                        var leaveMsg = new LeaveRoom()
                        {
                            UserInfo = new UserInfo(){ Id =disconnectUser.Id }
                        
                        };
                        await OnLeaveRoom(leaveMsg);
                    }
                }
            });

            Receive<LeaveRoom>(async cmd => {                
                string jsonString = JsonSerializer.Serialize(cmd);
                log.Info("Received LeaveRoom message: {0}", jsonString);

                if(users.ContainsKey(cmd.UserInfo.Id))
                {
                    users.Remove(cmd.UserInfo.Id);
                    await OnLeaveRoom(cmd);
                }
            });

        }

        public async Task OnJoinRoom(RoomInfo roomInfo, UserInfo user, UpdateUserPos updateUserPos)
        {
            using(var scope = scopeFactory.CreateScope())
            {
                var wsHub = scope.ServiceProvider.GetRequiredService<IHubContext<ChatHub>>();
                await wsHub.Clients.All.SendAsync("OnJoinRoom", roomInfo, user, updateUserPos);
            }            
        }

        public async Task OnSyncRoom(UserInfo user, List<UpdateUserPos> updateUserPos )
        {
            using(var scope = scopeFactory.CreateScope())
            {
                var wsHub = scope.ServiceProvider.GetRequiredService<IHubContext<ChatHub>>();
                await wsHub.Clients.All.SendAsync("OnSyncRoom", user, updateUserPos);
            }
        }

        public async Task OnLeaveRoom(LeaveRoom leaveRoom)
        {
            using(var scope = scopeFactory.CreateScope())
            {
                var wsHub = scope.ServiceProvider.GetRequiredService<IHubContext<ChatHub>>();
                await wsHub.Clients.All.SendAsync("OnLeaveRoom", leaveRoom);
            }            
        }

        public async Task OnUpdateUserPos(UpdateUserPos updatePos)
        {
            using(var scope = scopeFactory.CreateScope())
            {
                var wsHub = scope.ServiceProvider.GetRequiredService<IHubContext<ChatHub>>();
                await wsHub.Clients.All.SendAsync("OnUpdateUserPos", updatePos);
            }            
        }

        public async Task OnChatMessage(ChatMessage chatMessage)
        {
            using(var scope = scopeFactory.CreateScope())
            {
                var wsHub = scope.ServiceProvider.GetRequiredService<IHubContext<ChatHub>>();
                await wsHub.Clients.All.SendAsync("OnChatMessage", chatMessage);
            }            
        }
    }
}



클라이언트 통신 코드

자바스크립트를 사용하지 않아도~ Blazor의 C#통합으로 

C#오브젝트를 변환기처리기를 작성할 필요없이 자연스럽게 브라우저내에 작동하는 프론트 코드 작성이 가능합니다.

ChatRoom.razor
    protected override async Task OnInitializedAsync()
    {

        LoginId = Guid.NewGuid().ToString();    //Fake Login ID

        hubConnection = new HubConnectionBuilder()
            .WithUrl(_navigationManager.ToAbsoluteUri("/chathub"))
            .Build();

        hubConnection.On<RoomInfo, UserInfo, UpdateUserPos>("OnJoinRoom", (room, user, pos) =>
        {
            Console.WriteLine($"WS - OnJoinRoom");
            if(user.Id == LoginId)
            {
                Name = user.Name;
                RoomName = room.Name;
                StateHasChanged();
            }
            else
            {
                BallField.AddUser(user.Id,user.Name, pos.PosX, pos.PosY);
            }
        });

        hubConnection.On<UserInfo,List<UpdateUserPos>>("OnSyncRoom", (user, updateUserPos) =>
        {
            Console.WriteLine($"WS - OnSyncRoom");
            if(user.Id == LoginId)
            {
                 foreach(var pos in updateUserPos)
                 {
                     BallField.AddUser(pos.Id, pos.Name, pos.PosX, pos.PosY);
                 }
            }
        });
        
        hubConnection.On<UpdateUserPos>("OnUpdateUserPos", (userPos) =>
        {
            BallField.UpdateUserPos(userPos);
        });

        hubConnection.On<LeaveRoom>("OnLeaveRoom", (room) =>
        {
            Console.WriteLine($"WS - OnLeaveRoom");
            BallField.RemoveUser(room.UserInfo.Id);
        });

        await hubConnection.StartAsync();

        JoinRoom sendMsg = new JoinRoom()
        {
            UserInfo = new UserInfo(){Name="user", Id= LoginId},
            RoomInfo = new RoomInfo(){Name="room1"}
        };

        SyncRoom syndMsg = new SyncRoom()
        {
            UserInfo = new UserInfo(){Name="user", Id= LoginId},
            RoomInfo = new RoomInfo(){Name="room1"}
        };

        await hubConnection.SendAsync("JoInRoom", sendMsg);

        await hubConnection.SendAsync("SyncRoom", syndMsg);

    }


애니메이션

Blazor 컴포넌트에서, Canvas(브라우저)를 제어할수 있으며

통합된 C#코드로 브라우저내의 그래픽요소를 렌더링 할수 있습니다.

더 멋진 렌더링 코드로 업그레이드 해보세요~

ChatRoom.razor
@using Blazor.Extensions.Canvas
@using Blazor.Extensions.Canvas.Canvas2D;

    [JSInvokable]
    public async ValueTask RenderInBlazor(float timeStamp)
    {
        double fps = 1.0 / (DateTime.Now - LastRender).TotalSeconds;
        LastRender = DateTime.Now;

        await this.ctx.BeginBatchAsync();
        await this.ctx.ClearRectAsync(0, 0, BallField.Width, BallField.Height);
        await this.ctx.SetFillStyleAsync("#003366");
        await this.ctx.FillRectAsync(0, 0, BallField.Width, BallField.Height);
        await this.ctx.SetFontAsync("26px Segoe UI");
        await this.ctx.SetFillStyleAsync("#FFFFFF");
        await this.ctx.FillTextAsync("Blazor WebAssembly + HTML Canvas", 10, 30);
        await this.ctx.SetFontAsync("16px consolas");
        await this.ctx.FillTextAsync($"FPS: {fps:0.000}", 10, 50);
        await this.ctx.SetStrokeStyleAsync("#FFFFFF");

        await this.ctx.SetFontAsync("12px consolas");
        await this.ctx.SetStrokeStyleAsync("#FFFFFF");

        foreach (var ball in BallField.Balls)
        {
            await this.ctx.FillTextAsync($"{ball.Name}", ball.X -10, ball.Y -20);
            await this.ctx.BeginPathAsync();
            await this.ctx.ArcAsync(ball.X, ball.Y, ball.Radius, 0, 2 * Math.PI, false);
            await this.ctx.SetFillStyleAsync(ball.Color);
            await this.ctx.FillAsync();
            await this.ctx.StrokeAsync();
        }
        await this.ctx.EndBatchAsync();
    }



Blazor를 활용한 그래픽웹 채팅 - 닷넷conf 2022

dotnetconf2022-그래픽웹채팅-박상만.pptx


Html VS Canvas


Html5 연습장데모 : http://psmon.x-y.net/pscoco/sample.html
순수 JS버전으로 간단한 애니메이션을 만들수 있는 자작 Canvas연습장 입니다.( html5지원안되는 ie6,7 점유율이 높은 시점 제작~ )


성능분리전략

커넥션비용과 도메인의 로직 성능비용과 성능전략은 다릅니다.

일반적으로 웹소켓비용은 커넥션 비용이 높으며, 도메인의 경우 분산처리 복잡성이 증가할수 있습니다.

도메인 이벤트 Pub/Sub에 Redis를 활용할수도 있지만 저성능 전략중 하나입니다.

웹소켓은 최소한의 인증정보상태만유지하고 분산처리에 특화된 Akka-Actor를 활용할수 있습니다.

Node.js Socket.io 기반이라고 한다고하면 유사하게

AkkaServerLess를 선택할수 있습니다. 웹소켓 성능분리전략은 언어/프레임워크에 종속적인 내용이아닙니다.

https://developer.lightbend.com/docs/akka-serverless/javascript/index.html


추가참고 자료

애니메이션,캐릭터,맵등 그래픽 렌더링에 기능을 더 강화하고자 할때 참고할수 있습니다. 

대부분 웹과별개로 2D게임 영역에서 오랫동안 축적된 개발방법들입니다.



  • No labels