整合RocketChat至現有的.Net專案中,為ChatGPT鋪路

2023-03-01 18:00:25

@

前言

今天我們來聊一聊一個Paas的方案,如何整合到一個既有的專案中。
以其中一個需求為例子:在產品專案中,加入IM(即時通訊)功能,開始徒手擼程式碼,會發現工作量很大,去github找開源專案,結果也可能事與願違:功能不夠強大,或者用不同的語言編寫的,編譯出來程式集無法整合到專案中。
可能當下最好的方案是利用獨立的聊天功能元件,作為專案的中介軟體(Paas方案)。

  1. 元件是獨立部署,獨立執行的,功能的穩定性,搭建速度快,
  2. 作為基礎設施服務,可以用在其他專案中,並且專案中的對接作為抽象層,可隨時替換現有元件。

這個聊天元件就是RocketChat。
RocketChat 是一款免費,開源的聊天軟體平臺。
其主要功能是:群組聊天、相互通訊、私密聊群、桌面通知、檔案上傳、語音/視訊、截圖等,實現了使用者之間的實時訊息轉換。
https://github.com/RocketChat/Rocket.Chat

它本身是使用Meteor全棧框架以JavaScript開發的Web聊天伺服器。本身帶有一個精美的web端,甚至有開源的App端。
整合到一個既有的專案中我們是需要做減法的,然而在實際對接中,我們仍然需要解決一些問題:
首先是Rocket.Chat自己有一套獨立的使用者系統,其中登入鑑權邏輯,這一部分是我們不需要的。
第二是Rocket.Chat聊天功能依賴這個使用者系統,需要簡化流程同步使用者資訊,只保留使用者,不需要許可權,角色。

準備工作:搭建Rocket.Chat服務

Rocket.Chat有兩套Api,一個是基於https的REST Api,和一個基於wss的Realtime API, https://developer.rocket.chat/reference/api/realtime-api
這兩個Api都需要鑑權。

解決這個有兩套方案,一個是通過完全的後端接管,兩個Api都經過後端專案進行轉發,另一個是後端只接管REST Api, Realtime API和Rocket.Chat服務直接通訊

專案搭建

後端

新建一個.Net 6 Abp專案後,新增AbpBoilerplate.RocketChat庫,AbpBoilerplate.RocketChat的具體介紹請參考https://blog.csdn.net/jevonsflash/article/details/128342430

dotnet add package AbpBoilerplate.RocketChat

在Domain層中建立IM專案,建立Authorization目錄存放與IM鑑權相關的程式碼,ImWebSocket目錄用於存放處理Realtime API相關的程式碼.

在搭建Rocket.Chat環節,還記得有一個設定管理員的步驟嗎?在AdminUserName和AdminPassword設定中,指定這個管理員的密碼,

管理員用於在使用者未登入時,提供操作的許可權主體,

  "Im": {
    "Provider": "RocketChat",
    "Address": "http://localhost:3000/",
    "WebSocketAddress": "ws://localhost:3000/",
    "AdminUserName": "super",
    "AdminPassword": "123qwe",
    "DefaultPassword": "123qwe"
  }

前端

用vue2來搭建一個簡單的前端介面,需要用到以下庫

  • element-UI庫
  • axios
  • vuex
  • signalr
    新建一個vue專案,在package.json中的 "dependencies"新增如下:
"axios": "^0.26.1",
"element-ui": "^2.15.6",
"@microsoft/signalr": "^5.0.6"
"vuex": "^3.6.2"

代理賬號

代理賬號是一個管理員賬號
在程式的啟動時,要登入這個管理員賬號,並儲存Token,程式停止時退出登入這個賬號。
我們需要一個cache儲存管理員賬號的登入資訊(使用者ID和Token)
在Threads目錄下建立ImAdminAgentAuthBackgroundWorker,
並在ImModule中註冊這個後臺任務

private async Task LoginAdminAgent()
{
    var userName = rocketChatConfiguration.AdminUserName;
    var password = rocketChatConfiguration.AdminPassword;
    var loginResult = await imManager.Authenticate(userName, password);
    if (loginResult.Success && loginResult.Content != null)
    {
        var cache = imAdminAgentCache.GetCache("ImAdminAgent");
        await cache.SetAsync("UserId", loginResult.Content.Data.UserId);
        await cache.SetAsync("AuthToken", loginResult.Content.Data.AuthToken);
        await cache.SetAsync("UserName", userName);
    }
    else
    {
        throw new UserFriendlyException("無法登入IM服務Admin代理賬號");
    }
}

public override async void Stop()
{
    base.Stop();
    var cache = imAdminAgentCache.GetCache("ImAdminAgent");
    var token = (string)cache.Get("AuthToken", (i) => { return string.Empty; });
    var userId = (string)cache.Get("UserId", (i) => { return string.Empty; });

    if (string.IsNullOrEmpty(token) || string.IsNullOrEmpty(userId))
    {
        return;
    }

    using (_iocManager.IocContainer.BeginScope()) //extension method
    {
        _iocManager.Resolve<SessionContextDto>().Token = token;
        _iocManager.Resolve<SessionContextDto>().UserId = userId;
        _iocManager.Resolve<SessionContextDto>().IsAuthorized = true;


        try
        {
            await imManager.Logout();
        }
        catch (Exception ex)
        {

            throw;
        }
    }

}

SessionContextDto是一個對談上下文物件,在.net專案中,登入校驗成功後寫入,在請求Rocket.Chat的時候讀取,並寫入到請求頭中。

在ImModule的PostInitialize方法中註冊ImAdminAgentAuthBackgroundWorker

public override void PostInitialize()
{
    var workerManager = IocManager.Resolve<IBackgroundWorkerManager>();
    workerManager.Add(IocManager.Resolve<ImAdminAgentAuthBackgroundWorker>());
}

使用者登入時,需要傳使用者名稱密碼,使用者名稱是跟.net專案中相同的,密碼可以獨立設定,也可以設定約定一個預設密碼,那麼新建使用者和登入的時候,可以不用傳密碼,直接使用預設密碼即可,使用者成功登入後,將使用者ID和Token回傳給前端。

定義傳輸物件類AuthenticateResultDto

public class AuthenticateResultDto
{
    public string AccessToken { get; set; }
    public string UserId { get; set; }
}

在應用層中建立類ImAppService,建立應用層服務Authenticate,用於使用者登入。

 private async Task<AuthenticateResultDto> Authenticate(MatoAppSample.Authorization.Users.User user, string password = null)
{
    var loginResult = await _imManager.Authenticate(user.UserName, password);

    if (loginResult.Success)
    {
        var userId = loginResult.Content.Data.UserId;
        var token = loginResult.Content.Data.AuthToken;
        this.imAuthTokenCache.Set(user.UserName, new ImAuthTokenCacheItem(userId, token), new TimeSpan(1, 0, 0));
    }
    else
    {
        this.imAuthTokenCache.Remove(user.UserName);
        throw new UserFriendlyException($"登入失敗, {loginResult.Error}");

    }
    return new AuthenticateResultDto
    {
        AccessToken = loginResult.Content.Data.AuthToken,
        UserId = loginResult.Content.Data.UserId
    };
}

鑑權方式介紹

由於Rocket.Chat的Realtime API基於REST API基礎上進行鑑權,在呼叫完成/api/v1/login介面後,需要在已經建立的Websocket連線中傳送

{
    "msg": "method",
    "method": "login",
    "id": "42",
    "params":[
        { "resume": "auth-token" }
    ]
}

詳見官方檔案

在整合RocketChat時,對於Realtime API方案有二:

  1. 前端鑑權,前端通過Abp登入後,呼叫/api/v1/login介面,返回token之後存入前端Token快取中,之後前端將與Rocketchat直接建立websocket聯絡,訂閱的聊天訊息和房間訊息將被直接推播至前端。

    優點是訊息訂閱推播直接,效率較高,但前端需要同時顧及Abp的鑑權和RocketChat Realtime API鑑權,前端的程式碼邏輯複雜,代理賬號邏輯複雜,後期擴充套件性差。小型專案適合此方式

  2. 後端鑑權,前端通過Abp登入後,呼叫/api/v1/login介面,返回token之後存入後端Token快取中,由後端發起websocket連線,訂閱的聊天訊息和房間訊息將被轉發成signalR訊息傳送給前端,由後端快取過期機制統一管理各連線的生命週期。

    優點是統一了前端的訊息推播機制,架構更趨於合理,對於多使用者端的大型專案,能夠減少前端不必要的程式碼邏輯。但是後端的程式碼會複雜一些。適合中大型專案。

