項目_仿Redis數據庫的協議解析過程及命令的基本實現

Redis是一款數據跑在內存上的數據庫


項目源碼:我的github鏈接,僅供參考哦!


一、認識Redis

我們常用的是MySQL或者SqlServer數據庫。接入層(http服務器)直接操作數據庫,整體服務效率較低,因爲數據庫的內容主要在磁盤上。

我們引入Redis的理由也就是希望能夠更快的訪問到數據,提高效率。就像以下場景一樣:

熱加載:把熱點數據提前加載到內存中,提升整體的訪問速度。
緩存模型:把熱點數據提前加載到讀寫速度更快的介質中。

我們在有緩存的前提下,爲什麼要引入redis或者memcached呢?

可以使用緩存,但是沒必要。自己做緩存成本較高。

Tips:Redis和memcached是分佈式緩存

1. Redis vs Memcached
  1. redis支持的數據結構豐富,memcached只支持key-value型的數據結構
  2. redis支持的社區更友好
  3. redis效率很高,Redis重啓後數據還在,本身自帶持久化。
  4. 是屬於IT界的跟風???
2. Redis支持的類型
類型 返回值 Java中的類型 標記字節 格式說明
Simple String 返回OK String ‘+’ ‘+OK\r\n’
Error 通知出錯 Exception ‘-’ '-ERR unknown command ‘put’\r\n
Integer 整數 long ‘:’ ':0\r\n
Bulk String 字節流 byte[] ‘$’ '$5\r\nhello\r\n
Array 數組 List ‘*’ '*2\r\n:0\r\n$5\r\nhello\r\n
Redis和Java語言的類型對應起來就需要"協議解析"
如將‘+OK\r\n’變爲'OK'的過程,其他類型也是一樣的道理。

二、協議解析

服務器接收到客戶端發來的輸入流,經過了協議解析,收到的實際上是帶標記字節的字符串列表,因此我們可以根據標記字節解析出客戶端輸入的是哪一類型的Redis指令。

一般來說Redis命令傳過來都是數組類型,即Java中的List;以*標記字節開頭的輸入流。
如“+OK\r\n”,判斷爲String類型。最後的返回結果應該爲OK。
每個字符串的結尾都爲\r\n,因此在readLine()方法中,一旦我們讀到最後的\r\n,就將\r\n前(除了標記字節)的字符串返回。
所以,如果不是末尾的\r,就要加上轉義符(\\r),否則\r返回到行首,就會將前面的字符串覆蓋。

這裏涉及到狀態轉換機:

其他字符-> \r -> \n->結束。
如果其他字符遇到\r,則判斷\r的下一個字符是否爲\n。如果下一個爲\n,則結束;如果爲\r,則繼續判斷當前\r的下一個字符是否爲\n;如果是其他字符,拼接字符串即可,繼續判斷其他字符的下一個字符。

ProtocolInputStream 類

import java.io.FilterInputStream;
import java.io.IOException;
import java.io.InputStream;

public class ProtocolInputStream extends FilterInputStream {

    public ProtocolInputStream(InputStream in) {
        super(in);
    }

    public String readLine() throws IOException {
        boolean needRead = true;
        StringBuilder sb = new StringBuilder();
        int b = -1;
        while (true) {
            if (needRead) {
                b= in.read();
                if (b == -1) {
                    throw new RuntimeException("不應該讀到結尾的");
                }
            }else {
                needRead = true;
            }

            // +OK\r3\r\n -> 結果:
            // 3
            //

            // +OK\\r3\r\n -> 結果:
            // OK\r3
            //
            //如果末尾\r\n前存在\r,需要加上轉義符\。
            //否則,\r後面的字符會覆蓋前面的字符,產生數據覆蓋的情況。

            if (b == '\r') {
                int c = in.read();
                if (c == -1) {
                    throw new RuntimeException("不應該讀到結尾的");
                }
                if (c == '\n') {
                    break;
                }
                if (c == '\r') {
                    sb.append((char)b);
                    b = c;
                    needRead = false;
                }else {
                    sb.append((char)b);
                    sb.append((char)c);
                }

            }else {
                sb.append((char)b);
            }
        }
        return sb.toString();
    }

