從 IM 通信 Web SDK 來看如何提高代碼可維護性與可擴展性

本文內容概述

在架構設計和功能開發中,代碼的可維護性和可擴展性一直是工程師不懈的追求。本文將以我工作中開發的 IM 通信服務 SDK 作爲示例,和大家一起探討下前端基礎服務類業務的代碼中對可維護性和可擴展方面的探索。

本文不涉及具體的代碼和技術相關細節,如果想了解 IM 長連接相關的技術細節,可以閱讀我之前的文章:

背景介紹

大象 SDK 是美團生態中負責 IM 通信服務的基礎服務。作爲 IM 通信服務的 Web 端載體,我們對不同的業務線提供不同的功能來滿足特定的需求,同時需要支持 PC、Web、移動端H5、微信小程序等各個平臺。

不同的業務方需求和不同的平臺對 Web SDK 的功能和模塊要求都不相同,因此在整個 Web SDK 中有許多部分存在需要適配多場景的情況。

處理這種常見的場景,我們一般有以下幾個思路:

  1. 針對不同的場景單獨開發不同的基礎服務代碼。這種操作靈活性最強,但是成本也是最高的,如果我們需要面對 M 個業務需求和 N 個平臺,我們就需要有 M * N 套代碼。這個對於技術人員來說,基本上是一個不可能接受的情況。
  2. 將所有的代碼全部聚合到一個業務模塊中,通過內部的 IF ELSE 判斷邏輯來自動選擇需要執行的代碼邏輯。這種方案不會出現相同代碼重複編寫的情況,同時也兼顧了靈活性,看上去是一個不錯的選擇。但是我們仔細一想就會發現,所有的代碼都堆積到一起,在後期會遇到大量的判斷邏輯,在可維護性上來看是一個巨大的災難。同時,我們所有的代碼都放到一起,這會導致我們的包體積越來越大,而其他業務在使用相關功能時,也會引入大量無用代碼,浪費流量。

那麼,我們在既需要兼顧可維護性,有需要保證開發效率的情況下,我們應該如何去進行相關業務的架構設計呢?

核心原則

在我的設計理念中,有這麼幾個原則需要遵守:

  1. 針對接口規範編程,而不針對特定代碼編程(即設計模式中的策略模式)。我們在進行架構設計時,優先判斷各個功能和模塊中流轉的數據格式和交互的數據接口規範,這樣我們可以保證在進行特定代碼編寫的時候,只針對具體格式進行數據處理,而不會設計到數據內容本身。
  2. 各模塊權責分明,寬進嚴出。每個模塊都是單一全責,暴露特定數據格式的 API,處理約定好數據格式的內容。
  3. 提供方案供用戶選擇,而不幫用戶做決策。我們不去判斷用戶所在環境、選擇功能,而是提供多個選擇來讓用戶主動去做這個決策。

具體實踐

上面的原則可能比較抽象,我們來看幾個具體的場景,大家就能夠對這個有一個特定的概念。

連接模塊設計(長連接部分)

連接模塊包含長連接和短連接部分,我們在這裏就用長連接部分來進行舉例,短連接部分我們可以按照類似的原則進行設計即可。在設計長連接部分時,我們需要考慮的是:連接策略與切換策略。總的來說就是我們需要在什麼時候使用哪一種長連接。

首先,我們以瀏覽器端爲例,我們可以選擇的長連接有:WebSocket 和長輪詢。這個時候,我們可能首先以 WebSocket 優先,而長輪詢作爲備選方案來構成我們的長連接部分。因此,我們可能會在代碼中直接用代碼來實現這個方案。相關僞代碼如下:

import WebSocket from 'websocket';
import LongPolling from 'longPolling';

class Connection {
    private _websocket;
    private _longPolling;

    constructor() {
        this._websocket = new WebSocket();
        this._longPollong = new LongPolling();
    }

    connect() {
        this.websocket.connect();
        // 只表達相關含義用於說明
        if (websocket.isConnected) {
            this.websocket.send(message);
        } else {
            this.longPolling.connect();
        }
    }
}

在正常情況下來看,我們發現這個代碼沒有什麼問題。但是,如果我們的需求發生了某些變化呢?比如我們現在需要在某些特定的場景下,只開啓長輪詢,而不開啓 WebSocket 呢(比如在 IE 瀏覽器裏面)?之前的做法是在構造器的時候,傳遞一個參數進去,用來控制我們是不是開啓 WebSocket。因此,我們的代碼會變成以下的樣子。

class Connection {
    private _useWebSocket;
    private _websocket;
    private _longPolling;

    constructor({useWebSocket}) {
        this._useWebSocket = useWebSocket;
        this._websocket = new WebSocket();
        this._longPollong = new LongPolling();
    }

