In this post we will explore some basic concepts while working with a microservices architecture in a distributed application. If you are not very familiar with microservices and the motivation behind it, you should take a look at this series of posts from NGINX that gives a great introduction on the subject.

All the concepts are quite simple and although the autor had sugested a few things to get started I will take you through the construction of a simple microservices app that you could deploy anywhere.

Warning

This is a simple introduction and will help you get started with the microservices architecture, and hopefully , with Docker. This series of posts are focused towards beginners and enthusiasts

Architecture

Simple architecture

In this simple app we will split it in three simple parts:

  • Client - WebUI that will be responsible to create an interface for the final user
  • WebServer - API Gateway - Will be responsible for authentication and authorisation, simple data validation and forwarding the requests to other services, if necessery.
  • Tasks - A service responsible only to store and manage tasks

In this series we will start from the Tasks service. After we have it ready we will work on our API Gateway and later on we will build an interface for the user.

Tasks service

This is a service that will not be reachable from the outside of our network. It is only responsible to store and manage Task related data. One of the biggest advantages of the microservices architecture is that each service can be implemented with different programming languages. Although, in your team, you will want to keep it to the languages that everybody knows, you could still take advantage of using different languages to solve different things.

For this service I picked Python and Thrift as the RPC. I will not explain the details of Thrift and why I picked it, and focus more in the implementation, so I suggest you take your time to check some articles about it, or other related RPC technologies, like gRPC.

If you prefer to use other language to develop this service, feel free to convert the python syntax to you language of choice. As long as Thrift supports it, everything should work almost exactly the same. If you find some trouble because of the difference, I suggest that you hit the Thrift tutorial for more examples in your language. Keep in mind that the thrift examples are not very updated and you may need to explore a try slight differences in the code to get it working.

If you are on a Mac, you can use brew install thrift to download and install the thrift code generator.

Thrift defines its service-client contract API through a thrift file. For our service we will start from there:

// Basic struct that we will use
struct Task {
  1: optional string id,
  2: string userId,
  3: optional string name = "",
  4: optional string createdOn,
  5: optional bool done
}

// An base exception class
exception BaseException {
  1: i32 code,
  2: string message
}

// Here is our Tasks service definition. Just like an interface definition
// it will give us the signature of the service. With Thrift you, after processing
// the file 
service Tasks {

  //List Tasks
  list<Task> all(1:string userId),

  //Add Task
  Task add(1:string userId, 2:string name),

  //Update
  Task update(1:string taskId, 2:string name, 3:bool done, 4:string userId) throws (1:BaseException ouch)

  //Upsert
  Task upsert(1:Task task) throws (1:BaseException ouch)

}

Save this content to a tasks.thrift fileo on the root of your repo and use the thrift cli to generate your server stub

thrift --gen py tasks.thrift

This command will generate a gen-py folder with many files in it

.
├── __init__.py
└── tasks
    ├── Tasks-remote
    ├── Tasks.py
    ├── __init__.py
    ├── constants.py
    └── ttypes.py

1 directory, 6 files

When I was developing this service I faced many issues while figuring out how to work with Thrift and use it for real. Here I will make it all simple for you.

I suggest that you start by creating an virtualenv and install the dependencies there. This way you will not polute your computer’s global scope with unecessary dependencies. Here is the requirements.txt

thriftpy
pymongo

In order to have a flexible configuration parameter, we will use a configuration file called config.py

import os

MONGO_HOST = os.environ.get('MONGO_HOST', 
                os.environ.get('MONGO_PORT_27017_TCP_ADDR', 
                    os.environ.get('MONGODB_PORT_27017_TCP_ADDR', 'localhost')
                )
            )
MONGO_PORT = os.environ.get('MONGO_PORT',
                os.environ.get('MONGO_PORT_27017_TCP_PORT', 
                    os.environ.get('MONGODB_PORT_27017_TCP_PORT', '27017')
                )
            )   
MONGO_DB   = os.environ.get('MONGODB_DATABASE', 'tasks-db')

print(MONGO_HOST)
print(MONGO_PORT)
class Config:
    @staticmethod
    def getTaskDBConfig():
        return {
            'host': MONGO_HOST,
            'port': MONGO_PORT,
            'db': MONGO_DB 
        }

    @staticmethod
    def getTaskServiceConfig():
        return {
            'host': '0.0.0.0',
            'port': 6001
        }

