Writing a Portable Transport-Independent Web Server

John examines the Transport Layer Interface (TLI) for building a Web server. TLI is portable, allowing you to write server code that is 98 percent transport independent.


May 01, 1996
URL:http://www.drdobbs.com/web-development/writing-a-portable-transport-independent/184409882

Figure 2

Figure 2

MAY96: Writing a Portable Transport-Independent Web Server

Writing a Portable Transport-Independent Web Server

TLI makes HTTP transport independent

John Calcote

John is an engineer on Novell's Directory Services team. He can be contacted at [email protected].


Like all standards, HTTP is being asked to do things its designers never considered--implementing reliable user authentication for secure business transactions over the Internet, displaying information in newly created formats, and the like. Consequently, new versions of the standard are under development in an attempt to solve emerging problems without losing backward compatibility.

One of the more solvable limitations of most contemporary HTTP servers (those based on the NCSA or CERN implementations) is that they are implemented on top of Berkeley Sockets, and thus closely tied to TCP/IP. To address this problem in a recent project, I chose to implement my Web server on top of the Transport Layer Interface (TLI). Because TLI is not a transport service provider (TSP), but rather an abstraction layer over various TSP's, my Web server will still communicate with existing Sockets-based browsers, as long as I choose to listen on a TCP socket for Web requests. As it turns out, TLI source code is 98 percent transport independent (even transport unaware). I demonstrate this fact in the accompanying source code (available electronically; see "Availability," page 3) by creating two listen threads, one for TCP/IP and one for Novell's SPX II. Both types of requests are mapped to TLI file handles, and are serviced by the same Web server code path.

The OSI Communications Model

TLI was developed by AT&T in an effort to solve some of the problems associated with Berkeley Sockets and to stratify some of the many inconsistent communication interfaces in the UNIX kernel at the time. In the first place, BSD Sockets are actually part of the operating-system kernel. The BSD Sockets functions are system calls. The left side of Figure 1 shows the seven layers of the OSI communications model. The right side indicates the dividing line between those portions of the model that are implemented in the operating-system kernel and those portions implemented in application space. BSD Sockets are implemented at the network layer of the OSI model. Because of its design, it is "hard-wired" into the TCP/IP and UDP/IP protocols.

TLI is implemented at the OSI model's transport layer. These functions actually become a part of the network application. TLI is implemented as a library of functions that are either linked into the application statically at compile time or dynamically at run time in the form of a loadable DLL. In either case, the code for TLI runs in the same address space as the application, rather than in kernel space. The implications of this difference are subtle. First, since TLI is not part of the kernel, some method must be provided for it to communicate with system resources and networking hardware. Since portability was a major goal in TLI's design, the UNIX STREAMS interface was chosen as a system-level interface because of its multipurpose, generic functionality. Most ports of TLI are implemented on top of a port of the STREAMS subsystem (although this is not a requirement), which provides an efficient, integrated, full-duplex pipeline to kernel device drivers.

As Figure 2 shows, there are actually three components to TLI:

TLI is implemented as a particular state machine. A TLI end point at any given time depends on a number of factors, including the initialization stage of the end point and asynchronous events that have been received by TLI on the end point.

Local Management

Local management of TLI data structures is straightforward. The t_open() function initializes and returns an OS file descriptor based on a particular Transport Service Provider (TSP), such as TCP or Novell's SPX II. How you specify the TSP is implementation dependent, but generally, it takes the form of a string containing a UNIX-like file system path to the desired device; for example /dev/tcp or /dev/nspx.

Once a file descriptor has been opened and assigned to a TSP, it may be bound to a specific or arbitrary network address using t_bind(). This phase creates one of the few places in TLI application code that is transport specific, however, the transport-specific information is concise enough to be passed into a generic routine as a parameter. For instance, an OpenEndPoint() function might take a string signifying the desired transport provider. The t_bind structure contains two fields, a netbuf structure and an integer, qlen, used to specify the maximum number of simultaneous, outstanding indications a server is willing to handle; see Example 1.

The netbuf structure is generic enough to handle any sort of transport-specific network address or data imaginable. It is basically a sized buffer of variable length. The maxlen field tells TLI how large the buffer actually is (allocation size), while the len field tells the application how much data was returned by TLI.