    public long readInteger() throws IOException {
    	/*
        如果讀到的第二個字符爲-,則爲負數,標記isNegative爲true。
        如果不是-,就拼接字符b
        繼續讀剩下的字符,如果是\r\n,則跳出循環,結束。
        如果不是\r,就拼接字符b。繼續循環,直到符合跳出條件(到達末尾的\r\n)。

        最後將讀到的數字字符串轉爲long型整數。如果是負數,v = -v。
        最終返回v即可。
  
        * */
        boolean isNegative = false;
        StringBuilder sb = new StringBuilder();
        int b = in.read();
        if (b == -1) {
            throw new RuntimeException("不應該讀到結尾");
        }
        if (b == '-') {
            isNegative = true;
        }else {
            sb.append((char)b);
        }
        while (true) {
            b = in.read();
            if (b == -1) {
                throw new RuntimeException("不應該讀到結尾的");
            }
            if (b == '\r') {
                int c = in.read();
                if (c == -1) {
                    throw new RuntimeException("不應該讀到結尾的");
                }
                if (c == '\n') {
                    break;
                }
                throw new RuntimeException("沒有讀到\\r\\n");
            }else {
                sb.append((char)b);
            }
        }
        long v = Long.parseLong(sb.toString());
        if (isNegative) {
            v = -v;
        }
        return v;
    }
}

Protocol類:我們根據讀到的第一個字節是+、-、:、$、*中的其中一個,而進行不同的操作。

import java.io.IOException;
import java.rmi.RemoteException;
import java.util.ArrayList;
import java.util.List;

public class Protocol {
	public static Object process(ProtocolInputStream is) throws IOException {
        int b = is.read();
        if (b == -1) {//等於-1,說明字節流結尾,對端關閉
            throw new RuntimeException("不應該讀到結尾");
        }
        switch (b){
            case '+':
                return processSimpleString(is);
            case '-':
                throw new RemoteException(processError(is));
            case ':':
                return processInteger(is);
            case '$':
                return processBulkString(is);
            case '*':
                return processArray(is);
            default:
                throw new RuntimeException("不識別的類型");
        }
    }



    public static Object read(ProtocolInputStream is) throws IOException {
        return process(is);
    }
    
    private static String processSimpleString(ProtocolInputStream is) throws IOException {//返回簡單字符串
    	//+OK\r\n   ->  String("OK")
        return is.readLine();
    }

    public static String processError(ProtocolInputStream is) throws IOException {//異常處理
        return is.readLine();
    }
    public static long processInteger(ProtocolInputStream is) throws IOException {//返回整數
    //:0\r\n   ->   Integer(0)
        return is.readInteger();
    }
    public static byte[] processBulkString(ProtocolInputStream is) throws IOException {//返回字節流
        int len = (int)is.readInteger();//讀到數字,即字節的長度。
        if (len == -1) {
            //"$-1\r\n"  ->  null
            return null;
        }
        byte[] r = new byte[len];
        for (int i = 0; i < len; i++) {
            int b = is.read();
            r[i] = (byte)b;

        }
        //如"$5\r\nhello\r\n" --》 返回:hello
        is.read();
        is.read();

        return r;
    }
    private static List<Object> processArray(ProtocolInputStream is) throws IOException { //返回數組 List
        int len = (int) is.readInteger();//取得List的大小。
        if (len == -1) {
            //"*-1\r\n"  ->  null
            return null;
        }
        List<Object> list = new ArrayList<Object>(len);
        for (int i = 0; i < len; i++) {
            try {
                list.add(process(is));//繼續判斷List中存儲的是什麼類型的數據。
            } catch (RemoteException e) {
                list.add(e);
            }
        }
        //如  *2\r\n:0\r\n$5\r\nhello\r\n  =>   List<Object> = {0,byte[]{hello}};
        return list;//返回列表。
    }


    
}


三、lpush lrange hset hget命令的實現

