
本篇将基于Socket编程TCP实现一个Echo Server。这个EchoServer的功能和【Linux网络】Socket编程UDP 中实现的是一样的。
1.预备工作
首先我们要创建如下几个文件。

一般来说服务器是不允许拷贝的,为了不让服务器能被拷贝,我们可以设计成单例模式,或者把服务器的赋值以及拷贝构造设为私有或禁用,单例模式之前实现过这里就不选择单例模式了,我们把复制和拷贝构造私有的话那如果UDP也想自己不被拷贝呢?
这里介绍另一种方式,首先在Common.hpp里实现一个NoCope的类。
cpp
// Common.hpp文件
#pragma once
#include <iostream>
class NoCope
{
public:
NoCope() {}
NoCope(const NoCope &) = delete; // 拷贝构造
const NoCope &operator=(const NoCope &) = delete; // 赋值
~NoCope() {}
};
然后让服务器的类继承NoCope类。
cpp
//TcpServer.hpp文件
#pragma once
#include <iostream>
#include <string>
#include <sys/types.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include "Common.hpp"
class TcpServer : public NoCope // TcpServer类public继承NoCope类
{
public:
TcpServer(uint16_t port) : _port(port)
{
}
~TcpServer() {}
private:
uint16_t _port; // 端口号
};
此时这个服务器就不会被拷贝了,因为想要TcpServer的拷贝或赋值,就得先拷贝基类,但是基类NoCope的拷贝已经被我们设置为不可用了。
此时如果UdpServer也想禁止拷贝,就用同样的方法,把Common.hpp文件包含进去,然后将自己的服务器类设为NoCope的子类。
2.初始化
2.1 创建套接字
创建套接字的接口为socket,返回值成功时是一个文件描述符,就是sockfd,失败返回-1.

第一个参数是AF_INET;因为TCP面向字节流,所以第二个参数填SOCK_STREAM;第三个参数就填0。


cpp
// TcpServer.hpp文件
#pragma once
#include <iostream>
#include <string>
#include <stdlib.h>
#include <sys/types.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include "Common.hpp"
#include "MyLog.hpp"
using namespace MyLog;
class TcpServer : public NoCope // TcpServer类public继承NoCope类
{
public:
TcpServer(uint16_t port)
: _port(port),
_sockfd(-1)
{
}
void Init()
{
_sockfd = socket(AF_INET, SOCK_STREAM, 0);
if(_sockfd < 0)
{
LOG(LogLevel::FATAL) << "创建套接字失败";
exit(); // ?
}
LOG(LogLevel::FATAL) << "create socket success, sockfd: " << _sockfd;
}
~TcpServer() {}
private:
uint16_t _port; // 端口号
int _sockfd;
};
如果创建套接字失败就直接exit退出,退出码我们可以自己设置一下。
在Common.hpp文件里新加一个枚举类型,正常退出就是0。
cpp
// Common.hpp文件
#pragma once
#include <iostream>
enum ExitCode
{
normal = 0,
SOCKET_ERR
};
class NoCope
{
public:
NoCope() {}
NoCope(const NoCope &) = delete; // 拷贝构造
const NoCope &operator=(const NoCope &) = delete; // 赋值
~NoCope() {}
};
然后TcpServer 初始化的时候如果创建套接字失败,退出码可以像如下写。
cpp
void Init()
{
_sockfd = socket(AF_INET, SOCK_STREAM, 0);
if(_sockfd < 0)
{
LOG(LogLevel::FATAL) << "创建套接字失败";
exit(ExitCode::SOCKET_ERR);
}
}
2.2 绑定端口号
要用到bind接口,成功返回0,失败返回-1。

