其实经过这几天写的几种不同的UDP的简易客户端与服务端,还是很有套路的,起手式都是非常像的。
更多的难点对我来说反而是解耦,各种各样的function一用,回调函数一调,呕吼,就会懵一下。
对于这篇文章,我主要是把那些起手式,还有我觉得有点难得解耦方式稍微进行一下说明,方便我自己回顾,当然如果可以帮助更多的小伙伴那自然是更好啦。

Echo

服务端起手式

这个echo的代码是为了熟悉起手式,因为几乎没有业务的附带,所以是很简单的。
而它的功能就是你向服务器中发送消息,你的服务端会重新发给你。

注意:日志真的很重要,可以让你知道你的程序在哪一步出错了,很快的定位。

首先大概的看一下起手式接口:
因为我们的网络要通信需要IP + 端口号才能定位到具体主机内具体进程,而IP + port就是我们说的套接字,关于套接字在UDP中需要知道2个接口,但是这两个接口中我们注意到有一个sockaddr 结构体,因此我们需要看一下结构体。

// 创建 socket 文件描述符 (TCP/UDP, 客户端 + 服务器)
int socket(int domain, int type, int protocol);
// 绑定端口号 (TCP/UDP, 服务器)
int bind(int socket, const struct sockaddr *address,
socklen_t address_len);

在这里插入图片描述
首先,这组接口不仅可以实现网络通信,也可以实现主机内通信。
其中sockaddr_in是网络,sockaddr_un是主机内,那么为什么bind接口内是sockaddr?

因为我们会填16位地址类型,所以当我们将对应的结构体强转为sockaddr *时,函数内部就会根据16位地址类型判断究竟是哪一种,这也就是C语言层面的多态!

首先,我们的server是面向对象的,代码中没有定义就出现变量都是私有成员。根据名字都可以知道大概意思,在最后会有完整代码。

 int fd = ::socket(AF_INET, SOCK_DGRAM, 0);
 if (fd < 0)
 {
     exit(1);
 }
 _socketfd = fd;
 // 将Ip Port与套接字绑定
 struct sockaddr_in addr;
 memset(&addr, 0, sizeof(addr));
 addr.sin_family = AF_INET;
 // 不要忘记转为网络序列!
 // addr.sin_addr.s_addr = inet_addr(_ip.c_str());
 addr.sin_addr.s_addr = INADDR_ANY;
 addr.sin_port = htons(_port);
 // bind
 int n = ::bind(_socketfd, (struct sockaddr *)&addr, sizeof(addr));

再来解释一下代码

第一个参数:

因为我们是网络通信,所以16位网络地址选择使用AF_INET

第二个参数:

我们选择的是UDP
在这里插入图片描述
也就是无连接,不可靠,数据报。

第三个参数:

表示希望使用的协议,我们通常设置为0,系统会根据情况自己处理。

返回值:

socket返回的是一个文件描述符在这里插入图片描述
为什么返回文件描述符?

我们在此感性的理解
因为我们网络的通信是建立在网卡上的,而linux中一切皆文件,所以就相当于我们返回的是网卡的文件描述符。

于是我们的socket就创建好了,但是还要与IP与port进行绑定起来。
那么就先要创建一个sockaddr_in的结构体填参

sin我们可以理解为socket Internet。
其中我们只关心图中的框起来部分,sin_zero是作为将结构体补齐用的,新的网络编程库中甚至都见不到这个字段了。
在这里插入图片描述

我们逐个分析一下这个要填的3个字段


在这里插入图片描述
第一个参数的形式是一个宏,在预处理阶段会进行处理,进行替换得到sa_family_t sin_family##在预处理阶段会将两边的字符串进行拼接)。
而这个填的就是AF_INET,与套接字对应。


第二个参数是一个无符号短整型,uint16_t的类型,我们填入自定义端口即可。
注意:由于我们要注意网络序列与主机序列的转换,自己进行判断的话过于麻烦,OS也提供了一组接口方便我们进行转换。在这里插入图片描述


第三个参数是IP
注意这个IP可是有很大讲究的,
首先他是结构体内嵌套结构体,填的时候要注意
其次我们刚开始肯定觉得绑定自己的公网IP,或者局域网IP,又或者是本地环回。
但是如果填一个具体的IP,那么就意味着你以后只能从这一个向指定的IP中获取信息,但是你的主机IP有多个,反而不能全部利用,因此这里我们选择填入INADDR_ANY(0)。
在这里插入图片描述

此时我们就可以接收多个IP+端口号发送来的信息了。

注意:我们一般在进行网络测试时,一般会使用本地环回IP测试,也就是127.0.0.1,当你的客服端向127.0.0.1这个IP发送时,那么就不会在网络中传输,而是在本机。
在这里插入图片描述
那么此时我们就完成起手式,socket的创建与绑定了。

服务端LOOP

while (true)
{
    char buffer[1024];
    sockaddr_in peer;
    socklen_t len = sizeof(peer);
    int n = recvfrom(_socketfd, buffer, sizeof(buffer) - 1, 0, (sockaddr*)&peer, &len);
    if (n != -1)
    {
        buffer[n] = 0;
        
        int m = sendto(_socketfd, buffer, n, 0, (struct sockaddr*)&peer, sizeof(peer));
        if (m == -1)
        {
            perror("发送错误");
            break;
        }
    }
}

我们的服务端肯定是要进行接收消息的,然后在做一些加工返回给客服端。
这里就不得不说两个函数了。
在这里插入图片描述
在这里插入图片描述
他们的参数都非常的类似,在接收时我们传入一段缓冲区,填入大小,即可得到客户端发来的消息了,因为我们接收后还要发送给对方,所以后边的两个参数是输入型参数,会得到对方的sockaddr信息。

对于发送时,我们也是如此。
另外:它们的 flags 参数是用来控制函数行为的标志位,允许程序员指定一些特殊的选项或操作模式。flags 参数通常是多个标志的按位或(OR)组合,但大多数情况下,这些标志并不是必需的,因此绝大多数会传递0作为默认值。

客户端起手

与服务端有很大的不同。

int fd = socket(AF_INET, SOCK_DGRAM, 0);
if (fd == -1)
{
    exit(1);
}

先说结论:我们在服务端只需要创建socket即可,肯定需要bind,但无需显示bind,因为会在sendto中OS会自主绑定。

为什么不需要自己指定IP + 端口号?
OS肯定是知道你的IP,那么OS给你绑定也说得过去,那么端口号为什么不自主绑定?
我们举一个例子,一个主机上的端口号是有限的,如果客户端是自主定义,那么可能不同客户端会出现重复!比如抖音用端口号8888,那么快手绑定8888是势必不成功,因为IP + port标识一个唯一进程。所以这个由OS自主分配即可。

客户端LOOP

while (true)
{
    std::string buffer;
    std::cout << "Please write msg:";
    std::getline(std::cin, buffer);
    
    // 处理sockaddr结构体 + 发送数据到服务端
    struct sockaddr_in peer;
    peer.sin_addr.s_addr = inet_addr(ip.c_str());
    peer.sin_port = htons(port);
    peer.sin_family = AF_INET;
    socklen_t len = sizeof(peer);
    int n = sendto(fd, buffer.c_str(), buffer.size(), 0, (struct sockaddr *)&peer, len);
    if (n != -1)
    {
        char inbuffer[1024];
        struct sockaddr_in temp;
        socklen_t len;
        int m = recvfrom(fd, inbuffer, n, 0, (struct sockaddr *)&temp, &len);
        if (m != -1)
        {
            inbuffer[m] = 0;
            std::cout << inbuffer << std::endl;
        }
    }
}

我们在服务端绑定时不需要填真正的IP,发送和接收时也直接使用现成的,但是在客户端我们需要手动填写,但是我们现在有的是一个字符串,我们压迫将他转为4字节,还要转为网络序列,这也是很繁琐的,因此也有一批函数用来转化。
在这里插入图片描述
可以看到我们放入一个字符串地址即可得到网络序列4字节IP,非常的方便,但是这里可以改进(我们最后说,一般使用inet_pton)。

所以我们现在也就没啥干货了,起手式已经完成!

验证

由于上图代码都是非常简略的,并没有将如何封装写出,但是还是要验证一下的
在这里插入图片描述
完整代码在Gitee链接中给出。
在这里插入图片描述

Dict

设计思想

我们在以上的基础上增加一些业务,这里也就开始涉及一些解耦的设计了。

我们的理想效果为输入一个单词返回他的意思。

其实服务器与客户端大的逻辑仍旧是不变的,但是这里进行设计解耦的思想是很好的,要学习!
首先我们要改变的就是服务端,我们在recvfrom到字符串单词后可以使用回调函数,将这个单词交给外部来做,返回汉语意思字符串sendto。这样就很好的完成了解耦,因为我们要用到回调函数,所以也就意味着在构造服务端对象时要传入可调用对象。

此时我们就可以利用function进行包装,包装出一个可调用对象类型。

using func_t = std::function<std::string(std::string)>; 

关于这里我其实还想补充几点
我们命名时可以看到func_t中的func代表这是函数,t代表typename表示类型,这样别人一看就知道这是一个函数类型。
而命名空间时也有这样的讲究,比如我们有一个日志类,使用log_ns域封装起来,ns就是namespace的缩写,也是一目了然。

另外就是关于function的一些点了,实际上我们的function绑定时与被绑定的函数类型并不需要完全相符,就像下图这样的代码甚至可以编过,我一点都不理解…
在这里插入图片描述
但是我认为还是最好保证一样。


回到主线:
由于我们希望解耦,因此function中我们也就没有必要传引用,就解耦解的结结实实(但实际上传也是可以的,还避免了拷贝)。
随后我们再编写一个字典类,最终要的是要有支持翻译的功能!

我们可以选择搞一个配置文件的形式,创建对象时进行加载即可~
在这里插入图片描述

const std::string sep = ": ";// 单词与翻译的分隔符

class Dict
{
private:
    void Load()
    {
        std::ifstream in(_path.c_str());
        if (!in.is_open())
        {
            exit(1);
        }

        std::string line;
        while (std::getline(in, line))
        {
            std::string key;
            std::string value;
            auto pos = line.find(sep);
            if (pos == -1)
                continue;
            key = line.substr(0, pos);
            value = line.substr(pos + sep.size());
            _map.insert(std::make_pair(key, value));
        }
    }

public:
    Dict(const std::string & path)
        : _path(path)
    {
        Load();
    }
    std::string GetChinese(std::string word)
    {
        auto ite = _map.find(word);        
        if (ite != _map.end())
            return ite->second;
        else
            return "None";
    }
    ~Dict()
    {}
private:
    std::unordered_map<std::string, std::string> _map; 
    std::string _path;
};

其中GetChinese函数就是我们未来在服务端回调的函数!

但是此时要注意,我们不能直接将这个函数传入服务器的构造函数中,因为这是一个静态成员函数!所以我们需要将这个函数bind一下,让this指针隐式写入即可!

我觉得这就是最精髓的地方了。
代码见链接

验证

在这里插入图片描述

Chat

这是一个聊天室项目,我觉得是还算挑战性的,但实际上只是套的层数有点的,好多整合在一起。
听说Java那更喜欢各种封,各种套,什么结构啥的,害怕~

但是对于当前的chat聊天室来说最重要的搞清楚整体的大框架。
不仅仅是对于当前的聊天室,甚至可以说是任何比较嵌套的,只要把结构搞清楚了,那么就会轻松很多。
在这里插入图片描述
我们现在直接使用以上的服务端 + 客户端进行改进即可。

线程池的代码

服务端的修改

我们在字典中已经学到了在服务端使用回调进行业务处理,我们当然也可以使用回调完成转发!

我们进行转发需要3个元素。
sockfd描述符, message消息体,sockaddr结构体。
因此我们的回调设置为这样子即可。

这里的Inet就是我们带码云中封装过的转化类,

using service_t = std::function<void(int, const std::string &, const Inet &)>;

在服务器端上一个版本原本调用处理获取翻译的地方更改一下即可~

所以又到了设计转发类的时候了,我们一般喜欢在应用层中把这个工作叫做路由。
也就是设计一个路由类。

我们这样设计:当服务端回调到路由模块时,我们就得到了sockfd,message,addr,

  1. 首先检查当前ip + port是否在在线列表中,不在就add,在了就不管。
  2. 当消息为QUIT或者Q时,将在线列表中的user删除
  3. 转发我们只需要遍历一遍在线用户列表即可

也就是转发时使用线程池。

class Route
{
public:
    Route()
    {}
    void CheckOnlineUsers(const Inet &inet_addr)
    {
        _online_users.insert(inet_addr)
    }
    void Offline(const Inet &inet_addr)
    {
        LOG(DEBUG, "%s offline\n", inet_addr.AddrStr().c_str());
        _online_users.erase(inet_addr);
    }
    void ForwardHelper(int socket, const std::string &message)
    {
        for (auto &user : _online_users)
        {
            sockaddr_in peer = user.Sockaddr();
            socklen_t len = sizeof(peer);
            int n = ::sendto(socket, message.c_str(), message.size(), 0, (struct sockaddr *)&peer, len);
        }
    }
    void Forward(int socket, const std::string &message, const Inet &inet_addr)
    {
        CheckOnlineUsers(inet_addr);

        if (message == "Q" || message == "QUIT")
        {
            Offline(inet_addr);
        }
        // 转发模块,线程池去执行
        std::function<void()> f = std::bind(&Route::ForwardHelper, this, socket, send_message);
        ThreadPool<std::function<void()>>::GetInstance()->Equeue(f);
    }
    ~Route()
    {}

private:
    std::set<Inet, Route_ns::comp> _online_users;
    pthread_mutex_t _mutex;
};

注意到我们的线程池中只需要push进去一个可调用对象即可,所以我们进行bind一下以进行适配线程池模板。

而我们在进行构造客户端时传入Route类中的Forward函数即可~
依旧和Dict服务器一样的方法套路。

这样服务端就设计好了

客户段的修改

我们当前的客户端首先是有问题的,因为我们当前只有一个线程同时进行收和发,当我们多起几个客户端时,如果客户端A进行发消息,其他的客户端其实都不会显示的,因为只有别的客户端进行sendto时才会收到消息,否则就一直阻塞在sendto中。

所以这里我们也是用多线程进行一下修改,一个线程一直读,一个一直进行发送。

main函数中我们创建2个线程,分别执行各自的读和写,这里就没什么细节了。

int ClientInit()
{
    int fd = socket(AF_INET, SOCK_DGRAM, 0);
    if (fd == -1)
    {
        LOG(FATAL, "create socket err");
        exit(1);
    }
    return fd;
}

void receiver(const std::string &name, int socketfd)
{
    while (true)
    {
        char inbuffer[1024];
        struct sockaddr_in temp;
        socklen_t len;
        int n = recvfrom(socketfd, inbuffer, sizeof(inbuffer) - 1, 0, (struct sockaddr *)&temp, &len);
        if (n >= 0)
        {
            inbuffer[n] = 0;
            std::cerr << inbuffer << std::endl;
        }
    }
}

void sender(const std::string &name, int socketfd, const std::string &ip, uint16_t port)
{
    while (true)
    {
        std::string buffer;
        std::cout << "Please input msg:";
        std::getline(std::cin, buffer);
        // 处理sockaddr结构体 + 发送数据到服务端
        struct sockaddr_in peer;
        peer.sin_addr.s_addr = inet_addr(ip.c_str());
        peer.sin_port = htons(port);
        peer.sin_family = AF_INET;
        socklen_t len = sizeof(peer);
        int n = sendto(socketfd, buffer.c_str(), buffer.size(), 0, (struct sockaddr *)&peer, len);
    }
}

int main(int args, char *argv[])
{
    // 处理命令行行参数
    if (args != 3)
    {
        std::cerr << "Usage:" << argv[0] << " Ip Port" << std::endl;
        exit(1);
    }
    std::string ip = argv[1];
    uint16_t port = std::stoi(argv[2]);

    int socketfd = ClientInit();

    MyThread t1("thread-receiver", std::bind(receiver, std::placeholders::_1, socketfd));
    MyThread t2("thread-sender", std::bind(sender, std::placeholders::_1, socketfd, ip, port));

    t1.Start();
    t2.Start();

    t1.Join();
    t2.Join();


    return 0;
}

代码链接

效果展示

在这里插入图片描述

完~~

点赞(0) 打赏

评论列表 共有 0 条评论

暂无评论

微信公众账号

微信扫一扫加关注

发表
评论
返回
顶部