Realtime API 的前端鑑權


Realtime API 的後端鑑權

登入校驗模組

前端鑑權方式

由於是從小程式,或者web端共用的所以要分別從Header和Cookie中獲取登入資訊,IHttpContextAccessor型別的引數用於從http請求上下文物件中存取Header或Cookie,

整個流程如下:

建立AuthorizedFrontendWrapper.cs,新建AuthorizationVerification方法,此方法是登入校驗邏輯

private static void AuthorizationVerification(IHttpContextAccessor _httpContextAccessor, bool useAdminIfNotAuthorized, out StringValues? token, out StringValues? userId)
{
    var isCommonUserLoginPassed = true;
    token = _httpContextAccessor.HttpContext?.Request.Headers["X-Auth-Token"];
    userId = _httpContextAccessor.HttpContext?.Request.Headers["X-User-Id"];
    if (!ValidateToken(token, userId))
    {

        token = _httpContextAccessor.HttpContext?.Request.Cookies["chat_token"];
        userId = _httpContextAccessor.HttpContext?.Request.Cookies["chat_uid"];
        if (!ValidateToken(token, userId))
        {
            isCommonUserLoginPassed = false;
        }
    }

    var cache = Manager.GetCache("ImAdminAgent");
    if (!isCommonUserLoginPassed)
    {
        if (useAdminIfNotAuthorized)
        {
            //若不存在則取admin作為主體
            token = (string)cache.Get("AuthToken", (i) => { return string.Empty; });
            userId = (string)cache.Get("UserId", (i) => { return string.Empty; });
            if (!ValidateToken(token, userId))
            {
                throw new UserFriendlyException("操作未取得IM服務授權, 當前使用者未登入,且初始代理使用者未登入");
            }
        }
        else
        {
            throw new UserFriendlyException("操作未取得IM服務授權, 當前使用者未登入");
        }
    }
    else
    {
        if ((string)cache.Get("UserId", (i) => { return string.Empty; }) == userId.Value)
        {
            token = (string)cache.Get("AuthToken", (i) => { return string.Empty; });
            if (!ValidateToken(token, userId))
            {
                throw new UserFriendlyException("操作未取得IM服務授權, 初始代理使用者未登入");
            }
        }
    }
}

後端鑑權方式

整個流程如下:

建立AuthorizedBackendWrapper.cs,新建AuthorizationVerification方法,登入校驗程式碼如下

public void AuthorizationVerification(out string token, out string userId)
{
    User user = null;
    try
    {
        user = userManager.FindByIdAsync(abpSession.GetUserId().ToString()).Result;
    }
    catch (Exception)
    {
    }

    var userName = user != null ? user.UserName : rocketChatConfiguration.AdminUserName;
    var password = user != null ? ImUserDefaultPassword : rocketChatConfiguration.AdminPassword;
    var userIdAndToken = imAuthTokenCache.Get(userName, (i) => { return default; });
    if (userIdAndToken == default)
    {
        var loginResult = imManager.Authenticate(userName, password).Result;
        if (loginResult.Success && loginResult.Content != null)
        {
            userId = loginResult.Content.Data.UserId;
            token = loginResult.Content.Data.AuthToken;
            var imAuthTokenCacheItem = new ImAuthTokenCacheItem(userId, token);
            imAuthTokenCache.Set(userName, imAuthTokenCacheItem, new TimeSpan(1, 0, 0));
            var userIdentifier = abpSession.ToUserIdentifier();
            if (userIdentifier != null)
            {
                Task.Run(async () =>
                {
                    await Login(imAuthTokenCacheItem, userIdentifier, userName);
                });
            }
        }
        else
        {
            var adminUserName = rocketChatConfiguration.AdminUserName;
            var adminPassword = rocketChatConfiguration.AdminPassword;
            var adminLoginResult = imManager.Authenticate(adminUserName, adminPassword).Result;
            if (adminLoginResult.Success && adminLoginResult.Content != null)
            {
                userId = adminLoginResult.Content.Data.UserId;
                token = adminLoginResult.Content.Data.AuthToken;
                if (!ValidateToken(token, userId))
                {
                    throw new UserFriendlyException("操作未取得IM服務授權, 無法登入賬號" + userName);
                }
            }
            else
            {
                throw new UserFriendlyException("賬號登入失敗:" + adminLoginResult.Error);

            }

        }

    }
    else
    {
        userId = userIdAndToken.UserId;
        token = userIdAndToken.Token;
    }
    if (!ValidateToken(token, userId))
    {
        throw new UserFriendlyException("操作未取得IM服務授權, 登入失敗");
    }
}

