简介

  • C++服务器开发相关笔记

C++ 服务器开发 github推荐开源项目

以下是一些在 GitHub 上受欢迎且有用的 C++ 服务器开发的开源项目,它们可用于构建各种类型的服务器应用程序。请注意,GitHub 上的项目可能会不断更新,因此建议查看其最新状态和文档:

  1. Boost.Asio: Boost.Asio 是一个 C++ 网络库,提供异步事件处理和网络通信的功能。它可以用于构建高性能的网络服务器和客户端应用程序。 GitHub 地址:https://github.com/boostorg/asio

  2. cpp-netlib: cpp-netlib 是一个现代的 C++ 网络库,提供 HTTP、HTTPS、HTTP client 等功能。它提供了易于使用的 API 来处理网络通信。 GitHub 地址:https://github.com/cpp-netlib/cpp-netlib

  3. Crow: Crow 是一个 C++ 微型 Web 框架,可用于构建高性能的 Web 服务器。它具有简单的 API 和支持 WebSocket 等功能。 GitHub 地址:https://github.com/ipkn/crow

  4. Pistache: Pistache 是一个 C++ RESTful Web 服务器框架,它具有异步处理和路由功能。它适用于构建 REST API 服务器。 GitHub 地址:https://github.com/oktal/pistache

  5. Civetweb: Civetweb 是一个轻量级的嵌入式 HTTP/WebSocket 服务器,适用于嵌入式系统和小型应用程序。 GitHub 地址:https://github.com/civetweb/civetweb

  6. uWebSockets: uWebSockets 是一个高性能的 WebSocket 库,用于构建 WebSocket 服务器和客户端。它具有低延迟和高吞吐量的特性。 GitHub 地址:https://github.com/uNetworking/uWebSockets

  7. Restbed: Restbed 是一个 C++11 Web 服务框架,支持 HTTP 和 HTTPS,并提供异步事件处理和路由功能。 GitHub 地址:https://github.com/corvusoft/restbed

  8. Drogon: Drogon 是一个高性能的 C++ Web 服务器框架,支持 HTTP/1.1 和 HTTP/2,提供异步和多线程支持。 GitHub 地址:https://github.com/an-tao/drogon

  9. Seastar: Seastar 是一个高性能的 C++ 框架,用于构建分布式系统和高性能的服务器应用程序。它采用异步编程模型。 GitHub 地址:https://github.com/scylladb/seastar

请注意,选择适合你的项目需求和技术栈的开源项目非常重要。这些项目在不同的场景和用例中具有不同的优势,因此建议根据你的具体需求来评估这些项目。同时,查看每个项目的文档和社区支持,以确保你能够成功地使用它们。

从零设计跨平台C/C++服务器框架

1.1 基础

  • 技术框架,其实质就是建立一种标准的工作流,让更多的人参与,更低成本地实现目标。

  • 目前我们所规定的标准:

    • 使用CMake管理项目,以Linux开源系统为标准,如果在windows上缺少的库,就模仿Linux造一个,实现一份代码跑全部平台
    • 采用utf8字符编码,调用win32接口,需要把unicode转utf8。
    • 采用骆驼峰形式代码风格
      1
      2
      3
      4
      5
      6
      7
      
        class DemoClass //类名首字母大写,骆驼峰
        {
          int parramArg_;           // 变量名末尾有_,非静态变量首字母小写
          static int ParramArg_;    // 变量名末尾有_,静态变量首字母大写
          static void FuncA(){}     // 函数名末尾无有_,静态函数首字母大写
          void funcA(){}            // 函数名末尾无有_,非静态函数首字母小写
        };
      
    • 设计模式众多,选择人脑比较容易接受的设计模式:面向对象、状态机和组件设计等。模块用组件实现,可达到组件通用化,避免重复造轮子

