逻辑层实现:gRPC 服务与消息处理

网关只负责「把字节从连接上搬进搬出」,真正决定消息能不能发、发给谁、落哪张表的逻辑全在逻辑层。第 03 章的 send_msg 帧到网关后,会被转成一次 gRPC(Google Remote Procedure Call)调用;逻辑层是无状态服务:任何一台网关都能调它,它从不持有连接,因此可以按 CPU 直接扩副本,消息的可靠性与顺序性由它的幂等(idempotent)落库与投递编排保证。

本章目标

把 logic/ 从空目录跑成一个能被 grpcurl 调通的 gRPC 服务(监听 logic.grpc_addr,默认 0.0.0.0:9090),落地 SendMessage、PullOffline、AckMessage 三个方法,并说清每个方法里哪一步会失败、失败后由谁兜底。

涉及的文件

入口 cmd/logic/main.go(gRPC 端口 + 第 04 章的 HTTP 鉴权口 8081);logic/server.go(三个 RPC 实现);logic/validate.go(入参校验,集中在 RPC 边界);logic/interceptor.go(trace_id 透传、panic 兜底、慢调用日志);service/message.go 与 service/group.go(业务编排);store/(MySQL 与 Redis 访问);proto/im.proto(生成物落到 pkg/pb)。

proto:先把接口钉死

syntax = "proto3";
package im.v1;
option go_package = "github.com/example/im-go/pkg/pb;pb";

service LogicService {
  rpc SendMessage(SendMessageReq) returns (SendMessageResp);
  rpc PullOffline(PullOfflineReq) returns (PullOfflineResp);
  rpc AckMessage(AckMessageReq) returns (AckMessageResp);
}

message SendMessageReq {
  string client_msg_id = 1; // 客户端生成,重发时复用,幂等键(第 10 章)
  uint64 from_uid      = 2; // 网关只填会话里的 uid,绝不采信报文里的 from
  uint64 to_uid        = 3; // 单聊收件人
  uint64 group_id      = 4; // 群聊群 ID
  int32  conv_type     = 5; // 1=单聊 2=群聊
  bytes  payload       = 6; // 第 03 章 Envelope.Body 原样透传,逻辑层不解析
  string device_id     = 7; // 上行来源设备,多端去重用
}

SendMessageResp 只回三个字段:msg_id(服务端权威 ID)、seq(会话内递增序号)、sent_at(落库毫秒时间戳),网关把它们填进 send_msg 的响应信封回给客户端。PullOfflineReq 带 uid、device_id、cursors([{conv_id, seq}] 的设备位点)与 limit(第 09 章展开),AckMessageReq 带 msg_id、uid、conv_id(第 10 章展开)。

进程入口:注册服务与优雅停止

func main() {
	confPath := flag.String("conf", "configs/config.yaml", "配置文件路径") // 与第 01 章一致
	flag.Parse()
	cfg := mustConfig(*confPath)          // config.Load 失败即打日志退出,下同
	logger := logx.Init(cfg.Env, "logic") // 全项目统一 slog JSON 输出
	st := mustStore(cfg)                  // store.Open:MySQL + Redis 两个连接池
	defer st.Close()
	lis, err := net.Listen("tcp", cfg.Logic.GRPCAddr) // 只监听内网地址,绝不暴露公网
	if err != nil {
		logger.Error("listen failed", "grpc_addr", cfg.Logic.GRPCAddr, "err", err)
		os.Exit(1)
	}
	srv := grpc.NewServer(grpc.ChainUnaryInterceptor(logic.RecoverInterceptor, logic.TraceInterceptor))
	pb.RegisterLogicServiceServer(srv, logic.NewServer(st, cfg))

	go func() {
		logger.Info("logic serving", "grpc_addr", cfg.Logic.GRPCAddr)
		logger.Error("serve", "err", srv.Serve(lis)) // Serve 返回即监听失效,进程应退出
	}()

	// 收到信号先 GracefulStop(等在途请求做完)再退出,发布时才不会掐断网关调用
	ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
	defer stop()
	<-ctx.Done()
	srv.GracefulStop()
}

