從 IM 通訊 Web SDK 來看如何提高程式碼可維護性與可擴充套件性

hjava發表於2019-02-16

本文內容概述

在架構設計和功能開發中,程式碼的可維護性和可擴充套件性一直是工程師不懈的追求。本文將以我工作中開發的 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 的開發與維護。

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

相關文章