Friday, October 23, 2020

3.2 Modular Software Design

 When you develop software that delivers various services to end users, it is important that the code is structured in a way that it is readable, easy to maintain, and reliable. Well-maintained code is just as important as the product or service that this code delivers. When you do not follow some good practices while growing the codebase for your services with new features, you may come to a point where adding a new feature is a daunting task for any developer, including your future self.

The figure highlights that you should strive to develop a software system that has a clear communication path between different components. Such systems are easier to reason about and are more sustainable. There are various paths to achieve more sustainable software that you, as a developer, can take when working on a project. The decision on how to design software largely depends on the type of system that you are trying to develop and how this system will be deployed.

Cloud-based applications that are meant to be flexible and scalable, supporting complexities that networks introduce, will use a different approach to software design than, for example, an enterprise application that can be more predictable, with a simpler deployment model. Different architecture and design patterns emerge from the needs to make efficient use of the deployment options and services that applications are offering.

Cloud-based and other applications are often split into a suite of multiple smaller, independently running components or services, all complementing each other to reach a common goal. Often, these applications communicate with lightweight mechanisms such as a Representational State Transfer (REST) application programming interface (API).

This type of architecture is widely known as microservices. With microservices, each of the components can be managed separately. This means that change cycles are not tightly coupled together, which enables developers to introduce changes and deliver each component individually without the need to rebuild and redeploy the entire system. Microservices also enable independent scaling of parts of the system that require more resources, instead of scaling the entire application. The following figure shows an example of microservice architecture with multiple isolated services.

While the benefits of microservices are obvious, it should be clear that applications are not always developed this way. There are cases where using microservices is not the preferred architecture, because you are not building an application that would benefit from all those things mentioned previously. Sometimes, an application that runs as a single logical executable unit is simpler to develop and will suffice for a particular use case. Such application development architectures are referred to as monoliths.

Does this mean that a monolithic application is harder to maintain, with less readable codebase, and not so efficient output? The answer is, it depends. With monolithic architecture, it becomes much easier for your code to become a complicated sequence of lines that is hard to navigate through. Also, because of dependencies, it can be even harder to change parts of it. But that would mean that developers, before microservices came along, had a great amount of trouble with maintaining code, developing new features, and fixing the bugs that this unstructured code brought to surface when the projects grew.

The following figure is an example of scaling a monolithic application. Because a monolithic application is typically deployed as a single executable component, it also needs to be scaled as a single component. Even though this component contains multiple smaller pieces that represent the entire logic of the application, you cannot scale just the critical parts as you would in the microservices architecture.

Different techniques and good practices emerged to cope with such problems and to make the code more elegant and efficient in a monolithic design. These techniques are done in the code, on a much lower level than architectural, not dependent on the type of architecture. Code designs that will be discussed here can be used in monolithic and microservices architecture for maintaining a clean, well-defined, and modular codebase.

Next, you will look at some of the tools and examples of these practices that you can use while developing software.

Functions

Functions are the first line in organizing and achieving a certain level of modularity in your program. With functions, you can make order in your code by dividing it into blocks of reusable chunks that are used to perform a single, related task. Many programming languages come with built-in functions that are always available to programmers. For example, getting the binary representation of a number in Python language can be as simple as calling the bin() function, without having to install an additional library.

>>> bin(42)
'0b101010'

Note

The bin() function was called from Python interactive command line in the example.

Many lines of code can be hidden behind a simple function call like in the previous example. Functions can be built into the programming language itself, or they can come as part of libraries, also referred to as modules, that you import to your program. You as a developer can also define your own functions that belong and purposely serve together to perform a single defined task.

How to define functions varies between different programming languages. Each of them will have their own syntax with custom keywords that denote where functions start and end, but in general, they share similarities. Observe the example of a function in Python.

def is_ipv4_address(ip):
    """ Return True if ipv4 address,
    False if not
    """
    octets = ip.split('.')

    if len(octets) != 4:
        return False
    elif any(not octet.isdigit() for octet in octets):
        return False
    elif any(int(octet) < 0 for octet in octets):
        return False
    elif any(int(octet) > 255 for octet in octets):
        return False

    return True

