聊天服务器-客户端telnet登入

Posted by 周思进 on August 17, 2019

在搭建完了基本的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回车

可以看到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); 可以直接查找对应字符的十六进制


上述内容可能不一定正确,如有错误,欢迎指正,谢谢!