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

手写Muduo网络库核心代码1-- noncopyable、Timestamp、InetAddress、Channel 最详细讲解

前言

        从本文开始,就要跟着陈硕大佬的Muduo网络库源码来自己实现以下网络库中核心的部分,由于Muduo网络库中还依赖了boost库,我们在手写的时候直接用C++的语法代替boost库这部分內容。

        分析底层的源码并且能手写出来不仅能巩固所学的知识,还能学到大佬的很多的良好的编程习惯。


防拷贝模块

第一个学到的好的编程习惯就是设计一个工具类,作用是防止拷贝构造和拷贝赋值

#pragma once
class noncopyable
{protected:noncopyable()=default;~noncopyable()=default;public:noncopyable& operator=(const noncopyable&)=delete;noncopyable(const noncopyable&)=delete;
};

        通过设计这个基类,后面但凡是涉及到不需要拷贝构造和赋值构造的类再也不用手动去 delete掉它们的拷贝和赋值构造函数,只需要让该类继承这个工具类就行,父类想要调用自己的拷贝或赋值构造时,会先调用基类的,但是基类被禁掉了,所以父类也不能使用自己的拷贝和赋值构造函数。这大大提高了编程的效率。

        但是这里并没有禁掉移动构造和移动赋值,这是因为我们知道移动语义是C++11一个重要的优化手段,它能高效地转移资源,避免不必要的拷贝。网络编程中,

        某些资源(如 Socket、文件描述符)不能直接拷贝,否则会导致竞争条件或资源泄漏。而移动语义可以安全地转移资源的所有权,不会导致重复释放或竞争问题。

日志模块

        日志模块在一个项目中是必不可少的,这里我们就设计一个简单点的日志系统就行了,只是将日志输出到屏幕,就不考虑将日志写进文件中了。

        我们要实现一个日志类首先需要考虑的是日志等级,这里将日志设置为四个等级如下

enum LogLevel
{INFO,//正常信息ERROR,//错误信息但不致命FATAL,//致命信息DEBUG,//调试信息,通常在开发和测试阶段使用,生产环境中可能会关闭以减少日志量。//WARNING;//某个操作失败但有备用方案,或者资源使用接近上限。
};

那么一个日志类中要包含的是 日志等级,接口的话考虑到针对不同的情况我们在打印日志的时候要设置不同的等级。所以我们需要两个接口分别为 设置日志等级和打印日志。

一般为了防止

  • 避免多个日志实例竞争资源(如文件写入)。
  • 集中管理日志级别和输出方式。

日志系统都采用单例模式,这里我们也使用单例模式。

头文件

class Logger:noncopyable
{public:static Logger& GetInstance();//设置日志级别void setLogLevel(int level);//写日志void log(std::string msg);private:int LogLevel_;Logger(){};
};

源文件

#include<iostream>#include"Logger.h"Logger& Logger::GetInstance()
{static Logger logger;return logger;
}void Logger::setLogLevel(int level)
{LogLevel_=level;
}//写日志 格式:[级别信息] time:msg
void Logger::log(std::string msg)
{switch(LogLevel_){case INFO:std::cout<<"[INFO]";break;case ERROR:std::cout<<"[ERROR]";break;case FATAL:std::cout<<"[FATAL]";break;case DEBUG:std::cout<<"[DEBUG]";break;default:break;}std::cout<<"print time"<<":"<<msg<<std::endl;
}

考虑到用户使用日志的时候,不需要让用户自己去获取Logger的实例,自己用实例去写日志,这样太过于麻烦,所以我们可以定义宏,以可变参数的方式为用户提供更方便更快捷的日志写入方式。

定义宏

且支持可变参数的话,用户在写日志的时候可以使用自己的风格 logmsgformat,比如我们接下来就使用如下风格的日志:   LOG_INFO("xxx %d %s",2000,"xxx")

