继上篇《GGTalk 开源即时通讯系统源码剖析之:虚拟数据库》详细介绍了 GGTalk 内置的虚拟的数据库,无需部署真实数据库便能体验GGTalk的全部功能,虚拟数据库将极大地简化服务端的部署过程,能使服务端立即运行起来。接下来我们将进入GGTalk的客户端,此篇将介绍GGTalk 客户端全局缓存及本地存储。

GGTalk V8.0 对需要频繁请求服务器的数据做了客户端全局缓存处理,大大减少了向服务器的请求次数,降低了服务器的压力,而且,这也使得客户端的运行速度更快、用户操作体验更流畅。

这篇文章将会详细的介绍GGTalk客户端的全局缓存以及客户端的本地持久化存储。还没有GGTalk源码的朋友,可以到 GGTalk源码下载中心 下载。

一. GGTalk 客户端缓存设计

1. ClientGlobalCache类

ClientGlobalCache 类是GGTalk客户端全局缓存的核心实现,其代码位置如下图所示:

然后来到这个类的定义:
GGTalk 开源即时通讯系统源码剖析之:客户端全局缓存及本地存储-小白菜博客
这个类的核心作用是在内存中保存用户和群组的数据。首先这个类接受两个泛型参数,分别为TUserTGroup,并且限定TUser为引用类型,并且需要实现TalkBase.IUser接口,还要具有一个无参数的公共构造函数;限定TGroup需要实现TalkBase.IGroup接口,且要求具有一个无参数的公共构造函数。除此之外,这个类继承自BaseGlobalCache<TUser, TGroup>类(后面将详细介绍)。

在 ClientGlobalCache 类的实现里,首先我们可以看到三个私有字段的定义,其作用如下:

  • rapidPassiveEngine;:rapid客户端引擎,用于与rapid服务端引擎之间进行通信。
  • talkBaseHelper;:工具方法调用器,由TalkBase类库约定方法的定义。
  • talkBaseInfoTypes;:客户端与服务端进行通信的消息的类型。

紧接着,我们来到ClientGlobalCache类构造函数的实现:

public ClientGlobalCache(IRapidPassiveEngine engine, ITalkBaseHelper<TGroup> helper, TalkBaseInfoTypes infoTypes, string persistenceFilePath, IAgileLogger logger) {
  this.rapidPassiveEngine = engine;
  this.talkBaseHelper = helper;
  this.talkBaseInfoTypes = infoTypes;
  this.Initialize(this.rapidPassiveEngine.CurrentUserID, persistenceFilePath, helper, logger);
}

构造函数接受五个参数,其分别是:

  • engine:rapid客户端引擎。
  • helper:工具方法调用器。
  • infoTypes:消息类型。
  • persistenceFilePath:数据缓存文件的目录。
  • logger:日志记录器。

在构造函数方法体内,分别对ClientGlobalCache类内部定义的三个私有字段进行了赋值,并且还调用Initialize方法,这个方法的作用是什么呢?想要了解这个方法我们得先去了解ClientGlobalCache类的父类BaseGlobalCache,因为 Initialize 方法定义在其父类里面。

2. BaseGlobalCache类

我们找到 BaseGlobalCache 类的源码,接着查看关于这个类的部分实现:

public abstract class BaseGlobalCache<TUser, TGroup>
  where TUser : TalkBase.IUser
  where TGroup : TalkBase.IGroup
{
  //...
  private ObjectManager<string, TUser> userManager = new ObjectManager<string, TUser>(); //缓存用户资料
  private ObjectManager<string, TGroup> groupManager = new ObjectManager<string, TGroup>();
  private UserLocalPersistence<TUser, TGroup> originUserLocalPersistence;
  //...
}

首先我们能够看到两个字段,userManagergroupManager,它们作用分别是用来缓存用户和群组的数据,它们的类型都是ObjectManager(看到这里如果你了解GGTalk服务端虚拟数据库设计和GGTalk服务端全局缓存的话,你会发现他们都用到了这个类型),这里就不再赘述了。

二. GGTalk 客户端本地持久化存储

接下来我们再来看 BaseGlobalCache 的 originUserLocalPersistence字段,这个字段的作用是将用户和群组的数据缓存到本地文件。它的类型是UserLocalPersistence<TUser, TGroup>,来到定义:

GGTalk 开源即时通讯系统源码剖析之:客户端全局缓存及本地存储-小白菜博客
在这个类的内部定义了四个属性,分别为FriendListGroupListQuickAnswerListRecentList,其代表含义如下:

  • FriendList:好友列表;
  • GroupList:群组列表;
  • QuickAnswerList:快捷回复列表;
  • RecentList: 最近联系人/群列表。