If you took time to read the code you probably noticed the following:

MONGO_HOST = os.environ.get('MONGO_HOST', 
                os.environ.get('MONGO_PORT_27017_TCP_ADDR', 
                    os.environ.get('MONGODB_PORT_27017_TCP_ADDR', 'localhost')
                )
            )
MONGO_PORT = os.environ.get('MONGO_PORT',
                os.environ.get('MONGO_PORT_27017_TCP_PORT', 
                    os.environ.get('MONGODB_PORT_27017_TCP_PORT', '27017')
                )
            )   

This will be used to setup our connection with the database. If you are familiar with python you will probably noticed that there are a few defaults to this file, and also a few options available for each variable. Once we get to the Docker part this will be much easier to understand.

We will use Pymongo as our Database interface. Here is the code to implement the basics for the database. Save it as db.py:

from pymongo import MongoClient
from bson.objectid import ObjectId

import pymongo
import datetime
from config import Config
import logging

logger = logging.getLogger(__name__)
mongoConfig = Config.getTaskDBConfig()
baseUrl = 'mongodb://{}:{}'.format(mongoConfig['host'], mongoConfig['port'])

logger.debug('--- MONGO URL: {}'.format(baseUrl))
database = mongoConfig['db']

db = MongoClient(baseUrl)
client = db[database]

class TaskDB:
    @staticmethod
    def all(userId):
        return client.tasks.find({"userId": userId}).sort("createdOn", pymongo.DESCENDING)

    @staticmethod
    def addOne(userId, name):
        instance = {"userId": userId, "name": name, "createdOn": datetime.datetime.utcnow(), "done": False}
        instance_id = client.tasks.insert_one(instance).inserted_id
        instance["_id"] = instance_id
        return instance

    @staticmethod
    def updateOne(id, userId, name=None, done=None):
        print('updateOne(%s,%s,%s,%s)' % (id, name, done, userId))
        criteria = {"userId": userId, "_id": ObjectId(id)}

        update = {}
        if (name is not None):
            print('setting name as %s' % name)
            update['name'] = name
        if (done is not None):
            print('setting done as %s' % done)
            update['done'] = done

        result = client.tasks.update_one(criteria, {'$set': update})
        instance = None
        if (result.matched_count > 0):
            instance = client.tasks.find_one(criteria)

        return instance

The both files described above will create a simple database manager and a configuration file that you could customize according to your idea. Take note that the DB manager is only connecting at startup and is not managing any connection exceptions or connection failures. This is not good for a production environment, and you should strive to handle failure and reconnect whenever your database connection fails, but this is not the scope of this post so I will leave this for you to change.

With this two classes already implemented, what is missing is just a Thrift interface to start our server and connect with the database. save this as server.py

import thriftpy
tasks = thriftpy.load("./tasks.thrift", module_name="tasks_thrift")

from thriftpy.rpc import make_server
from thriftpy.protocol import TJSONProtocolFactory
import logging
logging.basicConfig()

from db import TaskDB

from config import Config


class TaskHandler(object):

    def check(self):
        hc = tasks.Healthcheck()
        hc.ok = True
        hc.message = "OK"
        return hc

    def all(self, userId):
        print('getting all tasks for user: %s' % userId)
        cursor = TaskDB.all(userId)
        result = []
        task = None
        for t in cursor:
            task = tasks.Task()
            task.id = str(t['_id'])
            task.name = t['name']
            task.createdOn = t['createdOn'].isoformat()
            task.userId = t['userId']
            task.done = t['done']
            result.append(task)

        return result

    def add(self, userId, name):
        print('add(%s,%s)' % (userId, name))
        instance = TaskDB.addOne(userId, name)

        task = TaskHandler.convertInstance(instance)
        return task

    def update(self, id, name, done, userId):
        print('update(%s, %s, %b, %s)' % (id, name, done, userId))

        instance = TaskDB.updateOne(id, name, done, userId)

        if (instance == None):
            exception = tasks.BaseException()
            exception.code = 404
            exception.mesage = 'Task not found'
            raise exception

        task = TaskHandler.convertInstance(instance)
        return task

    def upsert(self, task):
        print('upsert(%s)' % (task))
        if (task is None):
            exception = tasks.BaseException()
            exception.code = 400
            exception.message = 'Task data is invalid'

        try:
            if (task.id is not None):
                instance = TaskDB.updateOne(task.id, task.userId, task.name, task.done)
            else:
                instance = TaskDB.addOne(task.userId, task.name)
        except (Exception):
            exception = tasks.BaseException()
            exception.code = 400
            exception.message = 'Unkown error'
            raise exception

        print(instance)
        if (instance is None):
            exception = tasks.BaseException()
            exception.code = 404
            exception.message = 'Task not found'
            raise exception

        task = TaskHandler.convertInstance(instance)
        return task

    @staticmethod
    def convertInstance(instance):
        task = tasks.Task()
        task.id = str(instance['_id'])
        task.userId = instance['userId']
        task.name = instance['name']
        task.createdOn = instance['createdOn'].isoformat()
        task.done = instance['done']
        return task