Functions are defined with special keywords and can take parameters or arguments that can be passed when invoking a function. Arguments are used inside the execution block for parts that need some input for further processing. They are optional and can be skipped in the definition of a function. Inside functions, it is possible to invoke other functions that might complement the task that your function is trying to make.

To stop the function execution, a return statement can be used. Return will exit the function and make sure that the program execution continues from the function caller onward. The return statement can contain a value that is returned to the caller and can be used there for further processing. You can see this in the example of the is_ipv4_address() function, where the Boolean value was returned.

Also common in programming languages is variables. When using functions, you need to be aware of scope where a defined variable is recognized. Observe an example of another function in Python.

datacenter = 'APJ'

def generate_device_name(device, description): 
    """ This function generates a name 
    of a VNF running in RTP data center  
    """  
    datacenter = 'RTP' 
    devices = {'firewall': 'Cisco_ASAv', 'router': 'Cisco_CSR-1000v'} 

    device_type = devices[device] 
    name = f"{device_type}--{description}__{datacenter}"  
     
    return name

A variable datacenter was defined outside and inside the function generate_device_name(). Variables that are defined inside functions have a local scope and are as such not visible from the outside.

>>> print(generate_device_name('firewall', 'mycompany-managed-firewall'))
Cisco_ASAv--mycompany-managed-firewall__RTP

>>> print(datacenter)
APJ

In the example, you can see that the value of the variable datacenter is "APJ" initially. Even though the function call changed the value of the variable to "RTP," it did not change it outside the function. These two variables share the same name but are two different variables with different scope.

Now that you know how to define a function, you might ask yourself how can your code benefit in readability and maintainability when using them. With a larger set of code, it becomes more obvious, but even with smaller examples, there are visible benefits.

You can avoid repetitive code by capturing code intent into functions and making calls to them when their actions are needed. You can call the same functions multiple times with different parameters. However, it would not be very reasonable to repeat the same blocks of code across the file to achieve the same result. This principle is also known as DRY, or "Don't repeat yourself."

While writing functions will improve your code, you can still learn some good practices on how to write them to be even more intuitive and easy on the eyes of a programmer.

Using functions enables you to document the intent of your code in a more concise way. Just by adding a simple comment, what a function return value is or amplification, why something is necessary to have in the code, will make it easier and faster to understand when, how, and why to use the function. Have in mind that comments should not justify poorly written code. If you must explain yourself too much, you should think about writing your code in a different way. Naming your functions properly is important as well. Names should reveal the intent of the function, why it exists, and what it does.

Functions should be short and do one thing. While it is sometimes hard to determine whether a function does one or more things, you should examine your function to see if you can extract another function out of it that has a task on its own, and does not only restate the code but also changes the level of abstraction in your original function.

Defining functions is your first step toward modularity of your code.

Modules

You learned that functions can be used to group code into callable units. They are great on their own, but they make even more sense when they are packaged into modules. With modules, the source code for a certain feature should be separated in some way from the rest of the application code and then used together with the rest of the code in the run time.

Modules are about encapsulating functionality and constraining how different parts of your application interact.

An application should be broken into modules, small enough that a developer can reason about module function and responsibility, while making sure that there are no side effects if the implementation is changed. The following figure is an example of importing two Python modules into a single module, which then references the functions from the two separate modules.

Modules usually contain functions, classes, global variables, and different statements that can be used to initialize a module. They should ideally be developed with no or few dependencies on other modules, but most of the time, they are not completely independent. Modules will invoke functions from other modules, and design decisions in one module must sometimes be known to other modules.

The interface or API toward the model will be defined by the function definitions, its parameters, public variables, usage constraints, and so on.

The idea is that the interface presents a simplified view of the implementation that is hidden behind it. Once these parameters are defined, the application is created by putting the modules together with the rest of the application code.

More or less all modern, popular languages formally support the module concept. The syntax of the languages differs, of course, and is a subject of looking into the documentation, but they all share the idea of providing abstraction for making a large structure of a program easier to understand.

