当前位置: 首页 > news >正文

《深入剖析TCP Socket API:从连接到断开的全链路解读》

目录

一、TCP socket API 详解

1. connect 建立连接

2. listen 设置监听状态

3. accept 获得连接请求并建立连接

二、Tcp Echo Server 网络字典

1. Echo Server

(1)代码设计

(2)TcpServer.hpp

(3)Common.hpp

(4)InetAddr.hpp

(5)TcpServer.cc

(6)TcpClient.cc

(7)运行结果

2. Echo Server 多进程版本

3. Echo Server 多线程版本

4. Echo Server 线程池版本

三、Tcp 远程命令执行功能 --- 多线程

(1)Command.hpp

(2)TcpServer.cc

(3)运行结果


上一篇我们的实例都采用的是无连接的套接字,对于无连接的套接字,数据包到达时可能已经没有次序,因此如果不能将所有的数据放在个数据包里,则在应用程序中就必须关心数据包的次序。数据包的最大尺寸是通信协议的特征。另外,对于无连接的套接字,数据包可能会丢失。如果应用程序不能容忍这种丢失,必须使用面向连接的套接字。

容忍数据包丢失意味着两种选择。一种选择是,如果想和对等方可靠通信,就必须对数据包编号,并且在发现数据包丢失时,请求对等应用程序重传,还必须标识重复数据包并丢弃它们,因为数据包可能会延迟或疑似丢失,可能请求重传之后,它们又出现了。

另一种选择是,通过让用户再次尝试那个命令来处理错误。对于简单的应用程序,这可能就足够了,但对于复杂的应用程序,这种选择通常不可行。因此,一般在这种情况下使用面向连接的套接字比较好。

面向连接的套接字的缺陷在于需要更多的时间和工作来建立一个连接,并且每个连接都需要消耗较多的操作系统资源。

一、TCP socket API 详解

1. connect 建立连接

TCP与UDP类似,除了那些基本API外,TCP协议最重要的就是它是一个面向连接的网络服务。那么在开始交换数据以前,需要在请求服务的进程套接字(客户端)和提供服务的进程套接字(服务器)之间建立一个连接。客户端使用 connect 函数来建立连接。当建立连接成功时,底层会自动bind。

#include <sys/socket.h>int connect(int sockfd, const struct sockaddr *addr, socklen_t addrlen);

参数:

• sockfd: 套接字描述符,由 socket() 函数创建

• addr: 指向目标服务器地址结构的指针

• addrlen: 地址结构的长度

返回值:成功返回 0,失败返回 -1。

• 在 connect 中指定的地址 addr 是我们想与之通信的服务器地址。如果 sockd 没有绑定到一个地址,connect 会给调用者绑定一个默认地址。

• 当尝试连接服务器时,出于一些原因,连接可能会失败。要想一个连接请求成功,要连接的计算机必须是开启的,并且正在运行,服务器必须绑定到一个想与之连接的地址上,并且服务器的等待连接队列要有足够的空间。因此,应用程序必须能够处理connect返回的错误,这些错误可能是由一些瞬时条件引起的。网络编程必须假设失败是常态,成功是特例,因此健壮的错误处理不是可选项,而是必选项。下面显示一种如何处理瞬时connect错误的方法:

#include <sys/types.h>
#include <sys/socket.h>
#include <unistd.h>
#include <stdio.h>
#include <string.h>
#include <errno.h>#define MAXSLEEP 128int connect_retry(int domain, int type, int protocol, const struct sockaddr *addr, socklen_t alen)
{int numsec, fd;// 指数退避重试:1, 2, 4, 8, 16, 32, 64, 128 秒for (numsec = 1; numsec <= MAXSLEEP; numsec <<= 1) {// 创建套接字if ((fd = socket(domain, type, protocol)) < 0) {return (-1);  // 套接字创建失败,通常是系统资源问题}// 尝试连接if (connect(fd, addr, alen) == 0) {// 连接成功return (fd);}// 连接失败,关闭套接字close(fd);// 最后一次尝试后不等待if (numsec <= MAXSLEEP / 2) {sleep(numsec);}}return -1;  // 所有重试都失败
}

指数补偿算法:如果调用connect失败,进程会休眠一小段时间,然后进入下次循环再次尝试每次循环休眠时间会以指数级增加,直到最大延迟为MAXSLEEP,最后一次尝试后不等待直接返回。