Once bound to a network address, a TLI file descriptor may be used by a client to request a connection with t_connect(), or it may be used by a server to listen for a connect indication with t_listen(). When t_listen() returns, signifying that a connect indication has arrived, the server may accept the indication on the server socket or on another opened and bound end point. If the server accepts on the server socket, then no other indications may be serviced until the session has been terminated, at which point the server may reopen the socket and return to a blocked state by calling t_listen(). If the server accepts on another end point (as is typical), then it may immediately return to t_listen() on the server socket after calling t_accept() and passing the new end point to a responder thread or process.

The t_accept() function takes a listening-file descriptor, a receiving-file descriptor, and the t_call structure used in t_listen(). This structure contains three netbuf structures and an integer value representing the indication sequence number; see Example 2. For most situations the opt and udata buffers are not used. The addr buffer contains the address of the calling client, in a transport-specific format, and the sequence value specifies which of the incoming indications this call is associated with.

The trivial case of qlen is 1. If qlen is set to 1 when a server port is bound to a TLI end point, then all attempts to connect to the server on that descriptor while it is processing a previously received connect indication are rejected by the underlying TSP. This case is far easier to code, but makes for a rather nonrobust server. If qlen is greater than 1, then outstanding connect indications must be queued up by the server while it is processing the current indication. The difficulty in coding servers using a qlen greater than 1 lies in the fact that asynchronous incoming indications must be gathered off the wire before t_accept() will work. The server is notified of such an asynchronous event by t_accept() failing with an error code of TLOOK. At this point, the server needs to call t_look() to retrieve the event, and then process it according to the nature of the event.

Two possible events may occur during t_accept(). The T_LISTEN event indicates that another connect indication has arrived, while the T_DISCONNECT event means that someone previously queued up for connect desires to cancel his connect indication. If the event is T_LISTEN, you call t_listen() with a new call structure and queue it up for later processing. If the event is T_DISCONNECT, you call t_rcvdis() to retrieve the sequence number of the disconnecting client, scan the indication queue for the t_call block containing this sequence number, and then remove it from the waiting queue. You then return to t_accept() to try accepting the original connect indication again. Asynchronous events could keep you from accepting the current indication until you have filled your outstanding indication queue with qlen connect indications.

When you have finally accepted the indication (establishing the connection on a new end point), you either fork a new process to handle the client's request, or begin a new thread in a multithreaded environment, passing the client file descriptor.

The server must handle all outstanding connect indications before returning to t_listen() to wait for another incoming indication. It's quite apparent that on a busy day the server may never get back to blocking on t_listen()!

TLI File Descriptors

As in Berkeley Sockets, the end points created by TLI are operating-system file descriptors. TLI opens such a descriptor by calling the STREAMS open() function, and pushing the TIMOD STREAMS module onto the stream for this descriptor. This is where the real fun begins. The request-handler thread cannot tell the difference between a TLI file descriptor and a standard file descriptor, because all file descriptors in the UNIX environment are STREAMS based. This means that the standard C library function fdopen() may be called on the descriptor to open an I/O stream buffer for the client file handle. (Note that these standard C library functions are not at all related to the UNIX STREAMS interface, which is actually based on POSIX.) Once this is done, all of the standard file I/O functions may be used on the descriptor. Input is gathered from the client using fgets(), fgetc(), or fread(), and data may be sent to the client using fprintf(), fputs(), fputc(), or fwrite().

Furthermore, in the UNIX heavy-weight process environment, it is common for the server to redirect the child process's stdio to the client's network file descriptor before forking, by using the POSIX dup2() function to duplicate the client descriptor on the system's stdin and stdout file descriptors. This trick allows the responder to retrieve all client input using the even simpler stdio interface provided in gets() and printf(). It makes for some really interesting network application code to see all client I/O being handled with stdio functions.

