unity基于网络从登录到背包与商店系统的简单实例
环境准备
客户端:unity2021
服务端:vs2019
工具:protobuf、cJson、libuv
流程图如下
角色登录与创建
协议
//角色登录请求
message PlayerLoginReq {
string PlayerID = 1;
string Password = 2;
}
//角色登录响应
message PlayerLoginRsp {
int32 Result = 1;
string Reason = 2;
PlayerSyncData PlayerData = 3;
}
//角色数据
message PlayerSaveData {
string PlayerID = 1;
string Password = 2;
bytes Name = 3;
int32 Money=4;
}
//角色创建请求
message PlayerCreateReq {
string PlayerID = 1;
string Password = 2;
bytes Name = 3;
}
//角色创建响应
message PlayerCreateRsp {
int32 Result = 1;
string PlayerID = 2;
bytes Name = 3;
string Reason = 4;
}
//角色信息请求
message PlayerInfoReq{
string PlayerID=1;
}
//角色信息响应
message PlayerInfoRsp{
PlayerSaveData PlayerData=1;
PlayerBagData PlayerBagData=2;
int32 Result = 3;
string Reason = 4;
}
客户端
通过UI点击事件作为驱动逻辑发送网络请求,接受请求的时候也作出相应回调
private void OnLoginClick()
{
//预先判断账号密码是否为空
if (string.IsNullOrEmpty(playerId.text))
{
Debug.Log("用户名为空");
return;
}
if (string.IsNullOrEmpty(password.text))
{
Debug.Log("密码为空");
return;
}
UserService.Instance().SendPlayerLogin(this.playerId.text, this.password.text);
}
private void OnLogin(int result,string reason)
{
//能够成功登录
if (result == 0)
{
SceneManager.LoadScene(1);
UserService.Instance().SendPlayerInfoReq(Player.Instance().PlayerID);
}
//登录失败
else
{
Debug.LogFormat("Login Fail [Result:{0} Reason:{1}]", result, reason);
}
}
UserService
当客户端接收到Response,Service接收到该事件,并向UI层和其他数据管理模块进行回调
和NetWork的事件注册逻辑
public UserService()
{
EventModule.Instance().AddNetEvent((int)SERVER_CMD.ServerLoginRsp, OnPlayerLogin);
EventModule.Instance().AddNetEvent((int)SERVER_CMD.ServerCreateRsp, OnPlayerCreate);
EventModule.Instance().AddNetEvent((int)SERVER_CMD.ServerShopbuyRsp, OnShopBuy);
EventModule.Instance().AddNetEvent((int)SERVER_CMD.ServerPlayerinfoRsp, OnPlayerInfo);
}
public void OnDispose()
{
EventModule.Instance().RemoveNetEvent((int)SERVER_CMD.ServerLoginRsp, OnPlayerLogin);
EventModule.Instance().RemoveNetEvent((int)SERVER_CMD.ServerCreateRsp, OnPlayerCreate);
EventModule.Instance().RemoveNetEvent((int)SERVER_CMD.ServerShopbuyRsp, OnShopBuy);
EventModule.Instance().RemoveNetEvent((int)SERVER_CMD.ServerPlayerinfoRsp, OnPlayerInfo);
}
让其他模块通过这里进行注册回调
public UnityAction<int, string> OnLogin; //角色进入对UI回调
public UnityAction<int, string> OnCreate; //角色创建对UI回调
public UnityAction OnBuySuccess; //购买物品时回调
public UnityAction<int,string> OnBuyFail; //购买失败的回调
public UnityAction OnPlayerInfoLoad; //角色信息加载完成的回调
以角色登录举例 一个发送一个接受逻辑。角色登录成功会再向服务器发送一个角色信息的请求
//发送登录请求
public void SendPlayerLogin(string playerId, string password)
{
Debug.LogFormat("Send Player Login Request:[id:{0} password:{1}]", playerId, password);
PlayerLoginReq req = new PlayerLoginReq();
req.PlayerID = playerId;
req.Password = password;
Player.Instance().PlayerID = playerId;
Network.Instance().SendMsg((int)CLIENT_CMD.ClientLoginReq, req);
}
//接受登录响应
public void OnPlayerLogin(int cmd, IMessage msg)
{
PlayerLoginRsp rsp = msg as PlayerLoginRsp;
Debug.LogFormat("OnPlayerLogin:[Result:{0} Reason:{1}]", rsp.Result, rsp.Reason);
if (this.OnLogin != null)
{
OnLogin.Invoke(rsp.Result, rsp.Reason);
}
}
角色信息请求和响应
//发送请求角色信息
public void SendPlayerInfoReq(string playerId)
{
Debug.LogFormat("Send PlayerInfo Request:[id:{0}]", playerId);
PlayerInfoReq req = new PlayerInfoReq();
req.PlayerID = playerId;
Network.Instance().SendMsg((int)CLIENT_CMD.ClientPlayerinfoReq, req);
}
//接受角色信息响应
public void OnPlayerInfo(int cmd, IMessage msg)
{
PlayerInfoRsp rsp = msg as PlayerInfoRsp;
Debug.LogFormat("PlayInfoRsp:[Result:{0} Reason:{1}]", rsp.Result, rsp.Reason);
if (rsp.Result == 0)
{
Debug.Log("加载角色数据完成...");
Player.Instance().Name = rsp.PlayerData.Name.ToStringUtf8();
Player.Instance().Password = rsp.PlayerData.Password;
Player.Instance().Money = rsp.PlayerData.Money;
OnPlayerInfoLoad.Invoke();
if (rsp.PlayerBagData != null)
{
foreach (BagItem item in rsp.PlayerBagData.BagItem)
{
Player.Instance().AddBagItem(item.ItemId,item.Count);
}
Debug.Log("加载背包数据完成...");
}
}
else
{
Debug.LogFormat("加载角色数据失败...[Result:{0} Reason:{1}]",rsp.Result, rsp.Reason);
}
}
玩家实体唯一,用单例的方式表示
public class Player : Singleton<Player>
{
private string playerID; //角色id
private string password; //密码
private string name; //角色姓名
private int money; //角色金钱
public Dictionary<int, int> bag = new Dictionary<int, int>(); //背包 k:道具id v:数量
public UnityAction OnMoneyChange; //让ui注册对金钱变化的回调
public UnityAction OnBagChange; //让ui注册对背包道具变化的回调
public string PlayerID { get => playerID; set => playerID = value; }
public string Password { get => password; set => password = value; }
public string Name { get => name; set => name = value; }
public int Money
{
get => money;
set
{
money = value;
if (OnMoneyChange != null)
{
OnMoneyChange.Invoke();
}
}
}
public void AddBagItem(int itemId,int count)
{
if (!bag.ContainsKey(itemId))
{
bag.Add(itemId, count);
}
else
{
bag[itemId] += count;
}
if (OnBagChange != null)
{
OnBagChange.Invoke();
}
}
服务端
服务端的网络层客户端发来的网络包后通过解析Proto协议,根据Proto头进行不同处理
// 处理一个完成的消息包
bool _OnPackHandle(uv_tcp_t* client, Packet* pack) {
bool result = false;
int len = 0;
// todo 处理收到的数据包
fprintf(stdout, "OnPackHandle: cmd:%d, len:%d, client:%llu\n", pack->cmd, pack->len, (uint64_t)client);
switch (pack->cmd) {
case CLIENT_PING: // 处理客户端的ping
{
fprintf(stdout, "client ping, client:%llu\n", (uint64_t)client);
len = encode(s_send_buff, SERVER_PONG, nullptr, 0);
sendData((uv_stream_t*)client, s_send_buff, len);
break;
}
case CLIENT_ADD_REQ: // 处理客户端发起的一个加法计算请求
{
AddReq req;
req.ParseFromArray(pack->data, pack->len);
if (!_OnAdd(client, &req)) {
goto Exit0;
}
break;
}
case CLIENT_LOGIN_REQ:
{
PlayerLoginReq req;
req.ParseFromArray(pack->data, pack->len);
g_playerMgr.player_login(client, &req);
break;
}
case CLIENT_CREATE_REQ:
{
PlayerCreateReq req;
req.ParseFromArray(pack->data, pack->len);
g_playerMgr.player_create(client, &req);
break;
}
case CLIENT_ANNOUNCE_REQ:
{
g_playerMgr.announce_request(client);
break;
}
case CLIENT_PLAYERINFO_REQ:
{
PlayerInfoReq req;
req.ParseFromArray(pack->data, pack->len);
g_playerMgr.playerInfo_request(client, &req);
break;
}
case CLIENT_SHOPBUY_REQ:
{
ShopBuyReq req;
req.ParseFromArray(pack->data, pack->len);
g_itemMgr.item_buy(client, &req);
break;
}
default:
fprintf(stderr, "invalid cmd:%d\n", pack->cmd);
return false;
}
result = true;
Exit0:
return result;
}
角色信息请求的处理逻辑
//客户端拉取角色信息
bool PlayerMgr::playerInfo_request(uv_tcp_t* client,const PlayerInfoReq* req)
{
bool result = false;
Player* player = nullptr;
PlayerSaveData* playerData = new PlayerSaveData();
PlayerBagData* playerBag = new PlayerBagData();
PlayerInfoRsp rsp;
fprintf(stdout, "playerInfo request\n");
// 1. 查看玩家是否已经在游戏中,如果不在游戏中则返回失败
player = find_player(req->playerid());
if (player == nullptr) {
rsp.set_result(-1);
rsp.set_reason("player has not login");
player = nullptr;
goto Exit1;
}
// 2. 尝试从文件中加载玩家数据,如果未加载成功则返回失败
if (!_load_player(req->playerid(), playerData)) {
rsp.set_result(-2);
rsp.set_reason("player id exist");
goto Exit1;
}
// 3、尝试从文件中加载背包数据
if (g_itemMgr._load_player_Items(req->playerid(), playerBag))
{
rsp.set_allocated_playerbagdata(playerBag);
}
// 4. 应答客户端登录结果
rsp.set_allocated_playerdata(playerData);
rsp.set_result(0);
Exit1:
{
string r = rsp.SerializeAsString();
int len = encode(s_send_buff, SERVER_PLAYERINFO_RSP, r.c_str(), (int)r.length());
sendData((uv_stream_t*)client, s_send_buff, len);
}
result = true;
Exit0:
return result;
此外创建的时候要将角色存储下来
登录的时候要将角色读取出来存到角色容器
bool PlayerMgr::player_login(uv_tcp_t* client, const PlayerLoginReq* req) {
bool result = false;
Player* player = nullptr;
PlayerLoginRsp rsp;
PlayerSaveData playerData;
fprintf(stdout, "player login request\n");
// 1. 查看玩家是否已经在游戏中,如果在游戏中登录失败
player = find_player(req->playerid());
if (player != nullptr) {
rsp.set_result(-1);
rsp.set_reason("player has login");
goto Exit1;
}
// 2. 从文件中读取玩家数据并展开
if (!_load_player(req->playerid(), &playerData)) {
rsp.set_result(-2);
rsp.set_reason("player not exist");
goto Exit1;
}
// 3. 检验玩家登录的用户名密码与文件中的是否匹配
if (playerData.password() != req->password()) {
rsp.set_result(-3);
rsp.set_reason("invalid password");
goto Exit1;
}
// 4. 创建玩家对象,并构造
player = new Player;
if (player == nullptr) {
goto Exit0;
}
player->Name = playerData.name();
player->PlayerID = playerData.playerid();
player->Password = playerData.password();
player->Money = playerData.money();
// 5. 把玩家对象加入m_playerMap中进行管理
m_playerMap.insert(pair<uv_tcp_t*, Player*>(client, player));
// 6. 应答客户端登录结果
{
rsp.set_result(0);
PlayerSyncData* sync = rsp.mutable_playerdata();
sync->set_name(player->Name);
}
Exit1:
{
string r = rsp.SerializeAsString();
int len = encode(s_send_buff, SERVER_LOGIN_RSP, r.c_str(), (int)r.length());
sendData((uv_stream_t*)client, s_send_buff, len);
}
result = true;
Exit0:
return result;
}
为了让数据持久化,用文件方式存取数据
// 把玩家数据保存到文件中,成功返回true,失败返回false
bool PlayerMgr::_save_player(const Player* player) {
// todo 把玩家数据序列化后存盘,参考save函数
int retCode = 0;
PlayerSaveData playerData;
playerData.set_password(player->Password);
playerData.set_name(player->Name);
playerData.set_playerid(player->PlayerID);
playerData.set_money(player->Money);
retCode = save(player->PlayerID.c_str(), playerData.SerializeAsString().c_str(), playerData.ByteSize());
if (retCode != 0)
return false;
else
return true;
}
// 从文件中加载玩家数据,用PlayerSaveData的方式读出 成功返回true,失败返回false
bool PlayerMgr::_load_player(string playerID, PlayerSaveData* playerData) {
// todo 从文件中读取数据,并反序列化到playerData中,参考load函数
int len = load(playerID.c_str(), s_send_buff, sizeof(s_send_buff));
if (len >= 0) {
playerData->ParseFromArray(s_send_buff, len);
return true;
}
return false;
}
Save文件
//保存 文件名、数据、长度
int save(const char* name, const char* data, int len) {
int result = -1;
FILE * fp = nullptr;
char path[512] = {0};
sprintf(path, "%s/%s", SAVE_PATH, name);
fp = fopen(path, "wb"); //二进制写入 没有文件会新建
if (fp == nullptr) {
result = -1;
goto Exit0;
}
result = (int)fwrite(data, 1, len, fp);
if (result != len) {
result = -2;
goto Exit0;
}
result = 0;
Exit0:
if (fp != nullptr) {
fclose(fp);
fp = nullptr;
}
return result;
}
Load文件
//读取 文件名 数据 长度
int load(const char* name, char* data, int size) {
int result = -1;
FILE * fp = nullptr;
char path[512] = {0};
int fileSize = 0;
sprintf(path, "%s/%s", SAVE_PATH, name);
fp = fopen(path, "rb");
if (fp == nullptr) {
result = -1;
goto Exit0;
}
fseek(fp, 0, SEEK_END); //指针移动到末尾 偏移量 文件末尾
fileSize = ftell(fp); //求出文件字节数
if (size < fileSize) {
result = -2;
goto Exit0;
}
rewind(fp); //指针移动会开头
result = (int)fread(data, 1, fileSize, fp); //每次读一个字节读fileSize次
if (result != fileSize) {
result = -3;
goto Exit0;
}
Exit0:
if (fp != nullptr) {
fclose(fp);
fp = nullptr;
}
return result;
}
Player实体数据
struct Player {
string PlayerID;
string Password;
string Name;
int Money;
};
背包与商城
流程简单表示如下:
- 客户端由ui控件监听购买点击事件,向Uservice层传递购买的物品id和数量
- Uservice使用BuyRequest协议结构进行数据封装,调用网络层发送包体给服务端
- 服务端进行粘包处理,当解析到buyRequest请求时,将请求的包体进行相应处理
- 首先根据请求中的道具id信息读取商店配置表中的道具价格信息,再根据角色id读取用文件存储角色的金钱信息
- 当金钱足够时,读取到用文件存储的角色背包信息,添加相应道具和数量重新存一遍
- 当处理完请求后,发送buyResponse给客户端
- 客户端接收到响应后,修改角色数据和ui的信息
配置表
客户端和服务端都持有一个配置表数据,记录关于存放在商城和背包的道具的静态信息。双方只需要传递道具的id就可以在各自的配置表中找出道具的具体信息
json配置表
客户端
客户端背包商城逻辑
客户端和服务端执行相应的读取操作,存储到相应的实体中去
客户端利用字典的结构进行存储
[Serializable]
public class ItemInfos
{
public List<Item> items;
}
[Serializable]
public class Item
{
public int id;
public string name;
public string introduce;
public int price;
public string iconName;
}
public class ItemManager : Singleton<ItemManager>
{
Dictionary<int, Item> ItemDic = new Dictionary<int, Item>();
Dictionary<string, Sprite> spriteDic = new Dictionary<string, Sprite>(); //icon图集
public void Init()
{
InitItems();
}
void InitItems()
{
TextAsset itemConfig = ResourceManager.Instance().Load<TextAsset>("ItemConfig");
if (itemConfig != null)
{
string str = itemConfig.text.Replace("\n", "").Replace("\r", "").Replace("\t", "");
ItemInfos itemInfos = JsonUtility.FromJson<ItemInfos>(str);
//添加到管理器字典
foreach(Item item in itemInfos.items)
{
if (!ItemDic.ContainsKey(item.id))
{
ItemDic.Add(item.id, item);
}
}
}
//获取Icon图标
Sprite[] sprites = Resources.LoadAll<Sprite>("UI/Icon/itemIcon");
//添加到管理器图片字典
foreach (Sprite sp in sprites)
{
if(!spriteDic.ContainsKey(sp.name))
spriteDic.Add(sp.name, sp);
}
}
//获取图片
public bool TryGetSprite(string name,out Sprite sprite)
{
bool value=false;
if (spriteDic.TryGetValue(name,out sprite))
{
value = true;
}
return value;
}
public bool TryGetSprite(int itemId,out Sprite sprite)
{
bool value = false;
string name = ItemDic[itemId].iconName;
if (spriteDic.TryGetValue(name, out sprite))
{
value = true;
}
return value;
}
public List<Item> GetItems()
{
List<Item> items = new List<Item>();
foreach (Item item in ItemDic.Values)
{
items.Add(item);
}
return items;
}
public Item GetItem(int id)
{
return ItemDic[id];
}
}
发送购买请求和接收购买请求
//发送角色购买请求
public void SendShopBuy(string playerId,int itemId,int count)
{
Debug.LogFormat("Send Shop Buy Request:[id:{0} itemId:{1} count:{2}]", playerId, itemId, count);
ShopBuyReq req = new ShopBuyReq();
req.PlayerID = playerId;
req.ItemId = itemId;
req.Count = count;
Network.Instance().SendMsg((int)CLIENT_CMD.ClientShopbuyReq, req);
}
//接受购买请求响应
public void OnShopBuy(int cmd, IMessage msg)
{
ShopBuyRsp rsp = msg as ShopBuyRsp;
Debug.LogFormat("ShopBuyRsp:[Result:{0} Reason:{1}]", rsp.Result, rsp.Reason);
if (rsp.Result == 0)
{
OnBuySuccess.Invoke();
}
else
{
OnBuyFail.Invoke(rsp.Result, rsp.Reason);
Debug.LogFormat("Buy Fail [Result:{0} Reason:{1}]", rsp.Result, rsp.Reason);
}
}
协议结构如下:
message ShopBuyReq{
string PlayerID=1;
int32 ItemId=2;
int32 Count=3;
}
message ShopBuyRsp{
int32 Result = 1;
string Reason = 2;
}
购买成功后一个是会修改角色的金钱数据和ui上的金币数量,一个是修改背包的道具信息
ShopUI记录了购买时点击的商品id和数量,当购买成功后修改角色背包信息,同时对UIBag的信息进行了更新
添加Player中的道具
public void AddBagItem(int itemId,int count)
{
if (!bag.ContainsKey(itemId))
{
bag.Add(itemId, count);
}
else
{
bag[itemId] += count;
}
if (OnBagChange != null)
{
OnBagChange.Invoke();
}
}
修改背包UI 重新刷新一遍
void UpdateItem()
{
ClearItems();
this.money.text = Player.Instance().Money.ToString(); //修改金币数量
Dictionary<int, int> bag = Player.Instance().bag;
foreach (int id in bag.Keys)
{
Item item = ItemManager.Instance().GetItem(id);
GameObject go = Instantiate<GameObject>(ItemPrefab, itemGrid.transform);
Slot ui;
if (go.TryGetComponent<Slot>(out ui))
{
if (ui is UIBagItem)
{
UIBagItem bagItem = ui as UIBagItem;
Sprite sprite;
ItemManager.Instance().TryGetSprite(item.iconName, out sprite);
//将item信息赋值给ui
bagItem.SetInfo(item, sprite);
bagItem.count.text = string.Format("*{0}", bag[id]);
bagItem.onClick.AddListener(OnSlotClick);
}
}
}
}
服务端
服务端也需要从配置表读取道具信息
本地的实体结构如下
struct PlayerItem
{
string PlayerID;
map<int, int> Items;
};
struct Item
{
int id;
string name;
string introduce;
int price;
string iconName;
};
服务端维护了一个玩家背包的存储数据
相应的存取操作
// 从文件中加载玩家数据,成功返回true,失败返回false
bool ItemMgr::_load_player_Items(string playerID,PlayerBagData *playerBagData) {
// todo 从文件中读取数据,并反序列化到playerData中,参考load函数
string strBag = playerID + "_Bag";
int len = load(strBag.c_str(), s_send_buff, sizeof(s_send_buff));
if (len >= 0) {
playerBagData->ParseFromArray(s_send_buff, len);
return true;
}
return false;
}
// 把玩家数据保存到文件中,成功返回true,失败返回false
bool ItemMgr::_save_player_Items(const PlayerItem* playeritem) {
// todo 把玩家数据序列化后存盘,参考save函数
int retCode = 0;
PlayerBagData playerData;
//还原修改后的PlayerBagData结构
map<int, int> items = playeritem->Items;
map<int, int>::iterator iter;
iter = items.begin();
while (iter != items.end()) {
BagItem *bagItem= playerData.add_bagitem();
bagItem->set_itemid(iter->first);
bagItem->set_count(iter->second);
iter++;
}
string strBag = playeritem->PlayerID + "_Bag";
retCode = save(strBag.c_str(), playerData.SerializeAsString().c_str(), playerData.ByteSize());
if (retCode != 0)
return false;
else
协议结构如下
message BagItem{
int32 ItemId=1;
int32 Count=2;
}
message PlayerBagData{
repeated BagItem BagItem=2;
}
接收到购买请求进行处理
bool ItemMgr::item_buy(uv_tcp_t* client, const ShopBuyReq* req) {
bool result = false;
Player* player = nullptr;
PlayerSaveData playerData;
ShopBuyRsp rsp;
PlayerBagData playerBagData;
PlayerItem* playerItem = new PlayerItem();
fprintf(stdout, "item buy request\n");
// 1. 查看玩家是否已经在游戏中,如果在游戏中登录失败
player = g_playerMgr.find_player(req->playerid());
if (player == nullptr) {
rsp.set_result(-1);
rsp.set_reason("player has not login");
goto Exit1;
}
//2、判断商店是否有该商品
if (m_storeMap.count(req->itemid()) == 0)
{
rsp.set_result(-2);
rsp.set_reason("store has not this item");
goto Exit1;
}
//3、读取当前玩家金钱数据 读取商店物品价格数据 判断是否足够购买
if (player->Money < m_storeMap[req->itemid()]->price * req->count())
{
rsp.set_result(-4);
rsp.set_reason("has not enough money");
goto Exit1;
}
player->Money -= m_storeMap[req->itemid()]->price * req->count();
// 4. 从文件中读取玩家背包数据并展开
playerItem->PlayerID = req->playerid();
if (_load_player_Items(playerItem->PlayerID, &playerBagData)) {
//如果存在玩家背包 用playerItem暂存
if (playerBagData.bagitem_size() > 0)
{
for (int i = 0; i < playerBagData.bagitem_size(); i++)
{
playerItem->Items.insert(make_pair(playerBagData.bagitem()[i].itemid(), playerBagData.bagitem()[i].count()));
}
}
}
//找到该道具 直接添加数量
if (playerItem->Items.count(req->itemid()) == 1)
{
playerItem->Items[req->itemid()] += req->count();
}
//没有找到 添加该道具
else
{
playerItem->Items.insert(make_pair(req->itemid(), req->count()));
}
//5、 保存背包信息
if (!_save_player_Items(playerItem))
{
fprintf(stdout, "save playerItems error\n");
goto Exit0;
}
//6、保存玩家信息
if (!g_playerMgr._save_player(player)) {
fprintf(stdout, "save playerInfo error\n");
goto Exit0;
}
// 7. 应答客户端登录结果
rsp.set_result(0);
Exit1:
{
fprintf(stdout, "buy success [item:%d count:%d moneyLeft:%d]\n", req->itemid(), req->count(), player->Money);
string r = rsp.SerializeAsString();
int len = encode(s_send_buff, SERVER_SHOPBUY_RSP, r.c_str(), (int)r.length());
sendData((uv_stream_t*)client, s_send_buff, len);
}
result = true;
Exit0:
return result;
}
【推荐】国内首个AI IDE,深度理解中文开发场景,立即下载体验Trae
【推荐】编程新体验,更懂你的AI,立即体验豆包MarsCode编程助手
【推荐】抖音旗下AI助手豆包,你的智能百科全书,全免费不限次数
【推荐】轻量又高性能的 SSH 工具 IShell:AI 加持,快人一步
· 分享4款.NET开源、免费、实用的商城系统
· 全程不用写代码,我用AI程序员写了一个飞机大战
· MongoDB 8.0这个新功能碉堡了,比商业数据库还牛
· 记一次.NET内存居高不下排查解决与启示
· 白话解读 Dapr 1.15:你的「微服务管家」又秀新绝活了