github.com/Aoi-hosizora/ahlib-more@v1.5.1-0.20230404072844-256112befaf6/xrotation/xrotation.go (about) 1 package xrotation 2 3 import ( 4 "errors" 5 "fmt" 6 "github.com/Aoi-hosizora/ahlib/xerror" 7 "github.com/Aoi-hosizora/ahlib/xtime" 8 "io" 9 "log" 10 "os" 11 "path/filepath" 12 "sort" 13 "strings" 14 "sync" 15 "time" 16 ) 17 18 // loggerOptions is a type of RotationLogger's option, each field can be set by Option function type. 19 type loggerOptions struct { 20 symlinkFilename string 21 nowClock xtime.Clock 22 forceNewFile bool 23 24 rotationTime time.Duration 25 rotationSize int64 26 rotationMaxAge time.Duration 27 rotationMaxCount int32 28 } 29 30 // Option represents an option type for RotationLogger's option, can be created by WithXXX functions. 31 type Option func(*loggerOptions) 32 33 // WithSymlinkFilename creates an Option to specify symlink filename for RotationLogger, defaults to empty, and means not to create symlink. 34 func WithSymlinkFilename(f string) Option { 35 return func(o *loggerOptions) { 36 o.symlinkFilename = f 37 } 38 } 39 40 // WithClock creates an Option to specify a xtime.Clock for RotationLogger, defaults to xtime.Local. 41 func WithClock(c xtime.Clock) Option { 42 return func(o *loggerOptions) { 43 o.nowClock = c 44 } 45 } 46 47 // WithForceNewFile creates an Option to let RotationLogger create a new file when write initially, defaults to false. 48 func WithForceNewFile(b bool) Option { 49 return func(o *loggerOptions) { 50 o.forceNewFile = b 51 } 52 } 53 54 // WithRotationTime creates an Option to specify a rotation time for RotationLogger, defaults to 24 hours. 55 func WithRotationTime(t time.Duration) Option { 56 return func(o *loggerOptions) { 57 if t < 0 { 58 t = 0 59 } 60 o.rotationTime = t 61 } 62 } 63 64 // WithRotationSize creates an Option to specify a rotation size for RotationLogger, defaults to no limit. 65 func WithRotationSize(size int64) Option { 66 return func(o *loggerOptions) { 67 if size < 0 { 68 size = 0 69 } 70 o.rotationSize = size 71 } 72 } 73 74 // WithRotationMaxAge creates an Option to specify rotation loggers' max age for RotationLogger, defaults to 7 days if maxCount is not set. 75 // Note that maxAge and maxCount cannot be set at the same time. 76 func WithRotationMaxAge(age time.Duration) Option { 77 return func(o *loggerOptions) { 78 if age < 0 { 79 age = 0 80 } 81 o.rotationMaxAge = age 82 } 83 } 84 85 // WithRotationMaxCount creates an Option to specify rotation loggers' max count for RotationLogger, defaults to no limits, and it cannot less 86 // than one. Note that maxAge and maxCount cannot be set at the same time. 87 func WithRotationMaxCount(count int32) Option { 88 return func(o *loggerOptions) { 89 if count < 0 { 90 count = 0 91 } 92 o.rotationMaxCount = count 93 } 94 } 95 96 // RotationLogger represents a rotation logger, which will gets automatically rotated when new file created. Some codes and interfaces are referred 97 // from https://github.com/lestrrat-go/file-rotatelogs. 98 type RotationLogger struct { 99 option *loggerOptions 100 namePattern string 101 globPattern string 102 103 mu sync.RWMutex 104 currFile *os.File 105 currBasename string 106 currGeneration uint32 107 currFilename string 108 } 109 110 var _ io.WriteCloser = (*RotationLogger)(nil) 111 112 var ( 113 errEmptyFilenamePattern = errors.New("xrotation: empty filename pattern is not allowed") 114 errRotationMaxAgeMaxCount = errors.New("xrotation: rotation max age and max count can not be set at the same time") 115 ) 116 117 const ( 118 errInvalidFilenamePattern = "xrotation: filename pattern `%s` is invalid: %w" 119 ) 120 121 // New creates a RotationLogger with given filename pattern (in C-style / strftime) and Option-s, returns error if you give invalid options. 122 // 123 // Example: 124 // rl, err := New( 125 // "console.%Y%m%d.log", 126 // WithSymlinkFilename("console.current.log"), 127 // WithClock(xtime.UTC), 128 // WithForceNewFile(false), 129 // WithRotationSize(20*1024*1024), // 20M 130 // WithRotationTime(24*time.Hour), // 1d 131 // WithRotationMaxAge(7*24*time.Hour), // 7d 132 // ) 133 func New(pattern string, options ...Option) (*RotationLogger, error) { 134 opt := &loggerOptions{} 135 for _, o := range options { 136 if o != nil { 137 o(opt) 138 } 139 } 140 if opt.nowClock == nil { 141 opt.nowClock = xtime.Local 142 } 143 if opt.rotationTime == 0 { 144 opt.rotationTime = 24 * time.Hour 145 } 146 147 // check options 148 if pattern == "" { 149 return nil, errEmptyFilenamePattern 150 } 151 if opt.rotationMaxAge > 0 && opt.rotationMaxCount > 0 { 152 return nil, errRotationMaxAgeMaxCount 153 } 154 if opt.rotationMaxAge == 0 && opt.rotationMaxCount == 0 { 155 opt.rotationMaxAge = 7 * 24 * time.Hour 156 } 157 158 // check filename pattern 159 _, err := xtime.StrftimeInString(pattern, time.Now()) 160 if err != nil { 161 return nil, fmt.Errorf(errInvalidFilenamePattern, pattern, err) 162 } 163 globPattern := xtime.StrftimeToGlobPattern(pattern) 164 _, err = filepath.Match(globPattern, "") 165 if err != nil { 166 return nil, fmt.Errorf(errInvalidFilenamePattern, pattern, err) 167 } 168 169 logger := &RotationLogger{option: opt, namePattern: pattern, globPattern: globPattern} 170 return logger, nil 171 } 172 173 // Write implements the io.Writer interface, it writes given bytes to file, and does rotation when a new file is created. 174 func (r *RotationLogger) Write(p []byte) (n int, err error) { 175 r.mu.Lock() 176 defer r.mu.Unlock() 177 writer, err := r.getRotatedWriter(false) // in some cases, it is no need to do rotation 178 if err != nil { 179 return 0, err 180 } 181 return writer.Write(p) 182 } 183 184 // Rotate rotates the logger files first manually, returns error when new file is unavailable to get, or rotate failed. 185 func (r *RotationLogger) Rotate() error { 186 r.mu.Lock() 187 defer r.mu.Unlock() 188 _, err := r.getRotatedWriter(true) // rotation will be done in all cases 189 return err 190 } 191 192 // CurrentFilename returns the current file name that the RotationLogger is writing to. 193 func (r *RotationLogger) CurrentFilename() string { 194 r.mu.RLock() 195 defer r.mu.RUnlock() 196 return r.currFilename 197 } 198 199 // Close implements the io.Closer interface, it closes the opened file, you can also call Write later because the closed file will be opened again. 200 func (r *RotationLogger) Close() error { 201 r.mu.Lock() 202 defer r.mu.Unlock() 203 if r.currFile == nil { 204 return nil 205 } 206 _ = r.currFile.Close() 207 208 // initialize all the states 209 r.currFile = nil 210 r.currBasename = "" 211 r.currGeneration = 0 212 r.currFilename = "" 213 return nil 214 } 215 216 // =================== 217 // core implementation 218 // =================== 219 220 // These unexported variables are only used for testing. 221 var ( 222 _t_testHookMkdir func() 223 _t_testHookSymlink [3]func() string 224 ) 225 226 const ( 227 errCreateDirectory = "xrotation: failed to create directory `%s`: %w" 228 errOpenOrCreateFile = "xrotation: failed to open or create file `%s`: %w" 229 warnCreateSymlink = "xrotation warning: failed to create symlink for `%s`: %v" 230 warnDoRotation = "xrotation warning: failed to rotate: [%v]" 231 errDoRotation = "xrotation: failed to rotate: [%w]" 232 ) 233 234 // getRotatedWriter does: check whether it needs to create new file, create a unique-filename file, generate symlink and do rotation. 235 func (r *RotationLogger) getRotatedWriter(rotateManually bool) (io.Writer, error) { 236 // check whether it needs to create new file 237 createNewFile := false 238 generation := r.currGeneration 239 basename, _ := xtime.StrftimeInString(r.namePattern, xtime.TruncateTime(r.option.nowClock.Now(), r.option.rotationTime)) 240 if r.currFilename == "" { // invoke initially 241 fi, err := os.Stat(basename) 242 if existed := !os.IsNotExist(err); !existed || r.option.forceNewFile || (r.option.rotationSize > 0 && fi.Size() >= r.option.rotationSize) { 243 createNewFile = true // 4. 244 if existed { 245 generation = 1 246 } else { 247 generation = 0 248 } 249 } else { 250 createNewFile = false // 3. 251 } 252 } else if basename != r.currBasename { // new basename 253 createNewFile = true // 2. 254 generation = 0 255 } else { // check whether file exceeds rotation size 256 fi, err := os.Stat(r.currFilename) 257 if err == nil && r.option.rotationSize > 0 && fi.Size() >= r.option.rotationSize { 258 createNewFile = true // 2. 259 generation++ 260 } 261 } 262 263 // cases the following code deals with: 264 // 1.1. !createNewFile && currFile != nil && !rotateManually => return directly (happens in most cases) 265 // 1.2. !createNewFile && currFile != nil && rotateManually => close the file, open it again, check symlink and do rotate (happens when calling Rotate()) 266 // 2. createNewFile && currFile != nil => create a new file with basename or basename_x (happens when rotation basename changes or file exceeds rotation size) 267 // 3. !createNewFile && currFile == nil => open the old file with basename (happens when the first time call this method, with file exists) 268 // 4. createNewFile && currFile == nil => same with 2 (happens when the first time call this method, with file not exists, or forceNewFile, or file size exceeds) 269 filename := basename 270 if !createNewFile && r.currFile != nil { 271 if !rotateManually { 272 // also don't check symlink and do rotation 273 return r.currFile, nil 274 } 275 filename = r.currFilename 276 // close first, later it will be reopened 277 _ = r.currFile.Close() 278 r.currFile = nil 279 } 280 281 // generate a non-conflict filename 282 if createNewFile { 283 var tempName string 284 for ; ; generation++ { 285 if generation == 0 { 286 tempName = filename 287 } else { 288 tempName = fmt.Sprintf("%s_%d", filename, generation) // xxx, xxx_1, xxx_2, ... 289 } 290 if _, err := os.Stat(tempName); os.IsNotExist(err) { 291 filename = tempName 292 break 293 } 294 } 295 } 296 297 // open or create the file 298 if createNewFile { 299 dirname := filepath.Dir(filename) 300 if _, err := os.Stat(dirname); os.IsNotExist(err) { 301 if _t_testHookMkdir != nil { // only used when testing 302 _t_testHookMkdir() 303 } 304 err := os.MkdirAll(dirname, 0755) // drwxr-xr-x 305 if err != nil { 306 return nil, fmt.Errorf(errCreateDirectory, dirname, err) 307 } 308 } 309 } 310 file, err := os.OpenFile(filename, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0644) // -rwxr--r-- 311 if err != nil { 312 return nil, fmt.Errorf(errOpenOrCreateFile, filename, err) 313 } 314 315 // generate a symlink and do rotation 316 if r.option.symlinkFilename != "" { 317 // only when need to create symlink links to current filename 318 err := createSymlink(filename, r.option.symlinkFilename) 319 if err != nil { 320 // Windows: "A required privilege is not held by the client" 321 log.Printf(warnCreateSymlink, filename, err) // ignore symlink error 322 } 323 } 324 if createNewFile || rotateManually { 325 // only when need to create a new file or rotate manually 326 err := doRotation(r.globPattern, r.option.nowClock.Now(), r.option.rotationMaxAge, r.option.rotationMaxCount) // errors returned from os.Remove 327 if err != nil { 328 if !rotateManually { 329 log.Printf(warnDoRotation, err) // ignore rotation error 330 } else { 331 _ = file.Close() 332 return nil, fmt.Errorf(errDoRotation, err) 333 } 334 } 335 } 336 337 if r.currFile != nil { 338 _ = r.currFile.Close() 339 } 340 r.currFile = file 341 r.currGeneration = generation 342 r.currBasename = basename 343 r.currFilename = filename 344 return file, nil 345 } 346 347 // createSymlink creates a symlink file `linkname` and its destination is `filename`. 348 func createSymlink(filename, linkname string) error { 349 // create target link file directory 350 linkDirname := filepath.Dir(linkname) 351 if _, err := os.Stat(linkDirname); os.IsNotExist(err) { 352 if _t_testHookSymlink[0] != nil { // only used when testing 353 _t_testHookSymlink[0]() 354 } 355 err := os.MkdirAll(linkDirname, 0755) 356 if err != nil { 357 // hint: no need for "xrotation: " prefix 358 return fmt.Errorf("failed to create directory `%s`: %w", linkDirname, err) 359 } 360 } 361 362 // check the relative path of destination 363 destinationPath, _ := filepath.Abs(filename) 364 linkDirnamePath, _ := filepath.Abs(linkDirname) 365 if _t_testHookSymlink[1] != nil { 366 linkDirnamePath = _t_testHookSymlink[1]() 367 } 368 destination, err := filepath.Rel(linkDirnamePath, destinationPath) 369 if err != nil { 370 return fmt.Errorf("failed to evaluate the relative path from `%s` to `%s`: %w", destinationPath, linkDirnamePath, err) 371 } 372 373 // make symlink and rename to the link file 374 tempLinkname := filename + "_symlink" 375 if _, err := os.Stat(tempLinkname); err == nil { 376 _ = os.Remove(tempLinkname) 377 } 378 if _t_testHookSymlink[2] != nil { 379 _t_testHookSymlink[2]() 380 } 381 err = os.Symlink(destination, tempLinkname) 382 if err != nil { 383 return fmt.Errorf("failed to create symlink `%s`: %w", tempLinkname, err) 384 } 385 err = os.Rename(tempLinkname, linkname) 386 if err != nil { 387 return fmt.Errorf("failed to rename symlink `%s` to `%s`: %w", tempLinkname, linkname, err) 388 } 389 return nil 390 } 391 392 // doRotation does the real rotation work, this will rotate for loggers' max age or for loggers' max count, and remove all unlinked files. 393 func doRotation(globPattern string, now time.Time, maxAge time.Duration, maxCount int32) error { 394 // get matches by glob pattern 395 matches, _ := filepath.Glob(globPattern) // error is always nil if in safe manner, here ignore it 396 unlinkFiles := make([]string, 0) 397 398 // I) rotate for max age 399 if maxAge > 0 { 400 cutoffDuration := now.Add(-1 * maxAge) 401 for _, match := range matches { 402 fi, err := os.Lstat(match) 403 if err != nil || (fi.Mode()&os.ModeSymlink) == os.ModeSymlink { 404 continue 405 } 406 if fi.ModTime().Before(cutoffDuration) { 407 unlinkFiles = append(unlinkFiles, match) 408 } 409 } 410 } 411 412 // II) rotate for max count 413 if count := int(maxCount); count > 0 { 414 type nameTimeTuple struct { 415 name string 416 mod time.Time 417 } 418 pairs := make([]nameTimeTuple, 0, len(matches)) 419 for _, match := range matches { 420 fi, err := os.Lstat(match) 421 if err != nil || (fi.Mode()&os.ModeSymlink) == os.ModeSymlink { 422 continue 423 } 424 pairs = append(pairs, nameTimeTuple{match, fi.ModTime()}) 425 } 426 if len(pairs) > count { 427 sort.Slice(pairs, func(i, j int) bool { return pairs[i].mod.Before(pairs[j].mod) }) 428 for _, fi := range pairs[:len(pairs)-count] { 429 unlinkFiles = append(unlinkFiles, fi.name) 430 } 431 } 432 } 433 434 // expand unlinkFiles for file with "xxx_*" name 435 if len(unlinkFiles) == 0 { 436 return nil 437 } 438 moreMatches, _ := filepath.Glob(globPattern + "_*") // also ignore error 439 if len(moreMatches) > 0 { 440 more := make([]string, 0) 441 for _, match := range moreMatches { 442 for _, path := range unlinkFiles { 443 if strings.HasPrefix(match, path) { 444 more = append(more, match) 445 } 446 } 447 } 448 unlinkFiles = append(unlinkFiles, more...) 449 } 450 451 // remove unlinked files 452 errs := make([]error, 0) 453 for _, path := range unlinkFiles { 454 err := os.Remove(path) 455 if err != nil { 456 errs = append(errs, err) 457 } 458 } 459 if len(errs) == 0 { 460 return nil 461 } 462 return xerror.Combine(errs...) 463 }