#define LOG_INFO(logmsgformat, ...)\do\{\Logger& logger = Logger::GetInstance();\logger.setLogLevel(INFO);\char buf[1024]={0};\snprintf(buf,1024,logmsgformat,##__VA_ARGS__);\logger.log(buf);\}while(0)
#define LOG_ERROR(logmsgformat, ...)\do\{\Logger& logger = Logger::GetInstance();\logger.setLogLevel(ERROR);\char buf[1024]={0};\snprintf(buf,1024,logmsgformat,##__VA_ARGS__);\logger.log(buf);\}while(0)
#define LOG_FATAL(logmsgformat, ...)\do\{\Logger& logger = Logger::GetInstance();\logger.setLogLevel(FATAL);\char buf[1024]={0};\snprintf(buf,1024,logmsgformat,##__VA_ARGS__);\logger.log(buf);\exit(-1);\}while(0)//调试日志仅在 MUDEBUG 宏定义时生效,减少生产环境的日志量。
#ifdef MUDEBUG
#define LOG_DEBUG(logmsgformat, ...)\do\{\Logger& logger = Logger::GetInstance();\logger.setLogLevel(IDEBUG);\char buf[1024]={0};\snprintf(buf,1024,logmsgformat,##__VA_ARGS__);\logger.log(buf);\}while(0)
#else#define LOG_DEBUG(logmsgformat, ...)
#endif

使用 do while 结构来封装多语句宏定义,避免宏展开时可能引起的语法错误或逻辑错误。
##__VA_ARGS__ :可变参的参数列表
在 C/C++ 中,宏定义默认是单行的。也就是说,预处理器会把 #define 后面的内容当作一行处理。但为了代码可读性,我们通常希望将复杂的宏分成多行写。这时就需要使用 \ 来告诉预处理器。

这里是简单的日志系统的实现,如果想要了解异步的写入文件的日志系统,请看这篇文章

https://blog.csdn.net/newbie5277/article/details/148518729https://blog.csdn.net/newbie5277/article/details/148518729

Timestamp时间代码模块

在网络编程中我们需要经常用到时间,如

  • 超时控制: 如 TCP 连接超时、HTTP 请求超时,超时参数timeout等 
  • 日志时间戳: 每条日志需要精确到微秒级的时间标记。

考虑怎么实现这个时间类呢?

  1. 成员变量:首先,我们需要一个成员变量来存储时间戳,即存储从 Unix Epoch(1970-01-01 00:00:00 UTC)至今的微秒数。
  2. 我们需要一个接口来返回当前时间的秒数。这个接口最好使用静态方法,这样它不依赖于类的实例,而是与类本身相关。可以直接通过类名调用,如 Timestamp::now()。不用每次都创建无意义的对象再来调用这个接口。
  3. 另一个接口来将时间戳格式化为用户方便看到的样子 如 "YYYY-MM-DD HH:MM:SS
  4. 允许外部传入自定义时间戳(如从网络或文件读取的时间),所以我们需要两个构造函数。

接着我们又会学习到一个编程的好习惯,那就是explicit关键字,它是为了防止隐式类型的转换,避免意外的类型转换导致的潜在错误或歧义。

头文件

#pragma once
#include<iostream>
#include<string>
#include<cstdint>
#include<cstdio>
class Timestamp
{public:Timestamp();explicit Timestamp(int64_t microSecondsSinceEpoch);static Timestamp now();std::string toString() const;//const保证了这个函数不会修改当前对象的状态。private:int64_t microSecondsSinceEpoch_;
};

源文件

#include<time.h>
#include"Timestamp.h"Timestamp::Timestamp():microSecondsSinceEpoch_(0){}
Timestamp::Timestamp(int64_t microSecondsSinceEpoch):microSecondsSinceEpoch_(microSecondsSinceEpoch){}Timestamp Timestamp::now()
{return Timestamp(time(NULL));
}
std::string Timestamp::toString() const
{char buf[128]={0};tm *tm_time=localtime(&microSecondsSinceEpoch_);snprintf(buf,128,"%4d-%02d-%02d %02d:%02d:%02d",tm_time->tm_year+1900,tm_time->tm_mon+1,tm_time->tm_mday,tm_time->tm_hour,tm_time->tm_min,tm_time->tm_sec);return buf;
}

tm 是 C/C++ 标准库中用于表示**日历时间(broken-down time)**的结构体,定义在 <time.h>(C)或 <ctime>(C++)中。它将时间分解为年、月、日、时、分、秒等易于理解的字段,常用于时间格式化和计算。

struct tm {int tm_sec;   // 秒 [0, 60](60 用于闰秒)int tm_min;   // 分 [0, 59]int tm_hour;  // 时 [0, 23]int tm_mday;  // 月中的第几天 [1, 31]int tm_mon;   // 月份 [0, 11](0 = 一月,11 = 十二月)int tm_year;  // 从 1900 开始的年数(如 2023 年对应 123)int tm_wday;  // 星期几 [0, 6](0 = 周日,6 = 周六)int tm_yday;  // 年中的第几天 [0, 365]int tm_isdst; // 夏令时标志(>0:夏令时;=0:非夏令时;<0:未知)
};

