Creating clients - Part 2
Welcome back! In this tutorial, we'll make the structs and data associated with a client. However, I want to introduce a topic first. See, when we create the server, we need a way of keeping track of clients. We ought to store them in some type of data structure so we can traverse it and find clients for later lookup. How can we do that, you ask?
Normally, if you were efficient, you may want to do some sort of TreeSet
or something like that
to ensure that clients are ordered for quick searches down the road, but for this project where there won't be
more than 5 clients connected, it's perfectly reasonable to go with a standard linked list.
A linked list, for those unfamiliar, is a data structure similar to an array, but different in that there is no such thing as contiguous memory - everything is linked together by pointers. So, instead of having an "index", each element is called a "node". All nodes will point to another node, namely, their successor. So, the head, or the front of the list points to the next element in line. That element points to the next "next" element, and so on and so forth.
This specific kind of linked list is called a singly-linked list - you can kind more about the details online. We are going to create what's called a doubly linked list. The difference is that instead of only having one pointer (namely, to the next node in succession), each node will have two: one for the next node, and one for the previous. This will make removing and inserting clients much easier down the road.
I won't go too into details about the implementation of the data structure itself, since that's effectively a prerequisite. However, I will provide details on the code related to the networking and whatnot. Let's get started!
Firstly, let's create a header file called client.h
and a source called client.c
.
#ifndef CLIENT_H
#define CLIENT_H
#include <stdio.h>
// TODO client.h
#endif // CLIENT_H
#include "client.h"
// TODO client.c
Neat. Now, let's talk about what the header file needs. We need three things: a linked list, a type for the linked list nodes, and a type for the client itself.
The linked list structure will have:
The linked list node structure will have:
And finally, the client itself will have multiple things, and we may need to extend this definition later. For now, though:
We'll expand on those last two in the client struct in a bit, but the first two should be trivial if you understand linked lists (if not, please read about them before proceeding - they are super important!). So, let's code them up!
/**
* Typedef for the list itself. Keeps track of two pointers:
* the head and the rear of the list.
*/
typedef struct client_list_s {
struct client_node_s *head; /* Head (first element) of the list. */
struct client_node_s *tail; /* Tail (last element) of the list. */
size_t size; /* Number of currently-connected clients. */
} client_list_t;
/**
* Node in the linked list. Abstracts the client_s struct and keeps
* two pointers: the previous node and the next node.
*/
struct client_node_s {
struct client_s *client;
struct client_node_s *next;
struct client_node_s *prev;
};
Let's discuss the client type for a minute (haven't coded it yet). Recall that when a client connects to the
server in the connection while loop, we get two things: the name, and the communication file descriptor. These
are the two (obvious) fields that we named earlier, but we also named two others: the read and write
FILE
pointers. If you've ever messed with file IO in C, FILE *
should make sense to
you. If not, however, it's still relatively simple.
There are three "primary" types of file streams: standard input, standard output, and standard error. These,
in C, are listed as stdin, stdout, stderr
respectively. We can mimic the behavior of these file
streams using FILE
pointers with that communication file descriptor. We need to be able to read
and write to the socket, right? So, how can we do that? It's simple, really (I know I keep saying that, but
the concept itself is - the underlying code may be a bit more confusing to some)! We can use that unique
communication file descriptor that we get from accept, and create FILE
pointer objects from them,
and dedicate one to reading, and one to writing!
I hear some of you asking: "Why not just create one FILE
pointer and use that instead?"
Great question! The issue with this is that, when attempting to read from the client using only one file
descriptor (or FILE *
in this context), for instance, there can be no other activity on that file
descriptor, meaning we can't write to the client at all until the read is complete. That's why we dedicate two
duplicate file descriptors for this. Effectively, we create two pipes: one solely for reading, and one
solely for writing for that specific client and only that specific client. Sounds confusing at first, I
know! Let's start off by writing the client struct.
struct client_s {
char *username;
int comm_fd;
FILE *read_fp;
FILE *write_fp;
};
So far so good, eh? Again, the first two fields should be clear - they're retrieved from the
accept
call. The two bottom ones may be a bit fuzzy, but you can think of them as "links". You
can use FILE *
for reading any type of connection (for the most part - this may need
fact-checking...)! You can read not only from sockets like we're doing, but also from files directly (which is
generally what it's used for). Of course, we could just use the read
and
write
or send
and recv
functions, but those are lower level, and using
FILE *
allows us to use high-level and string-friendly functions such as fgets
and
getline
.
Alright, let's get started with client.c
:
#include <string.h>
#include <stdio.h>
#include <stdlib.h>
#include "client.h"
void
client_create( int comm_fd ) {
// Dynamically allocate struct.
struct client_s *client = malloc( sizeof( struct client_s ) );
if ( client == NULL ) {
perror( "Could not allocate memory for client struct.\n" );
exit( EXIT_FAILURE );
}
memset( client, 0, sizeof( struct client_s ) );
// TODO...
}
When we accept
the client request, we're going to create and dynamically allocate a client
struct. From here, we'll need to make a couple of modifications to the header file so server.c
can call this function.
Add the extern void client_create( int comm_fd );
prototype to client.h
, and
include client.h
in server.c
. Those are all the modifications for now.
Next, let's create the pipes for reading and writing to the client. Recall that the file descriptor is
a pipe for this, but because we want to read and write at the same time to and from the client, we need to
make use of the dup()
function. This function returns a duplicated copy of the file descriptor.
Since we need two new links, we can call dup
twice and dedicate one file descriptor for reading,
and one for writing. Note that, to do this, you need to include two new headers: unistd.h
, and
fcntl.h
. So, here's what I mean:
// Create the read and write file descriptors.
client->comm_fd = comm_fd;
int read_fd = dup( client->comm_fd );
int write_fd = dup( client->comm_fd );
// Now attach them to FILE *s.
client->read_fp = fdopen( read_fd, "r" );
client->write_fp = fdopen( write_fd, "w" );
Notice what we're doing here: after we create the two unique file descriptors, we attach them to
FILE *
with the fdopen
function (conveniently named "file-descriptor open"!). You
may have seen a variant of this called fopen
. The difference here is that we're operating with a
file descriptor integer rather than a path location or filename. Again, we do this so we have access to
high-level functions for string processing/parsing later on - it makes our lives much easier!
For now, that's all we'll do. Let's write the client cleanup function, of course!
void
client_destroy( struct client_s *client ) {
// Free the name if it exists.
if ( client->username != NULL ) {
free( client->username );
}
if ( fclose( client->read_fp ) < 0 ) {
perror( "Could not close client's read file ptr.\n" );
exit ( EXIT_FAILURE );
}
if ( fclose( client->write_fp ) < 0 ) {
perror( "Could not close client's write file ptr.\n" );
exit( EXIT_FAILURE );
}
free( client );
}
You may be wondering: what is username
? Remember it's that char *
in the struct! We
haven't done anything with it yet because 1. we're not passing the IP address from the server to the
client_create
function as there's no real need to, and 2. because it's not a static char array,
we have to check to see if it has been malloc
'd, and if so, free it. We'll come back to this
later.
The rest of the code should make sense. We're closing the file pointers and if an error occurs, we just bail out. Finally, we free the memory associated with the client. Make sure to add this function to the header.
Awesome. Since I want to make these parts a bit shorter, we'll stop here for now, and next time we'll actually build the doubly linked list to store clients once they connect. Check the latest part 2 release tag for the latest changes, including the oh-so-slightly updated Makefile.