C语言实现--基于UDP的多人在线聊天室

目录

实现功能

实现思想

实现代码(部分及详解)

服务端部分代码

客户端部分代码

实现效果

项目中出现的问题和解决方法

项目整体代码展示

代码优化思路

服务端代码

客户端代码


实现功能

  1. 服务端可以同时连接多个客户端;
  2. 新的客户端连接服务端时,可以在服务端显示自己的名字并提示登陆成功;
  3. 新客户端登陆成功时,其他客户端也会提示有新用户登陆;
  4. 客户端发送消息时,服务器内可以提示是哪个客户端发送了消息;
  5. 客户端发送消息时,其他客户端也可以接收到该客户端发送的消息内容;
  6. 服务端相当于主机,客户端相当于用户。

实现思想

  1. 客户端可以发送三种消息:登录消息(用户名),聊天消息,退出消息,所以需要定义枚举类型,包含这三种消息,当发送对应的消息内容时,将消息类型一起发送,接收消息的一端可以根据消息类型进入不同的函数进行处理。
  2. 服务器主要的功能就是转发消息,将某一用户发送的消息转发给其他用户,包括用户的登录,聊天和退出消息,还要可以给所有的用户主动发送通知。
  3. 聊天室要实现的是支持多人同时在线聊天,所以就需要使用链表的思想,将加入的用户依次链接,发送消息时依次遍历,退出时方便找到对应用户。
  4. 服务端和客户端都需要有发送消息和接收消息的功能,两种功能互不影响,所以需要创建子进程,由父进程完成消息的发送处理,由子进程完成消息的接收处理。
  5. 重点还是理解链表的思想。

实现代码(部分及详解)

服务端部分代码

        1. 定义枚举的三种消息类型:

  • 登录Login;
  • 聊天Chat;
  • 退出Quit。
//消息类型
enum type_t
{
    Login = 1, //登录
    Chat,      //聊天
    Quit,      //退出
};

        2. 定义描述发送的消息内容的结构体:

  • 消息类型type(枚举中的三种消息);
  • 姓名name(用户名);
  • 消息正文text(聊天信息以及退出消息)。

        将姓名和消息正文分开定义的原因是,用户发送消息时,需要体现出是哪个用户发送的消息,而且用户登录时要先取名,再发消息正文,如果使用一个定义的话,用户名会被消息正文覆盖。

//定义描述消息结构体
typedef struct msg_t
{
    int type;       //消息类型:登录  聊天  退出
    char name[32];  //姓名
    char text[128]; //消息正文
} MSG_t; //将结构体名重定义为MSG_t

        3. 定义链表的节点结构体:根据链表的特性,定义用户地址作为数据域,定义结构体指针作为指针域。

//链表的节点结构体
typedef struct node_t
{
    struct sockaddr_in addr; //数据域
    struct node_t *next;     //指针域
} link_t;

        4.主函数中子进程实现消息的发送处理:

  • 服务端不需要退出,所以使用while一直循环在子进程和父进程中,也不需要break跳出处理;
  • 子进程中需要接收消息并判断接收到消息的类型,然后根据消息类型调用对应的处理函数。
//创建一个空的有头单向链表
link_t *p = createLink();
while (1) 
{
   //接收客户端消息
   if(recvfrom(sockfd,&msg,sizeof(msg),0,(struct sockaddr *)&clientaddr,&len) < 0)
     {
        perror("recvfrom err.");
        return -1;
     }
    //判断消息类型,调用对应函数
     switch (msg.type)
     {
        case Login: //登录
          client_login(sockfd, p, clientaddr, msg);
          break;
        case Chat:  //聊天
          client_chat(sockfd, p, clientaddr, msg);
          break;
        case Quit:  //退出
          client_quit(sockfd, p, clientaddr, msg);
          break;
     }
}

        5.主函数中父进程实现消息的接收处理:

  • 父进程发送消息时,记得处理字符串尾部的换行符,否则客户端每次接收到消息后,都会多打印一个换行。
while (1) //服务器发通知
{
   msg.type = Chat;
   //给结构体中的数组成员变量赋值,一般使用strcpy进行赋值
   strcpy(msg.name, "server");
   //获取终端输入
   fgets(msg.text, sizeof(msg.text), stdin);
   //解决发送信息时,会将换行符发送过去的问题
   if (msg.text[strlen(msg.text) - 1] == '\n')
       msg.text[strlen(msg.text) - 1] = '\0';
   //将信息发送给同一局域网的其他客户端
   sendto(sockfd, &msg, sizeof(msg), 0, (struct sockaddr *)&serveraddr,
                   sizeof(serveraddr));
}

        6. 用户登录函数:

  • 用户登录时,会将用户自己的地址和用户名一起发送给服务端,所以在服务端函数中可以循环遍历链表,来告诉已经登录的其他用户新登录的用户是谁。
  • 然后将新登录的用户保存到当前的链表结尾。
  • 需要传递给该函数的参数有(其他函数也是这些形参):
    • 套接字描述符(因为要在函数中给其他用户发送消息);
    • 链表(头)指针(因为要遍历链表和创建新节点);
    • 新登录的用户地址(新用户加入链表时需要将用户地址保存到节点的数据域);
    • 消息结构体对象(MSG_t,函数中发送消息时需要发送新用户的名字和消息正文)。
//登录函数
void client_login(int sockfd, link_t *p, struct sockaddr_in clientaddr, MSG_t msg)
{
    //1.告诉其他用户登录的新用户是谁
    strcpy(msg.name,"server"); //发送消息的人是服务端
    sprintf(msg.text, "%s login!", msg.name); //服务端发送新登陆的用户名
    //循环发送给之前已经登陆的用户
    while (p->next != NULL)
    {
        p = p->next;
        sendto(sockfd, &msg, sizeof(msg), 0, (struct sockaddr *)&(p->addr), 
                    sizeof(p->addr));
    }

    //上面的代码运行结束后,此时链表指针已经指向了最后一个用户

    //2.创建一个新节点保存新连接的客户端地址 ,连接到链表结尾
    link_t *pnew = (link_t *)malloc(sizeof(link_t));
    if (pnew == NULL)
    {
        perror("malloc new node err.");
    }
    //初始化
    pnew->addr = clientaddr;
    pnew->next = NULL;
    //链接  p是最后一个节点的地址
    p->next = pnew;
}

        7. 聊天消息发送函数:

  • 用户发送聊天消息时,要将消息发送给链表中的,除了自己以外的其他所有用户,当然也要在服务端上打印;
  • 判断链表中是否为发送消息用户时,使用了memcmp函数。

int memcmp(const void *buf1, const void *buf2, unsigned int count);

功能:比较内存区域buf1和buf2的前count个字节;

参数:内存区buf1,内存区buf2,比较字节数count;

返回值:如果buf1 < buf2 则返回小于0的数,

如果buf1 = buf2 则返回0,

如果buf1 > buf2 则返回大于01的数;

//聊天信息发送函数
void client_chat(int sockfd, link_t *p, struct sockaddr_in clientaddr, MSG_t msg)
{
    //从链表头开始遍历
    while (p->next != NULL)
    {
        p = p->next;

        if(memcmp(&(p->addr), &clientaddr, sizeof(clientaddr)) != 0)
        {
            //只要判断出用户地址和发送消息的用户地址不同,就将消息发送给该用户
            sendto(sockfd, &msg, sizeof(msg), 0, (struct sockaddr *)&(p->addr),
                   sizeof(p->addr));
        }
    }
    //在服务器中打印发送的消息,“谁说了什么”
    printf("%s said %s\n", msg.name, msg.text);
}

        8. 用户退出处理函数:

  • 用户退出时,还是要在服务端遍历链表,而且是需要全部遍历;
  • 如果遍历到退出的那个用户,则将该用户对应的链表节点删除;
  • 如果遍历到其他用户,则依次发送用户要退出的信息。
//退出函数
void client_quit(int sockfd, link_t *p, struct sockaddr_in clientaddr, MSG_t msg)
{
    link_t *pdel = NULL;
    //从头开始遍历查找要删除的节点
    while (p->next != NULL)
    {
        //如果循环到的地址是要删除的用户,则删除该节点
        if (memcmp(&(p->next->addr), &clientaddr, sizeof(clientaddr)) == 0)
        {
            //删除指定用户
            pdel = p->next;
            p->next = pdel->next;

            free(pdel);
            pdel = NULL;
        }
        else
        {
            //如果不是要删除的用户,则向其发送指定用户要删除的消息
            sendto(sockfd, &msg, sizeof(msg), 0, (struct sockaddr *)&(p->next->addr),
                   sizeof(p->next->addr));
            p = p->next;
        }
    }
}

客户端部分代码

        1. 客户端和服务端一样需要定义枚举消息类型,和描述消息结构体类型;

        2. 在客户端中,一进入主函数,就应该进行用户的登录,向服务端发送登录信息

  • 主要是发送用户的地址和名字;
  • 名字只是为了提示服务端和其他用户,有新用户登录;
  • 服务端会将新用户的地址连接到链表结尾。
MSG_t msg;
//UDP客户端不用bind地址,可以直接发送自己的登陆消息
msg.type = Login; //登录
printf("please input login name>>");
fgets(msg.name, sizeof(msg.name), stdin);
if (msg.name[strlen(msg.name) - 1] == '\n')
    msg.name[strlen(msg.name) - 1] = '\0';
/*UDP客户端不用绑定地址,可以直接发送自己的登陆消息,
    只需要在sendto的参数中填写接收方的地址*/
sendto(sockfd, &msg, sizeof(msg), 0, (struct sockaddr *)&serveraddr,
       sizeof(serveraddr));

        3. 主进程中创建子进程并实现发送消息的功能

  • 用户发送的消息中,主要是聊天消息和退出消息 “quit” 。
  • 如果发送的消息字符串是 “quit” ,则先将该用户要退出的信息发送给服务端,然后再杀死父进程,子进程跳出循环后,在程序运行结束时被释放。
  • 如果发送的消息是正常的聊天信息,则只发送给服务端,然后子进程继续运行。
while (1) //发
{
    fgets(msg.text, sizeof(msg.text), stdin);
    if (msg.text[strlen(msg.text) - 1] == '\n')
                msg.text[strlen(msg.text) - 1] = '\0';
    //判断发送的消息是否为“quit”退出消息
    if (strncmp(msg.text, "quit", 4) == 0)
            {
                msg.type = Quit;
                sendto(sockfd, &msg, sizeof(msg), 0, (struct sockaddr *)&serveraddr,
                       sizeof(serveraddr));
                //杀死父进程
                kill(getppid(), SIGKILL);
                //退出循环,子进程结束
                break;
            }else{
                msg.type = Chat;
                sendto(sockfd, &msg, sizeof(msg), 0, (struct sockaddr *)&serveraddr,
                       sizeof(serveraddr));
            }
}

        4. 父进程中实现接收消息的功能

  • 用户接收到服务端转发的消息时,会在自己的界面打印,且会打印出详细信息,比如 “谁说了什么话” 。
while (1) //收
{
    if (recvfrom(sockfd, &msg, sizeof(msg), 0, NULL, NULL) < 0)
            {
                perror("recvfrom err.");
                return -1;
            }
    printf("%s said %s\n", msg.name, msg.text);
}

实现效果

        1. 运行服务端后,运行客户端进行连接;

  • 客户端成功连接服务端后,在服务端上显示用户名,以及新用户连接成功的消息;

  • 客户端向服务端发送消息,服务端可以打印用户名以及消息内容(我在客户端发消息时,测试打印了进程号,s 是子进程号,f 是父进程号);

         2. 连接第二个客户端;

  • 连接成功后,服务端会打印第二个客户端的用户名并提示连接成功,并且第一个用户也会提示新用户的连接;sprintf(msg.text, "%s login", msg.name);
        strcpy(msg.name, "server");

  • 不管是第一个用户发送消息还是第二个用户发送消息,另一个用户都能收到该消息,并且会提示是哪个用户发送的消息;

        3.连接第三个客户端

  • 三个客户端的实现效果

项目中出现的问题和解决方法

        问题一:a用户退出时,只会通知其他用户a用户退出,在服务端不显示a用户退出;

        解决上述问题后,当登陆的用户大于2时,除了最后一个用户退出时还是有上述问题,其他用户退出时都正常;

        上述两个问题可以用一句打印用户信息的代码解决,只不过需要将这句代码需要放到程序正确的位置,否则实现了问题1后很容易出现问题2,所以需要添加一句“printf("%s quit.\n", msg.name);”,而且要添加到“服务端程序中的用户退出函数的删除链表节点的代码中”或“服务端程序主函数中的switch判断消息类型是Quit的操作代码中”。

        问题二:客户端程序中,在子进程中一开始写了一句杀死父进程的代码,后来发现可能会出现孤儿进程的问题;

        尝试运行了一下之后,发现输入quit退出用户的时候,终端输出“killed”,表示父进程已被结束,而且使用命令查看进程时,子进程和父进程确实都已经退出,且查看后台进程时,子进程也不存在;后来分析代码发现,子进程杀死父进程之后,就跳出了while循环,然后程序向下依次运行,最后运行到“return 0”,子进程也正常结束;也可以在子进程的break上面加一句“exit(0)”保证子进程先退出,在向下运行到程序结束。

项目整体代码展示

代码优化思路

        我在编写代码时,只编写了两个文件,所以两个文件中有部分重复代码,而且服务端的函数全部跟主函数声明实现在一起,所以有以下优化思路:

  • 创建一个 “.h” 文件,将枚举的定义和描述消息的结构体定义放入该文件,且将服务端中函数的声明和实现放入该文件,服务端和客户端在头文件中包含该“.h”文件;
  • Makefile文件中补充“.h”文件;

        这里只展示优化前的代码;

服务端代码

#include <stdio.h>
#include <string.h>
#include <unistd.h>
#include <stdlib.h>
#include <sys/types.h>
#include <sys/socket.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <netinet/ip.h>
#include <arpa/inet.h>
#include <signal.h>

//消息类型
enum type_t
{
    Login = 1, //登录
    Chat,      //聊天
    Quit,      //退出
};

//定义描述消息结构体
typedef struct msg_t
{
    int type;       //消息类型:登录  聊天  退出
    char name[32];  //姓名
    char text[128]; //消息正文
} MSG_t;

//链表的节点结构体
typedef struct node_t
{
    struct sockaddr_in addr; //数据域
    struct node_t *next;     //指针域
} link_t;

link_t *createLink(void);
void client_login(int sockfd, link_t *p, struct sockaddr_in clientaddr, MSG_t msg);
void client_chat(int sockfd, link_t *p, struct sockaddr_in clientaddr, MSG_t msg);
void client_quit(int sockfd, link_t *p, struct sockaddr_in clientaddr, MSG_t msg);

int main(int argc, char const *argv[])
{
    int sockfd;
    sockfd = socket(AF_INET, SOCK_DGRAM, 0);
    if (sockfd < 0)
    {
        perror("socket err.");
        return -1;
    }

    //填充服务器的ip和port
    struct sockaddr_in serveraddr, clientaddr;
    serveraddr.sin_family = AF_INET;
    serveraddr.sin_port = htons(atoi(argv[1]));
    serveraddr.sin_addr.s_addr = htonl(INADDR_ANY);

    socklen_t len = sizeof(clientaddr);

    if (bind(sockfd, (struct sockaddr *)&serveraddr, sizeof(serveraddr)) < 0)
    {
        perror("bind err.");
        return -1;
    }
    MSG_t msg;
    //创建子进程,父进程接收客户端的信息并处理,子进程转发消息
    pid_t pid = fork();
    if (pid < 0)
    {
        perror("fork err.");
        return -1;
    }
    else if (pid == 0)
    {
        //创建一个空的有头单向链表
        link_t *p = createLink();
        while (1) //收到客户端的请求,处理请求
        {
            if (recvfrom(sockfd, &msg, sizeof(msg), 0, (struct sockaddr *)&clientaddr, &len) < 0)
            {
                perror("recvfrom err.");
                return -1;
            }
            switch (msg.type)
            {
            case Login: //登录
                client_login(sockfd, p, clientaddr, msg);
                break;
            case Chat:
                client_chat(sockfd, p, clientaddr, msg);
                break;
            case Quit:
                client_quit(sockfd, p, clientaddr, msg);
                break;
            }
        }
    }
    else
    {
        while (1) //服务器发通知
        {
            msg.type = Chat;
            //给结构体中的数组成员变量赋值,一般使用strcpy进行赋值
            strcpy(msg.name, "server");
            //获取终端输入
            fgets(msg.text, sizeof(msg.text), stdin);
            //解决发送信息时,会将换行符发送过去的问题
            if (msg.text[strlen(msg.text) - 1] == '\n')
                msg.text[strlen(msg.text) - 1] = '\0';
            //将信息发送给同一局域网的其他客户端
            sendto(sockfd, &msg, sizeof(msg), 0, (struct sockaddr *)&serveraddr,
                   sizeof(serveraddr));
        }
    }
    //程序结束时,关闭套接字描述符
    close(sockfd);
    return 0;
}

//链表函数 -- 创建一个空的有头单向链表
link_t *createLink(void)
{
    link_t *p = (link_t *)malloc(sizeof(link_t));
    if (p == NULL)
    {
        perror("malloc head node err.");
        return NULL;
    }
    p->next = NULL;
    return p;
}

//登录函数 -- 将客户端的clientaddr保存到链表中,循环链表告诉其他用户谁登录了
void client_login(int sockfd, link_t *p, struct sockaddr_in clientaddr, MSG_t msg)
{
    //1.告诉其他用户登录的新用户是谁
    strcpy(msg.name,"server"); //发送消息的人是服务端
    sprintf(msg.text, "%s login!", msg.name); //服务端发送新登陆的用户名
    //循环发送给之前已经登陆的用户
    while (p->next != NULL)
    {
        p = p->next;
        sendto(sockfd, &msg, sizeof(msg), 0, (struct sockaddr *)&(p->addr), sizeof(p->addr));
    }
    //上面的代码运行结束后,此时链表指针已经指向了最后一个用户
    //2.创建一个新节点保存新连接的客户端地址 ,连接到链表结尾
    link_t *pnew = (link_t *)malloc(sizeof(link_t));
    if (pnew == NULL)
    {
        perror("malloc new node err.");
    }
    //初始化
    pnew->addr = clientaddr;
    pnew->next = NULL;
    //链接  p是最后一个节点的地址
    p->next = pnew;
}

//聊天信息发送函数 -- 将消息转发给所有的用户,除去发送消息的自己
void client_chat(int sockfd, link_t *p, struct sockaddr_in clientaddr, MSG_t msg)
{
    //从链表头开始遍历
    while (p->next != NULL)
    {
        p = p->next;
        //memcmp函数,比较内存区域a和b的前n个字节
        //参数--区域a,区域b,比较字节数n
        //返回值--a<b返回负数,a=b返回0,a<b返回正数
        if(memcmp(&(p->addr), &clientaddr, sizeof(clientaddr)) != 0)
        {
            //只要判断出用户地址和发送消息的用户地址不同,就将消息发送给该用户
            sendto(sockfd, &msg, sizeof(msg), 0, (struct sockaddr *)&(p->addr),
                   sizeof(p->addr));
        }
    }
    //在服务器中打印发送的消息
    printf("%s said %s\n", msg.name, msg.text);
}

//退出函数 -- 将客户端的clientaddr从链表中删除,循环链表告诉其他用户谁退出了
void client_quit(int sockfd, link_t *p, struct sockaddr_in clientaddr, MSG_t msg)
{
    link_t *pdel = NULL;
    //从头开始遍历查找要删除的节点
    while (p->next != NULL)
    {
        //如果循环到的地址是要删除的用户,则删除
        if (memcmp(&(p->next->addr), &clientaddr, sizeof(clientaddr)) == 0)
        {
            //删除指定用户
            pdel = p->next;
            p->next = pdel->next;

            free(pdel);
            pdel = NULL;
        }
        else
        {
            //如果不是要删除的用户,则向其发送指定用户要删除的消息
            sendto(sockfd, &msg, sizeof(msg), 0, (struct sockaddr *)&(p->next->addr),
                   sizeof(p->next->addr));
            p = p->next;
        }
    }
}

客户端代码

#include <stdio.h>
#include <string.h>
#include <unistd.h>
#include <stdlib.h>
#include <sys/types.h>
#include <sys/socket.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <netinet/ip.h>
#include <arpa/inet.h>
#include <signal.h>

//消息类型
enum type_t
{
    Login = 1, //登录
    Chat,      //聊天
    Quit,      //退出
};

//定义描述消息结构体
typedef struct msg_t
{
    int type;       //消息类型:登录  聊天  退出
    char name[32];  //姓名
    char text[128]; //消息正文
} MSG_t;


int main(int argc, char const *argv[])
{
    int sockfd;
    sockfd = socket(AF_INET, SOCK_DGRAM, 0);
    if (sockfd < 0)
    {
        perror("socket err.");
        return -1;
    }

    //填充结构体
    struct sockaddr_in serveraddr;
    serveraddr.sin_family = AF_INET;
    serveraddr.sin_port = htons(atoi(argv[2]));
    serveraddr.sin_addr.s_addr = inet_addr(argv[1]);

    MSG_t msg;
    //UDP客户端不用bind地址,可以直接发送自己的登陆消息
    msg.type = Login; //登录
    printf("please input login name>>");
    fgets(msg.name, sizeof(msg.name), stdin);
    if (msg.name[strlen(msg.name) - 1] == '\n')
        msg.name[strlen(msg.name) - 1] = '\0';

    sendto(sockfd, &msg, sizeof(msg), 0, (struct sockaddr *)&serveraddr,
           sizeof(serveraddr));
    //创建父子进程:父进程收消息 子进程发送消息
    pid_t pid = fork();
    if (pid < 0)
    {
        perror("fork err.");
        return -1;
    }
    else if (pid == 0)
    {
        while (1) //发
        {
            fgets(msg.text, sizeof(msg.text), stdin);
            if (msg.text[strlen(msg.text) - 1] == '\n')
                msg.text[strlen(msg.text) - 1] = '\0';
            //判断发送的消息是否为“quit”退出消息
            if (strncmp(msg.text, "quit", 4) == 0)
            {
                msg.type = Quit;
                sendto(sockfd, &msg, sizeof(msg), 0, (struct sockaddr *)&serveraddr,
                       sizeof(serveraddr));
                //杀死父进程
                kill(getppid(), SIGKILL);
                //退出循环,子进程结束
                break;
            }
            else
            {
                msg.type = Chat;
                sendto(sockfd, &msg, sizeof(msg), 0, (struct sockaddr *)&serveraddr,
                       sizeof(serveraddr));
            }
        }
    }
    else
    {
        while (1) //收
        {
            if (recvfrom(sockfd, &msg, sizeof(msg), 0, NULL, NULL) < 0)
            {
                perror("recvfrom err.");
                return -1;
            }
            printf("%s said %s\n", msg.name, msg.text);
        }
    }
    close(sockfd);
    return 0;
}

  • 6
    点赞
  • 30
    收藏
    觉得还不错? 一键收藏
  • 1
    评论
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值