1c967f1d6c2dee7d4ef043f4c7376ac4.png
我一个人,静悄悄的独坐在桌前。 ——繁漪

虽然说这个项目对我一个菜鸡来说难度有些过大了…

但是毕竟还是有熟悉的直系新生在大一…所以也略微关心了一下这个作业吧…

啊…当然,我个菜鸡,而且时间还不多,就不用指望我能做出什么太好看的工作了…

顶多就是大概介绍一些可能需要用到的工具,并且给一个比较小的demo吧。而且还会磨叨一些没有用的东西(逃

对不起可能让各位看官失望了

所以大佬们就别来看我笑话了(捂脸)

首先让我们从服务端说起。

如果我没记错的话…在大作业要求里提到了数据库…

虽然说用文件做也无所谓,但是本着多介绍一点是一点的想法,我们先把sqlite3的用法简单介绍一下。如果希望直接用文件做的话…就跳过吧…

当然,也没必要部署成服务器了…把雨课堂服务器和数据库部署在同一台计算机上应该就可以了(毕竟只在建立连接的时候有差别)

从这个奇怪的网站下载sqlite3…的源码

SQLite Download Page​www.sqlite.org

然后解压后在本机编译sqlite3.c就可以了吧(

cl /c sqlite3.c /Fosqlite3.obj
lib sqlite3.obj /OUT:sqlite3.lib

之后把编译出来的lib加入到我们的工程中就可以了吧…

(这个文件我也会上传到自己的云盘…有需要的话可以从那里找…)

然后,我这里有这样的一个数据库

d74d3678c322d35497a2dbc36b764345.png

嗯…原则上来讲密码应该加hash的…但是……

一方面,我又不是正经做大作业的怎么可能做这些extra work……更何况hash模板网上有一大堆…随便拉一个套上就行了吧(反正我写大作业的时候就是直接拉了一个MD5模板再处理了一下加盐就用了)

另一方面,需求文档里好像都已经说了这是个管理员帮着改密码之后微信发给用户的憨憨系统了……还加什么hash哇!

然后…简单地测试一下(废话开始

#include <stdio.h>
#include <sqlite3.h>
int main()
{
    sqlite3 *db=nullptr;
    auto err=sqlite3_open("E:data.db",&db);
    if(err){
        fprintf(stderr, "Can't open database: %s ", sqlite3_errmsg(db));
        sqlite3_close(db);
        return 1;
    }
    auto sql="select * from users where uname='Admin'";
    sqlite3_stmt *stmt;
    sqlite3_prepare_v2(db,sql,-1,&stmt,NULL);
    sqlite3_next_stmt(db,stmt);
    if(sqlite3_step(stmt) == SQLITE_ROW) {
        const unsigned char *name = sqlite3_column_text(stmt, 0);
        const unsigned char *pass = sqlite3_column_text(stmt, 1);
        const unsigned char *courses = sqlite3_column_text(stmt, 2);
        int role = sqlite3_column_int(stmt, 3);
        if(role!=2){
            printf("%s %s %s %dn",name,pass,courses,role);
        }else{
            long long student_id=sqlite3_column_int64(stmt, 4);
            printf("%s %s %s %d %lldn",name,pass,courses,role,student_id);
        }
    }
    sqlite3_finalize(stmt);
    sqlite3_close_v2(db);
    return 0;
}

程序(至少在我的电脑上)输出了正确的结果…

接下来,我们把上述程序做一个基本的封装,以供在其它地方使用。这段内容没什么太大的难度,而且还有较强的个人偏好在,我们略过不讲。

对于每次查询都需要创建一个sqlite3_stmt对象,并在查询后通过sqlite3_finalize释放它。

不过需要注意的是,在sqlite3_finalize之后,通过sqlite_column_text取到的字符指针会实现…如果不太在意性能的话可以把它复制出来,如果在意性能的话,就要在其它地方完成使用后,再调用sqlite3_finalize……

除了数据库,在服务端的另一个问题就是网络…

当然,如果想要像我们去年的计网大作业那样…用socket各种轮询…也不是不行…

没有要求的话尽可能用好用一些的轮子嘛。这里我们在服务端选用boost.asio做网络连接,而用户端反正也要做UI,就直接用Qt提供的连接了…(发出了懒癌发作的声音)

再之后就是需要道歉的事情了。原则上这个任务更加适合用UDP来做…

但是同样还是计网大作业的经历告诉我…用不好轮子的我如果想要在UDP里管理会话生命期什么的…就一定会写出来x一样的代码…

(我觉得写出来手动管理超时的满天std::function和早就忘了捕获了什么有没有超过生命期的一地lambda的破玩意当做大一作业的参考示例未免有点太丢人了…虽然用boost可以把超时做的稍微简单一些,比如说socket和另一个timeout竞争什么的,但是也还是颇为难看…既然不是必要的那就暂时咕了好了)

所以…非常抱歉地把这个问题暂且咕到一边,我们用TCP来做。

(反正也并没有要求高并发嘛!性能上的那点差距就不管它啦!)

当然,有的内容需要联系到后面的其它内容来做。所以我们在这里就只是先搭建一个小框架(

信息类型定义什么的先放在一边,我们先搭建一个“最小的,能支持用户登录的”这样一个服务器。

嗯…首先把boost相关的头文件引进来。我们这里需要用到以下这些…

#define BOOST_REGEX_NO_LIB
#define BOOST_DATE_TIME_SOURCE
#define BOOST_SYSTEM_NO_LIB
#include <boost/asio.hpp> //网络
#include <boost/bind.hpp> 
#include <boost/system/error_code.hpp>
#include <boost/array.hpp>

前边的那几个宏定义的话…是为了确保这几个库可以以"header-only"的形式引入(也就是说:不用添加lib文件就可以调用里面的函数)。

然后,我们声明这样的一个Class(嗯…为了看清楚每个类都是哪里来的,我没写using namespace…实际用的话可以using进来或者给常用的类型typedef一个别名)

class Server
{
private:
    const uint16_t PORT=2333;
    boost::asio::io_service m_io;
    boost::asio::ip::tcp::acceptor m_acceptor;
    std::set<std::shared_ptr<User>> users;
public:
    inline Server(): m_acceptor(m_io, boost::asio::ip::tcp::endpoint(boost::asio::ip::tcp::v4(), PORT)){
        accept();//开始运行时就要接受连接请求
    }
    inline void run(){
        m_io.run();//运行io_service
    }
    void accept();
    void accept_handler(const boost::system::error_code& ec, boost::shared_ptr<boost::asio::ip::tcp::socket> sock);
    void login_handler(const boost::system::error_code&ec,size_t size,boost::shared_ptr<boost::asio::ip::tcp::socket> sock,boost::shared_ptr<boost::array<uint8_t,64>> buffer);
    void msg_handler(const boost::system::error_code&ec,size_t size,std::shared_ptr<User>,boost::shared_ptr<boost::array<uint8_t,1>> buffer);
};

其中,User类的定义会在稍后介绍。

然后,accept和accept_handler的内容也基本上是固定的。前者让程序等待下一个连接并接受,而后者则在连接到来时被调用。

void Server::accept(){
    boost::shared_ptr<boost::asio::ip::tcp::socket> sock(new boost::asio::ip::tcp::socket(m_io));//创建socket
    //boost::shared_ptr会接管new出来的对象,并在所有指向这一对象的shared_ptr生命期结束后释放它,所以并不会造成内存泄漏。
    m_acceptor.async_accept(*sock, boost::bind(&Server::accept_handler, this, boost::asio::placeholders::error, sock));
    //这里我们开始接受连接
    //shared_ptr因为被传给了accept_handler,所以暂时还没有离开生命期。
}
void Server::accept_handler(const boost::system::error_code& ec, boost::shared_ptr<boost::asio::ip::tcp::socket> sock)
{
    if (ec)
    {    return;    }//如果出错,忽略这个请求。在退出这段上下文之后,socket会被销毁。

    auto b=boost::shared_ptr<boost::array<uint8_t,64>>(new boost::array<uint8_t,64>);
    boost::asio::async_read(*sock,boost::asio::buffer(*b),boost::bind(&Server::login_handler,this,boost::asio::placeholders::error,boost::asio::placeholders::bytes_transferred,sock,b));
    //为了方便,我们读取定长的登录信息(64字节)。
    accept();
}

这里要解释一下boost::bind…

bind本身有多种可选的参数列表,我们对这里选择的一种进行解释

boost::bind(
    &Server::login_handler,
    this,
    boost::asio::placeholders::error,
    boost::asio::placeholders::bytes_transferred,
    sock,
    b
);

这里的前两个参数代表着,我们要调用的是this->login_handler。

后面的四个参数对应着login_handler的四个参数:读取的错误码、读取的字节数、socket、buffer。

其中最后两个是现在就可以确定的,直接传入就可以。前面两个是需要等到调用时(读取完成时)才能够确定的,所以我们用asio库提供的placeholder来标记这一点。

下一步就是处理登录…

void Server::login_handler(const boost::system::error_code&ec,size_t size,boost::shared_ptr<boost::asio::ip::tcp::socket> sock,boost::shared_ptr<boost::array<uint8_t,64>> buffer)
{
    if ((boost::asio::error::eof == ec) ||(boost::asio::error::connection_reset == ec))
    {
        //如果连接意外终止,则直接返回,放弃此连接
        return;
    }
    else
    {
        //否则,试图用提供的信息登录
        auto usr=User::getUser(sock,buffer->data());
        if(usr!=nullptr){
            users.insert(usr);//如果登录成功,则记录socket和登录信息。
            //sock在usr中保存,因此就没有必要继续保存了。
            auto b=boost::shared_ptr<boost::array<uint8_t,1>>(new boost::array<uint8_t,1>);
            boost::asio::async_read(*sock,boost::asio::buffer(*b),boost::bind(&Server::msg_handler,this,boost::asio::placeholders::error,boost::asio::placeholders::bytes_transferred,sock,b));
            //并准备读取用户端发来的消息。
        }
    }
}

需要注意的是,我们每次只读取发来的信息开头的第一个字节,根据此判断信息格式,并再进行接下来的内容。

当有消息来时,下面的函数会被调用从而响应这一消息。

void Server::msg_handler(const boost::system::error_code&ec,size_t size,std::shared_ptr<User> usr,boost::shared_ptr<boost::array<uint8_t,1>> buffer)
{
    if ((boost::asio::error::eof == ec) ||(boost::asio::error::connection_reset == ec)){
        users.erase(usr);
        return;
        //如果连接中断,那么把它从已连接用户列表中剔除,并中断连接。
    }
    usr->msg_handler(*buffer->data());
    auto b=boost::shared_ptr<boost::array<uint8_t,1>>(new boost::array<uint8_t,1>);
    boost::asio::async_read(*(usr->socket),boost::asio::buffer(*b),boost::bind(&Server::msg_handler,this,boost::asio::placeholders::error,boost::asio::placeholders::bytes_transferred,usr,b));
    //否则,调用User类中的相关函数进行处理,并且开始下一次读取。
    //当然,这种做法并不是那么“异步”…我们需要在msg_handler中用同步的方式进行剩余数据的读取和写入
    //也就是说,为了不影响性能,我们希望对面能够将每一个“信息包”一口气发送出来(虽然在传输层的意义上TCP没有“包”的概念)
}

User的话…目前考虑到需要用到的成员不多…所以暂时是如下的一个类型

class User
{
public:
    static std::shared_ptr<User> getUser(boost::shared_ptr<boost::asio::ip::tcp::socket> socket,const unsigned char*data);
    uint8_t type;
    virtual void msg_handler(uint8_t MSGTYPE);
    boost::shared_ptr<boost::asio::ip::tcp::socket> socket;
};

说到这里,我们还要说一下virtual function 的用处。

当我们有两个类型A和B,其中A是B的基类。且在A和B中都定义了不同的func()。

那么当我们声明了一个B,并用某个A*指向它,再进行调用…

如果在A中这个func()被声明为了virtual,那么它会在运行时判断类型,并按照实际类型进行调用。在上面的例子里,就是按照B的方式进行调用。

这里我们在recv和send中实现同步接收和异步发送,用来给继承了User类的众多类型提供网络接口。这里我们分支出三种用户类型:Admin,Teacher,Student。

对于后两者,在其数据中增加一个std::shared_ptr<Class>成员用来辅助班级管理…

Class类型中,我们记录老师和学生的信息…

class Class
{
public:
    static std::map<size_t,std::shared_ptr<Class>> classes;//find class from classid
    Class();
    ~Class();
    void send_to_teacher(std::shared_ptr<std::vector<unsigned char>> data);
    void send_to_students(std::shared_ptr<std::vector<unsigned char>> data);
    void send_to_student(std::shared_ptr<std::vector<unsigned char>> data,size_t student_id);
    void async_write_handler(const boost::system::error_code&ec,std::size_t s,std::shared_ptr<std::vector<unsigned char>>);
    
private:
    std::shared_ptr<Teacher> teacher;
    std::map<size_t,std::shared_ptr<Student>>student;
};

在老师开始上课时创建对应的Class对象并加入到classes中…

在消息发送的实现中,因为大量数据的发送可能比较费时,为了保证系统不失去响应,我们进行异步发送。利用boost::asio::async_write进行发送,并将data也传入其中(为了保证在发送过程中data指向的数据还是有效的)

比较丢人而且繁琐的工作暂且略去。(毕竟如果真的要完整实现一个大作业的话…时间是个不小的问题…)

最后,我们在主函数里调用Server::run()启动我们的服务器。

int main()
{
    try
    {
        std::cout<<"Server start."<<std::endl;
        Server srv;
        srv.run();
    }
    catch (std::exception &e)
    {
        std::cout<<e.what()<<std::endl;
    }
    return 0;
}

接下来,我们继续丢人。

这次我们考虑客户端的网络部分。

Qt的在线安装包可以从http://qt.io找到,这里就不再重复了。(而离线安装包也可以从tuna找到)

我们在项目的pro文件中加上一行

QT+=network

之后,在我们需要使用TCP的地方引入头文件

#include<QTcpSocket>

之后,如下建立一个连接

const char* serverName="127.0.0.1";
QTcpSocket socket;
socket.connectToHost(serverName,2333);

在建立连接之后,发送登录消息并读取回复。

我们可以通过以下两个函数进行:

socket.write(data,size);
socket.read(data,size);

注意到这两个函数不保证一次读完/写完,我们进行一下包装。

size_t rsize=size;
auto tdata=data;
while(rsize>0){
    auto written=socket.write(tdata,rsize);
    if(written==-1)break;//error
    rsize=rsize-written;
    tdata=tdata+written;
    socket.waitForBytesWritten();
}

read同理。

大概可以拼接出如下的内容:

#include <QCoreApplication>
#include <QTcpSocket>
void do_write(QTcpSocket& socket,const char*data,size_t size){
    size_t rsize=size;
    auto tdata=data;
    while(rsize>0){
        auto written=socket.write(tdata,rsize);
        if(written==-1)break;//error
        rsize=rsize-written;
        tdata=tdata+written;
        socket.waitForBytesWritten();
    }
}
void do_read(QTcpSocket& socket,char*data,size_t size){
    size_t rsize=size;
    auto tdata=data;
    while(rsize>0){
        auto r=socket.read(tdata,rsize);
        if(r==-1)break;//error
        rsize=rsize-r;
        tdata=tdata+r;
        if(rsize>0)socket.waitForReadyRead();
    }
}
int main(int argc, char *argv[])
{

    struct{
        char uname[32];
        char password[32];
    } credential;;
    printf("Input Username:n");fflush(stdout);
    scanf("%s",credential.uname);
    printf("Input Password:n");fflush(stdout);
    scanf("%s",credential.password);

    QTcpSocket socket;
    socket.connectToHost("127.0.0.1",2333);
    do_write(socket,reinterpret_cast<const char*>(&credential),sizeof(credential));
    uint64_t size;
    do_read(socket,reinterpret_cast<char*>(&size),sizeof(size));
    std::vector<char> logon_response;
    logon_response.resize(size);

    do_read(socket,reinterpret_cast<char*>(logon_response.data()),size);

    uint8_t role=*reinterpret_cast<uint8_t*>(logon_response.data());
    int64_t sid=*reinterpret_cast<int64_t*>(logon_response.data()+1);
    printf("role:%dn",role);
    printf("Student ID:%lldn",sid);
    printf("Courses:%sn",logon_response.data()+sizeof(uint8_t)+sizeof(int64_t));
    return 0;
}

运行结果,成功解析出了服务器返回的信息。

20dea7b1d54c58c181f8964440ccb5c5.png

而在我们的主函数中,我们还可以把这个QTcpSocket的readyRead信号绑定到自定义的槽函数上,从而在可以读取数据时获得提示。

比如说如下:

socket.connect(&socket,&QTcpSocket::readyRead,some_object,some_slot);

说到这里,再介绍一下Qt的信号槽机制(

Qt的信号槽机制提供了一种"event-driver programming"的方式。

信号是标记“某件事发生了”的一种方式,比如超时、鼠标点击等等。固然,Qt提供了一套常用的信号,比如上面提到的QTcpSocket::readyRead。另一方面,我们也可以对于自己定义的QObject声明一个signal,并在需要时通过emit signal_name();的格式“发射”这个信号。

槽则是“要做的某些操作”。同样地,既可以使用Qt提供的槽函数,也可以自行定义槽函数。甚至,您也可以在连接时传一个lambda函数进去(

当我们把一个信号和一个槽连接起来,这个信号的"发射"就会导致对应的槽函数被调用。调用的方式有多种。比如说QueuedConnection。在这种模式下,当信号被“发射”时,对应的消息会被放在一个队列里。而在QApplication的exec()中,它会反复检查这个队列中的消息并加以处理。

当然,您也可以指定DirectConnection,在这种模式下,信号会直接调用对应的槽函数。这也是当信号和槽在同一个线程时的默认操作…

嘛…倒是还有一些其它的模式,但是这里就不加以赘述了。

废话说了这么多,我觉得还是有一点需要强调。

在TCP连接下,并没有“包”这个概念的存在。您同时发送的消息可能被几个recv分别收到,您好几次发送的消息也可能被一个recv收到。

如果没有特别的需求的话,我这里建议采用最基本的分包形式。

也就是在不定长的包(比如音频、视频包)前面加上长度。

因此,我们每次可以读取数据时,都只读取一个字节判断包的类型。如果是定长的包(举手,上课,下课,发言等…),那么只要按照长度读取剩下的内容就可以了。否则,我们需要再读取一个uint64_t来标识内容的长度,并进行读取。

还有一个需要注意的点是,计算机储存数据的方式分为大端序和小端序,在网络上通信的两台计算机如果有着不一样的字节序,可能会引起数据的错乱,还请注意。

不过在写大作业的过程中倒是可以假设都是小端序就是了…毕竟最近的大多数电脑好像都是小端序的…

嘛嘛嘛…废话说了这么多也差不多了?

大概…也就是写了asio和sqlite的两个丑陋的demo?(捂脸逃

明天我会再试着用一下屏幕拷贝和视频编码的相关库,并再弄一个缝合怪出来吧(

然后…然后…如果有人需要提到的那些库,或者要参考今天这堆乱七八糟的代码的话…请看这里!

T_used​cloud.tsinghua.edu.cn
71530d1c908dde46c92219b74b5526b2.png

嗯…因为懒癌只编译了MSVC的版本…

反正在学校里不用MSVC的人我可以默认为有解决编译问题的能力吧(

最后祝各位劳动节快乐!(困死)

Logo

开源鸿蒙跨平台开发社区汇聚开发者与厂商,共建“一次开发,多端部署”的开源生态,致力于降低跨端开发门槛,推动万物智联创新。

更多推荐