trpc.group/trpc-go/trpc-go@v1.0.3/log/rollwriter/roll_writer.go (about) 1 // 2 // 3 // Tencent is pleased to support the open source community by making tRPC available. 4 // 5 // Copyright (C) 2023 THL A29 Limited, a Tencent company. 6 // All rights reserved. 7 // 8 // If you have downloaded a copy of the tRPC source code from Tencent, 9 // please note that tRPC source code is licensed under the Apache 2.0 License, 10 // A copy of the Apache 2.0 License is included in this file. 11 // 12 // 13 14 // Package rollwriter provides a high performance rolling file log. 15 // Package rollwriter does not print logs, but implements io.Writer. 16 // It can coordinate with any logs which depends on io.Writer, such as golang standard log. 17 // Main features: 18 // 1. support rolling logs by file size. 19 // 2. support rolling logs by datetime. 20 // 3. support scavenging expired or useless logs. 21 // 4. support compressing logs. 22 package rollwriter 23 24 import ( 25 "compress/gzip" 26 "errors" 27 "fmt" 28 "io" 29 "io/fs" 30 "os" 31 "path/filepath" 32 "sort" 33 "strings" 34 "sync" 35 "sync/atomic" 36 "time" 37 38 "github.com/lestrrat-go/strftime" 39 ) 40 41 const ( 42 backupTimeFormat = "bk-20060102-150405.00000" 43 compressSuffix = ".gz" 44 ) 45 46 // Ensure we always implement io.WriteCloser. 47 var _ io.WriteCloser = (*RollWriter)(nil) 48 49 // RollWriter is a file log writer which support rolling by size or datetime. 50 // It implements io.WriteCloser. 51 type RollWriter struct { 52 filePath string 53 opts *Options 54 55 pattern *strftime.Strftime 56 currDir string 57 currPath string 58 currSize int64 59 currFile atomic.Value 60 openTime int64 61 62 mu sync.Mutex 63 notifyOnce sync.Once 64 notifyCh chan bool 65 closeOnce sync.Once 66 closeCh chan *closeAndRenameFile 67 68 os customizedOS 69 } 70 71 // NewRollWriter creates a new RollWriter. 72 func NewRollWriter(filePath string, opt ...Option) (*RollWriter, error) { 73 opts := &Options{ 74 MaxSize: 0, // Default no rolling by file size. 75 MaxAge: 0, // Default no scavenging on expired logs. 76 MaxBackups: 0, // Default no scavenging on redundant logs. 77 Compress: false, // Default no compressing. 78 } 79 80 // opt has the highest priority and should overwrite the original one. 81 for _, o := range opt { 82 o(opts) 83 } 84 85 if filePath == "" { 86 return nil, errors.New("invalid file path") 87 } 88 89 pattern, err := strftime.New(filePath + opts.TimeFormat) 90 if err != nil { 91 return nil, errors.New("invalid time pattern") 92 } 93 94 w := &RollWriter{ 95 filePath: filePath, 96 opts: opts, 97 pattern: pattern, 98 currDir: filepath.Dir(filePath), 99 os: defaultCustomizedOS, 100 } 101 102 if err := w.os.MkdirAll(w.currDir, 0755); err != nil { 103 return nil, err 104 } 105 106 return w, nil 107 } 108 109 // Write writes logs. It implements io.Writer. 110 func (w *RollWriter) Write(v []byte) (n int, err error) { 111 // Reopen file every 10 seconds. 112 if w.getCurrFile() == nil || time.Now().Unix()-atomic.LoadInt64(&w.openTime) > 10 { 113 w.mu.Lock() 114 w.reopenFile() 115 w.mu.Unlock() 116 } 117 118 // Return when failed to open the file. 119 if w.getCurrFile() == nil { 120 return 0, errors.New("open file fail") 121 } 122 123 // Write logs to file. 124 n, err = w.getCurrFile().Write(v) 125 atomic.AddInt64(&w.currSize, int64(n)) 126 127 // Rolling on full. 128 if w.opts.MaxSize > 0 && atomic.LoadInt64(&w.currSize) >= w.opts.MaxSize { 129 w.mu.Lock() 130 w.backupFile() 131 w.mu.Unlock() 132 } 133 return n, err 134 } 135 136 // Close closes the current log file. It implements io.Closer. 137 func (w *RollWriter) Close() error { 138 if w.getCurrFile() == nil { 139 return nil 140 } 141 err := w.getCurrFile().Close() 142 w.setCurrFile(nil) 143 144 if w.notifyCh != nil { 145 close(w.notifyCh) 146 w.notifyCh = nil 147 } 148 149 if w.closeCh != nil { 150 close(w.closeCh) 151 w.closeCh = nil 152 } 153 154 return err 155 } 156 157 // getCurrFile returns the current log file. 158 func (w *RollWriter) getCurrFile() *os.File { 159 if file, ok := w.currFile.Load().(*os.File); ok { 160 return file 161 } 162 return nil 163 } 164 165 // setCurrFile sets the current log file. 166 func (w *RollWriter) setCurrFile(file *os.File) { 167 w.currFile.Store(file) 168 } 169 170 // reopenFile reopens the file regularly. It notifies the scavenger if file path has changed. 171 func (w *RollWriter) reopenFile() { 172 if w.getCurrFile() == nil || time.Now().Unix()-atomic.LoadInt64(&w.openTime) > 10 { 173 atomic.StoreInt64(&w.openTime, time.Now().Unix()) 174 oldPath := w.currPath 175 currPath := w.pattern.FormatString(time.Now()) 176 if w.currPath != currPath { 177 w.currPath = currPath 178 w.notify() 179 } 180 if err := w.doReopenFile(w.currPath, oldPath); err != nil { 181 fmt.Printf("w.doReopenFile %s err: %+v\n", w.currPath, err) 182 } 183 } 184 } 185 186 // notify runs scavengers. 187 func (w *RollWriter) notify() { 188 w.notifyOnce.Do(func() { 189 w.notifyCh = make(chan bool, 1) 190 go w.runCleanFiles() 191 }) 192 select { 193 case w.notifyCh <- true: 194 default: 195 } 196 } 197 198 // runCleanFiles cleans redundant or expired (compressed) logs in a new goroutine. 199 func (w *RollWriter) runCleanFiles() { 200 for range w.notifyCh { 201 if w.opts.MaxBackups == 0 && w.opts.MaxAge == 0 && !w.opts.Compress { 202 continue 203 } 204 w.cleanFiles() 205 } 206 } 207 208 // delayCloseAndRenameFile delays closing and renaming the given file. 209 func (w *RollWriter) delayCloseAndRenameFile(f *closeAndRenameFile) { 210 w.closeOnce.Do(func() { 211 w.closeCh = make(chan *closeAndRenameFile, 100) 212 go w.runCloseFiles() 213 }) 214 w.closeCh <- f 215 } 216 217 // runCloseFiles delays closing file in a new goroutine. 218 func (w *RollWriter) runCloseFiles() { 219 for f := range w.closeCh { 220 time.Sleep(20 * time.Millisecond) 221 if err := f.file.Close(); err != nil { 222 fmt.Printf("f.file.Close err: %+v, filename: %s\n", err, f.file.Name()) 223 } 224 if f.rename == "" || f.file.Name() == f.rename { 225 continue 226 } 227 if err := w.os.Rename(f.file.Name(), f.rename); err != nil { 228 fmt.Printf("os.Rename from %s to %s err: %+v\n", f.file.Name(), f.rename, err) 229 } 230 w.notify() 231 } 232 } 233 234 // cleanFiles cleans redundant or expired (compressed) logs. 235 func (w *RollWriter) cleanFiles() { 236 // Get the file list of current log. 237 files, err := w.getOldLogFiles() 238 if err != nil { 239 fmt.Printf("w.getOldLogFiles err: %+v\n", err) 240 return 241 } 242 if len(files) == 0 { 243 return 244 } 245 246 // Find the oldest files to scavenge. 247 var compress, remove []logInfo 248 files = filterByMaxBackups(files, &remove, w.opts.MaxBackups) 249 250 // Find the expired files by last modified time. 251 files = filterByMaxAge(files, &remove, w.opts.MaxAge) 252 253 // Find files to compress by file extension .gz. 254 filterByCompressExt(files, &compress, w.opts.Compress) 255 256 // Delete expired or redundant files. 257 w.removeFiles(remove) 258 259 // Compress log files. 260 w.compressFiles(compress) 261 } 262 263 // getOldLogFiles returns the log file list ordered by modified time. 264 func (w *RollWriter) getOldLogFiles() ([]logInfo, error) { 265 entries, err := os.ReadDir(w.currDir) 266 if err != nil { 267 return nil, fmt.Errorf("can't read log file directory %s :%w", w.currDir, err) 268 } 269 270 var logFiles []logInfo 271 filename := filepath.Base(w.filePath) 272 for _, e := range entries { 273 if e.IsDir() { 274 continue 275 } 276 277 if modTime, err := w.matchLogFile(e.Name(), filename); err == nil { 278 logFiles = append(logFiles, logInfo{modTime, e}) 279 } 280 } 281 sort.Sort(byFormatTime(logFiles)) 282 return logFiles, nil 283 } 284 285 // matchLogFile checks whether current log file matches all relative log files, if matched, returns 286 // the modified time. 287 func (w *RollWriter) matchLogFile(filename, filePrefix string) (time.Time, error) { 288 // Exclude current log file. 289 // a.log 290 // a.log.20200712 291 if filepath.Base(w.currPath) == filename { 292 return time.Time{}, errors.New("ignore current logfile") 293 } 294 295 // Match all log files with current log file. 296 // a.log -> a.log.20200712-1232/a.log.20200712-1232.gz 297 // a.log.20200712 -> a.log.20200712.20200712-1232/a.log.20200712.20200712-1232.gz 298 if !strings.HasPrefix(filename, filePrefix) { 299 return time.Time{}, errors.New("mismatched prefix") 300 } 301 302 st, err := w.os.Stat(filepath.Join(w.currDir, filename)) 303 if err != nil { 304 return time.Time{}, fmt.Errorf("file stat fail: %w", err) 305 } 306 return st.ModTime(), nil 307 } 308 309 // removeFiles deletes expired or redundant log files. 310 func (w *RollWriter) removeFiles(remove []logInfo) { 311 // Clean expired or redundant files. 312 for _, f := range remove { 313 file := filepath.Join(w.currDir, f.Name()) 314 if err := w.os.Remove(file); err != nil { 315 fmt.Printf("remove file %s err: %+v\n", file, err) 316 } 317 } 318 } 319 320 // compressFiles compresses demanded log files. 321 func (w *RollWriter) compressFiles(compress []logInfo) { 322 // Compress log files. 323 for _, f := range compress { 324 fn := filepath.Join(w.currDir, f.Name()) 325 w.compressFile(fn, fn+compressSuffix) 326 } 327 } 328 329 // filterByMaxBackups filters redundant files that exceeded the limit. 330 func filterByMaxBackups(files []logInfo, remove *[]logInfo, maxBackups int) []logInfo { 331 if maxBackups == 0 || len(files) < maxBackups { 332 return files 333 } 334 var remaining []logInfo 335 preserved := make(map[string]bool) 336 for _, f := range files { 337 fn := strings.TrimSuffix(f.Name(), compressSuffix) 338 preserved[fn] = true 339 340 if len(preserved) > maxBackups { 341 *remove = append(*remove, f) 342 } else { 343 remaining = append(remaining, f) 344 } 345 } 346 return remaining 347 } 348 349 // filterByMaxAge filters expired files. 350 func filterByMaxAge(files []logInfo, remove *[]logInfo, maxAge int) []logInfo { 351 if maxAge <= 0 { 352 return files 353 } 354 var remaining []logInfo 355 diff := time.Duration(int64(24*time.Hour) * int64(maxAge)) 356 cutoff := time.Now().Add(-1 * diff) 357 for _, f := range files { 358 if f.timestamp.Before(cutoff) { 359 *remove = append(*remove, f) 360 } else { 361 remaining = append(remaining, f) 362 } 363 } 364 return remaining 365 } 366 367 // filterByCompressExt filters all compressed files. 368 func filterByCompressExt(files []logInfo, compress *[]logInfo, needCompress bool) { 369 if !needCompress { 370 return 371 } 372 for _, f := range files { 373 if !strings.HasSuffix(f.Name(), compressSuffix) { 374 *compress = append(*compress, f) 375 } 376 } 377 } 378 379 // compressFile compresses file src to dst, and removes src on success. 380 func (w *RollWriter) compressFile(src, dst string) (err error) { 381 f, err := w.os.Open(src) 382 if err != nil { 383 return fmt.Errorf("failed to open file: %v", err) 384 } 385 386 gzf, err := w.os.OpenFile(dst, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, 0666) 387 if err != nil { 388 f.Close() 389 return fmt.Errorf("failed to open compressed file: %v", err) 390 } 391 392 gz := gzip.NewWriter(gzf) 393 defer func() { 394 gz.Close() 395 // Make sure files are closed before removing, or else the removal 396 // will fail on Windows. 397 f.Close() 398 gzf.Close() 399 if err != nil { 400 w.os.Remove(dst) 401 err = fmt.Errorf("failed to compress file: %v", err) 402 return 403 } 404 w.os.Remove(src) 405 }() 406 407 if _, err := io.Copy(gz, f); err != nil { 408 return err 409 } 410 return nil 411 } 412 413 type closeAndRenameFile struct { 414 file *os.File 415 rename string 416 } 417 418 // logInfo is an assistant struct which is used to return file name and last modified time. 419 type logInfo struct { 420 timestamp time.Time 421 os.DirEntry 422 } 423 424 // byFormatTime sorts by time descending order. 425 type byFormatTime []logInfo 426 427 // Less checks whether the time of b[j] is early than the time of b[i]. 428 func (b byFormatTime) Less(i, j int) bool { 429 return b[i].timestamp.After(b[j].timestamp) 430 } 431 432 // Swap swaps b[i] and b[j]. 433 func (b byFormatTime) Swap(i, j int) { 434 b[i], b[j] = b[j], b[i] 435 } 436 437 // Len returns the length of list b. 438 func (b byFormatTime) Len() int { 439 return len(b) 440 } 441 442 var defaultCustomizedOS = stdOS{} 443 444 type stdOS struct{} 445 446 func (stdOS) Open(name string) (*os.File, error) { 447 return os.Open(name) 448 } 449 450 func (stdOS) OpenFile(name string, flag int, perm fs.FileMode) (*os.File, error) { 451 return os.OpenFile(name, flag, perm) 452 } 453 454 func (stdOS) MkdirAll(path string, perm fs.FileMode) error { 455 return os.MkdirAll(path, perm) 456 } 457 458 func (stdOS) Rename(oldpath string, newpath string) error { 459 return os.Rename(oldpath, newpath) 460 } 461 462 func (stdOS) Stat(name string) (fs.FileInfo, error) { 463 return os.Stat(name) 464 } 465 466 func (stdOS) Remove(name string) error { 467 return os.Remove(name) 468 } 469 470 type customizedOS interface { 471 Open(name string) (*os.File, error) 472 OpenFile(name string, flag int, perm fs.FileMode) (*os.File, error) 473 MkdirAll(path string, perm fs.FileMode) error 474 Rename(oldpath string, newpath string) error 475 Stat(name string) (fs.FileInfo, error) 476 Remove(name string) error 477 }