diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..0532a21 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,18 @@ +# https://hub.docker.com/_/golang/ +FROM golang:1.11.3-alpine3.8 + +RUN mkdir -p /go/src/github.com/ITandElectronics/GoHomework +WORKDIR /go/src/github.com/ITandElectronics/GoHomework +COPY . . + +RUN CGO_ENABLED=0 go test -v ./... + +RUN CGO_ENABLED=0 go install -v ./... + +FROM alpine:3.8 + +WORKDIR /root/ +COPY --from=0 /go/bin/client . +COPY --from=0 /go/bin/server . + +CMD ["./server"] diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..db76f1e --- /dev/null +++ b/Makefile @@ -0,0 +1,15 @@ +build: + CGO_ENABLED=0 go install ./... + docker build -t redislight . +test: + go test -coverprofile=./coverage.profile ./... + go tool cover --func=./coverage.profile > coverage.out +check: + go get -u golang.org/x/lint/golint + go get -u golang.org/x/tools/cmd/goimports + + ${GOPATH}/bin/goimports -w . + go vet ./... + ${GOPATH}/bin/golint ./... +run: + CGO_ENABLED=0 go run ./cmd/server diff --git a/Project.pdf b/Project.pdf new file mode 100755 index 0000000..53f81b5 Binary files /dev/null and b/Project.pdf differ diff --git a/README.md b/README.md new file mode 100755 index 0000000..1293710 --- /dev/null +++ b/README.md @@ -0,0 +1,75 @@ +## Redislight +### Simplified version of a redis. Supports only GET, SET and DEL commands + +## Usage +### Pre Requisites + - Docker + +Redis light consists of two application: client and server. They both bundled into a single docker image which can be built by running following command: +```bash +$ docker build -t redislight . +``` +### Server +In order to run server: +```bash +$ docker run -p 9090:9090 redislight +``` +Server supports following options: +```bash +./server --help +Usage of server: + --mode, -m string + Storage options. One of [disk] (default "disk") + --port, -p int + Port to listen on (default 9090) +``` + +### Client +In order to run client: +```bash +$ docker run redislight ./client +``` + +Client supports following options: +```bash +./client --help +Usage of client: + --host, -h string + Remote server address (default "127.0.0.1") + --port, -p int + Remote server port (default 9090) +``` + +### Supported commands +**GET** *key* - return value associated with proveded *key* or 'key is not exists' error +**SET** *key* *value* - create or update *value* associated with the *key* +**DEL** *key* - remove value associated with the *key*. If *key* is not exists, it will return 'not exists error' + + +## Development +### Pre Requisites + - Go >= 1.11 + - Docker + - Make + +Clone this repository into GOPATH/src/{repository}/redislight: +```bash +$ git clone .../redislight.git +``` +Run linters: +```bash +$ make check +``` +Run tests(also will produce code coverage in coverage.out file): +```bash +$ make tests +``` +Build: +```bash +$ make build +``` + +Run server: +```bash +$ make run +``` \ No newline at end of file diff --git a/cmd/client/main.go b/cmd/client/main.go new file mode 100644 index 0000000..e9a57f2 --- /dev/null +++ b/cmd/client/main.go @@ -0,0 +1,76 @@ +package main + +import ( + "bufio" + "flag" + "fmt" + "io" + "log" + "net" + "os" + + "github.com/ITandElectronics/GoHomework/protocol" +) + +func main() { + var ( + host string + port int + ) + + flag.IntVar(&port, "port", 9090, "Remote server port") + flag.IntVar(&port, "p", 9090, "Remote server port") + flag.StringVar(&host, "host", "127.0.0.1", "Remote server address") + flag.StringVar(&host, "h", "127.0.0.1", "Remote server address") + flag.Usage = func() { + usage := `Usage of %s: + --host, -h string + Remote server address (default "127.0.0.1") + --port, -p int + Remote server port (default 9090) +` + fmt.Fprintf(os.Stderr, usage, os.Args[0]) + } + flag.Parse() + + conn, err := net.Dial("tcp4", fmt.Sprintf("%s:%d", host, port)) + if err != nil { + log.Fatalf("couldn't connect to server: %v\n", err) + } + + input := bufio.NewReader(os.Stdin) + response := bufio.NewReader(conn) + for { + fmt.Print("Enter command: ") + line, _, err := input.ReadLine() + if err != nil { + fmt.Printf("[error] couldn't read line from stdin: %v\n", err) + continue + } + msg, err := protocol.DecodeMessage(line) + if err != nil { + fmt.Printf("[error] invalid message format: %v\n", err) + continue + } + if err := protocol.ValidateMessage(msg); err != nil { + fmt.Printf("validation failed: %v\n", err) + continue + } + if _, err = conn.Write(append(line, '\n')); err != nil { + fmt.Printf("[error] coudn't write to connection: %v\n", err) + continue + } + + resp, _, err := response.ReadLine() + if err != nil { + if err == io.EOF { + fmt.Println("connection is closed") + return + } + fmt.Printf("[error] couldn't read response: %v\n", err) + continue + } + fmt.Printf("[server] %s\n", string(resp)) + } + +} diff --git a/cmd/server/main.go b/cmd/server/main.go new file mode 100644 index 0000000..7b2b3b6 --- /dev/null +++ b/cmd/server/main.go @@ -0,0 +1,47 @@ +package main + +import ( + "flag" + "fmt" + "log" + "os" + + "github.com/ITandElectronics/GoHomework/disk" + "github.com/ITandElectronics/GoHomework/server" +) + +func main() { + const storagePath = "./db.json" + + var ( + port int + mode string + ) + + flag.IntVar(&port, "port", 9090, "Port to listen on") + flag.IntVar(&port, "p", 9090, "Port to listen on") + flag.StringVar(&mode, "mode", "disk", "Storage options. One of [disk]") + flag.StringVar(&mode, "m", "disk", "Storage options. One of [disk]") + flag.Usage = func() { + usage := `Usage of %s: + --mode, -m string + Storage options. One of [disk] (default "disk") + --port, -p int + Port to listen on (default 9090) + ` + fmt.Fprintf(os.Stderr, usage, os.Args[0]) + + } + flag.Parse() + + fmt.Printf("server is going to start on '%d' and work in '%s' mode\n", port, mode) + storage, err := disk.New(storagePath) + if err != nil { + log.Fatalf("coudn't create stroage: %v\n", err) + } + s, err := server.New(port, storage) + if err != nil { + log.Fatalf("coudln't create server: %v\n", err) + } + log.Fatalf("unable to start server: %v\n", s.Run()) +} diff --git a/cmd/server/server b/cmd/server/server new file mode 100755 index 0000000..30519e1 Binary files /dev/null and b/cmd/server/server differ diff --git a/disk/disk.go b/disk/disk.go new file mode 100644 index 0000000..40b198c --- /dev/null +++ b/disk/disk.go @@ -0,0 +1,74 @@ +package disk + +import ( + "encoding/json" + "fmt" + "io" + "os" + "sync" + + redislight "github.com/ITandElectronics/GoHomework" +) + +// New construct new storage +func New(path string) (*Disk, error) { + fd, err := os.OpenFile(path, os.O_CREATE|os.O_RDWR, os.FileMode(0640)) + if err != nil { + return nil, err + } + data := make(map[string]string) + if err := json.NewDecoder(fd).Decode(&data); err != nil && err != io.EOF { + return nil, err + } + return &Disk{ + m: &sync.RWMutex{}, + fd: fd, + data: data, + }, nil +} + +// Disk represent disk storage +type Disk struct { + m *sync.RWMutex + fd *os.File + data map[string]string +} + +// Get checks whether key exists and either return appropriate value or KeyIsNotExists error +func (d *Disk) Get(key string) (string, error) { + d.m.RLock() + defer d.m.RUnlock() + val, ok := d.data[key] + if !ok { + return "", redislight.ErrKeyIsNotExists + } + return val, nil +} + +// Set create new or update existing key value +func (d *Disk) Set(key, value string) error { + d.m.Lock() + defer d.m.Unlock() + d.data[key] = value + return d.sync() +} + +// Del remove entry related to provided key from the storage +func (d *Disk) Del(key string) error { + d.m.Lock() + defer d.m.Unlock() + if _, ok := d.data[key]; !ok { + return redislight.ErrKeyIsNotExists + } + delete(d.data, key) + return d.sync() +} + +// all sync calls should be protected by mutex +func (d *Disk) sync() error { + _, err := d.fd.Seek(0, 0) + if err != nil { + return fmt.Errorf("coudn't seek to the start of the file: %v", err) + } + return json.NewEncoder(d.fd).Encode(d.data) +} diff --git a/disk/disk_test.go b/disk/disk_test.go new file mode 100644 index 0000000..50ff661 --- /dev/null +++ b/disk/disk_test.go @@ -0,0 +1,77 @@ +package disk + +import ( + "fmt" + "io/ioutil" + "os" + "testing" + + redislight "github.com/ITandElectronics/GoHomework" +) + +func createTempDir(t *testing.T) string { + dir, err := ioutil.TempDir("", "redis-light-tests") + if err != nil { + t.Fatal(err) + } + return dir +} + +func TestNew(t *testing.T) { + dir := createTempDir(t) + defer os.RemoveAll(dir) + + _, err := New(fmt.Sprintf("%s/test_db.json", dir)) + if err != nil { + t.Fatal(err) + } +} + +func TestDiskGet(t *testing.T) { + disk, err := New("./fixtures/db.json") + if err != nil { + t.Fatal(err) + } + _, err = disk.Get("not_existing_key") + if err == nil { + t.Fatal("should return error") + } + if err != redislight.ErrKeyIsNotExists { + t.Fatalf("%v - is not expected", err) + } + val, err := disk.Get("exists") + if err != nil { + t.Fatal(err) + } + if val == "" { + t.Fatal("should return some value") + } + +} + +func TestDiskSet(t *testing.T) { + disk, err := New("./fixtures/db.json") + if err != nil { + t.Fatal(err) + } + err = disk.Set("new", "world") + if err != nil { + t.Fatal(err) + } + val, err := disk.Get("new") + if err != nil { + t.Fatal(err) + } + if val != "world" { + t.Fatal("Set is not working") + } + +} + +func TestDiskDel(t *testing.T) { + +} + +func TestDiskSync(t *testing.T) { + +} diff --git a/protocol/protocol.go b/protocol/protocol.go new file mode 100644 index 0000000..a6562e2 --- /dev/null +++ b/protocol/protocol.go @@ -0,0 +1,53 @@ +package protocol + +import ( + "bufio" + "bytes" + "fmt" +) + +// Msg represent message from a client +type Msg struct { + Cmd string + Key string + Val string +} + +// DecodeMessage parse message from bytes to Msg struct +func DecodeMessage(line []byte) (Msg, error) { + scanner := bufio.NewScanner(bytes.NewReader(line)) + scanner.Split(bufio.ScanWords) + data := make([]string, 3) + for i := 0; i < 3 && scanner.Scan(); i++ { + data[i] = scanner.Text() + } + return Msg{Cmd: data[0], Key: data[1], Val: data[2]}, scanner.Err() +} + +// ValidateMessage check weither message is valid or not +func ValidateMessage(msg Msg) error { + switch msg.Cmd { + case "GET", "DEL": + if err := checkField("key", msg.Key); err != nil { + return err + } + case "SET": + if err := checkField("key", msg.Key); err != nil { + return err + } + if err := checkField("value", msg.Val); err != nil { + return err + } + default: + return fmt.Errorf("command '%s' is not supported", msg.Cmd) + } + + return nil +} + +func checkField(name, value string) error { + if value == "" { + return fmt.Errorf("invalid format of a command: %s is not provided", name) + } + return nil +} diff --git a/protocol/protocol_test.go b/protocol/protocol_test.go new file mode 100644 index 0000000..8555a0b --- /dev/null +++ b/protocol/protocol_test.go @@ -0,0 +1,116 @@ +package protocol + +import ( + "testing" +) + +func TestDecodeMessage(t *testing.T) { + cases := []struct { + testName string + inputLine []byte + expectedMessage Msg + }{ + { + testName: "Valid 'GET' command", + inputLine: []byte("GET cats"), + expectedMessage: Msg{Cmd: "GET", Key: "cats"}, + }, + { + testName: "Valid 'DEL' command", + inputLine: []byte("DEL dogs"), + expectedMessage: Msg{Cmd: "DEL", Key: "dogs"}, + }, + { + testName: "Valid 'SET' command", + inputLine: []byte("SET name XXX"), + expectedMessage: Msg{Cmd: "SET", Key: "name", Val: "XXX"}, + }, + { + testName: "Unsupported command", + inputLine: []byte("HGET region us-east-1"), + expectedMessage: Msg{Cmd: "HGET", Key: "region", Val: "us-east-1"}, + }, + } + + for _, testCase := range cases { + t.Run(testCase.testName, func(t *testing.T) { + actualMessage, err := DecodeMessage(testCase.inputLine) + if err != nil { + t.Fatal(err) + } + compareMessages(t, actualMessage, testCase.expectedMessage) + }) + } + +} + +func compareMessages(t *testing.T, actual, expected Msg) { + if actual.Cmd != expected.Cmd { + t.Fatalf("actual msg.Cmd(%s) != expected msg.Cmd(%s)", actual.Cmd, expected.Cmd) + } + if actual.Key != expected.Key { + t.Fatalf("actual msg.Key(%s) != expected msg.Key(%s)", actual.Key, expected.Key) + } + if actual.Val != expected.Val { + t.Fatalf("actual msg.Val(%s) !+ expected msg.Val(%s)", actual.Val, expected.Val) + } +} + +func TestValidateMessage(t *testing.T) { + cases := []struct { + testName string + msg Msg + expectedError string + }{ + { + testName: "Valid 'GET' command", + msg: Msg{Cmd: "GET", Key: "cats"}, + expectedError: "", + }, + { + testName: "'GET' without key", + msg: Msg{Cmd: "GET"}, + expectedError: "invalid format of a command: key is not provided", + }, + { + testName: "Valid 'SET' command", + msg: Msg{Cmd: "SET", Key: "cats", Val: "---"}, + expectedError: "", + }, + { + testName: "'SET' without key", + msg: Msg{Cmd: "SET"}, + expectedError: "invalid format of a command: key is not provided", + }, + { + testName: "'SET' without value", + msg: Msg{Cmd: "SET", Key: "cats"}, + expectedError: "invalid format of a command: value is not provided", + }, + { + testName: "Valid 'DEL'", + msg: Msg{Cmd: "DEL", Key: "cats"}, + expectedError: "", + }, + { + testName: "'DEL' without key", + msg: Msg{Cmd: "DEL"}, + expectedError: "invalid format of a command: key is not provided", + }, + { + testName: "Unsupported command should raise an error", + msg: Msg{Cmd: "HGET"}, + expectedError: "command 'HGET' is not supported", + }, + } + + for _, testCase := range cases { + t.Run(testCase.testName, func(t *testing.T) { + err := ValidateMessage(testCase.msg) + if err != nil && err.Error() != testCase.expectedError { + t.Fatalf("actual error(%s) != expected error(%s)", err.Error(), testCase.expectedError) + } + }) + } + +} diff --git a/redis-light.go b/redis-light.go new file mode 100644 index 0000000..8562bdb --- /dev/null +++ b/redis-light.go @@ -0,0 +1,13 @@ +package redislight // Where is an Entry point? + +import "errors" + +// ErrKeyIsNotExists indicates that there is no value associated with provided key +var ErrKeyIsNotExists = errors.New("key is not exists") + +// Storage provides abstraction for data storage +type Storage interface { + Get(key string) (string, error) + Set(key, val string) error + Del(key string) error +} diff --git a/server/server.go b/server/server.go new file mode 100644 index 0000000..a1573b7 --- /dev/null +++ b/server/server.go @@ -0,0 +1,106 @@ +package server + +import ( + "bufio" + "fmt" + "io" + "log" + "net" + + redislight "github.com/ITandElectronics/GoHomework" + "github.com/ITandElectronics/GoHomework/protocol" +) + +// Server tcp server. Server will handle each client into separate gorutine +type Server struct { + l net.Listener + storage redislight.Storage + port int +} + +// New contruct tcp server from provided port and storage +func New(port int, storage redislight.Storage) (*Server, error) { + l, err := net.Listen("tcp4", fmt.Sprintf(":%d", port)) + if err != nil { + return nil, fmt.Errorf("coundn't create tcp listener: %v", err) + } + return &Server{l: l, port: port, storage: storage}, nil +} + +func (s *Server) handleConnection(conn net.Conn) { + defer conn.Close() + r := bufio.NewReader(conn) + for { + line, _, err := r.ReadLine() + if err != nil { + if err == io.EOF { + log.Println("connection has been closed by client") + return + } + log.Printf("couldn't read from connection: %v\n", err) + continue + } + msg, err := protocol.DecodeMessage(line) + if err != nil { + fmt.Fprintf(conn, "%s\r\n", err.Error()) + continue + } + if err := protocol.ValidateMessage(msg); err != nil { + fmt.Fprintf(conn, "%v\r\n", err) + log.Printf("invalid message: %v\n", err) + continue + } + + log.Printf("client request: %s %s %s\n", msg.Cmd, msg.Key, msg.Val) + switch msg.Cmd { + case "GET": + val, err := s.storage.Get(msg.Key) + if err != nil { + fmt.Fprintf(conn, "(%s, absent)\r\n", err.Error()) + continue + } + fmt.Fprintf(conn, "(%s, present)\r\n", val) + continue + case "SET": + if err := s.storage.Set(msg.Key, msg.Val); err != nil { + fmt.Fprintf(conn, "(err, %v)\r\n", err) + continue + } + fmt.Fprintf(conn, "(ok)\r\n") + continue + case "DEL": + if err := s.storage.Del(msg.Key); err != nil { + fmt.Fprintf(conn, "(ignored)\r\n") + continue + } + fmt.Fprintf(conn, "(absent)\r\n") + continue + default: + fmt.Fprintf(conn, "command '%s' is not supported\r\n", msg.Cmd) + continue + } + } +} + +func (Server) welcome(conn net.Conn) { + fmt.Fprintf(conn, `Welcome to redis-server! +Commands should be in following format: [cmd name] [param1] [param2] +Supported commands: GET [key name], SET [key name] [value], DEL [key name] +`) +} + +// Run start accepting connections from clients and handle their requests. Blocking operation +func (s *Server) Run() error { + if s.l == nil { + return fmt.Errorf("server is not initialized. please call server.New fist") + } + log.Printf("server is accepting connections on %d\n", s.port) + for { + conn, err := s.l.Accept() + if err != nil { + log.Printf("coudln't accept connection: %v\n", err) + continue + } + go s.handleConnection(conn) + } +} diff --git a/server/server_test.go b/server/server_test.go new file mode 100644 index 0000000..a0e7ffe --- /dev/null +++ b/server/server_test.go @@ -0,0 +1,18 @@ +package server + +import ( + "testing" +) + +func TestNew(t *testing.T) { + server, err := New(8080, nil) + if err != nil { + t.Fatal(err) + } + if server.l == nil { + t.Fatal("listener is nil") + } + if server.storage != nil { + t.Fatal("storage should be nil") + } +}