面向对象设计

  • 面向对象设计,把业务逻辑封装类似实物世界,以一种人脑熟悉的方式,让人脑更容易接受和使用。制作工具一定要符合用户习惯
  • 我们设计的服务器框架,服务器主要功能是加工数据、提供数据和数据通讯,就像一座数据工厂,里面有各种各样的数据加工机器人。我们用变量记录和描述属性,用函数描述行为
  • 面向对象只是一种逻辑,跟编程语言无关,我们可以用C语言和C++语言各自实现一个对象。但是C++编译器对面向对象提供了更好的语法便利,这就是C++语法的优点,代价就是损失一些性能,好处就是提高写代码效率,代码更少,更容易维护

状态机设计

  • 计算机可以算是一种状态机:通过输入设备输入操作,就会改变状态
  • 例如HTTP服务器的状态:
    • 监听到客户端连接,执行accept,建立链接状态;
    • 解析HTTP头状态;
    • 解析Cookie,处理session状态
    • 验证modify和etag缓存状态
    • 路由到具体url业务状态
    • 获取到数据进行文件渲染状态
    • 向客户端发送数据状态
    • 最后关闭连接状态
  • 上述把HTTP服务器复杂的请求过程,细分成各种具体的状态,大幅降低了问题的复杂度,转换人脑更加容易熟悉的状态。
  • 程序发生bug,可以快速定位;对性能调优,监测每个状态的消耗时间,带来极大便利性

组件设计

  • 我们的机器人需要接收消息,接收到消息以后,需要进行加工,然后把加工的数据,通过消息发出去。我们有成千上万种数据加工业务,我们需要设计各种各样的机器人。
  • 如果按照继承的方式,robot作为父类,需要实现各种各样的子类,这种方式虽然也可以做到业务分离,但是复用和共享很困难。
  • 我们希望像汽车一样,标准化各种零部件,想要什么价位和性能的汽车,就选择组装什么样的零件。这个就是组件设计。

  • 设计一个类Com,作为组件基类,把单一功能的逻辑,作为一个组件。Robot作为集合类,需要什么样的Robot,就组装什么样的组件。

  • 我们通过CMake建立跨平台工程,以Linux做标准库,让我们的程序可以移植在任何操作系统上。
  • 我们也希望写的代码可以轻松地移植在任意一个项目中,甚至发到网上,轻松导入即可使用。
  • 我们也希望可视化操作,人脑更容易接受的方式去开发程序
  • 这就是组件设计。我们设计组件的核心目标是可视化操作,通过可视化方式组装我们的业务。

C++ 高性能服务器网络框架设计细节

1.1 基础

  • 这篇文章我们将介绍服务器的开发,并从多个方面探究如何开发一款高性能高并发的服务器程序。需要注意的是一般大型服务器,其复杂程度在于其业务,而不是在于其代码工程的基本框架。
  • 大型服务器一般有多个服务组成,可能会支持CDN,或者支持所谓的“分布式”等,这篇文章不会介绍这些东西,因为不管结构多么复杂的服务器,都是由单个服务器组成的。所以这篇文章的侧重点是讨论单个服务程序的结构,而且这里的结构指的也是单个服务器的网络通信层结构,如果你能真正地理解了我所说的,那么在这个基础的结构上面开展任何业务都是可以的,也可以将这种结构扩展成复杂的多个服务器组,例如“分布式”服务。
  • 文中的代码示例虽然是以C++为例,但同样适合Java(我本人也是Java开发者),原理都是一样的,只不过Java可能在基本的操作系统网络通信API的基础上用虚拟机包裹了一层接口而已(Java甚至可能基于一些常用的网络通信框架思想提供了一些现成的API,例如NIO)。有鉴于此,这篇文章不讨论那些大而空、泛泛而谈的技术术语,而是讲的是实实在在的能指导读者在实际工作中实践的编码方案或优化已有编码的方法。另外这里讨论的技术同时涉及windows和linux两个平台。

  • 所谓高性能就是服务器能流畅地处理各个客户端的连接并尽量低延迟地应答客户端的请求;
  • 所谓高并发,不仅指的是服务器可以同时支持多的客户端连接,而且这些客户端在连接期间内会不断与服务器有数据来往。
  • 网络上经常有各种网络库号称单个服务能同时支持百万甚至千万的并发,然后我实际去看了下,结果发现只是能同时支持很多的连接而已。如果一个服务器能单纯地接受n个连接(n可能很大),但是不能有条不紊地处理与这些连接之间的数据来往也没有任何意义,这种服务器框架只是“玩具型”的,对实际生产和应用没有任何意义。
  • 这篇文章将从两个方面来介绍:
    • 一个是服务器中的基础的网络通信部件;
    • 另外一个是,如何利用这些基础通信部件整合成一个完整的高效的服务器框架。