The syntax for a basic module in Python would not be any different from a Python file with function definitions and other statements. The name of the module in the Python language becomes the name of the file, containing the statements without the suffix .py.

As an example, create a module and save it in the file toolbox.py:

def generate_device_name(device, description):
    """ This function generates a name
    of a VNF running in RTP data center
    """
    datacenter = 'RTP'
    devices = {'firewall': 'Cisco_ASAv', 'router': 'Cisco_CSR-1000v'}

    device_type = devices[device]
    name = f"{device_type}--{description}__{datacenter}"

    return name

def is_ipv4_address(ip):
    """ Return True if ipv4 address,
    False if not
    """
    octets = ip.split('.')

    if len(octets) != 4:
        return False
    elif any(not octet.isdigit() for octet in octets):
        return False
    elif any(int(octet) < 0 for octet in octets):
        return False
    elif any(int(octet) > 255 for octet in octets):
        return False

    return True

This module can then be imported to your program code or other modules. For demonstration, use an interactive Python shell to import and use this module, as the following figure showcases.

In Python, modules are essentially scripts that also can be executed that way. Running toolbox.py will not produce any output. There is not any execution point that would tell the Python interpreter what to do when running this module directly. But you can change that.

The difference between importing a module to other programs and running it directly is that in the latter, a special system variable __name__ is set to have the value "__main__".

To see this in action, modify the toolbox module and make it generate a random device name when being executed directly.

import random
import string

<... output omitted ...>

if __name__ == "__main__":
    characters = string.ascii_lowercase
    description = ''.join(random.choice(characters) for i in range(10))
    device = ['router', 'firewall'][random.randrange(0, 2)]
    print(generate_device_name(device, description))

The result of calling this module directly will be the following:

~ python toolbox.py 
Cisco_CSR-1000v--brpdtebcdp__RTP
~ python toolbox.py
Cisco_ASAv--lsbfspvrxq__RTP

When importing modules to your application code, the import itself should not make any changes or execute any actions automatically. The best practice is that you do not put any statements inside the module that would be carried out on import. Any statement that you would want to have in your module belongs inside if __name__ == "__main__" block.

The major advantage of using modules in software development is that it allows one module to be developed with little knowledge of the implementation in another module.

Modules can be reassembled and replaced without reassembly of the entire system. They are very valuable in the production of large pieces of code. Development should be shortened if separate groups work on each module with little need for communication. If the module can be reused on other projects, it also makes sense that it gets its own source code repository.

Modules bring flexibility because one model can be changed entirely without affecting others. Essentially, the design of the whole system can be better understood because of modular structure.

Classes and Methods

With functions and modules, you have learned some great techniques to improve modularity of your code. There can be even higher levels of code organization that come in a form of classes.

Class is a construct used in an object-oriented programming (OOP) language. OOP is a method of programming that introduces objects. Objects are basically records or instances of code that allow you to carry data with them and execute defined actions on them. It represents a blueprint of what an object looks like, and how it behaves is defined within a class.

Class is a formal description of an object that you want to create. It will contain parameters for holding data and methods that will enable interaction with the object and execution of defined actions. Classes can also inherit data from other classes. Class hierarchies are made to simplify the code by eliminating duplication.

Every language has its own way of implementing classes and OOP. Python is special in this manner because it was created according to the principle "first-class everything." Essentially, this principle means that everything is a class in Python. In this overview of classes and OOP, you will skip many details and go just through the essentials of writing classes. For more in-depth information about OOP, refer to the documentation of your language of choice.

It is time to build your first class in Python and then create object instances from it. Often classes reside in their own files, so you might create device.py file for the Device class.

class Device:
    def __init__(self):
        print('Device object created!')

Creating a new device object results in printing the message that it is being created. You can create as many objects as you want; each will be a new isolated instance of the device object with its own copies of data.

<... output omitted ...>
>>> from device import Device
>>> dev1 = Device()
Device object created!
>>> dev2 = Device()
Device object created!

The method __init__ is a special initialization method that is, if defined, called on every object creation. This method is generally known as a constructor and is usually used for initialization of object data when creating a new object.

