Qt实现简单TCP服务器
本文实例为大家分享了Qt学习记录之简单的TCP服务器,供大家参考,具体内容如下
简单的多连接TCP服务器?
本节我们使用Qt来编写一个简单的多连接TCP服务器程序,涉及到的功能有监听本地IP、打印上线客户端的IP端口号,接收客户端发来的文字信息并打印其IP端口号、单独或全部地向客户端发送文字信息、显示下线客户端的IP端口号,并具有踢人的功能。
? 该程序使用正点原子的网络助手来验证功能。Qt基于5.9.9版本。
1、创建工程以及配置工作
创建工程的过程就不再介绍了,这里我选择的是 QWidget ,因为比较简单。
文章图片
然后我们在 .pro 文件中添加网络的模块,否则待会添加头文件时会提示没有这个头文件。
文章图片
其他东西不需要看,只要在这后面加上 network 即可。
再然后,在 widget.h 头文件中包含两个头文件:
#include#include
使用Qt搭建TCP服务器需要两个套接字:
- 一个是QTcpServer套接字,这是是用来监听本地的某个IP及端口的。监听成功后,其他的TCP客户端就可以连接这个服务器了(当然前提是这个服务器监听的IP是公网IP,或者客户端与服务器在同一局域网下)
- 一个是QTcpSocket套接字,这个是服务器与客户端通信用的套接字。每当有一台客户端连接上了这台服务器,都会产生这样的一个套接字。客户端可以通过某个套接字来查看某个客户端的IP地址与端口号等信息,也可以拿着这个套接字单独地与这一个客户端收发数据。理论上来讲,一个TCP服务器是可以被无限个客户端连上的。
2、ui界面的设计
文章图片
1、comboBox。下拉列表框,用来选择监听的IP地址,因为能够被监听的地址肯定是本机拥有的地址,可以是有线、无线网卡的IP地址(局域网),也可以是宽带分配到的IP地址(广域网)。下一节我们再来学习如何查看本机支持的IP。
2、portEdit。旁边的单行文本框是用来输入要监听的端口号。有时候IP的某个端口号已经被某个程序占用,此时再去监听就会监听失败。
3、openbtn。点击开启按钮,监听选定的IP地址及端口号。监听失败会在Qt Creator的控制台打印失败的信息。监听成功,同样也会打印信息,并且IP下拉框、端口号文本框还有本按钮控件都会变成不可选中状态。
4、comboBox_2。选择某个已连接的客户端(如果有的话),或者选择全部。这个可以查看连接上服务器的客户端IP及端口号,单独或全部地向客户端发送信息,或者强制踢下线。
5、kickbtn。点击按钮,强制使选中的客户端下线。
6、closebtn。关闭服务器。停止监听之前选中的IP及端口号,并断开全部的TCP连接。
7、recvEdit。接收文本框。当客户端发来信息,会打印在上面。
8、clearbtn。清除接收文本框内的全部内容。
9、pushButton。在控制台上打印全部连接的客户端IP及端口号。
10、sendEdit。发送文本框。
11、sendbtn。点击发送按钮,会向某个、全部客户端发送文本框内的信息
大体的设计就是这样,目前只满足了基本的功能,后续可以增添更多的功能,并对界面进行美化
3、本地IP的获取
从这一节开始,我们将正式进行代码的编写
首先,打开命令行,输入 ipconfig
文章图片
这四个IP地址就我我电脑上可以监听的IP地址,我的程序就是以此来搭建TCP服务器。其他的IP地址,目前是监听不了的。
Qt获取本机IP
首先在 widge.cpp 文件中包含一个头文件
#include
然后在Widget类的构造函数中添加如下代码:
/*读取本机网卡信息...*/QString localHostName = QHostInfo::localHostName(); QHostInfo info = QHostInfo::fromName(localHostName); /*将本机所有的IPV4地址添加到comboBox下.*/foreach(QHostAddress ipAddress, info.addresses()){if(ipAddress.protocol() == QAbstractSocket::IPv4Protocol){qDebug() << ipAddress.toString(); ui->comboBox->addItem(ipAddress.toString()); }}
这里解释几点:
- 调用
info.addresses()
成员函数,返回的是一个 QList 容器,里面包含着本机全部的IP地址 - 所有的IP地址都在一个容器中,我们需要遍历这个容器,将里面的IP地址一一取出,这里用到了 foreach 去遍历。和for循环类似,首先定义一个用于接收的对象:ipAddress,然后会一一读取容器中的内容,赋值给这个对象。如果暂时弄不明白也没有关系,看多了自然就懂了。或者学过python,这句语句和
for ipAddr in ipAddrList:
一样。 if(ipAddress.protocol() == QAbstractSocket::IPv4Protocol)
这句用来判断遍历到的IP是不是IPV4的地址。因为目前IPV6还没有完全普及,而且不如IPV4易读。- 下拉列表控件使用
addItem()
成员函数添加元素。
文章图片
4、开启服务器
首先要有一个 QTcpServer 套接字对象,可以直接在 Widget 类中声明这么一个对象;也可以在类中声明一个对象指针,然后在构造函数中 new 一个对象,让这个指针指向它。这里我选择第二种方法。
/*在widget.h中类定义里添加*/QTcpServer *server; /*在widget.cpp中构造函数中添加*/server = new QTcpServer(this);
然后为开启按钮添加槽函数,这里我图方便直接右击控件并点击转到槽。
void Widget::on_openbtn_clicked(){/*监听本地IP加端口号.*/if(server->listen(QHostAddress(ui->comboBox->currentText()), ui->portEdit->text().toInt()) == false){/*监听失败,打印信息.*/qDebug()<<"listen false"; }else{/*监听成功,则将一些控件锁死.*/ui->openbtn->setDisabled(true); ui->portEdit->setDisabled(true); ui->comboBox->setDisabled(true); qDebug()<<"监听成功"; /*激活关闭按钮.*/ui->closebtn->setEnabled(true); }}
解释:
对于下拉列表控件(comboBox),使用
currentText()
成员函数获取选中元素的信息,返回QString型。对于单行文本控件(portEdit),使用
text()
成员函数返回文本,并用toInt()
转化为int型对于TCP服务器套接字(server),使用
listen(const QHostAddress &address = QHostAddress::Any, quint16 port = 0)
成员函数绑定IP及端口。对于大部分控件,使用
setDisabled(true)
成员函数可使控件变为不可点击状态最后,我们就可以开启服务器了。如果开启失败,往往是因为选择的端口号被占用,这里推荐使用8081这个端口号(8080经常会被占用)。开启成功后,我们就可以使用正点原子的网络助手去尝试连接它。
文章图片
可以看到,右边的网络助手已经处于连接成功的状态,因此可以证明TCP服务器已经被成功开启。其他的东西暂时不用看,后面会一一实现。
5、服务器等待客户端连接
在构造函数中添加如下代码:
connect(server, &QTcpServer::newConnection, this, [=](){/*获取新连接客户端的socket*/QTcpSocket *socket = server->nextPendingConnection(); /*将这个socket添加到List容器中...*/sockList.append(socket); /*获取客户端的IP地址和端口号信息,并转换为字符串.*/QString info = socket->peerAddress().toString() \+ ':' + QString::number(socket->peerPort()); /*将信息打印到文本框.*/ui->recvEdit->append("已连接:"+info); /*将客户端的信息添加到comboBox_2下.*/ui->comboBox_2->addItem(info); /*将新连接的socket对象的可以读取信号连接到接收槽函数.*/connect(socket, &QTcpSocket::readyRead, this, &Widget::on_recv); /*将新连接的socket对象的断开连接信号连接到断开槽函数.*/connect(socket, &QTcpSocket::disconnected, this, &Widget::on_disconnect); });
解释:
- 当有客户端连接到服务器,服务器套接字会触发一个 newConnection 信号。
- 服务器套接字通过
nextPendingConnection()
成员函数获取到用于和客户端通信的socket(QTcpSocket)。 - QTcpSocket 套接字通过
peerAddress()
和peerPort()
成员函数,得到该客户端的IP及端口号。toString()
是为了把IP信息转化为字符串,可以试试打印转化前的样子。QString::number(long n, int base = 10)
用于将数字转化为字符串。 - 接收文本框(recvEdit)使用
append()
成员函数打印文字。 - 最后的两个信号和槽的连接暂时不用看
- 这里我还使用了一个 QList 容器来保存已连接客户端的socket
/*在widget.h中类定义里添加*/QListsockList;
最后,我们就可以检验一下多连接时的状态了
文章图片
6、实现接收数据
如果用于通信的socket(QTcpSocket)接收到数据,就会触发 QTcpSocket::readyRead 信号,我这里写了一个槽函数,专门用来接收数据:
void Widget::on_recv(){/*找到触发信号的那个socket对象.*/QTcpSocket *sock = qobject_cast(sender()); /*读取信息并转化为字符串.*/QString info = "来自" + sock->peerAddress().toString() \+ ':' + QString::number(sock->peerPort()); /*将客户端的信息打印到文本框.*/ui->recvEdit->append(info); /*将接收到的数据也打印到文本框.*/ui->recvEdit->append(sock->readAll()); }
解释:
- 第一句是用来找到触发信号的那个对象(QTcpSocket)。因为这个程序允许多个客户端连接,每个客户端在连接后,server都会获取到与之相对应的QTcpSocket套接字对象,意思就是说套接字是与客户端一一对应的。因此找到触发信号的QTcpSocket对象,就等于找到了发送消息的那个客户端。
- 同样的,将客户端的信息打包成字符串,并且显示到接收文本框
- QTcpSocket对象,调用
readAll()
成员函数,来获取接收到的信息。
connect(socket, &QTcpSocket::readyRead, this, &Widget::on_recv);
了。可能有人会疑惑,这里的socket是局部变量,调用完会被释放掉,那么这个连接是不是传了个野指针进去?其实并不是的,socket是个指针,指向server->nextPendingConnection()
返回的QTcpSocket对象,真正的对象应该是存在内部堆内存中的,这里只是用了一个局部指针变量去接收它。而connect函数,传进去的是地址,地址自然就是内部QTcpSocket对象的地址了。最后,演示一下:
文章图片
注意:Qt使用utf-8编码格式,而原子的软件默认是GBk格式,所以发送中文之前,先把原子的软件全部改成utf-8格式。
实现清除数据
这个非常简单,就没有必要多说了:
void Widget::on_clearbtn_clicked(){/*点击清除按钮则清除接收文本框.*/ui->recvEdit->clear(); }
7、实现客户端的选择
对客户端的选择无非就两种情况:一种情况是选择全部连接上的客户端,一种是选择单独的某个客户端。
我为Widget类添加了一个属性来解决这两类问题:
QTcpSocket *currSock;
如果第二个下拉列表框选择了 All ,那么这个指针就会指向 NULL 。如果选择的是某个特定的IP及端口,那么这个指针就会指向对应的那个QTcpSocket对象了。
那么对象从哪里找?从前几节说过的容器中去找。这个容器用于保存当前连接上的客户端的socket。每当有客户端连上,这个容器就会将它的socket保存进来。每当有客户端下线,这个容器同样会把下线客户端的socket删掉。
QListsockList;
- QList 表示这是一个 QList 类型的容器,还有其他类型的容器
- 尖括号内的东西,表示这个一个装QTcpSocket对象指针的容器。因为这是一个模板类,还可以是int型的容器,取决于自己的定义。
- 最后的 sockList 则代表这个容器的名字
这里我同样是以右击控件再点转到槽的方式来自动生成槽函数:
文章图片
这里的信号不要选错了,当这个控件选择的选项发送变化时,会触发这个信号。下面是槽函数:
void Widget::on_comboBox_2_currentIndexChanged(const QString &arg1){/*如果当前选择的是All,当前sock指针就指向空.*/if(arg1 == "All"){currSock = NULL; return; }/*不然就读取选中的信息,将其拆分为IP地址和端口号.*/QStringList info = arg1.split(':'); QString ip = info[0]; int port = info[1].toInt(); /*遍历容器,找到对应的那个socket.*/foreach(QTcpSocket *sock, sockList){if(sock->peerAddress().toString() == ip && sock->peerPort() == port){/*当前sock指针指向找到的那个socket.*/currSock = sock; break; }}}
解释:
- 这个槽函数是带有参数的,参数就是选中选项的字符串。
- 后面的代码通过冒号用来拆分字符串。比如某个选项上面的内容是“192.168.1.1:8080”,调用这些代码后,就会被拆分成IP地址和端口号。
- 最后遍历容器内的所有socket,找到IP与端口与选项中一样的那个socket。
8、实现发送功能
到了这一步,程序就开始越写越简单了
为发送按钮添加一个槽函数:
void Widget::on_sendbtn_clicked(){/*如果是为开启服务器、未连接或者选择All的时候,currSock才会指向空前两种情况容器是空的,所以也不会出错。如果是选中All时候,且多个客户端连接,则会遍历容器中所有的socket,并且一一发送出去.*/if(currSock == NULL){foreach(QTcpSocket *sock, sockList){sock->write(ui->sendEdit->toPlainText().toUtf8()); }}else{/*如果选择的是一个特定的客户端,则只向它发送.*/currSock->write(ui->sendEdit->toPlainText().toUtf8()); }}
解释:
当 currSock 指针指向空的时候,可能有以下几种状态:
程序刚刚初始化完成,此时还没有监听
程序已经开始监听,且有一个或多个客户端上线
程序开始监听,还没有客户端连接,或者之前连接的客户端全部下线
对于1、3两种情况,sockList 容器中是空的,因此即使遍历也遍历不到任何东西,自然也就不会给不存在的socket发送信息。对于第二种情况,也就不用多解释了。当然,这样的程序可能是存在问题的。可以多做一些判断,并且弹出警告提示框,提醒用户做了错误的操作。
最后,演示一下。服务器先给1号客户端发送你好1,接着分别给2、3号客户端发送你好2、3,最后给所有的客户端发送你好123:
文章图片
9、实现客户端下线
之前我们在构造函数中添加了这一句代码:
connect(socket, &QTcpSocket::disconnected, this, &Widget::on_disconnect);
把套接字的断开连接信号连接到了断开连接槽函数,槽函数内容如下:
void Widget::on_disconnect(){/*找到触发信号的那个socket对象.*/QTcpSocket *sock = qobject_cast(sender()); /*将断开连接的那个socket从List容器中移除.*/sockList.removeOne(sock); /*将客户端信息转化为字符串.*/QString info = sock->peerAddress().toString() \+ ':' + QString::number(sock->peerPort()); /*将断开连接的消息打印到文本框.*/ui->recvEdit->append(info+"已断开"); /*根据字符串找到comboBox_2中的对应元素的索引号*/int index = ui->comboBox_2->findText(info); /*删除那个元素.*/ui->comboBox_2->removeItem(index); /*将新连接的socket对象的可以读取信号与接收槽函数断开.*/disconnect(sock, &QTcpSocket::readyRead, this, &Widget::on_recv); /*将新连接的socket对象的断开连接信号与断开槽函数断开.*/disconnect(sock, &QTcpSocket::disconnected, this, &Widget::on_disconnect); }
解释:
- 同样先找到触发信号的那一个套接字。
- 使用
removeOne
成员函数将它从容器中删除 - 将对应客户端的信息打包成字符串,并且将离线的消息打印到接收文本框
- 将下拉列表框中的对应项也给删除
- 断开信号和槽的连接
文章图片
10、实现踢人功能
【Qt实现简单TCP服务器】刚才是客户端自己下线,现在是服务器主动踢人下线。但无论是哪一种,在断开连接的时候都会触发QTcpSocket::disconnected 信号
为踢人按钮添加槽函数:
void Widget::on_kickbtn_clicked(){/*将全部的客户端踢下线.*/if(currSock == NULL){foreach(QTcpSocket *sock, sockList){sock->close(); }}else{/*将选中的那一个客户端踢下线.*/currSock->close(); }}
到了这里,我感觉已经没有什么好说的了,只需知道调用
close()
函数可以断开连接。这里也不做演示了。
11、实现关闭服务器
关闭服务器主要需要做两件事:
1、停止监听IP及端口。
2、关闭所有与客户端之间的连接。因为停止监听是不够的,之前的连接还是存在的,甚至还能继续收发数据。
void Widget::on_closebtn_clicked(){currSock = NULL; /*关闭监听.*/server->close(); /*将一些控件恢复.*/ui->closebtn->setDisabled(true); ui->openbtn->setEnabled(true); ui->portEdit->setEnabled(true); ui->comboBox->setEnabled(true); /*遍历之前全部连接的socket,并一一断开.*/foreach(QTcpSocket *sock, sockList){sock->close(); }}
演示:
文章图片
在断开连接后,下拉框会删除掉之前全部的连接信息,被迫选择到“All”,然后 currSock 指针也会自然而然地指向NULL。当然不放心的也可以手动弄一下。
好了,至此全部的功能已经讲解完毕了,多的两个按钮是我自己调试用的。如果绑定的是一个公网IP,时间长了也许会有一些奇怪的信息显示在接收文本框,不用害怕,可能是哪个迷路的孩子找错家了。
总结
1、这个程序总体而言是比较简单的,实现的功能简单、设计的想法也很简单,连注释加空行也不过两百行代码,但我却花了这么长的篇幅去介绍它,可能有些大佬看着会嫌啰嗦,但其实我写这样的一篇文档也是很花费时间的,我自己也觉得很累。我主要是想锻炼一下自己的文档能力,也希望能帮助其他人度过入门的难关,往后的文章可能不会写这么详细了。
2、Qt Creator 好像有什么奇怪的问题。如果用 /**/ 的方式写注释,编译是不通过的。解决办法是最后一个字用英文字符。还有控件自动生成的槽函数一直有黄色警告,虽然不影响使用,但感觉很变扭,希望有知道原因的大佬能不啬赐教。
文章图片
3、这个程序只能用于非常简单且频率很低的数据收发。信号和槽的机制虽然很容易使用,但并不好用在并发的场合下。且 foreach 遍历是比较耗费时间的,假如有上万个客户端都连接到这台服务器,并且服务器要给所有客户端都发送一条较长的信息,这个时候就不太好了。所以,后续要升级,肯定要使用线程的方式去解决这些问题。
完整代码
widget.h
#ifndef WIDGET_H#define WIDGET_H#include#include #include QT_BEGIN_NAMESPACEnamespace Ui { class Widget; }QT_END_NAMESPACEclass Widget : public QWidget{Q_OBJECTpublic:Widget(QWidget *parent = nullptr); ~Widget(); private slots:void on_openbtn_clicked(); void on_clearbtn_clicked(); void on_recv(); void on_disconnect(); void on_sendbtn_clicked(); void on_pushButton_clicked(); void on_closebtn_clicked(); void on_comboBox_2_currentIndexChanged(const QString &arg1); void on_kickbtn_clicked(); private:Ui::Widget *ui; QTcpServer *server; QList sockList; QTcpSocket *currSock; }; #endif // WIDGET_H
widget.cpp
#include "widget.h"#include "ui_widget.h"#include#include Widget::Widget(QWidget *parent): QWidget(parent), ui(new Ui::Widget){ui->setupUi(this); /*整个对象的初始化工作.*/currSock = NULL; //当前的socket对象指针指向空ui->recvEdit->setReadOnly(true); //将接受文本框设为只读模式ui->closebtn->setEnabled(true); //将关闭按钮设为不可点击,因为此时服务器还未被开启ui->portEdit->setText("8081"); //默认端口号为8081/*server指针指向new创建出来的QTCPServer对象.*/server = new QTcpServer(this); /*读取本机网卡信息...*/QString localHostName = QHostInfo::localHostName(); QHostInfo info = QHostInfo::fromName(localHostName); /*将本机所有的IPV4地址添加到comboBox下.*/foreach(QHostAddress ipAddress, info.addresses()){if(ipAddress.protocol() == QAbstractSocket::IPv4Protocol){ui->comboBox->addItem(ipAddress.toString()); }}/*将server对象的新连接信号连接到槽.*/connect(server, &QTcpServer::newConnection, this, [=](){/*获取新连接客户端的socket*/QTcpSocket *socket = server->nextPendingConnection(); /*将这个socket添加到List容器中...*/sockList.append(socket); /*获取客户端的IP地址和端口号信息,并转换为字符串.*/QString info = socket->peerAddress().toString() \+ ':' + QString::number(socket->peerPort()); /*将信息打印到文本框.*/ui->recvEdit->append("已连接:"+info); /*将客户端的信息添加到comboBox_2下.*/ui->comboBox_2->addItem(info); /*将新连接的socket对象的可以读取信号连接到接收槽函数.*/connect(socket, &QTcpSocket::readyRead, this, &Widget::on_recv); /*将新连接的socket对象的断开连接信号连接到断开槽函数.*/connect(socket, &QTcpSocket::disconnected, this, &Widget::on_disconnect); }); }Widget::~Widget(){delete ui; }void Widget::on_recv(){/*找到触发信号的那个socket对象.*/QTcpSocket *sock = qobject_cast (sender()); /*读取信息并转化为字符串.*/QString info = "来自" + sock->peerAddress().toString() \+ ':' + QString::number(sock->peerPort()); /*将客户端的信息打印到文本框.*/ui->recvEdit->append(info); /*将接收到的数据也打印到文本框.*/ui->recvEdit->append(sock->readAll()); }void Widget::on_disconnect(){/*找到触发信号的那个socket对象.*/QTcpSocket *sock = qobject_cast (sender()); /*将断开连接的那个socket从List容器中移除.*/sockList.removeOne(sock); /*将客户端信息转化为字符串.*/QString info = sock->peerAddress().toString() \+ ':' + QString::number(sock->peerPort()); /*将断开连接的消息打印到文本框.*/ui->recvEdit->append(info+"已断开"); /*根据字符串找到comboBox_2中的对应元素的索引号*/int index = ui->comboBox_2->findText(info); /*删除那个元素.*/ui->comboBox_2->removeItem(index); /*将新连接的socket对象的可以读取信号与接收槽函数断开.*/disconnect(sock, &QTcpSocket::readyRead, this, &Widget::on_recv); /*将新连接的socket对象的断开连接信号与断开槽函数断开.*/disconnect(sock, &QTcpSocket::disconnected, this, &Widget::on_disconnect); }void Widget::on_openbtn_clicked(){/*监听本地IP加端口号.*/if(server->listen(QHostAddress(ui->comboBox->currentText()), ui->portEdit->text().toInt()) == false){/*监听失败打印信息.*/qDebug()<<"listen false"; }else{/*监听成功则将一些控件锁死.*/ui->openbtn->setDisabled(true); ui->portEdit->setDisabled(true); ui->comboBox->setDisabled(true); qDebug()<<"监听成功"; /*激活关闭按钮.*/ui->closebtn->setEnabled(true); }}void Widget::on_clearbtn_clicked(){/*点击清除按钮则清除接收文本框.*/ui->recvEdit->clear(); }void Widget::on_sendbtn_clicked(){/*如果是为开启服务器、未连接或者选择All的时候,currSock才会指向空前两种情况容器是空的,所以也不会出错。如果是选中All时候,且多个客户端连接,则会遍历容器中所有的socket,并且一一发送出去.*/if(currSock == NULL){foreach(QTcpSocket *sock, sockList){sock->write(ui->sendEdit->toPlainText().toUtf8()); }}else{/*如果选择的是一个特定的客户端,则只向它发送.*/currSock->write(ui->sendEdit->toPlainText().toUtf8()); }}void Widget::on_pushButton_clicked(){foreach(QTcpSocket *sock, sockList){qDebug()close(); /*将一些控件恢复.*/ui->closebtn->setDisabled(true); ui->openbtn->setEnabled(true); ui->portEdit->setEnabled(true); ui->comboBox->setEnabled(true); /*遍历之前全部连接的socket,并一一断开.*/foreach(QTcpSocket *sock, sockList){sock->close(); }}void Widget::on_comboBox_2_currentIndexChanged(const QString &arg1){/*如果当前选择的是All,当前sock指针就指向空.*/if(arg1 == "All"){currSock = NULL; return; }/*不然就读取选中的信息,将其拆分为IP地址和端口号.*/QStringList info = arg1.split(':'); QString ip = info[0]; int port = info[1].toInt(); /*遍历容器,找到对应的那个socket.*/foreach(QTcpSocket *sock, sockList){if(sock->peerAddress().toString() == ip && sock->peerPort() == port){/*当前sock指针指向找到的那个socket.*/currSock = sock; break; }}}void Widget::on_kickbtn_clicked(){/*将全部的客户端踢下线.*/if(currSock == NULL){foreach(QTcpSocket *sock, sockList){sock->close(); }}else{/*将选中的那一个客户端踢下线.*/currSock->close(); }}
main.cpp
维持原样不需改动
以上就是本文的全部内容,希望对大家的学习有所帮助,也希望大家多多支持脚本之家。
推荐阅读
- 利用MongoDB的SplitVector命令实现并发数据迁移
- ReentrantReadWriteLock的简单实用
- css实现炫酷的圆环相交转动动画
- 新形势下,集团企业的资产管理如何实现精细化()
- leetcode 594. Longest Harmonious Subsequence 最长和谐子序列(简单).md
- 面试官(设计模式之简单工厂模式)
- 二分法基本思路和实现
- intellij-idea|全网最详细(基于SpringMVC实现CRUD&文件上传下载)
- python|手把手教你使用Python获取B站视频并在本地实现弹幕播放功能
- python|python写一个简单的爬虫程序(爬取快手)(附源码)