首页 关于
树枝想去撕裂天空 / 却只戳了几个微小的窟窿 / 它透出天外的光亮 / 人们把它叫做月亮和星星
目录

迟到的TCP服务器系统框图

到目前为之,我们通过系统调用 poll 复用 IO,并用类 PollLoop 进行了封装,提供了一个可以在单线程中处理并发任务的 Reactor 框架。 在此基础上,我们实现了一个简单的 TCP 服务器,提供 echo 服务,还初步讨论了一下输出和输入缓存。现在,让我们来回顾一下之前的文章, 画画系统框图,准备重构代码。

在本文中,我们还将把编译系统从手写 Makefile 切换到 CMake 体系下。主要是考虑到以后将使用 glog, gtest, gflags等第三方的工具, 而它们的安装路径在不同的机器上可能不太一样。手写 Makefile 来管理这些依赖多少有些麻烦,所以为了方便我们选择使用 CMake 来指导编译过程。


1. 事件响应框架 PollLoop

如右侧的框图所示,我们的事件响应框架主要涉及到事件监听器 PollLoop 和事件分发器 PollEventHandler 两个类型。

PollLoop 有两个容器 mPollFdList 和 mHanderList,其中的元素是一一对应的。mPollFdList的元素类型是一个系统的结构体 struct pollfd, 是系统调用 poll 使用的数据类型。字段 fd 描述了需要监听的文件描述符,events记录了需要监听的事件,当poll返回的时候, 系统会将发生的事件记录在字段 revents 中。

mHandlerList 则为每个 pollfd 提供了一个事件分发器。我们提供了函数ApplyHandlerOnLoop用来注册分发器到监听器上, 相应的函数UnApplyHandlerOnLoop用来注销。

监听器在函数LoopOnce中调用分发器的HandleEvents接口实现事件的分发,目前我们只支持可读、可写、关闭三种类型的事件。 分发器查询 pollfd 的 revents 字段来确认事件类型,并调用与之对应的回调函数。用户可以通过 SetXXXCallBk 接口来注册回调。


2. TCP服务器框架

下面的框图描述了TCP服务器框架涉及到的几个主要的数据结构及其关联关系。 在前文中, 我们定义了类TcpServer来表示一个TCP服务器。同时为该类添加了一个Acceptor类型的成员 mAcceptor,用来监听指定端口。 当有新的连接请求到来的时候,mAcceptor的EventHandler就会产生一个可读事件,并在其回调函数中新建一个连接,用类型 Connection 来描述。

后来为了实现一个功能, 自动关闭那些长时间没有通信的连接,我们增加了计时器的类型 Timer。它可以通过RunAfter或者RunEvery两个接口,在一段时间之后运行指定回调函数一次, 或者每隔一段时间执行一次回调函数。我们还通过时间轮盘来管理超时的连接。 类 TcpServer 的成员 mTimeWheel 就是一个通过链表实现的时间轮盘,它可以看作是类 Connection 的容器。

下面的四副图是TCP服务器主要处理的四种事件:新建连接、超时连接、接收消息、连接可写。除此之外还有一个关闭连接的事件需要处理, 但它的逻辑以及涉及到的数据类型与接收消息类似,所以这里就省略了没画。这些事件的处理都是在一个线程中进行的。

(a). TCP服务器处理新建连接 (b). TCP服务器处理超时连接

如上图(a)所示,新建连接事件是由Acceptor捕获到并通过TcpServer的OnNewConnection函数来构建的新的Connection对象, 最后会通过TcpServer的回调接口通知用户。如图(b)所示,连接超时事件是由Timer捕获到的,在TcpServer的OnTimeOut函数中更新时间轮盘并关闭超时连接。

(c). TCP服务器接收新消息 (d). 连接可写

如上图(c)所示,TcpServer通过Connection接收新的消息并最终通知用户。这里的框图与代码稍微有些不一致, 在介绍输入缓存的时候, 我们偷懒直接在Connection.OnReadEvent函数中就把接收到的数据分发给用户了,TcpServer.OnNewRawMsg仅仅起到了维护时间轮盘的作用。 这让整个代码看起来有点混乱,我们将在后续的实现中改正过来。

上图(d)描述了连接可写事件,这个事件是用于发送消息的。 在介绍输出缓存的时候, 我们希望用户把需要发送的数据交给输出缓存之后就不在关心了,后面的数据发送都有服务器来完成。但系统缓存是有用完的时候, 面对这种情况我们就需要在系统缓存可写的时候,把输出缓存张红的数据搬到系统缓存中。

3. CMake编译

