一 前言
本文是《JDFS:一款分布式文件管理实用程序》系列博客的第二篇,在上一篇博客中,笔者向读者展示了JDFS的核心功能部分,包括:服务端与客户端部分的上传、下载功能的实现,epoll的运用,线程池的运用等。当然目前JDFS还仅仅支持上传、下载功能,还不具备分布式文件管理的功能,这些都会在后续的开发过程中加进来。在写博客的过程中,笔者发现最好是每完成一个小的功能就及时用博客记录下来,如果等功能全部实现完成后再写博客的话,一方面由于功能点比较多,博客写起来会比较费力;另一方面由于时间间隔太长,有些有价值的细节恐怕会忘记。所以最好是增量式写博客,每实现一个关键点的功能,及时用博客记录下来。本文是在该系列博客第一篇的基础上做了部分更新升级以及解决一些小bug.当然主要针对的是上传部分的功能。如果读者对这篇博客的背景不是太了解的话,请先移步笔者的上一篇博客:点击我 。
根据上一篇博客我们知道,JDFS的服务端主程序在epoll里面先recv客户端的数据,然后解析头部,根据请求类型,把作业交给线程池来执行。对于查询、下载部分的功能这是没有问题的,因为查询、下载部分客服端只是发送一个头部过来,服务端接收后解析的过程不会太占用多少时间。而如果是上传功能的话,服务端recv到的数据不仅包含头部而且包含客服端期望上传的文件实体的数据,而笔者的本意是让线程池来接收数据的,所以这个代码的实现与笔者的期望是矛盾的。本文首先就会对这一点进行更新改进,使得查询、上传、下载都可以并行的被线程池来执行。
另外在上一篇博客中,上传部分的功能代码比较粗糙,这次也进行了一些更新改进。在笔者测试上传功能的时候,发现了一些偶尔出现而且不容易重现的bug,而下载功能目前为止在笔者的测试过程当中还没有遇到过bug。所以从代码实现以及测试的过程来看,上传部分的功能要比下载部分复杂、更难调试。具体代码实现请移步笔者的github链接:点击我。
PS: 本篇博客是博客园用户“cs小学生”的原创作品,转载请注明原作者和原文链接,谢谢。
二 上传功能演示
在上一篇博客中,笔者展示了上传功能的截图,但那个只有一个客户端在向服务端上传文件,在这里再补充一个同时有两个客服端向服务端上传文件的截图。
在此次的上传中(使用shell脚本来提交),两个客服端分别同时向服务端上传不同的文件:CRLS-en.pdf和CRLS-e.pdf. 图左半边是服务端打印的一些信息,我们可以清晰的看到服务端交叉的接收CRLS-e.pdf和CRLS-en.pdf。图右半边是客服端打印的一些消息,我们也可以清晰的看到客服端也是交叉上传两个文件。服务端最后三次接收是:CRLS-e.pdf、CRLS-en.pdf、CRLS-en.pdf,客服端最后三次上传的是CRLS-en.pdf、CRLS-e.pdf、CRLS-en.pdf,可见客服端上传和服务端接收的文件的次序并不是一致的。但是从图中我们也可以很容易的发现:对于同一个文件,服务端接收的次序和客服端发送的次序是一致的。
下图是服务端接收完成后的截图:
三 改进
1. 修改服务端epoll框架,使得上传也能并行化
1 for(int i=0;i<num_of_events_to_happen;i++){ 2 struct sockaddr_in client_socket; 3 int client_socket_len=sizeof(client_socket); 4 if(*server_listen_fd==event_for_epoll_wait[i].data.fd){ 5 int client_socket_fd=accept(*server_listen_fd,(struct sockaddr *)&client_socket,&client_socket_len); 6 if(client_socket_fd==-1){ 7 perror("Http_server_body,accept"); 8 continue; 9 } 10 11 event_for_epoll_ctl.data.fd=client_socket_fd; 12 event_for_epoll_ctl.events=EPOLLIN; 13 14 epoll_ctl(epoll_fd,EPOLL_CTL_ADD,client_socket_fd,&event_for_epoll_ctl); 15 16 }else if(event_for_epoll_wait[i].events & EPOLLIN){ 17 18 int client_socket_fd=event_for_epoll_wait[i].data.fd; 19 if(client_socket_fd<0){ 20 continue; 21 } 22 23 callback_arg *cb_arg=(callback_arg *)malloc(sizeof(callback_arg)); 24 cb_arg->socket_fd=client_socket_fd; 25 threadpool_add_jobs_to_taskqueue(pool, Http_server_callback, (void *)cb_arg); 26 27 //epoll delete client_fd 28 29 } 30 }
如上述代码是服务端主程序使用epoll不断接收客服端连接、发送数据的主要逻辑部分。在第16行,原来的代码是先接收,然后解析头部,根据具体的请求再将之打包成作业加入到作业队列,然后线程池的线程就会来执行之。现在不这样做了,只要服务端epoll监听到读请求,就把该读请求打包成作业交给线程池来处理。线程相关函数会负责从传入的客户端连接的socket fd上读数据,然后根据具体请求再做不同操作。整个逻辑很简单,就是把要干的事情从服务端推迟到线程池里面去做。前文提到的打包作业是这样的:以前服务端解析完头部后,分别把三个代表查询、下载、上传的回调函数指针设置好,加入到作业队列里面。按照本文所述的新方法,服务端不用关心具体的操作是什么,只需要把回调函数指针Http_server_callback()和参数client_socket_fd传递到线程池就行了。而Http_server_callback()是新添加的一个函数,其代码如下:
1 void *Http_server_callback(void *arg){ 2 3 if(arg==NULL){ 4 printf("Http_server_callback,argument error\n"); 5 exit(0); 6 } 7 8 callback_arg *cb_arg=(callback_arg *)arg; 9 int client_socket_fd=cb_arg->socket_fd; 10 memset(cb_arg->server_buffer, 0, sizeof(http_request_buffer)+4); 11 int ret=recv(client_socket_fd,cb_arg->server_buffer,sizeof(http_request_buffer)+4,0); 12 if(ret!=(4+sizeof(http_request_buffer))){ 13 close(client_socket_fd); 14 return (void *)0; 15 } 16 17 http_request_buffer *hrb=(http_request_buffer *)(cb_arg->server_buffer); 18 if(hrb->request_kind==0){ 19 20 callback_arg_query cb_arg_query; 21 cb_arg_query.socket_fd=client_socket_fd; 22 cb_arg_query.server_buffer=cb_arg->server_buffer; 23 cb_arg_query.server_buffer_size=cb_arg->server_buffer_size; 24 strcpy(cb_arg_query.file_name, hrb->file_name); 25 26 Http_server_callback_query((void *)(&cb_arg_query)); 27 28 }else if(hrb->request_kind==1){ 29 30 callback_arg_upload cb_arg_upload; 31 cb_arg_upload.socket_fd=client_socket_fd; 32 cb_arg_upload.server_buffer=cb_arg->server_buffer; 33 cb_arg_upload.server_buffer_size=cb_arg->server_buffer_size; 34 cb_arg_upload.range_begin=hrb->num1; 35 cb_arg_upload.range_end=hrb->num2; 36 37 strcpy(cb_arg_upload.file_name, hrb->file_name); 38 39 Http_server_callback_upload((void *)(&cb_arg_upload)); 40 41 }else if(hrb->request_kind==2){ 42 43 callback_arg_download cb_arg_download; 44 cb_arg_download.socket_fd=client_socket_fd; 45 cb_arg_download.server_buffer=cb_arg->server_buffer; 46 cb_arg_download.server_buffer_size=cb_arg->server_buffer_size; 47 cb_arg_download.range_begin=hrb->num1; 48 cb_arg_download.range_end=hrb->num2; 49 50 strcpy(cb_arg_download.file_name, hrb->file_name); 51 52 Http_server_callback_download((void *)(&cb_arg_download)); 53 54 }else{ 55 56 } 57 58 59 }
2. 上传部分代码的改进
int recv_size=0;
1 while(1){ 2 int ret=recv(client_socket_fd,server_buffer+recv_size,range_end-range_begin+1-recv_size,0); 3 if(ret<=0){ 4 perror("Http_server_callback_upload,recv in while"); 5 break; 6 } 7 8 recv_size+=ret; 9 if(recv_size==(range_end-range_begin+1)){ 10 break; 11 } 12 13 }
上面是服务端上传功能的部分逻辑,在一个while无限循环中,服务端接收客服端发送过来的[n,m]区间的数据,因为recv一次不一定能够接收完整个[n,m]区间的数据,因此需要在循环里面不断地接收,直到接收到的数据达到m-n+1的长度,这个时候就用break跳出循环。另外如果recv的返回值ret<=0,则表明网络出错或者客服端断开网络,此时也要break出去。
跳出while循环后,要判断是正确接收到了完整数据还是出错了,并且给客服端发送一个ack消息,客服端根据ack消息来决定下一步的走向,该部分逻辑如下:
1 if(recv_size==(range_end-range_begin+1)){ 2 3 int ret1=fwrite(server_buffer,range_end-range_begin+1, 1, fp); 4 memset(server_buffer, 0, sizeof(http_request_buffer)+4); 5 http_request_buffer *hrb=(http_request_buffer *)server_buffer; 6 if(ret1==1){ 7 hrb->request_kind=3; 8 hrb->num1=range_begin; 9 hrb->num2=range_end; 10 }else{ 11 hrb->request_kind=4; 12 } 13 14 int ret=send(client_socket_fd,server_buffer,sizeof(http_request_buffer)+4,0); 15 16 if(ret!=(sizeof(http_request_buffer)+4)){ 17 18 perror("Http_server_callback_upload, send ack to client"); 19 20 }else{ 21 22 23 } 24 25 26 }else{
在第6行判断如果数据接收完毕并且成功写入到服务端,则给客服端发送一个正确接收并写入的消息,或者设置request_kind=4,代表服务端接收失败,客服端需要重新发送数据。更详细的代码请读者直接阅读github里面的源代码。
四 一些遇到的问题、bug
1. 在后来跑JDFS的时候,发现会提示no host to route的错误,发现原因是虚拟Ubuntu的ip地址发生了变化,执行下ifconfig命令,用最新的ip地址就可以了。
2.
1 int ret=fread(upload_buffer+sizeof(http_request_buffer)+4, size_of_last_piece, 1, fp); 2 if(ret!=1 && ret!=0){ 3 printf("JDFS_http_upload,fread failed,ret=%d\n",ret); 4 exit(0); 5 }
上面这段代码是客户端读取文件的最后一段数据准备上传,下面是一个if判断语句,之前判断语句是if(ret!=1){...},而且之前一直上传也没出现过fread的错误,但是这次却发生客户端上传文件都能成功但是到了传送最后一部分数据的时候fread老是提示错误,经分析fread由于到达了文件尾部,所以返回0,在if语句里面加上这个判断就没问题了。但是奇怪的是,笔者之前好多次上传都成功并没有提示这个错误啊。
3. 在下载功能部分,客户端从服务端recv数据,一旦recv返回值小于等于0,则不管错误原因的类型,客户端无条件重新连接到服务端,并请求数据。而客户端上传数据到服务端,某种程度上比较像服务端从客户端下载数据,不同的是服务端此时是被动从客户端下载数据。那么此时如果服务端接收数据时recv返回错误的结果,服务端不应该重新连接客户端请求那部分失败的数据,而应该是告诉客户端数据接收失败,由客户端决定此时应该怎么办。为什么呢?一方面,服务端是被动的服务客户端的请求,如果服务端主动向客服端重新连接,并请求那部分失败的数据,此时服务端变成了客户端,客户端变成了服务端,这不符合C/S的模型;另一方面,服务端应该是服务大量并发的请求,也不应该因为某一个请求服务失败,就主动重新连接客户端,请求数据,万一这个过程老是出错,服务端岂不是一直陷入特定的请求泥潭,而大量其他的请求得不到服务?
所以,服务端只需要告诉客户端该请求服务失败就行了,剩下的客户端要么重新向服务端提交请求,要么终止执行或者其他。笔者一开始想的比较简单,在服务失败的时候,服务端关闭socket fd,这样客户端检测到链接被关闭,自然就知道服务失败了。在客户端逻辑里,如果send失败,只发送了部分数据,也关闭链接,这样服务端就检测到socket fd链接被关闭。这么做结果引发了很多问题,原因在于send()端一次发送的数据,recv()端可能要分好几次才能接收完毕,如果一方已经close套接字,而另一方还没有接收完数据,就因为对端close了而接收失败,因此close的时机不好协调。
于是取消了用close()传递消息的方法,而改为:线程池Job的一次操作结果,无论有没有达到目的,都给客户端发送一条ack确认信息,客户端根据ack信息如果成功则继续,否则重新上传失败的数据。这么做基本上解决了问题,但是很奇怪的是,非常偶然的情况下会出现一个bug:在重新启动server端,然后执行客户端的时候,服务端调用recv的时候会提示bad file descriptor的错误;在重新启动server一到两次后又恢复正常了,这个错误由于非常难重新,目前还没有找到问题的根源所在。
4. 在调试的过程中,还遇到过另外一个问题:客户端是执行的上传功能,而服务端有时候会解析为查询的操作。经分析可能原因如下:有可能客户端发送了[n1,m1] [n2,m2]两段数据, 而服务端接收的序列很可能是这样的,[n1,b],[b+1,m1],[n2,m2]. 服务端在接收完[n1,b]后(由于网络原因[n1,m1]很可能不是一次接收完成),下一次接收[b+1,m1]的时候误以为是一段新的数据,并把开头若干字节的数据当做头部处理,而头部恰好有一部分数据是0,而0就代表着查询请求。但是经过研究代码并没有发现明显会产生上述场景的条件。由于修复其他bug后,导致这个错误没能继续重现,现在也很难找到根源,也留到以后再研究吧。
五 结束语
至此本篇博客就结束了,此篇博客主要解决了上传功能的并行化问题,以及修复了一些bug,当然还有一些不容易重新的bug,其原因有待进一步的分析解决。截止目前JDFS的上传、下载功能已趋于完成了,下一篇博客开始将在此基础上增加分布式文件管理的功能,比如把本地文件冗余地存储于不同的虚拟节点上,查询虚拟文件系统,从虚拟文件系统上读取目标文件等。欢迎继续关注本系列博客,我们下期再见。