Shell Objects
Christopher A. Jones
Maintaining UNIX systems often involves programming. Unlike programming for application development, a compiled language usually isn't appropriate to perform routine system tasks. The usual tool for such tasks is a scripting language such as the Korn shell, which can help automate the countless configurations, updates, file distributions, and statistic gathering that's required in system administration. It is rarely the case that a UNIX machine is alone, and as a result, network-wide shell programming definitely has its complications. Every flavor of UNIX varies slightly in its architecture. While most features are present, file paths and command syntax often vary from machine to machine. These variations complicate network-wide tasks such as copying a network-wide version of inetd.conf to multiple systems or tracking stats from syslog.
Although shell programming can be powerful, it can also seem inefficient if you make frequent calls to standard UNIX tools, or have to write several different versions of your script for different systems on your network. It's easy to picture shell scripts as a simple string of UNIX commands, and to many, that is solely what they're used for, but shell scripts can be much more. If you've tried to perform a network-wide system change, or gather statistics, you've probably started with a list of host machines, and then executed a remsh command to each one, and collected your data or issued your command. Upon retrieving the information or evidence of the change, you may run awk or grep through the resulting file to pull out the information needed. Although this works, it can involve rewriting the script several times, or weighing it down with case statements to meet the differences of the machines on your network. Another drawback is that you always start from scratch every time you want to do something a little bit different.
Borrowing a few simple object-oriented programming (OOP) principles and applying them to shell scripting can make the job easier. Collecting certain system exceptions, behaviors, and specifics into classes and then providing a convenient interface allows you to spend your time scripting code to handle your network objects, rather than re-running the same long, procedural script over your network. By defining a shell script class to represent a basic UNIX system, you can develop an interface to perform various tasks on this object. Code for one class can later be reused in classes derived from it that represent specific qualities of various systems on your network, all the while keeping the same interfaces.
In this article, I present some features of OOP that can be implemented in the Korn shell. Not all features of C++ or object-oriented programming are present. C++ does its job perfectly well, and my goal is not to translate all of the features of C++ to the Korn shell. Rather, some of the main focal points of OOP are implemented: objects, methods, inheritance, and polymorphism are present, but many other C++ features are not. There are no access specifiers, and all data members and functions are "public", although if desired, this too, can be implemented. You won't see the virtual specifier, but polymorphism is easily achieved- you can override any function in a derived class. My main concern was utilizing the power of classes, inheritance, and encapsulation to deal with heterogeneous UNIX servers on a network. Whether or not you wish to implement an object-oriented system with these classes is also up to you. Many system tasks can still be carried out with shell objects in a procedural method while still taking advantage of the encapsulation provided by classes. If circumstances seem appropriate, an object-oriented system is easy to design by extending the features of these classes. The Korn shell objects shown below can be run from the command line or used in other ways, such as procedural scripts. Extending these classes, and implementing a small but functional object-oriented network management system is entirely possible.
I present a Korn shell base class: UnixSystem, which is a shell object representing a UNIX network host. Also, I present three derived classes, LinuxSystem, HPSystem, and SunSystem, which inherit the base class code and redefine some system-specific functions. Also, an ObjectList class is presented, which functions as a simple object stack to aid in executing repetitive commands on a collection of host objects. All of these are written entirely in the Korn shell. The example application I build with these objects is quite simple, but once the methods are understood, it's easy to imagine using shell objects to build much more complex maintenance and performance monitoring systems based on the specifics of your domain. The classes can be extended by adding functions for different applications that run on different hosts, or methods to monitor users on heavily used machines, and the code can be re-used. Also, by keeping a common interface among classes, more general scripts can be written which will operate on all UnixSystem and derived objects in general, but which will accomplish different things depending on what hosts they encounter. This will allow you to concentrate on coding with your network hosts in mind, rather than grappling with syntactical particulars specific to each.
OOP Features in the Korn Shell As shown in the examples, a few key features of OOP, including classes, inheritance, encapsulation, and polymorphism, have been extended from the Korn shell. I'll create functions and variables that "belong" to a certain class. The UnixSystem class created below consists of function definitions, which create and define several variables. These functions and variables are specific, and only apply to the object created with the class. The variables can provide a wide range of information to be evaluated, and will remain in memory until the shell exits and the objects are "destroyed", or the object is redefined. To create an object, you specify the name of the class and the Object ID you want to represent the class. If you created an object of UnixSystem with host Homer as a parameter, you could then reference all the functions of UnixSystem through Homer, and these functions would operate upon your object. In C++, you would call Homer.GetUptime(), or similar, to retrieve the systems uptime. The syntax used in the Korn shell is a bit different, but works the same way: Homer_GetUptime.
An application developer may use a class in his code, but he or she only needs to know the functions and what they accomplish. How the functions are implemented is not necessarily important. Thus, you can change how your functions are implemented, but as long as you keep the interface, the code using that class won't have to change. For example, suppose you write a shell class that tracks where important system files are kept on network hosts and provides functions to replace or edit those files when necessary. You can create a simple method interface for this class: TransferNewFile() and reuse it many times as it cheerfully copies a system file to the appropriate host. Eventually, however, you may want to keep track of all system files on several different revision levels of UNIX. You will need the class to know that syslog is under /usr/adm on one host and /var/adm on another. By keeping the specific information "behind" the interface, you can add different network hosts to your class, and always call TransferNewFile() or the like from your shell script. The script would never have to change, and you could add information on files for every flavor of UNIX on your network.
Inheritance and polymorphism are powerful features of OOP. Inheritance is easy to implement in the Korn shell. The classes created here will all inherit from the base class UnixSystem and provide extra, system-specific functions, or rewrite existing ones that need to be changed. The class HPSystem shown later in this article "inherits" UnixSystem by creating an instance of UnixSystem for the given object ID and then extending the class with more functions and data members. This is accomplished by executing the base class into the current shell with a dot. This provides for flexible use of the functions in objects that behave differently. For example, most UNIX systems provide the executable xterm. The UnixSystem class provides a function called GetWindow() that returns an XTerm for the object with which it belongs. You can redefine this function in your derived classes to bring you a system-specific window, like an HPterm on HP-UX. If the remote object doesn't provide Windows, you can redefine the function to launch a local window and then rlogin to the remote machine. You can call GetWindow() for every object in your script without knowing what kind of Window utility it's actually executing on the remote machine.
The Syntax of Object-Oriented Korn Shell Programming Implementing OOP techniques in the Korn shell involves some strange syntax. Basically, the script, or even command-line shell, calls the class into the current shell by using the dot, as in:
. UnixSystem Marge
where Marge is the network host. The dot is very important, it allows the functions of the object to be available in the current working shell. The second parameter, Marge, is the object ID. If you're familiar with C++, you may be wondering what happened to the constructor parameters. Parameters can easily be added to any constructor in the Korn shell, but for simplicity, the UnixSystem object is initialized at creation. A UnixSystem object is one thing: a network host. UnixSystem creates function definitions in the current shell, as well as some data members. All of these functions and data members are prefixed with the object's name, for example:
Marge_GetDiskUsage
This function sets Marge's data members accordingly where:
Marge_DEVICE[x]
Marge_MOUNTP[x]
Marge_PUSED[x]
are arrays where [x] is how many I/O devices Marge is configured with. Another function, ShowDiskUsage(), will write the results to standard output. Since our concern involving disks is usually filesystem space, GetDiskUsage() automatically flags any device with a percentage over 90 with "!" before the device name. You can specify parameters by passing arguments like: GetDiskUsage() 75, and any disk more than 75% utilized will be flagged. When instantiating objects, you can initialize as many objects as you'd like, and all will carry their own data and functions. Other UnixSystem functions include GetUsers(), which returns the number of users on the system, and GetLoad(), which prints the load average via uptime and shows in three figures the number of processes active in the past 1, 5, and 15 minutes. GetWindow() takes a command as a parameter and executes it in a window for you. If no argument is specified, the default command is /bin/ksh. Of course, your DISPLAY environment variable must be set correctly.
One note about this implementation: the UnixSystem class is designed to retrieve stats on various network hosts. As a result, it uses the remsh or rsh (depending on the machine) to access the remote hosts. It does require the authentication involved in using remsh, (a .rhosts in your home directory, and the remote machine must be able to resolve your hostname). Also, if you plan to use these objects, you may want to make a directory devoted to them in your home directory and add this directory to your path, as in PATH=$PATH:$HOME/object_dir. If you don't have them in your path, you must "path-out" the class name in your script. So, if you run your script from the same directory as the class, your object creation would be:
. ./UnixSystem Marge
in your script. If you're not on a network of machines, or you can't issue remote commands, you can create objects on your local machine.
The code for creating a object into the current shell involves heavy use of the eval statement and escaping resulting variables so they are not evaluated at the same time as the object identifier. The goal is to create functions and variables prefixed with a parameter that you pass. The syntactical details for creating your own classes will be presented later. For now, the code to the UnixSystem object is presented in Listing 1.
The UnixSystem object is intended for, and tested on, Linux, HP-UX, and SunOS. Objects can be created on any of these platforms, on AIX, and I'm sure on others. The remote objects that are targeted can be any of these and more. Although GetDiskUsage(), works fine on the intended hosts, different versions of UNIX will cause it to behave slightly differently. It works by issuing the appropriate bdf or df -k and then calls itself again with the output of the command as new arguments. It then shifts these different arguments into place and assigns them to variables. In an AIXSystem class for example, you could redefine, or override GetDiskUsage() to function correctly by shifting the appropriate number of parameters.
There is heavy use of the escape character "\" and the eval statement. Derived from UnixSystem, to implement more specific object details, are HPSystem, LinuxSystem, and SunSystem, as presented in Listings 2 - 4.
The derived classes only extend, or override necessary functions, and inherit all of the original code from class UnixSystem. These classes can be used directly from the command line to see how they work. If you can remsh into your own machine, try the following:
. UnixSystem localhost
localhost_GetDiskUsage
localhost_ShowDiskUsage
You should get back something similar to a bdf, or df -k statement. Now, to see what's going on in the background take a look at the environment variables. On many UNIX systems, you can just issue the set command to see all the current identifiers. There are now arrays that contain all of your disk information, devices, and the amounts used. All of these variables only belong to the object for which they were created and can be easily manipulated by your shell scripts. They will be persistent until the shell exits, the objects are destroyed, or you call GetDiskUsage() and they are redefined.
If you plan to call functions for a large list of objects, it's convenient to use an ObjectList class to maintain and call the functions. The ObjectList class is presented in Listing 5.
Methods are provided to insert an object at the top of the list, delete the object at the top of the list, and retrieve any object within the list. The power of this class is in the _Execute() function. The motivation for creating this class was mainly to manage large lists of objects more easily. It provides some methods for controlling and updating the contents of the list, and cleans up the syntax of calling functions with a variable instead of the actual object name. In the Korn shell, when you use an identifier within other text, it's necessary to put {} around the identifier as in: ${file}_txt to separate the variable from the rest of the text. For example, assume that file="papers", and the variable $papers_txt will be used repeatedly to represent different files. If you have a variable within a variable, it's necessary to evaluate the expression and expand the inside variable first, before letting the shell evaluate the rest of the expression. So, if ${file}_txt were a variable representing a variable, it would have to be presented to the shell as: eval \$${file}_txt. This evaluates first to $papers_txt, where $papers_txt can be one of a long list of files. Needless to say, this can make programming with embedded variables quite confusing. The ObjectList class can help with managing the objects and their data members.
The small demonstration script uses all of these classes to present you with disk devices that are nearing capacity and systems that have a high load average, and to provide some detailed information. While this application is simple, it's easy to see the power of object-orientation even in shell scripting. A heterogeneous network of hosts, each with their own specifics, lends itself well to an object-oriented design model. These examples, written merely to illustrate the methods of the classes, test disk usage on five imaginary hosts. You can change the machine names to systems on your network, or list your local machine five times and maybe give a different "percent used" parameter to GetDiskUsage(). The demo program is presented in Listing 6 and just steps through the function calls to illustrate their use.
It would not take very much effort to create an object-oriented system to utilize these classes and perform system monitoring on a network. By extending a few methods to let each object monitor itself, and creating a method to post a response to the running system, an automated monitoring system would be in place. The object classes could easily be improved, and could even be designed to be "re-initialized" without interrupting the running monitoring system. Two methods, _Monitor() and _ListenForActivity(), could be the basis for developing such a system interaction. With the methods we already have, these new activities could be implemented. First, the _Monitor() function could periodically analyze system statistics through GetDiskUsage() and GetLoad() and more specific, system-relevant methods created for your object, such as an application's status or network activity. When _Monitor() detected a problem, it could write to a log, or send a message across the network if you decided to have your objects distributed onto the machines that they represent. _ListenForActivity() would be watching the log file or waiting for the network message, and upon detection, could alert the administrator with email, trigger paging software, or better yet, attempt to solve the problem itself.
How to Create Korn Shell Classes The method for creating a Korn shell class is similar to what you'd have in C++. Basically, you decide what functions or methods your object will need to operate, and what data members it needs to represent itself. After that, you can just write a plain shell script with those functions which operate upon those data members. The only tricky part is creating your shell script to accept a parameter, then prefixing every function and data member in the body of the script with this parameter. When writing derived classes, you also need to consider that you will be loading the base class into the current shell process, so if you reuse any variables, they will carry along the value of the base class or be redefined by your shell script. So, it's probably a good idea not to reuse variable names in your derived classes. For the simplest case, let's create a class representing a UNIX machine. It will have one operation, Uptime, and one data member, UpValue, which will contain the systems uptime. If you were to hard-code this as a function, it would be:
#!/bin/ksh
#################
GetUptime() {
UpValue=$(uptime)
}
If you wanted this to belong to an object, you would need to prefix the function and the resulting variable with the objects identifier:
#!/bin/ksh
###############
ObjectId=$1 ## take the first parameter as the Object Identifier
eval " ${ObjectId}_GetUptime () {
${ObjectId}_UpValue=\$(uptime)
}"
This would create a function and variable with your parameter name as a prefix. The syntax gets messy when you need to have other variables and quotation marks that do not get evaluated along with ${ObjectId}. Remember that if you really want a changing variable, literally prefixed with a $, then you must escape it with a "\" or it will be evaluated along with the object identifier at creation time.
Going Further Although the primary interest in creating these methods was to do some simple object operations in shell scripting, almost any feature of object-oriented programming could be implemented in the Korn shell. Access specifiers, both public and private, could be created by prefixing each object with the ObjectId and by the /bin/ksh built-in identifier $RANDOM, which is a randomly generated integer. This value could be saved at creation, and only the object's access functions would know it, and thus, be able to change the value of a private data member. Your scripts wouldn't be able to modify private members without an access function.
Object-oriented programming is a powerful design philosophy and is far outside the scope of this article. A network of UNIX servers each with their own specifics, functions, quirks, and whatnot, is begging for an object-oriented solution. And although there is a plethora of network and system management packages to run your network, in most complex environments, a team of system administrators is still very much needed. Utilizing an object-oriented approach to system management and maintenance will make administration easier and more efficient, even in your hard-working shell scripts.
About the Author
Christopher Jones is an enthusiastic programmer and musician. He lives in Seattle with his wife, Barb, and their 10-month-old son, Miles. For more on OOSHP visit his home site: http://www.blarg.net/~chrisj.
|