The variable self represents the instance of the object itself and is used for accessing the data and methods of an object. In some other languages, the keyword "this" is used instead.

Besides the __init__ method, there are many more built-in methods that you can define inside your class. In the Python language, these are known as magic methods. With them, you can control how objects behave when you interact with them. For controlling how an object is displayed when you print it, for example, define the __str__ magic method. Using the __lt____gt__, and __eq__ magic methods, you can write custom sorting procedure, or by defining the __add__ method, specify how the addition of two objects using the "+" operator works.

Objects usually carry some data with them, so add an option to give the device object a hostname and a message of the day (motd).

class Device:
    def __init__(self, hostname): 
        self.hostname = hostname 
        self.motd = None

When you create an object, you can pass arguments that are based on the signature of the class. The device class now accepts one parameter, which is hostname. The variable motd can be changed after the object is created. After the object initialization, hostname can also be changed to something else.

>>> device1 = Device('test_device1')
>>> device2 = Device('test_device2')                                                                                   
>>> device2.motd = "Unauthorized access is prohibited!"
>>> print(f"device1 name:{device1.hostname} motd:{device1.motd}")
device1 name:test_device1 motd:None
>>> print(f"device2 name:{device2.hostname} motd:{device2.motd}")
device2 name:test_device2 motd:Unauthorized access is prohibited!

Besides storing values to objects, classes should also provide possibility to perform domain-specific actions. To develop them, you will create code constructs called methods.

Methods in Python are very similar to functions that you already learned about. The difference is that methods are part of classes and they need to define the self parameter. This parameter will be used inside methods to access all data of the current object instance and other methods if necessary.

Add a show() method to the device class, which will enable you to print the current configuration of a device object.

class Device: 
    def __init__(self, hostname): 
        self.hostname = hostname 
        self.motd = None 
     
    def show(self, p = None): 
        if not p: 
            return str(vars(self)) 
        elif hasattr(self, p): 
            return (getattr(self, p)) 
        else: 
            return f">> no attribute '{p}'"

Create a couple of objects and try to print the configuration using the new show() method.

<... output omitted ...>
>>> dev1 = Device("dev1")
>>> dev2 = Device("dev2")
>>> dev2.motd = 'Welcome!'
>>> print(dev1.show())
{'hostname': 'dev1', 'motd': None}
>>> print(dev2.show())
{'hostname': 'dev2','motd': Welcome!'}
>>> print(dev2.show('motd'))
Welcome!
>>> print(dev2.show('interface'))
no attribute 'interface'

Your next step will be to investigate class inheritance. Generally, every object-oriented language supports inheritance, a mechanism for deriving new classes from existing classes.

Inheritance allows you to create new child classes while inheriting all the parameters and methods from the so-called parent class. This can improve code reusability even further.

Extend the previous example with a router class, which will be inherited from the device class. You will also define an Interface class with some properties and use it in the device object for initializing the interface variable.

Note

You will not add any custom implementation to the Router object yet, so you can use the pass keyword in Python, which will enable you to leave the implementation empty.

Create a router object. Even though it clearly does not have anything defined in it yet, it inherited all from the device class.

>>> dev_name = generate_device_name("router", "test_env")
>>> router1 = Router(dev_name)
>>> router1.interface = Interface("eth0", "1.1.1.1")
>>> print(router1.show())
{'hostname': 'Cisco_CSR-1000v--test_env__RTP', 'motd': None, 
'interface': {'name': 'eth0', '_address': '1.1.1.1', 'state': 'Down'}}
>>> router1.interface.address = "in.domain.com"
<... output omitted ...>
ValueError: >> in.domain.com is not a valid ipv4 address

The router object originates from the parent class device but can be now extended further to support parameters and methods that a router might require for its operations.

These are some of the constructs that are used in programming to improve readability, modularity, and efficiency of your codebase. Out of these constructs, you will be able to build and understand other, more sophisticated design patterns.

Content Review Question

Which code construct can be used to efficiently split the workload between multiple groups of software developers?

No comments:

Post a Comment