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  }