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  }