原本我们使用Makefile来指导编译过程的,图省事,我们还把安装目录指定到 '/usr/local' 下,并通过cp直接拷贝过去的,整个脚本硬编码的形式太多。 再加上后面我们还想用一些第三方的库,发现改起来还挺费劲的。所以干脆在这次重构的时候把编译系统也换一下吧。

cmake可以看作是make的一个封装,它可以比较方便的管理代码的依赖关系,生成Makefile。在没有cmake之间,人们一般都会在代码目录中添加一个config的脚本, 用来检查各种依赖,那时编译安装一个项目需要经历 config-->make-->make install 三个阶段。有了cmake之后则是 cmake-->make-->make install。

        cmake_minimum_required(VERSION 3.1.0)    
        project(XiaoTuNetBox)
        set(PACKAGE_VERSION "0.0.7")
        
        add_compile_options(-std=c++17)
        set(CMAKE_BUILD_TYPE RELEASE)

我们在项目的根目录下创建了CMakeLists.txt, 在该文件的一开始写下了右侧所示的片段,我们声明了CMake的最低版本,定义了项目名称和版本号,指定使用C++17标准进行RELEASE编译。 我们从C++11标准开始C++有了很大的改动增加了很多语法特性,其标准库也着实扩大了一波。我感觉其中最实用的就是智能指针和线程库的引入, 用起来却是很舒爽,我应该很难回去了。从C++11到C++17并没有太大的改进,但是由于我平时工作中会大量使用一个叫做pcl的点云库, 这个库从1.10版本开始就使用C++17标准了,所以我在这里也就选用C++17。

至于编译方式是选择RELEASE还是DEBUG,关键在于是否要发布软件。RELEASE编译会进行一些优化,编译出来的文件是没有符号表这些调试信息的, 我们也就很难使用gdb这样的工具进行调试。而且它也会新增一些宏屏蔽掉cassert或者assert.h中声明的assert断言,所以如果不能保证assert断言总是成立, RELEASE编译出来的程序会因此而跑飞。一般情况下我们会先DEBUG编译,经过充分测试,确认没有致命bug之后再RELEASE发布。

        message("${PROJECT_NAME}")
        message("${PROJECT_SOURCE_DIR}")
        message("${PROJECT_BINARY_DIR}")
        message("${CMAKE_INSTALL_PREFIX}")    

        include_directories(
            ${PROJECT_SOURCE_DIR}/include
        )

接着,我们通过message输出了一些环境变量。${PROJECT_NAME}就是我们刚刚通过project定义的项目名称XiaoTuNetBox, ${PROJECT_SOURCE_DIR}则是程序的源码路径,${PROJECT_BINARY_DIR}是编译生成二进制文件的保存路径。 而${CMAKE_INSTALL_PREFIX}指示了安装路径的前缀。通过如下的指令,我们就可以指定安装路径了。 其中'/path/to/install'和'/path/to/source'分别表示安装路径和源码路径,读者需要根据实际情况自己调整。

$ cmake -DCMAKE_INSTALL_PREFIX=/path/to/install /path/to/source