登入委託

在AuthorizedFrontendWrapper(或AuthorizedBackendWrapper)中

寫一個登入委託AuthorizedChatAction,用於包裝一個需要登入之後才能使用的操作

public static async Task AuthorizedChatAction(Func<Task> func, IocManager _iocManager)
{
    if (_iocManager.IsRegistered<SessionContextDto>())
    {
        string token, userId;
        AuthorizationVerification(out token, out userId);

        using (_iocManager.IocContainer.Begin()) //extension method
        {
            _iocManager.Resolve<SessionContextDto>().Token = token;
            _iocManager.Resolve<SessionContextDto>().UserId = userId;
            _iocManager.Resolve<SessionContextDto>().IsAuthorized = true;
            try
            {
                await func();
            }
            catch (Exception ex)
            {
                throw;
            }
        }
    }
    else
    {
        throw new UserFriendlyException("沒有註冊即時通訊對談上下文物件");
    }
}

使用登入委託

我們在建立IM相關方法的時候,需要用AuthorizedFrontendWrapper(或AuthorizedBackendWrapper),來包裝登入校驗的邏輯。

public async Task<bool> DeleteUser(long userId)
{
    var user = await _userManager.GetUserByIdAsync(userId);
    var result = await AuthorizedBackendWrapper.AuthorizedChatAction(() =>
    {
        return _imManager.DeleteUser(user.UserName);
    }, _iocManager);

    if (!result.Success || !result.Content)
    {
        throw new UserFriendlyException($"刪除失敗, {result.Error}");
    }
    return result.Content;
}

處理聊天訊息

前端鑑權方式

新建messageHandler_frontend_auth.ts處理程式

使用者端支援WebSocket的瀏覽器中,在建立socket後,可以通過onopen、onmessage、onclose和onerror四個事件對socket進行響應。

我已經封裝好了一個WebSocket 通訊模組\web\src\utils\socket.ts,Socket物件是一個WebSocket抽象,後期將擴充套件到uniapp小程式專案上使用的WebSocket。通過這個物件可以方便的進行操作。

建立一個Socket物件wsConnection,用於接收和傳送基於wss的Realtime API訊息

const wsRequestUrl: string = "ws://localhost:3000/websocket";

const socketOpt: ISocketOption = {
  server: wsRequestUrl,
  reconnect: true,
  reconnectDelay: 2000,
};

const wsConnection: Socket = new Socket(socketOpt);

WebSocket的所有操作都是採用事件的方式觸發的,這樣不會阻塞UI,是的UI有更快的響應時間,有更好的使用者體驗。

連線建立後,使用者端和伺服器就可以通過TCP連線直接交換資料。我們訂閱onmessage事件觸發newMsgHandler處理資訊

wsConnection.$on("message", newMsgHandler);

當連結開啟後,立即傳送{"msg":"connect","version":"1","support":["1","pre2","pre1"]}報文

wsConnection.$on("open", (newMsg) => {
    console.info("WebSocket Connected");
    wsConnection.send({
      msg: "connect",
      version: "1",
      support: ["1"],
    });
  });

建立連結後,會從Rocket.Chat收到connected訊息,此時需要傳送登入請求的訊息到Rocket.Chat
接收到報文

"{"msg":"connected","session":"cMvzWpCNSCR24bwCf"}"

傳送報文

{"msg":"method","method":"login","params":[{"resume":"wY67O8rJFyf2FrqD5vxpQjIUs5tdThmyfW_VaA7MrsG"}],"id":"1"}

接下來,在newMsgHandler方法中,根據msg型別,處理一系列的訊息

