del: 将go版本的内容删除,只保留处理日志功能
This commit is contained in:
94
go/README.md
Normal file
94
go/README.md
Normal file
@@ -0,0 +1,94 @@
|
||||
# OmniSocketGo
|
||||
|
||||
Linux only. Go 1.22.
|
||||
|
||||
如果目标机器只运行 `server`,只需要编译并拷贝 `server` 二进制。
|
||||
如果目标机器只运行 `peer`,只需要编译并拷贝 `peer` 二进制。
|
||||
|
||||
`go build ./cmd/server` 和 `go build ./cmd/peer` 会把各自依赖到的功能一起编译进最终二进制,不需要再单独编译 `cmd/internal/...` 包。
|
||||
|
||||
- `server` 二进制会包含它依赖到的转发、协议、传输等代码
|
||||
- `peer` 二进制会包含它依赖到的注册、交互发送、接收落盘、协议、传输等代码
|
||||
- 只有没有被这个可执行程序引用的其他命令,才不在该二进制里,比如 `cmd/latencysummary`
|
||||
|
||||
## Build
|
||||
|
||||
按目标架构分别编译。
|
||||
mkdir -p bin
|
||||
go build -o bin/server ./cmd/server
|
||||
go build -o bin/peer ./cmd/peer
|
||||
go build -o bin/latencysummary ./cmd/latencysummary
|
||||
|
||||
### Linux amd64
|
||||
|
||||
```bash
|
||||
CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -o bin/server-linux-amd64 ./cmd/server
|
||||
```
|
||||
|
||||
### Linux arm64
|
||||
|
||||
```bash
|
||||
CGO_ENABLED=0 GOOS=linux GOARCH=arm64 go build -o bin/peer-linux-arm64 ./cmd/peer
|
||||
```
|
||||
|
||||
### Linux armv7
|
||||
|
||||
```bash
|
||||
CGO_ENABLED=0 GOOS=linux GOARCH=arm GOARM=7 go build -o bin/server-linux-armv7 ./cmd/server
|
||||
CGO_ENABLED=0 GOOS=linux GOARCH=arm GOARM=7 go build -o bin/peer-linux-armv7 ./cmd/peer
|
||||
```
|
||||
|
||||
|
||||
|
||||
## Run On Different Machines
|
||||
|
||||
`server D` 所在机器监听 `0.0.0.0:10909`。
|
||||
|
||||
```bash
|
||||
go run cmd/kcpserver/ -listen 0.0.0.0:10909
|
||||
-kcp-ts-debug-log logs/d-kcp-ts.jsonl -kcp-session-stats-log logs/d-kcp-stats.jsonl
|
||||
```
|
||||
|
||||
`relay server C` 所在机器
|
||||
|
||||
```bash
|
||||
go run ./cmd/kcpserver/ -mode=relay -listen 0.0.0.0:10909 -relay-remote 172.21.32.15:10909
|
||||
|
||||
2>&1 | tee logs/c.stdout.log
|
||||
```
|
||||
|
||||
### peer-a (A)
|
||||
|
||||
```bash
|
||||
go run ./cmd/kcppeer/ -id peer-a -server 172.21.32.15:10909 -relay-via 106.55.173.235:10909 -inbox-dir inbox/a
|
||||
|
||||
-latency-log logs/a-latency.jsonl -kcp-ts-debug-log logs/a-kcp-ts.jsonl -kcp-session-stats-log logs/a-kcp-stats.jsonl
|
||||
|
||||
go run ./cmd/kcpping/ -id peer-a -server 106.55.173.235:10909 -echo
|
||||
```
|
||||
|
||||
### peer-b (B)
|
||||
|
||||
```bash
|
||||
go run ./cmd/kcppeer/ -id peer-b -server 81.70.156.140:10909 -inbox-dir inbox/b
|
||||
|
||||
-latency-log logs/b-latency.jsonl -kcp-ts-debug-log logs/b-kcp-ts.jsonl -kcp-session-stats-log logs/b-kcp-stats.jsonl
|
||||
|
||||
go run ./cmd/kcpping -id peer-b -server 81.70.156.140:10909 -to peer-a -count 20 -interval 100ms
|
||||
```
|
||||
|
||||
## Interactive Commands
|
||||
|
||||
`peer` 启动后可以在终端里持续使用同一条长连接发送多次消息。
|
||||
|
||||
```text
|
||||
help
|
||||
text peer-b hello
|
||||
text peer-a hi
|
||||
file peer-a /tmp/test125.bin
|
||||
file peer-a /tmp/test5.bin
|
||||
quit
|
||||
```
|
||||
### 自动化拉取更新汇总数据
|
||||
cd /home/limingjie/LMJ_Work/OmniSocketGo
|
||||
./scripts/refresh-latency-summary.sh
|
||||
465
go/change_to_c.md
Normal file
465
go/change_to_c.md
Normal file
@@ -0,0 +1,465 @@
|
||||
OmniSocketGo -> OmniSocketC 转换计划
|
||||
|
||||
Context
|
||||
|
||||
将现有的 Go 语言实现的 UDP/KCP 传输层项目 (OmniSocketGo) 转换为纯 C 语言项目,运行在 Linux 系统上。
|
||||
|
||||
原项目架构:A(Jetson) <-> C(relay cloud) <-> D(hub cloud) <-> B(host)
|
||||
- B <-> D:KCP 链路
|
||||
- D <-> C:UDP relay 转发
|
||||
- C <-> A:KCP 链路(A 通过 relay C 连接到 hub D)
|
||||
- 最终目的:B 和 A 之间双向传输数据
|
||||
|
||||
转换要求:
|
||||
- 只保留 UDP 和 KCP,不需要 TCP
|
||||
- 不需要写测试
|
||||
- 完整实现协议层、传输层、日志事件系统
|
||||
- Linux only
|
||||
|
||||
项目位置
|
||||
|
||||
OmniSocketGo/c/ — 作为当前 Go 项目的子目录
|
||||
|
||||
项目结构
|
||||
|
||||
c/
|
||||
├── Makefile
|
||||
├── README.md
|
||||
├── include/
|
||||
│ ├── protocol.h # 协议消息定义 + 编解码
|
||||
│ ├── transport_kcp.h # KCP 连接封装
|
||||
│ ├── transport_udp.h # UDP 连接封装(含 Linux timestamping)
|
||||
│ ├── linux_timestamping.h # Linux SO_TIMESTAMPING 底层实现
|
||||
│ ├── kcp_packet_debug.h # KCP packet-level kernel timestamp debug logger
|
||||
│ ├── kcp_session_stats.h # KCP session stats (RTO/SRTT) logger
|
||||
│ ├── tx_timestamp_debug.h # TX errqueue timestamp debug logger
|
||||
│ ├── server_kcp_hub.h # KCP Hub (D 节点)
|
||||
│ ├── server_udp_relay.h # UDP Relay (C 节点)
|
||||
│ ├── peer_kcp_client.h # KCP Peer Client (A/B 节点)
|
||||
│ ├── latencylog.h # 延迟日志事件系统
|
||||
│ ├── interactive.h # 交互式命令行
|
||||
│ └── cJSON.h # JSON 库 (第三方轻量级)
|
||||
├── src/
|
||||
│ ├── protocol.c
|
||||
│ ├── transport_kcp.c
|
||||
│ ├── transport_udp.c
|
||||
│ ├── linux_timestamping.c
|
||||
│ ├── kcp_packet_debug.c
|
||||
│ ├── kcp_session_stats.c
|
||||
│ ├── tx_timestamp_debug.c
|
||||
│ ├── server_kcp_hub.c
|
||||
│ ├── server_udp_relay.c
|
||||
│ ├── peer_kcp_client.c
|
||||
│ ├── latencylog.c
|
||||
│ ├── interactive.c
|
||||
│ └── cJSON.c
|
||||
├── cmd/
|
||||
│ ├── kcpserver.c # 主程序: KCP Hub 或 UDP Relay
|
||||
│ ├── kcppeer.c # 主程序: KCP Peer (A/B)
|
||||
│ └── kcpping.c # 主程序: KCP Ping 工具
|
||||
└── third_party/
|
||||
└── kcp/
|
||||
├── ikcp.h # KCP 协议核心实现 (github.com/skywind3000/kcp)
|
||||
└── ikcp.c
|
||||
|
||||
依赖说明
|
||||
|
||||
- KCP: 使用 skywind3000/kcp 的原始 C 实现 (ikcp.h/ikcp.c),替代 Go 的 xtaci/kcp-go/v5
|
||||
- JSON: 使用 cJSON (DaveGamble/cJSON) 替代 Go 的 encoding/json
|
||||
- 线程: 使用 pthread 替代 Go goroutine
|
||||
- 同步: 使用 pthread_mutex/pthread_rwlock 替代 Go sync.Mutex/sync.RWMutex
|
||||
|
||||
模块实现计划
|
||||
|
||||
1. 第三方库集成
|
||||
|
||||
- 下载 ikcp.h/ikcp.c (skywind3000/kcp)
|
||||
- 下载 cJSON.h/cJSON.c (DaveGamble/cJSON)
|
||||
|
||||
2. protocol.h / protocol.c
|
||||
|
||||
对应 Go: cmd/internal/protocol/message.go + codec.go
|
||||
|
||||
// 消息类型
|
||||
typedef enum {
|
||||
MSG_TYPE_TEXT = 0,
|
||||
MSG_TYPE_FILE = 1,
|
||||
MSG_TYPE_REGISTER = 2,
|
||||
MSG_TYPE_ERROR = 3,
|
||||
} message_type_t;
|
||||
|
||||
// 消息结构
|
||||
typedef struct {
|
||||
message_type_t type;
|
||||
uint64_t id;
|
||||
char from[64];
|
||||
char to[64];
|
||||
char file_name[256];
|
||||
uint8_t *body;
|
||||
int body_len;
|
||||
} message_t;
|
||||
|
||||
#define MAX_FRAME_SIZE (8 * 1024 * 1024)
|
||||
#define SERVER_PEER_ID "server"
|
||||
|
||||
核心函数:
|
||||
- int protocol_encode_message(const message_t *msg, uint8_t **out, int *out_len) — 编码消息为 [4B headerLen][header JSON][body]
|
||||
- int protocol_decode_message(const uint8_t *data, int data_len, message_t *msg) — 解码
|
||||
- int protocol_write_frame(int fd, const uint8_t *payload, int payload_len) — 写带长度前缀的帧 (用于 KCP stream)
|
||||
- int protocol_read_frame(int fd, uint8_t **payload, int *payload_len) — 读帧
|
||||
- int protocol_write_message(int fd, const message_t *msg) — 完整编码+写帧
|
||||
- int protocol_read_message(int fd, message_t *msg) — 读帧+解码
|
||||
- int protocol_validate_message(const message_t *msg) — 校验
|
||||
- void message_free(message_t *msg) — 释放 body 内存
|
||||
|
||||
注意: KCP session 在 stream 模式下行为类似 TCP,需要 [4B frameLen] 前缀来分帧。
|
||||
|
||||
3. latencylog.h / latencylog.c
|
||||
|
||||
对应 Go: cmd/internal/latencylog/logger.go
|
||||
|
||||
// 事件名常量
|
||||
#define EVENT_A_APP_PREP_BEGIN "A_APP_PREP_BEGIN"
|
||||
#define EVENT_SEND_HANDOFF_BEGIN "send_handoff_begin"
|
||||
#define EVENT_SEND_HANDOFF_END "send_handoff_end"
|
||||
#define EVENT_B_APP_RECV "B_APP_RECV"
|
||||
#define EVENT_B_PERSIST_BEGIN "B_PERSIST_BEGIN"
|
||||
#define EVENT_B_PERSIST_END "B_PERSIST_END"
|
||||
// ... 其他事件
|
||||
|
||||
typedef struct {
|
||||
int64_t ts_unix_nano;
|
||||
char node_role[16];
|
||||
char node_id[64];
|
||||
char event[32];
|
||||
message_type_t message_type;
|
||||
uint64_t message_id;
|
||||
char from[64];
|
||||
char to[64];
|
||||
char file_name[256];
|
||||
int body_size;
|
||||
} latency_event_t;
|
||||
|
||||
typedef struct latency_logger latency_logger_t;
|
||||
|
||||
核心函数:
|
||||
- latency_logger_t *latencylog_new_jsonl(const char *path) — 创建 JSONL 文件日志器
|
||||
- void latencylog_log_event(latency_logger_t *logger, const latency_event_t *event) — 写事件
|
||||
- void latencylog_log_message_event(latency_logger_t *logger, const char *node_role, const char *node_id, const char *event_name, const message_t
|
||||
*msg) — 为业务消息记事件
|
||||
- void latencylog_close(latency_logger_t *logger) — 关闭
|
||||
- int latencylog_is_business_message(const message_t *msg) — 判断是否业务消息
|
||||
|
||||
4. transport_kcp.h / transport_kcp.c
|
||||
|
||||
对应 Go: cmd/internal/transport/kcp.go + kcp_packet_conn.go
|
||||
|
||||
KCP 连接封装,底层用 raw ikcp + UDP socket:
|
||||
|
||||
typedef struct kcp_conn {
|
||||
ikcpcb *kcp;
|
||||
int udp_fd;
|
||||
struct sockaddr_in remote_addr;
|
||||
pthread_mutex_t write_mu;
|
||||
pthread_t recv_thread; // 底层 UDP -> ikcp_input 的线程
|
||||
latency_logger_t *logger;
|
||||
char node_role[16];
|
||||
char node_id[64];
|
||||
int closed;
|
||||
} kcp_conn_t;
|
||||
|
||||
核心函数:
|
||||
- kcp_conn_t *kcp_conn_dial(const char *server_addr, const char *bind_ip, const char *bind_device) — 客户端拨号
|
||||
- kcp_conn_t *kcp_conn_accept(int udp_fd, struct sockaddr_in *remote, uint32_t conv) — 服务端接受
|
||||
- int kcp_conn_send(kcp_conn_t *conn, const message_t *msg) — 发送消息
|
||||
- int kcp_conn_receive(kcp_conn_t *conn, message_t *msg) — 接收消息
|
||||
- void kcp_conn_close(kcp_conn_t *conn) — 关闭
|
||||
|
||||
KCP 配置参数(与 Go 版一致):
|
||||
#define KCP_NODELAY 1
|
||||
#define KCP_INTERVAL 10
|
||||
#define KCP_RESEND 2
|
||||
#define KCP_NC 1
|
||||
#define KCP_WND_SIZE 256
|
||||
#define KCP_MTU 1400
|
||||
|
||||
KCP 底层架构说明:
|
||||
Go 版使用 kcp-go 库,该库内部维护了一个 Listener 来多路复用一个 UDP socket 上的多个 KCP session(通过 conv ID 区分)。在 C 中需要自行实现:
|
||||
- 服务端:一个 UDP socket 监听,一个接收线程读取所有 UDP 包,根据 conv ID 分发到对应的 ikcpcb
|
||||
- 客户端:一个 UDP socket,一个 ikcpcb,一个后台线程负责 UDP recv -> ikcp_input
|
||||
|
||||
5. transport_udp.h / transport_udp.c
|
||||
|
||||
对应 Go: cmd/internal/transport/udp.go + udp_linux.go
|
||||
|
||||
typedef struct udp_conn {
|
||||
int fd;
|
||||
struct sockaddr_in peer_addr;
|
||||
syscall_rawconn_t raw; // syscall.RawConn 等价
|
||||
int linux_timestamping_enabled;
|
||||
latency_logger_t *logger;
|
||||
tx_timestamp_debug_logger_t *tx_debug_logger;
|
||||
uint32_t tx_packet_seq;
|
||||
// pending TX records for errqueue correlation
|
||||
struct udp_tx_pending *pending_tx;
|
||||
char node_role[16];
|
||||
char node_id[64];
|
||||
pthread_mutex_t write_mu;
|
||||
} udp_conn_t;
|
||||
|
||||
完整实现 Linux SO_TIMESTAMPING:
|
||||
- TX: SOF_TIMESTAMPING_TX_SCHED + SOF_TIMESTAMPING_TX_SOFTWARE + OPT_ID
|
||||
- RX: SOF_TIMESTAMPING_RX_SOFTWARE
|
||||
- errqueue 采集: recvmsg(MSG_ERRQUEUE) 读取 SCM_TIMESTAMPING 控制消息
|
||||
- TX timestamp debug logger: 记录 send_chunk / errqueue_event 到 JSONL
|
||||
- 对应 Go 文件: udp_linux.go, tx_timestamp_debug.go
|
||||
|
||||
同时为 KCP packet conn 实现类似的 timestamping:
|
||||
- 对应 Go 文件: kcp_packet_conn_linux.go, kcp_packet_debug.go
|
||||
- KCP 底层 UDP 包的 TX/RX kernel timestamp 记录
|
||||
|
||||
KCP session stats 完整实现:
|
||||
- session-level: conv, RTO, SRTT, SRTTVar 周期采样
|
||||
- 对应 Go 文件: kcp_session_stats.go
|
||||
|
||||
6. server_kcp_hub.h / server_kcp_hub.c
|
||||
|
||||
对应 Go: cmd/internal/server/kcp_hub.go
|
||||
|
||||
typedef struct {
|
||||
pthread_rwlock_t lock;
|
||||
// peer_id -> kcp_conn_t* 的哈希表
|
||||
struct peer_entry *peers; // 简单链表或哈希表
|
||||
int peer_count;
|
||||
latency_logger_t *logger;
|
||||
// relay 相关
|
||||
int relay_udp_fd;
|
||||
struct sockaddr_in relay_peer_addr;
|
||||
int relay_peer_known;
|
||||
} kcp_hub_t;
|
||||
|
||||
核心函数:
|
||||
- kcp_hub_t *kcp_hub_new(latency_logger_t *logger) — 创建 hub
|
||||
- int kcp_hub_serve_session(kcp_hub_t *hub, kcp_conn_t *conn) — 处理新会话(注册 + 转发循环)
|
||||
- void kcp_hub_set_relay(kcp_hub_t *hub, int udp_fd, struct sockaddr_in *peer_addr) — 配置 relay
|
||||
- int kcp_hub_serve_relay(kcp_hub_t *hub) — relay 接收循环
|
||||
- void kcp_hub_free(kcp_hub_t *hub) — 释放
|
||||
|
||||
服务端 KCP listener 实现:
|
||||
- 主 UDP socket 监听
|
||||
- 收到新 conv ID 时创建新 ikcpcb
|
||||
- 用 pthread 为每个 session 创建处理线程
|
||||
|
||||
7. server_udp_relay.h / server_udp_relay.c
|
||||
|
||||
对应 Go: cmd/internal/server/udp_relay.go
|
||||
|
||||
typedef struct {
|
||||
int downstream_fd; // 监听端
|
||||
int upstream_fd; // 连接到 hub D 的 UDP
|
||||
struct sockaddr_in upstream_addr;
|
||||
struct sockaddr_in client_addr;
|
||||
int client_known;
|
||||
pthread_mutex_t lock;
|
||||
} udp_relay_t;
|
||||
|
||||
核心函数:
|
||||
- udp_relay_t *udp_relay_new(int listen_fd, struct sockaddr_in *upstream_addr) — 创建
|
||||
- int udp_relay_serve(udp_relay_t *relay) — 双向转发循环(两个线程)
|
||||
- void udp_relay_close(udp_relay_t *relay) — 关闭
|
||||
|
||||
8. peer_kcp_client.h / peer_kcp_client.c
|
||||
|
||||
对应 Go: cmd/internal/peer/kcp_client.go + persist.go
|
||||
|
||||
typedef struct {
|
||||
char id[64];
|
||||
kcp_conn_t *conn;
|
||||
latency_logger_t *logger;
|
||||
uint64_t next_msg_id; // atomic
|
||||
pthread_mutex_t id_mu;
|
||||
} kcp_client_t;
|
||||
|
||||
核心函数:
|
||||
- kcp_client_t *kcp_client_dial(const char *server_addr, const char *peer_id, ...) — 连接并注册
|
||||
- int kcp_client_send_text(kcp_client_t *c, const char *to, const char *text) — 发文本
|
||||
- int kcp_client_send_file(kcp_client_t *c, const char *to, const char *path) — 发文件
|
||||
- int kcp_client_receive(kcp_client_t *c, message_t *msg) — 接收
|
||||
- int kcp_client_persist_message(kcp_client_t *c, const message_t *msg, const char *inbox_dir) — 持久化
|
||||
- void kcp_client_close(kcp_client_t *c) — 关闭
|
||||
|
||||
9. interactive.h / interactive.c
|
||||
|
||||
对应 Go: cmd/kcppeer/interactive.go
|
||||
|
||||
交互式命令行 REPL:
|
||||
- help / text <peer> <message> / file <peer> <path> / quit
|
||||
- int run_interactive_shell(kcp_client_t *client) — 运行交互循环
|
||||
|
||||
10. cmd/kcpserver.c
|
||||
|
||||
对应 Go: cmd/kcpserver/main.go
|
||||
|
||||
用法:
|
||||
kcpserver -listen 0.0.0.0:10909 # hub 模式
|
||||
kcpserver -mode relay -listen 0.0.0.0:10909 -relay-remote 172.21.32.15:10909 # relay 模式
|
||||
|
||||
- 解析命令行参数 (getopt)
|
||||
- hub 模式:创建 KCP listener -> 接受连接 -> kcp_hub_serve_session
|
||||
- relay 模式:创建 UDP relay -> udp_relay_serve
|
||||
|
||||
11. cmd/kcppeer.c
|
||||
|
||||
对应 Go: cmd/kcppeer/main.go
|
||||
|
||||
用法:
|
||||
kcppeer -id peer-a -server 172.21.32.15:10909 -relay-via 106.55.173.235:10909 -inbox-dir inbox/a
|
||||
kcppeer -id peer-b -server 81.70.156.140:10909 -inbox-dir inbox/b
|
||||
|
||||
- 连接到 KCP server
|
||||
- 启动接收线程
|
||||
- 运行交互式 shell 或单次发送
|
||||
|
||||
12. cmd/kcpping.c
|
||||
|
||||
对应 Go: cmd/kcpping/main.go + platform_linux.go
|
||||
|
||||
KCP ping 工具:
|
||||
- ping 模式: 发 JSON payload, 计算 RTT
|
||||
- echo 模式: 回弹文本消息
|
||||
- 统计: min/avg/max/p50/p95/p99/stddev
|
||||
|
||||
KCP session 多路复用实现(核心难点)
|
||||
|
||||
Go 版的 kcp-go 库在一个 UDP socket 上透明地多路复用多个 KCP session。C 版需要手动实现:
|
||||
|
||||
typedef struct kcp_listener {
|
||||
int udp_fd;
|
||||
pthread_t recv_thread;
|
||||
pthread_mutex_t sessions_lock;
|
||||
// conv -> kcp_session 的哈希表
|
||||
struct kcp_session_entry *sessions;
|
||||
// 新会话通知队列
|
||||
kcp_conn_t **accept_queue;
|
||||
int accept_queue_head, accept_queue_tail, accept_queue_cap;
|
||||
pthread_mutex_t accept_lock;
|
||||
pthread_cond_t accept_cond;
|
||||
} kcp_listener_t;
|
||||
|
||||
- kcp_listener_t *kcp_listen(const char *addr, const char *bind_device) — 创建 listener
|
||||
- kcp_conn_t *kcp_accept(kcp_listener_t *listener) — 阻塞等待新会话
|
||||
- 内部 recv_thread 循环读 UDP 包,解析前 4 字节 conv ID,分发到对应 ikcpcb
|
||||
- 未知 conv ID 时创建新 session 并放入 accept_queue
|
||||
|
||||
编译
|
||||
|
||||
CC = gcc
|
||||
CFLAGS = -Wall -Wextra -O2 -pthread -D_GNU_SOURCE
|
||||
LDFLAGS = -lpthread
|
||||
|
||||
SRCS = src/protocol.c src/transport_kcp.c src/transport_udp.c \
|
||||
src/server_kcp_hub.c src/server_udp_relay.c \
|
||||
src/peer_kcp_client.c src/latencylog.c src/interactive.c \
|
||||
src/cJSON.c third_party/kcp/ikcp.c
|
||||
|
||||
all: kcpserver kcppeer kcpping
|
||||
|
||||
kcpserver: cmd/kcpserver.c $(SRCS)
|
||||
$(CC) $(CFLAGS) -Iinclude -Ithird_party/kcp -o $@ $^ $(LDFLAGS
|
||||
|
||||
kcppeer: cmd/kcppeer.c $(SRCS)
|
||||
$(CC) $(CFLAGS) -Iinclude -Ithird_party/kcp -o $@ $^ $(LDFLAGS
|
||||
|
||||
kcpping: cmd/kcpping.c $(SRCS)
|
||||
$(CC) $(CFLAGS) -Iinclude -Ithird_party/kcp -o $@ $^ $(LDFLAGS
|
||||
|
||||
验证方法
|
||||
|
||||
1. 编译: make all 无错误无警告
|
||||
2. 单机测试:
|
||||
- 启动 hub: ./kcpserver -listen 0.0.0.0:10909
|
||||
- 启动 peer-a: ./kcppeer -id peer-a -server 127.0.0.1:10909 -inbox-dir inbox/a
|
||||
- 启动 peer-b: ./kcppeer -id peer-b -server 127.0.0.1:10909 -inbox-dir inbox/b
|
||||
- peer-b shell 中: text peer-a hello
|
||||
- 验证 peer-a 收到消息并落盘到 inbox/a/
|
||||
3. 跨机器 relay 测试:
|
||||
- D 机器: ./kcpserver -listen 0.0.0.0:10909
|
||||
- C 机器: ./kcpserver -mode relay -listen 0.0.0.0:10909 -relay-remote <D_IP>:10909
|
||||
- A 机器: ./kcppeer -id peer-a -server <D_IP>:10909 -relay-via <C_IP>:10909 -inbox-dir inbox/a
|
||||
- B 机器: ./kcppeer -id peer-b -server <D_IP>:10909 -inbox-dir inbox/b
|
||||
4. kcpping 测试:
|
||||
- echo 端: ./kcpping -id peer-a -server <IP>:10909 -echo
|
||||
- ping 端: ./kcpping -id peer-b -server <IP>:10909 -to peer-a -count 20 -interval 100
|
||||
|
||||
实现顺序
|
||||
|
||||
1. 集成第三方库 (ikcp, cJSON)
|
||||
2. protocol 模块 (消息编解码)
|
||||
3. latencylog 模块 (日志事件)
|
||||
4. transport_kcp 模块 (KCP 连接 + listener 多路复用)
|
||||
5. transport_udp 模块 (UDP 连接,简化 timestamping)
|
||||
6. server_udp_relay 模块 (C 节点 relay)
|
||||
7. server_kcp_hub 模块 (D 节点 hub)
|
||||
8. peer_kcp_client 模块 (A/B 节点 peer + persist)
|
||||
9. interactive 模块 (交互 shell)
|
||||
10. cmd/kcpserver.c 主程序
|
||||
11. cmd/kcppeer.c 主程序
|
||||
12. cmd/kcpping.c 主程序
|
||||
13. Makefile + README
|
||||
14. 编译测试
|
||||
|
||||
简化决策
|
||||
|
||||
- 不实现 TCP 传输: 去除 transport/tcp.go, server/hub.go(TCP版), peer/client.go(TCP版) 等 TCP 相关代码
|
||||
- 不写测试: 去除所有 _test.go 对应的测试代码
|
||||
- 完整实现 Linux timestamping: 完整移植 SO_TIMESTAMPING 的 TX/RX timestamp 采集,包括 errqueue TX sched/software timestamp 和 RX software
|
||||
timestamp,以及对应的 debug logger (KCPPacketDebugLogger, TXTimestampDebugLogger)
|
||||
- 完整实现 KCP session stats: 包括 session-level RTO/SRTT 采样和 JSONL 记录
|
||||
- 不实现 latency summary/chart: 不实现 latencysummary 工具和 HTML chart 生成(这是离线分析工具,不属于核心传输功能)
|
||||
- peer 哈希表: 使用简单链表实现,hub 连接数不多时性能足够
|
||||
|
||||
|
||||
# OmniSocketGo -> OmniSocketC 全量 UDP/KCP 迁移计划
|
||||
|
||||
## Summary
|
||||
- 在仓库新增 `c/` 子项目,作为 Linux-only、C11、`make` 驱动的独立实现;现有 Go 项目保留不动,作为行为对照。
|
||||
- 迁移范围按“全量 Go 对齐,但去掉 TCP 和离线 summary/chart”执行:保留 UDP/KCP 协议、纯 UDP 程序族、KCP 程序族、运行时 JSONL 日志、Linux timestamping、KCP packet debug、KCP session stats、以及 KCP hub-to-hub 内部 relay 能力。
|
||||
- 你当前草案需要修正的关键点有 5 个:`protocol_*frame(int fd, ...)` 不适合 KCP;KCP 必须补齐 `ikcp_update/check` 调度与 conv 多路复用;纯 UDP 程序族不能省略;`latencysummary`/HTML chart 本次不迁移;Makefile 需要修正链接目标并统一输出到 `c/bin/`。
|
||||
|
||||
## Public Interfaces
|
||||
- 新增二进制:`kcpserver`、`kcppeer`、`kcpping`、`udpserver`、`udppeer`、`udpping`、`udprelay`。
|
||||
- `kcpserver` 保留当前 Go 旗标语义:`-mode=hub|relay`、`-listen`、`-bind-device`、`-relay-remote`、deprecated relay aliases、`-latency-log`、`-kcp-ts-debug-log`、`-kcp-session-stats-log`、`-kcp-session-stats-interval`。
|
||||
- `kcppeer` 保留当前 Go 旗标语义:`-id`、`-server`、`-relay-via`、`-to`、`-text`、`-file`、`-bind-ip`、`-bind-device`、`-inbox-dir`、`-interactive`、`-latency-log`、`-kcp-ts-debug-log`、`-kcp-session-stats-log`、`-kcp-session-stats-interval`。
|
||||
- `kcpping`、`udpserver`、`udppeer`、`udpping`、`udprelay` 的参数与输出行为对齐当前 Go 入口;`udpserver` 默认不开 Linux timestamping,只有设置 `-tx-ts-debug-log` 时才启用。
|
||||
- 协议层改为内存接口,不再设计 fd 风格 API:`message_t`、datagram 编解码、stream frame 编解码、增量 frame feed。
|
||||
- 运行时日志层保留当前 JSON 字段和事件名;server/hub 继续作为 black-box relay,不新增端到端业务事件。
|
||||
- 内部网络 API 包括:`udp_conn_t`、`kcp_conn_t`、`kcp_listener_t`、`udp_hub_t`、`kcp_hub_t`、`udp_relay_t`、`udp_client_t`、`kcp_client_t`;KCP hub-to-hub relay 只做库级能力,不新增额外 CLI。
|
||||
|
||||
## Implementation Changes
|
||||
- 目录固定为 `c/include`、`c/src`、`c/cmd`、`c/third_party/{ikcp,cjson}`、`c/bin`、`c/README.md`、`c/Makefile`。
|
||||
- 第三方依赖直接 vendoring 到仓库:`ikcp` 用于 KCP 核心,`cJSON` 同时用于协议头、ping payload、运行时日志。
|
||||
- 协议规则完全保留:`text/file/register/error`、`ServerPeerID`、`8 MiB` 限制、UTF-8 校验、`file_name` 约束、`register/error` 来源与目标约束。
|
||||
- 线上 wire format 完全保留:UDP datagram 为 `[4B headerLen][header JSON][body]`;KCP stream 为 `[4B frameLen][4B headerLen][header JSON][body]`。
|
||||
- inbox 持久化完全保留:文本追加写 `messages.log` JSONL;文件落盘为 `<from>-<messageID>-<baseFileName>`。
|
||||
- UDP 传输层实现 connected/unconnected 两种发送模式,保留 register/forward 消息收发、Linux SO_TIMESTAMPING、TX errqueue 关联、JSONL debug 记录。
|
||||
- KCP 客户端连接采用“一连接一 UDP socket + 一 `ikcpcb` + 一接收线程 + 一 update 线程 + 一阻塞接收缓冲区/条件变量”模型。
|
||||
- KCP 服务端监听采用“单 listener UDP socket + 单 listener RX 线程 + conv->session 表 + accept 队列”模型;每个 session 拥有自己的 `ikcpcb`、update 线程、接收缓冲区和关闭状态,发送通过 listener 共享 socket 和写锁完成。
|
||||
- `kcpserver` 的 relay 模式保持为原始 UDP 端口转发,不解码协议;`udprelay` 同样保持透明字节转发。
|
||||
- 纯 UDP hub、KCP hub、双 peer、双 ping 工具、两套 interactive shell 全部对齐现有 Go 行为。
|
||||
- KCP hub 保留“先本地投递,再尝试 relay”的策略;未知目标、重复注册、已注册 peer 再发 `register/error`、过大 relay 消息等错误路径全部保留。
|
||||
- Linux 观测能力完整迁移:业务事件 JSONL、UDP TX debug、KCP packet debug、KCP session/process stats;不迁移 `latencysummary` 与 HTML chart。
|
||||
|
||||
## Acceptance
|
||||
- 在 Linux 上执行 `make` 能无缺失符号地构建 7 个二进制,并输出到 `c/bin/`。
|
||||
- 纯 UDP 冒烟通过:`udpserver` + 两个 `udppeer` 可双向收发文本和文件,`udpping` 的 echo/ping 正常。
|
||||
- 单 hub KCP 冒烟通过:`kcpserver` + 两个 `kcppeer` 可双向收发文本和文件,`kcpping` 的 echo/ping 正常。
|
||||
- README 目标拓扑通过:D 跑 `kcpserver -mode=hub`,C 跑 `kcpserver -mode=relay`,A 用 `-relay-via C` 连 D,B 直连 D,A/B 双向传输正常。
|
||||
- 全量 Go 对齐场景通过:两个 KCP hub 通过内部 raw UDP relay API 互通,跨 hub 文本、文件、错误回送行为与当前 Go 一致。
|
||||
- 负路径通过:重复注册被拒、未注册 UDP sender 被拒、未知目标返回 `error`、已注册 peer 发送 `register/error` 被拒、oversize relayed message 在实际 `WriteTo` 前被拒、`bind-ip`/`bind-device` 非法值在启动时失败。
|
||||
- 打开任一日志旗标后,生成的 JSONL 记录字段名、事件名、时间戳语义与现有运行时日志一致,并在 Linux 支持的情况下出现非零 kernel timestamps。
|
||||
|
||||
## Assumptions
|
||||
- 默认编译器为 `gcc`/`clang`,编译参数基线为 `-std=c11 -Wall -Wextra -O2 -pthread -D_GNU_SOURCE`。
|
||||
- 本次不迁移任何 Go 测试文件,也不为 C 版编写自动化测试;验证仅靠 Linux 构建和手工场景回归。
|
||||
- 本次不迁移 TCP 入口,也不迁移 `latencysummary`/HTML chart。
|
||||
- hub-to-hub relay 在 C 版中实现为内部库能力,保持与当前 Go 仓库一致的范围,不额外扩展新的公共命令。
|
||||
166
go/cmd/internal/latencylog/logger.go
Normal file
166
go/cmd/internal/latencylog/logger.go
Normal file
@@ -0,0 +1,166 @@
|
||||
package latencylog
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"omnisocketgo/cmd/internal/protocol"
|
||||
)
|
||||
|
||||
const (
|
||||
NodeRolePeer = "peer" //客户端节点
|
||||
NodeRoleServer = "server" //云端转发节点
|
||||
)
|
||||
|
||||
// 记录的消息事件的类型常量。
|
||||
const (
|
||||
EventAAppPrepBegin = "A_APP_PREP_BEGIN" // A 端应用开始准备这条消息
|
||||
EventATXSched = "A_TX_SCHED" // A 端进入 Linux qdisc 之前
|
||||
EventATXSoftware = "A_TX_SOFTWARE" // A 端即将交给网卡驱动
|
||||
EventATXHardware = "A_TX_HARDWARE" // A 端网卡真正发出到物理介质
|
||||
EventBRXHardware = "B_RX_HARDWARE" // B 端网卡真正从物理介质收到
|
||||
EventBRXSoftware = "B_RX_SOFTWARE" // B 端驱动把数据交给 Linux 接收栈
|
||||
EventBAppRecv = "B_APP_RECV" // B 端应用真正读到完整消息
|
||||
EventBPersistBegin = "B_PERSIST_BEGIN" // B 端开始写盘
|
||||
EventBPersistEnd = "B_PERSIST_END" // B 端写盘完成
|
||||
|
||||
EventSendHandoffBegin = "send_handoff_begin" // 调试事件:应用把消息交给传输层开始
|
||||
EventSendHandoffEnd = "send_handoff_end" // 调试事件:应用把消息交给传输层结束
|
||||
)
|
||||
|
||||
// Event 是一条时延时间戳日志记录。
|
||||
type Event struct {
|
||||
TsUnixNano int64 `json:"ts_unix_nano"`
|
||||
NodeRole string `json:"node_role"`
|
||||
NodeID string `json:"node_id"`
|
||||
Event string `json:"event"`
|
||||
MessageType protocol.MessageType `json:"message_type"`
|
||||
MessageID uint64 `json:"message_id"`
|
||||
From string `json:"from"`
|
||||
To string `json:"to"`
|
||||
FileName string `json:"file_name,omitempty"`
|
||||
BodySize int `json:"body_size"`
|
||||
}
|
||||
|
||||
// Logger 负责接收事件并将其写入外部介质。
|
||||
type Logger interface {
|
||||
LogEvent(Event) error
|
||||
}
|
||||
|
||||
// NoopLogger 是默认的空实现。
|
||||
type NoopLogger struct{}
|
||||
|
||||
// LogEvent 对空日志实现始终返回 nil。
|
||||
func (NoopLogger) LogEvent(Event) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// JSONLLogger 以 JSONL 形式追加写日志文件。
|
||||
type JSONLLogger struct {
|
||||
mu sync.Mutex
|
||||
closeOnce sync.Once
|
||||
closeErr error
|
||||
file *os.File
|
||||
}
|
||||
|
||||
// NewJSONLLogger 创建一个线程安全的 JSONL 文件日志器。
|
||||
func NewJSONLLogger(path string) (*JSONLLogger, error) {
|
||||
dir := filepath.Dir(path)
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
file, err := os.OpenFile(path, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o644)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &JSONLLogger{file: file}, nil
|
||||
}
|
||||
|
||||
// LogEvent 以单行 JSON 的形式追加一条事件。
|
||||
func (l *JSONLLogger) LogEvent(event Event) error {
|
||||
line, err := json.Marshal(event)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
l.mu.Lock()
|
||||
defer l.mu.Unlock()
|
||||
|
||||
if _, err := l.file.Write(append(line, '\n')); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Close 关闭底层文件;重复调用是安全的。
|
||||
func (l *JSONLLogger) Close() error {
|
||||
l.closeOnce.Do(func() {
|
||||
l.closeErr = l.file.Close()
|
||||
})
|
||||
|
||||
return l.closeErr
|
||||
}
|
||||
|
||||
// IsBusinessMessage 判断消息是否属于要参与 A-C-B 时延分析的业务消息。
|
||||
func IsBusinessMessage(msg protocol.Message) bool {
|
||||
switch msg.Type {
|
||||
case protocol.MessageTypeText, protocol.MessageTypeFile:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// NewMessageEvent 用当前 UTC 时间为一条业务消息构造事件。
|
||||
func NewMessageEvent(nodeRole, nodeID, eventName string, msg protocol.Message) Event {
|
||||
return NewMessageEventAt(time.Now().UTC().UnixNano(), nodeRole, nodeID, eventName, msg)
|
||||
}
|
||||
|
||||
// NewMessageEventAt 用指定的 UnixNano 时间为一条业务消息构造事件。
|
||||
func NewMessageEventAt(tsUnixNano int64, nodeRole, nodeID, eventName string, msg protocol.Message) Event {
|
||||
return Event{
|
||||
TsUnixNano: tsUnixNano,
|
||||
NodeRole: nodeRole,
|
||||
NodeID: nodeID,
|
||||
Event: eventName,
|
||||
MessageType: msg.Type,
|
||||
MessageID: msg.ID,
|
||||
From: msg.From,
|
||||
To: msg.To,
|
||||
FileName: msg.FileName,
|
||||
BodySize: len(msg.Body),
|
||||
}
|
||||
}
|
||||
|
||||
// LogBestEffort 写一条事件,失败时静默忽略,避免打断主收发流程。
|
||||
func LogBestEffort(logger Logger, event Event) {
|
||||
if logger == nil {
|
||||
return
|
||||
}
|
||||
|
||||
_ = logger.LogEvent(event)
|
||||
}
|
||||
|
||||
// LogMessageEvent 为业务消息构造并写入一条事件。
|
||||
func LogMessageEvent(logger Logger, nodeRole, nodeID, eventName string, msg protocol.Message) {
|
||||
if !IsBusinessMessage(msg) {
|
||||
return
|
||||
}
|
||||
|
||||
LogBestEffort(logger, NewMessageEvent(nodeRole, nodeID, eventName, msg))
|
||||
}
|
||||
|
||||
// LogMessageEventAt 为业务消息写入一条指定时间戳的事件。
|
||||
func LogMessageEventAt(logger Logger, nodeRole, nodeID, eventName string, tsUnixNano int64, msg protocol.Message) {
|
||||
if !IsBusinessMessage(msg) {
|
||||
return
|
||||
}
|
||||
|
||||
LogBestEffort(logger, NewMessageEventAt(tsUnixNano, nodeRole, nodeID, eventName, msg))
|
||||
}
|
||||
131
go/cmd/internal/latencylog/logger_test.go
Normal file
131
go/cmd/internal/latencylog/logger_test.go
Normal file
@@ -0,0 +1,131 @@
|
||||
package latencylog
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"omnisocketgo/cmd/internal/protocol"
|
||||
)
|
||||
|
||||
func TestJSONLLoggerWritesOneEventPerLine(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "latency.jsonl")
|
||||
|
||||
logger, err := NewJSONLLogger(path)
|
||||
if err != nil {
|
||||
t.Fatalf("NewJSONLLogger() error = %v", err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
_ = logger.Close()
|
||||
})
|
||||
|
||||
event := Event{
|
||||
TsUnixNano: 123,
|
||||
NodeRole: NodeRolePeer,
|
||||
NodeID: "peer-a",
|
||||
Event: EventAAppPrepBegin,
|
||||
MessageType: protocol.MessageTypeText,
|
||||
MessageID: 1,
|
||||
From: "peer-a",
|
||||
To: "peer-b",
|
||||
BodySize: 5,
|
||||
}
|
||||
if err := logger.LogEvent(event); err != nil {
|
||||
t.Fatalf("LogEvent() error = %v", err)
|
||||
}
|
||||
|
||||
file, err := os.Open(path)
|
||||
if err != nil {
|
||||
t.Fatalf("os.Open() error = %v", err)
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
scanner := bufio.NewScanner(file)
|
||||
if !scanner.Scan() {
|
||||
t.Fatal("expected one JSONL line, got none")
|
||||
}
|
||||
|
||||
var got Event
|
||||
if err := json.Unmarshal(scanner.Bytes(), &got); err != nil {
|
||||
t.Fatalf("json.Unmarshal() error = %v", err)
|
||||
}
|
||||
if got != event {
|
||||
t.Fatalf("event mismatch: got %+v want %+v", got, event)
|
||||
}
|
||||
if scanner.Scan() {
|
||||
t.Fatal("expected exactly one JSONL line")
|
||||
}
|
||||
if err := scanner.Err(); err != nil {
|
||||
t.Fatalf("scanner.Err() = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestJSONLLoggerHandlesConcurrentWrites(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "latency.jsonl")
|
||||
|
||||
logger, err := NewJSONLLogger(path)
|
||||
if err != nil {
|
||||
t.Fatalf("NewJSONLLogger() error = %v", err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
_ = logger.Close()
|
||||
})
|
||||
|
||||
const total = 32
|
||||
|
||||
var wg sync.WaitGroup
|
||||
for i := 0; i < total; i++ {
|
||||
i := i
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
|
||||
err := logger.LogEvent(Event{
|
||||
TsUnixNano: int64(i + 1),
|
||||
NodeRole: NodeRoleServer,
|
||||
NodeID: protocol.ServerPeerID,
|
||||
Event: EventBAppRecv,
|
||||
MessageType: protocol.MessageTypeFile,
|
||||
MessageID: uint64(i + 1),
|
||||
From: "peer-a",
|
||||
To: "peer-b",
|
||||
FileName: "payload.bin",
|
||||
BodySize: 3,
|
||||
})
|
||||
if err != nil {
|
||||
t.Errorf("LogEvent() error = %v", err)
|
||||
}
|
||||
}()
|
||||
}
|
||||
wg.Wait()
|
||||
|
||||
file, err := os.Open(path)
|
||||
if err != nil {
|
||||
t.Fatalf("os.Open() error = %v", err)
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
scanner := bufio.NewScanner(file)
|
||||
var count int
|
||||
seen := make(map[uint64]bool, total)
|
||||
for scanner.Scan() {
|
||||
var event Event
|
||||
if err := json.Unmarshal(scanner.Bytes(), &event); err != nil {
|
||||
t.Fatalf("json.Unmarshal() error = %v", err)
|
||||
}
|
||||
count++
|
||||
seen[event.MessageID] = true
|
||||
}
|
||||
if err := scanner.Err(); err != nil {
|
||||
t.Fatalf("scanner.Err() = %v", err)
|
||||
}
|
||||
if count != total {
|
||||
t.Fatalf("line count = %d, want %d", count, total)
|
||||
}
|
||||
if len(seen) != total {
|
||||
t.Fatalf("unique message count = %d, want %d", len(seen), total)
|
||||
}
|
||||
}
|
||||
457
go/cmd/internal/latencylog/summary.go
Normal file
457
go/cmd/internal/latencylog/summary.go
Normal file
@@ -0,0 +1,457 @@
|
||||
package latencylog
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
|
||||
"omnisocketgo/cmd/internal/protocol"
|
||||
)
|
||||
|
||||
// Summary 是针对单条消息的时延的规则列表。
|
||||
var requiredTimestampNames = []string{
|
||||
EventAAppPrepBegin, // A 端应用开始准备这条消息
|
||||
EventATXSched, // A 端进入 Linux qdisc 之前
|
||||
EventATXSoftware, // A 端即将交给网卡驱动
|
||||
EventBRXSoftware, // B 端网卡驱动把数据交给 Linux 接收栈
|
||||
EventBAppRecv, // B 端应用真正读到完整消息
|
||||
EventBPersistEnd, // B 端写盘完成
|
||||
}
|
||||
|
||||
// Summary 是针对单条消息的时延整理结果。
|
||||
type Summary struct {
|
||||
MessageType protocol.MessageType `json:"message_type"` //消息类型
|
||||
MessageID uint64 `json:"message_id"` //消息ID
|
||||
From string `json:"from"` //发送方
|
||||
To string `json:"to"` //接收方
|
||||
FileName string `json:"file_name,omitempty"` //文件名(仅文件消息)
|
||||
BodySize int `json:"body_size"` //消息体大小(字节数)
|
||||
Timestamps map[string]int64 `json:"timestamps"` //事件时间戳,key 是事件名称,value 是 UnixNano 时间戳
|
||||
|
||||
AProcessingLatencyNS *int64 `json:"a_processing_latency_ns,omitempty"` // A 处理时延:A_TX_SCHED - A_APP_PREP_BEGIN
|
||||
AQueueLatencyNS *int64 `json:"a_queue_latency_ns,omitempty"` // A 排队时延:A_TX_SOFTWARE - A_TX_SCHED
|
||||
ABTransportPropagationNS *int64 `json:"a_b_transport_propagation_ns,omitempty"` // A-B 传输+传播时延近似:B_APP_RECV - A_TX_SOFTWARE
|
||||
BKernelReceivePathLatencyNS *int64 `json:"b_kernel_receive_path_latency_ns,omitempty"` // B 内核接收路径近似:B_APP_RECV - B_RX_SOFTWARE
|
||||
BProcessingLatencyNS *int64 `json:"b_processing_latency_ns,omitempty"` // B 处理时延:B_PERSIST_END - B_APP_RECV
|
||||
EndToEndLatencyNS *int64 `json:"end_to_end_latency_ns,omitempty"` // 端到端时延:B_PERSIST_END - A_APP_PREP_BEGIN
|
||||
AProcessingBitrateBPS *float64 `json:"a_processing_bitrate_bps,omitempty"` // A 处理阶段近似比特率:(BodySize * 8) / A 处理时延(秒)
|
||||
ABTransportPropagationBitrateBPS *float64 `json:"a_b_transport_propagation_bitrate_bps,omitempty"` // A-B 传输+传播阶段近似比特率:(BodySize * 8) / A-B 传输+传播时延(秒)
|
||||
EndToEndBitrateBPS *float64 `json:"end_to_end_bitrate_bps,omitempty"` // 端到端近似比特率:(BodySize * 8) / 端到端时延(秒)
|
||||
ApproxRTTNS *int64 `json:"approx_rtt_ns,omitempty"` // 近似 RTT:首条反向应答的 B_APP_RECV - 当前请求的 A_TX_SOFTWARE
|
||||
MissingTimestamps []string `json:"missing_timestamps,omitempty"` // 缺失的时间戳列表,包含 requiredTimestampNames 中但在原始事件中没有的事件名称
|
||||
}
|
||||
|
||||
// LoadEventsFromFiles 从JSONL 原始日志文件中加载事件。
|
||||
type messageKey struct {
|
||||
MessageType protocol.MessageType //消息类型
|
||||
MessageID uint64 //消息ID
|
||||
From string //发送方
|
||||
To string //接收方
|
||||
}
|
||||
|
||||
// LoadEventsFromFiles 从多个 JSONL 原始日志文件中加载事件。
|
||||
func LoadEventsFromFiles(paths []string) ([]Event, error) {
|
||||
var events []Event
|
||||
for _, path := range paths {
|
||||
fileEvents, err := LoadEventsFromFile(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
events = append(events, fileEvents...)
|
||||
}
|
||||
|
||||
return events, nil
|
||||
}
|
||||
|
||||
// LoadEventsFromFilesWithSharedMaxOffset 从多个 JSONL 原始日志文件中加载事件,
|
||||
// 并按每个输入文件的最大 message_id 计算共享截断点。
|
||||
func LoadEventsFromFilesWithSharedMaxOffset(paths []string, sharedMaxOffset uint64) ([]Event, *uint64, error) {
|
||||
eventsByFile := make([][]Event, 0, len(paths))
|
||||
var minMaxMessageID uint64
|
||||
hasSharedMax := false
|
||||
|
||||
for _, path := range paths {
|
||||
fileEvents, err := LoadEventsFromFile(path)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
eventsByFile = append(eventsByFile, fileEvents)
|
||||
|
||||
fileMaxMessageID, ok := maxBusinessMessageID(fileEvents)
|
||||
if !ok {
|
||||
return nil, nil, nil
|
||||
}
|
||||
if !hasSharedMax || fileMaxMessageID < minMaxMessageID {
|
||||
minMaxMessageID = fileMaxMessageID
|
||||
hasSharedMax = true
|
||||
}
|
||||
}
|
||||
|
||||
if !hasSharedMax {
|
||||
return nil, nil, nil
|
||||
}
|
||||
|
||||
cutoff, ok := subtractUint64(minMaxMessageID, sharedMaxOffset)
|
||||
if !ok {
|
||||
return []Event{}, nil, nil
|
||||
}
|
||||
|
||||
var events []Event
|
||||
for _, fileEvents := range eventsByFile {
|
||||
events = append(events, filterEventsByMaxMessageID(fileEvents, cutoff)...)
|
||||
}
|
||||
|
||||
return events, &cutoff, nil
|
||||
}
|
||||
|
||||
// LoadEventsFromFile 从单个 JSONL 原始日志文件中加载事件。
|
||||
func LoadEventsFromFile(path string) ([]Event, error) {
|
||||
file, err := os.Open(path)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("latencylog: open raw log %s: %w", path, err)
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
var events []Event
|
||||
scanner := bufio.NewScanner(file)
|
||||
for scanner.Scan() {
|
||||
if len(scanner.Bytes()) == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
var event Event
|
||||
if err := json.Unmarshal(scanner.Bytes(), &event); err != nil { //解析 JSONL 行失败,返回错误
|
||||
return nil, fmt.Errorf("latencylog: decode event from %s: %w", path, err)
|
||||
}
|
||||
events = append(events, event)
|
||||
}
|
||||
if err := scanner.Err(); err != nil {
|
||||
return nil, fmt.Errorf("latencylog: scan raw log %s: %w", path, err)
|
||||
}
|
||||
|
||||
return events, nil
|
||||
}
|
||||
|
||||
// SummarizeEvents 将原始事件整理成按消息分组的时延结果。
|
||||
func SummarizeEvents(events []Event) []Summary {
|
||||
grouped := make(map[messageKey]*Summary)
|
||||
|
||||
for _, event := range events {
|
||||
if !IsBusinessEvent(event) {
|
||||
continue
|
||||
}
|
||||
|
||||
key := messageKey{
|
||||
MessageType: event.MessageType,
|
||||
MessageID: event.MessageID,
|
||||
From: event.From,
|
||||
To: event.To,
|
||||
}
|
||||
|
||||
summary, ok := grouped[key]
|
||||
if !ok {
|
||||
summary = &Summary{
|
||||
MessageType: event.MessageType,
|
||||
MessageID: event.MessageID,
|
||||
From: event.From,
|
||||
To: event.To,
|
||||
FileName: event.FileName,
|
||||
BodySize: event.BodySize,
|
||||
Timestamps: make(map[string]int64),
|
||||
}
|
||||
grouped[key] = summary
|
||||
}
|
||||
|
||||
if summary.FileName == "" {
|
||||
summary.FileName = event.FileName
|
||||
}
|
||||
if event.BodySize > 0 {
|
||||
summary.BodySize = event.BodySize
|
||||
}
|
||||
|
||||
if existing, exists := summary.Timestamps[event.Event]; !exists || event.TsUnixNano < existing {
|
||||
summary.Timestamps[event.Event] = event.TsUnixNano
|
||||
}
|
||||
}
|
||||
|
||||
summaryPointers := make([]*Summary, 0, len(grouped))
|
||||
for _, summary := range grouped {
|
||||
completeSummary(summary) //补全时延指标和缺失时间戳信息
|
||||
summaryPointers = append(summaryPointers, summary)
|
||||
}
|
||||
assignApproxRTTs(summaryPointers)
|
||||
|
||||
summaries := make([]Summary, 0, len(summaryPointers))
|
||||
for _, summary := range summaryPointers {
|
||||
summaries = append(summaries, *summary)
|
||||
}
|
||||
//对整理结果进行排序,先按发送方、再按接收方、再按消息 ID、最后按消息类型排序,保证输出的稳定性和可读性。
|
||||
sort.Slice(summaries, func(i, j int) bool {
|
||||
if summaries[i].From != summaries[j].From {
|
||||
return summaries[i].From < summaries[j].From
|
||||
}
|
||||
if summaries[i].To != summaries[j].To {
|
||||
return summaries[i].To < summaries[j].To
|
||||
}
|
||||
if summaries[i].MessageID != summaries[j].MessageID {
|
||||
return summaries[i].MessageID < summaries[j].MessageID
|
||||
}
|
||||
return summaries[i].MessageType < summaries[j].MessageType
|
||||
})
|
||||
|
||||
return summaries
|
||||
}
|
||||
|
||||
// WriteSummariesJSONL 将整理结果写成 JSONL 汇总文件。
|
||||
func WriteSummariesJSONL(path string, summaries []Summary) error {
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||||
return fmt.Errorf("latencylog: create summary dir for %s: %w", path, err)
|
||||
}
|
||||
|
||||
file, err := os.OpenFile(path, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o644)
|
||||
if err != nil {
|
||||
return fmt.Errorf("latencylog: open summary file %s: %w", path, err)
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
writer := bufio.NewWriter(file)
|
||||
for _, summary := range summaries { //将每条整理结果编码成 JSONL 行并写入文件
|
||||
line, err := json.Marshal(summary)
|
||||
if err != nil {
|
||||
return fmt.Errorf("latencylog: encode summary for message %d: %w", summary.MessageID, err)
|
||||
}
|
||||
if _, err := writer.Write(append(line, '\n')); err != nil {
|
||||
return fmt.Errorf("latencylog: write summary file %s: %w", path, err)
|
||||
}
|
||||
}
|
||||
|
||||
if err := writer.Flush(); err != nil { //将缓冲区内容写入文件
|
||||
return fmt.Errorf("latencylog: flush summary file %s: %w", path, err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// completeSummary 根据事件时间戳计算时延指标,并找出缺失的时间戳。
|
||||
func completeSummary(summary *Summary) {
|
||||
summary.MissingTimestamps = missingTimestampNames(summary.Timestamps)
|
||||
|
||||
if value := subtractIfPresent(summary.Timestamps, EventATXSched, EventAAppPrepBegin); value != nil {
|
||||
summary.AProcessingLatencyNS = value
|
||||
}
|
||||
if value := subtractIfPresent(summary.Timestamps, EventATXSoftware, EventATXSched); value != nil {
|
||||
summary.AQueueLatencyNS = value
|
||||
}
|
||||
if value := subtractIfPresent(summary.Timestamps, EventBAppRecv, EventATXSoftware); value != nil {
|
||||
summary.ABTransportPropagationNS = value
|
||||
}
|
||||
if value := subtractIfPresent(summary.Timestamps, EventBAppRecv, EventBRXSoftware); value != nil {
|
||||
summary.BKernelReceivePathLatencyNS = value
|
||||
}
|
||||
if value := subtractIfPresent(summary.Timestamps, EventBPersistEnd, EventBAppRecv); value != nil {
|
||||
summary.BProcessingLatencyNS = value
|
||||
}
|
||||
if value := subtractIfPresent(summary.Timestamps, EventBPersistEnd, EventAAppPrepBegin); value != nil {
|
||||
summary.EndToEndLatencyNS = value
|
||||
}
|
||||
|
||||
summary.AProcessingBitrateBPS = calculateBitrateBPS(summary.BodySize, summary.AProcessingLatencyNS)
|
||||
summary.ABTransportPropagationBitrateBPS = calculateBitrateBPS(summary.BodySize, summary.ABTransportPropagationNS)
|
||||
summary.EndToEndBitrateBPS = calculateBitrateBPS(summary.BodySize, summary.EndToEndLatencyNS)
|
||||
}
|
||||
|
||||
type routeKey struct {
|
||||
From string
|
||||
To string
|
||||
}
|
||||
|
||||
func assignApproxRTTs(summaries []*Summary) {
|
||||
grouped := make(map[routeKey][]*Summary)
|
||||
for _, summary := range summaries {
|
||||
grouped[routeKey{From: summary.From, To: summary.To}] = append(grouped[routeKey{From: summary.From, To: summary.To}], summary)
|
||||
}
|
||||
|
||||
for key, requests := range grouped {
|
||||
replies := grouped[routeKey{From: key.To, To: key.From}]
|
||||
if len(replies) == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
assignApproxRTTsForRoute(
|
||||
sortSummariesByTimestamp(requests, EventBAppRecv),
|
||||
sortSummariesByTimestamp(replies, EventATXSoftware),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
func assignApproxRTTsForRoute(requests, replies []*Summary) {
|
||||
replyIndex := 0
|
||||
for _, request := range requests {
|
||||
requestReceivedAtResponder, ok := request.Timestamps[EventBAppRecv]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
|
||||
for replyIndex < len(replies) {
|
||||
reply := replies[replyIndex]
|
||||
replySentAtResponder, ok := reply.Timestamps[EventATXSoftware]
|
||||
if !ok {
|
||||
replyIndex++
|
||||
continue
|
||||
}
|
||||
if replySentAtResponder < requestReceivedAtResponder {
|
||||
replyIndex++
|
||||
continue
|
||||
}
|
||||
|
||||
if value := subtractSummaryTimestamps(reply, EventBAppRecv, request, EventATXSoftware); value != nil {
|
||||
request.ApproxRTTNS = value
|
||||
}
|
||||
replyIndex++
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func sortSummariesByTimestamp(summaries []*Summary, eventName string) []*Summary {
|
||||
sorted := append([]*Summary(nil), summaries...)
|
||||
sort.SliceStable(sorted, func(i, j int) bool {
|
||||
leftTS, leftOK := sorted[i].Timestamps[eventName]
|
||||
rightTS, rightOK := sorted[j].Timestamps[eventName]
|
||||
switch {
|
||||
case leftOK && rightOK:
|
||||
if leftTS != rightTS {
|
||||
return leftTS < rightTS
|
||||
}
|
||||
case leftOK:
|
||||
return true
|
||||
case rightOK:
|
||||
return false
|
||||
}
|
||||
|
||||
if sorted[i].MessageID != sorted[j].MessageID {
|
||||
return sorted[i].MessageID < sorted[j].MessageID
|
||||
}
|
||||
if sorted[i].From != sorted[j].From {
|
||||
return sorted[i].From < sorted[j].From
|
||||
}
|
||||
if sorted[i].To != sorted[j].To {
|
||||
return sorted[i].To < sorted[j].To
|
||||
}
|
||||
|
||||
return sorted[i].MessageType < sorted[j].MessageType
|
||||
})
|
||||
return sorted
|
||||
}
|
||||
|
||||
// 返回 requiredTimestampNames 中哪些在给定的 timestamps 中缺失。
|
||||
func missingTimestampNames(timestamps map[string]int64) []string {
|
||||
var missing []string
|
||||
for _, name := range requiredTimestampNames {
|
||||
if _, ok := timestamps[name]; !ok {
|
||||
missing = append(missing, name)
|
||||
}
|
||||
}
|
||||
|
||||
return missing
|
||||
}
|
||||
|
||||
// 如果 timestamps 中同时存在 endName 和 beginName,则返回它们的差值;否则返回 nil。
|
||||
func subtractIfPresent(timestamps map[string]int64, endName, beginName string) *int64 {
|
||||
end, ok := timestamps[endName]
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
begin, ok := timestamps[beginName]
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
|
||||
value := end - begin
|
||||
return &value
|
||||
}
|
||||
|
||||
func subtractSummaryTimestamps(endSummary *Summary, endName string, beginSummary *Summary, beginName string) *int64 {
|
||||
end, ok := endSummary.Timestamps[endName]
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
begin, ok := beginSummary.Timestamps[beginName]
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
|
||||
value := end - begin
|
||||
return &value
|
||||
}
|
||||
|
||||
// 除法函数,如果 bodySize <= 0 或 latencyNS 不存在或 <= 0,则返回 nil;否则返回 bodySize / latencyNS 的结果。
|
||||
func calculateBitrateBPS(bodySize int, latencyNS *int64) *float64 {
|
||||
if bodySize <= 0 || latencyNS == nil || *latencyNS <= 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
value := float64(bodySize) * 8 * 1_000_000_000 / float64(*latencyNS)
|
||||
return &value
|
||||
}
|
||||
|
||||
// 最大 message_id 计算函数
|
||||
func maxBusinessMessageID(events []Event) (uint64, bool) {
|
||||
var maxMessageID uint64
|
||||
hasBusinessMessage := false
|
||||
|
||||
for _, event := range events {
|
||||
if !IsBusinessEvent(event) {
|
||||
continue
|
||||
}
|
||||
if !hasBusinessMessage || event.MessageID > maxMessageID {
|
||||
maxMessageID = event.MessageID
|
||||
hasBusinessMessage = true
|
||||
}
|
||||
}
|
||||
|
||||
return maxMessageID, hasBusinessMessage
|
||||
}
|
||||
|
||||
// 根据 message_id 截断事件列表的函数
|
||||
func filterEventsByMaxMessageID(events []Event, maxMessageID uint64) []Event {
|
||||
filtered := make([]Event, 0, len(events))
|
||||
for _, event := range events {
|
||||
if event.MessageID > maxMessageID {
|
||||
continue
|
||||
}
|
||||
filtered = append(filtered, event)
|
||||
}
|
||||
|
||||
return filtered
|
||||
}
|
||||
|
||||
func subtractUint64(value, offset uint64) (uint64, bool) {
|
||||
if offset > value {
|
||||
return 0, false
|
||||
}
|
||||
|
||||
return value - offset, true
|
||||
}
|
||||
|
||||
// 判断事件是否是业务相关的时延事件(其中一项)
|
||||
func IsBusinessEvent(event Event) bool {
|
||||
switch event.Event {
|
||||
case EventAAppPrepBegin,
|
||||
EventATXSched,
|
||||
EventATXSoftware,
|
||||
EventATXHardware,
|
||||
EventBRXHardware,
|
||||
EventBRXSoftware,
|
||||
EventBAppRecv,
|
||||
EventBPersistBegin,
|
||||
EventBPersistEnd:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
498
go/cmd/internal/latencylog/summary_chart.go
Normal file
498
go/cmd/internal/latencylog/summary_chart.go
Normal file
@@ -0,0 +1,498 @@
|
||||
package latencylog
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"fmt"
|
||||
"html/template"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"omnisocketgo/cmd/internal/protocol"
|
||||
)
|
||||
|
||||
const summaryChartHTMLTemplate = `<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Latency Summary Chart</title>
|
||||
<style>
|
||||
:root {
|
||||
color-scheme: light;
|
||||
--bg: #f6f7fb;
|
||||
--panel: #ffffff;
|
||||
--text: #172033;
|
||||
--muted: #60708a;
|
||||
--border: #d9dfeb;
|
||||
--track: #e8edf5;
|
||||
--a-proc: #3b82f6;
|
||||
--a-queue: #14b8a6;
|
||||
--transport: #f59e0b;
|
||||
--b-proc: #22c55e;
|
||||
--unknown: #94a3b8;
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: "Segoe UI", "Helvetica Neue", Arial, sans-serif;
|
||||
background: linear-gradient(180deg, #eef3ff 0%, var(--bg) 220px);
|
||||
color: var(--text);
|
||||
}
|
||||
main {
|
||||
max-width: 1120px;
|
||||
margin: 0 auto;
|
||||
padding: 32px 20px 48px;
|
||||
}
|
||||
h1 {
|
||||
margin: 0 0 8px;
|
||||
font-size: 32px;
|
||||
line-height: 1.1;
|
||||
}
|
||||
.intro {
|
||||
color: var(--muted);
|
||||
margin: 0 0 24px;
|
||||
font-size: 15px;
|
||||
}
|
||||
.stats {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
|
||||
gap: 12px;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
.stat {
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 14px;
|
||||
padding: 14px 16px;
|
||||
box-shadow: 0 10px 30px rgba(23, 32, 51, 0.06);
|
||||
}
|
||||
.stat-label {
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
.stat-value {
|
||||
font-size: 22px;
|
||||
font-weight: 700;
|
||||
}
|
||||
.legend {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
margin-bottom: 20px;
|
||||
color: var(--muted);
|
||||
font-size: 13px;
|
||||
}
|
||||
.legend-item {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
.swatch {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border-radius: 999px;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
.rows {
|
||||
display: grid;
|
||||
gap: 14px;
|
||||
}
|
||||
.row {
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 16px;
|
||||
padding: 16px;
|
||||
box-shadow: 0 12px 30px rgba(23, 32, 51, 0.05);
|
||||
}
|
||||
.row-head {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: baseline;
|
||||
gap: 12px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.row-title {
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
margin: 0;
|
||||
}
|
||||
.row-e2e {
|
||||
font-size: 16px;
|
||||
font-weight: 700;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.row-meta {
|
||||
color: var(--muted);
|
||||
font-size: 13px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.ratio-list {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
margin: -2px 0 12px;
|
||||
}
|
||||
.ratio-pill {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 6px 10px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid var(--border);
|
||||
background: #f7f9fc;
|
||||
color: var(--text);
|
||||
font-size: 12px;
|
||||
line-height: 1.2;
|
||||
}
|
||||
.bar {
|
||||
height: 24px;
|
||||
display: flex;
|
||||
overflow: hidden;
|
||||
background: var(--track);
|
||||
border-radius: 999px;
|
||||
}
|
||||
.segment {
|
||||
height: 100%;
|
||||
}
|
||||
.segment:first-child {
|
||||
border-top-left-radius: 999px;
|
||||
border-bottom-left-radius: 999px;
|
||||
}
|
||||
.segment:last-child {
|
||||
border-top-right-radius: 999px;
|
||||
border-bottom-right-radius: 999px;
|
||||
}
|
||||
.row-legend {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px 14px;
|
||||
margin-top: 10px;
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
}
|
||||
.missing {
|
||||
margin-top: 10px;
|
||||
color: #a16207;
|
||||
font-size: 12px;
|
||||
}
|
||||
.empty {
|
||||
color: var(--muted);
|
||||
font-style: italic;
|
||||
padding: 24px 16px;
|
||||
background: var(--panel);
|
||||
border: 1px dashed var(--border);
|
||||
border-radius: 16px;
|
||||
text-align: center;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<main>
|
||||
<h1>Latency Summary</h1>
|
||||
<p class="intro">A simple per-message end-to-end latency chart generated from summarized JSONL records.</p>
|
||||
|
||||
<section class="stats">
|
||||
<div class="stat">
|
||||
<div class="stat-label">Messages</div>
|
||||
<div class="stat-value">{{.TotalMessages}}</div>
|
||||
</div>
|
||||
<div class="stat">
|
||||
<div class="stat-label">With End-To-End</div>
|
||||
<div class="stat-value">{{.MessagesWithEndToEnd}}</div>
|
||||
</div>
|
||||
<div class="stat">
|
||||
<div class="stat-label">Average End-To-End</div>
|
||||
<div class="stat-value">{{.AverageEndToEnd}}</div>
|
||||
</div>
|
||||
<div class="stat">
|
||||
<div class="stat-label">Max End-To-End</div>
|
||||
<div class="stat-value">{{.MaxEndToEnd}}</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="legend">
|
||||
{{range .Legend}}
|
||||
<span class="legend-item">
|
||||
<span class="swatch" style="background: {{.Color}}"></span>
|
||||
<span>{{.Label}}</span>
|
||||
</span>
|
||||
{{end}}
|
||||
</section>
|
||||
|
||||
{{if .Rows}}
|
||||
<section class="rows">
|
||||
{{range .Rows}}
|
||||
<article class="row">
|
||||
<div class="row-head">
|
||||
<h2 class="row-title">{{.Title}}</h2>
|
||||
<div class="row-e2e">{{.EndToEnd}}</div>
|
||||
</div>
|
||||
<div class="row-meta">{{.Subtitle}}</div>
|
||||
<div class="row-meta">{{.ApproxRTT}}</div>
|
||||
{{if .RatioMetrics}}
|
||||
<div class="ratio-list">
|
||||
{{range .RatioMetrics}}
|
||||
<span class="ratio-pill">{{.Label}} {{.Value}}</span>
|
||||
{{end}}
|
||||
</div>
|
||||
{{end}}
|
||||
<div class="bar">
|
||||
{{range .Segments}}
|
||||
<div class="segment" style="width: {{printf "%.4f" .WidthPercent}}%; background: {{.Color}}" title="{{.Label}}: {{.Value}}"></div>
|
||||
{{end}}
|
||||
</div>
|
||||
{{if .Segments}}
|
||||
<div class="row-legend">
|
||||
{{range .Segments}}
|
||||
<span class="legend-item">
|
||||
<span class="swatch" style="background: {{.Color}}"></span>
|
||||
<span>{{.Label}} {{.Value}}</span>
|
||||
</span>
|
||||
{{end}}
|
||||
</div>
|
||||
{{end}}
|
||||
{{if .MissingTimestamps}}
|
||||
<div class="missing">Missing timestamps: {{.MissingTimestamps}}</div>
|
||||
{{end}}
|
||||
</article>
|
||||
{{end}}
|
||||
</section>
|
||||
{{else}}
|
||||
<section class="empty">No summarized messages were available for chart rendering.</section>
|
||||
{{end}}
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
`
|
||||
|
||||
type summaryChartPage struct {
|
||||
TotalMessages int
|
||||
MessagesWithEndToEnd int
|
||||
AverageEndToEnd string
|
||||
MaxEndToEnd string
|
||||
Legend []summaryChartLegendItem
|
||||
Rows []summaryChartRow
|
||||
}
|
||||
|
||||
type summaryChartLegendItem struct {
|
||||
Label string
|
||||
Color string
|
||||
}
|
||||
|
||||
type summaryChartRow struct {
|
||||
Title string
|
||||
Subtitle string
|
||||
EndToEnd string
|
||||
ApproxRTT string
|
||||
MissingTimestamps string
|
||||
RatioMetrics []summaryChartValue
|
||||
Segments []summaryChartSegment
|
||||
}
|
||||
|
||||
type summaryChartSegment struct {
|
||||
Label string
|
||||
Value string
|
||||
Color string
|
||||
WidthPercent float64
|
||||
}
|
||||
|
||||
type summaryChartValue struct {
|
||||
Label string
|
||||
Value string
|
||||
}
|
||||
|
||||
type summaryChartSegmentMetric struct {
|
||||
label string
|
||||
value *int64
|
||||
color string
|
||||
}
|
||||
|
||||
// WriteSummariesHTMLChart 将整理结果写成一个可直接在浏览器中打开的简单 HTML 图表。
|
||||
func WriteSummariesHTMLChart(path string, summaries []Summary) error {
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||||
return fmt.Errorf("latencylog: create chart dir for %s: %w", path, err)
|
||||
}
|
||||
|
||||
file, err := os.OpenFile(path, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o644)
|
||||
if err != nil {
|
||||
return fmt.Errorf("latencylog: open chart file %s: %w", path, err)
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
page := buildSummaryChartPage(summaries)
|
||||
tmpl, err := template.New("summary-chart").Parse(summaryChartHTMLTemplate)
|
||||
if err != nil {
|
||||
return fmt.Errorf("latencylog: parse chart template: %w", err)
|
||||
}
|
||||
|
||||
writer := bufio.NewWriter(file)
|
||||
if err := tmpl.Execute(writer, page); err != nil {
|
||||
return fmt.Errorf("latencylog: render chart %s: %w", path, err)
|
||||
}
|
||||
if err := writer.Flush(); err != nil {
|
||||
return fmt.Errorf("latencylog: flush chart %s: %w", path, err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func buildSummaryChartPage(summaries []Summary) summaryChartPage {
|
||||
page := summaryChartPage{
|
||||
TotalMessages: len(summaries),
|
||||
Legend: []summaryChartLegendItem{
|
||||
{Label: "A processing", Color: "var(--a-proc)"},
|
||||
{Label: "A queue", Color: "var(--a-queue)"},
|
||||
{Label: "A-B transport + propagation", Color: "var(--transport)"},
|
||||
{Label: "B processing", Color: "var(--b-proc)"},
|
||||
{Label: "Unknown / missing", Color: "var(--unknown)"},
|
||||
},
|
||||
Rows: make([]summaryChartRow, 0, len(summaries)),
|
||||
}
|
||||
|
||||
var (
|
||||
endToEndValues []int64
|
||||
totalEndToEnd int64
|
||||
maxEndToEnd int64
|
||||
)
|
||||
|
||||
for _, summary := range summaries {
|
||||
page.Rows = append(page.Rows, buildSummaryChartRow(summary))
|
||||
|
||||
if summary.EndToEndLatencyNS == nil {
|
||||
continue
|
||||
}
|
||||
endToEnd := *summary.EndToEndLatencyNS
|
||||
endToEndValues = append(endToEndValues, endToEnd)
|
||||
totalEndToEnd += endToEnd
|
||||
if endToEnd > maxEndToEnd {
|
||||
maxEndToEnd = endToEnd
|
||||
}
|
||||
}
|
||||
|
||||
page.MessagesWithEndToEnd = len(endToEndValues)
|
||||
page.AverageEndToEnd = "n/a"
|
||||
page.MaxEndToEnd = "n/a"
|
||||
if len(endToEndValues) > 0 {
|
||||
page.AverageEndToEnd = formatLatencyNS(totalEndToEnd / int64(len(endToEndValues)))
|
||||
page.MaxEndToEnd = formatLatencyNS(maxEndToEnd)
|
||||
}
|
||||
|
||||
return page
|
||||
}
|
||||
|
||||
func buildSummaryChartRow(summary Summary) summaryChartRow {
|
||||
row := summaryChartRow{
|
||||
Title: buildSummaryChartTitle(summary),
|
||||
Subtitle: buildSummaryChartSubtitle(summary),
|
||||
EndToEnd: "End-to-end: n/a",
|
||||
ApproxRTT: "Approx RTT: n/a",
|
||||
MissingTimestamps: strings.Join(summary.MissingTimestamps, ", "),
|
||||
}
|
||||
if summary.ApproxRTTNS != nil && *summary.ApproxRTTNS > 0 {
|
||||
row.ApproxRTT = fmt.Sprintf("Approx RTT: %s", formatLatencyNS(*summary.ApproxRTTNS))
|
||||
}
|
||||
|
||||
ratioMetrics := []struct {
|
||||
label string
|
||||
value *float64
|
||||
}{
|
||||
{label: "A processing bitrate", value: summary.AProcessingBitrateBPS},
|
||||
{label: "A-B transport + propagation bitrate", value: summary.ABTransportPropagationBitrateBPS},
|
||||
{label: "End-to-end bitrate", value: summary.EndToEndBitrateBPS},
|
||||
}
|
||||
for _, metric := range ratioMetrics {
|
||||
if metric.value == nil || *metric.value <= 0 {
|
||||
continue
|
||||
}
|
||||
row.RatioMetrics = append(row.RatioMetrics, summaryChartValue{
|
||||
Label: metric.label,
|
||||
Value: formatBitrateBPS(*metric.value),
|
||||
})
|
||||
}
|
||||
|
||||
if summary.EndToEndLatencyNS == nil || *summary.EndToEndLatencyNS <= 0 {
|
||||
return row
|
||||
}
|
||||
|
||||
total := *summary.EndToEndLatencyNS
|
||||
row.EndToEnd = fmt.Sprintf("End-to-end: %s", formatLatencyNS(total))
|
||||
|
||||
metrics := []summaryChartSegmentMetric{
|
||||
{label: "A processing", value: summary.AProcessingLatencyNS, color: "var(--a-proc)"},
|
||||
{label: "A queue", value: summary.AQueueLatencyNS, color: "var(--a-queue)"},
|
||||
{label: "A-B transport + propagation", value: summary.ABTransportPropagationNS, color: "var(--transport)"},
|
||||
{label: "B processing", value: summary.BProcessingLatencyNS, color: "var(--b-proc)"},
|
||||
}
|
||||
|
||||
var knownTotal int64
|
||||
for _, metric := range metrics {
|
||||
if metric.value == nil || *metric.value <= 0 {
|
||||
continue
|
||||
}
|
||||
knownTotal += *metric.value
|
||||
}
|
||||
|
||||
scaleTotal := total
|
||||
if knownTotal > scaleTotal {
|
||||
scaleTotal = knownTotal
|
||||
}
|
||||
if scaleTotal <= 0 {
|
||||
return row
|
||||
}
|
||||
|
||||
for _, metric := range metrics {
|
||||
if metric.value == nil || *metric.value <= 0 {
|
||||
continue
|
||||
}
|
||||
row.Segments = append(row.Segments, summaryChartSegment{
|
||||
Label: metric.label,
|
||||
Value: formatLatencyNS(*metric.value),
|
||||
Color: metric.color,
|
||||
WidthPercent: float64(*metric.value) * 100 / float64(scaleTotal),
|
||||
})
|
||||
}
|
||||
|
||||
if remaining := total - knownTotal; remaining > 0 {
|
||||
row.Segments = append(row.Segments, summaryChartSegment{
|
||||
Label: "Unknown / missing",
|
||||
Value: formatLatencyNS(remaining),
|
||||
Color: "var(--unknown)",
|
||||
WidthPercent: float64(remaining) * 100 / float64(scaleTotal),
|
||||
})
|
||||
}
|
||||
|
||||
return row
|
||||
}
|
||||
|
||||
func buildSummaryChartTitle(summary Summary) string {
|
||||
if summary.MessageType == protocol.MessageTypeFile && summary.FileName != "" {
|
||||
return fmt.Sprintf("%s #%d (%s)", summary.MessageType, summary.MessageID, summary.FileName)
|
||||
}
|
||||
|
||||
return fmt.Sprintf("%s #%d", summary.MessageType, summary.MessageID)
|
||||
}
|
||||
|
||||
func buildSummaryChartSubtitle(summary Summary) string {
|
||||
parts := []string{
|
||||
fmt.Sprintf("%s -> %s", summary.From, summary.To),
|
||||
fmt.Sprintf("%d bytes", summary.BodySize),
|
||||
}
|
||||
|
||||
if summary.MessageType == protocol.MessageTypeFile && summary.FileName != "" {
|
||||
parts = append(parts, fmt.Sprintf("file: %s", summary.FileName))
|
||||
}
|
||||
|
||||
return strings.Join(parts, " | ")
|
||||
}
|
||||
|
||||
func formatLatencyNS(ns int64) string {
|
||||
return fmt.Sprintf("%.3f ms", float64(ns)/1_000_000)
|
||||
}
|
||||
|
||||
func formatBitrateBPS(bitsPerSecond float64) string {
|
||||
return fmt.Sprintf("%.3f Mb/s", bitsPerSecond/1_000_000)
|
||||
}
|
||||
79
go/cmd/internal/latencylog/summary_chart_test.go
Normal file
79
go/cmd/internal/latencylog/summary_chart_test.go
Normal file
@@ -0,0 +1,79 @@
|
||||
package latencylog
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"omnisocketgo/cmd/internal/protocol"
|
||||
)
|
||||
|
||||
func TestWriteSummariesHTMLChart(t *testing.T) {
|
||||
aProcessing := int64(20_000_000)
|
||||
aQueue := int64(10_000_000)
|
||||
transport := int64(40_000_000)
|
||||
bProcessing := int64(30_000_000)
|
||||
endToEnd := int64(100_000_000)
|
||||
aProcessingBitrate := float64(5) * 8 * 1_000_000_000 / float64(aProcessing)
|
||||
transportBitrate := float64(5) * 8 * 1_000_000_000 / float64(transport)
|
||||
endToEndBitrate := float64(5) * 8 * 1_000_000_000 / float64(endToEnd)
|
||||
|
||||
summaries := []Summary{
|
||||
{
|
||||
MessageType: protocol.MessageTypeText,
|
||||
MessageID: 7,
|
||||
From: "peer-a",
|
||||
To: "peer-b",
|
||||
BodySize: 5,
|
||||
AProcessingLatencyNS: &aProcessing,
|
||||
AQueueLatencyNS: &aQueue,
|
||||
ABTransportPropagationNS: &transport,
|
||||
BProcessingLatencyNS: &bProcessing,
|
||||
EndToEndLatencyNS: &endToEnd,
|
||||
AProcessingBitrateBPS: &aProcessingBitrate,
|
||||
ABTransportPropagationBitrateBPS: &transportBitrate,
|
||||
EndToEndBitrateBPS: &endToEndBitrate,
|
||||
ApproxRTTNS: &endToEnd,
|
||||
},
|
||||
{
|
||||
MessageType: protocol.MessageTypeFile,
|
||||
MessageID: 8,
|
||||
From: "peer-b",
|
||||
To: "peer-a",
|
||||
FileName: "payload.bin",
|
||||
BodySize: 128,
|
||||
MissingTimestamps: []string{EventBRXSoftware},
|
||||
},
|
||||
}
|
||||
|
||||
path := filepath.Join(t.TempDir(), "charts", "latency-summary.html")
|
||||
if err := WriteSummariesHTMLChart(path, summaries); err != nil {
|
||||
t.Fatalf("WriteSummariesHTMLChart() error = %v", err)
|
||||
}
|
||||
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatalf("os.ReadFile() error = %v", err)
|
||||
}
|
||||
|
||||
content := string(data)
|
||||
for _, want := range []string{
|
||||
"Latency Summary",
|
||||
"text #7",
|
||||
"peer-a -> peer-b | 5 bytes",
|
||||
"End-to-end: 100.000 ms",
|
||||
"Approx RTT: 100.000 ms",
|
||||
"A processing bitrate 0.002 Mb/s",
|
||||
"A-B transport + propagation bitrate 0.001 Mb/s",
|
||||
"End-to-end bitrate 0.000 Mb/s",
|
||||
"A processing 20.000 ms",
|
||||
"A-B transport + propagation 40.000 ms",
|
||||
"file #8 (payload.bin)",
|
||||
"Missing timestamps: B_RX_SOFTWARE",
|
||||
} {
|
||||
if !strings.Contains(content, want) {
|
||||
t.Fatalf("chart content missing %q\n%s", want, content)
|
||||
}
|
||||
}
|
||||
}
|
||||
399
go/cmd/internal/latencylog/summary_test.go
Normal file
399
go/cmd/internal/latencylog/summary_test.go
Normal file
@@ -0,0 +1,399 @@
|
||||
package latencylog
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"sort"
|
||||
"testing"
|
||||
|
||||
"omnisocketgo/cmd/internal/protocol"
|
||||
)
|
||||
|
||||
func TestSummarizeEventsComputesLatencyMetrics(t *testing.T) {
|
||||
events := []Event{
|
||||
{TsUnixNano: 100, Event: EventAAppPrepBegin, MessageType: protocol.MessageTypeText, MessageID: 1, From: "peer-a", To: "peer-b", BodySize: 320},
|
||||
{TsUnixNano: 120, Event: EventATXSched, MessageType: protocol.MessageTypeText, MessageID: 1, From: "peer-a", To: "peer-b", BodySize: 320},
|
||||
{TsUnixNano: 140, Event: EventATXSoftware, MessageType: protocol.MessageTypeText, MessageID: 1, From: "peer-a", To: "peer-b", BodySize: 320},
|
||||
{TsUnixNano: 180, Event: EventBRXSoftware, MessageType: protocol.MessageTypeText, MessageID: 1, From: "peer-a", To: "peer-b", BodySize: 320},
|
||||
{TsUnixNano: 220, Event: EventBAppRecv, MessageType: protocol.MessageTypeText, MessageID: 1, From: "peer-a", To: "peer-b", BodySize: 320},
|
||||
{TsUnixNano: 230, Event: EventBPersistBegin, MessageType: protocol.MessageTypeText, MessageID: 1, From: "peer-a", To: "peer-b", BodySize: 320},
|
||||
{TsUnixNano: 260, Event: EventBPersistEnd, MessageType: protocol.MessageTypeText, MessageID: 1, From: "peer-a", To: "peer-b", BodySize: 320},
|
||||
}
|
||||
|
||||
summaries := SummarizeEvents(events)
|
||||
if len(summaries) != 1 {
|
||||
t.Fatalf("summary count = %d, want 1", len(summaries))
|
||||
}
|
||||
|
||||
summary := summaries[0]
|
||||
if got := ptrValue(summary.AProcessingLatencyNS); got != 20 {
|
||||
t.Fatalf("AProcessingLatencyNS = %d, want 20", got)
|
||||
}
|
||||
if got := ptrValue(summary.AQueueLatencyNS); got != 20 {
|
||||
t.Fatalf("AQueueLatencyNS = %d, want 20", got)
|
||||
}
|
||||
if got := ptrValue(summary.ABTransportPropagationNS); got != 80 {
|
||||
t.Fatalf("ABTransportPropagationNS = %d, want 80", got)
|
||||
}
|
||||
if got := ptrValue(summary.BKernelReceivePathLatencyNS); got != 40 {
|
||||
t.Fatalf("BKernelReceivePathLatencyNS = %d, want 40", got)
|
||||
}
|
||||
if got := ptrValue(summary.BProcessingLatencyNS); got != 40 {
|
||||
t.Fatalf("BProcessingLatencyNS = %d, want 40", got)
|
||||
}
|
||||
if got := ptrValue(summary.EndToEndLatencyNS); got != 160 {
|
||||
t.Fatalf("EndToEndLatencyNS = %d, want 160", got)
|
||||
}
|
||||
if got := ptrValueFloat(summary.AProcessingBitrateBPS); got != 128_000_000_000 {
|
||||
t.Fatalf("AProcessingBitrateBPS = %v, want 128000000000", got)
|
||||
}
|
||||
if got := ptrValueFloat(summary.ABTransportPropagationBitrateBPS); got != 32_000_000_000 {
|
||||
t.Fatalf("ABTransportPropagationBitrateBPS = %v, want 32000000000", got)
|
||||
}
|
||||
if got := ptrValueFloat(summary.EndToEndBitrateBPS); got != 16_000_000_000 {
|
||||
t.Fatalf("EndToEndBitrateBPS = %v, want 16000000000", got)
|
||||
}
|
||||
if got := summary.Timestamps[EventBRXSoftware]; got != 180 {
|
||||
t.Fatalf("timestamps[%q] = %d, want 180", EventBRXSoftware, got)
|
||||
}
|
||||
if len(summary.MissingTimestamps) != 0 {
|
||||
t.Fatalf("MissingTimestamps = %v, want empty", summary.MissingTimestamps)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSummarizeEventsComputesApproxRTTByPairingReverseMessages(t *testing.T) {
|
||||
events := []Event{
|
||||
{TsUnixNano: 100, Event: EventAAppPrepBegin, MessageType: protocol.MessageTypeText, MessageID: 1, From: "peer-a", To: "peer-b"},
|
||||
{TsUnixNano: 110, Event: EventATXSoftware, MessageType: protocol.MessageTypeText, MessageID: 1, From: "peer-a", To: "peer-b"},
|
||||
{TsUnixNano: 180, Event: EventBAppRecv, MessageType: protocol.MessageTypeText, MessageID: 1, From: "peer-a", To: "peer-b"},
|
||||
{TsUnixNano: 120, Event: EventAAppPrepBegin, MessageType: protocol.MessageTypeText, MessageID: 2, From: "peer-a", To: "peer-b"},
|
||||
{TsUnixNano: 140, Event: EventATXSoftware, MessageType: protocol.MessageTypeText, MessageID: 2, From: "peer-a", To: "peer-b"},
|
||||
{TsUnixNano: 190, Event: EventBAppRecv, MessageType: protocol.MessageTypeText, MessageID: 2, From: "peer-a", To: "peer-b"},
|
||||
{TsUnixNano: 200, Event: EventAAppPrepBegin, MessageType: protocol.MessageTypeText, MessageID: 11, From: "peer-b", To: "peer-a"},
|
||||
{TsUnixNano: 210, Event: EventATXSoftware, MessageType: protocol.MessageTypeText, MessageID: 11, From: "peer-b", To: "peer-a"},
|
||||
{TsUnixNano: 260, Event: EventBAppRecv, MessageType: protocol.MessageTypeText, MessageID: 11, From: "peer-b", To: "peer-a"},
|
||||
{TsUnixNano: 220, Event: EventAAppPrepBegin, MessageType: protocol.MessageTypeText, MessageID: 12, From: "peer-b", To: "peer-a"},
|
||||
{TsUnixNano: 230, Event: EventATXSoftware, MessageType: protocol.MessageTypeText, MessageID: 12, From: "peer-b", To: "peer-a"},
|
||||
{TsUnixNano: 310, Event: EventBAppRecv, MessageType: protocol.MessageTypeText, MessageID: 12, From: "peer-b", To: "peer-a"},
|
||||
}
|
||||
|
||||
summaries := SummarizeEvents(events)
|
||||
if len(summaries) != 4 {
|
||||
t.Fatalf("summary count = %d, want 4", len(summaries))
|
||||
}
|
||||
|
||||
gotByMessageID := make(map[uint64]Summary, len(summaries))
|
||||
for _, summary := range summaries {
|
||||
gotByMessageID[summary.MessageID] = summary
|
||||
}
|
||||
|
||||
if got := ptrValue(gotByMessageID[1].ApproxRTTNS); got != 150 {
|
||||
t.Fatalf("message 1 ApproxRTTNS = %d, want 150", got)
|
||||
}
|
||||
if got := ptrValue(gotByMessageID[2].ApproxRTTNS); got != 170 {
|
||||
t.Fatalf("message 2 ApproxRTTNS = %d, want 170", got)
|
||||
}
|
||||
if gotByMessageID[11].ApproxRTTNS != nil {
|
||||
t.Fatalf("message 11 ApproxRTTNS = %d, want nil", ptrValue(gotByMessageID[11].ApproxRTTNS))
|
||||
}
|
||||
if gotByMessageID[12].ApproxRTTNS != nil {
|
||||
t.Fatalf("message 12 ApproxRTTNS = %d, want nil", ptrValue(gotByMessageID[12].ApproxRTTNS))
|
||||
}
|
||||
}
|
||||
|
||||
func TestSummarizeEventsReportsMissingTimestamps(t *testing.T) {
|
||||
events := []Event{
|
||||
{TsUnixNano: 100, Event: EventAAppPrepBegin, MessageType: protocol.MessageTypeText, MessageID: 2, From: "peer-a", To: "peer-b"},
|
||||
{TsUnixNano: 240, Event: EventBPersistEnd, MessageType: protocol.MessageTypeText, MessageID: 2, From: "peer-a", To: "peer-b"},
|
||||
}
|
||||
|
||||
summaries := SummarizeEvents(events)
|
||||
if len(summaries) != 1 {
|
||||
t.Fatalf("summary count = %d, want 1", len(summaries))
|
||||
}
|
||||
|
||||
wantMissing := []string{EventATXSched, EventATXSoftware, EventBRXSoftware, EventBAppRecv}
|
||||
if !reflect.DeepEqual(summaries[0].MissingTimestamps, wantMissing) {
|
||||
t.Fatalf("MissingTimestamps = %v, want %v", summaries[0].MissingTimestamps, wantMissing)
|
||||
}
|
||||
if summaries[0].AProcessingLatencyNS != nil {
|
||||
t.Fatalf("AProcessingLatencyNS = %v, want nil", ptrValue(summaries[0].AProcessingLatencyNS))
|
||||
}
|
||||
if summaries[0].EndToEndLatencyNS == nil {
|
||||
t.Fatal("EndToEndLatencyNS = nil, want non-nil because endpoints are present")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadAndWriteSummaryFiles(t *testing.T) {
|
||||
rawPath := filepath.Join(t.TempDir(), "raw.jsonl")
|
||||
rawLogger, err := NewJSONLLogger(rawPath)
|
||||
if err != nil {
|
||||
t.Fatalf("NewJSONLLogger() error = %v", err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
_ = rawLogger.Close()
|
||||
})
|
||||
|
||||
for _, event := range []Event{
|
||||
{TsUnixNano: 100, Event: EventAAppPrepBegin, MessageType: protocol.MessageTypeText, MessageID: 3, From: "peer-a", To: "peer-b", BodySize: 320},
|
||||
{TsUnixNano: 120, Event: EventATXSched, MessageType: protocol.MessageTypeText, MessageID: 3, From: "peer-a", To: "peer-b", BodySize: 320},
|
||||
{TsUnixNano: 140, Event: EventATXSoftware, MessageType: protocol.MessageTypeText, MessageID: 3, From: "peer-a", To: "peer-b", BodySize: 320},
|
||||
{TsUnixNano: 180, Event: EventBRXSoftware, MessageType: protocol.MessageTypeText, MessageID: 3, From: "peer-a", To: "peer-b", BodySize: 320},
|
||||
{TsUnixNano: 220, Event: EventBAppRecv, MessageType: protocol.MessageTypeText, MessageID: 3, From: "peer-a", To: "peer-b", BodySize: 320},
|
||||
{TsUnixNano: 260, Event: EventBPersistEnd, MessageType: protocol.MessageTypeText, MessageID: 3, From: "peer-a", To: "peer-b", BodySize: 320},
|
||||
} {
|
||||
if err := rawLogger.LogEvent(event); err != nil {
|
||||
t.Fatalf("LogEvent() error = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
events, err := LoadEventsFromFile(rawPath)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadEventsFromFile() error = %v", err)
|
||||
}
|
||||
|
||||
summaryPath := filepath.Join(t.TempDir(), "summary.jsonl")
|
||||
if err := WriteSummariesJSONL(summaryPath, SummarizeEvents(events)); err != nil {
|
||||
t.Fatalf("WriteSummariesJSONL() error = %v", err)
|
||||
}
|
||||
|
||||
file, err := os.Open(summaryPath)
|
||||
if err != nil {
|
||||
t.Fatalf("os.Open() error = %v", err)
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
scanner := bufio.NewScanner(file)
|
||||
if !scanner.Scan() {
|
||||
t.Fatal("expected one summary line, got none")
|
||||
}
|
||||
|
||||
var summary Summary
|
||||
if err := json.Unmarshal(scanner.Bytes(), &summary); err != nil {
|
||||
t.Fatalf("json.Unmarshal() error = %v", err)
|
||||
}
|
||||
if summary.MessageID != 3 {
|
||||
t.Fatalf("MessageID = %d, want 3", summary.MessageID)
|
||||
}
|
||||
if got := ptrValue(summary.BKernelReceivePathLatencyNS); got != 40 {
|
||||
t.Fatalf("BKernelReceivePathLatencyNS = %d, want 40", got)
|
||||
}
|
||||
if got := ptrValue(summary.EndToEndLatencyNS); got != 160 {
|
||||
t.Fatalf("EndToEndLatencyNS = %d, want 160", got)
|
||||
}
|
||||
if got := ptrValueFloat(summary.EndToEndBitrateBPS); got != 16_000_000_000 {
|
||||
t.Fatalf("EndToEndBitrateBPS = %v, want 16000000000", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadEventsFromFilesWithSharedMaxOffsetFiltersToSharedCutoff(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
testCases := []struct {
|
||||
name string
|
||||
firstMessageIDs []uint64
|
||||
secondMessageIDs []uint64
|
||||
offset uint64
|
||||
wantCutoff *uint64
|
||||
wantMessageIDs []uint64
|
||||
}{
|
||||
{
|
||||
name: "same max message id rolls back one",
|
||||
firstMessageIDs: []uint64{1, 2, 3, 4, 5, 6, 7},
|
||||
secondMessageIDs: []uint64{1, 2, 3, 4, 5, 6, 7},
|
||||
offset: 1,
|
||||
wantCutoff: uint64Ptr(6),
|
||||
wantMessageIDs: []uint64{1, 2, 3, 4, 5, 6},
|
||||
},
|
||||
{
|
||||
name: "smaller input max wins before rollback",
|
||||
firstMessageIDs: []uint64{1, 2, 3, 4, 5, 6, 7, 8, 9},
|
||||
secondMessageIDs: []uint64{1, 2, 3, 4, 5, 6, 7},
|
||||
offset: 1,
|
||||
wantCutoff: uint64Ptr(6),
|
||||
wantMessageIDs: []uint64{1, 2, 3, 4, 5, 6},
|
||||
},
|
||||
{
|
||||
name: "not enough shared messages yields empty result",
|
||||
firstMessageIDs: []uint64{1},
|
||||
secondMessageIDs: []uint64{1},
|
||||
offset: 1,
|
||||
wantCutoff: uint64Ptr(0),
|
||||
wantMessageIDs: nil,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range testCases {
|
||||
tt := tt
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tempDir := t.TempDir()
|
||||
firstPath := filepath.Join(tempDir, "first.jsonl")
|
||||
secondPath := filepath.Join(tempDir, "second.jsonl")
|
||||
writeEventsJSONL(t, firstPath, testEventsForMessageIDs(tt.firstMessageIDs, "peer-a", "peer-b"))
|
||||
writeEventsJSONL(t, secondPath, testEventsForMessageIDs(tt.secondMessageIDs, "peer-b", "peer-a"))
|
||||
|
||||
events, cutoff, err := LoadEventsFromFilesWithSharedMaxOffset([]string{firstPath, secondPath}, tt.offset)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadEventsFromFilesWithSharedMaxOffset() error = %v", err)
|
||||
}
|
||||
if !reflect.DeepEqual(cutoff, tt.wantCutoff) {
|
||||
t.Fatalf("cutoff = %v, want %v", cutoff, tt.wantCutoff)
|
||||
}
|
||||
|
||||
if got := businessMessageIDs(events); !reflect.DeepEqual(got, tt.wantMessageIDs) {
|
||||
t.Fatalf("message IDs = %v, want %v", got, tt.wantMessageIDs)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadEventsFromFilesWithSharedMaxOffsetPreservesEarlierSummaries(t *testing.T) {
|
||||
tempDir := t.TempDir()
|
||||
firstPath := filepath.Join(tempDir, "first.jsonl")
|
||||
secondPath := filepath.Join(tempDir, "second.jsonl")
|
||||
|
||||
writeEventsJSONL(t, firstPath, []Event{
|
||||
{TsUnixNano: 100, Event: EventAAppPrepBegin, MessageType: protocol.MessageTypeText, MessageID: 1, From: "peer-a", To: "peer-b", BodySize: 320},
|
||||
{TsUnixNano: 120, Event: EventATXSched, MessageType: protocol.MessageTypeText, MessageID: 1, From: "peer-a", To: "peer-b", BodySize: 320},
|
||||
{TsUnixNano: 140, Event: EventATXSoftware, MessageType: protocol.MessageTypeText, MessageID: 1, From: "peer-a", To: "peer-b", BodySize: 320},
|
||||
{TsUnixNano: 180, Event: EventBRXSoftware, MessageType: protocol.MessageTypeText, MessageID: 1, From: "peer-a", To: "peer-b", BodySize: 320},
|
||||
{TsUnixNano: 220, Event: EventBAppRecv, MessageType: protocol.MessageTypeText, MessageID: 1, From: "peer-a", To: "peer-b", BodySize: 320},
|
||||
{TsUnixNano: 260, Event: EventBPersistEnd, MessageType: protocol.MessageTypeText, MessageID: 1, From: "peer-a", To: "peer-b", BodySize: 320},
|
||||
{TsUnixNano: 300, Event: EventAAppPrepBegin, MessageType: protocol.MessageTypeText, MessageID: 2, From: "peer-a", To: "peer-b", BodySize: 160},
|
||||
{TsUnixNano: 330, Event: EventATXSched, MessageType: protocol.MessageTypeText, MessageID: 2, From: "peer-a", To: "peer-b", BodySize: 160},
|
||||
{TsUnixNano: 360, Event: EventATXSoftware, MessageType: protocol.MessageTypeText, MessageID: 2, From: "peer-a", To: "peer-b", BodySize: 160},
|
||||
{TsUnixNano: 390, Event: EventBRXSoftware, MessageType: protocol.MessageTypeText, MessageID: 2, From: "peer-a", To: "peer-b", BodySize: 160},
|
||||
{TsUnixNano: 420, Event: EventBAppRecv, MessageType: protocol.MessageTypeText, MessageID: 2, From: "peer-a", To: "peer-b", BodySize: 160},
|
||||
{TsUnixNano: 470, Event: EventBPersistEnd, MessageType: protocol.MessageTypeText, MessageID: 2, From: "peer-a", To: "peer-b", BodySize: 160},
|
||||
{TsUnixNano: 500, Event: EventAAppPrepBegin, MessageType: protocol.MessageTypeText, MessageID: 3, From: "peer-a", To: "peer-b", BodySize: 80},
|
||||
{TsUnixNano: 520, Event: EventATXSched, MessageType: protocol.MessageTypeText, MessageID: 3, From: "peer-a", To: "peer-b", BodySize: 80},
|
||||
{TsUnixNano: 540, Event: EventATXSoftware, MessageType: protocol.MessageTypeText, MessageID: 3, From: "peer-a", To: "peer-b", BodySize: 80},
|
||||
{TsUnixNano: 560, Event: EventBRXSoftware, MessageType: protocol.MessageTypeText, MessageID: 3, From: "peer-a", To: "peer-b", BodySize: 80},
|
||||
{TsUnixNano: 580, Event: EventBAppRecv, MessageType: protocol.MessageTypeText, MessageID: 3, From: "peer-a", To: "peer-b", BodySize: 80},
|
||||
{TsUnixNano: 600, Event: EventBPersistEnd, MessageType: protocol.MessageTypeText, MessageID: 3, From: "peer-a", To: "peer-b", BodySize: 80},
|
||||
{TsUnixNano: 700, Event: EventAAppPrepBegin, MessageType: protocol.MessageTypeText, MessageID: 4, From: "peer-a", To: "peer-b", BodySize: 40},
|
||||
})
|
||||
writeEventsJSONL(t, secondPath, []Event{
|
||||
{TsUnixNano: 90, Event: EventAAppPrepBegin, MessageType: protocol.MessageTypeText, MessageID: 1, From: "peer-b", To: "peer-a", BodySize: 20},
|
||||
{TsUnixNano: 95, Event: EventATXSoftware, MessageType: protocol.MessageTypeText, MessageID: 1, From: "peer-b", To: "peer-a", BodySize: 20},
|
||||
{TsUnixNano: 150, Event: EventBAppRecv, MessageType: protocol.MessageTypeText, MessageID: 1, From: "peer-b", To: "peer-a", BodySize: 20},
|
||||
{TsUnixNano: 290, Event: EventAAppPrepBegin, MessageType: protocol.MessageTypeText, MessageID: 2, From: "peer-b", To: "peer-a", BodySize: 20},
|
||||
{TsUnixNano: 295, Event: EventATXSoftware, MessageType: protocol.MessageTypeText, MessageID: 2, From: "peer-b", To: "peer-a", BodySize: 20},
|
||||
{TsUnixNano: 350, Event: EventBAppRecv, MessageType: protocol.MessageTypeText, MessageID: 2, From: "peer-b", To: "peer-a", BodySize: 20},
|
||||
{TsUnixNano: 490, Event: EventAAppPrepBegin, MessageType: protocol.MessageTypeText, MessageID: 3, From: "peer-b", To: "peer-a", BodySize: 20},
|
||||
{TsUnixNano: 495, Event: EventATXSoftware, MessageType: protocol.MessageTypeText, MessageID: 3, From: "peer-b", To: "peer-a", BodySize: 20},
|
||||
{TsUnixNano: 550, Event: EventBAppRecv, MessageType: protocol.MessageTypeText, MessageID: 3, From: "peer-b", To: "peer-a", BodySize: 20},
|
||||
{TsUnixNano: 690, Event: EventAAppPrepBegin, MessageType: protocol.MessageTypeText, MessageID: 4, From: "peer-b", To: "peer-a", BodySize: 20},
|
||||
})
|
||||
|
||||
events, cutoff, err := LoadEventsFromFilesWithSharedMaxOffset([]string{firstPath, secondPath}, 1)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadEventsFromFilesWithSharedMaxOffset() error = %v", err)
|
||||
}
|
||||
if !reflect.DeepEqual(cutoff, uint64Ptr(3)) {
|
||||
t.Fatalf("cutoff = %v, want %v", cutoff, uint64Ptr(3))
|
||||
}
|
||||
|
||||
summaries := SummarizeEvents(events)
|
||||
if got := len(summaries); got != 6 {
|
||||
t.Fatalf("summary count = %d, want 6", got)
|
||||
}
|
||||
|
||||
for _, summary := range summaries {
|
||||
if summary.MessageID == 4 {
|
||||
t.Fatalf("message 4 should have been truncated from summaries: %+v", summary)
|
||||
}
|
||||
}
|
||||
|
||||
var forwardMessageTwo Summary
|
||||
found := false
|
||||
for _, summary := range summaries {
|
||||
if summary.From == "peer-a" && summary.To == "peer-b" && summary.MessageID == 2 {
|
||||
forwardMessageTwo = summary
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatal("summary for message 2 peer-a -> peer-b not found")
|
||||
}
|
||||
if got := ptrValue(forwardMessageTwo.EndToEndLatencyNS); got != 170 {
|
||||
t.Fatalf("message 2 EndToEndLatencyNS = %d, want 170", got)
|
||||
}
|
||||
if got := ptrValue(forwardMessageTwo.ApproxRTTNS); got != 190 {
|
||||
t.Fatalf("message 2 ApproxRTTNS = %d, want 190", got)
|
||||
}
|
||||
}
|
||||
|
||||
func ptrValue(value *int64) int64 {
|
||||
if value == nil {
|
||||
return 0
|
||||
}
|
||||
return *value
|
||||
}
|
||||
|
||||
func ptrValueFloat(value *float64) float64 {
|
||||
if value == nil {
|
||||
return 0
|
||||
}
|
||||
return *value
|
||||
}
|
||||
|
||||
func uint64Ptr(value uint64) *uint64 {
|
||||
return &value
|
||||
}
|
||||
|
||||
func businessMessageIDs(events []Event) []uint64 {
|
||||
seen := make(map[uint64]struct{})
|
||||
var ids []uint64
|
||||
|
||||
for _, event := range events {
|
||||
if !IsBusinessEvent(event) {
|
||||
continue
|
||||
}
|
||||
if _, ok := seen[event.MessageID]; ok {
|
||||
continue
|
||||
}
|
||||
seen[event.MessageID] = struct{}{}
|
||||
ids = append(ids, event.MessageID)
|
||||
}
|
||||
|
||||
sort.Slice(ids, func(i, j int) bool {
|
||||
return ids[i] < ids[j]
|
||||
})
|
||||
return ids
|
||||
}
|
||||
|
||||
func testEventsForMessageIDs(messageIDs []uint64, from, to string) []Event {
|
||||
events := make([]Event, 0, len(messageIDs)*2)
|
||||
for _, messageID := range messageIDs {
|
||||
events = append(events,
|
||||
Event{TsUnixNano: int64(messageID*100 + 10), Event: EventAAppPrepBegin, MessageType: protocol.MessageTypeText, MessageID: messageID, From: from, To: to, BodySize: 32},
|
||||
Event{TsUnixNano: int64(messageID*100 + 20), Event: EventATXSoftware, MessageType: protocol.MessageTypeText, MessageID: messageID, From: from, To: to, BodySize: 32},
|
||||
)
|
||||
}
|
||||
|
||||
return events
|
||||
}
|
||||
|
||||
func writeEventsJSONL(t *testing.T, path string, events []Event) {
|
||||
t.Helper()
|
||||
|
||||
file, err := os.OpenFile(path, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o644)
|
||||
if err != nil {
|
||||
t.Fatalf("os.OpenFile(%s) error = %v", path, err)
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
encoder := json.NewEncoder(file)
|
||||
for _, event := range events {
|
||||
if err := encoder.Encode(event); err != nil {
|
||||
t.Fatalf("encoder.Encode(%s) error = %v", path, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
279
go/cmd/internal/protocol/codec.go
Normal file
279
go/cmd/internal/protocol/codec.go
Normal file
@@ -0,0 +1,279 @@
|
||||
package protocol
|
||||
|
||||
import (
|
||||
"encoding/binary"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"unicode/utf8"
|
||||
)
|
||||
|
||||
// MaxFrameSize 用于限制单个帧的最大长度,
|
||||
// 避免异常对端通过伪造超大长度值导致接收方无上限分配内存。
|
||||
const MaxFrameSize = 8 * 1024 * 1024 // 先临时设置传输的视频帧不超过8MB
|
||||
|
||||
var (
|
||||
ErrInvalidFrameLength = errors.New("protocol: invalid frame length") // 表示帧长度非法,例如长度为 0。
|
||||
ErrFrameTooLarge = errors.New("protocol: frame too large") // 表示帧长度超过允许的上限。
|
||||
ErrInvalidMessageType = errors.New("protocol: invalid message type") // 表示消息类型不是当前协议支持的类型。
|
||||
ErrMissingFrom = errors.New("protocol: missing from") // 表示消息缺少发送方标识。
|
||||
ErrMissingTo = errors.New("protocol: missing to") // 表示消息缺少接收方标识。
|
||||
ErrMissingFileName = errors.New("protocol: missing file name") // 表示 file 消息缺少文件名。
|
||||
ErrUnexpectedFileName = errors.New("protocol: unexpected file name") // 表示 text 消息错误地携带了文件名。
|
||||
ErrInvalidTextBody = errors.New("protocol: invalid text body") // 表示 text 消息正文不是合法 UTF-8。
|
||||
ErrUnexpectedBody = errors.New("protocol: unexpected body") // 表示某些控制消息不允许携带正文。
|
||||
ErrInvalidRegisterTarget = errors.New("protocol: invalid register target") // 表示 register 消息没有发往 server。
|
||||
ErrInvalidErrorSource = errors.New("protocol: invalid error source") // 表示 error 消息不是由 server 发出。
|
||||
ErrInvalidHeaderLength = errors.New("protocol: invalid header length") // 表示 header 长度字段为 0、越界或无法完整切分。
|
||||
ErrInvalidHeaderJSON = errors.New("protocol: invalid header json") // 表示 header JSON 无法解析,可能是格式错误或缺少必要字段。
|
||||
ErrInvalidContentLength = errors.New("protocol: invalid content length") // 表示头部记录的正文长度与实际正文不一致。
|
||||
)
|
||||
|
||||
// 应用层消息:[4字节 frameLength][4字节 headerLen][header JSON(下面自定义的Message头)][body bytes]
|
||||
// 写了 tag:JSON 字段名是你指定的 type;不写 tag:JSON 字段名默认是 Go 字段名 Type
|
||||
type messageHeader struct {
|
||||
Type MessageType `json:"type"`
|
||||
ID uint64 `json:"id"`
|
||||
From string `json:"from"`
|
||||
To string `json:"to"`
|
||||
FileName string `json:"file_name,omitempty"`
|
||||
ContentLength int `json:"content_length"`
|
||||
}
|
||||
|
||||
// EncodeMessage 将逻辑消息编码为帧内字节格式:
|
||||
// 1. 4 字节大端序 header 长度
|
||||
// 2. header JSON
|
||||
// 3. 原始 body 字节
|
||||
func EncodeMessage(msg Message) ([]byte, error) {
|
||||
if err := validateMessage(msg); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
header := messageHeader{
|
||||
Type: msg.Type,
|
||||
ID: msg.ID,
|
||||
From: msg.From,
|
||||
To: msg.To,
|
||||
FileName: msg.FileName,
|
||||
ContentLength: len(msg.Body),
|
||||
}
|
||||
|
||||
headerPayload, err := json.Marshal(header)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("protocol: encode header: %w", err)
|
||||
}
|
||||
// 创建一个新的字节切片来存储完整的帧内容,避免直接在 headerPayload 上修改导致数据混乱。
|
||||
payload := make([]byte, 4+len(headerPayload)+len(msg.Body))
|
||||
// 在 payload 前 4 字节写入 header 长度,后续内容依次是 header JSON(第五个字节开始) 和 body。
|
||||
binary.BigEndian.PutUint32(payload[:4], uint32(len(headerPayload)))
|
||||
copy(payload[4:], headerPayload)
|
||||
copy(payload[4+len(headerPayload):], msg.Body)
|
||||
|
||||
//检查整个帧长度是否合法,避免上层调用者构造的消息过大导致发送失败。
|
||||
if len(payload) > MaxFrameSize {
|
||||
return nil, ErrFrameTooLarge
|
||||
}
|
||||
|
||||
return payload, nil
|
||||
}
|
||||
|
||||
// DecodeMessage 将帧内字节格式还原为 Message。
|
||||
func DecodeMessage(data []byte) (Message, error) {
|
||||
if len(data) > MaxFrameSize {
|
||||
return Message{}, ErrFrameTooLarge
|
||||
}
|
||||
if len(data) < 4 {
|
||||
return Message{}, ErrInvalidHeaderLength
|
||||
}
|
||||
|
||||
headerLen := int(binary.BigEndian.Uint32(data[:4]))
|
||||
if headerLen == 0 || headerLen > len(data)-4 {
|
||||
return Message{}, ErrInvalidHeaderLength
|
||||
}
|
||||
|
||||
headerPayload := data[4 : 4+headerLen]
|
||||
body := data[4+headerLen:]
|
||||
|
||||
var header messageHeader
|
||||
if err := json.Unmarshal(headerPayload, &header); err != nil {
|
||||
return Message{}, fmt.Errorf("protocol: decode header: %w", errors.Join(ErrInvalidHeaderJSON, err))
|
||||
}
|
||||
|
||||
if header.ContentLength < 0 || header.ContentLength != len(body) {
|
||||
return Message{}, ErrInvalidContentLength
|
||||
}
|
||||
|
||||
bodyCopy := make([]byte, len(body))
|
||||
copy(bodyCopy, body)
|
||||
|
||||
msg := Message{
|
||||
Type: header.Type,
|
||||
ID: header.ID,
|
||||
From: header.From,
|
||||
To: header.To,
|
||||
FileName: header.FileName,
|
||||
Body: bodyCopy,
|
||||
}
|
||||
|
||||
if err := validateMessage(msg); err != nil {
|
||||
return Message{}, err
|
||||
}
|
||||
|
||||
return msg, nil
|
||||
}
|
||||
|
||||
// WriteFrame 向流中写入一个带长度前缀的帧。
|
||||
// TCP帧格式如下:
|
||||
// 1. 4 字节大端序长度
|
||||
// 2. 后续 payload 内容
|
||||
//
|
||||
// TCP 是字节流协议,没有天然的消息边界。
|
||||
// 增加显式长度前缀后,接收方就知道一条完整消息应该读取多少字节,
|
||||
// 从而解决粘包和拆包问题。
|
||||
func WriteFrame(w io.Writer, payload []byte) error {
|
||||
size := len(payload)
|
||||
//空帧
|
||||
if size == 0 {
|
||||
return ErrInvalidFrameLength
|
||||
}
|
||||
//帧过大
|
||||
if size > MaxFrameSize {
|
||||
return ErrFrameTooLarge
|
||||
}
|
||||
|
||||
var header [4]byte
|
||||
binary.BigEndian.PutUint32(header[:], uint32(size))
|
||||
|
||||
// 先写长度头,接收方才能根据长度一次性读取完整消息体。
|
||||
if err := writeFull(w, header[:]); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return writeFull(w, payload)
|
||||
}
|
||||
|
||||
// ReadFrame 从流中读取一个完整的长度前缀帧。
|
||||
// 它会先读取固定 4 字节长度头,校验长度是否合法,
|
||||
// 再使用 io.ReadFull 按长度读取完整消息体,
|
||||
// 这样即使底层 TCP 发生分段读取,也不会把半条消息暴露给上层。
|
||||
func ReadFrame(r io.Reader) ([]byte, error) {
|
||||
var header [4]byte
|
||||
if _, err := io.ReadFull(r, header[:]); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
size := binary.BigEndian.Uint32(header[:])
|
||||
// 长度为 0 的帧被认为是非法输入,而不是合法的空消息。
|
||||
if size == 0 {
|
||||
return nil, ErrInvalidFrameLength
|
||||
}
|
||||
// 长度超过上限的帧会被拒绝,避免接收方无上限分配内存。
|
||||
if size > MaxFrameSize {
|
||||
return nil, ErrFrameTooLarge
|
||||
}
|
||||
|
||||
payload := make([]byte, int(size))
|
||||
if _, err := io.ReadFull(r, payload); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return payload, nil
|
||||
}
|
||||
|
||||
// WriteMessage 是给上层直接使用的完整发送路径:
|
||||
// 把一条结构化消息完整编码并发送出去”的总入口。
|
||||
// Message -> header+body -> 长度前缀帧 -> io.Writer。
|
||||
func WriteMessage(w io.Writer, msg Message) error {
|
||||
payload, err := EncodeMessage(msg)
|
||||
if err != nil {
|
||||
return fmt.Errorf("protocol: encode message: %w", err)
|
||||
}
|
||||
|
||||
if err := WriteFrame(w, payload); err != nil {
|
||||
return fmt.Errorf("protocol: write frame: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// ReadMessage 是给上层直接使用的完整接收路径:
|
||||
// io.Reader -> 长度前缀帧 -> header+body -> Message。
|
||||
func ReadMessage(r io.Reader) (Message, error) {
|
||||
payload, err := ReadFrame(r)
|
||||
if err != nil {
|
||||
return Message{}, fmt.Errorf("protocol: read frame: %w", err)
|
||||
}
|
||||
|
||||
msg, err := DecodeMessage(payload)
|
||||
if err != nil {
|
||||
return Message{}, fmt.Errorf("protocol: decode message: %w", err)
|
||||
}
|
||||
|
||||
return msg, nil
|
||||
}
|
||||
|
||||
// validateMessage 检查 Message 传输的类型(只接受 text 和 file )。
|
||||
func validateMessage(msg Message) error {
|
||||
if msg.From == "" {
|
||||
return ErrMissingFrom
|
||||
}
|
||||
if msg.To == "" {
|
||||
return ErrMissingTo
|
||||
}
|
||||
|
||||
switch msg.Type {
|
||||
case MessageTypeText:
|
||||
if msg.FileName != "" {
|
||||
return ErrUnexpectedFileName
|
||||
}
|
||||
if !utf8.Valid(msg.Body) {
|
||||
return ErrInvalidTextBody
|
||||
}
|
||||
case MessageTypeFile:
|
||||
if msg.FileName == "" {
|
||||
return ErrMissingFileName
|
||||
}
|
||||
case MessageTypeRegister:
|
||||
if msg.To != ServerPeerID {
|
||||
return ErrInvalidRegisterTarget
|
||||
}
|
||||
if msg.FileName != "" {
|
||||
return ErrUnexpectedFileName
|
||||
}
|
||||
if len(msg.Body) != 0 {
|
||||
return ErrUnexpectedBody
|
||||
}
|
||||
case MessageTypeError:
|
||||
if msg.From != ServerPeerID {
|
||||
return ErrInvalidErrorSource
|
||||
}
|
||||
if msg.FileName != "" {
|
||||
return ErrUnexpectedFileName
|
||||
}
|
||||
if !utf8.Valid(msg.Body) {
|
||||
return ErrInvalidTextBody
|
||||
}
|
||||
default:
|
||||
return ErrInvalidMessageType
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// writeFull 会持续写入,直到所有字节都写完或者底层返回错误。
|
||||
// 这样可以避免某些 Writer 发生部分写入时破坏帧格式。
|
||||
func writeFull(w io.Writer, data []byte) error {
|
||||
for len(data) > 0 {
|
||||
n, err := w.Write(data)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if n == 0 {
|
||||
return io.ErrShortWrite
|
||||
}
|
||||
data = data[n:]
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
507
go/cmd/internal/protocol/codec_test.go
Normal file
507
go/cmd/internal/protocol/codec_test.go
Normal file
@@ -0,0 +1,507 @@
|
||||
package protocol
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/binary"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestEncodeDecodeMessageTextASCII 验证 ASCII 文本可以按 text 消息往返编解码。
|
||||
func TestEncodeDecodeMessageTextASCII(t *testing.T) {
|
||||
original := Message{
|
||||
Type: MessageTypeText,
|
||||
ID: 42,
|
||||
From: "peer-a",
|
||||
To: "peer-b",
|
||||
Body: []byte("hello"),
|
||||
}
|
||||
|
||||
data, err := EncodeMessage(original)
|
||||
if err != nil {
|
||||
t.Fatalf("EncodeMessage() error = %v", err)
|
||||
}
|
||||
|
||||
decoded, err := DecodeMessage(data)
|
||||
if err != nil {
|
||||
t.Fatalf("DecodeMessage() error = %v", err)
|
||||
}
|
||||
|
||||
if !reflect.DeepEqual(decoded, original) {
|
||||
t.Fatalf("round trip mismatch: got %+v want %+v", decoded, original)
|
||||
}
|
||||
}
|
||||
|
||||
// TestEncodeDecodeMessageTextUTF8 验证 text 消息允许合法 UTF-8,
|
||||
// 从而天然兼容 ASCII 之外的普通文本。
|
||||
func TestEncodeDecodeMessageTextUTF8(t *testing.T) {
|
||||
original := Message{
|
||||
Type: MessageTypeText,
|
||||
ID: 43,
|
||||
From: "peer-a",
|
||||
To: "peer-b",
|
||||
Body: []byte("你好, world"),
|
||||
}
|
||||
|
||||
data, err := EncodeMessage(original)
|
||||
if err != nil {
|
||||
t.Fatalf("EncodeMessage() error = %v", err)
|
||||
}
|
||||
|
||||
decoded, err := DecodeMessage(data)
|
||||
if err != nil {
|
||||
t.Fatalf("DecodeMessage() error = %v", err)
|
||||
}
|
||||
|
||||
if !reflect.DeepEqual(decoded, original) {
|
||||
t.Fatalf("round trip mismatch: got %+v want %+v", decoded, original)
|
||||
}
|
||||
}
|
||||
|
||||
// TestEncodeDecodeMessageFile 验证 file 消息会保留文件名和原始二进制正文。
|
||||
func TestEncodeDecodeMessageFile(t *testing.T) {
|
||||
original := Message{
|
||||
Type: MessageTypeFile,
|
||||
ID: 44,
|
||||
From: "peer-a",
|
||||
To: "peer-b",
|
||||
FileName: "data.bin",
|
||||
Body: []byte{0x00, 0xff, 0x10, 0x7f},
|
||||
}
|
||||
|
||||
data, err := EncodeMessage(original)
|
||||
if err != nil {
|
||||
t.Fatalf("EncodeMessage() error = %v", err)
|
||||
}
|
||||
|
||||
decoded, err := DecodeMessage(data)
|
||||
if err != nil {
|
||||
t.Fatalf("DecodeMessage() error = %v", err)
|
||||
}
|
||||
|
||||
if !reflect.DeepEqual(decoded, original) {
|
||||
t.Fatalf("round trip mismatch: got %+v want %+v", decoded, original)
|
||||
}
|
||||
}
|
||||
|
||||
// TestEncodeDecodeMessageRegister 验证 register 控制消息也能正常编解码。
|
||||
func TestEncodeDecodeMessageRegister(t *testing.T) {
|
||||
original := Message{
|
||||
Type: MessageTypeRegister,
|
||||
ID: 45,
|
||||
From: "peer-a",
|
||||
To: ServerPeerID,
|
||||
Body: []byte{},
|
||||
}
|
||||
|
||||
data, err := EncodeMessage(original)
|
||||
if err != nil {
|
||||
t.Fatalf("EncodeMessage() error = %v", err)
|
||||
}
|
||||
|
||||
decoded, err := DecodeMessage(data)
|
||||
if err != nil {
|
||||
t.Fatalf("DecodeMessage() error = %v", err)
|
||||
}
|
||||
|
||||
if !reflect.DeepEqual(decoded, original) {
|
||||
t.Fatalf("round trip mismatch: got %+v want %+v", decoded, original)
|
||||
}
|
||||
}
|
||||
|
||||
// TestEncodeDecodeMessageError 验证 error 控制消息会保留 UTF-8 错误文本。
|
||||
func TestEncodeDecodeMessageError(t *testing.T) {
|
||||
original := Message{
|
||||
Type: MessageTypeError,
|
||||
ID: 46,
|
||||
From: ServerPeerID,
|
||||
To: "peer-a",
|
||||
Body: []byte("unknown target"),
|
||||
}
|
||||
|
||||
data, err := EncodeMessage(original)
|
||||
if err != nil {
|
||||
t.Fatalf("EncodeMessage() error = %v", err)
|
||||
}
|
||||
|
||||
decoded, err := DecodeMessage(data)
|
||||
if err != nil {
|
||||
t.Fatalf("DecodeMessage() error = %v", err)
|
||||
}
|
||||
|
||||
if !reflect.DeepEqual(decoded, original) {
|
||||
t.Fatalf("round trip mismatch: got %+v want %+v", decoded, original)
|
||||
}
|
||||
}
|
||||
|
||||
// TestWriteReadFrame 单独验证最底层的长度前缀帧逻辑,
|
||||
// 不依赖 Message 结构,方便确认 TCP 粘包拆包问题是否被正确处理。
|
||||
func TestWriteReadFrame(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
payload := []byte("header+body")
|
||||
|
||||
if err := WriteFrame(&buf, payload); err != nil {
|
||||
t.Fatalf("WriteFrame() error = %v", err)
|
||||
}
|
||||
|
||||
got, err := ReadFrame(&buf)
|
||||
if err != nil {
|
||||
t.Fatalf("ReadFrame() error = %v", err)
|
||||
}
|
||||
|
||||
if !bytes.Equal(got, payload) {
|
||||
t.Fatalf("payload mismatch: got %q want %q", got, payload)
|
||||
}
|
||||
}
|
||||
|
||||
// TestWriteReadMessageAllowsEmptyBody 验证空文本和空文件都可以正常通过协议层,
|
||||
// 因为外层帧非空的前提下,空正文是合法业务内容。
|
||||
func TestWriteReadMessageAllowsEmptyBody(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
message Message
|
||||
}{
|
||||
{
|
||||
name: "empty text",
|
||||
message: Message{
|
||||
Type: MessageTypeText,
|
||||
ID: 1,
|
||||
From: "peer-a",
|
||||
To: "peer-b",
|
||||
Body: []byte(""),
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "empty file",
|
||||
message: Message{
|
||||
Type: MessageTypeFile,
|
||||
ID: 2,
|
||||
From: "peer-a",
|
||||
To: "peer-b",
|
||||
FileName: "empty.txt",
|
||||
Body: []byte{},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
|
||||
if err := WriteMessage(&buf, tt.message); err != nil {
|
||||
t.Fatalf("WriteMessage() error = %v", err)
|
||||
}
|
||||
|
||||
got, err := ReadMessage(&buf)
|
||||
if err != nil {
|
||||
t.Fatalf("ReadMessage() error = %v", err)
|
||||
}
|
||||
|
||||
if !reflect.DeepEqual(got, tt.message) {
|
||||
t.Fatalf("round trip mismatch: got %+v want %+v", got, tt.message)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestWriteReadMessageRejectsInvalidMessages 验证协议层会在编码前拦住明显非法的消息。
|
||||
func TestWriteReadMessageRejectsInvalidMessages(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
message Message
|
||||
wantErr error
|
||||
}{
|
||||
{
|
||||
name: "invalid type",
|
||||
message: Message{
|
||||
Type: MessageType("unknown"),
|
||||
ID: 1,
|
||||
From: "peer-a",
|
||||
To: "peer-b",
|
||||
},
|
||||
wantErr: ErrInvalidMessageType,
|
||||
},
|
||||
{
|
||||
name: "missing from",
|
||||
message: Message{
|
||||
Type: MessageTypeText,
|
||||
ID: 2,
|
||||
To: "peer-b",
|
||||
},
|
||||
wantErr: ErrMissingFrom,
|
||||
},
|
||||
{
|
||||
name: "missing to",
|
||||
message: Message{
|
||||
Type: MessageTypeText,
|
||||
ID: 3,
|
||||
From: "peer-a",
|
||||
},
|
||||
wantErr: ErrMissingTo,
|
||||
},
|
||||
{
|
||||
name: "text with file name",
|
||||
message: Message{
|
||||
Type: MessageTypeText,
|
||||
ID: 4,
|
||||
From: "peer-a",
|
||||
To: "peer-b",
|
||||
FileName: "bad.txt",
|
||||
Body: []byte("hello"),
|
||||
},
|
||||
wantErr: ErrUnexpectedFileName,
|
||||
},
|
||||
{
|
||||
name: "text with invalid utf8",
|
||||
message: Message{
|
||||
Type: MessageTypeText,
|
||||
ID: 5,
|
||||
From: "peer-a",
|
||||
To: "peer-b",
|
||||
Body: []byte{0xff, 0xfe},
|
||||
},
|
||||
wantErr: ErrInvalidTextBody,
|
||||
},
|
||||
{
|
||||
name: "file without file name",
|
||||
message: Message{
|
||||
Type: MessageTypeFile,
|
||||
ID: 6,
|
||||
From: "peer-a",
|
||||
To: "peer-b",
|
||||
Body: []byte{0x01},
|
||||
},
|
||||
wantErr: ErrMissingFileName,
|
||||
},
|
||||
{
|
||||
name: "register with wrong target",
|
||||
message: Message{
|
||||
Type: MessageTypeRegister,
|
||||
ID: 7,
|
||||
From: "peer-a",
|
||||
To: "peer-b",
|
||||
},
|
||||
wantErr: ErrInvalidRegisterTarget,
|
||||
},
|
||||
{
|
||||
name: "register with body",
|
||||
message: Message{
|
||||
Type: MessageTypeRegister,
|
||||
ID: 8,
|
||||
From: "peer-a",
|
||||
To: ServerPeerID,
|
||||
Body: []byte("unexpected"),
|
||||
},
|
||||
wantErr: ErrUnexpectedBody,
|
||||
},
|
||||
{
|
||||
name: "error with wrong source",
|
||||
message: Message{
|
||||
Type: MessageTypeError,
|
||||
ID: 9,
|
||||
From: "peer-a",
|
||||
To: "peer-b",
|
||||
Body: []byte("bad"),
|
||||
},
|
||||
wantErr: ErrInvalidErrorSource,
|
||||
},
|
||||
{
|
||||
name: "error with file name",
|
||||
message: Message{
|
||||
Type: MessageTypeError,
|
||||
ID: 10,
|
||||
From: ServerPeerID,
|
||||
To: "peer-a",
|
||||
FileName: "bad.txt",
|
||||
Body: []byte("bad"),
|
||||
},
|
||||
wantErr: ErrUnexpectedFileName,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
_, err := EncodeMessage(tt.message)
|
||||
if !errors.Is(err, tt.wantErr) {
|
||||
t.Fatalf("EncodeMessage() error = %v, want %v", err, tt.wantErr)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestReadFrameRejectsInvalidLength 验证长度为 0 的帧会被当成非法输入,
|
||||
// 而不是被当成一条合法的空消息。
|
||||
func TestReadFrameRejectsInvalidLength(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
|
||||
if err := binary.Write(&buf, binary.BigEndian, uint32(0)); err != nil {
|
||||
t.Fatalf("binary.Write() error = %v", err)
|
||||
}
|
||||
|
||||
_, err := ReadFrame(&buf)
|
||||
if !errors.Is(err, ErrInvalidFrameLength) {
|
||||
t.Fatalf("ReadFrame() error = %v, want %v", err, ErrInvalidFrameLength)
|
||||
}
|
||||
}
|
||||
|
||||
// TestReadFrameRejectsTooLargeFrame 验证超大帧会在分配消息体前被拒绝,
|
||||
// 从而保证最大长度限制真正生效。
|
||||
func TestReadFrameRejectsTooLargeFrame(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
|
||||
if err := binary.Write(&buf, binary.BigEndian, uint32(MaxFrameSize+1)); err != nil {
|
||||
t.Fatalf("binary.Write() error = %v", err)
|
||||
}
|
||||
|
||||
_, err := ReadFrame(&buf)
|
||||
if !errors.Is(err, ErrFrameTooLarge) {
|
||||
t.Fatalf("ReadFrame() error = %v, want %v", err, ErrFrameTooLarge)
|
||||
}
|
||||
}
|
||||
|
||||
// TestWriteFrameRejectsEmptyPayload 验证写入端和读取端的约束保持一致:
|
||||
// 既然读取端不接受 0 长度帧,写入端也不应该产生这种帧。
|
||||
func TestWriteFrameRejectsEmptyPayload(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
|
||||
err := WriteFrame(&buf, nil)
|
||||
if !errors.Is(err, ErrInvalidFrameLength) {
|
||||
t.Fatalf("WriteFrame() error = %v, want %v", err, ErrInvalidFrameLength)
|
||||
}
|
||||
}
|
||||
|
||||
// TestDecodeMessageRejectsInvalidHeaderLength 验证无法切出完整头部时会被立即拒绝。
|
||||
func TestDecodeMessageRejectsInvalidHeaderLength(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
data []byte
|
||||
}{
|
||||
{
|
||||
name: "too short for header len",
|
||||
data: []byte{0x00, 0x00, 0x00},
|
||||
},
|
||||
{
|
||||
name: "zero header len",
|
||||
data: []byte{0x00, 0x00, 0x00, 0x00},
|
||||
},
|
||||
{
|
||||
name: "header len exceeds payload",
|
||||
data: []byte{0x00, 0x00, 0x00, 0x10, '{', '}'},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
_, err := DecodeMessage(tt.data)
|
||||
if !errors.Is(err, ErrInvalidHeaderLength) {
|
||||
t.Fatalf("DecodeMessage() error = %v, want %v", err, ErrInvalidHeaderLength)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestDecodeMessageRejectsInvalidHeaderJSON 验证头部 JSON 非法时能返回明确错误。
|
||||
func TestDecodeMessageRejectsInvalidHeaderJSON(t *testing.T) {
|
||||
data := append([]byte{0x00, 0x00, 0x00, 0x09}, []byte("{invalid}")...)
|
||||
|
||||
_, err := DecodeMessage(data)
|
||||
if !errors.Is(err, ErrInvalidHeaderJSON) {
|
||||
t.Fatalf("DecodeMessage() error = %v, want %v", err, ErrInvalidHeaderJSON)
|
||||
}
|
||||
}
|
||||
|
||||
// TestDecodeMessageRejectsContentLengthMismatch 验证头部声明长度和实际正文不一致时会失败。
|
||||
func TestDecodeMessageRejectsContentLengthMismatch(t *testing.T) {
|
||||
headerPayload, err := json.Marshal(messageHeader{
|
||||
Type: MessageTypeText,
|
||||
ID: 7,
|
||||
From: "peer-a",
|
||||
To: "peer-b",
|
||||
ContentLength: 10,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("json.Marshal() error = %v", err)
|
||||
}
|
||||
|
||||
var data bytes.Buffer
|
||||
if err := binary.Write(&data, binary.BigEndian, uint32(len(headerPayload))); err != nil {
|
||||
t.Fatalf("binary.Write() error = %v", err)
|
||||
}
|
||||
if _, err := data.Write(headerPayload); err != nil {
|
||||
t.Fatalf("data.Write(headerPayload) error = %v", err)
|
||||
}
|
||||
if _, err := data.Write([]byte("hello")); err != nil {
|
||||
t.Fatalf("data.Write(body) error = %v", err)
|
||||
}
|
||||
|
||||
_, err = DecodeMessage(data.Bytes())
|
||||
if !errors.Is(err, ErrInvalidContentLength) {
|
||||
t.Fatalf("DecodeMessage() error = %v, want %v", err, ErrInvalidContentLength)
|
||||
}
|
||||
}
|
||||
|
||||
// TestReadMultipleMessages 模拟同一条流中连续写入 text 和 file,
|
||||
// 验证读取端每次都能严格停在当前帧边界,不会串包。
|
||||
func TestReadMultipleMessages(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
|
||||
first := Message{
|
||||
Type: MessageTypeText,
|
||||
ID: 1,
|
||||
From: "peer-a",
|
||||
To: "peer-b",
|
||||
Body: []byte("hello"),
|
||||
}
|
||||
|
||||
second := Message{
|
||||
Type: MessageTypeFile,
|
||||
ID: 2,
|
||||
From: "peer-b",
|
||||
To: "peer-a",
|
||||
FileName: "payload.bin",
|
||||
Body: []byte{0x01, 0x02, 0x03},
|
||||
}
|
||||
|
||||
if err := WriteMessage(&buf, first); err != nil {
|
||||
t.Fatalf("WriteMessage(first) error = %v", err)
|
||||
}
|
||||
if err := WriteMessage(&buf, second); err != nil {
|
||||
t.Fatalf("WriteMessage(second) error = %v", err)
|
||||
}
|
||||
|
||||
gotFirst, err := ReadMessage(&buf)
|
||||
if err != nil {
|
||||
t.Fatalf("ReadMessage(first) error = %v", err)
|
||||
}
|
||||
gotSecond, err := ReadMessage(&buf)
|
||||
if err != nil {
|
||||
t.Fatalf("ReadMessage(second) error = %v", err)
|
||||
}
|
||||
|
||||
if !reflect.DeepEqual(gotFirst, first) {
|
||||
t.Fatalf("first message mismatch: got %+v want %+v", gotFirst, first)
|
||||
}
|
||||
if !reflect.DeepEqual(gotSecond, second) {
|
||||
t.Fatalf("second message mismatch: got %+v want %+v", gotSecond, second)
|
||||
}
|
||||
}
|
||||
|
||||
// TestReadMessageWrapsDecodeError 验证 ReadMessage 在返回错误时会保留解码阶段上下文。
|
||||
func TestReadMessageWrapsDecodeError(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
|
||||
if err := WriteFrame(&buf, append([]byte{0x00, 0x00, 0x00, 0x09}, []byte("{invalid}")...)); err != nil {
|
||||
t.Fatalf("WriteFrame() error = %v", err)
|
||||
}
|
||||
|
||||
_, err := ReadMessage(&buf)
|
||||
if err == nil {
|
||||
t.Fatal("ReadMessage() error = nil, want non-nil")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "decode message") {
|
||||
t.Fatalf("ReadMessage() error = %v, want wrapped decode error", err)
|
||||
}
|
||||
}
|
||||
33
go/cmd/internal/protocol/message.go
Normal file
33
go/cmd/internal/protocol/message.go
Normal file
@@ -0,0 +1,33 @@
|
||||
package protocol
|
||||
|
||||
// MessageType 表示一条消息的传输类型。
|
||||
// v1 只区分普通文本和文件两类负载。
|
||||
type MessageType string
|
||||
|
||||
const (
|
||||
// MessageTypeText 表示正文按 UTF-8 文本解释,天然兼容 ASCII。
|
||||
MessageTypeText MessageType = "text"
|
||||
// MessageTypeFile 表示正文是原始文件字节。
|
||||
MessageTypeFile MessageType = "file"
|
||||
// MessageTypeRegister 表示 peer 向 server 显式注册自己的身份。
|
||||
MessageTypeRegister MessageType = "register"
|
||||
// MessageTypeError 表示 server 向 peer 返回错误信息。
|
||||
MessageTypeError MessageType = "error"
|
||||
)
|
||||
|
||||
// ServerPeerID 是协议中约定的 server 端固定标识。
|
||||
const ServerPeerID = "server"
|
||||
|
||||
// Message 是 peer 和 server 共用的传输消息结构。
|
||||
// 头部元信息会被编码为 JSON,Body 则作为原始字节拼接在头部之后。
|
||||
type Message struct {
|
||||
Type MessageType `json:"type"` // 消息类型,只允许 text 或 file。
|
||||
ID uint64 `json:"id"` // 由发送方生成,用于追踪消息。
|
||||
From string `json:"from"` // 发送方标识。
|
||||
To string `json:"to"` // 接收方标识。
|
||||
|
||||
// FileName 仅在 Type 为 file 时使用。
|
||||
FileName string `json:"file_name,omitempty"`
|
||||
// Body 是真正传输的正文内容,不进入头部 JSON。
|
||||
Body []byte `json:"-"`
|
||||
}
|
||||
67
go/cmd/latencysummary/main.go
Normal file
67
go/cmd/latencysummary/main.go
Normal file
@@ -0,0 +1,67 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"flag"
|
||||
"log"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"omnisocketgo/cmd/internal/latencylog"
|
||||
)
|
||||
|
||||
type stringListFlag []string
|
||||
|
||||
func (f *stringListFlag) String() string {
|
||||
return ""
|
||||
}
|
||||
|
||||
func (f *stringListFlag) Set(value string) error {
|
||||
*f = append(*f, value)
|
||||
return nil
|
||||
}
|
||||
|
||||
func main() {
|
||||
var inputPaths stringListFlag
|
||||
outputPath := flag.String("output", "latency-summary.jsonl", "output JSONL file for summarized latency metrics")
|
||||
// shared-max-offset 是一个可选参数,用于在对齐输入文件的 per-file max message_id 后,排除掉最新的共享 message_id 以外的记录。它指定了要排除的共享 message_id 的数量。
|
||||
sharedMaxOffset := flag.Uint64("shared-max-offset", 1, "number of newest shared message IDs to exclude after aligning inputs by per-file max message_id")
|
||||
flag.Var(&inputPaths, "input", "raw latency JSONL file path; can be provided multiple times")
|
||||
flag.Parse()
|
||||
|
||||
if len(inputPaths) == 0 {
|
||||
log.Fatal("at least one -input raw latency log file is required")
|
||||
}
|
||||
|
||||
events, sharedMaxMessageID, err := latencylog.LoadEventsFromFilesWithSharedMaxOffset(inputPaths, *sharedMaxOffset)
|
||||
if err != nil {
|
||||
log.Fatalf("load raw latency logs: %v", err)
|
||||
}
|
||||
// sharedMaxMessageID 可能为 nil,表示没有可用的共享 message_id 截止值(例如因为输入文件中没有共享消息)。在这种情况下,我们将继续处理所有事件,但会记录一个警告。
|
||||
if sharedMaxMessageID != nil {
|
||||
log.Printf("using shared message_id cutoff <= %d (shared-max-offset=%d)", *sharedMaxMessageID, *sharedMaxOffset)
|
||||
} else {
|
||||
log.Printf("no shared message_id cutoff available after applying shared-max-offset=%d", *sharedMaxOffset)
|
||||
}
|
||||
|
||||
summaries := latencylog.SummarizeEvents(events)
|
||||
if err := latencylog.WriteSummariesJSONL(*outputPath, summaries); err != nil {
|
||||
log.Fatalf("write latency summary: %v", err)
|
||||
}
|
||||
|
||||
chartPath := replaceFileExt(*outputPath, ".html")
|
||||
if err := latencylog.WriteSummariesHTMLChart(chartPath, summaries); err != nil {
|
||||
log.Fatalf("write latency chart: %v", err)
|
||||
}
|
||||
|
||||
log.Printf("wrote %d summarized message records to %s", len(summaries), *outputPath)
|
||||
log.Printf("wrote simple latency chart to %s", chartPath)
|
||||
}
|
||||
|
||||
func replaceFileExt(path, ext string) string {
|
||||
currentExt := filepath.Ext(path)
|
||||
if currentExt == "" {
|
||||
return path + ext
|
||||
}
|
||||
|
||||
return strings.TrimSuffix(path, currentExt) + ext
|
||||
}
|
||||
16
go/go.mod
Normal file
16
go/go.mod
Normal file
@@ -0,0 +1,16 @@
|
||||
module omnisocketgo
|
||||
|
||||
go 1.24.0
|
||||
|
||||
require github.com/xtaci/kcp-go/v5 v5.6.70
|
||||
|
||||
require (
|
||||
github.com/klauspost/cpuid/v2 v2.2.6 // indirect
|
||||
github.com/klauspost/reedsolomon v1.12.0 // indirect
|
||||
github.com/pkg/errors v0.9.1 // indirect
|
||||
github.com/tjfoc/gmsm v1.4.1 // indirect
|
||||
golang.org/x/crypto v0.45.0 // indirect
|
||||
golang.org/x/net v0.47.0 // indirect
|
||||
golang.org/x/sys v0.38.0 // indirect
|
||||
golang.org/x/time v0.14.0 // indirect
|
||||
)
|
||||
98
go/go.sum
Normal file
98
go/go.sum
Normal file
@@ -0,0 +1,98 @@
|
||||
cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=
|
||||
github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
|
||||
github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU=
|
||||
github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw=
|
||||
github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc=
|
||||
github.com/davecgh/go-spew v1.1.0 h1:ZDRjVQ15GmhC3fiQ8ni8+OwkZQO4DARzQgrnXU1Liz8=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
|
||||
github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98=
|
||||
github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c=
|
||||
github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q=
|
||||
github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A=
|
||||
github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
|
||||
github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
|
||||
github.com/golang/protobuf v1.3.3/go.mod h1:vzj43D7+SQXF/4pzW/hwtAqwc6iTitCiVSaWz5lYuqw=
|
||||
github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8=
|
||||
github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA=
|
||||
github.com/golang/protobuf v1.4.0-rc.2/go.mod h1:LlEzMj4AhA7rCAGe4KMBDvJI+AwstrUpVNzEA03Pprs=
|
||||
github.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:WU3c8KckQ9AFe+yFwt9sWVRKCVIyN9cPHBJSNnbL67w=
|
||||
github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0=
|
||||
github.com/golang/protobuf v1.4.2/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI=
|
||||
github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M=
|
||||
github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
|
||||
github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
|
||||
github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||
github.com/klauspost/cpuid/v2 v2.2.6 h1:ndNyv040zDGIDh8thGkXYjnFtiN02M1PVVF+JE/48xc=
|
||||
github.com/klauspost/cpuid/v2 v2.2.6/go.mod h1:Lcz8mBdAVJIBVzewtcLocK12l3Y+JytZYpaMropDUws=
|
||||
github.com/klauspost/reedsolomon v1.12.0 h1:I5FEp3xSwVCcEh3F5A7dofEfhXdF/bWhQWPH+XwBFno=
|
||||
github.com/klauspost/reedsolomon v1.12.0/go.mod h1:EPLZJeh4l27pUGC3aXOjheaoh1I9yut7xTURiW3LQ9Y=
|
||||
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
|
||||
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA=
|
||||
github.com/stretchr/testify v1.6.1 h1:hDPOHmpOpP40lSULcqw7IrRb/u7w6RpDC9399XyoNd0=
|
||||
github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/tjfoc/gmsm v1.4.1 h1:aMe1GlZb+0bLjn+cKTPEvvn9oUEBlJitaZiiBwsbgho=
|
||||
github.com/tjfoc/gmsm v1.4.1/go.mod h1:j4INPkHWMrhJb38G+J6W4Tw0AbuN8Thu3PbdVYhVcTE=
|
||||
github.com/xtaci/kcp-go/v5 v5.6.70 h1:AYX0QZl6PqmNj2IdYGZGuBfZuDUkUfl+eHYNijCqaO0=
|
||||
github.com/xtaci/kcp-go/v5 v5.6.70/go.mod h1:9O3D8WR+cyyUjGiTILYfg17vn72otWuXK2AFfqIe6CM=
|
||||
github.com/xtaci/lossyconn v0.0.0-20190602105132-8df528c0c9ae h1:J0GxkO96kL4WF+AIT3M4mfUVinOCPgf2uUWYFUzN0sM=
|
||||
github.com/xtaci/lossyconn v0.0.0-20190602105132-8df528c0c9ae/go.mod h1:gXtu8J62kEgmN++bm9BVICuT/e8yiLI2KFobd/TRFsE=
|
||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
|
||||
golang.org/x/crypto v0.0.0-20201012173705-84dcc777aaee/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
|
||||
golang.org/x/crypto v0.45.0 h1:jMBrvKuj23MTlT0bQEOBcAE0mjg8mK9RXFhRH6nyF3Q=
|
||||
golang.org/x/crypto v0.45.0/go.mod h1:XTGrrkGJve7CYK7J8PEww4aY7gM3qMCElcJQ8n8JdX4=
|
||||
golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
|
||||
golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
|
||||
golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU=
|
||||
golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
|
||||
golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||
golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||
golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||
golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
||||
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
||||
golang.org/x/net v0.0.0-20201010224723-4f7140c49acb/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
|
||||
golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY=
|
||||
golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU=
|
||||
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
|
||||
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc=
|
||||
golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI=
|
||||
golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4=
|
||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY=
|
||||
golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
|
||||
golang.org/x/tools v0.0.0-20190524140312-2c0ae7006135/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=
|
||||
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM=
|
||||
google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
|
||||
google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc=
|
||||
google.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc=
|
||||
google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c=
|
||||
google.golang.org/grpc v1.23.0/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg=
|
||||
google.golang.org/grpc v1.25.1/go.mod h1:c3i+UQWmh7LiEpx4sFZnkU36qjEYZ0imhYfXVyQciAY=
|
||||
google.golang.org/grpc v1.31.0/go.mod h1:N36X2cJ7JwdamYAgDz+s+rVMFjt3numwzf/HckM8pak=
|
||||
google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8=
|
||||
google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0=
|
||||
google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM=
|
||||
google.golang.org/protobuf v1.20.1-0.20200309200217-e05f789c0967/go.mod h1:A+miEFZTKqfCUM6K7xSMQL9OKL/b6hQv+e19PK+JZNE=
|
||||
google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzikPIcrTAo=
|
||||
google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
|
||||
honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
|
||||
Reference in New Issue
Block a user