再接下来我们需要关注这个类里面的两个方法,也是这个类的核心功能,分别是LoadSave方法。Load 方法接受一个文件路径作为参数,将这个文件的内容读取出来并转化为UserLocalPersistence<TUser, TGroup>类型;Save 方法也是接受一个文件路径作为参数,将调用这个方法的对象转化为byte[],并存入指定文件路径的文件中。

以下是这两个方法的实现:

// 从文件读取数据
public static UserLocalPersistence<TUser, TGroup> Load(string filePath) {
  try {
    if (!File.Exists(filePath)) {
      return null;
    }
    byte[] data = ESBasic.Helpers.FileHelper.ReadFileReturnBytes(filePath);
    return (UserLocalPersistence<TUser, TGroup>)ESBasic.Helpers.SerializeHelper.DeserializeBytes(data, 0, data.Length);
  }
  catch {
    return null;
  }
}
// 将数据存储到文件
public void Save(string filePath) {
  byte[] data = ESBasic.Helpers.SerializeHelper.SerializeObject(this);
  ESBasic.Helpers.FileHelper.WriteBuffToFile(data, filePath);
} 

了解到这里,我想你应该明白什么数据会被缓存到本地文件。没错,就是上述的四个属性,分别是好友列表、群组列表、快捷回复列表和最近联系人/群列表。

在了解完BaseGlobalCache类的字段后,我们回到主题,来看关于Initialize 方法的实现:

public virtual void Initialize(string curUserID, string persistencePath, IUnitTypeRecognizer recognizer, IAgileLogger _logger) {
  //...
  //自己的信息始终加载最新的           
  this.currentUser = this.DoGetUser(curUserID);
  this.userManager.Add(this.currentUser.ID, this.currentUser);
  this.persistenceFilePath = persistencePath;
  this.originUserLocalPersistence = UserLocalPersistence<TUser, TGroup>.Load(this.persistenceFilePath);//返回null,表示该登录帐号还没有任何缓存
  if (this.originUserLocalPersistence == null) {}
  else {
    this.quickAnswerList = this.originUserLocalPersistence.QuickAnswerList;
    foreach (TUser user in this.originUserLocalPersistence.FriendList) {
      if (user.ID == null) {
        continue;
      }
      if (user.ID != this.currentUser.ID) {                        
        user.UserStatus = UserStatus.OffLine;
        user.CommentName = this.currentUser.GetUnitCommentName(user.ID);
        this.userManager.Add(user.ID, user);
      }
    }
    foreach (TGroup group in this.originUserLocalPersistence.GroupList) {
      if (this.currentUser.GroupList.Contains(group.ID)) {
      group.CommentName = this.currentUser.GetUnitCommentName(group.ID);
      this.groupManager.Add(group.ID, group);
      }
    }
  }
}

由于本篇文章介绍的是客户端全局缓存,故在此方法中的一些无关逻辑被有意隐藏,如果你想了解更完整的实现,建议配合GGTalk源码进行阅读。

来分析这段代码,首先通过调用DoGetUser方法,拿到当前登录的用户数据,然后通过userManager将其缓存到内存。接着接将本地缓存文件路径保存到persistenceFilePath 字段,接着通过调用UserLocalPersistence<TUser, TGroup>上的静态方法Load,读取本地缓存文件的内容。在本地缓存存在的情况下,去获取本地缓存文件中的快捷回复列表、好友列表和群组列表,将快捷回复列表保存到quickAnswerList 字段,并且将好友列表中的每一个好友的数据都通过userManager保存到内存中,将群组列表中的每一个群组的数据都通过groupManager保存到内存中。

综上所述:Initialize 方法的作用就是读取关于当前登录用户对应的本地缓存文件的数据,并将其保存到内存中。

现在有了文件 ——> 数据,那么数据 ——> 文件是在哪里实现的呢?还记得BaseGlobalCache类save 方法吗,我们顺着引用查看最终将数据存入文件的代码:

顺着引用我们找到了SaveUserLocalCache 方法,这个方法的作用就是将用户的好友列表数据、群组列表数据和快捷回复列表数据存入本地缓存文件。这个方法是在 MainForm_FormClosing 方法中调用的。

看到这里就串起来了,即在客户端窗体关闭时,就会将好友列表、群组列表和快捷回复列表数据缓存到本地文件中。

三. 更新本地缓存

