RPC 框架-格式化输出、eventfd、Reactor模式、linux定时器、XML文件的使用

完整代码

什么是RPC(远程过程调用)

通过网络调用远程计算机的服务,主要用于分布式系统内部。
由于单纯使用TCP会导致粘包问题,因此需要在应用层对数据进行封装用于区分消息边界,例如在应用层使用HTTP协议或RPC协议。RPC协议的诞生比HTTP早。
服务发现:找到服务对应的ip地址和端口 的 过程。http中是通过DNS服务解析域名得到ip地址,而rpc有专门的中间服务保存服务名和ip信息(例如redis)。
主流的http1.1使用json序列化结构体数据,而rpc定制化程度更高,使用protobuf序列化协议
http2性能优于rpc,由于历史原因,还是要使用rpc

为了实现远程过程调用,需要解决如下问题:

  1. 客户端与服务器怎么进行通信:应用层可以使用http2协议避免粘包问题,例如gRPC,也可以使用基于protubuf的协议,本文基于protubuf自定义协议报文格式。而传输层通常使用tcp协议。
  2. tcp通信使用什么事件处理模式(reactor还是proactor),本文使用reactor模式
  3. 怎么进行数据的序列化与反序列化:使用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(&currentTime_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网络编程

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中,子线程监听其读写事件并进行业务处理。

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定时器

  1. alarm(多久后发送SIGALAM信号):只发送一次SIGALRM信号

  2. 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;
    }
    
  3. 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的实现:提供一个数组和读写指针,用于往数组中读写数据
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()

  1. 指针的提前释放问题:RpcChannel的CallMethod(…)中,由于回调函数什么时候执行取决于事件什么时候发生,导致有可能request等指针已经释放了,回调函数才执行,此时无法再去取那些指针。
    m_fd_event->listen(FdEvent::OUT_EVENT, [this, done](){...})
    解决方法:将其中的参数都使用智能指针保存起来,将RpcChannel对象的智能指针也保存起来(使用shared_from_this)

  2. lambda函数问题:lambda表达式默认const捕获,如果捕获对象client时使用按值捕获,就导致常量对象client只能调用常量成员函数,而writeMessage()是非常量的,因此捕获&client才行。

  3. 怎么获取本地地址:使用getsockname
    getsockname(): 这个函数用于获取一个已连接的 socket 的本地地址和端口号。这对于在服务器端了解它正在监听哪个地址和端口特别有用。
    getpeername(): 这个函数用于获取已连接 socket 的对端地址和端口号。这对于在客户端了解它正在与哪个服务器通信特别有用

  4. 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;
    }
    
  • 29
    点赞
  • 46
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值