1. 數據類型:

1.String(字符串) HashMap<String,String>
2.hash(哈希,類似java裏的Map) HashMap<String,HashMap<String,String>>
3.list(列表) HashMap<String,List< String > >
4.set(集合) HashMap<String,Set< String > >
5.zset(sorted set:有序集合) LinkedListSet 按照插入順序有序

2. 讀命令解析:

在Protocol類中的讀命令操作:

public class Protocol {

    public static Command readCommand(InputStream is) throws Exception {//通過此類取得命令後的字符串hello 1
        /*
        * 假如在客戶端輸入的是lpush hello 1,通過Redis的協議將其變爲*3\r\n$5\r\nlpush\r\n$5\r\nhello\r\n$1\r\n1\r\n
        * 給服務器的傳入的參數是數組List格式.然後通過read(is)將其解析爲lpush hello 1,返回Object類型。
        * 將第一個元素取出並刪掉。將它變成大寫字母,方便匹配類名。
        * 通過命令名LPUSH反射獲取類的class對象。判斷class對象表示的類是否和Command的子類相同。
        * 是,則返回類的實例化對象。
        * */
        Object o  = read(is);//解析協議。返回lpush hello 1



        // 作爲 Server 來說,一定不會收到 "+OK\r\n"
        if (!(o instanceof List)) //它不是數組執行
        {
            throw new Exception("命令必須是 Array 類型");
        }

        List<Object> list = (List<Object>)o;

        //把第一個元素取出來並且把它刪掉
        if (list.size() < 1) {
            throw new Exception("命令元素個數必須大於 1");
        }
        //object o2 =list.get(0);
        Object o2 = list.remove(0);
        if (!(o2 instanceof byte[])) {
            throw new Exception("錯誤的命令類型");
        }

        byte[] array = (byte[])o2;
        String commandName = new String(array);
        String className = String.format("roman.commands.%sCommand", commandName.toUpperCase());
        Class<?> cls = Class.forName(className);//反射---獲取類的class對象
        if (!Command.class.isAssignableFrom(cls)) {
            throw new Exception("錯誤的命令");
        }
        Command command = (Command)cls.newInstance();
        command.setArgs(list);

        return command;
        /*
        1.Class.isAssignableFrom(Class cls)
        判定此 Class 對象所表示的類或接口與指定的 Class 參數cls所表示的類或接口是否相同,
        或是否是其超類或(超)接口,如果是則返回 true,否則返回 false。
        2.instanceof   是用來判斷一個對象實例是否是一個類或接口或其子類子接口的實例。
        格式是:   oo   instanceof   TypeName
                     interImpl instanceof inter
        第一個參數是對象實例名,第二個參數是具體的類名或接口名,例如   String,InputStream。
        * */
    }
}
3. 最終結果返回客戶端的具體操作:

讀到命令後,進行lpush、lrange、hset、hget等操作。最終的結果寫進輸出流,經過協議解析,返回客戶端。
1、lpush將數據存到Map<String, List< String >>中,結果返回list的大小。
2、lrange將key所對應的value值全部取出來,返回到客戶端。
3、hset將數據存到Map<String, String>中,結果返回map的大小。
4、hget就是將map中key對應的value值取出來,返回到客戶端。

LPUSHCommand類:將數據存儲在緩存中,並將結果返回。

package roman.commands;

import roman.Command;
import roman.Database;
import roman.Protocol;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import java.io.IOException;
import java.io.OutputStream;
import java.util.List;
/*
Lpush 命令將一個或多個值插入到列表頭部。
如果 key 不存在,一個空列表會被創建並執行 LPUSH操作。
當 key 存在但不是列表類型時,返回一個錯誤。
*/
public class LPUSHCommand implements Command {

    private static final Logger logger = LoggerFactory.getLogger(LPUSHCommand.class);
    private List<Object> args;

    @Override
    public void setArgs(List<Object> args) {
        this.args = args;
    }

