search_plus_index.json 1.6 MB

1
  1. {"./":{"url":"./","title":"Introduction","keywords":"","body":" 本站点是公众号【高性能服务器开发】文章汇总,如需下载全部文章,可以在公众号回复关键字“文章下载”即可得到下载链接。分享和转发文章时请保留作者信息,部分文章来源于网络,侵权请联系删除。如果你需要下载该站点的源码自己部署,可以在公众号后台回复关键字“站点下载”。 我也专门建立了读者交流群,想加群的读者可以加我微信easy_coder Part I C++必知必会的知识点 如何成为一名合格的C/C++开发者? 不定参数函数实现var_arg系列的宏 你一定要搞明白的C函数调用方式与栈原理 深入理解C/C++中的指针 详解C++11中的智能指针 C++17结构化绑定 C++必须掌握的pimpl惯用法 用Visual Studio调试Linux程序 如何使用Visual Studio管理和阅读开源项目代码 利用cmake工具生成Visual Studio工程文件 多线程 后台C++开发你一定要知道的条件变量 整型变量赋值是原子操作吗? 网络编程 bind 函数重难点解析 connect 函数在阻塞和非阻塞模式下的行为 select 函数重难点解析 Linux epoll 模型(含LT 模式和 ET 模式详解) socket 的阻塞模式和非阻塞模式 非阻塞模式下 send 和 recv 函数的返回值 服务器开发通信协议设计介绍 TCP 协议如何解决粘包、半包问题 网络通信中收发数据的正确姿势 服务器端发数据时,如果对端一直不收,怎么办? 程序员必知必会的网络命令 利用telnet命令发电子邮件 做Java或者C++开发都应该知道的lsof命令 Linux网络故障排查的瑞士军刀nc命令 Linux tcpdump使用详解 从抓包的角度分析connect函数的连接过程 服务器开发中网络数据分析与故障排查经验漫谈 Part II 高性能服务器框架设计 主线程与工作线程的分工 Reactor模式 实例:一个服务器程序的架构介绍 错误码系统的设计 日志系统的设计 如何设计断线自动重连机制 心跳包机制设计详解 业务数据处理一定要单独开线程吗 C++ 高性能服务器网络框架设计细节 服务器开发案例实战 从零实现一个http服务器 从零实现一款12306刷票软件 从零实现一个邮件收发客户端 从零开发一个WebSocket服务器 从零学习开源项目系列(一) 从一款多人联机实时对战游戏开始 从零学习开源项目系列(二) 最后一战概况 从零学习开源项目系列(三) CSBattleMgr服务源码研究 从零学习开源项目系列(四)LogServer源码探究 Part III TeamTalk IM源码分析 01 TeamTalk介绍 02 服务器端的程序的编译与部署 03 服务器端的程序架构介绍 04 服务器端db_proxy_server源码分析 05 服务器端msg_server源码分析 06 服务器端login_server源码分析 07 服务器端msfs源码分析 08 服务器端file_server源码分析 09 服务器端route_server源码分析 10 开放一个TeamTalk测试服务器地址和几个测试账号 11 pc客户端源码分析 libevent源码深度剖析 libevent源码深度剖析01 libevent源码深度剖析02 libevent源码深度剖析03 libevent源码深度剖析04 libevent源码深度剖析05 libevent源码深度剖析06 libevent源码深度剖析07 libevent源码深度剖析08 libevent源码深度剖析09 libevent源码深度剖析10 libevent源码深度剖析11 libevent源码深度剖析12 libevent源码深度剖析13 leveldb源码分析 leveldb源码分析1 leveldb源码分析2 leveldb源码分析3 leveldb源码分析4 leveldb源码分析5 leveldb源码分析6 leveldb源码分析7 leveldb源码分析8 leveldb源码分析9 leveldb源码分析10 leveldb源码分析11 leveldb源码分析12 leveldb源码分析13 leveldb源码分析14 leveldb源码分析15 leveldb源码分析16 leveldb源码分析17 leveldb源码分析18 leveldb源码分析19 leveldb源码分析20 leveldb源码分析21 leveldb源码分析22 Memcached源码分析 00 服务器资源调整 01 初始化参数解析 02 网络监听的建立 03 网络连接建立 04 内存初始化 05 资源初始化 06 get过程 07 cas属性 08 内存池 09 连接队列 10 Hash表操作 12 set操作 13 do_item_alloc操作 14 item结构 15 Hash表扩容 16 线程交互 17 状态机 游戏开发专题 1 游戏服务器开发的基本体系与服务器端开发的一些建议 2 网络游戏服务器开发框架设计介绍 3 游戏后端开发需要掌握的知识 4 关于游戏服务端架构的整理 5 各类游戏对应的服务端架构 6 从腾讯QQgame高性能服务器集群架构看“分而治之”与“自治”等分布式架构设计原则 7 QQ游戏百万人同时在线服务器架构实现 8 大型多人在线游戏服务器架构设计 9 百万用户级游戏服务器架构设计 10 十万在线的WebGame的数据库设计思路 11 一种高性能网络游戏服务器架构设计 12 经典游戏服务器端架构概述 13 游戏跨服架构进化之路 Part IV 程序员面试题精讲 腾讯后台开发实习生技能要求 聊聊如何拿大厂的 offer 网络通信题目集锦 我面试后端开发经理的经历 Linux C/C++后端开发面试问哪些问题 职业规划 给工作 4 年迷茫的程序员们的一点建议 聊聊技术人员的常见的职业问题 写给那些傻傻想做服务器开发的朋友 自我提升与开源代码 2020 年好好读一读开源代码吧 后端开发相关的书籍 后台开发应该读的书 程序员的简历 程序员如何写简历 程序员的薪资与年终奖那些事儿 技术面试与HR谈薪资技巧 聊一聊程序员如何增加收入 谈一谈年终奖 程序员的烦心事 拒绝了一家公司的offer后,他们的副总和hr总监同时打电话来询问拒绝原因并极力要求加入,我该不该去? 我是一名程序员,结婚时女友要求我用两年的工资作为彩礼,我该不该答应? 作者的故事 我的 2019 我是如何年薪五十万的 如果您在阅读上述文章的过程中有任何问题或者建议,可以加我微信easy_coder交流。 Enjoy it! 如需下载本站全部技术文章,可以在【高性能服务器开发】公众号回复关键字“文章下载”即可。最近更新时间: 2020-10-08 16:10:49 "},"articles/C++必知必会的知识点/":{"url":"articles/C++必知必会的知识点/","title":"C++必知必会的知识点","keywords":"","body":"C++必知必会的知识点 如何成为一名合格的C/C++开发者? 不定参数函数实现var_arg系列的宏 你一定要搞明白的C函数调用方式与栈原理 深入理解C/C++中的指针 详解C++11中的智能指针 C++17结构化绑定 C++必须掌握的pimpl惯用法 用Visual Studio调试Linux程序 如何使用Visual Studio管理和阅读开源项目代码 利用cmake工具生成Visual Studio工程文件 如需下载本站全部技术文章,可以在【高性能服务器开发】公众号回复关键字“文章下载”即可。最近更新时间: 2020-10-08 11:37:39 "},"articles/C++必知必会的知识点/如何成为一名合格的CC++开发者?.html":{"url":"articles/C++必知必会的知识点/如何成为一名合格的CC++开发者?.html","title":"如何成为一名合格的C/C++开发者?","keywords":"","body":"如何成为一名合格的 C/C++ 开发者? 写在前面的话 在大多数开发或者准开发人员的认识中,C/C++ 是一门非常难的编程语言,很多人知道它的强大,但因为认为“难”造成的恐惧让很多人放弃。 笔者从学生时代开始接触 C/C++,工作以后先后担任过 C++ 客户端和服务器的开发经理并带队开发,至今已经有十多年了。虽然时至今日哪种编程语言对我来说已经不再重要(我目前主要从事 Java 开发),但 C/C++ 仍然是笔者最喜欢的编程语言。在我看来,C/C++ 一旦学成,其妙无穷,就像武侠小说中的“九阳神功”一样,有了这个基础,您可以快速学习任何语言和编程技术。 C/C++ 的当前应用领域 需要注意的是本文不细分 C与 C++ 的区别,通常情况下,C++ 可以看成是 C 的一个超集,在古典时期,可以认为 C++ 就是 C with classes。虽然如今的 C++ 从功能层面上来看,离 C 越来越远了;但是从语法层面来上来看,大多数 C++ 语法还是与 C 基本一致的——所谓 C++ 的面向对象特性,如果细究 C++ 类方法的具体语法还是 C 的过程式语法。当然,面向对象是一种思想,语言本身对其支持的程度固然重要,能否熟练使用更要看开发者的水平。 C 语言目前主要用于像操作系统一类偏底层的应用开发,包括像 Windows/Linux 这样的大型商业操作系统,以及嵌入式操作系统、嵌入式设备上的应用。还有一些开源的软件,也会选择 C 开发,这些系统主要优先考虑程序执行效率和生成的可执行文件的体积(C 代码生成的可执行文件体积相对更小),当然还有一些是历史技术选型问题,这类软件像 Redis、libevent、Nginx,目前像国内的电信服务商所使用的电话呼叫系统,一般也是基于一款叫 FreeSWITCH 的开源 C 程序做的二次开发(项目地址:https://freeswitch.com/ )。 C++ 面向对象的语法与 C 相比较起来,在将高级语言翻译成机器二进制码的时候,C++ 编译器在背后偷偷地做了大量工作,生成了大量的额外机器码,而这种机器码相对于 C 来说不是必须的。例如,对于一个 C++ 类的实例方法,编译器在生成这个方法的机器码时,会将函数的第一个参数设置成对象的 this 指针地址,以此来实现对象与函数的绑定。正因为如此,许多开发者会优化和调整编译器生成的汇编代码。 我们再来说说 C++。C++ 的应用领域目前有三大类,第一类就是我们目前见到的各种桌面应用软件,尤其 Windows 桌面软件,如 QQ、安全类杀毒类软件(如金山的安全卫士,已开源,其代码地址:http://code.ijinshan.com/source/source.html )、各种浏览器等;另外就是一些基础软件和高级语言的运行时环境,如大型数据库软件、Java 虚拟机、C# 的 CLR、Python 编译器和运行时环境等;第三类就是一些业务型应用软件的后台,像游戏的服务器后台,如魔兽世界的服务器(代码地址:https://github.com/azerothcore/azerothcore-wotlk )和一些企业内部的应用系统。笔者曾在某交易所从事后台开发,其交易系统和行情系统就是基于 C++ 开发的。 C++ 与操作系统平台 从上面的介绍可以看出,与 Java、Python 等语言相比,C/C++ 语言是离操作系统最近的一种高级语言,因此其执行效率也比较高。但是有得必有失,因为如此,C/C++ 这门语言存在如下特点。 C/C++ 整套的语法不具备“功能完备性”,单纯地使用这门语言本身提供的功能无法创建任何有意义的程序,必须借助操作系统的 API 接口函数来达到相应的功能。当然,随着 C++ 语言标准和版本的不断更新升级,这种现状正在改变;而像 Java、Python 这类语言,其自带的 SDK 提供了各种操作系统的功能。 举个例子,C/C++ 语言本身不具备网络通信功能,必须使用操作系统提供的网络通信函数(如 Socket 系列函数);而对于 Java 来说,其 JDK 自带的 java.net 和 java.io 等包则提供了完整的网络通信功能。我在读书的时候常常听人说,QQ、360 安全卫士这类软件是用 C/C++ 开发的,但是当我学完整本 C/C++ 教材以后,仍然写不出来一个像样的窗口程序。许多过来人应该都有类似的困惑吧?其原因是一般 C/C++ 的教材不会教你如何使用操作系统 API 函数的内容。 C/C++ 语言需要直接使用操作系统的接口功能,这就造成了 C/C++ 语言繁、难的地方。如操作内存不当容易引起程序宕机,不同操作系统的 API 接口使用习惯和风格也不一样。接口函数种类繁多,开发者如果想开发跨平台的程序,必须要学习多个平台的接口函数和对应的系统原理。 在应用层开发,直接使用操作系统接口的函数,往往执行效率高、控制力度大。而开发能力仅仅限制于操作系统本身,Java 这类语言,很多功能即使操作系统提供了,如果 Java 虚拟机不提供,开发人员也无法使用。正如著名的编程大师 Charles Petzold 所说: “显而易见,究竟用哪种方式编写应用程序最好,其实并无一定之规。应用程序本身的特性应该是决定采用何种编程工具的最主要因素,但是无论将来你采用什么样的编程工具,通过了解操作系统 API 从而深入理解操作系统的工作原理,这本身就有很重要的意义。操作系统是一个非常复杂的系统,在 API 之上加一层编程语言并不能消除其复杂性,最多不过是把复杂性隐藏起来而已。说不定什么时候,复杂的那一面迟早会蹦出来拖你的后腿,懂得系统 API 能让你到时候可以更快地挣脱困境。 在基本操作系统 API 之上的任何软件层或多或少都会限制你使用操作系统的全部功能。比如,你或许发现采用 Visual Basic 来编写你的应用程序非常理想,但是就有那么一两项非常基本的功能 Visual Basic 无法支持。往往这个时候你得非要调用基本 API 。作为直接使用操作系统 API 的程序员,我们的活动空间完全由 API 来规范,再没有什么其他方式比直接调用 API 更有效、更灵活多样了。” 总结起来,C/C++ 语言的开发核心建立在直接调用操作系统 API 的基础上,优点是执行效率高、发挥空间大;缺点是,需要经过系统深入的学习,学习周期长,编写代码较复杂,容易出错。 Linux C++ 与 Windows C++ 领域之争 我之所以把这个标题单独列出来,是想纠正现在很多 C/C++ 新人和初学者一些不当的认识,一般有以下几种观点: Linux C++ 开发就是后台开发,而 Windows C++ 开发就是客户端开发; 后端开发比客户端开发(前端)高级,因此后端开发行业薪资水平比客户端开发薪资要高; 我只学 Linux,不学 Windows。 我相信对于 80 和 90 的这一代开发者来说,当初接触计算机并进入软件行业,都是从接触 Windows 开始的。时至今日,大数据、人工智能等各种新技术方兴未艾,移动互联网如火如荼。无论是 Linux 还是 Windows,尤其是 Windows,仍然是我们大多数人工作、学习、娱乐使用最多的操作系统——我们每天都会使用其上的各种软件。使用这些软件像喝水、呼吸空气一样自然,所以很多人就忽视了这类软件的 “基础作用”。 Windows 上的软件开发发展了很多年了,这些领域也比较成熟,一般不再招初中级开发,而是需要水平较高、经验较丰富的高级开发者。这让很多人造成了“Windows C++”开发市场需求已经很小了的错觉。试问,QQ PC 部门这些年对外招了多少人? Linux C++ 和 Windows C++ 一样,没有孰高孰低之分,只是两种不同的操作系统而已,不要觉得在 Linux 下敲命令就比在 Windows 的图形化界面点击鼠标达高级。 图形化界面之于命令行,是人们对更高级、更方便的工具追求的必然结果。Linux C++ 也不一定就是后台开发,Windows C++ 也不一定就是客户端开发;所谓的服务器与客户端是个相对的概念,即谁给谁提供服务,提供服务的我们认为是服务端(后台),被服务的我们认为是客户端(前台)。而 Windows 作为后台服务的应用也比比皆是,如笔者之前所在的某交易所的服务器后台都是 Windows 下的 C++ 程序;另外如一些游戏类的服务器端,也不少是 Windows 的。 借用《UNIX 编程艺术》这本书的观点,Windows 和 Linux 的哲学理念不一样,Windows 是假设你不会操作,它教你如何操作,而 Linux 是假设你会操作然后进行操作。根据这个理念,Windows 一般是普通人用的多,而 Linux 是程序员用的多。 从编程的角度来说,Windows 的代码风格是所谓的匈牙利命名法,而 Linux 是短小精悍的连字符风格。例如同一个表示屏幕尺寸的整型变量,Windows 上可能被命名为 iScreen 或 cxScreen ,而 Linux 可能是 screen;再例如 Windows 上创建线程的函数叫 CreateThread,Linux 下叫 pthread_create。有时候,我觉得 Windows 的匈牙利命名法反而更容易理解代码。 这里既然提到前端(客户端)开发和后端开发,这里不得不提一下,这二者没有优劣之分。其侧重点和开发思维是不一样的,前端(客户端)开发一般有较多的界面逻辑,它们是直接与用户打交道,因而一款客户端软件的好坏很大程度上取决于其界面的易用性和流畅性,开发者只要把这一端的“一亩三分地”给管理好即可;而后端服务,对于普通用户是透明的,开发者的程序必须尽量体现“服务”这个字眼,即更有效地为更多的客户端服务,这就要求兼顾请求响应的正确性、及时性和流畅性。 由于服务软件也是运行在某台物理机器上的程序,鉴于 CPU、内存、网络带宽资源有限,而服务程序一般是长周期运行的,因此必须合理地分配和使用资源(如尽量回收不再使用的各种资源)。开发者应从全局考虑,不能在某个“客户端”这一棵树上“吊死”。 从个人的职业发展来看,建议从事客户端开发的人员适当地了解一下服务器开发的思路,反过来也建议从事后端开发的人员去学习一下客户端开发,二者相得益彰。从个人的技术提高来说,也是很有帮助的。 例如您要学习一套开源的软件代码,如果您熟悉客户端和服务器的基本开发和调试技巧,您可以更好地学习它。而在工作上,一个项目,往往是由客户端和服务器程序组成,如果您都熟悉,您可以站在一个更高的角度去审视它、规划它,这也是架构师的基本要求之一。 最后就是很多读者关心的客户端和服务器的薪资问题,这个没有绝对的谁高谁低,因人而异,因能力而异,因岗位而异。 如何看待 C++ 11/14/17 新标准 C++ 开发者有个不成文的规定:即使您对 C++ 很熟悉,也不要在简历上写上您精通 C++,原因很简单—— C++ 这门语言包含的东西实在太多了,没有人能真正“精通”所有。 C++ 既支持面向对象设计(OOP),也支持以模板语法为代表的泛型编程(GP)。而且新的 C++ 标准和遵循 C++ 新标准的编译器也层出不穷,这些年,C++ 变化越来越大、越来越快,从最初业界和开发者翘首以盼的 C++11 标准,历经 C++14、C++17 到今天的 C++20,这门语言与之前的版本差别越来越大,更多原来需要使用第三库的功能也被陆续添加到 C++ 标准库中。以致于 C++ 之父 Bjarne Stroustrup 也开始对这门语言表示担忧: “C++11 开始的基础建设尚未完成,而 C++17 在使基础更加稳固、规范性和完整性方面,基本没有做出改善。相反地,却增加了重要接口的复杂度,让人们需要学习的特性数量越来越多。C++ 可能在这种不成熟提议的重压之下崩溃,我们不应该花费大量的时间为专家级用户们(比如我们自己)去创建越来越复杂的东西。(还要考虑普通用户的学习曲线,越复杂的东西越不易普及。)” 文章参看这里:https://zhuanlan.zhihu.com/p/48793948,在 Bjarne Stroustrup 的信中,他担心 C++ 会像历史的瓦萨号军舰一样,某天新的标准刚启航(发布)便立即沉没。 当然,我们不用有这种担忧,毕竟我们既不是 C++ 标准委员会成员,也不是 C++ 编译器开发厂商。就我个人经验来说,对于C++11、C++14、C++17 乃至 C++20,我们学习它们的准则应该是以实用为主,也就是说我们应该学习其实用的部分,至于新标准提到的一些高级特性和各种复杂的模板,我们大可不必去了解。我们并不是做学术研究,我们学习 C++ 是为了投入实际的生产开发,所以应该去学习 C++ 新标准中实用的语法和工具库。关于 C++11 常用一些知识点,这里也简单地给读者列举一下。 auto 关键字 for-each 循环 右值及移动构造函数 + std::forward + std::move + stl 容器新增的 emplace_back() 方法 std::thread 库、std::chrono 库 智能指针系列(std::shared_ptr/std::unique_ptr/std::weak_ptr),智能指针的实现原理一定要知道,最好是自己实现过 线程库 std::thread + 线程同步技术库 std::mutex/std::condition_variable/std::lock_guard 等 Lamda 表达式(Java 中现在也常常考察 Lamda 表达式的作用) std::bind/std::function 库 其他的就是一些关键字的用法(override、final、delete),还有就是一些细节如可以像 Java 一样在类成员变量定义处给出初始化值。 C++ 语言基础与进阶 基础 这里说的基础不是狭义上的 C++ 语言基础,而是包括 C++ 开发这一生态体系的基础,笔者认为的基础包括: C++ 语言本身熟练使用程度。 前面也介绍了单纯的 C++ 您啥也干不了,您必须结合一个具体的操作系统平台,所以得熟悉某个操作系统平台的 API 函数,比如Linux,以及该操作系统的原理。这里说的操作系统的原理不局限于您在操作系统原理图书上看的知识,而是实实在在与系统 API 关联起来的,如熟练使用各种进程与线程函数、多线程资源同步函数、文件操作函数、系统时间函数、窗口自绘与操作函数(这点针对 Windows)、内存分配与管理函数、PE 或 ELF 文件的编译、链接原理等等。 网络通信,网络通信在这里具体一点就是 Socket 编程。这里的 Socket 编程不仅要求熟练使用各种网络 API 函数,还要求理解和灵活运用像三次握手四次挥手等各种基础网络通信协议与原理。关于 Socket 编程实践,《TCP/IP 网络编程》这本书是非常好的入门教材。 说了这么多,您可能会觉得很抽象。笔者在这里举个具体例子。假设我们现在要开发一个类似电驴这样的软件,软件界面如下图: 如上图所示,假设操作系统选择 Windows,使用语言使用 C++,这就要求您必须熟悉 C++ 常用的语法,如果还不熟悉,就需要补充这方面的知识。 电驴的源码可以在公众号【 高性能服务器开发 】后台回复“获取电驴源码”即可获取。 在熟悉 C++ 语法的前提下,从这款产品实现技术来看,我们的目标产品分为 UI 和网络通信部分。下面将详细介绍这两部分。 UI 部分 对于 UI 部分,我们的认识是,这里需要使用 Windows 的窗口技术。可以直接使用原生的 Win 32 API 来制作自己的界面库,也可以选择一些熟悉的界面框架,如 MFC、WTL、Duilib、wxWidgets 等。无论您是在阅读别人的项目还是需要自己开发这样的项目,在确定了这款软件使用的 UI 库(或者使用原生 Win 32 API),您就需要对 Windows 的窗口、对话框、消息产生、派发与处理机制进行了解。同样的道理,如果不熟悉您需要补充相关的知识(关于这一点,下文不再赘述)。 接着,根据上图中的软件功能,大致分为三大模块,即资源、下载和分享。这三大块是可以使用一个 Windows Tab 控件去组织,这个时候您需要了解 Windows Tab 控件的特性。 对于资源模块,本质上是一个窗口中嵌入了一个浏览器控件(WebBrowser 控件),那么您需要了解这一个功能点的相关知识。当用户点击了某个列表中某个具体的资源,可以对其进行下载。这就又涉及到 WebBrowser 控件与 C++ 宿主程序的交互了,那么如何实现呢?可以选择使用 ActiveX 技术,也可以使用 JavaScript 与 C++ 交互技术。 再来看下载模块,当产生一个下载操作时,界面上会产生以下下载列表,每个列表项会实时显示下载进度、下载速率等参数,同时正在下载的项目也可以被暂停、停止和删除。那么这又涉及到 ListView 控件的相关功能,以及 ListView 如何与后台网络通信的逻辑交互。 分享模块是将本地资源分享到服务器或者给其他用户。界面左侧是文件系统的一个快照,那么这又涉及到如何遍历文件系统(了解枚举文件系统的 API),右侧也是一个 ListView 控件,这里不再赘述。 网络通信部分 网络通信部分,主要有两大块,第一个是程序启动时,与服务端的交互;第二个就是文件下载与分享的 P2P 网络。您在阅读或开发的过程中,如果对这些技术比较陌生,您需要补充这些知识,具体的就是 Socket 的各种 API 函数,以及基于这些 API 逻辑的组合。当然可能也会用到操作系统平台所特有的网络 API 函数,如 WSAAsyncSelect 网络模型。 另外一点,网络通信部分如何与 UI 部分进行数据交换,是使用队列?全局变量?或者相应的 Windows 操作平台提供的特殊通信技术,如 PostMessage 函数、管道?如果使用队列,多线程之间如何保持资源的一致性和解决资源竞态,使用 Event、CriticalSection、Mutex、Semaphore 等? 当然,笔者这里只列举了这个软件的主干部分,还有许多方方面面的细节需要考虑。这就需要读者根据自己的情况,斟酌和筛选了。您想达到什么目的,就要去学习和研究相关的代码。 总结起来,可以得到如下公式: 一款 C++ 软件 = C++ 语法 + 操作系统 API 函数调用 进阶 如果您达到了我上面说的三点后,可以再找一些高质量的开源项目去实战一下。需要注意的是,最好找一些没有复杂业务或者您熟悉其业务的开源项目(如开源的 IM 系统)。如果你不熟悉其业务,不仅要学习其业务(软件功能),还需要再去学习它的源码,最后可能让我们迷失了最初学习这款软件的目的。 学习这些项目的同时,读者应该先确定自己的学习目的,如果您的目的是学习和借鉴这款软件的架构,那么先从整体去把握,不要一开始就迷失在细枝末节中,这类我称之为“粗读”;或者您的目的是学习开源软件在一些细节上的处理与做法,这个时候,您可以针对性地去阅读您感兴趣的模块,深入到每一行代码上。 学习开源软件存在一种风气,许多新手喜欢道听途说,一听别人说这个软件不好,那个软件存在某某瑕疵就放弃阅读它的打算了。然后到了实际开发中,因为心中没有任何已有软件开发问题的解决方案,产生挫败感,久而久之就对本来喜欢的 C/C++ 开发失去了兴趣。 学习的过程是先接触,再熟悉,再模仿,再创造。不管什么开源项目,在您心中没有任何思路或者解决方案时,您应该先接触熟悉,不断模仿,做到至少心中有一套对于某场景的解决方案,然后再来谈创新谈批判、改造别人的项目。 我个人学习一套陌生的开源项目时,总是喜欢将程序用调试器正常跑起来,然后再中断下来,统计当前的线程数目,然后通过程序入口 main 函数从主线程追踪其他工作线程是如何创建的;接着,分析和研究每个线程的用途以及线程之间交互的,这就是整体把握,接着找我感兴趣的细节去学习。 这里我以学习 Redis 为例。将 Redis 源码从官网下载下来以后,使用喜欢的代码阅读器进行管理。我这里使用的是 Visual Studio,如下图所示: 在大致了解了 Redis 有哪些代码模块以后,我们把代码拷贝到 Linux 平台,然后编译并使用 GDB 调试器跑起来。如下图所示: 然后按 CTRL+C 将 GDB 中断下来,输入 info threads 查看当前程序的所有线程: 接着挨个使用 thread + 线程编号 和 bt 命令去查看每个线程的上下文调用堆栈: 对照每个线程的上下文堆栈,搞清楚其逻辑,并结合主线程,看看每个线程是在何时启动的,端口在何时启动侦听的,等等。做完这一步,关于 redis-server 的框架也基本清楚了。 接着我们可以选择一个自己感兴趣的命令,搞清楚 redis-cli 与 redis-server 命令的交互流程。 最后,如果对 redis-server 源码中各种数据结构和细节感兴趣,我们可以进一步深入到具体的代码细节。 当然,不熟悉 GDB 的读者看笔者这段操作流程比较困难,这是正常的,说明如果想通过调试去研究 Redis 这一款开源软件,您需要去补充一点 GDB 调试的知识。这就是我上文中所说的,针对性地补缺补差。 C++ 面试 关于 C++ 面试,面试的要求到底是侧重代码量、项目经验,还是侧重操作系统、数据结构这种基础知识?我在知乎上曾经专门写过一篇文章来介绍我曾经的 C++ 面试经历和经验,有兴趣的读者可以点击这里查看:https://www.zhihu.com/question/264198516/answer/341999235。 关于 C++ 面试常见的面试题,可以参考这里:https://zhuanlan.zhihu.com/p/45668078,这篇文章问题点整理得非常详细,读者可以参考一下。 需要注意的是,不仅仅是 C++ 面试,其他语言开发面试也是一样。如果您是想进入大型互联网公司的应届生,那么您应该优先好好准备算法和数据结构知识以应对面试,这是大型互联网公司面试频率最高的考察范围。至于其他的基础知识,如操作系统原理、网络通信等(作为计算机相关专业的学生,这些应该是您的专业课),如果您已经在平时的学习中掌握得很好,那就不用担心,这类问题一般对于应届生求职不会问得太深;倘若您尚未学得扎实,而春招或秋招又时间临近,没有足够的时间去准备这些,您应该只是尽量去补,实在来不及也没关系,还是应该把重心放在好好准备算法和数据结构等知识上。 对于社会人士参加的 C++ 职位的面试,如果是大型互联网公司,虽然社会招聘问的更多的是项目经验,适当地为一些基础的算法和数据结构知识做一些准备也是非常有用的。举个例子,如果问到二分查找这一类基础算法,如果答不出来未免会让面试官印象不太好,场面也比较尴尬。另外,C++ 是一门讲究深度的编程技能,对于有一定工作年限的面试者,面试官往往会问很多原理性的细节,这就要求广大 C++ 开发者在平常多留心、多积累、多思考技术背后的原理。 对于大多数小型企业,无论是应届生还是社会人士,只要有能力胜任一定的工作即可。一般只要对所面试的公司项目有类似经验或者相关的技术能力,基本上就可以通过面试。大多数小公司在乎的是您来了能不能干活,所以这类公司对实际项目经验和技能要求更高一点。 关于项目经验,许多人可能觉得项目经验一定是自己参与的项目,其实不然,项目经验也可以来源于您阅读和学习其他人的项目代码或者开源软件,只要您能看懂并理解它们,在面试的时候提及到,能条理清晰、自圆其说即可。当然,如果不熟悉或者只是了解些皮毛,切记不可信口开河、胡编乱造甚至张冠李戴。 我曾经面试过一些开发者,看简历项目经验丰富,实际一问的时候,只是把别人的框架或者库拿来包装调用一下,问及其技术原理时,不是顾左右而言他就是说不清道不明模棱两可含糊不清,这一类人往往比不知道还让人讨厌,面试官一般反感这一类面试者所谓的项目经验。 学生与社会人士学习 C++ 方式的区别 作为学生有充裕的时间,建议除了把 C++ 语法学好,系统地多读一点基础的书籍,如操作系统原理、网络编程、数据结构与算法等相关各方各面的经典书籍。 可以参考下这里: https://mp.weixin.qq.com/s/EjgtX2Wghia7ajn2AugCtw 尽量做到等您毕业走出校园以后,至少熟悉一门编程语言和其相应的开发环境,这就是一个基础扎实、理论清晰、编码能力强的求职者。可惜的是,从现在的各种招聘反馈来看,大多数学生在求职时,对相关开发工具和语言的陌生程度实在让人瞠目结舌,面试官在面试的时候会很纳闷:这位学生大学四年(或者七年)到底是否调试过程序? 社会人士由于已经走上工作岗位,家庭、工作的琐事繁多,没有太多的时间去系统地阅读一些相关基础书籍,如果您当前工作正好是从事 C/C++ 开发,那么请结合您当前的项目来学习,搞清楚项目的体系结构、吸收项目中优秀的实现细节,针对性地补充相关知识,这是进步最快的方式。 但是实际情形中,很多人觉得公司的项目代码又烂又杂,不愿意去研究。这种思想千万不能有的,在您没有自己足够好的能力给公司提供更好的解决方案,请先学习和模仿,我们此时要保持“空杯”心态,公司的代码再烂,它也是公司的商业价值所在;即使是纯粹的业务代码,也有它的可取之处,择其善者而从之,其不善者而改之。尤其是开发者处于一些初中级的开发岗位时,可能接触不到公司核心框架的源码,此时千万不要盲目地去排斥。学业务,补基础,时刻意识清醒自己所需,明白自己想要学的东西。 如果从事的不是 C++ 相关的开发,那么可以挤出一些时间去学习一些开源的代码,在阅读开源代码的过程中,针对性地补缺补差。不建议系统地去看《C++ Primer 中文版》《UNIX 环境高级编程》诸如此类的大部头书籍,实际开发中不需要太多这类书中的细枝末节,阅读这类书往往只会事倍功半,甚至最后因书籍太厚、时间不够,最后坚持不下去,最终放弃。 当然,对于社会人士,当您有一定的时间的时候一定要去补充一些基础的、原理性的东西,千万不要沉溺于“面向搜索引擎编程”或者“面向工资编程”,有些问题虽然当时通过搜索引擎解决了,但如果想在技术或职业上有长足的发展,一定要系统地去读一些经典的、轻量级的书籍(如《C++ 对象模型》)。长期在网上的文章中寻章摘句,只会让您的知识结构碎片化、凌乱化,甚至混乱化。而且互联网上的技术文章质量良莠不齐,有时候也容易对自己形成误导和依赖。总而言之,作为技术开发人员,提高自己技术水平是改变现状、改善生活最直接的途径。 小结 关于 C/C++,暂且就讨论这么多。最后再强调一遍,C++ 是一门讲究深度的语言,其“深度”不是体现在会多少 C++ 语法,而是能够洞察您所写的 C++ 代码背后的系统原理,这是需要长期不断的积累的,没有速成之法。反过来一旦学成,可以快速地学习其他语言和框架。个人觉得,如果自主创业或者想在二三线城市长期发展的读者,C/C++ 应该是优选语言,有了它作为基础,您可以跳出依赖各种环境和框架的窠臼,快速地学习和开发您想要的软件,完成您想要的业务产品。 最后,限于笔者经验水平有限,欢迎读者就文中的观点提出宝贵的建议和意见。如果想获得更多的学习资源或者想与我做进一步交流,可以加我微信 easy_coder 一起交流。 文中提到的电驴的源码可以在公众号【 高性能服务器开发 】后台回复“获取电驴源码”即可获取。 如需下载本站全部技术文章,可以在【高性能服务器开发】公众号回复关键字“文章下载”即可。最近更新时间: 2020-10-09 23:27:50 "},"articles/C++必知必会的知识点/不定参数函数实现var_arg系列的宏.html":{"url":"articles/C++必知必会的知识点/不定参数函数实现var_arg系列的宏.html","title":"不定参数函数实现var_arg系列的宏","keywords":"","body":"不定参数函数实现var_arg系列的宏 电驴的源码日志模块有一个叫 DebugLogError 函数,其签名如下: //代码位于easyMule-master/src/WorkLayer/Log.h 55行 void DebugLogError(LPCTSTR pszLine, ...); 电驴的源码可以在公众号【 高性能服务器开发 】后台回复“获取电驴源码”即可获取。 这个函数的申明在 Log.h 头文件中,是一个全局函数,其实现代码在 Log.cpp 文件中: //代码位于easyMule-master/src/WorkLayer/Log.cpp 111行 void DebugLogError(LPCTSTR pszFmt, ...) { va_list argp; va_start(argp, pszFmt); LogV(LOG_DEBUG | LOG_ERROR, pszFmt, argp); va_end(argp); } 这个函数是一个具有不定参数的函数(也就是参数个数不确定),比如调用这个函数我们可以传入一个参数,也可以传入二个或者三个参数等等: DebugLogError(L\"我喜欢你!\"); DebugLogError(L\"我喜欢你!\", L\"你喜欢谁?\"); DebugLogError(L\"我喜欢你!\", L\"你喜欢谁?\", L\"萧雨萌!\"); 与此类似, C 语言中最熟悉的函数 printf() 和 scanf() 就是能传入不定参数的函数的例子,可是你知道如何编写这样具有不定参数的函数么? 你可以通过这段代码学习到编写方法,奥秘就在DebugLogError()中使用的几个你从来没见过的宏,让我们欢迎它们: va_list va_start va_end 这几个宏是C函数库提供的,位于头文件stdarg.h中。下面我们利用这几个宏自定义一个ShowLove()函数: #include #include #include #include int ShowLove(wchar_t* szFirstSentence, ...) { //用来统计可变参数数量 int num = 0; //第一步: //申明一个va_list类型对象ap,用于对参数进行遍历 va_list ap; //第二步: //使用va_start对变量进行初始化 //这里需要注意的是: //在传统C语言中,va_start把ap中内部指针设置为传递给函数参数的【第一个实参】; //而在标准的C中,va_start接受一个额外参数,也就是最后一个【固定参数】的名称, //并把ap中的内部指针设置为传递给函数的第一个【可变参数】. //所以你在VC++ 6.0和VS2008等高版本的编译器中使用va_start需要注意区别 //这里使用标准C va_start(ap, szFirstSentence); //第三步: //使用va_arg宏返回实参列表中的下一个参数值,并把ap的内部指针推向下一个参数(如果有的话) //必须指定下一个参数的类型。 //在调用va_start之后第一次调用va_arg将返回第一个可变参数的值 wprintf(szFirstSentence); wchar_t* p = 0; while(p = va_arg(ap, wchar_t*)) { wprintf(L\"%s\", p); num ++; } //第四步: //待所有可变参数都读取完毕以后,调用va_end宏对ap和va_list做必要的清理工作 va_end(ap); return num; } int main(int argc, char* argv[]) { setlocale(LC_ALL, \"\"); int z = ShowLoveL\"我喜欢你!\\n\"); int y = ShowLove(L\"我喜欢你!\", L\"你喜欢谁?\\n\"); int l = ShowLove(L\"我喜欢你!\", L\"你喜欢谁?\", L\"萧雨萌!\\n\"); printf(\"可变参数个数依次是:%d\\t%d\\t%d\\n\", z, y, l); return 0; } 上述代码的运行结果是: 这里顺便补充下,va 的是英文 varied arguments (可变参数)的意思。关于 va_list 等宏的实现原理其实也很容易搞明白,这里不再讲解了。 我们现在来看看函数 DebugLogError(): void DebugLogError(LPCTSTR pszFmt, ...) { va_list argp; va_start(argp, pszFmt); LogV(LOG_DEBUG | LOG_ERROR, pszFmt, argp); va_end(argp); } 其他的没什么,就是调用了一个函数叫 LogV(),LogV() 的的声明位于 Log.h 文件中,也是一个全局函数: void LogV(UINT uFlags, LPCTSTR pszFmt, va_list argp); 其实现代码位于 Log.cpp 文件中: void LogV(UINT uFlags, LPCTSTR pszFmt, va_list argp) { AddLogTextV(uFlags, DLP_DEFAULT, pszFmt, argp); } 这里又调用了一个函数 AddLogTextV(),这个函数的也声明在 Log.h 中: void AddLogTextV(UINT uFlags, EDebugLogPriority dlpPriority, LPCTSTR pszLine, va_list argptr); 其实现代码在 Log.cpp 文件中: void AddLogTextV(UINT uFlags, EDebugLogPriority dlpPriority, LPCTSTR pszLine, va_list argptr) { ASSERT(pszLine != NULL); if ((uFlags & LOG_DEBUG) && !thePrefs.GetVerbose() && dlpPriority >= thePrefs.GetVerboseLogPriority()) return; //Xman Anti-Leecher-Log if ((uFlags & LOG_LEECHER) && !thePrefs.GetAntiLeecherLog()) return; //Xman end TCHAR szLogLine[1000]; if (_vsntprintf(szLogLine, ARRSIZE(szLogLine), pszLine, argptr) == -1) szLogLine[ARRSIZE(szLogLine) - 1] = _T('\\0'); if(CGlobalVariable::m_hListenWnd) UINotify(WM_ADD_LOGTEXT, uFlags, (LPARAM)new CString(szLogLine)); // Comment UI /*if (theApp.emuledlg) theApp.emuledlg->AddLogText(uFlags, szLogLine); else*/ /*if(SendMessage(CGlobalVariable::m_hListenWnd, WM_ADD_LOGTEXT, uFlags, (LPARAM)szLogLine)==0)*/ else { TRACE(_T(\"App Log: %s\\n\"), szLogLine); TCHAR szFullLogLine[1060]; int iLen = _sntprintf(szFullLogLine, ARRSIZE(szFullLogLine), _T(\"%s: %s\\r\\n\"), CTime::GetCurrentTime().Format(thePrefs.GetDateTimeFormat4Log()), szLogLine); if (iLen >= 0) { //Xman Anti-Leecher-Log //Xman Code Improvement if (!((uFlags & LOG_DEBUG) || (uFlags & LOG_LEECHER))) { if (thePrefs.GetLog2Disk()) theLog.Log(szFullLogLine, iLen); } else if (thePrefs.GetVerbose()) // && ((uFlags & LOG_DEBUG) || thePrefs.GetFullVerbose())) { if (thePrefs.GetDebug2Disk()) theVerboseLog.Log(szFullLogLine, iLen); } //Xman end } } } 我们从源头函数调用来理下思路: 首先用下列参数调用 DebugLogError(): DebugLogError(L\"Unable to load shell32.dll to retrieve the systemfolder locations, using fallbacks\"); 然后在上述函数内部又调用: LogV(LOG_DEBUG | LOG_ERROR, L\"Unable to load shell32.dll to retrieve the systemfolder locations, using fallbacks\", argp); 其中,argp 是函数 DebugLogError() 的内部变量,而 LOG_DEBUG 和 LOG_ERROR 是 Log.h 中定义几个宏,其类型为整形: // Log message type enumeration #define LOG_INFO 0 #define LOG_WARNING 1 #define LOG_ERROR 2 #define LOG_SUCCESS 3 #define LOGMSGTYPEMASK 0x03 // Log message targets flags #define LOG_DEFAULT 0x00 #define LOG_DEBUG 0x10 #define LOG_STATUSBAR 0x20 #define LOG_DONTNOTIFY 0x40 #define LOG_LEECHER 0x80 //Xman Anti-Leecher-Log 最后调用: AddLogTextV(LOG_DEBUG | LOG_ERROR, DLP_DEFAULT, L\"Unable to load shell32.dll to retrieve the systemfolder locations, using fallbacks\", argp); 这个函数的第二个参数类型是一个定义在 Log.h 中的枚举变量 EDebugLogPriority,代表调试的记录级别,其取值如下: enum EDebugLogPriority{ DLP_VERYLOW = 0, DLP_LOW, DLP_DEFAULT, DLP_HIGH, DLP_VERYHIGH }; 这里提醒一点,由于枚举量 DLP_VERYLOW = 0,所以后面的 DLP_LOW、 DLP_DEFAULT、 DLP_HIGH、 DLP_VERYHIGH 就依次等于1、2、3、4,这是C语言规定的,C语言规定枚举量如果不赋初值,根据前面一个量的值依次递增。 我们来实际看看AddTextLogText()函数的实现代码: void AddLogTextV(UINT uFlags, EDebugLogPriority dlpPriority, LPCTSTR pszLine, va_list argptr) { ASSERT(pszLine != NULL); if ((uFlags & LOG_DEBUG) && !thePrefs.GetVerbose() && dlpPriority >= thePrefs.GetVerboseLogPriority()) return; //Xman Anti-Leecher-Log if ((uFlags & LOG_LEECHER) && !thePrefs.GetAntiLeecherLog()) return; //Xman end 首先是一个ASSERT断言,这个断言要求 pszLine (函数第三个参数)不能为空。 接着如果同时满足下列两个条件,则函数返回: 条件1:表达式 ((uFlags & LOG_DEBUG) || (uFlags & LOG_LEECHER)) 为真; 条件2:表达式 !(thePrefs.GetVerbose() && dlpPriority >= thePrefs.GetVerboseLogPriority()) 为真。 我们先看条件1,很多年以前,我对这种按位或运算(|)和按位与运算(&)来组合这些程序中的标志的原理一头雾水,虽然那个时候,我知道这些运算符的含义。 现在就以这两个为例吧: 按位或运算,就是把两个数在二进制层面上按位或,比如二进制数: 11 | 10 = 11 第一个数字高位上 1 与第二个数字高位上的 1 来进行或运算,等于 1,放在高位; 第一个数字低位上 1 与第二个数字低位上的 0 来进行或运算,等于 1,放在低位。 同理,与运算: 11 & 10 = 10 按位与,要求两个数字都是 1 才是 1;而按位或只要有一个是 1 就等于 1,除非两者都是 0,则为 0。 看个复杂的: 11001100 & 10101010 = 10001000 这种做法有个两个好处: 第一,可以将某个位置的上的数字来代表当前的状态,比如电路中 1 代表开,0 代表关。那么我用下面数字 a = 10001000 表示电路开关状态,你会发现电路是开的。 再比如,颜色值 RGB 表示法:CD1298, 我想把其中绿色值单独提取出来,怎么做? 方法: GreenValue = 0xCD1298 & 0x001200, 这样就可以做到了。 第二,因为是二进制层次上的操作,所以速度非常快。 我们现在分析下代码: (uFlags & LOG_DEBUG) || (uFlags & LOG_LEECHER) 先看第一部分: uFlags & LOG_DEBUG 再结合下面的定义: // Log message targets flags #define LOG_DEFAULT 0x00 #define LOG_DEBUG 0x10 #define LOG_STATUSBAR 0x20 #define LOG_DONTNOTIFY 0x40 #define LOG_LEECHER 0x80 //Xman Anti-Leecher-Log 这几个常量定义的数值是有讲究的,不是任何数值都行的。我们将它们都化成二进制: LOG_DEFAULT 0000 0000 LOG_DEBUG 0001 0000 LOG_STATUSBAR 0010 0000 LOG_DONTNOTIFY 0100 0000 LOG_LEECHER 1000 0000 这样假如 uFlags = 1010 0000,这样我要检测是否设置了LOG_DEBUG,我只要这样做: Result = uFlags & LOG_DEBUG 计算结果 Result => 0000 0000 => 0 这样 if(RESULT){} 中条件为假;说明我没有设置这个标志位;同理我需要检测是否设置 LOG_STATUSBAR 标志,则执行: Result = uFlags & LOG_STATUSBAR = 0001 0000 这个数字化为十进制不为 0,所以为真,因此在判断语句里面条件也为真,说明设置了这个标志位。 这是正面检测,反过来我想设置这些标识位,而且可以一次设置多个标志位,比如 uFlags = LOG_STATUSBAR | LOG_DONTNOTIFY | LOG_LEECHER = 1110 0000 是不是一目了然? 而且我也可以很方便地从设置好的标志位中去掉某个或某些标识位,比如我想从上面的uFlags值中去掉LOG_DONTNOTIFY 标识,怎么办?这样做就可以了: uFlags & (~LOG_DONTNOTIFY) 来解释下~符号是二进制层次上求反,将对应位上的 1 改为 0,0 改为 1,那么: ~ LOG_DONTNOTIFY = 1011 1111 然后与 uFlags 或起来等于 1010 0000,你看下是不是刚好把 LOG_DONTNOTIFY 去掉了呀? 这种方法效率高不仅是因为在二进制层次上运算,而且它可以用一个较小的数据类型代表多个信息,对数据的利用程度精准到二进制位。 如需下载本站全部技术文章,可以在【高性能服务器开发】公众号回复关键字“文章下载”即可。最近更新时间: 2020-10-09 23:23:00 "},"articles/C++必知必会的知识点/你一定要搞明白的C函数调用方式与栈原理.html":{"url":"articles/C++必知必会的知识点/你一定要搞明白的C函数调用方式与栈原理.html","title":"你一定要搞明白的C函数调用方式与栈原理","keywords":"","body":"你一定要搞明白的C函数调用方式与栈原理 写在前面的话 这绝对不是标题党。而是C/C++开发中你必须要掌握的基础知识,也是高级技术岗位面试中高频题。我真的真的真的希望无论是学生还是广大C/C++开发者,都该掌握此文中介绍的知识。 正文 这篇blog试图讲明当一个c函数被调用时,一个栈帧(stack frame)是如何被建立,又如何被消除的。这些细节跟操作系统平台及编译器的实现有关,下面的描述是针对运行在Intel奔腾芯片上Linux的gcc编译器而言。c语言的标准并没有描述实现的方式,所以,不同的编译器,处理器,操作系统都可能有自己的建立栈帧的方式。 一个典型的栈帧 图1是一个典型的栈帧,图中,栈顶在上,地址空间往下增长。 这是如下一个函数调用时的栈的内容: int foo(int arg1, int arg2, int arg3); 并且,foo有两个局部的int变量(4个字节)。在这个简化的场景中,main调用foo,而程序的控制仍在foo中。这里,main是调用者(caller),foo是被调用者(callee)。 ESP被foo使用来指示栈顶。EBP相当于一个“基准指针”。从main传递到foo的参数以及foo本身的局部变量都可以通过这个基准指针为参考,加上偏移量找到。 由于被调用者允许使用EAX,ECX和EDX寄存器,所以如果调用者希望保存这些寄存器的值,就必须在调用子函数之前显式地把他们保存在栈中。另一方面,如果除了上面提到的几个寄存器,被调用者还想使用别的寄存器,比如EBX,ESI和EDI,那么,被调用者就必须在栈中保存这些被额外使用的寄存器,并在调用返回前回复他们。也就是说,如果被调用者只使用约定的EAX,ECX和EDX寄存器,他们由调用者负责保存并回复,但如果被调用这还额外使用了别的寄存器,则必须有他们自己保存并回复这些寄存器的值。 传递给foo的参数被压到栈中,最后一个参数先进栈,所以第一个参数是位于栈顶的。foo中声明的局部变量以及函数执行过程中需要用到的一些临时变量也都存在栈中。 小于等于4个字节的返回值会被保存到**EAX**中,如果大于4字节,小于8字节,那么EDX也会被用来保存返回值。如果返回值占用的空间还要大,那么调用者会向被调用者传递一个额外的参数,这个额外的参数指向将要保存返回值的地址。用C语言来说,就是函数调用: x = foo(a, b, c); 被转化为: foo(&x, a, b, c); 注意,这仅仅在返回值占用大于8个字节时才发生。有的编译器不用EDX保存返回值,所以当返回值大于4个字节时,就用这种转换。 当然,并不是所有函数调用都直接赋值给一个变量,还可能是直接参与到某个表达式的计算中,如: m = foo(a, b, c) + foo(d, e, f); 有或者作为另外的函数的参数, 如: fooo(foo(a, b, c), 3); 这些情况下,foo的返回值会被保存在一个临时变量中参加后续的运算,所以,foo(a, b, c)还是可以被转化成foo(&tmp, a, b, c)。 让我们一步步地看一下在c函数调用过程中,一个栈帧是如何建立及消除的。 函数调用前调用者的动作 在我们的例子中,调用者是main,它准备调用函数foo。在函数调用前,main正在用ESP和EBP寄存器指示它自己的栈帧。 首先,main把EAX,ECX和EDX压栈。这是一个可选的步骤,只在这三个寄存器内容需要保留的时候执行此步骤。 接着,main把传递给foo的参数一一进栈,最后的参数最先进栈。例如,我们的函数调用是: a = foo(12, 15, 18); 相应的汇编语言指令是: push dword 18 push dword 15 push dword 12 最后,main用call指令调用子函数: call foo 当call指令执行的时候,EIP指令指针寄存器的内容被压入栈中。因为EIP寄存器是指向main中的下一条指令,所以现在返回地址就在栈顶了。在call指令执行完之后,下一个执行周期将从名为foo的标记处开始。 图2展示了call指令完成后栈的内容。图2及后续图中的粗线指示了函数调用前栈顶的位置。我们将会看到,当整个函数调用过程结束后,栈顶又回到了这个位置。 被调用者在函数调用后的动作 当函数foo,也就是被调用者取得程序的控制权,它必须做3件事:建立它自己的栈帧,为局部变量分配空间,最后,如果需要,保存寄存器EBX,ESI和EDI的值。 首先foo必须建立它自己的栈帧。EBP寄存器现在正指向main的栈帧中的某个位置,这个值必须被保留,因此,EBP进栈。然后ESP的内容赋值给了EBP。这使得函数的参数可以通过对EBP附加一个偏移量得到,而栈寄存器ESP便可以空出来做其他事情。如此一来,几乎所有的c函数都由如下两个指令开始: push ebp mov ebp, esp 此时的栈入图3所示。在这个场景中,第一个参数的地址是EBP加8,因为main的EBP和返回地址各在栈中占了4个字节。 ​ 下一步,foo必须为它的局部变量分配空间,同时,也必须为它可能用到的一些临时变量分配空间。比如,foo中的一些C语句可能包括复杂的表达式,其子表达式的中间值就必须得有地方存放。这些存放中间值的地方同城被称为临时的,因为他们可以为下一个复杂表达式所复用。为说明方便,我们假设我们的foo中有两个int类型(每个4字节)的局部变量,需要额外的12字节的临时存储空间。简单地把栈指针减去20便为这20个字节分配了空间: sub esp, 20 现在,局部变量和临时存储都可以通过基准指针EBP加偏移量找到了。 最后,如果foo用到EBX,ESI和EDI寄存器,则它f必须在栈里保存它们。结果,现在的栈如图4所示。 ​ foo的函数体现在可以执行了。这其中也许有进栈、出栈的动作,栈指针ESP也会上下移动,但EBP是保持不变的。这意味着我们可以一直用[EBP+8]找到第一个参数,而不管在函数中有多少进出栈的动作。 函数foo的执行也许还会调用别的函数,甚至递归地调用foo本身。然而,只要EBP寄存器在这些子调用返回时被恢复,就可以继续用EBP加上偏移量的方式访问实际参数,局部变量和临时存储。 被调用者返回前的动作 在把程序控制权返还给调用者前,被调用者foo必须先把返回值保存在EAX寄存器中。我们前面已经讨论过,当返回值占用多于4个或8个字节时,接收返回值的变量地址会作为一个额外的指针参数被传到函数中,而函数本身就不需要返回值了。这种情况下,被调用者直接通过内存拷贝把返回值直接拷贝到接收地址,从而省去了一次通过栈的中转拷贝。 其次,foo必须恢复EBX,ESI和EDI寄存器的值。如果这些寄存器被修改,正如我们前面所说,我们会在foo执行开始时把它们的原始值压入栈中。如果ESP寄存器指向如图4所示的正确位置,寄存器的原始值就可以出栈并恢复。可见,在foo函数的执行过程中正确地跟踪ESP是多么的重要————也就是说,进栈和出栈操作的次数必须保持平衡。 这两步之后,我们不再需要foo的局部变量和临时存储了,我们可以通过下面的指令消除栈帧: mov esp, ebp pop ebp 其结果就是现在栈里的内容跟图2中所示的栈完全一样。现在可以执行返回指令了。从栈里弹出返回地址,赋值给EIP寄存器。栈如图5所示: i386指令集有一条“leave”指令,它与上面提到的mov和pop指令所作的动作完全相同。所以,C函数通常以这样的指令结束: leave ret 调用者在返回后的动作 在程序控制权返回到调用者(也就是我们例子中的main)后,栈如图5所示。这时,传递给foo的参数通常已经不需要了。我们可以把3个参数一起弹出栈,这可以通过把栈指针加12(=3个4字节)实现: add esp, 12 如果在函数调用前,EAX,ECX和EDX寄存器的值被保存在栈中,调用者main函数现在可以把它们弹出。这个动作之后,栈顶就回到了我们开始整个函数调用过程前的位置,也就是图5中粗线的位置。 看个具体的实例: 这段代码反汇编后,代码是什么呢? #include long test(int a, int b) { a = a + 3; b = b + 5; return a + b; } int main(int argc, char* argv[]) { printf(\"%d\", test(10,90)); return 0; } 先来看一个概貌: 9: int main(int argc, char* argv[]) 10: { 00401070 push ebp 00401071 mov ebp,esp 00401073 sub esp,40h 00401076 push ebx 00401077 push esi 00401078 push edi 00401079 lea edi,[ebp-40h] 0040107C mov ecx,10h 00401081 mov eax,0CCCCCCCCh 00401086 rep stos dword ptr [edi] 11: printf(\"%d\",test(10,90)); 00401088 push 5Ah 0040108A push 0Ah 0040108C call @ILT+0(test) (00401005) 00401091 add esp,8 00401094 push eax 00401095 push offset string \"%d\" (0042201c) 0040109A call printf (004010d0) 0040109F add esp,8 12: return 0; 004010A2 xor eax,eax 13: } 下面来解释一下, 开始进入Main函数 esp=0x12FF84 ebp=0x12FFC0 完成椭圆形框起来的部分: 00401070 push ebp ebp的值入栈,保存现场(调用现场,从test函数看,如红线所示,即保存的0x12FF80用于从test函数堆栈返回到main函数): 00401071 mov ebp,esp 此时ebp=0x12FF80 此时ebp就是“当前函数堆栈”的基址 以便访问堆栈中的信息;还有就是从当前函数栈顶返回到栈底: 00401073 sub esp,40h 函数使用的堆栈,默认64个字节,堆栈上就是16个横条(密集线部分)此时esp=0x12FF40。 在上图中,上面密集线是test函数堆栈空间,下面是Main的堆栈空间(补充,其实这个就叫做 Stack Frame): 00401076 push ebx 00401077 push esi 00401078 push edi 入栈 00401079 lea edi,[ebp-40h] 0040107C mov ecx,10h 00401081 mov eax,0CCCCCCCCh 00401086 rep stos dword ptr [edi] 初始化用于该函数的栈空间为0XCCCCCCCC,即从0x12FF40~0x12FF80所有的值均为0xCCCCCCCC: 11: printf(\"%d\",test(10,90)); 00401088 push 5Ah 参数入栈 从右至左 先90 后10 0040108A push 0Ah 0040108C call @ILT+0(test) (00401005) 函数调用,转向eip 00401005 。 注意,此时仍入栈,入栈的是call test 指令下一条指令的地址00401091下一条指令是add esp,8。 @ILT+0(?test@@YAJHH@Z): 00401005 jmp test (00401020) 即转向被调函数test: 2: long test(int a,int b) 3: { 00401020 push ebp 00401021 mov ebp,esp 00401023 sub esp,40h 00401026 push ebx 00401027 push esi 00401028 push edi 00401029 lea edi,[ebp-40h] 0040102C mov ecx,10h 00401031 mov eax,0CCCCCCCCh 00401036 rep stos dword ptr [edi] //这些和上面一样 4: a = a + 3; 00401038 mov eax,dword ptr [ebp+8] //ebp=0x12FF24 加8 [0x12FF30]即取到了参数10 0040103B add eax,3 0040103E mov dword ptr [ebp+8],eax 5: b = b + 5; 00401041 mov ecx,dword ptr [ebp+0Ch] 00401044 add ecx,5 00401047 mov dword ptr [ebp+0Ch],ecx 6: return a + b; 0040104A mov eax,dword ptr [ebp+8] 0040104D add eax,dword ptr [ebp+0Ch] //最后的结果保存在eax, 结果得以返回 7: } 00401050 pop edi 00401051 pop esi 00401052 pop ebx 00401053 mov esp,ebp //esp指向0x12FF24, test函数的堆栈空间被放弃,从当前函数栈顶返回到栈底 00401055 pop ebp //此时ebp=0x12FF80, 恢复现场 esp=0x12FF28 00401056 ret //ret负责栈顶0x12FF28之值00401091弹出到指令寄存器中,esp=0x12FF30 因为win32汇编一般用eax返回结果 所以如果最终结果不是在eax里面的话 还要把它放到eax。 注意,从被调函数返回时,是弹出EBP,恢复堆栈到函数调用前的地址,弹出返回地址到EIP以继续执行程序。 从test函数返回,执行: 00401091 add esp,8 清栈,清除两个压栈的参数10 90 调用者main负责。 (所谓__cdecl调用由调用者负责恢复栈,调用者负责清理的只是入栈的参数,test函数自己的堆栈空间自己返回时自己已经清除,靠!一直理解错) 00401094 push eax //入栈,计算结果108入栈,即printf函数的参数之一入栈 00401095 push offset string \"%d\" (0042201c)//入栈,参数 \"%d\" 当然其实是%d的地址 0040109A call printf (004010d0)//函数调用 printf(\"%d\",108) 因为printf函数时 0040109F add esp,8 //清栈,清除参数 (\"%d\", 108) 19: return 0; 004010A2 xor eax,eax //eax清零 20: } main函数执行完毕 此时esp=0x12FF34 ebp=0x12FF80: 004010A4 pop edi 004010A5 pop esi 004010A6 pop ebx 004010A7 add esp,40h //为啥不用mov esp, ebp? 是为了下面的比较 004010AA cmp ebp,esp //比较,若不同则调用chkesp抛出异常 004010AC call __chkesp (00401150) 004010B1 mov esp,ebp 004010B3 pop ebp //ESP=0X12FF84 EBP=0x12FFC0 尘归尘 土归土 一切都恢复最初的平静了 :) 004010B4 ret 另: 如果函数调用方式是__stdcall不同之处在于main函数call 后面没有了add esp, 8;test函数最后一句是 ret 8 (由test函数清栈, ret 8意思是执行ret后,esp+8)。 运行过程中0x12FF28 保存了指令地址 00401091是怎么保存的?栈每个空间保存4个字节(粒度4字节) 例如下一个栈空间0x12FF2C保存参数10,因此: 0x12FF28 0x12FF29 0x12FF2A 0x12FF2B 91 10 40 00 little-endian 认为其读的第一个字节为最小的那位上的数。 char a[] = \"abcde\" 对局部字符数组变量(栈变量)赋值,是利用寄存器从全局数据内存区把字符串“abcde”拷贝到栈内存中的。 int szNum[5] = { 1, 2, 3, 4, 5 }; 栈中是如何分布的? 00401798 mov dword ptr [ebp-14h],1 0040179F mov dword ptr [ebp-10h],2 004017A6 mov dword ptr [ebp-0Ch],3 004017AD mov dword ptr [ebp-8],4 004017B4 mov dword ptr [ebp-4],5 可以看出来是从右边开始入栈,所以是 5 4 3 2 1 入栈, int *ptrA = (int*)(&szNum+1); int *ptrB = (int*)((int)szNum + 1); std::cout结果如何? 28: int *ptrA = (int*)(&szNum+1); 004017BB lea eax,[ebp] 004017BE mov dword ptr [ebp-18h],eax &szNum是指向数组指针;加1是加一个数组宽度;&szNum+1指向移动5个int单位之后的那个地方, 就是把EBP的地址赋给指针; ptrA[-1]是回退一个int*宽度,即ebp-4; 29: int *ptrB = (int*)((int)szNum + 1); 004017C1 lea ecx,[ebp-13h] 004017C4 mov dword ptr [ebp-1Ch],ecx 如果上面是指针算术,那这里就是地址算术,只是首地址+1个字节的offset,即ebp-13h给指针。实际保存是这样的: 01 00 00 00 02 00 00 00 ebp-14h ebp-13h ebp-10h 注意,是int*类型的,最后获得的是 00 00 00 02,由于Little-endian, 实际上逻辑数是02000000,转换为十进制数就为33554432,最后输出533554432。 如需下载本站全部技术文章,可以在【高性能服务器开发】公众号回复关键字“文章下载”即可。最近更新时间: 2020-09-15 20:41:14 "},"articles/C++必知必会的知识点/深入理解CC++中的指针.html":{"url":"articles/C++必知必会的知识点/深入理解CC++中的指针.html","title":"深入理解C/C++中的指针","keywords":"","body":"深入理解C/C++中的指针 C和C++中最强大的功能莫过于指针了(pointer),但是对于大多数人尤其是新手来说,指针是一个最容易出错、也最难掌握的概念了。本文将从指针的方方面面来讲述指针的概念和用法,希望对大家有所帮助。 内存模型 为了更好地理解指针,让我们来看一下计算机的内存模型。 内存分为物理内存和虚拟内存,物理内存对应计算机中的内存条,虚拟内存是操作系统内存管理系统假象出来的。由于这些不是我们本文的重点,下面不做区分。有不清楚这些概念的同学,可以给我留言或者在线询问。 在不考虑cpu缓存的情况下,计算机运行程序本质上就是对内存中的数据的操作,通俗地来说,就是将内存条某些部分的数据搬进搬出或者搬来搬去,其中“搬进搬出”是指将内存中的二进制数据搬入cpu寄存器及运算器中进行相应的加减运算或者将寄存器中的数据搬回内存单元中,而“搬来搬去”是指将内存中的数据由这个位置搬到另外一个位置(当然,一般不是直接搬,而是借助寄存器作为中间存储区)。如下图所示: 计算机为了方便管理内存,将内存的每个单元用一个数字编号,如下图所以: 图中所示,是一个大小为128个字节的内存空间,其中每一个空格代表一个字节,所以内存编号是0~127。 对于一个32位的操作系统来说,内存空间中每一个字节的编号是一个32位二进制数,所以内存编号从0000 0000 0000 0000 0000 0000 0000 0000至1111 1111 1111 1111 1111 1111 1111 1111,转换成16进制也就是0x00000000至0xFFFFFFFF,由于是从0开始的,所以化成10机制就是从0至2的32次方减1;对于64位操作系统,内存编号也就是从64个0至64个1。 大家需要注意的是,从上面两个图我们可以发现,我们一般将编号小的内存单元画在上面,编号大的画在下面,也就是说从上至下,内存编号越来越大。 指针与指针变量 指针的本意就是内存地址,我们可以通俗地理解成内存编号,既然计算机通过编号来操作内存单元,这就造就了指针的高效率。 那么什么是指针变量呢?指针变量可通俗地理解成存储指针的变量,也就是存储内存地址(内存编号)的变量。首先指针变量和整型变量、字符型变量以及其他数据类型的变量一样都是变量类型;但是,反过来,我们不应该按这样的方式来分类,即:整型指针变量、字符型指针变量、浮点型指针变量等等。为什么不推荐这样的分类方法呢?首先,指针变量就是一个数据类型,指针数据类型,这种数据类型首先是一个变量数据类型,那么它的大小是多少呢?很多同学理所当然地认为整型指针变量和一个字符指针变量的大小是不一样的,这种认识是错的。指针变量也是一个变量,它是一个用来存储其他变量的内存地址的,更准确地说,指针变量时用来存储其他变量的内存首地址的,因为不同的数据类型所占的内存大小不一样。举个例子,在32位机器上,假如a是int型变量,pa是指向a的指针变量,b是一个double型变量,pb是指向b的指针变量,那么a在内存中占四个字节,b在内存中占8个字节,假如a在内存中分布是从0x11111110~0x11111113,而b在内存中分布是0x11112221至0x11112228,那么指针变量pa中存储的内容是0x11111110,而pb中存储就是0x11112221,看到了吧,也就是说,pa和pb中存储的都是地址,而且都是32位的二进制地址;再者,因为存储这样的地址需要4个字节,所以无论是int型指针变量pa或者是double型指针变量pb,它们所占的内存大小都是四个字节,从这点来说,不管什么类型的指针都是一样的,所以不论按整型指针变量、字符型指针变量、浮点型指针变量等等来区分指针变量。总结起来,指针变量和int、float、char等类型一样同属变量类型,指针变量类型占四个字节(32位机器下),存储的是32位的内存地址。下面的代码证明这一点: 上面介绍的是指针变量的一个方面,指针变量还有另外一层含义:在C/C++中星号(*)被定义成取内容符号,虽然所有指针变量占的内存大小和存储的内存地址大小都是一样的,但是由于存储的只是数据的内存首地址,所以指针变量存储的内存地址所指向的数据类型决定着如何解析这个首地址,也就是说对于int型指针变量,我们需要从该指针变量存储的(首)地址开始向后一直搜索4个字节的内存空间,以图中的变量a为例就是从0x12ff60~0x12ff63,对于变量b就是0x12ff44~0x12ff4b。所以从这个意义来上讲,当我们使用*pa,必须先知道pa是一个整型的指针,这里强调“整型”,而a的值1也就存储在从0x12ff60~0x12ff63这四个字节里面,当我们使用*pb,必须先知道pb是一个double型指针,这里强调\"double\",也就是说值2.0000存储在0x12ff44~0x12ff4b这八个字节里面。因此,我们对指针变量进行算术运算,比如pa + 2,pb + +之类的操作,是以数据类型大小为单位的,也就是说pa + 2,相当于0x12ff60 + sizeof(int) * 2 = 0x12ff60 + 4 * 2 = 0x12ff68,不是0x12ff60 + 2哦;而pb - -相当于0x12ff44 + sizeof(double) * 1 = 0x12ff44 + 8 * 1 = 0x12ff4c。理解这一点很重要。 同理&a + 2和&b - 1也是一样(注意由于&b是一个指针常量,所以写成&b - -是错误的)。 指针变量和指针常量 指针变量首先是一个变量,由于指针变量存储了某个变量的内存首地址,我们通常认为”指针变量指向了该变量“,但是在这个时刻指针变量pa指向变量a,下个时候可能不存储变量a的首地址,而是存储变量c的首地址,那么我们可以认为这个时候,pa不再指向a,而是指向c。请别嫌我啰嗦,为了帮助你理解,我是故意说得这么细的,后面我们讨论高级主题的时候,当你觉得迷糊,请回来反复咀嚼一下这段话。也就是说指针变量是一个变量,它的值可以变动的。 相反,指针常量可通俗地理解为存储固定的内存单元地址编号的”量“,它一旦存储了某个内存地址以后,不可再改存储其他的内存地址了。所以指针常量是坚韧,因为它”咬定青山不放松“;说是”痴情“,因为它”曾经沧海难为水“。我这里讲的指针常量对应的是const关键字定义的量,而不是指针字面量。像&a, &b, &a + 2等是指针字面量,而const int *p = &a;中的p才算是真正的指针常量,指针常量一般用在函数的参数中,表示该函数不可改变实参的内容。来看一个例子吧: 上面的函数由于修改了一个常指针(多数情况下等同指针常量),因而会编译出错:error C3892: “x”: 不能给常量赋值。 指针变量与数组 记得多年以前,我在学生会给电子技术部和地理信息系统专业的同学进行C语言培训时,这是一个最让他们头疼和感到一头雾水的话题,尤其是指针变量与二维数组的结合,我永远忘不了胡永月那一脸迷惑与无助的表情。今天我这里给大家深入地分析一下。先看一个例子: 如果你能得出下面这样的结果,说明你已经基本上对数组与指针的概念理解清楚了: 通过上图,我们可以知道*(a + 1) = 2, *(ptr - 1) = 5。 且不说很多同学根本得不到这样的结果,他们看到int ptr = (int)(&a+1);这样的语句就已经懵了,首先,我们知道C语言中规定数组名表示这个数组的首地址,而这里竟然出现了&a这样的符号,本来a就是一个指针常量了,这里对&a再次取地址难道不是非法操作吗?哈哈,当你有这样的疑问的时候,说明你对二维数组相关知识理解不深入。我这里先给你补充下知识点吧: 看这样一个二维数组:int arr[3][4],这个数组布局如下: 这是一个3行4列的数组,它在内存中的分布如下: 这里每一个数组元素占4字节空间,我们知道C语言规定,数组名arr是整个数组元素的首地址,比如是0x0012ff08,而像arr[0]、arr[1]、arr[2]分别是数组第一行、第二行、第三行的首地址,也就是0x0012ff08、0x0012ff18、0x0012ff28。 我们把arr、arr[0]和&arr[0][0]单独拿出来分析,因为数组的首地址也是第一列的首地址,同时也是第一个元素的首地址,所以arr和arr[0]和&arr[0][0]表示的都是同一个地址,但是这三个首地址在进行算术运算时是有区别的。如果&arr[0][0] + 1,这里的相当于跳一个元素的内存字节数,也就是4个;但是arr[0] + 1,移动的内存字节数是一列元素所占的字节数,也就是4 4 = 16个;最后,也是最让人迷惑的的就是arr + 1,这个时候移动的内存数目是整个数组占的内存字节数,也就是48个字节数,所以a + 1所表示的内存地址已经不属于这个数组了,这个地址位于数组最后一个元素所占内存空间的*下一个字节空间。 光有这些知识还是不能解决上面的问题,我们再补充一个知识点。 C++是一种强类型的语言,其中有一种类型叫void类型,从本质上说void不是一种类型,因为变量都是”有类型“的,就好像人的性别,不是男人就是女人,不存在无性别的人,所以void更多是一种抽象。在程序中,void类型更多是用来”修饰“和”限制“一个函数的:例如一个函数如果不返回任何类型的值,可以用void作返回类型;如果一个函数无参数列表,可以用void作为参数列表。 跟void类型”修饰“作用不同,void型指针作为指向抽象数据的指针,它本质上表示一段内存块。如果两个指针类型不同,在进行指针类型赋值时必须进行强制类型转换,看下面的例子: 但是可以将任何指针类型赋值给void类型而无须进行强制类型转换: 当然,如果把void型指针转换成并不是它实际指向的数据类型,其结果是不可预测的。试想,如果把一个int型指针赋给void型,然后再把这个void型指针强制转换成double型指针,这样的结果是不可预测的。因为不同数据类型所占内存大小不一样,这样做可能或截断内存数据或者会增加一些未知的额外数据。所以,最好是将void类型指针转换成它实际数据类型指针。 有了上面的说明,你应该能看懂C函数库中下面这个函数的签名含义了吧? void *memcpy(void *dest,const void *src,size_t len); 在这里,任何数据类型的指针都可以传给这个函数,所以这个函数成为了一个通用的内存复制函数。 好了,说了这么多,回答最初的那个问题上: 我们来分析一下。首先,我们可以将这个数组看成是一个特殊的二维数组,也就是1行5列的二维数组,现在a表示的是第一个元素的首地址,那么a + 1指向的就是下一个元素的内存首地址,所以*(a + 1) = 2;而&a则是表示整个数组的首地址,那么&a + 1移动的内存数目就是整个数组所占字节数,假如这里我们量化来说明,假如原先数组中第一个元素的首地址是1,那么&a + 1表示的就是21,而这个地址已经不属于数组了,接着通过(int*)(&a + 1)将数组指针转换成整型指针,这样原先&a + 1表示的数据范围是21~40一下缩小到21~24,正好是一个int型的大小,所以ptr - 1的存储的地址就是17了,表示的数据内存范围是17~20,这样*(ptr - 1)正好就是最后一个元素5了。 但是话说回来,首先这样的转换安全与否尚有争议,再次,这样的程序晦涩难懂,难于理解,所以建议不要写出这样的程序。 上面的例子,只是通过一些简单的数据类型来说明内存分布,但是实际对于一些复杂的数据类型,尤其是一些自定义的类或者结构体类型,内存分布必须还要充分考虑到字节对齐。比如下面的代码: 这是输出结果: 由于结构体s1中存在字节对齐现象(以sizeof(double) = 8个字节对齐),所以s1占据24字节内存,而s2只占16个字节。知道这点,我们平常在设计结构体字段的时候,就可以合理安排字段顺序来使用更少的内存空间了。 函数指针 函数指针是指向函数的指针变量。 因而“函数指针”本身首先应是指针变量,只不过该指针变量指向函数。这正如用指针变量可指向整型变量、字符型、数组一样,这里是指向函数。C/C++程序在编译时,每一个函数都有一个入口地址,该入口地址就是函数指针所指向的地址。有了指向函数的指针变量后,可用该指针变量调用函数,就如同用指针变量可引用其他类型变量一样,在这些概念上是一致的。函数指针有两个用途:调用函数和做函数的参数。 我们先来先使用函数指针调用函数。如下图所示: 上面的代码首先是定义了一个函数f,然后是定义一个函数指针pf,接着在主函数里面将函数f的地址赋值给函数指针,这样pf就指向了函数f,这样使用pf就可以直接调用函数了。但是上面的例子定义函数指针的方法在某些编译器中是无法通过的,最好通过*typedef关键字定义函数指针,推荐的写法如下: 通过上面的例子,我们来总结下函数指针的定义和使用方法: 首先,通过typedef关键字定义一个函数指针类型,然后定义一个该函数指针类型变量,接着将函数的入口地址赋值给该函数指针类型变量,这样就可以通过这个函数指针变量调用函数了。 需要注意的是,定义函数指针类型时的函数签名(包括函数返回值和函数参数列表的类型、个数、顺序)要将赋值给该类型变量的函数签名保持一致,不然可能会发生很多无法预料的情况。还有一点,就是C/C++规定函数名就表示函数入口地址,所以,函数名赋值时函数名前面加不加取地址符&都一样,也就是说PF pf = f等价于PF pf = &f。这个&是可以省略的。但是这是单个函数的情况,在C++中取类的方法函数的地址时,这个&符号式不能省略的,见下面的例子: 函数指针的另外一个用处,而且是用的最多的,就是作为一个函数的参数。也就是说某个函数的某个参数类型是一个函数,这在windows编程中作为回调函数(callback)尤其常见。我们来看一个例子: 上图中,函数f2第一个参数类型是一个函数,我们传入函数f1作为参数。这种函数参数是函数类型的用法很重要,建议大家掌握。 指针变量的定义方法 先插播一段广告,说下main函数的返回值问题,如下图: 这种main函数无返回值的写法,在国内各大C/C++教材上屡见不鲜,这种写法是错误的! 有一点你必须明确:C/C++标准中从来没有定义过void main()这样的代码形式。C++之父Bjarne Stroustrup在他的主页FAQ中明确地写了这样一句话: 在C++中绝对没有出现过void main(){ / ... / } 这样的函数定义,在C语言中也是。 main函数的返回值应该定义为int型,在C/C++标准中都是这样规定的。在C99标准规定,只有以下两种定义方式是正确的的: 1 int main(void); 2 int main(int argc,char *argv[]); 虽然在C和C++标准中并不支持void main(),但是在部分编译器中void main()依旧是可以通过编译并执行的,比如微软的VC++。由于微软产品的市场占有率和影响力很大,因为在某种程度上加剧了这种不良习惯的蔓延。不过,并非所有犯人编译器都支持void main(),gcc就站在VC++的对立面,它是这一不良习气的坚定抵制者,它会在编译时明确地给出一个错误。 广告播完,我们回到正题上来。我们来看下如何定义一个指针,首先看一个例子: 我来替你回答吧,你肯定认为a是一个指针变量,b是一个整型变量,c和d都是一个指针变量。好吧,恭喜你,答错了! 其实定义指针变量的时候,星号(*)无论是与数据类型结合还是与变量名结合在一起都是一样的!但是,为了便于理解,还是推荐大家写成第一种形式,第二种形式容易误导人,不是吗?而且第一种形式还有一个好处,我们可以这样看: int *a; //将*a看成一个整体,它是一个int型数据,那么a自然就是指向*a的指针了。 说完定义指针的方法,下面我们来看下如何初始化一个指针变量,看下面的代码: 上面的代码有错误吗? 错误在于我们不能这样写:int *p = 1; 由于p是一个匿名指针,也就是说p没有正确的初始化,它可能指向一个不确定的内存地址,而这个内存地址可能是系统程序内存所在,我们将数值1装入那个不确定的内存单元中是很危险的,因为可能会破坏系统那个内存原来的数据,引发异常。换另一个方面来看,将整型数值1直接赋值给指针型变量p是非法的。 这样的指针我们称为匿名指针或者野指针。和其他变量类型一样,为了防止发生意料之外的错误,我们应该给新定义的指针变量一个初始值。但是有时候,我们又没有合适的初始值给这个指针,怎么办?我们可以使用NULL关键字或者C++中的nullptr。代码如下: 通过上面的写法就告诉编译器,这两个指针现在不会指向不确定的内存单元了,但是目前暂时不需要使用它们。  C++中的引用 C++中不仅有指针的概念,而且还存在一个引用的概念,看下面的代码: 我开始在接触这个概念的时候,老是弄错。当时这么想的,既然b是a的引用,那么&b应该等于a吧?也就是说,在需要使用变量a的时候,可以使用&b来代替。 上面的这种认识是错误的!所谓引用,使用另外一个变量名来代表某一块内存,也就是说a和b完全是一样,所以任何地方,可以使用a的,换成b也可以,而不是使用&b,这就相当于同一个人有不同的名字,但是不管哪个名字,指的都是同一个人。 新手在刚接触引用的使用,还有一个地方容易出错,就是忘记给引用及时初始化,注意这里的“及时”两个字,C++规定,定义一个引用时,必须马上初始化。看下面的代码: 传值还是传引用(by value or by reference) 看下面的伪代码: 在涉及到利用一个已有初值的变量给另外一个变量赋值时,必须考虑这样的情况。图中变量a已经有了初值,然后利用a来给b赋初值,那么最后改变b的值,a的值会不会受影响呢?这就取决于b到底是a的副本还是和a同时指向同一内存区域,这就是我们常说的赋值时是传值还是传引用。各大语言都是这样规定的,也就是说不局限于C/C++,同时Java、C#、php、javascript等都一样: 如果变量类型是基元数据类型(基础数据类型),比如int、float、bool、char等小数据类型被称为基元数据类型(primitive data type),那么赋值时传的是值。也就是说,这个时候b的值是a的拷贝,那么更改b不会影响到a,同理更改a也不会影响到b。 但是,如果变量类型是复杂数据类型(complex data type),不如数组、类对象,那么赋值时传的就是引用,这个时候,a和b指向的都是同一块内存区域,那么无论更改a或者b都会相互影响。 让我们来深入地分析下,为什么各大语言都采取这种机制。对于那些基元数据类型,由于数据本身占用的内存空间就小,这样复制起来不仅速度快,即使这样的变量数目很多,总共也不会占多大空间。但是对于复杂数据类型,比如一些类对象,它们包含的属性字段就很多,占用的空间就大,如果赋值时,也是复制数据,那么一个两个对象还好,一旦多一点比如10个、100个,会占很大的内存单元的,这就导致效率的下降。 最后,提醒一点,在利用C++中拷贝构造函数复制对象时需要注意,基元数据类型可以直接复制,但是对于引用类型数据,我们需要自己实现引用型数据的真正复制。 C/C++中的new关键字与Java、C#中的关键字对比 我大学毕业的时候痴迷于于网页游戏开发,使用的语言是flash平台的actionscript 3.0(简称as3,唉,如今已经没落),我刚开始由as3转行至C/C++,对于C/C++中new出来的对象必须通过指针对象来引用它非常不习惯。上图中,Object是一个类(class),在Java或者C#等语言中,通过new关键字定义一个对象,直接得到Object的实例,也就是说后续引用这个对象,我们可以直接使用obj.property或者obj.method()等形式,但是在C++中不行,比如用一个指针去接受这个new出来的对象,我们引用这个对象必须使用指针引用运算符->,也就是我们需要这样写:pObj->property或pObject->method()。代码如下: 当然C++中还有一种不需要使用指针就可以实例化出来类对象的方法,从Java、C#等转向C++的程序员容易误解为未初始化对象变量的定义,看下列代码: 这是C++中利用Object类实例化两个对象obj1和obj2,obj2因为调用构造函数传了两个参数param1,param2还好理解一点,对于obj1很多Java或者C#的程序员开始很难接受这种写法,因为如果放在Java或者C#中,obj1根本就没有被实例化嘛,在他们看来,obj1只是一个简单的类型申明。希望Java、C#等程序员要转换过思维来看待C++中的这种写法。 还有一点也容易出错,在C++中,this关键字是一个指针,而不是像在Java、C#中是一个类实例。也就是说,在C++中*this才等价于Java、C#中的this。所以写法也就不一样了: Windows编程中的指针 Windows是操作系统是用C语言写出来的,所以尽管你在Windows中看到很多不认识的数据类型,但是这些数据类型也是通过基本的C语言类型组装起来的。我们这里只介绍Windows中指针型数据。 定义指针数据类型必须使用星号(*),但是Windows为了开发的方便,通过宏定义将指针“隐藏起来”,严格地说应该是将星号隐藏起来了,下面给出一些例子: C++中的智能指针 为了保持内容的完整性,暂且列一个标题放在这里,这个话题请参考本专题相关文章。 我能想到的关于C/C++中指针的内容就这么多了,希望本文对你有用。文中如果有不当或者纰漏的地方欢迎批评指正。 如需下载本站全部技术文章,可以在【高性能服务器开发】公众号回复关键字“文章下载”即可。最近更新时间: 2020-09-15 21:27:59 "},"articles/C++必知必会的知识点/详解C++11中的智能指针.html":{"url":"articles/C++必知必会的知识点/详解C++11中的智能指针.html","title":"详解C++11中的智能指针","keywords":"","body":"详解 C++ 11 中的智能指针 C/C++ 语言最为人所诟病的特性之一就是存在内存泄露问题,因此后来的大多数语言都提供了内置内存分配与释放功能,有的甚至干脆对语言的使用者屏蔽了内存指针这一概念。这里不置贬褒,手动分配内存与手动释放内存有利也有弊,自动分配内存和自动释放内存亦如此,这是两种不同的设计哲学。有人认为,内存如此重要的东西怎么能放心交给用户去管理呢?而另外一些人则认为,内存如此重要的东西怎么能放心交给系统去管理呢?在 C/C++ 语言中,内存泄露的问题一直困扰着广大的开发者,因此各类库和工具的一直在努力尝试各种方法去检测和避免内存泄露,如 boost,智能指针技术应运而生。 C++ 98/03 的尝试——std::auto_ptr 在 2019 年讨论 std::auto_ptr 不免有点让人怀疑是不是有点过时了,确实如此,随着 C++11 标准的出现(最新标准是 C++20),std::auto_ptr 已经被彻底废弃了,取而代之是 std::unique_ptr。然而,我之所以还向你介绍一下 std::auto_ptr 的用法以及它的设计不足之处是想让你了解 C++ 语言中智能指针的发展过程,一项技术如果我们了解它过去的样子和发展的轨迹,我们就能更好地掌握它,不是吗? std::auto_ptr 的基本用法如下代码所示: #include int main() { //初始化方式1 std::auto_ptr sp1(new int(8)); //初始化方式2 std::auto_ptr sp2; sp2.reset(new int(8)); return 0; } 智能指针对象 sp1 和 sp2 均持有一个在堆上分配 int 对象,其值均是 8,这两块堆内存均可以在 sp1 和 sp2 释放时得到释放。这是 std::auto_ptr 的基本用法。 sp 是 smart pointer(智能指针)的简写。 std::auto_ptr 真正让人容易误用的地方是其不常用的复制语义,即当复制一个 std::auto_ptr 对象时(拷贝复制或 operator = 复制),原对象所持有的堆内存对象也会转移给复制出来的对象。示例代码如下: #include #include int main() { //测试拷贝构造 std::auto_ptr sp1(new int(8)); std::auto_ptr sp2(sp1); if (sp1.get() != NULL) { std::cout sp3(new int(8)); std::auto_ptr sp4; sp4 = sp3; if (sp3.get() != NULL) { std::cout 上述代码中分别利用拷贝构造(sp1 => sp2)和 赋值构造(sp3 => sp4)来创建新的 std::auto_ptr 对象,因此 sp1 持有的堆对象被转移给 sp2,sp3 持有的堆对象被转移给 sp4。我们得到程序执行结果如下: [root@iZ238vnojlyZ testx]# g++ -g -o test_auto_ptr test_auto_ptr.cpp [root@iZ238vnojlyZ testx]# ./test_auto_ptr sp1 is empty. sp2 is not empty. sp3 is empty. sp4 is not empty. 由于 std::auto_ptr 这种不常用的复制语义,我们应该避免在 stl 容器中使用 std::auto_ptr,例如我们绝不应该写出如下代码: std::vector> myvectors; 当用算法对容器操作的时候(如最常见的容器元素遍历),很难避免不对容器中的元素实现赋值传递,这样便会使容器中多个元素被置为空指针,这不是我们想看到的,会造成很多意想不到的错误。 以史为鉴,作为 std::auto_ptr 的替代者 std::unique_ptr 吸取了这个经验教训。下文会来详细介绍。 正因为 std::auto_ptr 的设计存在如此重大缺陷,C++11 标准在充分借鉴和吸收了 boost 库中智能指针的设计思想,引入了三种类型的智能指针,即 std::unique_ptr、std::shared_ptr 和 std::weak_ptr。 boost 还有 scoped_ptr,C++11 并没有全部照搬,而是选择了三个最实用的指针类型。在 C++11 中可以通过 std::unique_ptr 达到与 boost::scoped_ptr 一样的效果。 所有的智能指针类(包括 std::unique_ptr)均包含于头文件 ** 中。 正因为存在上述设计上的缺陷,在 C++11及后续语言规范中 std::auto_ptr 已经被废弃,你的代码不应该再使用它。 std::unique_ptr std::unique_ptr 对其持有的堆内存具有唯一拥有权,也就是说引用计数永远是 1,std::unique_ptr 对象销毁时会释放其持有的堆内存。可以使用以下方式初始化一个 std::unique_ptr 对象: //初始化方式1 std::unique_ptr sp1(new int(123)); //初始化方式2 std::unique_ptr sp2; sp2.reset(new int(123)); //初始化方式3 std::unique_ptr sp3 = std::make_unique(123); 你应该尽量使用初始化方式 3 的方式去创建一个 std::unique_ptr 而不是方式 1 和 2,因为形式 3 更安全,原因 Scott Meyers 在其《Effective Modern C++》中已经解释过了,有兴趣的读者可以阅读此书相关章节。 令很多人对 C++11 规范不满的地方是,C++11 新增了 std::make_shared() 方法创建一个 std::shared_ptr 对象,却没有提供相应的 std::make_unique() 方法创建一个 std::unique_ptr 对象,这个方法直到 C++14 才被添加进来。当然,在 C++11 中你很容易实现出这样一个方法来: template std::unique_ptr make_unique(Ts&& ...params) { return std::unique_ptr(new T(std::forward(params)...)); } 鉴于 std::auto_ptr 的前车之鉴,std::unique_ptr 禁止复制语义,为了达到这个效果,std::unique_ptr 类的拷贝构造函数和赋值运算符(operator =)被标记为 delete。 template class unique_ptr { //省略其他代码... //拷贝构造函数和赋值运算符被标记为delete unique_ptr(const unique_ptr&) = delete; unique_ptr& operator=(const unique_ptr&) = delete; }; 因此,下列代码是无法通过编译的: std::unique_ptr sp1(std::make_unique(123));; //以下代码无法通过编译 //std::unique_ptr sp2(sp1); std::unique_ptr sp3; //以下代码无法通过编译 //sp3 = sp1; 禁止复制语义也存在特例,即可以通过一个函数返回一个 std::unique_ptr: #include std::unique_ptr func(int val) { std::unique_ptr up(new int(val)); return up; } int main() { std::unique_ptr sp1 = func(123); return 0; } 上述代码从 func 函数中得到一个 std::unique_ptr 对象,然后返回给 sp1。 既然 std::unique_ptr 不能复制,那么如何将一个 std::unique_ptr 对象持有的堆内存转移给另外一个呢?答案是使用移动构造,示例代码如下: #include int main() { std::unique_ptr sp1(std::make_unique(123)); std::unique_ptr sp2(std::move(sp1)); std::unique_ptr sp3; sp3 = std::move(sp2); return 0; } 以上代码利用 std::move 将 sp1 持有的堆内存(值为 123)转移给 sp2,再把 sp2 转移给 sp3。最后,sp1 和 sp2 不再持有堆内存的引用,变成一个空的智能指针对象。并不是所有的对象的 std::move 操作都有意义,只有实现了移动构造函数(Move Constructor)或移动赋值运算符(operator =)的类才行,而 std::unique_ptr 正好实现了这二者,以下是实现伪码: template class unique_ptr { //其他函数省略... public: unique_ptr(unique_ptr&& rhs) { this->m_pT = rhs.m_pT; //源对象释放 rhs.m_pT = nullptr; } unique_ptr& operator=(unique_ptr&& rhs) { this->m_pT = rhs.m_pT; //源对象释放 rhs.m_pT = nullptr; return *this; } private: T* m_pT; }; 这是 std::unique_ptr 具有移动语义的原因,希望读者可以理解之。关于移动构造和 std::move,我们将在后面章节详细介绍。 std::unique_ptr 不仅可以持有一个堆对象,也可以持有一组堆对象,示例如下: #include #include int main() { //创建10个int类型的堆对象 //形式1 std::unique_ptr sp1(new int[10]); //形式2 std::unique_ptr sp2; sp2.reset(new int[10]); //形式3 std::unique_ptr sp3(std::make_unique(10)); for (int i = 0; i 程序执行结果如下: [root@myaliyun testmybook]# g++ -g -o test_unique_ptr_with_array test_unique_ptr_with_array.cpp -std=c++17 [root@myaliyun testmybook]# ./test_unique_ptr_with_array 0, 0, 0 1, 1, 1 2, 2, 2 3, 3, 3 4, 4, 4 5, 5, 5 6, 6, 6 7, 7, 7 8, 8, 8 9, 9, 9 std::shared_ptr 和 std::weak_ptr 也可以持有一组堆对象,用法与 std::unique_ptr 相同,下文不再赘述。 自定义智能指针对象持有的资源的释放函数 默认情况下,智能指针对象在析构时只会释放其持有的堆内存(调用 delete 或者 delete[]),但是假设这块堆内存代表的对象还对应一种需要回收的资源(如操作系统的套接字句柄、文件句柄等),我们可以通过自定义智能指针的资源释放函数。假设现在有一个 Socket 类,对应着操作系统的套接字句柄,在回收时需要关闭该对象,我们可以如下自定义智能指针对象的资源析构函数,这里以 std::unique_ptr 为例: #include #include class Socket { public: Socket() { } ~Socket() { } //关闭资源句柄 void close() { } }; int main() { auto deletor = [](Socket* pSocket) { //关闭句柄 pSocket->close(); //TODO: 你甚至可以在这里打印一行日志... delete pSocket; }; std::unique_ptr spSocket(new Socket(), deletor); return 0; } 自定义 std::unique_ptr 的资源释放函数其规则是: std::unique_ptr 其中 T 是你要释放的对象类型,DeletorPtr 是一个自定义函数指针。上述代码 33 行表示 DeletorPtr 有点复杂,我们可以使用 decltype(deletor) 让编译器自己推导 deletor 的类型,因此可以将 33 行代码修改为: std::unique_ptr spSocket(new Socket(), deletor); std::shared_ptr std::unique_ptr 对其持有的资源具有独占性,而 std::shared_ptr 持有的资源可以在多个 std::shared_ptr 之间共享,每多一个 std::shared_ptr 对资源的引用,资源引用计数将增加 1,每一个指向该资源的 std::shared_ptr 对象析构时,资源引用计数减 1,最后一个 std::shared_ptr 对象析构时,发现资源计数为 0,将释放其持有的资源。多个线程之间,递增和减少资源的引用计数是安全的。(注意:这不意味着多个线程同时操作 std::shared_ptr 引用的对象是安全的)。std::shared_ptr 提供了一个 use_count() 方法来获取当前持有资源的引用计数。除了上面描述的,std::shared_ptr 用法和 std::unique_ptr 基本相同。 下面是一个初始化 std::shared_ptr 的示例: //初始化方式1 std::shared_ptr sp1(new int(123)); //初始化方式2 std::shared_ptr sp2; sp2.reset(new int(123)); //初始化方式3 std::shared_ptr sp3; sp3 = std::make_shared(123); 和 std::unique_ptr 一样,你应该优先使用 std::make_shared 去初始化一个 std::shared_ptr 对象。 再来看另外一段代码: #include #include class A { public: A() { std::cout sp1(new A()); std::cout sp2(sp1); std::cout sp3 = sp1; std::cout 上述代码 22 行 sp1 构造时,同时触发对象 A 的构造,因此 A 的构造函数会执行; 此时只有一个 sp1 对象引用 22 行 new 出来的 A 对象(为了叙述方便,下文统一称之为资源对象 A),因此代码 24 行打印出来的引用计数值为 1; 代码 27 行,利用 sp1 拷贝一份 sp2,导致代码 28 行打印出来的引用计数为 2; 代码 30 行调用 sp2 的 reset() 方法,sp2 释放对资源对象 A 的引用,因此代码 31 行打印的引用计数值再次变为 1; 代码 34 行 利用 sp1 再次 创建 sp3,因此代码 35 行打印的引用计数变为 2; 程序执行到 36 行以后,sp3 出了其作用域被析构,资源 A 的引用计数递减 1,因此 代码 38 行打印的引用计数为 1; 程序执行到 39 行以后,sp1 出了其作用域被析构,在其析构时递减资源 A 的引用计数至 0,并析构资源 A 对象,因此类 A 的析构函数被调用。 所以整个程序的执行结果如下: [root@myaliyun testmybook]# ./test_shared_ptr_use_count A constructor use count: 1 use count: 2 use count: 1 use count: 2 use count: 1 A destructor std::enable_shared_from_this 实际开发中,有时候需要在类中返回包裹当前对象(this)的一个 std::shared_ptr 对象给外部使用,C++ 新标准也为我们考虑到了这一点,有如此需求的类只要继承自 std::enable_shared_from_this 模板对象即可。用法如下: #include #include class A : public std::enable_shared_from_this { public: A() { std::cout getSelf() { return shared_from_this(); } }; int main() { std::shared_ptr sp1(new A()); std::shared_ptr sp2 = sp1->getSelf(); std::cout 上述代码中,类 A 的继承 std::enable_shared_from_this 并提供一个 getSelf() 方法返回自身的 std::shared_ptr 对象,在 getSelf() 中调用 shared_from_this() 即可。 std::enable_shared_from_this 用起来比较方便,但是也存在很多不易察觉的陷阱。 陷阱一:不应该共享栈对象的 this 给智能指针对象 假设我们将上面代码 main 函数 25 行生成 A 对象的方式改成一个栈变量,即: //其他相同代码省略... int main() { A a; std::shared_ptr sp2 = a.getSelf(); std::cout 运行修改后的代码会发现程序在 std::shared_ptr sp2 = a.getSelf(); 产生崩溃。这是因为,智能指针管理的是堆对象,栈对象会在函数调用结束后自行销毁,因此不能通过 shared_from_this() 将该对象交由智能指针对象管理。切记:智能指针最初设计的目的就是为了管理堆对象的(即那些不会自动释放的资源)。 陷阱二:避免 std::enable_shared_from_this 的循环引用问题 再来看另外一段代码: // test_std_enable_shared_from_this.cpp : This file contains the 'main' function. Program execution begins and ends there. // #include #include class A : public std::enable_shared_from_this { public: A() { m_i = 9; //注意: //比较好的做法是在构造函数里面调用shared_from_this()给m_SelfPtr赋值 //但是很遗憾不能这么做,如果写在构造函数里面程序会直接崩溃 std::cout m_SelfPtr; }; int main() { { std::shared_ptr spa(new A()); spa->func(); } return 0; } 乍一看上面的代码好像看不出什么问题,让我们来实际运行一下看看输出结果: [root@myaliyun testmybook]# g++ -g -o test_std_enable_shared_from_this_problem test_std_enable_shared_from_this_problem.cpp [root@myaliyun testmybook]# ./test_std_enable_shared_from_this_problem A constructor 我们发现在程序的整个生命周期内,只有 A 类构造函数的调用输出,没有 A 类析构函数的调用输出,这意味着 new 出来的 A 对象产生了内存泄漏了! 我们来分析一下为什么 new 出来的 A 对象得不到释放。当程序执行到 42 行后,spa 出了其作用域准备析构,在析构时其发现仍然有另外的一个 std::shared_ptr 对象即 A::m_SelfPtr 引用了 A,因此 spa 只会将 A 的引用计数递减为 1,然后就销毁自身了。现在留下一个矛盾的处境:必须销毁 A 才能销毁其成员变量 m_SelfPtr,而销毁 m_SelfPtr 必须先销毁 A。这就是所谓的 std::enable_shared_from_this 的循环引用问题。我们在实际开发中应该避免做出这样的逻辑设计,这种情形下即使使用了智能指针也会造成内存泄漏。也就是说一个资源的生命周期可以交给一个智能指针对象,但是该智能指针的生命周期不可以再交给整个资源来管理。 std::weak_ptr std::weak_ptr 是一个不控制资源生命周期的智能指针,是对对象的一种弱引用,只是提供了对其管理的资源的一个访问手段,引入它的目的为协助 std::shared_ptr 工作。 std::weak_ptr 可以从一个 std::shared_ptr 或另一个 std::weak_ptr 对象构造,std::shared_ptr 可以直接赋值给 std::weak_ptr ,也可以通过 std::weak_ptr 的 lock() 函数来获得 std::shared_ptr。它的构造和析构不会引起引用计数的增加或减少。std::weak_ptr 可用来解决 std::shared_ptr 相互引用时的死锁问题(即两个std::shared_ptr 相互引用,那么这两个指针的引用计数永远不可能下降为 0, 资源永远不会释放)。 示例代码如下: #include #include int main() { //创建一个std::shared_ptr对象 std::shared_ptr sp1(new int(123)); std::cout sp2(sp1); std::cout sp3 = sp1; std::cout sp4 = sp2; std::cout 程序执行结果如下: [root@myaliyun testmybook]# g++ -g -o test_weak_ptr test_weak_ptr.cpp [root@myaliyun testmybook]# ./test_weak_ptr use count: 1 use count: 1 use count: 1 use count: 1 无论通过何种方式创建 std::weak_ptr 都不会增加资源的引用计数,因此每次输出引用计数的值都是 1。 既然,std::weak_ptr 不管理对象的生命周期,那么其引用的对象可能在某个时刻被销毁了,如何得知呢?std::weak_ptr 提供了一个 expired() 方法来做这一项检测,返回 true,说明其引用的资源已经不存在了;返回 false,说明该资源仍然存在,这个时候可以使用 std::weak_ptr 的 lock() 方法得到一个 std::shared_ptr 对象然后继续操作资源,以下代码演示了该用法: //tmpConn_ 是一个 std::weak_ptr 对象 //tmpConn_引用的TcpConnection已经销毁,直接返回 if (tmpConn_.expired()) return; std::shared_ptr conn = tmpConn_.lock(); if (conn) { //对conn进行操作,省略... } 有读者可能对上述代码产生疑问,既然使用了 std::weak_ptr 的 expired() 方法判断了对象是否存在,为什么不直接使用 std::weak_ptr 对象对引用资源进行操作呢?实际上这是行不通的,std::weak_ptr 类没有重写 operator-> 和 operator* 方法,因此不能像 std::shared_ptr 或 std::unique_ptr 一样直接操作对象,同时 std::weak_ptr 类也没有重写 operator! 操作,因此也不能通过 std::weak_ptr 对象直接判断其引用的资源是否存在: #include class A { public: void doSomething() { } }; int main() { std::shared_ptr sp1(new A()); std::weak_ptr sp2(sp1); //正确代码 if (sp1) { //正确代码 sp1->doSomething(); (*sp1).doSomething(); } //正确代码 if (!sp1) { } //错误代码,无法编译通过 //if (sp2) //{ // //错误代码,无法编译通过 // sp2->doSomething(); // (*sp2).doSomething(); //} //错误代码,无法编译通过 //if (!sp2) //{ //} return 0; } 之所以 std::weak_ptr 不增加引用资源的引用计数不管理资源的生命周期,是因为,即使它实现了以上说的几个方法,调用它们也是不安全的,因为在调用期间,引用的资源可能恰好被销毁了,这会造成棘手的错误和麻烦。 因此,std::weak_ptr 的正确使用场景是那些资源如果可能就使用,如果不可使用则不用的场景,它不参与资源的生命周期管理。例如,网络分层结构中,Session 对象(会话对象)利用 Connection 对象(连接对象)提供的服务工作,但是 Session 对象不管理 Connection 对象的生命周期,Session 管理 Connection 的生命周期是不合理的,因为网络底层出错会导致 Connection 对象被销毁,此时 Session 对象如果强行持有 Connection 对象与事实矛盾。 std::weak_ptr 的应用场景,经典的例子是订阅者模式或者观察者模式中。这里以订阅者为例来说明,消息发布器只有在某个订阅者存在的情况下才会向其发布消息,而不能管理订阅者的生命周期。 class Subscriber { }; class SubscribeManager { public: void publish() { for (const auto& iter : m_subscribers) { if (!iter.expired()) { //TODO:给订阅者发送消息 } } } private: std::vector> m_subscribers; }; 智能指针对象的大小 一个 std::unique_ptr 对象大小与裸指针大小相同(即 sizeof(std::unique_ptr) == sizeof(void)),而 std::shared_ptr 的大小是 *std::unique_ptr 的一倍。以下是我分别在 Visual Studio 2019 和 gcc/g++ 4.8 上(二者都编译成 x64 程序)的测试结果: 测试代码 #include #include #include int main() { std::shared_ptr sp0; std::shared_ptr sp1; sp1.reset(new std::string()); std::unique_ptr sp2; std::weak_ptr sp3; std::cout Visual Studio 2019 运行结果: gcc/g++ 运行结果: 在 32 位机器上,std_unique_ptr 占 4 字节,std::shared_ptr 和 std::weak_ptr 占 8 字节;在 64 位机器上,std_unique_ptr 占 8 字节,std::shared_ptr 和 std::weak_ptr 占 16 字节。也就是说,std_unique_ptr 的大小总是和原始指针大小一样,std::shared_ptr 和 std::weak_ptr 大小是原始指针的一倍。 智能指针使用注意事项 C++ 新标准提倡的理念之一是不应该再手动调用 delete 或者 free 函数去释放内存了,而应该把它们交给新标准提供的各种智能指针对象。C++ 新标准中的各种智能指针是如此的实用与强大,在现代 C++ 项目开发中,读者应该尽量去使用它们。智能指针虽然好用,但稍不注意,也可能存在许多难以发现的 bug,这里我根据经验总结了几条: 一旦一个对象使用智能指针管理后,就不该再使用原始裸指针去操作; 看一段代码: #include class Subscriber { }; int main() { Subscriber* pSubscriber = new Subscriber(); std::unique_ptr spSubscriber(pSubscriber); delete pSubscriber; return 0; } 这段代码利用创建了一个堆对象 Subscriber,然后利用智能指针 spSubscriber 去管理之,可以却私下利用原始指针销毁了该对象,这让智能指针对象 spSubscriber 情何以堪啊? 记住,一旦智能指针对象接管了你的资源,所有对资源的操作都应该通过智能指针对象进行,不建议再通过原始指针进行操作了。当然,除了 std::weak_ptr,std::unique_ptr 和 std::shared_ptr 都提供了获取原始指针的方法——get() 函数。 int main() { Subscriber* pSubscriber = new Subscriber(); std::unique_ptr spSubscriber(pSubscriber); //pTheSameSubscriber和pSubscriber指向同一个对象 Subscriber* pTheSameSubscriber= spSubscriber.get(); return 0; } 分清楚场合应该使用哪种类型的智能指针; 通常情况下,如果你的资源不需要在其他地方共享,那么应该优先使用 std::unique_ptr,反之使用 std::shared_ptr,当然这是在该智能指针需要管理资源的生命周期的情况下;如果不需要管理对象的生命周期,请使用 std::weak_ptr。 认真考虑,避免操作某个引用资源已经释放的智能指针; 前面的例子,一定让你觉得非常容易知道一个智能指针的持有的资源是否还有效,但是还是建议在不同场景谨慎一点,有些场景是很容易造成误判。例如下面的代码: #include #include class T { public: void doSomething() { std::cout sp1(new T()); const auto& sp2 = sp1; sp1.reset(); //由于sp2已经不再持有对象的引用,程序会在这里出现意外的行为 sp2->doSomething(); return 0; } 上述代码中,sp2 是 sp1 的引用,sp1 被置空后,sp2 也一同为空。这时候调用 sp2->doSomething(),sp2->(即 operator->)在内部会调用 get() 方法获取原始指针对象,这时会得到一个空指针(地址为 0),继续调用 doSomething() 导致程序崩溃。 你一定仍然觉得这个例子也能很明显地看出问题,ok,让我们把这个例子放到实际开发中再来看一下: //连接断开 void MonitorServer::OnClose(const std::shared_ptr& conn) { std::lock_guard guard(m_sessionMutex); for (auto iter = m_sessions.begin(); iter != m_sessions.end(); ++iter) { //通过比对connection对象找到对应的session if ((*iter)->GetConnectionPtr() == conn) { m_sessions.erase(iter); //注意这里:程序在此处崩溃 LOGI(\"monitor client disconnected: %s\", conn->peerAddress().toIpPort().c_str()); break; } } } 这段代码不是我杜撰的,而是来自于我实际的一个商业项目中。注意代码中我提醒注意的地方,该段程序会在代码 12 行处崩溃,崩溃原因是调用了 conn->peerAddress() 方法。为什么这个方法的调用可能会引起崩溃?现在可以一目了然地看出了吗? 崩溃原因是传入的 conn 对象和上一个例子中的 sp2 一样都是另外一个 std::shared_ptr 的引用,当连接断开时,对应的 TcpConnection 对象可能早已被销毁,而 conn 引用就会变成空指针(严格来说是不再拥有一个 TcpConnection 对象),此时调用 TcpConnection 的 peerAddress() 方法就会产生和上一个示例一样的错误。 作为类成员变量时,应该优先使用前置声明(forward declarations) 我们知道,为了减小编译依赖加快编译速度和生成二进制文件的大小,C/C++ 项目中一般在 *.h 文件对于指针类型尽量使用前置声明,而不是直接包含对应类的头文件。例如: //Test.h //在这里使用A的前置声明,而不是直接包含A.h文件 class A; class Test { public: Test(); ~Test(); private: A* m_pA; }; 同样的道理,在头文件中当使用智能指针对象作为类成员变量时,也应该优先使用前置声明去引用智能指针对象的包裹类,而不是直接包含包裹类的头文件。 //Test.h #include //智能指针包裹类A,这里优先使用A的前置声明,而不是直接包含A.h class A; class Test { public: Test(); ~Test(); private: std::unique_ptr m_spA; }; C++ 新标准中的智能指针我想介绍的就这么多了,Modern C/C++ 已经变为 C/C++ 开发的趋势,希望读者能善用和熟练使用本节介绍的后三种智能指针对象。 如需下载本站全部技术文章,可以在【高性能服务器开发】公众号回复关键字“文章下载”即可。最近更新时间: 2020-09-15 21:36:21 "},"articles/C++必知必会的知识点/C++17结构化绑定.html":{"url":"articles/C++必知必会的知识点/C++17结构化绑定.html","title":"C++17结构化绑定","keywords":"","body":"C++ 17 结构化绑定 stl 的 map 容器很多读者应该都很熟悉,map 容器提供了一个 insert 方法,我们用该方法向 map 中插入元素,但是应该很少有人记得 insert 方法的返回值是什么类型,让我们来看一下 C++98/03 提供的 insert 方法的签名: std::pair insert( const value_type& value ); 这里我们仅关心其返回值,这个返回值是一个 std::pair 类型,由于 map 中的元素的 key 不允许重复,所以如果 insert 方法调用成功,T1 是被成功插入到 map 中的元素的迭代器,T2 的类型为 bool,此时其值为 true(表示插入成功);如果 insert 由于 key 重复,T1 是造成 insert 插入失败、已经存在于 map 中的元素的迭代器,此时 T2 的值为 false(表示插入失败)。 在 C++98/03 标准中我们可以使用 std::pair 的 first 和 second 属性来分别引用 T1 和 T2 的值。如下面的我们熟悉的代码所示: #include #include #include int main() { std::map cities; cities[\"beijing\"] = 0; cities[\"shanghai\"] = 1; cities[\"shenzhen\"] = 2; cities[\"guangzhou\"] = 3; //for (const auto& [key, value] : m) //{ // std::cout ::iterator, int> insertResult = cities.insert(std::pair(\"shanghai\", 2)); //C++ 11中我们写成: auto insertResult = cities.insert(std::pair(\"shanghai\", 2)); std::cout first second 代码 19 行实在太啰嗦了,我们使用 auto 关键字让编译器自动推导类型。 std::pair 一般只能表示两个元素,C++11 标准中引入了 std::tuple 类型,有了这个类型,我们就可以放任意个元素了,原来需要定义成结构体的 POD 对象我们可以直接使用 std::tuple 表示,例如下面表示用户信息的结构体: struct UserInfo { std::string username; std::string password; int gender; int age; std::string address; }; int main() { UserInfo userInfo = { \"Tom\", \"123456\", 0, 25, \"Pudong Street\" }; std::string username = userInfo.username; std::string password = userInfo.password; int gender = userInfo.gender; int age = userInfo.age; std::string address = userInfo.address; return 0; } 我们不再需要定义 struct UserInfo 这样的对象,可以直接使用 std::tuple 表示: int main() { std::tuple userInfo(\"Tom\", \"123456\", 0, 25, \"Pudong Street\"); std::string username = std::get(userInfo); std::string password = std::get(userInfo); int gender = std::get(userInfo); int age = std::get(userInfo); std::string address = std::get(userInfo); return 0; } 从 std::tuple 中获取对应位置的元素,我们使用 std::get ,其中 N 是元素的序号(从 0 开始)。 与定义结构体相比,通过 std::pair 的 first 和 second 还是 std::tuple 的 std::get 方法来获取元素子属性,这些代码都是非常难以维护的,其根本原因是 first 和 second 这样的命名不能做到见名知意。 C++17 引入的结构化绑定(Structured Binding )将我们从这类代码中解放出来。结构化绑定使用语法如下: auto [a, b, c, ...] = expression; auto [a, b, c, ...] { expression }; auto [a, b, c, ...] ( expression ); 右边的 expression 可以是一个函数调用、花括号表达式或者支持结构化绑定的某个类型的变量。例如: //形式1 auto [iterator, inserted] = someMap.insert(...); //形式2 double myArray[3] = { 1.0, 2.0, 3.0 }; auto [a, b, c] = myArray; //形式3 struct Point { double x; double y; }; Point myPoint(10.0, 20.0); auto [myX, myY] = myPoint; 这样,我们可以给用于绑定到目标的变量名(语法中的 a、b、c)起一个有意义的名字。 需要注意的是,绑定名称 a、b、c 是绑定目标的一份拷贝,当绑定类型不是基础数据类型时,如果你的本意不是想要得到绑定目标的副本,为了避免拷贝带来的不必要开销,建议使用引用,如果不需要修改绑定目标建议使用 const 引用。示例如下: double myArray[3] = { 1.0, 2.0, 3.0 }; auto& [a, b, c] = myArray; //形式3 struct Point { double x; double y; }; Point myPoint(10.0, 20.0); const auto& [myX, myY] = myPoint; 结构化绑定(Structured Binding )是 C++17 引入的一个非常好用的语法特性。有了这种语法,在遍历像 map 这样的容器时,我们可以使用更简洁和清晰的代码去遍历这些容器了: std::map cities; cities[\"beijing\"] = 0; cities[\"shanghai\"] = 1; cities[\"shenzhen\"] = 2; cities[\"guangzhou\"] = 3; for (const auto& [cityName, cityNumber] : cities) { std::cout 上述代码中 cityName 和 cityNumber 可以更好地反映出这个 map 容器的元素内容。 我们再来看一个例子,某 WebSocket 网络库(https://github.com/uNetworking/uWebSockets)中有如下代码: std::pair uncork(const char *src = nullptr, int length = 0, bool optionally = false) { LoopData *loopData = getLoopData(); if (loopData->corkedSocket == this) { loopData->corkedSocket = nullptr; if (loopData->corkOffset) { /* Corked data is already accounted for via its write call */ auto [written, failed] = write(loopData->corkBuffer, loopData->corkOffset, false, length); loopData->corkOffset = 0; if (failed) { /* We do not need to care for buffering here, write does that */ return {0, true}; } } /* We should only return with new writes, not things written to cork already */ return write(src, length, optionally, 0); } else { /* We are not even corked! */ return {0, false}; } } 代码的第 9 行 write 函数返回类型是 std::pair,被绑定到 [written, failed] 这两个变量中去。前者在写入成功的情况下表示实际写入的字节数,后者表示是否写入成功。 std::pair write(const char *src, int length, bool optionally = false, int nextLength = 0) { //具体实现省略... } 结构化绑定的限制 结构化绑定不能使用 constexpr 修饰或被申明为 static,例如: //正常编译 auto [first, second] = std::pair(1, 2); //无法编译通过 //constexpr auto [first, second] = std::pair(1, 2); //无法编译通过 //static auto [first, second] = std::pair(1, 2); 注意:有些编译器也不支持在 lamda 表达式捕获列表中使用结构化绑定语法。 如需下载本站全部技术文章,可以在【高性能服务器开发】公众号回复关键字“文章下载”即可。最近更新时间: 2020-09-15 21:52:36 "},"articles/C++必知必会的知识点/C++必须掌握的pimpl惯用法.html":{"url":"articles/C++必知必会的知识点/C++必须掌握的pimpl惯用法.html","title":"C++必须掌握的pimpl惯用法","keywords":"","body":"pimpl 惯用法 现在这里有一个名为 CSocketClient 的网络通信类,定义如下: /** * 网络通信的基础类, SocketClient.h * zhangyl 2017.07.11 */ class CSocketClient { public: CSocketClient(); ~CSocketClient(); public: void SetProxyWnd(HWND hProxyWnd); bool Init(CNetProxy* pNetProxy); bool Uninit(); int Register(const char* pszUser, const char* pszPassword); void GuestLogin(); BOOL IsClosed(); BOOL Connect(int timeout = 3); void AddData(int cmd, const std::string& strBuffer); void AddData(int cmd, const char* pszBuff, int nBuffLen); void Close(); BOOL ConnectServer(int timeout = 3); BOOL SendLoginMsg(); BOOL RecvLoginMsg(int& nRet); BOOL Login(int& nRet); private: void LoadConfig(); static UINT CALLBACK SendDataThreadProc(LPVOID lpParam); static UINT CALLBACK RecvDataThreadProc(LPVOID lpParam); bool Send(); bool Recv(); bool CheckReceivedData(); void SendHeartbeatPackage(); private: SOCKET m_hSocket; short m_nPort; char m_szServer[64]; long m_nLastDataTime; //最近一次收发数据的时间 long m_nHeartbeatInterval; //心跳包时间间隔,单位秒 CRITICAL_SECTION m_csLastDataTime; //保护m_nLastDataTime的互斥体 HANDLE m_hSendDataThread; //发送数据线程 HANDLE m_hRecvDataThread; //接收数据线程 std::string m_strSendBuf; std::string m_strRecvBuf; HANDLE m_hExitEvent; bool m_bConnected; CRITICAL_SECTION m_csSendBuf; HANDLE m_hSemaphoreSendBuf; HWND m_hProxyWnd; CNetProxy* m_pNetProxy; int m_nReconnectTimeInterval; //重连时间间隔 time_t m_nLastReconnectTime; //上次重连时刻 CFlowStatistics* m_pFlowStatistics; }; 这段代码来源于笔者实际项目中开发的一个股票客户端的软件。 CSocketClient 类的 public 方法提供对外接口供第三方使用,每个函数的具体实现在 SocketClient.cpp 中,对第三方使用者不可见。在 Windows 系统上作为提供给第三方使用的库,一般需要提供给使用者 *.h、*.lib 和 *.dll 文件,在 Linux 系统上需要提供 *.h、.a 或 .so 文件。 不管是在哪个操作系统平台上,像 SocketClient.h 这样的头文件提供给第三方使用时,都会让库的作者心里隐隐不安——因为 SocketClient.h 文件中 SocketClient 类大量的成员变量和私有函数暴露了这个类太多的实现细节,很容易让使用者看出实现原理。这样的头文件,对于一些不想对使用者暴露核心技术实现的库和 sdk,是非常不好的。 那有没有什么办法既能保持对外的接口不变,又能尽量不暴露一些关键性的成员变量和私有函数的实现方法呢?有的。我们可以将代码稍微修改一下: /** * 网络通信的基础类, SocketClient.h * zhangyl 2017.07.11 */ class Impl; class CSocketClient { public: CSocketClient(); ~CSocketClient(); public: void SetProxyWnd(HWND hProxyWnd); bool Init(CNetProxy* pNetProxy); bool Uninit(); int Register(const char* pszUser, const char* pszPassword); void GuestLogin(); BOOL IsClosed(); BOOL Connect(int timeout = 3); void AddData(int cmd, const std::string& strBuffer); void AddData(int cmd, const char* pszBuff, int nBuffLen); void Close(); BOOL ConnectServer(int timeout = 3); BOOL SendLoginMsg(); BOOL RecvLoginMsg(int& nRet); BOOL Login(int& nRet); private: Impl* m_pImpl; }; 上述代码中,所有的关键性成员变量已经没有了,取而代之的是一个类型为 Impl 的指针成员变量 m_pImpl。 具体采用什么名称,读者完全可以根据自己的实际情况来定,不一定非要使用这里的 Impl 和 m_pImpl。 Impl 类型现在是完全对使用者透明,为了在当前类中可以使用 Impl,使用了一个前置声明: //原代码第5行 class Impl; 然后我们就可以将刚才隐藏的成员变量放到这个类中去: class Impl { public: Impl() { //TODO: 你可以在这里对成员变量做一些初始化工作 } ~Impl() { //TODO: 你可以在这里做一些清理工作 } public: SOCKET m_hSocket; short m_nPort; char m_szServer[64]; long m_nLastDataTime; //最近一次收发数据的时间 long m_nHeartbeatInterval; //心跳包时间间隔,单位秒 CRITICAL_SECTION m_csLastDataTime; //保护m_nLastDataTime的互斥体 HANDLE m_hSendDataThread; //发送数据线程 HANDLE m_hRecvDataThread; //接收数据线程 std::string m_strSendBuf; std::string m_strRecvBuf; HANDLE m_hExitEvent; bool m_bConnected; CRITICAL_SECTION m_csSendBuf; HANDLE m_hSemaphoreSendBuf; HWND m_hProxyWnd; CNetProxy* m_pNetProxy; int m_nReconnectTimeInterval; //重连时间间隔 time_t m_nLastReconnectTime; //上次重连时刻 CFlowStatistics* m_pFlowStatistics; }; 接着我们在 CSocketClient 的构造函数中创建这个 m_pImpl 对象,在 CSocketClient 析构函数中释放这个对象。 CSocketClient::CSocketClient() { m_pImpl = new Impl(); } CSocketClient::~CSocketClient() { delete m_pImpl; } 这样,原来需要引用的成员变量,可以在 CSocketClient 内部使用 m_pImpl->变量名 来引用了。 这里仅仅以演示隐藏 CSocketClient 的成员变量为例,隐藏其私有方法与此类似,都是变成类 Impl 的方法。 需要强调的是,在实际开发中,由于 Impl 类是 CSocketClient 的辅助类, Impl 类没有独立存在的必要,所以一般会将 Impl 类定义成 CSocketClient 的内部类。即采用如下形式: /** * 网络通信的基础类, SocketClient.h * zhangyl 2017.07.11 */ class CSocketClient { public: CSocketClient(); ~CSocketClient(); //重复的代码省略... private: class Impl; Impl* m_pImpl; }; 然后在 ClientSocket.cpp 中定义 Impl 类的实现: /** * 网络通信的基础类, SocketClient.cpp * zhangyl 2017.07.11 */ class CSocketClient::Impl { public: void LoadConfig() { //方法的具体实现 } //其他方法省略... public: SOCKET m_hSocket; short m_nPort; char m_szServer[64]; long m_nLastDataTime; //最近一次收发数据的时间 long m_nHeartbeatInterval; //心跳包时间间隔,单位秒 CRITICAL_SECTION m_csLastDataTime; //保护m_nLastDataTime的互斥体 HANDLE m_hSendDataThread; //发送数据线程 HANDLE m_hRecvDataThread; //接收数据线程 std::string m_strSendBuf; std::string m_strRecvBuf; HANDLE m_hExitEvent; bool m_bConnected; CRITICAL_SECTION m_csSendBuf; HANDLE m_hSemaphoreSendBuf; HWND m_hProxyWnd; CNetProxy* m_pNetProxy; int m_nReconnectTimeInterval; //重连时间间隔 time_t m_nLastReconnectTime; //上次重连时刻 CFlowStatistics* m_pFlowStatistics; } CSocketClient::CSocketClient() { m_pImpl = new Impl(); } CSocketClient::~CSocketClient() { delete m_pImpl; } 现在CSocketClient 这个类除了保留对外的接口以外,其内部实现用到的变量和方法基本上对使用者不可见了。C++ 中对类的这种封装方式,我们称之为 pimpl 惯用法,即 Pointer to Implementation (也有人认为是 Private Implementation)。 在实际的开发中,Impl 类的声明和定义既可以使用 class 关键字也可以使用 struct 关键字。在 C++ 语言中,struct 类型可以定义成员方法,但 struct 所有成员变量和方法默认都是 public 的。 现在来总结一下这个方法的优点: 核心数据成员被隐藏; 核心数据成员被隐藏,不必暴露在头文件中,对使用者透明,提高了安全性。 降低编译依赖,提高编译速度; 由于原来的头文件的一些私有成员变量可能是非指针非引用类型的自定义类型,需要在当前类的头文件中包含这些类型的头文件,使用了 pimpl 惯用法以后,这些私有成员变量被移动到当前类的 cpp 文件中,因此头文件不再需要包含这些成员变量的类型头文件,当前头文件变“干净”,这样其他文件在引用这个头文件时,依赖的类型变少,加快了编译速度。 接口与实现分离。 使用了 pimpl 惯用法之后,即使 CSocketClient 或者 Impl 类的实现细节发生了变化,对使用者都是透明的,对外的 CSocketClient 类声明仍然可以保持不变。例如我们可以增删改 Impl 的成员变量和成员方法而保持 SocketClient.h 文件内容不变;如果不使用 pimpl 惯用法,我们做不到不改变 SocketClient.h 文件而增删改 CSocketClient 类的成员。 智能指针用于 pimpl 惯用法 C++ 11 标准引入了智能指针对象,我们可以使用 std::unique_ptr 对象来管理上述用于隐藏具体实现的 m_pImpl 指针。 SocketClient.h 文件可以修改成如下方式: #include //for std::unique_ptr class CSocketClient { public: CSocketClient(); ~CSocketClient(); //重复的代码省略... private: struct Impl; std::unique_ptr m_pImpl; }; SocketClient.cpp 中修改 CSocketClient 对象的构造函数和析构函数的实现如下: 构造函数 如果你的编译器仅支持 C++ 11 标准,我们可以按如下修改: CSocketClient::CSocketClient() { //C++11 标准并未提供 std::make_unique(),该方法是 C++14 提供的 m_pImpl.reset(new Impl()); } 如果你的编译器支持 C++14 及以上标准,可以这么修改: CSocketClient::CSocketClient() : m_pImpl(std::make_unique()) { } 由于已经使用了智能指针来管理 m_pImpl 指向的堆内存,因此析构函数中不再需要显式释放堆内存: CSocketClient::~CSocketClient() { //不再需要显式 delete 了 //delete m_pImpl; } pimp 惯用法是 C/C++ 项目开发中一种非常实用的代码编写策略,建议读者掌握它。 如需下载本站全部技术文章,可以在【高性能服务器开发】公众号回复关键字“文章下载”即可。最近更新时间: 2020-09-15 13:40:44 "},"articles/C++必知必会的知识点/用VisualStudio调试Linux程序.html":{"url":"articles/C++必知必会的知识点/用VisualStudio调试Linux程序.html","title":"用Visual Studio调试Linux程序","keywords":"","body":"用Visual Studio调试Linux程序 用Visual Studio调试Linux程序?你真的没看错,这个是真的,不是标题党。当然如果你说VS2015及以上版本自带的Linux调试插件,那就算了。这些自带的插件调试一个有简单的main函数程序还凑合,稍微复杂点的程序,根本无法编译调试。 而本文介绍的主角是VS的另外一款插件Visual GDB,让我们欢迎主角登场,下面是正文。 使用Visual Studio+VisualGDB调试远程Linux程序 需要工具: Visual Studio 2013或以上版本(以下简称VS) VisualGDB(一款VS插件,官网为:http://visualgdb.com/) 含有调试符号的Linux程序文件(该程序文件为调试目标) Visual Assistant(番茄助手,另外一款VS插件) 在VS上安装完VisualGDB插件以后,有如下几种方式来对远程Linux机器上的程序进行调试: 方法一、如果该程序已经启动,则可以使用VS菜单【Debug】->【Attach to Process...】。 这种方法有个缺点是,不能从开始启动的main函数处添加断点,自始至终地调试程序,查看完整程序运行脉络,所以下面推荐方法二。 方法二、利用VS启动远程Linux机器上一个Linux程序文件进行调试。选择VS菜单【Debug】 ->【Quick Debugwith GDB】。 需要注意的地方,已经在上图中标红框。这里简单地解释一下: 如果你安装了交叉编译环境Target可以选择MinGW/Cygwin,否则就选择远程Linux系统。这里如果不存在一个ssh连接,则需要创建一个。 Debugged program是需要设置的被调试程序的路径,位于远程Linux机器上。 Arguments是该调试程序需要设置的命令行参数,如果被调试程序不需要命令行参数可以不设置。 Working directory是被调试程序运行的工作目录。 另外建议勾选上Initial breakpoint in main,这样启动调试时,程序就会停在程序入口处。 这样,我们就可以利用VS强大的功能去查看程序的各种状态了,常用的面板,如【内存】【线程】【观察】【堆栈】【GDB Session】【断点】等窗口位于VS 菜单【Debug】->【Windows】菜单下,注意,有些窗口只有在调试状态下才可见。这里有两个值得强调一下的功能是: GDB Session**窗口**,在这个窗口里面可以像原来直接使用gdb调试一样输入gdb指令来进行调试。 SSH console**窗口**,这个窗口类似一个远程操作Linux系统的应用程序如xshell、SecureCRT。 现在还剩下一个问题,就是我们虽然在调试时可视化地远程查看一个Linux进程的状态信息,但很多类型的定义和什么却无法看到。解决这个问题的方法就是你可以先在VS里面建立一个工程,导入你要调试的程序的源代码目录。然后利用方法一或者方法二去启动调试程序。这个时候你想查看某个类型的定义或什么只要利用Visual Assit的查看源码功能即可,快捷键是Alt + G。 需要注意的时:同时安装了Visual Assist和VisualGDB后,后者也会提供一个go按钮去查找源码定义,但这个功能远不如Visual Assist按钮好用,我们可以禁用掉它来使用Visual Assist的Go功能。禁用方法,打开菜单:【Tools】->【Option...】: 然后重启VS即可。 到这里,既可以查看源码,也可以调试程序了。 VisualGDB 下载地址: 链接:https://share.weiyun.com/57aGHLM 密码:kj9ahs 如需下载本站全部技术文章,可以在【高性能服务器开发】公众号回复关键字“文章下载”即可。最近更新时间: 2020-09-15 21:51:56 "},"articles/C++必知必会的知识点/如何使用VisualStudio管理和阅读开源项目代码.html":{"url":"articles/C++必知必会的知识点/如何使用VisualStudio管理和阅读开源项目代码.html","title":"如何使用Visual Studio管理和阅读开源项目代码","keywords":"","body":"如何使用 Visual Studio 管理和阅读开源项目代码 对于 Linux C/C++ 项目,虽然我们在 Linux 系统中使用 gdb 去调试,但是通常情况下对于 C/C++ 项目笔者一般习惯使用 Visual Studio 去做项目管理,Visual Studio 提供了强大的 C/C++ 项目开发和管理能力。这里以 redis 源码为例,介绍一下如何将这种开源项目整体添加到 Visual Studio 的解决方案中去。 启动 Visual Studio 新建一个空的 Win32 控制台程序。(工程建好后,关闭该工程防止接下来的步骤中文件占用导致的无法移动。) \\2. 这样会在 redis 源码目录下会根据你设置的名称生成一个文件夹(这里是 redis-4.0.1),将该文件夹中所有文件拷贝到 redis 源码根目录,然后删掉生成的这个文件夹。 \\3. 再次用 Visual Studio 打开 redis-4.0.1.sln 文件,然后在解决方案资源管理器视图中点击显示所有文件按钮并保持该按钮选中。(如果找不到解决方案资源管理器视图,可以在【视图】菜单中打开,快捷键 Ctrl + Alt + L。) \\4. 然后选中所有需要添加到解决方案中的文件,右键选择菜单【包括在项目中】即可,如果文件比较多,Visual Studio 可能需要一会儿才能完成,为了减少等待时间,读者也可以一批一批的添加。 5.接着选择【文件】菜单【全部保存】菜单项保存即可(快捷键 Ctrl + Shift + S )。 最终效果如下图所示: 这样我们就能利用 Visual Studio 强大的功能管理和阅读我们的源码了。 这里要提醒一下读者:C/C++ 开源项目中一般会使用各种宏去条件编译一些代码,实际生成的二进制文件中不一定包含这些代码,所以在 Visual Studio 中看到某段代码的行号与实际在 gdb 中调试的代码行号不一定相同,在给某一行代码设置断点时请以 gdb 中 list 命令看到的代码行号为准。 如需下载本站全部技术文章,可以在【高性能服务器开发】公众号回复关键字“文章下载”即可。最近更新时间: 2020-09-15 20:48:23 "},"articles/C++必知必会的知识点/利用cmake工具生成VisualStudio工程文件.html":{"url":"articles/C++必知必会的知识点/利用cmake工具生成VisualStudio工程文件.html","title":"利用cmake工具生成Visual Studio工程文件","keywords":"","body":"利用 cmake 工具生成 Visual Studio 工程文件 对于习惯了 Visual Studio 强大的管理项目、编码和调试功能的读者来说,在 Linux 下使用 gcc/g++ 编译、使用 gdb 调试是一件何其痛苦的事情,对于大多数的开源 C/C++ 项目,如果我们不在意 Windows 和 Linux 在一些底层 API 接口上的使用差别,想熟悉该项目的执行脉络和原理,在 Windows 上使用 Visual Studio 调试该项目也未尝不可。凡是可以使用 CMake 工具编译的 Linux 程序(即提供了 CMakeLists.txt 文件),我们同样也可以利用 CMake 工具生成 Windows 上的 Visual Studio 工程文件。 这里我们以著名的开源网络库 libuv 为例。 从 libuv 的官方地址提供的下载链接:https://dist.libuv.org/dist/ 下载最新的 libuv 的源码得到文件 libuv-v1.31.0.tar.gz(笔者写作此书时,libuv 最新版本是 1.31.0),解压该文件。作者的机器上我将代码解压至 F:\\mycode\\libuv-v1.31.0\\ ,解压后的目录中确实存在一个 CMakeLists.txt 文件,如下图所示: 启动 Windows 上的 CMake 图形化工具(cmake-gui),按下图进行设置: 设置完成之后,点击界面上的Configure 按钮,会提示 vsprojects 目录不存在,提示是否创建,我们点击 Yes 进行创建。 如果您的机器上安装了多个版本的Visual Studio,接下来会弹窗对话框让我们选择要生成的工程文件对应的 Visual Studio 版本号。读者可以根据自己的实际情况按需选择。我这里选择 Visual Studio 2019。 点击 Finish 按钮后开始启动 CMake 的检测和配置工作。等待一会儿,CMake 底部的输出框中提示 “Configuring Done” 表示配置工作已经完成。 接下来点击 Generate 按钮即可生成所选版本的 Visual Studio 工程文件,生成的文件位于 vsprojects 目录。 我们可以在界面上点击按钮 Open Project 按钮直接打开工程文件,也可以找到对应目录下的 libuv.sln 打开。 打开后如下图所示: 接下来,我们就可以使用 Visual Studio 愉快地进行编译和调试了。 让我们再深入聊一下上述过程:在点击 Configure 按钮之后,和在 Linux 下执行 cmake 命令一样,CMake 工具也是在检测所在的系统环境是否匹配 CMakeLists.txt 中定义的各种环境,本质上是生成了一份可以在 Windows 上编译和运行的代码(也就是说该源码支持在 Windows 上运行) 。因此,对于很多虽然提供了 CMakeLists.txt 文件但并不支持在 Windows 上运行的的 Linux 工程,虽然利用上述方法也能最终生成 Visual Studio 工程文件,但是这些文件并不能在 Windows 上直接无错编译和调试。 由于不同的 CMake 版本支持的 CMakeLists.txt 中的语法可能略有细微差别,有些 CMakeLists.txt 文件在使用上述方法 configure 时可能会产生一些错误,需要读者做些修改才能通过。 如需下载本站全部技术文章,可以在【高性能服务器开发】公众号回复关键字“文章下载”即可。最近更新时间: 2020-09-15 20:14:35 "},"articles/多线程/":{"url":"articles/多线程/","title":"多线程","keywords":"","body":"多线程 后台C++开发你一定要知道的条件变量 整型变量赋值是原子操作吗? 如需下载本站全部技术文章,可以在【高性能服务器开发】公众号回复关键字“文章下载”即可。最近更新时间: 2020-10-08 11:50:07 "},"articles/多线程/后台C++开发你一定要知道的条件变量.html":{"url":"articles/多线程/后台C++开发你一定要知道的条件变量.html","title":"后台C++开发你一定要知道的条件变量","keywords":"","body":"后台C++开发你一定要知道的条件变量 今天因为工作需要,需要帮同事用C语言(不是C++)写一个生产者消费者的任务队列工具库,考虑到不能使用任何第三库和C++的任何特性,所以我将任务队列做成一个链表,生产者在队列尾部加入任务,消费者在队列头部取出任务。很快就写好了,代码如下: /** * 线程池工具, ctrip_thread_pool.h * zhangyl 2018.03.23 */ #ifndef __CTRIP_THREAD_POOL_H__ #define __CTRIP_THREAD_POOL_H__ #include #ifndef NULL #define NULL 0 #endif #define PUBLIC PUBLIC struct ctrip_task { struct ctrip_task* pNext; int value; }; struct ctrip_thread_info { //线程退出标志 int thread_running; int thread_num; int tasknum; struct ctrip_task* tasks; pthread_t* threadid; pthread_mutex_t mutex; pthread_cond_t cond; }; /* 初始化线程池线程数目 * @param thread_num 线程数目, 默认为8个 */ PUBLIC void ctrip_init_thread_pool(int thread_num); /* 销毁线程池 */ PUBLIC void ctrip_destroy_thread_pool(); /**向任务池中增加一个任务 * @param t 需要增加的任务 */ PUBLIC void ctrip_thread_pool_add_task(struct ctrip_task* t); /**从任务池中取出一个任务 * @return 返回得到的任务 */ struct ctrip_task* ctrip_thread_pool_retrieve_task(); /**执行任务池中的任务 * @param t 需要执行的任务 */ PUBLIC void ctrip_thread_pool_do_task(struct ctrip_task* t); /**线程函数 * @param thread_param 线程参数 */ void* ctrip_thread_routine(void* thread_param); #endif //!__CTRIP_THREAD_POOL_H__ /** * 线程池工具, ctrip_thread_pool.c * zhangyl 2018.03.23 */ #include \"ctrip_thread_pool.h\" #include #include struct ctrip_thread_info g_threadinfo; int thread_running = 0; void ctrip_init_thread_pool(int thread_num) { if (thread_num pNext != NULL) { head = head->pNext; } head->pNext = t; } ++g_threadinfo.tasknum; //当有变化后,使用signal通知wait函数 pthread_cond_signal(&g_threadinfo.cond); pthread_mutex_unlock(&g_threadinfo.mutex); } struct ctrip_task* ctrip_thread_pool_retrieve_task() { struct ctrip_task* head = g_threadinfo.tasks; if (head != NULL) { g_threadinfo.tasks = head->pNext; --g_threadinfo.tasknum; printf(\"retrieve a task, task value is [%d]\\n\", head->value); return head; } printf(\"no task\\n\"); return NULL; } void* ctrip_thread_routine(void* thread_param) { printf(\"thread NO.%d start.\\n\", (int)pthread_self()); while (thread_running/*g_threadinfo.thread_running*/) { struct ctrip_task* current = NULL; pthread_mutex_lock(&g_threadinfo.mutex); while (g_threadinfo.tasknum value); //TODO:如果t需要释放,记得在这里释放 } 测试代码如下: // ctrip_thread_pool.cpp : Defines the entry point for the console application. // //#include \"stdafx.h\" #include \"ctrip_thread_pool.h\" #include #include int main(int argc, char* argv[]) { ctrip_init_thread_pool(5); struct ctrip_task* task = NULL; int i; for (i = 0; i value = i + 1; task->pNext = NULL; printf(\"add task, task value [%d]\\n\", task->value); ctrip_thread_pool_add_task(task); } sleep(10); ctrip_destroy_thread_pool(); return 0; } 代码很快就写好了,但是每次程序只能执行前几个加到任务池子里面的任务,导致池子有不少任务积累在池子里面。甚是奇怪,我也看了半天才看出结果。聪明的你,能看出上述代码为啥只能执行加到池子里面的前几个任务?先不要看答案,自己想一会儿。 linux条件变量是做后台开发必须熟练掌握的基础知识,而条件变量使用存在以下几个非常让人迷惑的地方,讲解如下 第一、必须要结合一个互斥体一起使用。使用流程如下: pthread_mutex_lock(&g_threadinfo.mutex) pthread_cond_wait(&g_threadinfo.cond, &g_threadinfo.mutex); pthread_mutex_unlock(&g_threadinfo.mutex); 上面的代码,我们分为一二三步,当条件不满足是pthread_cond_wait会挂起线程,但是不知道你有没有注意到,如果在第二步挂起线程的话,第一步的mutex已经被上锁,谁来解锁?mutex的使用原则是谁上锁谁解锁,所以不可能在其他线程来给这个mutex解锁,但是这个线程已经挂起了,这就死锁了。所以pthread_cond_wait在挂起之前,额外做的一个事情就是给绑定的mutex解锁。反过来,如果条件满足,pthread_cond_wait不挂起线程,pthread_cond_wait将什么也不做,这样就接着走pthread_mutex_unlock解锁的流程。而在这个加锁和解锁之间的代码就是我们操作受保护资源的地方。 第二,不知道你有没有注意到pthread_cond_wait是放在一个while循环里面的: pthread_mutex_lock(&g_threadinfo.mutex); while (g_threadinfo.tasknum 注意,我说的是内层的while循环,不是外层的。pthread_cond_wait一定要放在一个while循环里面吗?一定要的。这里有一个非常重要的关于条件变量的基础知识,叫条件变量的虚假唤醒(spurious wakeup),那啥叫条件变量的虚假唤醒呢?假设pthread_cond_wait不放在这个while循环里面,正常情况下,pthread_cond_wait因为条件不满足,挂起线程。然后,外部条件满足以后,调用pthread_cond_signal或pthread_cond_broadcast来唤醒挂起的线程。这没啥问题。但是条件变量可能在某些情况下也被唤醒,这个时候pthread_cond_wait处继续往下执行,但是这个时候,条件并不满足(比如任务队列中仍然为空)。这种唤醒我们叫“虚假唤醒”。为了避免虚假唤醒时,做无意义的动作,我们将pthread_cond_wait放到while循环条件中,这样即使被虚假唤醒了,由于while条件(比如任务队列是否为空,资源数量是否大于0)仍然为true,导致线程进行继续挂起。有人说条件变量是最不可能用错的线程之间同步技术,我却觉得这是最容易使用错误的线程之间同步技术。 上述代码存在的问题是,只考虑了任务队列开始为空,生产者后来添加了任务,条件变量被唤醒,然后消费者取任务执行的逻辑。假如一开始池中就有任务呢?这个原因导致,只有开始的几个添加到任务队列中任务被执行。因为一旦任务队列不为空。内层while循环条件将不再满足,导致消费者线程不再从任务队列中取任务消费。正确的代码如下: /** * 线程池工具, ctrip_thread_pool.c(修正后的代码) * zhangyl 2018.03.23 */ #include \"ctrip_thread_pool.h\" #include #include struct ctrip_thread_info g_threadinfo; void ctrip_init_thread_pool(int thread_num) { if (thread_num pNext != NULL) { head = head->pNext; } head->pNext = t; } ++g_threadinfo.tasknum; //当有变化后,使用signal通知wait函数 pthread_cond_signal(&g_threadinfo.cond); pthread_mutex_unlock(&g_threadinfo.mutex); } struct ctrip_task* ctrip_thread_pool_retrieve_task() { struct ctrip_task* head = g_threadinfo.tasks; if (head != NULL) { g_threadinfo.tasks = head->pNext; --g_threadinfo.tasknum; printf(\"retrieve a task, task value is [%d]\\n\", head->value); return head; } printf(\"no task\\n\"); return NULL; } void* ctrip_thread_routine(void* thread_param) { printf(\"thread NO.%d start.\\n\", (int)pthread_self()); while (g_threadinfo.thread_running) { struct ctrip_task* current = NULL; pthread_mutex_lock(&g_threadinfo.mutex); while (g_threadinfo.tasknum value); //TODO:如果t需要释放,记得在这里释放 } ok,不知道你有没有看明白呀? 如需下载本站全部技术文章,可以在【高性能服务器开发】公众号回复关键字“文章下载”即可。最近更新时间: 2020-06-06 22:25:56 "},"articles/多线程/整型变量赋值是原子操作吗?.html":{"url":"articles/多线程/整型变量赋值是原子操作吗?.html","title":"整型变量赋值是原子操作吗?","keywords":"","body":"整型变量赋值是原子操作吗? 整型变量赋值操作不是原子操作 那么为什么整型变量的操作不是原子性的呢?常见的整型变量操作有如下几种情况: 给整型变量赋值一个确定的值,如 int a = 1; 这条指令操作一般是原子的,因为对应着一条计算机指令,cpu将立即数1搬运到变量a的内存地址中即可,汇编指令如下: mov dword ptr [a], 2 然后这确是最不常见的情形,由于现代编译器一般有优化策略,如果变量a的值在编译期间就可以计算出来(例如这里的例子中a的值就是1),那么a这个变量本身在正式版本的软件中(release版)就很有可能被编译器优化掉,使用a的地方,直接使用常量1来代替。所以实际的执行指令中,这样的指令存在的可能性比较低。 变量自身增加或者减去一个值,如 a ++; 从C/C++语法的级别来看,这是一条语句,是原子的;但是从实际执行的二进制指令来看,也不是原子的,其一般对应三条指令,首先将变量a对应的内存值搬运到某个寄存器(如eax)中,然后将该寄存器中的值自增1,再将该寄存器中的值搬运回a的内存中: mov eax, dword ptr [a] inc eax mov dword ptr [a], eax 现在假设a的值是0,有两个线程,每个线程对变量a的值递增1,我们预想的结果应该是2,可实际运行的结果可能是1!是不是很奇怪?分析如下: int a = 0; //线程1 void thread_func1() { a ++; } //线程2 void thread_func2() { a ++; } 我们预想的结果是线程1和线程2的三条指令各自执行,最终a的值为2,但是由于操作系统线程调度的不确定性,线程1执行完指令①和②后,eax寄存器中的值为1,此时操作系统切换到线程2执行,执行指令③④⑤,此时eax的值变为1;接着操作系统切回线程1继续执行,执行指令⑦,得到a的最终结果1。 把一个变量的值赋值给另外一个变量,或者把一个表达式的值赋值给另外一个变量,如 int a = b; 从C/C++语法的级别来看,这是也是一条语句,是原子的;但是从实际执行的二进制指令来看,由于现代计算机CPU架构体系的限制,数据不可以直接从内存搬运到另外一块内存,必须借助寄存器中断,这条语句一般对应两条计算机指令,即将变量b的值搬运到某个寄存器(如eax)中,再从该寄存器搬运到变量a的内存地址: mov eax, dword ptr [b] mov dword ptr [a], eax 既然是两条指令,那么多个线程在执行这两条指令时,某个线程可能会在第一条指令执行完毕后被剥夺CPU时间片,切换到另外一个线程而产生不确定的情况。这和上一种情况类似,就不再详细分析了。 说点题外话,网上很多人强调某些特殊的整型数值类型(如bool类型)的操作是原子的,这是由于,某些CPU生产商开始有意识地从硬件平台保证这一类操作的原子性,但这并不是每一种类型的CPU架构都支持,在这一事实成为标准之前,我们在多线程操作整型时还是老老实实使用下文介绍的原子操作或线程同步技术来对这些数据类型进行保护。 如需下载本站全部技术文章,可以在【高性能服务器开发】公众号回复关键字“文章下载”即可。最近更新时间: 2020-09-15 23:42:16 "},"articles/网络编程/":{"url":"articles/网络编程/","title":"网络编程","keywords":"","body":"网络编程 bind 函数重难点解析 connect 函数在阻塞和非阻塞模式下的行为 select 函数重难点解析 Linux epoll 模型(含LT 模式和 ET 模式详解) socket 的阻塞模式和非阻塞模式 非阻塞模式下 send 和 recv 函数的返回值 服务器开发通信协议设计介绍 TCP 协议如何解决粘包、半包问题 网络通信中收发数据的正确姿势 服务器端发数据时,如果对端一直不收,怎么办? 如需下载本站全部技术文章,可以在【高性能服务器开发】公众号回复关键字“文章下载”即可。最近更新时间: 2020-10-08 11:49:34 "},"articles/网络编程/bind函数重难点解析.html":{"url":"articles/网络编程/bind函数重难点解析.html","title":"bind 函数重难点解析","keywords":"","body":"bind 函数重难点解析 bind 函数如何选择绑定地址 bind 函数的基本用法如下: struct sockaddr_in bindaddr; bindaddr.sin_family = AF_INET; bindaddr.sin_addr.s_addr = htonl(INADDR_ANY); bindaddr.sin_port = htons(3000); if (bind(listenfd, (struct sockaddr *)&bindaddr, sizeof(bindaddr)) == -1) { std::cout 其中 bind 的地址我们使用了一个宏叫 INADDR_ANY ,关于这个宏的解释如下: If an application does not care what local address is assigned, specify the constant value INADDR_ANY for an IPv4 local address or the constant value in6addr_any for an IPv6 local address in the sa_data member of the name parameter. This allows the underlying service provider to use any appropriate network address, potentially simplifying application programming in the presence of multihomed hosts (that is, hosts that have more than one network interface and address). 意译一下: 如果应用程序不关心bind绑定的ip地址,可以使用INADDR_ANY(如果是IPv6, 则对应in6addr_any),这样底层的(协议栈)服务会自动选择一个合适的ip地址, 这样使在一个有多个网卡机器上选择ip地址问题变得简单。 也就是说 INADDR_ANY 相当于地址 0.0.0.0。可能读者还是不太明白我想表达什么。这里我举个例子,假设我们在一台机器上开发一个服务器程序,使用 bind 函数时,我们有多个ip 地址可以选择。首先,这台机器对外访问的ip地址是120.55.94.78,这台机器在当前局域网的地址是192.168.1.104;同时这台机器有本地回环地址127.0.0.1。 如果你指向本机上可以访问,那么你 bind 函数中的地址就可以使用127.0.0.1; 如果你的服务只想被局域网内部机器访问,bind 函数的地址可以使用192.168.1.104;如果 希望这个服务可以被公网访问,你就可以使用地址0.0.0.0或 INADDR_ANY。 bind 函数端口号问题 网络通信程序的基本逻辑是客户端连接服务器,即从客户端的地址:端口连接到服务器地址:端口上,以 4.2 小节中的示例程序为例,服务器端的端口号使用 3000,那客户端连接时的端口号是多少呢?TCP 通信双方中一般服务器端端口号是固定的,而客户端端口号是连接发起时由操作系统随机分配的(不会分配已经被占用的端口)。端口号是一个 C short 类型的值,其范围是0~65535,知道这点很重要,所以我们在编写压力测试程序时,由于端口数量的限制,在某台机器上网卡地址不变的情况下压力测试程序理论上最多只能发起六万五千多个连接。注意我说的是理论上,在实际情况下,由于当时的操作系统很多端口可能已经被占用,实际可以使用的端口比这个更少,例如,一般规定端口号在1024以下的端口是保留端口,不建议用户程序使用。而对于 Windows 系统,MSDN 甚至明确地说: On Windows Vista and later, the dynamic client port range is a value between 49152 and 65535. This is a change from Windows Server 2003 and earlier where the dynamic client port range was a value between 1025 and 5000. Vista 及以后的Windows,可用的动态端口范围是49152~65535,而 Windows Server及更早的系统,可以的动态端口范围是1025~5000。(你可以通过修改注册表来改变这一设置,参考网址:https://docs.microsoft.com/en-us/windows/desktop/api/winsock/nf-winsock-bind) 如果将 bind 函数中的端口号设置成0,那么操作系统会随机给程序分配一个可用的侦听端口,当然服务器程序一般不会这么做,因为服务器程序是要对外服务的,必须让客户端知道确切的ip地址和端口号。 很多人觉得只有服务器程序可以调用 bind 函数绑定一个端口号,其实不然,在一些特殊的应用中,我们需要客户端程序以指定的端口号去连接服务器,此时我们就可以在客户端程序中调用 bind 函数绑定一个具体的端口。 我们用代码来实际验证一下上路所说的,为了能看到连接状态,我们将客户端和服务器关闭socket的代码注释掉,这样连接会保持一段时间。 情形一:客户端代码不绑定端口 修改后的服务器代码如下: /** * TCP服务器通信基本流程 * zhangyl 2018.12.13 */ #include #include #include #include #include #include #include int main(int argc, char* argv[]) { //1.创建一个侦听socket int listenfd = socket(AF_INET, SOCK_STREAM, 0); if (listenfd == -1) { std::cout clientfds; while (true) { struct sockaddr_in clientaddr; socklen_t clientaddrlen = sizeof(clientaddr); //4. 接受客户端连接 int clientfd = accept(listenfd, (struct sockaddr *)&clientaddr, &clientaddrlen); if (clientfd != -1) { char recvBuf[32] = {0}; //5. 从客户端接受数据 int ret = recv(clientfd, recvBuf, 32, 0); if (ret > 0) { std::cout 修改后的客户端代码如下: /** * TCP客户端通信基本流程 * zhangyl 2018.12.13 */ #include #include #include #include #include #include #define SERVER_ADDRESS \"127.0.0.1\" #define SERVER_PORT 3000 #define SEND_DATA \"helloworld\" int main(int argc, char* argv[]) { //1.创建一个socket int clientfd = socket(AF_INET, SOCK_STREAM, 0); if (clientfd == -1) { std::cout 0) { std::cout 将程序编译好后(编译方法和上文一样),我们先启动server,再启动三个客户端。然后通过 lsof 命令查看当前机器上的 TCP 连接信息,为了更清楚地显示结果,已经将不相关的连接信息去掉了,结果如下所示: [root@localhost ~]# lsof -i -Pn COMMAND PID USER FD TYPE DEVICE SIZE/OFF NODE NAME server 1445 root 3u IPv4 21568 0t0 TCP *:3000 (LISTEN) server 1445 root 4u IPv4 21569 0t0 TCP 127.0.0.1:3000->127.0.0.1:40818 (ESTABLISHED) server 1445 root 5u IPv4 21570 0t0 TCP 127.0.0.1:3000->127.0.0.1:40820 (ESTABLISHED) server 1445 root 6u IPv4 21038 0t0 TCP 127.0.0.1:3000->127.0.0.1:40822 (ESTABLISHED) client 1447 root 3u IPv4 21037 0t0 TCP 127.0.0.1:40818->127.0.0.1:3000 (ESTABLISHED) client 1448 root 3u IPv4 21571 0t0 TCP 127.0.0.1:40820->127.0.0.1:3000 (ESTABLISHED) client 1449 root 3u IPv4 21572 0t0 TCP 127.0.0.1:40822->127.0.0.1:3000 (ESTABLISHED) 上面的结果显示,server 进程(进程 ID 是 1445)在 3000 端口开启侦听,有三个 client 进程(进程 ID 分别是1447、1448、1449)分别通过端口号 40818、40820、40822 连到 server 进程上的,作为客户端的一方,端口号是系统随机分配的。 情形二:客户端绑定端口号 0 服务器端代码保持不变,我们修改下客户端代码: /** * TCP服务器通信基本流程 * zhangyl 2018.12.13 */ #include #include #include #include #include #include #define SERVER_ADDRESS \"127.0.0.1\" #define SERVER_PORT 3000 #define SEND_DATA \"helloworld\" int main(int argc, char* argv[]) { //1.创建一个socket int clientfd = socket(AF_INET, SOCK_STREAM, 0); if (clientfd == -1) { std::cout 0) { std::cout 我们再次编译客户端程序,并启动三个 client 进程,然后用 lsof 命令查看机器上的 TCP 连接情况,结果如下所示: [root@localhost ~]# lsof -i -Pn COMMAND PID USER FD TYPE DEVICE SIZE/OFF NODE NAME server 1593 root 3u IPv4 21807 0t0 TCP *:3000 (LISTEN) server 1593 root 4u IPv4 21808 0t0 TCP 127.0.0.1:3000->127.0.0.1:44220 (ESTABLISHED) server 1593 root 5u IPv4 19311 0t0 TCP 127.0.0.1:3000->127.0.0.1:38990 (ESTABLISHED) server 1593 root 6u IPv4 21234 0t0 TCP 127.0.0.1:3000->127.0.0.1:42365 (ESTABLISHED) client 1595 root 3u IPv4 22626 0t0 TCP 127.0.0.1:44220->127.0.0.1:3000 (ESTABLISHED) client 1611 root 3u IPv4 21835 0t0 TCP 127.0.0.1:38990->127.0.0.1:3000 (ESTABLISHED) client 1627 root 3u IPv4 21239 0t0 TCP 127.0.0.1:42365->127.0.0.1:3000 (ESTABLISHED) 通过上面的结果,我们发现三个 client 进程使用的端口号仍然是系统随机分配的,也就是说绑定 0 号端口和没有绑定效果是一样的。 情形三:客户端绑定一个固定端口 我们这里使用 20000 端口,当然读者可以根据自己的喜好选择,只要保证所选择的端口号当前没有被其他程序占用即可,服务器代码保持不变,客户端绑定代码中的端口号从 0 改成 20000。这里为了节省篇幅,只贴出修改处的代码: struct sockaddr_in bindaddr; bindaddr.sin_family = AF_INET; bindaddr.sin_addr.s_addr = htonl(INADDR_ANY); //将socket绑定到20000号端口上去 bindaddr.sin_port = htons(20000); if (bind(clientfd, (struct sockaddr *)&bindaddr, sizeof(bindaddr)) == -1) { std::cout 再次重新编译程序,先启动一个客户端后,我们看到此时的 TCP 连接状态: [root@localhost testsocket]# lsof -i -Pn COMMAND PID USER FD TYPE DEVICE SIZE/OFF NODE NAME server 1676 root 3u IPv4 21933 0t0 TCP *:3000 (LISTEN) server 1676 root 4u IPv4 21934 0t0 TCP 127.0.0.1:3000->127.0.0.1:20000 (ESTABLISHED) client 1678 root 3u IPv4 21336 0t0 TCP 127.0.0.1:20000->127.0.0.1:3000 (ESTABLISHED) 通过上面的结果,我们发现 client 进程确实使用 20000 号端口连接到 server 进程上去了。这个时候如果我们再开启一个 client 进程,我们猜想由于端口号 20000 已经被占用,新启动的 client 会由于调用 bind 函数出错而退出,我们实际验证一下: [root@localhost testsocket]# ./client bind socket error. [root@localhost testsocket]# 结果确实和我们预想的一样。 在技术面试的时候,有时候面试官会问 TCP 网络通信的客户端程序中的 socket 是否可以调用 bind 函数,相信读到这里,聪明的读者已经有答案了。 另外,Linux 的 nc 命令有个 -p 选项(字母 p 是小写),这个选项的作用就是 nc 在模拟客户端程序时,可以使用指定端口号连接到服务器程序上去,实现原理相信读者也明白了。我们还是以上面的服务器程序为例,这个我们不用我们的 client 程序,改用 nc 命令来模拟客户端。在 shell 终端输入: [root@localhost testsocket]# nc -v -p 9999 127.0.0.1 3000 Ncat: Version 6.40 ( http://nmap.org/ncat ) Ncat: Connected to 127.0.0.1:3000. My name is zhangxf My name is zhangxf -v 选项表示输出 nc 命令连接的详细信息,这里连接成功以后,会输出“Ncat: Connected to 127.0.0.1:3000.” 提示已经连接到服务器的 3000 端口上去了。 -p 选项的参数值是 9999 表示,我们要求 nc 命令本地以端口号 9999 连接服务器,注意不要与端口号 3000 混淆,3000 是服务器的侦听端口号,也就是我们的连接的目标端口号,9999 是我们客户端使用的端口号。我们用 lsof 命令来验证一下我们的 nc 命令是否确实以 9999 端口号连接到 server 进程上去了。 [root@localhost testsocket]# lsof -i -Pn COMMAND PID USER FD TYPE DEVICE SIZE/OFF NODE NAME server 1676 root 3u IPv4 21933 0t0 TCP *:3000 (LISTEN) server 1676 root 7u IPv4 22405 0t0 TCP 127.0.0.1:3000->127.0.0.1:9999 (ESTABLISHED) nc 2005 root 3u IPv4 22408 0t0 TCP 127.0.0.1:9999->127.0.0.1:3000 (ESTABLISHED) 结果确实如我们期望的一致。 当然,我们用 nc 命令连接上 server 进程以后,我们还给服务器发了一条消息\"My name is zhangxf\",server 程序收到消息后把这条消息原封不动地返还给我们,以下是 server 端运行结果: [root@localhost testsocket]# ./server recv data from client, data: My name is zhangxf send data to client successfully, data: My name is zhangxf 关于 lsof 和 nc 命令我们会在后面的系列文章中详细讲解。 如需下载本站全部技术文章,可以在【高性能服务器开发】公众号回复关键字“文章下载”即可。最近更新时间: 2020-09-15 23:47:48 "},"articles/网络编程/connect函数在阻塞和非阻塞模式下的行为.html":{"url":"articles/网络编程/connect函数在阻塞和非阻塞模式下的行为.html","title":"connect 函数在阻塞和非阻塞模式下的行为","keywords":"","body":"connect 函数在阻塞和非阻塞模式下的行为 在 socket 是阻塞模式下 connect 函数会一直到有明确的结果才会返回(或连接成功或连接失败),如果服务器地址“较远”,连接速度比较慢,connect 函数在连接过程中可能会导致程序阻塞在 connect 函数处好一会儿(如两三秒之久),虽然这一般也不会对依赖于网络通信的程序造成什么影响,但在实际项目中,我们一般倾向使用所谓的异步的 connect 技术,或者叫非阻塞的 connect。这个流程一般有如下步骤: 1. 创建socket,并将 socket 设置成非阻塞模式; 2. 调用 connect 函数,此时无论 connect 函数是否连接成功会立即返回;如果返回-1并不表示连接出错,如果此时错误码是EINPROGRESS 3. 接着调用 select 函数,在指定的时间内判断该 socket 是否可写,如果可写说明连接成功,反之则认为连接失败。 按上述流程编写代码如下: /** * 异步的connect写法,nonblocking_connect.cpp * zhangyl 2018.12.17 */ #include #include #include #include #include #include #include #include #include #define SERVER_ADDRESS \"127.0.0.1\" #define SERVER_PORT 3000 #define SEND_DATA \"helloworld\" int main(int argc, char* argv[]) { //1.创建一个socket int clientfd = socket(AF_INET, SOCK_STREAM, 0); if (clientfd == -1) { std::cout 为了区别到底是在调用 connect 函数时判断连接成功还是通过 select 函数判断连接成功,我们在后者的输出内容中加上了“[select]”标签以示区别。 我们先用 nc 命令启动一个服务器程序: nc -v -l 0.0.0.0 3000 然后编译客户端程序并执行: [root@localhost testsocket]# g++ -g -o nonblocking_connect nonblocking_connect.cpp [root@localhost testsocket]# ./nonblocking_connect [select] connect to server successfully. 我们把服务器程序关掉,再重新启动一下客户端,这个时候应该会连接失败,程序输出结果如下: [root@localhost testsocket]# ./nonblocking_connect [select] connect to server successfully. 奇怪?为什么连接不上也会得出一样的输出结果?难道程序有问题?这是因为: 在 Windows 系统上,一个 socket 没有建立连接之前,我们使用 select 函数检测其是否可写,能得到正确的结果(不可写),连接成功后检测,会变为可写。所以,上述介绍的异步 connect 写法流程在 Windows 系统上时没有问题的。 在 Linux 系统上一个 socket 没有建立连接之前,用 select 函数检测其是否可写,你也会得到可写得结果,所以上述流程并不适用于 Linux 系统。正确的做法是,connect 之后,不仅要用 select 检测可写,还要检测此时 socket 是否出错,通过错误码来检测确定是否连接上,错误码为 0 表示连接上,反之为未连接上。完整代码如下: /** * Linux 下正确的异步的connect写法,linux_nonblocking_connect.cpp * zhangyl 2018.12.17 */ #include #include #include #include #include #include #include #include #include #define SERVER_ADDRESS \"127.0.0.1\" #define SERVER_PORT 3000 #define SEND_DATA \"helloworld\" int main(int argc, char* argv[]) { //1.创建一个socket int clientfd = socket(AF_INET, SOCK_STREAM, 0); if (clientfd == -1) { std::cout (sizeof err); if (::getsockopt(clientfd, SOL_SOCKET, SO_ERROR, &err, &len) 当然,在实际的项目中,第 3 个步骤中 Linux 平台上你也可以使用 poll 函数来判断 socket 是否可写;在 Windows 平台上你可以使用 WSAEventSelect 或 WSAAsyncSelect 函数判断连接是否成功,关于这三个函数我们将在后面的章节中详细讲解,这里暂且仅以 select 函数为例。 如需下载本站全部技术文章,可以在【高性能服务器开发】公众号回复关键字“文章下载”即可。最近更新时间: 2020-09-15 23:50:05 "},"articles/网络编程/select函数重难点解析.html":{"url":"articles/网络编程/select函数重难点解析.html","title":"select 函数重难点解析","keywords":"","body":"select 函数重难点解析 select 函数是网络通信编程中非常常用的一个函数,因此应该熟练掌握它。虽然它是 BSD 标准之一的 Socket 函数之一,但在 Linux 和 Windows 平台,其行为表现还是有点区别的。我们先来看一下 Linux 平台上的 select 函数。 Linux 平台下的 select 函数 select 函数的作用是检测一组 socket 中某个或某几个是否有“事件”,这里的“事件”一般分为如下三类: 可读事件,一般意味着可以调用 recv 或 read 函数从该 socket 上读取数据;如果该 socket 是侦听 socket(即调用了 bind 函数绑定过 ip 地址和端口号,并调用了 listen 启动侦听的 socket),可读意味着此时可以有新的客户端连接到来,此时可调用 accept 函数接受新连接。 可写事件,一般意味着此时调用 send 或 write 函数可以将数据“发出去”。 异常事件,某个 socket 出现异常。 函数签名如下: int select(int nfds, fd_set *readfds, fd_set *writefds, fd_set *exceptfds, struct timeval *timeout); 参数说明: 参数 nfds, Linux 下 socket 也称 fd,这个参数的值设置成所有需要使用 select 函数监听的 fd 中最大 fd 值加 1。 参数 readfds,需要监听可读事件的 fd 集合。 参数 writefds,需要监听可写事件的 fd 集合。 参数 exceptfds,需要监听异常事件 fd 集合。 readfds、writefds 和 exceptfds 类型都是 fd_set,这是一个结构体信息,其定义位于 /usr/include/sys/select.h 中: /* The fd_set member is required to be an array of longs. */ typedef long int __fd_mask; /* Some versions of define this macros. */ #undef __NFDBITS /* It's easier to assume 8-bit bytes than to get CHAR_BIT. */ #define __NFDBITS (8 * (int) sizeof (__fd_mask)) #define __FD_ELT(d) ((d) / __NFDBITS) #define __FD_MASK(d) ((__fd_mask) 1 fds_bits) #else // 在我的centOS 7.0 系统中的值: // __FD_SETSIZE = 1024 //__NFDBITS = 64 __fd_mask __fds_bits[__FD_SETSIZE / __NFDBITS]; # define __FDS_BITS(set) ((set)->__fds_bits) #endif } fd_set; /* Maximum number of file descriptors in 'fd_set'. */ #define FD_SETSIZE __FD_SETSIZE 我们假设未定义宏 __USE_XOPEN,将上面的代码整理一下: typedef struct { long int __fds_bits[16]; } fd_set; 将一个 fd 添加到 fd_set 这个集合中需要使用 FD_SET 宏,其定义如下: void FD_SET(int fd, fd_set *set); 其实现如下: #define FD_SET(fd,fdsetp) __FD_SET(fd,fdsetp) FD_SET 在内部又是通过宏 __FD_SET 来实现的,__FD_SET 的定义如下(位于 /usr/include/bits/select.h 中): #if defined __GNUC__ && __GNUC__ >= 2 # if __WORDSIZE == 64 # define __FD_ZERO_STOS \"stosq\" # else # define __FD_ZERO_STOS \"stosl\" # endif # define __FD_ZERO(fdsp) \\ do { \\ int __d0, __d1; \\ __asm__ __volatile__ (\"cld; rep; \" __FD_ZERO_STOS \\ : \"=c\" (__d0), \"=D\" (__d1) \\ : \"a\" (0), \"0\" (sizeof (fd_set) \\ / sizeof (__fd_mask)), \\ \"1\" (&__FDS_BITS (fdsp)[0]) \\ : \"memory\"); \\ } while (0) #else /* ! GNU CC */ /* We don't use `memset' because this would require a prototype and the array isn't too big. */ # define __FD_ZERO(set) \\ do { \\ unsigned int __i; \\ fd_set *__arr = (set); \\ for (__i = 0; __i 重点看这一行: ((void) (__FDS_BITS (set)[__FD_ELT (d)] |= __FD_MASK (d))) __FD_MASK 和 __FD_ELT 宏在上面的代码中已经给出定义: #define __FD_ELT(d) ((d) / __NFDBITS) #define __FD_MASK(d) ((__fd_mask) 1 __NFDBITS 的值是 64 (8 * 8),也就是说 __FD_MASK (d) 先计算 fd 与 64 的余数 n,然后执行 1 __FD_ELT(d) 就是计算位置索引值了。举个例子,假设现在 fd 的 值是 57,那么在这 64 个位置的 57 位,其值在 64 个长度的二进制中置位是: 0000 0010 0000 0000 0000 0000 0000 0000 0000 0000 0000 0000 0000 0000 0000 0000 这个值就是 1 得到的数字。 但是前面 fd 数组的定义是: typedef struct { long int __fds_bits[16]; //可以看成是128 bit的数组 } fd_set; long int 占 8 个字节,一个 16 个 long int,如果换成二进制的位(bit)就是 8 * 16 = 128, 也就是这个数组只用了低 64 位, 高 64 位并没有使用。这说明在我的机器上,select 函数支持操作的最大 fd 数量是 64。 同理,如果我们需要从 fd_set 上删除一个 fd,我们可以调用 FD_CLR,其定义如下: void FD_CLR(int fd, fd_set *set); 原理和 FD_SET 相同,即将对应的标志位由1变0即可。 如果,我们需要将 fd_set 中所有的 fd 都清掉,则使用宏 FD_ZERO: void FD_ZERO(fd_set *set); 当 select 函数返回时, 我们使用 FD_ISSET 宏来判断某个 fd 是否有我们关心的事件,FD_ISSET 宏的定义如下: int FD_ISSET(int fd, fd_set *set); FD_ISSET 宏本质上就是检测对应的位置上是否置 1,实现如下: #define __FD_ISSET(d, set) \\ ((__FDS_BITS (set)[__FD_ELT (d)] & __FD_MASK (d)) != 0) 提醒一下: FD_ELT 和 FD_MASK 宏前文的代码已经给过具体实现了。 参数 timeout,超时时间,即在这个参数设定的时间内检测这些 fd 的事件,超过这个时间后 select 函数将立即返回。这是一个 timeval 类型结构体,其定义如下: struct timeval { long tv_sec; /* seconds */ long tv_usec; /* microseconds */ }; select 函数的总超时时间是 timeout->tv_sec 和 timeout->tv_usec 之和, 前者的时间单位是秒,后者的时间单位是微妙。 说了这么多理论知识,我们先看一个具体的示例: /** * select函数示例,server端, select_server.cpp * zhangyl 2018.12.24 */ #include #include #include #include #include #include #include #include #include //自定义代表无效fd的值 #define INVALID_FD -1 int main(int argc, char* argv[]) { //创建一个侦听socket int listenfd = socket(AF_INET, SOCK_STREAM, 0); if (listenfd == -1) { std::cout clientfds; int maxfd = listenfd; while (true) { fd_set readset; FD_ZERO(&readset); //将侦听socket加入到待检测的可读事件中去 FD_SET(listenfd, &readset); //将客户端fd加入到待检测的可读事件中去 int clientfdslength = clientfds.size(); for (int i = 0; i maxfd) maxfd = clientfd; } else { //假设对端发来的数据长度不超过63个字符 char recvbuf[64]; int clientfdslength = clientfds.size(); for (int i = 0; i 我们编译并运行程序: [root@localhost testsocket]# g++ -g -o select_server select_server.cpp [root@localhost testsocket]# ./select_server 然后,我们再多开几个 shell 窗口,我们这里不再专门编写客户端程序了,我们使用 Linux 下的 nc 指令模拟出两个客户端。 shell 窗口1,连接成功以后发送字符串 hello123: [root@localhost ~]# nc -v 127.0.0.1 3000 Ncat: Version 6.40 ( http://nmap.org/ncat ) Ncat: Connected to 127.0.0.1:3000. hello123 shell 窗口2,连接成功以后发送字符串 helloworld: [root@localhost ~]# nc -v 127.0.0.1 3000 Ncat: Version 6.40 ( http://nmap.org/ncat ) Ncat: Connected to 127.0.0.1:3000. helloworld 此时服务器端输出结果如下: 注意,由于 nc 发送的数据是按换行符来区分的,每一个数据包默认的换行符以\\n 结束(当然,你可以 -C 选项换成\\r\\n),所以服务器收到数据后,显示出来的数据每一行下面都有一个空白行。 当断开各个客户端连接时,服务器端 select 函数对各个客户端 fd 检测时,仍然会触发可读事件,此时对这些 fd 调用 recv 函数会返回 0(recv 函数返回0,表明对端关闭了连接,这是一个很重要的知识点,下文我们会有一章节专门介绍这些函数的返回值),服务器端也关闭这些连接就可以了。 客户端断开连接后,服务器端的运行输出结果: 以上代码是一个简单的服务器程序实现的基本流程,代码虽然简单,但是非常具有典型性和代表性,而且同样适用于客户端网络通信,如果用于客户端的话,只需要用 select 检测连接 socket 就可以了,如果连接 socket 有可读事件,调用 recv 函数来接收数据,剩下的逻辑都是一样的。上面的代码我们画一张流程图如下: 关于上述代码在实际开发中有几个需要注意的事项,这里逐一来说明一下: 1. select 函数调用前后会修改 readfds、writefds 和 exceptfds 这三个集合中的内容(如果有的话),所以如果您想下次调用 select 复用这个变量,记得在下次调用前再次调用 select 前先使用 FD_ZERO 将集合清零,然后调用 FD_SET 将需要检测事件的 fd 再次添加进去。 select 函数调用之后,readfds、writefds 和 exceptfds 这三个集合中存放的不是我们之前设置进去的 fd,而是有相关有读写或异常事件的 fd,也就是说 select 函数会修改这三个参数的内容,这也要求我们当一个 fd_set 被 select 函数调用后,这个 fd_set 就已经发生了改变,下次如果我们需要使用它,必须使用 FD_ZERO 宏先清零,再重新将我们关心的 fd 设置进去。这点我们从 FD_ISSET 源码也可以看出来: #define __FD_ISSET(d, set) \\ ((__FDS_BITS (set)[__FD_ELT (d)] & __FD_MASK (d)) != 0) 如果调用 select 函数之后没有改变 fd_set 集合,那么即使某个 socket 上没有事件,调用 select 函数之后我们用 FD_ISSET 检测,会原路得到原来设置上去的 socket。这是很多初学者在学习 select 函数容易犯的一个错误,我们通过一个示例来验证一下,这次我们把 select 函数用在客户端。 /** * 验证调用select后必须重设fd_set,select_client.cpp * zhangyl 2018.12.24 */ #include #include #include #include #include #include #include #include #define SERVER_ADDRESS \"127.0.0.1\" #define SERVER_PORT 3000 int main(int argc, char* argv[]) { //创建一个socket int clientfd = socket(AF_INET, SOCK_STREAM, 0); if (clientfd == -1) { std::cout 在 shell 窗口输入以下命令编译程序产生可执行文件 select_client: g++ -g -o select_client select_client.cpp 这次产生的是客户端程序,服务器程序我们这里使用 Linux nc 命令来模拟一下,由于客户端连接的是 127.0.0.1:3000 这个地址和端口号,所以我们在另外一个shell 窗口的 nc 命令的参数可以这么写: nc -v -l 0.0.0.0 3000 执行效果如下:接着我们启动客户端 select_client: [root@myaliyun testsocket]# ./select_client 需要注意的是,这里我故意将客户端代码中 select 函数的超时时间设置为5秒,以足够我们在这 5 秒内给客户端发一个数据。如果我们在 5 秒内给客户端发送 hello 字符串: 客户端输出如下: [root@myaliyun testsocket]# ./select_client equal recv data: hello ...部分数据省略... not equal tm.tv_sec: 0, tm.tv_usec: 0 no event in specific time interval, count:31454 not equal tm.tv_sec: 0, tm.tv_usec: 0 no event in specific time interval, count:31455 not equal tm.tv_sec: 0, tm.tv_usec: 0 no event in specific time interval, count:31456 not equal tm.tv_sec: 0, tm.tv_usec: 0 no event in specific time interval, count:31457 ...部分输出省略... 除了第一次 select_client 会输出 equal 字样,后面再也没输出,而 select 函数以后的执行结果也是超时,即使此时服务器端再次给客户端发送数据。因此验证了:select 函数执行后,确实会对三个参数的 fd_set 进行修改 。select 函数修改某个 fd_set 集合可以使用如下两张图来说明一下: 因此在调用 select 函数以后, 原来位置的的标志位可能已经不复存在,这也就是为什么我们的代码中调用一次 select 函数以后,即使服务器端再次发送数据过来,select 函数也不会再因为存在可读事件而返回了,因为第二次 clientfd 已经不在那个 read_set 中了。因此如果复用这些 fd_set 变量,必须按上文所说的重新清零再重新添加关心的 socket 到集合中去。 2. select 函数也会修改 timeval 结构体的值,这也要求我们如果像复用这个变量,必须给 timeval 变量重新设置值。 注意观察上面的例子的输出,我们在调用 select 函数一次之后,变量 tv 的值也被修改了。具体修改成多少,得看系统的表现。当然这种特性却不是跨平台的,在 Linux 系统中是这样的,而在其他操作系统上却不一定是这样(Windows 上就不会修改这个结构体的值),这点在 Linux man 手册 select 函数的说明中说的很清楚: On Linux, select() modifies timeout to reflect the amount of time not slept; most other implementations do not do this. (POSIX.1-2001 permits either behavior.) This causes problems both when Linux code which reads timeout is ported to other operating systems, and when code is ported to Linux that reuses a struct timeval for multiple select()s in a loop without reinitializing it. Consider timeout to be undefined after select() returns. 由于不同系统的实现不一样,man 手册的建议将 select 函数修改 timeval 结构体的值的行为当作是未定义的,言下之意是如果你要下次使用 select 函数复用这个变量时,记得重新赋值。这是 select 函数需要注意的第二个地方。 3. select 函数的 timeval 结构体的 tv_sec 和 tv_sec 如果两个值设置为 0,即检测事件总时间设置为0,其行为是 select 会检测一下相关集合中的 fd,如果没有需要的事件,则立即返回。 我们将上述 select_client.cpp 修改一下,修改后的代码如下: /** * 验证select时间参数设置为0,select_client_tv0.cpp * zhangyl 2018.12.25 */ #include #include #include #include #include #include #include #include #define SERVER_ADDRESS \"127.0.0.1\" #define SERVER_PORT 3000 int main(int argc, char* argv[]) { //创建一个socket int clientfd = socket(AF_INET, SOCK_STREAM, 0); if (clientfd == -1) { std::cout 执行结果确实如我们预期的,这里 select 函数只是简单地检测一下 clientfd,并不会等待固定的时间,然后立即返回。 4. 如果将 select 函数的 timeval 参数设置为 NULL,则 select 函数会一直阻塞下去,直到我们需要的事件触发。 我们将上述代码再修改一下: /** * 验证select时间参数设置为NULL,select_client_tvnull.cpp * zhangyl 2018.12.25 */ #include #include #include #include #include #include #include #include #define SERVER_ADDRESS \"127.0.0.1\" #define SERVER_PORT 3000 int main(int argc, char* argv[]) { //创建一个socket int clientfd = socket(AF_INET, SOCK_STREAM, 0); if (clientfd == -1) { std::cout 我们先在另外一个 shell 窗口用 nc 命令模拟一个服务器,监听的 ip 地址和端口号是 0.0.0.0:3000: [root@myaliyun ~]# nc -v -l 0.0.0.0 3000 Ncat: Version 6.40 ( http://nmap.org/ncat ) Ncat: Listening on 0.0.0.0:3000 然后回到原来的 shell 窗口,编译上述 select_client_tvnull.cpp,并使用 gdb 运行程序,这次使用 gdb 运行程序的目的是为了当程序“卡”在某个位置时,我们可以使用 Ctrl + C 把程序中断下来看看程序阻塞在哪个函数调用处: [root@myaliyun testsocket]# g++ -g -o select_client_tvnull select_client_tvnull.cpp [root@myaliyun testsocket]# gdb select_client_tvnull Reading symbols from /root/testsocket/select_client_tvnull...done. (gdb) r Starting program: /root/testsocket/select_client_tvnull ^C Program received signal SIGINT, Interrupt. 0x00007ffff72e7783 in __select_nocancel () from /lib64/libc.so.6 Missing separate debuginfos, use: debuginfo-install glibc-2.17-196.el7_4.2.x86_64 libgcc-4.8.5-16.el7_4.1.x86_64 libstdc++-4.8.5-16.el7_4.1.x86_64 (gdb) bt #0 0x00007ffff72e7783 in __select_nocancel () from /lib64/libc.so.6 #1 0x0000000000400c75 in main (argc=1, argv=0x7fffffffe5f8) at select_client_tvnull.cpp:51 (gdb) c Continuing. recv data: hello ^C Program received signal SIGINT, Interrupt. 0x00007ffff72e7783 in __select_nocancel () from /lib64/libc.so.6 (gdb) c Continuing. recv data: world 如上输出结果所示,我们使用 gdb 的 r 命令(run)将程序跑起来后,程序卡在某个地方,我们按 Ctrl + C(代码中的 ^C)中断程序后使用 bt 命令查看当前程序的调用堆栈,发现确实阻塞在 select 函数调用处;接着我们在服务器端给客户端发送一个 hello 数据: [root@myaliyun ~]# nc -v -l 0.0.0.0 3000 Ncat: Version 6.40 ( http://nmap.org/ncat ) Ncat: Listening on 0.0.0.0:3000 Ncat: Connection from 127.0.0.1. Ncat: Connection from 127.0.0.1:55968. hello 客户端收到数据后,select 函数满足条件,立即返回,并将数据输出来后继续进行下一轮 select 检测,我们使用 Ctrl + C 将程序中断,发现程序又阻塞在 select 调用处;输入 c 命令(continue)让程序继续运行, 此时,我们再用服务器端给客户端发送 world 字符串,select 函数再次返回,并将数据打印出来,然后继续进入下一轮 select 检测,并继续在 select 处阻塞。 [root@myaliyun ~]# nc -v -l 0.0.0.0 3000 Ncat: Version 6.40 ( http://nmap.org/ncat ) Ncat: Listening on 0.0.0.0:3000 Ncat: Connection from 127.0.0.1. Ncat: Connection from 127.0.0.1:55968. hello world 5. 在 Linux 平台上,select 函数的第一个参数必须设置成需要检测事件的所有 fd 中的最大值加1。所以上文中 select_server.cpp 中,每新产生一个 clientfd,我都会与当前最大的 maxfd 作比较,如果大于当前的 maxfd 则将 maxfd 更新成这个新的最大值。其最终目的是为了在 select 调用时作为第一个参数(加 1)传进去。 在 Windows 平台上,select 函数的第一个值传任意值都可以,Windows 系统本身不使用这个值,只是为了兼容性而保留了这个参数,但是在实际开发中为了兼容跨平台代码,也会按惯例,将这个值设置为最大 socket 加 1。这点请读者注意。 以上是我总结的 Linux 下 select 使用的五个注意事项,希望读者能理解它们。 如需下载本站全部技术文章,可以在【高性能服务器开发】公众号回复关键字“文章下载”即可。最近更新时间: 2020-09-15 23:57:54 "},"articles/网络编程/Linuxepoll模型(含LT模式和ET模式详解).html":{"url":"articles/网络编程/Linuxepoll模型(含LT模式和ET模式详解).html","title":"Linux epoll 模型(含LT 模式和 ET 模式详解)","keywords":"","body":"Linux epoll 模型(含LT 模式和 ET 模式详解) 综合 select 和 poll 的一些优缺点,Linux 从内核 2.6 版本开始引入了更高效的 epoll 模型,本节我们来详细介绍 epoll 模型。 要想使用 epoll 模型,必须先需要创建一个 epollfd,这需要使用 epoll_create 函数去创建: #include int epoll_create(int size); 参数 size 从 Linux 2.6.8 以后就不再使用,但是必须设置一个大于 0 的值。epoll_create 函数调用成功返回一个非负值的 epollfd,调用失败返回 -1。 有了 epollfd 之后,我们需要将我们需要检测事件的其他 fd 绑定到这个 epollfd 上,或者修改一个已经绑定上去的 fd 的事件类型,或者在不需要时将 fd 从 epollfd 上解绑,这都可以使用 epoll_ctl 函数: int epoll_ctl(int epfd, int op, int fd, struct epoll_event* event); 参数说明: 参数 epfd 即上文提到的 epollfd; 参数 op,操作类型,取值有 EPOLL_CTL_ADD、EPOLL_CTL_MOD 和 EPOLL_CTL_DEL,分别表示向 epollfd 上添加、修改和移除一个其他 fd,当取值是 EPOLL_CTL_DEL,第四个参数 event 忽略不计,可以设置为 NULL; 参数 fd,即需要被操作的 fd; 参数 event,这是一个 epoll_event 结构体的地址,epoll_event 结构体定义如下: struct epoll_event { uint32_t events; /* 需要检测的 fd 事件,取值与 poll 函数一样 */ epoll_data_t data; /* 用户自定义数据 */ }; epoll_event 结构体的 data 字段的类型是 epoll_data_t,我们可以利用这个字段设置一个自己的自定义数据,它本质上是一个 Union 对象,在 64 位操作系统中其大小是 8 字节,其定义如下: typedef union epoll_data { void* ptr; int fd; uint32_t u32; uint64_t u64; } epoll_data_t; 函数返回值:epoll_ctl 调用成功返回 0,调用失败返回 -1,你可以通过 errno 错误码获取具体的错误原因。 创建了 epollfd,设置好某个 fd 上需要检测事件并将该 fd 绑定到 epollfd 上去后,我们就可以调用 epoll_wait 检测事件了,epoll_wait 函数签名如下: int epoll_wait(int epfd, struct epoll_event* events, int maxevents, int timeout); 参数的形式和 poll 函数很类似,参数 events 是一个 epoll_event 结构数组的首地址,这是一个输出参数,函数调用成功后,events 中存放的是与就绪事件相关 epoll_event 结构体数组;参数 maxevents 是数组元素的个数;timeout 是超时时间,单位是毫秒,如果设置为 0,epoll_wait 会立即返回。 当 epoll_wait 调用成功会返回有事件的 fd 数目;如果返回 0 表示超时;调用失败返回 -1。 epoll_wait 使用示例如下: while (true) { epoll_event epoll_events[1024]; int n = epoll_wait(epollfd, epoll_events, 1024, 1000); if (n epoll_wait 与 poll 的区别 通过前面介绍 poll 与 epoll_wait 函数的介绍,我们可以发现: epoll_wait 函数调用完之后,我们可以直接在 event 参数中拿到所有有事件就绪的 fd,直接处理即可(event 参数仅仅是个出参);而 poll 函数的事件集合调用前后数量都未改变,只不过调用前我们通过 pollfd 结构体的 events 字段设置待检测事件,调用后我们需要通过 pollfd 结构体的 revents 字段去检测就绪的事件( 参数 fds 既是入参也是出参)。 举个生活中的例子,某人不断给你一些苹果,这些苹果有生有熟,调用 epoll_wait 相当于: 1. 你把苹果挨个投入到 epoll 机器中(调用 epoll_ctl); 2. 调用 epoll_wait 加工,你直接通过另外一个袋子就能拿到所有熟苹果。 调用 poll 相当于: 1. 把收到的苹果装入一个袋子里面然后调用 poll 加工; 2. 调用结束后,拿到原来的袋子,袋子中还是原来那么多苹果,只不过熟苹果被贴上了标签纸,你还是需要挨个去查看标签纸挑选熟苹果。 当然,这并不意味着,poll 函数的效率不如 epoll_wait,一般在 fd 数量比较多,但某段时间内,就绪事件 fd 数量较少的情况下,epoll_wait 才会体现出它的优势,也就是说 socket 连接数量较大时而活跃连接较少时 epoll 模型更高效。 LT 模式和 ET 模式 与 poll 的事件宏相比,epoll 新增了一个事件宏 EPOLLET,这就是所谓的边缘触发模式(Edge Trigger,ET),而默认的模式我们称为 水平触发模式(Level Trigger,LT)。这两种模式的区别在于: 对于水平触发模式,一个事件只要有,就会一直触发; 对于边缘触发模式,只有一个事件从无到有才会触发。 这两个词汇来自电学术语,你可以将 fd 上有数据认为是高电平,没有数据认为是低电平,将 fd 可写认为是高电平,fd 不可写认为是低电平。那么水平模式的触发条件是状态处于高电平,而边缘模式是状态改为高电平,即: 水平模式的触发条件 1. 低电平 => 高电平 2. 高电平 => 高电平 边缘模式的触发条件 1. 低电平 => 高电平 说的有点抽象,以 socket 的读事件为例,对于水平模式,只要 socket 上有未读完的数据,就会一直产生 POLLIN 事件;而对于边缘模式,socket 上第一次有数据会触发一次,后续 socket 上存在数据也不会再触发,除非把数据读完后,再次产生数据才会继续触发。对于 socket 写事件,如果 socket 的 TCP 窗口一直不饱和,会一直触发 POLLOUT 事件;而对于边缘模式,只会触发一次,除非 TCP 窗口由不饱和变成饱和再一次变成不饱和,才会再次触发 POLLOUT 事件。 socket 可读事件水平模式触发条件: 1. socket上无数据 => socket上有数据 2. socket上有数据 => socket上有数据 socket 可读事件边缘模式触发条件: 1. socket上无数据 => socket上有数据 socket 可写事件水平模式触发条件: 1. socket可写 => socket可写 2. socket不可写 => socket可写 socket 可写事件边缘模式触发条件: 1. socket不可写 => socket可写 也就是说,如果对于一个非阻塞 socket,如果使用 epoll 边缘模式去检测数据是否可读,触发可读事件以后,一定要一次性把 socket 上的数据收取干净才行,也就是一定要循环调用 recv 函数直到 recv 出错,错误码是EWOULDBLOCK(EAGAIN 一样);如果使用水平模式,则不用,你可以根据业务一次性收取固定的字节数,或者收完为止。边缘模式下收取数据的代码示例如下: bool TcpSession::RecvEtMode() { //每次只收取256个字节 char buff[256]; while (true) { int nRecv = ::recv(clientfd_, buff, 256, 0); if (nRecv == -1) { if (errno == EWOULDBLOCK) return true; else if (errno == EINTR) continue; return false; } //对端关闭了socket else if (nRecv == 0) return false; inputBuffer_.add(buff, (size_t)nRecv); } return true; } 最后,我们来看一个 epoll 模型的完整例子: /** * 演示 epoll 通信模型,epoll_server.cpp * zhangyl 2019.03.16 */ #include #include #include #include #include #include #include #include #include #include #include int main(int argc, char* argv[]) { //创建一个侦听socket int listenfd = socket(AF_INET, SOCK_STREAM, 0); if (listenfd == -1) { std::cout 编译上述程序生成 epoll_server 并启动,然后使用 nc 命令启动三个客户端给服务器发数据效果如下图所示: 如需下载本站全部技术文章,可以在【高性能服务器开发】公众号回复关键字“文章下载”即可。最近更新时间: 2020-09-16 00:00:40 "},"articles/网络编程/socket的阻塞模式和非阻塞模式.html":{"url":"articles/网络编程/socket的阻塞模式和非阻塞模式.html","title":"socket 的阻塞模式和非阻塞模式","keywords":"","body":"socket 的阻塞模式和非阻塞模式 对 socket 在阻塞和非阻塞模式下的各个函数的行为差别深入的理解是掌握网络编程的基本要求之一,是重点也是难点。 阻塞和非阻塞模式下,我们常讨论的具有不同行为表现的 socket 函数一般有如下几个,见下表: connect accept send (Linux 平台上对 socket 进行操作时也包括 write 函数,下文中对 send 函数的讨论也适用于 write 函数) recv (Linux 平台上对 socket 进行操作时也包括 read 函数,下文中对 recv 函数的讨论也适用于 read 函数) 限于文章篇幅,本文只讨论 send 和recv函数,connect 和 accept 函数我们将在该系列的后面文章中讨论。在正式讨论之前,我们先解释一下阻塞模式和非阻塞模式的概念。所谓阻塞模式,就当某个函数“执行成功的条件”当前不能满足时,该函数会阻塞当前执行线程,程序执行流在超时时间到达或“执行成功的条件”满足后恢复继续执行。而非阻塞模式恰恰相反,即使某个函数的“执行成功的条件”不当前不能满足,该函数也不会阻塞当前执行线程,而是立即返回,继续运行执行程序流。如果读者不太明白这两个定义也没关系,后面我们会以具体的示例来讲解这两种模式的区别。 如何将 socket 设置成非阻塞模式 无论是 Windows 还是 Linux 平台,默认创建的 socket 都是阻塞模式的。 在 Linux 平台上,我们可以使用 fcntl() 函数或 ioctl() 函数给创建的 socket 增加 O_NONBLOCK 标志来将 socket 设置成非阻塞模式。示例代码如下: int oldSocketFlag = fcntl(sockfd, F_GETFL, 0); int newSocketFlag = oldSocketFlag | O_NONBLOCK; fcntl(sockfd, F_SETFL, newSocketFlag); ioctl() 函数 与 fcntl() 函数使用方式基本一致,这里就不再给出示例代码了。 当然,Linux 下的 socket() 创建函数也可以直接在创建时将 socket 设置为非阻塞模式,socket() 函数的签名如下: int socket(int domain, int type, int protocol); 给 type 参数增加一个 SOCK_NONBLOCK 标志即可,例如: int s = socket(AF_INET, SOCK_STREAM | SOCK_NONBLOCK, IPPROTO_TCP); 不仅如此,Linux 系统下利用 accept() 函数返回的代表与客户端通信的 socket 也提供了一个扩展函数 accept4(),直接将 accept 函数返回的 socket 设置成非阻塞的。 int accept(int sockfd, struct sockaddr *addr, socklen_t *addrlen); int accept4(int sockfd, struct sockaddr *addr, socklen_t *addrlen, int flags); 只要将 accept4() 函数最后一个参数 flags 设置成 SOCK_NONBLOCK 即可。也就是说以下代码是等价的: socklen_t addrlen = sizeof(clientaddr); int clientfd = accept4(listenfd, &clientaddr, &addrlen, SOCK_NONBLOCK); socklen_t addrlen = sizeof(clientaddr); int clientfd = accept(listenfd, &clientaddr, &addrlen); if (clientfd != -1) { int oldSocketFlag = fcntl(clientfd, F_GETFL, 0); int newSocketFlag = oldSocketFlag | O_NONBLOCK; fcntl(clientfd, F_SETFL, newSocketFlag); } 在 Windows 平台上,可以调用 ioctlsocket() 函数 将 socket 设置成非阻塞模式,ioctlsocket() 签名如下: int ioctlsocket(SOCKET s, long cmd, u_long *argp); 将 cmd 参数设置为 FIONBIO,argp\\ 设置为 0 即可将 socket 设置成阻塞模式,而将 argp 设置成非 0 即可设置成非阻塞模式。示例如下: //将 socket 设置成非阻塞模式 u_long argp = 1; ioctlsocket(s, FIONBIO, &argp); //将 socket 设置成阻塞模式 u_long argp = 0; ioctlsocket(s, FIONBIO, &argp); Windows 平台需要注意一个地方,如果对一个 socket 调用了 WSAAsyncSelect() 或 WSAEventSelect() 函数后,再调用 ioctlsocket() 函数将该 socket 设置为非阻塞模式会失败,你必须先调用 WSAAsyncSelect() 通过将 lEvent 参数为 0 或调用 WSAEventSelect() 通过设置 lNetworkEvents 参数为 0 来清除已经设置的 socket 相关标志位,再次调用 ioctlsocket() 将该 socket 设置成阻塞模式才会成功。因为调用 WSAAsyncSelect() 或WSAEventSelect() 函数会自动将 socket 设置成非阻塞模式。MSDN 上原文(https://docs.microsoft.com/en-us/windows/desktop/api/winsock/nf-winsock-ioctlsocket)如下: The WSAAsyncSelect and WSAEventSelect functions automatically set a socket to nonblocking mode. If WSAAsyncSelect or WSAEventSelect has been issued on a socket, then any attempt to use ioctlsocket to set the socket back to blocking mode will fail with WSAEINVAL. To set the socket back to blocking mode, an application must first disable WSAAsyncSelect by calling WSAAsyncSelect with the lEvent parameter equal to zero, or disable WSAEventSelect by calling WSAEventSelect with the lNetworkEvents parameter equal to zero. 关于 WSAAsyncSelect() 和 WSAEventSelect() 这两个函数,后文中会详细讲解。 注意事项:无论是 Linux 的 fcntl 函数,还是 Windows 的 ioctlsocket,建议读者在实际编码中判断一下函数返回值以确定是否调用成功。 send 和 recv 函数在阻塞和非阻塞模式下的行为 send 和 recv 函数其实名不符实。 send 函数本质上并不是往网络上发送数据,而是将应用层发送缓冲区的数据拷贝到内核缓冲区(下文为了叙述方便,我们以“网卡缓冲区”代指)中去,至于什么时候数据会从网卡缓冲区中真正地发到网络中去要根据 TCP/IP 协议栈的行为来确定,这种行为涉及到一个叫 nagel 算法和 TCP_NODELAY 的 socket 选项,我们将在《nagle算法与 TCP_NODELAY》章节详细介绍。 recv 函数本质上也并不是从网络上收取数据,而只是将内核缓冲区中的数据拷贝到应用程序的缓冲区中,当然拷贝完成以后会将内核缓冲区中该部分数据移除。 可以用下面一张图来描述上述事实: 通过上图我们知道,不同的程序进行网络通信时,发送的一方会将内核缓冲区的数据通过网络传输给接收方的内核缓冲区。在应用程序 A 与 应用程序 B 建立了 TCP 连接之后,假设应用程序 A 不断调用 send 函数,这样数据会不断拷贝至对应的内核缓冲区中,如果 B 那一端一直不调用 recv 函数,那么 B 的内核缓冲区被填满以后,A 的内核缓冲区也会被填满,此时 A 继续调用 send 函数会是什么结果呢? 具体的结果取决于该 socket 是否是阻塞模式。我们这里先给出结论: 当 socket 是阻塞模式的,继续调用 send/recv 函数会导致程序阻塞在 send/recv 调用处。 当 socket 是非阻塞模式,继续调用 send/recv 函数,send/recv 函数不会阻塞程序执行流,而是会立即出错返回,我们会得到一个相关的错误码,Linux 平台上该错误码为 EWOULDBLOCK 或 EAGAIN(这两个错误码值相同),Windows 平台上错误码为 WSAEWOULDBLOCK。 我们实际来编写一下代码来验证一下以上说的两种情况。 socket 阻塞模式下的 send 行为 服务端代码(blocking_server.cpp)如下: /** * 验证阻塞模式下send函数的行为,server端 * zhangyl 2018.12.17 */ #include #include #include #include #include #include int main(int argc, char* argv[]) { //1.创建一个侦听socket int listenfd = socket(AF_INET, SOCK_STREAM, 0); if (listenfd == -1) { std::cout 客户端代码(blocking_client.cpp)如下: /** * 验证阻塞模式下send函数的行为,client端 * zhangyl 2018.12.17 */ #include #include #include #include #include #include #define SERVER_ADDRESS \"127.0.0.1\" #define SERVER_PORT 3000 #define SEND_DATA \"helloworld\" int main(int argc, char* argv[]) { //1.创建一个socket int clientfd = socket(AF_INET, SOCK_STREAM, 0); if (clientfd == -1) { std::cout 在 shell 中分别编译这两个 cpp 文件得到两个可执行程序 blocking_server 和 blocking_client: g++ -g -o blocking_server blocking_server.cpp g++ -g -o blocking_client blocking_client.cpp 我们先启动 blocking_server,然后用 gdb 启动 blocking_client,输入 run 命令让 blocking_client跑起来,blocking_client 会不断地向 blocking_server 发送\"helloworld\"字符串,每次 send 成功后,会将计数器 count 的值打印出来,计数器会不断增加,程序运行一段时间后,计数器 count 值不再增加且程序不再有输出。操作过程及输出结果如下: blocking_server 端: [root@localhost testsocket]# ./blocking_server accept a client connection. [root@localhost testsocket]# gdb blocking_client GNU gdb (GDB) Red Hat Enterprise Linux 7.6.1-100.el7_4.1 Copyright (C) 2013 Free Software Foundation, Inc. License GPLv3+: GNU GPL version 3 or later This is free software: you are free to change and redistribute it. There is NO WARRANTY, to the extent permitted by law. Type \"show copying\" and \"show warranty\" for details. This GDB was configured as \"x86_64-redhat-linux-gnu\". For bug reporting instructions, please see: ... Reading symbols from /root/testsocket/blocking_client...done. (gdb) run //输出结果太多,省略部分... send data successfully, count = 355384 send data successfully, count = 355385 send data successfully, count = 355386 send data successfully, count = 355387 send data successfully, count = 355388 send data successfully, count = 355389 send data successfully, count = 355390 此时程序不再有输出,说明我们的程序应该“卡在”某个地方,继续按 Ctrl + C 让 gdb 中断下来,输入 bt 命令查看此时的调用堆栈,我们发现我们的程序确实阻塞在 send 函数调用处: ^C Program received signal SIGINT, Interrupt. 0x00007ffff72f130d in send () from /lib64/libc.so.6 (gdb) bt #0 0x00007ffff72f130d in send () from /lib64/libc.so.6 #1 0x0000000000400b46 in main (argc=1, argv=0x7fffffffe598) at blocking_client.cpp:41 (gdb) 上面的示例验证了如果一端一直发数据,而对端应用层一直不取数据(或收取数据的速度慢于发送速度),则很快两端的内核缓冲区很快就会被填满,导致发送端调用 send 函数被阻塞。这里说的“内核缓冲区” 其实有个专门的名字,即 TCP 窗口。也就是说 socket 阻塞模式下, send 函数在 TCP 窗口太小时的行为是阻塞当前程序执行流(即阻塞 send 函数所在的线程的执行)。 说点题外话,上面的例子,我们每次发送一个“helloworld”(10个字节),一共发了 355390 次(每次测试的结果略有不同),我们可以粗略地算出 TCP 窗口的大小大约等于 1.7 M左右 (10 * 355390 / 2)。 让我们再深入一点,我们利用 Linux tcpdump 工具来动态看一下这种情形下 TCP 窗口大小的动态变化。需要注意的是,Linux 下使用 tcpdump 这个命令需要有 root 权限。 我们开启三个 shell 窗口,在第一个窗口先启动 blocking_server 进程,在第二个窗口用 tcpdump 抓经过 TCP 端口 3000 上的数据包: [root@localhost testsocket]# tcpdump -i any -nn -S 'tcp port 3000' tcpdump: verbose output suppressed, use -v or -vv for full protocol decode listening on any, link-type LINUX_SLL (Linux cooked), capture size 262144 bytes 接着在第三个 shell 窗口,启动 blocking_client。当 blocking_client 进程不再输出时,我们抓包的结果如下: [root@localhost testsocket]# tcpdump -i any -nn -S 'tcp port 3000' tcpdump: verbose output suppressed, use -v or -vv for full protocol decode listening on any, link-type LINUX_SLL (Linux cooked), capture size 262144 bytes 11:52:35.907381 IP 127.0.0.1.40846 > 127.0.0.1.3000: Flags [S], seq 1394135076, win 43690, options [mss 65495,sackOK,TS val 78907688 ecr 0,nop,wscale 7], length 0 20:32:21.261484 IP 127.0.0.1.3000 > 127.0.0.1.40846: Flags [S.], seq 1233000591, ack 1394135077, win 43690, options [mss 65495,sackOK,TS val 78907688 ecr 78907688,nop,wscale 7], length 0 11:52:35.907441 IP 127.0.0.1.40846 > 127.0.0.1.3000: Flags [.], ack 1233000592, win 342, options [nop,nop,TS val 78907688 ecr 78907688], length 0 11:52:35.907615 IP 127.0.0.1.40846 > 127.0.0.1.3000: Flags [P.], seq 1394135077:1394135087, ack 1233000592, win 342, options [nop,nop,TS val 78907688 ecr 78907688], length 10 11:52:35.907626 IP 127.0.0.1.3000 > 127.0.0.1.40846: Flags [.], ack 1394135087, win 342, options [nop,nop,TS val 78907688 ecr 78907688], length 0 11:52:35.907785 IP 127.0.0.1.40846 > 127.0.0.1.3000: Flags [P.], seq 1394135087:1394135097, ack 1233000592, win 342, options [nop,nop,TS val 78907688 ecr 78907688], length 10 11:52:35.907793 IP 127.0.0.1.3000 > 127.0.0.1.40846: Flags [.], ack 1394135097, win 342, options [nop,nop,TS val 78907688 ecr 78907688], length 0 11:52:35.907809 IP 127.0.0.1.40846 > 127.0.0.1.3000: Flags [P.], seq 1394135097:1394135107, ack 1233000592, win 342, options [nop,nop,TS val 78907688 ecr 78907688], length 10 11:52:35.907814 IP 127.0.0.1.3000 > 127.0.0.1.40846: Flags [.], ack 1394135107, win 342, options [nop,nop,TS val 78907688 ecr 78907688], length 0 ...内容太长, 部分省略... 11:52:40.075794 IP 127.0.0.1.3000 > 127.0.0.1.40846: Flags [.], ack 1395013717, win 374, options [nop,nop,TS val 78911856 ecr 78911816], length 0 11:52:40.075829 IP 127.0.0.1.40846 > 127.0.0.1.3000: Flags [P.], seq 1395013717:1395030517, ack 1233000592, win 342, options [nop,nop,TS val 78911856 ecr 78911856], length 16800 11:52:40.115847 IP 127.0.0.1.3000 > 127.0.0.1.40846: Flags [.], ack 1395030517, win 305, options [nop,nop,TS val 78911896 ecr 78911856], length 0 11:52:40.115866 IP 127.0.0.1.40846 > 127.0.0.1.3000: Flags [P.], seq 1395030517:1395047317, ack 1233000592, win 342, options [nop,nop,TS val 78911896 ecr 78911896], length 16800 11:52:40.155703 IP 127.0.0.1.3000 > 127.0.0.1.40846: Flags [.], ack 1395047317, win 174, options [nop,nop,TS val 78911936 ecr 78911896], length 0 11:52:40.155752 IP 127.0.0.1.40846 > 127.0.0.1.3000: Flags [P.], seq 1395047317:1395064117, ack 1233000592, win 342, options [nop,nop,TS val 78911936 ecr 78911936], length 16800 11:52:40.195132 IP 127.0.0.1.3000 > 127.0.0.1.40846: Flags [.], ack 1395064117, win 43, options [nop,nop,TS val 78911976 ecr 78911936], length 0 11:52:40.435748 IP 127.0.0.1.40846 > 127.0.0.1.3000: Flags [P.], seq 1395064117:1395069621, ack 1233000592, win 342, options [nop,nop,TS val 78912216 ecr 78911976], length 5504 11:52:40.435782 IP 127.0.0.1.3000 > 127.0.0.1.40846: Flags [.], ack 1395069621, win 0, options [nop,nop,TS val 78912216 ecr 78912216], length 0 11:52:40.670661 IP 127.0.0.1.40846 > 127.0.0.1.3000: Flags [.], ack 1233000592, win 342, options [nop,nop,TS val 78912451 ecr 78912216], length 0 11:52:40.670674 IP 127.0.0.1.3000 > 127.0.0.1.40846: Flags [.], ack 1395069621, win 0, options [nop,nop,TS val 78912451 ecr 78912216], length 0 11:52:41.141703 IP 127.0.0.1.40846 > 127.0.0.1.3000: Flags [.], ack 1233000592, win 342, options [nop,nop,TS val 78912922 ecr 78912451], length 0 11:52:42.083643 IP 127.0.0.1.40846 > 127.0.0.1.3000: Flags [.], ack 1233000592, win 342, options [nop,nop,TS val 78913864 ecr 78912451], length 0 11:52:42.083655 IP 127.0.0.1.3000 > 127.0.0.1.40846: Flags [.], ack 1395069621, win 0, options [nop,nop,TS val 78913864 ecr 78912216], length 0 11:52:43.967506 IP 127.0.0.1.40846 > 127.0.0.1.3000: Flags [.], ack 1233000592, win 342, options [nop,nop,TS val 78915748 ecr 78913864], length 0 11:52:43.967532 IP 127.0.0.1.3000 > 127.0.0.1.40846: Flags [.], ack 1395069621, win 0, options [nop,nop,TS val 78915748 ecr 78912216], length 0 11:52:47.739259 IP 127.0.0.1.40846 > 127.0.0.1.3000: Flags [.], ack 1233000592, win 342, options [nop,nop,TS val 78919520 ecr 78915748], length 0 11:52:47.739274 IP 127.0.0.1.3000 > 127.0.0.1.40846: Flags [.], ack 1395069621, win 0, options [nop,nop,TS val 78919520 ecr 78912216], length 0 11:52:55.275863 IP 127.0.0.1.40846 > 127.0.0.1.3000: Flags [.], ack 1233000592, win 342, options [nop,nop,TS val 78927056 ecr 78919520], length 0 11:52:55.275931 IP 127.0.0.1.3000 > 127.0.0.1.40846: Flags [.], ack 1395069621, win 0, options [nop,nop,TS val 78927056 ecr 78912216], length 0 抓取到的前三个数据包是 blocking_client 与 blocking_server 建立三次握手的过程。 11:52:35.907381 IP 127.0.0.1.40846 > 127.0.0.1.3000: Flags [S], seq 1394135076, win 43690, options [mss 65495,sackOK,TS val 78907688 ecr 0,nop,wscale 7], length 0 20:32:21.261484 IP 127.0.0.1.3000 > 127.0.0.1.40846: Flags [S.], seq 1233000591, ack 1394135077, win 43690, options [mss 65495,sackOK,TS val 78907688 ecr 78907688,nop,wscale 7], length 0 11:52:35.907441 IP 127.0.0.1.40846 > 127.0.0.1.3000: Flags [.], ack 1233000592, win 342, options [nop,nop,TS val 78907688 ecr 78907688], length 0 示意图如下: 当每次 blocking_client 给 blocking_server 发数据以后,blocking_server 会应答 blocking_server,在每次应答的数据包中会带上自己的当前可用 TCP 窗口大小(看上文中结果从 127.0.0.1.3000 > 127.0.0.1.40846方向的数据包的 win 字段大小变化),由于 TCP 流量控制和拥赛控制机制的存在,blocking_server 端的 TCP 窗口大小短期内会慢慢增加,后面随着接收缓冲区中数据积压越来越多, TCP 窗口会慢慢变小,最终变为 0。 另外,细心的读者如果实际去做一下这个实验会发现一个现象,即当 tcpdump 已经显示对端的 TCP 窗口是 0 时, blocking_client 仍然可以继续发送一段时间的数据,此时的数据已经不是在发往对端,而是逐渐填满到本端的内核发送缓冲区中去了,这也验证了 send 函数实际上是往内核缓冲区中拷贝数据这一行为。 socket 非阻塞模式下的 send 行为 我们再来验证一下非阻塞 socket 的 send 行为,server 端的代码不变,我们将 blocking_client.cpp 中 socket 设置成非阻塞的,修改后的代码如下: /** * 验证非阻塞模式下send函数的行为,client端,nonblocking_client.cpp * zhangyl 2018.12.17 */ #include #include #include #include #include #include #include #include #include #define SERVER_ADDRESS \"127.0.0.1\" #define SERVER_PORT 3000 #define SEND_DATA \"helloworld\" int main(int argc, char* argv[]) { //1.创建一个socket int clientfd = socket(AF_INET, SOCK_STREAM, 0); if (clientfd == -1) { std::cout 编译 nonblocking_client.cpp 得到可执行程序 nonblocking_client: g++ -g -o nonblocking_client nonblocking_client.cpp 运行 nonblocking_client,运行一段时间后,由于对端和本端的 TCP 窗口已满,数据发不出去了,但是 send 函数不会阻塞,而是立即返回,返回值是 -1(Windows 系统上 返回 SOCKET_ERROR,这个宏的值也是 -1),此时得到错误码是 EWOULDBLOCK。执行结果如下: socket 阻塞模式下的 recv 行为 在了解了 send 函数的行为,我们再来看一下阻塞模式下的 recv 函数行为。服务器端代码不需要修改,我们修改一下客户端代码,如果服务器端不给客户端发数据,此时客户端调用 recv 函数执行流会阻塞在 recv 函数调用处。继续修改一下客户端代码: /** * 验证阻塞模式下recv函数的行为,client端,blocking_client_recv.cpp * zhangyl 2018.12.17 */ #include #include #include #include #include #include #define SERVER_ADDRESS \"127.0.0.1\" #define SERVER_PORT 3000 #define SEND_DATA \"helloworld\" int main(int argc, char* argv[]) { //1.创建一个socket int clientfd = socket(AF_INET, SOCK_STREAM, 0); if (clientfd == -1) { std::cout 0) { std::cout 编译 blocking_client_recv.cpp 并使用启动,我们发现程序既没有打印 recv 调用成功的信息也没有调用失败的信息,将程序中断下来,使用 bt 命令查看此时的调用堆栈,发现程序确实阻塞在 recv 函数调用处。 [root@localhost testsocket]# g++ -g -o blocking_client_recv blocking_client_recv.cpp [root@localhost testsocket]# gdb blocking_client_recv Reading symbols from /root/testsocket/blocking_client_recv...done. (gdb) r Starting program: /root/testsocket/blocking_client_recv ^C Program received signal SIGINT, Interrupt. 0x00007ffff72f119d in recv () from /lib64/libc.so.6 Missing separate debuginfos, use: debuginfo-install glibc-2.17-196.el7_4.2.x86_64 libgcc-4.8.5-16.el7_4.2.x86_64 libstdc++-4.8.5-16.el7_4.2.x86_64 (gdb) bt #0 0x00007ffff72f119d in recv () from /lib64/libc.so.6 #1 0x0000000000400b18 in main (argc=1, argv=0x7fffffffe588) at blocking_client_recv.cpp:40 socket 非阻塞模式下的 recv 行为 非阻塞模式下如果当前无数据可读,recv 函数将立即返回,返回值为 -1,错误码为 EWOULDBLOCK。将客户端代码修成一下: /** * 验证阻塞模式下recv函数的行为,client端,blocking_client_recv.cpp * zhangyl 2018.12.17 */ #include #include #include #include #include #include #include #include #include #define SERVER_ADDRESS \"127.0.0.1\" #define SERVER_PORT 3000 #define SEND_DATA \"helloworld\" int main(int argc, char* argv[]) { //1.创建一个socket int clientfd = socket(AF_INET, SOCK_STREAM, 0); if (clientfd == -1) { std::cout 0) { //收到了数据 std::cout 执行结果与我们预期的一模一样, recv 函数在无数据可读的情况下并不会阻塞情绪,所以程序会一直有“There is no data available now.”相关的输出。 如需下载本站全部技术文章,可以在【高性能服务器开发】公众号回复关键字“文章下载”即可。最近更新时间: 2020-09-16 00:05:42 "},"articles/网络编程/非阻塞模式下send和recv函数的返回值.html":{"url":"articles/网络编程/非阻塞模式下send和recv函数的返回值.html","title":"非阻塞模式下 send 和 recv 函数的返回值","keywords":"","body":"非阻塞模式下 send 和 recv 函数的返回值 我们来总结一下 send 和 recv 函数的各种返回值意义: 返回值 n 返回值含义 大于 0 成功发送 n 个字节 0 对端关闭连接 小于 0( -1) 出错或者被信号中断或者对端 TCP 窗口太小数据发不出去(send)或者当前网卡缓冲区已无数据可收(recv) 我们来逐一介绍下这三种情况: 返回值大于 0 对于 send 和 recv 函数返回值大于 0,表示发送或接收多少字节,需要注意的是,在这种情形下,我们一定要判断下 send 函数的返回值是不是我们期望发送的缓冲区长度,而不是简单判断其返回值大于 0。举个例子: 1int n = send(socket, buf, buf_length, 0); 2if (n > 0) 3{ 4 printf(\"send data successfully\\n\"); 5} 很多新手会写出上述代码,虽然返回值 n 大于 0,但是实际情形下,由于对端的 TCP 窗口可能因为缺少一部分字节就满了,所以返回值 n 的值可能在 (0, buf_length] 之间,当 0 1 //推荐的方式一 2 int n = send(socket, buf, buf_length, 0); 3 if (n == buf_length) 4 { 5 printf(\"send data successfully\\n\"); 6 } 1//推荐的方式二:在一个循环里面根据偏移量发送数据 2bool SendData(const char* buf , int buf_length) 3{ 4 //已发送的字节数目 5 int sent_bytes = 0; 6 int ret = 0; 7 while (true) 8 { 9 ret = send(m_hSocket, buf + sent_bytes, buf_length - sent_bytes, 0); 10 if (nRet == -1) 11 { 12 if (errno == EWOULDBLOCK) 13 { 14 //严谨的做法,这里如果发不出去,应该缓存尚未发出去的数据,后面介绍 15 break; 16 } 17 else if (errno == EINTR) 18 continue; 19 else 20 return false; 21 } 22 else if (nRet == 0) 23 { 24 return false; 25 } 26 27 sent_bytes += ret; 28 if (sent_bytes == buf_length) 29 break; 30 31 //稍稍降低 CPU 的使用率 32 usleep(1); 33 } 34 35 return true; 36} 返回值等于 0 通常情况下,如果 send 或者 recv 函数返回 0,我们就认为对端关闭了连接,我们这端也关闭连接即可,这是实际开发时最常见的处理逻辑。 但是,现在还有一种情形就是,假设调用 send 函数传递的数据长度就是 0 呢?send 函数会是什么行为?对端会 recv 到一个 0 字节的数据吗?需要强调的是,在实际开发中,你不应该让你的程序有任何机会去 send 0 字节的数据,这是一种不好的做法。 这里仅仅用于实验性讨论,我们来通过一个例子,来看下 send 一个长度为 0 的数据,send 函数的返回值是什么?对端会 recv 到 0 字节的数据吗? server 端代码: 1 /** 2 * 验证recv函数接受0字节的行为,server端,server_recv_zero_bytes.cpp 3 * zhangyl 2018.12.17 4 */ 5 #include 6 #include 7 #include 8 #include 9 #include 10 #include 11 #include 12 13 int main(int argc, char* argv[]) 14 { 15 //1.创建一个侦听socket 16 int listenfd = socket(AF_INET, SOCK_STREAM, 0); 17 if (listenfd == -1) 18 { 19 std::cout 0) 57 { 58 std::cout 上述代码侦听端口号是 3000,代码 55 行调用了 recv 函数,如果客户端一直没有数据,程序会阻塞在这里。 client 端代码: 1/** 2 * 验证非阻塞模式下send函数发送0字节的行为,client端,nonblocking_client_send_zero_bytes.cpp 3 * zhangyl 2018.12.17 4 */ 5#include 6#include 7#include 8#include 9#include 10#include 11#include 12#include 13#include 14 15#define SERVER_ADDRESS \"127.0.0.1\" 16#define SERVER_PORT 3000 17#define SEND_DATA \"\" 18 19int main(int argc, char* argv[]) 20{ 21 //1.创建一个socket 22 int clientfd = socket(AF_INET, SOCK_STREAM, 0); 23 if (clientfd == -1) 24 { 25 std::cout client 端连接服务器成功以后,每隔 3 秒调用 send 一次发送一个 0 字节的数据。除了先启动 server 以外,我们使用 tcpdump 抓一下经过端口 3000 上的数据包,使用如下命令: 1tcpdump -i any 'tcp port 3000' 然后启动 client ,我们看下结果: 客户端确实是每隔 3 秒 send 一次数据。此时我们使用 lsof -i -Pn 命令查看连接状态,也是正常的: 然后,tcpdump 抓包结果输出中,除了连接时的三次握手数据包,再也无其他数据包,也就是说,send 函数发送 0 字节数据,client 的协议栈并不会把这些数据发出去。 1[root@localhost ~]# tcpdump -i any 'tcp port 3000' 2tcpdump: verbose output suppressed, use -v or -vv for full protocol decode 3listening on any, link-type LINUX_SLL (Linux cooked), capture size 262144 bytes 417:37:03.028449 IP localhost.48820 > localhost.hbci: Flags [S], seq 1632283330, win 43690, options [mss 65495,sackOK,TS val 201295556 ecr 0,nop,wscale 7], length 0 517:37:03.028479 IP localhost.hbci > localhost.48820: Flags [S.], seq 3669336158, ack 1632283331, win 43690, options [mss 65495,sackOK,TS val 201295556 ecr 201295556,nop,wscale 7], length 0 617:37:03.028488 IP localhost.48820 > localhost.hbci: Flags [.], ack 1, win 342, options [nop,nop,TS val 201295556 ecr 201295556], length 0 因此,server 端也会一直没有输出,如果你用的是 gdb 启动 server,此时中断下来会发现,server 端由于没有数据会一直阻塞在 recv 函数调用处(55 行)。 上述示例再次验证了,send 一个 0 字节的数据没有任何意思,希望读者在实际开发时,避免写出这样的代码。 如需下载本站全部技术文章,可以在【高性能服务器开发】公众号回复关键字“文章下载”即可。最近更新时间: 2020-09-16 00:07:15 "},"articles/网络编程/服务器开发通信协议设计介绍.html":{"url":"articles/网络编程/服务器开发通信协议设计介绍.html","title":"服务器开发通信协议设计介绍","keywords":"","body":"服务器开发通信协议设计介绍 一、选择TCP还是UDP协议 由于我们的即时通讯软件的用户存在用户状态问题,即用户登录成功以后可以在他的好友列表中看到哪些好友在线,所以客户端和服务器需要保持长连接状态。另外即时通讯软件一般要求信息准确、有序、完整地到达对端,而这也是TCP协议的特点之一。综合这两个所以这里我们选择TCP协议,而不是UDP协议。 二、协议的结构 由于TCP协议是流式协议,所谓流式协议即通讯的内容是无边界的字节流:如A给B连续发送了三个数据包,每个包的大小都是100个字节,那么B可能会一次性收到300个字节;也可能先收到100个字节,再收到200个字节;也可能先收到100个字节,再收到50个字节,再收到150个字节;或者先收到50个字节,再收到50个字节,再收到50个字节,最后收到150个字节。也就是说,B可能以任何组合形式收到这300个字节。即像水流一样无明确的边界。为了能让对端知道如何给包分界,目前一般有三种做法: 以固定大小字节数目来分界,上文所说的就是属于这种类型,如每个包100个字节,对端每收齐100个字节,就当成一个包来解析; 以特定符号来分界,如每个包都以特定的字符来结尾(如\\n),当在字节流中读取到该字符时,则表明上一个包到此为止。 固定包头+包体结构,这种结构中一般包头部分是一个固定字节长度的结构,并且包头中会有一个特定的字段指定包体的大小。这是目前各种网络应用用的最多的一种包格式。 上面三种分包方式各有优缺点,方法1和方法2简单易操作,但是缺点也很明显,就是很不灵活,如方法一当包数据不足指定长度,只能使用占位符如0来凑,比较浪费;方法2中包中不能有包界定符,否则就会引起歧义,也就是要求包内容中不能有某些特殊符号。而方法3虽然解决了方法1和方法2的缺点,但是操作起来就比较麻烦。我们的即时通讯协议就采用第三种分包方式。所以我们的协议包的包头看起来像这样: struct package_header { int32_t bodysize; }; 一个应用中,有许多的应用数据,拿我们这里的即时通讯来说,有注册、登录、获取好友列表、好友消息等各种各样的协议数据包,而每个包因为业务内容不一样可能数据内容也不一样,所以各个包可能看起来像下面这样: struct package_header { int32_t bodysize; }; //登录数据包 struct register_package { package_header header; //命令号 int32_t cmd; //注册用户名 char username[16]; //注册密码 char password[16]; //注册昵称 char nickname[16]; //注册手机号 char mobileno[16]; }; //登录数据包 struct login_package { package_header header; //命令号 int32_t cmd; //登录用户名 char username[16]; //密码 char password[16]; //客户端类型 int32_t clienttype; //上线类型,如在线、隐身、忙碌、离开等 int32_t onlinetype; }; //获取好友列表 struct getfriend_package { package_header header; //命令号 int32_t cmd; }; //聊天内容 struct chat_package { package_header header; //命令号 int32_t cmd; //发送人userid int32_t senderid; //接收人userid int32_t targetid; //消息内容 char chatcontent[8192]; }; 看到没有?由于每一个业务的内容不一样,定义的结构体也不一样。如果业务比较多的话,我们需要定义各种各样的这种结构体,这简直是一场噩梦。那么有没有什么方法可以避免这个问题呢?有,我受jdk中的流对象的WriteInt32、WriteByte、WriteInt64、WriteString,这样的接口的启发,也发明了一套这样的协议,而且这套协议基本上是通用协议,可用于任何场景。我们的包还是分为包头和包体两部分,包头和上文所说的一样,包体是一个不固定大小的二进制流,其长度由包头中的指定包体长度的字段决定。 struct package_protocol { int32_t bodysize; //注意:C/C++语法不能这么定义结构体, //这里只是为了说明含义的伪代码 //bodycontent即为一个不固定大小的二进制流 char binarystream[bodysize]; }; 接下来的核心部分就是如何操作这个二进制流,我们将流分为二进制读和二进制写两种流,下面给出接口定义: //写 class BinaryWriteStream { public: BinaryWriteStream(string* data); const char* GetData() const; size_t GetSize() const; bool WriteCString(const char* str, size_t len); bool WriteString(const string& str); bool WriteDouble(double value, bool isNULL = false); bool WriteInt64(int64_t value, bool isNULL = false); bool WriteInt32(int32_t i, bool isNULL = false); bool WriteShort(short i, bool isNULL = false); bool WriteChar(char c, bool isNULL = false); size_t GetCurrentPos() const{ return m_data->length(); } void Flush(); void Clear(); private: string* m_data; }; //读 class BinaryReadStream : public IReadStream { private: const char* const ptr; const size_t len; const char* cur; BinaryReadStream(const BinaryReadStream&); BinaryReadStream& operator=(const BinaryReadStream&); public: BinaryReadStream(const char* ptr, size_t len); const char* GetData() const; size_t GetSize() const; bool IsEmpty() const; bool ReadString(string* str, size_t maxlen, size_t& outlen); bool ReadCString(char* str, size_t strlen, size_t& len); bool ReadCCString(const char** str, size_t maxlen, size_t& outlen); bool ReadInt32(int32_t& i); bool ReadInt64(int64_t& i); bool ReadShort(short& i); bool ReadChar(char& c); size_t ReadAll(char* szBuffer, size_t iLen) const; bool IsEnd() const; const char* GetCurrent() const{ return cur; } public: bool ReadLength(size_t & len); bool ReadLengthWithoutOffset(size_t &headlen, size_t & outlen); }; 这样如果是上文的一个登录数据包,我们只要写成如下形式就可以了: std::string outbuf; BinaryWriteStream stream(&outbuf); stream.WriteInt32(cmd); stream.WriteCString(username, 16); stream.WriteCString(password, 16); stream.WriteInt32(clienttype); stream.WriteInt32(onlinetype); //最终数据就存储到outbuf中去了 stream.Flush(); 接着我们再对端,解得正确的包体后,我们只要按写入的顺序依次读出来即可: BinaryWriteStream stream(outbuf.c_str(), outbuf.length()); int32_t cmd; stream.WriteInt32(cmd); char username[16]; stream.ReadCString(username, 16, NULL); char password[16]; stream.WriteCString(password, 16, NULL); int32_t clienttype; stream.WriteInt32(clienttype); int32_t onlinetype; stream.WriteInt32(onlinetype); 这里给出BinaryReadStream和BinaryWriteStream的完整实现: //计算校验和 unsigned short checksum(const unsigned short *buffer, int size) { unsigned int cksum = 0; while (size > 1) { cksum += *buffer++; size -= sizeof(unsigned short); } if (size) { cksum += *(unsigned char*)buffer; } //将32位数转换成16 while (cksum >> 16) cksum = (cksum >> 16) + (cksum & 0xffff); return (unsigned short)(~cksum); } bool compress_(unsigned int i, char *buf, size_t &len) { len = 0; for (int a = 4; a >= 0; a--) { char c; c = i >> (a * 7) & 0x7f; if (c == 0x00 && len == 0) continue; if (a == 0) c &= 0x7f; else c |= 0x80; buf[len] = c; len++; } if (len == 0) { len++; buf[0] = 0; } //cout strlen) { return false; } // 偏移到数据的位置 //cur += BINARY_PACKLEN_LEN_2; cur += headlen; if (cur + fieldlen > ptr + len) { outlen = 0; return false; } memcpy(str, cur, fieldlen); outlen = fieldlen; cur += outlen; return true; } bool BinaryReadStream::ReadString(string* str, size_t maxlen, size_t& outlen) { size_t headlen; size_t fieldlen; if (!ReadLengthWithoutOffset(headlen, fieldlen)) { return false; } // user buffer is not enough if (maxlen != 0 && fieldlen > maxlen) { return false; } // 偏移到数据的位置 //cur += BINARY_PACKLEN_LEN_2; cur += headlen; if (cur + fieldlen > ptr + len) { outlen = 0; return false; } str->assign(cur, fieldlen); outlen = fieldlen; cur += outlen; return true; } bool BinaryReadStream::ReadCCString(const char** str, size_t maxlen, size_t& outlen) { size_t headlen; size_t fieldlen; if (!ReadLengthWithoutOffset(headlen, fieldlen)) { return false; } // user buffer is not enough if (maxlen != 0 && fieldlen > maxlen) { return false; } // 偏移到数据的位置 //cur += BINARY_PACKLEN_LEN_2; cur += headlen; //memcpy(str, cur, fieldlen); if (cur + fieldlen > ptr + len) { outlen = 0; return false; } *str = cur; outlen = fieldlen; cur += outlen; return true; } bool BinaryReadStream::ReadInt32(int32_t& i) { const int VALUE_SIZE = sizeof(int32_t); if (cur + VALUE_SIZE > ptr + len) return false; memcpy(&i, cur, VALUE_SIZE); i = ntohl(i); cur += VALUE_SIZE; return true; } bool BinaryReadStream::ReadInt64(int64_t& i) { char int64str[128]; size_t length; if (!ReadCString(int64str, 128, length)) return false; i = atoll(int64str); return true; } bool BinaryReadStream::ReadShort(short& i) { const int VALUE_SIZE = sizeof(short); if (cur + VALUE_SIZE > ptr + len) { return false; } memcpy(&i, cur, VALUE_SIZE); i = ntohs(i); cur += VALUE_SIZE; return true; } bool BinaryReadStream::ReadChar(char& c) { const int VALUE_SIZE = sizeof(char); if (cur + VALUE_SIZE > ptr + len) { return false; } memcpy(&c, cur, VALUE_SIZE); cur += VALUE_SIZE; return true; } bool BinaryReadStream::ReadLength(size_t & outlen) { size_t headlen; if (!ReadLengthWithoutOffset(headlen, outlen)) { return false; } //cur += BINARY_PACKLEN_LEN_2; cur += headlen; return true; } bool BinaryReadStream::ReadLengthWithoutOffset(size_t& headlen, size_t & outlen) { headlen = 0; const char *temp = cur; char buf[5]; for (size_t i = 0; i> 7 | 0x0) == 0x0) if ((buf[i] & 0x80) == 0x00) break; } if (cur + headlen > ptr + len) return false; unsigned int value; uncompress_(buf, headlen, value); outlen = value; /*if ( cur + BINARY_PACKLEN_LEN_2 > ptr + len ) { return false; } unsigned int tmp; memcpy(&tmp, cur, sizeof(tmp)); outlen = ntohl(tmp);*/ return true; } bool BinaryReadStream::IsEnd() const { assert(cur clear(); char str[BINARY_PACKLEN_LEN_2 + CHECKSUM_LEN]; m_data->append(str, sizeof(str)); } bool BinaryWriteStream::WriteCString(const char* str, size_t len) { char buf[5]; size_t buflen; compress_(len, buf, buflen); m_data->append(buf, sizeof(char)*buflen); m_data->append(str, len); //unsigned int ulen = htonl(len); //m_data->append((char*)&ulen,sizeof(ulen)); //m_data->append(str,len); return true; } bool BinaryWriteStream::WriteString(const string& str) { return WriteCString(str.c_str(), str.length()); } const char* BinaryWriteStream::GetData() const { return m_data->data(); } size_t BinaryWriteStream::GetSize() const { return m_data->length(); } bool BinaryWriteStream::WriteInt32(int32_t i, bool isNULL) { int32_t i2 = 999999999; if (isNULL == false) i2 = htonl(i); m_data->append((char*)&i2, sizeof(i2)); return true; } bool BinaryWriteStream::WriteInt64(int64_t value, bool isNULL) { char int64str[128]; if (isNULL == false) { #ifndef _WIN32 sprintf(int64str, \"%ld\", value); #else sprintf(int64str, \"%lld\", value); #endif WriteCString(int64str, strlen(int64str)); } else WriteCString(int64str, 0); return true; } bool BinaryWriteStream::WriteShort(short i, bool isNULL) { short i2 = 0; if (isNULL == false) i2 = htons(i); m_data->append((char*)&i2, sizeof(i2)); return true; } bool BinaryWriteStream::WriteChar(char c, bool isNULL) { char c2 = 0; if (isNULL == false) c2 = c; (*m_data) += c2; return true; } bool BinaryWriteStream::WriteDouble(double value, bool isNULL) { char doublestr[128]; if (isNULL == false) { sprintf(doublestr, \"%f\", value); WriteCString(doublestr, strlen(doublestr)); } else WriteCString(doublestr, 0); return true; } void BinaryWriteStream::Flush() { char *ptr = &(*m_data)[0]; unsigned int ulen = htonl(m_data->length()); memcpy(ptr, &ulen, sizeof(ulen)); } void BinaryWriteStream::Clear() { m_data->clear(); char str[BINARY_PACKLEN_LEN_2 + CHECKSUM_LEN]; m_data->append(str, sizeof(str)); } 这里详细解释一下上面的实现原理,即如何把各种类型的字段写入这种所谓的流中,或者怎么从这种流中读出各种类型的数据。上文的字段在流中的格式如下图: 这里最简便的方式就是每个字段的长度域都是固定字节数目,如4个字节。但是这里我们并没有这么做,而是使用了一个小小技巧去对字段长度进行了一点压缩。对于字符串类型的字段,我们将表示其字段长度域的整型值(int32类型,4字节)按照其数值的大小压缩成1~5个字节,对于每一个字节,如果我们只用其低7位。最高位为标志位,为1时,表示其左边的还有下一个字节,反之到此结束。例如,对于数字127,我们二进制表示成01111111,由于最高位是0,那么如果字段长度是127及以下,一个字节就可以存储下了。如果一个字段长度大于127,如等于256,对应二进制100000000,那么我们按照刚才的规则,先填充最低字节(从左往右依次是从低到高),由于最低的7位放不下,还有后续高位字节,所以我们在最低字节的最高位上填1,即10000000,接着次高位为00000100,由于次高位后面没有更高位的字节了,所以其最高位为0,组合起来两个字节就是10000000 0000100。对于数字50000,其二进制是1100001101010000,根据每7个一拆的原则是:11 0000110 1010000再加上标志位就是:10000011 10000110 01010000。采用这样一种策略将原来占4个字节的整型值根据数值大小压缩成了1~5个字节(由于我们对数据包最大长度有限制,所以不会出现长度需要占5个字节的情形)。反过来,解析每个字段的长度,就是先取出一个字节,看其最高位是否有标志位,如果有继续取下一个字节当字段长度的一部分继续解析,直到遇到某个字节最高位不为1为止。 对一个整形压缩和解压缩的部分从上面的代码中摘录如下: 压缩: 1 //将一个四字节的整形数值压缩成1~5个字节 2 bool compress_(unsigned int i, char *buf, size_t &len) 3 { 4 len = 0; 5 for (int a = 4; a >= 0; a--) 6 { 7 char c; 8 c = i >> (a * 7) & 0x7f; 9 if (c == 0x00 && len == 0) 10 continue; 11 if (a == 0) 12 c &= 0x7f; 13 else 14 c |= 0x80; 15 buf[len] = c; 16 len++; 17 } 18 if (len == 0) 19 { 20 len++; 21 buf[0] = 0; 22 } 23 //cout 解压 1 //将一个1~5个字节的值还原成四字节的整形值 2 bool uncompress_(char *buf, size_t len, unsigned int &i) 3 { 4 i = 0; 5 for (int index = 0; index 三、关于跨系统与跨语言之间的网络通信协议解析与识别问题 由于我们的即时通讯同时涉及到Java和C++两种编程语言,且有windows、linux、安卓三个平台,而我们为了保障学习的质量和效果,所以我们不用第三跨平台库(其实我们也是在学习如何编写这些跨平台库的原理),所以我们需要学习以下如何在Java语言中去解析C++的网络数据包或者反过来。安卓端发送的数据使用Java语言编写,pc与服务器发送的数据使用C++编写,这里以在Java中解析C++网络数据包为例。 这对于很多人来说是一件很困难的事情,所以只能变着法子使用第三方的库。其实只要你掌握了一定的基础知识,利用一些现成的字节流抓包工具(如tcpdump、wireshark)很容易解决这个问题。我们这里使用tcpdump工具来尝试分析和解决这个问题。 首先,我们需要明确字节序列这样一个概念,即我们说的大端编码(big endian)和小端编码(little endian),x86和x64系列的cpu使用小端编码,而数据在网络上传输,以及Java语言中,使用的是大端编码。那么这是什么意思呢? 我们举个例子,看一个x64机器上的32位数值在内存中的存储方式: i在内存中的地址序列是0x003CF7C4~0x003CF7C8,值为40 e2 01 00。 十六进制0001e240正好等于10进制123456,也就是说小端编码中权重高的的字节值存储在内存地址高(地址值较大)的位置,权重值低的字节值存储在内存地址低(地址值较小)的位置,也就是所谓的高高低低。 相反,大端编码的规则应该是高低低高,也就是说权值高字节存储在内存地址低的位置,权值低的字节存储在内存地址高的位置。 所以,如果我们一个C++程序的int32值123456不作转换地传给Java程序,那么Java按照大端编码的形式读出来的值是:十六进制40E20100 = 十进制1088553216。 所以,我们要么在发送方将数据转换成网络字节序(大端编码),要么在接收端再进行转换。 下面看一下如果C++端传送一个如下数据结构,Java端该如何解析(由于Java中是没有指针的,也无法操作内存地址,导致很多人无从下手),下面利用tcpdump来解决这个问题的思路。 我们客户端发送的数据包: 其结构体定义如下: 利用tcpdump抓到的包如下: 放大一点: 我们白色标识出来就是我们收到的数据包。这里我想说明两点: 如果我们知道发送端发送的字节流,再比照接收端收到的字节流,我们就能检测数据包的完整性,或者利用这个来排查一些问题; 对于Java程序只要按照这个顺序,先利用java.net.Socket的输出流java.io.DataOutputStream对象readByte、readInt32、readInt32、readBytes、readBytes方法依次读出一个char、int32、int32、16个字节的字节数组、63个字节数组即可,为了还原像int32这样的整形值,我们需要做一些小端编码向大端编码的转换。 如需下载本站全部技术文章,可以在【高性能服务器开发】公众号回复关键字“文章下载”即可。最近更新时间: 2020-09-16 00:20:24 "},"articles/网络编程/TCP协议如何解决粘包、半包问题.html":{"url":"articles/网络编程/TCP协议如何解决粘包、半包问题.html","title":"TCP 协议如何解决粘包、半包问题","keywords":"","body":"TCP 协议如何解决粘包、半包问题 一 TCP 协议是流式协议 很多读者从接触网络知识以来,应该听说过这句话:TCP 协议是流式协议。那么这句话到底是什么意思呢?所谓流式协议,即协议的内容是像流水一样的字节流,内容与内容之间没有明确的分界标志,需要我们人为地去给这些协议划分边界。 举个例子,A 与 B 进行 TCP 通信,A 先后给 B 发送了一个 100 字节和 200 字节的数据包,那么 B 是如何收到呢?B 可能先收到 100 字节,再收到 200 字节;也可能先收到 50 字节,再收到 250 字节;或者先收到 100 字节,再收到 100 字节,再收到 200 字节;或者先收到 20 字节,再收到 20 字节,再收到 60 字节,再收到 100 字节,再收到 50 字节,再收到 50 字节…… 不知道读者看出规律没有?规律就是 A 一共给 B 发送了 300 字节,B 可能以一次或者多次任意形式的总数为 300 字节收到。假设 A 给 B 发送的 100 字节和 200 字节分别都是一个数据包,对于发送端 A 来说,这个是可以区分的,但是对于 B 来说,如果不人为规定多长为一个数据包,B 每次是不知道应该把收到的数据中多少字节作为一个有效的数据包的。而规定每次把多少数据当成一个包就是协议格式规范的内容之一。 经常会有新手写出类似下面这样的代码: 发送端: //...省略创建socket,建立连接等部分不相关的逻辑... char buf[] = \"the quick brown fox jumps over a lazy dog.\"; int n = send(socket, buf, strlen(buf), 0); //...省略出错处理逻辑... 接收端: //省略创建socket,建立连接等部分不相关的逻辑... char recvBuf[50] = { 0 }; int n = recv(socket, recvBuf, 50, 0); //省略出错处理逻辑... printf(\"recvBuf: %s\", recvBuf); 为了专注问题本身的讨论,我这里省略掉了建立连接和部分错误处理的逻辑。上述代码中发送端给接收端发送了一串字符”the quick brown fox jumps over a lazy dog.“,接收端收到后将其打印出来。 类似这样的代码在本机一般会工作的很好,接收端也如期打印出来预料的字符串,但是一放到局域网或者公网环境就出问题了,即接收端可能打印出来字符串并不完整;如果发送端连续多次发送字符串,接收端会打印出来的字符串不完整或出现乱码。不完整的原因很好理解,即对端某次收到的数据小于完整字符串的长度,recvBuf 数组开始被清空成 0,收到部分字符串后,该字符串的末尾仍然是 0,printf 函数寻找以 0 为结束标志的字符结束输出;乱码的原因是如果某次收入的数据不仅包含一个完整的字符串,还包含下一个字符串部分内容,那么 recvBuf 数组将会被填满,printf 函数输出时仍然会寻找以 0 为结束标志的字符结束输出,这样读取的内存就越界了,一直找到为止,而越界后的内存可能是一些不可读字符,显示出来后就乱码了。 我举这个例子希望你明白 能对TCP 协议是流式协议有一个直观的认识。正因为如此,所以我们需要人为地在发送端和接收端规定每一次的字节流边界,以便接收端知道从什么位置取出多少字节来当成一个数据包去解析,这就是我们设计网络通信协议格式的要做的工作之一。 二 如何解决粘包问题 网络通信程序实际开发中,或者技术面试时,面试官通常会问的比较多的一个问题是:网络通信时,如何解决粘包? 有的面试官可能会这么问:网络通信时,如何解决粘包、丢包或者包乱序问题?这个问题其实是面试官在考察面试者的网络基础知识,如果是 TCP 协议,在大多数场景下,是不存在丢包和包乱序问题的,TCP 通信是可靠通信方式,TCP 协议栈通过序列号和包重传确认机制保证数据包的有序和一定被正确发到目的地;如果是 UDP 协议,如果不能接受少量丢包,那就要自己在 UDP 的基础上实现类似 TCP 这种有序和可靠传输机制了(例如 RTP协议、RUDP 协议)。所以,问题拆解后,只剩下如何解决粘包的问题。 先来解释一下什么是粘包,所谓粘包就是连续给对端发送两个或者两个以上的数据包,对端在一次收取中可能收到的数据包大于 1 个,大于 1 个,可能是几个(包括一个)包加上某个包的部分,或者干脆就是几个完整的包在一起。当然,也可能收到的数据只是一个包的部分,这种情况一般也叫半包。 无论是半包还是粘包问题,其根源是上文介绍中 TCP 协议是流式数据格式。解决问题的思路还是想办法从收到的数据中把包与包的边界给区分出来。那么如何区分呢?目前主要有三种方法: 固定包长的数据包 顾名思义,即每个协议包的长度都是固定的。举个例子,例如我们可以规定每个协议包的大小是 64 个字节,每次收满 64 个字节,就取出来解析(如果不够,就先存起来)。 这种通信协议的格式简单但灵活性差。如果包内容不足指定的字节数,剩余的空间需要填充特殊的信息,如 \\0(如果不填充特殊内容,如何区分包里面的正常内容与填充信息呢?);如果包内容超过指定字节数,又得分包分片,需要增加额外处理逻辑——在发送端进行分包分片,在接收端重新组装包片(分包和分片内容在接下来会详细介绍)。 以指定字符(串)为包的结束标志 这种协议包比较常见,即字节流中遇到特殊的符号值时就认为到一个包的末尾了。例如,我们熟悉的 FTP协议,发邮件的 SMTP 协议,一个命令或者一段数据后面加上\"\\r\\n\"(即所谓的 CRLF)表示一个包的结束。对端收到后,每遇到一个”\\r\\n“就把之前的数据当做一个数据包。 这种协议一般用于一些包含各种命令控制的应用中,其不足之处就是如果协议数据包内容部分需要使用包结束标志字符,就需要对这些字符做转码或者转义操作,以免被接收方错误地当成包结束标志而误解析。 包头 + 包体格式 这种格式的包一般分为两部分,即包头和包体,包头是固定大小的,且包头中必须含有一个字段来说明接下来的包体有多大。 例如: struct msg_header { int32_t bodySize; int32_t cmd; }; 这就是一个典型的包头格式,bodySize 指定了这个包的包体是多大。由于包头大小是固定的(这里是 size(int32_t) + sizeof(int32_t) = 8 字节),对端先收取包头大小字节数目(当然,如果不够还是先缓存起来,直到收够为止),然后解析包头,根据包头中指定的包体大小来收取包体,等包体收够了,就组装成一个完整的包来处理。在有些实现中,包头中的 bodySize可能被另外一个叫 packageSize 的字段代替,这个字段的含义是整个包的大小,这个时候,我们只要用 packageSize 减去包头大小(这里是 sizeof(msg_header))就能算出包体的大小,原理同上。 在使用大多数网络库时,通常你需要根据协议格式自己给数据包分界和解析,一般的网络库不提供这种功能是出于需要支持不同的协议,由于协议的不确定性,因此没法预先提供具体解包代码。当然,这不是绝对的,也有一些网络库提供了这种功能。在 Java Netty 网络框架中,提供了FixedLengthFrameDecoder 类去处理长度是定长的协议包,提供了 DelimiterBasedFrameDecoder 类去处理按特殊字符作为结束符的协议包,提供 ByteToMessageDecoder 去处理自定义格式的协议包(可用来处理包头 + 包体 这种格式的数据包),然而在继承 ByteToMessageDecoder 子类中你需要根据你的协议具体格式重写 decode() 方法来对数据包解包。 这三种包格式,希望读者能在理解其原理和优缺点的基础上深入掌握。 三 解包与处理 在理解了前面介绍的数据包的三种格式后,我们来介绍一下针对上述三种格式的数据包技术上应该如何处理。其处理流程都是一样的,这里我们以包头 + 包体 这种格式的数据包来说明。处理流程如下: 假设我们的包头格式如下: //强制一字节对齐 #pragma pack(push, 1) //协议头 struct msg { int32_t bodysize; //包体大小 }; #pragma pack(pop) 那么上面的流程实现代码如下: //包最大字节数限制为10M #define MAX_PACKAGE_SIZE 10 * 1024 * 1024 void ChatSession::OnRead(const std::shared_ptr& conn, Buffer* pBuffer, Timestamp receivTime) { while (true) { //不够一个包头大小 if (pBuffer->readableBytes() readableBytes()=\" readableBytes() peek(), sizeof(msg)); //包头有错误,立即关闭连接 if (header.bodysize MAX_PACKAGE_SIZE) { //客户端发非法数据包,服务器主动关闭之 LOGE(\"Illegal package, bodysize: %lld, close TcpConnection, client: %s\", header.bodysize, conn->peerAddress().toIpPort().c_str()); conn->forceClose(); return; } //收到的数据不够一个完整的包 if (pBuffer->readableBytes() retrieve(sizeof(msg)); //inbuf用来存放当前要处理的包 std::string inbuf; inbuf.append(pBuffer->peek(), header.bodysize); pBuffer->retrieve(header.bodysize); //解包和业务处理 if (!Process(conn, inbuf.c_str(), inbuf.length())) { //客户端发非法数据包,服务器主动关闭之 LOGE(\"Process package error, close TcpConnection, client: %s\", conn->peerAddress().toIpPort().c_str()); conn->forceClose(); return; } }// end while-loop } 上述流程代码的处理过程和流程图中是一致的,pBuffer 这里是一个自定义的接收缓冲区,这里的代码,已经将收到的数据放入了这个缓冲区,所以判断当前已收取的字节数目只需要使用这个对象的相应方法即可。上述代码有些细节我需要强调一下: 取包头时,你应该拷贝一份数据包头大小的数据出来,而不是从缓冲区 pBuffer 中直接将数据取出来(即取出来的数据从 pBuffer 中移除),这是因为倘若接下来根据包头中的字段得到包体大小时,如果剩余数据不够一个包体大小,你又得把这个包头数据放回缓冲区。为了避免这种不必要的操作,只有缓冲区数据大小够整个包的大小(代码中:header.bodysize + sizeof(msg))你才需要把整个包大小的数据从缓冲区移除,这也是这里的 pBuffer->peek() 方法 peek 单词的含义(中文可以翻译成“瞟一眼”或者“偷窥”)。 通过包头得到包体大小时,你一定要对 bodysize 的数值进行校验,我这里要求 bodysize 必须大于 0 且不大于 10 1024 1024(即 10 M)。当然,实际开发中,你可以根据你自己的需求要决定 bodysize 的上下限(包体大小是 0 字节的包在某些业务场景下是允许的)。记住,一定要判断这个上下限,因为假设这是一个非法的客户端发来的数据,其 bodysize 设置了一个比较大的数值,例如 1 1024 1024 * 1024(即 1 G),你的逻辑会让你一直缓存该客户端发来的数据,那么很快你的服务器内存将会被耗尽,操作系统在检测到你的进程占用内存达到一定阈值时会杀死你的进程,导致服务不能再正常对外服务。如果你检测了 bodysize 字段的是否满足你设置的上下限,对于非法的 bodysize,直接关闭这路连接即可。这也是服务的一种自我保护措施,避免因为非法数据包带来的损失。 不知道你有没有注意到整个判断包头、包体以及处理包的逻辑放在一个 while 循环里面,这是必要的。如果没有这个 while 循环,当你一次性收到多个包时,你只会处理一个,下次接着处理就需要等到新一批数据来临时再次触发这个逻辑。这样造成的结果就是,对端给你发送了多个请求,你最多只能应答一个,后面的应答得等到对端再次给你发送数据时。这就是对粘包逻辑的正确处理。 以上逻辑和代码是最基本的粘包和半包处理机制,也就是所谓的技术上的解包处理逻辑(业务上的解包处理逻辑后面章节再介绍)。希望读者能理解他们,在理解了他们的基础之上,我们可以给解包拓展很多功能,例如,我们再给我们的协议包增加一个支持压缩的功能,我们的包头变成下面这个样子: #pragma pack(push, 1) //协议头 struct msg { char compressflag; //压缩标志,如果为1,则启用压缩,反之不启用压缩 int32_t originsize; //包体压缩前大小 int32_t compresssize; //包体压缩后大小 char reserved[16]; //保留字段,用于将来拓展 }; #pragma pack(pop) 修改后的代码如下: void ChatSession::OnRead(const std::shared_ptr& conn, Buffer* pBuffer, Timestamp receivTime) { while (true) { //不够一个包头大小 if (pBuffer->readableBytes() readableBytes()=\" readableBytes() peek(), sizeof(msg)); //数据包压缩过 if (header.compressflag == PACKAGE_COMPRESSED) { //包头有错误,立即关闭连接 if (header.compresssize MAX_PACKAGE_SIZE || header.originsize MAX_PACKAGE_SIZE) { //客户端发非法数据包,服务器主动关闭之 LOGE(\"Illegal package, compresssize: %lld, originsize: %lld, close TcpConnection, client: %s\", header.compresssize, header.originsize, conn->peerAddress().toIpPort().c_str()); conn->forceClose(); return; } //收到的数据不够一个完整的包 if (pBuffer->readableBytes() retrieve(sizeof(msg)); std::string inbuf; inbuf.append(pBuffer->peek(), header.compresssize); pBuffer->retrieve(header.compresssize); std::string destbuf; if (!ZlibUtil::UncompressBuf(inbuf, destbuf, header.originsize)) { LOGE(\"uncompress error, client: %s\", conn->peerAddress().toIpPort().c_str()); conn->forceClose(); return; } //业务逻辑处理 if (!Process(conn, destbuf.c_str(), destbuf.length())) { //客户端发非法数据包,服务器主动关闭之 LOGE(\"Process error, close TcpConnection, client: %s\", conn->peerAddress().toIpPort().c_str()); conn->forceClose(); return; } } //数据包未压缩 else { //包头有错误,立即关闭连接 if (header.originsize MAX_PACKAGE_SIZE) { //客户端发非法数据包,服务器主动关闭之 LOGE(\"Illegal package, compresssize: %lld, originsize: %lld, close TcpConnection, client: %s\", header.compresssize, header.originsize, conn->peerAddress().toIpPort().c_str()); conn->forceClose(); return; } //收到的数据不够一个完整的包 if (pBuffer->readableBytes() retrieve(sizeof(msg)); std::string inbuf; inbuf.append(pBuffer->peek(), header.originsize); pBuffer->retrieve(header.originsize); //业务逻辑处理 if (!Process(conn, inbuf.c_str(), inbuf.length())) { //客户端发非法数据包,服务器主动关闭之 LOGE(\"Process error, close TcpConnection, client: %s\", conn->peerAddress().toIpPort().c_str()); conn->forceClose(); return; } }// end else }// end while-loop } 这段代码先根据包头的压缩标志字段判断包体是否有压缩,如果有压缩,则取出包体大小去解压,解压后的数据才是真正的业务数据。整个程序执行流程图如下: 如需下载本站全部技术文章,可以在【高性能服务器开发】公众号回复关键字“文章下载”即可。最近更新时间: 2020-09-16 00:25:13 "},"articles/网络编程/网络通信中收发数据的正确姿势.html":{"url":"articles/网络编程/网络通信中收发数据的正确姿势.html","title":"网络通信中收发数据的正确姿势","keywords":"","body":"网络通信中收发数据的正确姿势 在网络通信中,我们可能既要通过 socket 去发送数据也要通过 socket 来收取数据。那么一般的网络通信框架是如何收发数据的呢?注意,这里讨论的范围是基于各种 IO 复用函数(select、poll、epoll 等)来判断 socket 读写来收发数据,其他情形比较简单,这里就不提了。 我们这里以服务器端为例。服务器端接受客户端连接后,产生一个与客户端连接对应的 socket(Linux 下也叫 fd,为了叙述方便,以后称之为 clientfd),我们可以通过这个 clientfd 收取从客户端发来的数据,也可以通过这个 clientfd 将数据发往客户端。但是收与发在操作流程上是有明显的区别的。 收数据的正确姿势 对于收数据,当接受连接成功得到 clientfd 后,我们会将该 clientfd 绑定到相应的 IO 复用函数上并监听其可读事件。不同的 IO 复用函数可读事件标志不一样,例如对于 poll 模型,可读标志是 POLLIN,对于 epoll 模型,可读事件标志是 EPOLLIN。当可读事件触发后,我们调用 recv 函数从 clientfd 上收取数据(这里不考虑出错的情况),根据不同的网络模式我们可能会收取部分,或一次性收完。收取到的数据我们会放入接收缓冲区内,然后做解包操作。这就是收数据的全部“姿势”。对于使用 epoll 的 LT 模式(水平触发模式),我们每次可以只收取部分数据;但是对于 ET 模式(边缘触发模式),我们必须将本次收到的数据全部收完。 ET 模式收完的标志是 recv 或者 read 函数的返回值是 -1,错误码是 EWOULDBLOCK,针对 Windows 和 Linux 下区别,前面章节已经详细地说过了。 这就是读数据的全部姿势。流程图如下: 发数据的正确姿势 对于发数据,除了 epoll 模型的 ET 模式外,epoll 的 LT 模式或者其他 IO 复用函数,我们通常都不会去注册监听该 clientfd 的可写事件。这是因为,只要对端正常收数据,一般不会出现 TCP 窗口太小导致 send 或 write 函数无法写的问题。因此大多数情况下,clientfd 都是可写的,如果注册了可写事件,会导致一直触发可写事件,而此时不一定有数据需要发送。故而,如果有数据要发送一般都是调用 send 或者 write 函数直接发送,如果发送过程中, send 函数返回 -1,并且错误码是 EWOULDBLOCK 表明由于 TCP 窗口太小数据已经无法写入时,而仍然还剩下部分数据未发送,此时我们才注册监听可写事件,并将剩余的服务存入自定义的发送缓冲区中,等可写事件触发后再接着将发送缓冲区中剩余的数据发送出去,如果仍然有部分数据不能发出去,继续注册可写事件,当已经无数据需要发送时应该立即移除对可写事件的监听。这是目前主流网络库的做法。 流程图如下: 上述逻辑示例如下: 直接尝试发送消息处理逻辑: /** *@param data 待发送的数据 *@param len 待发送数据长度 */ void TcpConnection::sendMessage(const void* data, size_t len) { int32_t nwrote = 0; size_t remaining = len; bool faultError = false; if (state_ == kDisconnected) { LOGW(\"disconnected, give up writing\"); return; } // 当前未监听可写事件,且发送缓冲区中没有遗留数据 if (!channel_->isWriting() && outputBuffer_.readableBytes() == 0) { //直接发送数据 nwrote = sockets::write(channel_->fd(), data, len); if (nwrote >= 0) { remaining = len - nwrote; } else // nwrote 0) { //将剩余部分加入发送缓冲区 outputBuffer_.append(static_cast(data) + nwrote, remaining); if (!channel_->isWriting()) { //注册可写事件 channel_->enableWriting(); } } } 不能全部发出去监听可写事件后,可写事件触发后处理逻辑: //可写事件触发后会调用handleWrite()函数 void TcpConnection::handleWrite() { //将发送缓冲区中的数据发送出去 int32_t n = sockets::write(channel_->fd(), outputBuffer_.peek(), outputBuffer_.readableBytes()); if (n > 0) { //发送多少从发送缓冲区移除多少 outputBuffer_.retrieve(n); //如果发送缓冲区中已经没有剩余,则移除监听可写事件 if (outputBuffer_.readableBytes() == 0) { //移除监听可写事件 channel_->disableWriting(); if (state_ == kDisconnecting) { shutdown(); } } } else { //发数据出错处理 LOGSYSE(\"TcpConnection::handleWrite\"); handleClose(); } } 对于 epoll LT 模式注册监听一次可写事件后,可写事件触发后,尝试发送数据,如果数据此时还不能全部发送完,不用再次注册可写事件;如果是 epoll 的 ET 模式,注册监听可写事件后,可写事件触发后,尝试发送数据,如果数据此时还不能全部发送完,需要再次注册可写事件以便让可写事件下次再次触发(给予再次发数据的机会)。当然,这只是理论上的情况,实际开发中,如果一段数据反复发送都不能完全发送完(例如对端先不收,后面每隔很长时间再收一个字节),我们可以设置一个最大发送次数或最大发送总时间,超过这些限定,我们可以认为对端出了问题,应该立即清空发送缓冲区并关闭连接。 本节的标题是“收发数据的正确姿势”,其实还可以换一种说法,即“检测网络事件的正确姿势”,这里意指检测一个 fd 的读写事件的区别(对于侦听 fd,只检测可读事件): 在 select、poll 和 epoll 的 LT 模式下,可以直接设置检测 fd 的可读事件; 在 select、poll 和 epoll 的 LT 模式下不要直接设置检测 fd 的可写事件,应该先尝试发送数据,因为 TCP 窗口太小发不出去再设置检测 fd 的可写事件,一旦数据发出去应立即取消对可写事件的检测。 在 epoll 的 ET 模式下,需要发送数据时,每次都要设置检测可写事件。 如需下载本站全部技术文章,可以在【高性能服务器开发】公众号回复关键字“文章下载”即可。最近更新时间: 2020-09-16 00:29:57 "},"articles/网络编程/服务器端发数据时,如果对端一直不收,怎么办?.html":{"url":"articles/网络编程/服务器端发数据时,如果对端一直不收,怎么办?.html","title":"服务器端发数据时,如果对端一直不收,怎么办?","keywords":"","body":"服务器端发数据时,如果对端一直不收,怎么办? 这类问题一般出现在跨部门尤其是与外部开发人员合作的时候。假设现在有这样一种情况,我们的服务器提供对外的服务,指定好了协议,然后对外提供服务,客户端由外部人员去开发,由于存在太多的不确定性,如果我们在给对端(客户端)发送数据时,对端因为一些问题(可能是逻辑 bug 或者其他的一些问题)一直不从 socket 系统缓冲区中收取数据,而服务器端可能定期产生一些数据需要发送给客户端,再发了一段时间后,由于 TCP 窗口太小,导致数据发送不出去,这样待发送的数据会在服务器端对应的连接的发送缓冲区中积压,如果我们不做任何处理,很快系统就会因为缓冲区过大内存耗尽,导致服务被系统杀死。 对于这种情况,我们一般建议从以下几个方面来增加一些防御措施: 设置每路发送连接的发送缓冲区大小上限(如 2 M,或者小于这个值),当某路连接上的数据发送不出去的时候,即将数据存入发送缓冲区时,先判断一下缓冲区最大剩余空间,如果剩余空间已经小于我们要放入的数据大小,也就是说缓冲区中数据大小会超过了我们规定的上限,则认为该连接出现了问题,关闭该路连接并回收相应的资源(如清空缓冲区、回收套接字资源等)。示例代码如下: //outputBuffer_为发送缓冲区对象 size_t remainingLen = outputBuffer_.remainingBytes(); //如果加入到缓冲区中的数据长度超出了发送缓冲区最大剩余量 if (remainingLen (dataToAppend.c_str()), dataToAppend.length()); 还有另外一种场景,当有一部分数据已经积压在发送缓冲区了,此后服务器端未产生新的待发送的数据,此时如果不做任何处理,发送缓冲区的数据会一直积压,但是发送缓冲区的数据容量也不会超过上限。如果不做任何处理的话,该数据会一直在缓冲区中积压,白白浪费系统资源。对于这种情况一般我们会设置一个定时器,每隔一段时间(如 3 秒)去检查一下各路连接的发送缓冲区中是否还有数据未发送出去,也就是说如果一个连接超过一定时间内还存在未发送出去的数据,我们也认为该连接出现了问题,我们可以关闭该路连接并回收相应的资源(如清空缓冲区、回收套接字资源等)。示例代码如下: //每3秒检测一次 const int SESSION_CHECK_INTERVAL = 3000; SetTimer(SESSION_CHECK_TIMER_ID, SESSION_CHECK_INTERVAL); void CSessionManager::OnTimer() { for (auto iter = m_mapSession.begin(); iter != m_mapSession.end(); ++iter) { if (!CheckSession(iter->value)) { //关闭session,回收相关的资源 iter->value->ForceClose(); iter = m_mapSession.erase(iter); } } } void CSessionManager::CheckSession(CSession* pSession) { if (!pSession->GetConnection().OutputBuffer.IsEmpty()) return false; return true; } 上述代码,每隔 3 秒检测所有的 Session 的对应的 Connection 对象,如果发现发送缓冲区非空,说明该连接中发送缓冲区中数据已经驻留 3 秒了,将该连接关闭并清理资源。 如需下载本站全部技术文章,可以在【高性能服务器开发】公众号回复关键字“文章下载”即可。最近更新时间: 2020-09-16 00:30:29 "},"articles/程序员必知必会的网络命令/":{"url":"articles/程序员必知必会的网络命令/","title":"程序员必知必会的网络命令","keywords":"","body":"程序员必知必会的网络命令 利用telnet命令发电子邮件 做Java或者C++开发都应该知道的lsof命令 Linux网络故障排查的瑞士军刀nc命令 Linux tcpdump使用详解 从抓包的角度分析connect函数的连接过程 服务器开发中网络数据分析与故障排查经验漫谈 如需下载本站全部技术文章,可以在【高性能服务器开发】公众号回复关键字“文章下载”即可。最近更新时间: 2020-10-08 11:58:15 "},"articles/程序员必知必会的网络命令/利用telnet命令发电子邮件.html":{"url":"articles/程序员必知必会的网络命令/利用telnet命令发电子邮件.html","title":"利用telnet命令发电子邮件","keywords":"","body":"利用 telnet 命令发电子邮件 telnet 命令是我们最常用的网络调试命令之一。如果你的机器上还没有安装 telnet 命令,可以使用如下命令安装一下: yum install telnet 如果一个服务程序对外开启了侦听服务,我们都可以使用 telnet ip port 来连接上去,例如: [root@localhost ~]# telnet 120.55.94.78 8888 Trying 120.55.94.78... Connected to 120.55.94.78. Escape character is '^]'. 如果不指定端口号,telnet 会使用默认 23 号端口。 反过来说,可以通过 telnet 命令去检测指定 ip 地址和端口号的侦听服务是否存在。知道这点很重要,我们可以利用这个去检测一个服务是否可以正常连接。举个例子,比如某次从某处得到一个代码下载地址,这是一个 svn 地址:svn://120.55.94.78/mycode/mybook。为了检测这个 svn 服务是否还能正常对外服务,我们可以先用 ping 命令去检测一下到达这个 ip:120.55.94.78 的网络是否畅通: [root@localhost ~]# ping 120.55.94.78 PING 120.55.94.78 (120.55.94.78) 56(84) bytes of data. 64 bytes from 120.55.94.78: icmp_seq=1 ttl=128 time=15.3 ms 64 bytes from 120.55.94.78: icmp_seq=2 ttl=128 time=14.3 ms 64 bytes from 120.55.94.78: icmp_seq=3 ttl=128 time=16.4 ms 64 bytes from 120.55.94.78: icmp_seq=4 ttl=128 time=16.1 ms 64 bytes from 120.55.94.78: icmp_seq=5 ttl=128 time=15.5 ms ^C --- 120.55.94.78 ping statistics --- 5 packets transmitted, 5 received, 0% packet loss, time 4007ms rtt min/avg/max/mdev = 14.343/15.568/16.443/0.723 ms 如果网络畅通,我们再用 telnet 去连接上去,由于 svn 服务器使用的默认端口是 3690,我们执行如下命令: [root@localhost ~]# telnet 120.55.94.78 3690 Trying 120.55.94.78... Connected to 120.55.94.78. Escape character is '^]'. ( success ( 2 2 ( ) ( edit-pipeline svndiff1 absent-entries commit-revprops depth log-revprops atomic-revprops partial-replay ) ) ) 如上所示,证明这个 svn 服务是正常开启对外服务的。反之,如果 telnet 命令连不上,说明这个服务不能被外部网络正常连接,我们就没必要去做进一步的尝试了。 同样的道理,对于一个 Web 服务,如 baidu.com,由于我们平常都可以通过 www.baidu.com 去访问百度的页面,Web 服务器默认的端口号是 80,我们使用 telnet www.baidu.com 80 应该也可以连接成功的: [root@localhost ~]# telnet www.baidu.com 80 Trying 115.239.211.112... Connected to www.baidu.com. Escape character is '^]'. hello HTTP/1.1 400 Bad Request Connection closed by foreign host. 我们使用 telnet 命令连接上以后,我们随意发送了一个 hello 消息,由于是非法的 http 请求,被服务器关闭了连接。 telnet 命令不仅可以连接某个服务器,还能与服务器进行交互,这通常用于操作一些接受纯文本数据的服务器程序,如 FTP 服务、邮件服务等等。为了演示如何利用 telnet 命令收发数据,我们这里利用 telnet 命令来模拟给某个邮箱发送一封邮件,发送邮件我们通常使用的是 SMTP 协议,该协议默认使用的端口为 25。 假设我们的发件地址是:testformybook@163.com,收件地址是:balloonwj@qq.com。 其中发件地址是一个 163 邮箱,如果你没有的话可以去申请一个,申请后进入邮箱,在设置页面获得网易邮箱的 smtp 服务的服务器地址: 我们得到的地址 smptp 地址是 smtp.163.com,端口号是 25。 同时,我们需要开启客户端授权,设置一个客户端授权码: 我们这里将授权码设置为 2019hhxxttxs。 早些年很多邮件服务器允许在其他客户端登陆只需要输入正确的邮件服务器地址、用户名和密码就可以了,后来出于安全考虑,很多邮箱采用了授权码机制,在其他第三方客户端登陆该邮箱时需要输入授权码(不是密码),且需要用户主动打开允许第三方客户端登陆的配置选项。 配置完成以后,我们现在就可以利用 telnet 命令连接 163 邮件服务器并发送邮件了,由于在登陆的过程中需要验证用户名和授权码,而且用户名和授权码必须使用 base64 编码之后的,我们先将用户名和授权码的 base64 码准备好,用的时候直接拷贝过去: 原文 base64 码 testformybook dGVzdGZvcm15Ym9vaw== 2019hhxxttxs MjAxOWhoeHh0dHhz 如果你不知道 base64 编码的原理,可以从网上搜索找一个 base64 编解码工具,例如:https://base64.supfree.net/。 在整个演示过程我们一共需要使用如下 SMTP 协议命令: 命令 含义 helo 向 smtp 服务器发送问候信息 auth login 请求登陆验证 data 请求输入邮件正文 SMTP 协议 是文本协议,每一个数据包以 \\r\\n 结束(Windows 系统下默认换行符)。 我们来看一下演示过程: [root@localhost ~]# telnet smtp.163.com 25 Trying 220.181.12.14... Connected to smtp.163.com. Escape character is '^]'. 220 163.com Anti-spam GT for Coremail System (163com[20141201]) helo 163.com 250 OK auth login 334 dXNlcm5hbWU6 dGVzdGZvcm15Ym9vaw== 334 UGFzc3dvcmQ6 MjAxOWhoeHh0dHhz 235 Authentication successful mail from: 250 Mail OK rcpt to: 250 Mail OK data 354 End data with . from:testformybook@163.com to: balloonwj@qq.com subject: Test Hello, this is a message from 163. . 250 Mail OK queued as smtp10,DsCowADHAgQS1IBcwtExJA--.62308S2 1551946998 Connection closed by foreign host. [root@localhost ~]# 我们来分析一下上述操作过程: 使用 telnet smtp.163.com 25 连接 163 邮件服务器;连接成功以后,服务器给我们发送了一条欢迎消息: 220 163.com Anti-spam GT for Coremail System (163com[20141201])\\r\\n 接着,必须向服务器发送一条问候消息,使用 helo 163.com,当然 163.com 这个是问候内容,可以随意填写,然后回车,最终组成的数据包内容是: helo 163.com\\r\\n 接着服务器会回复一条状态码是 250 的消息,这里是: 250 OK\\r\\n 我们再输入命令 auth login 请求验证,然后按回车,实际发送给服务器的是: auth login\\r\\n 服务器应答状态码 334: 334 dXNlcm5hbWU6\\r\\n dXNlcm5hbWU6 是字符串 username: 的 base64 码。 我们输入我们的用户名 testformybook 的 base64 码,然后按回车: dGVzdGZvcm15Ym9vaw==\\r\\n 服务器应答状态码 334: 334 UGFzc3dvcmQ6\\r\\n UGFzc3dvcmQ6 是字符串 Password: 的 base64 码。这里实际上要求我们输入的是上文介绍的授权码,而不是密码。 我们输入 MjAxOWhoeHh0dHhz,并回车: MjAxOWhoeHh0dHhz\\r\\n 服务器提示我们授权成功(应答状态码 235): 235 Authentication successful\\r\\n 接着输入邮件的发件地址和收件地址,服务器也会给我们响应的应答(应答状态码 250): mail from: \\r\\n 250 Mail OK\\r\\n rcpt to: \\r\\n 250 Mail OK\\r\\n 接着输入 data 命令设置邮件的主题、正文、对方收到邮件后显示的的发件人信息等: data\\r\\n 354 End data with . 服务器应答 354,并且提示,如果确定结束输入邮件正文就先按一个回车键,再输入一个点 .,再接着回车,这样邮件就发送出去了。 服务器应答 250: 250 Mail OK queued as smtp10,DsCowADHAgQS1IBcwtExJA--.62308S2 1551946998 如果我们想退出,输入 quit 或 close 都可以。 最终,这封邮件就发出去了,我们去 balloonwj@qq.com 这个邮箱查看一下: 如果你在实际实验时,对端没有收到邮件,请查看下你的垃圾箱或者邮箱反垃圾邮件设置,有可能被邮箱反垃圾邮件机制给拦截了。 上述在组装 SMTP 协议包时涉及到很多状态码,常见的 SMTP 状态码含义如下: 211 帮助返回系统状态 214 帮助信息 220 服务准备就绪 221 关闭连接 235 用户验证成功 250 请求操作就绪 251 用户不在本地,转寄到其他路径 334 等待用户输入验证信息 354 开始邮件输入 421 服务不可用 450 操作未执行,邮箱忙 451 操作中止,本地错误 452 操作未执行,存储空间不足 500 命令不可识别或语言错误 501 参数语法错误 502 命令不支技 503 命令顺序错误 504 命令参数不支持 550 操作未执行,邮箱不可用 551 非本地用户 552 中止存储空间不足 553 操作未执行,邮箱名不正确 554 传输失败 由于我们使用的开发机器以 Windows 居多,默认情况下,Windows 系统的 telnet 命令是没有开启的,我们可以在【控制面板】- 【程序】- 【程序和功能】- 【打开或关闭Windows功能】中打开telnet功能。如下图所示: 如需下载本站全部技术文章,可以在【高性能服务器开发】公众号回复关键字“文章下载”即可。最近更新时间: 2020-09-20 13:12:08 "},"articles/程序员必知必会的网络命令/做Java或者C++开发都应该知道的lsof命令.html":{"url":"articles/程序员必知必会的网络命令/做Java或者C++开发都应该知道的lsof命令.html","title":"做Java或者C++开发都应该知道的lsof命令","keywords":"","body":"做 Java 或者 C++ 开发都应该知道的 lsof 命令 lsof 命令是 Linux 系统的扩展工具,它的含义是 list opened filedesciptor (列出已经打开的文件描述符),在 Linux 系统中,所有的与资源句柄相关的东西都可以统一抽象成文件描述符(filedescriptor,简称 fd)。一个文件句柄是一个 fd,一个 socket 对象也可以称之为 fd 等等。 默认情况下,系统是不存在这个命令的,你需要安装一下,使用如下命令安装: yum install lsof 我们来看一下这个命令的使用效果: COMMAND PID TID USER FD TYPE DEVICE SIZE/OFF NODE NAME systemd 1 root cwd DIR 202,1 4096 2 / nscd 453 469 nscd 8u netlink 0t0 11017 ROUTE nscd 453 470 nscd cwd DIR 202,1 4096 2 / nscd 453 470 nscd rtd DIR 202,1 4096 2 / nscd 453 470 nscd txt REG 202,1 180272 146455 /usr/sbin/nscd nscd 453 470 nscd mem REG 202,1 217032 401548 /var/db/nscd/hosts nscd 453 470 nscd mem REG 202,1 90664 132818 /usr/lib64/libz.so.1.2.7 nscd 453 470 nscd mem REG 202,1 68192 133155 /usr/lib64/libbz2.so.1.0.6 nscd 453 470 nscd mem REG 202,1 153192 133002 /usr/lib64/liblzma.so.5.0.99 nscd 453 470 nscd mem REG 202,1 91496 133088 nscd 453 471 nscd 5u a_inode 0,9 0 4796 [eventpoll] nscd 453 471 nscd 6r REG 202,1 217032 401548 /var/db/nscd/hosts nscd 453 471 nscd 7u unix 0xffff880037497440 0t0 11015 /var/run/nscd/socket nscd 453 471 nscd 8u netlink 0t0 11017 ROUTE imgserver 611 zhangyl cwd DIR 202,1 4096 1059054 /home/zhangyl/flamingoserver imgserver 611 zhangyl rtd DIR 202,1 4096 2 / imgserver 611 zhangyl txt REG 202,1 4788917 1057044 /home/zhangyl/flamingoserver/imgserver imgserver 611 zhangyl 24u a_inode 0,9 0 4796 [eventfd] imgserver 611 zhangyl 25u IPv4 55707643 0t0 TCP *:commtact-http (LISTEN) imgserver 611 zhangyl 26r CHR 1,3 0t0 4800 /dev/null imgserver 611 613 zhangyl 32w REG 202,1 131072 2754609 /home/zhangyl/flamingoserver/imgcache/258bfb8945288a117d98d440986d7a03 结果显示中列出了各个进程打开的各种 fd 类型,对于 Uinx Socket,lsof 命令会显示出其详细的路径,打开的文件 fd 亦是如此。 使用 lsof 命令有三点需要注意: 默认情况下,lsof 的输出比较多,我们可以使用 grep 命令过滤我们想要查看的进程打开的 fd 信息,如: lsof -i | grep myapp 或者使用 lsof -p pid 也能过滤出指定的进程打开的 fd 信息: [root@iZ238vnojlyZ ~]# lsof -p 26621 COMMAND PID USER FD TYPE DEVICE SIZE/OFF NODE NAME chatserve 26621 zhangyl cwd DIR 202,1 4096 1059054 /home/zhangyl/flamingoserver chatserve 26621 zhangyl rtd DIR 202,1 4096 2 / chatserve 26621 zhangyl txt REG 202,1 8027035 1051942 /home/zhangyl/flamingoserver/chatserver chatserve 26621 zhangyl mem REG 202,1 61928 141417 /usr/lib64/libnss_files-2.17.so chatserve 26621 zhangyl mem REG 202,1 44096 143235 /usr/lib64/librt-2.17.so chatserve 26621 zhangyl mem REG 202,1 19520 137064 /usr/lib64/libdl-2.17.so chatserve 26621 zhangyl mem REG 202,1 2112384 132824 /usr/lib64/libc-2.17.so chatserve 26621 zhangyl mem REG 202,1 142304 132850 /usr/lib64/libpthread-2.17.so chatserve 26621 zhangyl mem REG 202,1 88720 135291 /usr/lib64/libgcc_s-4.8.5-20150702.so.1 chatserve 26621 zhangyl mem REG 202,1 1141560 137077 /usr/lib64/libm-2.17.so chatserve 26621 zhangyl mem REG 202,1 999944 140059 /usr/lib64/libstdc++.so.6.0.19 chatserve 26621 zhangyl mem REG 202,1 9879756 269001 /usr/lib64/mysql/libmysqlclient.so.20.3.4 chatserve 26621 zhangyl mem REG 202,1 164440 133622 /usr/lib64/ld-2.17.so chatserve 26621 zhangyl 0u CHR 1,3 0t0 4800 /dev/null chatserve 26621 zhangyl 1u CHR 1,3 0t0 4800 /dev/null chatserve 26621 zhangyl 2u CHR 1,3 0t0 4800 /dev/null chatserve 26621 zhangyl 3u a_inode 0,9 0 4796 [eventpoll] chatserve 26621 zhangyl 4u a_inode 0,9 0 4796 [timerfd] chatserve 26621 zhangyl 5u a_inode 0,9 0 4796 [eventfd] chatserve 26621 zhangyl 7u a_inode 0,9 0 4796 [eventpoll] lsof 命令只能查看到当前用户有权限查看到的进程 fd 信息,对于其没有权限的进程,最右边一列会显示 “Permission denied”。如下所示: sshd 26759 root cwd unknown /proc/26759/cwd (readlink: Permission denied) sshd 26759 root rtd unknown /proc/26759/root (readlink: Permission denied) sshd 26759 root txt unknown /proc/26759/exe (readlink: Permission denied) sshd 26759 root NOFD /proc/26759/fd (opendir: Permission denied) bash 26761 root cwd unknown /proc/26761/cwd (readlink: Permission denied) bash 26761 root rtd unknown /proc/26761/root (readlink: Permission denied) bash 26761 root txt unknown /proc/26761/exe (readlink: Permission denied) bash 26761 root NOFD /proc/26761/fd (opendir: Permission denied) lsof 命令第一栏进程名在显示的时候,默认显示前 n 个字符,这样如果我们需要显示完整的进程名以方便过滤的话,可以使用 +c 选项。用法如下: #最左侧的程序名最大显示 15 个字符 [zhangyl@iZ238vnojlyZ ~]$ lsof +c 15 当然,如果你设置值太大, lsof 便不会采用你设置的最大值,而是使用默认最大值。 上文也介绍了,socket 也是一种 fd,如果需要仅显示系统的网络连接信息,使用的是 -i 选项即可,这个选项可以形象地显示出系统当前的出入连接情况: 看到图中的连接方向了吧? 当然,和 netstat 命令一样,lsof -i 默认也会显示 ip 地址和端口号的别名,我们只要使用 -n 和 -P 选项就能相对应地显示 ip 地址和端口号了,综合起来就是 lsof -Pni: 如需下载本站全部技术文章,可以在【高性能服务器开发】公众号回复关键字“文章下载”即可。最近更新时间: 2020-09-20 13:09:29 "},"articles/程序员必知必会的网络命令/Linux网络故障排查的瑞士军刀.html":{"url":"articles/程序员必知必会的网络命令/Linux网络故障排查的瑞士军刀.html","title":"Linux网络故障排查的瑞士军刀nc命令","keywords":"","body":"Linux 网络故障排查的瑞士军刀 nc 即 netcat 命令,这个工具在排查网络故障时非常有用,功能非常强大,因而被业绩称为网络界的“瑞士军刀”,请读者务必掌握。默认系统是没有这个命令的,你需要安装一下,安装方法: yum install nc nc 命令常见的用法是模拟一个服务器程序被其他客户端连接,或者模拟一个客户端连接其他服务器,连接之后就可以进行数据收发。我们来逐一介绍一下: 模拟一个服务器程序 使用 -l 选项(单词 listen 的第一个字母)在某个 ip 地址和端口号上开启一个侦听服务,以便让其他客户端连接。通常为了显示更详细的信息,会带上 -v 选项。 示例如下: [root@iZ238vnojlyZ ~]# nc -v -l 127.0.0.1 6000 Ncat: Version 6.40 ( http://nmap.org/ncat ) Ncat: Listening on 127.0.0.1:6000 这样就在 6000 端口开启了一个侦听服务器,我们可以通过 127.0.0.1:6000 去连接上去;如果你的机器可以被外网访问,你可以使用 0.0.0.0 这样的侦听地址,示例: [root@iZ238vnojlyZ ~]# nc -v -l 0.0.0.0 6000 Ncat: Version 6.40 ( http://nmap.org/ncat ) Ncat: Listening on 0.0.0.0:6000 模拟一个客户端程序 用 nc 命令模拟一个客户端程序时,我们不需要使用 -l 选项,直接写上 ip 地址(或域名,nc 命令可以自动解析域名)和端口号即可,示例如下: ## 连接百度 web 服务器 [root@iZ238vnojlyZ ~]# nc -v www.baidu.com 80 Ncat: Version 6.40 ( http://nmap.org/ncat ) Ncat: Connected to 115.239.211.112:80. 输出提示我们成功连接上百度 Web 服务器。 我们知道客户端连接服务器一般都是操作系统随机分配一个可用的端口号连接到服务器上去,使用 nc 命令作为客户端时可以使用 -p 选项指定使用哪个端口号连接服务器,例如,我们希望通过本地 5555 端口连接百度的 Web 服务器,可以这么输入: [root@iZ238vnojlyZ ~]# nc -v -p 5555 www.baidu.com 80 Ncat: Version 6.40 ( http://nmap.org/ncat ) Ncat: Connected to 115.239.211.112:80. 再开一个 shell 窗口,我们使用上文中介绍的 lsof 命令验证一下,是否确实通过 5555 端口连接上了百度 Web 服务器。 [root@iZ238vnojlyZ ~]# lsof -Pni | grep nc nc 32610 root 3u IPv4 113369437 0t0 TCP 120.55.94.78:5555->115.239.211.112:80 (ESTABLISHED) 结果确实如我们所期望的。 当然,当使用 nc 命令与对端建立连接后,我们可以发送消息。下面通过一个具体的例子来演示一下这个过程 使用 nc -v -l 0.0.0.0 6000 模拟一个侦听服务,再新建一个 shell 窗口利用 nc -v 127.0.0.1 6000 模拟一个客户端程序连接刚才的服务器。 此时在客户端和服务器就可以相互发消息了。我们可以达到一个简化版的 IM 软件聊天效果: 客户端效果: 服务器端效果: 果你在使用 nc 命令发消息时不小心输入错误,可以使用 Ctrl + Backspace 键删除。 nc 命令默认会将 \\n 作为每条消息的结束标志,如果你指定了 -C 选项,将会使用 \\r\\n 作为消息结束标志。 nc 命令不仅可以发消息,同时也能发文件。我们也来演示一下: 需要注意的是是接收文件的一方是服务器端,发送文件的一方是客户端。 服务器端命令: nc -l ip地址 端口号 > 接收的文件名 客户端命令: nc ip地址 端口号 服务器端效果: 客户端效果: 意:这里客户端发送一个文件叫 index.html,服务器端以文件名 xxx.html 来保存,也就是说服务器端保存接收的文件名时不一定要使用客户端发送的文件名。 根据上面的介绍,当我们需要调试我们自己的服务器或者客户端程序时,又不想自己开发相应的对端,我们就可以使用 nc 命令去模拟。 当然,nc 命令非常强大,其功能远非本节介绍的这些,读者如果有兴趣可以去 nc 的 man 手册上获取更多的信息。 如需下载本站全部技术文章,可以在【高性能服务器开发】公众号回复关键字“文章下载”即可。最近更新时间: 2020-09-20 13:17:41 "},"articles/程序员必知必会的网络命令/Linuxtcpdump使用介绍.html":{"url":"articles/程序员必知必会的网络命令/Linuxtcpdump使用介绍.html","title":"Linux tcpdump使用详解","keywords":"","body":"Linux tcpdump 使用介绍 tcpdump 是 Linux 系统提供一个非常强大的抓包工具,熟练使用它,对我们排查网络问题非常有用。如果你的机器上还没有安装,可以使用如下命令安装: yum install tcpdump 如果要使用 tcpdump 命令必须具有 sudo 权限。 tcpdump 常用的选项有: -i 指定要捕获的目标网卡名,网卡名可以使用前面章节中介绍的 ifconfig 命令获得;如果要抓所有网卡的上的包,可以使用 any 关键字。 ## 抓取网卡ens33上的包 tcpdump -i ens33 ## 抓取所有网卡上的包 tcpdump -i any -X 以 ASCII 和十六进制的形式输出捕获的数据包内容,减去链路层的包头信息;-XX 以 ASCII 和十六进制的形式输出捕获的数据包内容,包括链路层的包头信息。 -n 不要将 ip 地址显示成别名的形式;-nn 不要将 ip 地址和端口以别名的形式显示。 -S 以绝对值显示包的 ISN 号(包序列号),默认以上一包的偏移量显示。 -vv 抓包的信息详细地显示;-vvv 抓包的信息更详细地显示。 -w 将抓取的包的原始信息(不解析,也不输出)写入文件中,后跟文件名: tcpdump -i any -w filename -r 从利用 -w 选项保存的包文件中读取数据包信息。 除了可以使用选项以外,tcpdump 还支持各种数据包过滤的表达式,常见的形式如下: ## 仅显示经过端口 8888 上的数据包(包括tcp:8888和udp:8888) tcpdump -i any 'port 8888' ## 仅显示经过端口是 tcp:8888 上的数据包 tcpdump -i any 'tcp port 8888' ## 仅显示从源端口是 tcp:8888 的数据包 tcpdump -i any 'tcp src port 8888' ## 仅显示源端口是 tcp:8888 或目标端口是 udp:9999 的包 tcpdump -i any 'tcp src port 8888 or udp dst port 9999' ## 仅显示地址是127.0.0.1 且源端口是 tcp:9999 的包 ,以 ASCII 和十六进制显示详细输出, ## 不显示 ip 地址和端口号的别名 tcpdump -i any 'src host 127.0.0.1 and tcp src port 9999' -XX -nn -vv 下面我们通过三个具体的操作实例来演示一下使用 tcpdump 的抓包过程。 实例一 :连接一个正常的侦听端口 假设我的服务器端的地址是 127.0.0.0.1:12345,使用 nc 命令在一个 shell 窗口创建一个服务器程序并在这个地址上进行侦听。 nc –v -l 127.0.0.0.112345 效果如下图所示: 在另外一个 shell 窗口开启 tcpdump 抓包: tcpdump -i any 'port 12345' -XX -nn -vv 效果如下: 然后再开一个 shell 窗口,利用 nc 命令创建一个客户端去连接服务器: nc -v 127.0.0.1 12345 效果如下: 我们抓到的包如下: 由于我们没有在客户端和服务器之间发送任何消息,其实抓到的包就是 TCP 连接的三次握手数据包,分析如下: 三次握手过程是客户端先给服务器发送一个 SYN,然后服务器应答一个 SYN + ACK,应答的序列号是递增 1 的,表示应答哪个请求,即从 4004096087 递增到 4004096088,接着客户端再应答一个 ACK。这个时候,我们发现发包序列号和应答序列号都变成 1了,这是 tcpdump 使用相对序号,我们加上 -S 选项后就变成绝对序列号了。 我们按 Ctrl + C 中断 tcpdump 抓包过程,并停止用 nc 开启的客户端和服务器程序,然后在前面的 tcpdump 命令后面加上 -S 选项重新开启抓包,使用命令如下: tcpdump -i any 'port 12345' -XX -nn -vv -S 然后再按顺序用 nc 命令再次启动下服务器和客户端程序。再次得到抓包结果: 这次得到的包的序号就是绝对序号了。 实例二:连接一个不存在的侦听端口 实例一演示的是正常的 TCP 连接三次握手过程捕获到的数据包。假如我们连接的服务器 ip 地址存在,但监听端口号不存在,我们看下 tcpdump 抓包结果。除了在一个 shell 窗口启动一个 tcpdump 抓包监测,在另外一个 shell 窗口用 nc 命令去连接一个不存在的侦听端口即可。 抓包数据如下: 这个时候客户端发送 SYN,服务器应答 ACK+RST,这个应答包会导致客户端的 connect 连接失败返回。 实例三:连接一个很遥远的 ip,或者网络繁忙的情形 实际情形中,还存在一种情况就是客户端访问一个很遥远的 ip,或者网络繁忙,服务器对客户端发送的 TCP 三次握手的网络 SYN 报文没有应答,会出现什么情况呢? 我们通过设置防火墙规则来模拟一下这种情况。使用 iptables -F 先将防火墙的已有规则都清理掉,然后给防火墙的 INPUT 链上增加一个规则:丢弃本地网卡 lo(也就是 127.0.0.1 这个回环地址)上的所有 SYN 包。 iptables -F iptables -I INPUT -p tcp --syn -i lo -j DROP 如果读者对 CentOS 的防火墙 iptables 命令有兴趣,可以使用 man iptables 在 man 手册中查看更详细的帮助。 在开启 tcpdump 抓包之后和设置防火墙规则之后,利用 nc 命令去连接 127.0.0.1:12345 这个地址。整个过程操作效果图如下: 接着,我们得到 tcpdump 抓到的数据包如下: 通过抓包数据我们可以看到:如果连接不上,一共重试了 5 次,重试的时间间隔是 1 秒,2秒,4秒,8秒,16秒,最后返回超时失败。这个重试次数在 /proc/sys/net/ipv4/tcp_syn_retries 内核参数中设置,默认为 6 。 TCP 四次挥手与三次握手基本上类似,这里就不贴出 tcpdump 抓包的详情了,强烈建议不熟悉这块的读者实际练习一遍。 如需下载本站全部技术文章,可以在【高性能服务器开发】公众号回复关键字“文章下载”即可。最近更新时间: 2020-09-20 13:27:19 "},"articles/程序员必知必会的网络命令/从抓包的角度分析connect函数的连接过程.html":{"url":"articles/程序员必知必会的网络命令/从抓包的角度分析connect函数的连接过程.html","title":"从抓包的角度分析connect函数的连接过程","keywords":"","body":"从抓包的角度分析connect()函数的连接过程 这篇文章主要是从tcp连接建立的角度来分析客户端程序如何利用connect函数和服务端程序建立tcp连接的,了解connect函数在建立连接的过程中底层协议栈做了哪些事情。 tcp三次握手 在正式介绍connect函数时,我们先来看一下tcp三次握手的过程,下面这个实验是客户端通过telnet远程登录服务端的例子,telnet协议是基于tcp协议,我们可以通过wireshark抓包工具看到客户端和服务端之间三次握手的过程,12.1.1.1是客户端的ip地址,12.1.1.2是服务端的ip地址。 下面是我们通过wireshark抓取到的tcp三次握手的数据包: 我们看到客户端远程登录服务端时,首先发送了一个SYN报文,其中目标端口为23(远程登录telnet协议使用23端口),初始序号seq = 0,并设置自己的窗口rwnd = 4128(rwnd是一个对端通告的接收窗口,用于流量控制)。 然后服务端回复了一个SYN + ACK报文,初始序号seq = 0,ack = 1(在前一个包的seq基础上加1),同时也设置自己的窗口rwnd = 4128。 然后客户端收到服务端的SYN + ACK报文时,回复了一个ACK报文,表示确认建立tcp连接,序号为seq = 1, ack = 1\\(在前一个包的seq基础上加1)**, 设置窗口rwnd = 4128**,此时客户端和服务端之间已经建立tcp连接。 connect函数 前面我们在介绍tcp三次握手的时候说过,客户端在跟服务端建立tcp连接时,通常是由客户端主动向目标服务端发起tcp连接建立请求,服务端被动接受tcp连接请求;同时服务端也会发起tcp连接建立请求,表示服务端希望和客户端建立连接,然后客户端会接受连接并发送一个确认,这样双方就已经建立好连接,可以开始通信。 这里说明一下:可能有的小伙伴会感到疑惑,为啥服务端也要跟客户端建立连接呢?其实这跟tcp采用全双工通信的方式有关。对于全双工通信,简单来说就是两端可以同时收发数据,如下图所示: 我们再回到正题,那么在网络编程中,肯定也有对应的函数做到跟上面一样的事情,没错,就是connect(连接)。顾名思义,connect函数就是用于客户端程序和服务端程序建立tcp连接的。 一般来说,客户端使用connect函数跟服务端建立连接,肯定要指定一个ip地址和端口号(相当于客户端的身份标识),要不然服务端都不知道你是谁?凭什么跟你建立连接。同时还得指明服务端的ip地址和端口号,也就是说,你要跟谁建立连接。 connect函数原型: int connect(int sockfd, const struct sockaddr *addr, socklen_t addrlen); 参数说明: sockfd:客户端的套接字文件描述符 addr:要连接的套接字地址,这是一个传入参数,指定了要连接的套接字地址信息(例如IP地址和端口号) addrlen:是一个传入参数,参数addr的大小,即sizeof(addr) 返回值说明:连接建立成功返回0,失败返回-1并设置errno connect函数在建立tcp连接的过程中用到了一个非常重要的队列,那就是未决连接队列,这个队列用来管理tcp的连接,包括已完成三次握手的tcp连接和未完成三次握手的tcp连接,下面我们就来详细介绍一下未决连接队列。 未决连接队列 未决连接队列是指服务器接收到客户端的连接请求,但是尚未被处理(也就是未被accept,后面会说)的连接,可以理解为未决连接队列是一个容器,这个容器存储着这些尚未被处理的链接。 当一个客户端进程使用 connect 函数发起请求后,服务器进程就会收到连接请求,然后检查未决连接队列是否有空位,如果未决队列满了,就会拒绝连接,那么客户端调用的connect 函数返回失败。 如果未决连接队列有空位,就将该连接加入未决连接队列。当 connect 函数成功返回后,表明tcp的“三次握手”连接已完成,此时accept函数获取到一个客户端连接并返回。 在上图中,在未决连接队列中又分为2个队列: 未完成队列(未决队列):即客户端已经发出SYN报文并到达服务器,但是在tcp三次握手连接完成之前,这些套接字处于SYN_RCVD状态,服务器会将这些套接字加入到未完成队列。 已完成队列:即刚刚完成tcp三次握手的tcp连接,这些套接字处于ESTABLISHED状态,服务器会将这些套接字加入到已完成队列。 我们来看一下连接建立的具体过程,如图所示: 服务端首先调用listen函数监听客户端的连接请求,然后调用accept函数阻塞等待取出未决连接队列中的客户端连接,如果未决连接队列一直为空,这意味着没有客户端和服务器建立连接,那么accept就会一直阻塞。 当客户端一调用connect函数发起连接时,如果完成tcp三次握手,那么accept函数会取出一个客户端连接(注意:是已经建立好的连接)然后立即返回。 上面就是客户端和服务端在网络中的状态变迁的具体过程,前面我们在学习tcp三次握手的过程中还知道,服务端和客户端在建立连接的时候会设置自己的一个接收缓冲区窗口rwnd的大小。 服务端在发送SYN + ACK数据报文时会设置并告知对方自己的接收缓冲区窗口大小,客户端在发送ACK数据报文时也会设置并告知对方自己的接收缓冲区窗口大小。 注意,accept函数调用成功,返回的是一个已经完成tcp三次握手的客户端连接。如果在三次握手的过程中(最后一步),服务端没有接收到客户端的ACK,则说明三次握手还没有建立完成,accept函数依然会阻塞。 关于tcp三次握手连接建立的几种状态:SYN_SENT,SYN_RCVD,ESTABLISHED。 SYN_SENT:当客户端调用connect函数向服务端发送SYN包时,客户端就会进入 SYN_SENT状态,并且还会等待服务器发送第二个SYN + ACK包,因此SYN_SENT状态就是表示客户端已经发送SYN包。 SYN_RCVD:当服务端接收到客户端发送的SYN包并确认时,服务端就会进入 SYN_RCVD状态,这是tcp三次握手建立的一个很短暂的中间状态,一般很难看到, SYN_RCVD状态表示服务端已经确认收到客户端发送的SYN包。 ESTABLISHED:该状态表示tcp三次握手连接建立完成。 对于这两个队列需要注意几点注意: 1. 未完成队列和已完成队列的总和不超过listen函数的backlog参数的大小。listen函数的签名如下: int listen(int sockfd, int backlog); 2. 一旦该连接的tcp三次握手完成,就会从未完成队列加入到已完成队列中 3. 如果未决连接队列已满,当又接收到一个客户端SYN时,服务端的tcp将会忽略该SYN,也就是不会理客户端的SYN,但是服务端并不会发送RST报文,原因是:客户端tcp可以重传SYN,并期望在超时前未决连接队列找到空位与服务端建立连接,这当然是我们所希望看到的。如果服务端直接发送一个RST的话,那么客户端的connect函数将会立即返回一个错误,而不会让tcp有机会重传SYN,显然我们也并不希望这样做。 但是不排除有些linux实现在未决连接队列满时,的确会发送RST。但是这种做法是不正确的,因此我们最好忽略这种情况,处理这种额外情况的代码也会降低客户端程序的健壮性。 connect函数出错情况 由于connect函数是在建立tcp连接成功或失败才返回,返回成功的情况本文上面已经介绍过了。这里我们介绍connect函数返回失败的几种情况: 第一种 当客户端发送了SYN报文后,没有收到确认则返回ETIMEDOUT错误,值得注意的是,失败一次并不会马上返回ETIMEDOUT错误。即当你调用了connect函数,客户端发送了一个SYN报文,没有收到确认就等6s后再发一个SYN报文,还没有收到就等24s再发一个(不同的linux系统设置的时间可能有所不同,这里以BSD系统为主)。这个时间是累加的,如果总共等了75s后还是没收到确认,那么客户端将返回ETIMEDOUT错误。 对于linux系统,改变这个系统上限值也比较容易,由于需要改变系统配置参数,你需要root权限。 相关的命令是sysctl net.ipv4.tcp_syn_retries(针对于ipv4)。 在设置该值时还是要比较保守的,因为每次syn包重试的间隔都会增大(比如BSD类的系统实现中间隔会以2到3倍增加),所有tcp_syn_retries的一个微小变化对connect超时时间的影响都非常大,不过扩大这个值也不会有什么坏处,因为你代码中设置的超时值都能够生效。但是如果代码中没有设置connect的超时值,那么connect就会阻塞很久,你发现对端机器down掉的间隔就更长。 作者建议设置这个值到6或者7,最多8。6对应的connect超时为45s,7对应90s,8对应190s。 你能通过以下命令修改该值: sysctl -w net.ipv4.tcp_syn_retries=6 查看该值的命令是: sysctl net.ipv4.tcp_syn_retries 如果希望重启后生效,将net.ipv4.tcp_syn_retries = 6放入/etc/sysctl.conf中。 这种情况一般是发生在服务端的可能性比较大,也就是服务端当前所处网络环境流量负载过高,网络拥塞了,然后服务端收到了客户端的SYN报文却来不及响应,或者发送的响应报文在网络传输过程中老是丢失,导致客户端迟迟收不到确认,最后返回ETIMEDOUT错误。 我们可以简单复现一下这种情况,这个实验是基于CentOS系统进行的,具体过程如下所示: 首先通过iptables -F把Centos上的防火墙规则清理掉,然后再通过iptables -I INPUT -p tcp --syn -i lo -j DROP命令把本地的所有SYN包都过滤掉(模拟服务端当前网络不稳定)。 执行以下命令: 1iptables -F 2iptables -I INPUT -p tcp --syn -i lo -j DROP 然后通过nc命令向本地的环回地址127.0.0.1发起tcp连接请求(相当于自己跟自己发起tcp连接),来模拟客户端跟服务端发起tcp连接,但是服务器端就是不响应,最后导致客户端的tcp连接建立请求超时,并终止tcp连接。 然后再通过tcpdump工具把客户端和服务端建立tcp连接过程中的数据报都抓取下来,由于我们设置的服务器侦听端口号是10086,这里我们可以通过tcpdump -i any port 10086命令来过滤所有网卡的10086端口的数据包。 如上图所示,localhost.39299代表客户端,localhost.10086代表服务端,客户端总共向服务端发送了6个SYN报文,这6个SYN包的间隔时间分别是1s,2s,4s,8s,16s,这些时间累积加起来总共为31s,其实客户端在发送最后一个SYN报文时还等待了一段时间,然后才超时。也就是说,客户端在发送了第一个SYN报文时,会设置了一个计时器并开始计时,在最后一个SYN报文还没收到服务端的确认时,这个计时器就会超时,然后关闭tcp连接。 第二种 客户端连接一个服务器没有侦听的端口。 过程是:客户端发送了一个SYN报文后,然后服务端回复了一个RST报文,说明这是一个异常的tcp连接,服务端发送了RST报文重置这个异常的tcp连接。 这种情况一般为拒绝连接请求,比如:客户端想和服务端建立tcp连接,但是客户端的连接请求中使用了一个不存在或没有侦听的端口(比如:这个端口超出65535的范围),那么服务端就可以发送RST报文段拒绝这个请求。 拒绝连接一般是由服务器主动发起的,因为客户端发起请求连接携带的目的端口,可能服务器并没有开启LISTEN状态。因此服务器在收到这样的报文段后会发送一个RST报文段,在这个报文里把RST和ACK都置为1,它确认了SYN报文段并同时重置了该tcp连接,然后服务器等待另一个连接。客户端在收到RST+ACK报文段后就会进入CLOSED状态。 这里以通过20000不存在的端口远程登录为例: tcpdump抓取到的数据包如下: 113:35:08.609549 IP 192.168.98.137.49057 > 192.168.0.102.dnp: Flags [S], seq 2919679902, win 14600, options [mss 1460,sackOK,TS val 39134059 ecr 0,nop,wscale 6], length 0 213:35:09.610018 IP 192.168.98.137.49057 > 192.168.0.102.dnp: Flags [S], seq 2919679902, win 14600, options [mss 1460,sackOK,TS val 39135059 ecr 0,nop,wscale 6], length 0 313:35:09.610115 IP 192.168.0.102.dnp > 192.168.98.137.49057: Flags [R.], seq 1766537774, ack 2919679903, win 64240, length 0 413:35:10.610188 IP 192.168.0.102.dnp > 192.168.98.137.49057: Flags [R.], seq 3482791532, ack 1, win 64240, length 0 通过分析tcpdump工具抓取的数据发现,RST报文段不携带数据。 第三种 如果客户端调用connect函数向服务端发送了一个SYN报文,这个SYN报文在网络传输过程中经过某个路由器时,正好这个路由器出问题了,缺少到达目的地的路由,不能把这个SYN报文转发给目的地址,那么该路由器会丢弃这个SYN报文,并同时给客户端发送一个Destination unreachable(主机不可达)的ICMP差错报文。客户端的linux内核会保存这个Destination unreachable的ICMP差错报文,同时按第一种情况继续发送SYN报文,如果在规定的时间超时后还没收到服务端的响应报文,那么linux内核会把保存的ICMP差错报文作为EHOSTUNREACH或ENETUNREACH错误返回给客户端的应用进程。 下面的这个实验就是用来说明第三种情况,帮助理解,大家能看明白就行了,可以不用去做这个实验,当然,有兴趣的同学可以去模拟一下。 然后client远程登录server成功。 上图中没有指定telnet端口号,使用默认端口号23。 这是抓取到的数据包,client在远程登录server时,发起了SYN连接请求。 现在我们来模拟client设备出故障,删除R1设备到server的路由信息 no ip route 12.1.3.0 255.255.255.0 12.1.2.2 client再登录server时就会失败,我们从抓取到的数据包可以发现,client发送了一个SYN报文,然后R1设备收到这个SYN报文时,发现自己不能到达server,于是会把这个SYN报文丢弃掉,并向client发送了一个目标主机不可达的ICMP差错报文,于是client发送了RST报文来关闭这条异常的tcp连接。 学习知识不仅要知其然也要知其所以然,这是我想通过这篇文章传达的一个理念,文中一步步的实验探索体现了学习知识动手实践的重要性,这是非常值得提倡的。 如需下载本站全部技术文章,可以在【高性能服务器开发】公众号回复关键字“文章下载”即可。最近更新时间: 2020-09-18 00:22:17 "},"articles/程序员必知必会的网络命令/服务器开发中网络数据分析与故障排查经验漫谈.html":{"url":"articles/程序员必知必会的网络命令/服务器开发中网络数据分析与故障排查经验漫谈.html","title":"服务器开发中网络数据分析与故障排查经验漫谈","keywords":"","body":"服务器开发中网络数据分析与故障排查经验漫谈 ​ 一、 操作系统提供的网络接口 为了能更好的排查网络通信问题,我们需要熟悉操作系统提供的以下网络接口函数,列表如下: 接口函数名称 接口函数描述 接口函数签名 socket 创建套接字 int socket(int domain, int type, int protocol); connect 连接一个服务器地址 int connect(int sockfd, const struct sockaddr *addr, socklen_t addrlen); send 发送数据 ssize_t send(int sockfd, const void *buf, size_t len, int flags); recv 收取数据 ssize_t recv(int sockfd, void *buf, size_t len, int flags); accept 接收连接 int accept4(int sockfd, struct sockaddr addr, socklen_t addrlen, int flags); shutdown 关闭收发链路 int shutdown(int sockfd, int how); close 关闭套接字 int close(int fd); setsockopt 设置套接字选项 int setsockopt(int sockfd, int level, int optname, const void *optval, socklen_t optlen); 注意:这里以bekeley提供的标准为例,不包括特定操作系统上特有的接口函数(如Windows平台的WSASend,linux的accept4),也不包括实际与网络数据来往不相关的函数(如select、linux的epoll),这里只讨论与tcp相关的接口函数,像与udp相关的函数sendto/recvfrom等函数与此类似。 下面讨论一下以上函数的一些使用注意事项: 1 以上函数如果调用出错后,返回值均为-1;但是返回值是-1,不一定代表出错,这还得根据对应的套接字模式(阻塞与非阻塞模式)。 2 默认使用的socket函数创建的套接字是阻塞模式的,可以调用相关接口函数将其设置为非阻塞模式(Windows平台可以使用ioctlsocket函数,linux平台可以使用fcntl函数,具体设置方法可以参考这里。)。阻塞模式和非阻塞模式的套接字,对服务器的连接服务器和网络数据的收发行为影响很大。详情如下: 阻塞模式下,connect函数如果不能立刻连上服务器,会导致执行流阻塞在那里一会儿,直到connect连接成功或失败或网络超时;而非阻塞模式下,无论是否连接成功connect将立即返回,此时如果未连接成功,返回值将是-1,错误码是EINPROGRESS,表示连接操作仍然在进行中。Linux平台后续可以通过使用select/poll等函数检测该socket是否可写来判断连接是否成功。 阻塞套接字模式下,send函数如果由于对端tcp窗口太小,不足以将全部数据发送出去,将阻塞执行流,直到出错或超时或者全部发送出去为止;同理recv函数如果当前协议栈系统缓冲区中无数据可读,也会阻塞执行流,直到出错或者超时或者读取到数据。send和recv函数的超时时间可以参考下文关于常用socket选项的介绍。 非阻塞套接字模式下,如果由于对端tcp窗口太小,不足以将数据发出去,它将立刻返回,不会阻塞执行流,此时返回值为-1,错误码是EAGAIN或EWOULDBLOCK,表示当前数据发不出去,希望你下次再试。但是返回值如果是-1,也可能是真正的出错了,也可能得到错误码EINTR,表示被linux信号中断了,这点需要注意一下。recv函数与send函数情形一样。 3 send函数虽然名称叫“send”,但是其并不是将数据发送到网络上去,只是将数据从应用层缓冲区中拷贝到协议栈内核缓冲区中,具体什么时候发送到网络上去,与协议栈本身行为有关系(socket选项nagle算法与这个有关系,下文介绍常见套接字选项时会介绍),这点需要特别注意,所以即使send函数返回一个大于0的值n,也不能表明已经有n个字节发送到网络上去了。同样的道理,recv函数也不是从网络上收取数据,只是从协议栈内核缓冲区拷贝数据至应用层缓冲区,并不是真正地从网络上收数据,所以,调用recv时,操作系统的协议栈已经将数据从网络上收到自己的内核缓冲区中了,recv仅仅是一次数据拷贝操作而已。 4 由于套接字实现是收发全双工的,收和发通道相互独立,不会相互影响,shutdown函数是用来选择关闭socket收发通道中某一路(当然,也可以两路都关闭),其how参数取值一般有三个:SHUT_RD/SHUT_WR/SHUT_RDWR,SHUT_RD表示关闭收消息链路,即该套接字不能再收取数据,同理SHUT_WR表示关闭套接字发消息链路,但是这里有个问题,有时候我们需要等待缓冲区中数据发送完后再关闭连接怎么办?这里就要用到套接字选项LINGER,关于这个选项请参考下文常见的套接字选项介绍。最后,SHUT_RDWR同时关闭收消息链路和发消息链路。通过上面的分析,我们得出结论,shutdown函数并不会要求操作系统底层回收套接字等资源,真正会回收资源是close函数,这个函数会要求操作系统回收相关套接字资源,并释放对ip地址与端口号二元组的占用,但是由于tcp四次挥手最后一个阶段有个TIME_WAIT状态(关于这个状态下文介绍tcp三次握手和四次回收时会详细介绍),导致与该socket相关的端口号资源不会被立即释放,有时候为了达到释放端口用来复用,我们会设置套接字选项SOL_REUSEPORT(关于这个选项,下文会介绍)。综合起来,我们关闭一个套接字,一般会先调用shutdown函数再调用close函数,这就是所谓的优雅关闭: 5 常见的套接字选项 严格意义上说套接字选项是有不同层级的(level),如socket级别、TCP级别、IP级别,这里我们不区分具体的级别。 SO_SNDTIMEO与SO_RCVTIMEO 这两个选项用于设置阻塞模式下套接字,SO_SNDTIMEO用于在send数据由于对端tcp窗口太小,发不出去而最大的阻塞时长;SO_RCVTIMEO用于recv函数因接受缓冲区无数据而阻塞的最大阻塞时长。如果你需要获取它们的默认值,请使用getsockopt函数。 TCP_NODELAY 操作系统底层协议栈默认有这样一个机制,为了减少网络通信次数,会将send等函数提交给tcp协议栈的多个小的数据包合并成一个大的数据包,最后再一次性发出去,也就是说,如果你调用send函数往内核协议栈缓冲区拷贝了一个数据,这个数据也许不会马上发到网络上去,而是要等到协议栈缓冲区积累到一定量的数据后才会一次性发出去,我们把这种机制叫做nagle算法。默认打开了这个机制,有时候我们希望关闭这种机制,让send的数据能够立刻发出去,我们可以选择关闭这个算法,这就可以通过设置套接字选项TCP_NODELAY,即关闭nagle算法。 SO_LINGER linger这个单词本身的意思,是“暂停、逗留”。这个选项的用处是用于解决,当需要关闭套接字时,协议栈发送缓冲区中尚有未发送出去的数据,等待这些数据发完的最长等待时间。 SO_REUSEADDR/SO_REUSEPORT 一个端口,尤其是作为服务器端端口在四次挥手的最后一步,有一个为TIME_WAIT的状态,这个状态一般持续2MSL(MSL,maximum segment life, 最大生存周期,RFC上建议是2分钟)。这个状态存在原因如下:1. 保证发出去的ack能被送达(超时会重发ack)2. 让迟来的报文有足够的时间被丢弃,反过来说,如果不存在这个状态,那么可以立刻复用这个地址和端口号,那么可能会收到老的连接迟来的数据,这显然是不好的。为了立即回收复用端口号,我们可以通过开启套接字SO_REUSEADDR/SO_REUSEPORT。 SO_KEEPALIVE 默认情况下,当一个连接长时间没有数据来往,会被系统防火墙之类的服务关闭。为了避免这种现象,尤其是一些需要长连接的应用场景下,我们需要使用心跳包机制,即定时从两端定时发一点数据,这种行为叫做“保活”。而tcp协议栈本身也提供了这种机制,那就是设置套接字SO_KEEPALIVE选项,开启这个选项后,tcp协议栈会定时发送心跳包探针,但是这个默认时间比较长(2个小时),我们可以继续通过相关选项改变这个默认值。 ​ 二、常用的网络故障排查工具 1.ping ping命令可用于测试网络是否连通。 2.telnet 命令使用格式: telnet ip或域名 port 例如: telnet 120.55.94.78 8888 telnet www.baidu.com 80 结合ping和telnet命令我们就可以判断一个服务器地址上的某个端口号是否可以对外提供服务。 由于我们使用的开发机器以windows居多,默认情况下,windows系统的telnet命令是没有打开的,我们可以在【控制面板】- 【程序】- 【程序和功能】- 【打开或关闭Windows功能】中打开telnet功能。 3.host命令 host 命令可以解析域名得到对应的ip地址。例如,我们要得到www.baidu.com这个域名的ip地址,可以输入: 得到www.google.com的ip地址可以输入: 4 .netstat命令 常见的选项有: -a (all)显示所有选项,netstat默认不显示LISTEN相关 -t (tcp)仅显示tcp相关选项 -u (udp)仅显示udp相关选项 -n 拒绝显示别名,能显示数字的全部转化成数字。(重要) -l 仅列出有在 Listen (监听) 的服務状态 -p 显示建立相关链接的程序名(macOS中表示协议 -p protocol) -r 显示路由信息,路由表 -e 显示扩展信息,例如uid等 -s 按各个协议进行统计 (重要) -c 每隔一个固定时间,执行该netstat命令。 5. lsof命令 lsof,即list opened filedescriptor,即列出当前操作系统中打开的所有文件描述符,socket也是一种file descriptor,常见的选项是: -i 列出系统打开的socket fd -P 不要显示端口号别名 -n 不要显示ip地址别名(如localhost会用127.0.0.1来代替) +c w 程序列名称最大可以显示到w个字符。 常见的选项组合为lsof –i –Pn: 可以看到列出了当前侦听的socket,和连接socket的tcp状态。 6.pstack 严格意义上来说,这个不算网络排查故障和调试命令,但是我们可以利用这个命令来查看某个进程的线程数量和线程调用堆栈是否运行正常。指令使用格式: pstack pid 即,pstack 进程号,如: 7.nc命令 即netcat命令,这个工具在排查网络故障时非常有用,因而被业绩称为网络界的“瑞士军刀”。常见的用法如下: 模拟服务器端在指定ip地址和端口号上侦听 nc –l 0.0.0.0 8888 模拟客户端连接到指定ip地址和端口号 nc 0.0.0.0 8888 我们知道客户端连接服务器一般都是操作系统随机分配一个可用的端口号连接到服务器上去,这个指令甚至可以指定使用哪个端口号连接,如: nc –p 12345 127.0.0.1 8888 客户端使用端口12345去连接服务器127.0.0.1::8888。 使用nc命令发消息和发文件 客户端 服务器 8 .tcpdump 这个是linux系统自带的抓包工具,功能非常强大,默认需要开启root权限才能使用。 其常见的选项有: -i 指定网卡 -X –XX 打印十六进制的网络数据包 -n –nn 不显示ip地址和端口的别名 -S 以绝对值显示包的ISN号(包序列号) 常用的过滤条件有如下形式: tcpdump –i any ‘port 8888’ tcpdump –i any ‘tcp port 8888’ tcpdump –i any ‘tcp src port 8888’ tcpdump –i any ‘tcp src port 8888 and udp dst port 9999’ tcpdump -i any 'src host 127.0.0.1 and tcp src port 12345' -XX -nn -vv 关于tcpdump命令接下来将会以对tcp三次握手和四次挥手的包数据进行抓包来分析。 三、 tcp三次握手和四次挥手过程解析 熟练地掌握tcp三次握手和四次挥手过程的每一个细节是我们排查网络问题的基础。 下面我们来通过tcpdump抓包能实战一下三次握手的过程,假设我的服务器端的地址是 127.0.0.0.1 : 12345,使用nc命令创建一个服务器程序并在这个地址上进行侦听: nc –v -l 127.0.0.0.112345 然后在客户端机器上开启tcpdump工具: 然后在客户端使用nc命令创建一个客户端去连接服务器: 我们抓到的包如下: 图片看不清,可以放大来看。上面我们需要注意的是: 三次握手过程是客户端先给服务器发送一个SYN,然后服务器应答一个SYN+ACK,应答的序列号是递增1的,表示应答哪个请求,即从4004096087递增到4004096088,接着客户端再应答一个ACK。这个时候,我们发现发包序列号和应答序列号都变成1了,这是tcpdump使用相对序号,我们加上-S选项后就变成绝对序列号了。 这是正常的tcp三次握手,假如我们连接的服务器ip地址存在,但监听端口号并不存在,我们看下tcpdump抓包结果: 这个时候客户端发送SYN,服务器应答ACK+RST: 这个应答包会导致客户端的connect连接失败。 还有一种情况就是客户端访问一个很遥远的ip,或者网络繁忙,服务器对客户端发送的网络SYN报文没有应答,会出现什么情况呢? 我们先将防火墙的已有规则都清理掉: iptables -F 然后给防火墙的INPUT链上增加一个规则,丢弃本地网卡lo(也就是127.0.0.1这个回环地址)上的所有SYN包。 接着,我们看到tcpdump抓到的数据包如下: 连接不上,一共重试了5次,重试的时间间隔是1秒,2秒,4秒,8秒,16秒,最后返回失败。这个重试次数在/proc/sys/net/ipv4/tcp_syn_retries 内核参数中设置,默认为6。 四次挥手与三次握手基本上类似,这里就不贴出tcpdump抓包的详情了。实际的网络开发中,尤其是高QPS的服务器程序,可能在在服务器程序所在的系统上留下大量非ESTABLISHED的中间状态,如CLOSE_WAIT/TIME_WAIT,我们可以使用以下指令来统计这些状态信息: netstat -n | awk '/^tcp/ {++S[$NF]} END {for(a in S) print a, S[a]}' 得到结果可能类似: 让我们再贴一张tcp三次握手和四次挥手更清晰的图吧。 下面看下一般比较关心的三种TCP状态 SYN_RECV 服务端收到建立连接的SYN没有收到ACK包的时候处在SYN_RECV状态。有两个相关系统配置: 1 net.ipv4.tcp_synack_retries,整形,默认值是5 对于远端的连接请求SYN,内核会发送SYN + ACK数据报,以确认收到上一个 SYN连接请求包。这是三次握手机制的第二个步骤。这里决定内核在放弃连接之前所送出的 SYN+ACK 数目。不应该大于255,默认值是5,对应于180秒左右时间。通常我们不对这个值进行修改,因为我们希望TCP连接不要因为偶尔的丢包而无法建立。 2 net.ipv4.tcp_syncookies 一般服务器都会设置net.ipv4.tcp_syncookies=1来防止SYN Flood攻击。假设一个用户向服务器发送了SYN报文后突然死机或掉线,那么服务器在发出SYN+ACK应答报文后是无法收到客户端的ACK报文的(第三次握手无法完成),这种情况下服务器端一般会重试(再次发送SYN+ACK给客户端)并等待一段时间后丢弃这个未完成的连接,这段时间的长度我们称为SYN Timeout,一般来说这个时间是分钟的数量级(大约为30秒-2分钟)。这些处在SYNC_RECV的TCP连接称为半连接,并存储在内核的半连接队列中,在内核收到对端发送的ack包时会查找半连接队列,并将符合的requst_sock信息存储到完成三次握手的连接的队列中,然后删除此半连接。大量SYNC_RECV的TCP连接会导致半连接队列溢出,这样后续的连接建立请求会被内核直接丢弃,这就是SYN Flood攻击。能够有效防范SYN Flood攻击的手段之一,就是SYN Cookie。SYN Cookie原理由D. J. Bernstain和 Eric Schenk发明。SYN Cookie是对TCP服务器端的三次握手协议作一些修改,专门用来防范SYN Flood攻击的一种手段。它的原理是,在TCP服务器收到SYN包并返回SYN+ACK包时,不分配一个专门的数据区,而是根据这个SYN包计算出一个cookie值。在收到ACK包时,TCP服务器在根据那个cookie值检查这个TCP ACK包的合法性。如果合法,再分配专门的数据区进行处理未来的TCP连接。观测服务上SYN_RECV连接个数为:7314,对于一个高并发连接的通讯服务器,这个数字比较正常。 CLOSE_WAIT 发起TCP连接关闭的一方称为client,被动关闭的一方称为server。被动关闭的server收到FIN后,但未发出ACK的TCP状态是CLOSE_WAIT。出现这种状况一般都是由于server端代码的问题,如果你的服务器上出现大量CLOSE_WAIT,应该要考虑检查代码。 TIME_WAIT 根据三次握手断开连接规定,发起socket主动关闭的一方 socket将进入TIME_WAIT状态。TIME_WAIT状态将持续2MSL。TIME_WAIT状态下的socket不能被回收使用。 具体现象是对于一个处理大量短连接的服务器,如果是由服务器主动关闭客户端的连接,将导致服务器端存在大量的处于TIME_WAIT状态的socket, 甚至比处于Established状态下的socket多的多,严重影响服务器的处理能力,甚至耗尽可用的socket,停止服务。TIME_WAIT是TCP协议用以保证被重新分配的socket不会受到之前残留的延迟重发报文影响的机制,是必要的逻辑保证。和TIME_WAIT状态有关的系统参数有一般由3个,本机设置如下: net.ipv4.tcp_tw_recycle = 1 net.ipv4.tcp_tw_reuse = 1 net.ipv4.tcp_fin_timeout = 30 net.ipv4.tcp_fin_timeout,默认60s,减小fin_timeout,减少TIME_WAIT连接数量。 net.ipv4.tcp_tw_reuse = 1表示开启重用。允许将TIME-WAIT sockets重新用于新的TCP连接,默认为0,表示关闭; net.ipv4.tcp_tw_recycle = 1表示开启TCP连接中TIME-WAIT sockets的快速回收,默认为0,表示关闭。 我们这里总结一下这些与tcp状态的选项: net.ipv4.tcp_syncookies=1 表示开启SYN Cookies。当出现SYN等待队列溢出时,启用cookie来处理,可防范少量的SYN攻击。默认为0,表示关闭。 net.ipv4.tcp_tw_reuse=1 表示开启重用。允许将TIME-WAIT套接字重新用于新的TCP连接。默认为0,表示关闭。 net.ipv4.tcp_tw_recycle=1 表示开启TCP连接中TIME-WAIT套接字的快速回收。默认为0,表示关闭。** net.ipv4.tcp_fin_timeout=30 表示如果套接字由本端要求关闭,这个参数决定了它保持在FIN-WAIT-2状态的时间。 net.ipv4.tcp_keepalive_time=1200 表示当keepalive启用时,TCP发送keepalive消息的频度。默认是2小时,这里改为20分钟。 net.ipv4.ip_local_port_range=1024 65000 表示向外连接的端口范围。默认值很小:32768~61000,改为1024~65000。 net.ipv4.tcp_max_syn_backlog=8192 表示SYN队列的长度,默认为1024,加大队列长度为8192,可以容纳更多等待连接的网络连接数。 net.ipv4.tcp_max_tw_buckets=5000 表示系统同时保持TIME_WAIT套接字的最大数量,如果超过这个数 字,TIME_WAIT套接字将立刻被清除并打印警告信息。默认为180000,改为5000。 注意 上文中用红色字体标识出来的两个参数: net.ipv4.tcp_tw_recycle net.ipv4.tcp_tw_reuse 在实际linux内核参数调优时并不建议开启。至于原因,我会单独用一篇文章来介绍。 四、 关于跨系统与跨语言之间的网络通信连通问题 如何在Java语言中去解析C++的网络数据包,如何在C++中解析Java的网络数据包,对于很多人来说是一件很困难的事情,所以只能变着法子使用第三方的库。其实使用tcpdump工具可以很容易解决与分析。 首先,我们需要明确字节序列这样一个概念,即我们说的大端编码(big endian)和小端编码(little endian),x86和x64系列的cpu使用小端编码,而数据在网络上传输,以及Java语言中,使用的是大端编码。那么这是什么意思呢? 我们举个例子,看一个x64机器上的32位数值在内存中的存储方式: i 在内存中的地址序列是0x003CF7C4~ 0x003CF7C8,值为40 e2 01 00。 十六进制0001e240正好等于10进制123456,也就是说小端编码中权重高的的字节值存储在内存地址高(地址值较大)的位置,权重值低的字节值存储在内存地址低(地址值较小)的位置,也就是所谓的高高低低。 相反,大端编码的规则应该是高低低高,也就是说权值高字节存储在内存地址低的位置,权值低的字节存储在内存地址高的位置。 所以,如果我们一个C++程序的int32值123456不作转换地传给Java程序,那么Java按照大端编码的形式读出来的值是:十六进制40E20100 = 十进制1088553216。 所以,我们要么在发送方将数据转换成网络字节序(大端编码),要么在接收端再进行转换。 下面看一下如果C++端传送一个如下数据结构,Java端该如何解析(由于Java中是没有指针的,也无法操作内存地址,导致很多人无从下手),下面利用tcpdump来解决这个问题的思路。 我们客户端发送的数据包: 其结构体定义如下: 利用tcpdump抓到的包如下: 放大一点: 我们白色标识出来就是我们收到的数据包。这里我想说明两点: 如果我们知道发送端发送的字节流,再比照接收端收到的字节流,我们就能检测数据包的完整性,或者利用这个来排查一些问题; 对于Java程序只要按照这个顺序,先利用java.net.Socket的输出流java.io.DataOutputStream对象readByte、readInt32、readInt32、readBytes、readBytes方法依次读出一个char、int32、int32、16个字节的字节数组、63个字节数组即可,为了还原像int32这样的整形值,我们需要做一些小端编码向大端编码的转换。 参考资料: 《TCP/IP详解卷一:协议》 《TCP/IP详解卷二:实现》 游双《Linux高性能服务器编程》 https://man.cx/?page=iptables(8) https://vincent.bernat.im/en/blog/2014-tcp-time-wait-state-linux https://blog.csdn.net/chinalinuxzend/article/details/1792184 https://www.zhihu.com/question/29212769 https://blog.csdn.net/launch_225/article/details/9211731 https://www.cnblogs.com/splenday/articles/7668589.html http://man.linuxde.net/ss http://www.cnxct.com/coping-with-the-tcp-time_wait-state-on-busy-linux-servers-in-chinese-and-dont-enable-tcp_tw_recycle/ https://www.cnblogs.com/xkus/p/7463135.html 如需下载本站全部技术文章,可以在【高性能服务器开发】公众号回复关键字“文章下载”即可。最近更新时间: 2020-09-16 16:31:23 "},"articles/高性能服务器框架设计/":{"url":"articles/高性能服务器框架设计/","title":"高性能服务器框架设计","keywords":"","body":"高性能服务器框架设计 主线程与工作线程的分工 Reactor模式 实例:一个服务器程序的架构介绍 错误码系统的设计 日志系统的设计 如何设计断线自动重连机制 心跳包机制设计详解 业务数据处理一定要单独开线程吗 C++ 高性能服务器网络框架设计细节 如需下载本站全部技术文章,可以在【高性能服务器开发】公众号回复关键字“文章下载”即可。最近更新时间: 2020-10-08 12:04:33 "},"articles/高性能服务器框架设计/主线程与工作线程的分工.html":{"url":"articles/高性能服务器框架设计/主线程与工作线程的分工.html","title":"主线程与工作线程的分工","keywords":"","body":"主线程与工作线程的分工 服务器端为了能流畅处理多个客户端链接,一般在某个线程A里面accept新的客户端连接并生成新连接的socket fd,然后将这些新连接的socketfd给另外开的数个工作线程B1、B2、B3、B4,这些工作线程处理这些新连接上的网络IO事件(即收发数据),同时,还处理系统中的另外一些事务。这里我们将线程A称为主线程,B1、B2、B3、B4等称为工作线程。工作线程的代码框架一般如下: while (!m_bQuit) { epoll_or_select_func(); handle_io_events(); handle_other_things(); } 在epoll_or_select_func()中通过select()或者poll/epoll()去检测socket fd上的io事件,若存在这些事件则下一步handle_io_events()来处理这些事件(收发数据),做完之后可能还要做一些系统其他的任务,即调用handle_other_things()。 这样做有三个好处: 线程A只需要处理新连接的到来即可,不用处理网络IO事件。由于网络IO事件处理一般相对比较慢,如果在线程A里面既处理新连接又处理网络IO,则可能由于线程忙于处理IO事件,而无法及时处理客户端的新连接,这是很不好的。 线程A接收的新连接,可以根据一定的负载均衡原则将新的socket fd分配给工作线程。常用的算法,比如round robin,即轮询机制,即,假设不考虑中途有连接断开的情况,一个新连接来了分配给B1,又来一个分配给B2,再来一个分配给B3,再来一个分配给B4。如此反复,也就是说线程A记录了各个工作线程上的socket fd数量,这样可以最大化地来平衡资源,避免一些工作线程“忙死”,另外一些工作线程“闲死”的现象。 即使工作线程不满载的情况下,也可以让工作线程做其他的事情。比如现在有四个工作线程,但只有三个连接。那么线程B4就可以在handle_other_thing()做一些其他事情。 下面讨论一个很重要的效率问题: 在上述while循环里面,epoll_or_selec_func()中的epoll_wait/poll/select等函数一般设置了一个超时时间。如果设置超时时间为0,那么在没有任何网络IO时间和其他任务处理的情况下,这些工作线程实际上会空转,白白地浪费cpu时间片。如果设置的超时时间大于0,在没有网络IO时间的情况,epoll_wait/poll/select仍然要挂起指定时间才能返回,导致handle_other_thing()不能及时执行,影响其他任务不能及时处理,也就是说其他任务一旦产生,其处理起来具有一定的延时性。这样也不好。那如何解决该问题呢? 其实我们想达到的效果是,如果没有网络IO时间和其他任务要处理,那么这些工作线程最好直接挂起而不是空转;如果有其他任务要处理,这些工作线程要立刻能处理这些任务而不是在epoll_wait/poll/selec挂起指定时间后才开始处理这些任务。 我们采取如下方法来解决该问题,以linux为例,不管epoll_fd上有没有文件描述符fd,我们都给它绑定一个默认的fd,这个fd被称为唤醒fd。当我们需要处理其他任务的时候,向这个唤醒fd上随便写入1个字节的,这样这个fd立即就变成可读的了,epoll_wait()/poll()/select()函数立即被唤醒,并返回,接下来马上就能执行handle_other_thing(),其他任务得到处理。反之,没有其他任务也没有网络IO事件时,epoll_or_select_func()就挂在那里什么也不做。 这个唤醒fd,在linux平台上可以通过以下几种方法实现: 管道pipe,创建一个管道,将管道绑定到epoll_fd上。需要时,向管道一端写入一个字节,工作线程立即被唤醒。 linux 2.6新增的eventfd: int eventfd(unsigned int initval, int flags); 步骤也是一样,将生成的eventfd绑定到epoll_fd上。需要时,向这个eventfd上写入一个字节,工作线程立即被唤醒。 第三种方法最方便。即linux特有的socketpair,socketpair是一对相互连接的socket,相当于服务器端和客户端的两个端点,每一端都可以读写数据。 int socketpair(int domain, int type, int protocol, int sv[2]); 调用这个函数返回的两个socket句柄就是sv[0],和sv[1],在一个其中任何一个写入字节,在另外一个收取字节。 将收取的字节的socket绑定到epoll_fd上。需要时,向另外一个写入的socket上写入一个字节,工作线程立即被唤醒。如果是使用socketpair,那么domain参数一定要设置成AFX_UNIX。 由于在windows,select函数只支持检测socket这一种fd,所以Windows上一般只能用方法3的原理。而且需要手动创建两个socket,然后一个连接另外一个,将读取的那一段绑定到select的fd上去。这在写跨两个平台代码时,需要注意的地方。 如需下载本站全部技术文章,可以在【高性能服务器开发】公众号回复关键字“文章下载”即可。最近更新时间: 2020-09-20 22:48:27 "},"articles/高性能服务器框架设计/Reactor模式.html":{"url":"articles/高性能服务器框架设计/Reactor模式.html","title":"Reactor模式","keywords":"","body":"Reactor模式 最近一直在看游双的《高性能Linux服务器编程》一书,下载链接: http://download.csdn.net/detail/analogous_love/9673008 书上是这么介绍Reactor模式的: 按照这个思路,我写个简单的练习: /** *@desc: 用reactor模式练习服务器程序,main.cpp *@author: zhangyl *@date: 2016.11.23 */ #include #include #include #include #include #include //for htonl() and htons() #include #include #include #include //for signal() #include #include #include #include #include #include #include //for std::setw()/setfill() #include #define WORKER_THREAD_NUM 5 #define min(a, b) ((a g_listClients; void prog_exit(int signo) { ::signal(SIGINT, SIG_IGN); //::signal(SIGKILL, SIG_IGN);//该信号不能被阻塞、处理或者忽略 ::signal(SIGTERM, SIG_IGN); std::cout tm_year + 1900 tm_mon + 1 tm_mday tm_hour tm_min tm_sec 0) { exit(0); } //之前parent和child运行在同一个session里,parent是会话(session)的领头进程, //parent进程作为会话的领头进程,如果exit结束执行的话,那么子进程会成为孤儿进程,并被init收养。 //执行setsid()之后,child将重新获得一个新的会话(session)id。 //这时parent退出之后,将不会影响到child了。 setsid(); int fd; fd = open(\"/dev/null\", O_RDWR, 0); if (fd != -1) { dup2(fd, STDIN_FILENO); dup2(fd, STDOUT_FILENO); dup2(fd, STDERR_FILENO); } if (fd > 2) close(fd); } int main(int argc, char* argv[]) { short port = 0; int ch; bool bdaemon = false; while ((ch = getopt(argc, argv, \"p:d\")) != -1) { switch (ch) { case 'd': bdaemon = true; break; case 'p': port = atol(optarg); break; } } if (bdaemon) daemon_run(); if (port == 0) port = 12345; if (!create_server_listener(\"0.0.0.0\", port)) { std::cout 程序的功能一个简单的echo服务:客户端连接上服务器之后,给服务器发送信息,服务器加上时间戳等信息后返回给客户端。 使用到的知识点有: 条件变量 epoll的边缘触发模式 程序的大致框架是: 主线程只负责监听侦听socket上是否有新连接,如果有新连接到来,交给一个叫accept的工作线程去接收新连接,并将新连接socket绑定到主线程使用epollfd上去。 主线程如果侦听到客户端的socket上有可读事件,则通知另外五个工作线程去接收处理客户端发来的数据,并将数据加上时间戳后发回给客户端。 可以通过传递-p port来设置程序的监听端口号;可以通过传递-d来使程序以daemon模式运行在后台。这也是标准linux daemon模式的书写方法。 程序难点和需要注意的地方是: 条件变量为了防止虚假唤醒,一定要在一个循环里面调用pthread_cond_wait()函数,我在worker_thread_func()中使用了: while (g_listClients.empty()) ::pthread_cond_wait(&g_cond, &g_clientmutex); 在accept_thread_func()函数里面我没有使用循环,这样会有问题吗? 使用条件变量pthread_cond_wait()函数的时候一定要先获得与该条件变量相关的mutex,即像下面这样的结构: mutex_lock(...); while (condition is true) ::pthread_cond_wait(...); //这里可以有其他代码... mutex_unlock(...); //这里可以有其他代码... 因为pthread_cond_wait()如果阻塞的话,它解锁相关mutex和阻塞当前线程这两个动作加在一起是原子的。 作为服务器端程序最好对侦听socket调用setsocketopt()设置SO_REUSEADDR和SO_REUSEPORT两个标志,因为服务程序有时候会需要重启(比如调试的时候就会不断重启),如果不设置这两个标志的话,绑定端口时就会调用失败。因为一个端口使用后,即使不再使用,因为四次挥手该端口处于TIME_WAIT状态,有大约2min的MSL(Maximum Segment Lifetime,最大存活期)。这2min内,该端口是不能被重复使用的。你的服务器程序上次使用了这个端口号,接着重启,因为这个缘故,你再次绑定这个端口就会失败(bind函数调用失败)。要不你就每次重启时需要等待2min后再试(这在频繁重启程序调试是难以接收的),或者设置这种SO_REUSEADDR和SO_REUSEPORT立即回收端口使用。 其实,SO_REUSEADDR在Windows上和Unix平台上还有些细微的区别,我在libevent源码中看到这样的描述: int evutil_make_listen_socket_reuseable(evutil_socket_t sock) { #ifndef WIN32 int one = 1; /* REUSEADDR on Unix means, \"don't hang on to this address after the * listener is closed.\" On Windows, though, it means \"don't keep other * processes from binding to this address while we're using it. */ return setsockopt(sock, SOL_SOCKET, SO_REUSEADDR, (void*) &one, (ev_socklen_t)sizeof(one)); #else return 0; #endif } 注意注释部分,在Unix平台上设置这个选项意味着,任意进程可以复用该地址;而在windows,不要阻止其他进程复用该地址。也就是在在Unix平台上,如果不设置这个选项,任意进程在一定时间内,不能bind该地址;在windows平台上,在一定时间内,其他进程不能bind该地址,而本进程却可以再次bind该地址。 epoll_wait对新连接socket使用的是边缘触发模式EPOLLET(edge trigger),而不是默认的水平触发模式(level trigger)。因为如果采取水平触发模式的话,主线程检测到某个客户端socket数据可读时,通知工作线程去收取该socket上的数据,这个时候主线程继续循环,只要在工作线程没有将该socket上数据全部收完,或者在工作线程收取数据的过程中,客户端有新数据到来,主线程会继续发通知(通过pthread_cond_signal())函数,再次通知工作线程收取数据。这样会可能导致多个工作线程同时调用recv函数收取该客户端socket上的数据,这样产生的结果将会导致数据错乱。 相反,采取边缘触发模式,只有等某个工作线程将那个客户端socket上数据全部收取完毕,主线程的epoll_wait才可能会再次触发来通知工作线程继续收取那个客户端socket新来的数据。 代码中有这样一行: //gdb调试时不能实时刷新标准输出,用这个函数刷新标准输出,使信息在屏幕上实时显示出来 std::cout 如果不加上这一行,正常运行服务器程序,程序中要打印到控制台的信息都会打印出来,但是如果用gdb调试状态下,程序的所有输出就不显示了。我不知道这是不是gdb的一个bug,所以这里加上std::endl来输出一个换行符并flush标准输出,让输出显示出来。(std::endl不仅是输出一个换行符而且是同时刷新输出,相当于fflush()函数)。 程序我部署起来了,你可以使用linux的nc命令或自己写程序连接服务器来查看程序效果,当然也可以使用telnet命令,方法: Linux: nc 120.55.94.78 12345 或 telnet 120.55.94.78 12345 然后就可以给服务器自由发送数据了,服务器会给你发送的信息加上时间戳返回给你。效果如图: 另外我将这个代码改写了成纯C++11版本,使用CMake编译,为了支持编译必须加上这-std=c++11: CMakeLists.txt代码如下: cmake_minimum_required(VERSION 2.8) PROJECT(myreactorserver) AUX_SOURCE_DIRECTORY(./ SRC_LIST) SET(EXECUTABLE_OUTPUT_PATH ./) ADD_DEFINITIONS(-g -W -Wall -Wno-deprecated -DLINUX -D_REENTRANT -D_FILE_OFFSET_BITS=64 -DAC_HAS_INFO -DAC_HAS_WARNING -DAC_HAS_ERROR -DAC_HAS_CRITICAL -DTIXML_USE_STL -DHAVE_CXX_STDHEADERS ${CMAKE_CXX_FLAGS} -std=c++11) INCLUDE_DIRECTORIES( ./ ) LINK_DIRECTORIES( ./ ) set( main.cpp myreator.cpp ) ADD_EXECUTABLE(myreactorserver ${SRC_LIST}) TARGET_LINK_LIBRARIES(myreactorserver pthread) myreactor.h文件内容: /** *@desc: myreactor头文件, myreactor.h *@author: zhangyl *@date: 2016.12.03 */ #ifndef __MYREACTOR_H__ #define __MYREACTOR_H__ #include #include #include #include #include #define WORKER_THREAD_NUM 5 class CMyReactor { public: CMyReactor(); ~CMyReactor(); bool init(const char* ip, short nport); bool uninit(); bool close_client(int clientfd); static void* main_loop(void* p); private: //no copyable CMyReactor(const CMyReactor& rhs); CMyReactor& operator = (const CMyReactor& rhs); bool create_server_listener(const char* ip, short port); static void accept_thread_proc(CMyReactor* pReatcor); static void worker_thread_proc(CMyReactor* pReatcor); private: //C11语法可以在这里初始化 int m_listenfd = 0; int m_epollfd = 0; bool m_bStop = false; std::shared_ptr m_acceptthread; std::shared_ptr m_workerthreads[WORKER_THREAD_NUM]; std::condition_variable m_acceptcond; std::mutex m_acceptmutex; std::condition_variable m_workercond ; std::mutex m_workermutex; std::list m_listClients; }; #endif //!__MYREACTOR_H__ myreactor.cpp文件内容: /** *@desc: myreactor实现文件, myreactor.cpp *@author: zhangyl *@date: 2016.12.03 */ #include \"myreactor.h\" #include #include #include #include #include #include //for htonl() and htons() #include #include #include #include #include #include #include //for std::setw()/setfill() #include #define min(a, b) ((a join(); for (auto& t : m_workerthreads) { t->join(); } ::epoll_ctl(m_epollfd, EPOLL_CTL_DEL, m_listenfd, NULL); //TODO: 是否需要先调用shutdown()一下? ::shutdown(m_listenfd, SHUT_RDWR); ::close(m_listenfd); ::close(m_epollfd); return true; } bool CMyReactor::close_client(int clientfd) { if (::epoll_ctl(m_epollfd, EPOLL_CTL_DEL, clientfd, NULL) == -1) { std::cout (p); while (!pReatcor->m_bStop) { struct epoll_event ev[1024]; int n = ::epoll_wait(pReatcor->m_epollfd, ev, 1024, 10); if (n == 0) continue; else if (n m_listenfd) pReatcor->m_acceptcond.notify_one(); //通知普通工作线程接收数据 else { { std::unique_lock guard(pReatcor->m_workermutex); pReatcor->m_listClients.push_back(ev[i].data.fd); } pReatcor->m_workercond.notify_one(); //std::cout guard(pReatcor->m_acceptmutex); pReatcor->m_acceptcond.wait(guard); if (pReatcor->m_bStop) break; //std::cout m_listenfd, (struct sockaddr *)&clientaddr, &addrlen); } if (newfd == -1) continue; std::cout m_epollfd, EPOLL_CTL_ADD, newfd, &e) == -1) { std::cout guard(pReatcor->m_workermutex); while (pReatcor->m_listClients.empty()) { if (pReatcor->m_bStop) { std::cout m_workercond.wait(guard); } clientfd = pReatcor->m_listClients.front(); pReatcor->m_listClients.pop_front(); } //gdb调试时不能实时刷新标准输出,用这个函数刷新标准输出,使信息在屏幕上实时显示出来 std::cout close_client(clientfd); bError = true; break; } } //对端关闭了socket,这端也关闭。 else if (nRecv == 0) { std::cout close_client(clientfd); bError = true; break; } strclientmsg += buff; } //出错了,就不要再继续往下执行了 if (bError) continue; std::cout tm_year + 1900 tm_mon + 1 tm_mday tm_hour tm_min tm_sec close_client(clientfd); break; } } std::cout main.cpp文件内容: /** *@desc: 用reactor模式练习服务器程序 *@author: zhangyl *@date: 2016.12.03 */ #include #include //for signal() #include #include //for exit() #include #include #include #include \"myreactor.h\" CMyReactor g_reator; void prog_exit(int signo) { std::cout 0) { exit(0); } //之前parent和child运行在同一个session里,parent是会话(session)的领头进程, //parent进程作为会话的领头进程,如果exit结束执行的话,那么子进程会成为孤儿进程,并被init收养。 //执行setsid()之后,child将重新获得一个新的会话(session)id。 //这时parent退出之后,将不会影响到child了。 setsid(); int fd; fd = open(\"/dev/null\", O_RDWR, 0); if (fd != -1) { dup2(fd, STDIN_FILENO); dup2(fd, STDOUT_FILENO); dup2(fd, STDERR_FILENO); } if (fd > 2) close(fd); } int main(int argc, char* argv[]) { //设置信号处理 signal(SIGCHLD, SIG_DFL); signal(SIGPIPE, SIG_IGN); signal(SIGINT, prog_exit); signal(SIGKILL, prog_exit); signal(SIGTERM, prog_exit); short port = 0; int ch; bool bdaemon = false; while ((ch = getopt(argc, argv, \"p:d\")) != -1) { switch (ch) { case 'd': bdaemon = true; break; case 'p': port = atol(optarg); break; } } if (bdaemon) daemon_run(); if (port == 0) port = 12345; if (!g_reator.init(\"0.0.0.0\", 12345)) return -1; g_reator.main_loop(&g_reator); return 0; } 完整实例代码下载地址: 普通版本:https://pan.baidu.com/s/1o82Mkno C++11版本:https://pan.baidu.com/s/1dEJdrih 如需下载本站全部技术文章,可以在【高性能服务器开发】公众号回复关键字“文章下载”即可。最近更新时间: 2020-09-20 22:46:31 "},"articles/高性能服务器框架设计/实例:一个服务器程序的架构介绍.html":{"url":"articles/高性能服务器框架设计/实例:一个服务器程序的架构介绍.html","title":"实例:一个服务器程序的架构介绍","keywords":"","body":"实例:一个服务器程序的架构介绍 本文将介绍我曾经做过的一个项目的服务器架构和服务器编程的一些重要细节。 一、程序运行环境 操作系统:Centos 7.0 编译器:gcc/g++ 4.8.3、cmake 2.8.11 mysql数据库:5.5.47 项目代码管理工具:Visual Studio 2013 一、程序结构 该程序总共有 17 个线程,其中分为 9 个数据库工作线程 D 和一个日志线程 L,6 个普通工作线程 W,一个主线程 M。(以下会用这些字母来代指这些线程) (一)、数据库工作线程的用途 9 个数据库工作线程在线程启动之初,与 mysql 建立连接,也就是说每个线程都与 mysql 保持一路连接,共 9 个数据库连接。 每个数据库工作线程同时存在两个任务队列,第一个队列 A 存放需要执行数据库增删查改操作的任务 sqlTask,第二个队列 B 存放 sqlTask 执行完成后的结果。sqlTask 执行完成后立即放入结果队列中,因而结果队列中任务也是一个个的需要执行的任务。大致伪代码如下: void db_thread_func() { while (!m_bExit) { if (NULL != (pTask = m_sqlTask.Pop())) { //从m_sqlTask中取出的任务先执行完成后,pTask将携带结果数据 pTask->Execute(); //得到结果后,立刻将该任务放入结果任务队列 m_resultTask.Push(pTask); continue; } sleep(1000); }//end while-loop } 现在的问题来了: 任务队列 A 中的任务从何而来,目前只有消费者,没有生产者,那么生产者是谁? 任务队列 B 中的任务将去何方,目前只有生产者没有消费者。 这两个问题先放一会儿,等到后面我再来回答。 (二)工作线程和主线程 在介绍主线程和工作线程具体做什么时,我们介绍下服务器编程中常常抽象出来的几个概念(这里以 tcp 连接为例): TcpServer 即 Tcp 服务,服务器需要绑定ip地址和端口号,并在该端口号上侦听客户端的连接(往往由一个成员变量 TcpListener 来管理侦听细节)。所以一个 TcpServer 要做的就是这些工作。除此之外,每当有新连接到来时,TcpServer 需要接收新连接,当多个新连接存在时,TcpServer 需要有条不紊地管理这些连接:连接的建立、断开等,即产生和管理下文中说的 TcpConnection 对象。 一个连接对应一个 TcpConnection 对象,TcpConnection 对象管理着这个连接的一些信息:如连接状态、本端和对端的 ip 地址和端口号等。 数据通道对象 Channel,Channel 记录了 socket 的句柄,因而是一个连接上执行数据收发的真正执行者,Channel 对象一般作为 TcpConnection 的成员变量。 TcpSession 对象,是将 Channel 收取的数据进行解包,或者对准备好的数据进行装包,并传给 Channel 发送。 归纳起来:一个 TcpServer 依靠 TcpListener 对新连接的侦听和处理,依靠 TcpConnection 对象对连接上的数据进行管理,TcpConnection 实际依靠 Channel 对数据进行收发,依靠 TcpSession 对数据进行装包和解包。也就是说一个 TcpServer 存在一个 TcpListener,对应多个 TcpConnection,有几个TcpConnection 就有几个 TcpSession,同时也就有几个 Channel。 以上说的 TcpServer、TcpListener、TcpConnection、Channel 和 TcpSession 是服务器框架的网络层。一个好的网络框架,应该做到与业务代码脱耦。即上层代码只需要拿到数据,执行业务逻辑,而不用关注数据的收发和网络数据包的封包和解包以及网络状态的变化(比如网络断开与重连)。 拿数据的发送来说: 当业务逻辑将数据交给 TcpSession,TcpSession 将数据装好包后(装包过程后可以有一些加密或压缩操作),交给 TcpConnection::SendData(),而TcpConnection::SendData() 实际是调用 Channel::SendData(),因为 Channel 含有 socket 句柄,所以 Channel::SendData() 真正调用send()/sendto()/write() 方法将数据发出去。 对于数据的接收,稍微有一点不同: 通过 select()/poll()/epoll() 等IO multiplex技术,确定好了哪些 TcpConnection 上有数据到来后,激活该 TcpConnection 的 Channel 对象去调用recv()/recvfrom()/read() 来收取数据。数据收到以后,将数据交由 TcpSession来处理,最终交给业务层。注意数据收取、解包乃至交给业务层是一定要分开的。我的意思是:最好不要解包并交给业务层和数据收取的逻辑放在一起。因为数据收取是 IO 操作,而解包和交给业务层是逻辑计算操作。IO 操作一般比逻辑计算要慢。到底如何安排要根据服务器业务来取舍,也就是说你要想好你的服务器程序的性能瓶颈在网络 IO 还是逻辑计算,即使是网络 IO,也可以分为上行操作和下行操作,上行操作即客户端发数据给服务器,下行即服务器发数据给客户端。有时候数据上行少,下行大。(如游戏服务器,一个 npc 移动了位置,上行是该客户端通知服务器自己最新位置,而下行确是服务器要告诉在场的每个客户端)。 在我的文章《主线程与工作线程的分工》中介绍了,工作线程的流程: while (!m_bQuit) { epoll_or_select_func(); handle_io_events(); handle_other_things(); } 其中 epoll_or_select_func() 即是上文所说的通过 select()/poll()/epoll() 等 IO multiplex 技术,确定好了哪些 TcpConnection 上有数据到来。我的服务器代码中一般只会监测 socket 可读事件,而不会监测 socket 可写事件。至于如何发数据,文章后面会介绍。所以对于可读事件,以 epoll 为例,这里需要设置的标识位是: EPOLLIN 普通可读事件(当连接正常时,产生这个事件,recv()/read()函数返回收到的字节数;当连接关闭,这两个函数返回0,也就是说我们设置这个标识已经可以监测到新来数据和对端关闭事件) EPOLLRDHUP 对端关闭事件(linux man 手册上说这个事件可以监测对端关闭,但我实际调试时发送即使对端关闭也没触发这个事件,仍然是EPOLLIN,只不过此时调用recv()/read()函数,返回值会为0,所以实际项目中是否可以通过设置这个标识来监测对端关闭,仍然待考证) EPOLLPRI 带外数据 muduo 里面将 epoll_wait 的超时事件设置为 1 毫秒,我的另一个项目将 epoll_wait 超时时间设置为 10 毫秒。这两个数值供大家参考。 这个项目中,工作线程和主线程都是上文代码中的逻辑,主线程监听侦听socket 上的可读事件,也就是监测是否有新连接来了。主线程和每个工作线程上都存在一个 epollfd。如果新连接来了,则在主线程的 handle_io_events() 中接受新连接。产生的新连接的socket句柄挂接到哪个线程的 epollfd 上呢?这里采取的做法是 round-robin 算法,即存在一个对象 CWorkerThreadManager 记录了各个工作线程上工作状态。伪码大致如下: void attach_new_fd(int newsocketfd) { workerthread = get_next_worker_thread(next); workerthread.attach_to_epollfd(newsocketfd); ++next; if (next > max_worker_thread_num) next = 0; } 即先从第一个工作线程的 epollfd 开始挂接新来 socket,接着累加索引,这样下次就是第二个工作线程了。如果所以超出工作线程数目,则从第一个工作重新开始。这里解决了新连接 socket “负载均衡”的问题。在实际代码中还有个需要注意的细节就是:epoll_wait 的函数中的 struct epoll_event 数量开始到底要设置多少个才合理?存在的顾虑是,多了浪费,少了不够用,我在曾经一个项目中直接用的是 4096: const int EPOLL_MAX_EVENTS = 4096; const int dwSelectTimeout = 10000; struct epoll_event events[EPOLL_MAX_EVENTS]; int nfds = epoll_wait(m_fdEpoll, events, EPOLL_MAX_EVENTS, dwSelectTimeout / 1000); 我在陈硕的 muduo 网络库中发现作者才用了一个比较好的思路,即动态扩张数量:开始是 n 个,当发现有事件的 fd 数量已经到达 n 个后,将 struct epoll_event 数量调整成 2n 个,下次如果还不够,则变成 4n 个,以此类推,作者巧妙地利用 stl::vector 在内存中的连续性来实现了这种思路: //初始化代码 std::vector events_(16); //线程循环里面的代码 while (m_bExit) { int numEvents = ::epoll_wait(epollfd_, &*events_.begin(), static_cast(events_.size()), 1); if (numEvents > 0) { if (static_cast(numEvents) == events_.size()) { events_.resize(events_.size() * 2); } } } 读到这里,你可能觉得工作线程所做的工作也不过就是调用 handle_io_events() 来接收网络数据,其实不然,工作线程也可以做程序业务逻辑上的一些工作。也就是在 handle_other_things() 里面。那如何将这些工作加到 handle_other_things() 中去做呢?写一个队列,任务先放入队列,再让 handle_other_things() 从队列中取出来做?我在该项目中也借鉴了muduo库的做法。即 handle_other_things() 中调用一系列函数指针,伪码如下: void do_other_things() { somefunc(); } //m_functors是一个stl::vector,其中每一个元素为一个函数指针 void somefunc() { for (size_t i = 0; i 当任务产生时,只要我们将执行任务的函数 push_back 到 m_functors 这个 stl::vector 对象中即可。但是问题来了,如果是其他线程产生的任务,两个线程同时操作 m_functors,必然要加锁,这也会影响效率。muduo 是这样做的: void add_task(const Functor& cb) { std::unique_lock lock(mutex_); m_functors.push_back(cb); } void do_task() { std::vector functors; { std::unique_lock lock(mutex_); functors.swap(m_functors); } for (size_t i = 0; i 看到没有,利用一个栈变量 functors 将 m_functors 中的任务函数指针倒换(swap)过来了,这样大大减小了对 m_functors 操作时的加锁粒度。前后变化:变化前,相当于原来 A 给 B 多少东西,B 消耗多少,A 给的时候,B 不能消耗;B 消耗的时候A不能给。现在变成A将东西放到篮子里面去,B 从篮子里面拿,B 如果拿去一部分后,只有消耗完了才会来拿,或者 A 通知 B 去篮子里面拿,而 B 忙碌时,A 是不会通知 B 来拿,这个时候 A 只管将东西放在篮子里面就可以了。 bool bBusy = false; void add_task(const Functor& cb) { std::unique_lock lock(mutex_); m_functors_.push_back(cb); //B不忙碌时只管往篮子里面加,不要通知B if (!bBusy) { wakeup_to_do_task(); } } void do_task() { bBusy = true; std::vector functors; { std::unique_lock lock(mutex_); functors.swap(pendingFunctors_); } for (size_t i = 0; i 看,多巧妙的做法! 因为每个工作线程都存在一个 m_functors,现在问题来了,如何将产生的任务均衡地分配给每个工作线程。这个做法类似上文中如何将新连接的 socket 句柄挂载到工作线程的 epollfd 上,也是 round-robin 算法。上文已经描述,此处不再赘述。 还有种情况,就是希望任务产生时,工作线程能够立马执行这些任务,而不是等 epoll_wait 超时返回之后。这个时候的做法,就是使用一些技巧唤醒epoll_wait,Linux 系统可以使用 socketpair 或 timerevent、eventfd 等技巧。 上文中留下三个问题: 数据库线程任务队列A中的任务从何而来,目前只有消费者,没有生产者,那么生产者是谁? 数据库线程任务队列B中的任务将去何方,目前只有生产者没有消费者。 业务层的数据如何发送出去? 问题 1 的答案是:业务层产生任务可能会交给数据库任务队列A,这里的业务层代码可能就是工作线程中 do_other_things() 函数执行体中的调用。至于交给这个 9 个数据库线程的哪一个的任务队列,同样采用了 round-robin 算法。所以就存在一个对象 CDbThreadManager 来管理这九个数据库线程。下面的伪码是向数据库工作线程中加入任务: bool CDbThreadManager::AddTask(IMysqlTask* poTask ) { if (m_index >= m_dwThreadsCount) { m_index = 0; } return m_aoMysqlThreads[m_index++].AddTask(poTask); } 同理问题 2 中的消费者也可能就是 do_other_things() 函数执行体中的调用。 现在来说问题 3,业务层的数据产生后,经过 TcpSession 装包后,需要发送的话,产生任务丢给工作线程的 do_other_things(),然后在相关的 Channel 里面发送,因为没有监测该 socket 上的可写事件,所以该数据可能调用 send() 或者 write() 时会阻塞,没关系,sleep() 一会儿,继续发送,一直尝试,到数据发出去。伪码如下: bool Channel::Send() { int offset = 0; while (true) { int n = ::send(socketfd, buf + offset, length - offset); if (n == -1) { if (errno == EWOULDBLOCK) { ::sleep(100); continue; } } //对方关闭了socket,这端建议也关闭 else if (n == 0) { close(socketfd); return false; } offset += n; if (offset >= length) break; } return true; } 如需下载本站全部技术文章,可以在【高性能服务器开发】公众号回复关键字“文章下载”即可。最近更新时间: 2020-09-20 23:06:40 "},"articles/高性能服务器框架设计/错误码系统的设计.html":{"url":"articles/高性能服务器框架设计/错误码系统的设计.html","title":"错误码系统的设计","keywords":"","body":"错误码系统的设计 本文介绍服务器开发中一组服务中错误码系统的设计理念与实践,如果读者从来没想过或者没接触过这种设计理念,建议认真体会一下这种设计思路的优点。 错误码的作用 读者如果有使用过中国电信的宽带账号上网的经历,如果我们登陆不成功,一般服务器会返回一个错误码,如651、678。然后,我们打中国电信的客服电话,客服会询问我们错误码是多少,通过错误码他们的技术人员就大致知道了错误原因;并且通过错误码,他们就知道到底是电信的服务器问题还是宽带用户自己的设备或者操作问题,如果是用户自己的问题,他们一般会尝试教用户如何操作,而不是冒然就派遣维修人员上门,这样不仅能尽早解决问题同时也节约了人力成本。 再举另外一个例子,我们日常浏览网页,当Web服务器正常返回页面时,状态码一般是200(OK),而当页面不存在时,错误码一般是404,另外像503等错误都是比较常见的。 通过以上两个例子,读者应该能明白,对于服务器系统来说,设计一套好的错误码是非常有必要的,可以在用户请求出问题时迅速定位并解决问题。具体包括两个方面: 可以迅速定位是用户“输入”问题还是服务器自身的问题。 所谓的用户“输入”问题,是指用户的不当操作,这里的“用户的不当操作”可能是因为客户端软件本身的逻辑错误或漏洞,也可能是使用客户端的人的非法操作,而客户端软件在设计上因为考虑不周而缺乏有效性校验,这两类情形都可能会产生非法的数据,并且直接发给服务器。一个好的服务端系统不能假设客户端的请求数据一定是合法的,必须对传过来的数据做有效性校验。服务器没有义务一定给非法的请求做出应答,因此请求的最终结果是服务器不应答或给客户端不想要的应答。 以上面的例子为例,宽带用户输入了无效的用户名或者密码造成服务器拒绝访问;用户在浏览器中输入了一个无效的网址等。这类错误,都是需要用户自己解决或者用户可以自己解决的。如果错误码可以反映出这类错误,那么在实际服务器运维的过程中,当用户反馈这一类故障时,我们通过服务器内部产生的错误码或者应答给客户端的错误码,准确快速地确定问题原因。如果是用户非法请求造成的,可以让用户自行解决。注意,这里的“用户”,可以代指人,也可以代指使用某个服务器的所有下游服务和客户端。 可以快速定位哪个步骤或哪个服务出了问题。 对于单个服务,假设收到某个“客户端”请求时,需要经历多个步骤才能完成,而这中间任何一个步骤都可能出问题,在不同步骤出错时返回不同的错误码,那么就可以知道是哪个步骤出了问题。 其次,一般稍微复杂一点的系统,都不是单个服务,往往是由一组服务构成。如果将错误码分段,每个服务的错误码都有各自的范围,那么通过错误码,我们也能准确地知道是哪个服务出了问题。 错误码系统设计实践 前面介绍了太多的理论知识,我们来看一个具体的例子。假设如下一个“智能邮件系统”,其结构如下所示: 上图中的服务“智能邮件坐席站点”和“配置站点”是客户端,”智能邮件操作综合接口“和”邮件配置服务“是对客户端提供服务的前置服务,这两个前置服务后面还依赖后面的数个服务。由于这里我们要说明的是技术问题,而不是业务问题,所以具体每个服务作何用途这里就不一一介绍了。在这个系统中,当客户端得到前置服务某个不正确应答时,会得到一个错误码,我们按以下规则来设计错误码: 服务名称 正值错误码范围 负值错误码范围 智能邮件综合操作接口 100~199 -100~-199 ES数据同步服务 200~299 -200~-299 邮件配置服务 300~399 -300~-399 邮件基础服务 400~499 -400~-499 我们在设计这套系统时,做如下规定: 所有的正值错误码表示所在服务的上游服务发来的请求不满足业务要求。举个例子,假设某次智能邮件坐席站点客户端得到了一个错误码101,我们可以先确定错误产生的服务器是智能邮件综合操作接口服务;其次,产生该错误的原因是智能邮件坐席站点客户端发送给智能邮件综合操作接口服务的请求不满足要求,通过这个错误码我们甚至可以进一步确定发送的请求哪里不符合要求。如我们可以这样定义: 100 用户名不存在 101 密码无效 102 发送的邮件收件人非法 103 邮件正文含有非法字符 其他从略,此处就不一一列举了。 所有的负值错误码表示程序内部错误。如: -100 数据库操作错误 -101 网络错误 -102 内存分配失败 -103 ES数据同步服务连接不上 其他从略,此处就不一一列举了。 对负值错误码的特殊处理 通过前面的介绍,读者应该能看出正值错误码与负值错误码的区别,即正值错误码一般是由请求服务的客户产生,如果出现这样的错误,应该由客户自己去解决问题;而负值错误码,则一般是服务内部产生的错误。因此,如果是正值错误码,错误码和错误信息一般可以直接返回给客户端;而对于负值错误,我们一般只将错误码返回给客户端,而不带上具体的错误信息,这也是读者在使用很多软件产品时,经常会得到“网络错误”这类万能错误提示。也就是说对于负值错误码的错误信息,我们可以统一显示成“网络错误”或者其他比较友好的错误提示。 这样做的原因有二: 客户端即使拿到这样的错误信息,也不能对排查和解决问题提供任何帮助,因为这些错误是程序内部错误或者bug。 这类错误有可能是企业内部的设计缺陷,直接暴露给客户,除了让客户对企业的技术实力产生质疑以外,没有任何其他正面效应。 而之所以带上错误码,是为了方便内部排查和定位问题。当然,现在的企业服务,内部也有大量监控系统,可能也不会再暴露这样的错误码了。 扩展 上文介绍了利用错误码的分段来定位问题的技术思想,其实不仅仅是错误码可以分段,我们在开发一组服务时,业务类型也可以通过编号来分段,这样通过业务号就能知道归属哪个服务了。 如果读者以前没接触过这种设计思想,希望可以好好的思考和体会一下。 如需下载本站全部技术文章,可以在【高性能服务器开发】公众号回复关键字“文章下载”即可。最近更新时间: 2020-10-09 23:22:13 "},"articles/高性能服务器框架设计/日志系统的设计.html":{"url":"articles/高性能服务器框架设计/日志系统的设计.html","title":"日志系统的设计","keywords":"","body":"日志系统的设计 为什么需要日志 实际的软件项目产出都有一个流程,即先开发、测试,再发布生产,由于人的因素,既然是软件产品就不可能百分百没有 bug 或者逻辑错误,对于已经发布到生产的项目,一旦某个时刻产生非预期的结果,我们就需要去定位和排查问题。但是一般正式的生产环境的服务器或者产品是不允许开发人员通过附加调试器去排查问题的,主要有如下可能原因: 在很多互联网企业,开发部门、测试部分和产品运维部门是分工明确的,软件产品一旦发布到生产环境以后,将全部交由运维部门人员去管理和维护,而原来开发此产品的开发人员不再拥有相关的操作程序的权限。 对于已经上了生产环境的服务,其数据和程序稳定性是公司的核心产值所在,一般不敢或不允许被开发人员随意调试或者操作,以免造成损失。 发布到生产环境的服务,一般为了让程序执行效率更高、文件体积更小,都是去掉调试符号后的版本,不方便也不利于调试。 既然我们无法通过调试器去调试,这个时候为了跟踪和回忆当时的程序行为进而定位问题,我们就需要日志系统。 退一步说,即使在开发或者测试环境,我们可以把程序附加到调试器上去调试,但是对于一些特定的程序行为,我们无法通过设置断点,让程序在某个时刻暂停下来进行调试。例如,对于某些网络通信功能,如果暂停时间过长(相对于某些程序逻辑来说),通信的对端可能由于彼端没有在规定时间内响应而断开连接,导致程序逻辑无法进入我们想要的执行流中去;再例如,对于一些高频操作(如心跳包、定时器、界面绘制下的某些高频重复行为),可能在少量次数下无法触发我们想要的行为,而通过断点的暂停方式,我们不得不重复操作几十次、上百次甚至更多,这样排查问题效率是非常低下的。对于这类操作,我们可以通过打印日志,将当时的程序行为上下文现场记录下来,然后从日志系统中找到某次不正常的行为的上下文信息。这也是日志的另外一个作用。 本文将从技术和业务上两个方面来介绍日志系统相关的设计与开发,所谓技术上,就是如何从程序开发的角度设计一款功能强大、性能优越、使用方便的日志系统;而业务上,是指我们在使用日志系统时,应该去记录哪些行为和数据,既简洁、不啰嗦,又方便需要时快速准确地定位到问题。 日志系统的技术上的实现 日志的最初的原型即将程序运行的状态打印出来,对于 C/C++ 这门语言来说,即可以利用 printf、std::cout 等控制台输出函数,将日志信息输出到控制台,这类简单的情形我们不在此过多赘述。 对于商业项目,为了方便排查问题,我们一般不将日志写到控制台,而是输出到文件或者数据库系统。不管哪一种,其思路基本上一致,我们这里以写文件为例来详细介绍。 同步写日志 所谓同步写日志,指的是在输出日志的地方,将日志即时写入到文件中去。根据笔者的经验,这种设计广泛地用于相当多的的客户端软件。笔者曾从事过数年的客户端开发(包括 PC、安卓软件),设计过一些功能复杂的金融客户端产品,在这些系统中采用的就是这种同步写日志的方式。之所以使用这种方式其主要原因就是设计简单,而又不会影响用户使用体验。说到这里读者可能有这样一个疑问:一般的客户端软件,一般存在界面,而界面部分所属的逻辑就是程序的主线程,如果采取这种同步写日志的方式,当写日志时,写文件是磁盘 IO 操作,相比较程序其他部分是 CPU 操作,前者要慢很多,这样势必造成CPU等待,进而导致主线程“卡”在写文件处,进而造成界面卡顿,从而导致用户使用软件的体验不好。读者的这种顾虑确实是存在的。但是,很多时候我们不用担心这种问题,主要有两个原因: 对于客户端程序,即使在主线程(UI 线程)中同步写文件,其单次或者几次磁盘操作累加时间,与人(用户)的可感知时间相比,也是非常小的,也就是说用户根本感觉不到这种同步写文件造成的延迟。当然,这里也给您一个提醒就是,如果在 UI 线程里面写日志,尤其是在一些高频操作中(如 Windows 的界面绘制消息 WM_PAINT 处理逻辑中),一定要控制写日志的长度和次数,否则就会因频繁写文件或一次写入数据过大而对界面造成卡顿。 客户端程序除了 UI 线程,还有其他与界面无关的工作线程,在这些线程中直接写文件,一般不会对用户的体验产生什么影响。 说了这么多,我们给出一个具体的例子。 日志类的 .h 文件 /** *@desc: IULog.h *@author: zhangyl *@date: 2014.12.25 */ #ifndef __LOG_H__ #define __LOG_H__ enum LOG_LEVEL { LOG_LEVEL_INFO, LOG_LEVEL_WARNING, LOG_LEVEL_ERROR }; //注意:如果打印的日志信息中有中文,则格式化字符串要用_T()宏包裹起来, #define LOG_INFO(...) CIULog::Log(LOG_LEVEL_INFO, __FUNCSIG__,__LINE__, __VA_ARGS__) #define LOG_WARNING(...) CIULog::Log(LOG_LEVEL_WARNING, __FUNCSIG__, __LINE__,__VA_ARGS__) #define LOG_ERROR(...) CIULog::Log(LOG_LEVEL_ERROR, __FUNCSIG__,__LINE__, __VA_ARGS__) class CIULog { public: static bool Init(bool bToFile, bool bTruncateLongLog, PCTSTR pszLogFileName); static void Uninit(); static void SetLevel(LOG_LEVEL nLevel); //不输出线程ID号和所在函数签名、行号 static bool Log(long nLevel, PCTSTR pszFmt, ...); //输出线程ID号和所在函数签名、行号 static bool Log(long nLevel, PCSTR pszFunctionSig, int nLineNo, PCTSTR pszFmt, ...); //注意:pszFunctionSig参数为Ansic版本 static bool Log(long nLevel, PCSTR pszFunctionSig, int nLineNo, PCSTR pszFmt, ...); private: CIULog() = delete; ~CIULog() = delete; CIULog(const CIULog& rhs) = delete; CIULog& operator=(const CIULog& rhs) = delete; static void GetTime(char* pszTime, int nTimeStrLength); private: static bool m_bToFile; //日志写入文件还是写到控制台 static HANDLE m_hLogFile; static bool m_bTruncateLongLog; //长日志是否截断 static LOG_LEVEL m_nLogLevel; //日志级别 }; #endif // !__LOG_H__ 日志的 cpp 文件 /** *@desc: IULog.cpp *@author: zhangyl *@date: 2014.12.25 */ #include \"stdafx.h\" #include \"IULog.h\" #include \"EncodingUtil.h\" #include #ifndef LOG_OUTPUT #define LOG_OUTPUT #endif #define MAX_LINE_LENGTH 256 bool CIULog::m_bToFile = false; bool CIULog::m_bTruncateLongLog = false; HANDLE CIULog::m_hLogFile = INVALID_HANDLE_VALUE; LOG_LEVEL CIULog::m_nLogLevel = LOG_LEVEL_INFO; bool CIULog::Init(bool bToFile, bool bTruncateLongLog, PCTSTR pszLogFileName) { #ifdef LOG_OUTPUT m_bToFile = bToFile; m_bTruncateLongLog = bTruncateLongLog; if (pszLogFileName == NULL || pszLogFileName[0] == NULL) return FALSE; TCHAR szHomePath[MAX_PATH] = {0}; ::GetModuleFileName(NULL, szHomePath, MAX_PATH); for (int i = _tcslen(szHomePath); i >= 0; --i) { if (szHomePath[i] == _T('\\\\')) { szHomePath[i] = _T('\\0'); break; } } TCHAR szLogDirectory[MAX_PATH] = { 0 }; _stprintf_s(szLogDirectory, _T(\"%s\\\\Logs\\\\\"), szHomePath); DWORD dwAttr = ::GetFileAttributes(szLogDirectory); if (!((dwAttr != 0xFFFFFFFF) && (dwAttr & FILE_ATTRIBUTE_DIRECTORY))) { TCHAR cPath[MAX_PATH] = { 0 }; TCHAR cTmpPath[MAX_PATH] = { 0 }; TCHAR* lpPos = NULL; TCHAR cTmp = _T('\\0'); _tcsncpy_s(cPath, szLogDirectory, MAX_PATH); for (int i = 0; i 上述代码中根据日志级别定义了三个宏 LOG_INFO、LOG_WARNING、LOG_ERROR,如果要使用该日志模块,只需要在程序启动处的地方调用 CIULog::Init 函数初始化日志: SYSTEMTIME st = {0}; ::GetLocalTime(&st); TCHAR szLogFileName[MAX_PATH] = {0}; _stprintf_s(szLogFileName, MAX_PATH, _T(\"%s\\\\Logs\\\\%04d%02d%02d%02d%02d%02d.log\"), g_szHomePath, st.wYear, st.wMonth, st.wDay, st.wHour, st.wMinute, st.wSecond); CIULog::Init(true, false, szLogFileName); 当然,最佳的做法,在程序退出的地方,调用 CIULog::Uninit 回收日志模块相关的资源: CIULog::Uninit(); 在做好这些准备工作以后,如果你想在程序的某个地方写一条日志,只需要这样写: //打印一条 INFO 级别的日志 LOG_INFO(\"Request logon: Account=%s, Password=*****, Status=%d, LoginType=%d.\", pLoginRequest->m_szAccountName, pLoginRequest->m_szPassword, pLoginRequest->m_nStatus, (long)pLoginRequest->m_nLoginType); //打印一条 WARNING 级别的日志 LOG_WARN(\"Some warning...\"); //打印一条 ERROR 级别的日志 LOG_ERROR(\"Recv data error, errorNO=%d.\", ::WSAGetLastError()); 关于 CIULog 这个日志模块类,如果读者要想实际运行查看效果,可以从链接(https://github.com/baloonwj/flamingo/tree/master/flamingoclient )下载完整的项目代码来运行。该日志输出效果如下: [2018-11-09 23:52:54:0826][INFO][ThreadID: 7252][bool __thiscall CIUSocket::Login(const char *,const char *,int,int,int,class std::basic_string,class std::allocator > &):1107]Request logon: Account=zhangy, Password=*****, Status=76283204, LoginType=1. [2018-11-09 23:52:56:0352][INFO][ThreadID: 5828][void __thiscall CIUSocket::SendThreadProc(void):794]Recv data thread start... [2018-11-09 23:52:56:0385][INFO][ThreadID: 6032][void __thiscall CSendMsgThread::HandleUserBasicInfo(const class CUserBasicInfoRequest *):298]Request to get userinfo. [2018-11-09 23:52:56:0355][INFO][ThreadID: 7140][void __thiscall CIUSocket::RecvThreadProc(void):842]Recv data thread start... [2018-11-09 23:52:57:0254][INFO][ThreadID: 7220][int __thiscall CRecvMsgThread::HandleFriendListInfo(const class std::basic_string,class std::allocator > &):593]Recv user basic info, info count=1. 多线程同步写日志出现的问题一 从上面的日志输出来看,这种同步的日志输出方式,也存在时间顺序不正确的问题(时间戳大的日志比时间戳小的日志靠前)。这是由于多线程同时写日志到同一个文件时,产生日志的时间和实际写入磁盘的时间不是一个原子操作。下图解释了该现象出现的根源: 多线程写同一个日志文件出现先产生的日志后写入到文件中的现象 好在这种时间顺序不正确只会出现在不同线程之间,对于同一个线程的不同时间的日志记录顺序肯定是正确的。所以这种日期错乱现象,并不影响我们使用日志。 多线程同步写日志出现的问题二 多线程同时写入同一个日志文件还有一个问题,就是假设线程 A 写某一个时刻追加日志内容为 “AAAAA” ,线程 B 在同一时刻追加日志内容为 “BBBBB” ,线程 C 在同一时刻追加日志内容为 “CCCCC” ,那么最终的日志文件中的内容会不会出现 “AABBCCABCAACCBB” 这种格式? 在类 Unix 系统上(包括 Linux),同一个进程内针对同一个 FILE* 的操作是线程安全的,也就是说,在这类操作系统上得到的日志结果 A、B、C 各个字母组一定是连续在一起,也就是说最终得到的日志内容可能是 “AAAAACCCCCBBBBB” 或 “AAAAABBBBBCCCCC” 等这种连续的格式,绝不会出现 A、B、C 字母交错相间的现象。 而在Windows系统上,对于 FILE* 的操作并不是线程安全的。但是笔者做了大量实验,在 Windows 系统上也没有出现这种 A、B、C 字母相间的现象。(关于这个问题的讨论,可以参考这里:https://www.zhihu.com/question/40472431 ) 这种同步日志的实现方式,一般用于低频写日志的软件系统中(如客户端软件),所以我可以认为这种多线程同时写日志到一个文件中是可行的。 异步写日志 当然,对于性能要求不高的应用(如大多数客户端程序、某些并发数量不高的服务)来说,这种同步写日志的实现方式是可以满足要求的。但是对于 QPS 要求很高或者对性能有一定要求的服务器程序,同步写日志等待磁盘 IO 的完成对于服务的关键性逻辑的快速执行和及时响应性会造成一定的性能损失。为了减小这种损失,我们可以采用异步日志。 所谓异步写日志,与同步写日志相反,即产生日志的地方,不会将日志实时写入到文件中去,而是通过一些线程同步技术将日志先暂存下来,然后再通过一个或多个专门的日志写入线程去将这些缓存的日志写入到磁盘中去,这样的话,原来输出日志的线程就不存在等待写日志到磁盘这样的效率损耗了。这本质上,其实就是一个生产者和消费者,产生日志的线程是生产者,将日志写入文件的线程是消费者。当然,对于日志的消费者线程,我这里提到“一个”或“多个”日志线程,在实际开发中,如果多个日志消费线程,我们又要考虑多个线程可能会造成写日志的时间顺序错位(时间较晚的日志写在时间较早的日志前面),这在上文中已经讨论过,这里不再赘述。 总结起来,为了方便读者理解和编码,我们可以认为异步写日志的逻辑一般存在一组专门写日志的线程(一个或多个),程序的其他线程为这些日志线程生产日志。 至于其他线程如何将产生的日志交给日志线程,这就是多线程之间资源同步的问题了。我们可以使用一个队列来存储其他线程产生的日志,日志线程从该队列中取出日志,然后将日志内容写入文件。最简单的方式是日志生产线程将每次产生的日志信息放入一个队列时、日志写入线程从队列中取出日志时,都使用一个互斥体( mutex )保护起来。代码示例如下(C++11 代码): /** *@desc: AsyncLogger.cpp *@author: zhangyl *@date: 2018.11.10 */ #include \"stdafx.h\" #include #include #include #include #include #include //保护队列的互斥体 std::mutex log_mutex; std::list cached_logs; FILE* log_file = NULL; bool init_log_file() { //以追加内容的形式写入文件内容,如果文件不存在,则创建 log_file = fopen(\"my.log\", \"a+\"); return log_file != NULL; } void uninit_log_file() { if (log_file != NULL) fclose(log_file); } bool write_log_tofile(const std::string& line) { if (log_file == NULL) return false; if (fwrite((void*)line.c_str(), 1, line.length(), log_file) != line.length()) return false; //将日志立即冲刷到文件中去 fflush(log_file); return true; } void log_producer() { int index = 0; while (true) { ++ index; std::ostringstream os; os lock(log_mutex); cached_logs.emplace_back(os.str()); } std::chrono::milliseconds duration(100); std::this_thread::sleep_for(duration); } } void log_consumer() { std::string line; while (true) { //使用花括号括起来为的是减小锁的粒度 { std::lock_guard lock(log_mutex); if (!cached_logs.empty()) { line = cached_logs.front(); cached_logs.pop_front(); } } if (line.empty()) { std::chrono::milliseconds duration(1000); std::this_thread::sleep_for(duration); continue; } write_log_tofile(line); line.clear(); } } int main(int argc, char* argv[]) { if (!init_log_file()) { std::cout 上述代码分别模拟了三个生产日志的线程(log_producer1~3)和三个消费日志线程(log_consumer1~3)。当然上述代码可以继续优化,如果当前缓存队列中没有日志记录,那么消费日志线程会做无用功。 优化方法一 可以使用条件变量,如果当前队列中没有日志记录,则将日志消费线程挂起;当生产日志的线程产生了新的日志后,置信(signal)条件变量,这样日志消费线程会被唤醒,以将日志从队列中取出来并写入文件。我们来看下代码: /** *@desc: AsyncLoggerLinux.cpp *@author: zhangyl *@date: 2018.11.10 */ #include \"stdafx.h\" #include #include #include #include #include #include #include std::mutex log_mutex; std::condition_variable log_cv; std::list cached_logs; FILE* log_file = NULL; bool init_log_file() { //以追加内容的形式写入文件内容,如果文件不存在,则创建 log_file = fopen(\"my.log\", \"a+\"); return log_file != NULL; } void uninit_log_file() { if (log_file != NULL) fclose(log_file); } bool write_log_tofile(const std::string& line) { if (log_file == NULL) return false; if (fwrite((void*)line.c_str(), 1, line.length(), log_file) != line.length()) return false; //将日志立即冲刷到文件中去 fflush(log_file); return true; } void log_producer() { int index = 0; while (true) { ++ index; std::ostringstream os; os lock(log_mutex); cached_logs.emplace_back(os.str()); log_cv.notify_one(); } std::chrono::milliseconds duration(100); std::this_thread::sleep_for(duration); } } void log_consumer() { std::string line; while (true) { //使用花括号括起来为的是减小锁的粒度 { std::unique_lock lock(log_mutex); if (cached_logs.empty()) { //无限等待 log_cv.wait(lock); } line = cached_logs.front(); cached_logs.pop_front(); } if (line.empty()) { std::chrono::milliseconds duration(1000); std::this_thread::sleep_for(duration); continue; } write_log_tofile(line); line.clear(); } } int main(int argc, char* argv[]) { if (!init_log_file()) { std::cout 优化方法二 除了条件变量以外,我们还可以使用信号量来设计我们的异步日志系统,信号量是带有资源计数的线程同步对象,当每产生一条日志时,我们将信号量资源计数自增1,日志消费线程默认是等待这个信号量是否受信,如果受信,每唤醒一个日志消费线程,信号量字数计数将自动减1。通俗地说就是生成者每生产一个资源,就将资源计数加1,而消费者每消费一个资源数量,就将资源计数减一;如果当前资源计数已经为0,则消费者将自动挂起。 由于 C++ 11 没有提供对不同平台的信号量对象的封装,我们这里分别给出 Windows 和 Linux 两个平台的实现代码,读者可以根据需要来学习其中一个或两个同时学习。注意,为了保持代码风格一致,对于线程和读写文件相关函数,在不同的操作系统平台,我们使用该系统平台相关的 API 接口,而不再使用 C++ 11 的相关函数和类库。 Windows 平台代码 /** *@desc: AsyncLogger.cpp,Windows版本 *@author: zhangyl *@date: 2018.11.10 */ #include \"stdafx.h\" #include #include #include #include #include std::list cached_logs; CRITICAL_SECTION g_cs; HANDLE g_hSemaphore = NULL; HANDLE g_hLogFile = INVALID_HANDLE_VALUE; bool Init() { InitializeCriticalSection(&g_cs); //假设资源数量上限是0xFFFFFFFF g_hSemaphore = CreateSemaphore(NULL, 0, 0xFFFFFFFF, NULL); //如果文件不存在,则创建 g_hLogFile = CreateFile(_T(\"my.log\"), GENERIC_WRITE, FILE_SHARE_READ, NULL, CREATE_ALWAYS, FILE_ATTRIBUTE_NORMAL, NULL); if (g_hLogFile == INVALID_HANDLE_VALUE) return false; return true; } void Uninit() { DeleteCriticalSection(&g_cs); if (g_hSemaphore != NULL) CloseHandle(g_hSemaphore); if (g_hLogFile != INVALID_HANDLE_VALUE) CloseHandle(g_hLogFile); } bool WriteLogToFile(const std::string& line) { if (g_hLogFile == INVALID_HANDLE_VALUE) return false; DWORD dwBytesWritten; //如果对于比较长的日志,应该分段写入,因为单次写入可能只能写入部分,这里为了演示方便,逻辑从简 if (!WriteFile(g_hLogFile, line.c_str(), line.length(), &dwBytesWritten, NULL) || dwBytesWritten != line.length()) return false; //将日志立即冲刷到文件中去 FlushFileBuffers(g_hLogFile); return true; } DWORD CALLBACK LogProduceThreadProc(LPVOID lpThreadParameter) { int index = 0; while (true) { ++ index; std::ostringstream os; os 上述代码,在多线程向队列中增加日志记录和从队列中取出日志记录使用了Windows的上的临界区(CRITICAL_SECTION,有的书上译作“关键段”)对象来对队列进行保护。 Linux 平台代码 /** *@desc: AsyncLogger.cpp,linux版本 *@author: zhangyl *@date: 2018.11.10 */ #include #include #include #include #include #include #include #include std::list cached_logs; pthread_mutex_t log_mutex = PTHREAD_MUTEX_INITIALIZER; sem_t log_semphore; FILE* plogfile = NULL; bool init() { pthread_mutex_init(&log_mutex, NULL); //初始信号量资源数目是0 sem_init(&log_semphore, 0, 0); //如果文件不存在,则创建 plogfile = fopen(\"my.log\", \"a++\"); return plogfile != NULL; } void uninit() { pthread_mutex_destroy(&log_mutex); sem_destroy(&log_semphore); if (plogfile != NULL) fclose(plogfile); } bool write_log_to_file(const std::string& line) { if (plogfile == NULL) return false; //如果对于比较长的日志,应该分段写入,因为单次写入可能只能写入部分,这里为了演示方便,逻辑从简 if (fwrite((void*)line.c_str(), 1, line.length(), plogfile) != line.length()) return false; //将日志立即冲刷到文件中去 fflush(plogfile); return true; } void* producer_thread_proc(void* arg) { int index = 0; while (true) { ++ index; std::ostringstream os; os 我们使用 g++ 编译器编译上述代码,使用如下命令生成可移植性文件 AsyncLoggerLinux: g++ -g -o AsyncLoggerLinux AsyncLoggerLinux.cpp -lpthread 接着执行生成的 AsyncLoggerLinux 文件,然后生成的日志效果如下: This is log, index: 1, producer threadID: 140512358795008 This is log, index: 1, producer threadID: 140512367187712 This is log, index: 1, producer threadID: 140512375580416 This is log, index: 2, producer threadID: 140512358795008 This is log, index: 2, producer threadID: 140512367187712 This is log, index: 2, producer threadID: 140512375580416 This is log, index: 3, producer threadID: 140512358795008 This is log, index: 3, producer threadID: 140512367187712 This is log, index: 3, producer threadID: 140512375580416 This is log, index: 4, producer threadID: 140512358795008 This is log, index: 4, producer threadID: 140512367187712 This is log, index: 4, producer threadID: 140512375580416 This is log, index: 5, producer threadID: 140512358795008 This is log, index: 5, producer threadID: 140512367187712 This is log, index: 5, producer threadID: 140512375580416 //省略... 当然,您可能需要同时能在 Windows 和 Linux 同时运行的代码,我们自然而然想到用 C++11 语法的信号量,由于 C++ 11 本身没有提供现成的信号量库,我们可以自己利用std::mutex、std::condition_variable模拟一下信号量的功能,这里就不再详细介绍了,有兴趣的可以自行尝试一下。 以上就是异步日志的基本原理,在这个原理的基础上,我们可以增加很多的特性。 如需下载本站全部技术文章,可以在【高性能服务器开发】公众号回复关键字“文章下载”即可。最近更新时间: 2020-10-09 23:36:09 "},"articles/高性能服务器框架设计/如何设计断线自动重连机制.html":{"url":"articles/高性能服务器框架设计/如何设计断线自动重连机制.html","title":"如何设计断线自动重连机制","keywords":"","body":"如何设计断线自动重连机制 在有连接依赖关系的服务与服务之间,或客户端与服务器之间,无论是出于方便使用、降低运维成本、提高工作效率(服务与服务之间),还是优化用户体验(客户端与服务器之间)自动重连机制通常都是一个非常重要的功能。 情景一 对于一组服务之间,如果其中一些服务(主动连接方,下文以 A 代称)需要与另外一些服务(被连接方,下文以 B 代称)建立 TCP 长连接,如果 A 没有自动连接 B 的功能,那么在部署或者测试这些服务的时候,必须先启动 B,再启动 A,因为一旦先启动 A,A 此时去尝试连接 B(由于 B 还没有启动)会失败,之后 A 再也不会去连接 B了(即使随后 B 被启动了),从而导致整个系统不能正常工作。这是缺点一。 情景二 即使部署或测试的时候,先启动了 B,再启动 A,A 与 B 之间的连接在运行期间内,可能由于网络波动等原因导致 A 与 B 之间连接断开,之后整个系统也不能再正常工作了。这是缺点二。 情景三 如果我们想升级 B,更新完程序后,重启 B,也必须重启 A。如果这种依赖链比较长(例如 A 连接 B,B 连接 C,C 连接 D,D 连接 E,等等),那么更新某个程序的效率和成本会非常高。这是缺点三。 情景四 对于客户端软件来说,如果因为用户的网络短暂故障导致客户端与服务器失去连接,等网络恢复后,较好的用户体验是客户端能检测到用户网络变化后,自动与服务器重连,以便用户能及时收到最新的消息。 以上四个情景说明了断线自动重连功能的重要性,那如何去设计好的断线重连机制呢? 重连本身的功能开发很简单,其实就是调用 socket 函数 connect 函数,不断去“重试”。这里的“重试”我使用了双引号,是为了说明重试的技巧非常有讲究: 对于服务器端程序,例如 A 连接 B,如果连接不上,整个系统将无法工作,那么我们开发 A 服务时,重连的逻辑可以很简单,即 A 一旦发现与 B 断开了连接,就立即尝试与 B 重新连接,如果连接不上,隔一段时间再重试(一般设置为 3 秒或 5 秒即可),一直到连接成功为止。当然,期间可以不断发送报警邮件或者持续输出错误日志,来引起开发或者运维人员的尽快干预,以便尽早排查和解决连接不上的原因。 对于客户端软件,以上做法也是可以的,但是不是最优的。客户端所处的网络环境比服务器程序所处的网络环境一般要恶劣的多,等间隔的定时去重连,一般作用不大(例如用户拔掉了网线)。因此,对于客户端软件,一般出现断线,会尝试去重连,如果连接不上,会隔个比前一次时间更长的时间间隔去重连,例如这个时间间隔可以是 2 秒、4 秒、8 秒、16秒等等。但是,这样也存在一个问题,随着重连次数的变多,重连的时间间隔会越来越大(当然,你也可以设置一个最大重连时间间隔,之后恢复到之前较小的时间间隔)。如果网络此时已经恢复(例如用户重新插上网线),我们的程序需要等待一个很长的时间间隔(如 16 秒)才能恢复连接,这同样不利于用户体验。一般情况下,如果网络发生波动,我们的程序可以检测网络状态,如果网络状态恢复正常此时应该立即进行一次重连,而不是一成不变地按照设置的时间间隔去重连。 操作系统提供了检测网络状态变化的 API 函数,例如对于 Windows 可以使用 IsNetworkAlive() 函数去检测,对于 Android,网络变化时会发送消息类型是 WifiManager.NETWORK_STATE_CHANGED_ACTION 的广播通知。 另外,还需要注意的是,如果客户端网络断开,一般会在界面某个地方显式地告诉用户当前连接状态,并提醒当前正在进行断线重连,且应该有一个可以让用户放弃断线重连或者立即进行一次断线重连的功能。 综上所述,总结起来,对于服务器程序之间的重连可以设计成等时间间隔的定时重连,对于客户端程序要结合依次放大重连时间间隔、网络状态变化立即重连或用户主动发起重连这三个因素来设计。 不需要重连的情形 不需要重连一般有以下情形: 用户使用客户端主动放弃重连; 因为一些业务上的规定,禁止客户端重连; 举个例子,如果某个系统同一时刻同一个账户只允许登陆一个,某个账户在机器 A 上登陆,此时接着又在机器 B 上登陆,此时 A 将被服务器踢下线,那么此时 A 客户端的逻辑就应该禁止自动重连。 技术上的断线重连和业务上的断线重连 这里说的技术上的重连,指的是调用 connect 函数连接,在实际开发中,大多数系统光有技术上的重连成功(即 connect 连接成功)是没有任何意义的,网络连接成功以后,接下来还得再次向服务器发送账号验证信息等等(如登陆数据包),只有这些数据验签成功后,才能算是真正的重连成功,这里说的发送账号验证信息并验签成功就是业务上的重连成功。复杂的系统可能会需要连续好几道验签流程。因此,我们在设计断线重连机制的时候,不仅要考虑技术上的重连,还要考虑业务上的重连。只有完整地包含这两个流程,才算是较优的断线自动重连功能。 本节介绍的知识点主要是思路性的内容,一旦搞清楚了思路,技术上实现起来并不会存在什么困难,因此本节没有给出具体的代码示例。 欢迎关注公众号『easyserverdev』,本公众号推崇基础学习与原理理解,不谈大而空的架构与技术术语,分享接地气的服务器开发实战技巧与项目经验,实实在在分享可用于实际编码的编程知识。同时,您也可以加入我的 QQ 群578019391。 文章已于2019-08-12修改 如需下载本站全部技术文章,可以在【高性能服务器开发】公众号回复关键字“文章下载”即可。最近更新时间: 2020-06-07 12:17:04 "},"articles/高性能服务器框架设计/心跳包机制设计详解.html":{"url":"articles/高性能服务器框架设计/心跳包机制设计详解.html","title":"心跳包机制设计详解","keywords":"","body":"心跳包机制设计详解 存在下面两种情形: 情形一:一个客户端连接服务器以后,如果长期没有和服务器有数据来往,可能会被防火墙程序关闭连接,有时候我们并不想要被关闭连接。例如,对于一个即时通讯软件,如果服务器没有消息时,我们确实不会和服务器有任何数据交换,但是如果连接被关闭了,有新消息来时,我们再也没法收到了,这就违背了“即时通讯”的设计要求。 情形二:通常情况下,服务器与某个客户端一般不是位于同一个网络,其之间可能经过数个路由器和交换机,如果其中某个必经路由器或者交换器出现了故障,并且一段时间内没有恢复,导致这之间的链路不再畅通,而此时服务器与客户端之间也没有数据进行交换,由于 TCP 连接是状态机,对于这种情况,无论是客户端或者服务器都无法感知与对方的连接是否正常,这类连接我们一般称之为“死链”。 情形一中的应用场景要求必须保持客户端与服务器之间的连接正常,就是我们通常所说的“保活“。如上文所述,当服务器与客户端一定时间内没有有效业务数据来往时,我们只需要给对端发送心跳包即可实现保活。 情形二中的死链,只要我们此时任意一端给对端发送一个数据包即可检测链路是否正常,这类数据包我们也称之为”心跳包”,这种操作我们称之为“心跳检测”。顾名思义,如果一个人没有心跳了,可能已经死亡了;一个连接长时间没有正常数据来往,也没有心跳包来往,就可以认为这个连接已经不存在,为了节约服务器连接资源,我们可以通过关闭 socket,回收连接资源。 根据上面的分析,让我再强调一下,心跳检测一般有两个作用: 保活 检测死链 TCP keepalive 选项 操作系统的 TCP/IP 协议栈其实提供了这个的功能,即 keepalive 选项。在 Linux 操作系统中,我们可以通过代码启用一个 socket 的心跳检测(即每隔一定时间间隔发送一个心跳检测包给对端),代码如下: //on 是 1 表示打开 keepalive 选项,为 0 表示关闭,0 是默认值 int on = 1; setsockopt(fd, SOL_SOCKET, SO_KEEPALIVE, &on, sizeof(on)); 但是,即使开启了这个选项,这个选项默认发送心跳检测数据包的时间间隔是 7200 秒(2 小时),这时间间隔实在是太长了,不具有实用性。 我们可以通过继续设置 keepalive 相关的三个选项来改变这个时间间隔,它们分别是 TCP_KEEPIDLE、TCP_KEEPINTVL 和 TCP_KEEPCNT,示例代码如下: //发送 keepalive 报文的时间间隔 int val = 7200; setsockopt(fd, IPPROTO_TCP, TCP_KEEPIDLE, &val, sizeof(val)); //两次重试报文的时间间隔 int interval = 75; setsockopt(fd, IPPROTO_TCP, TCP_KEEPINTVL, &interval, sizeof(interval)); int cnt = 9; setsockopt(fd, IPPROTO_TCP, TCP_KEEPCNT, &cnt, sizeof(cnt)); TCP_KEEPIDLE 选项设置了发送 keepalive 报文的时间间隔,发送时如果对端回复 ACK。则本端 TCP 协议栈认为该连接依然存活,继续等 7200 秒后再发送 keepalive 报文;如果对端回复 RESET,说明对端进程已经重启,本端的应用程序应该关闭该连接。 如果对端没有任何回复,则本端做重试,如果重试 9 次(TCP_KEEPCNT 值)(前后重试间隔为 75 秒(TCP_KEEPINTVL 值))仍然不可达,则向应用程序返回 ETIMEOUT(无任何应答)或 EHOST 错误信息。 我们可以使用如下命令查看 Linux 系统上的上述三个值的设置情况: [root@iZ238vnojlyZ ~]# sysctl -a | grep keepalive net.ipv4.tcp_keepalive_intvl = 75 net.ipv4.tcp_keepalive_probes = 9 net.ipv4.tcp_keepalive_time = 7200 在 Windows 系统设置 keepalive 及对应选项的代码略有不同: //开启 keepalive 选项 const char on = 1; setsockopt(socket, SOL_SOCKET, SO_KEEPALIVE, (char *)&on, sizeof(on); // 设置超时详细信息 DWORD cbBytesReturned; tcp_keepalive klive; // 启用保活 klive.onoff = 1; klive.keepalivetime = 7200; // 重试间隔为10秒 klive.keepaliveinterval = 1000 * 10; WSAIoctl(socket, SIO_KEEPALIVE_VALS, &klive, sizeof(tcp_keepalive), NULL, 0, &cbBytesReturned, NULL, NULL); 应用层的心跳包机制设计 由于 keepalive 选项需要为每个连接中的 socket 开启,这不一定是必须的,可能会产生大量无意义的带宽浪费,且 keepalive 选项不能与应用层很好地交互,因此一般实际的服务开发中,还是建议读者在应用层设计自己的心跳包机制。那么如何设计呢? 从技术来讲,心跳包其实就是一个预先规定好格式的数据包,在程序中启动一个定时器,定时发送即可,这是最简单的实现思路。但是,如果通信的两端有频繁的数据来往,此时到了下一个发心跳包的时间点了,此时发送一个心跳包。这其实是一个流量的浪费,既然通信双方不断有正常的业务数据包来往,这些数据包本身就可以起到保活作用,为什么还要浪费流量去发送这些心跳包呢?所以,对于用于保活的心跳包,我们最佳做法是,设置一个上次包时间,每次收数据和发数据时,都更新一下这个包时间,而心跳检测计时器每次检测时,将这个包时间与当前系统时间做一个对比,如果时间间隔大于允许的最大时间间隔(实际开发中根据需求设置成 15 ~ 45 秒不等),则发送一次心跳包。总而言之,就是在与对端之间,没有数据来往达到一定时间间隔时才发送一次心跳包。 发心跳包的伪码: bool CIUSocket::Send() { int nSentBytes = 0; int nRet = 0; while (true) { nRet = ::send(m_hSocket, m_strSendBuf.c_str(), m_strSendBuf.length(), 0); if (nRet == SOCKET_ERROR) { if (::WSAGetLastError() == WSAEWOULDBLOCK) break; else { LOG_ERROR(\"Send data error, disconnect server:%s, port:%d.\", m_strServer.c_str(), m_nPort); Close(); return false; } } else if (nRet guard(m_mutexLastDataTime); m_nLastDataTime = (long)time(NULL); } return true; } bool CIUSocket::Recv() { int nRet = 0; char buff[10 * 1024]; while (true) { nRet = ::recv(m_hSocket, buff, 10 * 1024, 0); if (nRet == SOCKET_ERROR) //一旦出现错误就立刻关闭Socket { if (::WSAGetLastError() == WSAEWOULDBLOCK) break; else { LOG_ERROR(\"Recv data error, errorNO=%d.\", ::WSAGetLastError()); //Close(); return false; } } else if (nRet guard(m_mutexLastDataTime); //记录一下最近一次收包时间 m_nLastDataTime = (long)time(NULL); } return true; } void CIUSocket::RecvThreadProc() { LOG_INFO(\"Recv data thread start...\"); int nRet; //上网方式 DWORD dwFlags; BOOL bAlive; while (!m_bStop) { //检测到数据则收数据 nRet = CheckReceivedData(); //出错 if (nRet == -1) { m_pRecvMsgThread->NotifyNetError(); } //无数据 else if (nRet == 0) { long nLastDataTime = 0; { std::lock_guard guard(m_mutexLastDataTime); nLastDataTime = m_nLastDataTime; } if (m_nHeartbeatInterval > 0) { //当前系统时间与上一次收发数据包的时间间隔超过了m_nHeartbeatInterval //则发一次心跳包 if (time(NULL) - nLastDataTime >= m_nHeartbeatInterval) SendHeartbeatPackage(); } } //有数据 else if (nRet == 1) { if (!Recv()) { m_pRecvMsgThread->NotifyNetError(); continue; } DecodePackages(); }// end if }// end while-loop LOG_INFO(\"Recv data thread finish...\"); } 同理,检测心跳包的一端,应该是在与对端没有数据来往达到一定时间间隔时才做一次心跳检测。 心跳检测一端的伪码如下: void BusinessSession::send(const char* pData, int dataLength) { bool sent = TcpSession::send(pData, dataLength); //发送完数据更新下发包时间 updateHeartbeatTime(); } void BusinessSession::handlePackge(char* pMsg, int msgLength, bool& closeSession, std::vector& vectorResponse) { //对数据合法性进行校验 if (pMsg == NULL || pMsg[0] == 0 || msgLength MAX_DATA_LENGTH) { //非法刺探请求,不做任何应答,直接关闭连接 closeSession = true; return; } //更新下收包时间 updateHeartbeatTime(); //省略包处理代码... } void BusinessSession::updateHeartbeatTime() { std::lock_guard scoped_guard(m_mutexForlastPackageTime); m_lastPackageTime = (int64_t)time(nullptr); } bool BusinessSession::doHeartbeatCheck() { const Config& cfg = Singleton::Instance(); int64_t now = (int64_t)time(nullptr); std::lock_guard lock_guard(m_mutexForlastPackageTime); if (now - m_lastPackageTime >= cfg.m_nMaxClientDataInterval) { //心跳包检测,超时,关闭连接 LOGE(\"heartbeat expired, close session\"); shutdown(); return true; } return false; } void TcpServer::checkSessionHeartbeat() { int64_t now = (int64_t)time(nullptr); if (now - m_nLastCheckHeartbeatTime >= m_nHeartbeatCheckInterval) { m_spSessionManager->checkSessionHeartbeat(); m_nLastCheckHeartbeatTime = (int64_t)time(nullptr); } } void SessionManager::checkSessionHeartbeat() { std::lock_guard scoped_lock(m_mutexForSession); for (const auto& iter : m_mapSessions) { //这里调用 BusinessSession::doHeartbeatCheck() iter.second->doHeartbeatCheck(); } } 需要注意的是:一般是客户端主动给服务器端发送心跳包,服务器端做心跳检测决定是否断开连接。而不是反过来,从客户端的角度来说,客户端为了让自己得到服务器端的正常服务有必要主动和服务器保持连接状态正常,而服务器端不会局限于某个特定的客户端,如果客户端不能主动和其保持连接,那么就会主动回收与该客户端的连接。当然,服务器端在收到客户端的心跳包时应该给客户端一个心跳应答。 带业务数据的心跳包 上面介绍的心跳包是从纯技术的角度来说的,在实际应用中,有时候我们需要定时或者不定时从服务器端更新一些数据,我们可以把这类数据放在心跳包中,定时或者不定时更新。 这类带业务数据的心跳包,就不再是纯粹技术上的作用了(这里说的技术的作用指的上文中介绍的心跳包起保活和检测死链作用)。 这类心跳包实现也很容易,即在心跳包数据结构里面加上需要的业务字段信息,然后在定时器中定时发送,客户端发给服务器,服务器在应答心跳包中填上约定的业务数据信息即可。 心跳包与流量 通常情况下,多数应用场景下,与服务器端保持连接的多个客户端中,同一时间段活跃用户(这里指的是与服务器有频繁数据来往的客户端)一般不会太多。当连接数较多时,进出服务器程序的数据包通常都是心跳包(为了保活)。所以为了减轻网络代码压力,节省流量,尤其是针对一些 3/4 G 手机应用,我们在设计心跳包数据格式时应该尽量减小心跳包的数据大小。 心跳包与调试 如前文所述,对于心跳包,服务器端的逻辑一般是在一定时间间隔内没有收到客户端心跳包时会主动断开连接。在我们开发调试程序过程中,我们可能需要将程序通过断点中断下来,这个过程可能是几秒到几十秒不等。等程序恢复执行时,连接可能因为心跳检测逻辑已经被断开。 调试过程中,我们更多的关注的是业务数据处理的逻辑是否正确,不想被一堆无意义的心跳包数据干扰实线。 鉴于以上两点原因,我们一般在调试模式下关闭或者禁用心跳包检测机制。代码大致如下: ChatSession::ChatSession(const std::shared_ptr& conn, int sessionid) : TcpSession(conn), m_id(sessionid), m_seq(0), m_isLogin(false) { m_userinfo.userid = 0; m_lastPackageTime = time(NULL); //这里设置了非调试模式下才开启心跳包检测功能 #ifndef _DEBUG EnableHearbeatCheck(); #endif } 当然,你也可以将开启心跳检测的开关做成配置信息放入程序配置文件中。 心跳包与日志 实际生产环境,我们一般会将程序收到的和发出去的数据包写入日志中,但是无业务信息的心跳包信息是个例外,一般会刻意不写入日志,这是因为心跳包数据一般比较多,如果写入日志会导致日志文件变得很大,且充斥大量无意义的心跳包日志,所以一般在写日志时会屏蔽心跳包信息写入。 我这里的建议是,可以将心跳包信息是否写入日志做成一个配置开关,一般处于关闭状态,有需要时再开启。例如,对于一个 WebSocket 服务,ping 和 pong 是心跳包数据,下面示例代码按需输出心跳日志信息: void BusinessSession::send(std::string_view strResponse) { bool success = WebSocketSession::send(strResponse); if (success) { bool enablePingPongLog = Singleton::Instance().m_bPingPongLogEnabled; //其他消息正常打印,心跳消息按需打印 if (strResponse != \"pong\" || enablePingPongLog) { LOGI(\"msg sent to client [%s], sessionId: %s, session: 0x%0x, clientId: %s, accountId: %s, frontId: %s, msg: %s\", getClientInfo(), m_strSessionId.c_str(), (int64_t)this, m_strClientID.c_str(), m_strAccountID.c_str(), BusinessSession::m_strFrontId.c_str(), strResponse.data()); } } } 需要说明的是,以上示例代码使用 C/C++ 语言编写,但是本节介绍的心跳包机制设计思路和注意事项是普适性原理,同样适用于其他编程语言。 如需下载本站全部技术文章,可以在【高性能服务器开发】公众号回复关键字“文章下载”即可。最近更新时间: 2020-09-20 23:07:00 "},"articles/高性能服务器框架设计/业务数据处理一定要单独开线程吗.html":{"url":"articles/高性能服务器框架设计/业务数据处理一定要单独开线程吗.html","title":"业务数据处理一定要单独开线程吗","keywords":"","body":"业务数据处理一定要单独开线程吗 在 《one thread one loop 思想》一文我们介绍了一个 loop 的主要结构一般如下所示: while (!m_bQuitFlag) { epoll_or_select_func(); handle_io_events(); handle_other_things(); } 对于一些业务逻辑处理比较简单、不会太耗时的应用来说,handle_io_events() 方法除了收发数据也可以直接用来直接做业务的处理,即其结构如下: void handle_io_events() { //收发数据 recv_or_send_data(); //解包并处理数据 decode_packages_and_process(); } 其中 recv_or_send_data() 方法中调用 send/recv API 进行实际的网络数据收发。以收数据为例,收完数据存入接收缓冲区后,接下来进行解包处理,然后进行业务处理,例如一个登陆数据包,其业务就是验证登陆的账户密码是否正确、记录其登陆行为等等。从程序函数调用堆栈来看,这些业务处理逻辑其实是直接在网络收发数据线程中处理的。我的意思是:网络线程调用 handle_io_events() 方法,handle_io_events() 方法调用 decode_packages_and_process() 方法,decode_packages_and_process() 方法做具体的业务逻辑处理。 需要注意的是,为了让网络层与业务层脱耦,网络层中通常会提供一些回调函数的接口,这些回调函数我们将其指向具体的业务处理函数。以 libevent 网络库的用法为例: int main(int argc, char **argv) { struct event_base *base; struct evconnlistener *listener; struct event *signal_event; struct sockaddr_in sin; base = event_base_new(); memset(&sin, 0, sizeof(sin)); sin.sin_family = AF_INET; sin.sin_port = htons(PORT); //listener_cb是我们自定义回调函数 listener = evconnlistener_new_bind(base, listener_cb, (void *)base, LEV_OPT_REUSEABLE|LEV_OPT_CLOSE_ON_FREE, -1, (struct sockaddr*)&sin, sizeof(sin)); if (!listener) { fprintf(stderr, \"Could not create a listener!\\n\"); return 1; } //signal_cb是我们自定义回调函数 signal_event = evsignal_new(base, SIGINT, signal_cb, (void *)base); if (!signal_event || event_add(signal_event, NULL)上述代码根据 libevent 自带的 helloworld 示例修改而来,其中 listener_cb 和 signal_cb 是自定义的回调函数,有相应的事件触发后,libevent 的事件循环会调用我们设置的回调,在这些回调函数中,我们可以编写自己的业务逻辑代码。 这种基本的服务器结构,我们可以绘制成如下流程图: 这是这个结构的最基本逻辑,在这基础上可以延伸出很多变体。不知道读者有没有发现,上述流程图中第三步解包和业务逻辑处理这一步中(位于 handle_io_events() 中的 decode_packages_and_process() 方法中),如果业务逻辑处理过程比较耗时(例如,从数据库取大量数据、写文件),那么会导致 网络线程在这个步骤停留时间很长,导致很久以后才能执行下一次循环,影响网络数据的检测和收发,最终导致整个程序的效率低下。 因此,对于这种情形,我们需要将业务处理逻辑单独拆出来交给另外的业务工作线程处理,业务工作线程可以是一个线程池,这个过程业务数据从网络线程组流向业务线程组。 这样的程序结构图如下图所示: 上图中,对于网络线程将业务数据包交给业务线程,可以使用一个共享的业务数据队列来实现,此时网络线程是生产者,业务线程从业务数据队列中取出任务去处理,业务线程是消费者。业务线程处理完成后如果需要将结果数据发出去,则再将数据交给网络线程。这里处理后的数据从业务线程再次流向网络线程,那么如何将数据从业务线程交给网络线程呢?这里以发数据为例,一般有三种方法: 方法一 直接调用相应的的发数据的方法,如果你的网络线程本身也会调用这些发数据的方法,那么此时就可能会出现网络线程和业务线程同时对发方法进行调用,相当于多个线程同时调用 socket send 函数,这样可能会导致同一个连接上的数据顺序有问题,此时的做法时,利用锁机制,同一时刻只有一个线程可以调用 socket send 方法。这里给出一段伪代码,假设 TcpConnection 对象表示某路连接,无论网络线程还是业务线程处理完数据后需要发送数据,则使用: void TcpConnection::sendData(const std::string& data) { //加上锁 std::lock_guard scoped_lock(m_mutexForConnection); //在这里调用 send } 方法一的做法在设计上来说,存在让人不满意的地方,即数据发送应该属于网络层自己的事情,而不是其他模块(这里指的是业务线程)强行抢夺过来越俎代庖。 方法二 前面章节介绍了存在定时器结构的情况,网络线程结构变成如下流程: while (!m_bQuitFlag) { check_and_handle_timers(); epoll_or_select_func(); handle_io_events(); } 业务线程可以将需要发送的数据放入另外一个共享区域中(例如相应的 TcpConnection 对象的一个成员变量中),定时器定时从这个共享区域取出来,再发送出去,这种方案的优点是网络线程做了它该做的事情,缺点是需要添加定时器,让程序逻辑变得复杂,且定时器是每隔一段时间才会触发,发送的数据可能会有一定的延迟。 方法三 利用线程执行流中的 handle_other_things() 方法,再来看下前面章节中介绍的基本结构: while (!m_bQuitFlag) { epoll_or_select_func(); handle_io_events(); handle_other_things(); } 我们在《one thread one loop 思想》章节介绍了 handle_other_things() 函数可以做一些“其他事情”,这个函数可以在需要执行时通过前面章节介绍的唤醒机制立即被唤醒执行。业务线程将数据放入某个共享区域中(这一步和方法二介绍的一样),然后添加 \"other_things\" ,在 handle_other_things() 中执行数据的发送。 如果读者能清晰明白地看到这里,说明您大致明白了一个不错的服务器框架是怎么回事了。上面介绍的服务器结构是目前主流的基于 Reactor 模式的服务程序的通用结构,例如 libevent、libuv。 如果读者有兴趣,咱们可以再进一步深入讨论一下。 实际应用中,很多程序的业务逻辑处理其实是不耗时的,也就是说这些业务逻辑处理速度很快。由于 CPU 核数有限,当线程数量超过 CPU 数量时,各个线程(网络线程和业务线程)也不是真正地并行执行,那么即使开了一组业务线程也不一定能真正地并发执行,而业务逻辑处理并不耗时,不会影响网络线程的执行效率,那么我们不如就在网络线程里面直接处理。 上文介绍了在 handle_io_events() 方法中直接处理,如果处理的业务逻辑会产生新的其他任务,那么我们可以投递 \"other_things\",最终交给 handle_other_things() 方法来处理。此时的服务器程序结构如下: 特别说明一下:这种方式仅限于 handle_io_events() 或 handle_other_things() 里面不会有耗时的逻辑,才可以替代专门开业务线程,如果有耗时操作还得老老实实单独开业务线程。虽然线程数量超过 CPU 数量时,各个线程不会得到真正的并行,但那是操作系统线程调度的事情了,应用层开发不必关心这点。 如需下载本站全部技术文章,可以在【高性能服务器开发】公众号回复关键字“文章下载”即可。最近更新时间: 2020-09-20 23:33:56 "},"articles/高性能服务器框架设计/C++高性能服务器网络框架设计细节.html":{"url":"articles/高性能服务器框架设计/C++高性能服务器网络框架设计细节.html","title":"C++ 高性能服务器网络框架设计细节","keywords":"","body":"C++ 高性能服务器网络框架设计细节 这篇文章我们将介绍服务器的开发,并从多个方面探究如何开发一款高性能高并发的服务器程序。需要注意的是一般大型服务器,其复杂程度在于其业务,而不是在于其代码工程的基本框架。大型服务器一般有多个服务组成,可能会支持CDN,或者支持所谓的“分布式”等,这篇文章不会介绍这些东西,因为不管结构多么复杂的服务器,都是由单个服务器组成的。所以这篇文章的侧重点是讨论单个服务程序的结构,而且这里的结构指的也是单个服务器的网络通信层结构,如果你能真正地理解了我所说的,那么在这个基础的结构上面开展任何业务都是可以的,也可以将这种结构扩展成复杂的多个服务器组,例如“分布式”服务。文中的代码示例虽然是以C++为例,但同样适合Java(我本人也是Java开发者),原理都是一样的,只不过Java可能在基本的操作系统网络通信API的基础上用虚拟机包裹了一层接口而已(Java甚至可能基于一些常用的网络通信框架思想提供了一些现成的API,例如NIO)。有鉴于此,这篇文章不讨论那些大而空、泛泛而谈的技术术语,而是讲的是实实在在的能指导读者在实际工作中实践的编码方案或优化已有编码的方法。另外这里讨论的技术同时涉及windows和linux两个平台。 所谓高性能就是服务器能流畅地处理各个客户端的连接并尽量低延迟地应答客户端的请求;所谓高并发,不仅指的是服务器可以同时支持多的客户端连接,而且这些客户端在连接期间内会不断与服务器有数据来往。网络上经常有各种网络库号称单个服务能同时支持百万甚至千万的并发,然后我实际去看了下,结果发现只是能同时支持很多的连接而已。如果一个服务器能单纯地接受n个连接(n可能很大),但是不能有条不紊地处理与这些连接之间的数据来往也没有任何意义,这种服务器框架只是“玩具型”的,对实际生产和应用没有任何意义。 这篇文章将从两个方面来介绍,一个是服务器中的基础的网络通信部件;另外一个是,如何利用这些基础通信部件整合成一个完整的高效的服务器框架。注意:本文以下内容中的客户端是相对概念,指的是连接到当前讨论的服务程序的终端,所以这里的客户端既可能是我们传统意义上的客户端程序,也可能是连接该服务的其他服务器程序。 一、网络通信部件 按上面介绍的思路,我们先从服务程序的网络通信部件开始介绍。 (一)、需要解决的问题 既然是服务器程序肯定会涉及到网络通信部分,那么服务器程序的网络通信模块要解决哪些问题?目前,网络上有很多网络通信框架,如libevent、boost asio、ACE,但都网络通信的常见的技术手段都大同小异,至少要解决以下问题: 如何检测有新客户端连接? 如何接受客户端连接? 如何检测客户端是否有数据发来? 如何收取客户端发来的数据? 如何检测连接异常?发现连接异常之后,如何处理? 如何给客户端发送数据? 如何在给客户端发完数据后关闭连接? 稍微有点网络基础的人,都能回答上面说的其中几个问题,比如接收客户端连接用socket API的accept函数,收取客户端数据用recv函数,给客户端发送数据用send函数,检测客户端是否有新连接和客户端是否有新数据可以用IO multiplexing技术(IO复用)的select、poll、epoll等socket API。确实是这样的,这些基础的socket API构成了服务器网络通信的地基,不管网络通信框架设计的如何巧妙,都是在这些基础的socket API的基础上构建的。但是如何巧妙地组织这些基础的socket API,才是问题的关键。我们说服务器很高效,支持高并发,实际上只是一个技术实现手段,不管怎样,从软件开发的角度来讲无非就是一个程序而已,所以,只要程序能最大可能地满足“尽量减少等待或者不等待”这一原则就是高效的,也就是说高效不是“忙的忙死,闲的闲死”,而是大家都可以闲着,但是如果有活要干,大家尽量一起干,而不是一部分忙着依次做事情123456789,另外一部分闲在那里无所事事。说的可能有点抽象,下面我们来举一些例子具体来说明一下。 例如: 默认情况下,recv函数如果没有数据的时候,线程就会阻塞在那里; 默认情况下,send函数,如果tcp窗口不是足够大,数据发不出去也会阻塞在那里; connect函数默认连接另外一端的时候,也会阻塞在那里; 又或者是给对端发送一份数据,需要等待对端回答,如果对方一直不应答,当前线程就阻塞在这里。 以上都不是高效服务器的开发思维方式,因为上面的例子都不满足“尽量减少等待”的原则,为什么一定要等待呢?有没用一种方法,这些过程不需要等待,最好是不仅不需要等待,而且这些事情完成之后能通知我。这样在这些本来用于等待的cpu时间片内,我就可以做一些其他的事情。有,也就是我们下文要讨论的IO Multiplexing技术(IO复用技术)。 (二)、几种IO复用机制的比较 目前windows系统支持select、WSAAsyncSelect、WSAEventSelect、完成端口(IOCP),linux系统支持select、poll、epoll。这里我们不具体介绍每个具体的函数的用法,我们来讨论一点深层次的东西,以上列举的API函数可以分为两个层次: 层次一 select和poll 层次二 WSAAsyncSelect、WSAEventSelect、完成端口(IOCP)、epoll 为什么这么分呢?先来介绍第一层次,select和poll函数本质上还是在一定时间内主动去查询socket句柄(可能是一个也可能是多个)上是否有事件,比如可读事件,可写事件或者出错事件,也就是说我们还是需要每隔一段时间内去主动去做这些检测,如果在这段时间内检测出一些事件来,我们这段时间就算没白花,但是倘若这段时间内没有事件呢?我们只能是做无用功了,说白了,还是在浪费时间,因为假如一个服务器有多个连接,在cpu时间片有限的情况下,我们花费了一定的时间检测了一部分socket连接,却发现它们什么事件都没有,而在这段时间内我们却有一些事情需要处理,那我们为什么要花时间去做这个检测呢?把这个时间用在做我们需要做的事情不好吗?所以对于服务器程序来说,要想高效,我们应该尽量避免花费时间主动去查询一些socket是否有事件,而是等这些socket有事件的时候告诉我们去处理。这也就是层次二的各个函数做的事情,它们实际相当于变主动查询是否有事件为当有事件时,系统会告诉我们,此时我们再去处理,也就是“好钢用在刀刃”上了。只不过层次二的函数通知我们的方式是各不相同,比如WSAAsyncSelect是利用windows窗口消息队列的事件机制来通知我们设定的窗口过程函数,IOCP是利用GetQueuedCompletionStatus返回正确的状态,epoll是epoll_wait函数返回而已。 例如,connect函数连接另外一端,如果用于连接socket是非阻塞的,那么connect虽然不能立刻连接完成,但是也是会立刻返回,无需等待,等连接完成之后,WSAAsyncSelect会返回FD_CONNECT事件告诉我们连接成功,epoll会产生EPOLLOUT事件,我们也能知道连接完成。甚至socket有数据可读时,WSAAsyncSelect产生FD_READ事件,epoll产生EPOLLIN事件,等等。所以有了上面的讨论,我们就可以得到网络通信检测可读可写或者出错事件的正确姿势。这是我这里提出的第二个原则:尽量减少做无用功的时间。这个在服务程序资源够用的情况下可能体现不出来什么优势,但是如果有大量的任务要处理,这里就成了性能的一个瓶颈。 (三)、检测网络事件的正确姿势 根据上面的介绍,第一,为了避免无意义的等待时间,第二,不采用主动查询各个socket的事件,而是采用等待操作系统通知我们有事件的状态的策略。我们的socket都要设置成非阻塞的。在此基础上我们回到栏目(一)中提到的七个问题: 如何检测有新客户端连接? 如何接受客户端连接? 默认accept函数会阻塞在那里,如果epoll检测到侦听socket上有EPOLLIN事件,或者WSAAsyncSelect检测到有FD_ACCEPT事件,那么就表明此时有新连接到来,这个时候调用accept函数,就不会阻塞了。当然产生的新socket你应该也设置成非阻塞的。这样我们就能在新socket上收发数据了。    如何检测客户端是否有数据发来? 如何收取客户端发来的数据? 同理,我们也应该在socket上有可读事件的时候才去收取数据,这样我们调用recv或者read函数时不用等待,至于一次性收多少数据好呢?我们可以根据自己的需求来决定,甚至你可以在一个循环里面反复recv或者read,对于非阻塞模式的socket,如果没有数据了,recv或者read也会立刻返回,错误码EWOULDBLOCK会表明当前已经没有数据了。示例: bool CIUSocket::Recv() { int nRet = 0; while(true) { char buff[512]; nRet = ::recv(m_hSocket, buff, 512, 0); if(nRet == SOCKET_ERROR) { if (::WSAGetLastError() == WSAEWOULDBLOCK) break; else return false; } else if(nRet } 如何检测连接异常?发现连接异常之后,如何处理? 同样当我们收到异常事件后例如EPOLLERR或关闭事件FD_CLOSE,我们就知道了有异常产生,我们对异常的处理一般就是关闭对应的socket。另外,如果send/recv或者read/write函数对一个socket进行操作时,如果返回0,那说明对端已经关闭了socket,此时这路连接也没必要存在了,我们也可以关闭对应的socket。 如何给客户端发送数据? 这也是一道常见的网络通信面试题,某一年的腾讯后台开发职位就问到过这样的问题。给客户端发送数据,比收数据要稍微麻烦一点,也是需要讲点技巧的。首先我们不能像注册检测数据可读事件一样一开始就注册检测数据可写事件,因为如果检测可写的话,一般情况下只要对端正常收取数据,我们的socket就都是可写的,如果我们设置监听可写事件,会导致频繁地触发可写事件,但是我们此时并不一定有数据需要发送。所以正确的做法是:如果有数据要发送,则先尝试着去发送,如果发送不了或者只发送出去部分,剩下的我们需要将其缓存起来,然后再设置检测该socket上可写事件,下次可写事件产生时,再继续发送,如果还是不能完全发出去,则继续设置侦听可写事件,如此往复,一直到所有数据都发出去为止。一旦所有数据都发出去以后,我们要移除侦听可写事件,避免无用的可写事件通知。不知道你注意到没有,如果某次只发出去部分数据,剩下的数据应该暂且存起来,这个时候我们就需要一个缓冲区来存放这部分数据,这个缓冲区我们称为“发送缓冲区”。发送缓冲区不仅存放本次没有发完的数据,还用来存放在发送过程中,上层又传来的新的需要发送的数据。为了保证顺序,新的数据应该追加在当前剩下的数据的后面,发送的时候从发送缓冲区的头部开始发送。也就是说先来的先发送,后来的后发送。    如何在给客户端发完数据后关闭连接? 这个问题比较难处理,因为这里的“发送完”不一定是真正的发送完,我们调用send或者write函数即使成功,也只是向操作系统的协议栈里面成功写入数据,至于能否被发出去、何时被发出去很难判断,发出去对方是否收到就更难判断了。所以,我们目前只能简单地认为send或者write返回我们发出数据的字节数大小,我们就认为“发完数据”了。然后调用close等socket API关闭连接。当然,你也可以调用shutdown函数来实现所谓的“半关闭”。关于关闭连接的话题,我们再单独开一个小的标题来专门讨论一下。 (四)被动关闭连接和主动关闭连接 在实际的应用中,被动关闭连接是由于我们检测到了连接的异常事件,比如EPOLLERR,或者对端关闭连接,send或recv返回0,这个时候这路连接已经没有存在必要的意义了,我们被迫关闭连接。 而主动关闭连接,是我们主动调用close/closesocket来关闭连接。比如客户端给我们发送非法的数据,比如一些网络攻击的尝试性数据包。这个时候出于安全考虑,我们关闭socket连接。 (五)发送缓冲区和接收缓冲区 上面已经介绍了发送缓冲区了,并说明了其存在的意义。接收缓冲区也是一样的道理,当收到数据以后,我们可以直接进行解包,但是这样并不好,理由一:除非一些约定俗称的协议格式,比如http协议,大多数服务器的业务的协议都是不同的,也就是说一个数据包里面的数据格式的解读应该是业务层的事情,和网络通信层应该解耦,为了网络层更加通用,我们无法知道上层协议长成什么样子,因为不同的协议格式是不一样的,它们与具体的业务有关。理由二:即使知道协议格式,我们在网络层进行解包处理对应的业务,如果这个业务处理比较耗时,比如需要进行复杂的运算,或者连接数据库进行账号密码验证,那么我们的网络线程会需要大量时间来处理这些任务,这样其它网络事件可能没法及时处理。鉴于以上二点,我们确实需要一个接收缓冲区,将收取到的数据放到该缓冲区里面去,并由专门的业务线程或者业务逻辑去从接收缓冲区中取出数据,并解包处理业务。 说了这么多,那发送缓冲区和接收缓冲区该设计成多大的容量?这是一个老生常谈的问题了,因为我们经常遇到这样的问题:预分配的内存太小不够用,太大的话可能会造成浪费。怎么办呢?答案就是像string、vector一样,设计出一个可以动态增长的缓冲区,按需分配,不够还可以扩展。 需要特别注意的是,这里说的发送缓冲区和接收缓冲区是每一个socket连接都存在一个。这是我们最常见的设计方案。 (六)协议的设计 除了一些通用的协议,如http、ftp协议以外,大多数服务器协议都是根据业务制定的。协议设计好了,数据包的格式就根据协议来设置。我们知道tcp/ip协议是流式数据,所以流式数据就是像流水一样,数据包与数据包之间没有明显的界限。比如A端给B端连续发了三个数据包,每个数据包都是50个字节,B端可能先收到10个字节,再收到140个字节;或者先收到20个字节,再收到20个字节,再收到110个字节;也可能一次性收到150个字节。这150个字节可以以任何字节数目组合和次数被B收到。所以我们讨论协议的设计第一个问题就是如何界定包的界限,也就是接收端如何知道每个包数据的大小。目前常用有如下三种方法: 1. 固定大小,这种方法就是假定每一个包的大小都是固定字节数目,例如上文中讨论的每个包大小都是50个字节,接收端每收气50个字节就当成一个包。 2. 指定包结束符,例如以一个\\r\\n(换行符和回车符)结束,这样对端只要收到这样的结束符,就可以认为收到了一个包,接下来的数据是下一个包的内容。 3. 指定包的大小,这种方法结合了上述两种方法,一般包头是固定大小,包头中有一个字段指定包体或者整个大的大小,对端收到数据以后先解析包头中的字段得到包体或者整个包的大小,然后根据这个大小去界定数据的界线。 协议要讨论的第二个问题是,设计协议的时候要尽量方便解包,也就是说协议的格式字段应该尽量清晰明了。 协议要讨论的第三个问题是,根据协议组装的单个数据包应该尽量小,注意这里指的是单个数据包,这样有如下好处:第一、对于一些移动端设备来说,其数据处理能力和带宽能力有限,小的数据不仅能加快处理速度,同时节省大量流量费用;第二、如果单个数据包足够小的话,对频繁进行网络通信的服务器端来说,可以大大减小其带宽压力,其所在的系统也能使用更少的内存。试想:假如一个股票服务器,如果一只股票的数据包是100个字节或者1000个字节,那同样是10000只股票区别呢? 协议要讨论的第四个问题是,对于数值类型,我们应该显式地指定数值的长度,比如long型,在32位机器上是32位4个字节,但是如果在64位机器上,就变成了64位8个字节了。这样同样是一个long型,发送方和接收方可能因为机器位数的不同会用不同的长度去解码。所以建议最好,在涉及到跨平台使用的协议最好显式地指定协议中整型字段的长度,比如int32、int64等等。下面是一个协议的接口的例子,当然java程序员应该很熟悉这样的接口: class BinaryReadStream { private: const char* const ptr; const size_t len; const char* cur; BinaryReadStream(const BinaryReadStream&); BinaryReadStream& operator=(const BinaryReadStream&); public: BinaryReadStream(const char* ptr, size_t len); virtual const char* GetData() const; virtual size_t GetSize() const; bool IsEmpty() const; bool ReadString(string* str, size_t maxlen, size_t& outlen); bool ReadCString(char* str, size_t strlen, size_t& len); bool ReadCCString(const char** str, size_t maxlen, size_t& outlen); bool ReadInt32(int32_t& i); bool ReadInt64(int64_t& i); bool ReadShort(short& i); bool ReadChar(char& c); size_t ReadAll(char* szBuffer, size_t iLen) const; bool IsEnd() const; const char* GetCurrent() const{ return cur; } public: bool ReadLength(size_t & len); bool ReadLengthWithoutOffset(size_t &headlen, size_t & outlen); }; class BinaryWriteStream { public: BinaryWriteStream(string* data); virtual const char* GetData() const; virtual size_t GetSize() const; bool WriteCString(const char* str, size_t len); bool WriteString(const string& str); bool WriteDouble(double value, bool isNULL = false); bool WriteInt64(int64_t value, bool isNULL = false); bool WriteInt32(int32_t i, bool isNULL = false); bool WriteShort(short i, bool isNULL = false); bool WriteChar(char c, bool isNULL = false); size_t GetCurrentPos() const{ return m_data->length(); } void Flush(); void Clear(); private: string* m_data; }; 其中BinaryWriteStream是编码协议的类,BinaryReadStream是解码协议的类。可以按下面这种方式来编码和解码。 编码: std::string outbuf; BinaryWriteStream writeStream(&outbuf); writeStream.WriteInt32(msg_type_register); writeStream.WriteInt32(m_seq); writeStream.WriteString(retData); writeStream.Flush(); 解码: BinaryReadStream readStream(strMsg.c_str(), strMsg.length()); int32_t cmd; if (!readStream.ReadInt32(cmd)) { return false; } //int seq; if (!readStream.ReadInt32(m_seq)) { return false; } std::string data; size_t datalength; if (!readStream.ReadString(&data, 0, datalength)) { return false; } 二、服务器程序结构的组织 上面的六个标题,我们讨论了很多具体的细节问题,现在是时候讨论将这些细节组织起来了。根据我的个人经验,目前主流的思想是one thread one loop+reactor模式(也有proactor模式)的策略。通俗点说就是一个线程一个循环,即在一个线程的函数里面不断地循环依次做一些事情,这些事情包括检测网络事件、解包数据产生业务逻辑。我们先从最简单地来说,设定一些线程在一个循环里面做网络通信相关的事情,伪码如下: while(退出标志) { //IO复用技术检测socket可读事件、出错事件 //(如果有数据要发送,则也检测可写事件) //如果有可读事件,对于侦听socket则接收新连接; //对于普通socket则收取该socket上的数据,收取的数据存入对应的接收缓冲区,如果出错则关闭连接; //如果有数据要发送,有可写事件,则发送数据 //如果有出错事件,关闭该连接 } 另外设定一些线程去处理接收到的数据,并解包处理业务逻辑,这些线程可以认为是业务线程了,伪码如下: //从接收缓冲区中取出数据解包,分解成不同的业务来处理 上面的结构是目前最通用的服务器逻辑结构,但是能不能再简化一下或者说再综合一下呢?我们试试,你想过这样的问题没有:假如现在的机器有两个cpu(准确的来说应该是两个核),我们的网络线程数量是2个,业务逻辑线程也是2个,这样可能存在的情况就是:业务线程运行的时候,网络线程并没有运行,它们必须等待,如果是这样的话,干嘛要多建两个线程呢?除了程序结构上可能稍微清楚一点,对程序性能没有任何实质性提高,而且白白浪费cpu时间片在线程上下文切换上。所以,我们可以将网络线程与业务逻辑线程合并,合并后的伪码看起来是这样子的: while(退出标志) { //IO复用技术检测socket可读事件、出错事件 //(如果有数据要发送,则也检测可写事件)   //如果有可读事件,对于侦听socket则接收新连接;   //对于普通socket则收取该socket上的数据,收取的数据存入对应的接收缓冲区,如果出错则关闭连接;   //如果有数据要发送,有可写事件,则发送数据   //如果有出错事件,关闭该连接   //从接收缓冲区中取出数据解包,分解成不同的业务来处理 } 你没看错,其实就是简单的合并,合并之后和不仅可以达到原来合并前的效果,而且在没有网络IO事件的时候,可以及时处理我们想处理的一些业务逻辑,并且减少了不必要的线程上下文切换时间。 我们再更进一步,甚至我们可以在这个while循环增加其它的一些任务的处理,比如程序的逻辑任务队列、定时器事件等等,伪码如下: while(退出标志) { //定时器事件处理 //IO复用技术检测socket可读事件、出错事件 //(如果有数据要发送,则也检测可写事件) //如果有可读事件,对于侦听socket则接收新连接; //对于普通socket则收取该socket上的数据,收取的数据存入对应的接收缓冲区,如果出错则关闭连接; //如果有数据要发送,有可写事件,则发送数据 //如果有出错事件,关闭该连接 //从接收缓冲区中取出数据解包,分解成不同的业务来处理 //程序自定义任务1 //程序自定义任务2 } 注意:之所以将定时器事件的处理放在网络IO事件的检测之前,是因为避免定时器事件过期时间太长。假如放在后面的话,可能前面的处理耗费了一点时间,等到处理定时器事件时,时间间隔已经过去了不少时间。虽然这样处理,也没法保证定时器事件百分百精确,但是能尽量保证。当然linux系统下提供eventfd这样的定时器对象,所有的定时器对象就能像处理socket这样的fd一样统一成处理。这也是网络库libevent的思想很像,libevent将socket、定时器、信号封装成统一的对象进行处理。 说了这么多理论性的东西,我们来一款流行的开源网络库muduo来说明吧(作者:陈硕),原库是基于boost的,我改成了C++11的版本,并修改了一些bug,在此感谢原作者陈硕。 上文介绍的核心线程函数的while循环位于eventloop.cpp中: void EventLoop::loop() { assert(!looping_); assertInLoopThread(); looping_ = true; quit_ = false; LOG_TRACE poll(kPollTimeMs, &activeChannels_); ++iteration_; if (Logger::logLevel() handleEvent(pollReturnTime_); } currentActiveChannel_ = NULL; eventHandling_ = false; doPendingFunctors(); if (frameFunctor_) { frameFunctor_(); } } LOG_TRACE poller_->poll利用epoll分离网络事件,然后接着处理分离出来的网络事件,每一个客户端socket对应一个连接,即一个TcpConnection和Channel通道对象。currentActiveChannel_->handleEvent(pollReturnTime_)根据是可读、可写、出错事件来调用对应的处理函数,这些函数都是回调函数,程序初始化阶段设置进来的: void Channel::handleEvent(Timestamp receiveTime) { std::shared_ptr guard; if (tied_) { guard = tie_.lock(); if (guard) { handleEventWithGuard(receiveTime); } } else { handleEventWithGuard(receiveTime); } } void Channel::handleEventWithGuard(Timestamp receiveTime) { eventHandling_ = true; LOG_TRACE 当然,这里利用了Channel对象的“多态性”,如果是普通socket,可读事件就会调用预先设置的回调函数;但是如果是侦听socket,则调用Aceptor对象的handleRead() 来接收新连接: void Acceptor::handleRead() { loop_->assertInLoopThread(); InetAddress peerAddr; //FIXME loop until no more int connfd = acceptSocket_.accept(&peerAddr); if (connfd >= 0) { // string hostport = peerAddr.toIpPort(); // LOG_TRACE 主循环里面的业务逻辑处理对应: doPendingFunctors(); if (frameFunctor_) { frameFunctor_(); } void EventLoop::doPendingFunctors() { std::vector functors; callingPendingFunctors_ = true; { std::unique_lock lock(mutex_); functors.swap(pendingFunctors_); } for (size_t i = 0; i 这里增加业务逻辑是增加执行任务的函数指针的,增加的任务保存在成员变量pendingFunctors_中,这个变量是一个函数指针数组(vector对象),执行的时候,调用每个函数就可以了。上面的代码先利用一个栈变量将成员变量pendingFunctors_里面的函数指针换过来,接下来对这个栈变量进行操作就可以了,这样减少了锁的粒度。因为成员变量pendingFunctors_在增加任务的时候,也会被用到,设计到多个线程操作,所以要加锁,增加任务的地方是: void EventLoop::queueInLoop(const Functor& cb) { { std::unique_lock lock(mutex_); pendingFunctors_.push_back(cb); } if (!isInLoopThread() || callingPendingFunctors_) { wakeup(); } } 而frameFunctor_就更简单了,就是通过设置一个函数指针就可以了。当然这里有个技巧性的东西,即增加任务的时候,为了能够立即执行,使用唤醒机制,通过往一个fd里面写入简单的几个字节,来唤醒epoll,使其立刻返回,因为此时没有其它的socke有事件,这样接下来就执行刚才添加的任务了。 我们看一下数据收取的逻辑: void TcpConnection::handleRead(Timestamp receiveTime) { loop_->assertInLoopThread(); int savedErrno = 0; ssize_t n = inputBuffer_.readFd(channel_->fd(), &savedErrno); if (n > 0) { messageCallback_(shared_from_this(), &inputBuffer_, receiveTime); } else if (n == 0) { handleClose(); } else { errno = savedErrno; LOG_SYSERR 将收到的数据放到接收缓冲区里面,将来我们来解包: void ClientSession::OnRead(const std::shared_ptr& conn, Buffer* pBuffer, Timestamp receivTime) { while (true) { //不够一个包头大小 if (pBuffer->readableBytes() readableBytes()=\" readableBytes() peek(), sizeof(msg)); if (pBuffer->readableBytes() retrieve(sizeof(msg)); std::string inbuf; inbuf.append(pBuffer->peek(), header.packagesize); pBuffer->retrieve(header.packagesize); if (!Process(conn, inbuf.c_str(), inbuf.length())) { LOG_WARN forceClose(); } }// end while-loop } 先判断接收缓冲区里面的数据是否够一个包头大小,如果够再判断够不够包头指定的包体大小,如果还是够的话,接着在Process函数里面处理该包。 再看看发送数据的逻辑: void TcpConnection::sendInLoop(const void* data, size_t len) { loop_->assertInLoopThread(); ssize_t nwrote = 0; size_t remaining = len; bool faultError = false; if (state_ == kDisconnected) { LOG_WARN isWriting() && outputBuffer_.readableBytes() == 0) { nwrote = sockets::write(channel_->fd(), data, len); if (nwrote >= 0) { remaining = len - nwrote; if (remaining == 0 && writeCompleteCallback_) { loop_->queueInLoop(std::bind(writeCompleteCallback_, shared_from_this())); } } else // nwrote 0) { size_t oldLen = outputBuffer_.readableBytes(); if (oldLen + remaining >= highWaterMark_ && oldLen queueInLoop(std::bind(highWaterMarkCallback_, shared_from_this(), oldLen + remaining)); } outputBuffer_.append(static_cast(data)+nwrote, remaining); if (!channel_->isWriting()) { channel_->enableWriting(); } } } 如果剩余的数据remaining大于则调用channel_->enableWriting();开始监听可写事件,可写事件处理如下: void TcpConnection::handleWrite() { loop_->assertInLoopThread(); if (channel_->isWriting()) { ssize_t n = sockets::write(channel_->fd(), outputBuffer_.peek(), outputBuffer_.readableBytes()); if (n > 0) { outputBuffer_.retrieve(n); if (outputBuffer_.readableBytes() == 0) { channel_->disableWriting(); if (writeCompleteCallback_) { loop_->queueInLoop(std::bind(writeCompleteCallback_, shared_from_this())); } if (state_ == kDisconnecting) { shutdownInLoop(); } } } else { LOG_SYSERR fd() 如果发送完数据以后调用channel_->disableWriting();移除监听可写事件。 很多读者可能一直想问,文中不是说解包数据并处理逻辑是业务代码而非网络通信的代码,你这里貌似都混在一起了,其实没有,这里实际的业务代码处理都是框架曾提供的回调函数里面处理的,具体怎么处理,由框架使用者——业务层自己定义。 总结起来,实际上就是一个线程函数里一个loop那么点事情,不信你再看我曾经工作上的一个交易系统服务器项目代码: void CEventDispatcher::Run() { m_bShouldRun = true; while(m_bShouldRun) { DispatchIOs(); SyncTime(); CheckTimer(); DispatchEvents(); } } void CEpollReactor::DispatchIOs() { DWORD dwSelectTimeOut = SR_DEFAULT_EPOLL_TIMEOUT; if (HandleOtherTask()) { dwSelectTimeOut = 0; } struct epoll_event ev; CEventHandlerIdMap::iterator itor = m_mapEventHandlerId.begin(); for(; itor!=m_mapEventHandlerId.end(); itor++) { CEventHandler *pEventHandler = (CEventHandler *)(*itor).first; if(pEventHandler == NULL){ continue; } ev.data.ptr = pEventHandler; ev.events = 0; int nReadID, nWriteID; pEventHandler->GetIds(&nReadID, &nWriteID); if (nReadID > 0) { ev.events |= EPOLLIN; } if (nWriteID > 0) { ev.events |= EPOLLOUT; } epoll_ctl(m_fdEpoll, EPOLL_CTL_MOD, (*itor).second, &ev); } struct epoll_event events[EPOLL_MAX_EVENTS]; int nfds = epoll_wait(m_fdEpoll, events, EPOLL_MAX_EVENTS, dwSelectTimeOut/1000); for (int i=0; iHandleInput(); } if ((evref.events|EPOLLOUT)!=0 && m_mapEventHandlerId.find(pEventHandler)!=m_mapEventHandlerId.end()) { pEventHandler->HandleOutput(); } } } void CEventDispatcher::DispatchEvents() { CEvent event; CSyncEvent *pSyncEvent; while(m_queueEvent.PeekEvent(event)) { int nRetval; if(event.pEventHandler != NULL) { nRetval = event.pEventHandler->HandleEvent(event.nEventID, event.dwParam, event.pParam); } else { nRetval = HandleEvent(event.nEventID, event.dwParam, event.pParam); } if(event.pAdd != NULL) //同步消息 { pSyncEvent=(CSyncEvent *)event.pAdd; pSyncEvent->nRetval = nRetval; pSyncEvent->sem.UnLock(); } } } 再看看蘑菇街开源的TeamTalk的源码(代码下载地址:https://github.com/baloonwj/TeamTalk): void CEventDispatch::StartDispatch(uint32_t wait_timeout) { fd_set read_set, write_set, excep_set; timeval timeout; timeout.tv_sec = 0; timeout.tv_usec = wait_timeout * 1000; // 10 millisecond if(running) return; running = true; while (running) { _CheckTimer(); _CheckLoop(); if (!m_read_set.fd_count && !m_write_set.fd_count && !m_excep_set.fd_count) { Sleep(MIN_TIMER_DURATION); continue; } m_lock.lock(); memcpy(&read_set, &m_read_set, sizeof(fd_set)); memcpy(&write_set, &m_write_set, sizeof(fd_set)); memcpy(&excep_set, &m_excep_set, sizeof(fd_set)); m_lock.unlock(); int nfds = select(0, &read_set, &write_set, &excep_set, &timeout); if (nfds == SOCKET_ERROR) { log(\"select failed, error code: %d\", GetLastError()); Sleep(MIN_TIMER_DURATION); continue; // select again } if (nfds == 0) { continue; } for (u_int i = 0; i OnRead(); pSocket->ReleaseRef(); } } for (u_int i = 0; i OnWrite(); pSocket->ReleaseRef(); } } for (u_int i = 0; i OnClose(); pSocket->ReleaseRef(); } } } } 再看filezilla,一款ftp工具的服务器端,它采用的是Windows的WSAAsyncSelect模型(代码下载地址:https://github.com/baloonwj/filezilla): //Processes event notifications sent by the sockets or the layers static LRESULT CALLBACK WindowProc(HWND hWnd, UINT message, WPARAM wParam, LPARAM lParam) { if (message>=WM_SOCKETEX_NOTIFY) { //Verify parameters ASSERT(hWnd); CAsyncSocketExHelperWindow *pWnd=(CAsyncSocketExHelperWindow *)GetWindowLongPtr(hWnd, GWLP_USERDATA); ASSERT(pWnd); if (!pWnd) return 0; if (message (WM_SOCKETEX_NOTIFY+pWnd->m_nWindowDataSize)) //Index is within socket storage { //Lookup socket and verify if it's valid CAsyncSocketEx *pSocket=pWnd->m_pAsyncSocketExWindowData[message - WM_SOCKETEX_NOTIFY].m_pSocket; SOCKET hSocket = wParam; if (!pSocket) return 0; if (hSocket == INVALID_SOCKET) return 0; if (pSocket->m_SocketData.hSocket != hSocket) return 0; int nEvent = lParam & 0xFFFF; int nErrorCode = lParam >> 16; //Dispatch notification if (!pSocket->m_pFirstLayer) { //Dispatch to CAsyncSocketEx instance switch (nEvent) { case FD_READ: #ifndef NOSOCKETSTATES if (pSocket->GetState() == connecting && !nErrorCode) { pSocket->m_nPendingEvents |= FD_READ; break; } else if (pSocket->GetState() == attached) pSocket->SetState(connected); if (pSocket->GetState() != connected) break; // Ignore further FD_READ events after FD_CLOSE has been received if (pSocket->m_SocketData.onCloseCalled) break; #endif //NOSOCKETSTATES #ifndef NOSOCKETSTATES if (nErrorCode) pSocket->SetState(aborted); #endif //NOSOCKETSTATES if (pSocket->m_lEvent & FD_READ) { pSocket->OnReceive(nErrorCode); } break; case FD_FORCEREAD: //Forceread does not check if there's data waiting #ifndef NOSOCKETSTATES if (pSocket->GetState() == connecting && !nErrorCode) { pSocket->m_nPendingEvents |= FD_FORCEREAD; break; } else if (pSocket->GetState() == attached) pSocket->SetState(connected); if (pSocket->GetState() != connected) break; #endif //NOSOCKETSTATES if (pSocket->m_lEvent & FD_READ) { #ifndef NOSOCKETSTATES if (nErrorCode) pSocket->SetState(aborted); #endif //NOSOCKETSTATES pSocket->OnReceive(nErrorCode); } break; case FD_WRITE: #ifndef NOSOCKETSTATES if (pSocket->GetState() == connecting && !nErrorCode) { pSocket->m_nPendingEvents |= FD_WRITE; break; } else if (pSocket->GetState() == attached && !nErrorCode) pSocket->SetState(connected); if (pSocket->GetState() != connected) break; #endif //NOSOCKETSTATES if (pSocket->m_lEvent & FD_WRITE) { #ifndef NOSOCKETSTATES if (nErrorCode) pSocket->SetState(aborted); #endif //NOSOCKETSTATES pSocket->OnSend(nErrorCode); } break; case FD_CONNECT: #ifndef NOSOCKETSTATES if (pSocket->GetState() == connecting) { if (nErrorCode && pSocket->m_SocketData.nextAddr) { if (pSocket->TryNextProtocol()) break; } pSocket->SetState(connected); } else if (pSocket->GetState() == attached && !nErrorCode) pSocket->SetState(connected); #endif //NOSOCKETSTATES if (pSocket->m_lEvent & FD_CONNECT) pSocket->OnConnect(nErrorCode); #ifndef NOSOCKETSTATES if (!nErrorCode) { if ((pSocket->m_nPendingEvents&FD_READ) && pSocket->GetState() == connected) pSocket->OnReceive(0); if ((pSocket->m_nPendingEvents&FD_FORCEREAD) && pSocket->GetState() == connected) pSocket->OnReceive(0); if ((pSocket->m_nPendingEvents&FD_WRITE) && pSocket->GetState() == connected) pSocket->OnSend(0); } pSocket->m_nPendingEvents = 0; #endif break; case FD_ACCEPT: #ifndef NOSOCKETSTATES if (pSocket->GetState() != listening && pSocket->GetState() != attached) break; #endif //NOSOCKETSTATES if (pSocket->m_lEvent & FD_ACCEPT) pSocket->OnAccept(nErrorCode); break; case FD_CLOSE: #ifndef NOSOCKETSTATES if (pSocket->GetState() != connected && pSocket->GetState() != attached) break; // If there are still bytes left to read, call OnReceive instead of // OnClose and trigger a new OnClose DWORD nBytes = 0; if (!nErrorCode && pSocket->IOCtl(FIONREAD, &nBytes)) { if (nBytes > 0) { // Just repeat message. pSocket->ResendCloseNotify(); pSocket->m_SocketData.onCloseCalled = true; pSocket->OnReceive(WSAESHUTDOWN); break; } } pSocket->SetState(nErrorCode ? aborted : closed); #endif //NOSOCKETSTATES pSocket->OnClose(nErrorCode); break; } } else //Dispatch notification to the lowest layer { if (nEvent == FD_READ) { // Ignore further FD_READ events after FD_CLOSE has been received if (pSocket->m_SocketData.onCloseCalled) return 0; DWORD nBytes; if (!pSocket->IOCtl(FIONREAD, &nBytes)) nErrorCode = WSAGetLastError(); if (pSocket->m_pLastLayer) pSocket->m_pLastLayer->CallEvent(nEvent, nErrorCode); } else if (nEvent == FD_CLOSE) { // If there are still bytes left to read, call OnReceive instead of // OnClose and trigger a new OnClose DWORD nBytes = 0; if (!nErrorCode && pSocket->IOCtl(FIONREAD, &nBytes)) { if (nBytes > 0) { // Just repeat message. pSocket->ResendCloseNotify(); if (pSocket->m_pLastLayer) pSocket->m_pLastLayer->CallEvent(FD_READ, 0); return 0; } } pSocket->m_SocketData.onCloseCalled = true; if (pSocket->m_pLastLayer) pSocket->m_pLastLayer->CallEvent(nEvent, nErrorCode); } else if (pSocket->m_pLastLayer) pSocket->m_pLastLayer->CallEvent(nEvent, nErrorCode); } } return 0; } else if (message == WM_USER) //Notification event sent by a layer { //Verify parameters, lookup socket and notification message //Verify parameters ASSERT(hWnd); CAsyncSocketExHelperWindow *pWnd=(CAsyncSocketExHelperWindow *)GetWindowLongPtr(hWnd, GWLP_USERDATA); ASSERT(pWnd); if (!pWnd) return 0; if (wParam >= static_cast(pWnd->m_nWindowDataSize)) //Index is within socket storage { return 0; } CAsyncSocketEx *pSocket = pWnd->m_pAsyncSocketExWindowData[wParam].m_pSocket; CAsyncSocketExLayer::t_LayerNotifyMsg *pMsg = (CAsyncSocketExLayer::t_LayerNotifyMsg *)lParam; if (!pMsg || !pSocket || pSocket->m_SocketData.hSocket != pMsg->hSocket) { delete pMsg; return 0; } int nEvent=pMsg->lEvent&0xFFFF; int nErrorCode=pMsg->lEvent>>16; //Dispatch to layer if (pMsg->pLayer) pMsg->pLayer->CallEvent(nEvent, nErrorCode); else { //Dispatch to CAsyncSocketEx instance switch (nEvent) { case FD_READ: #ifndef NOSOCKETSTATES if (pSocket->GetState() == connecting && !nErrorCode) { pSocket->m_nPendingEvents |= FD_READ; break; } else if (pSocket->GetState() == attached && !nErrorCode) pSocket->SetState(connected); if (pSocket->GetState() != connected) break; #endif //NOSOCKETSTATES if (pSocket->m_lEvent & FD_READ) { #ifndef NOSOCKETSTATES if (nErrorCode) pSocket->SetState(aborted); #endif //NOSOCKETSTATES pSocket->OnReceive(nErrorCode); } break; case FD_FORCEREAD: //Forceread does not check if there's data waiting #ifndef NOSOCKETSTATES if (pSocket->GetState() == connecting && !nErrorCode) { pSocket->m_nPendingEvents |= FD_FORCEREAD; break; } else if (pSocket->GetState() == attached && !nErrorCode) pSocket->SetState(connected); if (pSocket->GetState() != connected) break; #endif //NOSOCKETSTATES if (pSocket->m_lEvent & FD_READ) { #ifndef NOSOCKETSTATES if (nErrorCode) pSocket->SetState(aborted); #endif //NOSOCKETSTATES pSocket->OnReceive(nErrorCode); } break; case FD_WRITE: #ifndef NOSOCKETSTATES if (pSocket->GetState() == connecting && !nErrorCode) { pSocket->m_nPendingEvents |= FD_WRITE; break; } else if (pSocket->GetState() == attached && !nErrorCode) pSocket->SetState(connected); if (pSocket->GetState() != connected) break; #endif //NOSOCKETSTATES if (pSocket->m_lEvent & FD_WRITE) { #ifndef NOSOCKETSTATES if (nErrorCode) pSocket->SetState(aborted); #endif //NOSOCKETSTATES pSocket->OnSend(nErrorCode); } break; case FD_CONNECT: #ifndef NOSOCKETSTATES if (pSocket->GetState() == connecting) pSocket->SetState(connected); else if (pSocket->GetState() == attached && !nErrorCode) pSocket->SetState(connected); #endif //NOSOCKETSTATES if (pSocket->m_lEvent & FD_CONNECT) pSocket->OnConnect(nErrorCode); #ifndef NOSOCKETSTATES if (!nErrorCode) { if (((pSocket->m_nPendingEvents&FD_READ) && pSocket->GetState() == connected) && (pSocket->m_lEvent & FD_READ)) pSocket->OnReceive(0); if (((pSocket->m_nPendingEvents&FD_FORCEREAD) && pSocket->GetState() == connected) && (pSocket->m_lEvent & FD_READ)) pSocket->OnReceive(0); if (((pSocket->m_nPendingEvents&FD_WRITE) && pSocket->GetState() == connected) && (pSocket->m_lEvent & FD_WRITE)) pSocket->OnSend(0); } pSocket->m_nPendingEvents = 0; #endif //NOSOCKETSTATES break; case FD_ACCEPT: #ifndef NOSOCKETSTATES if ((pSocket->GetState() == listening || pSocket->GetState() == attached) && (pSocket->m_lEvent & FD_ACCEPT)) #endif //NOSOCKETSTATES { pSocket->OnAccept(nErrorCode); } break; case FD_CLOSE: #ifndef NOSOCKETSTATES if ((pSocket->GetState() == connected || pSocket->GetState() == attached) && (pSocket->m_lEvent & FD_CLOSE)) { pSocket->SetState(nErrorCode?aborted:closed); #else { #endif //NOSOCKETSTATES pSocket->OnClose(nErrorCode); } break; } } delete pMsg; return 0; } else if (message == WM_USER+1) { // WSAAsyncGetHostByName reply // Verify parameters ASSERT(hWnd); CAsyncSocketExHelperWindow *pWnd = (CAsyncSocketExHelperWindow *)GetWindowLongPtr(hWnd, GWLP_USERDATA); ASSERT(pWnd); if (!pWnd) return 0; CAsyncSocketEx *pSocket = NULL; for (int i = 0; i m_nWindowDataSize; ++i) { pSocket = pWnd->m_pAsyncSocketExWindowData[i].m_pSocket; if (pSocket && pSocket->m_hAsyncGetHostByNameHandle && pSocket->m_hAsyncGetHostByNameHandle == (HANDLE)wParam && pSocket->m_pAsyncGetHostByNameBuffer) break; } if (!pSocket || !pSocket->m_pAsyncGetHostByNameBuffer) return 0; int nErrorCode = lParam >> 16; if (nErrorCode) { pSocket->OnConnect(nErrorCode); return 0; } SOCKADDR_IN sockAddr{}; sockAddr.sin_family = AF_INET; sockAddr.sin_addr.s_addr = ((LPIN_ADDR)((LPHOSTENT)pSocket->m_pAsyncGetHostByNameBuffer)->h_addr)->s_addr; sockAddr.sin_port = htons(pSocket->m_nAsyncGetHostByNamePort); BOOL res = pSocket->Connect((SOCKADDR*)&sockAddr, sizeof(sockAddr)); delete [] pSocket->m_pAsyncGetHostByNameBuffer; pSocket->m_pAsyncGetHostByNameBuffer = 0; pSocket->m_hAsyncGetHostByNameHandle = 0; if (!res) if (GetLastError() != WSAEWOULDBLOCK) pSocket->OnConnect(GetLastError()); return 0; } else if (message == WM_USER + 2) { //Verify parameters, lookup socket and notification message //Verify parameters if (!hWnd) return 0; CAsyncSocketExHelperWindow *pWnd=(CAsyncSocketExHelperWindow *)GetWindowLongPtr(hWnd, GWLP_USERDATA); if (!pWnd) return 0; if (wParam >= static_cast(pWnd->m_nWindowDataSize)) //Index is within socket storage return 0; CAsyncSocketEx *pSocket = pWnd->m_pAsyncSocketExWindowData[wParam].m_pSocket; if (!pSocket) return 0; // Process pending callbacks std::list tmp; tmp.swap(pSocket->m_pendingCallbacks); pSocket->OnLayerCallback(tmp); for (auto & cb : tmp) { delete [] cb.str; } } else if (message == WM_TIMER) { if (wParam != 1) return 0; ASSERT(hWnd); CAsyncSocketExHelperWindow *pWnd=(CAsyncSocketExHelperWindow *)GetWindowLongPtr(hWnd, GWLP_USERDATA); ASSERT(pWnd && pWnd->m_pThreadData); if (!pWnd || !pWnd->m_pThreadData) return 0; if (pWnd->m_pThreadData->layerCloseNotify.empty()) { KillTimer(hWnd, 1); return 0; } CAsyncSocketEx* socket = pWnd->m_pThreadData->layerCloseNotify.front(); pWnd->m_pThreadData->layerCloseNotify.pop_front(); if (pWnd->m_pThreadData->layerCloseNotify.empty()) KillTimer(hWnd, 1); if (socket) PostMessage(hWnd, socket->m_SocketData.nSocketIndex + WM_SOCKETEX_NOTIFY, socket->m_SocketData.hSocket, FD_CLOSE); return 0; } return DefWindowProc(hWnd, message, wParam, lParam); } 上面截取的代码段,如果你对这些项目不是很熟悉的话,估计你也没有任何兴趣去细细看每一行代码逻辑。但是你一定要明白我所说的这个结构的逻辑,基本上目前主流的网络框架都是这套原理。比如filezilla的网络通信层同样也被用在大名鼎鼎的电驴(easyMule)中。 关于单个服务程序的框架,我已经介绍完了,如果你能完全理解我要表达的意思,我相信你也能构建出一套高性能服务程序来。 另外,服务器框架也可以在上面的设计思路的基础上增加很多有意思的细节,比如流量控制。举另外 一个我实际做过的项目中的例子吧: 一般实际项目中,当客户端连接数目比较多的时候,服务器在处理网络数据的时候,如果同时有多个socket上有数据要处理,由于cpu核数有限,根据上面先检测iO事件再处理IO事件可能会出现工作线程一直处理前几个socket的事件,直到前几个socket处理完毕后再处理后面几个socket的数据。这就相当于,你去饭店吃饭,大家都点了菜,但是有些桌子上一直在上菜,而有些桌子上一直没有菜。这样肯定不好,我们来看下如何避免这种现象: int CFtdEngine::HandlePackage(CFTDCPackage *pFTDCPackage, CFTDCSession *pSession) { //NET_IO_LOG0(\"CFtdEngine::HandlePackage\\n\"); FTDC_PACKAGE_DEBUG(pFTDCPackage); if (pFTDCPackage->GetTID() != FTD_TID_ReqUserLogin) { if (!IsSessionLogin(pSession->GetSessionID())) { SendErrorRsp(pFTDCPackage, pSession, 1, \"客户未登录\"); return 0; } } CalcFlux(pSession, pFTDCPackage->Length()); //统计流量 REPORT_EVENT(LOG_DEBUG, \"Front/Fgateway\", \"登录请求%0x\", pFTDCPackage->GetTID()); int nRet = 0; switch(pFTDCPackage->GetTID()) { case FTD_TID_ReqUserLogin: ///huwp:20070608:检查过高版本的API将被禁止登录 if (pFTDCPackage->GetVersion()>FTD_VERSION) { SendErrorRsp(pFTDCPackage, pSession, 1, \"Too High FTD Version\"); return 0; } nRet = OnReqUserLogin(pFTDCPackage, (CFTDCSession *)pSession); FTDRequestIndex.incValue(); break; case FTD_TID_ReqCheckUserLogin: nRet = OnReqCheckUserLogin(pFTDCPackage, (CFTDCSession *)pSession); FTDRequestIndex.incValue(); break; case FTD_TID_ReqSubscribeTopic: nRet = OnReqSubscribeTopic(pFTDCPackage, (CFTDCSession *)pSession); FTDRequestIndex.incValue(); break; } return 0; } 当有某个socket上有数据可读时,接着接收该socket上的数据,对接收到的数据进行解包,然后调用CalcFlux(pSession, pFTDCPackage->Length())进行流量统计: void CFrontEngine::CalcFlux(CSession *pSession, const int nFlux) { TFrontSessionInfo *pSessionInfo = m_mapSessionInfo.Find(pSession->GetSessionID()); if (pSessionInfo != NULL) { //流量控制改为计数 pSessionInfo->nCommFlux ++; ///若流量超过规定,则挂起该会话的读操作 if (pSessionInfo->nCommFlux >= pSessionInfo->nMaxCommFlux) { pSession->SuspendRead(true); } } } 该函数会先让某个连接会话(Session)处理的包数量递增,接着判断是否超过最大包数量,则设置读挂起标志: void CSession::SuspendRead(bool bSuspend) { m_bSuspendRead = bSuspend; } 这样下次将会从检测的socket列表中排除该socket: void CEpollReactor::RegisterIO(CEventHandler *pEventHandler) { int nReadID, nWriteID; pEventHandler->GetIds(&nReadID, &nWriteID); if (nWriteID != 0 && nReadID ==0) { nReadID = nWriteID; } if (nReadID != 0) { m_mapEventHandlerId[pEventHandler] = nReadID; struct epoll_event ev; ev.data.ptr = pEventHandler; if(epoll_ctl(m_fdEpoll, EPOLL_CTL_ADD, nReadID, &ev) != 0) { perror(\"epoll_ctl EPOLL_CTL_ADD\"); } } } void CSession::GetIds(int *pReadId, int *pWriteId) { m_pChannelProtocol->GetIds(pReadId,pWriteId); if (m_bSuspendRead) { *pReadId = 0; } } 也就是说不再检测该socket上是否有数据可读。然后在定时器里1秒后重置该标志,这样这个socket上有数据的话又可以重新检测到了: const int SESSION_CHECK_TIMER_ID = 9; const int SESSION_CHECK_INTERVAL = 1000; SetTimer(SESSION_CHECK_TIMER_ID, SESSION_CHECK_INTERVAL); void CFrontEngine::OnTimer(int nIDEvent) { if (nIDEvent == SESSION_CHECK_TIMER_ID) { CSessionMap::iterator itor = m_mapSession.Begin(); while (!itor.IsEnd()) { TFrontSessionInfo *pFind = m_mapSessionInfo.Find((*itor)->GetSessionID()); if (pFind != NULL) { CheckSession(*itor, pFind); } itor++; } } } void CFrontEngine::CheckSession(CSession *pSession, TFrontSessionInfo *pSessionInfo) { ///重新开始计算流量 pSessionInfo->nCommFlux -= pSessionInfo->nMaxCommFlux; if (pSessionInfo->nCommFlux nCommFlux = 0; } ///若流量超过规定,则挂起该会话的读操作 pSession->SuspendRead(pSessionInfo->nCommFlux >= pSessionInfo->nMaxCommFlux); } 这就相当与饭店里面先给某一桌客人上一些菜,让他们先吃着,等上了一些菜之后不会再给这桌继续上菜了,而是给其它空桌上菜,大家都吃上后,继续回来给原先的桌子继续上菜。实际上我们的饭店都是这么做的。上面的例子是单服务流量控制的实现的一个非常好的思路,它保证了每个客户端都能均衡地得到服务,而不是一些客户端等很久才有响应。当然,这样的技术不能适用于有顺序要求的业务,例如销售系统,这些系统一般是先下单先得到的。 另外现在的服务器为了加快IO操作,大量使用缓存技术,缓存实际上是以空间换取时间的策略。对于一些反复使用的,但是不经常改变的信息,如果从原始地点加载这些信息就比较耗时的数据(比如从磁盘中、从数据库中),我们就可以使用缓存。所以时下像redis、leveldb、fastdb等各种内存数据库大行其道。如果你要从事服务器开发,你至少需要掌握它们中的几种。    鉴于笔者能力和经验有限,文中难免有错漏之处,欢迎提意见。 如需下载本站全部技术文章,可以在【高性能服务器开发】公众号回复关键字“文章下载”即可。最近更新时间: 2020-09-21 00:18:30 "},"articles/服务器开发案例实战/":{"url":"articles/服务器开发案例实战/","title":"服务器开发案例实战","keywords":"","body":"服务器开发案例实战 从零实现一个http服务器 从零实现一款12306刷票软件 从零实现一个邮件收发客户端 从零开发一个WebSocket服务器 从零学习开源项目系列(一) 从一款多人联机实时对战游戏开始 从零学习开源项目系列(二) 最后一战概况 从零学习开源项目系列(三) CSBattleMgr服务源码研究 从零学习开源项目系列(四)LogServer源码探究 如需下载本站全部技术文章,可以在【高性能服务器开发】公众号回复关键字“文章下载”即可。最近更新时间: 2020-10-08 15:53:37 "},"articles/服务器开发案例实战/从零实现一个http服务器.html":{"url":"articles/服务器开发案例实战/从零实现一个http服务器.html","title":"从零实现一个http服务器","keywords":"","body":"从零实现一个http服务器 我始终觉得,天生的出身很重要,但后天的努力更加重要,所以如今的很多“科班”往往不如后天努力的“非科班”。所以,我们需要重新给“专业”和“专家”下一个定义:所谓专业,就是别人不搞你搞,这就是你的“专业”;你和别人同时搞,你比别人搞的好,就是“专家”。 说到http协议和http请求,很多人都知道,但是他们真的“知道”吗?我面试过很多求职者,一说到http协议,他们能滔滔不绝,然后我问他http协议的具体格式是啥样子的?很多人不清楚,不清楚就不清楚吧,他甚至能将http协议的头扯到html文档头部。当我问http GET和POST请求的时候,GET请求是什么形式一般人都可以答出来,但是POST请求的数据放在哪里,服务器如何识别和解析这些POST数据,很多人又说不清道不明了。当说到http服务器时,很多人离开了apache、Nginx这样现成的http server之外,自己实现一个http服务器无从下手,如果实际应用场景有需要使用到一些简单http请求时,使用apache、Nginx这样重量级的http服务器程序实在劳师动众,你可以尝试自己实现一个简单的。 上面提到的问题,如果您不能清晰地回答出来,可以阅读一下这篇文章,这篇文章在不仅介绍http的格式,同时带领大家从零实现一个简单的http服务器程序。 一、项目背景 最近很多朋友希望我的flamingo服务器支持http协议,我自己也想做一个微信小程序,小程序通过http协议连接通过我的flamingo服务器进行聊天。flamingo是一个开源的即时通讯软件,目前除了服务器端,还有pc端、android端,后面会支持更多的终端。关于flamingo的介绍您可以参考这里: https://github.com/baloonwj/flamingo,更新日志:https://github.com/baloonwj/flamingo/issues/1。下面是flamingo的部分截图: 二、http协议介绍 1. http协议是应用层协议,一般建立在tcp协议的基础之上(当然你的实现非要基于udp也是可以的),也就是说http协议的数据收发是通过tcp协议的。 2. http协议也分为head和body两部分,但是我们一般说的html中的和标记不是http协议的头和身体,它们都是http协议的body部分。 那么http协议的头到底长啥样子呢?我们来介绍一下http协议吧。 http协议的格式如下: GET或POST 请求的url路径(一般是去掉域名的路径) HTTP协议版本号\\r\\n 字段1名: 字段1值\\r\\n 字段2名: 字段2值\\r\\n … 字段n名 : 字段n值\\r\\n \\r\\n http协议包体内容 也就是说http协议由两部分组成:包头和包体,包头与包体之间使用一个\\r\\n分割,由于http协议包头的每一行都是以\\r\\n结束,所以http协议包头一般以\\r\\n\\r\\n结束。 举个例子,比如我们在浏览器中请求http://www.hootina.org/index_2013.php这个网址,这是一个典型的GET方法,浏览器组装的http数据包格式如下: GET /index_2013.php HTTP/1.1\\r\\n Host: www.hootina.org\\r\\n Connection: keep-alive\\r\\n Upgrade-Insecure-Requests: 1\\r\\n User-Agent: Mozilla/5.0 (Windows NT 6.1; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/65.0.3325.146 Safari/537.36\\r\\n Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,image/apng,*/*;q=0.8\\r\\n Accept-Encoding: gzip, deflate\\r\\n Accept-Language: zh-CN,zh;q=0.9,en;q=0.8\\r\\n \\r\\n 上面这个请求只有包头没有包体,http协议的包体不是必须的,也就是说GET请求一般没有包体。 如果GET请求带参数,那么一般是附加在请求的url后面,参数与参数之间使用&分割,例如请求http://www.hootina.org/index_2013.php?param1=value1&param2=value2&param3=value3,我们看下这个请求组装的的http协议包格式: GET /index_2013.php?param1=value1&param2=value2&param3=value3 HTTP/1.1\\r\\n Host: www.hootina.org\\r\\n Connection: keep-alive\\r\\n Upgrade-Insecure-Requests: 1\\r\\n User-Agent: Mozilla/5.0 (Windows NT 6.1; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/65.0.3325.146 Safari/537.36\\r\\n Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,image/apng,*/*;q=0.8\\r\\n Accept-Encoding: gzip, deflate\\r\\n Accept-Language: zh-CN,zh;q=0.9,en;q=0.8\\r\\n \\r\\n 对比一下,你现在知道http协议的GET参数放在协议包的什么位置了吧。 那么POST的数据放在什么位置呢?我们再12306网站https://kyfw.12306.cn/otn/login/init中登陆输入用户名和密码: 然后发现浏览器以POST方式组装了http协议包发送了我们的用户名、密码和其他一些信息,组装的包格式如下: POST /passport/web/login HTTP/1.1\\r\\n Host: kyfw.12306.cn\\r\\n Connection: keep-alive\\r\\n Content-Length: 55\\r\\n Accept: application/json, text/javascript, */*; q=0.01\\r\\n Origin: https://kyfw.12306.cn\\r\\n X-Requested-With: XMLHttpRequest\\r\\n User-Agent: Mozilla/5.0 (Windows NT 6.1; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/65.0.3325.146 Safari/537.36\\r\\n Content-Type: application/x-www-form-urlencoded; charset=UTF-8\\r\\n Referer: https://kyfw.12306.cn/otn/login/init\\r\\n Accept-Encoding: gzip, deflate, br\\r\\n Accept-Language: zh-CN,zh;q=0.9,en;q=0.8\\r\\n Cookie: _passport_session=0b2cc5b86eb74bcc976bfa9dfef3e8a20712; _passport_ct=18d19b0930954d76b8057c732ce4cdcat8137; route=6f50b51faa11b987e576cdb301e545c4; RAIL_EXPIRATION=1526718782244; RAIL_DEVICEID=QuRAhOyIWv9lwWEhkq03x5Yl_livKZxx7gW6_-52oTZQda1c4zmVWxdw5Zk79xSDFHe9LJ57F8luYOFp_yahxDXQAOmEV8U1VgXavacuM2UPCFy3knfn42yTsJM3EYOy-hwpsP-jTb2OXevJj5acf40XsvsPDcM7; BIGipServerpool_passport=300745226.50215.0000; BIGipServerotn=1257243146.38945.0000; BIGipServerpassport=1005060362.50215.0000\\r\\n \\r\\n username=balloonwj%40qq.com&password=iloveyou&appid=otn 其中username=balloonwj%40qq.com&password=iloveyou&appid=otn就是我们的POST数据,但是大家需要注意的以下几种,不要搞错: 1. 我的用户名是balloonwj@qq.com,到POST里面变成balloonwj%40qq.com,其中%40是@符号的16进制转码形式。这个码表可以参考这里:**http://www.w3school.com.cn/tags/html_ref_urlencode.html** 2.这里有三个变量,分别是username、password和appid,他们之间使用&符号分割,但是请注意的是,这不意味着传递多个POST变量时必须使用&符号分割,只不过这里是浏览器html表单(输入用户名和密码的文本框是html表单的一种)分割多个变量采用的默认方式而已。你可以根据你的需求,来自由定制,只要让服务器知道你的解析方式即可。比如可以这么分割: 方法一 1username=balloonwj%40qq.com|password=iloveyou|appid=otn 方法二 username:balloonwj%40qq.com\\r\\n password:iloveyou\\r\\n appid:otn\\r\\n 方法三 1username,password,appid=balloonwj%40qq.com,iloveyou,otn 不管怎么分割,只要你能自己按一定的规则解析出来就可以了。 不知道你注意到没有,上面的POST数据放在http包体中,服务器如何解析呢?可能你没明白我的意思,看下图: 如上图所示,由于http协议是基于tcp协议的,tcp协议是流式协议,包头部分可以通过多出的\\r\\n来分界,包体部分如何分界呢?这是协议本身要解决的问题。目前一般有两种方式,第一种方式就是在包头中有个content-Length字段,这个字段的值的大小标识了POST数据的长度,上图中55就是数据username=balloonwj%40qq.com&password=iloveyou&appid=otn的长度,服务器收到一个数据包后,先从包头解析出这个字段的值,再根据这个值去读取相应长度的作为http协议的包体数据。还有一个格式叫做http chunked技术(分块),大致意思是将大包分成小包,具体的详情有兴趣的读者可以自行搜索学习。 三、http客户端实现 如果您能掌握以上说的http协议,你就可以自己通过代码组装http协议发送http请求了(也是各种开源http库的做法)。我们先简单地介绍一下如何模拟发送http。举个例子,我们要请求http://www.hootina.org/index_2013.php,那么我们可以先通过域名得到ip地址,即通过socket API gethostbyname()得到www.hootina.org的ip地址,由于http服务器默认的端口号是80,有了域名和ip地址之后,我们使用socket API connect()去连接服务器,然后根据上面介绍的格式组装成http协议包,利用socket API send()函数发出去,如果服务器有应答,我们可以使用socket API recv()去接受数据,接下来就是解析数据(先解析包头和包体)。 四、http服务器实现 我们这里简化一些问题,假设客户端发送的请求都是GET请求,当客户端发来http请求之后,我们拿到http包后就做相应的处理。我们以为我们的flamingo服务器实现一个支持http格式的注册请求为例。假设用户在浏览器里面输入以下网址,就可以实现一个注册功能: http://120.55.94.78:12345/register.do?p={\"username\": \"13917043329\", \"nickname\": \"balloon\", \"password\": \"123\"} 这里我们的http协议使用的是12345端口号而不是默认的80端口。如何侦听12345端口,这个是非常基础的知识了,这里就不介绍了。当我们收到数据以后: void HttpSession::OnRead(const std::shared_ptr& conn, Buffer* pBuffer, Timestamp receivTime) { //LOG_INFO peerAddress().toIpPort(); string inbuf; //先把所有数据都取出来 inbuf.append(pBuffer->peek(), pBuffer->readableBytes()); //因为一个http包头的数据至少\\r\\n\\r\\n,所以大于4个字符 //小于等于4个字符,说明数据未收完,退出,等待网络底层接着收取 if (inbuf.length() lines; StringUtil::Split(inbuf, lines, \"\\r\\n\"); if (lines.size() forceClose(); return; } std::vector chunk; StringUtil::Split(lines[0], chunk, \" \"); //chunk中至少有三个字符串:GET+url+HTTP版本号 if (chunk.size() forceClose(); return; } LOG_INFO peerAddress().toIpPort(); //inbuf = /register.do?p={%22username%22:%20%2213917043329%22,%20%22nickname%22:%20%22balloon%22,%20%22password%22:%20%22123%22} std::vector part; //通过?分割成前后两端,前面是url,后面是参数 StringUtil::Split(chunk[1], part, \"?\"); //chunk中至少有三个字符串:GET+url+HTTP版本号 if (part.size() forceClose(); return; } string url = part[0]; string param = part[1].substr(2); if (!Process(conn, url, param)) { LOG_ERROR peerAddress().toIpPort() retrieveAllAsString(); } //短连接,处理完关闭连接 conn->forceClose(); } 代码注释都写的很清楚,我们先利用\\r\\n分割得到每一行,其中第一行的数据是: GET /register.do?p={%22username%22:%20%2213917043329%22,%20%22nickname%22:%20%22balloon%22,%20%22password%22:%20%22123%22} HTTP/1.1 其中%22是双引号的url转码形式,%20是空格的url转码形式,然后我们根据空格分成三段,其中第二段就是我们的网址和参数: /register.do?p={%22username%22:%20%2213917043329%22,%20%22nickname%22:%20%22balloon%22,%20%22password%22:%20%22123%22} 然后我们根据网址与参数之间的问号将这个分成两段:第一段是网址,第二段是参数: bool HttpSession::Process(const std::shared_ptr& conn, const std::string& url, const std::string& param) { if (url.empty()) return false; if (url == \"/register.do\") { OnRegisterResponse(param, conn); } else if (url == \"/login.do\") { OnLoginResponse(param, conn); } else if (url == \"/getfriendlist.do\") { } else if (url == \"/getgroupmembers.do\") { } else return false; return true; } 然后我们根据url匹配网址,如果是注册请求,会走注册处理逻辑: void HttpSession::OnRegisterResponse(const std::string& data, const std::shared_ptr& conn) { string retData; string decodeData; URLEncodeUtil::Decode(data, decodeData); BussinessLogic::RegisterUser(decodeData, conn, false, retData); if (!retData.empty()) { std::string response; URLEncodeUtil::Encode(retData, response); MakeupResponse(retData, response); conn->send(response); LOG_INFO peerAddress().toIpPort();; } } 注册结果放在retData中,为了发给客户端,我们将结果中的特殊字符如双引号转码,如返回结果是: {\"code\":0, \"msg\":\"ok\"} 会被转码成: {%22code%22:0,%20%22msg%22:%22ok%22} 然后,将数据组装成http协议发给客户端,给客户端的应答协议与http请求协议有一点点差别,就是将请求的url路径换成所谓的http响应码,如200表示应答正常返回、404页面不存在。应答协议格式如下: GET或POST 响应码 HTTP协议版本号\\r\\n 字段1名: 字段1值\\r\\n 字段2名: 字段2值\\r\\n … 字段n名 : 字段n值\\r\\n \\r\\n http协议包体内容 举个例子如: HTTP/1.1 200 OK\\r\\n Content-Type: text/html\\r\\n Content-Length:42\\r\\n \\r\\n {%22code%22:%200,%20%22msg%22:%20%22ok%22} 注意,包头中的Content-Length长度必须正好是包体{%22code%22:%200,%20%22msg%22:%20%22ok%22}的长度,这里是42。这也符合我们浏览器的返回结果: 当然,需要注意的是,我们一般说http连接一般是短连接,这里我们也实现了这个功能(看上面的代码:conn->forceClose();),不管一个http请求是否成功,服务器处理后立马就关闭连接。 当然,这里还有一些没处理好的地方,如果你仔细观察上面的代码就会发现这个问题,就是不满足一个http包头时的处理,如果某个客户端(不是使用浏览器)通过程序模拟了一个连接请求,但是迟迟不发含有\\r\\n\\r\\n的数据,这路连接将会一直占用。我们可以判断收到的数据长度,防止别有用心的客户端给我们的服务器乱发数据。我们假定,我们能处理的最大url长度是2048,如果用户发送的数据累积不含\\r\\n\\r\\n,且超过2048个,我们认为连接非法,将连接断开。代码修改成如下形式: void HttpSession::OnRead(const std::shared_ptr& conn, Buffer* pBuffer, Timestamp receivTime) { //LOG_INFO peerAddress().toIpPort(); string inbuf; //先把所有数据都取出来 inbuf.append(pBuffer->peek(), pBuffer->readableBytes()); //因为一个http包头的数据至少\\r\\n\\r\\n,所以大于4个字符 //小于等于4个字符,说明数据未收完,退出,等待网络底层接着收取 if (inbuf.length() = MAX_URL_LENGTH) { conn->forceClose(); return; } //以\\r\\n分割每一行 std::vector lines; StringUtil::Split(inbuf, lines, \"\\r\\n\"); if (lines.size() forceClose(); return; } std::vector chunk; StringUtil::Split(lines[0], chunk, \" \"); //chunk中至少有三个字符串:GET+url+HTTP版本号 if (chunk.size() forceClose(); return; } LOG_INFO peerAddress().toIpPort(); //inbuf = /register.do?p={%22username%22:%20%2213917043329%22,%20%22nickname%22:%20%22balloon%22,%20%22password%22:%20%22123%22} std::vector part; //通过?分割成前后两端,前面是url,后面是参数 StringUtil::Split(chunk[1], part, \"?\"); //chunk中至少有三个字符串:GET+url+HTTP版本号 if (part.size() forceClose(); return; } string url = part[0]; string param = part[1].substr(2); if (!Process(conn, url, param)) { LOG_ERROR peerAddress().toIpPort() retrieveAllAsString(); } //短连接,处理完关闭连接 conn->forceClose(); } 但这只能解决发送非法数据的情况,如果一个客户端连上来不给我们发任何数据,这段逻辑就无能为力了。如果不断有客户端这么做,会浪费我们大量的连接资源,所以我们还需要一个定时器去定时检测哪些http连接超过一定时间内没给我们发数据,找到后将连接断开。这又涉及到服务器定时器如何设计了,关于这部分请参考我写的其他文章。 限于作者经验水平有限,文中难免有错乱之处,欢迎拍砖。另外,关于上面的代码,可以去github上下载,地址是: https://github.com/baloonwj/flamingo 如需下载本站全部技术文章,可以在【高性能服务器开发】公众号回复关键字“文章下载”即可。最近更新时间: 2020-10-02 22:15:16 "},"articles/服务器开发案例实战/从零实现一款12306刷票软件.html":{"url":"articles/服务器开发案例实战/从零实现一款12306刷票软件.html","title":"从零实现一款12306刷票软件","keywords":"","body":"从零实现一款12306刷票软件 写在前面的话 每年逢年过节,一票难求读者肯定不陌生。这篇文章,我们带领读者从零实现一款12306刷票软件,其核心原理是通过发送http请求模拟登录12306网站的购票的过程,最后买到票。 关于http请求的格式和如何组装http数据包给服务器发送请求,我们在上一篇文章《从零实现一个http服务器》中已经详细介绍过了,如果还不明白的朋友可以去那篇文章看下。 郑重申明一下:这里介绍的技术仅供用于学习,不可用于恶意攻击12306服务器,请勿滥用本文介绍的技术。对12306服务器造成的任何损失,后果自负。 当然,由于12306服务器用户量巨大,为了防止黄牛和其他一些非法攻击者,12306的很多url和在购票过程中各个步骤的协议细节经常发生变化。所以,本文中介绍的一些具体的url,可能在你看到本文时已经失效。但是这并没有关系,只要你掌握了本文中介绍的分析方法,您就可以灵活地修改您的代码,以适应最新的12306服务器的要求。 举个例子,如12306的查票接口目前的url是: https://kyfw.12306.cn/otn/leftTicket/query 可能过几天就变成了 https://kyfw.12306.cn/otn/leftTicket/queryX 再过几天又可能变成 https://kyfw.12306.cn/otn/leftTicket/queryY 然后一个星期后又可能变成 https://kyfw.12306.cn/otn/leftTicket/queryZ 这些笔者都见过。所以,重在原理的学习,掌握了原理,不管12306的相关url变成什么样,都可以以不变应万变。哎,12306在与黄牛的斗争中越走越远啊T_T 本文将使用以下工具来分析12306购票的过程,然后使用C++语言,模拟相关的过程,最终购票。 Chrome浏览器(其他的浏览器也可以,都有类似的界面,如Chrome,装了httpwatch的IE浏览器等) 一个可以登录12306网址并且可以购票的12306账号 Visual Studio(版本随意,我这里用的是VS 2013) 一、查票与站点信息接口 之所以先分析这个接口,是因为查票不需要用户登录的,相对来说最简单。我们在Chrome浏览器中打开12306余票查询页面,网址是:https://kyfw.12306.cn/otn/leftTicket/init,如下图所示: 然后在页面中右键菜单中选择【检查】菜单,打开后,选择【网络】选项卡。如下图所示: 打开后页面变成二分窗口了,左侧是正常的网页页面,右侧是浏览器自带的控制台,当我们在左侧页面中进行操作后,右侧会显示我们浏览器发送的各种http请求和应答。我们这里随便查一个票吧,如查2018年5月20日从上海到北京的票,点击12306网页中【查询】按钮后,我们发现右侧是这样的: 通过图中列表的type值是xhr,我们可以得出这是一个ajax请求(ajax是一种浏览器原生支持的异步技术,具体细节请读者自行搜索)。我们选择这个请求,你能看到这个请求的细节——请求和响应结果: 在reponse中,我们可以看到我们的这个http的去除http头的响应结果(包体,可能是解压缩或者解码后的): 这是一个json格式,我们找个json格式化工具,把这个json格式化后贴出来给大家看一下,其实您后面会发现12306的http请求结果中与购票相关的数据基本上都是json格式。这里的json如下: { \"validateMessagesShowId\": \"_validatorMessage\", \"status\": true, \"httpstatus\": 200, \"data\": { \"result\": [\"null|23:00-06:00系统维护时间|5l0000G10270|G102|AOH|VNP|AOH|VNP|06:26|12:29|06:03|IS_TIME_NOT_BUY|RLVVIt093U2EZuy2NE+VQyRloXyqTzFp6YyNk6J52QcHEA01|20180520|3|HZ|01|11|1|0|||||||||||1|有|13||O090M0|O9M|0\",(内容太长,这里省略) \"], \"flag\": \"1\", \"map\": { \"AOH\": \"上海虹桥\", \"BJP\": \"北京\", \"VNP\": \"北京南\", \"SHH\": \"上海\" } }, \"messages\": [], \"validateMessages\": {} } 其中含有的余票信息在result节点中,这是一个数组。每个节点以|分割,我们可以格式化后显示在自己的界面上: 我这里做的界面比较简陋,读者如果有兴趣可以做更精美的界面。我们列下这个请求发送的http数据包和应答包: 请求包: GET /otn/leftTicket/query?leftTicketDTO.train_date=2018-05-20&leftTicketDTO.from_station=SHH&leftTicketDTO.to_station=BJP&purpose_codes=ADULT HTTP/1.1 Host: kyfw.12306.cn Connection: keep-alive Cache-Control: no-cache Accept: */* X-Requested-With: XMLHttpRequest If-Modified-Since: 0 User-Agent: Mozilla/5.0 (Windows NT 6.1; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/66.0.3359.181 Safari/537.36 Referer: https://kyfw.12306.cn/otn/leftTicket/init Accept-Encoding: gzip, deflate, br Accept-Language: zh-CN,zh;q=0.9,en;q=0.8 Cookie: RAIL_EXPIRATION=1526978933395; RAIL_DEVICEID=WKxIYg-q1zjIPVu7VjulZ9PqEGvW2gUB9LvoM1Vx8fa7l3SUwnO_BVSatbTq506c6VYNOaxAiRaUcGFTMjCz9cPayEIc9vJ0pHaXdSqDlujJP8YrIoXbpAAs60l99z8bEtnHgAJzxLzKiv2nka5nmLY_BMNur8b8; _jc_save_fromStation=%u4E0A%u6D77%2CSHH; _jc_save_toStation=%u5317%u4EAC%2CBJP; _jc_save_fromDate=2018-05-20; _jc_save_toDate=2018-05-19; _jc_save_wfdc_flag=dc 应答包: HTTP/1.1 200 OK Date: Sat, 19 May 2018 15:23:58 GMT Content-Type: application/json;charset=UTF-8 Transfer-Encoding: chunked ct: C1_217_85_8 Content-Encoding: gzip Age: 1 X-Via: 1.1 houdianxin183:6 (Cdn Cache Server V2.0) Connection: keep-alive X-Dscp-Value: 0 X-Cdn-Src-Port: 33963 Cache-Control: no-cache, no-store 通过上一篇文章《从零实现一个http服务器》我们知道这是一个http GET请求,其中在url后面是请求附带的参数: leftTicketDTO.train_date: 2018-05-20 leftTicketDTO.from_station: SHH leftTicketDTO.to_station: BJP purpose_codes: ADULT 这四个参数分别是购票日期、出发站、到达站和票类型(这里是成人票(即普通票)),正好对应我们界面上的查询信息: 但是,读者可能会问,这里的出发站和到达站分别是SHH和BJP,这些站点代码,我如何获得呢?因为只有知道这些站点编码我才能自己购买指定出发站和到达站的火车票啊。如果您是一位细心的人,您肯定会想到,我们查票的时候再进入查票页面,这些站点信息就已经有了,那么可能是在这个查票页面加载时,从服务器请求的站点信息,所以我们刷新下查票页面,发现果然是这样: 进入查票页面之前,浏览器从 https://kyfw.12306.cn/otn/resources/js/framework/station_name.js?station_version=1.9053 下载一个叫 station.name.js 文件,这是一个javascript脚本,里面只有一行代码,就是定义了一个station_names的js变量,之所以url地址后面加一个station_version=1.9053,你可以理解成版本号,但是主要是通过一个随机值1.9053,让浏览器不要使用缓存中的station_name.js,而是每次都从服务器重新加载下这个文件,这样的话如果站点信息有更新,也可以避免因为缓存问题,导致本地的缓存与服务器上的站点信息不一致。由于站点信息比较多,我们截个图把: 看上图,我们可以看出来,每个站点信息都是通过@符号分割,然后通过|分割每一个站点的各种信息。这样的话,根据上文的格式假如我们要查询2018年5月30日从长春到南京的火车普通票,就可以通过网址: https://kyfw.12306.cn/otn/leftTicket/query?leftTicketDTO.train_date=2018-05-30&leftTicketDTO.from_station=CCT&leftTicketDTO.to_station=NJH&purpose_codes=ADULT 当然,这里需要说明一下的就是,由于全国的火车站点信息文件比较大,我们程序解析起来时间较长,加上火车站编码信息并不是经常变动,所以,我们我们没必要每次都下载这个station_name.js,所以我在写程序模拟这个请求时,一般先看本地有没有这个文件,如果有就使用本地的,没有才发http请求向12306服务器请求。这里我贴下我请求站点信息的程序代码(C++代码): /** * 获取全国车站信息 * @param si 返回的车站信息 * @param bForceDownload 强制从网络上下载,即不使用本地副本 */ bool GetStationInfo(vector& si, bool bForceDownload = false); #define URL_STATION_NAMES \"https://kyfw.12306.cn/otn/resources/js/framework/station_name.js?station_version=1.9053\" bool Client12306::GetStationInfo(vector& si, bool bForceDownload/* = false*/) { FILE* pfile; pfile = fopen(\"station_name.js\", \"rt+\"); //文件不存在,则必须下载 if (pfile == NULL) { bForceDownload = true; } string strResponse; if (bForceDownload) { if (pfile != NULL) fclose(pfile); pfile = fopen(\"station_name.js\", \"wt+\"); if (pfile == NULL) { LogError(\"Unable to create station_name.js\"); return false; } CURLcode res; CURL* curl = curl_easy_init(); if (NULL == curl) { fclose(pfile); return false; } //URL_STATION_NAMES curl_easy_setopt(curl, CURLOPT_URL, URL_STATION_NAMES); //响应结果中保留头部信息 //curl_easy_setopt(curl, CURLOPT_HEADER, 1); curl_easy_setopt(curl, CURLOPT_COOKIEFILE, \"\"); curl_easy_setopt(curl, CURLOPT_READFUNCTION, NULL); curl_easy_setopt(curl, CURLOPT_WRITEFUNCTION, OnWriteData); curl_easy_setopt(curl, CURLOPT_WRITEDATA, (void *)&strResponse); curl_easy_setopt(curl, CURLOPT_NOSIGNAL, 1); //设定为不验证证书和HOST curl_easy_setopt(curl, CURLOPT_SSL_VERIFYPEER, false); curl_easy_setopt(curl, CURLOPT_SSL_VERIFYHOST, false); curl_easy_setopt(curl, CURLOPT_CONNECTTIMEOUT, 10); curl_easy_setopt(curl, CURLOPT_TIMEOUT, 10); res = curl_easy_perform(curl); bool bError = false; if (res == CURLE_OK) { int code; res = curl_easy_getinfo(curl, CURLINFO_RESPONSE_CODE, &code); if (code != 200) { bError = true; LogError(\"http response code is not 200, code=%d\", code); } } else { LogError(\"http request error, error code = %d\", res); bError = true; } curl_easy_cleanup(curl); if (bError) { fclose(pfile); return !bError; } if (fwrite(strResponse.data(), strResponse.length(), 1, pfile) != 1) { LogError(\"Write data to station_name.js error\"); return false; } fclose(pfile); } //直接读取文件 else { //得到文件大小 fseek(pfile, 0, SEEK_END); int length = ftell(pfile); if (length singleStation; split(strResponse, \"@\", singleStation); size_t size = singleStation.size(); for (size_t i = 1; i v; split(singleStation[i], \"|\", v); if (v.size() 这里用了一个站点信息结构体stationinfo,定义如下: //var station_names = '@bjb|北京北|VAP|beijingbei|bjb|0@bjd|北京东|BOP|beijingdong|bjd|1@bji|北京|BJP|beijing|bj|2 struct stationinfo { string code1; string hanzi; string code2; string pingyin; string simplepingyin; int no; }; 因为我们这里目的是为了模拟http请求做买火车票相关的操作,而不是技术方面本身,所以为了快速实现我们的目的,我们就使用curl库。这个库是一个强大的http相关的库,例如12306服务器返回的数据可能是分块的(chunked),这个库也能帮我们组装好;再例如,服务器返回的数据是使用gzip格式压缩的,curl也会帮我们自动解压好。所以,接下来的所有12306的接口,都基于我封装的curl库一个接口: /** * 发送一个http请求 *@param url 请求的url *@param strResponse http响应结果 *@param get true为GET,false为POST *@param headers 附带发送的http头信息 *@param postdata post附带的数据 *@param bReserveHeaders http响应结果是否保留头部信息 *@param timeout http请求超时时间 */ bool HttpRequest(const char* url, string& strResponse, bool get = true, const char* headers = NULL, const char* postdata = NULL, bool bReserveHeaders = false, int timeout = ); 函数各种参数已经在函数注释中写的清清楚楚了,这里就不一一解释了。这个函数的实现代码如下: bool Client12306::HttpRequest(const char* url, string& strResponse, bool get/* = true*/, const char* headers/* = NULL*/, const char* postdata/* = NULL*/, bool bReserveHeaders/* = false*/, int timeout/* = 10*/) { CURLcode res; CURL* curl = curl_easy_init(); if (NULL == curl) { LogError(\"curl lib init error\"); return false; } curl_easy_setopt(curl, CURLOPT_URL, url); //响应结果中保留头部信息 if (bReserveHeaders) curl_easy_setopt(curl, CURLOPT_HEADER, 1); curl_easy_setopt(curl, CURLOPT_COOKIEFILE, \"\"); curl_easy_setopt(curl, CURLOPT_READFUNCTION, NULL); curl_easy_setopt(curl, CURLOPT_WRITEFUNCTION, OnWriteData); curl_easy_setopt(curl, CURLOPT_WRITEDATA, (void *)&strResponse); curl_easy_setopt(curl, CURLOPT_NOSIGNAL, 1); //设定为不验证证书和HOST curl_easy_setopt(curl, CURLOPT_SSL_VERIFYPEER, false); curl_easy_setopt(curl, CURLOPT_SSL_VERIFYHOST, false); //设置超时时间 curl_easy_setopt(curl, CURLOPT_CONNECTTIMEOUT, timeout); curl_easy_setopt(curl, CURLOPT_TIMEOUT, timeout); curl_easy_setopt(curl, CURLOPT_REFERER, URL_REFERER); //12306早期版本是不需要USERAGENT这个字段的,现在必须了,估计是为了避免一些第三方的非法刺探吧。 //如果没有这个字段,会返回 /* HTTP/1.0 302 Moved Temporarily Location: http://www.12306.cn/mormhweb/logFiles/error.html Server: Cdn Cache Server V2.0 Mime-Version: 1.0 Date: Fri, 18 May 2018 02:52:05 GMT Content-Type: text/html Content-Length: 0 Expires: Fri, 18 May 2018 02:52:05 GMT X-Via: 1.0 PSshgqdxxx63:10 (Cdn Cache Server V2.0) Connection: keep-alive X-Dscp-Value: 0 */ curl_easy_setopt(curl, CURLOPT_USERAGENT, \"Mozilla/5.0 (Windows NT 6.1; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/65.0.3325.146 Safari/537.36\"); //不设置接收的编码格式或者设置为空,libcurl会自动解压压缩的格式,如gzip //curl_easy_setopt(curl, CURLOPT_ACCEPT_ENCODING, \"gzip, deflate, br\"); //添加自定义头信息 if (headers != NULL) { //LogInfo(\"http custom header: %s\", headers); struct curl_slist *chunk = NULL; chunk = curl_slist_append(chunk, headers); curl_easy_setopt(curl, CURLOPT_HTTPHEADER, chunk); } if (!get && postdata != NULL) { //LogInfo(\"http post data: %s\", postdata); curl_easy_setopt(curl, CURLOPT_POSTFIELDS, postdata); } LogInfo(\"http %s: url=%s, headers=%s, postdata=%s\", get ? \"get\" : \"post\", url, headers != NULL ? headers : \"\", postdata!=NULL?postdata : \"\"); res = curl_easy_perform(curl); bool bError = false; if (res == CURLE_OK) { int code; res = curl_easy_getinfo(curl, CURLINFO_RESPONSE_CODE, &code); if (code != 200 && code != 302) { bError = true; LogError(\"http response code is not 200 or 302, code=%d\", code); } } else { LogError(\"http request error, error code = %d\", res); bError = true; } curl_easy_cleanup(curl); LogInfo(\"http response: %s\", strResponse.c_str()); return !bError; } 正如上面注释中所提到的,浏览器在发送http请求时带的某些字段,不是必须的,我们在模拟这个请求时可以不添加,如查票接口浏览器可能会发以下http数据包: GET /otn/leftTicket/query?leftTicketDTO.train_date=2018-05-30&leftTicketDTO.from_station=SHH&leftTicketDTO.to_station=BJP&purpose_codes=ADULT HTTP/1.1 Host: kyfw.12306.cn Connection: keep-alive Cache-Control: no-cache Accept: */* X-Requested-With: XMLHttpRequest If-Modified-Since: 0 User-Agent: Mozilla/5.0 (Windows NT 6.1; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/66.0.3359.181 Safari/537.36 Referer: https://kyfw.12306.cn/otn/leftTicket/init Accept-Encoding: gzip, deflate, br Accept-Language: zh-CN,zh;q=0.9,en;q=0.8 Cookie: JSESSIONID=ACD9CB098169C4D73CDE80D6F6C38E5A; RAIL_EXPIRATION=1526978933395; RAIL_DEVICEID=WKxIYg-q1zjIPVu7VjulZ9PqEGvW2gUB9LvoM1Vx8fa7l3SUwnO_BVSatbTq506c6VYNOaxAiRaUcGFTMjCz9cPayEIc9vJ0pHaXdSqDlujJP8YrIoXbpAAs60l99z8bEtnHgAJzxLzKiv2nka5nmLY_BMNur8b8; _jc_save_fromStation=%u4E0A%u6D77%2CSHH; _jc_save_toStation=%u5317%u4EAC%2CBJP; _jc_save_wfdc_flag=dc; route=c5c62a339e7744272a54643b3be5bf64; BIGipServerotn=1708720394.50210.0000; _jc_save_fromDate=2018-05-30; _jc_save_toDate=2018-05-20 其中像Connection、Cache-Control、Accept、If-Modified-Since等字段都不是必须的,所以我们在模拟我们自己的http请求时可以不用可以添加这些字段,当然据我观察,12306服务器现在对发送过来的http数据包要求越来越严格了,如去年的时候,User-Agent这个字段还不是必须的,现在如果你不带上这个字段,可能12306返回的结果就不一定正确。当然,不正确的结果中一定不会有明确的错误信息,充其量可能会告诉你页面不存在或者系统繁忙请稍后再试,这是服务器自我保护的一种重要的措施,试想你做服务器程序,会告诉非法用户明确的错误信息吗?那样不就给了非法攻击服务器的人不断重试的机会了嘛。 需要特别注意的是:查票接口发送的http协议的头还有一个字段叫Cookie,其值是一串非常奇怪的东西: JSESSIONID=ACD9CB098169C4D73CDE80D6F6C38E5A; RAIL_EXPIRATION=1526978933395; RAIL_DEVICEID=WKxIYg-q1zjIPVu7VjulZ9PqEGvW2gUB9LvoM1Vx8fa7l3SUwnO_BVSatbTq506c6VYNOaxAiRaUcGFTMjCz9cPayEIc9vJ0pHaXdSqDlujJP8YrIoXbpAAs60l99z8bEtnHgAJzxLzKiv2nka5nmLY_BMNur8b8; _jc_save_fromStation=%u4E0A%u6D77%2CSHH; _jc_save_toStation=%u5317%u4EAC%2CBJP; _jc_save_wfdc_flag=dc; route=c5c62a339e7744272a54643b3be5bf64; BIGipServerotn=1708720394.50210.0000; _jc_save_fromDate=2018-05-30; _jc_save_toDate=2018-05-2 注意:原代码中各个字段都是连在一起的,我这里为了读者方便阅读,将各个字段单独放在一行。在这串字符中有一个JSESSIONID,在不需要登录的查票接口,我们可以传或者不传这个字段值。但是在购票以及查询常用联系人这些需要在已经登录的情况下才能进行的操作,我们必须带上这个数据,这是服务器给你的token(验证令牌),而这个令牌是在刚进入12306站点时,服务器发过来的,你后面的登录等操作必须带上这个token,否则服务器会认为您的请求是非法请求。我第一次去研究12306的买票流程时,即使在用户名、密码和图片验证码正确的情况下,也无法登录就是这个原因。这是12306为了防止非法登录使用的一个安全措施。 二、登录与拉取图片验证码接口 我的登录页面效果如下: 12306的图片验证码一般由八个图片组成,像上面的“龙舟”文字,也是图片,这两处的图片(文字图片和验证码)都是在服务器上拼装后,发给客户端的,12306服务器上这种类型的小图片有一定的数量,虽然数量比较大,但是是有限的。如果你要做验证码自动识别功能,可以尝试着下载大部分图片,然后做统计规律。所以,我这里并没有做图片自动识别功能。有兴趣的读者可自行尝试。 先说下,拉取验证码的接口。我们打开Chrome浏览器12306的登录界面: https://kyfw.12306.cn/otn/login/init 如下图所示: 可以得到拉取验证码的接口: 我们可以看到发送的http请求数据包格式是: GET /passport/captcha/captcha-image?login_site=E&module=login&rand=sjrand&0.7520968747611347 HTTP/1.1 Host: kyfw.12306.cn Connection: keep-alive User-Agent: Mozilla/5.0 (Windows NT 6.1; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/66.0.3359.181 Safari/537.36 Accept: image/webp,image/apng,image/*,*/*;q=0.8 Referer: https://kyfw.12306.cn/otn/login/init Accept-Encoding: gzip, deflate, br Accept-Language: zh-CN,zh;q=0.9,en;q=0.8 Cookie: _passport_session=badc97f6a852499297796ee852515f957153; _passport_ct=9cf4ea17c0dc47b6980cac161483f522t9022; RAIL_EXPIRATION=1526978933395; RAIL_DEVICEID=WKxIYg-q1zjIPVu7VjulZ9PqEGvW2gUB9LvoM1Vx8fa7l3SUwnO_BVSatbTq506c6VYNOaxAiRaUcGFTMjCz9cPayEIc9vJ0pHaXdSqDlujJP8YrIoXbpAAs60l99z8bEtnHgAJzxLzKiv2nka5nmLY_BMNur8b8; _jc_save_fromStation=%u4E0A%u6D77%2CSHH; _jc_save_toStation=%u5317%u4EAC%2CBJP; _jc_save_wfdc_flag=dc; route=c5c62a339e7744272a54643b3be5bf64; BIGipServerotn=1708720394.50210.0000; _jc_save_fromDate=2018-05-30; _jc_save_toDate=2018-05-20; BIGipServerpassport=837288202.50215.0000 这里也是一个http GET请求,Host、Referer和Cookie这三个字段是必须的,且Cookie字段必须带上上文说的JSESSIONID,下载图片验证码和下文中各个步骤也必须在Cookie字段中带上这个JSESSIONID值,否则无法从12306服务器得到正确的应答。后面会介绍如何拿到这个这。这个拉取图片验证码的http GET请求需要三个参数,如上面的代码段所示,即login_site、module、rand和一个类似于0.7520968747611347的随机值,前三个字段的值都是固定的,module字段表示当前是哪个模块,当前是登录模块,所以值是login,后面获取最近联系人时取值是passenger。这里还有一个需要注意的地方是,如果您验证图片验证码失败时,重新请求图片时,必须也重新请求下JSESSIONID。这个url是 https://kyfw.12306.cn/otn/login/init http请求和应答包如下: 请求包: Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,image/apng,*/*;q=0.8 Accept-Encoding: gzip, deflate, br Accept-Language: zh-CN,zh;q=0.9,en;q=0.8 Cache-Control: max-age=0 Connection: keep-alive Cookie: RAIL_EXPIRATION=1526978933395; RAIL_DEVICEID=WKxIYg-q1zjIPVu7VjulZ9PqEGvW2gUB9LvoM1Vx8fa7l3SUwnO_BVSatbTq506c6VYNOaxAiRaUcGFTMjCz9cPayEIc9vJ0pHaXdSqDlujJP8YrIoXbpAAs60l99z8bEtnHgAJzxLzKiv2nka5nmLY_BMNur8b8; _jc_save_fromStation=%u4E0A%u6D77%2CSHH; _jc_save_toStation=%u5317%u4EAC%2CBJP; _jc_save_wfdc_flag=dc; route=c5c62a339e7744272a54643b3be5bf64; BIGipServerotn=1708720394.50210.0000; _jc_save_fromDate=2018-05-30; _jc_save_toDate=2018-05-20; BIGipServerpassport=837288202.50215.0000 Host: kyfw.12306.cn Referer: https://kyfw.12306.cn/otn/passport?redirect=/otn/login/loginOut Upgrade-Insecure-Requests: 1 User-Agent: Mozilla/5.0 (Windows NT 6.1; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/66.0.3359.181 Safari/537.36 应答包: HTTP/1.1 200 OK Date: Sun, 20 May 2018 02:23:53 GMT Content-Type: text/html;charset=utf-8 Transfer-Encoding: chunked Set-Cookie: JSESSIONID=D5AE154D66F67DE53BF70420C772158F; Path=/otn ct: C1_217_101_6 Content-Language: zh-CN Content-Encoding: gzip X-Via: 1.1 houdianxin184:4 (Cdn Cache Server V2.0) Connection: keep-alive X-Dscp-Value: 0 X-Cdn-Src-Port: 46480 这个值在应答包字段Set-Cookie中拿到: Set-Cookie: JSESSIONID=D5AE154D66F67DE53BF70420C772158F; Path=/otn 所以,我们每次请求图片验证码时,都重新请求一下这个JSESSIONID,代码如下: #define URL_LOGIN_INIT \"https://kyfw.12306.cn/otn/login/init\" bool Client12306::loginInit() { string strResponse; if (!HttpRequest(URL_LOGIN_INIT, strResponse, true, \"Upgrade-Insecure-Requests: 1\", NULL, true, 10)) { LogError(\"loginInit failed\"); return false; } if (!GetCookies(strResponse)) { LogError(\"parse login init cookie error, url=%s\", URL_LOGIN_INIT); return false; } return true; } bool Client12306::GetCookies(const string& data) { if (data.empty()) { LogError(\"http data is empty\"); return false; } //解析http头部 string str; str.append(data.c_str(), data.length()); size_t n = str.find(\"\\r\\n\\r\\n\"); string header = str.substr(0, n); str.erase(0, n + 4); //m_cookie.clear(); //获取http头中的JSESSIONID=21AC68643BBE893FBDF3DA9BCF654E98; vector v; while (true) { size_t index = header.find(\"\\r\\n\"); if (index == string::npos) break; string tmp = header.substr(0, index); v.push_back(tmp); header.erase(0, index + 2); if (header.empty()) break; } string jsessionid; string BIGipServerotn; string BIGipServerportal; string current_captcha_type; size_t m; OutputDebugStringA(\"\\nresponse http headers:\\n\"); for (size_t i = 0; i #define URL_GETPASSCODENEW \"https://kyfw.12306.cn/passport/captcha/captcha-image\" bool Client12306::DownloadVCodeImage(const char* module) { if (module == NULL) { LogError(\"module is invalid\"); return false; } //https://kyfw.12306.cn/passport/captcha/captcha-image?login_site=E&module=login&rand=sjrand&0.06851784300754482 ostringstream osUrl; osUrl tm_year, 1 + tblock->tm_mon, tblock->tm_mday, tblock->tm_hour, tblock->tm_min, tblock->tm_sec); #else sprintf(m_szCurrVCodeName, \"vcode%04d%02d%02d%02d%02d%02d.v\", 1900 + tblock->tm_year, 1 + tblock->tm_mon, tblock->tm_mday, tblock->tm_hour, tblock->tm_min, tblock->tm_sec); #endif FILE* fp = fopen(m_szCurrVCodeName, \"wb\"); if (fp == NULL) { LogError(\"open file %s error\", m_szCurrVCodeName); return false; } const char* p = strResponse.data(); size_t count = fwrite(p, strResponse.length(), 1, fp); if (count != 1) { LogError(\"write file %s error\", m_szCurrVCodeName); fclose(fp); return false; } fclose(fp); return true; } 我们再看下验证码去服务器验证的接口 https://kyfw.12306.cn/passport/captcha/captcha-check 请求头: POST /passport/captcha/captcha-check HTTP/1.1 Host: kyfw.12306.cn Connection: keep-alive Content-Length: 50 Accept: application/json, text/javascript, */*; q=0.01 Origin: https://kyfw.12306.cn X-Requested-With: XMLHttpRequest User-Agent: Mozilla/5.0 (Windows NT 6.1; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/66.0.3359.181 Safari/537.36 Content-Type: application/x-www-form-urlencoded; charset=UTF-8 Referer: https://kyfw.12306.cn/otn/login/init Accept-Encoding: gzip, deflate, br Accept-Language: zh-CN,zh;q=0.9,en;q=0.8 Cookie: _passport_session=3e39a33a25bf4ea79146bd9362c11ad62327; _passport_ct=c5c7940e08ce44db9ad05d213c1296ddt4410; RAIL_EXPIRATION=1526978933395; RAIL_DEVICEID=WKxIYg-q1zjIPVu7VjulZ9PqEGvW2gUB9LvoM1Vx8fa7l3SUwnO_BVSatbTq506c6VYNOaxAiRaUcGFTMjCz9cPayEIc9vJ0pHaXdSqDlujJP8YrIoXbpAAs60l99z8bEtnHgAJzxLzKiv2nka5nmLY_BMNur8b8; _jc_save_fromStation=%u4E0A%u6D77%2CSHH; _jc_save_toStation=%u5317%u4EAC%2CBJP; _jc_save_wfdc_flag=dc; route=c5c62a339e7744272a54643b3be5bf64; BIGipServerotn=1708720394.50210.0000; _jc_save_fromDate=2018-05-30; _jc_save_toDate=2018-05-20; BIGipServerpassport=837288202.50215.0000 这是一个POST请求,其中POST数据带上的输入的图片验证码选择的坐标X和Y值: answer: 175,58,30,51 login_site: E rand: sjrand 这里我选择了两张图片,所以有两组坐标值,(175,58)是一组,(30,51)是另外一组,这个坐标系如下: 因为每个图片的尺寸都一样,所以,我可以给每个图片设置一个坐标范围,当选择了一个图片,给一个在其中的坐标即可,不一定是鼠标点击时的准确位置: //刷新验证码 登录状态下的验证码传入”randp“,非登录传入”sjrand“ 具体参看原otsweb中的传入参数 struct VCodePosition { int x; int y; }; const VCodePosition g_pos[] = { { 39, 40 }, { 114, 43 }, { 186, 42 }, { 252, 47 }, { 36, 120 }, { 115, 125 }, { 194, 125 }, { 256, 120 } }; //验证码图片八个区块的位置 struct VCODE_SLICE_POS { int xLeft; int xRight; int yTop; int yBottom; }; const VCODE_SLICE_POS g_VCodeSlicePos[] = { {0, 70, 0, 70}, {71, 140, 0, 70 }, {141, 210, 0, 70 }, {211, 280, 0, 70 }, { 0, 70, 70, 140 }, {71, 140, 70, 140 }, {141, 210, 70, 140 }, {211, 280, 70, 140 } }; //8个验证码区块的鼠标点击状态 bool g_bVodeSlice1Pressed[8] = { false, false, false, false, false, false, false, false}; 验证的图片验证码的接口代码是: int Client12306::checkRandCodeAnsyn(const char* vcode) { string param; param = \"randCode=\"; param += vcode; param += \"&rand=sjrand\"; //passenger:randp string strResponse; string strCookie = \"Cookie: \"; strCookie += m_strCookies; if (!HttpRequest(URL_CHECKRANDCODEANSYN, strResponse, false, strCookie.c_str(), param.c_str(), false, 10)) { LogError(\"checkRandCodeAnsyn failed\"); return -1; } ///** 成功返回 //HTTP/1.1 200 OK //Date: Thu, 05 Jan 2017 07:44:16 GMT //Server: Apache-Coyote/1.1 //X-Powered-By: Servlet 2.5; JBoss-5.0/JBossWeb-2.1 //ct: c1_103 //Content-Type: application/json;charset=UTF-8 //Content-Length: 144 //X-Via: 1.1 jiandianxin29:6 (Cdn Cache Server V2.0) //Connection: keep-alive //X-Cdn-Src-Port: 19153 //参数无效 //{\"validateMessagesShowId\":\"_validatorMessage\",\"status\":true,\"httpstatus\":200,\"data\":{\"result\":\"0\",\"msg\":\"\"},\"messages\":[],\"validateMessages\":{}} //验证码过期 //{\"validateMessagesShowId\":\"_validatorMessage\",\"status\":true,\"httpstatus\":200,\"data\":{\"result\":\"0\",\"msg\":\"EXPIRED\"},\"messages\":[],\"validateMessages\":{}} //验证码错误 //{\"validateMessagesShowId\":\"_validatorMessage\",\"status\":true,\"httpstatus\":200,\"data\":{\"result\":\"1\",\"msg\":\"FALSE\"},\"messages\":[],\"validateMessages\":{}} //验证码正确 //{\"validateMessagesShowId\":\"_validatorMessage\",\"status\":true,\"httpstatus\":200,\"data\":{\"result\":\"1\",\"msg\":\"TRUE\"},\"messages\":[],\"validateMessages\":{}} Json::Reader JsonReader; Json::Value JsonRoot; if (!JsonReader.parse(strResponse, JsonRoot)) return -1; //{\"validateMessagesShowId\":\"_validatorMessage\", \"status\" : true, \"httpstatus\" : 200, \"data\" : {\"result\":\"1\", \"msg\" : \"TRUE\"}, \"messages\" : [], \"validateMessages\" : {}} if (JsonRoot[\"status\"].isNull() || JsonRoot[\"status\"].asBool() != true) return -1; if (JsonRoot[\"httpstatus\"].isNull() || JsonRoot[\"httpstatus\"].asInt() != 200) return -1; if (JsonRoot[\"data\"].isNull() || !JsonRoot[\"data\"].isObject()) return -1; if (JsonRoot[\"data\"][\"result\"].isNull()) return -1; if (JsonRoot[\"data\"][\"result\"].asString() != \"1\" && JsonRoot[\"data\"][\"result\"].asString() != \"0\") return -1; if (JsonRoot[\"data\"][\"msg\"].isNull()) return -1; //if (JsonRoot[\"data\"][\"msg\"].asString().empty()) // return -1; if (JsonRoot[\"data\"][\"msg\"].asString() == \"\") return 0; else if (JsonRoot[\"data\"][\"msg\"].asString() == \"FALSE\") return 1; return 1; } 同理,这里也给出验证用户名和密码的接口实现代码: int Client12306::loginAysnSuggest(const char* user, const char* pass, const char* vcode) { string param = \"loginUserDTO.user_name=\"; param += user; param += \"&userDTO.password=\"; param += pass; param += \"&randCode=\"; param += vcode; string strResponse; string strCookie = \"Cookie: \"; strCookie += m_strCookies; if (!HttpRequest(URL_LOGINAYSNSUGGEST, strResponse, false, strCookie.c_str(), param.c_str(), false, 10)) { LogError(\"loginAysnSuggest failed\"); return 2; } ///** 成功返回 //HTTP/1.1 200 OK //Date: Thu, 05 Jan 2017 07:49:53 GMT //Server: Apache-Coyote/1.1 //X-Powered-By: Servlet 2.5; JBoss-5.0/JBossWeb-2.1 //ct: c1_103 //Content-Type: application/json;charset=UTF-8 //Content-Length: 146 //X-Via: 1.1 f186:10 (Cdn Cache Server V2.0) //Connection: keep-alive //X-Cdn-Src-Port: 48361 //邮箱不存在 //{\"validateMessagesShowId\":\"_validatorMessage\",\"status\":true,\"httpstatus\":200,\"data\":{},\"messages\":[\"该邮箱不存在。\"],\"validateMessages\":{}} //密码错误 //{\"validateMessagesShowId\":\"_validatorMessage\",\"status\":true,\"httpstatus\":200,\"data\":{},\"messages\":[\"密码输入错误。如果输错次数超过4次,用户将被锁定。\"],\"validateMessages\":{}} //登录成功 //{\"validateMessagesShowId\":\"_validatorMessage\",\"status\":true,\"httpstatus\":200,\"data\":{\"otherMsg\":\"\",loginCheck:\"Y\"},\"messages\":[],\"validateMessages\":{}} //WCHAR* psz1 = Utf8ToAnsi(strResponse.c_str()); //wstring str = psz1; //delete[] psz1; Json::Reader JsonReader; Json::Value JsonRoot; if (!JsonReader.parse(strResponse, JsonRoot)) return 2; //{\"validateMessagesShowId\":\"_validatorMessage\", \"status\" : true, //\"httpstatus\" : 200, \"data\" : {\"otherMsg\":\"\", loginCheck : \"Y\"}, \"messages\" : [], \"validateMessages\" : {}} if (JsonRoot[\"status\"].isNull()) return -1; bool bStatus = JsonRoot[\"status\"].asBool(); if (!bStatus) return -1; if (JsonRoot[\"httpstatus\"].isNull() || JsonRoot[\"httpstatus\"].asInt() != 200) return 2; if (JsonRoot[\"data\"].isNull() || !JsonRoot[\"data\"].isObject()) return 2; if (JsonRoot[\"data\"][\"otherMsg\"].isNull() || JsonRoot[\"data\"][\"otherMsg\"].asString() != \"\") return 2; if (JsonRoot[\"data\"][\"loginCheck\"].isNull() || JsonRoot[\"data\"][\"loginCheck\"].asString() != \"Y\") return 1; return 0; } 这里还有个注意细节,就是通过POST请求发送的数据需要对一些符号做URL Encode,这个我在上一篇文章《从零实现一个http服务器》也详细做了介绍,还不清楚的可以参见上一篇文章。所以对于向图片验证码坐标信息中含有的逗号信息就要进行URL编码,从 answer=114,54,44,46&login_site=E&rand=sjrand 变成 answer=114%2C54%2C44%2C46&login_site=E&rand=sjrand 所以,在http包头中指定的Content-Length字段的值应该是编码后的字符串长度,而不是原始的长度,这个地方特别容易出错。 如果验证成功后,接下来就是查票和购票了。这里就不一一介绍了,所有的原理都是一样的,作者可以自行探索。当然,我已经将大多数的接口都探索完了,并实现了,我这里贴一下吧: /** *@desc: 封装获取验证码、校验验证码、登录等12306各个请求的类,Client12306.h文件 *@author: zhangyl *@date: 2017.01.17 */ #ifndef __CLIENT_12306_H__ #define __CLIENT_12306_H__ #include #include using namespace std; //车次类型 #define TRAIN_GC 0x00000001 #define TRAIN_D (0x00000001 & v); /** * 初始化session,获取JSESSIONID */ bool loginInit(); bool DownloadVCodeImage(const char* module = \"login\"); /** *@return 0校验成功;1校验失败;2校验出错 */ int checkRandCodeAnsyn(const char* vcode); /** *@return 0校验成功;1校验失败;2校验出错 */ int loginAysnSuggest(const char* user, const char* pass, const char* vcode); /** * 正式登录 */ bool userLogin(); /** * 模拟12306跳转 */ bool initMy12306(); /** * 拉取乘客买票验证码 */ //bool GetVCodeImage(); /** * 拉取乘客买票验证码 */ /** * 查询余票第一步 * https://kyfw.12306.cn/otn/leftTicket/log?leftTicketDTO.train_date=2017-02-08&leftTicketDTO.from_station=SHH&leftTicketDTO.to_station=NJH&purpose_codes=ADULT * 应答:{“validateMessagesShowId”:”_validatorMessage”,”status”:true,”httpstatus”:200,”messages”:[],”validateMessages”:{}} *@param: train_date列车发车日期,格式:2017-01-28 *@param: from_station出发站,格式:SHH 对应上海 *@parma: to_station到站,格式:BJP 对应北京 *@param: purpose_codes 票类型,成人票:ADULT 学生票:0X00 */ bool QueryTickets1(const char* train_date, const char* from_station, const char* to_station, const char* purpose_codes); /** * 查询余票第二步 * 这几种情形都有可能,所以应该都尝试一下 * https://kyfw.12306.cn/otn/leftTicket/queryZ?leftTicketDTO.train_date=2017-02-08&leftTicketDTO.from_station=SHH&leftTicketDTO.to_station=NJH&purpose_codes=ADULT * https://kyfw.12306.cn/otn/leftTicket/queryX?leftTicketDTO.train_date=2017-02-08&leftTicketDTO.from_station=SHH&leftTicketDTO.to_station=NJH&purpose_codes=ADULT * https://kyfw.12306.cn/otn/leftTicket/query?leftTicketDTO.train_date=2017-02-08&leftTicketDTO.from_station=SHH&leftTicketDTO.to_station=NJH&purpose_codes=ADULT * {\"status\":false,\"c_url\":\"leftTicket/query\",\"c_name\":\"CLeftTicketUrl\"} * {\"validateMessagesShowId\":\"_validatorMessage\",\"status\":true,\"httpstatus\":200,\"messages\":[\"非法请求\"],\"validateMessages\":{}} * 应答中含有实际余票信息 *@param: train_date列车发车日期,格式:2017-01-28 *@param: from_station出发站,格式:SHH 对应上海 *@parma: to_station到站,格式:BJP 对应北京 *@param: purpose_codes 票类型,成人票:ADULT 学生票:0X00 */ bool QueryTickets2(const char* train_date, const char* from_station, const char* to_station, const char* purpose_codes, vector& v); /** * 检测用户是否登录 * https://kyfw.12306.cn/otn/login/checkUser POST _json_att= * Cookie: JSESSIONID=0A01D967FCD9827FC664E43DEE3C7C6EF950F677C2; __NRF=86A7CBA739653C1CC2C3C3AA7C88A1E3; BIGipServerotn=1742274826.64545.0000; BIGipServerportal=3134456074.17695.0000; current_captcha_type=Z; _jc_save_fromStation=%u4E0A%u6D77%2CSHH; _jc_save_toStation=%u5357%u4EAC%2CNJH; _jc_save_fromDate=2017-01-22; _jc_save_toDate=2017-01-22; _jc_save_wfdc_flag=dc * {\"validateMessagesShowId\":\"_validatorMessage\",\"status\":true,\"httpstatus\":200,\"data\":{\"flag\":true},\"messages\":[],\"validateMessages\":{}} */ bool checkUser(); /** * 预提交订单 POST * https://kyfw.12306.cn/otn/leftTicket/submitOrderRequest?secretStr=secretStr&train_date=2017-01-21&back_train_date=2016-12-23&tour_flag=dc&purpose_codes=ADULT&query_from_station_name=深圳&query_to_station_name=武汉&undefined= */ bool submitOrderRequest(const char* secretStr, const char* train_date, const char* back_train_date, const char* tour_flag, const char* purpose_codes, const char* query_from_station_name, const char* query_to_station_name); /** * 模拟跳转页面InitDc,Post */ bool initDc(); /** * 拉取常用联系人 POST * https://kyfw.12306.cn/otn/confirmPassenger/getPassengerDTOs?_json_att=&REPEAT_SUBMIT_TOKEN=SubmitToken */ bool getPassengerDTOs(vector& v); /** * 购票人确定 * https://kyfw.12306.cn/otn/confirmPassenger/checkOrderInfo @param oldPassengerStr oldPassengerStr组成的格式:乘客名,passenger_id_type_code,passenger_id_no,passenger_type,’_’ 示例: 张远龙,1,342623198912088150,1_ @param passengerTicketStr passengerTicketStr组成的格式:seatType,0,票类型(成人票填1),乘客名,passenger_id_type_code,passenger_id_no,mobile_no,’N’ 示例: O,0,1,张远龙,1,342623198912088150,13917043320,N 101 @tour_flag dc表示单程票 应答:{\"validateMessagesShowId\":\"_validatorMessage\",\"status\":true,\"httpstatus\":200,\"data\":{\"ifShowPassCode\":\"N\",\"canChooseBeds\":\"N\",\"canChooseSeats\":\"N\",\"choose_Seats\":\"MOP9\",\"isCanChooseMid\":\"N\",\"ifShowPassCodeTime\":\"1\",\"submitStatus\":true,\"smokeStr\":\"\"},\"messages\":[],\"validateMessages\":{}} */ bool checkOrderInfo(const char* oldPassengerStr, const char* passengerTicketStr, const char* tour_flag, bool& bVerifyVCode); /** * 准备进入排队 * https://kyfw.12306.cn/otn/confirmPassenger/getQueueCount _json_att 10 fromStationTelecode VNP 23 leftTicket enu80ehMzuVJlK2Q43c6kn5%2BzQF41LEI6Nr14JuzThrooN57 63 purpose_codes 00 16 REPEAT_SUBMIT_TOKEN 691c09b5605e46bfb2ec2380ee65de0e 52 seatType O 10 stationTrainCode G5 19 toStationTelecode AOH 21 train_date Fri Feb 10 00:00:00 UTC+0800 2017 50 train_location P2 17 train_no 24000000G502 21 应答:{\"validateMessagesShowId\":\"_validatorMessage\",\"status\":true,\"httpstatus\":200,\"data\":{\"count\":\"4\",\"ticket\":\"669\",\"op_2\":\"false\",\"countT\":\"0\",\"op_1\":\"true\"},\"messages\":[],\"validateMessages\":{}} */ bool getQueueCount(const char* fromStationTelecode, const char* leftTicket, const char* purpose_codes, const char* seatType, const char* stationTrainCode, const char* toStationTelecode, const char* train_date, const char* train_location, const char* train_no); /** * 确认购买 * https://kyfw.12306.cn/otn/confirmPassenger/confirmSingleForQueue _json_att 10 choose_seats 13 dwAll N 7 key_check_isChange 7503FD317E01E290C3D95CAA1D26DD8CFA9470C3643BA9799D3FB753 75 leftTicketStr enu80ehMzuVJlK2Q43c6kn5%2BzQF41LEI6Nr14JuzThrooN57 66 oldPassengerStr 张远龙,1,342623198912088150,1_ 73 passengerTicketStr O,0,1,张远龙,1,342623198912088150,13917043320,N 101 purpose_codes 00 16 randCode 9 REPEAT_SUBMIT_TOKEN 691c09b5605e46bfb2ec2380ee65de0e 52 roomType 00 11 seatDetailType 000 18 train_location P2 17 应答:{\"validateMessagesShowId\":\"_validatorMessage\",\"status\":true,\"httpstatus\":200,\"data\":{\"submitStatus\":true},\"messages\":[],\"validateMessages\":{}} */ bool confirmSingleForQueue(const char* leftTicketStr, const char* oldPassengerStr, const char* passengerTicketStr, const char* purpose_codes, const char* train_location); /** * 查询订单状态: https://kyfw.12306.cn/otn/confirmPassenger/queryOrderWaitTime?random=1486368851278&tourFlag=dc&_json_att=&REPEAT_SUBMIT_TOKEN=691c09b5605e46bfb2ec2380ee65de0e GET _json_att random 1486368851278 REPEAT_SUBMIT_TOKEN 691c09b5605e46bfb2ec2380ee65de0e tourFlag dc 响应:{\"validateMessagesShowId\":\"_validatorMessage\",\"status\":true,\"httpstatus\":200,\"data\":{\"queryOrderWaitTimeStatus\":true,\"count\":0,\"waitTime\":-1,\"requestId\":6234282826330508533,\"waitCount\":0,\"tourFlag\":\"dc\",\"orderId\":\"E061149209\"},\"messages\":[],\"validateMessages\":{}} */ bool queryOrderWaitTime(const char* tourflag, string& orderId); /** * https://kyfw.12306.cn/otn/confirmPassenger/resultOrderForDcQueue POST _json_att 10 orderSequence_no E061149209 27 REPEAT_SUBMIT_TOKEN 691c09b5605e46bfb2ec2380ee65de0e 52 {\"validateMessagesShowId\":\"_validatorMessage\",\"status\":true,\"httpstatus\":200,\"data\":{\"submitStatus\":true},\"messages\":[],\"validateMessages\":{}} */ //bool resultOrderForDcQueue(); /** * 未完成的订单页面 https://kyfw.12306.cn/otn/queryOrder/initNoComplete GET * 获取未完成的订单 https://kyfw.12306.cn/otn/queryOrder/queryMyOrderNoComplete POST _json_att= */ /* { \"validateMessagesShowId\": \"_validatorMessage\", \"status\": true, \"httpstatus\": 200, \"data\": { \"orderDBList\": [ { \"sequence_no\": \"E079331507\", \"order_date\": \"2017-02-09 10:10:55\", \"ticket_totalnum\": 1, \"ticket_price_all\": 55300, \"cancel_flag\": \"Y\", \"resign_flag\": \"4\", \"return_flag\": \"N\", \"print_eticket_flag\": \"N\", \"pay_flag\": \"Y\", \"pay_resign_flag\": \"N\", \"confirm_flag\": \"N\", \"tickets\": [ { \"stationTrainDTO\": { \"trainDTO\": {}, \"station_train_code\": \"G41\", \"from_station_telecode\": \"VNP\", \"from_station_name\": \"北京南\", \"start_time\": \"1970-01-01 09:16:00\", \"to_station_telecode\": \"AOH\", \"to_station_name\": \"上海虹桥\", \"arrive_time\": \"1970-01-01 14:48:00\", \"distance\": \"1318\" }, \"passengerDTO\": { \"passenger_name\": \"范蠡\", \"passenger_id_type_code\": \"1\", \"passenger_id_type_name\": \"二代身份证\", \"passenger_id_no\": \"34262319781108815X\", \"total_times\": \"98\" }, \"ticket_no\": \"E079331507110008B\", \"sequence_no\": \"E079331507\", \"batch_no\": \"1\", \"train_date\": \"2017-02-11 00:00:00\", \"coach_no\": \"10\", \"coach_name\": \"10\", \"seat_no\": \"008B\", \"seat_name\": \"08B号\", \"seat_flag\": \"0\", \"seat_type_code\": \"O\", \"seat_type_name\": \"二等座\", \"ticket_type_code\": \"1\", \"ticket_type_name\": \"成人票\", \"reserve_time\": \"2017-02-09 10:10:55\", \"limit_time\": \"2017-02-09 10:10:55\", \"lose_time\": \"2017-02-09 10:40:55\", \"pay_limit_time\": \"2017-02-09 10:40:55\", \"ticket_price\": 55300, \"print_eticket_flag\": \"N\", \"resign_flag\": \"4\", \"return_flag\": \"N\", \"confirm_flag\": \"N\", \"pay_mode_code\": \"Y\", \"ticket_status_code\": \"i\", \"ticket_status_name\": \"待支付\", \"cancel_flag\": \"Y\", \"amount_char\": 0, \"trade_mode\": \"\", \"start_train_date_page\": \"2017-02-11 09:16\", \"str_ticket_price_page\": \"553.0\", \"come_go_traveller_ticket_page\": \"N\", \"return_deliver_flag\": \"N\", \"deliver_fee_char\": \"\", \"is_need_alert_flag\": false, \"is_deliver\": \"N\", \"dynamicProp\": \"\", \"fee_char\": \"\", \"insure_query_no\": \"\" } ], \"reserve_flag_query\": \"p\", \"if_show_resigning_info\": \"N\", \"recordCount\": \"1\", \"isNeedSendMailAndMsg\": \"N\", \"array_passser_name_page\": [ \"张远龙\" ], \"from_station_name_page\": [ \"北京南\" ], \"to_station_name_page\": [ \"上海虹桥\" ], \"start_train_date_page\": \"2017-02-11 09:16\", \"start_time_page\": \"09:16\", \"arrive_time_page\": \"14:48\", \"train_code_page\": \"G41\", \"ticket_total_price_page\": \"553.0\", \"come_go_traveller_order_page\": \"N\", \"canOffLinePay\": \"N\", \"if_deliver\": \"N\", \"insure_query_no\": \"\" } ], \"to_page\": \"db\" }, \"messages\": [], \"validateMessages\": {} } */ /** * 已完成订单(改/退) : https://kyfw.12306.cn/otn/queryOrder/queryMyOrder POST * queryType 1 按订票日期 2 按乘车日期 * 查询日期queryStartDate=2017-02-09&queryEndDate=2017-02-09 * come_from_flag: my_order 全部 my_resign 可改签 my_cs_resign 可变更到站 my_refund 可退票 * &pageSize=8&pageIndex=0& * query_where G 未出行订单 H 历史订单 * sequeue_train_name 订单号/车次/乘客姓名 */ /* 历史订单格式 参见[历史订单.txt] */ /** * 获取全国车站信息 *@param si 返回的车站信息 *@param bForceDownload 强制从网络上下载,即不使用本地副本 */ bool GetStationInfo(vector& si, bool bForceDownload = false); /** * 获取所有高校信息 https://kyfw.12306.cn/otn/userCommon/schoolNames POST provinceCode=11&_json_att= */ /** * 获取所有城市信息 https://kyfw.12306.cn/otn/userCommon/allCitys POST station_name=&_json_att= */ /** * 查询常用联系人 */ bool QueryPassengers(int pageindex = 2, int pagesize = 10); bool GetVCodeFileName(char* pszDst, int nLength); private: bool GetCookies(const string& data); /** * 发送一个http请求 *@param url 请求的url *@param strResponse http响应结果 *@param get true为GET,false为POST *@param headers 附带发送的http头信息 *@param postdata post附带的数据 *@param bReserveHeaders http响应结果是否保留头部信息 *@param timeout http请求超时时间 */ bool HttpRequest(const char* url, string& strResponse, bool get = true, const char* headers = NULL, const char* postdata = NULL, bool bReserveHeaders = false, int timeout = 10); private: char m_szCurrVCodeName[256]; //当前验证码图片的名称 string m_strCookies; string m_strGlobalRepeatSubmitToken; string m_strKeyCheckIsChange; }; #endif //!__CLIENT_12306_H__ 具体的实现代码就不在文章中贴出来了,您可以下载我的代码。下载地址在微信公众号『easyserverdev』中回复『12306源码』即可得到下载地址,当然,由于12306的接口经常发生改变,当你拿到代码时,可能12306服务器的接口已经稍微发生了改变,您可以按上面介绍的原理做响应的修改。 最后当您实现了基本的登录和购票功能后,你就可以不断模拟某些请求去进行刷票了,这就是刷票的基本原理。 如需下载本站全部技术文章,可以在【高性能服务器开发】公众号回复关键字“文章下载”即可。最近更新时间: 2020-10-02 17:25:49 "},"articles/服务器开发案例实战/从零实现一个邮件收发客户端.html":{"url":"articles/服务器开发案例实战/从零实现一个邮件收发客户端.html","title":"从零实现一个邮件收发客户端","keywords":"","body":"从零实现一个邮件收发客户端 与邮件收发有关的协议有 POP3、SMPT 和 IMAP 等。 POP3 POP3全称是 Post Office Protocol 3 ,即邮局协议的第 3 个版本,它规定怎样将个人计算机连接到 Internet 的邮件服务器和下载电子邮件的电子协议,它是因特网电子邮件的第一个离线协议标准,POP3 允许用户从服务器上把邮件存储到本地主机(即自己的计算机)上,同时删除保存在邮件服务器上的邮件,而 POP3 服务器则是遵循 POP3 协议的接收邮件服务器,用来接收电子邮件的。 SMTP SMTP 的全称是 Simple Mail Transfer Protocol,即简单邮件传输协议。它是一组用于从源地址到目的地址传输邮件的规范,它帮助每台计算机在发送或中转邮件时找到下一个目的地。SMTP 服务器就是遵循 SMTP 协议的发送邮件服务器。SMTP 需要认证,简单地说就是要求必须在提供了账户名和密码之后才可以登录 SMTP 服务器,这就使得那些垃圾邮件的散播者无可乘之机,使用户避免受到垃圾邮件的侵扰。 IMAP IMAP全称是 Internet Mail Access Protocol,即交互式邮件存取协议,它是跟 POP3 类似邮件访问标准协议之一。不同的是,开启了 IMAP 后,在电子邮件客户端收取的邮件仍然保留在服务器上,同时在客户端上的操作都会反馈到服务器上,如:删除邮件,标记已读等,服务器上的邮件也会做相应的动作。所以无论从浏览器登录邮箱或者客户端软件登录邮箱,看到的邮件以及状态都是一致的。而 POP3 对邮件的操作只会在本地邮件客户端起作用。 读者如果需要自己编写相关的邮件收发客户端,需要登录对应的邮件服务器开启相应的 POP3/SMTP/IMAP 服务。以 163 邮箱为例: 请登录 163 邮箱(http://mail.163.com/),点击页面正上方的“**设置**”,再点击左侧上“**POP3/SMTP/IMAP**”,其中“**开启 SMTP 服务**”是系统默认勾选开启的。读者可勾选图中另两个选项,点击确定,即可开启成功。不勾选图中两个选项,点击确定,可关闭成功。 网易163免费邮箱相关服务器信息: 163免费邮客户端设置的POP3、SMTP、IMAP地址 POP3、SMTP、IMAP 协议就是我们前面介绍的以指定字符(串)为包的结束标志的协议典型例子。我们来以 SMTP 协议和 POP3 协议为例来讲解一下。 SMTP 协议 先来介绍 SMTP 协议吧,SMTP 全称是 Simple Mail Transfer Protocol,即简单邮件传输协议,该协议用于发送邮件。 SMTP 协议的格式: 关键字 自定义内容\\r\\n “自定义内容”根据“关键字”的类型是否设置,对于使用 SMTP 作为客户端的一方常用的“关键字“如下所示: //连接上邮件服务器之后登录服务器之前向服务器发送的问候信息 HELO 自定义问候语\\r\\n //请求登录邮件服务器 AUTH LOGIN\\r\\n base64形式的用户名\\r\\n base64形式的密码\\r\\n //设置发件人邮箱地址 MAIL FROM:发件人地址\\r\\n //设置收件人地址,每次发送可设置一个收件人地址,如果有多个收件地址,要分别设置对应次数 rcpt to:收件人地址\\r\\n //发送邮件正文开始标志 DATA\\r\\n //发送邮件正文,注意邮件正文以.\\r\\n结束 邮件正文\\r\\n.\\r\\n //登出服务器 QUIT\\r\\n 使用 SMTP 作为邮件服务器的一方常用的“关键字“是定义的各种应答码,应答码后面可以带上自己的信息,然后以\\r\\n作为结束,格式如下: 应答码 自定义消息\\r\\n 常用的应答码含义如下所示: 211 帮助返回系统状态 214 帮助信息 220 服务准备就绪 221 关闭连接 235 用户验证成功 250 请求操作就绪 251 用户不在本地,转寄到其他路径 334 等待用户输入验证信息 354 开始邮件输入 421 服务不可用 450 操作未执行,邮箱忙 451 操作中止,本地错误 452 操作未执行,存储空间不足 500 命令不可识别或语言错误 501 参数语法错误 502 命令不支技 503 命令顺序错误 504 命令参数不支持 550 操作未执行,邮箱不可用 551 非本地用户 552 中止存储空间不足 553 操作未执行,邮箱名不正确 554 传输失败 更多的 SMTP 协议的细节可以参考相应的 RFC 文档。 下面我们来看一个具体的使用 SMTP 发送邮件的代码示例,假设我们现在要实现一个邮件报警系统,根据上文的介绍,我们实现一个 SmtpSocket 类来综合常用邮件的功能: SmtpSocket.h /** * 发送邮件类,SmtpSocket.h * zhangyl 2019.05.11 */ #pragma once #include #include #include \"Platform.h\" class SmtpSocket final { public: static bool sendMail(const std::string& server, short port, const std::string& from, const std::string& fromPassword, const std::vector& to, const std::string& subject, const std::string& mailData); public: SmtpSocket(void); ~SmtpSocket(void); bool isConnected() const { return m_hSocket; } bool connect(const char* pszUrl, short port = 25); bool logon(const char* pszUser, const char* pszPassword); bool setMailFrom(const char* pszFrom); bool setMailTo(const std::vector& sendTo); bool send(const std::string& subject, const std::string& mailData); void closeConnection(); void quit(); //退出 private: /** * 验证从服务器返回的前三位代码和传递进来的参数是否一样 */ bool checkResponse(const char* recvCode); private: bool m_bConnected; SOCKET m_hSocket; std::string m_strUser; std::string m_strPassword; std::string m_strFrom; std::vector m_strTo;; }; SmtpSocket.cpp #include \"SmtpSocket.h\" #include #include #include #include \"Base64Util.h\" #include \"Platform.h\" bool SmtpSocket::sendMail(const std::string& server, short port, const std::string& from, const std::string& fromPassword, const std::vector& to, const std::string& subject, const std::string& mailData) { size_t atSymbolPos = from.find_first_of(\"@\"); if (atSymbolPos == std::string::npos) return false; std::string strUser = from.substr(0, atSymbolPos); SmtpSocket smtpSocket; //smtp.163.com 25 if (!smtpSocket.connect(server.c_str(), port)) return false; //testformybook 2019hhxxttxs if (!smtpSocket.logon(strUser.c_str(), fromPassword.c_str())) return false; //testformybook@163.com if (!smtpSocket.setMailFrom(from.c_str())) return false; if (!smtpSocket.setMailTo(to)) return false; if (!smtpSocket.send(subject, mailData)) return false; return true; } SmtpSocket::SmtpSocket() : m_bConnected(false), m_hSocket(-1) { } SmtpSocket::~SmtpSocket() { quit(); } bool SmtpSocket::checkResponse(const char* recvCode) { char recvBuffer[1024] = { 0 }; long lResult = 0; lResult = recv(m_hSocket, recvBuffer, 1024, 0); if (lResult == SOCKET_ERROR || lResult = 0) { closesocket(m_hSocket); m_hSocket = -1; m_bConnected = false; } } bool SmtpSocket::connect(const char* pszUrl, short port/* = 25*/) { //MailLogNormalA(\"[SmtpSocket::Connect] Start connect [%s:%d].\", lpUrl, lPort); struct sockaddr_in server = { 0 }; struct hostent* pHostent = NULL; unsigned int addr = 0; closeConnection(); m_hSocket = socket(AF_INET, SOCK_STREAM, IPPROTO_TCP); if (m_hSocket h_addr); if (::connect(m_hSocket, (struct sockaddr*) & server, sizeof(server)) == SOCKET_ERROR) return false; if (!checkResponse(\"220\")) return false; //向服务器发送\"HELO \"+服务器名 //string strTmp=\"HELO \"+SmtpAddr+\"\\r\\n\"; char szSend[256] = { 0 }; snprintf(szSend, sizeof(szSend), \"HELO %s\\r\\n\", pszUrl); if (::send(m_hSocket, szSend, strlen(szSend), 0) == SOCKET_ERROR) return false; if (!checkResponse(\"250\")) return false; m_bConnected = true; return true; } bool SmtpSocket::setMailFrom(const char* pszFrom) { if (m_hSocket \\r\\n\", pszFrom); if (::send(m_hSocket, szSend, strlen(szSend), 0) == SOCKET_ERROR) return false; if (!checkResponse(\"250\")) return false; m_strFrom = pszFrom; return true; } bool SmtpSocket::setMailTo(const std::vector& sendTo) { if (m_hSocket \\r\\n\", iter.c_str()); if (::send(m_hSocket, szSend, strlen(szSend), 0) == SOCKET_ERROR) return false; if (!checkResponse(\"250\")) return false; } m_strTo = sendTo; return true; } bool SmtpSocket::send(const std::string& subject, const std::string& mailData) { if (m_hSocket SEND_MAX_SIZE) dwSend = SEND_MAX_SIZE; else dwSend = lTotal - dwOffset; lResult = ::send(m_hSocket, lpSendBuffer + dwOffset, dwSend, 0); if (lResult == SOCKET_ERROR) return false; dwOffset += lResult; } if (!checkResponse(\"250\")) return false; return true; } 然后我们使用另外一个类 MailMonitor 对 SmtpSocket 对象的功能进行高层抽象: MailMonitor.h /** * 邮件监控线程, MailMonitor.h * zhangyl 2019.05.11 */ #pragma once #include #include #include #include #include #include #include struct MailItem { std::string subject; std::string content; }; class MailMonitor final { public: static MailMonitor& getInstance(); private: MailMonitor() = default; ~MailMonitor() = default; MailMonitor(const MailMonitor & rhs) = delete; MailMonitor& operator=(const MailMonitor & rhs) = delete; public: bool initMonitorMailInfo(const std::string& servername, const std::string& mailserver, short mailport, const std::string& mailfrom, const std::string& mailfromPassword, const std::string& mailto); void uninit(); void wait(); void run(); bool alert(const std::string& subject, const std::string& content); private: void alertThread(); void split(const std::string& str, std::vector& v, const char* delimiter = \"|\"); private: std::string m_strMailName; //用于标识是哪一台服务器发送的邮件 std::string m_strMailServer; short m_nMailPort; std::string m_strFrom; std::string m_strFromPassword; std::vector m_strMailTo; std::list m_listMailItemsToSend; //待写入的日志 std::shared_ptr m_spMailAlertThread; std::mutex m_mutexAlert; std::condition_variable m_cvAlert; bool m_bExit; //退出标志 bool m_bRunning; //运行标志 }; MailMonitor.cpp #include \"MailMonitor.h\" #include #include #include #include #include \"SmtpSocket.h\" MailMonitor& MailMonitor::getInstance() { static MailMonitor instance; return instance; } bool MailMonitor::initMonitorMailInfo(const std::string& servername, const std::string& mailserver, short mailport, const std::string& mailfrom, const std::string& mailfromPassword, const std::string& mailto) { if (servername.empty() || mailserver.empty() || mailport joinable()) m_spMailAlertThread->join(); } void MailMonitor::wait() { if (m_spMailAlertThread->joinable()) m_spMailAlertThread->join(); } void MailMonitor::run() { m_spMailAlertThread.reset(new std::thread(std::bind(&MailMonitor::alertThread, this))); } void MailMonitor::alertThread() { m_bRunning = true; while (true) { MailItem mailItem; { std::unique_lock guard(m_mutexAlert); while (m_listMailItemsToSend.empty()) { if (m_bExit) return; m_cvAlert.wait(guard); } mailItem = m_listMailItemsToSend.front(); m_listMailItemsToSend.pop_front(); } std::ostringstream osSubject; osSubject lock_guard(m_mutexAlert); m_listMailItemsToSend.push_back(mailItem); m_cvAlert.notify_one(); } return true; } void MailMonitor::split(const std::string& str, std::vector& v, const char* delimiter/* = \"|\"*/) { if (delimiter == NULL || str.empty()) return; std::string buf(str); size_t pos = std::string::npos; std::string substr; int delimiterlength = strlen(delimiter); while (true) { pos = buf.find(delimiter); if (pos != std::string::npos) { substr = buf.substr(0, pos); if (!substr.empty()) v.push_back(substr); buf = buf.substr(pos + delimiterlength); } else { if (!buf.empty()) v.push_back(buf); break; } } } 程序中另外用到的两个辅助类文件如下: Base64Util.h #pragma once class Base64Util final { private: Base64Util() = delete; ~Base64Util() = delete; Base64Util(const Base64Util& rhs) = delete; Base64Util& operator=(const Base64Util& rhs) = delete; public: static int encode(char* pDest, const char* pSource, int lenSource, char chMask, int maxDest); static int decode(char* pDest, const char* pSource, int lenSource, char chMask, int maxDest); static bool check(char* lpString); }; Base64Util.cpp #include \"Base64Util.h\" ///////////////////////////////////////////////////////////////////////////////////////////////// static const char __DeBase64Tab__[] = { 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 62, // '+' 0, 0, 0, 63, // '/' 52, 53, 54, 55, 56, 57, 58, 59, 60, 61, // '0'-'9' 0, 0, 0, 0, 0, 0, 0, 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, // 'A'-'Z' 0, 0, 0, 0, 0, 0, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37, 38, 39, 40, 41, 42, 43, 44, 45, 46, 47, 48, 49, 50, 51, // 'a'-'z' }; static const char __EnBase64Tab__[] = { \"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/\" }; int Base64Util::encode(char* pDest, const char* pSource, int lenSource, char chMask, int maxDest) { char c1, c2, c3; int i = 0, lenDest(0), lDiv(lenSource / 3), lMod(lenSource % 3); for (; i = maxDest) return 0; c1 = *pSource++; c2 = *pSource++; c3 = *pSource++; *pDest++ = __EnBase64Tab__[c1 >> 2]; *pDest++ = __EnBase64Tab__[((c1 > 4)) & 0X3F]; *pDest++ = __EnBase64Tab__[((c2 > 6)) & 0X3F]; *pDest++ = __EnBase64Tab__[c3 & 0X3F]; } if (lMod == 1) { if (lenDest + 4 >= maxDest) return(0); c1 = *pSource++; *pDest++ = __EnBase64Tab__[(c1 & 0XFC) >> 2]; *pDest++ = __EnBase64Tab__[((c1 & 0X03) = maxDest) return(0); c1 = *pSource++; c2 = *pSource++; *pDest++ = __EnBase64Tab__[(c1 & 0XFC) >> 2]; *pDest++ = __EnBase64Tab__[((c1 & 0X03) > 4)]; *pDest++ = __EnBase64Tab__[((c2 & 0X0F) = maxDest) break; *pDest++ = char((nValue & 0X00FF0000) >> 16); if (*pSource != chMask) { nValue += __DeBase64Tab__[(int)*pSource] = maxDest) break; *pDest++ = (nValue & 0X0000FF00) >> 8; if (*pSource != chMask) { nValue += __DeBase64Tab__[(int)*pSource]; pSource++; if (++lenDest >= maxDest) break; *pDest++ = nValue & 0X000000FF; } } } *pDest = 0; return(lenDest); } bool Base64Util::check(char* lpString) { for (; *lpString; ++lpString) { switch (*lpString) { case '+': *lpString = '@'; break; case '@': *lpString = '+'; break; case '=': *lpString = '$'; break; case '$': *lpString = '='; break; case '/': *lpString = '#'; break; case '#': *lpString = '/'; break; default: if (*lpString >= 'A' && *lpString = 'a' && *lpString = '0' && *lpString = '5' && *lpString Platform.h #pragma once #include #if defined(__GNUC__) #pragma GCC diagnostic push #pragma GCC diagnostic ignored \"-Wdeprecated-declarations\" #elif defined(_MSC_VER) #pragma warning(disable : 4996) #endif #ifdef WIN32 #pragma comment(lib, \"Ws2_32.lib\") #pragma comment(lib, \"Shlwapi.lib\") //remove warning C4996 on Windows //#define _CRT_SECURE_NO_WARNINGS typedef int socklen_t; //typedef uint64_t ssize_t; typedef unsigned int in_addr_t; //Windows 上没有这些结构的定义,为了移植方便,手动定义这些结构 #define XPOLLIN 1 #define XPOLLPRI 2 #define XPOLLOUT 4 #define XPOLLERR 8 #define XPOLLHUP 16 #define XPOLLNVAL 32 #define XPOLLRDHUP 8192 #define XEPOLL_CTL_ADD 1 #define XEPOLL_CTL_DEL 2 #define XEPOLL_CTL_MOD 3 #pragma pack(push, 1) typedef union epoll_data { void* ptr; int fd; uint32_t u32; uint64_t u64; } epoll_data_t; struct epoll_event { uint32_t events; /* Epoll events */ epoll_data_t data; /* User data variable */ }; #pragma pack(pop) #include #include #include #include #include //_pipe #include //for O_BINARY #include class NetworkInitializer { public: NetworkInitializer(); ~NetworkInitializer(); }; #else typedef int SOCKET; #define SOCKET_ERROR -1 #define closesocket(s) close(s) #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include //for ubuntu readv not found #ifdef __UBUNTU #include #endif #define XPOLLIN POLLIN #define XPOLLPRI POLLPRI #define XPOLLOUT POLLOUT #define XPOLLERR POLLERR #define XPOLLHUP POLLHUP #define XPOLLNVAL POLLNVAL #define XPOLLRDHUP POLLRDHUP #define XEPOLL_CTL_ADD EPOLL_CTL_ADD #define XEPOLL_CTL_DEL EPOLL_CTL_DEL #define XEPOLL_CTL_MOD EPOLL_CTL_MOD //Linux下没有这两个函数,定义之 #define ntohll(x) be64toh(x) #define htonll(x) htobe64(x) #endif Platform.cpp #include \"Platform.h\" #ifdef WIN32 NetworkInitializer::NetworkInitializer() { WORD wVersionRequested = MAKEWORD(2, 2); WSADATA wsaData; ::WSAStartup(wVersionRequested, &wsaData); } NetworkInitializer::~NetworkInitializer() { ::WSACleanup(); } #endif 我们在 main 函数模拟产生一条新的报警邮件: main.cpp /** * 邮件报警demo * zhangyl 2020.04.09 **/ #include #include #include \"Platform.h\" #include \"MailMonitor.h\" //Winsock网络库初始化 #ifdef WIN32 NetworkInitializer windowsNetworkInitializer; #endif #ifndef WIN32 void prog_exit(int signo) { std::cout 上述代码使用了 163 邮箱账号 testformybook@163.com 给 QQ 邮箱账户 balloonwj@qq.com 和 analogous_love@qq.com 分别发送邮件,发送给邮件的函数是 MailMonitor::alert() 函数,实际发送邮件的函数是 SmtpSocket::send() 函数。 无论在 Windows 或者 Linux 上编译运行程序,我们的两个邮箱都会分别收到两封邮件,如下图所示: 产生第一封邮件的原因是我们在 main 函数中调用 MailMonitor::getInstance().initMonitorMailInfo() 初始化邮箱服务器名、地址、端口号、用户名和密码时,MailMonitor::initMonitorMailInfo() 函数内部会调用 SmtpSocket::sendMail() 函数发送一封邮件通知指定联系人邮件报警系统已经启动: bool MailMonitor::initMonitorMailInfo(const std::string& servername, const std::string& mailserver, short mailport, const std::string& mailfrom, const std::string& mailfromPassword, const std::string& mailto) { //...无关代码省略... SmtpSocket::sendMail(m_strMailServer, m_nMailPort, m_strFrom, m_strFromPassword, m_strMailTo, osSubject.str(), \"You have started Mail Alert System.\"); return true; } 产生第二封邮件则是我们在 main 函数中主动调用产生报警邮件的函数: const std::string subject = \"Alert Mail\"; const std::string content = \"This is an alert mail from \" + mailuser; MailMonitor::getInstance().alert(subject, content); 我们以第一封邮件为例来说明整个邮件发送过程中,我们的程序(客户端)与 163 邮件服务器之间的协议数据的交换内容,核心的邮件发送功能在 SmtpSocket::sendMail() 函数中: bool SmtpSocket::sendMail(const std::string& server, short port, const std::string& from, const std::string& fromPassword, const std::vector& to, const std::string& subject, const std::string& mailData) { size_t atSymbolPos = from.find_first_of(\"@\"); if (atSymbolPos == std::string::npos) return false; std::string strUser = from.substr(0, atSymbolPos); SmtpSocket smtpSocket; //smtp.163.com 25 if (!smtpSocket.connect(server.c_str(), port)) return false; //testformybook 2019hhxxttxs if (!smtpSocket.logon(strUser.c_str(), fromPassword.c_str())) return false; //testformybook@163.com if (!smtpSocket.setMailFrom(from.c_str())) return false; if (!smtpSocket.setMailTo(to)) return false; if (!smtpSocket.send(subject, mailData)) return false; return true; } 这个函数先创建 socket,再使用邮箱的地址和端口后去连接服务器(smtpSocket.connect() 函数内部额外做了一步将域名解析成 ip 地址的工作),连接成功后开始和服务器端进行数据交换: client: 尝试连接服务器 client: 连接成功 server: 220\\r\\n client: helo自定义问候语\\r\\n server: 250\\r\\n client: AUTH LOGIN\\r\\n server: 334\\r\\n client: base64编码后的用户名\\r\\n server: 334\\r\\n client: base64编码后的密码\\r\\n server: 235\\r\\n client: MAIL FROM:\\r\\n server: 250\\r\\n client: rcpt to:\\r\\n server: 250\\r\\n client: rcpt to:\\r\\n server: 250\\r\\n client: DATA\\r\\n server: 354\\r\\n client: 邮件正文\\r\\n.\\r\\n server: 250\\r\\n client:QUIT\\r\\n server:221\\r\\n 我们将上述过程绘制成如下示意图: 最终邮件就发出去了,这里我们模拟了客户端使用 smtp 协议给服务器端发送邮件,我们自己实现服务器端接收客户端发送的邮件请求也是一样的道理。这就是 SMTP 协议的格式,SMTP 协议是以特定标记作为分隔符的协议格式典型。 读者可以在 Windows 上或者 Linux 主机上测试上述程序,如果读者在阿里云这样的云主机上测试,阿里云等云主机为了避免在网络上产生大量垃圾邮件默认是禁止发往其他服务器的 25 号端口的数据的,读者需要申请解除该端口限制,或者将邮件服务器的 25 端口改成其他端口(一般改成 465 端口)。 完整的发送邮件编解码 SMTP 协议示例代码请在【高性能服务器开发】公众号后台回复关键字“SMTP”。 上文我们介绍了 SMTP 协议常用的协议命令,SMTP 协议支持的完整命令列表读者可以参考 rfc5321 文档:https://tools.ietf.org/html/rfc5321 。 POP3 协议 我们再来看下 POP3 协议: client:连接邮箱pop服务器,连接成功 server:+OK Welcome to coremail Mail Pop3 Server (163coms[10774b260cc7a37d26d71b52404dcf5cs])\\r\\n client:USER 用户名\\r\\n server:+OK core mail client:PASS 密码\\r\\n server:+OK 202 message(s) [3441786 byte(s)]\\r\\n client:LIST\\r\\n server:+OK 5 30284\\r\\n1 8284\\r\\n2 11032\\r\\n3 2989\\r\\n4 3871\\r\\n5 4108\\r\\n.\\r\\n client:RETR 100\\r\\n server: +OK 4108 octets\\r\\n Received: from sonic310-21.consmr.mail.gq1.yahoo.com (unknown [98.137.69.147]) by mx29 (Coremail) with SMTP id T8CowABHlztmml5erAoHAQ--.23443S3; Wed, 04 Mar 2020 01:56:57 +0800 (CST)\\r\\n DKIM-Signature: v=1; a=rsa-sha256; c=relaxed/relaxed; d=yahoo.com; s=s2048; t=1583258213; bh=ABL3sF+YL/syl+mwknwxiAlvKPRNYq4AYTujNrPA86g=; h=Date:From:Reply-To:Subject:References:From:Subject;\\r\\n b=OrAQTs0GJnA9yVA08gRolpsoe9E2PQhc3BLvK0msqlZkIYPYVLD1SHAHc7eI3imH4b+hggrFA6wUeiSqqq2du3tOokCU8ckq3LrbdI82EZ013M3KL6o2y+/wdPIj9Mo1TeGbmqtthYBOpGvEgwzsQMNnydkJdy5tDaW6IBT2Ux+IaP0K+jp71eYXcWjdR0mSyu3aMhLqc0z4l5HlZYpZRQG1hjxZOaCH/UjgBAdr98JecVvuRp4s5iGe6OIxc0p3xzRZBxTlLdgdHjmTKHQ00eTNCfFYai2rMxf4830lMYTwKI6O/iu3jUbTA2yjxx0LrYBFTiWzFetwAQupKLw3Qg==\\r\\n X-YMail-OSG: 7HG016cVM1nEI.fdz8BF9PN3tO6MvrppAOwu_jpQ09s4eVdYvLXavghvjDvWrRW B7PF6pZKuhiLjV7yCErxEmbWUKPLzX.WL4RJ9u4tnPC4NyVp30cLaoGZVIapWeFtqRpKlh31orVY WTsWE9FcDuHts5p2MPAd7Si52EZfyPuoffEIWrd481hx1IdSsRQN_V7mpfxihvReOIoQ5rCWuMzd oK5kXOho8iOwXlEVPzdTs33RD5rQmwbycPtLS7.FARNxWl9yO9Lrd25gDYa1hXvgG4aQptJQK5aD cHQpZUYqdiaNUaEoGoIDQR_HVndus53gTyzUzmJONpDo6wQM39O.pih7VGCrgLqB2_hHeJdPEUIk jCcwkqNn0cfDyc4QwBdQ65jcgm2cJDPFgoODhxDIqTqeFVbXFr2cXLand8vAqARi3tlnmsOUA7ZI DiSvhSx8eYGd4_frX1LfP.TpctO9Uuc3ZP6iP_K24F9HE9HNN9_swBUEPlBOB3jjSPSOdmiEMteF NWj1qOJ8i47BwMBILtx0dZheRRSxvfzSA.JmnUghFo80EgaGRgXYIEAzt8hpvxdZbtwrg0k0WPeF Y4LC2my.A9XcsnF58558bweJDaDHCJyLGFnE8__ZQI163vMPqY6QbU3OP0EJz2OE1rPBOrq9PUol TZjOEu6ghV1PG2HhX0Ydc.vvq5mloqbKusdzV5EgtpFZjLdp1_RQWuI1LG865Ig756HBaozMU6RG 0FUMn86pvXRBbNMPD6ADwcw4rdw.Xqk5TRZkqJpSp6KX82OjAgFu0xxMiZnQ7LNemrsJ2UQK9Y2_ nm8nrwOIX03Ol6Z2KspWUcPNkqPIZ6vGAr9FO9qqE_elB3K4hh04lq_KS5Tv_XoI3deD4r3J6RTb O9xp5O6cbe0Svy7FS7DosvJfK958_57Kk_6vk6wxxc3D8cx_k6P.yPbphTCLYFdfnbV5sRKNvUKT .apHpO8d0GUf29QtSc3dwBDrLEcRSpguJ3tMKBc2GZPCwUMOgf2b24zFZ49.D7MRQbZifaHsk4dJ L9jxS2qdN5pSjhZUjbLCUQ2YGcYgmNnTbfjAIaxqUWNSgpypIYNmi.lgG4bM_gW7sXH_Y3TULcsC .1GXTSjZUdUvvkr7BDnzUy3FGqv9Eyfb7GOwPzTXLzdurcd6eHx0ejCmC6gVJIwoIh9S0YSK659a K2usThSAyogrxqQ664fZr70CrLJehr5OZNLstPt3fiJhyUR1DXrlm6myQ9uSQ5vPTl0p2.DemDaY k84mtcZO0EEjKIzqeSvZ505Fex0u.66Mzu2lmr07WwMCE7wgqwOSWRnYNCz2rWcLmXA_TVDtdJ85 bHZ79FY6Vs5pGJjp.7YgDnVqysBp95w--\\r\\n Received: from sonic.gate.mail.ne1.yahoo.com by sonic310.consmr.mail.gq1.yahoo.com with HTTP; Tue, 3 Mar 2020 17:56:53 +0000\\r\\n Date: Tue, 3 Mar 2020 17:56:49 +0000 (UTC)\\r\\n From: Peter Edward Copley \\r\\n Reply-To: Peter Edward Copley \\r\\n Message-ID: \\r\\n Subject: Re:Hello\\r\\n MIME-Version: 1.0\\r\\n Content-Type: multipart/alternative; \\r\\n boundary=\"----=_Part_5391235_1821490954.1583258209466\"\\r\\n References: \\r\\n X-Mailer: WebService/1.1.15302 YMailNorrin Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/80.0.3987.122 Safari/537.36\\r\\n X-CM-TRANSID:T8CowABHlztmml5erAoHAQ--.23443S3\\r\\n Authentication-Results: mx29; spf=pass smtp.mail=noodlelife@yahoo.com;\\r\\n dkim=pass header.i=@yahoo.com\\r\\n X-Coremail-Antispam: 1Uf129KBjDUn29KB7ZKAUJUUUUU529EdanIXcx71UUUUU7v73 VFW2AGmfu7bjvjm3AaLaJ3UbIYCTnIWIevJa73UjIFyTuYvjxU-NtIUUUUU\\r\\n \\r\\n ------=_Part_5391235_1821490954.1583258209466\\r\\n .\\r\\n client:QUIT\\r\\n server:+OK core mail\\r\\n 上述过程如下示意图所示: 当我们收取邮件正文之后,我们就可以根据邮件正文中的各种 tag 来解析邮件内容得到邮件的 MessageID、收件人、发件人、邮件主题、正文和附件,注意附件内容也会被拆成特定的编码格式放在邮件中。邮件正文里面按所谓的 boundary 来分成多个块,例如上文中的 boundary=\"----=_Part_5391235_1821490954.1583258209466\"。 我们来看一个具体的例子吧。 上述邮件的主题是“测试邮件”,内容是纯文本“这是一封测试邮件,含有两个附件。”,还有两个附件,一张名为 self.jpg 的图片,一个名为 test.docx 的文档。我们将邮件下载下来后得到邮件原文是: +OK 93763 octets Received: from qq.com (unknown [183.3.226.165]) by mx27 (Coremail) with SMTP id TcCowABHJo+dMqReI+72Bg--.18000S3; Sat, 25 Apr 2020 20:52:45 +0800 (CST) DKIM-Signature: v=1; a=rsa-sha256; c=relaxed/relaxed; d=qq.com; s=s201512; t=1587819165; bh=RLNDml5+GusG7KQTgkjeS/Mpn1m/LmqBUaz6Nmo6ukY=; h=From:To:Subject:Mime-Version:Date:Message-ID; b=K3sJK+aPQ9zHu1GUvKckofm3cfocpze10XBp9FufVVVYS423myQnFWMaREpGGbeaS vrCGdjawcfhXpkvGZnhOkJZrtut1er5zWZRkmsDnqvoekRURXKt3wWyOv5WUuSPHZI NzGjMQbtYmbWjFla7zs1Cg81UQKRtg1s5KxWwGVQ= X-QQ-FEAT: CPmoSFXLZ/TSSc3nxNJn8bUc57myjtkH8mxkmSC9/G9nP1mNDXcYVAAERmmiE 038rlXj8w6qkTmh1317bdJp9MqMMEUSgpJC5DulJn4k6WCURo4NEYDiuUQK/J+YfUQnpETt w4aQYpj6nKAIqKgorGGK0zy6oQWavfOgssyvSU15d6wqlw904x6aZhS3KAUAM4+eGitBRk9 fxUEABnV/opGuLtZ/fex+UsUAVgXFbTZPoYjhxoM4ZKJsDEJ38x/9QHR1FymBebmAvNzzbB JT45M4OYwynKE/mrFR1FPSeXA= X-QQ-SSF: 00010000000000F000000000000000Z X-HAS-ATTACH: no X-QQ-BUSINESS-ORIGIN: 2 X-Originating-IP: 255.21.142.175 X-QQ-STYLE: X-QQ-mid: webmail504t1587819163t7387219 From: \"=?gb18030?B?1/PRp7fG?=\" To: \"=?gb18030?B?dGVzdGZvcm15Ym9vaw==?=\" Subject: =?gb18030?B?suLK1NPKvP4=?= Mime-Version: 1.0 Content-Type: multipart/mixed; boundary=\"----=_NextPart_5EA4329B_0FBAC2B8_51634C9D\" Content-Transfer-Encoding: 8Bit Date: Sat, 25 Apr 2020 20:52:43 +0800 X-Priority: 3 Message-ID: X-QQ-MIME: TCMime 1.0 by Tencent X-Mailer: QQMail 2.x X-QQ-Mailer: QQMail 2.x X-QQ-SENDSIZE: 520 Received: from qq.com (unknown [127.0.0.1]) by smtp.qq.com (ESMTP) with SMTP id ; Sat, 25 Apr 2020 20:52:44 +0800 (CST) Feedback-ID: webmail:qq.com:bgweb:bgweb16 X-CM-TRANSID:TcCowABHJo+dMqReI+72Bg--.18000S3 Authentication-Results: mx27; spf=pass smtp.mail=balloonwj@qq.com; dki m=pass header.i=@qq.com X-Coremail-Antispam: 1Uf129KBjDUn29KB7ZKAUJUUUUU529EdanIXcx71UUUUU7v73 VFW2AGmfu7bjvjm3AaLaJ3UbIYCTnIWIevJa73UjIFyTuYvjxU-LIDUUUUU This is a multi-part message in MIME format. ------=_NextPart_5EA4329B_0FBAC2B8_51634C9D Content-Type: multipart/alternative; boundary=\"----=_NextPart_5EA4329B_0FBAC2B8_71508FA9\"; ------=_NextPart_5EA4329B_0FBAC2B8_71508FA9 Content-Type: text/plain; charset=\"gb18030\" Content-Transfer-Encoding: base64 1eLKx9K7t+Ky4srU08q8/qOsuqzT0MG9uPa4vbz+oaM= ------=_NextPart_5EA4329B_0FBAC2B8_71508FA9 Content-Type: text/html; charset=\"gb18030\" Content-Transfer-Encoding: base64 PG1ldGEgaHR0cC1lcXVpdj0iQ29udGVudC1UeXBlIiBjb250ZW50PSJ0ZXh0L2h0bWw7IGNo YXJzZXQ9R0IxODAzMCI+PGRpdj7V4srH0ru34rLiytTTyrz+o6y6rNPQwb249ri9vP6hozwv ZGl2Pg== ------=_NextPart_5EA4329B_0FBAC2B8_71508FA9-- ------=_NextPart_5EA4329B_0FBAC2B8_51634C9D Content-Type: application/octet-stream; charset=\"gb18030\"; name=\"self.jpg\" Content-Disposition: attachment; filename=\"self.jpg\" Content-Transfer-Encoding: base64 /9j/4AAQSkZJRgABAQEAYABgAAD/2wBDAAgGBgcGBQgHBwcJCQgKDBQNDAsLDBkSEw8UHRof Hh0aHBwgJC4nICIsIxwcKDcpLDAxNDQ0Hyc5PTgyPC4zNDL/2wBDAQkJCQwLDBgNDRgyIRwh MjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjL/wAAR CAEXAR8DASIAAhEBAxEB/8QAHwAAAQUBAQEBAQEAAAAAAAAAAAECAwQFBgcICQoL/8QAtRAA AgEDAwIEAwUFBAQAAAF9AQIDAAQRBRIhMUEGE1FhByJxFDKBkaEII0KxwRVS0fAkM2JyggkK FhcYGRolJicoKSo0NTY3ODk6Q0RFRkdISUpTVFVWV1hZWmNkZWZnaGlqc3R1dnd4eXqDhIWG h4iJipKTlJWWl5iZmqKjpKWmp6ipqrKztLW2t7i5usLDxMXGx8jJytLT1NXW19jZ2uHi4+Tl 5ufo6erx8vP09fb3+Pn6/8QAHwEAAwEBAQEBAQEBAQAAAAAAAAECAwQFBgcICQoL/8QAtREA AgECBAQDBAcFBAQAAQJ3AAECAxEEBSExBhJBUQdhcRMiMoEIFEKRobHBCSMzUvAVYnLRChYk NOEl8RcYGRomJygpKjU2Nzg5OkNERUZHSElKU1RVVldYWVpjZGVmZ2hpanN0dXZ3eHl6goOE hYaHiImKkpOUlZaXmJmaoqOkpaanqKmqsrO0tba3uLm6wsPExcbHyMnK0tPU1dbX2Nna4uPk 3nOqryf+eEvH/jlX4PHfhIS(限于篇幅,省去部分内容...)F11n5mOMG3lwMf8AAKKKmOxDKeq+PPDtxPA0OuBIYz+8T7PL8/8A45Uj/ETwzDCWOqqV64EE v/xFFFO5q3eIknj7wy8AH9qrsccjyJeP/HKhsvGHhWyT93qwyflX9xLwP++KKKaqS5TFxVwv PiBoLRMbTUonkzgBoJQPr9ysXWvGGg30kVumpqoLh2byZP8A4iiikpNS0GtjpNM8c+FraHB1 f/yBL/8AEVej+InhUf8AMWXGf+feX/4iiihslmVrPj3wzLGETVlPPeCX/wCIrMl8YeH5rdBD rCLInIzBL/8AEUUU7msNyRPGugi2/eaohbG5R5EnHt9ym3vjPw3JZAHUtp2hvlhk6/8AfFFF RzNNWJfvT1Kz+LvDctsBHqW0p0Bik/8AiKrr4v0HH/H+P+/Mn+FFFZz3Gf/Z ------=_NextPart_5EA4329B_0FBAC2B8_51634C9D Content-Type: application/octet-stream; charset=\"gb18030\"; name=\"test.docx\" Content-Disposition: attachment; filename=\"test.docx\" Content-Transfer-Encoding: base64 UEsDBBQABgAIAAAAIQCshlBXjgEAAMAFAAATAAgCW0NvbnRlbnRfVHlwZXNdLnhtbCCiBAIo oAACAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA AAAAAAAAAAAAAAAAA(限于篇幅,省去部分内容...) V5cAEAAOECAAARAAAAAAAAAAAAAAAAAHyyAABk b2NQcm9wcy9jb3JlLnhtbFBLAQItABQABgAIAAAAIQAKRdqw1gcAAGM8AAAPAAAAAAAAAAAA AAAAACO1AAB3b3JkL3N0eWxlcy54bWxQSwECLQAUAAYACAAAACEAISmu3xsCAADFBQAAEgAA AAAAAAAAAAAAAAAmvQAAd29yZC9mb250VGFibGUueG1sUEsBAi0AFAAGAAgAAAAhAMB9S4Zw AQAAxQIAABAAAAAAAAAAAAAAAAAAcb8AAGRvY1Byb3BzL2FwcC54bWxQSwUGAAAAAA0ADQBM AwAAF8IAAAAA ------=_NextPart_5EA4329B_0FBAC2B8_51634C9D-- . 我们如何去解析这样的邮件格式呢? 这封邮件内容主要由两部分组成,第一部分是“OK”关键字,第二部分是邮件内容,邮件内容以 点 + \\r\\n 结束。其中邮件内容中前面一部分是一个的 tag 和 tag 值,我们可以从这些 tag 中得到邮件的 MessageID、收件人姓名和地址、发件人姓名和地址、邮件主题,例如: From: \"=?gb18030?B?1/PRp7fG?=\" To: \"=?gb18030?B?dGVzdGZvcm15Ym9vaw==?=\" Subject: =?gb18030?B?suLK1NPKvP4=?= Date: Sat, 25 Apr 2020 20:52:43 +0800 Message-ID: 其中像邮件的收发人姓名(From 和 To)使用了 base64 编码,我们使用 base64 解码即可还原其内容。 Content-Type: multipart/mixed; 说明邮件有多个部分组成。 我们先根据boundary=\"----=_NextPart_5EA4329B_0FBAC2B8_71508FA9\";中指定的 ----=_NextPart_5EA4329B_0FBAC2B8_71508FA9 分隔符得到除了邮件附件内容外的邮件正文内容,一共有两段: 正文段一 Content-Type: text/plain; charset=\"gb18030\" Content-Transfer-Encoding: base64 1eLKx9K7t+Ky4srU08q8/qOsuqzT0MG9uPa4vbz+oaM= 这段内容为纯文本格式(text/plain),使用 base64 编码,字符集格式为 gb18030,解码之后得到正文即: 正文段二 Content-Type: text/html; charset=\"gb18030\" Content-Transfer-Encoding: base64 PG1ldGEgaHR0cC1lcXVpdj0iQ29udGVudC1UeXBlIiBjb250ZW50PSJ0ZXh0L2h0bWw7IGNo YXJzZXQ9R0IxODAzMCI+PGRpdj7V4srH0ru34rLiytTTyrz+o6y6rNPQwb249ri9vP6hozwv ZGl2Pg== 这段内容为富文本格式(text/html),使用 base64 编码,字符集格式为 gb18030,解码之后得到正文即邮件中的那个带超级链接的英语广告,这是我使用的 163 邮件服务器自动插入到邮件正文中的: 接下来就是两个附件的内容了,使用的编码格式也是 base64,我们使用 base64 解码还原成 ASCII 字节流后作为文件的内容,再取 tag 中附件文件名生成对应的文件即可还原成附件内容。 完整的邮件解码及 POP3 协议收发示例代码请在【高性能服务器开发】公众号后台回复关键字“POP3”。 上文我们介绍了 POP3 协议常用的命令,POP3 完整的命令读者可以参考 rfc1939 文档 https://tools.ietf.org/html/rfc1939。 邮件客户端 上面我们介绍了 POP3 和 SMTP 协议,IMAP 与此类似这里就不再介绍了,读者可以参考 rfc3501:https://tools.ietf.org/html/rfc3501 。 除了上面说的三种协议,邮件还有使用 Exchange 协议的,具体可以参考这里:https://docs.microsoft.com/en-us/openspecs/exchange_server_protocols/ms-asemail/f3d27369-e0f5-4164-aa5e-9b1abda16f5f。 在理解了上述邮件协议之后,我们就可以编写自己的邮件客户端了,且可以自由定制邮件展示功能(如上文中 163 邮箱在收到的邮件内部插入自定义英语广告)。 如需下载本站全部技术文章,可以在【高性能服务器开发】公众号回复关键字“文章下载”即可。最近更新时间: 2020-10-02 21:50:52 "},"articles/服务器开发案例实战/从零开发一个WebSocket服务器.html":{"url":"articles/服务器开发案例实战/从零开发一个WebSocket服务器.html","title":"从零开发一个WebSocket服务器","keywords":"","body":"从零开发一个WebSocket服务器 WebSocket 协议是为了解决 http 协议的无状态、短连接(通常是)和服务端无法主动给客户端推送数据等问题而开发的新型协议,其通信基础也是基于 TCP。由于较旧的浏览器可能不支持 WebSocket 协议,所以使用 WebSocket 协议的通信双方在进行 TCP 三次握手之后,还要再额外地进行一次握手,这一次的握手通信双方的报文格式是基于 HTTP 协议改造的。 WebSocket 握手过程 TCP 三次握手的过程我们就不在这里赘述了,任何一本网络通信书籍上都有详细的介绍。我们这里来介绍一下 WebSocket 通信最后一次的握手过程。 握手开始后,一方给另外一方发送一个 http 协议格式的报文,这个报文格式大致如下: GET /realtime HTTP/1.1\\r\\n Host: 127.0.0.1:9989\\r\\n Connection: Upgrade\\r\\n Pragma: no-cache\\r\\n Cache-Control: no-cache\\r\\n User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64)\\r\\n Upgrade: websocket\\r\\n Origin: http://xyz.com\\r\\n Sec-WebSocket-Version: 13\\r\\n Accept-Encoding: gzip, deflate, br\\r\\n Accept-Language: zh-CN,zh;q=0.9,en;q=0.8\\r\\n Sec-WebSocket-Key: IqcAWodjyPDJuhGgZwkpKg==\\r\\n Sec-WebSocket-Extensions: permessage-deflate; client_max_window_bits\\r\\n \\r\\n 对这个格式有如下要求: 握手必须是一个有效的 HTTP 请求; 请求的方法必须为 GET,且 HTTP 版本必须是 1.1; 请求必须包含 Host 字段信息; 请求必须包含 Upgrade字段信息,值必须为 websocket; 请求必须包含 Connection 字段信息,值必须为 Upgrade; 请求必须包含 Sec-WebSocket-Key 字段,该字段值是客户端的标识编码成 base64 格式; 请求必须包含 Sec-WebSocket-Version 字段信息,值必须为 13; 请求必须包含 Origin 字段; 请求可能包含 Sec-WebSocket-Protocol 字段,规定子协议; 请求可能包含 Sec-WebSocket-Extensions字段规定协议扩展; 请求可能包含其他字段,如 cookie 等。 对端收到该数据包后如果支持 WebSocket 协议,会回复一个 http 格式的应答,这个应答报文的格式大致如下: HTTP/1.1 101 Switching Protocols\\r\\n Upgrade: websocket\\r\\n Connection: Upgrade\\r\\n Sec-WebSocket-Accept: 5wC5L6joP6tl31zpj9OlCNv9Jy4=\\r\\n \\r\\n 上面列出了应答报文中必须包含的几个字段和对应的值,即 Upgrade、Connection、Sec-WebSocket-Accept,注意:第一行必须是 HTTP/1.1 101 Switching Protocols\\r\\n。 对于字段 Sec-WebSocket-Accept 字段,其值是根据对端传过来的 Sec-WebSocket-Key 的值经过一定的算法计算出来的,这样应答的双方才能匹配。算法如下: 将 Sec-WebSocket-Key 值与固定字符串“258EAFA5-E914-47DA-95CA-C5AB0DC85B11” 进行拼接; 将拼接后的字符串进行 SHA-1 处理,然后将结果再进行 base64 编码。 算法公式: mask = \"258EAFA5-E914-47DA-95CA-C5AB0DC85B11\"; // 这是算法中要用到的固定字符串 accept = base64( sha1( Sec-WebSocket-Key + mask ) ); 我用 C++ 实现了该算法: namespace uWS { struct WebSocketHandshake { template struct static_for { void operator()(uint32_t *a, uint32_t *b) { static_for()(a, b); T::template f(a, b); } }; template struct static_for { void operator()(uint32_t *a, uint32_t *hash) {} }; template struct Sha1Loop { static inline uint32_t rol(uint32_t value, size_t bits) {return (value > (32 - bits));} static inline uint32_t blk(uint32_t b[16], size_t i) { return rol(b[(i + 13) & 15] ^ b[(i + 8) & 15] ^ b[(i + 2) & 15] ^ b[i], 1); } template static inline void f(uint32_t *a, uint32_t *b) { switch (state) { case 1: a[i % 5] += ((a[(3 + i) % 5] & (a[(2 + i) % 5] ^ a[(1 + i) % 5])) ^ a[(1 + i) % 5]) + b[i] + 0x5a827999 + rol(a[(4 + i) % 5], 5); a[(3 + i) % 5] = rol(a[(3 + i) % 5], 30); break; case 2: b[i] = blk(b, i); a[(1 + i) % 5] += ((a[(4 + i) % 5] & (a[(3 + i) % 5] ^ a[(2 + i) % 5])) ^ a[(2 + i) % 5]) + b[i] + 0x5a827999 + rol(a[(5 + i) % 5], 5); a[(4 + i) % 5] = rol(a[(4 + i) % 5], 30); break; case 3: b[(i + 4) % 16] = blk(b, (i + 4) % 16); a[i % 5] += (a[(3 + i) % 5] ^ a[(2 + i) % 5] ^ a[(1 + i) % 5]) + b[(i + 4) % 16] + 0x6ed9eba1 + rol(a[(4 + i) % 5], 5); a[(3 + i) % 5] = rol(a[(3 + i) % 5], 30); break; case 4: b[(i + 8) % 16] = blk(b, (i + 8) % 16); a[i % 5] += (((a[(3 + i) % 5] | a[(2 + i) % 5]) & a[(1 + i) % 5]) | (a[(3 + i) % 5] & a[(2 + i) % 5])) + b[(i + 8) % 16] + 0x8f1bbcdc + rol(a[(4 + i) % 5], 5); a[(3 + i) % 5] = rol(a[(3 + i) % 5], 30); break; case 5: b[(i + 12) % 16] = blk(b, (i + 12) % 16); a[i % 5] += (a[(3 + i) % 5] ^ a[(2 + i) % 5] ^ a[(1 + i) % 5]) + b[(i + 12) % 16] + 0xca62c1d6 + rol(a[(4 + i) % 5], 5); a[(3 + i) % 5] = rol(a[(3 + i) % 5], 30); break; case 6: b[i] += a[4 - i]; } } }; /** * sha1 函数的实现 */ static inline void sha1(uint32_t hash[5], uint32_t b[16]) { uint32_t a[5] = {hash[4], hash[3], hash[2], hash[1], hash[0]}; static_for>()(a, b); static_for>()(a, b); static_for>()(a, b); static_for>()(a, b); static_for>()(a, b); static_for>()(a, hash); } /** * base64 编码函数 */ static inline void base64(unsigned char *src, char *dst) { const char *b64 = \"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/\"; for (int i = 0; i > 2) & 63]; *dst++ = b64[((src[i] & 3) > 4)]; *dst++ = b64[((src[i + 1] & 15) > 6)]; *dst++ = b64[src[i + 2] & 63]; } *dst++ = b64[(src[18] >> 2) & 63]; *dst++ = b64[((src[18] & 3) > 4)]; *dst++ = b64[((src[19] & 15) > 8) & 0xff; bytes[1] = (tmp >> 16) & 0xff; bytes[0] = (tmp >> 24) & 0xff; } base64((unsigned char *) b_output, output); } }; 握手完成之后,通信双方就可以保持连接并相互发送数据了。 WebSocket 协议格式 WebSocket 协议格式的 RFC 文档可以参见:[]https://tools.ietf.org/html/rfc6455。 常听人说 WebSocket 协议是基于 http 协议的,因此我在刚接触 WebSocket 协议时总以为每个 WebSocket 数据包都是 http 格式,其实不然,WebSocket 协议除了上文中提到的这次握手过程中使用的数据格式是 http 协议格式,之后的通信双方使用的是另外一种自定义格式。每一个 WebSocket 数据包我们称之为一个 Frame(帧),其格式图如下: 我们来逐一介绍一下上文中各字段的含义: 第一个字节内容: FIN 标志,占第一个字节中的第一位(bit),即一字节中的最高位(一字节等于 8 位),该标志置 0 时表示当前包未结束后续有该包的分片,置 1 时表示当前包已结束后续无该包的分片。我们在解包时,如果发现该标志为 1,则需要将当前包的“包体”数据(即图中 Payload Data)缓存起来,与后续包分片组装在一起,才是一个完整的包体数据。 RSV1、RSV2、RSV3 每个占一位,一共三位,这三个位是保留字段(默认都是 0),你可以用它们作为通信的双方协商好的一些特殊标志; opCode 操作类型,占四位,目前操作类型及其取值如下: // 4 bits enum OpCode { //表示后续还有新的 Frame CONTINUATION_FRAME = 0x0, //包体是文本类型的Frame TEXT_FRAME = 0x1, //包体是二进制类型的 Frame BINARY_FRAME = 0x2, //保留值 RESERVED1 = 0x3, RESERVED2 = 0x4, RESERVED3 = 0x5, RESERVED4 = 0x6, RESERVED5 = 0x7, //建议对端关闭的 Frame CLOSE = 0x8, //心跳包中的 ping Frame PING = 0x9, //心跳包中的 pong Frame PONG = 0xA, //保留值 RESERVED6 = 0xB, RESERVED7 = 0xC, RESERVED8 = 0xD, RESERVED9 = 0xE, RESERVED10 = 0xF }; 第二个字节内容: mask 标志,占一位,该标志为 1 时,表明该 Frame 在包体长度字段后面携带 4 个字节的 masking-key 信息,为 0 时则没有 masking-key 信息。masking-key 信息下文会介绍。 Payload len,占七位,该字段表示包体的长度信息。由于 Payload length 值使用了一个字节的低七位(7 bit),因此其能表示的长度范围是 0 ~ 127,其中 126 和 127 被当做特殊标志使用。 当该字段值是 0~125 时,表示跟在 masking-key 字段后面的就是包体内容长度;当该值是 126 时,接下来的 2 个字节内容表示跟在 masking-key 字段后面的包体内容的长度(即图中的 Extended Payload Length)。由于 2 个字节最大表示的无符号整数是 0xFFFF(十进制是 65535, 编译器提供了一个宏 UINT16_MAX 来表示这个值)。如果包体长度超过 65535,包长度就记录不下了,此时应该将 Payload length 设置为 127,以使用更多的字节数来表示包体长度。 当 Payload length 是 127 时,接下来则用 8 个字节内容表示跟在 masking-key 字段后面的包体内容的长度(Extended Payload Length)。 总结起来,Payload length = 0 ~ 125,Extended Payload Length 不存在, 0 字节;Payload length = 126, Extended Payload Length 占 2 字节;Payload length = 127 时,Extended Payload Length 占 8 字节。 另外需要注意的是,当 Payload length = 125 或 126 时接下来存储实际包长的 2 字节或 8 字节,其值必须转换为网络字节序(Big Endian)。 Masking-key ,如果前面的 mask 标志设置成 1,则该字段存在,占 4 个字节;反之,则 Frame 中不存在存储 masking-key 字段的字节。 网络上一些资料说,客户端(主动发起握手请求的一方)给服务器(被动接受握手的另一方)发的 frame 信息(包信息),mask 标志必须是 1,而服务器给客户端发送的 frame 信息中 mask 标志是 0。因此,客户端发给服务器端的数据帧中存在 4 字节的 masking-key,而服务器端发给客户端的数据帧中不存在 masking-key 信息。 我在 Websocket 协议的 RFC 文档中并没有看到有这种强行规定,另外在研究了一些 websocket 库的实现后发现,此结论并不一定成立,客户端发送的数据也可能没有设置 mask 标志。 如果存在 masking-key 信息,则数据帧中的数据(图中 Payload Data)都是经过与 masking-key 进行运算后的内容。无论是将原始数据与 masking-key 运算后得到传输的数据,还是将传输的数据还原成原始数据,其算法都是一样的。算法如下: 假设: original-octet-i:为原始数据的第 i 字节。 transformed-octet-i:为转换后的数据的第 i 字节。 j:为i mod 4的结果。 masking-key-octet-j:为 mask key 第 j 字节。 算法描述为:original-octet-i 与 masking-key-octet-j 异或后,得到 transformed-octet-i。 j = i MOD 4 transformed-octet-i = original-octet-i XOR masking-key-octet-j 我用 C++ 实现了该算法: /** * @param src 函数调用前是原始需要传输的数据,函数调用后是mask或者unmask后的内容 * @param maskingKey 四字节 */ void maskAndUnmaskData(std::string& src, const char* maskingKey) { char j; for (size_t n = 0; n 使用上面的描述可能还不是太清楚,我们举个例子,假设有一个客户端发送给服务器的数据包,那么 mask = 1,即存在 4 字节的 masking-key,当包体数据长度在 0 ~ 125 之间时,该包的结构: 第 1 个字节第 0 位 => FIN 第 1 个字节第 1 ~ 3位 => RSV1 + RSV2 + RSV3 第 1 个字节第 4 ~ 7位 => opcode 第 2 个字节第 0 位 => mask(等于 1) 第 2 个字节第 1 ~ 7位 => 包体长度 第 3 ~ 6 个字节 => masking-key 第 7 个字节及以后 => 包体内容 这种情形,包头总共 6 个字节。 当包体数据长度大于125 且小于等于 UINT16_MAX 时,该包的结构: 第 1 个字节第 0 位 => FIN 第 1 个字节第 1 ~ 3位 => RSV1 + RSV2 + RSV3 第 1 个字节第 4 ~ 7位 => opcode 第 2 个字节第 0 位 => mask(等于 1) 第 2 个字节第 1 ~ 7位 => 开启扩展包头长度标志,值为 126 第 3 ~ 4 个字节 => 包头长度 第 5 ~ 8 个字节 => masking-key 第 9 个字节及以后 => 包体内容 这种情形,包头总共 8 个字节。 当包体数据长度大于 UINT16_MAX 时,该包的结构: 第 1 个字节第 0 位 => FIN 第 1 个字节第 1 ~ 3位 => RSV1 + RSV2 + RSV3 第 1 个字节第 4 ~ 7位 => opcode 第 2 个字节第 0 位 => mask(等于 1) 第 2 个字节第 1 ~ 7位 => 开启扩展包头长度标志,值为 127 第 3 ~ 10 个字节 => 包头长度 第 11 ~ 14 个字节 => masking-key 第 15 个字节及以后 => 包体内容 这种情形,包头总共 14 个字节。由于存储包体长度使用 8 字节存储(无符号),因此最大包体长度是 0xFFFFFFFFFFFFFFFF,这是一个非常大的数字,但实际开发中,我们用不到这么长的包体,且当包体超过一定值时,我们就应该分包(分片)了。 分包的逻辑经过前面的分析也很简单,假设将一个包分成 3 片,那么应将第一个和第二个包片的第一个字节的第一位 FIN 设置为 0,OpCode 设置为 CONTINUATION_FRAME(也是 0);第三个包片 FIN 设置为 1,表示该包至此就结束了,OpCode 设置为想要的类型(如 TEXT_FRAME、BINARY_FRAME 等)。对端收到该包时,如果发现标志 FIN = 0 或 OpCode = 0,将该包包体的数据暂存起来,直到收到 FIN = 1,OpCode ≠ 0 的包,将该包的数据与前面收到的数据放在一起,组装成一个完整的业务数据。示例代码如下: //某次解包后得到包体 payloadData,根据 FIN 标志判断, //如果 FIN = true,则说明一个完整的业务数据包已经收完整, //调用 processPackage() 函数处理该业务数据 //否则,暂存于 m_strParsedData 中 //每次处理完一个完整的业务包数据,即将暂存区m_strParsedData中的数据清空 if (FIN) { m_strParsedData.append(payloadData); processPackage(m_strParsedData); m_strParsedData.clear(); } else { m_strParsedData.append(payloadData); } WebSocket 压缩格式 WebSocket 对于包体也支持压缩的,是否需要开启压缩需要通信双方在握手时进行协商。让我们再看一下握手时主动发起一方的包内容: GET /realtime HTTP/1.1\\r\\n Host: 127.0.0.1:9989\\r\\n Connection: Upgrade\\r\\n Pragma: no-cache\\r\\n Cache-Control: no-cache\\r\\n User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64)\\r\\n Upgrade: websocket\\r\\n Origin: http://xyz.com\\r\\n Sec-WebSocket-Version: 13\\r\\n Accept-Encoding: gzip, deflate, br\\r\\n Accept-Language: zh-CN,zh;q=0.9,en;q=0.8\\r\\n Sec-WebSocket-Key: IqcAWodjyPDJuhGgZwkpKg==\\r\\n Sec-WebSocket-Extensions: permessage-deflate; client_max_window_bits\\r\\n \\r\\n 在该包中 Sec-WebSocket-Extensions 字段中有一个值 permessage-deflate,如果发起方支持压缩,在发起握手时将包中带有该标志,对端收到后,如果也支持压缩,则在应答的包也带有该字段,反之不带该标志即表示不支持压缩。例如: HTTP/1.1 101 Switching Protocols\\r\\n Upgrade: websocket\\r\\n Connection: Upgrade\\r\\n Sec-WebSocket-Accept: 5wC5L6joP6tl31zpj9OlCNv9Jy4=\\r\\n Sec-WebSocket-Extensions: permessage-deflate; client_no_context_takeover \\r\\n 如果双方都支持压缩,此后通信的包中的包体部分都是经过压缩后的,反之是未压缩过的。在解完包得到包体(即 Payload Data) 后,如果有握手时有压缩标志并且乙方也回复了支持压缩,则需要对该包体进行解压;同理,在发数据组装 WebSocket 包时,需要先将包体(即 Payload Data)进行压缩。 收到包需要解压示例代码: bool MyWebSocketSession::processPackage(const std::string& data) { std::string out; //m_bClientCompressed在握手确定是否支持压缩 if (m_bClientCompressed) { //解压 if (!ZlibUtil::inflate(data, out)) { LOGE(\"uncompress failed, dataLength: %d\", data.length()); return false; } } else out = data; //如果不需要解压,则out=data,反之则out是解压后的数据 LOGI(\"receid data: %s\", out.c_str()); return Process(out); } 对包进行压缩的算法: size_t dataLength = data.length(); std::string destbuf; if (m_bClientCompressed) { //按需压缩 if (!ZlibUtil::deflate(data, destbuf)) { LOGE(\"compress buf error, data: %s\", data.c_str()); return; } } else destbuf = data; LOGI(\"destbuf.length(): %d\", destbuf.length()); 压缩和解压算法即 gzip 压缩算法。 由于公众号文章最大是 5000 字数限制,本文原文一共有 12000 字,公众号发文时有省略。如果想获取完整的文章请在【高性能服务器开发】公众号后台回复 关键字【websocket协议分析】。获取文中完整源码,请在公众号后台回复关键字【websocket源码】。 如需下载本站全部技术文章,可以在【高性能服务器开发】公众号回复关键字“文章下载”即可。最近更新时间: 2020-10-02 21:56:18 "},"articles/服务器开发案例实战/1从一款多人联机实时对战游戏开始.html":{"url":"articles/服务器开发案例实战/1从一款多人联机实时对战游戏开始.html","title":"从零学习开源项目系列(一) 从一款多人联机实时对战游戏开始","keywords":"","body":"从零学习开源项目系列(一) 从一款多人联机实时对战游戏开始 写在前面的话 经常有学生或者初学者问我如何去阅读和学习一个开源软件的代码,也有不少朋友在工作岗位时面对前同事留下的项目,由于文档不完善、代码注释少、工程数量大,而无从下手。本文将来通过一个多人联机实时对战游戏——最后一战,来解答以上问题。 其实,我以上问题在我是一个学生时,我也同样因此而困惑,但是后来,我发现,对于文档缺失、注释缺失的项目,需要自己摸索,虽然是挑战,同时也是机遇——一个不错的学习机会。因为至少有代码,正如侯捷大师所说的的,“源码面前,了无秘密”,所以我们应该“read the fucking code”。 所以,这个系列的文章,我们分析“最后一战”这个游戏源码时,我们不会按照传统的思路:先介绍总结的程序结构,再介绍各个模块的细节,因为,当我们面对一套陌生的源码时,尤其是在文档缺失的情况下,我们根本无法开始就掌握这个项目的总体结构,我们只能从零开始一个个模块的对代码进行阅读和调试,所以我们这个系列的文章也按这个思路来分析,以真实的案例来教会新手一步步读懂一个开源项目的代码。 我们先来看下这个游戏的内容吧,下面给出游戏画面的部分截图: 这是一款类似于王者荣耀、dota之类的5v5实时RPG竞技游戏。 客户端的逻辑比较简单,主要是一些游戏特效和动画(基于Unity 3D),所以这里我们主要分析游戏的服务器端源码。 先介绍一下推荐的源码的运行和开发环境(我的配置): Windows 7 Visual Studio 2010 服务器端有非常多的模块,这里先截一张主要模块的项目图示: 从下一篇文章开始,我们将介绍如何学习这样的源码。 欢迎阅读下一篇《从零学习开源项目系列(二) 最后一战概况》。 源码下载方法: 微信搜索公众号『高性能服务器开发』(中文名:高性能服务器开发),关注公众号后,在公众号中回复『英雄联盟』,即可得到下载链接。(喷子和代码贩子请远离!) 如需下载本站全部技术文章,可以在【高性能服务器开发】公众号回复关键字“文章下载”即可。最近更新时间: 2020-10-02 22:20:44 "},"articles/服务器开发案例实战/2最后一战概况.html":{"url":"articles/服务器开发案例实战/2最后一战概况.html","title":"从零学习开源项目系列(二) 最后一战概况","keywords":"","body":"从零学习开源项目系列(二) 最后一战概况 这份代码我也是无意中来自一个朋友,据他说也是来源于互联网,服务器端代码原来是Linux版本的,但被厉害的大神修改成可以在Windows上运行。(如果不小心侵犯了您的版权,请联系我删除)。好在,这份代码中使用的大多数方法和接口都是可以跨Windows和Linux两个平台的,所以Linux开发下的朋友请不要感到不适,我们学习这份代码更多的不是纠结细节而是学习思路和原理。 游戏主solution文件用Visual Studio打开后如下图所示: 这里总共有10个工程项目,模块比较多。我们应该从何处入手呢?我们先看下源码目录: 我们进入Server目录,发现如下一个文件: 我们打开看一下内容: cd Bin\\x64\\Release start.bat 这个代码进入Bin\\x64\\Release目录,执行另外一个start.bat,我们进入这个目录去看下这个文件内容: taskkill /f /t /im redis-server.exe taskkill /f /t /im CSBattleMgr.exe taskkill /f /t /im SSBattleMgr.exe taskkill /f /t /im GSConsole.exe taskkill /f /t /im BalanceServer.exe taskkill /f /t /im LoginServer.exe taskkill /f /t /im GSKernel.exe taskkill /f /t /im RobotConsole.exe taskkill /f /t /im LogServer.exe ping -n 1 127.0>nul start /min \"redis-server\" \"redis-server.exe\" redis.conf ping -n 1 127.0>nul start /min \"redis-Logicserver\" \"redis-server.exe\" redis-logic.conf ping -n 1 127.0>nul echo \"start CSBattleMgr.exe\" start /min \"CSBattleMgr\" \"CSBattleMgr.exe\" ping -n 1 127.0>nul echo \"start SSBattleMgr.exe\" start /min \"SSBattleMgr\" \"SSBattleMgr.exe\" ping -n 1 127.0>nul echo \"start GSKernel.exe\" start /min \"GSKernel\" \"GSKernel.exe\" ping -n 1 127.0>nul echo \"start BalanceServer.exe\" start /min \"BalanceServer\" \"BalanceServer.exe\" ping -n 1 127.0>nul echo \"start LoginServer.exe\" start /min \"LoginServer\" \"LoginServer.exe\" ping -n 1 127.0>nul echo \"start LogServer.exe\" start /min \"LogServer\" \"LogServer.exe\" 通过这个脚本,我们得到了这个服务器项目的一些信息,这个服务器由以下一些服务组成: redis-server(启动两个) CSBattleMgr SSBattleMgr GSKernel BalanceServer LoginServer LogServer 这些服务器具体是做啥的,我现在也不知道,后面我们会教大家如何阅读。 另外在Bin/x64/Release/dbsql目录我们发下一些sql文件: 我们打开建表的createdb.sql: drop database if exists fball_gamedb; drop database if exists fball_logdb; drop database if exists fball_accountdb; create database fball_accountdb character set utf8; drop database if exists fball_chargedb; create database fball_chargedb character set utf8; drop database if exists fball_robedb; create database fball_robedb character set utf8; drop database if exists fball_gamedb_1; create database fball_gamedb_1 character set utf8; drop database if exists fball_gamedb_2; create database fball_gamedb_2 character set utf8; drop database if exists fball_gamedb_3; create database fball_gamedb_3 character set utf8; drop database if exists fball_logdb_1; create database fball_logdb_1 character set utf8; drop database if exists fball_logdb_2; create database fball_logdb_2 character set utf8; drop database if exists fball_logdb_3; create database fball_logdb_3 character set utf8; 也就是说,这套服务需要使用mysql,我们安装一个mysql,并执行这个Rebuild.bat把这些库创建一下,Rebuild.bat内容如下: @echo off :begin @echo ----------1, create all game database------------ mysql -uroot -p123321通过这个,我们发现这里mysql的root用户的密码123321。你在创建mysql时,需要建议将密码也设置成这个。 我们下载mysql community版本(免费的),下载地址: https://dev.mysql.com/downloads/mysql/ 类型我们选Microsoft: 注意,这套服务也使用了redis,我们不用专门下载和编译redis windows版本,我们的程序目录下已经提供了windows版本的redis的服务程序和配置文件: 我们搞清楚基本服务器概况后,各个服务的作用以及服务之间的关系如何,我们下一篇文章继续分析。 欢迎阅读下一篇《从零学习开源项目系列(三) CSBattleMgr和LogServer》。 源码下载方法: 微信搜索公众号『高性能服务器开发』(中文名:高性能服务器开发),关注公众号后,在公众号中回复『英雄联盟』,即可得到下载链接。(喷子和代码贩子请远离!) 如需下载本站全部技术文章,可以在【高性能服务器开发】公众号回复关键字“文章下载”即可。最近更新时间: 2020-10-02 22:21:33 "},"articles/服务器开发案例实战/3CSBattleMgr服务源码研究.html":{"url":"articles/服务器开发案例实战/3CSBattleMgr服务源码研究.html","title":"从零学习开源项目系列(三) CSBattleMgr服务源码研究","keywords":"","body":"从零学习开源项目系列(三) CSBattleMgr服务源码研究 服务器项目工程如下图所示: 如上图所示,这篇文章我们将介绍CSBattleMgr的情况,但是我们不会去研究这个服务器的特别细节的东西(这些细节我们将在后面的文章中介绍)。阅读一个未知的项目源码如果我们开始就纠结于各种细节,那么我们最终会陷入“横看成岭侧成峰,远近高低各不同”的尴尬境界,浪费时间不说,可能收获也是事倍功半。所以,尽管我们不熟悉这套代码,我们还是尽量先从整体来把握,先大致了解各个服务的功能,细节部分回头再针对性地去研究。 这个系列的第二篇文章《从零学习开源项目系列(二) 最后一战概况》中我们介绍了,这套游戏的服务需要使用redis和mysql,我们先看下mysql是否准备好了(mysql服务启动起来,数据库建表数据存在,具体细节请参考第二篇文章)。打开Windows的cmd程序,输入以下指令连接mysql: mysql -uroot -p123321 连接成功以后,如下图所示: 然后我们输入以下指令,查看我们需要的数据库是否创建成功: show databases; 这些都是基本的sql语句,如果您不熟悉的话,可能需要专门学习一下。 数据库创建成功后如下图所示: 至于数据库中的表是否创建成功,我们这里先不关注,后面我们实际用到哪张数据表,我们再去研究。 mysql没问题了,接下来我们要启动一下redis,通过第二篇文章我们知道redis需要启动两次,也就是一共两个redis进程,我们游戏服务中分别称为redis-server和redis-login-server(它们的配置文件信息不一样),我们可以在Server\\Bin\\x64\\Release目录下手动cmd命令行执行下列语句: start /min \"redis-server\" \"redis-server.exe\" redis.conf start /min \"redis-Logicserver\" \"redis-server.exe\" redis-logic.conf 但是这样比较麻烦,我将这两句拷贝出来,放入一个叫start-redis.bat文件中了,每次启动只要执行一下这个bat文件就可以: redis和redis-logic服务启动后如下图所示: 我们常见的redis服务都是linux下的源码,微软公司对redis源码进行了改造,出了一个Windows版本,稍微有点不尽人意(例如:Windows下没有完全与linux的fork()相匹配的API,所以只能用CreateProcess()去替代)。关于windows版本的redis源码官方下载地址为:https://github.com/MicrosoftArchive/redis/releases。 在启动好了mysql和redis后,我们现在正式来看一下CSBattleMgr这个服务。读者不禁可能要问,那么多服务,你怎么知道要先看这个服务呢?我们上一篇文章中也说过,我们再start.bat文件中发现除了redis以外,这是第三个需要启动的服务,所以我们先研究它(start.bat我们可以认为是源码作者为我们留下的部署步骤“文档”): 我们打开CSBattleMgr服务main.cpp文件,找到入口main函数,内容如下: int main(){ DbgLib::CDebugFx::SetExceptionHandler(true); DbgLib::CDebugFx::SetExceptionCallback(ExceptionCallback, NULL); GetCSKernelInstance(); GetCSUserMgrInstance(); GetBattleMgrInstance(); GetCSKernelInstance()->Initialize(); GetBattleMgrInstance()->Initialize(); GetCSUserMgrInstance()->Initialize(); GetCSKernelInstance()->Start(); mysql_library_init(0, NULL, NULL); GetCSKernelInstance()->MainLoop(); } 通过调试,我们发下这个函数大致做了以下任务: //1. 设置程序异常处理函数 //2. 初始化一系列单例对象 //3. 初始化mysql //4. 进入一个被称作“主循环”的无限循环 步骤1设置程序异常处理函数没有好介绍的,我们看一下步骤2初始化一系列单例对象,总共初始化了三个类的对象CCSKernel、CCSUserMgr和CCSBattleMgr。单例模式本身没啥好介绍的,但是有人要提单例模式的线程安全性,所以出现很多通过加锁的单例模式代码,我个人觉得没必要;认为要加锁的朋友可能认为单例对象如果在第一次初始化时同时被多个线程调用就会有问题,我觉得加锁带来的开销还不如像上面的代码一样,在整个程序初始化初期获取一下单例对象,让单例对象生成出来,后面即使多个线程获取这个单例对象也都是读操作,无需加锁。以GetCSKernelInstance();为例: CCSKernel* GetCSKernelInstance(){ return &CCSKernel::GetInstance(); } CCSKernel& CCSKernel::GetInstance(){ if (NULL == pInstance){ pInstance = new CCSKernel; } return *pInstance; } GetCSKernelInstance()->Initialize()的初始化动作其实是加载各种配置信息和事先设置一系列的回调函数和定时器: INT32 CCSKernel::Initialize() { //JJIAZ加载配置的时候 不要随便调整顺序 CCSCfgMgr::getInstance().Initalize(); INT32 n32Init = LoadCfg(); if (eNormal != n32Init) { ELOG(LOG_ERROR,\" loadCfg()............failed!\"); return n32Init; } if(m_sCSKernelCfg.un32MaxSSNum > 0 ) { m_psSSNetInfoList = new SSSNetInfo[m_sCSKernelCfg.un32MaxSSNum]; memset(m_psSSNetInfoList, 0, sizeof(SSSNetInfo) * m_sCSKernelCfg.un32MaxSSNum); m_psGSNetInfoList = new SGSNetInfo[m_sCSKernelCfg.un32MaxGSNum]; memset(m_psGSNetInfoList, 0, sizeof(SGSNetInfo) * m_sCSKernelCfg.un32MaxGSNum); m_psRCNetInfoList = new SRCNetInfo[10]; } m_GSMsgHandlerMap[GSToCS::eMsgToCSFromGS_AskRegiste] = std::bind(&CCSKernel::OnMsgFromGS_AskRegiste, this, std::placeholders::_1, std::placeholders::_2, std::placeholders::_3); m_GSMsgHandlerMap[GSToCS::eMsgToCSFromGS_AskPing] = std::bind(&CCSKernel::OnMsgFromGS_AskPing, this, std::placeholders::_1, std::placeholders::_2, std::placeholders::_3); m_GSMsgHandlerMap[GSToCS::eMsgToCSFromGS_ReportGCMsg] = std::bind(&CCSKernel::OnMsgFromGS_ReportGCMsg, this, std::placeholders::_1, std::placeholders::_2, std::placeholders::_3); m_SSMsgHandlerMap[SSToCS::eMsgToCSFromSS_AskPing] = std::bind(&CCSKernel::OnMsgFromSS_AskPing, this, std::placeholders::_1, std::placeholders::_2, std::placeholders::_3); AddTimer(std::bind(&CCSKernel::ProfileReport, this, std::placeholders::_1, std::placeholders::_2), 5000, true); return eNormal; } 如上图所示,这些配置信息都是游戏术语,包括各种技能、英雄、模型等信息。 GetBattleMgrInstance()->Initialize()其实是帮CSKernel对象启动一个定时器: INT32 CCSBattleMgr::Initialize(){ GetCSKernelInstance()->AddTimer(std::bind(&CCSMatchMgr::Update, m_pMatchMgr, std::placeholders::_1, std::placeholders::_2), c_matcherDelay, true); return eNormal; } GetCSUserMgrInstance()->Initialize()是初始化mysql和redis的一些相关信息,由于redis是做服务的缓存的,所以我们一般在项目中看到cacheServer这样的字眼指的都是redis: void CCSUserMgr::Initialize(){ SDBCfg cfgGameDb = CCSCfgMgr::getInstance().GetDBCfg(eDB_GameDb); SDBCfg cfgCdkeyDb=CCSCfgMgr::getInstance().GetDBCfg(eDB_CdkeyDb); m_UserCacheDBActiveWrapper = new DBActiveWrapper( std::bind(&CCSUserMgr::UserCacheDBAsynHandler, this, std::placeholders::_1), cfgGameDb, std::bind(&CCSUserMgr::DBAsyn_QueryWhenThreadBegin, this) ); m_UserCacheDBActiveWrapper->Start(); m_CdkeyWrapper = new DBActiveWrapper( std::bind(&CCSUserMgr::UserAskDBAsynHandler, this, std::placeholders::_1), cfgCdkeyDb, std::bind(&CCSUserMgr::CDKThreadBeginCallback, this) ); m_CdkeyWrapper->Start(); for (int i = 0; i Start(); m_pUserAskDBActiveWrapperVec.push_back(pThreadDBWrapper); } } 注意一点:不知道大家有没有发现,我们代码中大量使用C++11中的std::bind()这样函数,注意由于我们使用的Visual Studio版本是2010,2010这个版本是不支持C++11的,所以这里的std::bind不是C++11的,而是C++11发布之前的草案tr1中的,所以全部的命名空间应该是tr1::std::bind,其他的类似C++11的功能也是一样,所以你在代码中可以看到这样引入命名空间的语句: GetCSKernelInstance()->Start();是初始化所有的网络连接的Session管理器,所谓Session,中文译为“会话”,其下层对应网络通信的连接,每一路连接对应一个Session,而管理这些Session的对象就是Session Manager,在我们的代码中是CSNetSessionMgr,它继承自接口类INetSessionMgr: class CSNetSessionMgr : public INetSessionMgr { public: CSNetSessionMgr(); virtual ~CSNetSessionMgr(); public: virtual ISDSession* UCAPI CreateSession(ISDConnection* pConnection) { return NULL; /*重写*/} virtual ICliSession* UCAPI CreateConnectorSession(SESSION_TYPE type); virtual bool CreateConnector(SESSION_TYPE type, const char* ip, int port, int recvsize, int sendsize, int logicId); private: CSParser m_CSParser; }; 初始化CSNetSessionMgr的代码如下: INT32 CCSKernel::Start() { CSNetSessionMgr* pNetSession = new CSNetSessionMgr; GetBattleMgrInstance()->RegisterMsgHandle(m_SSMsgHandlerMap, m_GSMsgHandlerMap, m_GCMsgHandlerMap, m_RCMsgHandlerMap); GetCSUserMgrInstance()->RegisterMsgHandle(m_SSMsgHandlerMap, m_GSMsgHandlerMap, m_GCMsgHandlerMap, m_RCMsgHandlerMap); ELOG(LOG_INFO, \"success!\"); return 0; } 连接数据库成功以后,我们的CSBattleMgr程序的控制台会显示一行提示mysql连接成功: 读者看上图会发现,这些日志信息有三个颜色,出错信息使用红色,重要的正常信息使用绿色,一般的输出信息使用灰色。这是如何实现的呢?我们将在下一篇文章《从零学习开源项目系列(三) LogServer服务源码研究》中介绍具体实现原理,这也是一种不错的日志级别提醒方式。 介绍完了初始化流程,我们介绍一下这个服务的主体部分MainLoop()函数,先看一下整体代码: void CCSKernel::MainLoop(){ TIME_TICK tHeartBeatCDTick = 10; //侦听端口10002 INetSessionMgr::GetInstance()->CreateListener(m_sCSKernelCfg.n32GSNetListenerPort, 1024000, 10240000, 0, &gGateSessionFactory); //侦听端口10001 INetSessionMgr::GetInstance()->CreateListener(m_sCSKernelCfg.n32SSNetListenerPort, 1024000, 10240000, 1, &gSceneSessionFactory); //侦听端口10010 INetSessionMgr::GetInstance()->CreateListener(m_sCSKernelCfg.n32RCNetListenerPort, 1024000, 10240000, 2, &gRemoteConsoleFactory); //连接LogServer 1234端口 INetSessionMgr::GetInstance()->CreateConnector(ST_CLIENT_C2Log, m_sCSKernelCfg.LogAddress.c_str(), m_sCSKernelCfg.LogPort, 102400, 102400, 0); //连接redis 6379 if (m_sCSKernelCfg.redisAddress != \"0\"){ INetSessionMgr::GetInstance()->CreateConnector(ST_CLIENT_C2R, m_sCSKernelCfg.redisAddress.c_str(), m_sCSKernelCfg.redisPort, 102400, 102400, 0); } //连接redis 6380,也是redis-logic if (m_sCSKernelCfg.redisLogicAddress != \"0\"){ INetSessionMgr::GetInstance()->CreateConnector(ST_CLIENT_C2LogicRedis, m_sCSKernelCfg.redisLogicAddress.c_str(), m_sCSKernelCfg.redisLogicPort, 102400, 102400, 0); } while (true) { if (kbhit()) { static char CmdArray[1024] = { 0 }; static int CmdPos = 0; char CmdOne = getche(); CmdArray[CmdPos++] = CmdOne; bool bRet = 0; if (CmdPos >= 1024 || CmdOne == 13) { CmdArray[--CmdPos] = 0; bRet = DoUserCmd(CmdArray); CmdPos = 0; if (bRet) break; } } INetSessionMgr::GetInstance()->Update(); GetCSUserMgrInstance()->OnHeartBeatImmediately(); ++m_RunCounts; m_BattleTimer.Run(); Sleep(1); } } 这个函数虽然叫MainLoop(),但是实际MainLoop()只是后半部分,前半部分总共创建三个侦听端口和三个连接器,也就是所谓的Listener和Connector,这些对象都是由上文提到的CSNetSessionMgr管理,所谓Listener就是这个服务使用socket API bind()和listen()函数在某个地址+端口号的二元组上绑定,供其他程序连接(其他程序可能是其他服务程序也可能是客户端,具体是哪个,我们后面的文章再进一步挖掘),侦听端口统计如下: 侦听端口10002 侦听端口10001 侦听端口10010 连接器(Connector)也有三个,分别连接的服务和端口号是: 连接redis的6379号端口 连接redis-logic的6380端口 连接某服务的1234端口 这个1234端口到底是哪个服务的呢?通过代码我们可以看出是LogServer的,那么到底是不是LogServer的呢,我们后面具体求证一下。 INetSessionMgr::GetInstance()->CreateConnector( ST_CLIENT_C2Log, m_sCSKernelCfg.LogAddress.c_str(), m_sCSKernelCfg.LogPort, 102400, 102400, 0); 接着我们就正式进入了一个while循环: while (true) { if (kbhit()) { static char CmdArray[1024] = { 0 }; static int CmdPos = 0; char CmdOne = getche(); CmdArray[CmdPos++] = CmdOne; bool bRet = 0; if (CmdPos >= 1024 || CmdOne == 13) { CmdArray[--CmdPos] = 0; bRet = DoUserCmd(CmdArray); CmdPos = 0; if (bRet) break; } } INetSessionMgr::GetInstance()->Update(); GetCSUserMgrInstance()->OnHeartBeatImmediately(); ++m_RunCounts; m_BattleTimer.Run(); Sleep(1); } 循环具体做了啥,我们先看INetSessionMgr::GetInstance()->Update();代码: void INetSessionMgr::Update() { mNetModule->Run(); vector tempQueue; EnterCriticalSection(&mNetworkCs); tempQueue.swap(m_SafeQueue); LeaveCriticalSection(&mNetworkCs); for (auto it=tempQueue.begin();it!=tempQueue.end();++it){ char* pBuffer = (*it); int nType = *(((int*)pBuffer)+0); int nSessionID = *(((int*)pBuffer)+1); Send((SESSION_TYPE)nType,nSessionID,pBuffer+2*sizeof(int)); delete []pBuffer; } auto &map = m_AllSessions.GetPointerMap(); for (auto it=map.begin();it!=map.end();++it) { (*it)->Update(); } } 通过这段代码我们看出,这个函数先是使用std::vector对象的swap()方法把一个公共队列中的数据倒换到一个临时队列中,这是一个很常用的技巧,目的是减小锁的粒度:由于公共的队列需要被生产者和消费者同时使用,我们为了减小加锁的粒度和时间,把当前队列中已有的数据一次性倒换到消费者本地的一个临时队列中来,这样消费者就可以使用这个临时队列了,从而避免了每次都要通过加锁从公共队列中取数据了,提高了效率。接着,我们发现这个队列中的数据是一个个的Session对象,遍历这些Session对象个每个Session对象的连接的对端发数据,同时执行Session对象的Update()方法。具体发了些什么数据,我们后面的文章再研究。 我们再看一下循环中的第二个函数GetCSUserMgrInstance()->OnHeartBeatImmediately();,其代码如下: INT32 CCSUserMgr::OnHeartBeatImmediately() { OnTimeUpdate(); SynUserAskDBCallBack(); return eNormal; } 这些名字都是自解释的,先是同步时间,再同步数据库的一些操作: INT32 CCSUserMgr::SynUserAskDBCallBack(){ while (!m_DBCallbackQueue.empty()){ Buffer* pBuffer = NULL; m_DBCallbackQueue.try_pop(pBuffer); switch (pBuffer->m_LogLevel) { case DBToCS::eQueryUser_DBCallBack: SynHandleQueryUserCallback(pBuffer); break; case DBToCS::eQueryAllAccount_CallBack: SynHandleAllAccountCallback(pBuffer); break; case DBToCS::eMail_CallBack: SynHandleMailCallback(pBuffer); break; case DBToCS::eQueryNotice_CallBack: DBCallBack_QueryNotice(pBuffer); break; default: ELOG(LOG_WARNNING, \"not hv handler:%d\", pBuffer->m_LogLevel); break; } if (pBuffer){ m_DBCallbackQueuePool.ReleaseObejct(pBuffer); } } return 0; } 再看一下while循环中第三个函数m_BattleTimer.Run();其代码如下: void CBattleTimer::Run(){ TimeKey nowTime = GetInternalTime(); while(!m_ThreadTimerQueue.empty()){ ThreadTimer& sThreadTimer = m_ThreadTimerQueue.top(); if (!m_InvalidTimerSet.empty()){ auto iter = m_InvalidTimerSet.find(sThreadTimer.sequence); if (iter != m_InvalidTimerSet.end()){ m_InvalidTimerSet.erase(iter); m_ThreadTimerQueue.pop(); continue; } } if (nowTime >= sThreadTimer.nextexpiredTime){ m_PendingTimer.push_back(sThreadTimer); m_ThreadTimerQueue.pop(); } else{ break; } } if (!m_PendingTimer.empty()){ for (auto iter = m_PendingTimer.begin(); iter != m_PendingTimer.end(); ++iter){ ThreadTimer& sThreadTimer = *iter; nowTime = GetInternalTime(); int64_t tickSpan = nowTime - sThreadTimer.lastHandleTime; sThreadTimer.pHeartbeatCallback(nowTime, tickSpan); if (sThreadTimer.ifPersist){ TimeKey newTime = nowTime + sThreadTimer.interval; sThreadTimer.lastHandleTime = nowTime; sThreadTimer.nextexpiredTime = newTime; m_ThreadTimerQueue.push(sThreadTimer); } } m_PendingTimer.clear(); } if (!m_ToAddTimer.empty()){ for (auto iter = m_ToAddTimer.begin(); iter != m_ToAddTimer.end(); ++iter){ m_ThreadTimerQueue.push(*iter); } m_ToAddTimer.clear(); } } 这也是一个与时间有关的操作。具体细节我们也在后面文章中介绍。 CSBattleMgr服务跑起来之后,cmd窗口显示如下: 上图中我们看到Mysql和redis服务均已连上,但是程序会一直提示连接127.0.0.1:1234端口连不上。由此我们断定,这个使用1234端口的服务没有启动。这不是我们介绍的重点,重点是说明这个服务会定时自动重连这个1234端口,自动重连机制是我们做服务器开发必须熟练开发的一个功能。所以我建议大家好好看一看这一块的代码。这里我带着大家简单梳理一遍吧。 首先,我们根据提示找到INetSessionMgr::LogText的42行,并在那里加一个断点: 很快,由于重连机制,触发这个断点,我们看下此时的调用堆栈: 我们切换到如图箭头所示的堆栈处代码: 箭头所示说明是mNetModule->Run();调用产生的日志输出。我们看下这个的调用: bool CUCODENetWin::Run(INT32 nCount) { CConnDataMgr::Instance()->RunConection(); do { // #ifdef UCODENET_HAS_GATHER_SEND // #pragma message(\"[preconfig]sdnet collect buffer, has a internal timer\") // if (m_pTimerModule) // { // m_pTimerModule->Run(); // } // #endif #ifdef UCODENET_HAS_GATHER_SEND static INT32 sendCnt = 0; ++sendCnt; if (sendCnt == 10) { sendCnt = 0; UINT32 now = GetTickCount(); if (now 50) { m_dwLastTick = now; FlushBufferedData(); } } #endif // //SNetEvent stEvent; SNetEvent *pstEvent = CEventMgr::Instance()->PopFrontNetEvt(); if (pstEvent == NULL) { return false; } SNetEvent & stEvent = *pstEvent; switch(stEvent.nType) { case NETEVT_RECV: _ProcRecvEvt(&stEvent.stUn.stRecv); break; case NETEVT_SEND: _ProcSendEvt(&stEvent.stUn.stSend); break; case NETEVT_ESTABLISH: _ProcEstablishEvt(&stEvent.stUn.stEstablish); break; case NETEVT_ASSOCIATE: _ProcAssociateEvt(&stEvent.stUn.stAssociate); break; case NETEVT_TERMINATE: _ProcTerminateEvt(&stEvent.stUn.stTerminate); break; case NETEVT_CONN_ERR: _ProcConnErrEvt(&stEvent.stUn.stConnErr); break; case NETEVT_ERROR: _ProcErrorEvt(&stEvent.stUn.stError); break; case NETEVT_BIND_ERR: _ProcBindErrEvt(&stEvent.stUn.stBindErr); break; default: SDASSERT(false); break; } CEventMgr::Instance()->ReleaseNetEvt(pstEvent); }while(--nCount != 0); return true; } 我们看到SNetEvent *pstEvent = CEventMgr::Instance()->PopFrontNetEvt();时,看到这里我们大致可以看出这又是一个生产者消费者模型,只不过这里是消费者——从队列中取出数据,对应的switch-case分支是: case NETEVT_CONN_ERR: _ProcConnErrEvt(&stEvent.stUn.stConnErr); 即连接失败。那么在哪里连接的呢?我们只需要看看这个队列的生产者在哪里就能找到了,因为连接不成功,往队列中放入一条连接出错的数据,我们看一下CEventMgr::Instance()->PopFrontNetEvt()的实现,找到具体的队列名称: /** * @brief 获取一个未处理的网络事件(目前为最先插入的网络事件) * @return 返回一个未处理的网络事件.如果处理失败,返回NULL * @remark 由于此类只有在主线程中调用,所以,此函数内部并未保证线程安全 */ inline SNetEvent* PopFrontNetEvt() { return (SNetEvent*)m_oEvtQueue.PopFront(); } 通过这段代码我们发现队列的名字叫m_oEvtQueue,我们通过搜索这个队列的名字找到生产者,然后在生产者往队列中加入数据那里加上一个断点: 等断点触发以后,我们看下此时的调用堆栈: 我们切换到上图中箭头所指向的代码处: 到这里我们基本上认识了,这里连接使用的异步connect(),即在线程A中将连接socket,然后使用WSAEventSelect绑定该socket并设置该socket为非阻塞模式,等连接有结果了(成功或失败)使用Windows API WSAEnumNetworkEvents去检测这个socket的连接事件(FD_CONNECT),然后将判断结果加入队列m_oEvtQueue中,另外一个线程B从队列中取出判断结果打印出日志。如果您不清楚这个流程,请学习一下异步connect的使用方法和WSAEventSelect、WSAEnumNetworkEvents的用法。那么这个异步connect在哪里呢?我们搜索一下socket API connect函数(其实我可以一开始就搜索connect函数的,但是我之所以不这么做是想让您了解一下我研究一个不熟悉的项目代码的思路),得到如下图: 我们在上述标红的地方加个断点: 通过上图中的端口信息1234,我们验证了的确是上文说的流程。然后我们观察一下这个调用堆栈: 发现这里又是一个消费者,又存在一个队列! 同样的道理,我们通过队列名称m_oReqQueue找到生产者: 我们看下这个时候的生产者的调用堆栈: 切换到如图所示的代码处: bool ICliSession::Reconnect() { if (IsHadRecon() && mReconnectTag) { UINT32 curTime = GetTickCount(); if (curTime>mReconTime) { mReconTime = curTime+10000; if (m_poConnector->ReConnect()) { //printf(\"client reconnect server(%s)...\\n\",mRemoteEndPointer.c_str()); ResetRecon(); return true; } } } return false; } 在这里我们终于可以好好看一下重连的逻辑如何设计了。具体代码读者自己分析哈,限于篇幅这里就不介绍了。 看到这里,可能很多读者在对照我提供的代码时,会产生一个困难:同样的代码为啥在我手中可以这样分析,但是到你们手中可能就磕磕绊绊了?只能说经验和自我学习这是相辅相成的过程,例如上文中说的生产者消费者模式、任务队列,我曾经也和你们一样,也不熟悉这些东西,但是当我知道这些东西时我就去学习这些我认为的“基础”知识,并且反复练习,这样也就慢慢积累经验了。所以,孔子说的没错:学而不思则罔,思而不学则殆。什么时候该去学习,什么时候该去思考,古人诚不欺我也。 到这里我们也大致清楚了CSBattleMgr做了哪些事情。后面我们把所有的服务都过一遍之后再从整体来介绍。下一篇文章我们将继续研究这个侦听1234端口的LogServer,敬请期待。 限于作者经验水平有限,文章中可能有错漏的地方,欢迎批评指正。 欢迎阅读下一篇《从零学习开源项目系列(四) LogServer源码探究》。 源码下载方法: 微信搜索公众号 『高性能服务器开发』(中文名:高性能服务器开发),关注公众号后,在公众号中回复『英雄联盟』 ,即可得到下载链接。(喷子和代码贩子请远离!) 如需下载本站全部技术文章,可以在【高性能服务器开发】公众号回复关键字“文章下载”即可。最近更新时间: 2020-10-08 15:49:47 "},"articles/服务器开发案例实战/4LogServer源码探究.html":{"url":"articles/服务器开发案例实战/4LogServer源码探究.html","title":"从零学习开源项目系列(四)LogServer源码探究","keywords":"","body":"从零学习开源项目系列(四)LogServer源码探究 这是从零学习开源项目的第四篇,上一篇是《从零学习开源项目系列(三) CSBattleMgr服务源码研究》,这篇文章我们一起来学习LogServer,中文意思可能是“日志服务器”。那么这个日志服务器到底做了哪些工作呢? 我们在Visual Studio中将LogServer设置为启动项,然后按F5将LogServer启动起来,启动成功后显示如下图: 从上图中,我们可以到大致做了三件事: 1. 创建一个侦听端口(端口号1234) 2. 连接mysql数据库 3. 初始化日志处理程序 我们来验证一下这三件事的细节。我们再Visual Studio中将程序中断(【调试】菜单-【全部中断】,快捷键Ctrl + Alt + Break)。然后在线程窗口查看这个程序所有的线程,如下图所示: 所有用红色旗帜标记的线程都是用户线程,我们可以查看这些线程的调用堆栈。我们从最上面的主线程开始: 切换到main函数,我们可以看出这里是一个循环: int main() { auto res = CLogHandler::GetInstance().Init(); if (res) { while(true) { INetSessionMgr::GetInstance()->Update(); Sleep(1); } } return 0; } 这里一个是初始化动作,一个循环中Update动作,它们具体做了些什么,我们先不管,我们先看其他线程做了什么,再回过头来看这里的代码。 我们接着看下一个线程的内容: 从调用堆栈来看,这是一个使用boost::thread启动的线程,这个线程函数代码如下: void Active::Run() { if (m_BeginInThreadCallback){ m_BeginInThreadCallback(); } while (true){ Consume(); } } 我们先看下这个线程函数做了什么,主要是m_BeginInThreadCallback和Consume()函数,看下Consume()函数: void Active::Consume(){ boost::mutex::scoped_lock lock(m_IOMutex); while(m_Queue.empty()){ m_ConditionVar.wait(lock); } m_SwapQueue.swap(m_Queue); lock.unlock(); while(!m_SwapQueue.empty()){ Buffer* pBuffer = m_SwapQueue.front(); m_SwapQueue.pop(); m_Callback(pBuffer); --m_PendingWorkNum; if (pBuffer){ m_pBufferPool.ReleaseObejct(pBuffer); } } } 这段代码很好理解,先使用条件变量挂起当前线程,条件变量触发后,如果消费者和生产者共有队列m_Queue中有数据,将公用的队列m_Queue临时倒换到本地的一个局部队列m_SwapQueue中,然后挨个处理队列m_SwapQueue中的数据。 这个线程在哪里创建的呢?通过搜索线程函数,我们找到如下代码: void Active::Start(){ bool ifHvTimer = !m_ThreadTimer.IsEmpty(); if (ifHvTimer){ m_Thread = boost::thread(&Active::RunWithUpdate, this); } else{ m_Thread = boost::thread(&Active::Run, this); } m_ThreadID = get_native_thread_id(m_Thread); char sThreadName[30]; sprintf(sThreadName, \"%s-%d\", \"Actor-Run\", GetActorID()); _SetThreadName(m_ThreadID, sThreadName); } 在上面这个函数中添加断点,重启下程序,很快会触发断点,我们看下断点触发时的调用堆栈: 通过调用堆栈,我们发现这个线程在一个全局变量的构造函数中初始化的,这个全局变量在DllMain()函数中初始化: 而这个dll是ELogging项目生成的: 也就是说,这是一个与日志处理相关的线程。生产者产生日志记录,然后由这个线程作为消费者,来处理日志。 我们接着看下一个线程的内容: void CConnectCtrl::OnExecute() { while(!m_bTerminate) { _ProcRequests(); _ProcEvents(); //CCPSockMgr::Instance()->CheckDelayRelease(); Sleep(1); } } 这也是一个循环,先看下_ProcRequests()函数: void CConnectCtrl::_ProcRequests() { while(m_dwSockCount PushConnErrEvt(WSAGetLastError(), pstConnReq->dwConnectorID); m_oFreeQueue.PushBack(pstConnReq); break; } //// 2009-04-02 cwy modify for general use if (pstConnReq->bNeedBind) { if ( false == BindAddress(hSock, pstConnReq->pszBindIP, pstConnReq->wBindPort) ) { _OnSockError(hSock, pstConnReq); break; } } if (g_bNodelay) { const CHAR szOpt = 1; if (0 != ::setsockopt(hSock, IPPROTO_TCP, TCP_NODELAY, (char *)&szOpt, sizeof(char))) { WARN(_SDT(\"setsockopt for new socket on UpdateConetext failed, errno=%d\"), ::WSAGetLastError()); } } WSAEVENT hEvent = WSACreateEvent(); if(WSA_INVALID_EVENT == hEvent) { _OnSockError(hSock, pstConnReq); break; } if(SOCKET_ERROR == WSAEventSelect(hSock, hEvent, FD_CONNECT)) { _OnSockError(hSock, pstConnReq); WSACloseEvent(hEvent); break; } sockaddr_in stAddr = {0}; stAddr.sin_family = AF_INET; stAddr.sin_addr.s_addr = pstConnReq->dwIP; stAddr.sin_port = htons(pstConnReq->wPort); if( SOCKET_ERROR == connect(hSock, (sockaddr*)&stAddr, sizeof(stAddr)) ) { if(WSAEWOULDBLOCK != WSAGetLastError()) { _OnSockError(hSock, pstConnReq); WSACloseEvent(hEvent); break; } } m_pProcReqArray[m_dwSockCount] = pstConnReq; m_pSockArray[m_dwSockCount] = hSock; m_pEventsArray[m_dwSockCount] = hEvent; ++m_dwSockCount; } } 这段函数的逻辑也是比较容易懂,先从一个队列中取出数据,然后处理,只不过这些数据都是与连接相关的信息。 再看下while循环中第二个函数_ProcEvents: void CConnectCtrl::_ProcEvents() { if(0 == m_dwSockCount) { return; } WSANETWORKEVENTS stNetworkEvents; WSAEVENT* pEvents; UINT32 dwCount; UINT32 dwIndex; UINT32 dwStart = 0; do { pEvents = &m_pEventsArray[dwStart]; if(dwStart + WSA_MAXIMUM_WAIT_EVENTS > m_dwSockCount) { dwCount = m_dwSockCount - dwStart; } else { dwCount = WSA_MAXIMUM_WAIT_EVENTS; } dwIndex = WSAWaitForMultipleEvents(dwCount, pEvents, false, 0, false); if(WSA_WAIT_FAILED == dwIndex || WSA_WAIT_TIMEOUT == dwIndex) { dwStart += dwCount; continue; } dwIndex -= WSA_WAIT_EVENT_0; dwIndex += dwStart; ++dwStart; SDASSERT(m_pProcReqArray[dwIndex] != NULL && m_pSockArray[dwIndex] != INVALID_SOCKET && m_pEventsArray[dwIndex] != WSA_INVALID_EVENT); if(SOCKET_ERROR == WSAEnumNetworkEvents(m_pSockArray[dwIndex], m_pEventsArray[dwIndex], &stNetworkEvents)) { if(WSAEWOULDBLOCK != WSAGetLastError()) { CEventMgr::Instance()->PushConnErrEvt(WSAGetLastError(), m_pProcReqArray[dwIndex]->dwConnectorID); _CloseEvent(dwIndex); } continue; } if(stNetworkEvents.lNetworkEvents & FD_CONNECT) { if(stNetworkEvents.iErrorCode[FD_CONNECT_BIT] != 0) { CEventMgr::Instance()->PushConnErrEvt(stNetworkEvents.iErrorCode[FD_CONNECT_BIT], m_pProcReqArray[dwIndex]->dwConnectorID); _CloseEvent(dwIndex); continue; } // // 连接成功 // SConnReq* pstReq = m_pProcReqArray[dwIndex]; CConnData * pConnData = CConnDataMgr::Instance()->Alloc(pstReq->dwRecvBufSize, pstReq->dwSendBufSize); if (pConnData == NULL) { CRITICAL(_SDT(\"CConnectCtrl::_ProcEvents, create ConnData failed\")); CEventMgr::Instance()->PushConnErrEvt(0, pstReq->dwConnectorID); _CloseEvent(dwIndex); continue; } CCPSock *poSock = &pConnData->sock; CUCConnection * poConnection = &pConnData->connection; poSock->SetSock(m_pSockArray[dwIndex]); m_oFreeQueue.PushBack(m_pProcReqArray[dwIndex]); WSACloseEvent(m_pEventsArray[dwIndex]); m_pProcReqArray[dwIndex] = NULL; m_pSockArray[dwIndex] = INVALID_SOCKET; m_pEventsArray[dwIndex] = WSA_INVALID_EVENT; sockaddr_in stAddr = {0}; INT32 nAddrLen = sizeof(stAddr); getsockname(poSock->GetSock(), (sockaddr*)&stAddr, &nAddrLen); poConnection->SetAccept(false); poConnection->SetParentID(pstReq->dwConnectorID); poConnection->SetSession(pstReq->poSession); poConnection->SetLocalIP(stAddr.sin_addr.s_addr); poConnection->SetLocalPort(SDNtohs(stAddr.sin_port)); poConnection->SetRemoteIP(pstReq->dwIP); poConnection->SetRemotePort(pstReq->wPort); //poConnection->SetCpSock(poSock); //poSock->SetConnection(poConnection); poSock->SetPacketParser(pstReq->poPacketParser); poSock->SetConnect(TRUE); //CEventMgr::Instance()->PushEstablishEvt(pConnData, false, pstReq->dwConnectorID); if(false == poSock->AssociateWithIocp()) { poSock->Close(); } else { if(false == poSock->PostRecv()) { poSock->Close(); } } } }while(dwStart 这个函数,对上一个函数中发起的连接结果做出判断并处理。如果连接成功,则向完成端口上投递一个recv事件。这个循环的代码,我建议读者好好研究一下,非常好的重连实例,同时也组合了完成端口的模型,还有一些重要的网络编程细节(如nodelay选项等)。 那么这个线程在哪里启动的呢?通过搜索OnExecute函数名我们发现真正的线程函数: unsigned CConnectCtrl::ThreadFunc(LPVOID pParam) { CConnectCtrl* poCtrl = (CConnectCtrl*)pParam; poCtrl->OnExecute(); return 0; } 进而搜索到: bool CConnectCtrl::Init() { INT32 nMaxRequest = MAX_CONNECTION * 2; m_pAllReqArray = new SConnReq[nMaxRequest]; if(NULL == m_pAllReqArray) { return false; } if(false == m_oFreeQueue.Init(nMaxRequest+1)) { return false; } if(false == m_oReqQueue.Init(nMaxRequest+1)) { return false; } INT32 i; for(i = 0; i 我们在CConnectCtrl::Init()处加个断点,然后重启一下程序,看下调用堆栈: 在CUCODENETWin::_InitComponent()中我们看到整个网络通信框架的初始化,初始化CConnDataMgr、CEventMgr、CConnectCtrl和CIocpCtrl。 bool CUCODENetWin::_InitComponent() { if (false == CConnDataMgr::Instance()->Init()) { CRITICAL(_SDT(\"CUCODENetWin::_InitComponent, Init CConnDataMgr failed\" )); return false; } if(false == CEventMgr::Instance()->Init(MAX_NET_EVENT)) { CRITICAL(_SDT(\"CUCODENetWin::_InitComponent, Init CEventMgr %d failed\"), MAX_NET_EVENT); return false; } if(false == CConnectCtrl::Instance()->Init()) { CRITICAL(_SDT(\"CUCODENetWin::_InitComponent, Init CConnectCtrl failed\")); return false; } if(false == CIocpCtrl::Instance()->Init()) { CRITICAL(_SDT(\"CUCODENetWin::_InitComponent, Init CIocpCtrl failed\")); return false; } return true; } 而所有的这些初始化,都是在所谓的CLogNetSessionMgr中初始化的: 我们最终追溯到最上层的代码中: 到这里,终于找到家了。 最后一批介绍的四个线程是完成端口线程,如下图所示: 精华部分全在其线程函数中: void CIocpCtrl::OnExecute() { SPerHandleData* pstPerHandleData; SPerIoData* pstPerIoData; CCPSock* poSock; CCpListener* poListener; BOOL bRet; DWORD dwByteTrabsferred; while(true) { pstPerHandleData = NULL; pstPerIoData = NULL; dwByteTrabsferred = 0; bRet = GetQueuedCompletionStatus( m_hCompletionPort, &dwByteTrabsferred, (PULONG_PTR)&pstPerHandleData, (LPOVERLAPPED*)&pstPerIoData, INFINITE); // 检查是否是线程退出 if(NULL == pstPerHandleData) { return; } //当有客户端请求创建连接时 if(pstPerHandleData->bListen) { // for listen event poListener = (CCpListener*)pstPerHandleData->ptr; if(NULL != poListener && NULL != pstPerIoData) { poListener->OnAccept(bRet, pstPerIoData); //printf(\"Accpet Count:%d \\n\", InterlockedIncrement((LONG*)&m_acceptCount) ); } else { SDASSERT(false); } } else { //for non-listen event poSock = (CCPSock*)pstPerHandleData->ptr; if ( NULL == poSock ) { continue; } if( FALSE == bRet || NULL == pstPerIoData ) { if (::WSAGetLastError()!=ERROR_IO_PENDING) { INFO(_SDT(\"[%s:%d]CCPSock connID=%d error %d, close it\"), MSG_MARK, poSock->GetConnectionID(), ::WSAGetLastError()); poSock->OnClose(); } } else { switch(pstPerIoData->nOp) { case IOCP_RECV: { poSock->DecPostRecv(); if (dwByteTrabsferred > 0) { poSock->OnRecv(dwByteTrabsferred); } else { INFO(_SDT(\"[%s:%d]CCPSock connID=%d error %d, close it, socket :%d \"), MSG_MARK, poSock->GetConnectionID(), ::WSAGetLastError(), poSock->GetSock()); poSock->OnClose(); } } break; case IOCP_SEND: { poSock->DecPostSend(); if (dwByteTrabsferred > 0) { poSock->OnSend(dwByteTrabsferred); } else { INFO(_SDT(\"[%s:%d]CCPSock connID=%d error %d, close it\"), MSG_MARK, poSock->GetConnectionID(), ::WSAGetLastError()); poSock->OnClose(); } } break; case IOCP_CLOSE: { poSock->OnClose(false); } break; default: ; } } } } } 我始终觉得,完成端口模型即使不从事Windows开发的linux服务器开发人员应该也要掌握一下。尤其是linux服务器开发人员需要给客户端人员设计网络通信层的企业。 我们看下,这四个线程在哪里启动的。 同样的方法,我们通过搜索,先找到: unsigned CIocpCtrl::ThreadFunc(LPVOID pParam) { CIocpCtrl* poCtrl = (CIocpCtrl*)pParam; poCtrl->m_threadBufPool.CreateThreadBuffer(); poCtrl->OnExecute(); poCtrl->m_threadBufPool.ReleaseThreadBuffer(); return 0; } 进而进一步找到: bool CIocpCtrl::Init() { //创建IO完成端口句柄 m_hCompletionPort = CreateIoCompletionPort(INVALID_HANDLE_VALUE, NULL, 0, 0); if (m_hCompletionPort == NULL) { CRITICAL(_SDT(\"CIocpCtrl::Init, CreateIoCompletionPort failed, Error %d \\n\"), ::WSAGetLastError()); return false; } //获取当前服务器的CPU核数 SYSTEM_INFO stSysInfo; GetSystemInfo(&stSysInfo); m_nNumberOfWorkers = stSysInfo.dwNumberOfProcessors * THREAD_PER_CPU; if (g_nThreadNum > 0) { m_nNumberOfWorkers = g_nThreadNum; } m_WorkerArray = new HANDLE[m_nNumberOfWorkers]; for (INT32 i = 0; i Uninit(); CRITICAL(_SDT(\"CIocpCtrl::Init, Create Worker thread failed, Close Handler\\n\")); return false; } } return true; } 然后同样的方法在CIocpCtrl::Init()处加个断点,重新跑下程序,得到如下调用堆栈: 我们上文中已经介绍过了,这里就不再重复说明: 通过分析,我们知道LogServer大致的技术框架,业务细节和技术细节,我们在后面的文章中会接着介绍。我们当前的目的是快速把所有的服务的技术框架给熟悉一遍。 源码下载方法: 微信搜索公众号『高性能服务器开发』(中文名:高性能服务器开发),关注公众号后,在公众号中回复『英雄联盟』,即可得到下载链接。(喷子和代码贩子请远离!) 如需下载本站全部技术文章,可以在【高性能服务器开发】公众号回复关键字“文章下载”即可。最近更新时间: 2020-10-03 11:11:21 "},"articles/TeamTalk源码解析/":{"url":"articles/TeamTalk源码解析/","title":"TeamTalk IM源码分析","keywords":"","body":"TeamTalk源码解析 01 TeamTalk介绍 02 服务器端的程序的编译与部署 03 服务器端的程序架构介绍 04 服务器端db_proxy_server源码分析 05 服务器端msg_server源码分析 06 服务器端login_server源码分析 07 服务器端msfs源码分析 08 服务器端file_server源码分析 09 服务器端route_server源码分析 10 开放一个TeamTalk测试服务器地址和几个测试账号 11 pc客户端源码分析 如需下载本站全部技术文章,可以在【高性能服务器开发】公众号回复关键字“文章下载”即可。最近更新时间: 2020-10-08 12:13:03 "},"articles/TeamTalk源码解析/01TeamTalk介绍.html":{"url":"articles/TeamTalk源码解析/01TeamTalk介绍.html","title":"01 TeamTalk介绍","keywords":"","body":"01 TeamTalk介绍 TeamTalk是蘑菇街开源的一款企业内部用的即时通讯软件(Enterprise IM),类似腾讯的RTX。网上也有很多的介绍,我这里也有写几遍关于这款产品的“流水账”,一方面对自己这段时间的阅读其代码做个总结,尽量做个既能宏观上从全局来介绍,又不缺少很多有价值的微观细节,另一方面如果对于作为读者的您有些许帮助,那就善莫大焉了。 项目地址github:https://github.com/baloonwj/TeamTalk 如果您打不开github,请移步至百度网盘下载:http://pan.baidu.com/s/1slbJVf3 关于即时通讯软件本身,我相信使用过QQ的都知道是啥。 下载项目解压后目录结构是这样的: 这款即时通讯软件分为服务器端(linux)、pc端、web端、mac端和两个移动端(ios和安卓),源码中使用了大量的开源技术(用项目作者的话说,就是“拿来主义”)。例如通信协议使用了google protobuf,服务器端使用了内存数据库redis,pc端界面库使用的duilib,pc端的日志系统使用的是YAOLOG库、cximage、jsoncpp库等等。在接下来各个端的源码分析中,我们将会深入和细致地介绍。 下一篇我将介绍首先介绍服务器端的程序的编译与部署。 如需下载本站全部技术文章,可以在【高性能服务器开发】公众号回复关键字“文章下载”即可。最近更新时间: 2020-10-04 15:33:35 "},"articles/TeamTalk源码解析/02服务器端的程序的编译与部署.html":{"url":"articles/TeamTalk源码解析/02服务器端的程序的编译与部署.html","title":"02 服务器端的程序的编译与部署","keywords":"","body":"02 服务器端的程序的编译与部署 这篇我们来介绍下TeamTalk服务器端的编译与部署,部署文档在auto_setup下,这里我们只介绍下服务器程序的编译与部署,不包括管理后台的部署,其部署方法在auto_setup\\im_server文件夹,其实按官方介绍只要找一台干净的linux系统运行一下auto_setup\\im_server\\setup.sh程序就可以了,会自动安装mysql(maridb,mysql被oracle收购后,分为两个分支,继续开源的分支改名叫maridb)、nginx和redis。我们暂且不部署web端,所以不需要安装nginx。我这里是手动安装了mysql和redis。然后启动mysql和redis,并手动建立如下库和表。库名叫teamtalk,需要建立以下这些表: --后台管理员表 --password 密码,规则md5(md5(passwd)+salt) CREATE TABLE `IMAdmin` ( `id` mediumint(6) unsigned NOT NULL AUTO_INCREMENT, `uname` varchar(40) NOT NULL COMMENT '用户名', `pwd` char(32) NOT NULL COMMENT '经过md5加密的密码', `status` tinyint(2) unsigned NOT NULL DEFAULT '0' COMMENT '用户状态 0 :正常 1:删除 可扩展', `created` int(11) unsigned NOT NULL DEFAULT '0' COMMENT '创建时间', `updated` int(11) unsigned NOT NULL DEFAULT '0' COMMENT '更新时间', PRIMARY KEY (`id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8 --存储语音地址 CREATE TABLE `IMAudio` ( `id` int(11) NOT NULL AUTO_INCREMENT, `fromId` int(11) unsigned NOT NULL COMMENT '发送者Id', `toId` int(11) unsigned NOT NULL COMMENT '接收者Id', `path` varchar(255) COLLATE utf8mb4_bin DEFAULT '' COMMENT '语音存储的地址', `size` int(11) unsigned NOT NULL DEFAULT '0' COMMENT '文件大小', `duration` int(11) unsigned NOT NULL DEFAULT '0' COMMENT '语音时长', `created` int(11) unsigned NOT NULL COMMENT '创建时间', PRIMARY KEY (`id`), KEY `idx_fromId_toId` (`fromId`,`toId`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin --存储部门信息 CREATE TABLE `IMDepart` ( `id` int(11) unsigned NOT NULL AUTO_INCREMENT COMMENT '部门id', `departName` varchar(64) COLLATE utf8mb4_bin NOT NULL DEFAULT '' COMMENT '部门名称', `priority` int(11) unsigned NOT NULL DEFAULT '0' COMMENT '显示优先级,相同优先级按拼音顺序排列', `parentId` int(11) unsigned NOT NULL COMMENT '上级部门id', `status` int(11) unsigned NOT NULL DEFAULT '0' COMMENT '状态', `created` int(11) unsigned NOT NULL COMMENT '创建时间', `updated` int(11) unsigned NOT NULL COMMENT '更新时间', PRIMARY KEY (`id`), KEY `idx_departName` (`departName`), KEY `idx_priority_status` (`priority`,`status`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin --发现配置表 CREATE TABLE `IMDiscovery` ( `id` int(11) unsigned NOT NULL AUTO_INCREMENT COMMENT 'id', `itemName` varchar(64) COLLATE utf8mb4_bin NOT NULL DEFAULT '' COMMENT '名称', `itemUrl` varchar(64) COLLATE utf8mb4_bin NOT NULL DEFAULT '' COMMENT 'URL', `itemPriority` int(11) unsigned NOT NULL COMMENT '显示优先级', `status` int(11) unsigned NOT NULL DEFAULT '0' COMMENT '状态', `created` int(11) unsigned NOT NULL COMMENT '创建时间', `updated` int(11) unsigned NOT NULL COMMENT '更新时间', PRIMARY KEY (`id`), KEY `idx_itemName` (`itemName`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin --群组表 CREATE TABLE `IMGroup` ( `id` int(11) NOT NULL AUTO_INCREMENT, `name` varchar(256) COLLATE utf8mb4_bin NOT NULL DEFAULT '' COMMENT '群名称', `avatar` varchar(256) COLLATE utf8mb4_bin NOT NULL DEFAULT '' COMMENT '群头像', `creator` int(11) unsigned NOT NULL DEFAULT '0' COMMENT '创建者用户id', `type` tinyint(3) unsigned NOT NULL DEFAULT '1' COMMENT '群组类型,1-固定;2-临时群', `userCnt` int(11) unsigned NOT NULL DEFAULT '0' COMMENT '成员人数', `status` tinyint(3) unsigned NOT NULL DEFAULT '1' COMMENT '是否删除,0-正常,1-删除', `version` int(11) unsigned NOT NULL DEFAULT '1' COMMENT '群版本号', `lastChated` int(11) unsigned NOT NULL DEFAULT '0' COMMENT '最后聊天时间', `created` int(11) unsigned NOT NULL DEFAULT '0' COMMENT '创建时间', `updated` int(11) unsigned NOT NULL DEFAULT '0' COMMENT '更新时间', PRIMARY KEY (`id`), KEY `idx_name` (`name`(191)), KEY `idx_creator` (`creator`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin COMMENT='IM群信息' --群成员表 CREATE TABLE `IMGroupMember` ( `id` int(11) NOT NULL AUTO_INCREMENT, `groupId` int(11) unsigned NOT NULL COMMENT '群Id', `userId` int(11) unsigned NOT NULL COMMENT '用户id', `status` tinyint(4) unsigned NOT NULL DEFAULT '1' COMMENT '是否退出群,0-正常,1-已退出', `created` int(11) unsigned NOT NULL DEFAULT '0' COMMENT '创建时间', `updated` int(11) unsigned NOT NULL DEFAULT '0' COMMENT '更新时间', PRIMARY KEY (`id`), KEY `idx_groupId_userId_status` (`groupId`,`userId`,`status`), KEY `idx_userId_status_updated` (`userId`,`status`,`updated`), KEY `idx_groupId_updated` (`groupId`,`updated`) ) ENGINE=InnoDB AUTO_INCREMENT=68 DEFAULT CHARSET=utf8 COMMENT='用户和群的关系表' --群消息表,x代表第几张表,目前做了分表有8张:0-7.消息具体在哪张表中,是groupId%IMGroupMessage表的数目 CREATE TABLE `IMGroupMessage_(x)` ( `id` int(11) NOT NULL AUTO_INCREMENT, `groupId` int(11) unsigned NOT NULL COMMENT '用户的关系id', `userId` int(11) unsigned NOT NULL COMMENT '发送用户的id', `msgId` int(11) unsigned NOT NULL COMMENT '消息ID', `content` varchar(4096) COLLATE utf8mb4_bin NOT NULL DEFAULT '' COMMENT '消息内容', `type` tinyint(3) unsigned NOT NULL DEFAULT '2' COMMENT '群消息类型,101为群语音,2为文本', `status` int(11) unsigned NOT NULL DEFAULT '0' COMMENT '消息状态', `created` int(11) unsigned NOT NULL DEFAULT '0' COMMENT '创建时间', `updated` int(11) unsigned NOT NULL DEFAULT '0' COMMENT '更新时间', PRIMARY KEY (`id`), KEY `idx_groupId_status_created` (`groupId`,`status`,`created`), KEY `idx_groupId_msgId_status_created` (`groupId`,`msgId`,`status`,`created`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin COMMENT='IM群消息表' --消息表,x代表第几张表,目前做了分表有8张:0-7.具体在那张表,是relateId%IMMessage表数目. CREATE TABLE `IMMessage_0` ( `id` int(11) NOT NULL AUTO_INCREMENT, `relateId` int(11) unsigned NOT NULL COMMENT '用户的关系id', `fromId` int(11) unsigned NOT NULL COMMENT '发送用户的id', `toId` int(11) unsigned NOT NULL COMMENT '接收用户的id', `msgId` int(11) unsigned NOT NULL COMMENT '消息ID', `content` varchar(4096) COLLATE utf8mb4_bin DEFAULT '' COMMENT '消息内容', `type` tinyint(2) unsigned NOT NULL DEFAULT '1' COMMENT '消息类型', `status` tinyint(1) unsigned NOT NULL DEFAULT '0' COMMENT '0正常 1被删除', `created` int(11) unsigned NOT NULL COMMENT '创建时间', `updated` int(11) unsigned NOT NULL COMMENT '更新时间', PRIMARY KEY (`id`), KEY `idx_relateId_status_created` (`relateId`,`status`,`created`), KEY `idx_relateId_status_msgId_created` (`relateId`,`status`,`msgId`,`created`), KEY `idx_fromId_toId_created` (`fromId`,`toId`,`status`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin --最近联系人(会话)表。 CREATE TABLE `IMRecentSession` ( `id` int(11) NOT NULL AUTO_INCREMENT, `userId` int(11) unsigned NOT NULL COMMENT '用户id', `peerId` int(11) unsigned NOT NULL COMMENT '对方id', `type` tinyint(1) unsigned DEFAULT '0' COMMENT '类型,1-用户,2-群组', `status` tinyint(1) unsigned DEFAULT '0' COMMENT '用户:0-正常, 1-用户A删除,群组:0-正常, 1-被删除', `created` int(11) unsigned NOT NULL DEFAULT '0' COMMENT '创建时间', `updated` int(11) unsigned NOT NULL DEFAULT '0' COMMENT '更新时间', PRIMARY KEY (`id`), KEY `idx_userId_peerId_status_updated` (`userId`,`peerId`,`status`,`updated`), KEY `idx_userId_peerId_type` (`userId`,`peerId`,`type`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8 --用户关系表,标识两个用户之间的唯一关系id,用于消息分表。relationId % 消息表数目。 CREATE TABLE `IMRelationShip` ( `id` int(11) NOT NULL AUTO_INCREMENT, `smallId` int(11) unsigned NOT NULL COMMENT '用户A的id', `bigId` int(11) unsigned NOT NULL COMMENT '用户B的id', `status` tinyint(1) unsigned DEFAULT '0' COMMENT '用户:0-正常, 1-用户A删除,群组:0-正常, 1-被删除', `created` int(11) unsigned NOT NULL DEFAULT '0' COMMENT '创建时间', `updated` int(11) unsigned NOT NULL DEFAULT '0' COMMENT '更新时间', PRIMARY KEY (`id`), KEY `idx_smallId_bigId_status_updated` (`smallId`,`bigId`,`status`,`updated`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8 --用户表 --password 密码,规则md5(md5(passwd)+salt) CREATE TABLE `IMUser` ( `id` int(11) unsigned NOT NULL AUTO_INCREMENT COMMENT '用户id', `sex` tinyint(1) unsigned NOT NULL DEFAULT '0' COMMENT '1男2女0未知', `name` varchar(32) COLLATE utf8mb4_bin NOT NULL DEFAULT '' COMMENT '用户名', `domain` varchar(32) COLLATE utf8mb4_bin NOT NULL DEFAULT '' COMMENT '拼音', `nick` varchar(32) COLLATE utf8mb4_bin NOT NULL DEFAULT '' COMMENT '花名,绰号等', `password` varchar(32) COLLATE utf8mb4_bin NOT NULL DEFAULT '' COMMENT '密码', `salt` varchar(4) COLLATE utf8mb4_bin NOT NULL DEFAULT '' COMMENT '混淆码', `phone` varchar(11) COLLATE utf8mb4_bin NOT NULL DEFAULT '' COMMENT '手机号码', `email` varchar(64) COLLATE utf8mb4_bin NOT NULL DEFAULT '' COMMENT 'email', `avatar` varchar(255) COLLATE utf8mb4_bin DEFAULT '' COMMENT '自定义用户头像', `departId` int(11) unsigned NOT NULL COMMENT '所属部门Id', `status` tinyint(2) unsigned DEFAULT '0' COMMENT '1. 试用期 2. 正式 3. 离职 4.实习', `sign_info` varchar(32) COLLATE utf8mb4_bin NOT NULL DEFAULT '' COMMENT '个性签名', `created` int(11) unsigned NOT NULL COMMENT '创建时间', `updated` int(11) unsigned NOT NULL COMMENT '更新时间', PRIMARY KEY (`id`), KEY `idx_domain` (`domain`), KEY `idx_name` (`name`), KEY `idx_phone` (`phone`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin --离线文件传输表(同事建议的,待考证) CREATE TABLE `IMTransmitFile` ( `id` int(11) unsigned NOT NULL AUTO_INCREMENT COMMENT 'id', `fromId` int(11) unsigned NOT NULL COMMENT '发送用户的id', `toId` int(11) unsigned NOT NULL COMMENT '接收用户的id', `fileName` varchar(32) COLLATE utf8mb4_bin DEFAULT '' COMMENT '文件名字', `size` int(11) unsigned NOT NULL COMMENT '文件大小', `taskId` varchar(256) COLLATE utf8mb4_bin NOT NULL DEFAULT '' COMMENT '任务id', `status` tinyint(1) unsigned DEFAULT '0' COMMENT '状态', `created` int(11) unsigned NOT NULL COMMENT '创建时间', `updated` int(11) unsigned NOT NULL COMMENT '更新时间', PRIMARY KEY (`id`), KEY `idx_taskId` (`taskId`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin; 因为这个企业内部的即时通讯软件,所以客户端不提供注册功能,要想增加新用户,必须通过在管理后台界面手动添加,这里由于我们目前没有部署管理后台界面(需要先安装nginx),所以我们就直接先在用户表IMUser里面添加几个测试用户吧。这个就是基本的sql语句了。但是由于这里用户密码的生成规则是:md5(md5(passwd)+salt),而我们不知道salt到底是什么,这个现在不急,我们后面分析服务器代码时会介绍它。所以,我们就暂且在数据库里面随便写的密码,然后在程序里面修改代码,暂且不校验密码。修改的地方在这里:server\\src\\db_proxy_server\\business\\InterLogin.cpp的51行(CInterLoginStrategy::doLogin函数里面),将这个if语句注释掉: 接下来就是如何编译程序了: 编译程序需要用到cmake和make、gcc,由于程序用了部分C++11的东西,所以gcc的版本至少在4.7以上,我的部署环境是CentOS7、cmake 2.8,gcc 4.8.5。 编译方法: 编译说明 编译环境 TeamTalk编译需要依赖一些最新的c++标准, 建议使用CentOS 7.0, 如果使用的是CentOS 6.x,需要将g++版本升至支持c++11特性,升级脚本可以使用自动安装脚本目录下的gcc_setup 第三方库 TeamTalk使用了许多第三方库,包括protobuf,hiredis,mariadb(mysql),log4cxx等等,在第一次编译TeamTalk之前,建议先执行目录下的: protobuf: make_protobuf.sh hiredis: make_hiredis.sh mariadb: make_mariadb.sh log4cxx: make_log4cxx.sh 这些脚本执行完后会自动将头文件和库文件拷贝至指定的目录。如果你的机器上已经安装了相应的模块,可以不用执行相对应的脚本。 make_protobuf.sh会做以下工作: 解压和编译protobuf目录下的protobuf-2.6.1.tar.gz文件,并在将编译后的文件拷贝到该目录,分别是bin、include和lib,还有一个protobuf-2.6.1是解压后的目录。protobuf目录结构如下: . |-- bin |-- include |-- lib |-- protobuf-2.6.1 `-- protobuf-2.6.1.tar.gz 同时会往server/src/base/pb目录下拷贝google和lib两个文件夹,protocol是原来就存在的存放protobuf的*.proto文件的目录,目录结构如下: . |-- google |-- lib `-- protocol make_log4cxx.sh 会做以下工作: \\1. 从网址 http://mirror.bit.edu.cn/apache/logging/log4cxx/0.10.0/apache-log4cxx-0.10.0.tar.gz 下载apache-log4cxx-0.10.0.tar.gz文件。下载好后解压安装,并在log4 cxx目录下生成lib和include两个文件夹,目录结构如下: . |-- apache-log4cxx-0.10.0 |-- apache-log4cxx-0.10.0.tar.gz |-- console.cpp |-- include |-- inputstreamreader.cpp |-- lib `-- socketoutputstream.cpp 同样,apache-log4cxx-0.10.0是解压后的目录。 make_hiredis.sh和make_mariadb.sh原理一样,这里就不介绍了。 编译TeamTalk服务器 当以上步骤都完成后,可以使用\"./build.sh version 1\"编译整个TeamTalk工程,一旦编译完成,会在上级目录生成im_server_x.tar.gz包,该压缩包包含的内容有: sync_lib_for_zip.sh: 将lib目录下的依赖库copy至各个服务器的目录下,启动服务前需要先执行一次该脚本 lib: 主要包含各个服务器依赖的第三方库 restart.sh: 启动脚本,启动方式为./restart.sh msg_server login_server: msg_server: route_server: db_proxy_server: file_server: push_server: msfs: 也可以进入各个服务目录下手动使用cmake . && make去逐个编译。每个程序都有个配置文件,配置文件在auto_setup\\im_server\\conf目录下,手动编译时,请考到与与对应服务相同的目录下。 注意最终生成的可执行文件,其配置文件必须和他们在同一个目录。另外可执行程序需要一个log4cxx.properties文件,这个文件是程序使用的日志库log4 cxx的配置文件,必须也和可执行程序在同一个目录。如果没有,程序仍然能运行,但可能不能正常工作。你可以将\\server\\src\\slog\\log4cxx.properties下的该文件拷贝过去。 生成程序后,你需要启动以上服务,当然在这前提下你必须能正常连接你的mysql和redis。可以按下列顺序启动服务: db_proxy_server file_server msfs route_server http_server login_server msg_server 程序启动以后用lsof -i -Pn命令查看端口和连接情况: mysqld 2970 mysql 18u IPv6 26929 0t0 TCP *:3306 (LISTEN) mysqld 2970 mysql 40u IPv6 2347264 0t0 TCP 127.0.0.1:3306->127.0.0.1:34780 (ESTABLISHED) mysqld 2970 mysql 41u IPv6 2347266 0t0 TCP 127.0.0.1:3306->127.0.0.1:34782 (ESTABLISHED) mysqld 2970 mysql 42u IPv6 31596 0t0 TCP 127.0.0.1:3306->127.0.0.1:33954 (ESTABLISHED) mysqld 2970 mysql 43u IPv6 31598 0t0 TCP 127.0.0.1:3306->127.0.0.1:33956 (ESTABLISHED) mysqld 2970 mysql 50u IPv6 61604 0t0 TCP 127.0.0.1:3306->127.0.0.1:34638 (ESTABLISHED) mysqld 2970 mysql 51u IPv6 108460 0t0 TCP 127.0.0.1:3306->127.0.0.1:37204 (ESTABLISHED) redis-server 3772 zhangyl 4u IPv6 30711 0t0 TCP *:6379 (LISTEN) redis-server 3772 zhangyl 5u IPv4 30712 0t0 TCP *:6379 (LISTEN) redis-server 3772 zhangyl 6u IPv4 31560 0t0 TCP 127.0.0.1:6379->127.0.0.1:39540 (ESTABLISHED) redis-server 3772 zhangyl 7u IPv4 31563 0t0 TCP 127.0.0.1:6379->127.0.0.1:39542 (ESTABLISHED) redis-server 3772 zhangyl 8u IPv4 31566 0t0 TCP 127.0.0.1:6379->127.0.0.1:39544 (ESTABLISHED) redis-server 3772 zhangyl 9u IPv4 31569 0t0 TCP 127.0.0.1:6379->127.0.0.1:39546 (ESTABLISHED) redis-server 3772 zhangyl 10u IPv4 31572 0t0 TCP 127.0.0.1:6379->127.0.0.1:39548 (ESTABLISHED) redis-server 3772 zhangyl 11u IPv4 31575 0t0 TCP 127.0.0.1:6379->127.0.0.1:39550 (ESTABLISHED) redis-server 3772 zhangyl 12u IPv4 31578 0t0 TCP 127.0.0.1:6379->127.0.0.1:39552 (ESTABLISHED) redis-server 3772 zhangyl 13u IPv4 31581 0t0 TCP 127.0.0.1:6379->127.0.0.1:39554 (ESTABLISHED) redis-server 3772 zhangyl 14u IPv4 31584 0t0 TCP 127.0.0.1:6379->127.0.0.1:39556 (ESTABLISHED) redis-server 3772 zhangyl 15u IPv4 31587 0t0 TCP 127.0.0.1:6379->127.0.0.1:39558 (ESTABLISHED) db_proxy_server 3930 root 5u IPv4 31559 0t0 TCP 127.0.0.1:39540->127.0.0.1:6379 (ESTABLISHED) db_proxy_server 3930 root 6u IPv4 31562 0t0 TCP 127.0.0.1:39542->127.0.0.1:6379 (ESTABLISHED) db_proxy_server 3930 root 7u IPv4 31565 0t0 TCP 127.0.0.1:39544->127.0.0.1:6379 (ESTABLISHED) db_proxy_server 3930 root 8u IPv4 31568 0t0 TCP 127.0.0.1:39546->127.0.0.1:6379 (ESTABLISHED) db_proxy_server 3930 root 9u IPv4 31571 0t0 TCP 127.0.0.1:39548->127.0.0.1:6379 (ESTABLISHED) db_proxy_server 3930 root 10u IPv4 31574 0t0 TCP 127.0.0.1:39550->127.0.0.1:6379 (ESTABLISHED) db_proxy_server 3930 root 11u IPv4 31577 0t0 TCP 127.0.0.1:39552->127.0.0.1:6379 (ESTABLISHED) db_proxy_server 3930 root 12u IPv4 31580 0t0 TCP 127.0.0.1:39554->127.0.0.1:6379 (ESTABLISHED) db_proxy_server 3930 root 13u IPv4 31583 0t0 TCP 127.0.0.1:39556->127.0.0.1:6379 (ESTABLISHED) db_proxy_server 3930 root 14u IPv4 31586 0t0 TCP 127.0.0.1:39558->127.0.0.1:6379 (ESTABLISHED) db_proxy_server 3930 root 15u IPv4 2347263 0t0 TCP 127.0.0.1:34780->127.0.0.1:3306 (ESTABLISHED) db_proxy_server 3930 root 16u IPv4 2347265 0t0 TCP 127.0.0.1:34782->127.0.0.1:3306 (ESTABLISHED) db_proxy_server 3930 root 17u IPv4 31595 0t0 TCP 127.0.0.1:33954->127.0.0.1:3306 (ESTABLISHED) db_proxy_server 3930 root 18u IPv4 31597 0t0 TCP 127.0.0.1:33956->127.0.0.1:3306 (ESTABLISHED) db_proxy_server 3930 root 20u IPv4 31599 0t0 TCP *:10600 (LISTEN) db_proxy_server 3930 root 21u IPv4 2344452 0t0 TCP 127.0.0.1:10600->127.0.0.1:41630 (ESTABLISHED) db_proxy_server 3930 root 22u IPv4 2344453 0t0 TCP 127.0.0.1:10600->127.0.0.1:41632 (ESTABLISHED) db_proxy_server 3930 root 23u IPv4 2344454 0t0 TCP 127.0.0.1:10600->127.0.0.1:41640 (ESTABLISHED) db_proxy_server 3930 root 24u IPv4 2344455 0t0 TCP 127.0.0.1:10600->127.0.0.1:41642 (ESTABLISHED) db_proxy_server 3930 root 25u IPv4 2344456 0t0 TCP 127.0.0.1:10600->127.0.0.1:41644 (ESTABLISHED) db_proxy_server 3930 root 26u IPv4 2344457 0t0 TCP 127.0.0.1:10600->127.0.0.1:41646 (ESTABLISHED) db_proxy_server 3930 root 27u IPv4 2344458 0t0 TCP 127.0.0.1:10600->127.0.0.1:41648 (ESTABLISHED) db_proxy_server 3930 root 28u IPv4 2344459 0t0 TCP 127.0.0.1:10600->127.0.0.1:41650 (ESTABLISHED) db_proxy_server 3930 root 29u IPv4 2344460 0t0 TCP 127.0.0.1:10600->127.0.0.1:41652 (ESTABLISHED) db_proxy_server 3930 root 30u IPv4 2344461 0t0 TCP 127.0.0.1:10600->127.0.0.1:41654 (ESTABLISHED) db_proxy_server 3930 root 31u IPv4 61603 0t0 TCP 127.0.0.1:34638->127.0.0.1:3306 (ESTABLISHED) db_proxy_server 3930 root 32u IPv4 108459 0t0 TCP 127.0.0.1:37204->127.0.0.1:3306 (ESTABLISHED) file_server 3993 root 6u IPv4 32708 0t0 TCP *:8600 (LISTEN) file_server 3993 root 7u IPv4 32709 0t0 TCP 127.0.0.1:8601 (LISTEN) file_server 3993 root 8u IPv4 2344463 0t0 TCP 127.0.0.1:8600->127.0.0.1:49172 (ESTABLISHED) http_msg_server 3998 root 5u IPv4 32765 0t0 TCP *:8400 (LISTEN) http_msg_server 3998 root 7u IPv4 2344443 0t0 TCP 127.0.0.1:41640->127.0.0.1:10600 (ESTABLISHED) http_msg_server 3998 root 8u IPv4 2344444 0t0 TCP 127.0.0.1:41642->127.0.0.1:10600 (ESTABLISHED) http_msg_server 3998 root 9u IPv4 2344445 0t0 TCP 127.0.0.1:41644->127.0.0.1:10600 (ESTABLISHED) http_msg_server 3998 root 10u IPv4 2344446 0t0 TCP 127.0.0.1:41646->127.0.0.1:10600 (ESTABLISHED) http_msg_server 3998 root 11u IPv4 2344447 0t0 TCP 127.0.0.1:41648->127.0.0.1:10600 (ESTABLISHED) http_msg_server 3998 root 12u IPv4 2344448 0t0 TCP 127.0.0.1:41650->127.0.0.1:10600 (ESTABLISHED) http_msg_server 3998 root 13u IPv4 2344449 0t0 TCP 127.0.0.1:41652->127.0.0.1:10600 (ESTABLISHED) http_msg_server 3998 root 14u IPv4 2344450 0t0 TCP 127.0.0.1:41654->127.0.0.1:10600 (ESTABLISHED) http_msg_server 3998 root 15u IPv4 2344451 0t0 TCP 127.0.0.1:37566->127.0.0.1:8200 (ESTABLISHED) msg_server 4011 root 5u IPv4 32862 0t0 TCP *:8000 (LISTEN) msg_server 4011 root 7u IPv4 2344437 0t0 TCP 127.0.0.1:49172->127.0.0.1:8600 (ESTABLISHED) msg_server 4011 root 8u IPv4 2344438 0t0 TCP 127.0.0.1:41630->127.0.0.1:10600 (ESTABLISHED) msg_server 4011 root 9u IPv4 2344439 0t0 TCP 127.0.0.1:41632->127.0.0.1:10600 (ESTABLISHED) msg_server 4011 root 10u IPv4 2344440 0t0 TCP 127.0.0.1:47294->127.0.0.1:8100 (ESTABLISHED) msg_server 4011 root 11u IPv4 2344441 0t0 TCP 127.0.0.1:37546->127.0.0.1:8200 (ESTABLISHED) msg_server 4011 root 13u IPv4 2347252 0t0 TCP 192.168.226.128:8000->192.168.226.1:20801 (ESTABLISHED) push_server 4017 root 8u IPv4 32946 0t0 UDP 127.0.0.1:34870 route_server 4025 root 5u IPv4 32975 0t0 TCP *:8200 (LISTEN) route_server 4025 root 7u IPv4 2344467 0t0 TCP 127.0.0.1:8200->127.0.0.1:37546 (ESTABLISHED) route_server 4025 root 8u IPv4 2344468 0t0 TCP 127.0.0.1:8200->127.0.0.1:37566 (ESTABLISHED) msfs 4038 root 5u IPv4 33065 0t0 TCP 127.0.0.1:8700 (LISTEN) login_server 4075 root 5u IPv4 33115 0t0 TCP *:8008 (LISTEN) login_server 4075 root 7u IPv4 33116 0t0 TCP *:8100 (LISTEN) login_server 4075 root 8u IPv4 33117 0t0 TCP *:8080 (LISTEN) login_server 4075 root 9u IPv4 2344465 0t0 TCP 127.0.0.1:8100->127.0.0.1:47294 (ESTABLISHED) 服务之间的拓扑图如下: 各端口号在上面了。 现在我们用pc端来连接一下服务器,假如我们在用户表里面建立个账号叫test和zhangyl。 将登录服务器的地址设置为msg_server的监听端口号,如上图所示。输入用户名和密码(密码随意)。 这样就可以进行聊天了,当然我这里同一台机器上开了两个pc客户端。实际使用的时候一台机器是不允许开两个终端的,为了测试方便,你需要取消这个限制。用VS2013或以上版本打开win-client\\solution\\teamtalk.sln,修改如下代码取消pc客户端单例限制:在teamtalk.cpp的CteamtalkApp::InitInstance()中, pc端主程序用的是mfc框架,界面使用的duilib库。 我们将在下一篇文章中详细介绍pc端程序源码。 这篇关于服务器端的部署就到这里了,个人觉得很不详尽,因为后面关于服务器的架构分析时会再次详细地介绍这一块,所以这里写的就比较简单了。 如果你在实际部署时遇到任何问题都可以加我微信 easy_coder 交流。 如需下载本站全部技术文章,可以在【高性能服务器开发】公众号回复关键字“文章下载”即可。最近更新时间: 2020-10-04 15:33:28 "},"articles/TeamTalk源码解析/03服务器端的程序架构介绍.html":{"url":"articles/TeamTalk源码解析/03服务器端的程序架构介绍.html","title":"03 服务器端的程序架构介绍","keywords":"","body":"03 服务器端的程序架构介绍 通过上一节的编译与部署,我们会得到TeamTalk服务器端以下部署程序: db_proxy_server file_server http_msg_server login_server msfs msg_server push_server router_server 这些服务构成的拓扑图如下: 各个服务程序的作用描述如下: LoginServer (C++): 负载均衡服务器,分配一个负载小的MsgServer给客户端使用 MsgServer (C++): 消息服务器,提供客户端大部分信令处理功能,包括私人聊天、群组聊天等 RouteServer (C++): 路由服务器,为登录在不同MsgServer的用户提供消息转发功能 FileServer (C++): 文件服务器,提供客户端之间得文件传输服务,支持在线以及离线文件传输 MsfsServer (C++): 图片存储服务器,提供头像,图片传输中的图片存储服务 DBProxy (C++): 数据库代理服务器,提供mysql以及redis的访问服务,屏蔽其他服务器与mysql与redis的直接交互 HttpMsgServer(C++) :对外接口服务器,提供对外接口功能。(目前只是框架) PushServer(C++): 消息推送服务器,提供IOS系统消息推送。(IOS消息推送必须走apns) 注意:上图中并没有push_server和http_push_server。如果你不调试ios版本的客户端,可以暂且不启动push_server,另外http_push_server也可以暂不启动。 启动顺序: 一般来说,前端的服务会依赖后端的服务,所以一般先启动后端服务,再启动前端服务。建议按以下顺序启动服务: 1、启动db_proxy。 2、启动route_server,file_server,msfs 3、启动login_server 4、启动msg_server 那么我就按照服务端的启动顺序去讲解服务端的一个流程概述。 第一步:启动db_proxy后,db_proxy会去根据配置文件连接相应的MySQL实例,以及redis实例。 第二步:启动route_server,file_server,msfs后,各个服务端都会开始监听相应的端口。 第三步:启动login_server,login_server就开始监听相应的端口(8080),等待客户端的连接,而分配一个负载相对较小的msg_server给客户端。 第四步:启动msg_server(端口8000),msg_server启动后,会去主动连接route_server,login_server,db_proxy_server,会将自己的监听的端口信息注册到login_server去,同时在用户上线,下线的时候会将自己的负载情况汇报给login_server. 各个服务的端口号 (注意:如果出现部署完成后但是服务进程启动有问题或者只有部分服务进程启动了,请查看相应的log日志,请查看相应的log日志,请查看相应的log日志。) 服务 端口 login_server 8080/8008 msg_server 8000 db_proxy_server 10600 route_server 8200 http_msg_server 8400 file_server 8600/8601 服务网络通信框架介绍: 上面介绍的每一个服务都使用了相同的网络通信框架,该通信框架可以单独拿出来做为一个通用的网络通信框架。该网络框架是在一个循环里面不断地检测IO事件,然后对检测到的事件进行处理。流程如下: 使用IO复用技术(linux和windows平台用select、mac平台用kevent)分离网络IO。 对分离出来的网络IO进行操作,分为socket句柄可读、可写和出错三种情况。 当然再加上定时器事件,即检测一个定时器事件列表,如果有定时器到期,则执行该定时器事件。 整个框架的伪码大致如下: while (running) { //处理定时器事件 _CheckTimer(); //IO multiplexing int n = select(socket集合, ...); //事件处理 **if** (某些socket可读) { pSocket->OnRead(); } **if** (某些socket可写) { pSocket->OnWrite(); } **if** (某些socket出错) { pSocket->OnClose(); } } 处理定时器事件的代码如下: void CEventDispatch::_CheckTimer() { uint64_t curr_tick = get_tick_count(); list::iterator it; for (it = m_timer_list.begin(); it != m_timer_list.end(); ) { TimerItem* pItem = *it; // iterator maybe deleted in the callback, so we should increment it before callback it++; if(curr_tick >= pItem->next_tick) { pItem->next_tick += pItem->interval; pItem->callback(pItem->user_data, NETLIB_MSG_TIMER, 0, NULL); } } } 即遍历一个定时器列表,将定时器对象与当前时间(curr_tick)做比较,如果当前时间已经大于或等于定时器设置的时间,则表明定时器时间已经到了,执行定时器对象对应的回调函数。 在来看看OnRead、OnWrite和OnClose这三个函数。在TeamTalk源码中每一个socket连接被封装成一个CBaseSocket对象,该对象是一个使用引用计数的类的子类,通过这种方法来实现生存期自动管理。 void CBaseSocket::OnRead() { if (m_state == SOCKET_STATE_LISTENING) { _AcceptNewSocket(); } else { u_long avail = 0; if ( (ioctlsocket(m_socket, FIONREAD, &avail) == SOCKET_ERROR) || (avail == 0) ) { m_callback(m_callback_data, NETLIB_MSG_CLOSE, (net_handle_t)m_socket, NULL); } else { m_callback(m_callback_data, NETLIB_MSG_READ, (net_handle_t)m_socket, NULL); } } } OnRead()方法根据状态标识m_state确定一个socket是侦听的socket还是普通与客户端连接的socket,如果是侦听sokcet则接收客户端的连接;如果是与客户端连接的socket,则先检测socket上有多少字节可读,如果没有字节可读或者检测字节数时出错,则关闭socket,反之调用设置的回调函数。 void CBaseSocket::OnWrite() { \\#if ((defined _WIN32) || (defined __APPLE__)) CEventDispatch::Instance()->RemoveEvent(m_socket, SOCKET_WRITE); \\#endif if (m_state == SOCKET_STATE_CONNECTING) { int error = 0; socklen_t len = sizeof(error); \\#ifdef _WIN32 getsockopt(m_socket, SOL_SOCKET, SO_ERROR, (**char***)&error, &len); \\#else getsockopt(m_socket, SOL_SOCKET, SO_ERROR, (**void***)&error, &len); \\#endif if (error) { m_callback(m_callback_data, NETLIB_MSG_CLOSE, (net_handle_t)m_socket, NULL); } else { m_state = SOCKET_STATE_CONNECTED; m_callback(m_callback_data, NETLIB_MSG_CONFIRM, (net_handle_t)m_socket, NULL); } } else { m_callback(m_callback_data, NETLIB_MSG_WRITE, (net_handle_t)m_socket, NULL); } } OnWrite()函数则根据m_state标识检测socket是否是尝试连接的socket(connect函数中的socket),用于判断socket是否已经连接成功,反之则是与客户端保持连接的socket,调用预先设置的回调函数。 void CBaseSocket::OnClose() { m_state = SOCKET_STATE_CLOSING; m_callback(m_callback_data, NETLIB_MSG_CLOSE, (net_handle_t)m_socket, NULL); } OnClose()方法将标识m_state设置为需要关闭状态,并调用预先设置的回调函数。 每个服务程序都使用一个stl hash_map来管理所有的socket,键是socket句柄,值是CBaseSocket对象指针: typedef hash_map SocketMap; SocketMap g_socket_map; 所以在删除或者新增socket时,实际上就是从这个hash_map中删除或者向这个hash_map中增加对象。多线程操作,需要一个锁来进行保护: void CEventDispatch::AddEvent(SOCKET fd, uint8_t socket_event) { CAutoLock func_lock(&m_lock); if ((socket_event & SOCKET_READ) != 0) { FD_SET(fd, &m_read_set); } if ((socket_event & SOCKET_WRITE) != 0) { FD_SET(fd, &m_write_set); } if ((socket_event & SOCKET_EXCEP) != 0) { FD_SET(fd, &m_excep_set); } } 代码CAutoLock func_lock(&m_lock);即保护该hash_map的锁对象。 而管理以上功能的是一个单例类CEventDispatch,所以不难才出CEventDispatch提供的接口: class CEventDispatch { public: virtual ~CEventDispatch(); void AddEvent(SOCKET fd, uint8_t socket_event); void RemoveEvent(SOCKET fd, uint8_t socket_event); void AddTimer(callback_t callback, void* user_data, uint64_t interval); void RemoveTimer(callback_t callback, void* user_data); void AddLoop(callback_t callback, void* user_data); void StartDispatch(uint32_t wait_timeout = 100); void StopDispatch(); bool isRunning() {return running;} static CEventDispatch* Instance(); protected: CEventDispatch(); private: void _CheckTimer(); void _CheckLoop(); typedef struct { callback_t callback; void* user_data; uint64_t interval; uint64_t next_tick; } TimerItem; private: #ifdef _WIN32 fd_set m_read_set; fd_set m_write_set; fd_set m_excep_set; #elif __APPLE__ int m_kqfd; #else int m_epfd; #endif CLock m_lock; list m_timer_list; list m_loop_list; static CEventDispatch* m_pEventDispatch; bool running; }; 其中StartDispatch()和StopDispatcher()分别用于启动和停止整个循环流程。一般在程序初始化的时候StartDispatch(),在程序退出时StopDispatcher()。 下面我们以pc端登录为例来具体看一个数据包在服务器端各个服务之间走过的流程: 步骤1:login_server初始化侦听socket,设置新连接到来的回调函数。8080端口,该端口是为http服务配置的。 在login_server.cpp main函数中调用: netlib_listen调用如下: pSocket->Listen调用: AddBaseSocket将该socket加入hash_map中。AddEvent设置需要关注的socket上的事件,这里只关注可读和出错事件。 步骤2: 客户端调用connect()函数连接login_server的8080端口。 步骤3:login_server收到连接请求后调用OnRead方法,OnRead()方法里面调用_AcceptNewSocket(),_AcceptNewSocket()接收新连接,创建新的socket,并调用之前初始化阶段netlib_listen设置的回调函数http_callback。 void CBaseSocket::OnRead() { if (m_state == SOCKET_STATE_LISTENING) { _AcceptNewSocket(); } else { u_long avail = 0; if ( (ioctlsocket(m_socket, FIONREAD, &avail) == SOCKET_ERROR) || (avail == 0) ) { m_callback(m_callback_data, NETLIB_MSG_CLOSE, (net_handle_t)m_socket, NULL); } else { m_callback(m_callback_data, NETLIB_MSG_READ, (net_handle_t)m_socket, NULL); } } } void CBaseSocket::_AcceptNewSocket() { SOCKET fd = 0; sockaddr_in peer_addr; socklen_t addr_len = sizeof(sockaddr_in); char ip_str[64]; while ( (fd = accept(m_socket, (sockaddr*)&peer_addr, &addr_len)) != INVALID_SOCKET ) { CBaseSocket* pSocket = new CBaseSocket(); uint32_t ip = ntohl(peer_addr.sin_addr.s_addr); uint16_t port = ntohs(peer_addr.sin_port); snprintf(ip_str, sizeof(ip_str), \"%d.%d.%d.%d\", ip >> 24, (ip >> 16) & 0xFF, (ip >> 8) & 0xFF, ip & 0xFF); log(\"AcceptNewSocket, socket=%d from %s:%d\\n\", fd, ip_str, port); pSocket->SetSocket(fd); pSocket->SetCallback(m_callback); pSocket->SetCallbackData(m_callback_data); pSocket->SetState(SOCKET_STATE_CONNECTED); pSocket->SetRemoteIP(ip_str); pSocket->SetRemotePort(port); _SetNoDelay(fd); _SetNonblock(fd); AddBaseSocket(pSocket); CEventDispatch::Instance()->AddEvent(fd, SOCKET_READ | SOCKET_EXCEP); m_callback(m_callback_data, NETLIB_MSG_CONNECT, (net_handle_t)fd, NULL); } } void http_callback(void* callback_data, uint8_t msg, uint32_t handle, void* pParam) { if (msg == NETLIB_MSG_CONNECT) { CHttpConn* pConn = new CHttpConn(); pConn->OnConnect(handle); } else { log(\"!!!error msg: %d \", msg); } } pConn->OnConnect(handle)中设置http数据的回调函数httpconn_callback: void CHttpConn::OnConnect(net_handle_t handle) { printf(\"OnConnect, handle=%d\\n\", handle); m_sock_handle = handle; m_state = CONN_STATE_CONNECTED; g_http_conn_map.insert(make_pair(m_conn_handle, this)); netlib_option(handle, NETLIB_OPT_SET_CALLBACK, (void*)httpconn_callback); netlib_option(handle, NETLIB_OPT_SET_CALLBACK_DATA, reinterpret_cast(m_conn_handle) ); netlib_option(handle, NETLIB_OPT_GET_REMOTE_IP, (void*)&m_peer_ip); } httpconn_callback中处理http可读可写出错事件: void httpconn_callback(void* callback_data, uint8_t msg, uint32_t handle, uint32_t uParam, void* pParam) { NOTUSED_ARG(uParam); NOTUSED_ARG(pParam); // convert void* to uint32_t, oops uint32_t conn_handle = *((uint32_t*)(&callback_data)); CHttpConn* pConn = FindHttpConnByHandle(conn_handle); if (!pConn) { return; } switch (msg) { case NETLIB_MSG_READ: pConn->OnRead(); break; case NETLIB_MSG_WRITE: pConn->OnWrite(); break; case NETLIB_MSG_CLOSE: pConn->OnClose(); break; default: log(\"!!!httpconn_callback error msg: %d \", msg); break; } } 步骤4:客户端连接成功以后,发送http请求,方法是get,请求url:http://192.168.226.128:8080/msg_server。(具体网址与你的机器配置的网址有关) 步骤5:login_server检测到该socket可读,调用pConn->OnRead()方法。 void CHttpConn::OnRead() { for (;;) { uint32_t free_buf_len = m_in_buf.GetAllocSize() - m_in_buf.GetWriteOffset(); if (free_buf_len 1024) { log(\"get too much data:%s \", in_buf); Close(); return; } //log(\"OnRead, buf_len=%u, conn_handle=%u\\n\", buf_len, m_conn_handle); // for debug m_cHttpParser.ParseHttpContent(in_buf, buf_len); if (m_cHttpParser.IsReadAll()) { string url = m_cHttpParser.GetUrl(); if (strncmp(url.c_str(), \"/msg_server\", 11) == 0) { string content = m_cHttpParser.GetBodyContent(); _HandleMsgServRequest(url, content); } else { log(\"url unknown, url=%s \", url.c_str()); Close(); } } } CHttpConn::OnRead()先用recv收取数据,接着解析数据,如果出错或者非法数据就关闭连接。如果客户端发送的请求的http object正好是/msg_server,则调用_HandleMsgServRequest(url, content);进行处理: void CHttpConn::_HandleMsgServRequest(string& url, string& post_data) { msg_serv_info_t* pMsgServInfo; uint32_t min_user_cnt = (uint32_t)-1; map::iterator it_min_conn = g_msg_serv_info.end(); map::iterator it; if(g_msg_serv_info.size() second; if ( (pMsgServInfo->cur_conn_cnt max_conn_cnt) && (pMsgServInfo->cur_conn_cnt cur_conn_cnt; } } if (it_min_conn == g_msg_serv_info.end()) { log(\"All TCP MsgServer are full \"); Json::Value value; value[\"code\"] = 2; value[\"msg\"] = \"负载过高\"; string strContent = value.toStyledString(); char* szContent = new char[HTTP_RESPONSE_HTML_MAX]; snprintf(szContent, HTTP_RESPONSE_HTML_MAX, HTTP_RESPONSE_HTML, strContent.length(), strContent.c_str()); Send((void*)szContent, strlen(szContent)); delete [] szContent; return; } else { Json::Value value; value[\"code\"] = 0; value[\"msg\"] = \"\"; if(pIpParser->isTelcome(GetPeerIP())) { value[\"priorIP\"] = string(it_min_conn->second->ip_addr1); value[\"backupIP\"] = string(it_min_conn->second->ip_addr2); value[\"msfsPrior\"] = strMsfsUrl; value[\"msfsBackup\"] = strMsfsUrl; } else { value[\"priorIP\"] = string(it_min_conn->second->ip_addr2); value[\"backupIP\"] = string(it_min_conn->second->ip_addr1); value[\"msfsPrior\"] = strMsfsUrl; value[\"msfsBackup\"] = strMsfsUrl; } value[\"discovery\"] = strDiscovery; value[\"port\"] = int2string(it_min_conn->second->port); string strContent = value.toStyledString(); char* szContent = new char[HTTP_RESPONSE_HTML_MAX]; uint32_t nLen = strContent.length(); snprintf(szContent, HTTP_RESPONSE_HTML_MAX, HTTP_RESPONSE_HTML, nLen, strContent.c_str()); Send((void*)szContent, strlen(szContent)); delete [] szContent; return; } } 该方法根据客户端ip地址将msg_server的地址组装成json格式,返回给客户端。json格式内容如下: { \"backupIP\" : \"localhost\", \"code\" : 0, \"discovery\" : \"http://192.168.226.128/api/discovery\", \"msfsBackup\" : \"http://192.168.226.128:8700/\", \"msfsPrior\" : \"http://192.168.226.128:8700/\", \"msg\" : \"\", \"port\" : \"8000\", \"priorIP\" : \"localhost\" } 注意,发送数据给客户端调用的是Send方法,该方法会先尝试着调用底层的send()函数去发送,如果不能全部发送出去,则将剩余数据加入到对应的写数据缓冲区内。这样这些数据会在该socket可写时再继续发送。这是也是设计网络通信库一个通用的技巧,即先试着去send,如果send不了,将数据放入待发送缓冲区内,并设置检测可写标识位,当socket可写时,从待发送缓冲区取出数据发送出去。如果还是不能全部发送出去,继续设置检测可写标识位,下次再次发送,如此循环一直到所有数据都发送出去为止。 int CHttpConn::Send(void* data, int len) { m_last_send_tick = get_tick_count(); if (m_busy) { m_out_buf.Write(data, len); return len; } int ret = netlib_send(m_sock_handle, data, len); if (ret 当然,由于这里http设置成了短连接,每次应答完客户度之后立即关闭连接,在OnWriteComplete()里面: void CHttpConn::OnWriteComlete() { log(\"write complete \"); Close(); } 步骤6:客户端收到http请求的应答后,根据收到的json得到msg_server的ip地址,这里是ip地址是192.168.226.128,端口号是8000。客户端开始连接这个ip地址和端口号,连接过程与msg_server接收连接过程与上面的步骤相同。接着客户端给服务器发送登录数据包。 步骤7:msg_server收到登录请求后,在CImConn::OnRead()收取数据,解包,调用子类CMsgConn重写的HandlePdu,处理登录请求,如何处理呢?处理如下: //MsgConn.cpp void CMsgConn::HandlePdu(CImPdu* pPdu) { // request authorization check if (pPdu->GetCommandId() != CID_LOGIN_REQ_USERLOGIN && !IsOpen() && IsKickOff()) { log(\"HandlePdu, wrong msg. \"); throw CPduException(pPdu->GetServiceId(), pPdu->GetCommandId(), ERROR_CODE_WRONG_SERVICE_ID, \"HandlePdu error, user not login. \"); return; } switch (pPdu->GetCommandId()) { case CID_OTHER_HEARTBEAT: _HandleHeartBeat(pPdu); break; case CID_LOGIN_REQ_USERLOGIN: _HandleLoginRequest(pPdu ); break; case CID_LOGIN_REQ_LOGINOUT: _HandleLoginOutRequest(pPdu); break; case CID_LOGIN_REQ_DEVICETOKEN: _HandleClientDeviceToken(pPdu); break; case CID_LOGIN_REQ_KICKPCCLIENT: _HandleKickPCClient(pPdu); break; case CID_LOGIN_REQ_PUSH_SHIELD: _HandlePushShieldRequest(pPdu); break; case CID_LOGIN_REQ_QUERY_PUSH_SHIELD: _HandleQueryPushShieldRequest(pPdu); break; case CID_MSG_DATA: _HandleClientMsgData(pPdu); break; case CID_MSG_DATA_ACK: _HandleClientMsgDataAck(pPdu); break; case CID_MSG_TIME_REQUEST: _HandleClientTimeRequest(pPdu); break; case CID_MSG_LIST_REQUEST: _HandleClientGetMsgListRequest(pPdu); break; case CID_MSG_GET_BY_MSG_ID_REQ: _HandleClientGetMsgByMsgIdRequest(pPdu); break; case CID_MSG_UNREAD_CNT_REQUEST: _HandleClientUnreadMsgCntRequest(pPdu ); break; case CID_MSG_READ_ACK: _HandleClientMsgReadAck(pPdu); break; case CID_MSG_GET_LATEST_MSG_ID_REQ: _HandleClientGetLatestMsgIDReq(pPdu); break; case CID_SWITCH_P2P_CMD: _HandleClientP2PCmdMsg(pPdu ); break; case CID_BUDDY_LIST_RECENT_CONTACT_SESSION_REQUEST: _HandleClientRecentContactSessionRequest(pPdu); break; case CID_BUDDY_LIST_USER_INFO_REQUEST: _HandleClientUserInfoRequest( pPdu ); break; case CID_BUDDY_LIST_REMOVE_SESSION_REQ: _HandleClientRemoveSessionRequest( pPdu ); break; case CID_BUDDY_LIST_ALL_USER_REQUEST: _HandleClientAllUserRequest(pPdu ); break; case CID_BUDDY_LIST_CHANGE_AVATAR_REQUEST: _HandleChangeAvatarRequest(pPdu); break; case CID_BUDDY_LIST_CHANGE_SIGN_INFO_REQUEST: _HandleChangeSignInfoRequest(pPdu); break; case CID_BUDDY_LIST_USERS_STATUS_REQUEST: _HandleClientUsersStatusRequest(pPdu); break; case CID_BUDDY_LIST_DEPARTMENT_REQUEST: _HandleClientDepartmentRequest(pPdu); break; // for group process case CID_GROUP_NORMAL_LIST_REQUEST: s_group_chat->HandleClientGroupNormalRequest(pPdu, this); break; case CID_GROUP_INFO_REQUEST: s_group_chat->HandleClientGroupInfoRequest(pPdu, this); break; case CID_GROUP_CREATE_REQUEST: s_group_chat->HandleClientGroupCreateRequest(pPdu, this); break; case CID_GROUP_CHANGE_MEMBER_REQUEST: s_group_chat->HandleClientGroupChangeMemberRequest(pPdu, this); break; case CID_GROUP_SHIELD_GROUP_REQUEST: s_group_chat->HandleClientGroupShieldGroupRequest(pPdu, this); break; case CID_FILE_REQUEST: s_file_handler->HandleClientFileRequest(this, pPdu); break; case CID_FILE_HAS_OFFLINE_REQ: s_file_handler->HandleClientFileHasOfflineReq(this, pPdu); break; case CID_FILE_ADD_OFFLINE_REQ: s_file_handler->HandleClientFileAddOfflineReq(this, pPdu); break; case CID_FILE_DEL_OFFLINE_REQ: s_file_handler->HandleClientFileDelOfflineReq(this, pPdu); break; default: log(\"wrong msg, cmd id=%d, user id=%u. \", pPdu->GetCommandId(), GetUserId()); break; } } 分支case CID_LOGIN_REQ_USERLOGIN即处理登录请求: //在MsgConn.cpp中 void CMsgConn::_HandleLoginRequest(CImPdu* pPdu) { // refuse second validate request if (m_login_name.length() != 0) { log(\"duplicate LoginRequest in the same conn \"); return; } // check if all server connection are OK uint32_t result = 0; string result_string = \"\"; CDBServConn* pDbConn = get_db_serv_conn_for_login(); if (!pDbConn) { result = IM::BaseDefine::REFUSE_REASON_NO_DB_SERVER; result_string = \"服务端异常\"; } else if (!is_login_server_available()) { result = IM::BaseDefine::REFUSE_REASON_NO_LOGIN_SERVER; result_string = \"服务端异常\"; } else if (!is_route_server_available()) { result = IM::BaseDefine::REFUSE_REASON_NO_ROUTE_SERVER; result_string = \"服务端异常\"; } if (result) { IM::Login::IMLoginRes msg; msg.set_server_time(time(NULL)); msg.set_result_code((IM::BaseDefine::ResultType)result); msg.set_result_string(result_string); CImPdu pdu; pdu.SetPBMsg(&msg); pdu.SetServiceId(SID_LOGIN); pdu.SetCommandId(CID_LOGIN_RES_USERLOGIN); pdu.SetSeqNum(pPdu->GetSeqNum()); SendPdu(&pdu); Close(); return; } IM::Login::IMLoginReq msg; CHECK_PB_PARSE_MSG(msg.ParseFromArray(pPdu->GetBodyData(), pPdu->GetBodyLength())); //假如是汉字,则转成拼音 m_login_name = msg.user_name(); string password = msg.password(); uint32_t online_status = msg.online_status(); if (online_status IM::BaseDefine::USER_STATUS_LEAVE) { log(\"HandleLoginReq, online status wrong: %u \", online_status); online_status = IM::BaseDefine::USER_STATUS_ONLINE; } m_client_version = msg.client_version(); m_client_type = msg.client_type(); m_online_status = online_status; log(\"HandleLoginReq, user_name=%s, status=%u, client_type=%u, client=%s, \", m_login_name.c_str(), online_status, m_client_type, m_client_version.c_str()); CImUser* pImUser = CImUserManager::GetInstance()->GetImUserByLoginName(GetLoginName()); if (!pImUser) { pImUser = new CImUser(GetLoginName()); CImUserManager::GetInstance()->AddImUserByLoginName(GetLoginName(), pImUser); } pImUser->AddUnValidateMsgConn(this); CDbAttachData attach_data(ATTACH_TYPE_HANDLE, m_handle, 0); // continue to validate if the user is OK IM::Server::IMValidateReq msg2; msg2.set_user_name(msg.user_name()); msg2.set_password(password); msg2.set_attach_data(attach_data.GetBuffer(), attach_data.GetLength()); CImPdu pdu; pdu.SetPBMsg(&msg2); pdu.SetServiceId(SID_OTHER); pdu.SetCommandId(CID_OTHER_VALIDATE_REQ); pdu.SetSeqNum(pPdu->GetSeqNum()); pDbConn->SendPdu(&pdu); } 如需下载本站全部技术文章,可以在【高性能服务器开发】公众号回复关键字“文章下载”即可。最近更新时间: 2020-10-04 15:33:21 "},"articles/TeamTalk源码解析/04服务器端db_proxy_server源码分析.html":{"url":"articles/TeamTalk源码解析/04服务器端db_proxy_server源码分析.html","title":"04 服务器端db_proxy_server源码分析","keywords":"","body":"04 服务器端db_proxy_server源码分析 从这篇文章开始,我将详细地分析TeamTalk服务器端每一个服务的源码和架构设计。 这篇从db_proxy_server开始。db_proxy_server是TeamTalk服务器端最后端的程序,它连接着关系型数据库mysql和nosql内存数据库redis。其位置在整个服务架构中如图所示: 我们从db_proxy_server的main()函数开始,main()函数其实就是做了以下初始化工作,我整理成如下伪码: int main() { //1. 初始化redis连接 //2. 初始化mysql连接 //3. 启动任务队列,用于处理任务 //4. 启动从mysql同步数据到redis工作 //5. 在端口10600上启动侦听,监听新连接 //6. 主线程进入循环,监听新连接的到来以及出来新连接上的数据收发 } 下面,我们将一一介绍以上步骤。 一、初始化redis连接 CacheManager* pCacheManager = CacheManager::getInstance(); CacheManager* CacheManager::getInstance() { if (!s_cache_manager) { s_cache_manager = new CacheManager(); if (s_cache_manager->Init()) { delete s_cache_manager; s_cache_manager = NULL; } } return s_cache_manager; } int CacheManager::Init() { CConfigFileReader config_file(\"dbproxyserver.conf\"); //CacheInstances=unread,group_set,token,sync,group_member char* cache_instances = config_file.GetConfigName(\"CacheInstances\"); if (!cache_instances) { log(\"not configure CacheIntance\"); return 1; } char host[64]; char port[64]; char db[64]; char maxconncnt[64]; CStrExplode instances_name(cache_instances, ','); for (uint32_t i = 0; i Init()) { log(\"Init cache pool failed\"); return 3; } m_cache_pool_map.insert(make_pair(pool_name, pCachePool)); } return 0; } 在pCachePool->Init()中是实际连接redis的动作: int CachePool::Init() { for (int i = 0; i Init()) { delete pConn; return 1; } m_free_list.push_back(pConn); } log(\"cache pool: %s, list size: %lu\", m_pool_name.c_str(), m_free_list.size()); return 0; } pConn->Init()调用如下: int CacheConn::Init() { if (m_pContext) { return 0; } // 4s 尝试重连一次 uint64_t cur_time = (uint64_t)time(NULL); if (cur_time GetServerIP(), m_pCachePool->GetServerPort(), timeout); if (!m_pContext || m_pContext->err) { if (m_pContext) { log(\"redisConnect failed: %s\", m_pContext->errstr); redisFree(m_pContext); m_pContext = NULL; } else { log(\"redisConnect failed\"); } return 1; } redisReply* reply = (redisReply *)redisCommand(m_pContext, \"SELECT %d\", m_pCachePool->GetDBNum()); if (reply && (reply->type == REDIS_REPLY_STATUS) && (strncmp(reply->str, \"OK\", 2) == 0)) { freeReplyObject(reply); return 0; } else { log(\"select cache db failed\"); return 2; } } 层级关系是这样的: CacheManager的成员变量m_cache_pool_map存储了配置文件配置的redis缓存池,这是一个map对象,key是缓存池的名字,value是缓存池CachePool对象的指针。 map m_cache_pool_map; dbproxyserver.conf目前配置了如下几个redis缓存池: CacheInstances=unread,group_set,token,sync,group_member 每一个缓存池对象CachePool的成员变量m_free_list中存储着若干个与redis的连接对象,具体是多少个,根据配置文件来配置。m_free_list定义: list m_free_list; 这些与redis连接对象后面会介绍在何处使用。 二、初始化mysql连接 CDBManager* pDBManager = CDBManager::getInstance(); CDBManager* CDBManager::getInstance() { if (!s_db_manager) { s_db_manager = new CDBManager(); if (s_db_manager->Init()) { delete s_db_manager; s_db_manager = NULL; } } return s_db_manager; } int CDBManager::Init() { CConfigFileReader config_file(\"dbproxyserver.conf\"); //DBInstances=teamtalk_master,teamtalk_slave char* db_instances = config_file.GetConfigName(\"DBInstances\"); if (!db_instances) { log(\"not configure DBInstances\"); return 1; } char host[64]; char port[64]; char dbname[64]; char username[64]; char password[64]; char maxconncnt[64]; CStrExplode instances_name(db_instances, ','); for (uint32_t i = 0; i Init()) { log(\"init db instance failed: %s\", pool_name); return 3; } m_dbpool_map.insert(make_pair(pool_name, pDBPool)); } return 0; } 同理pDBPool->Init()中是实际连接mysql代码: int CDBPool::Init() { for (int i = 0; i Init(); if (ret) { delete pDBConn; return ret; } m_free_list.push_back(pDBConn); } log(\"db pool: %s, size: %d\", m_pool_name.c_str(), (int)m_free_list.size()); return 0; } int CDBConn::Init() { m_mysql = mysql_init(NULL); if (!m_mysql) { log(\"mysql_init failed\"); return 1; } my_bool reconnect = true; mysql_options(m_mysql, MYSQL_OPT_RECONNECT, &reconnect); mysql_options(m_mysql, MYSQL_SET_CHARSET_NAME, \"utf8mb4\"); if (!mysql_real_connect(m_mysql, m_pDBPool->GetDBServerIP(), m_pDBPool->GetUsername(), \"\"/*m_pDBPool->GetPasswrod()*/, m_pDBPool->GetDBName(), m_pDBPool->GetDBServerPort(), NULL, 0)) { log(\"mysql_real_connect failed: %s\", mysql_error(m_mysql)); return 2; } return 0; } 与redis连接对象类似,CDBManager的成员对象m_dbpool_map存储了mysql连接池,这也是一个stl map,key是池子的名字,value是连接池的对象CDBPool指针。配置文件中总共配置了名称为主从两个mysql连接池。 DBInstances=teamtalk_master,teamtalk_slave 连接池对象CDBPool用一个成员变量存储自己的若干个mysql连接: list m_free_list; //实际保存mysql连接的容器 具体每个连接池有多少个mysql连接,根据配置文件得到,这里主从两个库都是16个。 这些mysql连接的用途后面介绍。 三、启动任务队列,用于处理任务 初始化一:创建线程处理任务队列中的任务 init_proxy_conn(thread_num); int init_proxy_conn(uint32_t thread_num) { s_handler_map = CHandlerMap::getInstance(); g_thread_pool.Init(thread_num); netlib_add_loop(proxy_loop_callback, NULL); signal(SIGTERM, sig_handler); return netlib_register_timer(proxy_timer_callback, NULL, 1000); } 线程数量根据配置文件得到。g_thread_pool.Init(thread_num)中实际创建处理任务的线程。 int CThreadPool::Init(uint32_t worker_size) { m_worker_size = worker_size; m_worker_list = new CWorkerThread [m_worker_size]; if (!m_worker_list) { return 1; } for (uint32_t i = 0; i void CWorkerThread::Start() { (void)pthread_create(&m_thread_id, NULL, StartRoutine, this); } 线程函数调用序列如下: void* CWorkerThread::StartRoutine(void* arg) { CWorkerThread* pThread = (CWorkerThread*)arg; pThread->Execute(); return NULL; } void CWorkerThread::Execute() { while (true) { m_thread_notify.Lock(); // put wait in while cause there can be spurious wake up (due to signal/ENITR) while (m_task_list.empty()) { m_thread_notify.Wait(); } CTask* pTask = m_task_list.front(); m_task_list.pop_front(); m_thread_notify.Unlock(); pTask->run(); delete pTask; m_task_cnt++; //log(\"%d have the execute %d task\\n\", m_thread_idx, m_task_cnt); } } 可以看到工作线程一直在等待一个条件变量,当向任务队列中添加任务时,条件变量被唤醒: void CWorkerThread::PushTask(CTask* pTask) { m_thread_notify.Lock(); m_task_list.push_back(pTask); m_thread_notify.Signal(); m_thread_notify.Unlock(); } 任务队列的用途,下文会介绍。 初始化二:将各个任务id与对应的处理函数绑定起来: s_handler_map = CHandlerMap::getInstance(); CHandlerMap* CHandlerMap::getInstance() { if (!s_handler_instance) { s_handler_instance = new CHandlerMap(); s_handler_instance->Init(); } return s_handler_instance; } void CHandlerMap::Init() { //DB_PROXY是命名空间,不是类名 // Login validate m_handler_map.insert(make_pair(uint32_t(CID_OTHER_VALIDATE_REQ), DB_PROXY::doLogin)); m_handler_map.insert(make_pair(uint32_t(CID_LOGIN_REQ_PUSH_SHIELD), DB_PROXY::doPushShield)); m_handler_map.insert(make_pair(uint32_t(CID_LOGIN_REQ_QUERY_PUSH_SHIELD), DB_PROXY::doQueryPushShield)); // recent session m_handler_map.insert(make_pair(uint32_t(CID_BUDDY_LIST_RECENT_CONTACT_SESSION_REQUEST), DB_PROXY::getRecentSession)); m_handler_map.insert(make_pair(uint32_t(CID_BUDDY_LIST_REMOVE_SESSION_REQ), DB_PROXY::deleteRecentSession)); // users m_handler_map.insert(make_pair(uint32_t(CID_BUDDY_LIST_USER_INFO_REQUEST), DB_PROXY::getUserInfo)); m_handler_map.insert(make_pair(uint32_t(CID_BUDDY_LIST_ALL_USER_REQUEST), DB_PROXY::getChangedUser)); m_handler_map.insert(make_pair(uint32_t(CID_BUDDY_LIST_DEPARTMENT_REQUEST), DB_PROXY::getChgedDepart)); m_handler_map.insert(make_pair(uint32_t(CID_BUDDY_LIST_CHANGE_SIGN_INFO_REQUEST), DB_PROXY::changeUserSignInfo)); // message content m_handler_map.insert(make_pair(uint32_t(CID_MSG_DATA), DB_PROXY::sendMessage)); m_handler_map.insert(make_pair(uint32_t(CID_MSG_LIST_REQUEST), DB_PROXY::getMessage)); m_handler_map.insert(make_pair(uint32_t(CID_MSG_UNREAD_CNT_REQUEST), DB_PROXY::getUnreadMsgCounter)); m_handler_map.insert(make_pair(uint32_t(CID_MSG_READ_ACK), DB_PROXY::clearUnreadMsgCounter)); m_handler_map.insert(make_pair(uint32_t(CID_MSG_GET_BY_MSG_ID_REQ), DB_PROXY::getMessageById)); m_handler_map.insert(make_pair(uint32_t(CID_MSG_GET_LATEST_MSG_ID_REQ), DB_PROXY::getLatestMsgId)); // device token m_handler_map.insert(make_pair(uint32_t(CID_LOGIN_REQ_DEVICETOKEN), DB_PROXY::setDevicesToken)); m_handler_map.insert(make_pair(uint32_t(CID_OTHER_GET_DEVICE_TOKEN_REQ), DB_PROXY::getDevicesToken)); //push 推送设置 m_handler_map.insert(make_pair(uint32_t(CID_GROUP_SHIELD_GROUP_REQUEST), DB_PROXY::setGroupPush)); m_handler_map.insert(make_pair(uint32_t(CID_OTHER_GET_SHIELD_REQ), DB_PROXY::getGroupPush)); // group m_handler_map.insert(make_pair(uint32_t(CID_GROUP_NORMAL_LIST_REQUEST), DB_PROXY::getNormalGroupList)); m_handler_map.insert(make_pair(uint32_t(CID_GROUP_INFO_REQUEST), DB_PROXY::getGroupInfo)); m_handler_map.insert(make_pair(uint32_t(CID_GROUP_CREATE_REQUEST), DB_PROXY::createGroup)); m_handler_map.insert(make_pair(uint32_t(CID_GROUP_CHANGE_MEMBER_REQUEST), DB_PROXY::modifyMember)); // file m_handler_map.insert(make_pair(uint32_t(CID_FILE_HAS_OFFLINE_REQ), DB_PROXY::hasOfflineFile)); m_handler_map.insert(make_pair(uint32_t(CID_FILE_ADD_OFFLINE_REQ), DB_PROXY::addOfflineFile)); m_handler_map.insert(make_pair(uint32_t(CID_FILE_DEL_OFFLINE_REQ), DB_PROXY::delOfflineFile)); } m_handler_map.insert(make_pair(uint32_t(CID_OTHER_VALIDATE_REQ), DB_PROXY::doLogin)); m_handler_map.insert(make_pair(uint32_t(CID_LOGIN_REQ_PUSH_SHIELD), DB_PROXY::doPushShield)); m_handler_map.insert(make_pair(uint32_t(CID_LOGIN_REQ_QUERY_PUSH_SHIELD), DB_PROXY::doQueryPushShield)); // recent session m_handler_map.insert(make_pair(uint32_t(CID_BUDDY_LIST_RECENT_CONTACT_SESSION_REQUEST), DB_PROXY::getRecentSession)); m_handler_map.insert(make_pair(uint32_t(CID_BUDDY_LIST_REMOVE_SESSION_REQ), DB_PROXY::deleteRecentSession)); // users m_handler_map.insert(make_pair(uint32_t(CID_BUDDY_LIST_USER_INFO_REQUEST), DB_PROXY::getUserInfo)); m_handler_map.insert(make_pair(uint32_t(CID_BUDDY_LIST_ALL_USER_REQUEST), DB_PROXY::getChangedUser)); m_handler_map.insert(make_pair(uint32_t(CID_BUDDY_LIST_DEPARTMENT_REQUEST), DB_PROXY::getChgedDepart)); m_handler_map.insert(make_pair(uint32_t(CID_BUDDY_LIST_CHANGE_SIGN_INFO_REQUEST), DB_PROXY::changeUserSignInfo)); // message content m_handler_map.insert(make_pair(uint32_t(CID_MSG_DATA), DB_PROXY::sendMessage)); m_handler_map.insert(make_pair(uint32_t(CID_MSG_LIST_REQUEST), DB_PROXY::getMessage)); m_handler_map.insert(make_pair(uint32_t(CID_MSG_UNREAD_CNT_REQUEST), DB_PROXY::getUnreadMsgCounter)); m_handler_map.insert(make_pair(uint32_t(CID_MSG_READ_ACK), DB_PROXY::clearUnreadMsgCounter)); m_handler_map.insert(make_pair(uint32_t(CID_MSG_GET_BY_MSG_ID_REQ), DB_PROXY::getMessageById)); m_handler_map.insert(make_pair(uint32_t(CID_MSG_GET_LATEST_MSG_ID_REQ), DB_PROXY::getLatestMsgId)); // device token m_handler_map.insert(make_pair(uint32_t(CID_LOGIN_REQ_DEVICETOKEN), DB_PROXY::setDevicesToken)); m_handler_map.insert(make_pair(uint32_t(CID_OTHER_GET_DEVICE_TOKEN_REQ), DB_PROXY::getDevicesToken)); //push 推送设置 m_handler_map.insert(make_pair(uint32_t(CID_GROUP_SHIELD_GROUP_REQUEST), DB_PROXY::setGroupPush)); m_handler_map.insert(make_pair(uint32_t(CID_OTHER_GET_SHIELD_REQ), DB_PROXY::getGroupPush)); // group m_handler_map.insert(make_pair(uint32_t(CID_GROUP_NORMAL_LIST_REQUEST), DB_PROXY::getNormalGroupList)); m_handler_map.insert(make_pair(uint32_t(CID_GROUP_INFO_REQUEST), DB_PROXY::getGroupInfo)); m_handler_map.insert(make_pair(uint32_t(CID_GROUP_CREATE_REQUEST), DB_PROXY::createGroup)); m_handler_map.insert(make_pair(uint32_t(CID_GROUP_CHANGE_MEMBER_REQUEST), DB_PROXY::modifyMember)); // file m_handler_map.insert(make_pair(uint32_t(CID_FILE_HAS_OFFLINE_REQ), DB_PROXY::hasOfflineFile)); m_handler_map.insert(make_pair(uint32_t(CID_FILE_ADD_OFFLINE_REQ), DB_PROXY::addOfflineFile)); m_handler_map.insert(make_pair(uint32_t(CID_FILE_DEL_OFFLINE_REQ), DB_PROXY::delOfflineFile)); } 四、启动从mysql同步数据到redis工作 CSyncCenter::getInstance()->init(); CSyncCenter::getInstance()->startSync(); CSyncCenter::getInstance()->init()是获得上次同步的数据位置,接下来同步从这个位置开始。 /* * 初始化函数,从cache里面加载上次同步的时间信息等 */ void CSyncCenter::init() { // Load total update time CacheManager* pCacheManager = CacheManager::getInstance(); // increase message count CacheConn* pCacheConn = pCacheManager->GetCacheConn(\"unread\"); if (pCacheConn) { string strTotalUpdate = pCacheConn->get(\"total_user_updated\"); string strLastUpdateGroup = pCacheConn->get(\"last_update_group\"); pCacheManager->RelCacheConn(pCacheConn); if(strTotalUpdate != \"\") { m_nLastUpdate = string2int(strTotalUpdate); } else { updateTotalUpdate(time(NULL)); } if(strLastUpdateGroup.empty()) { m_nLastUpdateGroup = string2int(strLastUpdateGroup); } else { updateLastUpdateGroup(time(NULL)); } } else { log(\"no cache connection to get total_user_updated\"); } } CSyncCenter::getInstance()->startSync();新开启一个线程进行同步工作: /** * 开启内网数据同步以及群组聊天记录同步 */ void CSyncCenter::startSync() { #ifdef _WIN32 (void)CreateThread(NULL, 0, doSyncGroupChat, NULL, 0, &m_nGroupChatThreadId); #else (void)pthread_create(&m_nGroupChatThreadId, NULL, doSyncGroupChat, NULL); #endif } 线程函数doSyncGroupChat()如下: /** * 同步群组聊天信息 * * @param arg NULL * * @return NULL */ void* CSyncCenter::doSyncGroupChat(void* arg) { m_bSyncGroupChatRuning = true; CDBManager* pDBManager = CDBManager::getInstance(); map mapChangedGroup; do{ mapChangedGroup.clear(); CDBConn* pDBConn = pDBManager->GetDBConn(\"teamtalk_slave\"); if(pDBConn) { string strSql = \"select id, lastChated from IMGroup where status=0 and lastChated >=\" + int2string(m_pInstance->getLastUpdateGroup()); CResultSet* pResult = pDBConn->ExecuteQuery(strSql.c_str()); if(pResult) { while (pResult->Next()) { uint32_t nGroupId = pResult->GetInt(\"id\"); uint32_t nLastChat = pResult->GetInt(\"lastChated\"); if(nLastChat != 0) { mapChangedGroup[nGroupId] = nLastChat; } } delete pResult; } pDBManager->RelDBConn(pDBConn); } else { log(\"no db connection for teamtalk_slave\"); } m_pInstance->updateLastUpdateGroup(time(NULL)); for (auto it=mapChangedGroup.begin(); it!=mapChangedGroup.end(); ++it) { uint32_t nGroupId =it->first; list lsUsers; uint32_t nUpdate = it->second; CGroupModel::getInstance()->getGroupUser(nGroupId, lsUsers); for (auto it1=lsUsers.begin(); it1!=lsUsers.end(); ++it1) { uint32_t nUserId = *it1; uint32_t nSessionId = INVALID_VALUE; nSessionId = CSessionModel::getInstance()->getSessionId(nUserId, nGroupId, IM::BaseDefine::SESSION_TYPE_GROUP, true); if(nSessionId != INVALID_VALUE) { CSessionModel::getInstance()->updateSession(nSessionId, nUpdate); } else { CSessionModel::getInstance()->addSession(nUserId, nGroupId, IM::BaseDefine::SESSION_TYPE_GROUP); } } } // } while (!m_pInstance->m_pCondSync->waitTime(5*1000)); } while (m_pInstance->m_bSyncGroupChatWaitting && !(m_pInstance->m_pCondGroupChat->waitTime(5*1000))); // } while(m_pInstance->m_bSyncGroupChatWaitting); m_bSyncGroupChatRuning = false; return NULL; } 可以看到流程就是先用sql从mysql取出数据,再用“sql”写到redis中去。操作mysql和redis时,并没有新建新连接,而是使用上文介绍的连接池和缓存池中已有的连接。我们上文说了,每个池中都有若干个连接,那使用哪个连接呢?由于保存mysql的连接是一个list对象,所以默认从list的头部取一个可用的。如果当前没有空闲连接可用,则新建一个: CDBConn* CDBPool::GetDBConn() { m_free_notify.Lock(); while (m_free_list.empty()) { if (m_db_cur_conn_cnt >= m_db_max_conn_cnt) { m_free_notify.Wait(); } else { CDBConn* pDBConn = new CDBConn(this); int ret = pDBConn->Init(); if (ret) { log(\"Init DBConnecton failed\"); delete pDBConn; m_free_notify.Unlock(); return NULL; } else { m_free_list.push_back(pDBConn); m_db_cur_conn_cnt++; log(\"new db connection: %s, conn_cnt: %d\", m_pool_name.c_str(), m_db_cur_conn_cnt); } } } CDBConn* pConn = m_free_list.front(); m_free_list.pop_front(); m_free_notify.Unlock(); return pConn; } 分配redis和mysql的一模一样,这里代码就不贴了。 五、在端口10600上启动侦听,监听新连接 CStrExplode listen_ip_list(listen_ip, ';'); for (uint32_t i = 0; i netlib_listen()创建CBaseSocket对象,并将回调函数指针proxy_serv_callback保存在CBaseSocket对象中。 int netlib_listen( const char* server_ip, uint16_t port, callback_t callback, void* callback_data) { CBaseSocket* pSocket = new CBaseSocket(); if (!pSocket) return NETLIB_ERROR; int ret = pSocket->Listen(server_ip, port, callback, callback_data); if (ret == NETLIB_ERROR) delete pSocket; return ret; } pSocket->Listen()是实际调用bind()和listen()函数创建侦听的地方。 int CBaseSocket::Listen(const char* server_ip, uint16_t port, callback_t callback, void* callback_data) { m_local_ip = server_ip; m_local_port = port; m_callback = callback; m_callback_data = callback_data; m_socket = socket(AF_INET, SOCK_STREAM, 0); if (m_socket == INVALID_SOCKET) { printf(\"socket failed, err_code=%d\\n\", _GetErrorCode()); return NETLIB_ERROR; } _SetReuseAddr(m_socket); _SetNonblock(m_socket); sockaddr_in serv_addr; _SetAddr(server_ip, port, &serv_addr); int ret = ::bind(m_socket, (sockaddr*)&serv_addr, sizeof(serv_addr)); if (ret == SOCKET_ERROR) { log(\"bind failed, err_code=%d\", _GetErrorCode()); closesocket(m_socket); return NETLIB_ERROR; } ret = listen(m_socket, 64); if (ret == SOCKET_ERROR) { log(\"listen failed, err_code=%d\", _GetErrorCode()); closesocket(m_socket); return NETLIB_ERROR; } m_state = SOCKET_STATE_LISTENING; log(\"CBaseSocket::Listen on %s:%d\", server_ip, port); AddBaseSocket(this); CEventDispatch::Instance()->AddEvent(m_socket, SOCKET_READ | SOCKET_EXCEP); return NETLIB_OK; } 这个函数有大量的细节需要注意: socket被设置成非阻塞模式; 将绑定的地址设置成reuse(具体原因,我在《服务器编程心得》系列已经介绍过) 将socket的状态设置成SOCKET_STATE_LISTENING,这个状态将侦听的socket与普通客户端连接的socket区别开来。 AddBaseSocket(this);将socket句柄和对应的CBaseSocket放到一个全局对象中管理起来。 typedef hash_map SocketMap; SocketMap g_socket_map; void AddBaseSocket(CBaseSocket* pSocket) { g_socket_map.insert(make_pair((net_handle_t)pSocket->GetSocket(), pSocket)); } 之所以不用map而用hash_map是因为STL的map底层是用红黑树实现的,查找时间复杂度是log(n),而hash_map底层是用hash表存储的,查询时间复杂度是O(1)。后面会介绍将在这个hash_map中查找所有的socket。 目前只关注socket的读和异常事件,侦听socket可读意味着有新连接到来,异常就意味着侦听出错。对于服务器程序一般要关闭或重启服务。 六、主线程进入循环,监听新连接的到来以及出来新连接上的数据收发 netlib_eventloop(10) 10是超时时间,用于select()函数的调用: void netlib_eventloop(uint32_t wait_timeout) { CEventDispatch::Instance()->StartDispatch(wait_timeout); } void CEventDispatch::StartDispatch(uint32_t wait_timeout) { fd_set read_set, write_set, excep_set; timeval timeout; timeout.tv_sec = 0; timeout.tv_usec = wait_timeout * 1000; // 10 millisecond if(running) return; running = true; while (running) { _CheckTimer(); _CheckLoop(); if (!m_read_set.fd_count && !m_write_set.fd_count && !m_excep_set.fd_count) { Sleep(MIN_TIMER_DURATION); continue; } m_lock.lock(); memcpy(&read_set, &m_read_set, sizeof(fd_set)); memcpy(&write_set, &m_write_set, sizeof(fd_set)); memcpy(&excep_set, &m_excep_set, sizeof(fd_set)); m_lock.unlock(); int nfds = select(0, &read_set, &write_set, &excep_set, &timeout); if (nfds == SOCKET_ERROR) { log(\"select failed, error code: %d\", GetLastError()); Sleep(MIN_TIMER_DURATION); continue; // select again } if (nfds == 0) { continue; } for (u_int i = 0; i OnRead(); pSocket->ReleaseRef(); } } for (u_int i = 0; i OnWrite(); pSocket->ReleaseRef(); } } for (u_int i = 0; i OnClose(); pSocket->ReleaseRef(); } } } } 这个函数是整个服务程序的动力和消息泵。我把它简化成如下伪码来重点介绍一下: while(退出条件) { //1. 遍历定时器队列,检测是否有定时器事件到期,有则执行定时器的回调函数 //2. 遍历其他任务队列,检测是否有其他任务需要执行,有,执行之 //3. 检测socket集合,分离可读、可写和异常事件 //4. 处理socket可读事件 //5. 处理socket可写事件 //6. 处理socket异常事件 } 我们先不说1、2两点,当程序初始化后,socket集合中,也只有一个socket,就是上文中说的侦听socket。当有新连接来的时候,该socket被检测到可读。执行 for (u_int i = 0; i OnRead(); pSocket->ReleaseRef(); } } //log(\"select return read count=%d\\n\", read_set.fd_count); SOCKET fd = read_set.fd_array[i]; CBaseSocket* pSocket = FindBaseSocket((net_handle_t)fd); if (pSocket) { pSocket->OnRead(); pSocket->ReleaseRef(); } } FindBaseSocket()就是在上文提到的socket集合map中通过句柄查找socket: CBaseSocket* FindBaseSocket(net_handle_t fd) { CBaseSocket* pSocket = NULL; SocketMap::iterator iter = g_socket_map.find(fd); if (iter != g_socket_map.end()) { pSocket = iter->second; pSocket->AddRef(); } return pSocket; } 接着执行pSocket->OnRead(): void CBaseSocket::OnRead() { if (m_state == SOCKET_STATE_LISTENING) { _AcceptNewSocket(); } else { u_long avail = 0; if ( (ioctlsocket(m_socket, FIONREAD, &avail) == SOCKET_ERROR) || (avail == 0) ) { m_callback(m_callback_data, NETLIB_MSG_CLOSE, (net_handle_t)m_socket, NULL); } else { m_callback(m_callback_data, NETLIB_MSG_READ, (net_handle_t)m_socket, NULL); } } } 因为是侦听socket,其状态被设置成SOCKET_STATE_LISTENING(上文介绍了)。接着就接受新连接。 void CBaseSocket::_AcceptNewSocket() { SOCKET fd = 0; sockaddr_in peer_addr; socklen_t addr_len = sizeof(sockaddr_in); char ip_str[64]; while ( (fd = accept(m_socket, (sockaddr*)&peer_addr, &addr_len)) != INVALID_SOCKET ) { CBaseSocket* pSocket = new CBaseSocket(); uint32_t ip = ntohl(peer_addr.sin_addr.s_addr); uint16_t port = ntohs(peer_addr.sin_port); snprintf(ip_str, sizeof(ip_str), \"%d.%d.%d.%d\", ip >> 24, (ip >> 16) & 0xFF, (ip >> 8) & 0xFF, ip & 0xFF); log(\"AcceptNewSocket, socket=%d from %s:%d\\n\", fd, ip_str, port); pSocket->SetSocket(fd); pSocket->SetCallback(m_callback); pSocket->SetCallbackData(m_callback_data); pSocket->SetState(SOCKET_STATE_CONNECTED); pSocket->SetRemoteIP(ip_str); pSocket->SetRemotePort(port); _SetNoDelay(fd); _SetNonblock(fd); AddBaseSocket(pSocket); CEventDispatch::Instance()->AddEvent(fd, SOCKET_READ | SOCKET_EXCEP); m_callback(m_callback_data, NETLIB_MSG_CONNECT, (net_handle_t)fd, NULL); } } 接收新连接,需要注意以下事项: 产生一个新的socket和对应的CBaseSocket对象。 该socket和对应的CBaseSocket对象和侦听socket一样也被加入全局g_socket_map中进行管理。 新socket同样被设置成非阻塞的。 禁用该socket的nagle算法(_SetNoDelay(fd);)。 关注该socket的读和异常事件(CEventDispatch::Instance()->AddEvent(fd, SOCKET_READ | SOCKET_EXCEP);)。 将socket的状态设置成SOCKET_STATE_CONNECTED。 调用侦听socket的的回调函数m_callback(m_callback_data, NETLIB_MSG_CONNECT, (net_handle_t)fd, NULL),并传入消息类型是NETLIB_MSG_CONNECT。 这个回调函数在上面初始化侦听函数设置的,指向函数proxy_serv_callback。调用代码如下: void proxy_serv_callback(void* callback_data, uint8_t msg, uint32_t handle, void* pParam) { if (msg == NETLIB_MSG_CONNECT) { CProxyConn* pConn = new CProxyConn(); pConn->OnConnect(handle); } else { log(\"!!!error msg: %d\", msg); } } 接着调用CProxyConn的OnConnect()函数: void CProxyConn::OnConnect(net_handle_t handle) { m_handle = handle; g_proxy_conn_map.insert(make_pair(handle, this)); netlib_option(handle, NETLIB_OPT_SET_CALLBACK, (void*)imconn_callback); netlib_option(handle, NETLIB_OPT_SET_CALLBACK_DATA, (void*)&g_proxy_conn_map); netlib_option(handle, NETLIB_OPT_GET_REMOTE_IP, (void*)&m_peer_ip); netlib_option(handle, NETLIB_OPT_GET_REMOTE_PORT, (void*)&m_peer_port); log(\"connect from %s:%d, handle=%d\", m_peer_ip.c_str(), m_peer_port, m_handle); } 注意!这里,已经悄悄地将该新socket的回调函数由proxy_serv_callback偷偷地换成了imconn_callback。同时,将该连接对象放入一个全局map g_proxy_conn_map中: typedef hash_map ConnMap_t; static ConnMap_t g_proxy_conn_map; 同样,该map的key是socket句柄,value是连接对象基类的指针。 至此,对于侦听socket,如果socket可读,则接收新连接,并置换其默认OnRead的回调函数为imconn_callback;而对于新socket,如果socket可读,则会调用imconn_callback。 我们接着看新socket可读的处理流程: void CBaseSocket::OnRead() { if (m_state == SOCKET_STATE_LISTENING) { _AcceptNewSocket(); } else { u_long avail = 0; if ( (ioctlsocket(m_socket, FIONREAD, &avail) == SOCKET_ERROR) || (avail == 0) ) { m_callback(m_callback_data, NETLIB_MSG_CLOSE, (net_handle_t)m_socket, NULL); } else { m_callback(m_callback_data, NETLIB_MSG_READ, (net_handle_t)m_socket, NULL); } } } 上述OnRead函数会走else分支,先调用ioctlsocket获得可读的数据字节数。如果出错或者字节数为0,则以消息NETLIB_MSG_CLOSE调用回调函数imconn_callback, 反之,以消息NETLIB_MSG_READ调用回调函数imconn_callback。 void imconn_callback(void* callback_data, uint8_t msg, uint32_t handle, void* pParam) { NOTUSED_ARG(handle); NOTUSED_ARG(pParam); if (!callback_data) return; ConnMap_t* conn_map = (ConnMap_t*)callback_data; CImConn* pConn = FindImConn(conn_map, handle); if (!pConn) return; //log(\"msg=%d, handle=%d \", msg, handle); switch (msg) { case NETLIB_MSG_CONFIRM: pConn->OnConfirm(); break; case NETLIB_MSG_READ: pConn->OnRead(); break; case NETLIB_MSG_WRITE: pConn->OnWrite(); break; case NETLIB_MSG_CLOSE: pConn->OnClose(); break; default: log(\"!!!imconn_callback error msg: %d \", msg); break; } pConn->ReleaseRef(); } 出错消息NETLIB_MSG_CLOSE没啥好看的,无非是关闭连接。我们来看NETLIB_MSG_READ消息,会调用pConn->OnRead(),pConn是一个CImConn指针,但根据上文介绍我们知道,其实际是一个CImConn的子类CProxyConn对象: class CProxyConn : public CImConn { 所以pConn->OnRead()实际会调用CProxyConn的OnRead(): void CProxyConn::OnRead() { for (;;) { uint32_t free_buf_len = m_in_buf.GetAllocSize() - m_in_buf.GetWriteOffset(); if (free_buf_len CImConn实际是代表一个连接,即每一个连接都有这样一个对象。具体被分化成它的各个子对象,如CProxyConn。每一个连接CImConn都存在一个读缓冲区和写缓冲区,读缓冲区用于存放从网络上收取的数据,写缓冲区用于存放即将发到网络中数据。CProxyConn::OnRead()先检测该对象的读缓冲区中还有多少可用空间,如果可用空间小于当前收到的字节数目,则将该读缓冲区的大小扩展到需要的大小READ_BUF_SIZE。接着收到的数据放入读缓冲区中。并记录下这次收取数据的时间到m_last_recv_tick变量中。接着开始解包,即调用CImPdu::IsPduAvailable()从读取缓冲区中取出数据处理,先判断现有数据是否大于一个包头的大小,如果不大于,退出。如果大于一个包头的大小,则接着根据包头中的信息得到整个包的大小: bool CImPdu::IsPduAvailable(uchar_t* buf, uint32_t len, uint32_t& pdu_len) { if (len len) { //log(\"pdu_len=%d, len=%d\\n\", pdu_len, len); return false; } if(0 == pdu_len) { throw CPduException(1, \"pdu_len is 0\"); } return true; } 得到包的大小就可以正式处理包了,调用HandlePduBuf(m_in_buf.GetBuffer(), pdu_len); void CProxyConn::HandlePduBuf(uchar_t* pdu_buf, uint32_t pdu_len) { CImPdu* pPdu = NULL; pPdu = CImPdu::ReadPdu(pdu_buf, pdu_len); if (pPdu->GetCommandId() == IM::BaseDefine::CID_OTHER_HEARTBEAT) { return; } pdu_handler_t handler = s_handler_map->GetHandler(pPdu->GetCommandId()); if (handler) { CTask* pTask = new CProxyTask(m_uuid, handler, pPdu); g_thread_pool.AddTask(pTask); } else { log(\"no handler for packet type: %d\", pPdu->GetCommandId()); } } 包的数据结构是CImPdu(Im 即Instant Message即时通讯软件的意思,teamtalk本来就是一款即时通讯,pdu,Protocol Data Unit 协议数据单元,通俗的说就是一个包单位),该数据结构分为包头和包体两部分。类CImPdu的两个成员变量: CSimpleBuffer m_buf; PduHeader_t m_pdu_header; 分别表示包头和包体,包头的定义PduHeader_t如下: typedef struct { uint32_t length; // the whole pdu length uint16_t version; // pdu version number uint16_t flag; // not used uint16_t service_id; // uint16_t command_id; // uint16_t seq_num; // 包序号 uint16_t reversed; // 保留 } PduHeader_t; 通过包头的command_id就知道该包是什么数据了。接着根据对应的命令号调用在程序初始化阶段绑定的包处理函数: pdu_handler_t handler = s_handler_map->GetHandler(pPdu->GetCommandId()); 执行处理函数不是直接调用该函数,而是包装成一个任务放入前面介绍的任务队列中: du_handler_t handler = s_handler_map->GetHandler(pPdu->GetCommandId()); if (handler) { CTask* pTask = new CProxyTask(m_uuid, handler, pPdu); g_thread_pool.AddTask(pTask); } else { log(\"no handler for packet type: %d\", pPdu->GetCommandId()); } 前面介绍过,处理任务的线程可能有多个,那么到底将任务加入到哪个工作线程呢?这里采取的策略是随机分配: void CThreadPool::AddTask(CTask* pTask) { /* * select a random thread to push task * we can also select a thread that has less task to do * but that will scan the whole thread list and use thread lock to get each task size */ uint32_t thread_idx = random() % m_worker_size; m_worker_list[thread_idx].PushTask(pTask); } 当然需要注意的是。如果数据包是心跳包的话,就直接不处理了。因为心跳包只是来保活通信的,与具体业务无关: if (pPdu->GetCommandId() == IM::BaseDefine::CID_OTHER_HEARTBEAT) { return; } 该包处理完成以后,将该包的数据从连接的读缓冲区移除: m_in_buf.Read(NULL, pdu_len); 接着继续处理下一个包,因为收来的数据可能不够一个包大小,也可能是多个包的大小,所以要放在一个循环里面解包处理,直到读缓冲区中无数据或数据不够一个包的大小。 我们将这个流程抽象出来,这个流程也是现在所有网络通信库都要做的工作: while(退出条件) { //1. 检测非侦听socket可读 //2. 处理可读事件 //3. 检测可读取的字节数,出错就关闭,不出错,将收取的字节放入连接的读缓冲区 //循环做以下处理 //4. 检测可读缓冲区数据大小是否大于等于一个包头大小: 否,数据不够一个包,跳出该循环; // 是,从包头中得到一个包体的大小,检测读缓冲区是否够一个包头+包体的大小;否,数据不够一个包,跳出循环 // 是,解包,根据包命令号,处理该包数据,可以产生一个任务,丢入任务队列。 // 从可读缓冲区中移除刚才处理的包数据的字节数目。 // 继续第4步。 } 当加入任务后,任务队列线程被唤醒,从任务队列的头部拿出该任务执行。这个上文介绍过了。 到此,本文还没有完,因为上文只介绍了从客户端收取数据,然后解包。并没有介绍解完包,调用处理函数处理后如何应答客户端。下面以一个登录数据包的应答来叙述这个应答流程。登录任务从任务队列中取出来后,执行如下函数: void CHandlerMap::Init() { //DB_PROXY是命名空间,不是类名 // Login validate m_handler_map.insert(make_pair(uint32_t(CID_OTHER_VALIDATE_REQ), DB_PROXY::doLogin)); void doLogin(CImPdu* pPdu, uint32_t conn_uuid) { CImPdu* pPduResp = new CImPdu; IM::Server::IMValidateReq msg; IM::Server::IMValidateRsp msgResp; if(msg.ParseFromArray(pPdu->GetBodyData(), pPdu->GetBodyLength())) { string strDomain = msg.user_name(); string strPass = msg.password(); msgResp.set_user_name(strDomain); msgResp.set_attach_data(msg.attach_data()); do { CAutoLock cAutoLock(&g_cLimitLock); list& lsErrorTime = g_hmLimits[strDomain]; uint32_t tmNow = time(NULL); //清理超过30分钟的错误时间点记录 /* 清理放在这里还是放在密码错误后添加的时候呢? 放在这里,每次都要遍历,会有一点点性能的损失。 放在后面,可能会造成30分钟之前有10次错的,但是本次是对的就没办法再访问了。 */ auto itTime=lsErrorTime.begin(); for(; itTime!=lsErrorTime.end();++itTime) { if(tmNow - *itTime > 30*60) { break; } } if(itTime != lsErrorTime.end()) { lsErrorTime.erase(itTime, lsErrorTime.end()); } // 判断30分钟内密码错误次数是否大于10 if(lsErrorTime.size() > 10) { itTime = lsErrorTime.begin(); if(tmNow - *itTime SetPBMsg(&msgResp); pPduResp->SetSeqNum(pPdu->GetSeqNum()); pPduResp->SetServiceId(IM::BaseDefine::SID_OTHER); pPduResp->SetCommandId(IM::BaseDefine::CID_OTHER_VALIDATE_RSP); CProxyConn::AddResponsePdu(conn_uuid, pPduResp); return ; } } } while(false); log(\"%s request login.\", strDomain.c_str()); IM::BaseDefine::UserInfo cUser; if(g_loginStrategy.doLogin(strDomain, strPass, cUser)) { IM::BaseDefine::UserInfo* pUser = msgResp.mutable_user_info(); pUser->set_user_id(cUser.user_id()); pUser->set_user_gender(cUser.user_gender()); pUser->set_department_id(cUser.department_id()); pUser->set_user_nick_name(cUser.user_nick_name()); pUser->set_user_domain(cUser.user_domain()); pUser->set_avatar_url(cUser.avatar_url()); pUser->set_email(cUser.email()); pUser->set_user_tel(cUser.user_tel()); pUser->set_user_real_name(cUser.user_real_name()); pUser->set_status(0); pUser->set_sign_info(cUser.sign_info()); msgResp.set_result_code(0); msgResp.set_result_string(\"成功\"); //如果登陆成功,则清除错误尝试限制 CAutoLock cAutoLock(&g_cLimitLock); list& lsErrorTime = g_hmLimits[strDomain]; lsErrorTime.clear(); } else { //密码错误,记录一次登陆失败 uint32_t tmCurrent = time(NULL); CAutoLock cAutoLock(&g_cLimitLock); list& lsErrorTime = g_hmLimits[strDomain]; lsErrorTime.push_front(tmCurrent); log(\"get result false\"); msgResp.set_result_code(1); msgResp.set_result_string(\"用户名/密码错误\"); } } else { msgResp.set_result_code(2); msgResp.set_result_string(\"服务端内部错误\"); } pPduResp->SetPBMsg(&msgResp); pPduResp->SetSeqNum(pPdu->GetSeqNum()); pPduResp->SetServiceId(IM::BaseDefine::SID_OTHER); pPduResp->SetCommandId(IM::BaseDefine::CID_OTHER_VALIDATE_RSP); CProxyConn::AddResponsePdu(conn_uuid, pPduResp); } 这段代码有点复杂,下面分析之: 首先,将登录请求包数据通过函数参数(第一个参数)传入进来,其次是连接对象的id。前面已经介绍过了,每一个新的socket不仅对应一个CBaseSocket对象,同时也对应一个连接对象CImConn(可能会被具体化成对应的子类,如CProxyConn)。这些连接对象被放在另外一个全局map g_proxy_conn_map里面进行管理。 通过包数据,我们能得到登录的用户名和密码等信息。接着检测30分钟之内,尝试登录的次数,如果30分钟之内密码错误次数超过10此。则不允许登录。组成一个提示“用户名或密码错误此时太多”的包: msgResp.set_result_code(6); msgResp.set_result_string(\"用户名/密码错误次数太多\"); pPduResp->SetPBMsg(&msgResp); pPduResp->SetSeqNum(pPdu->GetSeqNum()); pPduResp->SetServiceId(IM::BaseDefine::SID_OTHER); pPduResp->SetCommandId(IM::BaseDefine::CID_OTHER_VALIDATE_RSP); CProxyConn::AddResponsePdu(conn_uuid, pPduResp); 如果不存在这种情况,则接着调用g_loginStrategy.doLogin(strDomain, strPass, cUser)连接数据库进行用户名和密码校验: bool CInterLoginStrategy::doLogin(const std::string &strName, const std::string &strPass, IM::BaseDefine::UserInfo& user) { bool bRet = false; CDBManager* pDBManger = CDBManager::getInstance(); CDBConn* pDBConn = pDBManger->GetDBConn(\"teamtalk_slave\"); if (pDBConn) { string strSql = \"select * from IMUser where name='\" + strName + \"' and status=0\"; CResultSet* pResultSet = pDBConn->ExecuteQuery(strSql.c_str()); if(pResultSet) { string strResult, strSalt; uint32_t nId, nGender, nDeptId, nStatus; string strNick, strAvatar, strEmail, strRealName, strTel, strDomain,strSignInfo; while (pResultSet->Next()) { nId = pResultSet->GetInt(\"id\"); strResult = pResultSet->GetString(\"password\"); strSalt = pResultSet->GetString(\"salt\"); strNick = pResultSet->GetString(\"nick\"); nGender = pResultSet->GetInt(\"sex\"); strRealName = pResultSet->GetString(\"name\"); strDomain = pResultSet->GetString(\"domain\"); strTel = pResultSet->GetString(\"phone\"); strEmail = pResultSet->GetString(\"email\"); strAvatar = pResultSet->GetString(\"avatar\"); nDeptId = pResultSet->GetInt(\"departId\"); nStatus = pResultSet->GetInt(\"status\"); strSignInfo = pResultSet->GetString(\"sign_info\"); } string strInPass = strPass + strSalt; char szMd5[33]; CMd5::MD5_Calculate(strInPass.c_str(), strInPass.length(), szMd5); string strOutPass(szMd5); //去掉密码校验 //if(strOutPass == strResult) { bRet = true; user.set_user_id(nId); user.set_user_nick_name(strNick); user.set_user_gender(nGender); user.set_user_real_name(strRealName); user.set_user_domain(strDomain); user.set_user_tel(strTel); user.set_email(strEmail); user.set_avatar_url(strAvatar); user.set_department_id(nDeptId); user.set_status(nStatus); user.set_sign_info(strSignInfo); } delete pResultSet; } pDBManger->RelDBConn(pDBConn); } return bRet; } 这里也需要一个mysql连接,这个连接的分配方式在前面介绍过了。即在连接池中随机拿一个,如果池中不存在,则新建一个。用完还回去: pDBManger->RelDBConn(pDBConn); 接着通过用户名从数据库中取出该用户信息,如果记录存在,则接着校验密码。密码在数据库里面的存储形式是:密码+用户的salt值 组成的字符串的md5值。密码如果也校验正确,组装成一个正确应答数据包(附上命令号、序列号、提示信息等): pPduResp->SetPBMsg(&msgResp); pPduResp->SetSeqNum(pPdu->GetSeqNum()); pPduResp->SetServiceId(IM::BaseDefine::SID_OTHER); pPduResp->SetCommandId(IM::BaseDefine::CID_OTHER_VALIDATE_RSP); CProxyConn::AddResponsePdu(conn_uuid, pPduResp); 现在不管登录成功与否,登录应答包也已经组装好了。接下来,就是如何发出去了?上述代码最后一行: CProxyConn::AddResponsePdu(conn_uuid, pPduResp) 其调用如下: void CProxyConn::AddResponsePdu(uint32_t conn_uuid, CImPdu* pPdu) { ResponsePdu_t* pResp = new ResponsePdu_t; pResp->conn_uuid = conn_uuid; pResp->pPdu = pPdu; s_list_lock.lock(); s_response_pdu_list.push_back(pResp); s_list_lock.unlock(); } 我们这里并没有直接将应答数据包通过连接对象CProxyConn发出去。因为直接发出去,未必能发出去。这会导致程序阻塞。(原因是:对方的tcp窗口太小,导致tcp窗口太小的常见原因是:对方无法收包或不及时收包,数据积压在对方网络协议栈里面)。我们这里是将应答数据包放入连接对象的一个应答链表s_response_pdu_list中。这是一个stl list容器: static list s_response_pdu_list; // 主线程发送回复消息 那么,包在这个链表中,何时被发出去呢?我们在介绍该服务的消息泵时介绍到如下流程: while(退出条件) { //1. 遍历定时器队列,检测是否有定时器事件到期,有则执行定时器的回调函数 //2. 遍历其他任务队列,检测是否有其他任务需要执行,有,执行之 //3. 检测socket集合,分离可读、可写和异常事件 //4. 处理socket可读事件 //5. 处理socket可写事件 //6. 处理socket异常事件 } 注意第2步:遍历其他任务队列,检测是否有其他任务需要执行,有,执行之。我们来看看这步具体做了什么。 在main函数里面初始化任务队列线程时,同时也创建了一个其他任务: init_proxy_conn(thread_num); int init_proxy_conn(uint32_t thread_num) { s_handler_map = CHandlerMap::getInstance(); g_thread_pool.Init(thread_num); netlib_add_loop(proxy_loop_callback, NULL); signal(SIGTERM, sig_handler); return netlib_register_timer(proxy_timer_callback, NULL, 1000); } 注意代码netlib_add_loop(proxy_loop_callback, NULL);该行加入了一个其他任务到其他任务队列。这样在主线程的消息泵中:2. 遍历其他任务队列,检测是否有其他任务需要执行,有,执行之。 _CheckLoop(); void CEventDispatch::_CheckLoop() { for (list::iterator it = m_loop_list.begin(); it != m_loop_list.end(); it++) { TimerItem* pItem = *it; pItem->callback(pItem->user_data, NETLIB_MSG_LOOP, 0, NULL); } } 其他任务的回调函数目前只有一个,就是上面设置的proxy_loop_callback: void proxy_loop_callback(void* callback_data, uint8_t msg, uint32_t handle, void* pParam) { CProxyConn::SendResponsePduList(); } void CProxyConn::SendResponsePduList() { s_list_lock.lock(); while (!s_response_pdu_list.empty()) { ResponsePdu_t* pResp = s_response_pdu_list.front(); s_response_pdu_list.pop_front(); s_list_lock.unlock(); CProxyConn* pConn = get_proxy_conn_by_uuid(pResp->conn_uuid); if (pConn) { if (pResp->pPdu) { pConn->SendPdu(pResp->pPdu); } else { log(\"close connection uuid=%d by parse pdu error\\b\", pResp->conn_uuid); pConn->Close(); } } if (pResp->pPdu) delete pResp->pPdu; delete pResp; s_list_lock.lock(); } s_list_lock.unlock(); } 看到这里,你应该明白了。原来应答数据包在这里从list中取出来。然后调用pConn->SendPdu(pResp->pPdu)“发出去”。这里需要解释两个问题:第一个就是一般服务器端会有多个连接对象,那么如何定位某个应答数据包对应的连接对象呢?这里就通过数据包本身的conn_uuid来确定: CProxyConn* pConn = get_proxy_conn_by_uuid(pResp->conn_uuid); CProxyConn* get_proxy_conn_by_uuid(uint32_t uuid) { CProxyConn* pConn = NULL; UserMap_t::iterator it = g_uuid_conn_map.find(uuid); if (it != g_uuid_conn_map.end()) { pConn = (CProxyConn *)it->second; } return pConn; } 全局对象g_uuid_conn_map里面存的是uuid与连接对象的对应关系。这个关系何时存入到这个全局g_uuid_conn_map对象的呢?在CProxyConn的构造函数中: CProxyConn::CProxyConn() { m_uuid = ++CProxyConn::s_uuid_alloctor; if (m_uuid == 0) { m_uuid = ++CProxyConn::s_uuid_alloctor; } g_uuid_conn_map.insert(make_pair(m_uuid, this)); } 这个uuid的基数是一个CProxyConn的静态变量: static uint32_t s_uuid_alloctor; 默认是0: uint32_t CProxyConn::s_uuid_alloctor = 0; 以后每产生一个新连接对象CProxyConn,在此基础上递增,因为没有用锁保护,所以只能在一个线程里面调用。而CProxyConn正好就是在主线程里面产生的,前面介绍过了,再次贴一下代码吧: void proxy_serv_callback(void* callback_data, uint8_t msg, uint32_t handle, void* pParam) { if (msg == NETLIB_MSG_CONNECT) { CProxyConn* pConn = new CProxyConn(); pConn->OnConnect(handle); } else { log(\"!!!error msg: %d\", msg); } } 这样uuid和连接对象CProxyConn还有CBaseSocket这三者的关系就唯一绑定了。 接着说,通过uuid获得对应数据包的连接对象后,调用其方法pConn->SendPdu(pResp->pPdu); “发出去”?但是,还是不行,因为这还没有解决上文提出的该连接上对端的tcp窗口太小导致数据发不出的问题。所以pConn->SendPdu()方法中一定不是调用send函数直接发送数据: int SendPdu(CImPdu* pPdu) { return Send(pPdu->GetBuffer(), pPdu->GetLength()); } 实际上是调用其基类CImConn类的Send方法,发送数据的时候,先记录一下发送数据的时间:m_last_send_tick = get_tick_count(); int CImConn::Send(void* data, int len) { m_last_send_tick = get_tick_count(); // ++g_send_pkt_cnt; if (m_busy) { m_out_buf.Write(data, len); return len; } int offset = 0; int remain = len; while (remain > 0) { int send_size = remain; if (send_size > NETLIB_MAX_SOCKET_BUF_SIZE) { send_size = NETLIB_MAX_SOCKET_BUF_SIZE; } int ret = netlib_send(m_handle, (char*)data + offset, send_size); if (ret 0) { m_out_buf.Write((char*)data + offset, remain); m_busy = true; log(\"send busy, remain=%d \", m_out_buf.GetWriteOffset()); } else { OnWriteCompelete(); } return len; } 注意这段代码,也是特别的讲究: 先试着调用底层send方法去发送,能发多少是多少,剩下发不完的,写入该连接的发送缓冲区中,并将忙碌标志m_busy置位(设置为ture)。反之,如果数据一次性发送完成,则调用数据发送完成函数OnWriteComplete(),这个函数目前为空,即不做任何事情。 int ret = netlib_send(m_handle, (char*)data + offset , send_size); int netlib_send(net_handle_t handle, void* buf, int len) { CBaseSocket* pSocket = FindBaseSocket(handle); if (!pSocket) { return NETLIB_ERROR; } int ret = pSocket->Send(buf, len); pSocket->ReleaseRef(); return ret; } 上面的代码通过socket句柄找到具体的CBaseSocket对象。接着调用CBaseSocket::Send()方法: int CBaseSocket::Send(void* buf, int len) { if (m_state != SOCKET_STATE_CONNECTED) return NETLIB_ERROR; int ret = send(m_socket, (char*)buf, len, 0); if (ret == SOCKET_ERROR) { int err_code = _GetErrorCode(); if (_IsBlock(err_code)) { #if ((defined _WIN32) || (defined __APPLE__)) CEventDispatch::Instance()->AddEvent(m_socket, SOCKET_WRITE); #endif ret = 0; //log(\"socket send block fd=%d\", m_socket); } else { log(\"!!!send failed, error code: %d\", err_code); } } return ret; } 该方法发送指定长度的数据,因为socket在创建的时候被设置成非阻塞的(上文介绍过)。所以,如果发送不了,底层send函数会立刻返回,并返回错误码EINPROGRESS(EWOULDBLOCK),表明对端tcp窗口太小,当前已经无法发出去: bool CBaseSocket::_IsBlock(int error_code) { #ifdef _WIN32 return ( (error_code == WSAEINPROGRESS) || (error_code == WSAEWOULDBLOCK) ); #else return ( (error_code == EINPROGRESS) || (error_code == EWOULDBLOCK) ); #endif } 这个时候,我们再设置关注该socket的可写事件。这样,下次对端tcp窗口大小增大时,本端的socket可写时,我们就能接着发送数据了。会在服务的消息泵中检测可写事件,接着调用CBaseSocket::OnWrite(), 该函数首先移除该socket的可写事件(这里为啥只有win32平台和mac机器移除可写事件,linux平台不需要吗?个人觉得是程序作者的疏忽)。 void CBaseSocket::OnWrite() { #if ((defined _WIN32) || (defined __APPLE__)) CEventDispatch::Instance()->RemoveEvent(m_socket, SOCKET_WRITE); #endif if (m_state == SOCKET_STATE_CONNECTING) { int error = 0; socklen_t len = sizeof(error); #ifdef _WIN32 getsockopt(m_socket, SOL_SOCKET, SO_ERROR, (char*)&error, &len); #else getsockopt(m_socket, SOL_SOCKET, SO_ERROR, (void*)&error, &len); #endif if (error) { m_callback(m_callback_data, NETLIB_MSG_CLOSE, (net_handle_t)m_socket, NULL); } else { m_state = SOCKET_STATE_CONNECTED; m_callback(m_callback_data, NETLIB_MSG_CONFIRM, (net_handle_t)m_socket, NULL); } } else { m_callback(m_callback_data, NETLIB_MSG_WRITE, (net_handle_t)m_socket, NULL); } } 走else分支,调用设置的回调函数imconn_callback: void imconn_callback(void* callback_data, uint8_t msg, uint32_t handle, void* pParam) { NOTUSED_ARG(handle); NOTUSED_ARG(pParam); if (!callback_data) return; ConnMap_t* conn_map = (ConnMap_t*)callback_data; CImConn* pConn = FindImConn(conn_map, handle); if (!pConn) return; //log(\"msg=%d, handle=%d \", msg, handle); switch (msg) { case NETLIB_MSG_CONFIRM: pConn->OnConfirm(); break; case NETLIB_MSG_READ: pConn->OnRead(); break; case NETLIB_MSG_WRITE: pConn->OnWrite(); break; case NETLIB_MSG_CLOSE: pConn->OnClose(); break; default: log(\"!!!imconn_callback error msg: %d \", msg); break; } pConn->ReleaseRef(); } 因为这次传入的消息是NETLIB_MSG_WRITE,所以走pConn->OnWrite分支,接着由于多态调用CImConn的子类CProxyConn的OnWrite()函数,但由于子类CProxyConn并没有改写OnWrite()方法,所以调用CImConn的OnWrite(): void CImConn::OnWrite() { if (!m_busy) return; while (m_out_buf.GetWriteOffset() > 0) { int send_size = m_out_buf.GetWriteOffset(); if (send_size > NETLIB_MAX_SOCKET_BUF_SIZE) { send_size = NETLIB_MAX_SOCKET_BUF_SIZE; } int ret = netlib_send(m_handle, m_out_buf.GetBuffer(), send_size); if (ret 接着继续从写缓冲区取出数据继续发送,如果还是只能发送出去,继续监听该socket可写事件,每次发送出去多少,就从写缓冲区中移除该部分字节。如果全部发送完了。将忙碌标志m_busy清零(false)。 至此,应答数据包的流程也介绍完了。我们来总结下该流程: //1. 主消息泵检测到有其他任务需要做,做之。 //2. 该任务是从全局的链表中取出应答包数据,找到对应的连接对象,然后尝试直接发出去; //3. 如果发不出,则将该数据存入该连接的发送缓冲区(写缓冲区),并监听该连接的socket可写事件。 //4. 下次该socket触发可写事件时,接着发送该连接的写缓冲区中剩余的数据。如此循环直到所有数据都发送成功。 //5. 取消监听该socket可写事件,以避免无数据的情况下触发写事件(该事件大多数情况下很频繁) 上面的流程从第2步到第5步也是主流网络库的发数据的逻辑。总而言之,就是说,先试着发送数据,如果发不出去,存起来,监听可写事件,下次触发可写事件后接着发。一直到数据全部发出去后,移除监听可写事件。通常只要可写事件是不断会触发的,所以默认不监听可写事件,只有数据发不出的时候才会监听可写事件。这个原则,千万要记住。 最后一个问题,是关于心跳包的,即db_proxy_server是如何发送心跳包的: 程序初始化的时候,注册一个定时器函数: init_proxy_conn(thread_num); int init_proxy_conn(uint32_t thread_num) { s_handler_map = CHandlerMap::getInstance(); g_thread_pool.Init(thread_num); netlib_add_loop(proxy_loop_callback, NULL); signal(SIGTERM, sig_handler); return netlib_register_timer(proxy_timer_callback, NULL, 1000); } 最后一行:return netlib_register_timer(proxy_timer_callback, NULL, 1000); 然后在消息泵里面检测定时器: _CheckTimer(); void CEventDispatch::_CheckTimer() { uint64_t curr_tick = get_tick_count(); list::iterator it; for (it = m_timer_list.begin(); it != m_timer_list.end(); ) { TimerItem* pItem = *it; it++; // iterator maybe deleted in the callback, so we should increment it before callback if (curr_tick >= pItem->next_tick) { pItem->next_tick += pItem->interval; pItem->callback(pItem->user_data, NETLIB_MSG_TIMER, 0, NULL); } } } uint64_t get_tick_count() { #ifdef _WIN32 LARGE_INTEGER liCounter; LARGE_INTEGER liCurrent; if (!QueryPerformanceFrequency(&liCounter)) return GetTickCount(); QueryPerformanceCounter(&liCurrent); return (uint64_t)(liCurrent.QuadPart * 1000 / liCounter.QuadPart); #else struct timeval tval; uint64_t ret_tick; gettimeofday(&tval, NULL); ret_tick = tval.tv_sec * 1000L + tval.tv_usec / 1000L; return ret_tick; #endif } 由上面的函数可以看出来定时器的单位是毫秒,当定时器时间到了后,调用回调函数proxy_timer_callback: void proxy_timer_callback(void* callback_data, uint8_t msg, uint32_t handle, void* pParam) { uint64_t cur_time = get_tick_count(); for (ConnMap_t::iterator it = g_proxy_conn_map.begin(); it != g_proxy_conn_map.end(); ) { ConnMap_t::iterator it_old = it; it++; CProxyConn* pConn = (CProxyConn*)it_old->second; pConn->OnTimer(cur_time); } } void CProxyConn::OnTimer(uint64_t curr_tick) { if (curr_tick > m_last_send_tick + SERVER_HEARTBEAT_INTERVAL) { CImPdu cPdu; IM::Other::IMHeartBeat msg; cPdu.SetPBMsg(&msg); cPdu.SetServiceId(IM::BaseDefine::SID_OTHER); cPdu.SetCommandId(IM::BaseDefine::CID_OTHER_HEARTBEAT); SendPdu(&cPdu); } if (curr_tick > m_last_recv_tick + SERVER_TIMEOUT) { log(\"proxy connection timeout %s:%d\", m_peer_ip.c_str(), m_peer_port); Close(); } } m_last_send_tick是上一次发送数据的时间,我们上文中介绍过,如果当前时间距上一次发送数据的时间已经超过了指定的时间间隔,则发送一个心跳包(这里的时间间隔是5000毫秒)。 m_last_recv_tick是上一次收取数据的时间,我们上文也介绍过,如果当前时间举例上一次接收时间已经超过了指定的时间间隔(相当于一段时间内,对端没有给当前服务发送任何数据),这个时候就关闭该连接(这里设置的时间间隔是30000毫秒,也就是30秒)。 这种心跳包机制特别值得推崇,也是常见的心跳包策略。 至此,db_proxy_server的框架和原理也就介绍完了。剩下的就是一些业务逻辑了。如果你感兴趣,可以自己查看对应的命令号绑定的处理函数的处理流程。 文中如果有说错的地方,欢迎提出留言指正。 如需下载本站全部技术文章,可以在【高性能服务器开发】公众号回复关键字“文章下载”即可。最近更新时间: 2020-10-04 14:34:33 "},"articles/TeamTalk源码解析/05服务器端msg_server源码分析.html":{"url":"articles/TeamTalk源码解析/05服务器端msg_server源码分析.html","title":"05 服务器端msg_server源码分析","keywords":"","body":"05 服务器端msg_server源码分析 在分析msg_server的源码之前,我们先简单地回顾一下msg_server在整个服务器系统中的位置和作用: 各个服务程序的作用描述如下: LoginServer (C++): 负载均衡服务器,分配一个负载小的MsgServer给客户端使用 MsgServer (C++): 消息服务器,提供客户端大部分信令处理功能,包括私人聊天、群组聊天等 RouteServer (C++): 路由服务器,为登录在不同MsgServer的用户提供消息转发功能 FileServer (C++): 文件服务器,提供客户端之间得文件传输服务,支持在线以及离线文件传输 MsfsServer (C++): 图片存储服务器,提供头像,图片传输中的图片存储服务 DBProxy (C++): 数据库代理服务器,提供mysql以及redis的访问服务,屏蔽其他服务器与mysql与redis的直接交互 HttpMsgServer(C++) :对外接口服务器,提供对外接口功能。(目前只是框架) PushServer(C++): 消息推送服务器,提供IOS系统消息推送。(IOS消息推送必须走apns) 从上面的介绍中,我们可以看出TeamTalk是支持分布式部署的一套聊天服务器程序,通过分布式部署可以实现分流和支持高数量的用户同时在线。msg_server是整个服务体系的核心系统,可以部署多个,不同的用户可以登录不同的msg_server。这套体系有如下几大亮点: login_server可以根据当前各个msg_server上在线用户数量,来决定一个新用户登录到哪个msg_server,从而实现了负载平衡; route_server可以将登录在不同的msg_server上的用户的聊天消息发给目标用户; 通过单独的一个数据库操作服务器db_proxy_server,避免了msg_server直接操作数据库,将数据库操作的入口封装起来。 在前一篇文章《服务器端db_proxy_server源码分析》中,我介绍了每个服务如何接收连接、读取数据并解包、以及组装数据包发包的操作,这篇文章我将介绍作为客户端,一个服务如何连接另外一个服务。这里msg_server在启动时会同时连接db_proxy_server,login_server,file_server,route_server,push_server。在msg_server服务main函数里面有如下初始化调用: //连接file_server init_file_serv_conn(file_server_list, file_server_count); //连接db_proxy_server init_db_serv_conn(db_server_list2, db_server_count2, concurrent_db_conn_cnt); //连接login_server init_login_serv_conn(login_server_list, login_server_count, ip_addr1, ip_addr2, listen_port, max_conn_cnt); //连接push_server init_route_serv_conn(route_server_list, route_server_count); //连接push_server init_push_serv_conn(push_server_list, push_server_count); 其中每个连接服务的流程都是一样的。我们这里以第一个连接file_server为例: void init_file_serv_conn(serv_info_t* server_list, uint32_t server_count) { g_file_server_list = server_list; g_file_server_count = server_count; serv_init(g_file_server_list, g_file_server_count); netlib_register_timer(file_server_conn_timer_callback, NULL, 1000); s_file_handler = CFileHandler::getInstance(); } template void serv_init(serv_info_t* server_list, uint32_t server_count) { for (uint32_t i = 0; i Connect(server_list[i].server_ip.c_str(), server_list[i].server_port, i); server_list[i].serv_conn = pConn; server_list[i].idle_cnt = 0; server_list[i].reconnect_cnt = MIN_RECONNECT_CNT / 2; } } 模板函数serv_init展开参数后实际上是调用CFileServConn->Connect(),我们看这个函数的调用: void CFileServConn::Connect(const char* server_ip, uint16_t server_port, uint32_t idx) { log(\"Connecting to FileServer %s:%d \", server_ip, server_port); m_serv_idx = idx; m_handle = netlib_connect(server_ip, server_port, imconn_callback, (void*)&g_file_server_conn_map); if (m_handle != NETLIB_INVALID_HANDLE) { g_file_server_conn_map.insert(make_pair(m_handle, this)); } } 在这个函数里面创建连接socket,将该socket加入全局map g_file_server_conn_map中保存,map的key是socket句柄值,值是当前连接对象CFileServConn的指针。注意这里设置了回调函数imconn_callback。我们来看netlib_connect()实际连接的代码: net_handle_t netlib_connect( const char* server_ip, uint16_t port, callback_t callback, void* callback_data) { CBaseSocket* pSocket = new CBaseSocket(); if (!pSocket) return NETLIB_INVALID_HANDLE; net_handle_t handle = pSocket->Connect(server_ip, port, callback, callback_data); if (handle == NETLIB_INVALID_HANDLE) delete pSocket; return handle; } net_handle_t CBaseSocket::Connect(const char* server_ip, uint16_t port, callback_t callback, void* callback_data) { log(\"CBaseSocket::Connect, server_ip=%s, port=%d\", server_ip, port); m_remote_ip = server_ip; m_remote_port = port; m_callback = callback; m_callback_data = callback_data; m_socket = socket(AF_INET, SOCK_STREAM, 0); if (m_socket == INVALID_SOCKET) { log(\"socket failed, err_code=%d\", _GetErrorCode()); return NETLIB_INVALID_HANDLE; } _SetNonblock(m_socket); _SetNoDelay(m_socket); sockaddr_in serv_addr; _SetAddr(server_ip, port, &serv_addr); int ret = connect(m_socket, (sockaddr*)&serv_addr, sizeof(serv_addr)); if ( (ret == SOCKET_ERROR) && (!_IsBlock(_GetErrorCode())) ) { log(\"connect failed, err_code=%d\", _GetErrorCode()); closesocket(m_socket); return NETLIB_INVALID_HANDLE; } m_state = SOCKET_STATE_CONNECTING; AddBaseSocket(this); CEventDispatch::Instance()->AddEvent(m_socket, SOCKET_ALL); return (net_handle_t)m_socket; } 注意这里有以下几点: 将socket设置成非阻塞的。这样如果底层连接函数connect()不能立马完成,connect会立刻返回。 将socket的状态设置成SOCKET_STATE_CONNECTING。 AddBaseSocket(this)将该socket加入一个全局map中。 关注该socket的所有事件(SOCKET_ALL)。 enum { SOCKET_READ = 0x1, SOCKET_WRITE = 0x2, SOCKET_EXCEP = 0x4, SOCKET_ALL = 0x7 }; 因为socket是非阻塞,所以connect可能没连接成功,也会立即返回。那连接成功以后,我们如何得知呢?还记得上一篇文章中介绍的主线程的消息泵吗?TeamTalk每个服务的主线程都有一个这样的消息泵: while(退出条件) { //1. 遍历定时器队列,检测是否有定时器事件到期,有则执行定时器的回调函数 //2. 遍历其他任务队列,检测是否有其他任务需要执行,有,执行之 //3. 检测socket集合,分离可读、可写和异常事件 //4. 处理socket可读事件 //5. 处理socket可写事件 //6. 处理socket异常事件 } 当socket连接成功以后,该socket立马会变的可写。此时会触发第5步中的可写事件: void CBaseSocket::OnWrite() { #if ((defined _WIN32) || (defined __APPLE__)) CEventDispatch::Instance()->RemoveEvent(m_socket, SOCKET_WRITE); #endif if (m_state == SOCKET_STATE_CONNECTING) { int error = 0; socklen_t len = sizeof(error); #ifdef _WIN32 getsockopt(m_socket, SOL_SOCKET, SO_ERROR, (char*)&error, &len); #else getsockopt(m_socket, SOL_SOCKET, SO_ERROR, (void*)&error, &len); #endif if (error) { m_callback(m_callback_data, NETLIB_MSG_CLOSE, (net_handle_t)m_socket, NULL); } else { m_state = SOCKET_STATE_CONNECTED; m_callback(m_callback_data, NETLIB_MSG_CONFIRM, (net_handle_t)m_socket, NULL); } } else { m_callback(m_callback_data, NETLIB_MSG_WRITE, (net_handle_t)m_socket, NULL); } } 由于该socket的状态是SOCKET_STATE_CONNECTING,会走第一个if分支。在不出错的情况下,以参数NETLIB_MSG_CONFIRM调用之前设置的回调函数imconn_callback。 void imconn_callback(void* callback_data, uint8_t msg, uint32_t handle, void* pParam) { NOTUSED_ARG(handle); NOTUSED_ARG(pParam); if (!callback_data) return; ConnMap_t* conn_map = (ConnMap_t*)callback_data; CImConn* pConn = FindImConn(conn_map, handle); if (!pConn) return; //log(\"msg=%d, handle=%d \", msg, handle); switch (msg) { case NETLIB_MSG_CONFIRM: pConn->OnConfirm(); break; case NETLIB_MSG_READ: pConn->OnRead(); break; case NETLIB_MSG_WRITE: pConn->OnWrite(); break; case NETLIB_MSG_CLOSE: pConn->OnClose(); break; default: log(\"!!!imconn_callback error msg: %d \", msg); break; } pConn->ReleaseRef(); } 这次走pConn->OnConfirm();分支,由于pConn实际是CImConn的子类对象,根据C++多态性,会调用CFileServConn的OnConfirm()函数: void CFileServConn::OnConfirm() { log(\"connect to file server success \"); m_bOpen = true; m_connect_time = get_tick_count(); g_file_server_list[m_serv_idx].reconnect_cnt = MIN_RECONNECT_CNT / 2; //连上file_server以后,给file_server发送获取ip地址的数据包 IM::Server::IMFileServerIPReq msg; CImPdu pdu; pdu.SetPBMsg(&msg); pdu.SetServiceId(SID_OTHER); pdu.SetCommandId(CID_OTHER_FILE_SERVER_IP_REQ); SendPdu(&pdu); } 连接上file_server后,msg_server会立即给file_server发一个数据包,以获得file_server的ip地址等信息。 这就是msg_server作为客户端连接其他服务的流程。与这些服务之间的连接都对应一个连接对象: file_server CFileServConn db_proxy_server CDBServConn login_server CLoginServConn route_server CRouteServConn push_server CPushServConn 而且,和连接file_server一样,msg_server在连接这些服务成功以后,可能会需要将自己的一些状态信息告诉对方: 连接file_server成功后,给对方发包获取对方的ip地址等信息 连接login_server成功以后,告诉login_server自己的ip地址、端口号和当前登录的用户数量和可容纳的最大用户数量,这样login_server将来对于一个需要登录的用户,会根据不同的msg_server的负载状态来决定用户到底登录哪个msg_server。 void CLoginServConn::OnConfirm() { log(\"connect to login server success \"); m_bOpen = true; g_login_server_list[m_serv_idx].reconnect_cnt = MIN_RECONNECT_CNT / 2; uint32_t cur_conn_cnt = 0; uint32_t shop_user_cnt = 0; //连接login_server成功以后,告诉login_server自己的ip地址、端口号 //和当前登录的用户数量和可容纳的最大用户数量 list user_conn_list; CImUserManager::GetInstance()->GetUserConnCnt(&user_conn_list, cur_conn_cnt); char hostname[256] = {0}; gethostname(hostname, 256); IM::Server::IMMsgServInfo msg; msg.set_ip1(g_msg_server_ip_addr1); msg.set_ip2(g_msg_server_ip_addr2); msg.set_port(g_msg_server_port); msg.set_max_conn_cnt(g_max_conn_cnt); msg.set_cur_conn_cnt(cur_conn_cnt); msg.set_host_name(hostname); CImPdu pdu; pdu.SetPBMsg(&msg); pdu.SetServiceId(SID_OTHER); pdu.SetCommandId(CID_OTHER_MSG_SERV_INFO); SendPdu(&pdu); } 连接route_server成功以后,给route_server发包告诉当前登录在本msg_server上有哪些用户(用户id、用户状态、用户客户端类型)。这样将来A用户给B发聊天消息,msg_server将该聊天消息转给route_server,route_server就知道用户B在哪个msg_server上了,以便将该聊天消息发给B所在的msg_server。 void CRouteServConn::OnConfirm() { log(\"connect to route server success \"); m_bOpen = true; m_connect_time = get_tick_count(); g_route_server_list[m_serv_idx].reconnect_cnt = MIN_RECONNECT_CNT / 2; if (g_master_rs_conn == NULL) { update_master_route_serv_conn(); } //连接route_server成功以后,给route_server发包告诉当前登录在本msg_server上有哪些 //用户(用户id、用户状态、用户客户端类型) list online_user_list; CImUserManager::GetInstance()->GetOnlineUserInfo(&online_user_list); IM::Server::IMOnlineUserInfo msg; for (list::iterator it = online_user_list.begin(); it != online_user_list.end(); it++) { user_stat_t user_stat = *it; IM::BaseDefine::ServerUserStat* server_user_stat = msg.add_user_stat_list(); server_user_stat->set_user_id(user_stat.user_id); server_user_stat->set_status((::IM::BaseDefine::UserStatType)user_stat.status); server_user_stat->set_client_type((::IM::BaseDefine::ClientType)user_stat.client_type); } CImPdu pdu; pdu.SetPBMsg(&msg); pdu.SetServiceId(SID_OTHER); pdu.SetCommandId(CID_OTHER_ONLINE_USER_INFO); SendPdu(&pdu); } 再来提一下,心跳包机制,和上一篇文章中介绍个与db_proxy_server一样,都是在定时器里面做的,这里不再赘述了,简单地贴出与file_server的心跳包代码吧: void init_file_serv_conn(serv_info_t* server_list, uint32_t server_count) { g_file_server_list = server_list; g_file_server_count = server_count; serv_init(g_file_server_list, g_file_server_count); netlib_register_timer(file_server_conn_timer_callback, NULL, 1000); s_file_handler = CFileHandler::getInstance(); } void file_server_conn_timer_callback(void* callback_data, uint8_t msg, uint32_t handle, void* pParam) { ConnMap_t::iterator it_old; CFileServConn* pConn = NULL; uint64_t cur_time = get_tick_count(); for (ConnMap_t::iterator it = g_file_server_conn_map.begin(); it != g_file_server_conn_map.end(); ) { it_old = it; it++; pConn = (CFileServConn*)it_old->second; pConn->OnTimer(cur_time); } // reconnect FileServer serv_check_reconnect(g_file_server_list, g_file_server_count); } 在注册的定时器回调函数里面调用CFileServConn::OnTimer函数: void CFileServConn::OnTimer(uint64_t curr_tick) { if (curr_tick > m_last_send_tick + SERVER_HEARTBEAT_INTERVAL) { IM::Other::IMHeartBeat msg; CImPdu pdu; pdu.SetPBMsg(&msg); pdu.SetServiceId(SID_OTHER); pdu.SetCommandId(CID_OTHER_HEARTBEAT); SendPdu(&pdu); } if (curr_tick > m_last_recv_tick + SERVER_TIMEOUT) { log(\"conn to file server timeout \"); Close(); } } 接下来的就是每个连接上的业务处理代码了,主消息泵收到数据后触发OnRead函数,然后收取数据解包,然后根据包的命令号处理包,所以每个连接对象根据自己的业务都有一个HandlePdu()函数,例如CFileServConn的: void CFileServConn::HandlePdu(CImPdu* pPdu) { switch (pPdu->GetCommandId()) { case CID_OTHER_HEARTBEAT: break; case CID_OTHER_FILE_TRANSFER_RSP: _HandleFileMsgTransRsp(pPdu); break; case CID_OTHER_FILE_SERVER_IP_RSP: _HandleFileServerIPRsp(pPdu); break; default: log(\"unknown cmd id=%d \", pPdu->GetCommandId()); break; } } 当然有些数据包,msg_server直接自己装包应答就可以了。有些必须发到其他服务进行进一步处理,比如登录请求,发给db_proxy_server拿到mysql中校验用户名和密码,db_proxy_server校验完成后,再应答msg_server,msg_server再应答客户端。 这大概就是msg_server服务的结构和源码了吧。具体业务代码你可以查看每个连接对象的HandlePdu()函数来看具体的流程细节。 需要指出的是:连接服务器、接受连接、收取数据解包、发送数据这四个模块是一个完整的网路库必须具有的东西。这篇文章和上一篇文章完整地介绍了这四个模块,而TeamTalk的实现手法也是目前主流网络库的通用做法。如果从事服务器开发,必须熟练掌握这里面的具体每个细节。而teamtalk服务器这种分布式架构设计的思想也是非常值得学习和借鉴的。 如需下载本站全部技术文章,可以在【高性能服务器开发】公众号回复关键字“文章下载”即可。最近更新时间: 2020-10-04 14:34:28 "},"articles/TeamTalk源码解析/06服务器端login_server源码分析.html":{"url":"articles/TeamTalk源码解析/06服务器端login_server源码分析.html","title":"06 服务器端login_server源码分析","keywords":"","body":"06 服务器端login_server源码分析 login_server从严格意义上来说,是一个登录分流器,所以名字起的有点名不符实。该服务根据已知的msg_server上的在线用户数量来返回告诉一个即将登录的用户登录哪个msg_server比较合适。关于其程序框架的非业务代码我们已经在前面的两篇文章《服务器端db_proxy_server源码分析》和《服务器端msg_server源码分析》中介绍过了。这篇文章主要介绍下其业务代码。 首先,程序初始化的时候,会初始化如下功能: //1. 在8008端口监听客户端连接 //2. 在8100端口上监听msg_server的连接 //3. 在8080端口上监听客户端http连接 其中连接对象CLoginConn代表着login_server与msg_server之间的连接;而CHttpConn代表着与客户端的http连接。我们先来看CLoginConn对象,上一篇文章中也介绍了其业务代码主要在其HandlePdu()函数中,可以看到这路连接主要处理哪些数据包: void CLoginConn::HandlePdu(CImPdu* pPdu) { switch (pPdu->GetCommandId()) { case CID_OTHER_HEARTBEAT: break; case CID_OTHER_MSG_SERV_INFO: _HandleMsgServInfo(pPdu); break; case CID_OTHER_USER_CNT_UPDATE: _HandleUserCntUpdate(pPdu); break; case CID_LOGIN_REQ_MSGSERVER: _HandleMsgServRequest(pPdu); break; default: log(\"wrong msg, cmd id=%d \", pPdu->GetCommandId()); break; } } 命令号CID_OTHER_HEARTBEAT是与msg_server的心跳包。上一篇文章《服务器端msg_server源码分析》中介绍过,msg_server连上login_server后会立刻给login_server发一个数据包,该数据包里面含有该msg_server上的用户数量、最大可容纳的用户数量、自己的ip地址和端口号。 list user_conn_list; CImUserManager::GetInstance()->GetUserConnCnt(&user_conn_list, cur_conn_cnt); char hostname[256] = {0}; gethostname(hostname, 256); IM::Server::IMMsgServInfo msg; msg.set_ip1(g_msg_server_ip_addr1); msg.set_ip2(g_msg_server_ip_addr2); msg.set_port(g_msg_server_port); msg.set_max_conn_cnt(g_max_conn_cnt); msg.set_cur_conn_cnt(cur_conn_cnt); msg.set_host_name(hostname); CImPdu pdu; pdu.SetPBMsg(&msg); pdu.SetServiceId(SID_OTHER); pdu.SetCommandId(CID_OTHER_MSG_SERV_INFO); SendPdu(&pdu); 命令号是CID_OTHER_MSG_SERV_INFO。我们来看下login_server如何处理这个命令的: void CLoginConn::_HandleMsgServInfo(CImPdu* pPdu) { msg_serv_info_t* pMsgServInfo = new msg_serv_info_t; IM::Server::IMMsgServInfo msg; msg.ParseFromArray(pPdu->GetBodyData(), pPdu->GetBodyLength()); pMsgServInfo->ip_addr1 = msg.ip1(); pMsgServInfo->ip_addr2 = msg.ip2(); pMsgServInfo->port = msg.port(); pMsgServInfo->max_conn_cnt = msg.max_conn_cnt(); pMsgServInfo->cur_conn_cnt = msg.cur_conn_cnt(); pMsgServInfo->hostname = msg.host_name(); g_msg_serv_info.insert(make_pair(m_handle, pMsgServInfo)); g_total_online_user_cnt += pMsgServInfo->cur_conn_cnt; log(\"MsgServInfo, ip_addr1=%s, ip_addr2=%s, port=%d, max_conn_cnt=%d, cur_conn_cnt=%d, \"\\ \"hostname: %s. \", pMsgServInfo->ip_addr1.c_str(), pMsgServInfo->ip_addr2.c_str(), pMsgServInfo->port,pMsgServInfo->max_conn_cnt, pMsgServInfo->cur_conn_cnt, pMsgServInfo->hostname.c_str()); } 其实所做的工作无非就是记录下的该msg_server上的ip、端口号、在线用户数量和最大可容纳用户数量等信息而已。存在一个全局map里面: map g_msg_serv_info; typedef struct { string ip_addr1; // 电信IP string ip_addr2; // 网通IP uint16_t port; uint32_t max_conn_cnt; uint32_t cur_conn_cnt; string hostname; // 消息服务器的主机名 } msg_serv_info_t; 另外一个命令号CID_OTHER_USER_CNT_UPDATE,是当msg_server上的用户上线或下线时,msg_server给login_server发该类型的命令号,让login_server更新保存的msg_server的上的在线用户数量: void CLoginConn::_HandleUserCntUpdate(CImPdu* pPdu) { map::iterator it = g_msg_serv_info.find(m_handle); if (it != g_msg_serv_info.end()) { msg_serv_info_t* pMsgServInfo = it->second; IM::Server::IMUserCntUpdate msg; msg.ParseFromArray(pPdu->GetBodyData(), pPdu->GetBodyLength()); uint32_t action = msg.user_action(); if (action == USER_CNT_INC) { pMsgServInfo->cur_conn_cnt++; g_total_online_user_cnt++; } else { pMsgServInfo->cur_conn_cnt--; g_total_online_user_cnt--; } log(\"%s:%d, cur_cnt=%u, total_cnt=%u \", pMsgServInfo->hostname.c_str(), pMsgServInfo->port, pMsgServInfo->cur_conn_cnt, g_total_online_user_cnt); } } 命令号CID_LOGIN_REQ_MSGSERVER没用到。 接着说login_server与客户端的http连接处理,这个连接收取数据和解包是直接在CHttpConn的OnRead函数里面处理的: void CHttpConn::OnRead() { for (;;) { uint32_t free_buf_len = m_in_buf.GetAllocSize() - m_in_buf.GetWriteOffset(); if (free_buf_len 1024) { log(\"get too much data:%s \", in_buf); Close(); return; } //log(\"OnRead, buf_len=%u, conn_handle=%u\\n\", buf_len, m_conn_handle); // for debug m_cHttpParser.ParseHttpContent(in_buf, buf_len); if (m_cHttpParser.IsReadAll()) { string url = m_cHttpParser.GetUrl(); if (strncmp(url.c_str(), \"/msg_server\", 11) == 0) { string content = m_cHttpParser.GetBodyContent(); _HandleMsgServRequest(url, content); } else { log(\"url unknown, url=%s \", url.c_str()); Close(); } } } 如果用户发送的http请求的地址形式是http://192.168.226.128:8080/msg_server,即路径是/msg_server,则调用_HandleMsgServRequest()函数处理: void CHttpConn::_HandleMsgServRequest(string& url, string& post_data) { msg_serv_info_t* pMsgServInfo; uint32_t min_user_cnt = (uint32_t)-1; map::iterator it_min_conn = g_msg_serv_info.end(); map::iterator it; if(g_msg_serv_info.size() second; if ( (pMsgServInfo->cur_conn_cnt max_conn_cnt) && (pMsgServInfo->cur_conn_cnt cur_conn_cnt; } } if (it_min_conn == g_msg_serv_info.end()) { log(\"All TCP MsgServer are full \"); Json::Value value; value[\"code\"] = 2; value[\"msg\"] = \"负载过高\"; string strContent = value.toStyledString(); char* szContent = new char[HTTP_RESPONSE_HTML_MAX]; snprintf(szContent, HTTP_RESPONSE_HTML_MAX, HTTP_RESPONSE_HTML, strContent.length(), strContent.c_str()); Send((void*)szContent, strlen(szContent)); delete [] szContent; return; } else { Json::Value value; value[\"code\"] = 0; value[\"msg\"] = \"\"; if(pIpParser->isTelcome(GetPeerIP())) { value[\"priorIP\"] = string(it_min_conn->second->ip_addr1); value[\"backupIP\"] = string(it_min_conn->second->ip_addr2); value[\"msfsPrior\"] = strMsfsUrl; value[\"msfsBackup\"] = strMsfsUrl; } else { value[\"priorIP\"] = string(it_min_conn->second->ip_addr2); value[\"backupIP\"] = string(it_min_conn->second->ip_addr1); value[\"msfsPrior\"] = strMsfsUrl; value[\"msfsBackup\"] = strMsfsUrl; } value[\"discovery\"] = strDiscovery; value[\"port\"] = int2string(it_min_conn->second->port); string strContent = value.toStyledString(); char* szContent = new char[HTTP_RESPONSE_HTML_MAX]; uint32_t nLen = strContent.length(); snprintf(szContent, HTTP_RESPONSE_HTML_MAX, HTTP_RESPONSE_HTML, nLen, strContent.c_str()); Send((void*)szContent, strlen(szContent)); delete [] szContent; return; } } 其实就是根据记录的msg_server的负载情况,返回一个可用的msg_server ip和端口给客户端,这是一个json格式: { \"backupIP\" : \"localhost\", \"code\" : 0, \"discovery\" : \"http://192.168.226.128/api/discovery\", \"msfsBackup\" : \"http://127.0.0.1:8700/\", \"msfsPrior\" : \"http://127.0.0.1:8700/\", \"msg\" : \"\", \"port\" : \"8000\", \"priorIP\" : \"localhost\" } 里面含有msg_server和聊天图片存放的服务器地址(msfsPrior)字段。这样客户端可以拿着这个地址去登录msg_server和图片服务器了。 发出去这个json之后会调用OnWriteComplete()函数,这个函数立刻关闭该http连接,也就是说这个与客户端的http连接是短连接: void CHttpConn::OnWriteComlete() { log(\"write complete \"); Close(); } login_server就这么多内容了。 如需下载本站全部技术文章,可以在【高性能服务器开发】公众号回复关键字“文章下载”即可。最近更新时间: 2020-10-04 15:31:59 "},"articles/TeamTalk源码解析/07服务器端msfs源码分析.html":{"url":"articles/TeamTalk源码解析/07服务器端msfs源码分析.html","title":"07 服务器端msfs源码分析","keywords":"","body":"07 服务器端msfs源码分析 这篇文章是对TeamTalk服务程序msfs的源码和架构设计分析。msfs作用是用来接受teamtalk聊天中产生的聊天图片的上传和下载。还是老规矩,把该服务在整个架构中的位置图贴一下吧。 可以看到,msfs仅被客户端连接,客户端以http的方式来上传和下载聊天图片。 可能很多同学对http协议不是很熟悉,或者说一知半解。这里大致介绍一下http协议,http协议其实也是一种应用层协议,建立在tcp/ip层之上,其由包头和包体两部分组成(不一定要有包体),看个例子: 比如当我们用浏览器请求一个网址http://www.hootina.org/index.php,实际是浏览器给特定的服务器发送如下数据包,包头部分如下: GET /index.php HTTP/1.1\\r\\n Host: www.hootina.org\\r\\n Connection: keep-alive\\r\\n Cache-Control: max-age=0\\r\\n Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,/;q=0.8\\r\\n User-Agent: Mozilla/5.0\\r\\n \\r\\n 这个包没有包体。 从上面我们可以看出一个http协议大致格式可以描述如下: GET或Post请求方法 请求的资源路径 http协议版本号\\r\\n 字段名1:值1\\r\\n 字段名2:值2\\r\\n 字段名3:值3\\r\\n 字段名4:值4\\r\\n 字段名5:值5\\r\\n 字段名6:值6\\r\\n \\r\\n 也就是是http协议的头部是一行一行的,每一行以\\r\\n表示该行结束,最后多出一个空行以\\r\\n结束表示头部的结束。接下来就是包体的大小了(如果有的话,上文的例子没有包体)。一般get方法会将参数放在请求的资源路径后面,像这样 http://wwww.hootina.org/index.php?变量1=值1&变量2=值2&变量3=值3&变量4=值4 网址后面的问号表示参数开始,每一个参数与参数之间用&隔开 还有一种post的请求方法,这种数据就是将数据放在包体里面了,例如: POST /otn/login/loginAysnSuggest HTTP/1.1\\r\\n Host: kyfw.12306.cn\\r\\n Connection: keep-alive\\r\\n Content-Length: 96\\r\\n Accept: */*\\r\\n Origin: https://kyfw.12306.cn\\r\\n X-Requested-With: XMLHttpRequest\\r\\n User-Agent: Mozilla/5.0 (Windows NT 6.1; WOW64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/55.0.2883.75\\r\\n Content-Type: application/x-www-form-urlencoded; charset=UTF-8\\r\\n Referer: https://kyfw.12306.cn/otn/login/init\\r\\n Accept-Encoding: gzip, deflate, br\\r\\n Accept-Language: zh-CN,zh;q=0.8\\r\\n \\r\\n loginUserDTO.user_name=balloonwj%40qq.com&userDTO.password=xxxxgjqf&randCode=184%2C55%2C37%2C117 上述报文中loginUserDTO.user_name=balloonwj%40qq.com&userDTO.password=2032_scsgjqf&randCode=184%2C55%2C37%2C117 其实包体内容,这个包是我的一个12306买票软件发给12306服务器的报文。这里拿来做个例子。 因为对方收到http报文的时候,如果包体有内容,那么必须告诉对方包体有多大。这个最常用的就是通过包头的Content-Length字段来指定大小。上面的例子中Content-Length等于96,正好就是字符串 loginUserDTO.user_name=balloonwj%40qq.com&userDTO.password=xxxxgjqf&randCode=184%2C55%2C37%2C117 的长度,也就是包体的大小。 还有一种叫做http chunk的编码技术,通过对http包内容进行分块传输。这里就不介绍了(如果你感兴趣,可以私聊我)。 常见的对http协议有如下几个误解: html文档的头就是http的头 这种认识是错误的,html文档的头部也是http数据包的包体的一部分。正确的http头是长的像上文介绍的那种。 关于http头Connection:keep-alive字段 一端指定了这个字段后,发http包给另外一端。这个选项只是一种建议性的选项,对端不一定必须采纳,对方也可能在实际实现时,将http连接设置为短连接,即不采纳这个字段的建议。 每个字段都是必须的吗? 不是,大多数字段都不是必须的。但是特定的情况下,某些字段是必须的。比如,通过post发送的数据,就必须设置Content-Length。不然,收包的一端如何知道包体多大。又比如如果你的数据采取了gzip压缩格式,你就必须指定Accept-Encoding: gzip,然对方如何解包你的数据。 好了,http协议就暂且介绍这么多,下面回到正题上来说msfs的源码。 msfs在main函数里面做了如下初始化工作,伪码如下: //1. 建立一个两个任务队列,分别处理http get请求和post请求 //2. 创建名称为000~255的文件夹,每个文件夹里面会有000~255个子目录,这些目录用于存放聊天图片 //3. 在8700端口上监听客户端连接 //4. 启动程序消息泵 第1点,建立任务队列我们前面系列的文章已经介绍过了。 第2点,代码如下: g_fileManager = FileManager::getInstance(listen_ip, base_dir, fileCnt, filesPerDir); int ret = g_fileManager->initDir(); int FileManager::initDir() { bool isExist = File::isExist(m_disk); if (!isExist) { u64 ret = File::mkdirNoRecursion(m_disk); if (ret) { log(\"The dir[%s] set error for code[%d], \\ its parent dir may no exists\", m_disk, ret); return -1; } } //255 X 255 char first[10] = {0}; char second[10] = {0}; for (int i = 0; i 下面,我们直接来看如何处理客户端的http请求,当连接对象CHttpConn收到客户端数据后,调用OnRead方法: void CHttpConn::OnRead() { for (;;) { uint32_t free_buf_len = m_in_buf.GetAllocSize() - m_in_buf.GetWriteOffset(); if (free_buf_len HTTP_UPLOAD_MAX) { // file is too big log(\"content is too big\"); char url[128]; snprintf(url, sizeof(url), \"{\\\"error_code\\\":1,\\\"error_msg\\\": \\\"上传文件过大\\\",\\\"url\\\":\\\"\\\"}\"); log(\"%s\",url); uint32_t content_length = strlen(url); char pContent[1024]; snprintf(pContent, sizeof(pContent), HTTP_RESPONSE_HTML, content_length,url); Send(pContent, strlen(pContent)); return; } int nContentLen = m_HttpParser.GetContentLen(); char* pContent = NULL; if(nContentLen != 0) { try { pContent =new char[nContentLen]; memcpy(pContent, m_HttpParser.GetBodyContent(), nContentLen); } catch(...) { log(\"not enough memory\"); char szResponse[HTTP_RESPONSE_500_LEN + 1]; snprintf(szResponse, HTTP_RESPONSE_500_LEN, \"%s\", HTTP_RESPONSE_500); Send(szResponse, HTTP_RESPONSE_500_LEN); return; } } Request_t request; request.conn_handle = m_conn_handle; request.method = m_HttpParser.GetMethod();; request.nContentLen = nContentLen; request.pContent = pContent; request.strAccessHost = m_HttpParser.GetHost(); request.strContentType = m_HttpParser.GetContentType(); request.strUrl = m_HttpParser.GetUrl() + 1; CHttpTask* pTask = new CHttpTask(request); if(HTTP_GET == m_HttpParser.GetMethod()) { g_GetThreadPool.AddTask(pTask); } else { g_PostThreadPool.AddTask(pTask); } } } 该方法先收取数据,接着解包,然后根据客户端发送的http请求到底是get还是post方法,分别往对应的get和post任务队列中丢一个任务CHttpTask。任务队列开始处理这个任务。我们以get请求的任务为例(Post请求与此类似): void CHttpTask::run() { if(HTTP_GET == m_nMethod) { OnDownload(); } else if(HTTP_POST == m_nMethod) { OnUpload(); } else { char* pContent = new char[strlen(HTTP_RESPONSE_403)]; snprintf(pContent, strlen(HTTP_RESPONSE_403), HTTP_RESPONSE_403); CHttpConn::AddResponsePdu(m_ConnHandle, pContent, strlen(pContent)); } if(m_pContent != NULL) { delete [] m_pContent; m_pContent = NULL; } } 处理任务时,根据请求类型判断到底是客户端下载图片还是上传图片,如果是下载图片则从本机缓存的图片信息中找到该图片,并读取该图片数据,因为是聊天图片,所以一般不会很大,所以这里都是一次性读取图片字节内容,然后发出去。 void CHttpTask::OnDownload() { uint32_t nFileSize = 0; int32_t nTmpSize = 0; string strPath; if(g_fileManager->getAbsPathByUrl(m_strUrl, strPath ) == 0) { nTmpSize = File::getFileSize((char*)strPath.c_str()); if(nTmpSize != -1) { char szResponseHeader[1024]; size_t nPos = strPath.find_last_of(\".\"); string strType = strPath.substr(nPos + 1, strPath.length() - nPos); if(strType == \"jpg\" || strType == \"JPG\" || strType == \"jpeg\" || strType == \"JPEG\" || strType == \"png\" || strType == \"PNG\" || strType == \"gif\" || strType == \"GIF\") { snprintf(szResponseHeader, sizeof(szResponseHeader), HTTP_RESPONSE_IMAGE, nTmpSize, strType.c_str()); } else { snprintf(szResponseHeader,sizeof(szResponseHeader), HTTP_RESPONSE_EXTEND, nTmpSize); } int nLen = strlen(szResponseHeader); char* pContent = new char[nLen + nTmpSize]; memcpy(pContent, szResponseHeader, nLen); g_fileManager->downloadFileByUrl((char*)m_strUrl.c_str(), pContent + nLen, &nFileSize); int nTotalLen = nLen + nFileSize; CHttpConn::AddResponsePdu(m_ConnHandle, pContent, nTotalLen); } else { int nTotalLen = strlen(HTTP_RESPONSE_404); char* pContent = new char[nTotalLen]; snprintf(pContent, nTotalLen, HTTP_RESPONSE_404); CHttpConn::AddResponsePdu(m_ConnHandle, pContent, nTotalLen); log(\"File size is invalied\\n\"); } } else { int nTotalLen = strlen(HTTP_RESPONSE_500); char* pContent = new char[nTotalLen]; snprintf(pContent, nTotalLen, HTTP_RESPONSE_500); CHttpConn::AddResponsePdu(m_ConnHandle, pContent, nTotalLen); } } 这里需要说明一下的就是FileManager::getAbsPathByUrl在获取本地文件时,用了一个锁,该锁是为了防止同一个进程同时读取同一个文件,这个锁是“建议性”的,必须自己主动检测有没有上锁: int FileManager::getAbsPathByUrl(const string &url, string &path) { string relate; if (getRelatePathByUrl(url, relate)) { log(\"Get path from url[%s] error\", url.c_str()); return -1; } path = string(m_disk) + relate; return 0; } u64 File::open(bool directIo) { assert(!m_opened); int flags = O_RDWR; #ifdef __linux__ m_file = open64(m_path, flags); #elif defined(__FREEBSD__) || defined(__APPLE__) m_file = ::open(m_path, flags); #endif if(-1 == m_file) { return errno; } #ifdef __LINUX__ if (directIo) if (-1 == fcntl(m_file, F_SETFL, O_DIRECT)) return errno; #endif struct flock lock; lock.l_type = F_WRLCK; lock.l_start = 0; lock.l_whence = SEEK_SET; lock.l_len = 0; if(fcntl(m_file, F_SETLK, &lock) 注意上面的fcntl函数设置的flock锁。这个是linux特有的,应该学习掌握。 图片上传的逻辑和下载逻辑大致类似,这里就不再分析了。 当然,发送图片数据的包和前面的发送逻辑也是一样的,在OnWrite里面发送。发送完毕后会调用CHttpConn::OnSendComplete,在这个函数里面关闭http连接。这也就是说msfs与客户端的http连接也是短连接。 void CHttpConn::OnSendComplete() { Close(); } 关于msfs也就这么多内容了。不知道你有没有发现,在搞清楚db_proxy_server和msg_server之后,每个程序框架其实都是一样的,只不过业务逻辑稍微有一点差别。后面介绍的file_server和route_server都是一样的。我们也着重分析其业务代码。 好了,msfs服务就这么多啦。 如需下载本站全部技术文章,可以在【高性能服务器开发】公众号回复关键字“文章下载”即可。最近更新时间: 2020-10-04 15:32:13 "},"articles/TeamTalk源码解析/08服务器端file_server源码分析.html":{"url":"articles/TeamTalk源码解析/08服务器端file_server源码分析.html","title":"08 服务器端file_server源码分析","keywords":"","body":"08 服务器端file_server源码分析 这篇文章我们来介绍file_server服务的功能和源码实现。TeamTalk支持离线在线文件和离线文件两种传送文件的方式。单纯地研究file_server的程序结构没多大意义,因为其程序结构和其他几个服务结构基本上一模一样,前面几篇文章已经介绍过了。 我们研究teamtalk的file_server是为了学习和借鉴teamtalk的文件传输功能实现思路,以内化为自己的知识,并加以应用。 所以这篇文章,我们将pc客户端的文件传输功能、msg_server转发消息、file_server处理文件数据三个方面结合起来一起介绍。 下面开始啦。 一、连接状况介绍 fileserver开始并不是和客户端连接的,客户端是按需连接file_server的。但是file_server与msg_server却是长连接。先启动file_server,再启动msg_server。msg_server初始化的时候,会去尝试连接file_server的8601端口。连接成功以后,会给file_server发送一个发包询问file_server侦听客户端连接的ip和端口号信息: void CFileServConn::OnConfirm() { log(\"connect to file server success \"); m_bOpen = true; m_connect_time = get_tick_count(); g_file_server_list[m_serv_idx].reconnect_cnt = MIN_RECONNECT_CNT / 2; //连上file_server以后,给file_server发送获取ip地址的数据包 IM::Server::IMFileServerIPReq msg; CImPdu pdu; pdu.SetPBMsg(&msg); pdu.SetServiceId(SID_OTHER); pdu.SetCommandId(CID_OTHER_FILE_SERVER_IP_REQ); SendPdu(&pdu); } file_server收到该数据包后,将自己的侦听客户端连接的ip地址和端口号发包告诉msg_server: void FileMsgServerConn::_HandleGetServerAddressReq(CImPdu* pPdu) { IM::Server::IMFileServerIPRsp msg; const std::list& addrs = ConfigUtil::GetInstance()->GetAddressList(); for (std::list::const_iterator it=addrs.begin(); it!=addrs.end(); ++it) { IM::BaseDefine::IpAddr* addr = msg.add_ip_addr_list(); *addr = *it; log(\"Upload file_client_conn addr info, ip=%s, port=%d\", addr->ip().c_str(), addr->port()); } SendMessageLite(this, SID_OTHER, CID_OTHER_FILE_SERVER_IP_RSP, pPdu->GetSeqNum(), &msg); } 得到的信息是file_server侦听的ip地址和端口号,默认配置的端口号是8600。也就是说file_server的8600用于客户端连接,8601端口用于msg_server连接。这样,客户端需要传输文件(注意:不是聊天图片,聊天图片使用另外一个服务msfs进行传输),会先告诉msg_server它需要进行文件传输,msg_server收到消息后告诉客户端,你连file_server来传输文件吧,并把file_server的地址和端口号告诉客户端。客户端这个时候连接file_server进行文件传输。我们来具体看一看这个流程的细节信息: 客户端发包给msg_server说要进行文件发送 然后选择一个文件: pc客户端发送文件逻辑: //pc客户端代码(Modules工程SessionLayout.cpp) void SessionLayout::Notify(TNotifyUI& msg) { ... //省略无关代码 else if (msg.pSender == m_pBtnsendfile) //文件传输 { module::UserInfoEntity userInfo; if (!module::getUserListModule()->getUserInfoBySId(m_sId, userInfo)) { LOG__(ERR, _T(\"SendFile can't find the sid\")); return; } CFileDialog fileDlg(TRUE, NULL, NULL, OFN_HIDEREADONLY | OFN_FILEMUSTEXIST , _T(\"文件|*.*||\"), AfxGetMainWnd()); fileDlg.m_ofn.Flags |= OFN_NOCHANGEDIR; fileDlg.DoModal(); CString sPathName; POSITION nPos = fileDlg.GetStartPosition(); if (nPos != NULL) { sPathName = fileDlg.GetNextPathName(nPos); } if (sPathName.IsEmpty()) return; module::getFileTransferModule()->sendFile(sPathName, m_sId, userInfo.isOnlne()); } ... //省略无关代码 } sPathName是文件的全饰路径;m_sId是收取文件的用户id,userInfo.isOnlne()判断m_sId代表的用户是否在线,以此来决定这次文件传输是在线文件还是离线文件模式。 BOOL FileTransferModule_Impl::sendFile(IN const CString& sFilePath, IN const std::string& sSendToSID,IN BOOL bOnlineMode) { if (TransferFileEntityManager::getInstance()->checkIfIsSending(sFilePath)) { return FALSE; } TransferFileEntity fileEntity; //获取文件大小 struct __stat64 buffer; _wstat64(sFilePath, &buffer); fileEntity.nFileSize = (UInt32)buffer.st_size; if (0 != fileEntity.nFileSize) { CString strFileName = sFilePath; strFileName.Replace(_T(\"\\\\\"), _T(\"/\"));//mac上对于路径字符“\\”需要做特殊处理,windows上则可以识别 fileEntity.sFileName = util::cStringToString(strFileName); fileEntity.sFromID = module::getSysConfigModule()->userID(); fileEntity.sToID = sSendToSID; uint32_t transMode = 0; transMode = bOnlineMode ? IM::BaseDefine::TransferFileType::FILE_TYPE_ONLINE : IM::BaseDefine::TransferFileType::FILE_TYPE_OFFLINE; LOG__(DEBG,_T(\"FileTransferSevice_Impl::sendFile sTaskID = %s\"), util::stringToCString(fileEntity.sTaskID)); imcore::IMLibCoreStartOperationWithLambda( [=]() { IM::File::IMFileReq imFileReq; LOG__(APP, _T(\"imFileReq,name=%s,size=%d,toId=%s\"),util::stringToCString(fileEntity.sFileName) ,fileEntity.nFileSize,util::stringToCString(fileEntity.sToID)); imFileReq.set_from_user_id(util::stringToInt32(fileEntity.sFromID)); imFileReq.set_to_user_id(util::stringToInt32(fileEntity.sToID)); imFileReq.set_file_name(fileEntity.sFileName); imFileReq.set_file_size(fileEntity.nFileSize); imFileReq.set_trans_mode(static_cast(transMode)); module::getTcpClientModule()->sendPacket(IM::BaseDefine::ServiceID::SID_FILE , IM::BaseDefine::FileCmdID::CID_FILE_REQUEST , &imFileReq); }); return TRUE; } LOG__(ERR, _T(\"fileEntity FileSize error,size = %d\"), fileEntity.nFileSize); return FALSE; } 上面代码中组装的包信息中含有要传输的文件路径、文件大小、发送人id、接收方id、文件传输模式(在线还是离线),包的命令号是IM::BaseDefine::FileCmdID::CID_FILE_REQUEST。这个包发给msg_server以后,msg_server应答: void CMsgConn::HandlePdu(CImPdu* pPdu) { ... //省略无关代码 case CID_FILE_REQUEST: s_file_handler->HandleClientFileRequest(this, pPdu); break; ... //省略无关代码 } void CFileHandler::HandleClientFileRequest(CMsgConn* pMsgConn, CImPdu* pPdu) { IM::File::IMFileReq msg; CHECK_PB_PARSE_MSG(msg.ParseFromArray(pPdu->GetBodyData(), pPdu->GetBodyLength())); uint32_t from_id = pMsgConn->GetUserId(); uint32_t to_id = msg.to_user_id(); string file_name = msg.file_name(); uint32_t file_size = msg.file_size(); uint32_t trans_mode = msg.trans_mode(); log(\"HandleClientFileRequest, %u->%u, fileName: %s, trans_mode: %u.\", from_id, to_id, file_name.c_str(), trans_mode); CDbAttachData attach(ATTACH_TYPE_HANDLE, pMsgConn->GetHandle()); CFileServConn* pFileConn = get_random_file_serv_conn(); if (pFileConn) { IM::Server::IMFileTransferReq msg2; msg2.set_from_user_id(from_id); msg2.set_to_user_id(to_id); msg2.set_file_name(file_name); msg2.set_file_size(file_size); msg2.set_trans_mode((IM::BaseDefine::TransferFileType)trans_mode); msg2.set_attach_data(attach.GetBuffer(), attach.GetLength()); CImPdu pdu; pdu.SetPBMsg(&msg2); pdu.SetServiceId(SID_OTHER); pdu.SetCommandId(CID_OTHER_FILE_TRANSFER_REQ); pdu.SetSeqNum(pPdu->GetSeqNum()); if (IM::BaseDefine::FILE_TYPE_OFFLINE == trans_mode) { pFileConn->SendPdu(&pdu); } else //IM::BaseDefine::FILE_TYPE_ONLINE { CImUser* pUser = CImUserManager::GetInstance()->GetImUserById(to_id); if (pUser && pUser->GetPCLoginStatus())//已有对应的账号pc登录状态 { pFileConn->SendPdu(&pdu); } else//无对应用户的pc登录状态,向route_server查询状态 { //no pc_client in this msg_server, check it from route_server CPduAttachData attach_data(ATTACH_TYPE_HANDLE_AND_PDU_FOR_FILE, pMsgConn->GetHandle(), pdu.GetBodyLength(), pdu.GetBodyData()); IM::Buddy::IMUsersStatReq msg3; msg3.set_user_id(from_id); msg3.add_user_id_list(to_id); msg3.set_attach_data(attach_data.GetBuffer(), attach_data.GetLength()); CImPdu pdu2; pdu2.SetPBMsg(&msg3); pdu2.SetServiceId(SID_BUDDY_LIST); pdu2.SetCommandId(CID_BUDDY_LIST_USERS_STATUS_REQUEST); pdu2.SetSeqNum(pPdu->GetSeqNum()); CRouteServConn* route_conn = get_route_serv_conn(); if (route_conn) { route_conn->SendPdu(&pdu2); } } } } else { log(\"HandleClientFileRequest, no file server. \"); IM::File::IMFileRsp msg2; msg2.set_result_code(1); msg2.set_from_user_id(from_id); msg2.set_to_user_id(to_id); msg2.set_file_name(file_name); msg2.set_task_id(\"\"); msg2.set_trans_mode((IM::BaseDefine::TransferFileType)trans_mode); CImPdu pdu; pdu.SetPBMsg(&msg2); pdu.SetServiceId(SID_FILE); pdu.SetCommandId(CID_FILE_RESPONSE); pdu.SetSeqNum(pPdu->GetSeqNum()); pMsgConn->SendPdu(&pdu); } } 这段代码,很有讲究,msg_server会检测file_server是否已经启动,如果没有启动,则直接发包告诉客户端,file_server不存在。另外,如果该文件传输模式是在线文件,会判断接收文件的用户是否和发送用户在同一台msg_server上。不在的话,则给route_server发送消息,查找该用户所在的msg_server(这个不具体介绍了,后面分析route_server会专门介绍的)。否则,会将文件发送请求转发给file_server,包的命令号是CID_OTHER_FILE_TRANSFER_REQ。file_server收到该请求后,处理如下: void FileMsgServerConn::HandlePdu(CImPdu* pdu) { ... //省略无关代码 case CID_OTHER_FILE_TRANSFER_REQ: _HandleMsgFileTransferReq(pdu); break ; ... //省略无关代码 } void FileMsgServerConn::_HandleMsgFileTransferReq(CImPdu* pdu) { IM::Server::IMFileTransferReq transfer_req; CHECK_PB_PARSE_MSG(transfer_req.ParseFromArray(pdu->GetBodyData(), pdu->GetBodyLength())); uint32_t from_id = transfer_req.from_user_id(); uint32_t to_id = transfer_req.to_user_id(); IM::Server::IMFileTransferRsp transfer_rsp; transfer_rsp.set_result_code(1); transfer_rsp.set_from_user_id(from_id); transfer_rsp.set_to_user_id(to_id); transfer_rsp.set_file_name(transfer_req.file_name()); transfer_rsp.set_file_size(transfer_req.file_size()); transfer_rsp.set_task_id(\"\"); transfer_rsp.set_trans_mode(transfer_req.trans_mode()); transfer_rsp.set_attach_data(transfer_req.attach_data()); bool rv = false; do { std::string task_id = GenerateUUID(); if (task_id.empty()) { log(\"Create task id failed\"); break; } log(\"trams_mode=%d, task_id=%s, from_id=%d, to_id=%d, file_name=%s, file_size=%d\", transfer_req.trans_mode(), task_id.c_str(), from_id, to_id, transfer_req.file_name().c_str(), transfer_req.file_size()); BaseTransferTask* transfer_task = TransferTaskManager::GetInstance()->NewTransferTask( transfer_req.trans_mode(), task_id, from_id, to_id, transfer_req.file_name(), transfer_req.file_size()); if (transfer_task == NULL) { // 创建未成功 // close connection with msg svr // need_close = true; log(\"Create task failed\"); break; } transfer_rsp.set_result_code(0); transfer_rsp.set_task_id(task_id); rv = true; // need_seq_no = false; log(\"Create task succeed, task id %s, task type %d, from user %d, to user %d\", task_id.c_str(), transfer_req.trans_mode(), from_id, to_id); } while (0); ::SendMessageLite(this, SID_OTHER, CID_OTHER_FILE_TRANSFER_RSP, pdu->GetSeqNum(), &transfer_rsp); if (!rv) { // 未创建成功,关闭连接 Close(); } } 上述代码会为本次传输任务创建一个唯一的标识uuid作为taskid,然后根据离线文件还是在线文件创建离线文件传输任务OfflineTransferTask或者在线文件传输任务OnlineTransferTask,并加入一个一个成员变量transfertasks中进行管理: BaseTransferTask* TransferTaskManager::NewTransferTask(uint32_t trans_mode, const std::string& task_id, uint32_t from_user_id, uint32_t to_user_id, const std::string& file_name, uint32_t file_size) { BaseTransferTask* transfer_task = NULL; TransferTaskMap::iterator it = transfer_tasks_.find(task_id); if (it==transfer_tasks_.end()) { if (trans_mode == IM::BaseDefine::FILE_TYPE_ONLINE) { transfer_task = new OnlineTransferTask(task_id, from_user_id, to_user_id, file_name, file_size); } else if (trans_mode == IM::BaseDefine::FILE_TYPE_OFFLINE) { transfer_task = new OfflineTransferTask(task_id, from_user_id, to_user_id, file_name, file_size); } else { log(\"Invalid trans_mode = %d\", trans_mode); } if (transfer_task) { transfer_tasks_.insert(std::make_pair(task_id, transfer_task)); } } else { log(\"Task existed by task_id=%s, why?????\", task_id.c_str()); } return transfer_task; } 这个map transfertasks是在定时器里面进行定期处理的,处理的依据是当前任务的状态,比如已经完成的任务就可以从map中移除了: void TransferTaskManager::OnTimer(uint64_t tick) { for (TransferTaskMap::iterator it = transfer_tasks_.begin(); it != transfer_tasks_.end();) { BaseTransferTask* task = it->second; if (task == NULL) { transfer_tasks_.erase(it++); continue; } if (task->state() != kTransferTaskStateWaitingUpload && task->state() == kTransferTaskStateTransferDone) { long esp = time(NULL) - task->create_time(); if (esp > ConfigUtil::GetInstance()->GetTaskTimeout()) { if (task->GetFromConn()) { FileClientConn* conn = reinterpret_cast(task->GetFromConn()); conn->ClearTransferTask(); } if (task->GetToConn()) { FileClientConn* conn = reinterpret_cast(task->GetToConn()); conn->ClearTransferTask(); } delete task; transfer_tasks_.erase(it++); continue; } } ++it; } } 完成这些工作以后,组装的应答包命令号是CID_OTHER_FILE_TRANSFER_RSP,回复给msg_server。msg_server收到该应答包后处理: void CFileServConn::HandlePdu(CImPdu* pPdu) { switch (pPdu->GetCommandId()) { ... //省略无关代码 case CID_OTHER_FILE_TRANSFER_RSP: _HandleFileMsgTransRsp(pPdu); break; ... //省略无关代码 } } void CFileServConn::_HandleFileMsgTransRsp(CImPdu* pPdu) { IM::Server::IMFileTransferRsp msg; CHECK_PB_PARSE_MSG(msg.ParseFromArray(pPdu->GetBodyData(), pPdu->GetBodyLength())); uint32_t result = msg.result_code(); uint32_t from_id = msg.from_user_id(); uint32_t to_id = msg.to_user_id(); string file_name = msg.file_name(); uint32_t file_size = msg.file_size(); string task_id = msg.task_id(); uint32_t trans_mode = msg.trans_mode(); CDbAttachData attach((uchar_t*)msg.attach_data().c_str(), msg.attach_data().length()); log(\"HandleFileMsgTransRsp, result: %u, from_user_id: %u, to_user_id: %u, file_name: %s, \\ task_id: %s, trans_mode: %u. \", result, from_id, to_id, file_name.c_str(), task_id.c_str(), trans_mode); const list* ip_addr_list = GetFileServerIPList(); IM::File::IMFileRsp msg2; msg2.set_result_code(result); msg2.set_from_user_id(from_id); msg2.set_to_user_id(to_id); msg2.set_file_name(file_name); msg2.set_task_id(task_id); msg2.set_trans_mode((IM::BaseDefine::TransferFileType)trans_mode); for (list::const_iterator it = ip_addr_list->begin(); it != ip_addr_list->end(); it++) { IM::BaseDefine::IpAddr ip_addr_tmp = *it; IM::BaseDefine::IpAddr* ip_addr = msg2.add_ip_addr_list(); ip_addr->set_ip(ip_addr_tmp.ip()); ip_addr->set_port(ip_addr_tmp.port()); } CImPdu pdu; pdu.SetPBMsg(&msg2); pdu.SetServiceId(SID_FILE); pdu.SetCommandId(CID_FILE_RESPONSE); pdu.SetSeqNum(pPdu->GetSeqNum()); uint32_t handle = attach.GetHandle(); CMsgConn* pFromConn = CImUserManager::GetInstance()->GetMsgConnByHandle(from_id, handle); if (pFromConn) { pFromConn->SendPdu(&pdu); } if (result == 0) { IM::File::IMFileNotify msg3; msg3.set_from_user_id(from_id); msg3.set_to_user_id(to_id); msg3.set_file_name(file_name); msg3.set_file_size(file_size); msg3.set_task_id(task_id); msg3.set_trans_mode((IM::BaseDefine::TransferFileType)trans_mode); msg3.set_offline_ready(0); for (list::const_iterator it = ip_addr_list->begin(); it != ip_addr_list->end(); it++) { IM::BaseDefine::IpAddr ip_addr_tmp = *it; IM::BaseDefine::IpAddr* ip_addr = msg3.add_ip_addr_list(); ip_addr->set_ip(ip_addr_tmp.ip()); ip_addr->set_port(ip_addr_tmp.port()); } CImPdu pdu2; pdu2.SetPBMsg(&msg3); pdu2.SetServiceId(SID_FILE); pdu2.SetCommandId(CID_FILE_NOTIFY); //send notify to target user CImUser* pToUser = CImUserManager::GetInstance()->GetImUserById(to_id); if (pToUser) { pToUser->BroadcastPduWithOutMobile(&pdu2); } //send to route server CRouteServConn* pRouteConn = get_route_serv_conn(); if (pRouteConn) { pRouteConn->SendPdu(&pdu2); } } } msg_server收到包后,首先装包数据,并把file_server的ip地址和端口信息带上,发给请求发文件的客户端,命令号是CID_FILE_RESPONSE;接着查询通知接收方有人给其发文件(通知方式也是一样,如果接收方在该msg_server上,直接发给该用户;不在的话,发给路由服务route_server)。当然接收到文件发送的端只有pc端,移动端会被过滤掉的,也就是说移动端不会收到发送文件的请求。 我们先看发送方pc客户端收到应答的逻辑(命令号是CID_FILE_RESPONSE): void FileTransferModule_Impl::onPacket(imcore::TTPBHeader& header, std::string& pbBody) { switch (header.getCommandId()) { case IM::BaseDefine::FileCmdID::CID_FILE_RESPONSE://发送“文件请求/离线文件”-返回 _sendfileResponse(pbBody); break; } } void FileTransferModule_Impl::_sendfileResponse(IN std::string& body) { IM::File::IMFileRsp imFileRsp; if (!imFileRsp.ParseFromString(body)) { LOG__(ERR, _T(\"parse failed,body:%s\"), util::stringToCString(body)); return; } UInt32 nResult = imFileRsp.result_code(); if (nResult != 0) { LOG__(ERR, _T(\"_sendfileResponse result != 0\")); module::getFileTransferModule()->asynNotifyObserver(module::KEY_FILESEVER_UPLOAD_FAILED); } TransferFileEntity fileEntity; fileEntity.sTaskID = imFileRsp.task_id(); assert(!fileEntity.sTaskID.empty()); fileEntity.sFromID = util::uint32ToString(imFileRsp.from_user_id()); fileEntity.sToID = util::uint32ToString(imFileRsp.to_user_id()); fileEntity.sFileName = imFileRsp.file_name(); fileEntity.setSaveFilePath(util::stringToCString(fileEntity.sFileName));//发送方文件地址,就是保存地址 fileEntity.time = static_cast(time(0)); uint32_t transMode = imFileRsp.trans_mode(); if (IM::BaseDefine::TransferFileType::FILE_TYPE_ONLINE == transMode) { fileEntity.nClientMode = IM::BaseDefine::ClientFileRole::CLIENT_REALTIME_SENDER; } else if (IM::BaseDefine::TransferFileType::FILE_TYPE_OFFLINE == transMode) { fileEntity.nClientMode = IM::BaseDefine::ClientFileRole::CLIENT_OFFLINE_UPLOAD; } fileEntity.pFileObject = new TransferFile(util::stringToCString(fileEntity.sFileName),FALSE); if (fileEntity.pFileObject) { fileEntity.nFileSize = fileEntity.pFileObject->length(); } UINT32 nIPCount = imFileRsp.ip_addr_list_size(); if (nIPCount pushTransferFileEntity(fileEntity)) TransferFileEntityManager::getInstance()->updateFileInfoBysTaskID(fileEntity); LOG__(DEBG, _T(\"FileTransferSevice_Impl::准备连接文件服务器 sTaskId = %s\"), util::stringToCString(fileEntity.sTaskID)); TransferFileEntityManager::getInstance()->openFileSocketByTaskId(fileEntity.sTaskID); } 客户端在TransferFileEntityManager::getInstance()->openFileSocketByTaskId(fileEntity.sTaskID);里面实际去连接file_server并尝试发文件: void TransferFileEntityManager::openFileSocketByTaskId(std::string& taskId) { m_fileUIThread->openFileSocketByTaskId(taskId); } void FileTransferUIThread::openFileSocketByTaskId(std::string& taskId) { FileTransferSocket* pFileSocket = _findFileSocketByTaskId(taskId); if (!pFileSocket) { pFileSocket = new FileTransferSocket(taskId); m_lstFileTransSockets.push_back(pFileSocket); assert(m_hWnd); ::PostMessage(m_hWnd, WM_FILE_TRANSFER, 0, (LPARAM)pFileSocket); } } LRESULT _stdcall FileTransferUIThread::_WndProc(HWND hWnd, UINT message, WPARAM wparam, LPARAM lparam) { if (WM_FILE_TRANSFER == message) { FileTransferSocket* pFileSocket = (FileTransferSocket*)lparam; pFileSocket->startFileTransLink(); } return ::DefWindowProc(hWnd, message, wparam, lparam); } BOOL FileTransferSocket::startFileTransLink() { TransferFileEntity FileInfo; if (TransferFileEntityManager::getInstance()->getFileInfoByTaskId(m_sTaskId, FileInfo)) { //大佛:使用msg server 传过来的IP和端口 LOG__(APP, _T(\"connect IP=%s,Port=%d\"), util::stringToCString(FileInfo.sIP), FileInfo.nPort); connect(util::stringToCString(FileInfo.sIP), FileInfo.nPort); //connect(util::stringToCString(module::FILETRANSFER_IP), module::FILETRANSFER_PORT); return TRUE; } LOG__(ERR, _T(\"can't find the TaskId\")); return FALSE; } 注意,这里只是去连接file_server服务器,连接成功的情况下,会尝试登录文件服务器,登录file_server的命令号是CID_FILE_LOGIN_REQ: void FileTransferSocket::onConnectDone() { LOG__(APP, _T(\"FileTransferSocket::onConnected()\")); startHeartbeat(); TransferFileEntity info; if (!TransferFileEntityManager::getInstance()->getFileInfoByTaskId(m_sTaskId, info)) { LOG__(APP, _T(\"Can't get the file info,task id:%s\"),util::stringToCString(m_sTaskId)); return; } //拉模式文件传输,传输taskid、token、client_mode IM::File::IMFileLoginReq imFileLoginReq; imFileLoginReq.set_user_id(module::getSysConfigModule()->userId()); imFileLoginReq.set_task_id(info.sTaskID); imFileLoginReq.set_file_role(static_cast(info.nClientMode)); LOG__(APP, _T(\"IMFileLoginReq,sTaskID:%s,nClientMode:%d\"), util::stringToCString(info.sTaskID), info.nClientMode); //send packet LOG__(APP, _T(\"IMFileLoginReq,taskId:%s\"), util::stringToCString(info.sTaskID)); sendPacket(IM::BaseDefine::ServiceID::SID_FILE, IM::BaseDefine::FileCmdID::CID_FILE_LOGIN_REQ, &imFileLoginReq); //CImPduClientFileLoginReq pduFileLoginReq(module::getSysConfigModule()->userID().c_str() // , \"\", info.sTaskID.c_str(), ); //sendPacket(&pduFileLoginReq); } file_server收到该数据包处理如下: void FileClientConn::HandlePdu(CImPdu* pdu) { ... //省略无关代码 case CID_FILE_LOGIN_REQ: _HandleClientFileLoginReq(pdu); break; } void FileClientConn::_HandleClientFileLoginReq(CImPdu* pdu) { IM::File::IMFileLoginReq login_req; CHECK_PB_PARSE_MSG(login_req.ParseFromArray(pdu->GetBodyData(), pdu->GetBodyLength())); uint32_t user_id = login_req.user_id(); string task_id = login_req.task_id(); IM::BaseDefine::ClientFileRole mode = login_req.file_role(); log(\"Client login, user_id=%d, task_id=%s, file_role=%d\", user_id, task_id.c_str(), mode); BaseTransferTask* transfer_task = NULL; bool rv = false; do { // 查找任务是否存在 transfer_task = TransferTaskManager::GetInstance()->FindByTaskID(task_id); if (transfer_task == NULL) { if (mode == CLIENT_OFFLINE_DOWNLOAD) { // 文件不存在,检查是否是离线下载,有可能是文件服务器重启 // 尝试从磁盘加载 transfer_task = TransferTaskManager::GetInstance()->NewTransferTask(task_id, user_id); // 需要再次判断是否加载成功 if (transfer_task == NULL) { log(\"Find task id failed, user_id=%u, taks_id=%s, mode=%d\", user_id, task_id.c_str(), mode); break; } } else { log(\"Can't find task_id, user_id=%u, taks_id=%s, mode=%d\", user_id, task_id.c_str(), mode); break; } } // 状态转换 rv = transfer_task->ChangePullState(user_id, mode); if (!rv) { // log(); break; // } // Ok auth_ = true; transfer_task_ = transfer_task; user_id_ = user_id; // 设置conn transfer_task->SetConnByUserID(user_id, this); rv = true; } while (0); IM::File::IMFileLoginRsp login_rsp; login_rsp.set_result_code(rv?0:1); login_rsp.set_task_id(task_id); ::SendMessageLite(this, SID_FILE, CID_FILE_LOGIN_RES, pdu->GetSeqNum(), &login_rsp); if (rv) { if (transfer_task->GetTransMode() == FILE_TYPE_ONLINE) { if (transfer_task->state() == kTransferTaskStateWaitingTransfer) { CImConn* conn = transfer_task_->GetToConn(); if (conn) { _StatesNotify(CLIENT_FILE_PEER_READY, task_id, transfer_task_->from_user_id(), conn); } else { log(\"to_conn is close, close me!!!\"); Close(); } // _StatesNotify(CLIENT_FILE_PEER_READY, task_id, user_id, this); // transfer_task->StatesNotify(CLIENT_FILE_PEER_READY, task_id, user_id_); } } else { if (transfer_task->state() == kTransferTaskStateWaitingUpload) { OfflineTransferTask* offline = reinterpret_cast(transfer_task); IM::File::IMFilePullDataReq pull_data_req; pull_data_req.set_task_id(task_id); pull_data_req.set_user_id(user_id); pull_data_req.set_trans_mode(FILE_TYPE_OFFLINE); pull_data_req.set_offset(0); pull_data_req.set_data_size(offline->GetNextSegmentBlockSize()); ::SendMessageLite(this, SID_FILE, CID_FILE_PULL_DATA_REQ, &pull_data_req); log(\"Pull Data Req\"); } } } else { Close(); } } file_server应答客户端的命令号是CID_FILE_LOGIN_RES,客户端收到该包后处理如下: void FileTransferSocket::onReceiveData(const char* data, int32_t size) { std::string pbBody; imcore::TTPBHeader pbHeader; try { pbHeader.unSerialize((byte*)data, imcore::HEADER_LENGTH); pbBody.assign(data + imcore::HEADER_LENGTH, size - imcore::HEADER_LENGTH); if (IM::BaseDefine::OtherCmdID::CID_OTHER_HEARTBEAT == pbHeader.getCommandId() && IM::BaseDefine::ServiceID::SID_OTHER == pbHeader.getModuleId()) return; } catch (CPduException e) { LOG__(ERR, _T(\"onPacket CPduException serviceId:%d,commandId:%d,errCode:%d\") , e.GetModuleId(), e.GetCommandId(), e.GetErrorCode()); return; } catch (...) { LOG__(ERR, _T(\"FileTransferSocket onPacket unknown exception\")); return; } UInt16 ncmdid = pbHeader.getCommandId(); switch (ncmdid) { case IM::BaseDefine::FileCmdID::CID_FILE_LOGIN_RES: _fileLoginResponse(pbBody); break; //无关代码省略 } } void FileTransferSocket::_fileLoginResponse(IN std::string& body) { IM::File::IMFileLoginRsp imFileLoginRsp; if (!imFileLoginRsp.ParseFromString(body)) { LOG__(ERR, _T(\"parse failed,body:%s\"), util::stringToCString(body)); return; } if (imFileLoginRsp.result_code() != 0) { LOG__(ERR, _T(\"file server login failed! \")); return; } //打开文件 std::string taskId = imFileLoginRsp.task_id(); TransferFileEntity fileEntity; if (!TransferFileEntityManager::getInstance()->getFileInfoByTaskId(taskId, fileEntity)) { LOG__(ERR, _T(\"file server login:can't find the fileInfo \")); return; } LOG__(APP, _T(\"IMFileLoginRsp, file server login succeed\")); //提示界面,界面上插入该项 if (IM::BaseDefine::ClientFileRole::CLIENT_REALTIME_SENDER == fileEntity.nClientMode || IM::BaseDefine::ClientFileRole::CLIENT_OFFLINE_UPLOAD == fileEntity.nClientMode) { module::getFileTransferModule()->asynNotifyObserver(module::KEY_FILETRANSFER_SENDFILE, fileEntity.sTaskID); } else if (IM::BaseDefine::ClientFileRole::CLIENT_REALTIME_RECVER == fileEntity.nClientMode || IM::BaseDefine::ClientFileRole::CLIENT_OFFLINE_DOWNLOAD == fileEntity.nClientMode) { module::getFileTransferModule()->asynNotifyObserver(module::KEY_FILETRANSFER_REQUEST, fileEntity.sTaskID); } } 至此,不管是离线文件还是在线文件发送,pc客户端会显示一个文件进度的对话框: 对于在线文件,需要对端同意接收文件的传输,客户端才会读取文件,这个进度条才会发生变化。而对于离线文件,应该会立马读取文件上传文件数据到服务器。可是哪里会触发客户端读取文件并发送的逻辑呢?门道在于file_server在收到登录请求CID_FILE_LOGIN_REQ后,不仅会给客户端发送登录应答数据包CID_FILE_LOGIN_RES。还会根据文件的传输模式,如果是离线文件则会给客户端发送拉取文件的数据包CID_FILE_PULL_DATA_REQ,代码我们已经在上面的FileClientConn::_HandleClientFileLoginReq(CImPdu* pdu)中贴过了,我们再贴一次: void FileClientConn::_HandleClientFileLoginReq(CImPdu* pdu) { IM::File::IMFileLoginReq login_req; CHECK_PB_PARSE_MSG(login_req.ParseFromArray(pdu->GetBodyData(), pdu->GetBodyLength())); uint32_t user_id = login_req.user_id(); string task_id = login_req.task_id(); IM::BaseDefine::ClientFileRole mode = login_req.file_role(); log(\"Client login, user_id=%d, task_id=%s, file_role=%d\", user_id, task_id.c_str(), mode); BaseTransferTask* transfer_task = NULL; bool rv = false; do { // 查找任务是否存在 transfer_task = TransferTaskManager::GetInstance()->FindByTaskID(task_id); if (transfer_task == NULL) { if (mode == CLIENT_OFFLINE_DOWNLOAD) { // 文件不存在,检查是否是离线下载,有可能是文件服务器重启 // 尝试从磁盘加载 transfer_task = TransferTaskManager::GetInstance()->NewTransferTask(task_id, user_id); // 需要再次判断是否加载成功 if (transfer_task == NULL) { log(\"Find task id failed, user_id=%u, taks_id=%s, mode=%d\", user_id, task_id.c_str(), mode); break; } } else { log(\"Can't find task_id, user_id=%u, taks_id=%s, mode=%d\", user_id, task_id.c_str(), mode); break; } } // 状态转换 rv = transfer_task->ChangePullState(user_id, mode); if (!rv) { // log(); break; // } // Ok auth_ = true; transfer_task_ = transfer_task; user_id_ = user_id; // 设置conn transfer_task->SetConnByUserID(user_id, this); rv = true; } while (0); IM::File::IMFileLoginRsp login_rsp; login_rsp.set_result_code(rv?0:1); login_rsp.set_task_id(task_id); ::SendMessageLite(this, SID_FILE, CID_FILE_LOGIN_RES, pdu->GetSeqNum(), &login_rsp); if (rv) { if (transfer_task->GetTransMode() == FILE_TYPE_ONLINE) { if (transfer_task->state() == kTransferTaskStateWaitingTransfer) { CImConn* conn = transfer_task_->GetToConn(); if (conn) { _StatesNotify(CLIENT_FILE_PEER_READY, task_id, transfer_task_->from_user_id(), conn); } else { log(\"to_conn is close, close me!!!\"); Close(); } // _StatesNotify(CLIENT_FILE_PEER_READY, task_id, user_id, this); // transfer_task->StatesNotify(CLIENT_FILE_PEER_READY, task_id, user_id_); } } else { if (transfer_task->state() == kTransferTaskStateWaitingUpload) { OfflineTransferTask* offline = reinterpret_cast(transfer_task); IM::File::IMFilePullDataReq pull_data_req; pull_data_req.set_task_id(task_id); pull_data_req.set_user_id(user_id); pull_data_req.set_trans_mode(FILE_TYPE_OFFLINE); pull_data_req.set_offset(0); pull_data_req.set_data_size(offline->GetNextSegmentBlockSize()); ::SendMessageLite(this, SID_FILE, CID_FILE_PULL_DATA_REQ, &pull_data_req); log(\"Pull Data Req\"); } } } else { Close(); } } pc端收到CID_FILE_PULL_DATA_REQ后,表示这是一个离线文件,就可以直接上传文件数据了: case IM::BaseDefine::FileCmdID::CID_FILE_PULL_DATA_REQ://发文件 _filePullDataReqResponse(pbBody); void FileTransferSocket::_filePullDataReqResponse(IN std::string& body)//发 { IM::File::IMFilePullDataReq imFilePullDataReq; if (!imFilePullDataReq.ParseFromString(body)) { LOG__(ERR, _T(\"parse failed,body:%s\"), util::stringToCString(body)); return; } UInt32 fileSize = imFilePullDataReq.data_size(); UInt32 fileOffset = imFilePullDataReq.offset(); std::string taskId = imFilePullDataReq.task_id(); TransferFileEntity fileEntity; if (!TransferFileEntityManager::getInstance()->getFileInfoByTaskId(taskId, fileEntity)) { LOG__(ERR, _T(\"PullDataReqResponse: can't find the fileInfo\")); return; } LOG__(DEBG, _T(\"send:taskId=%s,filesize=%d,name=%s,BolckSize=%d\") ,util::stringToCString(fileEntity.sTaskID) ,fileEntity.nFileSize ,fileEntity.getRealFileName() ,fileSize); std::string buff; if (nullptr == fileEntity.pFileObject) { LOG__(ERR, _T(\"PullDataReqResponse: file boject Destoryed!\")); return; } fileEntity.pFileObject->readBlock(fileOffset, fileSize, buff);//读取本地文件的数据块 IM::File::IMFilePullDataRsp imFilePullDataRsp;//todo check imFilePullDataRsp.set_result_code(0); imFilePullDataRsp.set_task_id(taskId); imFilePullDataRsp.set_user_id(util::stringToInt32(fileEntity.sFromID)); imFilePullDataRsp.set_offset(fileOffset); imFilePullDataRsp.set_file_data((void*)buff.data(), fileSize); //send packet sendPacket(IM::BaseDefine::ServiceID::SID_FILE, IM::BaseDefine::FileCmdID::CID_FILE_PULL_DATA_RSP , &imFilePullDataRsp); fileEntity.nProgress = fileOffset + fileSize; if (fileEntity.nProgress updateFileInfoBysTaskID(fileEntity);//保存当前进度 module::getFileTransferModule()->asynNotifyObserver(module::KEY_FILESEVER_UPDATA_PROGRESSBAR , fileEntity.sTaskID); } else//传输完成 { if (fileEntity.pFileObject) { delete fileEntity.pFileObject; fileEntity.pFileObject = nullptr; } module::getFileTransferModule()->asynNotifyObserver(module::KEY_FILESEVER_PROGRESSBAR_FINISHED , fileEntity.sTaskID); } TransferFileEntityManager::getInstance()->updateFileInfoBysTaskID(fileEntity); } 当然,如果文件比较大,一次发不完也没关系,在CID_FILE_PULL_DATA_REQ中有当前文件的偏移量,客户端在读取文件和应答服务器时也带上这个偏移量fileOffset,应答给服务器的包是CID_FILE_PULL_DATA_RSP。file_server收到应答后处理: void FileClientConn::HandlePdu(CImPdu* pdu) { ... //省略无关代码 case CID_FILE_PULL_DATA_RSP: _HandleClientFilePullFileRsp( pdu); break ; ... //省略无关代码 } void FileClientConn::_HandleClientFilePullFileRsp(CImPdu *pdu) { if (!auth_ || !transfer_task_) { log(\"auth is false\"); return; } // 只有rsp IM::File::IMFilePullDataRsp pull_data_rsp; CHECK_PB_PARSE_MSG(pull_data_rsp.ParseFromArray(pdu->GetBodyData(), pdu->GetBodyLength())); uint32_t user_id = pull_data_rsp.user_id(); string task_id = pull_data_rsp.task_id(); uint32_t offset = pull_data_rsp.offset(); uint32_t data_size = static_cast(pull_data_rsp.file_data().length()); const char* data = pull_data_rsp.file_data().data(); // log(\"Recv FilePullFileRsp, user_id=%d, task_id=%s, file_role=%d, offset=%d, datasize=%d\", user_id, task_id.c_str(), mode, offset, datasize); log(\"Recv FilePullFileRsp, task_id=%s, user_id=%u, offset=%u, data_size=%d\", task_id.c_str(), user_id, offset, data_size); int rv = -1; do { // // 检查user_id if (user_id != user_id_) { log(\"Received user_id valid, recv_user_id = %d, transfer_task.user_id = %d, user_id_ = %d\", user_id, transfer_task_->from_user_id(), user_id_); break; } // 检查task_id if (transfer_task_->task_id() != task_id) { log(\"Received task_id valid, recv_task_id = %s, this_task_id = %s\", task_id.c_str(), transfer_task_->task_id().c_str()); // Close(); break; } rv = transfer_task_->DoRecvData(user_id, offset, data, data_size); if (rv == -1) { break; } if (transfer_task_->GetTransMode() == FILE_TYPE_ONLINE) { // 对于在线,直接转发 OnlineTransferTask* online = reinterpret_cast(transfer_task_); pdu->SetSeqNum(online->GetSeqNum()); // online->SetSeqNum(pdu->GetSeqNum()); CImConn* conn = transfer_task_->GetToConn(); if (conn) { conn->SendPdu(pdu); } } else { // 离线 // all packages recved if (rv == 1) { _StatesNotify(CLIENT_FILE_DONE, task_id, user_id, this); // Close(); } else { OfflineTransferTask* offline = reinterpret_cast(transfer_task_); IM::File::IMFilePullDataReq pull_data_req; pull_data_req.set_task_id(task_id); pull_data_req.set_user_id(user_id); pull_data_req.set_trans_mode(static_cast(offline->GetTransMode())); pull_data_req.set_offset(offline->GetNextOffset()); pull_data_req.set_data_size(offline->GetNextSegmentBlockSize()); ::SendMessageLite(this, SID_FILE, CID_FILE_PULL_DATA_REQ, &pull_data_req); // log(\"size not match\"); } } } while (0); if (rv!=0) { // -1,出错关闭 // 1, 离线上传完成 Close(); } } 如果是在线文件,就直接转发含有文件数据的包;如果是离线文件,则存入文件服务上,即写入文件: int OfflineTransferTask::DoRecvData(uint32_t user_id, uint32_t offset, const char* data, uint32_t data_size) { // 离线文件上传 int rv = -1; do { // 检查是否发送者 if (!CheckFromUserID(user_id)) { log(\"rsp user_id=%d, but sender_id is %d\", user_id, from_user_id_); break; } // 检查状态 if (state_ != kTransferTaskStateWaitingUpload && state_ != kTransferTaskStateUploading) { log(\"state=%d error, need kTransferTaskStateWaitingUpload or kTransferTaskStateUploading\", state_); break; } // 检查offset是否有效 if (offset != transfered_idx_*SEGMENT_SIZE) { break; } //if (data_size != GetNextSegmentBlockSize()) { // break; //} // todo // 检查文件大小 data_size = GetNextSegmentBlockSize(); log(\"Ready recv data, offset=%d, data_size=%d, segment_size=%d\", offset, data_size, sengment_size_); if (state_ == kTransferTaskStateWaitingUpload) { if (fp_ == NULL) { fp_ = OpenByWrite(task_id_, to_user_id_); if (fp_ == NULL) { break; } } // 写文件头 OfflineFileHeader file_header; memset(&file_header, 0, sizeof(file_header)); file_header.set_create_time(time(NULL)); file_header.set_task_id(task_id_); file_header.set_from_user_id(from_user_id_); file_header.set_to_user_id(to_user_id_); file_header.set_file_name(\"\"); file_header.set_file_size(file_size_); fwrite(&file_header, 1, sizeof(file_header), fp_); fflush(fp_); state_ = kTransferTaskStateUploading; } // 存储 if (fp_ == NULL) { // break; } fwrite(data, 1, data_size, fp_); fflush(fp_); ++transfered_idx_; SetLastUpdateTime(); if (transfered_idx_ == sengment_size_) { state_ = kTransferTaskStateUploadEnd; fclose(fp_); fp_ = NULL; rv = 1; } else { rv = 0; } } while (0); return rv; } 如此循环,直至文件传输完成。当然文件上传完成后file_server也会断开与客户端的连接。 到这里我们介绍了发送文件方的逻辑,下面我们看看接收方的逻辑,上文中介绍了接收方会收到接收文件的通知CID_FILE_NOTIFY,客户端处理这个命令号: case IM::BaseDefine::FileCmdID::CID_FILE_NOTIFY://收到“发送文件请求” _fileNotify(pbBody); _fileNotify(pbBody); void FileTransferModule_Impl::_fileNotify(IN std::string& body) { IM::File::IMFileNotify imFileNotify; if (!imFileNotify.ParseFromString(body)) { LOG__(ERR, _T(\"parse failed,body:%s\"), util::stringToCString(body)); return; } TransferFileEntity file; file.sFileName = imFileNotify.file_name(); file.sFromID = util::uint32ToString(imFileNotify.from_user_id()); file.sToID = util::uint32ToString(imFileNotify.to_user_id()); file.sTaskID = imFileNotify.task_id(); file.nFileSize = imFileNotify.file_size(); UINT32 nIPCount = imFileNotify.ip_addr_list_size(); if (nIPCount (time(0)); TransferFileEntityManager::getInstance()->pushTransferFileEntity(file); LOG__(DEBG, _T(\"FileTransferSevice_Impl::给你发文件 sFileID = %s\"), util::stringToCString(file.sTaskID)); if (1 == imFileNotify.offline_ready()) { //TODO离线文件传输结束 } //连接服务器 TransferFileEntityManager::getInstance()->openFileSocketByTaskId(file.sTaskID); } 其实也就是接收方会去连接文件服务器。连接成功以后,在对应的回调函数里面触发显示接收文件对话框。但是此时实际上还不能接收文件,因为发送方可能还没准备好。发送方要准备啥呢?前面我们已经介绍了,我们梳理一下上述流程: 发送方先向msg_server请求发送文件,msg_server转发给file_server; file_server应答msg_server并告诉msg_server自己的地址和端口号; msg_server收到file_server的应答后,先回复发送方,再转发给接收方; 发送方接着发送登录请求给file_server,file_server收到请求决定是否给发送方发送拉取文件的数据包。如果是离线文件,则会立刻给发送方发送拉取文件的数据包;如果是在线文件,则需要等待接收方同意接收。 所以,必须过了步骤4,一直到file_server应答了发送方的登录文件服务器请求后,发送方才算准备好。此时,file_server知道发送方已经准备好了,给接收方发送数据包CID_FILE_STATE。接收方收到这个命令号后: case IM::BaseDefine::FileCmdID::CID_FILE_STATE:// _fileState(pbBody); void FileTransferSocket::_fileState(IN std::string& body) { IM::File::IMFileState imFileState; if (!imFileState.ParseFromString(body)) { LOG__(ERR, _T(\"parse failed,body:%s\"), util::stringToCString(body)); return; } UINT32 nfileState = imFileState.state(); std::string taskId = imFileState.task_id(); TransferFileEntity fileEntity; if (!TransferFileEntityManager::getInstance()->getFileInfoByTaskId(taskId, fileEntity)) { LOG__(ERR, _T(\"fileState:can't find the fileInfo \")); return; } switch (nfileState) { case IM::BaseDefine::ClientFileState::CLIENT_FILE_PEER_READY: LOG__(APP, _T(\"fileState--CLIENT_FILE_PEER_READY \")); break; case IM::BaseDefine::ClientFileState::CLIENT_FILE_CANCEL ://取消的了文件传输 LOG__(APP, _T(\"fileState--CLIENT_FILE_CANCEL \")); { if (fileEntity.pFileObject) { delete fileEntity.pFileObject; fileEntity.pFileObject = nullptr; } TransferFileEntityManager::getInstance()->updateFileInfoBysTaskID(fileEntity); module::getFileTransferModule()->asynNotifyObserver(module::KEY_FILESEVER_UPLOAD_CANCEL, fileEntity.sTaskID); } break; case IM::BaseDefine::ClientFileState::CLIENT_FILE_REFUSE://拒绝了文件 LOG__(APP, _T(\"fileState--CLIENT_FILE_REFUSE \")); { if (fileEntity.pFileObject) { delete fileEntity.pFileObject; fileEntity.pFileObject = nullptr; } TransferFileEntityManager::getInstance()->updateFileInfoBysTaskID(fileEntity); module::getFileTransferModule()->asynNotifyObserver(module::KEY_FILESEVER_UPLOAD_REJECT, fileEntity.sTaskID); } break; case IM::BaseDefine::ClientFileState::CLIENT_FILE_DONE: LOG__(APP, _T(\"fileState--CLIENT_FILE_DONE \")); if (fileEntity.pFileObject) { delete fileEntity.pFileObject; fileEntity.pFileObject = nullptr; } TransferFileEntityManager::getInstance()->updateFileInfoBysTaskID(fileEntity); module::getFileTransferModule()->asynNotifyObserver(module::KEY_FILESEVER_PROGRESSBAR_FINISHED, fileEntity.sTaskID); break; default: break; } } 同理,对于接收方,选择接收还是拒绝文件的逻辑也是在这里一起处理的,与此类似,这里就不再重复叙述了。 接收方下载文件的逻辑和发送方上传文件的逻辑类似。这里也不在描述了。 最后说一点我的建议,teamtalk的file_server逻辑、以及与客户端还有msg_server的逻辑流程加上各种细节写的比较的细腻,代码实现上也比较好。强烈建议好好地阅读这部分的代码。毕竟很多人在自己实现一个文件服务器时,还是存在不少问题的。 ​ 如需下载本站全部技术文章,可以在【高性能服务器开发】公众号回复关键字“文章下载”即可。最近更新时间: 2020-10-04 15:32:55 "},"articles/TeamTalk源码解析/09服务器端route_server源码分析.html":{"url":"articles/TeamTalk源码解析/09服务器端route_server源码分析.html","title":"09 服务器端route_server源码分析","keywords":"","body":"09 服务器端route_server源码分析 route_server的作用主要是用户不同msg_server之间消息路由,其框架部分和前面的服务类似,没有什么好说的。我们这里重点介绍下它的业务代码,也就是其路由细节: void CRouteConn::HandlePdu(CImPdu* pPdu) { switch (pPdu->GetCommandId()) { case CID_OTHER_HEARTBEAT: // do not take any action, heart beat only update m_last_recv_tick break; case CID_OTHER_ONLINE_USER_INFO: _HandleOnlineUserInfo( pPdu ); break; case CID_OTHER_USER_STATUS_UPDATE: _HandleUserStatusUpdate( pPdu ); break; case CID_OTHER_ROLE_SET: _HandleRoleSet( pPdu ); break; case CID_BUDDY_LIST_USERS_STATUS_REQUEST: _HandleUsersStatusRequest( pPdu ); break; case CID_MSG_DATA: case CID_SWITCH_P2P_CMD: case CID_MSG_READ_NOTIFY: case CID_OTHER_SERVER_KICK_USER: case CID_GROUP_CHANGE_MEMBER_NOTIFY: case CID_FILE_NOTIFY: case CID_BUDDY_LIST_REMOVE_SESSION_NOTIFY: _BroadcastMsg(pPdu, this); break; case CID_BUDDY_LIST_SIGN_INFO_CHANGED_NOTIFY: _BroadcastMsg(pPdu); break; default: log(\"CRouteConn::HandlePdu, wrong cmd id: %d \", pPdu->GetCommandId()); break; } } 上面是route_server处理的消息类型,我们逐一来介绍: CID_OTHER_ONLINE_USER_INFO 这个消息是msg_server连接上route_server后告知route_server自己上面的用户登录情况。route_server处理后,只是简单地记录一下每个msg_server上的用户数量和用户id: void CRouteConn::_HandleOnlineUserInfo(CImPdu* pPdu) { IM::Server::IMOnlineUserInfo msg; CHECK_PB_PARSE_MSG(msg.ParseFromArray(pPdu->GetBodyData(), pPdu->GetBodyLength())); uint32_t user_count = msg.user_stat_list_size(); log(\"HandleOnlineUserInfo, user_cnt=%u \", user_count); for (uint32_t i = 0; i CID_OTHER_USER_STATUS_UPDATE 这个消息是当某个msg_server上有用户上下线时,msg_server会给route_server发送自己最近的用户数量和在线用户id信息,route_server的处理也就是更新下记录的该msg_server上的用户数量和用户id。 CID_OTHER_ROLE_SET 这个消息没看懂,感觉是确定主从关系的,不过感觉没什么用? CID_OTHER_GET_DEVICE_TOKEN_RSP 这个消息用户获取某个用户的登录情况,比如pc登录、安卓版登录还是ios登录,用于某些特殊消息的处理,比如文件发送不会推给移动在线的用户。 CID_MSG_DATA: CID_SWITCH_P2P_CMD: CID_MSG_READ_NOTIFY: CID_OTHER_SERVER_KICK_USER: CID_GROUP_CHANGE_MEMBER_NOTIFY: CID_FILE_NOTIFY: CID_BUDDY_LIST_REMOVE_SESSION_NOTIFY CID_BUDDY_LIST_SIGN_INFO_CHANGED_NOTIFY 这几个消息都是往外广播消息,由于msg_server 可以配置多个,A给B发了一条消息,必须广播在各个msg_server 才能知道B到底在哪个msg_server上。 void CRouteConn::_BroadcastMsg(CImPdu* pPdu, CRouteConn* pFromConn) { ConnMap_t::iterator it; for (it = g_route_conn_map.begin(); it != g_route_conn_map.end(); it++) { CRouteConn* pRouteConn = (CRouteConn*)it->second; if (pRouteConn != pFromConn) { pRouteConn->SendPdu(pPdu); } } } 也就是说CRouteConn代表着到msg_server的连接。 route_server的介绍就这么多了,虽然比较简单,但是这种路由的思想却是非常值得我们学习。网络通信数据包的在不同ip地址的路由最终被送达目的地,也就是这个原理。 如需下载本站全部技术文章,可以在【高性能服务器开发】公众号回复关键字“文章下载”即可。最近更新时间: 2020-10-04 15:32:38 "},"articles/TeamTalk源码解析/10开放一个TeamTalk测试服务器地址和几个测试账号.html":{"url":"articles/TeamTalk源码解析/10开放一个TeamTalk测试服务器地址和几个测试账号.html","title":"10 开放一个TeamTalk测试服务器地址和几个测试账号","keywords":"","body":"10 开放一个TeamTalk测试服务器地址和几个测试账号 由于TeamTalk是用于企业内部的即时通讯软件,一般客户端并不提供账号注册功能。如果你仅对TeamTalk的客户端感兴趣,你可以仅仅研究pc端和移动端代码。官方的测试服务器地址已经失效,所以我已经部署了一套TeamTalk服务器,并建立了几个测试账户可以供你使用: tangseng sunwukong zhubajie shaseng ================== xiaowang xiaoming xiaozhao xiaoli ================== 以上是账户名,密码随意。我改了下服务器端的代码,密码不进行校验的。你可以填写任意密码。 pc端设置方式: 安卓端设置方式: 关于ios端,目前由于服务器端的push_server没有部署,暂且就不提供了。 我专门把pc端代码和安卓端代码提取出来供大家下载: pc端: 下载地址:http://download.csdn.net/detail/analogous_love/9851833 开发工具:VS2013 安卓端: 下载地址:http://download.csdn.net/detail/analogous_love/9851845 IDE使用Android-studio java 1.7 gradle 2.2.1 如果测试服务器连接不上,请通过微信 easy_coder 与我联系。 如需下载本站全部技术文章,可以在【高性能服务器开发】公众号回复关键字“文章下载”即可。最近更新时间: 2020-10-04 14:38:03 "},"articles/TeamTalk源码解析/11pc客户端源码分析.html":{"url":"articles/TeamTalk源码解析/11pc客户端源码分析.html","title":"11 pc客户端源码分析","keywords":"","body":"11 pc客户端源码分析 ——写在前面的话 在要不要写这篇文章的纠结中挣扎了好久,就我个人而已,我接触windows编程,已经六七个年头了,尤其是在我读研的三年内,基本心思都是花在学习和研究windows程序上了。我很庆幸我当初学习windows程序走了一条正确的路线:先是学习常用的windows程序原理和基本API,再学习的mfc、wtl等一些常用的框架和类库,同时看了大量windows项目的源码,如金山卫士的开源代码、filezilla、电驴源码等等。个人觉得,基础真的很重要,拿windows开发来说,当你掌握了windows的程序的基本原理,我列一下大致范围: windows消息机制(消息如何产生、如何发送、如何处理,常见的消息有哪些、消息的优先级、如何自定义消息、窗体消息、常用控件消息) gdi原理(要熟悉gdi的各种对象,如画笔、画刷、字体、区域、裁剪、位图等,熟悉它们的API,熟悉各种gdi绘图API、当然最好也要熟悉一整套的gdi+的类,gdi与gdi+的区别) windows进程与线程的概念(进程的概念、如何创建、如何结束、跨进程如何通信;线程的创建与销毁、线程间的同步与资源保护,熟悉windows常用的线程同步对象:临界区、事件、互斥体、信号量等) windows内存管理(清晰地掌握一个进程地址空间的内存分布、windows堆的创建与管理等) dll技术(dll的生成、变量的导出、函数的导出、类的导出、如何查看dll导出哪些函数、隐式dll的加载、显示dll的加载、远程dll注入技术等) PE文件(一个PE文件的结构、有哪些节、如何修改、分别映射到进程地址空间的什么位置等) windows SEH(结构化异常处理) windows socket编程 windows读写文件技术(像CreateFile、WriteFile、GetFileSize等这些API应该熟练掌握、内存映射技术) 当然很多必备的技术也不好归类到windows技术下面,比如socket编程,这涉及到很多网络的知识,比如tcp的三次握手,数据的收发等,还有就是各种字符编码的知识、以及之间的相互转换,又比如一系列的CRT函数及其对应的宽字符版本。当然如果你搞windows开发,一定要熟悉开发工具Visual Studio,熟悉其工程项目的大多数属性配置,而且要做到知其然也知其所以然。如果不是不能跨平台,我敢说VS是史上最好最强大的开发工具,没有之一!我已经有好几年年不做windows开发了,目前主要从事linux开发,但windows的很多设计思想真的很好,非常值得借鉴,而且从编码风格来说,虽然看起来有点怪异,但是非常规范和易懂。 有了基础知识,你可以轻松地对工作中的一些问题给出解决方案,也能轻松阅读和使用市面上的那些库,比如,如果你深刻理解windows GDI,你不会在一个群里大喊,duilib某个属性为什么不起作用,你可以直接去阅读它的画法代码,如果是bug你可以改bug,如果只是你使用错误,你可以了解到正确的使用方法。所以基础这个东西,在短时间内,可能让你看不出与其他人的差别,但是从长远来看,它决定着你在技术上走的高度与深度。套用侯捷先生的一句话:勿在浮沙筑高台。 —— 正题 上面简单地介绍了下,我个人学习windows程序设计的一些心得吧。扯的有点远了,让我们回到正题上来,来分析TeamTalk的源码吧。当然这篇文章与前面介绍的不一样,我们不仅介绍程序的正题设计思路,还会介绍一些有意义的细节,比如一些windows开发中常用的一些细节。 一、程序功能 我们来先看下TeamTalk pc客户端包括哪些功能:TeamTalk因为开发的初衷是用于企业内部的即时通讯软件,所以,不提供对外注册的功能,一个员工的加入一般是人事部门在后台管理系统来新增该员工信息。其功能包括登录、聊天、群聊和建讨论组,当然聊天过程中可以发文字、表情、图片和文件,还包括查看聊天记录和简单地查看某个员工的个人信息,业务功能其实不多的。下面是一些功能截图: 二、编译方法与项目工程文件介绍 TeamTalk的pc客户端的下载地址是:https://github.com/baloonwj/TeamTalk 代码包括服务器端代码、pc端、mac端、安卓和IOS端,还有web端所有代码。 pc客户端代码的编译方法很简单:用VS2013打开win-client\\solution目录下的teamtalk.sln,编译即可。你的VS版本至少要是VS2013,因为代码中大量使用了C++11的东西,VS2013以下版本是不支持C++11的语法的。当然,如果你是VS2015的话,可以参考这篇文章来进行修改和编译:http://www.07net01.com/linux/2017/01/1795569.html 打开teamtalk.sln之后,总共有10个解决方法,如下图所示: 其中teamtalk是主工程,你应该将它设置成启动工程,编译完成之后就可以调试了。你可以自己配置服务器来连接进行调试,我也可以连接我的测试服务器,具体参见《TeamTalk源码分析(十) —— 开放一个TeamTalk测试服务器地址和几个测试账号》。下面先大致介绍一个各个工程的作用: Duilib是teamtalk使用的一款开源界面库,该界面库模仿web开发中的布局技术,使用xml文件来布局windows界面,并且在主窗口上绘制所有子控件,也就是所谓的directUI技术; GifSmiley是程序中用来解析和显示gif格式的图片的库,以支持gif图片的动画效果; httpclient功能是程序中使用的http请求库,登录前程序会先连接服务器的login_server以获得后续需要登录的msg_server的ip地址和端口号 等信息,这里就是使用的http协议,同时聊天过程中收发的聊天图片与图片服务器msfs也使用http协议来收发这些图片; libogg是一个语音库,用来解析声音文件的,因为pc客户端可能会收到移动端的语音聊天,相比较传统的.wav、.mp3、.wma,.ogg格式的不仅音质高,而且音频文件的体积小,腾讯的QQ游戏英雄杀中的语音也是使用这个格式的。 libspeex是一个音频压缩库; Modules就是TeamTalk中使用的各种库了,展开来看下你就明白了: network是teamtalk使用的网络通信的代码,其实teamtalk pc端和服务器端使用的是同一套网络通信库,只不过如果服务器运行在linux下,其核心的IO复用模型是epoll,而pc客户端使用的IO复用模型是select; speexdec 也是和ogg格式相关的编码和解码器; teamtalk是主程序入口工程; utility包含了teamtalk中用到的一些工具类工程,比如sqlite的包装接口、md5工具类等。 除了上面介绍的一些库以外,程序还使用了sqlite库、谷歌protobuf库、日志库yaolog等。关于yaolog可参见http://blog.csdn.net/gemo/article/details/8499692,这个日志库比较有意思的地方是可以单独打印出网络通信中的字节流的二进制形式,推荐一下,效果如下图所示(位于win-client\\bin\\teamtalk\\Debug\\log\\socket.log文件中): 三、程序总体框架介绍 整个程序使用了mfc框架来做一个架子,而所有的窗口和对话框都使用的是duilib,关于duilib网上有很多资料,这里不介绍duilib细节的东西了。一个mfc程序框架,使用起来也很简单,就是定义一个类集成mfc的CWinApp类,并改写其InitInstance()方法,mfc内部会替我们做好消息循环的步骤。TeamTalk相关的代码如下: //位于teamtalk.h中 class CteamtalkApp : public CWinApp { public: CteamtalkApp(); public: virtual BOOL InitInstance(); virtual BOOL ExitInstance(); private: /** * 创建用户目录 * * @return BOOL * @exception there is no any exception to throw. */ BOOL _CreateUsersFolder(); /** * 创建主窗口 * * @return BOOL * @exception there is no any exception to throw. */ BOOL _CreateMainDialog(); /** * 销毁主窗口 * * @return BOOL * @exception there is no any exception to throw. */ BOOL _DestroyMainDialog(); /** * 判断是否是单实例 * * @return BOOL * @exception there is no any exception to throw. */ BOOL _IsHaveInstance(); void _InitLog(); private: MainDialog* m_pMainDialog; }; 在teamtalk.cpp中定义了唯一的全局对象CteamtalkApp对象: 接着,所有的初始化工作就是写在CteamtalkApp::InitInstance()方法中了: BOOL CteamtalkApp::InitInstance() { INITCOMMONCONTROLSEX InitCtrls; InitCtrls.dwSize = sizeof(InitCtrls); InitCtrls.dwICC = ICC_WIN95_CLASSES; InitCommonControlsEx(&InitCtrls); //log init _InitLog(); // Verify that the version of the library that we linked against is // compatible with the version of the headers we compiled against. GOOGLE_PROTOBUF_VERIFY_VERSION; LOG__(APP, _T(\"===================================VersionNO:%d======BulidTime:%s--%s==========================\") , TEAMTALK_VERSION, util::utf8ToCString(__DATE__), util::utf8ToCString(__TIME__)); if (!__super::InitInstance()) { LOG__(ERR, _T(\"__super::InitInstance failed.\")); return FALSE; } AfxEnableControlContainer(); //为了调试方便,暂且注释掉 //if (_IsHaveInstance()) //{ // LOG__(ERR, _T(\"Had one instance,this will exit\")); // HWND hwndMain = FindWindow(_T(\"TeamTalkMainDialog\"), NULL); // if (hwndMain) // { // ::SendMessage(hwndMain, WM_START_MOGUTALKINSTANCE, NULL, NULL); // } // return FALSE; //} //start imcore lib //在这里启动任务队列和网络IO线程 if (!imcore::IMLibCoreRunEvent()) { LOG__(ERR, _T(\"start imcore lib failed!\")); } LOG__(APP, _T(\"start imcore lib done\")); //start ui event //在这里创建代理窗口并启动定时器定时处理任务 if (module::getEventManager()->startup() != imcore::IMCORE_OK) { LOG__(ERR, _T(\"start ui event failed\")); } LOG__(APP, _T(\"start ui event done\")); //create user folders _CreateUsersFolder(); //duilib初始化 CPaintManagerUI::SetInstance(AfxGetInstanceHandle()); CPaintManagerUI::SetResourcePath(CPaintManagerUI::GetInstancePath() + _T(\"..\\\\gui\\\\\"));//track这个设置了路径,会导致base里设置的无效。 ::CoInitialize(NULL); ::OleInitialize(NULL); //无需配置server module::TTConfig* pCfg = module::getSysConfigModule()->getSystemConfig(); if (pCfg && pCfg->loginServIP.IsEmpty()) { if (!module::getSysConfigModule()->showServerConfigDialog(NULL)) { LOG__(APP, _T(\"server config canceled\")); return FALSE; } } if (!module::getLoginModule()->showLoginDialog()) { LOG__(ERR, _T(\"login canceled\")); return FALSE; } LOG__(APP,_T(\"login success\")); //创建主窗口 if (!_CreateMainDialog()) { LOG__(ERR, _T(\"Create MianDialog failed\")); return FALSE; } LOG__(APP, _T(\"Create MianDialog done\")); CPaintManagerUI::MessageLoop(); CPaintManagerUI::Term(); return TRUE; } 上述代码大致做了以下工作: // 1. 初始化yaolog日志库 // 2. google protobuf的版本号检测 // 3. 启动网络通信线程检测网络数据读写,再启动一个线程创建一个队列,如果队列中有任务,则取出该任务执行 // 4. 创建支线程与UI线程的桥梁——代理窗口 // 5. 创建用户文件夹 // 6. 配置duilib的资源文件路径、初始化com库、初始化ole库 // 7. 如果没有配置登录服务器的地址,则显示配置对话框 // 8. 显示登录对话框 // 9. 登录成功后,登录对话框销毁,显示主对话框 // 10. 启动duilib的消息循环(也就是说不使用mfc的消息循环) 其它的没什么好介绍的,我们来重点介绍下第3点和第4点。先说第3点,在第3点中又会牵扯出第4点,网络通信线程的启动: //start imcore lib //在这里启动任务队列和网络IO线程 if (!imcore::IMLibCoreRunEvent()) { LOG__(ERR, _T(\"start imcore lib failed!\")); } LOG__(APP, _T(\"start imcore lib done\")); LOG__(ERR, _T(\"start imcore lib failed!\")); } LOG__(APP, _T(\"start imcore lib done\")); bool IMLibCoreRunEvent() { LOG__(NET, _T(\"===============================================================================\")); //在这里启动任务队列处理线程 getOperationManager()->startup(); CAutoLock lock(&g_lock); if (!netlib_is_running()) { #ifdef _MSC_VER unsigned int m_dwThreadID; //在这里启动网络IO线程 g_hThreadHandle = (HANDLE)_beginthreadex(0, 0, event_run, 0, 0, (unsigned*)&m_dwThreadID); if (g_hThreadHandle (HANDLE)1; #else pthread_t pt; pthread_create(&pt, NULL, event_run, NULL); #endif } return true; } 先看getOperationManager()->startup();: IMCoreErrorCode OperationManager::startup() { m_operationThread = std::thread([&] { std::unique_lock lck(m_cvMutex); Operation* pOperation = nullptr; while (m_bContinue) { if (!m_bContinue) break; if (m_vecRealtimeOperations.empty()) m_CV.wait(lck); if (!m_bContinue) break; { std::lock_guard lock(m_mutexOperation); if (m_vecRealtimeOperations.empty()) continue; pOperation = m_vecRealtimeOperations.front(); m_vecRealtimeOperations.pop_front(); } if (!m_bContinue) break; if (pOperation) { pOperation->process(); pOperation->release(); } } }); return IMCORE_OK; } 这里利用一个C++11的新语法lamda表达式来创建一个线程,线程函数就是lamda表达式的具体内容:先从队列中取出任务,然后执行。所有的任务都继承其基类Operation,而Operation又继承接口类IOperatio,任务类根据自己具体需要做什么来改写process()方法: class NETWORK_DLL Operation : public IOperation { enum OperationState { OPERATION_IDLE = 0, OPERATION_STARTING, OPERATION_RUNNING, OPERATION_CANCELLING, OPERATION_FINISHED }; public: /** @name Constructors and Destructor*/ //@{ /** * Constructor */ Operation(); Operation(const std::string& name); /** * Destructor */ virtual ~Operation(); //@} public: virtual void processOpertion() = 0; public: virtual void process(); virtual void release(); inline std::string name() const { return m_name; } inline void set_name(__in std::string name){ m_name = name; } private: OperationState m_state; std::string m_name; }; struct NETWORK_DLL IOperation { public: virtual void process() = 0; //private: /** * 必须让容器来释放自己 * * @return void * @exception there is no any exception to throw. */ virtual void release() = 0; }; 这里我们介绍的任务队列我们称为队列A,下文中还有一个专门做http请求的队列,我们称为队列B。 后半部分代码其实就是启动网络检测线程,检测网络数据读写: g_hThreadHandle = (HANDLE)_beginthreadex(0, 0, event_run, 0, 0, (unsigned*)&m_dwThreadID); unsigned int __stdcall event_run(void* threadArgu) { LOG__(NET, _T(\"event_run\")); netlib_init(); netlib_set_running(); netlib_eventloop(); return NULL; } void netlib_eventloop(uint32_t wait_timeout) { CEventDispatch::Instance()->StartDispatch(wait_timeout); } void CEventDispatch::StartDispatch(uint32_t wait_timeout) { fd_set read_set, write_set, excep_set; timeval timeout; timeout.tv_sec = 1; //wait_timeout 1 second timeout.tv_usec = 0; while (running) { //_CheckTimer(); //_CheckLoop(); if (!m_read_set.fd_count && !m_write_set.fd_count && !m_excep_set.fd_count) { Sleep(MIN_TIMER_DURATION); continue; } m_lock.lock(); FD_ZERO(&read_set); FD_ZERO(&write_set); FD_ZERO(&excep_set); memcpy(&read_set, &m_read_set, sizeof(fd_set)); memcpy(&write_set, &m_write_set, sizeof(fd_set)); memcpy(&excep_set, &m_excep_set, sizeof(fd_set)); m_lock.unlock(); if (!running) break; //for (int i = 0; i OnRead(); pSocket->ReleaseRef(); } } for (u_int i = 0; i OnWrite(); pSocket->ReleaseRef(); } } for (u_int i = 0; i OnClose(); pSocket->ReleaseRef(); } } } } 我们举个具体的例子来说明这个三个线程的逻辑(任务队列A、网络线程和下文要介绍的专门处理http请求的任务队列B)和代理窗口的消息队列,以在登录对话框输入用户名和密码后接下来的步骤: //位于LoginDialog.cpp中 void LoginDialog::_DoLogin() { LOG__(APP,_T(\"User Clicked LoginBtn\")); m_ptxtTip->SetText(_T(\"\")); CDuiString userName = m_pedtUserName->GetText(); CDuiString password = m_pedtPassword->GetText(); if (userName.IsEmpty()) { CString csTip = util::getMultilingual()->getStringById(_T(\"STRID_LOGINDIALOG_USERNAME_EMPTY\")); m_ptxtTip->SetText(csTip); return; } if (password.IsEmpty()) { CString csTip = util::getMultilingual()->getStringById(_T(\"STRID_LOGINDIALOG_PASSWORD_EMPTY\")); m_ptxtTip->SetText(csTip); return; } module::TTConfig* pCfg = module::getSysConfigModule()->getSystemConfig(); pCfg->userName = userName; if (m_bPassChanged) { std::string sPass = util::cStringToString(CString(password)); char* pOutData = 0; uint32_t nOutLen = 0; int retCode = EncryptPass(sPass.c_str(), sPass.length(), &pOutData, nOutLen); if (retCode == 0 && nOutLen > 0 && pOutData != 0) { pCfg->password = std::string(pOutData, nOutLen); Free(pOutData); } else { LOG__(ERR, _T(\"EncryptPass Failed!\")); CString csTip = util::getMultilingual()->getStringById(_T(\"STRID_LOGINDIALOG_LOGIN_ENCRYPT_PASE_FAIL\")); m_ptxtTip->SetText(csTip); return; } } pCfg->isRememberPWD = m_pChkRememberPWD->GetCheck(); module::getSysConfigModule()->saveData(); CString csTxt = util::getMultilingual()->getStringById(_T(\"STRID_LOGINDIALOG_BTN_DOLOGIN\")); m_pBtnLogin->SetText(csTxt); m_pBtnLogin->SetEnabled(false); //连接登陆服务器 DoLoginServerParam param; DoLoginServerHttpOperation* pOper = new DoLoginServerHttpOperation( BIND_CALLBACK_1(LoginDialog::OnHttpCallbackOperation), param); module::getHttpPoolModule()->pushHttpOperation(pOper); } 点击登录按钮之后,程序先对用户名和密码进行一些有效性校验,接着产生一个DoLoginServerHttpOperation对象,该类继承IHttpOperation,IHttpOperation再继承ICallbackOpertaion,ICallbackOpertaion再继承Operation类。这个任务会绑定一个任务完成之后的回调函数,即宏BIND_CALLBACK_1,这个宏实际上就是std::bind: #define BIND_CALLBACK_1(func) std::bind(&func, this, placeholders::_1) #define BIND_CALLBACK_2(func) std::bind(&func, this, placeholders::_1, placeholders::_2) 往任务队列中放入任务的动作如下: void HttpPoolModule_Impl::pushHttpOperation(module::IHttpOperation* pOperaion, BOOL bHighPriority /*= FALSE*/) { if (NULL == pOperaion) { return; } CAutoLock lock(&m_mtxLock); if (bHighPriority) m_lstHttpOpers.push_front(pOperaion); else m_lstHttpOpers.push_back(pOperaion); _launchThread(); ::ReleaseSemaphore(m_hSemaphore, 1, NULL); return; } 其中_launchThread()会启动一个线程,该线程函数是另外一个任务队列,专门处理http任务: BOOL HttpPoolModule_Impl::_launchThread() { if ((int)m_vecHttpThread.size() >= MAX_THEAD_COUNT) { return TRUE; } TTHttpThread* pThread = new TTHttpThread(); PTR_FALSE(pThread); if (!pThread->create()) { return FALSE; } Sleep(300); m_vecHttpThread.push_back(pThread); return TRUE; } 线程函数最终实际执行代码如下: UInt32 TTHttpThread::process() { module::IHttpOperation * pHttpOper = NULL; HttpPoolModule_Impl *pPool = m_pInstance; while (m_bContinue) { if (WAIT_OBJECT_0 != ::WaitForSingleObject(pPool->m_hSemaphore, INFINITE)) { break; } if (!m_bContinue) { break; } { CAutoLock lock(&(pPool->m_mtxLock)); if (pPool->m_lstHttpOpers.empty()) pHttpOper = NULL; else { pHttpOper = pPool->m_lstHttpOpers.front(); pPool->m_lstHttpOpers.pop_front(); } } try { if (m_bContinue && pHttpOper) { pHttpOper->process(); pHttpOper->release(); } } catch (...) { LOG__(ERR, _T(\"TTHttpThread: Failed to execute opertaion(0x%p)\"), pHttpOper); } } return 0; } 当这个http任务被任务队列执行时,实际执行DoLoginServerHttpOperation::processOpertion(),代码如下: void DoLoginServerHttpOperation::processOpertion() { module::TTConfig* pCfg = module::getSysConfigModule()->getSystemConfig(); LOG__(APP, _T(\"loginAddr = %s\"), pCfg->loginServIP); std::string& loginAddr = util::cStringToString(pCfg->loginServIP); std::string url = loginAddr; DoLoginServerParam* pPamram = new DoLoginServerParam(); pPamram->resMsg = util::getMultilingual()->getStringById(_T(\"STRID_LOGINDIALOG_LOGIN_HTTP_DEFERROR\")); Http::HttpResponse response; Http::HttpClient client; //对于登录:url=http://192.168.226.128:8080/msg_server Http::HttpRequest request(\"get\", url); if (!client.execute(&request, &response)) { CString csTemp = util::stringToCString(url); pPamram->result = DOLOGIN_FAIL; LOG__(ERR,_T(\"failed %s\"), csTemp); asyncCallback(std::shared_ptr(pPamram)); client.killSelf(); return; } /** { \"backupIP\" : \"localhost\", \"code\" : 0, \"discovery\" : \"http://127.0.0.1/api/discovery\", \"msfsBackup\" : \"http://127.0.0.1:8700/\", \"msfsPrior\" : \"http://127.0.0.1:8700/\", \"msg\" : \"\", \"port\" : \"8000\", \"priorIP\" : \"localhost\" } */ std::string body = response.getBody(); client.killSelf(); //json解析 try { Json::Reader reader; Json::Value root; if (!reader.parse(body, root)) { CString csTemp = util::stringToCString(body); LOG__(ERR, _T(\"parse data failed,%s\"), csTemp); pPamram->result = DOLOGIN_FAIL; pPamram->resMsg = util::getMultilingual()->getStringById(_T(\"STRID_LOGINDIALOG_LOGIN_HTTP_JSONERROR\")); goto End; } int nCode = root.get(\"code\", \"\").asInt(); if (0 == nCode)//登陆成功 { LOG__(APP, _T(\"get msgSvr IP succeed!\")); pCfg->msgSevPriorIP = root.get(\"priorIP\", \"\").asString(); pCfg->msgSevBackupIP = root.get(\"backupIP\", \"\").asString(); std::string strPort = root.get(\"port\", \"\").asString(); pCfg->msgServPort = util::stringToInt32(strPort); pCfg->fileSysAddr = util::stringToCString(root.get(\"msfsPrior\", \"\").asString()); pCfg->fileSysBackUpAddr = util::stringToCString(root.get(\"msfsBackup\", \"\").asString()); pPamram->result = DOLOGIN_SUCC; } else { LOG__(ERR, _T(\"get msgSvr IP failed! Code = %d\"),nCode); pPamram->result = DOLOGIN_FAIL; CString csRetMsgTemp = util::stringToCString(root.get(\"msg\", \"\").asString()); if (!csRetMsgTemp.IsEmpty()) pPamram->resMsg = csRetMsgTemp; } } catch (...) { CString csTemp = util::stringToCString(body); LOG__(ERR,_T(\"parse json execption,%s\"), csTemp); pPamram->result = DOLOGIN_FAIL; pPamram->resMsg = util::getMultilingual()->getStringById(_T(\"STRID_LOGINDIALOG_LOGIN_HTTP_JSONERROR\")); } End: asyncCallback(std::shared_ptr(pPamram)); } 实际上是向login_server发送一个http请求,这是一个同步请求。得到的结果是一个json字符串,代码注释中已经给出。然后调用asyncCallback(std::shared_ptr(pPamram));参数pPamram携带了当前任务的回调函数指针: /** * 异步回调,借助UIEvent * * @param std::shared_ptr param * @return void * @exception there is no any exception to throw. */ void asyncCallback(std::shared_ptr param) { CallbackOperationEvent* pEvent = new CallbackOperationEvent(m_callback, param); module::getEventManager()->asynFireUIEvent(pEvent); } 这实际上产生了一个回调事件。也就是说队列B做http请求,操作完成后往代理窗口的消息队列中放入一个回调事件,这个事件通过代理窗口过程函数来处理的(这就是上文中第4点介绍的代理窗口过程的作用,实际上是利用windows消息队列来做任务处理(系统有现成的任务队列系统,为何不利用呢?)): module::IMCoreErrorCode UIEventManager::asynFireUIEvent(IN const IEvent* const pEvent) { assert(m_hWnd); assert(pEvent); if (0 == m_hWnd || 0 == pEvent) return IMCORE_ARGUMENT_ERROR; if (FALSE == ::PostMessage(m_hWnd, UI_EVENT_MSG, reinterpret_cast(this), reinterpret_cast(pEvent))) return IMCORE_WORK_POSTMESSAGE_ERROR; return IMCORE_OK; } 看到没有?向代理窗口的消息队列中投递一个UI_EVENT_MSG事件,并在消息参数LPARAM中传递了回调事件的对象指针。这样代理窗口过程函数就可以处理这个消息了: LRESULT _stdcall UIEventManager::_WindowProc(HWND hWnd , UINT message , WPARAM wparam , LPARAM lparam) { switch (message) { case UI_EVENT_MSG: reinterpret_cast(wparam)->_processEvent(reinterpret_cast(lparam), TRUE); break; case WM_TIMER: reinterpret_cast(wparam)->_processTimer(); break; default: break; } return ::DefWindowProc(hWnd, message, wparam, lparam); } void UIEventManager::_processEvent(IEvent* pEvent, BOOL bRelease) { assert(pEvent); if (0 == pEvent) return; try { pEvent->process(); if (bRelease) pEvent->release(); } catch (imcore::Exception *e) { LOG__(ERR, _T(\"event run exception\")); pEvent->onException(e); if (bRelease) pEvent->release(); if (e) { LOG__(ERR, _T(\"event run exception:%s\"), util::stringToCString(e->m_msg)); assert(FALSE); } } catch (...) { LOG__(ERR, _T(\"operation run exception,unknown reason\")); if (bRelease) pEvent->release(); assert(FALSE); } } 根据C++的多态特性,pEvent->process()实际上调用的是CallbackOperationEvent.process()。代码如下: virtual void process() { m_callback(m_param); } m_callback(m_param);调用的就是上文中介绍DoLoginServerHttpOperation操作的回调函数LoginDialog::OnHttpCallbackOperation(): void LoginDialog::OnHttpCallbackOperation(std::shared_ptr param) { DoLoginServerParam* pParam = (DoLoginServerParam*)param.get(); if (DOLOGIN_SUCC == pParam->result) { module::TTConfig* pCfg = module::getSysConfigModule()->getSystemConfig(); PTR_VOID(pCfg); LoginParam loginparam; loginparam.csUserName = pCfg->userName; loginparam.password = pCfg->password; loginparam.csUserName.Trim(); LoginOperation* pOperation = new LoginOperation( BIND_CALLBACK_1(LoginDialog::OnOperationCallback), loginparam); imcore::IMLibCoreStartOperation(pOperation); } else { m_ptxtTip->SetText(pParam->resMsg); module::TTConfig* pCfg = module::getSysConfigModule()->getSystemConfig(); LOG__(ERR, _T(\"get MsgServer config faild,login server addres:%s:%d\"), pCfg->loginServIP,pCfg->loginServPort); CString csTxt = util::getMultilingual()->getStringById(_T(\"STRID_LOGINDIALOG_BTN_LOGIN\")); m_pBtnLogin->SetText(csTxt); m_pBtnLogin->SetEnabled(true); } } ok,终于到家了。但是这并没结束,我们只介绍了队列B和代理窗口消息队列,还有队列A呢?LoginDialog::OnHttpCallbackOperation()会根据获取的msg_server的情况来再次产生一个新的任务LoginOperation来放入队列A中,这次才是真正的用户登录,根据上面的介绍,LoginOperation任务从队列A中取出来之后,实际执行的是LoginOperation::processOpertion(): void LoginOperation::processOpertion() { LOG__(APP,_T(\"login start,uname:%s,status:%d\"), m_loginParam.csUserName , m_loginParam.mySelectedStatus); LoginParam* pParam = new LoginParam; pParam->csUserName = m_loginParam.csUserName; pParam->mySelectedStatus = m_loginParam.mySelectedStatus; //连接消息服务器 module::TTConfig* pCfg = module::getSysConfigModule()->getSystemConfig(); CString server = util::stringToCString(pCfg->msgSevPriorIP); LOG__(APP, _T(\"MsgServeIp:%s,Port:%d\"), server, pCfg->msgServPort); //8000端口 IM::Login::IMLoginRes* pImLoginResp = (IM::Login::IMLoginRes*)module::getTcpClientModule() ->doLogin(server, pCfg->msgServPort,m_loginParam.csUserName,m_loginParam.password); if (0 == pImLoginResp || pImLoginResp->result_code() != IM::BaseDefine::REFUSE_REASON_NONE || !pImLoginResp->has_user_info()) { //TODO,若失败,尝试备用IP LOG__(ERR,_T(\"add:%s:%d,uname:%s,login for msg server failed\"),server,pCfg->msgServPort, m_loginParam.csUserName); if (pImLoginResp) { CString errInfo = util::stringToCString(pImLoginResp->result_string()); pParam->errInfo = errInfo; pParam->result = LOGIN_FAIL; pParam->server_result = pImLoginResp->result_code(); LOG__(ERR, _T(\"error code :%d,error info:%s\"), pImLoginResp->result_code(), errInfo); } else { pParam->result = IM::BaseDefine::REFUSE_REASON_NO_MSG_SERVER; LOG__(ERR, _T(\"login msg server faild!\")); } asyncCallback(std::shared_ptr(pParam)); return; } pParam->result = LOGIN_OK; pParam->serverTime = pImLoginResp->server_time(); pParam->mySelectedStatus = pImLoginResp->online_status(); //存储服务器端返回的userId IM::BaseDefine::UserInfo userInfo = pImLoginResp->user_info(); pCfg->userId = util::uint32ToString(userInfo.user_id()); pCfg->csUserId = util::stringToCString(pCfg->userId); //登陆成功,创建自己的信息 module::UserInfoEntity myInfo; myInfo.sId = pCfg->userId; myInfo.csName = m_loginParam.csUserName; myInfo.onlineState = IM::BaseDefine::USER_STATUS_ONLINE; myInfo.csNickName = util::stringToCString(userInfo.user_nick_name()); myInfo.avatarUrl = userInfo.avatar_url(); myInfo.dId = util::uint32ToString(userInfo.department_id()); myInfo.department = myInfo.dId; myInfo.email = userInfo.email(); myInfo.gender = userInfo.user_gender(); myInfo.user_domain = userInfo.user_domain(); myInfo.telephone = userInfo.user_tel(); myInfo.status = userInfo.status(); myInfo.signature = userInfo.sign_info(); module::getUserListModule()->createUserInfo(myInfo); asyncCallback(std::shared_ptr(pParam)); LOG__(APP, _T(\"login succeed! Name = %s Nickname = %s sId = %s status = %d\") , m_loginParam.csUserName , util::stringToCString(userInfo.user_nick_name()) , module::getSysConfigModule()->UserID() , m_loginParam.mySelectedStatus); //开始发送心跳包 module::getTcpClientModule()->startHeartbeat(); } 同理,数据包发生成功以后,会再往代理窗口的消息队列中产生一个回调事件,最终调用刚才说的LoginOperation绑定的回调函数: void asyncCallback(std::shared_ptr param) { CallbackOperationEvent* pEvent = new CallbackOperationEvent(m_callback, param); module::getEventManager()->asynFireUIEvent(pEvent); } void LoginDialog::OnOperationCallback(std::shared_ptr param) { LoginParam* pLoginParam = (LoginParam*)param.get(); if (LOGIN_OK == pLoginParam->result) //登陆成功 { Close(IDOK); //创建用户目录 _CreateUsersFolder(); //开启同步消息时间timer module::getSessionModule()->startSyncTimeTimer(); module::getSessionModule()->setTime(pLoginParam->serverTime); //通知服务器客户端初始化完毕,获取组织架构信息和群列表 module::getLoginModule()->notifyLoginDone(); } else //登陆失败处理 { module::getTcpClientModule()->shutdown(); if (IM::BaseDefine::REFUSE_REASON_NO_MSG_SERVER == pLoginParam->server_result) { CString csTip = util::getMultilingual()->getStringById(_T(\"STRID_LOGINDIALOG_LOGIN_MSGSVR_FAIL\")); m_ptxtTip->SetText(csTip); } else if (!pLoginParam->errInfo.IsEmpty()) { m_ptxtTip->SetText(pLoginParam->errInfo); } else { CString errorCode = util::int32ToCString(pLoginParam->server_result); CString csTip = util::getMultilingual()->getStringById(_T(\"STRID_LOGINDIALOG_LOGIN_UNKNOWN_ERROR\")); m_ptxtTip->SetText(csTip + CString(\":\") + errorCode); } } CString csTxt = util::getMultilingual()->getStringById(_T(\"STRID_LOGINDIALOG_BTN_LOGIN\")); m_pBtnLogin->SetText(csTxt); m_pBtnLogin->SetEnabled(true); } 至此,登录才成功。等等,那数据包是怎么发到服务器的呢?这也是一个重点,我们来详细地介绍一下,LoginOperation::processOpertion()中有这一行代码: doLogin函数代码如下: IM::Login::IMLoginRes* TcpClientModule_Impl::doLogin(CString &linkaddr, UInt16 port ,CString& uName,std::string& pass) { m_socketHandle = imcore::IMLibCoreConnect(util::cStringToString(linkaddr), port); imcore::IMLibCoreRegisterCallback(m_socketHandle, this); if(util::waitSingleObject(m_eventConnected, 5000)) { IM::Login::IMLoginReq imLoginReq; string& name = util::cStringToString(uName); imLoginReq.set_user_name(name); imLoginReq.set_password(pass); imLoginReq.set_online_status(IM::BaseDefine::USER_STATUS_ONLINE); imLoginReq.set_client_type(IM::BaseDefine::CLIENT_TYPE_WINDOWS); imLoginReq.set_client_version(\"win_10086\"); if (TCPCLIENT_STATE_OK != m_tcpClientState) return 0; sendPacket(IM::BaseDefine::SID_LOGIN, IM::BaseDefine::CID_LOGIN_REQ_USERLOGIN, ++g_seqNum , &imLoginReq); m_pImLoginResp->Clear(); util::waitSingleObject(m_eventReceived, 10000); } return m_pImLoginResp; } 这段代码先连接服务器,然后调用sendPacket()发送登录数据包。如何连接服务器使用了一些“奇技淫巧”,我们后面单独介绍。我们这里先来看sendPacket()发包代码: void TcpClientModule_Impl::sendPacket(UInt16 moduleId, UInt16 cmdId, UInt16 seq, google::protobuf::MessageLite* pbBody) { m_TTPBHeader.clear(); m_TTPBHeader.setModuleId(moduleId); m_TTPBHeader.setCommandId(cmdId); m_TTPBHeader.setSeqNumber(seq); _sendPacket(pbBody); } void TcpClientModule_Impl::_sendPacket(google::protobuf::MessageLite* pbBody) { UInt32 length = imcore::HEADER_LENGTH + pbBody->ByteSize(); m_TTPBHeader.setLength(length); std::unique_ptr data(new byte[length]); memset(data.get(), 0, length); memcpy(data.get(), m_TTPBHeader.getSerializeBuffer(), imcore::HEADER_LENGTH); if (!pbBody->SerializeToArray(data.get() + imcore::HEADER_LENGTH, pbBody->ByteSize())) { LOG__(ERR, _T(\"pbBody SerializeToArray failed\")); return; } imcore::IMLibCoreWrite(m_socketHandle, data.get(), length); } 其实就是序列化成protobuf要求的格式,然后调用imcore::IMLibCoreWrite(m_socketHandle, data.get(), length);发出去: int IMLibCoreWrite(int key, uchar_t* data, uint32_t size) { int nRet = -1; int nHandle = key; CImConn* pConn = TcpSocketsManager::getInstance()->get_client_conn(nHandle); if (pConn) { pConn->Send((void*)data, size); } else { LOG__(NET, _T(\"connection is invalied:%d\"), key); } return nRet; } 先尝试着直接发送,如果目前tcp窗口太小发不出去,则暂且将数据放在发送缓冲区里面,并检测socket可写事件。这里就是和服务器一样的网络库的代码了,前面一系列的文章,我们已经介绍过了。 int CImConn::Send(void* data, int len) { if (m_busy) { m_out_buf.Write(data, len); return len; } int offset = 0; int remain = len; while (remain > 0) { int send_size = remain; if (send_size > NETLIB_MAX_SOCKET_BUF_SIZE) { send_size = NETLIB_MAX_SOCKET_BUF_SIZE; } int ret = netlib_send(m_handle, (char*)data + offset, send_size); if (ret 0) { m_out_buf.Write((char*)data + offset, remain); m_busy = true; LOG__(NET, _T(\"send busy, remain=%d\"), m_out_buf.GetWriteOffset()); } return len; } 数据发出去以后,服务器应答登录包,网络线程会检测到socket可读事件: void CBaseSocket::OnRead() { if (m_state == SOCKET_STATE_LISTENING) { _AcceptNewSocket(); } else { u_long avail = 0; if ( (ioctlsocket(m_socket, FIONREAD, &avail) == SOCKET_ERROR) || (avail == 0) ) { m_callback(m_callback_data, NETLIB_MSG_CLOSE, (net_handle_t)m_socket, NULL); } else { m_callback(m_callback_data, NETLIB_MSG_READ, (net_handle_t)m_socket, NULL); } } } void imconn_callback(void* callback_data, uint8_t msg, uint32_t handle, void* pParam) { NOTUSED_ARG(handle); NOTUSED_ARG(pParam); CImConn* pConn = TcpSocketsManager::getInstance()->get_client_conn(handle); if (!pConn) { //LOG__(NET, _T(\"connection is invalied:%d\"), handle); return; } pConn->AddRef(); // LOG__(NET, \"msg=%d, handle=%d\\n\", msg, handle); switch (msg) { case NETLIB_MSG_CONFIRM: pConn->onConnect(); break; case NETLIB_MSG_READ: pConn->OnRead(); break; case NETLIB_MSG_WRITE: pConn->OnWrite(); break; case NETLIB_MSG_CLOSE: pConn->OnClose(); break; default: LOG__(NET, _T(\"!!!imconn_callback error msg: %d\"), msg); break; } pConn->ReleaseRef(); } void CImConn::OnRead() { for (;;) { uint32_t free_buf_len = m_in_buf.GetAllocSize() - m_in_buf.GetWriteOffset(); if (free_buf_len = imcore::HEADER_LENGTH) { uint32_t len = m_in_buf.GetWriteOffset(); uint32_t length = CByteStream::ReadUint32(m_in_buf.GetBuffer()); if (length > len) break; try { imcore::TTPBHeader pbHeader; pbHeader.unSerialize((byte*)m_in_buf.GetBuffer(), imcore::HEADER_LENGTH); LOG__(NET, _T(\"OnRead moduleId:0x%x,commandId:0x%x\"), pbHeader.getModuleId(), pbHeader.getCommandId()); if (m_pTcpSocketCB) m_pTcpSocketCB->onReceiveData((const char*)m_in_buf.GetBuffer(), length); LOGBIN_F__(SOCK, \"OnRead\", m_in_buf.GetBuffer(), length); } catch (std::exception& ex) { assert(FALSE); LOGA__(NET, \"std::exception,info:%s\", ex.what()); if (m_pTcpSocketCB) m_pTcpSocketCB->onReceiveError(); } catch (...) { assert(FALSE); LOG__(NET, _T(\"unknown exception\")); if (m_pTcpSocketCB) m_pTcpSocketCB->onReceiveError(); } m_in_buf.Read(NULL, length); } } } 收取数据,并解包: void TcpClientModule_Impl::onReceiveData(const char* data, int32_t size) { if (m_pServerPingTimer) m_pServerPingTimer->m_bHasReceivedPing = TRUE; imcore::TTPBHeader header; header.unSerialize((byte*)data, imcore::HEADER_LENGTH); if (IM::BaseDefine::CID_OTHER_HEARTBEAT == header.getCommandId() && IM::BaseDefine::SID_OTHER == header.getModuleId()) { //模块器端过来的心跳包,不跳到业务层派发 return; } LOG__(NET, _T(\"receiveData message moduleId:0x%x,commandId:0x%x\") , header.getModuleId(), header.getCommandId()); if (g_seqNum == header.getSeqNumber()) { m_pImLoginResp->ParseFromArray(data + imcore::HEADER_LENGTH, size - imcore::HEADER_LENGTH); ::SetEvent(m_eventReceived); return; } //将网络包包装成任务放到逻辑任务队列里面去 _handlePacketOperation(data, size); } void TcpClientModule_Impl::_handlePacketOperation(const char* data, UInt32 size) { std::string copyInBuffer(data, size); imcore::IMLibCoreStartOperationWithLambda( [=]() { imcore::TTPBHeader header; header.unSerialize((byte*)copyInBuffer.data(),imcore::HEADER_LENGTH); module::IPduPacketParse* pModule = (module::IPduPacketParse*)__getModule(header.getModuleId()); if (!pModule) { assert(FALSE); LOG__(ERR, _T(\"module is null, moduleId:%d,commandId:%d\") , header.getModuleId(), header.getCommandId()); return; } std::string pbBody(copyInBuffer.data() + imcore::HEADER_LENGTH, size - imcore::HEADER_LENGTH); pModule->onPacket(header, pbBody); }); } 根据不同的命令号来做相应的处理: void UserListModule_Impl::onPacket(imcore::TTPBHeader& header, std::string& pbBody) { switch (header.getCommandId()) { case IM::BaseDefine::BuddyListCmdID::CID_BUDDY_LIST_RECENT_CONTACT_SESSION_RESPONSE: _recentlistResponse(pbBody); break; case IM::BaseDefine::BuddyListCmdID::CID_BUDDY_LIST_STATUS_NOTIFY: _userStatusNotify(pbBody); break; case IM::BaseDefine::BuddyListCmdID::CID_BUDDY_LIST_USER_INFO_RESPONSE: _usersInfoResponse(pbBody); break; case IM::BaseDefine::BuddyListCmdID::CID_BUDDY_LIST_REMOVE_SESSION_RES: _removeSessionResponse(pbBody); break; case IM::BaseDefine::BuddyListCmdID::CID_BUDDY_LIST_ALL_USER_RESPONSE: _allUserlistResponse(pbBody); break; case IM::BaseDefine::BuddyListCmdID::CID_BUDDY_LIST_USERS_STATUS_RESPONSE: _usersLineStatusResponse(pbBody); break; case IM::BaseDefine::BuddyListCmdID::CID_BUDDY_LIST_CHANGE_AVATAR_RESPONSE: _changeAvatarResponse(pbBody); break; case IM::BaseDefine::CID_BUDDY_LIST_REMOVE_SESSION_NOTIFY: _removeSessionNotify(pbBody); break; case IM::BaseDefine::CID_BUDDY_LIST_DEPARTMENT_RESPONSE: _departmentResponse(pbBody); break; case IM::BaseDefine::BuddyListCmdID::CID_BUDDY_LIST_AVATAR_CHANGED_NOTIFY: _avatarChangeNotify(pbBody); break; case IM::BaseDefine::BuddyListCmdID::CID_BUDDY_LIST_CHANGE_SIGN_INFO_RESPONSE: _changeSignInfoResponse(pbBody); break; case IM::BaseDefine::BuddyListCmdID::CID_BUDDY_LIST_SIGN_INFO_CHANGED_NOTIFY: _signInfoChangedNotify(pbBody); break; default: LOG__(ERR, _T(\"Unknow commandID:%d\"), header.getCommandId()); return; } } 每一个处理分支,都最终会产生一个事件放入代理窗口的消息队列中。这前面已经介绍过了。这里我不得不说一点,teamtalk对于其它数据包的应答都是走的上面的介绍的流程,但是对于登录的应答却是使用了一些特殊处理。听我慢慢道来: 上文中发送了登录数据包之后,在那里等一个事件10秒钟,如果10秒内这个事件有信号,则认为登录成功。那么什么情况该事件会有信号呢? 该事件在构造函数里面创建,默认无信号: 当网络线程收到数据以后(上文逻辑流中介绍过了): 除了心跳包直接过滤以外,通过一个序列号(Seq,变量g_seqNum)唯一标识了登录数据包的应答,如果收到这个序列号的数据,则置信m_eventReceived。这样等待在那里的登录流程就可以返回了,同时也得到了登录应答,登录应答数据记录在成员变量m_pImLoginResp中。如果是其它的数据包,则走的流程是_handlePacketOperation(data, size);,处理逻辑上文也介绍了。 至此,整个客户端程序结构就介绍完了,我们总结一下,实际上程序有如下几类线程: 网络事件检测线程,用于接收和发送网络数据; http任务处理线程用于处理http操作; 普通的任务处理线程,用于处理一般性的任务,比如登录; UI线程,界面逻辑处理,同时在UI线程里面有一个代理窗口的窗口过程函数,用于非UI线程与UI线程之间的数据流和逻辑中转,核心是利用PostMessage往代理线程投递事件,事件消息参数携带任务信息。 至于,像聊天、查看用户信息这些业务性的内容,留给有兴趣的读者自己去研究吧。 四、程序中使用的一些比较有意思的技巧摘录 唯一实例判断 很多程序只能启动一个实例,当你再次启动某个程序的实例时,会激活前一个实例,其实实现起来很简单,就是新建一个命名的Mutex,因为Mutex可以跨进程,当再次启动程序实例时,创建同名的Mutex,会无法创建,错误信息是已经存在。这是windows上非常常用的技巧,如果你从事windows开发,请你务必掌握它。看teamtalk的实现: #ifdef _DEBUG #define AppSingletonMutex _T(\"{7A666640-EDB3-44CC-954B-0C43F35A2E17}\") #else #define AppSingletonMutex _T(\"{5676532A-6F70-460D-A1F0-81D6E68F046A}\") #endif BOOL CteamtalkApp::_IsHaveInstance() { // 单实例运行 HANDLE hMutex = ::CreateMutex(NULL, TRUE, AppSingletonMutex); if (hMutex != NULL && GetLastError() == ERROR_ALREADY_EXISTS) { MessageBox(0, _T(\"上次程序运行还没完全退出,请稍后再启动!\"), _T(\"TeamTalk\"), MB_OK); return TRUE; } return FALSE; } socket函数connect()连接等待时长设定 传统的做法是将socket设置为非阻塞的,调用完connect函数之后,调用select函数检测socket是否可写,在select函数里面设置超时时间。代码如下: //为了调试方便,暂且注释掉 int ret = ::connect(m_hSocket, (struct sockaddr*)&addrSrv, sizeof(addrSrv)); if (ret == 0) { m_bConnected = TRUE; return TRUE; } if (ret == SOCKET_ERROR && WSAGetLastError() != WSAEWOULDBLOCK) { return FALSE; } fd_set writeset; FD_ZERO(&writeset); FD_SET(m_hSocket, &writeset); struct timeval tv = { timeout, 0 }; if (::select(m_hSocket + 1, NULL, &writeset, NULL, &tv) != 1) { return FALSE; } return TRUE; 我们看看teamtalk里面怎么做的: ​ 红色箭头的地方调用connect函数连接服务器,然后绿色的箭头等待一个事件有信号(内部使用WaitForSingleObject函数),那事件什么时候有信号呢? 网络线程检测第一次到socket可写时,调用onConnectDone函数: 实际做的事情还是和上面介绍的差不多。其实对于登录流程做成同步的,也是和这个类似,上文中我们介绍过。我早些年刚做windows网络通信方面的项目时,开始总是找不到好的处理等待登录请求应答的方法。这里是一种很不错的设置超时等待的方法。 teamtalk的截图功能 不知道,你在使用qq这样的截图工具时,QQ截图工具能自动检测出某个窗口的范围。这个功能在teamtalk中也有实现,实现代码如下: BOOL ScreenCapture::initCapture(__in HWND hWnd) { //register hot key const std::wstring screenCaptureHotkeyName = L\"_SCREEN_CAPTURE_HOTKEY\"; int iHotkeyId = (int)GlobalAddAtom(screenCaptureHotkeyName.c_str()); if (!RegisterHotKey(hWnd, iHotkeyId, MOD_CONTROL | MOD_SHIFT, 0x51)) //ctrl + shift + Q { GlobalDeleteAtom(iHotkeyId); } m_iHotkeyId = iHotkeyId; m_hRegisterHotkeyWnd = hWnd; return createMsgWindow(); } 程序初始化时,注册截屏快捷键,这里是ctrl+shift+Q(QQ默认是ctrl+alt+A)。当点击截屏按钮之后,开始启动截图: HWND hDesktopWnd = GetDesktopWindow(); HDC hScreenDC = GetDC(hDesktopWnd); RECT rc = { 0 }; GetWindowRect(hDesktopWnd, &rc); int cx = rc.right - rc.left; int cy = rc.bottom - rc.top; HBITMAP hBitmap = CreateCompatibleBitmap(hScreenDC, cx, cy); m_hMemDC = CreateCompatibleDC(hScreenDC); HGDIOBJ hOldBitmap = SelectObject(m_hMemDC, (HGDIOBJ)hBitmap); BitBlt(m_hMemDC, 0, 0, cx, cy, hScreenDC, 0, 0, SRCCOPY); m_hBkgMemDC = CreateCompatibleDC(hScreenDC); HBITMAP hBkgBitmap = CreateCompatibleBitmap(hScreenDC, cx, cy); SelectObject(m_hBkgMemDC, (HGDIOBJ)hBkgBitmap); BitBlt(m_hBkgMemDC, 0, 0, cx, cy, hScreenDC, 0, 0, SRCCOPY); HDC hMaskDC = CreateCompatibleDC(hScreenDC); HBITMAP hMaskBitmap = CreateCompatibleBitmap(hScreenDC, cx, cy); SelectObject(hMaskDC, (HGDIOBJ)hMaskBitmap); BLENDFUNCTION ftn = { AC_SRC_OVER, 0, 100, 0}; AlphaBlend(m_hBkgMemDC, 0, 0, cx, cy, hMaskDC, 0, 0, cx, cy, ftn); DeleteObject(hMaskBitmap); DeleteDC(hMaskDC); m_hDrawMemDC = CreateCompatibleDC(hScreenDC); HBITMAP hDrawBitmap = CreateCompatibleBitmap(hScreenDC, cx, cy); SelectObject(m_hDrawMemDC, hDrawBitmap); ReleaseDC(hDesktopWnd, hScreenDC); 实际上就是在桌面窗口上画图。再遍历当前所有有显示区域的窗口,并记录这些窗口的窗口句柄和矩形区域: for (HWND hWnd = GetTopWindow(NULL); NULL != hWnd; hWnd = GetWindow(hWnd, GW_HWNDNEXT)) { if (!IsWindow(hWnd) || !IsWindowVisible(hWnd) || IsIconic(hWnd)) { continue; } RECT rcWnd = { 0 }; GetWindowRect(hWnd, &rcWnd); adjustRectInScreen(rcWnd); if (ScreenCommon::isRectEmpty(rcWnd)) { continue; } wchar_t szTxt[MAX_PATH] = { 0 }; GetWindowText(hWnd, szTxt, MAX_PATH); if (wcslen(szTxt) 0; 然后显示一个截图工具: BOOL UIScreenCaptureMgr::createWindows() { m_hBkgUI = BkgroundUI::Instance()->createWindow(); wchar_t szImg[MAX_PATH] = {0}; GetModuleFileName(NULL, szImg, MAX_PATH); PathRemoveFileSpec(szImg); PathRemoveFileSpec(szImg); std::wstring strBkgPic = std::wstring(szImg) + L\"\\\\gui\\\\ScreenCapture\\\\sc_toolbar_normal.png\"; std::wstring strHoverPic = std::wstring(szImg) + L\"\\\\gui\\\\ScreenCapture\\\\sc_toolbar_hover.png\"; std::wstring strSelPic = std::wstring(szImg) + L\"\\\\gui\\\\ScreenCapture\\\\sc_toolbar_select.png\"; EditToolbarInfo toolBarInfo = { 0, 0, 193, 37, strBkgPic, strHoverPic, strSelPic, { { 9, 5, 35, 31 }, { 43, 5, 69, 31 }, { 85, 5, 112, 31 }, { 119, 5, 185, 31 } } }; m_hEditToolBarUI = EditToolbarUI::Instance()->createWindow(toolBarInfo, m_hBkgUI); SetWindowPos(m_hBkgUI, HWND_TOPMOST, 0, 0, 0, 0, SWP_NOSIZE | SWP_NOMOVE); forceForgroundWindow(m_hBkgUI); ShowWindow(m_hBkgUI, SW_SHOW); return TRUE; } 然后安装一个消息钩子(hook): BOOL ScreenCapture::installMsgHook(BOOL bInstall) { BOOL result = FALSE; if (bInstall) { if (!m_hMouseHook) { m_hMouseHook = SetWindowsHookEx(WH_MOUSE, MouseProc, NULL, GetCurrentThreadId()); result = (NULL != m_hMouseHook); } } else { UnhookWindowsHookEx(m_hMouseHook); m_hMouseHook = NULL; result = TRUE; } return result; } LRESULT ScreenCapture::MouseProc(_In_ int nCode, _In_ WPARAM wParam, _In_ LPARAM lParam) { PMOUSEHOOKSTRUCT pHookInfo = (PMOUSEHOOKSTRUCT)lParam; int xPos = pHookInfo->pt.x; int yPos = pHookInfo->pt.y; LRESULT lResHandled = CallNextHookEx(ScreenCapture::getInstance()->getMouseHook(), nCode, wParam, lParam); if (WM_LBUTTONDBLCLK == wParam ) { ScreenCommon::postNotifyMessage(WM_SNAPSHOT_FINISH_CAPTURE, 0, 0); } else if (WM_RBUTTONDBLCLK == wParam) { ScreenCommon::postNotifyMessage(WM_SNAPSHOT_CANCEL_CPATURE, 0, 0); } else if (WM_LBUTTONDOWN == wParam) { if (CM_AUTO_SELECT == CaptureModeMgr::Instance()->getMode()) { CaptureModeMgr::Instance()->changeMode(CM_MANAL_SELECT); } } CaptureModeMgr::Instance()->handleMouseMsg(wParam, xPos, yPos); return lResHandled; } 在钩子函数中,如果出现鼠标双击事件,则表示取消截图;如果出现双击事件,则表示完成截图。如果鼠标按下则表示开始绘制截图区域,然后处理鼠标移动事件: void CaptureModeMgr::handleMouseMsg(__in UINT uMsg, __in int xPos, __in int yPos) { IModeMsgHandler *msgHandler = getModeHandler(); if (!msgHandler) return; if (WM_MOUSEMOVE == uMsg) { msgHandler->onMouseMove(xPos, yPos); } else if (WM_LBUTTONDOWN == uMsg) { msgHandler->onLButtonDown(xPos, yPos); } else if (WM_LBUTTONUP == uMsg) { msgHandler->onLButtonUp(xPos, yPos); } else if (WM_LBUTTONDBLCLK == uMsg) { msgHandler->onLButtonDBClick(xPos, yPos); } } 选取区域结束时,将选择的区域保存为位图并存至某个路径下: void ScreenCapture::finishCapture() { RECT rcSelect = {0}; UIScreenCaptureMgr::Instance()->sendBkgMessage(WM_SNAPSHOT_TEST_SELECT_RECT, (WPARAM)&rcSelect, 0); rcSelect.left += 2; rcSelect.top += 2; rcSelect.right -= 2; rcSelect.bottom -= 2; if (!ScreenCommon::isRectEmpty(rcSelect)) { ScreenSnapshot::Instance()->saveRect(rcSelect, m_strSavePath); } cancelCapture(); if (m_callBack) m_callBack->onScreenCaptureFinish(m_strSavePath); } RECT rcSelect = {0}; UIScreenCaptureMgr::Instance()->sendBkgMessage(WM_SNAPSHOT_TEST_SELECT_RECT, (WPARAM)&rcSelect, 0); rcSelect.left += 2; rcSelect.top += 2; rcSelect.right -= 2; rcSelect.bottom -= 2; if (!ScreenCommon::isRectEmpty(rcSelect)) { ScreenSnapshot::Instance()->saveRect(rcSelect, m_strSavePath); } cancelCapture(); if (m_callBack) m_callBack->onScreenCaptureFinish(m_strSavePath); } BOOL ScreenSnapshot::saveRect(__in RECT &rc, __in std::wstring &savePath) { snapshotScreen(); CxImage img; int cx = rc.right - rc.left; int cy = rc.bottom - rc.top; HDC hSaveDC = CreateCompatibleDC(m_hMemDC); HBITMAP hBitmap = CreateCompatibleBitmap(m_hMemDC, cx, cy); HBITMAP hSaveBitmap = (HBITMAP)SelectObject(hSaveDC, (HGDIOBJ)hBitmap); BitBlt(hSaveDC, 0, 0, cx, cy, m_hMemDC, rc.left, rc.top, SRCCOPY); hBitmap = (HBITMAP)SelectObject(hSaveDC, (HBITMAP)hSaveBitmap); BOOL result = FALSE; do { if (!img.CreateFromHBITMAP(hBitmap)) { break; } if (!img.Save(savePath.c_str(), CXIMAGE_FORMAT_BMP)) { break; } result = TRUE; } while (FALSE); DeleteObject((HGDIOBJ)hBitmap); DeleteDC(hSaveDC); return result; } 注意整个过程使用了一个神奇的windows API,你没看错,它叫mouse_event,很少有windows API长成这个样子。利用这个api可以用程序模拟鼠标很多事件,后面有时间我会专门介绍一下这个有用的API函数。当然,关于截图的描述,你可能有点迷糊。没关系,后面我会专门写一篇文章细致地探究下teamtalk的屏幕截图效果实现,因为这里面有价值的东西很多。 线程的创建 IMCoreErrorCode OperationManager::startup() { m_operationThread = std::thread([&] { std::unique_lock lck(m_cvMutex); Operation* pOperation = nullptr; while (m_bContinue) { if (!m_bContinue) break; if (m_vecRealtimeOperations.empty()) m_CV.wait(lck); if (!m_bContinue) break; { std::lock_guard lock(m_mutexOperation); if (m_vecRealtimeOperations.empty()) continue; pOperation = m_vecRealtimeOperations.front(); m_vecRealtimeOperations.pop_front(); } if (!m_bContinue) break; if (pOperation) { pOperation->process(); pOperation->release(); } } }); return IMCORE_OK; } 这是利用lamda表达式创建一个线程典型的语法,其中m_operationThread是一个成员变量,类型是std::thread,std::thread([&]中括号中的&符号表示该lamda表达式以引用的方式捕获了所有外部的自动变量,这是在一个成员函数里面,也就是说在线程函数里面可以以引用的方式使用该类的所有成员变量。这个语法值得大家学习。 teamtalk的httpclient工程可以直接拿来使用,作者主页:http://xiangwangfeng.com,github链接:https://github.com/xiangwangfeng/httpclient。 另外teamtalk pc端大量使用C++11的语法和一些替代原来平常的写法,这个就不专门列出来了,后面我将会专门写一篇文章来介绍C++11中那些好用的工程级技巧。 好了,这篇文章就到此为止了。限于作者水平有限,文中难免有错漏和不足,欢迎批评指正。 如需下载本站全部技术文章,可以在【高性能服务器开发】公众号回复关键字“文章下载”即可。最近更新时间: 2020-10-04 15:27:20 "},"articles/libevent源码深度剖析/":{"url":"articles/libevent源码深度剖析/","title":"libevent源码深度剖析","keywords":"","body":"libevent源码深度剖析 libevent源码深度剖析01 libevent源码深度剖析02 libevent源码深度剖析03 libevent源码深度剖析04 libevent源码深度剖析05 libevent源码深度剖析06 libevent源码深度剖析07 libevent源码深度剖析08 libevent源码深度剖析09 libevent源码深度剖析10 libevent源码深度剖析11 libevent源码深度剖析12 libevent源码深度剖析13 如需下载本站全部技术文章,可以在【高性能服务器开发】公众号回复关键字“文章下载”即可。最近更新时间: 2020-10-08 12:16:18 "},"articles/libevent源码深度剖析/libevent源码深度剖析01.html":{"url":"articles/libevent源码深度剖析/libevent源码深度剖析01.html","title":"libevent源码深度剖析01","keywords":"","body":"libevent源码深度剖析一 1. 前言 libevent是一个轻量级的开源高性能网络库,使用者众多,研究者更甚,相关文章也不少。写这一系列文章的用意在于,一则分享心得;二则对libevent代码和设计思想做系统的、更深层次的分析,写出来,也可供后来者参考。 附带一句:libevent是用c语言编写的(大牛们都偏爱c语言哪),而且几乎是无处不函数指针,学习其源代码也需要相当的c语言基础。 2. Libevent简介 上来当然要先夸奖啦,libevent 有几个显著的亮点: 事件驱动(event-driven),高性能; 轻量级,专注于网络,不如ACE那么臃肿庞大; 源代码相当精炼、易读; 跨平台,支持Windows、Linux、BSD和Mac Os; 支持多种I/O多路复用技术, epoll、poll、dev/poll、select和kqueue等; 支持I/O,定时器和信号等事件; 注册事件优先级; libevent已经被广泛的应用,作为底层的网络库;比如memcached、Vomit、Nylon、Netchat等等。 libevent当前的最新稳定版是1.4.13;这也是本文参照的版本。 3. 学习的好处 学习libevent有助于提升程序设计功力,除了网络程序设计方面外,libevent的代码里有很多有用的设计技巧和基础数据结构,比如信息隐藏、函数指针、c语言的多态支持、链表和堆等等,都有助于提升自身的程序功力。 程序设计不止要了解框架,很多细节之处恰恰也是事关整个系统成败的关键。只对libevent本身的框架大概了解,那或许仅仅是一知半解,不深入代码分析,就难以了解其设计的精巧之处,也就难以为自己所用。 事实上libevent本身就是一个典型的Reactor模型,理解Reactor模式是理解libevent的基石;因此下一节将介绍典型的事件驱动设计模式——Reactor模式。 参考资料: libevent官方地址: http://monkey.org/~provos/libevent/ 如需下载本站全部技术文章,可以在【高性能服务器开发】公众号回复关键字“文章下载”即可。最近更新时间: 2020-10-03 11:28:23 "},"articles/libevent源码深度剖析/libevent源码深度剖析02.html":{"url":"articles/libevent源码深度剖析/libevent源码深度剖析02.html","title":"libevent源码深度剖析02","keywords":"","body":"libevent源码深度剖析02 Reactor模式 前面讲到,整个libevent本身就是一个Reactor,因此本节将专门对Reactor模式进行必要的介绍,并列出libevnet中的几个重要组件和Reactor的对应关系,在后面的章节中可能还会提到本节介绍的基本概念。 1. Reactor的事件处理机制 首先来回想一下普通函数调用的机制:程序调用某函数?函数执行,程序等待?函数将结果和控制权返回给程序?程序继续处理。 Reactor释义“反应堆”,是一种事件驱动机制。和普通函数调用的不同之处在于:应用程序不是主动的调用某个API完成处理,而是恰恰相反,Reactor逆置了事件处理流程,应用程序需要提供相应的接口并注册到Reactor上,如果相应的时间发生,Reactor将主动调用应用程序注册的接口,这些接口又称为“回调函数”。使用libevent也是想libevent框架注册相应的事件和回调函数;当这些事件发生时,libevent会调用这些回调函数处理相应的事件(I/O读写、定时和信号)。 用“好莱坞原则”来形容Reactor再合适不过了:不要打电话给我们,我们会打电话通知你。 举个例子:你去应聘某xx公司,面试结束后。 “普通函数调用机制”公司HR比较懒,不会记你的联系方式,那怎么办呢,你只能面试完后自己打电话去问结果;有没有被录取啊,还是被据了; “Reactor”公司HR就记下了你的联系方式,结果出来后会主动打电话通知你:有没有被录取啊,还是被据了;你不用自己打电话去问结果,事实上也不能,你没有HR的留联系方式。 2. Reactor模式的优点 Reactor模式是编写高性能网络服务器的必备技术之一,它具有如下的优点 1)响应快,不必为单个同步时间所阻塞,虽然Reactor本身依然是同步的; 2)编程相对简单,可以最大程度的避免复杂的多线程及同步问题,并且避免了多线程/进程的切换开销; 3)可扩展性,可以方便的通过增加Reactor实例个数来充分利用CPU资源; 4)可复用性,reactor框架本身与具体事件处理逻辑无关,具有很高的复用性; 3. Reactor模式框架 使用Reactor模型,必备的几个组件:事件源、Reactor框架、多路复用机制和事件处理程序,先来看看Reactor模型的整体框架,接下来再对每个组件做逐一说明。 1) 事件源 Linux上是文件描述符,Windows上就是Socket或者Handle了,这里统一称为“句柄集”;程序在指定的句柄上注册关心的事件,比如I/O事件。 2) event demultiplexer——事件多路分发机制 由操作系统提供的I/O多路复用机制,比如select和epoll。 程序首先将其关心的句柄(事件源)及其事件注册到event demultiplexer上; 当有事件到达时,event demultiplexer会发出通知“在已经注册的句柄集中,一个或多个句柄的事件已经就绪”; 程序收到通知后,就可以在非阻塞的情况下对事件进行处理了。 对应到libevent中,依然是select、poll、epoll等,但是libevent使用结构体eventop进行了封装,以统一的接口来支持这些I/O多路复用机制,达到了对外隐藏底层系统机制的目的。 3) Reactor——反应器 Reactor,是事件管理的接口,内部使用event demultiplexer注册、注销事件;并运行事件循环,当有事件进入“就绪”状态时,调用注册事件的回调函数处理事件。 对应到libevent中,就是event_base结构体。 一个典型的Reactor声明方式: class Reactor{ public: int register_handler(Event_Handler *pHandler, int event); int remove_handler(Event_Handler *pHandler, int event); void handle_events(timeval *ptv); // ... }; 4) Event Handler——事件处理程序 事件处理程序提供了一组接口,每个接口对应了一种类型的事件,供Reactor在相应的事件发生时调用,执行相应的事件处理。通常它会绑定一个有效的句柄。 对应到libevent中,就是event结构体。 下面是两种典型的Event Handler类声明方式,二者互有优缺点。 class Event_Handler{ public: virtual void handle_read() = 0; virtual void handle_write() = 0; virtual void handle_timeout() = 0; virtual void handle_close() = 0; virtual HANDLE get_handle() = 0; // ... }; class Event_Handler{ public: // events maybe read/write/timeout/close .etc virtual void handle_events(int events) = 0; virtual HANDLE get_handle() = 0; // ... }; 4. Reactor事件处理流程 前面说过Reactor将事件流“逆置”了,那么使用Reactor模式后,事件控制流是什么样子呢? 可以参见下面的序列图。 5. 小结 上面讲到了Reactor的基本概念、框架和处理流程,对Reactor有个基本清晰的了解后,再来对比看libevent就会更容易理解了,接下来就正式进入到libevent的代码世界了,加油! 参考资料: Pattern-Oriented Software Architecture, Patterns for Concurrent and Networked Objects, Volume 2 如需下载本站全部技术文章,可以在【高性能服务器开发】公众号回复关键字“文章下载”即可。最近更新时间: 2020-10-03 11:28:13 "},"articles/libevent源码深度剖析/libevent源码深度剖析03.html":{"url":"articles/libevent源码深度剖析/libevent源码深度剖析03.html","title":"libevent源码深度剖析03","keywords":"","body":"libevent源码深度剖析03 libevent基本使用场景和事件流程 1. 前言 学习源代码该从哪里入手?我觉得从程序的基本使用场景和代码的整体处理流程入手是个不错的方法,至少从个人的经验上讲,用此方法分析libevent是比较有效的。 2. 基本应用场景 基本应用场景也是使用libevnet的基本流程,下面来考虑一个最简单的场景,使用livevent设置定时器,应用程序只需要执行下面几个简单的步骤即可。 1)首先初始化libevent库,并保存返回的指针 struct event_base* base = event_init(); 实际上这一步相当于初始化一个Reactor实例;在初始化libevent后,就可以注册事件了。 2)初始化事件event,设置回调函数和关注的事件 evtimer_set(&ev, timer_cb, NULL); 事实上这等价于调用 event_set(&ev, -1, 0, timer_cb, NULL); event_set的函数原型是: void event_set(struct event *ev, int fd, short event, void (*cb)(int, short, void *), void *arg) ev:执行要初始化的event对象; fd:该event绑定的“句柄”,对于信号事件,它就是关注的信号; event:在该fd上关注的事件类型,它可以是EV_READ, EV_WRITE, EV_SIGNAL; cb:这是一个函数指针,当fd上的事件event发生时,调用该函数执行处理,它有三个参数,调用时由event_base负责传入,按顺序,实际上就是event_set时的fd, event和arg; arg:传递给cb函数指针的参数; 由于定时事件不需要fd,并且定时事件是根据添加时(event_add)的超时值设定的,因此这里event也不需要设置。 这一步相当于初始化一个event handler,在libevent中事件类型保存在event结构体中。 注意:libevent并不会管理event事件集合,这需要应用程序自行管理; 3)设置event从属的event_base event_base_set(base, &ev); 这一步相当于指明event要注册到哪个event_base实例上; 4)是正式的添加事件的时候了 event_add(&ev, timeout); 基本信息都已设置完成,只要简单的调用event_add()函数即可完成,其中timeout是定时值; 这一步相当于调用Reactor::register_handler()函数注册事件。 5)程序进入无限循环,等待就绪事件并执行事件处理 event_base_dispatch(base); 3. 实例代码 上面例子的程序代码如下所示 struct event ev; struct timeval tv; void time_cb(int fd, short event, void *argc){ printf(\"timer wakeup/n\"); event_add(&ev, &tv); // reschedule timer } int main(){ struct event_base *base = event_init(); tv.tv_sec = 10; // 10s period tv.tv_usec = 0; evtimer_set(&ev, time_cb, NULL); event_add(&ev, &tv); event_base_dispatch(base); } 4. 事件处理流程 当应用程序向libevent注册一个事件后,libevent内部是怎么样进行处理的呢?下面的图就给出了这一基本流程。 1)首先应用程序准备并初始化event,设置好事件类型和回调函数;这对应于前面第步骤2和3; 2)向libevent添加该事件event。对于定时事件,libevent使用一个小根堆管理,key为超时时间;对于Signal和I/O事件,libevent将其放入到等待链表(wait list)中,这是一个双向链表结构; 3)程序调用event_base_dispatch()系列函数进入无限循环,等待事件,以select()函数为例;每次循环前libevent会检查定时事件的最小超时时间tv,根据tv设置select()的最大等待时间,以便于后面及时处理超时事件; 当select()返回后,首先检查超时事件,然后检查I/O事件; Libevent将所有的就绪事件,放入到激活链表中; 然后对激活链表中的事件,调用事件的回调函数执行事件处理; 5. 小结 本节介绍了libevent的简单实用场景,并旋风般的介绍了libevent的事件处理流程,读者应该对libevent有了基本的印象,下面将会详细介绍libevent的事件管理框架(Reactor模式中的Reactor框架)做详细的介绍,在此之前会对源代码文件做简单的分类。 如需下载本站全部技术文章,可以在【高性能服务器开发】公众号回复关键字“文章下载”即可。最近更新时间: 2020-10-03 11:32:55 "},"articles/libevent源码深度剖析/libevent源码深度剖析04.html":{"url":"articles/libevent源码深度剖析/libevent源码深度剖析04.html","title":"libevent源码深度剖析04","keywords":"","body":"libevent源码深度剖析04 1. 前言 详细分析源代码之前,如果能对其代码文件的基本结构有个大概的认识和分类,对于代码的分析将是大有裨益的。本节内容不多,我想并不是说它不重要! 2. 源代码组织结构 Libevent的源代码虽然都在一层文件夹下面,但是其代码分类还是相当清晰的,主要可分为头文件、内部使用的头文件、辅助功能函数、日志、libevent框架、对系统I/O多路复用机制的封装、信号管理、定时事件管理、缓冲区管理、基本数据结构和基于libevent的两个实用库等几个部分,有些部分可能就是一个源文件。 源代码中的test部分就不在我们关注的范畴了。 1)头文件 主要就是event.h:事件宏定义、接口函数声明,主要结构体event的声明; 2)内部头文件 xxx-internal.h:内部数据结构和函数,对外不可见,以达到信息隐藏的目的; 3)libevent框架 event.c:event整体框架的代码实现; 4)对系统I/O多路复用机制的封装 epoll.c:对epoll的封装; select.c:对select的封装; devpoll.c:对dev/poll的封装; kqueue.c:对kqueue的封装; 5)定时事件管理 min-heap.h:其实就是一个以时间作为key的小根堆结构; 6)信号管理 signal.c:对信号事件的处理; 7)辅助功能函数 evutil.h 和evutil.c:一些辅助功能函数,包括创建socket pair和一些时间操作函数:加、减和比较等。 8)日志 log.h和log.c:log日志函数 9)缓冲区管理 evbuffer.c和buffer.c:libevent对缓冲区的封装; 10)基本数据结构 compat/sys下的两个源文件:queue.h是libevent基本数据结构的实现,包括链表,双向链表,队列等;_libevent_time.h:一些用于时间操作的结构体定义、函数和宏定义; 11)实用网络库 http和evdns:是基于libevent实现的http服务器和异步dns查询库; 3. 小结 本节介绍了libevent的组织和分类,下面将会详细介绍libevent的核心部分event结构。 如需下载本站全部技术文章,可以在【高性能服务器开发】公众号回复关键字“文章下载”即可。最近更新时间: 2020-10-03 11:27:25 "},"articles/libevent源码深度剖析/libevent源码深度剖析05.html":{"url":"articles/libevent源码深度剖析/libevent源码深度剖析05.html","title":"libevent源码深度剖析05","keywords":"","body":"libevent源码深度剖析05 libevent的核心:事件event 对事件处理流程有了高层的认识后,本节将详细介绍libevent的核心结构event,以及libevent对event的管理。 1. libevent的核心-event libevent是基于事件驱动(event-driven)的,从名字也可以看到event是整个库的核心。event就是Reactor框架中的事件处理程序组件;它提供了函数接口,供Reactor在事件发生时调用,以执行相应的事件处理,通常它会绑定一个有效的句柄。 首先给出event结构体的声明,它位于event.h文件中: struct event { TAILQ_ENTRY (event) ev_next; TAILQ_ENTRY (event) ev_active_next; TAILQ_ENTRY (event) ev_signal_next; unsigned int min_heap_idx; /* for managing timeouts */ struct event_base *ev_base; int ev_fd; short ev_events; short ev_ncalls; short *ev_pncalls; /* Allows deletes in callback */ struct timeval ev_timeout; int ev_pri; /* smaller numbers are higher priority */ void (*ev_callback)(int, short, void *arg); void *ev_arg; int ev_res; /* result passed to event callback */ int ev_flags; }; ev_events:event关注的事件类型,它可以是以下3种类型: I/O事件: EV_WRITE和EV_READ 定时事件:EV_TIMEOUT 信号: EV_SIGNAL 辅助选项:EV_PERSIST,表明是一个永久事件 Libevent中的定义为: #define EV_TIMEOUT 0x01 #define EV_READ 0x02 #define EV_WRITE 0x04 #define EV_SIGNAL 0x08 #define EV_PERSIST 0x10 /* Persistant event */ 可以看出事件类型可以使用“|”运算符进行组合,需要说明的是,信号和I/O事件不能同时设置; 还可以看出libevent使用event结构体将这3种事件的处理统一起来; ev_next,ev_active_next和ev_signal_next都是双向链表节点指针;它们是libevent对不同事件类型和在不同的时期,对事件的管理时使用到的字段。 libevent使用双向链表保存所有注册的I/O和Signal事件 ev_next就是该I/O事件在链表中的位置;称此链表为“已注册事件链表”; 同样ev_signal_next就是signal事件在signal事件链表中的位置; ev_active_next:libevent将所有的激活事件放入到链表active list中,然后遍历active list执行调度,ev_active_next就指明了event在active list中的位置; min_heap_idx和ev_timeout,如果是timeout事件,它们是event在小根堆中的索引和超时值,libevent使用小根堆来管理定时事件,这将在后面定时事件处理时专门讲解; ev_base该事件所属的反应堆实例,这是一个event_base结构体,下一节将会详细讲解; ev_fd,对于I/O事件,是绑定的文件描述符;对于signal事件,是绑定的信号; ev_callback,event的回调函数,被ev_base调用,执行事件处理程序,这是一个函数指针,原型为: 1void (*ev_callback)(int fd, short events, void *arg) 其中参数fd对应于ev_fd;events对应于ev_events;arg对应于ev_arg; ev_arg:void*,表明可以是任意类型的数据,在设置event时指定; eb_flags:libevent用于标记event信息的字段,表明其当前的状态,可能的值有: 1#define EVLIST_TIMEOUT 0x01 // event在time堆中 2 3#define EVLIST_INSERTED 0x02 // event在已注册事件链表中 4 5#define EVLIST_SIGNAL 0x04 // 未见使用 6 7#define EVLIST_ACTIVE 0x08 // event在激活链表中 8 9#define EVLIST_INTERNAL 0x10 // 内部使用标记 10 11#define EVLIST_INIT 0x80 // event已被初始化 ev_ncalls:事件就绪执行时,调用ev_callback的次数,通常为1; ev_pncalls:指针,通常指向ev_ncalls或者为NULL; ev_res:记录了当前激活事件的类型; 2. libevent对event的管理 从event结构体中的3个链表节点指针和一个堆索引出发,大体上也能窥出libevent对event的管理方法了,可以参见下面的示意图: 每次当有事件event转变为就绪状态时,libevent就会把它移入到active event list[priority]中,其中priority是event的优先级; 接着libevent会根据自己的调度策略选择就绪事件,调用其cb_callback()函数执行事件处理;并根据就绪的句柄和事件类型填充cb_callback函数的参数。 3. 事件设置的接口函数 要向libevent添加一个事件,需要首先设置event对象,这通过调用libevent提供的函数有:event_set(), event_base_set(), event_priority_set()来完成;下面分别进行讲解。 void event_set(struct event *ev, int fd, short events, void (*callback)(int, short, void *), void *arg) 设置事件ev绑定的文件描述符或者信号,对于定时事件,设为-1即可; 设置事件类型,比如EV_READ|EV_PERSIST, EV_WRITE, EV_SIGNAL等; 设置事件的回调函数以及参数arg; 初始化其它字段,比如缺省的event_base和优先级; int event_base_set(struct event_base base, struct event ev) 设置event ev将要注册到的event_base; libevent有一个全局event_base指针current_base,默认情况下事件ev将被注册到current_base上,使用该函数可以指定不同的event_base; 如果一个进程中存在多个libevent实例,则必须要调用该函数为event设置不同的event_base; int event_priority_set(struct event *ev, int pri) 设置event ev的优先级,没什么可说的,注意的一点就是:当ev正处于就绪状态时,不能设置,返回-1。 4. 小结 本节讲述了libevent的核心event结构,以及libevent支持的事件类型和libevent对event的管理模型;接下来将会描述libevent的事件处理框架,以及其中使用的重要的结构体event_base。 如需下载本站全部技术文章,可以在【高性能服务器开发】公众号回复关键字“文章下载”即可。最近更新时间: 2020-10-03 11:31:59 "},"articles/libevent源码深度剖析/libevent源码深度剖析06.html":{"url":"articles/libevent源码深度剖析/libevent源码深度剖析06.html","title":"libevent源码深度剖析06","keywords":"","body":"libevent源码深度剖析06 初见事件处理框架 前面已经对libevent的事件处理框架和event结构体做了描述,现在是时候剖析libevent对事件的详细处理流程了,本节将分析libevent的事件处理框架event_base和libevent注册、删除事件的具体流程,可结合前一节libevent对event的管理。 1. 事件处理框架-event_base 回想Reactor模式的几个基本组件,本节讲解的部分对应于Reactor框架组件。在libevent中,这就表现为event_base结构体,结构体声明如下,它位于event-internal.h文件中: struct event_base { const struct eventop *evsel; void *evbase;  int event_count; /* counts number of total events */ int event_count_active; /* counts number of active events */ int event_gotterm; /* Set to terminate loop */ int event_break; /* Set to terminate loop immediately */ /* active event management */ struct event_list **activequeues; int nactivequeues; /* signal handling info */ struct evsignal_info sig; struct event_list eventqueue; struct timeval event_tv; struct min_heap timeheap; struct timeval tv_cache; }; 下面详细解释一下结构体中各字段的含义。 evsel和evbase这两个字段的设置可能会让人有些迷惑,这里你可以把evsel和evbase看作是类和静态函数的关系,比如添加事件时的调用行为:evsel->add(evbase, ev),实际执行操作的是evbase;这相当于class::add(instance, ev),instance就是class的一个对象实例。 evsel指向了全局变量static const struct eventop *eventops[]中的一个; 前面也说过,libevent将系统提供的I/O demultiplex机制统一封装成了eventop结构;因此eventops[]包含了select、poll、kequeue和epoll等等其中的若干个全局实例对象。 evbase实际上是一个eventop实例对象; 先来看看eventop结构体,它的成员是一系列的函数指针, 在event-internal.h文件中: struct eventop { const char *name; void *(*init)(struct event_base *); // 初始化 int (*add)(void *, struct event *); // 注册事件 int (*del)(void *, struct event *); // 删除事件 int (*dispatch)(struct event_base *, void *, struct timeval *); // 事件分发 void (*dealloc)(struct event_base *, void *); // 注销,释放资源 /* set if we need to reinitialize the event base */ int need_reinit; }; 也就是说,在libevent中,每种I/O demultiplex机制的实现都必须提供这五个函数接口,来完成自身的初始化、销毁释放;对事件的注册、注销和分发。 比如对于epoll,libevent实现了5个对应的接口函数,并在初始化时并将eventop的5个函数指针指向这5个函数,那么程序就可以使用epoll作为I/O demultiplex机制了,这个在后面会再次提到。 activequeues是一个二级指针,前面讲过libevent支持事件优先级,因此你可以把它看作是数组,其中的元素activequeues[priority]是一个链表,链表的每个节点指向一个优先级为priority的就绪事件event。 eventqueue,链表,保存了所有的注册事件event的指针。 sig是由来管理信号的结构体,将在后面信号处理时专门讲解; timeheap是管理定时事件的小根堆,将在后面定时事件处理时专门讲解; event_tv和tv_cache是libevent用于时间管理的变量,将在后面讲到; 其它各个变量都能因名知意,就不再啰嗦了。 2. 创建和初始化event_base 创建一个event_base对象也既是创建了一个新的libevent实例,程序需要通过调用event_init()(内部调用event_base_new函数执行具体操作)函数来创建,该函数同时还对新生成的libevent实例进行了初始化。 该函数首先为event_base实例申请空间, 然后初始化timer mini-heap,选择并初始化合适的系统I/O 的demultiplexer机制,初始化各事件链表; 函数还检测了系统的时间设置,为后面的时间管理打下基础。 3. 接口函数 前面提到Reactor框架的作用就是提供事件的注册、注销接口;根据系统提供的事件多路分发机制执行事件循环,当有事件进入“就绪”状态时,调用注册事件的回调函数来处理事件。 Libevent中对应的接口函数主要就是: int event_add(struct event *ev, const struct timeval *timeout); int event_del(struct event *ev); int event_base_loop(struct event_base *base, int loops); void event_active(struct event *event, int res, short events); void event_process_active(struct event_base *base); 本节将按介绍事件注册和删除的代码流程,libevent的事件循环框架将在下一节再具体描述。 对于定时事件,这些函数将调用timer heap管理接口执行插入和删除操作; 对于I/O和Signal事件将调用eventopadd和delete接口函数执行插入和删除操作(eventop会对Signal事件调用Signal处理接口执行操作); 这些组件将在后面的内容描述。 1)注册事件 函数原型: int event_add(struct event *ev, const struct timeval *tv) 参数:ev:指向要注册的事件; tv:超时时间; e函数将ev注册到ev->ev_base上,事件类型由ev->ev_events指明, 如果注册成功,v将被插入到已注册链表中; 如果tv不是NULL,则会同时注册定时事件,将ev添加到timer堆上; 如果其中有一步操作失败,那么函数保证没有事件会被注册,可以讲这相当于一个原子操作。这个函数也体现了libevent细节之处的巧妙设计,且仔细看程序代码,部分有省略,注释直接附在代码中。 int event_add(struct event *ev, const struct timeval *tv) { struct event_base *base = ev->ev_base; // 要注册到的event_base const struct eventop *evsel = base->evsel; void *evbase = base->evbase; // base使用的系统I/O策略 // 新的timer事件,调用timer heap接口在堆上预留一个位置 // 注:这样能保证该操作的原子性: // 向系统I/O机制注册可能会失败,而当在堆上预留成功后, // 定时事件的添加将肯定不会失败; // 而预留位置的可能结果是堆扩充,但是内部元素并不会改变 if (tv != NULL && !(ev->ev_flags & EVLIST_TIMEOUT)) { if (min_heap_reserve(&base->timeheap, 1 + min_heap_size(&base->timeheap)) == -1) return (-1); /* ENOMEM == errno */ } // 如果事件ev不在已注册或者激活链表中,则调用evbase注册事件 if ((ev->ev_events & (EV_READ|EV_WRITE|EV_SIGNAL)) && !(ev->ev_flags & (EVLIST_INSERTED|EVLIST_ACTIVE))) { res = evsel->add(evbase, ev); if (res != -1) // 注册成功,插入event到已注册链表中 event_queue_insert(base, ev, EVLIST_INSERTED); } // 准备添加定时事件 if (res != -1 && tv != NULL) { struct timeval now; // EVLIST_TIMEOUT表明event已经在定时器堆中了,删除旧的 if (ev->ev_flags & EVLIST_TIMEOUT) event_queue_remove(base, ev, EVLIST_TIMEOUT); // 如果事件已经是就绪状态则从激活链表中删除 if ((ev->ev_flags & EVLIST_ACTIVE) && (ev->ev_res & EV_TIMEOUT)) { // 将ev_callback调用次数设置为0 if (ev->ev_ncalls && ev->ev_pncalls) { *ev->ev_pncalls = 0; } event_queue_remove(base, ev, EVLIST_ACTIVE); } // 计算时间,并插入到timer小根堆中 gettime(base, &now); evutil_timeradd(&now, tv, &ev->ev_timeout); event_queue_insert(base, ev, EVLIST_TIMEOUT); } return (res); } event_queue_insert()负责将事件插入到对应的链表中,下面是程序代码; event_queue_remove()负责将事件从对应的链表中删除,这里就不再重复贴代码了; void event_queue_insert(struct event_base *base, struct event *ev, int queue) { // ev可能已经在激活列表中了,避免重复插入 if (ev->ev_flags & queue) { if (queue & EVLIST_ACTIVE) return; } // ... ev->ev_flags |= queue; // 记录queue标记 switch (queue) { case EVLIST_INSERTED: // I/O或Signal事件,加入已注册事件链表 TAILQ_INSERT_TAIL(&base->eventqueue, ev, ev_next); break; case EVLIST_ACTIVE: // 就绪事件,加入激活链表 base->event_count_active++; TAILQ_INSERT_TAIL(base->activequeues[ev->ev_pri], ev, ev_active_next); break; case EVLIST_TIMEOUT: // 定时事件,加入堆 min_heap_push(&base->timeheap, ev); break; } } 2)删除事件: 函数原型为: int event_del(struct event *ev); 该函数将删除事件ev 对于I/O事件,从I/O 的demultiplexer上将事件注销; 对于Signal事件,将从Signal事件链表中删除; 对于定时事件,将从堆上删除; 同样删除事件的操作则不一定是原子的,比如删除时间事件之后,有可能从系统I/O机制中注销会失败。 int event_del(struct event *ev) { struct event_base *base; const struct eventop *evsel; void *evbase; // ev_base为NULL,表明ev没有被注册 if (ev->ev_base == NULL) return (-1); // 取得ev注册的event_base和eventop指针 base = ev->ev_base; evsel = base->evsel; evbase = base->evbase; // 将ev_callback调用次数设置为 if (ev->ev_ncalls && ev->ev_pncalls) { *ev->ev_pncalls = 0; } // 从对应的链表中删除 if (ev->ev_flags & EVLIST_TIMEOUT) event_queue_remove(base, ev, EVLIST_TIMEOUT); if (ev->ev_flags & EVLIST_ACTIVE) event_queue_remove(base, ev, EVLIST_ACTIVE); if (ev->ev_flags & EVLIST_INSERTED) { event_queue_remove(base, ev, EVLIST_INSERTED); // EVLIST_INSERTED表明是I/O或者Signal事件, // 需要调用I/O demultiplexer注销事件 return (evsel->del(evbase, ev)); } return (0); } 4. 小结 分析了event_base这一重要结构体,初步看到了libevent对系统的I/O demultiplex机制的封装event_op结构,并结合源代码分析了事件的注册和删除处理,下面将会接着分析事件管理框架中的主事件循环部分。 如需下载本站全部技术文章,可以在【高性能服务器开发】公众号回复关键字“文章下载”即可。最近更新时间: 2020-10-03 11:40:52 "},"articles/libevent源码深度剖析/libevent源码深度剖析07.html":{"url":"articles/libevent源码深度剖析/libevent源码深度剖析07.html","title":"libevent源码深度剖析07","keywords":"","body":"libevent源码深度剖析07 事件主循环 现在我们已经初步了解了libevent的Reactor组件——event_base和事件管理框架,接下来就是libevent事件处理的中心部分——事件主循环,根据系统提供的事件多路分发机制执行事件循环,对已注册的就绪事件,调用注册事件的回调函数来处理事件。 1. 阶段性的胜利 libevent将I/O事件、定时器和信号事件处理很好的结合到了一起,本节也会介绍libevent是如何做到这一点的。 在看完本节的内容后,读者应该会对Libevent的基本框架:事件管理和主循环有比较清晰的认识了,并能够把libevent的事件控制流程清晰的串通起来,剩下的就是一些细节的内容了。 2. 事件处理主循环 libevent的事件主循环主要是通过event_base_loop ()函数完成的,其主要操作如下面的流程图所示,event_base_loop所作的就是持续执行下面的循环。 清楚了event_base_loop所作的主要操作,就可以对比源代码看个究竟了,代码结构还是相当清晰的。 int event_base_loop(struct event_base *base, int flags){ const struct eventop *evsel = base->evsel; void *evbase = base->evbase; struct timeval tv; struct timeval *tv_p; int res, done; // 清空时间缓存 base->tv_cache.tv_sec = 0; // evsignal_base是全局变量,在处理signal时,用于指名signal所属的event_base实例 if (base->sig.ev_signal_added) evsignal_base = base; done = 0; while (!done) { // 事件主循环 // 查看是否需要跳出循环,程序可以调用event_loopexit_cb()设置event_gotterm标记 // 调用event_base_loopbreak()设置event_break标记 if (base->event_gotterm) { base->event_gotterm = 0; break; } if (base->event_break) { base->event_break = 0; break; } // 校正系统时间,如果系统使用的是非MONOTONIC时间,用户可能会向后调整了系统时间 // 在timeout_correct函数里,比较last wait time和当前时间,如果当前时间event_count_active && !(flags & EVLOOP_NONBLOCK)) { timeout_next(base, &tv_p); } else { // 依然有未处理的就绪时间,就让I/O demultiplexer立即返回,不必等待 // 下面会提到,在libevent中,低优先级的就绪事件可能不能立即被处理 evutil_timerclear(&tv); } // 如果当前没有注册事件,就退出 if (!event_haveevents(base)) { event_debug((\"%s: no events registered.\", __func__)); return (1); } // 更新last wait time,并清空time cache gettime(base, &base->event_tv); base->tv_cache.tv_sec = 0; // 调用系统I/O demultiplexer等待就绪I/O events,可能是epoll_wait,或者select等; // 在evsel->dispatch()中,会把就绪signal event、I/O event插入到激活链表中 res = evsel->dispatch(base, evbase, tv_p); if (res == -1) return (-1); // 将time cache赋值为当前系统时间 gettime(base, &base->tv_cache); // 检查heap中的timer events,将就绪的timer event从heap上删除,并插入到激活链表中 timeout_process(base); // 调用event_process_active()处理激活链表中的就绪event,调用其回调函数执行事件处理 // 该函数会寻找最高优先级(priority值越小优先级越高)的激活事件链表, // 然后处理链表中的所有就绪事件; // 因此低优先级的就绪事件可能得不到及时处理; if (base->event_count_active) { event_process_active(base); if (!base->event_count_active && (flags & EVLOOP_ONCE)) done = 1; } else if (flags & EVLOOP_NONBLOCK) done = 1; } // 循环结束,清空时间缓存 base->tv_cache.tv_sec = 0; event_debug((\"%s: asked to terminate loop.\", __func__)); return (0); } 3. I/O和Timer事件的统一 libevent将Timer和Signal事件都统一到了系统的I/O 的demultiplex机制中了,相信读者从上面的流程和代码中也能窥出一斑了,下面就再啰嗦一次了。 首先将Timer事件融合到系统I/O多路复用机制中,还是相当清晰的,因为系统的I/O机制像select()和epoll_wait()都允许程序制定一个最大等待时间(也称为最大超时时间)timeout,即使没有I/O事件发生,它们也保证能在timeout时间内返回。 那么根据所有Timer事件的最小超时时间来设置系统I/O的timeout时间;当系统I/O返回时,再激活所有就绪的Timer事件就可以了,这样就能将Timer事件完美的融合到系统的I/O机制中了。 这是在Reactor和Proactor模式(主动器模式,比如Windows上的IOCP)中处理Timer事件的经典方法了,ACE采用的也是这种方法,大家可以参考POSA vol2书中的Reactor模式一节。 堆是一种经典的数据结构,向堆中插入、删除元素时间复杂度都是O(lgN),N为堆中元素的个数,而获取最小key值(小根堆)的复杂度为O(1);因此变成了管理Timer事件的绝佳人选(当然是非唯一的),libevent就是采用的堆结构。 4. I/O和Signal事件的统一 Signal是异步事件的经典事例,将Signal事件统一到系统的I/O多路复用中就不像Timer事件那么自然了,Signal事件的出现对于进程来讲是完全随机的,进程不能只是测试一个变量来判别是否发生了一个信号,而是必须告诉内核“在此信号发生时,请执行如下的操作”。 如果当Signal发生时,并不立即调用event的callback函数处理信号,而是设法通知系统的I/O机制,让其返回,然后再统一和I/O事件以及Timer一起处理,不就可以了嘛。是的,这也是libevent中使用的方法。 问题的核心在于,当Signal发生时,如何通知系统的I/O多路复用机制,这里先买个小关子,放到信号处理一节再详细说明,我想读者肯定也能想出通知的方法,比如使用pipe。 5 小节 介绍了libevent的事件主循环,描述了libevent是如何处理就绪的I/O事件、定时器和信号事件,以及如何将它们无缝的融合到一起。 如需下载本站全部技术文章,可以在【高性能服务器开发】公众号回复关键字“文章下载”即可。最近更新时间: 2020-10-03 11:44:06 "},"articles/libevent源码深度剖析/libevent源码深度剖析08.html":{"url":"articles/libevent源码深度剖析/libevent源码深度剖析08.html","title":"libevent源码深度剖析08","keywords":"","body":"libevent源码深度剖析08 集成信号处理 现在我们已经了解了libevent的基本框架:事件管理框架和事件主循环。上节提到了libevent中I/O事件和Signal以及Timer事件的集成,这一节将分析如何将Signal集成到事件主循环的框架中。 1. 集成策略——使用socket pair 前一节已经做了足够多的介绍了,基本方法就是采用“消息机制”。在libevent中这是通过socket pair完成的,下面就来详细分析一下。 Socket pair就是一个socket对,包含两个socket,一个读socket,一个写socket。工作方式如下图所示: 创建一个socket pair并不是复杂的操作,可以参见下面的流程图,清晰起见,其中忽略了一些错误处理和检查。 Libevent提供了辅助函数evutil_socketpair()来创建一个socket pair,可以结合上面的创建流程来分析该函数。 2. 集成到事件主循环——通知event_base Socket pair创建好了,可是libevent的事件主循环还是不知道Signal是否发生了啊,看来我们还差了最后一步,那就是:为socket pair的读socket在libevent的event_base实例上注册一个persist的读事件。 这样当向写socket写入数据时,读socket就会得到通知,触发读事件,从而event_base就能相应的得到通知了。 前面提到过,Libevent会在事件主循环中检查标记,来确定是否有触发的signal,如果标记被设置就处理这些signal,这段代码在各个具体的I/O机制中,以Epoll为例,在epoll_dispatch()函数中,代码片段如下: res = epoll_wait(epollop->epfd, events, epollop->nevents, timeout); if (res == -1) { if (errno != EINTR) { event_warn(\"epoll_wait\"); return (-1); } evsignal_process(base);// 处理signal事件 return (0); } else if (base->sig.evsignal_caught) { evsignal_process(base);// 处理signal事件 } 完整的处理框架如下所示: 注1:libevent中,初始化阶段并不注册读socket的读事件,而是在注册信号阶段才会测试并注册; 注2:libevent中,检查I/O事件是在各系统I/O机制的dispatch()函数中完成的,该dispatch()函数在event_base_loop()函数中被调用; 3. evsignal_info结构体 libevent中Signal事件的管理是通过结构体evsignal_info完成的,结构体位于evsignal.h文件中,定义如下: struct evsignal_info { struct event ev_signal; int ev_signal_pair[2]; int ev_signal_added; volatile sig_atomic_t evsignal_caught; struct event_list evsigevents[NSIG]; sig_atomic_t evsigcaught[NSIG]; #ifdef HAVE_SIGACTION struct sigaction **sh_old; #else ev_sighandler_t **sh_old; #endif int sh_old_max; }; 下面详细介绍一下个字段的含义和作用: 1)ev_signal, 为socket pair的读socket向event_base注册读事件时使用的event结构体; 2)ev_signal_pair,socket pair对,作用见第一节的介绍; 3)ev_signal_added,记录ev_signal事件是否已经注册了; 4)evsignal_caught,是否有信号发生的标记;是volatile类型,因为它会在另外的线程中被修改; 5)evsigvents[NSIG],数组,evsigevents[signo]表示注册到信号signo的事件链表; 6)evsigcaught[NSIG],具体记录每个信号触发的次数,evsigcaught[signo]是记录信号signo被触发的次数; 7)sh_old记录了原来的signal处理函数指针,当信号signo注册的event被清空时,需要重新设置其处理函数; evsignal_info的初始化包括,创建socket pair,设置ev_signal事件(但并没有注册,而是等到有信号注册时才检查并注册),并将所有标记置零,初始化信号的注册事件链表指针等。 4. 注册、注销signal事件 注册signal事件是通过evsignal_add(struct event *ev)函数完成的,libevent对所有的信号注册同一个处理函数evsignal_handler(),该函数将在下一段介绍,注册过程如下: 1 取得ev要注册到的信号signo; 2 如果信号signo未被注册,那么就为signo注册信号处理函数evsignal_handler(); 3 如果事件ev_signal还没哟注册,就注册ev_signal事件; 4 将事件ev添加到signo的event链表中; 从signo上注销一个已注册的signal事件就更简单了,直接从其已注册事件的链表中移除即可。如果事件链表已空,那么就恢复旧的处理函数; 下面的讲解都以signal()函数为例,sigaction()函数的处理和signal()相似。 处理函数evsignal_handler()函数做的事情很简单,就是记录信号的发生次数,并通知event_base有信号触发,需要处理: static void evsignal_handler(int sig){ int save_errno = errno; // 不覆盖原来的错误代码 if (evsignal_base == NULL) { event_warn(\"%s: received signal %d, but have no base configured\", __func__, sig); return; } // 记录信号sig的触发次数,并设置event触发标记 evsignal_base->sig.evsigcaught[sig]++; evsignal_base->sig.evsignal_caught = 1; #ifndef HAVE_SIGACTION signal(sig, evsignal_handler); // 重新注册信号 #endif // 向写socket写一个字节数据,触发event_base的I/O事件,从而通知其有信号触发,需要处理 send(evsignal_base->sig.ev_signal_pair[0], \"a\", 1, 0); errno = save_errno; // 错误代码 } 5. 小节 本节介绍了libevent对signal事件的具体处理框架,包括事件注册、删除和socket pair通知机制,以及是如何将Signal事件集成到事件主循环之中的。 如需下载本站全部技术文章,可以在【高性能服务器开发】公众号回复关键字“文章下载”即可。最近更新时间: 2020-10-03 12:15:03 "},"articles/libevent源码深度剖析/libevent源码深度剖析09.html":{"url":"articles/libevent源码深度剖析/libevent源码深度剖析09.html","title":"libevent源码深度剖析09","keywords":"","body":"libevent源码深度剖析09 集成定时器事件 现在再来详细分析libevent中I/O事件和Timer事件的集成,与Signal相比,Timer事件的集成会直观和简单很多。Libevent对堆的调整操作做了一些优化,本节还会描述这些优化方法。 1. 集成到事件主循环 因为系统的I/O机制像select()和epoll_wait()都允许程序制定一个最大等待时间(也称为最大超时时间)timeout,即使没有I/O事件发生,它们也保证能在timeout时间内返回。 那么根据所有Timer事件的最小超时时间来设置系统I/O的timeout时间;当系统I/O返回时,再激活所有就绪的Timer事件就可以了,这样就能将Timer事件完美的融合到系统的I/O机制中了。 具体的代码在源文件event.c的event_base_loop()中,现在就对比代码来看看这一处理方法: if (!base->event_count_active && !(flags & EVLOOP_NONBLOCK)) { // 根据Timer事件计算evsel->dispatch的最大等待时间 timeout_next(base, &tv_p); } else { // 如果还有活动事件,就不要等待,让evsel->dispatch立即返回 evutil_timerclear(&tv); } // ... // 调用select() or epoll_wait() 等待就绪I/O事件 res = evsel->dispatch(base, evbase, tv_p); // ... // 处理超时事件,将超时事件插入到激活链表中 timeout_process(base); timeout_next()函数根据堆中具有最小超时值的事件和当前时间来计算等待时间,下面看看代码: 1static int timeout_next(struct event_base *base, struct timeval **tv_p){ 2 struct timeval now; 3 struct event *ev; 4 struct timeval *tv = *tv_p; 5 // 堆的首元素具有最小的超时值 6 if ((ev = min_heap_top(&base->timeheap)) == NULL) { 7 // 如果没有定时事件,将等待时间设置为NULL,表示一直阻塞直到有I/O事件发生 8 *tv_p = NULL; 9 return (0); 10 } 11 // 取得当前时间 12 gettime(base, &now); 13 // 如果超时时间ev_timeout, &now, ev_timeout, &now, tv); 20 return (0); 21} 2. Timer小根堆 libevent使用堆来管理Timer事件,其key值就是事件的超时时间,源代码位于文件min_heap.h中。 所有的数据结构书中都有关于堆的详细介绍,向堆中插入、删除元素时间复杂度都是O(lgN),N为堆中元素的个数,而获取最小key值(小根堆)的复杂度为O(1)。堆是一个完全二叉树,基本存储方式是一个数组。 libevent实现的堆还是比较轻巧的,虽然我不喜欢这种编码方式(搞一些复杂的表达式)。轻巧到什么地方呢,就以插入元素为例,来对比说明,下面伪代码中的size表示当前堆的元素个数: 典型的代码逻辑如下: Heap[size++] = new; // 先放到数组末尾,元素个数+1 // 下面就是shift_up()的代码逻辑,不断的将new向上调整 _child = size; while(_child>0) // 循环 { _parent = (_child-1)/2; // 计算parent if(Heap[_parent].key 而libevent的heap代码对这一过程做了优化,在插入新元素时,只是为新元素预留了一个位置hole(初始时hole位于数组尾部),但并不立刻将新元素插入到hole上,而是不断向上调整hole的值,将父节点向下调整,最后确认hole就是新元素的所在位置时,才会真正的将新元素插入到hole上,因此在调整过程中就比上面的代码少了一次赋值的操作,代码逻辑是: // 下面就是shift_up()的代码逻辑,不断的将new的“预留位置”向上调整 _hole = size; // _hole就是为new预留的位置,但并不立刻将new放上 while(_hole>0) // 循环 { _parent = (_hole-1)/2; // 计算parent if(Heap[_parent].key 由于每次调整都少做一次赋值操作,在调整路径比较长时,调整效率会比第一种有所提高。libevent中的minheap_shift_up()函数就是上面逻辑的具体实现,对应的向下调整函数是minheap_shift_down()。 举个例子,向一个小根堆3, 5, 8, 7, 12中插入新元素2,使用第一中典型的代码逻辑,其调整过程如下图所示: 使用libevent中的堆调整逻辑,调整过程如下图所示: 对于删除和元素修改操作,也遵从相同的逻辑,就不再罗嗦了。 3. 小节 通过设置系统I/O机制的wait时间,从而简洁的集成Timer事件;主要分析了libevent对堆调整操作的优化。 如需下载本站全部技术文章,可以在【高性能服务器开发】公众号回复关键字“文章下载”即可。最近更新时间: 2020-10-03 12:03:58 "},"articles/libevent源码深度剖析/libevent源码深度剖析10.html":{"url":"articles/libevent源码深度剖析/libevent源码深度剖析10.html","title":"libevent源码深度剖析10","keywords":"","body":"libevent源码深度剖析10 支持I/O多路复用技术 libevent的核心是事件驱动、同步非阻塞,为了达到这一目标,必须采用系统提供的I/O多路复用技术,而这些在Windows、Linux、Unix等不同平台上却各有不同,如何能提供优雅而统一的支持方式,是首要关键的问题,这其实不难,本节就来分析一下。 1. 统一的关键 libevent支持多种I/O多路复用技术的关键就在于结构体eventop,这个结构体前面也曾提到过,它的成员是一系列的函数指针, 定义在event-internal.h文件中: struct eventop { const char *name; void *(*init)(struct event_base *); // 初始化 int (*add)(void *, struct event *); // 注册事件 int (*del)(void *, struct event *); // 删除事件 int (*dispatch)(struct event_base *, void *, struct timeval *); // 事件分发 void (*dealloc)(struct event_base *, void *); // 注销,释放资源 /* set if we need to reinitialize the event base */ int need_reinit; }; 在libevent中,每种I/O demultiplex机制的实现都必须提供这五个函数接口,来完成自身的初始化、销毁释放;对事件的注册、注销和分发。 比如对于epoll,libevent实现了5个对应的接口函数,并在初始化时并将eventop的5个函数指针指向这5个函数,那么程序就可以使用epoll作为I/O demultiplex机制了。 2. 设置I/O demultiplex机制 libevent把所有支持的I/O demultiplex机制存储在一个全局静态数组eventops中,并在初始化时选择使用何种机制,数组内容根据优先级顺序声明如下: /* In order of preference */ static const struct eventop *eventops[] = { #ifdef HAVE_EVENT_PORTS &evportops, #endif #ifdef HAVE_WORKING_KQUEUE &kqops, #endif #ifdef HAVE_EPOLL &epollops, #endif #ifdef HAVE_DEVPOLL &devpollops, #endif #ifdef HAVE_POLL &pollops, #endif #ifdef HAVE_SELECT &selectops, #endif #ifdef WIN32 &win32ops, #endif NULL }; 然后libevent根据系统配置和编译选项决定使用哪一种I/O demultiplex机制,这段代码在函数event_base_new()中: base->evbase = NULL; for (i = 0; eventops[i] && !base->evbase; i++) { base->evsel = eventops[i]; base->evbase = base->evsel->init(base); } base->evbase = NULL; for (i = 0; eventops[i] && !base->evbase; i++) { base->evsel = eventops[i]; base->evbase = base->evsel->init(base); } 可以看出,libevent在编译阶段选择系统的I/O demultiplex机制,而不支持在运行阶段根据配置再次选择。 以Linux下面的epoll为例,实现在源文件epoll.c中,eventops对象epollops定义如下: const struct eventop epollops = { \"epoll\", epoll_init, epoll_add, epoll_del, epoll_dispatch, epoll_dealloc, 1 /* need reinit */ }; 变量epollops中的函数指针具体声明如下,注意到其返回值和参数都和eventop中的定义严格一致,这是函数指针的语法限制。 static void *epoll_init (struct event_base *); static int epoll_add (void *, struct event *); static int epoll_del (void *, struct event *); static int epoll_dispatch(struct event_base *, void *, struct timeval *); static void epoll_dealloc (struct event_base *, void *); 那么如果选择的是epoll,那么调用结构体eventop的init和dispatch函数指针时,实际调用的函数就是epoll的初始化函数epoll_init()和事件分发函数epoll_dispatch()了; http://blog.csdn.net/sparkliang/archive/2009/06/09/4254115.aspx 同样的,上面epollops以及epoll的各种函数都直接定义在了epoll.c源文件中,对外都是不可见的。对于libevent的使用者而言,完全不会知道它们的存在,对epoll的使用也是通过eventop来完成的,达到了信息隐藏的目的。 3. 小节 支持多种I/O demultiplex机制的方法其实挺简单的,借助于函数指针就OK了。通过对源代码的分析也可以看出,libevent是在编译阶段选择系统的I/O demultiplex机制的,而不支持在运行阶段根据配置再次选择。 如需下载本站全部技术文章,可以在【高性能服务器开发】公众号回复关键字“文章下载”即可。最近更新时间: 2020-10-03 12:05:31 "},"articles/libevent源码深度剖析/libevent源码深度剖析11.html":{"url":"articles/libevent源码深度剖析/libevent源码深度剖析11.html","title":"libevent源码深度剖析11","keywords":"","body":"libevent源码深度剖析11 时间管理 为了支持定时器,libevent必须和系统时间打交道,这一部分的内容也比较简单,主要涉及到时间的加减辅助函数、时间缓存、时间校正和定时器堆的时间值调整等。下面就结合源代码来分析一下。 1. 初始化检测 libevent在初始化时会检测系统时间的类型,通过调用函数d**etect_monotonic()完成,它通过调用clock_gettime()**来检测系统是否支持monotonic时钟类型: static void detect_monotonic(void){ #if defined(HAVE_CLOCK_GETTIME) && defined(CLOCK_MONOTONIC) struct timespec ts; if (clock_gettime(CLOCK_MONOTONIC, &ts) == 0) use_monotonic = 1; // 系统支持monotonic时间 #endif } Monotonic时间指示的是系统从boot后到现在所经过的时间,如果系统支持Monotonic时间就将全局变量use_monotonic设置为1,设置use_monotonic到底有什么用,这个在后面说到时间校正时就能看出来了。 2. 时间缓存 结构体event_base中的tv_cache,用来记录时间缓存。这个还要从函数gettime()说起,先来看看该函数的代码: static int gettime(struct event_base *base, struct timeval *tp){ // 如果tv_cache时间缓存已设置,就直接使用 if (base->tv_cache.tv_sec) { *tp = base->tv_cache; return (0); } // 如果支持monotonic,就用clock_gettime获取monotonic时间 #if defined(HAVE_CLOCK_GETTIME) && defined(CLOCK_MONOTONIC) if (use_monotonic) { struct timespec ts; if (clock_gettime(CLOCK_MONOTONIC, &ts) == -1) return (-1); tp->tv_sec = ts.tv_sec; tp->tv_usec = ts.tv_nsec / 1000; return (0); } #endif // 否则只能取得系统当前时间 return (evutil_gettimeofday(tp, NULL)); } 如果tv_cache已经设置,那么就直接使用缓存的时间;否则需要再次执行系统调用获取系统时间。 函数evutil_gettimeofday()用来获取当前系统时间,在Linux下其实就是系统调用gettimeofday();Windows没有提供函数gettimeofday,而是通过调用_ftime()来完成的。 在每次系统事件循环中,时间缓存tv_cache将会被相应的清空和设置,再次来看看下面event_base_loop的主要代码逻辑: int event_base_loop(struct event_base *base, int flags){ // 清空时间缓存 base->tv_cache.tv_sec = 0; while(!done){ timeout_correct(base, &tv); // 时间校正 // 更新event_tv到tv_cache指示的时间或者当前时间(第一次) // event_tv event_tv); // 清空时间缓存-- 时间点1 base->tv_cache.tv_sec = 0; // 等待I/O事件就绪 res = evsel->dispatch(base, evbase, tv_p); // 缓存tv_cache存储了当前时间的值-- 时间点2 // tv_cache tv_cache); // .. 处理就绪事件 } // 退出时也要清空时间缓存 base->tv_cache.tv_sec = 0; return (0); } 时间event_tv指示了dispatch()上次返回,也就是I/O事件就绪时的时间,第一次进入循环时,由于tv_cache被清空,因此gettime()执行系统调用获取当前系统时间;而后将会更新为tv_cache指示的时间。 时间tv_cache在dispatch()返回后被设置为当前系统时间,因此它缓存了本次I/O事件就绪时的时间(event_tv)。 从代码逻辑里可以看出event_tv取得的是tv_cache上一次的值,因此event_tv应该小于tv_cache的值。 设置时间缓存的优点是不必每次获取时间都执行系统调用,这是个相对费时的操作;在上面标注的时间点2到时间点1的这段时间(处理就绪事件时),调用gettime()取得的都是tv_cache缓存的时间。 3. 时间校正 如果系统支持monotonic时间,该时间是系统从boot后到现在所经过的时间,因此不需要执行校正。 根据前面的代码逻辑,如果系统不支持monotonic时间,用户可能会手动的调整时间,如果时间被向前调整了(MS前面第7部分讲成了向后调整,要改正),比如从5点调整到了3点,那么在时间点2取得的值可能会小于上次的时间,这就需要调整了,下面来看看校正的具体代码,由函数timeout_correct()完成: static void timeout_correct(struct event_base *base, struct timeval *tv){ struct event **pev; unsigned int size; struct timeval off; if (use_monotonic) // monotonic时间就直接返回,无需调整 return; gettime(base, tv); // tv event_tv, >=)) { base->event_tv = *tv; return; } // 计算时间差值 evutil_timersub(&base->event_tv, tv, &off); // 调整定时事件小根堆 pev = base->timeheap.p; size = base->timeheap.n; for (; size-- > 0; ++pev) { struct timeval *ev_tv = &(**pev).ev_timeout; evutil_timersub(ev_tv, &off, ev_tv); } base->event_tv = *tv; // 更新event_tv为tv_cache } 在调整小根堆时,因为所有定时事件的时间值都会被减去相同的值,因此虽然堆中元素的时间键值改变了,但是相对关系并没有改变,不会改变堆的整体结构。因此只需要遍历堆中的所有元素,将每个元素的时间键值减去相同的值即可完成调整,不需要重新调整堆的结构。 当然调整完后,要将event_tv值重新设置为tv_cache值了。 4. 小节 主要分析了一下libevent对系统时间的处理,时间缓存、时间校正和定时堆的时间值调整等,逻辑还是很简单的,时间的加减、设置等辅助函数则非常简单,主要在头文件evutil.h中,就不再多说了。 如需下载本站全部技术文章,可以在【高性能服务器开发】公众号回复关键字“文章下载”即可。最近更新时间: 2020-10-03 12:06:41 "},"articles/libevent源码深度剖析/libevent源码深度剖析12.html":{"url":"articles/libevent源码深度剖析/libevent源码深度剖析12.html","title":"libevent源码深度剖析12","keywords":"","body":"libevent源码深度剖析12 让libevent支持多线程 libevent本身不是多线程安全的,在多核的时代,如何能充分利用CPU的能力呢,这一节来说说如何在多线程环境中使用libevent,跟源代码并没有太大的关系,纯粹是使用上的技巧。 1. 错误使用示例 在多核的CPU上只使用一个线程始终是对不起CPU的处理能力啊,那好吧,那就多创建几个线程,比如下面的简单服务器场景。 1 主线程创建工作线程1; 2 接着主线程监听在端口上,等待新的连接; 3 在线程1中执行event事件循环,等待事件到来; 4 新连接到来,主线程调用libevent接口event_add将新连接注册到libevent上; … … 上面的逻辑看起来没什么错误,在很多服务器设计中都可能用到主线程和工作线程的模式…. 可是就在线程1注册事件时,主线程很可能也在操作事件,比如删除,修改,通过libevent的源代码也能看到,没有同步保护机制,问题麻烦了,看起来不能这样做啊,难道只能使用单线程不成!? 2. 支持多线程的几种模式 libevent并不是线程安全的,但这不代表libevent不支持多线程模式,其实方法在前面已经将signal事件处理时就接触到了,那就是消息通知机制。 一句话,“你发消息通知我,然后再由我在合适的时间来处理”; 说到这就再多说几句,再打个比方,把你自己比作一个工作线程,而你的头是主线程,你有一个消息信箱来接收别人发给你的消息,当时头有个新任务要指派给你。 2.1 暴力抢占 那么第一节中使用的多线程方法相当下面的流程: 1 当时你正在做事,比如在写文档; 2 你的头找到了一个任务,要指派给你,比如帮他搞个PPT,哈; 3 头命令你马上搞PPT,你这是不得不停止手头的工作,把PPT搞定了再接着写文档; … 2.2 纯粹的消息通知机制 那么基于纯粹的消息通知机制的多线程方式就像下面这样: 1 当时你正在写文档; 2 你的头找到了一个任务,要指派给你,帮他搞个PPT; 3 头发个消息到你信箱,有个PPT要帮他搞定,这时你并不鸟他; 4 你写好文档,接着检查消息发现头有个PPT要你搞定,你开始搞PPT; … 第一种的好处是消息可以立即得到处理,但是很方法很粗暴,你必须立即处理这个消息,所以你必须处理好切换问题,省得把文档上的内容不小心写到PPT里。在操作系统的进程通信中,消息队列(消息信箱)都是操作系统维护的,你不必关心。 第二种的优点是通过消息通知,切换问题省心了,不过消息是不能立即处理的(基于消息通知机制,这个总是难免的),而且所有的内容都通过消息发送,比如PPT的格式、内容等等信息,这无疑增加了通信开销。 2.3 消息通知+同步层 有个折中机制可以减少消息通信的开销,就是提取一个同步层,还拿上面的例子来说,你把工作安排都存放在一个工作队列中,而且你能够保证“任何人把新任务扔到这个队列”,“自己取出当前第一个任务”等这些操作都能够保证不会把队列搞乱(其实就是个加锁的队列容器)。 再来看看处理过程和上面有什么不同: 1 当时你正在写文档; 2 你的头找到了一个任务,要指派给你,帮他搞个PPT; 2 头有个PPT要你搞定,他把任务push到你的工作队列中,包括了PPT的格式、内容等信息; 3 头发个消息(一个字节)到你信箱,有个PPT要帮他搞定,这时你并不鸟他; 4 你写好文档,发现有新消息(这预示着有新任务来了),检查工作队列知道头有个PPT要你搞定,你开始搞PPT; … 工作队列其实就是一个加锁的容器(队列、链表等等),这个很容易实现实现;而消息通知仅需要一个字节,具体的任务都push到了在工作队列中,因此想比2.2减少了不少通信开销。 多线程编程有很多陷阱,线程间资源的同步互斥不是一两句能说得清的,而且出现bug很难跟踪调试;这也有很多的经验和教训,因此如果让我选择,在绝大多数情况下都会选择机制3作为实现多线程的方法。 3. 例子——memcached Memcached中的网络部分就是基于libevent完成的,其中的多线程模型就是典型的消息通知+同步层机制。下面的图足够说明其多线程模型了,其中有详细的文字说明。 注:该图的具体出处忘记了,感谢原作者。 4. 小节 本节更是libevent的使用方面的技巧,讨论了一下如何让libevent支持多线程,以及几种支持多线程的机制,和memcached使用libevent的多线程模型。 如需下载本站全部技术文章,可以在【高性能服务器开发】公众号回复关键字“文章下载”即可。最近更新时间: 2020-10-03 12:08:20 "},"articles/libevent源码深度剖析/libevent源码深度剖析13.html":{"url":"articles/libevent源码深度剖析/libevent源码深度剖析13.html","title":"libevent源码深度剖析13","keywords":"","body":"libevent源码深度剖析13 libevent信号处理注意点 前面讲到了 libevent 实现多线程的方法,然而在多线程的环境中注册信号事件,还是有一些情况需要小心处理,那就是不能在多个 libevent 实例上注册信号事件。依然冠名追加到 libevent 系列。 以 2 个线程为例,做简单的场景分析。 1 首先是创建并初始化线程 1 的 libevent 实例 base1 ,线程 1 的 libevent 实例 base2 ; 2 在 base1 上注册 SIGALRM 信号;在 base2 上注册 SIGINT 信号; 3 假设当前 base1 和 base2 上都没有注册其他的事件; 4 线程 1 和 2 都进入 event_base_loop 事件循环: 5 假设线程 1 先进入 event_base_loop ,并设置 evsignal_base = base1 ;并等待; 6 接着线程 2 也进入 event_base_loop ,并设置 evsignal_base = base2 ;并等待; 于是 evsignal_base 就指向了 base2 ; 7 信号 ALARM 触发,调用服务例程: static void evsignal_handler(int sig){ ... evsignal_base->sig.evsigcaught[sig]++; evsignal_base->sig.evsignal_caught = 1; /* Wake up our notification mechanism */ send(evsignal_base->sig.ev_signal_pair[0], \"a\", 1, 0); ... } 于是 base2 得到通知 ALARM 信号发生了,而实际上 ALARM 是注册在 base1 上的, base2 上的 ALARM 注册 event 是空的,于是处理函数将不能得到调用;因此在 libevent 中,如果需要处理信号,只能将信号注册到一个 libevent 实例上。 memcached 就没有使用 libevent 提供的 signal 接口,而是直接使用系统提供的原生 API ,看起来这样更简洁。 libevent源码深度剖析全系列完。 如需下载本站全部技术文章,可以在【高性能服务器开发】公众号回复关键字“文章下载”即可。最近更新时间: 2020-10-03 12:09:45 "},"articles/leveldb源码分析/":{"url":"articles/leveldb源码分析/","title":"leveldb源码分析","keywords":"","body":"leveldb源码分析 leveldb源码分析1 leveldb源码分析2 leveldb源码分析3 leveldb源码分析4 leveldb源码分析5 leveldb源码分析6 leveldb源码分析7 leveldb源码分析8 leveldb源码分析9 leveldb源码分析10 leveldb源码分析11 leveldb源码分析12 leveldb源码分析13 leveldb源码分析14 leveldb源码分析15 leveldb源码分析16 leveldb源码分析17 leveldb源码分析18 leveldb源码分析19 leveldb源码分析20 leveldb源码分析21 leveldb源码分析22 如需下载本站全部技术文章,可以在【高性能服务器开发】公众号回复关键字“文章下载”即可。最近更新时间: 2020-10-08 12:22:45 "},"articles/leveldb源码分析/leveldb源码分析1.html":{"url":"articles/leveldb源码分析/leveldb源码分析1.html","title":"leveldb源码分析1","keywords":"","body":"leveldb源码分析1 本系列《leveldb源码分析》共有22篇文章,这是第一篇。 leveldb,除去测试部分,代码不超过1.5w行。这是一个单机k/v存储系统,决定看完它,并把源码分析完整的写下来,还是会很有帮助的。我比较厌烦太复杂的东西,而Leveldb的逻辑很清晰,代码不多、风格很好,功能就不用讲了,正合我的胃口。 BTW,分析Leveldb也参考了网上一些朋友写的分析blog,如【巴山独钓】。 leveldb源码分析 2012年1月21号开始研究下leveldb的代码,Google两位大牛开发的单机KV存储系统,涉及到了skip list、内存KV table、LRU cache管理、table文件存储、operation log系统等。先从边边角角的小角色开始扫。 不得不说,Google大牛的代码风格太好了,读起来很舒服,不像有些开源项目,很快就看不下去了。 开始之前先来看看Leveldb的基本框架,几大关键组件,如图1-1所示。 图1-1 leveldb是一种基于operation log的文件系统,是Log-Structured-Merge Tree的典型实现。LSM源自Ousterhout和Rosenblum在1991年发表的经典论文The Design and Implementation of a Log-Structured File System >>。 由于采用了op log,它就可以把随机的磁盘写操作,变成了对op log的append操作,因此提高了IO效率,最新的数据则存储在内存memtable中。 当op log文件大小超过限定值时,就定时做check point。Leveldb会生成新的Log文件和Memtable,后台调度会将Immutable Memtable的数据导出到磁盘,形成一个新的SSTable文件。SSTable就是由内存中的数据不断导出并进行Compaction操作后形成的,而且SSTable的所有文件是一种层级结构,第一层为Level 0,第二层为Level 1,依次类推,层级逐渐增高,这也是为何称之为LevelDb的原因。 1. 一些约定 先说下代码中的一些约定: 1.1 字节序 Leveldb对于数字的存储是little-endian的,在把int32或者int64转换为char*的函数中,是按照先低位再高位的顺序存放的,也就是little-endian的。 1.2 VarInt 把一个int32或者int64格式化到字符串中,除了上面说的little-endian字节序外,大部分还是变长存储的,也就是VarInt。对于VarInt,每byte的有效存储是7bit的,用最高的8bit位来表示是否结束,如果是1就表示后面还有一个byte的数字,否则表示结束。直接见Encode和Decode函数。 在操作log中使用的是Fixed存储格式。 1.3 字符比较 是基于unsigned char的,而非char。 2. 基本数据结构 别看是基本数据结构,有些也不是那么简单的,像LRU Cache管理和Skip list那都算是leveldb的核心数据结构。 2.1 Slice Leveldb中的基本数据结构: 包括length和一个指向外部字节数组的指针。 和string一样,允许字符串中包含’\\0’。 提供一些基本接口,可以把const char和string转换为Slice;把Slice转换为string,取得数据指针const char。 2.2 Status Leveldb 中的返回状态,将错误号和错误信息封装成Status类,统一进行处理。并定义了几种具体的返回状态,如成功或者文件不存在等。 为了节省空间Status并没有用std::string来存储错误信息,而是将返回码(code), 错误信息message及长度打包存储于一个字符串数组中。 成功状态OK 是NULL state,否则state 是一个包含如下信息的数组: state_[0..3] == 消息message长度 state_[4] == 消息code state_[5..] ==消息message 2.3 Arena Leveldb的简单的内存池,它所作的工作十分简单,申请内存时,将申请到的内存块放入std::vector blocks_中,在Arena的生命周期结束后,统一释放掉所有申请到的内存,内部结构如图2.3-1所示。 Arena主要提供了两个申请函数:其中一个直接分配内存,另一个可以申请对齐的内存空间。 Arena没有直接调用delete/free函数,而是由Arena的析构函数统一释放所有的内存。 应该说这是和leveldb特定的应用场景相关的,比如一个memtable使用一个Arena,当memtable被释放时,由Arena统一释放其内存。 2.4 Skip list Skip list(跳跃表)是一种可以代替平衡树的数据结构。Skip lists应用概率保证平衡,平衡树采用严格的旋转(比如平衡二叉树有左旋右旋)来保证平衡,因此Skip list比较容易实现,而且相比平衡树有着较高的运行效率。 从概率上保持数据结构的平衡比显式的保持数据结构平衡要简单的多。对于大多数应用,用skip list要比用树更自然,算法也会相对简单。由于skip list比较简单,实现起来会比较容易,虽然和平衡树有着相同的时间复杂度(O(logn)),但是skip list的常数项相对小很多。skip list在空间上也比较节省。一个节点平均只需要1.333个指针(甚至更少),并且不需要存储保持平衡的变量。 如图2.4-1所示。 在Leveldb中,skip list是实现memtable的核心数据结构,memtable的KV数据都存储在skip list中。 2.5 Cache Leveldb内部通过双向链表实现了一个标准版的LRUCache,先上个示意图,看看几个数据之间的关系,如图2.5-1。 Leveldb实现LRUCache的几个步骤 接下来说说Leveldb实现LRUCache的几个步骤,很直观明了。 S1 定义一个LRUHandle结构体,代表cache中的元素。它包含了几个主要的成员: void* value; 这个存储的是cache的数据; void (*deleter)(const Slice&, void* value); 这个是数据从Cache中清除时执行的清理函数; 后面的三个成员事关LRUCache的数据的组织结构: > LRUHandle *next_hash; 指向节点在hash table链表中的下一个hash(key)相同的元素,在有碰撞时Leveldb采用的是链表法。最后一个节点的next_hash为NULL。 > LRUHandle *next, *prev; 节点在双向链表中的前驱后继节点指针,所有的cache数据都是存储在一个双向list中,最前面的是最新加入的,每次新加入的位置都是head->next。所以每次剔除的规则就是剔除list tail。 S2 Leveldb自己实现了一个hash table:HandleTable,而不是使用系统提供的hash table。这个类就是基本的hash操作:Lookup、Insert和Delete。 Hash table的作用是根据key快速查找元素是否在cache中,并返回LRUHandle节点指针,由此就能快速定位节点在hash表和双向链表中的位置。 它是通过LRUHandle的成员next_hash组织起来的。 HandleTable使用LRUHandle list_存储所有的hash节点,其实就是一个二维数组,**一维是不同的hash(key),另一维则是相同hash(key)的碰撞list。 每次当hash节点数超过当前一维数组的长度后,都会做Resize操作: LRUHandle** new_list = new LRUHandle*[new_length]; 然后复制list到new_list中,并删除旧的list。 S3 基于HandleTable和LRUHandle,实现了一个标准的LRUcache,并内置了mutex保护锁,是线程安全的。 其中存储所有数据的双向链表是LRUHandle lru_,这是一个list head; Hash表则是HandleTable table_; S4 ShardedLRUCache类,实际上到S3,一个标准的LRU Cache已经实现了,为何还要更近一步呢?答案就是速度! 为了多线程访问,尽可能快速,减少锁开销,ShardedLRUCache内部有16个LRUCache,查找Key时首先计算key属于哪一个分片,分片的计算方法是取32位hash值的高4位,然后在相应的LRUCache中进行查找,这样就大大减少了多线程的访问锁的开销。 LRUCache shard_[kNumShards] 它就是一个包装类,实现都在LRUCache类中。 2.6 其它 此外还有其它几个Random、Hash、CRC32、Histogram等,都在util文件夹下,不仔细分析了。 如需下载本站全部技术文章,可以在【高性能服务器开发】公众号回复关键字“文章下载”即可。最近更新时间: 2020-10-03 12:20:53 "},"articles/leveldb源码分析/leveldb源码分析2.html":{"url":"articles/leveldb源码分析/leveldb源码分析2.html","title":"leveldb源码分析2","keywords":"","body":"leveldb源码分析2 本系列《leveldb源码分析》共有22篇文章,这是第二篇。 3.Int Coding 轻松一刻,前面约定中讲过Leveldb使用了很多VarInt型编码,典型的如后面将涉及到的各种key。其中的编码、解码函数分为VarInt和FixedInt两种。int32和int64操作都是类似的。 3.1 Decode 首先是FixedInt编码,直接上代码,很简单明了。 void EncodeFixed32(char* buf, uint32_t value) { if (port::kLittleEndian) { memcpy(buf, &value,sizeof(value)); } else { buf[0] = value & 0xff; buf[1] = (value >> 8)& 0xff; buf[2] = (value >> 16)& 0xff; buf[3] = (value >> 24)& 0xff; } } 下面是VarInt编码,int32和int64格式,代码如下,有效位是7bit的,因此把uint32按7bit分割,对unsigned char赋值时,超出0xFF会自动截断,因此直接*(ptr++) = v|B即可,不需要再把(v|B)与0xFF作&操作。 char* EncodeVarint32(char* dst, uint32_t v) { unsigned char* ptr =reinterpret_cast(dst); static const int B = 128; if (v >7; } else if (v >7) | B; *(ptr++) = v>>14; } else if (v >7) | B; *(ptr++) = (v>>14) | B; *(ptr++) = v>>21; } else { *(ptr++) = v | B; *(ptr++) = (v>>7) | B; *(ptr++) = (v>>14) | B; *(ptr++) = (v>>21) | B; *(ptr++) = v>>28; } return reinterpret_cast(ptr); } // 对于uint64,直接循环 char* EncodeVarint64(char* dst, uint64_t v) { static const int B = 128; unsigned char* ptr =reinterpret_cast(dst); while (v >= B) { *(ptr++) = (v & (B-1)) |B; v >>= 7; } *(ptr++) =static_cast(v); returnreinterpret_cast(ptr); } 3.2 Decode Fixed Int的Decode,操作,代码: inline uint32_t DecodeFixed32(const char* ptr) { if (port::kLittleEndian) { uint32_t result; // gcc optimizes this to a plain load memcpy(&result, ptr,sizeof(result)); return result; } else { return((static_cast(static_cast(ptr[0]))) |(static_cast(static_cast(ptr[1])) (static_cast(ptr[2])) (static_cast(ptr[3])) 再来看看VarInt的解码,很简单,依次读取1byte,直到最高位为0的byte结束,取低7bit,作( const char* GetVarint32Ptr(const char* p, const char* limit, uint32_t* value) { if (p (p)); if ((result & 128) == 0) { *value = result; return p + 1; } } return GetVarint32PtrFallback(p,limit, value); } const char* GetVarint32PtrFallback(const char* p, const char* limit, uint32_t* value) { uint32_t result = 0; for (uint32_t shift = 0; shift(p)); p++; if (byte & 128) { // More bytes are present result |= ((byte & 127)(p); } } return NULL; } 如需下载本站全部技术文章,可以在【高性能服务器开发】公众号回复关键字“文章下载”即可。最近更新时间: 2020-10-03 12:24:16 "},"articles/leveldb源码分析/leveldb源码分析3.html":{"url":"articles/leveldb源码分析/leveldb源码分析3.html","title":"leveldb源码分析3","keywords":"","body":"leveldb源码分析3 本系列《leveldb源码分析》共有22篇文章,这是第三篇。 4. Memtable之一 Memtable是leveldb很重要的一块,leveldb的核心之一。我们肯定关注KV数据在Memtable中是如何组织的,秘密在Skip list中。 4.1 用途 在Leveldb中,所有内存中的KV数据都存储在Memtable中,物理disk则存储在SSTable中。在系统运行过程中,如果Memtable中的数据占用内存到达指定值(Options.write_buffer_size),则Leveldb就自动将Memtable转换为Memtable,并自动生成新的Memtable,也就是Copy-On-Write机制了。 Immutable Memtable则被新的线程Dump到磁盘中,Dump结束则该Immutable Memtable就可以释放了。因名知意,Immutable Memtable是只读的。 所以可见,最新的数据都是存储在Memtable中的,Immutable Memtable和物理SSTable则是某个时点的数据。 为了防止系统down机导致内存数据Memtable或者Immutable Memtable丢失,leveldb自然也依赖于log机制来保证可靠性了。 Memtable提供了写入KV记录,删除以及读取KV记录的接口,但是事实上Memtable并不执行真正的删除操作,删除某个Key的Value在Memtable内是作为插入一条记录实施的,但是会打上一个Key的删除标记,真正的删除操作在后面的 Compaction过程中,lazy delete。 4.2 核心是Skip list 另外,Memtable中的KV对是根据Key排序的,leveldb在插入等操作时保证key的有序性。想想,前面看到的Skip list不正是合适的人选吗,因此Memtable的核心数据结构是一个Skip list,Memtable只是一个接口类。当然随之而来的一个问题就是Skip list是如何组织KV数据对的,在后面分析Memtable的插入、查询接口时我们将会看到答案。 4.3 接口说明 先来看看Memtable的接口: void Ref() { ++refs_; } void Unref(); Iterator* NewIterator(); void Add(SequenceNumber seq, ValueType type, const Slice& key, const Slice& value); bool Get(const LookupKey& key, std::string* value, Status* s); 首先Memtable是基于引用计数的机制,如果引用计数为0,则在Unref中删除自己,Ref和Unref就是干这个的。 NewIterator是返回一个迭代器,可以遍历访问table的内部数据,很好的设计思想,这种方式隐藏了table的内部实现。外部调用者必须保证使用Iterator访问Memtable的时候该Memtable是live的。 Add和Get是添加和获取记录的接口,没有Delete,还记得前面说过,memtable的delete实际上是插入一条type为kTypeDeletion的记录。 4.4 类图 先来看看Memtable相关的整体类层次吧,并不复杂,还是相当清晰的。见图4.4-1。 4.5 Key结构 Memtable是一个KV存储结构,那么这个key肯定是个重点了,在分析接口实现之前,有必要仔细分析一下Memtable对key的使用。 这里面有5个key的概念,可能会让人混淆,下面就来一个一个的分析。 4.5.1 InternalKey & ParsedInternalKey & User Key InternalKey是一个复合概念,是有几个部分组合成的一个key,ParsedInternalKey就是对InternalKey分拆后的结果,先来看看ParsedInternalKey的成员,这是一个struct: Slice user_key; SequenceNumber sequence; ValueType type; 也就是说InternalKey是由User key + SequenceNumber + ValueType组合而成的,顺便先分析下几个Key相关的函数,它们是了解Internal Key和User Key的关键。 首先是InternalKey和ParsedInternalKey相互转换的两个函数,如下。 bool ParseInternalKey (const Slice& internal_key, ParsedInternalKey* result); void AppendInternalKey (std::string* result, const ParsedInternalKey& key); 函数实现很简单,就是字符串的拼接与把字符串按字节拆分,代码略过。根据实现,容易得到InternalKey的格式为: | User key (string) | sequence number (7 bytes) | value type (1 byte) | 由此还可知道sequence number大小是7 bytes,sequence number是所有基于op log系统的关键数据,它唯一指定了不同操作的时间顺序。 把user key放到前面**的原因**是,这样对同一个user key的操作就可以按照sequence number顺序连续存放了,不同的user key是互不相干的,因此把它们的操作放在一起也没有什么意义。 另外用户可以为user key定制比较函数,系统默认是字母序的。 下面的两个函数是分别从InternalKey中拆分出User Key和Value Type的,非常直观,代码也附上吧。 inline Slice ExtractUserKey(const Slice& internal_key) { assert(internal_key.size() >= 8); return Slice(internal_key.data(), internal_key.size() - 8); } inline ValueType ExtractValueType(const Slice& internal_key) { assert(internal_key.size() >= 8); const size_t n = internal_key.size(); uint64_t num = DecodeFixed64(internal_key.data() + n - 8); unsigned char c = num & 0xff; return static_cast(c); } 4.5.2 LookupKey & Memtable Key Memtable的查询接口传入的是LookupKey,它也是由User Key和Sequence Number组合而成的,从其构造函数: LookupKey(const Slice& user_key, SequenceNumber s) 中分析出LookupKey的格式为: | Size (int32变长)| User key (string) | sequence number (7 bytes) | value type (1 byte) | 两点: 这里的Size是user key长度+8,也就是整个字符串长度了; value type是kValueTypeForSeek,它等于kTypeValue。 由于LookupKey的size是变长存储的,因此它使用kstart_记录了user key string的起始地址,否则将不能正确的获取size和user key; LookupKey导出了三个函数,可以分别从LookupKey得到Internal Key,Memtable Key和User Key,如下: // Return a key suitable for lookup in a MemTable. Slice memtable_key() const { return Slice(start_, end_ - start_); } // Return an internal key (suitable for passing to an internal iterator) Slice internal_key() const { return Slice(kstart_, end_ - kstart_); } // Return the user key Slice user_key() const { return Slice(kstart_, end_ - kstart_ - 8); } 其中start_是LookupKey字符串的开始,end_是结束,kstart_是start_+4,也就是user key字符串的起始地址。 如需下载本站全部技术文章,可以在【高性能服务器开发】公众号回复关键字“文章下载”即可。最近更新时间: 2020-10-03 12:27:10 "},"articles/leveldb源码分析/leveldb源码分析4.html":{"url":"articles/leveldb源码分析/leveldb源码分析4.html","title":"leveldb源码分析4","keywords":"","body":"leveldb源码分析4 本系列《leveldb源码分析》共有22篇文章,这是第四篇 4.Memtable之2 4.6 Comparator 弄清楚了key,接下来就要看看key的使用了,先从Comparator开始分析。首先Comparator是一个抽象类,导出了几个接口。 其中Name()和Compare()接口都很明了,另外的两个Find xxx接口都有什么功能呢,直接看程序注释: //Advanced functions: these are used to reduce the space requirements //for internal data structures like index blocks. // 这两个函数:用于减少像index blocks这样的内部数据结构占用的空间 // 其中的*start和*key参数都是IN OUT的。 //If *start = *key. //Simple comparator implementations may return with *key unchanged, //i.e., an implementation of this method that does nothing is correct. //这个函数的作用就是:找一个>= *key的短字符串 //简单的comparator实现可能不改变*key,这也是正确的 virtual void FindShortSuccessor(std::string* key) const = 0; 其中的实现类有两个,一个是内置的BytewiseComparatorImpl,另一个是InternalKeyComparator。下面分别来分析。 4.6.1 BytewiseComparatorImpl 首先是重载的Name和比较函数,比较函数如其名,就是字符串比较,如下: virtual const char* Name() const {return\"leveldb.BytewiseComparator\";} virtual int Compare(const Slice& a, const Slice& b) const {return a.compare(b);} 再来看看Byte wise的comparator是如何实现FindShortestSeparator()的,没什么特别的,代码 + 注释如下: virtual void FindShortestSeparator(std::string* start, onst Slice& limit) const { // 首先计算共同前缀字符串的长度 size_t min_length = std::min(start->size(), limit.size()); size_t diff_index = 0; while ((diff_index = min_length) { // 说明*start是limit的前缀,或者反之,此时不作修改,直接返回 } else { // 尝试执行字符start[diff_index]++, 设置start长度为diff_index+1,并返回 // ++条件:字符((*start)[diff_index]); if (diff_byte (0xff) && diff_byte + 1 (limit[diff_index])) { (*start)[diff_index]++; start->resize(diff_index + 1); assert(Compare(*start, limit) 最后是FindShortSuccessor(),这个更简单了,代码+注释如下: virtual void FindShortSuccessor(std::string* key) const { // 找到第一个可以++的字符,执行++后,截断字符串; // 如果找不到说明*key的字符都是0xff啊,那就不作修改,直接返回 size_t n = key->size(); for (size_t i = 0; i (0xff)) { (*key)[i] = byte + 1; key->resize(i+1); return; } } } Leveldb内建的基于Byte wise的comparator类就这么多内容了,下面再来看看InternalKeyComparator。 4.6.2 InternalKeyComparator 从上面对Internal Key的讨论可知,由于它是由user key和sequence number和value type组合而成的,因此它还需要user key的比较,所以InternalKeyComparator有一个Comparator usercomparator成员,用于*user key的比较。 在leveldb中的名字为:\"leveldb.InternalKeyComparator\",下面来看看比较函数: Compare(const Slice& akey, const Slice& bkey) 代码很简单,其比较逻辑是: S1 首先比较user key,基于用户设置的comparator,如果user key不相等就直接返回比较,否则执行进入S2 S2 取出8字节的sequence number | value type,如果akey的 > bkey的则返回-1,如果akey的的返回1,相等返回0 由此可见其排序比较依据依次是: 首先根据user key按升序排列 然后根据sequence number按降序排列 最后根据value type按降序排列 虽然比较时value type并不重要,因为sequence number是唯一的,但是直接取出8byte的sequence number | value type,然后做比较更方便,不需要再次移位提取出7byte的sequence number,又何乐而不为呢。这也是把value type安排在低7byte的好处吧,排序的两个依据就是user key和sequence number。 接下来就该看看其FindShortestSeparator()函数实现了,该函数取出Internal Key中的user key字段,根据user指定的comparator找到并替换start,如果start被替换了,就用新的start更新Internal Key,并使用最大的sequence number。否则保持不变。 函数声明: void InternalKeyComparator::FindShortestSeparator(std::string* start, const Slice& limit) const; 函数实现: // 尝试更新user key,基于指定的user comparator Slice user_start = ExtractUserKey(*start); Slice user_limit = ExtractUserKey(limit); std::string tmp(user_start.data(), user_start.size()); user_comparator_->FindShortestSeparator(&tmp, user_limit); if(tmp.size()Compare(user_start, tmp)Compare(*start, tmp) Compare(tmp, limit) swap(tmp); } 接下来是FindShortSuccessor(std::string* key)函数,该函数取出Internal Key中的user key字段,根据user指定的comparator找到并替换key,如果key被替换了,就用新的key更新Internal Key,并使用最大的sequence number。否则保持不变。实现逻辑如下: Slice user_key = ExtractUserKey(*key); // 尝试更新user key,基于指定的user comparator std::string tmp(user_key.data(), user_key.size()); user_comparator_->FindShortSuccessor(&tmp); if(tmp.size()Compare(user_key, tmp)Compare(*key, tmp) swap(tmp); } 4.7 Memtable::Insert() 把相关的Key和Key Comparator都弄清楚后,是时候分析memtable本身了。首先是向memtable插入记录的接口,函数原型如下: void Add(SequenceNumber seq, ValueType type, const Slice& key, const Slice& value); 代码实现如下: // KV entry字符串有下面4部分连接而成 //key_size : varint32 of internal_key.size() //key bytes : char[internal_key.size()] //value_size : varint32 of value.size() // value bytes : char[value.size()] size_t key_size = key.size(); size_t val_size = value.size(); size_t internal_key_size = key_size + 8; const size_t encoded_len = VarintLength(internal_key_size) + internal_key_size + VarintLength(val_size) + val_size; char* buf = arena_.Allocate(encoded_len); char* p = EncodeVarint32(buf, internal_key_size); memcpy(p, key.data(), key_size); p += key_size; EncodeFixed64(p, (s 根据代码,我们可以分析出KV记录在skip list的存储格式等信息,首先总长度为: VarInt(Internal Key size) len + internal key size + VarInt(value) len + value size 它们的相互衔接也就是KV的存储格式: | VarInt(Internal Key size) len | internal key |VarInt(value) len |value| 其中前面说过: internal key = |user key |sequence number |type | Internal key size = key size + 8 4.8 Memtable::Get() Memtable的查找接口,根据一个LookupKey找到响应的记录,函数声明: bool MemTable::Get(const LookupKey& key, std::string* value, Status* s) 函数实现如下: Slice memkey = key.memtable_key(); Table::Iterator iter(&table_); iter.Seek(memkey.data()); // seek到value>= memkey.data()的第一个记录 if (iter.Valid()) { // 这里不需要再检查sequence number了,因为Seek()已经跳过了所有 // 值更大的sequence number了 const char* entry = iter.key(); uint32_t key_length; const char* key_ptr = GetVarint32Ptr(entry, entry+5, &key_length); // 比较user key是否相同,key_ptr开始的len(internal key) -8 byte是user key if (comparator_.comparator.user_comparator()->Compare (Slice(key_ptr, key_length - 8), key.user_key()) == 0) { // len(internal key)的后8byte是 |sequence number | value type| const uint64_t tag = DecodeFixed64(key_ptr + key_length - 8); switch (static_cast(tag & 0xff)) { case kTypeValue: { // 只取出value Slice v = GetLengthPrefixedSlice(key_ptr + key_length); value->assign(v.data(), v.size()); return true; } case kTypeDeletion: *s = Status::NotFound(Slice()); return true; } } } return false; 这段代码,主要就是一个Seek函数,根据传入的LookupmKey得到在emtable中存储的key,然后调用Skip list::Iterator的Seek函数查找。Seek直接调用Skip list的FindGreaterOrEqual(key)接口,返回大于等于key的Iterator。然后取出user key判断时候和传入的user key相同,如果相同则取出value,如果记录的Value Type为kTypeDeletion,返回Status::NotFound(Slice())。 4.9 小结 Memtable到此就分析完毕了,本质上就是一个有序的Skip list,排序基于user key的sequence number,其排序比较依据依次是: 首先根据user key按升序排列 然后根据sequence number按降序排列 最后根据value type按降序排列(这个其实无关紧要) 如需下载本站全部技术文章,可以在【高性能服务器开发】公众号回复关键字“文章下载”即可。最近更新时间: 2020-10-03 12:28:55 "},"articles/leveldb源码分析/leveldb源码分析5.html":{"url":"articles/leveldb源码分析/leveldb源码分析5.html","title":"leveldb源码分析5","keywords":"","body":"leveldb源码分析5 本系列《leveldb源码分析》共有22篇文章,这是第五篇。 5.操作Log 1 分析完KV在内存中的存储,接下来就是操作日志。所有的写操作都必须先成功的append到操作日志中,然后再更新内存memtable。这样做有两点: 可以将随机的写IO变成append,极大的提高写磁盘速度; 防止在节点down机导致内存数据丢失,造成数据丢失,这对系统来说是个灾难。 在各种高效的存储系统中,这已经是口水技术了。 5.1 格式 在源码下的文档doc/log_format.txt中,作者详细描述了log格式: The log file contents are a sequence of 32KB blocks. The only exception is that the tail of thefile may contain a partial block. Each block consists of a sequence of records: block:= record* trailer? record := checksum: uint32 // crc32c of type and data[] ; little-endian length: uint16 // little-endian type: uint8 // One of FULL,FIRST, MIDDLE, LAST data: uint8[length] A record never starts within the last six bytes of a block (since it won'tfit). Any leftover bytes here form thetrailer, which must consist entirely of zero bytes and must be skipped byreaders. 翻译过来就是: Leveldb把日志文件切分成了大小为32KB的连续block块,block由连续的log record组成,log record的格式为: 注意:CRC32, Length都是little-endian的。 Log Type有4种:FULL = 1、FIRST = 2、MIDDLE = 3、LAST = 4。FULL类型表明该log record包含了完整的user record;而user record可能内容很多,超过了block的可用大小,就需要分成几条log record,第一条类型为FIRST,中间的为MIDDLE,最后一条为LAST。也就是: FULL,说明该log record包含一个完整的user record; FIRST,说明是user record的第一条log record MIDDLE,说明是user record中间的log record LAST,说明是user record最后的一条log record 翻一下文档上的例子,考虑到如下序列的user records: A: length 1000 B: length 97270 C: length 8000 A作为FULL类型的record存储在第一个block中; B将被拆分成3条log record,分别存储在第1、2、3个block中,这时block3还剩6byte,将被填充为0; C将作为FULL类型的record存储在block 4中。 由于一条logrecord长度最短为7,如果一个block的剩余空间被填充为\\空字**符串,另外长度为7的log record是不包括任何用户数据的**。 5.2 写日志 写比读简单,而且写入决定了读,所以从写开始分析。有意思的是在写文件时,Leveldb使用了内存映射文件,内存映射文件的读写效率比普通文件要高,关于内存映射文件为何更高效,这篇文章写的不错: http://blog.csdn.net/mg0832058/article/details/5890688 其中涉及到的类层次比较简单,如图5.2-1: 注意Write类的成员typecrc数组,这里存放的为Record Type预先计算的CRC32值,因为Record Type是固定的几种,为了效率。Writer类只有一个接口,就是AddRecord(),传入Slice参数,下面来看函数实现。首先取出slice的字符串指针和长度,初始化begin=true,表明是第一条log record。 const char* ptr = slice.data(); size_t left = slice.size(); bool begin = true; 然后进入一个do{}while循环,直到写入出错,或者成功写入全部数据,如下: 1 S1 首先查看当前block是否 dest_->Append(Slice(\"\\x00\\x00\\x00\\x00\\x00\\x00\",leftover)); block_offset_ = 0; S2 计算block剩余大小,以及本次log record可写入数据长度 const size_t avail =kBlockSize - block_offset_ - kHeaderSize; const size_t fragment_length = (left S3 根据两个值,判断log type RecordType type; const bool end = (left ==fragment_length); // 两者相等,表明写 if (begin && end) type = kFullType; else if (begin) type = kFirstType; else if (end) type = kLastType; else type = kMiddleType; S4 调用EmitPhysicalRecord函数,append日志;并更新指针、剩余长度和begin标记 s = EmitPhysicalRecord(type, ptr,fragment_length); ptr += fragment_length; left -= fragment_length; begin = false; 2 接下来看看EmitPhysicalRecord函数,这是实际写入的地方,涉及到log的存储格式。函数声明为: StatusWriter::EmitPhysicalRecord(RecordType t, const char* ptr, size_t n) 参数ptr为用户record数据,参数n为record长度,不包含log header。 S1 计算header,并Append到log文件,共7byte格式为: | CRC32 (4 byte) | payload length lower + high (2 byte) | type (1byte)| char buf[kHeaderSize]; buf[4] = static_cast(n& 0xff); buf[5] =static_cast(n >> 8); buf[6] =static_cast(t); // 计算record type和payload的CRC校验值 uint32_t crc = crc32c::Extend(type_crc_[t], ptr, n); crc = crc32c::Mask(crc); // 空间调整 EncodeFixed32(buf, crc); dest_->Append(Slice(buf,kHeaderSize)); S2 写入payload,并Flush,更新block的当前偏移 s =dest_->Append(Slice(ptr, n)); s = dest_->Flush(); block_offset_ += kHeaderSize +n; 以上就是写日志的逻辑,很直观。 如需下载本站全部技术文章,可以在【高性能服务器开发】公众号回复关键字“文章下载”即可。最近更新时间: 2020-10-03 12:31:10 "},"articles/leveldb源码分析/leveldb源码分析6.html":{"url":"articles/leveldb源码分析/leveldb源码分析6.html","title":"leveldb源码分析6","keywords":"","body":"leveldb源码分析6 本系列《leveldb源码分析》共有22篇文章,这是第六篇。 5. 操作Log 2 5.3 读日志 日志读取显然比写入要复杂,要检查checksum,检查是否有损坏等等,处理各种错误。 5.3.1 类层次 Reader主要用到了两个接口,一个是汇报错误的Reporter,另一个是log文件读取类SequentialFile。 Reporter的接口只有一个: void Corruption(size_t bytes,const Status& status); SequentialFile有两个接口: Status Read(size_t n, Slice* result, char* scratch); Status Skip(uint64_t n); 说明下,Read接口有一个result参数传递结果就行了,为何还有一个scratch呢,这个就和Slice相关了。它的字符串指针是传入的外部char*指针,自己并不负责内存的管理与分配。因此Read接口需要调用者提供一个字符串指针,实际存放字符串的地方。 Reader类有几个成员变量,需要注意: bool eof_; // 上次Read()返回长度5.3.2日志读取流程 Reader只有一个接口,那就是ReadRecord,下面来分析下这个函数。 S1 根据initial offset跳转到调用者指定的位置,开始读取日志文件。跳转就是直接调用SequentialFile的Seek接口。 另外,需要先调整调用者传入的initialoffset参数,调整和跳转逻辑在SkipToInitialBlock函数中。 if (last_record_offset_ 下面的代码是SkipToInitialBlock函数调整read offset的逻辑: // 计算在block内的偏移位置,并圆整到开始读取block的起始位置 size_t offset_in_block =initial_offset_ % kBlockSize; uint64_t block_start_location =initial_offset_ - offset_in_block; // 如果偏移在最后的6byte里,肯定不是一条完整的记录,跳到下一个block if (offset_in_block >kBlockSize - 6) { offset_in_block = 0; block_start_location +=kBlockSize; } end_of_buffer_offset_ =block_start_location; // 设置读取偏移 if (block_start_location > 0) file_->Skip(block_start_location); // 跳转 首先计算出在block内的偏移位置,然后圆整到要读取block的起始位置。开始读取日志的时候都要保证读取的是完整的block,这就是调整的目的。 同时成员变量endof_buffer_offset记录了这个值,在后续读取中会用到。 S2在开始while循环前首先初始化几个标记: // 当前是否在fragment内,也就是遇到了FIRST 类型的record bool in_fragmented_record = false; uint64_t prospective_record_offset = 0; // 我们正在读取的逻辑record的偏移 S3 进入到while(true)循环,直到读取到KLastType或者KFullType的record,或者到了文件结尾。从日志文件读取完整的record是ReadPhysicalRecord函数完成的。 读取出现错误时,并不会退出循环,而是汇报错误,继续执行,直到成功读取一条user record,或者遇到文件结尾。 S3.1 从文件读取record uint64_t physical_record_offset = end_of_buffer_offset_ -buffer_.size(); const unsigned int record_type = ReadPhysicalRecord(&fragment); physical_record_offset存储的是当前正在读取的record的偏移值。接下来根据不同的record_type类型,分别处理,一共有7种情况: S3.2 FULL type(kFullType),表明是一条完整的log record,成功返回读取的user record数据。另外需要对早期版本做些work around,早期的Leveldb会在block的结尾生产一条空的kFirstType log record。 if (in_fragmented_record) { if (scratch->empty())in_fragmented_record = false; else ReportCorruption(scratch->size(),\"partial record without end(1)\"); } prospective_record_offset= physical_record_offset; scratch->clear(); // 清空scratch,读取成功不需要返回scratch数据 *record = fragment; last_record_offset_ =prospective_record_offset; // 更新last record offset return true; S3.3 FIRST type(kFirstType),表明是一系列logrecord(fragment)的第一个record。同样需要对早期版本做work around。 把数据读取到scratch中,直到成功读取了LAST类型的log record,才把数据返回到result中,继续下次的读取循环。 如果再次遇到FIRSTor FULL类型的log record,如果scratch不为空,就说明日志文件有错误。 if (in_fragmented_record) { if (scratch->empty())in_fragmented_record = false; else ReportCorruption(scratch->size(),\"partial record without end(2)\"); } prospective_record_offset =physical_record_offset; scratch->assign(fragment.data(), fragment.size()); //赋值给scratch in_fragmented_record =true; // 设置fragment标记为true S3.4 MIDDLE type(kMiddleType),这个处理很简单,如果不是在fragment中,报告错误,否则直接append到scratch中就可以了。 if (!in_fragmented_record) { ReportCorruption(fragment.size(), \"missing start of fragmentedrecord(1)\"); } else {scratch->append(fragment.data(),fragment.size());} S3.5 LAST type(kLastType),说明是一系列log record(fragment)中的最后一条。如果不在fragment中,报告错误。 if (!in_fragmented_record) { ReportCorruption(fragment.size(), \"missing start of fragmentedrecord(2)\"); } else { scratch->append(fragment.data(), fragment.size()); *record = Slice(*scratch); last_record_offset_ =prospective_record_offset; return true; } 至此,4种正常的log record type已经处理完成,下面3种情况是其它的错误处理,类型声明在Logger类中: enum { kEof = kMaxRecordType + 1, // 遇到文件结尾 // 非法的record,当前有3中情况会返回bad record: // * CRC校验失败 (ReadPhysicalRecord reports adrop) // * 长度为0 (No drop is reported) // * 在指定的initial_offset之外 (No drop is reported) kBadRecord = kMaxRecordType +2 }; S3.6 遇到文件结尾kEof,返回false。不返回任何结果。 if (in_fragmented_record) { ReportCorruption(scratch->size(), \"partial record withoutend(3)\"); scratch->clear(); } return false; S3.7 非法的record(kBadRecord),如果在fragment中,则报告错误。 if (in_fragmented_record) { ReportCorruption(scratch->size(), \"error in middle ofrecord\"); in_fragmented_record = false; scratch->clear(); } S3.8 缺省分支,遇到非法的record 类型,报告错误,清空scratch。 ReportCorruption(…, \"unknownrecord type %u\", record_type); in_fragmented_record = false; // 重置fragment标记 scratch->clear();// 清空scratch 上面就是ReadRecord的全部逻辑,解释起来还有些费力。 5.3.3 从log文件读取record 就是前面讲过的ReadPhysicalRecord函数,它调用SequentialFile的Read接口,从文件读取数据。 该函数开始就进入了一个while(true)循环,其目的是为了读取到一个完整的record。读取的内容存放在成员变量buffer_中。这样的逻辑有些奇怪,实际上,完全不需要一个while(true)循环的。 函数基本逻辑如下: S1 如果buffer_小于block header大小kHeaderSize,进入如下的几个分支: S1.1 如果eof_为false,表明还没有到文件结尾,清空buffer,并读取数据。 buffer_.clear(); // 因为上次肯定读取了一个完整的record Status status =file_->Read(kBlockSize, &buffer_, backing_store_); end_of_buffer_offset_ +=buffer_.size(); // 更新buffer读取偏移值 if (!status.ok()) { // 读取失败,设置eof_为true,报告错误并返回kEof buffer_.clear(); ReportDrop(kBlockSize,status); eof_ = true; return kEof; } else if (buffer_.size()S1.2 如果eof_为true并且buffer为空,表明已经到了文件结尾,正常结束,返回kEof。 S1.3 否则,也就是eof_为true,buffer不为空,说明文件结尾包含了一个不完整的record,报告错误,返回kEof。 size_t drop_size =buffer_.size(); buffer_.clear(); ReportCorruption(drop_size,\"truncated record at end of file\"); return kEof; S2 进入到这里表明上次循环中的Read读取到了一个完整的log record,continue后的第二次循环判断buffer_.size() >= kHeaderSize将执行到此处。 解析出log record的header部分,判断长度是否一致。 根据log的格式,前4byte是crc32。后面就是length和type,解析如下: const char* header = buffer_.data(); const uint32_t length = ((header[4])& 0xff) | ((header[5]&0xff)buffer_.size()) { // 长度超出了,汇报错误 size_t drop_size =buffer_.size(); buffer_.clear(); ReportCorruption(drop_size,\"bad record length\"); return kBadRecord; // 返回kBadRecord } if (type == kZeroType&& length == 0) { // 对于Zero Type类型,不汇报错误 buffer_.clear(); return kBadRecord; // 依然返回kBadRecord } S3 校验CRC32,如果校验出错,则汇报错误,并返回kBadRecord。 S4 如果record的开始位置在initial offset之前,则跳过,并返回kBadRecord,否则返回record数据和type。 buffer_.remove_prefix(kHeaderSize+ length); if (end_of_buffer_offset_ -buffer_.size() - kHeaderSize - length clear(); return kBadRecord; } *result = Slice(header +kHeaderSize, length); return type; 从log文件读取record的逻辑就是这样的。至此,读日志的逻辑也完成了。接下来将进入磁盘存储的sstable部分。 如需下载本站全部技术文章,可以在【高性能服务器开发】公众号回复关键字“文章下载”即可。最近更新时间: 2020-10-03 12:35:50 "},"articles/leveldb源码分析/leveldb源码分析7.html":{"url":"articles/leveldb源码分析/leveldb源码分析7.html","title":"leveldb源码分析7","keywords":"","body":"leveldb源码分析7 本系列《leveldb源码分析》共有22篇文章,这是第七篇。 6. SSTable之一 SSTable是Leveldb的核心之一,是表数据最终在磁盘上的物理存储。也是体量比较大的模块。 6.1 SSTable的文件组织 作者在文档doc/table_format.txt中描述了表的逻辑结构,如图6.1-1所示。逻辑上可分为两大块,数据存储区Data Block,以及各种Meta信息。 文件中的k/v对是有序存储的,并且被划分到连续排列的Data Block里面,这些Data Block从文件头开始顺序存储,Data Block的存储格式代码在block_builder.cc中; 紧跟在Data Block之后的是Meta Block,其格式代码也在block_builder.cc中;Meta Block存储的是Filter信息,比如Bloom过滤器,用于快速定位key是否在data block中。 MetaIndex Block是对Meta Block的索引,它只有一条记录,key是meta index的名字(也就是Filter的名字),value为指向meta index的BlockHandle;BlockHandle是一个结构体,成员offset是Block在文件中的偏移,成员size是block的大小; Index block是对Data Block的索引,对于其中的每个记录,其key >=Data Block最后一条记录的key,同时 Footer,文件的最后,大小固定,其格式如图6.1-2所示。 成员metaindex_handle指出了meta index block的起始位置和大小; 成员index_handle指出了index block的起始地址和大小; 这两个字段都是BlockHandle对象,可以理解为索引的索引,通过Footer可以直接定位到metaindex和index block。再后面是一个填充区和魔数(0xdb4775248b80fb57)。 6.2 Block存储格式 6.2.1 Block的逻辑存储 Data Block是具体的k/v数据对存储区域,此外还有存储meta的metaIndex Block,存储data block索引信息的Index Block等等,他们都是以Block的方式存储的。来看看Block是如何组织的。每个Block有三部分构成:block data, type, crc32,如图6.2-1所示。 类型type指明使用的是哪种压缩方式,当前支持none和snappy压缩。 虽然block有好几种,但是Block Data都是有序的k/v对,因此写入、读取BlockData的接口都是统一的,对于Block Data的管理也都是相同的。 对Block的写入、读取将在创建、读取sstable时分析,知道了格式之后,其读取写入代码都是很直观的。 由于sstable对数据的存储格式都是Block,因此在分析sstable的读取和写入逻辑之前,我们先来分析下Leveldb对Block Data的管理。 Leveldb对Block Data的管理是读写分离的,读取后的遍历查询操作由Block类实现,BlockData的构建则由BlockBuilder类实现。 6.2.2 重启点-restartpoint BlockBuilder对key的存储是前缀压缩的,对于有序的字符串来讲,这能极大的减少存储空间。但是却增加了查找的时间复杂度,为了兼顾查找效率,每隔K个key,leveldb就不使用前缀压缩,而是存储整个key,这就是重启点(restartpoint)。 在构建Block时,有参数Options::block_restart_interval定每隔几个key就直接存储一个重启点key。 Block在结尾记录所有重启点的偏移,可以二分查找指定的key。Value直接存储在key的后面,无压缩。 对于一个k/v对,其在block中的存储格式为: 共享前缀长度 shared_bytes: varint32 前缀之后的字符串长度 unshared_bytes: varint32 值的长度 value_length: varint32 前缀之后的字符串 key_delta: char[unshared_bytes] 值 value: char[value_length] 对于重启点,shared_bytes= 0 Block的结尾段格式是: > restarts: uint32[num_restarts] > num_restarts: uint32 // 重启点个数 元素restarts[i]存储的是block的第i个重启点的偏移。很明显第一个k/v对,总是第一个重启点,也就是restarts[0] = 0; 图6.2-2给出了block的存储示意图。 总体来看Block可分为k/v存储区和后面的重启点存储区两部分,其中k/v的存储格式如前面所讲,可看做4部分: 前缀压缩的key长度信息 + value长度 + key前缀之后的字符串+ value 最后一个4byte为重启点的个数。 对Block的存储格式了解之后,对Block的构建和读取代码分析就是很直观的事情了。见下面的分析。 6.3 Block的构建与读取 6.3.1 BlockBuilder的接口 首先从Block的构建开始,这就是BlockBuilder类,来看下BlockBuilder的函数接口,一共有5个: void Reset(); // 重设内容,通常在Finish之后调用已构建新的block //添加k/v,要求:Reset()之后没有调用过Finish();Key > 任何已加入的key void Add(const Slice& key,const Slice& value); // 结束构建block,并返回指向block内容的指针 Slice Finish();// 返回Slice的生存周期:Builder的生存周期,or直到Reset()被调用 size_t CurrentSizeEstimate()const; // 返回正在构建block的未压缩大小—估计值 bool empty() const { returnbuffer_.empty();} // 没有entry则返回true 主要成员变量如下: std::string buffer_; // block的内容 std::vector restarts_; // 重启点-后面会分析到 int counter_; // 重启后生成的entry数 std::string last_key_; // 记录最后添加的key 6.3.2 BlockBuilder::Add() 调用Add函数向当前Block中新加入一个k/v对{key, value}。函数处理逻辑如下: S1 保证新加入的key > 已加入的任何一个key; assert(!finished_); assert(counter_ block_restart_interval); assert(buffer_.empty() || options_->comparator->Compare(key,last_key_piece) > 0); S2 如果计数器counter block_restart_interval,则使用前缀算法压缩key,否则就把key作为一个重启点,无压缩存储; Slice last_key_piece(last_key_); if (counter_ block_restart_interval) { //前缀压缩 // 计算key与last_key_的公共前缀 const size_t min_length= std::min(last_key_piece.size(), key.size()); while ((shared block_restart_interval) { //前缀压缩 // 计算key与last_key_的公共前缀 const size_t min_length= std::min(last_key_piece.size(), key.size()); while ((shared S3根据上面的数据格式存储k/v对,追加到buffer中,并更新block状态。 const size_t non_shared = key.size() - shared; // key前缀之后的字符串长度 // append\"\" 到buffer_ PutVarint32(&buffer_, shared); PutVarint32(&buffer_, non_shared); PutVarint32(&buffer_, value.size()); // 其后是前缀之后的字符串 + value buffer_.append(key.data() + shared, non_shared); buffer_.append(value.data(), value.size()); // 更新状态 ,last_key_ = key及计数器counter_ last_key_.resize(shared); // 连一个string的赋值都要照顾到,使内存copy最小化 last_key_.append(key.data() + shared, non_shared); assert(Slice(last_key_) == key); counter_++; 6.3.3 BlockBuilder::Finish() 调用该函数完成Block的构建,很简单,压入重启点信息,并返回buffer,设置结束标记finished: for (size_t i = 0; i 6.3.4 BlockBuilder::Reset() & 大小 还有Reset和CurrentSizeEstimate两个函数,Reset复位函数,清空各个信息;函数CurrentSizeEstimate返回block的预计大小,从函数实现来看,应该在调用Finish之前调用该函数。 void BlockBuilder::Reset() { buffer_.clear(); restarts_.clear(); last_key_.clear(); restarts_.push_back(0); // 第一个重启点位置总是 0 counter_ = 0; finished_ = false; } size_t BlockBuilder::CurrentSizeEstimate () const { // buffer大小 +重启点数组长度 + 重启点长度(uint32) return (buffer_.size() + restarts_.size() * sizeof(uint32_t) + sizeof(uint32_t)); } Block的构建就这些内容了,下面开始分析Block的读取,就是类Block。 6.3.5 Block类接口 对Block的读取是由类Block完成的,先来看看其函数接口和关键成员变量。 Block只有两个函数接口,通过Iterator对象,调用者就可以遍历访问Block的存储的k/v对了;以及几个成员变量,如下: size_t size() const { returnsize_; } Iterator* NewIterator(constComparator* comparator); const char* data_; // block数据指针 size_t size_; // block数据大小 uint32_t restart_offset_; // 重启点数组在data_中的偏移 bool owned_; //data_[]是否是Block拥有的 6.3.6 Block初始化 Block的构造函数接受一个BlockContents对象contents初始化,BlockContents是一个有3个成员的结构体。 >data = Slice(); >cachable = false; // 无cache >heap_allocated = false; // 非heap分配 根据contents为成员赋值 data_ = contents.data.data(), size_ =contents.data.size(),owned_ = contents.heap_allocated; 然后从data中解析出重启点数组,如果数据太小,或者重启点计算出错,就设置size_=0,表明该block data解析失败。 if (size_ size_- sizeof(uint32_t)) size_ = 0; } NumRestarts()函数就是从最后的uint32解析出重启点的个数,并返回: return DecodeFixed32(data_ +size_ - sizeof(uint32_t)) 6.3.7 Block::Iter 这是一个用以遍历Block内部数据的内部类,它继承了Iterator接口。函数NewIterator返回Block::Iter对象: return new Iter(cmp, data_,restart_offset_, num_restarts); 下面我们就分析Iter的实现。 主要成员变量有: const Comparator* constcomparator_; // key比较器 const char* const data_; // block内容 uint32_t const restarts_; // 重启点(uint32数组)在data中的偏移 uint32_t const num_restarts_; // 重启点个数 uint32_t current_; // 当前entry在data中的偏移. >= restarts_表明非法 uint32_t restart_index_; // current_所在的重启点的index 下面来看看对Iterator接口的实现,简单函数略过。 首先是Next()函数,直接调用private函数ParseNextKey()跳到下一个k/v对,函数实现如下: S1 跳到下一个entry,其位置紧邻在当前value之后。如果已经是最后一个entry了,返回false,标记current为invalid。 current_ = NextEntryOffset(); // (value_.data() + value_.size()) - data_ const char* p = data_ +current_; const char* limit = data_ +restarts_; // Restarts come right after data if (p >= limit) { // entry到头了,标记为invalid. current_ = restarts_; restart_index_ =num_restarts_; return false; } S2 解析出entry,解析出错则设置错误状态,记录错误并返回false。解析成功则根据信息组成key和value,并更新重启点index。 uint32_t shared, non_shared,value_length; p = DecodeEntry(p, limit,&shared, &non_shared, &value_length); if (p == NULL || key_.size() 函数DecodeEntry从字符串[p, limit)解析出key的前缀长度、key前缀之后的字符串长度和value的长度这三个vint32值,代码很简单。 函数CorruptionError将current和restart_index都设置为invalid状态,并在status中设置错误状态。 函数GetRestartPoint从data中读取指定restart index的偏移值restart[index],并返回: DecodeFixed32(data_ + restarts_ +index * sizeof(uint32_t); 接下来看看Prev函数,Previous操作分为两步:首先回到current之前的重启点,然后再向后直到current,实现如下: S1首先向前回跳到在current_前面的那个重启点,并定位到重启点的k/v对开始位置。 const uint32_t original =current_; while (GetRestartPoint(restart_index_)>= original) { // 到第一个entry了,标记invalid状态 if (restart_index_ == 0) { current_ = restarts_; restart_index_ =num_restarts_; return; } restart_index_--; } //根据restart index定位到重启点的k/v对 SeekToRestartPoint(restart_index_); S2 第二步,从重启点位置开始向后遍历,直到遇到original前面的那个k/v对。 do {} while (ParseNextKey() &&NextEntryOffset() 说说上面遇到的SeekToRestartPoint函数,它只是设置了几个有限的状态,其它值将在函数ParseNextKey()中设置。感觉这有点tricky,这里的value_并不是k/v对的value,而只是一个指向k/v对起始位置的0长度指针,这样后面的ParseNextKey函数将会取出重启点的k/v值。 void SeekToRestartPoint(uint32_tindex) { key_.clear(); restart_index_ = index; // ParseNextKey()会设置current_; //ParseNextKey()从value_结尾开始, 因此需要相应的设置value_ uint32_t offset =GetRestartPoint(index); value_ = Slice(data_ + offset,0); // value长度设置为0,字符串指针是data_+offset } SeekToFirst/Last,这两个函数都很简单,借助于前面的SeekToResartPoint函数就可以完成。 virtual void SeekToFirst() { SeekToRestartPoint(0); ParseNextKey(); } virtual void SeekToLast() { SeekToRestartPoint(num_restarts_ - 1); while (ParseNextKey()&& NextEntryOffset() 最后一个Seek函数,跳到指定的target(Slice),函数逻辑如下: S1 二分查找,找到key S2 找到后,跳转到重启点,其索引由left指定,这是前面二分查找到的结果。如前面所分析的,value指向重启点的地址,而size指定为0,这样ParseNextKey函数将会取出重启点的k/v值。 SeekToRestartPoint(left); S3 自重启点线性向下,直到遇到key>= target的k/v对。 while (true) { if (!ParseNextKey()) return; if (Compare(key_, target)>= 0) return; } 上面就是Block::Iter的全部实现逻辑,这样Block的创建和读取遍历都已经分析完毕。 如需下载本站全部技术文章,可以在【高性能服务器开发】公众号回复关键字“文章下载”即可。最近更新时间: 2020-10-03 12:40:52 "},"articles/leveldb源码分析/leveldb源码分析8.html":{"url":"articles/leveldb源码分析/leveldb源码分析8.html","title":"leveldb源码分析8","keywords":"","body":"leveldb源码分析8 本系列《leveldb源码分析》共有22篇文章,这是第八篇 6 SSTable之2 6.4 创建sstable文件 了解了sstable文件的存储格式,以及Data Block的组织,下面就可以分析如何创建sstable文件了。相关代码在table_builder.h/.cc以及block_builder.h/.cc(构建Block)中。 6.4.1 TableBuilder类 构建sstable文件的类是TableBuilder,该类提供了几个有限的方法可以用来添加k/v对,Flush到文件中等等,它依赖于BlockBuilder来构建Block。 TableBuilder的几个接口说明下: void Add(const Slice& key, const Slice& value),向当前正在构建的表添加新的{key, value}对,要求根据Option指定的Comparator,key必须位于所有前面添加的key之后; void Flush(),将当前缓存的k/v全部flush到文件中,一个高级方法,大部分的client不需要直接调用该方法; void Finish(),结束表的构建,该方法被调用后,将不再会使用传入的WritableFile; void Abandon(),结束表的构建,并丢弃当前缓存的内容,该方法被调用后,将不再会使用传入的WritableFile;【只是设置closed为true,无其他操作】 一旦Finish()/Abandon()方法被调用,将不能再次执行Flush或者Add操作。 下面来看看涉及到的类,如图6.3-1所示。图6.3-1 其中WritableFile和op log一样,使用的都是内存映射文件。Options是一些调用者可设置的选项。 TableBuilder只有一个成员变量Rep* rep_,实际上Rep结构体的成员就是TableBuilder所有的成员变量;这样做的目的,可能是为了隐藏其内部细节。Rep的定义也是在.cc文件中,对外是透明的。 简单解释下成员的含义: Options options; // data block的选项 Options index_block_options; // index block的选项 WritableFile* file; // sstable文件 uint64_t offset; // 要写入data block在sstable文件中的偏移,初始0 Status status; //当前状态-初始ok BlockBuilder data_block; //当前操作的data block BlockBuilder index_block; // sstable的index block std::string last_key; //当前data block最后的k/v对的key int64_t num_entries; //当前data block的个数,初始0 bool closed; //调用了Finish() or Abandon(),初始false FilterBlockBuilder*filter_block; //根据filter数据快速定位key是否在block中 bool pending_index_entry; //见下面的Add函数,初始false BlockHandle pending_handle; //添加到index block的data block的信息 std::string compressed_output;//压缩后的data block,临时存储,写入后即被清空 Filter block是存储的过滤器信息,它会存储{key, 对应data block在sstable的偏移值},不一定是完全精确的,以快速定位给定key是否在data block中。 下面分析如何向sstable中添加k/v对,创建并持久化sstable。其它函数都比较简单,略过。另外对于Abandon,简单设置closed=true即返回。 6.4.2 添加k/v对 这是通过方法Add(constSlice& key, const Slice& value)完成的,没有返回值。下面分析下函数的逻辑: 1 S1 首先保证文件没有close,也就是没有调用过Finish/Abandon,以及保证当前status是ok的;如果当前有缓存的kv对,保证新加入的key是最大的。 Rep* r = rep_; assert(!r->closed); if (!ok()) return; if (r->num_entries > 0) { assert(r->options.comparator->Compare(key, Slice(r->last_key))> 0); } S2 如果标记r->pending_index_entry为true,表明遇到下一个data block的第一个k/v,根据key调整r->last_key,这是通过Comparator的FindShortestSeparator完成的。 if (r->pending_index_entry) { assert(r->data_block.empty()); r->options.comparator->FindShortestSeparator(&r->last_key,key); std::string handle_encoding; r->pending_handle.EncodeTo(&handle_encoding); r->index_block.Add(r->last_key, Slice(handle_encoding)); r->pending_index_entry =false; } 接下来将pending_handle加入到index block中{r->last_key, r->pending_handle’sstring}。最后将r->pending_index_entry设置为false。 值得讲讲pending_index_entry这个标记的意义,见代码注释: 直到遇到下一个databock的第一个key时,我们才为上一个datablock生成index entry,这样的好处是:可以为index使用较短的key;比如上一个data block最后一个k/v的key是\"the quick brown fox\",其后继data block的第一个key是\"the who\",我们就可以用一个较短的字符串\"the r\"作为上一个data block的index block entry的key。 简而言之,就是在开始下一个datablock时,Leveldb才将上一个data block加入到index block中。标记pending_index_entry就是干这个用的,对应data block的index entry信息就保存在(BlockHandle)pending_handle。 S3 如果filter_block不为空,就把key加入到filter_block中。 if (r->filter_block != NULL) { r->filter_block->AddKey(key); } S4 设置r->last_key = key,将(key, value)添加到r->data_block中,并更新entry数。 r->last_key.assign(key.data(), key.size()); r->num_entries++; r->data_block.Add(key,value); S5 如果data block的个数超过限制,就立刻Flush到文件中。 const size_testimated_block_size = r->data_block.CurrentSizeEstimate(); if (estimated_block_size >=r->options.block_size) Flush(); 6.4.3 Flush文件 该函数逻辑比较简单,直接见代码如下: Rep* r = rep_; assert(!r->closed); // 首先保证未关闭,且状态ok if (!ok()) return; if (r->data_block.empty())return; // data block是空的 // 保证pending_index_entry为false,即data block的Add已经完成 assert(!r->pending_index_entry); // 写入data block,并设置其index entry信息—BlockHandle对象 WriteBlock(&r->data_block, &r->pending_handle); //写入成功,则Flush文件,并设置r->pending_index_entry为true, //以根据下一个data block的first key调整index entry的key—即r->last_key if (ok()) { r->pending_index_entry =true; r->status =r->file->Flush(); } if (r->filter_block != NULL) { //将data block在sstable中的便宜加入到filter block中 r->filter_block->StartBlock(r->offset); // 并指明开始新的data block } 6.4.4 WriteBlock函数 在Flush文件时,会调用WriteBlock函数将data block写入到文件中,该函数同时还设置data block的index entry信息。原型为: void WriteBlock(BlockBuilder* block, BlockHandle* handle) 该函数做些预处理工作,序列化要写入的data block,根据需要压缩数据,真正的写入逻辑是在WriteRawBlock函数中。下面分析该函数的处理逻辑。 2 S1 获得block的序列化数据Slice,根据配置参数决定是否压缩,以及根据压缩格式压缩数据内容。对于Snappy压缩,如果压缩率太低 BlockBuilder的Finish()函数将data block的数据序列化成一个Slice。 Rep* r = rep_; Slice raw = block->Finish(); // 获得data block的序列化字符串 Slice block_contents; CompressionType type =r->options.compression; switch (type) { case kNoCompression: block_contents= raw; break; // 不压缩 case kSnappyCompression: { // snappy压缩格式 std::string* compressed =&r->compressed_output; if(port::Snappy_Compress(raw.data(), raw.size(), compressed) && compressed->size()S2 将data内容写入到文件,并重置block成初始化状态,清空compressedoutput。 WriteRawBlock(block_contents,type, handle); r->compressed_output.clear(); block->Reset(); 6.4.5 WriteRawBlock函数 在WriteBlock把准备工作都做好后,就可以写入到sstable文件中了。来看函数原型: void WriteRawBlock(const Slice& data, CompressionType, BlockHandle*handle); 函数逻辑很简单,见代码。 Rep* r = rep_; handle->set_offset(r->offset); // 为index设置data block的handle信息 handle->set_size(block_contents.size()); r->status =r->file->Append(block_contents); // 写入data block内容 if (r->status.ok()) { // 写入1byte的type和4bytes的crc32 chartrailer[kBlockTrailerSize]; trailer[0] = type; uint32_t crc = crc32c::Value(block_contents.data(), block_contents.size()); crc = crc32c::Extend(crc, trailer, 1); // Extend crc tocover block type EncodeFixed32(trailer+1, crc32c::Mask(crc)); r->status =r->file->Append(Slice(trailer, kBlockTrailerSize)); if (r->status.ok()) { // 写入成功更新offset-下一个data block的写入偏移 r->offset +=block_contents.size() + kBlockTrailerSize; } } 6.4.6 Finish函数 调用Finish函数,表明调用者将所有已经添加的k/v对持久化到sstable,并关闭sstable文件。 该函数逻辑很清晰,可分为5部分。 3 S1 首先调用Flush,写入最后的一块data block,然后设置关闭标志closed=true。表明该sstable已经关闭,不能再添加k/v对。 1 Rep* r = rep_; 2 Flush(); 3 assert(!r->closed); 4 r->closed = true; 5 BlockHandle filter_block_handle,metaindex_block_handle, index_block_handle; S2 写入filter block到文件中。 if (ok() &&r->filter_block != NULL) { WriteRawBlock(r->filter_block->Finish(), kNoCompression,&filter_block_handle); } S3 写入meta index block到文件中。 如果filterblock不为NULL,则加入从\"filter.Name\"到filter data位置的映射。通过meta index block,可以根据filter名字快速定位到filter的数据区。 if (ok()) { BlockBuildermeta_index_block(&r->options); if (r->filter_block !=NULL) { //加入从\"filter.Name\"到filter data位置的映射 std::string key =\"filter.\"; key.append(r->options.filter_policy->Name()); std::string handle_encoding; filter_block_handle.EncodeTo(&handle_encoding); meta_index_block.Add(key,handle_encoding); } // TODO(postrelease): Add stats and other metablocks WriteBlock(&meta_index_block, &metaindex_block_handle); } S4 写入index block,如果成功Flush过data block,那么需要为最后一块data block设置index block,并加入到index block中。 if (ok()) { if (r->pending_index_entry) { // Flush时会被设置为true r->options.comparator->FindShortSuccessor(&r->last_key); std::string handle_encoding; r->pending_handle.EncodeTo(&handle_encoding); r->index_block.Add(r->last_key, Slice(handle_encoding)); // 加入到index block中 r->pending_index_entry =false; } WriteBlock(&r->index_block, &index_block_handle); } S5 写入Footer。 if (ok()) { Footer footer; footer.set_metaindex_handle(metaindex_block_handle); footer.set_index_handle(index_block_handle); std::string footer_encoding; footer.EncodeTo(&footer_encoding); r->status =r->file->Append(footer_encoding); if (r->status.ok()) { r->offset +=footer_encoding.size(); } } 整个写入流程就分析完了,对于Datablock和Filter Block的操作将在Data block和Filter Block中单独分析,下面的读取相同。 如需下载本站全部技术文章,可以在【高性能服务器开发】公众号回复关键字“文章下载”即可。最近更新时间: 2020-10-03 12:42:57 "},"articles/leveldb源码分析/leveldb源码分析9.html":{"url":"articles/leveldb源码分析/leveldb源码分析9.html","title":"leveldb源码分析9","keywords":"","body":"leveldb源码分析9 本系列《leveldb源码分析》共有22篇文章,这是第九篇 6 SSTable之3 6.5 读取sstable文件 6.5.1 类层次 Sstable文件的读取逻辑在类Table中,其中涉及到的类还是比较多的,如图6.5-1所示。 Table类导出的函数只有3个,先从这三个导出函数开始分析。其中涉及到的类(包括上图中为画出的)都会一一遇到,然后再一一拆解。 本节分析sstable的打开逻辑,后面再分析key的查找与数据遍历。 6.5.2 Table::Open() 打开一个sstable文件,函数声明为: static Status Open(const Options& options, RandomAccessFile* file, uint64_tfile_size, Table** table); 这是Table类的一个静态函数,如果操作成功,指针*table指向新打开的表,否则返回错误。 要打开的文件和大小分别由参数file和file_size指定;option是一些选项; 下面就分析下函数逻辑: 1\\ S1 首先从文件的结尾读取Footer,并Decode到Footer对象中,如果文件长度小于Footer的长度,则报错。Footer的decode很简单,就是根据前面的Footer结构,解析并判断magic number是否正确,解析出meta index和index block的偏移和长度。 *table = NULL; if (size Read(size -Footer::kEncodedLength, Footer::kEncodedLength, &footer_input, footer_space); if (!s.ok()) return s; Footer footer; s =footer.DecodeFrom(&footer_input); if (!s.ok()) return s; S2 解析出了Footer,我们就可以读取index block和meta index了,首先读取index block。 BlockContents contents; Block* index_block = NULL; if (s.ok()) { s = ReadBlock(file, ReadOptions(),footer.index_handle(), &contents); if (s.ok()) { index_block = newBlock(contents); } } 这是通过调用ReadBlock完成的,下面会分析这个函数。 S3 已经成功读取了footer和index block,此时table已经可以响应请求了。构建table对象,并读取metaindex数据构建filter policy。如果option打开了cache,还要为table创建cache。 if (s.ok()) { // 已成功读取footer和index block: 可以响应请求了 Rep* rep = new Table::Rep; rep->options = options; rep->file = file; rep->metaindex_handle =footer.metaindex_handle(); rep->index_block =index_block; rep->cache_id =(options.block_cache ? options.block_cache->NewId() : 0); rep->filter_data = rep->filter= NULL; *table = new Table(rep); (*table)->ReadMeta(footer); // 调用ReadMeta读取metaindex } else { if (index_block) deleteindex_block; } 到这里,Table的打开操作就已经为完成了。下面来分析上面用到的ReadBlock()和ReadMeta()函数。 6.5.3 ReadBlock() 前面讲过block的格式,以及Block的写入(TableBuilder::WriteRawBlock),现在我们可以轻松的分析Block的读取操作了。 这是一个全局函数,声明为: Status ReadBlock(RandomAccessFile* file, const ReadOptions& options, const BlockHandle&handle, BlockContents* result); 下面来分析实现逻辑: 2 S1 初始化结果result,BlockContents是一个有3个成员的结构体。 result->data = Slice(); result->cachable = false; // 无cache result->heap_allocated =false; // 非heap分配 S2 根据handle指定的偏移和大小,读取block内容,type和crc32值,其中常量kBlockTrailerSize=5= 1byte的type和4bytes的crc32。 Status s = file->Read(handle.offset(),handle.size() + kBlockTrailerSize, &contents, buf); S3 如果option要校验CRC32,则计算content + type的CRC32并校验。 S4 最后根据type指定的存储类型,如果是非压缩的,则直接取数据赋给result,否则先解压,把解压结果赋给result,目前支持的是snappy压缩。 另外,文件的Read接口返回的Slice结果,其data指针可能没有使用我们传入的buf,如果没有,那么释放Slice的data指针就是我们的事情,否则就是文件来管理的。 if (data != buf) { // 文件自己管理,cacheable等标记设置为false delete[] buf; result->data =Slice(data, n); result->heap_allocated= result->cachable =false; } else { // 读取者自己管理,标记设置为true result->data =Slice(buf, n); result->heap_allocated= result->cachable = true; } 对于压缩存储,解压后的字符串存储需要读取者自行分配的,所以标记都是true。 6.5.4 Table::ReadMeta() 解决完了Block的读取,接下来就是meta的读取了。函数声明为: void Table::ReadMeta(const Footer& footer) 函数逻辑并不复杂 。 3 S1首先调用ReadBlock读取meta的内容 if(rep_->options.filter_policy == NULL) return; // 不需要metadata ReadOptions opt; BlockContents contents; if (!ReadBlock(rep_->file,opt, footer.metaindex_handle(), &contents).ok()) { return; // 失败了也没报错,因为没有meta信息也没关系 } S2 根据读取的content构建Block,找到指定的filter;如果找到了就调用ReadFilter构建filter对象。Block的分析留在后面。 Block* meta = newBlock(contents); Iterator* iter =meta->NewIterator(BytewiseComparator()); std::string key =\"filter.\"; key.append(rep_->options.filter_policy->Name()); iter->Seek(key); if (iter->Valid() &&iter->key() == Slice(key)) ReadFilter(iter->value()); delete iter; delete meta; 6.5.5 Table::ReadFilter() 根据指定的偏移和大小,读取filter,函数声明: void ReadFilter(const Slice& filter_handle_value); 简单分析下函数逻辑: 4 S1 从传入的filter_handle_value Decode出BlockHandle,这是filter的偏移和大小; BlockHandle filter_handle; filter_handle.DecodeFrom(&filter_handle_value); S2 根据解析出的位置读取filter内容,ReadBlock。如果block的heap_allocated为true,表明需要自行释放内存,因此要把指针保存在filter_data中。最后根据读取的data创建FilterBlockReader对象。 ReadOptions opt; BlockContents block; ReadBlock(rep_->file, opt,filter_handle, &block); if (block.heap_allocated)rep_->filter_data = block.data.data(); // 需要自行释放内存 rep_->filter = newFilterBlockReader(rep_->options.filter_policy, block.data); 以上就是sstable文件的读取操作,不算复杂。 如需下载本站全部技术文章,可以在【高性能服务器开发】公众号回复关键字“文章下载”即可。最近更新时间: 2020-10-03 12:45:24 "},"articles/leveldb源码分析/leveldb源码分析10.html":{"url":"articles/leveldb源码分析/leveldb源码分析10.html","title":"leveldb源码分析10","keywords":"","body":"leveldb源码分析10 本系列《leveldb源码分析》共有22篇文章,这是第十篇 6.SSTable之四 6.6 遍历Table 6.6.1 遍历接口 Table导出了一个返回Iterator的接口,通过Iterator对象,调用者就可以遍历Table的内容,它简单的返回了一个TwoLevelIterator对象。见函数实现: Iterator* NewIterator(const ReadOptions&options) const; { return NewTwoLevelIterator(rep_->index_block->NewIterator(rep_->options.comparator), &Table::BlockReader,const_cast(this), options); } // 函数NewTwoLevelIterator创建了一个TwoLevelIterator对象: Iterator* NewTwoLevelIterator(Iterator* index_iter,BlockFunction block_function, void* arg, constReadOptions& options) { return newTwoLevelIterator(index_iter, block_function, arg, options); } 这里有一个函数指针BlockFunction,类型为: typedef Iterator* (*BlockFunction)(void*, const ReadOptions&, constSlice&); 为什么叫TwoLevelIterator呢,下面就来看看。 6.6.2 TwoLevelIterator 它也是Iterator的子类,之所以叫two level应该是不仅可以迭代其中存储的对象,它还接受了一个函数BlockFunction,可以遍历存储的对象,可见它是专门为Table定制的。 我们已经知道各种Block的存储格式都是相同的,但是各自block data存储的k/v又互不相同,于是我们就需要一个途径,能够在使用同一个方式遍历不同的block时,又能解析这些k/v。这就是BlockFunction,它又返回了一个针对block data的Iterator。Block和block data存储的k/v对的key是统一的。 先来看类的主要成员变量: BlockFunction block_function_; // block操作函数 void* arg_; // BlockFunction的自定义参数 const ReadOptions options_; // BlockFunction的read option参数 Status status_; // 当前状态 IteratorWrapper index_iter_; // 遍历block的迭代器 IteratorWrapper data_iter_; // May be NULL-遍历block data的迭代器 // 如果data_iter_ != NULL,data_block_handle_保存的是传递给 // block_function_的index value,以用来创建data_iter_ std::string data_block_handle_; 下面分析一下对于Iterator几个接口的实现。 S1 对于其Key和Value接口都是返回的dataiter对应的key和value: virtual bool Valid() const { return data_iter_.Valid(); } virtual Slice key() const { assert(Valid()); return data_iter_.key(); } virtual Slice value() const { assert(Valid()); return data_iter_.value(); } S2 在分析Seek系函数之前,有必要先了解下面这几个函数的用途。 void InitDataBlock(); void SetDataIterator(Iterator*data_iter); //设置date_iter_ = data_iter voidSkipEmptyDataBlocksForward(); voidSkipEmptyDataBlocksBackward(); S2.1首先是InitDataBlock(),它是根据index_iter来初始化data_iter,当定位到新的block时,需要更新data Iterator,指向该block中k/v对的合适位置,函数如下: if (!index_iter_.Valid()) SetDataIterator(NULL); // index_iter非法 else { Slice handle =index_iter_.value(); if (data_iter_.iter() != NULL&& handle.compare(data_block_handle_) == 0) { //data_iter已经在该block data上了,无须改变 } else { // 根据handle数据定位data iter Iterator* iter =(*block_function_)(arg_, options_, handle); data_block_handle_.assign(handle.data(), handle.size()); SetDataIterator(iter); } } S2.2 SkipEmptyDataBlocksForward,向前跳过空的datablock,函数实现如下: while (data_iter_.iter() == NULL|| !data_iter_.Valid()) { // 跳到下一个block if (!index_iter_.Valid()) { // 如果index iter非法,设置data iteration为NULL SetDataIterator(NULL); return; } index_iter_.Next(); InitDataBlock(); if (data_iter_.iter() != NULL)data_iter_.SeekToFirst(); // 跳转到开始 } S2.3 SkipEmptyDataBlocksBackward,向后跳过空的datablock,函数实现如下: while (data_iter_.iter() == NULL|| !data_iter_.Valid()) { // 跳到前一个block if (!index_iter_.Valid()) { // 如果index iter非法,设置data iteration为NULL SetDataIterator(NULL); return; } index_iter_.Prev(); InitDataBlock(); if (data_iter_.iter() != NULL)data_iter_.SeekToLast(); // 跳转到开始 } S3 了解了几个跳转的辅助函数,再来看Seek系接口。 void TwoLevelIterator::Seek(const Slice& target) { index_iter_.Seek(target); InitDataBlock(); // 根据index iter设置data iter if (data_iter_.iter() != NULL)data_iter_.Seek(target); // 调整data iter跳转到target SkipEmptyDataBlocksForward(); // 调整iter,跳过空的block } void TwoLevelIterator::SeekToFirst() { index_iter_.SeekToFirst(); InitDataBlock(); // 根据index iter设置data iter if (data_iter_.iter() != NULL)data_iter_.SeekToFirst(); SkipEmptyDataBlocksForward(); // 调整iter,跳过空的block } void TwoLevelIterator::SeekToLast() { index_iter_.SeekToLast(); InitDataBlock(); // 根据index iter设置data iter if (data_iter_.iter() != NULL)data_iter_.SeekToLast(); SkipEmptyDataBlocksBackward();// 调整iter,跳过空的block } void TwoLevelIterator::Next() { assert(Valid()); data_iter_.Next(); SkipEmptyDataBlocksForward(); // 调整iter,跳过空的block } void TwoLevelIterator::Prev() { assert(Valid()); data_iter_.Prev(); SkipEmptyDataBlocksBackward();// 调整iter,跳过空的block } 6.6.3 BlockReader() 上面传递给twolevel Iterator的函数是Table::BlockReader函数,声明如下: static Iterator* Table::BlockReader(void* arg, const ReadOptions&options, constSlice& index_value); 它根据参数指明的blockdata,返回一个iterator对象,调用者就可以通过这个iterator对象遍历blockdata存储的k/v对,这其中用到了LRUCache。 函数实现逻辑如下: S1 从参数中解析出BlockHandle对象,其中arg就是Table对象,index_value存储的是BlockHandle对象,读取Block的索引。 Table* table =reinterpret_cast(arg); Block* block = NULL; Cache::Handle* cache_handle =NULL; BlockHandle handle; Slice input = index_value; Status s =handle.DecodeFrom(&input); S2 根据block handle,首先尝试从cache中直接取出block,不在cache中则调用ReadBlock从文件读取,读取成功后,根据option尝试将block加入到LRU cache中。并在Insert的时候注册了释放函数DeleteCachedBlock。 Cache* block_cache =table->rep_->options.block_cache; BlockContents contents; if (block_cache != NULL) { char cache_key_buffer[16]; // cache key的格式为table.cache_id + offset EncodeFixed64(cache_key_buffer, table->rep_->cache_id); EncodeFixed64(cache_key_buffer+8, handle.offset()); Slice key(cache_key_buffer,sizeof(cache_key_buffer)); cache_handle =block_cache->Lookup(key); // 尝试从LRU cache中查找 if (cache_handle != NULL) { // 找到则直接取值 block =reinterpret_cast(block_cache->Value(cache_handle)); } else { // 否则直接从文件读取 s =ReadBlock(table->rep_->file, options, handle, &contents); if (s.ok()) { block = new Block(contents); if (contents.cachable&& options.fill_cache) // 尝试加到cache中 cache_handle =block_cache->Insert(key, block,block->size(), &DeleteCachedBlock); } } } else { s =ReadBlock(table->rep_->file, options, handle, &contents); if (s.ok()) block = newBlock(contents); } S3 如果读取到了block,调用Block::NewIterator接口创建Iterator,如果cache handle为NULL,则注册DeleteBlock,否则注册ReleaseBlock,事后清理。 Iterator* iter; if (block != NULL) { iter =block->NewIterator(table->rep_->options.comparator); if (cache_handle == NULL) iter->RegisterCleanup(&DeleteBlock,block, NULL); else iter->RegisterCleanup(&ReleaseBlock,block_cache, cache_handle); } else iter = NewErrorIterator(s); 处理结束,最后返回iter。这里简单列下这几个静态函数,都很简单: static void DeleteBlock(void* arg, void* ignored) { deletereinterpret_cast(arg); } static void DeleteCachedBlock(const Slice& key, void* value) { Block* block =reinterpret_cast(value); delete block; } static void ReleaseBlock(void* arg, void* h) { Cache* cache =reinterpret_cast(arg); Cache::Handle* handle =reinterpret_cast(h); cache->Release(handle); } 6.7 定位key 这里并不是精确的定位,而是在Table中找到第一个>=指定key的k/v对,然后返回其value在sstable文件中的偏移。也是Table类的一个接口: uint64_t ApproximateOffsetOf(const Slice& key) const; 函数实现比较简单: S1 调用Block::Iter的Seek函数定位 Iterator* index_iter=rep_->index_block->NewIterator(rep_->options.comparator); index_iter->Seek(key); uint64_t result; S2 如果index_iter是合法的值,并且Decode成功,返回结果offset。 BlockHandle handle; handle.DecodeFrom(&index_iter->value()); result = handle.offset(); S3 其它情况,设置result为rep_->metaindex_handle.offset(),metaindex的偏移在文件结尾附近。 6.8 获取Key—InternalGet() InternalGet,这是为TableCache开的一个口子。这是一个private函数,声明为: Status Table::InternalGet(const ReadOptions& options, constSlice& k, void*arg, void (*saver)(void*, const Slice&, const Slice&)) 其中又有函数指针,在找到数据后,就调用传入的函数指针saver执行调用者的自定义处理逻辑,并且TableCache可能会做缓存。 函数逻辑如下: S1 首先根据传入的key定位数据,这需要indexblock的Iterator。 Iterator* iiter =rep_->index_block->NewIterator(rep_->options.comparator); iiter->Seek(k); S2 如果key是合法的,取出其filter指针,如果使用了filter,则检查key是否存在,这可以快速判断,提升效率。 Status s; Slice handle_value =iiter->value(); FilterBlockReader* filter = rep_->filter; BlockHandle handle; if (filter != NULL && handle.DecodeFrom(&handle_value).ok() && !filter->KeyMayMatch(handle.offset(),k)) { // key不存在 } else { // 否则就要读取block,并查找其k/v对 Slice handle = iiter->value(); Iterator* block_iter =BlockReader(this, options, iiter->value()); block_iter->Seek(k); if (block_iter->Valid())(*saver)(arg, block_iter->key(), block_iter->value()); s = block_iter->status(); delete block_iter; } S3 最后返回结果,删除临时变量。 if (s.ok()) s =iiter->status(); delete iiter; return s; 随着有关sstable文件读取的结束,sstable的源码也就分析完了,其中我们还遗漏了一些功课要做,那就是Filter和TableCache部分。 如需下载本站全部技术文章,可以在【高性能服务器开发】公众号回复关键字“文章下载”即可。最近更新时间: 2020-10-03 12:48:41 "},"articles/leveldb源码分析/leveldb源码分析11.html":{"url":"articles/leveldb源码分析/leveldb源码分析11.html","title":"leveldb源码分析11","keywords":"","body":"leveldb源码分析11 本系列《leveldb源码分析》共有22篇文章,这是第十一篇 7.TableCache 这章的内容比较简单,篇幅也不长。 7.1 TableCache简介 TableCache缓存的是Table对象,每个DB一个,它内部使用一个LRUCache缓存所有的table对象,实际上其内容是文件编号{file number, TableAndFile}。TableAndFile是一个拥有2个变量的结构体:RandomAccessFile和Table*; TableCache类的主要成员变量有: Env* const env_; // 用来操作文件 const std::string dbname_; // db名 Cache* cache_; // LRUCache 三个函数接口,其中的参数@file_number是文件编号,@file_size是文件大小: void Evict(uint64_tfile_number); // 该函数用以清除指定文件所有cache的entry, //函数实现很简单,就是根据file number清除cache对象。 EncodeFixed64(buf,file_number); cache_->Erase(Slice(buf, sizeof(buf))); Iterator* NewIterator(constReadOptions& options, uint64_t file_number, uint64_t file_size, Table**tableptr = NULL); //该函数为指定的file返回一个iterator(对应的文件长度必须是\"file_size\"字节). //如果tableptr不是NULL,那么tableptr保存的是底层的Table指针。 //返回的tableptr是cache拥有的,不能被删除,生命周期同返回的iterator Status Get(constReadOptions& options, uint64_t file_number,uint64_t file_size, const Slice& k,void* arg, void(*handle_result)(void*, const Slice&, const Slice&)); // 这是一个查找函数,如果在指定文件中seek 到internal key \"k\" 找到一个entry, //就调用 (*handle_result)(arg,found_key, found_value). 7.2 TableCache::Get() 先来看看Get接口,只有几行代码: Cache::Handle* handle = NULL; Status s =FindTable(file_number, file_size, &handle); if (s.ok()) { Table* t =reinterpret_cast(cache_->Value(handle))->table; s = t->InternalGet(options,k, arg, saver); cache_->Release(handle); } return s; 首先根据file_number找到Table的cache对象,如果找到了就调用Table::InternalGet,对查找结果的处理在调用者传入的saver回调函数中。 Cache在Lookup找到cache对象后,如果不再使用需要调用Release减引用计数。这个见Cache的接口说明。 7.3 TableCache遍历 函数NewIterator(),返回一个可以遍历Table对象的Iterator指针,函数逻辑: S1 初始化tableptr,调用FindTable,返回cache对象 if (tableptr != NULL) *tableptr =NULL; Cache::Handle* handle = NULL; Status s =FindTable(file_number, file_size, &handle); if (!s.ok()) returnNewErrorIterator(s); S2 从cache对象中取出Table对象指针,调用其NewIterator返回Iterator对象,并为Iterator注册一个cleanup函数。 Table* table =reinterpret_cast(cache_->Value(handle))->table; Iterator* result =table->NewIterator(options); result->RegisterCleanup(&UnrefEntry, cache_, handle); if (tableptr != NULL) *tableptr= table; return result; 7.4 TableCache::FindTable() 前面的遍历和Get函数都依赖于FindTable这个私有函数完成对cache的查找,下面就来看看该函数的逻辑。函数声明为: Status FindTable(uint64_t file_number, uint64_t file_size, Cache::Handle** handle) 函数流程为: S1 首先根据file number从cache中查找table,找到就直接返回成功。 char buf[sizeof(file_number)]; EncodeFixed64(buf, file_number); Slice key(buf, sizeof(buf)); *handle = cache_->Lookup(key); S2 如果没有找到,说明table不在cache中,则根据file number和db name打开一个RadomAccessFile。Table文件格式为:..sst。如果文件打开成功,则调用Table::Open读取sstable文件。 std::string fname =TableFileName(dbname_, file_number); RandomAccessFile* file = NULL; Table* table = NULL; s =env_->NewRandomAccessFile(fname, &file); if (s.ok()) s =Table::Open(*options_, file, file_size, &table); S3 如果Table::Open成功则,插入到Cache中。 TableAndFile* tf = newTableAndFile(table, file); *handle = cache_->Insert(key,tf, 1, &DeleteEntry); 如果失败,则删除file,直接返回失败,失败的结果是不会cache的。 7.5 辅助函数 有点啰嗦,不过还是写一下吧。其中一个是为LRUCache注册的删除函数DeleteEntry。 static void DeleteEntry(const Slice& key, void* value) { TableAndFile* tf =reinterpret_cast(value); delete tf->table; delete tf->file; delete tf; } 另外一个是为Iterator注册的清除函数UnrefEntry。 static void UnrefEntry(void* arg1, void* arg2) { Cache* cache =reinterpret_cast(arg1); Cache::Handle* h =reinterpret_cast(arg2); cache->Release(h); } 如需下载本站全部技术文章,可以在【高性能服务器开发】公众号回复关键字“文章下载”即可。最近更新时间: 2020-10-03 12:50:11 "},"articles/leveldb源码分析/leveldb源码分析12.html":{"url":"articles/leveldb源码分析/leveldb源码分析12.html","title":"leveldb源码分析12","keywords":"","body":"leveldb源码分析12 本系列《leveldb源码分析》共有22篇文章,这是第十二篇 8.FilterPolicy&Bloom之1 8.1 FilterPolicy 因名知意,FilterPolicy是用于key过滤的,可以快速的排除不存在的key。前面介绍Table的时候,在Table::InternalGet函数中有过一面之缘。 FilterPolicy有3个接口: virtual const char* Name() const = 0; // 返回filter的名字 virtual void CreateFilter(const Slice* keys, int n, std::string* dst)const = 0; virtual bool KeyMayMatch(const Slice& key, const Slice& filter)const = 0; CreateFilter接口,它根据指定的参数创建过滤器,并将结果append到dst中,注意:不能修改dst的原始内容,只做append。 参数@keys[0,n-1]包含依据用户提供的comparator排序的key列表--可重复,并把根据这些key创建的filter追加到@dst中。 KeyMayMatch,参数@filter包含了调用CreateFilter函数append的数据,如果key在传递函数CreateFilter的key列表中,则必须返回*true。 注意:它不需要精确,也就是即使key不在前面传递的key列表中,也可以返回true,但是如果key在列表中,就必须返回true。 涉及到的类如图8.1-1所示。 8.2InternalFilterPolicy 这是一个简单的FilterPolicy的wrapper,以方便的把FilterPolicy应用在InternalKey上,InternalKey是Leveldb内部使用的key,这些前面都讲过。它所做的就是从InternalKey拆分得到user key,然后在user key上做FilterPolicy的操作。 它有一个成员: constFilterPolicy* const user_policy_; 其Name()返回的是userpolicy->Name(); bool InternalFilterPolicy::KeyMayMatch(const Slice& key, constSlice& f) const { returnuser_policy_->KeyMayMatch(ExtractUserKey(key), f); } void InternalFilterPolicy::CreateFilter(const Slice* keys, int n,std::string* dst) const { Slice* mkey =const_cast(keys); for (int i = 0; i CreateFilter(keys, n, dst); } 8.3 BloomFilter 8.3.1 基本理论 Bloom Filter实际上是一种hash算法,数学之美系列有专门介绍。它是由巴顿.布隆于一九七零年提出的,它实际上是一个很长的二进制向量和一系列随机映射函数。 Bloom Filter将元素映射到一个长度为m的bit向量上的一个bit,当这个bit是1时,就表示这个元素在集合内。使用hash的缺点就是元素很多时可能有冲突,为了减少误判,就使用k个hash函数计算出k个bit,只要有一个bit为0,就说明元素肯定不在集合内。下面的图8.3-1是一个示意图。 在leveldb的实现中,Name()返回\"leveldb.BuiltinBloomFilter\",因此metaindex block 中的key就是”filter.leveldb.BuiltinBloomFilter”。Leveldb使用了double hashing来模拟多个hash函数,当然这里不是用来解决冲突的。 和线性再探测(linearprobing)一样,Double hashing从一个hash值开始,重复向前迭代,直到解决冲突或者搜索完hash表。不同的是,double hashing使用的是另外一个hash函数,而不是固定的步长。 给定两个独立的hash函数h1和h2,对于hash表T和值k,第i次迭代计算出的位置就是:h(i, k) = (h1(k) + i*h2(k)) mod |T|。** 对此,Leveldb选择的hash函数是: Gi(x)=H1(x)+iH2(x) H2(x)=(H1(x)>>17) | (H1(x)H1是一个基本的hash函数,H2是由H1循环右移得到的,Gi(x)就是第i次循环得到的hash值。【理论分析可参考论文Kirsch,Mitzenmacher2006】 在bloom_filter的数据的最后一个字节存放的是k的值,k实际上就是G(x)的个数,也就是计算时采用的hash函数个数。 8.3.2 BloomFilter参数 这里先来说下其两个成员变量:bitsper_key和key_;其实这就是Bloom Hashing的两个关键参数。 变量k_实际上就是模拟的hash函数的个数; 关于变量bitsper_key,对于n个key,其hash table的大小就是bitsper_key\\。它的值越大,发生冲突的概率就越低,那么bloom hashing误判的概率就越低。因此这是一个时间空间的trade-off。 对于hash(key),在平均意义上,发生冲突的概率就是1/ bitsper_key。 它们在构造函数中根据传入的参数bits_per_key初始化。 bits_per_key_ = bits_per_key; k_ =static_cast(bits_per_key * 0.69); // 0.69 =~ ln(2) if (k_ 30) k_ = 30; 模拟hash函数的个数k取值为**bits_per_key*ln(2)**,为何不是0.5或者0.4了,可能是什么理论推导的结果吧,不了解了。 8.3.3 建立BloomFilter 了解了上面的理论,再来看leveldb对Bloom Fil**ter的实现就轻松多了,先来看Bloom Filter的构建。这就是FilterPolicy::CreateFilter接口的实现**: void CreateFilter(const Slice* keys, int n, std::string* dst) const 下面分析其实现代码,大概有如下几个步骤: S1 首先根据key个数分配filter空间,并圆整到8byte。 size_t bits = n * bits_per_key_; if (bits size(); dst->resize(init_size +bytes, 0); // 分配空间 S2 在filter最后的字节位压入hash函数个数 dst->push_back(static_cast(k_)); // Remember # of probes in filter S3 对于每个key,使用double-hashing生产一系列的hash值h(K_个),设置bits array的第h位=1。 char* array =&(*dst)[init_size]; for (size_t i = 0; i > 17) | (h Bloom Filter的创建就完成了。 8.3.4 查找BloomFilter 在指定的filer中查找key是否存在,这就是bloom filter的查找函数: bool KeyMayMatch(const Slice& key, const Slice& bloom_filter),函数逻辑如下: S1 准备工作,并做些基本判断。 const size_t len =bloom_filter.size(); if (len 30) return true; // 为短bloom filter保留,当前认为直接match S2 计算key的hash值,重复计算阶段的步骤,循环计算k个hash值,只要有一个结果对应的bit位为0,就认为不匹配,否则认为匹配。 uint32_t h = BloomHash(key); const uint32_t delta = (h>> 17) | (h 8.4 Filter Block格式 Filter Block也就是前面sstable中的meta block,位于data block之后。 如果打开db时指定了FilterPolicy,那么每个创建的table都会保存一个filter block,table中的metaindex就包含一条从”filter.到filter block的BlockHandle的映射,其中””是filter policy的Name()函数返回的string。 Filter block存储了一连串的filter值,其中第i个filter保存的是block b中所有的key通过FilterPolicy::CreateFilter()计算得到的结果,block b在sstable文件中的偏移满足[ i*base … (i+1)*base-1 ]。 当前base是2KB,举个例子,如果block X和Y在sstable的起始位置都在[0KB, 2KB-1]中,X和Y中的所有key调用FilterPolicy::CreateFilter()的计算结果都将生产到同一个filter中,而且该filter是filter block的第一个filter。 Filter block也是一个block,其格式遵从block的基本格式:|block data| type | crc32|。其中block dat的格式如图8.4-1所示。 图8.4-1 filter block data 了解了格式,再分析构建和读取filter的代码就很简单了。 如需下载本站全部技术文章,可以在【高性能服务器开发】公众号回复关键字“文章下载”即可。最近更新时间: 2020-10-03 12:55:09 "},"articles/leveldb源码分析/leveldb源码分析13.html":{"url":"articles/leveldb源码分析/leveldb源码分析13.html","title":"leveldb源码分析13","keywords":"","body":"leveldb源码分析13 本系列《leveldb源码分析》共有22篇文章,这是第十三篇 8.FilterPolicy&Bloom之二 8.5 构建FilterBlock 8.5.1 FilterBlockBuilder 了解了filter机制,现在来看看filter block的构建,这就是类FilterBlockBuilder。它为指定的table构建所有的filter,结果是一个string字符串,并作为一个block存放在table中。它有三个函数接口: // 开始构建新的filter block,TableBuilder在构造函数和Flush中调用 void StartBlock(uint64_tblock_offset); // 添加key,TableBuilder每次向data block中加入key时调用 void AddKey(const Slice&key); // 结束构建,TableBuilder在结束对table的构建时调用 Slice Finish(); FilterBlockBuilder的构建顺序必须满足如下范式:(StartBlock AddKey*)* Finish,显然这和前面讲过的BlockBuilder有所不同。 其成员变量有: const FilterPolicy* policy_; // filter类型,构造函数参数指定 std::string keys_; //Flattened key contents std::vector start_; // 各key在keys_中的位置 std::string result_; // 当前计算出的filter data std::vectorfilter_offsets_; // 各个filter在result_中的位置 std::vector tmp_keys_;// policy_->CreateFilter()参数 前面说过base是2KB,这对应两个常量kFilterBase =11, kFilterBase =(1其实从后面的实现来看tmpkeys完全不必作为成员变量,直接作为函数GenerateFilter()的栈变量就可以。下面就分别分析三个函数接口。 8.5.2 FilterBlockBuilder::StartBlock() 它根据参数block_offset计算出filter index,然后循环调用GenerateFilter生产新的Filter。 uint64_t filter_index =(block_offset / kFilterBase); assert(filter_index >=filter_offsets_.size()); while (filter_index >filter_offsets_.size()) GenerateFilter(); 我们来到GenerateFilter这个函数,看看它的逻辑。 //S1 如果filter中key个数为0,则直接压入result_.size()并返回 const size_t num_keys =start_.size(); if (num_keys == 0) { // there are no keys for this filter filter_offsets_.push_back(result_.size()); //result_.size()应该是0 return; } //S2 从key创建临时key list,根据key的序列字符串kyes_和各key在keys_ //中的开始位置start_依次提取出key。 start_.push_back(keys_.size()); // Simplify lengthcomputation tmp_keys_.resize(num_keys); for (size_t i = 0; i CreateFilter(&tmp_keys_[0], num_keys, &result_); //S4 清空,重置状态 tmp_keys_.clear(); keys_.clear(); start_.clear(); 8.5.3 FilterBlockBuilder::AddKey() 这个接口很简单,就是把key添加到key中,并在start中记录位置。 Slice k = key; start_.push_back(keys_.size()); keys_.append(k.data(),k.size()); 8.5.4 FilterBlockBuilder::Finish() 调用这个函数说明整个table的data block已经构建完了,可以生产最终的filter block了,在TableBuilder::Finish函数中被调用,向sstable写入meta block。 函数逻辑为: //S1 如果start_数字不空,把为的key列表生产filter if (!start_.empty()) GenerateFilter(); //S2 从0开始顺序存储各filter的偏移值,见filter block data的数据格式。 const uint32_t array_offset =result_.size(); for (size_t i = 0; i 8.5.5 简单示例 让我们根据TableBuilder对FilterBlockBuilder接口的调用范式: (StartBlock AddKey) Finish以及上面的函数实现,结合一个简单例子看看leveldb是如何为data block创建filter block(也就是meta block)的。 考虑两个datablock,在sstable的范围分别是:Block 1 [0, 7KB-1], Block 2 [7KB, 14.1KB] S1 首先TableBuilder为Block 1调用FilterBlockBuilder::StartBlock(0),该函数直接返回; S2 然后依次向Block 1加入k/v,其中会调用FilterBlockBuilder::AddKey,FilterBlockBuilder记录这些key。 S3 下一次TableBuilder添加k/v时,例行检查发现Block 1的大小超过设置,则执行Flush操作,Flush操作在写入Block 1后,开始准备Block 2并更新block offset=7KB,最后调用FilterBlockBuilder::StartBlock(7KB),开始为Block 2构建Filter。 S4 在FilterBlockBuilder::StartBlock(7KB)中,计算出filter index = 3,触发3次GenerateFilter函数,为Block 1添加的那些key列表创建filter,其中第2、3次循环创建的是空filter。此时filter的结构如图8.5-1所示。 图8.5-1 在StartBlock(7KB)时会向filter的偏移数组filteroffsets压入两个包含空key set的元素,filteroffsets[1]和filteroffsets[2],它们的值都等于7KB-1。 S5Block 2构建结束,TableBuilder调用Finish结束table的构建,这会再次触发Flush操作,在写入Block 2后,为Block 2的key创建filter。最终的filter如图8.5-2所示。 图8.5-2 这里如果Block 1的范围是[0, 1.8KB-1],Block 2从1.8KB开始,那么Block 2将会和Block 1共用一个filter,它们的filter都被生成到filter 0中。 当然在TableBuilder构建表时,Block的大小是根据参数配置的,也是基本均匀的。 8.6 读取FilterBlock 8.6.1 FilterBlockReader FilterBlock的读取操作在FilterBlockReader类中,它的主要功能是根据传入的FilterPolicy和filter,进行key的匹配查找。 它有如下的几个成员变量: const FilterPolicy* policy_; // filter策略 const char* data_; // filter data指针 (at block-start) const char* offset_; // offset array的开始地址 (at block-end) size_t num_; // offsetarray元素个数 size_t base_lg_; // 还记得kFilterBaseLg吗 Filter策略和filter block内容都由构造函数传入。一个接口函数,就是key的批判查找: bool KeyMayMatch(uint64_t block_offset, const Slice& key); 8.6.2 构造 在构造函数中,根据存储格式解析出偏移数组开始指针、个数等信息。 FilterBlockReader::FilterBlockReader(const FilterPolicy* policy, constSlice& contents) : policy_(policy),data_(NULL), offset_(NULL), num_(0), base_lg_(0) { size_t n = contents.size(); if (n n - 5)return; data_ = contents.data(); offset_ = data_ + last_word; // 偏移数组开始指针 num_ = (n - 5 - last_word) / 4; // 计算出filter个数 8.6.3 查找 查找函数传入两个参数 @block_offset是查找data block在sstable中的偏移,Filter根据此偏移计算filter的编号; @key是查找的key。 声明如下: bool FilterBlockReader::KeyMayMatch(uint64_t block_offset, constSlice& key) 它首先计算出filterindex,根据index解析出filter的range,如果是合法的range,就从data_中取出filter,调用policy_做key的匹配查询。函数实现: uint64_t index = block_offset>> base_lg_; // 计算出filter index if (index KeyMayMatch(key, filter); } else if (start == limit) { return false; // 空filter不匹配任何key } } return true; // 当匹配处理 至此,FilterPolicy和Bloom就分析完了。 如需下载本站全部技术文章,可以在【高性能服务器开发】公众号回复关键字“文章下载”即可。最近更新时间: 2020-10-03 12:57:04 "},"articles/leveldb源码分析/leveldb源码分析14.html":{"url":"articles/leveldb源码分析/leveldb源码分析14.html","title":"leveldb源码分析14","keywords":"","body":"leveldb源码分析14 本系列《leveldb源码分析》共有22篇文章,这是第十四篇 9 LevelDB框架之1 到此为止,基本上Leveldb的主要功能组件都已经分析完了,下面就是把它们组合在一起,形成一个高性能的k/v存储系统。这就是leveldb::DB类。 这里先看一下LevelDB的导出接口和涉及的类,后面将依次以接口分析的方式展开。 而实际上leveldb::DB只是一个接口类,真正的实现和框架类是DBImpl这个类,正是它集合了上面的各种组件。 此外,还有Leveldb对版本的控制,执行版本控制的是Version和VersionSet类。 在leveldb的源码中,DBImpl和VersionSet是两个庞然大物,体量基本算是最大的。对于这两个类的分析,也会分散在打开、销毁和快照等等这些功能中,很难在一个地方集中分析。 作者在文档impl.html中描述了leveldb的实现,其中包括文件组织、compaction和recovery等等。下面的9.1和9.2基本都是翻译子impl.html文档。 在进入框架代码之前,先来了解下leveldb的文件组织和管理。 9.1 DB文件管理 9.1.1 文件类型 对于一个数据库Level包含如下的6种文件: 1/[0-9]+.log:db操作日志 这就是前面分析过的操作日志,log文件包含了最新的db更新,每个更新都以append的方式追加到文件结尾。当log文件达到预定大小时(缺省大约4MB),leveldb就把它转换为一个有序表(如下-2),并创建一个新的log文件。 当前的log文件在内存中的存在形式就是memtable,每次read操作都会访问memtable,以保证read读取到的是最新的数据。 2/[0-9]+.sst:db的sstable文件 这两个就是前面分析过的静态sstable文件,sstable存储了以key排序的元素。每个元素或者是key对应的value,或者是key的删除标记(删除标记可以掩盖更老sstable文件中过期的value)。 Leveldb把sstable文件通过level的方式组织起来,从log文件中生成的sstable被放在level 0。当level 0的sstable文件个数超过设置(当前为4个)时,leveldb就把所有的level 0文件,以及有重合的level 1文件merge起来,组织成一个新的level 1文件(每个level 1文件大小为2MB)。 Level 0的SSTable文件(后缀为.sst)和Level>1的文件相比有特殊性:这个层级内的.sst文件,两个文件可能存在key重叠。对于Level>0,同层sstable文件的key不会重叠。考虑level>0,level中的文件的总大小超过10^level MB时(如level=1是10MB,level=2是100MB),那么level中的一个文件,以及所有level+1中和它有重叠的文件,会被merge到level+1层的一系列新文件。Merge操作的作用是将更新从低一级level迁移到最高级,只使用批量读写(最小化seek操作,提高效率)。 3/MANIFEST-[0-9]+:DB元信息文件 它记录的是leveldb的元信息,比如DB使用的Comparator名,以及各SSTable文件的管理信息:如Level层数、文件名、最小key和最大key等等。 4/CURRENT:记录当前正在使用的Manifest文件 它的内容就是当前的manifest文件名;因为在LevleDb的运行过程中,随着Compaction的进行,新的SSTable文件被产生,老的文件被废弃。并生成新的Manifest文件来记载sstable的变动,而CURRENT则用来记录我们关心的Manifest文件。 当db被重新打开时,leveldb总是生产一个新的manifest文件。Manifest文件使用log的格式,对服务状态的改变(新加或删除的文件)都会追加到该log中。 上面的log文件、sst文件、清单文件,末尾都带着序列号,其序号都是单调递增的(随着next_file_number从1开始递增),以保证不和之前的文件名重复。 5/log:系统的运行日志,记录系统的运行信息或者错误日志。 6/dbtmp:临时数据库文件,repair时临时生成的。 这里就涉及到几个关键的number计数器,log文件编号,下一个文件(sstable、log和manifest)编号,sequence。 所有正在使用的文件编号,包括log、sstable和manifest都应该小于下一个文件编号计数器。 9.1.2 Level 0 当操作log超过一定大小时(缺省是1MB),执行如下操作: S1 创建新的memtable和log文件,并重导向新的更新到新memtable和log中; S2 在后台: S2.1 将前一个memtable的内容dump到sstable文件; S2.2 丢弃前一个memtable; S2.3 删除旧的log文件和memtable S2.4 把创建的sstable文件放到level 0 9.2 Compaction 当level L的总文件大小查过限制时,我们就在后台执行compaction操作。Compaction操作从level L中选择一个文件f,以及选择中所有和f有重叠的文件。如果某个level (L+1)的文件ff只是和f部分重合,compaction依然选择ff的完整内容作为输入,在compaction后f和ff都会被丢弃。 另外:因为level 0有些特殊(同层文件可能有重合),从level 0到level 1的compaction就需要特殊对待:level 0的compaction可能会选择多个level 0文件,如果它们之间有重叠。 Compaction将选择的文件内容merge起来,并生成到一系列的level (L+1)文件中,如果输出文件超过设置(2MB),就切换到新的。当输出文件的key范围太大以至于和超过10个level (L+2)文件有重合时,也会切换。后一个规则确保了level (L+1)的文件不会和过多的level (L+2)文件有重合,其后的level (L+1) compaction不会选择过多的level (L+2)文件。 老的文件会被丢弃,新创建的文件将加入到server状态中。 Compaction操作在key空间中循环执行,详细讲一点就是,对于每个level,我们记录上次compaction的ending key。Level的下一次compaction将选择ending key之后的第一个文件(如果这样的文件不存在,将会跳到key空间的开始)。 Compaction会忽略被写覆盖的值,如果更高一层的level没有文件的范围包含了这个key,key的删除标记也会被忽略。 9.2.1 时间 Level 0的compaction最多从level 0读取4个1MB的文件,以及所有的level 1文件(10MB),也就是我们将读取14MB,并写入14BM。 Level > 0的compaction,从level L选择一个2MB的文件,最坏情况下,将会和levelL+1的12个文件有重合(10:level L+1的总文件大小是level L的10倍;边界的2:level L的文件范围通常不会和level L+1的文件对齐)。因此Compaction将会读26MB,写26MB。对于100MB/s的磁盘IO来讲,compaction将最坏需要0.5秒。 如果磁盘IO更低,比如10MB/s,那么compaction就需要更长的时间5秒。如果user以10MB/s的速度写入,我们可能生成很多level 0文件(50个来装载5*10MB的数据)。这将会严重影响读取效率,因为需要merge更多的文件。 解决方法1:为了降低该问题,我们可能想增加log切换的阈值,缺点就是,log文件越大,对应的memtable文件就越大,这需要更多的内存。 解决方法2:当level 0文件太多时,人工降低写入速度。 解决方法3:降低merge的开销,如把level 0文件都无压缩的存放在cache中。 9.2.2 文件数 对于更高的level我们可以创建更大的文件,而不是2MB,代价就是更多突发性的compaction。或者,我们可以考虑分区,把文件放存放多目录中。 在2011年2月4号,作者做了一个实验,在ext3文件系统中打开100KB的文件,结果表明可以不需要分区。 文件数 文件打开ms 1000 9 10000 10 100000 16 9.3 Recovery & GC 9.3.1 Recovery Db恢复的步骤: S1 首先从CURRENT读取最后提交的MANIFEST S2 读取MANIFEST内容 S3 清除过期文件 S4 这里可以打开所有的sstable文件,但是更好的方案是lazy open S5 把log转换为新的level 0sstable S6 将新写操作导向到新的log文件,从恢复的序号开始 9.3.2 GC 垃圾回收,每次compaction和recovery之后都会有文件被废弃,成为垃圾文件。GC就是删除这些文件的,它在每次compaction和recovery完成之后被调用。 如需下载本站全部技术文章,可以在【高性能服务器开发】公众号回复关键字“文章下载”即可。最近更新时间: 2020-10-03 13:25:38 "},"articles/leveldb源码分析/leveldb源码分析15.html":{"url":"articles/leveldb源码分析/leveldb源码分析15.html","title":"leveldb源码分析15","keywords":"","body":"leveldb源码分析15 本系列《leveldb源码分析》共有22篇文章,这是第十五篇 9 LevelDB框架之2 9.4 版本控制 当执行一次compaction后,Leveldb将在当前版本基础上创建一个新版本,当前版本就变成了历史版本。还有,如果你创建了一个Iterator,那么该Iterator所依附的版本将不会被leveldb删除。 在leveldb中,Version就代表了一个版本,它包括当前磁盘及内存中的所有文件信息。在所有的version中,只有一个是CURRENT。 VersionSet是所有Version的集合,这是个version的管理机构。 前面讲过的VersionEdit记录了Version之间的变化,相当于delta增量,表示又增加了多少文件,删除了文件。也就是说:Version0 + VersionEdit --> Version1。 每次文件有变动时,leveldb就把变动记录到一个VersionEdit变量中,然后通过VersionEdit把变动应用到current version上,并把current version的快照,也就是db元信息保存到MANIFEST文件中。 另外,MANIFEST文件组织是以VersionEdit的形式写入的,它本身是一个log文件格式,采用log::Writer/Reader的方式读写,一个VersionEdit就是一条log record。 9.4.1 VersionSet 和DBImpl一样,下面就初识一下Version和VersionSet。 先来看看Version的成员: std::vectorfiles_[config::kNumLevels]; // sstable文件列表 // Next fileto compact based on seek stats. 下一个要compact的文件 FileMetaData* file_to_compact_; int file_to_compact_level_; // 下一个应该compact的level和compaction分数. // 分数 可见一个Version就是一个sstable文件集合,以及它管理的compact状态。Version通过Version prev和next指针构成了一个Version双向循环链表,表头指针则在VersionSet中(初始都指向自己)。 下面是VersionSet的成员。可见它除了通过Version管理所有的sstable文件外,还关心manifest文件信息,以及控制log文件等编号。 //=== 第一组,直接来自于DBImple,构造函数传入 Env* const env_; // 操作系统封装 const std::string dbname_; const Options* const options_; TableCache* const table_cache_; // table cache const InternalKeyComparatoricmp_; //=== 第二组,db元信息相关 uint64_t next_file_number_; // log文件编号 uint64_t manifest_file_number_; // manifest文件编号 uint64_t last_sequence_; uint64_t log_number_; // log编号 uint64_t prev_log_number_; // 0 or backingstore for memtable being compacted //=== 第三组,menifest文件相关 WritableFile* descriptor_file_; log::Writer* descriptor_log_; //=== 第四组,版本管理 Version dummy_versions_; // versions双向链表head. Version* current_; // ==dummy_versions_.prev_ // level下一次compaction的开始key,空字符串或者合法的InternalKey std::stringcompact_pointer_[config::kNumLevels]; 关于版本控制大概了解其Version和VersionEdit的功能和管理范围,详细的函数操作在后面再慢慢揭开。 9.4.2 VersionEdit LevelDB中对Manifest的Decode/Encode是通过类VersionEdit完成的,Menifest文件保存了LevelDB的管理元信息。VersionEdit这个名字起的蛮有意思,每一次compaction,都好比是生成了一个新的DB版本,对应的Menifest则保存着这个版本的DB元信息。VersionEdit并不操作文件,只是为Manifest文件读写准备好数据、从读取的数据中解析出DB元信息。 VersionEdit有两个作用: 1 当版本间有增量变动时,VersionEdit记录了这种变动; 2 写入到MANIFEST时,先将current version的db元信息保存到一个VersionEdit中,然后在组织成一个log record写入文件; 了解了VersionEdit的作用,来看看这个类导出的函数接口: void Clear(); // 清空信息 void Setxxx(); // 一系列的Set函数,设置信息 // 添加sstable文件信息,要求:DB元信息还没有写入磁盘Manifest文件 // @level:.sst文件层次;@file 文件编号-用作文件名 @size 文件大小 // @smallest, @largest:sst文件包含k/v对的最大最小key void AddFile(int level, uint64_t file, uint64_t file_size, constInternalKey& smallest, const InternalKey& largest); void DeleteFile(int level, uint64_t file); // 从指定的level删除文件 void EncodeTo(std::string* dst) const; // 将信息Encode到一个string中 Status DecodeFrom(const Slice& src); // 从Slice中Decode出DB元信息 //===================下面是成员变量,由此可大概窥得DB元信息的内容。 typedef std::set > DeletedFileSet; std::string comparator_; // key comparator名字 uint64_t log_number_; // 日志编号 uint64_t prev_log_number_; // 前一个日志编号 uint64_t next_file_number_; // 下一个文件编号 SequenceNumber last_sequence_; // 上一个seq bool has_comparator_; // 是否有comparator bool has_log_number_;// 是否有log_number_ bool has_prev_log_number_;// 是否有prev_log_number_ bool has_next_file_number_;// 是否有next_file_number_ bool has_last_sequence_;// 是否有last_sequence_ std::vector >compact_pointers_; // compact点 DeletedFileSet deleted_files_; // 删除文件集合 std::vector > new_files_; // 新文件集合 Set系列的函数都很简单,就是根据参数设置相应的信息。 AddFile函数就是根据参数生产一个FileMetaData对象,把sstable文件信息添加到newfiles数组中。 DeleteFile函数则是把参数指定的文件添加到deleted_files中; SetCompactPointer函数把{level, key}指定的compact点加入到compact_pointers_中。 执行序列化和发序列化的是Decode和Encode函数,根据这些代码,我们可以了解Manifest文件的存储格式。序列化函数逻辑都很直观,不详细说了。 9.4.3 Manifest文件格式 前面说过Manifest文件记录了leveldb的管理元信息,这些元信息到底都包含哪些内容呢?下面就来一一列示。 首先是使用的coparator名、log编号、前一个log编号、下一个文件编号、上一个序列号。这些都是日志、sstable文件使用到的重要信息,这些字段不一定必然存在。 Leveldb在写入每个字段之前,都会先写入一个varint型数字来标记后面的字段类型。在读取时,先读取此字段,根据类型解析后面的信息。一共有9种类型: kComparator = 1, kLogNumber = 2, kNextFileNumber = 3, kLastSequence = 4, kCompactPointer = 5, kDeletedFile = 6, kNewFile = 7, kPrevLogNumber = 9 // 8 was used for large value refs 其中8另有它用。 其次是compact点,可能有多个,写入格式为{kCompactPointer, level, internal key}。 其后是删除文件,可能有多个,格式为{kDeletedFile, level, file number}。 最后是新文件,可能有多个,格式为 {kNewFile, level, file number, file size, min key, max key}。 对于版本间变动它是新加的文件集合,对于MANIFEST快照是该版本包含的所有sstable文件集合。 一张图表示一下,如图9.3-1所示。 其中的数字都是varint存储格式,string都是以varint指明其长度,后面跟实际的字符串内容。 9.5 DB接口 9.5.1 接口函数 除了DB类, leveldb还导出了C语言风格的接口:接口和实现在c.h&c.cc,它其实是对leveldb::DB的一层封装。 DB是一个持久化的有序map{key, value},它是线程安全的。DB只是一个虚基类,下面来看看其接口: 首先是一个静态函数,打开一个db,成功返回OK,打开的db指针保存在dbptr中,用完后,调用者需要调用`delete dbptr`删除之。 1static Status Open(const Options& options, const std::string&name, DB** dbptr); 下面几个是纯虚函数,最后还有两个全局函数,为何不像Open一样作为静态函数呢。 注:在几个更新接口中,可考虑设置options.sync = true。另外,虽然是纯虚函数,但是leveldb还是提供了缺省的实现。 // 设置db项{key, value} virtual Status Put(const WriteOptions& options, const Slice&key, const Slice& value) = 0; // 在db中删除\"key\",key不存在依然返回成功 virtual Status Delete(const WriteOptions& options, const Slice&key) = 0; // 更新操作 virtual Status Write(const WriteOptions& options, WriteBatch*updates) = 0; // 获取操作,如果db中有”key”项则返回结果,没有就返回Status::IsNotFound() virtual Status Get(const ReadOptions& options, const Slice& key,std::string* value) = 0; // 返回heap分配的iterator,访问db的内容,返回的iterator的位置是invalid的 // 在使用之前,调用者必须先调用Seek。 virtual Iterator* NewIterator(const ReadOptions& options) = 0; // 返回当前db状态的handle,和handle一起创建的Iterator看到的都是 // 当前db状态的稳定快照。不再使用时,应该调用ReleaseSnapshot(result) virtual const Snapshot* GetSnapshot() = 0; // 释放获取的db快照 virtual voidReleaseSnapshot(const Snapshot* snapshot) = 0; // 借此方法DB实现可以展现它们的属性状态. 如果\"property\" 是合法的, // 设置\"*value\"为属性的当前状态值并返回true,否则返回false. // 合法属性名包括: // // >\"leveldb.num-files-at-level\"– 返回level 的文件个数, // 是level 数的ASCII 值 (e.g. \"0\"). // >\"leveldb.stats\" – 返回描述db内部操作统计的多行string // >\"leveldb.sstables\" – 返回一个多行string,描述构成db内容的所有sstable virtual bool GetProperty(constSlice& property, std::string* value) = 0; //\"sizes[i]\"保存的是\"[range[i].start.. range[i].limit)\"中的key使用的文件空间. // 注:返回的是文件系统的使用空间大概值, // 如果用户数据以10倍压缩,那么返回值就是对应用户数据的1/10 // 结果可能不包含最近写入的数据大小. virtual voidGetApproximateSizes(const Range* range, int n, uint64_t* sizes) = 0; // Compactkey范围[*begin,*end]的底层存储,删除和被覆盖的版本将会被抛弃 // 数据会被重新组织,以减少访问开销 // 注:那些不了解底层实现的用户不应该调用该方法。 //begin==NULL被当作db中所有key之前的key. //end==NULL被当作db中所有key之后的key. // 所以下面的调用将会compact整个db: // db->CompactRange(NULL, NULL); virtual void CompactRange(constSlice* begin, const Slice* end) = 0; // 最后是两个全局函数--删除和修复DB // 要小心,该方法将删除指定db的所有内容 Status DestroyDB(const std::string& name, const Options&options); // 如果db不能打开了,你可能调用该方法尝试纠正尽可能多的数据 // 可能会丢失数据,所以调用时要小心 Status RepairDB(const std::string& dbname, const Options&options); 9.5.2 类图 这里又会设计到几个功能类,如图9.5-1所示。此外还有前面我们讲过的几大组件:操作日志的读写类、内存MemTable类、InternalFilterPolicy类、Internal Key比较类、以及sstable的读取构建类。如图9.5-2所示。 图9.5-1 图9.5-2 这里涉及的类很多,snapshot是内存快照,Version和VersionSet类。 9.6 DBImpl类 在向下继续之前,有必要先了解下DBImpl这个具体的实现类。主要是它的成员变量,这说明了它都利用了哪些组件。 整篇代码里面,这算是一个庞然大物了。现在只是先打第一个照面吧,后面的路还很长,先来看看类成员。 //== 第一组,他们在构造函数中初始化后将不再改变。其中,InternalKeyComparator和InternalFilterPolicy已经分别在Memtable和FilterPolicy中分析过。 Env* const env_; // 环境,封装了系统相关的文件操作、线程等等 const InternalKeyComparatorinternal_comparator_; // key comparator const InternalFilterPolicyinternal_filter_policy_; // filter policy const Options options_; //options_.comparator == &internal_comparator_ bool owns_info_log_; bool owns_cache_; const std::string dbname_; //== 第二组,只有两个。 TableCache* table_cache_; // Table cache,线程安全的 FileLock* db_lock_;// 锁db文件,persistent state,直到leveldb进程结束 //== 第三组,被mutex_包含的状态和成员 port::Mutex mutex_; // 互斥锁 port::AtomicPointershutting_down_; port::CondVar bg_cv_; // 在background work结束时激发 MemTable* mem_; MemTable* imm_; // Memtablebeing compacted port::AtomicPointerhas_imm_; // BGthread 用来检查是否是非NULL的imm_ // 这三个是log相关的 WritableFile* logfile_; // log文件 uint64_t logfile_number_; // log文件编号 log::Writer* log_; // log writer //== 第四组,没有规律 std::dequewriters_; // writers队列. WriteBatch* tmp_batch_; SnapshotList snapshots_; //snapshot列表 // Setof table files to protect from deletion because they are // part ofongoing compactions. std::setpending_outputs_; // 待copact的文件列表,保护以防误删 bool bg_compaction_scheduled_; // 是否有后台compaction在调度或者运行? Status bg_error_; // paranoid mode下是否有后台错误? ManualCompaction*manual_compaction_; // 手动compaction信息 CompactionStatsstats_[config::kNumLevels]; // compaction状态 VersionSet* versions_; // 多版本DB文件,又一个庞然大物 如需下载本站全部技术文章,可以在【高性能服务器开发】公众号回复关键字“文章下载”即可。最近更新时间: 2020-10-03 13:29:24 "},"articles/leveldb源码分析/leveldb源码分析16.html":{"url":"articles/leveldb源码分析/leveldb源码分析16.html","title":"leveldb源码分析16","keywords":"","body":"Leveldb源码分析16 本系列《leveldb源码分析》共有22篇文章,这是第十六篇 10.Version分析之一 先来分析leveldb对单版本的sstable文件管理,主要集中在Version类中。前面的10.4节已经说明了Version类的功能和成员,这里分析其函数接口和代码实现。 Version不会修改其管理的sstable文件,只有读取操作。 10.1 Version接口 先来看看Version类的接口函数,接下来再一一分析。 // 追加一系列iterator到 @*iters中, //将在merge到一起时生成该Version的内容 // 要求: Version已经保存了(见VersionSet::SaveTo) void AddIterators(constReadOptions&, std::vector* iters); // 给定@key查找value,如果找到保存在@*val并返回OK。 // 否则返回non-OK,设置@ *stats. // 要求:没有hold lock struct GetStats { FileMetaData* seek_file; int seek_file_level; }; Status Get(constReadOptions&, const LookupKey& key, std::string* val,GetStats* stats); // 把@stats加入到当前状态中,如果需要触发新的compaction返回true // 要求:hold lock bool UpdateStats(constGetStats& stats); void GetOverlappingInputs(intlevel, const InternalKey*begin, // NULL 指在所有key之前 const InternalKey* end, // NULL指在所有key之后 std::vector* inputs); // 如果指定level中的某些文件和[*smallest_user_key,*largest_user_key] //有重合就返回true。 // @smallest_user_key==NULL表示比DB中所有key都小的key. // @largest_user_key==NULL表示比DB中所有key都大的key. bool OverlapInLevel(int level,const Slice*smallest_user_key, const Slice* largest_user_key); // 返回我们应该在哪个level上放置新的memtable compaction, // 该compaction覆盖了范围[smallest_user_key,largest_user_key]. int PickLevelForMemTableOutput(const Slice& smallest_user_key, const Slice& largest_user_key); // 指定level的sstable个数 int NumFiles(int level) const {return files_[level].size(); 10.2 Version::AddIterators() 该函数最终在DB::NewIterators()接口中被调用,调用层次为: DBImpl::NewIterator()->DBImpl::NewInternalIterator()->Version::AddIterators()。 函数功能是为该Version中的所有sstable都创建一个Two Level Iterator,以遍历sstable的内容。 对于level=0级别的sstable文件,直接通过TableCache::NewIterator()接口创建,这会直接载入sstable文件到内存cache中。 对于level>0级别的sstable文件,通过函数NewTwoLevelIterator()创建一个TwoLevelIterator,这就使用了lazy open的机制。 下面来分析函数代码: S1 对于level=0级别的sstable文件,直接装入cache,level0的sstable文件可能有重合,需要merge。 for (size_t i = 0; i push_back(vset_->table_cache_->NewIterator(// versionset::table_cache_ options,files_[0][i]->number, files_[0][i]->file_size)); } S2 对于level>0级别的sstable文件,lazy open机制,它们不会有重叠。 for (int ll = 1; ll push_back(NewConcatenatingIterator(options,level)); } 函数NewConcatenatingIterator()直接返回一个TwoLevelIterator对象: return NewTwoLevelIterator(new LevelFileNumIterator(vset_->icmp_,&files_[level]), &GetFileIterator,vset_->table_cache_, options); 其第一级iterator是一个LevelFileNumIterator 第二级的迭代函数是GetFileIterator 下面就来分别分析之。 GetFileIterator是一个静态函数,很简单,直接返回TableCache::NewIterator()。函数声明为: static Iterator* GetFileIterator(void* arg,const ReadOptions& options, constSlice& file_value) TableCache* cache =reinterpret_cast(arg); if (file_value.size() != 16) { // 错误 return NewErrorIterator(Status::Corruption(\"xxx\")); } else { return cache->NewIterator(options, DecodeFixed64(file_value.data()), // filenumber DecodeFixed64(file_value.data() + 8)); // filesize } 这里的file_value是取自于LevelFileNumIterator的value,它的value()函数把file number和size以Fixed 8byte的方式压缩成一个Slice对象并返回。 10.3 Version::LevelFileNumIterator类 这也是一个继承者Iterator的子类,一个内部Iterator。 给定一个version/level对,生成该level内的文件信息。 对于给定的entry: key()返回的是文件中所包含的最大的key; value()返回的是|file number(8 bytes)|file size(8 bytes)|串; 它的构造函数接受两个参数:InternalKeyComparator&,用于key的比较; vector*,指向version的所有sstable文件列表。 LevelFileNumIterator(const InternalKeyComparator& icmp, const std::vector* flist) : icmp_(icmp), flist_(flist),index_(flist->size()) {} // Marks as invalid 来看看其接口实现,不限啰嗦,全部都列出来。 Valid函数、SeekToxx和Next/Prev函数都很简单,毕竟容器是一个vector。Seek函数调用了FindFile,这个函数后面会分析。 virtual void Seek(constSlice& target) { index_ = FindFile(icmp_, *flist_, target);} virtual void SeekToFirst() {index_ = 0; } virtual void SeekToLast() {index_ = flist_->empty() ? 0 : flist_->size() - 1;} virtual void Next() { assert(Valid()); index_++; } virtual void Prev() { assert(Valid()); if (index_ == 0) index_ =flist_->size(); // Marks as invalid else index_--; } Slice key() const { assert(Valid()); return(*flist_)[index_]->largest.Encode(); // 返回当前sstable包含的largest key } Slice value() const { // 根据|number|size|的格式Fixed int压缩 assert(Valid()); EncodeFixed64(value_buf_,(*flist_)[index_]->number); EncodeFixed64(value_buf_+8,(*flist_)[index_]->file_size); return Slice(value_buf_,sizeof(value_buf_)); } 来看FindFile,这其实是一个二分查找函数,因为传入的sstable文件列表是有序的,因此可以使用二分查找算法。就不再列出代码了。 10.4 Version::Get() 查找函数,直接在DBImpl::Get()中被调用,函数原型为: Status Version::Get(const ReadOptions& options, constLookupKey& k, std::string* value, GetStats* stats) 如果本次Get不止seek了一个文件(仅会发生在level 0的情况),就将搜索的第一个文件保存在stats中。如果stat有数据返回,表明本次读取在搜索到包含key的sstable文件之前,还做了其它无谓的搜索。这个结果将用在UpdateStats()中。 这个函数逻辑还是有些复杂的,来看看代码。 S1 首先,取得必要的信息,初始化几个临时变量 Slice ikey = k.internal_key(); Slice user_key = k.user_key(); const Comparator* ucmp =vset_->icmp_.user_comparator(); Status s; stats->seek_file = NULL; stats->seek_file_level = -1; FileMetaData* last_file_read =NULL; // 在找到>1个文件时,读取时记录上一个 int last_file_read_level = -1; // 这仅发生在level 0的情况下 std::vectortmp; FileMetaData* tmp2; S2 从0开始遍历所有的level,依次查找。因为entry不会跨越level,因此如果在某个level中找到了entry,那么就无需在后面的level中查找了。 for (int level = 0; level 后面的所有逻辑都在for循环体中。 S3 遍历level下的sstable文件列表,搜索,注意对于level=0和>0的sstable文件的处理,由于level 0文件之间的key可能有重叠,因此处理逻辑有别于>0的level。 S3.1 对于level 0,文件可能有重叠,找到所有和user_key有重叠的文件,然后根据时间顺序从最新的文件依次处理。 tmp.reserve(num_files); for (uint32_t i = 0; i Compare(user_key, f->smallest.user_key()) >= 0 && ucmp->Compare(user_key, f->largest.user_key()) S3.2 对于level>0,leveldb保证sstable文件之间不会有重叠,所以处理逻辑有别于level 0,直接根据ikey定位到sstable文件即可。 //二分查找,找到第一个largest key >=ikey的file index uint32_t index =FindFile(vset_->icmp_, files_[level], ikey); if (index >= num_files) { // 未找到,文件不存在 files = NULL; num_files = 0; } else { tmp2 = files[index]; if(ucmp->Compare(user_key, tmp2->smallest.user_key()) S4 遍历找到的文件,存在files中,其个数为num_files。 for (uint32_t i = 0; i 后面的逻辑都在这一层循环中,只要在某个文件中找到了k/v对,就跳出for循环。 S4.1 如果本次读取不止搜索了一个文件,记录之,这仅会发生在level 0的情况下。 if(last_file_read != NULL && stats->seek_file == NULL) { // 本次读取不止seek了一个文件,记录第一个 stats->seek_file =last_file_read; stats->seek_file_level= last_file_read_level; } FileMetaData* f = files[i]; last_file_read = f; // 记录本次读取的level和file last_file_read_level =level; S4.2 调用TableCache::Get()尝试获取{ikey, value},如果返回OK则进入,否则直接返回,传递的回调函数是SaveValue()。 Saver saver; // 初始化saver saver.state = kNotFound; saver.ucmp = ucmp; saver.user_key = user_key; saver.value = value; s = vset_->table_cache_->Get(options,f->number, f->file_size, ikey, &saver, SaveValue); if (!s.ok()) return s; S4.3 根据saver的状态判断,如果是Not Found则向下搜索下一个更早的sstable文件,其它值则返回。 switch (saver.state) { case kNotFound: break; // 继续搜索下一个更早的sstable文件 case kFound: return s; // 找到 case kDeleted: // 已删除 s =Status::NotFound(Slice()); // 为了效率,使用空的错误字符串 return s; case kCorrupt: // 数据损坏 s =Status::Corruption(\"corrupted key for \", user_key); return s; } 以上就是Version::Get()的代码逻辑,如果level 0的sstable文件太多的话,会影响读取速度,这也是为什么进行compaction的原因。 另外,还有一个传递给TableCache::Get()的saver函数,下面就来简单分析下。这是一个静态函数:static void SaveValue(void* arg,const Slice& ikey, const Slice& v)。它内部使用了结构体Saver: struct Saver { SaverState state; const Comparator* ucmp; // user key比较器 Slice user_key; std::string* value; }; 函数SaveValue的逻辑很简单。首先解析Table传入的InternalKey,然后根据指定的Comparator判断user key是否是要查找的user key。如果是并且type是kTypeValue,则设置到Saver::value中,并*返回kFound,否则返回kDeleted。代码如下: Saver* s =reinterpret_cast(arg); ParsedInternalKey parsed_key; // 解析ikey到ParsedInternalKey if (!ParseInternalKey(ikey,&parsed_key)) s->state = kCorrupt; // 解析失败 else { if(s->ucmp->Compare(parsed_key.user_key, s->user_key) == 0) { // 比较user key s->state =(parsed_key.type == kTypeValue) ? kFound : kDeleted; if (s->state == kFound) s->value->assign(v.data(), v.size()); // 找到,保存结果 } } 下面要分析的几个函数,或多或少都和compaction相关。 如需下载本站全部技术文章,可以在【高性能服务器开发】公众号回复关键字“文章下载”即可。最近更新时间: 2020-10-03 13:34:32 "},"articles/leveldb源码分析/leveldb源码分析17.html":{"url":"articles/leveldb源码分析/leveldb源码分析17.html","title":"leveldb源码分析17","keywords":"","body":"leveldb源码分析17 本系列《leveldb源码分析》共有22篇文章,这是第十七篇 10 Version分析之2 10.5 Version::UpdateStats() 当Get操作直接搜寻memtable没有命中时,就需要调用Version::Get()函数从磁盘load数据文件并查找。如果此次Get不止seek了一个文件,就记录第一个文件到stat并返回。其后leveldb就会调用UpdateStats(stat)。 Stat表明在指定key range查找key时,都要先seek此文件,才能在后续的sstable文件中找到key。 该函数是将stat记录的sstable文件的allowed_seeks减1,减到0就执行compaction。也就是说如果文件被seek的次数超过了限制,表明读取效率已经很低,需要执行compaction了。所以说allowed_seeks是对compaction流程的有一个优化。 函数声明:boolVersion::UpdateStats(const GetStats& stats) 函数逻辑很简单: FileMetaData* f =stats.seek_file; if (f != NULL) { f->allowed_seeks--; if (f->allowed_seeks 变量allowed_seeks的值在sstable文件加入到version时确定,也就是后面将遇到的VersionSet::Builder::Apply()函数。 10.6 Version::GetOverlappingInputs() 它在指定level中找出和[begin, end]有重合的sstable文件,函数声明为: void Version::GetOverlappingInputs(int level, const InternalKey* begin, constInternalKey* end, std::vector* inputs); 要注意的是,对于level0,由于文件可能有重合,其处理具有特殊性。当在level 0中找到有sstable文件和[begin, end]重合时,会相应的将begin/end扩展到文件的min key/max key,然后重新开始搜索。 了解了功能,下面分析函数实现代码,逻辑还是很直观的。 S1 首先根据参数初始化查找变量。 inputs->clear(); Slice user_begin, user_end; if (begin != NULL) user_begin =begin->user_key(); if (end != NULL) user_end = end->user_key(); const Comparator* user_cmp =vset_->icmp_.user_comparator(); S2 遍历该层的sstable文件,比较sstable的{minkey,max key}和传入的[begin, end],如果有重合就记录文件到@inputs中,需要对level 0做特殊处理。 for (size_t i = 0; i smallest.user_key(); const Slice file_limit =f->largest.user_key(); if (begin != NULL &&user_cmp->Compare(file_limit, user_begin) Compare(file_start, user_end) > 0) { //\"f\" 中的k/v全部在指定范围之后; 跳过 } else { inputs->push_back(f); // 有重合,记录 if (level == 0) { // 对于level 0,sstable文件可能相互有重叠,所以要检查新加的文件 // 是否范围更大,如果是则扩展范围重新开始搜索 if (begin != NULL&& user_cmp->Compare(file_start, user_begin) clear(); i = 0; } else if (end != NULL&& user_cmp->Compare(file_limit, user_end) > 0) { user_end = file_limit; inputs->clear(); i = 0; } } } } 10.7 Version::OverlapInLevel() 检查是否和指定level的文件有重合,该函数直接调用了SomeFileOverlapsRange(),这两个函数的声明为: bool Version::OverlapInLevel(int level,const Slice*smallest_user_key, const Slice* largest_user_key){ return SomeFileOverlapsRange(vset_->icmp_,(level > 0), files_[level], smallest_user_key, largest_user_key); } bool SomeFileOverlapsRange(const InternalKeyComparator& icmp, bool disjoint_sorted_files, const std::vector& files,const Slice*smallest_user_key, const Slice* largest_user_key); 所以下面直接分析SomeFileOverlapsRange()函数的逻辑,代码很直观。 disjoint_sorted_files=true,表明文件集合是互不相交、有序的,对于乱序的、可能有交集的文件集合,需要逐个查找,找到有重合的就返回true;对于有序、互不相交的文件集合,直接执行二分查找。 // S1 乱序、可能相交的文件集合,依次查找 for (size_t i = 0; i = files.size()) // 不存在比smallest_user_key小的key return false; //保证在largest_user_key之后 return !BeforeFile(ucmp,largest_user_key, files[index]); 上面的逻辑使用到了AfterFile()和BeforeFile()两个辅助函数,都很简单。 static bool AfterFile(const Comparator* ucmp, const Slice* user_key, constFileMetaData* f) { return (user_key!=NULL&& ucmp->Compare(*user_key, f->largest.user_key())>0); } static bool BeforeFile(const Comparator* ucmp, constSlice* user_key, const FileMetaData* f) { return (user_key!=NULL&& ucmp->Compare(*user_key, f->smallest.user_key())10.8 Version::PickLevelForMemTableOutput() 函数返回我们应该在哪个level上放置新的memtable compaction,这个compaction覆盖了范围[smallest_user_key,largest_user_key]。 该函数的调用链为: DBImpl::RecoverLogFile/DBImpl::CompactMemTable -> DBImpl:: WriteLevel0Table->Version::PickLevelForMemTableOutput; 函数声明如下: int Version::PickLevelForMemTableOutput(const Slice& smallest_user_key, constSlice& largest_user_key); 如果level 0没有找到重合就向下一层找,最大查找层次为kMaxMemCompactLevel = 2。如果在level 0or1找到了重合,就返回level 0。否则查找level 2,如果level 2有重合就返回level 1,否则返回level 2。 函数实现: int level = 0; //level 0无重合 if (!OverlapInLevel(0,&smallest_user_key, &largest_user_key)) { // 如果下一层没有重叠,就压到下一层, // andthe #bytes overlapping in the level after that are limited. InternalKeystart(smallest_user_key, kMaxSequenceNumber, kValueTypeForSeek); InternalKeylimit(largest_user_key, 0, static_cast(0)); std::vector overlaps; while (level kMaxGrandParentOverlapBytes) break; level++; } } return level; 这个函数在整个compaction逻辑中的作用在分析DBImpl时再来结合整个流程分析,现在只需要了解它找到一个level存放新的compaction就行了。 如果返回level = 0,表明在level 0或者1和指定的range有重叠;如果返回1,表明在level2和指定的range有重叠;否则就返回2(kMaxMemCompactLevel)。 也就是说在compactmemtable的时候,写入的sstable文件不一定总是在level 0,如果比较顺利,没有重合的,它可能会写到level1或者level2中。 10.9 小结 Version是管理某个版本的所有sstable的类,就其导出接口而言,无非是遍历sstable,查找k/v。以及为compaction做些事情,给定range,检查重叠情况。 而它不会修改它管理的sstable这些文件,对这些文件而言它是只读操作接口。 如需下载本站全部技术文章,可以在【高性能服务器开发】公众号回复关键字“文章下载”即可。最近更新时间: 2020-10-03 13:41:37 "},"articles/leveldb源码分析/leveldb源码分析18.html":{"url":"articles/leveldb源码分析/leveldb源码分析18.html","title":"leveldb源码分析18","keywords":"","body":"leveldb源码分析18 本系列《leveldb源码分析》共有22篇文章,这是第十八篇 11 VersionSet分析之1 Version之后就是VersionSet,它并不是Version的简单集合,还肩负了不少的处理逻辑。这里的分析不涉及到compaction相关的部分,这部分会单独分析。包括log等各种编号计数器,compaction点的管理等等。 11.1 VersionSet接口 1 首先是构造函数,VersionSet会使用到TableCache,这个是调用者传入的。TableCache用于Get k/v操作。 VersionSet(const std::string& dbname, const Options* options, TableCache*table_cache, const InternalKeyComparator*); VersionSet的构造函数很简单,除了根据参数初始化,还有两个地方值得注意: N1 nextfile_number从2开始; N2 创建新的Version并加入到Version链表中,并设置CURRENT=新创建version; 其它的数字初始化为0,指针初始化为NULL。 2 恢复函数,从磁盘恢复最后保存的元信息 Status Recover(); 3 标记指定的文件编号已经被使用了 void MarkFileNumberUsed(uint64_t number); 逻辑很简单,就是根据编号更新文件编号计数器: if (next_file_number_ 4 在current version上应用指定的VersionEdit,生成新的MANIFEST信息,保存到磁盘上,并用作current version。 要求:没有其它线程并发调用;要用于mu; Status LogAndApply(VersionEdit* edit, port::Mutex* mu)EXCLUSIVE_LOCKS_REQUIRED(mu); 5 对于@v中的@key,返回db中的大概位置 uint64_t ApproximateOffsetOf(Version* v, const InternalKey& key); 6 其它一些简单接口,信息获取或者设置,如下: //返回current version Version* current() const { return current_; } // 当前的MANIFEST文件号 uint64_t ManifestFileNumber() const { return manifest_file_number_; } // 分配并返回新的文件编号 uint64_t NewFileNumber() { return next_file_number_++; } // 返回当前log文件编号 uint64_t LogNumber() const { return log_number_; } // 返回正在compact的log文件编号,如果没有返回0 uint64_t PrevLogNumber() const { return prev_log_number_; } // 获取、设置last sequence,set时不能后退 uint64_t LastSequence() const { return last_sequence_; } void SetLastSequence(uint64_t s) { assert(s >=last_sequence_); last_sequence_ = s; } // 返回指定level中所有sstable文件大小的和 int64_t NumLevelBytes(int level) const; // 返回指定level的文件个数 int NumLevelFiles(int level) const; // 重用@file_number,限制很严格:@file_number必须是最后分配的那个 // 要求: @file_number是NewFileNumber()返回的. void ReuseFileNumber(uint64_t file_number) { if (next_file_number_ ==file_number + 1) next_file_number_ = file_number; } // 对于所有level>0,遍历文件,找到和下一层文件的重叠数据的最大值(in bytes) // 这个就是Version:: GetOverlappingInputs()函数的简单应用 int64_t MaxNextLevelOverlappingBytes(); // 获取函数,把所有version的所有level的文件加入到@live中 void AddLiveFiles(std::set* live); // 返回一个可读的单行信息——每个level的文件数,保存在*scratch中 struct LevelSummaryStorage {char buffer[100]; }; const char* LevelSummary(LevelSummaryStorage* scratch) const; 下面就来分析这两个接口Recover、LogAndApply以及ApproximateOffsetOf。 11.2 VersionSet::Builder类 Builder是一个内部辅助类,其主要作用是: 1 把一个MANIFEST记录的元信息应用到版本管理器VersionSet中; 2 把当前的版本状态设置到一个Version对象中。 11.2.1 成员与构造 Builder的vset与base都是调用者传入的,此外它还为FileMetaData定义了一个比较类BySmallestKey,首先依照文件的min key,小的在前;如果min key相等则file number小的在前。 typedefstd::set FileSet; // 这个是记录添加和删除的文件 struct LevelState { std::setdeleted_files; // 保证添加文件的顺序是有效定义的 FileSet* added_files; }; VersionSet* vset_; Version* base_; LevelStatelevels_[config::kNumLevels]; // 其接口有3个: void Apply(VersionEdit* edit); void SaveTo(Version* v); void MaybeAddFile(Version* v, int level, FileMetaData* f); 构造函数执行简单的初始化操作,在析构时,遍历检查LevelState::added_files,如果文件引用计数为0,则删除文件。 11.2.2 Apply() 函数声明:voidApply(VersionEdit* edit),该函数将edit中的修改应用到当前状态中。注意除了compaction点直接修改了vset_,其它删除和新加文件的变动只是先存储在Builder自己的成员变量中,在调用SaveTo(v)函数时才施加到v上。 S1 把edit记录的compaction点应用到当前状态 edit->compact_pointers_ => vset_->compact_pointer_ S2 把edit记录的已删除文件应用到当前状态 edit->deleted_files_ => levels_[level].deleted_files S3把edit记录的新加文件应用到当前状态,这里会初始化文件的allowed_seeks值,以在文件被无谓seek指定次数后自动执行compaction,这里作者阐述了其设置规则。 for (size_t i = 0; i new_files_.size(); i++) { const int level =edit->new_files_[i].first; FileMetaData* f = newFileMetaData(edit->new_files_[i].second); f->refs = 1; f->allowed_seeks = (f->file_size /16384); // 16KB-见下面 if (f->allowed_seeks allowed_seeks = 100; levels_[level].deleted_files.erase(f->number); // 以防万一 levels_[level].added_files->insert(f); } 值allowed_seeks事关compaction的优化,其计算依据如下,首先假设: 1 一次seek时间为10ms 2 写入10MB数据的时间为10ms(100MB/s) 3 compact 1MB的数据需要执行25MB的IO ->从本层读取1MB ->从下一层读取10-12MB(文件的key range边界可能是非对齐的) ->向下一层写入10-12MB 这意味这25次seek的代价等同于compact 1MB的数据,也就是一次seek花费的时间大约相当于compact 40KB的数据。基于保守的角度考虑,对于每16KB的数据,我们允许它在触发compaction之前能做一次seek。 11.2.3 MaybeAddFile() 函数声明: voidMaybeAddFile(Version* v, int level, FileMetaData* f); 该函数尝试将f加入到levels_[level]文件set中。 要满足两个条件: 1 文件不能被删除,也就是不能在levels[level].deleted_files集合中; 2 保证文件之间的key是连续的,即基于比较器vset->icmp,f的min key要大于levels[level]集合中最后一个文件的max key; 11.2.4 SaveTo() 把当前的状态存储到v中返回,函数声明: void SaveTo(Version* v); 函数逻辑:For循环遍历所有的level[0, config::kNumLevels-1],把新加的文件和已存在的文件merge在一起,丢弃已删除的文件,结果保存在v中。对于level> 0,还要确保集合中的文件没有重合。 S1 merge流程 // 原文件集合 conststd::vector& base_files = base_->files_[level]; std::vector::const_iterator base_iter =base_files.begin(); std::vector::const_iterator base_end =base_files.end(); const FileSet* added =levels_[level].added_files; v->files_[level].reserve(base_files.size()+ added->size()); for (FileSet::const_iteratoradded_iter = added->begin(); added_iter !=added->end(); ++added_iter) { //加入base_中小于added_iter的那些文件 for(std::vector::const_iterator bpos = std::upper_bound(base_iter,base_end,*added_iter, cmp); base_iter != bpos;++base_iter) { // base_iter逐次向后移到 MaybeAddFile(v, level,*base_iter); } // 加入added_iter MaybeAddFile(v, level,*added_iter); } // 添加base_剩余的那些文件 for (; base_iter != base_end;++base_iter) MaybeAddFile(v, level, *base_iter); 对象cmp就是前面定义的比较仿函数BySmallestKey对象。 S2 检查流程,保证level>0的文件集合无重叠,基于vset->icmp,确保文件i-1的max key 11.3 Recover() 对于VersionSet而言,Recover就是根据CURRENT指定的MANIFEST,读取db元信息。这是9.3介绍的Recovery流程的开始部分。 11.3.1 函数流程 下面就来分析其具体逻辑。 S1 读取CURRENT文件,获得最新的MANIFEST文件名,根据文件名打开MANIFEST文件。CURRENT文件以\\n结尾,读取后需要trim下。 std::string current; // MANIFEST文件名 ReadFileToString(env_, CurrentFileName(dbname_), ¤t); std::string dscname = dbname_ + \"/\" + current; SequentialFile* file; env_->NewSequentialFile(dscname, &file); S2 读取MANIFEST内容,MANIFEST是以log的方式写入的,因此这里调用的是log::Reader来读取。然后调用VersionEdit::DecodeFrom,从内容解析出VersionEdit对象,并将VersionEdit记录的改动应用到versionset中。读取MANIFEST中的log number, prev log number, nextfile number, last sequence。 Builder builder(this, current_); while (reader.ReadRecord(&record, &scratch) && s.ok()) { VersionEdit edit; s = edit.DecodeFrom(record); if (s.ok())builder.Apply(&edit); // log number, file number, …逐个判断 if (edit.has_log_number_) { log_number =edit.log_number_; have_log_number = true; } … … } S3 将读取到的log number, prev log number标记为已使用。 MarkFileNumberUsed(prev_log_number); MarkFileNumberUsed(log_number); S4 最后,如果一切顺利就创建新的Version,并应用读取的几个number。 if (s.ok()) { Version* v = newVersion(this); builder.SaveTo(v); // 安装恢复的version Finalize(v); AppendVersion(v); manifest_file_number_ =next_file; next_file_number_ = next_file+ 1; last_sequence_ = last_sequence; log_number_ = log_number; prev_log_number_ =prev_log_number; } Finalize(v)和AppendVersion(v)用来安装并使用version v,在AppendVersion函数中会将current version设置为v。下面就来分别分析这两个函数。 11.3.2 Finalize() 函数声明: void Finalize(Version*v); 该函数依照规则为下次的compaction计算出最适用的level,对于level 0和>0需要分别对待,逻辑如下。 S1 对于level 0以文件个数计算,kL0_CompactionTrigger默认配置为4。 score =v->files_[level].size()/static_cast(config::kL0_CompactionTrigger); S2 对于level>0,根据level内的文件总大小计算 const uint64_t level_bytes = TotalFileSize(v->files_[level]); score = static_cast(level_bytes) /MaxBytesForLevel(level); S3 最后把计算结果保存到v的两个成员compactionlevel和compactionscore中。 其中函数MaxBytesForLevel根据level返回其本层文件总大小的预定最大值。 计算规则为:1048576.0* level^10。 这里就有一个问题,为何level0和其它level计算方法不同,原因如下,这也是leveldb为compaction所做的另一个优化。 1 对于较大的写缓存(write-buffer),做太多的level 0 compaction并不好 2 每次read操作都要merge level 0的所有文件,因此我们不希望level 0有太多的小文件存在(比如写缓存太小,或者压缩比较高,或者覆盖/删除较多导致小文件太多)。 看起来这里的写缓存应该就是配置的操作log大小。 11.3.3 AppendVersion() 函数声明: void AppendVersion(Version*v); 把v加入到versionset中,并设置为current version。并对老的current version执行Uref()。 在双向循环链表中的位置在dummy_versions_之前。 如需下载本站全部技术文章,可以在【高性能服务器开发】公众号回复关键字“文章下载”即可。最近更新时间: 2020-10-03 13:44:16 "},"articles/leveldb源码分析/leveldb源码分析19.html":{"url":"articles/leveldb源码分析/leveldb源码分析19.html","title":"leveldb源码分析19","keywords":"","body":"leveldb源码分析19 本系列《leveldb源码分析》共有22篇文章,这是第十九篇 11.VersionSet分析之2 11.4 LogAndApply() 函数声明: Status LogAndApply(VersionEdit*edit, port::Mutex* mu) 前面接口小节中讲过其功能:在currentversion上应用指定的VersionEdit,生成新的MANIFEST信息,保存到磁盘上,并用作current version,故为Log And Apply。 参数edit也会被函数修改。 11.4.1 函数流程 下面就来具体分析函数代码。 S1 为edit设置log number等4个计数器。 if (edit->has_log_number_) { assert(edit->log_number_ >= log_number_); assert(edit->log_number_ SetLogNumber(log_number_); if (!edit->has_prev_log_number_) edit->SetPrevLogNumber(prev_log_number_); edit->SetNextFile(next_file_number_); edit->SetLastSequence(last_sequence_); 要保证edit自己的log number是比较大的那个,否则就是致命错误。保证edit的log number小于next file number,否则就是致命错误-见9.1小节。 S2 创建一个新的Version v,并把新的edit变动保存到v中。 Version* v = new Version(this); { Builder builder(this, current_); builder.Apply(edit); builder.SaveTo(v); } Finalize(v); //如前分析,只是为v计算执行compaction的最佳level S3 如果MANIFEST文件指针不存在,就创建并初始化一个新的MANIFEST文件。这只会发生在第一次打开数据库时。这个MANIFEST文件保存了current version的快照。 std::string new_manifest_file; Status s; if (descriptor_log_ == NULL) { // 这里不需要unlock *mu因为我们只会在第一次调用LogAndApply时 // 才走到这里(打开数据库时). assert(descriptor_file_ == NULL); // 文件指针和log::Writer都应该是NULL new_manifest_file = DescriptorFileName(dbname_, manifest_file_number_); edit->SetNextFile(next_file_number_); s = env_->NewWritableFile(new_manifest_file, &descriptor_file_); if (s.ok()) { descriptor_log_ = new log::Writer(descriptor_file_); s = WriteSnapshot(descriptor_log_); // 写入快照 } } S4 向MANIFEST写入一条新的log,记录current version的信息。在文件写操作时unlock锁,写入完成后,再重新lock,以防止浪费在长时间的IO操作上。 [cpp] view plain copy mu->Unlock(); if (s.ok()) { std::string record; edit->EncodeTo(&record);// 序列化current version信息 s = descriptor_log_->AddRecord(record); // append到MANIFEST log中 if (s.ok()) s = descriptor_file_->Sync(); if (!s.ok()) { Log(options_->info_log, \"MANIFEST write: %s\\n\", s.ToString().c_str()); if (ManifestContains(record)) { // 返回出错,其实确实写成功了 Log(options_->info_log, \"MANIFEST contains log record despiteerror \"); s = Status::OK(); } } } //如果刚才创建了一个MANIFEST文件,通过写一个指向它的CURRENT文件 //安装它;不需要再次检查MANIFEST是否出错,因为如果出错后面会删除它 if (s.ok() && !new_manifest_file.empty()) { s = SetCurrentFile(env_, dbname_, manifest_file_number_); } mu->Lock(); S5 安装这个新的version if (s.ok()) { // 安装这个version AppendVersion(v); log_number_ = edit->log_number_; prev_log_number_ = edit->prev_log_number_; } else { // 失败了,删除 delete v; if (!new_manifest_file.empty()) { delete descriptor_log_; delete descriptor_file_; descriptor_log_ = descriptor_file_ = NULL; env_->DeleteFile(new_manifest_file); } } 流程的S4中,函数会检查MANIFEST文件是否已经有了这条record,那么什么时候会有呢? 主函数使用到了几个新的辅助函数WriteSnapshot,ManifestContains和SetCurrentFile,下面就来分析。 11.4.2 WriteSnapshot() 函数声明: Status WriteSnapshot(log::Writer*log) 把currentversion保存到*log中,信息包括comparator名字、compaction点和各级sstable文件,函数逻辑很直观。 S1 首先声明一个新的VersionEdit edit; S2 设置comparator:edit.SetComparatorName(icmp_.user_comparator()->Name()); S3 遍历所有level,根据compactpointer[level],设置compaction点: edit.SetCompactPointer(level, key); S4 遍历所有level,根据current->files,设置sstable文件集合:edit.AddFile(level, xxx) S5 根据序列化并append到log(MANIFEST文件)中; std::string record; edit.EncodeTo(&record); returnlog->AddRecord(record); 以上就是WriteSnapshot的代码逻辑。 11.4.3 ManifestContains() 函数声明: bool ManifestContains(conststd::string& record) 如果当前MANIFEST包含指定的record就返回true,来看看函数逻辑。 S1 根据当前的manifestfile_number文件编号打开文件,创建SequentialFile对象 S2 根据创建的SequentialFile对象创建log::Reader,以读取文件 S3 调用log::Reader的ReadRecord依次读取record,如果和指定的record相同,就返回true,没有相同的record就返回false SetCurrentFile很简单,就是根据指定manifest文件编号,构造出MANIFEST文件名,并写入到CURRENT即可。 11.5 ApproximateOffsetOf() 函数声明: uint64_tApproximateOffsetOf(Version* v, const InternalKey& ikey) 在指定的version中查找指定key的大概位置。 假设version中有n个sstable文件,并且落在了地i个sstable的key空间内,那么返回的位置= sstable1文件大小+sstable2文件大小 + … + sstable (i-1)文件大小 + key在sstable i中的大概偏移。 可分为两段逻辑。 首先直接和sstable的max key作比较,如果key > max key,直接跳过该文件,还记得sstable文件是有序排列的。 对于level >0的文件集合而言,如果如果key key在sstable i中的大概偏移使用的是Table:: ApproximateOffsetOf(target)接口,前面分析过,它返回的是Table中>= target的key的位置。 VersionSet的相关函数暂时分析到这里,compaction部分后需单独分析。 如需下载本站全部技术文章,可以在【高性能服务器开发】公众号回复关键字“文章下载”即可。最近更新时间: 2020-10-03 13:45:24 "},"articles/leveldb源码分析/leveldb源码分析20.html":{"url":"articles/leveldb源码分析/leveldb源码分析20.html","title":"leveldb源码分析20","keywords":"","body":"leveldb源码分析20 本系列《leveldb源码分析》共有22篇文章,这是第二十篇 12 DB的打开 先分析LevelDB是如何打开db的,万物始于创建。在打开流程中有几个辅助函数:DBImpl(),DBImpl::Recover, DBImpl::DeleteObsoleteFiles, DBImpl::RecoverLogFile, DBImpl::MaybeScheduleCompaction。 12.1 DB::Open() 打开一个db,进行PUT、GET操作,就是前面的静态函数DB::Open的工作。如果操作成功,它就返回一个db指针。前面说过DB就是一个接口类,其具体实现在DBImp类中,这是一个DB的子类。 函数声明为: Status DB::Open(const Options& options, const std::string&dbname, DB** dbptr); 分解来看,Open()函数主要有以下5个执行步骤。 S1 创建DBImpl对象,其后进入DBImpl::Recover()函数执行S2和S3。 S2 从已存在的db文件恢复db数据,根据CURRENT记录的MANIFEST文件读取db元信息;这通过调用VersionSet::Recover()完成。 S3 然后过滤出那些最近的更新log,前一个版本可能新加了这些log,但并没有记录在MANIFEST中。然后依次根据时间顺序,调用DBImpl::RecoverLogFile()从旧到新回放这些操作log。回放log时可能会修改db元信息,比如dump了新的level 0文件,因此它将返回一个VersionEdit对象,记录db元信息的变动。 S4 如果DBImpl::Recover()返回成功,就执行VersionSet::LogAndApply()应用VersionEdit,并保存当前的DB信息到新的MANIFEST文件中。 S5 最后删除一些过期文件,并检查是否需要执行compaction,如果需要,就启动后台线程执行。 下面就来具体分析Open函数的代码,在Open函数中涉及到上面的3个流程。 S1 首先创建DBImpl对象,锁定并试图做Recover操作。Recover操作用来处理创建flag,比如存在就返回失败等等,尝试从已存在的sstable文件恢复db。并返回db元信息的变动信息,一个VersionEdit对象。 1DBImpl* impl = newDBImpl(options, dbname); 2impl->mutex_.Lock(); // 锁db 3VersionEdit edit; 4Status s =impl->Recover(&edit); // 处理flag&恢复:create_if_missing,error_if_exists S2 如果Recover返回成功,则调用VersionSet取得新的log文件编号——实际上是在当前基础上+1,准备新的log文件。如果log文件创建成功,则根据log文件创建log::Writer。然后执行VersionSet::LogAndApply,根据edit记录的增量变动生成新的current version,并写入MANIFEST文件。 函数NewFileNumber(){returnnextfile_number++;},直接返回nextfile_number。 1uint64_t new_log_number = impl->versions_->NewFileNumber(); 2WritableFile* lfile; 3s = options.env->NewWritableFile(LogFileName(dbname, new_log_number), &lfile); 4if (s.ok()) { 5 edit.SetLogNumber(new_log_number); 6 impl->logfile_ = lfile; 7 impl->logfile_number_ = new_log_number; 8 impl->log_ = newlog::Writer(lfile); 9 s = impl->versions_->LogAndApply(&edit, &impl->mutex_); 10} S3 如果VersionSet::LogAndApply返回成功,则删除过期文件,检查是否需要执行compaction,最终返回创建的DBImpl对象。 1if (s.ok()) { 2 impl->DeleteObsoleteFiles(); 3 impl->MaybeScheduleCompaction(); 4} 5impl->mutex_.Unlock(); 6if (s.ok()) *dbptr = impl; 7return s; 以上就是DB::Open的主题逻辑。 12.2 DBImpl::DBImpl() 构造函数做的都是初始化操作, DBImpl::DBImpl(const Options& options, const std::string&dbname) 首先是初始化列表中,直接根据参数赋值,或者直接初始化。Comparator和filter policy都是参数传入的。在传递option时会首先将option中的参数合法化,logfilenumber初始化为0,指针初始化为NULL。 创建MemTable,并增加引用计数,创建WriteBatch。 1mem_(newMemTable(internal_comparator_)), 2tmp_batch_(new WriteBatch), 3mem_->Ref(); 4// 然后在函数体中,创建TableCache和VersionSet。 5// 为其他预留10个文件,其余的都给TableCache. 6const int table_cache_size = options.max_open_files - 10; 7table_cache_ = newTableCache(dbname_, &options_, table_cache_size); 8versions_ = newVersionSet(dbname_, &options_, table_cache_, &internal_comparator_); 12.3 DBImp::NewDB() 当外部在调用DB::Open()时设置了option指定如果db不存在就创建,如果db不存在leveldb就会调用函数创建新的db。判断db是否存在的依据是/CURRENT文件是否存在。其逻辑很简单。 1// S1首先生产DB元信息,设置comparator名,以及log文件编号、文件编号,以及seq no。 2VersionEdit new_db; 3new_db.SetComparatorName(user_comparator()->Name()); 4new_db.SetLogNumber(0); 5new_db.SetNextFile(2); 6new_db.SetLastSequence(0); 7// S2 生产MANIFEST文件,将db元信息写入MANIFEST文件。 8const std::string manifest = DescriptorFileName(dbname_, 1); 9WritableFile* file; 10Status s = env_->NewWritableFile(manifest, &file); 11if (!s.ok()) return s; 12{ 13 log::Writer log(file); 14 std::string record; 15 new_db.EncodeTo(&record); 16 s = log.AddRecord(record); 17 if (s.ok()) s = file->Close(); 18} 19delete file; 20// S3 如果成功,就把MANIFEST文件名写入到CURRENT文件中 21if (s.ok()) s = SetCurrentFile(env_, dbname_, 1); 22elseenv_->DeleteFile(manifest); 23return s; 这就是创建新DB的逻辑,很简单。 12.4 DBImpl::Recover() 函数声明为: StatusDBImpl::Recover(VersionEdit* edit) 如果调用成功则设置VersionEdit。Recover的基本功能是:首先是处理创建flag,比如存在就返回失败等等;然后是尝试从已存在的sstable文件恢复db;最后如果发现有大于原信息记录的log编号的log文件,则需要回放log,更新db数据。回放期间db可能会dump新的level 0文件,因此需要把db元信息的变动记录到edit中返回。函数逻辑如下: S1 创建目录,目录以db name命名,忽略任何创建错误,然后尝试获取db name/LOCK文件锁,失败则返回。 1env_->CreateDir(dbname_); 2Status s = env_->LockFile(LockFileName(dbname_), &db_lock_); 3if (!s.ok()) return s; S2 根据CURRENT文件是否存在,以及option参数执行检查。 如果文件不存在&create_is_missing=true,则调用函数NewDB()创建;否则报错。 如果文件存在& error_if_exists=true,则报错。 S3 调用VersionSet的Recover()函数,就是从文件中恢复数据。如果出错则打开失败,成功则向下执行S4。 s = versions_->Recover(); S4尝试从所有比manifest文件中记录的log要新的log文件中恢复(前一个版本可能会添加新的log文件,却没有记录在manifest中)。另外,函数PrevLogNumber()已经不再用了,仅为了兼容老版本。 1// S4.1 这里先找出所有满足条件的log文件:比manifest文件记录的log编号更新。 2SequenceNumber max_sequence(0); 3const uint64_t min_log = versions_->LogNumber(); 4const uint64_t prev_log = versions_->PrevLogNumber(); 5std::vectorfilenames; 6s = env_->GetChildren(dbname_, &filenames); // 列出目录内的所有文件 7uint64_t number; 8FileType type; 9std::vectorlogs; 10for (size_t i = 0; i = min_log) || (number == prev_log))) { 13 logs.push_back(number); 14 } 15} 16// S4.2 找到log文件后,首先排序,保证按照生成顺序,依次回放log。并把DB元信息的变动(sstable文件的变动)追加到edit中返回。 17std::sort(logs.begin(), logs.end()); 18for (size_t i = 0; i MarkFileNumberUsed(logs[i]); 23} 24// S4.3 更新VersionSet的sequence 25if (s.ok()) { 26 if (versions_->LastSequence() SetLastSequence(max_sequence); 28} 上面就是Recover的执行流程。 12.5 DBImpl::DeleteObsoleteFiles() 这个是垃圾回收函数,如前所述,每次compaction和recovery之后都会有文件被废弃。DeleteObsoleteFiles就是删除这些垃圾文件的,它在每次compaction和recovery完成之后被调用。 其调用点包括:DBImpl::CompactMemTable,DBImpl::BackgroundCompaction, 以及DB::Open的recovery步骤之后。 它会删除所有过期的log文件,没有被任何level引用到、或不是正在执行的compaction的output的sstable文件。 该函数没有参数,其代码逻辑也很直观,就是列出db的所有文件,对不同类型的文件分别判断,如果是过期文件,就删除之,如下: 1// S1 首先,确保不会删除pending文件,将versionset正在使用的所有文件加入到live中。 2std::set live = pending_outputs_; 3versions_->AddLiveFiles(&live); //该函数其后分析 4 // S2 列举db的所有文件 5std::vectorfilenames; 6env_->GetChildren(dbname_, &filenames); 7// S3 遍历所有列举的文件,根据文件类型,分别处理; 8uint64_t number; 9FileType type; 10for (size_t i = 0; i = versions_->LogNumber()) || 15 (number == versions_->PrevLogNumber())); 16 // S3.2 kDescriptorFile,MANIFEST文件,根据versionset记录的编号判断 17 keep = (number >= versions_->ManifestFileNumber()); 18 // S3.3 kTableFile,sstable文件,只要在live中就不能删除 19 // S3.4 kTempFile,如果是正在写的文件,只要在live中就不能删除 20 keep = (live.find(number) != live.end()); 21 // S3.5 kCurrentFile,kDBLockFile, kInfoLogFile,不能删除 22 keep = true; 23 // S3.6 如果keep为false,表明需要删除文件,如果是table还要从cache中删除 24 if (!keep) { 25 if (type == kTableFile) table_cache_->Evict(number); 26 Log(options_.info_log, \"Delete type=%d #%lld\\n\", type, number); 27 env_->DeleteFile(dbname_ + \"/\" + filenames[i]); 28 } 29 } 30} 这就是删除过期文件的逻辑,其中调用到了VersionSet::AddLiveFiles函数,保证不会删除active的文件。 函数DbImpl::MaybeScheduleCompaction()放在Compaction一节分析,基本逻辑就是如果需要compaction,就启动后台线程执行compaction操作。 12.6 DBImpl::RecoverLogFile() 函数声明: StatusRecoverLogFile(uint64_t log_number, VersionEdit* edit,SequenceNumber* max_sequence) 参数说明: @log_number是指定的log文件编号 @edit记录db元信息的变化——sstable文件变动 @max_sequence 返回max{log记录的最大序号, *max_sequence} 该函数打开指定的log文件,回放日志。期间可能会执行compaction,生产新的level 0sstable文件,记录文件变动到edit中。 它声明了一个局部类LogReporter以打印错误日志,没什么好说的,下面来看代码逻辑。 1// S1 打开log文件返回SequentialFile*file,出错就返回,否则向下执行S2。 2// S2 根据log文件句柄file创建log::Reader,准备读取log。 3log::Reader reader(file, &reporter, true/*checksum*/, 0/*initial_offset*/); 4// S3 依次读取所有的log记录,并插入到新生成的memtable中。这里使用到了批量更新接口WriteBatch,具体后面再分析。 5std::string scratch; 6Slice record; 7WriteBatch batch; 8MemTable* mem = NULL; 9while (reader.ReadRecord(&record, &scratch) && status.ok()) { // 读取全部log 10 if (record.size() Ref(); 18 } 19 status = WriteBatchInternal::InsertInto(&batch, mem); // 插入到memtable中 20 MaybeIgnoreError(&status); 21 if (!status.ok()) break; 22 const SequenceNumber last_seq = 23 WriteBatchInternal::Sequence(&batch) + WriteBatchInternal::Count(&batch) - 1; 24 if (last_seq > *max_sequence) *max_sequence = last_seq; // 更新max sequence 25 // 如果mem的内存超过设置值,则执行compaction,如果compaction出错, 26 // 立刻返回错误,DB::Open失败 27 if (mem->ApproximateMemoryUsage() > options_.write_buffer_size) { 28 status = WriteLevel0Table(mem, edit, NULL); 29 if (!status.ok()) break; 30 mem->Unref(); // 释放当前memtable 31 mem = NULL; 32 } 33} 34// S4 扫尾工作,如果mem != NULL,说明还需要dump到新的sstable文件中。 35if (status.ok() && mem != NULL) {// 如果compaction出错,立刻返回错误 36 status = WriteLevel0Table(mem, edit, NULL); 37} 38if (mem != NULL)mem->Unref(); 39delete file; 40return status; 把MemTabledump到sstable是函数WriteLevel0Table的工作,其实这是compaction的一部分,准备放在compaction一节来分析。 12.7 小结 如上DB打开的逻辑就已经分析完了,打开逻辑参见DB::Open()中描述的5个步骤。此外还有两个东东:把Memtable dump到sstable的WriteLevel0Table()函数,以及批量修改WriteBatch。第一个放在后面的compaction一节,第二个放在DB更新操作中。接下来就是db的关闭。 13 DB的关闭&销毁 13.1 DB关闭 外部调用者通过DB::Open()获取一个DB对象,如果要关闭打开的DBdb对象,则直接delete db即可,这会调用到DBImpl的析构函数。 析构依次执行如下的5个逻辑: S1 等待后台compaction任务结束 S2 释放db文件锁,/lock文件 S3 删除VersionSet对象,并释放MemTable对象 S4 删除log相关以及TableCache对象 S5 删除options的block_cache以及info_log对象 13.2 DB销毁 函数声明: StatusDestroyDB(const std::string& dbname, const Options& options) 该函数会删除掉db的数据内容,要谨慎使用。函数逻辑为: S1 获取dbname目录的文件列表到filenames中,如果为空则直接返回,否则进入S2。 S2 锁文件/lock,如果锁成功就执行S3 S3 遍历filenames文件列表,过滤掉lock文件,依次调用DeleteFile删除。 S4 释放lock文件,并删除之,然后删除文件夹。 Destory就执行完了,如果删除文件出现错误,记录之,依然继续删除下一个。最后返回错误代码。 看来这一章很短小。DB的打开关闭分析完毕。 如需下载本站全部技术文章,可以在【高性能服务器开发】公众号回复关键字“文章下载”即可。最近更新时间: 2020-09-13 18:56:48 "},"articles/leveldb源码分析/leveldb源码分析21.html":{"url":"articles/leveldb源码分析/leveldb源码分析21.html","title":"leveldb源码分析21","keywords":"","body":"leveldb源码分析21 本系列《leveldb源码分析》共有22篇文章,这是第二十一篇 14 DB的查询与遍历之1 分析完如何打开和关闭db,本章就继续分析如何从db中根据key查询value,以及遍历整个db。 14.1 Get() 函数声明:StatusGet(const ReadOptions& options, const Slice& key, std::string* value) 从DB中查询key 对应的value,参数@options指定读取操作的选项,典型的如snapshot号,从指定的快照中读取。快照本质上就是一个sequence号,后面将单独在快照一章中分析。 下面就来分析下函数逻辑: // S1 锁mutex,防止并发,如果指定option则尝试获取snapshot;然后增加MemTable的引用值。 MutexLock l(&mutex_); SequenceNumber snapshot; if (options.snapshot != NULL) snapshot = reinterpret_cast(options.snapshot)->number_; else snapshot = versions_->LastSequence(); // 取当前版本的最后Sequence MemTable *mem = mem_, *imm = imm_; Version* current = versions_->current(); mem->Ref(); if (imm != NULL) imm->Ref(); current->Ref(); // S2 从sstable文件和MemTable中读取时,释放锁mutex;之后再次锁mutex。 bool have_stat_update = false; Version::GetStats stats; { mutex_.Unlock(); // 先从memtable中查询,再从immutable memtable中查询 LookupKey lkey(key, snapshot); if (mem->Get(lkey, value, &s)) { } else if (imm != NULL && imm->Get(lkey, value, &s)) { } else { // 需要从sstable文件中查询 s = current->Get(options, lkey, value, &stats); have_stat_update = true; // 记录之,用于compaction } mutex_.Lock(); } // S3 如果是从sstable文件查询出来的,检查是否需要做compaction。最后把MemTable的引用计数减1。 if (have_stat_update &¤t->UpdateStats(stats)) { MaybeScheduleCompaction(); } mem->Unref(); if (imm != NULL)imm->Unref(); current->Unref(); 查询是比较简单的操作,UpdateStats在前面Version一节已经分析过。 14.2 NewIterator() 函数声明:Iterator*NewIterator(const ReadOptions& options) 通过该函数生产了一个Iterator对象,调用这就可以基于该对象遍历db内容了。 函数很简单,调用两个函数创建了一个二级*Iterator。 Iterator* DBImpl::NewIterator(const ReadOptions& options) { SequenceNumber latest_snapshot; Iterator* internal_iter = NewInternalIterator(options, &latest_snapshot); returnNewDBIterator(&dbname_, env_, user_comparator(), internal_iter, (options.snapshot != NULL ? reinterpret_cast(options.snapshot)->number_ : latest_snapshot)); } 其中,函数NewDBIterator直接返回了一个DBIter指针 Iterator* NewDBIterator(const std::string* dbname, Env* env, const Comparator*user_key_comparator, Iterator* internal_iter, const SequenceNumber& sequence) { return new DBIter(dbname, env, user_key_comparator, internal_iter, sequence); } 函数NewInternalIterator有一些处理逻辑,就是收集所有能用到的iterator,生产一个Merging Iterator。这包括MemTable,Immutable MemTable,以及各sstable。 Iterator* DBImpl::NewInternalIterator(const ReadOptions& options, SequenceNumber*latest_snapshot) { IterState* cleanup = newIterState; mutex_.Lock(); // 根据last sequence设置lastest snapshot,并收集所有的子iterator *latest_snapshot = versions_->LastSequence(); std::vectorlist; list.push_back(mem_->NewIterator()); // >memtable mem_->Ref(); if (imm_ != NULL) { list.push_back(imm_->NewIterator()); // >immutablememtable imm_->Ref(); } versions_->current()->AddIterators(options, &list); // >current的所有sstable Iterator* internal_iter = NewMergingIterator(&internal_comparator_, &list[0], list.size()); versions_->current()->Ref(); // 注册清理机制 cleanup->mu = &mutex_; cleanup->mem = mem_; cleanup->imm = imm_; cleanup->version = versions_->current(); internal_iter->RegisterCleanup(CleanupIteratorState, cleanup, NULL); mutex_.Unlock(); return internal_iter; } 这个清理函数CleanupIteratorState是很简单的,对注册的对象做一下Unref操作即可。 static void CleanupIteratorState(void* arg1, void* arg2) { IterState* state = reinterpret_cast(arg1); state->mu->Lock(); state->mem->Unref(); if (state->imm != NULL)state->imm->Unref(); state->version->Unref(); state->mu->Unlock(); delete state; } 可见对于db的遍历依赖于DBIter和Merging Iterator这两个迭代器,它们都是Iterator接口的实现子类。 14.3 MergingIterator MergingIterator是一个合并迭代器,它内部使用了一组自Iterator,保存在其成员数组children_中。如上面的函数NewInternalIterator,包括memtable,immutable memtable,以及各sstable文件;它所做的就是根据调用者指定的key和sequence,从这些Iterator中找到合适的记录。 在分析其Iterator接口之前,先来看看两个辅助函数FindSmallest和FindLargest。FindSmallest从0开始向后遍历内部Iterator数组,找到key最小的Iterator,并设置到current;FindLargest从最后一个向前遍历内部Iterator数组,找到key最大的Iterator,并设置到current; MergingIterator还定义了两个移动方向:kForward,向前移动;kReverse,向后移动。 14.3.1 Get系接口 下面就把其接口拖出来一个一个分析,首先是简单接口,key和value都是返回current的值,current是当前seek到的Iterator位置。 virtual Slice key() const { assert(Valid()); return current_->key(); } virtual Slice value() const { assert(Valid()); return current_->value(); } virtual Status status() const { Status status; for (int i = 0; i 14.3.2 Seek系接口 然后是几个seek系的函数,也比较简单,都是依次调用内部Iterator的seek系函数。然后做merge,对于Seek和SeekToFirst都调用FindSmallest;对于SeekToLast调用FindLargest。 virtual void SeekToFirst() { for (int i = 0; i 14.3.3 逐步移动 最后就是Next和Prev函数,完成迭代遍历。这可能会有点绕。下面分别来说明。 首先,在Next移动时,如果当前direction不是kForward的,也就是上一次调用了Prev或者SeekToLast函数,就需要先调整除current之外的所有iterator,为什么要做这种调整呢?啰嗦一点,考虑如下的场景,如图14.3-1所示。 图14.3-1 Next的移动 当前direction为kReverse,并且有:Current = memtable Iterator。各Iterator位置为:{memtable, stable 0, sstable1} ={ key3:1:1, key2:3:1, key2:1:1},这符合prev操作的largest key要求。 注:需要说明下,对于每个update操作,leveldb都会赋予一个全局唯一的sequence号,且是递增的。例子中的sequence号可理解为每个key的相对值,后面也是如此。 接下来我们来分析Prev移动的操作。 第一次Prev,current(memtable iterator)移动到key1:3:0上,3者中最大者变成sstable0;因此current修改为sstable0; 第二次Prev,current(sstable0 Iterator)移动到key1:2:1上,3者中最大者变成sstable1;因此current修改为sstable1: 此时各Iterator的位置为{memtable, sstable 0, sstable1} = { key1:3:0, key1:2:1, key2:2:1},并且current=sstable1。 接下来再调用Next,显然当前Key()为key2:2:1,综合考虑3个iterator,两次Next()的调用结果应该是key2:1:1和key3:1:1。而memtable和sstable0指向的key却是key1:3:0和key1:2:1,这时就需要调整memtable和sstable0了,使他们都定位到Key()之后,也就是key3:1:1和key2:3:1上。 然后current(current1)Next移动到key2:1:1上。这就是Next时的调整逻辑,同理,对于Prev也有相同的调整逻辑。代码如下: virtual void Next() { assert(Valid()); // 确保所有的子Iterator都定位在key()之后. // 如果我们在正向移动,对于除current_外的所有子Iterator这点已然成立 // 因为current_是最小的子Iterator,并且key() = current_->key()。 // 否则,我们需要明确设置其它的子Iterator if (direction_ != kForward) { for (int i = 0; i Seek(key()); if (child->Valid() && comparator_->Compare(key(), child->key()) == 0) child->Next(); // key等于current_->key()的,再向后移动一位 } } direction_ = kForward; } // current也向后移一位,然后再查找key最小的Iterator current_->Next(); FindSmallest(); } virtual void Prev() { assert(Valid()); // 确保所有的子Iterator都定位在key()之前. // 如果我们在逆向移动,对于除current_外的所有子Iterator这点已然成立 // 因为current_是最大的,并且key() = current_->key() // 否则,我们需要明确设置其它的子Iterator if (direction_ != kReverse) { for (int i = 0; i Seek(key()); if (child->Valid()) { // child位于>=key()的第一个entry上,prev移动一位到Prev(); } else { // child所有的entry都 SeekToLast(); } } } direction_ = kReverse; } //current也向前移一位,然后再查找key最大的Iterator current_->Prev(); FindLargest(); } 这就是MergingIterator的全部代码逻辑了,每次Next或者Prev移动时,都要重新遍历所有的子Iterator以找到key最小或最大的Iterator作为current_。这就是merge的语义所在了。 但是它没有考虑到删除标记等问题,因此直接使用MergingIterator是不能正确的遍历DB的,这些问题留待给DBIter来解决。 如需下载本站全部技术文章,可以在【高性能服务器开发】公众号回复关键字“文章下载”即可。最近更新时间: 2020-10-03 13:51:42 "},"articles/leveldb源码分析/leveldb源码分析22.html":{"url":"articles/leveldb源码分析/leveldb源码分析22.html","title":"leveldb源码分析22","keywords":"","body":"leveldb源码分析22 本系列《leveldb源码分析》共有22篇文章,这是第二十二篇 14 DB的查询与遍历之2 14.4 DBIter Leveldb数据库的MemTable和sstable文件的存储格式都是(user key, seq, type) => uservalue。DBIter把同一个userkey在DB中的多条记录合并为一条,综合考虑了userkey的序号、删除标记、和写覆盖等等因素。 从前面函数NewIterator的代码还能看到,DBIter内部使用了MergingIterator,在调用MergingItertor的系列seek函数后,DBIter还要处理key的删除标记。否则,遍历时会把已删除的key列举出来。 DBIter还定义了两个移动方向,默认是kForward: 1) kForward,向前移动,代码保证此时DBIter的内部迭代器刚好定位在this->key(),this->value()这条记录上; 2) kReverse,向后移动,代码保证此时DBIter的内部迭代器刚好定位在所有key=this->key()的entry之前。 其成员变量savedkey和saved value保存的是KReverse方向移动时的k/v对,每次seek系调用之后,其值都会跟随iter_而改变。 DBIter的代码开始读来感觉有些绕,主要就是它要处理删除标记,而且其底层的MergingIterator,对于同一个key会有多个不同sequence的entry。导致其Next/Prev操作比较复杂,要考虑到上一次移动的影响,跳过删除标记和重复的key。 DBIter必须导出Iterator定义的几个接口,下面就拖出来挨个分析。 14.4.1 Get系接口 首先是几个简单接口,获取key、value和status的: //kForward直接取iter_->value(),否则取saved value virtual Slice value() const { assert(valid_); return (direction_ == kForward) ? iter_->value() : saved_value_; } virtual Status status() const { if (status_.ok()) returniter_->status(); return status_; } 14.4.2 辅助函数 在分析seek系函数之前,先来理解两个重要的辅助函数:FindNextUserEntry和FindPrevUserEntry的功能和逻辑。其功能就是循环跳过下一个/前一个delete的记录,直到遇到kValueType的记录。 先来看看,函数声明为: void DBIter::FindNextUserEntry(bool skipping, std::string* skip) 参数@skipping表明是否要跳过sequence更小的entry; 参数@skip临时存储空间,保存seek时要跳过的key; 在进入FindNextUserEntry时,iter_刚好定位在this->key(), this->value()这条记录上。下面来看函数实现: virtual Slice key() const { //kForward直接取iter_->key(),否则取saved key assert(valid_); return (direction_ == kForward) ? ExtractUserKey(iter_->key()) : saved_key_; } // 循环直到找到合适的entry,direction必须是kForward assert(iter_->Valid()); assert(direction_ == kForward); do { ParsedInternalKey ikey; // 确保iter_->key()的sequence Compare(ikey.user_key, *skip) Next(); // 继续检查下一个entry } while (iter_->Valid()); // 到这里表明已经找到最后了,没有符合的entry saved_key_.clear(); valid_ = false; FindNextUserKey移动方向是kForward,DBIter在向kForward移动时,借用了saved key作为临时缓存。FindNextUserKey确保定位到的entry的sequence不会大于指定的sequence,并跳过被删除标记覆盖的旧记录。 接下来是FindPrevUserKey,函数声明为:void DBIter::FindPrevUserEntry(),在进入FindPrevUserEntry时,iter_刚好位于saved key对应的所有记录之前。源代码如下: assert(direction_ == kReverse); // 确保是kReverse方向 ValueType value_type =kTypeDeletion; //后面的循环至少执行一次Prev操作 if (iter_->Valid()) { do { // 循环 // 确保iter_->key()的sequence Compare(ikey.user_key, saved_key_) value(); if(saved_value_.capacity() > raw_value.size() + 1048576) { std::string empty; swap(empty,saved_value_); } SaveKey(ExtractUserKey(iter_->key()), &saved_key_); saved_value_.assign(raw_value.data(), raw_value.size()); } } iter_->Prev(); // 前一个 } while (iter_->Valid()); } if (value_type == kTypeDeletion){ // 表明遍历结束了,将direction设置为kForward valid_ = false; saved_key_.clear(); ClearSavedValue(); direction_ = kForward; } else { valid_ = true; } 函数FindPrevUserKey根据指定的sequence,依次检查前一个entry,直到遇到user key小于saved key,并且类型不是Delete的entry。如果entry的类型是Delete,就清空saved key和saved value,这样在依次遍历前一个entry的循环中,只要类型不是Delete,就是要找的entry。这就是Prev的语义。 14.4.3 Seek系函数 了解了这两个重要的辅助函数,可以分析几个Seek接口了,它们需要借助于上面的这两个函数来跳过被delete的记录。 void DBIter::Seek(const Slice& target) { direction_ = kForward; // 向前seek // 清空saved value和saved key,并根据target设置saved key ClearSavedValue(); saved_key_.clear(); AppendInternalKey( // kValueTypeForSeek(1) > kDeleteType(0) &saved_key_,ParsedInternalKey(target, sequence_, kValueTypeForSeek)); iter_->Seek(saved_key_); // iter seek到saved key //可以定位到合法的iter,还需要跳过Delete的entry if (iter_->Valid()) FindNextUserEntry(false,&saved_key_); else valid_ = false; } void DBIter::SeekToFirst() { direction_ = kForward; // 向前seek // 清空saved value,首先iter_->SeekToFirst,然后跳过Delete的entry ClearSavedValue(); iter_->SeekToFirst(); if (iter_->Valid()) FindNextUserEntry(false,&saved_key_ /*临时存储*/); else valid_ = false; } void DBIter::SeekToLast() { // 更简单 direction_ = kReverse; ClearSavedValue(); iter_->SeekToLast(); FindPrevUserEntry(); } 14.4.4 Prev()和Next() Next和Prev接口,相对复杂一些。和底层的merging iterator不同,DBIter的Prev和Next步进是以key为单位的,而mergingiterator是以一个record为单位的。所以在调用merging Iterator做Prev和Next迭代时,必须循环直到key发生改变。 这次让我们以Prev为例,以14.4-1图解一下,还真是一图胜千言啊。 假设指定读取的sequence为2,当前iter在key4:2:1上,direction为kForward。此时调用Prev(),此图显示了Prev操作执行的5个步骤: S1 首先因为direction为kForward,先调整iter到key3:1:1上。此图也说明了调整的理由,key4:2:1前面还有key4:3:1。然后进入FindPrevUserEntry函数,执行S2到S4。 S2 跳到key3:2:0上时,这是一个删除标记,清空saved key(其中保存的是key3:1:1)。 S3 循环继续,跳到key2:1:1上,此时key2:1:1 > saved key,设置saved key为key2:1:1,并继续循环。 S4 循环继续,跳到key2:2:1上,此时key2:2:1 > saved key,设置saved key为key2:2:1,并继续循环。 S5 跳到Key1:1:1上,因为key1:1:1 iter->Next()就跳到了saved key的dentry范围的sequence最大的那个entry。在前面的例子中,在Prev后执行Next,那么iter首先跳转到key2:3:1上,然后再调用FindNextUserEntry循环,使iter定位在key2:2:1上。 下面首先来分析Next的实现。如果direction是kReverse,表明上一次做的是kReverse跳转,这种情况下,iter位于key是this->key()的所有entry之前,我们需要先把iter跳转到this->key()对应的entries范围内。 void DBIter::Next() { assert(valid_); if (direction_ == kReverse) { //需要预处理,并更改direction=kForward direction_ = kForward; // iter_刚好在this->key()的所有entry之前,所以先跳转到this->key() // 的entries范围之内,然后再做常规的skip if (!iter_->Valid()) iter_->SeekToFirst(); else iter_->Next(); if (!iter_->Valid()) { valid_ = false; saved_key_.clear(); return; } } // 把saved_key_ 用作skip的临时存储空间 std::string* skip =&saved_key_; SaveKey(ExtractUserKey(iter_->key()), skip);// 设置skip为iter_->key()的user key FindNextUserEntry(true, skip); } 接下来是Prev(),其实和Next()逻辑相似,但方向相反。 如果direction是kForward,表明上一次是做的是kForward跳转,这种情况下,iter_指向当前的entry,我们需要调整iter,使其指向到前一个key,iter的位置是这个key所有record序列的最后一个,也就是sequence最小的那个record。 void DBIter::Prev() { assert(valid_); if (direction_ == kForward) { //需要预处理,并更改direction // iter_指向当前的entry,向后扫描直到key发生改变,然后我们可以做 //常规的reverse扫描 assert(iter_->Valid()); // iter_必须合法,并把saved key设置为iter_->key() SaveKey(ExtractUserKey(iter_->key()), &saved_key_); while (true) { iter_->Prev(); if (!iter_->Valid()) { // 到头了,直接返回 valid_ = false; saved_key_.clear(); ClearSavedValue(); return; } if (user_comparator_->Compare(ExtractUserKey(iter_->key()), saved_key_) 接下来要分析的是插入和删除操作。 14.5 小结 查询操作并不复杂,只需要根据seq找到最新的记录即可。知道leveldb的遍历会比较复杂,不过也没想到会这么复杂。这主要是得益于sstable 0的重合性,以及memtable和sstable文件的重合性。 leveldb源码分析全系列完。 如需下载本站全部技术文章,可以在【高性能服务器开发】公众号回复关键字“文章下载”即可。最近更新时间: 2020-10-03 13:54:13 "},"articles/Memcached源码分析/":{"url":"articles/Memcached源码分析/","title":"Memcached源码分析","keywords":"","body":"Memcached源码分析 00 服务器资源调整 01 初始化参数解析 02 网络监听的建立 03 网络连接建立 04 内存初始化 05 资源初始化 06 get过程 07 cas属性 08 内存池 09 连接队列 10 Hash表操作 12 set操作 13 do_item_alloc操作 14 item结构 15 Hash表扩容 16 线程交互 17 状态机 ​ 如需下载本站全部技术文章,可以在【高性能服务器开发】公众号回复关键字“文章下载”即可。最近更新时间: 2020-10-08 12:28:04 "},"articles/Memcached源码分析/00服务器资源调整.html":{"url":"articles/Memcached源码分析/00服务器资源调整.html","title":"00 服务器资源调整","keywords":"","body":"Memcached源码阅读序 服务器资源调整 本篇作为Memcached源码分析的开篇,这次阅读的源码版本为: 1.4.15,开源软件各个版本之间差异比较大,同学们学习时,记得核对版本。 memcached的main函数位于memcached.c文件中,从main函数启动之后,会初始化一些资源和申请一些服务器资源,如下面所示: 1 Core文件大小和进程打开文件个数限制的调整。 if (maxcore != 0) { struct rlimit rlim_new; //获取当前Core文件大小的配置值 if (getrlimit(RLIMIT_CORE, &rlim) == 0) { //变量初始化为无限制 rlim_new.rlim_cur = rlim_new.rlim_max = RLIM_INFINITY; if (setrlimit(RLIMIT_CORE, &rlim_new) != 0)//如果设置失败 { //变量初始化为当前值的最大值 rlim_new.rlim_cur = rlim_new.rlim_max = rlim.rlim_max; (void) setrlimit(RLIMIT_CORE, &rlim_new);//重新进行设置 } } //再次确认Core文件允许的大小,如果当前的Core文件的大小为0,则不允许Core文件产生,和maxcore!=0不符,程序结束 if ((getrlimit(RLIMIT_CORE, &rlim) != 0) || rlim.rlim_cur == 0) { fprintf(stderr, \"failed to ensure corefile creation\\n\"); exit(EX_OSERR); } } //读取进程允许打开的文件数信息,读取失败,程序退出 if (getrlimit(RLIMIT_NOFILE, &rlim) != 0) { fprintf(stderr, \"failed to getrlimit number of files\\n\"); exit(EX_OSERR); } else { //按memcached启动时的指定的最大连接数进行设置 rlim.rlim_cur = settings.maxconns; rlim.rlim_max = settings.maxconns; if (setrlimit(RLIMIT_NOFILE, &rlim) != 0) { fprintf(stderr, \"failed to set rlimit for open files. Try starting as root or requesting smaller maxconns value.\\n\"); exit(EX_OSERR); } } 2 启动用户的选择。 //uid==0表示以root运行程序 if (getuid() == 0 || geteuid() == 0) { //以root运行程序,同时未指定新的用户名称 if (username == 0 || *username == '\\0') { fprintf(stderr, \"can't run as root without the -u switch\\n\"); exit(EX_USAGE); } //判断是否存在指定的用户名称 if ((pw = getpwnam(username)) == 0) { fprintf(stderr, \"can't find the user %s to switch to\\n\", username); exit(EX_NOUSER); } //按新的用户修改memcached的执行权限位 if (setgid(pw->pw_gid) pw_uid) 3 以daemon的方式启动,daemon的实现如下,该daemon没有进行2次fork,APUE上面也有说第二次fork不是必须的。 int daemonize(int nochdir, int noclose) { int fd; //首先fork一次 switch (fork()) { case -1://fork失败,程序结束 return (-1); case 0://子进程执行下面的流程 break; default://父进程安全退出 _exit(EXIT_SUCCESS); } //setsid调用成功之后,返回新的会话的ID,调用setsid函数的进程成为新的会话的领头进程,并与其父进程的会话组和进程组脱离 if (setsid() == -1) return (-1); if (nochdir == 0) { //进程的当前目录切换到根目录下,根目录是一直存在的,其他的目录就不保证 if(chdir(\"/\") != 0) { perror(\"chdir\"); return (-1); } } if (noclose == 0 && (fd = open(\"/dev/null\", O_RDWR, 0)) != -1) { if(dup2(fd, STDIN_FILENO) STDERR_FILENO) { if(close(fd) 4 锁定内存,默认分配的内存都是虚拟内存,在程序执行过程中可以按需换出,如果内存充足的话,可以锁定内存,不让系统将该进程所持有的内存换出。 if (lock_memory) { #ifdef HAVE_MLOCKALL int res = mlockall(MCL_CURRENT | MCL_FUTURE); if (res != 0) { fprintf(stderr, \"warning: -k invalid, mlockall() failed: %s\\n\", strerror(errno)); } #else fprintf(stderr, \"warning: -k invalid, mlockall() not supported on this platform. proceeding without.\\n\"); #endif } 5 忽略PIPE信号,PIPE信号是当网络连接一端已经断开,这时发送数据,会进行RST的重定向,再次发送数据,会触发PIPE信号,而PIPE信号的默认动作是退出进程,所以需要忽略该信号。 if (sigignore(SIGPIPE) == -1) { perror(\"failed to ignore SIGPIPE; sigaction\"); exit(EX_OSERR); } 6 保存daemon进程的进程id到文件中,这样便于控制程序,读取文件内容,即可得到进程ID。 if (pid_file != NULL) { save_pid(pid_file); } 如需下载本站全部技术文章,可以在【高性能服务器开发】公众号回复关键字“文章下载”即可。最近更新时间: 2020-10-03 14:31:55 "},"articles/Memcached源码分析/01初始化参数解析.html":{"url":"articles/Memcached源码分析/01初始化参数解析.html","title":"01 初始化参数解析","keywords":"","body":"Memcached源码阅读一 初始化参数解析 Memcached启动时,有很多配置参数可以选择,这些配置参数严重影响着Memcached的使用,下面分析下这些参数的意义,开源软件版本之间差异比较大,我这次分析是基于1.4.15进行分析的,大家学习时记得核对版本。 \"a:\" //unix socket的权限位信息,unix socket的权限位信息和普通文件的权限位信息一样 \"p:\" //memcached监听的TCP端口值,默认是11211 \"s:\" //unix socket监听的socket文件路径 \"U:\" //memcached监听的UDP端口值,默认是11211 \"m:\" //memcached使用的最大内存值,默认是64M \"M\" //当memcached的内存使用完时,不进行LRU淘汰数据,直接返回错误,该选项就是关闭LRU \"c:\" //memcached的最大连接数,如果不指定,按系统的最大值进行 \"k\" //是否锁定memcached所持有的内存,如果锁定了内存,其他业务持有的内存就会减小 \"hi\" //帮助信息 \"r\" //core文件的大小,如果不指定,按系统的最大值进行 \"v\" //调试信息 \"d\" //设定以daemon方式运行 \"l:\" //绑定的ip信息,如果服务器有多个ip,可以在多个ip上面启动多个Memcached实例,注意:这个不是可接收的IP地址 \"u:\" //memcached运行的用户,如果以root启动,需要指定用户,否则程序错误,退出。 \"P:\" //memcached以daemon方式运行时,保存pid的文件路径信息 \"f:\" //内存的扩容因子,这个关系到Memcached内部初始化空间时的一个变化,后面详细说明 \"n:\" //chunk的最小大小(byte),后续的增长都是该值*factor来进行增长的 \"t:\" //内部worker线程的个数,默认是4个,最大值推荐不超过64个 \"D:\" //内部数据存储时的分割符 \"L\" //指定内存页的大小,默认内存页大小为4K,页最大不超过2M,调大页的大小,可有效减小页表的大小,提高内存访问的效率 \"R:\" //单个worker的最大请求个数 \"C\" //禁用业务的cas,即compare and set \"b:\" //listen操作缓存连接个数 \"B:\" //memcached内部使用的协议,支持二进制协议和文本协议,早期只有文本协议,二进制协议是后续加上的 \"I:\" //单个item的最大值,默认是1M,可以修改,修改的最小值为1k,最大值不能超过128M \"S\" //打开sasl安全协议 \"o:\" /** *有四个参数项可以设置: *maxconns_fast(如果连接数超过最大连接数,立即关闭新的连接) *hashpower(hash表的大小的指数值,是按1Memcached内部是通过settings来抽象上面的这些初始化参数。 struct settings { size_t maxbytes; int maxconns; int port; int udpport; char* inter; int verbose; rel_time_t oldest_live; /* ignore existing items older than this */ int evict_to_free; char* socketpath; /* path to unix socket if using local socket */ int access; /* access mask (a la chmod) for unix domain socket */ double factor; /* chunk size growth factor */ int chunk_size; int num_threads; /* number of worker (without dispatcher) libevent threads to run */ int num_threads_per_udp; /* number of worker threads serving each udp socket */ char prefix_delimiter; /* character that marks a key prefix (for stats) */ int detail_enabled; /* nonzero if we're collecting detailed stats */ int reqs_per_event; /* Maximum number of io to process on each io-event.*/ bool use_cas; enum protocol binding_protocol; int backlog; int item_size_max; /* Maximum item size, and upper end for slabs */ bool sasl; /* SASL on/off */ bool maxconns_fast; /* Whether or not to early close connections */ bool slab_reassign; /* Whether or not slab reassignment is allowed */ int slab_automove; /* Whether or not to automatically move slabs */ int hashpower_init; /* Starting hash power level */ }; 改结构的初始化: static void settings_init(void) { settings.use_cas = true; settings.access = 0700; settings.port = 11211; settings.udpport = 11211; /* By default this string should be NULL for getaddrinfo() */ settings.inter = NULL; settings.maxbytes = 64 * 1024 * 1024; /* default is 64MB */ settings.maxconns = 1024; /* to limit connections-related memory to about 5MB */ settings.verbose = 0; settings.oldest_live = 0; settings.evict_to_free = 1; /* push old items out of cache when memory runs out */ settings.socketpath = NULL; /* by default, not using a unix socket */ settings.factor = 1.25; settings.chunk_size = 48; /* space for a modest key and value */ settings.num_threads = 4; /* N workers */ settings.num_threads_per_udp = 0; settings.prefix_delimiter = ':'; settings.detail_enabled = 0; settings.reqs_per_event = 20; settings.backlog = 1024; settings.binding_protocol = negotiating_prot; settings.item_size_max = 1024 * 1024; /* The famous 1MB upper limit. */ settings.maxconns_fast = false; settings.hashpower_init = 0; settings.slab_reassign = false; settings.slab_automove = 0; } 这些值都是一些默认值,后续按启动时所指定的进行修改,比如对监听端口号的修改: case 'a': //修改unix socket的权限位信息 settings.access = strtol(optarg, NULL, 8); break; case 'U': //udp端口信息 settings.udpport = atoi(optarg); udp_specified = true; break; case 'p': //tcp端口信息 settings.port = atoi(optarg); tcp_specified = true; break; 如需下载本站全部技术文章,可以在【高性能服务器开发】公众号回复关键字“文章下载”即可。最近更新时间: 2020-10-03 14:04:17 "},"articles/Memcached源码分析/02网络监听的建立.html":{"url":"articles/Memcached源码分析/02网络监听的建立.html","title":"02 网络监听的建立","keywords":"","body":"Memcached源码阅读二 网络监听的建立 Memcahced是一个服务器程序,所以需要建立网络监听来接受其他客户端机器的连接,下面分析下其过程,这次分析是基于Memcached 1.4.15版本分析的。 // 如果socketpath为空,则表示使用的TCP / UDP, 不是使用unix socket //如果socketpath为空,则表示使用的TCP/UDP,不是使用unix socket if (settings.socketpath == NULL) { //可以从环境变量读取端口文件所在的文件路径 const char *portnumber_filename = getenv(\"MEMCACHED_PORT_FILENAME\"); char temp_portnumber_filename[PATH_MAX]; FILE *portnumber_file = NULL; //如果端口文件不为空,则打开 if (portnumber_filename != NULL) { snprintf(temp_portnumber_filename, sizeof(temp_portnumber_filename), \"%s.lck\", portnumber_filename); portnumber_file = fopen(temp_portnumber_filename, \"a\"); if (portnumber_file == NULL) { fprintf(stderr, \"Failed to open \\\"%s\\\": %s\\n\", temp_portnumber_filename, strerror(errno)); } } //settings.port表示Memcached采用的是TCP协议,创建TCP Socket,监听并且绑定 errno = 0; if (settings.port && server_sockets(settings.port, tcp_transport, portnumber_file)) { vperror(\"failed to listen on TCP port %d\", settings.port); exit(EX_OSERR); } //settings.udpport表示Memcached采用的是UDP协议,创建UDP Socket,监听并且绑定 errno = 0; if (settings.udpport && server_sockets(settings.udpport, udp_transport, portnumber_file)) { vperror(\"failed to listen on UDP port %d\", settings.udpport); exit(EX_OSERR); } //端口文件不为空 if (portnumber_file) { fclose(portnumber_file);//关闭文件 rename(temp_portnumber_filename, portnumber_filename);//重命名端口文件 } } //TCP和UDP使用的是同一个接口来创建监听和绑定 static int server_sockets(int port, enum network_transport transport, FILE *portnumber_file) { //settings.inter指定的是要绑定的ip地址信息,如果为空,则表示是绑定本机一个ip if (settings.inter == NULL) { //执行监听和绑定操作 return server_socket(settings.inter, port, transport, portnumber_file); } //如果服务器有多个ip信息,可以在每个(ip,port)上面绑定一个Memcached实例,下面是一些输入参数的解析,解析完毕之后,执行绑定 else { // tokenize them and bind to each one of them.. char *b; int ret = 0; char *list = strdup(settings.inter); if (list == NULL) { fprintf(stderr, \"Failed to allocate memory for parsing server interface string\\n\"); return 1; } for (char *p = strtok_r(list, \";,\", &b); p != NULL; p = strtok_r(NULL, \";,\", &b)) { int the_port = port; char *s = strchr(p, ':'); if (s != NULL) { *s = '\\0'; ++s; if (!safe_strtol(s, &the_port)) { fprintf(stderr, \"Invalid port number: \\\"%s\\\"\", s); return 1; } } if (strcmp(p, \"*\") == 0) { p = NULL; } //绑定多次,循环调用单个的绑定函数 ret |= server_socket(p, the_port, transport, portnumber_file); } free(list); return ret; } } //执行真正的绑定 static int server_socket(const char *interface, int port, enum network_transport transport, FILE *portnumber_file) { int sfd; struct linger ling = { 0, 0 }; struct addrinfo *ai; struct addrinfo *next; //设定协议无关,用于监听的标志位 struct addrinfo hints = { .ai_flags = AI_PASSIVE,.ai_family = AF_UNSPEC }; char port_buf[NI_MAXSERV]; int error; int success = 0; int flags = 1; //指定socket的类型,如果是udp,则用数据报协议,如果是tcp,则用数据流协议 hints.ai_socktype = IS_UDP(transport) ? SOCK_DGRAM : SOCK_STREAM; if (port == -1) { port = 0; } snprintf(port_buf, sizeof(port_buf), \"%d\", port); //调用getaddrinfo,将主机地址和端口号映射成为socket地址信息,地址信息由ai带回 error = getaddrinfo(interface, port_buf, &hints, &ai); if (error != 0) { if (error != EAI_SYSTEM) fprintf(stderr, \"getaddrinfo(): %s\\n\", gai_strerror(error)); else perror(\"getaddrinfo()\"); return 1; } /*getaddrinfo返回多个addrinfo的情形有如下两种: 1.如果与interface参数关联的地址有多个,那么适用于所请求地址簇的每个地址都返回一个对应的结构。 2.如果port_buf参数指定的服务支持多个套接口类型,那么每个套接口类型都可能返回一个对应的结构。 */ for (next = ai; next; next = next->ai_next) { conn *listen_conn_add; //为每个地址信息建立socket if ((sfd = new_socket(next)) == -1) { //建立socket过程中可能发生的,比如打开文件描述符过多等 if (errno == EMFILE) { perror(\"server_socket\"); exit(EX_OSERR); } continue; } #ifdef IPV6_V6ONLY if (next->ai_family == AF_INET6) { //设定IPV6的选项值,设置了IPV6_V6ONLY,表示只收发IPV6的数据包,此时IPV4和IPV6可以绑定到同一个端口而不影响数据的收发 error = setsockopt(sfd, IPPROTO_IPV6, IPV6_V6ONLY, (char *)&flags, sizeof(flags)); if (error != 0) { perror(\"setsockopt\"); close(sfd); continue; } } #endif //设定socket选项,SO_REUSEADDR表示重用地址信息,具体重用哪些东西自行学习,必须在bind操作之前设置 setsockopt(sfd, SOL_SOCKET, SO_REUSEADDR, (void *)&flags, sizeof(flags)); if (IS_UDP(transport)){ maximize_sndbuf(sfd);//扩大发送缓冲区 } else { //设定socket选项,SO_KEEPALIVE表示保活 error = setsockopt(sfd, SOL_SOCKET, SO_KEEPALIVE, (void *)&flags, sizeof(flags)); if (error != 0) perror(\"setsockopt\"); //设定socket选项,SO_LINGER表示执行close操作时,如果缓冲区还有数据,可以继续发送 error = setsockopt(sfd, SOL_SOCKET, SO_LINGER, (void *)&ling, sizeof(ling)); if (error != 0) perror(\"setsockopt\"); //设定IP选项,TCP_NODELAY表示禁用Nagle算法 error = setsockopt(sfd, IPPROTO_TCP, TCP_NODELAY, (void *)&flags, sizeof(flags)); if (error != 0) perror(\"setsockopt\"); } if (bind(sfd, next->ai_addr, next->ai_addrlen) == -1) { if (errno != EADDRINUSE) { perror(\"bind()\"); close(sfd); freeaddrinfo(ai); return 1; } close(sfd); continue; } else { success++; //如果不是UDP协议,则执行监听操作,监听队列为初始启动的值 if (!IS_UDP(transport) && listen(sfd, settings.backlog) == -1) { perror(\"listen()\"); close(sfd); freeaddrinfo(ai); return 1; } if (portnumber_file != NULL && (next->ai_addr->sa_family == AF_INET || next->ai_addr->sa_family == AF_INET6)) { union { struct sockaddr_in in; struct sockaddr_in6 in6; } my_sockaddr; socklen_t len = sizeof(my_sockaddr); //这时还没连接建立,调用getsockname不知道有什么用? if (getsockname(sfd, (struct sockaddr*) &my_sockaddr, &len) == 0) { if (next->ai_addr->sa_family == AF_INET) { fprintf(portnumber_file, \"%s INET: %u\\n\", IS_UDP(transport) ? \"UDP\" : \"TCP\", ntohs(my_sockaddr.in.sin_port)); } else { fprintf(portnumber_file, \"%s INET6: %u\\n\", IS_UDP(transport) ? \"UDP\" : \"TCP\", ntohs(my_sockaddr.in6.sin6_port)); } } } } if (IS_UDP(transport)) { int c; for (c = 0; c next = listen_conn; listen_conn = listen_conn_add; //释放资源 freeaddrinfo(ai); return success == 0; } //建立socket static int new_socket(struct addrinfo *ai) { int sfd; int flags; //调用系统函数建立socket if ((sfd = socket(ai->ai_family, ai->ai_socktype, ai->ai_protocol)) == -1) { return -1; } //设定socket为非阻塞的 if ((flags = fcntl(sfd, F_GETFL, 0)) 0) perror(\"getsockopt(SO_SNDBUF)\"); return; } //二分搜索来设定,很巧的设计 min = old_size; max = MAX_SENDBUF_SIZE; while (min 1) fprintf(stderr, \"至此,网络相关的部分已经完成,后向连接的建立(conn_new)和连接分发(dispatch_conn_new),我们放到其他文章中进行分析。 如需下载本站全部技术文章,可以在【高性能服务器开发】公众号回复关键字“文章下载”即可。最近更新时间: 2020-10-03 14:06:36 "},"articles/Memcached源码分析/03网络连接建立.html":{"url":"articles/Memcached源码分析/03网络连接建立.html","title":"03 网络连接建立","keywords":"","body":"Memcached源码分析三 网络连接建立 接着上一篇继续分析,上一篇请参考 《Memcached源码阅读之网络监听的建立》,这篇主要分析TCP的连接建立(从前面的代码分析可以看出,这个过程是由主线程驱动的),UDP没有连接建立的过程,所以之间进行连接分发,我们后续分析,现在直接上代码进行讲解。 conn *conn_new(const int sfd, enum conn_states init_state, const int event_flags, const int read_buffer_size, enum network_transport transport, struct event_base *base) { conn *c = conn_from_freelist(); //获取一个空闲连接,conn是Memcached内部对网络连接的一个封装 //如果没有空闲的连接 if (NULL == c) { if (!(c = (conn *)calloc(1, sizeof(conn))))//申请空间 { fprintf(stderr, \"calloc()\\n\"); return NULL; }MEMCACHED_CONN_CREATE(c); //进行一些初始化 c->rbuf = c->wbuf = 0; c->ilist = 0; c->suffixlist = 0; c->iov = 0; c->msglist = 0; c->hdrbuf = 0; c->rsize = read_buffer_size; c->wsize = DATA_BUFFER_SIZE; c->isize = ITEM_LIST_INITIAL; c->suffixsize = SUFFIX_LIST_INITIAL; c->iovsize = IOV_LIST_INITIAL; c->msgsize = MSG_LIST_INITIAL; c->hdrsize = 0; //每个conn都自带读入和输出缓冲区,在进行网络收发数据时,特别方便 c->rbuf = (char *)malloc((size_t)c->rsize); c->wbuf = (char *)malloc((size_t)c->wsize); c->ilist = (item **)malloc(sizeof(item *) * c->isize); c->suffixlist = (char **)malloc(sizeof(char *) * c->suffixsize); c->iov = (struct iovec *) malloc(sizeof(struct iovec) * c->iovsize); c->msglist = (struct msghdr *) malloc( sizeof(struct msghdr) * c->msgsize); if (c->rbuf == 0 || c->wbuf == 0 || c->ilist == 0 || c->iov == 0 || c->msglist == 0 || c->suffixlist == 0) { conn_free(c); fprintf(stderr, \"malloc()\\n\"); return NULL; } STATS_LOCK(); //统计变量更新 stats.conn_structs++; STATS_UNLOCK(); } c->transport = transport; c->protocol = settings.binding_protocol; if (!settings.socketpath) { c->request_addr_size = sizeof(c->request_addr); } else { c->request_addr_size = 0; } //输出一些日志信息 if (settings.verbose > 1) { if (init_state == conn_listening) { fprintf(stderr, \"protocol)); } else if (IS_UDP(transport)) { fprintf(stderr, \"protocol == negotiating_prot) { fprintf(stderr, \"protocol == ascii_prot) { fprintf(stderr, \"protocol == binary_prot) { fprintf(stderr, \"protocol); assert(false); } } c->sfd = sfd; c->state = init_state; c->rlbytes = 0; c->cmd = -1; c->rbytes = c->wbytes = 0; c->wcurr = c->wbuf; c->rcurr = c->rbuf; c->ritem = 0; c->icurr = c->ilist; c->suffixcurr = c->suffixlist; c->ileft = 0; c->suffixleft = 0; c->iovused = 0; c->msgcurr = 0; c->msgused = 0; c->write_and_go = init_state; c->write_and_free = 0; c->item = 0; c->noreply = false; //建立sfd描述符上面的event事件,事件回调函数为event_handler event_set(&c->event, sfd, event_flags, event_handler, (void *)c); event_base_set(base, &c->event); c->ev_flags = event_flags; if (event_add(&c->event, 0) == -1) { //如果建立libevent事件失败,将创建的conn添加到空闲列表中 if (conn_add_to_freelist(c)) { conn_free(c); } perror(\"event_add\"); return NULL; } STATS_LOCK(); //统计信息更新 stats.curr_conns++; stats.total_conns++; STATS_UNLOCK(); MEMCACHED_CONN_ALLOCATE(c->sfd); return c; } //获得conn conn *conn_from_freelist() { conn *c; pthread_mutex_lock(&conn_lock);//操作链表,加锁,保持同步 //freecurr为静态全局变量 if (freecurr > 0) { //freeconns是在Memcached启动时初始化的 c = freeconns[--freecurr]; } else//没有conn { c = NULL; } pthread_mutex_unlock(&conn_lock); return c; } //添加conn到空闲链表中 bool conn_add_to_freelist(conn *c) { bool ret = true; pthread_mutex_lock(&conn_lock); //freeconns还有空间 if (freecurr which = which; //这种情况应该很少出现 if (fd != c->sfd) { if (settings.verbose > 0) fprintf(stderr, \"Catastrophic: event fd doesn't match conn fd!\\n\"); conn_close(c); return; } //进入业务处理状态机 drive_machine(c); return; } 如需下载本站全部技术文章,可以在【高性能服务器开发】公众号回复关键字“文章下载”即可。最近更新时间: 2020-10-03 14:37:24 "},"articles/Memcached源码分析/04内存初始化.html":{"url":"articles/Memcached源码分析/04内存初始化.html","title":"04 内存初始化","keywords":"","body":"Memcached源码阅读四 内存初始化 Memcached作为内存cache服务器,内存高效管理是其最重要的任务之一,Memcached使用SLAB管理其内存,SLAB内存管理直观的解释就是分配一块大的内存,之后按不同的块(48byte, 64byte, … 1M)等切分这些内存,存储业务数据时,按需选择合适的内存空间存储数据。 Memcached首次默认分配64M的内存,之后所有的数据都是在这64M空间进行存储,在Memcached启动之后,不会对这些内存执行释放操作,这些内存只有到Memcached进程退出之后会被系统回收,下面分析下Memcached的内存初始化过程。 //内存初始化,settings.maxbytes是Memcached初始启动参数指定的内存值大小,settings.factor是内存增长因子 slabs_init(settings.maxbytes, settings.factor, preallocate); #define POWER_SMALLEST 1 //最小slab编号 #define POWER_LARGEST 200 //首次初始化200个slab //实现内存池管理相关的静态全局变量 static size_t mem_limit = 0;//总的内存大小 static size_t mem_malloced = 0;//初始化内存的大小,这个貌似没什么用 static void *mem_base = NULL;//指向总的内存的首地址 static void *mem_current = NULL;//当前分配到的内存地址 static size_t mem_avail = 0;//当前可用的内存大小 static slabclass_t slabclass[MAX_NUMBER_OF_SLAB_CLASSES];//定义slab结合,总共200个 void slabs_init(const size_t limit, const double factor, const bool prealloc) { int i = POWER_SMALLEST - 1; //size表示申请空间的大小,其值由配置的chunk_size和单个item的大小来指定 unsigned int size = sizeof(item) + settings.chunk_size; mem_limit = limit;//mem_limit是全局变量 if (prealloc) { //支持预分配 mem_base = malloc(mem_limit);//申请地址,mem_base指向申请的地址 if (mem_base != NULL) { //mem_current指向当前地址 mem_current = mem_base; //可用内存大小为mem_limit mem_avail = mem_limit; } else { //支持预分配失败 fprintf(stderr, \"Warning: Failed to allocate requested memory in\" \" one large chunk.\\nWill allocate in smaller chunks\\n\"); } } //置空slabclass数组 memset(slabclass, 0, sizeof(slabclass)); //开始分配,i 1) { //如果有打开调试信息,则输出调试信息 fprintf(stderr, \"slab class %3d: chunk size %9u perslab %7u\\n\", i, slabclass[i].size, slabclass[i].perslab); } } //循环结束时,size已经增长到1M power_largest = i;//再增加一个slab slabclass[power_largest].size = settings.item_size_max; //slab的size为item_size_max slabclass[power_largest].perslab = 1;//chunk个数为1 //打印调试信息 if (settings.verbose > 1) { fprintf(stderr, \"slab class %3d: chunk size %9u perslab %7u\\n\", i, slabclass[i].size, slabclass[i].perslab); } //读取环境变量T_MEMD_INITIAL_MALLOC的值 { char *t_initial_malloc = getenv(\"T_MEMD_INITIAL_MALLOC\"); if (t_initial_malloc) { mem_malloced = (size_t)atol(t_initial_malloc); } } if (prealloc) { //分配每个slab的内存空间,传入最大已经初始化的最大slab编号 slabs_preallocate(power_largest); } } //分配每个slab的内存空间 static void slabs_preallocate (const unsigned int maxslabs) { int i; unsigned int prealloc = 0; for (i = POWER_SMALLEST; i maxslabs) return; //执行分配操作,对第i个slabclass执行分配操作 if (do_slabs_newslab(i) == 0) { fprintf(stderr, \"Error while preallocating slab memory!\\n\" \"If using -L or other prealloc options, max memory must be \" \"at least %d megabytes.\\n\", power_largest); exit(1); } } } //执行分配操作 static int do_slabs_newslab(const unsigned int id) { slabclass_t *p = &slabclass[id];//p指向第i个slabclass int len = settings.slab_reassign ? settings.item_size_max:p->size*p->perslab; char *ptr; //grow_slab_list初始化slabclass的slab_list,而slab_list中的指针指向每个slab //memory_allocate从内存池申请1M的空间 if ((mem_limit && mem_malloced + len > mem_limit && p->slabs > 0) || (grow_slab_list(id) == 0) || ((ptr = memory_allocate((size_t)len)) == 0)) { MEMCACHED_SLABS_SLABCLASS_ALLOCATE_FAILED(id); return 0; } memset(ptr, 0, (size_t)len); //将申请的1M空间按slabclass的size进行切分 split_slab_page_into_freelist(ptr, id); p->slab_list[p->slabs++] = ptr;//循环分配 mem_malloced += len;//增加已经分配出去的内存数 MEMCACHED_SLABS_SLABCLASS_ALLOCATE(id); return 1; } //初始化slabclass的slab_class,而slab_list中的指针指向每个slab,id为slabclass的序号 static int grow_slab_list (const unsigned int id) { slabclass_t *p = &slabclass[id]; //p指向第id个slabclass; if (p->slabs == p->list_size) { size_t new_size = (p->list_size != 0) ? p->list_size * 2 : 16;//new_size如果是首次分配,则取16,否则按旧值的2倍扩容 void *new_list = realloc(p->slab_list, new_size * sizeof(void *));//申请空间,这个空间是从系统分配,不是从内存池分配 if (new_list == 0) return 0; p->list_size = new_size;//修改第id个slabclass的值 p->slab_list = new_list; } return 1; } //从内存池分配size个空间 static void *memory_allocate(size_t size) { void *ret; if (mem_base == NULL) {//如果内存池没创建,则从系统分配 ret = malloc(size); } else { ret = mem_current; //size大于剩余的空间 if (size > mem_avail) { return NULL; } //按8字节对齐 if (size % CHUNK_ALIGN_BYTES) { size += CHUNK_ALIGN_BYTES - (size % CHUNK_ALIGN_BYTES); } //扣除size个空间 mem_current = ((char*)mem_current) + size; if (size perslab; x++) { do_slabs_free(ptr, 0, id);//创建空闲item ptr += p->size; } } //创建空闲item static void do_slabs_free(void *ptr, const size_t size, unsigned int id) { slabclass_t *p; item *it; assert(((item *)ptr)->slabs_clsid == 0); assert(id >= POWER_SMALLEST && id power_largest) return; MEMCACHED_SLABS_FREE(size, id, ptr); p = &slabclass[id]; it = (item *)ptr; it->it_flags |= ITEM_SLABBED; it->prev = 0; it->next = p->slots;//挂载到slabclass的空闲链表中 if (it->next) it->next->prev = it; p->slots = it; p->sl_curr++;//空闲item个数+1 p->requested -= size;//已经申请到的空间数量更新 return; } 如需下载本站全部技术文章,可以在【高性能服务器开发】公众号回复关键字“文章下载”即可。最近更新时间: 2020-10-03 14:09:57 "},"articles/Memcached源码分析/05资源初始化.html":{"url":"articles/Memcached源码分析/05资源初始化.html","title":"05 资源初始化","keywords":"","body":"Memcached源码阅读五 资源初始化 Memcached内部有hash表,各种统计信息,工作线程,网络,连接,内存结构等,在memcached启动时(执行main函数),会对这些资源进行初始化的,网络和内存的初始化操作放到后续分析,这次分析hash表,统计信息,工作线程,网络连接的初始化过程。 1 hash表的初始化 //hash表的初始化,传入的参数是启动时传入的 assoc_init(settings.hashpower_init); //hashsize的实现 #define hashsize(n) ((ub4)12 统计信息的初始化 Memcached内部有很多全局的统计信息,用于实时获取各个资源的使用情况,后面将会看到,所有对统计信息的更新都需要加锁,而这些信息的更新是和Memcached的操作次数同数量级的,所以,在一定程度来说,这些统计信息对性能有影响。 stats结构是对统计信息的一个抽象,各个字段都比较好理解,不做解释。 struct stats { pthread_mutex_t mutex; unsigned int curr_items; unsigned int total_items; uint64_t curr_bytes; unsigned int curr_conns; unsigned int total_conns; uint64_t rejected_conns; unsigned int reserved_fds; unsigned int conn_structs; uint64_t get_cmds; uint64_t set_cmds; uint64_t touch_cmds; uint64_t get_hits; uint64_t get_misses; uint64_t touch_hits; uint64_t touch_misses; uint64_t evictions; uint64_t reclaimed; time_t started; /* when the process was started */ bool accepting_conns; /* whether we are currently accepting */ uint64_t listen_disabled_num; unsigned int hash_power_level; /* Better hope it's not over 9000 */ uint64_t hash_bytes; /* size used for hash tables */ bool hash_is_expanding; /* If the hash table is being expanded */ uint64_t expired_unfetched; /* items reclaimed but never touched */ uint64_t evicted_unfetched; /* items evicted but never touched */ bool slab_reassign_running; /* slab reassign in progress */ uint64_t slabs_moved; /* times slabs were moved around */ }; 统计信息的初始化也就是对stats变量的一个初始化。 //全局对象的定义 struct stats stats; //全局变量的初始化,该全局变量在memcached启动之后,一直使用 static void stats_init(void) { stats.curr_items = stats.total_items = stats.curr_conns = stats.total_conns = stats.conn_structs = 0; stats.get_cmds = stats.set_cmds = stats.get_hits = stats.get_misses = stats.evictions = stats.reclaimed = 0; stats.touch_cmds = stats.touch_misses = stats.touch_hits = stats.rejected_conns = 0; stats.curr_bytes = stats.listen_disabled_num = 0; stats.hash_power_level = stats.hash_bytes = stats.hash_is_expanding = 0; stats.expired_unfetched = stats.evicted_unfetched = 0; stats.slabs_moved = 0; stats.accepting_conns = true; /* assuming we start in this state. */ stats.slab_reassign_running = false; /* make the time we started always be 2 seconds before we really did, so time(0) - time.started is never zero. if so, things like 'settings.oldest_live' which act as booleans as well as values are now false in boolean context... */ process_started = time(0) - 2; stats_prefix_init(); } 3 工作线程的初始化 Memcached采用了典型的Master-Worker的线程模式,Master就是由main线程来充当,而Worker线程则是通过Pthread创建的。 //传入线程个数和libevent的main_base实例 thread_init(settings.num_threads, main_base); //工作线程初始化 void thread_init(int nthreads, struct event_base *main_base) { int i; int power; //初始化各种锁和条件变量 pthread_mutex_init(&cache_lock, NULL); pthread_mutex_init(&stats_lock, NULL); pthread_mutex_init(&init_lock, NULL); pthread_cond_init(&init_cond, NULL); pthread_mutex_init(&cqi_freelist_lock, NULL); cqi_freelist = NULL; //Memcached对hash桶的锁采用分段锁,按线程个数来分段,默认总共是1base = event_init();//创建libevent实例 if (! me->base) { fprintf(stderr, \"Can't allocate event base\\n\"); exit(1); } //创建管道读的libevent事件,事件的回调函数处理具体的业务信息,关于回调函数的处理,后续分析 event_set(&me->notify_event, me->notify_receive_fd, EV_READ | EV_PERSIST, thread_libevent_process, me); event_base_set(me->base, &me->notify_event);//设置libevent实例 //添加事件到libevent中 if (event_add(&me->notify_event, 0) == -1) { fprintf(stderr, \"Can't monitor libevent notify pipe\\n\"); exit(1); } //创建消息队列,用于接受主线程连接 me->new_conn_queue = malloc(sizeof(struct conn_queue)); if (me->new_conn_queue == NULL) { perror(\"Failed to allocate memory for connection queue\"); exit(EXIT_FAILURE); } cq_init(me->new_conn_queue);//消息队列初始化 if (pthread_mutex_init(&me->stats.mutex, NULL) != 0) { perror(\"Failed to initialize mutex\"); exit(EXIT_FAILURE); } //创建线程的后缀cache,没搞懂这个cache有什么作用。 me->suffix_cache = cache_create(\"suffix\", SUFFIX_SIZE, sizeof(char*), NULL, NULL); if (me->suffix_cache == NULL) { fprintf(stderr, \"Failed to create suffix cache\\n\"); exit(EXIT_FAILURE); } } //创建工作线程 static void create_worker(void *(*func)(void *), void *arg) { pthread_t thread; pthread_attr_t attr; int ret; pthread_attr_init(&attr);//Posix线程部分,线程属性初始化 //通过pthread_create创建线程,线程处理函数是通过外部传入的处理函数为worker_libevent if ((ret = pthread_create(&thread, &attr, func, arg)) != 0) { fprintf(stderr, \"Can't create thread: %s\\n\", strerror(ret)); exit(1); } } //线程处理函数 static void *worker_libevent(void *arg) { LIBEVENT_THREAD *me = arg; //默认的hash表的锁为局部锁 me->item_lock_type = ITEM_LOCK_GRANULAR; pthread_setspecific(item_lock_type_key, &me->item_lock_type);//设定线程的属性 //用于控制工作线程初始化,通过条件变量来控制 register_thread_initialized(); //工作线程的libevent实例启动 event_base_loop(me->base, 0); return NULL; } //阻塞工作线程 static void wait_for_thread_registration(int nthreads) { while (init_count 4 连接的初始化 static conn **freeconns;//空闲连接列表 //连接初始化 static void conn_init(void) { freetotal = 200;//空闲连接总数 freecurr = 0;//当前空闲的索引 //申请200个空间 if ((freeconns = calloc(freetotal, sizeof(conn *))) == NULL) { fprintf(stderr, \"Failed to allocate connection structures\\n\"); } return; } 如需下载本站全部技术文章,可以在【高性能服务器开发】公众号回复关键字“文章下载”即可。最近更新时间: 2020-10-03 14:14:47 "},"articles/Memcached源码分析/06get过程.html":{"url":"articles/Memcached源码分析/06get过程.html","title":"06 get过程","keywords":"","body":"Memcached源码阅读六 get过程 我们在前面分析过,Memcached从网络读取完数据,解析数据,如果是get操作,则执行get操作,下面我们分析下get操作的流程。 //根据key信息和key的长度信息读取数据 item *item_get(const char *key, const size_t nkey) { item *it; uint32_t hv; hv = hash(key, nkey, 0);//获得分段锁信息,如果未进行扩容,则item的hash表是多个hash桶共用同一个锁,即是分段的锁 item_lock(hv);//执行分段加锁 it = do_item_get(key, nkey, hv);//执行get操作 item_unlock(hv);//释放锁 return it; } //执行分段加锁 void item_lock(uint32_t hv) { uint8_t *lock_type = pthread_getspecific(item_lock_type_key); if (likely(*lock_type == ITEM_LOCK_GRANULAR)) { mutex_lock(&item_locks[(hv & hashmask(hashpower)) % item_lock_count]);//执行分段加锁 } else {//如果在扩容过程中 mutex_lock(&item_global_lock); } } //执行分段解锁 void item_unlock(uint32_t hv) { uint8_t *lock_type = pthread_getspecific(item_lock_type_key); if (likely(*lock_type == ITEM_LOCK_GRANULAR)) { mutex_unlock(&item_locks[(hv & hashmask(hashpower)) % item_lock_count]);//释放分段锁 } else {//如果在扩容过程中 mutex_unlock(&item_global_lock); } } //执行读取操作 item *do_item_get(const char *key, const size_t nkey, const uint32_t hv) { item *it = assoc_find(key, nkey, hv);//从Hash表中获取相应的结构 if (it != NULL) { refcount_incr(&it->refcount);//item的引用次数+1 if (slab_rebalance_signal && //如果正在进行slab调整,且该item是调整的对象 ((void *)it >= slab_rebal.slab_start && (void *)it 2) { if (it == NULL) { fprintf(stderr, \"> NOT FOUND %s\", key); } else { fprintf(stderr, \"> FOUND KEY %s\", ITEM_key(it)); was_found++; } } if (it != NULL) { //判断Memcached初始化是否开启过期删除机制,如果开启,则执行删除相关操作 if (settings.oldest_live != 0 && settings.oldest_live time exptime != 0 && it->exptime it_flags |= ITEM_FETCHED;//item的标识修改为已经读取 DEBUG_REFCNT(it, '+'); } } if (settings.verbose > 2) fprintf(stderr, \"\\n\"); return it; } //移除item void do_item_remove(item *it) { MEMCACHED_ITEM_REMOVE(ITEM_key(it), it->nkey, it->nbytes); assert((it->it_flags & ITEM_SLABBED) == 0);//判断item的状态是否正确 if (refcount_decr(&it->refcount) == 0) {//修改item的引用次数 item_free(it);//释放item } } //释放item void item_free(item *it) { size_t ntotal = ITEM_ntotal(it);//获得item的大小 unsigned int clsid; assert((it->it_flags & ITEM_LINKED) == 0);//判断item的状态是否正确 assert(it != heads[it->slabs_clsid]);//item不能为LRU的头指针 assert(it != tails[it->slabs_clsid]);//item不能为LRU的尾指针 assert(it->refcount == 0);//释放时,需保证引用次数为0 /* so slab size changer can tell later if item is already free or not */ clsid = it->slabs_clsid; it->slabs_clsid = 0;//断开slabclass的链接 DEBUG_REFCNT(it, 'F'); slabs_free(it, ntotal, clsid);//slabclass结构执行释放 } //slabclass结构释放 void slabs_free(void *ptr, size_t size, unsigned int id) { pthread_mutex_lock(&slabs_lock);//保持同步 do_slabs_free(ptr, size, id);//执行释放 pthread_mutex_unlock(&slabs_lock); } //slabclass结构释放 static void do_slabs_free(void *ptr, const size_t size, unsigned int id) { slabclass_t *p; item *it; assert(((item *)ptr)->slabs_clsid == 0);//判断数据是否正确 assert(id >= POWER_SMALLEST && id power_largest)//判断id合法性 return; MEMCACHED_SLABS_FREE(size, id, ptr); p = &slabclass[id]; it = (item *)ptr; it->it_flags |= ITEM_SLABBED;//修改item的状态标识,修改为空闲 it->prev = 0;//断开数据链表 it->next = p->slots; if (it->next) it->next->prev = it; p->slots = it; p->sl_curr++;//空闲item个数+1 p->requested -= size;//空间增加size return; } //将item从hashtable和LRU链中移除。是do_item_link的逆操作 void do_item_unlink(item *it, const uint32_t hv) { MEMCACHED_ITEM_UNLINK(ITEM_key(it), it->nkey, it->nbytes); mutex_lock(&cache_lock);//执行同步 if ((it->it_flags & ITEM_LINKED) != 0) {//判断状态值,保证item还在LRU队列中 it->it_flags &= ~ITEM_LINKED;//修改状态值 STATS_LOCK();//更新统计信息 stats.curr_bytes -= ITEM_ntotal(it); stats.curr_items -= 1; STATS_UNLOCK(); assoc_delete(ITEM_key(it), it->nkey, hv);//从Hash表中删除 item_unlink_q(it);//将item从slabclass对应的LRU队列摘除 do_item_remove(it);//移除item } mutex_unlock(&cache_lock); } Memcached的get操作在读取数据时,会判断数据的有效性,使得不用额外去处理过期数据,get操作牵涉到Slab结构,Hash表,LRU队列的更新,我们后面专门分析这些的变更,这里暂不分析。 如需下载本站全部技术文章,可以在【高性能服务器开发】公众号回复关键字“文章下载”即可。最近更新时间: 2020-10-03 14:15:43 "},"articles/Memcached源码分析/07cas属性.html":{"url":"articles/Memcached源码分析/07cas属性.html","title":"07 cas属性","keywords":"","body":"Memcached源码阅读七 cas属性 cas即compare and set或者compare and swap,是实现乐观锁的一种技术,乐观锁是相对悲观锁来说的,所谓悲观锁是在数据处理过程中,完全锁定,这种能完全保证数据的一致性,但在多线程情况下,并发性能差,通常是使用各种锁技术实现;而乐观锁是通过版本号机制来实现数据一致性,过程中会使用CPU提供的原子操作指令,乐观锁能提高系统的并发性能,Memcached使用cas是保证数据的一致性,不是严格为了实现锁。 Memcached是多客户端应用,在多个客户端修改同一个数据时,会出现相互覆盖的情况,在这种情况下,使用cas版本号验证,可以有效的保证数据的一致性,Memcached默认是打开cas属性的,每次存储数据时,都会生成其cas值并和item一起存储,后续的get操作会返回系统生成的cas值,在执行set等操作时,需要将cas值传入,下面我们看看Memcached内部是如何实现cas的,关于如何使用Mecached的CAS协议,请参考文章:Memcached的CAS协议(链接:http://langyu.iteye.com/blog/680052)。 //为新的item生成cas值 uint64_t get_cas_id(void) { static uint64_t cas_id = 0; return ++cas_id; } //这段代码是store_item的代码片段,这里是执行cas存储时执行的判断逻辑, else if (ITEM_get_cas(it) == ITEM_get_cas(old_it))//cas值一致 { pthread_mutex_lock(&c->thread->stats.mutex); c->thread->stats.slab_stats[old_it->slabs_clsid].cas_hits++; pthread_mutex_unlock(&c->thread->stats.mutex); item_replace(old_it, it, hv);//执行存储逻辑 stored = STORED; } //cas值不一致,不进行实际的存储 else { pthread_mutex_lock(&c->thread->stats.mutex); c->thread->stats.slab_stats[old_it->slabs_clsid].cas_badval++; //更新统计信息 pthread_mutex_unlock(&c->thread->stats.mutex); if (settings.verbose > 1) { //打印错误日志 fprintf(stderr, \"CAS: failure: expected %llu, got %llu\\n\", (unsigned long long) ITEM_get_cas(old_it), (unsigned long long) ITEM_get_cas(it)); } stored = EXISTS; } 如需下载本站全部技术文章,可以在【高性能服务器开发】公众号回复关键字“文章下载”即可。最近更新时间: 2020-10-03 14:16:32 "},"articles/Memcached源码分析/08内存池.html":{"url":"articles/Memcached源码分析/08内存池.html","title":"08 内存池","keywords":"","body":"Memcached源码阅读八 内存池 Memcached内部维护了一个内存池来减少频繁的malloc和free,在该内存池的基础上面实现了slab内存管理,下面简单介绍下内存池的实现,大家在实现类似结构时,可以做个参考。 static void *mem_base = NULL;//mem_base指向新申请的内存空间,指向整个内存空间的头部 static void *mem_current = NULL;//指向已经分配过的空间,且指向已经分配了空间的尾部 static size_t mem_avail = 0;//剩余空间大小 //部分初始化操作 mem_limit = limit;//初始容量 mem_base = malloc(mem_limit);//申请内存空间 if (mem_base != NULL) //如果不为空 { mem_current = mem_base; //当前还没分配,所以其指向为整个空间 mem_avail = mem_limit; //可用空间为满 } else { fprintf(stderr, \"Warning: Failed to allocate requested memory in\" \" one large chunk.\\nWill allocate in smaller chunks\\n\"); } //分配空间的过程,分配size大小的空间 static void *memory_allocate(size_t size) { void *ret; //如果未初始化 if (mem_base == NULL) { ret = malloc(size); } else { ret = mem_current; if (size > mem_avail) { return NULL; } //执行对齐操作 if (size % CHUNK_ALIGN_BYTES) { size += CHUNK_ALIGN_BYTES - (size % CHUNK_ALIGN_BYTES); } mem_current = ((char*)mem_current) + size; if (size 如需下载本站全部技术文章,可以在【高性能服务器开发】公众号回复关键字“文章下载”即可。最近更新时间: 2020-10-03 14:17:03 "},"articles/Memcached源码分析/09连接队列.html":{"url":"articles/Memcached源码分析/09连接队列.html","title":"09 连接队列","keywords":"","body":"Memcached源码阅读九 连接队列 Memcached中Master线程和Worker线程之间通信连接信息时,是通过连接队列来通信的,即Master线程投递一个消息到Worker线程的连接队列中,Worker线程从连接队列中读取链接信息来执行连接操作,下面我们简单分析下Memcached的连接队列结构。 typedef struct conn_queue_item CQ_ITEM;//每个连接信息的封装 struct conn_queue_item { int sfd;//accept之后的描述符 enum conn_states init_state;//连接的初始状态 int event_flags;//libevent标志 int read_buffer_size;//读取数据缓冲区大小 enum network_transport transport;//内部通信所用的协议 CQ_ITEM *next;//用于实现链表的指针 }; typedef struct conn_queue CQ;//连接队列的封装 struct conn_queue { CQ_ITEM *head;//头指针,注意这里是单链表,不是双向链表 CQ_ITEM *tail;//尾部指针, pthread_mutex_t lock;//锁 pthread_cond_t cond;//条件变量 }; //连接队列初始化 static void cq_init(CQ *cq) { pthread_mutex_init(&cq->lock, NULL);//初始化锁 pthread_cond_init(&cq->cond, NULL);//初始化条件变量 cq->head = NULL; cq->tail = NULL; } //获取一个连接 static CQ_ITEM *cq_pop(CQ *cq) { CQ_ITEM *item; pthread_mutex_lock(&cq->lock);//执行加锁操作 item = cq->head; //获得头部指针指向的数据 if (NULL != item) { //更新头指针信息 cq->head = item->next; //这里为空的话,则尾指针也为空,链表此时为空 if (NULL == cq->head) cq->tail = NULL; } //释放锁操作 pthread_mutex_unlock(&cq->lock); return item; } //添加一个连接信息 static void cq_push(CQ *cq, CQ_ITEM *item) { item->next = NULL; pthread_mutex_lock(&cq->lock);//执行加锁操作 //如果链表目前是空的 if (NULL == cq->tail) //则头指针指向该结点 cq->head = item; else cq->tail->next = item;//添加到尾部 cq->tail = item; //尾部指针后移 pthread_cond_signal(&cq->cond); //唤醒条件变量,如果有阻塞在该条件变量的线程,则会唤醒该线程 pthread_mutex_unlock(&cq->lock); } //创建连接队列 static CQ_ITEM *cqi_new(void) { CQ_ITEM *item = NULL; pthread_mutex_lock(&cqi_freelist_lock); //加锁,保持数据同步 if (cqi_freelist) { //更新空闲链表信息 item = cqi_freelist; cqi_freelist = item->next; } pthread_mutex_unlock(&cqi_freelist_lock); //如果空闲链表没有多余的链接 if (NULL == item) { int i; //初始化64个空闲连接信息 item = malloc(sizeof(CQ_ITEM) * ITEMS_PER_ALLOC); if (NULL == item) return NULL; //将空闲的连接信息进行链接 for (i = 2; i next = cqi_freelist; cqi_freelist = item; pthread_mutex_unlock(&cqi_freelist_lock); } 空闲链表类似于一种连接池的实现,服务器开发中经常需要各种池操作,大家在实现类似池时,可以做参考。 如需下载本站全部技术文章,可以在【高性能服务器开发】公众号回复关键字“文章下载”即可。最近更新时间: 2020-10-03 14:18:03 "},"articles/Memcached源码分析/10Hash表操作.html":{"url":"articles/Memcached源码分析/10Hash表操作.html","title":"10 Hash表操作","keywords":"","body":"Memcached源码阅读十 Hash表操作 Memcached的Hash表用来提高数据访问性能,通过链接法来解决Hash冲突,当Hash表中数据多余Hash表容量的1.5倍时,Hash表就会扩容,Memcached的Hash表操作没什么特别的,我们这里简单介绍下Memcached里面的Hash表操作。 //hash表插入元素 int assoc_insert(item *it, const uint32_t hv) { unsigned int oldbucket; //如果已经开始扩容,且扩容的桶编号大于目前的item所在桶的编号 if (expanding && (oldbucket = (hv & hashmask(hashpower - 1))) >= expand_bucket) { //这里是类似单链表的,按单链表的操作进行插入 it->h_next = old_hashtable[oldbucket]; old_hashtable[oldbucket] = it; } else { //已经扩容,则按新的Hash规则进行路由 it->h_next = primary_hashtable[hv & hashmask(hashpower)]; //这里在新的Hash表中执行单链表插入 primary_hashtable[hv & hashmask(hashpower)] = it; } hash_items++;//元素个数+1 if (! expanding && hash_items > (hashsize(hashpower) * 3) / 2) { //开始扩容 assoc_start_expand();//唤醒扩容条件变量 } MEMCACHED_ASSOC_INSERT(ITEM_key(it), it->nkey, hash_items); return 1; } //hash表删除元素 void assoc_delete(const char *key, const size_t nkey, const uint32_t hv) { item **before = _hashitem_before(key, nkey, hv); //获得item对应的桶的前一个元素 if (*before) { item *nxt; hash_items--;//元素个数-1 MEMCACHED_ASSOC_DELETE(key, nkey, hash_items); nxt = (*before)->h_next;//执行单链表的删除操作 (*before)->h_next = 0; *before = nxt; return; } assert(*before != 0); } 像Hash表的扩容,初始化等已经在其他博客中介绍过了,这里就不在阐述。 如需下载本站全部技术文章,可以在【高性能服务器开发】公众号回复关键字“文章下载”即可。最近更新时间: 2020-10-03 14:39:00 "},"articles/Memcached源码分析/12set操作.html":{"url":"articles/Memcached源码分析/12set操作.html","title":"12 set操作","keywords":"","body":"Memcached源码阅读十二 set操作 之前分析了Memcached的get操作,下面分析set操作的流程。 //存储item enum store_item_type store_item(item *item, int comm, conn* c) { enum store_item_type ret; uint32_t hv; hv = hash(ITEM_key(item), item->nkey, 0);//获取Hash表的分段锁 item_lock(hv);//执行数据同步 ret = do_store_item(item, comm, c, hv);//存储item item_unlock(hv); return ret; } //存储item enum store_item_type do_store_item(item *it, int comm, conn *c,const uint32_t hv) { char *key = ITEM_key(it);//读取item对应的key item *old_it = do_item_get(key, it->nkey, hv); //读取相应的item,如果没有相关的数据,old_it为NULL enum store_item_type stored = NOT_STORED;//item状态标记 item *new_it = NULL; int flags; //如果old_it不为NULL,且操作为add操作 if (old_it != NULL && comm == NREAD_ADD) { do_item_update(old_it);//更新数据 } //old_it为空,且操作为REPLACE,则什么都不做 else if (!old_it && (comm == NREAD_REPLACE || comm == NREAD_APPEND || comm == NREAD_PREPEND)) { //memcached的Replace操作是替换已有的数据,如果没有相关数据,则不做任何操作 } //以cas方式读取 else if (comm == NREAD_CAS) { if (old_it == NULL) //为空 { // LRU expired stored = NOT_FOUND;//修改状态 pthread_mutex_lock(&c->thread->stats.mutex);//更新Worker线程统计数据 c->thread->stats.cas_misses++; pthread_mutex_unlock(&c->thread->stats.mutex); } //old_it不为NULL,且cas属性一致 else if (ITEM_get_cas(it) == ITEM_get_cas(old_it)) { pthread_mutex_lock(&c->thread->stats.mutex); c->thread->stats.slab_stats[old_it->slabs_clsid].cas_hits++; //更新Worker线程统计信息 pthread_mutex_unlock(&c->thread->stats.mutex); item_replace(old_it, it, hv); //执行item的替换操作,用新的item替换老的item stored = STORED;//修改状态值 } else //old_it不为NULL,且cas属性不一致 { pthread_mutex_lock(&c->thread->stats.mutex); c->thread->stats.slab_stats[old_it->slabs_clsid].cas_badval++; //更新Worker线程统计信息 pthread_mutex_unlock(&c->thread->stats.mutex); if (settings.verbose > 1) { fprintf(stderr, \"CAS: failure: expected %llu, got %llu\\n\", (unsigned long long) ITEM_get_cas(old_it), (unsigned long long) ITEM_get_cas(it)); } stored = EXISTS; //修改状态值,修改状态值为已经存在,且不存储最新的数据 } } else //执行其他操作的写 { //以追加的方式执行写 if (comm == NREAD_APPEND || comm == NREAD_PREPEND) { //验证cas有效性 if (ITEM_get_cas(it) != 0) { //cas验证不通过 if (ITEM_get_cas(it) != ITEM_get_cas(old_it)) { stored = EXISTS;//修改状态值为已存在 } } //状态值为没有存储,也就是cas验证通过,则执行写操作 if (stored == NOT_STORED) { flags = (int) strtol(ITEM_suffix(old_it), (char **) NULL, 10); //申请新的空间 new_it = do_item_alloc(key, it->nkey, flags, old_it->exptime,it->nbytes + old_it->nbytes - 2 , hv); if (new_it == NULL) { //空间不足 if (old_it != NULL) do_item_remove(old_it);//删除老的item return NOT_STORED; } if (comm == NREAD_APPEND)//追加方式 { memcpy(ITEM_data(new_it), ITEM_data(old_it),old_it->nbytes);//老数据拷贝到新数据中 memcpy(ITEM_data(new_it) + old_it->nbytes - 2,ITEM_data(it), it->nbytes);//同时拷贝最近缓冲区已有的数据 } else { //这里和具体协议相关 memcpy(ITEM_data(new_it), ITEM_data(it), it->nbytes);//拷贝it的数据到new_it中 memcpy(ITEM_data(new_it) + it->nbytes - 2 ,ITEM_data(old_it), old_it->nbytes);//同时拷贝最近缓冲区已有的数据 } it = new_it; } } if (stored == NOT_STORED) { if (old_it != NULL)//如果old_it不为空 item_replace(old_it, it, hv);//替换老的值 else do_item_link(it, hv);//重新存储数据 c->cas = ITEM_get_cas(it);//获取cas值 stored = STORED; } } if (old_it != NULL) do_item_remove(old_it);//释放空间 if (new_it != NULL) do_item_remove(new_it);//释放空间 if (stored == STORED)//如果已经存储了 { c->cas = ITEM_get_cas(it);//获取cas属性 } return stored; } //更新item,这个只更新时间 void do_item_update(item *it) { MEMCACHED_ITEM_UPDATE(ITEM_key(it), it->nkey, it->nbytes); if (it->time it_flags & ITEM_SLABBED) == 0); mutex_lock(&cache_lock);//保持同步 //更新LRU队列的Item if ((it->it_flags & ITEM_LINKED) != 0) { item_unlink_q(it);//断开连接 it->time = current_time;//更新item的时间 item_link_q(it);//重新添加 } mutex_unlock(&cache_lock); } } //用新的item替换老的item int do_item_replace(item *it, item *new_it, const uint32_t hv) { MEMCACHED_ITEM_REPLACE(ITEM_key(it), it->nkey, it->nbytes, ITEM_key(new_it), new_it->nkey, new_it->nbytes); //判断it是已经分配过的,如果未分配,则断言失败 assert((it->it_flags & ITEM_SLABBED) == 0); do_item_unlink(it, hv);//断开连接 return do_item_link(new_it, hv);//重新添加 } 有些item的操作已经在get操作中有分析,我们此处不做分析,我们下一篇分析下Memcached内部如何选择合适的空间来存放item。 如需下载本站全部技术文章,可以在【高性能服务器开发】公众号回复关键字“文章下载”即可。最近更新时间: 2020-10-03 14:22:00 "},"articles/Memcached源码分析/13do_item_alloc操作.html":{"url":"articles/Memcached源码分析/13do_item_alloc操作.html","title":"13 do_item_alloc操作","keywords":"","body":"Memcached源码阅读十三 do_item_alloc操作 前面我们分析了Memcached的set操作,其set操作在经过所有的数据有效性检查之后,如果需要存储item,则会执行item的实际存储操作,我们下面分析下其过程。 //执行item的存储操作,该操作会将item挂载到LRU表和slabcalss中 item *do_item_alloc(char *key, const size_t nkey, const int flags, const rel_time_t exptime, const int nbytes, const uint32_t cur_hv) { uint8_t nsuffix; item *it = NULL; char suffix[40]; //计算item的总大小(空间) size_t ntotal = item_make_header(nkey + 1, flags, nbytes, suffix, &nsuffix); //如果使用了cas if (settings.use_cas) { //增加cas的空间 ntotal += sizeof(uint64_t); } unsigned int id = slabs_clsid(ntotal); //那大小选择合适的slab if (id == 0) return 0; //执行LRU锁 mutex_lock(&cache_lock); //存储时,会尝试从LRU中选择合适的空间的空间 int tries = 5; //如果LRU中尝试5次还没合适的空间,则执行申请空间的操作 int tried_alloc = 0; item *search; void *hold_lock = NULL; //初始化时选择的过期时间 rel_time_t oldest_live = settings.oldest_live; search = tails[id];//第id个LRU表的尾部 for (; tries > 0 && search != NULL; tries--, search=search->prev) { uint32_t hv = hash(ITEM_key(search), search->nkey, 0);//获取分段锁 //尝试执行锁操作,这里执行的乐观锁 if (hv != cur_hv && (hold_lock = item_trylock(hv)) == NULL) continue; //判断item是否被锁住,item的引用次数其实充当的也是一种锁 if (refcount_incr(&search->refcount) != 2) { refcount_decr(&search->refcount);//更新it的引用次数 //如果it的添加时间比当前时间小于3*3600 if (search->time + TAIL_REPAIR_TIME refcount = 1; do_item_unlink_nolock(search, hv);//执行分段解锁操作 } if (hold_lock) item_trylock_unlock(hold_lock);//执行分段解锁操作 continue; } //过期时间判断 if ((search->exptime != 0 && search->exptime time it_flags & ITEM_FETCHED) == 0) { itemstats[id].expired_unfetched++;//更新统计信息 } it = search; //slabclass申请合适的空间 slabs_adjust_mem_requested(it->slabs_clsid, ITEM_ntotal(it), ntotal); //执行的Hash表的分段解锁操作 do_item_unlink_nolock(it, hv); it->slabs_clsid = 0; } else if ((it = slabs_alloc(ntotal, id)) == NULL) { //申请失败一次 tried_alloc = 1; //关闭了LRU的 if (settings.evict_to_free == 0) { itemstats[id].outofmemory++; //统计信息更新 } else { itemstats[id].evicted++; //更新统计信息 itemstats[id].evicted_time = current_time - search->time; if (search->exptime != 0) itemstats[id].evicted_nonzero++; if ((search->it_flags & ITEM_FETCHED) == 0) { itemstats[id].evicted_unfetched++; } it = search; slabs_adjust_mem_requested(it->slabs_clsid, ITEM_ntotal(it), ntotal);//选择合适的slabclass空间 do_item_unlink_nolock(it, hv);//执行it的分段解锁操作 it->slabs_clsid = 0; if (settings.slab_automove == 2)//如果打开了slab调整 slabs_reassign(-1, id);//唤醒调整线程 } } //更新引用次数 refcount_decr(&search->refcount); if (hold_lock) item_trylock_unlock(hold_lock);//解分段锁 break; } //5次循环查找,未找到合适的空间 if (!tried_alloc && (tries == 0 || search == NULL)) //则从内存池申请新的空间 it = slabs_alloc(ntotal, id); //内存池申请失败 if (it == NULL) { itemstats[id].outofmemory++;//更新统计信息 mutex_unlock(&cache_lock);//释放LRU锁 return NULL; } assert(it->slabs_clsid == 0); assert(it != heads[id]); it->refcount = 1;//更新it的引用次数 mutex_unlock(&cache_lock); it->next = it->prev = it->h_next = 0;//执行初始化操作 it->slabs_clsid = id;//it所属的slabclass为第id个 DEBUG_REFCNT(it, '*'); it->it_flags = settings.use_cas ? ITEM_CAS : 0; it->nkey = nkey;//it的key it->nbytes = nbytes;//it的缓冲区的数据 memcpy(ITEM_key(it), key, nkey);//it的数据信息 it->exptime = exptime;//it的过期时间 memcpy(ITEM_suffix(it), suffix, (size_t)nsuffix);//it的前缀信息 it->nsuffix = nsuffix;//it的一些前缀信息 return it; } //计算item的大小 static size_t item_make_header(const uint8_t nkey, const int flags, const int nbytes, char *suffix, uint8_t *nsuffix) { //suffix限定了40个字节 *nsuffix = (uint8_t) snprintf(suffix, 40, \" %d %d\\r\\n\", flags, nbytes - 2); //返回item的长度 return sizeof(item) + nkey + *nsuffix + nbytes; } //选择合适的slabclass unsigned int slabs_clsid(const size_t size) { int res = POWER_SMALLEST; if (size == 0) return 0; //按slabclass的size的选择 while (size > slabclass[res].size) //如果大于最大的slab的,则直接返回错误,按默认的,大于1M的申请空间失败 if (res++ == power_largest) return 0; return res; } //从内存池申请合适的空间 void slabs_adjust_mem_requested(unsigned int id, size_t old, size_t ntotal) { //slabclass加锁,保持同步 pthread_mutex_lock(&slabs_lock); slabclass_t *p; //判断数据合法性 if (id power_largest) { fprintf(stderr, \"Internal error! Invalid slab class\\n\"); abort(); } p = &slabclass[id]; //调整request信息,request表示的是old所在的slab申请空间大小 p->requested = p->requested - old + ntotal; pthread_mutex_unlock(&slabs_lock); } 如需下载本站全部技术文章,可以在【高性能服务器开发】公众号回复关键字“文章下载”即可。最近更新时间: 2020-10-03 14:23:07 "},"articles/Memcached源码分析/14item结构.html":{"url":"articles/Memcached源码分析/14item结构.html","title":"14 item结构","keywords":"","body":"Memcached源码阅读十四 item结构 item是Memcached中抽象实际数据的结构,我们分析下item的一些特性,便于后续Memcached的其他特性分析。 typedef struct _stritem { struct _stritem *next;//item在slab中存储时,是以双链表的形式存储的,next即后向指针 struct _stritem *prev;//prev为前向指针 struct _stritem *h_next;//Hash桶中元素的链接指针 rel_time_t time;//最近访问时间 rel_time_t exptime;//过期时间 int nbytes;//数据大小 unsigned short refcount;//引用次数 uint8_t nsuffix;//不清楚什么意思? uint8_t it_flags;//不清楚什么意思? uint8_t slabs_clsid;//标记item属于哪个slabclass下 uint8_t nkey;//key的长度 union { uint64_t cas; char end; } data[];//真实的数据信息 } item; 其结构图如下所示: Item由两部分组成,item的属性信息和item的数据部分,属性信息解释如上,数据部分包括cas,key和真实的value信息,item在内存中的存储形式如下: 这个图画出了部分结构,还有Hash表的结构没有画出。 这里大概介绍了item的一些信息,后面我们会分析item插入Hash表等信息。 如需下载本站全部技术文章,可以在【高性能服务器开发】公众号回复关键字“文章下载”即可。最近更新时间: 2020-10-03 14:24:38 "},"articles/Memcached源码分析/15Hash表扩容.html":{"url":"articles/Memcached源码分析/15Hash表扩容.html","title":"15 Hash表扩容","keywords":"","body":"Memcached阅读十五 Hash表扩容 Hash表是Memcached里面最重要的结构之一,其采用链接法来处理Hash冲突,当Hash表中的项太多时,也就是Hash冲突比较高的时候,Hash表的遍历就脱变成单链表,此时为了提供Hash的性能,Hash表需要扩容,Memcached的扩容条件是当表中元素个数超过Hash容量的1.5倍时就进行扩容,扩容过程由独立的线程来完成,扩容过程中会采用2个Hash表,将老表中的数据通过Hash算法映射到新表中,每次移动的桶的数目可以配置,默认是每次移动老表中的1个桶。 //hash表中增加元素 int assoc_insert(item *it, const uint32_t hv) { unsigned int oldbucket; //如果已经进行扩容且目前进行扩容还没到需要插入元素的桶,则将元素添加到旧桶中 if (expanding &&(oldbucket = (hv & hashmask(hashpower - 1))) >= expand_bucket) { //添加元素 it->h_next = old_hashtable[oldbucket]; old_hashtable[oldbucket] = it; } else { //如果没扩容,或者扩容已经到了新的桶中,则添加元素到新表中 it->h_next = primary_hashtable[hv & hashmask(hashpower)];//添加元素 primary_hashtable[hv & hashmask(hashpower)] = it; } hash_items++;//元素数目+1 //还没开始扩容,且表中元素个数已经超过Hash表容量的1.5倍 if (! expanding && hash_items > (hashsize(hashpower) * 3) / 2) { //唤醒扩容线程 assoc_start_expand(); } MEMCACHED_ASSOC_INSERT(ITEM_key(it), it->nkey, hash_items); return 1; } //唤醒扩容线程 static void assoc_start_expand(void) { if (started_expanding) return; started_expanding = true; //唤醒信号量 pthread_cond_signal(&maintenance_cond); } //启动扩容线程,扩容线程在main函数中会启动,启动运行一遍之后会阻塞在条件变量maintenance_cond上面,插入元素超过规定,唤醒条件变量 static void *assoc_maintenance_thread(void *arg) { //do_run_maintenance_thread的值为1,即该线程持续运行 while (do_run_maintenance_thread) { int ii = 0; item_lock_global();//加Hash表的全局锁 mutex_lock(&cache_lock);//加cache_lock锁 //执行扩容时,每次按hash_bulk_move个桶来扩容 for (ii = 0; ii h_next; //按新的Hash规则进行定位 bucket = hash(ITEM_key(it), it->nkey, 0) & hashmask(hashpower); it->h_next = primary_hashtable[bucket];//挂载到新的Hash表中 primary_hashtable[bucket] = it; } //旧表中的这个Hash桶已经按新规则完成了扩容 old_hashtable[expand_bucket] = NULL; //老表中的桶计数+1 expand_bucket++; //hash表扩容结束,expand_bucket从0开始,一直递增 if (expand_bucket == hashsize(hashpower - 1)) { //修改扩容标志 expanding = false; //释放老的表结构 free(old_hashtable); //更新一些统计信息 STATS_LOCK(); stats.hash_bytes -= hashsize(hashpower - 1) * sizeof(void *); stats.hash_is_expanding = 0; STATS_UNLOCK(); if (settings.verbose > 1) fprintf(stderr, \"Hash table expansion done\\n\"); } } mutex_unlock(&cache_lock);//释放cache_lock锁 item_unlock_global();//释放Hash表的全局锁 //完成扩容 if (!expanding) { //修改Hash表的锁类型,此时锁类型更新为分段锁,默认是分段锁,在进行扩容时,改为全局锁 switch_item_lock_type(ITEM_LOCK_GRANULAR); //释放用于扩容的锁 slabs_rebalancer_resume(); /* We are done expanding.. just wait for next invocation */ mutex_lock(&cache_lock); //加cache_lock锁,保护条件变量 started_expanding = false; //修改扩容标识 pthread_cond_wait(&maintenance_cond, &cache_lock); //阻塞扩容线程 mutex_unlock(&cache_lock); slabs_rebalancer_pause(); //加用于扩容的锁 switch_item_lock_type(ITEM_LOCK_GLOBAL); //修改锁类型为全局锁 mutex_lock(&cache_lock); //临时用来实现临界区 assoc_expand();//执行扩容 mutex_unlock(&cache_lock); } } return NULL; } //按2倍容量扩容Hash表 static void assoc_expand(void) { //old_hashtable指向主Hash表 old_hashtable = primary_hashtable; //申请新的空间 primary_hashtable = calloc(hashsize(hashpower + 1), sizeof(void *)); //空间申请成功 if (primary_hashtable) { if (settings.verbose > 1) fprintf(stderr, \"Hash table expansion starting\\n\"); hashpower++; //hash等级+1 expanding = true; //扩容标识打开 expand_bucket = 0; STATS_LOCK(); //更新全局统计信息 stats.hash_power_level = hashpower; stats.hash_bytes += hashsize(hashpower) * sizeof(void *); stats.hash_is_expanding = 1; STATS_UNLOCK(); } else { primary_hashtable = old_hashtable; } } 如需下载本站全部技术文章,可以在【高性能服务器开发】公众号回复关键字“文章下载”即可。最近更新时间: 2020-10-03 14:25:35 "},"articles/Memcached源码分析/16线程交互.html":{"url":"articles/Memcached源码分析/16线程交互.html","title":"16 线程交互","keywords":"","body":"Memcached源码阅读十六 线程交互 Memcached按之前的分析可以知道,其是典型的Master-Worker线程模型,这种模型很典型,其工作模型是Master绑定端口,监听网络连接,接受网络连接之后,通过线程间通信来唤醒Worker线程,Worker线程已经连接的描述符执行读写操作,这种模型简化了整个通信模型,下面分析下这个过程。 case conn_listening: addrlen = sizeof(addr); //Master线程(main)进入状态机之后执行accept操作,这个操作也是非阻塞的。 if ((sfd = accept(c->sfd, (struct sockaddr *) &addr, &addrlen)) == -1) { //非阻塞模型,这个错误码继续等待 if (errno == EAGAIN || errno == EWOULDBLOCK) { stop = true; } //连接超载 else if (errno == EMFILE) { if (settings.verbose > 0) fprintf(stderr, \"Too many open connections\\n\"); accept_new_conns(false); stop = true; } else { perror(\"accept()\"); stop = true; } break; } //已经accept成功,将accept之后的描述符设置为非阻塞的 if ((flags = fcntl(sfd, F_GETFL, 0)) = settings.maxconns - 1) { str = \"ERROR Too many open connections\\r\\n\"; res = write(sfd, str, strlen(str)); close(sfd); STATS_LOCK(); stats.rejected_conns++; STATS_UNLOCK(); } else { //直线连接分发 dispatch_conn_new(sfd, conn_new_cmd, EV_READ | EV_PERSIST, DATA_BUFFER_SIZE, tcp_transport); } stop = true; break; 这个是TCP的连接建立过程,由于UDP不需要建立连接,所以直接分发给Worker线程,让Worker线程进行读写操作,而TCP在建立连接之后,也执行连接分发(和UDP的一样),下面看看dispatch_conn_new内部是如何进行链接分发的。 void dispatch_conn_new(int sfd, enum conn_states init_state, int event_flags, int read_buffer_size, enum network_transport transport) { //创建一个连接队列 CQ_ITEM *item = cqi_new(); char buf[1]; //通过round-robin算法选择一个线程 int tid = (last_thread + 1) % settings.num_threads; //thread数组存储了所有的工作线程 LIBEVENT_THREAD *thread = threads + tid; //缓存这次的线程编号,下次待用 last_thread = tid; //sfd表示accept之后的描述符 item->sfd = sfd; item->init_state = init_state; item->event_flags = event_flags; item->read_buffer_size = read_buffer_size; item->transport = transport; //投递item信息到Worker线程的工作队列中 cq_push(thread->new_conn_queue, item); MEMCACHED_CONN_DISPATCH(sfd, thread->thread_id); buf[0] = 'c'; //在Worker线程的notify_send_fd写入字符c,表示有连接 if (write(thread->notify_send_fd, buf, 1) != 1) { perror(\"Writing to thread notify pipe\"); } } 投递到子线程的连接队列之后,同时,通过往子线程的PIPE管道写入字符c来,下面我们看看子线程是如何处理的? //子线程会在PIPE管道读上面建立libevent事件,事件回调函数是thread_libevent_process event_set(&me->notify_event, me->notify_receive_fd, EV_READ | EV_PERSIST, thread_libevent_process, me); static void thread_libevent_process(int fd, short which, void *arg) { LIBEVENT_THREAD *me = arg; CQ_ITEM *item; char buf[1]; //PIPE管道读取一个字节的数据 if (read(fd, buf, 1) != 1) if (settings.verbose > 0) fprintf(stderr, \"Can't read from libevent pipe\\n\"); switch (buf[0]) { case 'c': //从连接队列读出Master线程投递的消息 item = cq_pop(me->new_conn_queue); if (NULL != item) { conn *c = conn_new(item->sfd, item->init_state, item->event_flags, item->read_buffer_size, item->transport, me->base);//创建连接 if (c == NULL) { if (IS_UDP(item->transport)) { fprintf(stderr, \"Can't listen for events on UDP socket\\n\"); exit(1); } else { if (settings.verbose > 0) { fprintf(stderr, \"Can't listen for events on fd %d\\n\", item->sfd); } close(item->sfd); } } else { c->thread = me; } cqi_free(item); } break; } } 之前分析过conn_new的执行流程,conn_new里面会建立sfd的网络监听libevent事件,事件回调函数为event_handler。 event_set(&c->event, sfd, event_flags, event_handler, (void *) c); event_base_set(base, &c->event); event_handler的执行流程最终会进入到业务处理的状态机中,关于状态机,后续分析。 如需下载本站全部技术文章,可以在【高性能服务器开发】公众号回复关键字“文章下载”即可。最近更新时间: 2020-10-03 14:26:34 "},"articles/Memcached源码分析/17状态机.html":{"url":"articles/Memcached源码分析/17状态机.html","title":"17 状态机","keywords":"","body":"Memcached源码阅读十七 状态机 按我们之前的描述,Master线程建立连接之后,分发给Worker线程,而Worker线程处理业务逻辑时,会进入状态机,状态机按不同的状态处理业务逻辑,我们在分析连接分发时,已经看到了Master线程进入状态机时在有新连接建立的时候,后续的状态都是业务逻辑的状态,其处理流程如下图所示: 共有10个状态(代码中的状态不止这些,有些没什么用,此处就没展现),状态listenning状态是Master建立连接的过程,我们已经分析过了,我们接下来分不同的文章分析其余的9中状态。 enum conn_states { conn_listening, //监听状态 conn_new_cmd, //为新连接做一些准备 conn_waiting, //等待读取一个数据包 conn_read, //读取网络数据 conn_parse_cmd, //解析缓冲区的数据 conn_write, //简单的回复数据 conn_nread, //读取固定数据的网络数据 conn_swallow, //处理不需要的写缓冲区的数据 conn_closing, //关闭连接 conn_mwrite, //顺序的写多个item数据 conn_max_state //最大状态,做断言使用 }; 这篇文件先分析conn_new_cmd和conn_wating状态,子线程最初进入的状态就是conn_new_cmd状态,这个状态主要是做一些清理。 case conn_new_cmd: //全局变量,记录每个libevent实例处理的事件,通过初始启动参数配置 --nreqs; //还可以处理请求 if (nreqs >= 0) { //整理缓冲区 reset_cmd_handler(c); } //拒绝请求 else { pthread_mutex_lock(&c->thread->stats.mutex); c->thread->stats.conn_yields++;//更新统计数据 pthread_mutex_unlock(&c->thread->stats.mutex); //如果缓冲区有数据,则需要处理 if (c->rbytes > 0) { //更新libevent状态 if (!update_event(c, EV_WRITE | EV_PERSIST)) { if (settings.verbose > 0) fprintf(stderr, \"Couldn't update event\\n\"); conn_set_state(c, conn_closing);//关闭连接 } } stop = true; } break; //整理缓冲区 static void reset_cmd_handler(conn *c) { c->cmd = -1; c->substate = bin_no_state; //还有item if (c->item != NULL) { //删除item,本篇不分析其实现,后续分析 item_remove(c->item); c->item = NULL; } //整理缓冲区 conn_shrink(c); //缓冲区还有数据 if (c->rbytes > 0) { //更新状态 conn_set_state(c, conn_parse_cmd); } //如果没有数据 else { //进入等待状态,状态机没有数据要处理,就进入这个状态 conn_set_state(c, conn_waiting); } } //缩小缓冲区 static void conn_shrink(conn *c) { assert(c != NULL); //如果是UDP协议,不牵涉缓冲区管理 if (IS_UDP(c->transport)) return; //读缓冲区空间大小>READ_BUFFER_HIGHWAT && 已经读到的数据还没解析的数据小于 DATA_BUFFER_SIZE if (c->rsize > READ_BUFFER_HIGHWAT && c->rbytes rcurr != c->rbuf) //目前数据是从rcurr开始的,移动数据到rbuf中 memmove(c->rbuf, c->rcurr, (size_t) c->rbytes); //按DATA_BUFFER_SIZE扩大缓冲区 newbuf = (char *) realloc((void *) c->rbuf, DATA_BUFFER_SIZE); if (newbuf) { //更新读缓冲区 c->rbuf = newbuf; //更新读缓冲区大小 c->rsize = DATA_BUFFER_SIZE; } c->rcurr = c->rbuf; } //需要写出的item的个数,也就是要发送给客户端的item的个数 if (c->isize > ITEM_LIST_HIGHWAT) { //增大存放item的空间 item **newbuf = (item**) realloc((void *) c->ilist,ITEM_LIST_INITIAL * sizeof(c->ilist[0])); if (newbuf) { //更新信息 c->ilist = newbuf; //更新信息 c->isize = ITEM_LIST_INITIAL; } } //msghdr的个数,memcached发送消息是通过sendmsg批量发送的 if (c->msgsize > MSG_LIST_HIGHWAT) { struct msghdr *newbuf = (struct msghdr *) realloc((void *) c->msglist,MSG_LIST_INITIAL * sizeof(c->msglist[0]));//增大空间 if (newbuf) { //更新信息 c->msglist = newbuf; //更新信息 c->msgsize = MSG_LIST_INITIAL; } } //msghdr里面iov的数量 if (c->iovsize > IOV_LIST_HIGHWAT) { //增大空间 struct iovec *newbuf = (struct iovec *) realloc((void *) c->iov,IOV_LIST_INITIAL * sizeof(c->iov[0])); if (newbuf) { //更新信息 c->iov = newbuf; //更新信息 c->iovsize = IOV_LIST_INITIAL; } } } 从conn_new_cmd状态会进入conn_parse_cmd状态(如果有数据)或者conn_waiting(如果没有数据)状态,下面看看conn_waiting状态。 case conn_waiting: //修改libevent状态,读取数据 if (!update_event(c, EV_READ | EV_PERSIST)) { if (settings.verbose > 0) fprintf(stderr, \"Couldn't update event\\n\"); conn_set_state(c, conn_closing); break; } //进入读数据状态 conn_set_state(c, conn_read); stop = true; break; //更新libevent状态,也就是删除libevent事件后,重新注册libevent事件 static bool update_event(conn *c, const int new_flags) { assert(c != NULL); struct event_base *base = c->event.ev_base; if (c->ev_flags == new_flags) return true; //删除旧的事件 if (event_del(&c->event) == -1) return false; //注册新事件 event_set(&c->event, c->sfd, new_flags, event_handler, (void *) c); event_base_set(base, &c->event); c->ev_flags = new_flags; if (event_add(&c->event, 0) == -1) return false; return true; } conn_wating状态是在等待读取数据,conn_wating通过修改libevent事件(修改为读事件)之后就进入了conn_read状态,该状态就是从网络中读取数据,下面我们详细分析conn_read状态。 case conn_read: res = IS_UDP(c->transport) ? try_read_udp(c) : try_read_network(c);//判断采用UDP协议还是TCP协议 switch (res) { case READ_NO_DATA_RECEIVED://未读取到数据 conn_set_state(c, conn_waiting);//继续等待 break; case READ_DATA_RECEIVED://读取数据 conn_set_state(c, conn_parse_cmd);//开始解析数据 break; case READ_ERROR://读取发生错误 conn_set_state(c, conn_closing);//关闭连接 break; case READ_MEMORY_ERROR: //申请内存空间错误,继续尝试 break; } break; //采用TCP协议,从网络读取数据 static enum try_read_result try_read_network(conn *c) { enum try_read_result gotdata = READ_NO_DATA_RECEIVED; int res; int num_allocs = 0; assert(c != NULL); //rcurr标记读缓冲区的开始位置,如果不在,通过memmove调整 if (c->rcurr != c->rbuf) { if (c->rbytes != 0) memmove(c->rbuf, c->rcurr, c->rbytes); //rcurr指向读缓冲区起始位置 c->rcurr = c->rbuf; } //循环读取 while (1) { //已经读取到的数据大于读缓冲区的大小 if (c->rbytes >= c->rsize) { if (num_allocs == 4) { return gotdata; } ++num_allocs; //按2倍扩容空间 char *new_rbuf = realloc(c->rbuf, c->rsize * 2); //realloc发生错误,也就是申请内存失败 if (!new_rbuf) { if (settings.verbose > 0) fprintf(stderr, \"Couldn't realloc input buffer\\n\"); //忽略已经读取到的数据 c->rbytes = 0; out_string(c, \"SERVER_ERROR out of memory reading request\"); //下一个状态就是conn_closing状态 c->write_and_go = conn_closing; return READ_MEMORY_ERROR; } //读缓冲区指向新的缓冲区 c->rcurr = c->rbuf = new_rbuf; //读缓冲区的大小扩大2倍 c->rsize *= 2; } //读缓冲区剩余空间 int avail = c->rsize - c->rbytes; //执行网络读取,这个是非阻塞的读 res = read(c->sfd, c->rbuf + c->rbytes, avail); //如果读取到了数据 if (res > 0) { pthread_mutex_lock(&c->thread->stats.mutex); //更新线程的统计数据 c->thread->stats.bytes_read += res; pthread_mutex_unlock(&c->thread->stats.mutex); //返回读取到数据的状态 gotdata = READ_DATA_RECEIVED; //读取到的数据个数增加res c->rbytes += res; //最多读取到avail个,如果已经读到了,则可以尝试继续读取 if (res == avail) { continue; } //否则,小于avail,表示已经没数据了,退出循环。 else { break; } } //表示已经断开网络连接了 if (res == 0) { return READ_ERROR; } //因为是非阻塞的,所以会返回下面的两个错误码 if (res == -1) { if (errno == EAGAIN || errno == EWOULDBLOCK) { break; } return READ_ERROR; } } return gotdata; } 上面描述的是TCP的数据读取,下面我们分析下UDP的数据读取,UDP是数据报的形式,读取到一个,就是一个完整的数据报,所以其处理过程简单。 //UDP读取网络数据 static enum try_read_result try_read_udp(conn *c) { int res; assert(c != NULL); c->request_addr_size = sizeof(c->request_addr); //执行UDP的网络读取 res = recvfrom(c->sfd, c->rbuf, c->rsize, 0, &c->request_addr, &c->request_addr_size); //UDP数据包大小大于8,已经有可能是业务数据包 if (res > 8) { unsigned char *buf = (unsigned char *)c->rbuf; pthread_mutex_lock(&c->thread->stats.mutex); //更新每个线程的统计数据 c->thread->stats.bytes_read += res; pthread_mutex_unlock(&c->thread->stats.mutex); /* Beginning of UDP packet is the request ID; save it. */ c->request_id = buf[0] * 256 + buf[1]; //一些业务的特征信息判断 if (buf[4] != 0 || buf[5] != 1) { out_string(c, \"SERVER_ERROR multi-packet request not supported\"); return READ_NO_DATA_RECEIVED; } /* Don't care about any of the rest of the header. */ res -= 8; //调整缓冲区 memmove(c->rbuf, c->rbuf + 8, res); c->rbytes = res;//更新信息 c->rcurr = c->rbuf; return READ_DATA_RECEIVED; } return READ_NO_DATA_RECEIVED; } 从网络读取了数据之后,将会进入conn_parse_cmd状态,该状态是按协议来解析读取到的网络数据。 case conn_parse_cmd: //解析数据 if (try_read_command(c) == 0) { //如果读取到的数据不够,我们继续等待,等读取到的数据够了,再进行解 conn_set_state(c, conn_waiting); } break; //memcached支持二进制协议和文本协议 static int try_read_command(conn *c) { assert(c != NULL); assert(c->rcurr rbuf + c->rsize)); assert(c->rbytes > 0); if (c->protocol == negotiating_prot || c->transport == udp_transport) { //二进制协议有标志,按标志进行区分 if ((unsigned char)c->rbuf[0] == (unsigned char)PROTOCOL_BINARY_REQ) { c->protocol = binary_prot;//二进制协议 } else { c->protocol = ascii_prot;//文本协议 } if (settings.verbose > 1) { fprintf(stderr, \"%d: Client using the %s protocol\\n\", c->sfd, prot_text(c->protocol)); } } //如果是二进制协议 if (c->protocol == binary_prot) { //二进制协议读取到的数据小于二进制协议的头部长度 if (c->rbytes binary_header)) { //返回继续读数据 return 0; } else { #ifdef NEED_ALIGN //如果需要对齐,则按8字节对齐,对齐能提高CPU读取的效率 if (((long)(c->rcurr)) % 8 != 0) { //调整缓冲区 memmove(c->rbuf, c->rcurr, c->rbytes); c->rcurr = c->rbuf; if (settings.verbose > 1) { fprintf(stderr, \"%d: Realign input buffer\\n\", c->sfd); } } #endif protocol_binary_request_header* req;//二进制协议头 req = (protocol_binary_request_header*)c->rcurr; //调试信息 if (settings.verbose > 1) { /* Dump the packet before we convert it to host order */ int ii; fprintf(stderr, \"sfd); for (ii = 0; ii bytes); ++ii) { if (ii % 4 == 0) { fprintf(stderr, \"\\nsfd); } fprintf(stderr, \" 0x%02x\", req->bytes[ii]); } fprintf(stderr, \"\\n\"); } c->binary_header = *req; c->binary_header.request.keylen = ntohs(req->request.keylen); c->binary_header.request.bodylen = ntohl(req->request.bodylen); c->binary_header.request.cas = ntohll(req->request.cas); //判断魔数是否合法,魔数用来防止TCP粘包 if (c->binary_header.request.magic != PROTOCOL_BINARY_REQ) { if (settings.verbose) { fprintf(stderr, \"Invalid magic: %x\\n\", c->binary_header.request.magic); } conn_set_state(c, conn_closing); return -1; } c->msgcurr = 0; c->msgused = 0; c->iovused = 0; if (add_msghdr(c) != 0) { out_string(c, \"SERVER_ERROR out of memory\"); return 0; } c->cmd = c->binary_header.request.opcode; c->keylen = c->binary_header.request.keylen; c->opaque = c->binary_header.request.opaque; //清除客户端传递的cas值 c->cas = 0; dispatch_bin_command(c);//协议数据处理 //更新已经读取到的字节数据 c->rbytes -= sizeof(c->binary_header); //更新缓冲区的路标信息 c->rcurr += sizeof(c->binary_header); } } } 文本协议的过程和二进制协议的过程类似,此处不分析,另外dispatch_bin_command是处理具体的(比如get,set等)操作的,和是二进制协议具体相关的,解析完一些数据之后,会进入到conn_nread的流程,也就是读取指定数目数据的过程,这个过程主要是做具体的操作了,比如get,add,set操作。 case bin_read_set_value: complete_update_bin(c);//执行Update操作 break; case bin_reading_get_key: process_bin_get(c);//执行get操作 break; 状态机的整个处理过程就介绍到这里,其他的状态我们就不介绍了,了解了这些之后,其实其他状态就相对容易很多。 如需下载本站全部技术文章,可以在【高性能服务器开发】公众号回复关键字“文章下载”即可。最近更新时间: 2020-10-03 14:30:18 "},"articles/游戏开发专题/":{"url":"articles/游戏开发专题/","title":"游戏开发专题","keywords":"","body":"游戏开发专题 1 游戏服务器开发的基本体系与服务器端开发的一些建议 2 网络游戏服务器开发框架设计介绍 3 游戏后端开发需要掌握的知识 4 关于游戏服务端架构的整理 5 各类游戏对应的服务端架构 6 从腾讯QQgame高性能服务器集群架构看“分而治之”与“自治”等分布式架构设计原则 7 QQ游戏百万人同时在线服务器架构实现 8 大型多人在线游戏服务器架构设计 9 百万用户级游戏服务器架构设计 10 十万在线的WebGame的数据库设计思路 11 一种高性能网络游戏服务器架构设计 12 经典游戏服务器端架构概述 13 游戏跨服架构进化之路 如需下载本站全部技术文章,可以在【高性能服务器开发】公众号回复关键字“文章下载”即可。最近更新时间: 2020-10-08 12:33:40 "},"articles/游戏开发专题/1游戏服务器开发的基本体系与服务器端开发的一些建议.html":{"url":"articles/游戏开发专题/1游戏服务器开发的基本体系与服务器端开发的一些建议.html","title":"1 游戏服务器开发的基本体系与服务器端开发的一些建议","keywords":"","body":"1 游戏服务器开发的基本体系与服务器端开发的一些建议 近年来,我身边的朋友有很多都从web转向了游戏开发。他们以前都没有做过游戏服务器开发,更谈不上什么经验,而从网上找的例子或游戏方面的知识,又是那么的少,那么的零散。当他们进入游戏公司时,显得一脸茫然。如果是大公司还好点,起码有人带带,能学点经验,但是有些人是直接进入了小公司,甚至这些小公司只有他一个后台。他们一肩扛起了公司的游戏后端的研发,也扛起了公司的成败。他们也非常尽力,他们也想把游戏的后端做好。可是就是因为没什么经验,刚开始时以为做游戏服务器和做web差不多,但是经过一段时间之后,才发现代码太多,太乱了,一看代码都想重构,都是踩着坑往前走。 这里我把一些游戏开发方面的东西整理一下,希望能对那些想做游戏服务器开发的朋友有所帮助。 首先,要明确一点,做游戏服务器开发和做传统的web开发有着本质的区别。游戏服务器开发,如果没有经验,一开始根本没有一个明确清析的目标,不像web那样,有些明确的MVC架构,往往就是为了尽快满足策划的需求,尽快的实现功能,尽快能让游戏跑起来。但是随着功能越来越多,在老代码上面修改的越来越频繁,游戏测试时暴露出来的一堆bug,更让人觉得束手无策,这个时候我们想到了重构,想到了架构的设计。 游戏的构架设计非常重要,好的构架代码清析,责任明确,扩展性强,易调试。这些会为我们的开发省去不少时间。那要怎么样设计游戏的构架呢?可能每个游戏都不一样,但是本质上还是差不多的。 对于游戏服务器的构架设计,我们首先要了解游戏的服务器构架都有什么组成的?一款游戏到上线,需要具备哪些功能?有些人可能会说,只要让游戏跑起来,访问服务器不出问题不就行了吗?答案是不行的,游戏构架本身代表的是一个体系,它包括: 系统初始化 游戏逻辑 数据库系统 缓存系统 游戏日志 游戏管理工具 公共服务组件 这一系统的东西都是不可少的,它们共同服务于游戏的整个运营过程。我们一点点来介绍各个系统的功能。 一,系统初始化 系统初始化是在没有客户端连接的时候,服务器启动时所需要做的工作。基本上就是配置文件的读取,初始化系统参数。 但是我们必须要考虑的是: 系统初始化需要的参数配置在哪儿,是配置在本地服务器,还是配置在数据库; 服务器启动的时候去数据库取; 配置的修改需不需要重启服务器等。 二,游戏逻辑 游戏逻辑是游戏的核心功能实现,也是整个游戏的服务中心,它被开发的好坏,直接决定了游戏服务器在运行中的性能。那在游戏逻辑的开发中我们要注意些什么呢? 游戏是一种网络交互比较强的业务,好的底层通信,可以最大化游戏的性能,增加单台服务器处理的同时在线人数,给游戏带来更好的体验,至少不容易出现因为网络层导致的数据交互卡顿的现象。在这里我推荐使用Netty,它是目前最流行的NIO框架,它的用法可以在我之前的文章中查看,这里不再多说了。 有人疑问,代码也需要分层次?这个是当然了,不同的代码,代表了不同的功能实现。现在的开发语言都是面向对象的,如果我们不加思考,不加整理的把功能代码乱堆一起,起始看起来是快速实现了功能,但是到后期,如果要修改需求,或在原来的代码上增加新的需求,那真是被自己打败了。所以代码一定要分层,主要有以下几层: 协议层,也叫前后台交互层,它主要负责与前台交互协议的解析和返回数据。在这一层基本上没有什么业务逻辑实现。与前台交互的数据都在这一层开始,也在这一层终止。比如你使用了Netty框架,那么Netty的ChannelHandlerContext即Ctx只能出现在这一层,他不能出现到游戏业务逻辑代码的实现中,接收到客户端的请求,在这一层把需要的参数解析出来,再把参数传到业务逻辑方法中,业务逻辑方法处理完后,把要返回给客户端的数据再返回到这一层,在这一层组织数据,返回给客户端,这样就可以把业务逻辑和网络层分离,业务逻辑只关心业务实现,而且也方便对业务逻辑进行单元测试。 业务逻辑层,这里处理真正的游戏逻辑,该计算价格计算价格,该通关的通关,该计时的计时。该保存数据的保存数据。但是这一层不直接操作缓存或数据库,只是处理游戏逻辑计算。因为业务逻辑层是整个游戏事件的处理核心,所以他的处理是否正确直接决定游戏的正确性。所以这一层的代码要尽量使用面向对象的方法去实现。**不要出现重复代码或相似的功能进行复制粘贴,这样修改起来非常不方便,可能是修改了某一处,而忘记了修改另外同样的代码。还要考虑每个方法都是可测试的,一个方法的行数最好不要超过一百行。另外,可以多看看设计模式的书**,它可以帮助我们设计出灵活,整洁的代码。 三,数据库系统 数据库是存储数据库的核心,但是游戏数据在存储到数据库的时候会经过网络和磁盘的IO,它的访问速度相对于内存来说是很慢的。一般来说,每次访问数据库都要和数据库建立连接,访问完成之后,为了节省数据库的连接资源,要再把连接断开。 这样无形中又为服务器增加了开销,在大量的数据访问时,可能会更慢,而游戏又是要求低延时的,这时该怎么办呢?我们想到了数据库连接池,即把访问数据库的连接放到一个地方管理,用完我不断开,用的时候去那拿,用完再放回去。这样不用每次都建立新的连接了。 但是如果要我们自己去实现一套连接池管理组件的话,需要时间不说,对技术的把控也是一个考验,还要再经过测试等等,幸好互联网开源的今天,有一些现成的可以使用,这里推荐Mybatis,即实现了代码与SQL的分离,又有足够的SQL编写的灵活性,是一个不错的选择。 四,缓存系统 游戏中,客户端与服务器的交互是要求低延迟的,延迟越低,用户体验越好。像之前说过的一样,低延迟就是要求服务器处理业务尽量的快,客户端一个请求过来,要在最短的时间内响应结果,最低不得超过500ms,因为加上来回的网络传输耗时,基本上就是600ms-到700ms了,再长玩家就会觉得游戏卡了。 如果直接从数据库中取数据,处理完之后再存回数据库的话,这个性能是跟不上的。在服务器,数据在内存中处理是最快的,所以我们要把一部分常用的数据提前加载到内存中,比如说游戏数据配置表,经常登陆的玩家数据等。这样在处理业务时,就不用走数据库了,直接从内存中取就可以了,速度更快。 游戏中常见的缓存有两种: 直接把数据存储在jvm或服务器内存中 使用第三方的缓存工具,这里推荐Redis,详细的用法可以自己去查询。(本公号内有系列文章,详情见【菜单栏】- 【技术文章】 - 【基础系列】 - 【实战R1,实战R2】) 五,游戏日志 日志是个好东西呀,一个游戏中更不能少了日志,而且日志一定要记录的详细。它是玩家在整个游戏中的行为记录,有了这个记录,我们就可以分析玩家的行为,查找游戏的不足,在处理玩家在游戏中的问题时,日志也是一个良好的凭证和快速处理方式。 在游戏中,日志分为: 系统日志,主要记录游戏服务器的系统情况。比如:数据库能否正常连接,服务器是否正常启动,数据是否正常加载; 玩家行为日志,比如玩家发送了什么请求,得到了什么物品,消费了多少货币等等; 统计日志,这种日志是对游戏中所有玩家某种行为的一种统计,根据这个统计来分析大部分玩家的行为,得出一些共性或不同之处,以方法运营做不同的活动吸引用户消费。 在构架设计中,日志记录一定要做为一种强制行为,因为不强制的话,可能由于某种原因某个功能忘记加日志了,那么当这个功能出问题了,或者运营跟我们要这个功能的一些数据库,就傻眼了。又得加需求,改代码了。日志一定要设计一种良好的格式,日志记录的数据要容易读取,分解。日志行为可以用枚举描述,在功能最后的处理方法里面加上这个枚举做为参数,这样不管谁在调用这个方法时,都要去加参数描述。 俗话说,工欲善其事,必先利其器。游戏管理工具是对游戏运行中的一系列问题处理的一种工具。它不仅是给开发人员用,大多数是给运营使用。游戏上线后,我们需要针对线上的问题进行不同的处理。不可能把所有问题都让程序员去处理吧,于是程序员们想到了一个办法,给你们做一个工具,你们爱谁处理谁处理去吧。 六, 游戏管理工具 游戏管理工具是一个不断增涨的系统,因为它很多时候是伴随着游戏中遇到的问题而实现的。 但是根据经验,有一些功能是必须有的,比如: 服务器管理,主要负责服务器的开启,关闭,服务器配置信息,玩家信息查询; 玩家管理,比如踢人,封号; 统计查询,玩家行为日志查询,统计查询,次留率查询,邮件服务,修改玩家数据等。 根据游戏的不同要求,凡是可以能过工具实现的,都做到游戏管理工具里面。它是针对所有服务器的管理。 一个好的,全的游戏管理工具,可以提高游戏运营中遇到问题处理的效率,为玩家提供更好的服务。 七,公共组件 公共组件是为游戏运行中提供公共的服务。例如: 充值服务器,我们没必须一个服用一个充值,而且你也不能对外提供多个充值服务器地址,和第三方公司对接,他们绝对不干,这是要疯呀; 还有运营搞活动时的礼包码; 还有注册用户的管理,玩家一个注册账号可以进不同的区等。 这些都是针对所有区服提供的服务,所以要单独做,与游戏逻辑分开,这样方便管理,部署和负载均衡。 还有SDK的登陆验证,现在手游比较多,与渠道对接里要进行验证,这往往是很多http请求,速度慢,所以这个也要拿出来单独做,不要在游戏逻辑中去验证,因为网络IO的访问时间是不可控制的,http是阻塞的请求。 所以,综上来看,一个游戏服务器起码有几个大的功能模块组成: 游戏逻辑工程; 日志处理工程; 充值工程; 游戏管理工具工程; 用户登陆工程; 公共活动工程等。 根据游戏的不同需要,可能还有其它的。所在构架的设计中,一定要考虑到系统的分布式部署,尽量把公共的功能拆出来做,这样可以增强系统的可扩展性。 服务器端开发的一些建议 本文作为游戏服务器端开发的基本大纲,是游戏实践开发中的总结。 第一部分 —— 专业基础,用于指导招聘和实习考核; 第二部分 —— 游戏入门,讲述游戏服务器端开发的基本要点; 第三部分 —— 服务端架构,介绍架构设计中的一些基本原则。 希望能帮到大家! 一、专业基础 1.1网络 1.1.1理解TCP/IP协议 网络传输模型 滑动窗口技术 建立连接的三次握手与断开连接的四次握手 连接建立与断开过程中的各种状态 TCP/IP协议的传输效率 思考: 请解释DOS攻击与DRDOS攻击的基本原理 一个100Byte数据包,精简到50Byte, 其传输效率提高了50% TIMEWAIT状态怎么解释? 1.1.2掌握常用的网络通信模型 Select Epoll,边缘触发与平台出发点区别与应用 Select与Epoll的区别及应用 1.2存储 计算机系统存储体系 程序运行时的内存结构 计算机文件系统,页表结构 内存池与对象池的实现原理,应用场景与区别 关系数据库MySQL的使用(本公众号内有系列文章,详情见【菜单】-【 共享内存 1.3程序 对C/C++语言有较深的理解 深刻理解接口,封装与多态,并且有实践经验 深刻理解常用的数据结构:数组,链表,二叉树,哈希表 熟悉常用的算法及相关复杂度:冒泡排序,快速排序 二、游戏开发入门 2.1防御式编程 不要相信客户端数据,一定要检验。作为服务器端你无法确定你的客户端是谁,你也不能假定它是善意的,请做好自我保护。(这是判断一个服务器端程序员是否入门的基本标准) 务必对于函数的传人参数和返回值进行合法性判断,内部子系统,功能模块之间不要太过信任,要求低耦合,高内聚。 插件式的模块设计,模块功能的健壮性应该是内建的,尽量减少模块间耦合。 2.2设计模式 道法自然。不要迷信,迷恋设计模式,更不要生搬硬套 简化,简化,再简化,用最简单的办法解决问题 借大宝一句话:设计本天成,妙手偶得之 2.3网络模型 自造轮子: Select, Epoll, Epoll一定比Select高效吗? 开源框架: Libevent, libev, ACE。(本公众号内有Libevent源码详解,详情见【菜单】-【开源软件】-【源码分析】-【网络库I】) 2.4数据持久化 自定义文件存储,如《梦幻西游》 关系数据库: MySQL NO-SQL数据库: MongoDB 选择存储系统要考虑到因素:稳定性,性能,可扩展性 2.5内存管理 使用内存池和对象池,禁止运行期间动态分配内存 对于输入输出的指针参数,严格检查,宁滥勿缺 写内存保护,使用带内存保护的函数(strncpy, memcpy, snprintf, vsnprintf等) 严防数组下标越界 防止读内存溢出,确保字符串以'\\0'结束 2.6日志系统 简单高效,大量日志操作不应该影响程序性能 稳定,做到服务器崩溃是日志不丢失 完备,玩家关键操作一定要记日志,理想的情况是通过日志能重建任何时刻的玩家数据 开关,开发日志的要加级别开关控制 2.7通信协议 采用PDL(Protocol Design Language), 如Protobuf,可以同时生成前后端代码,减少前后端协议联调成本, 扩展性好 JSON,文本协议,简单,自解释,无联调成本,扩展性好,也很方便进行包过滤以及写日志 自定义二进制协议,精简,有高效的传输性能,完全可控,几乎无扩展性 2.8全局唯一Key(GUID) 为合服做准备 方便追踪道具,装备流向 每个角色,装备,道具都应对应有全局唯一Key 2.9多线程与同步 消息队列进行同步化处理 2.10状态机 强化角色的状态 前置状态的检查校验 2.11数据包操作 合并, 同一帧内的数据包进行合并,减少IO操作次数 单副本, 用一个包尽量只保存一份,减少内存复制次数 AOI同步中减少中间过程无用数据包 2.12状态监控 随时监控服务器内部状态 内存池,对象池使用情况 帧处理时间 网络IO 包处理性能 各种业务逻辑的处理次数 2.13包频率控制 基于每个玩家每条协议的包频率控制,瘫痪变速齿轮 2.14开关控制 每个模块都有开关,可以紧急关闭任何出问题的功能模块 2.15反外挂反作弊 包频率控制可以消灭变速齿轮 包id自增校验,可以消灭WPE 包校验码可以消灭或者拦截篡改的包 图形识别码,可以踢掉99%非人的操作 魔高一尺,道高一丈 2.16热更新 核心配置逻辑的热更新,如防沉迷系统,包频率控制,开关控制等 代码基本热更新,如Erlang,Lua等 2.17防刷 关键系统资源(如元宝,精力值,道具,装备等)的产出记日志 资源的产出和消耗尽量依赖两个或以上的独立条件的检测 严格检查各项操作的前置条件 校验参数合法性 2.18防崩溃 系统底层与具体业务逻辑无关,可以用大量的机器人压力测试暴露各种bug,确保稳定 业务逻辑建议使用脚本 系统性的保证游戏不会崩溃 2.19性能优化 IO操作异步化 IO操作合并缓写 (事务性的提交db操作,包合并,文件日志缓写) Cache机制 减少竞态条件 (避免频繁进出切换,尽量减少锁定使用,多线程不一定由于单线程) 多线程不一定比单线程快 减少内存复制 自己测试,用数据说话,别猜 2.20运营支持 接口支持:实时查询,控制指令,数据监控,客服处理等 实现考虑提供http接口 2.21容灾与故障预案 略 三、服务器端架构 3.1什么是好的架构? 满足业务要求 能迅速的实现策划需求,响应需求变更 系统级的稳定性保障 简化开发。将复杂性控制在架构底层,降低对开发人员的技术要求,逻辑开发不依赖于开发人员本身强大的技术实力,提高开发效率 完善的运营支撑体系 3.2架构实践的思考 简单,满足需求的架构就是好架构 设计性能,抓住重要的20%, 没必要从程序代码里面去抠性能 热更新是必须的 人难免会犯错,尽可能的用一套机制去保障逻辑的健壮性 游戏服务器的设计是一项颇有挑战性的工作,游戏服务器的发展也由以前的单服结构转变为多服机构,甚至出现了bigworld引擎的分布式解决方案,最近了解到Unreal的服务器解决方案atlas也是基于集群的方式。 负载均衡是一个很复杂的课题,这里暂不谈bigworld和atlas的这类服务器的设计,更多的是基于功能和场景划分服务器结构。 首先说一下思路,服务器划分基于以下原则: 分离游戏中占用系统资源(cpu,内存,IO等)较多的功能,独立成服务器。 在同一服务器架构下的不同游戏,应尽可能的复用某些服务器(进程级别的复用)。 以多线程并发的编程方式适应多核处理器。 宁可在服务器之间多复制数据,也要保持清晰的数据流向。 主要按照场景划分进程,若需按功能划分,必须保持整个逻辑足够的简单,并满足以上1,2点。 服务器结构图: 各个服务器的简要说明: Gateway 是应用网关,主要用于保持和client的连接,该服务器需要2种IO: 对client采用高并发连接,低吞吐量的网络模型,如IOCP等 对服务器采用高吞吐量连接,如阻塞或异步IO。 网关主要有以下用途: 分担了网络IO资源,同时,也分担了网络消息包的加解密,压缩解压等cpu密集的操作。 隔离了client和内部服务器组,对client来说,它只需要知道网关的相关信息即可(ip和port)。client由于一直和网关保持常连接,所以切换场景服务器等操作对client来说是透明的。 维护玩家登录状态。 World Server 是一个控制中心,它负责把各种计算资源分布到各个服务器,它具有以下职责: **管理和维护多个Scene Server。 管理和维护多个功能服务器,主要是同步数据到功能服务器。 复杂转发其他服务器和Gateway之间的数据。 实现其他需要跨场景的功能,如组队,聊天,帮派等。 Phys Server 主要用于玩家移动,碰撞等检测。 所有玩家的移动类操作都在该服务器上做检查,所以该服务器本身具备所有地图的地形等相关信息。具体检查过程是这样的:首先,Worldserver收到一个移动信息,WorldServer收到后向Phys Server请求检查,Phys Server检查成功后再返回给world Server,然后world server传递给相应的Scene Server。 Scene Server场景服务器,按场景划分,每个服务器负责的场景应该是可以配置的。理想情况下是可以动态调节的。 ItemMgr Server 物品管理服务器,负责所有物品的生产过程。在该服务器上存储一个物品掉落数据库,服务器初始化的时候载入到内存。任何需要产生物品的服务器均与该服务器直接通信。 AIServer 又一个功能服务器,负责管理所有NPC的AI。AI服务器通常有2个输入: 一个是Scene Server发送过来的玩家相关操作信息 另一个时钟Timer驱动 在这个设计中,对其他服务器来说,AIServer就是一个拥有很多个NPC的客户端。AIserver需要同步所有与AI相关的数据,包括很多玩家数据。由于AIServer的Timer驱动特性,可在很大程度上使用TBB程序库来发挥多核的性能。 把网络游戏服务器分拆成多个进程,分开部署。 这种设计的好处是模块自然分离,可以单独设计。分担负荷,可以提高整个系统的承载能力。 缺点在于,网络环境并不那么可靠。跨进程通讯有一定的不可预知性。服务器间通讯往往难以架设调试环境,并很容易把事情搅成一团糨糊。而且正确高效的管理多连接,对程序员来说也是一项挑战。 前些年,我也曾写过好几篇与之相关的设计。这几天在思考一个问题:如果我们要做一个底层通用模块,让后续开发更为方便。到底要解决怎样的需求?这个需求应该是单一且基础的,每个应用都需要的。 正如 TCP 协议解决了互联网上稳定可靠的点对点数据流通讯一样。游戏世界实际需要的是一个稳定可靠的在游戏系统内的点对点通讯需要。 我们可以在一条 TCP 连接之上做到这一点。一旦实现,可以给游戏服务的开发带来极大的方便。 可以把游戏系统内的各项服务,包括并不限于登陆,拍卖,战斗场景,数据服务,等等独立服务看成网络上的若干终端。每个玩家也可以是一个独立终端。它们一起构成一个网络。在这个网络之上,终端之间可以进行可靠的连接和通讯。 实现可以是这样的: 每个虚拟终端都在游戏虚拟网络(Game Network)上有一个唯一地址 (Game Network Address , GNA) 。这个地址可以预先设定,也可以动态分配。每个终端都可以通过游戏网络的若干接入点 ( GNAP ) 通过唯一一条 TCP 连接接入网络。 接入过程需要通过鉴权。 鉴权过程依赖内部的安全机制,可以包括密码证书,或是特别的接入点区分。(例如,玩家接入网络就需要特定的接入点,这个接入点接入的终端都一定是玩家) 鉴权通过后,网络为终端分配一个固定的游戏域名。例如,玩家进入会分配到 player.12345 这样的域名,数据库接入可能分配到 database 。 游戏网络默认提供一个域名查询服务(这个服务可以通过鉴权的过程注册到网络中),让每个终端都能通过域名查询到对应的地址。 然后,游戏网络里所有合法接入的终端都可以通过其地址相互发起连接并通讯了。 整个协议建立在 TCP 协议之上,工作于唯一的这个 TCP 连接上。和直接使用 TCP 连接不同。游戏网络中每个终端之间相互发起连接都是可靠的。不仅玩家可以向某个服务发起连接,反过来也是可以的。玩家之间的直接连接也是可行的(是否允许这样,取决于具体设计)。 由于每个虚拟连接都是建立在单一的 TCP 连接之上。所以减少了互连网上发起 TCP 连接的各种不可靠性。鉴权过程也是一次性唯一的。 并且我们提供域名反查服务,我们的游戏服务可以清楚且安全的知道连接过来的是谁。 系统可以设计为,**游戏网络上每个终端离网,域名服务将广播这条消息,通知所有人。这种广播服务在互联网上难以做到,但无论是广播还是组播,在这个虚拟游戏网络中都是可行的。 在这种设计上。在逻辑层面,我们可以让玩家直接把聊天信息从玩家客互端发送到聊天服务器,而不需要建立多余的 TCP 连接,也不需要对转发处理聊天消息做多余的处理。聊天服务器可以独立的存在于游戏网络。也**可以让广播服务主动向玩家推送消息,由服务器向玩家发起连接,而不是所有连接请求都是由玩家客互端发起。 虚拟游戏网络的构成是一个独立的层次,完全可以撇开具体游戏逻辑来实现,并能够单独去按承载量考虑具体设计方案。非常利于剥离出具体游戏项目来开发并优化。 最终,我们或许需要的一套 C 库,用于游戏网络内的通讯。api 可以和 socket api 类似。额外多两条接入与离开游戏网络即可。 如需下载本站全部技术文章,可以在【高性能服务器开发】公众号回复关键字“文章下载”即可。最近更新时间: 2020-09-13 20:27:23 "},"articles/游戏开发专题/2网络游戏服务器开发框架设计介绍.html":{"url":"articles/游戏开发专题/2网络游戏服务器开发框架设计介绍.html","title":"2 网络游戏服务器开发框架设计介绍","keywords":"","body":"2 网络游戏服务器开发框架设计介绍 在开发过程中,会先有一份开发大纲或是一份策划案,但是这些在我的开发中可能不会有,或者即使有,也很有可能是我随性写下来的,但是我会尽可能写好它。 网络通信层,我会放到单独的SOCKET编程中去讲解,这里的主题是游戏的架构设计以及系统模块间的协同工作。 所以,在这里假设所有的网络层都已经开发完毕,具体的网络层开发代码不会再这里出现,因为这需要很多年的开发经验,或者对SOCKET有一定的了解才能够讲述清楚或理解,所以我不想再我还没有足够的把握之前去说这样的问题,主要问题是不想让人说我不专业;另一方面是不希望给没有接触过SOCKET编程或了解不多的人带来误导或困扰。 在开发游戏具体功能前,第一个要做的就是理清系统功能,这里的系统功能并不是具体的游戏功能,而是从软件角度出发的,行业内部称其为分布式服务器开发,讲的是如何构建一个可移植、可分布到不同网络机器独立或依赖运行的应用程序。 本系列开发教程是我个人游戏经历和工作历程的一个沉淀,也是我个人主观的一个未实现版本,在这里,我希望它可以以教程的方式存在,并去按部就班的一步一步实现出来。所有的源码代码都是开源的,我不会有丝毫保留,这样做的目的是方便很多像我一样的游戏狂热者入门无门,另一方面也是希望前辈们可以对我的错误进行指正。下面将具体描述服务器的划分以及功能实现。 此系列开发教程,总共将分为10个模块:它们分别为 LoginGate服务器、 LoginServer服务器、 GameGate服务器、 GameServer服务器、 IMServer服务器、 AIServer服务器、 CenterServer服务器、 BillingServer服务器、 WebServices服务器、 DBServer服务器。 1 LoginGate:登陆网关服务器,将所有的LoginServer服务器地址暴露给最终用户,每个LoginGate服务可以挂接n个LoginServer,将最终用户的所有请求转发给目标LoginServer。当最终用户通过此服务完成登陆后,会与该服务断开连接,断开连接前,服务器会将数据上报给GameGate服务。 2 LoginServer:登陆服务器,仅作于内部服务与LoginGate进行连接,所有的最终用户请求由LoginGate过滤后,转发过来进行处理。与LoginGate的所有通信都是明文,即未加密数据。 3 GameGate:游戏网关服务器,与LoginGate协作完成最终用户的登陆过程,每一个服务会连接到唯一一个LoginGate服务上进行注册,LoginGate会将以完成验证登陆的用户信息同步到所有已注册成功的GameGate上,根据注册不同的GameGate类型信息,LoginGate会发生不同的通过认证的最终用户信息。 GameGate挂接n个GameServer服务到自身,此服务将所有注册到自身的GameServer信息发送给最终用户,提供用户选择具体的区或线路进行游戏(区和线路在不同的游戏设定中有不同的定义),在这里区的定义对应的是GameGate,每一个GameGate可以表示物理或逻辑上的多个游戏分区,每个分区由至少一个GameServer组成; 线路定义为GameServer,每一个GameServer代表一条线路,线路之间互相不可见,但是可以通过IMServer进行一些扩展通信,例如公会、好友、聊天等服务可以设置透明通信或隐藏通信。透明通信由IMServer向目标GameServer转发请求,并进行处理;隐藏通信仅在当前GameServer进行处理,不会做跨越性操作。 4 GameServer:游戏服务器,作为内部服务与GameGate协作处理最终用户的请求,这个服务主要处理游戏逻辑,例如战斗。此服务启动后,会根据配置文件的配置信息进行相应的服务注册,该服务启动成功后,会注册到GameGate和IMServer、AIServer服务器,它们分别提供最终用户游戏、交友、公会、聊天和智能体的移动、创建、销毁等服务。作为整个游戏的核心处理服务器,会处理掉大部分的用户交互服务请求,只有在不能处理的情况下,才会请求其它服务协同处理。 5 IMServer:IM通信服务器,全称InstantMessaging(译为即时通讯),ICQ、MSN、QQ等聊天工具都属于此范畴。此服务的作用是提供物理或逻辑不同位置的GameServer上的最终用户通讯的一个媒介,用户成功登陆GameServer时,会将自己的好友、公会信息注册到此服务上,当需要跨GameServer服务时,共IMServer使用。此服务主要提供聊天、交友、交易、公会等社交类行为服务,该服务可以直接或间接的与最终用户进行通信,但最终用户无法直接与该服务进行通信,比如请求操作,所有的用户操作都由GameServer转发,IMServer可以选择性的直接反馈最终用户或通过GameServer反馈。 6 AIServer:人工智能服务器,全称Artificial Intelligence(译为人工智能),例如现代服务性机器人(自动吸尘器、智能探测仪、智能防爆装置等)都属于人工智能范畴。这里的人工智能主要体现在游戏中的NPC、MONSTER等有行为表现物体。GameServer启动后会连接到此服务进行注册,并获取所需智能体的信息,以反馈给最终用户,并最终显示在用户应用程序中。该服务主要控制智能体的移动、攻击、创建、销毁等行为,另外包括在战斗中或非战斗状态下的行为,比如游走在街道上的商品小贩;在搜索到攻击目标时,主动或召集附近的战斗单位一起攻击用户,都属于该服务的工作内容。 7 CenterServer:中心服务器,用于监控、更新已注册到此服务的状态,比如电信1区(傲视天地)服务器的运行状态等。此服务主要是管理除自身以外的所有服务程序的运行状态,以及时反馈给技术活运维人员。 8 BillingServer:计费服务器,用于计算用户在游戏中的消耗、增值;比如XX在游戏中购买了一个双倍经验卡,消耗10金币,或者用户通过网站形式进行充值,都会通过该服务反馈给用户最终结果。 9 WebServices:网站服务,主要用于网站与游戏之间的交互。比如XX用户通过网站进行充值服务,充值成功后,通知计费服务以响应用户操作;或通过网站进行游戏激活、礼品领取等,都需要此服务与游戏应用程序进行交互,以体现实时的变化。 10 DBServer:用于全局数据维护,例如更新、查询、插入、删除操作;这些数据包含用户账号、充值、代金卷、点卡、月卡以及游戏中需要用到的角色数据。 服务器整体架构图分布示意图: LoginGate内部运行示意图: LoginServer内部运行示意图: 由于其它服务器模块程序的内部图与这两个类似,所以就不在这个上面耽搁太多时间,下一篇将讲述具体的游戏开发,网络库使用的是开源库ACE,下载地址http://download.dre.vanderbilt.edu/previous_versions/ACE-5.8.0.zip。 如需下载本站全部技术文章,可以在【高性能服务器开发】公众号回复关键字“文章下载”即可。最近更新时间: 2020-10-07 22:51:37 "},"articles/游戏开发专题/3游戏后端开发需要掌握的知识.html":{"url":"articles/游戏开发专题/3游戏后端开发需要掌握的知识.html","title":"3 游戏后端开发需要掌握的知识","keywords":"","body":"3 游戏后端开发需要掌握的知识 这篇是从网上找到牛人的博客总结下来的: 实战方面: (1)两种在知名IT公司使用的游戏服务器架构设计 点击图片可以放大 1 各个服务器的功能以及作用: CenterServer服务器管理器:管理所有的服务器,分配服务器的端口,负责全局的逻辑(管理),对各功能服务器和场景服务器提供服务,保证服务器的合法性 DBserver角色档案缓冲服务器 GameServer逻辑服务器:玩家的实时同步在里面实现 GateServer网关服务器:负责消息转发 LoginServer登录服务器:连接账号数据 2 不带负载均衡的和带负载均衡: 相同点: ​ 与带负载均衡大概的架构相同 不同点: 不带负载均衡 Gate Server 和Game Server之间是一对一的关系,每个Game Server能容纳的玩家数量是一定的,正常情况下一个Gate Server的对应一个Game Server实时在线人数能达到3000人,一旦达到峰值,就会找下一个对应的Game Server。 各个Gate Server服务器之间是不通信的 带负载均衡 一个Gate Server的对应多个Game Server 各个GateServer之间可以互相通信,而且还可以随意扩展,通过配置文件可以实现配置 3 服务器的工作过程: 用户从客户端选择游戏服务器列表 登录到Login Server,在登陆的过程中 先去平台服务器进行账号的验证 验证通过后会通知Login Server,然后Login Server会把验证的消息发送 到center Server,请求其中的Gate Server的地址和端口 Center Server会找一个可用的Gate Server信息,发送回LoginServer Login Server会把消息发送给客户端 客户端断开与Login Server的连接,然后与Game Server 连接进入游戏场景中 如需下载本站全部技术文章,可以在【高性能服务器开发】公众号回复关键字“文章下载”即可。最近更新时间: 2020-10-07 22:54:14 "},"articles/游戏开发专题/4关于游戏服务端架构的整理.html":{"url":"articles/游戏开发专题/4关于游戏服务端架构的整理.html","title":"4 关于游戏服务端架构的整理","keywords":"","body":"4 关于游戏服务端架构的整理 一个大型的网落游戏服务器应该包含几个模块:网络通讯,业务逻辑,数据存储,守护监控(不是必须)。其中业务逻辑可能根据具体需要,又划分为好几个子模块。 这里说的模块可以指一个进程,或者一个线程方式存在,本质上就是一些类的封装。 对于服务器的并发性,要么采用单进程多线程,要么采用多进程单线程的方式,说说两种方式的优缺点: 一、单进程多线程的服务器设计模式,只有一个进程,但一个进程包好多个线程: 网络通讯层,业务逻辑,数据存储,分别在独立的线程中,无守护进程。 优点: 数据共享和交换方便,使用全局变量或者单例就可以,数据存储方便。 单进程,服务器框架结构相对简单,编码容易。 缺点: 所有功能只能在单个物理服务器上,不能做成分布式。 不方便监控各个线程状态,容易死锁 一个线程出错,例如内存非法访问,栈空间被破坏,那么服务器进程就退出,所有玩家掉线,影响大。 二、多进程单线程的服务器设计模式,多个进程,每个进程只有一个线程: 网路通讯,业务逻辑,数据存储,守护进程,分别在不同的进程。 优点: 各个进程可以分布在不同的物理服务器上,可以做成分布式的服务器框架,例如可以将数据存储单独放到一个物理服务器上,供几个区的服务器使用。将网络通讯进程独立出来,甚至可以做成导向服务器,实现跨服战。 可以通过守护进程监控其它进程状态,例如有进程死掉,马上重启该进程,或者某个进程cpu使用率接近100%(基本可以判断是某个逻辑死循环了), 强制kill掉该进程,然后重启。 单个服务器进程异常退出,只要不是网络通讯进程(一般这个都会比较稳定,没什么逻辑),那么就可以及时被守护进程重启,不会造成玩家掉线,只会造成在1-2秒内,某个逻辑功能无法使用,甚至玩家都感觉不到。 服务器通过共享内存进行数据交换,那么如果其中一个服务器死掉,数据还在,可以保护用户数据(当然多线程也可以使用共享内存)。 并发性相对多线程要高点。 缺点: 不方便使用互斥锁,因为进程切换的时间片远远于线程切换,对于一个高并发服务器是无法允许这么高时间片的切换代价的。因此必须设计好服务器的框架,尽量避开使用锁机制,但要保证数据不出错。 多进程编程,在各个进程间会有很多通讯,跨服务器进程的异步消息较多,会让服务器的编码难度加大。 下面先按照一个游戏的功能,将服务器的功能分块框架画出来: 点击图片可放大 以上是一个游戏服务器最基础的功能框架图,接下来要做的就是设计服务器的框架了 1. 早期的MMORPG服务器结构 ClientGameServerDB 所有业务数据集中处理 优点: 简单,快速开发 缺点: 所有业务放在一起,系统负担大大增加.一个bug可能导致整个服务器崩溃,造成所有玩家掉线甚至丢失等严重后果。 开服一刹那,所有玩家全部堆积在同一个新手村.->>>>卡,客户端卡(同屏人数过多渲染/广播风暴) 服务器卡(处理大量同场景消息/广播风暴) 2. 中期-用户分离集群式 GameServe1 Client | DB GameServer2 玩家不断增多->分线->程序自动或玩家手动选择进入 缺点:运营到后期,随着每条线玩家的减少, 互动大大减少。 3. 中后期 数据分离集群式 按地图划分服务器,当前主流 新手村问题:《天龙八部》提出了较好的解决方案,建立多个平行的新手村地图,一主多副,开服时尽可能多的同时容纳新用户的涌入,高等级玩家从其它地图回新手村只能到达主新手村。 4. 当前主流的网络游戏架构 注:在GateServer和CenterServer之间是有一条TCP连接的。而GameServer和LogServer之间的连接可以是UDP连接。这是有一个大概的图,很多地方需要细化。 GateServer:网关服务器,AgentServer、ProxyServer 优点: 作为网络通信的中转站,负责维护将内网和外网隔离开,使外部无法直接访问内部服务器,保障内网服务器的安全,一定程度上较少外挂的攻击。 网关服务器负责解析数据包、加解密、超时处理和一定逻辑处理,这样可以提前过滤掉错误包和非法数据包。 客户端程序只需建立与网关服务器的连接即可进入游戏,无需与其它游戏服务器同时建立多条连接,节省了客户端和服务器程序的网络资源开销。 在玩家跳服务器时,不需要断开与网关服务器的连接,玩家数据在不同游戏服务器间的切换是内网切换,切换工作瞬问完成,玩家几乎察觉不到,这保证了游戏的流畅性和良好的用户体验。 缺点: 网关服务器成为高负载情况下的通讯瓶颈问题 由于网关的单节点故障导致整组服务器无法对外提供服务的问题 解决: 多网关技术。顾名思义,“多网关” 就是同时存在多个网关服务器,比如一组服务器可以配置三台GameGme。当负载较大时,可以通过增加网关服务器来增加网关的总体通讯流量,当一台网关服务器宕机时,它只会影响连接到本服务器的客户端,其它客户端不会受到任何影响。 DCServer:数据中心服务器。主要的功能是缓存玩家角色数据,保证角色数据能快速的读取和保存 CenterServer:全局服务器/中心服务器,也叫WorldServer. 主要负责维持GameServer之间数据的转发和数据广播。另外一些游戏系统也可能会放到Center上处理,比如好友系统,公会系统。 改进: 将网关服务器细化为LogingateServer和多个GameGateServer. 5. 按业务分离式集群 由于网络游戏存在很多的业务,如聊天,战斗,行走,NPC等,可以将某些业务分到单独的服务器上。这样每个服务器的程序则会精简很多。而且一些大流量业务的分离,可以有效的提高游戏服务器人数上限。 优点: 业务的分离使得每种服务器的程序变的简单,这样可以降低出错的几率。即使出错,也不至于影响到每一个整个游戏的进行,而且通过快速启动另一台备用服务器替换出错的服务器。 业务的分离使得流量得到了分散,进而相应速度回得到提升 。 大部分业务都分离了成了单独的服务器,所以可以动态的添加,从而提高人数上限。 改进: 甚至可以将登陆服务器细化拆分建角色,选择角色服务器 6. 一种简单实用的网络游戏服务器架构 下图中每个方框表示一个独立的进程APP组件,每个服务进程如果发生宕机会影响部分用户,整体服务但不会全部中断。在宕机进程重启后,又可以并入整体,全部服务得以继续。 gls:game login server,游戏登录服务器,某种程序上,其不是核心组件,gls调用外部的接口,进行基本的用户名密码认证。此外需要实现很多附属的功能:登录排队 (对开服非常有帮助),GM超级登录通道(GM可以不排队进入游戏),封测期间激活用户控制,限制用户登录,控制客户端版本等。 db:实质上是后台sql的大内存缓冲,隔离了数据库操作,比较内存中的数据,只把改变的数据定时批量写入sql。系统的算法,开发稳定性都要求非常高。 center:所有组件都要在这里注册,在线玩家的session状态都在这里集中存放,和各组件有心跳连接。所有对外的接口也全部通过这里。 角色入口:玩家登录游戏后的选择角色 gs:game server,最核心组件,同一地图,所有游戏逻辑相关的功能,都在这里完成。 gate:建立和用户的常链接,主要作sockt转发,屏蔽恶意包,对gs进行保护。协议加密解密功能,一个gate共享多个gs,降低跳转地图连接不上的风险。 IM,关系,寄售:表示其它组件,负责对应的跨地图发生全局的游戏逻辑。 7.另一个架构图 1- 这是一条WebService的管道,在用户激活该区帐号,或者修改帐号密码的时候,通过这条通道来插入和更新用户的帐号信息。 2- 这也是一条WebService管道,用来获取和控制用户该该组内的角色信息,以及进行付费商城代币之类的更新操作。 3- 这是一条本地的TCP/IP连接,这条连接主要用来进行服务器组在登陆服务器的注册,以及登陆服务器验证帐户后,向用户服务器注册帐户登陆信息,以及进行对已经登陆的帐户角色信息进行操作(比如踢掉当前登陆的角色),还有服务器组的信息更新(当前在线玩家数量等)。 4- 这也是一条本地TCP/IP连接,这条连接用来对连接到GameServer的客户端进行验证,以及获取角色数据信息,还有传回GameServer上角色的数据信息改变。 5- 这条连接也是一条本地的TCP/IP连接,它用来进行公共信息服务器和数个游戏服务器间的交互,用来交换一些游戏世界级的信息(比如公会信息,跨服组队信息,跨服聊天频道等)。 6- 这里的两条连接,想表达的意思是,UserServer和GameServer的Agent是可以互换使用的,也就是玩家进入组内之后,就不需要再切换 Agent。如果不怕乱套,也可以把登陆服务器的Agent也算上,这样用户整个过程里就不需要再更换Agent,减少重复连接的次数,也提高了稳定性。 (毕竟连接次数少了,也降低了连不上服务器的出现几率) 在这个架构里面,GameServer实际上是一个游戏逻辑的综合体,里面可以再去扩展成几个不同的逻辑服务器,通过PublicServer进行公共数据交换。 UserServer实际上扮演了一个ServerGroup的领头羊的角色,它负责向LoginServer注册和更新服务器组的信息(名字,当前人数),并且对Agent进 行调度,对选择了该组的玩家提供一个用户量最少的Agent。同时,它也兼了一个角色管理服务器的功能,发送给客户端当前的角色列表,角色的创建,删除, 选择等管理操作,都是在这里进行的。而且,它还是一个用户信息的验证服务器,GameServer需要通过它来进行客户端的合法性验证,以及获取玩家选择 的角色数据信息。 采用这种架构的游戏,通常有以下表现: 1- 用户必须激活一个大区,才能在大区内登陆自己的帐号。 2- 用户启动客户端的时候,弹出一个登陆器,选择大区。 3- 用户启动真正的客户端的时候,一开始就是输入帐号密码。 4- 帐号验证完成之后,进行区内的服务器选择。 5- 服务器选择完成之后,进入角色管理。同时,角色在不同的服务器里不能共享。 三、正文网络通讯 1.网络协议 根据游戏类型 实时性要求/是否允许丢包 来决定 TCP/UDP协议 a.TCP:面向连接,可靠,保证顺序,慢,有延迟 TCP每次发送一个数据包后都要等待接收方发送一个应答信息,这样TCP才可以确认数据包通过因特网完整地送到了接收方。如果在一段时间内TCP没有收到 接收方的应答,他就会停止发送新的数据包,转而去重新发送没有收到应答2的数据包,并且持续这种发送状态知道收到接收方的应答。所以这会造成网络数据传输 的延迟,若网络情况不好,发送方会等待相当长一段时间 UDP:无连接,不可靠,不保证顺序,快 b.长连接/短连接 长连接,指在一个TCP连接上可以连续发送多个数据包,在TCP连接保持期间,如果没有数据包发送,需要双方发检测包以维持此连接,一般需要自己做在线维 连接→数据传输→保持连接(心跳)→数据传输→保持连接(心跳)→……→关闭连接 短连接,是指通信双方有数据交互时,就建立一个TCP连接,数据发送完成后,则断开此TCP连接,如Http 连接→数据传输→关闭连接 2.IO模型 Unix5中io模型 阻塞IO (Blocking I/O Model) 非阻塞IO (Nonblocking I/O Model) IO复用 (I/O Multiplexing Model) 信号驱动IO (Signal-Driven I/O Model) 异步IO (Asynchronous I/O Model) IO分两个阶段: 通知内核准备数据。 数据从内核缓冲区拷贝到应用缓冲区 根据这2点IO类型可以分成: 阻塞IO,在两个阶段上面都是阻塞的。 .非阻塞IO,在第1阶段,程序不断的轮询直到数据准备好,第2阶段还是阻塞的 IO复用,在第1阶段,当一个或者多个IO准备就绪时,通知程序,第2阶段还是阻塞的,在第1阶段还是轮询实现的,只是所有的IO都集中在一个地方,这个地方进行轮询 信号IO,当数据准备完毕的时候,信号通知程序数据准备完毕,第2阶段阻塞 异步IO,1,2都不阻塞 同时阻塞多个I/O操作。而且可以同时对多个读操作,多个写操作的I/O函数进行检测,直到有数据可读或可写时,才真正调用I/O操作函数 Java#Selector 允许套接口进行信号驱动I/O,并安装一个信号处理函数,进程继续运行并不阻塞。当数据准备好时,进程会收到一个SIGIO信号,可以在信号处理函数中调用I/O操作函数处理数据. Java#NIO2 发出系统调用后,直接返回。通知IO操作完成。 前四种同步IO,最后一种异步IO.二者区别:第二个阶段必须要求进程主动调用recvfrom.而异步io则将io操作全部交给内核完成,完成后发信号通知。此期间,用户不需要去检查IO操作的状态,也不需要主动的去拷贝数据。 3.线程阻塞的原因: Thread.sleep(),线程放弃CPU,睡眠N秒,然后恢复运行 线程要执行一段同步代码,由于无法获得相关的锁,阻塞。获得同步锁后,才可以恢复运行。 线程执行了一个对象的wait方法,进入阻塞状态,只有等到其他线程执行了该对象的notify、nnotifyAll,才能将其唤醒。 IO操作,等待相关资源 阻塞线程的共同特点是:放弃CPU,停止运行,只有等到导致阻塞的原因消除,才能恢复运行 。或者被其他线程中断,该线程会退出阻塞状态,并抛出InterruptedException. 4.阻塞/非阻塞/同步/异步 同步/异步关注的是消息如何通知的机制。而阻塞和非阻塞关注的是处理消息。是两组完全不同的概念。 5.几个常用概念 Select Poll Epoll(Linux) Kqueue(FreeBSD) IOCP Windows Reactor Dispatcher(分 发器),Notifer(通知器), 事件到来时,使用Dispatcher(分发器)对Handler进行分派,这个Dispatcher要对所有注册的Handler进行维护。同时,有一 个Demultiplexer(分拣器)对多路的同步事件进行分拣。 Proactor Proactor和Reactor都是并发编程中的设计模式.用于派发/分离IO操作事件的。这里所谓的IO事件也就是诸如read/write的IO操作。\"派发/分离\"就是将单独的IO事件通知到上层模块。两个模式不同的地方在于,Proactor用于异步IO,而Reactor用于同步IO。 两个模式的相同点,都是对某个IO事件的事件通知(即告诉某个模块,这个IO操作可以进行或已经完成)。在结构上,两者也有相同点:demultiplexor负责提交IO操作(异步)、查询设备是否可操作(同步),然后当条件满足时,就回调handler。 不同点在于,异步情况下(Proactor),当回调handler时,表示IO操作已经完成;同步情况下(Reactor),回调handler时,表示IO设备可以进行某个操作(can read or can write),handler这个时候开始提交操作。 6.网络通讯框架 TCP Server框架: Apache MINA(Multipurpose Infrastructure for Network Applications)2.0.4 Netty 3.5.0Final Grizzly 2.2 Quickserv**e**r是一个免费的开源Java库,用于快速创建健壮的多线程、多客户端TCP服务器应用程序。使用QuickServer,用户可以只集中处理应用程序的逻辑/协议 Cindy :强壮,可扩展,高效的异步I/O框架 xSocket:一个轻量级的基于nio的服务器框架用于开发高性能、可扩展、多线程的服务器。该框架封装了线程处理、异步读/写等方面 ACE 6.1.0 C++ADAPTIVE CommunicationEnvironment, SmaxFoxServer 2.X :专门为Adobe Flash设计的跨平台socket服务器 7.消息编码协议 AMF/JSON/XML/自定义/ProtocolBuffer 无论是做何种网络应用,必须要解决的问题之一就是应用层从字节流中拆分出消息的问题,也就是对于 TCP 这种字节流协议,接收方应用层能够从字节流中识别发送方传输的消息. 使用特殊字符或者字符串作为消息的边界,应用层解析收到的字节流时,遇见此字符或者字符串则认为收到一个完整的消息 为每个消息定义一个长度,应用层收到指定长度的字节流则认为收到了一个完整的消息 消息分隔标识(separator)、消息头(header)、消息体(body) len | message_id | data |separator | header | body | | len | message_id | data 8. 粘包: TCP粘包是指发送方发送的若干包数据到接收方接收时粘成一包,从接收缓冲区看,后一包数据的头紧接着前一包数据的尾。 发送方引起的粘包是由TCP协议本身造成的,TCP为提高传输效率,发送方往往要收集到足够多的数据后才发送一包数据。若连续发送几次的数据都很少,通常TCP会根据优化算法把这些数据合成一包后一次发送出去,这样接收方就收到了粘包数据。 接收方引起的粘包是由于接收方用户进程不及时接收数据,从而导致粘包现象。这是因为接收方先把收到的数据放在系统接收缓冲区,用户进程从该缓冲区取数据, 若下一包数据到达时前一包数据尚未被用户进程取走,则下一包数据放到系统接收缓冲区时就接到前一包数据之后,而用户进程根据预先设定的缓冲区大小从系统接 收缓冲区取数据,这样就一次取到了多包数据 解决措施: 对于发送方引起的粘包现象,用户可通过编程设置来避免,TCP提供了强制数据立即传送的操作指令push,TCP软件接收到该操作指令后,就立即将本段数据发送出去,而不必等待发送缓冲区满; TCP-NO-DELAY-关闭了优化算法,不推荐 对于接收方引起的粘包,则可通过优化程序设计、精简接收进程工作量、提高接收进程优先级等措施,使其及时接收数据,从而尽量避免出现粘包现象-当发送频率高时依然可能出现粘包 接收方控制,将一包数据按结构字段,人为控制分多次接收,然后合并,通过这种手段来避免粘包。-效率低 接收方创建一预处理线程,对接收到的数据包进行预处理,将粘连的包分开 #### 分包算法思路: 基本思路是首先将待处理的接收数据(长度设为m)强行转换成预定的结构数据形式,并从中取出数据结构长度字段,即n,而后根据n计算得到第一包数据长度 1) 若nm,则表明数据流内容尚不够构成一个完整结构数据,需留待与下一包数据合并后再行处理。 在单位设计上必须从头到尾贯彻面向对象的“继承”观念先设计基础单位A ,再在之上扩展到所有的单位,也就是说,所有的普通单位都可以追溯到一个起源的对象,否则代码量会让你想死,然后就能获得所有的单位和建筑物了。 地图寻路 寻路的问题在于自然的移动,追着一个单位打,或者进入射程中停下来,比起如何自然的经过一个单位打,成为了一个,为什么你要在A站或者B站坐公交的问题,Why,如何才能符合逻辑的设计---敌人进攻的单位,这个AI,不但是策略的问题,还是行为的问题,所以,将敌人的最终目标确定在哪里呢? 回答:AI 和 行为控制模块要分成两个模块来做 行为控制模块复制地图上所有单位的移动、攻击等动作,目标、目的的指示; 这必须是一个独立的模块,可以避免为每个单位都写逻辑的同时,让大部分单位战斗起来有个统一的目的;这么做的好处是,大部分的CPU时间都在一段高效率的代码上,由这个模块负责按照顺序给每个单位下达动作指令 如果有必要 还需要一个监控模块,监控单位的状态改变 AI : 就是上面写过的 Why , 我要在A站乘车 还是B站乘车的逻辑,我是优先攻击单位,还是优先攻击建筑物的逻辑。。。本虎还没想明白,这是一个逻辑怪圈。。。。 如需下载本站全部技术文章,可以在【高性能服务器开发】公众号回复关键字“文章下载”即可。最近更新时间: 2020-10-07 23:01:10 "},"articles/游戏开发专题/5各类游戏对应的服务端架构.html":{"url":"articles/游戏开发专题/5各类游戏对应的服务端架构.html","title":"5 各类游戏对应的服务端架构","keywords":"","body":"5 各类游戏对应的服务端架构 类型一:卡牌、跑酷等弱交互服务端 卡牌跑酷类因为交互弱,玩家和玩家之间不需要实时面对面PK,打一下对方的离线数据,计算下排行榜,买卖下道具即可,所以实现往往使用简单的 HTTP服务器: 登录时可以使用非对称加密(RSA, DH),服务器根据客户端uid,当前时间戳还有服务端私钥,计算哈希得到的加密 key 并发送给客户端。之后双方都用 HTTP通信,并用那个key进行RC4加密。客户端收到key和时间戳后保存在内存,用于之后通信,服务端不需要保存 key,因为每次都可以根据客户端传上来的 uid 和 时间戳 以及服务端自己的私钥计算得到。用模仿 TLS的行为,来保证多次 HTTP请求间的客户端身份,并通过时间戳保证同一人两次登录密钥不同。 每局开始时,访问一下,请求一下关卡数据,玩完了又提交一下,验算一下是否合法,获得什么奖励,数据库用单台 MySQL或者 MongoDB即可,后端的 Redis做缓存(可选)。 如果要实现通知,那么让客户端定时15秒轮询一下服务器,如果有消息就取下来,如果没消息可以逐步放长轮询时间,比如30秒;如果有消息,就缩短轮询时间到10秒,5秒,即便两人聊天,延迟也能自适应。 此类服务器用来实现一款三国类策略或者卡牌及酷跑的游戏已经绰绰有余,这类游戏因为逻辑简单,玩家之间交互不强,使用 HTTP来开发的话,开发速度快,调试只需要一个浏览器就可以把逻辑调试清楚了。 类型2:第一代游戏服务器 1978 1978年,英国著名的财经学校University of Essex的学生 Roy Trubshaw编写了世界上第一个MUD程序《MUD1》,在University of Essex于1980年接入 ARPANET之后加入了不少外部的玩家,甚至包括国外的玩家。《MUD1》程序的源代码在 ARPANET共享之后出现了众多的改编版本,至此MUD才在全世界广泛流行起来。不断完善的 MUD1的基础上产生了开源的 MudOS(1991),成为众多网游的鼻祖: MUDOS采用 C语言开发,因为玩家和玩家之间有比较强的交互(聊天,交易,PK),MUDOS使用单线程无阻塞套接字来服务所有玩家,所有玩家的请求都发到同一个线程去处**理,主线程每隔1秒钟更新一次所有对象(网络收发,更新对象状态机,处理超时,刷新地图,刷新NPC)。 游戏世界采用房间的形式组织**起来,每个房间有东南西北四个方向可以移动到下一个房间,由于欧美最早的网游都是地牢迷宫形式的,因此场景的基本单位被称为 “房间”。 MUDOS使用一门称为LPC的脚本语言来描述整个世界(包括房间拓扑,配置,NPC,以及各种剧情)。游戏里面的高级玩家(巫师),可以不断的通过修改脚本来为游戏添加房间以及增加剧情。早年 MUD1上线时只有17个房间,Roy Trubshaw毕业以后交给他的师弟 Richard Battle,在 Richard Battle手上,不断的添加各种玩法到一百多个房间,终于将 MUD发扬光大。 用户使用 Telnet之类的客户端用 Tcp协议连接到 MUDOS上,使用纯文字进行游戏,每条指令用回车进行分割。比如 1995年国内第一款 MUD游戏《侠客行》,你敲入:”go east”,游戏就会提示你:“后花园 - 这里是归云庄的后花园,种满了花草,几个庄丁正在浇花。此地乃是含羞草生长之地。这里唯一的出口是 north。这里有:花待 阿牧(A mu),还有二位庄丁(Zhuang Ding)”,然后你继续用文字操作,查看阿牧的信息:“look a mu”,系统提示:“花待 阿牧(A mu)他是陆乘风的弟子,受命在此看管含羞草。他看起来三十多岁,生得眉清目秀,端正大方,一表人才。他的武艺看上去【不是很高】,出手似乎【极轻】”。然后你可以选择击败他获得含羞草,但是你吃了含羞草却又可能会中毒死亡。在早期网上资源贫乏的时候,这样的游戏有很强的代入感。 用户数据保存在文件中,每个用户登录时,从文本文件里把用户的数据全部加载进来,操作全部在内存里面进行,无需马上刷回磁盘。用户退出了,或者每隔5分钟检查到数据改动了,都会保存到磁盘。这样的系统在当时每台服务器承载个4000人同时游戏,不是特别大的问题。从1991年的 MUDOS发布后,全球各地都在为他改进,扩充,退出新版本,随着 Windows图形机能的增强。1997游戏《UO》在 MUDOS的基础上为角色增加的x,y坐标,为每个房间增加了地图,并且为每个角色增加了动画,形成了第一代的图形网络游戏。 因为游戏内容基本可以通过 LPC脚本进行定制,所以MUDOS也成为名副其实的第一款服务端引擎,引擎一次性开发出来,然后制作不同游戏内容。后续国内的《万王之王》等游戏,很多都是跟《UO》一样,直接在 MUDOS上进行二次开发,加入房间的地图还有角色的坐标等要素,该架构一直为国内的第一代 MMORPG提供了稳固的支持,直到 2003年,还有游戏基于 MUDOS开发。 虽然后面图形化增加了很多东西,但是这些MMORPG后端的本质还是 MUDOS。 随着游戏内容的越来越复杂,架构变得越来越吃不消了,各种负载问题慢慢浮上水面,于是有了我们的第二代游戏服务器。 类型3:第二代游戏服务器 2003 2000年后,网游已经脱离最初的文字MUD,进入全面图形化年代。最先承受不住的其实是很多小文件,用户上下线,频繁的读取写入用户数据,导致负载越来越大。随着在线人数的增加和游戏数据的增加,服务器变得不抗重负。同时早期 EXT磁盘分区比较脆弱,稍微停电,容易发生大面积数据丢失。因此第一步就是拆分文件存储到数据库去。 此时游戏服务端已经脱离陈旧的 MUDOS体系,各个公司在参考 MUDOS结构的情况下,开始自己用 C再重新开发自己的游戏服务端。并且脚本也抛弃了 LPC,采用扩展性更好的 Python或者 Lua来代替。由于主逻辑使用单线程模型,随着游戏内容的增加,传统单服务器的结构进一步成为瓶颈。于是有人开始拆分游戏世界,变为下面的模型: 游戏服务器压力拆分后得以缓解,但是两台游戏服务器同时访问数据库,大量重复访问,大量数据交换,使得数据库成为下一个瓶颈。于是形成了数据库前端代理(DB Proxy),游戏服务器不直接访问数据库而是访问代理,再有代理访问数据库,同时提供内存级别的cache。早年 MySQL4之前没有提供存储过程,这个前端代理一般和 MySQL跑在同一台上,它转化游戏服务器发过来的高级数据操作指令,拆分成具体的数据库操作,一定程度上代替了存储过程: 但是这样的结构并没有持续太长时间,因为玩家切换场景经常要切换连接,中间的状态容易错乱。而且游戏服务器多了以后,相互之间数据交互又会变得比较麻烦,于是人们拆分了网络功能,独立出一个网关服务 Gate(有的地方叫 Session,有的地方叫 LinkSvr之类的,名字不同而已): 把网络功能单独提取出来,让用户统一去连接一个网关服务器,再有网关服务器转发数据到后端游戏服务器。而游戏服务器之间数据交换也统一连接到网管进行交换。这样类型的服务器基本能稳定的为玩家提供游戏服务,一台网关服务1-2万人,后面的游戏服务器每台服务5k-1w,依游戏类型和复杂度不同而已,图中隐藏了很多不重要的服务器,如登录和管理。这是目前应用最广的一个模型,到今天仍然很多新项目会才用这样的结构来搭建。 人都是有惯性的,按照先前的经验,似乎把 MUDOS拆分的越开性能越好。于是大家继续想,网关可以拆分呀;基础服务如聊天交易可以拆分呀;还可以提供web接口,数据库可以拆分呀,于是有了下面的模型: 这样的模型好用么?确实有成功游戏使用类似这样的架构,并且发挥了它的性能优势,比如一些大型 MMORPG。但是有两个挑战: 每增加一级服务器,状态机复杂度可能会翻倍,导致研发和找bug的成本上升; 并且对开发组挑战比较大,一旦项目时间吃紧,开发人员经验不足,很容易弄挂。 比如我见过某上海一线游戏公司的一个 RPG上来就要上这样的架构,我看了下他们团队成员的经验,问了下他们的上线日期,劝他们用前面稍微简单一点的模型。人家自信得很,认为有成功项目是这么做的,他们也要这么做,自己很想实现一套。于是他们义无反顾的开始编码,项目做了一年多,然后,就没有然后了。 现今在游戏成功率不高的情况下,一开始上一套比较复杂的架构需要考虑投资回报率,比如你的游戏上线半年内 PCU会去到多少?如果一个 APRG游戏,每组服务器5千人都到不了的话,那么选择一套更为贴近实际情况的结构更为经济。即使后面你的项目真的超过5千人朝着1万人目标奔的话,相信那个时候你的项目已经挣大钱了 ,你数着钱加着班去逐步迭代,一次次拆分它,相信心里也是乐开花的。 上面这些类型基本都是从拆分 MUDOS开始,将 MUDOS中的各个部件从单机一步步拆成分布式。虽然今天任然很多新项目在用上面某一种类似的结构,或者自己又做了其他热点模块的拆分。因为他们本质上都是对 MUDOS的分解,故将他们归纳为第二代游戏服务器。 类型4:第三代游戏服务器 2007 从魔兽世界开始无缝世界地图已经深入人心,比较以往游戏玩家走个几步还需要切换场景,每次切换就要等待 LOADING个几十秒是一件十分破坏游戏体验的事情。于是对于 2005年以后的大型 MMORPG来说,无缝地图已成为一个标准配置。比较以往按照地图来切割游戏而言,无缝世界并不存在一块地图上面的人有且只由一台服务器处理了: 每台 Node服务器用来管理一块地图区域,由 NodeMaster(NM)来为他们提供总体管理。更高层次的 World则提供大陆级别的管理服务。这里省略若干细节服务器,比如传统数据库前端,登录服务器,日志和监控等,统统用 ADMIN概括。在这样的结构下,玩家从一块区域走向另外一块区域需要简单处理一下: 玩家1完全由节点A控制,玩家3完全由节点B控制。而处在两个节点边缘的2号玩家,则同时由A和B提供服务。玩家2从A移动到B的过程中,会同时向A请求左边的情况,并向B请求右边的情况。但是此时玩家2还是属于A管理。直到玩家2彻底离开AB边界很远,才彻底交由B管理。按照这样的逻辑将世界地图分割为一块一块的区域,交由不同的 Node去管理。 对于一个 Node所负责的区域,地理上没必要连接在一起,比如大陆的四周边缘部分和高山部分的区块人比较少,可以统一交给一个Node去管理,而这些区块在地理上并没有联系在一起的必要性。一个 Node到底管理哪些区块,可以根据游戏实时运行的负载情况,定时维护的时候进行更改 NodeMaster 上面的配置。 于是碰到第一个问题是很多 Node服务器需要和玩家进行通信,需要问管理服务器特定UID为多少的玩家到底在哪台 Gate上,以前按场景切割的服务器这个问题不大,问了一次以后就可以缓存起来了,但是现在服务器种类增加不少,玩家又会飘来飘去,按UID查找玩家比较麻烦;另外一方面 GATE需要动态根据坐标计算和哪些 Node通信,导致逻辑越来越厚,于是把:“用户对象”从负责连接管理的 GATE中切割出来势在必行于是有了下面的模型: 网关服务器再次退回到精简的网络转发功能,而用户逻辑则由按照 UID划分的 OBJ服务器来承担,GATE是按照网络接入时的负载来分布,而 OBJ则是按照资源的编号(UID)来分布,这样和一个用户通信直接根据 UID计算出 OBJ服务器编号发送数据即可。而新独立出来的 OBJ则提供了更多高层次的服务: 对象移动:管理具体玩家在不同的 Node所管辖的区域之间的移动,并同需要的 Node进行沟通。 数据广播:Node可以给每个用户设置若干 TAG,然后通知 Object Master 按照TAG广播。 对象消息:通用消息推送,给某个用户发送数据,直接告诉 OBJ,不需要直接和 GATE打交道。 好友聊天:角色之间聊天直接走 OBJ/OBJ MASTER。 整个服务器主体分为三层以后,NODE专注场景,OBJ专注玩家对象,GATE专注网络。这样的模型在无缝场景服务器中得到广泛的应用。但是随着时间的推移,负载问题也越来越明显,做个活动,远来不活跃的区域变得十分活跃,靠每周维护来调整还是比较笨重的,于是有了动态负载均衡。 动态负载均衡有两种方法,第一种是按照负载,由 Node Master 定时动态移动修改一下各个 Node的边界,而不同的玩家对象按照先前的方法从一台 Node上迁移到另外一台 Node上: 这样 Node Master定时查找地图上的热点区域,计算新的场景切割方式,然后告诉其他服务器开始调整,具体处理方式还是和上面对象跨越边界移动的方法一样。 但是上面这种方式实现相对复杂一些,于是人们设计出了更为简单直接的一种新方法: 还是将地图按照标准尺寸均匀切割成静态的网格,每个格子由一个具体的Node负责,但是根据负载情况,能够实时的迁移到其他 Node上。 在迁移分为三个阶段:准备,切换,完成。三个状态由Node Master负责维护。准备阶段新的 Node开始同步老 Node上面该网格的数据,完成后告诉NM;NM确认OK后同时通知新旧 Node完成切换。完成切换后,如果 Obj服务器还在和老的 Node进行通信,老的 Node将会对它进行纠正,得到纠正的 OBJ将修正自己的状态,和新的 Node进行通信。 很多无缝动态负载均衡的服务端宣称自己支持无限的人数,但不意味着 MMORPG游戏的人数上限真的可以无限扩充,因为这样的体系会受制于网络带宽和客户端性能。带宽决定了同一个区域最大广播上限,而客户端性能决定了同一个屏幕到底可以绘制多少个角色。 从无缝地图引入了分布式对象模型开始,已经完全脱离 MUDOS体系,成为一种新的服务端模型。又由于动态负载均衡的引入,让无缝服务器如虎添翼,容纳着超过上一代游戏服务器数倍的人数上限,并提供了更好的游戏体验,我们称其为第三代游戏服务端架构。网游以大型多人角色扮演为开端,RPG网游在相当长的时间里一度占据90%以上,使得基于 MMORPG的服务端架构得到了蓬勃的发展,然而随着玩家对RPG的疲惫,各种非MMORPG游戏如雨后春笋般的出现在人们眼前,受到市场的欢迎。 类型5:战网游戏服务器 经典战网服务端和 RPG游戏有两个区别:RPG是分区分服的,北京区的用户和广州区的用户老死不相往来。而战网,虽然每局游戏一般都是 8人以内,但全国只有一套服务器,所有的玩家都可以在一起游戏,而玩家和玩家之使用 P2P的方式连接在一起,组成一局游戏: 玩家通过 Match Making 服务器使用:创建、加入、自动匹配、邀请 等方式组成一局游戏。服务器会选择一个人做 Host,其他人 P2P连接到做主的玩家上来。STUN是帮助玩家之间建立 P2P的牵引服务器,而由于 P2P联通情况大概只有 75%,实在联不通的玩家会通过 Forward进行转发。 大量的连接对战,体育竞技游戏采用类似的结构。P2P有网状模型(所有玩家互相连接),和星状模型(所有玩家连接一个主玩家)。复杂的游戏状态在网状模型下难以形成一致,因此星状P2P模型经受住了历史的考验。除去游戏数据,支持语音的战网系统也会将所有人的语音数据发送到做主的那个玩家机器上,通过混音去重再编码的方式返回给所有用户。 战网类游戏,以竞技、体育、动作等类型的游戏为主,较慢节奏的 RPG(包括ARPG)有本质上的区别,而激烈的游戏过程必然带来到较 RPG复杂的多的同步策略,这样的同步机制往往带来的是很多游戏结果由客户端直接计算得出,那在到处都是破解的今天,如何保证游戏结果的公正呢? 主要方法就是投票法,所有客户端都会独立计算,然后传递给服务器。如果结果相同就更新记录,如果结果不一致,会采取类似投票的方式确定最终结果。同时记录本剧游戏的所有输入,在可能的情况下,找另外闲散的游戏客户端验算整局游戏是否为该结果。并且记录经常有作弊嫌疑的用户,供运营人员封号时参考。 类型6:休闲游戏服务器 休闲游戏同战网服务器类似,都是全区架构,不同的是有房间服务器,还有具体的游戏服务器,游戏主体不再以玩家 P2P进行,而是连接到专门的游戏服务器处理: 和战网一样的全区架构,用户数据不能象分区的 RPG那样一次性load到内存,然后在内存里面直接修改。全区架构下,为了应对一个用户同时玩几个游戏,用户数据需要区分基本数据和不同的游戏数据,而游戏数据又需要区分积分数据、和文档数据。胜平负之类的积分可以直接提交增量修改,而更为普遍的文档类数据则需要提供读写令牌,写令牌只有一块,读令牌有很多块。同帐号同一个游戏同时在两台电脑上玩时,最先开始的那个游戏获得写令牌,可以操作任意的用户数据。而后开始的那个游戏除了可以提交胜平负积分的增量改变外,对用户数据采用只读的方式,保证游戏能运行下去,但是会提示用户,游戏数据锁定。 类型7:现代动作类网游 从早期的韩国动作游戏开始,传统的战网动作类游戏和 RPG游戏开始尝试融合。单纯的动作游戏玩家容易疲倦,留存也没有 RPG那么高;而单纯 RPG战斗却又慢节奏的乏味,无法满足很多玩家激烈对抗的期望,于是二者开始融合成为新一代的:动作 + 城镇 模式。玩家在城镇中聚集,然后以开副本的方式几个人出去以动作游戏的玩法来完成各种 RPG任务。本质就是一套 RPG服务端+副本服务端。由于每次副本时人物可以控制在8人以内,因此可以获得更为实时的游戏体验,让玩家玩的更加爽快。 思考 说了那么多的游戏服务器类型,其实也差不多了,剩下的类型大家拼凑一下其实也就是这个样子而已。游戏服务端经历了那么多结构上的变迁,内部开发模式是否依然不变?究竟是继续延续传统的开发方式?还是有了更多突破性的方法?经历那么多次架构变迁,后面是否有共通的逻辑?未来的发展还会存在哪些困难?游戏服务端开发如何达到最终的彼岸? 如需下载本站全部技术文章,可以在【高性能服务器开发】公众号回复关键字“文章下载”即可。最近更新时间: 2020-10-07 23:06:54 "},"articles/游戏开发专题/6从腾讯QQgame高性能服务器集群架构看“分而治之”与“自治”等分布式架构设计原则.html":{"url":"articles/游戏开发专题/6从腾讯QQgame高性能服务器集群架构看“分而治之”与“自治”等分布式架构设计原则.html","title":"6 从腾讯QQgame高性能服务器集群架构看“分而治之”与“自治”等分布式架构设计原则","keywords":"","body":"6 从腾讯QQgame高性能服务器集群架构看“分而治之”与“自治”等分布式架构设计原则 腾讯QQGame游戏同时在线的玩家数量极其庞大,为了方便组织玩家组队游戏,腾讯设置了大量游戏室(房间),玩家可以选择进入属意的房间,并在此房间内找到可以加入的游戏组(牌桌、棋盘等)。玩家选择进入某个房间时,必须确保此房间当前人数未满(通常上限为400),否则进入步骤将会失败。玩家在登入QQGame后,会从服务器端获取某类游戏下所有房间的当前人数数据,玩家可以据此找到未满的房间以便进入。 如上篇所述的原因,如果待进入房间的人数接近上限时,玩家的进入请求可能失败,这是因为服务器在收到此进入请求之前可能有若干其他玩家也请求进入这个房间,造成房间人数达到上限。 这一问题是无法通过上篇所述调整协作分配的方法来解决的,这是因为:要进入的房间是由玩家来指定的,无法在服务器端完成此项工作,游戏软件必须将服务器端所维护的所有房间人数数据复制到玩家的客户端,并让玩家在界面上看到这些数据,以便进行选择。 这样,上篇所述的客户端与服务器端协作分配原则(谁掌握数据,谁干活),还得加上一些限制条件,并让位于另一个所谓\"用户驱动客户端行为\"原则--如果某个功能的执行是由用户来推动的,则这个功能的实现应当放在客户端(或者至少由客户端来控制整个协作),并且客户端必须持有此功能所依赖相关数据的副本,这个副本应当尽量与服务器端的源保持同步。 图一\"进入房间\"失败示意 QQGame还存在一个明显的不足,就是:玩家如果在游戏一段时间后,离开了某个房间,并且想进入其它房间,这时QQGame并不会刷新所有房间的当前人数,造成玩家据此信息所选的待进入房间往往实际上人数已满,使得进入步骤失败。笔者碰到的最糟情形是重复3、4次以上,才最后成功进入另外某个房间。此缺陷其实质是完全放弃了客户端数据副本与服务器端的源保持同步的原则。 实际上,QQGame的开发者有非常充分的理由来为此缺陷的存在进行辩护:QQGame同时在线的用户数超过百万甚至千万数量级,如果所有客户端要实时(所谓实时,就玩家的体验容忍度而言,可以定为不超过1秒的延迟)地从服务器端获取更新数据,那么最终只有一个结果--系统彻底崩溃。 设想一下每秒千万次请求的吞吐量,以普通服务器每秒上百个请求的处理能力(这个数据是根据服务请求处理过程可能涉及到I/O操作来估值的,纯内存处理的情形可能提高若干数量级),需要成千上万台服务器组成集群方能承受(高可用性挑战);而随着玩家不断地进入或退出游戏房间,相关数据一直在快速变化中, 正向来看,假设有一台中心服务器持有这些数据,那么需要让成千上万台服务器与中心保持这些动态数据的实时同步(数据一致性挑战); 相对应的,逆向来看,玩家进入房间等请求被分配给不同的服务器来处理,一旦玩家进入房间成功则对应服务器内的相关数据被改变,那么假定中的中心服务器就需要实时汇集所有工作服务器内发生的数据变动(数据完整性挑战)。 同时处理上万台服务器的数据同步,这需要什么样的中心服务器呢?即使有这样的超级服务器存在,那么Internet网较大的(而且不稳定的)网络通讯延迟又怎么解决呢? 对于软件缺陷而言,可以在不同的层面来加以解决--从设计、到需求、甚至是直接在业务层面来解决(例如,08年北京奥运会网上购票系统,为了解决订票请求拥塞而至系统崩溃的缺陷,最后放弃了原先\"先到先得\"的购票业务流程,改为:用户先向系统发订票申请,系统只是记录下来而不进行处理,而到了空闲时,在后台随机抽选幸运者,为他们一一完成订票业务)。当然解决方案所处的层面越高,可能就越让人不满意。 就上述进入房间可能遭遇失败的缺陷而言,最简便的解决方案就是:在需求层面调整系统的操作方式,即增加一个类似上篇所述\"自动快速加入游戏\"的功能--\"自动进入房间\"功能。系统在服务器端为玩家找到一个人数较多又未满的房间,并尝试进入(注意,软件需求是由用户的操作目标所驱动的,玩家在此的目标就是尽快加入一个满意的游戏组,因此由系统来替代玩家选择目标房间同样符合相关目标)。而为了方便玩家手工选择要进入的房间,则应当增加一个\"刷新当前各房间人数\"的功能。另外,还可以调整房间的组织模式,例如以地域为单位来划分房间,像深圳(长城宽带)区房间1、四川(电信)房间3、北美区房间1等,在深圳上网的玩家将被系统引导而优先进入深圳区的房间。 不管怎样,解决软件缺陷的王道还是在设计层面。要解决上述缺陷,架构设计师就必须同时面对高可用、数据一致性、完整性等方面的严峻挑战。 在思考相关解决方案时,我们将应用若干与高性能服务器集群架构设计相关的一些重要原则。首先是\"分而治之\"原则,即将大量客户端发出的服务请求进行适当的划分(例如,所有从深圳长城宽带上网的玩家所发出的服务请求分为一组),分别分配给不同的服务器(例如,将前述服务请求分组分配给放置于深圳数据中心的服务器)来加以处理。对于QQGame千万级的并发服务请求数而言,采用Scale Up向上扩展,即升级单个服务器处理能力的方式基本上不予考虑(没有常规的主机能处理每秒上千万的请求)。唯一可行的,只有Scale Out向外扩展,即利用大量服务器集群做负载均衡的方式,这实质上就是\"分而治之\"原则的具体应用。 图二 分而治之\"下的QQGame游戏服务集群部署 然而,要应用\"分而治之\"原则进行Scale Out向外扩展,还依赖于其它的条件。如果各服务器在处理被分配的服务请求时,其行为与其它服务器的行为结果产生交叉(循环)依赖,换句话讲就是共享了某些数据(例如,服务器A处理客户端a发来的进入房间#n请求,而同时,服务器B也在处理客户端b发来的进入房间#n请求,此时服务器A与B的行为存在循环依赖--因为两者要同时访问房间#n的数据,这一共享数据会造成两者间的循环依赖),则各服务器之间必须确保这些共享数据的一致完整性,否则就可能发生逻辑错误(例如,假定房间#n的人数差一个就满了,服务器A与B在独自处理的情况下,将同时让客户端a与b的进入请求成功,于是房间#n的最终人数将超出上限)。 而要做到此点,各服务器的处理进程之间就必须保持同步(实际上就是排队按先后顺序访问共享数据,例如:服务器A先处理,让客户端a进入房间成功,此时房间#n满员;此后服务器B更新到房间#n满的数据,于是客户端b的进入请求处理结果失败),这样,原来将海量请求做负载均衡的意图就彻底失败了,多台服务器的并发处理能力在此与一台实质上并没有区别。 由此,我们导出了另外一个所谓\"处理自治\"(或称\"行为独立\")的原则,即所有参与负载均衡的服务器,其处理对应服务请求的行为应当不循环依赖于其它服务器,换句话讲,就是各服务器的行为相对独立(注意:在这里,非循环依赖是允许的,下文中我们来分析为什么)。 由此可见,简单的负载均衡策略对于QQGame而言是解决不了问题的。我们必须找到一种途径,使得在使用大量服务器进行\"分而治之\"的同时,同时有确保各个服务器\"处理自治\"。此间的关键就在于\"分而治之\"的\"分\"字上。前述将某个地域网段内上网的玩家所发出的服务请求分到一组,并分配给同一服务器的做法,其目的不外乎是尽可能地减少网络通讯延迟带来的负面影响。但它不能满足\"处理自治\"的要求,为了确保自治,应当让同一台服务器所处理的请求本身是\"自治\"(准确的说法是\"自闭包\"Closure)的。同一台服务器所处理的所有请求组成一个服务请求集合,这个集合如果与其它任何与其无交集的(请求)集合(包含此集合的父集合除外)不循环依赖,则此服务请求集合是\"自闭包\"的,而处理此请求集合的服务器,其\"行为独立\"。 我们可以将针对同一房间的进入请求划分到同一服务请求分组,这些请求相互之间当然是存在循环依赖的,但与其它分组中的请求却不存在循环依赖(本房间内人数的变化不会影响到其它房间),而将它们都分配给同一服务器(不妨命名为\"房间管理服务器\",简称\"房间服务器\")后,那个服务器将是\"处理自治\"的。 图三 满足\"处理自治\"条件的QQ游戏区域\"房间管理\"服务部署 那么接下来要解决的问题,就是玩家所关注的某个游戏区内,所有房间当前人数数据的实时更新问题。其解决途径与上述的方法类似,我们还是将所有获取同一区内房间数据的服务请求归为一组,并交给同一服务器处理。与上文所述场景不同的是,这个服务器需要实时汇集本区内所有房间服务器的房间人数数据。我们可以让每个房间服务器一旦发生数据变更时,就向此服务器(不妨命名为\"游戏区域管理服务器\",简称\"区服务器\")推送一个变更数据记录,而推送的数据只需包含房间Id和所有进入的玩家Id(房间服务器还包含其它细节数据,例如牌桌占位数据)便可。 另外,由于一个区内的玩家数可能是上十万数量级,一个服务器根本承担不了此种负荷,那么怎么解决这一矛盾呢?如果深入分析,我们会发现,更新区内房间数据的请求是一种数据只读类请求,它不会对服务器状态造成变更影响,因此这些请求相互间不存在依赖关系;这样,我们可以将它们再任意划分为更小的分组,而同时这些分组仍然保持\"自闭包\"特性,然后分配给不同的区服务器。多台区服务器来负责同一区的数据更新请求,负载瓶颈被解决。 当然,此前,还需将这些区服务器分为1台主区服务器和n台从属区服务器;主区服务器负责汇集本区内所有房间服务器的房间人数数据,从属区服务器则从主区服务器实时同步区房间数据副本。 更好的做法,则是如『图五』所示,由房间服务器来充当从属区服务器的角色,玩家进入某个房间后,在玩家进入另外一个房间之前,其客户端都将从此房间对应的房间服务器来更新区内房间数据。要注意的是,图中房间服务器的数据更新利用了所谓的\"分布式对象缓存服务\"。 玩家进入某个房间后,还要加入某个游戏组才能玩游戏。上篇所述的方案,是让第一个加入某个牌桌的用户,其主机自动充当本牌桌的游戏服务器;而其它玩家要加入此牌桌,其加入请求应当发往第一个加入的用户主机;此后开始游戏,其对弈过程将由第一个加入用户的主机来主导执行。 那么此途径是否同样也符合上述的前两个设计原则呢?游戏在执行的过程中,根据输赢结果,玩家要加分或减分,同时还要记录胜负场数。这些数据必须被持久化(比如在数据库中保存下来),因此游戏服务器(『图六』中的设计,是由4个部署于QQ客户端的\"升级\"游戏前台逻辑执行服务,加上1个\"升级\"游戏后台逻辑执行服务,共同组成一个牌桌的\"升级\"游戏服务)在处理相关游戏执行请求时,将依赖于玩家游戏账户数据服务(『图六』中的所谓\"QQGame会话服务\"); 不过这种依赖是非循环的,即玩家游戏账户数据服务器的行为反过来并不依赖于游戏服务器。上文中曾提到,\"处理自治\"原则中非循环依赖是允许的。这里游戏服务器在处理游戏收盘请求时,要调用玩家游戏账户数据服务器来更新相关数据;因为不同玩家的游戏账户数据是相互独立的,此游戏服务器在调用游戏账户数据服务器时,逻辑上不受其它游戏服务器调用游戏账户数据服务器的影响,不存在同步等待问题;所以,游戏服务器在此能够达成负载均衡的意图。 点击图片可以放大 图**四 存在\"非循环依赖\"的QQ游戏客户端P2P服务与交互逻辑部署** 不过,在上述场景中,虽然不存在同步依赖,但是性**能依赖还是存在的,游戏账户数据服务器的处理性能不够时,会造成游戏服务器长时间等待。为此,我们可以应用分布式数据库表水平分割的技术,**将QQ玩家用户以其登记的行政区来加以分组,并部署于对应区域的数据库中(例如,深圳的玩家数据都在深圳的游戏账户数据库中)。 点击图片可以放大 图五 满足\"自闭包\"条件的QQ分布式数据库(集群)部署 实际上,我们由此还可以推论出一个数据库表水平分割的原则--任何数据库表水平分割的方式,必须确保同一数据库实例中的数据记录是\"自闭包\"的,即不同数据库实例中的数据记录相互间不存在循环依赖。 总之,初步满足QQGame之苛刻性能要求的分布式架构现在已经是初具雏形了,但仍然有很多涉及性能方面的细节问题有待解决。例如,Internet网络通讯延迟的问题、服务器之间协作产生的性能瓶颈问题等等。笔者将在下篇中继续深入探讨这些话题。 如需下载本站全部技术文章,可以在【高性能服务器开发】公众号回复关键字“文章下载”即可。最近更新时间: 2020-10-07 23:10:20 "},"articles/游戏开发专题/7QQ游戏百万人同时在线服务器架构实现.html":{"url":"articles/游戏开发专题/7QQ游戏百万人同时在线服务器架构实现.html","title":"7 QQ游戏百万人同时在线服务器架构实现","keywords":"","body":"7 QQ游戏百万人同时在线服务器架构实现 QQ游戏于前几日终于突破了百万人同时在线的关口,向着更为远大的目标迈进,这让其它众多传统的棋牌休闲游戏平台黯然失色,相比之下,联众似乎已经根本不是QQ的对手,因为QQ除了这100万的游戏在线人数外,它还拥有3亿多的注册量(当然很多是重复注册的)以及QQ聊天软件900万的同时在线率,我们已经可以预见未来由QQ构建起来的强大棋牌休闲游戏帝国。 服务器程序,其可承受的同时连接数目是有理论峰值的,在实际应用中,能达到一万人的同时连接并能保证正常的数据交换已经是很不容易了,通常这个值都在2000到5000之间,据说QQ的单台服务器同时连接数目也就是在这个值这间。 如果要实现2000到5000用户的单服务器同时在线,是不难的。在windows下,比较成熟的技术是采用IOCP---完成端口。只要运用得当,一个完成端口服务器是完全可以达到2K到5K的同时在线量的。但,5K这样的数值离百万这样的数值实在相差太大了,所以,百万人的同时在线是单台服务器肯定无法实现的。 要实现百万人同时在线,首先要实现一个比较完善的完成端口服务器模型,这个模型要求至少可以承载2K到5K的同时在线率(当然,如果你MONEY多,你也可以只开发出最多允许100人在线的服务器)。在构建好了基本的完成端口服务器之后,就是有关服务器组的架构设计了。之所以说这是一个服务器组,是因为它绝不仅仅只是一台服务器,也绝不仅仅是只有一种类型的服务器。 简单地说,实现百万人同时在线的服务器模型应该是:登陆服务器+大厅服务器+房间服务器。当然,也可以是其它的模型,但其基本的思想是一样的。下面,我将逐一介绍这三类服务器的各自作用。 / 1 / 登陆服务器:一般情况下,我们会向玩家开放若干个公开的登陆服务器,就如QQ登陆时让你选择的从哪个QQ游戏服务器登陆一样,QQ登陆时让玩家选择的六个服务器入口实际上就是登陆服务器。登陆服务器主要完成负载平衡的作用。详细点说就是,在登陆服务器的背后,有N个大厅服务器,登陆服务器只是用于为当前的客户端连接选择其下一步应该连接到哪个大厅服务器,当登陆服务器为当前的客户端连接选择了一个合适的大厅服务器后,客户端开始根据登陆服务器提供的信息连接到相应的大厅上去,同时客户端断开与登陆服务器的连接,为其他玩家客户端连接登陆服务器腾出套接字资源。 在设计登陆服务器时,至少应该有以下功能:N个大厅服务器的每一个大厅服务器都要与所有的登陆服务器保持连接,并实时地把本大厅服务器当前的同时在线人数通知给各个登陆服务器,这其中包括:用户进入时的同时在线人数增加信息以及用户退出时的同时在线人数减少信息。这里的各个大厅服务器同时在线人数信息就是登陆服务器为客户端选择某个大厅让其登陆的依据。举例来说,玩家A通过登陆服务器1连接到登陆服务器,登陆服务器开始为当前玩家在众多的大厅服务器中根据哪一个大厅服务器人数比较少来选择一个大厅,同时把这个大厅的连接IP和端口发给客户端,客户端收到这个IP和端口信息后,根据这个信息连接到此大厅,同时,客户端断开与登陆服务器之间的连接,这便是用户登陆过程中,在登陆服务器这一块的处理流程。 / 2 / 大厅服务器:是普通玩家看不到的服务器,它的连接IP和端口信息是登陆服务器通知给客户端的。也就是说,在QQ游戏的本地文件中,具体的大厅服务器连接IP和端口信息是没有保存的。大厅服务器的主要作用是向玩家发送游戏房间列表信息。 这些信息包括: 每个游戏房间的类型 名称 在线人数 连接地址以及其它如游戏帮助文件URL的信息 从**界面上看的话**,大厅服务器就是我们输入用户名和密码并校验通过后进入的游戏房间列表界面。 大厅服务器,主要有以下功能: 一是向当前玩家广播各个游戏房间在线人数信息; 二是提供游戏的版本以及下载地址信息; 三是提供各个游戏房间服务器的连接IP和端口信息; 四是提供游戏帮助的URL信息; 五是提供其它游戏辅助功能。 但在这众多的功能中,有一点是最为核心的,即:为玩家提供进入具体的游戏房间的通道,让玩家顺利进入其欲进入的游戏房间。玩家根据各个游戏房间在线人数,判定自己进入哪一个房间,然后双击服务器列表中的某个游戏房间后玩家开始进入游戏房间服务器。 / 3 / 游戏房间服务器:具体地说就是如“斗地主1”,“斗地主2”这样的游戏房间。游戏房间服务器才是具体的负责执行游戏相关逻辑的服务器。这样的游戏逻辑分为两大类: 第一类是通用的游戏房间逻辑,如:进入房间,离开房间,进入桌子,离开桌子以及在房间内说话等; 第二类是游戏桌子逻辑,这个就是各种不同类型游戏的主要区别之处了,比如斗地主中的叫地主或不叫地主的逻辑等,当然,游戏桌子逻辑里也包括有通用的各个游戏里都存在的游戏逻辑,比如在桌子内说话等。 总之,游戏房间服务器才是真正负责执行游戏具体逻辑的服务器。 这里提到的三类服务器,均采用的是完成端口模型,每个服务器最多连接数目是5000人,但是,我在游戏房间服务器上作了逻辑层的限定,最多只允许300人同时在线。其他两个服务器仍然允许最多5000人的同时在线。 如果按照这样的结构来设计,那么要实现百万人的同时在线就应该是这样: 首先是大厅,1000000/5000=200。也就是说,至少要200台大厅服务器,但通常情况下,考虑到实际使用时服务器的处理能力和负载情况,应该至少准备250台左右的大厅服务器程序。 另外,具体的各种类型的游戏房间服务器需要多少,就要根据当前玩各种类型游戏的玩家数目分别计算了,比如斗地主最多是十万人同时在线,每台服务器最多允许300人同时在线,那么需要的斗地主服务器数目就应该不少于:100000/300=333,准备得充分一点,就要准备350台斗地主服务器。 除正常的玩家连接外,还要考虑到:对于登陆服务器,会有250台大厅服务器连接到每个登陆服务器上,这是始终都要保持的连接; 而对于大厅服务器而言,如果仅仅有斗地主这一类的服务器,就要有350多个连接与各个大厅服务器始终保持着。所以从这一点看,结构在某些方面还存在着需要改进的地方,但核心思想是:尽快地提供用户登陆的速度,尽可能方便地让玩家进入游戏中。 如需下载本站全部技术文章,可以在【高性能服务器开发】公众号回复关键字“文章下载”即可。最近更新时间: 2020-09-13 20:35:35 "},"articles/游戏开发专题/8大型多人在线游戏服务器架构设计.html":{"url":"articles/游戏开发专题/8大型多人在线游戏服务器架构设计.html","title":"8 大型多人在线游戏服务器架构设计","keywords":"","body":"8 大型多人在线游戏服务器架构设计 由于大型多人在线游戏服务器理论上需要支持无限多的玩家,所以对服务器端是一个非常大的考验。服务器必须是安全的,可维护性高的,可伸缩性高的,可负载均衡的,支持高并发请求的。面对这些需求,我们在设计服务器的时候就需要慎重考虑,特别是架构的设计,如果前期设计不好,最后面临的很可能是重构。 一款游戏服务器的架构都是慢慢从小变大的,不可能一下子就上来一个完善的服务器构架,目前流行的说法是游戏先上线,再扩展。所以说我们在做架构的时候,一定要把底层的基础组件做好,方便以后扩展,但是刚开始的时候留出一些接口,并不实现它,将来游戏业务的发展,再慢慢扩展。当然,如果前期设计的不好,后期业务扩展了,但架构没办法扩展,只能加班加点搞了。 面对庞大的数据量我们想到的唯一个解决方案就是分而治之,即采用分布式的方式去解决它。把紧凑独立的功能单独拿出来做。分担到不同的物理服务器上面去运行。而且做到可以动态扩展。这就需要我们考虑好模块的划分,尽量要业务独立,关联性低。 前期,由于游戏需要尽快上线,开发周期短,我们需要把服务尽快的跑起来,这个时候的目标应该是尽快完成测试版本开发,单台服务器支持的人数可以稍微低一些,但是当人数暴涨时,我们可以能过多开几组服务来支持新增涨的用户量,即可以平衡扩展就可以了。到后期我们再把具体的模块单独拿出来支持,比如前期逻辑服务器上包括:活动,关卡,背包,技能,好友管理等。后期我们可以把好友,背包管理或其它的单独做一个服务进程,部署在不同的物理服务器上面。我们先按分区的服务进行设计,后面在部署的时候可以部署为世界服务器,下面是一个前期的架构图,下面我们从每个服务器的功能说起: 1,登陆管理服务 负责用户的登陆验证,如果有注册功能的话,也可以放在这里。一般手机游戏直接走sdk验证。网页游戏和客户端游戏会有注册功能,也可以叫用户管理服务。 1.1 用户登陆验证 负责接收客户端的用户登陆请求,验证账号的合法性,是否在黑名单(被封号的用户),是否在白名单(一般是测试账号,服务未开启时也可以进入)。如果是sdk登陆,此服务向第三方服务发起回调请求。 1.2 登陆安全加密 使用加密的传输协议,见通信协议部分。 1.3 是否在白名单内 白名单是给内部测试人员使用的,在服务器未开启的状态下,白名单的用户可以提前进入游戏进行游戏测试。 1.4 判断是否在黑名单 黑名单的用户是禁止登陆的,一般这是一些被封号的用户,拒绝登陆。 1.5 登陆验证 服务器使用私钥解密密码,进行验证,如果是sdk登陆,则直接向第三方服务发起回调。 1.6 登陆令牌(token)生成 当用户登陆验证成功之后,服务器端需要生成一个登陆令牌token,这个token具有时效性,当用户客户端拿到这个token之后,如果在一定时间内没有登陆游戏成功,那么这个token将失败,用户需要重新申请token,token存储在登陆服务这,向外提供用户是否已登陆的接口,其它服务器想验证如果是否登陆,就拿那个服务收到的token来此验证。 1.7 显示用户角色信息 当用户登陆成功之后,显示最近登陆的角色信息。 2,显示公告 用户登陆成功之后,请求公告服务器,获取最新的公告,公告服务先根据token和Userid验证用户是否已登陆,公告有可能根据渠道的不同,显示不同的公告。所以 公告一定是要可以根据渠道编辑的。 3,选区服务 当用户登陆成功之后,请求服务器分区列表服务器,显示当前所有的大区列表。 3.1 验证用户是否已登陆 向登陆服务器请求验证是否已登陆。 3.2 大区列表显示 大区列表信息中只显示大区id和大区名称。这样做是为了安全考虑,不一次性把大区对应的网关ip和端口暴露出来,也可以减少网络的传输量。 3.3 用户点击选择某个大区,客户端拿到大区id再向选区服务请求获取此大区对应的网关ip地址和端口。根据负载算法计算得出。 3.4 网关的选择 选区服务会维护一份网关的配置列表。一个大区对应一到多个网关,当配置有多个网关时,需要定时检测各个网关是否连接正常,如果发现有网关连接不上,需要把大区对应的网关信息设置为无效,不再参与网关的分配,并发出报警。 一般对于网关的选择,可以使用用户id求余法加虚网关节点法。这样在网关节点数量固定的情况下,一个用户总是会被分配到同一个网关上面。但是如果只是使用求余法的话,可能会造成用户分布不均衡,这里可以通过增加网关的虚拟节点(其它就是增加某个网关的权重,让用户多来一些到这个网关上面),这个可以参考哈稀一致性算法。包括后面说到的一个网关对应多个逻辑服务器,也可以使用同样的方法。这部分可以抽象出来一个模块使用。 3.5 选区服务对内要提供修改服务器状态的接口,比如维护中… 4,登陆网关 4.1 建立连接 收到客户端的建立连接请求之后,记录此channel和对应的连接建立时间。并设置如果在一定时间内未收到登陆请求,则断开连接。返回给客户端登陆超时。 4.2 登陆请求 收到登陆请求后,移除记录的channelid信息,向登陆服务器验证用户是否已登陆过,并向外广播用户角色登陆成功的消息。 4.3 登陆成功后,接收网关的其它的消息 4.4 客户端消息合法性验证 在向逻辑服务器转发消息之前验证消息的合法性,具体验证方法见协议安全验 证。 4.5 将客户端消息转发送到对应的逻辑服务器。 5 通信协议 5.1协议序列化和返回序列化 可以直接使用protobuf,直接对协议进行序列化和反序列化。 5.2协议组成 5.2.1 包头构成 包总长度,加密字符串长度,加密字符串内容,userId,playerId,版本号,内包内容。 5.2.2 包体组成 请求的逻辑信息,是protobuf后对应的二进制数据。 包总长度 加密内容 UserId playerId 请求序列id 版本号 内包内容 Int 64 Long Long Long int varchar 4 64 8 8 8 4 变长 5.3 协议内容加密 如果协议明文传输的话,被篡改的风险就非常大,所以我们要对传输协议进行加密传输,由于协议内容大小不固定,为了保证效率,采用对称加密算法,首先客户端使用AES的公钥对消息内容加密(上表中userid之后的信息),客户端把加密后的报文发送到服务器端。AES的公钥在用户第一次连接时获取。 5.4 协议完整性验证 尽管我们对消息做了加密,但也不是万无一失的,为了进一步确保消息没有被篡改,我们需要对消息的完整性进行检测,使用数字摘要的方式,首先客户端对userid及之后的协议信息进行AES加密,加密之后取它的md5值,md5值用于验证数据的完整性。这个md5值会被传送到服务器,如果协议信息被修改了,那个md5就会不同。 5.5 保证md5数字摘要的值的安全 为了防止非法用户修改协议内容后,模拟客户端操作重新生成新的数字摘要信息,我们对生成的数字摘要信息进行二次加密,这次使用RSA的公钥对md5的值进行加密,将加密的内容和其它信息一起发送到服务器。服务器根据ip向登陆服务器拿到AES的公钥和RSA的私钥,先用RSA 私钥取出客户端加密的md5值,服务器端计算userid后面的数据的md5值,如果两个md5值一样,说明安全的。如果不一样,说明用户是非法的,加入黑名单。因为RAS使用公钥加密,必须使用对应的私钥才能解密,而且不同的公钥对应的私钥不同,这样就算非法用户重新生成了数字摘要,在服务器端也是验证不通过的。 5.6 取出明文信息 当服务器收到报文后,对报文进行数子摘要验证通过之后,服务器端使用用户自己对应的AES的公钥,解密数据,获得明文数据。为了保证安全,每个用户的AES公钥可能不一样。 6 发布订阅服务 发布订阅是一种分布式的解耦方式,它使用模块更加独立,模块间的数据交互更加方便,发布订阅模式是一种一对多的关系,发布方不关心谁订阅了它,只要想获得它发布的消息的服务,都可以去订阅它。发布方式是异步的,它增强了系统的处理性能,增加了系统的吞吐量。目前的大多数消息队列都支持发布订阅模式,比如rabbitmq,activemq,kafka等消息队列。发布订阅服务可以单独部署,增强了系统的扩展性和稳定性。 7,RPC调用 在服务器内部不同的服务有时候需要信息交互。为了方便服务之间的调用,我们引入了RPC的概念。客户端调用一个api之后,底层会把此调用发送到远程的服务上处理,远程服务处理完之后再返回结果。rpc的作用就是封装底层协议的序列化和反序列化,它让用户感觉不到调用被发送到了远程服务,而感觉还是在本地一样 7.1 同步rpc 当调用一个同步的rpc之后,结果并不是立刻返回,而是在等待rpc服务器端的返回。同步rpc可以直接使用带同步的socket实现。或者http请求。另一种方式是调用rpc方法之后,在本地自旋,直到服务端返回。 7.2 异步rpc 异步rpc调用之后,结果是立刻返回的,它的处理方式是把业务放在回调方法里面,而不是一直占用线程在那里等待数据的返回,这样就可以记空闲的线程去处理另外的消息,当消息从服务器端返回后,会去调用那个回调方法。 8,合服要提前设计好 现在大多数的游戏都是分区分服的,经过一段时间的运营之后,有些老的大区可能在线人数非常的少了,为了节约成本,首先会在一台物理机器上运行多个大区对应的进程,再过一段时间,可能需要把不同区的数据合并起来到一个数据库中。而对用户来说是感觉不到变化的。 为什么说合服要提交设计好呢?因为如果设计不好,后期在合服的时候会遇到很多问题, 比如用户唯一主键问题,表与表主键关联重复问题,那么在合服存在的情况下,如何保证用户的唯一性呢,也就是我一个用户在两个大区都建立了账号,这个时候userid是一样的,还有一个角色id,如果角色id不是全局唯一的,也可能重复。而角色id如果参与了表外键设计,一重复数据就乱了。 首先,要保证用户的唯一性。而且各个表的外键引用也必须是唯一的,即合服之后不会再发生改变。那么有几个键需要全局唯一,userid(用户id),roleId(角色id),为了区分用户原来所在的区,需要记录角色所在的大区id,所以一个userid和一个大区id来确定一个唯一的角色id,而角色的其它信息使用角色id做外键引用。这样合服就可以直接把两个库的数据合并到一起了。 这个只是用角色数据举个例子,在数据库中,凡是独立存在的,最好都使用全局唯一id,比如公会,每个服都会有公会,但每个服的公会id不能都是从一开始,即不能使用数据库自增的方式。 如需下载本站全部技术文章,可以在【高性能服务器开发】公众号回复关键字“文章下载”即可。最近更新时间: 2020-10-07 23:11:42 "},"articles/游戏开发专题/9百万用户级游戏服务器架构设计.html":{"url":"articles/游戏开发专题/9百万用户级游戏服务器架构设计.html","title":"9 百万用户级游戏服务器架构设计","keywords":"","body":"9 百万用户级游戏服务器架构设计 服务器结构探讨 -- 最简单的结构 所谓服务器结构,也就是如何将服务器各部分合理地安排,以实现最初的功能需求。所以,结构本无所谓正确与错误;当然,优秀的结构更有助于系统的搭建,对系统的可扩展性及可维护性也有更大的帮助。 好的结构不是一蹴而就的,而且每个设计者心中的那把尺都不相同,所以这个优秀结构的定义也就没有定论。在这里,我们不打算对现有游戏结构做评价,而是试着从头开始搭建一个我们需要的MMOG结构。 对于一个最简单的游戏服务器来说,它只需要能够接受来自客户端的连接请求,然后处理客户端在游戏世界中的移动及交互,也即游戏逻辑处理即可。如果我们把这两项功能集成到一个服务进程中,则最终的结构很简单: 嗯,太简单了点,这样也敢叫服务器结构?好吧,现在我们来往里面稍稍加点东西,让它看起来更像是服务器结构一些。 一般来说,我们在接入游戏服务器的时候都会要提供一个帐号和密码,验证通过后才能进入。关于为什么要提供用户名和密码才能进入的问题我们这里不打算做过多讨论,云风曾对此也提出过类似的疑问,并给出了只用一个标识串就能进入的设想,有兴趣的可以去看看他们的讨论。但不管是采用何种方式进入,照目前看来我们的服务器起码得提供一个帐号验证的功能。 我们把观察点先集中在一个大区内。在大多数情况下,一个大区内都会有多组游戏服,也就是多个游戏世界可供选择。简单点来实现,我们完全可以抛弃这个大区的概念,认为一个大区也就是放在同一个机房的多台服务器组,各服务器组间没有什么关系。这样,我们可为每组服务器单独配备一台登录服。最后的结构图应该像这样: 该结构下的玩家操作流程为,先选择大区,再选择大区下的某台服务器,即某个游戏世界,点击进入后开始帐号验证过程,验证成功则进入了该游戏世界。但是,如果玩家想要切换游戏世界,他只能先退出当前游戏世界,然后进入新的游戏世界重新进行帐号验证。 早期的游戏大都采用的是这种结构,有些游戏在实现时采用了一些技术手段使得在切换游戏服时不需要再次验证帐号,但整体结构还是未做改变。 该结构存在一个服务器资源配置的问题。因为登录服处理的逻辑相对来说比较简单,就是将玩家提交的帐号和密码送到数据库进行验证,和生成会话密钥发送给游戏服和客户端,操作完成后连接就会立即断开,而且玩家在以后的游戏过程中不会再与登录服打任何交道。这样处理短连接的过程使得系统在大多数情况下都是比较空闲的,但是在某些时候,由于请求比较密集,比如开新服的时候,登录服的负载又会比较大,甚至会处理不过来。 另外在实际的游戏运营中,有些游戏世界很火爆,而有些游戏世界却非常冷清,甚至没有多少人玩的情况也是很常见的。所以,我们能否更合理地配置登录服资源,使得整个大区内的登录服可以共享就成了下一步改进的目标。 服务器结构探讨 -- 登录服的负载均衡 回想一下我们在玩wow时的操作流程:运行wow.exe进入游戏后,首先就会要求我们输入用户名和密码进行验证,验证成功后才会出来游戏世界列表,之后是排队进入游戏世界,开始游戏… 可以看到跟前面的描述有个很明显的不同,那就是要先验证帐号再选择游戏世界。这种结构也就使得登录服不是固定配备给个游戏世界,而是全区共有的。 我们可以试着从实际需求的角度来考虑一下这个问题。正如我们之前所描述过的那样,登录服在大多数情况下都是比较空闲的,也许我们的一个拥有20个游戏世界的大区仅仅使用10台或更少的登录服即可满足需求。而当在开新区的时候,或许要配备40台登录服才能应付那如潮水般涌入的玩家登录请求。所以,登录服在设计上应该能满足这种动态增删的需求,我们可以在任何时候为大区增加或减少登录服的部署。 当然,在这里也不会存在要求添加太多登录服的情况。还是拿开新区的情况来说,即使新增加登录服满足了玩家登录的请求,游戏世界服的承载能力依然有限,玩家一样只能在排队系统中等待,或者是进入到游戏世界中导致大家都卡。 另外,当我们在增加或移除登录服的时候不应该需要对游戏世界服有所改动,也不会要求重启世界服,当然也不应该要求客户端有什么更新或者修改,一切都是在背后自动完成。 最后,有关数据持久化的问题也在这里考虑一下。一般来说,使用现有的商业数据库系统比自己手工技术先进要明智得多。我们需要持久化的数据有玩家的帐号及密码,玩家创建的角色相关信息,另外还有一些游戏世界全局共有数据也需要持久化。 好了,需求已经提出来了,现在来考虑如何将其实现。 对于负载均衡来说,已有了成熟的解决方案。一般最常用,也最简单部署的应该是基于DNS的负载均衡系统了,其通过在DNS中为一个域名配置多个IP地址来实现。最新的DNS服务已实现了根据服务器系统状态来实现的动态负载均衡,也就是实现了真正意义上的负载均衡,这样也就有效地解决了当某台登录服当机后,DNS服务器不能立即做出反应的问题。当然,如果找不到这样的解决方案,自己从头打造一个也并不难。而且,通过DNS来实现的负载均衡已经包含了所做的修改对登录服及客户端的透明。 而对于数据库的应用,在这种结构下,登录服及游戏世界服都会需要连接数据库。从数据库服务器的部署上来说,可以将帐号和角色数据都放在一个中心数据库中,也可分为两个不同的库分别来处理,基到从物理上分到两台不同的服务器上去也行。 但是对于不同的游戏世界来说,其角色及游戏内数据都是互相独立的,所以一般情况下也就为每个游戏世界单独配备一台数据库服务器,以减轻数据库的压力。所以,整体的服务器结构应该是一个大区有一台帐号数据库服务器,所有的登录服都连接到这里。而每个游戏世界都有自己的游戏数据库服务器,只允许本游戏世界内的服务器连接。 最后,我们的服务器结构就像这样: 这里既然讨论到了大区及帐号数据库,所以顺带也说一下关于激活大区的概念。wow中一共有八个大区,我们想要进入某个大区游戏之前,必须到官网上激活这个区,这是为什么呢? 一般来说,在各个大区帐号数据库之上还有一个总的帐号数据库,我们可以称它为中心数据库。比如我们在官网上注册了一个帐号,这时帐号数据是只保存在中心数据库上的。而当我们要到一区去创建角色开始游戏的时候,在一区的帐号数据库中并没有我们的帐号数据,所以,我们必须先到官网上做一次激活操作。这个激活的过程也就是从中心库上把我们的帐号数据拷贝到所要到的大区帐号数据库中。 服务器结构探讨 -- 简单的世界服实现 讨论了这么久我们一直都还没有进入游戏世界服务器内部,现在就让我们来窥探一下里面的结构吧。 对于现在大多数MMORPG来说,游戏服务器要处理的基本逻辑有移动、聊天、技能、物品、任务和生物等,另外还有地图管理与消息广播来对其他高级功能做支撑。如纵队、好友、公会、战场和副本等,这些都是通过基本逻辑功能组合或扩展而成。 在所有这些基础逻辑中,与我们要讨论的服务器结构关系最紧密的当属地图管理方式。决定了地图的管理方式也就决定了我们的服务器结构,我们仍然先从最简单的实现方式开始说起。 回想一下我们曾战斗过无数个夜晚的暗黑破坏神,整个暗黑的世界被分为了若干个独立的小地图,当我们在地图间穿越时,一般都要经过一个叫做传送门的装置。世界中有些地图间虽然在地理上是直接相连的,但我们发现其游戏内部的逻辑却是完全隔离的。可以这样认为,一块地图就是一个独立的数据处理单元。 既然如此,我们就把每块地图都当作是一台独立的服务器,他提供了在这块地图上游戏时的所有逻辑功能,至于内部结构如何划分我们暂不理会,先把他当作一个黑盒子吧。 当两个人合作做一件事时,我们可以以对等的关系相互协商着来做,而且一般也都不会有什么问题。当人数增加到三个时,我们对等的合作关系可能会有些复杂,因为我们每个人都同时要与另两个人合作协商。正如俗语所说的那样,三个和尚可能会碰到没水喝的情况。当人数继续增加,情况就变得不那么简单了,我们得需要一个管理者来对我们的工作进行分工、协调。游戏的地图服务器之间也是这么回事。 一般来说,我们的游戏世界不可能会只有一块或者两块小地图,那顺理成章的,也就需要一个地图管理者。先称它为游戏世界的中心服务器吧,毕竟是管理者嘛,大家都以它为中心。 中心服务器主要维护一张地图ID到地图服务器地址的映射表。当我们要进入某张地图时,会从中心服上取得该地图的IP和port告诉客户端,客户端主动去连接,这样进入他想要去的游戏地图。在整个游戏过程中,客户端始终只会与一台地图服务器保持连接,当要切换地图的时候,在获取到新地图的地址后,会先与当前地图断开连接,再进入新的地图,这样保证玩家数据在服务器上只有一份。 我们来看看结构图是怎样的: 很简单,不是吗。但是简单并不表示功能上会有什么损失,简单也更不能表示游戏不能赚钱。早期不少游戏也确实采用的就是这种简单结构。 服务器结构探讨 -- 继续世界服 都已经看出来了,这种每切换一次地图就要重新连接服务器的方式实在是不够优雅,而且在实际游戏运营中也发现,地图切换导致的卡号,复制装备等问题非常多,这里完全就是一个事故多发地段,如何避免这种频繁的连接操作呢? 最直接的方法就是把那个图倒转过来就行了。客户端只需要连接到中心服上,所有到地图服务器的数据都由中心服来转发。很完美的解决方案,不是吗? 这种结构在实际的部署中也遇到了一些挑战。对于一般的MMORPG服务器来说,单台服务器的承载量平均在2000左右,如果你的服务器很不幸地只能带1000人,没关系,不少游戏都是如此;如果你的服务器上跑了3000多玩家依然比较流畅,那你可以自豪地告诉你的策划,多设计些大量消耗服务器资源的玩法吧,比如大型国战、公会战争等。 2000人,似乎我们的策划朋友们不大愿意接受这个数字。我们将地图服务器分开来原来也是想将负载分开,以多带些客户端,现在要所有的连接都从中心服上转发,那连接数又遇到单台服务器的可最大承载量的瓶颈了。 这里有必要再解释下这个数字。我知道,有人一定会说,才带2000人,那是你水平不行,我随便写个TCP服务器都可带个五六千连接。问题恰恰在于你是随便写的,而MMORPG的服务器是复杂设计的。如果一个演示socket API用的echo服务器就能满足MMOG服务器的需求,那写服务器该是件多么惬意的事啊。 但我们所遇到的事实是,服务器收到一个移动包后,要向周围所有人广播,而不是echo服务器那样简单的回应;服务器在收到一个连接断开通知时要向很多人通知玩家退出事件,并将该玩家的资料写入数据库,而不是echo服务器那样什么都不需要做;服务器在收到一个物品使用请求包后要做一系列的逻辑判断以检查玩家有没有作弊;服务器上还启动着很多定时器用来更新游戏世界的各种状态…… 其实这么一比较,我们也看出资源消耗的所在了:服务器上大量的复杂的逻辑处理。再回过头来看看我们想要实现的结构,我们既想要有一个唯一的入口,使得客户端不用频繁改变连接,又希望这个唯一入口的负载不会太大,以致于接受不了多少连接。 仔细看一看这个需求,我们想要的仅仅只是一台管理连接的服务器,并不打算让他承担太多的游戏逻辑。既然如此,那五六千个连接也还有满足我们的要求。至少在现在来说,一个游戏世界内,也就是一组服务器内同时有五六千个在线的玩家还是件让人很兴奋的事。事实上,在大多数游戏的大部分时间里,这个数字也是很让人眼红的。 什么?你说梦幻、魔兽还有史先生的那个什么征途远不止这么点人了!噢,我说的是大多数,是大多数,不包括那些明星。你知道大陆现在有多少游戏在运营吗?或许你又该说,我们不该在一开始就把自己的目标定的太低!好吧,我们还是先不谈这个。 继续我们的结构讨论。一般来说,我们把这台负责连接管理的服务器称为网关服务器,因为内部的数据都要通过这个网关才能出去,不过从这台服务器提供的功能来看,称其为反向代理服务器可能更合适。我们也不在这个名字上纠缠了,就按大家通用的叫法,还是称他为网关服务器吧。 网关之后的结构我们依然可以采用之前描述的方案,只是,似乎并没有必要为每一个地图都开一个独立的监听端口了。我们可以试着对地图进行一些划分,由一个Master Server来管理一些更小的Zone Server,玩家通过网关连接到Master Server上,而实际与地图有关的逻辑是分派给更小的Zone Server去处理。 最后的结构看起来大概是这样的: 服务器结构探讨 -- 最终的结构 如果我们就此打住,可能马上就会有人要嗤之以鼻了,就这点古董级的技术也敢出来现。好吧,我们还是把之前留下的问题拿出来解决掉吧。 一般来说,当某一部分能力达不到我们的要求时,最简单的解决方法就是在此多投入一点资源。既然想要更多的连接数,那就再加一台网关服务器吧。新增加了网关服后需要在大区服上做相应的支持,或者再简单点,有一台主要的网关服,当其负载较高时,主动将新到达的连接重定向到其他网关服上。 而对于游戏服来说,有一台还是多台网关服是没有什么区别的。每个代表客户端玩家的对象内部都保留一个代表其连接的对象,消息广播时要求每个玩家对象使用自己的连接对象发送数据即可,至于连接是在什么地方,那是完全透明的。当然,这只是一种简单的实现,也是普通使用的一种方案,如果后期想对消息广播做一些优化的话,那可能才需要多考虑一下。 既然说到了优化,我们也稍稍考虑一下现在结构下可能采用的优化方案。 首先是当前的Zone Server要做的事情太多了,以至于他都处理不了多少连接。这其中最消耗系统资源的当属生物的AI处理了,尤其是那些复杂的寻路算法,所以我们可以考虑把这部分AI逻辑独立出来,由一台单独的AI服务器来承担。 然后,我们可以试着把一些与地图数据无关的公共逻辑放到Master Server上去实现,这样Zone Server上只保留了与地图数据紧密相关的逻辑,如生物管理,玩家移动和状态更新等。 还有聊天处理逻辑,这部分与游戏逻辑没有任何关联,我们也完全可以将其独立出来,放到一台单独的聊天服务器上去实现。 最后是数据库了,为了减轻数据库的压力,提高数据请求的响应速度,我们可以在数据库之前建立一个数据库缓存服务器,将一些常用数据缓存在此,服务器与数据库的通信都要通过这台服务器进行代理。缓存的数据会定时的写入到后台数据库中。 好了,做完这些优化我们的服务器结构大体也就定的差不多了,暂且也不再继续深入,更细化的内容等到各个部分实现的时候再探讨。 好比我们去看一场晚会, 舞台上演员们按着预定的节目单有序地上演着,但这就是整场晚会的全部吗?显然不止,在幕后还有太多太多的人在忙碌着,甚至在晚会前和晚会后都有。我们的游戏服务器也如此。 在之前描述的部分就如同舞台上的演员,是我们能直接看到的,幕后的工作人员我们也来认识一下。 现实中有警察来维护秩序,游戏中也如此,这就是我们常说的GM。GM可以采用跟普通玩家一样的拉入方式来进入游戏,当然权限会比普通玩家高一些,也可以提供一台GM服务器专门用来处理GM命令,这样可以有更高的安全性,GM服一般接在中心服务器上。 在以时间收费的游戏中,我们还需要一台计费的服务器,这台服务器一般接在网关服务器上,注册玩家登录和退出事件以记录玩家的游戏时间。 任何为用户提供服务的地方都会有日志记录,游戏服务器当然也不例外。从记录玩家登录的时间,地址,机器信息到游戏过程中的每一项操作都可以作为日志记录下来,以备查错及数据挖掘用。至于搜集玩家机器资料所涉及到的法律问题不是我们该考虑的。 差不多就这么多了吧,接下来我们会按照这个大致的结构来详细讨论各部分的实现。 服务器结构探讨 —— 一点杂谈 再强调一下,服务器结构本无所谓好坏,只有是否适合自己。我们在前面探讨了一些在现在的游戏中见到过的结构,并尽我所知地分析了各自存在的一些问题和可以做的一些改进,希望其中没有谬误,如果能给大家也带来些启发那自然更好。 突然发现自己一旦罗嗦起来还真是没完没了。接下来先说说我在开发中遇到过的一些困惑和一基础问题探讨吧,这些问题可能有人与我一样,也曾遇到过,或者正在被困扰中,而所要探讨的这些基础问题向来也是争论比较多的,我们也不评价其中的好与坏,只做简单的描述。 首先是服务器操作系统,linux与windows之争随处可见,其实在大多数情况下这不是我们所能决定的,似乎各大公司也基本都有了自己的传统,如网易的freebsd,腾讯的linux等。如果真有权利去选择的话,选自己最熟悉的吧。 决定了OS也就基本上确定了网络IO模型,windows上的IOCP和linux下的epool,或者直接使用现有的网络框架,如ACE和asio等,其他还有些商业的网络库在国内的使用好像没有见到,不符合中国国情嘛。:) 然后是网络协议的选择,以前的选择大多倾向于UDP,为了可靠传输一般自己都会在上面实现一层封装,而现在更普通的是直接采用本身就很可靠的TCP,或者TCP与UDP的混用。早期选择UDP的主要原因还是带宽限制,现在宽带普通的情况下TCP比UDP多出来的一点点开销与开发的便利性相比已经不算什么了。当然,如果已有了成熟的可靠UDP库,那也可以继续使用着。 还有消息包格式的定义,这个曾在云风的blog上展开过激烈的争论。消息包格式定义包括三段,包长、消息码和包体,争论的焦点在于应该是消息码在前还是包长在前,我们也把这个当作是信仰问题吧,有兴趣的去云风的blog上看看,论论。 另外早期有些游戏的包格式定义是以特殊字符作分隔的,这样一个好处是其中某个包出现错误后我们的游戏还能继续。但实际上,我觉得这是完全没有必要的,真要出现这样的错误,直接断开这个客户端的连接可能更安全。而且,以特殊字符做分隔的消息包定义还加大了一点点网络数据量。 最后是一个纯技术问题,有关socket连接数的最大限制。开始学习网络编程的时候我犯过这样的错误,以为port的定义为unsigned short,所以想当然的认为服务器的最大连接数为65535,这会是一个硬性的限制。而实际上,一个socket描述符在windows上的定义是unsigned int,因此要有限制那也是四十多亿,放心好了。 在服务器上port是监听用的,想象这样一种情况,web server在80端口上监听,当一个连接到来时,系统会为这个连接分配一个socket句柄,同时与其在80端口上进行通讯;当另一个连接到来时,服务器仍然在80端口与之通信,只是分配的socket句柄不一样。这个socket句柄才是描述每个连接的唯一标识。按windows网络编程第二版上的说法,这个上限值配置影响。 好了,废话说完了,我们开始进入登录服的设计吧。 登录服的设计 -- 功能需求 正如我们在前面曾讨论过的,登录服要实现的功能相当简单,就是帐号验证。为了便于描述,我们暂不引入那些讨论过的优化手段,先以最简单的方式实现,另外也将基本以mangos的代码作为参考来进行描述。 想象一下帐号验证的实现方法,最容易的那就是把用户输入的明文用帐号和密码直接发给登录服,服务器根据帐号从数据库中取出密码,与用户输入的密码相比较。 这个方法存在的安全隐患实在太大,明文的密码传输太容易被截获了。那我们试着在传输之前先加一下密,为了服务器能进行密码比较,我们应该采用一个可逆的加密算法,在服务器端把这个加密后的字串还原为原始的明文密码,然后与数据库密码进行比较。既然是一个可逆的过程,那外挂制作者总有办法知道我们的加密过程,所以,这个方法仍不够安全。 哦,如果我们只是希望密码不可能被还原出来,那还不容易吗,使用一个不可逆的散列算法就行了。用户在登录时发送给服务器的是明文的帐号和经散列后的不可逆密码串,服务器取出密码后也用同样的算法进行散列后再进行比较。比如,我们就用使用最广泛的md5算法吧。噢,不要管那个王小云的什么论文,如果我真有那么好的运气,早中500w了,还用在这考虑该死的服务器设计吗? 似乎是一个很完美的方案,外挂制作者再也偷不到我们的密码了。慢着,外挂偷密码的目的是什么?是为了能用我们的帐号进游戏!如果我们总是用一种固定的算法来对密码做散列,那外挂只需要记住这个散列后的字串就行了,用这个做密码就可以成功登录。 嗯,这个问题好解决,我们不要用固定的算法进行散列就是了。只是,问题在于服务器与客户端采用的散列算法得出的字串必须是相同的,或者是可验证其是否匹配的。很幸运的是,伟大的数学字们早就为我们准备好了很多优秀的这类算法,而且经理论和实践都证明他们也确实是足够安全的。 这其中之一是一个叫做SRP的算法,全称叫做Secure Remote Password,即安全远程密码。wow使用的是第6版,也就是SRP6算法。有关其中的数学证明,如果有人能向我解释清楚,并能让我真正弄明白的话,我将非常感激。不过其代码实现步骤倒是并不复杂,mangos中的代码也还算清晰,我们也不再赘述。 登录服除了帐号验证外还得提供另一项功能,就是在玩家的帐号验证成功后返回给他一个服务器列表让他去选择。这个列表的状态要定时刷新,可能有新的游戏世界开放了,也可能有些游戏世界非常不幸地停止运转了,这些状态的变化都要尽可能及时地让玩家知道。不管发生了什么事,用户都有权利知道,特别是对于付过费的用户来说,我们不该藏着掖着,不是吗? 这个游戏世界列表的功能将由大区服来提供,具体的结构我们在之前也描述过,这里暂不做讨论。登录服将从大区服上获取到的游戏世界列表发给已验证通过的客户端即可。好了,登录服要实现的功能就这些,很简单,是吧。 确实是太简单了,不过简单的结构正好更适合我们来看一看游戏服务器内部的模块结构,以及一些服务器共有组件的实现方法。这就留作下一篇吧。 服务器公共组件实现 -- mangos的游戏主循环 当阅读一项工程的源码时,我们大概会选择从main函数开始,而当开始一项新的工程时,第一个写下的函数大多也是main。那我们就先来看看,游戏服务器代码实现中,main函数都做了些什么。 由于我在读技术文章时最不喜看到的就是大段大段的代码,特别是那些直接Ctrl+C再Ctrl+V后未做任何修改的代码,用句时髦的话说,一点技术含量都没有!所以在我们今后所要讨论的内容中,尽量会避免出现直接的代码,在有些地方确实需要代码来表述时,也将会选择使用伪码。 先从mangos的登录服代码开始。mangos的登录服是一个单线程的结构,虽然在数据库连接中可以开启一个独立的线程,但这个线程也只是对无返回结果的执行类SQL做缓冲,而对需要有返回结果的查询类SQL还是在主逻辑线程中阻塞调用的。 登录服中唯一的这一个线程,也就是主循环线程对监听的socket做select操作,为每个连接进来的客户端读取其上的数据并立即进行处理,直到服务器收到SIGABRT或SIGBREAK信号时结束。 所以,mangos登录服主循环的逻辑,也包括后面游戏服的逻辑,主循环的关键代码其实是在SocketHandler中,也就是那个Select函数中。检查所有的连接,对新到来的连接调用OnAccept方法,有数据到来的连接则调用OnRead方法,然后socket处理器自己定义对接收到的数据如何处理。 很简单的结构,也比较容易理解。 只是,在对性能要求比较高的服务器上,select一般不会是最好的选择。如果我们使用windows平台,那IOCP将是首选;如果是linux,epool将是不二选择。我们也不打算讨论基于IOCP或是基于epool的服务器实现,如果仅仅只是要实现服务器功能,很简单的几个API调用即可,而且网上已有很多好的教程;如果是要做一个成熟的网络服务器产品,不是我几篇简单的技术介绍文章所能达到。 另外,在服务器实现上,网络IO与逻辑处理一般会放在不同的线程中,以免耗时较长的IO过程阻塞住了需要立即反应的游戏逻辑。 数据库的处理也类似,会使用异步的方式,也是避免耗时的查询过程将游戏服务器主循环阻塞住。想象一下,因某个玩家上线而发起的一次数据库查询操作导致服务器内所有在线玩家都卡住不动将是多么恐怖的一件事! 另外还有一些如事件、脚本、消息队列、状态机、日志和异常处理等公共组件,我们也会在接下来的时间里进行探讨。 服务器公共组件实现 -- 继续来说主循环 前面我们只简单了解了下mangos登录服的程序结构,也发现了一些不足之处,现在我们就来看看如何提供一个更好的方案。 正如我们曾讨论过的,为了游戏主逻辑循环的流畅运行,所有比较耗时的IO操作都会分享到单独的线程中去做,如网络IO,数据库IO和日志IO等。当然,也有把这些分享到单独的进程中去做的。 另外对于大多数服务器程序来说,在运行时都是作为精灵进程或服务进程的,所以我们并不需要服务器能够处理控制台用户输入,我们所要处理的数据来源都来自网络。 这样,主逻辑循环所要做的就是不停要取消息包来处理,当然这些消息包不仅有来自客户端的玩家操作数据包,也有来自GM服务器的管理命令,还包括来自数据库查询线程的返回结果消息包。这个循环将一直持续,直到收到一个通知服务器关闭的消息包。 主逻辑循环的结构还是很简单的,复杂的部分都在如何处理这些消息包的逻辑上。我们可以用一段简单的伪码来描述这个循环过程: while (Message* msg = getMessage()) { if (msg为服务器关闭消息)    break;      处理msg消息; } 这里就有一个问题需要探讨了,在getMessage()的时候,我们应该去哪里取消息?前面我们考虑过,至少会有三个消息来源,而我们还讨论过,这些消息源的IO操作都是在独立的线程中进行的,我们这里的主线程不应该直接去那几处消息源进行阻塞式的IO操作。 很简单,让那些独立的IO线程在接收完数据后自己送过来就是了。好比是,我这里提供了一个仓库,有很多的供货商,他们有货要给我的时候只需要交到仓库,然后我再到仓库去取就是了,这个仓库也就是消息队列。消息队列是一个普通的队列实现,当然必须要提供多线程互斥访问的安全性支持,其基本的接口定义大概类似这样: IMessageQueue { void putMessage(Message*); Message* getMessage(); } 网络IO,数据库IO线程把整理好的消息包都加入到主逻辑循环线程的这个消息队列中便返回。有关消息队列的实现和线程间消息的传递在ACE中有比较完全的代码实现及描述,还有一些使用示例,是个很好的参考。 这样的话,我们的主循环就很清晰了,从主线程的消息队列中取消息,处理消息,再取下一条消息…… 服务器公共组件实现 -- 消息队列 既然说到了消息队列,那我们继续来稍微多聊一点吧。 我们所能想到的最简单的消息队列可能就是使用stl的**list**来实现了,即消息队列内部维护一个list和一个互斥锁,putMessage时将message加入到队列尾,getMessage时从队列头取一个message返回,同时在getMessage和putMessage之前都要求先获取锁资源。 实现虽然简单,但功能是绝对满足需求的,只是性能上可能稍稍有些不尽如人意。其最大的问题在频繁的锁竞争上。 对于如何减少锁竞争次数的优化方案,Ghost Cheng提出了一种。提供一个队列容器,里面有多个队列,每个队列都可固定存放一定数量的消息。网络IO线程要给逻辑线程投递消息时,会从队列容器中取一个空队列来使用,直到将该队列填满后再放回容器中换另一个空队列。而逻辑线程取消息时是从队列容器中取一个有消息的队列来读取,处理完后清空队列再放回到容器中。 这样便使得只有在对队列容器进行操作时才需要加锁,而IO线程和逻辑线程在操作自己当前使用的队列时都不需要加锁,所以锁竞争的机会大大减少了。 这里为每个队列设了个最大消息数,看来好像是打算只有当IO线程写满队列时才会将其放回到容器中换另一个队列。那这样有时也会出现IO线程未写满一个队列,而逻辑线程又没有数据可处理的情况,特别是当数据量很少时可能会很容易出现。Ghost Cheng在他的描述中没有讲到如何解决这种问题,但我们可以先来看看另一个方案。 这个方案与上一个方案基本类似,只是不再提供队列容器,因为在这个方案中只使用了两个队列,arthur在他的一封邮件中描述了这个方案的实现及部分代码。两个队列,一个给逻辑线程读,一个给IO线程用来写,当逻辑线程读完队列后会将自己的队列与IO线程的队列相调换。所以,这种方案下加锁的次数会比较多一些,IO线程每次写队列时都要加锁,逻辑线程在调换队列时也需要加锁,但逻辑线程在读队列时是不需要加锁的。 虽然看起来锁的调用次数是比前一种方案要多很多,但实际上大部分锁调用都是不会引起阻塞的,只有在逻辑线程调换队列的那一瞬间可能会使得某个线程阻塞一下。另外对于锁调用过程本身来说,其开销是完全可以忽略的,我们所不能忍受的仅仅是因为锁调用而引起的阻塞而已。 两种方案都是很优秀的优化方案,但也都是有其适用范围的。Ghost Cheng的方案因为提供了多个队列,可以使得多个IO线程可以总工程师的,互不干扰的使用自己的队列,只是还有一个遗留问题我们还不了解其解决方法。arthur的方案很好的解决了上一个方案遗留的问题,但因为只有一个写队列,所以当想要提供多个IO线程时,线程间互斥地写入数据可能会增大竞争的机会,当然,如果只有一个IO线程那将是非常完美的。 服务器公共组件实现 -- 环形缓冲区 消息队列锁调用太频繁的问题算是解决了,另一个让人有些苦恼的大概是这太多的内存分配和释放操作了。频繁的内存分配不但增加了系统开销,更使得内存碎片不断增多,非常不利于我们的服务器长期稳定运行。也许我们可以使用内存池,比如SGI STL中附带的小内存分配器。但是对于这种按照严格的先进先出顺序处理的,块大小并不算小的,而且块大小也并不统一的内存分配情况来说,更多使用的是一种叫做环形缓冲区的方案,mangos的网络代码中也有这么一个东西,其原理也是比较简单的。 就好比两个人围着一张圆形的桌子在追逐,跑的人被网络IO线程所控制,当写入数据时,这个人就往前跑;追的人就是逻辑线程,会一直往前追直到追上跑的人。如果追上了怎么办?那就是没有数据可读了,先等会儿呗,等跑的人向前跑几步了再追,总不能让游戏没得玩了吧。那要是追的人跑的太慢,跑的人转了一圈过来反追上追的人了呢?那您也先歇会儿吧。要是一直这么反着追,估计您就只能换一个跑的更快的追逐者了,要不这游戏还真没法玩下去。 前面我们特别强调了,按照严格的先进先出顺序进行处理,这是环形缓冲区的使用必须遵守的一项要求。也就是,大家都得遵守规定,追的人不能从桌子上跨过去,跑的人当然也不允许反过来跑。至于为什么,不需要多做解释了吧。 环形缓冲区是一项很好的技术,不用频繁的分配内存,而且在大多数情况下,内存的反复使用也使得我们能用更少的内存块做更多的事。 在网络IO线程中,我们会为每一个连接都准备一个环形缓冲区,用于临时存放接收到的数据,以应付半包及粘包的情况。在解包及解密完成后,我们会将这个数据包复制到逻辑线程消息队列中,如果我们只使用一个队列,那这里也将会是个环形缓冲区,IO线程往里写,逻辑线程在后面读,互相追逐。可要是我们使用了前面介绍的优化方案后,可能这里便不再需要环形缓冲区了,至少我们并不再需要他们是环形的了。因为我们对同一个队列不再会出现同时读和写的情况,每个队列在写满后交给逻辑线程去读,逻辑线程读完后清空队列再交给IO线程去写,一段固定大小的缓冲区即可。没关系,这么好的技术,在别的地方一定也会用到的。 服务器公共组件实现 -- 发包的方式 前面一直都在说接收数据时的处理方法,我们应该用专门的IO线程,接收到完整的消息包后加入到主线程的消息队列,但是主线程如何发送数据还没有探讨过。 一般来说最直接的方法就是逻辑线程什么时候想发数据了就直接调用相关的socket API发送,这要求服务器的玩家对象中保存其连接的socket句柄。但是直接send调用有时候有会存在一些问题,比如遇到系统的发送缓冲区满而阻塞住的情况,或者只发送了一部分数据的情况也时有发生。我们可以将要发送的数据先缓存一下,这样遇到未发送完的,在逻辑线程的下一次处理时可以接着再发送。 考虑数据缓存的话,那这里这可以有两种实现方式了,一是为每个玩家准备一个缓冲区,另外就是只有一个全局的缓冲区,要发送的数据加入到全局缓冲区的时候同时要指明这个数据是发到哪个socket的。如果使用全局缓冲区的话,那我们可以再进一步,使用一个独立的线程来处理数据发送,类似于逻辑线程对数据的处理方式,这个独立发送线程也维护一个消息队列,逻辑线程要发数据时也只是把数据加入到这个队列中,发送线程循环取包来执行send调用,这时的阻塞也就不会对逻辑线程有任何影响了。 采用第二种方式还可以附带一个优化方案。一般对于广播消息而言,发送给周围玩家的数据都是完全相同的,我们如果采用给每个玩家一个缓冲队列的方式,这个数据包将需要拷贝多份,而采用一个全局发送队列时,我们只需要把这个消息入队一次,同时指明该消息包是要发送给哪些socket的即可。有关该优化的说明在云风描述其连接服务器实现的blog文章中也有讲到,有兴趣的可以去阅读一下。 服务器公共组件实现 -- 状态机 有关State模式的设计意图及实现就不从设计模式中摘抄了,我们只来看看游戏服务器编程中如何使用State设计模式。 首先还是从mangos的代码开始看起,我们注意到登录服在处理客户端发来的消息时用到了这样一个结构体: struct AuthHandler { eAuthCmd cmd; uint32 status; bool (AuthSocket::*handler)(void); }; 该结构体定义了每个消息码的处理函数及需要的状态标识,只有当前状态满足要求时才会调用指定的处理函数,否则这个消息码的出现是不合法的。这个status状态标识的定义是一个宏,有两种有效的标识,STATUS_CONNECTED和STATUS_AUTHED,也就是未认证通过和已认证通过。而这个状态标识的改变是在运行时进行的,确切的说是在收到某个消息并正确处理完后改变的。 我们再来看看设计模式中对State模式的说明,其中关于State模式适用情况里有一条,当操作中含有庞大的多分支的条件语句,且这些分支依赖于该对象的状态,这个状态通常用一个或多个枚举变量表示。 描述的情况与我们这里所要处理的情况是如此的相似,也许我们可以试一试。那再看看State模式提供的解决方案是怎样的,State模式将每一个条件分支放入一个独立的类中。 由于这里的两个状态标识只区分出了两种状态,所以,我们仅需要两个独立的类,用以表示两种状态即可。然后,按照State模式的描述,我们还需要一个Context类,也就是状态机管理类,用以管理当前的状态类。稍作整理,大概的代码会类似这样: 状态基类接口: StateBase { void Enter() = 0; void Leave() = 0; void Process(Message* msg) = 0; }; 状态机基类接口: MachineBase { void ChangeState(StateBase* state) = 0; StateBase* m_curState; }; 我们的逻辑处理类会从MachineBase派生,当取出数据包后交给当前状态处理,前面描述的两个状态类从StateBase派生,每个状态类只处理该状态标识下需要处理的消息。当要进行状态转换时,调用MachineBase的ChangeState()方法,显示地告诉状态机管理类自己要转到哪一个状态。所以,状态类内部需要保存状态机管理类的指针,这个可以在状态类初始化时传入。具体的实现细节就不做过多描述了。 使用状态机虽然避免了复杂的判断语句,但也引入了新的麻烦。当我们在进行状态转换时,可能会需要将一些现场数据从老状态对象转移到新状态对象,这需要在定义接口时做一下考虑。如果不希望执行拷贝,那么这里公有的现场数据也可放到状态机类中,只是这样在使用时可能就不那么优雅了。    正如同在设计模式中所描述的,所有的模式都是已有问题的另一种解决方案,也就是说这并不是唯一的解决方案。放到我们今天讨论的State模式中,就拿登录服所处理的两个状态来说,也许用mangos所采用的遍历处理函数的方法可能更简单,但当系统中的状态数量增多,状态标识也变多的时候,State模式就显得尤其重要了。 比如在游戏服务器上玩家的状态管理,还有在实现NPC人工智能时的各种状态管理,这些就留作以后的专题吧。 服务器公共组件 -- 事件与信号 关于这一节,这几天已经打了好几遍草稿,总觉得说不清楚,也不好组织这些内容,但是打铁要趁热,为避免热情消退,先整理一点东西放这,好继续下面的主题,以后如果有机会再回来完善吧。本节内容欠考虑,希望大家多给点意见。 有些类似于QT中的event与signal,我将一些动作请求消息定义为事件,而将状态改变消息定义为信号。比如在QT应用程序中,用户的一次鼠标点击会产生一个鼠标点击事件加入到事件队列中,当处理此事件时可能会导致某个按钮控件产生一个clicked()信号。 对应到我们的服务器上的一个例子,玩家登录时会发给服务器一个请求登录的数据包,服务器可将其当作一个用户登录事件,该事件处理完后可能会产生一个用户已登录信号。 这样,与QT类似,对于事件我们可以重定义其处理方法,甚至过滤掉某些事件使其不被处理,但对于信号我们只是收到了一个通知,有些类似于Observe模式中的观察者,当收到更新通知时,我们只能更新自己的状态,对刚刚发生的事件我不已不能做任何影响。 仔细来看,事件与信号其实并无多大差别,从我们对其需求上来说,都只要能注册事件或信号响应函数,在事件或信号产生时能够被通知到即可。但有一项区别在于,事件处理函数的返回值是有意义的,我们要根据这个返回值来确定是否还要继续事件的处理,比如在QT中,事件处理函数如果返回true,则这个事件处理已完成,QApplication会接着处理下一个事件,而如果返回false,那么事件分派函数会继续向上寻找下一个可以处理该事件的注册方法。信号处理函数的返回值对信号分派器来说是无意义的。 简单点说,就是我们可以为事件定义过滤器,使得事件可以被过滤。这一功能需求在游戏服务器上是到处存在的。 关于事件和信号机制的实现,网络上的开源训也比较多,比如FastDelegate,sigslot,boost::signal等,其中sigslot还被Google采用,在libjingle的代码中我们可以看到他是如何被使用的。 在实现事件和信号机制时或许可以考虑用同一套实现,在前面我们就分析过,两者唯一的区别仅在于返回值的处理上。 另外还有一个需要我们关注的问题是事件和信号处理时的优先级问题。在QT中,事件因为都是与窗口相关的,所以事件回调时都是从当前窗口开始,一级一级向上派发,直到有一个窗口返回true,截断了事件的处理为止。对于信号的处理则比较简单,默认是没有顺序的,如果需要明确的顺序,可以在信号注册时显示地指明槽的位置。 在我们的需求中,因为没有窗口的概念,事件的处理也与信号类似,对注册过的处理器要按某个顺序依次回调,所以优先级的设置功能是需要的。 最后需要我们考虑的是事件和信号的处理方式。在QT中,事件使用了一个事件队列来维护,如果事件的处理中又产生了新的事件,那么新的事件会加入到队列尾,直到当前事件处理完毕后,QApplication再去队列头取下一个事件来处理。而信号的处理方式有些不同,信号处理是立即回调的,也就是一个信号产生后,他上面所注册的所有槽都会立即被回调。这样就会产生一个递归调用的问题,比如某个信号处理器中又产生了一个信号,会使得信号的处理像一棵树一样的展开。我们需要注意的一个很重要的问题是会不会引起循环调用。 关于事件机制的考虑其实还很多,但都是一些不成熟的想法。在上面的文字中就同时出现了消息、事件和信号三个相近的概念,而在实际处理中,经常发现三者不知道如何界定的情况,实际的情况比我在这里描述的要混乱的多。 这里也就当是挖下一个坑,希望能够有所交流。 再谈登录服的实现 离我们的登录服实现已经太远了,先拉回来一下。 关于登录服、大区服及游戏世界服的结构之前已做过探讨,这里再把各自的职责和关系列一下。 其中DNSServer负责带负载均衡的域名解析服务,返回LoginServer的IP地址给客户端。WorldServerMgr维护当前大区内的世界服列表,LoginServer会从这里取世界列表发给客户端。LoginServer处理玩家的登录及世界服选择请求。GateWay/WorldServer为各个独立的世界服或者通过网关连接到后面的世界服。 在mangos的代码中,我们注意到登录服是从数据库中取的世界列表,而在wow官方服务器中,我们却会注意到,这个世界服列表并不是一开始就固定,而是动态生成的。当每周一次的维护完成之后,我们可以很明显的看到这个列表生成的过程。刚开始时,世界列表是空的,慢慢的,世界服会一个个加入进来,而这里如果有世界服当机,他会显示为离线,不会从列表中删除。但是当下一次服务器再维护后,所有的世界服都不存在了,全部重新开始添加。 从上面的过程描述中,我们很容易想到利用一个临时的列表来保存世界服信息,这也是我们增加WorldServerMgr服务器的目的所在。GateWay/WorldServer在启动时会自动向WorldServerMgr注册自己,这样就把自己所代表的游戏世界添加到世界列表中了。类似的,如果DNSServer也可以让LoginServer自己去注册,这样在临时LoginServer时就不需要去改动DNSServer的配置文件了。 WorldServerMgr内部的实现很简单,监听一个固定的端口,接受来自WorldServer的主动连接,并检测其状态。这里可以用一个心跳包来实现其状态的检测,如果WorldServer的连接断开或者在规定时间内未收到心跳包,则将其状态更新为离线。另外WorldServerMgr还处理来自LoginServer的列表请求。由于世界列表并不常变化,所以LoginServer没有必要每次发送世界列表时都到WorldServerMgr上去取,LoginServer完全可以自己维护一个列表,当WorldServerMgr上的列表发生变化时,WorldServerMgr会主动通知所有的LoginServer也更新一下自己的列表。这个或许就可以用前面描述过的事件方式,或者就是观察者模式了。 WorldServerMgr实现所要考虑的内容就这些,我们再来看看LoginServer,这才是我们今天要重点讨论的对象。 前面探讨一些服务器公共组件,那我们这里也应该试用一下,不能只是停留在理论上。先从状态机开始,前面也说过了,登录服上的连接会有两种状态,一是帐号密码验证状态,一是服务器列表选择状态,其实还有另外一个状态我们未曾讨论过,因为它与我们的登录过程并无多大关系,这就是升级包发送状态。三个状态的转换流程大致为: 这个版本检查的和决定下一个状态的过程是在LogonState中进行的,下一个状态的选择是由当前状态来决定。密码验证的过程使用了SRP6协议,具体过程就不多做描述,每个游戏使用的方式也都不大一样。而版本检查的过程就更无值得探讨的东西,一个if-else即可。 升级状态其实就是文件传输过程,文件发送完毕后通知客户端开始执行升级文件并关闭连接。世界选择状态则提供了一个列表给客户端,其中包括了所有游戏世界网关服务器的IP、PORT和当前负载情况。如果客户端一直连接着,则该状态会以每5秒一次的频率不停刷新列表给客户端,当然是否值得这样做还是有待商榷。 整个过程似乎都没有值得探讨的内容,但是,还没有完。当客户端选择了一个世界之后该怎么办?wow的做法是,当客户端选择一个游戏世界时,客户端 会主动去连接该世界服的IP和PORT,然后进入这个游戏世界。与此同时,与登录服的连接还没有断开,直到客户端确实连接上了选定的世界服并且走完了排队过程为止。这是一个很必要的设计,保证了我们在因意外情况连接不上世界服或者发现世界服正在排队而想换另外一个试试时不会需要重新进行密码验证。 但是我们所要关注的还不是这些,而是客户端去连接游戏世界的网关服时服务器该如何识别我们。打个比方,有个不自觉的玩家不遵守游戏规则,没有去验证帐号密码就直接跑去连接世界服了,就如同一个不自觉的乘客没有换登机牌就直接跑到登机口一样。这时,乘务员会客气地告诉你要先换登机牌,那登机牌又从哪来?检票口换的,人家会先验明你的身份,确认后才会发给你登机牌。一样的处理过程,我们的登录服在验明客户端身份后,也会发给客户端一个登机牌,这个登机牌还有一个学名,叫做session key。 客户端拿着这个session key去世界服网关处就可正确登录了吗?似乎还是有个疑问,他怎么知道我这个key是不是造假的?没办法,中国的假货太多,我们不得不到处都考虑假货的问题。方法很简单,去找给他登机牌的那个检票员问一下,这张牌是不是他发的不就得了。可是,那么多的LoginServer,要一个个问下来,这效率也太低了,后面排的长队一定会开始叫唤了。那么,LoginServer将这个key存到数据库中,让网关服自己去数据库验证?似乎也是个可行的方案。 如果觉得这样给数据库带来了太大的压力的话,也可以考虑类似WorldServerMgr的做法,用一个临时的列表来保存,甚至可以将这个列表就保存到WorldServerMgr上,他正好是全区唯一的。这两种方案的本质并无差别,只是看你愿意将负载放在哪里。而不管在哪里,这个查询的压力都是有点大的,想想,全区所有玩家呢。所以,我们也可以试着考虑一种新的方案,一种不需要去全区唯一一个入口查询的方案。 那我们将这些session key分开存储不就得了。一个可行的方案是,让任意时刻只有一个地方保存一个客户端的session key,这个地方可能是客户端当前正连接着的服务器,也可以是它正要去连接的服务器。让我们来详细描述一下这个过程,客户端在LoginServer上验证通过时,LoginServer为其生成了本次会话的session key,但只是保存在当前的LoginServer上,不会存数据库,也不会发送给WorldServerMgr。如果客户端这时想要去某个游戏世界,那么他必须先通知当前连接的LoginServer要去的服务器地址,LoginServer将session key安全转移给目标服务器,转移的意思是要确保目标服务器收到了session key,本地保存的要删除掉。转移成功后LoginServer通知客户端再去连接目标服务器,这时目标服务器在验证session key合法性的时候就不需要去别处查询了,只在本地保存的session key列表中查询即可。 当然了,为了session key的安全,所有的服务器在收到一个新的session key后都会为其设一个有效期,在有效期过后还没来认证的,则该session key会被自动删除。同时,所有服务器上的session key在连接关闭后一定会被删除,保证一个session key真正只为一次连接会话服务。 但是,很显然的,wow并没有采用这种方案,因为客户端在选择世界服时并没有向服务器发送要求确认的消息。wow中的session key应该是保存在一个类似于WorldServerMgr的地方,或者如mangos一样,就是保存在了数据库中。不管是怎样一种方式,了解了其过程,代码实现都是比较简单的,我们就不再赘述了。 文章转载自: https://blog.csdn.net/GoOnDrift/article/details/18843483 https://blog.csdn.net/erlib/article/details/8936990 如需下载本站全部技术文章,可以在【高性能服务器开发】公众号回复关键字“文章下载”即可。最近更新时间: 2020-10-07 23:17:57 "},"articles/游戏开发专题/10十万在线的WebGame的数据库设计思路.html":{"url":"articles/游戏开发专题/10十万在线的WebGame的数据库设计思路.html","title":"10 十万在线的WebGame的数据库设计思路","keywords":"","body":"10 十万在线的WebGame的数据库设计思路 服务器数量预估 在线人数预估: 在项目设计之前,需要先对运营后的服务器人数做一下预估,预计激活人数300w,活跃人数40w,同时在线10w。而服务器的设计极限则在激活人数500w,活跃人数60w,最高同时在线15w。 数据参考: 这里之所以预计这么低的激活人数,是从整个服务器考虑的。《热血三国》是将不同的用户放在不同的服务器里,所以单一服务器的激活人数不会对服务器压力产生太 大影响。而如果将所有玩家统一到一组服务器里,则会导致用户表访问压力过大。偏低的激活人数靠定期清理不活跃账户来实现。 数据库服务器数量估计: 服务器在搭配上,一般分为db服务器和web服务器。在这之前的运营中,通常按照1:1的方式来配置数据库和web服务器,而实际情况可以使1:2的配置比 例。不过在单一世界的设计里,单台db服务器肯定无法满足需求。之前设计过一款策略类webgame,在运营时,每秒sql数为在线人数的1~1.5倍。 不过这个测试数据,是在没有钱全面应用缓存的情况的数据,在新系统里,如果全面应用缓存,并采用类似于Memcache的软件提供数据缓存,这样数据库的访问压力将可以得到极大的缓解,因此我们暂定吧每秒sql数暂定为在线人数的1倍。正常情况下数据库的访问压力应该为 10w sql/秒 极限数据应该为15w sql/秒。 数据库使用ms sqlserver 2008,在这之前的一个策略类webgame项目,对一台CPU为 E5520核的服务器上做压力测试,得到的数据如下: Sql数 5k CPU 50% 硬盘IO 0.5M(突发3M) 网卡流量 30Mbit/s(100M网卡) 按照上面的分析,在正常情况下,我们需要为整个系统提供20台db。Web服务端按照1:1和db做搭配,也将安排20台,预计3个机柜的服务器。 数据库表结构划分 数据库表设计: 因为要把这么多访问量分担到不同的服务器里,原先的数据库表设计肯定不会合适。初步的想法是根据游戏的逻辑模块,将不同模块的数据库表拆分到各个服务器 里,如果按照上面的服务器预估得到的结论是4~6组服务器,实际上这个方案还是可行的。但如果是20组服务器的话,除非是一台服务器一张数据库表,但这的 设计会造成数据表太分散,在处理事务的时候,会跨多个数据库 策略类webgame一般的主要模块为:建筑物和资源、军事、英雄、物品、帮会、交易、地图。根据这些模块的应用场景,可以将数据库表分为2种类型,一种是属于玩家的数据,另外一种是公共数据。 属于玩家的数据是指玩家个人说拥有的基地、资源、军事单位、物品等数据,它们都是围绕着玩家而产生的。 公共数据则是指由多位玩家共同组合而产生的数据,例如:账户信息、帮会、地图等。 这里划分两种数据的目的是在于他们的数据库表的划分。对于公共数据,则采用单一服务器,单一数据库表处理的方式来处理。例如帮会模块和地图模块就准备分别 用3台服务器来存储各自对应的数据库表。而对于玩家的数据,则根据用户ID采用一定的划分方式,将玩家数据打散到各个服务器里(http://blog.zhaojie.me/2010/03/sharding-by-id-characteristic.html)。 (数据表的结构划分) 用户表和其单表的设计思路: 这里所说的单表是指在逻辑上部队数据库表做拆分,程序在访问时只访问一个数据库。当然这只是逻辑上的单一,根据实际上的访问压力,可以将数据库文件作水平切割分布在不同的文件分区和服务器里。这部分的数据库表设计继续沿用之前的设计方案就可以了。 对于用户信息,帮会信息等数据,实际上插入和更新的频率不会太高,更多的是在查询上,因此这部分的设计重点应该是在缓存上。从以前的资料里得知Memcache服务器每秒可以响应4w次的读请求,用一台Memcache就能处理好用户和帮会信息的缓存处理。 地图模块设计思路 地图模块: 地图在传统策略类webgame里都是以平面的方式展示和存储的。地图的移动都是在这个平面上实现。但一般来说,平面地图的设计容量都会有一个上限,一般来 地图多为400*400,他的人数上限就是16w,实际上服务器容纳3~5w人后,整张地图就会显得很拥挤了。如果要想容纳几百万人在线,平面地图的尺寸 就需要扩容得相当大了,这样玩家从地图中间移动到边缘的时间会相当恐怖,因此平面地图在这里不是很合适。因此,地图不能用平面来构造,必须是立体的方法构 造。在这里我设计了两组方案: 立体平面空间: 如上图所描述的,立体平面空间,就是把多块地图一层层叠加在一起,形成一个立体的空间。这样如果用户不够,再增加一个新的平面就行。游戏的背景可以根据需要 做调整(例如整个世界是被大海隔开的5片大陆组成,在这5片大陆之外,还有其它的超位面空间,这些空间自身是互不相连的,但是可以通过传送阵进行位面传 送)。这样做的好处是,用户容易理解,以往用户的操作习惯不用改变,毕竟都是在平面地图上战斗。只不过要做跨位面的战斗的移动计算上会存在问题(逻辑上的 问题:是否允许跨大陆的远征军) 用户坐标的表示方法:地图层次、x坐标、y坐标 数据库设计方案: 采用了层次结构,只需要增加一个地图层次的字段,这个地图表就能沿用。(参考字段:ID、地图层次、X坐标、y坐标、地图类型、玩家ID、城池ID) 虽然说,加入了一个地图层次的字段能解决地图的表示问题,不过,因为整个游戏世界是单一世界的服务器,当所用地图信息存储到一张表的时候,这数据量就不容小 视。在这之前做webgame项目的时候,整张地图是预先生成好数据库记录的,当有玩家加入游戏的时候,就去修改表里的玩家ID和城池ID。同时因为地图 大小只有400400,整张表也就16w条记录。但如果是要做一个承载500w人的服务器,那地图的尺寸最好是要800800,并且地图的层次为 15~20层,就算最小的15层,按照原先的设计思路,至少需要预先插入960w条记录。 数据量看上去比较夸张,不过对于SqlServer来说也不是处理不了,并且我们还将计划把地图表单独用一台服务器来处理,其压力远小很多。不过也不能不考虑当发生性能瓶颈时的优化处理。优化的方法有两个: 拆分:按照地图层次,把这张表拆分成15~20张表,或者拆分到15~20个数据库里 用疏矩阵存储:地图不预先生成用户的地图信息,而是有玩家加入时才插入数据。这个方案在服务器早期人数比较少时会得到良好的性能效果,但当用户人数达到一定量时,还是避免不了因为记录函数过多而导致而外的开销。 全立体空间: 全立体空间就是取消了平面的坐标显示,用户都是在一个三维的立体地图里战斗。好处是地图不用那么分散,在移动计算让很好处理,存在的问题就是游戏在显示的时候,如何表现地图的三维效果会比较困难。 用户坐标的表示方式:x坐标 y坐标 z坐标 数据库存储方案: 三维空间的数据库表设计结构可以和上面的表一样,而且也只能采用疏矩阵的方式存储,因为做成三维空间后,可表示的位置的记录数更多了。 可移动基地在全立体空间的设想: 早在两年前,看过《超时空要塞F》的时候,就产生了一个想法,就是玩家的基地是可以移动的。玩家的母舰在游戏的过程中,已一定的速度在整个世界里移动。 可以移动体系的设计要点: 用户的基地可移动 用户基地只能拥有一个(武林三国、travian都能建立多个) 空间坐标由x坐标 y坐标 z坐标 组成,并且坐标的值应为小数 同一个坐标里运行多个玩家存在,玩家的航线交叉并不会造成影响(只是为了方便计算减少判断过程) 移动的数据通过后台定时刷新 a)每个短周期(1~60s)在内存里更新坐标 b)每个长周期(10~100个短周期时间)将坐标的数据更新的数据库 攻击舰队移动的时间是按照2个阶段来进行的 a)第一个阶段是从母舰移动到目标坐标的时间 b)第二个阶段,在快到达时(前60分钟),做一个判断,判断攻击舰队的雷达能否搜索到目标的母舰坐标,能则做攻击坐标的新修正,如果不能则继续按照原先的坐标点移动。以上判断将每隔1分钟做一次,直到到达目标坐标点。如果到达目标坐标点仍然无法视为攻击失败,舰队返回 舰队的移动距离和舰队所携带的能量有关,超过移动范围的坐标,舰队是无法出发的。 部队和母舰应该是可以进行空间跳跃实现长距离的移动,不过空间跳跃需要在制定地点消耗大量的能量才能实现。 默认情况下,母舰移动速度为1格(x、y、z坐标)/天。 默认舰队的雷达查询范围为1格 默认母舰的雷达查询范围为3格 玩家数据的数据库设计 数据库的划分: 在游戏里数据交互最频繁的还是玩家的数据,他的访问量是一台服务器所不能解决的,因此我们考虑将这部分数据分担到多台服务器里。分担的方法还是做水平切 割,但这次不使用数据库自身的切割功能,而是在应用逻辑层上对数据库进行切割。根据用户的ID取模后写入对应的服务器里。 服务器1 用户ID % 服务器数量 = 0 服务器2 用户ID % 服务器数量 = 1 …… 预计每台服务器能提供6k~8k的在线用户访问,预计一共需要16台服务器。考虑到服务器的进一步扩容问题,在初期规划时,建议规划为32个数据库,每台服务器可以先放3~5个数据库,等服务器用户人数上来后,再将数据库拆分到不同的服务器里。 用户数据库各个模块的设计: 玩家基地里的建筑物,资源,物品,英雄等相关表,基本上都是玩家独立拥有的,不存在和其他玩家交互的情况,因此这些表的设计继续沿用之前的设计就可以了。 军事模块: 军事模块分为部队表,部队创建事件表和战斗事件表。部队表和部队创建事件都是玩家自己内部的事情,把相关的数据和玩家其他数据放在一个数据库里就行了,但是战斗事件表则会设计到两位或者多为玩家则会比较复杂一些。 战斗事件表通常记录的是A玩家(城池)对B玩家(城池)的攻击,里面有攻击部队,到达时间等信息。这个条记录和A放在一起,那么B在查询自己被攻击的记录 时,就需要访问32个数据库,反之,和B放在一起,这A查询自己部队的攻击情况时,就需要遍历32个数据库。如果和用户表一样单独把这张表拿出来,用单独 的一个服务器来处理,则会导致表过大,查询会变慢以及战斗服务器的压力过大。 在之前的项目,战斗服务器处理每场战斗大约是100ms,也就是每秒能处理10场战斗。当然你也许说可以用多线程来进行,但是使用多线程后,战斗事件的顺序可能会点到,影响用户的战术安排。 在这里,我设想,将一个表设计改为2个表:攻击事件表和被攻击事件表。这两个表的结构一样。加入A玩家发起对B玩家的攻击,那么将攻击事件加入A玩家所在 服务器里的攻击事件表,在B玩家服务器里,将数据插入被攻击事件表。然后每个数据库对应一个战斗服务器程序,这个程序在已被攻击事件表为依据,进行攻击计 算。在计算完成后,在同时删除2个表里的数据。 好友模块: 好友表本身就可以分为2个表,已某位玩家ID为主键和对应玩家放在同一个数据库里。但是好友申请则需要另外考虑了。如果申请的申请方不可见自己发出的申 请,则只需把申请记录和被申请玩家放在一个数据里。但如果需要可见,则会麻烦一些,一种方法是参考战斗表的设计思路,分为申请表和被申请表。还有一种方法 就是把申请表独立出来,所用用户的申请都放在这张表里。作为我个人,我倾向于后面的一种方法。 用户邮件表设计: 用户邮件虽然是属于2位用户之间的交互数据,但从整个系统的角度上来说,用单一的一张表放在单独的服务器里会更简单一些。因为邮件表的内容基本为只读内容,只存在插入和读取功能,并且用户访问的频率不是很高,可以很方便的在逻辑层和web层作缓存。 整体架构和总结 总结: 虽然对于单一世界的webgame思考了很多,但到最后细化写成文字,也就只有这4篇短文。不是说不想深入细节去讨论,而是发现如果不做一些具体开发就没法深入写下去,因此本系列文章页就在这里点到即止,希望能给大家一些启发。 如需下载本站全部技术文章,可以在【高性能服务器开发】公众号回复关键字“文章下载”即可。最近更新时间: 2020-10-07 23:24:03 "},"articles/游戏开发专题/11一种高性能网络游戏服务器架构设计.html":{"url":"articles/游戏开发专题/11一种高性能网络游戏服务器架构设计.html","title":"11 一种高性能网络游戏服务器架构设计","keywords":"","body":"11 一种高性能网络游戏服务器架构设计 ​ 网络游戏的结构分为客户端与服务器端,客户端采用2D绘制引擎或者3D绘制引擎绘制游戏世界的实时画面,服务器端则负责响应所有客户端的连接请求和游戏逻辑处理,并控制所有客户端的游戏画面绘制。客户端与服务器通过网络数据包交互完成每一步游戏逻辑,由于游戏逻辑是由服务器负责处理的,要保证面对海量用户登录时,游戏具有良好的流畅性和用户体验,优秀的服务器架构起到了关键的作用。 1 服务器架构设计 1.1 服务器架构分类 服务器组的架构一般分为两种:第一种是带网关服务器的服务器架构;第二种是不带网关服务器的服务器架构,这两种方案各有利弊。在给出服务器架构设计之前,先对这两种设计方案进行详细的探讨。 所谓网关服务器,其实是Gate服务器,比如LoginGate、GameGate等。网关服务器的主要职责是将客户端和游戏服务器隔离,客户端程序直接与这些网关服务器通信,并不需要知道具体的游戏服务器内部架构,包括它们的IP、端口、网络通信模型(完成端口或Epoll)等。客户端只与网关服务器相连,通过网关服务器转发数据包间接地与游戏服务器交互。同样地,游戏服务器也不直接和客户端通信,发给客户端的协议都通过网关服务器进行转发。 1.2 服务器架构设计 根据网络游戏的规模和设计的不同,每组服务器中服务器种类和数量是不尽相同的。本文设计出的带网关服务器的服务器组架构如图1所示。 本文将服务器设计成带网关服务器的架构,虽然加大了服务器的设计复杂度,但却带来了以下几点好处: (1)作为网络通信的中转站,负责维护将内网和外网隔离开,使外部无法直接访问内部服务器,保障内网服务器的安全,一定程度上较少外挂的攻击。 (2)网关服务器负责解析数据包、加解密、超时处理和一定逻辑处理,这样可以提前过滤掉错误包和非法数据包。 (3)客户端程序只需建立与网关服务器的连接即可进入游戏,无需与其它游戏服务器同时建立多条连接,节省了客户端和服务器程序的网络资源开销。 (4)在玩家跳服务器时,不需要断开与网关服务器的连接,玩家数据在不同游戏服务器间的切换是内网切换,切换工作瞬间完成,玩家几乎察觉不到,这保证了游戏的流畅性和良好的用户体验。 在享受网关服务器带来上述好处的同时,还需注意以下可能导致负面效果的两个情况:如何避免网关服务器成为高负载情况下的通讯瓶颈问题以及由于网关的单节点故障导致整组服务器无法对外提供服务的问题。上述两个问题可以采用“多网关” 技术加以解决。顾名思义,“多网关” 就是同时存在多个网关服务器,比如一组服务器可以配置三台GameGate。当负载较大时,可以通过增加网关服务器来增加网关的总体通讯流量,当一台网关服务器宕机时,它只会影响连接到本服务器的客户端,其它客户端不会受到任何影响。 从图1的服务器架构图可以看出,一组服务器包括LoginGate、LoginServer、GameGate、GameServer、DBServer和MServer等多种服务器。LoginGate和GameGate就是网关服务器,一般一组服务器会配置3台GameGate,因为稳定性对于网络游戏运营来说是至关重要的,而服务器宕机等突发事件是游戏运营中所面临的潜在风险,配置多台服务器可以有效地降低单个服务器宕机带来的风险。另外,配置多台网关服务器也是进行负载均衡的有效手段之一。下面将对各种服务器的主要功能和彼此之间的数据交互做详细解释。 (1)LoginGate LoginGate主要负责在玩家登录时维护客户端与LoginServer之间的网络连接与通讯,对LoginServer和客户端的通信数据进行加解密、校验。 (2)LoginServer LoginServer主要功能是验证玩家的账号是否合法,只有通过验证的账号才能登录游戏。从架构图可以看出, DBServer和GameServer会连接LoginServer。玩家登录基本流程是,客户端发送账号和密码到LoginServer验证,如果验证通过,LoginServer会给玩家分配一个SessionKey,LoginServer会把这个SessionKey发送给客户端、DBServer和GameServer,在后续的选择角色以后进入游戏过程中,DBServer和GameServer将验证SessionKey合法性,如果和客户端携带的SessionKey不一致,将无法成功获取到角色或者进入游戏。 (3)GameGate GameGate(GG)主要负责在用户游戏过程中负责维持GS与客户端之间的网络连接和通讯,对GS和客户端的通信数据进行加解密和校验,对客户端发往GS的用户数据进行解析,过滤错误包,对客户端发来的一些协议作简单的逻辑处理,其中包括游戏逻辑中的一些超时判断。在用户选择角色过程中负责维持DBServer与客户端之间的网络连接和通讯,对DBServer和客户端的通信数据进行加解密和校验,对客户端发往DBServer的用户数据做简单的分析。维持客户端与MServer之间的网络连接与通讯、加解密、数据转发和简单的逻辑处理等。 (4)GameServer GameServer(GS)主要负责游戏逻辑处理。网络游戏有庞大世界观背景,绚丽激烈的阵营对抗以及完备的装备和技能体系。目前,网络游戏主要包括任务系统、声望系统、玩家PK、宠物系统、摆摊系统、行会系统、排名系统、副本系统、生产系统和宝石系统等。从软件架构角度来看,这些系统可以看着GS的子系统或模块,它们共同处理整个游戏世界逻辑的运算。游戏逻辑包括角色进入与退出游戏、跳GS以及各种逻辑动作(比如行走、跑动、说话和攻击等)。 由于整个游戏世界有许多游戏场景,在该架构中一组服务器有3台GS共同负责游戏逻辑处理,每台游戏服务器负责一部分地图的处理,这样不仅降低了单台服务器的负载,而且降低了GS宕机带来的风险。玩家角色信息里会保持玩家上次退出游戏时的地图编号和所在GS编号,这样玩家再次登录时,会进入到上次退出时的GS。 上面提到过,在验证账号之后,LoginServer会把这个SessionKey 发给GS,当玩家选择角色登录GS时,会把SessionKey一起发给GS,这时GS会验证SessionKey是否与其保存的相一致,不一致的话GS会拒绝玩家进入游戏。MServer的主要负责GS之间的数据转发以及数据广播,另外,一些系统也可以放到MServer上,这样也可以减轻GS的运算压力。 (5)DBServer DBServer主要的功能是缓存玩家角色数据,保证角色数据能快速的读取和保存。由于角色数据量是比较大的,包括玩家的等级、经验、生命值、魔法值、装备、技能、好友、公会等。如果每次GS获取角色数据都去读数据库,效率必然非常低下,用DBServer缓存角色数据之后,极大地提高了数据请求的响应速度。 LoginServer会在玩家选组时把SessionKey发给DBServer,当玩家发送获取角色信息协议时会带上这个SessionKey,如果跟DBServer保存的SessionKey不一致,则DBServer会认为玩家不是合法用户,获取角色协议将会失败。另外,玩家选取角色正式进入游戏时,GS会给DBServer发送携带SessionKey的获取角色信息协议,这时DBServer同样会验证SessionKey的合法性。总之,只有客户端、DBServer和GS所保存的SessionKey一致,才能保证协议收到成功反馈。 与DBServer通讯的服务器主要有GG,GS和LoginServer,DBServer与GG交互的协议主要包括列角色、创建角色、删除角色、恢复角色等,DBServer与GS交互的协议包括读取角色数据、保存角色数据和跳服务器等,DBServer与LoginServer交互的协议主要是用户登录协议,这时候会给DBServer发送SessionKey。 (6)MServer 每一个组有一台MServer,主要负责维持3台GS之间数据的转发和数据广播。另外一些游戏系统也可能会放到MServer上处理,比如行会系统。 1.3 服务器交互的主要流程 下面给出服务器之间数据通讯的主要流程从这些流程能看出各种服务器之间是如何数据交互和协同工作的。 图2的流程说明了,在选角色过程中,客户端会把携带游戏账号和SessionKey的选角色协议发给GG,GG做一些简单处理之后转发给DBServer,DBServer要验证SessionKey的合法性,验证通过之后,DBServer会从角色信息缓冲区里取出该账户的所有角色信息发给客户端。这个过程在客户端的表现是,当选择好服务器组之后,客户端会直接显示该账号下的所有角色,之后就可以选择角色进入游戏了。 图3的流程说明了,在玩家选角色正式进入游戏时,客户端会把携带游戏账号、角色ID和SessionKey的登录协议发给GG,GG做一些简单处理之后转发给GS。GS会验证SessionKey的合法性,验证通过之后,GS会把验证通过的结果发给客户端,同时GS给DBServer发获取角色数据的协议,这些角色数据是一个玩家所有的游戏数据,包括装备、技能等等。 图4的流程说明了,在玩家游戏过程,客户端把逻辑协议(包括走、说话、跑、使用技能等)发给GG,GG完成加解密和简单逻辑处理之后转发给GS,GS负责这些协议的主要 逻辑处理。 2 总结 网络游戏服务器的架构设计已经成为当前网络游戏研究领域的热点,因为高性能服务器架构设计是一款网络游戏成功的关键。本文从实际应用出发,提出了一种高性能的服务器架构设计解决方案,并且详细探讨了各种服务器的功能,本文的最后给出了几个服务器之间数据通讯的关键流程,以图文并茂的方式解释各个服务器是如何协同工作的。 如需下载本站全部技术文章,可以在【高性能服务器开发】公众号回复关键字“文章下载”即可。最近更新时间: 2020-10-07 23:28:21 "},"articles/游戏开发专题/12经典游戏服务器端架构概述.html":{"url":"articles/游戏开发专题/12经典游戏服务器端架构概述.html","title":"12 经典游戏服务器端架构概述","keywords":"","body":"12 经典游戏服务器端架构概述 架构的分析模型 一. 讨论的背景 ​ 现代电子游戏,基本上都会使用一定的网络功能。从验证正版,到多人交互等等,都需要架设一些专用的服务器,以及编写在服务器上的程序。因此,游戏服务器端软件的架构,本质上也是游戏服务器这个特定领域的软件架构。 ​ 软件架构的分析,可以通过不同的层面入手。比较经典的软件架构描述,包含了以下几种架构: ​ 1.运行时架构——这种架构关心如何解决运行效率问题,通常以程序进程图、数据流图为表达方式。在大多数开发团队的架构设计文档中,都会包含运行时架构,说明这是一种非常重要的设计方面。这种架构也会显著的影响软件代码的开发效率和部署效率。本文主要讨论的是这种架构。 ​ 2.逻辑架构——这种架构关心软件代码之间的关系,主要目的是为了提高软件应对需求变更的便利性。人们往往会以类图、模块图来表达这种架构。这种架构设计在需要长期运营和重用性高的项目中,有至关重要的作用。因为软件的可扩展性和可重用度基本是由这个方面的设计决定的。特别是在游戏领域,需求变更的频繁程度,在多个互联网产业领域里可以说是最高的。本文会涉及一部分这种架构的内容,但不是本文的讨论重点。 ​ 3.物理架构——关心软件如何部署,以机房、服务器、网络设备为主要描述对象。 ​ 4.数据架构——关心软件涉及的数据结构的设计,对于数据分析挖掘,多系统协作有较大的意义。 ​ 5.开发架构——关心软件开发库之间的关系,以及版本管理、开发工具、编译构建的设计,主要为了提高多人协作开发,以及复杂软件库引用的开发效率。现在流行的集成构建系统就是一种开发架构的理论。 二. 游戏服务器架构的要素 ​ 服务器端软件的本质,是一个会长期运行的程序,并且它还要服务于多个不定时,不定地点的网络请求。所以这类软件的特点是要非常关注稳定性和性能。这类程序如果需要多个协作来提高承载能力,则还要关注部署和扩容的便利性;同时,还需要考虑如何实现某种程度容灾需求。由于多进程协同工作,也带来了开发的复杂度,这也是需要关注的问题。 ​ 功能约束,是架构设计决定性因素。一个万能的架构,必定是无能的架构。一个优秀的架构,则是正好把握了对应业务领域的核心功能产生的。游戏领域的功能特征,于服务器端系统来说,非常明显的表现为几个功能的需求: ​ 1.对于游戏数据和玩家数据的存储 ​ 2.对玩家客户端进行数据广播 ​ 把一部分游戏逻辑在服务器上运算,便于游戏更新内容,以及防止外挂。 ​ 针对以上的需求特征,在服务器端软件开发上,我们往往会关注软件对电脑内存和CPU的使用,以求在特定业务代码下,能尽量满足承载量和响应延迟的需求。最基本的做法就是“时空转换”,用各种缓存的方式来开发程序,以求在CPU时间和内存空间上取得合适的平衡。在CPU和内存之上,是另外一个约束因素:网卡。网络带宽直接限制了服务器的处理能力,所以游戏服务器架构也必定要考虑这个因素。 ​ 对于游戏服务器架构设计来说,最重要的是利用游戏产品的需求约束,从而优化出对此特定功能最合适的“时-空”架构。并且最小化对网络带宽的占用。 [图:游戏服务器的分析模型] 三. 核心的三个架构 ​ 基于上述的分析模型,对于游戏服务端架构,最重要的三个部分就是,如何使用CPU、内存、网卡的设计: ​ 1.内存架构:主要决定服务器如何使用内存,以保证尽量少的内存泄漏的可能,以及最大化利用服务器端内存来提高承载量,降低服务延迟。 ​ 2.调度架构:设计如何使用进程、线程、协程这些对于CPU调度的方案。选择同步、异步等不同的编程模型,以提高服务器的稳定性和承载量。同时也要考虑对于开发带来的复杂度问题。现在出现的虚拟化技术,如虚拟机、docker、云服务器等,都为调度架构提供了更多的选择。 ​ 3.通信模式:决定使用何种方式通讯。网络通讯包含有传输层的选择,如TCP/UDP;据表达层的选择,如定义协议;以及应用层的接口设计,如消息队列、事件分发、远程调用等。 ​ 本文的讨论,也主要是集中于对以上三个架构的分析。 四. 游戏服务器模型的进化历程 ​ 最早的游戏服务器是比较简单的,如UO《网络创世纪》的服务端一张3.5寸软盘就能存下。基本上只是一个广播和存储文件的服务器程序。后来由于国内的外挂、盗版流行,各游戏厂商开始以MUD为模型,建立主要运行逻辑在服务器端的架构。这种架构在MMORPG类产品的不断更新中发扬光大,从而出现了以地图、视野等分布要素设计的分布式游戏服务器。而在另外一个领域,休闲游戏,天然的需要集中超高的在线用户,所以全区型架构开始出现。现代的游戏服务器架构,基本上都希望能结合承载量和扩展性的有点来设计,从而形成了更加丰富多样的形态。 ​ 本文的讨论主要是选取这些比较典型的游戏服务器模型,分析其底层各种选择的优点和缺点,希望能探讨出更具广泛性,更高开发效率的服务器模型。 分服模型 一. 模型描述 ​ 分服模型是游戏服务器中最典型,也是历久最悠久的模型。其特征是游戏服务器是一个个单独的世界。每个服务器的帐号是独立的,而且只用同一服务器的帐号才能产生线上交互。在早期服务器的承载量达到上限的时候,游戏开发者就通过架设更多的服务器来解决。这样提供了很多个游戏的“平行世界”,让游戏中的人人之间的比较,产生了更多的空间。所以后来以服务器的开放、合并形成了一套成熟的运营手段。一个技术上的选择最后导致了游戏运营方式的模式,是一个非常有趣的现象。 [图:分服模型] 二. 调度架构 1.单进程游戏服务器 最简单的游戏服务器只有一个进程,是一个单点。这个进程如果退出,则整个游戏世界消失。在此进程中,由于需要处理并发的客户端的数据包,因此产生了多种选择方法: [图:单进程调度模型] ​ a.同步-动态多线程:每接收一个用户会话,就建立一个线程。这个用户会话往往就是由客户端的TCP连接来代表,这样每次从socket中调用读取或写出数据包的时候,都可以使用阻塞模式,编码直观而简单。有多少个游戏客户端的连接,就有多少个线程。但是这个方案也有很明显的缺点,就是服务器容易产生大量的线程,这对于内存占用不好控制,同时线程切换也会造成CPU的性能损失。更重要的多线程下对同一块数据的读写,需要处理锁的问题,这可能让代码变的非常复杂,造成各种死锁的BUG,影响服务器的稳定性。 ​ b.同步-多线程池:为了节约线程的建立和释放,建立了一个线程池。每个用户会话建立的时候,向线程池申请处理线程的使用。在用户会话结束的时候,线程不退出,而是向线程池“释放”对此线程的使用。线程池能很好的控制线程数量,可以防止用户暴涨下对服务器造成的连接冲击,形成一种排队进入的机制。但是线程池本身的实现比较复杂,而“申请”、“施放”线程的调用规则需要严格遵守,否则会出现线程泄露,耗尽线程池。 ​ c.异步-单线程/协程:在游戏行业中,采用Linux的epoll作为网络API,以期得到高性能,是一个常见的选择。游戏服务器进程中最常见的阻塞调用就是网路IO,因此在采用epoll之后,整个服务器进程就可能变得完全没有阻塞调用,这样只需要一个线程即可。这彻底解决了多线程的锁问题,而且也简化了对于并发编程的难度。但是,“所有调用都不得阻塞”的约束,并不是那么容易遵守的,比如有些数据库的API就是阻塞的;另外单进程单线程只能使用一个CPU,在现在多核多CPU的服务器情况下,不能充分利用CPU资源。异步编程由于是基于“回调”的方式,会导致要定义很多回调函数,并且把一个流程里面的逻辑,分别写在多个不同的回调函数里面,对于代码阅读非常不理。——针对这种编码问题,协程(Coroutine)能较好的帮忙,所以现在比较流行使用异步+协程的组合。不管怎样,异步-单线程模型由于性能好,无需并发思维,依然是现在很多团队的首选。 ​ d.异步-固定多线程:这是基于异步-单线程模型进化出来的一种模型。这种模型一般有三类线程:主线程、IO线程、逻辑线程。这些线程都在内部以全异步的方式运行,而他们之间通过无锁消息队列通信。 2.多进程游戏服务器 ​ 多进程的游戏服务器系统,最早起源于对于性能问题需求。由于单进程架构下,总会存在承载量的极限,越是复杂的游戏,其单进程承载量就越低,因此开发者们一定要突破进程的限制,才能支撑更复杂的游戏。 ​ 一旦走上多进程之路,开发者们还发现了多进程系统的其他一些好处:能够利用上多核CPU能力;利用操作系统的工具能更仔细的监控到运行状态、更容易进行容灾处理。多进程系统比较经典的模型是“三层架构”。 ​ 在多进程架构下,开发者一般倾向于把每个模块的功能,都单独开发成一个进程,然后以使用进程间通信来协调处理完整的逻辑。这种思想是典型的“管道与过滤器”架构模式思想——把每个进程看成是一个过滤器,用户发来的数据包,流经多个过滤器衔接而成的管道,最后被完整的处理完。由于使用了多进程,所以首选使用单进程单线程来构造其中的每个进程。这样对于程序开发来说,结构清晰简单很多,也能获得更高的性能。 [图:经典的三层模型] ​ 尽管有很多好处,但是多进程系统还有一个需要特别注意的问题——数据存储。由于要保证数据的一致性,所以存储进程一般都难以切分成多个进程。就算对关系型数据做分库分表处理,也是非常复杂的,对业务类型有依赖的。而且如果单个逻辑处理进程承载不了,由于其内存中的数据难以分割和同步,开发者很难去平行的扩展某个特定业务逻辑。他们可能会选择把业务逻辑进程做成无状态的,但是这更加加重了存储进程的性能压力,因为每次业务处理都要去存储进程处拉取或写入数据。 ​ 除了数据的问题,多进程也架构也带来了一系列运维和开发上的问题:首先就是整个系统的部署更为复杂了,因为需要对多个不同类型进程进行连接配置,造成大量的配置文件需要管理;其次是由于进程间通讯很多,所以需要定义的协议也数量庞大,在单进程下一个函数调用解决的问题,在多进程下就要定义一套请求、应答的协议,这造成整个源代码规模的数量级的增大;最后是整个系统被肢解为很多个功能短小的代码片段,如果不了解整体结构,是很难理解一个完整的业务流程是如何被处理的,这让代码的阅读和交接成本巨高无比,特别是在游戏领域,由于业务流程变化非常快,几经修改后的系统,几乎没有人能完全掌握其内容。 三. 内存架构 ​ 由于服务器进程需要长期自动化运行,所以内存使用的稳定是首要大事。在服务器进程中,就算一个触发几率很小的内存泄露,都会积累起来变成严重的运营事故。需要注意的是,不管你的线程和进程结构如何,内存架构都是需要的,除非是Erlang这种不使用堆的函数式语言。 1.动态内存 ​ 在需要的时候申请内存来处理问题,是每个程序员入门的时候必然要学会的技能。但是,如何控制内存释放却是一个大问题。在C/C++语言中,对于堆的控制至关重要。有一些开发者会以树状来规划内存使用,就是一般只new/delete一个主要的类型的对象,其他对象都是此对象的成员(或者指针成员),只要这棵树上所有的对象都管理好自己的成员,就不会出现内存漏洞,整个结构也比较清晰简单。 [图:对象树架构] ​ 在Objective C语言中,有所谓autorealse的特性,这种特性实际上是一种引用计数的技术。由于能配合在某个调度模型下,所以使用起来会比较简单。同样的思想,有些开发者会使用一些智能指针,配合自己写的框架,在完整的业务逻辑调用后一次性清理相关内存。 [图:根据业务处理调度管理内存池] ​ 在带虚拟机的语言中,最常见的是JAVA,这个问题一般会简单一些,因为有自动垃圾回收机制。但是,JAVA中的容器类型、以及static变量依然是可能造成内存泄露的原因。加上无规划的使用线程,也有可能造成内存的泄露——有些线程不会退出,而且在不断增加,最后耗尽内存。所以这些问题都要求开发者专门针对static变量以及线程结构做统一设计、严格规范。 2.预分配内存 ​ 动态分配内存在小心谨慎的程序员手上,是能发挥很好的效果的。但是游戏业务往往需要用到的数据结构非常多,变化非常大,这导致了内存管理的风险很高。为了比较彻底的解决内存漏洞的问题,很多团队采用了预先分配内存的结构。在服务器启动的时候分配所有的变量,在运行过程中不调用任何new关键字的代码。 ​ 这样做的好处除了可以有效减少内存漏洞的出现概率,也能降低动态分配内存所消耗的性能。同时由于启动时分配内存,如果硬件资源不够的话,进程就会在启动时失败,而不是像动态分配内存的程序一样,可能在任何一个分配内存的时候崩溃。然而,要获得这些好处,在编码上首先还是要遵循“动态分配架构”中对象树的原则,把一类对象构造为“根”对象,然后用一个内存池来管理这些根对象。而这个内存池能存放的根对象的数目,就是此服务进程的最大承载能力。一切都是在启动的时候决定,非常的稳妥可靠。 [图:预分配内存池] ​ 不过这样做,同样有一些缺点:首先是不太好部署,比如你想在某个资源较小的虚拟机上部署一套用来测试,可能一位内没改内存池的大小,导致启动不成功。每次更换环境都需要修改这个配置。其次,是所有的用到的类对象,都要在根节点对象那里有个指针或者引用,否则就可能泄漏内存。由于对于非基本类型的对象,我们一般不喜欢用拷贝的方式来作为函数的参数和返回值,而指针和应用所指向的内存,如果不能new的话,只能是现成的某个对象的成员属性。这回导致程序越复杂,这类的成员属性就越多,这些属性在代码维护是一个不小的负担。 ​ 要解决以上的缺点,可以修改内存池的实现,为动态增长,但是具备上限的模型,每次从内存池中“获取”对象的时候才new。这样就能避免在小内存机器上启动不了的问题。对于对象属性复杂的问题,一般上需要好好的按面向对象的原则规划代码,做到尽量少用仅仅表示函数参数和返回值的属性,而是主要是记录对象的“业务状态”属性为主,多花点功夫在构建游戏的数据模型上。 四. 进程间通讯手段 ​ 在多进程的系统中,进程间如何通讯是一个至关重要的问题,其性能和使用便利性,直接决定了多进程系统的技术效能。 1.Socket通讯 ​ TCP/IP协议是一种通用的、跨语言、跨操作系统、跨机器的通讯方案。这也是开发者首先想到的一种手段。在使用上,有使用TCP和UDP两个选择。一般我们倾向在游戏系统中使用TCP,因为游戏数据的逻辑相关性比较强,UDP由于可能存在的丢包和重发处理,在游戏逻辑上的处理一般比较复杂。由于多进程系统的进程间网络一般情况较好,UDP的性能优势不会特别明显。 ​ 要使用TCP做跨进程通讯,首先就是要写一个TCP Server,做端口监听和连接管理;其次需要对可能用到的通信内容做协议定制;最后是要编写编解码和业务逻辑转发的逻辑。这些都完成了之后,才能真正的开始用来作为进程间通信手段。 ​ 使用Socket编程的好处是通用性广,你可以用来实现任何的功能,和任何的进程进行协作。但是其缺点也异常明显,就是开发量很大。虽然现在有一些开源组件,可以帮你简化Socket Server的编写工作,简化连接管理和消息分发的处理,但是选择目标建立连接、定制协议编解码这两个工作往往还是要自己去做。游戏的特点是业务逻辑变化很多,导致协议修改的工作量非常大。因此我们除了直接使用TCP/IP socket以外,还有很多其他的方案可以尝试。 [图:TCP通讯] 2.消息队列 在多进程系统中,如果进程的种类比较多,而且变化比较快,大量编写和配置进程之间的连接是一件非常繁琐的工作,所以开发者就发明了一种简易的通讯方法——消息队列。这种方法的底层还是Socket通讯实现,但是使用者只需要好像投递信件一样,把消息包投递到某个“信箱”,也就是队列里,目标进程则自动不断去“收取”属于自己的“信件”,然后触发业务处理。 这种模型的好处是非常简单易懂,使用者只需要处理“投递”和“收取”两个操作即可,对于消息也只需要处理“编码”和“解码”两个部分。在J2EE规范中,就有定义一套消息队列的规范,叫JMS,Apache ActiveMQ就是一个应用广泛的实现者。在Linux环境下,我们还可以利用共享内存,来承担消息队列的存储器,这样不但性能很高,而且还不怕进程崩溃导致未处理消息丢失。 [图:消息队列] ​ 需要注意的是,有些开发者缺乏经验,使用了数据库,如MySQL,或者是NFS这类运行效率比较低的媒介作为队列的存储者。这在功能上虽然可以行得通,但是操作一频繁,就难以发挥作用了。如以前有一些手机短信应用系统,就用MySQL来存储“待发送”的短信。 ​ 消息队列虽然非常好用,但是我们还是要自己对消息进行编解码,并且分发给所需要的处理程序。在消息到处理程序之间,存在着一个转换和对应的工作。由于游戏逻辑的繁多,这种对应工作完全靠手工编码,是比较容易出错的。所以这里还有进一步的改进空间。 3.远程调用 ​ 有一些开发者会希望,在编码的时候完全屏蔽是否跨进程在进行调用,完全可以好像调用本地函数或者本地对象的方法一样。于是诞生了很多远程调用的方案,最经典的有Corba方案,它试图实现能在不同语言的代码直接,实现远程调用。JAVA虚拟机自带了RMI方案的支持,在JAVA进程之间远程调用是比较方便的。在互联网的环境下,还有各种Web Service方案,以HTTP协议作为承载,WSDL作为接口描述。 ​ 使用远程调用的方案,最大好处是开发的便捷,你只需要写一个函数,就能在任何一个其他进程上对此函数进行调用。这对游戏开发来说,就解决了多进程方案最大的一个开发效率问题。但是这种便捷是有成本的:一般来说,远程调用的性能会稍微差一点,因为需要用一套统一的编解码方案。如果你使用的是C/C++这类静态语言,还需要使用一种IDL语言来先描述这种远程函数的接口。但是这些困难带来的好处,在游戏开发领域还是非常值得的。 [图:远程调用] 五. 容灾和扩容手段 ​ 在多进程模型中,由于可以采用多台物理服务器来部署服务进程,所以为容灾和扩容提供了基础条件。 ​ 在单进程模型下,容灾常常使用的热备服务器,依然可以在多进程模型中使用,但是开着一台什么都不做的服务器完全是为了做容灾,多少有点浪费。所以在多进程环境下,我们会启动多个相同功能的服务器进程,在请求的时候,根据某种规则来确定对哪个服务进程发起请求。如果这种规则能规避访问那些“失效”了的服务进程,就自动实现了容灾,如果这个规则还包括了“更新新增服务进程”的逻辑,就可以做到很方便的扩容了。而这两个规则,统一起来就是一条:对服务进程状态的集中保存和更新。 ​ 为了实现上面的方案,常常会架设一个“目录”服务器进程。这个进程专门负责搜集服务器进程的状态,并且提供查询。ZooKeeper就是实现这种目录服务器的一个优秀工具。 [图:服务器状态管理] ​ 尽管用简单的目录服务器可以实现大部分容灾和扩容的需求,但是如果被访问进程的内存中有数据存在,那么问题就比较复杂了。对于容灾来说,新的进程必须要有办法重建那个“失效”了的进程内存中的数据,才可能完成容灾功能;对于扩容功能来说,新加入的进程,也必须能把需要的数据载入到自己的内存中才行,而这些数据,可能已经存在于其他平行的进程中,如何把这部分数据转移过来,是一个比较耗费性能和需要编写相当多代码的工作。——所以一般我们喜欢对“无状态”的进程来做扩容和容灾。 全服分线模型 一. 模型描述 ​ 由于多进程服务器模型的发展,游戏开发者们首先发现,由于游戏业务的特点,那些需要持久化的数据,一般都是玩家的存档,以及一些游戏本身需要用的,在运行期只读的数据。这对于存储进程的分布,提供了非常有利的条件。于是玩家数据可以存放于同一个集群中,可以不再和游戏服务器绑定在一起,因为登录的时候便可根据玩家的ID去存储集群中定位想要存取的存储进程。 [图-全区分线模型] 二. 存储的挑战 1.需求:扩容和容灾 在全区分线模型下,游戏玩家可以随便选择任何一个服务器登录,自己的帐号数据都可以提取出来玩。这种显然比每个服务器重新“练”一个号要省事的多。而且这样也可以和朋友们约定去一个负载较低的服务器一起玩,而不用苦苦等待某一个特定的服务器变得空闲。然而,这些好处所需要付出的代价,是在存储层的分布式设计。这种设计有一个最需要解决的问题,就是游戏服务器系统的扩容和容灾。 从模型上说,扩容是加入新的服务器,容灾是减掉失效的服务器。这两个操作在无状态的服务器进程上操作,都只是更新一下连接配置表,然后重启一下即可。但是,由于游戏存在大量的状态,包括运行时内存中的状态,以及持久化的存储状态,这就让扩容和容灾需要更多的处理才能成功。 最普通的情况下,在扩容和容灾的时候,首先需要通知所有玩家下线,把内存中的状态数据写入持久化数据进程;然后根据需要的配置,把持久化数据重新“搬迁”到新的变化后的服务器上。——如果一个游戏有几千万用户,这样的数据搬迁将会耗时非常长,玩家也被迫等待很长的时间才能重新登录游戏。所以在这种模型下,对于数据存储的设计是最关键的地方。 2.分区分服的关系型数据库 我们常常会使用MySQL这种关系型数据库来存放游戏数据。由于SQL能够表述非常复杂的数据操作,这对于游戏数据的一些后期处理有非常好的支持:如客服需要发奖励,需要撤销某些错误的运营数据,需要封停某些特征的玩家……但是,分布式数据库也是最难做分布的。一般来说我们都需要通过某一主键字段做分库和分表;而另外一些如唯一关键字等数据,就需要一些技巧来处理。 [图-分表分库] ​ 以玩家ID作为分表分库是一个非常自然的选择,但是这种方案,往往需要在逻辑代码中,对玩家数据按照自定义的规则,做存储进程的选择。但是如果发现这个分表分库的算法(原则)不符合需求,就需要把大量的数据做搬迁。如上图是按玩家ID做奇偶规则分布到两个表中,一旦需要增加第三台服务器,数据存储的目的服务器编号就变成了id%3,这样就需要把好多数据需要从原来的第一、二台数据库中拷贝出来,非常麻烦。 ​ 有的开发者会预先建立几十个表(如120个表=2x3x4x5),一开始是全部都放在一个服务器上,然后在增加数据库服务器的时候,把对应的整个表搬迁出来。这样能减轻在搬迁数据的时候造成的复杂度,但还是需要搬迁数据的。最后如果与建立的表还是放不下了,依然还是需要很复杂和耗时的重新拷贝数据。 3.NoSQL ​ 在很多开发者绞尽脑汁折腾MySQL的时候,NoSQL横空出世了。实际上在很早,目录型存储进程就在DNS等特定领域默默工作了。NoSQL系统最大的好处正是关系型数据库最大的弱点——分布。 ​ 由于主键只有一个,因此内置的分布功能使用起来非常简便。而且游戏玩家数据,绝大多数的操作都是根据主键来读写的。“自古以来”游戏就有“SL大法”之称,其本质就是对存档数据的简单读、写。在网游的早期版本MUD游戏时代,玩家存档只是简单的放在硬盘的文件上,文件名就是玩家的ID。这些,都说明了游戏中的玩家数据,其读写都是有明显约束的——玩家ID。这和NoSQL简直是天作之合。 [图-NoSQL] ​ NoSQL的确是非常适合用来存储游戏数据。特别是有些服务器如Redis还带有丰富的字段值类型。但是,NoSQL本身往往不带很复杂的容灾热备机制,这是需要额外注意的。而且NoSQL的访问延迟虽然比关系型数据库快很多,但是毕竟要经过一层网络。这对于那些发展了很多年的ORM库来说,缺乏了一个本地缓存的功能。这就导致了NoSQL还不能简单的取代掉所有服务器上的“状态”。而这些正是分布式缓存所希望达成的目标。 4.分布式缓存 ​ 在业界用的比较多的缓存系统有memcached,开发者有时候也会使用诸如Hibernate这样的ROM库提供的cache功能。但是这些缓存系统在使用上往往会有一些限制,最主要的限制是“无法分布式使用”,也就是说缓存系统本身成为性能瓶颈后,就没有办法扩容了。或者在容灾的情景下,缓存系统往往容易变成致命的单点。 ​ Orcale公司有一款叫Coherence的产品,就是一种能很好解决以上问题的“能分布式使用”的产品。他利用局域网的组播功能来做节点间的状态同步,同时采用节点互相备份的方案来分布数据。这款产品还使用Map接口来提供功能。这让整个缓存系统既使用简单又功能强大。更重要的是,它能让用户对于数据的存取特性做配置,从而提供用户可接受的数据风险下的更高性能——本地缓存。 ​ 由于游戏的数据,真正变化频繁的,往往不是“关键”的需要安全保障数据,如玩家的位置、玩家在某次战斗中的HP、子弹怪物的位置等等。而那些非常重要的数据,如等级、装备,又变化的不频繁。这就给了开发者针对数据特性做优化以很大的空间。而且,大部分数据的读、写频率都有典型的不平衡状态。普遍游戏数据都是读多写少。少量的日志、上报数据是写多、几乎不读。 ​ 对于缓存系统来说,有三个重要的因数决定了在游戏开发中的地位。首先是其使用的便利性,因为游戏的数据结构变化非常频繁,如果要很繁琐的配置数据结构,则不会适合游戏开发;其次是要能提供近似本地内存的性能,由于游戏服务器逻辑基本上都是在频繁的读写某一特定数据块,如玩家位置、经验、HP等等,而且游戏对于处理延迟也有较高的需求(WEB应用在2秒以内都可以忍受,游戏则要求最好能在20ms以内完成)。要能同时满足这两点,是不太容易的。 [图-分布式缓存] 5.集成缓存的NoSQL 根据上面的描述,读者应该也会想到,如果数据库系统,或者叫持久化系统,自带了缓存,是否更好呢?这样确实是会更好的,而且特别是对于NOSQL系统来说,能以一些内部的算法策略,来降低前端逻辑开发的复杂程度。一般来说,我们需要对集成缓存的NOSQL系统有以下几方面的需求:首先是冷热数据自动交换,就是对于常用数据有算法来判别其冷热,然后换入到内存以提高存取性;其次是分布式扩容和容灾功能,由于NOSQL是可以知道数据的主关键字的,所以自然就可以自动的去划分数据所在的分段,从而可以自动化的寻找到目标存储位置来做操作;最后是数据导出功能,由于NOSQL支持的查询索引只能是主键,对于很多后台游戏操作来说是不够的,所以一定要能够到处到传统的SQL服务器上去。 在这方面,有很多产品都做过一定的尝试,比如在Redis或者MangoDB上做插件修改,或者以ORM系统封装MySQL以试图构造这种系统等等。 [图-集成缓存的NOSQL] 三. 跳线和开房间 1.开房间型游戏模型 在全区分线服务器模型中,最早出现在开房间类型的游戏中。因为海量玩家需要临时聚合到一个个小的在线服务单元上互动。比如一起下棋、打牌等。这类游戏玩法和MMORPG有很大的不同,在于其在线广播单元的不确定性和广播数量很小。 这一类游戏最重要的是其“游戏大厅”的承载量,每个“游戏房间”受逻辑所限,需要维持和广播的玩家数据是有限的,但是“游戏大厅”需要维持相当高的在线用户数,所以一般来说,这种游戏还是需要做“分服”的。典型的游戏就是《英雄联盟》《穿越火线》这一类游戏了。而“游戏大厅”里面最有挑战性的任务,就是“自动匹配”玩家进入一个“游戏房间”,这需要对所有在线玩家做搜索和过滤。 [图-开房间型游戏] ​ 这类游戏服务器,玩家先登录“大厅服务器”,然后选择组队游戏的功能,服务器会通知参与的所有游戏客户端,新开一条连接到房间服务器上,这样所有参与的用户就能在房间服务器里进行游戏交互了。 ​ 由于“大厅服务器”只负责“组队”,所以其承载力会比具体的房间服务器更高一些,但这里仍然会是性能瓶颈。所以一般我们需要尽量减少大厅服务器的功能,比如把登录功能单独列出来、把玩家的购买物品商城功能也单独出来等等。最后,我们也可以直接想办法把“组队”功能也按组队逻辑做一定划分,比如不同的组队玩法、副本类型、组队用户等级等等。 ​ 虽然这种模型已经可以对很多游戏做很好的承载了,但是在大厅服务器这里依然无法做到平行扩展,原因是玩家的在线数据比较难分布到不同的服务进程上去,而且还带有大量复杂的数据查询逻辑。 2.专用聊天服务器 不管是MMORPG还是开房间类游戏,聊天一直都是网络游戏中一个重要的功能。而这个功能在“在线人数”很多,“聊天频道”很多的情况下,会给性能带来非常大的挑战。在很多类型的页游和少部分手机游戏里面,在线聊天甚至是唯一的“带公共状态”的服务。 ​ 聊天服务处理点对点的聊天,还有群聊。用户可能会添加好友、建立好友群组等各种功能。这些功能,都是和一般的游戏逻辑有一定差别的功能。这些功能往往并不是非常容易实现。很多游戏都期望建立类似腾讯QQ的游戏聊天功能,但是QQ是一整个公司在做开发,要用仅仅一个游戏团队做成这么完整的功能,是有一定困难的。 ​ 因此游戏开发者们常常会专门的针对聊天功能来开发一系列的服务进程,以便能让游戏的聊天功能独立出来,做到负载分流和代码重用的逻辑。很多网游系统,其聊天系统从客户端来说就是和主游戏进程分开的。 ​ 聊天服务器的本质是对客户端数据做广播,从而让玩家可以交互,所以有很多游戏开发者也直接拿聊天服务器来做棋牌游戏的房间服务器,或者反过来用。由于在游戏“分服”里面单独部署了聊天服务器,这类服务器也往往被用来承担做“跨服玩法”的进程。比如跨服团队战、跨服副本等等。不管这些服务器最终叫什么名字,实际上他们承担的主要功能还是广播,而且是运行玩家“二次登录”的广播服务器。以至于后来,有部分游戏直接全部都用聊天服务器来代替原始的“游戏服务器”,这样还能实现一个叫“跳线”的功能,也就是玩家从一个“在线环境”跳到另外一个“在线环境”去。——这些都是对于“广播”功能的灵活运用。 [图-专用聊天服务器] 全服全线模型 尽管分服的游戏模型已经运营了很多年,但是有一些游戏运营商还是希望能让尽量多的玩家一起玩。因为网游的人气越活跃,产生的交互越多,游戏的乐趣也可能越多。这一点最突出表现在棋牌类网游上。如联众、QQ游戏这类产品,无不是希望更多玩家能同时在线接入一个“大”服务器,从而找到可以一起玩的伙伴。在手游时代,由于手机本身在线时间不稳定,所以想要和朋友一起玩本来就比较困难,如果再以“服务器”划分区域,交互的乐趣就更少了,所以同样也呼唤这一个“大”服务器,能容纳下所有此款游戏的玩家。因此,开发者们在以前积累的分服模型和分线模型基础上,开发出满足海量在线互动需求的一系列游戏服务器模型——全服全线模型。 [图-全服全线模型] 一. 服务进程的组织 1. 静态配置 全服全线模型的本质是一个各种不同功能进程组成的分布式系统,因此这些进程间的关系是在运维部署期间必须关注的信息。最简单的处理方法,就是预先规划出具体的进程数量、以及进程部署的物理位置,然后通过一套配置文件来描述这个规划的内容。对于每个进程,需要配置列明每个进程的pid文件位置;内部通讯用的地址,如IP+端口或者消息队列ID;启动和停止脚本路径;日志路径等等……由于有了一套这样的配置文件,我们还可以编写工具对所有的这些进程进行监控和操作批量启停。 [图-静态配置] ​ 虽然我们可以以静态配置为基础做很丰富的管理工具,但是这种做法还是有可以改进的空间:每次扩容、更换故障服务器或者搬迁服务器(这在运营中很常见),我们都必须手工修改静态配置数据,由于是人工操作,就总会产生很多错误,根据个人经验,游戏运营事故中的70%以上,是跟运维操作有关;由于整个分布式系统被切分成大量的进程,对于新进入此项目的程序员来说,要完整的理解这个系统,需要在思想上跨越层层阻隔:每个进程的功能、它们部署的关联、每个进程间的协议报的含义、每个业务流程具体的跨进程过程……这要花费很多时间才能搞明白的。而且大部分游戏的这种架构并不统一,每个游戏都可能需要重新理解一次,知识无法重用;在开发测试上,由于分布式系统的复杂性,要多搭几个开发、测试环境也是很费时间的,以至于这项工作甚至要安排专人来负责,这对于小型游戏开发团队来说几乎是不可承担的成本。因此我们还需要一些更加自动化,更加容易理解的全服全线游戏服务器模型。 2.基于中心点的动态组织 ​ SOA架构模式是业界一个比较经典的分布式软件架构模式,这个架构的特点是能动态的组织一个非常复杂的分布式服务系统。这个系统可以包含提供各种各样供的服务程序,而这些服务程序都以同一个标准接口来使用,并且服务自己会注册自己到集群中,以便请求方能找到自己。这种架构使用Web Serivce来作为服务接口标准,通过发布WSDL来提供接口API,这极大的降低了开发者对这些服务的使用成本。在游戏领域,服务器端提供的功能程序,实际上也是非常多样的,如果要构建一个分布式的系统,在这个方面是非常适合SOA架构的思想的;然而,游戏却很少使用HTTP协议及其之上的Web Service做通讯层,因为这个协议性能太低。不过,类似SOA的,基于中心节点的动态组织的服务管理思路,却依然适用。 [图-基于中心点的动态组织] ​ 一般来说我们会使用一组目录服务器来充当“中心点”,代表整个集群。开源产品中最好的产品就是ZooKeeper了。当然也有一些开发者自己编写这样的目录服务器。由于每个服务进程会自己上报负载和状态,所以每个进程只需要配置自己提供的服务即可:服务名字、服务接口。对于请求方来说,一般都可以预先编写目标服务接口的类库,用来编程,有些项目还使用RPC功能,使用IDL语言配置直接生成这些接口类库。当需要请求的时候,执行“名字查找”-“路由选择”-“发起请求”就可以完成整个过程。由于有“查找”-“路由”的过程,所以如果目标服务故障、或者新增了服务提供者,请求方就能自动获得这些信息,从而达到自动动态扩容或容灾的效果,这些都是无需专门去做配置的。 3.服务化与云 ​ 尽管动态组织的架构有如此多优点,但是开发者还是需要自己部署和维护中心节点。对于一些常用的服务,如网络代理服务、数据存储服务,用户还是要自己去安装,以及想办法接入到这套体系中去。这对于开发、测试还是有一定的运维工作压力的。于是一些开发团队就把这类工作集中起来,预先部署一套大的集群中心系统,所有开发者都直接使用,而不是自己去安装部署,这就成为了服务化,或者云服务。 [图-服务化、云] ​ 使用专人维护的服务化集群确实是一个轻松愉快的过程。但是游戏开发和运营过程中,往往需要多套环境,如各个不同版本的测试环境、给不同运营平台搭建的环境、海外运营的环境等等……这些环境会大大增加维护服务化集群的工作量,对于解决这个问题,建立高度自动化运维的私有云,成为一个需要解决的问题放上了桌面。提高集群的运维效率,降低工作复杂程度,需要一些特别的技术,而虚拟化技术正式解决这些问题的最新突破。 二. 提高开发效率所用的结构 1.使用RPC提高网络接口编写效率 在分布式系统中,如果所有的接口都需要自己定义数据协议报来做交互,这个网络编程的工作量将会非常的大,因为对于一个普通的通信接口来说,至少包括了:一个请求包结构、一个响应包结构、四段代码,包括请求响应包的编码和解码、一个接收数据做分发的代码分支、一个发送回应的调用。由于分布式的游戏服务器进程非常多,一个类似登录这样的操作,可能需要历经三、四个进程的合作处理,这就导致了接近十个数据结构的定义和无数段类似的代码。而这些代码,如果在单进程的环境下,仅仅只是三、四个函数定义而已。 因此很多开发者投入很大精力,让网络通信的编写过程,尽量简化成类似函数的编写一样。这就是前文所述的远程调用的方法。在全区全线的游戏中,如果是比较重度的游戏,采用RPC方式做开发,会大大降低开发的复杂程度。当然也有一些比较轻度的游戏,还是采用传统的协议包编解码、分发逻辑调用的做法。 2.简化数据处理 在分布式系统中,对于避免单点、容灾、扩容中最复杂的问题,就是在内存中的数据。由于内存中有游戏业务的数据,所以一般我们不敢随便停止进程,也难以把一个进程的服务替换为另外一个进程。然而,游戏数据对比其他业务,还是非常有特点的: a.写入越不频繁的数据,价值越高。比如过关、升级、获得重要装备。 b.大量数据都是读非常频繁,而写非常不频繁的,如玩家的等级、经验。 c.大量写入频繁的数据,实际上是不太重要,可以有一定损失,比如玩家位置,在某个关卡内的HP/MP等…… d.因此,只要我们能按数据的特性,对游戏中需要处理的数据做一定分类,就能很好的解决分布式中的这些问题。 e.首先我们要对数据的分布做规划,一般来说采用按玩家ID做分布,这样能让服务进程中内存的数据缓存高度命中。常用的手法有用一致性哈希来选择路由,调用相关的服务进程。 f.其次对于读频繁而写不频繁的数据,我们采用读缓存而写不缓存的策略。每个服务进程都保留其读缓存数据,如果需要扩容和容灾,仅仅需要修改服务访问的路由即可。 g.再次对于读不频繁而写频繁的数据,我们采用写缓存和读不缓存的策略。由于这些数据丢失掉一些是不要紧的,所以容灾处理就直接忽略即可,对于扩容,只需要对所有服务进程都做一次回写即可。 h.最后,有一些数据是读和写都频繁的数据,比如玩家位置,HP/MP这类,我们采用读写都缓存,由于数据重要性不高,只要我们多分几个服务进程即可降低故障时影响的范围;在扩容的时候调用全节点清理读缓存和回写脏数据即可。 在和持久化设备打交道的时候,传统的ORM类库往往能帮我们把数据存入关系型数据库,然而,使用一个自带数据热备的NOSQL也是很好的选择。因为这样能节省大量的分库分表逻辑代码。 3.自动化部署集群环境 最新的虚拟化技术给分布式系统提供能更好的部署手段,以Docker为标志的虚拟化平台,可以很好的提高服务化集群的管理。我们可以把每个服务进程打包成一个映像文件,放入Docker虚拟机中运行,也可以把一组互相关联的服务进程打包运行。这些环境问题都由Docker处理了。 但是,我们同时需要注意的是,如果我们的进程的资源是静态分配的(前文提到),在Docker的虚拟机中可能因为内存不足等原因直接无法启动。这就需要我们把完全静态分配资源的程序,修改为有资源限制,但是动态分配的程序。这样我们才能在任何可以部署Docker的机器上部署我们的游戏服务器。 三. 分布式难点:状态同步 1.分布式接入层 一般来说,我们全线服务器系统碰到的第一个问题,就是大量并发的网络请求。特别是大量玩家都在一起交互,产生了大量由于状态同步而需要广播的数据包。这些网络请求的处理,显然应该独立出来成为单独的进程。同时这些网络接入进程,还应该是一个集群中的成员。这就诞生了分布式接入服务层。 这些网路接入进程的第一个功能,就是把并发的连接,代理成为后端一个串行的连接,这可以让后端服务进程的处理逻辑更简单,而且网络处理消耗变得更小。 其次,网络接入进程需要支持广播功能。如果只是普通的广播实现,很多人会需要拷贝很多次需要广播的内容,然后挨个对Socket做发送。这其实是一个消耗很高的操作。而单独的网络接入进程,可以善用“零拷贝”等技术,大大降低广播的性能开销。而且还可以通过多个进程一起做广播操作,以达到更大的在线同步区域。 最后,网络接入进程需要支持一些额外的有用功能,包括通讯的加密、压缩、流量控制、过载保护等等。有些团队还把用户的登录鉴权也加入网络接入功能中。 ) [图-分布式接入层] 2.使用P2P 网络状态同步产生的广播请求中,绝大多数都是客户端之间的网络状态,因此我们在可以使用P2P的客户端之间,直接建立P2P的UDP数据连接,会比通过服务器转发降低非常多的负载。在一些如赛车、音乐、武打类型的著名游戏中,都有使用P2P技术。而接入进程天然的就是一个P2P撮合服务器。 有些游戏为了进一步降低延迟,还对所有的玩家状态,只同步输入动作,以及死亡、技能等重要状态,让怪物和一般状态通过计算获得,这样就更能节省玩家的带宽,提高及时性。加上一些动作预测技术,在客户端上能表现的非常流畅。 展望 一. 可重用的游戏业务模版 ​ 游戏服务端的各种架构中,以前往往比较关注那些非功能性的需求:容灾性、扩容、承载量,延迟。而在现在手游时代,开发效率越来越重要,有些团队甚至不设专门的服务器端程序员。因此游戏服务端架构应该更多的关注业务开发的效率。 ​ 现代游戏中,只要是带RPG元素的,角色系统、物品系统、技能系统、任务系统就都会具备,而且都有一批比较稳定的核心逻辑。只要是能在线交互的,就有好友系统、邮件系统、聊天系统、公会系统等。另外商城系统、活动系统、公告系统更是每个游戏都似乎要重复发明的轮子。 ​ 游戏的后端应用也有很多可重用的部分,比如客服系统、数据统计平台、官网数据接口等等。这些在游戏服务端框架中往往是最后再添加进去的。 ​ 如果把以上的问题都统一考虑起来,我们实际上是可以在一个稳定的底层架构上,构造出一整套常用的游戏业务逻辑模板,用来减少游戏领域的业务代码开发。所以这样一套可以运行各种业务逻辑模版的底层架构,正是游戏服务端架构发展的方向。 二. 动态资源调度的PaaS云 ​ 现在有的团队已经在搭建自己的Docker云,这可以让游戏服务器在虚拟云上动态的生长,从而达到真正的动态扩容和动态容灾。加上如果游戏服务器不再是一个个服务进程,而是真正意义上的一个个服务,可以动态的加入或者离开云环境,那么这就是一个游戏领域的PaaS系统。我热切的希望能看到,可以用一套SDK,开发或重用那些成型的业务模版,然后动态注册到服务云中就能运行,这样一种游戏服务器架构。 如需下载本站全部技术文章,可以在【高性能服务器开发】公众号回复关键字“文章下载”即可。最近更新时间: 2020-10-07 23:44:05 "},"articles/游戏开发专题/13游戏跨服架构进化之路.html":{"url":"articles/游戏开发专题/13游戏跨服架构进化之路.html","title":"13 游戏跨服架构进化之路","keywords":"","body":"13 游戏跨服架构进化之路 江贵龙,游戏行业从业8年,历任多款游戏项目服务器主程,服务器负责人。 关注游戏服务器架构及优化,监控预警,智能运维,数据统计分析等。 1.背景 ​ 虽然游戏市场竞争激烈,产品格局变动较大,但游戏产业一直处于稳步增长阶段,无论是在端游,页游,手游还是已经初露端倪的H5游戏。可以预见,游戏类型中,MMOARPG游戏仍然会是引领市场的主流趋势,贡献着大部分流水,市场上也仍然在不断涌现精品。研发团队对MMO游戏的探索从来未间断过,从付费模式的改变,到题材多元化,次时代的视觉效果,更成熟的玩法及数值体系,本文主要针对跨服玩法上的探索和实现做一些思考和分析。 ​ 根据2016年《中国游戏产业报告》数据显示,随着游戏人口红利逐渐消失,获取用户的成本居高不下,几年来至少翻了十倍以上,目前平均导量成本页游为10~15元/人,手游在15~20元/人,其中IOS上成本30~50元/人,“洗”用户模式的效果正在变得微弱,用户流失严重。让我们先来看看滚服玩法的局限性,滚服洗量模式下存在着如下的弊端: 2.设计目标 ​ 在上述背景下,一款长留存,低流失的精品游戏就成了平台方,渠道商,研发方追捧的目标,设想一下,如果让所有服务器玩家通过“跨域体系”实现自由畅通交互,在此基础上,玩家可以体验到前所未有的“国战系统”——7×24小时昼夜不停服的国家战争,随时开战;突破单地图承载容量极限的国战对决,带来真正万人国战的刺激体验,形成全区玩家能够互动的游戏社交环境。依托平台运营来打造一款真正意义上摆脱传统游戏运营模式的全新产品,为平台吸纳足够的市场份额,大幅降低流失率。 ​ 我们的蓝图是开创“1=1000”模式,让所有玩家,身处一个服务器却如同同时存在于所有服务器,这种打破服务器屏障的设定,杜绝了游戏出现“被迫滚服”现象出现,玩家不用再担心鬼服人烟稀少,不用担心交易所一无所有,所有的数据共享,让玩家轻松Hold住全世界。 3.进化过程 ​ 项目组那时面临的现状是游戏各种档期计划、宣传推广安排都已经就绪,两个月后该独代项目要在腾讯平台按时上线,开发不能因引入跨服机制而导致所有完成度100%的功能都要去分别去增加跨服的支持,而技术人员在跨服功能开发这块经验的积累上也不充分。 技术小组分析了时下项目的现状,跨服业务需求及现有的框架结构,明确了几点原则: ​ 1.为了实现跨服,游戏代码从底层架构到上层业务逻辑的代码改动成本尽量降低 ​ 2.业务逻辑里尽量少关心或者不用关心是否在本服或者跨服,降低开发人员的跨服功能开发复杂度,提高开发的效率,缩短开发周期。 那么,我们需要解决哪些技术疑点呢? 3.1 客户端直连还是服务器转发 a)如果直连,那么,跨服玩法时客户端要维持两个连接,在跨服里,要模拟玩家登陆,绑定session的过程,游戏服和跨服两边要同时维护两份玩家数据,如何做到数据的同步?跨服要暴露给玩家,需要有公网访问IP和端口。对客户端连接管理来说较复杂。 b)如果通过大区服务器消息转发,那么,服务器之间做RPC通信,连接管理,消息需额外做一步跳转,性能能否满足?跨不跨服,对于客户端来说透明,跨服隐藏在大区之后,更加安全,不需再浪费公网IP和端口。 综合考虑了下,采用了B方案。 3.1.1 RPC框架设计需求 那么,我们需要先准备一套高性能轻量级的RPC框架。 业界有很多典型的RPC框架,比如Motan、Thrift、gRPC、Hessian、Hprose,Wildfly,Dubbo,DubboX,为什么我们还要重复造轮子呢?综合考虑了下,框架要满足以下几点业务需求: 1.该框架要简单、易用、支持高并发的跨服请求; 2.根据现有的游戏服务器框架,会有很多定制化的场景; 3.通过NIO TCP长连接获取服务,但无需跨语言的需求; 4.支持同步请求,异步请求,异步回调CallBack; 5.要有服务发现的功能,要有Failfast能力; 6.具备负载均衡,分组等路由策略; 基于有以上的诉求,结合团队以前的开发经验,于是就决定自主研发。 我们选用的技术栈有 Netty、Apache Commons Pool、Redis等。 框架分为服务提供方(RPC Server)、服务调用方(RPC Client)、注册中心(Registry)三个角色,基于Redis为服务注册中心,通过其Pub/Sub实现服务动态的注册和发现。Server 端会在服务初始化时向Registry 注册声明所提供的服务;Client 向 Registry 订阅到具体提供服务的 Server 列表,根据需要与相关的 Server 建立连接,进行 RPC 服务调用。同时,Client 通过 Registry 感知 Server 的状态变更。三者的交互关系如右图: 图1、RPC框架三者关系 3.1.2 RPC请求的有序性 连接池在设计过程中,比较重要的是要考虑请求的顺序性,也就是先请求的先完成。 如果玩家的跨服请求通过不同的RPC连接并发执行,就有可能单个玩家请求因错序而导致逻辑矛盾,比如玩家移动,见图2: 图2、玩家移动 ​ 玩家移动是很频繁的,如果A请求让玩家从位置1移动到位置2,B请求从位置2移动到位置3,有可能B请求先被跨服接收处理,这就会产生逻辑问题。 ​ 那么,如何做到请求的有序性呢?其本质是让同一份数据的访问能串行化,方法就是让同一个玩家的跨服请求通过同一条RPC连接执行,加上逻辑上的有效性验证,如图3所示: 3.1.3 同步RPC实现细节 限于篇幅,这里只讲同步请求的RPC连接池实现。 同步请求的时序图如图4: 上图为进入跨服战场的一次同步请求,场景切换控制器StageControllAction发起进入跨服战场的请求applyChangeByBattlefield(),场景管理器StageControllManager首先要调用登录跨服的RPC请求GameRpcClient.loginCrossServer(LoginCrossServerReq), ​ 跨服RPC请求的工作流是这样的: public LoginCrossServerAck loginCrossServer(LoginCrossServerReqreq)throws ServiceException { //从连接池中获取一个连接 RpcClient rpcClient = rpcClientPool.getResource(req.getRoleId()); try { //发起一次同步RPC请求 RpcMsg msg = rpcClient.sendWithReturn(MsgType.RPC_LoginCrossServerReq, req); return JSON.parseObject(msg.getContent(), LoginCrossServerAck.class); } finally { //将连接放回连接池中 rpcClientPool.returnResource(rpcClient); } } ​ 该请求第一步先从连接池里获取一个连接RpcClient rpcClient = rpcClientPool.getResource(roleId),然后发起一个同步请求RpcClient.sendWithReturn(),等待直到结果返回,然后把资源归还连接池。 ​ 我们重点来看看sendWithReturn代码实现: private ChannelsocketChannel; private MapwatchDog = new ConcurrentHashMap<>(); private Mapresponses = new ConcurrentHashMap<>(); /**同步请求*/ public RpcMsg sendWithReturn(intmsgType, Objectmsg) throws ServiceException { RpcMsg rpcMsg = RpcMsg.newBuilder().setServer(false).setSync(true).setSeqId(buildSeqId()). setTimestamp(System.nanoTime()).setType(msgType).setContent(JSON.toJSONString(msg)).build(); //创建一把共享锁 CountDownLatch latch = new CountDownLatch(1); watchDog.put(rpcMsg.getSeqId(), latch); writeRequest(rpcMsg); return readRequest(rpcMsg.getSeqId(), latch); } /**发送消息*/ publicvoid writeRequest(RpcMsgmsg)throws ServiceException { if (channel.isActive()) { channel.writeAndFlush(msg); } } /**阻塞等待返回*/ protected RpcMsg readRequest(longseqId, CountDownLatchlatch)throws ServiceException { try { //锁等待 if (timeout ​ 测试场景为分别在连接数在1,8,并发数1,8,数据大小在22byte,94byte,2504byte情况下,做测试,消息同步传输,原样返回,以下是针对同步请求压力测试的结果(取均值): 连接数 并发数 请求类型 数据大小(bytes) 平均TPS 平均响应时间(ms) 1 1 Sync 22 5917 0.169 8 1 Sync 22 6849 0.146 8 8 Sync 22 25125 0.0398 8 8 Sync 94 20790 0.0481 8 8 Sync 2504 16260 0.0725 3.2 服务器之间主动推,还是被动拉取 3.2.1被动拉取模式(Pull) 由于我们的游戏服务器和跨服服务器代码基本一致,所以只要能在跨服中获得游戏功能所要的数据,那么,就能完成任何原有的功能,并且改造成本基本为零,我们选择了被动拉取。 这里要提出一个概念:数据源的相对性。 提供数据方,C向B请求一份数据,B是C的数据源,B向A请求一份数据,A是B的数据源。 图5、数据源的相对性 ​ 一个玩家跨服过去后,往游戏原服拉取数据的细节图如图6: 图6、被动拉取模式 ​ 玩家先跨服过去,loginCrossServer(LoginCrossServerReq),然后,在用到任意数据时(主角,技能,坐骑,装备,宠物等),反向同步请求各个系统的数据。 ​ 我们的实现如图7所示: 图7、被动拉取UML图 public abstractclass AbstractCacheRepository { private final LoadingCache>caches; public AbstractCacheRepository() { Type mySuperClass = this.getClass().getGenericSuperclass(); Type type = ((ParameterizedType)mySuperClass).getActualTypeArguments()[0]; AnnotationEntityMaker maker = new AnnotationEntityMaker(); EntityMapping entityMapping = maker.make((Class) type); CacheLoader> loader = new CacheLoader>() { @Override public DataWrapper load(K entityId) throws Exception { return new DataWrapper(this.load(entityId, entityId)); } //根据不同的访问接口访问数据 public T load(Serializable roleId, K entityId) { return this.getDataAccessor(roleId).load(entityMapping, roleId, entityId); } public DataAccessor getDataAccessor(SerializableroleId) { return DataContext.getDataAccessorManager().getDataAccess(roleId); } }; caches = CacheBuilder.newBuilder().expireAfterAccess(300, TimeUnit.SECONDS).build(loader); } public T cacheLoad(K entityId) { return this.load(entityId); } private T load(K entityId) { return caches.getUnchecked(entityId).getEntity(); } } ​ 1) 玩家在游戏本服,获取Role数据,通过RoleRepository.cacheLoad(long roleId),先从Cache里读取,没有,则调用访问器MySQLDataAccessor.load(EntityMapping em,Serializable roleId, K id)从数据库读取数据。 ​ 2) 玩家在跨服,获取Role数据,通过RoleRepository.cacheLoad(long roleId),先从Cache里读取,没有,则调用访问器NetworkDataAccessor.load(EntityMappingem, Serializable roleId, K id),通过RPC远程同步调用读取数据session.sendRPCWithReturn(),该方法的实现可以参考上述的RpcClient.sendWithReturn(),相类似。 ​ 关于被动拉取的优缺点介绍,在下文另有论述。总之,由于被动拉取的一些我们始料未及的缺陷存在,成为了我们服务器端开发部分功能的噩梦,从选择该模式时就埋下了一个天坑。 3.2.2主动推送模式(Push) 为了解决了上面碰到的一系列问题, 并且还能坚持最初的原则,我们做了如下几点优化 优化方案有如下几点: 1.如果玩家在本服,和调整前一样的处理流程,如果玩家在跨服,客户端请求的指令,发布的事件,异步事件需要在场景Stage线程处理的,就转发到跨服,需要在其他个人业务线程(bus),公共业务线程(public)处理的,仍旧在本服处理。 2.场景业务线程不再允许有DB操作 3.内部指令的转发、事件分发系统、异步事件系统要在底层支持跨服 4.玩家在登录本服时就会构PlayerTemplate, 场景用到的数据会实时更新,玩家去跨服,则会把场景中用到的数据PlayerTemplate主动推送给跨服。 图8、主动推送模式 ​ 主动推送模式图示显示如图8所示: 方案对比 基本参数 被动拉取模式 主动推送模式 改动工作量 既实现了原先的既定目标,改动成本基本为零,对于进度紧张的项目来说,是个极大的诱惑 需屏蔽在Stage线程中针对DB的CRUD操作,构建PlayerTemplate而引发的一系列改动 服务器之间的内部指令和事件分发量 由于个人业务数据和场景业务数据都在跨服处理,所以不需要进行跨进程通信 对于服务器之间内部指令,事件分发增加了一定的量 数据中心问题 数据中心进行了转移,把本服的数据更新给锁住。如果部分数据没锁住,就会导致数据的不同步,或者说,本服数据做了更新而导致回档的风险。而如果跨服宕机,则有5分钟的回档风险 不变不转移,从根本上规避了数据回档的风险 通信数据量 大量数据的迁移,比如要获得一个道具,需要把这个玩家的所有的道具的数据从本服迁移到跨服,大大增加的了数据的通信量 只把跨服所需要的场景数据推送过去,数据量大大降低 用户体验 为了不让一些游戏数据回档,我们不得不对某些功能做显式屏蔽,但这样带来的体验就很不好,当跨服后,点击获取邮件,会显示你在跨服不允许获取提取附件;屏蔽公会的操作,比如公会捐献,公会领工资,因为不可能把整个公会的数据给同步到跨服中 所有的功能都不会被屏蔽 开发活动的难易度 由于每个游戏区的活动系统(开服活动,和服活动,节日活动,商业化冲KPI的活动)的差异性,给编码带来了很大复杂性。 涉及到的所有商业化活动的功能开发和本服一样简单 充值问题 充值回调都是到游戏区本服,那怎么办呢,就必须同步这个数据到跨服 在处理充值回调时不用再考虑是否在跨服 RPC性能问题 因为要跨服从本服拉取数据,这个请求必须是同步的,所以同步的RPC请求的频繁导致了跨服性能的降低,特别是当某个跨服活动刚开启时,有很多玩家涌入这个场景,会发生很多同步请求(role,item,skill,horse,pet,achievement…),导致部分玩家的卡在跨服场景跳转过程中,具体实现请参考上述同步请求代码实现sendWithReturn 去掉了跨服从游戏服拉数据的需求,改成了跨服时本地推送一次场景需要用得到的数据,基本去掉了99%同步RPC请求。 消息转发量 需要把所有玩家的请求都转发到跨服,转发量非常大,60+%的消息其实是没必要转发到跨服去处理的 除了场景上的操作的Action请求,不需要再被转发到跨服去执行,极大的降低了消息的转发量 看下事件分发代码的改造: /**事件分发器*/ public abstract class AbEvent { private static AtomicLong seq = new AtomicLong(System.currentTimeMillis()); /**事件订阅*/ public abstract void subscribe(); /**事件监听器*/ protected abstract List getHandlerPipeline(); /**事件分发*/ protected void dispatch() { id = seq.incrementAndGet(); List handlerList = this.getHandlerPipeline(); DispatchEventReq req = new DispatchEventReq<>(); req.setRoleId(roleId); req.setEntity(this); for (HandlerWrapper wrapper : handlerList) { byte group = wrapper.getGroup(); if (group == 0) { // 同线程串行执行 eventManager.syncCall(wrapper, this); } else { // 非同线程异步执行,可能去远程执行 this.advancedAsyncCall(req, wrapper); } } } } /** 跨服接收消息分发的事件 */ @Override public void dispatchEvent(Session session, DispatchEventReq msg) { T event = msg.getEntity(); List list = msg.getHandlerList(); long roleId = msg.getRoleId(); for (String e : list) { HandlerWrapper wrapper = eventManager.getHandlerWrapper(e, event); eventManager.asyncCall(roleId, wrapper, event); } } ​ 如下图,举个例子,在跨服怪物死亡后,会抛出 MonsterDeadEvent事件,在跨服进程直接处理场景的监听对应的逻辑: 场景中道具掉落,尸体处理;其他的监听逻辑抛回游戏服处理,根据这事件,任务模块处理完成任务,获得奖励;成就模块处理完成成就,获得奖励; 主角模块获得经验,金币等奖励;活动模块处理完成活动,获得奖励。 图9、杀怪事件分发 3.3 其他方面的优化 3.3.1 消息组播机制 消息组播的优化,在跨服,来自同一服的全部玩家广播从分别单独消息转发,改成一个消息发回本服,然后再广播给玩家(比如来自同一个服n个玩家,原本广播一条消息,服务器之间之间要处理n个RPC消息,现在只需要处理1个消息,降到了原先的1/n) 图10、消息组播机制 3.3.2 通信数据量 一个完整的PlayerTemplate模版数据由于包含了玩家在场景里用到的所有数据,比如角色、宠物、坐骑、装备、神器、法宝、时装、技能、翅膀等等,数据量比较大,平均能达到5KB左右,需要在服务器之间传输时做zlib压缩,比如,做了压缩后,11767 Byte的玩家数据能压缩到2337Byte,压缩率可达到19.86%。 3.3.3 序列化/反序列化 改造前,所有的请求都需要先在本服做AMF3反序列化,如果请求是需要转发到跨服的,再通过JSON序列化传输给跨服,在跨服通过JSON反序列化,最终该请求被处理。 但实际上,中间过程JSON序列化和反序列化似乎是没有必要的,经过改造,对需要转发给跨服的请求,在本服先不做AMF3反序列化,发送到跨服后再处理,这样就少了一次JSON的序列化和反序列化,同时收益了另外的一个好处:降低了传输的字节 图12、占用字节对比 图11、序列化和反序列化 3.3.4 内存占用优化 Oracle JVM目前只能在JVM停止运行的时候才能做到释放占有内存,直到下次重启,所以为了防止资源浪费,各种类型的跨服服务器,游戏服务器都需要设置不同的启动参数。启动参数的设定根据我们自行设置的公式,如下所示。 但内存占用仍然会经常突破预警线90%,由于一旦系统分配内存发现不够时,就会触发自我保护机制,进行OOM killer,所以需要预留很大的内存给每个JVM进程,而且每次维护的时候去脚本修改内存也比较麻烦。 ​ 内存占用状况如上图,服务器更新维护后,内存占用一路上扬,一直到最后维持在一定的值,不会回收,除非等下次维护或者系统触发OOM killer。 ​ 基于阿里 JVM 1.8,只要开启-XX:+DeallocateHeapPages,CMS能在不重启应用的情况下把不使用的HEAP归还给系统的物理内存,这样,我们就不需要预留很多空间给JVM,再也不用担心被误杀了。 ​ 拿我们一款内测阶段的游戏举例,使用了ALI JVM后, 64内存配置的机器最后开到了24个新区,相比起以前64G内存的机器,单台只能放9个独立的游戏区的状况下,单区的成本将会节省62.5% 机器资源,非常可观。完美的解决了内存经常吃紧的问题,并大幅节省了成本。 ​ 上图就是使用了Ali JDK后的锯齿形效果,每隔一定时间闲置内存会被系统回收,这在Oracle JVM是难以做到的。 3.3.5 服务器分组机制 不定向跨服是指任意游戏区的玩家都有可能匹配到一起进行游戏玩法的体验,比如跨服战场,比如跨服副本匹配,如右图所示: 图15、服务器未分组前 ​ 如何在游戏正式大区中选择几个服做灰度服,又不影响不定向跨服体验;以及如何解决新老服玩家战力发展不在同一起跑线而导致的不平衡问题曾一度让人纠结。 图16、服务器分组后 比如游戏产品推出了大型资料片,想先做下灰度测试,让1~4区的玩家先做下新功能的体验,同时又能防止玩家穿了一件旧版本不存在的装备而在跨服环境下报异常,根据运营需求通过分组,就很完美的解决了上述问题。 3.3.6 战区自动分配机制* 图17、战区自动分配 ​ 调整后,每一种基于战区的跨服类型都可以自定义调整时间间隔,到时间点全局服务器(global server)系统自动根据全区的活跃战力匹配进行调整,让运营人员从繁杂的配置中解脱出来。定向跨服是指在一定时间内会固定参与跨服玩法的几个国家,常用于战区中国家之间对战,如右图所示,需要运营在后台配置;当一段时间后,随着玩家流失,又需要运营根据战力进行战区的调整,对运营人员的要求比较高 3.3.7 跨服断线重连机制 比如战场系统或组队副本,由于网络状况而掉线,如果重新登录后,没法进入,将会严重影响战场的战况,顺风局马上就可能会变成逆风局,主力DPS掉线副本就有可能通不了,这个机制就弥补了这块的缺陷。 4.支持的玩法 ​ 目前,我们已经能支持任意的游戏区玩家可以到任意的跨服服务器进行游戏功能的体验。比如已经实现的跨服组队副本、跨服战场、跨服国战、跨服皇城争夺、跨服资源战、虫群入侵战、跨服押镖、挖矿争夺等。 ​ 也支持玩家在本服就可以进行跨服互动,比如和别的区的玩家聊天、加好友、送礼等无缝交互,及国家拍卖行,世界拍卖行的跨服贸易。 ​ 甚至支持玩家穿越到另外的游戏区做任意的游戏体验,比如一区的玩家听说二区服在举行抢亲活动, ​ 你可以跑到2区去观赏参与,也跑到任意的区的中央广场去显摆你的极品套装。 5.跨服在线数据 ​ 如图18,跨服定向玩法有战区国家玩法,虫群入侵,跨服押镖,挖矿争夺, 跨服皇城争夺,跨服国战等,如下图所示,我们可以看出这种玩法的规律:每次活动开启,跨服就会迎来一波波玩家涌入,活动一结束,玩家就会离开,4个跨服进程支持了7600在线的玩家。 图18、定向跨服在线图 ​ 如图19,跨服非定向性玩法有跨服组队副本,跨服战场等,支持负载均衡,可以随时动态增加跨服。如右图所示,这些玩法的规律是24小时随时可以体验进入,在线比较稳定,8个跨服进程支持了28000在线的玩家。 图19、不定向跨服在线图 ​ 图20是游戏某个跨服玩法的截图,可以看出,该游戏当时具有很高的人气。当时的最高DAU为650467,最高PCU为143319 图20、跨服玩法在线截图 ​ 图21为跨服通信拓扑图,属于整体架构的核心部分,关于这一部分的说明见图表:6 技术架构 图21、游戏服&跨服通信拓扑图 服务器种类 说明 游戏逻辑服务器 Game Server 1.网关,跟玩家保持连接, 提供对外访问,转发消息,直接与客户消息交互; 2.协议的加密解密,压缩解压缩 3.游戏逻辑服务器,核心逻辑的实现都在这里; 4. Game 会缓存玩家的数据,定时持久化更新的数据到数据库,而对于非在线玩家,用LRU算法; 5.不同Game server间可以跨区通信,跨区加好友,聊天等 6.和全局服务器进行RPC 通信,进行递交申请匹配等请求 7.和跨服服务器进行RPC 通信,承担跨服后的指令转发 跨服服务器Cross Server 处理跨服相关的逻辑,任意区的玩家可以到达到任意的的跨服服务器, 根据负载压力无限动态扩展 全局服务器 Gobal Server 控制跨服服务器的负载均衡,处理要跨服的玩家的匹配处理,分配跨服房间等 Redis 做战区的Pub/Sub服务 关于整体架构的介绍,后续的文章会和大家分享。 6.小结 此套架构历经了《大闹天宫OL》、《诸神黄昏》、《暴风王座》、《惊天动地》,《三打白骨精》、《英雄领主》、《封神霸业》等先后近两万组服务器运行的验证和团队的技术积累。 图22、我们的游戏产品 ​ 本文从当前游戏市场发展的背景出发,提出了设计自由交互的“跨域体系”的必要性,然后在实现跨服架构过程中对设计目标、原则、存在的技术难点进行了思考,实现了一套用于跨服通信的高吞吐的RPC通信框架,先后体验了被动拉取模式带来的坑,和改成主动推送模式带来的便利。并且,对该架构设计在消息组播,通信量,消息序列化/反序列化,服务器分组,战区自动分配,断线重连等进行了多方面机制的分析及深度优化,最后上线实践做了可行性验证,提供了强有力的数据支持,总体表现稳定流畅。 如需下载本站全部技术文章,可以在【高性能服务器开发】公众号回复关键字“文章下载”即可。最近更新时间: 2020-10-08 00:03:27 "},"articles/程序员面试题精讲/":{"url":"articles/程序员面试题精讲/","title":"程序员面试题精讲","keywords":"","body":"程序员面试题精讲 腾讯后台开发实习生技能要求 聊聊如何拿大厂的 offer 网络通信题目集锦 我面试后端开发经理的经历 Linux C/C++后端开发面试问哪些问题 如需下载本站全部技术文章,可以在【高性能服务器开发】公众号回复关键字“文章下载”即可。最近更新时间: 2020-10-08 12:36:16 "},"articles/程序员面试题精讲/腾讯后台开发实习生技能要求.html":{"url":"articles/程序员面试题精讲/腾讯后台开发实习生技能要求.html","title":"腾讯后台开发实习生技能要求","keywords":"","body":"腾讯后台开发实习生技能要求 如题,应届生除了要良好地掌握算法和数据结构以外,以下一些技能点列表希望对大家有帮助,有兴趣的朋友可以参考这个针对性地补缺补差。文章列出的技能点有的要求熟悉,有的了解即可,注意技能点前面的修饰词。如果没有明确给出“熟悉”“了解”等字眼,要求均为熟悉。 一、操作系统方面 多线程相关与线程之间同步技术 熟练使用(但不局限于)以下linux API linux下的线程创建、等待、获取线程id int pthread_create(pthread_t *thread, const pthread_attr_t *attr, void *(*start_routine) (void *), void *arg); int pthread_join(pthread_t thread, void **retval); pthread_t pthread_self(void); 常见线程之间的同步技术(何时该用那种技术) 互斥体 int pthread_mutex_init(pthread_mutex_t *mutex, const pthread_mutexattr_t *mutexattr); int pthread_mutex_destroy(pthread_mutex_t *mutex); int pthread_mutex_lock(pthread_mutex_t *mutex); int pthread_mutex_trylock(pthread_mutex_t *mutex); int pthread_mutex_unlock(pthread_mutex_t *mutex); 信号量 int sem_init(sem_t *sem, int pshared, unsigned int value); int sem_destroy(sem_t *sem); int sem_wait(sem_t *sem); int sem_post(sem_t *sem); int sem_getvalue(sem_t *sem, int *valp); 条件变量 int pthread_cond_init(pthread_cond_t *restrict cond, const pthread_condattr_t *restrict attr); int pthread_cond_destroy(pthread_cond_t *cond); int pthread_cond_signal(pthread_cond_t *cond); int pthread_cond_broadcast(pthread_cond_t *cond); int pthread_cond_wait(pthread_cond_t *restrict cond, pthread_mutex_t *restrict mutex); int pthread_cond_timedwait(pthread_cond_t *restrict cond, pthread_mutex_t *restrict mutex, const struct timespec *restrict abstime); 读写/自旋锁 int pthread_rwlock_init(pthread_rwlock_t *restrict rwlock, const pthread_rwlockattr_t *restrict attr); int pthread_rwlock_destroy(pthread_rwlock_t *rwlock); int pthread_rwlock_rdlock(pthread_rwlock_t *rwlock); int pthread_rwlock_tryrdlock(pthread_rwlock_t *rwlock); int pthread_rwlock_wrlock(pthread_rwlock_t *rwlock); int pthread_rwlock_trywrlock(pthread_rwlock_t *rwlock); int pthread_rwlock_unlock(pthread_rwlock_t *rwlock); //这两个函数在Linux和Mac的man文档里都没有,新版的pthread.h里面也没有,旧版的能找到 int pthread_rwlock_timedrdlock_np(pthread_rwlock_t *rwlock, const struct timespec *deltatime); int pthread_rwlock_timedwrlock_np(pthread_rwlock_t *rwlock, const struct timespec *deltatime); int pthread_spin_init (__pthread_spinlock_t *__lock, int __pshared); int pthread_spin_destroy (__pthread_spinlock_t *__lock); int pthread_spin_trylock (__pthread_spinlock_t *__lock); int pthread_spin_unlock (__pthread_spinlock_t *__lock); int pthread_spin_lock (__pthread_spinlock_t *__lock); 熟悉守护进程的创建、原理 了解计划作业crontab 熟悉进程、线程状态查看命令(top、strace、pstack) 熟悉内存状态查看命令memstat、free 熟悉IO状态查看命令iostat、df、du 了解linux文件的权限、用户、时间(ctime、mtime、atime)、inode等文件基本属性,熟练使用chmod、chown、chgrp等基本命令。 熟悉文件传输命令scp、rz、sz命令、 熟悉文件定位命令find、whereis命令。 熟悉软链接,熟悉ln命令。 熟悉lsof命令。 二、网络 熟悉tcp状态机(三次握手、四次挥手)。 熟悉tcpdump命令。 熟悉网络状态和防火墙状态查看命令:netstat、ifconfig、iptables 熟悉socket API,包括但不限于(connect、accept、bind、listen、send/sendto、recv/recvfrom、select、gethostbyname) int connect(int sockfd, const struct sockaddr *addr, socklen_t addrlen); int accept(int sockfd, struct sockaddr *addr, socklen_t *addrlen); int bind(int socket, const struct sockaddr *address, socklen_t address_len); int listen(int sockfd, int backlog); ssize_t send(int sockfd, const void *buf, size_t len, int flags); ssize_t sendto(int sockfd, const void *buf, size_t len, int flags, const struct sockaddr *dest_addr, socklen_t addrlen); ssize_t recv(int sockfd, void *buf, size_t len, int flags); ssize_t recvfrom(int sockfd, void *buf, size_t len, int flags, struct sockaddr *src_addr, socklen_t *addrlen); int select(int nfds, fd_set *readfds, fd_set *writefds, fd_set *exceptfds, struct timeval *timeout); void FD_CLR(int fd, fd_set *set); int FD_ISSET(int fd, fd_set *set); void FD_SET(int fd, fd_set *set); void FD_ZERO(fd_set *set); struct hostent *gethostbyname(const char *name); 熟悉epoll,熟悉水平触发与边缘触发。 int epoll_create(int size); int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event); int epoll_wait(int epfd, struct epoll_event *events, int maxevents, int timeout); 熟悉阻塞socket和非阻塞socket在connect、send、recv等行为上的区别,如何将socket设置为非阻塞的。 三、脚本工具 了解shell基本语法、变量操作、函数、循环/条件判断等程序结构。 熟练使用文本编辑工具vi/vim。 了解使用文本处理命令grep、sed、cut。 了解awk命令。 四、数据库 熟悉数据表结构设计(三范式、字段属性)。 了解查询优化(索引的概念与创建、sql优化)。 熟悉常见的mysql API函数: mysql_real_connect mysql_select_db mysql_query mysql_store_result mysql_free_result mysql_num_rows mysql_close mysql_errno 五、编程语言 C/C++方面 熟悉内存分布(堆、栈、静态/全局/局部变量、虚指针…) 熟悉Makefile。 熟悉gdb调试(断点、查看内存、执行跟踪、了解CPU主要寄存器作用…)。 熟悉性能分析工具(gprof)。 熟悉C-Runtime常用函数(如字符串格式化函数printf、scanf,字符串比较连接函数、内存分配函数、文件与目录操作函数等)。 熟悉stl库。 熟悉OO思想、常见设计模式(如单例模式、工厂设计模式、装饰者模式、Builder模式、生产者消费者模式、策略模式等)。 熟悉RAII、pimpl惯用法。 有一定的代码质量和重构能力。 文章版权所有,转载请保留文章末尾版权信息和公众号信息。 如需下载本站全部技术文章,可以在【高性能服务器开发】公众号回复关键字“文章下载”即可。最近更新时间: 2020-10-03 14:46:45 "},"articles/程序员面试题精讲/聊聊如何拿大厂的offer.html":{"url":"articles/程序员面试题精讲/聊聊如何拿大厂的offer.html","title":"聊聊如何拿大厂的 offer","keywords":"","body":"聊聊如何拿大厂的 offer 为什么要进大厂 许多读者,尤其是一些学生朋友在找我聊职业规划和职场困惑时,我给的建议就是,如果你是应届生或者工作年限较短(五年以下),那一定要找个机会去大厂工作几年。 无论是出于所谓的“镀镀金”的心理,还是想去大厂挑战大业务量、接触高并发、提高技术、开阔视野,都是非常值得的。 虽然很多大厂都加班,但是作为工薪阶层的一员,哪里不加班呢?再者大厂的各项规章制度和福利待遇都比较完善,你可以见识到很多成熟的系统和优秀的做法和理念。 就福利待遇来说,大厂给的薪资待遇比一般的小公司给的要高上一截。就算你从大厂离职,你也可以很容易的涨薪去另外一家大厂。这些都是小公司的没有的优势(我这里并不是说小公司不好)。 由于刚毕业的时候,没有能够进大厂,导致起点和平台都比同时间进大厂的同学低许多。虽然最终通过自己的努力,从刚毕业时的月薪 5 千到现在的年薪 50 W+。 这期间我走了很多弯路和吃了很多苦头。以工资收入来说,未进大厂的,可能在社会上摸爬滚打好多年才勉强达到月薪 2~3 万,而进大厂可能工作一两年就够了,甚至有些大厂开出的 SSP 直接就有三五十万。 因此,如果你一毕业就进入了大厂,那么你的第一份工作的收入、起点和视野就会比同龄人高很多。这也是我苦口婆心地劝毕业生们在毕业前夕的那段日子里面咬咬牙,努力去拿个大厂的 Offer 的原因。 进入大厂的难点在哪里 虽然大厂很好,但是进大厂对个人资质、个人素养和技术水平都有一定的要求,并不是每个人都有机会的。这里说的个人资质,如学历和毕业院校的层次。 一般大厂都只接收本科及本科以上的学历,对于本科以下的学历的应届生一般都不会考虑。而且会优先选择学校层次还不错的毕业生。 也就是说对于应届生,学校和学历成了硬性要求。即使你的能力再强,HR 筛选简历时就已经把你给 pass 掉了,你根本没有面试的机会。 高考已经没考好了,这个已经成为既成事实了。那对于学历和学校不好的人,还有机会补救吗? 有的,通过社招。 也就是说,你可以先工作几年,再尝试去大厂面试。因为社招更多的是看重的是你的技术水平、工作经验等,对学历要求没那么高了。 如何进入大厂 无论是应届生还是工作几年的人,一般都需要通过技术面试才能进入大厂。 那么大厂技术面试一般会哪些问题呢?除了少部分相关的技术外,重头戏都是算法与数据结构。 说到算法和数据结构这门学科,很多人尤其是已经工作了几年的社会人士,用范玮琪的一句歌词来形容,那真是“那一些是非题,总让人伤透脑筋”。 大家常学常忘,但为了面试,尤其是大厂面试,所以不得不学。 很多人对算法和数据结构这门课,甚至存在这样一个误解:实际工作中根本用不到算法,只有面试才会用到。产生这种错觉的原理,莫外乎此人技术不够资深、水平不够好,无缘参与核心开发而已。 学好算法和数据结构,无论对从技术水平长远的发展来说,还是对个人逻辑思维锻炼都是大有裨益的。 国内的大厂面试,基本上大多数问题都是各种算法和数据结构题,而国外的大厂,像 Google、Facebook、微软等等,基本上百分之百是算法和数据结构题目。 很多应届毕业生横扫各大大厂 Offer,很大一部分原因是因为算法和数据结构掌握的好,当然薪资也非常可观。社会人士虽然在面试大厂时对相关的项目有一定的工作经验,没有像应届生要求那么高,但是最基础最常用的算法和数据结构还是要熟悉的。 说了这么多,那么大厂面试到底要求哪些算法和数据结构知识?我根据我面试的经验,给大家整理了一个清单: 排序(常考的排序按频率排序为:**快速排序 > 冒泡排序 > 归并排序 > 桶排序)** 一般对于对算法基础有要求的公司,如果你是应届生或者工作经验在一至三年内,以上算法如果写不出来,给面试官的印象会非常不好,甚至直接被 pass 掉。 对于工作三年以上的社会人士,如果写不出来,但是能分析出其算法平均、最好和最坏的情况下的复杂度,说出算法大致原理,在多数面试官面前也可以过的。注意,如果你是学生,写不出来或者写的不对,基本上面试就过不了。 二分查找 二分查找的算法尽量要求写出来。当然,大多数面试官并不会直接问你二分查找,而是结合具体的场景,例如如何求一个数的平方根,这个时候你要能想到是二分查找。 我在 2017 年年底,面试 agora 时,面试官问了一个问题:如何从所有很多的 ip 地址中快速找个某个 ip 地址。 链表 无论是应届生还是工作年限不长的社会人士,琏表常见的操作一定要熟练写出来,如链表的查找、定位、反转、连接等等。还有一些经典的问题也经常被问到,如两个链表如何判断有环(我在 2017 年面试饿了么二面、上海黄金交易所一面被问过)。 链表的问题一般不难,但是链表的问题存在非常多的“坑”,如很多人不注意边界检查、空链表、返回一个链表的函数应该返回链表的头指针等等。 队列与栈 对于应届生来说一般这一类问的比较少,但是对于社会人士尤其是中高级岗位开发,会结合相关的问题问的比较多,例如让面试者利用队列写一个多线程下的生产者和消费者程序,全面考察的多线程的资源同步与竞态问题(下文介绍多线程面试题时详细地介绍)。 栈一般对于基础要求高的面试,会结合函数调用实现来问。即函数如何实现的,包括函数的调用的几种常见调用方式、参数的入栈顺序、内存栈在地址从高向低扩展、栈帧指针和栈顶指针的位置、函数内局部变量在栈中的内存分布、函数调用结束后,调用者和被调用者谁和如何清理栈等等 某年面试京东一基础部门,面试官让写从 0 加到 100 这样一个求和算法,然后写其汇编代码。 哈希表 哈希表是考察最多的数据结构之一。常见的问题有哈希冲突的检测、让面试者写一个哈希插入函数等等。基本上一场面试下来不考察红黑树基本上就会问哈希表,而且问题可浅可深。 我印象比较深刻的是,当年面试百度广告推荐部门时,二面问的一些关于哈希表的问题。 当时面试官时先问的链表,接着问的哈希冲突的解决方案,后来让写一个哈希插入算法,这里需要注意的是,你的算法中插入的元素一定要是通用元素,所以对于 C++ 或者 Java 语言,一定要使用模板这一类参数作为哈希插入算法的对象。 然后,就是哈希表中多个元素冲突时,某个位置的元素使用链表往后穿成一串的方案。 最终考察 Linux 下 malloc(下面的 ptmalloc) 函数在频繁调用造成的内存碎片问题,以及开源方案解决方案 tcmalloc 和 jemalloc。 总体下来,面试官是一步步引导你深入。(有兴趣的读者可以自行搜索,网上有很多相关资料) 树 面试高频的树是红黑树,也有一部分是 B 树(B+ 树)。 红黑树一般的问的深浅不一,大多数面试官只要能说出红黑树的概念、左旋右旋的方式、分析出查找和插入的平均算法复杂度和最好最坏时的算法复杂度,并不要写面试者写出具体代码实现。 一般 C++ 面试问 stl 的 map,java 面试问 TreeMap 基本上就等于开始问你红黑树了,要有心里准备。笔者曾经面试爱奇艺被问过红黑树。 B树一般不会直接问,问的最多的形式是通过问 MySQL 索引实现原理。笔者面试腾讯看点部门二面被问到过。 图 图的问题我在面试三星电子时就有一道面试题就是深度优先和广度优先问题。 其他的一些算法 如 A* 寻路、霍夫曼编码也偶尔会在某一个领域的公司的面试中被问到,我在面试宝开(《植物大战僵尸》的母公司)就被问到过。 如需下载本站全部技术文章,可以在【高性能服务器开发】公众号回复关键字“文章下载”即可。最近更新时间: 2020-10-03 14:42:08 "},"articles/程序员面试题精讲/网络通信题目集锦.html":{"url":"articles/程序员面试题精讲/网络通信题目集锦.html","title":"网络通信题目集锦","keywords":"","body":"网络通信面试题集锦 TCP/IP协议栈层次结构 TCP三次握手需要知道的细节点 TCP四次挥手需要知道的细节点(CLOSE_WAIT、TIME_WAIT、MSL) TCP与UDP的区别与适用场景 linux常见网络模型详解(select、poll与epoll) epoll_event结构中的epoll_data_t的fd与ptr的使用场景 Windows常见的网络模型详解(select、WSAEventSelect、WSAAsyncSelect) Windows上的完成端口模型(IOCP) 异步的connect函数如何编写 select函数可以检测网络异常吗? epoll的水平模式和边缘模式 如何将socket设置成非阻塞的(创建时设置与创建完成后设置),非阻塞socket与阻塞的socket在收发数据上的区别 send/recv(read/write)返回值大于0、等于0、小于0的区别 如何编写正确的收数据代码与发数据代码 发送数据缓冲区与接收数据缓冲区如何设计 socket选项SO_SNDTIMEO和SO_RCVTIMEO socket选项TCP_NODELAY socket选项SO_REUSEADDR和SO_REUSEPORT(Windows平台与linux平台的区别) socket选项SO_LINGER shutdown与优雅关闭 socket选项SO_KEEPALIVE 关于错误码EINTR 如何解决tcp粘包问题 信号SIGPIPE与EPIPE错误码 gethostbyname阻塞与错误码获取问题 心跳包的设计技巧(保活心跳包与业务心跳包) 断线重连机制如何设计 如何检测对端已经关闭 如何清除无效的死链(端与端之间的线路故障) 定时器的不同实现及优缺点 http协议的具体格式 http head、get与post方法的细节 http代理、socks4代理与socks5代理如何编码实现 ping telnet 关于以上问题的答案,有兴趣可以参考我的知乎live:https://www.zhihu.com/lives/922110858308485120 或者如果你有任何不明白的地方,可以加我微信 easy_coder 交流。 如需下载本站全部技术文章,可以在【高性能服务器开发】公众号回复关键字“文章下载”即可。最近更新时间: 2020-10-03 14:49:11 "},"articles/程序员面试题精讲/我面试后端开发经理的经历.html":{"url":"articles/程序员面试题精讲/我面试后端开发经理的经历.html","title":"我面试后端开发经理的经历","keywords":"","body":"我面试后端开发经理的经历 我去年12月份从上一家公司离职,一直到今年3月份,基本上都在面试中度过来的。 先交代下背景:坐标上海,做技术开发,我本人面试的职位是linux服务器开发,最倾向的职位是服务器开发主程或技术经理。我本人也是上几家公司的面试官,因为接下来几年面临着成家,技术上也到了瓶颈期,虽然拿了不少offer,但是想综合比对一下,于是就参加了很多的面试。我先后去了如下一些公司:腾讯、百度、饿了么、爱奇艺、360、携程网、京东、华为、bilibili、上海黄金交易所、东方财富网、zilliz、掌门集团(做无线万能钥匙的那一家)、喜马拉雅听书、峰果网络、华尔街新闻、万得财经、汇正财经、逗屋网络、朝阳永续,还有数家小规模的公司或创业公司吧。 为了避免引起不必要的纠纷,下面我就不说具体的公司名称了。技术面试的细节我尽量写的详细一点,希望对大家有参考价值,技术面试大致有三种情形: 经验分享 一、以百度、爱奇艺等为代表的,以数据结构和算法为主。 首先是简单地了解下你之前的工作经历和项目经验,然后就是算法和数据结构题目,具体涉及到以下内容: 01 快速排序 快速排序(包括算法步骤、平均算法复杂度、最好和最坏的情形),有人说校招要把算法写出来,我是社招,所以描述一下算法步骤即可。 02 二分查找算法 写二分查找算法,这个尽管是社招,但是一般也不难,所以要求面试者写出来。但是很多公司,比如不会直接让你写算法,而是结合一个具体场景来提问,然后让你自己联想到二分查找,比如求一个数的平方根。 03 链表 链表,常见的面试题有写一个链表中删除一个节点的算法、单链表倒转、两个链表找相交的部分,这个一般必须得完全无误的情况下写出来。 04 自己实现一些基础的函数 自己实现一些基础的函数,例如strcpy / memcpy / memmov / atoi,同样的道理,这些必须完全无误且高效地写出来,比如你的实现中会动态分配堆内存,那么这道题目就算答错。 第3点和第4点的门道一般在于考察你的代码风格、对边界条件的处理,比如判断指针是否为空,千万不要故意不考虑这种情形,即使你知道也不行,只要你不写,一般面试官就认为你的思路不周详,容错率低;再比如,单链表的倒转,最后的返回值肯定是倒转后的链表头结点,这样才能引用一个链表,这些都是面试官想考虑的重点。 05 哈希表 哈希表,对哈希表的细节要求很高,比如哈希表的冲突检测、哈希函数常用实现、算法复杂度;比如百度二面就让我写一个哈希表插入元素算法,元素类型是任意类型。 06 AVL树和B树的概念、细节 AVL树和B树的概念、细节,比如会问mysql数据库的索引的实现原理,基本上就等于问你B树了。 07 红黑树 红黑树,这个基本上必问的一个数据结构,包括红黑树的概念、平均算法复杂度、最好最坏情况下的算法复杂度、、左右旋转、颜色变换。面试官常见的算法套路有:你熟悉C++的stl吗?你说熟悉,ok,stl的map用过吧?用过,ok,那map是如何实现的?红黑树,ok,那什么是红黑树?这样提问红黑树就开始了。Java的也类似。 二、以饿了么、bilibli、喜马拉雅、360、携程等为代表的,兼顾算法数据结构和其他开发技术。 算法和数据结构部分上文提过了,下面提一下其他技术,大致包括以下东西: 01 基础的C++问题 以C++语言为例(不是C++开发的朋友可以跳过这一点),第一类是基础的C++问题,常见的有C++的继承体系中virtual关键字的作用(如继承关系中析构函数为什么要申明成virtual函数,如果不申明为virtual会有什么影响)、在涉及到父子类时构造与析构函数的执行顺序、多重继承时类的成员列表在地址空间的排列;static关键字的作用,static_cast / reinterpret_cast / dynamic_cast等几个转换符的使用场景;问的最多的就是虚表的布局,尤其是菱形继承(B和C继承A,D继承B和C)时每个对象的空间结构分布,比如问D有几份虚表,D中B和C的成员空间排布。 另外,如果你应聘的职位使用C++开发,很多公司会问你一些C++11的东西(或者问boost库,基本上都一样),这个你用过就用过,没有用过就说没用过不要装X,常见的C++11需要掌握的一些技术库我也列举一下吧(JAVA及其他语言的读者可以忽略): auto关键字、for-each循环、右值及移动构造函数 + std::forward + std::move + stl容器新增的emplace_back()方法、std::thread库、std::chrono库、智能指针系列(std::shared_ptr/std::unique_ptr/std::weak_ptr)(智能指针的实现原理一定要知道,最好是自己实现过)、线程库std::thread+线程同步技术库std::mutex/std::condition_variable/std::lock_guard等、lamda表达式(JAVA中现在也常常考察lamda表达式的作用)、std::bind/std::function库、其他的就是一些关键字的用法(override、final、delete),还有就是一些细节如可以像JAVA一样在类成员变量定义处给出初始化值。 02 网络通信问题 网络通信问题,比如协议栈的层级关系,三次握手和四次挥手的【细节】,注意我说的是细节,比如CLOSE_WAIT和TIME_WAIT状态(bilibili问了这样一个问题,你可以感受一下:A与B建立了正常连接后,从未相互发过数据,这个时候B突然机器重启,问A此时的tcp状态处于什么状态?如何消除服务器程序中的这个状态? 万得问过流量拥塞和控制机制、腾讯问过tcp和ip包头常见有哪些字段),阻塞和非阻塞socket在send、recv函数上的行为表现,异步connect函数的写法,select函数的用法,epoll与select的区别,基本上只要问到epoll,必问epoll的水平模式和边缘模式的区别;一些socket选项的用法,nagle / keepalive / linger等选项的区别;tcp / udp的区别和适用场景;通信协议如何设计避免粘包;http协议的get和post方法的区别(问的比较深的会让你画出http协议的格式,参照这篇文章中关于http协议格式的讲解:http://blog.csdn.net/analogous_love/article/details/72540130);**windows用户**可能会问到完成端口模型(IOCP),网络通信方面的问题,我专门开了一个知乎live系统地总结了一下,有兴趣的朋友可以看这里:https://www.zhihu.com/lives/922110858308485120 和 这里:https://www.zhihu.com/lives/902113324999778304。 总之,网络通信问题能搞的多清楚就可以搞的多清楚,最起码把tcp应用层的各种socket API的用法细节搞清楚。 03 操作系统原理性的东西 比如linux下elf文件的节结构,映射到进程地址空间后,分别对应哪些段,相关的问题还有,全局变量、静态存储在进程地址空间的哪里;堆和栈的区别,栈的结构,栈的细节一点要搞的特别清楚,因为一些对技术要求比较高的公司会问的比较深入,例如京东的一面是让我先写一个从1加到100的求和函数,然后让我写出这个函数的汇编代码(JAVA开发的同学可能会让你试着去写一点JVM的指令),如果你对栈的结构(如函数参数入栈顺序、函数局部变量在栈中的布局、栈帧指针和栈顶指针的位置)不熟悉的话,这题目就无法答对了;栈的问题,可能会以常见的函数调用方式来提问,常见的函数调用有如下cdecl/stdcall/thiscall/fastcall的区别,比如像printf这样具有不定参数的函数为什么不能使用__stdcall;还有就是进程和线程的联系与区别,问的最多的就是线程之间的一些同步技术,如互斥体、信号量、条件变量等(Windows上还有事件、临界区等),这些东西你必须熟悉到具体的API函数使用的层面上来,从另外一个角度来说,这是咱们实际工作中编码最常用的东西,如果你连这个都不能熟练使用,那么你肯定不是一个合格的开发者;这类问题还可以引申为什么是死锁、如何避免死锁;进程之间通信的常用技术也需要掌握,常用的通信方式(linux下)有共享内存、匿名和具名管道、socket、消息队列等等,管道和socket是两个必须深入掌握的考察点(与上面网络通信有点重复);linux系统下可能还会问什么是daemon进程,如何产生daemo进程,什么是僵尸进程,僵尸进程如何产生和消除(bilibili问过)。 CAS机制(饿了么二面问过)。 04 使用过的开源技术 第四类就是一个使用过的开源技术,比如代表nosql技术的redis;网络库libevent等等;数据库如mysql的一些东西。这个一般不做硬性要求,但是这里必须强调的就是redis,熟练使用redis甚至研究过redis源码,现在一般是做后台开发的技术标配或者说不可缺少的部分,基于redis的面试题既可以聊算法与数据结构,也可以聊网络框架等等一类东西。我面试的公司中基本上百分之九十以上都问到了redis,只是深浅不一而已,比如喜马拉雅问了redis的数据存储结构、rehash;bilibili问了redis的事务与集群。 三、只问一些做过的业务或者项目经验。 这类公司他们招人其实对技术要求不高(资深及主管级开发除外),只要你过往的项目与当前应聘职位匹配,可以过来直接上手干活就可以了,当然薪资也就不会给很多。比如游戏公司会关心你是否有某某类型的游戏开发经验、股票类公司会关心你是否有过证券或者交易系统的开发经验等。我的经验就是这类公司,能去的话可以去,不能去的话就当积累面试经验。业务开发哪里都能找到,真正的重视技术的公司,应该是广大做技术尤其是初中级开发的朋友应该值得关心的事情。 不靠谱型公司。 我遇到的大致有四类: 01 装X忽悠型 第一类:装X忽悠型 面试过程冗长繁琐,比如号称每一百份简历中才发一个面试邀请,号称每一百个面试者发一个offer,号称硅谷风格,我面试的有一家公司就是这个样子,先是一轮长长的电话面试,然后是五轮技术面试,前三轮是刷leetcode上原题,然后后几轮面试,面试官从基本的操作系统的中断、GDT、LDT、分表分页机制问到上层高并发海量数据的架构,说的不好听,真是从外太空聊到内子宫,最后问具体职位做什么时,要么遮遮掩掩要么原型毕露;或者讨论薪资时,要么面露难色要么各种画饼,但是实际就给不了多少薪水的。 02 佛性公司 第二类:佛性公司 面试下来,全程面试官面带微笑,问你的问题你回答的面试官也很赞同,但最后你就没通过,我猜测要么公司不是很缺人,想观望一下是否有合适的人才;要么招聘信息上开的薪资给不到。 03 老奶奶裹脚布型公司 第三类:老奶奶裹脚布型公司 其特点是面试周期长,往往第一轮面试通知你过了,让你回去等上十天半个月后,给你打电话通知你来第二轮面试,面试要求穿正装,带好各种证件,面试前必须先查验你的身份证、学历证学位证,甚至是四六级考试证等等,麻烦至极,即使你一路过关斩将过了终面,薪资也给不了多少。大家都是要养家糊口的,都是忙着找工作,谁有时间和你耗上十天半个月呢? 04 不尊重人类型公司 第四类:不尊重人类型公司 我这里说的不尊重人,不是指的是面试过程中对你人身攻击,而是不根据你的工作年限和经验随意安排面试官,举个例子,比如你工作十年,你去面试一个技术总监的职位,对方公司安排一个工作不满两年的部门职员作为面试官,这个面试官如果是走过场可以理解,但是非要和你纠结一个如二进制位移、现代编译器要不要在子类析构函数前加virtual关键字这些技术细节就没必要了。还有一类就是故意问一些刁钻的问题,或者全场都心不在焉、玩手机、漫不经心的面试官,比如问你tcp协议头有多少个字段,每个字段是干啥的。遇到这一类面试官我的经验就是要么婉拒,要么直接怼回去。 注意细节 下面再说下面试中需要注意的一些细节: 01 把目光放长远一点 第一,如果你的工作年限不长,尤其是渴望在技术方面有一定的造诣,那么你首先考虑的应该是新的单位是否能有利于你技术上的成长,而不是两份同样的工作,薪资上的上下相差的三五千、五六千。如果想转行的同学(比如从客户端转服务器,从C++转JAVA),不要因为薪资突然变低而拒绝这种阵痛,要把目光放长远一点。 02 可能最终会因为薪资达不到不被录取 第二,一些公司虽然招聘信息上写了最多能给到多少多少,但实际即使你全程面试下来都很完美,可能最终你也会因为薪资达不到不被录取。 03 多面试积累经验 第三,一些根本不想去的公司,如果你有时间的话,去面试积累下经验也不是什么坏事。 04 警惕技术天花板 第四,面试的时候,同时也是你在考察面试官,一般面试官问你的问题,你能回答出来的在百分之八十左右,这样的公司可以考虑去入职,你进去的话可能才会在技术上有一些提升。如果你全场秒杀面试官的题目,你的技术天花板可能也在那里。 05 聊清楚将来的职位内容 第五,面试的时候聊清楚你将来的职位内容,避免进去客串一些不想做的工作。 06 不会的面试题尝试去和面试官沟通 第六,遇到不会的面试题,不要直接就否定自己,可以尝试着去和面试官沟通一下,或者要求给点提示或者思路。 07 不要轻视笔试中的数学智力题 第七,不要轻视笔试中的一些数学智力题目,认真作答,试问算法不也是数学智力题吗? 08 自信点 第八,自信一点,每个人的经历和经验都是独一无二的,面试的时候,一些特定领域的问题,回答不出来也不要太在意。 希望对阅读的朋友有所帮助。因为个人经验能力有限,所说的也可能只是一家之言,说的不妥当的地方还请温和地提出建议,如果读者有任何问题可以加我微信 easy_coder 交流。 如需下载本站全部技术文章,可以在【高性能服务器开发】公众号回复关键字“文章下载”即可。最近更新时间: 2020-10-03 14:52:08 "},"articles/程序员面试题精讲/LinuxCC++后端开发面试问哪些问题.html":{"url":"articles/程序员面试题精讲/LinuxCC++后端开发面试问哪些问题.html","title":"Linux C/C++后端开发面试问哪些问题","keywords":"","body":"Linux C/C++后端开发面试问哪些问题 今天我的技术群(想加技术群的可以加我微信 easy_coder)里面一名叫“成都-go-戒炸鸡”的群友提出了他最近面试的一些面试题,面试题内容个人觉得非常典型、也非常有代表性和针对性,故拿出来与大家分享一下,也感谢他的分享。成都-go-戒炸鸡说: “今天面试,我没答出来的有redis持久化机制,redis销毁方式机制,mq实现原理,c++虚函数,hash冲突的解决,memcached一致性哈希,socket函数select的缺陷,epoll模型,同步互斥,异步非阻塞,回调的概念,innodb索引原理,单向图最短路径,动态规划算法。” 为了避免问题有歧义,面试题略有修改。 思路分析 从面试题的内容可以看出,这是一个后台开发的职位。 除了关于 c++ 虚函数这个问题以外,其他的大多数问题都与哪种编程语言关系不大,大多数是原理性和基础性的问题,少数是工作经验问题,笔者试着给大家分析。 语言基础 C++ 虚函数这是面试初、中级 C ++ 职位一个概率95%以上的面试题。一般有以下几种问法: 在有继承关系的父子类中,构建和析构一个子类对象时,父子构造函数和析构函数的执行顺序分别是怎样的? 在有继承关系的类体系中,父类的构造函数和析构函数一定要申明为 virtual 吗?如果不申明为 virtual 会怎样? 什么是 C++ 多态?C++ 多态的实现原理是什么? 什么是虚函数?虚函数的实现原理是什么? 什么是虚表?虚表的内存结构布局如何?虚表的第一项(或第二项)是什么? 菱形继承(类D同时继承B和C,B和C又继承自A)体系下,虚表在各个类中的布局如何?如果类B和类C同时有一个成员变了m,m如何在D对象的内存地址上分布的?是否会相互覆盖? 算法与数据结构基础 说到算法和数据结构,对于社招人士和对于应届生一般是不一样的,对于大的互联网公司和一般的小的企业也是不一样的。下面根据我当面试官面试别人和找工作被别人面试经验来谈一谈。 先说考察的内容,除了一些特殊的岗位,常见的算法和数据结构面试问题有如下: 排序(常考的排序按频率考排序为:快速排序 > 冒泡排序 > 归并排序 > 桶排序) 一般对于对算法基础有要求的公司,如果你是应届生或者工作经验在一至三年内,以上算法如果写不出来,给面试官的影响会非常不好,甚至直接被 pass 掉。对于工作三年以上的社会人士,如果写不出来,但是能分析出其算法复杂度、最好和最坏的情况下的复杂度,说出算法大致原理,在多数面试官面前也可以过的。注意,如果你是学生,写不出来或者写的不对,基本上面试过不了。 二分查找 二分查找的算法尽量要求写出来。当然,大多数面试官并不会直接问你二分查找,而是结合具体的场景,例如如何求一个数的平方根,这个时候你要能想到是二分查找。我在2017年年底,面试agora时,面试官问了一个问题:如何从所有很多的ip地址中快速找个某个ip地址。 链表 无论是应届生还是工作年限不长的社会人士,琏表常见的操作一定要熟练写出来,如链表的查找、定位、反转、连接等等。还有一些经典的问题也经常被问到,如两个链表如何判断有环(我在2017年面试饿了么二面、上海黄金交易所一面被问过)。链表的问题一般不难,但是链表的问题存在非常多的“坑”,如很多人不注意边界检查、空链表、返回一个链表的函数应该返回链表的头指针等等。 队列与栈 对于应届生来说一般这一类问的比较少,但是对于社会人士尤其是中高级岗位开发,会结合相关的问题问的比较多,例如让面试者利用队列写一个多线程下的生产者和消费者程序,全面考察的多线程的资源同步与竞态问题(下文介绍多线程面试题时详细地介绍)。 栈一般对于基础要求高的面试,会结合函数调用实现来问。即函数如何实现的,包括函数的调用的几种常见调用方式、参数的入栈顺序、内存栈在地址从高向低扩展、栈帧指针和栈顶指针的位置、函数内局部变量在栈中的内存分布、函数调用结束后,调用者和被调用者谁和如何清理栈等等。某年面试京东一基础部门,面试官让写从0加到100这样一个求和算法,然后写其汇编代码。 哈希表 哈希表是考察最多的数据结构之一。常见的问题有哈希冲突的检测、让面试者写一个哈希插入函数等等。基本上一场面试下来不考察红黑树基本上就会问哈希表,而且问题可浅可深。我印象比较深刻的是,当年面试百度广告推荐部门时,二面问的一些关于哈希表的问题。当时面试官时先问的链表,接着问的哈希冲突的解决方案,后来让写一个哈希插入算法,这里需要注意的是,你的算法中插入的元素一定要是通用元素,所以对于 C++ 或者 Java 语言,一定要使用模板这一类参数作为哈希插入算法的对象。然后,就是哈希表中多个元素冲突时,某个位置的元素使用链表往后穿成一串的方案。最终考察 linux 下 malloc(下面的ptmalloc) 函数在频繁调用造成的内存碎片问题,以及开源方案解决方案 tcmalloc 和 jemalloc。总体下来,面试官是一步步引导你深入。(有兴趣的读者可以自行搜索,网上有很多相关资料) 树 面试高频的树是红黑树,也有一部分是B树(B+树)。 红黑树一般的问的深浅不一,大多数面试官只要能说出红黑树的概念、左旋右旋的方式、分析出查找和插入的平均算法复杂度和最好最坏时的算法复杂度,并不要写面试者写出具体代码实现。一般 C++ 面试问 stl 的map,java 面试问 TreeMap 基本上就等于开始问你红黑树了,要有心里准备。笔者曾经面试爱奇艺被问过红黑树。 B树一般不会直接问,问的最多的形式是通过问 MySQL 索引实现原理(数据库知识点将在下文中讨论)。笔者面试腾讯看点部门二面被问到过。 图 图的问题就我个人面试从来没遇到过,不过据我某位哥哥所说,他在进三星电子之前有一道面试题就是深度优先和广度优先问题。 其他的一些算法 如A*寻路、霍夫曼编码也偶尔会在某一个领域的公司的面试中被问到,如宝开(《植物大战僵尸》的母公司, 在上海人民广场附近有分公司)。 编码基本功 还有一类面试题不好分类,笔者姑且将其当作是考察编码基本功,这类问题既可以考察算法也可以考察你写代码基本素养,这些素养不仅包括编码风格、计算机英语水平、调试能力等,还包括你对细节的掌握和易错点理解,如有意识地对边界条件的检查和非法值的过滤。请读者看以下的代码执行结果是什么?(笔者2011年去北京中关村的鼎普面试的问题) for(char i = 0; i 下面再列举几个常见的编码题: 实现一个 memmov 函数 这个题目考查点在于 memmov 函数与 memcpy 函数的区别,这两者对于源地址与目标地址内存有重叠的这一情况的处理方式是不一样的。 实现strcpy或strcpy函数 这个函数写出来没啥难度,但是除了边界条件需要检查以外,还有一个容易被忽视的地方即其返回值一定要是目标内存地址,以支持所谓的链式拷贝。即: strcpy(dest3, strcpy(dest2, strcpy(dest1, src1))); 实现atoi函数 这个函数的签名如下: int atoi(const char* p); 容易疏忽的地方有如下几点: 小数点问题,如数字0.123和.123都是合法的; 正负号问题,如+123和-123; 考虑如何识别第一个非法字符问题,如123Z89,则应转换成应该123。 我在面试掌门科技(无线万能钥匙那一家)就遇到过这样的问题。 多线程开发基础 现如今的多核CPU早已经是司空见惯,而多线程编程早已经是“飞入寻常百姓家”。对于大多数桌面应用(与 Web 开发相对),尤其是像后台开发这样的岗位,且面试者是社会人员(有一定的工作经验),如果面试者不熟悉多线程编程,那么一般会被直接 pass 掉。 这里说的“熟悉多线程编程”到底熟悉到什么程度呢?一般包括:知道何种场合下需要新建新的线程、线程如何创建和等待、线程与进程的关系、线程局部存储(TLS 或者叫 thread local)、多线程访问资源产生竞态的原因和解决方案等等、熟练使用所在操作系统平台提供的线程同步的各种原语。 对于 C++ 开发者,你需要: 对于 Windows 开发者,你需要熟练使用 Interlock系列函数、CriticalSection、Event、Mutex、Semphore等API 函数和两个重要的函数 WaitForSingleObject、WaitForMultipleObjects。 对于linux 开发者,你需要熟练使用 mutex、semphore、condition_variable、read-write-lock 等操作系统API。 对于 Java,你需要熟悉使用 synchronized关键字、CountDownLatch、CyclicBarrier、Semaphore以及java.util.concurrent 等包中的大多数线程同步对象。 数据库 数据库知识一般在大的互联网企业对应届生不做硬性要求,对于小的互联网企业或社会人士一般有一定的要求。其要求一般包括: 熟悉基本 SQL 操作 包括增删改查(insert、delete、update、select语句),排序 order,条件查询(where 子语句),限制查询结果数量(LIMIT语句)等 稍微高级一点的 SQL 操作(如Group by,in,join,left join,多表联合查询,别名的使用,select 子语句等) 索引的概念、索引的原理、索引的创建技巧 数据库本身的操作,建库建表,数据的导入导出 数据库用户权限控制(权限机制) MySQL的两种数据库引擎的区别 SQL 优化技巧 网络编程 网络编程这一块,对于应届生或者初级岗位一般只会问一些基础网络通信原理(如三次握手和四次挥手)的socket 基础 API 的使用,客户端与服务器端网络通信的流程(回答 【客户端创建socket -> 连接server ->收发数据;服务器端创建socket -> 绑定ip和端口号 -> 启动侦听 ->接受客户端连接 ->与客户端通信收发数据】即可)、TCP 与 UDP的区别等等。 对于工作经验三年以内的社会人士或者一些中级面试者一般会问一些稍微重难点问题,如 select 函数的用法,非阻塞 connect 函数的写法,epoll 的水平和边缘模式、阻塞socket与非阻塞socket的区别、send/recv函数的返回值情形、reuse_addr选项等等。Windows 平台可能还会问 WSAEventSelect 和 WSAAsyncSelect 函数的用法、完成端口(IOCP模型)。 对于三年以上尤其是“号称”自己设计过服务器、看过开源网络通信库代码的面试者,面试官一般会深入问一些问题,这类问题要么是实际项目中常见的难题或者网络通信细节,根据我的经验,一般有这样一些问题: nagle算法; keepalive选项; Linger选项; 对于某一端出现大量CLOSE_WAIT 或者 TIME_WAIT如何解决; 通讯协议如何设计或如何解决数据包的粘包与分片问题; 心跳机制如何设计;(可能不会直接问问题本身,如问如何检查死链) 断线重连机制如何设计; 对 IO Multiplexing 技术的理解; 收发数据包正确的方式,收发缓冲区如何设计; 优雅关闭; 定时器如何设计; epoll 的实现原理。 举个例子,让读者感受一下,笔者曾去BiliBili被问过这样一个问题:如果A机器与B机器网络 connect 成功后从未互发过数据,此时其中一机器突然断电,则另外一台机器与断电的机器之间的网络连接处于哪种状态? 内存数据库技术 时下以NoSql key-value为思想的内存数据库大行其道,广泛地用于各种后台项目开发。所以熟悉一种或几种内存数据库程序已经是面试后台开发的基本要求,而这当中以 redis 和 memcached 为最典型代表,这里以 redis 为例。 第一层面一般是对 redis 的基础用法的考察 如考察 redis 支持的基础数据类型、redis的数据持久化、事务等。 第二层面不仅考察 redis 的基础用法,还会深入到 redis 源码层面上,如 redis 的网络通信模型、redis 各种数据结构的实现等等。 笔者以为,无论是从找工作应付面试还是从提高技术的角度,redis 是一个非常值得学习的开源软件,希望广大读者有意识地去了解、学习它。 项目经验 除了社会招聘和一些小型的企业,一般的大型互联网公司对应届生不会做过多的项目经验要求,而是希望他们算法与数据结构等基础扎实、动手实践能力强即可。对于一般的小公司,对于应届生会要求其至少熟练使用一门编程语言以及相应的开发工具,号称熟悉linux C++ 开发的面试者,不熟悉 GDB 调试基本上不是真正的熟悉 linux C++ 开发;号称熟悉汇编或者反汇编,不熟悉 IDA 或者 OllyDbg,基本上也是名不符实的;号称熟悉 VC++ 开发,连F8、F9、F10、F11、F12等快捷键不熟悉也是难以经得住面试官的提问的;号称熟悉 Java 开发的却对 IDEA 或 eclipse 陌生,这也是说不过去的。 这里给一些学历不算好,学校不是非常有名,尤其是二本以下的广大想进入 IT 行业的同学一个建议,在大学期间除了要学好计算机专业基础知识以外,一定要熟练使用一门编程语言以及相应的开发工具。 关于项目经验,许多面试者认为一定要是自己参与的项目,其实也可以来源于你学习和阅读他人源码或开源软件的源码,如果你能理解并掌握这些开源软件中的思想和技术,在面试的时候能够与面试官侃侃而谈,面试官也会非常满意的。笔者的一个学弟前段时间告诉我,他看懂了我公众号【easyserverdev】中《服务器开发基础系列和进阶》的文章后,成功拿到了网易的offer,有兴趣的读者可以好好看一下。 很多同学可能纠结大学或者研究生期间要不要跟着导师做一些项目。当然,如果这些项目是课程要求,那么你必须得参加;如果这些项目是可以选择性的,尤其是一些仅仅拿着第三方的库进行所谓的包装和加工,那么建议可以少参加一些。 思路总结 不知道通过我上面的技术分析,聪明的读者是否已经明确本文开头“成都-go-戒炸鸡”同学提出的面试题中,哪些是技术面试重难点,哪些又是技术开发的重难点呢? 技术比重与薪资 这里根据我自己招人的经验来谈一谈技术水平与薪资,就上面的面试题来看: 第一层次:如果面试者能答出上面面试题中的C++基础问题和算法与数据结构题目(如 C++ 函数与hash冲突的解决、innodb索引原理,单向图最短路径,动态规划算法等),可以认为面试者是一个合格的初、中级开发者,薪资范围一般在6 ~ 12k(注意:这里以我所在的上海为参考标准)。 第二层次:在第一层次基础之上,如果面试者还能答出上述面试题中网络编程相关的或者多线程相关的问题(如socket函数select的缺陷,epoll模型,同步互斥,异步非阻塞,回调的概念等),可以认为面试者是个基础不错的中级开发者,薪资范围一般在14~22k之间。 第三层次:在前两个层次之间,如果面试者还能回答出上述问题中关于redis、memcached和mq实现原理,说明面试者是一个有着不错项目经验并且对一些常用开源项目也有一定的理解,薪资可以给到22k +。 总结 工资收入是每个人的秘密,一般不轻易对外人道也。这里笔者冒天下之大不韪,只想说明一点——对于普通开发人员,提高薪资最好的捷径就是提高自己的技术,无论是“面向搜索引擎编程”还是“面向工资编程”终将得不偿失,聪明的你一定会深谋远虑的。 如需下载本站全部技术文章,可以在【高性能服务器开发】公众号回复关键字“文章下载”即可。最近更新时间: 2020-10-03 14:53:41 "},"articles/职业规划/":{"url":"articles/职业规划/","title":"职业规划","keywords":"","body":"职业规划 给工作 4 年迷茫的程序员们的一点建议 聊聊技术人员的常见的职业问题 写给那些傻傻想做服务器开发的朋友 如需下载本站全部技术文章,可以在【高性能服务器开发】公众号回复关键字“文章下载”即可。最近更新时间: 2020-10-08 12:38:13 "},"articles/职业规划/给工作4年迷茫的程序员们的一点建议.html":{"url":"articles/职业规划/给工作4年迷茫的程序员们的一点建议.html","title":"给工作 4 年迷茫的程序员们的一点建议","keywords":"","body":"给工作 4 年迷茫的程序员们的一点建议 有公众号读者在后台向我提问: JAVA 程序员,4 年了,迷茫了,希望由前辈可以给指出一个技术路线5年左右程序员必须要掌握的知识技能树? 工作了很久了,对于目前自己的技术程度不满意,但是不知道如何梳理。学习一些技术是不知道是否有用。希望前辈可以指点迷津。不以年限轮英雄,希望可以给出您的见解。修改一次。。。。。。项目设计都是我来做。。。数据库设计也是我来做。。。我的意思是。。感觉目前自己的知识储备不足以支撑我架构以及设计。。求个知识树。。。。 以下是我的回答: 先举两个真实的例子。 例子一: 前两天我在给我们部门做服务器网络故障排查经验分享时,我问了一个问题关于 java.io.DataOutputStream 的问题,如果从一个 socket 输出流中读取数据,如果当前流中没有数据,读方法是否会阻塞。我又问,假如阻塞,会阻塞多久?我们如何避免这个问题。很多人回答不上来,更不用说,Java 中的 AIO、NIO 的使用细节了。 例子二: 我归纳一下,情况大致如下: 有不少朋友通过我的公众号『高性能服务器开发』中的『职业指导』模块找到我,来意大致是:做 java 开发工作了三五年了,月收入不到二万,现在因为人到中年,经济压力比较大; 但是工作上只能做做模块,写写业务代码,所以即使跳槽也不会拿到满意的薪资,所以只好维持现状(但又特别苦闷、迷茫)。 我来说一下我的观点,说的现实一点,题主所谓的迷茫其实因知识能力的不足导致的成就感、收入水平与日益增长的工作年限的矛盾。 越是高薪的职位,其对人的要求也越高。诸如上面的例子,工作有几年的 java 开发者,连 jdk 中基本的输入输出流的细节都搞不清楚,一问到就是各种摇头,然后说各种 java 框架,这样的开发者其实并不合格,因为他们离开了框架就啥也做不了,那么在工作安排上这样的人不天天也业务代码,谁来写呢?(核心的技术框架是不能让他们写的,由于基础水平不扎实,写出来的框架稳定性和性能会不好)。说的悲观一点,这样的开发者公司是从来不缺的,铁打的营盘,流水的兵,走了再招一批罢了,这也就是所谓的千军易得一将难求,我们要努力做将才乃至帅才,而不是小兵。 在面试某些 java 开发者时,我问的比较多的一个问题就是,java 多线程之间的同步技术有哪些,然后不少面试者就病急乱投医了,甚至连 ConcurrentHashMap 都说上了。这也是典型的基础概念模糊不清,ConcurrentHashMap 是一个线程安全性容器,但绝不是一个线程同步技术。 再比如问面试者 java.lang.Object 有哪些常用方法时,不少面试者能说出来的也不多。 我举这些例子并不是为了要教大家具体的 java 知识,而是为了说明基础知识的重要性。如果你的java基础足够好(熟悉 jdk 的常用类,知道常用接口的各种坑和注意事项),那么开发一个东西时即使不用框架你也能顺畅地写出来。这样的人才具备进一步发展的潜力。退一步说,不管多么复杂的java框架,都是基于jdk那些类库的。你jdk的基础知识都学不好,我不相信那些上层框架你能搞的透彻。 说一千道一万,核心的还是基础知识不扎实的问题。就和刘备当年成就帝业一样,诸葛亮给的策略就是先谋取荆州,再进军西蜀,最后三分天下。同理jdk的基础知识就是你应该要首先谋取的“荆州”,进一步的各种框架、架构设计是你的“蜀地”。基础不牢,想其他的东西都是好高骛远,不切实际。最后日复一日,年复一年,在恨自己生不逢时,领导不是伯乐的嗟叹中蹉跎了岁月。 对于上面这个注重基础的问题上,实际情形中,我遇到三种人。 第一类:意识不到基础知识的重要性,这类人就不提了。 第二类,意识到基础知识的重要性,但是总是在各种理由和借口中麻痹自己,温水煮青蛙把自己“煮死”。很多咨询我的人,也是这种情况,说什么自己工作忙,家庭琐事多。我其实不想多说啥,为失败找借口的人太多,为成功找方法的人太少。你工作五年了,每个月抽一天时间来补一下基础,你现在都不是这样了,这个时间也抽不出来?自我麻痹而已。这类人其实是有想法没啥行动。 第三类,意识到基础的重要性,同时在各种闲暇时间去补充,去积累。这样的人学的最快,最后达到的高度也很高(当然收入也不菲)。 扎实的基础知识 + 见多识广的框架经验,让你在职场上变得无可替代,这才是你的核心竞争力。答案可能有点跑题了,但是我觉得先解决思想上的问题,行动上就容易许多了。 如果你想和我聊聊职业上的困惑,可以在『高性能服务器开发』公众号后台回复关键字『职业指导』,我们可以针对性地聊一聊。 如需下载本站全部技术文章,可以在【高性能服务器开发】公众号回复关键字“文章下载”即可。最近更新时间: 2020-10-03 14:57:08 "},"articles/职业规划/聊聊技术人员的常见的职业问题.html":{"url":"articles/职业规划/聊聊技术人员的常见的职业问题.html","title":"聊聊技术人员的常见的职业问题","keywords":"","body":"聊聊技术人员的常见的职业问题 由于时间有限,很多读者提出的问题,不能一一解答,因此这篇文章,来系统地就各类型的读者遇到的一些常见职业问题回答一下: Q1 应届生如何选择自己的第一份工作? Q2 作为一个程序员,是进入大厂好,还是进入创业公司好? Q3 我专科(或二本)毕业,学历不行,如何进大厂工作? Q4 我非科班出身,如何进大厂工作? Q5 有没有人能分享一下大厂的面经? A1 答案点 这里 和 这里。 Q6 我工作了几年,技术不行,如何提高? Q7 我非科班出身,应该看哪些书才能补上计算机专业的基础? Q8 我想成为一名技术高手,应该如何提高? Q9 天天写业务代码,如何能有机会做一些底层的设计和开发? A2 答案点 这里。 Q10 服务器端开发与前端开发有什么差别?哪个发展潜力好一点?哪个薪资高一点? A3 答案点 这里。 Q11 我想成为一名 C++ 程序员,该如何入门、进阶以及升华? Q12 C++ 后端开发需要掌握哪些东西? Q13 C++ 面试应该准备哪些东西? A4 答案点 这里 和 这里 以及 这里。 Q15 我是一名 Java 程序员,天天增删改查数据库,我如何实质性的提高自己? Q16 Java 技术栈的所谓的基础在哪里? A5 答案点 这里。 Q17 程序员真的很难找女朋友吗? Q18 大厂加班严重,在大厂上班的程序员真的没有女朋友吗? A6 这是一个忧伤的话题,答案戳 这里 和 这里 以及 这里**。 Q20 如何通过技术面试来确定面试官的职级?如何确定自己面试职位所对应的职级? A7 答案看 这里。 Q21 技术面试中,面试官问我薪资,我该不该告诉他? Q22 技术面试过了,如何和 HR 谈薪水? Q23 我报了一个薪水之后,HR 爽快的答应了,我是不是报低了?我能不能再找他们提高一点? A8 答案看 这里。 Q24 年薪五十万的技术岗位做些什么工作? Q25 做技术岗位如何年薪五十万呢? Q26 年薪五十万的程序员是不是真的头发很少? A9 别害怕,答案戳 这里。 Q26 年终奖是如何发的?什么时候发? Q27 年终奖还没发,我跳槽是不是就没有年终奖了? Q28 入职时人事说月薪低一点,年终奖多很多,我要不要同意? A10 答案点 这里。 Q29 结婚有娃了,生活压力大,工资入不敷出,如何改变? Q30 钱不够花,作为一名只会写代码的码农,我如何赚点外快呢? A11 答案看 这里。 Q31 工资爆炸式的增长是一种什么体验**?** A12 答案 在... \\在知乎的故事里 T_T。** 如需下载本站全部技术文章,可以在【高性能服务器开发】公众号回复关键字“文章下载”即可。最近更新时间: 2020-10-03 15:05:37 "},"articles/职业规划/写给那些傻傻想做服务器开发的朋友.html":{"url":"articles/职业规划/写给那些傻傻想做服务器开发的朋友.html","title":"写给那些傻傻想做服务器开发的朋友","keywords":"","body":"写给那些傻傻想做服务器开发的朋友 很久以前看过一篇标题为《写给那些傻傻的,想做服务器开发的应届生》文章,无意中看到知乎上也对这篇文章进行了激烈的讨论。下面谈谈我的看法。 写在前面的话 我在七八年前就看过这篇文章,那个时候我还是一名学生,它深深地影响了我学生时代以及后来的人生轨迹。(所以原文绝对不是首次发表于2015年,我猜想可能是后来的作者2015年修改了原作者的一些内容,并增加了一些自己的东西,让它\"与时俱进\")。我学生时代深受这篇文章的影响,以至于我印象中的服务器开发的样子和地位就是这篇文章中所描述的。 我的工作经历 我毕业的时候,一心想做出Windows C/C++客户端开发,当时为了做这个开发放弃了我熟悉的flash编程和web开发,当然薪资也是比较低的。做了几年Windows客户端后,我毅然以一定的代价转到了linux服务器开发。到今天为止,大致做过股票资讯、交易系统、游戏服务器、即时通讯系统和直播类型的服务器,架构的能力也由最初的千人到后来的百万在线。我从不后悔我当初转行服务器开发,甚至很庆幸当初的抉择,然而我可能更喜欢的还是客户端开发。 《写给那些傻傻的,想做服务器开发的应届生》一文中的有些观点,根据我的经历,我不敢赞同,或者说我的感受与之大相径庭。 加班的情况 首先说下加班的情况,不管是大公司还是小公司,由于现在的各种测试、预警机制、监控策略和公司发布流程的不断完善,一个月内经常为各种服务器bug、和应急的情况加班的现状已经大为改善不少,当然偶尔发版或者赶项目加班还是有的,不过一个月的频率也就那么一两次。如果你们团队频繁地为了修正紧急bug、解决服务器稳定性问题,那么你们真要好好考虑你们的方法是不是有问题了。 服务器开发与轮子 其次,服务器开发,不仅仅如文中所说的,利用或者组装各种轮子。一个稳定的服务器架构,必须是建立在设计师良好的基础知识和见多识广的经验基础上,即使是使用现有的轮子,也是对这个轮子足够熟悉的基础上,才能让轮子最大地适用自己的公司的业务。也就是说,服务器核心项目人员虽然不一定要造轮子,但一定要具备造轮子的能力。开源的东西好用是好用,但是要么不出问题,一旦出问题往往很难修改。我们去年做类似“冲顶大会”、“百万英雄”这类直播答题应用,由于这类游戏是从美国HQ刮过来的风,国内各大公司为了迅速抢占市场与用户,都想着要比别人早点做出来上线,所以我们公司当时deadline压得比较紧。我们那个时候,最不想看到的人就是项目经理,天天跟着我们后面催项目的进度。项目进度紧不说,另外还有一个技术挑战,由于节目比较火热,同一个房间里面可能会达到百万人同时在线,而这百万人可能同时都会发弹幕消息。假设某个时刻,房间里面有n个人,某个人发一条消息,其他n-1个人收到,服务器需要推送n-1次。如果n个人同时发消息,那么服务器同一时间就要推送n*n,如果n等于1百万的时候,那么单秒的数据量将非常恐怖,这个是我们需要解决的一个技术难题,解决目标是最少延迟的情况下,弹幕最多的送达率;另外一个难题就是,保证出题和答案不能有太多的延时(小于1秒),并在用户给出答案后,服务器能够迅速统计出答案结果并应答客户端。(没办法,所以此时主持人的作用就发挥了,万一延迟太厉害,主持人可以和观众各种唠嗑,当然这是下下策,如果频繁出现这种情况,领导的脸色肯定也不好看,我们做技术的脸上也没有光彩。)那段时间基本上是周六周日都要加班,甚至连周末都可能要到凌晨才能回去。注意:我把这段经历并没有放在上面的关于服务器开发是否频繁地加班的栏目下,这里我想说明的并不是服务器开发要经常加班,我想说的是,如果你平常只会用轮子,而不注重基础内功的修养,这种场景你是很难应对的,首先是单机服务性能要做到极致,其次是多个服务之间的高效配合。很多人可能觉得这种场景也不难,甚至有的人号称单机服务就能解决,这些都是站着说话不腰疼了。像熊猫tv的“冲顶大会”和西瓜视频的“百万英雄”前几次的答题活动中,也出现了服务中断或者题目延迟厉害,甚至“百万英雄”还出现过一次因技术问题答题活动被迫延期的事故。 技术与产品思维 接着说下,技术和产品方面的,服务器开发与客户端开发的思维方式和理念其实是不一样的,如果说客户端产品是一个产品的脸面,那么服务器端就是产品的灵魂。这里可能比喻有点不恰当,与客户端开发相比,优秀的服务器开发应该尽量在单机服务上的性能做到极致,必须尽量利用少的资源给尽可能多的客户端服务(在资源总量有限的情况下,你为单个客户端服务使用的资源越少,你才可能为越多的客户服务)。而服务器开发必须有条不紊地处理与每个客户端的交互,不能纠结或把资源花费在某一个客户端上。但是客户端不一样,客户端只需要管理好自己的一亩三分地就可以了,而且客户端的大多数逻辑和细节在界面(UI)逻辑上。但是我不赞成文中作者所说的客户端代码比服务器代码少很多,相反,我经历过的项目,都是客户度代码比服务器代码多很多。因为客户端代码往往有大量的界面逻辑,如果服务器端没有UI的话,其核心除了网路通信部分,剩下的就是各种业务逻辑(包括存储逻辑,也就是业务逻辑服务器和客户端都有,但是客户端还有界面逻辑)。而从开发团队的人数配比上来说,一般单个端(比如pc、安卓、ios中的一端)的人数要小于服务器开发人员的数量,因为一般一个高级客户端开发,往往可以一个人搞定一个客户端,但是一般很少有一个高级服务器开发可以单独搞定一套服务开发的。(说的是通常情形,请不要走极端)。服务器开发的核心字眼体现在“服务”上,如何为客户端提供稳定的、高效的服务,这是关键的地方。这里“稳定”也包括容灾容错。大凡有一定规模的用户群体的产品,如果服务器不稳定,那后果将是灾难性的,试想QQ或者微信服务器中断一两个小时,后果会怎样?而客户端更侧重的就是产品的细节、用户的体验,当然尽管有些用户体验可能是由服务器端决定的,但是最终还是由客户端反映出来。我不赞同文章中说,客户端更能积累除了技术以外的其他知识,服务器开发也一样的,不管是客户端还是服务器,只有具有产品思维的开发才是好的开发,而功能的设计与规划服务器端的开发在时间点上一般先于客户端开发的。而具体的功能点,也是需要服务器开发人员与产品人员乃至客户沟通的。 薪资方面 最后说下,薪资方面。一般大于两年且同样的工作年限的服务器开发人员要比客户端开发人员高至少三分之一左右。当然不排除一些非常优秀的客户端开发人员可能不在这个规则内。 结语 总结起来,选择了哪条路就选择了什么样的生活。做服务器开发的可以在高并发、高可用方向进一步努力,而做客户端开发可以在用户体验、设计细节方面下功夫。不管怎样,都是我们想要的生活,那里倾洒了我们的汗水,也收获了我们自己的成就感。 如需下载本站全部技术文章,可以在【高性能服务器开发】公众号回复关键字“文章下载”即可。最近更新时间: 2020-10-03 15:06:47 "},"articles/自我提升与开源代码/":{"url":"articles/自我提升与开源代码/","title":"自我提升与开源代码","keywords":"","body":"自我提升与开源代码 2020 年好好读一读开源代码吧 如需下载本站全部技术文章,可以在【高性能服务器开发】公众号回复关键字“文章下载”即可。最近更新时间: 2020-10-08 12:40:26 "},"articles/自我提升与开源代码/2020年好好读一读开源代码吧.html":{"url":"articles/自我提升与开源代码/2020年好好读一读开源代码吧.html","title":"2020 年好好读一读开源代码吧","keywords":"","body":"2020 年好好读一读开源代码吧 2019 年就这么结束了,2020 年也来临了,虽然我曾对过去 2019 年做了一份总结,但是认真的来说,其实我对自己的 2019 年的收获并不太满意,一个主要的原因是计划好好研读的几个开源项目的源码都没有去做。好在,2020 新的一年,不再像 2019 年创业一般忙碌,终于可以静下心来认真去把这些未完成的计划好好做完。 其实,我一直想找个机会和我的读者,好好讨论一下阅读开源项目源码这个话题的,我这里观点无任何含糊或者模棱两可,我旗帜鲜明的亮出我的观点——想在技术上有所造诣或者想成为某一技术领域的专家的同学一定要认认真真的研读几个开源软件的源码。下面我会具体来展开说下这个问题。 知识付费与阅读源码 大家都知道,时下\"知识付费\"这个词非常火热,各大平台各个领域都推出了许多基于知识付费的课程,有图文版、语音版和视频版(包括在线实时教育直播)。当然,知识付费是一个好东西。众所周知,互联网信息的特点是信息量大、有用信息少、信息质量良莠不齐,各大平台推出的各种付费课程,精心制作,用心分类和梳理,读者只要花费一定的费用,就能省去大量搜索、查找和遴选信息的时间,直接专注于获得相关知识本身。 在各类知识付费课程中,有一类课程是介绍业界或者大家平常工作中用到的一些开源软件的原理的,进一步说,有的是分析这类软件的源码的,如 nginx、netty、Spring Boot。 我个人觉得,虽然你可以购买一些这样那样的开源软件的教程或者图书(包括电子书)去学习,但一定不要以这些学习材料为主要的学习这些开源软件的方法和途径,有机会的话,或者说如果你要学习的开源软件所使用的开发语言正好是你熟悉或者使用的编程语言,那么你应该尽量多去以阅读这些开源项目的源码本身为主。举个例子,如果你是 C/C++ 后端开发者,那么像 redis、nginx(它们都是使用 C 编写的)这样的开源项目的源码你应该认真的去研读一下;如果你是做 Windows C/C++ 客户端或者一名 QT 客户端开发人员,那么像 MFC、DUILIB、金山卫士等源码,你可以拿来读一读;如果你是 Java 程序员,netty、Spring 等源码是你进阶路上必须迈过去的一关。 为什么建议以阅读相关源码为主,而不是其他相关教程呢? 首先,任何其他相关教程介绍的内容都是基于这个软件的源码实现创作出来的,虽然能帮助你快速理解一些东西,但是不同的教程作者在阅读同样一份代码时的注意点和侧重点不一样,加上如果作者在某些地方有理解偏差的,这种偏差会被引入你所学习的教程或者图书里面,也就是说,你学习的这些东西其实不是第一手的,而是经过别人加工或者理解意译过的,在这个过程中如果别人理解有偏差,那么你或多或少的会受一点影响。所以,为了\"不受制于人”,亲自去阅读一些源码时非常有必要的。 其次,如果你按照别人的教程大纲,那么你的学习该软件的开源项目时,可能会受限于别人的视野和侧重点,通俗的说,假设一个开源项目其可以学习和借鉴的内容有 A、B、C、D、E 五个大的点,别人的教程可能只写了 A、B、C、D 四个点,如果你只局限于别人的教程,你就错过 E 这个点了。 这里可以给读者讲一个具体的例子。我最初开始走上工作岗位时做的是 C/C++ 客户端开发,我无意中找到了一份完整的电驴源码,但是开始阅读这份代码比较吃力,于是我就在网上找相关的电驴源码分析教程来看。但是呢,网上的这方面的教程都是关于电驴的网络通信模块和通信协议介绍的,很多做客户端的读者是知道的,做客户端开发很大一部分工作是在开发 UI 界面方面的逻辑和布局,其实电驴源码中关于界面设计逻辑写的也是很精彩的,也非常值得当时的我去借鉴和学习。如果我只按照网上的教程去学习,那么就错过这方面的学习了。也就是同样一份电驴源码,不同的学习者汲取的其源码中的营养成分是不一样的。需要电驴源码的同学可以在公众号后台回复关键字【电驴源码】获取下载链接。 如何去阅读源码呢? 这应该是很多读者想知道的问题,先讨论几种老生常谈的阅读源码的方式。 第一种方式就是所谓的精读和粗读。很多读者应该听说过这种所谓的阅读源代码的方式,有些人认为有些源码只需要搞清楚其主要结构和流程就可以了,而另外一些源码需要逐行认真去研读其某个或者某几个模块的源码。或者,只阅读自己感兴趣或者需要的模块。 第二种方式,说的是先熟悉代码的整体结构,再去依次搞清楚各个模块的代码细节。 第三种方式是所谓的调试法,通过开源项目的一个或几个典型的流程,去调试跟踪信息流,然后逐步搞清楚整个项目的结构。 以上三种方式都是不错的阅读源码的方式,读者可以根据自己的水平、目的和阶段去使用。但是,我这里想说的并不是这些东西。 我个人觉得,一个技术人员如果想通过源码去提高自己,应该以一种\"闲登小阁看新晴\"的心境去阅读源码,这也许是在某个节假日的清晨,某个下过雨的午后,某个夜黑人静的深夜。看源码尤其是看高质量源码本来就是一种享受,像品茗。闲暇时间去细细品味一些开源软件的源码,和锻炼身体一样,都是人生中重要不紧急的事情,这类事情做的越多,坚持的越久,越能提高你的人生厚度。虽然阅读源码的最终目的是功利性的,但是阅读源码的心态不建议是功利性的,喜欢做一件事本身的过程,比把这件事做好的目标更快乐。 我从学生时代开始,就喜欢看一些开源软件的源码,当然,从现在的标准来看,看的很多源码都不是\"高质量\"的,择其善者而从之其不善者而改之,不是吗?有些源码可以学习其架构、结构设计,有些源码则可以学习其细节设计(如变量命名、编码风格等)。 看过的这些源码对我的技术视野影响很大。我上大学的时候,迷恋 Flash 编程,当时非常崇拜 Flash 界的两位前辈——鼠标炸弹(https://mousebomb.org/)和寂寞火山(现在已成币圈有名的大佬),另外还有淘沙网的沙子。多年后再看他们的代码可能质量没有那么高,但是我从他们开源出来的代码中学到了很多东西。举个例子,我喜欢在一些成对结束的花括号后面加上明显的成对结束的注释就是从沙子的代码那里学来的。虽然,现在的 IDE 会清楚的标示出来各个花括号的范围,但是这种注释风格在某些时候大大方便了代码阅读和 review。 //实例 class A { public: void someFunc() { for (int i = 0; i 给大家阅读源码的一些建议 很多人阅读源码存在以下不当的习惯或者认知方式: 很多人阅读源码其实是随波逐流的,今天有人推荐阅读 A 项目的源码,他就去阅读 A 项目的源码,明天有人推荐阅读 B 项目的源码,他就去阅读 B 项目的源码。天下源码何其多呀,找到自己感兴趣的或者对自己有用的,不要随波逐流,适合别人的不一定适合你。 有些人阅读源码非要满足了\"天时地利人和\"才会去阅读。例如,有些人觉得自己不懂网络编程,所以就不方便阅读 nginx 的源码,有些人听别人说阅读某个项目的源码前必须先做 XX,而自己又不熟悉 XX,所以就放弃了阅读该项目。或者觉得当下时机不适合阅读某个项目的源码。再或者在阅读几个源码文件或者模块的代码时,因为看不懂就放弃了。其实这些做法都不可取,任何源码和你刚进入公司去接触一个新的业务项目的源码一样,只要慢慢熟悉,在这过程中针对性的补缺补差,坚持下来总会有所收获的。尤其是对那些走上工作岗位的读者来说,成年人的世界事情那么多,此生余年应该不会再有什么时间可以同时满足\"天时地利人和\"了吧。 代码的质量高低是相对的,不要因为一些项目的源码质量低或者不符合你的 style 就放弃。大多数完整的项目代码总有其可取之处,要学会吸取其有用之处。举个例子,很多做 Windows C++ 客户端开发的同学,应该会在网络的各个地方看到很多人抨击 MFC 的,然后一堆建议不要学习 MFC 的。从我个人的经历和感受来看,MFC 的源码还是很值得做 Windows C++ 客户端的同学学习的,尤其是其设计思想。当然,MFC 之所以被很多人抨击,是因为其臃肿笨拙,这有很多历史原因,MFC 不仅封装 Windows 界面逻辑那一套,同时实现了一套常用软件文档、视图模型的程序框架结构,同时自己实现了一套 STL 相关功能,以及其他一些常用功能(如对象的序列化和反序列化)。这些设计思想都被后来的各种软件框架借鉴和继承,例如 QT 和 Java 中的序列化和反序列化。一个开发者如果想成为架构师,其心中一定要对某个场景有一套可行的技术方案,如果你经验不足或者水平不够,拿不出来这样的方案,那就去借鉴和学习这些开源的软件。而不是只会抨击这些软件源码的缺点,而自己又无更好的解决方案。旧的方案虽然不好,但是我们需要去学习、熟悉,只有熟悉了之后,我们才能基于其去改造和优化。 最后,阅读源码不是做给别人看的,如果你之前从未意识到阅读各种大大小小的开源项目的源码的重要性,2020 年循序渐进,少买点在线课程,少囤点书,多读些源码吧。 如需下载本站全部技术文章,可以在【高性能服务器开发】公众号回复关键字“文章下载”即可。最近更新时间: 2020-10-03 15:08:32 "},"articles/后端开发相关的书籍/":{"url":"articles/后端开发相关的书籍/","title":"后端开发相关的书籍","keywords":"","body":"后端开发相关的书籍 后台开发应该读的书 如需下载本站全部技术文章,可以在【高性能服务器开发】公众号回复关键字“文章下载”即可。最近更新时间: 2020-10-08 12:42:04 "},"articles/后端开发相关的书籍/后台开发应该读的书.html":{"url":"articles/后端开发相关的书籍/后台开发应该读的书.html","title":"后台开发应该读的书","keywords":"","body":"后台开发应该读的书 根据我的经验来谈一谈,先介绍一下我的情况,坐标上海,后台开发(也带团队了),某知名互联网公司。 目前主流的有C++和JAVA,C++我的经验稍微多一点。我就说说我关于C++方面的学习经验。如果您是学生,临近毕业,没有那么多时间读许多书,可以按下面列举的重要程度来参考。 首先,我觉得你应该好好准备算法和数据结构,做到常见的算法和数据结构知识点都能非常熟悉,这样的话你毕业求职的时候可以轻松拿一些大厂(BAT等)的offer。我本人非科班出身,一毕业之后各种摸爬滚打。一毕业去大厂个人觉得有两个好处,第一,你的收入会比一般的的小公司高很多,小公司招人要求相对低一些,薪资给的也少很多,它们是实实在在招能干本职工作活儿的人。第二,你的起点也会比一般进入小厂的同学高。我这里并不是歧视小厂,只是说一种普遍的情况。我本人也是从小厂一路过来的。这里我是强调算法和数据结构的重要性。尤其是应届生求职,更应该去好好准备一下这个,因为这个东西是原理性的基础。企业在面试应届生时不会过分要求项目经验和各种操作系统原理、网络通信原理之类的东西,而唯一能考察一个人的基本功的就是这个了。我是社招进大厂,基本上算法和数据结构这类问题问的比项目经验本身要多许多。但是社招又不太一样,因为除了要准备算法和数据结构以外,还得准备有项目经验、了解操作系统原理、熟悉网络通信、了解数据库、熟悉要求的各种开源框架和技术等等,实在太多了,即使再怎么准备也不一定能一举拿下。相反,应届生基本上只要好好准备算法和数据结构的东西,大学其他专业课学的不是太差,这基本上就是进大厂的捷径。图书方面,你可以使用你们计算机专业的相关教材,也可以使用《数据结构与算法分析:C语言描述》《算法导论》这一类严谨的教材,当然,平心而论我是不敢推荐《算法导论》的,因为这一本书实在是太大部头了,没有好的数学知识,真的很难啃。如果想看一下比较幽默轻松类的书,可以看看程杰的《大话数据结构》。 其次,如果你学有余力,可以看看操作系统原理方面的书籍,当然也可以使用你们的教材,我这里推荐一本我看过的吧,Tanenbaum.A.S《现代操作系统》,Tanenbaum是荷兰人,也是Linux之父Linus Torvalds操作系统方面的启蒙老师。当然,我的建议是这种书毕竟流于理论知识,也不一定要看完,但一定将一些基础概念,如进程线程内存模式等基础概念看懂理解。你如果还有时间强烈推荐看看俞甲子的《程序员的自我修养:链接、装载与库》,这本书同时涉及到了Windows和linux两个操作系统平台,用各种辅助工具剖析了程序从源码到二进制文件再到装载到进程地址空间里面的各个细节,甚至连进程地址空间中的堆结构、栈结构也分析得清清楚楚,同时也分析了C Runtime(CRT)、glibc这样的操作系统接口库的原理和执行逻辑,是一本实实在在难得的帮你实战操作系统原理的一本好书。我特别喜欢这个书中序言的一段话: “CPU体系结构、汇编、C语言(包括C++)和操作系统,永远都是编程大师们的护身法宝,就如同少林寺的《易筋经》,是最为上乘的武功;学会了《易筋经》,你将无所不能,任你创造武功;学会了编程“易筋经”,大师们可以任意开发操作系统、编译器,甚至是开发一种新的程序设计语言!” 再次,你学这些东西是为了将来实践并有产出的,而落实这个产出的东西就是编程语言,如果是入门,我首推C/C++。你只有熟练使用一门编程语言,你才能将你的想法变成现实。注意这里我把C和C++放在一起,但是严格意义上说,C和C++还是有点区别的,但是除了语法上的一些细节差异,基本上可以认为是相通的。个人觉得C语言是所有想成为高手最应该使用的入门语言,不要和我说现在很火的python、go这类语言,“玄都观里桃千树,尽是刘郎去后栽”。我这里也推荐一本C语言方面的图书吧,有兴趣的可以参考一下:《C语言程序设计:现代方法》。至于谭浩强的书就不要提了,还有就是大部头的《C++ Primer》,它虽然是一本好书,但实在是太大部头了。语法层面的东西学会很快,stl库的东西需要实战,也不是翻这类字典一样的书就能很好地掌握的。当然,如果你想掌握好C++,《深度探索C++对象模型》是一定要看的。C++实际编码技巧还有另外一本非常好的书,介绍了常见的C++编码技巧《提高C++性能的编程技术》,建议C++开发的把书中说的技巧全部掌握。 接着说,我们再说说网络方面的,首先网络基础方面的书籍,我就没啥推荐了,现在很多计算机学院也开始使用《计算机网络:自顶向下方法》这本不错的教材,如果没有看过的可以看下。当然还是那句话你一定要看懂而不是看完。比如三次握手和四次挥手的细节,你一定要很清楚。然后你就可以找一本网络编程的实战书来看下,如果你没有使用任何socket api编程的经验,你可以看看韩国人尹圣雨写的这本《TCP/IP网络编程》,这本书从基础的socket api介绍到比较高级的io复用技术,有非常详细和生动的例子。如果你是初级水平,强烈建议看看这本书。网络编程的细节需要注意的地方实在太多了,这本书上都有介绍。很多人尤其是一些学生,写了一些可以相互聊天的小程序就觉得自己熟悉网络通信了,但是这类程序拿到互联网上或者离开局域网,不是连接出错,就是数据总是收发不全。我当年也是这么过来的,看看这本书,你就能明白许多网络故障的原因。等你有了一定的网络编程以后(熟练使用常见socket API),你可以看看游双的《Linux高性能服务器编程》,这本书给没有基础的人或者基础不扎实的人的感觉是,尤其是书的前三章,这书怎么这么垃圾,又把网络理论书上面的东西搬过来凑字数,但是如果你有基础再按照书上的步骤在机器上实践一遍,你会发现,真是一本难得的、良心的书,桃李不言下自成蹊吧。如果你掌握了这本说上说的这些知识,你再看陈硕老师的《Linux多线程服务端编程》或者去看像libevent这样的开源网络库,你会进一步的得到提升。这也是我学习网络编程的一些经验和经历吧。注意这里有必要提一下:像UNP、APUE、还有《TCP/IP详解》这一类书,如果你将来不是专门做网络方面的工作或研究,其实是非常不建议抱着他们看的,因为部头太大,其次太多理论和Unix的东西,花的时间产出投入比很低的。 接着说,以上说的都是一些基础的东西。其实不管是什么开发,后台开发也不例外,你都是需要基于特定的操作系统的,这里不提Windows系统,单单拿linux操作系统来说,既然你选择做这个方面的开发,你需要熟悉这个操作系统平台提供的一些常用的API函数,网络通信方面上文已经说过,除了网络通信还有如操作文件、操作内存、字符串操作、进程线程系列、线程同步系列(如互斥体、条件变量、信号量)、管道等常用的各种API接口函数。这里的意思是,不是要你背诵记忆每一个接口函数的签名,而是你要知道何时该用哪个接口,如何用,有什么注意事项。我入门的时候看的是Robert Love的《Linux系统编程》,熟悉这个人的应该知道,google的工程师,他还有另外一本非常有名的书《Linux内核设计与实现》。 最后,我强调一下,如果你是快毕业的学生,面临着找工作的压力,应该以算法和数据结构为主。如果你是大一大二或研一这个阶段的学生,我上面推荐的书,你还是可以考虑好好咀嚼一下。标准是看懂而不是看完。 再补充一些我觉得要成为高手应该要掌握的东西,先说汇编。虽然第三代第四代语言越来越多,硬件性能越来越好。但是如果你熟练掌握汇编,你就比其他人多很多优势,你会能透彻地知道你写的每一行C/C++代码背后的机器指令的效率。无论是做安全工程还是自己技术提升上都是非常不错的。这里推荐一本王爽老师的《汇编语言(第3版)》,这本书不厚,语言通俗易懂,你也不用刻意去记忆,基本上当小说书看一下就能很快看完了。汇编实战类图书还有另外一本《老\"码\"识途:从机器码到框架的系统观逆向修炼之路》。我个人是非常喜欢这本书的。当年读这本书的时候,真的有一种“笑看妻子愁何在?漫卷诗书喜欲狂”的感觉。尽管那个时候连女朋友都没有——! 另外补充一些我学生时代看过的书吧,我本人是熟悉Windows和linux两个平台的开发,这也归功于我学生时代看过的一些经典书籍,可能有点跑题了,如果不介意,我可以和你说说: 《Windows程序设计》第五版(第六版以后,这个不再是用Windows Native API写C程序了,而是转到C#平台上了),这本书是中国第一代程序的windows启蒙书籍,你所看到的大多数桌面软件,如QQ,的开发者可能都是通过阅读这本书启蒙起来的。 《Windows核心编程》,这本书搞Windows开发的一定都知道这本书的分量。 《linux内核情景分析》毛德操老师的书,非常的实在,另外他写了一套关于Windows源码分析的书,这本书是基于开源的“Windows”ReactOS,书名叫《Windows内核情景分析》。 《编译系统透视:图解编译原理》,编译原理方面的实践书。 《编程之美》,关于面试的,主要是一些算法和逻辑思维题实战。 《重构:改善既有代码设计》,没有实际写代码经验不推荐看。 《程序员的修炼之道——从小工到专家》这本书特别推荐学生看一下,能大幅度地提高你实际编码的技巧和编码风格。 《代码整洁之道》同上 《大话设计模式》 《Windows PE文件权威指南》 《Java编程思想》 《Effective C++》系列 《80x86汇编语言程序设计教程》 总的来看,我学生时代主要是侧重基础知识来读书的。本科四年、硕士三年,多谢这些书帮助我成长,记得大学毕业的时候,我光读书笔记就有满满十个笔记本。 工作以后,也读了像redis、netty、分布式这一类书。但是那都是工作需要吧。由于我扎实的基础,当然也可能是因为运气成分吧吧,所以得到一些注重扎实的技术基础公司的青睐,给了目前这个阶段看起来还不错的薪资(当然可能还有人比我更厉害,那我这里就贻笑大方了,所以请不喜勿喷)。同时非常感谢我一路上遇到的公司和同事给我的技术上和生活上的帮助。薪资本身不能说明一个人是否成功,我码这么多字,希望广大的开发者注重基础,勿在浮沙筑高台。尤其是学生,你有大把读书的机会,一定要珍惜大学时光。毕竟工作以后,尤其是毕业后,面临着工作、家庭等各种问题,你可能再也没有心思和完整的时间去学习和提升了。所以前期的积累很重要,毕竟选择技术这条路,提高技术是升职加薪改善生活水平最直接的方法。最后用我学生时代看到一个技术前辈写的一首诗来结束吧: 仗鼠红尘已是癫, 有网平步上青天。 游星戏斗弄日月, 醉卧云端笑人间。 七载寻梦像扑火, 九州谁共我疯癫? 以上是我的经历,我也曾迷惘和无助过。也有很多朋友找到我,希望我做一些经验分享和职业规划指导,有需要的小伙伴可以加我微信 easy_coder。 如需下载本站全部技术文章,可以在【高性能服务器开发】公众号回复关键字“文章下载”即可。最近更新时间: 2020-10-03 15:12:04 "},"articles/程序员的简历/":{"url":"articles/程序员的简历/","title":"程序员的简历","keywords":"","body":"为什么你的简历没人看 程序员如何写简历 如需下载本站全部技术文章,可以在【高性能服务器开发】公众号回复关键字“文章下载”即可。最近更新时间: 2020-10-08 13:22:43 "},"articles/程序员的简历/程序员如何写简历.html":{"url":"articles/程序员的简历/程序员如何写简历.html","title":"程序员如何写简历","keywords":"","body":"程序员如何写简历 笔者工作多年后面试了很多公司,例如 2018 年年初横扫各大互联网公司,也作为面试官面试了很多人,看过不少的简历。现在疫情快过去了,很多小伙伴开始准备简历看新机会了,但是不少小伙伴遇到以下两种情况: 投了很多公司,邀请面试的寥寥无几; 面试的时候被面试官问的哑口无言。 造成以上原因很大一部分是因为简历的问题,本文将结合自身的面试和被面试的经历和大家聊一聊简历怎么写。我们先来分析一些简历素材。 简历一 这是一位毕业生的简历,大家看下这个简历存在什么问题? 分析: 简历中写了自己做的一个项目,项目描述中将该项目描述成 RPC、分布式网络框架,试问从项目描述来看,哪里体现出该项目使用了 RPC 框架和分布式?且不说没用到,就算用了,一般按大多数应届生的经验水平是很难在面试时经得住面试官在分布式等问题上的追问的,这非常容易给自己面试挖坑,一般校招或者对应届生的项目要求并不会太高,但是自己在简历中写上这些“分布式”、“RPC”等高大上的术语,如果实际并未掌握,只能是给自己埋雷。 另外,求职者的项目是一个网络通信库,但是通信协议不是自己的(Protobuf),网络库也是别人的(Muduo),那这个项目有自己的东西吗?一般作为面试官对应届生没有多少项目经验是可以理解的,但是如果把别人的东西拿来自己加个壳,并写在简历中,这就没多大意义了。如果该同学尝试自己设计了一种通信协议,哪怕最终实现的不是很好,面试官也可能非常喜欢,因为融入了自己的创作和思考;退一步说,用 protobuf 也是可以的,如果面试时能说得清楚 protobuf 的序列化和反序列化的原理和该库的结构,也是 OK 的。 面试结果: 该同学在面试时因这个项目被面试官死怼,铩羽而归。 简历二 分析: 这个简历我第一眼看到之后,我猜想应该很少有 HR 或者猎头联系该同学面试吧,后来和当事人确认下,果不其然。该简历的问题有以下几点: 简历中列举的技术栈非常多,如 Linux 、Shell、Python、C++、Golang、Django、Flask、Bootstrap、JQuery......面试者真的掌握了这么多技术吗?另外,求职目标写的是“后端开发”,虽然 HR 可能不知道 Bootstrap、vue 等是前端技术,但技术面试官不知道吗?你一个求职后端职位的,你写许多前端技术干嘛?体现自己全栈吗?按作者的年龄和工作经历,很多技术只是了解或者使用过,并不一定掌握,且不说面试容易被问到而答不出来,最主要的是这份简历让人一看就觉得求职者没有自己专精的领域。说白了,啥可能都知道,但啥都没掌握好。所以大多数公司看到这样一份简历直接就 pass 掉了。 求职目标写的是“后端开发”,位置不够显眼,其次求职目标后端开发一词描述太泛,这位同学本意是求职 C++ 后台开发,但是这样一写,php、Java、golang、python 等不算后端开发吗?所以建议把求职的职位稍微缩小点范围。 面试结果: 基本无面试邀请。 简历三 分析 同学醒醒,你已经毕业工作三年了,还把毕业的硕士论文贴到简历中。。。。。。问题是,你这个毕业论文中还有 “ demo” 字样,可能你的毕业论文获过奖,但是大多数 HR 和 面试官都看不懂里面的行业术语,但是一定能看懂 “demo” 这个词,demo 给大家的感觉好像高大上不起来吧。。。。。。 这位同学作为一个非科班(动物科学)转计算机行业的人,已经成功入行三年了,为啥还要把自己本科的专业放在这么明显的位置,是强调自己转行不易、很努力吗?- -! 如果你不是科班出身,或者不是名牌大学(清华、北大、复旦、武大等)毕业,尤其是毕业几年了,就不要把学历和毕业院校放在这么显眼的位置吧,可以放在“兴趣爱好”之前。 求职意向也是一样的问题; 技术专长描述的也不好,一般我们看用人单位的招聘信息,也都是先写通用技术后写专业领域的技术,所以通用技术指的是算法数据结构、操作系统原理、网络编程等等;专业的技术,指的是 C++、Java、golang、python 等语言、各种框架、开源软件等。 另外,如果长得不是特别帅的话,就不建议放自己的照片了。。。。。。 简历四 分析 这个简历看完我是真的醉了。 大哥,我知道你没有拿得出手的项目经历和技术,可是你求职的是开发岗位,你也不至于把饲养猪的经历写进简历吧,虽然有些大厂自己给员工养猪吃,但是程序开发和养猪毕竟是两码事吧。。。。。。 简历五 分析 这份简历的项目描述也得太详细了,尤其是业务部分,感觉像项目招标书或者项目售前方案。。如果你求职的是技术开发类岗位,且你求职的下家公司与你简历中的项目不是同一个类型,那就把项目业务内容写得简略点,描述项目经历时多写一些技术内容。。。。。。说实话这份简历适合去应聘项目经理,尤其是公路局的项目经理。。。。。。 简历六 分析 这是一位大哥的简历,大哥已经工作十三年了,请读者看看这个项目经历描述是否有 13 年的水平?这项目描述实在太细了,首先可能把之前公司的商业技术机密全部泄露了。。。。。其次,和上面的简历六一样,多写点技术内容少写点业务内容不行吗,简历六可以应聘项目经理,这份简历可以应聘产品经理。。。。。。需求写的太细了,你确定是要找后端开发吗。。。。。。 简历七 分析 人常说,一份文案的整洁程度可以反映一个人的细致程度。这份简历存在两个问题: 个人技能这一块分类很混乱,例如“掌握 C++ 应用,理解底层原理,部分 c11 特性”中底层原理和 C++ 应用有什么关系,完全可以分开写嘛,另外 C++11已经目前已经被广泛使用,如果你不熟悉就不要写,写熟悉部分是熟悉多少?是告诉面试官自己这方面掌握的不好吗?原本面试时面试官可能不会问,看到这个可能说不定忍不住问几个 C++11 的东西;“多线程,同步,ipc通信等”中的“同步”难道不是针对多线程讲的吗?“熟悉设计模式、策略模式、单例模式、工厂模式”中策略模式、单例模式、工厂模式难道不是设计模式的一种吗?为何和设计模式一起用顿号并列起来? 简历中标点符号一会儿中文的逗号,一会儿英文的逗号,像 C++、Linux 这样的专用名词一会儿首字母大写,一会儿小写,导致整个排版脏乱不堪。 总结 成功的方法都差不多,错误的情形千奇百怪。因文章篇幅,就不贴更多的简历了,看完上面七份简历,你是否也有类似的情形呢?下面给大家总结一下投递简历注意事项和如何写技术简历。 一、投递简历时,如果投递到企业或者 HR 的邮箱,一定要在邮件主题中写清楚来意,一般是【XXX 求职或者应聘 XXX 职位】,例如【张小方应聘后端 C++ 开发岗位】,不然邮件很容易被忽略或者被邮件垃圾过滤系统所过滤,简历根本到不了 HR 或者面试官手里;简历附件的文件名尽量写清楚附件的内容,如 【XXX 求职 XXX 岗位】.pdf/doc/docx 的简历,如【张小方应聘字节跳动资深开发的简历】.pdf。切记文件不要出现类似“新建文件夹.pdf”、“新建压缩包.zip”、“1111.doc”、“简历.pdf”这样的文件命名,被 HR 下载后放在电脑上难以寻找,给别人阅读你的简历造成不便。 二、如果你是通过微信、QQ 等 IM 工具发给别人的简历,在求职期间为了方便交流,一定不要把自己的微信昵称、QQ 名、头像设置成不易识别的非主流名,如微信名设置成一片空白或者一片空白的头像或者根本很难搜索或者 at 出来的名字。举个例子,笔者曾见过一个面试者把自己的头像设置成一张母猪头,我原本计划和这位求职者多聊几句,看到这种头像实感不适,只好放弃。大家都很忙,尤其是在候选人众多的情况下,没人愿意在你身上因为这种问题花过多的时间。当然,如果你对那些特别的 IM 昵称有特殊的嗜好,建议在求职期间改成正常的,等找到工作后再改回去。 成年人的世界,没人会刻意迁就你,方便别人等于给自己机会。 三、简历中不要出现病句、错误的标点符号,尤其不要把一些重要的技术名词写错,非行首非行末的英文单词或者数字左右各一个空格。 四、简历的首部把自己的联系信息写清楚,不要写许多非重要信息,一般写上自己的姓名、电话、邮箱、性别、年龄、求职意向即可,像身高、体重啥的就别写了,没人对你身高、体重感兴趣。。。。。。另外不要留一些让人产生分裂印象的联系方式,如姓名和邮箱明显感觉不是一个人,如你的姓名是你自己,你的邮箱联系地址像你的老婆的..... 五、如果你不是科班出身或者不是名校出身,尤其是非应届生,就不要把你的教育经历放在简历醒目位置,一般建议把教育经历放到简历尾部。教育经历一定不要作假哦。 六、定位清楚自己求职的职位,如一般不要写求职“软件开发”、“后端开发”这样的字眼,这样的求职意向描述范围太宽泛了,既不利于企业筛选,也降低了你的获得面试邀请的机会。建议写成“C++ 软件开发”、“JAVA 后端开发”、“Linux C++ 后端开发 ”等具体职位描述。 七、写自己的技术栈的时候,要根据技术类型分清楚,尽量把不相关的技术分成不同的条目,先写通用技术再写专业的技术,最后写业务技术,下面是一份样例: 1. 熟悉常用的算法和数据结构; 2. 熟悉多线程编程技术,熟悉常见的线程同步、进程通信技术; 3. 熟悉网络编程,熟悉 TCP/IP 通信原理,熟悉 HTTP、FTP 等常用协议; 4. 熟悉 C/C++,熟悉 C++11,良好的面向对象思维和编码风格; 5. 熟悉 Linux 系统常用操作,熟练使用 gcc/gdb 等 Linux 下开发工具; 6. 熟悉 mysql、redis 等数据库原理,熟悉常见数据库调优技术; 7. 熟悉 kafka、RabbitMQ 等消息中间件; 8. 熟悉金融交易系统,有大宗交易系统开发经验。 八、从业经历建议分成工作经历和项目经历,工作经历写清楚从某年某月到某年某月你在哪家公司担任某某职位即可,项目经历介绍你的具体项目经历,如果你投递的下家公司和你的项目的业务是同行或者类似行业,可以多写一点项目业务介绍,反之粗略的交代下项目的背景、业务内容即可,多写点技术描述,写清楚你在这个项目中利用何种技术解决了或者达到了或者实现了什么效果,或者给公司或团队带来了什么收益,或者写你在该项目中遇到技术难题的攻关过程,千万不要写类似“通过该项目,我学习到了XXX”,企业招你来是干活的不是专门给你学习的,你给企业干活企业给你付工资,你这样写,是想不拿工资还给企业交学费吗? 项目中的技术描述要根据求职职位做一些收敛,尽量写自己掌握的或者熟悉的技术术语,这样一定程度上可以在面试时把面试话题往自己熟悉的技术栈上引;少出现自己不熟悉的技术栈或者技术术语,这样面试时容易出错,甚至出现不知所措的场景。 如果你的工作经历不长,你在项目中的角色可能是负责单个服务或者单个服务中的部分模块,此时写项目经历时可以多写点技术细节,如网络通信的协议细节、队列数据交换的设计细节、程序对数据加工的细节等等;但是如果你已经工作三年及以上的高级开发者,描写项目经历时,要侧重写一点对项目整体的框架或者架构的认知,如消息在各个服务中的流转过程、每个服务的作用、核心服务的结构、技术重难点等等。千万不要再像记流水账一样交代每个技术细节。 描述技术栈时针对自己求职的公司职位或者求职意向来写,例如求职开发职位,就弱化一些自己曾经做过的一些测试、运维或者项目经理的工作描述;求职后端开发,就不要写类似于 MFC、QT、VUE 等技术术语了;求职 Java 开发,就不要在简历中大写特写 C++、Python 等其他语种的项目或模块经历。尤其不要写与职位无关的经历,如果存在的话可以一笔带过。 项目描述中不要泄露之前公司的机密信息。 针对自己心仪的公司,要准备专门的简历,不要一份简历到处投递。 九、如果你有一些出色的开源项目或者已经发布的、可以被公众看到的产品展示,可以在简历中附上 GitHub 地址、技术博客地址或者项目上线地址。有的求职者博客少有高质量原创,或者 GitHub 的项目工程组织、代码风格混乱,甚至只有一个 README.md,这种就不要往简历中写了。大凡面试官看到求职者贴了技术博客或者 GitHub 地址都会要打开看一下的。 十、自我描述或者自我评价建议写一些积极的、与工作、学习相关的,例如乐观好学、沟通能力、组织能力、团队合作能力,不要写一些无关紧要的,或者自曝短处的描述,如喜欢玩英雄联盟,有强迫症,爱与人较真等。 限于笔者经验水平有限,文中一家之言难免有失偏颇,欢迎读者友善的提出自己的建议和意见,另外感谢文中提供简历精彩出演的各位知识星球小伙伴。 如需下载本站全部技术文章,可以在【高性能服务器开发】公众号回复关键字“文章下载”即可。最近更新时间: 2020-10-03 15:45:11 "},"articles/程序员的薪资与年终奖那些事儿/":{"url":"articles/程序员的薪资与年终奖那些事儿/","title":"程序员的薪资与年终奖那些事儿","keywords":"","body":"程序员的薪资与年终奖那些事儿 技术面试与HR谈薪资技巧 聊一聊程序员如何增加收入 谈一谈年终奖 如需下载本站全部技术文章,可以在【高性能服务器开发】公众号回复关键字“文章下载”即可。最近更新时间: 2020-10-08 13:10:33 "},"articles/程序员的薪资与年终奖那些事儿/技术面试与HR谈薪资技巧.html":{"url":"articles/程序员的薪资与年终奖那些事儿/技术面试与HR谈薪资技巧.html","title":"技术面试与HR谈薪资技巧","keywords":"","body":"技术面试与HR谈薪资技巧 作为“生在红旗下,长在春风里”的“四有新人”(现在90后00后还有知道这个词的吗?^_^),张小方同志从毕业至今,与各路HR、HRD斗智斗勇,再加上自己的不懈努力,历尽千辛万苦终于将毕业时的1500每月的薪资提高了二十几倍。本文就和大家唠唠这些年风里来雨里去无数次铩羽而归、兢兢业业、如履薄冰、诚惶诚恐、夜不能寐、枕戈待旦、惴惴不安、临盆一脚,最终守得云开见月明的谈薪经历。当然,本文说的主要是技术面试中谈薪的经历,主要针对的是一些社会人士求职,当然一些通用的原则同样适用于应届生求职。 面试官的级别 一般技术面试的模式是 n + 1 或者是 1 + n + 1,什么意思呢?其中 n 指的是你见到的不同级别的面试官的个数,1 指的你见到的hr。 两种模式 模式一:一般技术面试有两种情形,你进入公司以后,会让你填写一些个人资料,如果有笔试题,也会做一些笔试题,接着HR会先找你简单地聊几句了解一下你的情况,然后通知技术面试官过来面试,如果一轮或者多轮技术面试后,面试官觉得你还不错,HR会接着详细地了解一下你的情况,如之前做什么工作的、是否已经离职、是否成家有小孩,当然最最关键的是询问你期望的薪资。这就是所谓的“1 + n + 1”模式,即开始由HR面试,中间是技术面试,最后还是HR面试。 模式二: 在登记个人信息和笔试(如果有的话)完以后,直接就是技术面试,技术面试结束后如果面试官觉得你还不错,HR会接着进行人事面试。这就是所谓的“n + 1”模式,即开始是技术面试,最后由HR面试。 当然,可能在你去公司现场面试之前,会有一些电话或者远程视频的面试的形式,这类基本上也属于技术面试的范畴。这里就不再赘述了。 面试官的级别和面试轮次 一般公司的的级别是按如下方式划分的:普通员工上面是部门经理(或技术主管),部分经理(或技术主管)上面是总监(即所谓的大领导)(规模小的公司或者扁平化的公司上面就是CTO、副总或者CEO了,因此到这一层就没有了),总监(大领导)上面一般是公司的CTO、副总或者老板了。 如果是应聘初、中级的岗位,第一轮面试官一般是你的职位所在的部门的经理。一般经理觉得没问题,接着就是HR面试了,也就是说就这么两类人来面试你。 如果你面试的是高级、资深岗位,第一轮面试官是所在部门的经理,只要面试表现不是太差,经理会让总监来继续第二轮面试你。如果总监觉得也没有问题,接着就是HR面试了。 如果你面试的是经理或开发主管的职位,第一轮面试一般是相关部门与你职级差不多的部门经理来面试你,注意这一轮大多数一般只是简单地聊一聊(走过场),然后由上一级总监(大领导)来进行第二轮面试(这轮面试很关键),如果这一轮面试也OK的话,会由大领导的上一级,如公司CTO或副总甚至CEO进行第三轮面试甚至第四轮面试。如果以上都没问题,接着就是HR或HRD的人事面试了。 注意,我这里面试的轮次是按职级划分的,而不是按次数划分的。实际上在大多数公司,会由搭配交叉部门的其他同事来面试。举个例子,例如你求职的是高级开发,除了部门经理 A1 和大领导 B 会面试你,部门经理 A1 或者大领导 B 可能会邀请其他部门的某个同事C或者领导A2来面试你,这里的职级按从低到高依次是C 人事面试 说完了技术面试官的职级和面试次数,再来说说人事面试,一般人事面试是最后一轮面试。你需要注意的是——HR 一般没有决策权,也就是说 HR 没有权利决定你最终的去留,她们只是转达用人部门的意见。当然,也不排除有少数强势的 HR 或 HRD,大多数的HR都没有一票否决权。所以,如果HR和你详谈时,也说明你前面的面试结果不是太差。这个时候,你要做的就是尽量在公司可接受的范围内达到自己利益最大化就可以了。 在我看来,大多数HR虽然看起来美丽大方,但是都是“妖精”,尤其是一些阅人无数的资深HR,那简直是“职场白骨精”(调侃一下,没有恶意,请各位HR勿怪)。 首先,由于她们有自己的绩效考核,即最短时间、最低的成本招到最合适的人才。据我所知,举个例子,比如一个计划最高可以用25K招到的人,现在某个HR用20K就招到了,那节省下来的5K就会算作HR的绩效,所以这也是HR为什么会找你谈工资的目的了,其核心目的其实就是为了压工资。减少人力资源成本是负责招聘的HR的一项重要职责。 其次,由于大多数HR没有否决权,只是忠实地转达用人部门的意见。所以你问她的一些问题,大多数情况下是得不到任何实质性的答案的,一般都是些场面上的官话。所以,你也不用问诸如“面试成绩如何”、“面试官对你的影响如何”、“什么时候给面试结果”之类的傻傻的问题。 当然,HR与你谈论很多问题,其实是通过交流中了解你这个人的性格、反应能力、情商、经历和资历等信息,以最大化地为公司招到一个合适的人,排除一些人事隐患。比如,HR一般会问男同胞是否有女朋友、是否结婚、老家是哪里的等等,这些不是说HR要查你的户口(这个从身份证信息上就能看出来),而是看你这个人未来几年是否稳定,一般成家就意味着责任感,而不是要刺探你的婚姻状况。 当然读者最关心的可能就是如何谈薪资。这里单独来开一节来详细讨论下这个问题。 谈薪的基本要点与脱坑技巧 谈薪是一个与HR斗智斗勇的过程,在谈薪的过程中有很多坑。一般HR会问你期望的薪资,然后就你的报价(请原谅我用这个词,谈好薪确实就等于把自己卖了 - -!)进行讨价还价,当然不和你讨价还价的HR也有,一般有两种:第一种,你的报价实在太高,已经远远超过公司的预算,HR觉得没有谈下去的必要;第二种,天使。第二种,我反正是从来没遇到过。除了总监及以上职位,一般你求职的JD上都会有一个薪资范围,你报价时可以参考一下这个。其次就是,除非你能力特别优秀,面试效果特别好,否则 IT 行业一般的涨薪最大幅度是你前一份工作的百分之三十,也就是说如果你前一份工作月薪是20K,那么你这份工作你最多可以报价27K。 一般与HR谈薪的过程中,即要展示自己对求职的职位有很大的兴趣,但又不要暴露自己想尽快找到工作的想法,尤其是在你手头上没有offer 、且已经离职的情况下,这样会让自己很被动,你迫切需要一份工作,而现在又无多余的选择,这样HR就会使劲压制你的薪资。 HR与你谈论薪资经常有如下套路: HR: 您期望的薪资是多少? 你: 25K。 OK,你已经被HR成功套路。这个时候你的最高价就是25K了,然后HR会顺着这个价往下砍,所以你最终的薪资一般都会低于25K。等你接到offer,你的心里肯定充满了各种“悔恨”:其实当时报价26、27甚至28、29也是可以的。 正确的回答可以这样,并且还能够反套路一下HR: HR: 您期望的薪资是多少? 你: 就我的面试表现,贵公司最高可以给多少薪水? 哈哈,如果经验不够老道的HR可能就真会说出一个报价(如25K)来,然后,你就可以很开心地顺着这个价慢慢地往上谈了。所以这种情况下,你最终的薪资肯定是大于25K的。当然,经验老道的HR会给你一句很官方的套话: HR: 您期望的薪资是多少? 你: 就我的面试表现,贵公司最高可以给多少薪水? HR: 这个暂且没法确定,要结合您几轮面试结果和用人部门的意见来综合评定。 如果HR这么回答你,我的建议是这样的: 虽然薪资很重要,但是我个人觉得这不是最重要的。我有以下建议: 如果你觉得你技术面试效果很好,可以报一个高一点的薪资,这样如果HR想要你,会找你商量的。 如果你觉得技术面试效果一般,但是你比较想进这家公司,可以报一个折中的薪资。 如果你觉得面试效果很好,但是你不想进这家公司,你可以适当“漫天要价”一下。 如果你觉得面试效果不好,但是你想进这家公司,你可以开一个稍微低一点的工资。 需要注意的是,面试求职是一个双向选择的过程。面试应该做到不卑不亢,千万不要因为面试结果不好,就低声下气地乞求工作,每个人的工作经历和经验都是不一样的,技术面试不好,知道自己的短板针对性地补缺补差就行,而不是在人事关系上动歪脑筋。当然也不要盲目自信,把自己的无知当理所当然,谦虚一点。笔者曾经面试Intel时,因为单词“cache”的读音和面试官争论了很久,面试官读“cash”,我坚持认为读“cake”,这里就闹了一个笑话。 除了和HR谈薪有陷阱,和技术面试官谈薪也一般存在一些陷阱。大多数公司都要求总监级别以下的面试官不得询问面试者期望薪资,但是也不排除一些面试官的个人好奇心,”无意中“向面试者询问该问题。除了级别高的领导,一般面试官是无权询问薪资的,此时面试者就要留心了。如果被问到,可以委婉地回答一下,如可以说:现在还不确定,会按招聘信息的薪资范围和结合自己的面试结果来提出一个期望薪资。如果实在绕不过去这个问题,可以把薪资说的低一点(口头上只是说说而已),实际薪资还是由高层领导决定并最终和HR谈的。原因是因为,在信息不对称的情况下,如果你报的薪资过高,超过当前面试官的薪资,很可能引起当前面试官的不愉快,造成对自己非技术上的差评,造成失去入职该公司的机会。 谈薪资还有一个坑,你一定要搞清楚公司的薪资构成,就是尽量把月薪或者基础薪资谈高一点。说说我之前的两段经历: 经历一:我之前有份工作,HR和我谈的时候,说是月薪14K。但是我实际进去以后,发现14K分为基础工资和绩效工资,这其中6K是基本工资,剩下的8K是绩效工资,而每个月会有一个绩效系数,系数范围是从0.8~1.5,也就是说当你某个月绩效系数是1时,你拿到完整的8K;如果你系数是0.8时,你只能拿到6400,也就是辛辛苦苦干了一个月实际拿到手的只有6000 + 6400 = 12400,平白无故地少了1600。而公司对外宣称你的月薪是14K,还有就是基本上绩效系数在1以上的都没有。 经历二:我后来还有一份工作,我当时要月薪30K 13,公司坚持要给我26K 15(年薪都是39W),也就是说前者是一个月的年终奖,后者是3个月的年终奖。等我入职以后也发现是个大坑,首先,所谓的年终奖并不会写到劳动合同中去,所以公司发不发、发多少就要看公司的良心了。大多数公司,至少会发一个月的年终奖。但是多于一个月就很难说了,一般就按所谓的绩效系数,这里15薪的系数就是从1到3,但是实际上大多数人年底系数也就是1。其次,很多公司年终奖还有其他的一些规定,比如年终奖的比例按你当年在公司工作的实际天数来算,也就是说,假设你是6月1日入职的,正好是半年,你的年终奖只能拿一半,这就是所谓的年终奖 = 预先设定的金额 * n / 365,n 是你当年实际工作的自然日。如此一比,相信读者应该能看出30K 13 与 26K 15的巨大差别了吧。 所以,不要被HR以少交税、年薪、期权等各种\"诡计\"所迷惑,在同等水平下,尽量把写到合同里面薪资谈高一点。多交税也不一定是坏事,以上海这边为例,税交多一点,对上海市的社会贡献就高一点,很多政策就会对你倾斜一点。 职业发展与薪资 对于工作年限不长的读者,我个人建议是,如果两份工作月薪相差八千以内,我会优先选择更利于个人发展到人工作机会,而不是工资高一点的。对于努力的人来说,早些年每个月多几千或者少几千块钱,对以后的生活基本上没多大影响的,尤其是现在普遍存不住钱的职场新人。你更在乎的应该是你的平台、职业发展机会。这点尤其适用于求职的应届生。 因作者个人经历和经验也有限,文中言论属一家之言,难免有失偏颇,请各位读者斟酌,欢迎温和地提出建议和批评。 原创不易,写了这么多,打个小广告。 如果读者在技术学习或者职业发展上有任何困惑,可以加入我的知识星球【小方说服务器开发】寻求帮助,在这里你不仅可以获得认真的问题解答,还能获得很多内部学习资料和不定期的内部免费技术分享。在这里,你将看到别人看不到的内部经验! 有兴趣的读者,长按下图,微信扫码即可加入: 往期推荐 求如何成为一名合格的C/C++开发者? C++高性能服务器网络框架设计细节 写给那些傻傻想做服务器开发的朋友 我的github开源软件列表 后台开发面试会问哪些问题? 后台开发应该读的书 如需下载本站全部技术文章,可以在【高性能服务器开发】公众号回复关键字“文章下载”即可。最近更新时间: 2020-06-07 12:06:08 "},"articles/程序员的薪资与年终奖那些事儿/聊一聊程序员如何增加收入.html":{"url":"articles/程序员的薪资与年终奖那些事儿/聊一聊程序员如何增加收入.html","title":"聊一聊程序员如何增加收入","keywords":"","body":"聊一聊程序员如何增加收入 亲爱的读者朋友,你好。我是高性能服务器开发公众号的作者,范蠡。一些老的读者应该知道,我有个同名 QQ 群叫高性能服务器开发(研究)群,目前两个群加在一起,也快五千人了吧。很多群友不止一次的想了解我的收入情况,粗略的算了一下,今年一年到目前为止,大概有一百万。然而,这种程度的收入在上海这样的城市,依旧是买不起房,解决不了生活的大问题,比上不足比下有余吧。 咱公众号的大多数读者应该都是从事程序开发或者相关的,你或许在北京上海深圳,或许在南京武汉广州,或许在郑州合肥,或许在其他一些二线三线城市。大多数程序员其实是单纯而又朴实的,晚上可能在骂老板抠门、产品经理SB,但明天早上依然会早起去挤地铁,认真写每一行代码,因为高堂明镜悲白发,朝如青丝暮成雪,一天天老去的父母,需要我们赡养;\"笑看妻子愁何在,漫卷诗书喜欲狂”,一天天长大的孩子,需要我们去养育。哪个程序员曾经不是不为五斗米而折腰的男子,如今却不得过着李白洗尿布一样的生活?所以,尽管有时候我们有千万种不愿意,但还是不得说这言不由衷的话,做着自己不想做的事情——因为我们缺钱。 今天,我们就和大家讨论一下程序员如何提高收入,当然,由于个人经历经验有限,难免是一家之言,文中内容仅供参考,欢迎温和地提出意见和建议。 程序员们的主要收入来源 这个标题其实不言自明的,程序员们,当然对于大多数上班族,工作是收入的主要来源。看到群里很多学生讨论 offer 薪资的时候,动辄就月薪 30 k 甚至如 45 k以及更高的,虽然不排除确实存在这一类的 SP 或者 SSP offer。但是大多数人会是这类高收入者或者幸运儿吗?月薪 30 k 意味着什么?意味着在一个小城市两口之家半年多的生活费,意味着在中国广大农村一家两三年的生活成本。老板都不是傻子,你能干多少活才会给你多少钱,那么 30 k 需要干多少活呢?其他的城市我不熟悉,以我所在的上海为例吧,对于一般学校毕业的应届毕业生月薪 5 k 起步,硕士会稍微高上 3~5 k,工作两年月薪在 12k~16k 之间,工作四五年月薪在 20 k ~26 k 之间,达到 30 k 及以上,一般需要工作七八年以上。如果较短工作年限,需要达到较高收入水平的,都是技术非常好或者能力特别强的。我工作三多年时,在一家做公司做音视频实时通讯技术,月薪 26 k。但是工作内容和工作量就很大,当时负责 pc 、安卓、iOS、mac 四个端的 C++ sdk 开发和维护,同时负责这几个端的 Java sdk 开发,每天需要处理多家客户使用这些 sdk 报的各种问题。每天晚上九点下班,被项目经理看到,她会说,你今天下班真早啊。我印象深刻的是,那一年春节,从年二十五到正月初六每天早上九点,我需要准时参加公司的项目会议,汇报项目状况和进度,每天二十四小时要随时响应。 我们大多人毕业学校一般、学历也一般,而且也不是特别努力,本身存在\"先天不足”。高中或者大学不努力,毕业后本来起点就比名校或者努力的同学差上一截,这一截可能不是 0.1 到 0.11 的距离,可能是 0.1 到 10 的距离。哈佛大学有句校训是这样写的:今天不走,明天要跑。这句话是很有道理的,你从前不努力的阿喀琉斯之踵,可能在短期内对你没多少影响,但是有一天生活的压力,会逼着你补救之,补救的日子里你会觉得异常辛苦。例如人到中年,上有老小,加班加点为了那点微薄的薪资,在行业或者公司不景气时,被公司无情的降薪或者裁员。然后偷偷地抹掉眼泪,整理简历,为下一份微不足道的薪水继续努力。 中国有句老话叫,失之东隅,收之桑榆。意思是说,如果你失去了早上的朝阳,那么你一定要及时抓住晚上的夕阳,它是你最后弥补的机会。既然工资是主要收入来源,那么提高职场竞争力是加薪升职的唯一途径。而对于程序员来说就是提高技术能力和开阔视野。不管你是什么原因入了此行,既然选择了这一行,凑合或者破罐子破摔在这一行是行不通的,互联网行业的特点就是变化迅速,你需要不断学习去适应新的变化。你可能并不喜欢这份职业,这就如同一场婚姻一样,你可能对你的对象不满意,但是大多数人都没有推倒重来(离婚)的勇气和资本。如果你不尝试去与你这个不满意的爱人去培养感情,你的心情只会更加恶心,生活只会更糟。所以,从现在努力,好好培养对技术的热情还来得及,这就是所谓的先结婚再恋爱。不要盲目相信网上所谓程序员 35 岁危机,真正的技术大神是不会有啥危机的。我个人的经历告诉我,30 岁之前的每个月工资多几千块少几千块,对后来的生活真的没多大影响。对于开发人员来说,影响你后期收入却是人到中年的技术实力。我自做公众号以来,因为我的号(高性能服务器开发)是以技术为主,也认识了许许多多的技术号主,但是很多号的号主其实并不是做技术的,因为我本人是个技术痴迷者,所以我对那些技术实力一般的号主都不怎么感冒,反之我会主动约一些技术实力非常好的号主线下见面。在我的了解中,这些坚持做好技术的号主,工资收入都不低,年薪基本都在 50 W+,甚至有到 150 W。当然,技术实力好的,还有许多其他的优势,例如不用担心被裁员、不用担心找不到好工作,而且可能利用自己的技术去轻松地赚一些钱(下文会详细介绍)。 说了这么多,我建议亲爱的读者,你,如果是从事开发的,那么一定要热爱技术,并努力把它学好,因为它是你吃饭的家伙。吃饭的家伙都不重视,那还能指望你有多大的提高?虽然一些人从技术成功转型了,也赚了不少钱,但是这些都是个例,不具有普适性,你觉得你会成为那个幸运的个例吗? 有读者可能会问,那如何学好技术呢?我个人觉得是肯对自己投资。很多人会愿意为自己一趟旅游、一顿大餐花许多钱,却为自己买本书、买个课程、报个学习班的几十或几百块钱而纠结半天。消费行为分为投资型消费和纯消费型消费,工作的早些年,你一定要肯为自己多一些投资型消费。例如,我月薪不过万的时候,我会为见一个技术前辈一面,从上海跑到北京,转好几次车;会在得到 App 上花 1500 块钱约某个技术大神去咖啡店聊上两个小时。很多高人或行业前辈,我们在现实生活中可能永远都没机会与他们接触,但是现在的知识付费平台,给我们提供了很多机会。或许高人前辈的一句话,一个建议或者思路就能让你受益无穷。这样的例子自古有之,我这里就不举例了。 要对自己负责,学习和提高是自己的事情。我发现现在很多的人,出了社会之后还是学生时代被老师教的思维。学校里面老师教你是因为你交了并不便宜的学费给学校,学校给老师发工资和补助。但是到了社会上,大家都很忙,别人凭什么要给你无偿提供帮助或者解决问题;别人提供了一份学习资料,你自己没保存,过几天别人删掉了,你又腆着脸让别人再分享一次;别人给你解决问题,你却说你不方便,让别人等一会儿。或者是你觉得工作太忙、孩子吵得太凶没时间学习等等。这些都是理由和借口,都没把自己的学习和提高当自己的事情。 提高技术,先解决思想上的问题,再解决行动上的问题,这样就容易的多了。其实现实生活中大多数人都不努力,或者貌似很努力,所以你只要稍微真努力一点,你就能超过 90% 的人了。不信你可以试一试。两年前加入高性能服务器开发群的,并认真听我的建议付诸行动的群友,现在年薪都 50 W 了吧。 程序员的副业 程序员有哪些副业?很多人说去接外包,但是我并不建议你去接各种外包,尤其是那些需求不是很明确或者金额达到上千的外包项目。由于外包项目一般很难有明确的需求,尤其是和非技术出身的甲方人员对接时,很多功能的界限和定义都是不明确的,例如为一个即时通讯软件做一个\"发送消息功能”,这个\"发送消息功能\"可多可少,可轻可重。发文字发表情比较简单,发图片就不容易做了,而发语音视频尤其是发实时的语音和视频的工作的量是需要一个专门的专业团队至少花上好几个月的。需求不明确的结果就导致容易出现反复沟通和返工,这会耗费你大量的时间和精力,必然会影响你正常的工作和生活,尤其是对于本职工作本来就忙碌的程序员们来说。而最后可能因为甲方的不满意,必然导致不会按期按量付款。当然,现在很多外包平台正在改善这一状况,如码云、开源中国社区、程序员客栈,不过还是存在不少问题。 除了外包,我们再来聊一聊知识付费,知识付费主要是程序员给各大知识付费站点或平台录制或者写作技术教程。文字系列的知识付费课程,国内做的比较好的有极客时间、GitChat 和 掘金社区。由于商业的运作,很多课程的标题和内容比较容易吸引用户购买,当然内容质量也是有保证的。如果你在某些技术方面有积累或者独到之处,可以尝试在这些平台上写一些专栏课程。但是,很多人看到别人的专栏动辄几千甚至上万的购买量,加上定价都在两位数,觉得作者一定通过课程赚到一笔不少的收入。其实也未必,一般的课程在开售前都有一定的基础数量,比如某个课程可能还没开始出售,就有 100 的购买量,这类纯粹是为了吸引用户去购买的。另外,很多课程都会被平台拿去做一些商业活动,如打折优惠、会员免费学习等等,通过这个形式购买的收入,平台会拿去不少一部分,分到每个作者的并不多。最后,剩下的的终于结算给作者了,平台又会为作者缴纳不少的个人所得税(纳税光荣!纳税光荣!纳税光荣!),最后到作者这里就剩下十之三四了。 视频型的知识付费平台,以慕课网和网易云课堂为例,当然由于平台对你录制的课程有一定的质量要求,你需要花费不少时间和精力去撰写课程教案和 PPT,提前练习,保证录制的视频讲解流畅、技术娴熟、知识专业。这类对一般的程序员属于比较重量级的副业了,有一定的难度。 再来说语音型的知识付费平台,例如得到、知乎 live。这里以知乎 live 为例,在知乎举办一场 live,为了保证质量,平台需要你进行资格认证,例如你说你在某某大公司任职,那需要你提交在该公司的工牌、身份证件或者劳动合同;你说你是某方面的专家,你需要有那一方面的相关证书,另外需要缴纳 500 块钱的保证金,这个用途是,如果你不能按期按质举办你的 live,那么这个保证金将不会退还给你。知乎 live 是我比较喜欢的一种形式,主要是比较省事,举办一次,每个月都会一点收入(同样需要缴税),我在知乎上举办过三场开发方面的 live,一年多时间,所有收入加起来大概有一万块钱左右。如果你在大城市生活和工作,可能觉得这没多少钱(我就是),但是如果你在像郑州、合肥这样的二三线城市做 IT,由于这类城市程序员的收入本身就不高,一万块钱绝对至少抵得上一两个月的收入,可以让生活负担小一点。所以建议在这类城市工作的读者可以尝试一下。 再来说做公众号。做公众号赚钱吗?这不能一概而论。公众号的收入主要有三个来源,来源一是公众号的流量主,来源二是原创文章的打赏,来源三是公众号的广告收入。腾讯微信公众号是一个非常不错重视和保护原创作者权益的平台。新注册的公众号,现在只要粉丝达到 500 就可以开通流量主,流量主开通之后微信会在公众号文章的中间(文中)或者底部插入广告,当用户看到这个广告或者点击这些广告,公众号主就会有一点收入,收入多少与用户阅读这个广告的次数(曝光量)和点击量有关。我一般不开公众号文中广告,那样对读者阅读体验不好。当然,这种流量主的收入基本很少,不过如果你坚持原创的话,每天利用流量主的收入用餐时给自己加个鸡蛋或者鸡腿还是可以的。原创文章的打赏是公众号的收入的第二个来源,这类收入比较少,尤其是技术类公众号,一般很少有读者会为你打赏的。也就是说公众号的广告收入是公众号的主要来源,因为流量主和打赏实在太少了。辛辛苦苦每天写文章和排版,其实也不容易,所以希望读者在看到一些公众号发广告时可以多一点理解,少一点抨击。经常有一些号主反映只要一发广告就有读者在后台开骂。每天发文章,你也没怎么打赏,号主也要吃饭,不喜欢就取关,没必要骂的。当你的公众号平均阅读量达到一定数量时,会有广告商主动联系你,给你投放广告。按目前的市场行情,广告文的单价是根据文章平均阅读量来算的,平均下来是 0.7~1.2 元/阅读量。也就是说一篇广告文阅读量如果在四五千,那么一篇广告收入也会有四五千。当然,平均阅读量达到四五千也不容易,一般可能是十万粉丝。我个人觉得月薪三万容易,公众号三万粉丝却不容易。当然,就技术圈号主来说,大多数号还是比较良心的,不是每种广告都会接,一般理财类、美容类、保健类等等都不会接。目前不少大号的(粉丝量超过 10 W)的号主,都辞去了工作,全职做公众号。他们的理由是:既然副业收入已经超过主业(上班)了,在公司继续上班就是亏钱,不如辞职全身心做副业。当然,我自己不会走这条路的,我还想在技术上继续精进,所以会去更多公司挑战对系统要求更高的业务。所以,读者不用担心,此号会继续给大家分享高质量的后台开发技术,但是由于我有正常的全职工作,做不到每天都推送一篇高质量的原创,希望读者能理解。 基于公众号,很多号主会做一些付费增值服务,如付费知识群、专栏课程、知识星球、线下付费活动等等,这个读者可以按需选取。以知识星球为例,有些知识星球只需花少许费用,的确能让人耳目一新。 与公众号类似的平台还有很多,一些全职做自媒体的个人或者公司,他们除了运营公众号以外,还有今日头条号、百度的百家号、抖音号、简书、知乎、新浪微博等等。如果有兴趣的读者也可以试试。 另外一些就是提供一些付费咨询,例如知乎、分答的付费的咨询。 还有一类是出书,书的编写方式有\"著\"、\"编写\"和\"译\",其中\"著\"是完全原创,要求比较高;\"编写\"一般是原创一部分,整理撰写一部分;\"译\"就是翻译国外的书籍。你可以主动联系出版社沟通出书计划。大多数时候,当你在某个平台上的某个领域有一定的影响之后,会有出版社的编辑或者图书策划公司的工作人员主动联系你写书。我就是在知乎上发过一些列高性能服务器开发专题的文章,被出版社主动联系的。当然,写书是一件非常磨人的事情,写书的过程是一个很痛苦的过程,和写博客不同,作者需要小心翼翼,为自己的每句话负责,以免出现技术性错误或者造成误解(以免误人子弟)。同时需要规划书籍整体内容,要根据出版社编辑的意见反复修改,字句要反复斟酌,版面要反复优化。不过,写书也是很锻炼人的事情,你不仅可以系统性地梳理一下你在那个领域的知识体系,在和出版社老师沟通的过程中学到很多写作和排版的技巧;而且写书会让你在某个领域增加一点名气和\"光环\",对你将来的求职和谈薪都有一定的加成作用。很多人的可能会说写书也能得到一大笔稿费,实际情况是靠写书真赚不了多少钱。出版一本书,一般的作者只能拿到 8% 的分成,名气大一点的或者销量好一点的可以拿到 10%,也就是说一本定价 100 元的书,每卖出去一本你可以拿 8 块钱,卖出去三千本(可能很难),是 24000,然后再缴去一部分个人所得税,到手其实也不没多少。 以上介绍了一些常见的程序员的一些副业。但是我还想提醒一下读者,不要光看到别人搞公众号和在知识付费平台写专栏赚钱了,尤其是做公众号,如果你是一名初入职场或者技术不是特别好的开发人员,一定不要把重心放在这上面,一定要把学习和积累技术作为中心,否则可能会捡了芝麻丢了西瓜。我在 GitChat 上写《C/C++ 多线程编程》时,我已经使用 C++ 快 10 年了,这 10 年了利用 C/C++ 开发过大大小小的系统,有客户端也有服务器程序,所以该课程总结了是我这 10 年中 C/C++ 多线程编程最常用和实用的技术的重难点。举个例子,课程中我介绍了条件变量时只介绍了 Linux 系统上的条件变量,而没有介绍 Windows 系统上的条件变量,不是因为 Windows 系统上不存在条件变量,而是在 Windows 上使用它的场景我基本没见过,为此我翻阅过大量的源码,如金山卫士、电驴、filezilla 等。 最后,希望本文对身为开发者的你有一点启发,那就善莫大焉了。感谢阅读~ 如需下载本站全部技术文章,可以在【高性能服务器开发】公众号回复关键字“文章下载”即可。最近更新时间: 2020-10-08 13:27:59 "},"articles/程序员的薪资与年终奖那些事儿/谈一谈年终奖.html":{"url":"articles/程序员的薪资与年终奖那些事儿/谈一谈年终奖.html","title":"谈一谈年终奖","keywords":"","body":"谈一谈年终奖 转眼 2019 年就快过完了,对于广大程序员读者来说,重要的事情除了关心能不能买到回老家的车票以外,剩下的事情应该就属年终奖了。 对于 IT 行业来说,所谓年终奖其实就是公司在当年的月底基于你工资的数额发一定的比例的奖金,这也是很多企业的 HR 和猎头向求职者“许诺”的待遇之一。关于年终奖,一般是求职者在应聘时和 HR 谈好,再结合所在的公司的规定在年终兑现给求职者。但是,城市的套路太深了,本文就和大家讨论一下关于年终奖的那些坑,希望对读者朋友有一定启发意义。 年终奖的计算套路 先来说通用规则吧。IT 行业默认的不成文的规定,大多数公司,对于普通员工的年终奖一般是月底多发一个月工资,也就是所谓的 13 薪,这个基本上是保底的。对于从事开发的小伙伴来说,这个规则适用于初中高级,对于技术专家或者开发经理及以上级别一般保底工资会大于 13 薪,常见的是 14 ~ 16 薪不等,总结起来,就是所谓 12 + n,n 的可能取值是 1 ~ 5,它们就是所谓的年终奖,这是大多数公司的通用做法。但是在这些规定的基础之上不同的公司也有一些特殊的规定,常见的有如下几种形式: 年终奖的数量是 n 个月的月薪,但是要根据员工在当年在公司实际工作的天数来定,也就是说员工实际拿到的年终奖数目是 年终奖数目 = 月薪 n (员工当年实际工作的天数 / 365) 举个例子,员工小明在某公司当年工作半年,其月薪是 20k, 当时和 HR 谈好是 2 个月年终奖(n = 2),那么小明当年拿到的年终奖是数额是:20000 2 0.5 = 20000。 ok,有读者看到这里可能美滋滋,他可能会想,今年 12 月 1 日入职现在的公司,按这个规则年终奖是 2 个月,那么我今年的年终奖可以拿到 20000 2 (1 / 12) = 3333,3333 元也不少啦,过年回家给长辈或者小朋友包个红包,或者给女朋友买几件衣服也是戳戳有余的啦。我只能说,这位读者想多了。因为某些公司还有第 2 条规定。 计算年终奖系数时虽然按员工当年实际工作的天数 / 365,但是如果员工当年实际工作的天数小于某个数值(例如 2 个月),则系数为 0。也就是说,很多 12 月份入职该公司的小伙伴在当年大概率是没有年终奖的。 除了上述两个规定外,企业对于年终奖还有一个比较常见的规定,就是年终奖绩效正态分布制。啥意思呢?举个例子,我在求职某大型旅游互联网公司时,HR 告诉我待遇是 16 薪,于是我就相信了。等到当年年底的时候发现,果然是 16 薪啊,但是每个员工都需要进行绩效考评,绩效分为 ABCD 四个等级,A 最优,D 最差,且 ABCD 四个等级的比例是 20%、30%、30%、20%,也就是说一个部门 10 个人,实际上只有 2 个人能拿到 16 薪,剩下的依次是 15 薪、14 薪和 13 薪。到此时,我也只能无奈的接受现实。 这也是为什么同等的年薪,HR 在和你谈薪时,会尽量压低你的月薪,而承诺多给你月薪次数。举个例子,同样的年薪 39 W,你要求按 30k 13 来发,但是 HR 会找你谈判希望是以 26k 15 来发。HR 会“站在你的角度苦口婆心“的劝说你,员工上班不容易, 26k 15 相比 30k 13 的方式,每个月可以少交很多税等理由。但是请读者注意:这里感觉好像总额是一样的,但是差别其实非常大,30k 13 虽然多交了一点税(纳税光荣),但是比 26k 15 更有保障一些。首先,30k 是实实在在每个月的月薪,发 13 个月(多发一个月的年终奖)基本也不成问题;但是对于 26k 15 这个 15 薪多出来的 3 个月属于年终奖部分,由于年终奖具体是多少一般不会明确的写到劳动合同中去的,根据上文介绍的公司对于年终奖的数额发放比例和计算规则来说,你最终实际上能拿到 26k 13 已经算不错的了。 另外一种绩效规则就是,根据员工的当年表现,给员工一个绩效值,例如我曾经的一个公司,绩效值范围 0.5 ~ 1.5,如果满额的年终奖是 2 万(月薪),你年底的绩效值是 0.5,你的年终奖拿 20000 0.5 = 10000 元,如果你年终的绩效值是 1.5,你的年终奖是 20000 1.5 = 30000 元。这里不得不吐槽一些公司的一些拿着鸡毛当令箭的领导,有些公司的某些部门的领导(开发经理或者主管级别),在公司没有规定某个绩效值不少于或者不多于某个值的情况,年年给部门下面的任劳任怨的某些员工的绩效系数都评为 1.0。我的言外之意就是,部门员工认认真真干好本职工作,在公司干了三五年,薪资基本不涨。 另外,也有一些公司将年终奖一分为二,分为年中奖和年终奖部分,为这类公司点个赞。例如某公司某员工的是 14 薪,其中额外的 2 个月分两次在当年的 6 月份和 12 月份发放,每次多发 1 个月。当然这类发放方式中具体年中和年终部分是多少也可能按绩效来评比,但员工能尽快拿到手,离职损失最少。 当然,上面说的是一些普遍的计算年终奖的规则和套路,对于那些以高绩效来确定员工年终奖不在此列。例如游戏公司,某年的腾讯的王者荣耀开发团队的年终奖。 年终奖的发放时间套路 对于大多数良心企业,发放年终奖的时间,会在次年 1 月份随着上一年的 12 月份工资一起发放,当然不一定是一次到账,可能是 12 月份工资先到账,然后过几天年终奖到账,或者反过来。对于一些员工人数比较多,考核流程比较长,年终奖的发放数额需要一段较长的时间才会统计出来,这类企业年终奖一般会在次年的三四月发放。这两类公司的年终奖发放时间,都可以让人接受。但是下面一些公司发放年终奖的方式就让人非常鄙视: 我们知道每年过完年的三四月份是离职求职的高峰期,很多公司为了防止员工离职,故意拖延前一年的年终奖,甚至有拖到第二年的七八月份的,且规定在这之前离职年终奖就没有了。大多数员工针对这种 情况都只能默默的接受现实:要不选择熬到年终奖发放的那一天之后再离职,可能因此也错过了一些更好的工作机会;要不就是壮士断腕,选择放弃年终奖。后者,对于工作没几年的人来说,年终奖数额不大,没多大影响,但是对于已经工作不少年了,年龄偏大,养活一家老小,这一笔年终奖金额会比较大,放弃很可惜,不放弃又错失新的工作机会,就比较可惜。 我曾经有一份工作,老板故意拖着年终奖不发,直到第二年五六月都没有发,同事们议论纷纷,HR 竟然在群里和大家说,据她了解大多数公司在第二年下半年发放前一年的年终奖是非常普遍的现象。 公司业绩不好的情况下,高级员工年终奖减半,普通员工可能没有年终奖。我遇到过这样一类公司,那一年公司先是在快到年底时裁掉了一批员工,剩下的的员工公司不再裁员,但是当年年终奖大大折扣了。这样的举动也会变相导致员工大批离职。 离职到底有没有年终奖? 我相信很多读者很关心这个问题,毕竟和月薪相比,年终奖可能是大头部分。除去上文介绍的无良公司离职时没有年终奖的情况,一般公司会有一个明文规定,规定在当年的某月某日之前(例如元旦之前)的离职是没有年终奖的,所以如果有读者要离职但有关心年终奖能否拿的到可以查询下公司的相关规章制度。这个日期,一般是当年结束之前的某一天就是合理的,但是倘若在次年的某月某日就不太合理了。建议大家离职之前和 HR 核实清楚。 上面说的是主动离职,倘若你被公司裁员了,年终奖也应该算在赔偿款之内。 年终奖避坑建议 相对于企业,员工一般是弱势群体,尤其是一些大型企业,普通人没有那么多的时间、精力和金钱去公司的法务部门耗。所以,相对于离职之后出现经济纠纷,更多的建议读者在入职前和 HR 关于一些福利待遇方面谈好。事前风险控制比事后风险控制收益更大一些。谈钱不羞耻,千万不要碍于面子,不好意思在一些细节上问。HR 是代表公司,有义务把各项福利待遇和入职者交代清楚。 关于程序员如何与 HR 谈薪,我公众号曾专门写过这样一篇文章,可以参考这里《技术面试与 HR 谈薪技巧》。本文的重点是年终奖,由于大多数人都是普通员工,年终奖等福利的具体数额都不会写到劳动合同里面去的,所以到了年终的时候,企业给不给你发年终奖、发多少年终奖、什么时候发就要看公司的实际处理了,此时个人是很被动的,遇到企业效益不好的时候不发年终奖的也是很常见的。但是呢,HR 或者主管在面试你的时候,会以最大比例的年终奖和股票来作为条件压你的月薪。月薪是有保障的,因为实实在在的写到劳动合同里面去的,所以我的第一个建议是,谈薪时尽量把月薪谈高。如果你是应聘某些核心职位,可以直接和 HR 商量,不要或者少要年终奖和股票期权,但把月薪要的高一点。读者可以想想上文中的 30k 13 与 26k 15 的例子。 第二个建议是,离职之前,把年终奖的发放规定打探清楚,衡量清楚选择继续留下来等到发年终奖还是去下一家公司这二者的收益哪个大。当然,一些不错的领导,会在你离职时为你尽量争取一部分年终奖。 有些人会在离职时利用年假褥公司的羊毛,举个例子,某公司规定12 月 1日之前离职的员工没有年终奖,某员工可能需要提前离职,但距离 12 月 1 日还有几天,他可能会在不能来的几天里请掉自己未休完的年假以让离职日超过 12 月 1 日。大多数公司,对这种做法会睁一只眼闭一只眼,但有些公司会有专门的规定,已申请离职的员工,不能连续请年假超过一定的天数(例如 3 天),以此来杜绝这种现象。读者如果离职时要进行此类操作建议查清楚公司在这方面的相关规定。 第三个建议,关于离职日的日期选择。这个也很有讲究,我经历的有些公司喜欢玩双标。所谓双标就是当月未满工作的天数在计算支付给员工的工资时按两个标准来算,向尽量支付少的工资的方向来计算。举个例子,一种常用的套路就是所谓的国家规定的月工作出勤天数。假设你的月薪是 30k,你最后一个月离职,如果那个月没有较长的节假日,那么计算你离职当月的实际收入是 30k * 你最后一个月的实际出勤日 / 当月的天数,反过来如果你离职的当月有长假,例如 10 月份,而你是在国庆长假后离职的,那么计算你离职当月的实际收入不会算上这法定七天假日的。这是很多公司套路,请假或离职扣薪资时算日薪按按你的月薪 / 21.75,21.75 是所谓的法定月出勤天数,计算绩效或者支付你工资日薪按你的月薪 / 当月天数来算。对于这一类,很多人都无可奈何,尤其是已经从公司离职了。大家了解了这一套规则后,可以根据自己的情况选择离职日期,减少一些自己的损失。 最后,无论大环境好与不好,作为开发人员,应该踏踏实实把技术学好。核心职位被裁员或者在考评年终奖时,都不会吃亏。 以上观点,因笔者的经验、经历有限,难免存在个人观点偏颇的问题,欢迎温和的提出意见和建议。『高性能服务器开发』公众号与你同在。 ====== END ===== 欢迎关注公众号『高性能服务器开发』,如果你在学习的过程中有任何疑问可以加入高性能服务器开发交流群:578019391 一起交流,本群汇集 BAT 各类大佬,也欢迎各大互联网公司猎头、HR 进群寻觅人才。 如需下载本站全部技术文章,可以在【高性能服务器开发】公众号回复关键字“文章下载”即可。最近更新时间: 2020-06-07 12:46:08 "},"articles/程序员的烦心事/":{"url":"articles/程序员的烦心事/","title":"程序员的烦心事","keywords":"","body":"程序员的烦心事 拒绝了一家公司的offer后,他们的副总和hr总监同时打电话来询问拒绝原因并极力要求加入,我该不该去? 我是一名程序员,结婚时女友要求我用两年的工资作为彩礼,我该不该答应? 如需下载本站全部技术文章,可以在【高性能服务器开发】公众号回复关键字“文章下载”即可。最近更新时间: 2020-10-08 13:13:14 "},"articles/程序员的烦心事/拒绝了一家公司的offer后,他们的副总和hr总监同时打电话来询问拒绝原因并极力要求加入,我该不该去?.html":{"url":"articles/程序员的烦心事/拒绝了一家公司的offer后,他们的副总和hr总监同时打电话来询问拒绝原因并极力要求加入,我该不该去?.html","title":"拒绝了一家公司的offer后,他们的副总和hr总监同时打电话来询问拒绝原因并极力要求加入,我该不该去?","keywords":"","body":"拒绝了一家公司的offer后,他们的副总和hr总监同时打电话来询问拒绝原因并极力要求加入,我该不该去? 网友提问: 拒绝了一家公司的offer后,他们的副总和hr总监同时打电话来询问拒绝原因并极力要求加入,我该不该去? 面试的时候双方都感觉还可以,一面后hr开始压价,比预期低了3K,我拒绝。 然后邀请我去复试,跟经理交流后,觉得我可以,然而hr又压价,比预期低了1K,我拒绝,因为我对他们提出的期望薪资是我的最低底线。 然而过去不到1小时,hr说可以给到我期望薪资,并发了offer,但是试用期6个月打8折这个条件我不是很满意,而且offer居然没说明具体薪资组成部分,感觉他们很没诚意,然后思前想后决定发邮件拒绝了。 然而过去三天后,接到他们副总的电话,询问我拒绝原因,说自己公司管理制度如何如何人性化,还说条件可以再谈,非常诚恳也非常谦逊,感觉不好意思拒绝。然后我说自己再考虑考虑。随后hr再次电话来谈条件,说试用期不打折,并把薪资组成部分说的很详细,邀请我加入。 我犹豫了,现在有点纠结,手里还有另一家offer,待遇差不多,但是这家录用我很爽快,感觉我合适就直接发offer了,并说清楚了具体待遇。但是考虑这家公司目前规模和发展趋势不是符合自己预期的,所以也在犹豫中。 反正个人感觉第一家公司想要我但是却各种理由为难我,被我拒绝后又放低标准极力邀请我,这是不是一个坑啊,这么着急招人估计这个岗位肯定是个大坑啊。 现在很纠结,跳槽需要慎重考虑啊,真怕自己跳入一个更大的坑爬不出来。 小方老师建议: 先说结论:现在拿到offer的话,如果比较纠结就都不去,哪怕一家公司都去不了。再找就是了。 说两段我的经验吧,第一段,几年前,在A公司和B公司之间两家公司的offer纠结,A公司离我住的地方太远不想去,B公司感觉氛围不太喜欢,最后在各种纠结中去了B公司,没干三个月受不了离职了。 某年年底找工作时,有个猎头,给我推荐了一个公司,我开始不太想去,因为这家公司我并不了解,所以我和猎头说,除非月薪可以给到30k,否则我不想去,后来猎头反复和对方公司沟通,最终满足了我的要求,但是,猎头说由于对方公司有一定的涨薪限制,所以不能给到月薪30k,但是由于我要求的是30k*13薪,对方公司换了种方式,给26k*15薪,这样一年下来也是39万。我开始是很不愿意这种所谓的变通方法,于是猎头就和他的领导那段时间反复做我的思想工作。我当时也非常纠结要不要去,因为相对于我当前的薪资确实翻了一番,但是我隐约觉得当初面试我的面试官(也就是我进去后我的直系领导)是个\"不好相处\"的人。后来猎头又反复和我说,面试我的面试官是和乐于传道受业解惑的和蔼可亲的技术大神最终在各种纠结中我还是去入职了。但是没过多久,我就干的很不开心。那个领导的代码风格稀烂,例如写C++代码,没有任何注释文档,甚至所有的实现文件都写在*.h文件中;其次,与人交流非常没耐心,下面的同事问他问题说不到三句可能就不耐烦,所以隔壁组的同事也都不太喜欢他,但是由于这个项目一直是他一个人做也没商业化,所以公司也没多管他。等到他带团队,与其他同事打交道时就暴露各种问题了。更让人受不了的是,他经常在项目要发版本时修改代码,也不通知其他同事,直到我们发现不对劲,排查很久发现问题,他才说。最让我们受不了的是,有次周六周日加了两天班,周六晚上到夜里十二点,周日到凌晨三点,他还冲团队成员发火。所以那天夜里,我四点钟半到家,直接给CTO写了封投诉他的信。 后来的情况不用说了,我当初面试的时顾虑都一一应验了,和我一起来的几个同事,年后都陆续离职了。 我想说的是,不管是薪资还是公司面试的时候面试官和人事的种种举动,如果你有顾虑或者觉得不适合你,千万不要为了钱本身就去了。 小方老师联系方式:微信 easy_coder。 如需下载本站全部技术文章,可以在【高性能服务器开发】公众号回复关键字“文章下载”即可。最近更新时间: 2020-10-03 15:46:44 "},"articles/程序员的烦心事/我是一名程序员,结婚时女友要求我用两年的工资作为彩礼,我该不该答应?.html":{"url":"articles/程序员的烦心事/我是一名程序员,结婚时女友要求我用两年的工资作为彩礼,我该不该答应?.html","title":"我是一名程序员,结婚时女友要求我用两年的工资作为彩礼,我该不该答应?","keywords":"","body":"我是一名程序员,结婚时女友要求我用两年的工资作为彩礼,我该不该答应? 以下内容来自于一名群友的求助,经当事人同意首发于『高性能服务器开发』公众号,文字略有改动,未经许可,不得转载。 (以下文中的“我”乃当事人本人,非文章作者。) 群主,您好。我是一名上海一名 C++ 客户端开发,最近遇到了点私人问题,请群主帮我参考一下。以下是我的问题: 我是一名程序员,很爱我的女友,谈婚论嫁时,女友要求我用两年的工资作为彩礼,我该不该答应? (一) 我老家是湖北黄石的,大学就读于武汉某学校,2018 年 7 月毕业,辗转来到上海。由于学历不太好,加上大学荒废了四年,在上海找了近一个月工作之后,终于 2018 年 7 月底上海找了一份 做 PC 客户端开发的工作,使用 C++ 语言。公司是做金融系统平台定制的,主要业务是股票交易系统,为此招了很多数据分析师,女孩子比较多。我就在这家公司认识了我现在的女朋友,她也是一名数据分析师,她是江苏人,也是在武汉上的学,大学毕业时在券商实习做过柜台,所以业务比较熟悉。平常她需要和我们这些开发进行对接,一来二去我们就熟悉了。有一次无意中一起下班,发现我们顺路,后来我们就经常一起加班下班,我向她请教业务问题,她和我了解系统实现逻辑。 后来一段时间,我发现她每天早上来的很晚,而且总是眼睛红红的或者像是没休息好的样子。于是,我趁着下班的机会,问她怎么了,可是她总是说没事。终于有一天晚上我们一起下班回家的日子,她告诉我原因了:她前男友那段时间总是打电话找她,请求复合,她已经和前男友分手快一年了,她前男友回了老家,她来到了上海。现在她男朋友生了病,据她描述应该病的不轻,她拒绝了他的复合要求,但是她心里觉得很过意不去,对于一个病人这样做是不是太残忍了。但是,她又不想复合,虽然她的前男友平常对她很好,但前男友是个喜怒无常且无任何人生规划的人,还有一点点家庭暴力倾向。(画外音:小方群主看到这里惊呆了,好狗血的剧情。。。) 后来,某天晚上在公司附近的公园的草坪上我不断的安慰她,让她想开一点,既然分手了,又有顾忌,就不要再想前一段感情了,建议她向前看。 又过了几天,她说她想通了,这次决定放下。那天晚上,我去了她租的房子。之前,她每次只允许我走到她小区门口,从不允许我进去,调侃我说我“图谋不轨”。那天晚上,我真是感到受宠若惊,聊的比较晚。后来不知道因为什么事情,她又说起了她的前男友如何可怜,我当时很生气,抓起我的书包就准备走。她突然一把抱住我,并且强吻了我。我当时就木头了,单身二十三年,女孩子手都没摸过,更不用说和女孩子接吻了。那感觉,当时脑子就不清醒了。然后,她反问我:XXX,你是不是想追我?我当时小心翼翼地回答是。她说让她考虑一下,然后就让我先回去了。 我一宿未眠,不知道她如何想的。第二天一大早就去了公司,可是一上午都没看到她来公司,我给她发微信打电话也没人回。终于,下午她终于来公司了,而且看起来精神状态不错。她神秘兮兮的和我说晚上一起吃饭。 晚上我们一起在公司的楼下食堂吃饭,她说她和她前男友好好的聊了一下,把事情说清楚了。她不会再想她的前男友,并且接受我的追求。那天,当她告诉我她愿意接受我的时候,我非常的开心。 (二) 接下来的日子,应该是我人生中最开心的一段日子了。我发现,我们对文学都很感兴趣,我们一起聊《红楼梦》《雷雨》《巴黎圣母院》《飘》《百年孤独》《海上钢琴师》等一些国内外的经典著作和电影,她和我聊她之前在深圳和小伙伴们的趣事,还有一些我从未听过的文学作品,她的学识令我佩服。 那段时候,下班以后,我俩骑着共享单车在华师和同济大学的校园里漫游,有时候手拉手在河边散步,无话不谈,真的佩服她的文学素养和对世界的认识。而且让我惊喜的是,她有一手好厨艺,我琢磨着和她自小的家庭环境有关系吧。周末她烧饭时,从来不让我洗碗,而且一个人全包了从买菜到炒菜煮饭、饭后洗碗、打扫的全过程。我们一起玩王者荣耀和英雄联盟,她对这两个游戏也很感兴趣。 周末有的时候我们会一起去南京路步行街逛街、看看电影,我觉得她是个非常独立的人,她每次买东西都不会让我付钱,而且我之前断断续续借给她五六千块钱,在一次闹矛盾后也一分不少的还给我了。当然后来,我们又和好了。 在交往中,我知道了她的家庭状况。家在苏北农村,父母务农。家里有一个哥哥,高中没读完就辍学了。父母比较对哥哥比较宠爱,从小对她管不多,她经常和家里吵架。她从小的愿望就是将来离开家。所以她一个人去武汉上学,毕业后一个人去深圳,干了一年来到上海。但是,她的哥哥从小对他很好,她很感激。唉,这个\"好\"字,埋下了我后来的悲剧。 在一个周五的晚上,我们去了一家很有特色的古风饭店吃了顿饭,我们一起喝了点店里的招牌酒水——女儿红。她貌似喝醉了,脸红扑扑的,回来的路上,她一边挽着我的手一边给我唱王菲的情歌。那天星光下,她穿着一套白色的裙子,像个仙子一样美丽。那天,我也很开心,心里想:她的独立自强以及读了那么多书,具有一种一般女孩不具有的知性美,还有她的勤劳质朴,在现在的女大学生中实在太少了,这应该是我理想中的对象吧。 顺理成章,那天晚上,我们在她的租的房子里面发生了关系,我很意外的是,她竟然还是个chu。我问她后不后悔,她意味深长的问我,你知道女儿红这酒名的来历吗?我说不知道。她说,古时候,穷人家的父亲会在女儿出生的那一天,把一坛白酒用红色的布包好藏在地窖里,等女儿长大出嫁那一天会拿出来,送给出嫁的女儿和女婿。所以,她不后悔,并安慰我她爱我,这是迟早的事情。那天我暗暗下定决心,一定一辈子对她好。 (三) 后来,有了第一次以后,我就经常在她那里过夜了,只不过我们隐藏的很好,公司没人知道我们的关系,我因为工作表现好,工资从六千涨到一万。后来,闲聊时,她和我说她哥哥对她的好,她虽然恨她的父母,但是她觉得她的哥哥挺可怜的,她想嫁给我,但希望我给她家 20 万的彩礼,她哥哥也可以拿着这笔钱娶老婆,她哥哥比她大五岁,她父母很着急,由于她哥哥学历不高没什么文化,又没啥手艺,生活过的很一般,年纪比较大了,家庭状况也不好,所以还没找到老婆,所以她父母压着她希望她在她哥哥的经济上照顾,她想着从小哥哥就照顾她,所以希望能帮哥哥一把。我当时听了这个话,不知道如何回答。20 万的彩礼对我来说,有点多,差不多相当于我现在快两年多的工资收入。我也是农村家庭,父母辛辛苦苦供我上大学。现在毕业了,父母年纪也大了,还有三万多的助学贷款没还完。 我带她回了趟老家,父母很满意,非常开心。在家里催婚的状况下,我尝试着和她沟通过很多次,能不能彩礼钱少一点,结婚本身也要花钱的,她似乎并不想让步。我不敢告诉我的父母,不想让他们本来很开心的心情泼一盆冷水。我很爱她,但是她的这个条件我确实有点为难。甚至有时候,我在想,本来好好的一场可以开花结果的恋爱,怎么感觉有种卖女儿的感觉? 另外还有件事,让我一直耿耿于怀,群主你不要笑(画外音:群主尽量忍住)。有次和她 XXOO 的时候,正在做的时候,她说她哥真的需要一个老婆,我有这方面的生理需求,她哥哥也有的。所以,她希望我能答应她的请求,她想和我过一辈子,但是希望我能理解下她,同意她的要求。我当时听了这话,像吃了苍蝇,立马没兴趣 XO 了。 这个月,她提出来双方父母见个面,希望我们可以定下来,可是我现在很纠结:我不想和她分手,我爱她,而且她的第一次给了我,但是我又不能满足她的彩礼要求。我不敢想这些事,现在上班也没心思。群主作为过来人和各位读者,能给我点建议吗? 如需下载本站全部技术文章,可以在【高性能服务器开发】公众号回复关键字“文章下载”即可。最近更新时间: 2020-10-03 15:47:29 "},"articles/作者的故事/":{"url":"articles/作者的故事/","title":"作者的故事","keywords":"","body":"作者的故事 我的 2019 我是如何年薪五十万的 如需下载本站全部技术文章,可以在【高性能服务器开发】公众号回复关键字“文章下载”即可。最近更新时间: 2020-10-08 13:35:13 "},"articles/作者的故事/我的2019.html":{"url":"articles/作者的故事/我的2019.html","title":"我的 2019","keywords":"","body":"我的 2019 2019 年就这么悄无声息的过去了,我并不是一个喜欢缅怀过去的人,很多人喜欢回忆过去经历的困难,但就我倒是认为如果过去的苦难不能对将来生活质量或者人生经验有帮助,那这些苦难其实相当于白挨了。 从学生时代开始,每年在年末时都会总结一下过去的一年,给自己复盘,总结一下过去一年的得失。成年人的生活没有谁是容易的,而成年人的工作和交往大多数都是逐利的,只是有些是直接的,有些是间接的。当然,这也无可厚非,大家的目的虽然功利却也高尚,都希望给自己和家人或者爱的人提供更好的生活质量。 工作 先来说说我的工作吧。我是一名地地道道的程序员。我于 2018 年 12 月底从携程旅行网离职,应朋友邀请一起合伙创业,投资人是一知名大佬,创业的项目是基于区块链的期货交易系统,项目从 2018 年年底开始启动,从零开始开发,于 2019 年 8 月份正式全球上线,8 月后开始优化和重构部分框架。 整个交易系统分为场上系统和场下系统,场上系统是交易系统的核心系统,一共有多个服务,从功能上有下单服务、清算服务、撮合服务、条件单服务、K 线服务和行情推送服务,场下系统为交易非核心系统,包括指数服务、管理系统等。使用的开发语言是 Java 和 C++,行情服务使用的是 C++,其他所有服务使用的是 Java,另外我们的客户端有 Web 端和手机端(安卓和 ios)。服务与服务之间使用 Kafka 作为消息中间件,数据存储和查询使用 mysql 和 ElasticSearch。交易核心系统开发要求比较高,无论是对性能还是代码的质量的要求比较高,交易系统非核心部分,例如管理系统,由于只是给内部运营团队使用,要求不高做的相对来说粗糙一些,其技术原理也比较简单(各种对数据库进行增删改查的 RESTFUL 接口),但由于功能比较多,工作量也比较大。除了这些技术栈外,还用到了 zookeeper、consul、Prometheus 等。 互联网时代市场动态和风口转瞬即逝,因此我们需要尽快把产品做出来。从立项之初,我们自己公司技术人员加上我一共有 3 位,另外一位是邀请我的朋友,还有一位是我从携程\"忽悠”过来的一位玩的比较好的同事。为了加快开发进度,加上起初我们对部分业务不是很熟悉或者经验不足,我们花了大约四百万在上海找了一个专门做交易系统的外包团队,外包团队负责主要开发,我们负责 review 代码和把控整体开发进度。原来外包团队承诺我们的是,项目可以在五月份完成,但是由于外包团队本身的质量问题导致后来我们不得不强力干预,甚至完全自己接手。由于外包团队中开发人员的素养问题以及外包团队的 leader 的管理和待遇问题,导致我们直到五月初还看不到一个可以走通基本流程的产品。于是后来我们的策略做了调整,与外包团队所在的公司进行了沟通,吸纳了外包团队中部分还不错的开发人员,对于无法达到我们要求的开发人员停止合作,最终我接手了条件单、撮合服务、K 线服务、行情推送服务等 4 个核心服务的开发,其中行情推送服务使用的是 C++,目前的人员配置中了,只有我同时拥有 C++ 和 Java 技术栈,因此只能我来接手行情推送服务。 其实从我们与外包团队的合作的刚开始的两个月后,我们就觉得行情服务外包团队在预定的工期内无法完成,因此在那个时候我就被安排开始接手这个服务的开发,起初他们给我的一套程序是他们根据之前做过的一个股票服务的行情代码精简来的一个空架子,加上他们的代码有大量我们不需要的无用功能,加上其通信协议与我们的业务并不完全契合,在我花了一周时间熟悉后,我一边在原来的老的上面开发,另外有自己重新设计了一套,经过大家的试用后完全采用了我新的设计。由于 C++ 是我的技术专长,在行情服务基本开发完毕后,八月份上线后到目前没有出现任何问题,所以基本未做过任何的修改。这样为我腾出大量的时间去集中精力去开发和优化条件单、撮合和 K 线服务。 先来说撮合服务吧。在大多数交易系统中,撮合服务是非常核心的一轮。所谓撮合,即根据一定的交易规则(常见的规则是时间优先、价格优先),将用户的报单进行成交,产生成交等相关信息,如果不能成交,则成为市场上的挂单。最初负责这个服务的外包同事,哎,但是由于其工作态度和代码素养问题,写出来的代码真的是\"惨不忍睹\"。我们与外包团队约定的要求是,撮合服务必须至少达到每秒可以处理 3000 ~ 5000 笔报单的性能。但是其交付后我们测试发现,每秒三百到五百的速度都达不到。但是这个事情的结果在我们整个公司,包括 CEO 都引起来非常大的恐慌。项目上线在即,如果按这个速度,我们的系统注定是个失败的产品。于是后来,CEO 给外包团队的领导施压,强行把这么同事给\"撵走\"了,并由我来接手,当然压力也落在我的身上,我记得我最初接手的那几周内,CEO 每天跑我工位上来问我撮合现在最新的进展怎样,有时候一天可能会跑两次。我在阅读其撮合代码的过程中,发现这个代码的质量非常差。当然造成撮合效率如此之低有两个主要原因:一是他使用的一个链表去存储所有用户的报单,这样的话,改单或者撤单,寻找某个订单时需要遍历这个链表,当订单数量多的时候,这个过程会很慢。哎,数据结构和算法不用心学的开发人员,真是贻害无穷啊。二是他往 Kafka 中写数据时使用了 Future 接口的 get 方法,熟悉 Java 的同学可能知道,这个是需要等待的,也就是说每往 Kafka 发一次数据都要等待结果返回。这种同步的做法,让整个撮合系统对报单的吞吐量变得很低。于是,在全公司上下尤其是 CEO 的\"密切\"注视下,我花了大概三个月时间基本重写了撮合服务的所有业务逻辑,而这个外包同事已经做这个做了半年了。 剩下的是条件单服务,所谓条件单就是用户发起的、根据一定规则(例如某个价格指数达到一定值时)才会产生的报单。这个服务也是上文中开发撮合服务的外包同事做的,条件单和撮合服务存在同样的问题,我也重写了全部的业务逻辑,但是由于时间来不及,18 年 8 月份我们上线时由于还没重构完成,我们第一次没有上线这个功能,第一次上线后的一周后我们上线了这个功能。 剩下的就是不太重要的 K 线服务了,K 线服务本身业务并不复杂,但是数据量非常大,原来是外包团队的另外一位同事开发的。原来我们的注意力并没有在这个上面有特别多的关注,主要是其比较简单。但是某天公司的产品同事,在群里发了几张关于我们生产环境的 K 线服务的图,结果某些大周期的 K 线数据竟然和小周期的 K 线数据竟然对不上,我当时真的是无语了。于是我又被安排维护 K 线服务,K 线服务的代码质量其实还是不错的,只不过一些核心的算法竟然不对,例如计算从某天到某天之间有多少小时,这都能算错,而且如果自己稍微验证一下就很容易发现问题。 上面说的四个服务,除了行情推送服务由于早期就使用我开发的版本因此对我来说,很轻松,大家也很放心,但是撮合、条件单和 K 线这三个服务让我承受了很大的压力。我没预料到的是,一个外包团队的某些开发人员开发水平能不靠谱到如此程度,而且还是工作多年的程序员。好在一切都过来了,项目也成功上线了。后来,我们陆续招聘了前端开发人员、测试团队、补齐了自己的产品团队和运营团队,后来我们又招了客服团队。 由于项目任务比较多,我们采用的是 996 模式,但是在 2018 年上半年,我基本上在晚上 12 点之前是没回去过的。当然,我并不认为工作时间与实际的产出会成正比,在团队扩大的过程中也暴露了很多的问题,有我自己的,也有 CEO 和团队其他一些负责人的。有的时候,我其实挺想和 CEO 说让大家每周多休息一天,但是看到当前的现状,我最终还是没开口。 陶渊明说:种豆南山下,草盛豆苗稀;晨兴理荒秽,带月荷锄归;道狭草木长,夕露沾我衣;衣沾不足惜,但使愿无违。有的时候一些事情并不是我个人能力而能改变的,希望我们或者他们可以成功吧。 陈述事情如果不能进行一次总结,那其实也没有意义的。让我们来复盘一下这一段的工作经历吧。应该很多人都有一个创业的梦想,这一年多的折腾我想说的是: 创业的话一定要找靠谱的人,所有的 CEO 都会画饼,当你以为他很慎重的对待你的时候,他此时此刻和你说过的话,也许已经和其他人说过了。 创业团队的成员一定要有共同的信念和理想,如果最初的团队,大家都各自想着自己的利益,那么最终项目一定是做不好的; 创业团队的 leader 或者前辈一定要虚心接受他人意见,而不是根据自己所谓丰富的行业经验压制其他人的建议。 如果你的朋友在创业团队中没有很大的话语权,慎重接受他拉你去创业的邀请。 同样的道理,如果你的朋友在创业团队中没有很大的话语权,你如果接受和他一起创业,但是请慎重再邀请你其他的朋友或者同事去创业。 知识付费和自媒体 除了忙碌的工作以外,我在我那少的可怜的闲暇时间还维护着公众号和写了几个付费课程。 先来说公众号吧,我从 2018 年 3 月份开始做公众号,我做公众号比较佛系,很少去通过一些专门的运营手段来运营。因此做了快两年了,目前粉丝只有三万四千左右。好在写的一些原创文章由于质量比较高,加上用心维护的 QQ 群氛围比较好,平均阅读量也有 1500+ 左右。当然,相比较 2018 年,2019 年写的原创文章就比较少了,同时也接了一些广告。广告是公众号的目前的主要收入来源,只有公众号作者有收入了,才会有更多的动力去给读者写更多的优质文章,做更多的优质分享。所以希望我的读者,如果你不想看我公众号发广告,可以不看,实在不喜欢就取关,实在没必要在后台喷。2020 年争取给大家创作更多的优质技术文章。 另外,就是在 GitChat 上写作了两个付费课程,一个是关于 GDB 调试的,另外一个是关于多线程编程的。由于 GitChat 的方式,我个人觉得付出的时间成本和收入其实是不太匹配的。 另外,就是运营我的知识星球——小方说服务器开发,由于个人精力有限,为了更好的为每一位球友服务,我的星球定价偏高,325一年,这样可以过滤掉一部分叶公好龙的人;当然如果真正愿意想学东西的,这个价格并不高。 最后就是从年初到现在一直在写作一本关于高性能服务器开发方面的图书,我的写作特点是人云亦云的东西我不写,我诚惶诚恐的使用\"著\"一词,书的内容是我几年做 C++ 服务器方面的经验总结,当然这本书可能并不适合初级读者,可能做了几年 C++ 服务器开发后,遇到了一些问题,可能通过此书与我产生共鸣,到目前为止,我统计了一下,大概写了有 120 万字了。 生活 我上次在公司和另外一位同事说,你要兼顾生活和工作,他回敬我:现在这种工作强度,我有鸡毛的生活。我竟然无法反驳,确实,今年大半年无论是平常还是节假日都是在加班中度过了,在此感谢我的媳妇对家庭的照顾和对我的支持。如果读者朋友遇到一位愿意和你一起在大城市打拼的姑娘一定要好好珍惜。 2020 的计划 2020 年的计划之一是把书写完,出版出来。 另外,继续完善自己的技术栈。自媒体和知识付费确实给我带来了一部分收入,但是与很其他的一些技术公众号主相比,让副业的收入达到或者超过主业的收入,对我来说那基本不可能。而且我个人是一个非常喜欢编码的人,我喜欢在技术上能达到一定的造诣。所以,自媒体不会成为我的重心。所以认认真真的再研究点代码,看几本好书,做些挑战性的项目,也是我 2020 的计划。 最后,锻炼身体,当然,希望我能坚持。 新的一年,大家一起加油,张小方与你同在。 如果你想和我聊一聊,可以加我微信 easy_coder。 如需下载本站全部技术文章,可以在【高性能服务器开发】公众号回复关键字“文章下载”即可。最近更新时间: 2020-10-03 15:09:32 "},"articles/作者的故事/我是如何年薪五十万的.html":{"url":"articles/作者的故事/我是如何年薪五十万的.html","title":"我是如何年薪五十万的","keywords":"","body":"我是如何年薪五十万的 以下经历希望对广大程序员同行有点启发。 1 我姓方,码农一枚,14 年硕士毕业于某 211 学校,哎,这里就不提母校了。人到中年,还没混出什么名堂,就不给学校丢脸了。经常很多人问我现在的收入多少,这个嘛,男人的收入就和女人的身高一样是个秘密。不过,今天姑且聊一聊这个话题。 2 我的第一家公司做 Windows C/C++ 开发,第二家公司做 Linux C++ 开发,第三家公司是某大型互联网公司,以 C++ 技术专家的加入,同时从事 C++ 和 Java 开发。我大学学的非计算机专业,非科班出身的劣势就是参加大厂的校招时,筛选简历那一关直接给你 pass 掉了,这也是我毕业时未通过校招去大厂的原因之一。之所以走到今天靠的是自己的兴趣加上一些运气,当然也离不开很多人的帮助。大学时早年自学 Web ,熟悉 html 5 标准前的各类前端开发技术和 PS 等软件,后痴迷 Flash 编程,做过很多 Flash 动画自娱自乐,2011 年大学毕业时先后在上海一家开发 Flash 整站和一家做网页游戏公司任 Flash 程序员,第一家公司实习工资 1500,第二家公司正式员工月薪 3000。许多年后,我和第二家公司的 Flash 主程再聚首时,他告诉我其实我当时作为一名应届生 3000 的工资并不低,当时他作为项目负责人工资也才 8000。当然,据他说,经过这么多年后,在经历了几次创业失败后他也回归于平淡,在张江一家做游戏的公司安安心心地上班。 3 我的大学后半段时间,真的非常痴迷于 Flash,那个时候觉得 Flash 就是整个人生的意义,并为此写过很多轻狂的话,像什么“你 flash一下子,我爱你一辈子,真像个傻子”、“让我们高举 Flash 伟大旗帜,紧密地团结在以 Adobe 为核心的富媒体公司周围,紧随乔纳森.盖伊的脚步,不舍昼夜的编程,把我国的 RIA 事业全面推向现代化!” 那个时候,坐五个小时的火车来上海,就为去上海书城买一本全英文版的《Flash 编程精髓》,甚至为了一份 flash 开发的工作差点儿放弃读研究生。时过境迁,八年后的今天,浏览器原生支持很多以前仅能通过 flash 实现的技术和效果,Adobe 公司宣布不再更新 flash,各个浏览器逐步禁用乃至不再支持 flash。真是让人唏嘘不已啊。 大四正式离校的前一天晚上在逛蓝色理想站点时,发现有人在一篇帖子上推荐《Windows 程序设计》(第五版)这本书,看了下目录,果断购买,听说过这本书的读者应该知道,这本书一百多块钱,老厚了。这本书可谓是改变我整个人生轨迹的一本书吧,这本书介绍了 Windows 操作系统上程序运行的原理,直接利用操作系统提供的 API 进行编程。愈看这本书,我愈喜欢,它介绍了很多操作系统层面的原理,从前很多在 flash 平台不明白的东西一下子变得清晰起来,从前很多 flash 平台提供的类库不知道该如何使用一下子明白了为什么要那么设计了,后面又陆陆续续地看了《Windows 核心编程》等书。整个人更意识到对底层原理和计算机基础科学的掌握的重要性。于是等到硕士毕业时,我可以去一些公家单位从事地质相关的工作,去互联网业务做前端开发(也包括 flash 开发),但是我还是很倔强啊,薪资高低和工作地点并不是我考虑的因为,我就要做 Windows C++ 开发。当年非 flash 不嫁,如今却非要嫁给另外一个人。哎,人这辈子啊,真的可能会爱上很多人,工作、兴趣亦如是。 4 让我们来复盘一下这段经历,整个学生生涯,大学期间学的是 web 开发和 flash 编程,硕士期间学的是 Windows C++,并针对性地补充很多计算机科学的基础知识,也看了不少“闲杂知识”,如汇编、逆向、安全工程等等,当然都是自学。人的经历是有限的,自学的太多其他东西,很可能让你的专业课就变得一塌糊涂。我也是这样,所以,我特别理解 CSDN 上那位叫 moreWindows 的前辈在读研期间的痛苦,好几次想辍学去做开发。但是,作为过来人,我也想劝来者:如果你有机会读研一定去读个研究生,哪怕是自费或者非全日制的,你现在不明白,会有明白的一天,我们大多数人都不是命运的幸运儿,所以有时候学历还是有点用的。 前期学习 Web 开发技术,让我对 html、javascript、CSS 等非常熟悉,而且我读了非常多的 web 方面的经典书籍,也熟悉 web 标准,那个时候讲究的是三层分离(即表现层、样式层和行为层要分离),加上后来又学习了 web 后端开发技术(主要是 php),我的水平具备开发一个商业的 web 整站的水平。这段经历,让我熟悉了很多计算机和软件开发的一些基础理论和设计原则,如 URL、相对路径等概念。 后来,深入系统地学习了 Flash ActionScript 编程。这门语言的语法和类库,我现在已逐渐忘记了。但是这门语言让我深刻地理解了什么是面向对象编程,让我在后来学习 C++ 和 MFC 轻松了许多。读研期间,我也读了不少经典的计算机书籍,如《代码大全》、《整洁代码之道》、《程序员的自我修养》等等。这些书,与其说是从技术上影响了我,不如说从思想上影响了我,在我学生时代,让我对自己的编码无论是从效率还是从风格都严格要求自己。因此,它们带给我的正向效应也体现在我的第一份工作中。 5 由于我硕士毕业时,第一份工作非 Windows C++ 不做,但是很多公司要求有工作经验,挑来挑去,最后在上海(我是不愿意去二三线城市的)闵行的一家公司安定下来。离职的人千万不要说自己之前的公司多么不好,就和席慕蓉说年轻时被迫和爱过的一个人分开不要心存怨恨一样,那样只会显得自己多么差劲和眼瞎。这家公司做的是炒股软件,男怕入错行,女怕嫁错郎,刚毕业的男孩子们一定要做好职业规划,选好自己的职业和方向,从那以后,我虽然在短暂时间内离开互联网金融行业几次,但是我从未真正离开过。尽管这家公司存在很多问题,老板也不是很 nice,但是这家公司老板和两个 leader 都是做技术的,并且非常乐于传道授业解惑,定期的给各位新同事讲解开发知识;几年以后,成为技术 leader 的我,对于那些勤奋好学的组员,我也是愿意多给他们讲讲技术的。可惜职场中真正知道自己想要什么、明白自己为谁而干活的人太少,大多数都是重复着一圈又一圈的年轮,重复着冗杂业务代码的开发,工作做的不好也不坏,渐渐地变成了职场老油条或转行,留下来的老油条然后告诉新来的人,啊,程序员到了四十岁有职业危机啊。我一个硕士学历进入这家公司的薪资只有 5200 元,2014 年一个名牌大学的硕士从事 IT 行业拿这样的薪水还是有点低的,五千多出来的两百还是我和人事讨价还价要来的上下班公交费呢。那个时候为了省一点房租,我住的很远,可就是这样我仍旧每天加班到很晚,周末也会去公司。之前在书中看到的各种技术理论,慢慢地在项目代码中找到了应用,而这些代码就向对我打开的天堂之门。我如饥似渴地学习着。由于表现的好,加上公司人事调动,我很快成了整个客户端项目的负责人,并且为了更好地开发,老板也给我开了服务器代码权限。二个月后薪资涨到 7200,去北京出差回来,涨到 8000, 2015 年 10 月份,向老板提加薪 4000,老板也答应了。公司整体环境加上自己的努力和一点点运气,我熟悉了从 Windows 客户端到 Windows 服务器开发的一整套流程和常用技术。这里不得不提醒一下各位即将从事这个行业的读者,看懂别人的代码是一回事,自己会写会设计是另外一回事。我也是,举个例子,我们老板一直告诫我们 Windows 的完成端口模型一定要自己多练习几遍才能掌握,为此我练习了五遍,并且在后来的新项目中顶着项目进度的压力将底层网络通信框架重构了三个版本。在北京出差的那段日子里,我经常周日孤零零地坐在甲方的办公楼里写代码、调试代码到深夜。 由于这家公司使用的是 Windows C++ 技术栈,后来公司与某个证券公司合作要开发一个新的项目,后台使用 Linux C++ 开发,为此招了两名 Linux C++ 开发,同时招了两名手机开发人员(安卓和 IOS 各一名),PC 端由我带领另外一名同事一起开发,我同时兼任整个项目的负责人。现在想想,当时真是初生牛犊不怕虎,那个时候谁都敢喷,作为一名客户端开发人员,竟然有时候会喷站在更高角度的后端开发人员。不过,回过头来想想,那时候这个项目的后端开发设计的协议确实不方便使用。 6 在之后,2015 年年底快过年了,发生了一件因为年终奖被克扣 80% 的问题,让我对老板很失望,在做完项目并交付后,我离职了。此时,我的月薪是 12 k。由于在第一家公司磨练出一身技术,加上扎实的基本功,经历三天的面试,锁定了两家单位,一家是上海张江的某家网络公司(为了叙述方便,以下称 A 公司),另外一家是东方财富网,前者给月薪 14 k,后者给月薪 18 k,由于学生时代感受到 A 公司插件的”厉害之处“,向往其犀利的技术,再加上东方财富国企式的面试风格和办事效率让我没有好感,在经过短暂几天纠结之后,选择去了 A 公司。这次是拒绝了东财的客户端职位,一年后又再次拒绝了东财的 23k 的后端开发职位,真是对不住那个可爱的人事小姐姐,两次都是同一个人事。 但是干了一段时间后,A 公司让我觉得特别不舒服,这种不舒服不是在于 A 公司的待遇不好。平心而论,A 公司的工作没什么压力,负责的项目已经很稳定,而且是多个人负责一个项目甚至一个模块,每天可以准点下班,且每天下午都有较长时间的下午茶时间,每天每人一袋水果,常见的水果都有。每天晚上超过八点,可以享用公司的加班餐,加班餐很丰富,20 元一份的水果拼盘可以让两人吃到撑。年终奖是保底 14 薪,平均下来是 16 薪。真是个适合养生的好去处啊。 我之所以觉得不舒服,是因为开发模式,第一,整个项目的框架由 A 公司的基础架构部给你开发好,另外 A 公司有个巨大的 RCFL 库,这个库封装了几乎所有常用的工具类,上层开发直接调用这个库里面的类。问题是,我们看不到这个库的代码。我并不想在这里养老,而且我也讨厌日复一日的业务开发且还看不到底层框架代码,并且那个时候,受在第一家公司 Linux 服务器开发的同事的影响,我有点不想继续做客户端开发了,我想去试试 Linux C++ 开发。于是,任性的我,这次决定离职了,虽然同事和部门 leader 意外的眼神让我觉得很对不住他们。但是,Linux 操作系统我并不熟悉,而且 Linux 的很多编程原理我也不清楚,于是我在 A 公司偷偷摸摸地学习了三个月 Linux 开发。三个月后人事告诉我可以转正了,我却告诉他她不想转正,我要离职了。那是 2016 年 5 月份的事情了。那个时候,上海的天气已经有点热了。社会人士求职要求的更多的是工作经验,所以我求职 Linux 开发的经历还是比较坎坷的。当时,女朋友还调侃我说,我要失业了。在寻找了好几个星期之后,我终于在另外一家公司以 16 k 入职。 7 这段工作经历,让我熟悉了 Linux C++ 后台开发的流程,加上自己爱研究,很快就对服务器后端的框架举一反三了。先后在这家公司做了两个项目,可惜好景不长,由于当时我们做的是现货业务,一年后由于国家政策收缩,公司业务被砍,大批量裁员。但是我呢,由于既可以做后端开发又可以做 pc 和 web 开发,被留下来了。当时另外一家公司正在高薪招技术好的全栈开发,这家公司工作压力确实很大,我最终以 26k * 15 被挖过去。我在这家公司做直播的后端开发,同时负责各个平台(pc、mac、安卓、ios等多操作系统多语言(主要是 C++ 和 Java 的开发))的 sdk 的维护。这家公司的技术我是从心底里佩服,全公司 90% 都是技术人员,同事要么毕业于各种名牌大学要么就是各个领域的技术专家。一家百人不到的公司,可以做到年盈利 3 亿。 但是我很快从这家公司离职了,离职的原因是我和我的 leader 总是吵架,leader 也是一个性情中人,总是不分场合的骂人,导致项目组同事陆续离职,最后项目被合并。离职还有另外一个原因,就是我认为我理解了 C++,我想去把 Java 好好学一下。于是这次,我要找 Java 方面的职位,哪怕降薪也可以,于是我后来去了很多公司,把大大小小的互联网公司都撸了一遍。其中月薪最高的是一家 37 k 的创业公司技术主管,但是这不符合我的职业规划,我要学 Java,我要去大厂镀金。我没有去这家公司,但是我推荐给另外一位朋友去了这家公司。我之所以没去是当时想:如果我去了,是能在几年内赚点快钱,小公司的业务量和技术难度能让我在技术上提高多少?若干年后,我又该何去何从?而推荐给这个朋友去的原因是他的年龄比我大不少,他当时已经不想去什么大公司了,赚钱对他来说是第一要务。 8 最终,我以 C++ 技术专家的身份去了某大型互联网基础框架部,主要工作任务是维护一套 C 系统,同时参与各种新项目开发(以 Java 语言为主),既能立足于我从前的基础,也满足了我深入实践下 Java 的愿望。薪水是 33 k * 15 = 49.5,算上股票,年薪 50 W +。此时,我工作了四年。当然,由于我的技术比较好,面试官很满意,我也提了两点入职要求:1. 我要能看到我负责项目的全部源码;2. 所在的项目一定至少是有百万级业务量的,我需要挑战更高的技术难度。这家公司的体量和规模加上面试官的 level 都能满足我。 把时间放长的话,例如工作八年到十年,对于做技术开发来说,年薪五十万并不难。但是,难点是如何毕业的前四五年内年薪 50 W。当然,年薪 50 W 的人到处都是,而且这个群体,多数人生活并不轻松。 现在的生活,平静如水,我也继续积累和沉淀着。现在对自己的期许是做好当下事,莫问前程。 9 今年是 2019 年,算起来一共工作五年了,总结一下我的一些经验和感悟: 要喜爱技术,兴趣在任何时候都是最好的动力,作为一名开发人员,一定要把技术学好,它是你吃饭的家伙,提高你的技能,能让你在职场上和收入上有立竿见影的效果;提高自己实质性的东西,在职场中成为某些职位无可替代者;踏踏实实做技术,不要附庸风雅地整各种技术名词,弄懂技术背后的原理;精益求精反复总结,你需要定期总结和提炼你的技术知识。 职业规划要趁早,明确自己想要什么,成为什么样的人;有脱离自己舒适区的毅力,不要为了一点蝇头小利去一个地方选择一份工作,为自己的技能提高做投资。职业生涯的早些年,决定你去选择一份工作一定是因为有利于你成长,而不是工资高。我的几份工作都是为了习得某一种技术栈而做的选择,当然这种选择会有痛苦,但是只要你最终能达到目标,所有的痛苦都会成为你宝贵的人生经历。因此,悠闲舒适的地方我不去,看不到大多数源码让我觉得无法提高的地方我不去。经常玩知乎的人应该知道,知乎上各种”技术大神“动辄月薪三五万,还有学生群体讨论某某同学、师兄拿到 sp,年薪四五十万,这是典型的臆断妄想症,当然不排除少数天才和各种研究性人才。试想,年薪四五十万平均到每个月是多少钱?换位思考一下,在 IT 行业从原来的高级脑力活动变成工厂式的制作流程,干同样的活,一个几千或稍高工资就能招来的人干的活,如果你是老板,你会花高价招干同样活的人吗?人人都渴望高薪,但多数人都停留在嘴上或者想法中,凤毛麟角的人付诸于行动。 不要停止学习基础知识,不要盲目去跟风时下流行的技术,注重内功培养,肯为自己的提高投入时间和金钱;在学习上对自己抠门的人,时间久了,必将泯然众人矣。正因为很多职场新人分不清楚哪是内功,哪是流行技术,我不建议一开始工作就去从事像 python、go 这样的语种的开发。 做技术要有产品思维,技术本身不会给你带来财富,相应的业务可以,一般某一行技术好的人很多,但是既熟悉业务又懂技术的人就难能可贵了。 10 另外,给大家推荐一个高质量关于后台开发的学习公众号【高性能服务器开发】,非常 nice,从前我是一个服务器开发小白,我认真地把他写的每篇技术文章都看了一下(当然也有一些广告文,我直接忽略,哈哈),能感受到作者细腻的技术和厚实的基本功,特别赞同他说学习技术的准则:推崇基础学习与原理理解,不谈大而空的架构与技术术语,分享接地气的服务器开发实战技巧与项目经验,实实在在分享可用于实际编码的编程知识。作者承诺是凡是第一个发现他公众号中所有原创文章中的技术错误,可以获得他送的书。 他有个很大的 QQ 群,群里面除了不定期技术分享外,讨论话题只能是技术或者职业规划相关的话题,禁止任何形式的灌水,同时为了鼓励群友学习,他会在每逢过节时给群友赠书(当然,只有他认为勤奋好学的群友才有机会获得赠书),书都是被赠者自己选的,群号是 578019391,有兴趣的知友也可以加一下。 我和公众号作者在上海有过一面之缘,是个其貌不扬的憨憨的“大叔”(与我相比),我在找工作的过程中得到了他的很多帮助,不过他有时候脾气也很暴躁,他不解决别人的伸手党问题,只会给你说思路和解决问题的方法,完了还得自己动手。哎,真是奇怪又奇妙的人。T_T 虽然我不赞同他的很多观点和做法,但是人与人本来就是不同的,大家共同的目标是为了提高技术,增加收入,求同存异嘛。 以上是自己真实的经历,文中观点难免一家之言,欢迎温和地提出意见和建议。 最后,用冰心的话和大家一起共勉:成功的花儿,人们只惊羡她现时的明艳,然而当初她的芽儿,浸透了奋斗的泪泉,洒遍了牺牲的血雨。祝所有可爱的程序员同行们都能成功。 如需下载本站全部技术文章,可以在【高性能服务器开发】公众号回复关键字“文章下载”即可。最近更新时间: 2020-10-03 15:48:02 "}}