项目实战6-消息推送

总览

基于聊天室项目做的扩展改进,引入完善logic模块、Kafka消息队列、comet服务器、job模块。

各模块功能:

logic:鉴权/token;消息持久化(MySQL)、路由/房间缓存(Redis);计算 fanout→ Kafka。

comet:维护长连接/心跳,userid->conn_list,room_id->user_set;上行经gRPC送logic,下行接收 job 推送并本机 fanout。

job:Kafka consumer group,按 comet_id 将下行消息推给对应comet(gRPC,同步+线程池)

web_demo:HTTP 登录、WS 收发示例页面。

用户1连接到comet1服务器,发送消息给用户2(用户2可能连接 到了comet2和comet3服务器,因为支持多条连接,可能开启了多个网页访问),消息通过grpc服务提交给logic业务处理,logic会把消息存储到MySQL和Redis(存储房间相关信息),再把消息发送到kafka,job模块负责从Kafka取出消息,选择一个comet进行发送(job会连接到所有的comet)。用户2在websocket登录的时候,就要在redis里面存储用户id 连接uuid cometid 对应的映射,这样在job选择发送的时候有所依据避免广播所有的comet。

提问:

在websocket登录的时候,就要在redis里面存储用户id 连接uuid cometid 对应的映射。具体的实现流程是怎么样的呢?对应的代码又怎么实现?

实现原理

在logic模块的redis_store.cpp里面有实现封装 Redis 访问方法,其中就有一个GetUserRoutes方法,用来获取用户所有 comet 路由。

  • 在 Redis 里,每个房间会维护一个集合(Set),key 形如:room:comets:<room_id>。
  • 这个集合里存的是当前有该房间在线用户的 comet 节点的 ID集合(如 "comet1""comet2" 等)
cpp 复制代码
redisReply* reply = (redisReply*)redisCommand(
    ctx, "SMEMBERS room:comets:%lld", static_cast<long long>(room_id));
  • 这行代码向 Redis 发送 SMEMBERS 命令,获取集合 room:comets:<room_id> 的所有成员。然后再把返回的结果填入传入的vector集合

具体流程

具体流程

  1. 用户上线/加入房间时

    记录该用户所在的 comet_id,并把 comet_id 加入房间的 comet 集合 (room_id -> set<comet_id>)。

  2. 推送消息时

(1)先查出房间涉及的所有 comet_id,只向这些 comet 推送消息。

(2)comet 收到后再分发给本节点上的所有房间成员。

**3.**用户下线/离开房间时

如果该 comet 上已无该房间用户,可以把 comet_id 从集合中移除。

项目框架

comet服务器

- app.cpp / app.h

Comet 的主流程实现,负责服务的整体启动、停止和资源管理。

主要功能:

初始化 WebSocket 服务和 gRPC 服务,加载配置。

主线程启动muduo 网络层(WebSocket 服务)

cpp 复制代码
CometServer server(&loop, cfg);
server.SetThreadNum(cfg.comet_io_threads);
server.Start();

创建子线程启动 gRPC CometService服务。

cpp 复制代码
std::thread grpc_thread([&grpcServer]() {
        grpcServer->Wait();  //gRPC 服务器的 Wait() 方法会阻塞,它会一直等待job发送 gRPC 请求,直到服务器关闭
    });

- comet_server.cpp / comet_server.h

实现一个CometServe类

主要功能:

(1)实现muduo需要的两个回调:OnMessage和OnConnection

(2)监听指定端口,接受客户端 WebSocket 连接,接收客户端的消息,维护连接映射(用户ID <--> 连接)。

(3)根据客户端请求,封装grpc请求logic服务的数据内容,利用gRPC stub,调用logic服务的方法,并且根据logic服务的响应状态给用户返回ACK。

- comet_grpc_service.cpp / comet_grpc_service.h

gRPC 下行服务实现,供 Job 调用。

主要功能:

实现了 gRPC 服务端的 PushToComet 方法,让 logic/job 能向 comet 推送消息给指定用户

具体推送逻辑:

Comet 侧收到 job/logic 的下行推送请求:

1.如果 targets 列表不为空,表示要精确推送给某些用户,调用 CometServer的方法PushToUsers()。

2.如果 targets 为空,则根据 session_id 判断是房间广播(如 r_123)还是全服广播(broadcast),分别调用 CometServer的方法PushToRoom() 或 PushToAll()。

3.comet1服务器不会发送给comet2服务器上面的用户,需要调用方自己判断要发送的用户在哪个comet服务器上面

- websocket_utils.cpp / websocket_utils.h

WebSocket 协议相关工具函数。

主要功能:

(1)解析 WebSocket 帧包裹的 JSON 数据,提取路由信息(如 type、to_user_id、group_id 等)。

(2)构造 WebSocket 文本帧用于下发消息。

comet服务器函数间配合关系:

app.h启动comet服务器的网络层(Websocket)监听和grpc服务层监听。用户连接上comet服务器(前端根据配置选择一个可用的 Comet 地址),触发comet_server.cpp中实现的CometServe类的OnConnection回调,与用户建立HTTP连接。等用户发送来消息,触发OnMessage回调,首次调用会进入HandleHandshake处理 HTTP 升级握手,在HandleHandshake里面会调用 logic 的 VerifyToken方法 做鉴权。成功后,comet服务器返回握手响应,随后用户发来websocket帧,触发OnMessage回调,会进入HandleWebSocketFrame解析websocket帧取出里面的json数据,并且调用OnTextMessage方法。在OnTextMessage里面,会调用websocket_utils.h解析json数据,获取路由信息(比如是单人聊天还是群聊,要发给哪个用户,或者发在哪个群,发送内容是什么等),封装成grpc请求logic服务的数据内容,调用logic的SendUpstreamMessage方法,进行业务处理。具体logic服务处理完后,要推送给用户的消息内容,会写入Kafka ,再由JobKafka 中拉取,然后选择comet服务器,调用comet_grpc_service.h实现的comet gRPC 服务端的 PushToComet 方法,精准推送给用户。

**更新补充:**Websocket握手鉴权过程:(背景:用户登录后,有了token。)

comet模块的tcpserver服务器监听到用户消息,触发OnMessage回调,第一次调用这个回调,需要先进行Websocket握手把HTTP服务升级。正常的获取字段回复字段就不赘述了,这里补充项目中的特点:1.会在鉴权把token发给logic检查的时候,顺便也把comet_id发过去了,这样要是logic那边返回鉴权ok,logic也能在redis里顺便存下user_id与comet_id的映射。一举两得。2.可能是之前遗漏的一点,就是muduo的Tcpserver服务器还有提供一个上下文字段,而且还有对应的set方法,目的就是提供给开发人员,看看你有没有什么东西要绑定到这个TcpConnection上面的。而我们这里的鉴权成功后,刚好需要把user_id绑定上去,因为后面是websocket长连接,一次鉴权成功后,后续这个连接就和这个user_id绑定了,会高效很多。

图中redis_store 是 project 中对 Redis 的封装层(封装连接池 + 常用 Redis 操作)。

**更新补充:**保活机制设计:

在websocket连接建立后,客户端和服务器互相发"心跳"以表明连接存活,每固定时间发送业务心跳(特定 JSON 消息)。comet的tcpserver服务器,就需要连接redis,然后在接收到这种心跳帧就把user_id为key的键值对的TTL进行刷新,默认就是60s,在有任何业务处理也会刷新TTL。如果超时了,其实也就类似用户退出了,就需要清理两个地方:1、redis的路由,2、cometserver服务器栈上保存的user_id到Tcpcon的映射。

logic服务器

主要工作:

  • 处理客户端业务请求,登录、鉴权、消息发送等。
  • gRPC模块通信,处理来自 Comet 的 gRPC 请求。
  • 为前端提供HTTP API 服务,登录、发消息、获取历史消息等。
  • 与 MySQL、Redis 的数据交互
  • 消息写入 Kafka
  • 弹幕功能实现

通用/主流程

  • main.cpp

主要功能:

1、加载命令行传入的配置文件,通过配置文件内容,初始化Kafka (传入 Kafka broker 地址和 topic)、mysql连接池(传入主机、端口、账号、密码、数据库名、池大小等)、redis连接池(主机、端口、密码、数据库号、池大小、超时等)。用连接池实例初始化各 DAO(里面封装了对数据库某个表的增删改查操作),SessionDao(session 表)、MessageDao(message 表)、UserSessionStateDao(user_session_state 表)、RedisStore(对 Redis 缓存进行操作)等

2、启动gRPC服务监听和HTTP服务监听

3、**初始化面向业务的数据访问对象,**不同的业务有不同的数据访问对象,数据访问对象会持有不同的DAO来完成对数据库的操作,目的是让业务层不用关心底层 SQL 或 Redis 命令,只需调用这些类的方法即可完成数据操作。比如

类名 面向业务 依赖/需要的 DAO
ConversationStore 会话、消息、未读数管理 SessionDao、MessageDao、UserSessionStateDao、RedisStore
GroupMemberDao 群成员管理 主要自己就是 DAO,可能用 UserDao、GroupDao
UserSessionStateDao 用户会话状态管理 主要自己就是 DAO
  • model.cpp / model.h
    定义统一业务数据结构,如用户(User)、会话(Session)、消息(Message)、用户会话状态(UserSessionState)等核心结构体。
cpp 复制代码
Session ≈ QQ/微信的"聊天窗口",你和某人/某群的聊天历史都属于一个 session
enum class SessionType {
    kSingle = 0,
    kGroup = 1,
    kChatroom = 2,
};

struct Session {
    std::string id;  // session_id
    SessionType type{SessionType::kSingle};
    int64_t user1_id{0};
    int64_t user2_id{0};
    int64_t group_id{0};
    int64_t last_msg_seq{0};
};

gRPC/HTTP 服务

更新对grpc的理解:在logic的main函数里面,会用主线程去启动HTTP服务器,再用一个子线程去启动gRPC服务器。

归根结底,我们知道HTTP服务器是启动muduo中的HTTPServer(继承自最基础的TcpServer),具体见http_server.h

而gRPC服务器,是启动/build/proto/generated/spark_push.grpc.pb.h里面实现的server基类服务器,具体见grpc_service.h

而这个spark_push.grpc.pb.h,明显是在build里面的,其实也就是在编译阶段,由spark_push.proto文件利用protoc工具自动生成的,就像项目实战2里面的RPC一样,但是自动生成的.grpc.pb.h里面的基类server与stub都只有简单的函数格式规定,具体的业务代码需要继承重写,所以有了grpc_service.h里面的LogicServiceImpl新服务器。

  • grpc_service.cpp / grpc_service.h
    实现 Logic 的 gRPC 服务端,处理来自 Comet 的 gRPC 请求(如消息上行、用户状态同步等)。
  1. 用户登录、鉴权、连接建立时:调用 VerifyToken
  2. 用户通过 WebSocket 发送消息时:调用 SendUpstreamMessage
  3. 用户断开连接时:调用 UserOffline
  4. 用户进入/离开聊天室时:调用 ReportRoomJoinReportRoomLeave
  5. 需要全员广播时:调用 Broadcast

logic把消息推送到Kafka的具体实现:

  1. comet调用logic服务的SendUpstreamMessage后,在SendUpstreamMessage里,会根据消息类型(单聊/聊天室),获取或创建会话,并查找需要推送的目标 comet 节点和用户。
  2. 在推送之前,根据消息类型,就会从redis中获取到comet_to_users,目标对象
  3. 把comet_id、消息内容、usr_id等打包成protobuf对象再通过protobuf序列化
  4. 通过 Kafka 官方 C/C++ 客户端库Producer类的produce方法,把消息发送到对应的topic
  • http_server.cpp / http_server.h
    实现 HTTP API 服务(如登录、发单聊消息、获取历史消息、发送弹幕消息、获取历史弹幕列表(不是为了展示,而是为了断线/刷新重连)等),前端直接向 logic 服务器发 HTTP 请求(如 POST/GET)访问这些接口,实现登录、注册、发消息、拉取历史、弹幕等功能。

注意与comet的作用区分:comet:负责长连接与实时消息推送(WebSocket),HTTP 适合短连接、请求-响应、表单登录、拉历史、发起动作(登录/注册/发消息)。前端直接调用方便、兼容性好。

插入弹幕功能的具体实现:

  1. 检验并解析请求,获取请求参数, video_id、timeline_ms、text、token 等
  2. 鉴权判断是否登录
  3. 组装弹幕消息内容为 JSON 字符串(包含类型、视频ID、时间线、文本内容)
  4. 写入MySQL(持久化)和Redis(更新 Redis 中的 last_seq,便于获取未读消息数)
  5. 构造数据对象 ChatMessage
  6. 从 Redis 查询房间在线的 comet 节点
  7. 再推送到Kafka然后由job推给comet服务器。(让视频(聊天室)所有在线用户都能看到消息)
  8. 返回响应。让前端知道弹幕已被受理,并可据此做 UI 反馈。

特点:是被动查询 ,客户端主动请求,服务端一次性返回结果。而客户端通过websocket帧发送给comet再通过grpc到logic再返回给客户端数据的流程,是长连接,是一种实时消息的**主动推送,**比如新消息、群聊、系统通知等。


数据访问层(DAO)

都是封装对数据库的操作,然后提供给业务层(http_server)的接口方法。

  • user_dao.cpp / user_dao.h

    用户信息的数据库操作。

实现功能:创建用户、按账号查询用户、按 id 查询用户

  • group_dao.cpp / group_dao.h

    群组/聊天室相关数据库操作。

实现功能:创建聊天室、按 id 查询聊天室信息、分页列出聊天室列表

  • session_dao.cpp / session_dao.h

    会话(如单聊、群聊)的数据库操作。

实现功能:获取或创建单聊会话、获取或创建聊天室会话、按 session_id 查询会话、为会话分配下一个消息序列号、列出用户参与的单聊会话

  • message_dao.cpp / message_dao.h

    消息的存储、查询等数据库操作。

实现功能:插入一条消息记录、按会话分页查询历史消息

  • user_session_state_dao.cpp / user_session_state_dao.h

    用户会话状态(如在线/离线、活跃时间等)的数据库操作。

实现功能:插入或更新用户会话已读序列、查询用户会话已读序列

  • danmaku_dao.cpp / danmaku_dao.h

    弹幕相关的数据操作(如聊天室弹幕消息)。

实现功能:插入一条弹幕记录、获取视频弹幕列表

  • redis_store.cpp / redis_store.h

    也是封装,但只是Redis 相关操作封装,做缓存、状态同步等。属于DAO层

主要功能:添加/移除/获取用户路由 comet 集合、未读相关:会话最新 seq 与用户已读 seq、聊天室房间路由:room_id -> set<comet_id>、聊天室在线人数:room_id -> online_count


存储与缓存

  • conversation_store.cpp / conversation_store.h

会话与消息的统一数据访问和业务封装类,它把"会话、消息、未读数"等相关操作整合到一起,对外提供一套简单的接口。比如:

像 AppendMessage 这样的函数就会用到多个 DAO,比如:

先用 session_dao_ 分配消息序号

再用 message_dao_ 插入消息

最后用 redis_store_ 更新缓存


logic服务器函数间配合关系:

用户先通过 logic 服务器的 HTTP API 完成登录,获取token(这个 token 会被写入 Redis,后续用于鉴权),此时logic在监听两个服务,一个对接前端的HTTP服务,一个对接comet的gRPC服务。

(1)如果用户在聊天室发送一条消息,logic就会接收到comet发送的gRPC服务请求,调用grpc_service.h 里的SendUpstreamMessage方法。在这个函数里面,会调用conversation_store.h里面的GetOrCreateRoomSession,获取聊天室的session会话,调用conversation_store.h的AppendMessage,持久化消息到MySQL(会话消息表)和redis(更新seq序号)。再通过redis_store.h的GetRoomComets方法,查出该房间涉及的所有 comet 节点(comet 收到后再分发给本节点上的所有房间成员),组装成ChatMessage(protobuf序列化)后,最后通过KafkaProducer::Send(对Kafka官方produce发送到topic的封装)发送。

(2)如果接收到的是前端调用的HTTP请求,如登录、发单聊消息、获取历史消息、发送弹幕消息、获取历史弹幕列表(不是为了展示,而是为了断线/刷新重连)等,比如发送弹幕,就会调用http_server 里的handleDanmakuSend函数,在函数里面,会通过danmaku_dao.h的InsertDanmaku方法,实现弹幕内容的持久化(写入mysql弹幕表,++用于弹幕轨道的查询和展示++ ),再通过conversation_store.h里面的GetOrCreateRoomSession,获取视频(聊天室)的session会话,然后再调用conversation_store.h里面的AppendMessage,作为一条消息写入会话消息表(如 im_message),++用于聊天室消息推送++ 。最后也是通过redis_store.h的GetRoomComets方法获取room_id->comet_id,再打包通过protobuf序列化后push到Kafka上面。

job服务器


main.cpp

程序入口,负责初始化配置、日志系统、依赖组件(如 Kafka、Redis 等),创建并启动 Job 服务主循环。


service.h

声明 Job 服务的主要类(JobRunner),定义需要实现的业务方法和接口,供其他模块调用。


service.cpp

实现 Job 服务的核心业务逻辑,包括从 Kafka 消费消息、解析消息、分发处理、推送到 comet 等。


job工作流程:

main函数读取配置文件,获取Kafka相关配置(broker地址、topic名称),comet节点的地址列表(comet_id -> ip:port 映射),Redis 相关配置,服务线程池中线程数量等。然后拿这些配置参数cfg初始化service.h 里的JobRunner类,在JobRunner::init初始化函数里面会初始化Kafka 消费者和线程池,其中Kafka消费者有两个,普通推送消费者consumer_,订阅 push topic;广播任务消费者broadcast_consumer_:订阅 broadcast_task topic。这个初始化消费者的KafkaConsumer::Init由Kafka库提供,在里面还要设置回调函数 ,会在 Kafka 消费者拉取到新消息时被自动调用。而消费者的启动,以及消费者如何从Kafka拉取到消息,都在**common目录下的Kafka_consumer中实现。**而回调函数的具体内容,是在service.cpp里面编写,具体工作就是,将Kafka中取出的消息,经过protobuf请求解析后,投递到线程池来完成调用comet的RPC服务 发送grpc请求的任务。线程入口函数:(JobRunner::ProcessPushRequest 或者 JobRunner::HandleBroadcastTask)因为有两类消费者,他们执行的业务不同。

common层:通用基础组件

为整个项目提供配置、日志、消息队列、数据库、缓存、并发等底层能力的统一封装。让业务模块可以方便、安全、高效地使用这些基础服务,提升开发效率和系统健壮性。

common层会通过自己的CMakelist.txt,CMake编译成一个spark_push_common静态库,在logic、comet这样的业务模块的CMakelist.txt里面链接上使用。

kafka_consumer.cpp / .h

cpp 复制代码
#include <librdkafka/rdkafkacpp.h>
std::unique_ptr<RdKafka::KafkaConsumer> consumer_;

主要功能:

kafka消费者封装,提供下面两个方法,三个功能:

(1)Init方法初始化消费者,conf是从job服务器的配置文件里面传下来的,从job服务器main.cpp:RunJob(cfg) ->JobRunner runner(cfg)->runner.Init()->consumer_.Init()

cpp 复制代码
consumer_.reset(RdKafka::KafkaConsumer::create(conf, errstr));

(2)订阅topic,通过Kafka提供的subscribe(topics)函数

cpp 复制代码
RdKafka::ErrorCode err = consumer_->subscribe(topics);

(3)Loop方法开启while循环,每间隔1s通过Kafka提供的consume函数,主动向 Kafka 拉取消息

cpp 复制代码
std::unique_ptr<RdKafka::Message> msg(consumer_->consume(1000));

kafka_producer.cpp / .h

Kafka 生产者封装,提供初始化与发送接口。

cpp 复制代码
#include <librdkafka/rdkafkacpp.h>
std::unique_ptr<RdKafka::Producer> producer_;

(1)init方法,初始化生产者

cpp 复制代码
producer_.reset(RdKafka::Producer::create(conf, errstr));

(2)send方法,推送到Kafka队列,然后再用poll方法,通知Kafka内部的回调,进行消息投递。

cpp 复制代码
producer_->produce(
        topic_.get(),
        RdKafka::Topic::PARTITION_UA,
        RdKafka::Producer::RK_MSG_COPY,
        const_cast<char*>(value.data()),
        value.size(),
        key.empty() ? nullptr : &key,
        nullptr);

producer_->poll(0);

logging.cpp / .h

日志系统封装,提供日志记录、格式化、输出等功能

mysql_pool.cpp / .h

把底层的 MySQL 连接管理(创建、复用、回收、健康检查等)都封装在 MySqlConnectionPool 类里。提供了线程安全的获取和归还连接接口,支持自动扩容、空闲回收、超时等待等功能。

但是注意是同步的MySQL连接池,不是异步的,没有建立一个线程池来实现异步。

CreateContextUnlocked 负责建立真实连接:

cpp 复制代码
#include <mysql/mysql.h>
MYSQL* conn = mysql_init(nullptr);

连接获取和自动扩容:

cpp 复制代码
// 阻塞获取连接;timeout_ms < 0 表示一直等待,=0 表示立即返回,大于 0 表示等待指定毫秒
    MySqlConnGuard Acquire(int timeout_ms = -1);


// 自动扩容
if (cfg_.enable_auto_grow && total_conns_ < cfg_.max_pool_size) {
    conn = CreateConnectionUnlocked();
    if (conn) {
        ++total_conns_;
        break;
    }
}

CleanupIdleUnlocked 负责空闲回收:

cpp 复制代码
mysql_close(entry.conn);
idle_.pop_front();    //idle_是存放MySQL真实连接的双端队列 deque
--total_conns_;

redis_pool.cpp / .h

Redis 连接池封装,管理 Redis 连接的创建、复用和回收。也是同步实现的,实现方式和MySQL连接池类似,也是由一个双端队列来存储连接,然后对外提供两个接口:Init初始化和Acquire获取连接

对内:

cpp 复制代码
redisContext* CreateContextUnlocked();//负责建立真实连接
redisContext* PopIdleUnlocked();//负责从空闲队列弹出/归还并做通知
void CleanupIdleUnlocked();//负责空闲回收
bool Validate(redisContext** ctx);//负责判定连接健康

thread_pool.cpp / .h

和正常线程池实现一样。线程池封装,提供任务并发执行、线程管理等功能。支持提交普通函数任务到队列queue里。

性能优化

相关推荐
程序员老舅2 小时前
【无标题】
c++·嵌入式·八股文·c++八股文·八股文面试题·c++面经·c++面试题
码界奇点2 小时前
基于DDD与CQRS的Java企业级应用框架设计与实现
java·开发语言·c++·毕业设计·源代码管理
Frank_refuel2 小时前
C++STL之set和map的接口使用介绍
数据库·c++·算法
闻缺陷则喜何志丹2 小时前
【模拟】P9670 [ICPC 2022 Jinan R] Frozen Scoreboard|普及+
c++·算法·模拟·洛谷
sunnyday04262 小时前
Nginx与Spring Cloud Gateway QPS统计全攻略
java·spring boot·后端·nginx
何以不说话2 小时前
zabbix部署及nginx的监控
运维·nginx·zabbix
王老师青少年编程2 小时前
2024年6月GESP真题及题解(C++八级): 最远点对
c++·题解·真题·gesp·csp·八级·最远点对
小龙报2 小时前
【C语言进阶数据结构与算法】LeetCode27 && LeetCode88顺序表练习:1.移除元素 2.合并两个有序数组
c语言·开发语言·数据结构·c++·算法·链表·visual studio
无限进步_2 小时前
C语言实现贪吃蛇游戏完整教程【最终版】
c语言·开发语言·c++·git·游戏·github·visual studio
Script kid2 小时前
Redis(Remote Dictionary Server远程字典服务器)五种常见数据结构及常见用法和指令
服务器·数据结构·redis