    @Override
    public void run(OutputStream os) throws IOException {
        if (args.size() != 2) {
            Protocol.writeError(os, "命令至少需要兩個參數");
            return;
        }
        String key = new String((byte[])args.get(0));
        String value = new String((byte[])args.get(1));
        logger.debug("運行的是 lpush 命令: {} {}", key, value);

        // 這種方式不是一個很好的線程同步的方式
        List<String> list = Database.getList(key);//將插入的數據都放入List<String>中
        list.add(0, value);//頭插

        logger.debug("插入後數據共有 {} 個", list.size());

        Protocol.writeInteger(os, list.size());
        //輸出 :1\r\n。
        //將輸出流再返回到客戶端,則最後客戶端顯示的是(integer)1
    }
}


4. 整體流程:

1、客戶端輸入lpush hello 1
2、經過協議解析,服務器得到的輸入流是*3\r\n$5\r\nlpush\r\n$5\r\nhello\r\n$1\r\n1\r\n
3、服務器read(is)方法解析輸入的內容,得到lpush hello 1
4、根據lpush字符串,調用Command的子類LPUSHCommand。
5、子類中將key和value存到Map<String, List< String >>中,返回value的個數。
6、在writeInteger(os)方法中將輸出流變爲:1\r\n(這是協議能夠識別的格式),最終經過協議解析,客戶端收到(integer)1。<對於不同的輸出,有不同的方法將結果寫到輸出流中,如writeBulkString()、writeArray()、writeInteger()>


//將輸出流變成協議解析中能夠解析的格式:帶有標記字節的一系列字符串。然後經過協議解析,輸出返回值。如(integer)1等。
    public static void writeInteger(OutputStream os, long v) throws IOException {
        // v = 10  -》
        //:10\r\n

        // v = -1  -》
        //:-1\r\n

        os.write(':');
        os.write(String.valueOf(v).getBytes());
        os.write("\r\n".getBytes());
    }

    public static void writeArray(OutputStream os, List<?> list) throws Exception {
        os.write('*');
        os.write(String.valueOf(list.size()).getBytes());
        os.write("\r\n".getBytes());
        for (Object o : list) {
            if (o instanceof String) {
                writeBulkString(os, (String)o);
            } else if (o instanceof Integer) {
                writeInteger(os, (Integer)o);
            } else if (o instanceof Long) {
                writeInteger(os, (Long)o);
            } else {
                throw new Exception("錯誤的類型");
            }
        }
    }

    public static void writeBulkString(OutputStream os, String s) throws IOException {
        byte[] buf = s.getBytes();
        os.write('$');
        os.write(String.valueOf(buf.length).getBytes());
        os.write("\r\n".getBytes());
        os.write(buf);
        os.write("\r\n".getBytes());
    }

四、使用手冊

1.啓動我的github鏈接中的Server.java文件。等待客戶端連接……
2.本地客戶端可下載Redis的windows版本,運行已下載的文件中的redis-cli.exe。
即可進行服務器和客戶端的連接

以下是我的Redis實現的四種命令:(lpush類似於頭插的形式,hset類似於尾插的形式)

redis 127.0.0.1:6379> lpush hello 1
(integer) 1
redis 127.0.0.1:6379> lpush hello 2
(integer) 2
redis 127.0.0.1:6379> lpush hello 3
(integer) 3
redis 127.0.0.1:6379> lrange hello 0 -1
1) "3"
2) "2"
3) "1"
redis 127.0.0.1:6379> hset lm name "roman"
(integer) 1
redis 127.0.0.1:6379> hset lm age 22
(integer) 1
redis 127.0.0.1:6379> hset lm sex "female"
(integer) 1
redis 127.0.0.1:6379> hget lm name
"roman"
redis 127.0.0.1:6379> hget lm age
"22"
redis 127.0.0.1:6379> hget lm sex
"female"
redis 127.0.0.1:6379>

五、改進點

1.嵌入持久化:原Redis本身自帶持久化,在重啓Redis後,原數據依舊存在。

六、測試

1.功能性
2.性能測試
  • 讀寫效率
  • 併發效率
  • 存儲空間
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章