const newMsgHandler: Function = (newMsgRaw) => {
  if (!getIsNull(newMsgRaw)) {
    if (newMsgRaw.msg == "ping") {
      wsConnection.send({
        msg: "pong",
      });
    } else if (newMsgRaw.msg == "connected") {
      let newMsg: ConnectedWsDto = newMsgRaw
      let session = newMsg.session;
      if (
        wsConnection.isConnected
      ) {
        wsConnection.send({
          msg: "method",
          method: "login",
          params: [
            {
              resume: UserModule.chatToken,
            },
          ],
          id: "1",
        });
      }
    } else if (newMsgRaw.msg == "added") {
      subEvent("stream-notify-user", "message");
      subEvent("stream-notify-user", "subscriptions-changed");
      subEvent("stream-notify-user", "rooms-changed");
    } else if (newMsgRaw.msg == "changed") {
      let newMsg: SubChangedWsDto = newMsgRaw
      if (newMsg.collection == "stream-notify-user") {
        let fields = newMsg.fields;
        if (fields.eventName.indexOf("/") != -1) {
          let id = fields.eventName.split('/')[0];
          let eventName = fields.eventName.split('/')[1];
          if (eventName == "subscriptions-changed") {
            let args = fields.args;
            let msg: ISubscription = null;
            let method: string;
            args.forEach((arg) => {
              if (typeof arg == "string") {
                if (arg == "remove" || arg == "insert") {
                  method = arg;
                }
              }
              else if (typeof arg == "object") {
                msg = arg
              }
            });
            $EventBus.$emit("getRoomSubscriptionChangedNotification", { msg, method });
          }
          else if (eventName == "rooms-changed") {
            let args = fields.args;
            let msg: RoomMessageNotificationDto = null;
            args.forEach((arg) => {
              if (typeof arg == "object") {
                msg = arg
              }
            });
            $EventBus.$emit("getRoomMessageNotification", msg.lastMessage);

          }
        }
        else {
          let id = fields.eventName
        }


      }
      else if (newMsg.collection == "stream-room-messages") {
        let fields = newMsg.fields;

        let id = fields.eventName
        let msg: MessageItemDto = fields.args;

        $EventBus.$emit("getRoomMessageNotification", msg);
      }
    }
  }
}

store/chat.ts檔案中,定義了ChatState用於儲存聊天資訊,當有訊息收到,或者房間資訊變更時,更新這些儲存物件

export interface IChatState {
  currentChannel: ChannelDto;
  channelList: Array<ChannelDto>;
  currentMessage: MessageDto;
}

後端校驗方式

Login時將生成webSocket物件,並行送connect訊息

public async Task Login(ImAuthTokenCacheItem imAuthTokenCacheItem, UserIdentifier userIdentifier, string userName)
{
    using (var webSocket = new ClientWebSocket())
    {
        webSocket.Options.RemoteCertificateValidationCallback = delegate { return true; };
        var url = Flurl.Url.Combine(rocketChatConfiguration.WebSocketHost, "websocket");
        await webSocket.ConnectAsync(new Uri(url), CancellationToken.None);
        if (webSocket.State == WebSocketState.Open)
        {

            var model = new ImWebSocketConnectRequest()
            {
                Msg = "connect",
                Version = "1",
                Support = new string[] { "1" }
            };
            var jsonStr = JsonConvert.SerializeObject(model);
            var sendStr = Encoding.UTF8.GetBytes(jsonStr);
            await webSocket.SendAsync(sendStr, WebSocketMessageType.Text, true, CancellationToken.None);
            await Echo(webSocket, imAuthTokenCacheItem, userIdentifier, userName);
        }
    }
}

每次接收指令時,將判斷快取中的Token值是否合法,若不存在,或過期(session變化),將主動斷開websocket連線
在接收Realtime API訊息後,解析方式同前端鑑權邏輯
在拿到資料後,做signalR轉發。

