Ответ
Чтение последних строк из файла — частая задача, особенно при работе с логами. Существует два основных подхода: неэффективный, но простой, и эффективный, но более сложный.
1. Неэффективный подход: Чтение всего файла
Этот метод заключается в чтении всего файла построчно и хранении последних N строк в памяти. Он прост в реализации, но потребляет много памяти и времени для больших файлов.
// Подходит только для небольших файлов!
func readLastLinesNaive(filename string, n int) ([]string, error) {
file, err := os.Open(filename)
if err != nil {
return nil, err
}
defer file.Close()
scanner := bufio.NewScanner(file)
var lines []string
for scanner.Scan() {
lines = append(lines, scanner.Text())
// Когда буфер превышает N, удаляем самый старый элемент
if len(lines) > n {
lines = lines[1:]
}
}
return lines, scanner.Err()
}
2. Эффективный подход: Чтение с конца файла (Seek
)
Этот метод является предпочтительным для больших файлов. Идея состоит в том, чтобы переместить курсор в конец файла (os.SeekEnd
) и читать файл небольшими блоками в обратном направлении, подсчитывая символы новой строки (n
).
import (
"bufio"
"bytes"
"io"
"os"
)
// Эффективно читает последние N строк
func readLastLines(filename string, n int) ([]string, error) {
file, err := os.Open(filename)
if err != nil {
return nil, err
}
defer file.Close()
// Получаем информацию о файле, чтобы узнать его размер
stat, err := file.Stat()
if err != nil {
return nil, err
}
lines := make([]string, 0, n)
var lineCount int
var buffer bytes.Buffer
// Читаем файл с конца блоками по 1024 байта
const chunkSize = 1024
for offset := stat.Size(); offset > 0 && lineCount < n; {
readSize := int64(chunkSize)
if offset < readSize {
readSize = offset
}
offset -= readSize
chunk := make([]byte, readSize)
_, err := file.ReadAt(chunk, offset)
if err != nil && err != io.EOF {
return nil, err
}
// Ищем переносы строк в прочитанном блоке
for i := len(chunk) - 1; i >= 0; i-- {
if chunk[i] == 'n' {
// Переворачиваем буфер, чтобы получить строку в правильном порядке
lineBytes := buffer.Bytes()
for j, k := 0, len(lineBytes)-1; j < k; j, k = j+1, k-1 {
lineBytes[j], lineBytes[k] = lineBytes[k], lineBytes[j]
}
lines = append(lines, string(lineBytes))
buffer.Reset()
lineCount++
if lineCount >= n {
break
}
} else {
buffer.WriteByte(chunk[i])
}
}
}
// Не забываем про самую первую строку файла, если она попала в буфер
if lineCount < n && buffer.Len() > 0 {
lineBytes := buffer.Bytes()
for j, k := 0, len(lineBytes)-1; j < k; j, k = j+1, k-1 {
lineBytes[j], lineBytes[k] = lineBytes[k], lineBytes[j]
}
lines = append(lines, string(lineBytes))
}
// Переворачиваем итоговый срез, чтобы строки были в хронологическом порядке
for i, j := 0, len(lines)-1; i < j; i, j = i+1, j-1 {
lines[i], lines[j] = lines[j], lines[i]
}
return lines, nil
}
Вывод: Для производственного кода всегда используйте второй, эффективный подход, так как он не зависит от размера файла и имеет предсказуемое потребление памяти.