1.2 网络通信部件

1.1 需要解决的问题:

  • 既然是服务器程序肯定会涉及到网络通信部分,那么服务器程序的网络通信模块要解决哪些问题?
  • 目前,网络上有很多网络通信框架,如libevent、boost asio、ACE,但都网络通信的常见的技术手段都大同小异,至少要解决以下问题:
    • 如何检测有新客户端连接?
    • 如何接受客户端连接?
    • 如何检测客户端是否有数据发来?
    • 如何收取客户端发来的数据?
    • 如何检测连接异常?发现连接异常之后,如何处理?
    • 如何给客户端发送数据?
    • 如何在给客户端发完数据后关闭连接?
  • 稍微有点网络基础的人,都能回答上面说的其中几个问题,比如接收客户端连接用socket API的accept函数,收取客户端数据用recv函数,给客户端发送数据用send函数,检测客户端是否有新连接和客户端是否有新数据可以用IO multiplexing技术(IO复用)的select、poll、epoll等socket API
  • 确实是这样的,这些基础的socket API构成了服务器网络通信的地基,不管网络通信框架设计的如何巧妙,都是在这些基础的socket API的基础上构建的。但是如何巧妙地组织这些基础的socket API,才是问题的关键
  • 我们说服务器很高效,支持高并发,实际上只是一个技术实现手段,不管怎样,从软件开发的角度来讲无非就是一个程序而已,所以,只要程序能最大可能地满足“尽量减少等待或者不等待”这一原则就是高效的,也就是说高效不是“忙的忙死,闲的闲死”,而是大家都可以闲着,但是如果有活要干,大家尽量一起干,而不是一部分忙着依次做事情,另外一部分闲在那里无所事事

  • 我们来举一些例子具体来说明一下。 例如:
    • 默认情况下,recv函数如果没有数据的时候,线程就会阻塞在那里;
    • 默认情况下,send函数,如果tcp窗口不是足够大,数据发不出去也会阻塞在那里;
    • connect函数默认连接另外一端的时候,也会阻塞在那里;
    • 又或者是给对端发送一份数据,需要等待对端回答,如果对方一直不应答,当前线程就阻塞在这里。
  • 以上都不是高效服务器的开发思维方式,因为上面的例子都不满足“尽量减少等待”的原则,为什么一定要等待呢?有没用一种方法,这些过程不需要等待,最好是不仅不需要等待,而且这些事情完成之后能通知我。这样在这些本来用于等待的cpu时间片内,我就可以做一些其他的事情。有,也就是我们下文要讨论的IO Multiplexing技术(IO复用技术)

1.2 几种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事件,等等。所以有了上面的讨论,我们就可以得到网络通信检测可读可写或者出错事件的正确姿势。这是我这里提出的第二个原则:尽量减少做无用功的时间。这个在服务程序资源够用的情况下可能体现不出来什么优势,但是如果有大量的任务要处理,这里就成了性能的一个瓶颈。