第一个参数是sockfd;第二个参数是一个输出型参数,要传一个sockaddr_in结构体,并且强转为sockaddr类型;第三个参数是这个结构体的大小。
第三个参数现在不需要我们手动的填充信息了,在文章【Linux网络】实现一个简单的聊天室 中,我们封装了一个InetAddr的类,此时就可以直接拿来用。
但是之前我们并没有实现获取sockaddr_in地址的接口,这里再新增一个接口NetPtr,返回值为struct sockaddr *类型,并把构造函数再重载一个只需要传个端口号的。
cpp
// InetAddr.hpp文件
#pragma once
#include "Common.hpp"
class InetAddr
{
public:
InetAddr(struct sockaddr_in &addr) : _addr(addr) // 传参为网络地址
{
// 网络转主机
_port = ntohs(addr.sin_port); // 网络序列转主机序列
// 4字节网络序列转点分十进制q
//_ip = inet_ntoa(addr.sin_addr);
char ip_buffer[64];
inet_ntop(AF_INET, &_addr.sin_addr, ip_buffer, sizeof(ip_buffer));
_ip = ip_buffer;
}
InetAddr(const std::string ip, uint16_t port) : _ip(ip), _port(port) // 传参为主机地址
{
//主机转网络
memset(&_addr, 0, sizeof(_addr)); // 清0
_addr.sin_family = AF_INET;
_addr.sin_port = htons(_port);
inet_pton(AF_INET, _ip.c_str(), &_addr.sin_addr);
}
InetAddr(uint16_t port) : _port(port), _ip("0") // 只需要主机的端口号
{
// 主机转网络
memset(&_addr, 0, sizeof(_addr)); // 清0
_addr.sin_family = AF_INET;
_addr.sin_port = htons(_port);
_addr.sin_addr.s_addr = INADDR_ANY;
}
const struct sockaddr_in &NetAddr() { return _addr; }
const struct sockaddr *NetAddrPtr()
{
//return &_addr; // 直接这样写会报错
return CONV(_addr); // 强转一下,这里用宏替换
}
std::string Ip() { return _ip; } // 主机ip
uint16_t Port() { return _port; } // 主机port
std::string StringAddr() { return "[" + _ip + ":" + std::to_string(_port) + "]"; }
bool operator==(InetAddr &i)
{
return i.Ip() == _ip && i.Port() == _port;
}
~InetAddr() {}
private:
struct sockaddr_in _addr; // 网络序列
std::string _ip;
uint16_t _port;
};
cpp
// Common.hpp文件
#pragma once
#include <iostream>
#include <iostream>
#include <string>
#include <cstring>
#include <stdlib.h>
#include <sys/types.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
enum ExitCode
{
normal = 0,
SOCKET_ERR
};
class NoCope
{
public:
NoCope() {}
NoCope(const NoCope &) = delete; // 拷贝构造
const NoCope &operator=(const NoCope &) = delete; // 赋值
~NoCope() {}
};
#define CONV(add) ((struct sockaddr *)&add) // 定义宏
根据bind的参数,我们还需要这个结构体的长度,在InetAddr.hpp文件里新增一个接口。
cpp
socklen_t NetAddrLen() { return sizeof(_addr); } // 长度
所以从此往后,我们就再也不用每次写网络服务的时候都填充一下结构体信息,直接用InetAddr类就行。
此时我们再在服务端bind。
cpp
// TcpServer.hpp文件
#pragma once
#include "Common.hpp"
#include "InetAddr.hpp"
#include "MyLog.hpp"
using namespace MyLog;
class TcpServer : public NoCope // TcpServer类public继承NoCope类
{
public:
TcpServer(uint16_t port)
: _port(port),
_sockfd(-1)
{
}
void Init()
{
// 1.创建套接字
_sockfd = socket(AF_INET, SOCK_STREAM, 0);
if (_sockfd < 0)
{
LOG(LogLevel::FATAL) << "创建套接字失败";
exit(ExitCode::SOCKET_ERR);
}
LOG(LogLevel::INFO) << "create socket success, sockfd: " << _sockfd;
// 2.bind
InetAddr local(_port);
int n = bind(_sockfd, local.NetAddrPtr(), local.NetAddrLen());
}
~TcpServer() {}
private:
uint16_t _port; // 端口号
int _sockfd;
};
bind可能会失败,如果失败就输出一条日志信息,然后退出,这里退出时退出码依旧自己设置。
cpp
// Common.hpp文件
enum ExitCode
{
normal = 0,
SOCKET_ERR, //套接字出错
BIND_ERR //bind出错
};
cpp
// TcpServer.hpp文件
void Init()
{
// 1.创建套接字
_sockfd = socket(AF_INET, SOCK_STREAM, 0);
if (_sockfd < 0)
{
LOG(LogLevel::FATAL) << "创建套接字失败";
exit(ExitCode::SOCKET_ERR);
}
LOG(LogLevel::INFO) << "create socket success, sockfd: " << _sockfd;
// 2.bind
InetAddr local(_port);
int n = bind(_sockfd, local.NetAddrPtr(), local.NetAddrLen());
if(n < 0)
{
LOG(LogLevel::FATAL) << "bind 失败";
exit(ExitCode::BIND_ERR);
}
LOG(LogLevel::INFO) << "bind succes, sockfd: " << _sockfd;
}
到目前为止其实和UDP没啥区别。
2.3 设置socket状态为listen
将socket状态设置为listen就要用到listen接口。成功返回0,失败返回-1,错误码被设置。

