Skip to content
NaN03 edited this page Sep 5, 2024 · 21 revisions

libhv是一个比libevent、libev、libuv更易用的跨平台国产网络库,可用来开发TCP/UDP/SSL/HTTP/WebSocket客户端/服务端。

项目地址:https://github.com/ithewei/libhv.git

码云镜像:https://gitee.com/libhv/libhv.git

QQ技术交流群:739352073

libhv入门教程:https://hewei.blog.csdn.net/article/details/113733758

libhv源码剖析:https://hewei.blog.csdn.net/article/details/123295998

Q:libhv名称由来

A:libhv是一个类似于libevent、libev、libuv的跨平台网络库,提供了带非阻塞IO和定时器的事件循环。 libhv的名称也正是继承此派,寓意高性能的事件循环High-performance event loop library

Q:libhv和libevent、libev、libuv有什么不同

A:

  • libevent最为古老、有历史包袱,bufferevent虽为精妙,却也难以理解使用;
  • libev可以说是libevent的简化版,代码极为精简,但宏定义用的过多,代码可读性不强,且在Windows上实现不佳;
  • libuv是nodejs的c底层库,最先也是由libevent+对Windows IOCP支持,后来才改写自成一体,同时实现了管道、文件的异步读写,很强大,但结构体比较多,封装比较深,uv_write个人感觉难用;
  • libhv本身是参考了libevent、libev、libuv的实现思路,它们的核心都是事件循环(即在一个事件循环中处理IO、定时器等事件),但提供的接口最为精简,API接近原生系统调用,最容易上手;
  • 具体这几个库的写法比较见https://github.com/ithewei/libhv/tree/master/echo-servers
  • 此外libhv集成了SSL/TLS加密通信,支持心跳、转发、拆包、多线程安全write和close等特性,实现了HTTP、WebSocket等协议;
  • 当然这几个库的性能是接近的,都将非阻塞IO多路复用用到了极致;
  • 更详细介绍见国产开源库libhv为何能被awesome-c和awesome-cpp收录

Q:libhv的定位

A:精妙小巧跨平台,简单实用易上手

  • base封装了很多跨平台的代码,如hatomic原子操作、hthread线程、hmutex线程同步,当然这都是基于configure/cmake自动生成的hconfig.hhplatform.h两个头文件中提供的平台宏、编译器宏等实现的;
  • event模块则实现了事件循环(包括IO、timer、idle),不同的平台有不同的实现,如Linux使用epollWindows使用IOCPMac使用kqueueSolaris使用evport,感兴趣的可以读一读event下的源码;
  • http模块则基于event模块实现了本世纪最为通用的应用层协议http协议,包括http服务端和客户端,libhv中examples下提供的httpd,性能可媲美nginx服务;
  • 不妨勇敢的说,libhv是c++编写HTTP API服务端/客户端最简单的库,没有之一

Q:libhv的发展规划

