Featured image of post 轻量级日志分析利器:Go实战

轻量级日志分析利器:Go实战

log-analyzer 是一个轻量级、实用的日志分析工具,它以简洁的代码实现了核心的日志分析功能,能够有效提升开发者和测试人员的日常工作效率。后续根据公司需求添加更多的功能。

背景

从重复劳动到自动化提效 在日常开发运维工作中,日志分析是每个工程师都会遇到的"必修课"。当我们需要定位线上问题、分析访问趋势或验证测试结果时,往往需要面对以下痛点场景:

  • 在GB级的日志文件中手动grep关键信息
  • 重复编写awk/sed脚本统计访问量
  • 每次分析日志都需要进行相似的筛选、统计操作,重复劳动让人疲惫不堪
  • 人工分析难以快速提取出日志中的关键指标,例如 IP 访问量、错误状态码分布等。

为此,我基于Go语言开发了这款命令行日志分析工具,它具备以下核心价值:

  1. 极简部署(单文件二进制)
  2. 实时交互式分析
  3. 常用统计场景全覆盖
  4. 毫秒级响应速度
  5. 测试友好型输出

工具功能概览

这款日志分析工具 ( log-analyzer ) 基于 Go 语言开发,充分利用了 Go 语言在处理文本和并发方面的优势,旨在提供简洁、高效的日志分析能力。它主要具备以下核心功能:

  • Top N 统计分析: 快速统计日志中出现频率最高的 IP 地址、HTTP 状态码和请求路径,帮助你快速了解访问概况和潜在热点。
  • 关键词过滤: 支持根据关键词快速筛选出包含特定信息的日志行,例如错误日志、特定用户的访问日志等,方便问题定位。
  • 时间范围过滤: 允许用户指定时间范围,筛选出特定时间段内的日志,例如分析某个时间段内的异常请求或用户行为。

环境准备与技术选型

  1. 开发环境要求

    • Go 1.18+(支持泛型特性)
    • 支持正则表达式的日志格式(如Nginx/Apache通用格式)
    • 内存:100MB+(取决于日志规模)

    关键技术栈

    组件 用途 优势
    bufio.Scanner 大文件流式读取 内存效率高,支持GB级文件处理
    regexp 结构化日志解析 高性能正则匹配(RE2引擎)
    sort.Slice 统计结果排序 灵活的自定义排序实现
    time.Parse 多时区时间解析 精确处理全球分布式系统日志

    选择Go语言的核心考量:

    • 编译为单文件二进制,无依赖部署
    • 原生并发模型适合日志处理场景
    • 卓越的性能表现(相比Python/Node.js)
    • 丰富的标准库覆盖常见需求

核心功能实现解析

Nginx日志格式

Nginx配置

1
2
3
4
5
6
7
    log_format  main    '$remote_addr $host $document_uri [$time_local]  '
			'"$remote_user" "$scheme" "$request" "$status" "$body_bytes_sent" '
			'"X-Forwarded-For: $proxy_add_x_forwarded_for" '
			'"Referer: $http_referer" "User_Agent: $http_user_agent" '
			'"upstream_addr:$upstream_addr" "upstream_cache_status:$upstream_cache_status" '
			'"upstream_status:$upstream_status" "upstream_response_time:$upstream_response_time"';
    access_log  logs/access.log  main;

日志格式

1
2
106.224.56.100 track-backend.xxx.com /trace [28/Jan/2025:23:57:01 +0800]  "-" "https" "POST /trace HTTP/1.1" "200" "50" "X-Forwarded-For: 106.224.56.100, 106.224.56.100" "Referer: -" "User_Agent: okhttp/4.2.2" "upstream_addr:172.18.199.211:80" "upstream_cache_status:-" "upstream_status:200" "upstream_response_time:0.008"
8.135.0.81 ykdgate.xxx.com /api/v1/contract/query/WLSK/21070321435307266422910/LOAN_CONTRACT [28/Jan/2025:23:57:01 +0800]  "-" "https" "GET /api/v1/contract/query/WLSK/21070321435307266422910/LOAN_CONTRACT HTTP/1.1" "200" "120" "X-Forwarded-For: 8.135.0.81, 8.135.0.81" "Referer: -" "User_Agent: Apache-HttpClient/4.5.2 (Java/1.8.0_402)" "upstream_addr:172.18.199.211:80" "upstream_cache_status:-" "upstream_status:200" "upstream_response_time:0.013"

正则表达 https://hiregex.com/

1
(?P<ip>\S+) (?P<host>\S+) (?P<uri>\S+) \[(?P<time>.*?)\]  "(?P<user>.*?)" "(?P<scheme>.*?)" "(?P<request>[^"]+)" "(?P<status>\d+)"