When a session is complete, the server simply calls fclose() on the file pointer to terminate the session. If stdio is redirected, as in the UNIX server environment, the startup code of the responder process automatically calls fclose() on the stdio file descriptors when the main function returns. The HTTP protocol depends on orderly release of the connection for correct operation. Beware that some ports of TLI do not correctly handle orderly release of the connection when buffered I/O is used in this manner. Since TLI is an open specification, not all TLI ports implement the full functionality of the specification. The specification even documents those portions that are optional. The way to handle this deficiency is to use the t_sndrel() and t_rcvrel() functions on the embedded file descriptor before calling fclose() on the file pointer.

Essentially, when the server is finished sending data, it calls t_sndrel(), and then blocks on a call to t_rcvrel() while waiting for the client to indicate that it has finished reading this data. The server will wake up when the client either calls t_sndrel(), or aborts the connection with t_close(). This handshake ensures that the client has received and processed all the data sent by the server before the connection is torn down. By definition, an abortive release on a TLI file descriptor will truncate all data not explicitly received by the client.

The Hypertext Transfer Protocol

HTTP is a line-oriented, ASCII protocol. The most common method of handling I/O in the responder is to call fgets() to retrieve the client's request strings, and then send data back using fprintf() or fputs().

There are several (ever-changing) types of HTTP requests, including GET, HEAD, PUT, POST, LINK, UNLINK, and DELETE. Of these, GET is by far the most popular, followed by POST. Since 95 percent of all requests are GET requests, I'll limit my discussion to it.

An HTTP GET request is made up of a single request line, followed by several MIME header fields, and then a blank line. If, as in the case of POST or PUT, the request contains any data, then one of the MIME header fields should be "content-length." The value in this field will specify how many bytes of data are to follow the blank line. Since GET requests don't contain data, this is not a concern for us. You simply read until you hit a blank line, and then you can start sending your response. In reality, since these TLI sessions are full duplex, you can send data before you have retrieved it all. This means that you could read the request line, and ignore any MIME headers sent by the client. Some of the most-common MIME headers are listed in Table 1. An HTTP response is formatted in a manner similar to a request. The first line contains a signature string, a status code, and a human-readable form of the status. A valid "OK" response might look like "HTTP/1.0 200 Here she comes!".

After the status-code line, the server sends zero or more MIME headers indicating the type and length of the file being sent. Again, a blank line follows the header, and then the file data itself. At the very least, a server should send a content-type header field. If a content-length header field is sent, the client can display a progress bar to the user as the transfer is taking place.

Writing an entire Web server is a topic for more than one article, but the front-end server code is a large portion of any server (and a daunting one at that). The sample code in the source accompanying this article should give you a good headstart if you're planning on using TLI for a transport interface. Like all programmer's interfaces, there is a learning curve, but TLI has good documentation and was designed to be easy to learn and easy to use.

References

Graham, Ian S. The HTML Sourcebook. New York, NY: John Wiley & Sons, 1995.

NLM Transport Interfaces (included with the Network C for NLMs Software Developer's Kit). Novell Inc.: September 1991.

Rago, Stephen A. Unix System V Network Programming. Reading, MA: Addison-Wesley, 1993.

The Novell Software Developer's Kit CD, volume 5. Novell Inc.: 1995.

Figure 1: OSI communications model.

Figure 2: TLI component block diagram.

Example 1: The t_bind structure contains two fields, a netbuf structure and an integer, glen.

struct netbuf {
    unsigned int maxlen;
    unsigned int len;
    char *buf;
};
struct t_bind {
    struct netbuf addr;
    unsigned int qlen;
};

Example 2: The t_call structure used in t_listen().

struct t_call {
    struct netbuf addr;
    struct netbuf opt;
    struct netbuf udata;
    int sequence;
};

Table 1: Common HTTP response headers.

Accept:
Accept-encoding:
Authorization:
Content-Length: 

(POST or PUT requests only)
Content-Type: 

(POST or PUT requests only)
From:
If-Modified-Since:
Pragma:
Referrer:
User-Agent:

Content-Encoding:
Content-length:
Content-Transfer-Encoding:
Content-type:
Date:
Expires:
Last-modified:
MIME-version:
Server:
Title:
WWW-Authenticate:
WWW-Link:

Terms of Service | Privacy Statement | Copyright © 2024 UBM Tech, All rights reserved.