iT邦幫忙

2

C# 回家作業(1)

最近都在全台跑面試

都沒時間繼續寫..

剛好面試某金控

面試官出了個回家作業給我

就花了一個下午把它完成 順便做為這次的主題

需求如下

寫個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的核心功能就大致完成了

剩下的下一篇再繼續!


尚未有邦友留言

立即登入留言