IM系统(一)-状态服务器设计

文章目录

    • 文章概要
    • 需求分析
    • 技术栈
    • 准备工作
      • 封装日志框架
      • 封装Redis
      • 封装ETCD
    • 业务逻辑开发
      • 定义模型
      • 定义服务
      • 实现服务接口
      • 服务端启动程序
      • 客户端测试程序
    • 总结

文章概要

本篇文章分享在做一个IM(即时通讯)系统时,设计一个管理用户在线状态的服务。

该系列文章将从以下几个方面进行介绍:

  1. 状态服务的设计,需求分析、采用哪些技术栈
  2. 各个需求功能的设计和实现
  3. 运行效果

需求分析

顾名思义,状态服务器是用来记录和管理用户在线状态的,该系统中简化了用户的在线状态,分为两种:Online和Offline。
作为一个独立的服务,应该为聊天服务器提供以下功能:

  1. 实现多端设备同时在线
  2. 查询某个用户的在线状态
  3. 登记某个用户的在线状态(包括下线和上线)

技术栈

Redis
这里在设计时,没有将用户的上下线记录进行持久化,只需要将用户的在线数据使用redis进行缓存即可,当然为了方便用户查看自己的登录历史记录,该数据也具有一定的重要性,后序可能会增加。
gRPC
该服务是可以独立运行的,并且可以进行集群部署,实现系统的高扩展性和高可用,所以整体必然采用微服务架构,使用RPC协议作为微服务之间的通讯协议,满足实时性。gRPC框架是RPC的一种通用实现,官网文档也很详细,所以这里采用该框架来实现RPC通信。
ETCD
采用微服务的架构,必然需要考虑服务的注册与发现的实现方案,虽然gRPC框架支持服务的注册与发现,并且可以进行负载均衡,但是使用ETCD可以为系统提供高扩展性和可用性,并且ETCD更加全面,支持服务的健康状态检查和数据一致性,所以为系统后序可以更好的扩展,这里采用ETCD来实现服务的注册与发现。
Zap
Zap是一个轻量、快速的日志框架。

准备工作

封装日志框架

本封装只是对zap进行简单的套壳,目的是为了更方便的调用。像log一样

//pkg/logger/logger.go
var (
	logger *zap.Logger
)

func init() {
	dev, err := zap.NewDevelopment()
	if err != nil {
		panic(err)
	}
	logger = dev
}

func Info(msg string, fields ...zap.Field) {
	logger.Info(msg, fields...)
}

func Error(msg string, field ...zap.Field) {
	logger.Error(msg, field...)
}

func Fatal(msg string, field ...zap.Field) {
	logger.Fatal(msg, field...)
}

func Warn(msg string, field ...zap.Field) {
	logger.Warn(msg, field...)
}

func Debug(msg string, field ...zap.Field) {
	logger.Debug(msg, field...)
}

func DPanic(msg string, field ...zap.Field) {
	logger.DPanic(msg, field...)
}

func Sync() {
	logger.Sync()
}

封装Redis

为了实现用户状态的缓存,需要独立封装redis的相关操作,使得实际的业务代码更加的专一和简洁。

连接Redis服务

//pkg/db/redis.go
var (
	StatusRDB *redis.Client
)

func init() {
	StatusRDB = InitRedis()
}

// InitRedis 初始化Redis连接
func InitRedis() *redis.Client {
	rdb := redis.NewClient(&redis.Options{
		Addr:     config.RedisAddr,
		Password: config.RedisPass,
		DB:       config.RedisDB,
	})
	_, err := rdb.Ping(context.Background()).Result()
	if err != nil {
		logger.Fatal("Redis 初始化失败", zap.String("失败原因", err.Error()))
	}
	logger.Info("Redis 初始化成功")
	return rdb
}

封装Redis操作
封装之前需要考虑一下需要哪些功能,我们的目的是记录一个用户的在线状态,该在线状态是一个对象,我们需要做的是添加整个字段和修改用户的在线状态字段。所以value可选择的方案有String和Hash。使用字符串的话需要频繁的序列化和反序列化对象,所以这里使用Hash作为value的容器。