在这里插入图片描述

日志解析引擎设计

1
2
var logPattern = regexp.MustCompile(
    `(?P<ip>\S+) (?P<host>\S+) (?P<uri>\S+) \[(?P<time>.*?)\] "(?P<user>.*?)" "(?P<scheme>.*?)" "(?P<request>[^"]+)" "(?P<status>\d+)"`)

这个正则表达式定义了日志行中各个字段的提取规则,例如 IP 地址、主机名、请求 URI、时间戳、用户、请求协议、请求内容和 HTTP 状态码等。 (?P...) 是 Go 语言正则表达式的命名捕获组,可以将匹配到的内容存储到指定名称的组中,方便后续代码引用。

统计数据结构

1
2
3
4
5
6
type LogStats struct {
    IPs     map[string]int  // IP访问计数器
    Status  map[string]int  // 状态码分布
    Paths   map[string]int  // URI访问排行
    Lines   []string        // 原始日志缓存
}

IPs、 Status 和 Paths 字段使用 map[string]int 存储统计数据,键为字符串类型的 IP 地址、状态码或路径,值为出现次数。 Lines 字段使用字符串切片 []string 存储原始日志行,用于后续的关键词和时间范围过滤。

ProcessLine 函数:日志行的“处理器”

ProcessLine 函数负责逐行处理日志文件,解析每行日志并提取关键信息,更新 LogStats 结构体中的统计数据:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
// ProcessLine 处理日志行
func (ls *LogStats) ProcessLine(line string) {
    matches := logPattern.FindStringSubmatch(line)
    if matches == nil {
       return //  如果日志行不匹配正则,则忽略
    }

    fields := make(map[string]string)
    for i, name := range logPattern.SubexpNames() {
       if i != 0 && name != "" {
          fields[name] = matches[i] //  将正则匹配到的内容存储到 fields map 中
       }
    }

    ls.IPs[fields["ip"]]++      //  统计 IP 地址
    ls.Status[fields["status"]]++  //  统计 HTTP 状态码
    ls.Paths[fields["uri"]]++     //  统计请求路径
    ls.Lines = append(ls.Lines, line) //  存储原始日志行
}

该函数首先使用 logPattern.FindStringSubmatch(line) 尝试匹配日志行,如果匹配失败则直接返回。匹配成功后,将匹配到的内容存储到 fields map 中,并根据字段名更新 LogStats 结构体中的 IPs、 Status 和 Paths 统计数据,同时将原始日志行添加到 Lines 切片中

交互式设计

