windows版客户端更适合大多数人~
文章目录
- 一. udp客户端windows版
- 二.Tcp服务器的实现
- 总结
一、udp客户端windows版
首先我们将上一篇文章中实现的udp大型聊天室的代码进行修改,注意我们只修改服务端代码将代码修改的很简单就好,因为我们只是做一个如何用windows做一个客户端的例子。
我们服务端头文件不变,将.cc文件中的hander方法简化一下:
static void Usage(string proc)
{
cout<<"Usage:\n\t"<<proc<<" local_port\n\n";
}
void handerMessage(int sockfd,string clientip,uint16_t clientport,string message)
{
string response = message;
response += "[server echo]: ";
struct sockaddr_in client;
socklen_t len = sizeof(client);
bzero(&client, sizeof(client));
client.sin_family = AF_INET;
client.sin_port = htons(clientport);
client.sin_addr.s_addr = inet_addr(clientip.c_str());
// 构建好结构体后,我们要把处理的数据发给谁呢?当然是客户端了,客户端给我们发数据我们再将处理后的数据发回给客户端
sendto(sockfd, response.c_str(), response.size(), 0, (struct sockaddr *)&client, len);
}
// ./udpServer port
int main(int argc,char* argv[])
{
if (argc!=2)
{
Usage(argv[0]);
exit(USAGE_ERR);
}
uint16_t port = atoi(argv[1]);
unique_ptr<udpServer> usvr(new udpServer(handerMessage,port));
usvr->InitServer();
usvr->start();
return 0;
}
接下来我们开始演示如何在windows环境下编写udp客户端的代码:
首先我们需要包含头文件以及lib的一个库:
#include <iostream>
#include <WinSock2.h>
#include <string>
#pragma comment(lib,"ws2_32.lib")
然后我们需要启动windows的套接字,并且对winsocket进行初始化:
int main()
{
WSAData wsd;
//启动Winsock
//进行Winsocket的初始化,windows初始化socket网络库,申请2.2的版本
if (WSAStartup(MAKEWORD(2, 2), &wsd) != 0)
{
cout << "WSAStartup Error =" << WSAGetLastError() << endl;
return 0;
}
else
{
cout << "WSAStartup Success" << endl;
}
}
startup就是启动的接口,里面的参数的意思是:初始化socket网络库,申请2.2的版本。如果startup这个函数的返回值等于0就说明启动成功,否则就启动失败我们就打印一下。然后就和linux上的一样,创建套接字即可:
当然我们客户端需要知道服务端的ip和端口号,并且用户一般是不知道这些东西的,所以我们要做到让用户直接启动就能连接windows,我们可以将服务器的ip和端口号放在一个文件中,也可以直接定义:
这里的ip和端口号填的是你的服务器,可不要和我一样-.-。
然后我们就创建套接字:
SOCKET sock = socket(AF_INET, SOCK_DGRAM, 0);
if (sock == SOCKET_ERROR)
{
cout << "socket ERROR = " << WSAGetLastError() << endl;
return 1;
}
else
{
cout << "socket success" << endl;
}
struct sockaddr_in server;
memset(&server, 0, sizeof(server));
server.sin_family = AF_INET;
server.sin_port = htons(serverport);
server.sin_addr.s_addr = inet_addr(serverip.c_str());
string line;
while (true)
{
cout << "Please Enter# ";
getline(cin, line);
int n = sendto(sock, line.c_str(), line.size(), 0, (struct sockaddr*)&server, sizeof(server));
if (n < 0)
{
cerr << "sendto error" << endl;
break;
}
//接收服务器的数据
char buffer[1024];
struct sockaddr_in client;
int len = sizeof(client);
n = recvfrom(sock, buffer, sizeof(buffer) - 1, 0, (struct sockaddr*)&client, &len);
if (n >= 0)
{
buffer[n] = 0;
}
cout << "[server echo]: " << buffer << endl;
}
这里与linux中是完全一样的,最后我们还需要将使用库的相关资源全部释放掉:
//最后将使用库的相关资源全部释放掉 关闭套接字的文件描述符
closesocket(sock);
WSACleanup();
return 0;
}
int main()
{
WSAData wsd;
//启动Winsock
//进行Winsocket的初始化,windows初始化socket网络库,申请2.2的版本
if (WSAStartup(MAKEWORD(2, 2), &wsd) != 0)
{
cout << "WSAStartup Error =" << WSAGetLastError() << endl;
return 0;
}
else
{
cout << "WSAStartup Success" << endl;
}
SOCKET sock = socket(AF_INET, SOCK_DGRAM, 0);
if (sock == SOCKET_ERROR)
{
cout << "socket ERROR = " << WSAGetLastError() << endl;
return 1;
}
else
{
cout << "socket success" << endl;
}
struct sockaddr_in server;
memset(&server, 0, sizeof(server));
server.sin_family = AF_INET;
server.sin_port = htons(serverport);
server.sin_addr.s_addr = inet_addr(serverip.c_str());
string line;
while (true)
{
cout << "Please Enter# ";
getline(cin, line);
int n = sendto(sock, line.c_str(), line.size(), 0, (struct sockaddr*)&server, sizeof(server));
if (n < 0)
{
cerr << "sendto error" << endl;
break;
}
//接收服务器的数据
char buffer[1024];
struct sockaddr_in client;
int len = sizeof(client);
n = recvfrom(sock, buffer, sizeof(buffer) - 1, 0, (struct sockaddr*)&client, &len);
if (n >= 0)
{
buffer[n] = 0;
}
cout << "[server echo]: " << buffer << endl;
}
//最后将使用库的相关资源全部释放掉 关闭套接字的文件描述符
closesocket(sock);
WSACleanup();
return 0;
}
所以我们可以发现,windows客户端和linux客户端的区别在于,windows需要先启动winsocket并且初始化网络库,最后还需要手动释放使用库的相关资源,并且也要关闭套接字的文件描述符。
我们在运行的时候发现inet_addr这个函数会报错,原因是vs编译器认为这个函数不安全,大家可以将这个报错禁掉或者使用vs推荐的函数:
注意:4996代表的是我刚说的报错信息,不要理解为可以禁掉任意报错,要禁什么需要看你报错的编号。
二、Tcp服务器的实现
首先tcp服务器的实现一定是比udp困难的,但是因为udp的特性是面向数据报所以在日常生活中使用的没有tcp广泛,所以tcp我们必须要掌握,和之前一样我们先创建需要用到的头文件,比如tcpserver.hpp,tcpserver.cc。
接下来我们先将服务端的框架写出来:
namespace server
{
static const uint16_t gport = 8080;
class TcpServer
{
public:
TcpServer(const uint16_t& port = gport)
:_port(port)
,_sock(-1)
{
}
void initServer()
{
}
void start()
{
}
~TcpServer()
{
}
private:
int _sock;
uint16_t _port;
};
}
我们将udp绑定ip和端口号的时候说过,实际上一款服务器的启动只需要端口号,因为在绑定IP的时候我们会绑定任意ip,这样只要用户知道我们的端口号他就可以访问我们的服务器,所以我们的私有变量中没有ip只有port和文件描述符。在构造函数中我们直接给一个默认的端口号,这样我们启动的时候可以设置自己想用的端口号也可以直接用缺省的。然后我们将server.cc文件也写一下:
#include "TcpServer.hpp"
#include <memory>
using namespace server;
static void Usage(string proc)
{
cout<<"\nUasge:\n\t"<<proc<<" port\n\n";
}
//./tcpserver port
int main(int argc,char* argv[])
{
if (argc!=2)
{
Usage(argv[0]);
exit(USE_ERR);
}
uint16_t port = atoi(argv[1]);
unique_ptr<TcpServer> tsvr(new TcpServer(port));
tsvr->initServer();
tsvr->start();
return 0;
}
这里还是和之前udp服务器一样,唯一要说明的是我们在服务器构造的时候给了缺省,其实一个参数就可以运行,但是由于后面要进行演示,我们还是按照./tcpserver port这样的启动方法来走。
然后我们再写一下客户端的框架:
namespace client
{
class TcpClient
{
public:
TcpClient(const string& serverip,const uint16_t& serverport)
:_serverip(serverip)
,_serverport(serverport)
,_sock(-1)
{
}
void initClient()
{
}
void start()
{
}
~TcpClient()
{
}
private:
int _sock;
string _serverip;
uint16_t _serverport;
};
}
同样的udp客户端的一样我们就不解释了,顺便也把client.cc写出来:
#include "TcpClient.hpp"
#include <memory>
using namespace client;
static void Usage(string proc)
{
cout<<"\nUsage:\n\t"<<proc<<" serverip serverport\n\n";
}
// ./tcpclient serverip serverport
int main(int argc,char* argv[])
{
if (argc!=3)
{
Usage(argv[0]);
exit(1);
}
uint16_t serverport = atoi(argv[2]);
string serverip = argv[1];
unique_ptr<TcpClient> tcet(new TcpClient(serverip,serverport));
tcet->initClient();
tcet->start();
return 0;
}
准备工作做完后,我们就开始进行服务器的初始化函数的编写:
我们第一步还是创建套接字,不过这次我们可以加入一个日志的功能,每次服务器启动可以告诉我们那些函数接口是否调用成功,所以我们再创建一个log.hpp:
#pragma once
#include <iostream>
#include <string>
#define DEBUG 0
#define NORMAL 1
#define WARNING 2
#define ERROR 3
#define FATAL 4
void logMessage(int level,const std::string &message)
{
//[日志等级][时间戳/时间][pid][message]
std::cout<<message<<std::endl;
}
我们将等级分为5个,0,1,2都可以算作正常的,3,4就说明是某部分写错或者函数运行失败,然后我们就先简单的打印一下,等后期Tcp服务器实现完了我们再给日志添加好玩的功能。
void initServer()
{
//1.创建文件套接字对象
_sock = socket(AF_INET,SOCK_STREAM,0);
if (_sock==-1)
{
logMessage(FATAL,"create socket error");
exit(SOCKET_ERR);
}
logMessage(NORMAL,"socket success");
//2.进行bind
struct sockaddr_in local;
bzero(&local,sizeof(local));
local.sin_family = AF_INET;
local.sin_port = htons(_port);
local.sin_addr.s_addr = INADDR_ANY; //INADDR_ANY绑定任意地址IP
if (bind(_sock,(struct sockaddr*)&local,sizeof(local))<0)
{
logMessage(FATAL,"bind socket error");
exit(BIND_ERR);
}
logMessage(NORMAL,"bind socket success");
}
tcp服务器初始化的前两步与udp是一模一样的,都是先创建套接字,然后再bind。首先我们创建套接字,然后因为tcp是面向字节流的,所以socket的第二个参数我们选择sock_stream。如果创建失败,那么我们就向日志中打印信息,像这种使用接口失败的那么错误等级一定是严重错误fatal,然后我们还可以写一个枚举来保存所有的退出码。
enum
{
SOCKET_ERR = 2
,USE_ERR
,BIND_ERR
,LISTEN_ERR
};
如果创建套接字成功,我们就向日志中打印创建成功。绑定的时候我们可以看到我们将IP用INADDR_ANY绑定,这个选项的意思就是绑定任意IP,然后我们判断绑定是否成功,如果失败就向日志中写信息并且退出。下面我们将解Tcp服务器初始化与udp不一样的点:
//3.Tcp需要将套接字状态设为listen状态来一直监听(因为Tcp是面向字节流的)
if (listen(_sock,gbacklog)<0)
{
logMessage(FATAL,"listen socket error");
exit(LISTEN_ERR);
}
logMessage(NORMAL,"listen socket success");
}
首先一款Tcp服务器是面向链接的,当客户端要正常的向服务器发起请求的时候,客户端不能直接给服务器发送消息,而是需要先建立链接,这就意味着我们的服务器必须要时时刻刻准备接受客户端向我们发送的链接,那么如何做到呢?我们需要将socket设置为监听状态。
下面我们先看看listen这个接口的文档:
第一个参数是我们使用套接字返回的文件描述符,第二个参数是底层全链接长度+1,这里我们就不详细的解释第二个参数了,后面讲tcp原理的时候再详细的讲解。要使用这个参数我们首先定义一个变量:
这个变量可以是5,10,20之类的不能太大。如果监听成功我们就像日志写入成功的信息。
这样我们就写完了tcp服务器初始化的接口,不知道大家有没有这样的疑问,为什么udp不需要监听呢?这是因为udp不需要链接,客户端发给我们的数据就是数据本身。由于tcp是面向链接的,所以tcp的第一步不是发数据而是建立链接(这就是Tcp的三次握手,后面讲原理的时候细谈)。
下面我们编写start接口:
对于一款服务器,一旦启动那么必定是死循环:
void start()
{
for (;;)
{
}
}
那么启动后我们该干什么呢?在udp那里我们是接收客户端发来的消息,然后再将消息处理后发回给客户端,而对于tcp服务器我们刚刚也说了需要先建立链接,建立链接就需要用到accept接口:
注意:之前udp服务器中接收消息的recvfrom在tcp是用不了的。
第一个参数是一个文件描述符,后面两个参数是输出型参数,调用接口会接口会自动帮我们填充结构体,填充的结构体的信息是客户端的ip和端口等。accept的返回值是一个文件描述符,下面我们解释一下:
首先accept的后两个参数和recvfrom的后两个参数的含义是一模一样的,都是帮我们填充客户端的ip和端口号,最重要的是第一个参数,这个参数的含义是不一样的,因为accept的返回值是一个文件描述符,这个文件描述符和我们之前创建套接字返回的那个文件描述符是什么关系呢?我们在用listen接口的时候说过,将套接字设置为监听状态就可以一直监听客户端是否要给我们发现请求链接,而accept的第一个参数实际上就是监听的那个套接字,因为只有成功监听到客户端的请求链接我们才可以和客户端通信,所以accept返回的那个套接字才是我们真正用来和客户端通信的套接字,所以我们应该将刚开始创建的私有成员变量sock改名为listensock,因为这个变量只是起到监听新链接的作用。
改名后我们就可以更容易理解这两个套接字的关系了。
void start()
{
for (;;)
{
//4.server获取新链接 未来真正使用的是accept返回的文件描述符
struct sockaddr_in peer;
socklen_t len = sizeof(peer);
// sock是和client通信的fd
int sock = accept(_listensock,(struct sockaddr*)&peer,&len);
//accept失败也无所谓,继续让accept去获取新链接
if (sock<0)
{
logMessage(ERROR,"accept error,next");
continue;
}
logMessage(NORMAL,"accept a new link success");
cout<<"sock: "<<sock<<endl;
//5.用sock和客户端通信,面向字节流的,后续全部都是文件操作
serviceIO(sock);
//对于一个已经使用完毕的sock,我们要关闭这个sock,要不然会导致文件描述符泄漏
close(sock);
}
}
要使用accept需要先创建结构体,然后拿到返回的套接字后我们可以打印一下这个套接字。注意:即使我们accept失败也无所谓,因为listensock会持续监听客户端的新链接,所以我们accept失败不能退出。当我们成功拿到和客户端通信需要的sock后,我们就要考虑和客户端通信了,这里我们专门写一个服务器与客户端通信的函数,将sock传入:
void serviceID(int sock)
{
while (true)
{
}
}
首先我们要能读到客户端发来的消息,所以我们直接用以前学文件用到的read接口:
这个接口很简单,第一个参数是我们从哪个文件描述符里读,第二个参数是要读到哪个缓冲区,第三个参数是缓冲区的大小。
void serviceID(int sock)
{
char buffer[1024];
while (true)
{
ssize_t n = read(sock,buffer,sizeof(buffer)-1);
if (n>0)
{
//目前我们先把读到的数据当成字符串
buffer[n] = 0;
cout<<"recv message: "<<buffer<<endl;
}
}
}
我们定义一个缓冲区,然后如果读取成功就在前面加上"接收消息:"然后把消息打印出来,接下里我们再简单的处理一下数据把数据转回的客户端,注意:我们的只是方便演示用的字符串,实际上这里的数据可以是任意的,比如结构化的。
write接口我们也用过,第一个参数要向哪个文件描述符写入,第二个参数是写入的消息的缓冲区,第三个参数是缓冲区的大小。
void serviceIO(int sock)
{
char buffer[1024];
while (true)
{
ssize_t n = read(sock,buffer,sizeof(buffer)-1);
if (n>0)
{
//目前我们先把读到的数据当成字符串
buffer[n] = 0;
cout<<"recv message: "<<buffer<<endl;
//将消息转回客户端
string outbuffer = buffer;
outbuffer+="[serverecho]";
write(sock,outbuffer.c_str(),outbuffer.size()); //多路转接解释write返回值
}
else if(n==0)
{
//n==0说明客户端退出了
logMessage(NORMAL,"client quit,server me to!");
break;
}
}
}
read的返回值是读到的数据的大小,如果读到0说明读到了文件结尾,既然读到了文件结尾那么客户端肯定是退出了。这就类似于管道,当写端不写了并且把文件描述符关闭了,我们的读端就会把数据读完后然后读到文件结尾返回0,所以读到0就代表客户端退出了。这也就是为什么我们在start中一旦serviceIO后就直接关闭了文件描述符,因为serviceIO是一个死循环,一旦循环退出就说明客户端退出了,既然客户端退出了那么我们的服务端当然要将与客户端通信的文件描述符关闭。(注意:对于已经使用完的文件套接字我们必须关闭,如果不关闭则会造成文件描述符泄漏)
下面我们编写客户端的代码:
客户端初始化同样也需要创建套接字,我们在udp的时候就说过,客户端一定要bind,但是不需要程序员明确的bind,在tcp中也一样。那么tcp客户端需要listen吗?当然不需要了,客户端又不是服务器,没人会链接客户端的所以不需要监听,那么需要accept吗?答案是也不需要,因为客户端没有人去链接所以不需要。
void initClient()
{
// 1.创建套接字
_sock = socket(AF_INET,SOCK_STREAM,0);
if (_sock<0)
{
cout<<"socket error"<<endl;
exit(2);
}
// 2.客户端要bind吗?必须要! 要程序员显式的bind吗?不需要
// 3.客户端要listen吗?不需要,没人去连客户端所以不需要
// 4.客户端要accept吗?不需要
// 5.客户端要发起链接。
}
没错,我们客户端初始化的代码非常的简洁,就只需要创建套接字即可。
那么客户端启动需要干什么呢?实际上就是我们的第5点,我们要发起链接。
下面我们认识一下connect接口:
第一个参数是文件描述符,第二个参数和第三个参数是我们要传结构体,这个结构体是我们要和哪个服务器建立链接里面就是哪个服务器的ip和port。仔细看我们红色圈出来的部分就会发现,我们刚刚初始化的时候说客户端不用显式的bind,可以看到在connect接口中会自动帮我们绑定,如果绑定成功会返回0.
void start()
{
struct sockaddr_in server;
bzero(&server,sizeof(server));
server.sin_family = AF_INET;
server.sin_port = htons(_serverport);
server.sin_addr.s_addr = inet_addr(_serverip.c_str());
//connet的时候操作系统会帮客户端bind 返回值等于0成功
if (connect(_sock,(struct sockaddr*)&server,sizeof(server))!=0)
{
cerr<<"socket connect error"<<endl;
}
else
{
//链接成功客户端要干什么?与服务端通信
}
}
我们首先填充结构体,将服务端的ip和port填充完毕后,我们就可以进行连接,如果连接失败我们就打印错误,只有成功我们才开始与服务端通信,那么如何通信呢?实际上与服务端accept后一样,因为tcp是面向字节流的,所以我们通信的过程都是文件操作。
else
{
//链接成功客户端要干什么?与服务端通信
string message;
while (true)
{
cout<<"Enter# ";
getline(cin,message);
//将消息发送给服务端
write(_sock,message.c_str(),message.size());
//读取服务端给我们发送的消息
char buffer[1024];
int n = read(_sock,buffer,sizeof(buffer)-1);
if (n>0)
{
buffer[n] = 0;
cout<<"Server回显# "<<buffer<<endl;
}
else
{
break;
}
}
}
这里就是死循环式的给服务器发消息,我们直接用getline将cin中输入的消息保存到string中,然后将这个消息写入文件描述符,接下来我们还需要读取服务端给我们发送的消息,因为read接口需要缓冲区,所以我们定一个缓冲区,如果read的返回值大于0说明消息读到缓冲区中我们就在读取到字节数的位置放一个\0,然后打印这个消息,如果返回值等于0说明写端不写入了并且关闭了文件描述符,所以我们就直接退出循环并且关闭文件描述符,我们这里就直接用析构函数关闭即可。
~TcpClient()
{
if (_sock!=-1)
{
close(_sock);
}
}
下面我们就运行起来演示一下:
运行后我们可以用netstat命令查看服务器信息,n代表将端口号等信息用数字显示,l代表监听,tp代表tcp,下面我们开启客户端:
大家可以猜一下为什么我们创建的套接字是4号呢?首先我们都知道OS会默认打开3个文件描述符分别是0,1,2,那么我们为什么不是创建的3呢不是说文件描述符对应数组的下标吗?这是因为我们服务器中创建listensock套接字用了3号,所以我们调用accept创建的套接字是4号套接字。
当我们用客户端连接上服务端后查询所有的tcp发现有两个服务端:
这是因为我们当前是在本地通信的,所以有两个链接,客户端到服务端,服务端到客户端。正常情况下都是不同的电脑进行连接,这种情况下查看就只有一条链接了。
当然我们在写代码的时候其实故意留了一个问题,如果有多个客户端发消息是这样的:
为什么只有先链接的那个客户端可以发消息呢?
当我们将先链接的那个客户端退出后为什么消息一股脑全发来了,这是因为我们当时写代码的时候只针对一个客户端,谁进来谁就死循环式的收发消息:
只有当一个客户端退出了另一个客户端才会收到消息,在下一篇文章中我们将用多进程,多线程,线程池版本的serviceIO,届时这种问题就不会存在了。
总结
tcp服务器的实现相较于udp服务器并没有多多少东西,所以只有udp明白了那么简单的tcp服务器还是很好实现的,下一篇才是我们tcp服务器的重点。