A:

  • 开发libhv官网
  • redis clientredis同步/异步客户端
  • mysql clientmysql同步/异步客户端(虽然我不太赞成使用c/c++开发数据库CURD业务)
  • async DNS:异步DNS(自实现或者接入cares
  • 绑定到其它语言,如lua、js、python,可以使用脚本开发业务逻辑
  • hrpc :基于libhv实现RPC框架,兼容grpcthrift协议(注:简单的rpc实现可以参考 examples目录 下的 jsonrpcprotorpc,当然一个完善的RPC框架涉及IDL自动生成各种语言的脚手架、服务注册与发现、负载均衡、重试、熔断、限流等,推荐看 brpc的文档,能学到很多,目前能满足多语言版本的成熟RPC框架也就grpcthrift
  • rudp: 可靠UDP(目前接入了KCP,后续可能接入FEC、QUIC
  • io_uring:接入io_uring尝鲜
  • coroutine:协程,我的想法是等c++20成熟后,直接基于c++20的协程实现
  • cppsocket.io:基于libhv实现c++版本的 socket.io
  • IM-libhv:基于libhv实现IM即时通信,例如网页即时聊天、即时消息推送、微信小程序、手机app消息推送、PC软件消息推送等等
  • MediaServer-libhv:基于libhv实现流媒体服务,类似 srsZLMediaKit
  • GameServer-libhv:基于libhv实现游戏服务,类似 skynetkbengine

以上列举的这些计划和应用场景仅供参考,抛砖引玉,给大家提供一个思路。 更多发展规划详见docs/PLAN.md

Q:libhv性能如何

A:

Q:libhv稳定性如何,是否商用

A:libhv自2018年5月创建,至今已有四年多迭代,900+提交,4k+ stars,1k+ QQ群成员,广泛用于公司IoT和HTTP API服务中,此外QQ群里也是有不少水友成功用在各种项目中,反馈很好; 请放心使用,开源且保证长期维护,QQ群里也有很多大神积极解答。

Q:libhv如何入门

A:

  • 建议先从运行项目根目录下getting_started.sh脚本开始, 你会被libhv的httpd所展示的便利性所吸引;
  • 阅读libhv入门教程:https://hewei.blog.csdn.net/category_9866493.html
  • examples下的示例代码;
  • 源码阅读推荐路线base->event->http

Q:libhv如何使用

A:libhv可通过Makefilecmake编译出动态库和静态库,make install后包含相关头文件(base模块下头文件比较分散,可直接#include "hv.h")和链接库文件即可使用;当然libhv模块划分清晰,低耦合,你也可以直接把源文件拿到自己项目中去编译,如日志功能hlog.hhlog.c就可以直接拿去用。

Q:libhv如何交叉编译

A:以ubuntu下编译arm为例:

Makefile方式:

sudo apt install gcc-arm-linux-gnueabi g++-arm-linux-gnueabi
export CROSS_COMPILE=arm-linux-gnueabi-
./configure
make clean
make libhv

cmake方式:

mkdir build
cd build
cmake .. -DCMAKE_C_COMPILER=arm-linux-gnueabi-gcc -DCMAKE_CXX_COMPILER=arm-linux-gnueabi-g++
cmake --build . --target libhv libhv_static

更多编译平台和编译选项介绍见BUILD.md

Q:libhv在Windows下如何编译

A:Windows下编译libhv请先使用cmake生成VS工程。

附VS各版本下载地址VS2008 ~ VS2019下载地址

cmake官网下载过慢的可以到gitee下载cmake releasehttps://gitee.com/ithewei/cmake-release cmake不会使用的请自行百度

Q:Windows下编译不过

A:Windows下VS编译最低要求VS2015版本(VS2015要求Update3版本,推荐使用VS2017或更高版本VS),这是因为http模块中使用了一个modern c++ JSON解析库nlohmann::json,该json库使用方法见 https://github.com/nlohmann/json

如果想使用vs低版本编译或只使用c语言的,可以在cmake时关闭使用了c++11的模块WITH_EVPPWITH_HTTP,只编译baseevent等c模块,当然你就不能使用http模块的功能了。

Q:Windows下链接不过

A: Windows下cmake生成vs工程,打开hv.sln编译后会生成头文件include/hv、静态库lib/hv_static.lib和动态库lib/hv.dll,所以有动态库和静态库两种链库方式:

1、动态导入库hv.lib + 动态库hv.dll

方案一:工程-> 属性 -> Linker -> Input -> Additional Dependencies 加 hv.lib

方案二:代码里添加#pragma comment(lib, "hv.lib")

2、静态库声明宏HV_STATICLIB + 静态库hv_static.lib

  • 工程-->属性-->c/c++-->预处理器-->预处理器定义中添加HV_STATICLIB预编译宏,以屏蔽hexport.h头文件中动态库导入宏#define HV_EXPORT __declspec(dllimport) 如使用curl静态库类似加CURL_STATICLIB预编译宏
  • 工程-> 属性 -> Linker -> Input -> Additional Dependencies 加 hv_static.lib 或 代码里添加#pragma comment(lib, "hv_static.lib")

Q: Windows下报<winsock2.h>头文件里符号重定义错误

A:如果在#include hv头文件前#include<windows.h>windows.h就会#include <winsock.h>,而hv里#include<winsock2.h>,就会报socket相关符号重定义错误。

解决方法是添加WIN32_LEAN_AND_MEAN预编译宏,或者先#include hv的头文件。

Q:如何禁用libhv默认日志

A:hlog_disable:即logger_set_level(hlog, LOG_LEVEL_SILENT),设置日志等级为SILENT。

hlog默认输出到当天日志文件,带truncate功能,不用担心长时间运行磁盘不足,记录了libhv内部的关键信息,方便出错时排查,非必要不建议禁用。

Q:如何开启SSL/TLS、https、wss功能

A:libhv中集成了openssl来支持SSL/TLS加密通信,通过打开./configure --with-opensslcmake -DWITH_OPENSSL=ON开启WITH_OPENSSL选项,编译即可。

Makefile方式:

./configure --with-openssl
make clean && make && sudo make install

cmake方式:

mkdir build
cd build
cmake .. -DWITH_OPENSSL=ON
cmake --build .
sudo cmake --install .

测试https:

bin/httpd -s restart -d
bin/curl -v http://localhost:8080
bin/curl -v https://localhost:8443
# curl -v https://127.0.0.1:8443 --insecure

https代码示例可以参考examples/http_server_test.cppTEST_HTTPS相关内容
wss代码示例可以参考examples/websocket_server_test.cppTEST_WSS相关内容

当然你也可以用nginxhttps代理。

Q:Windows下如何集成openssl

A:Windows下请自行下载或编译openssl,将openssl头文件include和库文件lib放到libhv可搜索路径(如libhv根目录下includelib)。

giteeWindows openssl已编译好的https://gitee.com/ithewei/openssl-release.git (需将libssl.dll.a改名为ssl.liblibcrypto.dll.a改名为crypto.lib

Q:https/wss连接失败排除步骤

1、确认是否已集成SSL/TLS

以集成openssl为例,确认方法如下:

  • linux下可使用ldd libhv.so,查看动态库依赖项中是否有libssl.so、libcrypto.so
  • windows下使用命令行工具dumpbin /DEPENDENTS hv.dll或者图形界面工具dependency查看
  • 代码里可打印hssl_backend(),如打印openssl则表示使用了openssl

2、连接失败后,日志里查看是否有ssl handshake failed失败的字样,如有表示开启了SSL,但是握手失败,具体原因可能是对端开启了证书验证,需要调用hssl_ctx_init输入有效的证书。

接口定义:

typedef struct {
    const char* crt_file;
    const char* key_file;
    const char* ca_file;
    const char* ca_path;
    short       verify_peer;
    short       endpoint;
} hssl_ctx_init_param_t;

HV_EXPORT hssl_ctx_t hssl_ctx_init(hssl_ctx_init_param_t* param);

调用示例:

    hssl_ctx_init_param_t param;
    memset(&param, 0, sizeof(param));
    param.crt_file = "cert/server.crt";
    param.key_file = "cert/server.key";
    if (hssl_ctx_init(&param) == NULL) {
        fprintf(stderr, "hssl_ctx_init failed!\n");
        return -20;
    }

Q:websocket启动不久断链排查

A:

1、日志里查看是否有websocket no pong的错误打印,如有表示对方不支持心跳响应,而libhvwebsocket客户端默认开启了应用层心跳检测,即websocketPING/PONG帧,一段时间没收到PONG帧就会主动断链,可以通过如下方式关闭心跳检测功能来规避:

  • websocket客户端:WebSocketClient::setPingInterval(0);
  • websocket服务端:WebSocketService::setPingInterval(0);

2、日志里查看是否有keepalive timeout的打印,如有表示客户端/服务端都没有发送心跳来保活,此时可以通过开启一端心跳来保活,如每10s发送一次心跳:

  • websocket客户端:WebSocketClient::setPingInterval(10000); (更推荐客户端发送心跳)
  • websocket服务端:WebSocketService::setPingInterval(10000);

或者干脆关闭keepalive判活机制:

  • websocket服务端:HttpService::keepalive_timeout=0;

Q:数据量过大,发送过快,导致断链情况排查

A:当日志里出现ERROR write bufsize > xxx, close it!的字样时,说明写缓存满了,hv内部自动断开了连接。

对于大数据的发送,我们应该做分片和流控,不然带宽不够或者对端接收过慢都会导致内部写缓存堆积,如果不加约束就会导致内存占用过大,所以hv内部对写缓存有个最大限制是16M,可以通过hio_set_max_write_bufsize或者channel->setMaxWriteBufsize修改该值,当然最正确的方法还是做流控,在channel->onwrite回调里,通过channel->isWriteComplete判断写完成时再发送下一帧数据,也可以通过channel->writeBufsize()获取当前写缓冲数据积压情况做决定。 具体代码示例可参考 examples/tinyhttpd.c 里的 http_serve_file

Q:http如何上传、下载文件

A:

上传文件:

  • 只上传文件,设置Content-Type,如image/jpeg,将文件内容读入body即可,见requests::uploadFile、HttpMessage::File接口;
  • 上传文件+其它参数,推荐使用formdata格式,即Content-Type: multipart/form-data,见requests::uploadFormFile、HttpMessage::FormFile接口;
  • 如不得不使用json格式,需将二进制文件base64编码后当做普通字符串赋值;

下载文件:

  • 通过HttpService::Static接口,即可提供静态资源访问,通过url下载该目录下的文件,如wget http://ip:port/path/to/filename
  • 下载小文件见requests::downloadFile、HttpMessage::SaveFile、HttpMessage::SaveFormFile接口;
  • 下载大文件推荐使用Range头分片请求,具体参考examples/wget.cpp (内含下载进度、断点续传);

Q: http如何异步响应

A:编写http服务端,强烈建议通读examples/httpd,里面有你想要的一切

  • 异步响应参考/async;
  • 定时响应参考Handler::setTimeout;
  • json响应参考Handler::json;
  • formdata响应参考Handler::form;
  • urlencoded响应参考Handler::kv;
  • restful风格参考Handler::restful
// 同步handler: 适用于非阻塞型的快速响应
typedef std::function<int(HttpRequest* req, HttpResponse* resp)>                            http_sync_handler;
// 异步handler: 适用于耗时处理和响应
typedef std::function<void(const HttpRequestPtr& req, const HttpResponseWriterPtr& writer)> http_async_handler;
// 类似nodejs koa的ctx handler: 兼容以上两种handler的最新写法,可在回调里自己决定同步响应还是异步响应
typedef std::function<int(const HttpContextPtr& ctx)>                                       http_ctx_handler;

因为历史兼容原因,同时保留支持以上三种格式的handler,用户可根据自己的业务和接口耗时选择合适的handler,如果使用的较新版libhv,推荐使用带HttpContext参数的http_ctx_handler

三种handler的等同写法见:

    // 同步handler: 回调函数运行在IO线程
    router.POST("/echo", [](HttpRequest* req, HttpResponse* resp) {
        resp->content_type = req->content_type;
        resp->body = req->body;
        return 200;
    });

    // 异步handler:回调函数运行在hv::async全局线程池
    router.POST("/echo", [](const HttpRequestPtr& req, const HttpResponseWriterPtr& writer) {
        writer->Begin();
        writer->WriteStatus(HTTP_STATUS_OK);
        writer->WriteHeader("Content-Type", req->GetHeader("Content-Type"));
        writer->WriteBody(req->body);
        writer->End();
    });

    // 带HttpContext参数的handler是兼容同步/异步handler的最新写法,推荐使用
    // 回调函数运行在IO线程,可通过hv::async丢到全局线程池处理,或者自己的消费者线程/线程池
    // HttpContext里包含了HttpRequest和HttpResponseWriter成员变量,参照nodejs koa提供了一系列操作HttpRequest和HttpResponse的成员函数,写法更加简洁
    router.POST("/echo", [](const HttpContextPtr& ctx) {
        return ctx->send(ctx->body(), ctx->type());
    });

    router.POST("/echo", [](const HttpContextPtr& ctx) {
        // demo演示丢到hv::async全局线程池处理,实际使用推荐丢到自己的消费者线程/线程池
        hv::async([ctx]() {
            ctx->send(ctx->body(), ctx->type());
        });
        return 0;
    });

Tips:

  • std::async在不同c++运行库下有着不同的实现,有的是线程池,有的就是当场另起一个线程,而且返回值析构时也会阻塞等待,不推荐使用,可以使用hv::async代替(需要#include “hasync.h”),可以通过hv::async::startup配置全局线程池的最小线程数、最大线程数、最大空闲时间,hv::async::cleanup用于销毁全局线程池;
  • 关于是否需要丢到消费者线程处理请求的考量:在并发不高的场景,通过设置worker_threads起多线程就可以满足了,不能满足的(并发很高不能容忍阻塞后面请求、handler回调里耗时秒级以上)才考虑将HttpContextPtr丢到消费者线程池处理;
  • 关于大文件的发送可以参考 examples/httpd 里的largeFileHandler,单独起线程循环读文件->发送,但是要注意做好流量控制,因为磁盘IO总是快于网络IO的,或者对方接受过慢,都会导致发送数据积压在发送缓存里,耗费大量内存,示例里是通过判断WriteBody返回值调整sleep睡眠时间从而控制发送速度的,当然你也可以通过ctx->writer->fd()获取到套接字,设置成阻塞来发;或者设置ctx->writer->onwrite监听写完成事件统计写数据来决定是否继续发送;或者通过ctx->writer->writeBufsize()获取当前写缓存积压字节数来决定是否继续发送;
  • 关于发送事先不知道长度的实时流数据,可以通过chunked方式,回调里基本流程是Begin -> EndHeaders("Transfer-Encoding", "chunked") -> WriteChunked -> WriteChunked -> ... -> End

Q: HTTP服务允许跨域访问

A:HttpService::AllowCORS()

Q: TCP如何处理粘包与分包

A:libhv提供了设置拆包规则接口,c接口见hio_set_unpack,c++接口见SocketChannel::setUnpack,支持固定包长、分隔符、头部长度字段三种常见的拆包方式,调用该接口设置拆包规则后,内部会根据拆包规则处理粘包与分包,保证回调上来的是完整的一包数据,大大节省了上层处理粘包与分包的成本,该接口具体定义如下:

typedef enum {
    UNPACK_BY_FIXED_LENGTH  = 1,    // 根据固定长度拆包
    UNPACK_BY_DELIMITER     = 2,    // 根据分隔符拆包,如常见的“\r\n”
    UNPACK_BY_LENGTH_FIELD  = 3,    // 根据头部长度字段拆包
} unpack_mode_e;

#define DEFAULT_PACKAGE_MAX_LENGTH  (1 << 21)   // 2M

// UNPACK_BY_DELIMITER
#define PACKAGE_MAX_DELIMITER_BYTES 8

// UNPACK_BY_LENGTH_FIELD
typedef enum {
    ENCODE_BY_VARINT        = 1,                // varint编码
    ENCODE_BY_LITTEL_ENDIAN = LITTLE_ENDIAN,    // 小端编码
    ENCODE_BY_BIG_ENDIAN    = BIG_ENDIAN,       // 大端编码
} unpack_coding_e;

typedef struct unpack_setting_s {
    unpack_mode_e   mode; // 拆包模式
    unsigned int    package_max_length; // 最大包长度限制
    // UNPACK_BY_FIXED_LENGTH
    unsigned int    fixed_length; // 固定包长度
    // UNPACK_BY_DELIMITER
    unsigned char   delimiter[PACKAGE_MAX_DELIMITER_BYTES]; // 分隔符
    unsigned short  delimiter_bytes; // 分隔符长度
    // UNPACK_BY_LENGTH_FIELD
    unsigned short  body_offset; // body偏移量(即头部长度)real_body_offset = body_offset + varint_bytes - length_field_bytes
    unsigned short  length_field_offset; // 头部长度字段偏移量
    unsigned short  length_field_bytes; // 头部长度字段所占字节数
    unpack_coding_e length_field_coding; // 头部长度字段编码方式,支持varint、大小端三种编码方式,通常使用大端字节序(即网络字节序)
#ifdef __cplusplus
    unpack_setting_s() {
        // Recommended setting:
        // head = flags:1byte + length:4bytes = 5bytes
        mode = UNPACK_BY_LENGTH_FIELD;
        package_max_length = DEFAULT_PACKAGE_MAX_LENGTH;
        fixed_length = 0;
        delimiter_bytes = 0;
        body_offset = 5;
        length_field_offset = 1;
        length_field_bytes = 4;
        length_field_coding = ENCODE_BY_BIG_ENDIAN;
    }
#endif
} unpack_setting_t;

HV_EXPORT void hio_set_unpack(hio_t* io, unpack_setting_t* setting);

ftp为例(分隔符方式)可以这样设置:

unpack_setting_t ftp_unpack_setting;
memset(&ftp_unpack_setting, 0, sizeof(unpack_setting_t));
ftp_unpack_setting.package_max_length = DEFAULT_PACKAGE_MAX_LENGTH;
ftp_unpack_setting.mode = UNPACK_BY_DELIMITER;
ftp_unpack_setting.delimiter[0] = '\r';
ftp_unpack_setting.delimiter[1] = '\n';
ftp_unpack_setting.delimiter_bytes = 2;

mqtt为例(头部长度字段方式)可以这样设置:

unpack_setting_t mqtt_unpack_setting = {
    .mode = UNPACK_BY_LENGTH_FIELD,
    .package_max_length = DEFAULT_PACKAGE_MAX_LENGTH,
    .body_offset = 2,
    .length_field_offset = 1,
    .length_field_bytes = 1,
    .length_field_coding = ENCODE_BY_VARINT,
};

具体实现代码在event/unpack.c中,在内部readbuf的基础上直接原地拆包与组包,基本做到零拷贝,比抛给上层处理更高效,感兴趣的可以研究一下。

具体示例可参考examples/jsonrpcexamples/protorpc

Q:c++已经跨平台,base模块为何要封装跨平台操作

A:

1、c++标准库提取的是所有操作系统的共性,所以它甚至不能像其它语言(没有操作系统包袱,只需要满足主流操作系统)那样提供通用的时间日期操作,也没有提供差异化的锁(自旋锁、读写锁),你可以发现java中锁的类型一大堆,而c++只有一个mutex;至于没有提供标准网络库,更是c++一直被诟病之处。

2、event模块是纯c实现的,libevent、libuv也是如此,底层库使用c++性能有损、库大小、复杂度也会增加,并不会带来编码上的简化。如果只把libhv当作libevent来使用,关闭WITH_HTTP选项,是可以做到不依赖stdc++的。event模块本身也是封装了各种操作系统的IO多路复用机制(如linux的epollbsd的kqueue通用的select、poll等),提供出了统一的非阻塞IO接口。

3、http模块使用c++的考量,是为了接口使用上的便利性(HttpRequestHttpResponse中使用了map、string来表示headers、bodyjson、form、kv来存储各种Content-Type解析后的结构化数据,Get、Set模板函数屏蔽了int、float、string之间的类型转化),你如果使用过libeventevhttp就会发现,c写这些会非常痛苦。

4、没有任何贬低或者褒奖c、c++,归根结底它们只是有各自特色的编程语言,只是你实现业务的工具,避其糟粕、用其精华、为你所有,才是其价值。

如果你是写数据库的CRUD应用,提供http api服务,我也并不推荐使用libhv,使用golang、python、ruby它不香吗?c++ http库使用场景可能就是需要将c接口SDK的算法功能以http api服务的方式提供出去。

Q:libhv的代码风格?

A: libhv里的命名风格确实不统一,这点也有人向我吐槽过了,例如gettimeofday_ms、hio_read、hv_sleep、hbuf_t、HBuf、hv::Buffer,即有没加任何前缀的,也有加了单字母h前缀的、还有加了hv_前缀的,c++类名有使用H开头的,有使用namespace hv的。

因为libhv是c/c++混合编程,几年时间迭代来的,c语言风格受linux、libuv等影响,c++风格受Qt、golang等影响,造成了多种风格共存的局面,但为了保持前向兼容,已有的对外的文件名、类名、函数名确实不好再变动,后面我也会尽量统一的,基本规则是c文件里使用下划线命名法,hv_作为前缀,c++里使用驼峰命名法,使用namespace hv防止和其它库命名冲突。

Q:libhv提倡的编程范式?

A: c/c++本身是一种支持多编程范式的语言,简单的函数式编程,流行的OOP面向对象编程、还有c++的GP泛型编程,也就是模板编程。语言没有谁好谁坏,只有其适用场景,编程范式亦是如此。c with class我认为恰恰是c++最精华之处。

所以event模块中将IO、timer、idle统一抽象成事件,方便放入事件队列中统一调度,也是一种OOP的思想,而http模块中也不是全是class,也有很多函数式,强行封装成类,反而显得别扭。

而模板编程的核心是使静态类型语言具有动态类型的泛化,STL就是泛型编程的典范,其提供的容器如vector、list、deque、map、set、算法如max、min、sort、count、find、search、transform,应该是每个c++ coder应该熟练掌握的,即使如此,它的源码可读性还是很低,所以没有一定的功底和必要性,不推荐滥用模板编程。