因为要支持多端设备登录,所以是多个设备对应同一个用户,所以这里将用户的设备号作为key,保证唯一性。

  1. func RegisterUserStatus(context context.Context, deviceID string, status model.UserStatus) error;
    后面将看到model.UserStatus的定义
  2. func UpdateUserStatus(context context.Context, deviceID string, status string, newStatus bool) error
  3. func RemoveAfter(context context.Context, deviceID string, duration time.Duration) error;
  4. func CheckOnline(context context.Context, deviceID string) (bool, error)
  5. func GetUserStatus(context context.Context, deviceID string) (*model.UserStatus, error)
// RegisterUserStatus 注册用户的在线状态
func RegisterUserStatus(context context.Context, deviceID string, status model.UserStatus) error {
	_, err := db.StatusRDB.HSet(context, deviceID, map[string]interface{}{
		"UserID":     status.UserID,
		"DeviceID":   status.DeviceID,
		"Status":     status.Status,
		"ServerAddr": status.ServerAddr,
		"LoginTime":  status.LoginTime,
		"LogoutTime": status.LogoutTime,
	}).Result()
	return err
}

// UpdateUserStatus 更新用户的在线状态
func UpdateUserStatus(context context.Context, deviceID string, status string, newStatus bool) error {
	_, err := db.StatusRDB.HSet(context, deviceID, map[string]interface{}{
		status: newStatus,
	}).Result()
	return err
}

// CheckOnline 检查用户是否在线
func CheckOnline(context context.Context, deviceID string) (bool, error) {
	status, err := db.StatusRDB.HGet(context, deviceID, "status").Result()
	if err == redis.Nil {
		return false, nil
	}
	return status == "1", err
}

// RemoveAfter 设置键的过期时间
func RemoveAfter(context context.Context, deviceID string, duration time.Duration) error {
	_, err := db.StatusRDB.ExpireNX(context, deviceID, duration).Result()
	return err
}

// GetUserStatus 获取用户的在线状态记录
func GetUserStatus(context context.Context, deviceID string) (*model.UserStatus, error) {

	// 查询该键值是否存在
	count, err := db.StatusRDB.Exists(context, deviceID).Result()
	if err != nil {
		return nil, err
	}
	if count <= 0 {
		return nil, nil
	}

	result, err := db.StatusRDB.HGetAll(context, deviceID).Result()
	if err != nil {
		return nil, err
	}
	loginTime, _ := strconv.Atoi(result["LoginTime"])
	logoutTime, _ := strconv.Atoi(result["LogoutTime"])
	return &model.UserStatus{
		UserID:     result["UserID"],
		DeviceID:   result["DeviceID"],
		Status:     result["Status"] == "1",
		ServerAddr: result["ServerAddr"],
		LoginTime:  int64(loginTime),
		LogoutTime: int64(logoutTime),
	}, nil
}

封装ETCD

ETCD的作用是用于服务的注册与发现,所以我们要封装注册服务和注销服务的方法。程序设计可以参考ETCD官网

  1. func RegisterServerToEtcd(addr string) error;
  2. func UnRegisterFromEtcd(addr string) error;
const (
	serviceName = "im/state-server"
	ttl         = 10
)

var (
	etcdClient *clientv3.Client
)

func init() {
	c, err := clientv3.NewFromURL(config.EtcdAddr)
	if err != nil {
		logger.Error("连接ETCD服务器失败", zap.String("失败原因", err.Error()))
		panic(err)
	}
	etcdClient = c
}

// RegisterServerToEtcd 注册服务到ETCD
func RegisterServerToEtcd(addr string) error {
	manager, err := endpoints.NewManager(etcdClient, serviceName)
	if err != nil {
		return err
	}
	lease, _ := etcdClient.Grant(context.TODO(), ttl)
	err = manager.AddEndpoint(context.TODO(), fmt.Sprintf("%s/%s", serviceName, addr), endpoints.Endpoint{Addr: addr}, clientv3.WithLease(lease.ID))
	if err != nil {
		return err
	}
	alive, err := etcdClient.KeepAlive(context.TODO(), lease.ID)
	if err != nil {
		return err
	}
	go func() {
		for {
			<-alive
		}
	}()

	return nil
}