time(NULL) 是 C/C++ 标准库中用于获取当前系统时间的函数,它返回从 Unix 纪元(1970-01-01 00:00:00 UTC) 到当前时间的秒数(类型为 time_t)。

InetAddress模块

网络通信中,大部分的接口我们都要有sockaddr这个参数。

sockaddr 是一个结构体。套接字通信在设计的时候不仅实现了网络间通信还实现了本机内进程的通信。为了方便将两种的通信接口统一了起来,只用前16位区分,这部分忘记的请看网络编程一文。

所以在这个模块我们需要做的就是将我们网络通信需要用到的 sockaddr_in 结构体的操作进行了面向对象封装,主要解决网络编程中地址处理的复杂性问题。

头文件

#pragma once
#include<arpa/inet.h>
#include<netinet/in.h>
#include<string>//封装socket地址类型
class InetAddress
{public:explicit InetAddress(uint16_t port=0,std::string ip="127.0.0.1");//可以只传端口号或者IP,更加灵活explicit InetAddress(const sockaddr_in &addr)//当接受连接时,直接从 accept() 返回的地址结构体初始化。:addr_(addr){}std::string toIP() const;uint16_t toPort()const;std::string toIpPort() const;const sockaddr_in* getSockaddr() const{return &addr_;}void setSockAddr(const sockaddr_in &addr){addr_=addr;}private:sockaddr_in addr_;
};

源文件

构造函数

InetAddress::InetAddress(uint16_t port, std::string ip) {bzero(&addr_, sizeof addr_);          // 清空结构体addr_.sin_family = AF_INET;           // 设置为 IPv4 地址族addr_.sin_port = htons(port);         // 端口号转网络字节序addr_.sin_addr.s_addr = inet_addr(ip.c_str()); // IP 字符串转二进制
}

IP 格式化

std::string InetAddress::toIP() const {char buf[64] = {0};::inet_ntop(AF_INET, &addr_.sin_addr, buf, sizeof(buf)); // 二进制 IP → 字符串return buf;
}

inet_ntop 用于将网络字节序的 IP 地址转换为人类可读的点分十进制字符串格式,并将它存在指定的缓冲区中。

  • inet: Internet
  • n: network (网络字节序)
  • top: to printable (转换为可打印的字符串)
     

获取端口

uint16_t InetAddress::toPort() const {uint16_t port = ntohs(addr_.sin_port); // 网络字节序 → 主机字节序return port;
}

IP+Port组合

std::string InetAddress::toIpPort() const {char buf[64] = {0};::inet_ntop(AF_INET, &addr_.sin_addr, buf, sizeof(buf)); // 先转换 IPsize_t len = strlen(buf);uint16_t port = ntohs(addr_.sin_port);                   // 再转换端口sprintf(buf + len, ":%u", port);                         // 拼接为 "IP:Port"return buf;
}

Channel模块

Channel 是对 fd 进行封装的,一个 fd 对应一个 Channel 实例,而一个 loop 监听的是多个Channel

只有栈对象就是直接实例化出的存在栈上的对象或 RAII 对象在异常时能保证析构。而 new 出来的堆上的对象如果不 delete 则会内存泄露。

头文件