host = Config.getTaskServiceConfig()['host']
port = Config.getTaskServiceConfig()['port']

print('Server is running on %s port %d' % (host, port))
server = make_server(tasks.Tasks,
                    TaskHandler(),
                    host,
                    port)
server.serve()

You will notice that we finally started to use our thrift generated server skeletton in the first few lines. The TaskHandler class will be the one responsible to receive thrift transported data and implement all the methods declared in our service interface inside our thrift file.

With these files you have a very simple service that will handle our Task related data. To start your server you will need first to install MongoDB and then type python server.py in your terminal. As this is a Thrift service, you will need to implement your client. This is an example client that you can use for testing.

import thriftpy
tasks_thrift = thriftpy.load("tasks.thrift", module_name="tasks_thrift")

from thriftpy.rpc import make_client

client = make_client(tasks_thrift.Tasks, '127.0.0.1', 6000)
# Add a task
client.add('userid','name')

list = client.all('userid')
for l in list:
    print l

# Add other functions you want to test
# client.update()

Save as client.py and start it in another terminal session python client.py

Now, having all this stuff installed in our computers is not really practical. With time you end up with a computer install with many dependencies and you don’t really use most of them after a while. This is one of the many benefits of using Docker. Go ahead an install it in your machine.

For the purpose of this tutorial I created a simple Dockerfile that bundles our Task service in a docker container. Here it is:

# For now, we can use onbuild kind of image, this is not advised for production
FROM python:2-onbuild

CMD ["python", "./server.py"]

EXPOSE 6001

This is a simple file useful for development. Don’t use this for production. As you learn more and more about Docker, you will understand that each Docker container should be treated as a remote server, and all the production requirements that comes with it like:

  • Handling failure
  • Logging
  • Monitoring
  • Handling database connections

If you are familiar with Linux servers and deploying applications in production, you already know how to do all this. Explaining these are out of the context of this post and we might explore in a later post.

Save the Dockerfile as Dockerfile in the root of your repo and type docker build -t tasks . to build your docker image.

Now, you will also want to run your database as a docker container as well, and we will run both of them using docker compose

version: '2'

services:
    tasks:
        image: tasks
        ports:
          - 6001
        environment:
          MONGO_HOST: 'mongo'
        depends_on:
          - mongo
        links:
          - mongo
    mongo:
        image: mongo
    
    

Save this file as docker-compose.yml in the root of the repo and now you can launch your service, together with a mongo database, from the root of your repo, using the following command:

docker-compose up

It should be running now in the foreground. If you want it running in the background:

docker-compose up -d

Now, you might notice that your docker container has its own IP address and ports, so you client file will not work if not changed. To easily test your file you can enter your docker container using the following commands

# you will need to list your containers first
docker ps
# grab the name of your tasks container, should be something like tasks_tasks_1
docker exec -it tasks_tasks_1 bash

You will notice that you now are currently inside the container but still you can run your client file from within using

python client.py

This project’s code is available in this github repo

https://github.com/danielfbm/tasks-py

Summary

Altough we didn’t explore much in details, In this post we talked about a few topics:

  • Microservices architecture
  • Thrift
  • Tasks service using MongoDB
  • Docker basics

In the next post we will explore:

  • API gateway
  • REST API
  • User authentication
  • User signup and login
  • Connecting to our Tasks service

Stay tunned.