private async Task Echo(WebSocket webSocket, ImAuthTokenCacheItem imAuthTokenCacheItem, UserIdentifier userIdentifier, string userName)
{
    JsonSerializerSettings serializerSettings = new JsonSerializerSettings()
    {
        NullValueHandling = NullValueHandling.Ignore
    };
    var buffer = new byte[1024 * 4];
    var receiveResult = await webSocket.ReceiveAsync(
        new ArraySegment<byte>(buffer), CancellationToken.None);
    string session=string.Empty;
    ImAuthTokenCacheItem im;
    while (!receiveResult.CloseStatus.HasValue)
    {
        im = imAuthTokenCache.GetOrDefault(userName);
        if (im == null)
        {
            await webSocket.CloseAsync(WebSocketCloseStatus.NormalClosure,
                "快取超時自動退出",
                CancellationToken.None);
            Console.WriteLine(userName + "超時主動斷開IM連線");

            break;


        }
        else
        {
            if (!string.IsNullOrEmpty(session) && im.Session!=session)
            {
                await webSocket.CloseAsync(WebSocketCloseStatus.NormalClosure,
                    "快取更新自動退出",
                    CancellationToken.None);
                Console.WriteLine(userName + "快取更新主動斷開IM連線");

                break;
            }
        }
        var text = Encoding.UTF8.GetString(buffer.AsSpan(0, receiveResult.Count));
        if (!string.IsNullOrEmpty(text))
        {
            dynamic response = JsonConvert.DeserializeObject<dynamic>(text);
            if (response.msg == "ping")
            {

                var model = new ImWebSocketCommandRequest()
                {
                    Msg = "pong",
                };

                var jsonStr = JsonConvert.SerializeObject(model, serializerSettings);
                var sendStr = Encoding.UTF8.GetBytes(jsonStr);
                await webSocket.SendAsync(sendStr, WebSocketMessageType.Text, true, CancellationToken.None);

            }
            if (response.msg == "connected")
            {
                session = response.session;

                var model = new ImWebSocketCommandRequest()
                {
                    Msg = "method",
                    Method = "login",
                    Params = new object[]{
                        new {
                            resume = imAuthTokenCacheItem.Token,
                        }
                    },
                    Id = "1"
                };
                imAuthTokenCacheItem.Session = session;
                imAuthTokenCache.Set(userName, imAuthTokenCacheItem, new TimeSpan(1, 0, 0));

                var jsonStr = JsonConvert.SerializeObject(model, serializerSettings);
                var sendStr = Encoding.UTF8.GetBytes(jsonStr);
                await webSocket.SendAsync(sendStr, WebSocketMessageType.Text, true, CancellationToken.None);

            }
            else if (response.msg == "added")
            {
                await SubEvent(webSocket, imAuthTokenCacheItem, "stream-notify-user", "message");
                await SubEvent(webSocket, imAuthTokenCacheItem, "stream-notify-user", "subscriptions-changed");
                await SubEvent(webSocket, imAuthTokenCacheItem, "stream-notify-user", "rooms-changed");
            }
            else if (response.msg == "changed")
            {
                var newMsg = response;
                if (newMsg.collection == "stream-notify-user")
                {
                    var fields = newMsg.fields;
                    var fullEventName = fields.eventName.ToString();
                    if (fullEventName.IndexOf("/") != -1)
                    {
                        var id = fullEventName.Split('/')[0];
                        var eventName = fullEventName.Split('/')[1];
                        if (eventName == "subscriptions-changed")
                        {
                            var args = fields.args;
                            dynamic msg = null;
                            var method = string.Empty;

                            foreach (var arg in args as IEnumerable<dynamic>)
                            {

                                if (arg.ToString() == "remove" || arg.ToString() == "insert")
                                {
                                    method = arg.ToString();
                                }

                                else
                                {
                                    msg = arg;
                                }
                            }

                            await signalREventPublisher.PublishAsync(userIdentifier, "getRoomSubscriptionChangedNotification", new { msg, method });
                        }
                        else if (eventName == "rooms-changed")
                        {
                            var args = fields.args;
                            dynamic msg = null;
                            var method = string.Empty;
                            foreach (var arg in args as IEnumerable<dynamic>)
                            {

                                if (arg.ToString() == "updated")
                                {
                                    method = arg.ToString();
                                }

                                else
                                {
                                    msg = arg;
                                }
                            };

                            var jobject = msg.lastMessage as JObject;

                            await signalREventPublisher.PublishAsync(userIdentifier, "getRoomMessageNotification", jobject);

                        }
                    }
                    else
                    {
                        var id = fields.eventName;
                    }
                }

            }
            else if (response.collection == "stream-room-messages")
            {
                var fields = response.fields;
                var id = fields.eventName;
                var msg = fields.args;
                var jobject = msg as JObject;
                await signalREventPublisher.PublishAsync(userIdentifier, "getRoomMessageNotification", jobject);
            }
        }
        try
        {
            receiveResult = await webSocket.ReceiveAsync(
new ArraySegment<byte>(buffer), CancellationToken.None);
        }
        catch (Exception ex)
        {
            Console.WriteLine(userName + "異常斷開IM連線");

            break;
        }

    }

    try
    {
        await webSocket.CloseAsync(
receiveResult.CloseStatus.Value,
receiveResult.CloseStatusDescription,
CancellationToken.None);
    }
    catch (Exception ex)
    {
    }

    imAuthTokenCache.Remove(userName);

}