1
2
3
4
5
请选择您的操作(输入数字:
1. 输出 Top 10 IPs, Status Codes, and Paths
2. 根据 keyword 过滤日志行
3. 根据 时间 过滤日志行
4. 退出程序

通过简单的菜单驱动,将常用操作封装为原子功能,提升用户体验。

完整代码

  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
package main

import (
	"bufio"
	"fmt"
	"os"
	"regexp"
	"sort"
	"strings"
	"time"
)

/**
 * @Author: 南宫乘风
 * @Description:
 * @File:  main.go
 * @Email: 1794748404@qq.com
 * @Date: 2025-02-17 14:15
 */

// 正则匹配格式
var logPattern = regexp.MustCompile(`(?P<ip>\S+) (?P<host>\S+) (?P<uri>\S+) \[(?P<time>.*?)\]  "(?P<user>.*?)" "(?P<scheme>.*?)" "(?P<request>[^"]+)" "(?P<status>\d+)"`)

// 时间格式
const timeLayout = "02/Jan/2006:15:04:05 -0700" // 加入时区解析

// LogStats 日志统计结构体
type LogStats struct {
	IPs    map[string]int
	Status map[string]int
	Paths  map[string]int
	Lines  []string
}

// ProcessLine 处理日志行
func (ls *LogStats) ProcessLine(line string) {
	matches := logPattern.FindStringSubmatch(line)
	if matches == nil {
		return
	}

	fields := make(map[string]string)
	for i, name := range logPattern.SubexpNames() {
		if i != 0 && name != "" {
			fields[name] = matches[i]
		}
	}

	ls.IPs[fields["ip"]]++
	ls.Status[fields["status"]]++
	ls.Paths[fields["uri"]]++
	ls.Lines = append(ls.Lines, line)
}

// printTopN 统计数据
func printTopN(data map[string]int, n int) {
	type kv struct {
		Key   string
		Value int
	}

	var sortedData []kv
	for k, v := range data {
		sortedData = append(sortedData, kv{k, v})
	}

	sort.Slice(sortedData, func(i, j int) bool {
		return sortedData[i].Value > sortedData[j].Value
	})

	for i, item := range sortedData {
		if i >= n {
			break
		}
		fmt.Printf("%s: %d\n", item.Key, item.Value)
	}
}

// PrintTopN 打印统计数据
func (ls *LogStats) PrintTopN(n int) {
	fmt.Println("Top IPs:")
	printTopN(ls.IPs, n)
	fmt.Println("\nTop Status Codes:")
	printTopN(ls.Status, n)
	fmt.Println("\nTop Paths:")
	printTopN(ls.Paths, n)
}

// FilterLines 根据关键字过滤日志
func (ls *LogStats) FilterLines(keyword string) {
	fmt.Println("Filtered Lines:")
	for _, line := range ls.Lines {
		if strings.Contains(line, keyword) {
			fmt.Println(line)
		}
	}
}

// FilterByTime 根据时间过滤日志
func (ls *LogStats) FilterByTime(start, end string) {
	fmt.Println("按时间筛选日志:")
	startTime, err1 := time.Parse(timeLayout, start)
	endTime, err2 := time.Parse(timeLayout, end)

	if err1 != nil || err2 != nil {
		fmt.Println("Invalid time format. Use: 02/Jan/2006:15:04:05 -0700")
		return
	}

	for _, line := range ls.Lines {
		matches := logPattern.FindStringSubmatch(line)
		if matches == nil {
			continue
		}

		logTime, err := time.Parse(timeLayout, matches[4]) // 直接解析日志中的完整时间戳
		if err != nil {
			continue
		}

		if (logTime.Equal(startTime) || logTime.After(startTime)) && logTime.Before(endTime) {
			fmt.Println(line)
		}
	}
}

// NewLogStats 创建新的 LogStats 实例
func NewLogStats() *LogStats {
	return &LogStats{
		IPs:    make(map[string]int),
		Status: make(map[string]int),
		Paths:  make(map[string]int),
		Lines:  []string{},
	}
}

// main 函数
func main() {
	// 检查参数
	if len(os.Args) < 2 {
		fmt.Println("Usage: log-analyzer <logfile>")
		os.Exit(1)
	}
	// 打开文件
	file, err := os.Open(os.Args[1])
	if err != nil {
		fmt.Println("Error opening file:", err)
		os.Exit(1)
	}
	defer file.Close()
	// 创建 LogStats 实例
	stats := NewLogStats()
	// 读取文件
	scanner := bufio.NewScanner(file)
	for scanner.Scan() {
		stats.ProcessLine(scanner.Text())
	}

	if err := scanner.Err(); err != nil {
		fmt.Println("Error reading file:", err)
	}
	// 主循环
	for {
		fmt.Println("\n请选择您的操作(输入数字:")
		fmt.Println("1. 输出 Top 10 IPs, Status Codes, and Paths")
		fmt.Println("2. 根据 keyword 过滤日志行")
		fmt.Println("3. 根据 时间 过滤日志行")
		fmt.Println("4. 退出程序")
		fmt.Print("请输入您的选择: ")
		// 读取用户输入
		var choice int
		fmt.Scanln(&choice)
		// 根据选择执行操作
		switch choice {
		case 1:
			stats.PrintTopN(10)
		case 2:
			fmt.Print("您输入的 keyword 是: ")
			var keyword string
			fmt.Scanln(&keyword)
			stats.FilterLines(keyword)
		case 3:
			reader := bufio.NewReader(os.Stdin)
			fmt.Print("Enter start time (format: 29/Jan/2025:12:58:00 +0800): ")
			startTime, _ := reader.ReadString('\n')
			startTime = strings.TrimSpace(startTime) // 去除换行符
			fmt.Print("Enter end time (format: 29/Jan/2025:12:59:00 +0800): ")
			var endTime string
			endTime, _ = reader.ReadString('\n')
			endTime = strings.TrimSpace(endTime) // 去除换行符
			stats.FilterByTime(startTime, endTime)
		case 4:
			fmt.Println("退出...")
			return
		default:
			fmt.Println("无效的选择, 请重新输入")
		}
	}
}

测试

1
2
使用方法
./二进制文件   日志文件

在这里插入图片描述

Top显示

在这里插入图片描述 关键字 在这里插入图片描述 时间过滤 在这里插入图片描述

总结

log-analyzer 是一个轻量级、实用的日志分析工具,它以简洁的代码实现了核心的日志分析功能,能够有效提升开发者和测试人员的日常工作效率。后续根据公司需求添加更多的功能