refactor: project structure

This commit is contained in:
2026-05-21 21:13:14 +08:00
parent e60a1ef082
commit f2936ac6ad
8 changed files with 298 additions and 247 deletions

31
.github/workflows/release.yml vendored Normal file
View File

@@ -0,0 +1,31 @@
name: Release
on:
push:
tags:
- "v*"
permissions:
contents: write
jobs:
release:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Setup Go
uses: actions/setup-go@v5
with:
go-version: "1.22"
- name: Run GoReleaser
uses: goreleaser/goreleaser-action@v6
with:
version: "~> v2"
args: release --clean
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

36
.goreleaser.yml Normal file
View File

@@ -0,0 +1,36 @@
version: 2
builds:
- binary: tcping
env:
- CGO_ENABLED=0
goos:
- linux
- darwin
- windows
goarch:
- amd64
- arm64
- "386"
ignore:
- goos: darwin
goarch: "386"
ldflags:
- -w -s -buildid=
archives:
- format: tar.gz
name_template: "tcping-{{ .Os }}-{{ .Arch }}"
format_overrides:
- goos: windows
format: zip
checksum:
name_template: checksums.txt
changelog:
sort: asc
filters:
exclude:
- "^docs:"
- "^test:"

View File

@@ -1,39 +0,0 @@
NAME=tcping
BINDIR=bin
GOBUILD=CGO_ENABLED=0 go build -trimpath -ldflags '-w -s -buildid='
VERSION=1.2.0
# The -w and -s flags reduce binary sizes by excluding unnecessary symbols and debug info
# The -buildid= flag makes builds reproducible
all: releases
linux-amd64:
GOARCH=amd64 GOOS=linux $(GOBUILD) -o $(BINDIR)/$(NAME)-$@
linux-arm64:
GOARCH=arm64 GOOS=linux $(GOBUILD) -o $(BINDIR)/$(NAME)-$@
darwin-amd64:
GOARCH=amd64 GOOS=darwin $(GOBUILD) -o $(BINDIR)/$(NAME)-$@
darwin-arm64:
GOARCH=arm64 GOOS=darwin $(GOBUILD) -o $(BINDIR)/$(NAME)-$@
windows-amd64:
GOARCH=amd64 GOOS=windows $(GOBUILD) -o $(BINDIR)/$(NAME)-$@.exe
windows-386:
GOARCH=386 GOOS=windows $(GOBUILD) -o $(BINDIR)/$(NAME)-$@.exe
releases: linux-amd64 linux-arm64 darwin-amd64 darwin-arm64 windows-amd64 windows-386
chmod +x $(BINDIR)/$(NAME)-*
tar zcf $(BINDIR)/$(NAME)-linux-amd64-$(VERSION).tar.gz -C $(BINDIR) $(NAME)-linux-amd64
tar zcf $(BINDIR)/$(NAME)-linux-arm64-$(VERSION).tar.gz -C $(BINDIR) $(NAME)-linux-arm64
tar zcf $(BINDIR)/$(NAME)-darwin-amd64-$(VERSION).tar.gz -C $(BINDIR) $(NAME)-darwin-amd64
tar zcf $(BINDIR)/$(NAME)-darwin-arm64-$(VERSION).tar.gz -C $(BINDIR) $(NAME)-darwin-arm64
zip -j $(BINDIR)/$(NAME)-windows-386-$(VERSION).zip $(BINDIR)/$(NAME)-windows-386.exe
zip -j $(BINDIR)/$(NAME)-windows-amd64-$(VERSION).zip $(BINDIR)/$(NAME)-windows-amd64.exe
rm -f $(BINDIR)/$(NAME)-darwin-amd64 $(BINDIR)/$(NAME)-darwin-arm64 $(BINDIR)/$(NAME)-linux-amd64 $(BINDIR)/$(NAME)-linux-arm64 $(BINDIR)/$(NAME)-windows-386.exe $(BINDIR)/$(NAME)-windows-amd64.exe
clean:
rm $(BINDIR)/*-$(VERSION).tar.gz $(BINDIR)/*-$(VERSION).zip

2
go.mod
View File

@@ -1,4 +1,4 @@
module tcping
module github.com/taurusxin/tcping-go
go 1.22.1

168
internal/cli/cli.go Normal file
View File

@@ -0,0 +1,168 @@
package cli
import (
"fmt"
"net"
"os"
"os/signal"
"strconv"
"syscall"
"time"
flag "github.com/spf13/pflag"
"github.com/taurusxin/tcping-go/internal/filter"
"github.com/taurusxin/tcping-go/internal/stats"
)
const version = "1.3.0"
// Run executes the tcping CLI.
func Run() {
var (
port int
count int
timeoutDuration time.Duration
infinite bool
ipv6 bool
fast bool
successCount int
successDelay []time.Duration
attemptCount int
stopped bool
)
showHelp := flag.BoolP("help", "h", false, "Show help")
showVersion := flag.BoolP("version", "v", false, "Show version")
flag.IntVarP(&count, "count", "c", 4, "Number of probes")
flag.DurationVarP(&timeoutDuration, "timeout", "s", 2*time.Second, "Timeout")
flag.BoolVarP(&infinite, "infinite", "t", false, "Infinite probes")
flag.BoolVarP(&ipv6, "ipv6", "6", false, "Use IPv6; requires domain name")
flag.BoolVarP(&fast, "fast", "f", false, "Fast mode; reduce delay between successful probes")
flag.Parse()
if *showHelp {
flag.Usage()
os.Exit(0)
}
if *showVersion {
fmt.Printf("tcping v%s\n", version)
os.Exit(0)
}
args := flag.Args()
if len(args) < 1 {
flag.Usage()
os.Exit(1)
}
hostname := args[0]
port = 80
if len(args) >= 2 {
p, err := strconv.Atoi(args[1])
if err != nil {
fmt.Println("Port must be an integer")
os.Exit(1)
}
port = p
}
if port < 1 || port > 65535 {
fmt.Println("Port must be between 1 and 65535")
os.Exit(1)
}
stopped = false
sigChan := make(chan os.Signal, 1)
signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM)
ip := ""
if net.ParseIP(hostname) == nil {
ips, err := net.LookupIP(hostname)
if err != nil {
fmt.Printf("Failed to resolve %s: %s\n", hostname, err)
os.Exit(1)
}
record := "A"
if ipv6 {
record = "AAAA"
}
ip, err = filter.IP(ips, ipv6)
if err != nil {
fmt.Printf("No %s record found for %s\n", record, hostname)
os.Exit(1)
}
fmt.Printf("Using %s %s record: %s\n", hostname, record, ip)
} else {
ip = hostname
}
address := net.JoinHostPort(ip, fmt.Sprintf("%d", port))
if infinite {
count = -1
}
done := make(chan bool, 1)
go func() {
for i := 0; infinite || i < count; i++ {
start := time.Now()
conn, err := net.DialTimeout("tcp", address, timeoutDuration)
duration := time.Since(start)
attemptCount++
fmt.Printf("[%d] ", i+1)
if err != nil {
fmt.Printf("Connection to %s failed: %s\n", address, "timeout")
} else {
successCount++
successDelay = append(successDelay, duration)
fmt.Printf("Reply from %s: time=%s\n", address, fmt.Sprintf("%.3fms", float64(duration)/float64(time.Millisecond)))
conn.Close()
}
if !infinite && attemptCount >= count {
break
}
if fast {
time.Sleep(150 * time.Millisecond)
} else {
time.Sleep(1 * time.Second)
}
}
done <- true
}()
select {
case <-sigChan:
fmt.Println("\nTest interrupted by user")
stopped = true
case <-done:
}
if !stopped {
fmt.Println()
}
successDelayMs := make([]float64, len(successDelay))
for i, delay := range successDelay {
successDelayMs[i] = float64(delay) / float64(time.Millisecond)
}
minDelay := 0.0
maxDelay := 0.0
avgDelay := 0.0
successRate := 0.0
if successCount > 0 {
minDelay = stats.Min(successDelayMs)
maxDelay = stats.Max(successDelayMs)
avgDelay = stats.Avg(successDelayMs)
}
if attemptCount > 0 {
successRate = float64(successCount) / float64(attemptCount) * 100
}
fmt.Printf("Test finished, success %d/%d (%.2f%%)\nmin = %.3fms, max = %.3fms, avg = %.3fms\n", successCount, attemptCount, successRate, minDelay, maxDelay, avgDelay)
}

25
internal/filter/filter.go Normal file
View File

@@ -0,0 +1,25 @@
package filter
import (
"fmt"
"net"
)
// IP filters a list of resolved IPs and returns the first match
// for the requested address family (IPv4 or IPv6).
func IP(ips []net.IP, ipv6 bool) (string, error) {
if ipv6 {
for _, ip := range ips {
if ip.To16() != nil && ip.To4() == nil {
return ip.String(), nil
}
}
} else {
for _, ip := range ips {
if ip.To4() != nil && ip.To16() != nil {
return ip.String(), nil
}
}
}
return "", fmt.Errorf("no suitable IP address found")
}

35
internal/stats/stats.go Normal file
View File

@@ -0,0 +1,35 @@
package stats
// Min returns the minimum value in a float64 slice.
// The slice must not be empty.
func Min(array []float64) float64 {
min := array[0]
for _, value := range array {
if value < min {
min = value
}
}
return min
}
// Max returns the maximum value in a float64 slice.
// The slice must not be empty.
func Max(array []float64) float64 {
max := array[0]
for _, value := range array {
if value > max {
max = value
}
}
return max
}
// Avg returns the arithmetic mean of a float64 slice.
// The slice must not be empty.
func Avg(array []float64) float64 {
sum := 0.0
for _, value := range array {
sum += value
}
return sum / float64(len(array))
}

209
main.go
View File

@@ -1,212 +1,7 @@
package main
import (
"fmt"
"net"
"os"
"os/signal"
"strconv"
"syscall"
"time"
flag "github.com/spf13/pflag"
)
import "github.com/taurusxin/tcping-go/internal/cli"
func main() {
var (
port int
count int
timeoutDuration time.Duration
infinite bool
ipv6 bool
fast bool
successCount int
successDelay []time.Duration
attemptCount int
stopped bool
)
version := "1.2.0"
showHelp := flag.BoolP("help", "h", false, "Show help")
showVersion := flag.BoolP("version", "v", false, "Show version")
flag.IntVarP(&count, "count", "c", 4, "Number of probes, default 4")
flag.DurationVarP(&timeoutDuration, "timeout", "s", 2*time.Second, "Timeout, default 2s")
flag.BoolVarP(&infinite, "infinite", "t", false, "Infinite probes")
flag.BoolVarP(&ipv6, "ipv6", "6", false, "Use IPv6; requires domain name")
flag.BoolVarP(&fast, "fast", "f", false, "Fast mode; reduce delay between successful probes")
flag.Parse()
if *showHelp {
flag.Usage()
os.Exit(0)
}
if *showVersion {
fmt.Printf("tcping v%s\n", version)
os.Exit(0)
}
args := flag.Args()
if len(args) < 1 {
flag.Usage()
os.Exit(1)
}
hostname := args[0]
port = 80
if len(args) >= 2 {
p, err := strconv.Atoi(args[1])
if err != nil {
fmt.Println("Port must be an integer")
os.Exit(1)
}
port = p
}
if port < 1 || port > 65535 {
fmt.Println("Port must be between 1 and 65535")
os.Exit(1)
}
// 设置信号捕获
stopped = false
sigChan := make(chan os.Signal, 1)
signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM)
ip := ""
if net.ParseIP(hostname) == nil {
// 解析域名
ips, err := net.LookupIP(hostname)
if err != nil {
fmt.Printf("Failed to resolve %s: %s\n", hostname, err)
os.Exit(1)
}
record := "A"
if ipv6 {
record = "AAAA"
}
ip, err = filterIP(ips, ipv6)
if err != nil {
fmt.Printf("No %s record found for %s\n", record, hostname)
os.Exit(1)
}
fmt.Printf("Using %s %s record: %s\n", hostname, record, ip)
} else {
ip = hostname
}
address := net.JoinHostPort(ip, fmt.Sprintf("%d", port))
if infinite {
count = -1
}
done := make(chan bool, 1)
go func() {
for i := 0; infinite || i < count; i++ {
start := time.Now()
conn, err := net.DialTimeout("tcp", address, timeoutDuration)
duration := time.Since(start)
attemptCount++
fmt.Printf("[%d] ", i+1)
if err != nil {
fmt.Printf("Connection to %s failed: %s\n", address, "timeout")
} else {
successCount++
successDelay = append(successDelay, duration)
fmt.Printf("Reply from %s: time=%s\n", address, fmt.Sprintf("%.3fms", float64(duration)/float64(time.Millisecond)))
conn.Close()
}
if !infinite && attemptCount >= count {
break
}
if fast {
time.Sleep(150 * time.Millisecond)
} else {
time.Sleep(1 * time.Second)
}
}
done <- true
}()
select {
case <-sigChan:
fmt.Println("\nTest interrupted by user")
stopped = true
case <-done:
}
if !stopped {
fmt.Println()
}
successDelayMs := make([]float64, len(successDelay))
for i, delay := range successDelay {
successDelayMs[i] = float64(delay) / float64(time.Millisecond)
}
minDelay := 0.0
maxDelay := 0.0
avgDelay := 0.0
successRate := 0.0
if successCount > 0 {
minDelay = float64_min(successDelayMs)
maxDelay = float64_max(successDelayMs)
avgDelay = float64_avg(successDelayMs)
}
if attemptCount > 0 {
successRate = float64(successCount) / float64(attemptCount) * 100
}
fmt.Printf("Test finished, success %d/%d (%.2f%%)\nmin = %.3fms, max = %.3fms, avg = %.3fms\n", successCount, attemptCount, successRate, minDelay, maxDelay, avgDelay)
}
func filterIP(ips []net.IP, ipv6 bool) (string, error) {
if ipv6 {
for _, ip := range ips {
if ip.To16() != nil && ip.To4() == nil {
return ip.String(), nil
}
}
} else {
for _, ip := range ips {
if ip.To4() != nil && ip.To16() != nil {
return ip.String(), nil
}
}
}
return "", fmt.Errorf("no suitable IP address found")
}
func float64_min(array []float64) float64 {
min := array[0]
for _, value := range array {
if value < min {
min = value
}
}
return min
}
func float64_max(array []float64) float64 {
max := array[0]
for _, value := range array {
if value > max {
max = value
}
}
return max
}
func float64_avg(array []float64) float64 {
sum := 0.0
for _, value := range array {
sum += value
}
return sum / float64(len(array))
cli.Run()
}