From c4fd6e6deaf745cd1a660355ad7007929490b472 Mon Sep 17 00:00:00 2001 From: marcel-dempers Date: Mon, 22 Nov 2021 14:36:29 +1100 Subject: [PATCH] add python redis code and guide --- .../part-5.database.redis/readme.md | 2 +- .../part-5.database.redis/README.md | 499 ++++++++++++++++++ .../part-5.database.redis/dockerfile | 14 + .../part-5.database.redis/requirements.txt | 2 + .../part-5.database.redis/src/app.py | 99 ++++ 5 files changed, 615 insertions(+), 1 deletion(-) create mode 100644 python/introduction/part-5.database.redis/README.md create mode 100644 python/introduction/part-5.database.redis/dockerfile create mode 100644 python/introduction/part-5.database.redis/requirements.txt create mode 100644 python/introduction/part-5.database.redis/src/app.py diff --git a/golang/introduction/part-5.database.redis/readme.md b/golang/introduction/part-5.database.redis/readme.md index b7cd39f..2cac64b 100644 --- a/golang/introduction/part-5.database.redis/readme.md +++ b/golang/introduction/part-5.database.redis/readme.md @@ -23,7 +23,7 @@ Code is over [here](../../../storage/redis/clustering/readme.md) ## Go Dev Environment -The same as Part 1+2+3, we start with a [dockerfile](./dockerfile) where we declare our version of `go`. +The same as Part 1+2+3+4, we start with a [dockerfile](./dockerfile) where we declare our version of `go`. The `dockerfile`: diff --git a/python/introduction/part-5.database.redis/README.md b/python/introduction/part-5.database.redis/README.md new file mode 100644 index 0000000..eefa375 --- /dev/null +++ b/python/introduction/part-5.database.redis/README.md @@ -0,0 +1,499 @@ +# Introduction to Python: Storing data in Redis Database + +So far, we've learnt Python fundamentals, worked with data, files , HTTP and more importantly, basic data structures like `csv` and `json`. + +Be sure to checkout:
+[Part 1: Intro to Python](../README.md)
+[Part 2: Files](../part-2.files/README.md)
+[Part 3: JSON](../part-3.json/README.md)
+ +## Start up a Redis Cluster + +Follow my Redis clustering Tutorial
+ +Redis Guide + +Code is over [here](../../../storage/redis/clustering/readme.md) + +## Python Dev Environment + +The same as Part 1+2+3+4, we start with a [dockerfile](./dockerfile) where we declare our version of `python`. + +``` +FROM python:3.9.6-alpine3.13 as dev + +WORKDIR /work +``` + +Let's build and start our container: + +``` +cd python\introduction\part-5.database.redis + +docker build --target dev . -t python +docker run -it -v ${PWD}:/work -p 5000:5000 --net redis python sh + +/work # python --version +Python 3.9.6 + +``` + +## Our application + +We're going to use what we've learnt in part 1,2 & 3 and create +our customer app that handles customer data
+Firstly we have to import our dependencies: + +``` +import os.path +import csv +import json +from flask import Flask +from flask import request + +``` + +Then we have a class to define what a customer looks like: +``` +class Customer: + def __init__(self, c="",f="",l=""): + self.customerID = c + self.firstName = f + self.lastName = l + def fullName(self): + return self.firstName + " " + self.lastName +``` + +And also set a global variable for the location of our videos `json` file: + +``` +dataPath = "./customers.json" +``` + +Then we need a function which returns our customers: +``` +def getCustomers(): + if os.path.isfile(dataPath): + with open(dataPath, newline='') as customerFile: + data = customerFile.read() + customers = json.loads(data) + return customers + else: + return {} +``` + +Here is a function to return a specific customer: +``` +def getCustomer(customerID): + customers = getCustomers() + + if customerID in customers: + return customers[customerID] + else: + return {} +``` +And finally a function for updating our customers: + +``` +def updateCustomers(customers): + with open(dataPath, 'w', newline='') as customerFile: + customerJSON = json.dumps(customers) + customerFile.write(customerJSON) +``` + +In the previous episode, we've created a `json` file to hold all our customers.
+We've learnt how to read and write to file and temporarily use the file for our storage.
+ +Let's create a file called `customers.json` : +``` +{ + "a": { + "customerID": "a", + "firstName": "James", + "lastName": "Baker" + }, + "b": { + "customerID": "b", + "firstName": "Jonathan", + "lastName": "D" + }, + "c": { + "customerID": "c", + "firstName": "Aleem", + "lastName": "Janmohamed" + }, + "d": { + "customerID": "d", + "firstName": "Ivo", + "lastName": "Galic" + }, + "e": { + "customerID": "e", + "firstName": "Joel", + "lastName": "Griffiths" + }, + "f": { + "customerID": "f", + "firstName": "Michael", + "lastName": "Spinks" + }, + "g": { + "customerID": "g", + "firstName": "Victor", + "lastName": "Savkov" + } +} +``` + +Now that we have our customer data and functions to read and update our customer data, let's define our `Flask` application: + + +``` +app = Flask(__name__) +``` + +We create our route to get all customers: + +``` +@app.route("/", methods=['GET']) +def get_customers(): + customers = getCustomers() + return json.dumps(customers) +``` + +A route to get one customer by ID: + +``` +@app.route("/get/", methods=['GET']) +def get_customer(customerID): + customer = getCustomer(customerID) + + if customer == {}: + return {}, 404 + else: + return customer +``` + +And finally a route to update or add customers called `/set` : + +``` +@app.route("/set", methods=['POST']) +def add_customer(): + jsonData = request.json + + if "customerID" not in jsonData: + return "customerID required", 400 + if "firstName" not in jsonData: + return "firstName required", 400 + if "lastName" not in jsonData: + return "lastName required", 400 + + customers = getCustomers() + customers[jsonData["customerID"]] = Customer( jsonData["customerID"], jsonData["firstName"], jsonData["lastName"]).__dict__ + updateCustomers(customers) + return "success", 200 +``` + +Before we can be done, we need to import our `Flask` dependency we covered in our Python HTTP video.
+Let's create a `requirements.txt` file: + +``` +Flask == 2.0.2 +``` + +We can install our dependencies using: + +``` +pip install -r requirements.txt +``` + +This gives us a web application that handles customer data and using a file as it's storage
+To test it, we can start up Flask: + +``` +export FLASK_APP=src/app +flask run -h 0.0.0 -p 5000 +``` + +Now we can confirm it's working by accessing our application in the browser on `http://localhost:5000` + +## Redis + +To connect to Redis, we'll use a popular library called `redis-py` which we can grab from [here](https://github.com/redis/redis-py)
+The pip install is over [here](https://pypi.org/project/redis/3.5.3/)
+ +Let's add that to our `requirements.txt` dependency files. + +``` +redis == 3.5.3 +``` + +We can proceed to install it using `pip install` + +``` +pip install -r requirements.txt +``` + +Now to connect to Redis in a highly available manner, we need to take a look at the +`Sentinel support` section of the guide
+ +Let's test the library. The beauty of Python is that it's a scripting language, so we don't have to compile and keep restarting our application, we can test each line of code.
+ +``` +python +from redis.sentinel import Sentinel + +sentinel = Sentinel([('sentinel-0', 5000),('sentinel-1', 5000),('sentinel-2', 5000)], socket_timeout=0.1) + +sentinel.discover_master('mymaster') + +sentinel.discover_slaves('mymaster') + +master = sentinel.master_for('mymaster',password = "a-very-complex-password-here", socket_timeout=0.1) + +slave = sentinel.slave_for('mymaster',password = "a-very-complex-password-here", socket_timeout=0.1) + +master.set('foo', 'bar') +slave.get('foo') +``` + +We can demonstrate reading and writing a key value pair.
+We can also demonstrate failure, when we stop the current master, we'll get a connection error. It's important to implement retry logic.
+If we wait a moment and execute commands again, we will see that it starts to work. + + +``` +# stop current master +docker rm -f redis-0 + +master.set('foo', 'bar2') + +redis.exceptions.ConnectionError: Connection closed by server. + +# retry moments later... + +master.set('foo', 'bar2') +slave.get('foo') + +sentinel.discover_master('mymaster') +sentinel.discover_slaves('mymaster') +``` + +We can find the current master by running `docker inspect` to see who owns that IP address. + +Start up `redis-0` again, to simulate a recovery from failure. + +## Connecting our App to Redis + +To connect to redis, we'll want to read the connection info from environment variables. Let's set some global variables. + +``` +import os + +redis_sentinels = os.environ.get('REDIS_SENTINELS') +redis_master_name = os.environ.get('REDIS_MASTER_NAME') +redis_password = os.environ.get('REDIS_PASSWORD') + +``` + +We will need to restart our container so we can inject these environment variables. Let's go ahead and do that: + +``` +docker run -it -p 5000:5000 ` + --net redis ` + -v ${PWD}:/work ` + -e REDIS_SENTINELS="sentinel-0:5000,sentinel-1:5000,sentinel-2:5000" ` + -e REDIS_MASTER_NAME="mymaster" ` + -e REDIS_PASSWORD="a-very-complex-password-here" ` + python sh + +# re-install our dependencies +pip install -r requirements.txt +``` + +Now we can setup a client: + +``` +from redis.sentinel import Sentinel + +sentinels = [] + +for s in redis_sentinels.split(","): + sentinels.append((s.split(":")[0], s.split(":")[1])) + +redis_sentinel = Sentinel(sentinels, socket_timeout=5) +redis_master = redis_sentinel.master_for(redis_master_name,password = redis_password, socket_timeout=5) +``` + +## Retry logic + +Now we noticed that if we have a master that fails, the sentinels will choose and assign a new master. We can see this by simply retrying our redis command.
+ +When talking to redis we need to have some retry capability to be able to recover from this scenario.
+ +Let's build a retry function at the top of our application, that runs a redis command: + +``` +def redis_command(command, *args): + max_retries = 3 + count = 0 + backoffSeconds = 5 + while True: + try: + return command(*args) + except (redis.exceptions.ConnectionError, redis.exceptions.TimeoutError): + count += 1 + if count > max_retries: + raise + print('Retrying in {} seconds'.format(backoffSeconds)) + time.sleep(backoffSeconds) +``` + +We can test out our `redis_command` by calling it and printing the result +to the screen + +``` +print(redis_command(redis_master.set, 'foo', 'bar')) +print(redis_command(redis_master.get, 'foo')) +``` + +We can simulate failure again, by finding and stopping the current master. + +Once we're done with our tests, we can `exec` into the current master and run `FLUSHALL` to remove our test records from redis. + +## Saving our data to Redis + +Now let's change our customer functions to point to Redis instead of file
+ +Starting with `getCustomer` to retrieve a single customer +``` +def getCustomer(customerID): + customer = redis_command(redis_master.get, customerID) + + if customer == None: + return {} + else: + c = str(customer, 'utf-8') + return json.loads(c) +``` +Now we can use that to return all our customers by updating the `getCustomers` function: + +``` +def getCustomers(): + customers = {} + customerIDs = redis_command(redis_master.scan_iter, "*") + for customerID in customerIDs: + customer = getCustomer(customerID) + customers[customer["customerID"]] = customer + + return customers +``` + +Let's improve our functions by adding a new function to update a single customer: + +``` +def updateCustomer(customer): + redis_command(redis_master.set, customer.customerID, json.dumps(customer.__dict__)) + +``` + +And finally we can use that function to update all customers by tweaking our `updateCustomers` function: + +``` +def updateCustomers(customers): + for customer in customers: + updateCustomer(customer) +``` + +Now our simple functions are done, let's hook them up to our endpoints + +``` +# firstly delete these test lines +print(redis_command(redis_master.set, 'foo', 'bar')) +print(redis_command(redis_master.get, 'foo')) +``` + +Our simple Get all + +``` +@app.route("/", methods=['GET']) +def get_customers(): + customers = getCustomers() + return json.dumps(customers) +``` + +Our Get by ID + +``` +@app.route("/get/", methods=['GET']) +def get_customer(customerID): + customer = getCustomer(customerID) + + if customer == {}: + return {}, 404 + else: + return customer +``` + +And our update endpoint to update a customer + +``` +@app.route("/set", methods=['POST']) +def add_customer(): + jsonData = request.json + + if "customerID" not in jsonData: + return "customerID required", 400 + if "firstName" not in jsonData: + return "firstName required", 400 + if "lastName" not in jsonData: + return "lastName required", 400 + + customer = Customer( jsonData["customerID"], jsonData["firstName"], jsonData["lastName"]) + updateCustomer(customer) + return "success", 200 +``` +## Docker + +Let's build our container image and run it while mounting our customer file + +Our final `dockerfile` +``` +FROM python:3.9.6-alpine3.13 as dev + +WORKDIR /work + +FROM dev as runtime +WORKDIR /app + +COPY ./requirements.txt /app/ +RUN pip install -r /app/requirements.txt + +COPY ./src/app.py /app/app.py +ENV FLASK_APP=app.py + +CMD flask run -h 0.0.0 -p 5000 + +``` + +Build our container. + +``` +cd python\introduction\part-5.database.redis + +docker build . -t customer-app + +``` + +Now we can run our production container: + +``` +docker build . -t customer-app + +docker run -it -p 5000:5000 ` + --net redis ` + -e REDIS_SENTINELS="sentinel-0:5000,sentinel-1:5000,sentinel-2:5000" ` + -e REDIS_MASTER_NAME="mymaster" ` + -e REDIS_PASSWORD="a-very-complex-password-here" ` + customer-app +``` \ No newline at end of file diff --git a/python/introduction/part-5.database.redis/dockerfile b/python/introduction/part-5.database.redis/dockerfile new file mode 100644 index 0000000..05fcb85 --- /dev/null +++ b/python/introduction/part-5.database.redis/dockerfile @@ -0,0 +1,14 @@ +FROM python:3.9.6-alpine3.13 as dev + +WORKDIR /work + +FROM dev as runtime +WORKDIR /app + +COPY ./requirements.txt /app/ +RUN pip install -r /app/requirements.txt + +COPY ./src/app.py /app/app.py +ENV FLASK_APP=app.py + +CMD flask run -h 0.0.0 -p 5000 \ No newline at end of file diff --git a/python/introduction/part-5.database.redis/requirements.txt b/python/introduction/part-5.database.redis/requirements.txt new file mode 100644 index 0000000..44daf50 --- /dev/null +++ b/python/introduction/part-5.database.redis/requirements.txt @@ -0,0 +1,2 @@ +Flask == 2.0.2 +Redis == 3.5.3 \ No newline at end of file diff --git a/python/introduction/part-5.database.redis/src/app.py b/python/introduction/part-5.database.redis/src/app.py new file mode 100644 index 0000000..c02a496 --- /dev/null +++ b/python/introduction/part-5.database.redis/src/app.py @@ -0,0 +1,99 @@ +import os.path +import csv +import os +import json +import time +from flask import Flask +from flask import request +import redis +from redis.sentinel import Sentinel + +redis_sentinels = os.environ.get('REDIS_SENTINELS') +redis_master_name = os.environ.get('REDIS_MASTER_NAME') +redis_password = os.environ.get('REDIS_PASSWORD') + +class Customer: + def __init__(self, c="",f="",l=""): + self.customerID = c + self.firstName = f + self.lastName = l + def fullName(self): + return self.firstName + " " + self.lastName + +def redis_command(command, *args): + max_retries = 3 + count = 0 + backoffSeconds = 5 + while True: + try: + return command(*args) + except (redis.exceptions.ConnectionError, redis.exceptions.TimeoutError): + count += 1 + if count > max_retries: + raise + print('Retrying in {} seconds'.format(backoffSeconds)) + time.sleep(backoffSeconds) + +def getCustomers(): + customers = {} + customerIDs = redis_command(redis_master.scan_iter, "*") + for customerID in customerIDs: + customer = getCustomer(customerID) + customers[customer["customerID"]] = customer + + return customers + +def getCustomer(customerID): + customer = redis_command(redis_master.get, customerID) + + if customer == None: + return {} + else: + c = str(customer, 'utf-8') + return json.loads(c) + +def updateCustomer(customer): + redis_command(redis_master.set, customer.customerID, json.dumps(customer.__dict__)) + +def updateCustomers(customers): + for customer in customers: + updateCustomer(customer) + +app = Flask(__name__) + +sentinels = [] + +for s in redis_sentinels.split(","): + sentinels.append((s.split(":")[0], s.split(":")[1])) + +redis_sentinel = Sentinel(sentinels, socket_timeout=5) +redis_master = redis_sentinel.master_for(redis_master_name,password = redis_password, socket_timeout=5) + +@app.route("/", methods=['GET']) +def get_customers(): + customers = getCustomers() + return json.dumps(customers) + +@app.route("/get/", methods=['GET']) +def get_customer(customerID): + customer = getCustomer(customerID) + + if customer == {}: + return {}, 404 + else: + return customer + +@app.route("/set", methods=['POST']) +def add_customer(): + jsonData = request.json + + if "customerID" not in jsonData: + return "customerID required", 400 + if "firstName" not in jsonData: + return "firstName required", 400 + if "lastName" not in jsonData: + return "lastName required", 400 + + customer = Customer( jsonData["customerID"], jsonData["firstName"], jsonData["lastName"]) + updateCustomer(customer) + return "success", 200 \ No newline at end of file