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

本文內容概述

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

本文不涉及具體的代碼和技術相關細節,若是想了解 IM 長鏈接相關的技術細節,能夠閱讀我以前的文章:web

背景介紹

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

不一樣的業務方需求和不一樣的平臺對 Web SDK 的功能和模塊要求都不相同,所以在整個 Web SDK 中有許多部分存在須要適配多場景的狀況。小程序

處理這種常見的場景,咱們通常有如下幾個思路:微信小程序

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

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

核心原則

在個人設計理念中,有這麼幾個原則須要遵照:瀏覽器

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

具體實踐

上面的原則可能比較抽象,咱們來看幾個具體的場景,你們就可以對這個有一個特定的概念。微信

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

鏈接模塊包含長鏈接和短鏈接部分,咱們在這裏就用長鏈接部分來進行舉例,短鏈接部分咱們能夠按照相似的原則進行設計便可。在設計長鏈接部分時,咱們須要考慮的是:鏈接策略與切換策略。總的來講就是咱們須要在何時使用哪種長鏈接。websocket

首先,咱們以瀏覽器端爲例,咱們能夠選擇的長鏈接有: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 的開發與維護。

本文未經做者容許,禁止轉載。

相關文章
相關標籤/搜索