• 如果套接字处于非阻塞模式,那么在连接不能马上建立时,connect将会返回-1并将errno设置为特殊的错误码EINPROGRESS。应用程序可以使用poll或select判断文件描述符何时可写。如果可写,连接完成。

connect 函数还可以用于无连接的网络服务(SOCK_DGRAM)。这看起来有点矛盾,实际上却是一个不错的选择。如果用SOCK_DGRAM套接字调用connect,传送的报文的目标地址会设置成connect调用中所指定的地址,这样每次传送报文时就不需要在提供地址。另外,仅能接收来自指定地址的报文。

2. listen 设置监听状态

#include <sys/socket.h>// 将套接字置于监听状态
int listen(int sockfd, int backlog);
// 成功返回0,失败返回-1

参数backlog提供一个提示,提示系统该进程所要如对的未完成连接请求数量。其实际值由系统决定,但上限由<sys/socket.h>中的 SOMAXCONN 指定。(对于TCP,其默认值是128)。

一旦队列满,系统就会拒绝多余的连接请求,所以backlog的值应该基于服务器期望负载和处理量来选择,其中处理量是指接受连接请求与启动服务的数量。

3. accept 获得连接请求并建立连接

一旦服务器调用了listen,所用的套接字就能接收连接请求。使用accept函数获得连接请求并建立连接。

• 获取连接请求是从内核中直接获取的,建立连接是因为服务器处于 listen 状态,与 accept 无关。

#include <sys/socket.h>// 接受客户端连接
int accept(int sockfd, struct sockaddr *addr, socklen_t *addrlen);
// 成功返回套接字描述符,失败返回-1

• 函数 accept 所返回的文件描述符是套接字描述符,该描述符连接到调用 connect 的客户端。这个新的套接字描述符和原始套接字(sockfd)具有相同的套接字类型和地址族。

• 如果不关心客户端标识,可以将参数 addr 和 Ien 设为 NULL 。否则,就要在调用 accept 之前,将 addr 参数设为足够大的缓冲区来存放地址,并且将 len 指向的整数设为这个缓冲区的字节大小返回时,accept会在缓冲区填充客户端的地址,并且更新指向 len 的整数来反映该地址的大小。

• 如果没有连接请求在等待,accept 会阻塞直到一个请求到来。如果 sockd 处于非阻塞模式accept 会返回-1,并将 errno 设置为 EAGAIN 或 EWOULDBLOCK。

二、Tcp Echo Server 网络字典

1. Echo Server

(1)代码设计

① 服务器是禁止拷贝的,通过继承 NoCopy 类来禁止拷贝,可以达到这一目的。

class NoCopy
{
protected:NoCopy() = default;virtual ~NoCopy() = default;  // 如果有多态需求private:NoCopy(const NoCopy&) = delete;NoCopy& operator=(const NoCopy&) = delete;
};class Server : private NoCopy  // 私有继承,强调是实现继承
{// 实现...
};

② 简化 socket 编程中的地址类型转换

取 sockaddr_in 结构体的地址,强制转换为通用的 sockaddr 指针

#define CONV(addr) ((struct sockaddr*)&addr)
struct sockaddr_in server_addr;
memset(&server_addr, 0, sizeof(server_addr));
server_addr.sin_family = AF_INET;
server_addr.sin_port = htons(8080);
server_addr.sin_addr.s_addr = INADDR_ANY;// 不使用宏的写法
int result = bind(socket_fd, (struct sockaddr*)&server_addr, sizeof(server_addr));// 使用宏的写法
int result = bind(socket_fd, CONV(server_addr), sizeof(server_addr));

③ 只要Tcp服务器处于监听状态,就算没有被accept,就已经可以被连接了!

(2)TcpServer.hpp

