news 2026/1/13 22:50:24

基于 cronet 的单链接性能信息收集

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
基于 cronet 的单链接性能信息收集

背景

公司的一款基于网络云盘的产品,需要统计每个链接到各个服务器节点的性能,以便后台做更优的调度。常用的性能指标有 DNS 解析耗时、连接耗时、ssl 握手耗时、首分片耗时、总的发送接收字节数、总的请求耗时以及基于它们计算的平均速度等。早先的基于 boost 的版本这些都很好统计,后来该产品底层网络库换成 cronet 就不好统计了,我的工作就是基于 cronet 重新收集上述性能信息。

cronet 网络编程

正式开搞前先简单看下 cronet 网络编程范式与之前有何不同:

/* by 01022.hk - online tools website : 01022.hk/zh/blood.html */ #include <string> #include <thread> #include <iostream> #include <cronet/cronet_c.h> // 回调:重定向 void on_redirect_received(Cronet_UrlRequestCallback* callback, Cronet_UrlRequest* request, Cronet_UrlResponseInfo* info, const char* new_location) { std::cout << "Redirect to: " << new_location << std::endl; Cronet_UrlRequest_FollowRedirect(request); } // 回调:响应开始 void on_response_started(Cronet_UrlRequestCallback* callback, Cronet_UrlRequest* request, Cronet_UrlResponseInfo* info) { std::cout << "Response started" << std::endl; Cronet_Buffer* buffer = Cronet_Buffer_Create(); Cronet_Buffer_InitWithAlloc(buffer, 4096); // 4KB缓冲区 Cronet_UrlRequest_Read(request, buffer); } // 回调:读取完成 void on_read_completed(Cronet_UrlRequestCallback* callback, Cronet_UrlRequest* request, Cronet_UrlResponseInfo* info, Cronet_Buffer* buffer, uint64_t bytes_read) { // 处理数据 if (bytes_read > 0) { const char* data = static_cast<const char*>(Cronet_Buffer_GetData(buffer)); std::cout << "Read " << bytes_read << " bytes" << std::endl; std::cout << data << std::endl; } // 释放当前buffer Cronet_Buffer_Destroy(buffer); // 继续读取(如果还有数据且未完成) if (bytes_read > 0) { Cronet_Buffer* new_buffer = Cronet_Buffer_Create(); Cronet_Buffer_InitWithAlloc(new_buffer, 4096); Cronet_UrlRequest_Read(request, new_buffer); } else { std::cout << "Read completed" << std::endl; } } // 回调:请求成功 void on_succeeded(Cronet_UrlRequestCallback* callback, Cronet_UrlRequest* request, Cronet_UrlResponseInfo* info) { std::cout << "Request succeeded" << std::endl; } // 回调:请求失败 void on_failed(Cronet_UrlRequestCallback* callback, Cronet_UrlRequest* request, Cronet_UrlResponseInfo* info, Cronet_Error* error) { std::cout << "Request failed" << std::endl; } // 回调:取消 void on_canceled(Cronet_UrlRequestCallback* callback, Cronet_UrlRequest* request, Cronet_UrlResponseInfo* info) { std::cout << "Request cancelled" << std::endl; } // Executor void executor_func(Cronet_Executor *executor, Cronet_Runnable *runnable) { Cronet_Runnable_Run(runnable); } int main() { // 1. 创建引擎 Cronet_EnginePtr engine = Cronet_Engine_Create(); Cronet_EngineParamsPtr params = Cronet_EngineParams_Create(); Cronet_Engine_StartWithParams(engine, params); // 3. 创建回调 Cronet_UrlRequestCallbackPtr callback = Cronet_UrlRequestCallback_CreateWith( on_redirect_received, // 重定向回调 on_response_started, on_read_completed, on_succeeded, on_failed, on_canceled ); // 4. 配置请求 Cronet_UrlRequestParamsPtr req_params = Cronet_UrlRequestParams_Create(); Cronet_UrlRequestParams_http_method_set(req_params, "GET"); // 添加请求头 Cronet_HttpHeaderPtr header = Cronet_HttpHeader_Create(); Cronet_HttpHeader_name_set(header, "User-Agent"); Cronet_HttpHeader_value_set(header, "Cronet-C-Client"); Cronet_UrlRequestParams_request_headers_add(req_params, header); // 5. 创建执行器 Cronet_ExecutorPtr executor = Cronet_Executor_CreateWith(executor_func); // 6. 创建并启动请求 Cronet_UrlRequestPtr request = Cronet_UrlRequest_Create(); Cronet_UrlRequest_InitWithParams(request, engine, "http://httpbin.org/get", req_params, callback, executor); Cronet_UrlRequest_Start(request); // 7. 等待请求完成 std::this_thread::sleep_for(std::chrono::seconds(15)); // 8. 清理资源 Cronet_UrlRequest_Destroy(request); Cronet_HttpHeader_Destroy(header); Cronet_UrlRequestParams_Destroy(req_params); Cronet_Executor_Destroy(executor); Cronet_UrlRequestCallback_Destroy(callback); Cronet_EngineParams_Destroy(params); Cronet_Engine_Destroy(engine); return 0; }

