【Linux网络】Socket编程TCP-实现Echo Server(上)

本篇将基于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。

下篇将会实现多线程的服务器,以及把客户端实现出来,我们下篇见~

相关推荐
少年已不再年少年轻以化为青年2 小时前
VirtualBox下虚拟机即可访问互联网,又可访问主机
运维·服务器·网络
爱奥尼欧2 小时前
【Linux笔记】网络部分——数据链路层mac-arp
linux·网络·笔记
Evan_ZGYF丶2 小时前
深入解析CFS虚拟运行时间:Linux公平调度的核心引擎
linux·驱动开发·嵌入式·bsp
❥ღ Komo·2 小时前
Elasticsearch单机部署全指南
运维·jenkins
CHN悠远2 小时前
debian13 安装钉钉后,钉钉无法运行问题的解决办法
linux·运维·服务器·钉钉·debian13
祎直向前2 小时前
在Ubuntu中下载gcc
linux·运维·ubuntu
guygg882 小时前
Rocky Linux 8.9配置Kubernetes集群详解,适用于CentOS环境
linux·kubernetes·centos
liu****2 小时前
11.Linux进程信号(三)
linux·运维·服务器·数据结构·1024程序员节
csdn_aspnet2 小时前
CentOS 7 上安装 MySQL 8.0
linux·mysql·centos