0%

socket server 開發筆記

 

最近幫朋友開發 socket server 來接收 IOT 設備的資料, 以前比較少碰觸這麼底層, 順便筆記下

接收資料函數

猜測 client 應該是 c 語言之類寫的, 所以傳來資料型態為 byte array 裡面存的則是 hex 16 進位, 並且還有 checksum 才能把資料解出來
還好有 chatgpt 可以快速搞定這些常用的功能 XD

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
public byte[] HexStringToByteArray(string hex)
{
int numberChars = hex.Length;
byte[] bytes = new byte[numberChars / 2];
for (int i = 0; i < numberChars; i += 2)
{
bytes[i / 2] = Convert.ToByte(hex.Substring(i, 2), 16);
}
return bytes;
}

public float HexToFloat(string hex)
{
if (hex.Length != 8)
throw new ArgumentException("Hex 字串長度必須為 8(32-bit)");

// 1. 將 hex 轉為 byte[]
byte[] bytes = Enumerable.Range(0, hex.Length / 2)
.Select(i => Convert.ToByte(hex.Substring(i * 2, 2), 16))
.ToArray();

// 2. 注意:IEEE 754 是小端序 (little-endian),要 reverse(依實際情況決定)
if (BitConverter.IsLittleEndian)
Array.Reverse(bytes);

// 3. 轉成 float
return BitConverter.ToSingle(bytes, 0);
}

public short HexStringToShort(string hex)
{
if (string.IsNullOrWhiteSpace(hex))
throw new ArgumentException("輸入字串不可為空");

hex = hex.Replace(" ", "");

if (hex.Length != 4)
throw new ArgumentException("輸入字串長度必須是 4 (2 bytes)");

byte[] bytes = new byte[2];
for (int i = 0; i < 2; i++)
{
bytes[i] = Convert.ToByte(hex.Substring(i * 2, 2), 16);
}

if (BitConverter.IsLittleEndian)
Array.Reverse(bytes);

return BitConverter.ToInt16(bytes, 0);
}


public byte HexStringToByte(string hex)
{
if (string.IsNullOrWhiteSpace(hex))
throw new ArgumentException("輸入字串不可為空");

hex = hex.Replace(" ", "");

if (hex.Length != 2)
throw new ArgumentException("輸入字串長度必須是 2 (1 byte)");

return Convert.ToByte(hex, 16);
}

public byte CalculateChecksum(string hexString)
{
// Remove any spaces just in case
hexString = hexString.Replace(" ", "");

int sum = 0;
for (int i = 0; i < hexString.Length; i += 2)
{
string byteStr = hexString.Substring(i, 2);
byte value = Convert.ToByte(byteStr, 16);
sum += value;
}

// Return only the lowest 8 bits
return (byte)(sum % 256);
}

port 監測

如果有 client 連上來的話就會出現一個以上的訊息, 否則只會出現 0.0.0.0

1
2
3
4
5
6
Get-NetTCPConnection -LocalPort 5987

LocalAddress LocalPort RemoteAddress RemotePort State AppliedSetting OwningProcess
------------ --------- ------------- ---------- ----- -------------- -------------
10.1.2.3 5987 123.45.67.89 12345 Established Datacenter 7916
0.0.0.0 5987 0.0.0.0 0 Listen 7916

windows service

socket server 通常會需要在背景跑, 所以要辛苦寫 windows service, 不過也可以用偷懶的方法, 直接靠 nssm 把 console 變成 windows service 即可
用法可以參考保哥

另外有可能會想要看到即時的 log, 所以可以在 Serilog 加上一個 NamedPipeServerStreamPipeSink, 每當 server 有印出東西來時就可以送給想要接收的即時 log 的 winform or wpf 之類的程式
可以在 chrome 打上這個網址 file://.//pipe// 就可以瀏覽到很多程式也是用 NamedPipeServerStream 這種方式來做程式之間的溝通

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
public static class NamedPipeSinkExtensions
{
public static LoggerConfiguration NamedPipe(
this LoggerSinkConfiguration loggerConfiguration,
string pipeName = "LogPipe",
ITextFormatter? textFormatter = null) // 改成 ITextFormatter
{
return loggerConfiguration.Sink(new NamedPipeSink(pipeName, textFormatter));
}
}


