用Nodejs 實現一個簡單的 Redis客戶端

0. 寫在前面

大家如果有去看過nodejs所支援的官方庫的話,應該會驚訝於它所提供了非常完善的網路庫,不僅是應用層,傳輸層,等等基礎的協議,我們可以按照事件驅動的邏輯編寫清晰易懂的網路應用,網路服務。這也是本文為什麼選擇Nodejs編寫的原因。

1. 背景映入

大家在使用一些資料庫軟體的時候常常會使用遠程連接

mysql -h xxx.xxx.xxx.xx -u xzzz -p

這裡也指明了ip地址,但是很明顯這裡可不是http協議在服務,而是更加底層的協議 – 傳輸層協議,具體來說是TCP協議(Transmission Control Protocol)。通訊的示意圖如下:
image
所以很自然的想到,資料庫的客戶端一定經過如下流程,從而與遠程相連接:

graph TB
身份驗證 –> 運輸層連接建立
運輸層連接建立 –> 客戶端服務端輸入輸出綁定_通道
客戶端服務端輸入輸出綁定_通道 –> 連接中斷
連接中斷 –> 雙方退出釋放資源

所以我們可以嘗試向服務端發送這樣的請求消息,建立與服務端的連接,發送一些數據,接受一些數據,最後斷開連接。

2. 資料庫選擇

這裡為了簡單起見,我們考慮不需要身份驗證的redis資料庫來作為此次實驗的服務端。
如果大家是mac,或者linux倒是可以直接安裝,如果是windows的話,推薦使用docker進行安裝,這裡給出一行docker命令。

docker run  --name redis-server -p 6379:6379 -d redis:latest

3. Nodejs TCP連接

在nodejs中支援TCP連接的是net模組, 其中使用createConnection(config)或者直接new Socket(config)來初始化一個TCP連接。
上面兩個函數不論哪一個都會返回socket實例,如果連接正常的話,就可以通過這個socket發送消息了。
image
image
當服務端redis接收到消息之後也會返回相應的消息,在本機客戶端通過對數據的校驗,檢查後,觸發相應的操作(是拒絕還是接受服務端的響應)。

3. 程式碼編寫

知道了原理之後,我這裡直接把程式碼貼出來

  • RedisSocket: 繼承自Socket
class RedisSocket extends Socket {
    constructor(config: RedisClientConfig) {
        super();
        this.connect(config.port, config.host);
    }
	// Set
    public set(key: string, value: string | number): Promise<Buffer> {
        return new Promise((resolve, reject) => {
            this.write(`SET ${key} ${value}\n`);
            const fetchAns = (chunk: Buffer) => {
                if (chunk.toString().includes("OK")) {
                    resolve(chunk);
                    this.off("data", fetchAns);
					// 在交付完成之後使用off 把函數取消綁定
                } else {
                    reject("error! can't set data");
                }
            }
            this.on("data", fetchAns);
        })
    }
	// Get
    public get(key: string): Promise<Buffer> {
        return new Promise((resolve, reject) => {
            try {
                this.write(`GET ${key}\n`);
                const fetchAns = (chunk: Buffer) => {
                    resolve(chunk);
                    this.off("data", fetchAns);
					// 在交付完成之後使用off 把函數取消綁定
                }
                this.on("data", fetchAns);
            } catch(err) {
                reject(err);
            }
        })
    }
	// 斷開TCP
    public close() {
        this.end();
    }
}

這個類將用來處理建立好後的連接的

  • RedisClient
class RedisClient {

    private config: RedisClientConfig;

    constructor(config: RedisClientConfig) {
        this.config = config; // 配置項
    }

	// 獲取redis實例
    getConnection(): Promise<RedisSocket> {
        return new Promise((resolve, reject) => {
            const socket = new RedisSocket(this.config);

            socket.on("connect", () => {
                resolve(socket);
            });

            socket.on("error", (err) => {
                reject(err);
            });
        });
    }
}

這個類用來建立與服務端的連接,使用getConnection()方法,將會交付一個redisSocket,使用這個Socket可以直接向server發送和接受數據。

4. 實驗

import { RedisClient, RedisSocket } from "./src/Client";


const Redis = new RedisClient({
    host: "localhost",
    port: 6379
});


Redis.getConnection().then((socket: RedisSocket) => {
    socket.set("Mushroom", "Cookie");
    socket.set("Mici", "Icmi").then( () => {
        socket.get("Mushroom").then((data: Buffer) => {
            console.log(data.toString());
            socket.close();
        })
    });
})

這裡使用RedisClient建立與本地redis的連接,隨後通過getConnection()獲取到連接實例,並通過這個連接實例設置了兩個數據,以及獲取了一數據並列印了出來。

> pnpm dev
> $6 // 這裡的$6你也許會感到奇怪,不過我們很快就會知道這是什麼
> Cookie

5. wireshark 抓包分析

image
這一次請求就是一整個完整的TCP流程,
在這其中TCP保證數據的可靠傳輸,而RESP(REdis Serialization Protocol)把數據封裝成一個fragment段,發送到下面的TCP
服務端相應的時候也是如此,會把數據封裝起來發送到TCP中轉發出去。

看看發送方的RESP
image
看看響應的RESP
image
image
所以知道了嗎?沒錯,6其實就是長度那一部分強行轉化為字元串的結果,所以在現在很多流行的redis客戶端中如ioredis都對RESP報文做了非常完備的解析,這使得開發者能夠非常絲滑的與redis服務端交互。(感謝這些開發者做的一切!)

6. 雜與程式碼

Github 倉庫

希望大家都對世界保持好奇!