繼上篇《GGTalk 開源即時通訊系統原始碼剖析之:資料庫設計》介紹了 GGTalk 資料庫中所有表的結構後,接下來我們將進入GGTalk伺服器端的核心部分。
GGTalk 對需要頻繁查詢資料庫的資料做了伺服器端全域性快取處理,這樣做一來大大降低了資料庫的讀取壓力,二來在使用者端的請求到來時,伺服器端能更快地響應,極大地提升了使用者體驗。這篇文章將會詳細剖析關於 GGTalk 伺服器端全域性快取的設計與實現。還沒有GGTalk原始碼的朋友,可以到 GGTalk原始碼下載中心 下載。
首先,我們需要了解 GGTalk伺服器端 的三大核心,其分別是:
此部分的程式碼位於
GGTalk/TalkBase/Server/Core/ServerHandle.cs
當一個使用者端的請求進來時,首先會進入訊息處理環節,根據使用者傳遞的訊息號,進入不同的邏輯分支。以修改使用者資訊為例:
//(使用者端邏輯程式碼)
/// <summary>
/// 修改個人資料。
/// </summary>
public void ChangeMyBaseInfo(string name, string signature, string department) {
//...
this.rapidPassiveEngine.SendMessage(null, this.talkBaseInfoTypes.ChangeMyBaseInfo, data, "", true);
//...
}
當一個使用者資訊被修改時,會呼叫如上方法,然後通過呼叫 rapid使用者端引擎 上的 SendMessage 方法傳送一條訊息(其中 data
為使用者資訊的 byte[]陣列
)。
//(伺服器端邏輯程式碼)
public void Initialize() {
//...
this.rapidServerEngine.MessageReceived += new ESBasic.CbGeneric<string,ClientType, int, byte[], string>(rapidServerEngine_MessageReceived);
//...
}
使用者端傳送訊息會觸發 rapid伺服器端引擎 上的 MessageReceived 事件,最終程式流程會來到如下圖的地方。
根據使用者端傳遞在訊息號來匹配對應的 if分支,然後進行對應的處理。
此部分的程式碼位於
GGTalk/TalkBase/Server/Core/ServerGlobalCache.cs
接著前面修改使用者資訊的例子:
if (informationType == this.talkBaseInfoTypes.ChangeMyBaseInfo) {
//...
this.serverGlobalCache.UpdateUserInfo(sourceUserID, contract.Name, contract.Signature, contract.OrgID);
TUser user = this.serverGlobalCache.GetUser(sourceUserID);
//...
}
訊息處理後會來到如上 if分支,其中分別呼叫了 serverGlobalCache 上的 UpdateUserInfo
和 GetUser
方法,下面是這兩個方法的具體實現。
/// <summary>
/// 獲取目標使用者,如果快取中不存在,則從DB載入。
/// </summary>
public TUser GetUser(string userID) {
TUser user = this.userCache.Get(userID);
if (user == null) {
user = this.dbPersister.GetUser(userID);
if (user != null) {
this.userCache.Add(userID, user);
}
}
return user;
}
此方法會從全域性快取獲取使用者資料,若快取中不存在,則會從資料庫中查詢,並將查詢到的使用者資料存入快取中,方法最終返回使用者資料。
// 更新使用者資訊
public void UpdateUserInfo(string userID, string name, string signature, string orgID) {
TUser user = this.GetUser(userID);
if (user == null) {
return;
}
user.Name = name;
user.Signature = signature;
user.OrgID = orgID;
user.Version += 1;
user.DeletePartialCopy();
this.dbPersister.UpdateUserInfo(userID, name, signature, orgID, user.Version);
}
此方法先去獲取使用者的資訊,修改使用者資訊,然後通過呼叫 user 上的 DeletePartialCopy
方法清除使用者的快取,最後再更新資料庫中使用者的資訊。
此部分的程式碼位於
GGTalk/GGTalk.Server/DBPersister.cs
同樣在這個修改使用者資訊的例子中,在前面的講解中有涉及到兩處與資料庫的互動,分別是 GetUser
和 UpdateUserInfo
方法的呼叫。下面是這兩個方法的具體實現:
// 獲取使用者資訊
public GGUser GetUser(string userID) {
GGUser user = null;
user = db.Queryable<GGUser>().Where(it => it.UserID == userID).First();
return user;
}
// 更新使用者資訊
public void UpdateUserInfo(string userID, string name, string signature, string orgID, int version) {
db.Updateable<GGUser>(it => new GGUser() { Signature = signature, Name = name, OrgID = orgID, Version = version }).Where(it => it.UserID == userID).ExecuteCommand();
}
在資料庫的互動環節,我們使用的是 sqlsugar 來運算元據庫(這是一個開源的ORM框架,若想了解其詳細用法,請移步sqlsugar檔案)。
看到這裡,相信你對 GGTalk伺服器端 的三大核心有了一定的瞭解,接下來將會詳細介紹關於 GGTalk 伺服器端全域性快取的設計。
由於在 GGTalk伺服器端 中對使用者和群組資訊查詢過於頻繁,故而 GGTalk 將使用者和群組的資訊快取在伺服器端記憶體之中,進而達到減少資源消耗和更快的伺服器端響應的好處,但這樣做同時也會增加編碼的複雜度,那麼 GGTalk 是如何在其中進行取捨的呢?下面將介紹具體實現。
public class ServerGlobalCache<TUser, TGroup>
where TUser : TalkBase.IUser
where TGroup : TalkBase.IGroup
{
private IDBPersister<TUser, TGroup> dbPersister;
//...
private ObjectManager<string, TUser> userCache = new ObjectManager<string, TUser>(); // key:使用者ID 。 Value:使用者資訊
private ObjectManager<string, TGroup> groupCache = new ObjectManager<string, TGroup>(); // key:組ID 。 Value:Group資訊
//...
}
ServerGlobalCache類 就是 GGTalk 伺服器端全域性快取的核心實現了,這個類接受兩個泛型引數,TUser
和TGroup
,並要求TUser
必須是TalkBase名稱空間中的IUser
介面的實現類或子類。TGroup
必須是TalkBase名稱空間中的IGroup
介面的實現類或子類。
IUser
:使用者基礎介面,定義了關於使用者一系列的屬性和方法。/// <summary>
/// 使用者基礎介面。
/// </summary>
public interface IUser : IUnit {
List<string> GroupList { get; }
UserStatus UserStatus { get; set; }
string GetFriendCatalog(string friendID);
string GetUnitCommentName(string unitID);
string Signature { get; set; }
string OrgID { get; set; }
/// <summary>
/// 使用者使用狀態
/// </summary>
UserState UserState { get; set; }
bool IsFriend(string userID);
List<string> GetAllFriendList();
void ChangeHeadImage(int defaultHeadImageIndex, byte[] customizedHeadImage);
DateTime PcOfflineTime { get; set; }
DateTime MobileOfflineTime { get; set; }
}
IGroup
:群/討論組的基礎介面,定義了一系列關於群/討論組的屬性和方法。/// <summary>
/// 群、討論組 基礎介面。
/// </summary>
public interface IGroup : IUnit {
GroupType GroupType { get; }
string CreatorID { get; }
DateTime CreateTime { get; }
List<string> MemberList { get; }
void AddMember(string userID);
void RemoveMember(string userID);
string Announce { get; set; }
void ChangeMembers(List<string> members);
}
除了這兩個泛型引數外,我們可以發現 ServerGlobalCache類 中還有三個欄位,這三個欄位是 ServerGlobalCache類 中所有方法的核心,其作用如下:
關於這三個欄位,在後面的具體場景會展開更加詳細的介紹。
關於伺服器端快取,最關鍵的就是 userCache 和 groupCache 欄位了,其中 userCache 用於快取使用者的資訊;而 groupCache 用於快取群組的資訊。
首先我們來看關於這兩個欄位的型別ObjectManager
:
public class ObjectManager<TPKey, TObject>
ObjectManager是對Dictionary的二次封裝,支援多執行緒安全,使用起來也更方便。這個類接受兩個泛型引數,我們通過傳入不同的泛型可以實現不同資料的管理(在 GGTalk伺服器端 中,僅管理了使用者和群組的資料)。
其內部的Dictionary就是用來將使用者或群組的資料儲存在記憶體中,達到快取資料的目的。
我們來看 ServerGlobalCache類 中如下兩個方法:
從名字上來看我們很容易就知道這兩個方法的意思,預載入使用者資料和預載入群組資料,這兩個方法的主要作用就是將資料庫中使用者和群組的資料載入到記憶體中。首先通過 dbPersister 欄位來從資料庫中查詢到所有使用者和群組資料,通過foreach遍歷,分別呼叫userCache
和groupCache
上的Add
方法將每一條資料儲存到前面提到的objectDictionary
欄位中,也是就儲存在了伺服器端程式執行時的記憶體裡面。
看到這裡,關於 ServerGlobalCache類 的基礎設施你已經瞭解的七七八八了,接下來都是基於這些基礎設施而實現的方法了。在這裡我要糾正一個你可能感到疑惑的點,本篇文章不是介紹伺服器端快取嗎,這裡怎麼扯到資料庫的增刪改查呢?
因為往往資料快取和資料來源之間存在著一些聯動,所以 ServerGlobalCache類 的作用不僅僅是快取資料,同時也存在大量獲取資料庫中的資料的方法,這也是為什麼在類裡面會有一個dbPersister
欄位,當然關於具體從資料庫中讀取資料的方法不在這個類裡邊(回顧 GGTalk三大核心)。
接下來,讓我們看看 ServerGlobalCache類 還有什麼:
我們可以看到,這些摺疊的部分的程式碼行數幾乎佔據了 ServerGlobalCache類 的百分之九十,這是正是對資料庫和資料快取的操作,每個摺疊程式碼塊的註釋都對應著 GGTalk資料庫 的一張表。
接下來我們主要分析一下關於使用者和群組的部分操作,看看 GGTalk伺服器端 是如何對資料庫和資料快取進行操作的。
首先來看一個簡單的,新增新使用者操作:
/// <summary>
/// 插入一個新使用者。
/// </summary>
public void InsertUser(TUser user) {
this.userCache.Add(user.ID, user);
this.dbPersister.InsertUser(user);
}
這個方法接受一個TUser
型別的引數,引數中包含使用者的必要資訊,然後分別新增到使用者快取和插入到資料庫中。
接下來,再看最開始講三大核心的那個例子:
/// <summary>
/// 獲取目標使用者,如果快取中不存在,則從DB載入。
/// </summary>
public TUser GetUser(string userID) {
TUser user = this.userCache.Get(userID);
if (user == null) {
user = this.dbPersister.GetUser(userID);
if (user != null) {
this.userCache.Add(userID, user);
}
}
return user;
}
現在再來看是不是很清晰了呢,這個方法用於查詢單個使用者,接受一個使用者ID,首先會從使用者快取中查詢這個使用者,如果快取中不存在,則從資料庫中查詢,在使用者存在的情況下再將其存入記憶體之中。
接下來再分析兩個關於群組操作的方法。
1、根據群組ID獲取群組資訊:
/// <summary>
/// 獲取某個組
/// </summary>
public TGroup GetGroup(string groupID) {
TGroup group = this.groupCache.Get(groupID);
if (group == null) {
group = this.dbPersister.GetGroup(groupID);
if (group != null) {
this.groupCache.Add(groupID, group);
}
}
return group;
}
和獲取使用者資訊類似,此方法首先會在群組快取中查詢對應ID的群組,若群組不存在,則會從資料庫讀取對應ID的群組,並且在群組存在的情況下將其存入記憶體之中。
2、解散群組操作
public void DeleteGroup(string groupID) {
TGroup group = this.GetGroup(groupID);
if (group == null) {
return;
}
foreach (string userID in group.MemberList) {
TUser user = this.GetUser(userID);
if (user != null) {
user.QuitGroup(groupID);
this.dbPersister.UpdateUserGroups(user);
}
}
this.groupCache.Remove(groupID);
this.dbPersister.DeleteGroup(groupID);
this.dbPersister.DeleteAddGroupRequest(groupID);
}
這個方法接受群組ID作為引數,首先會呼叫GetCroup
方法依次從記憶體和資料庫中讀取關於目標群組的資料(如果快取中沒有的話)。若群組存在,則從群組的MemberList
屬性中遍歷使用者ID,再通過GetUser
方法查詢使用者資料,通過使用者的QuitGroup
退出群組,然後在資料庫中更新使用者的資訊。在這個群組中的每一個存在的使用者都退出群組後,從群組快取中清除該群組的資料。然後再同步資料庫中的群組表的資料,以及在資料庫中申請加入群組表中刪除加入此群組的記錄。
將資料庫中的資料快取在記憶體中是一把雙刃劍,若是將大量的資料儲存在記憶體中,這會大大加大記憶體的佔用,存在程式因為記憶體不足而導致程式崩潰的風險。如何避免這樣的事情發生,這要求我們對記憶體保持足夠的敏感。最後,希望這篇文章能夠對你有所幫助。
在接下來的一篇我們將介紹GGTalk伺服器端的虛擬資料庫。
敬請期待:《GGTalk 開源即時通訊系統原始碼剖析之:虛擬資料庫》