想象这样一个场景,在某用户离线期间,此用户的好友或群组的信息发生了变更。比如,某好友资料发生了变化,或者有人从好友列表中删除了他,或者它所在的群组加入或移除了新成员,等等。那么在这名用户下次登录时,从本地存储拿到的缓存数据必然就是老版本的,那么GGTalk是如何解决这个问题的呢?

这里以好友列表数据为例(代码在路径GGTalk/GGTalk/TalkBase.Client/Core/BaseGlobalCache.cs):

public void StartRefreshFriendInfo()
{
  //直接使用线程,可以快速启动。
  this.updateThread = (this.userManager.Count > 1) ? new Thread(new ParameterizedThreadStart(this.RefreshContactRTData)) : new Thread(new ParameterizedThreadStart(this.LoadContactsFromServer));
  this.updateThread.Start();
}

当用户登录后窗体显示时,或断线重连成功时,此方法会被调用。这个方法的作用就是通过判断缓存中是否存在用户来决定刷新部分联系人数据还是重新从服务器加载数据。

如果缓存中只有自己一个人,表示是第一次在该电脑上登录,此时将执行LoadContactsFromServer方法以从服务器加载所有联系人等数据。

如果缓存中只有多个人,表示不是第一次在该电脑上登录,此时将执行RefreshContactRTData方法以更新本地数据到最新版本。

在这里我们主要需要关注RefreshContactRTData方法:

private void RefreshContactRTData(object state)
{
  try
  {
    this.BatchLoadStarted();
    ContactRTDatas contract = this.DoGetContactsRTDatas();  //1000用户数据量大小为22k
    foreach (string userID in this.userManager.GetKeyList())
    {
      if (userID != this.currentUser.ID && !contract.UserStatusDictionary.ContainsKey(userID)) //最新的联系人中不包含缓存用户,则将之从缓存中删除。
      {
        this.userManager.Remove(userID);
        if (this.FriendRemoved != null)
        {
          this.FriendRemoved(userID);
        }
      }
    }

    foreach (KeyValuePair<string, UserRTData> pair in contract.UserStatusDictionary)
    {
      if (pair.Key == this.currentUser.ID) {
        continue;
      }
      TUser origin = this.userManager.Get(pair.Key);
      if (origin == null) //不存在于本地缓存中
      {
        TUser user = this.DoGetUser(pair.Key);
        user.CommentName = this.currentUser.GetUnitCommentName(user.ID);
        this.userManager.Add(user.ID, user);
        if (this.UserBaseInfoChanged != null)
        {
          this.UserBaseInfoChanged(user);
        }
      }
      else
      {
      //资料变化
      if (pair.Value.Version != origin.Version) {
        TUser user = this.DoGetUser(pair.Key);                            
        user.CommentName = this.currentUser.GetUnitCommentName(user.ID);
        user.LastWordsRecord = origin.LastWordsRecord;
        user.ReplaceOldUnit(origin);
        this.userManager.Add(user.ID, user);
        if (this.UserBaseInfoChanged != null) {
          this.UserBaseInfoChanged(user);
        }
      }
      else {
        //状态变化
        if (origin.UserStatus != pair.Value.UserStatus) {
          origin.UserStatus = pair.Value.UserStatus;
          if (this.UserStatusChanged != null) {
            this.UserStatusChanged(origin);
          }
       }
     }
   }
 }

这个方法会先去获取当前登录用户在服务器上最新的联系人列表数据(仅仅包含ID、版本号、状态),然后去遍历缓存中用户的ID,检查来自服务器最新的联系人列表数据是否包含此ID对应的用户,若不包含则需要将此ID对应的用户从缓存中去除。接着再遍历来自服务器上最新联系人的用户数据,若此用户不存在于本地缓存,则下载该用户数据并将其加入缓存。接下来就是根据版本号比较来判断联系人的资料是否发生变化,若发生变化则将其同步到本地缓存。

四. 总结

GGTalk客户端缓存流程:在用户登录后,首先会从本地缓存文件中读取用户的好友列表、群组列表和快捷回复列表数据,将这些数据保存到内存中。然后,从服务器获取最新的联系人版本信息,与本地缓存比较后,下载需要更新的联系人资料。而当客户端窗口关闭,也就是退出登录时,会将该用户的好友列表、群组列表和快捷回复列表数据缓存到本地文件。

以上就是关于GGTalk客户端全局缓存设计的核心了,在接下来的一篇我们将介绍GGTalk中是如何收发消息及处理消息的。

敬请期待:《GGTalk 开源即时通讯系统源码剖析之:消息收发及处理》