public class NamedPipeSink : ILogEventSink
{
private readonly string _pipeName;
private readonly ITextFormatter _textFormatter;
private NamedPipeServerStream? _client;
private StreamWriter? _writer;

public NamedPipeSink(string pipeName, ITextFormatter? textFormatter = null)
{
_pipeName = pipeName;
_textFormatter = textFormatter ?? new MessageTemplateTextFormatter(
"[{Timestamp:yyyy-MM-dd HH:mm:ss} {Level:u3}] {Message:lj}{NewLine}{Exception}", null);

Task.Run(ListenForClient); // background task
}

public void Emit(LogEvent logEvent)
{
if (_client == null || !_client.IsConnected || _writer == null)
return;

try
{
_textFormatter.Format(logEvent, _writer);
_writer.WriteLine(); // 保證 log 換行
}
catch (IOException)
{
// 客戶端中斷或寫入錯誤
_client.Dispose();
_client = null;
_writer = null;
}
}

private async Task ListenForClient()
{
while (true)
{
_client = new NamedPipeServerStream(
_pipeName,
PipeDirection.InOut,
1, //永遠只有 1 個 client
PipeTransmissionMode.Byte,
PipeOptions.Asynchronous);

await _client.WaitForConnectionAsync();

_writer = new StreamWriter(_client, leaveOpen: true) { AutoFlush = true };
var reader = new StreamReader(_client);

try
{
while (_client.IsConnected)
{
string? line = await reader.ReadLineAsync();
if (line == null) break;

Log.Information($"收到 Manager Client 訊息: {line}");

// 回應 client(可選)
//await _writer.WriteLineAsync("Server 收到: " + line);
}
}
catch (IOException ex)
{
Log.Warning("Manager Client Pipe 發生錯誤: " + ex.Message);
}
finally
{
_client.Dispose();
_client = null;
_writer = null;
}
}
}
}

然後這樣設定 serilog

1
2
3
4
5
Log.Logger = new LoggerConfiguration()
.ReadFrom.Configuration(configuration)
.Enrich.FromLogContext()
.WriteTo.NamedPipe("YourLogPipe")
.CreateLogger();

這裡有個很容易暴雷的低能問題, 就是 appsettings.json 已經設定了, 但又多補上 Console 跟 File 然後導致輸出兩次 log -. -

1
2
3
4
5
6
7
Log.Logger = new LoggerConfiguration()
.ReadFrom.Configuration(builder.Configuration)
.Enrich.FromLogContext()
.WriteTo.Console()
.WriteTo.File("Logs/log-.txt", rollingInterval: RollingInterval.Day)
.WriteTo.NamedPipe("YourLogPipe")
.CreateLogger();

websocket

因為希望能在 web 發送命令給 client, 所以可以用 websocket 來當作 proxy 發給 socket server, server 會再轉發給 client 設備

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
app.Use(async (context, next) =>
{
if (context.Request.Path == "/ws")
{
if (context.WebSockets.IsWebSocketRequest)
{
using var webSocket = await context.WebSockets.AcceptWebSocketAsync();
using var tcpClient = new TcpClient(ip, port); // 每個 Client 一條 TCP 連線
using var tcpStream = tcpClient.GetStream();

string guid = Guid.NewGuid().ToString();
byte[] guidBytes = Encoding.UTF8.GetBytes(guid);
await tcpStream.WriteAsync(guidBytes, 0, guidBytes.Length);

var cts = new CancellationTokenSource();

var receiveFromWebSocket = Task.Run(async () =>
{
var buffer = new byte[1024];
while (!cts.Token.IsCancellationRequested)
{
try
{
var result = await webSocket.ReceiveAsync(new ArraySegment<byte>(buffer), cts.Token);
if (result.MessageType == WebSocketMessageType.Close)
{
cts.Cancel();
break;
}

string message = Encoding.UTF8.GetString(buffer, 0, result.Count);
byte[] data = Encoding.UTF8.GetBytes(message);
await tcpStream.WriteAsync(data, 0, data.Length, cts.Token);
}
catch
{
cts.Cancel();
}
}
});

var receiveFromTcp = Task.Run(async () =>
{
var buffer = new byte[1024];
while (!cts.Token.IsCancellationRequested)
{
try
{
int bytesRead = await tcpStream.ReadAsync(buffer, 0, buffer.Length, cts.Token);
if (bytesRead == 0)
{
cts.Cancel();
break;
}

string response = Encoding.UTF8.GetString(buffer, 0, bytesRead);
await webSocket.SendAsync(
new ArraySegment<byte>(Encoding.UTF8.GetBytes(response)),
WebSocketMessageType.Text,
true,
cts.Token);
}
catch
{
cts.Cancel();
}
}
});

await Task.WhenAny(receiveFromWebSocket, receiveFromTcp);
cts.Cancel(); // 雙向斷開
}
else
{
context.Response.StatusCode = 400;
}
}
else
{
await next();
}
});

前端則需要設定 wss,並實作自己需要的 onopen onclose onerror 等功能即可, 其他就問 gpt 大概就無腦使用

1
const socket = new WebSocket("wss://localhost:1234/ws");
關閉