diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..2b84a3f --- /dev/null +++ b/Dockerfile @@ -0,0 +1,7 @@ +FROM golang:latest + +COPY . /go/src/TinyRedis + +ENTRYPOINT ["/go/src/TinyRedis/TinyRedisServer/TinyRedisServer"] + +EXPOSE 9090 diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..7538865 --- /dev/null +++ b/Makefile @@ -0,0 +1,49 @@ +SERVER_PATH := ./TinyRedisServer +CLIENT_PATH := ./TinyRedisClient +SERVER_FILE := ./TinyRedisServer/TinyRedisServer.go +CLIENT_FILE := ./TinyRedisClient/TinyRedisClient.go +SERVER_BIN := ./TinyRedisServer/TinyRedisServer +CLIENT_BIN := ./TinyRedisClient/TinyRedisClient + +BECOME := sudo -E +VERSION := $(shell cat VERSION) +DOCKER_IMAGE := tinyredis:$(VERSION) +DOCKER_BUILDER := golang:1.11 +RUNNER = docker run --rm -v $(CURDIR):/go/src/TinyRedis/$(SERVER_PATH) +RUNNER += $(DOCKER_ENVS) -w /go/src/TinyRedis/$(SERVER_PATH) + +SEARCH_GOFILES = find -not -path '*/vendor/*' -type f -name "*.go" ! -name "*_test*" +BUILDER = $(RUNNER) $(DOCKER_BUILDER) +PORT := 9090 + +.PHONY: tinyredis +tinyredis: + docker build -t tinyredis . + +.PHONY: build +build: + go build -o $(SERVER_BIN) $(SERVER_FILE) + go build -o $(CLIENT_BIN) $(CLIENT_FILE) + +.PHONY: clean +clean: + $(BECOME) $(RM) $(SERVER_PATH)/TinyRedisServer + $(BECOME) $(RM) $(CLIENT_PATH)/TinyRedisClient + +.PHONY: cleancontainer +cleancontainer: + docker rm tinyredis + +.PHONY: check +check: + $(BECOME) $(BUILDER) sh -xc '\ + test -z "`$(SEARCH_GOFILES) -exec gofmt -s -l {} \;`" \ + && test -z "`$(SEARCH_GOFILES) -exec go vet {} \;`"' + +.PHONY: test +test: + go test $(SERVER_PATH) -coverprofile coverage.out + go test $(CLIENT_PATH) -coverprofile coverage_client.out + tail -n +2 coverage_client.out >> coverage.out + rm coverage_client.out + go tool cover -html coverage.out diff --git a/README.md b/README.md new file mode 100644 index 0000000..749be2d --- /dev/null +++ b/README.md @@ -0,0 +1,69 @@ +# TinyRedis + +*TinyRedis* is a very lightweight app, divided into two parts, *TinyRedisServer* & *TinyRedisClient*, which work together just like [Redis](https://redis.io), but only with 4 commands: `SET`, `GET`, `DEL` and `EXIT`. + +## Instruction +In order to launch the program, it's ought to do the following steps: +Get the app via following commands: +``` sh +git clone https://github.com/corvustristis/GoHomework +cd GoHomework +``` + +### Run via terminal +To do it, you have to install [Go](https://golang.org/) compiler. Then proceed to the following steps: +1) Launch *TinyRedisServer* from the folder of the same name: +```sh +./TinyRedisServer +``` +There are options `-p` or `--port` for the choice of port and options `-m` or `--mode` for the choice of port. + +2) Launch *TinyRedisClient* from another folder: +```sh +./TinyRedisClient +``` +If you had chosen a port of your preferences in a previous step, don't forget to use it here as well. Optionally you can launch multiple clients at the same time, though you would be doing it at your own risk. + +### Run via Docker +To proceed, you must have [Docker](https://www.docker.com/) installed. After that proceed to the following steps: + +1) The project is already built and checked, but just in case you can delete binaries, compile them again, or check the code with `gofmt` and `go vet`, run some of the the following lines: +```sh +make clean build check +``` + +2) Build the project with: +```sh +make tinyredis +``` + +3) Launch server: +```sh +docker run -p 9090:9090 --name tinyredis tinyredis +``` +There are options `-p` or `--port` for the choice of port and options `-m` or `--mode` for the choice of port. For example, if you have chosen custom port 3333, launch server via following: +```sh +docker run -p 3333:9090 --name tinyredis tinyredis --port=3333 +``` +If you have already lauched the container at least once, and there is a name conflict, do this: +```sh +make cleancontainer +``` + +4) Launch bash for *TinyRedisClient*: +```sh +docker exec -it tinyredis bash +``` + +5) Finally, lauch the client itself: +```sh +./src/TinyRedis/TinyRedisClient/TinyRedisClient +``` +Don't forget about your custom port here! + +## Testing +Thought test files are stored in each folder separately, you can test the code via following instruction right from the main folder: +```sh +make test +``` +After it, the browser with test data will open, and you would be able to choose either server, or client coverage. diff --git a/TinyRedisClient/TinyRedisClient b/TinyRedisClient/TinyRedisClient new file mode 100755 index 0000000..1a73de0 Binary files /dev/null and b/TinyRedisClient/TinyRedisClient differ diff --git a/TinyRedisClient/TinyRedisClient.go b/TinyRedisClient/TinyRedisClient.go new file mode 100644 index 0000000..7eb888c --- /dev/null +++ b/TinyRedisClient/TinyRedisClient.go @@ -0,0 +1,97 @@ +package main + +import ( + "bufio" + "flag" + "fmt" + "net" + "os" + "time" +) + +var port, host string + +type cmdFlagHandler struct { // command line flag handler + options []string + defaultVal string + warning string + possibleRange []string +} + +func main() { + address := getAddress() // address retrieving + + conn := connectionAttempt(address) // connection handler + fmt.Printf("Connected to %s\n", address) + defer fmt.Println("Connection closed") + + for { + fmt.Print(">>> ") + reader := bufio.NewReader(os.Stdin) + text, _ := reader.ReadString('\n') // got client's command + fmt.Fprintf(conn, text+"\n") // which is being sent via port + + if len(text) > 4 { // closing connection with server + if text[0:4] == "EXIT" { + return + } + } + + message, _ := bufio.NewReader(conn).ReadString('\n') + fmt.Print(message) + } +} + +func getAddress() string { // returns complete server address + port, host = cmdFlagParse() + address := host + ":" + port + return address +} + +func cmdFlagParse() (string, string) { // cmd flag parser + portHandler := cmdFlagHandler{ // default port and host data + options: []string{"p", "port"}, + defaultVal: "9090", + warning: "specify port to use", + } + hostHandler := cmdFlagHandler{ + options: []string{"h", "host"}, + defaultVal: "127.0.0.1", + warning: "specify port to use", + } + // long and short forms are for long and short flags, e.g. -p and --port + portShortResult := flag.String(portHandler.options[0], + portHandler.defaultVal, + portHandler.warning) + + portLongResult := flag.String(portHandler.options[1], + portHandler.defaultVal, + portHandler.warning) + + hostShortResult := flag.String(hostHandler.options[0], hostHandler.defaultVal, hostHandler.warning) + hostLongResult := flag.String(hostHandler.options[1], hostHandler.defaultVal, hostHandler.warning) + + flag.Parse() + + var portResult, hostResult string + if *portLongResult != portHandler.defaultVal { + portResult = *portLongResult + } else { + portResult = *portShortResult + } + + if *hostLongResult != hostHandler.defaultVal { + hostResult = *hostLongResult + } else { + hostResult = *hostShortResult + } + return portResult, hostResult +} + +func connectionAttempt(addr string) net.Conn { // five seconds for dialing server + conn, err := net.DialTimeout("tcp", addr, 5*time.Second) + if err != nil { + panic(err) + } + return conn +} diff --git a/TinyRedisClient/TinyRedisClient_test.go b/TinyRedisClient/TinyRedisClient_test.go new file mode 100644 index 0000000..5096eb3 --- /dev/null +++ b/TinyRedisClient/TinyRedisClient_test.go @@ -0,0 +1,24 @@ +package main + +import "testing" + +func TestGetAddress(t *testing.T) { + address := getAddress() + if address == "127.0.0.1:9090" { + t.Log("\t[OK]") + } else { + t.Error("\t[ERR]\tError with address") + } +} + +func TestConnectionAttempt(t *testing.T) { + defer func() { + if r := recover(); r == nil { + t.Errorf("Error with connection") + } else { + t.Log("\t[OK]") + } + }() + + connectionAttempt("127:0:0:1:9090") +} diff --git a/TinyRedisServer/ServerData.txt b/TinyRedisServer/ServerData.txt new file mode 100644 index 0000000..b748e2d --- /dev/null +++ b/TinyRedisServer/ServerData.txt @@ -0,0 +1 @@ +0 0 diff --git a/TinyRedisServer/TinyRedisServer b/TinyRedisServer/TinyRedisServer new file mode 100755 index 0000000..a9fc990 Binary files /dev/null and b/TinyRedisServer/TinyRedisServer differ diff --git a/TinyRedisServer/TinyRedisServer.go b/TinyRedisServer/TinyRedisServer.go new file mode 100644 index 0000000..a8b7fc3 --- /dev/null +++ b/TinyRedisServer/TinyRedisServer.go @@ -0,0 +1,272 @@ +package main + +import ( + "bufio" + "flag" + "fmt" + "log" + "net" + "os" + "strings" + "sync" +) + +type cmdFlagHandler struct { // different flags handler + options []string + defaultVal string + warning string + possibleRange []string +} + +type command struct { + fields []string + result chan string +} + +var address = "127.0.0.1" +var port, mode string +var dataMutex = sync.RWMutex{} +var diskDir = "ServerData.txt" // defaulf file for "-m=disk"option +var commands = make(chan command) + +func main() { + port, mode = cmdFlagParse() // parsing results from cmd + fullAddress := address + ":" + port + + l, err := net.Listen("tcp", fullAddress) + if err != nil { + panic(err) + } + defer l.Close() + + log.Printf("Server is running on %s\n", fullAddress) + log.Println("Ready to accept connections") + + var mapData = make(map[string]string) // creation of map for server data + createStorage(mapData) // creation of storage + + for { + conn, err := l.Accept() + if err != nil { + log.Fatalln(err) + } + go handle(commands, conn) + } +} + +func contains(strs []string, str string) bool { // function for flag parsing + if strs == nil { + return true + } + for _, val := range strs { + if str == val { + return true + } + } + return false +} + +func cmdFlagParse() (string, string) { + portHandler := cmdFlagHandler{ // different flag options + options: []string{"p", "port"}, + defaultVal: "9090", + warning: "specify port to use", + } + + modeHandler := cmdFlagHandler{ + options: []string{"m", "mode"}, + defaultVal: "ram", + warning: "specify memory mode", + possibleRange: []string{"ram", "disk"}, + } + + // long and short forms are for long and short flags, e.g. -p and --port + portShortResult := flag.String(portHandler.options[0], portHandler.defaultVal, portHandler.warning) + portLongResult := flag.String(portHandler.options[1], portHandler.defaultVal, portHandler.warning) + + modeShortResult := flag.String(modeHandler.options[0], modeHandler.defaultVal, modeHandler.warning) + modeLongResult := flag.String(modeHandler.options[1], modeHandler.defaultVal, modeHandler.warning) + + flag.Parse() + + var portResult, modeResult string + if *portLongResult != portHandler.defaultVal { + portResult = *portLongResult + } else { + portResult = *portShortResult + } + + if *modeLongResult != modeHandler.defaultVal && contains(modeHandler.possibleRange, *modeLongResult) { + modeResult = *modeLongResult + } else if contains(modeHandler.possibleRange, *modeShortResult) { + modeResult = *modeShortResult + } else { + modeResult = modeHandler.defaultVal + } + + return portResult, modeResult +} + +func handle(commands chan command, conn net.Conn) { // client input processing + defer func() { + conn.Close() + log.Println("Connection closed") + }() + + log.Println("Connection from", conn.RemoteAddr()) + for { + msg, err := bufio.NewReader(conn).ReadString('\n') + // if client was unexpectadly unconnected, server is able to detect it + // and close connection via following lines: + if err != nil { + return + } + + flds := strings.Fields(msg) // result formatting + + if len(flds) > 0 { + if flds[0] == "EXIT" { // decent exit processing + return + } + } + + result := make(chan string) + commands <- command{ + fields: flds, + result: result, + } + conn.Write([]byte(<-result + "\n>>> ")) + } +} + +func createStorage(mapData map[string]string, testStrs ...string) string { + go storage(commands, mapData) // performing client's commands + + if testStrs != nil { // it is here just for test purposes + result := make(chan string) + flds := strings.Fields(testStrs[0]) + commands <- command{ + fields: flds, + result: result, + } + return <-result + } + return "" +} + +func storage(cmd chan command, mapData map[string]string) { + for cmd := range cmd { + if len(cmd.fields) < 1 { + cmd.result <- "" + continue + } + if len(cmd.fields) < 2 { + cmd.result <- "Expected at least 2 arguments!" + continue + } + + switch cmd.fields[0] { + case "GET": + dataMutex.RLock() + if mode == "disk" { + var err error + if mapData, err = readFromFile(); err != nil { + panic(err) + } + } + val, ok := mapData[cmd.fields[1]] + if !ok { + cmd.result <- "nil" + } else { + cmd.result <- val + } + dataMutex.RUnlock() + + case "SET": + if len(cmd.fields) < 3 { + cmd.result <- "Expected value!" + continue + } else if len(cmd.fields) > 3 { + cmd.result <- "Expected Key and Value!" + continue + } + dataMutex.Lock() + if mode == "disk" { + var err error + if mapData, err = readFromFile(); err != nil { + panic(err) + } + } + mapData[cmd.fields[1]] = cmd.fields[2] + if mode == "disk" { + if err := writeToFile(mapData); err != nil { + panic(err) + } + } + cmd.result <- "done" + dataMutex.Unlock() + + case "DEL": + dataMutex.Lock() + if mode == "disk" { + var err error + if mapData, err = readFromFile(); err != nil { + panic(err) + } + } + delete(mapData, cmd.fields[1]) + if mode == "disk" { + if err := writeToFile(mapData); err != nil { + panic(err) + } + } + cmd.result <- "deleted" + dataMutex.Unlock() + + default: + cmd.result <- fmt.Sprintf("Invalid command \"%s\"", cmd.fields[0]) + } + } +} + +// reading from and writing to disk via following functions: +func readFromFile() (map[string]string, error) { + var mapData = make(map[string]string) + file, err := os.Open(diskDir) + if err != nil { + return mapData, err + } + defer file.Close() + + var lines []string + scanner := bufio.NewScanner(file) + for scanner.Scan() { + lines = append(lines, scanner.Text()) + } + + for _, line := range lines { // fillling out map variable + splitted := strings.Fields(string(line)) + mapData[splitted[0]] = splitted[1] + } + return mapData, scanner.Err() +} + +func writeToFile(mapData map[string]string) error { + file, err := os.Create(diskDir) + if err != nil { + return err + } + defer file.Close() + + var lines []string + for key, val := range mapData { // conversion from map to lines in file + line := key + " " + val + lines = append(lines, line) + } + + w := bufio.NewWriter(file) + for _, line := range lines { + fmt.Fprintln(w, line) + } + return w.Flush() +} diff --git a/TinyRedisServer/TinyRedisServer_test.go b/TinyRedisServer/TinyRedisServer_test.go new file mode 100644 index 0000000..48655b9 --- /dev/null +++ b/TinyRedisServer/TinyRedisServer_test.go @@ -0,0 +1,118 @@ +package main + +import ( + "testing" +) + +type cmd struct { + fields []string + result chan string +} + +func TestContains(t *testing.T) { + var stringsSet = []string{"one", "two"} + var testString = "two" + if contains(stringsSet, testString) { + t.Log("\t[OK]") + } else { + t.Error("\t[ERR]\tShould return true") + } + + testString = "three" + if !contains(stringsSet, testString) { + t.Log("\t[OK]") + } else { + t.Error("\t[ERR]\tShould return false") + } + + if contains(nil, testString) { + t.Log("\t[OK]") + } else { + t.Error("\t[ERR]\tShould return true") + } +} + +func TestCmdFlagParse(t *testing.T) { + port, mode := cmdFlagParse() + if port == "9090" && mode == "ram" { + t.Log("\t[OK]") + } else { + t.Error("\t[ERR]\tError with port and mode") + } +} + +func TestWriteToFile(t *testing.T) { + var mapData = make(map[string]string) + mapData["0"] = "0" + if err := writeToFile(mapData); err != nil { + t.Error("\t[ERR]\tError with writing to file") + } else { + t.Log("\t[OK]") + } +} + +func TestReadFromFile(t *testing.T) { + if _, err := readFromFile(); err != nil { + t.Error("\t[ERR]\tError with reading from file") + } else { + t.Log("\t[OK]") + } +} + +func TestCreateStorage(t *testing.T) { + var mapData = make(map[string]string) + + if line := createStorage(mapData, ""); line != "" { + t.Error("\t[ERR]\tError with storage commands processing") + } else { + t.Log("\t[OK]") + } + + if line := createStorage(mapData, "1"); line != "Expected at least 2 arguments!" { + t.Error("\t[ERR]\tError with storage commands processing") + } else { + t.Log("\t[OK]") + } + + if line := createStorage(mapData, "SET 0"); line != "Expected value!" { + t.Error("\t[ERR]\tError with setting to storage") + } else { + t.Log("\t[OK]") + } + + if line := createStorage(mapData, "SET 1 1 1"); line != "Expected Key and Value!" { + t.Error("\t[ERR]\tError with setting to storage") + } else { + t.Log("\t[OK]") + } + + if line := createStorage(mapData, "SET 1 2"); line != "done" { + t.Error("\t[ERR]\tError with setting to storage") + } else { + t.Log("\t[OK]") + } + + if line := createStorage(mapData, "GET 1"); line != "2" { + t.Error("\t[ERR]\tError with getting from storage") + } else { + t.Log("\t[OK]") + } + + if line := createStorage(mapData, "DEL 1"); line != "deleted" { + t.Error("\t[ERR]\tError with deleting from storage") + } else { + t.Log("\t[OK]") + } + + if line := createStorage(mapData, "GET 1"); line != "nil" { + t.Error("\t[ERR]\tError with getting from storage") + } else { + t.Log("\t[OK]") + } + + if line := createStorage(mapData, "REQUEST 1"); line != "Invalid command \"REQUEST\"" { + t.Error("\t[ERR]\tError with storage commands processing") + } else { + t.Log("\t[OK]") + } +} diff --git a/VERSION b/VERSION new file mode 100644 index 0000000..524cb55 --- /dev/null +++ b/VERSION @@ -0,0 +1 @@ +1.1.1