网络编程:开发UDP客户端

上一小节,我们学习了 UDP 服务端编程技术。现在我们趁热打铁,继续研究 UDP 客户端编程。

同样,我们以 Linux 为例,用 C 语言来开发一个同款的时间查询客户端,通过实战摸索 UDP 客户端开发技巧。

UDP客户端编程步骤

创建套接字

UDP 客户端想要通信,同样需要先创建一个套接字,方法与服务端一样:

1
2
3
4
5
int s = socket(PF_INET, SOCK_DGRAM, 0);
if (s == -1) {
    perror("Failed to create socket");
    return -1;
}

简而言之,我们创建了一个 family=PF_INETtype=SOCK_DGRAM 的套接字,即 UDP 套接字。这段代码上一小节已经介绍过,这里不再赘述。

发送请求报文

套接字创建完毕后,我们即可用它来发请求数据,不用绑定本地地址和端口。

同样,接收方的地址端口信息,需要通过 sockaddr_in 结构体传给 sendto 系统调用:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
// struct for storing server address
struct sockaddr_in server_addr;
bzero(&server_addr, sizeof(server_addr));

server_addr.sin_family = AF_INET;
server_addr.sin_port = htons(arguments->server_port);

// parse ip address
if (inet_aton(arguments->server_ip, &server_addr.sin_addr) == 0) {
    fprintf(stderr, "Invalid IP: %s\n", arguments->server_ip);
    return -1;
}

注意到,我们在填充端口号前,必须先将它的字节序转换成网络序。另外,由于用户指定的服务端 IP 是字符串形式(点分十进制),我们调用 inet_aton 库函数将它转换成二进制形式。

接着,我们构造请求报文,然后调用 sendto 将请求发送出去:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
// build request message
int format_bytes = stpncpy(request.format, arguments->time_format, MAX_FORMAT_SIZE-1) - request.format + 1;
request.bytes = htonl(format_bytes);
int request_len = sizeof(request.bytes) + format_bytes;

// send request
if (sendto(s, &request, request_len, 0, (struct sockaddr *)&server_addr, sizeof(server_addr)) == -1) {
    perror("Failed to send request");
    return -1;
}

套接字刚创建时,不是还没绑定本地地址和端口吗?怎么就可以发送数据了呢?

套接字一开始确实还没初始化本端地址和端口,因此地址是通配地址 0.0.0.0 ,端口号也是 0 。sendto 系统调用发送数据前,内核会随机为套接字分配一个端口号,这样才能发送数据:

作为客户端,我们更关心服务端的地址,本端地址一般无需过问。当然了,如果某些场景需要用到套接字本端的地址和端口,可以通过 getsockname 系统调用来获取。

1
2
3
4
struct sockaddr_in local_addr;
bzero(&local_addr, sizeof(local_addr));

getsockname(s, (struct sockaddr *)&local_addr, sizeof(local_addr));

接收应答报文

请求报文发送出去之后,我们就开始等待并接收应答报文。

同样,我们需要准备一个缓冲区,来接收 UDP 数据。由于时间查询应答由 time_reply 结构体定义,我们申明一个 time_reply 结构体变量:

1
2
// receive reply
struct time_reply reply;

缓冲区准备完毕后,我们执行 recvfrom 系统调用接收服务端发来的数据:

1
2
3
4
if (recvfrom(s, &reply, sizeof(reply), 0, NULL, NULL) == -1) {
    perror("Failed to receive reply");
    return -1;
}

注意到,我们将套接字描述符和准备好的缓冲区传给 recvfrom 。

为了保持简单,我们没有准备用于保存应答发送方地址的缓冲区,而是将 NULL 传给 recvfrom 。因为正常情况下,应答肯定是服务器发的,它的地址信息我们已经有了。

连接对端

如果 UDP 套接字创建后只跟固定的IP和端口进行通信,可以执行 connect 系统调用将它和对端“连接”起来。时间查询客户端就是一个典型的例子,它只会跟服务端进行通信,而服务端的IP地址和端口是固定的。

假设服务端运行在主机 apple 上,端口是 12345 ,那么客户端可以这样将套接字与之“连接”:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
// struct for storing server address
struct sockaddr_in server_addr;
bzero(&server_addr, sizeof(server_addr));

// arguments->server_port: 12345
server_addr.sin_family = AF_INET;
server_addr.sin_port = htons(arguments->server_port);

// parse ip address
// arguments->server_ip: 10.0.2.2
if (inet_aton(arguments->server_ip, &server_addr.sin_addr) == 0) {
    fprintf(stderr, "Invalid IP: %s\n", arguments->server_ip);
    return -1;
}

// connect to server
if (connect(s, (struct sockaddr *)&server_addr, sizeof(server_addr)) == -1) {
    perror("Failed to connect to server");
    return -1;
}

套接字一旦完成连接动作,它将在内部保存对端的地址端口信息,此后只能跟该端通信。

于此同时,套接字将随机生成一个端口作为本端端口。由于对端 IP 已经确定,根据路由规则,本端出口 IP 也可以确定(假设客户端运行在主机 ant 上):

由于此时套接字内部已经有了对端的地址信息,我们发送数据时可以不指定目的地址:

1
2
3
4
5
// send request
if (send(s, &request, request_len, 0) == -1) {
    perror("Failed to send request");
    return -1;
}

send 系统调用与 sendto 类似,都通过套接字来发送数据,只是 send 无须指定目的地址信息。

编译程序

上面只介绍了时间查询客户端的关键步骤,完整代码可从 GitHub 上获取。程序的逻辑并不复杂,结合注释即可轻松理解,这里不再赘述。

代码目录中还有一个源文件 argparse.c ,里面是命令行选项解析逻辑。

另外,还有一个 Makefile ,用来构建程序。客户端可以这样构建:

1
make client

如果你想自己编译程序,可以执行 gcc 命令:

1
gcc -o client client.c argparse.c

扩展阅读

小菜学网络】系列文章首发于公众号【小菜学编程】,敬请关注:

【小菜学网络】系列文章首发于公众号【小菜学编程】,敬请关注: