前言
这应该是我自己从头开始自己思考、自己设计、自己写代码的第一个项目了。
想做个小聊天软件,以命令行为主,我喜欢这种风格。不过因为是命令行交互,为了支持更多功能,所以整个客户端程序就变成了一个巨大的状态机,设计起来有些困难。
难点在于要单缓冲区多线程,一个输出输入缓冲(黑框框)要键入命令、接收随时可能到来的输出;并且用户可能输入一些不对应状态的命令,这也需要处理;最后还必须处理网络时延的问题。
项目代码链接:Chen-Jin-yuan/orange (github.com)
设计分析
- 单reactor多线程模式
- 主要有三种功能
- 命令:客户端向服务器发送命令进行交互,命令行状态下直接输入,在其他状态下使用@字符:@cmd
- 聊天:客户之间能进行文字聊天通信,数据通信路径为客户->服务器->客户,这样服务器可以记录聊天数据
- 需要注册登录,有一个唯一的登录id,链接到数据库;
- 登录后可以设置**标识字符串sid(search id)**,以及标识用户名sname。sid用于其他客户寻找本用户,即建立一个临时的会话(好玩的地方),sid在同一时间不能相同;标识用户名可任意,通信时显示。
- 文件传输:进行p2p的文件传输
- 用户A可以通过命令获取用户B的文件资源表(resource文件夹下放置可传输的文件),经用户B同意后可以传输
- 用户A也可以主动发送文件给用户B,用户B同意后可接收
- 调用主要以命令为主,本质是一台状态机,难点在于用户输入只有一个缓冲,状态不能交叉影响
命令系统
这里设计命令系统,用户在普通情况下是一个命令行输入,即cmd>
,这里为了清楚标识,命令关键字以**@**标识。
命令 | 作用 |
---|---|
@register [userID] [password] [password again] | 注册一个账号,用户名和密码不能含空格,用户名不能重 |
@login [userID] [password] | 登录账号 |
@search [sid] | 搜索某个sid,若存在则返回对方sname和是否在chat的信息;对方不会知道有用户搜索ta |
@chat [sid] | 准备进入chat状态,描述较长见表格后 |
@accept [sid] | 接受某个sid,收到chat请求后会维护更新本地客户端的一个请求表,需要存在该sid |
@reject [sid] | 拒绝某个sid,收到chat请求后会维护更新本地客户端的一个请求表,需要存在该sid |
@break | 退出状态,进入命令行状态,描述较长见表格后 |
@send [sid] [msg] | 直接向某个sid(若存在)发送一条信息,对方会得知sid和sname |
@sendfile [sid] [path] | 直接向某个sid(若存在)发送一条文件发送信息,对方会得知sid和sname和文件名,进入等待 |
@acceptfile [sid] | 接收某sid发送的文件,返回接收信息 |
@rejectfile [sid] | 拒绝某sid发送的文件,返回拒绝信息 |
@getfile [sid] | 请求获取对方的文件资源表,对方会得知sid和sname,进入等待 |
@acceptget [sid] | 同意对方获取资源,返回接受信息,等待对方选择文件 |
@rejectget [sid] | 拒绝对方获取资源,返回拒绝信息 |
@setsid [newsid] | 设置sid,不设置默认用登录的userID |
@setsname [newsname] | 设置sname,不设置默认用登录的userID |
@re | 重复上一条命令 |
@exit | 退出 |
@hisir [msg] | 向服务器说点事情,会得到服务器返回的一句话 |
@oyasumi | 退出,向服务器说晚安 |
- @chat:直接与某个sid用户通信,如果没有该sid会返回错误信息1;否则对方会接收到请求,获得你的sid和sname,并选择是否接受。若拒绝则返回拒绝信息;若接受则进入chat状态。用户在调用chat命令后,会进入等待状态(等待对方接受)
- @break:
- 退出等待状态,这会告知对方已经不请求chat了,同时删除对方的请求表中的sid,返回命令行状态
- 退出chat状态,这会告知对方已经断开chat了,返回命令行状态
- 退出发送文件等待状态,和chat一样
- 退出获取文件资源表的等待状态
- @sendfile:调用break可以退出等待;若对方接收,会开辟一个发送文件线程;若对方拒绝,直接退出等待
- @getfile:若对方同意,则可以开始选择文件
客户端
我们先随意写一些功能函数,最后封装起来,一些全局变量最后会变成类成员变量,请不用担心。
命令解析
首先解析命令,从命令行读入字符串然后分析。命令解析在本地完成,不发送给服务器,这样能降低出错的可能并降低服务器的复杂度。
下面是一个解析命令的demo
1 |
|
连接服务器
在客户端打开初始化时自动连接服务器,然后返回一个连接套接字描述符,允许收发信息。
1 |
|
设计状态机
为了简化设计,有一些状态是显式状态,有一些状态是隐式状态。什么是显式和隐式呢?显式状态比如说在命令行和在chat时输入的响应是不一样的,这是全局的;隐式状态比如说一些等待状态,这些等待是阻塞的,局部的,就不需要显式切换状态。简单来说,
- 显式状态:
- 未登录状态:可注册、登录,其他命令都是ban的;登录后进入命令行状态;
- 命令行状态:不可注册登录,其他可使用;chat后进入等待状态阻塞,对方接受后进入chat状态,否则返回命令行状态;
- chat状态:此状态下的输入都发送给对端,除非使用@标识命令,比如使用@break退出chat状态;
- 等待状态:所有等待状态下,可以输入@break返回命令行状态。
- 隐式状态:
- 请求获取等待状态:等待对方响应,响应后进入命令行状态(拒绝)或选择文件状态(接受);
- 选择文件状态:等待用户输入文件序号;
- chat等待状态:等待对方响应;
- 发送文件等待状态:等待对方响应,响应后进入命令行状态;
- 等待文件被获取状态:允许对方获取资源,等待对方选择文件。
状态使用枚举类,c++编程风格惯用法提出使用enum class代替enum和namespace,可以参考c++编程风格惯用法 | JySama
1 | enum class clientState |
状态机实现–重写run()
对于不同状态,我们希望输出的提示符不同,比如前面都是cmd>
,现在希望根据状态进行改变。一种方法是打印提示符时判断状态,太麻烦了;一种方法是把提示符看做变量,更改状态时就更改提示符。
1 | 命令行状态:"cmd> " |
如果没有登录而调用ban掉的命令,我们就打印一句错误信息
1 | if(state == clientState::noLogin) |
如果已经登录还调用注册和登录命令,就打印一句错误信息
1 | if(state != clientState::noLogin) |
这些错误信息,我们可以在case语句里面加,但太臃肿了,我们进行一层封装
1 | bool checkCmd(int cmdvalue) |
1 | //test |
处理@
我们对命令的解析还没有包含@形式,实际上@只有在chatting状态下才被必须,其他情况下只需要简单的把@去掉即可,即检测到第一个字符的@的话就把@去掉。
而在chatting状态下,我们依旧从fgets里接收输入,如果第一个字符是@,我们判定为输入命令,这样才有一系列的操作(解析命令、进行switch等等):
1 | if(state == clientState::isChatting && cmdbuf[0]!='@')//如果在chatting,并且输入第一个不是@,判定为聊天内容直接发送 |
这样,对于解析命令函数,就可以这样改写:
1 | vector<string> parse(string cmdstr) |
我们还没有处理chatting的状态转换,因为这涉及服务器发回确认,现在我们在chat命令那使用Sleep()函数阻塞一段时间,表示等待同意。demo可以这样改:
1 | //test |
接收处理
在基础设计时,已经降低了交互的复杂度,比如我们使用accept命令来回复,而不是直接根据请求键入其他东西,在接下来的考虑中,你会得到更深刻的理解。尽管我们仍然有许多命令没处理完,但在处理它们之前,要讨论怎么从服务器接收返回的数据,这是个很值得深思的问题。
对于发送命令来说,我们在本地解析了命令就可以直接发送给服务器,服务器根据前面设计的命令解析(只有少许不同,让服务器区分发送的是命令还是聊天内容)就可以解析完成,因此对于客户端来说,重点是处理接收,困难就在于:用户只有一个输出缓冲区和一个输入缓冲区!
- 一种方式是:让用户在调用需要服务器交互的命令后阻塞,等待接收,然后对接收内容进行处理。比如当login、register后,等待服务器返回成功和失败信息即可,理论上所有的命令都可以用这种方式来实现;
- 上面的方式没有考虑一个问题,就是当别的客户发来一条信息时,实际上本地客户端并不一定去recv。比如本地用户还在干别的事情就有别的用户发一个send或get请求过来,这个请求经由服务器再发送到本地客户端,直到下次本地客户调用阻塞命令才会进行接收。另外在chat的时候实际上也不知道什么时候该recv。一方面时延很久,一方面无法区分返回的命令。这是两个很严重的问题。
- 额外接收线程:我们还是对问题简化
- 直接与服务器交互的命令,就直接阻塞等待接收(因为必须等待处理);
- 与其他用户的交互,这会导致信息不知道什么时候发送过来,就另外用一个线程去接收,这个线程使用另外一个套接字(线程的port也会不一样),发送给哪个套接字交给服务器判断就好了。
- 看起来问题已经解决了,但如果服务器只有一个监听端口,会使得复杂度过大。服务器是可以监听多个端口的,那么可以让服务器用两个监听线程。这两个监听线程刚好可以把两种套接字区分开来。
- 由于有两个监听线程,导致我们不得不再考虑:什么信息该发送到哪个端口?这个问题产生的原因是,两个监听线程得到的连接套接字可以要交互。比如chat,当用户1发送一条消息时,实际上是在我们的主线程(接收stdin解析命令线程)发送的,如果走的是监听1(接收命令的线程),那么要想把这条消息发送给用户2,就要从监听2那里获取用户2的接收信息套接字,再发送。这使得问题复杂化,可能需要考虑共享变量互斥的问题。因此我们不得不考虑发送什么到哪里的问题。
- 好吧,其实这个问题不用考虑:“接收线程允许发送命令吗?”这是不太允许的,因为它要一直recv,你可能会说用非阻塞的recv,但这又使得问题走向另一个复杂的地方。我们可以断言:所有的命令,都要发送给服务器的接收命令的线程!至于命令或是回复的转交,不得不通过另一个连接来执行。
现在来完成接收线程要做的事情,先封装到一个函数里,这里首先要连接服务器,还记得前面写的connect_S函数吗:
1 |
|
这样我们的接收线程的工作函数的工作逻辑就更清楚了:
1 | void recvThread(const char* SERVER_IP, int MYPORT) |
接收线程的工作大概就是这些,只是简单的打印,因为我们把复杂度交给服务器了。不过,我们考虑下主线程和接收线程之间的关系——它们共享输出缓冲。因此会发生一种情况,在主线程打印出来的提示符会被顶掉,尽管用户仍然可以在主线程输入,但会让用户感觉“坏掉了”,大概像这样:
1 | cmd> something....recv> ..................... |
因此,除了打印前需要换行,还需要在打印后把提示符再打印出来:
1 | cout<<endl<<recvbuf<<endl<<prompt; |
然后加入线程,需要头文件<thread>
和<functional>
1 | thread(bind(recvThread, SERVER_IP, MYPORT)).detach(); |
阶段性测试的demo如下,我们把接收线程相关的函数都放在main函数前面,因为已经相当臃肿了,等处理完一并封装。注意这里面有<windows.h>
和<WinSock2.h>
,后者一定要在前者前include。
1 | “winsock2.h”定义了_WINSOCKAPI_,原意是不要编译''winsock.h"。 |
同时,我们进行一点优化,以往打印提示符时没有刷新缓冲区,现在我们把刷新缓冲加上,否则可能会堵住显得卡,像这样cout << prompt << flush;
。
1 | //test |
简单写一个linux服务器发送数据测试,有了这台服务器,我们之后就可以模拟响应往下做了,尽管我们现在是手动在服务器上发送数据。
服务器发送exit就可以退出,接收线程也会因此退出,我们现在可以先放着,不去管它。
1 | //server |
命令发送与接收
现在我们来处理主线程的命令发送,基本上就是处理run方法,不过注意,在main调用run前,我们先进行连接,然后把套接字传给run(),大概像这样子。
1 | int main() |
现在我们对run函数作修改,注意一个前提,进入switch内的else的部分,都是可以进行执行的正确的命令,这些命令可以发送给服务器。我们对这些部分进行send和recv处理。关于recv,在主线程中是阻塞我们的输入的,但是当我们在waiting时,还是能够输入break来退出等待(这是必要的),这就不能阻塞了,因此对于waiting的这部分,接收放到接收线程处理。
为什么说break是必要的呢?我们实际上可以认为服务器是不会让用户等(或者说等太久)的,但对等方是可能让本地用户等很久的(可能由于意外、可能由于心理道德),这就需要允许用户自主退出,尽管这会增加许多复杂度。
对于那些要接收回复的命令,我们在此探讨一下它们要接收什么。
- register
- 注册正确还是错误:success | failure
- 如果是success,在本地处理并返回注册正确的信息;
- 如果是failure,在本地处理并返回注册失败的信息,由于我们判断了密码相不相同了,这必然是用户ID已被使用了;
- login
- 如果是success,在本地处理并返回登录成功的信息,并进入命令行;
- 如果是failure,可能是密码错了或者根本没有用户ID,这时服务器不返回failure,而是返回相关的错误信息,直接打印即可;
- search
- 如果是failure,本地处理并返回不存在该sid
- 否则成功,服务器直接发回sname和是否在chat的信息,直接打印
- chat
- 服务器返回对方是否接受的信息,为了区别格式(命令与命令之间,命令与聊天信息之间),服务器会发送这样的信息:”@#chat accept”、”@#chat reject”
- 然后会进行状态的更新
- accept
- 暂不考虑交互,但可能因为时延影响,后续也许涉及交互的认证
- reject
- 与accept一样
- break
- 与accept一样
- send
- 这里降低复杂度,如果对方sid不存在也不返回消息,所以需要用户先search
- sendfile
- 服务器返回对方是否接受的信息,为了区别格式,服务器会发送这样的信息:”@#sendfile accept”、”@#sendfile reject”
- acceptfile
- 与accept一样
- rejectfile
- 与accept一样
- getfile
- 当对方acceptget时,会给本地用户一个资源表,因此服务器发回来的就是这样一个资源表。
- 那么服务器返回的消息像这样:”@#getfile reject”、”@#getfile accept resource_list”
- acceptget
- 不阻塞直接可以做别的事,即一定允许发送,接收线程处理对方选择,服务器返回这样一条信息:”@#choosefile [number]”
- rejectget
- 与accept一样
- setsid
- 返回成功和失败信息,直接打印
- setsname
- 返回成功和失败信息,直接打印
- re
- 不返回
- exit
- 不返回
- hisir
- 返回一句话,直接打印
- oyasumi
- 会返回一句晚安消息(随机选择),然后打印
有些返回是直接打印的,而有些返回需要进行解析,比如login;而那些accept之类的,因为主线程是在waiting状态,那就让接收线程解析。
当用户在选择文件时,要键入输入,为了不和主线程的输入冲突,我们就在主线程输入。为了不增加状态(比如目前在选择文件状态),我们只需要增加一个选择文件flag就好了,如果是true的话表明这个输入用于选择文件。发送的命令是:choosefile [number],下面的run先不对此做修改
1 | bool chooseflag = false; |
解析接收命令
进一步丰富接收线程的接收函数,可以借助前面的解析函数parse
1 | int fileNumber = 0;//全局变量,用于对方acceptget后选择文件的最大数量 |
1 | void recvThread(const char* SERVER_IP, int MYPORT) |
测试
需要两个服务器来模拟输入输出,代码不贴了(感觉没人能follow到这…写着写着发现太抽象了)。
不过测试发现挺好的,测了好久关了忘记截图了,不想再测了,因为手敲服务器的响应有点麻烦。
1 | //test |
请求表设计
前面提到,我们总要考虑用户“无故”输入“无效”命令的情况(往最坏的情况上考虑),当用户没有被请求时,用户可能输入accept和reject这类命令,此时是无效的。为了快速响应以及降低服务器的复杂度,我们将这类无效命令在本地判断,因此需要一个请求表维护到来的请求(chat、sendfile、getfile)。
- 当接收线程接收到一个请求时,继续解析以获得sid。比如对方发送一个chat sid时,服务器的命令接收端会得到请求方的sid等信息,然后把命令发给该sid的接收线程,格式像这样:@#chatfrom [sid] [sname]。只需要把该sid获取然后添加到请求表内即可。对于getfile:@#getfilefrom [sid] [sname];对于sendfile:@#sendfilefrom [sid] [sname] [filename]
- 当用户accept时,查表,只有存在才允许把该命令发送到服务器,然后服务器会发一个@#chat accept给对方,就完成了。reject同理。并且accept和reject后,都要把请求表内对应的表项删除。
- 当对方请求到来时,本地会更新请求表添加表项;然而对方用户可能在等待状态break,这时要通知本地对方取消请求并删除请求表项。但是在前面我们对用户的break命令仅仅只是发送了一个break命令,我们还需要做更多的处理。
- 我们选择在服务器端处理,服务器是可以得知用户在什么状态的。在noLogin状态不允许break;cmdLine状态下,break是无效的。服务器真正要区分的是用户在waiting还是在chatting。因此,当用户在cmdLine调用break时,不做处理。
- 当用户发送chat、sendfile、getfile后,服务器在转发信息时标记用户进入waiting状态。注意我们的设计,在waiting时只能break或等待,因此用户在同一时间最多只允许有一个请求。一旦用户在waiting时发送一个break,服务器在接收后会把用户标记回cmdLine状态,并且服务器还需要知道这个break发回给谁,因此服务器在标记用户进入wating时还必须添加用户之间的映射,通过这个映射把@#break [sid]发过去,并随后把这个映射删除。一旦对方接收到这个回复,就从请求表中把该sid对应的表项删除。
- 如果accept了一个chatting请求,服务器会标记双方进入chatting状态。此时如果某一方break,服务器通过映射找到另一方,发一个@#break now,对方接收后从chatting状态退出即可。
- 我们再考虑两种情况:
- 当用户在waiting状态下break,服务器标记用户回到cmdLine,而对方还没有接收到这个消息就accept了,由于用户已经回到cmdLine状态了,这个accept是失效的,因此服务器通过cmdLine状态得知这一事件,向accept一方发回一个@#break now,让其退回命令行状态(如果是chatting的话)并打印”the peers break before you accept”。如果是reject就不管
- 当用户在waiting状态下break,服务器还没标记用户回到cmdLine,对方就发了accept,此时由于服务器发现用户还在waiting,就把accept转过去了,但用户已经回到命令行了。在接收线程里,如果用户不在等待状态则accept是无效的,因此只用考虑向accept发送处理。服务器把accept转过去后,标记的用户的状态要么返回cmdLine(sendfile这些被accept就标记为cmdLine),要么进入chatting状态。然后服务器又收到break信息,前面说到在chatting状态收到break,服务器发一个@#break now。则如果accept方目前在chatting,也可以退出;否则用户应该在cmdLine状态,这样收到@#break now,给accept方打印一个信息”the peers break before you accept”。
- 因为用户最多一个请求,则**三个请求命令可以共用一个请求表,映射如下:[sid] [cmd],如 123->”sendfile”**。
至此,我们知道了请求表的设计和接收线程该如何修改。
1 | //请求表封装 |
再修改一下接收线程的解析命令函数:
1 | ReqTable reqTable; |
现在对accept那些命令进行更改,即修改run函数,这里把整个run都弄下来,分开弄到时候合在一起也麻烦,这里还改一下choosefile的发送
1 | void run(SOCKET connfd) |
到这就改完了,先不测试了,稍后就封装起来,代码量上来了有点难受。但还是等基本完成后再一并把封装好的代码放上来。
资源列表生成
前面提到的资源列表字符串的格式,这里最后有没有空格都没关系:
1 | //假设对方发送的文件资源表是[1] some.txt [2] some2.txt,即空格分隔的成对的 |
我们允许用户指定目录,也允许使用默认的目录,具体的:
- 在本地目录有个配置文件config.txt,里面允许配置resource_path[空格]路径(这是新增内容)
- 如果没有找到这个文件,就使用默认路径./resource;如果找到了就更换为该路径
- 然后找到该文件夹,如果没有则资源列表为空;否则遍历文件,生成资源列表,既生成map也生成能直接发送的字符串。
首先有个读配置的函数,以及判断是否存在文件夹的函数;然后用windows.h的api去遍历文件名。
1 | HANDLE FindFirstFile(LPCTSTR lpFileName,LPWIN32_FIND_DATA lpFindFileData); |
函数和测试代码如下:
1 |
|
文件传输
我们有时候还需要文件的发送和接收,这在另一个线程进行,下面再梳理一下这些场景:
- 当sendfile后收到一个accept时,准备发送
- 当使用一个acceptfile时,如果成功则准备接收
- 当acceptget后收到一个choosefile返回命令时,准备发送
- 当chooseflag为true时,输入选择的数字或准备接收
我们还没有定义线程的端口(ip服务器是知道的),这需要在命令里发送,我们先明确一下主被动关系:
- 使用sendfile时,对方可能会拒绝;因此让对方acceptfile时把默认端口发来,对方进入listen,本地去connect发送
- 使用getfile时,对方可能会拒绝;当对方acceptget时本地进入choose状态(目前还没设置能退出的命令,当如果对方资源列表为空是不会发过来的),choose后对方收到就会发文件过来,所以choose时把默认端口发去,本地进入listen,对方去connect。
因此需要两个端口号,一个用于acceptfile时开启监听接收,一个用于choose后开启监听接收。
因为对应关系明确,所以如果固定了端口就不需要在命令里再发送了,因为对方也知道;当然也允许用户自己选择端口号(通过配置),这里先不实现,给定:
- sendfile的端口是14242
- getfile的端口是14243
1 | //发送函数,从调用者获得ip和端口和文件名 |
现在要思考一下线程如何维护,这里主要是设计前面提到的@#break now命令,即本地用户break后没来得及通知对方,对方就accept了。因为我们设计的是让accept方发送,所以实际上接收后break now命令后只需要把acceptfile后产生的listen线程关掉就好了。
因为只设计监听一个端口,因此sendfile和getfile各自在同一时间只能接收一个文件(用线程池不方便管理,并且消耗的资源太多了;如果accept一个连接新开一个线程,对于文件名是不方便传参的,除非用线程池取任务,这些任务绑定好了参数,目前先不支持吧)。
然后这些线程不能用join,否则会在线程切换时阻塞(比如上一个文件下载完下载另一个要join,但其实不知道下载完没,就可能会阻塞),但不用join又无法释放线程资源;所以用deatch,这就需要给主线程提供是否在运行的信息,设置全局变量isRecvingGf和isRecvingSf
即可。因为同一时间只有一个线程改写该全局变量,所以不用互斥。
然后,我们还要考虑虚假acceptfile的情况,这时本地会开一个listen,如果不关掉下一个文件接收就无法bind端口,因此收到break now后,就用本地的一个连接去访问,让其accept,然后判断是本地的“127.0.0.1”就直接退出(实测可以)。
1 | void sendfile(const char* server_ip, const int port, std::string filename) |
补充
现在消息都能发给服务器了,但是服务器不知道是聊天信息还是命令信息…所以,我们给聊天信息加一个关键字符,一方面我们改的代码少,一方面由于命令没有这个关键字符,所以只要有就能判定为聊天信息,不会冲突。这个关键字符是~,加在内容前面。
本来是这样的:
1 | if (state == clientState::isChatting && cmdbuf[0] != '@')//如果在chatting,并且输入第一个不是@,判定为聊天内容直接发送 |
现在是这样的:
1 | if (state == clientState::isChatting && cmdbuf[0] != '@')//如果在chatting,并且输入第一个不是@,判定为聊天内容直接发送 |
加两个小功能
- 自动登录,在配置文件里写是user_id和user_password
- 加一个打印自己的资源列表的命令(意识到这是蛮必要的)。myresource->19。
1 | void Client::autoLogin(SOCKET connfd) |
1 | //run里面添加命令 |
现在,客户端差不多可以收尾了。
服务器的设计在下一章博客,封装好的代码也会放出来。