第一个参数是sockfd,第二个参数后面再细说,这里暂时可以设为8。
如果设置失败也是打印日志然后退出,退出码继续加。
cpp
// Common.hpp文件
enum ExitCode
{
normal = 0,
SOCKET_ERR,
BIND_ERR,
LISTEN_ERR
};
cpp
void Init()
{
// 1.创建套接字
_sockfd = socket(AF_INET, SOCK_STREAM, 0);
if (_sockfd < 0)
{
LOG(LogLevel::FATAL) << "创建套接字失败";
exit(ExitCode::SOCKET_ERR);
}
LOG(LogLevel::INFO) << "create socket success, sockfd: " << _sockfd;
// 2.bind
InetAddr local(_port);
int n = bind(_sockfd, local.NetAddrPtr(), local.NetAddrLen());
if (n < 0)
{
LOG(LogLevel::FATAL) << "bind 失败";
exit(ExitCode::BIND_ERR);
}
LOG(LogLevel::INFO) << "bind succes, sockfd: " << _sockfd;
// 3.设置listen状态
n = listen(_sockfd, 8);
if (n < 0)
{
LOG(LogLevel::FATAL) << "listen 失败";
exit(ExitCode::LISTEN_ERR);
}
LOG(LogLevel::INFO) << "listen succes";
}
2.4 测试和查询
cpp
// TcpServer.cc文件
#include "TcpServer.hpp"
#include <memory>
void Usage(std::string proc)
{
std::cerr << "Usage: " << proc << " port" << std::endl;
}
int main(int argc, char *argv[])
{
if (argc != 2)
{
Usage(argv[0]);
exit(ExitCode::USAGE_ERR);
}
uint16_t port = std::stoi(argv[1]);
LogToConsole();
std::unique_ptr<TcpServer> tcp_server = std::make_unique<TcpServer>(port);
tcp_server->Init();
while(1); // 先让程序不退出
return 0;
}

没有任何问题。
然后我们可以用指令查一下这个服务器。
- netstat [选项]:查看网络连接状态,选项顺序不固定[a所有,n数字化,t表示显示TCP,p显示进程信息]

这里用a选项打印出来的太多了,还有一个选项是l,表示listen状态。

我们刚刚启动的TcpServer就是端口号为8080的这个,状态为listen状态。
- telnet :远程登陆指定的Tcp服务
先用telnet 127.0.0.1 8080连接TCP服务,然后用 netstat -natp | grep 8080 查看所有端口号是8080的。