1.3 检测网络时间的正确姿势

  • 第一,为了避免无意义的等待时间
  • 第二,不采用主动查询各个socket的事件,而是采用等待操作系统通知我们有事件的状态的策略。

  • 我们的socket都要设置成非阻塞的。在此基础上我们回到栏目(一)中提到的七个问题:
    • 如何检测有新客户端连接?
    • 如何接受客户端连接? 默认accept函数会阻塞在那里,如果epoll检测到侦听socket上有EPOLLIN事件,或者WSAAsyncSelect检测到有FD_ACCEPT事件,那么就表明此时有新连接到来,这个时候调用accept函数,就不会阻塞了。当然产生的新socket你应该也设置成非阻塞的。这样我们就能在新socket上收发数据了。
    • 如何检测客户端是否有数据发来?
    • 如何收取客户端发来的数据? 同理,我们也应该在socket上有可读事件的时候才去收取数据,这样我们调用recv或者read函数时不用等待,至于一次性收多少数据好呢?我们可以根据自己的需求来决定,甚至你可以在一个循环里面反复recv或者read,对于非阻塞模式的socket,如果没有数据了,recv或者read也会立刻返回,错误码EWOULDBLOCK会表明当前已经没有数据了
    • 如何检测连接异常?发现连接异常之后,如何处理? 同样当我们收到异常事件后例如EPOLLERR或关闭事件FD_CLOSE,我们就知道了有异常产生,我们对异常的处理一般就是关闭对应的socket。另外,如果send/recv或者read/write函数对一个socket进行操作时,如果返回0,那说明对端已经关闭了socket,此时这路连接也没必要存在了,我们也可以关闭对应的socket。
    • 如何给客户端发送数据? 这也是一道常见的网络通信面试题,某一年的腾讯后台开发职位就问到过这样的问题。给客户端发送数据,比收数据要稍微麻烦一点,也是需要讲点技巧的。
      • 首先我们不能像注册检测数据可读事件一样一开始就注册检测数据可写事件,因为如果检测可写的话,一般情况下只要对端正常收取数据,我们的socket就都是可写的,如果我们设置监听可写事件,会导致频繁地触发可写事件,但是我们此时并不一定有数据需要发送。
      • 所以正确的做法是:
        • 如果有数据要发送,则先尝试着去发送,如果发送不了或者只发送出去部分,剩下的我们需要将其缓存起来,然后再设置检测该socket上可写事件,下次可写事件产生时,再继续发送,如果还是不能完全发出去,则继续设置侦听可写事件,如此往复,一直到所有数据都发出去为止。
        • 一旦所有数据都发出去以后,我们要移除侦听可写事件,避免无用的可写事件通知。
      • 不知道你注意到没有,如果某次只发出去部分数据,剩下的数据应该暂且存起来,这个时候我们就需要一个缓冲区来存放这部分数据,这个缓冲区我们称为“发送缓冲区”。
      • 发送缓冲区不仅存放本次没有发完的数据,还用来存放在发送过程中,上层又传来的新的需要发送的数据。
      • 为了保证顺序,新的数据应该追加在当前剩下的数据的后面,发送的时候从发送缓冲区的头部开始发送。也就是说先来的先发送,后来的后发送。   
    • 如何在给客户端发完数据后关闭连接? 这个问题比较难处理,因为这里的“发送完”不一定是真正的发送完,我们调用send或者write函数即使成功,也只是向操作系统的协议栈里面成功写入数据,至于能否被发出去、何时被发出去很难判断,发出去对方是否收到就更难判断了。所以,我们目前只能简单地认为send或者write返回我们发出数据的字节数大小,我们就认为“发完数据”了。然后调用close等socket API关闭连接。当然,你也可以调用shutdown函数来实现所谓的“半关闭”。

1.4 被动关闭连接和主动关闭连接

  • 在实际的应用中,被动关闭连接是由于我们检测到了连接的异常事件,比如EPOLLERR,或者对端关闭连接,send或recv返回0,这个时候这路连接已经没有存在必要的意义了,我们被迫关闭连接
  • 而主动关闭连接,是我们主动调用close/closesocket来关闭连接。比如客户端给我们发送非法的数据,比如一些网络攻击的尝试性数据包。这个时候出于安全考虑,我们关闭socket连接。