private async Task SubEvent(WebSocket webSocket, ImAuthTokenCacheItem imAuthTokenCacheItem, string name, string type)
{
    var eventstr = $"{imAuthTokenCacheItem.UserId}/${type}";
    var id = RandomHelper.GetRandom(100000).ToString().PadRight(5, '0');

    var model = new ImWebSocketCommandRequest()
    {
        Msg = "sub",
        Params = new object[]{eventstr,
            new {
                useCollection= false,
                args = new string[]{ }
            }
        },
        Id = id,
        Name = name,
    };
    var jsonStr = JsonConvert.SerializeObject(model);
    var sendStr = Encoding.UTF8.GetBytes(jsonStr);
    await webSocket.SendAsync(sendStr, WebSocketMessageType.Text, true, CancellationToken.None);
}

SignalREventPublisher.cs 中的PublishAsync,將訊息轉發給對應的使用者。

public async Task PublishAsync(IUserIdentifier userIdentifier, string method, object message)
{

    try
    {
        var onlineClients = _onlineClientManager.GetAllByUserId(userIdentifier);
        foreach (var onlineClient in onlineClients)
        {
            var signalRClient = _hubContext.Clients.Client(onlineClient.ConnectionId);
            if (signalRClient == null)
            {
                Logger.Debug("Can not get user " + userIdentifier.ToUserIdentifier() + " with connectionId " + onlineClient.ConnectionId + " from SignalR hub!");
                continue;
            }

            await signalRClient.SendAsync(method, message);
        }
    }
    catch (Exception ex)
    {
        Logger.Warn("Could not send notification to user: " + userIdentifier.ToUserIdentifier());
        Logger.Warn(ex.ToString(), ex);
    }

}

前端程式碼則要簡單得多
新建messageHandler_backend_auth.ts處理程式

import * as signalR from "@microsoft/signalr";

建立一個HubConnection物件hubConnection,用於接收SignalR訊息

const baseURL = "http://localhost:44311/"; // url = base url + request url
const requestUrl = "signalr";
let header = {};
if (UserModule.token) {
  header = {
    "X-XSRF-TOKEN": UserModule.token,
    Authorization: "Bearer " + UserModule.token,
  };
}

//signalR config
const hubConnection: signalR.HubConnection = new signalR.HubConnectionBuilder()
  .withUrl(baseURL + requestUrl, {
    headers: header,
    accessTokenFactory: () => getAccessToken(),
    transport: signalR.HttpTransportType.WebSockets,
    logMessageContent: true,
    logger: signalR.LogLevel.Trace,
  })
  .withAutomaticReconnect()
  .withHubProtocol(new signalR.JsonHubProtocol())
  .build();

我們只需要響應後端程式中定義好的signalR訊息的methodName就可以了

hubConnection.on("getRoomMessageNotification", (n: MessageItemDto) => {
  console.info(n.msg)
  if (ChatModule.currentChannel._id != n.rid) {
    ChatModule.increaseChannelUnread(n.rid);
  } else {
    if (n.t == null) {
      n.from =
        n.u.username == UserModule.userName
          ? constant.MSG_FROM_SELF
          : constant.MSG_FROM_OPPOSITE;
    } else {
      n.from = constant.MSG_FROM_SYSTEM;
    }
    ChatModule.appendMessage(n);
  }
});

hubConnection.on("getRoomSubscriptionChangedNotification", (n) => {
  console.info(n.method, n.msg)

  if (n.method == "insert") {
    console.info(n.msg + "has been inserted!");

    ChatModule.insertChannel(n.msg);

  }
  else if (n.method == "update") {

  }
});

至此,完成了所有的整合工作。

此文目的是介紹一種思路,使用快取生命週期管理的相關機制,規避第三方使用者系統對現有專案的使用者系統的影響。舉一反三,可以用到其他Paas的方案整合中。最近ChatGPT很火,可惜沒時間研究怎麼接入,有閒工夫的同學們可以嘗試著寫一個ChatGPT聊天機器人,歡迎大家評論留言!

最終效果如圖

專案地址

Github:matoapp-samples