#pragma once
#include"noncopyable.h"
#include<functional>
#include<memory>
#include"Timestamp.h"//当前头文件中只用到了类型的声明,
//并没有用到具体类型或者方法,所以这里头文件中只需要写这些类型的前置声明就行,到时候只在源文件中给出头文件,反正最后都会被生成so库
class Eventloop;
//class Timestamp;这个在handleEvent中使用了对象,所以不像Eventloop *loop一样是个指针确定四个字节
//这个对象不确定大小,所以得包含它的头文件,不能只声明//channel理解为通道,封装了sockfd和其感兴趣的event,如EPLLIN等,还绑定了poller返回的具体事件
class Channel:noncopyable
{public:using EventCallback=std::function<void()>;//事件的回调using ReadEventCallback=std::function<void(Timestamp)>;//只读事件的回调Channel(Eventloop *loop,int fd);~Channel();void handleEvent(Timestamp receiveTime);//设置回调函数操作void setReadCallback(ReadEventCallback cb){readCallback_=move(cb);}void setWriteCallback(EventCallback cb){writeCallback_=move(cb);}void setCloseCallback(EventCallback cb){closeCallback_=move(cb);}void setErrorCallback(EventCallback cb){errorCallback_=move(cb);}//防止当channel被手动remove掉,channel还在执行回调操作,使用这个来进行监听void tie(const std::shared_ptr<void>&);int fd(){return fd_;}int events(){return events_;}//为什么设置这个接口?因为这是给poller设计的,channel本身不能监听自己的事件,一定是poller监听了通过这个接口设置给它int set_revents(int revt) {revents_=revt;}//当前这个channel有没有注册感兴趣的事件,为啥要有这么个接口呢??//bool isNoneEvent() const {return events_==kNoneEvent;}//设置、修改、去除感兴趣的事件void enableReading(){events_ |= kReadEvent;update();}void disableReading(){events_ &=~ kReadEvent;update();}void enableWriting(){events_ |= kWriteEvent;update();}void disableWriting(){events_&=~ kWriteEvent;update();}void disableAll(){events_ =kNoneEvent;update();}//返回fd当前的事件状态bool isNoneEvent() const {return events_==kNoneEvent;}bool isWriting() const {return events_&kWriteEvent;}bool isReading() const {return events_&kReadEvent;}int index(){return index_;}void set_index(int idx){index_=idx;}Eventloop* ownerLoop(){return loop_;}void remove();private:void update();void handleEventWithGuard(Timestamp receiveTime);//下面这三个表示的是fd的状态,不感兴趣、对读事件感兴趣、对写事件感兴趣static const int kNoneEvent;static const int kReadEvent;static const int kWriteEvent;Eventloop *loop_;//表示事件循环const int fd_;//fd,poller监听的对象int events_;//注册fd感兴趣的事件int revents_;//poller返回的具体发生的事件int index_;//在Poller中的状态标记,kNew(-1),kAdded(1):已添加到poller。kDeleted(2)已从poller删除,优化 epoll_ctl 调用(避免重复 ADD/DEL)跟踪 Channel 在 Poller 中的状态std::weak_ptr<void> tie_;bool tied_;//因为channel里面能获知fd最终发生的具体的事件revents,所以它负责具体事件的回调操作ReadEventCallback readCallback_;EventCallback writeCallback_;EventCallback closeCallback_;EventCallback errorCallback_;};

成员变量

1、定义事件类型的掩码常量
static const int kNoneEvent;   // 值为 0,表示不监听任何事件
static const int kReadEvent;   // 值为 EPOLLIN | EPOLLPRI,监听读事件
static const int kWriteEvent;  // 值为 EPOLLOUT,监听写事件

避免直接使用系统常量(如 EPOLLIN),提高代码可读性
统一管理事件标志,便于扩展(如添加 EPOLLPRI 处理带外数据)

使用场景

enableReading() 设置 events_ |= kReadEvent
isReading() 检查 events_ & kReadEvent

2、事件循环
EventLoop* loop_

Channel就是用来封装fd的,每个 Channel 必须属于一个 EventLoop(单线程模型),由EventLoop选择一个subloop(多路复用模型如Epoll)处理channel。

使用场景

通过该指针调用 EventLoop::updateChannel() 更新事件监听

3、管理的文件描述符及其关心的事件
const int fd_;//fd,poller监听的对象
int events_;//注册fd感兴趣的事件
int revents_;//poller返回的具体发生的事件

events_ :通过 update() 同步到 epoll_ctl
revents_: 由 EventLoop 在调用 epoll_wait 后设置,且 handleEvent() 根据此值分发回调

4、在Poller中的状态标记
int index_;

kNew(-1):未添加到poller ,kAdded(1):已添加到poller。kDeleted(2)已从poller删除,优化 epoll_ctl 调用(避免重复 ADD/DEL)跟踪 Channel 在 Poller 中的状态

5、生命周期管理机制
std::weak_ptr<void> tie_;bool tied_;//标记是否已调用 tie() 绑定对象,避免每次事件处理都尝试升级 weak_ptr

设想这样一种情况,假设当前fd的读事件触发,当 Channel 正在执行回调函数时,上层对象(如 TcpConnection)可能被其他线程销毁,导致回调中访问无效内存,引发崩溃。

所以我们就使用弱引用绑定到上层对象(如 TcpConnection),TcpConnection 调用 channel_.tie(shared_from_this()) 事件触发时尝试升级为 shared_ptr 升级成功则执行回调,. 升级成功则执行回调,否则跳过,这里不懂的可以看这篇文章C++智能指针。

6、具体事件的回调操作
 ReadEventCallback readCallback_;EventCallback writeCallback_;EventCallback closeCallback_;EventCallback errorCallback_;

这些成员变量是用function,头文件中的一个类模板,封装的可调用对象。

using EventCallback = std::function<void()>;          // 通用事件回调
using ReadEventCallback = std::function<void(Timestamp)>; // 读事件回调(带时间戳)

ReadEventCallback需要传入时间戳可以计算传递数据到达时间。

成员函数

1、事件处理核心方法
void handleEvent(Timestamp receiveTime);

根据 revents_(实际发生的事件)调用对应的回调。调用用户设置的 readCallback_、writeCallback_ 等。

2、设置回调函数操作
void setReadCallback(ReadEventCallback cb){readCallback_=move(cb);}
void setWriteCallback(EventCallback cb){writeCallback_=move(cb);}
void setCloseCallback(EventCallback cb){closeCallback_=move(cb);}
void setErrorCallback(EventCallback cb){errorCallback_=move(cb);}
3、生命周期管理
void tie(const std::shared_ptr<void>&);

防止当channel被手动remove掉,channel还在执行回调操作,使用这个来进行监听

4、poller设置事件
 int fd(){return fd_;}int events(){return events_;}int set_revents(int revt) {revents_=revt;}

channel本身不能监听自己的事件,这是给poller设计的,一定是poller监听了通过这个接口设置给channel,channel才能调用回调函数。

5、设置、修改、去除感兴趣的事件
void enableReading(){events_ |= kReadEvent;update();}
void disableReading(){events_ &=~ kReadEvent;update();}
void enableWriting(){events_ |= kWriteEvent;update();}
void disableWriting(){events_&=~ kWriteEvent;update();}
void disableAll(){events_ =kNoneEvent;update();}
6、返回fd当前的事件状态
bool isNoneEvent() const {return events_==kNoneEvent;}
bool isWriting() const {return events_&kWriteEvent;}
bool isReading() const {return events_&kReadEvent;}
7、设置poller中channel的状态
int index(){return index_;}
void set_index(int idx){index_=idx;}

kNew(-1),kAdded(1):已添加到poller。kDeleted(2)已从poller删除,

8、Channel当前所在loop
Eventloop* ownerLoop(){return loop_;}
9、 从 EventLoop 注销监听
void remove();

调用场景:TcpConnection 析构时或连接关闭时。

源文件

#include "Channel.h"
#include <sys/epoll.h>
#include "Eventloop.h"
#include "Logger.h"
const int Channel::kNoneEvent = 0;
const int Channel::kReadEvent = EPOLLIN | EPOLLPRI;//这意味着这个组合值表示“关心可读数据和带外数据”。
const int Channel::kWriteEvent = EPOLLOUT;Channel::Channel(Eventloop *loop, int fd): loop_(loop), fd_(fd), events_(0), revents_(0), index_(-1), tied_(false) {}Channel::~Channel() {}
void Channel::tie(const std::shared_ptr<void> &obj)
{tie_ = obj;tied_ = true;
}// 改变channel所表示的fd的events事件后,update负责在poller里面更改相应的事件
void Channel::update()
{// 通过Channel所属的Eventloop方法,调用poller的相应方法,注册fd的events事件loop_->updateChannel(this);
}// 在eventloop中存储channel的容器中删除掉指定的channel
void Channel::remove()
{loop_->removeChannel(this);
}
void Channel::handleEvent(Timestamp receiveTime)
{LOG_INFO("channel handleEvent revents:%d\n", revents_);if (tied_){std::shared_ptr<void> guard = tie_.lock();if (guard){handleEventWithGuard(receiveTime);}}else{handleEventWithGuard(receiveTime);}
}// 根据poller通知的channel发生的具体事件,由channel负责调用具体的回调操作
void Channel::handleEventWithGuard(Timestamp receiveTime)
{if ((revents_ & EPOLLHUP) && !(revents_ & EPOLLIN)){if (closeCallback_){closeCallback_();}}if (revents_ & EPOLLERR){if (errorCallback_){errorCallback_();}}if (revents_ & (EPOLLIN | EPOLLPRI)){if (readCallback_){readCallback_(receiveTime);}}if (revents_ & EPOLLOUT){if (writeCallback_){writeCallback_();}}
}
1、定义事件类型
const int Channel::kNoneEvent = 0;
const int Channel::kReadEvent = EPOLLIN | EPOLLPRI;//这意味着这个组合值表示“关心可读数据和带外数据”。
const int Channel::kWriteEvent = EPOLLOUT;
2、生命周期绑定机制
void Channel::tie(const std::shared_ptr<void> &obj)
{tie_ = obj;     // 保存弱引用tied_ = true;   // 设置绑定标记
}
3、时间注册更新/删除机制
void Channel::update()
{loop_->updateChannel(this);
}void Channel::remove()
{loop_->removeChannel(this);
}

update():

改变channel所表示的fd的events事件后,update负责在poller里面更改相应的事件
当事件掩码变化时调用(如 enableReading())
通知 EventLoop 更新 Poller 的监听设置

通过Channel所属的Eventloop方法,调用poller的相应方法,注册fd的events事件
remove():
从 EventLoop 和 Poller 中注销该 Channel,因为一个epoll监听多个Channel,在eventloop中存储channel的容器中删除掉指定的channel
通常由上层对象析构时调用

4、事件处理分发机制
void Channel::handleEvent(Timestamp receiveTime)
{LOG_INFO("channel handleEvent revents:%d\n", revents_);if (tied_) {std::shared_ptr<void> guard = tie_.lock();if (guard) {handleEventWithGuard(receiveTime);}} else {handleEventWithGuard(receiveTime);}
}

安全机制:

  1. 检查是否绑定(tied_)
  2. 尝试将弱引用升级为强引用(tie_.lock())
  3. 升级成功才执行实际处理

Timestamp 参数:

  1. 事件发生的时间戳(精确到微秒)
  2. 主要用于读事件(数据到达时间)
5、实际事件处理
void Channel::handleEventWithGuard(Timestamp receiveTime)
{// 1. 处理挂起事件(对端关闭连接)if ((revents_ & EPOLLHUP) && !(revents_ & EPOLLIN)) {if (closeCallback_) closeCallback_();}// 2. 处理错误事件if (revents_ & EPOLLERR) {if (errorCallback_) errorCallback_();}// 3. 处理读事件if (revents_ & (EPOLLIN | EPOLLPRI)) {if (readCallback_) readCallback_(receiveTime);}// 4. 处理写事件if (revents_ & EPOLLOUT) {if (writeCallback_) writeCallback_();}
}

根据poller通知的channel发生的具体事件,由channel负责调用具体的回调操作。


感谢阅读!

http://www.xdnf.cn/news/1437589.html

相关文章:

  • 测试覆盖率不够高?这些技巧让你的FastAPI测试无懈可击!
  • maven【maven】技术详解
  • ARM编译器生成的AXF文件解析
  • 平衡车-ADC采集电池电压
  • 综合诊断板CAN时间戳稳定性测试报告8.28
  • Linux内核进程管理子系统有什么第四十回 —— 进程主结构详解(36)
  • 安装部署k3s
  • Java试题-选择题(29)
  • 算法题打卡力扣第3题:无重复字符的最长子串(mid)
  • Suno AI 新功能上线:照片也能唱歌啦!
  • Netty从0到1系列之NIO
  • 进程优先级(Process Priority)
  • 猫猫狐狐的“你今天有点怪怪的”侦察日记
  • CentOS7安装Nginx服务——为你的网站配置https协议和自定义服务端口
  • Java注解深度解析:从@ResponseStatus看注解奥秘
  • 大模型RAG项目实战:Pinecone向量数据库代码实践
  • 二叉树经典题目详解(下)
  • 【数据分享】31 省、342 个地级市、2532 个区县农业机械总动力面板数据(2000 - 2020)
  • MySQL数据库——概述及最基本的使用
  • Python实现浅拷贝的常用策略
  • Vite 插件 @vitejs/plugin-legacy 深度解析:旧浏览器兼容指南
  • 【Linux】信号量
  • 09.01总结
  • LeetCode算法日记 - Day 30: K 个一组翻转链表、两数之和
  • 基于Springboot和Vue的前后端分离项目
  • playwright+python UI自动化测试中实现图片颜色和像素对比
  • milvus使用
  • Hard Disk Sentinel:全面监控硬盘和SSD的健康与性能
  • Python学习-day4
  • 2026届长亭科技秋招正式开始