上面是 deepseek 生成的 cronet 基于 C 语言的示例,运行后有以下输出:

/* by 01022.hk - online tools website : 01022.hk/zh/blood.html */ $ ./cronet_conn_stat Response started Read 279 bytes { "args": {}, "headers": { "Accept-Encoding": "gzip, deflate", "Host": "httpbin.org", "User-Agent": "Cronet-C-Client", "X-Amzn-Trace-Id": "Root=1-69425f84-67dbe33a06e303cf4c611b72" }, "origin": "111.108.111.133", "url": "http://httpbin.org/get" } Request succeeded

与 libcurl 相比,Cronet_UrlRequest_Start 类似 curl_easy_perform 的角色,但变为异步执行,它会立即返回,之后通过回调不断通知连接上的事件,因此示例中是通过 sleep 15 秒来阻塞主线程的,工程实践中这个完全可以和消息、事件循环集成在一起,从而提高线程并发能力;与 boost 相比 (特别是基于 boost::asio::ip::tcp 版本的实现),完全不需要主动 async_resolve、async_connect、async_handeshake 以及 async_write,只需要在 on_read_completed 回调中无脑 Cronet_UrlRequest_Read 即可,底层过程 cronet 都帮你包办了,达到节省心智负担的目的。

不过这也带来一个问题,就是之前可以手动打桩计算的各种耗时,现在都看不到了,最多能获取个首分片耗时和总请求耗时,其中首分片这还包含了解析、连接、ssl 握手时长的耗时,相对失真了都。

cronet 对链接性能信息的支持

cronet 其实也有接口统计链接层的一些信息,主要通过下面的接口获取:

/////////////////////// // Struct Cronet_Metrics. CRONET_EXPORT Cronet_MetricsPtr Cronet_Metrics_Create(void); CRONET_EXPORT void Cronet_Metrics_Destroy(Cronet_MetricsPtr self); ... // Cronet_Metrics getters. CRONET_EXPORT Cronet_DateTimePtr Cronet_Metrics_request_start_get( const Cronet_MetricsPtr self); CRONET_EXPORT Cronet_DateTimePtr Cronet_Metrics_dns_start_get(const Cronet_MetricsPtr self); CRONET_EXPORT Cronet_DateTimePtr Cronet_Metrics_dns_end_get(const Cronet_MetricsPtr self); CRONET_EXPORT Cronet_DateTimePtr Cronet_Metrics_connect_start_get( const Cronet_MetricsPtr self); CRONET_EXPORT Cronet_DateTimePtr Cronet_Metrics_connect_end_get(const Cronet_MetricsPtr self); CRONET_EXPORT Cronet_DateTimePtr Cronet_Metrics_ssl_start_get(const Cronet_MetricsPtr self); CRONET_EXPORT Cronet_DateTimePtr Cronet_Metrics_ssl_end_get(const Cronet_MetricsPtr self); CRONET_EXPORT Cronet_DateTimePtr Cronet_Metrics_sending_start_get( const Cronet_MetricsPtr self); CRONET_EXPORT Cronet_DateTimePtr Cronet_Metrics_sending_end_get(const Cronet_MetricsPtr self); CRONET_EXPORT Cronet_DateTimePtr Cronet_Metrics_push_start_get(const Cronet_MetricsPtr self); CRONET_EXPORT Cronet_DateTimePtr Cronet_Metrics_push_end_get(const Cronet_MetricsPtr self); CRONET_EXPORT Cronet_DateTimePtr Cronet_Metrics_response_start_get( const Cronet_MetricsPtr self); CRONET_EXPORT Cronet_DateTimePtr Cronet_Metrics_request_end_get(const Cronet_MetricsPtr self); CRONET_EXPORT bool Cronet_Metrics_socket_reused_get(const Cronet_MetricsPtr self); CRONET_EXPORT int64_t Cronet_Metrics_sent_byte_count_get(const Cronet_MetricsPtr self); CRONET_EXPORT int64_t Cronet_Metrics_received_byte_count_get(const Cronet_MetricsPtr self);

主要是通过 Cronet_Metrics_xxx 的接口获取,所需的 dns、connect、ssl、request 耗时都有,耗时是通过接口返回的两个时间做差值得到的,举例来说:

Cronet_DateTimePtr start = Cronet_Metrics_connect_start(metrics); Cronet_DateTimePtr end = Cronet_Metrics_connect_end(metrics); if (start && end) { int64_t start_ms = Cronet_DateTime_value_get(start); int64_t end_ms = Cronet_DateTime_value_get(end); int64_t connect = (start_ms > 0 && end_ms > 0) ? (end_ms - start_ms) : 0; // printf("connect elapse %lld\n", connect); }

注意返回的 Cronet_DateTime 对象到毫秒值,还需要调用一个接口,莫法子,C 语言的接口就是这么废柴~

现在的关键落到了如何获取 Cronet_Metrics 对象,发现只有一个接口可以:

// Cronet_RequestFinishedInfo getters. CRONET_EXPORT Cronet_MetricsPtr Cronet_RequestFinishedInfo_metrics_get( const Cronet_RequestFinishedInfoPtr self);

需要输入 Cronet_RequestFinishedInfo 对象,这又是个什么东东,经过一番搜索,发现唯一途径是通过一个回调:

// The app implements abstract interface Cronet_RequestFinishedInfoListener by // defining custom functions for each method. typedef void (*Cronet_RequestFinishedInfoListener_OnRequestFinishedFunc)( Cronet_RequestFinishedInfoListenerPtr self, Cronet_RequestFinishedInfoPtr request_info, Cronet_UrlResponseInfoPtr response_info, Cronet_ErrorPtr error);

这个回调又是经由 Cronet_RequestFinishedInfoListener 对象设置的:

/////////////////////// // Abstract interface Cronet_RequestFinishedInfoListener is implemented by the // app. // There is no method to create a concrete implementation. // Destroy an instance of Cronet_RequestFinishedInfoListener. CRONET_EXPORT void Cronet_RequestFinishedInfoListener_Destroy( Cronet_RequestFinishedInfoListenerPtr self); // Set and get app-specific Cronet_ClientContext. ... // The app implements abstract interface Cronet_RequestFinishedInfoListener by // defining custom functions for each method. typedef void (*Cronet_RequestFinishedInfoListener_OnRequestFinishedFunc)( Cronet_RequestFinishedInfoListenerPtr self, Cronet_RequestFinishedInfoPtr request_info, Cronet_UrlResponseInfoPtr response_info, Cronet_ErrorPtr error); // The app creates an instance of Cronet_RequestFinishedInfoListener by // providing custom functions for each method. CRONET_EXPORT Cronet_RequestFinishedInfoListenerPtr Cronet_RequestFinishedInfoListener_CreateWith( Cronet_RequestFinishedInfoListener_OnRequestFinishedFunc OnRequestFinishedFunc);

看这个 CreateWith 接口,它的唯一参数就是上面声明的用户回调。这一系列接口其实是创建了一个侦听器,之后还需要关联到引擎才能生效:

void on_request_finished_listener( Cronet_RequestFinishedInfoListenerPtr self, Cronet_RequestFinishedInfoPtr request_info, Cronet_UrlResponseInfoPtr response_info, Cronet_ErrorPtr error) { } ... int main() { // 1. 创建引擎 Cronet_EnginePtr engine = Cronet_Engine_Create(); Cronet_EngineParamsPtr params = Cronet_EngineParams_Create(); Cronet_Engine_StartWithParams(engine, params); ... // 5. 创建执行器 Cronet_ExecutorPtr executor = Cronet_Executor_CreateWith(executor_func); ... Cronet_RequestFinishedInfoListenerPtr listener = Cronet_RequestFinishedInfoListener_CreateWith(on_request_finished_listener); if (listener) { Cronet_Engine_AddRequestFinishedListener(engine, listener, executor); std::cout << "request finished listener registered" << std::endl; } else { std::cout << "setup request finished listener failed, no connection statistic provided" << std::endl; } ... // 8. 清理资源 Cronet_UrlRequest_Destroy(request); Cronet_HttpHeader_Destroy(header); Cronet_UrlRequestParams_Destroy(req_params); if (listener) { Cronet_Engine_RemoveRequestFinishedListener(engine, listener); Cronet_RequestFinishedInfoListener_Destroy(listener); } Cronet_Executor_Destroy(executor); Cronet_UrlRequestCallback_Destroy(callback); Cronet_EngineParams_Destroy(params); Cronet_Engine_Destroy(engine); return 0; }

关联侦听器的接口如下:

CRONET_EXPORT void Cronet_Engine_AddRequestFinishedListener( Cronet_EnginePtr self, Cronet_RequestFinishedInfoListenerPtr listener, Cronet_ExecutorPtr executor); CRONET_EXPORT void Cronet_Engine_RemoveRequestFinishedListener( Cronet_EnginePtr self, Cronet_RequestFinishedInfoListenerPtr listener);

这样整个流程就串起来了:在 Cronet_Engine 初始化时创建并关联一个 Cronet_RequestFinishedInfoListener 对象,该对象持有一个 Cronet_RequestFinishedInfoListener_OnRequestFinished 类型的用户回调,当连接结束时 cronet 会将性能信息通过该回调通知到用户,用户通过回调的第二个参数request_info 获取链接性能信息,即 Cronet_RequestFinishedInfo -> Cronet_Metrics,再通过后者的一系列接口获取感兴趣的信息。

从整个流程可以看出:

* 性能信息只有在连接关闭时才能获取到

* 性能信息并不是关联到单链接 (Cronet_UrlRequest),而是关联到全局 (Cronet_Engine)

* 可以关联多个 Listener 对象,但感觉没什么必要

性能信息投递

回到业务层面,每个下载任务包含若干链接,在任务结束时 (成功、失败或取消) 对链接信息进行上报,平时这些信息是由链接对象管理的,因此需要将位于全局回调的性能信息进行投递。

用户定义的链接对象一般是关联到 Cronet_UrlRequest,即通过下面的接口:

/////////////////////// // Concrete interface Cronet_UrlRequest. CRONET_EXPORT void Cronet_UrlRequest_SetClientContext( Cronet_UrlRequestPtr self, Cronet_ClientContext client_context); CRONET_EXPORT Cronet_ClientContext Cronet_UrlRequest_GetClientContext(Cronet_UrlRequestPtr self);

顺便插一句,cronet 中各种对象都支持设置用户数据,命名也非常统一: XXX_Get/SetClientContext。

这样就可以通过 Cronet_UrlRequest 对象找到关联的链接对象,回过头来再看性能信息的回调:

// The app implements abstract interface Cronet_RequestFinishedInfoListener by // defining custom functions for each method. typedef void (*Cronet_RequestFinishedInfoListener_OnRequestFinishedFunc)( Cronet_RequestFinishedInfoListenerPtr self, Cronet_RequestFinishedInfoPtr request_info, Cronet_UrlResponseInfoPtr response_info, Cronet_ErrorPtr error);

这里提供的不是 Cronet_UrlRequest 而是 Cronet_UrlResponseInfo,两者对不上,于是问题演化为如何通过 Cronet_UrlResponseInfo 找到 Cronet_UrlRequest。

梳理 cronet 请求生命周期:

一般有这么几条路径:

* onResponseStarted -> onReadCompleted -> onSucceeded

* onCanceled / onResponseStarted -> onCanceled / onResponseStarted -> onReadCompleted -> onCanceled

* onFailed / onResponseStarted -> onReadCompleted -> onFailed

302 重定向就不单独列出了,可以在 follow 和 cancel 中选一种继续。结合相关的回调函数原型观察:

// The app implements abstract interface Cronet_UrlRequestCallback by defining // custom functions for each method. typedef void (*Cronet_UrlRequestCallback_OnRedirectReceivedFunc)( Cronet_UrlRequestCallbackPtr self, Cronet_UrlRequestPtr request, Cronet_UrlResponseInfoPtr info, Cronet_String new_location_url); typedef void (*Cronet_UrlRequestCallback_OnResponseStartedFunc)( Cronet_UrlRequestCallbackPtr self, Cronet_UrlRequestPtr request, Cronet_UrlResponseInfoPtr info); typedef void (*Cronet_UrlRequestCallback_OnReadCompletedFunc)( Cronet_UrlRequestCallbackPtr self, Cronet_UrlRequestPtr request, Cronet_UrlResponseInfoPtr info, Cronet_BufferPtr buffer, uint64_t bytes_read); typedef void (*Cronet_UrlRequestCallback_OnSucceededFunc)( Cronet_UrlRequestCallbackPtr self, Cronet_UrlRequestPtr request, Cronet_UrlResponseInfoPtr info); typedef void (*Cronet_UrlRequestCallback_OnFailedFunc)( Cronet_UrlRequestCallbackPtr self, Cronet_UrlRequestPtr request, Cronet_UrlResponseInfoPtr info, Cronet_ErrorPtr error); typedef void (*Cronet_UrlRequestCallback_OnCanceledFunc)( Cronet_UrlRequestCallbackPtr self, Cronet_UrlRequestPtr request, Cronet_UrlResponseInfoPtr info);

发现它们都提供 Cronet_UrlRequest & Cronet_UrlResponseInfo 两个对象,于是一个大胆的想法诞生了:在所有回调中建立二者的映射关系,最终在侦听器回调中再通过 Cronet_UrlResponseInfo 反查 Cronet_UrlRequest !

这个想法是可行的,特别是 Cronet_RequestFinishedInfoListener_OnRequestFinishedFunc 保证在上述所有回调之后被调用。

整合在一起

假设上述关系通过全局 rr_map 变量映射在一起,那么最终的 listener 回调可以这样实现:

extern on_request_finished(Cronet_ClientContext obj, int64_t connect); void on_request_finished_listener( Cronet_RequestFinishedInfoListenerPtr self, Cronet_RequestFinishedInfoPtr request_info, Cronet_UrlResponseInfoPtr response_info, Cronet_ErrorPtr error) { int64_t connect = 0; Cronet_MetricsPtr metrics = Cronet_RequestFinishedInfo_metrics(request_info); if (metrics) { Cronet_DateTimePtr start = Cronet_Metrics_connect_start(metrics); Cronet_DateTimePtr end = Cronet_Metrics_connect_end(metrics); if (start && end) { int64_t start_ms = Cronet::instance()->Cronet_DateTime_value_get(start); int64_t end_ms = Cronet::instance()->Cronet_DateTime_value_get(end); connect = (start_ms > 0 && end_ms > 0) ? (end_ms - start_ms) : 0; } } auto it = rr_map.find(response_info); if (it != rr_map.end()) { Cronet_UrlRequestPtr req = it->second; Cronet_ClientContext obj = Cronet_UrlRequest_GetClientContext(req); if (obj) { on_request_finished(obj, connect); } } }

其中 on_request_finished 是用户实现的回调,它的两个参数 obj 和 connect 分别是用户定义的链接层对象与连接耗时。其它的像 dns 耗时、ssl 握手耗时、首分片耗时都可以如法泡制,这里就不一一赘述了。

下面是整合后的示意图:

其中演示了 Cronet_UrlResponseInfo 与 Cronet_UrlRequestInfo 之间建立映射的过程,以及整个过程涉及的主要 Cronet 类型和回调。

后记

功能上线后,确实可用,解决了之前 cronet 收集不到链接性能数据的问题

其中 connect (xx) 标识的即为连接耗时。

本文完整的 demo 可参考 github 上的 cronet_conn_stat 项目,支持在 mac & windows 上进行验证。

下面是 demo 的一个典型输出:

$ ./cronet_conn_stat request finished listener registered Response started Read 279 bytes { "args": {}, "headers": { "Accept-Encoding": "gzip, deflate", "Host": "httpbin.org", "User-Agent": "Cronet-C-Client", "X-Amzn-Trace-Id": "Root=1-694268b2-27badad81ccc4cdb11ccc5f3" }, "origin": "111.108.111.133", "url": "http://httpbin.org/get" } request finished listen request finish, connect elapse 346 ms Request succeeded

最后一行输出了连接耗时。

早先 deepseek 给的一版示例中,未给 Cronet_Executor 提供回调函数:

Cronet_ExecutorPtr executor = Cronet_Executor_CreateWith(NULL);

编译正常,但运行到第一个回调时,就会崩溃:

$ ./cronet_conn_stat request finished listener registered Segmentation fault: 11

windows 上挂上调度器看甚至有详细的崩溃堆栈:

明显是在在第一个回调 (on_response_started) 中访问空指针崩了,增加 executor 回调设置后,就正常了。所以有时候 AI 给出的结果也不完全靠谱,还得自己去实际跑跑才行。

githubdemo 中还有一个开关 (ENABLE_EXECUTOR_THREAD),可以控制是否将各种事件的回调放在一个单独的线程中去执行:

void custom_executor_func(Cronet_Executor *executor, Cronet_Runnable *cronet_task) { ExecutorThread* et = (ExecutorThread*)Cronet_Executor_GetClientContext(executor); if (!et) { std::cerr << "Executor not initialized!" << std::endl; return; } // 将Cronet的任务包装成std::function if (cronet_task) { et->postTask([cronet_task]() { // 执行Cronet任务 Cronet_Runnable_Run(cronet_task); }); } }

为此还引入了一个线程与函数的投递封装类 (ExectorThread),有兴趣的读者可以去研究下。

参考

[1]. blob/main/components/cronet/native/test/url_request_test.cc

[2].Cronet 请求生命周期

本文来自博客园,作者:goodcitizen,转载请注明原文链接:https://www.cnblogs.com/goodcitizen/p/19253773/connection_performance_information_collection_based_on_cronet

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/1/5 15:48:19

网页时光机插件:守护你的数字记忆宝库

在瞬息万变的互联网世界中&#xff0c;重要网页的突然消失已成为数字时代的常见痛点。网页时光机插件作为专业的网页存档工具&#xff0c;能够有效保护你的数字资源&#xff0c;确保珍贵信息永不丢失。&#x1f6e1;️ 【免费下载链接】wayback-machine-webextension A web bro…

作者头像 李华
网站建设 2026/1/5 15:47:59

Tesseract OCR语言包完整实战指南:解锁多语言文本识别新境界

Tesseract OCR语言包完整实战指南&#xff1a;解锁多语言文本识别新境界 【免费下载链接】tessdata 训练模型基于‘最佳’LSTM模型的一个快速变体以及遗留模型。 项目地址: https://gitcode.com/gh_mirrors/te/tessdata 想要让Tesseract OCR真正发挥威力&#xff0c;语言…

作者头像 李华
网站建设 2026/1/5 15:47:35

Obsidian插件汉化与i18n翻译工具深度解析:从原理到实战应用

Obsidian插件汉化与i18n翻译工具深度解析&#xff1a;从原理到实战应用 【免费下载链接】obsidian-i18n 项目地址: https://gitcode.com/gh_mirrors/ob/obsidian-i18n 在Obsidian国际化进程中&#xff0c;插件本地化一直是用户面临的核心挑战。obsidian-i18n作为专业的…

作者头像 李华
网站建设 2026/1/5 15:47:22

MaterialDesignInXamlToolkit终极指南:快速打造现代化WPF应用界面

MaterialDesignInXamlToolkit终极指南&#xff1a;快速打造现代化WPF应用界面 【免费下载链接】MaterialDesignInXamlToolkit Googles Material Design in XAML & WPF, for C# & VB.Net. 项目地址: https://gitcode.com/gh_mirrors/ma/MaterialDesignInXamlToolkit …

作者头像 李华
网站建设 2026/1/13 12:55:34

微信机器人账号安全终极指南:告别封号困扰

微信机器人账号安全终极指南&#xff1a;告别封号困扰 【免费下载链接】wechat-bot &#x1f916;一个基于 WeChaty 结合 DeepSeek / ChatGPT / Kimi / 讯飞等Ai服务实现的微信机器人 &#xff0c;可以用来帮助你自动回复微信消息&#xff0c;或者管理微信群/好友&#xff0c;检…

作者头像 李华
网站建设 2026/1/5 15:46:16

PHP GoogleAuthenticator终极指南:3步实现双重身份验证

在当今数字化时代&#xff0c;账户安全已成为每个开发者和用户都必须重视的问题。PHP GoogleAuthenticator是一个强大的开源工具&#xff0c;专门用于实现Google Authenticator双重身份验证功能&#xff0c;让PHP应用的安全防护提升到专业级别。这个轻量级类库能够生成动态验证…

作者头像 李华