iT邦幫忙

2026 iThome 鐵人賽

DAY 13
0

前面我在 resp 模組寫了 RESP 解析器,db 模組也建好了記憶體 KV 引擎,但這兩個現在還是各過各的。

今天要在 command 模組裡補上Command Dispatcher,讓 TCP Server 收到的 RESP 指令真的能打到底層 DB。


Command Dispatcher的主要工作

Command Dispatcher有點像整個系統的入口守門員。它要負責幾件事:

  1. 指令解析:將客戶端傳送過來的 RESP Array 解開,提取出第一個元素作為命令名稱,後續元素作為命令參數。
  2. 命令路由:根據命令名稱,在註冊表中尋找對應的命令處理函數(CommandFunc)。
  3. 安全校驗:若伺服器設置了密碼保護,檢查該連線是否已經過 AUTH 驗證。
  4. 狀態管理:處理事務排隊(MULTI)或發布訂閱狀態。
  5. sync 到磁碟與傳播觸發:若該指令是寫操作(Write Command)且執行成功,觸發 AOF 追加與從節點廣播。

程式碼設計與實作

我在 code/command/dispatcher.go 裡實作了這個機制。

1. 命令處理函數簽名與 Dispatcher 結構

我先定義統一的 CommandFunc 簽名,然後在 Dispatcher 裡維護命令對應表和寫指令標記:

type CommandFunc func(dbEngine *db.DB, client *Client, args [][]byte) resp.Value

type Dispatcher struct {
	mu           sync.RWMutex
	handlers     map[string]CommandFunc
	writeCmds    map[string]bool // 標記寫操作命令
	dbEngine     *db.DB
	requirePass  string
	aofWriteCallback func(val resp.Value) // AOF/Replication 回呼
}

2. 命令註冊機制

我們使用 Register 方法動態註冊命令,並明確標註其是否為寫操作命令。這對於後續的持久化(AOF)和複製(Replication)至關重要:

func (d *Dispatcher) Register(name string, handler CommandFunc, isWrite bool) {
	d.mu.Lock()
	defer d.mu.Unlock()
	upperName := strings.ToUpper(name)
	d.handlers[upperName] = handler
	if isWrite {
		d.writeCmds[upperName] = true
	}
}

3. 主分發邏輯:Dispatch

這是整個資料庫命令執行的入口。這邊我一開始忘記把 cmdName 轉成大寫,結果用 redis-cli 敲小寫的 set 直接炸開報 unknown command,這才趕快補上 strings.ToUpper

它將接收到的 RESP 值轉換成命令,並執行安全校驗:

func (d *Dispatcher) Dispatch(client *Client, val resp.Value) resp.Value {
	// 1. 驗證格式必須是 Array,且第一個字元為 BulkString (命令名)
	if val.Type != resp.TypeArray || len(val.Array) == 0 {
		return resp.NewError("ERR Protocol error: invalid bulk/array format")
	}

	cmdVal := val.Array[0]
	cmdName := strings.ToUpper(string(cmdVal.Bulk))

	// 2. 提取參數 (其餘元素)
	args := make([][]byte, 0, len(val.Array)-1)
	for i := 1; i < len(val.Array); i++ {
		args = append(args, val.Array[i].Bulk)
	}

	d.mu.RLock()
	handler, exists := d.handlers[cmdName]
	isWrite := d.writeCmds[cmdName]
	d.mu.RUnlock()

	if !exists {
		return resp.NewError("ERR unknown command '" + cmdName + "'")
	}

	// 3. 安全與密碼校驗 (AUTH)
	if cmdName == "AUTH" {
		// ... 密碼驗證邏輯 ...
	}
	if d.requirePass != "" && !client.Authenticated {
		return resp.NewError("NOAUTH Authentication required.")
	}

	// 4. 執行命令
	reply := handler(d.dbEngine, client, args)

	// 5. 若是寫指令且成功執行,觸發 AOF 與主從同步
	if isWrite && reply.Type != resp.TypeError {
		// 觸發 AofWriteCallback 廣播給所有從節點與寫入 AOF 檔案
	}

	return reply
}

串接 TCP 伺服器與命令執行

code/server/server.gohandleConnection 中,我把原本單純的 Echo 邏輯換成了真正的命令執行:

	// 每次 Accept 後,建立一個 Client 上下文狀態
	clientCtx := command.NewClient(conn, s.password != "")

	reader := resp.NewReader(conn)
	for {
		val, err := reader.ReadValue()
		if err != nil {
			break
		}

		// 呼叫dispatcher 執行並回傳
		reply := s.dispatcher.Dispatch(clientCtx, val)

		if reply.Type != 0 {
			_, err = conn.Write(reply.Marshal())
			if err != nil {
				break
			}
		}
	}

跑起來看看

先把 Server 跑起來:

go run ./code/main.go

這次我們開兩個終端機來測試 Pub/Sub 訂閱功能。

終端機 1 (Subscriber):

# SUBSCRIBE news
printf "*2\r\n\$9\r\nSUBSCRIBE\r\n\$4\r\nnews\r\n" | nc localhost 6379
# 預期回覆:
# *3
# $9
# subscribe
# $4
# news
# :1
# (然後卡住等待訊息)

終端機 2 (Publisher):

# PUBLISH news "hello"
printf "*3\r\n\$7\r\nPUBLISH\r\n\$4\r\nnews\r\n\$5\r\nhello\r\n" | nc localhost 6379
# 預期回覆::1 (代表 1 個訂閱者收到)

此時回到終端機 1,應該會看到推送過來的訊息:

*3
$7
message
$4
news
$5
hello

總結

今天總算把 Command Dispatcher 接起來,RESP 請求可以從網路層一路分發到 handler,再把結果寫回 client。這個 dispatcher 第一版其實我少傳了一個 context,結果 client 斷線 goroutine 沒清掉,後來才加上連線管理。

明天要補 SETGETDEL 這些基礎命令的處理邏輯,先把最常用的打通!


上一篇
Day 12:實作跳躍表(Skip List)與有序集合 Sorted Set 命令
下一篇
Day 14:實作命令處理 - 基礎命令與 Key-Value String 操作
系列文
手刻 Redis:用 Go 從零打造高效能高併發的記憶體資料庫16
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言