SendMessage:网关上行的唯一入口

// SendMessage 是上行消息的唯一入口:先做参数校验,再交给业务服务落库与投递,
// 最后把权威的 msg_id / seq 回给网关,由网关填进响应信封。
func (s *Server) SendMessage(ctx context.Context, req *pb.SendMessageReq) (*pb.SendMessageResp, error) {
	if err := validateSend(req); err != nil {
		return nil, status.Error(codes.InvalidArgument, err.Error())
	}

	res, err := s.msg.Send(ctx, service.SendInput{
		ClientMsgID: req.GetClientMsgId(), FromUID: req.GetFromUid(),
		ToUID: req.GetToUid(), GroupID: req.GetGroupId(),
		ConvType: int(req.GetConvType()), Payload: req.GetPayload(),
		DeviceID: req.GetDeviceId(),
	})
	if err != nil {
		// 业务错误翻译成状态码,网关按码决定「不重试」还是「可重试」
		switch {
		case errors.Is(err, service.ErrDuplicate):
			return nil, status.Error(codes.AlreadyExists, "duplicate client_msg_id")
		case errors.Is(err, service.ErrNotMember):
			return nil, status.Error(codes.PermissionDenied, "sender not in group")
		default:
			slog.Error("send message", "uid", req.GetFromUid(), "client_msg_id", req.GetClientMsgId(), "err", err)
			return nil, status.Error(codes.Internal, "send failed")
		}
	}
	return &pb.SendMessageResp{MsgId: res.MsgID, Seq: res.Seq, SentAt: res.SentAt}, nil
}

边界校验的规则很简单,但缺一条都会把脏数据写进库:from_uid/client_msg_id 必填,payload 长度必须落在 (0, 32KB](与第 03 章的 MaxFrameSize 对齐),单聊必须有 to_uid、群聊必须有 group_id。PullOffline 按设备位点增量补齐,limit 要钳到 200 以内——第 09 章的 OfflineStore.Pull 已经这么做了,RPC 边界再钳一次防越权;AckMessage 只做一件事——把消息状态从「已落库」改成「已送达」,第一次成功后才给发送方推回执。

验证方式

cd im-go && protoc --go_out=. --go-grpc_out=. proto/im.proto && go build -o bin/logic ./cmd/logic
./bin/logic -conf configs/config.yaml
# 期望日志:level=INFO msg="logic serving" service=logic grpc_addr=0.0.0.0:9090

# 单聊上行:payload 是 bytes 用 base64(aGVsbG8= 即 hello);uint64 在 protojson 里是字符串
grpcurl -plaintext -d '{"client_msg_id":"c-1","from_uid":"1001","to_uid":"1002","conv_type":1,
  "payload":"aGVsbG8=","device_id":"ios-8a2c"}' 127.0.0.1:9090 im.v1.LogicService/SendMessage
# 期望输出:{"msgId":"70001","seq":"1","sentAt":"1735689600123"}
# 重发同一个 client_msg_id 应返回相同 msgId/seq;缺 client_msg_id 则报 InvalidArgument
grpcurl -plaintext -d '{"from_uid":"1001"}' 127.0.0.1:9090 im.v1.LogicService/SendMessage
# 期望输出:ERROR: Code: InvalidArgument Message: client_msg_id required

常见坑

坑现象正确做法
逻辑层缓存了在线表扩副本后投递忽好忽坏会话与路由只放 Redis,逻辑层保持无状态
handler 里 go func() 后直接返回落库报 context canceled同步做完,或换独立的带超时上下文
错误码一律 Internal网关对不可重试的错误反复重试InvalidArgument 不重试,Internal 才重试

小结:逻辑层是「无状态 + 幂等」的 gRPC 服务——SendMessage 负责校验、落库与投递编排,PullOffline 按设备位点补齐,AckMessage 把送达状态变成回执;业务错误要在 RPC 边界翻译成网关能据以决策的状态码,拦截器负责 trace_id 透传与 panic 兜底。下一章解决它投递时最依赖的一块数据:在线路由表。

笔记加载中…