所以只要Tcp处于listen状态就可以被连接。
3.启动服务器
3.1 获取连接
accept函数获取链接。这个函数的后两个参数其实等同于recvfrom函数后两个参数,是输出型参数。获取链接时要知道是谁连接的你。

我们获取的链接在哪里?对于服务器来说,就算没有调用accept,连接照样获取成功,建立连接这件事不需要accept的参与,说明我们获取的链接是从内核中直接获取的,而建立连接的过程与accept无关。所以我们的服务器有链接不是因为有accept,而是因为他是listen状态的。
这个函数的返回值比较特殊,失败返回-1,成功返回的是一个文件描述符。

但是我们之前也有一个sockfd,这个新的sockfd和accept函数返回的sockfd有什么区别?
举个例子,我们去某些餐厅吃饭的时候,餐厅外会有专门揽客的服务员张三,我们决定进这家餐厅吃饭的时候,这个服务员会招呼另外一个服务员李四服务我们,张三继续揽客,如果有别人也进来吃饭,张三又会叫另一个服务员王五服务他们,张三继续揽客...
上述例子中的揽客的张三就是之前的sockfd,我们一般叫做listen_sockfd,只用来从操作系统获取链接,而店内的其他服务员李四王五等们就是accept返回的sockfd,这个sockfd才是真正执行操作的文件描述符。
所以我们之前创建的都是listen_sockfd。把_sockfd改名为_listen_sockfd,然后还要加一个_isrunning标志程序运行。
如果accept失败,就相当于张三揽客失败,失败就失败呗,继续揽客,也就是accept失败了我们输出一条warning就行,然后继续获取链接即可;如果成功,就输出一下是谁连接的。
cpp
// TcpServer.hpp文件
#pragma once
#include "Common.hpp"
#include "InetAddr.hpp"
#include "MyLog.hpp"
using namespace MyLog;
// const static int backlog = 8;
class TcpServer : public NoCope // TcpServer类public继承NoCope类
{
public:
TcpServer(uint16_t port)
: _port(port),
_listen_sockfd(-1),
_isrunning(false)
{
}
void Init()
{
// 1.创建套接字
_listen_sockfd = socket(AF_INET, SOCK_STREAM, 0);
if (_listen_sockfd < 0)
{
LOG(LogLevel::FATAL) << "创建socket失败";
exit(ExitCode::SOCKET_ERR);
}
LOG(LogLevel::INFO) << "create listen socket success, sockfd: " << _listen_sockfd;
// 2.bind
InetAddr local(_port);
int n = bind(_listen_sockfd, local.NetAddrPtr(), local.NetAddrLen());
if (n < 0)
{
LOG(LogLevel::FATAL) << "bind 失败";
exit(ExitCode::BIND_ERR);
}
LOG(LogLevel::INFO) << "bind succes, sockfd: " << _listen_sockfd;
// 3.设置listen状态
n = listen(_listen_sockfd, 8);
if (n < 0)
{
LOG(LogLevel::FATAL) << "listen 失败";
exit(ExitCode::LISTEN_ERR);
}
LOG(LogLevel::INFO) << "listen succes";
}
void Run()
{
if(_isrunning) // 不能重复启动
return;
_isrunning = true;
// 获取链接
while(_isrunning)
{
struct sockaddr_in peer;
socklen_t len = sizeof(sockaddr_in);
// 没有链接时,accept会被阻塞
int sockfd = accept(_listen_sockfd, CONV(peer), &len);
if(sockfd < 0)
{
LOG(LogLevel::WARNING) << "accept error";
continue;
}
InetAddr local(peer); // 这里需要网络转主机
LOG(LogLevel::INFO) << "accept success " << local.StringAddr();
}
_isrunning = false;
}
~TcpServer() {}
private:
uint16_t _port; // 端口号
int _listen_sockfd;
bool _isrunning;
};
让TcpServer运行起来。
cpp
// TcpServer.cc文件
#include "TcpServer.hpp"
#include <memory>
void Usage(std::string proc)
{
std::cerr << "Usage: " << proc << " port" << std::endl;
}
int main(int argc, char *argv[])
{
if (argc != 2)
{
Usage(argv[0]);
exit(ExitCode::USAGE_ERR);
}
uint16_t port = std::stoi(argv[1]);
LogToConsole();
std::unique_ptr<TcpServer> tcp_server = std::make_unique<TcpServer>(port);
tcp_server->Init();
tcp_server->Run();
return 0;
}
没有链接的时候accept会阻塞住。
我们用telnet连一下。

