github.com/benz9527/xboot@v0.0.0-20240504061247-c23f15593274/xlog/rotate_log.go (about) 1 package xlog 2 3 import ( 4 // "archive/zip" 5 "context" 6 "fmt" 7 "io" 8 "io/fs" 9 "os" 10 "path/filepath" 11 "regexp" 12 "sort" 13 "strconv" 14 "strings" 15 "sync" 16 "sync/atomic" 17 "time" 18 19 "github.com/fsnotify/fsnotify" 20 "github.com/google/safearchive/zip" 21 "github.com/google/safeopen" 22 "go.uber.org/multierr" 23 24 "github.com/benz9527/xboot/lib/infra" 25 ) 26 27 type fileSizeUnit uint64 28 29 const ( 30 B fileSizeUnit = 1 << (10 * iota) 31 KB 32 MB 33 _maxSize = 1024 * MB 34 ) 35 36 type fileAgeUnit int64 37 38 const ( 39 backupDateTimeFormat = "2006_01_02T15_04_05.999999999_Z07_00" 40 Second fileAgeUnit = fileAgeUnit(time.Duration(1 * time.Second)) 41 Minute fileAgeUnit = fileAgeUnit(time.Duration(1 * time.Minute)) 42 Hour fileAgeUnit = fileAgeUnit(time.Duration(1 * time.Hour)) 43 Day fileAgeUnit = fileAgeUnit(time.Duration(1 * time.Hour * 24)) 44 _maxFileAge = 2 * 7 * Day 45 ) 46 47 var ( 48 fileSizeRegexp = regexp.MustCompile(`^(\d+)(([kK]|[mM])?[bB])$`) 49 fileAgeRegexp = regexp.MustCompile(`^(\d+)(s|[sS]ec|[mM]in|[hH](our[s]?)?|[dD](ay[s]?)?)$`) 50 ) 51 52 func parseFileSize(size string) (uint64, error) { 53 res := fileSizeRegexp.FindAllStringSubmatch(size, -1) 54 if res == nil || len(res) <= 0 || len(res[0]) < 3 || res[0][0] != size { 55 return 0, infra.NewErrorStack("invalid file size unit") 56 } 57 var unit fileSizeUnit 58 switch strings.ToUpper(res[0][2]) { 59 case "B": 60 unit = B 61 case "KB": 62 unit = KB 63 case "MB": 64 unit = MB 65 } 66 _size, _ := strconv.ParseUint(res[0][1], 10, 64) 67 return _size * uint64(unit), nil 68 } 69 70 func parseFileAge(age string) (time.Duration, error) { 71 res := fileAgeRegexp.FindAllStringSubmatch(age, -1) 72 if res == nil || len(res) <= 0 || len(res[0]) < 3 || res[0][0] != age { 73 return 0, infra.NewErrorStack("invalid file age unit") 74 } 75 var unit fileAgeUnit 76 switch strings.ToUpper(res[0][2]) { 77 case "S", "SEC": 78 unit = Second 79 case "M", "MIN": 80 unit = Minute 81 case "H", "HOUR", "HOURS": 82 unit = Hour 83 case "D", "DAY", "DAYS": 84 unit = Day 85 } 86 num, _ := strconv.ParseInt(res[0][1], 10, 64) 87 _age := time.Duration(num) * time.Duration(unit) 88 if _age >= time.Duration(_maxFileAge) { 89 _age = time.Duration(_maxFileAge) 90 } 91 return _age, nil 92 } 93 94 var _ io.WriteCloser = (*rotateLog)(nil) 95 96 type rotateLog struct { 97 ctx context.Context 98 filePath string 99 filename string 100 fileMaxSize string 101 fileMaxAge string 102 fileZipName string 103 maxSize uint64 104 wroteSize uint64 105 mkdirOnce sync.Once 106 currentFile atomic.Pointer[os.File] 107 fileWatcher atomic.Pointer[fsnotify.Watcher] 108 fileMaxBackups int 109 fileCompressBatch int 110 fileCompressible bool 111 } 112 113 func (log *rotateLog) Write(p []byte) (n int, err error) { 114 select { 115 case <-log.ctx.Done(): 116 return 0, io.EOF 117 default: 118 } 119 120 if log.currentFile.Load() == nil { 121 if err := log.openOrCreate(); err != nil { 122 return 0, err 123 } 124 } 125 logLen := uint64(len(p)) 126 if log.wroteSize+logLen > log.maxSize { 127 n, err = log.currentFile.Load().Write(p) 128 if err != nil { 129 return 130 } 131 if err = log.backupThenCreate(); err != nil { 132 return 133 } 134 return 135 } 136 137 n, err = log.currentFile.Load().Write(p) 138 log.wroteSize += uint64(n) 139 return 140 } 141 142 func (log *rotateLog) Close() error { 143 if log.currentFile.Load() == nil { 144 return nil 145 } 146 if err := log.currentFile.Load().Close(); err != nil { 147 return err 148 } 149 log.currentFile.Store(nil) 150 return nil 151 } 152 153 func (log *rotateLog) initialize() error { 154 if log.fileWatcher.Load() != nil { 155 return nil 156 } 157 158 size, err := parseFileSize(log.fileMaxSize) 159 if err != nil { 160 handleRollingError(err) 161 return err 162 } 163 log.maxSize = size 164 165 if _, err = parseFileAge(log.fileMaxAge); err != nil { 166 handleRollingError(err) 167 return err 168 } 169 170 var watcher *fsnotify.Watcher 171 if watcher, err = fsnotify.NewWatcher(); err != nil { 172 handleRollingError(infra.WrapErrorStackWithMessage(err, "failed to create file watcher")) 173 return err 174 } 175 log.fileWatcher.Store(watcher) 176 177 if err = log.fileWatcher.Load().Add(log.filePath); err != nil { 178 handleRollingError(infra.WrapErrorStackWithMessage(err, "failed to add log directory to watcher")) 179 return err 180 } 181 182 go log.watchAndArchive() 183 return nil 184 } 185 186 func (log *rotateLog) mkdir() error { 187 var err error = nil 188 log.mkdirOnce.Do(func() { 189 if log.filePath == "" { 190 log.filePath = os.TempDir() 191 } 192 if log.filePath == os.TempDir() { 193 return 194 } 195 err = os.MkdirAll(log.filePath, 0o644) 196 }) 197 return infra.WrapErrorStack(err) 198 } 199 200 func (log *rotateLog) backup() error { 201 logName := log.filename 202 ext := filepath.Ext(logName) 203 logNamePrefix := strings.TrimSuffix(logName, ext) 204 now := time.Now().UTC() 205 ts := now.Format(backupDateTimeFormat) 206 pathToBackup := filepath.Join(log.filePath, logNamePrefix+"_"+ts+ext) 207 if err := log.currentFile.Load().Close(); err != nil { 208 return infra.WrapErrorStackWithMessage(err, "failed to backup current log: "+filepath.Join(log.filePath, logName)) 209 } 210 return os.Rename(filepath.Join(log.filePath, logName), pathToBackup) 211 } 212 213 func (log *rotateLog) create() error { 214 if err := log.mkdir(); err != nil { 215 return err 216 } 217 218 f, err := safeopen.OpenFileBeneath(log.filePath, log.filename, os.O_RDWR|os.O_CREATE|os.O_APPEND, 0o644) 219 if err != nil { 220 return infra.WrapErrorStackWithMessage(err, "unable to create new log file: "+filepath.Join(log.filePath, log.filename)) 221 } 222 log.currentFile.Store(f) 223 log.wroteSize = 0 224 return nil 225 } 226 227 func (log *rotateLog) backupThenCreate() error { 228 if err := log.backup(); err != nil { 229 return err 230 } 231 return log.create() 232 } 233 234 func (log *rotateLog) openOrCreate() error { 235 if err := log.mkdir(); err != nil { 236 return err 237 } 238 239 pathToLog := filepath.Join(log.filePath, log.filename) 240 info, err := os.Stat(pathToLog) 241 if os.IsNotExist(err) { 242 var merr error 243 merr = multierr.Append(merr, err) 244 if err = log.create(); err != nil { 245 return multierr.Append(merr, err) 246 } 247 return log.initialize() 248 } else if err != nil { 249 log.currentFile.Store(nil) 250 return infra.WrapErrorStack(err) 251 } 252 253 if info.IsDir() { 254 log.currentFile.Store(nil) 255 return infra.NewErrorStack("log file <" + pathToLog + "> is a dir") 256 } 257 258 var f *os.File 259 if f, err = safeopen.OpenFileBeneath(log.filePath, log.filename, os.O_WRONLY|os.O_APPEND, 0o644); err != nil { 260 var merr error = infra.WrapErrorStackWithMessage(err, "unable to access log file: "+pathToLog) 261 if err = log.backupThenCreate(); err != nil { 262 return infra.WrapErrorStackWithMessage(multierr.Combine(merr, err), "failed to backup then open new log file: "+pathToLog) 263 } 264 } 265 log.currentFile.Store(f) 266 log.wroteSize = uint64(info.Size()) 267 return log.initialize() 268 } 269 270 // Watch the log directory and filter the match log files then archive 271 // or delete the expired. Endless until the rotate log is closed. 272 func (log *rotateLog) watchAndArchive() { 273 ext := filepath.Ext(log.filename) 274 logName := log.filename[:len(log.filename)-len(ext)] 275 duration, _ := parseFileAge(log.fileMaxAge) 276 for { 277 select { 278 case <-log.ctx.Done(): 279 _ = log.Close() 280 handleRollingError(log.fileWatcher.Load().Close()) 281 log.fileWatcher.Store(nil) 282 return 283 case event, ok := <-log.fileWatcher.Load().Events: 284 if !ok { 285 return 286 } 287 if event.Has(fsnotify.Create) { 288 // Walk through the log files and find the expired ones. 289 logInfos, err := log.loadFileInfos(logName, ext) 290 if err != nil || len(logInfos) <= 0 { 291 handleRollingError(err) 292 continue 293 } 294 now := time.Now().UTC() 295 expired, rest := filterExpiredLogs(now, logName, ext, duration, logInfos) 296 expired = filterMaxBackupLogs(expired, rest, log.fileMaxBackups) 297 if log.fileCompressible { 298 if len(expired) < log.fileCompressBatch { 299 continue 300 } 301 if err := compressExpiredLogs(log.filePath, log.fileZipName, expired); err != nil { 302 handleRollingError(err) 303 continue 304 } 305 } else { 306 for _, info := range expired { 307 filename := filepath.Base(info.Name()) 308 _ = os.Remove(filepath.Join(log.filePath, filename)) 309 } 310 } 311 } 312 case err, ok := <-log.fileWatcher.Load().Errors: 313 if !ok { 314 return 315 } 316 handleRollingError(err) 317 } 318 } 319 } 320 321 func (log *rotateLog) loadFileInfos(logName, ext string) ([]fs.FileInfo, error) { 322 // Walk through the log files and find the expired ones. 323 entries, err := os.ReadDir(log.filePath) 324 if err == nil && len(entries) > 0 { 325 logInfos := make([]os.FileInfo, 0, 16) 326 for _, entry := range entries { 327 if !entry.IsDir() { 328 filename := entry.Name() 329 if strings.HasPrefix(filename, logName) && strings.HasSuffix(filename, ext) && filename != log.filename { 330 if info, err := entry.Info(); err == nil && info != nil { 331 logInfos = append(logInfos, info) 332 } 333 } 334 } 335 } 336 return logInfos, nil 337 } 338 return nil, infra.WrapErrorStack(err) 339 } 340 341 func RotateLog(ctx context.Context, cfg *FileCoreConfig) io.WriteCloser { 342 if cfg == nil || ctx == nil { 343 return nil 344 } 345 w := &rotateLog{ 346 filename: cfg.Filename, 347 filePath: cfg.FilePath, 348 fileCompressible: cfg.FileCompressible, 349 fileCompressBatch: cfg.FileCompressBatch, 350 fileMaxAge: cfg.FileMaxAge, 351 fileZipName: cfg.FileZipName, 352 fileMaxSize: cfg.FileMaxSize, 353 fileMaxBackups: cfg.FileMaxBackups, 354 ctx: ctx, 355 } 356 if err := w.initialize(); err != nil { 357 return nil 358 } 359 return w 360 } 361 362 func filterExpiredLogs(now time.Time, logName, ext string, duration time.Duration, logInfos []fs.FileInfo) ([]fs.FileInfo, []fs.FileInfo) { 363 // Firstly, we satisfy the max age requirement. 364 expired := make([]os.FileInfo, 0, 16) 365 rest := make([]os.FileInfo, 0, 16) 366 for _, info := range logInfos { 367 filename := filepath.Base(info.Name()) 368 if !strings.HasPrefix(filename, logName) || !strings.HasSuffix(filename, ext) { 369 continue 370 } 371 ts := strings.TrimPrefix(filename, logName+"_") 372 ts = strings.TrimSuffix(ts, ext) 373 if dateTime, err := time.Parse(backupDateTimeFormat, ts); err == nil { 374 if now.Sub(dateTime) > duration { 375 expired = append(expired, info) 376 } else { 377 rest = append(rest, info) 378 } 379 } 380 } 381 return expired, rest 382 } 383 384 func handleRollingError(err error) { 385 if err != nil { 386 _, _ = fmt.Fprintf(os.Stderr, "[XLogger] rolling file occurs error: %s\n", err) 387 } 388 } 389 390 func filterMaxBackupLogs(expired, rest []fs.FileInfo, maxBackups int) []fs.FileInfo { 391 // Secondly, we satisfy the max backups requirement. 392 redundant := len(rest) - maxBackups 393 if redundant > 0 { 394 sort.Slice(rest, func(i, j int) bool { 395 // If the log file is modified manually, the sort maybe wrong! 396 return rest[i].ModTime().Before(rest[j].ModTime()) 397 }) 398 for i := 0; i < redundant; i++ { 399 expired = append(expired, rest[i]) 400 } 401 } 402 return expired 403 } 404 405 // Only one zip file will be presented. 406 func compressExpiredLogs(filePath, zipName string, expired []fs.FileInfo) error { 407 var ( 408 logZip *os.File 409 prevZip *zip.ReadCloser 410 ) 411 info, err := os.Stat(filepath.Join(filePath, zipName)) 412 if err == nil && !info.IsDir() { 413 // Exists 414 if logZip, err = safeopen.OpenFileBeneath(filePath, "xlog-tmp.zip", os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0o644); err != nil { 415 return err 416 } 417 if prevZip, err = zip.OpenReader(filepath.Join(filePath, zipName)); err != nil { 418 return err 419 } 420 } else { 421 if logZip, err = os.Create(filepath.Join(filePath, zipName)); err != nil { 422 return err 423 } 424 } 425 zipWriter := zip.NewWriter(logZip) 426 for _, info := range expired { 427 filename := filepath.Base(info.Name()) 428 file, err := safeopen.OpenBeneath(filePath, filename) 429 if err == nil { 430 if zipFile, err := zipWriter.Create(filename); err == nil { 431 if _, err = io.Copy(zipFile, file); err == nil { 432 _ = file.Close() 433 file = nil 434 if err = os.Remove(filepath.Join(filePath, filename)); err != nil { 435 handleRollingError(err) 436 } 437 } 438 } 439 if file != nil { 440 _ = file.Close() 441 } 442 } 443 } 444 // Copy previous zip content to new zip file. 445 if prevZip != nil { 446 prevZip.SetSecurityMode(prevZip.GetSecurityMode() | zip.MaximumSecurityMode) 447 for _, f := range prevZip.File { 448 oldReader, err := f.Open() 449 if err != nil || f.Mode().IsDir() { 450 if oldReader != nil { 451 _ = oldReader.Close() 452 } 453 continue 454 } 455 456 header := &zip.FileHeader{ 457 Name: f.Name, 458 Method: f.Method, 459 } 460 if zipFile, err := zipWriter.CreateHeader(header); err == nil { 461 if _, err = io.Copy(zipFile, oldReader); err == nil { 462 _ = oldReader.Close() 463 } 464 } 465 if oldReader != nil { 466 _ = oldReader.Close() 467 } 468 } 469 if err := zipWriter.Flush(); err != nil { 470 return err 471 } 472 } 473 _ = zipWriter.Close() 474 zipWriter = nil 475 _ = logZip.Close() 476 if prevZip != nil { 477 _ = prevZip.Close() 478 if err = os.Remove(filepath.Join(filePath, zipName)); err != nil { 479 handleRollingError(err) 480 } 481 if err := os.Rename(filepath.Join(filePath, "xlog-tmp.zip"), filepath.Join(filePath, zipName)); err != nil { 482 handleRollingError(err) 483 } 484 } 485 return nil 486 }