使用电脑端体验更佳

从0搭建博客:TCP服务器

发布于:1747556600750 | 修改于:1787487117688 | 分类: 网站的诞生 | 浏览: 18

# 前: 刚刚特意看了一下,发现服务端的实时内存占用好像有16MB之巨... 感觉像是DB部分,和tbb引入的...回头得做内存统计了 # TCP服务器? # Muduo-Mini 我其实觉得muduo已经做的够好了 如果要求严苛一些,最多能批判其没有用上proacter,毕竟内核态中的内存拷贝走dma的话效率肯定是高高的,还可以骂他不跨平台,这点确实大错特错。 有人在360的evpp的issue里转发了一篇[檄文](https://www.jackarain.org/2024/06/07/network-library-design.html),言辞激烈,直指muduo-like服务器的问题 有几条我觉得表面对但是不应该解决的问题是: 1. 网络库不应该 copy 用户要发送的数据 --> 不copy数据?那么就把指针交给网络库处理?你认真的?但是可以增加个asyn send接口,只有用户非常有把握的时候才需要调用这个函数。 Copy的负担也并没有那么大,回想我**前同事**的非人操作,每一帧通过pcie向显卡上传8mb数据,其中经过了 logic thread -> rhi thread -> driver -> pcie -> device,copy了无数遍,就这也能跑60fps,所以大部分情况我不觉得那可怜的几KB会是什么瓶颈。 > 多提一句,虽然我这位前同事有些时候不干人事,但是他是位非常厉害/学习能力很强/很有趣的人,我非常非常地佩服他。他最近就要离京了,以此纪念owo 2. 网络库不应该被设计成应用程序框架 --> 这样的话用socket不就好了,posix网络库。 ## Overview 说实话,我在写这些东西之前没有碰过网络编程,我不是很喜欢这部分。 Muduo使用了一种叫做 Reactor的模型: 通知用户事件可用,并且执行用户的回调。 对应到服务端中去就是: socket是否可以写入?执行回调。 socket是否可以读取?执行回调。 那么在一个常见的简单的服务器中,首先要创建一个socket作为服务器用来接收客户端链接的窗口,如果这个socket可以读了 --- 那么执行回调:链接客户端。 链接客户端完成后,把新产生的Socket分发到IO线程上 --- 因为我们不想阻塞用来创建链接的线程 --- 然后再去监听他,如果可读,执行用户的读事件。 所以我们需要一个Acceptor来执行创建链接这件事,也就是不断地执行socket中的Accept的工具人;还要一堆IO线程EventLoop,也就是执行客户相干的事情的线程。 这时候重要的事情就变成了 如何判断socket事件有没有就绪? 这里就要引出我们的IO多路复用器了 --- select poll epoll 从select开始: 我们向select函数注册信息:哪些socket需要知道是否可读,哪些socket需要知道是否可写。函数则会在有事件可用的时候返回有事件的Socket个数,用户要干的事情就是:遍历你注册进了select的socket,如果某个事件可用那么执行函数,否则继续遍历。 ```cpp while (1) { nfds = select(max + 1, &read_fd, &write_fd, NULL, &timeout); // 每次需要遍历所有fd,判断有无读写事件发生 for (int i = 0; i <= max && nfds; ++i) { if (FD_ISSET(i, &read_fd)) { // 这里处理read事件 nfds --; } } } ``` 这么做的问题是效率很低,用户只能知道有几个事件就绪,但不知道哪几个事件就绪,所以需要手动遍历判断。 Poll就是大号Select,本质没有区别,无非是现在通过events/revents来检测这个socket哪些事件就绪。 Epoll则做了一个你能想到的改进:我查询得到结果后只会返回所有就绪的Socket和他们的事件,用户不再需要手动查找就绪事件。 于是核心函数只有俩: ```cpp //创建epoll int epoll_create(int size); //等待epoll返回结果 int epoll_wait(int epfd, struct epoll_event * events, int maxevents, int timeout); //增加/删除/修改 epoll内部的事件 int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event); ``` 这部分的抽象在我这叫做Listener,监听事件嘛~ ,Muduo里面叫poller 在外层还得有不断循环去tick Listener,不断去查找有无可用事件,所以就有了EventLoop,一个线程只会有一个eventloop。 Lisenter收到了可读事件后,还得有个办法把事件分发给某个Socket。 我叫它Event,Muduo叫Channel 于是就有了程序的基础架构: 创建服务器Socket 创建一个EventLoop用于监听进来的链接,创建好几个IOloop用于处理事件。 每个loop上启动一个Listener 把服务端Socket加入监听Eventloop里的Listener 如果监听的socket可读,event会触发用户回调,创建链接,并且把它分发给IOLoop。 用户把要监听的socket事件和回调函数绑定到event上,并且把Event注册到IOLoop的Listener里去 如果IOLoop上有事件可用,通过Event触发可用事件的回调,再执行用户的回调。 这一个完整的部分Muduo做的很精彩(虽然有借鉴Java NIO的嫌疑),不用我来多讲。 ## 一个TcpConnectionPtr的一生 一个Tcp链接在Listener中被创建,放入了TcpServer中管理,最后使用权会被交予用户,同时他还需要出现在Event里以供事件到来的时候被调用,或许还会出现在别的地方,比如我们做了个定时器想在10秒后关闭链接,此时定时器回调也会持有一份connection。 那么假如,某一时刻时用户关闭了Connection,此时我们该如何安全的去除所有地方的TcpConnection引用呢? 一个简单的办法是用shared_ptr去管理生命周期,那么只有当引用计数全部为0的时候conn才会事实销毁。 但如果一个定时器定时在一年后才执行,那么也就会导致一年后这个任务执行完毕后引用计数才会为0,而用户恰巧没有保存这个定时器ID呢.... 那么我们还有weak_ptr,weak_ptr.lock可以用来检测shared_ptr是否还存在,如果lock后返回不为空,那么此时这个ptr还没有被析构。 把weak_ptr绑定到定时器回调函数上,开始的时候首先检查一下这个shared_ptr存不存在,存在就执行,不存在就返回。 这个操作我们完全可以封装成一个模板类,就叫他WeakCallback ```cpp template<typename T, typename... ARGS> class FWeakCallback { public: FWeakCallback(const std::weak_ptr<T>& weak, void (T::* func)(ARGS...)) :mWeak(weak), mFunction(func) {} void operator()(ARGS&&...args) const { auto ptr = mWeak.lock(); if (!ptr)return; mFunction(ptr.get(), std::forward<ARGS>(args)...); } private: std::weak_ptr<T> mWeak; std::function<void(T*, ARGS...)> mFunction; }; template<typename T, typename... ARGS> auto makeWeakFunction(const std::weak_ptr<T>& weak, void (T::* func)(ARGS...)) { FWeakCallback callback(weak, func); std::function<void(ARGS...)> ret = [callback](ARGS&&... args) { callback(std::forward<ARGS>(args)...); }; return ret; } template<typename T, typename... ARGS> auto makeWeakFunction(const std::shared_ptr<T>& ptr, void (T::* func)(ARGS...)) { return makeWeakFunction(std::weak_ptr<T>(ptr), func); } ``` 这样我们就可以把会延长TCPConnection生命周期的lambda简单的转化为一个弱回调lambda。 ## 定时器队列 我反正觉得定时器就不该用什么操作系统强相关的api 比较好的解决办法是用一个大根堆来解决,保证所有到期的定时器都在堆顶就好.. 我的方法更加偷懒,用了一个MultiMap,key是epoch时间,每次需要查找到期定时器,只需用upper_bound函数查找到第一个大于目标时间的迭代器,然后从头开始遍历迭代器,执行回调即可。 ```cpp void FTimeQueue::AddTask(const std::function<void()>& task) { FAUTO_LOCK(m_mutex); m_curTasks.push_back(task); } uint64 FTimeQueue::AddTask(const std::function<void()>& task, TimePoint time) { FAUTO_LOCK(m_mutex); FTimerTask timerTask{}; timerTask.task = task; TimerID id = m_timerCounter++; timerTask.id =id; m_tasks.insert({time, timerTask}); m_timerID2Time.insert(std::make_pair(id, time)); return id; } std::vector<TimerFunc> FTimeQueue::Tick(TimePoint t) { FAUTO_LOCK(m_mutex); auto timerItBegin = m_tasks.begin(); auto timerItEnd = m_tasks.upper_bound(t); std::vector<TimerFunc> ret; std::swap(ret, m_curTasks); for (auto it = timerItBegin; it != timerItEnd;) { if (!it->second.aborted) { ret.push_back(it->second.task); } m_timerID2Time.erase(it->second.id); it = m_tasks.erase(it); } return ret; } void FTimeQueue::CancelTask(TimerID id){ FAUTO_LOCK(m_mutex); auto timeIt = m_timerID2Time.find(id); if(timeIt == m_timerID2Time.end())return; auto taskIt = m_tasks.equal_range(timeIt->second); assert(taskIt.first != taskIt.second); for(auto it = taskIt.first; it != taskIt.second; ++it){ if(it->second.id == id){ it->second.aborted = true; m_timerID2Time.erase(id); return; } } } ``` 这比timefd简单多了不是么ovo ## 让异常更直观一点 我挺喜欢Java的一点是,每当捕获了一个异常,你可以打印出异常的调用栈然后方便的debug,但是在C++中想要获得这种功能简直天方夜谭,毕竟java这种虚拟机语言可以获取的元信息太多啦。至于C++,怎么获取堆栈呢? 好吧,其实操作系统都有对应的帮助函数,比如说win32,vs会在你开启编译选项 /Zi 的时候生成.pdb文件,这个东西呢储存调试相关信息,比如代码的行号,文件的映射,相当于为了debugger提供的元信息。 在头文件`dbghelp.h`中提供了去解析pdb的能力 : 比如函数 [StackWalk64](https://learn.microsoft.com/en-us/windows/win32/api/dbghelp/nf-dbghelp-stackwalk64),可以像db一样去获取当前的堆栈并且得到函数名称;再比如linux上gcc加上 -g 的编译选项后会为你在二进制产物的elf头里加上.debug_info,那么调用系统函数读取elf头就能获取函数名称了 当然我不想和操作系统pvp,这也太哈人了,我选择第三方库:[absl](https://github.com/abseil/abseil-cpp) - debugging ```cpp #include "absl/debugging/stacktrace.h" #include "absl/debugging/symbolize.h" #define MAX_STACK_DEPTH 128 std::vector<std::string> F_API Fei::getStackTrace(uint32 skip) { void* stack[MAX_STACK_DEPTH]; int sizes[MAX_STACK_DEPTH]; uint32 realStackDepth = absl::GetStackFrames(stack, sizes, MAX_STACK_DEPTH, skip + 1); std::vector<std::string> ret; char symbol[2048]; for (auto i = 0; i < realStackDepth; ++i) { if(absl::Symbolize(stack[i],symbol , 2048)){ ret.emplace_back(symbol); }else{ ret.emplace_back("<unknown>"); } } return ret; } ``` > 其实是后面为了满足一个核心功能我引入了absl发现他可以干这种好厉害的事情,我才做了个这种功能的( ## SSL 加密 窝觉得窝在一个问题上犯了非常非常严重的错误 --- SSL的支持应该在那一层提供给用户呢? 0. 在TCP服务器层,SSL加密可以是通用的,集成到TCP服务器里理所应当! 1. 在用户使用层,用户可以用SSL加密,也可以用他们自己的加密/解密器,不应该做这种限制! > 窝在纠结中选择了0现在好后悔唔... 首先SSL的复杂程度已经不是咱能手写出来的呢,所以需要接入一个重量级的三方库 --- OpenSSL! 关于openssl,比较烦的一点是它并不能从源码层面集成,因为 -- 他自己说的 -- 哎呀我们是系统库,你应该用系统提供的openssl.. 好吧,就这样 在linux上你可用包管理器安装 libssl-dev 这种东西的 在windows上我推荐你去安装firedaemon提供的[二进制包](https://kb.firedaemon.com/support/solutions/articles/4000121705-openssl-3-1-3-0-and-1-1-1-binary-distributions-for-microsoft-windows) > 并非推荐,唯一选项 在cmake上加上如下 ```cmake find_package(OpenSSL REQUIRED) target_link_libraries(${PROJECT_NAME} PRIVATE OpenSSL::SSL OpenSSL::Crypto) target_include_directories(${PROJECT_NAME} PRIVATE ${OPENSSL_INCLUDE_DIR}) ``` 就好了 > windows跳出来作妖了,没有找xxxxx...按照提示把那几个文件夹加入到系统环境变量即可。 不管我们把ssl支持放在哪一层,我们都需要把openssl的函数单独拎出来封装一个类,不能暴漏给外部。 因为...靠openSSL的函数命名真的搞的我的codeLint一团乱糟糟的,试想一个用户include了库头文件,结果发现命名空间中多出了一大堆奇奇怪怪函数,他会有多难受捏 openssl官方推荐的做法是调用ssl版的socket函数 ```cpp #include <openssl/ssl.h> int SSL_read(SSL *ssl, void *buf, int num); int SSL_write(SSL *ssl, const void *buf, int num); int SSL_accept(SSL *ssl); ``` 用法呢就是在socket进来后(accept),你把Socket和ssl的handle用SSL_set_fd绑定在一起,然后通过ssl_accept开启socket链接,之后就是read write啦 但是这样子不行! 首先ssl握手过程是双向的,你要发给peer你的ssl证书,他也会回应你,那么ssl_accept在不经意间其实已经帮你走了几回send/recv了,这种跳脱我们管理的行为非常危险。 那你会想,诶那么我们能不能把recv的数据喂给ssl,然后ssl再把数据拉出来给我们,我们再send出去捏 当然是可以的,这就是openssl的Memory BIO。 我是一个很烂很烂的讲解员,即使我的服务端已经在ssl环境下运行了...一个多月没出问题了,我还是不太敢说我的做法就一定对 那么出来吧!我的[OpenSSL_example](https://github.com/darrenjs/openssl_examples/),这个仓库里有一个简洁的bio ssl实现 但是注意,嘿!现在已经没有ssl_renegotiation了! 接下来是我的openssl包装实现。 ```cpp class _FSSLHelperPrivate; class FSSLEnv :public FSingleton<FSSLEnv> { public: // SetUp SSL Context //certificateFile --> like "cert.pem" //privateKeyFile --> like "key.pem" FSSLEnv(const std::string &certificateFile,const std::string &privateKeyFile); //Change cert on the fly, for current existed connection void loadCertFiles(const std::string &certificateFile,const std::string &privateKeyFile); // Destroy SSL Context ~FSSLEnv(); bool isEnvSetup()const{ return SSLContext != 0; } void* getSSLContext()const{ return SSLContext; } private: void *SSLContext = 0; }; class FSSLHelper { public: FSSLHelper(); ~FSSLHelper(); bool shakeHand(FTcpConnection* ptr, FBufferReader &reader); bool hasShakeHandFin()const; // Will throw FException e FBufferReader EncryptSendingData(const char* inData,int len); FBufferReader DecryptRecvingData(FBufferReader &reader); private: std::unique_ptr<_FSSLHelperPrivate> dp; }; ``` 首先是 FSSLEnv ,这里面是openssl的上下文,比如说openssl证书啊... 哦对了,你可能看到了 `class _FSSLHelperPrivate;` 这个是 pImpl 手法,不用在意。 接着就是非常激动人心的 `FSSLHelper` 了 在我的实现里每个TcpConnection里都会带一个这东西作为ssl加解密的工具 > 这个真的是设计失误。。。 其中函数分两批 ```cpp bool shakeHand(FTcpConnection* ptr, FBufferReader &reader); bool hasShakeHandFin()const; ``` 用于ssl握手 ```cpp FBufferReader EncryptSendingData(const char* inData,int len); FBufferReader DecryptRecvingData(FBufferReader &reader); ``` 用于加解密数据 除此之外还有一些我们不想让用户看到的部分,那就是sslbio相关了 这部分放在了 _FSSLHelperPrivate 里 ```cpp class _FSSLHelperPrivate { public: _FSSLHelperPrivate() : inBuffer(128), outBuffer(128) {} FBuffer inBuffer; FBuffer outBuffer; SSL *sslHandler = 0; BIO *rbio = 0; BIO *wbio = 0; }; ``` 首先是两个buffer模拟接受和发送缓冲区,所有外面来的数据先放在inBuffer里等待着SSL的消费,SSL解密出来的数据放在OutBuffer里等待读取 sslHandler则是解密的上下文,因为ssl内部是个严格的状态机,解密加密数据存在严苛的上下文,这部分很重要!!!! 我们先创建bio对象,然后绑定上我们的sslhandle ```cpp FSSLHelper::FSSLHelper() : dp(new _FSSLHelperPrivate) { auto sslCtx = FSSLEnv::instance()->getSSLContext(); if (!sslCtx) return; dp->sslHandler = SSL_new((SSL_CTX *)sslCtx); dp->rbio = BIO_new(BIO_s_mem()); // 读 BIO,模拟接收数据 BIO_set_mem_eof_return(dp->rbio, -1); dp->wbio = BIO_new(BIO_s_mem()); // 写 BIO,模拟发送数据 BIO_set_mem_eof_return(dp->wbio, -1); SSL_set_bio(dp->sslHandler, dp->rbio, dp->wbio); SSL_set_accept_state(dp->sslHandler); } ``` 我们把ssl认为是一个大大黑盒子,那么这个黑盒子从 rbio中读入数据,并且向wbio中输出数据 SSL究竟是怎么握手的我也不太了解,问了一下gpt他给我的图: ``` Client Server | | | --- ClientHello -----------------------> | | - 支持的协议版本 | | - 支持的密码套件列表 | | - Client Random | | | | <--- ServerHello ----------------------- | | - 协商的协议版本 | | - 选定的密码套件 | | - Server Random | | | | <--- Certificate ----------------------- | | - 服务器证书链(含中间 CA) | | | | <--- ServerHelloDone ------------------- | | | | --- ClientKeyExchange ----------------> | | - 使用服务器公钥加密的 Pre‑Master Secret | | | | --- ChangeCipherSpec ------------------> | | - 通知从此报文开始使用协商的加密参数 | | | | --- Finished --------------------------> | | - 加密的握手摘要 | | | | <--- ChangeCipherSpec ------------------ | | | | <--- Finished -------------------------- | | | | —— Application Data (加密) ——————→ | ``` 好吧,看起来就像在打乒乓球,c->s c<-s ,c->s...那我们也按照这个模式设计我们的握手函数 > ***此处需见 勘误:2025/8/28 TLS1.2问题*** 当Socket可读,服务器会先检查这个socket是否已经完成握手,如果没完成,会进入握手流程! ```cpp bool FSSLHelper::shakeHand(FTcpConnection *ptr, FBufferReader &reader) { SSL *ssl = dp->sslHandler; if (SSL_is_init_finished(ssl)) return true; BIO *in_bio = dp->rbio; BIO *out_bio = dp->wbio; auto r = SSL_do_handshake(ssl); if (r < 0) { // Get data from reader auto view = reader.peekAll(); int read = BIO_write(in_bio, (void *)view.get(), view.size()); if (read > 0) { view.resetSize(read); reader.expireView(view); } } r = SSL_do_handshake(ssl); if (r < 0) { auto pending = BIO_ctrl_pending(out_bio); std::string dataToSend; dataToSend.resize(pending); pending = BIO_read(out_bio, dataToSend.data(), dataToSend.size()); ptr->send(std::move(dataToSend)); } return false; } ``` 先简单介绍几个函数 SSL_do_handshake ,用于在ssl内部执行握手流程,只有当握手成功他才会大于等于0。 由于一开始我们没有喂给ssl数据,所以流程肯定是推进不了的,于是他会返回一个小于0的数,这里其实需要去判断一下这个错误是什么,但是我们可以假装此时它是缺数据了,于是尝试从Connection里取数据喂进去 然后我们再执行一遍ssl握手,此时也可假装它是需要发送数据了,于是尝试从bio中读取出ssl写入的数据,然后send出去。 这是第一次服务端上的处理。 当客户端第二次发来信息,这时候会再次进入握手处理,根据我上面的ssl链接图,再经过一次接收处理发送流程,就是握手成功啦! 一开始我确实也是这么做的,在我的本地,是完全没有问题的 但是在服务器部署后,发现存在一部分的请求会出现卡住的情况,并且如果中止浏览器的请求,会显示ssl错误的问题 问题出在哪里呢? 然后我看到[rfc7918](https://datatracker.ietf.org/doc/html/rfc7918)的Transport Layer Security (TLS) False Start,这个机制允许客户端在握手的最后一次向服务器额外发送应用数据,而不用等到收到服务器的确认消息。 所以在现在的实现中,如果在第二次的握手处理中,服务器发送了确认消息后会返回false代表握手尚未成功,服务器就会等待客户端下一次发送消息,再第三次进入这个函数,然后通过SSL_is_init_finished得到握手成功的信息,进入真正的用户消息解析函数。 但是客户端不按常理出牌,导致我的服务器卡在了第二次之后,等待接收新的客户端消息之时... 于是我们改进一下这个函数: ```cpp bool FSSLHelper::shakeHand(FTcpConnection *ptr, FBufferReader &reader) { SSL *ssl = dp->sslHandler; if (SSL_is_init_finished(ssl)) return true; BIO *in_bio = dp->rbio; BIO *out_bio = dp->wbio; auto r = SSL_do_handshake(ssl); if (r < 0) { // Get data from reader auto view = reader.peekAll(); int read = BIO_write(in_bio, (void *)view.get(), view.size()); if (read > 0) { view.resetSize(read); reader.expireView(view); } } r = SSL_do_handshake(ssl); if (r < 0) { auto pending = BIO_ctrl_pending(out_bio); std::string dataToSend; dataToSend.resize(pending); pending = BIO_read(out_bio, dataToSend.data(), dataToSend.size()); ptr->send(std::move(dataToSend)); } else { if (SSL_pending(ssl) > 0) { return true; } } return false; } ``` 如果第二次握手后发现ssl已经提示握手成功,并且有额外的数据可以读,那么就能直接返回true SSL_pending是用来探测ssl内部有没有等待读取数据的函数,如果pending大于0就说明我们可以用SSL_read读出数据 但是又翻车啦!表现情况和上次一样,浏览器一直转圈圈,好奇怪... 我花了好久好久,想破了头,突然,我在栈溢出上看到一个说法: OpenSSL是个状态机啦,你得先调用一次SSL_read函数,然后才能知道有多少pending的数据... 于是 ```cpp SSL_read(ssl, 0, 0); if (SSL_pending(ssl) > 0) { return true; } ``` 卧槽,成啦! 接着是openssl加密函数 这部分很简单滴呀 ```cpp FBufferReader FSSLHelper::EncryptSendingData(const char *inData, int len) { SSL *ssl = dp->sslHandler; if (!hasShakeHandFin()) { throw SSLNotPreparedException(); } // write data need encrypt into ssl int readSize = 0; // If has data not send, append current data back to it. if (dp->inBuffer.getReadableSize() > 0) { dp->inBuffer.Append(inData, len); FBufferReader reader(dp->inBuffer); auto view = reader.peekAll(); readSize = SSL_write(dp->sslHandler, (void *)view.get(), view.size()); if (readSize >= 0) { view.resetSize(readSize); reader.expireView(view); } } else { readSize = SSL_write(dp->sslHandler, (void *)inData, len); if (readSize >= 0 && readSize < len) { dp->inBuffer.Append(inData + readSize, len - readSize); } } if (readSize < 0) { auto r = SSL_get_error(ssl, readSize); auto err = ERR_error_string(r, 0); Logger::instance()->log(MODULE_NAME, lvl::warn, "SSL read error, reason \"{}\"", err); throw SSLDataException("SSL Write encrypt data error."); } // read encrypted data from bio BIO *ioOut = dp->wbio; auto pending = BIO_ctrl_pending(ioOut); std::unique_ptr<char[]> temp(new char[pending]); // Copy 1. int writeSize = BIO_read(ioOut, (void *)temp.get(), pending); // Copy 2. dp->outBuffer.Append(temp.get(), writeSize); return FBufferReader(dp->outBuffer); // And for send --> Copy 3 may happen QAQ } ``` 把要发送的数据写入SSL,如果上次还有残留的,一并写入 最后返回个加密后数据的view给上层,完事! 那解密数据也很简单啊 ```cpp FBufferReader FSSLHelper::DecryptRecvingData(FBufferReader &reader) { SSL *ssl = dp->sslHandler; if (!hasShakeHandFin()) { throw SSLNotPreparedException(); } BIO *ioIn = dp->rbio; // BIO *ioOut = dp->wbio; auto view = reader.peekAll(); int size = view.size(); // write encrypted data into bio if (view.size() > 0) { auto size = BIO_write(ioIn, (unsigned char *)view.get(), view.size()); view.resetSize(size); reader.expireView(view); } // read decrypted data out of ssl int toReadSize = SSL_pending(dp->sslHandler) + size; // To read size --> a predicted reading size. std::unique_ptr<char[]> temp(new char[toReadSize]); int readSize = SSL_read(dp->sslHandler, temp.get(), toReadSize); if (readSize <= 0) { auto r = SSL_get_error(ssl, toReadSize); auto err = ERR_error_string(r, 0); Logger::instance()->log(MODULE_NAME, lvl::warn, "SSL read error, reason \"{}\"", err); throw SSLDataException("SSL Write decrypt data error."); } dp->outBuffer.Append(temp.get(), readSize); return dp->outBuffer; } ``` 写入bio,读出ssl,最后储存到内部buffer,返回reader,完事! 但是又出问题啦 在服务器上部署后,发现窝发布长博文的时候,服务端会错误解析浏览器发来的数据 但是博文的长度短一些就没问题了。。。 在本地测试,这个问题就消失了。。。 当时窝好崩溃啊,首先是服务端上调试简直是噩梦,我只好把每个Connection的数据都打印出来,然后对比... 花了好久终于发现了端倪:为什莫ssl解密出来的数据会少一大截??? 然后我就发现 --- SSL_read是个有节制的好孩子 它对于非常长的报文,会分次去解密,所以每次解密完后需要调用`SSL_pending`,看看还能不能榨出剩余的数据,只有当SSL_pending返回值为0了,才能说所有数据都被提出来了 ```cpp FBufferReader FSSLHelper::DecryptRecvingData(FBufferReader &reader) { SSL *ssl = dp->sslHandler; if (!hasShakeHandFin()) { throw SSLNotPreparedException(); } BIO *ioIn = dp->rbio; // 1. Pull encrypted bytes from reader and feed into SSL BIO int encryptedSize = 0; const unsigned char *encryptedData = reinterpret_cast<const unsigned char *>(reader.peekAll(encryptedSize)); if (encryptedSize > 0) { int written = BIO_write(ioIn, encryptedData, encryptedSize); reader.expireSize(written); } // 2. Read all decrypted data from SSL into outBuffer const int defaultBufSize = 4096; std::unique_ptr<char[]> buffer(new char[defaultBufSize]); while (true) { // Determine how many bytes we can read without blocking int pending = SSL_pending(ssl); int toRead = (pending > 0 ? pending : defaultBufSize); toRead = std::min(toRead, defaultBufSize); // Perform the SSL read int readBytes = SSL_read(ssl, buffer.get(), toRead); if (readBytes > 0) { // Append decrypted bytes to the output buffer dp->outBuffer.Append(buffer.get(), readBytes); continue; // keep reading until no more pending data } // Handle zero or error return int err = SSL_get_error(ssl, readBytes); if (err == SSL_ERROR_WANT_READ || err == SSL_ERROR_WANT_WRITE || err == SSL_ERROR_ZERO_RETURN) { // No more data available now or clean shutdown break; } ``` 所以这里做出额外的改进,用循环读出数据,直到无数据可用。 那么ssl_write会不会存在同样的情况,每次只会写入部分数据?! 我查了一下ssl官方,答案是不会: `SSL_write() will only return with success, when the complete contents of buf of length num has been written. ` > 真的要被openssl榨干了,为了他贡献了好几次熬夜到凌晨三点,呜..... ## 结尾 这部分的代码设计其实非常简单,难度都在调试上了,多看看muduo真的能有许多收获OwO ----- 买的卡哈2终于到了,在这个春天即将过去的时候出趟远门吧! <iframe frameborder="no" border="0" marginwidth="0" marginheight="0" width=330 height=86 src="//music.163.com/outchain/player?type=2&id=1300703667&auto=0&height=66"></iframe> # 勘误 ## 2025/8/28 TLS1.2问题 当我在做http2支持的时候,突然发现TLS 1.2似乎握手不上。 然后拿出了我的wireShark开始找原因! 结果发现,在TLS1.2下,当客户端发送了change cipher sepc + encryted handshake message后,服务端就进入了tls链接建立状态,也就是SSL_do_handshake返回了1。 但是理论上,服务端也需要发送一个change cipher sepc + encryted handshake message。哪去了呢? 然后我发现:SSL_do_handshake返回1只能代表openssl内部把数据写到bio里去了... 此时bio里有未处理的数据,需要发送。 于是改进handShake函数 ```cxx bool FSSLHelper::shakeHand(FTcpConnection *ptr, FBufferReader &reader) { SSL *ssl = dp->sslHandler; if (SSL_is_init_finished(ssl)) return true; BIO *in_bio = dp->rbio; BIO *out_bio = dp->wbio; auto r = SSL_do_handshake(ssl); if (r < 0) { // Get data from reader int size = 0; auto data = reader.peekAll(size); int read = BIO_write(in_bio, (const void *)data, size); if (read > 0) { reader.expireSize(read); } } r = SSL_do_handshake(ssl); std::string dataToSend; while(1){ auto pending = BIO_ctrl_pending(out_bio); if(pending <= 0)break; auto oldSize = dataToSend.size(); dataToSend.resize(oldSize + pending); pending = BIO_read(out_bio, (void *)(dataToSend.data() + oldSize), pending); } if(dataToSend.size() > 0) { dp->mHasPending = true; ptr->send(std::move(dataToSend)); dp->mHasPending = false; } if (r < 0) { } else { SSL_read(ssl, 0, 0); if (SSL_pending(ssl) > 0) { return true; } } return false; } ``` 在tcpConnection处,需要通过mHasPending判断是否要用ssl加密信息,否则握手信息会被当作app data加密发送出去。 那为什么我一直能访问? 因为TLS1.3在客户端发送完Cahnge Cipher Spec后就finish了,其实此时服务端还是有信息要发送的,只不过...这信息并不重要,不影响链接罢了 QWQ wireshark真好用


评论