    connect() {
        if (this._useWebSocket) {
            this.websocket.connect();
            // 只表達相關含義用於說明
            if (websocket.isConnected) {
                this.websocket.send(message);
            } else {
                this.longPolling.connect();
            }
        } else {
            this._longPolling.connect();
        }
    }
}

現在,我們通過增加一個判斷參數,對connect函數進行了簡單的改造,滿足了在特定場景下的指使用長輪詢的需求。

很不幸,我們的問題又來了,我們在針對移動端 H5 的場景下,我們需要一個只要 WebSocket 連接,而不需要長輪詢。那麼,根據我們之前的方式,我們可能又需要在增加一個新的參數useLongPolling。這個代碼示例我就不增加了,大家應該能夠想象出來。

在線上運行了一段時間後,新的需求又來了,我們需要在微信小程序裏面支持 IM 的長連接。那麼,根據我們之前的思路,我們需要在私有屬性和connect方法中增加一堆判斷邏輯。具體示例如下:

import WebSocket from 'websocket';
import LongPolling from 'longPolling';
import WXWebSocket from 'wxwebsocket';

class Connection {
    private _websocket;
    private _longPolling;
    private _wxwebsocket;

    constructor() {
        // 如果在微信小程序容器中
        if (isInWX()) {
            this._wxwebsocket = new WXWebSocket();
        } else {
            this._websocket = new WebSocket();
            this._longPollong = new LongPolling();
        }
    }

    connect() {
        if (isInWx()) {
            this._wxwebsocket.connect();
        } else {
            this.websocket.connect();
            // 只表達相關含義用於說明
            if (websocket.isConnected) {
                this.websocket.send(message);
            } else {
                this.longPolling.connect();
            }
        }
    }
}

從這個例子,大家應該可以發現相關的問題了,如果我們再支持百度小程序、頭條小程序等更多的平臺,我們就會在我們的判斷邏輯裏面加更多的邏輯,這樣會讓我們的可維護性有明顯的下降。

現在有一些類庫可以支持多平臺的接口統一(大家去GitHub上面找一下就可以發現),那麼爲什麼我沒有用相關的產品呢?這是因爲 SDK 作爲一個基礎服務,對包大小比較敏感,同時用到的需要兼容 API 並不多,所以我們自己做相關的兼容比較合適。

那麼,我們應該如何設計這個方案,從而解決這個問題呢。讓我們回顧下我們的設計理念。

  1. 針對接口規範編程,而不針對特定代碼編程。
  2. 各模塊權責分明,寬進嚴出。
  3. 提供方案供用戶選擇,而不幫用戶做決策。

通過這些設計理念,我們來看下具體的做法。

三個設計理念我們需要組合使用。首先是針對結構規範編程。我們來看下具體的用法。

首先我們定義一個長連接的接口如下:

export default interface SocketInterface {
    connect(url: string): void;
    disconnect(): void;
    send(data: any[]): void;
    onOpen(func): void;
    onMessage(func): void;
    onClose(func): void;
    onError(func): void;
    isConnected(): boolean;
}

有了這個長連接的接口類型後,我們可以讓 WebSocket 和長輪詢兩個模塊都實現這個接口。因此,他們就有了統一的 API。有了統一的 API 之後,我們就可以將連接策略中的操作“泛化”,從操作具體的連接方式轉換爲操作被選中的連接方式。

其次,根據我們的各模塊全責分明的原則,我們的連接模塊應該只控制我們的連接策略,並不需要關心她使用的是 WebSocket 還是長輪詢,還是說微信小程序的 API。

道理很簡單,但是具體我們應該怎麼來實踐呢?我們來看下下面這個示例:

class Connection {
    private _sockets = [];
    private _currentSocket;

    constructor({Sockets}) {
        for (let Socket of Sockets) {
            let socket = new Socket();
            socket.onOpen(() => {
                for (let socket of this._sockets) {
                    if (socket.isconnected()) {
                        this._currentSocket = socket;
                    } else {
                        socket.disconnect();
                    }
                }
            });
            this._sockets.push(socket);
        }
    }

    connect() {
        for (let socekt of this._sockets) {
            socket.connect();
        }
    }
}

通過上面這個示例大家可以看到,我們泛化了每一個連接方式的差異,轉爲用統一的接口規範來約束相關的模塊。這樣帶來的好處是,我們如果需要兼容 WebSocket 和長輪詢時,我們可以把這兩個的構造函數傳遞進來;如果我們需要支持微信小程序,我們也只需要將微信小程序的 API 封裝一次,我們就可以得到我們需要的模塊,這樣可以保證我們的連接模塊只負責連接,而不去關心它不該關心的兼容性問題。

那麼由用戶就會問了,那我們是在哪一層來判斷傳入的參數到底是哪些呢?是在這個模塊的上一層嗎?這個問題很簡單,還記得我們的第三個規則是什麼嗎?那就是提供方案供用戶選擇,而不幫用戶做決策。因此,我們在構建長連接部分的時候,我們就在 Webpack 裏面定義一些常量用於判斷我們當前構建時,我們生產的的包是用於什麼場景。具體示例如下:

import Connection from 'connection';
import WebSocket from 'websocket';
import LongPolling from 'longPolling';
import WXWebSocket from 'wxwebsocket';

class WebSDK {
    private _connection;

    constructor() {
        if (CONTAINER_NAME === 'WX') {
            this._connection = new Connection({Sockets: [WXWebSocket]});
        }

        if (CONTAINER_NAME === 'PC') {
            this._connection = new Connection({Sockets: [WebSocket, LongPolling]});
        }

        if (CONTAINER_NAME === 'H5') {
            this._connection = new Connection({Sockets: [WebSocket]});
        }
    }
}

我們通過在 Webpack 中定義 CONTAINER_NAME 這個常量,我們可以在打包時構建不同的 Web SDK 包。在保證對外暴露 API 完全一致的情況下,業務方可以在不同的容器內,採用對應的打包方式,引入不同的 Web SDK 的包,同時不需要改動任何代碼。

可能有人會問了,這個方式看上去其實和之前的方式沒有什麼不同,只是把這個 IF ELSE 的邏輯移動到了外面。但是,我可以告訴大家,這裏有兩個明顯的優勢:

  1. 我們可以抽象單獨的模塊去管理和維護這個獨立的判斷邏輯,它不會和我們的長連接部分代碼進行耦合。
  2. 我們可以在打包過程中使用 tree-shaking,這樣我們可以讓我們的 Web SDK 構建的包中,不會出現我們不需要的模塊的代碼。

消息流處理

上面的長連接部分,我們看到了三個原則的使用。接下來我們來看下我們如何使用這個原則進行數據流的處理。

在 IM 場景中,我們會遇到許多類型的消息。我們以微信公衆號爲例,我們會碰到單聊(單人-單人)、羣聊(單人-羣組)、公衆號(單人-公衆號)等聊天場景。如果我們需要去計算消息的未讀數,同時用消息來更新左側的會話列表,我們就需要三套幾乎完全一樣的邏輯。

那麼,我們有沒有什麼更優的方法呢。很明顯,我們可以根據上面介紹的原則,定義一個消息接口。

interface MessageInterface {
    public fromId: string;
    public toId: string;
    public fromName: string;
    public messageType: number;
    public messageBody;
    public uuid: string;
    public serverId: string;
    public extension: string;
}

通過之前的例子,大家應該可以理解,我們現在的所有業務邏輯,比如更新未讀數、更新會話列表的預覽消息時,我們就只需要針對整個消息接口裏面的數據進行處理。這樣的話,我們的處理流程就會變成一個流水線作業,我們只負責處理特定邏輯的數據,而不管具體的數據內容是什麼樣子的。

因此,如果我們新增一類會話類型,比如客服消息,我們也可以按照上面這個接口去實現客服消息類,複用原來的邏輯,而不需要重新實現一套完整的代碼。

我們的在一開始就需要對數據進行轉換,這樣才能夠保證我們在內部流轉時不會猶豫數據格式不同導致代碼維護性變差。需要注意的是,根據我們的各模塊權責分明,寬進嚴出原則,我們在像其他模塊輸出時,我們也需要保證我們只輸出這一種格式的數據,而接受的數據,我們應該盡最大的努力去適應各種場景。

可能有人會問,我們內部自己規定使用那個系統就可以,控制了嚴出了,我們自然就不用處理寬進了。但是,你寫的代碼和模塊很有可能會和其他人一起維護,這個時候,你只能從規範上面來約束他,而不能控制他。因此,我們在接收其他非同一開發模塊的數據時,我們可能會遇到一些異常情況。這個時候如果我們對寬進有做處理,也能夠保證該模塊可以正常運行。

有了之前的經驗,大家對這個示例應該很好理解,我就不多做介紹了。

總結

這一篇文章沒有介紹什麼代碼層面的東西,而是和大家一起交流了一下,我在日常工作中遇到的一些可能的問題,以及關於設計模式相關的應用場景。

如果我們需要作爲一個基礎服務提供方,需要讓自己的代碼有擴展性和可維護性,我們需要:

  1. 面對接口規範編程。
  2. 單一全責、寬進嚴出。
  3. 不幫用戶做決策。

當然,在用戶產品層面,可能上面的設計有部分相同的地方,也有部分不同的地方,有時間的話,我會在後面再和大家進行分享。

大家如果有興趣的話可以在評論區發表下自己觀點,也可以在評論裏面留言進行討論,也歡迎大家發表自己的觀點。

作者介紹與轉載聲明

黃珏,2015年畢業於華中科技大學,目前任職於美團基礎研發平臺大象業務部,獨立負責大象 Web SDK 的開發與維護。

本文未經作者允許,禁止轉載。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章