在线编译系统
一、需求分析
1、客户端
(1)允许用户选择不同的语言,比如说:C,C++。
(2)提供用户编写代码的功能,将用户编写代码保存到本地
(3)将用户编写的代码传输到服务器
(4)能够接受服务器处理结果并显示
2、服务器
(1)接受客户端传输的数据,包括语言类型和代码
(2)能根据用户选择的语言类型对代码进行编译,编译完成后有两种结果
- 编译成功:将编译的可执行文件执行,将执行结果发送给客户端
- 编译失败:将出错信息反馈给客户端
3、功能流程
二、具体功能实现
(一)服务器功能实现
1、主函数
int main()
{
//一、创建一个套接字
int sockfd = CreateSocket();
assert(sockfd != -1);
//二、创建一个内核事件表
int epfd = epoll_create(5);
assert(epfd != -1);
//三、将sockfd添加到内核事件表中
struct epoll_event event;
event.data.fd = sockfd;
event.events = EPOLLIN;//用户关注的事件类型
epoll_ctl(epfd, EPOLL_CTL_ADD, sockfd, &event);
//四、监听内核事件表上的所有文件描述符
while (1)
{
struct epoll_event events[MAXEVENTS];
int n = epoll_wait(epfd, events, MAXEVENTS - 1, -1);//有epoll_wait返回说明有事件就绪
if (n <= 0)
{
printf("epoll_wait error\n");
continue;
}
//五、处理就绪事件
DealFinishEvents(sockfd, epfd, events, n);
}
}
2、创建套接字CreateSocket()
首先要实现的是服务器与客户端的连接,所以第一步我们创建出套接字,这里我们选用TCP协议使得客户端和服务器相连接(三次握手建立连接),也就是实现socket()、bind()和listen()等操作。
注意:
- 如果实现的是一台主机上完成操作,那么就可以将IP地址设为回环地址“127.0.0.1”。但是要是在两台主机(一个模拟客户端一个模拟服务器)来实现通讯就要把这个IP地址设置为服务器主机的IP地址。
- listen的第二个参数是内核维护的完成三次握手的连接,一般设置为5。
- bind()失败有两种原因:①ip地址不是我们本机的IP地址。
②端口号不对(使用了没有权限的端口号或者使用了别的程序正在用的端口号)。
//一、创建一个监听套接字(实现对套接字的初始化)
int CreateSocket()
{
int sockfd = socket(AF_INET, SOCK_STREAM, 0);
if (sockfd == -1)
{
return -1;
}
struct sockaddr_in ser;
memset(&ser, 0, sizeof(ser));//对地址初始化
ser.sin_family = AF_INET;//地址簇
ser.sin_port = htons(6000);//主机字节序转网络字节序
ser.sin_addr.s_addr = inet_addr("127.0.0.1");//点分十进制转转网络字节序标准IPV4
int res = bind(sockfd, (struct sockaddr*)&ser, sizeof(ser));
if (res == -1)
{
return -1;
}
res = listen(sockfd, 5);//5是为内核维护的完成三次握手的连接
if (res == -1)
{
return -1;
}
return sockfd;
}
3、处理就绪事件前的准备工作
- 创建一个内核事件表来存储客户端的信息;
创建epoll_creat()
,将客户端所连接上的所有事件添加到epoll
在内核所创建的事件表中,实现了I/O复用技术。 - 将sockfd添加到内核事件表中;
- 开始监听内核时间表上的所有文件描述符。
(1)具体实现:首先要通过epoll_create()
创建一个内核事件表epfd
。然后将套接字sockfd
使用cepoll_ctl
的方式添加到内核事件表当中,关注的事件为读事件。最后使用epoll_wait
来监听内核事件表,返回就绪事件。
(2)因为有的事件为空,所以我们使用epoll_wait()
循环获取就绪的文件描述符;有epoll_wait
返回说明有事件就绪。
4、处理就绪事件
- 代码实现
//五、处理就绪事件
void DealFinishEvents(int sockfd, int epfd, struct epoll_event *events, int num)
{
int i = 0;
for (; i < num; i++)
{
//获取就绪文件描述符的值
int fd = events[i].data.fd;
//1.有新的客户端链接造成sockfd就绪
if (fd == sockfd)
{
//获取新客户端链接
GetNewClient(sockfd, epfd);
}
//客户端的链接文件描述符上有事件就绪
else
{
//2.断开链接的事件
if (events[i].events & EPOLLRDHUP)
{
close(fd);
epoll_ctl(epfd, EPOLL_CTL_DEL, fd, NULL);
}
//3.处理客户端数据
else
{
DealClientData(fd);
}
}
}
}
(1)获取新的客户端连接
当有新的客户端连接时,会造成sockfd
就绪,通过accept
函数获取到新的连接事件。再将该连接关注的事件类型设置为EPOLLIN | EPOLLRDHUP
对端断开连接触发,并将epoll
事件设置为边缘触发的状态,再采取ET模式通过epoll_ctl
添加到内核事件表当中。最后将监听的文件描述符状态设置为非阻塞通过fcntl
函数给文件描述符。
//1.获取一个新的链接
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)断开连接
如果客户端的链接文件描述符上有事件就绪,而新的连接为对端断开触发事件,那么就将连接断开,防止将错误信息传到上层,导致异常。
(3)处理客户端数据
- 在这一阶段前,用户会在客户端进行代码编写完成前的工作。
- 然后服务器接收客户端的数据,并将它保存在本地文件中;
- 服务器对代码进行编译,编译的结果存放在一个编译文件中;
此时有两个结果,如果编译成功,则继续执行;如果编译失败,则将编译失败的结果反馈给客户端。 - 编译成功后执行代码;
执行成功或失败,都将最终的结果发送给客户端。
//3.处理客户端的数据
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);
}
}
(3.1)接收客户端数据
- 因为我们使用的是TCP协议传递数据,所有我们就要考虑粘包的问题,所以我们要将整个信息分为两部分进行接收,即协议头和代码;
- 接受协议头:根据语言来创建对应的本地文件,所以也需要函数指针,定义一个文件头,每次数据交互的时候先发送,解决粘包问题。
- 接收代码:用recv函数接收客户端发来的代码
- 当代码长度 n == 0 时,说明没有需要接收的代码,直接break掉
- 当 n == -1 时,说明缓冲区中没有数据可读,唤醒sockfd进行下一次读操作,如果不设置,文件描述符将一直等着数据到来。
因为是ET模式,errno == EAGAIN || errno == EWOULDBLOCK表示此次数据已经处理完成,退出循环。 - 当 n > 0 时,将收到的数据通过write的方式存储到可执行文件中
- 返回值:返回语言类型,在编译的过程中要根据语言类型选择合适的编译方式。
//3.1接收客户端数据,返回用户传递的语言类型
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)
{
//如果代码长度>127,就将127个字节读取进去;如果小于,就只将剩余的字节读取进去
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;//返回语言类型,根据语言类型进行语言编译
}
(3.2)编译代码
编译代码可以直接用系统自带的编译器进行编译。
- 定义存储文件信息的结构体,最后用来一返回值的形式看是否编译成功。
- 创建子进程,用来替换对应语言的编译程序。
- 默认编译器会直接将编译的错误信息打在显示器上,标准输入1,标准错误输入2,但是我们是想要实现让系统将错误信息全部存放在
./build_error.txt
这个文件中,那么就先将标准输入全部关闭,再将文件描述符重定向到文件中,这样就能把错误信息全部存放在文件中。打开的文件不用手动关闭,因为子进程结束后就会释放这些资源。- 为什么要用文件来保存错误信息的大小?
因为用子进程替换为自带编译器,将编译结果写入错误文件,就可以根据文件大小可以判定是否编译成功,文件大小为0,编译成功,大于0,表示有错误信息写入,编译失败。
- 为什么要用文件来保存错误信息的大小?
- 父进程必须先等待子进程结束,再将编译错误信息的文件大小存放在st结构体中。
- 最后返回错误文件大小,即st,0表示编译成功,>0表示编译失败
//3.2编译代码,替换为系统自带的编译器进行编译
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表示编译失败
}
(3.3)执行代码
- 编译会生成
.out
文件,直接执行.out
文件即可,最终将执行结果保存在"./result.txt"
文件中。
//3.3执行代码,替换程a.out程序,将执行结果保存在文件中
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);
}
}
(3.4)给客户端发送执行结果
- 发送结果和接收客户端文件一样,为了防止粘包,所以要以两部分进行发送,状态信息和文件大小。
//3.4发送结果
void SendResult(int fd, int flag)
{
//1、先发送状态信息
char* file = "./result.txt";
if (flag)
{
file = "./build_error.txt";
}
struct stat st;
stat(file, &st);
//2、再发送文件大小
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);
}
(二)客户端代码实现
1、主函数
int main()
{
//1、与服务器建立链接
int sockfd = StartLink();
assert(sockfd != -1);
//2、用户选择语言
int language = ChoiceLanguage();
//第一次使用flag和编写下一个代码逻辑相同,所以初始化falg = 2
int flag = 2;
while (1)
{
//3、用户输入代码
WriteCoding(flag, language);
//4、将选择的语言和代码发送给服务器
int empty = 0;
empty = SendData(sockfd, language);
if (!empty)//表示文件不为空
{
//5、获取服务器反馈的结果
RecvData(sockfd);
}
//6、用户选择下一次操作
flag = PrintTag();
if (flag == 3)
{
break;
}
}
close(sockfd);
}
2、用户选择语言
在本项目中,我只实现了C和C++两种语言。
//2.用户选择语言
int ChoiceLanguage()
{
printf("~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~\n");
printf("~~~~~~~~~ 1 c 语言 ~~~~~~~~~~~~\n");
printf("~~~~~~~~~ 2 c++ ~~~~~~~~~~~~\n");
printf("~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~\n");
printf("请输入您编写语言对应的数字: ");
int language = 0;
scanf("%d", &language);
return language;
}
3、用户输入代码
- 用flag标志用户选择创建新文件还是打开上一次的文件继续编辑。
- flag=1,打开上一次的文件继续编辑;直接vim打开上次的文件即可,和flag=2的第二个步骤一样。
- flag==2:创建新文件需要先删除原有的文件,再vim打开新的,打开上一次的直接vim打开即可。
- 因为我们编写代码直接用的是系统的vim,所以我们要用execl函数将vim文件与可执行文件进行进程替换。
- 用子进程进行编写代码和进程替换,vim退出,子进程结束。
- 父进程阻塞,等待子进程结束。
//3.用户输入代码,通过进程替换的方式打开系统vim,用户输入代码
void WriteCoding(int flag, int language)
{
//flag标志用户选择创建新文件还是打开上一次的文件继续编辑
if (flag == 2)
{
unlink(file[language - 1]);//删除文件
}
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、发送数据
- 先获取文件的大小,当用户打开编辑器,并没有写入内容,则不用发送。
- 为了防止出现粘包,语言+文件大小,先发送协议内容:定义结构体保存,包含两个整型;再发送代码文件内容。
//4.发送数据
int SendData(int sockfd, int language)
{
//获取文件属性就能得到文件的大小
struct stat st;
stat(file[language - 1], &st);
//用户打开文件没有输入数据的特殊情况
if (st.st_size == 0)
{
int empty = 1;
return empty;
}
//(1)发送文件头(先发送协议内容,语言+文件的大小)
struct Head head;
head.language = language;
head.file_size = st.st_size;
send(sockfd, &head, sizeof(head), 0);//用send字节流服务
//(2)打开文件并发送文件内容
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、读取服务器反馈信息
//5.读取服务器反馈信息
void RecvData(int sockfd)
{
//为了防止粘包问题,先接收文件大小
int size;
recv(sockfd, &size, 4, 0);
//接收文件内容
int num = 0;
printf("************* 编译运行结果为 ***************\n");
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);
exit(0);
}
printf("%s\n", buff);
num += n;
if (num >= size)
{
break;
}
}
printf("**********************************************\n");
}
6、用户选择下一次操作
//6.用户选择下一次操作
int PrintTag()
{
printf("~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~\n");
printf("~~~~~~~ 1 修改代码 ~~~~~~~\n");
printf("~~~~~~~ 2 编写下一个程序~~~~~~~\n");
printf("~~~~~~~ 3 退出程序 ~~~~~~~\n");
printf("~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~\n");
printf("please input number: ");
int flag = 0;
scanf("%d", &flag);
return flag;
}