// UnRegisterFromEtcd 从ETCD注销服务
func UnRegisterFromEtcd(addr string) error {
	em, err := endpoints.NewManager(etcdClient, serviceName)
	if err != nil {
		return err
	}
	err = em.DeleteEndpoint(context.TODO(), fmt.Sprintf("%s/%s", serviceName, addr))
	return err
}

业务逻辑开发

定义模型

// UserStatus 用户的在线状态表
type UserStatus struct {
	UserID     string `json:"user_id"`
	DeviceID   string `json:"device_id"`
	Status     bool   `json:"status"`      // (0:表示离线 1:表示在线)
	ServerAddr string `json:"server_addr"` //用户所在的服务器地址
	LoginTime  int64  `json:"login_time"`
	LogoutTime int64  `json:"logout_time"`
}

定义服务

syntax = "proto3";

package pb;
option go_package = "./pb";

// 当服务器查询某个用户的状态时,需要提供该用户的ID和设备号
message QueryUserStatusRequest{
  string UserID = 1;
  string DeviceID = 2;
}

// 状态服务器给出响应,返回该用户的在线状态
message QueryUserStatusResponse{
  bool Status = 1;
  string ServerAddr = 2;
}

// 当用户上线,需要告诉状态服务器
message OnlineRequest{
  string UserID = 1;
  string DeviceID = 2;
  string ServerAddr = 4;
}

// 返回用户的在线状态是否设置成功
message OnlineResponse{
}

// 当用户下线,需要告诉状态服务器
message OfflineRequest{
  string UserID = 1;
  string DeviceID = 2;
}

message OfflineResponse{
}

service UserStatus{
  // 查询用户在线状态的服务
  rpc QueryUserStatus(QueryUserStatusRequest) returns(QueryUserStatusResponse);
  // 上线服务
  rpc Online(OnlineRequest) returns(OnlineResponse);
  // 下线服务
  rpc Offline(OfflineRequest) returns(OfflineResponse);
}

通过protoc命令将上述文件生成一份客户端和服务端的代码。

实现服务接口

type UserStatusService struct {
	pb.UnimplementedUserStatusServer
}

func NewUserStatusService() *UserStatusService {
	return &UserStatusService{}
}

// QueryUserStatus 查询用户在线状态的服务
func (u *UserStatusService) QueryUserStatus(c context.Context, req *pb.QueryUserStatusRequest) (*pb.QueryUserStatusResponse, error) {
	logger.Info("调用用户在线状态查询服务")
	// 查询用户的在线状态
	status, err := repository.GetUserStatus(c, req.DeviceID)
	if err != nil {
		logger.Error("查询用户在线状态失败", zap.String("失败的原因", err.Error()))
		return nil, errors.New("查询用户在线状态失败")
	}
	if status == nil || !status.Status {
		logger.Info("查询的用户不在缓存中")
		return &pb.QueryUserStatusResponse{
			Status: false,
		}, nil
	}
	return &pb.QueryUserStatusResponse{
		Status:     status.Status,
		ServerAddr: status.ServerAddr,
	}, nil
}

// Online 上线服务
func (u *UserStatusService) Online(c context.Context, request *pb.OnlineRequest) (*pb.OnlineResponse, error) {
	logger.Info("调用上线服务")

	status := model.UserStatus{
		UserID:     request.UserID,
		DeviceID:   request.DeviceID,
		Status:     true,
		ServerAddr: request.ServerAddr,
		LoginTime:  time.Now().Unix(),
		LogoutTime: 0,
	}
	err := repository.RegisterUserStatus(c, request.DeviceID, status)
	if err != nil {
		logger.Error("将用户在线信息存入Redis失败", zap.String("失败原因", err.Error()))
		return nil, errors.New("记录用户的在线状态失败")
	}
	logger.Info("成功记录用户的在线状态")
	return &pb.OnlineResponse{}, nil
}