连接成功就打印相应的信息。
3.2 收消息
Tcp这里收消息也就是读消息,可以直接用read系统调用。

从指定的文件描述符里读数据,读到buffer里,读count个字节,返回实际读取的字节数。
cpp
// TcpServer.hpp文件
void Service(int sockfd, InetAddr &peer)
{
char buffer[1024];
while (true)
{
// b.收消息
ssize_t n = read(sockfd, buffer, sizeof(buffer) - 1);
if(n > 0)
{
buffer[n] = 0;
LOG(LogLevel::DEBUG) << "Read message, " << peer.StringAddr() << "# " << buffer;
}
}
}
void Run()
{
if (_isrunning) // 不能重复启动
return;
_isrunning = true;
// a.获取链接
while (_isrunning)
{
struct sockaddr_in peer;
socklen_t len = sizeof(sockaddr_in);
// 没有链接时,accept会被阻塞
int sockfd = accept(_listen_sockfd, CONV(peer), &len);
if (sockfd < 0)
{
LOG(LogLevel::WARNING) << "accept error";
continue;
}
InetAddr local(peer); // 这里需要网络转主机
LOG(LogLevel::INFO) << "accept success " << local.StringAddr();
// 执行任务
Service(sockfd, local);
}
_isrunning = false;
}
我们实现的是EchoServer,客户端发什么服务器就回显什么。
发消息也就是写数据,可以直接用write函数。

cpp
void Service(int sockfd, InetAddr &peer)
{
char buffer[1024];
while (true)
{
// b.收消息
ssize_t n = read(sockfd, buffer, sizeof(buffer) - 1);
if (n > 0)
{
buffer[n] = 0;
LOG(LogLevel::DEBUG) << "Read message, " << peer.StringAddr() << "# " << buffer;
// c.发消息
std::string echo_string = "Server echo$ ";
echo_string += buffer;
write(sockfd, echo_string.c_str(), echo_string.size());
}
}
}
再运行程序,用telnet链接。

read的返回值n有三种情况:
n>0读取成功,n<0读取失败
n==0表示在读取的时候对端把链接关闭了,相当于读到了文件的结尾。
所以这里我们还需要加上如果n为0和n<0的情况。
cpp
void Service(int sockfd, InetAddr &peer)
{
char buffer[1024];
while (true)
{
// b.收消息
ssize_t n = read(sockfd, buffer, sizeof(buffer) - 1);
if (n > 0)
{
buffer[n] = 0;
LOG(LogLevel::DEBUG) << "Read message, " << peer.StringAddr() << "# " << buffer;
// c.发消息
std::string echo_string = "Server echo$ ";
echo_string += buffer;
write(sockfd, echo_string.c_str(), echo_string.size());
}
else if (n == 0)
{
LOG(LogLevel::DEBUG) << peer.StringAddr() << "退出...";
close(sockfd);
break;
}
else
{
LOG(LogLevel::DEBUG) << peer.StringAddr() << "读取异常";
close(sockfd);
break;
}
}
}
验证一下,我们先正常连接,正常发消息,然后telnet退出,退出后Server端也会显示这个用户推出。

但是这个服务器当被多个客户连接的时候,只能处理一个。因为目前写的是单进程程序,只有一个进程accep。
下篇将会实现多线程的服务器,以及把客户端实现出来,我们下篇见~
