在搭建完了基本的TCP监听服务器后,需要完成基本的客户端连接并进行简单的读写操作。客户端不需要自己编写,使用的是telnet方式登入,通过telnet连接后,服务器返回用户进行登入或注册操作的编号,用户选择对应的数字发给服务器表示所要进行的操作。
服务器收发策略
服务端需要根据telnet协议的特性来确定收发策略:
一、目前设计的套接字是阻塞型的,所以write一定是等发送完要发送的数据才会返回;
如果是非阻塞的,那可能一次write写入的数据比实际要发送的数据少。原因是每个套接字有自己的发送缓存区,如果当前的缓存区不足以存放应用层所要发送的数据,那可能只先拷贝了一部分到缓存区就返回了。
所以对于非阻塞套接字需要通过实现类似内部循环写的代码机制,保证所要发送的数据都发送出去了。(这里也说明了一点,write操作接口返回不代表数据已经发送到对端,只是表示写到了内核缓存区)
考虑后面套接字阻塞属性可能进行更改,所以都按非阻塞型套接字写来实现代码。
二、TCP字节流通信read操作,个人觉得关注点不像write接口在于阻塞还是非阻塞,而是判断对方是否已经发送完数据了,有三种方法:
1、发送正式数据前先发数据头,数据头中有明确后面要发送的数据长度,接收端先读取数据头,就知道后面应该需要读取多少数据长度
2、两边明确数据发送完毕结束符,比如换行就表示此次数据发送完
3、对端关闭连接,表示数据发送完,此时read返回值为0
功能实现方案分析:
a、方案三在此功能中不现实,telnet登入是需要保持长连接的
b、方案一则客户端并不受我们控制,是直接使用的第三方协议
所以只剩方案二,每次接收到数据检测下是否有换行以确定数据发送完了
方案二无论套接字是阻塞还是非阻塞,都需要有循环读取的机制,而不是说对于阻塞套接字就像write一样只需要调用一次read操作就可以把想要的数据获取上来了。
原因也就像前面说,wirte操作系统可以知道你到底要发多少数据,但read操作方案二并不知道对方会发多少数据过来,所以需要应用自行循环读取,直到应用认为读取结束了。
当然对于非阻塞套接字的读取操作,需要额外考虑如果没有数据可读时接口直接返回-1的特殊处理(系统会发EINTR中断)
telnet登入
1、先启动tcp服务器,再另起一个终端进行telnet登入,登入方式:telnet ip port
telnet退出方式:
先按ctrl + ] ,再敲回车,进入telnet命令输入界面,然后输入quit,再按回车退出
2、发送数据
telnet端选择登入还是注册对应的数字之后按回车,在本地进行抓包显示如下(抓回环地址数据包方式 tcpdump -i lo -w telnet.pcap):
可以看到telnet客户端输入1之后,按回车一共发了三个字节过来,分别是16进制的 0x31,0x0d,0x0a
通过ascii表查得
Hex | Symbol |
---|---|
0x31 | 1 |
0x0d | CR |
0x0a | LF |
0x31是对应的数字1,CR LF 对应的就是回车、换行符
上述是一般用户的正常操作流程,所以服务端通过检测换行来确认用户是否结束输入。
(telnet输入完字符后,用户也可以按ctrl+d来表示结束输入,这种情况下发送过来的数据就没有任何结束符了)
相关接口代码
/** brief 适应非阻塞套接字写接口
* @param[in] fd : 文件描述符
* @param[in] buf : 要写的数据缓存地址
* @param[in] nbytes : 要写的数据长度
* return 返回已写字节数
*/
int writen(int fd, const void *buf, size_t nbytes)
{
int length = nbytes;
int nwrite = 0;
char *tmp_buf = (char *)buf; // 开始是声明了一个已写变量have_write,后面通过buf+have_wirte来指示偏移,改成声明指针来操作,代码更简洁点
if (-1 == fd || NULL == buf)
{
printf("%s %s %d \n", __FILE__, __func__, __LINE__ );
return -1;
}
while (length > 0)
{
nwrite = write(fd, tmp_buf, length);
if (nwrite < 0)
{
if (errno == EINTR)
{
continue;
}
printf("%s %s %d \n", __FILE__, __func__, __LINE__ );
return -1;
}
length -= nwrite;
tmp_buf += nwrite;
}
return (nbytes - length);
}
/** brief 读取指定数据长度,遇到对端关闭或者换行符则提前结束读取
* @param[in] fd : 文件描述符
* @param[in] buf : 要读的数据缓存地址
* @param[in] nbytes : 要读的数据长度
* return 返回已读字节数
*/
int readn(int fd, void *buf, size_t nbytes)
{
int length = nbytes;
int nread = 0;
char *tmp_buf = (char *)buf;
char *p_cr = NULL;
if (-1 == fd || NULL == buf)
{
printf("%s %s %d \n", __FILE__, __func__, __LINE__ );
return -1;
}
while (length > 0)
{
nread = read(fd, tmp_buf, length);
if (nread < 0)
{
if (errno == EINTR) // 非阻塞套接字,需要再次调用read接口
{
continue;
}
printf("%s %s %d \n", __FILE__, __func__, __LINE__ );
return -1;
}
else if (nread == 0) // 表示对端已关闭连接
{
break;
}
else
{
length -= nread;
tmp_buf += nread;
// 判断是否收到回车符号 CR CL,这里直接判断第一个是否是CR字符,可以直接查找其对应的16进制值
p_cr = strchr(buf, 0x0d);
if (p_cr != NULL)
{
*p_cr = 0;
return (p_cr - (char *)buf);
}
}
}
return (nbytes - length);
}
部分代码:
if (strlen(buf) != 1 || 0 == isdigit(buf[0]))
{
(void)writen(client_fd, LOGIN_STR, strlen(LOGIN_STR));
continue;
}
choose = atoi(&buf[0]);
相关问题记录:
1、需要判断下用户输入合法性,检查是否是数字,接口返回值需要判断清楚,isdigit失败的情况下是返回0,成功是返回非0
2、写接口最初实现是声明了一个已写变量have_write,后面通过buf+have_wirte来指示偏移,改成声明指针来操作,代码更简洁点
3、非阻塞套接字,读写失败的情况下,判断errno错误是否是EINTR,是的话需要再次调用读写接口
4、strchr(buf, 0x0d); 可以直接查找对应字符的十六进制
上述内容可能不一定正确,如有错误,欢迎指正,谢谢!