// Offline 下线服务
func (u *UserStatusService) Offline(c context.Context, request *pb.OfflineRequest) (*pb.OfflineResponse, error) {
	logger.Info("调用下线服务")
	err := repository.UpdateUserStatus(c, request.DeviceID, "Status", false)
	if err != nil {
		logger.Error("更新用户在线状态失败", zap.String("失败原因", err.Error()))
		return nil, errors.New("更新用户在线状态失败")
	}
	err = repository.RemoveAfter(c, request.DeviceID, time.Minute*3)
	if err != nil {
		logger.Error("设置用户在线状态过期时间失败", zap.String("失败原因", err.Error()))
		return nil, errors.New("更新用户在线状态失败")
	}
	return &pb.OfflineResponse{}, nil
}

服务端启动程序

var (
	ip   string
	port string
)

func init() {
	const (
		defaultAddr = "127.0.0.1"
		defaultPort = "8080"
	)
	flag.StringVar(&ip, "addr", defaultAddr, "IP地址")
	flag.StringVar(&port, "port", defaultPort, "服务端口")
}

func main() {
	flag.Parse()

	defer logger.Sync()
	logger.Info("正在启动状态服务器", zap.String("日志框架", "zap"))

	// 注册服务
	ch := make(chan os.Signal, 1)
	signal.Notify(ch, syscall.SIGTERM, syscall.SIGINT, syscall.SIGKILL, syscall.SIGHUP, syscall.SIGQUIT, syscall.SIGABRT)
	go func() {
		s := <-ch
		err := etcd.UnRegisterFromEtcd(ip + ":" + port)
		if err != nil {
			logger.Info("注销服务失败", zap.String("失败原因", err.Error()))
		} else {
			logger.Info("注销服务成功")
		}
		if i, ok := s.(syscall.Signal); ok {
			os.Exit(int(i))
		} else {
			os.Exit(0)
		}
	}()
	err := etcd.RegisterServerToEtcd(ip + ":" + port)
	if err != nil {
		logger.Fatal("注册服务到ETCD失败", zap.String("失败原因", err.Error()), zap.String("程序状态", "即将退出"))
	}
	// 添加选项
	var options []grpc.ServerOption

	userStatusRpcServer := grpc.NewServer(options...)
	pb.RegisterUserStatusServer(userStatusRpcServer, api.NewUserStatusService())
	listen, err := net.Listen("tcp", ":8080")
	logger.Info("服务启动", zap.String("服务地址", fmt.Sprintf("%s:%s", ip, port)))
	if err != nil {
		logger.Fatal("启动监听失败", zap.String("错误信息", err.Error()), zap.String("程序状态", "退出"))
	}
	if userStatusRpcServer.Serve(listen) != nil {
		logger.Fatal("启动监听失败", zap.String("错误信息", err.Error()), zap.String("程序状态", "退出"))
	}
}

客户端测试程序

func main() {
	cli, cerr := clientv3.NewFromURL(config.EtcdAddr)
	if cerr != nil {
		panic(cerr)
	}
	etcdResolver, err := resolver.NewBuilder(cli)
	if err != nil {
		panic(err)
	}
	conn, gerr := grpc.Dial("etcd:///im/state-server", grpc.WithResolvers(etcdResolver), grpc.WithTransportCredentials(insecure.NewCredentials()))
	if gerr != nil {
		println(gerr.Error())
		panic(conn)
	}
	defer conn.Close()

	client := pb.NewUserStatusClient(conn)
	response, err := client.QueryUserStatus(context.Background(), &pb.QueryUserStatusRequest{UserID: "1", DeviceID: "2"})
	if err != nil {
		println(err.Error())
	}
	fmt.Printf("%#v", response.Status)
}

总结

本篇文章记录了一个IM系统中状态服务器的设计和实现,完整代码可在github查看:state-server

你可能感兴趣的:(项目,服务器,golang,微服务,rpc)