1、概述
1、引言
有了对网络部分相关知识的了解和扩充以后,我在想着如何利用网络的一系列编程流程来实现一个可以在客户端和服务器两端分别实现的项目。基于对大量OJ题的练习,便引发了我对在线编译系统的探究和思考。
比如我们平时在力扣或者牛客上进行刷题的时候,我们作为一个用户角色在题库里面选择了一道练习题过后便开始编写代码,代码编写完毕后只需点击开始执行的按钮,系统就会出现这道题的编译结果
- 如果编译正确的话,我们再点击代码提交,系统就会自动运行,将运行结果显示
- 如果编译错误的话,就直接把编译出错信息反馈显示
2、思想
有了基于现实事物的发现,便引发了我对在线编译系统的思考。是否能实现一个在线编译系统使得用户在客户端能够编写自己想编写的语言的代码,然后将代码发送给服务器解析这段代码,最后服务器把结果发送给客户端。
关于实现该项目所用的知识储备,请见我写的另外一篇博客在线编译系统前期准备
2、项目综述
2.1项目功能描述
在线编译系统中分别在客户端和服务器两端具备了如下的功能:
1、客户端
- 允许用户选择不同的语言,比如说:C,C++。
- 提供用户编写代码的功能,将用户编写代码保存到本地
- 将用户编写的代码传输到服务器
- 能够接受服务器处理结果并显示
2、服务器
- 接受客户端传输的数据,包括语言类型和代码
- 能根据用户选择的语言类型对代码进行编译,编译完成后有两种结果
编译成功的话
:将编译的可执行文件执行,将执行结果发送给客户端
编译失败的话
:将出错信息反馈给客户端
2.2项目框架
因为整个代码的实现分为了客户端和服务器端的实现,因此主函数里面各自的流程图如下图所示。
3、项目功能具体实现
3.1服务器功能实现
(1)主函数
1、TCP建立连接——CreateSocket
因为我们使用的TCP的编程流程,所以首先要通过三次握手的方式建立客户端和服务器的连接。在服务器一端就应该实现socket()、bind()还有listen()
一系列流程。从而得到socket套接字sockfd。其中有几个点是需要注意的。
- IP地址的设置,如果就在一台机器上测试的话就设置为回环地址“127.0.0.1”即可。但是要实现两台主机一个模拟客户端一个模拟服务器的通讯就要把这个IP地址设置为服务器主机的IP地址。
- listen的第二个参数是内核维护的完成三次握手的连接,一般设置为5.
2、创建内核事件表,实现epoll的I/O复用
因为我们要实现高效的epoll I/O复用。所以首先要通过epoll_create
创建一个内核事件表epfd。然后将套接字sockfd使用cepoll_ctl
的方式添加到内核事件表当中,关注的事件为读事件。最后使用epoll_wait
来监听内核事件表,返回就绪事件。
(2)处理就绪事件
服务器应该就绪的文件描述进行处理,整个流程如下图所示:
1、获取新的客户端链接
当有新的客户端链接造成sockfd就绪的时候就需要获取新的客户端链接 。通过socket编程里面的accept
函数获取。再将该连接关注的事件类型设置为可读和断开异常事件并采取ET模式通过epoll_ctl
添加到内核事件表当中。最后将监听的文件描述符通过fcntl
函数设置为非阻塞
void GetNewClient(int sockfd,int epfd)
{
struct sockaddr_in cli;
socklen_t len = sizeof(cli);//保存连接的客户端信息
int fd = accept(sockfd,(struct sockaddr*)&cli,&len);
if(fd < 0)
{
return;
}
printf("客户端%d已连接\n",fd);
//设置新的链接关注的事件并将其添加到内核事件表中
struct epoll_event event;
event.data.fd = fd;
event.events = EPOLLIN | EPOLLRDHUP | EPOLLET;
epoll_ctl(epfd,EPOLL_CTL_ADD,fd,&event);
int flag = fcntl(fd,F_GETFL);//获得文件描述符状态
flag = flag | O_NONBLOCK;//将状态设置为非阻塞
fcntl(fd,F_SETFL,flag);//将非阻塞状态设置给文件描述符
}
2、处理客户端数据
对于客户端传来的数据首先服务器要对其接收然后再执行编译、运行和反馈一系列操作。具体流程图如下:
在这个过程中发送结果分为两个内容。如果编译失败了发送编译失败提示信息结果,如果编译成功了要进行下一步执行,要把执行结果发送给客户端。
void DealClientData(int fd)
{
//3.1接收客户端的数据,将代码存储到本地文件中
int language = RecvCoding(fd);
//3.2编译代码,将编译结果存储到编译错误文件中
int flag = BuildCoding(language);
if(flag == 0)
{
//3.3执行代码,结果存储到文件中
Carry(language);
//3.4发送执行结果
SendResult(fd,flag);
}
else
{
//发送编译失败执行结果
SendResult(fd,flag);
}
}
2.1 接收客户端数据
在RecvCoding的函数中要实现接受客户端数据并返回用户传递的语言类型的功能。
接收协议头
:协议头的结构体里面包含了语言类型和文件大小两部分。然后根据语言类型通过open
的方式创建对应的文件。接收代码
:接收代码的过程就通过recv
函数来进行循环的接收存放到服务器的内存缓冲区当中。对于接收到的数据会有三种情况进行对应处理。具体处理如下图所示:返回语言类型
:在编译的过程中要根据语言类型选择合适的编译方式。
int RecvCoding(int fd)
{
//接收协议头。根据语言类型创建对应文件
struct Head head;
recv(fd,&head,sizeof(head),0);
int filefd = open(file[head.language-1],O_WRONLY|O_TRUNC|O_CREAT,0664);
int size = 0;
//接收代码
while(1)
{
int num = head.file_size -size >127?127: head.file_size -size;
char buff[127] = {0};
int n = recv(fd,buff,num,0);
if(n == 0)
{
break;
}
if(n == -1)
{
//表示缓冲区中没有数据可读,唤醒sockfd进行下一次读操作,如果不设置文件描述符一直等着数据到来
if(errno == EAGAIN || errno == EWOULDBLOCK)
{
printf("ser read over\n");
break;
}
}
size+=n;
write(filefd,buff,n);//将接收到的数据存储到可执行文件中
if(size >= head.file_size)
{
break;
}
}
close(filefd);
return head.language;//返回语言类型,根据语言类型进行语言编译
}
2.2编译代码
编译代码的过程只需要进程替换为系统自带的编译器就可以进行编译了。具体流程如下图所示:
定义存储文件信息的结构体
:因为最后要返回错误文件的大小,获取文件信息,所以首先要通过stat
定义存储文件信息的结构体。子进程替换系统自带的编译器
:在这个过程中需要使用到dup
函数把标准输入和错误输入文件描述符重定向到错误文件build_error.txt
当中返回错误文件大小
:0表示编译成功,大于0表示编译失败
int BuildCoding(int language)
{
struct stat st;//定义存储文件信息的结构体
pid_t pid = fork();
assert(pid != -1);
//子进程编写程序
if(pid == 0)
{
int fd = open("./build_error.txt",O_CREAT | O_WRONLY|O_TRUNC,0664);
close(1);//关闭标准输入
close(2);//关闭标准错误输入
dup(fd);//将文件描述副重定位
dup(fd);
//进程替换编译文件
execl(build[language-1],build[language-1],file[language-1],(char*)0);
write(fd,"build error",11);//替换失败的处理
exit(0);
}
else
{
wait(NULL);//阻塞等待子进程的结束
stat("./build_error.txt",&st);//将build_error文件大小放到st结构中
}
return st.st_size;//返回错误文件大小,0表示编译成功,>0表示编译失败
}
2.3执行代码
还是通过进程替换的方式将子进程替换为./a.out程序即可。最后将执行结果保存到result.txt
文件当中。具体流程图如下:
void Carry(int language)
{
pid_t pid = fork();
assert(pid != -1);
if(pid == 0)
{
int fd = open("./result.txt",O_WRONLY|O_TRUNC|O_CREAT,0664);
close(1);
close(2);
dup(fd);
dup(fd);
execl(carry[language-1],carry[language-1],(char*)0);
write(fd,"carry error",11);
exit(0);
}
else
{
wait(NULL);
}
}
2.4发送结果
根据编译代码最后返回的build_error文件大小来决定发送错误文件还是执行文件给客户端。发送的过程还是
- 首先发送通过
stat
结构体获取的文件大小以防止粘包问题。 - 再通过
read
的方式从文件中把数据读到输出缓冲区中,采用send
的方式把输出缓冲区中的内容发送给客户端
void SendResult(int fd,int flag)
{
char* file = "./result.txt";
if(flag)
{
file = "./build_error.txt";
}
struct stat st;
stat(file,&st);
//发送文件大小
send(fd,(int*)&st.st_size,4,0);
//发送服务器反馈内容
int filefd = open(file,O_RDONLY);
while(1)
{
char buff[128] = {0};
int n = read(filefd,buff,127);
if(n <= 0)
{
break;
}
send(fd,buff,n,0);
}
close(filefd);
}
3.2客户端功能实现
客户端所要完成的功能如下面流程图所示:
1、和服务器建立链接
建立连接的过程就还是socket编程的一套流程。首先通过socket
创建一个套接字,然后客户端通过connect
的方式与服务器建立连接。
2、打印提示信息,让用户选择语言
在这个在线编译系统中我只实现了只能选择c语言或者c++。
3、用户输入代码
用户输入代码的过程还是通过进程替换的方式打开系统的vim
进行代码的输入。用flag标志用户选择创建新文件还是打开上一次的文件继续编辑。
【注意】
- 此处创建新文件需要先使用
unlink
的方式删除原有的文件,再vim打开新的文件进行编辑。 - 在全局就定义了一个指针数组存放不同语言对应的文件名。具体如下:
char* file[]={“main.c”,“main.cpp”};
void WriteCoding(int flag, int language)
{
// 1、 flag为2 创建新文件写代码
// 2、 flag为1 打开上一次的文件接着编辑
if(flag == 2)
{
//只需要把原有文件删除掉即可,之后用vim打开新建
unlink(file[language-1]);
}
//应该创建子进程去替换系统的可执行文件vim
pid_t pid = fork();
assert(pid != -1);
if(pid == 0)// 子
{
// vim是一个可执行程序,不需要自己编写直接使用,直接替换一个可执行程序即可
//创建的文件是在客户端的当前目录下,没有指定文件
execl("/usr/bin/vim", "/usr/bin/vim", file[language-1], (char*)0);
printf("exec vim error\n");//失败要给出提示
exit(0);
}
else
{
wait(NULL);//父进程等到子进程的结束
}
}
4、将信息发送给服务器
因为为了防止粘包问题还有要将语言信息发送给服务器以选择不同的编译方式,所以就在全局定义了一个数据头的结构体,该结构体主要有两部分组成,一部分是语言另一部分是文件大小。具体实现如下:
struct Head
{
int language;
int file_size;
};
发送头部信息
:采用send
的方式发送头部信息给客户端发送数据部分
:先将本地文件内容以read
的方式读取到输出缓冲区当中,再以send
的方式将输出缓冲区中的内容读到服务器
【特别地】
针对用户打开了文件但是没有写入数据的情况要进行特殊处理。不然会出现服务器一直阻塞等待客户端写入数据。
void SendData(int sockfd, int language)
{
//1、 先发送协议内容: 语言(决定了服务器接收到了以后接收到的本地文件名应该如何去取
//也决定了服务器拿到代码过后应该用什么工具去编译)+ 文件的大小(防止粘包问题)
//2、 代码文件的内容
//发文件头
struct stat st;
stat(file[language-1], &st);//获取文件属性
struct Head head;
head.language = language;
head.file_size = st.st_size;//文件大小
send(sockfd, &head, sizeof(head), 0); // 8字节
//打开文件
int fd = open(file[language-1], O_RDONLY);
//发送文件内容
while(1)
{
char buff[128] = {0};
int n = read(fd, buff, 127);
if(n <= 0)
{
break;//说明没有读到内容
}
send(sockfd, buff, n, 0);
}
close(fd);
}
5、接收服务器编译运行结果
为了防止粘包问题还是先接收文件的大小。然后再循环接收文件内容。当接收的数据小于等于0的时候就关闭文件描述符,退出程序
void RecvData(int sockfd)
{
int size;
recv(sockfd, &size, 4, 0);//防止粘包问题的处理
int num = 0;
while(1)
{
int x = size - num > 127 ? 127:size-num;
char buff[128] = {0};
int n = recv(sockfd, buff, x, 0);
if(n <= 0)//对方出现了问题
{
close(sockfd);//执行出错直接将sockfd关闭掉执行崩溃
exit(0);
}
printf("%s", buff);
num+=n;
if(num >= size)//结果已经读取完毕
{
break;
}
}
}
6、打印提示信息
用户选择要进行的操作对应的数字。其中1代表修改代码,2表示继续编写下一个代码,3代表退出程序。因为用户开始编写第一份代码的逻辑和编写下一个代码的逻辑相同,所以在主函数里面要将标志初始化为2。
4、项目效果展示
1、编写一个正确c语言代码并做修改
2、编写一个正确c++语言代码并做修改
3、同时打开两个客户端分别编写正确的c和c++代码并做修改
4、在打开了两个客户端的情况下再打开一个客户端编写错误的c语言代码并修改
在当前路径下存在的文件如下图所示:
5、项目分析与改进
这个项目的功能实现还是有些许的局限,因此我对此次项目的未来还有以下改进的地方。
支持的语言
:本项目还很局限于只支持c和c++两门语言。但是随着技术的日益更替,也出现了各种各样形式的语言类型,其中包括Java、Python、golong也是值得去扩充的。数据库
:本项目编写的初衷是基于牛客、力扣上的在线编译答题系统。但是本项目只能支持用户自己编写代码,服务器对此代码进行编译运行并反馈结果。
也就是说在本项目的基础上还可以通过连接数据库的方式,将用户选择想要做的题的类型提交给服务器过后,服务器会根据相应的算法从数据库中选取相应的题目以供答题。还可以编写相关测试用例将该项目改进成一个在线答题系统。客户端响应较慢
:因为事件就绪和处理事件之间存在创建的过程,当系统上有大量的客户端的时候会花费大量时间在进程切换上。因此本项目以后的改进方向还可以采用线程池的方式来并发处理各个客户端的数据来提高服务器处理的效率。当客户端到达一定数量过后还需要用集群式服务器分布式等来处理高并发问题。GUI设计
:本项目现在的界面还是仅限于Linux下的dos界面,美观性不强。为了更加具有可操作性和美观性再以后的设计中还可以使用GUI设计相关界面。