然后,就把源码目录中的include子目录放到头文件搜索路径中了。这样我们就可以在代码中方便的引用项目本身的头文件了。 为了在代码中体现出使用了XiaoTuNetBox这个库,我们专门在include目录下又建了一个XiaoTuNetBox的子目录。 这样我们在代码中就可以使用类似下面的语句来引用头文件,一眼就可以看出来使用的XiaoTuNetBox的代码。

        file(GLOB PROJECT_SRC_FILES
             ${PROJECT_SOURCE_DIR}/src/*cpp)
        file(GLOB PROJECT_INC_FILES
             ${PROJECT_SOURCE_DIR}/include/${PROJECT_NAME}/*.h
             ${PROJECT_SOURCE_DIR}/include/${PROJECT_NAME}/*.hpp)
        add_library(${PROJECT_NAME} SHARED ${PROJECT_SRC_FILES})
        target_link_libraries(${PROJECT_NAME})
#include <XiaoTuNetBox/TcpServer.h>

接下来,我们通过文件名的后缀和file接口,把src目录下的源文件和include下的头文件都写到变量 ${PROJECT_SRC_FILES} 和 ${PROJECT_INC_FILES} 中了。 这样,通过 add_library 指导编译库文件的时候就不需要把用到的文件一个个的手动写在其参数列表中了。我们只需要保证src中只放置那些不带有main函数的源文件。 target_link_libraries 用于指导链接生成名为 libXiaoTuNetBox.so 的库文件。

        add_executable(u_echo_server utils/u_echo_server.cpp)
        target_link_libraries(u_echo_server ${PROJECT_NAME})
        add_executable(u_stdin_ipv4_talk utils/u_stdin_ipv4_talk.cpp)
        target_link_libraries(u_stdin_ipv4_talk ${PROJECT_NAME})

再然后,我们通过 add_executable 指导编译了两个可执行文件 u_echo_server 和 u_stdin_ipv4_talk。这两个程序分别是我们的 echo 服务器和客户端例程。 由于它们可以当做工具来用,而且生成它们的源文件都带有 main 函数,所以我们将这两个cpp文件放到了utils目录下。

        enable_testing()
        set(TEST_DEPENDS gtest gtest_main pthread rt)
        
        function(build_test_case NAME EXEC TSRC)
            message(STATUS "build_test_case:"  ${NAME})
            message(STATUS "EXEC = " ${EXEC})
            message(STATUS "TSRC = " ${TSRC})
        
            add_executable(${EXEC} ${TSRC})
            target_link_libraries(${EXEC} ${TEST_DEPENDS} ${PROJECT_NAME})
            add_test(NAME ${NAME} COMMAND ${EXEC})
        endfunction()
        
        build_test_case(DataArray t_DataArray test/t_DataArray.cpp)
        build_test_case(DataQueue t_DataQueue test/t_DataQueue.cpp)
        build_test_case(Address t_Address test/t_Address.cpp)
        build_test_case(Socket t_Socket test/t_Socket.cpp)

我看有些工程可以在编译的时候直接通过make test运行测试用例,感觉挺方便的,所以就有了右侧的代码片段。 通过 enable_testing 来在生成的 Makefile 中添加编译目标 'test',然后通过接口 add_test 给出运行测试用例的指令,之后就可以 make test 跑测试了。

这里为了编写方便,就写了一个 build_test_case 的函数来指导各个测试用例的编译工作。同样还是为了CMakeList文件写起来方便, 我们约定把所有的测试用例的源文件都放到 test 的目录下。并且生成的可执行文件名与源文件名去掉后缀时一样。 测试用例的运行不接受任何参数和选项,如果一定需要,请在源文件中通过加载配置文件来解决。

个人觉得,程序员技术都是在为了能够偷懒摸鱼而不断精进的。我不喜欢花时间写文档,看源码是我了解一个工程的主要手段, 一直以为源码是接近作者思想的最直接途径。所以我一直提倡写让人可以看懂的代码,比较排斥写文档。但是文档也不是没有作用, 很多时候一张图就可以清晰的表达出整个系统的架构,胜过我们研究半天的代码。再者老板们往往要求我们出文档。所以,这里我们找到了 doxygen 来根据注释自动生成文档, 如下面的代码所示。其中 build_doxygen.cmake 用来指引文档生成,我忘了它是从哪个项目里拷过来的了,反正测试好用。

        include(cmake/build_doxygen.cmake)
        build_doxygen()

最后,让我们添加上 install 和 uninstall 的配方。其中 install_package.cmake 文件主要是生成项目的cmake配置文件, 该配置文件将在安装过程中拷贝到 ${CMAKE_INSTALL_PREFIX}/lib/cmake 目录下与项目同名的子目录下,这样我们在其它项目中使用XiaoTuNetBox的时候,就可以通过find_pacakge找到它了。

        include(cmake/install_package.cmake)
        install_package()
        
        INSTALL(TARGETS ${PROJECT_NAME}
            LIBRARY DESTINATION ${CMAKE_INSTALL_PREFIX}/lib
            ARCHIVE DESTINATION ${CMAKE_INSTALL_PREFIX}/lib
        )
        
        INSTALL(FILES ${PROJECT_INC_FILES}
            DESTINATION ${CMAKE_INSTALL_PREFIX}/include/${PROJECT_NAME}
        )

每次执行完 make install 指令之后,都会在 ${PROJECT_BINARY_DIR} 的目录下生成一个名为 install_manifest.txt 的文件。这个文件中记录了所有文件的安装目录, 所以在下面的代码片段的 COMMAND 一行中,我们可以看到 xargs rm -vf < install_manifest.txt 的指令,用来删除其中记录的文件。

        add_custom_target("uninstall" COMMENT "Uninstall installed files")
        add_custom_command(
            TARGET "uninstall"
            POST_BUILD
            COMMENT "Uninstall files with install_manifest.txt"
            COMMAND xargs rm -vf < install_manifest.txt || echo Nothing in
                    install_manifest.txt to be uninstalled!
        )

4. 完

本文中,我们先画了整个工程的基础 PollLoop 的运行时图,并解释了事件响应框架。然后画了 TcpServer 相关的 UML 关系图,并解释整个 TCP 服务器的架构。 又画了 TCP 服务器处理四个典型事件的运行时图,可以看到事件分发到用户的回调函数的整个过程。最后,更换了编译系统,并详细解释了编译指导文件 CMakeLists.txt。




Copyright @ 高乙超. All Rights Reserved. 京ICP备16033081号-1