C++写Socket——TCP篇(0)建立连接及双方传输数据

满山的红叶……飘落之时……

最近接触了点关于用C++写socket的东西,这里总结下。

这里主要是关于TCP的,TCP的特点什么的相关介绍在我另一篇博文里,所以这里直接动手吧。

我们先在windows下写,不过代码可以直接移植到linux下。

Visual Studio项目配置及初始化

这里用的版本是2015的。创建了项目之后要配置项目的属性:

在下图箭头处添加ws2_32.lib,不然没办法使用socket相关的函数。

然后在win平台下,使用这个库前需要初始化,因此在main函数中应有:

WSADATA ws;
WSAStartup(MAKEWORD(2, 2), &ws);

不过因为只是在win平台下才需要编译,所以可以这样写:

#ifdef WIN32
static bool first = true;
if (first) {
	WSADATA ws;
	WSAStartup(MAKEWORD(2, 2), &ws);
	first = false;
}
#endif

原理是,在win32平台下编译的时候,宏WIN32是有定义的,所以会自动执行ifdefendif之间的代码,初始化库。如果是Linux平台下的话则不会执行这段代码。不过前提是,使用的编译平台为:

x64的话宏是另一个。

上面代码中的first是为了保证只初始化一次,虽然多次初始化不会有问题,但是会对性能有一定的影响。

虽然可以直接写在main函数中初始化,但是为了后面拓展方便,最好封装在类中。类的构造函数如下:

XTcp::XTcp()
{
// 初始化库,如果不初始化的话会直接导致后面的socket函数无法使用,但是在初始化前
// 要加载Windows的网络库,就是在项目属性那里加ws2_32.lib
#ifdef WIN32
	static bool first = true;
	if (first) {
		WSADATA ws;
		WSAStartup(MAKEWORD(2, 2), &ws);
		first = false;
	}
#endif
}

这里说一下类的头文件/声明:

#ifndef XTCP_H
#define XTCP_H

#ifdef WIN32
#ifdef XSOCKET_EXPORTS
#define XSOCKET_API __declspec(dllexport)
#else
#define XSOCKET_API __declspec(dllimport)
#endif
#else
#define XSOCKET_API
#endif

//#include <string>
class XSOCKET_API XTcp
{
public:
	int CreateSocket();
	bool Bind(unsigned short port);
	XTcp Accept();
	void Close();
	int Recv(char* buf, int bufsize);
	int Send(const char* buf, int sendsize);
	bool Connect(const char *ip, unsigned short port, unsigned int timeoutms=1000);
	bool SetBlock(bool isblock);
	XTcp();
	virtual ~XTcp();
	
	unsigned short port = 0; // 用来建立连接的端口
	int sock = 0; // 用来通信的socket
	char ip[16];
};

#endif

注意,因为是双方都可以收发,所以必须是双方都有一个用来接收的函数,一个发送的函数。其实这里写的服务端代码和客户端代码是一样的,如果读者有兴趣的话再自行拓展。

配置服务端

在配置之前先弄清楚大概是怎么个流程。首先我们会监听一个端口,这个端口只是用来接收请求然后建立连接的,但是不会用来传输数据。客户端请求之后服务器会另外分配一个端口,客户端和服务端是通过这个新分配的端口来进行通信的。

监听指定端口

了解了大概的流程之后我们就可以开始编写了,首先是监听和建立连接的部分:

bool XTcp::Bind(unsigned short port) {
	if (sock <= 0) {
		CreateSocket();
	}
	sockaddr_in saddr;
	saddr.sin_family = AF_INET;
	saddr.sin_port = htons(port); // host to network,本地字节序转换成网络字节序
	saddr.sin_addr.s_addr = htons(0); // 绑定ip地址,0的话其实可以不转。这里是任意的ip发过来的数据都接受的意思。至于为什么0就是监听任意端口,建议看看计算机网络
									  // 一个int是4个char,所以可以通过int来表示ip地址

									  // bind端口,很容易失败,一定要有判断
	if (::bind(sock, (sockaddr*)&saddr, sizeof(saddr)) != 0) {	// :: 表示用的是全局的函数
		printf("bind port %d failed!", port);
		return false;
	}
	printf("bind port %d succeeded.", port);
	listen(sock, 10); // 监听指定的端口,只用来创建链接
	return true;
}

上面这段代码很简单(都有注释了欸!),就是先指定一个端口用来建立连接(就是代码里面所谓的“绑定”),监听这个端口,一有请求就创建连接。注意::bind,不要省略掉冒号,这里代表使用全局的bind,而不是c++自带的bind。使用这个函数的时候给个端口号就可以绑定了。

上面代码用到的CreateSocket()函数的定义如下:

int XTcp::CreateSocket() {
	// 使用TCP/IP协议,所以AF_INET,TCP,所以是SOCK_STREAM
	sock = socket(AF_INET, SOCK_STREAM, 0);

	// 创建socket失败,例如Linux中因为超出了每个进程分配的文件具体数量而被拒绝创建
	if (sock == -1) {
		printf("Create socket failed!
");
	}
	return sock;
}

其实就是配置一下socket属性,不解释。注意这是在类里面操作的,操作的sock是类的属性。

发送连接请求

发送连接请求要知道ip地址和端口号,这里封装好了,只需要提供端口号、ip地址、超时时间即可。

bool XTcp::Connect(const char * ip, unsigned short port, unsigned int timeoutms)
{
	if (sock <= 0) {
		CreateSocket();
	}
	sockaddr_in saddr;
	saddr.sin_family = AF_INET;
	saddr.sin_port = htons(port);
	saddr.sin_addr.s_addr = inet_addr(ip);

	SetBlock(false);
	fd_set set; // 文件描述符的数组
	if (connect(sock, (sockaddr*)&saddr, sizeof(saddr)) != 0) {
		FD_ZERO(&set);// 每次判断前必须要清空
		FD_SET(sock, &set);
		timeval tm;
		tm.tv_sec = 0;
		tm.tv_usec = timeoutms * 1000;
		if (select(sock + 1, 0, &set, 0, &tm) <= 0) {
			// 只要有一个可写,就会返回文件描述符的值,否则返回-1,超时返回0
			printf("connect timeout or error!
");
			printf("connect %s:%d failed!: %s
", ip, port, strerror(errno));
			return false;
		}
	}
	SetBlock(true);
	printf("connect %s:%d succeded!
", ip, port);
	return true;
}

bool XTcp::SetBlock(bool isblock)
{
	if (sock <= 0) {
		return false;
	}
#ifdef WIN32
	unsigned long ul = 0;
	if (!isblock) {
		ul = 1;
	}
	ioctlsocket(sock, FIONBIO, &ul);
	// 下面是Linux中的设置阻塞方式的代码
#else
	int flags = fcntl(sock, F_GETFL, 0);
	if (flags < 0) {
		return false;
	}
	if (isblock) {
		flags = flags&~O_NONBLOCK;
	}
	else {
		flags = flags | O_NONBLOCK; // 非阻塞模式
	}
	if (fcntl(sock, F_SETFL, flags) != 0) {
		return false; // 如果不等于0,那么设定失败
	}
#endif
	return true;
}

SetBlock是用来设置是否阻塞的,这里因为Windows和Linux系统的设置方式不一样,所以弄了判定条件,不同系统分别做不同处理。为什么非得要设置非阻塞?因为默认情况下connect是阻塞的,在connect发起的三次握手(是的,调用accept的时候三次握手已经完成了)结束之后才会返回值,因为握手不是瞬间就完成的,所以会需要设定延时功能,但是问题就在这里了,Windows下的延时和Linux下的延时好像是实现的效果是不一样的,哪怕设置相同。所以才会需要用非阻塞的方式自己另外实现延时的功能。

在非阻塞工作模式下,调用connect会立即返回EINPROCESS错误(或者0,即成功建立连接,但是通常不可能,除非连接的是本机),但是三次握手其实还在进行,所以需要使用select来检查连接是否建立成功。select的规则是这样的,描述字数组中有一个描述字是可写的时候就会返回那个描述字的值,否则返回-1或0。所以我们可以在配置好select后判断select返回的值来判断是否成功建立连接。之所以能用select这么做就是因为连接成功建立的时候,描述字变为可写(记住,Linux中所有的东西都被当成文件处理,socket也是),select会在数组中某个描述字变为可写的时候返回该描述字的值。

然后再提一下select中最后面的&tm位置的参数,这个地方用来设置延时时间,在延时时间内select是阻塞的(即一定要等这个函数执行完才能够继续向下执行),所以最终可以实现延时的功能。最后执行完后一定要设置回阻塞状态,否则会出错。

总之,如果暂时还理解不了的话可以先跳过select部分,这里只是用来实现延时功能的。

创建连接

在接收到连接请求后,服务端接受连接请求,就会创建一个新的socket来专门进行传输数据(其实可以联想下平时使用浏览器访问网站的时候,虽然都是访问HTTPS的端口443,但是如果只通过这一个端口来给多个用户服务的话显然是不够用的,所以肯定是另外分配临时的端口用来传输数据,443只是用来接收请求的)。

XTcp XTcp::Accept()
{
	XTcp tcp;
	sockaddr_in caddr;
	socklen_t len = sizeof(caddr);
	int client = accept(sock, (sockaddr*)&caddr, &len); // 读取用户连接信息,会创建新的socket,用来单独和这个客户端通信,后面两个
														// 参数要传指针,用来返回端口号和地址
	if (client <= 0) {
		return tcp;
	}
	printf("accept client %d
", client);
	char *ip = inet_ntoa(caddr.sin_addr);
	strcpy(tcp.ip, ip);
	tcp.port = ntohs(caddr.sin_port); // short,恰好最大65535
	tcp.sock = client;
	printf("client ip is %s, port is %d 
", tcp.ip, tcp.port);
	return tcp;
}

client其实就是分配的编号,分配好的端口号和地址其实存在caddr中。建立好通信用的连接之后,就可以开始通信了。

接收和发送数据

发送数据

int XTcp::Send(const char* buf, int size) {
	int s = 0;
	while(s != size) {
		int len = send(sock, buf + s, size - s, 0);
		if (len <= 0) {
			break;
		}
		s += len;
	}
	return s;
}

这里要结合计算机网络的一些基础只是来看,我在之前的博文有详细介绍,这里只是简单说一下。这里其实就是直接将存放在缓存中的数据发送出去,注意的是,TCP是以字节为单位的,所以缓存buf的定义就是char,然后s是索引,这里是每次尝试一次性发送所有的缓存,所以才是send(sock, buf + s, size - s, 0)send的定义是int send( SOCKET s,const char* buf,int len,int flags);),len是在收到确认报文之后计算出的接收方已经接收到哪里的长度,即按序连续接收到的数据数量(不懂的话看我的另一篇关于TCP的博文)。在send执行之后会进行判断,看对方是否接收到了所有的数据,如果没有就会重新发还没收到的那部分(由s作为索引决定,buf + s指针指向的后面那部分都是要发送且还没确认对方已经收到的)。其实这里有点类似滑动窗口,只是前沿没有推进。

接收数据

recv函数的定义是ret = sock.recv(bBuffer,iBufferLen,0);返回值是已经接收到了的数据量(必须是连续且按序到达的才算)。基本上这个函数就够用了,所以我们这里只是封装一下:

int XTcp::Recv(char* buf, int bufsize) {
	return recv(sock, buf, bufsize, 0);
}

断开连接

void XTcp::Close() {
	if (sock <= 0) return;
	closesocket(sock);
}

就调用一下函数关闭socket,没什么好说的。

最后补充下析构函数:

XTcp::~XTcp()
{

}

啥都没,不用搞什么骚操作。

用到的头文件就是这些:

#include "XTcp.h"
#include <iostream>
#include <string.h>
#include <stdlib.h>
#include <stdio.h>
#ifdef WIN32
// 兼容Linux
#include <Windows.h>
#define socklen_t int
#else
#include <sys/types.h>
#include <sys/socket.h>
#include <unistd.h>
#include <arpa/inet.h>
#include <fcntl.h>
#define closesocket close
#endif

#include <thread>

服务端逻辑编写

#include "XTcp.h"
#include <stdlib.h>
#include <thread>
#include <string.h>

class TcpThread
{
public:
	void Main()
	{
		char buf[1024] = { 0 };
		for (;;)
		{
			int recvlen = client.Recv(buf, sizeof(buf) - 1);
			if (recvlen <= 0) break;
			buf[recvlen] = '';

			if (strstr(buf, "quit") != NULL)
			{
				char re[] = "quit success!
";
				client.Send(re, strlen(re) + 1);
				break;
			}
			int sendlen = client.Send("ok
", 4);
			printf("recv %s
", buf);
		}
		client.Close();
		delete this;
	}
	XTcp client;
};

int main(int argc, char *argv[]) {
	unsigned short port = 8080;
	if (argc > 1) {
		port = atoi(argv[1]);
	}

	XTcp server;
	server.CreateSocket();
	server.Bind(port);
	for (;;)
	{
		XTcp client = server.Accept();
		TcpThread *th = new TcpThread();
		th->client = client;
		//创建线程
		std::thread sth(&TcpThread::Main, th);

		//释放父线程拥有的子线程资源
		sth.detach();
	}
	server.Close();
	getchar();
	return 0;
}

这里用创建新线程的方式为多个用户提供服务,大概了解下就行,不创建新进程也可以,只是会只能等一个用户断开连接之后新用户才能连接。服务端我是放在Linux服务器上的,但是makefile就不放出来了,这个比较简单。

客户端逻辑编写

#include "XTcp.h"
#include <stdlib.h>
#include <iostream>

int main() {
	XTcp client;
	client.CreateSocket();
	//client.SetBlock(true);

	client.Connect("192.168.56.102", 8080);// ip地址和端口可以改成自己想要的,记得设置防火墙放行对应的端口
	client.Send("client", 6);
	char buf[1024] = { 0 };
	client.Recv(buf, sizeof(buf));
	printf("%s
", buf);
	getchar(); // 只是用来暂停程序看效果的
	return 0;
}

最终效果

这里只是互相传了一段文字,怎么改的话就不多说了,嗯。

参考

socket函数send和recv函数
C++socket网络编程大全:讲解挺透彻,建议购买学习。大部分内容来自这个课程,其实课程中还有关于动态链接库的生成部分,值得一看,但是这里就不放出相关内容了,想看的话还是掏钱买吧(不到200的价格,要啥自行车)
socket编程之select:介绍了select函数,值得一看
socket通信中select函数的使用和解释:也是关于select的,感兴趣的可以去看看。还设计了点组阻塞的内容
非阻塞socket编程:值得一看,这里涉及的内容更广一些

原文地址:https://www.cnblogs.com/yejianying/p/cpp_socket_0.html