// TcpServer.hpp
#pragma once
#include <iostream>
#include <string>
#include <cstring>
#include <memory>
#include <sys/types.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include "Common.hpp"
#include "Log.hpp"
#include "InetAddr.hpp"using namespace LogMoudle;const static int defaultsockfd = -1;
const static int backlog = 8;
// 禁止拷贝
class TcpServer : private NoCopy // 私有继承,强调是实现继承
{
public:TcpServer(uint16_t port) : _port(port), _listensockfd(defaultsockfd), _isrunning(false){}void Init(){// 1. 创建套接字_listensockfd = socket(AF_INET, SOCK_STREAM, 0);if (_listensockfd < 0){LOG(LogLevel::FATAL) << "socket error";exit(SOCKET_ERR);}LOG(LogLevel::INFO) << "socket success : " << _listensockfd;// 2. 绑定众所周知的端口号InetAddr local(_port);int n = bind(_listensockfd, local.NetAddrPtr(), local.NETAddrLen());if (n < 0){LOG(LogLevel::FATAL) << "bind error";exit(BIND_ERR);}LOG(LogLevel::INFO) << "bind success : " << _listensockfd;// 3. 设置监听n = listen(_listensockfd, backlog);if (n < 0){LOG(LogLevel::FATAL) << "listen error";exit(LISTEN_ERR);}LOG(LogLevel::INFO) << "listen success : " << _listensockfd;}// 提供服务void Service(int sockfd, InetAddr &peer){char buffer[1024];while (true){// 1. 读取数据// a. n > 0 读取成功// b. n < 0 读取失败// c.c == 0 对端把连接关闭了,读到了文件结尾ssize_t n = read(sockfd, buffer, sizeof(buffer) - 1);if (n > 0){buffer[n] = 0;                                                   // 设置为字符串LOG(LogLevel::DEBUG) << peer.StringAddr() << " say# " << buffer; // 客户端说的消息// 2. 写数据std::string echo_string = "echo# ";echo_string += buffer;write(sockfd, echo_string.c_str(), echo_string.size()); // 客户端回显}else if(n == 0){LOG(LogLevel::DEBUG) << peer.StringAddr() << " 退出了...";close(sockfd);break;}else{LOG(LogLevel::DEBUG) << peer.StringAddr() << " 异常...";close(sockfd);break;}}}void Run(){_isrunning = true;while (_isrunning){// 1. 获取连接struct sockaddr_in peer;socklen_t len = sizeof(peer);int sockfd = accept(_listensockfd, CONV(peer), &len);if (sockfd < 0){LOG(LogLevel::WARNING) << "accept error";continue;}InetAddr addr(peer);LOG(LogLevel::INFO) << "accept success, peer addr : " << addr.StringAddr();// Echo Server --- 单进程读取(不会存在的)Service(sockfd, addr);}_isrunning = false;}~TcpServer(){}private:uint16_t _port;int _listensockfd;bool _isrunning;
};

(3)Common.hpp

// Common.hpp
#pragma once
#include <iostream>
#include <string>
#include <cstring>
#include <memory>
#include <sys/types.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include "Common.hpp"
#include "Log.hpp"// 错误码
enum ExitCode
{ok,USAGE_ERR,SOCKET_ERR,BIND_ERR,LISTEN_ERR,CONNECT_ERR
};class NoCopy
{
public:// 让构造函数和析构函数可以被子类正常使用NoCopy() = default;virtual ~NoCopy() = default;// 删除拷贝构造和赋值语句NoCopy(const NoCopy &) = delete;const NoCopy &operator = (const NoCopy&) = delete;
};// 简化 socket 编程中的地址类型转换
// 取 sockaddr_in 结构体的地址,强制转换为通用的 sockaddr 指针
#define CONV(addr) ((struct sockaddr*)&addr)

(4)InetAddr.hpp

// 网络地址与主机地址进行转换的类
#pragma once
#include <iostream>
#include <string>
#include <cstring>
#include <sys/types.h>
#include <sys/socket.h>
#include <arpa/inet.h>
#include <netinet/in.h>
#include "Common.hpp"class InetAddr
{
public:InetAddr(const sockaddr_in &addr) : _addr(addr){// 网络地址(网络)转化成ip和端口(主机)_port = ntohs(_addr.sin_port);// _ip = inet_ntoa(_addr.sin_addr); // 公共缓冲区二次调用会覆盖char ipbuffer[1024]; // 用户自定义缓冲需inet_ntop(AF_INET, &_addr.sin_addr, ipbuffer, sizeof(ipbuffer));_ip = ipbuffer;}InetAddr(const std::string &ip, uint16_t port): _ip(ip), _port(port){// 文本字符串格式(主机)转换成网络字节序的二进制地址(网络)memset(&_addr, 0, sizeof(_addr));_addr.sin_family = AF_INET;inet_pton(AF_INET, _ip.c_str(), &_addr.sin_addr);_addr.sin_port = htons(_port);}InetAddr(uint16_t port): _port(port), _ip("0"){// 文本字符串格式(主机)转换成网络字节序的二进制地址(网络)memset(&_addr, 0, sizeof(_addr));_addr.sin_family = AF_INET;_addr.sin_addr.s_addr = INADDR_ANY; // IP地址设置成众所周知的,通过端口号创建地址inet_pton(AF_INET, _ip.c_str(), &_addr.sin_addr);_addr.sin_port = htons(_port);}uint16_t Port() { return _port; }std::string Ip() { return _ip; }const struct sockaddr_in &NetAddr() { return _addr; } // 注意要返回左值(&const struct sockaddr *NetAddrPtr(){return CONV(_addr);}socklen_t NETAddrLen(){return sizeof(_addr);}bool operator==(const InetAddr &addr) // 重载{return addr._ip == _ip && addr._port == _port; // 端口号和ip相同就是==}std::string StringAddr() // 提供一个返回字符串风格的接口{return _ip + ":" + std::to_string(_port);}~InetAddr() {}private:struct sockaddr_in _addr;std::string _ip;uint16_t _port;
};

(5)TcpServer.cc

#include "TcpServer.hpp"void Usage(std::string proc)
{std::cerr << "Usage: " << proc << " port" << std::endl;
}// ./tcpserver port
int main(int argc, char *argv[])
{if (argc != 2){Usage(argv[0]);exit(USAGE_ERR);}uint16_t port = std::stoi(argv[1]);Enable_Console_Log_Strategy();std::unique_ptr<TcpServer> tsvr = std::make_unique<TcpServer>(port);tsvr->Init();tsvr->Run();return 0;
}

(6)TcpClient.cc

#include <iostream>
#include "Common.hpp"
#include "InetAddr.hpp"void Usage(std::string proc)
{std::cerr << "Usage: " << proc << " server_ip server_port" << std::endl;
}int main(int argc, char *argv[])
{if (argc != 3){Usage(argv[0]);exit(USAGE_ERR);}std::string serverip = argv[1];uint16_t serverport = std::stoi(argv[2]);// 1. 创建套接字int sockfd = socket(AF_INET, SOCK_STREAM, 0);if (sockfd < 0){std::cerr << "socket error" << std::endl;exit(SOCKET_ERR);}// 不显示bind,随机方式选择端口号, listen/accept都不用做// 2. 向目标服务器发送建立连接的消息connectInetAddr serveraddr(serverip, serverport);int n = connect(sockfd, serveraddr.NetAddrPtr(), serveraddr.NETAddrLen());if(n < 0){std::cerr << "connect error" << std::endl;exit(CONNECT_ERR);}// 3. echo clientwhile(true){std::string line;std::cout << "Please Enter@ ";std::getline(std::cin, line);write(sockfd, line.c_str(), line.size()); // 写入char buffer[1024];ssize_t size = read(sockfd, buffer, sizeof(buffer) - 1);  // 读出if(size > 0){buffer[size] = 0;std::cout << "server echo# " << buffer << std::endl;}}return 0;
}

(7)运行结果

服务端:

$ make
g++ -o tcpclient TcpClient.cc -std=c++17 -Wno-deprecated
g++ -o tcpserver TcpServer.cc -std=c++17 -Wno-deprecated
zyt@VM-24-14-ubuntu:~/linux-log/code_25_10_21/EchoServer$ ./tcpserver 8080
[2025-10-23 17:59:33][INFO][27953][TcpServer.hpp][34]- socket success : 3
[2025-10-23 17:59:33][INFO][27953][TcpServer.hpp][44]- bind success : 3
[2025-10-23 17:59:33][INFO][27953][TcpServer.hpp][53]- listen success : 3
[2025-10-23 17:59:36][INFO][27953][TcpServer.hpp][105]- accept success, peer addr : 127.0.0.1:58232
[2025-10-23 17:59:40][DEBUG][27953][TcpServer.hpp][70]- 127.0.0.1:58232 say# nihao
[2025-10-23 17:59:42][DEBUG][27953][TcpServer.hpp][70]- 127.0.0.1:58232 say# 你好
[2025-10-23 17:59:46][DEBUG][27953][TcpServer.hpp][70]- 127.0.0.1:58232 say# hello
[2025-10-23 17:59:48][DEBUG][27953][TcpServer.hpp][70]- 127.0.0.1:58232 say# 123
[2025-10-23 18:00:09][DEBUG][27953][TcpServer.hpp][78]- 127.0.0.1:58232 退出了...

客户端:

$ ./tcpclient 127.0.01 8080
Please Enter@ nihao
server echo# echo# nihao
Please Enter@ 你好
server echo# echo# 你好
Please Enter@ hello
server echo# echo# hello
Please Enter@ 123
server echo# echo# 123
Please Enter@ ^C

但此时的设计只允许单进程进行读写,只是一个测试版本!

2. Echo Server 多进程版本

多进程并发服务器:主要特点是通过"孙子进程"来避免僵尸进程

// TcpServer.hpp
void Run(){_isrunning = true;while (_isrunning){// 1. 获取连接struct sockaddr_in peer;socklen_t len = sizeof(peer);int sockfd = accept(_listensockfd, CONV(peer), &len);if (sockfd < 0){LOG(LogLevel::WARNING) << "accept error";continue;}InetAddr addr(peer);LOG(LogLevel::INFO) << "accept success, peer addr : " << addr.StringAddr();// Echo Server --- 单进程读取(不会存在的)// Service(sockfd, addr);// version2 --- 多进程版本pid_t id = fork();if(id < 0){LOG(LogLevel::FATAL) << "fork error";exit(FORK_ERR);}else if(id == 0){// childclose(_listensockfd); // 关掉不需要的文件描述符if(fork() > 0) // 创建孙子进程,子进程被退出exit(OK);Service(sockfd, addr); // 由孙子进程执行, 孤儿进程(由系统回收)exit(OK);}else{// fatherclose(sockfd); // 关掉不需要的文件描述符pid_t rid = waitpid(id, nullptr, 0); // (1) 用signal函数忽略SIG_IGN信号,可以避免阻塞 (2)孙子进程(void)rid;}}_isrunning = false;}

3. Echo Server 多线程版本

ThreadData类:封装线程执行所需的所有数据,通过void*指针安全传递给线程函数。

线程入口函数Routine:使用pthread_detach让线程结束后自动回收资源,通过tsvr->Service调用主服务器的业务处理方法,负责清理动态分配的ThreadData对象

 // TcpServer.hppclass ThreadData{public:ThreadData(int fd, InetAddr &ar, TcpServer *s): sockfd(fd), addr(ar), tsvr(s){}public:int sockfd;InetAddr addr;TcpServer *tsvr;};static void *Routine(void *args){pthread_detach(pthread_self()); // 分离ThreadData *td = static_cast<ThreadData*>(args);td->tsvr->Service(td->sockfd, td->addr);delete td;return nullptr;}void Run(){_isrunning = true;while (_isrunning){// 1. 获取连接struct sockaddr_in peer;socklen_t len = sizeof(peer);int sockfd = accept(_listensockfd, CONV(peer), &len);if (sockfd < 0){LOG(LogLevel::WARNING) << "accept error";continue;}InetAddr addr(peer);LOG(LogLevel::INFO) << "accept success, peer addr : " << addr.StringAddr();// version3 --- 多线程ThreadData *td = new ThreadData(sockfd, addr, this);pthread_t tid;pthread_create(&tid, nullptr, Routine, td);}_isrunning = false;}

4. Echo Server 线程池版本

 "任务队列 + 线程池"模型:将连接处理作为任务提交到线程池,由预先创建的工作线程统一处理

// version4 --- 线程池一般适合处理短服务
// 将新连接和客户端构建一个新的任务,push到线程池中
ThreadPool<task_t>::GetInstance()->Enqueue([this, sockfd, &addr](){this->Service(sockfd, addr);
});

三、Tcp 远程命令执行功能 --- 多线程

(1)Command.hpp

这里实现时,设置的白名单,只允许执行预设的安全命令。

popen 用于创建管道(父子进程之间)并执行shell命令(子进程的stdout重定向到管道)。

// Command.hpp
#pragma once#include <iostream>
#include <string>
#include <set>
#include <cstdio>
#include "Common.hpp"
#include "InetAddr.hpp"class Command
{
public:Command(){// 1. 添加远程命令白名单_WhiteListCommand.insert("ls");_WhiteListCommand.insert("pwd");_WhiteListCommand.insert("ls -l");_WhiteListCommand.insert("touch haha.txt");_WhiteListCommand.insert("whoami");}bool IsSafeCommand(const std::string& cmd){auto iter = _WhiteListCommand.find(cmd); // 迭代器查找return iter != _WhiteListCommand.end();}// 2. 执行命令std::string Execute(const std::string& cmd, InetAddr &addr){// 检查是否在白名单if(!IsSafeCommand(cmd)){return std::string("坏人!");}std::string who = addr.StringAddr(); // 执行者FILE *fp = popen(cmd.c_str(), "r");if(nullptr == fp){return std::string("你要执行的命令不存在!");}std::string res;char line[1024];while(fgets(line, sizeof(line), fp)){res += line; // 读取命令 }pclose(fp);std::string result =  who + " execute done, resault is : \n" + res;LOG(LogLevel::DEBUG) << result;return result;}~Command(){}
private:
std::set<std::string> _WhiteListCommand;
};

(2)TcpServer.cc

设计要点:

• bind() 参数绑定 Command 对象 cmd,函数是Execute()。

• 预留的两个参数,_1 对应实际参数【const std::string& command】,_2 对应实际参数【InetAddr &addr】。

func_t f = std::bind(&Command::Execute,  // ① 成员函数地址&cmd,                // ② 对象实例指针std::placeholders::_1,  // ③ 第一个参数占位符std::placeholders::_2); // ④ 第二个参数占位符
#include "TcpServer.hpp"
#include "Dict.hpp"
#include "Command.hpp"std::string defaulthandler(const std::string &word, InetAddr &peer)
{LOG(LogLevel::DEBUG) << "回调到了defaulthandler()";std::string s = "haha, ";s += word;return s;
}void Usage(std::string proc)
{std::cerr << "Usage: " << proc << " port" << std::endl;
}// ./tcpserver port
int main(int argc, char *argv[])
{if (argc != 2){Usage(argv[0]);exit(USAGE_ERR);}uint16_t port = std::stoi(argv[1]);Enable_Console_Log_Strategy();// // 1. 翻译模块// Dict d;// d.LoadDict();// std::unique_ptr<TcpServer> tsvr = std::make_unique<TcpServer>(port, [&d](const std::string &word, InetAddr &addr){//     return d.Translate(word, addr);// });// 2. 命令行执行模块Command cmd;// (1) 写法1: 参数绑定Command对象, _1和_2是预留的参数func_t f = std::bind(&Command::Execute, &cmd, std::placeholders::_1, std::placeholders::_2);std::unique_ptr<TcpServer> tsvr = std::make_unique<TcpServer>(port, f);// (2) 写法2:lambda表达式// std::unique_ptr<TcpServer> tsvr = std::make_unique<TcpServer>(port, [&cmd](const std::string &command, InetAddr &addr){//     return cmd.Execute(command, addr);// });tsvr->Init();tsvr->Run();return 0;
}

(3)运行结果

http://www.dtcms.com/a/523701.html

相关文章:

  • 数据库连接池 HikariCP Spring官方内置连接池 配置简单 以性能与稳定性闻名天下
  • Flink Watermark(水位线)机制详解
  • wordpress wpadmin东莞seo网站建设公司
  • 刷赞网站怎么做WordPress编辑器加载慢
  • 【知识图谱】图神经网络(GNN)核心概念详解:从消息传递到实战应用
  • 系统与网络安全------弹性交换网络(5)
  • 车联网车云架构_信息分享01
  • 纯css实现任务头像叠加
  • B2122 单词翻转
  • Tailwind CSS Next.js实战(官方)Tailwind Demo、Tailwind教程
  • 建设个人博客网站做网站页面设计报价
  • 告别显卡焦虑:Wan2.1+cpolar让AI视频创作走进普通家庭
  • 浙人医创新开新篇——用KingbaseES数据库开创首个多院区异构多活容灾架构
  • openstock部署
  • 平替 MongoDB 实践指南 | 金仓多模数据库助力电子证照系统国产化改造
  • android三方调试几个常用命令
  • 响应式网站建设开发公司网站名称需要备案吗
  • 凡科建站平台有一个外国网站专门做街头搭讪
  • 会计与电子商务:中专生的专业选择与发展路径
  • 什么是站点服务器?
  • 自助建站和速成网站合肥公司网站建设多少费用
  • 【麒麟桌面系统】V10-SP1 2503 系统知识——Umi-OCR⽂字识别⼯具
  • macOS 常用命令速查手册
  • Mac 安装neo4j(解压版)最新版本教程
  • 使用Python实现MCP协议Streamable HTTP详细教程
  • JMeter测试HTTP GET(附实例)
  • 保定网站建设系统wordpress 后台速度优化
  • 【OS笔记21】:处理机调度3-进程调度
  • Flutter中Key的作用以及应用场景
  • linux ubuntu 报错findfont: Font family ‘Times New Roman‘ not found.