文章目录
- 什么是RPC(远程过程调用)
- 字符串格式化的方法
- 时间格式化输出
- linux网络编程
- Reactor模式:使用IO多路复用监听事件,事件来了就反应
- eventloop的实现
- linux定时器
- IO线程
- IO线程组(线程池)
- TCP模块的封装
- TcpBuffer
- 什么是TCP粘包
- TCP粘包的解决方法
- TcpAcceptor:调用accept函数去连接客户端
- TcpConnection:IO线程将数据读入到缓冲区、从缓冲区发送
- TcpServer:管理主Reactor和从Reactor
- 字节序转换
- EINTR错误与EAGAIN错误
- TcpClient
- 使用shared_from_this
- RPC协议封装
- RPC模块封装
- RPC超时
- 异步日志优化
- XML文件的使用
- 总结
- 其它
什么是RPC(远程过程调用)
通过网络调用远程计算机的服务,主要用于分布式系统内部。
由于单纯使用TCP会导致粘包问题,因此需要在应用层对数据进行封装用于区分消息边界,例如在应用层使用HTTP协议或RPC协议。RPC协议的诞生比HTTP早。
服务发现:找到服务对应的ip地址和端口 的 过程。http中是通过DNS服务解析域名得到ip地址,而rpc有专门的中间服务保存服务名和ip信息(例如redis)。
主流的http1.1使用json序列化结构体数据,而rpc定制化程度更高,使用protobuf序列化协议
http2性能优于rpc,由于历史原因,还是要使用rpc
为了实现远程过程调用,需要解决如下问题:
- 客户端与服务器怎么进行通信:应用层可以使用http2协议避免粘包问题,例如gRPC,也可以使用基于protubuf的协议,本文基于protubuf自定义协议报文格式。而传输层通常使用tcp协议。
- tcp通信使用什么事件处理模式(reactor还是proactor),本文使用reactor模式
- 怎么进行数据的序列化与反序列化:使用protobuf,例如gRPC框架
字符串格式化的方法
- snprintf函数:注意其接受的是const char*而不是string
#include <iostream> int main() { char buff[5];//只能保存4个字符加一个\0 int count = snprintf(buff, sizeof(buff), "nh %s", "world"); std::cout<<"欲写入字符串长度:"<<count<<std::endl;//8 std::string s(buff); std::cout<<s<<std::endl;//nh w //sprintf比snprintf少了一个目标缓冲区大小参数size sprintf(buff, "hello %s", "world"); std::string s2(buff);//hello world s2.resize(5); std::cout<<s2<<std::endl;//hello }
- 字符串流 进行字符串拼接
头文件:sstream
std::stringstream输入输出字符串流,使用>>从字符串流中读取数据。#include <iostream> #include <sstream> #include <string> int main() { //创建流对象 std::stringstream ss("123 John"); int intValue; std::string stringValue; ss >> intValue >> stringValue; //打印 std::cout << "int value: " << intValue << std::endl; std::cout << "string value: " << stringValue << std::endl; return 0; }
- c++20的字符串:std::format()
由于gcc10不支持format,gcc13支持,这里使用fmt库来代替。sudo apt install libfmt-dev
#include <iostream> #include <string> #include <fmt/format.h> int main() { std::string str_f = fmt::format("nh,{}\n", "world"); std::cout<<str_f<<std::endl;//nh,world return 0; }
时间格式化输出
-
方法1:chrono获取系统时间、time_point转std::time_t、std::time_t转std::tm结构体、std::tm结构体转std::string
#include<iostream> #include<chrono> int main() { //chrono时间 auto currentTime = std::chrono::system_clock::now(); //to_time_t将std::chrono::time_point转std::time_t std::time_t currentTime_t = std::chrono::system_clock::to_time_t(currentTime); //localtime将std::time_t转std::tm结构体 std::tm* currentTime_t_tm = std::localtime(¤tTime_t); //strftime将tm时间结构体转为字符串 char res[100]; strftime(res, 50, "%Y年 %m月 %d日 %H时 %M分 %S秒", currentTime_t_tm); std::cout<<res<<std::endl;//2023年 11月 15日 20时 07分 04秒 return 0; }
-
方法2:利用timeval结构体的tv_sec也是time_t类型,将其转换为tm结构体
#include <iostream> #include <sys/time.h> int main() { struct timeval now_val; //tv是指向timeval结构体的指针,用于存储当前时间的秒数和微秒数。tz存储时区 gettimeofday(&now_val, nullptr); struct tm now_val_t; localtime_r(&(now_val.tv_sec), &now_val_t); return 0; }
总结:
1.chrono的now得到time_point------(to_time_t)---->time_t------(localtime)----->tm
2.gettimeofday得到timeval------(localtime_r)----->tm
有了tm就可以使用strftime将其转为字符串 -
string转tm结构体的方法:注意,tm结构体的年份是1900至今多少年,月份是0-11月
#include <iostream> #include <iomanip>//get_time int main() { //tm结构体 std::tm t1 = {}; //创建字符串流:用std::stringstream也可以,它既能输入,也能输出 std::istringstream iss("2022-11-17 10:20:18"); //解析到tm结构体 iss >> std::get_time(&t1, "%Y-%m-%d %H:%M:%S"); std::cout<<t1.tm_year+1900<<"年"<<t1.tm_mon + 1<<"月"<<t1.tm_mday<<"日"<<std::endl; return 0; }
-
获取线程号与进程号
//进程号:直接getpid #include <sys/types.h> #include <unistd.h> pid_t res = getpid(); //线程号:使用系统调用,这里传入系统调用号SYS_gettid即可 #include <sys/syscall.h> long g_pid = syscall(SYS_gettid);
linux网络编程
-
使用eventfd进行事件通知
每次写入的值会被累加,所以不适合多个线程同时写入,最佳使用环境就是睡眠唤醒。
头文件: #include <sys/eventfd.h>
eventfd(初始值、flag)
:创建一个文件描述符flag 意义 EFD_NONBLOCK 读写时不阻塞,若遇到文件不可读写,返回-1 EFD_SEMAPHORE 创建的是属于信号量类型的文件描述符,写入的值是可读次数(累加),读取得到的值每次都是1,直到可读次数减至0 EFD_CLOEXEC 当通过exec执行其他程序后,自动关闭eventfd eventfd_read(fd, event_t * value)
eventfd_write(fd, event_t value)
EFD_NONBLOCK 的使用演示:
#include <iostream> #include <sys/eventfd.h> //fork #include <sys/types.h> #include <unistd.h> int main() { //1.创建事件文件描述符 int ev_fd = eventfd(2, EFD_NONBLOCK); //2.创建子进程 pid_t pid = fork(); if (pid == -1) { std::cout<<"创建失败"<<std::endl; }else{ if (pid > 0)//父 { //3.父进程写入值 eventfd_t num = 3;//unsigned long int eventfd_write(ev_fd, num); eventfd_write(ev_fd, num); eventfd_write(ev_fd, num); }else{//0 子 //4.子进程读 eventfd_t res; eventfd_read(ev_fd, &res); std::cout<<res<<std::endl;//11 读取到的是累计值:2+3+3+3 } } close(ev_fd); return 0; }
EFD_SEMAPHORE 的使用演示:
#include <iostream> #include <sys/eventfd.h> #include <sys/types.h> #include <unistd.h> int main() { //1.创建事件文件描述符 int ev_fd = eventfd(2, EFD_SEMAPHORE); //2.创建子进程 pid_t pid = fork(); if (pid == -1) { std::cout<<"创建失败"<<std::endl; }else{ if (pid > 0)//父 { //3.父进程写入值 eventfd_t num = 3;//unsigned long int eventfd_write(ev_fd, num);//总共2+3=5次 }else{//0 子 //4.子进程读 eventfd_t res; eventfd_read(ev_fd, &res); std::cout<<res<<std::endl; ... //总共可以读出5次 } } close(ev_fd); return 0; }
Reactor模式:使用IO多路复用监听事件,事件来了就反应
- 单线程阻塞模型
服务器端只有一个线程,阻塞在accept函数上,建立连接后就去处理请求,处理完马上关闭连接等待下一个。例如Redis,采用单Reactor单进程模型,因为 Redis 业务处理主要是在内存中完成,性能瓶颈不在 CPU 上。
- 多线程阻塞模型
缺点1:线程频繁的创建销毁--->线程池解决
缺点2:主线程阻塞在accept上,子线程阻塞在read\write上,浪费线程资源--->IO多路复用
主线程阻塞在accept函数上,建立连接后单开一个线程去处理请求,主线程重新阻塞在accept函数上。
- Reactor模型 = 非阻塞IO + IO多路复用
提前注册好回调函数,利用IO多路复用监听套接字上的读写事件,当有对应事件发生时就调用其回调函数。执行loop的只能有一个线程,多个线程会造成资源竞争和惊群效应。
- 单Reactor模型
只有一个主线程允许Reactor,既负责监听是否有连接请求,又负责监听通信时的读写事件。当监听到读写事件发生时,可以从线程池取一个线程来处理,避免主线程阻塞在这里。
- 主从Reactor模型
主线程运行一个mainReactor,只负责监听是否有连接请求,获得到通信fd后就注册到子线程的epoll中,子线程监听其读写事件并进行业务处理。
- 单Reactor模型
eventloop的实现
事件:事件是发生在fd上的读写事件,因此定义类FdEvent,每个FdEvent有自己的fd、读回调函数、写回调函数、取得这些回调函数的方法handler、设置这些回调函数的方法listen、获取fd的方法getfd、知道所要监听事件的函数getEpollEvent
任务:任务就是回调函数,有服务端连接fd的可读事件触发时 的 处理函数(accept得到通信fd,将其注册到IO线程的epoll中,即主从Reactor模式),也有专门用来唤醒eventloop线程的fd 的 可读事件发生时 的处理函数(读出其它线程调用wakeup写入的8字节数组数据)
事件循环:loop()使用while循环,不断的从任务队列中取任务并执行,然后阻塞在epoll_wait处,等待所监视的fd发生事件(或是阻塞时长到了),然后通过fd取得其对应事件的回调函数,并将其加入到任务队列。
可以看到,主线程其实执行的任务也就这么2个,一个是为了其它线程能唤醒自己、另一个是为了把通信fd的读写事件注册到 从Reactor 的 epoll上,主Reactor本身并不负责与客户端进行通信。
只有eventloop线程才允许从任务队列增删任务:多个线程会造成资源竞争和惊群效应
主reactor将任务添加到队列中,然后去执行,任务具体是由所发生的事件来决定,如果是可读事件,那么任务就是可读事件的处理函数,使用FdEvent::listen可以设置具体的函数实现
简单实现一下eventloop:
eventloop的实现
定时任务TimerEvent:需要有时间戳(int64_t保存)、任务函数、时间间隔等
定时器Timer:需要继承自FdEvent,同样绑定有fd用于给epoll树监听、要有增删任务的方法、要有事件触发时的处理函数、multimap存储所有定时器任务(multi表示键可重复,所有map都有自动排序的特性 <任务执行时间点, 任务>)
添加定时任务:先和multimap中需要执行的最早任务时间点比较一下,如果当前任务已过期,就setitimer设置100ms后赶紧执行过期任务,否则直接加入到multimap中即可
定时器时间与任务时间的关系:每个timer_event任务都有自己的执行时间点=当前时间+时间间隔,要勤设置定时器事件为最早任务时间间隔
总之:
linux定时器
-
alarm(多久后发送SIGALAM信号):只发送一次SIGALRM信号
-
setitimer(定时器类型,定时器到期时要设置的新结构体,用于获取旧值的结构体)
#include <signal.h> #include <unistd.h> #include <iostream> #include <sys/time.h> void fun(int n){ std::cout<<"xx"<<std::endl; } int main(){ //1.设置信号捕捉 struct sigaction act; act.sa_flags = 0;//表示使用下面的信号处理函数 act.sa_handler = fun;//信号处理函数 sigaction(SIGALRM, &act, nullptr);//捕捉SIGALRM信号 //2.setitimer:每隔一段时间发送一次SIGALRM信号 struct itimerval it; it.it_interval.tv_sec = 1;//间隔时间 it.it_interval.tv_usec = 0; it.it_value.tv_sec = 1;//定时器启动延迟时间,不要设置为0 it.it_value.tv_usec = 0; setitimer(ITIMER_REAL, &it, NULL); while (1) {} return 0; }
-
timerfd:以文件描述符的形式监听时间变化,通常和select/poll/epoll 配合使用
创建定时器描述符:int ufd = timerfd_create(CLOCK_REALTIME, 0)
参数(计时方法、0),0表示不使用什么特殊选项
启动设置定时器:timerfd_settime(ufd, 0, &it, nullptr)
参数(定时器描述符、定时器时间类型、新定时值、旧定时值)定时器时间类型
0,启动一个相对定时器,基于当前时间 + 指定的 new_value;
TFD_TIMER_ABSTIME,使用绝对时间的定时器,由参数 new_value 决定;
TFD_TIMER_CANCEL_ON_SET,如果实时时钟发生改变,退出绝对时间定时器;查询定时器当前时间值:
timerfd_gettime(ufd, &curr_value)
注意:timerfd到时间了触发的是可读事件,其读缓冲区中保存uint64_t类型数据,即8字节的数据,用于表示该定时器超时的次数,读取timerfd后超时次数重置为零。#include <signal.h> #include <iostream> #include <sys/timerfd.h> #include <unistd.h> #include <sys/epoll.h> int main(){ //2.创建定时器描述符 int ufd = timerfd_create(CLOCK_REALTIME, 0); //3.启动定时器 itimerspec it; it.it_interval.tv_sec = 5; it.it_interval.tv_nsec = 0; it.it_value.tv_sec = 10; it.it_value.tv_nsec = 0; timerfd_settime(ufd, 0, &it, nullptr); //4.使用epoll监听fd int epoll_fd = epoll_create(10);//10无意义 epoll_event event; event.events = EPOLLIN; event.data.fd = ufd;//这样在事件触发时才知道对应的是哪个fd epoll_ctl(epoll_fd, EPOLL_CTL_ADD, ufd, &event); epoll_event eventres[1024]; while (1) { int res = epoll_wait(epoll_fd, eventres, 1024, 0);//0表示不阻塞 if (res > 0) { std::cout<<eventres->data.fd<<"发生可读事件"<<std::endl; //针对timerfd发生的读事件,需要读出数据,否则因为epoll默认是水平触发模式,会一直通知该fd已就绪 if (eventres->data.fd == ufd) { //timerfd的读缓冲区中是uint64_t,即8字节的数据。表示该定时器超时的次数,读取timerfd后超时次数重置为零。 uint64_t readCounter; read(ufd, &readCounter, sizeof(uint64_t)); std::cout<<"超时次数:"<<readCounter<<std::endl;//1 } } //获取timerfd定时器的当前状态的函数 itimerspec curr_value; timerfd_gettime(ufd, &curr_value); std::cout<<"fd事件发生剩余时间:"<<curr_value.it_value.tv_sec<<" 设置的时间间隔:"<<curr_value.it_interval.tv_sec<<std::endl; sleep(1); } return 0; }
小结:此前是主线程(eventloop线程)监听m_wakeup_fd用于其它线程来唤醒主线程,addEpollEvent监听服务器连接fd的读事件,设置好该读事件触发后就accept与客户端建立连接
IO线程
每个从Reactor需要一个线程来执行自己的loop,它只负责与相应的客户端通信。因此这样的线程在这里称为IO线程。
具体实现:IO线程需要有自己的eventloop对象。首先创建子线程(IO线程),然后在子线程里调用自己eventloop对象的loop函数。由于主线程pthread_create创建好子线程后就立即继续执行自己的事情了,就有可能访问IO线程的内容时 IO线程还没来得及做好loop前的各种准备,因此要使用信号量来进行线程同步。
IO线程组(线程池)
主线程从线程池中取线程用来作为 “从Reactor”的工作线程,因此这里的操作不需要加锁。使用vector保存IO线程的指针,getIOThread获取线程时需要轮询着返回该容器中的线程。
TCP模块的封装
TcpBuffer
作为应用层缓冲区,将读取到的数据放在这里,将待发送的数据也先放在这里,便于数据的处理,也便于将需要write的包合并起来一起发送,提高发送效率。
TcpBuffer的实现:提供一个数组和读写指针,用于往数组中读写数据
什么是TCP粘包
TCP是流式传输协议,数据的传输基于流的形式,而不是以数据包的形式传输,因此发送端和接收端处理数据的频率可以不对等,因此TCP协议本身是不存在粘包的问题的。
然而实际开发过程中使用TCP协议,数据是以数据包的形式来发送的,每次发送的数据包大小也可能不一样,接收端却是按照流的形式接收,不会按数据包的形式来接收,导致一次性读取到多个数据包,无法区分。
TCP粘包的解决方法
- 使用应用层协议(http、https)来封装要传输的不定长数据包
- 每条数据尾部添加特殊字符
- 在数据包前面添加固定大小的包头,存储后面数据块大小
TcpAcceptor:调用accept函数去连接客户端
把套接字通信的一整套流程封装起来。在构造函数中就创建好连接套接字、设置好端口复用,等待accept,自己封装一个accept
TcpConnection:IO线程将数据读入到缓冲区、从缓冲区发送
将数据读取到应用层InBuffer后面,将数据编码后写入到OutBuffer后面。等待eventloop监听到可读事件,就从InBuffer前面读出数据,监听到可写事件,就将OutBuffer中的数据全部发送出去。
这些都是IO线程(通过线程组取得)来做的,因此要将其添加到对应的IO线程事件监听里面。
在可写事件执行完后,需要将其从epoll中删除,等到需要发送数据时才添加写事件,因为fd大部分时候是可写的,就会一直触发可写事件,既然OutBuffer中的数据已经全部写到fd中了,就应该删除该事件。
在可读事件执行完后,不需要将fd的可读事件移出,因为当前这个IO线程本身就是为该通信fd服务的,始终监听fd的可读事件。
fd_event_group:先对FdEvent进行再封装,便于工作线程获取cfd对应的FdEvent对象,并将利用FdEvent对象设置事件函数。
一旦fd触发可读事件,就调用onRead()。
- onRead:调用系统read读取客户端发来的数据,并写入到InBuffer中。然后调用excute
- excute:从InBuffer中读出所有数据,执行业务逻辑后,将结果写到OutBuffer,等待fd触发可写事件,就调用onWrite
- onWrite:将OutBuffer中的所有数据发送给客户端
TcpServer:管理主Reactor和从Reactor
启动:主loop的启动、IO线程组loop启动
连接的建立:主Reactor监听利用TcpAcceptor创建好的fd,一旦有新连接,就执行onAccept
onAccept:从IO线程组里取一个线程,创建其对应的TcpConnection。所有已连接客户端的TcpConnection用set容器保存。
字节序转换
意义 | 函数名 |
---|---|
ip字符串转网络字节序 | inet_pton |
ip网络字节序转字符串 | inet_ntop |
ip网络转主机 | ntohl |
ip主机转网络 | htonl |
port网络转主机 | ntohs |
port主机转网络 | htons |
EINTR错误与EAGAIN错误
EINTR:在read、write、accept、open等阻塞函数被信号中断时,errno为EINTR,执行完信号处理函数后,可以试着重新调用一次。但在connect遇到EINTR错误时,不要重新connect,因为实际上connect请求已经发送出去给服务器了,如果对方接受了connect,那么这次的connect就会被拒绝
EAGAIN:表示没有数据可读了,或者缓冲区已满不能再写了,或者没有足够的资源fork,希望稍后再试一次
TcpClient
客户端 只有一个主线程,且将其设置为非阻塞,没有IO线程
分3个步骤:connect连接、write发送rpc响应并执行回调函数、read读返回结果
connect:返回0表示连接成功。返回-1就看errno,如果errno是EINPROGRESS,则表示三次握手还在进行,尚未完成连接的建立,此时需要添加监听可写事件,如果触发可写事件且使用getsockopt获取到fd的errno为0,则表示连接成功。
write:将数据写入到TcpConnection的OutBuffer,回调函数也保存到TcpConnection中,再开启fd的可写事件监听,一旦fd可写,就会调用TcpConnection的onWrite()将数据发送出去。
数据:message对象+done回调函数 -> m_write_done容器->messages容器->
outbuffer->write()发送到对端
为了让TcpConnection保存写回调函数:使用vector保存pair,每个pair的key为AbstractProtocol对象,val是以AbstractProtocol对象为参数的回调函数。
读回调函数使用map<req_id, done>
为了将rpc响应序列化,需要进行rpc编码。因此需要写一个类AbstractCoder:用于提供编解码方法,在TcpConnection的onWrite()中调用。
写一个类AbstractProtocol:在实现类里,带有数据info,还要带有请求号m_req_id,请求号是用来对应请求与响应信息的,一次write只把一个message和done放入容器,onWrite也是将message一个个编码写入发送缓冲区,但是发送时是直接把发送缓冲区中的数据全部一起发送出去,如果不使用请求号,就无法在将接收到的数据解码后区分各个message。
read:
在excute里将服务器的响应信息进行解码,并执行相应的回调函数。
由于回调函数的参数是message的智能指针,因此需要返回该对象的
int getsockopt(int socket, int level, int option_name,
void *restrict option_value, socklen_t *restrict option_len);
功能:获取一个套接字的选项
参数:
socket:文件描述符
level:协议层次
SOL_SOCKET 套接字层次
IPPROTO_IP ip层次
IPPROTO_TCP TCP层次
option_name:选项的名称(套接字层次)
SO_ERROR
SO_BROADCAST 是否允许发送广播信息
SO_REUSEADDR 是否允许重复使用本地地址
SO_SNDBUF 获取发送缓冲区长度
SO_RCVBUF 获取接收缓冲区长度
SO_RCVTIMEO 获取接收超时时间
SO_SNDTIMEO 获取发送超时时间
option_value:获取到的选项的值
option_len:value的长度
返回值:
成功:0
失败:-1
使用shared_from_this
当需要把当前类对象的共享指针传出去时,不能使用std::shared_ptr< A>(this),否则会导致引用计数多次析构。应该使该类继承自std::enable_shared_from_this< A>,在需要返回指针的地方使用shared_from_this()
注意:shared_from_this() 只能在已经存在的 shared_ptr 对象中调用,而不能在普通对象上调用,因此不能直接用A的对象来调用返回shared_from_this()的函数。
#include <memory>
#include <iostream>
class A : public std::enable_shared_from_this<A>{
public:
std::shared_ptr<A> fun(){
// return std::shared_ptr<A>(this);//引用计数不增加,导致重复析构
return shared_from_this();//正确做法
}
};
int main()
{
//不能只是创建A a后 用a调用fun获取共享指针
std::shared_ptr<A> p1 = std::make_shared<A>();
std::shared_ptr<A> p2 = p1->fun();
//打印引用计数
std::cout<<p1.use_count()<<std::endl;
std::cout<<p2.use_count()<<std::endl;
return 0;
}
RPC协议封装
在AbstractCoder里已经知道,把数据序列化后,还要使用请求号(MsgID)来避免串包,此外,还需要能够分割不同的请求,因此需要在采用Protobuf进行序列化的基础上自定义一个协议。
具体而言,怎么编解码要写一个类,协议的各字段定义要写一个类
目前大部分电脑采用小端字节序,因此主机字节序是小端存储、网络字节序是大端存储。
解码decode:
首先遍历Inbuffer找到开始符,读取(memcpy)其后32位的数据作为整包长度,由此得知结束符的位置,判断当前Inbuffer的writeIndex位置得知当前是否接收到了整个数据包(毕竟Tcp是流式传输,不清楚是不是得到完整的包了)。
编码encode:
message对象->字节流->写入到outbuffer
message对象->字节流:在此根据message设置好整包长度、请求号长度等信息,一个个memcpy写到char*数组中。如果当前请求没有请求号,那么在此给它一个。
字节流->写入到outbuffer:利用之前写的writeToBuffer(),将数据写入到buffer中。
RPC模块封装
RPC过程:读字节流->解码转为message对象->将该请求传给分发器dispatcher->得到响应的message对象->编码为字节流->write发送回去
此前已经实现了编解码过程,以下是实现分发器的服务注册与响应,会在TcpConnection::excute中调用。
- 注册service服务:将google::protobuf::Service对象保存到map中
std::string service_name = service->GetDescriptor()->full_name(); m_service_map[service_name] = service;
RPC分发器:
- 请求与响应(rpc分发器):核心就一个dispatch方法,对于传进来的message对象,首先下转后得到其中的方法名,据此调用rpc方法,得到的响应被序列化后传出。
全名->服务名、方法名->服务对象->方法的描述符->Message对象- 取 自定义协议类request对象 的 m_method_name
- 解析得到服务名service_name和方法名method_name:在自定义的协议类中,m_method_name虽然称作方法名,实际上是“服务名.方法名”。
- 从map容器中通过service_name得到service对象
- 通过service对象的FindMethodByName(方法名) 得到该方法的描述符MethodDescriptor
MethodDescriptor* method= service->GetDescriptor()->FindMethodByName(method_name);
- 通过service对象调用GetRequestPrototype(方法描述符).New()得到一个可变的Message对象req_msg
- req_msg使用ParseFromString进行反序列化
- CallMethod()调用rpc方法:上面步骤已确定了服务名、方法名、入参req_msg,这里还需要传入控制器和回调函数,就能得到响应rsp_msg
控制器是实现了gRPC的RpcController类的各种虚函数 的 一个子类对象,给服务端和客户端各自提供了一些函数用于设置参数、获取信息。设置好本地和对端地址、请求号以后,传入CallMethod(),这样调用rpc方法时才知道这些信息
- 将rsp_msg序列化为字节流存储到自定义协议的m_pb_data中
总结:
服务端:服务端实现了rpc方法,并将其保存到map中。启动服务端时,首先设置绑定的ip和端口,监听lfd的可读事件(TcpServer::init),一旦事件触发就表示有新连接来了(TcpServer::onAccept),就初始化IO线程组(这会导致多个IO线程阻塞在IOThread::Main()处等待启动loop),设置好线程的函数(在此使用cfd和io_thread创建TcpConnection,用于与客户端通信),阻塞等待主线程来启动loop(IOThread::Main)。
然后开启主loop循环和IO线程组的loop,主loop循环中,取任务、执行任务、添加任务(EventLoop::loop())。IO线程的loop循环中也是这样,只不过监控的epoll实例的文件描述符不一样,也就是说IO线程只监控并处理cfd上的事件。
初始化TcpConnection对象处启动了可读事件的监听,可读事件触发导致执行TcpConnection::onReadrpc()读取客户端的数据,读取完后执行的excute()执行业务逻辑,也就是在此执行rpc方法并返回响应。
客户端:首先连接服务端,然后构建请求对象request,序列化后发送给服务端,得到服务端的响应后解析即可。
RpcChannel: 将connect连接服务端、write发送数据、read读取服务端响应给封装成一个函数,便于客户端使用。
也就是要实现RpcChannel的CallMethod()
-
指针的提前释放问题:RpcChannel的CallMethod(…)中,由于回调函数什么时候执行取决于事件什么时候发生,导致有可能request等指针已经释放了,回调函数才执行,此时无法再去取那些指针。
m_fd_event->listen(FdEvent::OUT_EVENT, [this, done](){...})
解决方法:将其中的参数都使用智能指针保存起来,将RpcChannel对象的智能指针也保存起来(使用shared_from_this) -
lambda函数问题:lambda表达式默认const捕获,如果捕获对象client时使用按值捕获,就导致常量对象client只能调用常量成员函数,而writeMessage()是非常量的,因此捕获&client才行。
-
怎么获取本地地址:使用getsockname
getsockname(): 这个函数用于获取一个已连接的 socket 的本地地址和端口号。这对于在服务器端了解它正在监听哪个地址和端口特别有用。
getpeername(): 这个函数用于获取已连接 socket 的对端地址和端口号。这对于在客户端了解它正在与哪个服务器通信特别有用 -
EPOLLERR事件
该事件是自动添加到epoll监听的,在connect时,如果服务端没启动,就会触发该事件,此时需要删除该套接字。
RPC超时
添加一个定时任务,时间到了就去取消rpc调用。具体而言,回调函数中通过controller设置m_is_cancled为true,这样服务端看到该字段为true时就知道客户端要取消该rpc调用。
异步日志优化
回顾一下线程同步的各种操作:linux线程同步
rpc日志文件:显示框架中的各种信息,例如线程阻塞、连接成功
定时(Logger使用定时器)将之前Logger日志m_buffer中取出所有数据,加入到异步日志的队列中
异步日志线程:一旦异步日志队列中有数据来了,就被唤醒,从队列中取一个日志写入到文件。
日志文件名:m_file_name_yyyymmdd.m_no,文件名 时间 文件序号,序号从0开始。文件达到一定大小就打开新日志文件,序号++,如果跨天了,打开新日志文件且序号重新开始
业务日志文件:rpc方法的调用情况。和rpc日志文件一样,单独使用一个线程、buffer来处理
客户端不需要日志文件、配置文件。
XML文件的使用
此前使用的是tinyxml1(头文件在usr/include下,libtinyxml.a和libtinyxml.so在/usr/lib/x86_64-linux-gnu下),现在使用tinyxml2
首先写个xml文件
<?xml version="1.0" encoding="UTF-8"?>
<node_root>
<class1>
<teacher name="yx">A</teacher>
<student>B</student>
</class1>
<class2>
<number>3</number>
</class2>
<class3>
<number>4</number>
</class3>
</node_root>
常用操作:
#include <iostream>
#include <tinyxml2.h>
int main()
{
//2.加载xml文件
tinyxml2::XMLDocument doc;
auto ret = doc.LoadFile("test.xml");
doc.Print();//打印该xml文件
if (ret != tinyxml2::XMLError::XML_SUCCESS)
{
std::cout<<"xml文件加载失败"<<std::endl;
}
//2.获取根节点
tinyxml2::XMLElement* root = doc.RootElement();
//3.创建节点
root->InsertNewChildElement("class4");
//4.遍历节点:NextSiblingElement就是下一个兄弟节点
for (tinyxml2::XMLElement* e = root->FirstChildElement(); e!= nullptr; e=e->NextSiblingElement())
{
//5.打印标签名
std::cout<<e->Name()<<std::endl;//class1 class2 class3 class4
for (tinyxml2::XMLElement* ee = e->FirstChildElement(); ee!= nullptr; ee=ee->NextSiblingElement())
{
std::cout<<ee->Name()<<":"<<ee->GetText()<<std::endl;//GetText打印文本内容
}
}
return 0;
}
总结
eventloop的实现:使用的是主从reactor模型,主reactor执行eventloop,也就是取任务执行、epoll_wait等待唤醒(任务来了),添加任务。
也就是说,我是使用epoll来监听客户端连接的,在一开始,就创建好套接字lfd,监听其可读事件,一旦可读,就说明有新连接来了,
阻塞在epoll_wait处的主线程就被唤醒去添加可读任务,任务就是回调函数,这里添加的可读事件任务就是从线程组中取一个线程,accept后专门负责与该客户端通信。
线程组的实现:线程组就是创建多个线程,我的线程使用的是linux的线程创建方式,创建好以后,使用信号量将所有工作线程都阻塞起来,等待start。
对事件进行了封装fd_event:不同套接字fd上要监听不同事件,不同事件又要设置不同的任务函数。因此写一个事件类,带有事件的fd,能方便的设置读/写事件的任务函数,也能方便的知道其监听了什么事件。
由于可能有多个套接字fd需要监听事件,因此写了一个fd_event_group用来保存这些fd_event,都以fd为下标保存在vector容器中。
如果没有事件发生,主reactor不能一直阻塞在epoll_wait处,而是应该到时间了就开始下一个循环,去取任务执行,这就需要实现定时器。
定时器的实现:使用timerfd可以以文件描述符的形式监听时间变化,前面已经使用fd_event封装好普通的读写事件了,这里需要继承它实现定时功能。
使用timerfd设置好时间间隔后,定时器到时间就会触发可读事件,从缓冲区中读出8字节数据并直接执行任务。实际上定时任务的执行时间点不是这里设置的定时器时间间隔,
定时任务可能不止一个,因此使用multimap来存储所有的定时任务,它具有天然有序的特点,每次定时器到时间了就取第一个任务,看看它到时间没有,如果它都没到执行的时间,那就更不必说后面的任务了。
当然也有可能不小心任务过期了,那么就赶紧执行该任务。
主动唤醒阻塞的eventloop:除了前面使用定时器唤醒主线程,还可以主动唤醒,也就是主动的write数据,并在该可读事件的任务函数中读出数据。
tcp连接的实现:由于tcp是流式传输协议,如果直接读取数据,就分不清每个请求了,因此需要先放到缓冲区里,使用自定义的应用层协议慢慢解析。
使用vector来实现缓冲区,实现其中写字符串到缓冲区、从缓冲区读出数据等方法,通过两个下标来记录写了多少数据,并且随时将数据整体左移,避免前面的空间浪费了。
服务端的数据流向:tcp连接使用读缓冲区和写缓冲区。服务端的工作线程会监听一个fd的可读事件,这个fd是eventloop得知有客户端连接时accept得到的cfd,
一旦客户端发送来数据,那就全部读出来,读出来后根据自定义协议进行解码,还原成原始的结构体对象message,并借此执行rpc方法(rpc分发器里调用CallMethod),将执行结果作为响应发生给客户端。
rpc分发器的实现:首先使用protobuf定义请求和响应的结构体、服务以及服务里的rpc方法。
服务端需要实现rpc方法并提供注册rpc服务的方法,所谓的注册也就是把服务对象Service以及方法描述符、请求号都保存起来,本文使用map,以服务名为key,service对象为value保存。
为了在分发器里调用CallMethod,其中有一个参数是控制器需要实现,用于控制rpc请求过程中的参数,每次rpc请求需要重置控制器。
而实际上调用CallMethod是调用的pb.cc文件里的CallMethod,该方法根据method->index()来调用具体的rpc方法。
又因为客户端使用stub对象调用rpc方法时,实际上是调用RpcChannel::CallMethod(不同于上面的CallMethod),这是由protobuf规定的。
因此,在RpcChannel::CallMethod方法中,主要是写连接服务端、发送请求、读服务端响应、解析响应等操作。
日志:主要是2个类。一个类Logger实现基本的日志功能,通过读取xml配置文件来确定日志级别,每个日志也就是字符串,都保存在数组中。
另一个类AsyncLogger实现异步日志。异步日志的实现使用定时器和线程池。定时将Logger中的日志取出放到AsyncLogger的队列中,AsyncLogger有个线程专门用于将日志写入到文件。
其它
-
tail命令
查看日志文件最后10行内容:tail -n 10 access.log
实时监视文件增加的内容:tail -f access.log
每3秒刷新一次,查看日志最后20行:tail -fs 3 -n 20 access.log -
##__VA_ARGS__
的用法
__va__args__
是可变参数占位符,加上##后,当可变参数的个数为0时,##可以把前面多余的”,“去掉,否则编译出错#include <iostream> #include <string> #define LOG(strs, ...) fun(strs, ##__VA_ARGS__) template<class ...Args> int fun(std::string strs, Args ...args){ //string转const char * string转基本数据类型是int i=std::stoi(s) const char* cc = strs.c_str(); int res = snprintf(nullptr, 0, cc, args...); return res; } int main() { int res = LOG("name:%s", "yx"); std::cout<<"字符串长度为"<<res<<std::endl;//7 return 0; }
-
makefile中打印信息
$(info PATH_COMM is $(PATH_COMM)) -
##连接操作符:将两个标记连接在一起
#字符串化操作符:将该参数转换为一个以双引号括起来的字符串 -
git操作
git add . 将所有变更一次性添加到暂存区 git status 查看当前所处分支、暂存区状态等信息 git commit -m "finish log config and mutex" 提交变更到本地git仓库 git push 将本地分支提交到远程分支
git push报错Connection timed out的解决方法:
git config --global --unset https.proxy
git config --global --unset http.proxy查看最近的提交记录:git log --oneline
追加提交:git commit --amend
创建分支dev:git branch dev
查看所有分支:git branch -a
切换到dev分支:git checkout dev
暂存区文件来覆盖工作区文件:git checkout .
取消add操作:git reset
已经commit了怎么回退:git reset --hard <last_commit_id>
分支合并:git merge dev
修改远程仓库地址:git remote set-url origin https://github.com/example/example.git -
netstat 常见参数
-a (all)显示所有选项,默认不显示 LISTEN 相关
-t (tcp)显示tcp相关选项
-u (udp)显示udp相关选项
-l 列出有在 listen (监听) 的服务状态
-n 不显示别名,能显示数字的全部转化成数字
-p 显示建立相关链接(sockets)的程序名
-r 显示路由信息,路由表
-e 显示扩展信息,例如uid等
-s 按各个协议进行统计
-c 每隔一个固定时间,执行该netstat命令。
netstat -tln -
std::function的使用
#include <memory> #include <functional> #include <iostream> int fun(int a){ return a; } int main() { //定义一个函数类型 typedef std::function<int(int)> myfun; //将函数fun的地址存储在mf中 myfun mf = fun; //调用fun函数 int res = mf(3); std::cout<<res<<std::endl;//3 return 0; }
#include <memory> #include <functional> #include <iostream> void fun(std::function<void()> cb){ cb(); } class A{ public: void xxfun(){ std::cout<<"xx"<<std::endl; } }; int main() { A a; // fun(std::bind(&A::xxfun, &a)); fun([&a]() { a.xxfun(); });//这样也可以,但不能直接传&a.xxfun,因为成员函数指针是依赖于对象a的 return 0; }
-
正则表达式
#include <iostream> #include <regex> int main(){ //ip地址 std::cout<< std::regex_match("192.168.1.1", std::regex("^(((\\d)|([1-9]\\d)|(1\\d{2})|(2[0-4]\\d)|(25[0-5]))\\.){3}((\\d)|([1-9]\\d)|(1\\d{2})|(2[0-4]\\d)|(25[0-5]))$" ))<<std::endl; return 0; }
-
改用cmake
cmake的使用 -
真随机数的生成
#include <random> #include <iostream> int main() { //真随机数 作为种子 std::random_device rd; //使用mt19937随机数引擎 std::mt19937 gen(rd()); //随机数分布 std::uniform_int_distribution<> dis(0, 100); // 0-100的均匀分布 //生成随机数 std::cout<<dis(gen)<<std::endl; return 0; }