ในโพสต์นี้ ฉันจะ रूपรายการการนำไปใช้งานที่เรียบง่าย เข้าใจง่าย สำหรับสองส่วนประกอบของ Redis client ใน Go เพื่อเป็นวิธีเข้าใจว่า Redis protocol ทำงานยังไง และทำไมมันถึงยอดเยี่ยม
หากคุณมองหา Redis client ใน Go ที่พร้อม production และมีฟีเจอร์ครบ เราแนะนำให้ดู library redigo ของ Gary Burd
ก่อนเริ่ม ต้องอ่านบทนำสั้นๆ เกี่ยวกับ Redis protocol ก่อน – ครอบคลุมพื้นฐานที่คุณต้องเข้าใจสำหรับคู่มือนี้
RESP Command Writer ใน Go
สำหรับ Redis client สมมติของเรา มีเพียงหนึ่งประเภท object ที่ต้องเขียน: array ของ bulk strings สำหรับส่งคำสั่งไป Redis นี่คือการ implement command-to-RESP writer แบบง่าย:
package redis import ( "bufio" "io" "strconv" ) var ( arrayPrefixSlice = []byte{'*'} bulkStringPrefixSlice = []byte{'$'} lineEndingSlice = []byte{'\r', '\n'} ) type RESPWriter struct { *bufio.Writer } func NewRESPWriter(writer io.Writer) *RESPWriter { return &RESPWriter{ Writer: bufio.NewWriter(writer), } } func (w *RESPWriter) WriteCommand(args ...string) (err error) { // เขียน array prefix และจำนวน arguments w.Write(arrayPrefixSlice) w.WriteString(strconv.Itoa(len(args))) w.Write(lineEndingSlice) // เขียน bulk string สำหรับแต่ละ argument for _, arg := range args { w.Write(bulkStringPrefixSlice) w.WriteString(strconv.Itoa(len(arg))) w.Write(lineEndingSlice) w.WriteString(arg) w.Write(lineEndingSlice) } return w.Flush() }
แทนที่จะเขียนไป net.Conn object RESPWriter เขียนไป io.Writer object นี่ช่วยให้เราทดสอบ parser ได้โดยไม่ผูกมัดกับ net stack เราทดสอบ network protocol แบบที่ทดสอบ io อื่นๆ
ตัวอย่าง เราส่ง bytes.Buffer ไปเพื่อตรวจสอบ RESP สุดท้าย:
var buf bytes.Buffer writer := NewRESPWriter(&buf) writer.WriteCommand("GET", "foo") buf.Bytes() // *2\r\n$3\r\nGET\r\n$3\r\nfoo\r\n
RESP Reader เรียบง่ายใน Go
หลังส่งคำสั่งไป Redis ด้วย RESPWriter client ของเราจะใช้ RESPReader อ่านจาก TCP connection จนได้ RESP reply ครบ ต้องใช้ packages เล็กน้อยสำหรับ buffering และ parsing ข้อมูลเข้า:
package redis import ( "bufio" "bytes" "errors" "io" "strconv" )
และใช้ตัวแปรและค่าคงที่เล็กน้อยเพื่อให้โค้ดอ่านง่าย:
const ( SIMPLE_STRING = '+' BULK_STRING = '$' INTEGER = ':' ARRAY = '*' ERROR = '-' ) var ( ErrInvalidSyntax = errors.New("resp: invalid syntax") )
เหมือน RESPWriter RESPReader ไม่สนใจรายละเอียด implementation ของ object ที่อ่าน RESP มันต้องการแค่ความสามารถอ่านไบต์จนได้ RESP object ครบ ในกรณีนี้ ต้องการ io.Reader ซึ่ง wrap ด้วย bufio.Reader เพื่อจัดการ buffering ข้อมูลเข้า
Object และ initializer ของเราเรียบง่าย:
type RESPReader struct { *bufio.Reader } func NewReader(reader io.Reader) *RESPReader { return &RESPReader{ Reader: bufio.NewReaderSize(reader, 32*1024), } }
Buffer size สำหรับ bufio.Reader แค่เดาไว้ตอนพัฒนา ใน client จริง คุณควรทำให้ size configurable และทดสอบหาขนาดที่เหมาะสม 32KB พอสำหรับ development
RESPReader มีเพียงเมธอดเดียว: ReadObject() คืนค่า byte slice ที่มี RESP object ครบ ทุกครั้งที่เรียก จะส่งต่อ errors จาก io.Reader และคืนค่า errors เมื่อเจอ RESP syntax ไม่ถูกต้อง
ลักษณะ prefix ของ RESP หมายความว่าเราต้องอ่านไบต์แรกเท่านั้นเพื่อตัดสินใจจัดการไบต์ถัดไป แต่เพราะเราจะต้องอ่านบรรทัดแรกเต็ม (คือ จนถึง \r\n แรก) เราสามารถเริ่มอ่านบรรทัดทั้งหมดได้เลย:
func (r *RESPReader) ReadObject() ([]byte, error) { line, err := r.readLine() if err != nil { return nil, err } switch line[0] { case SIMPLE_STRING, INTEGER, ERROR: return line, nil case BULK_STRING: return r.readBulkString(line) case ARRAY: return r.readArray(line) default: return nil, ErrInvalidSyntax } }
เมื่อบรรทัดที่อ่านมี prefix เป็น simple string, integer, หรือ error เราคืนค่าบรรทัดเต็มเป็น RESP object ที่ได้ เพราะ object types เหล่านี้อยู่ในบรรทัดเดียวครบ
ใน readLine() เราอ่านจนเจอ \n แล้วเช็คว่ามี \r นำหน้าหรือไม่ ก่อนคืนค่าบรรทัดเป็น byte slice:
func (r *RESPReader) readLine() (line []byte, err error) { line, err = r.ReadBytes('\n') if err != nil { return nil, err } if len(line) > 1 && line[len(line)-2] == '\r' { return line, nil } else { // บรรทัดสั้นเกินไป หรือ \n ไม่มี \r นำหน้า return nil, ErrInvalidSyntax } }
ใน readBulkString() เรา parse ความยาวของ bulk string เพื่อรู้ว่าต้องอ่านกี่ไบต์ ถ้ารู้แล้ว เราอ่านจำนวนไบต์นั้นและ \r\n line terminator:
func (r *RESPReader) readBulkString(line []byte) ([]byte, error) { count, err := r.getCount(line) if err != nil { return nil, err } if count == -1 { return line, nil } buf := make([]byte, len(line)+count+2) copy(buf, line) _, err = io.ReadFull(r, buf[len(line):]) if err != nil { return nil, err } return buf, nil }
ผมดึง getCount() ออกมาเป็นเมธอดแยก เพราะ length specification ใช้สำหรับ arrays ด้วย:
func (r *RESPReader) getCount(line []byte) (int, error) { end := bytes.IndexByte(line, '\r') return strconv.Atoi(string(line[1:end])) }
จัดการ arrays: เราเอาจำนวน array elements แล้วเรียก ReadObject() แบบ recursive เพิ่ม objects ที่ได้เข้า RESP buffer ปัจจุบัน:
func (r *RESPReader) readArray(line []byte) ([]byte, error) { // ได้จำนวน array elements count, err := r.getCount(line) if err != nil { return nil, err } // อ่าน `count` จำนวน RESP objects ใน array for i := 0; i < count; i++ { buf, err := r.ReadObject() if err != nil { return nil, err } line = append(line, buf...) } return line, nil }
สรุป
ร้อยบรรทัดข้างต้นคือทั้งหมดที่ต้องการอ่าน RESP object ใดๆ จาก Redis แต่ยังมีชิ้นส่วนขาดหายไปบ้างที่ต้อง implement ก่อนใช้ library นี้ใน production:
- ความสามารถแยกค่าจริงจาก RESP
RESPReaderตอนนี้คืนแค่ full RESP response ไม่ได้คืน string จาก bulk string response ตัวอย่าง แต่นำไป implement ได้ง่าย RESPReaderต้องการ syntax error handling ที่ดีกว่า
โค้ดนี้ยัง unoptimized เลย และทำ allocations และ copies มากกว่าที่ต้อง เช่นเมธอด readArray(): สำหรับแต่ละ object ใน array เราอ่าน object แล้ว copy ไป buffer ท้องถิ่น
หากสนใจเรียนรู้การ implement ชิ้นส่วนเหล่านี้ แนะนำดูว่า libraries ยอดนิยมอย่าง hiredis หรือ redigo implement ยังไง
ขอบคุณพิเศษ Niel Smith ช่วยจับบั๊กในโค้ดในโพสต์นี้