1.5 发送缓冲区和接收缓冲区

  • 接收缓冲区也是一样的道理,当收到数据以后,我们可以直接进行解包,但是这样并不好,
    • 理由一:除非一些约定俗称的协议格式,比如http协议,大多数服务器的业务的协议都是不同的,也就是说一个数据包里面的数据格式的解读应该是业务层的事情,和网络通信层应该解耦,为了网络层更加通用,我们无法知道上层协议长成什么样子,因为不同的协议格式是不一样的,它们与具体的业务有关。
    • 理由二:即使知道协议格式,我们在网络层进行解包处理对应的业务,如果这个业务处理比较耗时,比如需要进行复杂的运算,或者连接数据库进行账号密码验证,那么我们的网络线程会需要大量时间来处理这些任务,这样其它网络事件可能没法及时处理。
  • 鉴于以上二点,我们确实需要一个接收缓冲区,将收取到的数据放到该缓冲区里面去,并由专门的业务线程或者业务逻辑去从接收缓冲区中取出数据,并解包处理业务。

  • 说了这么多,那发送缓冲区和接收缓冲区该设计成多大的容量?这是一个老生常谈的问题了,因为我们经常遇到这样的问题:预分配的内存太小不够用,太大的话可能会造成浪费。怎么办呢?答案就是像string、vector一样,设计出一个可以动态增长的缓冲区,按需分配,不够还可以扩展。
  • 需要特别注意的是,这里说的发送缓冲区和接收缓冲区是每一个socket连接都存在一个。这是我们最常见的设计方案。

1.6 协议的设计

  • 除了一些通用的协议,如http、ftp协议以外,大多数服务器协议都是根据业务制定的。协议设计好了,数据包的格式就根据协议来设置。
  • 我们知道tcp/ip协议是流式数据,所以流式数据就是像流水一样,数据包与数据包之间没有明显的界限
  • 比如A端给B端连续发了三个数据包,每个数据包都是50个字节,
    • B端可能先收到10个字节,再收到140个字节;
    • 或者先收到20个字节,再收到20个字节,再收到110个字节;
    • 也可能一次性收到150个字节。
    • 这150个字节可以以任何字节数目组合和次数被B收到。
  • 所以我们讨论协议的设计第一个问题就是如何界定包的界限,也就是接收端如何知道每个包数据的大小
  • 目前常用有如下三种方法
    • 固定大小,这种方法就是假定每一个包的大小都是固定字节数目,例如上文中讨论的每个包大小都是50个字节,接收端每收气50个字节就当成一个包
    • 指定包结束符,例如以一个\r\n(换行符和回车符)结束,这样对端只要收到这样的结束符,就可以认为收到了一个包,接下来的数据是下一个包的内容。
    • 指定包的大小,这种方法结合了上述两种方法,一般包头是固定大小,包头中有一个字段指定包体或者整个大的大小,对端收到数据以后先解析包头中的字段得到包体或者整个包的大小,然后根据这个大小去界定数据的界线。

  • 协议要讨论的第二个问题是,设计协议的时候要尽量方便解包,也就是说协议的格式字段应该尽量清晰明了。
  • 协议要讨论的第三个问题是,根据协议组装的单个数据包应该尽量小,注意这里指的是单个数据包,这样有如下好处
    • 第一、对于一些移动端设备来说,其数据处理能力和带宽能力有限,小的数据不仅能加快处理速度,同时节省大量流量费用;
    • 第二、如果单个数据包足够小的话,对频繁进行网络通信的服务器端来说,可以大大减小其带宽压力,其所在的系统也能使用更少的内存。
  • 协议要讨论的第四个问题是,对于数值类型,我们应该显式地指定数值的长度
    • 比如long型,在32位机器上是32位4个字节,但是如果在64位机器上,就变成了64位8个字节了。
    • 这样同样是一个long型,发送方和接收方可能因为机器位数的不同会用不同的长度去解码。所以建议最好,在涉及到跨平台使用的协议最好显式地指定协议中整型字段的长度,比如int32、int64等等。

1.3 服务器程序结构的组织

  • 上面的六个标题,我们讨论了很多具体的细节问题,现在是时候讨论将这些细节组织起来了。
  • 根据我的个人经验,目前主流的思想是one thread one loop+reactor模式(也有proactor模式)的策略。通俗点说就是一个线程一个循环,即在一个线程的函数里面不断地循环依次做一些事情,这些事情包括检测网络事件、解包数据产生业务逻辑
  • 我们先从最简单地来说,设定一些线程在一个循环里面做网络通信相关的事情。另外设定一些线程去处理接收到的数据,并解包处理业务逻辑,这些线程可以认为是业务线程了,