最近都在全台跑面試
都沒時間繼續寫..
剛好面試某金控
面試官出了個回家作業給我
就花了一個下午把它完成 順便做為這次的主題
需求如下
寫個Winform(Client)
利用GridView顯示100筆股票資料(名稱/成交價/漲跌/漲跌幅)
漲紅跌綠平黃
另外要寫個Server(Console)
client去取server資料更新
頻率..原本面試官是說一秒一次 後來也沒有特定限制 所以我寫個參數供使用者自行設定
直接上原始碼
https://github.com/bantime/CodeReview/tree/main/StockHomeWork
既然要寫TCP連線,就先寫個Lib來用,如果以後有需要也能直接使用
首先是Server端
public class TcpSocketServer
{
private readonly IPAddress _IPAddress;
private readonly int _Port;
private Socket _Listener;
public TcpSocketServer(IPAddress IPAddress, int Port)
{
_IPAddress = IPAddress;
_Port = Port;
}
private List<SocketObj> Clients { get; set; } = new List<SocketObj>();
public event Action<SocketObj> OnClientAccept;
public async Task StartListening()
{
try
{
IPEndPoint localEndPoint = new IPEndPoint(_IPAddress, _Port);
_Listener = new Socket(AddressFamily.InterNetwork,
SocketType.Stream, ProtocolType.Tcp);
_Listener.Bind(localEndPoint);
_Listener.Listen();
while (true)
{
var socket = await _Listener.AcceptAsync();
var clientObj = new SocketObj(socket);
OnClientAccept?.Invoke(clientObj);
clientObj.OnDisconnect += (co) =>
{
Clients.Remove(co);
Console.WriteLine("Client Disconnect!");
};
Clients.Add(clientObj);
Console.WriteLine("New Client Accept!");
}
}
catch (Exception e)
{
Console.WriteLine(e.ToString());
}
finally
{
Console.WriteLine("Server Closeing!");
}
}
}
主要核心為 StartListening這個方法
while迴圈前就只是設定要bind的ip/port
注意如果bind的port有其他程式已經先用了會出Exception
所以請稍微選一下port 不要用常用的(要衝到也有難度
再來是while迴圈
這裡是等待client接入 程式會block在var socket = await _Listener.AcceptAsync();這行
如果沒有任何client接入的話 不會繼續往下執行
所以此時如果有client接入我會呼叫OnClientAccept這個事件(委派的用法之前有說明過 有興趣可以回去翻
然後加入client的集合 設定斷線後要從集合移除的事件
然後繼續等待下一個client接入(while迴圈
這裡有用到一個類別 SocketObj 這是我自己寫的 程式碼如下 主要是提供接收訊息 及 送出訊息的方法
所以我可以從client的集合中挑出我要發送訊息的對象針對該client發送訊息
public class SocketObj
{
protected Socket Socket { get; set; }
private ProtocolComplete ProtocolComplete { get; set; }
public event Action<byte[]> OnDataReceive;
public event Action<SocketObj> OnDisconnect;
/// <summary>
/// 送出資料前加上資料長度的Head
/// </summary>
/// <param name="datas"></param>
/// <returns></returns>
public async Task SendAsync(byte[] datas)
{
var data = new ArraySegment<byte>(datas.GetDataWithHead());
await Socket.SendAsync(data, SocketFlags.None);
}
/// <summary>
/// 不預先計算資料長度 直接送出
/// </summary>
/// <param name="datas"></param>
/// <returns></returns>
public async Task SendAsyncNotSetHead(byte[] datas)
{
try
{
var data = new ArraySegment<byte>(datas);
await Socket.SendAsync(data, SocketFlags.None);
}
catch(Exception e)
{
OnDisconnect?.Invoke(this);
}
}
public SocketObj(Socket socket)
{
this.Socket = socket;
ProtocolComplete = new ProtocolComplete();
ProtocolComplete.CompleteProtocolEvent += (datas) => OnDataReceive?.Invoke(datas);
}
public async Task StartReceiveAsync()
{
var buffer = new byte[2048];
var data = new ArraySegment<byte>(buffer);
while (true)
{
try
{
var count = await Socket.ReceiveAsync(data, SocketFlags.None);
ProtocolComplete.ReceiveData(buffer.Take(count));
}
catch (Exception e)
{
OnDisconnect?.Invoke(this);
//TODO 斷線
return;
}
}
}
}
這裡的核心是StartReceiveAsync這個方法 會持續接收資料跟Server的等待接入有點像
如果都沒有資料傳來 會卡在var count = await Socket.ReceiveAsync(data, SocketFlags.None);這行
如果有資料傳來 我就會將資料傳入ProtocolComplete.ReceiveData(buffer.Take(count));
附帶一提 count 是這次接收到多少資料 然後繼續跑while迴圈繼續等待資料
如果斷線會出Exception 就呼叫斷線事件就好 這邊就看斷線想幹嘛
這裡面有用到ProtocolComplete這個類別
這個類別主要是我用來處理封包不完整的問題
以前曾經遇到過
client送出訊息 [1,2,3,4,5,6,7,8,9,10]
server先接收到 [1,2,3]
然後再接收到[4,5,6,7,8,9,10]
因此我這裡自己定義 所有封包訊息送出以前
前面會利用4個byte(1個uint的大小) 紀錄我需要讀取多少長度才算完整
當我socket持續收到資料 就會先塞給ProtocolComplete的ReceiveData
裡面會暫存目前的資料TempData
如果長度有至少四個 就代表我可以知道我接下來要讀取多長的資料才算完整封包
記錄在ReceiveLength上(如果是-1代表我尚未得知接下來要讀取多長
當我得知要讀取的長度後且TempData的資料長度夠了
我就將該資料取出 並呼叫 CompleteProtocolEvent這個事件給事件提供者
代表這個byte[]是完整的 可以進行處理了 並將ReceiveLength設置為-1 進行下一次完整封包判斷
程式碼如下
public class ProtocolComplete
{
public event Action<byte[]> CompleteProtocolEvent;
private readonly List<byte> TempData = new List<byte>();
private int ReceiveLength = -1;
public void ReceiveData(IEnumerable<byte> Data)
{
TempData.AddRange(Data);
if (ReceiveLength < 0 && TempData.Count >= 4)
{
ReceiveLength = BitConverter.ToInt32(TempData.GetRange(0, 4).ToArray(), 0);
TempData.RemoveRange(0, 4);
}
if (ReceiveLength >= 4)
{
if (ReceiveLength <= TempData.Count)
{
byte[] aFullData = TempData.GetRange(0, ReceiveLength).ToArray();
TempData.RemoveRange(0, ReceiveLength);
if (CompleteProtocolEvent != null)
CompleteProtocolEvent(aFullData);
ReceiveLength = -1;
}
}
else
{
throw new Exception("ReceiveLength Error! " + ReceiveLength);
}
}
}
所以 SocketObj在實例化的時候 就會註冊ProtocolComplete.CompleteProtocolEvent事件
ProtocolComplete.CompleteProtocolEvent += (datas) => OnDataReceive?.Invoke(datas);
會將data再拋給OnDataReceive事件
SocketObj會用在兩個地方 一個是Server接收到接入請求時,另外一個是Client
client還需要連接server的方法 但是大部分功能都跟SocketObj相同 於是我採用繼承關係
程式碼如下
public class TcpSocketClient : SocketObj
{
private readonly IPAddress _IPAddress;
private readonly int _Port;
public int ConnectTimeOut = 10;
public TcpSocketClient(IPAddress IPAddress, int Port) : base(new Socket(AddressFamily.InterNetwork,
SocketType.Stream, ProtocolType.Tcp))
{
_IPAddress = IPAddress;
_Port = Port;
}
public TcpSocketClient(string IPString, int Port) : base(new Socket(AddressFamily.InterNetwork,
SocketType.Stream, ProtocolType.Tcp))
{
_IPAddress = IPAddress.Parse(IPString);
_Port = Port;
}
public void ResetSocket()
{
this.Socket = new Socket(AddressFamily.InterNetwork,
SocketType.Stream, ProtocolType.Tcp);
}
public async Task<bool> StartClient()
{
try
{
IPEndPoint RemoteEP = new IPEndPoint(_IPAddress, _Port);
Socket.SendTimeout = 3000;
Socket.ReceiveTimeout = 3000;
await Socket.ConnectAsync(RemoteEP);
return true;
}
catch (Exception e)
{
Console.WriteLine(e.ToString());
return false;
}
}
}
當TcpSocketClient實例化後 呼叫StartClient就可以對server進行連接
另外補個擴充方法 這就是剛剛講說送出封包前 我需要在前面設置我需要接收多長的資料
例如我要送出 [1,2,3,4,5,6,7] 實際上會送出 [7,0,0,0,1,2,3,4,5,6,7]
而 7,0,0,0 就是指我後續要接收長度為7的資料
public static class SocketExtension
{
public static byte[] GetDataWithHead(this byte[] datas)
{
var length = (uint)datas.Length;
IEnumerable<byte[]> getDatas()
{
yield return BitConverter.GetBytes(length);//計算此次封包的長度 最長為uint.Maxvalue
yield return datas;
}
return getDatas().SelectMany(x => x).ToArray();
}
}
下面是使用範例
//這是Server端
Task.Run(async () =>
{
//設定Server的ip/port
var server = new TcpSocketServer(IPAddress.Parse("127.0.0.1"), 2001);
server.OnClientAccept += async (clientObj) =>//設定client接入的事件
{
//設定client如果傳訊息來server server的處置方式
//這裡因為ProtocolComplete的關係 已經是完整封包 可以進行處理
clientObj.OnDataReceive += async (datas) =>
{
/*
* 此處如果比較懶 也可以用json來傳物件
*/
//這裡就自己定義你的封包內容 我假設我client只有傳一個long 這個long是時間ticks
var datetime = new DateTime(BitConverter.ToInt64(datas));
Console.WriteLine($"ServerWriteLine : {datetime}");//印出client傳來的時間
datetime = datetime.AddSeconds(20);//加個20秒 回傳client
await clientObj.SendAsync(BitConverter.GetBytes(datetime.Ticks));
};
await clientObj.StartReceiveAsync();//開始接收client來的訊息
};
await server.StartListening();//開始接受接入請求
});
//這是Client端 可以多複製幾個啟動多個
_ = Task.Run(async () =>
{
//設定要連接的ip/port
var client = new TcpSocketClient("127.0.0.1", 2001);
//設定接收到的資料處理
client.OnDataReceive += (datas) =>
{
var datetime = new DateTime(BitConverter.ToInt64(datas));
//印出Server回傳來的+20秒的時間
Console.WriteLine($"ClientWriteLine : {datetime}");
};
//開始連接 會回傳bool true才是連接成功
//此處就不特別判斷了
await client.StartClient();
//連接完成才會繼續跑這裡(成不成功自己再判斷)
//開始接收資料
_ = client.StartReceiveAsync();
while(true)
{
//送出訊息給Server
//對Server送出當前時間 每五秒送一次
await client.SendAsync(BitConverter.GetBytes(DateTime.Now.Ticks));
await Task.Delay(TimeSpan.FromSeconds(5));
}
});
這樣TCP/IP Server Client的核心功能就大致完成了
剩下的下一篇再繼續!